Compare commits
	
		
			11 Commits
		
	
	
		
			develop
			...
			refine-piz
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | 2402754dcc | ||
|   | 2493592bd0 | ||
|   | eec4ab841a | ||
|   | d0b8ffe629 | ||
|   | cef7575b76 | ||
|   | 9842eb2eeb | ||
|   | 05078e9c14 | ||
|   | db5c6fa3c2 | ||
|   | 8a4e2659ed | ||
|   | d19c094a9b | ||
|   | a7f7ff33e7 | 
| @@ -180,12 +180,12 @@ export async function common(createVue: () => App<Element>) { | ||||
|  | ||||
| 	//#region Sync dark mode | ||||
| 	if (prefer.s.syncDeviceDarkMode) { | ||||
| 		store.set('darkMode', isDeviceDarkmode()); | ||||
| 		store.commit('darkMode', isDeviceDarkmode()); | ||||
| 	} | ||||
|  | ||||
| 	window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (mql) => { | ||||
| 		if (prefer.s.syncDeviceDarkMode) { | ||||
| 			store.set('darkMode', mql.matches); | ||||
| 			store.commit('darkMode', mql.matches); | ||||
| 		} | ||||
| 	}); | ||||
| 	//#endregion | ||||
|   | ||||
| @@ -223,10 +223,10 @@ export async function mainBoot() { | ||||
| 				prefer.commit('sound.on.noteMy', store.s.sound_noteMy as any); | ||||
| 				prefer.commit('sound.on.notification', store.s.sound_notification as any); | ||||
| 				prefer.commit('sound.on.reaction', store.s.sound_reaction as any); | ||||
| 				store.set('deck.profile', deckStore.s.profile); | ||||
| 				store.set('deck.columns', deckStore.s.columns); | ||||
| 				store.set('deck.layout', deckStore.s.layout); | ||||
| 				store.set('menu', []); | ||||
| 				store.commit('deck.profile', deckStore.s.profile); | ||||
| 				store.commit('deck.columns', deckStore.s.columns); | ||||
| 				store.commit('deck.layout', deckStore.s.layout); | ||||
| 				store.commit('menu', []); | ||||
| 			} | ||||
|  | ||||
| 			if (store.s.accountSetupWizard !== -1) { | ||||
| @@ -502,7 +502,7 @@ export async function mainBoot() { | ||||
| 			post(); | ||||
| 		}, | ||||
| 		'd': () => { | ||||
| 			store.set('darkMode', !store.s.darkMode); | ||||
| 			store.commit('darkMode', !store.s.darkMode); | ||||
| 		}, | ||||
| 		's': () => { | ||||
| 			mainRouter.push('/search'); | ||||
|   | ||||
| @@ -158,7 +158,7 @@ function complete(type: string, value: any) { | ||||
| 		let recents = store.s.recentlyUsedEmojis; | ||||
| 		recents = recents.filter((emoji: any) => emoji !== value); | ||||
| 		recents.unshift(value); | ||||
| 		store.set('recentlyUsedEmojis', recents.splice(0, 32)); | ||||
| 		store.commit('recentlyUsedEmojis', recents.splice(0, 32)); | ||||
| 	} | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -432,7 +432,7 @@ function chosen(emoji: string | Misskey.entities.EmojiSimple | UnicodeEmojiDef, | ||||
| 		let recents = store.s.recentlyUsedEmojis; | ||||
| 		recents = recents.filter((emoji) => emoji !== key); | ||||
| 		recents.unshift(key); | ||||
| 		store.set('recentlyUsedEmojis', recents.splice(0, 32)); | ||||
| 		store.commit('recentlyUsedEmojis', recents.splice(0, 32)); | ||||
| 	} | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -177,7 +177,7 @@ const files = ref(props.initialFiles ?? []); | ||||
| const poll = ref<PollEditorModelValue | null>(null); | ||||
| const useCw = ref<boolean>(!!props.initialCw); | ||||
| const showPreview = ref(store.s.showPreview); | ||||
| watch(showPreview, () => store.set('showPreview', showPreview.value)); | ||||
| watch(showPreview, () => store.commit('showPreview', showPreview.value)); | ||||
| const showAddMfmFunction = ref(prefer.s.enableQuickAddMfmFunction); | ||||
| watch(showAddMfmFunction, () => prefer.commit('enableQuickAddMfmFunction', showAddMfmFunction.value)); | ||||
| const cw = ref<string | null>(props.initialCw ?? null); | ||||
| @@ -270,8 +270,8 @@ const canPost = computed((): boolean => { | ||||
| 		(!poll.value || poll.value.choices.length >= 2); | ||||
| }); | ||||
|  | ||||
| const withHashtags = computed(store.makeGetterSetter('postFormWithHashtags')); | ||||
| const hashtags = computed(store.makeGetterSetter('postFormHashtags')); | ||||
| const withHashtags = store.model('postFormWithHashtags'); | ||||
| const hashtags = store.model('postFormHashtags'); | ||||
|  | ||||
| watch(text, () => { | ||||
| 	checkMissingMention(); | ||||
| @@ -480,7 +480,7 @@ function setVisibility() { | ||||
| 		changeVisibility: v => { | ||||
| 			visibility.value = v; | ||||
| 			if (prefer.s.rememberNoteVisibility) { | ||||
| 				store.set('visibility', visibility.value); | ||||
| 				store.commit('visibility', visibility.value); | ||||
| 			} | ||||
| 		}, | ||||
| 		closed: () => dispose(), | ||||
| @@ -528,7 +528,7 @@ async function toggleLocalOnly() { | ||||
|  | ||||
| 	localOnly.value = !localOnly.value; | ||||
| 	if (prefer.s.rememberNoteVisibility) { | ||||
| 		store.set('localOnly', localOnly.value); | ||||
| 		store.commit('localOnly', localOnly.value); | ||||
| 	} | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -131,7 +131,7 @@ async function ok() { | ||||
| 	let recents = store.s.recentlyUsedUsers; | ||||
| 	recents = recents.filter(x => x !== selected.value?.id); | ||||
| 	recents.unshift(selected.value.id); | ||||
| 	store.set('recentlyUsedUsers', recents.splice(0, 16)); | ||||
| 	store.commit('recentlyUsedUsers', recents.splice(0, 16)); | ||||
| } | ||||
|  | ||||
| function cancel() { | ||||
|   | ||||
| @@ -152,7 +152,7 @@ const dialog = shallowRef<InstanceType<typeof MkModalWindow>>(); | ||||
| const page = ref(store.s.accountSetupWizard); | ||||
|  | ||||
| watch(page, () => { | ||||
| 	store.set('accountSetupWizard', page.value); | ||||
| 	store.commit('accountSetupWizard', page.value); | ||||
| }); | ||||
|  | ||||
| async function close(skip: boolean) { | ||||
| @@ -165,11 +165,11 @@ async function close(skip: boolean) { | ||||
| 	} | ||||
|  | ||||
| 	dialog.value?.close(); | ||||
| 	store.set('accountSetupWizard', -1); | ||||
| 	store.commit('accountSetupWizard', -1); | ||||
| } | ||||
|  | ||||
| function setupComplete() { | ||||
| 	store.set('accountSetupWizard', -1); | ||||
| 	store.commit('accountSetupWizard', -1); | ||||
| 	dialog.value?.close(); | ||||
| } | ||||
|  | ||||
| @@ -194,7 +194,7 @@ async function later(later: boolean) { | ||||
| 	} | ||||
|  | ||||
| 	dialog.value?.close(); | ||||
| 	store.set('accountSetupWizard', 0); | ||||
| 	store.commit('accountSetupWizard', 0); | ||||
| } | ||||
| </script> | ||||
|  | ||||
|   | ||||
| @@ -113,7 +113,7 @@ const shouldHide = ref(!prefer.s.forceShowAds && $i && $i.policies.canHideAds && | ||||
| function reduceFrequency(): void { | ||||
| 	if (chosen.value == null) return; | ||||
| 	if (store.s.mutedAds.includes(chosen.value.id)) return; | ||||
| 	store.push('mutedAds', chosen.value.id); | ||||
| 	store.commit('mutedAds', [...store.s.mutedAds, chosen.value.id]); | ||||
| 	os.success(); | ||||
| 	chosen.value = choseAd(); | ||||
| 	showMenu.value = false; | ||||
|   | ||||
| @@ -69,15 +69,15 @@ export const loadDeck = async () => { | ||||
| 				return; | ||||
| 			} | ||||
|  | ||||
| 			store.set('deck.columns', []); | ||||
| 			store.set('deck.layout', []); | ||||
| 			store.commit('deck.columns', []); | ||||
| 			store.commit('deck.layout', []); | ||||
| 			return; | ||||
| 		} | ||||
| 		throw err; | ||||
| 	} | ||||
|  | ||||
| 	store.set('deck.columns', deck.columns); | ||||
| 	store.set('deck.layout', deck.layout); | ||||
| 	store.commit('deck.columns', deck.columns); | ||||
| 	store.commit('deck.layout', deck.layout); | ||||
| }; | ||||
|  | ||||
| export async function forceSaveDeck() { | ||||
| @@ -111,14 +111,14 @@ export async function deleteProfile(key: string): Promise<void> { | ||||
|  | ||||
| export function addColumn(column: Column) { | ||||
| 	if (column.name === undefined) column.name = null; | ||||
| 	store.push('deck.columns', column); | ||||
| 	store.push('deck.layout', [column.id]); | ||||
| 	store.commit('deck.columns', [...store.s['deck.columns'], column]); | ||||
| 	store.commit('deck.layout', [...store.s['deck.layout'], [column.id]]); | ||||
| 	saveDeck(); | ||||
| } | ||||
|  | ||||
| export function removeColumn(id: Column['id']) { | ||||
| 	store.set('deck.columns', store.s['deck.columns'].filter(c => c.id !== id)); | ||||
| 	store.set('deck.layout', store.s['deck.layout'] | ||||
| 	store.commit('deck.columns', store.s['deck.columns'].filter(c => c.id !== id)); | ||||
| 	store.commit('deck.layout', store.s['deck.layout'] | ||||
| 		.map(ids => ids.filter(_id => _id !== id)) | ||||
| 		.filter(ids => ids.length > 0)); | ||||
| 	saveDeck(); | ||||
| @@ -132,7 +132,7 @@ export function swapColumn(a: Column['id'], b: Column['id']) { | ||||
| 	const layout = deepClone(store.s['deck.layout']); | ||||
| 	layout[aX][aY] = b; | ||||
| 	layout[bX][bY] = a; | ||||
| 	store.set('deck.layout', layout); | ||||
| 	store.commit('deck.layout', layout); | ||||
| 	saveDeck(); | ||||
| } | ||||
|  | ||||
| @@ -144,7 +144,7 @@ export function swapLeftColumn(id: Column['id']) { | ||||
| 			if (left) { | ||||
| 				layout[i - 1] = store.s['deck.layout'][i]; | ||||
| 				layout[i] = left; | ||||
| 				store.set('deck.layout', layout); | ||||
| 				store.commit('deck.layout', layout); | ||||
| 			} | ||||
| 			return true; | ||||
| 		} | ||||
| @@ -161,7 +161,7 @@ export function swapRightColumn(id: Column['id']) { | ||||
| 			if (right) { | ||||
| 				layout[i + 1] = store.s['deck.layout'][i]; | ||||
| 				layout[i] = right; | ||||
| 				store.set('deck.layout', layout); | ||||
| 				store.commit('deck.layout', layout); | ||||
| 			} | ||||
| 			return true; | ||||
| 		} | ||||
| @@ -182,7 +182,7 @@ export function swapUpColumn(id: Column['id']) { | ||||
| 				ids[i] = up; | ||||
|  | ||||
| 				layout[idsIndex] = ids; | ||||
| 				store.set('deck.layout', layout); | ||||
| 				store.commit('deck.layout', layout); | ||||
| 			} | ||||
| 			return true; | ||||
| 		} | ||||
| @@ -203,7 +203,7 @@ export function swapDownColumn(id: Column['id']) { | ||||
| 				ids[i] = down; | ||||
|  | ||||
| 				layout[idsIndex] = ids; | ||||
| 				store.set('deck.layout', layout); | ||||
| 				store.commit('deck.layout', layout); | ||||
| 			} | ||||
| 			return true; | ||||
| 		} | ||||
| @@ -218,7 +218,7 @@ export function stackLeftColumn(id: Column['id']) { | ||||
| 	layout = layout.map(ids => ids.filter(_id => _id !== id)); | ||||
| 	layout[i - 1].push(id); | ||||
| 	layout = layout.filter(ids => ids.length > 0); | ||||
| 	store.set('deck.layout', layout); | ||||
| 	store.commit('deck.layout', layout); | ||||
| 	saveDeck(); | ||||
| } | ||||
|  | ||||
| @@ -229,7 +229,7 @@ export function popRightColumn(id: Column['id']) { | ||||
| 	layout = layout.map(ids => ids.filter(_id => _id !== id)); | ||||
| 	layout.splice(i + 1, 0, [id]); | ||||
| 	layout = layout.filter(ids => ids.length > 0); | ||||
| 	store.set('deck.layout', layout); | ||||
| 	store.commit('deck.layout', layout); | ||||
|  | ||||
| 	const columns = deepClone(store.s['deck.columns']); | ||||
| 	for (const column of columns) { | ||||
| @@ -237,7 +237,7 @@ export function popRightColumn(id: Column['id']) { | ||||
| 			column.active = true; | ||||
| 		} | ||||
| 	} | ||||
| 	store.set('deck.columns', columns); | ||||
| 	store.commit('deck.columns', columns); | ||||
|  | ||||
| 	saveDeck(); | ||||
| } | ||||
| @@ -250,7 +250,7 @@ export function addColumnWidget(id: Column['id'], widget: ColumnWidget) { | ||||
| 	if (column.widgets == null) column.widgets = []; | ||||
| 	column.widgets.unshift(widget); | ||||
| 	columns[columnIndex] = column; | ||||
| 	store.set('deck.columns', columns); | ||||
| 	store.commit('deck.columns', columns); | ||||
| 	saveDeck(); | ||||
| } | ||||
|  | ||||
| @@ -262,7 +262,7 @@ export function removeColumnWidget(id: Column['id'], widget: ColumnWidget) { | ||||
| 	if (column.widgets == null) column.widgets = []; | ||||
| 	column.widgets = column.widgets.filter(w => w.id !== widget.id); | ||||
| 	columns[columnIndex] = column; | ||||
| 	store.set('deck.columns', columns); | ||||
| 	store.commit('deck.columns', columns); | ||||
| 	saveDeck(); | ||||
| } | ||||
|  | ||||
| @@ -273,7 +273,7 @@ export function setColumnWidgets(id: Column['id'], widgets: ColumnWidget[]) { | ||||
| 	if (column == null) return; | ||||
| 	column.widgets = widgets; | ||||
| 	columns[columnIndex] = column; | ||||
| 	store.set('deck.columns', columns); | ||||
| 	store.commit('deck.columns', columns); | ||||
| 	saveDeck(); | ||||
| } | ||||
|  | ||||
| @@ -288,7 +288,7 @@ export function updateColumnWidget(id: Column['id'], widgetId: string, widgetDat | ||||
| 		data: widgetData, | ||||
| 	} : w); | ||||
| 	columns[columnIndex] = column; | ||||
| 	store.set('deck.columns', columns); | ||||
| 	store.commit('deck.columns', columns); | ||||
| 	saveDeck(); | ||||
| } | ||||
|  | ||||
| @@ -301,6 +301,6 @@ export function updateColumn(id: Column['id'], column: Partial<Column>) { | ||||
| 		currentColumn[k] = v; | ||||
| 	} | ||||
| 	columns[columnIndex] = currentColumn; | ||||
| 	store.set('deck.columns', columns); | ||||
| 	store.commit('deck.columns', columns); | ||||
| 	saveDeck(); | ||||
| } | ||||
|   | ||||
| @@ -93,7 +93,7 @@ function resolved(reportId) { | ||||
| } | ||||
|  | ||||
| function closeTutorial() { | ||||
| 	store.set('abusesTutorial', false); | ||||
| 	store.commit('abusesTutorial', false); | ||||
| } | ||||
|  | ||||
| const headerActions = computed(() => []); | ||||
|   | ||||
| @@ -240,13 +240,13 @@ function getHTMLElement(ev: MouseEvent): HTMLElement { | ||||
| } | ||||
|  | ||||
| watch(pinnedEmojisForReaction, () => { | ||||
| 	store.set('reactions', pinnedEmojisForReaction.value); | ||||
| 	store.commit('reactions', pinnedEmojisForReaction.value); | ||||
| }, { | ||||
| 	deep: true, | ||||
| }); | ||||
|  | ||||
| watch(pinnedEmojis, () => { | ||||
| 	store.set('pinnedEmojis', pinnedEmojis.value); | ||||
| 	store.commit('pinnedEmojis', pinnedEmojis.value); | ||||
| }, { | ||||
| 	deep: true, | ||||
| }); | ||||
|   | ||||
| @@ -72,7 +72,7 @@ const ro = new ResizeObserver((entries, observer) => { | ||||
| }); | ||||
|  | ||||
| function skipAutoBackup() { | ||||
| 	store.set('showPreferencesAutoCloudBackupSuggestion', false); | ||||
| 	store.commit('showPreferencesAutoCloudBackupSuggestion', false); | ||||
| } | ||||
|  | ||||
| const menuDef = computed<SuperMenuDef[]>(() => [{ | ||||
|   | ||||
| @@ -67,7 +67,7 @@ const items = ref(prefer.s.menu.map(x => ({ | ||||
| 	type: x, | ||||
| }))); | ||||
|  | ||||
| const menuDisplay = computed(store.makeGetterSetter('menuDisplay')); | ||||
| const menuDisplay = store.model('menuDisplay'); | ||||
|  | ||||
| async function addItem() { | ||||
| 	const menu = Object.keys(navbarItemDef).filter(k => !prefer.s.menu.includes(k)); | ||||
|   | ||||
| @@ -131,7 +131,7 @@ const reportError = prefer.model('reportError'); | ||||
| const enableCondensedLine = prefer.model('enableCondensedLine'); | ||||
| const skipNoteRender = prefer.model('skipNoteRender'); | ||||
| const devMode = prefer.model('devMode'); | ||||
| const defaultWithReplies = computed(store.makeGetterSetter('defaultWithReplies')); | ||||
| const defaultWithReplies = store.model('defaultWithReplies'); | ||||
|  | ||||
| watch(skipNoteRender, async () => { | ||||
| 	await reloadAsk({ reason: i18n.ts.reloadToApplySetting, unison: true }); | ||||
|   | ||||
| @@ -461,7 +461,7 @@ function downloadEmojiIndex(lang: typeof emojiIndexLangs[number]) { | ||||
| 		} | ||||
|  | ||||
| 		currentIndexes[lang] = await download(); | ||||
| 		await store.set('additionalUnicodeEmojiIndexes', currentIndexes); | ||||
| 		await store.commit('additionalUnicodeEmojiIndexes', currentIndexes); | ||||
| 	} | ||||
|  | ||||
| 	os.promiseDialog(main()); | ||||
| @@ -471,7 +471,7 @@ function removeEmojiIndex(lang: string) { | ||||
| 	async function main() { | ||||
| 		const currentIndexes = store.s.additionalUnicodeEmojiIndexes; | ||||
| 		delete currentIndexes[lang]; | ||||
| 		await store.set('additionalUnicodeEmojiIndexes', currentIndexes); | ||||
| 		await store.commit('additionalUnicodeEmojiIndexes', currentIndexes); | ||||
| 	} | ||||
|  | ||||
| 	os.promiseDialog(main()); | ||||
|   | ||||
| @@ -177,7 +177,7 @@ const $i = signinRequired(); | ||||
|  | ||||
| const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default)); | ||||
|  | ||||
| const reactionAcceptance = computed(store.makeGetterSetter('reactionAcceptance')); | ||||
| const reactionAcceptance = store.model('reactionAcceptance'); | ||||
|  | ||||
| function assertVaildLang(lang: string | null): lang is keyof typeof langmap { | ||||
| 	return lang != null && lang in langmap; | ||||
|   | ||||
| @@ -196,14 +196,14 @@ const lightThemeId = computed({ | ||||
| 	}, | ||||
| }); | ||||
|  | ||||
| const darkMode = computed(store.makeGetterSetter('darkMode')); | ||||
| const darkMode = store.model('darkMode'); | ||||
| const syncDeviceDarkMode = prefer.model('syncDeviceDarkMode'); | ||||
| const wallpaper = ref(miLocalStorage.getItem('wallpaper')); | ||||
| const themesCount = installedThemes.value.length; | ||||
|  | ||||
| watch(syncDeviceDarkMode, () => { | ||||
| 	if (syncDeviceDarkMode.value) { | ||||
| 		store.set('darkMode', isDeviceDarkmode()); | ||||
| 		store.commit('darkMode', isDeviceDarkmode()); | ||||
| 	} | ||||
| }); | ||||
|  | ||||
|   | ||||
| @@ -44,11 +44,11 @@ const pagination = { | ||||
| const notes = ref<InstanceType<typeof MkNotes>>(); | ||||
|  | ||||
| async function post() { | ||||
| 	store.set('postFormHashtags', props.tag); | ||||
| 	store.set('postFormWithHashtags', true); | ||||
| 	store.commit('postFormHashtags', props.tag); | ||||
| 	store.commit('postFormWithHashtags', true); | ||||
| 	await os.post(); | ||||
| 	store.set('postFormHashtags', ''); | ||||
| 	store.set('postFormWithHashtags', false); | ||||
| 	store.commit('postFormHashtags', ''); | ||||
| 	store.commit('postFormWithHashtags', false); | ||||
| 	notes.value?.pagingComponent?.reload(); | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -203,7 +203,7 @@ function saveSrc(newSrc: TimelinePageSrc): void { | ||||
| 		out.userList = prefer.r.pinnedUserLists.value.find(l => l.id === id) ?? null; | ||||
| 	} | ||||
|  | ||||
| 	store.set('tl', out); | ||||
| 	store.commit('tl', out); | ||||
| 	if (['local', 'global'].includes(newSrc)) { | ||||
| 		srcWhenNotSignin.value = newSrc as 'local' | 'global'; | ||||
| 	} | ||||
| @@ -212,7 +212,7 @@ function saveSrc(newSrc: TimelinePageSrc): void { | ||||
| function saveTlFilter(key: keyof typeof store.s.tl.filter, newValue: boolean) { | ||||
| 	if (key !== 'withReplies' || $i) { | ||||
| 		const out = deepMerge({ filter: { [key]: newValue } }, store.s.tl); | ||||
| 		store.set('tl', out); | ||||
| 		store.commit('tl', out); | ||||
| 	} | ||||
| } | ||||
|  | ||||
| @@ -233,7 +233,7 @@ function closeTutorial(): void { | ||||
| 	if (!isBasicTimeline(src.value)) return; | ||||
| 	const before = store.s.timelineTutorials; | ||||
| 	before[src.value] = true; | ||||
| 	store.set('timelineTutorials', before); | ||||
| 	store.commit('timelineTutorials', before); | ||||
| } | ||||
|  | ||||
| function switchTlIfNeeded() { | ||||
|   | ||||
| @@ -3,256 +3,67 @@ | ||||
|  * SPDX-License-Identifier: AGPL-3.0-only | ||||
|  */ | ||||
|  | ||||
| // PIZZAX --- A lightweight store | ||||
| import { computed, onUnmounted, ref, watch } from 'vue'; | ||||
| import { EventEmitter } from 'eventemitter3'; | ||||
| import type { Ref, WritableComputedRef } from 'vue'; | ||||
|  | ||||
| import { onUnmounted, ref, watch } from 'vue'; | ||||
| import { BroadcastChannel } from 'broadcast-channel'; | ||||
| import type { Ref } from 'vue'; | ||||
| import { $i } from '@/account.js'; | ||||
| import { misskeyApi } from '@/utility/misskey-api.js'; | ||||
| import { get, set } from '@/utility/idb-proxy.js'; | ||||
| import { store } from '@/store.js'; | ||||
| import { useStream } from '@/stream.js'; | ||||
| import { deepClone } from '@/utility/clone.js'; | ||||
| import { deepMerge } from '@/utility/merge.js'; | ||||
| // NOTE: 明示的な設定値のひとつとして null もあり得るため、設定が存在しないかどうかを判定する目的で null で比較したり ?? を使ってはいけない | ||||
|  | ||||
| type StateDef = Record<string, { | ||||
| 	where: 'account' | 'device' | 'deviceAccount'; | ||||
| 	default: any; | ||||
| }>; | ||||
| //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 State<T extends StateDef> = { [K in keyof T]: T[K]['default']; }; | ||||
| type ReactiveState<T extends StateDef> = { [K in keyof T]: Ref<T[K]['default']>; }; | ||||
|  | ||||
| type ArrayElement<A> = A extends readonly (infer T)[] ? T : never; | ||||
|  | ||||
| type PizzaxChannelMessage<T extends StateDef> = { | ||||
| 	where: 'device' | 'deviceAccount'; | ||||
| 	key: keyof T; | ||||
| 	value: T[keyof T]['default']; | ||||
| 	userId?: string; | ||||
| type PizzaxEvent<Data extends Record<string, any>> = { | ||||
| 	updated: <K extends keyof Data>(ctx: { | ||||
| 		key: K; | ||||
| 		value: Data[K]; | ||||
| 	}) => void; | ||||
| }; | ||||
|  | ||||
| export class Storage<T extends StateDef> { | ||||
| 	public readonly ready: Promise<void>; | ||||
| 	public readonly loaded: Promise<void>; | ||||
|  | ||||
| 	public readonly key: string; | ||||
| 	public readonly deviceStateKeyName: `pizzax::${this['key']}`; | ||||
| 	public readonly deviceAccountStateKeyName: `pizzax::${this['key']}::${string}` | ''; | ||||
| 	public readonly registryCacheKeyName: `pizzax::${this['key']}::cache::${string}` | ''; | ||||
|  | ||||
| 	public readonly def: T; | ||||
|  | ||||
| 	// TODO: これが実装されたらreadonlyにしたい: https://github.com/microsoft/TypeScript/issues/37487 | ||||
| export class Pizzax<Data extends Record<string, any>> extends EventEmitter<PizzaxEvent<Data>> { | ||||
| 	/** | ||||
| 	 * static / state の略 (static が予約語のため) | ||||
| 	 */ | ||||
| 	public readonly s: State<T>; | ||||
| 	public s = {} as { | ||||
| 		[K in keyof Data]: Data[K]; | ||||
| 	}; | ||||
|  | ||||
| 	/** | ||||
| 	 * reactive の略 | ||||
| 	 */ | ||||
| 	public readonly r: ReactiveState<T>; | ||||
| 	public r = {} as { | ||||
| 		[K in keyof Data]: Ref<Data[K]>; | ||||
| 	}; | ||||
|  | ||||
| 	private pizzaxChannel: BroadcastChannel<PizzaxChannelMessage<T>>; | ||||
| 	constructor(data: { [K in keyof Data]: Data[K] }) { | ||||
| 		super(); | ||||
|  | ||||
| 	// 簡易的にキューイングして占有ロックとする | ||||
| 	private currentIdbJob: Promise<any> = Promise.resolve(); | ||||
| 	private addIdbSetJob<T>(job: () => Promise<T>) { | ||||
| 		const promise = this.currentIdbJob.then(job, err => { | ||||
| 			console.error('Pizzax failed to save data to idb!', err); | ||||
| 			return job(); | ||||
| 		}); | ||||
| 		this.currentIdbJob = promise; | ||||
| 		return promise; | ||||
| 	} | ||||
|  | ||||
| 	constructor(key: string, def: T) { | ||||
| 		this.key = key; | ||||
| 		this.deviceStateKeyName = `pizzax::${key}`; | ||||
| 		this.deviceAccountStateKeyName = $i ? `pizzax::${key}::${$i.id}` : ''; | ||||
| 		this.registryCacheKeyName = $i ? `pizzax::${key}::cache::${$i.id}` : ''; | ||||
| 		this.def = def; | ||||
|  | ||||
| 		this.pizzaxChannel = new BroadcastChannel(`pizzax::${key}`); | ||||
|  | ||||
| 		this.s = {} as State<T>; | ||||
| 		this.r = {} as ReactiveState<T>; | ||||
|  | ||||
| 		for (const [k, v] of Object.entries(def) as [keyof T, T[keyof T]['default']][]) { | ||||
| 			this.s[k] = v.default; | ||||
| 			this.r[k] = ref(v.default); | ||||
| 		} | ||||
|  | ||||
| 		this.ready = this.init(); | ||||
| 		this.loaded = this.ready.then(() => this.load()); | ||||
| 	} | ||||
|  | ||||
| 	private isPureObject(value: unknown): value is Record<string | number | symbol, unknown> { | ||||
| 		return typeof value === 'object' && value !== null && !Array.isArray(value); | ||||
| 	} | ||||
|  | ||||
| 	private mergeState<X>(value: X, def: X): X { | ||||
| 		if (this.isPureObject(value) && this.isPureObject(def)) { | ||||
| 			const merged = deepMerge(value, def); | ||||
|  | ||||
| 			if (_DEV_) console.log('Merging state. Incoming: ', value, ' Default: ', def, ' Result: ', merged); | ||||
|  | ||||
| 			return merged as X; | ||||
| 		} | ||||
| 		return value; | ||||
| 	} | ||||
|  | ||||
| 	private async init(): Promise<void> { | ||||
| 		await this.migrate(); | ||||
|  | ||||
| 		const deviceState: State<T> = await get(this.deviceStateKeyName) || {}; | ||||
| 		const deviceAccountState = $i ? await get(this.deviceAccountStateKeyName) || {} : {}; | ||||
| 		const registryCache = $i ? await get(this.registryCacheKeyName) || {} : {}; | ||||
|  | ||||
| 		for (const [k, v] of Object.entries(this.def) as [keyof T, T[keyof T]['default']][]) { | ||||
| 			if (v.where === 'device' && Object.prototype.hasOwnProperty.call(deviceState, k)) { | ||||
| 				this.r[k].value = this.s[k] = this.mergeState<T[keyof T]['default']>(deviceState[k], v.default); | ||||
| 			} else if (v.where === 'account' && $i && Object.prototype.hasOwnProperty.call(registryCache, k)) { | ||||
| 				this.r[k].value = this.s[k] = this.mergeState<T[keyof T]['default']>(registryCache[k], v.default); | ||||
| 			} else if (v.where === 'deviceAccount' && Object.prototype.hasOwnProperty.call(deviceAccountState, k)) { | ||||
| 				this.r[k].value = this.s[k] = this.mergeState<T[keyof T]['default']>(deviceAccountState[k], v.default); | ||||
| 			} else { | ||||
| 				this.r[k].value = this.s[k] = v.default; | ||||
| 		for (const key in data) { | ||||
| 			this.s[key] = data[key]; | ||||
| 			this.r[key] = ref(this.s[key]); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 		this.pizzaxChannel.addEventListener('message', ({ where, key, value, userId }) => { | ||||
| 			// アカウント変更すればunisonReloadが効くため、このreturnが発火することは | ||||
| 			// まずないと思うけど一応弾いておく | ||||
| 			if (where === 'deviceAccount' && !($i && userId !== $i.id)) return; | ||||
| 			this.r[key].value = this.s[key] = value; | ||||
| 		}); | ||||
|  | ||||
| 		if ($i) { | ||||
| 			const connection = useStream().useChannel('main'); | ||||
|  | ||||
| 			// streamingのuser storage updateイベントを監視して更新 | ||||
| 			connection.on('registryUpdated', ({ scope, key, value }: { scope?: string[], key: keyof T, value: T[typeof key]['default'] }) => { | ||||
| 				if (!scope || scope.length !== 2 || scope[0] !== 'client' || scope[1] !== this.key || this.s[key] === value) return; | ||||
|  | ||||
| 				this.r[key].value = this.s[key] = value; | ||||
|  | ||||
| 				this.addIdbSetJob(async () => { | ||||
| 					const cache = await get(this.registryCacheKeyName); | ||||
| 					if (cache[key] !== value) { | ||||
| 						cache[key] = value; | ||||
| 						await set(this.registryCacheKeyName, cache); | ||||
| 					} | ||||
| 				}); | ||||
| 			}); | ||||
| 		} | ||||
| 	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 }); | ||||
| 	} | ||||
|  | ||||
| 	private load(): Promise<void> { | ||||
| 		return new Promise((resolve, reject) => { | ||||
| 			if ($i) { | ||||
| 				// api関数と循環参照なので一応setTimeoutしておく | ||||
| 				window.setTimeout(async () => { | ||||
| 					await store.ready; | ||||
|  | ||||
| 					misskeyApi('i/registry/get-all', { scope: ['client', this.key] }) | ||||
| 						.then(kvs => { | ||||
| 							const cache: Partial<T> = {}; | ||||
| 							for (const [k, v] of Object.entries(this.def) as [keyof T, T[keyof T]['default']][]) { | ||||
| 								if (v.where === 'account') { | ||||
| 									if (Object.prototype.hasOwnProperty.call(kvs, k)) { | ||||
| 										this.r[k].value = this.s[k] = (kvs as Partial<T>)[k]; | ||||
| 										cache[k] = (kvs as Partial<T>)[k]; | ||||
| 									} else { | ||||
| 										this.r[k].value = this.s[k] = v.default; | ||||
| 									} | ||||
| 								} | ||||
| 							} | ||||
|  | ||||
| 							return set(this.registryCacheKeyName, cache); | ||||
| 						}) | ||||
| 						.then(() => resolve()); | ||||
| 				}, 1); | ||||
| 			} else { | ||||
| 				resolve(); | ||||
| 			} | ||||
| 		}); | ||||
| 	} | ||||
|  | ||||
| 	public set<K extends keyof T>(key: K, value: T[K]['default']): Promise<void> { | ||||
| 		// IndexedDBやBroadcastChannelで扱うために単純なオブジェクトにする | ||||
| 		// (JSON.parse(JSON.stringify(value))の代わり) | ||||
| 		const rawValue = deepClone(value); | ||||
|  | ||||
| 		this.r[key].value = this.s[key] = rawValue; | ||||
|  | ||||
| 		return this.addIdbSetJob(async () => { | ||||
| 			switch (this.def[key].where) { | ||||
| 				case 'device': { | ||||
| 					this.pizzaxChannel.postMessage({ | ||||
| 						where: 'device', | ||||
| 						key, | ||||
| 						value: rawValue, | ||||
| 					}); | ||||
| 					const deviceState = await get(this.deviceStateKeyName) || {}; | ||||
| 					deviceState[key] = rawValue; | ||||
| 					await set(this.deviceStateKeyName, deviceState); | ||||
| 					break; | ||||
| 				} | ||||
| 				case 'deviceAccount': { | ||||
| 					if ($i == null) break; | ||||
| 					this.pizzaxChannel.postMessage({ | ||||
| 						where: 'deviceAccount', | ||||
| 						key, | ||||
| 						value: rawValue, | ||||
| 						userId: $i.id, | ||||
| 					}); | ||||
| 					const deviceAccountState = await get(this.deviceAccountStateKeyName) || {}; | ||||
| 					deviceAccountState[key] = rawValue; | ||||
| 					await set(this.deviceAccountStateKeyName, deviceAccountState); | ||||
| 					break; | ||||
| 				} | ||||
| 				case 'account': { | ||||
| 					if ($i == null) break; | ||||
| 					const cache = await get(this.registryCacheKeyName) || {}; | ||||
| 					cache[key] = rawValue; | ||||
| 					await set(this.registryCacheKeyName, cache); | ||||
| 					await misskeyApi('i/registry/set', { | ||||
| 						scope: ['client', this.key], | ||||
| 						key: key.toString(), | ||||
| 						value: rawValue, | ||||
| 					}); | ||||
| 					break; | ||||
| 				} | ||||
| 			} | ||||
| 		}); | ||||
| 	} | ||||
|  | ||||
| 	public push<K extends keyof T>(key: K, value: ArrayElement<T[K]['default']>): void { | ||||
| 		const currentState = this.s[key]; | ||||
| 		this.set(key, [...currentState, value]); | ||||
| 	} | ||||
|  | ||||
| 	public reset(key: keyof T) { | ||||
| 		this.set(key, this.def[key].default); | ||||
| 		return this.def[key].default; | ||||
| 	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; | ||||
| 	} | ||||
|  | ||||
| 	/** | ||||
| 	 * 特定のキーの、簡易的なgetter/setterを作ります | ||||
| 	 * 特定のキーの、簡易的なcomputed refを作ります | ||||
| 	 * 主にvue上で設定コントロールのmodelとして使う用 | ||||
| 	 */ | ||||
| 	// TODO: 廃止 | ||||
| 	public makeGetterSetter<K extends keyof T, R = T[K]['default']>( | ||||
| 	public model<K extends keyof Data, V extends Data[K] = Data[K]>( | ||||
| 		key: K, | ||||
| 		getter?: (v: T[K]['default']) => R, | ||||
| 		setter?: (v: R) => T[K]['default'], | ||||
| 	): { | ||||
| 			get: () => R; | ||||
| 			set: (value: R) => void; | ||||
| 		} { | ||||
| 		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 => { | ||||
| @@ -265,7 +76,7 @@ export class Storage<T extends StateDef> { | ||||
| 		}); | ||||
|  | ||||
| 		// TODO: VueのcustomRef使うと良い感じになるかも | ||||
| 		return { | ||||
| 		return computed({ | ||||
| 			get: () => { | ||||
| 				if (getter) { | ||||
| 					return getter(valueRef.value); | ||||
| @@ -275,30 +86,9 @@ export class Storage<T extends StateDef> { | ||||
| 			}, | ||||
| 			set: (value) => { | ||||
| 				const val = setter ? setter(value) : value; | ||||
| 				this.set(key, val); | ||||
| 				this.commit(key, val); | ||||
| 				valueRef.value = val; | ||||
| 			}, | ||||
| 		}; | ||||
| 	} | ||||
|  | ||||
| 	// localStorage => indexedDBのマイグレーション | ||||
| 	private async migrate() { | ||||
| 		const deviceState = localStorage.getItem(this.deviceStateKeyName); | ||||
| 		if (deviceState) { | ||||
| 			await set(this.deviceStateKeyName, JSON.parse(deviceState)); | ||||
| 			localStorage.removeItem(this.deviceStateKeyName); | ||||
| 		} | ||||
|  | ||||
| 		const deviceAccountState = $i && localStorage.getItem(this.deviceAccountStateKeyName); | ||||
| 		if ($i && deviceAccountState) { | ||||
| 			await set(this.deviceAccountStateKeyName, JSON.parse(deviceAccountState)); | ||||
| 			localStorage.removeItem(this.deviceAccountStateKeyName); | ||||
| 		} | ||||
|  | ||||
| 		const registryCache = $i && localStorage.getItem(this.registryCacheKeyName); | ||||
| 		if ($i && registryCache) { | ||||
| 			await set(this.registryCacheKeyName, JSON.parse(registryCache)); | ||||
| 			localStorage.removeItem(this.registryCacheKeyName); | ||||
| 		} | ||||
| 		}); | ||||
| 	} | ||||
| } | ||||
|   | ||||
| @@ -115,7 +115,7 @@ export async function authorizePlugin(plugin: Plugin) { | ||||
| 		}); | ||||
| 	}); | ||||
|  | ||||
| 	store.set('pluginTokens', { | ||||
| 	store.commit('pluginTokens', { | ||||
| 		...store.s.pluginTokens, | ||||
| 		[plugin.installId]: token, | ||||
| 	}); | ||||
| @@ -161,7 +161,7 @@ export async function uninstallPlugin(plugin: Plugin) { | ||||
| 		}); | ||||
| 		const pluginTokens = { ...store.s.pluginTokens }; | ||||
| 		delete pluginTokens[plugin.installId]; | ||||
| 		store.set('pluginTokens', pluginTokens); | ||||
| 		store.commit('pluginTokens', pluginTokens); | ||||
| 	} | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -8,11 +8,11 @@ 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 '@/utility/copy-to-clipboard.js'; | ||||
| import { i18n } from '@/i18n.js'; | ||||
| import { Pizzax } from '@/pizzax.js'; | ||||
|  | ||||
| // NOTE: 明示的な設定値のひとつとして null もあり得るため、設定が存在しないかどうかを判定する目的で null で比較したり ?? を使ってはいけない | ||||
|  | ||||
| @@ -48,7 +48,7 @@ export class ProfileManager extends EventEmitter<{ | ||||
| 	}) => void; | ||||
| }> { | ||||
| 	public profile: PreferencesProfile; | ||||
| 	public store: Store<{ | ||||
| 	public store: Pizzax<{ | ||||
| 		[K in keyof PREF]: ValueOf<K>; | ||||
| 	}>; | ||||
|  | ||||
| @@ -58,7 +58,7 @@ export class ProfileManager extends EventEmitter<{ | ||||
|  | ||||
| 		const states = this.genStates(); | ||||
|  | ||||
| 		this.store = new Store(states); | ||||
| 		this.store = new Pizzax(states); | ||||
| 		this.store.addListener('updated', ({ key, value }) => { | ||||
| 			console.log('prefer:set', key, value); | ||||
|  | ||||
|   | ||||
| @@ -1,92 +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]) { | ||||
| 		this.r[key].value = this.s[key] = value; | ||||
| 		this.emit('updated', { key, value }); | ||||
| 	} | ||||
|  | ||||
| 	public rewrite<K extends keyof Data>(key: K, value: Data[K]) { | ||||
| 		this.r[key].value = this.s[key] = value; | ||||
| 	} | ||||
|  | ||||
| 	/** | ||||
| 	 * 特定のキーの、簡易的な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; | ||||
| 			}, | ||||
| 		}); | ||||
| 	} | ||||
| } | ||||
| @@ -34,9 +34,9 @@ export function getPreferencesProfileMenu(): MenuItem[] { | ||||
| 				return; | ||||
| 			} | ||||
|  | ||||
| 			store.set('enablePreferencesAutoCloudBackup', true); | ||||
| 			store.commit('enablePreferencesAutoCloudBackup', true); | ||||
| 		} else { | ||||
| 			store.set('enablePreferencesAutoCloudBackup', false); | ||||
| 			store.commit('enablePreferencesAutoCloudBackup', false); | ||||
| 		} | ||||
| 	}); | ||||
|  | ||||
| @@ -183,7 +183,7 @@ export async function restoreFromCloudBackup() { | ||||
|  | ||||
| 	miLocalStorage.setItem('preferences', JSON.stringify(profile)); | ||||
| 	miLocalStorage.setItem('hidePreferencesRestoreSuggestion', 'true'); | ||||
| 	store.set('enablePreferencesAutoCloudBackup', true); | ||||
| 	store.commit('enablePreferencesAutoCloudBackup', true); | ||||
| 	shouldSuggestRestoreBackup.value = false; | ||||
| 	unisonReload(); | ||||
| } | ||||
| @@ -197,7 +197,7 @@ export async function enableAutoBackup() { | ||||
| 		return; | ||||
| 	} | ||||
|  | ||||
| 	store.set('enablePreferencesAutoCloudBackup', true); | ||||
| 	store.commit('enablePreferencesAutoCloudBackup', true); | ||||
| } | ||||
|  | ||||
| export const shouldSuggestRestoreBackup = ref(false); | ||||
|   | ||||
| @@ -8,17 +8,219 @@ import * as Misskey from 'misskey-js'; | ||||
| import lightTheme from '@@/themes/l-light.json5'; | ||||
| import darkTheme from '@@/themes/d-green-lime.json5'; | ||||
| import { hemisphere } from '@@/js/intl-const.js'; | ||||
| import { BroadcastChannel } from 'broadcast-channel'; | ||||
| import type { DeviceKind } from '@/utility/device-kind.js'; | ||||
| import type { Plugin } from '@/plugin.js'; | ||||
| import type { Column } from '@/deck.js'; | ||||
| import { miLocalStorage } from '@/local-storage.js'; | ||||
| import { Storage } from '@/pizzax.js'; | ||||
| import { DEFAULT_DEVICE_KIND } from '@/utility/device-kind.js'; | ||||
| import { Pizzax } from '@/pizzax.js'; | ||||
| import { $i } from '@/account.js'; | ||||
| import * as idb from '@/utility/idb-proxy.js'; | ||||
| import { misskeyApi } from '@/utility/misskey-api.js'; | ||||
| import { useStream } from '@/stream.js'; | ||||
| import { deepMerge } from '@/utility/merge.js'; | ||||
| import { deepClone } from '@/utility/clone.js'; | ||||
|  | ||||
| /** | ||||
|  * 「状態」を管理するストア(not「設定」) | ||||
|  */ | ||||
| export const store = markRaw(new Storage('base', { | ||||
| type StateDef = Record<string, { | ||||
| 	where: 'account' | 'device' | 'deviceAccount'; | ||||
| 	default: any; | ||||
| }>; | ||||
|  | ||||
| type State<T extends StateDef> = { [K in keyof T]: T[K]['default']; }; | ||||
|  | ||||
| type PizzaxChannelMessage<T extends StateDef> = { | ||||
| 	where: 'device' | 'deviceAccount'; | ||||
| 	key: keyof T; | ||||
| 	value: T[keyof T]['default']; | ||||
| 	userId?: string; | ||||
| }; | ||||
|  | ||||
| // TODO: export消す | ||||
| export class Store<T extends StateDef> extends Pizzax<State<T>> { | ||||
| 	public readonly def: T; | ||||
|  | ||||
| 	public readonly ready: Promise<void>; | ||||
| 	public readonly loaded: Promise<void>; | ||||
|  | ||||
| 	public readonly key: string; | ||||
| 	public readonly deviceStateKeyName: `pizzax::${this['key']}`; | ||||
| 	public readonly deviceAccountStateKeyName: `pizzax::${this['key']}::${string}` | ''; | ||||
| 	public readonly registryCacheKeyName: `pizzax::${this['key']}::cache::${string}` | ''; | ||||
|  | ||||
| 	private pizzaxChannel: BroadcastChannel<PizzaxChannelMessage<T>>; | ||||
|  | ||||
| 	// 簡易的にキューイングして占有ロックとする | ||||
| 	private currentIdbJob: Promise<any> = Promise.resolve(); | ||||
| 	private addIdbSetJob<T>(job: () => Promise<T>) { | ||||
| 		const promise = this.currentIdbJob.then(job, err => { | ||||
| 			console.error('Pizzax failed to save data to idb!', err); | ||||
| 			return job(); | ||||
| 		}); | ||||
| 		this.currentIdbJob = promise; | ||||
| 		return promise; | ||||
| 	} | ||||
|  | ||||
| 	constructor(def: T, key = 'base') { | ||||
| 		const data = {} as State<T>; | ||||
|  | ||||
| 		for (const [k, v] of Object.entries(def) as [keyof T, T[keyof T]['default']][]) { | ||||
| 			data[k] = v.default; | ||||
| 		} | ||||
|  | ||||
| 		super(data); | ||||
|  | ||||
| 		this.key = key; | ||||
| 		this.deviceStateKeyName = `pizzax::${key}`; | ||||
| 		this.deviceAccountStateKeyName = $i ? `pizzax::${key}::${$i.id}` : ''; | ||||
| 		this.registryCacheKeyName = $i ? `pizzax::${key}::cache::${$i.id}` : ''; | ||||
| 		this.def = def; | ||||
| 		this.pizzaxChannel = new BroadcastChannel(`pizzax::${key}`); | ||||
| 		this.ready = this.init(); | ||||
| 		this.loaded = this.ready.then(() => this.load()); | ||||
|  | ||||
| 		this.addListener('updated', ({ key, value }) => { | ||||
| 			// IndexedDBやBroadcastChannelで扱うために単純なオブジェクトにする | ||||
| 			// (JSON.parse(JSON.stringify(value))の代わり) | ||||
| 			const rawValue = deepClone(value); | ||||
|  | ||||
| 			this.r[key].value = this.s[key] = rawValue; | ||||
|  | ||||
| 			return this.addIdbSetJob(async () => { | ||||
| 				switch (this.def[key].where) { | ||||
| 					case 'device': { | ||||
| 						this.pizzaxChannel.postMessage({ | ||||
| 							where: 'device', | ||||
| 							key, | ||||
| 							value: rawValue, | ||||
| 						}); | ||||
| 						const deviceState = await idb.get(this.deviceStateKeyName) || {}; | ||||
| 						deviceState[key] = rawValue; | ||||
| 						await idb.set(this.deviceStateKeyName, deviceState); | ||||
| 						break; | ||||
| 					} | ||||
| 					case 'deviceAccount': { | ||||
| 						if ($i == null) break; | ||||
| 						this.pizzaxChannel.postMessage({ | ||||
| 							where: 'deviceAccount', | ||||
| 							key, | ||||
| 							value: rawValue, | ||||
| 							userId: $i.id, | ||||
| 						}); | ||||
| 						const deviceAccountState = await idb.get(this.deviceAccountStateKeyName) || {}; | ||||
| 						deviceAccountState[key] = rawValue; | ||||
| 						await idb.set(this.deviceAccountStateKeyName, deviceAccountState); | ||||
| 						break; | ||||
| 					} | ||||
| 					case 'account': { | ||||
| 						if ($i == null) break; | ||||
| 						const cache = await idb.get(this.registryCacheKeyName) || {}; | ||||
| 						cache[key] = rawValue; | ||||
| 						await idb.set(this.registryCacheKeyName, cache); | ||||
| 						await misskeyApi('i/registry/set', { | ||||
| 							scope: ['client', this.key], | ||||
| 							key: key.toString(), | ||||
| 							value: rawValue, | ||||
| 						}); | ||||
| 						break; | ||||
| 					} | ||||
| 				} | ||||
| 			}); | ||||
| 		}); | ||||
| 	} | ||||
|  | ||||
| 	private async init(): Promise<void> { | ||||
| 		const deviceState: State<T> = await idb.get(this.deviceStateKeyName) || {}; | ||||
| 		const deviceAccountState = $i ? await idb.get(this.deviceAccountStateKeyName) || {} : {}; | ||||
| 		const registryCache = $i ? await idb.get(this.registryCacheKeyName) || {} : {}; | ||||
|  | ||||
| 		for (const [k, v] of Object.entries(this.def) as [keyof T, T[keyof T]['default']][]) { | ||||
| 			if (v.where === 'device' && Object.prototype.hasOwnProperty.call(deviceState, k)) { | ||||
| 				this.rewrite(k, this.mergeState<T[keyof T]['default']>(deviceState[k], v.default)); | ||||
| 			} else if (v.where === 'account' && $i && Object.prototype.hasOwnProperty.call(registryCache, k)) { | ||||
| 				this.rewrite(k, this.mergeState<T[keyof T]['default']>(registryCache[k], v.default)); | ||||
| 			} else if (v.where === 'deviceAccount' && Object.prototype.hasOwnProperty.call(deviceAccountState, k)) { | ||||
| 				this.rewrite(k, this.mergeState<T[keyof T]['default']>(deviceAccountState[k], v.default)); | ||||
| 			} else { | ||||
| 				this.rewrite(k, v.default); | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		this.pizzaxChannel.addEventListener('message', ({ where, key, value, userId }) => { | ||||
| 			// アカウント変更すればunisonReloadが効くため、このreturnが発火することは | ||||
| 			// まずないと思うけど一応弾いておく | ||||
| 			if (where === 'deviceAccount' && !($i && userId !== $i.id)) return; | ||||
| 			this.r[key].value = this.s[key] = value; | ||||
| 		}); | ||||
|  | ||||
| 		if ($i) { | ||||
| 			const connection = useStream().useChannel('main'); | ||||
|  | ||||
| 			// streamingのuser storage updateイベントを監視して更新 | ||||
| 			connection.on('registryUpdated', ({ scope, key, value }: { scope?: string[], key: keyof T, value: T[typeof key]['default'] }) => { | ||||
| 				if (!scope || scope.length !== 2 || scope[0] !== 'client' || scope[1] !== this.key || this.s[key] === value) return; | ||||
|  | ||||
| 				this.rewrite(key, value); | ||||
|  | ||||
| 				this.addIdbSetJob(async () => { | ||||
| 					const cache = await idb.get(this.registryCacheKeyName); | ||||
| 					if (cache[key] !== value) { | ||||
| 						cache[key] = value; | ||||
| 						await idb.set(this.registryCacheKeyName, cache); | ||||
| 					} | ||||
| 				}); | ||||
| 			}); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	private load(): Promise<void> { | ||||
| 		return new Promise((resolve, reject) => { | ||||
| 			if ($i) { | ||||
| 				// api関数と循環参照なので一応setTimeoutしておく | ||||
| 				window.setTimeout(async () => { | ||||
| 					await store.ready; | ||||
|  | ||||
| 					misskeyApi('i/registry/get-all', { scope: ['client', this.key] }) | ||||
| 						.then(kvs => { | ||||
| 							const cache: Partial<T> = {}; | ||||
| 							for (const [k, v] of Object.entries(this.def) as [keyof T, T[keyof T]['default']][]) { | ||||
| 								if (v.where === 'account') { | ||||
| 									if (Object.prototype.hasOwnProperty.call(kvs, k)) { | ||||
| 										this.rewrite(k, (kvs as Partial<T>)[k]); | ||||
| 										cache[k] = (kvs as Partial<T>)[k]; | ||||
| 									} else { | ||||
| 										this.r[k].value = this.s[k] = v.default; | ||||
| 									} | ||||
| 								} | ||||
| 							} | ||||
|  | ||||
| 							return idb.set(this.registryCacheKeyName, cache); | ||||
| 						}) | ||||
| 						.then(() => resolve()); | ||||
| 				}, 1); | ||||
| 			} else { | ||||
| 				resolve(); | ||||
| 			} | ||||
| 		}); | ||||
| 	} | ||||
|  | ||||
| 	private isPureObject(value: unknown): value is Record<string | number | symbol, unknown> { | ||||
| 		return typeof value === 'object' && value !== null && !Array.isArray(value); | ||||
| 	} | ||||
|  | ||||
| 	private mergeState<X>(value: X, def: X): X { | ||||
| 		if (this.isPureObject(value) && this.isPureObject(def)) { | ||||
| 			const merged = deepMerge(value, def); | ||||
|  | ||||
| 			if (_DEV_) console.log('Merging state. Incoming: ', value, ' Default: ', def, ' Result: ', merged); | ||||
|  | ||||
| 			return merged as X; | ||||
| 		} | ||||
| 		return value; | ||||
| 	} | ||||
| } | ||||
|  | ||||
| const STORE_DEF = { | ||||
| 	accountSetupWizard: { | ||||
| 		where: 'account', | ||||
| 		default: 0, | ||||
| @@ -465,9 +667,12 @@ export const store = markRaw(new Storage('base', { | ||||
| 		}, | ||||
| 	}, | ||||
| 	//#endregion | ||||
| })); | ||||
| } satisfies StateDef; | ||||
|  | ||||
| // TODO: 他のタブと永続化されたstateを同期 | ||||
| /** | ||||
|  * 「状態」を管理するストア(not「設定」) | ||||
|  */ | ||||
| export const store = markRaw(new Store(STORE_DEF)); | ||||
|  | ||||
| const PREFIX = 'miux:' as const; | ||||
|  | ||||
| @@ -547,7 +752,7 @@ export class ColdDeviceStorage { | ||||
| 	 * 特定のキーの、簡易的なgetter/setterを作ります | ||||
| 	 * 主にvue場で設定コントロールのmodelとして使う用 | ||||
| 	 */ | ||||
| 	public static makeGetterSetter<K extends keyof typeof ColdDeviceStorage.default>(key: K) { | ||||
| 	public static model<K extends keyof typeof ColdDeviceStorage.default>(key: K) { | ||||
| 		// TODO: VueのcustomRef使うと良い感じになるかも | ||||
| 		const valueRef = ColdDeviceStorage.ref(key); | ||||
| 		return { | ||||
|   | ||||
| @@ -128,7 +128,7 @@ watch(store.r.menuDisplay, () => { | ||||
| }); | ||||
|  | ||||
| function toggleIconOnly() { | ||||
| 	store.set('menuDisplay', iconOnly.value ? 'sideFull' : 'sideIcon'); | ||||
| 	store.commit('menuDisplay', iconOnly.value ? 'sideFull' : 'sideIcon'); | ||||
| } | ||||
|  | ||||
| function openAccountMenu(ev: MouseEvent) { | ||||
|   | ||||
| @@ -62,7 +62,7 @@ const WINDOW_THRESHOLD = 1400; | ||||
|  | ||||
| const settingsWindowed = ref(window.innerWidth > WINDOW_THRESHOLD); | ||||
| const menu = ref(prefer.s.menu); | ||||
| // const menuDisplay = computed(store.makeGetterSetter('menuDisplay')); | ||||
| // const menuDisplay = store.model('menuDisplay'); | ||||
| const otherNavItemIndicated = computed<boolean>(() => { | ||||
| 	for (const def in navbarItemDef) { | ||||
| 		if (menu.value.includes(def)) continue; | ||||
|   | ||||
| @@ -67,7 +67,7 @@ import { prefer } from '@/preferences.js'; | ||||
| const WINDOW_THRESHOLD = 1400; | ||||
|  | ||||
| const menu = ref(prefer.s.menu); | ||||
| const menuDisplay = computed(store.makeGetterSetter('menuDisplay')); | ||||
| const menuDisplay = store.model('menuDisplay'); | ||||
| const otherNavItemIndicated = computed<boolean>(() => { | ||||
| 	for (const def in navbarItemDef) { | ||||
| 		if (menu.value.includes(def)) continue; | ||||
|   | ||||
| @@ -222,7 +222,7 @@ function changeProfile(ev: MouseEvent) { | ||||
| 		items.push(...(profiles.filter(k => k !== store.s['deck.profile']).map(k => ({ | ||||
| 			text: k, | ||||
| 			action: () => { | ||||
| 				store.set('deck.profile', k); | ||||
| 				store.commit('deck.profile', k); | ||||
| 				unisonReload(); | ||||
| 			}, | ||||
| 		}))), { type: 'divider' as const }, { | ||||
| @@ -237,7 +237,7 @@ function changeProfile(ev: MouseEvent) { | ||||
| 				if (canceled || name == null) return; | ||||
|  | ||||
| 				os.promiseDialog((async () => { | ||||
| 					await store.set('deck.profile', name); | ||||
| 					await store.commit('deck.profile', name); | ||||
| 					await forceSaveDeck(); | ||||
| 				})(), () => { | ||||
| 					unisonReload(); | ||||
| @@ -258,13 +258,13 @@ async function deleteProfile() { | ||||
|  | ||||
| 	os.promiseDialog((async () => { | ||||
| 		if (store.s['deck.profile'] === 'default') { | ||||
| 			await store.set('deck.columns', []); | ||||
| 			await store.set('deck.layout', []); | ||||
| 			await store.commit('deck.columns', []); | ||||
| 			await store.commit('deck.layout', []); | ||||
| 			await forceSaveDeck(); | ||||
| 		} else { | ||||
| 			await deleteProfile_(store.s['deck.profile']); | ||||
| 		} | ||||
| 		await store.set('deck.profile', 'default'); | ||||
| 		await store.commit('deck.profile', 'default'); | ||||
| 	})(), () => { | ||||
| 		unisonReload(); | ||||
| 	}); | ||||
|   | ||||
| @@ -5,10 +5,10 @@ | ||||
|  | ||||
| import { markRaw } from 'vue'; | ||||
| import type { Column } from '@/deck.js'; | ||||
| import { Storage } from '@/pizzax.js'; | ||||
| import { Store } from '@/store.js'; | ||||
|  | ||||
| // TODO: 消す(移行済みのため) | ||||
| export const deckStore = markRaw(new Storage('deck', { | ||||
| export const deckStore = markRaw(new Store({ | ||||
| 	profile: { | ||||
| 		where: 'deviceAccount', | ||||
| 		default: 'default', | ||||
| @@ -21,4 +21,4 @@ export const deckStore = markRaw(new Storage('deck', { | ||||
| 		where: 'deviceAccount', | ||||
| 		default: [] as Column['id'][][], | ||||
| 	}, | ||||
| })); | ||||
| }, 'deck')); | ||||
|   | ||||
| @@ -53,7 +53,7 @@ const changed = ref(false); | ||||
| let timeoutId; | ||||
|  | ||||
| const saveMemo = () => { | ||||
| 	store.set('memo', text.value); | ||||
| 	store.commit('memo', text.value); | ||||
| 	changed.value = false; | ||||
| }; | ||||
|  | ||||
|   | ||||
		Reference in New Issue
	
	Block a user