diff --git a/packages/frontend/src/pages/timeline.vue b/packages/frontend/src/pages/timeline.vue index 5a71b18afe..9a0782822a 100644 --- a/packages/frontend/src/pages/timeline.vue +++ b/packages/frontend/src/pages/timeline.vue @@ -8,7 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only -
+
{{ i18n.ts._timelineDescription[src] }} @@ -50,6 +50,7 @@ import { $i } from '@/account.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; import { antennasCache, userListsCache } from '@/cache.js'; import { deviceKind } from '@/scripts/device-kind.js'; +import { deepMerge } from '@/scripts/merge.js'; import { MenuItem } from '@/types/menu.js'; import { miLocalStorage } from '@/local-storage.js'; @@ -74,10 +75,14 @@ const withRenotes = computed({ get: () => defaultStore.reactiveState.tl.value.filter.withRenotes, set: (x: boolean) => saveTlFilter('withRenotes', x), }); -const withReplies = computed({ + +// computed内での無限ループを防ぐためのフラグ +const localSocialTLFilterSwitchStore = ref<'withReplies' | 'onlyFiles' | false>('withReplies'); + +const withReplies = computed({ get: () => { if (!$i) return false; - if (['local', 'social'].includes(src.value) && onlyFiles.value) { + if (['local', 'social'].includes(src.value) && localSocialTLFilterSwitchStore.value === 'onlyFiles') { return false; } else { return defaultStore.reactiveState.tl.value.filter.withReplies; @@ -85,9 +90,9 @@ const withReplies = computed({ }, set: (x: boolean) => saveTlFilter('withReplies', x), }); -const onlyFiles = computed({ +const onlyFiles = computed({ get: () => { - if (['local', 'social'].includes(src.value) && withReplies.value) { + if (['local', 'social'].includes(src.value) && localSocialTLFilterSwitchStore.value === 'withReplies') { return false; } else { return defaultStore.reactiveState.tl.value.filter.onlyFiles; @@ -95,20 +100,31 @@ const onlyFiles = computed({ }, set: (x: boolean) => saveTlFilter('onlyFiles', x), }); + +watch([withReplies, onlyFiles], ([withRepliesTo, onlyFilesTo]) => { + if (withRepliesTo) { + localSocialTLFilterSwitchStore.value = 'withReplies'; + } else if (onlyFilesTo) { + localSocialTLFilterSwitchStore.value = 'onlyFiles'; + } else { + localSocialTLFilterSwitchStore.value = false; + } +}); + const withSensitive = computed({ get: () => defaultStore.reactiveState.tl.value.filter.withSensitive, - set: (x: boolean) => { - saveTlFilter('withSensitive', x); - - // これだけはクライアント側で完結する処理なので手動でリロード - tlComponent.value?.reloadTimeline(); - }, + set: (x: boolean) => saveTlFilter('withSensitive', x), }); watch(src, () => { queue.value = 0; }); +watch(withSensitive, () => { + // これだけはクライアント側で完結する処理なので手動でリロード + tlComponent.value?.reloadTimeline(); +}); + function queueUpdated(q: number): void { queue.value = q; } @@ -184,10 +200,7 @@ async function chooseChannel(ev: MouseEvent): Promise { } function saveSrc(newSrc: 'home' | 'local' | 'social' | 'global' | `list:${string}`): void { - const out = { - ...defaultStore.state.tl, - src: newSrc, - }; + const out = deepMerge({ src: newSrc }, defaultStore.state.tl); if (newSrc.startsWith('userList:')) { const id = newSrc.substring('userList:'.length); @@ -200,20 +213,9 @@ function saveSrc(newSrc: 'home' | 'local' | 'social' | 'global' | `list:${string function saveTlFilter(key: keyof typeof defaultStore.state.tl.filter, newValue: boolean) { if (key !== 'withReplies' || $i) { - const out = { ...defaultStore.state.tl }; - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (!out.filter) { - out.filter = { - withRenotes: true, - withReplies: true, - withSensitive: true, - onlyFiles: false, - }; - } - out.filter[key] = newValue; + const out = deepMerge({ filter: { [key]: newValue } }, defaultStore.state.tl); defaultStore.set('tl', out); } - return newValue; } async function timetravel(): Promise { diff --git a/packages/frontend/src/scripts/merge.ts b/packages/frontend/src/scripts/merge.ts index 60097051fa..9fd87a9428 100644 --- a/packages/frontend/src/scripts/merge.ts +++ b/packages/frontend/src/scripts/merge.ts @@ -6,6 +6,10 @@ import { deepClone } from './clone.js'; import type { Cloneable } from './clone.js'; +type DeepPartial = { + [P in keyof T]?: T[P] extends Record ? DeepPartial : T[P]; +}; + function isPureObject(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } @@ -14,18 +18,18 @@ function isPureObject(value: unknown): value is Record>(value: X, def: X): X { +export function deepMerge>(value: DeepPartial, def: X): X { if (isPureObject(value) && isPureObject(def)) { const result = deepClone(value as Cloneable) as X; for (const [k, v] of Object.entries(def) as [keyof X, X[keyof X]][]) { if (!Object.prototype.hasOwnProperty.call(value, k) || value[k] === undefined) { result[k] = v; } else if (isPureObject(v) && isPureObject(result[k])) { - const child = deepClone(result[k] as Cloneable) as X[keyof X] & Record; + const child = deepClone(result[k] as Cloneable) as DeepPartial>; result[k] = deepMerge(child, v); } } return result; } - return value; + throw new Error('deepMerge: value and def must be pure objects'); }