* wip

* wip

* wip

* wip

* wip

* Update registry.value.vue

* wip

* wip

* wip

* wip

* typo
This commit is contained in:
syuilo 2021-01-11 20:38:34 +09:00 committed by GitHub
parent 1286dee1ab
commit 6c975275f8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
37 changed files with 1017 additions and 100 deletions

View file

@ -685,6 +685,19 @@ accentColor: "アクセント"
textColor: "文字"
saveAs: "名前を付けて保存"
advanced: "高度"
value: "値"
updatedAt: "更新日時"
saveConfirm: "保存しますか?"
deleteConfirm: "削除しますか?"
invalidValue: "有効な値ではありません。"
registry: "レジストリ"
_registry:
scope: "スコープ"
key: "キー"
keys: "キー"
domain: "ドメイン"
createKey: "キーを作成"
_aboutMisskey:
about: "Misskeyはsyuiloによって2014年から開発されている、オープンソースのソフトウェアです。"
@ -1558,6 +1571,7 @@ _deck:
swapDown: "下に移動"
stackLeft: "左に重ねる"
popRight: "右に出す"
profile: "プロファイル"
_columns:
main: "メイン"

View file

@ -0,0 +1,22 @@
import {MigrationInterface, QueryRunner} from "typeorm";
export class registry1610277136869 implements MigrationInterface {
name = 'registry1610277136869'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`CREATE TABLE "registry_item" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL, "userId" character varying(32) NOT NULL, "key" character varying(1024) NOT NULL, "scope" character varying(1024) array NOT NULL DEFAULT '{}'::varchar[], "domain" character varying(512), CONSTRAINT "PK_64b3f7e6008b4d89b826cd3af95" PRIMARY KEY ("id")); COMMENT ON COLUMN "registry_item"."createdAt" IS 'The created date of the RegistryItem.'; COMMENT ON COLUMN "registry_item"."updatedAt" IS 'The updated date of the RegistryItem.'; COMMENT ON COLUMN "registry_item"."userId" IS 'The owner ID.'; COMMENT ON COLUMN "registry_item"."key" IS 'The key of the RegistryItem.'`);
await queryRunner.query(`CREATE INDEX "IDX_fb9d21ba0abb83223263df6bcb" ON "registry_item" ("userId") `);
await queryRunner.query(`CREATE INDEX "IDX_22baca135bb8a3ea1a83d13df3" ON "registry_item" ("scope") `);
await queryRunner.query(`CREATE INDEX "IDX_0a72bdfcdb97c0eca11fe7ecad" ON "registry_item" ("domain") `);
await queryRunner.query(`ALTER TABLE "registry_item" ADD CONSTRAINT "FK_fb9d21ba0abb83223263df6bcb3" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "registry_item" DROP CONSTRAINT "FK_fb9d21ba0abb83223263df6bcb3"`);
await queryRunner.query(`DROP INDEX "IDX_0a72bdfcdb97c0eca11fe7ecad"`);
await queryRunner.query(`DROP INDEX "IDX_22baca135bb8a3ea1a83d13df3"`);
await queryRunner.query(`DROP INDEX "IDX_fb9d21ba0abb83223263df6bcb"`);
await queryRunner.query(`DROP TABLE "registry_item"`);
}
}

View file

@ -0,0 +1,16 @@
import {MigrationInterface, QueryRunner} from "typeorm";
export class registry21610277585759 implements MigrationInterface {
name = 'registry21610277585759'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "registry_item" ADD "value" jsonb NOT NULL DEFAULT '{}'`);
await queryRunner.query(`COMMENT ON COLUMN "registry_item"."value" IS 'The value of the RegistryItem.'`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`COMMENT ON COLUMN "registry_item"."value" IS 'The value of the RegistryItem.'`);
await queryRunner.query(`ALTER TABLE "registry_item" DROP COLUMN "value"`);
}
}

View file

@ -0,0 +1,14 @@
import {MigrationInterface, QueryRunner} from "typeorm";
export class registry31610283021566 implements MigrationInterface {
name = 'registry31610283021566'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "registry_item" ALTER COLUMN "value" DROP NOT NULL`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "registry_item" ALTER COLUMN "value" SET NOT NULL`);
}
}

View file

@ -98,6 +98,7 @@
"@types/sharp": "0.26.1",
"@types/sinonjs__fake-timers": "6.0.1",
"@types/speakeasy": "2.0.5",
"@types/throttle-debounce": "2.1.0",
"@types/tinycolor2": "1.4.2",
"@types/tmp": "0.2.0",
"@types/uuid": "8.3.0",
@ -232,6 +233,7 @@
"syuilo-password-strength": "0.0.1",
"textarea-caret": "3.1.0",
"three": "0.117.1",
"throttle-debounce": "3.0.1",
"tinycolor2": "1.4.2",
"tmp": "0.2.1",
"ts-loader": "8.0.11",

View file

@ -7,7 +7,6 @@ import { waiting } from '@/os';
type Account = {
id: string;
token: string;
clientData: Record<string, any>;
};
const data = localStorage.getItem('account');

View file

@ -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;
}

View file

@ -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);

View file

@ -347,14 +347,6 @@ if ($i) {
updateAccount({ hasUnreadAnnouncement: false });
});
main.on('clientSettingUpdated', x => {
updateAccount({
clientData: {
[x.key]: x.value
}
});
});
// トークンが再生成されたとき
// このままではMisskeyが利用できないので強制的にサインアウトさせる
main.on('myTokenRegenerated', () => {

View file

@ -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>

View file

@ -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();

View file

@ -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
}
},

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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;

View file

@ -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

View file

@ -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() {

View file

@ -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();
}

View file

@ -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
];

View file

@ -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;
}

View file

@ -94,6 +94,7 @@ export class UserProfile {
})
public password: string | null;
// TODO: そのうち消す
@Column('jsonb', {
default: {},
comment: 'The client-specific data of the User.'

View file

@ -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);

View file

@ -261,7 +261,6 @@ export class UserRepository extends Repository<User> {
} : {}),
...(opts.includeSecrets ? {
clientData: profile!.clientData,
email: profile!.email,
emailVerified: profile!.emailVerified,
securityKeysList: profile!.twoFactorEnabled

View file

@ -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();
};

View file

@ -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

View file

@ -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)

View file

@ -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;
});

View file

@ -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,
};
});

View file

@ -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;
});

View file

@ -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;
});

View file

@ -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);
});

View file

@ -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);
});

View file

@ -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;
});

View file

@ -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
});
});

View file

@ -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
});
});

View file

@ -911,6 +911,11 @@
resolved "https://registry.yarnpkg.com/@types/tapable/-/tapable-1.0.5.tgz#9adbc12950582aa65ead76bffdf39fe0c27a3c02"
integrity sha512-/gG2M/Imw7cQFp8PGvz/SwocNrmKFjFsm5Pb8HdbHkZ1K8pmuPzOX4VeVoiEecFCVf4CsN1r3/BRvx+6sNqwtQ==
"@types/throttle-debounce@2.1.0":
version "2.1.0"
resolved "https://registry.yarnpkg.com/@types/throttle-debounce/-/throttle-debounce-2.1.0.tgz#1c3df624bfc4b62f992d3012b84c56d41eab3776"
integrity sha512-5eQEtSCoESnh2FsiLTxE121IiE60hnMqcb435fShf4bpLRjEu1Eoekht23y6zXS9Ts3l+Szu3TARnTsA0GkOkQ==
"@types/tinycolor2@1.4.2":
version "1.4.2"
resolved "https://registry.yarnpkg.com/@types/tinycolor2/-/tinycolor2-1.4.2.tgz#721ca5c5d1a2988b4a886e35c2ffc5735b6afbdf"
@ -10025,6 +10030,11 @@ three@0.117.1:
resolved "https://registry.yarnpkg.com/three/-/three-0.117.1.tgz#a49bcb1a6ddea2f250003e42585dc3e78e92b9d3"
integrity sha512-t4zeJhlNzUIj9+ub0l6nICVimSuRTZJOqvk3Rmlu+YGdTOJ49Wna8p7aumpkXJakJfITiybfpYE1XN1o1Z34UQ==
throttle-debounce@3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/throttle-debounce/-/throttle-debounce-3.0.1.tgz#32f94d84dfa894f786c9a1f290e7a645b6a19abb"
integrity sha512-dTEWWNu6JmeVXY0ZYoPuH5cRIwc0MeGbJwah9KUNYSJwommQpCzTySTpEe8Gs1J23aeWEuAobe4Ag7EHVt/LOg==
through2-filter@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/through2-filter/-/through2-filter-3.0.0.tgz#700e786df2367c2c88cd8aa5be4cf9c1e7831254"