summaryrefslogtreecommitdiff
path: root/packages/frontend/src/components/MkPagination.vue
diff options
context:
space:
mode:
Diffstat (limited to 'packages/frontend/src/components/MkPagination.vue')
-rw-r--r--packages/frontend/src/components/MkPagination.vue169
1 files changed, 124 insertions, 45 deletions
diff --git a/packages/frontend/src/components/MkPagination.vue b/packages/frontend/src/components/MkPagination.vue
index 598529bf58..b9a75f6002 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,13 @@ 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 = [];
+ items.value = new Map();
+ 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 +202,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 +220,11 @@ async function init(): Promise<void> {
}
const reload = (): Promise<void> => {
- items.value = [];
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 +233,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()).at(-1),
}),
}).then(res => {
for (let i = 0; i < res.length; i++) {
@@ -217,7 +245,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 +265,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 +276,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 +288,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 +297,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()).at(-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 +314,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 +361,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 +377,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 +439,7 @@ onDeactivated(() => {
});
function toBottom() {
- scrollToBottom(contentEl);
+ scrollToBottom(contentEl!);
}
onMounted(() => {
@@ -388,7 +463,11 @@ onBeforeUnmount(() => {
clearTimeout(timerForSetPause);
timerForSetPause = null;
}
- scrollObserver.disconnect();
+ if (preventAppearFetchMoreTimer.value) {
+ clearTimeout(preventAppearFetchMoreTimer.value);
+ preventAppearFetchMoreTimer.value = null;
+ }
+ scrollObserver?.disconnect();
});
defineExpose({