feat: improve tl performance (#11946)
* wip * wip * wip * wip * wip * wip * Update NoteCreateService.ts * wip * wip * wip * wip * Update NoteCreateService.ts * wip * Update NoteCreateService.ts * wip * Update user-notes.ts * wip * wip * wip * Update NoteCreateService.ts * wip * Update timeline.ts * Update timeline.ts * Update timeline.ts * Update timeline.ts * Update timeline.ts * wip * Update timelines.ts * Update timelines.ts * Update timelines.ts * wip * wip * wip * Update timelines.ts * Update misskey-js.api.md * Update timelines.ts * Update timelines.ts * wip * wip * wip * Update timelines.ts * wip * Update timelines.ts * wip * test * Update activitypub.ts * refactor: UserListJoining -> UserListMembership * Update NoteCreateService.ts * wip
This commit is contained in:
		| @@ -29,16 +29,22 @@ SPDX-License-Identifier: AGPL-3.0-only | ||||
|  | ||||
| 				<div class="_gaps_s"> | ||||
| 					<MkButton rounded primary style="margin: 0 auto;" @click="addUser()">{{ i18n.ts.addUser }}</MkButton> | ||||
| 					<div v-for="user in users" :key="user.id" :class="$style.userItem"> | ||||
| 						<MkA :class="$style.userItemBody" :to="`${userPage(user)}`"> | ||||
| 							<MkUserCardMini :user="user"/> | ||||
| 						</MkA> | ||||
| 						<button class="_button" :class="$style.remove" @click="removeUser(user, $event)"><i class="ti ti-x"></i></button> | ||||
| 					</div> | ||||
| 					<MkButton v-if="!fetching && queueUserIds.length !== 0" v-appear="enableInfiniteScroll ? fetchMoreUsers : null" :class="$style.more" :style="{ cursor: 'pointer' }" primary rounded @click="fetchMoreUsers"> | ||||
| 						{{ i18n.ts.loadMore }} | ||||
| 					</MkButton> | ||||
| 					<MkLoading v-if="fetching" class="loading"/> | ||||
|  | ||||
| 					<MkPagination ref="paginationEl" :pagination="membershipsPagination"> | ||||
| 						<template #default="{ items }"> | ||||
| 							<div class="_gaps_s"> | ||||
| 								<div v-for="item in items" :key="item.id"> | ||||
| 									<div :class="$style.userItem"> | ||||
| 										<MkA :class="$style.userItemBody" :to="`${userPage(item.user)}`"> | ||||
| 											<MkUserCardMini :user="item.user"/> | ||||
| 										</MkA> | ||||
| 										<button class="_button" :class="$style.menu" @click="showMembershipMenu(item, $event)"><i class="ti ti-dots"></i></button> | ||||
| 										<button class="_button" :class="$style.remove" @click="removeUser(item, $event)"><i class="ti ti-x"></i></button> | ||||
| 									</div> | ||||
| 								</div> | ||||
| 							</div> | ||||
| 						</template> | ||||
| 					</MkPagination> | ||||
| 				</div> | ||||
| 			</MkFolder> | ||||
| 		</div> | ||||
| @@ -59,9 +65,11 @@ import MkUserCardMini from '@/components/MkUserCardMini.vue'; | ||||
| import MkSwitch from '@/components/MkSwitch.vue'; | ||||
| import MkFolder from '@/components/MkFolder.vue'; | ||||
| import MkInput from '@/components/MkInput.vue'; | ||||
| import { userListsCache } from '@/cache'; | ||||
| import { userListsCache } from '@/cache.js'; | ||||
| import { $i } from '@/account.js'; | ||||
| import { defaultStore } from '@/store.js'; | ||||
| import MkPagination, { Paging } from '@/components/MkPagination.vue'; | ||||
|  | ||||
| const { | ||||
| 	enableInfiniteScroll, | ||||
| } = defaultStore.reactiveState; | ||||
| @@ -70,40 +78,25 @@ const props = defineProps<{ | ||||
| 	listId: string; | ||||
| }>(); | ||||
|  | ||||
| const FETCH_USERS_LIMIT = 20; | ||||
|  | ||||
| const paginationEl = ref<InstanceType<typeof MkPagination>>(); | ||||
| let list = $ref<Misskey.entities.UserList | null>(null); | ||||
| let users = $ref<Misskey.entities.UserLite[]>([]); | ||||
| let queueUserIds = $ref<string[]>([]); | ||||
| let fetching = $ref(true); | ||||
| const isPublic = ref(false); | ||||
| const name = ref(''); | ||||
| const membershipsPagination = { | ||||
| 	endpoint: 'users/lists/get-memberships' as const, | ||||
| 	limit: 30, | ||||
| 	params: computed(() => ({ | ||||
| 		listId: props.listId, | ||||
| 	})), | ||||
| }; | ||||
|  | ||||
| function fetchList() { | ||||
| 	fetching = true; | ||||
| 	os.api('users/lists/show', { | ||||
| 		listId: props.listId, | ||||
| 	}).then(_list => { | ||||
| 		list = _list; | ||||
| 		name.value = list.name; | ||||
| 		isPublic.value = list.isPublic; | ||||
| 		queueUserIds = list.userIds; | ||||
|  | ||||
| 		return fetchMoreUsers(); | ||||
| 	}); | ||||
| } | ||||
|  | ||||
| function fetchMoreUsers() { | ||||
| 	if (!list) return; | ||||
| 	if (fetching && users.length !== 0) return; // fetchingがtrueならやめるが、usersが空なら続行 | ||||
| 	fetching = true; | ||||
| 	os.api('users/show', { | ||||
| 		userIds: queueUserIds.slice(0, FETCH_USERS_LIMIT), | ||||
| 	}).then(_users => { | ||||
| 		users = users.concat(_users); | ||||
| 		queueUserIds = queueUserIds.slice(FETCH_USERS_LIMIT); | ||||
| 	}).finally(() => { | ||||
| 		fetching = false; | ||||
| 	}); | ||||
| } | ||||
|  | ||||
| @@ -114,12 +107,12 @@ function addUser() { | ||||
| 			listId: list.id, | ||||
| 			userId: user.id, | ||||
| 		}).then(() => { | ||||
| 			users.push(user); | ||||
| 			paginationEl.value.reload(); | ||||
| 		}); | ||||
| 	}); | ||||
| } | ||||
|  | ||||
| async function removeUser(user, ev) { | ||||
| async function removeUser(item, ev) { | ||||
| 	os.popupMenu([{ | ||||
| 		text: i18n.ts.remove, | ||||
| 		icon: 'ti ti-x', | ||||
| @@ -128,9 +121,28 @@ async function removeUser(user, ev) { | ||||
| 			if (!list) return; | ||||
| 			os.api('users/lists/pull', { | ||||
| 				listId: list.id, | ||||
| 				userId: user.id, | ||||
| 				userId: item.userId, | ||||
| 			}).then(() => { | ||||
| 				users = users.filter(x => x.id !== user.id); | ||||
| 				paginationEl.value.removeItem(item.id); | ||||
| 			}); | ||||
| 		}, | ||||
| 	}], ev.currentTarget ?? ev.target); | ||||
| } | ||||
|  | ||||
| async function showMembershipMenu(item, ev) { | ||||
| 	os.popupMenu([{ | ||||
| 		text: item.withReplies ? i18n.ts.hideRepliesToOthersInTimeline : i18n.ts.showRepliesToOthersInTimeline, | ||||
| 		icon: item.withReplies ? 'ti ti-messages-off' : 'ti ti-messages', | ||||
| 		action: async () => { | ||||
| 			os.api('users/lists/update-membership', { | ||||
| 				listId: list.id, | ||||
| 				userId: item.userId, | ||||
| 				withReplies: !item.withReplies, | ||||
| 			}).then(() => { | ||||
| 				paginationEl.value.updateItem(item.id, (old) => ({ | ||||
| 					...old, | ||||
| 					withReplies: !item.withReplies, | ||||
| 				})); | ||||
| 			}); | ||||
| 		}, | ||||
| 	}], ev.currentTarget ?? ev.target); | ||||
| @@ -202,6 +214,12 @@ definePageMetadata(computed(() => list ? { | ||||
| 	align-self: center; | ||||
| } | ||||
|  | ||||
| .menu { | ||||
| 	width: 32px; | ||||
| 	height: 32px; | ||||
| 	align-self: center; | ||||
| } | ||||
|  | ||||
| .more { | ||||
| 	margin-left: auto; | ||||
| 	margin-right: auto; | ||||
|   | ||||
| @@ -5,29 +5,11 @@ SPDX-License-Identifier: AGPL-3.0-only | ||||
|  | ||||
| <template> | ||||
| <div class="_gaps_m"> | ||||
| 	<MkTab v-model="tab"> | ||||
| 		<option value="soft">{{ i18n.ts._wordMute.soft }}</option> | ||||
| 		<option value="hard">{{ i18n.ts._wordMute.hard }}</option> | ||||
| 	</MkTab> | ||||
| 	<div> | ||||
| 		<div v-show="tab === 'soft'" class="_gaps_m"> | ||||
| 			<MkInfo>{{ i18n.ts._wordMute.softDescription }}</MkInfo> | ||||
| 			<MkTextarea v-model="softMutedWords"> | ||||
| 				<span>{{ i18n.ts._wordMute.muteWords }}</span> | ||||
| 				<template #caption>{{ i18n.ts._wordMute.muteWordsDescription }}<br>{{ i18n.ts._wordMute.muteWordsDescription2 }}</template> | ||||
| 			</MkTextarea> | ||||
| 		</div> | ||||
| 		<div v-show="tab === 'hard'" class="_gaps_m"> | ||||
| 			<MkInfo>{{ i18n.ts._wordMute.hardDescription }} {{ i18n.ts.reflectMayTakeTime }}</MkInfo> | ||||
| 			<MkTextarea v-model="hardMutedWords"> | ||||
| 				<span>{{ i18n.ts._wordMute.muteWords }}</span> | ||||
| 				<template #caption>{{ i18n.ts._wordMute.muteWordsDescription }}<br>{{ i18n.ts._wordMute.muteWordsDescription2 }}</template> | ||||
| 			</MkTextarea> | ||||
| 			<MkKeyValue v-if="hardWordMutedNotesCount != null"> | ||||
| 				<template #key>{{ i18n.ts._wordMute.mutedNotes }}</template> | ||||
| 				<template #value>{{ number(hardWordMutedNotesCount) }}</template> | ||||
| 			</MkKeyValue> | ||||
| 		</div> | ||||
| 		<MkTextarea v-model="mutedWords"> | ||||
| 			<span>{{ i18n.ts._wordMute.muteWords }}</span> | ||||
| 			<template #caption>{{ i18n.ts._wordMute.muteWordsDescription }}<br>{{ i18n.ts._wordMute.muteWordsDescription2 }}</template> | ||||
| 		</MkTextarea> | ||||
| 	</div> | ||||
| 	<MkButton primary inline :disabled="!changed" @click="save()"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton> | ||||
| </div> | ||||
| @@ -56,25 +38,15 @@ const render = (mutedWords) => mutedWords.map(x => { | ||||
| }).join('\n'); | ||||
|  | ||||
| const tab = ref('soft'); | ||||
| const softMutedWords = ref(render(defaultStore.state.mutedWords)); | ||||
| const hardMutedWords = ref(render($i!.mutedWords)); | ||||
| const hardWordMutedNotesCount = ref(null); | ||||
| const mutedWords = ref(render($i!.mutedWords)); | ||||
| const changed = ref(false); | ||||
|  | ||||
| os.api('i/get-word-muted-notes-count', {}).then(response => { | ||||
| 	hardWordMutedNotesCount.value = response?.count; | ||||
| }); | ||||
|  | ||||
| watch(softMutedWords, () => { | ||||
| 	changed.value = true; | ||||
| }); | ||||
|  | ||||
| watch(hardMutedWords, () => { | ||||
| watch(mutedWords, () => { | ||||
| 	changed.value = true; | ||||
| }); | ||||
|  | ||||
| async function save() { | ||||
| 	const parseMutes = (mutes, tab) => { | ||||
| 	const parseMutes = (mutes) => { | ||||
| 		// split into lines, remove empty lines and unnecessary whitespace | ||||
| 		let lines = mutes.trim().split('\n').map(line => line.trim()).filter(line => line !== ''); | ||||
|  | ||||
| @@ -92,7 +64,7 @@ async function save() { | ||||
| 					os.alert({ | ||||
| 						type: 'error', | ||||
| 						title: i18n.ts.regexpError, | ||||
| 						text: i18n.t('regexpErrorDescription', { tab, line: i + 1 }) + '\n' + err.toString(), | ||||
| 						text: i18n.t('regexpErrorDescription', { tab: 'word mute', line: i + 1 }) + '\n' + err.toString(), | ||||
| 					}); | ||||
| 					// re-throw error so these invalid settings are not saved | ||||
| 					throw err; | ||||
| @@ -105,18 +77,16 @@ async function save() { | ||||
| 		return lines; | ||||
| 	}; | ||||
|  | ||||
| 	let softMutes, hardMutes; | ||||
| 	let parsed; | ||||
| 	try { | ||||
| 		softMutes = parseMutes(softMutedWords.value, i18n.ts._wordMute.soft); | ||||
| 		hardMutes = parseMutes(hardMutedWords.value, i18n.ts._wordMute.hard); | ||||
| 		parsed = parseMutes(mutedWords.value); | ||||
| 	} catch (err) { | ||||
| 		// already displayed error message in parseMutes | ||||
| 		return; | ||||
| 	} | ||||
|  | ||||
| 	defaultStore.set('mutedWords', softMutes); | ||||
| 	await os.api('i/update', { | ||||
| 		mutedWords: hardMutes, | ||||
| 		mutedWords: parsed, | ||||
| 	}); | ||||
|  | ||||
| 	changed.value = false; | ||||
|   | ||||
| @@ -15,11 +15,10 @@ SPDX-License-Identifier: AGPL-3.0-only | ||||
| 			<div :class="$style.tl"> | ||||
| 				<MkTimeline | ||||
| 					ref="tlComponent" | ||||
| 					:key="src + withRenotes + withReplies + onlyFiles" | ||||
| 					:key="src + withRenotes + onlyFiles" | ||||
| 					:src="src.split(':')[0]" | ||||
| 					:list="src.split(':')[1]" | ||||
| 					:withRenotes="withRenotes" | ||||
| 					:withReplies="withReplies" | ||||
| 					:onlyFiles="onlyFiles" | ||||
| 					:sound="true" | ||||
| 					@queue="queueUpdated" | ||||
| @@ -62,7 +61,6 @@ let queue = $ref(0); | ||||
| let srcWhenNotSignin = $ref(isLocalTimelineAvailable ? 'local' : 'global'); | ||||
| const src = $computed({ get: () => ($i ? defaultStore.reactiveState.tl.value.src : srcWhenNotSignin), set: (x) => saveSrc(x) }); | ||||
| const withRenotes = $ref(true); | ||||
| const withReplies = $ref(false); | ||||
| const onlyFiles = $ref(false); | ||||
|  | ||||
| watch($$(src), () => queue = 0); | ||||
| @@ -144,11 +142,6 @@ const headerActions = $computed(() => [{ | ||||
| 			text: i18n.ts.showRenotes, | ||||
| 			icon: 'ti ti-repeat', | ||||
| 			ref: $$(withRenotes), | ||||
| 		}, { | ||||
| 			type: 'switch', | ||||
| 			text: i18n.ts.withReplies, | ||||
| 			icon: 'ti ti-arrow-back-up', | ||||
| 			ref: $$(withReplies), | ||||
| 		}, { | ||||
| 			type: 'switch', | ||||
| 			text: i18n.ts.fileAttachedOnly, | ||||
|   | ||||
| @@ -128,14 +128,14 @@ SPDX-License-Identifier: AGPL-3.0-only | ||||
| 				</div> | ||||
| 				<MkInfo v-else-if="$i && $i.id === user.id">{{ i18n.ts.userPagePinTip }}</MkInfo> | ||||
| 				<template v-if="narrow"> | ||||
| 					<XPhotos :key="user.id" :user="user"/> | ||||
| 					<XFiles :key="user.id" :user="user"/> | ||||
| 					<XActivity :key="user.id" :user="user"/> | ||||
| 				</template> | ||||
| 				<MkNotes v-if="!disableNotes" :class="$style.tl" :noGap="true" :pagination="pagination"/> | ||||
| 			</div> | ||||
| 		</div> | ||||
| 		<div v-if="!narrow" class="sub _gaps" style="container-type: inline-size;"> | ||||
| 			<XPhotos :key="user.id" :user="user"/> | ||||
| 			<XFiles :key="user.id" :user="user"/> | ||||
| 			<XActivity :key="user.id" :user="user"/> | ||||
| 		</div> | ||||
| 	</div> | ||||
| @@ -182,7 +182,7 @@ function calcAge(birthdate: string): number { | ||||
| 	return yearDiff; | ||||
| } | ||||
|  | ||||
| const XPhotos = defineAsyncComponent(() => import('./index.photos.vue')); | ||||
| const XFiles = defineAsyncComponent(() => import('./index.files.vue')); | ||||
| const XActivity = defineAsyncComponent(() => import('./index.activity.vue')); | ||||
|  | ||||
| const props = withDefaults(defineProps<{ | ||||
|   | ||||
| @@ -6,20 +6,21 @@ SPDX-License-Identifier: AGPL-3.0-only | ||||
| <template> | ||||
| <MkContainer :max-height="300" :foldable="true"> | ||||
| 	<template #icon><i class="ti ti-photo"></i></template> | ||||
| 	<template #header>{{ i18n.ts.images }}</template> | ||||
| 	<template #header>{{ i18n.ts.files }}</template> | ||||
| 	<div :class="$style.root"> | ||||
| 		<MkLoading v-if="fetching"/> | ||||
| 		<div v-if="!fetching && images.length > 0" :class="$style.stream"> | ||||
| 		<div v-if="!fetching && files.length > 0" :class="$style.stream"> | ||||
| 			<MkA | ||||
| 				v-for="image in images" | ||||
| 				:key="image.note.id + image.file.id" | ||||
| 				v-for="file in files" | ||||
| 				:key="file.note.id + file.file.id" | ||||
| 				:class="$style.img" | ||||
| 				:to="notePage(image.note)" | ||||
| 				:to="notePage(file.note)" | ||||
| 			> | ||||
| 				<ImgWithBlurhash :hash="image.file.blurhash" :src="thumbnail(image.file)" :title="image.file.name"/> | ||||
| 				<!-- TODO: 画像以外のファイルに対応 --> | ||||
| 				<ImgWithBlurhash :hash="file.file.blurhash" :src="thumbnail(file.file)" :title="file.file.name"/> | ||||
| 			</MkA> | ||||
| 		</div> | ||||
| 		<p v-if="!fetching && images.length == 0" :class="$style.empty">{{ i18n.ts.nothing }}</p> | ||||
| 		<p v-if="!fetching && files.length == 0" :class="$style.empty">{{ i18n.ts.nothing }}</p> | ||||
| 	</div> | ||||
| </MkContainer> | ||||
| </template> | ||||
| @@ -40,7 +41,7 @@ const props = defineProps<{ | ||||
| }>(); | ||||
| 
 | ||||
| let fetching = $ref(true); | ||||
| let images = $ref<{ | ||||
| let files = $ref<{ | ||||
| 	note: Misskey.entities.Note; | ||||
| 	file: Misskey.entities.DriveFile; | ||||
| }[]>([]); | ||||
| @@ -52,24 +53,15 @@ function thumbnail(image: Misskey.entities.DriveFile): string { | ||||
| } | ||||
| 
 | ||||
| onMounted(() => { | ||||
| 	const image = [ | ||||
| 		'image/jpeg', | ||||
| 		'image/webp', | ||||
| 		'image/avif', | ||||
| 		'image/png', | ||||
| 		'image/gif', | ||||
| 		'image/apng', | ||||
| 		'image/vnd.mozilla.apng', | ||||
| 	]; | ||||
| 	os.api('users/notes', { | ||||
| 		userId: props.user.id, | ||||
| 		fileType: image, | ||||
| 		withFiles: true, | ||||
| 		excludeNsfw: defaultStore.state.nsfw !== 'ignore', | ||||
| 		limit: 10, | ||||
| 		limit: 15, | ||||
| 	}).then(notes => { | ||||
| 		for (const note of notes) { | ||||
| 			for (const file of note.files) { | ||||
| 				images.push({ | ||||
| 				files.push({ | ||||
| 					note, | ||||
| 					file, | ||||
| 				}); | ||||
		Reference in New Issue
	
	Block a user
	 syuilo
					syuilo