Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(frontend): vue v3.4.16以降でタイムラインが正常に表示できない問題を修正 #13248

Merged
merged 9 commits into from
Feb 16, 2024
4 changes: 2 additions & 2 deletions packages/frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
"@tabler/icons-webfont": "2.44.0",
"@twemoji/parser": "15.0.0",
"@vitejs/plugin-vue": "5.0.3",
"@vue/compiler-sfc": "3.4.15",
"@vue/compiler-sfc": "3.4.18",
"aiscript-vscode": "github:aiscript-dev/aiscript-vscode#v0.1.2",
"astring": "1.8.6",
"broadcast-channel": "7.0.0",
Expand Down Expand Up @@ -72,7 +72,7 @@
"uuid": "9.0.1",
"v-code-diff": "1.7.2",
"vite": "5.1.0",
"vue": "3.4.15",
"vue": "3.4.18",
"vuedraggable": "next"
},
"devDependencies": {
Expand Down
74 changes: 39 additions & 35 deletions packages/frontend/src/pages/timeline.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #header><MkPageHeader v-model:tab="src" :actions="headerActions" :tabs="$i ? headerTabs : headerTabsWhenNotLogin" :displayMyAvatar="true"/></template>
<MkSpacer :contentMax="800">
<MkHorizontalSwipe v-model:tab="src" :tabs="$i ? headerTabs : headerTabsWhenNotLogin">
<div :key="src + withRenotes + withReplies + onlyFiles" ref="rootEl" v-hotkey.global="keymap">
<div :key="src" ref="rootEl" v-hotkey.global="keymap">
<MkInfo v-if="['home', 'local', 'social', 'global'].includes(src) && !defaultStore.reactiveState.timelineTutorials.value[src]" style="margin-bottom: var(--margin);" closable @close="closeTutorial()">
{{ i18n.ts._timelineDescription[src] }}
</MkInfo>
Expand Down Expand Up @@ -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';

Expand All @@ -65,50 +66,65 @@ const tlComponent = shallowRef<InstanceType<typeof MkTimeline>>();
const rootEl = shallowRef<HTMLElement>();

const queue = ref(0);
const srcWhenNotSignin = ref(isLocalTimelineAvailable ? 'local' : 'global');
const src = computed({
const srcWhenNotSignin = ref<'local' | 'global'>(isLocalTimelineAvailable ? 'local' : 'global');
const src = computed<'home' | 'local' | 'social' | 'global' | `list:${string}`>({
get: () => ($i ? defaultStore.reactiveState.tl.value.src : srcWhenNotSignin.value),
set: (x) => saveSrc(x),
});
const withRenotes = computed({
const withRenotes = computed<boolean>({
get: () => defaultStore.reactiveState.tl.value.filter.withRenotes,
set: (x: boolean) => saveTlFilter('withRenotes', x),
set: (x) => saveTlFilter('withRenotes', x),
});
const withReplies = computed({

// computed内での無限ループを防ぐためのフラグ
const localSocialTLFilterSwitchStore = ref<'withReplies' | 'onlyFiles' | false>('withReplies');

const withReplies = computed<boolean>({
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;
}
},
set: (x: boolean) => saveTlFilter('withReplies', x),
set: (x) => saveTlFilter('withReplies', x),
});
const onlyFiles = computed({
const onlyFiles = computed<boolean>({
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;
}
},
set: (x: boolean) => saveTlFilter('onlyFiles', x),
set: (x) => saveTlFilter('onlyFiles', x),
});
const withSensitive = computed({
get: () => defaultStore.reactiveState.tl.value.filter.withSensitive,
set: (x: boolean) => {
saveTlFilter('withSensitive', x);

// これだけはクライアント側で完結する処理なので手動でリロード
tlComponent.value?.reloadTimeline();
},
watch([withReplies, onlyFiles], ([withRepliesTo, onlyFilesTo]) => {
if (withRepliesTo) {
localSocialTLFilterSwitchStore.value = 'withReplies';
} else if (onlyFilesTo) {
localSocialTLFilterSwitchStore.value = 'onlyFiles';
} else {
localSocialTLFilterSwitchStore.value = false;
}
});

const withSensitive = computed<boolean>({
get: () => defaultStore.reactiveState.tl.value.filter.withSensitive,
set: (x) => saveTlFilter('withSensitive', x),
});

watch(src, () => {
queue.value = 0;
});

watch(withSensitive, () => {
// これだけはクライアント側で完結する処理なので手動でリロード
tlComponent.value?.reloadTimeline();
});

function queueUpdated(q: number): void {
queue.value = q;
}
Expand Down Expand Up @@ -184,36 +200,24 @@ async function chooseChannel(ev: MouseEvent): Promise<void> {
}

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);
out.userList = defaultStore.reactiveState.pinnedUserLists.value.find(l => l.id === id) ?? null;
}

defaultStore.set('tl', out);
srcWhenNotSignin.value = newSrc;
if (['local', 'global'].includes(newSrc)) {
srcWhenNotSignin.value = newSrc as 'local' | 'global';
}
}

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<void> {
Expand Down
10 changes: 7 additions & 3 deletions packages/frontend/src/scripts/merge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@
import { deepClone } from './clone.js';
import type { Cloneable } from './clone.js';

type DeepPartial<T> = {
[P in keyof T]?: T[P] extends Record<string | number | symbol, unknown> ? DeepPartial<T[P]> : T[P];
};

function isPureObject(value: unknown): value is Record<string | number | symbol, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
Expand All @@ -14,18 +18,18 @@ function isPureObject(value: unknown): value is Record<string | number | symbol,
* valueにないキーをdefからもらう(再帰的)\
* nullはそのまま、undefinedはdefの値
**/
export function deepMerge<X extends Record<string | number | symbol, unknown>>(value: X, def: X): X {
export function deepMerge<X extends Record<string | number | symbol, unknown>>(value: DeepPartial<X>, 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<string | number | symbol, unknown>;
const child = deepClone(result[k] as Cloneable) as DeepPartial<X[keyof X] & Record<string | number | symbol, unknown>>;
result[k] = deepMerge<typeof v>(child, v);
}
}
return result;
}
return value;
throw new Error('deepMerge: value and def must be pure objects');
}
Loading
Loading