diff options
| author | syuilo <Syuilotan@yahoo.co.jp> | 2021-01-11 20:38:34 +0900 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2021-01-11 20:38:34 +0900 |
| commit | 6c975275f82c79eed2c7757d55283c95d23ca5b8 (patch) | |
| tree | 2871e4c3a1a67295ea5c3e19b9136ae79a17088c /src | |
| parent | fix context menu (diff) | |
| download | misskey-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')
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 - }); -}); |