enhance: クライアント設定の初期値を変更できるように(簡易)
This commit is contained in:
parent
224bbd486f
commit
1005d17313
|
@ -5218,6 +5218,31 @@ export interface Locale extends ILocale {
|
||||||
* 利用可能なロール
|
* 利用可能なロール
|
||||||
*/
|
*/
|
||||||
"availableRoles": string;
|
"availableRoles": string;
|
||||||
|
/**
|
||||||
|
* クライアント設定の初期値
|
||||||
|
*/
|
||||||
|
"clientSettingOverrides": string;
|
||||||
|
/**
|
||||||
|
* 全ユーザーに対するクライアント設定の初期値を変更できます。
|
||||||
|
* 知識のない方が変更すると全ユーザーがクライアントにアクセスできなくなる可能性があります。
|
||||||
|
*/
|
||||||
|
"clientSettingOverridesWarn": string;
|
||||||
|
/**
|
||||||
|
* オーバーライドする
|
||||||
|
*/
|
||||||
|
"enableOverride": string;
|
||||||
|
/**
|
||||||
|
* オーバーライドする値
|
||||||
|
*/
|
||||||
|
"overrideValue": string;
|
||||||
|
/**
|
||||||
|
* スイッチをオンにするとtrueとなります。
|
||||||
|
*/
|
||||||
|
"onToTrue": string;
|
||||||
|
/**
|
||||||
|
* リセット
|
||||||
|
*/
|
||||||
|
"reset": string;
|
||||||
"_accountSettings": {
|
"_accountSettings": {
|
||||||
/**
|
/**
|
||||||
* コンテンツの表示にログインを必須にする
|
* コンテンツの表示にログインを必須にする
|
||||||
|
|
|
@ -1300,6 +1300,12 @@ thisContentsAreMarkedAsSigninRequiredByAuthor: "投稿者により、表示に
|
||||||
lockdown: "ロックダウン"
|
lockdown: "ロックダウン"
|
||||||
pleaseSelectAccount: "アカウントを選択してください"
|
pleaseSelectAccount: "アカウントを選択してください"
|
||||||
availableRoles: "利用可能なロール"
|
availableRoles: "利用可能なロール"
|
||||||
|
clientSettingOverrides: "クライアント設定の初期値"
|
||||||
|
clientSettingOverridesWarn: "全ユーザーに対するクライアント設定の初期値を変更できます。\n知識のない方が変更すると全ユーザーがクライアントにアクセスできなくなる可能性があります。"
|
||||||
|
enableOverride: "オーバーライドする"
|
||||||
|
overrideValue: "オーバーライドする値"
|
||||||
|
onToTrue: "スイッチをオンにするとtrueとなります。"
|
||||||
|
reset: "リセット"
|
||||||
|
|
||||||
_accountSettings:
|
_accountSettings:
|
||||||
requireSigninToViewContents: "コンテンツの表示にログインを必須にする"
|
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'> = {
|
const packDetailed: Packed<'MetaDetailed'> = {
|
||||||
...packed,
|
...packed,
|
||||||
|
defaultClientSettingOverrides: instance.defaultClientSettingOverrides,
|
||||||
cacheRemoteFiles: instance.cacheRemoteFiles,
|
cacheRemoteFiles: instance.cacheRemoteFiles,
|
||||||
cacheRemoteSensitiveFiles: instance.cacheRemoteSensitiveFiles,
|
cacheRemoteSensitiveFiles: instance.cacheRemoteSensitiveFiles,
|
||||||
requireSetup: !await this.instanceActorService.realLocalUsersPresent(),
|
requireSetup: !await this.instanceActorService.realLocalUsersPresent(),
|
||||||
|
|
|
@ -409,6 +409,12 @@ export class MiMeta {
|
||||||
})
|
})
|
||||||
public defaultDarkTheme: string | null;
|
public defaultDarkTheme: string | null;
|
||||||
|
|
||||||
|
@Column('varchar', {
|
||||||
|
length: 8192,
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
public defaultClientSettingOverrides: string | null;
|
||||||
|
|
||||||
@Column('boolean', {
|
@Column('boolean', {
|
||||||
default: false,
|
default: false,
|
||||||
})
|
})
|
||||||
|
|
|
@ -315,6 +315,10 @@ export const packedMetaDetailedOnlySchema = {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
defaultClientSettingOverrides: {
|
||||||
|
type: 'string',
|
||||||
|
optional: false, nullable: true,
|
||||||
|
},
|
||||||
proxyAccountName: {
|
proxyAccountName: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
optional: false, nullable: true,
|
optional: false, nullable: true,
|
||||||
|
|
|
@ -420,6 +420,10 @@ export const meta = {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
optional: false, nullable: true,
|
optional: false, nullable: true,
|
||||||
},
|
},
|
||||||
|
defaultClientSettingOverrides: {
|
||||||
|
type: 'string',
|
||||||
|
optional: false, nullable: true,
|
||||||
|
},
|
||||||
description: {
|
description: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
optional: false, nullable: true,
|
optional: false, nullable: true,
|
||||||
|
@ -585,6 +589,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
logoImageUrl: instance.logoImageUrl,
|
logoImageUrl: instance.logoImageUrl,
|
||||||
defaultLightTheme: instance.defaultLightTheme,
|
defaultLightTheme: instance.defaultLightTheme,
|
||||||
defaultDarkTheme: instance.defaultDarkTheme,
|
defaultDarkTheme: instance.defaultDarkTheme,
|
||||||
|
defaultClientSettingOverrides: instance.defaultClientSettingOverrides,
|
||||||
enableEmail: instance.enableEmail,
|
enableEmail: instance.enableEmail,
|
||||||
enableServiceWorker: instance.enableServiceWorker,
|
enableServiceWorker: instance.enableServiceWorker,
|
||||||
translatorAvailable: instance.deeplAuthKey != null,
|
translatorAvailable: instance.deeplAuthKey != null,
|
||||||
|
|
|
@ -67,6 +67,7 @@ export const paramDef = {
|
||||||
description: { type: 'string', nullable: true },
|
description: { type: 'string', nullable: true },
|
||||||
defaultLightTheme: { type: 'string', nullable: true },
|
defaultLightTheme: { type: 'string', nullable: true },
|
||||||
defaultDarkTheme: { type: 'string', nullable: true },
|
defaultDarkTheme: { type: 'string', nullable: true },
|
||||||
|
defaultClientSettingOverrides: { type: 'string', nullable: true },
|
||||||
cacheRemoteFiles: { type: 'boolean' },
|
cacheRemoteFiles: { type: 'boolean' },
|
||||||
cacheRemoteSensitiveFiles: { type: 'boolean' },
|
cacheRemoteSensitiveFiles: { type: 'boolean' },
|
||||||
emailRequiredForSignup: { type: 'boolean' },
|
emailRequiredForSignup: { type: 'boolean' },
|
||||||
|
@ -303,6 +304,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
set.defaultDarkTheme = ps.defaultDarkTheme;
|
set.defaultDarkTheme = ps.defaultDarkTheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (ps.defaultClientSettingOverrides !== undefined) {
|
||||||
|
set.defaultClientSettingOverrides = ps.defaultClientSettingOverrides;
|
||||||
|
}
|
||||||
|
|
||||||
if (ps.cacheRemoteFiles !== undefined) {
|
if (ps.cacheRemoteFiles !== undefined) {
|
||||||
set.cacheRemoteFiles = ps.cacheRemoteFiles;
|
set.cacheRemoteFiles = ps.cacheRemoteFiles;
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,7 +14,7 @@ import { isDeviceDarkmode } from '@/scripts/is-device-darkmode.js';
|
||||||
import { updateI18n, i18n } from '@/i18n.js';
|
import { updateI18n, i18n } from '@/i18n.js';
|
||||||
import { $i, refreshAccount, login } from '@/account.js';
|
import { $i, refreshAccount, login } from '@/account.js';
|
||||||
import { defaultStore, ColdDeviceStorage } from '@/store.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 { deviceKind } from '@/scripts/device-kind.js';
|
||||||
import { reloadChannel } from '@/scripts/unison-reload.js';
|
import { reloadChannel } from '@/scripts/unison-reload.js';
|
||||||
import { getUrlWithoutLoginId } from '@/scripts/login-id.js';
|
import { getUrlWithoutLoginId } from '@/scripts/login-id.js';
|
||||||
|
@ -116,14 +116,11 @@ export async function common(createVue: () => App<Element>) {
|
||||||
html.setAttribute('lang', lang);
|
html.setAttribute('lang', lang);
|
||||||
//#endregion
|
//#endregion
|
||||||
|
|
||||||
|
await initInstance();
|
||||||
await defaultStore.ready;
|
await defaultStore.ready;
|
||||||
await deckStore.ready;
|
await deckStore.ready;
|
||||||
|
|
||||||
const fetchInstanceMetaPromise = fetchInstance();
|
miLocalStorage.setItem('v', instance.version);
|
||||||
|
|
||||||
fetchInstanceMetaPromise.then(() => {
|
|
||||||
miLocalStorage.setItem('v', instance.version);
|
|
||||||
});
|
|
||||||
|
|
||||||
//#region loginId
|
//#region loginId
|
||||||
const params = new URLSearchParams(location.search);
|
const params = new URLSearchParams(location.search);
|
||||||
|
@ -177,13 +174,11 @@ export async function common(createVue: () => App<Element>) {
|
||||||
});
|
});
|
||||||
//#endregion
|
//#endregion
|
||||||
|
|
||||||
fetchInstanceMetaPromise.then(() => {
|
if (defaultStore.state.themeInitial) {
|
||||||
if (defaultStore.state.themeInitial) {
|
if (instance.defaultLightTheme != null) ColdDeviceStorage.set('lightTheme', JSON.parse(instance.defaultLightTheme));
|
||||||
if (instance.defaultLightTheme != null) ColdDeviceStorage.set('lightTheme', JSON.parse(instance.defaultLightTheme));
|
if (instance.defaultDarkTheme != null) ColdDeviceStorage.set('darkTheme', JSON.parse(instance.defaultDarkTheme));
|
||||||
if (instance.defaultDarkTheme != null) ColdDeviceStorage.set('darkTheme', JSON.parse(instance.defaultDarkTheme));
|
defaultStore.set('themeInitial', false);
|
||||||
defaultStore.set('themeInitial', false);
|
}
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
watch(defaultStore.reactiveState.useBlurEffectForModal, v => {
|
watch(defaultStore.reactiveState.useBlurEffectForModal, v => {
|
||||||
document.documentElement.style.setProperty('--MI-modalBgFilter', v ? 'blur(4px)' : 'none');
|
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 cachedMeta = miLocalStorage.getItem('instance') ? JSON.parse(miLocalStorage.getItem('instance')!) : null;
|
||||||
let cachedAt = miLocalStorage.getItem('instanceCachedAt') ? parseInt(miLocalStorage.getItem('instanceCachedAt')!) : 0;
|
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;
|
const providedAt = providedMetaEl && providedMetaEl.dataset.generatedAt ? parseInt(providedMetaEl.dataset.generatedAt) : 0;
|
||||||
if (providedAt > cachedAt) {
|
if (providedAt > cachedAt) {
|
||||||
miLocalStorage.setItem('instance', JSON.stringify(providedMeta));
|
miLocalStorage.setItem('instance', JSON.stringify(providedMeta));
|
||||||
|
@ -38,6 +38,19 @@ export const notFoundImageUrl = computed(() => instance.notFoundImageUrl ?? DEFA
|
||||||
|
|
||||||
export const isEnabledUrlPreview = computed(() => instance.enableUrlPreview ?? true);
|
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> {
|
export async function fetchInstance(force = false): Promise<Misskey.entities.MetaDetailed> {
|
||||||
if (!force) {
|
if (!force) {
|
||||||
const cachedAt = miLocalStorage.getItem('instanceCachedAt') ? parseInt(miLocalStorage.getItem('instanceCachedAt')!) : 0;
|
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;
|
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>
|
<MkButton primary @click="chooseProxyAccount">{{ i18n.ts.selectAccount }}</MkButton>
|
||||||
</div>
|
</div>
|
||||||
</MkFolder>
|
</MkFolder>
|
||||||
|
|
||||||
|
<FormLink to="/admin/client-setting-overrides">{{ i18n.ts.clientSettingOverrides }} <span class="_beta">{{ i18n.ts.beta }}</span></FormLink>
|
||||||
</div>
|
</div>
|
||||||
</MkSpacer>
|
</MkSpacer>
|
||||||
</MkStickyContainer>
|
</MkStickyContainer>
|
||||||
|
@ -274,6 +276,7 @@ import MkKeyValue from '@/components/MkKeyValue.vue';
|
||||||
import { useForm } from '@/scripts/use-form.js';
|
import { useForm } from '@/scripts/use-form.js';
|
||||||
import MkFormFooter from '@/components/MkFormFooter.vue';
|
import MkFormFooter from '@/components/MkFormFooter.vue';
|
||||||
import MkRadios from '@/components/MkRadios.vue';
|
import MkRadios from '@/components/MkRadios.vue';
|
||||||
|
import FormLink from '@/components/form/link.vue';
|
||||||
|
|
||||||
const meta = await misskeyApi('admin/meta');
|
const meta = await misskeyApi('admin/meta');
|
||||||
|
|
||||||
|
|
|
@ -13,7 +13,7 @@ import { get, set } from '@/scripts/idb-proxy.js';
|
||||||
import { defaultStore } from '@/store.js';
|
import { defaultStore } from '@/store.js';
|
||||||
import { useStream } from '@/stream.js';
|
import { useStream } from '@/stream.js';
|
||||||
import { deepClone } from '@/scripts/clone.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, {
|
type StateDef = Record<string, {
|
||||||
where: 'account' | 'device' | 'deviceAccount';
|
where: 'account' | 'device' | 'deviceAccount';
|
||||||
|
@ -44,6 +44,7 @@ export class Storage<T extends StateDef> {
|
||||||
public readonly def: T;
|
public readonly def: T;
|
||||||
|
|
||||||
// TODO: これが実装されたらreadonlyにしたい: https://github.com/microsoft/TypeScript/issues/37487
|
// TODO: これが実装されたらreadonlyにしたい: https://github.com/microsoft/TypeScript/issues/37487
|
||||||
|
private readonly defaultState: State<T>;
|
||||||
public readonly state: State<T>;
|
public readonly state: State<T>;
|
||||||
public readonly reactiveState: ReactiveState<T>;
|
public readonly reactiveState: ReactiveState<T>;
|
||||||
|
|
||||||
|
@ -60,7 +61,7 @@ export class Storage<T extends StateDef> {
|
||||||
return promise;
|
return promise;
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor(key: string, def: T) {
|
constructor(key: string, def: T, defaultOverrides?: DeepPartial<State<T>>) {
|
||||||
this.key = key;
|
this.key = key;
|
||||||
this.deviceStateKeyName = `pizzax::${key}`;
|
this.deviceStateKeyName = `pizzax::${key}`;
|
||||||
this.deviceAccountStateKeyName = $i ? `pizzax::${key}::${$i.id}` : '';
|
this.deviceAccountStateKeyName = $i ? `pizzax::${key}::${$i.id}` : '';
|
||||||
|
@ -69,25 +70,43 @@ export class Storage<T extends StateDef> {
|
||||||
|
|
||||||
this.pizzaxChannel = new BroadcastChannel(`pizzax::${key}`);
|
this.pizzaxChannel = new BroadcastChannel(`pizzax::${key}`);
|
||||||
|
|
||||||
|
this.defaultState = {} as State<T>;
|
||||||
this.state = {} as State<T>;
|
this.state = {} as State<T>;
|
||||||
this.reactiveState = {} as ReactiveState<T>;
|
this.reactiveState = {} as ReactiveState<T>;
|
||||||
|
|
||||||
for (const [k, v] of Object.entries(def) as [keyof T, T[keyof T]['default']][]) {
|
for (const [k, v] of Object.entries(def) as [keyof T, T[keyof T]['default']][]) {
|
||||||
this.state[k] = v.default;
|
let _defaultState = v.default;
|
||||||
this.reactiveState[k] = ref(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.ready = this.init();
|
||||||
this.loaded = this.ready.then(() => this.load());
|
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);
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
private mergeState<X>(value: X, def: X): X {
|
private mergeState<X>(value: X, def: X): X {
|
||||||
if (this.isPureObject(value) && this.isPureObject(def)) {
|
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);
|
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']][]) {
|
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)) {
|
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)) {
|
} 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)) {
|
} 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 {
|
} else {
|
||||||
this.reactiveState[k].value = this.state[k] = v.default;
|
this.reactiveState[k].value = this.state[k] = this.defaultState[k];
|
||||||
if (_DEV_) console.log('Use default value', k, v.default);
|
if (_DEV_) console.log('Use default value', k, this.defaultState[k]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -486,6 +486,10 @@ const routes: RouteDef[] = [{
|
||||||
path: '/system-webhook',
|
path: '/system-webhook',
|
||||||
name: 'system-webhook',
|
name: 'system-webhook',
|
||||||
component: page(() => import('@/pages/admin/system-webhook.vue')),
|
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: '/',
|
path: '/',
|
||||||
component: page(() => import('@/pages/_empty_.vue')),
|
component: page(() => import('@/pages/_empty_.vue')),
|
||||||
|
|
|
@ -7,10 +7,10 @@ import { deepClone } from './clone.js';
|
||||||
import type { Cloneable } from './clone.js';
|
import type { Cloneable } from './clone.js';
|
||||||
|
|
||||||
export type DeepPartial<T> = {
|
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);
|
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からもらう(再帰的)\
|
* valueにないキーをdefからもらう(再帰的)\
|
||||||
* nullはそのまま、undefinedは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)) {
|
if (isPureObject(value) && isPureObject(def)) {
|
||||||
const result = deepClone(value as Cloneable) as X;
|
const result = deepClone(value as Cloneable) as X;
|
||||||
for (const [k, v] of Object.entries(def) as [keyof X, X[keyof 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) {
|
if (!Object.prototype.hasOwnProperty.call(value, k) || value[k] === undefined) {
|
||||||
result[k] = v;
|
result[k] = v;
|
||||||
} else if (isPureObject(v) && isPureObject(result[k])) {
|
} 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);
|
result[k] = deepMerge<typeof v>(child, v);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -12,7 +12,7 @@ let isReloadConfirming = false;
|
||||||
export async function reloadAsk(opts: {
|
export async function reloadAsk(opts: {
|
||||||
unison?: boolean;
|
unison?: boolean;
|
||||||
reason?: string;
|
reason?: string;
|
||||||
}) {
|
} = {}) {
|
||||||
if (isReloadConfirming) {
|
if (isReloadConfirming) {
|
||||||
return;
|
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 type { SoundType } from '@/scripts/sound.js';
|
||||||
import { Storage } from '@/pizzax.js';
|
import { Storage } from '@/pizzax.js';
|
||||||
import type { Ast } from '@syuilo/aiscript';
|
import type { Ast } from '@syuilo/aiscript';
|
||||||
|
import { getColdDeviceStorageOverrides, getDefaultStoreOverrides } from '@/scripts/store-overrides.js';
|
||||||
|
|
||||||
interface PostFormAction {
|
interface PostFormAction {
|
||||||
title: string,
|
title: string,
|
||||||
|
@ -502,7 +503,7 @@ export const defaultStore = markRaw(new Storage('base', {
|
||||||
where: 'device',
|
where: 'device',
|
||||||
default: { type: 'syuilo/bubble2', volume: 1 } as SoundStore,
|
default: { type: 'syuilo/bubble2', volume: 1 } as SoundStore,
|
||||||
},
|
},
|
||||||
}));
|
}, await getDefaultStoreOverrides() ?? undefined));
|
||||||
|
|
||||||
// TODO: 他のタブと永続化されたstateを同期
|
// TODO: 他のタブと永続化されたstateを同期
|
||||||
|
|
||||||
|
@ -548,7 +549,8 @@ export class ColdDeviceStorage {
|
||||||
// (indexedDBはnullを保存できるため、ユーザーが意図してnullを格納した可能性がある)
|
// (indexedDBはnullを保存できるため、ユーザーが意図してnullを格納した可能性がある)
|
||||||
const value = miLocalStorage.getItem(`${PREFIX}${key}`);
|
const value = miLocalStorage.getItem(`${PREFIX}${key}`);
|
||||||
if (value == null) {
|
if (value == null) {
|
||||||
return ColdDeviceStorage.default[key];
|
const override = getColdDeviceStorageOverrides();
|
||||||
|
return override != null ? override[key] ?? ColdDeviceStorage.default[key] : ColdDeviceStorage.default[key];
|
||||||
} else {
|
} else {
|
||||||
return JSON.parse(value);
|
return JSON.parse(value);
|
||||||
}
|
}
|
||||||
|
|
|
@ -5035,6 +5035,7 @@ export type components = {
|
||||||
/** @default true */
|
/** @default true */
|
||||||
miauth?: boolean;
|
miauth?: boolean;
|
||||||
};
|
};
|
||||||
|
defaultClientSettingOverrides: string | null;
|
||||||
proxyAccountName: string | null;
|
proxyAccountName: string | null;
|
||||||
/** @example false */
|
/** @example false */
|
||||||
requireSetup: boolean;
|
requireSetup: boolean;
|
||||||
|
@ -5186,6 +5187,7 @@ export type operations = {
|
||||||
deeplIsPro: boolean;
|
deeplIsPro: boolean;
|
||||||
defaultDarkTheme: string | null;
|
defaultDarkTheme: string | null;
|
||||||
defaultLightTheme: string | null;
|
defaultLightTheme: string | null;
|
||||||
|
defaultClientSettingOverrides: string | null;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
disableRegistration: boolean;
|
disableRegistration: boolean;
|
||||||
impressumUrl: string | null;
|
impressumUrl: string | null;
|
||||||
|
@ -9496,6 +9498,7 @@ export type operations = {
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
defaultLightTheme?: string | null;
|
defaultLightTheme?: string | null;
|
||||||
defaultDarkTheme?: string | null;
|
defaultDarkTheme?: string | null;
|
||||||
|
defaultClientSettingOverrides?: string | null;
|
||||||
cacheRemoteFiles?: boolean;
|
cacheRemoteFiles?: boolean;
|
||||||
cacheRemoteSensitiveFiles?: boolean;
|
cacheRemoteSensitiveFiles?: boolean;
|
||||||
emailRequiredForSignup?: boolean;
|
emailRequiredForSignup?: boolean;
|
||||||
|
|
Loading…
Reference in New Issue