From d30ddd4c2ebcacc0d0b49c74e8dfe05b5422ba2e Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Sun, 9 Mar 2025 12:34:08 +0900 Subject: Refine preferences (#15597) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * wip * wip * wip * test * wip rollup pluginでsearchIndexの情報生成 * wip * SPDX * wip: markerIdを自動付与 * rollupでビルド時・devモード時に毎回uuidを生成するように * 開発サーバーでだけ必要な挙動は開発サーバーのみで * 条件が逆 * wip: childrenの生成 * update comment * update comment * rename auto generated file * hashをパスと行数から決定 * Update privacy.vue * Update privacy.vue * wip * Update general.vue * Update general.vue * wip * wip * Update SearchMarker.vue * wip * Update profile.vue * Update mute-block.vue * Update mute-block.vue * Update general.vue * Update general.vue * childrenがduplicate key errorを吐く問題をいったん解決 * マーカーの形を成形 * loggerを置きかえ * とりあえず省略記法に対応 * Refactor and Format codes * wip * Update settings-search-index.ts * wip * wip * とりあえず不確定要因の仮置きidを削除 * hashの生成を正規化(絶対パスになっていたのを緩和) * pathの入力を省略可能に * adminでもパス生成できるように * Update settings-search-index.ts * Update privacy.vue * wip * build searchIndex * wip * build * Update general.vue * build * Update sounds.vue * build * build * Update sounds.vue * 🎨 * 🎨 * Update privacy.vue * Update privacy.vue * Update security.vue * create-search-indexを多少改善 * build * Update 2fa.vue * wip * 必ずtransformCodeCacheを利用するように, キャッシュの明確な受け渡しを定義 * キャッシュはdevServerでなくても更新 * Revert "wip" This reverts commit 41bffd3a13f55618bf939dc1c9acb2a77ead4054. * inlining * wip * Update theme.vue * 🎨 * wip normalize * Update theme.vue * キャッシュのパス変換 * build * wip * wip * Update SearchMarker.vue * i18n.ts['key'] の形式が取り出せない問題のFix * build * 仮でpath入れ * 必ず絶対パスが使われるように * wip * 🎨 * storybookビルド時はcreateSearchIndexをしない * inliningの構造化 * format code * Update index.vue * wip * wip * 🎨 * wip * wip * wip * wip * wip * wip * wip * wip * clean up * wip * wip * wip * Update rollup-plugin-unwind-css-module-class-name.test.ts * Update navbar.vue * clean up * wip * wip * wip * wip * wip * Update preferences-backups.vue * Update common.ts * Update preferences.ts * wip * wip * wip * wip * Update MkPreferenceContainer.vue * Update MkPreferenceContainer.vue * Update MkPreferenceContainer.vue * enhance: 検索で上下矢印を使用することで検索結果を移動できるように * Update main-boot.ts * refactor * wip * Update sounds.vue * fix(frontend): PageWindowでSearchMarkerが動作するように * enhance(frontend): SearchMarkerの点滅を一定時間で止める * wip * lint fix * fix: 子要素監視が抜けていたのを修正 * アニメーションの回数はCSSで制御するように * refactor * enhance(frontend): 検索インデックス作成時のログを削減 * revert * fix * fix * Update preferences.ts * Update preferences.ts * wip * Update preferences.ts * wip * 🎨 * wip * Update MkPreferenceContainer.vue * wip * Update preferences.ts * wip * Update preferences.ts * Update preferences.ts * wip * wip * Update preferences.ts * wip * wip * Update preferences.ts * Update CHANGELOG.md * Update preferences.ts * Update deck-store.ts * deckStoreをdefaultStoreに統合 * wip * defaultStore -> store * Update profile.ts * wip * refactor * wip: plugin * plugin * plugin * plugin * Update plugin.ts * wip * Update plugin.vue * Update preferences.ts * Update main-boot.ts * wip * fix test * Update plugin.vue * Update plugin.vue * Update utility.ts * wip * wip * Update utility.ts * wip * wip * clean up * Update utility.ts --------- Co-authored-by: tai-cha Co-authored-by: taichan <40626578+tai-cha@users.noreply.github.com> Co-authored-by: kakkokari-gtyih <67428053+kakkokari-gtyih@users.noreply.github.com> --- packages/frontend/src/preferences/def.ts | 308 +++++++++++++++++++++++++++ packages/frontend/src/preferences/profile.ts | 236 ++++++++++++++++++++ packages/frontend/src/preferences/store.ts | 92 ++++++++ packages/frontend/src/preferences/utility.ts | 222 +++++++++++++++++++ 4 files changed, 858 insertions(+) create mode 100644 packages/frontend/src/preferences/def.ts create mode 100644 packages/frontend/src/preferences/profile.ts create mode 100644 packages/frontend/src/preferences/store.ts create mode 100644 packages/frontend/src/preferences/utility.ts (limited to 'packages/frontend/src/preferences') diff --git a/packages/frontend/src/preferences/def.ts b/packages/frontend/src/preferences/def.ts new file mode 100644 index 0000000000..19284de6a1 --- /dev/null +++ b/packages/frontend/src/preferences/def.ts @@ -0,0 +1,308 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import * as Misskey from 'misskey-js'; +import { hemisphere } from '@@/js/intl-const.js'; +import type { Theme } from '@/scripts/theme.js'; +import type { SoundType } from '@/scripts/sound.js'; +import type { Plugin } from '@/plugin.js'; +import { DEFAULT_DEVICE_KIND } from '@/scripts/device-kind.js'; + +/** サウンド設定 */ +export type SoundStore = { + type: Exclude; + volume: number; +} | { + type: '_driveFile_'; + + /** ドライブのファイルID */ + fileId: string; + + /** ファイルURL(こちらが優先される) */ + fileUrl: string; + + volume: number; +}; + +export const PREF_DEF = { + pinnedUserLists: { + accountDependent: true, + default: [] as Misskey.entities.UserList[], + }, + uploadFolder: { + accountDependent: true, + default: null as string | null, + }, + + themes: { + default: [] as Theme[], + }, + lightTheme: { + default: null as Theme | null, + }, + darkTheme: { + default: null as Theme | null, + }, + syncDeviceDarkMode: { + default: true, + }, + defaultNoteVisibility: { + default: 'public' as (typeof Misskey.noteVisibilities)[number], + }, + defaultNoteLocalOnly: { + default: false, + }, + keepCw: { + default: true, + }, + keepOriginalUploading: { + default: false, + }, + rememberNoteVisibility: { + default: false, + }, + reportError: { + default: false, + }, + collapseRenotes: { + default: true, + }, + menu: { + default: [ + 'notifications', + 'clips', + 'drive', + 'followRequests', + '-', + 'explore', + 'announcements', + 'search', + '-', + 'ui', + ], + }, + statusbars: { + default: [] as { + name: string; + id: string; + type: string; + size: 'verySmall' | 'small' | 'medium' | 'large' | 'veryLarge'; + black: boolean; + props: Record; + }[], + }, + serverDisconnectedBehavior: { + default: 'quiet' as 'quiet' | 'reload' | 'dialog', + }, + nsfw: { + default: 'respect' as 'respect' | 'force' | 'ignore', + }, + highlightSensitiveMedia: { + default: false, + }, + animation: { + default: !window.matchMedia('(prefers-reduced-motion)').matches, + }, + animatedMfm: { + default: !window.matchMedia('(prefers-reduced-motion)').matches, + }, + advancedMfm: { + default: true, + }, + showReactionsCount: { + default: false, + }, + enableQuickAddMfmFunction: { + default: false, + }, + loadRawImages: { + default: false, + }, + imageNewTab: { + default: false, + }, + disableShowingAnimatedImages: { + default: window.matchMedia('(prefers-reduced-motion)').matches, + }, + emojiStyle: { + default: 'twemoji', // twemoji / fluentEmoji / native + }, + menuStyle: { + default: 'auto' as 'auto' | 'popup' | 'drawer', + }, + useBlurEffectForModal: { + default: DEFAULT_DEVICE_KIND === 'desktop', + }, + useBlurEffect: { + default: DEFAULT_DEVICE_KIND === 'desktop', + }, + showFixedPostForm: { + default: false, + }, + showFixedPostFormInChannel: { + default: false, + }, + enableInfiniteScroll: { + default: true, + }, + useReactionPickerForContextMenu: { + default: false, + }, + showGapBetweenNotesInTimeline: { + default: false, + }, + instanceTicker: { + default: 'remote' as 'none' | 'remote' | 'always', + }, + emojiPickerScale: { + default: 1, + }, + emojiPickerWidth: { + default: 1, + }, + emojiPickerHeight: { + default: 2, + }, + emojiPickerStyle: { + default: 'auto' as 'auto' | 'popup' | 'drawer', + }, + squareAvatars: { + default: false, + }, + showAvatarDecorations: { + default: true, + }, + numberOfPageCache: { + default: 3, + }, + showNoteActionsOnlyHover: { + default: false, + }, + showClipButtonInNoteFooter: { + default: false, + }, + reactionsDisplaySize: { + default: 'medium' as 'small' | 'medium' | 'large', + }, + limitWidthOfReaction: { + default: true, + }, + forceShowAds: { + default: false, + }, + aiChanMode: { + default: false, + }, + devMode: { + default: false, + }, + mediaListWithOneImageAppearance: { + default: 'expand' as 'expand' | '16_9' | '1_1' | '2_3', + }, + notificationPosition: { + default: 'rightBottom' as 'leftTop' | 'leftBottom' | 'rightTop' | 'rightBottom', + }, + notificationStackAxis: { + default: 'horizontal' as 'vertical' | 'horizontal', + }, + enableCondensedLine: { + default: true, + }, + keepScreenOn: { + default: false, + }, + disableStreamingTimeline: { + default: false, + }, + useGroupedNotifications: { + default: true, + }, + dataSaver: { + default: { + media: false, + avatar: false, + urlPreview: false, + code: false, + } as Record, + }, + hemisphere: { + default: hemisphere as 'N' | 'S', + }, + enableSeasonalScreenEffect: { + default: false, + }, + enableHorizontalSwipe: { + default: true, + }, + useNativeUiForVideoAudioPlayer: { + default: false, + }, + keepOriginalFilename: { + default: true, + }, + alwaysConfirmFollow: { + default: true, + }, + confirmWhenRevealingSensitiveMedia: { + default: false, + }, + contextMenu: { + default: 'app' as 'app' | 'appWithShift' | 'native', + }, + skipNoteRender: { + default: true, + }, + showSoftWordMutedWord: { + default: false, + }, + confirmOnReact: { + default: false, + }, + plugins: { + default: [] as Plugin[], + }, + 'sound.masterVolume': { + default: 0.3, + }, + 'sound.notUseSound': { + default: false, + }, + 'sound.useSoundOnlyWhenActive': { + default: false, + }, + 'sound.on.note': { + default: { type: 'syuilo/n-aec', volume: 1 } as SoundStore, + }, + 'sound.on.noteMy': { + default: { type: 'syuilo/n-cea-4va', volume: 1 } as SoundStore, + }, + 'sound.on.notification': { + default: { type: 'syuilo/n-ea', volume: 1 } as SoundStore, + }, + 'sound.on.reaction': { + default: { type: 'syuilo/bubble2', volume: 1 } as SoundStore, + }, + 'deck.alwaysShowMainColumn': { + default: true, + }, + 'deck.navWindow': { + default: true, + }, + 'deck.useSimpleUiForNonRootPages': { + default: true, + }, + 'deck.columnAlign': { + default: 'left' as 'left' | 'right' | 'center', + }, + 'game.dropAndFusion': { + default: { + bgmVolume: 0.25, + sfxVolume: 1, + }, + }, +} satisfies Record; diff --git a/packages/frontend/src/preferences/profile.ts b/packages/frontend/src/preferences/profile.ts new file mode 100644 index 0000000000..516fa2b557 --- /dev/null +++ b/packages/frontend/src/preferences/profile.ts @@ -0,0 +1,236 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { 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 { MenuItem } from '@/types/menu.js'; +import { $i } from '@/account.js'; +import { copyToClipboard } from '@/scripts/copy-to-clipboard.js'; +import { i18n } from '@/i18n.js'; + +// NOTE: 明示的な設定値のひとつとして null もあり得るため、設定が存在しないかどうかを判定する目的で null で比較したり ?? を使ってはいけない + +//type DottedToNested> = { +// [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 PREF = typeof PREF_DEF; +type ValueOf = PREF[K]['default']; +type Account = string; // / + +type Cond = { + server: string | null; // 将来のため + account: Account | null; + device: string | null; // 将来のため +}; + +export type PreferencesProfile = { + id: string; + version: string; + type: 'main'; + modifiedAt: number; + name: string; + preferences: { + [K in keyof PREF]: [Cond, ValueOf][]; + }; + syncByAccount: [Account, keyof PREF][], +}; + +export class ProfileManager extends EventEmitter<{ + updated: (ctx: { + profile: PreferencesProfile + }) => void; +}> { + public profile: PreferencesProfile; + public store: Store<{ + [K in keyof PREF]: ValueOf; + }>; + + constructor(profile: PreferencesProfile) { + super(); + this.profile = profile; + + const states = this.genStates(); + + this.store = new Store(states); + this.store.addListener('updated', ({ key, value }) => { + console.log('prefer:set', key, value); + + 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; + } + + record[1] = value; + this.save(); + }); + } + + private genStates() { + const states = {} as { [K in keyof PREF]: ValueOf }; + let key: keyof PREF; + for (key in PREF_DEF) { + const record = this.getMatchedRecord(key); + states[key] = record[1]; + } + + return states; + } + + 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]]; + } + return { + id: uuid(), + version: version, + type: 'main', + modifiedAt: Date.now(), + name: '', + preferences: data, + syncByAccount: [], + }; + } + + public static normalizeProfile(profile: any): PreferencesProfile { + const data = {} as PreferencesProfile['preferences']; + let key: keyof PREF; + for (key in PREF_DEF) { + const records = profile.preferences[key]; + if (records == null || records.length === 0) { + data[key] = [[{ + server: null, + account: null, + device: null, + }, PREF_DEF[key].default]]; + continue; + } else { + data[key] = records; + } + } + + return { + ...profile, + preferences: data, + }; + } + + public save() { + this.profile.modifiedAt = Date.now(); + this.profile.version = version; + this.emit('updated', { profile: this.profile }); + } + + public getMatchedRecord(key: K): [Cond, ValueOf] { + const records = this.profile.preferences[key]; + + if ($i == null) return records.find(([cond, v]) => cond.account == null)!; + + const accountOverrideRecord = records.find(([cond, v]) => cond.account === `${host}/${$i!.id}`); + if (accountOverrideRecord) return accountOverrideRecord; + + const record = records.find(([cond, v]) => cond.account == null); + return record!; + } + + public isAccountOverrided(key: K): boolean { + if ($i == null) return false; + return this.profile.preferences[key].some(([cond, v]) => cond.account === `${host}/${$i!.id}`) ?? false; + } + + public setAccountOverride(key: K) { + if ($i == null) return; + if (PREF_DEF[key].accountDependent) throw new Error('already account-dependent'); + if (this.isAccountOverrided(key)) return; + + const records = this.profile.preferences[key]; + records.push([{ + server: null, + account: `${host}/${$i!.id}`, + device: null, + }, this.store.s[key]]); + + this.save(); + } + + public clearAccountOverride(key: K) { + if ($i == null) return; + if (PREF_DEF[key].accountDependent) throw new Error('cannot clear override for this account-dependent property'); + + const records = this.profile.preferences[key]; + + const index = records.findIndex(([cond, v]) => cond.account === `${host}/${$i!.id}`); + if (index === -1) return; + + records.splice(index, 1); + + this.store.rewrite(key, this.getMatchedRecord(key)[1]); + + this.save(); + } + + public renameProfile(name: string) { + this.profile.name = name; + this.save(); + } + + public rewriteProfile(profile: PreferencesProfile) { + this.profile = profile; + const states = this.genStates(); + for (const key in states) { + this.store.rewrite(key, states[key]); + } + } + + public getPerPrefMenu(key: K): MenuItem[] { + const overrideByAccount = ref(this.isAccountOverrided(key)); + + watch(overrideByAccount, () => { + if (overrideByAccount.value) { + this.setAccountOverride(key); + } else { + this.clearAccountOverride(key); + } + }); + + return [{ + icon: 'ti ti-copy', + text: i18n.ts.copyPreferenceId, + action: () => { + copyToClipboard(key); + }, + }, { + icon: 'ti ti-refresh', + text: i18n.ts.resetToDefaultValue, + danger: true, + action: () => { + this.store.set(key, PREF_DEF[key].default); + }, + }, { + type: 'divider', + }, { + type: 'switch', + icon: 'ti ti-user-cog', + text: i18n.ts.overrideByAccount, + ref: overrideByAccount, + }]; + } +} diff --git a/packages/frontend/src/preferences/store.ts b/packages/frontend/src/preferences/store.ts new file mode 100644 index 0000000000..1029346d81 --- /dev/null +++ b/packages/frontend/src/preferences/store.ts @@ -0,0 +1,92 @@ +/* + * 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> = { +// [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> = { + updated: (ctx: { + key: K; + value: Data[K]; + }) => void; +}; + +export class Store> extends EventEmitter> { + /** + * static の略 (static が予約語のため) + */ + public s = {} as { + [K in keyof Data]: Data[K]; + }; + + /** + * reactive の略 + */ + public r = {} as { + [K in keyof Data]: Ref; + }; + + 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 set(key: K, value: Data[K]) { + this.r[key].value = this.s[key] = value; + this.emit('updated', { key, value }); + } + + public rewrite(key: K, value: Data[K]) { + this.r[key].value = this.s[key] = value; + } + + /** + * 特定のキーの、簡易的なcomputed refを作ります + * 主にvue上で設定コントロールのmodelとして使う用 + */ + public model( + key: K, + getter?: (v: Data[K]) => V, + setter?: (v: V) => Data[K], + ): WritableComputedRef { + 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.set(key, val); + valueRef.value = val; + }, + }); + } +} diff --git a/packages/frontend/src/preferences/utility.ts b/packages/frontend/src/preferences/utility.ts new file mode 100644 index 0000000000..7979695231 --- /dev/null +++ b/packages/frontend/src/preferences/utility.ts @@ -0,0 +1,222 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { ref, watch } from 'vue'; +import type { PreferencesProfile } from './profile.js'; +import type { MenuItem } from '@/types/menu.js'; +import { copyToClipboard } from '@/scripts/copy-to-clipboard.js'; +import { i18n } from '@/i18n.js'; +import { miLocalStorage } from '@/local-storage.js'; +import { prefer, profileManager } from '@/preferences.js'; +import * as os from '@/os.js'; +import { store } from '@/store.js'; +import { $i } from '@/account.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; +import { unisonReload } from '@/scripts/unison-reload.js'; + +export function getPreferencesProfileMenu(): MenuItem[] { + const autoBackupEnabled = ref(store.state.enablePreferencesAutoCloudBackup); + + watch(autoBackupEnabled, () => { + if (autoBackupEnabled.value) { + if (profileManager.profile.name == null || profileManager.profile.name.trim() === '') { + autoBackupEnabled.value = false; + os.alert({ + type: 'warning', + title: i18n.ts._preferencesBackup.youNeedToNameYourProfileToEnableAutoBackup, + }); + return; + } + + store.set('enablePreferencesAutoCloudBackup', true); + } else { + store.set('enablePreferencesAutoCloudBackup', false); + } + }); + + const menu: MenuItem[] = [{ + type: 'label', + text: profileManager.profile.name || `(${i18n.ts.noName})`, + }, { + text: i18n.ts.rename, + icon: 'ti ti-pencil', + action: () => { + renameProfile(); + }, + }, { + type: 'switch', + icon: 'ti ti-cloud-up', + text: i18n.ts._preferencesBackup.autoBackup, + ref: autoBackupEnabled, + }, { + text: i18n.ts.export, + icon: 'ti ti-download', + action: () => { + exportCurrentProfile(); + }, + }, { + type: 'divider', + }, { + text: i18n.ts._preferencesBackup.restoreFromBackup, + icon: 'ti ti-cloud-down', + action: () => { + restoreFromCloudBackup(); + }, + }, { + text: i18n.ts.import, + icon: 'ti ti-upload', + action: () => { + importProfile(); + }, + }]; + + if (prefer.s.devMode) { + menu.push({ + type: 'divider', + }, { + text: 'Copy profile as text', + icon: 'ti ti-clipboard', + action: () => { + copyToClipboard(JSON.stringify(profileManager.profile, null, '\t')); + }, + }); + } + + return menu; +} + +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, + }); + if (canceled || name == null || name.trim() === '') return; + + profileManager.renameProfile(name); +} + +function exportCurrentProfile() { + const p = profileManager.profile; + const txtBlob = new Blob([JSON.stringify(p)], { type: 'text/plain' }); + const dummya = document.createElement('a'); + dummya.href = URL.createObjectURL(txtBlob); + dummya.download = `${p.name || p.id}.misskeypreferences`; + dummya.click(); +} + +function importProfile() { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = '.misskeypreferences'; + input.onchange = async () => { + if (input.files == null || input.files.length === 0) return; + + const file = input.files[0]; + const txt = await file.text(); + const profile = JSON.parse(txt) as PreferencesProfile; + + miLocalStorage.setItem('preferences', JSON.stringify(profile)); + miLocalStorage.setItem('hidePreferencesRestoreSuggestion', 'true'); + shouldSuggestRestoreBackup.value = false; + unisonReload(); + }; + + input.click(); +} + +export async function cloudBackup() { + if ($i == null) return; + if (profileManager.profile.name == null || profileManager.profile.name.trim() === '') { + throw new Error('Profile name is not set'); + } + + await misskeyApi('i/registry/set', { + scope: ['client', 'preferences', 'backups'], + key: profileManager.profile.name, + value: profileManager.profile, + }); +} + +export async function restoreFromCloudBackup() { + if ($i == null) return; + + // TODO: 更新日時でソートして取得したい + const keys = await misskeyApi('i/registry/keys', { + scope: ['client', 'preferences', 'backups'], + }); + + console.log(keys); + + if (keys.length === 0) { + os.alert({ + type: 'warning', + title: i18n.ts._preferencesBackup.noBackupsFoundTitle, + text: i18n.ts._preferencesBackup.noBackupsFoundDescription, + }); + return; + } + + const select = await os.select({ + title: i18n.ts._preferencesBackup.selectBackupToRestore, + items: keys.map(k => ({ + text: k, + value: k, + })), + }); + if (select.canceled) return; + if (select.result == null) return; + + const profile = await misskeyApi('i/registry/get', { + scope: ['client', 'preferences', 'backups'], + key: select.result, + }); + + console.log(profile); + + miLocalStorage.setItem('preferences', JSON.stringify(profile)); + miLocalStorage.setItem('hidePreferencesRestoreSuggestion', 'true'); + store.set('enablePreferencesAutoCloudBackup', true); + shouldSuggestRestoreBackup.value = false; + unisonReload(); +} + +export async function enableAutoBackup() { + if (profileManager.profile.name == null || profileManager.profile.name.trim() === '') { + await renameProfile(); + } + + if (profileManager.profile.name == null || profileManager.profile.name.trim() === '') { + return; + } + + store.set('enablePreferencesAutoCloudBackup', true); +} + +export const shouldSuggestRestoreBackup = ref(false); + +if ($i != null) { + if (new Date($i.createdAt).getTime() < (Date.now() - 1000 * 60 * 30)) { // アカウント作成直後は意味ないので除外 + miLocalStorage.setItem('hidePreferencesRestoreSuggestion', 'true'); + } else { + if (miLocalStorage.getItem('hidePreferencesRestoreSuggestion') !== 'true') { + misskeyApi('i/registry/keys', { + scope: ['client', 'preferences', 'backups'], + }).then(keys => { + if (keys.length === 0) { + miLocalStorage.setItem('hidePreferencesRestoreSuggestion', 'true'); + } else { + shouldSuggestRestoreBackup.value = true; + } + }); + } + } +} + +export function hideRestoreBackupSuggestion() { + miLocalStorage.setItem('hidePreferencesRestoreSuggestion', 'true'); + shouldSuggestRestoreBackup.value = false; +} -- cgit v1.2.3-freya