summaryrefslogtreecommitdiff
path: root/packages/frontend/src/composables
diff options
context:
space:
mode:
Diffstat (limited to 'packages/frontend/src/composables')
-rw-r--r--packages/frontend/src/composables/use-chart-tooltip.ts63
-rw-r--r--packages/frontend/src/composables/use-form.ts57
-rw-r--r--packages/frontend/src/composables/use-leave-guard.ts50
-rw-r--r--packages/frontend/src/composables/use-loading.ts52
-rw-r--r--packages/frontend/src/composables/use-mutation-observer.ts21
-rw-r--r--packages/frontend/src/composables/use-note-capture.ts283
-rw-r--r--packages/frontend/src/composables/use-pagination.ts258
-rw-r--r--packages/frontend/src/composables/use-scroll-position-keeper.ts77
-rw-r--r--packages/frontend/src/composables/use-tooltip.ts106
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();
+ });
+}