enhance: クライアント設定の初期値を変更できるように(簡易)
This commit is contained in:
parent
224bbd486f
commit
1005d17313
|
@ -5218,6 +5218,31 @@ export interface Locale extends ILocale {
|
|||
* 利用可能なロール
|
||||
*/
|
||||
"availableRoles": string;
|
||||
/**
|
||||
* クライアント設定の初期値
|
||||
*/
|
||||
"clientSettingOverrides": string;
|
||||
/**
|
||||
* 全ユーザーに対するクライアント設定の初期値を変更できます。
|
||||
* 知識のない方が変更すると全ユーザーがクライアントにアクセスできなくなる可能性があります。
|
||||
*/
|
||||
"clientSettingOverridesWarn": string;
|
||||
/**
|
||||
* オーバーライドする
|
||||
*/
|
||||
"enableOverride": string;
|
||||
/**
|
||||
* オーバーライドする値
|
||||
*/
|
||||
"overrideValue": string;
|
||||
/**
|
||||
* スイッチをオンにするとtrueとなります。
|
||||
*/
|
||||
"onToTrue": string;
|
||||
/**
|
||||
* リセット
|
||||
*/
|
||||
"reset": string;
|
||||
"_accountSettings": {
|
||||
/**
|
||||
* コンテンツの表示にログインを必須にする
|
||||
|
|
|
@ -1300,6 +1300,12 @@ thisContentsAreMarkedAsSigninRequiredByAuthor: "投稿者により、表示に
|
|||
lockdown: "ロックダウン"
|
||||
pleaseSelectAccount: "アカウントを選択してください"
|
||||
availableRoles: "利用可能なロール"
|
||||
clientSettingOverrides: "クライアント設定の初期値"
|
||||
clientSettingOverridesWarn: "全ユーザーに対するクライアント設定の初期値を変更できます。\n知識のない方が変更すると全ユーザーがクライアントにアクセスできなくなる可能性があります。"
|
||||
enableOverride: "オーバーライドする"
|
||||
overrideValue: "オーバーライドする値"
|
||||
onToTrue: "スイッチをオンにするとtrueとなります。"
|
||||
reset: "リセット"
|
||||
|
||||
_accountSettings:
|
||||
requireSigninToViewContents: "コンテンツの表示にログインを必須にする"
|
||||
|
|
|
@ -0,0 +1,16 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export class ClientSettingOverrides1730552981368 {
|
||||
name = 'ClientSettingOverrides1730552981368'
|
||||
|
||||
async up(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "meta" ADD "defaultClientSettingOverrides" character varying(8192)`);
|
||||
}
|
||||
|
||||
async down(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "defaultClientSettingOverrides"`);
|
||||
}
|
||||
}
|
|
@ -151,6 +151,7 @@ export class MetaEntityService {
|
|||
|
||||
const packDetailed: Packed<'MetaDetailed'> = {
|
||||
...packed,
|
||||
defaultClientSettingOverrides: instance.defaultClientSettingOverrides,
|
||||
cacheRemoteFiles: instance.cacheRemoteFiles,
|
||||
cacheRemoteSensitiveFiles: instance.cacheRemoteSensitiveFiles,
|
||||
requireSetup: !await this.instanceActorService.realLocalUsersPresent(),
|
||||
|
|
|
@ -409,6 +409,12 @@ export class MiMeta {
|
|||
})
|
||||
public defaultDarkTheme: string | null;
|
||||
|
||||
@Column('varchar', {
|
||||
length: 8192,
|
||||
nullable: true,
|
||||
})
|
||||
public defaultClientSettingOverrides: string | null;
|
||||
|
||||
@Column('boolean', {
|
||||
default: false,
|
||||
})
|
||||
|
|
|
@ -315,6 +315,10 @@ export const packedMetaDetailedOnlySchema = {
|
|||
},
|
||||
},
|
||||
},
|
||||
defaultClientSettingOverrides: {
|
||||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
},
|
||||
proxyAccountName: {
|
||||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
|
|
|
@ -420,6 +420,10 @@ export const meta = {
|
|||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
},
|
||||
defaultClientSettingOverrides: {
|
||||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
},
|
||||
description: {
|
||||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
|
@ -585,6 +589,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
logoImageUrl: instance.logoImageUrl,
|
||||
defaultLightTheme: instance.defaultLightTheme,
|
||||
defaultDarkTheme: instance.defaultDarkTheme,
|
||||
defaultClientSettingOverrides: instance.defaultClientSettingOverrides,
|
||||
enableEmail: instance.enableEmail,
|
||||
enableServiceWorker: instance.enableServiceWorker,
|
||||
translatorAvailable: instance.deeplAuthKey != null,
|
||||
|
|
|
@ -67,6 +67,7 @@ export const paramDef = {
|
|||
description: { type: 'string', nullable: true },
|
||||
defaultLightTheme: { type: 'string', nullable: true },
|
||||
defaultDarkTheme: { type: 'string', nullable: true },
|
||||
defaultClientSettingOverrides: { type: 'string', nullable: true },
|
||||
cacheRemoteFiles: { type: 'boolean' },
|
||||
cacheRemoteSensitiveFiles: { type: 'boolean' },
|
||||
emailRequiredForSignup: { type: 'boolean' },
|
||||
|
@ -303,6 +304,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
set.defaultDarkTheme = ps.defaultDarkTheme;
|
||||
}
|
||||
|
||||
if (ps.defaultClientSettingOverrides !== undefined) {
|
||||
set.defaultClientSettingOverrides = ps.defaultClientSettingOverrides;
|
||||
}
|
||||
|
||||
if (ps.cacheRemoteFiles !== undefined) {
|
||||
set.cacheRemoteFiles = ps.cacheRemoteFiles;
|
||||
}
|
||||
|
|
|
@ -14,7 +14,7 @@ import { isDeviceDarkmode } from '@/scripts/is-device-darkmode.js';
|
|||
import { updateI18n, i18n } from '@/i18n.js';
|
||||
import { $i, refreshAccount, login } from '@/account.js';
|
||||
import { defaultStore, ColdDeviceStorage } from '@/store.js';
|
||||
import { fetchInstance, instance } from '@/instance.js';
|
||||
import { initInstance, instance } from '@/instance.js';
|
||||
import { deviceKind } from '@/scripts/device-kind.js';
|
||||
import { reloadChannel } from '@/scripts/unison-reload.js';
|
||||
import { getUrlWithoutLoginId } from '@/scripts/login-id.js';
|
||||
|
@ -115,15 +115,12 @@ export async function common(createVue: () => App<Element>) {
|
|||
const html = document.documentElement;
|
||||
html.setAttribute('lang', lang);
|
||||
//#endregion
|
||||
|
||||
|
||||
await initInstance();
|
||||
await defaultStore.ready;
|
||||
await deckStore.ready;
|
||||
|
||||
const fetchInstanceMetaPromise = fetchInstance();
|
||||
|
||||
fetchInstanceMetaPromise.then(() => {
|
||||
miLocalStorage.setItem('v', instance.version);
|
||||
});
|
||||
miLocalStorage.setItem('v', instance.version);
|
||||
|
||||
//#region loginId
|
||||
const params = new URLSearchParams(location.search);
|
||||
|
@ -177,13 +174,11 @@ export async function common(createVue: () => App<Element>) {
|
|||
});
|
||||
//#endregion
|
||||
|
||||
fetchInstanceMetaPromise.then(() => {
|
||||
if (defaultStore.state.themeInitial) {
|
||||
if (instance.defaultLightTheme != null) ColdDeviceStorage.set('lightTheme', JSON.parse(instance.defaultLightTheme));
|
||||
if (instance.defaultDarkTheme != null) ColdDeviceStorage.set('darkTheme', JSON.parse(instance.defaultDarkTheme));
|
||||
defaultStore.set('themeInitial', false);
|
||||
}
|
||||
});
|
||||
if (defaultStore.state.themeInitial) {
|
||||
if (instance.defaultLightTheme != null) ColdDeviceStorage.set('lightTheme', JSON.parse(instance.defaultLightTheme));
|
||||
if (instance.defaultDarkTheme != null) ColdDeviceStorage.set('darkTheme', JSON.parse(instance.defaultDarkTheme));
|
||||
defaultStore.set('themeInitial', false);
|
||||
}
|
||||
|
||||
watch(defaultStore.reactiveState.useBlurEffectForModal, v => {
|
||||
document.documentElement.style.setProperty('--MI-modalBgFilter', v ? 'blur(4px)' : 'none');
|
||||
|
|
|
@ -16,7 +16,7 @@ const providedMetaEl = document.getElementById('misskey_meta');
|
|||
|
||||
let cachedMeta = miLocalStorage.getItem('instance') ? JSON.parse(miLocalStorage.getItem('instance')!) : null;
|
||||
let cachedAt = miLocalStorage.getItem('instanceCachedAt') ? parseInt(miLocalStorage.getItem('instanceCachedAt')!) : 0;
|
||||
const providedMeta = providedMetaEl && providedMetaEl.textContent ? JSON.parse(providedMetaEl.textContent) : null;
|
||||
const providedMeta: Misskey.entities.MetaDetailed | null = providedMetaEl && providedMetaEl.textContent ? JSON.parse(providedMetaEl.textContent) : null;
|
||||
const providedAt = providedMetaEl && providedMetaEl.dataset.generatedAt ? parseInt(providedMetaEl.dataset.generatedAt) : 0;
|
||||
if (providedAt > cachedAt) {
|
||||
miLocalStorage.setItem('instance', JSON.stringify(providedMeta));
|
||||
|
@ -38,6 +38,19 @@ export const notFoundImageUrl = computed(() => instance.notFoundImageUrl ?? DEFA
|
|||
|
||||
export const isEnabledUrlPreview = computed(() => instance.enableUrlPreview ?? true);
|
||||
|
||||
/** instanceの中身が入っていることを保証する */
|
||||
export async function initInstance() {
|
||||
if (instance == null || Object.keys(instance).length === 0) {
|
||||
if (providedMeta != null) {
|
||||
for (const [k, v] of Object.entries(providedMeta)) {
|
||||
instance[k] = v;
|
||||
}
|
||||
} else {
|
||||
await fetchInstance(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchInstance(force = false): Promise<Misskey.entities.MetaDetailed> {
|
||||
if (!force) {
|
||||
const cachedAt = miLocalStorage.getItem('instanceCachedAt') ? parseInt(miLocalStorage.getItem('instanceCachedAt')!) : 0;
|
||||
|
@ -60,3 +73,9 @@ export async function fetchInstance(force = false): Promise<Misskey.entities.Met
|
|||
|
||||
return instance;
|
||||
}
|
||||
|
||||
/** キャッシュだけ飛ばす(リロード後からは新しい設定を読み込む) */
|
||||
export function pruneInstanceCache() {
|
||||
miLocalStorage.removeItem('instance');
|
||||
miLocalStorage.removeItem('instanceCachedAt');
|
||||
}
|
||||
|
|
|
@ -0,0 +1,261 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<MkStickyContainer>
|
||||
<template #header><XHeader :tabs="headerTabs"/></template>
|
||||
<MkSpacer :contentMax="700" :marginMin="16" :marginMax="32">
|
||||
<div class="_gaps">
|
||||
<MkInfo warn :class="$style.warn">{{ i18n.ts.clientSettingOverridesWarn }}</MkInfo>
|
||||
<div v-if="fetching">
|
||||
<MkLoading/>
|
||||
</div>
|
||||
<div v-else class="_gaps_s">
|
||||
<MkInput v-model="query" type="search">
|
||||
<template #prefix><i class="ti ti-search"></i></template>
|
||||
</MkInput>
|
||||
|
||||
<MkFolder
|
||||
v-for="def, key in clientSettingOverrides"
|
||||
:key="key"
|
||||
v-show="query === '' || key.toLowerCase().includes(query.toLowerCase())"
|
||||
>
|
||||
<template #label>{{ key }}</template>
|
||||
<template #suffix>
|
||||
<span v-if="def.enableOverride && def.overrideValue != null && def.overrideValue !== def.defaultValue" class="_warn">{{ i18n.ts.modified }}</span>
|
||||
</template>
|
||||
<div class="_gaps">
|
||||
<MkKeyValue>
|
||||
<template #key>{{ i18n.ts.default }}</template>
|
||||
<template #value>
|
||||
<MkCode v-bind="getMkCodeProps(def)"></MkCode>
|
||||
</template>
|
||||
</MkKeyValue>
|
||||
<MkSwitch v-model="def.enableOverride">{{ i18n.ts.enableOverride }}</MkSwitch>
|
||||
<MkInput v-if="def.formType === 'text'" v-model="def.overrideValue" :disabled="!def.enableOverride" type="text">
|
||||
<template #label>{{ i18n.ts.overrideValue }}</template>
|
||||
</MkInput>
|
||||
<MkInput v-else-if="def.formType === 'number'" v-model="def.overrideValue" :disabled="!def.enableOverride" type="number">
|
||||
<template #label>{{ i18n.ts.overrideValue }}</template>
|
||||
</MkInput>
|
||||
<MkSwitch v-else-if="def.formType === 'boolean'" v-model="def.overrideValue" :disabled="!def.enableOverride">
|
||||
<template #label>{{ i18n.ts.overrideValue }}</template>
|
||||
<template #caption>{{ i18n.ts.onToTrue }}</template>
|
||||
</MkSwitch>
|
||||
<MkTextarea v-else-if="def.formType === 'codeEditor'" v-model="def.overrideValue" :disabled="!def.enableOverride" pre>
|
||||
<template #label>{{ i18n.ts.overrideValue }}</template>
|
||||
</MkTextarea>
|
||||
</div>
|
||||
</MkFolder>
|
||||
</div>
|
||||
</div>
|
||||
</MkSpacer>
|
||||
<template #footer>
|
||||
<div :class="$style.footer">
|
||||
<div :class="$style.footerInner">
|
||||
<div class="_buttons">
|
||||
<MkButton primary @click="save"><i class="ti ti-check"></i> {{ i18n.ts.save }}</MkButton>
|
||||
<MkButton danger @click="reset"><i class="ti ti-trash"></i> {{ i18n.ts.reset }}</MkButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</MkStickyContainer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import XHeader from './_header_.vue';
|
||||
import * as os from '@/os.js';
|
||||
import { ColdDeviceStorage, defaultStore } from '@/store.js';
|
||||
import { pruneInstanceCache } from '@/instance.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { definePageMetadata } from '@/scripts/page-metadata.js';
|
||||
import MkInfo from '@/components/MkInfo.vue';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import MkInput from '@/components/MkInput.vue';
|
||||
import MkFolder from '@/components/MkFolder.vue';
|
||||
import MkKeyValue from '@/components/MkKeyValue.vue';
|
||||
import MkCode from '@/components/MkCode.vue';
|
||||
import { misskeyApi } from '@/scripts/misskey-api.js';
|
||||
import { reloadAsk } from '@/scripts/reload-ask.js';
|
||||
import MkSwitch from '@/components/MkSwitch.vue';
|
||||
import MkTextarea from '@/components/MkTextarea.vue';
|
||||
|
||||
const query = ref('');
|
||||
|
||||
const notConfigurableDefaultStoreSettings = [
|
||||
'accountSetupWizard',
|
||||
'timelineTutorials',
|
||||
'abusesTutorial',
|
||||
'memo',
|
||||
'mutedAds',
|
||||
'statusbars',
|
||||
'widgets',
|
||||
'pinnedUserLists',
|
||||
'recentlyUsedEmojis',
|
||||
'recentlyUsedUsers',
|
||||
'forceShowAds',
|
||||
'additionalUnicodeEmojiIndexes',
|
||||
'themeInitial',
|
||||
] satisfies (keyof typeof defaultStore.def)[];
|
||||
|
||||
const notConfigurableColdDeviceStorageSettings = [
|
||||
'darkTheme',
|
||||
'lightTheme',
|
||||
'plugins',
|
||||
] satisfies (keyof typeof ColdDeviceStorage.default)[];
|
||||
|
||||
type ClientSettingOverridesUIDefObj = {
|
||||
formType: 'text' | 'number' | 'boolean' | 'codeEditor';
|
||||
enableOverride: boolean;
|
||||
defaultValue: any;
|
||||
overrideValue?: any;
|
||||
}
|
||||
|
||||
const fetching = ref(true);
|
||||
const clientSettingOverrides = ref<Record<string, ClientSettingOverridesUIDefObj>>();
|
||||
|
||||
function getMkCodeProps(def: ClientSettingOverridesUIDefObj) {
|
||||
if (typeof def.defaultValue === 'string') {
|
||||
return {
|
||||
code: def.defaultValue,
|
||||
forceShow: true,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
code: JSON.stringify(def.defaultValue, null, 4),
|
||||
lang: 'json',
|
||||
forceShow: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function typeSafeObjectEntries<T extends Record<string, any>>(obj: T) {
|
||||
return Object.entries(obj) as [keyof T, T[keyof T]][];
|
||||
}
|
||||
|
||||
function getClientSettingOverridesUIDefObj(def: unknown): ClientSettingOverridesUIDefObj {
|
||||
return {
|
||||
formType: (() => {
|
||||
if (typeof def === 'boolean') {
|
||||
return 'boolean';
|
||||
} else if (typeof def === 'number') {
|
||||
return 'number';
|
||||
} else if (typeof def === 'object') {
|
||||
return 'codeEditor';
|
||||
} else {
|
||||
return 'text';
|
||||
}
|
||||
})() satisfies ClientSettingOverridesUIDefObj['formType'] as ClientSettingOverridesUIDefObj['formType'],
|
||||
enableOverride: false,
|
||||
defaultValue: def,
|
||||
overrideValue: def,
|
||||
};
|
||||
}
|
||||
|
||||
async function fetch() {
|
||||
fetching.value = true;
|
||||
const overrideDefs = Object.fromEntries([
|
||||
...typeSafeObjectEntries(defaultStore.def)
|
||||
.filter(([key, _]) => !(notConfigurableDefaultStoreSettings as string[]).includes(key))
|
||||
.map(([key, def]) => [`defaultStore::${key}`, getClientSettingOverridesUIDefObj(def.default)]),
|
||||
...typeSafeObjectEntries(ColdDeviceStorage.default)
|
||||
.filter(([key, _]) => !(notConfigurableColdDeviceStorageSettings as string[]).includes(key))
|
||||
.map(([key, def]) => [`ColdDeviceStorage::${key}`, getClientSettingOverridesUIDefObj(def)]),
|
||||
]);
|
||||
const res = await misskeyApi('admin/meta');
|
||||
if (res.defaultClientSettingOverrides != null) {
|
||||
try {
|
||||
const parsed = JSON.parse(res.defaultClientSettingOverrides);
|
||||
for (const key in parsed) {
|
||||
if (key in overrideDefs) {
|
||||
overrideDefs[key].enableOverride = true;
|
||||
overrideDefs[key].overrideValue = parsed[key];
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
clientSettingOverrides.value = overrideDefs;
|
||||
fetching.value = false;
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (clientSettingOverrides.value == null) return;
|
||||
|
||||
const overrides = Object.fromEntries(
|
||||
typeSafeObjectEntries(clientSettingOverrides.value)
|
||||
.filter(([key, def]) => (
|
||||
def.enableOverride &&
|
||||
def.overrideValue !== def.defaultValue &&
|
||||
JSON.stringify(def.overrideValue) !== JSON.stringify(def.defaultValue)
|
||||
))
|
||||
.map(([key, def]) => [key, def.overrideValue])
|
||||
);
|
||||
|
||||
let defaultClientSettingOverrides: string | null = JSON.stringify(overrides);
|
||||
|
||||
if (Object.keys(overrides).length === 0) {
|
||||
defaultClientSettingOverrides = null;
|
||||
}
|
||||
|
||||
await os.apiWithDialog('admin/update-meta', {
|
||||
defaultClientSettingOverrides,
|
||||
});
|
||||
|
||||
await fetch();
|
||||
pruneInstanceCache();
|
||||
await reloadAsk({ reason: i18n.ts.reloadToApplySetting });
|
||||
}
|
||||
|
||||
async function reset() {
|
||||
const { canceled } = await os.confirm({
|
||||
type: 'warning',
|
||||
text: i18n.ts.resetAreYouSure,
|
||||
});
|
||||
|
||||
if (canceled) return;
|
||||
|
||||
await os.apiWithDialog('admin/update-meta', {
|
||||
defaultClientSettingOverrides: null,
|
||||
});
|
||||
|
||||
await fetch();
|
||||
}
|
||||
|
||||
fetch();
|
||||
|
||||
const headerActions = computed(() => []);
|
||||
|
||||
const headerTabs = computed(() => []);
|
||||
|
||||
definePageMetadata(() => ({
|
||||
title: i18n.ts.clientSettingOverrides,
|
||||
icon: 'ti ti-checkbox',
|
||||
}));
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.warn {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.footer {
|
||||
backdrop-filter: var(--MI-blur, blur(15px));
|
||||
background: var(--MI_THEME-acrylicBg);
|
||||
border-top: solid .5px var(--MI_THEME-divider);
|
||||
}
|
||||
|
||||
.footerInner {
|
||||
max-width: 700px;
|
||||
margin: 0 auto;
|
||||
padding: 16px;
|
||||
}
|
||||
</style>
|
|
@ -249,6 +249,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<MkButton primary @click="chooseProxyAccount">{{ i18n.ts.selectAccount }}</MkButton>
|
||||
</div>
|
||||
</MkFolder>
|
||||
|
||||
<FormLink to="/admin/client-setting-overrides">{{ i18n.ts.clientSettingOverrides }} <span class="_beta">{{ i18n.ts.beta }}</span></FormLink>
|
||||
</div>
|
||||
</MkSpacer>
|
||||
</MkStickyContainer>
|
||||
|
@ -274,6 +276,7 @@ import MkKeyValue from '@/components/MkKeyValue.vue';
|
|||
import { useForm } from '@/scripts/use-form.js';
|
||||
import MkFormFooter from '@/components/MkFormFooter.vue';
|
||||
import MkRadios from '@/components/MkRadios.vue';
|
||||
import FormLink from '@/components/form/link.vue';
|
||||
|
||||
const meta = await misskeyApi('admin/meta');
|
||||
|
||||
|
|
|
@ -13,7 +13,7 @@ import { get, set } from '@/scripts/idb-proxy.js';
|
|||
import { defaultStore } from '@/store.js';
|
||||
import { useStream } from '@/stream.js';
|
||||
import { deepClone } from '@/scripts/clone.js';
|
||||
import { deepMerge } from '@/scripts/merge.js';
|
||||
import { deepMerge, type DeepPartial } from '@/scripts/merge.js';
|
||||
|
||||
type StateDef = Record<string, {
|
||||
where: 'account' | 'device' | 'deviceAccount';
|
||||
|
@ -44,6 +44,7 @@ export class Storage<T extends StateDef> {
|
|||
public readonly def: T;
|
||||
|
||||
// TODO: これが実装されたらreadonlyにしたい: https://github.com/microsoft/TypeScript/issues/37487
|
||||
private readonly defaultState: State<T>;
|
||||
public readonly state: State<T>;
|
||||
public readonly reactiveState: ReactiveState<T>;
|
||||
|
||||
|
@ -60,7 +61,7 @@ export class Storage<T extends StateDef> {
|
|||
return promise;
|
||||
}
|
||||
|
||||
constructor(key: string, def: T) {
|
||||
constructor(key: string, def: T, defaultOverrides?: DeepPartial<State<T>>) {
|
||||
this.key = key;
|
||||
this.deviceStateKeyName = `pizzax::${key}`;
|
||||
this.deviceAccountStateKeyName = $i ? `pizzax::${key}::${$i.id}` : '';
|
||||
|
@ -69,25 +70,43 @@ export class Storage<T extends StateDef> {
|
|||
|
||||
this.pizzaxChannel = new BroadcastChannel(`pizzax::${key}`);
|
||||
|
||||
this.defaultState = {} as State<T>;
|
||||
this.state = {} as State<T>;
|
||||
this.reactiveState = {} as ReactiveState<T>;
|
||||
|
||||
for (const [k, v] of Object.entries(def) as [keyof T, T[keyof T]['default']][]) {
|
||||
this.state[k] = v.default;
|
||||
this.reactiveState[k] = ref(v.default);
|
||||
let _defaultState = v.default;
|
||||
if (
|
||||
defaultOverrides != null &&
|
||||
this.isPureObject(defaultOverrides) &&
|
||||
defaultOverrides[k] !== undefined // ←意図的にnullになっている可能性があるためundefined判定
|
||||
) {
|
||||
if (this.isPureObject(defaultOverrides[k]) && this.isPureObject(v.default)) {
|
||||
_defaultState = deepMerge(defaultOverrides[k], v.default);
|
||||
} else if (Array.isArray(defaultOverrides[k]) && Array.isArray(v.default)) {
|
||||
_defaultState = Array.from(new Set([...defaultOverrides[k], ...v.default]));
|
||||
} else {
|
||||
_defaultState = defaultOverrides[k];
|
||||
}
|
||||
if (_DEV_) console.log('defaultState', k, _defaultState);
|
||||
}
|
||||
|
||||
this.defaultState[k] = _defaultState;
|
||||
this.state[k] = _defaultState;
|
||||
this.reactiveState[k] = ref(_defaultState);
|
||||
}
|
||||
|
||||
this.ready = this.init();
|
||||
this.loaded = this.ready.then(() => this.load());
|
||||
}
|
||||
|
||||
private isPureObject(value: unknown): value is Record<string | number | symbol, unknown> {
|
||||
private isPureObject(value: unknown): value is Record<PropertyKey, 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);
|
||||
const merged = deepMerge(value as DeepPartial<X>, def);
|
||||
|
||||
if (_DEV_) console.log('Merging state. Incoming: ', value, ' Default: ', def, ' Result: ', merged);
|
||||
|
||||
|
@ -105,14 +124,14 @@ export class Storage<T extends StateDef> {
|
|||
|
||||
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.reactiveState[k].value = this.state[k] = this.mergeState<T[keyof T]['default']>(deviceState[k], v.default);
|
||||
this.reactiveState[k].value = this.state[k] = this.mergeState<T[keyof T]['default']>(deviceState[k], this.defaultState[k]);
|
||||
} else if (v.where === 'account' && $i && Object.prototype.hasOwnProperty.call(registryCache, k)) {
|
||||
this.reactiveState[k].value = this.state[k] = this.mergeState<T[keyof T]['default']>(registryCache[k], v.default);
|
||||
this.reactiveState[k].value = this.state[k] = this.mergeState<T[keyof T]['default']>(registryCache[k], this.defaultState[k]);
|
||||
} else if (v.where === 'deviceAccount' && Object.prototype.hasOwnProperty.call(deviceAccountState, k)) {
|
||||
this.reactiveState[k].value = this.state[k] = this.mergeState<T[keyof T]['default']>(deviceAccountState[k], v.default);
|
||||
this.reactiveState[k].value = this.state[k] = this.mergeState<T[keyof T]['default']>(deviceAccountState[k], this.defaultState[k]);
|
||||
} else {
|
||||
this.reactiveState[k].value = this.state[k] = v.default;
|
||||
if (_DEV_) console.log('Use default value', k, v.default);
|
||||
this.reactiveState[k].value = this.state[k] = this.defaultState[k];
|
||||
if (_DEV_) console.log('Use default value', k, this.defaultState[k]);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -486,6 +486,10 @@ const routes: RouteDef[] = [{
|
|||
path: '/system-webhook',
|
||||
name: 'system-webhook',
|
||||
component: page(() => import('@/pages/admin/system-webhook.vue')),
|
||||
}, {
|
||||
path: '/client-setting-overrides',
|
||||
name: 'client-setting-overrides',
|
||||
component: page(() => import('@/pages/admin/client-setting-overrides.vue')),
|
||||
}, {
|
||||
path: '/',
|
||||
component: page(() => import('@/pages/_empty_.vue')),
|
||||
|
|
|
@ -7,10 +7,10 @@ import { deepClone } from './clone.js';
|
|||
import type { Cloneable } from './clone.js';
|
||||
|
||||
export type DeepPartial<T> = {
|
||||
[P in keyof T]?: T[P] extends Record<string | number | symbol, unknown> ? DeepPartial<T[P]> : T[P];
|
||||
[P in keyof T]?: T[P] extends Record<PropertyKey, unknown> ? DeepPartial<T[P]> : T[P];
|
||||
};
|
||||
|
||||
function isPureObject(value: unknown): value is Record<string | number | symbol, unknown> {
|
||||
function isPureObject(value: unknown): value is Record<PropertyKey, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
|
@ -18,14 +18,14 @@ function isPureObject(value: unknown): value is Record<string | number | symbol,
|
|||
* valueにないキーをdefからもらう(再帰的)\
|
||||
* nullはそのまま、undefinedはdefの値
|
||||
**/
|
||||
export function deepMerge<X extends Record<string | number | symbol, unknown>>(value: DeepPartial<X>, def: X): X {
|
||||
export function deepMerge<X extends Record<PropertyKey, unknown>>(value: DeepPartial<X>, def: X): X {
|
||||
if (isPureObject(value) && isPureObject(def)) {
|
||||
const result = deepClone(value as Cloneable) as X;
|
||||
for (const [k, v] of Object.entries(def) as [keyof X, X[keyof X]][]) {
|
||||
if (!Object.prototype.hasOwnProperty.call(value, k) || value[k] === undefined) {
|
||||
result[k] = v;
|
||||
} else if (isPureObject(v) && isPureObject(result[k])) {
|
||||
const child = deepClone(result[k] as Cloneable) as DeepPartial<X[keyof X] & Record<string | number | symbol, unknown>>;
|
||||
const child = deepClone(result[k] as Cloneable) as DeepPartial<X[keyof X] & Record<PropertyKey, unknown>>;
|
||||
result[k] = deepMerge<typeof v>(child, v);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -12,7 +12,7 @@ let isReloadConfirming = false;
|
|||
export async function reloadAsk(opts: {
|
||||
unison?: boolean;
|
||||
reason?: string;
|
||||
}) {
|
||||
} = {}) {
|
||||
if (isReloadConfirming) {
|
||||
return;
|
||||
}
|
||||
|
|
|
@ -0,0 +1,33 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { initInstance, instance } from '@/instance.js';
|
||||
|
||||
export async function getDefaultStoreOverrides() {
|
||||
await initInstance();
|
||||
if (instance.defaultClientSettingOverrides != null) {
|
||||
try {
|
||||
const clientSettingOverrides = JSON.parse(instance.defaultClientSettingOverrides);
|
||||
const out = Object.fromEntries(Object.keys(clientSettingOverrides).filter(key => key.startsWith('defaultStore::')).map(key => [key.split('::')[1], clientSettingOverrides[key]]));
|
||||
return out;
|
||||
} catch (err) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getColdDeviceStorageOverrides() {
|
||||
if (instance.defaultClientSettingOverrides != null) {
|
||||
try {
|
||||
const clientSettingOverrides = JSON.parse(instance.defaultClientSettingOverrides);
|
||||
const out = Object.fromEntries(Object.keys(clientSettingOverrides).filter(key => key.startsWith('ColdDeviceStorage::')).map(key => [key.split('::')[1], clientSettingOverrides[key]]));
|
||||
return out;
|
||||
} catch (err) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
|
@ -12,6 +12,7 @@ import { miLocalStorage } from './local-storage.js';
|
|||
import type { SoundType } from '@/scripts/sound.js';
|
||||
import { Storage } from '@/pizzax.js';
|
||||
import type { Ast } from '@syuilo/aiscript';
|
||||
import { getColdDeviceStorageOverrides, getDefaultStoreOverrides } from '@/scripts/store-overrides.js';
|
||||
|
||||
interface PostFormAction {
|
||||
title: string,
|
||||
|
@ -502,7 +503,7 @@ export const defaultStore = markRaw(new Storage('base', {
|
|||
where: 'device',
|
||||
default: { type: 'syuilo/bubble2', volume: 1 } as SoundStore,
|
||||
},
|
||||
}));
|
||||
}, await getDefaultStoreOverrides() ?? undefined));
|
||||
|
||||
// TODO: 他のタブと永続化されたstateを同期
|
||||
|
||||
|
@ -548,7 +549,8 @@ export class ColdDeviceStorage {
|
|||
// (indexedDBはnullを保存できるため、ユーザーが意図してnullを格納した可能性がある)
|
||||
const value = miLocalStorage.getItem(`${PREFIX}${key}`);
|
||||
if (value == null) {
|
||||
return ColdDeviceStorage.default[key];
|
||||
const override = getColdDeviceStorageOverrides();
|
||||
return override != null ? override[key] ?? ColdDeviceStorage.default[key] : ColdDeviceStorage.default[key];
|
||||
} else {
|
||||
return JSON.parse(value);
|
||||
}
|
||||
|
|
|
@ -5035,6 +5035,7 @@ export type components = {
|
|||
/** @default true */
|
||||
miauth?: boolean;
|
||||
};
|
||||
defaultClientSettingOverrides: string | null;
|
||||
proxyAccountName: string | null;
|
||||
/** @example false */
|
||||
requireSetup: boolean;
|
||||
|
@ -5186,6 +5187,7 @@ export type operations = {
|
|||
deeplIsPro: boolean;
|
||||
defaultDarkTheme: string | null;
|
||||
defaultLightTheme: string | null;
|
||||
defaultClientSettingOverrides: string | null;
|
||||
description: string | null;
|
||||
disableRegistration: boolean;
|
||||
impressumUrl: string | null;
|
||||
|
@ -9496,6 +9498,7 @@ export type operations = {
|
|||
description?: string | null;
|
||||
defaultLightTheme?: string | null;
|
||||
defaultDarkTheme?: string | null;
|
||||
defaultClientSettingOverrides?: string | null;
|
||||
cacheRemoteFiles?: boolean;
|
||||
cacheRemoteSensitiveFiles?: boolean;
|
||||
emailRequiredForSignup?: boolean;
|
||||
|
|
Loading…
Reference in New Issue