diff options
| author | かっこかり <67428053+kakkokari-gtyih@users.noreply.github.com> | 2025-07-03 11:20:26 +0900 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-07-03 11:20:26 +0900 |
| commit | 09a5e4b10aad85d27875f3cdc8f32bd820615978 (patch) | |
| tree | b79819b40e0630314522461ea01ef365562fb255 | |
| parent | 🎨 (diff) | |
| download | misskey-09a5e4b10aad85d27875f3cdc8f32bd820615978.tar.gz misskey-09a5e4b10aad85d27875f3cdc8f32bd820615978.tar.bz2 misskey-09a5e4b10aad85d27875f3cdc8f32bd820615978.zip | |
fix(frontend): Paginatorの型エラー解消 (#16230)
* fix(frontend): fix paginator type error
* fix
* refactor
* fix
* fix
* fix(paginator): remove readonly type
* fix
* typo
* fix: R -> E
* remove any
---------
Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com>
18 files changed, 213 insertions, 135 deletions
diff --git a/packages/frontend/src/components/MkChannelList.vue b/packages/frontend/src/components/MkChannelList.vue index 7f82e531ae..394dcb6bd1 100644 --- a/packages/frontend/src/components/MkChannelList.vue +++ b/packages/frontend/src/components/MkChannelList.vue @@ -14,15 +14,16 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import type { Paginator } from '@/utility/paginator.js'; +import * as Misskey from 'misskey-js'; +import type { IPaginator } from '@/utility/paginator.js'; import MkChannelPreview from '@/components/MkChannelPreview.vue'; import MkPagination from '@/components/MkPagination.vue'; import { i18n } from '@/i18n.js'; const props = withDefaults(defineProps<{ - paginator: Paginator; + paginator: IPaginator; noGap?: boolean; - extractor?: (item: any) => any; + extractor?: (item: any) => Misskey.entities.Channel; }>(), { extractor: (item) => item, }); diff --git a/packages/frontend/src/components/MkNotesTimeline.vue b/packages/frontend/src/components/MkNotesTimeline.vue index 1ae97fd0c0..83af7db26f 100644 --- a/packages/frontend/src/components/MkNotesTimeline.vue +++ b/packages/frontend/src/components/MkNotesTimeline.vue @@ -31,8 +31,9 @@ SPDX-License-Identifier: AGPL-3.0-only </MkPagination> </template> -<script lang="ts" setup generic="T extends Paginator"> -import type { Paginator } from '@/utility/paginator.js'; +<script lang="ts" setup generic="T extends IPaginator<Misskey.entities.Note>"> +import * as Misskey from 'misskey-js'; +import type { IPaginator } from '@/utility/paginator.js'; import MkNote from '@/components/MkNote.vue'; import MkPagination from '@/components/MkPagination.vue'; import { i18n } from '@/i18n.js'; diff --git a/packages/frontend/src/components/MkPagination.vue b/packages/frontend/src/components/MkPagination.vue index fb9cd6e1f0..8ca1c80e84 100644 --- a/packages/frontend/src/components/MkPagination.vue +++ b/packages/frontend/src/components/MkPagination.vue @@ -25,7 +25,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> <div v-else key="_root_" class="_gaps"> - <slot :items="paginator.items.value" :fetching="paginator.fetching.value || paginator.fetchingOlder.value"></slot> + <slot :items="unref(paginator.items)" :fetching="paginator.fetching.value || paginator.fetchingOlder.value"></slot> <div v-if="paginator.order.value === 'oldest'"> <MkButton v-if="!paginator.fetchingNewer.value" :class="$style.more" :wait="paginator.fetchingNewer.value" primary rounded @click="paginator.fetchNewer()"> {{ i18n.ts.loadMore }} @@ -44,11 +44,11 @@ SPDX-License-Identifier: AGPL-3.0-only </component> </template> -<script lang="ts" setup generic="T extends Paginator, I = UnwrapRef<T['items']>"> +<script lang="ts" setup generic="T extends IPaginator"> import { isLink } from '@@/js/is-link.js'; -import { onMounted, watch } from 'vue'; +import { onMounted, watch, unref } from 'vue'; import type { UnwrapRef } from 'vue'; -import type { Paginator } from '@/utility/paginator.js'; +import type { IPaginator } from '@/utility/paginator.js'; import MkButton from '@/components/MkButton.vue'; import { i18n } from '@/i18n.js'; import { prefer } from '@/preferences.js'; @@ -95,7 +95,7 @@ if (props.paginator.computedParams) { defineSlots<{ empty: () => void; - default: (props: { items: I }) => void; + default: (props: { items: UnwrapRef<T['items']> }) => void; }>(); </script> diff --git a/packages/frontend/src/components/MkPaginationControl.vue b/packages/frontend/src/components/MkPaginationControl.vue index 91630eca35..10bed575a4 100644 --- a/packages/frontend/src/components/MkPaginationControl.vue +++ b/packages/frontend/src/components/MkPaginationControl.vue @@ -37,9 +37,9 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </template> -<script lang="ts" setup generic="T extends Paginator"> +<script lang="ts" setup generic="T extends IPaginator"> import { ref, watch } from 'vue'; -import type { Paginator } from '@/utility/paginator.js'; +import type { IPaginator } from '@/utility/paginator.js'; import MkButton from '@/components/MkButton.vue'; import { i18n } from '@/i18n.js'; import MkSelect from '@/components/MkSelect.vue'; diff --git a/packages/frontend/src/components/MkStreamingNotesTimeline.vue b/packages/frontend/src/components/MkStreamingNotesTimeline.vue index 6e87c02949..b697e18f79 100644 --- a/packages/frontend/src/components/MkStreamingNotesTimeline.vue +++ b/packages/frontend/src/components/MkStreamingNotesTimeline.vue @@ -75,6 +75,7 @@ import { i18n } from '@/i18n.js'; import { globalEvents, useGlobalEvent } from '@/events.js'; import { isSeparatorNeeded, getSeparatorInfo } from '@/utility/timeline-date-separate.js'; import { Paginator } from '@/utility/paginator.js'; +import type { IPaginator, MisskeyEntity } from '@/utility/paginator.js'; const props = withDefaults(defineProps<{ src: BasicTimelineType | 'mentions' | 'directs' | 'list' | 'antenna' | 'channel' | 'role'; @@ -101,12 +102,12 @@ provide('inTimeline', true); provide('tl_withSensitive', computed(() => props.withSensitive)); provide('inChannel', computed(() => props.src === 'channel')); -let paginator: Paginator; +let paginator: IPaginator<Misskey.entities.Note>; if (props.src === 'antenna') { paginator = markRaw(new Paginator('antennas/notes', { computedParams: computed(() => ({ - antennaId: props.antenna, + antennaId: props.antenna!, })), useShallowRef: true, })); @@ -160,21 +161,21 @@ if (props.src === 'antenna') { computedParams: computed(() => ({ withRenotes: props.withRenotes, withFiles: props.onlyFiles ? true : undefined, - listId: props.list, + listId: props.list!, })), useShallowRef: true, })); } else if (props.src === 'channel') { paginator = markRaw(new Paginator('channels/timeline', { computedParams: computed(() => ({ - channelId: props.channel, + channelId: props.channel!, })), useShallowRef: true, })); } else if (props.src === 'role') { paginator = markRaw(new Paginator('roles/notes', { computedParams: computed(() => ({ - roleId: props.role, + roleId: props.role!, })), useShallowRef: true, })); @@ -259,7 +260,7 @@ function releaseQueue() { scrollToTop(rootEl.value); } -function prepend(note: Misskey.entities.Note) { +function prepend(note: Misskey.entities.Note & MisskeyEntity) { adInsertionCounter++; if (instance.notesPerOneAd > 0 && adInsertionCounter % instance.notesPerOneAd === 0) { @@ -281,12 +282,13 @@ function prepend(note: Misskey.entities.Note) { } } -let connection: Misskey.ChannelConnection | null = null; -let connection2: Misskey.ChannelConnection | null = null; +let connection: Misskey.IChannelConnection | null = null; +let connection2: Misskey.IChannelConnection | null = null; const stream = store.s.realtimeMode ? useStream() : null; function connectChannel() { + if (stream == null) return; if (props.src === 'antenna') { if (props.antenna == null) return; connection = stream.useChannel('antenna', { diff --git a/packages/frontend/src/components/MkStreamingNotificationsTimeline.vue b/packages/frontend/src/components/MkStreamingNotificationsTimeline.vue index 04b230277c..869d848d90 100644 --- a/packages/frontend/src/components/MkStreamingNotificationsTimeline.vue +++ b/packages/frontend/src/components/MkStreamingNotificationsTimeline.vue @@ -109,7 +109,7 @@ function reload() { return paginator.reload(); } -let connection: Misskey.ChannelConnection<Misskey.Channels['main']> | null = null; +let connection: Misskey.IChannelConnection<Misskey.Channels['main']> | null = null; onMounted(() => { paginator.init(); diff --git a/packages/frontend/src/components/MkUserList.vue b/packages/frontend/src/components/MkUserList.vue index 761e1cdd04..c639e18b1d 100644 --- a/packages/frontend/src/components/MkUserList.vue +++ b/packages/frontend/src/components/MkUserList.vue @@ -16,15 +16,16 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import type { Paginator } from '@/utility/paginator.js'; +import * as Misskey from 'misskey-js'; +import type { IPaginator } from '@/utility/paginator.js'; import MkUserInfo from '@/components/MkUserInfo.vue'; import MkPagination from '@/components/MkPagination.vue'; import { i18n } from '@/i18n.js'; const props = withDefaults(defineProps<{ - paginator: Paginator; + paginator: IPaginator; noGap?: boolean; - extractor?: (item: any) => any; + extractor?: (item: any) => Misskey.entities.UserDetailed; }>(), { extractor: (item) => item, }); diff --git a/packages/frontend/src/components/MkUserSetupDialog.Follow.vue b/packages/frontend/src/components/MkUserSetupDialog.Follow.vue index 02171a123d..c853eb5054 100644 --- a/packages/frontend/src/components/MkUserSetupDialog.Follow.vue +++ b/packages/frontend/src/components/MkUserSetupDialog.Follow.vue @@ -13,7 +13,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkPagination :paginator="pinnedUsersPaginator"> <template #default="{ items }"> <div :class="$style.users"> - <XUser v-for="item in (items as Misskey.entities.UserDetailed[])" :key="item.id" :user="item"/> + <XUser v-for="item in items" :key="item.id" :user="item"/> </div> </template> </MkPagination> @@ -25,7 +25,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkPagination :paginator="popularUsersPaginator"> <template #default="{ items }"> <div :class="$style.users"> - <XUser v-for="item in (items as Misskey.entities.UserDetailed[])" :key="item.id" :user="item"/> + <XUser v-for="item in items" :key="item.id" :user="item"/> </div> </template> </MkPagination> @@ -34,7 +34,6 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import * as Misskey from 'misskey-js'; import { markRaw } from 'vue'; import { i18n } from '@/i18n.js'; import MkFolder from '@/components/MkFolder.vue'; diff --git a/packages/frontend/src/pages/admin/files.vue b/packages/frontend/src/pages/admin/files.vue index 0f3a90b458..b4ec930997 100644 --- a/packages/frontend/src/pages/admin/files.vue +++ b/packages/frontend/src/pages/admin/files.vue @@ -14,7 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only <option value="local">{{ i18n.ts.local }}</option> <option value="remote">{{ i18n.ts.remote }}</option> </MkSelect> - <MkInput v-model="searchHost" :debounce="true" type="search" style="margin: 0; flex: 1;" :disabled="paginator.computedParams.value.origin === 'local'"> + <MkInput v-model="searchHost" :debounce="true" type="search" style="margin: 0; flex: 1;" :disabled="paginator.computedParams?.value?.origin === 'local'"> <template #label>{{ i18n.ts.host }}</template> </MkInput> </div> @@ -44,7 +44,7 @@ import { i18n } from '@/i18n.js'; import { definePage } from '@/page.js'; import { Paginator } from '@/utility/paginator.js'; -const origin = ref<Misskey.entities.AdminDriveFilesRequest['origin']>('local'); +const origin = ref<NonNullable<Misskey.entities.AdminDriveFilesRequest['origin']>>('local'); const type = ref<string | null>(null); const searchHost = ref(''); const userId = ref(''); diff --git a/packages/frontend/src/pages/admin/invites.vue b/packages/frontend/src/pages/admin/invites.vue index e1b4890513..1c551cb477 100644 --- a/packages/frontend/src/pages/admin/invites.vue +++ b/packages/frontend/src/pages/admin/invites.vue @@ -44,7 +44,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkPagination :paginator="paginator"> <template #default="{ items }"> <div class="_gaps_s"> - <MkInviteCode v-for="item in items" :key="item.id" :invite="(item as any)" :onDeleted="deleted" moderator/> + <MkInviteCode v-for="item in items" :key="item.id" :invite="item" :onDeleted="deleted" moderator/> </div> </template> </MkPagination> @@ -54,6 +54,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> +import * as Misskey from 'misskey-js'; import { computed, markRaw, ref, useTemplateRef } from 'vue'; import { i18n } from '@/i18n.js'; import * as os from '@/os.js'; @@ -68,8 +69,8 @@ import MkInviteCode from '@/components/MkInviteCode.vue'; import { definePage } from '@/page.js'; import { Paginator } from '@/utility/paginator.js'; -const type = ref('all'); -const sort = ref('+createdAt'); +const type = ref<NonNullable<Misskey.entities.AdminInviteListRequest['type']>>('all'); +const sort = ref<NonNullable<Misskey.entities.AdminInviteListRequest['sort']>>('+createdAt'); const paginator = markRaw(new Paginator('admin/invite/list', { limit: 10, diff --git a/packages/frontend/src/pages/admin/roles.role.vue b/packages/frontend/src/pages/admin/roles.role.vue index 64b6231398..1816aec21e 100644 --- a/packages/frontend/src/pages/admin/roles.role.vue +++ b/packages/frontend/src/pages/admin/roles.role.vue @@ -28,7 +28,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template #default="{ items }"> <div class="_gaps_s"> - <div v-for="item in items" :key="item.user.id" :class="[$style.userItem, { [$style.userItemOpend]: expandedItems.includes(item.id) }]"> + <div v-for="item in items" :key="item.user.id" :class="[$style.userItem, { [$style.userItemOpened]: expandedItems.includes(item.id) }]"> <div :class="$style.userItemMain"> <MkA :class="$style.userItemMainBody" :to="`/admin/user/${item.user.id}`"> <MkUserCardMini :user="item.user"/> @@ -76,12 +76,12 @@ const props = defineProps<{ const usersPaginator = markRaw(new Paginator('admin/roles/users', { limit: 20, - computedParams: computed(() => ({ + computedParams: computed(() => props.id ? ({ roleId: props.id, - })), + }) : undefined), })); -const expandedItems = ref([]); +const expandedItems = ref<string[]>([]); const role = reactive(await misskeyApi('admin/roles/show', { roleId: props.id, @@ -199,7 +199,7 @@ definePage(() => ({ transition: transform 0.1s ease-out; } -.userItem.userItemOpend { +.userItem.userItemOpened { .chevron { transform: rotateX(180deg); } diff --git a/packages/frontend/src/pages/admin/users.vue b/packages/frontend/src/pages/admin/users.vue index 581eb7eb97..7cbaeba8c7 100644 --- a/packages/frontend/src/pages/admin/users.vue +++ b/packages/frontend/src/pages/admin/users.vue @@ -38,7 +38,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template #prefix>@</template> <template #label>{{ i18n.ts.username }}</template> </MkInput> - <MkInput v-model="searchHost" style="flex: 1;" type="text" :spellcheck="false" :disabled="paginator.computedParams.value.origin === 'local'"> + <MkInput v-model="searchHost" style="flex: 1;" type="text" :spellcheck="false" :disabled="paginator.computedParams?.value?.origin === 'local'"> <template #prefix>@</template> <template #label>{{ i18n.ts.host }}</template> </MkInput> @@ -46,7 +46,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkPagination v-slot="{items}" :paginator="paginator"> <div :class="$style.users"> - <MkA v-for="user in items" :key="user.id" v-tooltip.mfm="`Last posted: ${dateString(user.updatedAt)}`" :class="$style.user" :to="`/admin/user/${user.id}`"> + <MkA v-for="user in items" :key="user.id" v-tooltip.mfm="`Last posted: ${user.updatedAt ? dateString(user.updatedAt) : 'Unknown'}`" :class="$style.user" :to="`/admin/user/${user.id}`"> <MkUserCardMini :user="user"/> </MkA> </div> diff --git a/packages/frontend/src/pages/invite.vue b/packages/frontend/src/pages/invite.vue index d69b7984c0..3b8929aeb4 100644 --- a/packages/frontend/src/pages/invite.vue +++ b/packages/frontend/src/pages/invite.vue @@ -17,7 +17,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkPagination :paginator="paginator"> <template #default="{ items }"> <div class="_gaps_s"> - <MkInviteCode v-for="item in (items as Misskey.entities.InviteCode[])" :key="item.id" :invite="item" :onDeleted="deleted"/> + <MkInviteCode v-for="item in items" :key="item.id" :invite="item" :onDeleted="deleted"/> </div> </template> </MkPagination> diff --git a/packages/frontend/src/pages/note.vue b/packages/frontend/src/pages/note.vue index b128cf5312..9de1ef099b 100644 --- a/packages/frontend/src/pages/note.vue +++ b/packages/frontend/src/pages/note.vue @@ -100,7 +100,7 @@ const prevChannelPaginator = markRaw(new Paginator('channels/timeline', { limit: 10, initialId: props.noteId, initialDirection: 'older', - computedParams: computed(() => note.value ? ({ + computedParams: computed(() => note.value && note.value.channelId != null ? ({ channelId: note.value.channelId, }) : undefined), })); @@ -109,7 +109,7 @@ const nextChannelPaginator = markRaw(new Paginator('channels/timeline', { limit: 10, initialId: props.noteId, initialDirection: 'newer', - computedParams: computed(() => note.value ? ({ + computedParams: computed(() => note.value && note.value.channelId != null ? ({ channelId: note.value.channelId, }) : undefined), })); diff --git a/packages/frontend/src/pages/page.vue b/packages/frontend/src/pages/page.vue index 99c147c8cf..cd63e51fd5 100644 --- a/packages/frontend/src/pages/page.vue +++ b/packages/frontend/src/pages/page.vue @@ -135,9 +135,9 @@ const page = ref<Misskey.entities.Page | null>(null); const error = ref<any>(null); const otherPostsPaginator = markRaw(new Paginator('users/pages', { limit: 6, - computedParams: computed(() => ({ + computedParams: computed(() => page.value ? ({ userId: page.value.user.id, - })), + }) : undefined), })); const path = computed(() => props.username + '/' + props.pageName); diff --git a/packages/frontend/src/pages/settings/drive-cleaner.vue b/packages/frontend/src/pages/settings/drive-cleaner.vue index 24f0fa5153..63b3c95233 100644 --- a/packages/frontend/src/pages/settings/drive-cleaner.vue +++ b/packages/frontend/src/pages/settings/drive-cleaner.vue @@ -48,6 +48,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script setup lang="ts"> +import * as Misskey from 'misskey-js'; import { computed, markRaw, ref, watch } from 'vue'; import tinycolor from 'tinycolor2'; import type { StyleValue } from 'vue'; @@ -62,7 +63,7 @@ import MkSelect from '@/components/MkSelect.vue'; import { getDriveFileMenu } from '@/utility/get-drive-file-menu.js'; import { Paginator } from '@/utility/paginator.js'; -const sortMode = ref('+size'); +const sortMode = ref<Misskey.entities.DriveFilesRequest['sort']>('+size'); const paginator = markRaw(new Paginator('drive/files', { limit: 10, computedParams: computed(() => ({ sort: sortMode.value })), diff --git a/packages/frontend/src/pages/settings/mute-block.vue b/packages/frontend/src/pages/settings/mute-block.vue index 3002cd0e89..6ca313da81 100644 --- a/packages/frontend/src/pages/settings/mute-block.vue +++ b/packages/frontend/src/pages/settings/mute-block.vue @@ -208,9 +208,9 @@ const blockingPaginator = markRaw(new Paginator('blocking/list', { limit: 10, })); -const expandedRenoteMuteItems = ref([]); -const expandedMuteItems = ref([]); -const expandedBlockItems = ref([]); +const expandedRenoteMuteItems = ref<string[]>([]); +const expandedMuteItems = ref<string[]>([]); +const expandedBlockItems = ref<string[]>([]); const showSoftWordMutedWord = prefer.model('showSoftWordMutedWord'); @@ -253,7 +253,7 @@ async function unblock(user, ev) { }], ev.currentTarget ?? ev.target); } -async function toggleRenoteMuteItem(item) { +async function toggleRenoteMuteItem(item: { id: string }) { if (expandedRenoteMuteItems.value.includes(item.id)) { expandedRenoteMuteItems.value = expandedRenoteMuteItems.value.filter(x => x !== item.id); } else { @@ -261,7 +261,7 @@ async function toggleRenoteMuteItem(item) { } } -async function toggleMuteItem(item) { +async function toggleMuteItem(item: { id: string }) { if (expandedMuteItems.value.includes(item.id)) { expandedMuteItems.value = expandedMuteItems.value.filter(x => x !== item.id); } else { @@ -269,7 +269,7 @@ async function toggleMuteItem(item) { } } -async function toggleBlockItem(item) { +async function toggleBlockItem(item: { id: string }) { if (expandedBlockItems.value.includes(item.id)) { expandedBlockItems.value = expandedBlockItems.value.filter(x => x !== item.id); } else { diff --git a/packages/frontend/src/utility/paginator.ts b/packages/frontend/src/utility/paginator.ts index eb805df7a8..63525b311a 100644 --- a/packages/frontend/src/utility/paginator.ts +++ b/packages/frontend/src/utility/paginator.ts @@ -5,7 +5,7 @@ import { ref, shallowRef, triggerRef } from 'vue'; import * as Misskey from 'misskey-js'; -import type { ComputedRef, DeepReadonly, Ref, ShallowRef } from 'vue'; +import type { ComputedRef, Ref, ShallowRef } from 'vue'; import { misskeyApi } from '@/utility/misskey-api.js'; const MAX_ITEMS = 30; @@ -17,14 +17,60 @@ export type MisskeyEntity = { id: string; createdAt: string; _shouldInsertAd_?: boolean; - [x: string]: any; }; -export class Paginator<Endpoint extends keyof Misskey.Endpoints = keyof Misskey.Endpoints, T extends { id: string; } = (Misskey.Endpoints[Endpoint]['res'] extends (infer I)[] ? I extends { id: string } ? I : { id: string } : { id: string })> { +type FilterByEpRes<E extends Record<string, any>> = { + [K in keyof E]: E[K]['res'] extends Array<{ id: string }> ? K : never +}[keyof E]; +export type PaginatorCompatibleEndpointPaths = FilterByEpRes<Misskey.Endpoints>; +export type PaginatorCompatibleEndpoints = { + [K in PaginatorCompatibleEndpointPaths]: Misskey.Endpoints[K]; +}; + +export interface IPaginator<T = unknown, _T = T & MisskeyEntity> { + /** + * 外部から直接操作しないでください + */ + items: Ref<_T[]> | ShallowRef<_T[]>; + queuedAheadItemsCount: Ref<number>; + fetching: Ref<boolean>; + fetchingOlder: Ref<boolean>; + fetchingNewer: Ref<boolean>; + canFetchOlder: Ref<boolean>; + canSearch: boolean; + error: Ref<boolean>; + computedParams: ComputedRef<Misskey.Endpoints[PaginatorCompatibleEndpointPaths]['req'] | null | undefined> | null; + initialId: MisskeyEntity['id'] | null; + initialDate: number | null; + initialDirection: 'newer' | 'older'; + noPaging: boolean; + searchQuery: Ref<null | string>; + order: Ref<'newest' | 'oldest'>; + + init(): Promise<void>; + reload(): Promise<void>; + fetchOlder(): Promise<void>; + fetchNewer(options?: { toQueue?: boolean }): Promise<void>; + trim(trigger?: boolean): void; + unshiftItems(newItems: (_T)[]): void; + pushItems(oldItems: (_T)[]): void; + prepend(item: _T): void; + enqueue(item: _T): void; + releaseQueue(): void; + removeItem(id: string): void; + updateItem(id: string, updater: (item: _T) => _T): void; +} + +export class Paginator< + Endpoint extends PaginatorCompatibleEndpointPaths, + E extends PaginatorCompatibleEndpoints[Endpoint] = PaginatorCompatibleEndpoints[Endpoint], + T extends E['res'][number] & MisskeyEntity = E['res'][number] & MisskeyEntity, + SRef extends boolean = false, +> implements IPaginator { /** * 外部から直接操作しないでください */ - public items: ShallowRef<T[]> | Ref<T[]>; + public items: SRef extends true ? ShallowRef<T[]> : Ref<T[]>; public queuedAheadItemsCount = ref(0); public fetching = ref(true); @@ -35,18 +81,18 @@ export class Paginator<Endpoint extends keyof Misskey.Endpoints = keyof Misskey. public error = ref(false); private endpoint: Endpoint; private limit: number; - private params: Misskey.Endpoints[Endpoint]['req'] | (() => Misskey.Endpoints[Endpoint]['req']); - public computedParams: ComputedRef<Misskey.Endpoints[Endpoint]['req']> | null; + private params: E['req'] | (() => E['req']); + public computedParams: ComputedRef<E['req'] | null | undefined> | null; public initialId: MisskeyEntity['id'] | null = null; public initialDate: number | null = null; public initialDirection: 'newer' | 'older'; private offsetMode: boolean; public noPaging: boolean; public searchQuery = ref<null | string>(''); - private searchParamName: string; + private searchParamName: keyof E['req'] | 'search'; private canFetchDetection: 'safe' | 'limit' | null = null; private aheadQueue: T[] = []; - private useShallowRef: boolean; + private useShallowRef: SRef; // 配列内の要素をどのような順序で並べるか // newest: 新しいものが先頭 (default) @@ -56,8 +102,8 @@ export class Paginator<Endpoint extends keyof Misskey.Endpoints = keyof Misskey. constructor(endpoint: Endpoint, props: { limit?: number; - params?: Misskey.Endpoints[Endpoint]['req'] | (() => Misskey.Endpoints[Endpoint]['req']); - computedParams?: ComputedRef<Misskey.Endpoints[Endpoint]['req']>; + params?: E['req'] | (() => E['req']); + computedParams?: ComputedRef<E['req'] | null | undefined>; /** * 検索APIのような、ページング不可なエンドポイントを利用する場合 @@ -75,14 +121,19 @@ export class Paginator<Endpoint extends keyof Misskey.Endpoints = keyof Misskey. // 一部のAPIはさらに遡れる場合でもパフォーマンス上の理由でlimit以下の結果を返す場合があり、その場合はsafe、それ以外はlimitにすることを推奨 canFetchDetection?: 'safe' | 'limit'; - useShallowRef?: boolean; + useShallowRef?: SRef; canSearch?: boolean; - searchParamName?: keyof Misskey.Endpoints[Endpoint]['req']; + searchParamName?: keyof E['req']; }) { this.endpoint = endpoint; - this.useShallowRef = props.useShallowRef ?? false; - this.items = this.useShallowRef ? shallowRef([] as T[]) : ref([] as T[]); + this.useShallowRef = (props.useShallowRef ?? false) as SRef; + if (this.useShallowRef) { + this.items = shallowRef<T[]>([]); + } else { + this.items = ref<T[]>([]) as Ref<T[]>; + } + this.limit = props.limit ?? FIRST_FETCH_LIMIT; this.params = props.params ?? {}; this.computedParams = props.computedParams ?? null; @@ -130,7 +181,7 @@ export class Paginator<Endpoint extends keyof Misskey.Endpoints = keyof Misskey. this.queuedAheadItemsCount.value = 0; this.fetching.value = true; - await misskeyApi<T[]>(this.endpoint, { + const data: E['req'] = { ...(typeof this.params === 'function' ? this.params() : this.params), ...(this.computedParams ? this.computedParams.value : {}), ...(this.searchQuery.value != null && this.searchQuery.value.trim() !== '' ? { [this.searchParamName]: this.searchQuery.value } : {}), @@ -145,39 +196,46 @@ export class Paginator<Endpoint extends keyof Misskey.Endpoints = keyof Misskey. untilId: this.initialId ?? undefined, untilDate: this.initialDate ?? undefined, } : {}), - }).then(res => { - // 逆順で返ってくるので - if ((this.initialId || this.initialDate) && this.initialDirection === 'newer') { - res.reverse(); - } + }; - for (let i = 0; i < res.length; i++) { - const item = res[i]; - if (i === 3) item._shouldInsertAd_ = true; - } + const apiRes = (await misskeyApi(this.endpoint, data).catch(err => { + this.error.value = true; + this.fetching.value = false; + return null; + })) as T[] | null; + + if (apiRes == null) { + return; + } - this.pushItems(res); + // 逆順で返ってくるので + if ((this.initialId || this.initialDate) && this.initialDirection === 'newer') { + apiRes.reverse(); + } - if (this.canFetchDetection === 'limit') { - if (res.length < FIRST_FETCH_LIMIT) { - this.canFetchOlder.value = false; - } else { - this.canFetchOlder.value = true; - } - } else if (this.canFetchDetection === 'safe' || this.canFetchDetection == null) { - if (res.length === 0 || this.noPaging) { - this.canFetchOlder.value = false; - } else { - this.canFetchOlder.value = true; - } + for (let i = 0; i < apiRes.length; i++) { + const item = apiRes[i]; + if (i === 3) item._shouldInsertAd_ = true; + } + + this.pushItems(apiRes); + + if (this.canFetchDetection === 'limit') { + if (apiRes.length < FIRST_FETCH_LIMIT) { + this.canFetchOlder.value = false; + } else { + this.canFetchOlder.value = true; + } + } else if (this.canFetchDetection === 'safe' || this.canFetchDetection == null) { + if (apiRes.length === 0 || this.noPaging) { + this.canFetchOlder.value = false; + } else { + this.canFetchOlder.value = true; } + } - this.error.value = false; - this.fetching.value = false; - }, err => { - this.error.value = true; - this.fetching.value = false; - }); + this.error.value = false; + this.fetching.value = false; } public reload(): Promise<void> { @@ -187,7 +245,8 @@ export class Paginator<Endpoint extends keyof Misskey.Endpoints = keyof Misskey. public async fetchOlder(): Promise<void> { if (!this.canFetchOlder.value || this.fetching.value || this.fetchingOlder.value || this.items.value.length === 0) return; this.fetchingOlder.value = true; - await misskeyApi<T[]>(this.endpoint, { + + const data: E['req'] = { ...(typeof this.params === 'function' ? this.params() : this.params), ...(this.computedParams ? this.computedParams.value : {}), ...(this.searchQuery.value != null && this.searchQuery.value.trim() !== '' ? { [this.searchParamName]: this.searchQuery.value } : {}), @@ -197,37 +256,46 @@ export class Paginator<Endpoint extends keyof Misskey.Endpoints = keyof Misskey. } : { untilId: this.getOldestId(), }), - }).then(res => { - for (let i = 0; i < res.length; i++) { - const item = res[i]; - if (i === 10) item._shouldInsertAd_ = true; - } + }; + + const apiRes = (await misskeyApi<T[]>(this.endpoint, data).catch(err => { + return null; + })) as T[] | null; + + this.fetchingOlder.value = false; + + if (apiRes == null) { + return; + } + + for (let i = 0; i < apiRes.length; i++) { + const item = apiRes[i]; + if (i === 10) item._shouldInsertAd_ = true; + } - this.pushItems(res); + this.pushItems(apiRes); - if (this.canFetchDetection === 'limit') { - if (res.length < FIRST_FETCH_LIMIT) { - this.canFetchOlder.value = false; - } else { - this.canFetchOlder.value = true; - } - } else if (this.canFetchDetection === 'safe' || this.canFetchDetection == null) { - if (res.length === 0) { - this.canFetchOlder.value = false; - } else { - this.canFetchOlder.value = true; - } + if (this.canFetchDetection === 'limit') { + if (apiRes.length < FIRST_FETCH_LIMIT) { + this.canFetchOlder.value = false; + } else { + this.canFetchOlder.value = true; + } + } else if (this.canFetchDetection === 'safe' || this.canFetchDetection == null) { + if (apiRes.length === 0) { + this.canFetchOlder.value = false; + } else { + this.canFetchOlder.value = true; } - }).finally(() => { - this.fetchingOlder.value = false; - }); + } } public async fetchNewer(options: { toQueue?: boolean; } = {}): Promise<void> { this.fetchingNewer.value = true; - await misskeyApi<T[]>(this.endpoint, { + + const data: E['req'] = { ...(typeof this.params === 'function' ? this.params() : this.params), ...(this.computedParams ? this.computedParams.value : {}), ...(this.searchQuery.value != null && this.searchQuery.value.trim() !== '' ? { [this.searchParamName]: this.searchQuery.value } : {}), @@ -237,25 +305,29 @@ export class Paginator<Endpoint extends keyof Misskey.Endpoints = keyof Misskey. } : { sinceId: this.getNewestId(), }), - }).then(res => { - if (res.length === 0) return; // これやらないと余計なre-renderが走る + }; - if (options.toQueue) { - this.aheadQueue.unshift(...res.toReversed()); - if (this.aheadQueue.length > MAX_QUEUE_ITEMS) { - this.aheadQueue = this.aheadQueue.slice(0, MAX_QUEUE_ITEMS); - } - this.queuedAheadItemsCount.value = this.aheadQueue.length; + const apiRes = (await misskeyApi<T[]>(this.endpoint, data).catch(err => { + return null; + })) as T[] | null; + + this.fetchingNewer.value = false; + + if (apiRes == null || apiRes.length === 0) return; // これやらないと余計なre-renderが走る + + if (options.toQueue) { + this.aheadQueue.unshift(...apiRes.toReversed()); + if (this.aheadQueue.length > MAX_QUEUE_ITEMS) { + this.aheadQueue = this.aheadQueue.slice(0, MAX_QUEUE_ITEMS); + } + this.queuedAheadItemsCount.value = this.aheadQueue.length; + } else { + if (this.order.value === 'oldest') { + this.pushItems(apiRes); } else { - if (this.order.value === 'oldest') { - this.pushItems(res); - } else { - this.unshiftItems(res.toReversed()); - } + this.unshiftItems(apiRes.toReversed()); } - }).finally(() => { - this.fetchingNewer.value = false; - }); + } } public trim(trigger = true): void { @@ -309,13 +381,13 @@ export class Paginator<Endpoint extends keyof Misskey.Endpoints = keyof Misskey. } } - public updateItem(id: string, updator: (item: T) => T): void { + public updateItem(id: string, updater: (item: T) => T): void { // TODO: queueのも更新 const index = this.items.value.findIndex(x => x.id === id); if (index !== -1) { const item = this.items.value[index]!; - this.items.value[index] = updator(item); + this.items.value[index] = updater(item); if (this.useShallowRef) triggerRef(this.items); } } |