diff --git a/packages/frontend/src/components/MkPagination.vue b/packages/frontend/src/components/MkPagination.vue index 9adc3d98da..78ba646111 100644 --- a/packages/frontend/src/components/MkPagination.vue +++ b/packages/frontend/src/components/MkPagination.vue @@ -11,11 +11,11 @@ SPDX-License-Identifier: AGPL-3.0-only :leaveToClass="prefer.s.animation ? $style.transition_fade_leaveTo : ''" mode="out-in" > - + - + -
+
@@ -25,15 +25,15 @@ SPDX-License-Identifier: AGPL-3.0-only
-
- +
+ {{ i18n.ts.loadMore }}
- -
- + +
+ {{ i18n.ts.loadMore }} @@ -42,451 +42,36 @@ SPDX-License-Identifier: AGPL-3.0-only - diff --git a/packages/frontend/src/components/MkTimeline.vue b/packages/frontend/src/components/MkTimeline.vue index cf6beedff0..afbb2ed63d 100644 --- a/packages/frontend/src/components/MkTimeline.vue +++ b/packages/frontend/src/components/MkTimeline.vue @@ -4,38 +4,48 @@ SPDX-License-Identifier: AGPL-3.0-only --> @@ -44,7 +54,8 @@ import { computed, watch, onUnmounted, provide, useTemplateRef, TransitionGroup import * as Misskey from 'misskey-js'; import { useInterval } from '@@/js/use-interval.js'; import type { BasicTimelineType } from '@/timelines.js'; -import type { Paging } from '@/components/MkPagination.vue'; +import type { PagingCtx } from '@/use/use-pagination.js'; +import { usePagination } from '@/use/use-pagination.js'; import MkPullToRefresh from '@/components/MkPullToRefresh.vue'; import { useStream } from '@/stream.js'; import * as sound from '@/utility/sound.js'; @@ -53,9 +64,9 @@ import { instance } from '@/instance.js'; import { prefer } from '@/preferences.js'; import { store } from '@/store.js'; import MkNote from '@/components/MkNote.vue'; -import MkPagination from '@/components/MkPagination.vue'; import { i18n } from '@/i18n.js'; import { infoImageUrl } from '@/instance.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; const props = withDefaults(defineProps<{ src: BasicTimelineType | 'mentions' | 'directs' | 'list' | 'antenna' | 'channel' | 'role'; @@ -94,15 +105,17 @@ type TimelineQueryType = { roleId?: string }; -const prComponent = useTemplateRef('prComponent'); -const pagingComponent = useTemplateRef('pagingComponent'); - let tlNotesCount = 0; const POLLING_INTERVAL = 1000 * 10; -useInterval(() => { - // TODO +useInterval(async () => { + const notes = await misskeyApi(paginationQuery.endpoint, { + ...paginationQuery.params, + limit: 10, + sinceId: Array.from(paginator.items.value.keys()).at(-1), + }); + console.log(notes); }, POLLING_INTERVAL, { immediate: false, afterMounted: true, @@ -126,7 +139,7 @@ function prepend(note) { let connection: Misskey.ChannelConnection | null = null; let connection2: Misskey.ChannelConnection | null = null; -let paginationQuery: Paging | null = null; +let paginationQuery: PagingCtx; const noGap = !prefer.s.showGapBetweenNotesInTimeline; const stream = store.s.realtimeMode ? useStream() : null; @@ -258,19 +271,14 @@ function updatePaginationQuery() { roleId: props.role, }; } else { - endpoint = null; - query = null; + throw new Error('Unrecognized timeline type: ' + props.src); } - if (endpoint && query) { - paginationQuery = { - endpoint: endpoint, - limit: 10, - params: query, - }; - } else { - paginationQuery = null; - } + paginationQuery = { + endpoint: endpoint, + limit: 10, + params: query, + }; } function refreshEndpointAndChannel() { @@ -292,17 +300,19 @@ watch(() => props.withSensitive, reloadTimeline); // 初回表示用 refreshEndpointAndChannel(); +const paginator = usePagination({ + ctx: paginationQuery, +}); + onUnmounted(() => { disconnectChannel(); }); function reloadTimeline() { return new Promise((res) => { - if (pagingComponent.value == null) return; - tlNotesCount = 0; - pagingComponent.value.reload().then(() => { + paginator.reload().then(() => { res(); }); }); diff --git a/packages/frontend/src/types/date-separated-list.ts b/packages/frontend/src/types/date-separated-list.ts deleted file mode 100644 index af685cff12..0000000000 --- a/packages/frontend/src/types/date-separated-list.ts +++ /dev/null @@ -1,11 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -export type MisskeyEntity = { - id: string; - createdAt: string; - _shouldInsertAd_?: boolean; - [x: string]: any; -}; diff --git a/packages/frontend/src/use/use-pagination.ts b/packages/frontend/src/use/use-pagination.ts new file mode 100644 index 0000000000..59bfcf5a2e --- /dev/null +++ b/packages/frontend/src/use/use-pagination.ts @@ -0,0 +1,214 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { computed, isRef, nextTick, onActivated, onBeforeMount, onBeforeUnmount, onDeactivated, onUnmounted, ref, 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 = 20; +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; + + /** + * items 配列の中身を逆順にする(新しい方が最後) + */ + reversed?: boolean; + + offsetMode?: boolean; +}; + +type MisskeyEntityMap = Map; + +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)]); +} + +export function usePagination(props: { + ctx: PagingCtx; +}) { + /** + * 表示するアイテムのソース + * 最新が0番目 + */ + const items = ref(new Map()); + + /** + * 初期化中かどうか(trueならMkLoadingで全て隠す) + */ + const fetching = ref(true); + + const moreFetching = ref(false); + const canFetchMore = ref(false); + const error = ref(false); + + // パラメータに何らかの変更があった際、再読込したい(チャンネル等のIDが変わったなど) + watch(() => [props.ctx.endpoint, props.ctx.params], init, { deep: true }); + + async function init(): Promise { + items.value = new Map(); + 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, + }).then(res => { + 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) { + concatItems(res); + canFetchMore.value = false; + } else { + if (props.ctx.reversed) moreFetching.value = true; + concatItems(res); + canFetchMore.value = true; + } + + error.value = false; + fetching.value = false; + }, err => { + error.value = true; + fetching.value = false; + }); + } + + const reload = (): Promise => { + return init(); + }; + + const fetchMore = async (): Promise => { + if (!canFetchMore.value || fetching.value || moreFetching.value || items.value.size === 0) return; + moreFetching.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.size, + } : { + untilId: Array.from(items.value.keys()).at(-1), + }), + }).then(res => { + for (let i = 0; i < res.length; i++) { + const item = res[i]; + if (i === 10) item._shouldInsertAd_ = true; + } + + if (res.length === 0) { + items.value = concatMapWithArray(items.value, res); + canFetchMore.value = false; + moreFetching.value = false; + } else { + items.value = concatMapWithArray(items.value, res); + canFetchMore.value = true; + moreFetching.value = false; + } + }, err => { + moreFetching.value = false; + }); + }; + + const fetchMoreAhead = async (): Promise => { + if (!canFetchMore.value || fetching.value || moreFetching.value || items.value.size === 0) return; + moreFetching.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.size, + } : { + sinceId: Array.from(items.value.keys()).at(-1), + }), + }).then(res => { + if (res.length === 0) { + items.value = concatMapWithArray(items.value, res); + canFetchMore.value = false; + } else { + items.value = concatMapWithArray(items.value, res); + canFetchMore.value = true; + } + moreFetching.value = false; + }, err => { + moreFetching.value = false; + }); + }; + + /** + * 新着アイテムをitemsの先頭に追加し、MAX_ITEMSを適用する + * @param newItems 新しいアイテムの配列 + */ + function unshiftItems(newItems: MisskeyEntity[]) { + const length = newItems.length + items.value.size; + items.value = new Map([...arrayToEntries(newItems), ...items.value].slice(0, MAX_ITEMS)); + + if (length >= MAX_ITEMS) canFetchMore.value = true; + } + + /** + * 古いアイテムをitemsの末尾に追加し、MAX_ITEMSを適用する + * @param oldItems 古いアイテムの配列 + */ + function concatItems(oldItems: MisskeyEntity[]) { + const length = oldItems.length + items.value.size; + items.value = new Map([...items.value, ...arrayToEntries(oldItems)].slice(0, MAX_ITEMS)); + + if (length >= MAX_ITEMS) canFetchMore.value = true; + } + + /* + * アイテムを末尾に追加する(使うの?) + */ + const appendItem = (item: MisskeyEntity): void => { + items.value.set(item.id, item); + }; + + const removeItem = (id: string) => { + items.value.delete(id); + }; + + const updateItem = (id: MisskeyEntity['id'], replacer: (old: MisskeyEntity) => MisskeyEntity): void => { + const item = items.value.get(id); + if (item) items.value.set(id, replacer(item)); + }; + + return { + items, + fetching, + moreFetching, + canFetchMore, + init, + reload, + fetchMore, + fetchMoreAhead, + error, + }; +}