From 92d9946f597c2b06552b23ba370708aea86575c0 Mon Sep 17 00:00:00 2001 From: tamaina <tamaina@hotmail.co.jp> Date: Wed, 5 Jul 2023 00:59:37 +0900 Subject: [PATCH] enhance(frontend): Better Timeline(MkPagination) Experience (#11066) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * enhance(frontend): Better MkPagination Appearance * fix * fix * 新規投稿が空でも先頭に戻ったらunshiftItemsする * use Map * refactor, 型エラー潰し * refactor --- .../frontend/src/components/MkPagination.vue | 170 +++++++++++++----- packages/frontend/src/pages/admin/abuses.vue | 2 +- .../src/pages/custom-emojis-manager.vue | 2 +- 3 files changed, 127 insertions(+), 47 deletions(-) diff --git a/packages/frontend/src/components/MkPagination.vue b/packages/frontend/src/components/MkPagination.vue index 598529bf5..b361d568e 100644 --- a/packages/frontend/src/components/MkPagination.vue +++ b/packages/frontend/src/components/MkPagination.vue @@ -21,14 +21,14 @@ <div v-else ref="rootEl"> <div v-show="pagination.reversed && more" key="_more_" class="_margin"> - <MkButton v-if="!moreFetching" v-appear="(enableInfiniteScroll && !props.disableAutoLoad) ? fetchMoreAhead : null" :class="$style.more" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary rounded @click="fetchMoreAhead"> + <MkButton v-if="!moreFetching" v-appear="(enableInfiniteScroll && !props.disableAutoLoad) ? appearFetchMoreAhead : null" :class="$style.more" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary rounded @click="fetchMoreAhead"> {{ i18n.ts.loadMore }} </MkButton> <MkLoading v-else class="loading"/> </div> - <slot :items="items" :fetching="fetching || moreFetching"></slot> + <slot :items="Array.from(items.values())" :fetching="fetching || moreFetching"></slot> <div v-show="!pagination.reversed && more" key="_more_" class="_margin"> - <MkButton v-if="!moreFetching" v-appear="(enableInfiniteScroll && !props.disableAutoLoad) ? fetchMore : null" :class="$style.more" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary rounded @click="fetchMore"> + <MkButton v-if="!moreFetching" v-appear="(enableInfiniteScroll && !props.disableAutoLoad) ? appearFetchMore : null" :class="$style.more" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary rounded @click="fetchMore"> {{ i18n.ts.loadMore }} </MkButton> <MkLoading v-else class="loading"/> @@ -50,6 +50,7 @@ import { i18n } from '@/i18n'; const SECOND_FETCH_LIMIT = 30; const TOLERANCE = 16; +const APPEAR_MINIMUM_INTERVAL = 600; export type Paging<E extends keyof misskey.Endpoints = keyof misskey.Endpoints> = { endpoint: E; @@ -71,6 +72,16 @@ export type Paging<E extends keyof misskey.Endpoints = keyof misskey.Endpoints> pageEl?: HTMLElement; }; + +type MisskeyEntityMap = Map<string, MisskeyEntity>; + +function arrayToEntries(entities: MisskeyEntity[]): [string, MisskeyEntity][] { + return entities.map(en => [en.id, en]); +} + +function concatMapWithArray(map: MisskeyEntityMap, entities: MisskeyEntity[]): MisskeyEntityMap { + return new Map([...map, ...arrayToEntries(entities)]); +} </script> <script lang="ts" setup> import { infoImageUrl } from '@/instance'; @@ -94,21 +105,38 @@ let backed = $ref(false); let scrollRemove = $ref<(() => void) | null>(null); -const items = ref<MisskeyEntity[]>([]); -const queue = ref<MisskeyEntity[]>([]); +/** + * 表示するアイテムのソース + * 最新が0番目 + */ +const items = ref<MisskeyEntityMap>(new Map()); + +/** + * タブが非アクティブなどの場合に更新を貯めておく + * 最新が0番目 + */ +const queue = ref<MisskeyEntityMap>(new Map()); + const offset = ref(0); + +/** + * 初期化中かどうか(trueならMkLoadingで全て隠す) + */ const fetching = ref(true); + const moreFetching = ref(false); const more = ref(false); +const preventAppearFetchMore = ref(false); +const preventAppearFetchMoreTimer = ref<number | null>(null); const isBackTop = ref(false); -const empty = computed(() => items.value.length === 0); +const empty = computed(() => items.value.size === 0); const error = ref(false); const { enableInfiniteScroll, } = defaultStore.reactiveState; const contentEl = $computed(() => props.pagination.pageEl ?? rootEl); -const scrollableElement = $computed(() => getScrollContainer(contentEl)); +const scrollableElement = $computed(() => contentEl ? getScrollContainer(contentEl) : document.body); const visibility = useDocumentVisibility(); @@ -133,9 +161,9 @@ watch([() => props.pagination.reversed, $$(scrollableElement)], () => { }, { immediate: true }); watch($$(rootEl), () => { - scrollObserver.disconnect(); + scrollObserver?.disconnect(); nextTick(() => { - if (rootEl) scrollObserver.observe(rootEl); + if (rootEl) scrollObserver?.observe(rootEl); }); }); @@ -155,12 +183,12 @@ if (props.pagination.params && isRef(props.pagination.params)) { } watch(queue, (a, b) => { - if (a.length === 0 && b.length === 0) return; - emit('queue', queue.value.length); + if (a.size === 0 && b.size === 0) return; + emit('queue', queue.value.size); }, { deep: true }); async function init(): Promise<void> { - queue.value = []; + queue.value = new Map(); fetching.value = true; const params = props.pagination.params ? isRef(props.pagination.params) ? props.pagination.params.value : props.pagination.params : {}; await os.api(props.pagination.endpoint, { @@ -173,11 +201,11 @@ async function init(): Promise<void> { } if (res.length === 0 || props.pagination.noPaging) { - items.value = res; + concatItems(res); more.value = false; } else { if (props.pagination.reversed) moreFetching.value = true; - items.value = res; + concatItems(res); more.value = true; } @@ -191,12 +219,13 @@ async function init(): Promise<void> { } const reload = (): Promise<void> => { - items.value = []; + items.value = new Map(); + queue.value = new Map(); return init(); }; const fetchMore = async (): Promise<void> => { - if (!more.value || fetching.value || moreFetching.value || items.value.length === 0) return; + if (!more.value || fetching.value || moreFetching.value || items.value.size === 0) return; moreFetching.value = true; const params = props.pagination.params ? isRef(props.pagination.params) ? props.pagination.params.value : props.pagination.params : {}; await os.api(props.pagination.endpoint, { @@ -205,7 +234,7 @@ const fetchMore = async (): Promise<void> => { ...(props.pagination.offsetMode ? { offset: offset.value, } : { - untilId: items.value[items.value.length - 1].id, + untilId: Array.from(items.value.keys())[items.value.size - 1], }), }).then(res => { for (let i = 0; i < res.length; i++) { @@ -217,7 +246,7 @@ const fetchMore = async (): Promise<void> => { const oldHeight = scrollableElement ? scrollableElement.scrollHeight : getBodyScrollHeight(); const oldScroll = scrollableElement ? scrollableElement.scrollTop : window.scrollY; - items.value = items.value.concat(_res); + items.value = concatMapWithArray(items.value, _res); return nextTick(() => { if (scrollableElement) { @@ -237,7 +266,7 @@ const fetchMore = async (): Promise<void> => { moreFetching.value = false; }); } else { - items.value = items.value.concat(res); + items.value = concatMapWithArray(items.value, res); more.value = false; moreFetching.value = false; } @@ -248,7 +277,7 @@ const fetchMore = async (): Promise<void> => { moreFetching.value = false; }); } else { - items.value = items.value.concat(res); + items.value = concatMapWithArray(items.value, res); more.value = true; moreFetching.value = false; } @@ -260,7 +289,7 @@ const fetchMore = async (): Promise<void> => { }; const fetchMoreAhead = async (): Promise<void> => { - if (!more.value || fetching.value || moreFetching.value || items.value.length === 0) return; + if (!more.value || fetching.value || moreFetching.value || items.value.size === 0) return; moreFetching.value = true; const params = props.pagination.params ? isRef(props.pagination.params) ? props.pagination.params.value : props.pagination.params : {}; await os.api(props.pagination.endpoint, { @@ -269,14 +298,14 @@ const fetchMoreAhead = async (): Promise<void> => { ...(props.pagination.offsetMode ? { offset: offset.value, } : { - sinceId: items.value[items.value.length - 1].id, + sinceId: Array.from(items.value.keys())[items.value.size - 1], }), }).then(res => { if (res.length === 0) { - items.value = items.value.concat(res); + items.value = concatMapWithArray(items.value, res); more.value = false; } else { - items.value = items.value.concat(res); + items.value = concatMapWithArray(items.value, res); more.value = true; } offset.value += res.length; @@ -286,7 +315,32 @@ const fetchMoreAhead = async (): Promise<void> => { }); }; -const isTop = (): boolean => isBackTop.value || (props.pagination.reversed ? isBottomVisible : isTopVisible)(contentEl, TOLERANCE); +/** + * Appear(IntersectionObserver)によってfetchMoreが呼ばれる場合、 + * APPEAR_MINIMUM_INTERVALミリ秒以内に2回fetchMoreが呼ばれるのを防ぐ + */ +const fetchMoreApperTimeoutFn = (): void => { + preventAppearFetchMore.value = false; + preventAppearFetchMoreTimer.value = null; +}; +const fetchMoreAppearTimeout = (): void => { + preventAppearFetchMore.value = true; + preventAppearFetchMoreTimer.value = window.setTimeout(fetchMoreApperTimeoutFn, APPEAR_MINIMUM_INTERVAL); +}; + +const appearFetchMore = async (): Promise<void> => { + if (preventAppearFetchMore.value) return; + await fetchMore(); + fetchMoreAppearTimeout(); +}; + +const appearFetchMoreAhead = async (): Promise<void> => { + if (preventAppearFetchMore.value) return; + await fetchMoreAhead(); + fetchMoreAppearTimeout(); +}; + +const isTop = (): boolean => isBackTop.value || (props.pagination.reversed ? isBottomVisible : isTopVisible)(contentEl!, TOLERANCE); watch(visibility, () => { if (visibility.value === 'hidden') { @@ -308,10 +362,15 @@ watch(visibility, () => { } }); +/** + * 最新のものとして1つだけアイテムを追加する + * ストリーミングから降ってきたアイテムはこれで追加する + * @param item アイテム + */ const prepend = (item: MisskeyEntity): void => { - // 初回表示時はunshiftだけでOK - if (!rootEl) { - items.value.unshift(item); + if (items.value.size === 0) { + items.value.set(item.id, item); + fetching.value = false; return; } @@ -319,38 +378,55 @@ const prepend = (item: MisskeyEntity): void => { else prependQueue(item); }; +/** + * 新着アイテムをitemsの先頭に追加し、displayLimitを適用する + * @param newItems 新しいアイテムの配列 + */ function unshiftItems(newItems: MisskeyEntity[]) { - const length = newItems.length + items.value.length; - items.value = [...newItems, ...items.value].slice(0, props.displayLimit); + const length = newItems.length + items.value.size; + items.value = new Map([...arrayToEntries(newItems), ...items.value].slice(0, props.displayLimit)); + + if (length >= props.displayLimit) more.value = true; +} + +/** + * 古いアイテムをitemsの末尾に追加し、displayLimitを適用する + * @param oldItems 古いアイテムの配列 + */ +function concatItems(oldItems: MisskeyEntity[]) { + const length = oldItems.length + items.value.size; + items.value = new Map([...items.value, ...arrayToEntries(oldItems)].slice(0, props.displayLimit)); if (length >= props.displayLimit) more.value = true; } function executeQueue() { - if (queue.value.length === 0) return; - unshiftItems(queue.value); - queue.value = []; + unshiftItems(Array.from(queue.value.values())); + queue.value = new Map(); } function prependQueue(newItem: MisskeyEntity) { - queue.value.unshift(newItem); - if (queue.value.length >= props.displayLimit) { - queue.value.pop(); - } + queue.value = new Map([[newItem.id, newItem], ...queue.value].slice(0, props.displayLimit) as [string, MisskeyEntity][]); } +/* + * アイテムを末尾に追加する(使うの?) + */ const appendItem = (item: MisskeyEntity): void => { - items.value.push(item); + items.value.set(item.id, item); }; -const removeItem = (finder: (item: MisskeyEntity) => boolean) => { - const i = items.value.findIndex(finder); - items.value.splice(i, 1); +const removeItem = (id: string) => { + items.value.delete(id); + queue.value.delete(id); }; const updateItem = (id: MisskeyEntity['id'], replacer: (old: MisskeyEntity) => MisskeyEntity): void => { - const i = items.value.findIndex(item => item.id === id); - items.value[i] = replacer(items.value[i]); + const item = items.value.get(id); + if (item) items.value.set(id, replacer(item)); + + const queueItem = queue.value.get(id); + if (queueItem) queue.value.set(id, replacer(queueItem)); }; const inited = init(); @@ -364,7 +440,7 @@ onDeactivated(() => { }); function toBottom() { - scrollToBottom(contentEl); + scrollToBottom(contentEl!); } onMounted(() => { @@ -388,7 +464,11 @@ onBeforeUnmount(() => { clearTimeout(timerForSetPause); timerForSetPause = null; } - scrollObserver.disconnect(); + if (preventAppearFetchMoreTimer.value) { + clearTimeout(preventAppearFetchMoreTimer.value); + preventAppearFetchMoreTimer.value = null; + } + scrollObserver?.disconnect(); }); defineExpose({ diff --git a/packages/frontend/src/pages/admin/abuses.vue b/packages/frontend/src/pages/admin/abuses.vue index 3bc5ee972..9cf96d3d0 100644 --- a/packages/frontend/src/pages/admin/abuses.vue +++ b/packages/frontend/src/pages/admin/abuses.vue @@ -75,7 +75,7 @@ const pagination = { }; function resolved(reportId) { - reports.removeItem(item => item.id === reportId); + reports.removeItem(reportId); } const headerActions = $computed(() => []); diff --git a/packages/frontend/src/pages/custom-emojis-manager.vue b/packages/frontend/src/pages/custom-emojis-manager.vue index 3da6a0d9c..901fbc1ee 100644 --- a/packages/frontend/src/pages/custom-emojis-manager.vue +++ b/packages/frontend/src/pages/custom-emojis-manager.vue @@ -144,7 +144,7 @@ const edit = (emoji) => { ...result.updated, })); } else if (result.deleted) { - emojisPaginationComponent.value.removeItem((item) => item.id === emoji.id); + emojisPaginationComponent.value.removeItem(emoji.id); } }, }, 'closed');