summaryrefslogtreecommitdiff
path: root/packages/frontend/src/use
diff options
context:
space:
mode:
authorsyuilo <4439005+syuilo@users.noreply.github.com>2025-05-09 17:40:08 +0900
committerGitHub <noreply@github.com>2025-05-09 17:40:08 +0900
commit8c2ab25e5f2040fcbc81bc2a02a279fed40e1c11 (patch)
treeae0d3573bd5a3175bc6174d33129dc64205a1436 /packages/frontend/src/use
parentrefactor (diff)
downloadmisskey-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.ts301
-rw-r--r--packages/frontend/src/use/use-pagination.ts258
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,
+ };
+}