summaryrefslogtreecommitdiff
path: root/packages/frontend/src/preferences
diff options
context:
space:
mode:
Diffstat (limited to 'packages/frontend/src/preferences')
-rw-r--r--packages/frontend/src/preferences/def.ts1
-rw-r--r--packages/frontend/src/preferences/profile.ts319
-rw-r--r--packages/frontend/src/preferences/store.ts94
-rw-r--r--packages/frontend/src/preferences/utility.ts20
4 files changed, 264 insertions, 170 deletions
diff --git a/packages/frontend/src/preferences/def.ts b/packages/frontend/src/preferences/def.ts
index 68e0b08f92..b75b99d6b5 100644
--- a/packages/frontend/src/preferences/def.ts
+++ b/packages/frontend/src/preferences/def.ts
@@ -327,4 +327,5 @@ export const PREF_DEF = {
} satisfies Record<string, {
default: any;
accountDependent?: boolean;
+ serverDependent?: boolean;
}>;
diff --git a/packages/frontend/src/preferences/profile.ts b/packages/frontend/src/preferences/profile.ts
index defa2747eb..fc8057540a 100644
--- a/packages/frontend/src/preferences/profile.ts
+++ b/packages/frontend/src/preferences/profile.ts
@@ -3,16 +3,16 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
-import { ref, watch } from 'vue';
+import { computed, onUnmounted, ref, watch } from 'vue';
import { v4 as uuid } from 'uuid';
import { host, version } from '@@/js/config.js';
-import { EventEmitter } from 'eventemitter3';
import { PREF_DEF } from './def.js';
-import { Store } from './store.js';
+import type { Ref, WritableComputedRef } from 'vue';
import type { MenuItem } from '@/types/menu.js';
import { $i } from '@/account.js';
import { copyToClipboard } from '@/utility/copy-to-clipboard.js';
import { i18n } from '@/i18n.js';
+import * as os from '@/os.js';
// NOTE: 明示的な設定値のひとつとして null もあり得るため、設定が存在しないかどうかを判定する目的で null で比較したり ?? を使ってはいけない
@@ -24,11 +24,41 @@ type PREF = typeof PREF_DEF;
type ValueOf<K extends keyof PREF> = PREF[K]['default'];
type Account = string; // <host>/<userId>
-type Cond = {
+type Cond = Partial<{
server: string | null; // 将来のため
account: Account | null;
device: string | null; // 将来のため
-};
+}>;
+
+type ValueMeta = Partial<{
+ sync: boolean;
+}>;
+
+type PrefRecord<K extends keyof PREF> = [cond: Cond, value: ValueOf<K>, meta: ValueMeta];
+
+function parseCond(cond: Cond): {
+ server: string | null;
+ account: Account | null;
+ device: string | null;
+} {
+ return {
+ server: cond.server ?? null,
+ account: cond.account ?? null,
+ device: cond.device ?? null,
+ };
+}
+
+function makeCond(cond: Partial<{
+ server: string | null;
+ account: Account | null;
+ device: string | null;
+}>): Cond {
+ const c = {} as Cond;
+ if (cond.server != null) c.server = cond.server;
+ if (cond.account != null) c.account = cond.account;
+ if (cond.device != null) c.device = cond.device;
+ return c;
+}
export type PreferencesProfile = {
id: string;
@@ -37,53 +67,119 @@ export type PreferencesProfile = {
modifiedAt: number;
name: string;
preferences: {
- [K in keyof PREF]: [Cond, ValueOf<K>][];
+ [K in keyof PREF]: PrefRecord<K>[];
};
- syncByAccount: [Account, keyof PREF][],
};
-// TODO: 任意のプロパティをデバイス間で同期できるようにする?
+export type StorageProvider = {
+ save: (ctx: { profile: PreferencesProfile; }) => void;
+ cloudGet: <K extends keyof PREF>(ctx: { key: K; }) => Promise<{ value: ValueOf<K>; } | null>;
+ cloudSet: <K extends keyof PREF>(ctx: { key: K; value: ValueOf<K>; }) => Promise<void>;
+};
-export class ProfileManager extends EventEmitter<{
- updated: (ctx: {
- profile: PreferencesProfile
- }) => void;
-}> {
+export class ProfileManager {
+ private storageProvider: StorageProvider;
public profile: PreferencesProfile;
- public store: Store<{
+
+ /**
+ * static / state の略 (static が予約語のため)
+ */
+ public s = {} as {
[K in keyof PREF]: ValueOf<K>;
- }>;
+ };
+
+ /**
+ * reactive の略
+ */
+ public r = {} as {
+ [K in keyof PREF]: Ref<ValueOf<K>>;
+ };
- constructor(profile: PreferencesProfile) {
- super();
+ constructor(profile: PreferencesProfile, storageProvider: StorageProvider) {
this.profile = profile;
+ this.storageProvider = storageProvider;
const states = this.genStates();
- this.store = new Store(states);
- this.store.addListener('updated', ({ key, value }) => {
- console.log('prefer:set', key, value);
+ for (const key in states) {
+ this.s[key] = states[key];
+ this.r[key] = ref(this.s[key]);
+ }
- const record = this.getMatchedRecord(key);
- if (record[0].account == null && PREF_DEF[key].accountDependent) {
- this.profile.preferences[key].push([{
- server: null,
- account: `${host}/${$i!.id}`,
- device: null,
- }, value]);
- this.save();
- return;
- }
+ this.fetchCloudValues();
- record[1] = value;
+ // TODO: 定期的にクラウドの値をフェッチ
+ }
+
+ private rewriteRawState<K extends keyof PREF>(key: K, value: ValueOf<K>) {
+ const v = JSON.parse(JSON.stringify(value)); // deep copy 兼 vueのプロキシ解除
+ this.r[key].value = this.s[key] = v;
+ }
+
+ public commit<K extends keyof PREF>(key: K, value: ValueOf<K>) {
+ console.log('prefer:commit', key, value);
+
+ this.rewriteRawState(key, value);
+
+ const record = this.getMatchedRecord(key);
+ if (parseCond(record[0]).account == null && PREF_DEF[key].accountDependent) {
+ this.profile.preferences[key].push([makeCond({
+ account: `${host}/${$i!.id}`,
+ }), value, {}]);
this.save();
+ return;
+ }
+
+ if (record[2].sync) {
+ // awaitの必要なし
+ // TODO: リクエストを間引く
+ this.storageProvider.cloudSet({ key, value });
+ }
+
+ record[1] = value;
+ this.save();
+ }
+
+ /**
+ * 特定のキーの、簡易的なcomputed refを作ります
+ * 主にvue上で設定コントロールのmodelとして使う用
+ */
+ public model<K extends keyof PREF, V extends ValueOf<K> = ValueOf<K>>(
+ key: K,
+ getter?: (v: ValueOf<K>) => V,
+ setter?: (v: V) => ValueOf<K>,
+ ): WritableComputedRef<V> {
+ const valueRef = ref(this.s[key]);
+
+ const stop = watch(this.r[key], val => {
+ valueRef.value = val;
+ });
+
+ // NOTE: vueコンポーネント内で呼ばれない限りは、onUnmounted は無意味なのでメモリリークする
+ onUnmounted(() => {
+ stop();
+ });
+
+ // TODO: VueのcustomRef使うと良い感じになるかも
+ return computed({
+ get: () => {
+ if (getter) {
+ return getter(valueRef.value);
+ } else {
+ return valueRef.value;
+ }
+ },
+ set: (value) => {
+ const val = setter ? setter(value) : value;
+ this.commit(key, val);
+ valueRef.value = val;
+ },
});
}
private genStates() {
const states = {} as { [K in keyof PREF]: ValueOf<K> };
- let key: keyof PREF;
- for (key in PREF_DEF) {
+ for (const key in PREF_DEF) {
const record = this.getMatchedRecord(key);
states[key] = record[1];
}
@@ -91,15 +187,37 @@ export class ProfileManager extends EventEmitter<{
return states;
}
+ private fetchCloudValues() {
+ // TODO: 値の取得を1つのリクエストで済ませたい(バックエンド側でAPIの新設が必要)
+
+ const promises: Promise<void>[] = [];
+ for (const key in PREF_DEF) {
+ const record = this.getMatchedRecord(key);
+ if (record[2].sync) {
+ const getting = this.storageProvider.cloudGet({ key });
+ promises.push(getting.then((res) => {
+ if (res == null) return;
+ const value = res.value;
+ if (value !== this.s[key]) {
+ this.rewriteRawState(key, value);
+ record[1] = value;
+ console.log('cloud fetched', key, value);
+ }
+ }));
+ }
+ }
+ Promise.all(promises).then(() => {
+ console.log('cloud fetched all');
+ this.save();
+
+ console.log(this.s.showFixedPostForm, this.r.showFixedPostForm.value);
+ });
+ }
+
public static newProfile(): PreferencesProfile {
const data = {} as PreferencesProfile['preferences'];
- let key: keyof PREF;
- for (key in PREF_DEF) {
- data[key] = [[{
- server: null,
- account: null,
- device: null,
- }, PREF_DEF[key].default]];
+ for (const key in PREF_DEF) {
+ data[key] = [[makeCond({}), PREF_DEF[key].default, {}]];
}
return {
id: uuid(),
@@ -108,29 +226,31 @@ export class ProfileManager extends EventEmitter<{
modifiedAt: Date.now(),
name: '',
preferences: data,
- syncByAccount: [],
};
}
- public static normalizeProfile(profile: any): PreferencesProfile {
+ public static normalizeProfile(profileLike: any): PreferencesProfile {
const data = {} as PreferencesProfile['preferences'];
- let key: keyof PREF;
- for (key in PREF_DEF) {
- const records = profile.preferences[key];
+ for (const key in PREF_DEF) {
+ const records = profileLike.preferences[key];
if (records == null || records.length === 0) {
- data[key] = [[{
- server: null,
- account: null,
- device: null,
- }, PREF_DEF[key].default]];
+ data[key] = [[makeCond({}), PREF_DEF[key].default, {}]];
continue;
} else {
data[key] = records;
+
+ // alpha段階ではmetaが無かったのでマイグレート
+ // TODO: そのうち消す
+ for (const record of data[key] as any[][]) {
+ if (record.length === 2) {
+ record.push({});
+ }
+ }
}
}
return {
- ...profile,
+ ...profileLike,
preferences: data,
};
}
@@ -138,24 +258,24 @@ export class ProfileManager extends EventEmitter<{
public save() {
this.profile.modifiedAt = Date.now();
this.profile.version = version;
- this.emit('updated', { profile: this.profile });
+ this.storageProvider.save({ profile: this.profile });
}
- public getMatchedRecord<K extends keyof PREF>(key: K): [Cond, ValueOf<K>] {
+ public getMatchedRecord<K extends keyof PREF>(key: K): PrefRecord<K> {
const records = this.profile.preferences[key];
- if ($i == null) return records.find(([cond, v]) => cond.account == null)!;
+ if ($i == null) return records.find(([cond, v]) => parseCond(cond).account == null)!;
- const accountOverrideRecord = records.find(([cond, v]) => cond.account === `${host}/${$i!.id}`);
+ const accountOverrideRecord = records.find(([cond, v]) => parseCond(cond).account === `${host}/${$i!.id}`);
if (accountOverrideRecord) return accountOverrideRecord;
- const record = records.find(([cond, v]) => cond.account == null);
+ const record = records.find(([cond, v]) => parseCond(cond).account == null);
return record!;
}
public isAccountOverrided<K extends keyof PREF>(key: K): boolean {
if ($i == null) return false;
- return this.profile.preferences[key].some(([cond, v]) => cond.account === `${host}/${$i!.id}`) ?? false;
+ return this.profile.preferences[key].some(([cond, v]) => parseCond(cond).account === `${host}/${$i!.id}`) ?? false;
}
public setAccountOverride<K extends keyof PREF>(key: K) {
@@ -164,11 +284,9 @@ export class ProfileManager extends EventEmitter<{
if (this.isAccountOverrided(key)) return;
const records = this.profile.preferences[key];
- records.push([{
- server: null,
+ records.push([makeCond({
account: `${host}/${$i!.id}`,
- device: null,
- }, this.store.s[key]]);
+ }), this.s[key], {}]);
this.save();
}
@@ -179,13 +297,64 @@ export class ProfileManager extends EventEmitter<{
const records = this.profile.preferences[key];
- const index = records.findIndex(([cond, v]) => cond.account === `${host}/${$i!.id}`);
+ const index = records.findIndex(([cond, v]) => parseCond(cond).account === `${host}/${$i!.id}`);
if (index === -1) return;
records.splice(index, 1);
- this.store.rewrite(key, this.getMatchedRecord(key)[1]);
+ this.rewriteRawState(key, this.getMatchedRecord(key)[1]);
+
+ this.save();
+ }
+
+ public isSyncEnabled<K extends keyof PREF>(key: K): boolean {
+ return this.getMatchedRecord(key)[2].sync ?? false;
+ }
+
+ public async enableSync<K extends keyof PREF>(key: K): Promise<{ enabled: boolean; } | null> {
+ if (this.isSyncEnabled(key)) return Promise.resolve(null);
+ const existing = await this.storageProvider.cloudGet({ key });
+ if (existing != null) {
+ const { canceled, result } = await os.select({
+ title: i18n.ts.preferenceSyncConflictTitle,
+ text: i18n.ts.preferenceSyncConflictText,
+ items: [{
+ text: i18n.ts.preferenceSyncConflictChoiceServer,
+ value: 'remote',
+ }, {
+ text: i18n.ts.preferenceSyncConflictChoiceDevice,
+ value: 'local',
+ }, {
+ text: i18n.ts.preferenceSyncConflictChoiceCancel,
+ value: null,
+ }],
+ default: 'remote',
+ });
+ if (canceled || result == null) return { enabled: false };
+
+ if (result === 'remote') {
+ this.commit(key, existing.value);
+ } else if (result === 'local') {
+ // nop
+ }
+ }
+
+ const record = this.getMatchedRecord(key);
+ record[2].sync = true;
+ this.save();
+
+ // awaitの必要性は無い
+ this.storageProvider.cloudSet({ key, value: this.s[key] });
+
+ return { enabled: true };
+ }
+
+ public disableSync<K extends keyof PREF>(key: K) {
+ if (!this.isSyncEnabled(key)) return;
+
+ const record = this.getMatchedRecord(key);
+ delete record[2].sync;
this.save();
}
@@ -198,13 +367,14 @@ export class ProfileManager extends EventEmitter<{
this.profile = profile;
const states = this.genStates();
for (const key in states) {
- this.store.rewrite(key, states[key]);
+ this.rewriteRawState(key, states[key]);
}
+
+ this.fetchCloudValues();
}
public getPerPrefMenu<K extends keyof PREF>(key: K): MenuItem[] {
const overrideByAccount = ref(this.isAccountOverrided(key));
-
watch(overrideByAccount, () => {
if (overrideByAccount.value) {
this.setAccountOverride(key);
@@ -213,6 +383,18 @@ export class ProfileManager extends EventEmitter<{
}
});
+ const sync = ref(this.isSyncEnabled(key));
+ watch(sync, () => {
+ if (sync.value) {
+ this.enableSync(key).then((res) => {
+ if (res == null) return;
+ if (!res.enabled) sync.value = false;
+ });
+ } else {
+ this.disableSync(key);
+ }
+ });
+
return [{
icon: 'ti ti-copy',
text: i18n.ts.copyPreferenceId,
@@ -224,7 +406,7 @@ export class ProfileManager extends EventEmitter<{
text: i18n.ts.resetToDefaultValue,
danger: true,
action: () => {
- this.store.commit(key, PREF_DEF[key].default);
+ this.commit(key, PREF_DEF[key].default);
},
}, {
type: 'divider',
@@ -233,6 +415,11 @@ export class ProfileManager extends EventEmitter<{
icon: 'ti ti-user-cog',
text: i18n.ts.overrideByAccount,
ref: overrideByAccount,
+ }, {
+ type: 'switch',
+ icon: 'ti ti-cloud-cog',
+ text: i18n.ts.syncBetweenDevices,
+ ref: sync,
}];
}
}
diff --git a/packages/frontend/src/preferences/store.ts b/packages/frontend/src/preferences/store.ts
deleted file mode 100644
index e061021be3..0000000000
--- a/packages/frontend/src/preferences/store.ts
+++ /dev/null
@@ -1,94 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import { computed, onUnmounted, ref, watch } from 'vue';
-import { EventEmitter } from 'eventemitter3';
-import type { Ref, WritableComputedRef } from 'vue';
-
-// NOTE: 明示的な設定値のひとつとして null もあり得るため、設定が存在しないかどうかを判定する目的で null で比較したり ?? を使ってはいけない
-
-//type DottedToNested<T extends Record<string, any>> = {
-// [K in keyof T as K extends string ? K extends `${infer A}.${infer B}` ? A : K : K]: K extends `${infer A}.${infer B}` ? DottedToNested<{ [key in B]: T[K] }> : T[K];
-//};
-
-type StoreEvent<Data extends Record<string, any>> = {
- updated: <K extends keyof Data>(ctx: {
- key: K;
- value: Data[K];
- }) => void;
-};
-
-export class Store<Data extends Record<string, any>> extends EventEmitter<StoreEvent<Data>> {
- /**
- * static / state の略 (static が予約語のため)
- */
- public s = {} as {
- [K in keyof Data]: Data[K];
- };
-
- /**
- * reactive の略
- */
- public r = {} as {
- [K in keyof Data]: Ref<Data[K]>;
- };
-
- constructor(data: { [K in keyof Data]: Data[K] }) {
- super();
-
- for (const key in data) {
- this.s[key] = data[key];
- this.r[key] = ref(this.s[key]);
- }
- }
-
- public commit<K extends keyof Data>(key: K, value: Data[K]) {
- const v = JSON.parse(JSON.stringify(value)); // deep copy 兼 vueのプロキシ解除
- this.r[key].value = this.s[key] = v;
- this.emit('updated', { key, value: v });
- }
-
- public rewrite<K extends keyof Data>(key: K, value: Data[K]) {
- const v = JSON.parse(JSON.stringify(value)); // deep copy 兼 vueのプロキシ解除
- this.r[key].value = this.s[key] = v;
- }
-
- /**
- * 特定のキーの、簡易的なcomputed refを作ります
- * 主にvue上で設定コントロールのmodelとして使う用
- */
- public model<K extends keyof Data, V extends Data[K] = Data[K]>(
- key: K,
- getter?: (v: Data[K]) => V,
- setter?: (v: V) => Data[K],
- ): WritableComputedRef<V> {
- const valueRef = ref(this.s[key]);
-
- const stop = watch(this.r[key], val => {
- valueRef.value = val;
- });
-
- // NOTE: vueコンポーネント内で呼ばれない限りは、onUnmounted は無意味なのでメモリリークする
- onUnmounted(() => {
- stop();
- });
-
- // TODO: VueのcustomRef使うと良い感じになるかも
- return computed({
- get: () => {
- if (getter) {
- return getter(valueRef.value);
- } else {
- return valueRef.value;
- }
- },
- set: (value) => {
- const val = setter ? setter(value) : value;
- this.commit(key, val);
- valueRef.value = val;
- },
- });
- }
-}
diff --git a/packages/frontend/src/preferences/utility.ts b/packages/frontend/src/preferences/utility.ts
index 64b2bde4de..fc6eff5f49 100644
--- a/packages/frontend/src/preferences/utility.ts
+++ b/packages/frontend/src/preferences/utility.ts
@@ -9,7 +9,7 @@ import type { MenuItem } from '@/types/menu.js';
import { copyToClipboard } from '@/utility/copy-to-clipboard.js';
import { i18n } from '@/i18n.js';
import { miLocalStorage } from '@/local-storage.js';
-import { prefer, profileManager } from '@/preferences.js';
+import { prefer } from '@/preferences.js';
import * as os from '@/os.js';
import { store } from '@/store.js';
import { $i } from '@/account.js';
@@ -17,7 +17,7 @@ import { misskeyApi } from '@/utility/misskey-api.js';
import { unisonReload } from '@/utility/unison-reload.js';
function canAutoBackup() {
- return profileManager.profile.name != null && profileManager.profile.name.trim() !== '';
+ return prefer.profile.name != null && prefer.profile.name.trim() !== '';
}
export function getPreferencesProfileMenu(): MenuItem[] {
@@ -42,7 +42,7 @@ export function getPreferencesProfileMenu(): MenuItem[] {
const menu: MenuItem[] = [{
type: 'label',
- text: profileManager.profile.name || `(${i18n.ts.noName})`,
+ text: prefer.profile.name || `(${i18n.ts.noName})`,
}, {
text: i18n.ts.rename,
icon: 'ti ti-pencil',
@@ -83,7 +83,7 @@ export function getPreferencesProfileMenu(): MenuItem[] {
text: 'Copy profile as text',
icon: 'ti ti-clipboard',
action: () => {
- copyToClipboard(JSON.stringify(profileManager.profile, null, '\t'));
+ copyToClipboard(JSON.stringify(prefer.profile, null, '\t'));
},
});
}
@@ -95,16 +95,16 @@ async function renameProfile() {
const { canceled, result: name } = await os.inputText({
title: i18n.ts._preferencesProfile.profileName,
text: i18n.ts._preferencesProfile.profileNameDescription + '\n' + i18n.ts._preferencesProfile.profileNameDescription2,
- placeholder: profileManager.profile.name || null,
- default: profileManager.profile.name || null,
+ placeholder: prefer.profile.name || null,
+ default: prefer.profile.name || null,
});
if (canceled || name == null || name.trim() === '') return;
- profileManager.renameProfile(name);
+ prefer.renameProfile(name);
}
function exportCurrentProfile() {
- const p = profileManager.profile;
+ const p = prefer.profile;
const txtBlob = new Blob([JSON.stringify(p)], { type: 'text/plain' });
const dummya = document.createElement('a');
dummya.href = URL.createObjectURL(txtBlob);
@@ -140,8 +140,8 @@ export async function cloudBackup() {
await misskeyApi('i/registry/set', {
scope: ['client', 'preferences', 'backups'],
- key: profileManager.profile.name,
- value: profileManager.profile,
+ key: prefer.profile.name,
+ value: prefer.profile,
});
}