/* * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ import { computed, isRef, onMounted, ref, shallowRef, triggerRef, watch } from 'vue'; import * as Misskey from 'misskey-js'; import type { ComputedRef, Ref, ShallowRef } from 'vue'; import { misskeyApi } from '@/utility/misskey-api.js'; const MAX_ITEMS = 30; const MAX_QUEUE_ITEMS = 100; const FIRST_FETCH_LIMIT = 15; const SECOND_FETCH_LIMIT = 30; export type MisskeyEntity = { id: string; createdAt: string; _shouldInsertAd_?: boolean; [x: string]: any; }; export type PagingCtx = { endpoint: E; limit?: number; params?: Misskey.Endpoints[E]['req'] | ComputedRef; /** * 検索APIのような、ページング不可なエンドポイントを利用する場合 * (そのようなAPIをこの関数で使うのは若干矛盾してるけど) */ noPaging?: boolean; offsetMode?: boolean; baseId?: MisskeyEntity['id']; direction?: 'newer' | 'older'; }; export function usePagination(props: { ctx: PagingCtx; useShallowRef?: boolean; }) { const items = props.useShallowRef ? shallowRef([]) : ref([]); let aheadQueue: T[] = []; const queuedAheadItemsCount = ref(0); const fetching = ref(true); const fetchingOlder = ref(false); const canFetchOlder = ref(false); const error = ref(false); // パラメータに何らかの変更があった際、再読込したい(チャンネル等のIDが変わったなど) watch(() => [props.ctx.endpoint, props.ctx.params], init, { deep: true }); function getNewestId(): string | null | undefined { // 様々な要因により並び順は保証されないのでソートが必要 if (aheadQueue.length > 0) { return aheadQueue.map(x => x.id).sort().at(-1); } return items.value.map(x => x.id).sort().at(-1); } function getOldestId(): string | null | undefined { // 様々な要因により並び順は保証されないのでソートが必要 return items.value.map(x => x.id).sort().at(0); } async function init(): Promise { items.value = []; aheadQueue = []; queuedAheadItemsCount.value = 0; fetching.value = true; const params = props.ctx.params ? isRef(props.ctx.params) ? props.ctx.params.value : props.ctx.params : {}; await misskeyApi(props.ctx.endpoint, { ...params, limit: props.ctx.limit ?? FIRST_FETCH_LIMIT, allowPartial: true, ...(props.ctx.baseId && props.ctx.direction === 'newer' ? { sinceId: props.ctx.baseId, } : props.ctx.baseId && props.ctx.direction === 'older' ? { untilId: props.ctx.baseId, } : {}), }).then(res => { // 逆順で返ってくるので if (props.ctx.baseId && props.ctx.direction === 'newer') { res.reverse(); } for (let i = 0; i < res.length; i++) { const item = res[i]; if (i === 3) item._shouldInsertAd_ = true; } if (res.length === 0 || props.ctx.noPaging) { pushItems(res); canFetchOlder.value = false; } else { pushItems(res); canFetchOlder.value = true; } error.value = false; fetching.value = false; }, err => { error.value = true; fetching.value = false; }); } function reload(): Promise { return init(); } async function fetchOlder(): Promise { if (!canFetchOlder.value || fetching.value || fetchingOlder.value || items.value.length === 0) return; fetchingOlder.value = true; const params = props.ctx.params ? isRef(props.ctx.params) ? props.ctx.params.value : props.ctx.params : {}; await misskeyApi(props.ctx.endpoint, { ...params, limit: SECOND_FETCH_LIMIT, ...(props.ctx.offsetMode ? { offset: items.value.length, } : { untilId: getOldestId(), }), }).then(res => { for (let i = 0; i < res.length; i++) { const item = res[i]; if (i === 10) item._shouldInsertAd_ = true; } if (res.length === 0) { canFetchOlder.value = false; fetchingOlder.value = false; } else { items.value.push(...res); if (props.useShallowRef) triggerRef(items); canFetchOlder.value = true; fetchingOlder.value = false; } }, err => { fetchingOlder.value = false; }); } async function fetchNewer(options: { toQueue?: boolean; } = {}): Promise { const params = props.ctx.params ? isRef(props.ctx.params) ? props.ctx.params.value : props.ctx.params : {}; await misskeyApi(props.ctx.endpoint, { ...params, limit: SECOND_FETCH_LIMIT, ...(props.ctx.offsetMode ? { offset: items.value.length, } : { sinceId: getNewestId(), }), }).then(res => { if (res.length === 0) return; // これやらないと余計なre-renderが走る if (options.toQueue) { aheadQueue.unshift(...res.toReversed()); if (aheadQueue.length > MAX_QUEUE_ITEMS) { aheadQueue = aheadQueue.slice(0, MAX_QUEUE_ITEMS); } queuedAheadItemsCount.value = aheadQueue.length; } else { items.value.unshift(...res.toReversed()); trim(false); if (props.useShallowRef) triggerRef(items); } }); } function trim(trigger = true) { if (items.value.length >= MAX_ITEMS) canFetchOlder.value = true; items.value = items.value.slice(0, MAX_ITEMS); if (props.useShallowRef && trigger) triggerRef(items); } function unshiftItems(newItems: T[]) { if (newItems.length === 0) return; // これやらないと余計なre-renderが走る items.value.unshift(...newItems); trim(false); if (props.useShallowRef) triggerRef(items); } function pushItems(oldItems: T[]) { items.value.push(...oldItems); if (props.useShallowRef) triggerRef(items); } function prepend(item: T) { items.value.unshift(item); trim(false); if (props.useShallowRef) triggerRef(items); } function enqueue(item: T) { aheadQueue.unshift(item); if (aheadQueue.length > MAX_QUEUE_ITEMS) { aheadQueue.pop(); } queuedAheadItemsCount.value = aheadQueue.length; } function releaseQueue() { if (aheadQueue.length === 0) return; // これやらないと余計なre-renderが走る unshiftItems(aheadQueue); aheadQueue = []; queuedAheadItemsCount.value = 0; } function removeItem(id: string) { // TODO: queueからも消す const index = items.value.findIndex(x => x.id === id); if (index !== -1) { items.value.splice(index, 1); if (props.useShallowRef) triggerRef(items); } } function updateItem(id: string, updator: (item: T) => T) { // TODO: queueのも更新 const index = items.value.findIndex(x => x.id === id); if (index !== -1) { const item = items.value[index]!; items.value[index] = updator(item); if (props.useShallowRef) triggerRef(items); } } onMounted(() => { init(); }); return { items, queuedAheadItemsCount, fetching, fetchingOlder, canFetchOlder, init, reload, fetchOlder, fetchNewer, unshiftItems, prepend, trim, removeItem, updateItem, enqueue, releaseQueue, error, }; }