260 lines
6.8 KiB
TypeScript
260 lines
6.8 KiB
TypeScript
/*
|
||
* 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<E extends keyof Misskey.Endpoints = keyof Misskey.Endpoints> = {
|
||
endpoint: E;
|
||
limit?: number;
|
||
params?: Misskey.Endpoints[E]['req'] | ComputedRef<Misskey.Endpoints[E]['req']>;
|
||
|
||
/**
|
||
* 検索APIのような、ページング不可なエンドポイントを利用する場合
|
||
* (そのようなAPIをこの関数で使うのは若干矛盾してるけど)
|
||
*/
|
||
noPaging?: boolean;
|
||
|
||
offsetMode?: boolean;
|
||
|
||
baseId?: MisskeyEntity['id'];
|
||
direction?: 'newer' | 'older';
|
||
};
|
||
|
||
export function usePagination<T extends MisskeyEntity>(props: {
|
||
ctx: PagingCtx;
|
||
useShallowRef?: boolean;
|
||
}) {
|
||
const items = props.useShallowRef ? shallowRef<T[]>([]) : ref<T[]>([]);
|
||
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<void> {
|
||
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<T[]>(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<void> {
|
||
return init();
|
||
}
|
||
|
||
async function fetchOlder(): Promise<void> {
|
||
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<T[]>(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<void> {
|
||
const params = props.ctx.params ? isRef(props.ctx.params) ? props.ctx.params.value : props.ctx.params : {};
|
||
await misskeyApi<T[]>(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,
|
||
};
|
||
}
|