summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorsyuilo <Syuilotan@yahoo.co.jp>2021-01-11 20:38:34 +0900
committerGitHub <noreply@github.com>2021-01-11 20:38:34 +0900
commit6c975275f82c79eed2c7757d55283c95d23ca5b8 (patch)
tree2871e4c3a1a67295ea5c3e19b9136ae79a17088c /src
parentfix context menu (diff)
downloadmisskey-6c975275f82c79eed2c7757d55283c95d23ca5b8.tar.gz
misskey-6c975275f82c79eed2c7757d55283c95d23ca5b8.tar.bz2
misskey-6c975275f82c79eed2c7757d55283c95d23ca5b8.zip
Registry (#7073)
* wip * wip * wip * wip * wip * Update registry.value.vue * wip * wip * wip * wip * typo
Diffstat (limited to 'src')
-rw-r--r--src/client/account.ts1
-rw-r--r--src/client/components/post-form.vue2
-rw-r--r--src/client/components/ui/info.vue2
-rw-r--r--src/client/init.ts8
-rw-r--r--src/client/pages/settings/deck.vue21
-rw-r--r--src/client/pages/settings/index.vue30
-rw-r--r--src/client/pages/settings/other.vue8
-rw-r--r--src/client/pages/settings/registry.keys.vue115
-rw-r--r--src/client/pages/settings/registry.value.vue149
-rw-r--r--src/client/pages/settings/registry.vue91
-rw-r--r--src/client/pizzax.ts56
-rw-r--r--src/client/router.ts1
-rw-r--r--src/client/ui/deck.vue3
-rw-r--r--src/client/ui/deck/deck-store.ts85
-rw-r--r--src/db/postgre.ts2
-rw-r--r--src/models/entities/registry-item.ts58
-rw-r--r--src/models/entities/user-profile.ts1
-rw-r--r--src/models/index.ts2
-rw-r--r--src/models/repositories/user.ts1
-rw-r--r--src/server/api/api-handler.ts5
-rw-r--r--src/server/api/endpoints/i.ts25
-rw-r--r--src/server/api/endpoints/i/notifications.ts2
-rw-r--r--src/server/api/endpoints/i/registry/get-all.ts33
-rw-r--r--src/server/api/endpoints/i/registry/get-detail.ts48
-rw-r--r--src/server/api/endpoints/i/registry/get.ts45
-rw-r--r--src/server/api/endpoints/i/registry/keys-with-type.ts41
-rw-r--r--src/server/api/endpoints/i/registry/keys.ts28
-rw-r--r--src/server/api/endpoints/i/registry/remove.ts45
-rw-r--r--src/server/api/endpoints/i/registry/scopes.ts30
-rw-r--r--src/server/api/endpoints/i/registry/set.ts61
-rw-r--r--src/server/api/endpoints/i/update-client-setting.ts40
31 files changed, 939 insertions, 100 deletions
diff --git a/src/client/account.ts b/src/client/account.ts
index fdf49ee213..e6ee8613d2 100644
--- a/src/client/account.ts
+++ b/src/client/account.ts
@@ -7,7 +7,6 @@ import { waiting } from '@/os';
type Account = {
id: string;
token: string;
- clientData: Record<string, any>;
};
const data = localStorage.getItem('account');
diff --git a/src/client/components/post-form.vue b/src/client/components/post-form.vue
index 19773b3b6c..bf300eebd8 100644
--- a/src/client/components/post-form.vue
+++ b/src/client/components/post-form.vue
@@ -262,7 +262,7 @@ export default defineComponent({
}
// keep cw when reply
- if (this.$store.keepCw && this.reply && this.reply.cw) {
+ if (this.$store.state.keepCw && this.reply && this.reply.cw) {
this.useCw = true;
this.cw = this.reply.cw;
}
diff --git a/src/client/components/ui/info.vue b/src/client/components/ui/info.vue
index 3bdb69b3d1..5c71b14a0a 100644
--- a/src/client/components/ui/info.vue
+++ b/src/client/components/ui/info.vue
@@ -34,7 +34,7 @@ export default defineComponent({
font-size: 90%;
background: var(--infoBg);
color: var(--infoFg);
- border-radius: 5px;
+ border-radius: var(--radius);
&.warn {
background: var(--infoWarnBg);
diff --git a/src/client/init.ts b/src/client/init.ts
index f39f50eea6..f09097fe31 100644
--- a/src/client/init.ts
+++ b/src/client/init.ts
@@ -347,14 +347,6 @@ if ($i) {
updateAccount({ hasUnreadAnnouncement: false });
});
- main.on('clientSettingUpdated', x => {
- updateAccount({
- clientData: {
- [x.key]: x.value
- }
- });
- });
-
// トークンが再生成されたとき
// このままではMisskeyが利用できないので強制的にサインアウトさせる
main.on('myTokenRegenerated', () => {
diff --git a/src/client/pages/settings/deck.vue b/src/client/pages/settings/deck.vue
index 0d9f1ab0aa..30d36d4a06 100644
--- a/src/client/pages/settings/deck.vue
+++ b/src/client/pages/settings/deck.vue
@@ -24,6 +24,8 @@
<span>{{ $ts._deck.columnMargin }}</span>
<template #suffix>px</template>
</FormInput>
+
+ <FormLink @click="setProfile">{{ $ts._deck.profile }}<template #suffix>{{ profile }}</template></FormLink>
</FormBase>
</template>
@@ -31,7 +33,7 @@
import { defineComponent } from 'vue';
import { faImage, faCog, faColumns } from '@fortawesome/free-solid-svg-icons';
import FormSwitch from '@/components/form/switch.vue';
-import FormSelect from '@/components/form/select.vue';
+import FormLink from '@/components/form/link.vue';
import FormRadios from '@/components/form/radios.vue';
import FormInput from '@/components/form/input.vue';
import FormBase from '@/components/form/base.vue';
@@ -42,7 +44,7 @@ import * as os from '@/os';
export default defineComponent({
components: {
FormSwitch,
- FormSelect,
+ FormLink,
FormInput,
FormRadios,
FormBase,
@@ -67,6 +69,7 @@ export default defineComponent({
columnAlign: deckStore.makeGetterSetter('columnAlign'),
columnMargin: deckStore.makeGetterSetter('columnMargin'),
columnHeaderHeight: deckStore.makeGetterSetter('columnHeaderHeight'),
+ profile: deckStore.makeGetterSetter('profile'),
},
watch: {
@@ -85,5 +88,19 @@ export default defineComponent({
mounted() {
this.$emit('info', this.INFO);
},
+
+ methods: {
+ async setProfile() {
+ const { canceled, result: name } = await os.dialog({
+ title: this.$ts._deck.profile,
+ input: {
+ allowEmpty: false
+ }
+ });
+ if (canceled) return;
+ this.profile = name;
+ location.reload();
+ }
+ }
});
</script>
diff --git a/src/client/pages/settings/index.vue b/src/client/pages/settings/index.vue
index aa9fe27164..0f95a76f11 100644
--- a/src/client/pages/settings/index.vue
+++ b/src/client/pages/settings/index.vue
@@ -35,13 +35,13 @@
</FormGroup>
</FormBase>
<div class="main">
- <component :is="component" @info="onInfo"/>
+ <component :is="component" :key="page" @info="onInfo" v-bind="pageProps"/>
</div>
</div>
</template>
<script lang="ts">
-import { computed, defineAsyncComponent, defineComponent, nextTick, onMounted, ref, watch } from 'vue';
+import { computed, defineAsyncComponent, defineComponent, nextTick, onMounted, reactive, ref, watch } from 'vue';
import { faCog, faPalette, faPlug, faUser, faListUl, faLock, faCommentSlash, faMusic, faCogs, faEllipsisH, faBan, faShareAlt, faLockOpen, faKey, faBoxes } from '@fortawesome/free-solid-svg-icons';
import { faLaugh, faBell, faEnvelope } from '@fortawesome/free-regular-svg-icons';
import { i18n } from '@/i18n';
@@ -78,7 +78,9 @@ export default defineComponent({
const onInfo = (viewInfo) => {
INFO.value = viewInfo;
};
+ const pageProps = ref({});
const component = computed(() => {
+ if (props.page == null) return null;
switch (props.page) {
case 'profile': return defineAsyncComponent(() => import('./profile.vue'));
case 'privacy': return defineAsyncComponent(() => import('./privacy.vue'));
@@ -104,16 +106,35 @@ export default defineComponent({
case 'plugins': return defineAsyncComponent(() => import('./plugins.vue'));
case 'import-export': return defineAsyncComponent(() => import('./import-export.vue'));
case 'account-info': return defineAsyncComponent(() => import('./account-info.vue'));
+ case 'registry': return defineAsyncComponent(() => import('./registry.vue'));
case 'experimental-features': return defineAsyncComponent(() => import('./experimental-features.vue'));
- default: return null;
+ }
+ if (props.page.startsWith('registry/keys/system/')) {
+ return defineAsyncComponent(() => import('./registry.keys.vue'));
+ }
+ if (props.page.startsWith('registry/value/system/')) {
+ return defineAsyncComponent(() => import('./registry.value.vue'));
}
});
watch(component, () => {
+ pageProps.value = {};
+
+ if (props.page) {
+ if (props.page.startsWith('registry/keys/system/')) {
+ pageProps.value.scope = props.page.replace('registry/keys/system/', '').split('/');
+ }
+ if (props.page.startsWith('registry/value/system/')) {
+ const path = props.page.replace('registry/value/system/', '').split('/');
+ pageProps.value.xKey = path.pop();
+ pageProps.value.scope = path;
+ }
+ }
+
nextTick(() => {
scroll(el.value, 0);
});
- });
+ }, { immediate: true });
onMounted(() => {
narrow.value = el.value.offsetWidth < 1025;
@@ -125,6 +146,7 @@ export default defineComponent({
view,
el,
onInfo,
+ pageProps,
component,
logout: () => {
signout();
diff --git a/src/client/pages/settings/other.vue b/src/client/pages/settings/other.vue
index 67edaf3faa..bc42b747d5 100644
--- a/src/client/pages/settings/other.vue
+++ b/src/client/pages/settings/other.vue
@@ -15,16 +15,17 @@
DEBUG MODE
</FormSwitch>
<template v-if="debug">
- <FormLink to="/settings/regedit">RegEdit</FormLink>
<FormButton @click="taskmanager">Task Manager</FormButton>
</template>
</FormGroup>
+
+ <FormLink to="/settings/registry"><template #icon><Fa :icon="faCogs"/></template>{{ $ts.registry }}</FormLink>
</FormBase>
</template>
<script lang="ts">
import { defineAsyncComponent, defineComponent } from 'vue';
-import { faEllipsisH } from '@fortawesome/free-solid-svg-icons';
+import { faEllipsisH, faCogs } from '@fortawesome/free-solid-svg-icons';
import FormSwitch from '@/components/form/switch.vue';
import FormSelect from '@/components/form/select.vue';
import FormLink from '@/components/form/link.vue';
@@ -53,7 +54,8 @@ export default defineComponent({
title: this.$ts.other,
icon: faEllipsisH
},
- debug
+ debug,
+ faCogs
}
},
diff --git a/src/client/pages/settings/registry.keys.vue b/src/client/pages/settings/registry.keys.vue
new file mode 100644
index 0000000000..c7a90fb461
--- /dev/null
+++ b/src/client/pages/settings/registry.keys.vue
@@ -0,0 +1,115 @@
+<template>
+<FormBase>
+ <FormGroup>
+ <FormKeyValueView>
+ <template #key>{{ $ts._registry.domain }}</template>
+ <template #value>{{ $ts.system }}</template>
+ </FormKeyValueView>
+ <FormKeyValueView>
+ <template #key>{{ $ts._registry.scope }}</template>
+ <template #value>{{ scope.join('/') }}</template>
+ </FormKeyValueView>
+ </FormGroup>
+
+ <FormGroup v-if="keys">
+ <template #label>{{ $ts._registry.keys }}</template>
+ <FormLink v-for="key in keys" :to="`/settings/registry/value/system/${scope.join('/')}/${key[0]}`" class="_monospace">{{ key[0] }}<template #suffix>{{ key[1].toUpperCase() }}</template></FormLink>
+ </FormGroup>
+
+ <FormButton @click="createKey" primary>{{ $ts._registry.createKey }}</FormButton>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineAsyncComponent, defineComponent } from 'vue';
+import { faCogs } from '@fortawesome/free-solid-svg-icons';
+import * as JSON5 from 'json5';
+import MkInfo from '@/components/ui/info.vue';
+import FormSwitch from '@/components/form/switch.vue';
+import FormSelect from '@/components/form/select.vue';
+import FormLink from '@/components/form/link.vue';
+import FormBase from '@/components/form/base.vue';
+import FormGroup from '@/components/form/group.vue';
+import FormButton from '@/components/form/button.vue';
+import FormKeyValueView from '@/components/form/key-value-view.vue';
+import * as os from '@/os';
+
+export default defineComponent({
+ components: {
+ MkInfo,
+ FormBase,
+ FormSelect,
+ FormSwitch,
+ FormButton,
+ FormLink,
+ FormGroup,
+ FormKeyValueView,
+ },
+
+ props: {
+ scope: {
+ required: true
+ }
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ INFO: {
+ title: this.$ts.registry,
+ icon: faCogs
+ },
+ keys: null,
+ }
+ },
+
+ watch: {
+ scope() {
+ this.fetch();
+ }
+ },
+
+ mounted() {
+ this.$emit('info', this.INFO);
+ this.fetch();
+ },
+
+ methods: {
+ fetch() {
+ os.api('i/registry/keys-with-type', {
+ scope: this.scope
+ }).then(keys => {
+ this.keys = Object.entries(keys).sort((a, b) => a[0].localeCompare(b[0]));
+ });
+ },
+
+ async createKey() {
+ const { canceled, result } = await os.form(this.$ts._registry.createKey, {
+ key: {
+ type: 'string',
+ label: this.$ts._registry.key,
+ },
+ value: {
+ type: 'string',
+ multiline: true,
+ label: this.$ts.value,
+ },
+ scope: {
+ type: 'string',
+ label: this.$ts._registry.scope,
+ default: this.scope.join('/')
+ }
+ });
+ if (canceled) return;
+ os.apiWithDialog('i/registry/set', {
+ scope: result.scope.split('/'),
+ key: result.key,
+ value: JSON5.parse(result.value),
+ }).then(() => {
+ this.fetch();
+ });
+ }
+ }
+});
+</script>
diff --git a/src/client/pages/settings/registry.value.vue b/src/client/pages/settings/registry.value.vue
new file mode 100644
index 0000000000..943ededd21
--- /dev/null
+++ b/src/client/pages/settings/registry.value.vue
@@ -0,0 +1,149 @@
+<template>
+<FormBase>
+ <MkInfo warn>{{ $ts.editTheseSettingsMayBreakAccount }}</MkInfo>
+
+ <template v-if="value">
+ <FormGroup>
+ <FormKeyValueView>
+ <template #key>{{ $ts._registry.domain }}</template>
+ <template #value>{{ $ts.system }}</template>
+ </FormKeyValueView>
+ <FormKeyValueView>
+ <template #key>{{ $ts._registry.scope }}</template>
+ <template #value>{{ scope.join('/') }}</template>
+ </FormKeyValueView>
+ <FormKeyValueView>
+ <template #key>{{ $ts._registry.key }}</template>
+ <template #value>{{ xKey }}</template>
+ </FormKeyValueView>
+ </FormGroup>
+
+ <FormGroup>
+ <FormTextarea tall v-model:value="valueForEditor" class="_monospace" style="tab-size: 2;">
+ <span>{{ $ts.value }} (JSON)</span>
+ </FormTextarea>
+ <FormButton @click="save" primary><Fa :icon="faSave"/> {{ $ts.save }}</FormButton>
+ </FormGroup>
+
+ <FormKeyValueView>
+ <template #key>{{ $ts.updatedAt }}</template>
+ <template #value><MkTime :time="value.updatedAt" mode="detail"/></template>
+ </FormKeyValueView>
+
+ <FormButton danger @click="del"><Fa :icon="faTrash"/> {{ $ts.delete }}</FormButton>
+ </template>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineAsyncComponent, defineComponent } from 'vue';
+import { faCogs, faSave, faTrash } from '@fortawesome/free-solid-svg-icons';
+import * as JSON5 from 'json5';
+import MkInfo from '@/components/ui/info.vue';
+import FormSwitch from '@/components/form/switch.vue';
+import FormSelect from '@/components/form/select.vue';
+import FormTextarea from '@/components/form/textarea.vue';
+import FormBase from '@/components/form/base.vue';
+import FormGroup from '@/components/form/group.vue';
+import FormButton from '@/components/form/button.vue';
+import FormKeyValueView from '@/components/form/key-value-view.vue';
+import * as os from '@/os';
+
+export default defineComponent({
+ components: {
+ MkInfo,
+ FormBase,
+ FormSelect,
+ FormSwitch,
+ FormButton,
+ FormTextarea,
+ FormGroup,
+ FormKeyValueView,
+ },
+
+ props: {
+ scope: {
+ required: true
+ },
+ xKey: {
+ required: true
+ },
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ INFO: {
+ title: this.$ts.registry,
+ icon: faCogs
+ },
+ value: null,
+ valueForEditor: null,
+ faSave, faTrash,
+ }
+ },
+
+ watch: {
+ key() {
+ this.fetch();
+ },
+ },
+
+ mounted() {
+ this.$emit('info', this.INFO);
+ this.fetch();
+ },
+
+ methods: {
+ fetch() {
+ os.api('i/registry/get-detail', {
+ scope: this.scope,
+ key: this.xKey
+ }).then(value => {
+ this.value = value;
+ this.valueForEditor = JSON5.stringify(this.value.value, null, '\t');
+ });
+ },
+
+ save() {
+ try {
+ JSON5.parse(this.valueForEditor);
+ } catch (e) {
+ os.dialog({
+ type: 'error',
+ text: this.$ts.invalidValue
+ });
+ return;
+ }
+
+ os.dialog({
+ type: 'warning',
+ text: this.$ts.saveConfirm,
+ showCancelButton: true
+ }).then(({ canceled }) => {
+ if (canceled) return;
+ os.apiWithDialog('i/registry/set', {
+ scope: this.scope,
+ key: this.xKey,
+ value: JSON5.parse(this.valueForEditor)
+ });
+ });
+ },
+
+ del() {
+ os.dialog({
+ type: 'warning',
+ text: this.$ts.deleteConfirm,
+ showCancelButton: true
+ }).then(({ canceled }) => {
+ if (canceled) return;
+ os.apiWithDialog('i/registry/remove', {
+ scope: this.scope,
+ key: this.xKey
+ });
+ });
+ }
+ }
+});
+</script>
diff --git a/src/client/pages/settings/registry.vue b/src/client/pages/settings/registry.vue
new file mode 100644
index 0000000000..a43c98e730
--- /dev/null
+++ b/src/client/pages/settings/registry.vue
@@ -0,0 +1,91 @@
+<template>
+<FormBase>
+ <FormGroup v-if="scopes">
+ <template #label>{{ $ts.system }}</template>
+ <FormLink v-for="scope in scopes" :to="`/settings/registry/keys/system/${scope.join('/')}`" class="_monospace">{{ scope.join('/') }}</FormLink>
+ </FormGroup>
+ <FormButton @click="createKey" primary>{{ $ts._registry.createKey }}</FormButton>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineAsyncComponent, defineComponent } from 'vue';
+import { faCogs } from '@fortawesome/free-solid-svg-icons';
+import * as JSON5 from 'json5';
+import MkInfo from '@/components/ui/info.vue';
+import FormSwitch from '@/components/form/switch.vue';
+import FormSelect from '@/components/form/select.vue';
+import FormLink from '@/components/form/link.vue';
+import FormBase from '@/components/form/base.vue';
+import FormGroup from '@/components/form/group.vue';
+import FormButton from '@/components/form/button.vue';
+import FormKeyValueView from '@/components/form/key-value-view.vue';
+import * as os from '@/os';
+
+export default defineComponent({
+ components: {
+ MkInfo,
+ FormBase,
+ FormSelect,
+ FormSwitch,
+ FormButton,
+ FormLink,
+ FormGroup,
+ FormKeyValueView,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ INFO: {
+ title: this.$ts.registry,
+ icon: faCogs
+ },
+ scopes: null,
+ }
+ },
+
+ created() {
+ this.fetch();
+ },
+
+ mounted() {
+ this.$emit('info', this.INFO);
+ },
+
+ methods: {
+ fetch() {
+ os.api('i/registry/scopes').then(scopes => {
+ this.scopes = scopes.slice().sort((a, b) => a.join('/').localeCompare(b.join('/')));
+ });
+ },
+
+ async createKey() {
+ const { canceled, result } = await os.form(this.$ts._registry.createKey, {
+ key: {
+ type: 'string',
+ label: this.$ts._registry.key,
+ },
+ value: {
+ type: 'string',
+ multiline: true,
+ label: this.$ts.value,
+ },
+ scope: {
+ type: 'string',
+ label: this.$ts._registry.scope,
+ }
+ });
+ if (canceled) return;
+ os.apiWithDialog('i/registry/set', {
+ scope: result.scope.split('/'),
+ key: result.key,
+ value: JSON5.parse(result.value),
+ }).then(() => {
+ this.fetch();
+ });
+ }
+ }
+});
+</script>
diff --git a/src/client/pizzax.ts b/src/client/pizzax.ts
index fdaf2bebb6..794738edd4 100644
--- a/src/client/pizzax.ts
+++ b/src/client/pizzax.ts
@@ -11,6 +11,7 @@ type ArrayElement<A> = A extends readonly (infer T)[] ? T : never;
export class Storage<T extends StateDef> {
public readonly key: string;
+ public readonly keyForLocalStorage: string;
public readonly def: T;
@@ -19,20 +20,22 @@ export class Storage<T extends StateDef> {
public readonly reactiveState: { [K in keyof T]: Ref<T[K]['default']> };
constructor(key: string, def: T) {
- this.key = 'pizzax::' + key;
+ this.key = key;
+ this.keyForLocalStorage = 'pizzax::' + key;
this.def = def;
// TODO: indexedDBにする
- const deviceState = JSON.parse(localStorage.getItem(this.key) || '{}');
- const deviceAccountState = $i ? JSON.parse(localStorage.getItem(this.key + '::' + $i.id) || '{}') : {};
+ const deviceState = JSON.parse(localStorage.getItem(this.keyForLocalStorage) || '{}');
+ const deviceAccountState = $i ? JSON.parse(localStorage.getItem(this.keyForLocalStorage + '::' + $i.id) || '{}') : {};
+ const registryCache = $i ? JSON.parse(localStorage.getItem(this.keyForLocalStorage + '::cache::' + $i.id) || '{}') : {};
const state = {};
const reactiveState = {};
for (const [k, v] of Object.entries(def)) {
if (v.where === 'device' && Object.prototype.hasOwnProperty.call(deviceState, k)) {
state[k] = deviceState[k];
- } else if (v.where === 'account' && $i && Object.prototype.hasOwnProperty.call($i.clientData, k)) {
- state[k] = $i.clientData[k];
+ } else if (v.where === 'account' && $i && Object.prototype.hasOwnProperty.call(registryCache, k)) {
+ state[k] = registryCache[k];
} else if (v.where === 'deviceAccount' && Object.prototype.hasOwnProperty.call(deviceAccountState, k)) {
state[k] = deviceAccountState[k];
} else {
@@ -47,16 +50,24 @@ export class Storage<T extends StateDef> {
this.reactiveState = reactiveState as any;
if ($i) {
- watch($i, () => {
- if (_DEV_) console.log('$i updated');
-
- for (const [k, v] of Object.entries(def)) {
- if (v.where === 'account' && Object.prototype.hasOwnProperty.call($i!.clientData, k)) {
- state[k] = $i!.clientData[k];
- reactiveState[k].value = $i!.clientData[k];
+ // なぜかsetTimeoutしないとapi関数内でエラーになる(おそらく循環参照してることに原因がありそう)
+ setTimeout(() => {
+ api('i/registry/get-all', { scope: ['client', this.key] }).then(kvs => {
+ for (const [k, v] of Object.entries(def)) {
+ if (v.where === 'account') {
+ if (Object.prototype.hasOwnProperty.call(kvs, k)) {
+ state[k] = kvs[k];
+ reactiveState[k].value = kvs[k];
+ } else {
+ state[k] = v.default;
+ reactiveState[k].value = v.default;
+ }
+ }
}
- }
- });
+ });
+ }, 1);
+
+ // TODO: streamingのuser storage updateイベントを監視して更新
}
}
@@ -68,21 +79,26 @@ export class Storage<T extends StateDef> {
switch (this.def[key].where) {
case 'device': {
- const deviceState = JSON.parse(localStorage.getItem(this.key) || '{}');
+ const deviceState = JSON.parse(localStorage.getItem(this.keyForLocalStorage) || '{}');
deviceState[key] = value;
- localStorage.setItem(this.key, JSON.stringify(deviceState));
+ localStorage.setItem(this.keyForLocalStorage, JSON.stringify(deviceState));
break;
}
case 'deviceAccount': {
if ($i == null) break;
- const deviceAccountState = JSON.parse(localStorage.getItem(this.key + '::' + $i.id) || '{}');
+ const deviceAccountState = JSON.parse(localStorage.getItem(this.keyForLocalStorage + '::' + $i.id) || '{}');
deviceAccountState[key] = value;
- localStorage.setItem(this.key + '::' + $i.id, JSON.stringify(deviceAccountState));
+ localStorage.setItem(this.keyForLocalStorage + '::' + $i.id, JSON.stringify(deviceAccountState));
break;
}
case 'account': {
- api('i/update-client-setting', {
- name: key,
+ if ($i == null) break;
+ const cache = JSON.parse(localStorage.getItem(this.keyForLocalStorage + '::cache::' + $i.id) || '{}');
+ cache[key] = value;
+ localStorage.setItem(this.keyForLocalStorage + '::cache::' + $i.id, JSON.stringify(cache));
+ api('i/registry/set', {
+ scope: ['client', this.key],
+ key: key,
value: value
});
break;
diff --git a/src/client/router.ts b/src/client/router.ts
index 5753a47024..6f79426b23 100644
--- a/src/client/router.ts
+++ b/src/client/router.ts
@@ -81,7 +81,6 @@ export const router = createRouter({
{ path: '/miauth/:session', component: page('miauth') },
{ path: '/authorize-follow', component: page('follow') },
{ path: '/share', component: page('share') },
- { path: '/test', component: page('test') },
{ path: '/:catchAll(.*)', component: page('not-found') }
],
// なんかHacky
diff --git a/src/client/ui/deck.vue b/src/client/ui/deck.vue
index 099a6f60c6..a074629ddd 100644
--- a/src/client/ui/deck.vue
+++ b/src/client/ui/deck.vue
@@ -41,7 +41,7 @@ import { getScrollContainer } from '@/scripts/scroll';
import * as os from '@/os';
import { sidebarDef } from '@/sidebar';
import XCommon from './_common_/common.vue';
-import { deckStore, addColumn } from './deck/deck-store';
+import { deckStore, addColumn, loadDeck } from './deck/deck-store';
export default defineComponent({
components: {
@@ -88,6 +88,7 @@ export default defineComponent({
document.documentElement.style.overflowY = 'hidden';
document.documentElement.style.scrollBehavior = 'auto';
window.addEventListener('wheel', this.onWheel);
+ loadDeck();
},
mounted() {
diff --git a/src/client/ui/deck/deck-store.ts b/src/client/ui/deck/deck-store.ts
index 3d2e1873d3..93ea0a3228 100644
--- a/src/client/ui/deck/deck-store.ts
+++ b/src/client/ui/deck/deck-store.ts
@@ -1,5 +1,7 @@
+import { throttle } from 'throttle-debounce';
import { i18n } from '@/i18n';
-import { markRaw } from 'vue';
+import { api } from '@/os';
+import { markRaw, watch } from 'vue';
import { Storage } from '../../pizzax';
type ColumnWidget = {
@@ -21,23 +23,17 @@ function copy<T>(x: T): T {
}
export const deckStore = markRaw(new Storage('deck', {
+ profile: {
+ where: 'deviceAccount',
+ default: 'default'
+ },
columns: {
where: 'deviceAccount',
- default: [{
- id: 'a',
- type: 'main',
- name: i18n.locale._deck._columns.main,
- width: 350,
- }, {
- id: 'b',
- type: 'notifications',
- name: i18n.locale._deck._columns.notifications,
- width: 330,
- }] as Column[]
+ default: [] as Column[]
},
layout: {
where: 'deviceAccount',
- default: [['a'], ['b']] as Column['id'][][]
+ default: [] as Column['id'][][]
},
columnAlign: {
where: 'deviceAccount',
@@ -61,10 +57,60 @@ export const deckStore = markRaw(new Storage('deck', {
},
}));
+export const loadDeck = async () => {
+ let deck;
+
+ try {
+ deck = await api('i/registry/get', {
+ scope: ['client', 'deck', 'profiles'],
+ key: deckStore.state.profile,
+ });
+ } catch (e) {
+ if (e.code === 'NO_SUCH_KEY') {
+ // 後方互換性のため
+ if (deckStore.state.profile === 'default') {
+ saveDeck();
+ return;
+ }
+
+ deckStore.set('columns', [{
+ id: 'a',
+ type: 'main',
+ name: i18n.locale._deck._columns.main,
+ width: 350,
+ }, {
+ id: 'b',
+ type: 'notifications',
+ name: i18n.locale._deck._columns.notifications,
+ width: 330,
+ }]);
+ deckStore.set('layout', [['a'], ['b']]);
+ return;
+ }
+ throw e;
+ }
+
+ deckStore.set('columns', deck.columns);
+ deckStore.set('layout', deck.layout);
+};
+
+// TODO: deckがloadされていない状態でsaveすると意図せず上書きが発生するので対策する
+export const saveDeck = throttle(1000, () => {
+ api('i/registry/set', {
+ scope: ['client', 'deck', 'profiles'],
+ key: deckStore.state.profile,
+ value: {
+ columns: deckStore.reactiveState.columns.value,
+ layout: deckStore.reactiveState.layout.value,
+ }
+ });
+});
+
export function addColumn(column: Column) {
if (column.name == undefined) column.name = null;
deckStore.push('columns', column);
deckStore.push('layout', [column.id]);
+ saveDeck();
}
export function removeColumn(id: Column['id']) {
@@ -72,6 +118,7 @@ export function removeColumn(id: Column['id']) {
deckStore.set('layout', deckStore.state.layout
.map(ids => ids.filter(_id => _id !== id))
.filter(ids => ids.length > 0));
+ saveDeck();
}
export function swapColumn(a: Column['id'], b: Column['id']) {
@@ -83,6 +130,7 @@ export function swapColumn(a: Column['id'], b: Column['id']) {
layout[aX][aY] = b;
layout[bX][bY] = a;
deckStore.set('layout', layout);
+ saveDeck();
}
export function swapLeftColumn(id: Column['id']) {
@@ -98,6 +146,7 @@ export function swapLeftColumn(id: Column['id']) {
return true;
}
});
+ saveDeck();
}
export function swapRightColumn(id: Column['id']) {
@@ -113,6 +162,7 @@ export function swapRightColumn(id: Column['id']) {
return true;
}
});
+ saveDeck();
}
export function swapUpColumn(id: Column['id']) {
@@ -132,6 +182,7 @@ export function swapUpColumn(id: Column['id']) {
return true;
}
});
+ saveDeck();
}
export function swapDownColumn(id: Column['id']) {
@@ -151,6 +202,7 @@ export function swapDownColumn(id: Column['id']) {
return true;
}
});
+ saveDeck();
}
export function stackLeftColumn(id: Column['id']) {
@@ -160,6 +212,7 @@ export function stackLeftColumn(id: Column['id']) {
layout[i - 1].push(id);
layout = layout.filter(ids => ids.length > 0);
deckStore.set('layout', layout);
+ saveDeck();
}
export function popRightColumn(id: Column['id']) {
@@ -169,6 +222,7 @@ export function popRightColumn(id: Column['id']) {
layout.splice(i + 1, 0, [id]);
layout = layout.filter(ids => ids.length > 0);
deckStore.set('layout', layout);
+ saveDeck();
}
export function addColumnWidget(id: Column['id'], widget: ColumnWidget) {
@@ -180,6 +234,7 @@ export function addColumnWidget(id: Column['id'], widget: ColumnWidget) {
column.widgets.unshift(widget);
columns[columnIndex] = column;
deckStore.set('columns', columns);
+ saveDeck();
}
export function removeColumnWidget(id: Column['id'], widget: ColumnWidget) {
@@ -190,6 +245,7 @@ export function removeColumnWidget(id: Column['id'], widget: ColumnWidget) {
column.widgets = column.widgets.filter(w => w.id != widget.id);
columns[columnIndex] = column;
deckStore.set('columns', columns);
+ saveDeck();
}
export function setColumnWidgets(id: Column['id'], widgets: ColumnWidget[]) {
@@ -200,6 +256,7 @@ export function setColumnWidgets(id: Column['id'], widgets: ColumnWidget[]) {
column.widgets = widgets;
columns[columnIndex] = column;
deckStore.set('columns', columns);
+ saveDeck();
}
export function updateColumnWidget(id: Column['id'], widgetId: string, data: any) {
@@ -213,6 +270,7 @@ export function updateColumnWidget(id: Column['id'], widgetId: string, data: any
} : w);
columns[columnIndex] = column;
deckStore.set('columns', columns);
+ saveDeck();
}
export function updateColumn(id: Column['id'], column: Partial<Column>) {
@@ -225,4 +283,5 @@ export function updateColumn(id: Column['id'], column: Partial<Column>) {
}
columns[columnIndex] = currentColumn;
deckStore.set('columns', columns);
+ saveDeck();
}
diff --git a/src/db/postgre.ts b/src/db/postgre.ts
index e2acdeafd1..2f3c910163 100644
--- a/src/db/postgre.ts
+++ b/src/db/postgre.ts
@@ -63,6 +63,7 @@ import { MutedNote } from '../models/entities/muted-note';
import { Channel } from '../models/entities/channel';
import { ChannelFollowing } from '../models/entities/channel-following';
import { ChannelNotePining } from '../models/entities/channel-note-pining';
+import { RegistryItem } from '../models/entities/registry-item';
const sqlLogger = dbLogger.createSubLogger('sql', 'white', false);
@@ -159,6 +160,7 @@ export const entities = [
Channel,
ChannelFollowing,
ChannelNotePining,
+ RegistryItem,
...charts as any
];
diff --git a/src/models/entities/registry-item.ts b/src/models/entities/registry-item.ts
new file mode 100644
index 0000000000..54d2ef2082
--- /dev/null
+++ b/src/models/entities/registry-item.ts
@@ -0,0 +1,58 @@
+import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm';
+import { User } from './user';
+import { id } from '../id';
+
+// TODO: 同じdomain、同じscope、同じkeyのレコードは二つ以上存在しないように制約付けたい
+@Entity()
+export class RegistryItem {
+ @PrimaryColumn(id())
+ public id: string;
+
+ @Column('timestamp with time zone', {
+ comment: 'The created date of the RegistryItem.'
+ })
+ public createdAt: Date;
+
+ @Column('timestamp with time zone', {
+ comment: 'The updated date of the RegistryItem.'
+ })
+ public updatedAt: Date;
+
+ @Index()
+ @Column({
+ ...id(),
+ comment: 'The owner ID.'
+ })
+ public userId: User['id'];
+
+ @ManyToOne(type => User, {
+ onDelete: 'CASCADE'
+ })
+ @JoinColumn()
+ public user: User | null;
+
+ @Column('varchar', {
+ length: 1024,
+ comment: 'The key of the RegistryItem.'
+ })
+ public key: string;
+
+ @Column('jsonb', {
+ default: {}, nullable: true,
+ comment: 'The value of the RegistryItem.'
+ })
+ public value: any | null;
+
+ @Index()
+ @Column('varchar', {
+ length: 1024, array: true, default: '{}'
+ })
+ public scope: string[];
+
+ // サードパーティアプリに開放するときのためのカラム
+ @Index()
+ @Column('varchar', {
+ length: 512, nullable: true
+ })
+ public domain: string | null;
+}
diff --git a/src/models/entities/user-profile.ts b/src/models/entities/user-profile.ts
index 97a4150be0..0e2c660325 100644
--- a/src/models/entities/user-profile.ts
+++ b/src/models/entities/user-profile.ts
@@ -94,6 +94,7 @@ export class UserProfile {
})
public password: string | null;
+ // TODO: そのうち消す
@Column('jsonb', {
default: {},
comment: 'The client-specific data of the User.'
diff --git a/src/models/index.ts b/src/models/index.ts
index dd05dcbcc6..213570a9c4 100644
--- a/src/models/index.ts
+++ b/src/models/index.ts
@@ -57,6 +57,7 @@ import { ChannelRepository } from './repositories/channel';
import { MutedNote } from './entities/muted-note';
import { ChannelFollowing } from './entities/channel-following';
import { ChannelNotePining } from './entities/channel-note-pining';
+import { RegistryItem } from './entities/registry-item';
export const Announcements = getRepository(Announcement);
export const AnnouncementReads = getRepository(AnnouncementRead);
@@ -116,3 +117,4 @@ export const MutedNotes = getRepository(MutedNote);
export const Channels = getCustomRepository(ChannelRepository);
export const ChannelFollowings = getRepository(ChannelFollowing);
export const ChannelNotePinings = getRepository(ChannelNotePining);
+export const RegistryItems = getRepository(RegistryItem);
diff --git a/src/models/repositories/user.ts b/src/models/repositories/user.ts
index 29facf5239..7bf11b3167 100644
--- a/src/models/repositories/user.ts
+++ b/src/models/repositories/user.ts
@@ -261,7 +261,6 @@ export class UserRepository extends Repository<User> {
} : {}),
...(opts.includeSecrets ? {
- clientData: profile!.clientData,
email: profile!.email,
emailVerified: profile!.emailVerified,
securityKeysList: profile!.twoFactorEnabled
diff --git a/src/server/api/api-handler.ts b/src/server/api/api-handler.ts
index 7fbc200fc0..80a4fd97c8 100644
--- a/src/server/api/api-handler.ts
+++ b/src/server/api/api-handler.ts
@@ -11,7 +11,7 @@ export default (endpoint: IEndpoint, ctx: Koa.Context) => new Promise((res) => {
const reply = (x?: any, y?: ApiError) => {
if (x == null) {
ctx.status = 204;
- } else if (typeof x === 'number') {
+ } else if (typeof x === 'number' && y) {
ctx.status = x;
ctx.body = {
error: {
@@ -23,7 +23,8 @@ export default (endpoint: IEndpoint, ctx: Koa.Context) => new Promise((res) => {
}
};
} else {
- ctx.body = x;
+ // 文字列を返す場合は、JSON.stringify通さないとJSONと認識されない
+ ctx.body = typeof x === 'string' ? JSON.stringify(x) : x;
}
res();
};
diff --git a/src/server/api/endpoints/i.ts b/src/server/api/endpoints/i.ts
index bceb9548ef..3d0c092adb 100644
--- a/src/server/api/endpoints/i.ts
+++ b/src/server/api/endpoints/i.ts
@@ -1,5 +1,7 @@
import define from '../define';
-import { Users } from '../../../models';
+import { RegistryItems, UserProfiles, Users } from '../../../models';
+import { ensure } from '../../../prelude/ensure';
+import { genId } from '../../../misc/gen-id';
export const meta = {
desc: {
@@ -22,6 +24,27 @@ export const meta = {
export default define(meta, async (ps, user, token) => {
const isSecure = token == null;
+ // TODO: そのうち消す
+ const profile = await UserProfiles.findOne(user.id).then(ensure);
+ for (const [k, v] of Object.entries(profile.clientData)) {
+ await RegistryItems.insert({
+ id: genId(),
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ userId: user.id,
+ domain: null,
+ scope: ['client', 'base'],
+ key: k,
+ value: v
+ });
+ }
+ await UserProfiles.createQueryBuilder().update()
+ .set({
+ clientData: {},
+ })
+ .where('userId = :id', { id: user.id })
+ .execute();
+
return await Users.pack(user, user, {
detail: true,
includeSecrets: isSecure
diff --git a/src/server/api/endpoints/i/notifications.ts b/src/server/api/endpoints/i/notifications.ts
index fd355dab83..0e09bc73fd 100644
--- a/src/server/api/endpoints/i/notifications.ts
+++ b/src/server/api/endpoints/i/notifications.ts
@@ -80,7 +80,7 @@ export default define(meta, async (ps, user) => {
.where('muting.muterId = :muterId', { muterId: user.id });
const suspendedQuery = Users.createQueryBuilder('users')
- .select('id')
+ .select('users.id')
.where('users.isSuspended = TRUE');
const query = makePaginationQuery(Notifications.createQueryBuilder('notification'), ps.sinceId, ps.untilId)
diff --git a/src/server/api/endpoints/i/registry/get-all.ts b/src/server/api/endpoints/i/registry/get-all.ts
new file mode 100644
index 0000000000..ce8653f22b
--- /dev/null
+++ b/src/server/api/endpoints/i/registry/get-all.ts
@@ -0,0 +1,33 @@
+import $ from 'cafy';
+import define from '../../../define';
+import { RegistryItems } from '../../../../../models';
+
+export const meta = {
+ requireCredential: true as const,
+
+ secure: true,
+
+ params: {
+ scope: {
+ validator: $.optional.arr($.str.match(/^[a-zA-Z0-9_]+$/)),
+ default: [],
+ },
+ }
+};
+
+export default define(meta, async (ps, user) => {
+ const query = RegistryItems.createQueryBuilder('item')
+ .where('item.domain IS NULL')
+ .andWhere('item.userId = :userId', { userId: user.id })
+ .andWhere('item.scope = :scope', { scope: ps.scope });
+
+ const items = await query.getMany();
+
+ const res = {} as Record<string, any>;
+
+ for (const item of items) {
+ res[item.key] = item.value;
+ }
+
+ return res;
+});
diff --git a/src/server/api/endpoints/i/registry/get-detail.ts b/src/server/api/endpoints/i/registry/get-detail.ts
new file mode 100644
index 0000000000..441833d3d7
--- /dev/null
+++ b/src/server/api/endpoints/i/registry/get-detail.ts
@@ -0,0 +1,48 @@
+import $ from 'cafy';
+import define from '../../../define';
+import { RegistryItems } from '../../../../../models';
+import { ApiError } from '../../../error';
+
+export const meta = {
+ requireCredential: true as const,
+
+ secure: true,
+
+ params: {
+ key: {
+ validator: $.str
+ },
+
+ scope: {
+ validator: $.optional.arr($.str.match(/^[a-zA-Z0-9_]+$/)),
+ default: [],
+ },
+ },
+
+ errors: {
+ noSuchKey: {
+ message: 'No such key.',
+ code: 'NO_SUCH_KEY',
+ id: '97a1e8e7-c0f7-47d2-957a-92e61256e01a'
+ },
+ },
+};
+
+export default define(meta, async (ps, user) => {
+ const query = RegistryItems.createQueryBuilder('item')
+ .where('item.domain IS NULL')
+ .andWhere('item.userId = :userId', { userId: user.id })
+ .andWhere('item.key = :key', { key: ps.key })
+ .andWhere('item.scope = :scope', { scope: ps.scope });
+
+ const item = await query.getOne();
+
+ if (item == null) {
+ throw new ApiError(meta.errors.noSuchKey);
+ }
+
+ return {
+ updatedAt: item.updatedAt,
+ value: item.value,
+ };
+});
diff --git a/src/server/api/endpoints/i/registry/get.ts b/src/server/api/endpoints/i/registry/get.ts
new file mode 100644
index 0000000000..275e660cb6
--- /dev/null
+++ b/src/server/api/endpoints/i/registry/get.ts
@@ -0,0 +1,45 @@
+import $ from 'cafy';
+import define from '../../../define';
+import { RegistryItems } from '../../../../../models';
+import { ApiError } from '../../../error';
+
+export const meta = {
+ requireCredential: true as const,
+
+ secure: true,
+
+ params: {
+ key: {
+ validator: $.str
+ },
+
+ scope: {
+ validator: $.optional.arr($.str.match(/^[a-zA-Z0-9_]+$/)),
+ default: [],
+ },
+ },
+
+ errors: {
+ noSuchKey: {
+ message: 'No such key.',
+ code: 'NO_SUCH_KEY',
+ id: 'ac3ed68a-62f0-422b-a7bc-d5e09e8f6a6a'
+ },
+ },
+};
+
+export default define(meta, async (ps, user) => {
+ const query = RegistryItems.createQueryBuilder('item')
+ .where('item.domain IS NULL')
+ .andWhere('item.userId = :userId', { userId: user.id })
+ .andWhere('item.key = :key', { key: ps.key })
+ .andWhere('item.scope = :scope', { scope: ps.scope });
+
+ const item = await query.getOne();
+
+ if (item == null) {
+ throw new ApiError(meta.errors.noSuchKey);
+ }
+
+ return item.value;
+});
diff --git a/src/server/api/endpoints/i/registry/keys-with-type.ts b/src/server/api/endpoints/i/registry/keys-with-type.ts
new file mode 100644
index 0000000000..06d77acbeb
--- /dev/null
+++ b/src/server/api/endpoints/i/registry/keys-with-type.ts
@@ -0,0 +1,41 @@
+import $ from 'cafy';
+import define from '../../../define';
+import { RegistryItems } from '../../../../../models';
+
+export const meta = {
+ requireCredential: true as const,
+
+ secure: true,
+
+ params: {
+ scope: {
+ validator: $.optional.arr($.str.match(/^[a-zA-Z0-9_]+$/)),
+ default: [],
+ },
+ }
+};
+
+export default define(meta, async (ps, user) => {
+ const query = RegistryItems.createQueryBuilder('item')
+ .where('item.domain IS NULL')
+ .andWhere('item.userId = :userId', { userId: user.id })
+ .andWhere('item.scope = :scope', { scope: ps.scope });
+
+ const items = await query.getMany();
+
+ const res = {} as Record<string, string>;
+
+ for (const item of items) {
+ const type = typeof item.value;
+ res[item.key] =
+ item.value === null ? 'null' :
+ Array.isArray(item.value) ? 'array' :
+ type === 'number' ? 'number' :
+ type === 'string' ? 'string' :
+ type === 'boolean' ? 'boolean' :
+ type === 'object' ? 'object' :
+ null as never;
+ }
+
+ return res;
+});
diff --git a/src/server/api/endpoints/i/registry/keys.ts b/src/server/api/endpoints/i/registry/keys.ts
new file mode 100644
index 0000000000..e4dd5044b4
--- /dev/null
+++ b/src/server/api/endpoints/i/registry/keys.ts
@@ -0,0 +1,28 @@
+import $ from 'cafy';
+import define from '../../../define';
+import { RegistryItems } from '../../../../../models';
+
+export const meta = {
+ requireCredential: true as const,
+
+ secure: true,
+
+ params: {
+ scope: {
+ validator: $.optional.arr($.str.match(/^[a-zA-Z0-9_]+$/)),
+ default: [],
+ },
+ }
+};
+
+export default define(meta, async (ps, user) => {
+ const query = RegistryItems.createQueryBuilder('item')
+ .select('item.key')
+ .where('item.domain IS NULL')
+ .andWhere('item.userId = :userId', { userId: user.id })
+ .andWhere('item.scope = :scope', { scope: ps.scope });
+
+ const items = await query.getMany();
+
+ return items.map(x => x.key);
+});
diff --git a/src/server/api/endpoints/i/registry/remove.ts b/src/server/api/endpoints/i/registry/remove.ts
new file mode 100644
index 0000000000..e73444efd2
--- /dev/null
+++ b/src/server/api/endpoints/i/registry/remove.ts
@@ -0,0 +1,45 @@
+import $ from 'cafy';
+import define from '../../../define';
+import { RegistryItems } from '../../../../../models';
+import { ApiError } from '../../../error';
+
+export const meta = {
+ requireCredential: true as const,
+
+ secure: true,
+
+ params: {
+ key: {
+ validator: $.str
+ },
+
+ scope: {
+ validator: $.optional.arr($.str.match(/^[a-zA-Z0-9_]+$/)),
+ default: [],
+ },
+ },
+
+ errors: {
+ noSuchKey: {
+ message: 'No such key.',
+ code: 'NO_SUCH_KEY',
+ id: '1fac4e8a-a6cd-4e39-a4a5-3a7e11f1b019'
+ },
+ },
+};
+
+export default define(meta, async (ps, user) => {
+ const query = RegistryItems.createQueryBuilder('item')
+ .where('item.domain IS NULL')
+ .andWhere('item.userId = :userId', { userId: user.id })
+ .andWhere('item.key = :key', { key: ps.key })
+ .andWhere('item.scope = :scope', { scope: ps.scope });
+
+ const item = await query.getOne();
+
+ if (item == null) {
+ throw new ApiError(meta.errors.noSuchKey);
+ }
+
+ RegistryItems.remove(item);
+});
diff --git a/src/server/api/endpoints/i/registry/scopes.ts b/src/server/api/endpoints/i/registry/scopes.ts
new file mode 100644
index 0000000000..8b0e1a7fd8
--- /dev/null
+++ b/src/server/api/endpoints/i/registry/scopes.ts
@@ -0,0 +1,30 @@
+import $ from 'cafy';
+import define from '../../../define';
+import { RegistryItems } from '../../../../../models';
+
+export const meta = {
+ requireCredential: true as const,
+
+ secure: true,
+
+ params: {
+ }
+};
+
+export default define(meta, async (ps, user) => {
+ const query = RegistryItems.createQueryBuilder('item')
+ .select('item.scope')
+ .where('item.domain IS NULL')
+ .andWhere('item.userId = :userId', { userId: user.id });
+
+ const items = await query.getMany();
+
+ const res = [] as string[][];
+
+ for (const item of items) {
+ if (res.some(scope => scope.join('.') === item.scope.join('.'))) continue;
+ res.push(item.scope);
+ }
+
+ return res;
+});
diff --git a/src/server/api/endpoints/i/registry/set.ts b/src/server/api/endpoints/i/registry/set.ts
new file mode 100644
index 0000000000..c732cfc8f5
--- /dev/null
+++ b/src/server/api/endpoints/i/registry/set.ts
@@ -0,0 +1,61 @@
+import $ from 'cafy';
+import { publishMainStream } from '../../../../../services/stream';
+import define from '../../../define';
+import { RegistryItems } from '../../../../../models';
+import { genId } from '../../../../../misc/gen-id';
+
+export const meta = {
+ requireCredential: true as const,
+
+ secure: true,
+
+ params: {
+ key: {
+ validator: $.str.min(1)
+ },
+
+ value: {
+ validator: $.nullable.any
+ },
+
+ scope: {
+ validator: $.optional.arr($.str.match(/^[a-zA-Z0-9_]+$/)),
+ default: [],
+ },
+ }
+};
+
+export default define(meta, async (ps, user) => {
+ const query = RegistryItems.createQueryBuilder('item')
+ .where('item.domain IS NULL')
+ .andWhere('item.userId = :userId', { userId: user.id })
+ .andWhere('item.key = :key', { key: ps.key })
+ .andWhere('item.scope = :scope', { scope: ps.scope });
+
+ const existingItem = await query.getOne();
+
+ if (existingItem) {
+ await RegistryItems.update(existingItem.id, {
+ updatedAt: new Date(),
+ value: ps.value
+ });
+ } else {
+ await RegistryItems.insert({
+ id: genId(),
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ userId: user.id,
+ domain: null,
+ scope: ps.scope,
+ key: ps.key,
+ value: ps.value
+ });
+ }
+
+ // TODO: サードパーティアプリが傍受出来てしまうのでどうにかする
+ publishMainStream(user.id, 'registryUpdated', {
+ scope: ps.scope,
+ key: ps.key,
+ value: ps.value
+ });
+});
diff --git a/src/server/api/endpoints/i/update-client-setting.ts b/src/server/api/endpoints/i/update-client-setting.ts
deleted file mode 100644
index 5143d3d9ba..0000000000
--- a/src/server/api/endpoints/i/update-client-setting.ts
+++ /dev/null
@@ -1,40 +0,0 @@
-import $ from 'cafy';
-import { publishMainStream } from '../../../../services/stream';
-import define from '../../define';
-import { UserProfiles } from '../../../../models';
-import { ensure } from '../../../../prelude/ensure';
-
-export const meta = {
- requireCredential: true as const,
-
- secure: true,
-
- params: {
- name: {
- validator: $.str.match(/^[a-zA-Z]+$/)
- },
-
- value: {
- validator: $.nullable.any
- }
- }
-};
-
-export default define(meta, async (ps, user) => {
- const profile = await UserProfiles.findOne(user.id).then(ensure);
-
- await UserProfiles.createQueryBuilder().update()
- .set({
- clientData: Object.assign(profile.clientData, {
- [ps.name]: ps.value
- }),
- })
- .where('userId = :id', { id: user.id })
- .execute();
-
- // Publish event
- publishMainStream(user.id, 'clientSettingUpdated', {
- key: ps.name,
- value: ps.value
- });
-});