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>
This commit is contained in:
かっこかり 2025-07-03 11:20:26 +09:00 committed by GitHub
parent c48acad04b
commit 09a5e4b10a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 216 additions and 138 deletions

View File

@ -14,15 +14,16 @@ SPDX-License-Identifier: AGPL-3.0-only
</template> </template>
<script lang="ts" setup> <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 MkChannelPreview from '@/components/MkChannelPreview.vue';
import MkPagination from '@/components/MkPagination.vue'; import MkPagination from '@/components/MkPagination.vue';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
paginator: Paginator; paginator: IPaginator;
noGap?: boolean; noGap?: boolean;
extractor?: (item: any) => any; extractor?: (item: any) => Misskey.entities.Channel;
}>(), { }>(), {
extractor: (item) => item, extractor: (item) => item,
}); });

View File

@ -31,8 +31,9 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkPagination> </MkPagination>
</template> </template>
<script lang="ts" setup generic="T extends Paginator"> <script lang="ts" setup generic="T extends IPaginator<Misskey.entities.Note>">
import type { Paginator } from '@/utility/paginator.js'; import * as Misskey from 'misskey-js';
import type { IPaginator } from '@/utility/paginator.js';
import MkNote from '@/components/MkNote.vue'; import MkNote from '@/components/MkNote.vue';
import MkPagination from '@/components/MkPagination.vue'; import MkPagination from '@/components/MkPagination.vue';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';

View File

@ -25,7 +25,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div> </div>
<div v-else key="_root_" class="_gaps"> <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'"> <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()"> <MkButton v-if="!paginator.fetchingNewer.value" :class="$style.more" :wait="paginator.fetchingNewer.value" primary rounded @click="paginator.fetchNewer()">
{{ i18n.ts.loadMore }} {{ i18n.ts.loadMore }}
@ -44,11 +44,11 @@ SPDX-License-Identifier: AGPL-3.0-only
</component> </component>
</template> </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 { isLink } from '@@/js/is-link.js';
import { onMounted, watch } from 'vue'; import { onMounted, watch, unref } from 'vue';
import type { UnwrapRef } 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 MkButton from '@/components/MkButton.vue';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { prefer } from '@/preferences.js'; import { prefer } from '@/preferences.js';
@ -95,7 +95,7 @@ if (props.paginator.computedParams) {
defineSlots<{ defineSlots<{
empty: () => void; empty: () => void;
default: (props: { items: I }) => void; default: (props: { items: UnwrapRef<T['items']> }) => void;
}>(); }>();
</script> </script>

View File

@ -37,9 +37,9 @@ SPDX-License-Identifier: AGPL-3.0-only
</div> </div>
</template> </template>
<script lang="ts" setup generic="T extends Paginator"> <script lang="ts" setup generic="T extends IPaginator">
import { ref, watch } from 'vue'; 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 MkButton from '@/components/MkButton.vue';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import MkSelect from '@/components/MkSelect.vue'; import MkSelect from '@/components/MkSelect.vue';

View File

@ -75,6 +75,7 @@ import { i18n } from '@/i18n.js';
import { globalEvents, useGlobalEvent } from '@/events.js'; import { globalEvents, useGlobalEvent } from '@/events.js';
import { isSeparatorNeeded, getSeparatorInfo } from '@/utility/timeline-date-separate.js'; import { isSeparatorNeeded, getSeparatorInfo } from '@/utility/timeline-date-separate.js';
import { Paginator } from '@/utility/paginator.js'; import { Paginator } from '@/utility/paginator.js';
import type { IPaginator, MisskeyEntity } from '@/utility/paginator.js';
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
src: BasicTimelineType | 'mentions' | 'directs' | 'list' | 'antenna' | 'channel' | 'role'; src: BasicTimelineType | 'mentions' | 'directs' | 'list' | 'antenna' | 'channel' | 'role';
@ -101,12 +102,12 @@ provide('inTimeline', true);
provide('tl_withSensitive', computed(() => props.withSensitive)); provide('tl_withSensitive', computed(() => props.withSensitive));
provide('inChannel', computed(() => props.src === 'channel')); provide('inChannel', computed(() => props.src === 'channel'));
let paginator: Paginator; let paginator: IPaginator<Misskey.entities.Note>;
if (props.src === 'antenna') { if (props.src === 'antenna') {
paginator = markRaw(new Paginator('antennas/notes', { paginator = markRaw(new Paginator('antennas/notes', {
computedParams: computed(() => ({ computedParams: computed(() => ({
antennaId: props.antenna, antennaId: props.antenna!,
})), })),
useShallowRef: true, useShallowRef: true,
})); }));
@ -160,21 +161,21 @@ if (props.src === 'antenna') {
computedParams: computed(() => ({ computedParams: computed(() => ({
withRenotes: props.withRenotes, withRenotes: props.withRenotes,
withFiles: props.onlyFiles ? true : undefined, withFiles: props.onlyFiles ? true : undefined,
listId: props.list, listId: props.list!,
})), })),
useShallowRef: true, useShallowRef: true,
})); }));
} else if (props.src === 'channel') { } else if (props.src === 'channel') {
paginator = markRaw(new Paginator('channels/timeline', { paginator = markRaw(new Paginator('channels/timeline', {
computedParams: computed(() => ({ computedParams: computed(() => ({
channelId: props.channel, channelId: props.channel!,
})), })),
useShallowRef: true, useShallowRef: true,
})); }));
} else if (props.src === 'role') { } else if (props.src === 'role') {
paginator = markRaw(new Paginator('roles/notes', { paginator = markRaw(new Paginator('roles/notes', {
computedParams: computed(() => ({ computedParams: computed(() => ({
roleId: props.role, roleId: props.role!,
})), })),
useShallowRef: true, useShallowRef: true,
})); }));
@ -259,7 +260,7 @@ function releaseQueue() {
scrollToTop(rootEl.value); scrollToTop(rootEl.value);
} }
function prepend(note: Misskey.entities.Note) { function prepend(note: Misskey.entities.Note & MisskeyEntity) {
adInsertionCounter++; adInsertionCounter++;
if (instance.notesPerOneAd > 0 && adInsertionCounter % instance.notesPerOneAd === 0) { 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 connection: Misskey.IChannelConnection | null = null;
let connection2: Misskey.ChannelConnection | null = null; let connection2: Misskey.IChannelConnection | null = null;
const stream = store.s.realtimeMode ? useStream() : null; const stream = store.s.realtimeMode ? useStream() : null;
function connectChannel() { function connectChannel() {
if (stream == null) return;
if (props.src === 'antenna') { if (props.src === 'antenna') {
if (props.antenna == null) return; if (props.antenna == null) return;
connection = stream.useChannel('antenna', { connection = stream.useChannel('antenna', {

View File

@ -109,7 +109,7 @@ function reload() {
return paginator.reload(); return paginator.reload();
} }
let connection: Misskey.ChannelConnection<Misskey.Channels['main']> | null = null; let connection: Misskey.IChannelConnection<Misskey.Channels['main']> | null = null;
onMounted(() => { onMounted(() => {
paginator.init(); paginator.init();

View File

@ -16,15 +16,16 @@ SPDX-License-Identifier: AGPL-3.0-only
</template> </template>
<script lang="ts" setup> <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 MkUserInfo from '@/components/MkUserInfo.vue';
import MkPagination from '@/components/MkPagination.vue'; import MkPagination from '@/components/MkPagination.vue';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
paginator: Paginator; paginator: IPaginator;
noGap?: boolean; noGap?: boolean;
extractor?: (item: any) => any; extractor?: (item: any) => Misskey.entities.UserDetailed;
}>(), { }>(), {
extractor: (item) => item, extractor: (item) => item,
}); });

View File

@ -13,7 +13,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkPagination :paginator="pinnedUsersPaginator"> <MkPagination :paginator="pinnedUsersPaginator">
<template #default="{ items }"> <template #default="{ items }">
<div :class="$style.users"> <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> </div>
</template> </template>
</MkPagination> </MkPagination>
@ -25,7 +25,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkPagination :paginator="popularUsersPaginator"> <MkPagination :paginator="popularUsersPaginator">
<template #default="{ items }"> <template #default="{ items }">
<div :class="$style.users"> <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> </div>
</template> </template>
</MkPagination> </MkPagination>
@ -34,7 +34,6 @@ SPDX-License-Identifier: AGPL-3.0-only
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import * as Misskey from 'misskey-js';
import { markRaw } from 'vue'; import { markRaw } from 'vue';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import MkFolder from '@/components/MkFolder.vue'; import MkFolder from '@/components/MkFolder.vue';

View File

@ -14,7 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<option value="local">{{ i18n.ts.local }}</option> <option value="local">{{ i18n.ts.local }}</option>
<option value="remote">{{ i18n.ts.remote }}</option> <option value="remote">{{ i18n.ts.remote }}</option>
</MkSelect> </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> <template #label>{{ i18n.ts.host }}</template>
</MkInput> </MkInput>
</div> </div>
@ -44,7 +44,7 @@ import { i18n } from '@/i18n.js';
import { definePage } from '@/page.js'; import { definePage } from '@/page.js';
import { Paginator } from '@/utility/paginator.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 type = ref<string | null>(null);
const searchHost = ref(''); const searchHost = ref('');
const userId = ref(''); const userId = ref('');

View File

@ -44,7 +44,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkPagination :paginator="paginator"> <MkPagination :paginator="paginator">
<template #default="{ items }"> <template #default="{ items }">
<div class="_gaps_s"> <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> </div>
</template> </template>
</MkPagination> </MkPagination>
@ -54,6 +54,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import * as Misskey from 'misskey-js';
import { computed, markRaw, ref, useTemplateRef } from 'vue'; import { computed, markRaw, ref, useTemplateRef } from 'vue';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import * as os from '@/os.js'; import * as os from '@/os.js';
@ -68,8 +69,8 @@ import MkInviteCode from '@/components/MkInviteCode.vue';
import { definePage } from '@/page.js'; import { definePage } from '@/page.js';
import { Paginator } from '@/utility/paginator.js'; import { Paginator } from '@/utility/paginator.js';
const type = ref('all'); const type = ref<NonNullable<Misskey.entities.AdminInviteListRequest['type']>>('all');
const sort = ref('+createdAt'); const sort = ref<NonNullable<Misskey.entities.AdminInviteListRequest['sort']>>('+createdAt');
const paginator = markRaw(new Paginator('admin/invite/list', { const paginator = markRaw(new Paginator('admin/invite/list', {
limit: 10, limit: 10,

View File

@ -28,7 +28,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #default="{ items }"> <template #default="{ items }">
<div class="_gaps_s"> <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"> <div :class="$style.userItemMain">
<MkA :class="$style.userItemMainBody" :to="`/admin/user/${item.user.id}`"> <MkA :class="$style.userItemMainBody" :to="`/admin/user/${item.user.id}`">
<MkUserCardMini :user="item.user"/> <MkUserCardMini :user="item.user"/>
@ -76,12 +76,12 @@ const props = defineProps<{
const usersPaginator = markRaw(new Paginator('admin/roles/users', { const usersPaginator = markRaw(new Paginator('admin/roles/users', {
limit: 20, limit: 20,
computedParams: computed(() => ({ computedParams: computed(() => props.id ? ({
roleId: props.id, roleId: props.id,
})), }) : undefined),
})); }));
const expandedItems = ref([]); const expandedItems = ref<string[]>([]);
const role = reactive(await misskeyApi('admin/roles/show', { const role = reactive(await misskeyApi('admin/roles/show', {
roleId: props.id, roleId: props.id,
@ -199,7 +199,7 @@ definePage(() => ({
transition: transform 0.1s ease-out; transition: transform 0.1s ease-out;
} }
.userItem.userItemOpend { .userItem.userItemOpened {
.chevron { .chevron {
transform: rotateX(180deg); transform: rotateX(180deg);
} }

View File

@ -38,7 +38,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #prefix>@</template> <template #prefix>@</template>
<template #label>{{ i18n.ts.username }}</template> <template #label>{{ i18n.ts.username }}</template>
</MkInput> </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 #prefix>@</template>
<template #label>{{ i18n.ts.host }}</template> <template #label>{{ i18n.ts.host }}</template>
</MkInput> </MkInput>
@ -46,7 +46,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkPagination v-slot="{items}" :paginator="paginator"> <MkPagination v-slot="{items}" :paginator="paginator">
<div :class="$style.users"> <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"/> <MkUserCardMini :user="user"/>
</MkA> </MkA>
</div> </div>

View File

@ -17,7 +17,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkPagination :paginator="paginator"> <MkPagination :paginator="paginator">
<template #default="{ items }"> <template #default="{ items }">
<div class="_gaps_s"> <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> </div>
</template> </template>
</MkPagination> </MkPagination>

View File

@ -100,7 +100,7 @@ const prevChannelPaginator = markRaw(new Paginator('channels/timeline', {
limit: 10, limit: 10,
initialId: props.noteId, initialId: props.noteId,
initialDirection: 'older', initialDirection: 'older',
computedParams: computed(() => note.value ? ({ computedParams: computed(() => note.value && note.value.channelId != null ? ({
channelId: note.value.channelId, channelId: note.value.channelId,
}) : undefined), }) : undefined),
})); }));
@ -109,7 +109,7 @@ const nextChannelPaginator = markRaw(new Paginator('channels/timeline', {
limit: 10, limit: 10,
initialId: props.noteId, initialId: props.noteId,
initialDirection: 'newer', initialDirection: 'newer',
computedParams: computed(() => note.value ? ({ computedParams: computed(() => note.value && note.value.channelId != null ? ({
channelId: note.value.channelId, channelId: note.value.channelId,
}) : undefined), }) : undefined),
})); }));

View File

@ -135,9 +135,9 @@ const page = ref<Misskey.entities.Page | null>(null);
const error = ref<any>(null); const error = ref<any>(null);
const otherPostsPaginator = markRaw(new Paginator('users/pages', { const otherPostsPaginator = markRaw(new Paginator('users/pages', {
limit: 6, limit: 6,
computedParams: computed(() => ({ computedParams: computed(() => page.value ? ({
userId: page.value.user.id, userId: page.value.user.id,
})), }) : undefined),
})); }));
const path = computed(() => props.username + '/' + props.pageName); const path = computed(() => props.username + '/' + props.pageName);

View File

@ -48,6 +48,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import * as Misskey from 'misskey-js';
import { computed, markRaw, ref, watch } from 'vue'; import { computed, markRaw, ref, watch } from 'vue';
import tinycolor from 'tinycolor2'; import tinycolor from 'tinycolor2';
import type { StyleValue } from 'vue'; 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 { getDriveFileMenu } from '@/utility/get-drive-file-menu.js';
import { Paginator } from '@/utility/paginator.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', { const paginator = markRaw(new Paginator('drive/files', {
limit: 10, limit: 10,
computedParams: computed(() => ({ sort: sortMode.value })), computedParams: computed(() => ({ sort: sortMode.value })),

View File

@ -208,9 +208,9 @@ const blockingPaginator = markRaw(new Paginator('blocking/list', {
limit: 10, limit: 10,
})); }));
const expandedRenoteMuteItems = ref([]); const expandedRenoteMuteItems = ref<string[]>([]);
const expandedMuteItems = ref([]); const expandedMuteItems = ref<string[]>([]);
const expandedBlockItems = ref([]); const expandedBlockItems = ref<string[]>([]);
const showSoftWordMutedWord = prefer.model('showSoftWordMutedWord'); const showSoftWordMutedWord = prefer.model('showSoftWordMutedWord');
@ -253,7 +253,7 @@ async function unblock(user, ev) {
}], ev.currentTarget ?? ev.target); }], ev.currentTarget ?? ev.target);
} }
async function toggleRenoteMuteItem(item) { async function toggleRenoteMuteItem(item: { id: string }) {
if (expandedRenoteMuteItems.value.includes(item.id)) { if (expandedRenoteMuteItems.value.includes(item.id)) {
expandedRenoteMuteItems.value = expandedRenoteMuteItems.value.filter(x => x !== item.id); expandedRenoteMuteItems.value = expandedRenoteMuteItems.value.filter(x => x !== item.id);
} else { } 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)) { if (expandedMuteItems.value.includes(item.id)) {
expandedMuteItems.value = expandedMuteItems.value.filter(x => x !== item.id); expandedMuteItems.value = expandedMuteItems.value.filter(x => x !== item.id);
} else { } 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)) { if (expandedBlockItems.value.includes(item.id)) {
expandedBlockItems.value = expandedBlockItems.value.filter(x => x !== item.id); expandedBlockItems.value = expandedBlockItems.value.filter(x => x !== item.id);
} else { } else {

View File

@ -5,7 +5,7 @@
import { ref, shallowRef, triggerRef } from 'vue'; import { ref, shallowRef, triggerRef } from 'vue';
import * as Misskey from 'misskey-js'; 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'; import { misskeyApi } from '@/utility/misskey-api.js';
const MAX_ITEMS = 30; const MAX_ITEMS = 30;
@ -17,14 +17,60 @@ export type MisskeyEntity = {
id: string; id: string;
createdAt: string; createdAt: string;
_shouldInsertAd_?: boolean; _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> {
/** /**
* *
*/ */
public items: ShallowRef<T[]> | Ref<T[]>; 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: SRef extends true ? ShallowRef<T[]> : Ref<T[]>;
public queuedAheadItemsCount = ref(0); public queuedAheadItemsCount = ref(0);
public fetching = ref(true); public fetching = ref(true);
@ -35,18 +81,18 @@ export class Paginator<Endpoint extends keyof Misskey.Endpoints = keyof Misskey.
public error = ref(false); public error = ref(false);
private endpoint: Endpoint; private endpoint: Endpoint;
private limit: number; private limit: number;
private params: Misskey.Endpoints[Endpoint]['req'] | (() => Misskey.Endpoints[Endpoint]['req']); private params: E['req'] | (() => E['req']);
public computedParams: ComputedRef<Misskey.Endpoints[Endpoint]['req']> | null; public computedParams: ComputedRef<E['req'] | null | undefined> | null;
public initialId: MisskeyEntity['id'] | null = null; public initialId: MisskeyEntity['id'] | null = null;
public initialDate: number | null = null; public initialDate: number | null = null;
public initialDirection: 'newer' | 'older'; public initialDirection: 'newer' | 'older';
private offsetMode: boolean; private offsetMode: boolean;
public noPaging: boolean; public noPaging: boolean;
public searchQuery = ref<null | string>(''); public searchQuery = ref<null | string>('');
private searchParamName: string; private searchParamName: keyof E['req'] | 'search';
private canFetchDetection: 'safe' | 'limit' | null = null; private canFetchDetection: 'safe' | 'limit' | null = null;
private aheadQueue: T[] = []; private aheadQueue: T[] = [];
private useShallowRef: boolean; private useShallowRef: SRef;
// 配列内の要素をどのような順序で並べるか // 配列内の要素をどのような順序で並べるか
// newest: 新しいものが先頭 (default) // newest: 新しいものが先頭 (default)
@ -56,8 +102,8 @@ export class Paginator<Endpoint extends keyof Misskey.Endpoints = keyof Misskey.
constructor(endpoint: Endpoint, props: { constructor(endpoint: Endpoint, props: {
limit?: number; limit?: number;
params?: Misskey.Endpoints[Endpoint]['req'] | (() => Misskey.Endpoints[Endpoint]['req']); params?: E['req'] | (() => E['req']);
computedParams?: ComputedRef<Misskey.Endpoints[Endpoint]['req']>; computedParams?: ComputedRef<E['req'] | null | undefined>;
/** /**
* APIのような * APIのような
@ -75,14 +121,19 @@ export class Paginator<Endpoint extends keyof Misskey.Endpoints = keyof Misskey.
// 一部のAPIはさらに遡れる場合でもパフォーマンス上の理由でlimit以下の結果を返す場合があり、その場合はsafe、それ以外はlimitにすることを推奨 // 一部のAPIはさらに遡れる場合でもパフォーマンス上の理由でlimit以下の結果を返す場合があり、その場合はsafe、それ以外はlimitにすることを推奨
canFetchDetection?: 'safe' | 'limit'; canFetchDetection?: 'safe' | 'limit';
useShallowRef?: boolean; useShallowRef?: SRef;
canSearch?: boolean; canSearch?: boolean;
searchParamName?: keyof Misskey.Endpoints[Endpoint]['req']; searchParamName?: keyof E['req'];
}) { }) {
this.endpoint = endpoint; this.endpoint = endpoint;
this.useShallowRef = props.useShallowRef ?? false; this.useShallowRef = (props.useShallowRef ?? false) as SRef;
this.items = this.useShallowRef ? shallowRef([] as T[]) : ref([] as T[]); if (this.useShallowRef) {
this.items = shallowRef<T[]>([]);
} else {
this.items = ref<T[]>([]) as Ref<T[]>;
}
this.limit = props.limit ?? FIRST_FETCH_LIMIT; this.limit = props.limit ?? FIRST_FETCH_LIMIT;
this.params = props.params ?? {}; this.params = props.params ?? {};
this.computedParams = props.computedParams ?? null; this.computedParams = props.computedParams ?? null;
@ -130,7 +181,7 @@ export class Paginator<Endpoint extends keyof Misskey.Endpoints = keyof Misskey.
this.queuedAheadItemsCount.value = 0; this.queuedAheadItemsCount.value = 0;
this.fetching.value = true; this.fetching.value = true;
await misskeyApi<T[]>(this.endpoint, { const data: E['req'] = {
...(typeof this.params === 'function' ? this.params() : this.params), ...(typeof this.params === 'function' ? this.params() : this.params),
...(this.computedParams ? this.computedParams.value : {}), ...(this.computedParams ? this.computedParams.value : {}),
...(this.searchQuery.value != null && this.searchQuery.value.trim() !== '' ? { [this.searchParamName]: this.searchQuery.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, untilId: this.initialId ?? undefined,
untilDate: this.initialDate ?? 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 apiRes = (await misskeyApi(this.endpoint, data).catch(err => {
const item = res[i];
if (i === 3) item._shouldInsertAd_ = true;
}
this.pushItems(res);
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;
}
}
this.error.value = false;
this.fetching.value = false;
}, err => {
this.error.value = true; this.error.value = true;
this.fetching.value = false; this.fetching.value = false;
}); return null;
})) as T[] | null;
if (apiRes == null) {
return;
}
// 逆順で返ってくるので
if ((this.initialId || this.initialDate) && this.initialDirection === 'newer') {
apiRes.reverse();
}
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;
} }
public reload(): Promise<void> { public reload(): Promise<void> {
@ -187,7 +245,8 @@ export class Paginator<Endpoint extends keyof Misskey.Endpoints = keyof Misskey.
public async fetchOlder(): Promise<void> { public async fetchOlder(): Promise<void> {
if (!this.canFetchOlder.value || this.fetching.value || this.fetchingOlder.value || this.items.value.length === 0) return; if (!this.canFetchOlder.value || this.fetching.value || this.fetchingOlder.value || this.items.value.length === 0) return;
this.fetchingOlder.value = true; this.fetchingOlder.value = true;
await misskeyApi<T[]>(this.endpoint, {
const data: E['req'] = {
...(typeof this.params === 'function' ? this.params() : this.params), ...(typeof this.params === 'function' ? this.params() : this.params),
...(this.computedParams ? this.computedParams.value : {}), ...(this.computedParams ? this.computedParams.value : {}),
...(this.searchQuery.value != null && this.searchQuery.value.trim() !== '' ? { [this.searchParamName]: this.searchQuery.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(), untilId: this.getOldestId(),
}), }),
}).then(res => { };
for (let i = 0; i < res.length; i++) {
const item = res[i];
if (i === 10) item._shouldInsertAd_ = true;
}
this.pushItems(res); const apiRes = (await misskeyApi<T[]>(this.endpoint, data).catch(err => {
return null;
})) as T[] | null;
if (this.canFetchDetection === 'limit') { this.fetchingOlder.value = false;
if (res.length < FIRST_FETCH_LIMIT) {
this.canFetchOlder.value = false; if (apiRes == null) {
} else { return;
this.canFetchOlder.value = true; }
}
} else if (this.canFetchDetection === 'safe' || this.canFetchDetection == null) { for (let i = 0; i < apiRes.length; i++) {
if (res.length === 0) { const item = apiRes[i];
this.canFetchOlder.value = false; if (i === 10) item._shouldInsertAd_ = true;
} else { }
this.canFetchOlder.value = true;
} this.pushItems(apiRes);
if (this.canFetchDetection === 'limit') {
if (apiRes.length < FIRST_FETCH_LIMIT) {
this.canFetchOlder.value = false;
} else {
this.canFetchOlder.value = true;
} }
}).finally(() => { } else if (this.canFetchDetection === 'safe' || this.canFetchDetection == null) {
this.fetchingOlder.value = false; if (apiRes.length === 0) {
}); this.canFetchOlder.value = false;
} else {
this.canFetchOlder.value = true;
}
}
} }
public async fetchNewer(options: { public async fetchNewer(options: {
toQueue?: boolean; toQueue?: boolean;
} = {}): Promise<void> { } = {}): Promise<void> {
this.fetchingNewer.value = true; this.fetchingNewer.value = true;
await misskeyApi<T[]>(this.endpoint, {
const data: E['req'] = {
...(typeof this.params === 'function' ? this.params() : this.params), ...(typeof this.params === 'function' ? this.params() : this.params),
...(this.computedParams ? this.computedParams.value : {}), ...(this.computedParams ? this.computedParams.value : {}),
...(this.searchQuery.value != null && this.searchQuery.value.trim() !== '' ? { [this.searchParamName]: this.searchQuery.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(), sinceId: this.getNewestId(),
}), }),
}).then(res => { };
if (res.length === 0) return; // これやらないと余計なre-renderが走る
if (options.toQueue) { const apiRes = (await misskeyApi<T[]>(this.endpoint, data).catch(err => {
this.aheadQueue.unshift(...res.toReversed()); return null;
if (this.aheadQueue.length > MAX_QUEUE_ITEMS) { })) as T[] | null;
this.aheadQueue = this.aheadQueue.slice(0, MAX_QUEUE_ITEMS);
} this.fetchingNewer.value = false;
this.queuedAheadItemsCount.value = this.aheadQueue.length;
} else { if (apiRes == null || apiRes.length === 0) return; // これやらないと余計なre-renderが走る
if (this.order.value === 'oldest') {
this.pushItems(res); if (options.toQueue) {
} else { this.aheadQueue.unshift(...apiRes.toReversed());
this.unshiftItems(res.toReversed()); if (this.aheadQueue.length > MAX_QUEUE_ITEMS) {
} this.aheadQueue = this.aheadQueue.slice(0, MAX_QUEUE_ITEMS);
} }
}).finally(() => { this.queuedAheadItemsCount.value = this.aheadQueue.length;
this.fetchingNewer.value = false; } else {
}); if (this.order.value === 'oldest') {
this.pushItems(apiRes);
} else {
this.unshiftItems(apiRes.toReversed());
}
}
} }
public trim(trigger = true): void { 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のも更新 // TODO: queueのも更新
const index = this.items.value.findIndex(x => x.id === id); const index = this.items.value.findIndex(x => x.id === id);
if (index !== -1) { if (index !== -1) {
const item = this.items.value[index]!; const item = this.items.value[index]!;
this.items.value[index] = updator(item); this.items.value[index] = updater(item);
if (this.useShallowRef) triggerRef(this.items); if (this.useShallowRef) triggerRef(this.items);
} }
} }