mirror of
https://github.com/misskey-dev/misskey.git
synced 2026-05-04 01:26:41 +02:00
refactor(frontend): use* 関数の格納場所のフォルダ名を composables に変更 (#16004)
* refactor(frontend): use* 関数の格納場所を正式名称(composables)に変更 * migrate * move useLoading
This commit is contained in:
63
packages/frontend/src/composables/use-chart-tooltip.ts
Normal file
63
packages/frontend/src/composables/use-chart-tooltip.ts
Normal file
@@ -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,
|
||||
};
|
||||
}
|
||||
57
packages/frontend/src/composables/use-form.ts
Normal file
57
packages/frontend/src/composables/use-form.ts
Normal file
@@ -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,
|
||||
};
|
||||
}
|
||||
50
packages/frontend/src/composables/use-leave-guard.ts
Normal file
50
packages/frontend/src/composables/use-leave-guard.ts
Normal file
@@ -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);
|
||||
});
|
||||
*/
|
||||
}
|
||||
52
packages/frontend/src/composables/use-loading.ts
Normal file
52
packages/frontend/src/composables/use-loading.ts
Normal file
@@ -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,
|
||||
};
|
||||
};
|
||||
21
packages/frontend/src/composables/use-mutation-observer.ts
Normal file
21
packages/frontend/src/composables/use-mutation-observer.ts
Normal file
@@ -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();
|
||||
});
|
||||
}
|
||||
283
packages/frontend/src/composables/use-note-capture.ts
Normal file
283
packages/frontend/src/composables/use-note-capture.ts
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
258
packages/frontend/src/composables/use-pagination.ts
Normal file
258
packages/frontend/src/composables/use-pagination.ts
Normal file
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
106
packages/frontend/src/composables/use-tooltip.ts
Normal file
106
packages/frontend/src/composables/use-tooltip.ts
Normal file
@@ -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();
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user