diff options
| author | syuilo <4439005+syuilo@users.noreply.github.com> | 2025-05-09 17:40:08 +0900 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-05-09 17:40:08 +0900 |
| commit | 8c2ab25e5f2040fcbc81bc2a02a279fed40e1c11 (patch) | |
| tree | ae0d3573bd5a3175bc6174d33129dc64205a1436 /packages/frontend/src/use | |
| parent | refactor (diff) | |
| download | misskey-8c2ab25e5f2040fcbc81bc2a02a279fed40e1c11.tar.gz misskey-8c2ab25e5f2040fcbc81bc2a02a279fed40e1c11.tar.bz2 misskey-8c2ab25e5f2040fcbc81bc2a02a279fed40e1c11.zip | |
Feat: No websocket mode (#15851)
* wip
* wip
* wip
* wip
* Update MkTimeline.vue
* wip
* wip
* wip
* Update MkTimeline.vue
* Update use-pagination.ts
* wip
* wip
* Update MkTimeline.vue
* Update MkTimeline.vue
* wip
* wip
* Update MkTimeline.vue
* Update MkTimeline.vue
* Update MkTimeline.vue
* wip
* Update use-pagination.ts
* wip
* Update use-pagination.ts
* Update MkNotifications.vue
* Update MkNotifications.vue
* wip
* wip
* wip
* Update use-note-capture.ts
* Update use-note-capture.ts
* Update use-note-capture.ts
* wip
* wip
* wip
* wip
* Update MkNoteDetailed.vue
* wip
* wip
* Update MkTimeline.vue
* wip
* fix
* Update MkTimeline.vue
* wip
* test
* Revert "test"
This reverts commit 3375619396c54dcda5e564eb1da444c2391208c9.
* Update use-pagination.ts
* test
* Revert "test"
This reverts commit 42c53c830e28485d2fb49061fa7cdeee31bc6a22.
* test
* Revert "test"
This reverts commit c4f8cda4aa1cec9d1eb97557145f3ad3d2d0e469.
* Update style.scss
* Update MkTimeline.vue
* Update MkTimeline.vue
* Update MkTimeline.vue
* ✌️
* Update MkTimeline.vue
* wip
* wip
* test
* Update MkPullToRefresh.vue
* Update MkPullToRefresh.vue
* Update MkPullToRefresh.vue
* Update MkPullToRefresh.vue
* Update MkTimeline.vue
* wip
* tweak navbar
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* Update home.vue
* wip
* refactor
* wip
* wip
* Update note.vue
* Update navbar.vue
* Update MkPullToRefresh.vue
* Update MkPullToRefresh.vue
* Update MkPullToRefresh.vue
* wip
* Update MkStreamingNotificationsTimeline.vue
* Update use-pagination.ts
* wip
* improve perf
* wip
* Update MkNotesTimeline.vue
* wip
* megre
* Update use-pagination.ts
* Update use-pagination.ts
* Update MkStreamingNotesTimeline.vue
* Update use-pagination.ts
* Update CHANGELOG.md
* Update CHANGELOG.md
* Update CHANGELOG.md
Diffstat (limited to 'packages/frontend/src/use')
| -rw-r--r-- | packages/frontend/src/use/use-note-capture.ts | 301 | ||||
| -rw-r--r-- | packages/frontend/src/use/use-pagination.ts | 258 |
2 files changed, 488 insertions, 71 deletions
diff --git a/packages/frontend/src/use/use-note-capture.ts b/packages/frontend/src/use/use-note-capture.ts index 97aec4c1f0..2f33c25a0a 100644 --- a/packages/frontend/src/use/use-note-capture.ts +++ b/packages/frontend/src/use/use-note-capture.ts @@ -4,106 +4,166 @@ */ import { onUnmounted } from 'vue'; -import type { Ref, ShallowRef } from 'vue'; import * as Misskey from 'misskey-js'; +import { EventEmitter } from 'eventemitter3'; +import type { Reactive, Ref } from 'vue'; import { useStream } from '@/stream.js'; import { $i } from '@/i.js'; +import { store } from '@/store.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { prefer } from '@/preferences.js'; +import { globalEvents } from '@/events.js'; -export function useNoteCapture(props: { - rootEl: ShallowRef<HTMLElement | undefined>; - note: Ref<Misskey.entities.Note>; - pureNote: Ref<Misskey.entities.Note>; - isDeletedRef: Ref<boolean>; +export const noteEvents = new EventEmitter<{ + [ev: `reacted:${string}`]: (ctx: { userId: Misskey.entities.User['id']; reaction: string; emoji?: { name: string; url: string; }; }) => void; + [ev: `unreacted:${string}`]: (ctx: { userId: Misskey.entities.User['id']; reaction: string; emoji?: { name: string; url: string; }; }) => void; + [ev: `pollVoted:${string}`]: (ctx: { userId: Misskey.entities.User['id']; choice: string; }) => void; +}>(); + +const fetchEvent = new EventEmitter<{ + [id: string]: Pick<Misskey.entities.Note, 'reactions' | 'reactionEmojis'>; +}>(); + +const pollingQueue = new Map<string, { + referenceCount: number; + lastAddedAt: number; +}>(); + +function pollingEnqueue(note: Pick<Misskey.entities.Note, 'id' | 'createdAt'>) { + if (pollingQueue.has(note.id)) { + const data = pollingQueue.get(note.id)!; + pollingQueue.set(note.id, { + ...data, + referenceCount: data.referenceCount + 1, + lastAddedAt: Date.now(), + }); + } else { + pollingQueue.set(note.id, { + referenceCount: 1, + lastAddedAt: Date.now(), + }); + } +} + +function pollingDequeue(note: Pick<Misskey.entities.Note, 'id' | 'createdAt'>) { + const data = pollingQueue.get(note.id); + if (data == null) return; + + if (data.referenceCount === 1) { + pollingQueue.delete(note.id); + } else { + pollingQueue.set(note.id, { + ...data, + referenceCount: data.referenceCount - 1, + }); + } +} + +const CAPTURE_MAX = 30; +const MIN_POLLING_INTERVAL = 1000 * 10; +const POLLING_INTERVAL = + prefer.s.pollingInterval === 1 ? MIN_POLLING_INTERVAL * 1.5 * 1.5 : + prefer.s.pollingInterval === 2 ? MIN_POLLING_INTERVAL * 1.5 : + prefer.s.pollingInterval === 3 ? MIN_POLLING_INTERVAL : + MIN_POLLING_INTERVAL; + +window.setInterval(() => { + const ids = [...pollingQueue.entries()] + .filter(([k, v]) => Date.now() - v.lastAddedAt < 1000 * 60 * 5) // 追加されてから一定時間経過したものは省く + .map(([k, v]) => k) + .sort((a, b) => (a > b ? -1 : 1)) // 新しいものを優先するためにIDで降順ソート + .slice(0, CAPTURE_MAX); + + if (ids.length === 0) return; + if (window.document.hidden) return; + + // まとめてリクエストするのではなく、個別にHTTPリクエスト投げてCDNにキャッシュさせた方がサーバーの負荷低減には良いかもしれない? + misskeyApi('notes/show-partial-bulk', { + noteIds: ids, + }).then((items) => { + for (const item of items) { + fetchEvent.emit(item.id, { + reactions: item.reactions, + reactionEmojis: item.reactionEmojis, + }); + } + }); +}, POLLING_INTERVAL); + +function pollingSubscribe(props: { + note: Pick<Misskey.entities.Note, 'id' | 'createdAt'>; + $note: ReactiveNoteData; }) { + const { note, $note } = props; + + function onFetched(data: Pick<Misskey.entities.Note, 'reactions' | 'reactionEmojis'>): void { + $note.reactions = data.reactions; + $note.reactionCount = Object.values(data.reactions).reduce((a, b) => a + b, 0); + $note.reactionEmojis = data.reactionEmojis; + } + + pollingEnqueue(note); + fetchEvent.on(note.id, onFetched); + + onUnmounted(() => { + pollingDequeue(note); + fetchEvent.off(note.id, onFetched); + }); +} + +function realtimeSubscribe(props: { + note: Pick<Misskey.entities.Note, 'id' | 'createdAt'>; +}): void { const note = props.note; - const pureNote = props.pureNote; - const connection = $i ? useStream() : null; + const connection = useStream(); function onStreamNoteUpdated(noteData): void { const { type, id, body } = noteData; - if ((id !== note.value.id) && (id !== pureNote.value.id)) return; + if (id !== note.id) return; switch (type) { case 'reacted': { - const reaction = body.reaction; - - if (body.emoji && !(body.emoji.name in note.value.reactionEmojis)) { - note.value.reactionEmojis[body.emoji.name] = body.emoji.url; - } - - // TODO: reactionsプロパティがない場合ってあったっけ? なければ || {} は消せる - const currentCount = (note.value.reactions || {})[reaction] || 0; - - note.value.reactions[reaction] = currentCount + 1; - note.value.reactionCount += 1; - - if ($i && (body.userId === $i.id)) { - note.value.myReaction = reaction; - } + noteEvents.emit(`reacted:${id}`, { + userId: body.userId, + reaction: body.reaction, + emoji: body.emoji, + }); break; } case 'unreacted': { - const reaction = body.reaction; - - // TODO: reactionsプロパティがない場合ってあったっけ? なければ || {} は消せる - const currentCount = (note.value.reactions || {})[reaction] || 0; - - note.value.reactions[reaction] = Math.max(0, currentCount - 1); - note.value.reactionCount = Math.max(0, note.value.reactionCount - 1); - if (note.value.reactions[reaction] === 0) delete note.value.reactions[reaction]; - - if ($i && (body.userId === $i.id)) { - note.value.myReaction = null; - } + noteEvents.emit(`unreacted:${id}`, { + userId: body.userId, + reaction: body.reaction, + emoji: body.emoji, + }); break; } case 'pollVoted': { - const choice = body.choice; - - const choices = [...note.value.poll.choices]; - choices[choice] = { - ...choices[choice], - votes: choices[choice].votes + 1, - ...($i && (body.userId === $i.id) ? { - isVoted: true, - } : {}), - }; - - note.value.poll.choices = choices; + noteEvents.emit(`pollVoted:${id}`, { + userId: body.userId, + choice: body.choice, + }); break; } case 'deleted': { - props.isDeletedRef.value = true; + globalEvents.emit('noteDeleted', id); break; } } } function capture(withHandler = false): void { - if (connection) { - // TODO: このノートがストリーミング経由で流れてきた場合のみ sr する - connection.send(window.document.body.contains(props.rootEl.value ?? null as Node | null) ? 'sr' : 's', { id: note.value.id }); - if (pureNote.value.id !== note.value.id) connection.send('s', { id: pureNote.value.id }); - if (withHandler) connection.on('noteUpdated', onStreamNoteUpdated); - } + connection.send('sr', { id: note.id }); + if (withHandler) connection.on('noteUpdated', onStreamNoteUpdated); } function decapture(withHandler = false): void { - if (connection) { - connection.send('un', { - id: note.value.id, - }); - if (pureNote.value.id !== note.value.id) { - connection.send('un', { - id: pureNote.value.id, - }); - } - if (withHandler) connection.off('noteUpdated', onStreamNoteUpdated); - } + connection.send('un', { id: note.id }); + if (withHandler) connection.off('noteUpdated', onStreamNoteUpdated); } function onStreamConnected() { @@ -111,14 +171,113 @@ export function useNoteCapture(props: { } capture(true); - if (connection) { - connection.on('_connected_', onStreamConnected); - } + connection.on('_connected_', onStreamConnected); onUnmounted(() => { decapture(true); - if (connection) { - connection.off('_connected_', onStreamConnected); + connection.off('_connected_', onStreamConnected); + }); +} + +type ReactiveNoteData = Reactive<{ + reactions: Misskey.entities.Note['reactions']; + reactionCount: Misskey.entities.Note['reactionCount']; + reactionEmojis: Misskey.entities.Note['reactionEmojis']; + myReaction: Misskey.entities.Note['myReaction']; + pollChoices: NonNullable<Misskey.entities.Note['poll']>['choices']; +}>; + +export function useNoteCapture(props: { + note: Pick<Misskey.entities.Note, 'id' | 'createdAt'>; + parentNote: Misskey.entities.Note | null; + $note: ReactiveNoteData; +}) { + const { note, parentNote, $note } = props; + + noteEvents.on(`reacted:${note.id}`, onReacted); + noteEvents.on(`unreacted:${note.id}`, onUnreacted); + noteEvents.on(`pollVoted:${note.id}`, onPollVoted); + + let latestReactedKey: string | null = null; + let latestUnreactedKey: string | null = null; + let latestPollVotedKey: string | null = null; + + function onReacted(ctx: { userId: Misskey.entities.User['id']; reaction: string; emoji?: { name: string; url: string; }; }): void { + const newReactedKey = `${ctx.userId}:${ctx.reaction}`; + if (newReactedKey === latestReactedKey) return; + latestReactedKey = newReactedKey; + + if (ctx.emoji && !(ctx.emoji.name in $note.reactionEmojis)) { + $note.reactionEmojis[ctx.emoji.name] = ctx.emoji.url; + } + + const currentCount = $note.reactions[ctx.reaction] || 0; + + $note.reactions[ctx.reaction] = currentCount + 1; + $note.reactionCount += 1; + + if ($i && (ctx.userId === $i.id)) { + $note.myReaction = ctx.reaction; } + } + + function onUnreacted(ctx: { userId: Misskey.entities.User['id']; reaction: string; emoji?: { name: string; url: string; }; }): void { + const newUnreactedKey = `${ctx.userId}:${ctx.reaction}`; + if (newUnreactedKey === latestUnreactedKey) return; + latestUnreactedKey = newUnreactedKey; + + const currentCount = $note.reactions[ctx.reaction] || 0; + + $note.reactions[ctx.reaction] = Math.max(0, currentCount - 1); + $note.reactionCount = Math.max(0, $note.reactionCount - 1); + if ($note.reactions[ctx.reaction] === 0) delete $note.reactions[ctx.reaction]; + + if ($i && (ctx.userId === $i.id)) { + $note.myReaction = null; + } + } + + function onPollVoted(ctx: { userId: Misskey.entities.User['id']; choice: string; }): void { + const newPollVotedKey = `${ctx.userId}:${ctx.choice}`; + if (newPollVotedKey === latestPollVotedKey) return; + latestPollVotedKey = newPollVotedKey; + + const choices = [...$note.pollChoices]; + choices[ctx.choice] = { + ...choices[ctx.choice], + votes: choices[ctx.choice].votes + 1, + ...($i && (ctx.userId === $i.id) ? { + isVoted: true, + } : {}), + }; + + $note.pollChoices = choices; + } + + onUnmounted(() => { + noteEvents.off(`reacted:${note.id}`, onReacted); + noteEvents.off(`unreacted:${note.id}`, onUnreacted); + noteEvents.off(`pollVoted:${note.id}`, onPollVoted); }); + + // 投稿からある程度経過している(=タイムラインを遡って表示した)ノートは、イベントが発生する可能性が低いためそもそも購読しない + // ただし「リノートされたばかりの過去のノート」(= parentNoteが存在し、かつparentNoteの投稿日時が最近)はイベント発生が考えられるため購読する + // TODO: デバイスとサーバーの時計がズレていると不具合の元になるため、ズレを検知して警告を表示するなどのケアが必要かもしれない + if (parentNote == null) { + if ((Date.now() - new Date(note.createdAt).getTime()) > 1000 * 60 * 5) { // 5min + // リノートで表示されているノートでもないし、投稿からある程度経過しているので購読しない + return; + } + } else { + if ((Date.now() - new Date(parentNote.createdAt).getTime()) > 1000 * 60 * 5) { // 5min + // リノートで表示されているノートだが、リノートされてからある程度経過しているので購読しない + return; + } + } + + if ($i && store.s.realtimeMode) { + realtimeSubscribe(props); + } else { + pollingSubscribe(props); + } } diff --git a/packages/frontend/src/use/use-pagination.ts b/packages/frontend/src/use/use-pagination.ts new file mode 100644 index 0000000000..f1042985bf --- /dev/null +++ b/packages/frontend/src/use/use-pagination.ts @@ -0,0 +1,258 @@ +/* + * 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, DeepReadonly, 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 { + pushItems(res); + 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 { + unshiftItems(res.toReversed()); + } + }); + } + + 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.filter(x => !items.value.some(y => y.id === x.id))); // ストリーミングやポーリングのタイミングによっては重複することがあるため + trim(false); + if (props.useShallowRef) triggerRef(items); + } + + function pushItems(oldItems: T[]) { + if (oldItems.length === 0) return; // これやらないと余計なre-renderが走る + items.value.push(...oldItems); + if (props.useShallowRef) triggerRef(items); + } + + function prepend(item: T) { + if (items.value.some(x => x.id === item.id)) return; + 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: items as DeepReadonly<ShallowRef<T[]>>, + queuedAheadItemsCount, + fetching, + fetchingOlder, + canFetchOlder, + init, + reload, + fetchOlder, + fetchNewer, + unshiftItems, + prepend, + trim, + removeItem, + updateItem, + enqueue, + releaseQueue, + error, + }; +} |