diff options
| author | かっこかり <67428053+kakkokari-gtyih@users.noreply.github.com> | 2025-05-10 07:58:26 +0900 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-05-10 07:58:26 +0900 |
| commit | e1cd7c94fb13f8e49667b17554d22ce8de627a2a (patch) | |
| tree | a064c4b0937160cf1e26697dcfe18de8f2eb0144 /packages/frontend/src/composables | |
| parent | Bump version to 2025.5.1-alpha.0 (diff) | |
| download | misskey-e1cd7c94fb13f8e49667b17554d22ce8de627a2a.tar.gz misskey-e1cd7c94fb13f8e49667b17554d22ce8de627a2a.tar.bz2 misskey-e1cd7c94fb13f8e49667b17554d22ce8de627a2a.zip | |
refactor(frontend): use* 関数の格納場所のフォルダ名を composables に変更 (#16004)
* refactor(frontend): use* 関数の格納場所を正式名称(composables)に変更
* migrate
* move useLoading
Diffstat (limited to 'packages/frontend/src/composables')
| -rw-r--r-- | packages/frontend/src/composables/use-chart-tooltip.ts | 63 | ||||
| -rw-r--r-- | packages/frontend/src/composables/use-form.ts | 57 | ||||
| -rw-r--r-- | packages/frontend/src/composables/use-leave-guard.ts | 50 | ||||
| -rw-r--r-- | packages/frontend/src/composables/use-loading.ts | 52 | ||||
| -rw-r--r-- | packages/frontend/src/composables/use-mutation-observer.ts | 21 | ||||
| -rw-r--r-- | packages/frontend/src/composables/use-note-capture.ts | 283 | ||||
| -rw-r--r-- | packages/frontend/src/composables/use-pagination.ts | 258 | ||||
| -rw-r--r-- | packages/frontend/src/composables/use-scroll-position-keeper.ts | 77 | ||||
| -rw-r--r-- | packages/frontend/src/composables/use-tooltip.ts | 106 |
9 files changed, 967 insertions, 0 deletions
diff --git a/packages/frontend/src/composables/use-chart-tooltip.ts b/packages/frontend/src/composables/use-chart-tooltip.ts new file mode 100644 index 0000000000..bba64fc6ee --- /dev/null +++ b/packages/frontend/src/composables/use-chart-tooltip.ts @@ -0,0 +1,63 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { onUnmounted, onDeactivated, ref } from 'vue'; +import * as os from '@/os.js'; +import MkChartTooltip from '@/components/MkChartTooltip.vue'; + +export function useChartTooltip(opts: { position: 'top' | 'middle' } = { position: 'top' }) { + const tooltipShowing = ref(false); + const tooltipX = ref(0); + const tooltipY = ref(0); + const tooltipTitle = ref<string | null>(null); + const tooltipSeries = ref<{ + backgroundColor: string; + borderColor: string; + text: string; + }[] | null>(null); + const { dispose: disposeTooltipComponent } = os.popup(MkChartTooltip, { + showing: tooltipShowing, + x: tooltipX, + y: tooltipY, + title: tooltipTitle, + series: tooltipSeries, + }, {}); + + onUnmounted(() => { + disposeTooltipComponent(); + }); + + onDeactivated(() => { + tooltipShowing.value = false; + }); + + function handler(context) { + if (context.tooltip.opacity === 0) { + tooltipShowing.value = false; + return; + } + + tooltipTitle.value = context.tooltip.title[0]; + tooltipSeries.value = context.tooltip.body.map((b, i) => ({ + backgroundColor: context.tooltip.labelColors[i].backgroundColor, + borderColor: context.tooltip.labelColors[i].borderColor, + text: b.lines[0], + })); + + const rect = context.chart.canvas.getBoundingClientRect(); + + tooltipShowing.value = true; + tooltipX.value = rect.left + window.scrollX + context.tooltip.caretX; + if (opts.position === 'top') { + tooltipY.value = rect.top + window.scrollY; + } else if (opts.position === 'middle') { + tooltipY.value = rect.top + window.scrollY + context.tooltip.caretY; + } + } + + return { + handler, + }; +} diff --git a/packages/frontend/src/composables/use-form.ts b/packages/frontend/src/composables/use-form.ts new file mode 100644 index 0000000000..1c93557413 --- /dev/null +++ b/packages/frontend/src/composables/use-form.ts @@ -0,0 +1,57 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { computed, reactive, watch } from 'vue'; +import type { Reactive } from 'vue'; +import { deepEqual } from '@/utility/deep-equal'; + +function copy<T>(v: T): T { + return JSON.parse(JSON.stringify(v)); +} + +function unwrapReactive<T>(v: Reactive<T>): T { + return JSON.parse(JSON.stringify(v)); +} + +export function useForm<T extends Record<string, any>>(initialState: T, save: (newState: T) => Promise<void>) { + const currentState = reactive<T>(copy(initialState)); + const previousState = reactive<T>(copy(initialState)); + + const modifiedStates = reactive<Record<keyof T, boolean>>({} as any); + for (const key in currentState) { + modifiedStates[key] = false; + } + const modified = computed(() => Object.values(modifiedStates).some(v => v)); + const modifiedCount = computed(() => Object.values(modifiedStates).filter(v => v).length); + + watch([currentState, previousState], () => { + for (const key in modifiedStates) { + modifiedStates[key] = !deepEqual(currentState[key], previousState[key]); + } + }, { deep: true }); + + async function _save() { + await save(unwrapReactive(currentState)); + for (const key in currentState) { + previousState[key] = copy(currentState[key]); + } + } + + function discard() { + for (const key in currentState) { + currentState[key] = copy(previousState[key]); + } + } + + return { + state: currentState, + savedState: previousState, + modifiedStates, + modified, + modifiedCount, + save: _save, + discard, + }; +} diff --git a/packages/frontend/src/composables/use-leave-guard.ts b/packages/frontend/src/composables/use-leave-guard.ts new file mode 100644 index 0000000000..395c12a756 --- /dev/null +++ b/packages/frontend/src/composables/use-leave-guard.ts @@ -0,0 +1,50 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import type { Ref } from 'vue'; + +export function useLeaveGuard(enabled: Ref<boolean>) { + /* TODO + const setLeaveGuard = inject('setLeaveGuard'); + + if (setLeaveGuard) { + setLeaveGuard(async () => { + if (!enabled.value) return false; + + const { canceled } = await os.confirm({ + type: 'warning', + text: i18n.ts.leaveConfirm, + }); + + return canceled; + }); + } else { + onBeforeRouteLeave(async (to, from) => { + if (!enabled.value) return true; + + const { canceled } = await os.confirm({ + type: 'warning', + text: i18n.ts.leaveConfirm, + }); + + return !canceled; + }); + } + */ + + /* + function onBeforeLeave(ev: BeforeUnloadEvent) { + if (enabled.value) { + ev.preventDefault(); + ev.returnValue = ''; + } + } + + window.addEventListener('beforeunload', onBeforeLeave); + onUnmounted(() => { + window.removeEventListener('beforeunload', onBeforeLeave); + }); + */ +} diff --git a/packages/frontend/src/composables/use-loading.ts b/packages/frontend/src/composables/use-loading.ts new file mode 100644 index 0000000000..6c6ff6ae0d --- /dev/null +++ b/packages/frontend/src/composables/use-loading.ts @@ -0,0 +1,52 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { computed, h, ref } from 'vue'; +import MkLoading from '@/components/global/MkLoading.vue'; + +export const useLoading = (props?: { + static?: boolean; + inline?: boolean; + colored?: boolean; + mini?: boolean; + em?: boolean; +}) => { + const showingCnt = ref(0); + + const show = () => { + showingCnt.value++; + }; + + const close = (force?: boolean) => { + if (force) { + showingCnt.value = 0; + } else { + showingCnt.value = Math.max(0, showingCnt.value - 1); + } + }; + + const scope = <T>(fn: () => T) => { + show(); + + const result = fn(); + if (result instanceof Promise) { + return result.finally(() => close()); + } else { + close(); + return result; + } + }; + + const showing = computed(() => showingCnt.value > 0); + const component = computed(() => showing.value ? h(MkLoading, props) : null); + + return { + show, + close, + scope, + component, + showing, + }; +}; diff --git a/packages/frontend/src/composables/use-mutation-observer.ts b/packages/frontend/src/composables/use-mutation-observer.ts new file mode 100644 index 0000000000..7b774022dc --- /dev/null +++ b/packages/frontend/src/composables/use-mutation-observer.ts @@ -0,0 +1,21 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { onUnmounted, watch } from 'vue'; +import type { Ref } from 'vue'; + +export function useMutationObserver(targetNodeRef: Ref<HTMLElement | null | undefined>, options: MutationObserverInit, callback: MutationCallback): void { + const observer = new MutationObserver(callback); + + watch(targetNodeRef, (targetNode) => { + if (targetNode) { + observer.observe(targetNode, options); + } + }, { immediate: true }); + + onUnmounted(() => { + observer.disconnect(); + }); +} diff --git a/packages/frontend/src/composables/use-note-capture.ts b/packages/frontend/src/composables/use-note-capture.ts new file mode 100644 index 0000000000..2f33c25a0a --- /dev/null +++ b/packages/frontend/src/composables/use-note-capture.ts @@ -0,0 +1,283 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { onUnmounted } 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 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 connection = useStream(); + + function onStreamNoteUpdated(noteData): void { + const { type, id, body } = noteData; + + if (id !== note.id) return; + + switch (type) { + case 'reacted': { + noteEvents.emit(`reacted:${id}`, { + userId: body.userId, + reaction: body.reaction, + emoji: body.emoji, + }); + break; + } + + case 'unreacted': { + noteEvents.emit(`unreacted:${id}`, { + userId: body.userId, + reaction: body.reaction, + emoji: body.emoji, + }); + break; + } + + case 'pollVoted': { + noteEvents.emit(`pollVoted:${id}`, { + userId: body.userId, + choice: body.choice, + }); + break; + } + + case 'deleted': { + globalEvents.emit('noteDeleted', id); + break; + } + } + } + + function capture(withHandler = false): void { + connection.send('sr', { id: note.id }); + if (withHandler) connection.on('noteUpdated', onStreamNoteUpdated); + } + + function decapture(withHandler = false): void { + connection.send('un', { id: note.id }); + if (withHandler) connection.off('noteUpdated', onStreamNoteUpdated); + } + + function onStreamConnected() { + capture(false); + } + + capture(true); + connection.on('_connected_', onStreamConnected); + + onUnmounted(() => { + decapture(true); + 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/composables/use-pagination.ts b/packages/frontend/src/composables/use-pagination.ts new file mode 100644 index 0000000000..f1042985bf --- /dev/null +++ b/packages/frontend/src/composables/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, + }; +} diff --git a/packages/frontend/src/composables/use-scroll-position-keeper.ts b/packages/frontend/src/composables/use-scroll-position-keeper.ts new file mode 100644 index 0000000000..b584171cbe --- /dev/null +++ b/packages/frontend/src/composables/use-scroll-position-keeper.ts @@ -0,0 +1,77 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { throttle } from 'throttle-debounce'; +import { nextTick, onActivated, onDeactivated, onUnmounted, watch } from 'vue'; +import type { Ref } from 'vue'; + +// note render skippingがオンだとズレるため、遷移直前にスクロール範囲に表示されているdata-scroll-anchor要素を特定して、復元時に当該要素までスクロールするようにする + +// TODO: data-scroll-anchor がひとつも存在しない場合、または手動で useAnchor みたいなフラグをfalseで呼ばれた場合、単純にスクロール位置を使用する処理にフォールバックするようにする + +export function useScrollPositionKeeper(scrollContainerRef: Ref<HTMLElement | null | undefined>): void { + let anchorId: string | null = null; + let ready = true; + + watch(scrollContainerRef, (el) => { + if (!el) return; + + const onScroll = () => { + if (!el) return; + if (!ready) return; + + const scrollContainerRect = el.getBoundingClientRect(); + const viewPosition = scrollContainerRect.height / 2; + + const anchorEls = el.querySelectorAll('[data-scroll-anchor]'); + for (let i = anchorEls.length - 1; i > -1; i--) { // 下から見た方が速い + const anchorEl = anchorEls[i] as HTMLElement; + const anchorRect = anchorEl.getBoundingClientRect(); + const anchorTop = anchorRect.top; + const anchorBottom = anchorRect.bottom; + if (anchorTop <= viewPosition && anchorBottom >= viewPosition) { + anchorId = anchorEl.getAttribute('data-scroll-anchor'); + break; + } + } + }; + + // ほんとはscrollイベントじゃなくてonBeforeDeactivatedでやりたい + // https://github.com/vuejs/vue/issues/9454 + // https://github.com/vuejs/rfcs/pull/284 + el.addEventListener('scroll', throttle(1000, onScroll), { passive: true }); + }, { + immediate: true, + }); + + const restore = () => { + if (!anchorId) return; + const scrollContainer = scrollContainerRef.value; + if (!scrollContainer) return; + const scrollAnchorEl = scrollContainer.querySelector(`[data-scroll-anchor="${anchorId}"]`); + if (!scrollAnchorEl) return; + scrollAnchorEl.scrollIntoView({ + behavior: 'instant', + block: 'center', + inline: 'center', + }); + }; + + onDeactivated(() => { + ready = false; + }); + + onActivated(() => { + restore(); + nextTick(() => { + restore(); + window.setTimeout(() => { + restore(); + + ready = true; + }, 100); + }); + }); +} diff --git a/packages/frontend/src/composables/use-tooltip.ts b/packages/frontend/src/composables/use-tooltip.ts new file mode 100644 index 0000000000..af76a3a1e8 --- /dev/null +++ b/packages/frontend/src/composables/use-tooltip.ts @@ -0,0 +1,106 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { ref, watch, onUnmounted } from 'vue'; +import type { Ref } from 'vue'; + +export function useTooltip( + elRef: Ref<HTMLElement | { $el: HTMLElement } | null | undefined>, + onShow: (showing: Ref<boolean>) => void, + delay = 300, +): void { + let isHovering = false; + + // iOS(Androidも?)では、要素をタップした直後に(おせっかいで)mouseoverイベントを発火させたりするため、それを無視するためのフラグ + // 無視しないと、画面に触れてないのにツールチップが出たりし、ユーザビリティが損なわれる + // TODO: 一度でもタップすると二度とマウスでツールチップ出せなくなるのをどうにかする 定期的にfalseに戻すとか...? + let shouldIgnoreMouseover = false; + + let timeoutId: number; + + let changeShowingState: (() => void) | null; + + let autoHidingTimer; + + const open = () => { + close(); + if (!isHovering) return; + if (elRef.value == null) return; + const el = elRef.value instanceof Element ? elRef.value : elRef.value.$el; + if (!window.document.body.contains(el)) return; // openしようとしたときに既に元要素がDOMから消えている場合があるため + + const showing = ref(true); + onShow(showing); + changeShowingState = () => { + showing.value = false; + }; + + autoHidingTimer = window.setInterval(() => { + if (elRef.value == null || !window.document.body.contains(elRef.value instanceof Element ? elRef.value : elRef.value.$el)) { + if (!isHovering) return; + isHovering = false; + window.clearTimeout(timeoutId); + close(); + window.clearInterval(autoHidingTimer); + } + }, 1000); + }; + + const close = () => { + if (changeShowingState != null) { + changeShowingState(); + changeShowingState = null; + } + }; + + const onMouseover = () => { + if (isHovering) return; + if (shouldIgnoreMouseover) return; + isHovering = true; + timeoutId = window.setTimeout(open, delay); + }; + + const onMouseleave = () => { + if (!isHovering) return; + isHovering = false; + window.clearTimeout(timeoutId); + window.clearInterval(autoHidingTimer); + close(); + }; + + const onTouchstart = () => { + shouldIgnoreMouseover = true; + if (isHovering) return; + isHovering = true; + timeoutId = window.setTimeout(open, delay); + }; + + const onTouchend = () => { + if (!isHovering) return; + isHovering = false; + window.clearTimeout(timeoutId); + window.clearInterval(autoHidingTimer); + close(); + }; + + const stop = watch(elRef, () => { + if (elRef.value) { + stop(); + const el = elRef.value instanceof Element ? elRef.value : elRef.value.$el; + el.addEventListener('mouseover', onMouseover, { passive: true }); + el.addEventListener('mouseleave', onMouseleave, { passive: true }); + el.addEventListener('touchstart', onTouchstart, { passive: true }); + el.addEventListener('touchend', onTouchend, { passive: true }); + el.addEventListener('click', close, { passive: true }); + } + }, { + immediate: true, + flush: 'post', + }); + + onUnmounted(() => { + close(); + }); +} |