From be7e3b9a0cb81b78a744993fef2fa2fd2833fa9c Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Sun, 9 Mar 2025 14:28:01 +0900 Subject: refactor(frontend): scripts -> utility --- packages/frontend/src/aiscript/api.ts | 125 ++++++ packages/frontend/src/aiscript/common.ts | 16 + packages/frontend/src/aiscript/ui.ts | 655 +++++++++++++++++++++++++++++++ 3 files changed, 796 insertions(+) create mode 100644 packages/frontend/src/aiscript/api.ts create mode 100644 packages/frontend/src/aiscript/common.ts create mode 100644 packages/frontend/src/aiscript/ui.ts (limited to 'packages/frontend/src/aiscript') diff --git a/packages/frontend/src/aiscript/api.ts b/packages/frontend/src/aiscript/api.ts new file mode 100644 index 0000000000..3acc1127c9 --- /dev/null +++ b/packages/frontend/src/aiscript/api.ts @@ -0,0 +1,125 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { errors, utils, values } from '@syuilo/aiscript'; +import * as Misskey from 'misskey-js'; +import { url, lang } from '@@/js/config.js'; +import { assertStringAndIsIn } from './common.js'; +import * as os from '@/os.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { $i } from '@/account.js'; +import { miLocalStorage } from '@/local-storage.js'; +import { customEmojis } from '@/custom-emojis.js'; + +const DIALOG_TYPES = [ + 'error', + 'info', + 'success', + 'warning', + 'waiting', + 'question', +] as const; + +export function aiScriptReadline(q: string): Promise { + return new Promise(ok => { + os.inputText({ + title: q, + }).then(({ result: a }) => { + ok(a ?? ''); + }); + }); +} + +export function createAiScriptEnv(opts: { storageKey: string, token?: string }) { + return { + USER_ID: $i ? values.STR($i.id) : values.NULL, + USER_NAME: $i?.name ? values.STR($i.name) : values.NULL, + USER_USERNAME: $i ? values.STR($i.username) : values.NULL, + CUSTOM_EMOJIS: utils.jsToVal(customEmojis.value), + LOCALE: values.STR(lang), + SERVER_URL: values.STR(url), + 'Mk:dialog': values.FN_NATIVE(async ([title, text, type]) => { + utils.assertString(title); + utils.assertString(text); + if (type != null) { + assertStringAndIsIn(type, DIALOG_TYPES); + } + await os.alert({ + type: type ? type.value : 'info', + title: title.value, + text: text.value, + }); + return values.NULL; + }), + 'Mk:confirm': values.FN_NATIVE(async ([title, text, type]) => { + utils.assertString(title); + utils.assertString(text); + if (type != null) { + assertStringAndIsIn(type, DIALOG_TYPES); + } + const confirm = await os.confirm({ + type: type ? type.value : 'question', + title: title.value, + text: text.value, + }); + return confirm.canceled ? values.FALSE : values.TRUE; + }), + 'Mk:api': values.FN_NATIVE(async ([ep, param, token]) => { + utils.assertString(ep); + if (ep.value.includes('://')) { + throw new errors.AiScriptRuntimeError('invalid endpoint'); + } + if (token) { + utils.assertString(token); + // バグがあればundefinedもあり得るため念のため + if (typeof token.value !== 'string') throw new Error('invalid token'); + } + const actualToken: string | null = token?.value ?? opts.token ?? null; + if (param == null) { + throw new errors.AiScriptRuntimeError('expected param'); + } + utils.assertObject(param); + return misskeyApi(ep.value, utils.valToJs(param) as object, actualToken).then(res => { + return utils.jsToVal(res); + }, err => { + return values.ERROR('request_failed', utils.jsToVal(err)); + }); + }), + /* セキュリティ上の問題があるため無効化 + 'Mk:apiExternal': values.FN_NATIVE(async ([host, ep, param, token]) => { + utils.assertString(host); + utils.assertString(ep); + if (token) utils.assertString(token); + return os.apiExternal(host.value, ep.value, utils.valToJs(param), token?.value).then(res => { + return utils.jsToVal(res); + }, err => { + return values.ERROR('request_failed', utils.jsToVal(err)); + }); + }), + */ + 'Mk:save': values.FN_NATIVE(([key, value]) => { + utils.assertString(key); + utils.expectAny(value); + miLocalStorage.setItem(`aiscript:${opts.storageKey}:${key.value}`, JSON.stringify(utils.valToJs(value))); + return values.NULL; + }), + 'Mk:load': values.FN_NATIVE(([key]) => { + utils.assertString(key); + return utils.jsToVal(miLocalStorage.getItemAsJson(`aiscript:${opts.storageKey}:${key.value}`) ?? null); + }), + 'Mk:remove': values.FN_NATIVE(([key]) => { + utils.assertString(key); + miLocalStorage.removeItem(`aiscript:${opts.storageKey}:${key.value}`); + return values.NULL; + }), + 'Mk:url': values.FN_NATIVE(() => { + return values.STR(window.location.href); + }), + 'Mk:nyaize': values.FN_NATIVE(([text]) => { + utils.assertString(text); + return values.STR(Misskey.nyaize(text.value)); + }), + }; +} diff --git a/packages/frontend/src/aiscript/common.ts b/packages/frontend/src/aiscript/common.ts new file mode 100644 index 0000000000..ba5dfb8368 --- /dev/null +++ b/packages/frontend/src/aiscript/common.ts @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { errors, utils } from '@syuilo/aiscript'; +import type { values } from '@syuilo/aiscript'; + +export function assertStringAndIsIn(value: values.Value | undefined, expects: A): asserts value is values.VStr & { value: A[number] } { + utils.assertString(value); + const str = value.value; + if (!expects.includes(str)) { + const expected = expects.map((expect) => `"${expect}"`).join(', '); + throw new errors.AiScriptRuntimeError(`"${value.value}" is not in ${expected}`); + } +} diff --git a/packages/frontend/src/aiscript/ui.ts b/packages/frontend/src/aiscript/ui.ts new file mode 100644 index 0000000000..46e193f7c1 --- /dev/null +++ b/packages/frontend/src/aiscript/ui.ts @@ -0,0 +1,655 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { utils, values } from '@syuilo/aiscript'; +import { v4 as uuid } from 'uuid'; +import { ref } from 'vue'; +import type { Ref } from 'vue'; +import * as Misskey from 'misskey-js'; +import { assertStringAndIsIn } from './common.js'; + +const ALIGNS = ['left', 'center', 'right'] as const; +const FONTS = ['serif', 'sans-serif', 'monospace'] as const; +const BORDER_STYLES = ['hidden', 'dotted', 'dashed', 'solid', 'double', 'groove', 'ridge', 'inset', 'outset'] as const; + +type Align = (typeof ALIGNS)[number]; +type Font = (typeof FONTS)[number]; +type BorderStyle = (typeof BORDER_STYLES)[number]; + +export type AsUiComponentBase = { + id: string; + hidden?: boolean; +}; + +export type AsUiRoot = AsUiComponentBase & { + type: 'root'; + children: AsUiComponent['id'][]; +}; + +export type AsUiContainer = AsUiComponentBase & { + type: 'container'; + children?: AsUiComponent['id'][]; + align?: Align; + bgColor?: string; + fgColor?: string; + font?: Font; + borderWidth?: number; + borderColor?: string; + borderStyle?: BorderStyle; + borderRadius?: number; + padding?: number; + rounded?: boolean; + hidden?: boolean; +}; + +export type AsUiText = AsUiComponentBase & { + type: 'text'; + text?: string; + size?: number; + bold?: boolean; + color?: string; + font?: Font; +}; + +export type AsUiMfm = AsUiComponentBase & { + type: 'mfm'; + text?: string; + size?: number; + bold?: boolean; + color?: string; + font?: Font; + onClickEv?: (evId: string) => Promise; +}; + +export type AsUiButton = AsUiComponentBase & { + type: 'button'; + text?: string; + onClick?: () => Promise; + primary?: boolean; + rounded?: boolean; + disabled?: boolean; +}; + +export type AsUiButtons = AsUiComponentBase & { + type: 'buttons'; + buttons?: AsUiButton[]; +}; + +export type AsUiSwitch = AsUiComponentBase & { + type: 'switch'; + onChange?: (v: boolean) => Promise; + default?: boolean; + label?: string; + caption?: string; +}; + +export type AsUiTextarea = AsUiComponentBase & { + type: 'textarea'; + onInput?: (v: string) => Promise; + default?: string; + label?: string; + caption?: string; +}; + +export type AsUiTextInput = AsUiComponentBase & { + type: 'textInput'; + onInput?: (v: string) => Promise; + default?: string; + label?: string; + caption?: string; +}; + +export type AsUiNumberInput = AsUiComponentBase & { + type: 'numberInput'; + onInput?: (v: number) => Promise; + default?: number; + label?: string; + caption?: string; +}; + +export type AsUiSelect = AsUiComponentBase & { + type: 'select'; + items?: { + text: string; + value: string; + }[]; + onChange?: (v: string) => Promise; + default?: string; + label?: string; + caption?: string; +}; + +export type AsUiFolder = AsUiComponentBase & { + type: 'folder'; + children?: AsUiComponent['id'][]; + title?: string; + opened?: boolean; +}; + +type PostFormPropsForAsUi = { + text: string; + cw?: string; + visibility?: (typeof Misskey.noteVisibilities)[number]; + localOnly?: boolean; +}; + +export type AsUiPostFormButton = AsUiComponentBase & { + type: 'postFormButton'; + text?: string; + primary?: boolean; + rounded?: boolean; + form?: PostFormPropsForAsUi; +}; + +export type AsUiPostForm = AsUiComponentBase & { + type: 'postForm'; + form?: PostFormPropsForAsUi; +}; + +export type AsUiComponent = AsUiRoot | AsUiContainer | AsUiText | AsUiMfm | AsUiButton | AsUiButtons | AsUiSwitch | AsUiTextarea | AsUiTextInput | AsUiNumberInput | AsUiSelect | AsUiFolder | AsUiPostFormButton | AsUiPostForm; + +type Options = T extends AsUiButtons + ? Omit & { 'buttons'?: Options[] } + : Omit; + +export function patch(id: string, def: values.Value, call: (fn: values.VFn, args: values.Value[]) => Promise) { + // TODO +} + +function getRootOptions(def: values.Value | undefined): Options { + utils.assertObject(def); + + const children = def.value.get('children'); + utils.assertArray(children); + + return { + children: children.value.map(v => { + utils.assertObject(v); + const id = v.value.get('id'); + utils.assertString(id); + return id.value; + }), + }; +} + +function getContainerOptions(def: values.Value | undefined): Options { + utils.assertObject(def); + + const children = def.value.get('children'); + if (children) utils.assertArray(children); + const align = def.value.get('align'); + if (align) assertStringAndIsIn(align, ALIGNS); + const bgColor = def.value.get('bgColor'); + if (bgColor) utils.assertString(bgColor); + const fgColor = def.value.get('fgColor'); + if (fgColor) utils.assertString(fgColor); + const font = def.value.get('font'); + if (font) assertStringAndIsIn(font, FONTS); + const borderWidth = def.value.get('borderWidth'); + if (borderWidth) utils.assertNumber(borderWidth); + const borderColor = def.value.get('borderColor'); + if (borderColor) utils.assertString(borderColor); + const borderStyle = def.value.get('borderStyle'); + if (borderStyle) assertStringAndIsIn(borderStyle, BORDER_STYLES); + const borderRadius = def.value.get('borderRadius'); + if (borderRadius) utils.assertNumber(borderRadius); + const padding = def.value.get('padding'); + if (padding) utils.assertNumber(padding); + const rounded = def.value.get('rounded'); + if (rounded) utils.assertBoolean(rounded); + const hidden = def.value.get('hidden'); + if (hidden) utils.assertBoolean(hidden); + + return { + children: children ? children.value.map(v => { + utils.assertObject(v); + const id = v.value.get('id'); + utils.assertString(id); + return id.value; + }) : [], + align: align?.value, + fgColor: fgColor?.value, + bgColor: bgColor?.value, + font: font?.value, + borderWidth: borderWidth?.value, + borderColor: borderColor?.value, + borderStyle: borderStyle?.value, + borderRadius: borderRadius?.value, + padding: padding?.value, + rounded: rounded?.value, + hidden: hidden?.value, + }; +} + +function getTextOptions(def: values.Value | undefined): Options { + utils.assertObject(def); + + const text = def.value.get('text'); + if (text) utils.assertString(text); + const size = def.value.get('size'); + if (size) utils.assertNumber(size); + const bold = def.value.get('bold'); + if (bold) utils.assertBoolean(bold); + const color = def.value.get('color'); + if (color) utils.assertString(color); + const font = def.value.get('font'); + if (font) assertStringAndIsIn(font, FONTS); + + return { + text: text?.value, + size: size?.value, + bold: bold?.value, + color: color?.value, + font: font?.value, + }; +} + +function getMfmOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise): Options { + utils.assertObject(def); + + const text = def.value.get('text'); + if (text) utils.assertString(text); + const size = def.value.get('size'); + if (size) utils.assertNumber(size); + const bold = def.value.get('bold'); + if (bold) utils.assertBoolean(bold); + const color = def.value.get('color'); + if (color) utils.assertString(color); + const font = def.value.get('font'); + if (font) assertStringAndIsIn(font, FONTS); + const onClickEv = def.value.get('onClickEv'); + if (onClickEv) utils.assertFunction(onClickEv); + + return { + text: text?.value, + size: size?.value, + bold: bold?.value, + color: color?.value, + font: font?.value, + onClickEv: async (evId: string) => { + if (onClickEv) await call(onClickEv, [values.STR(evId)]); + }, + }; +} + +function getTextInputOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise): Options { + utils.assertObject(def); + + const onInput = def.value.get('onInput'); + if (onInput) utils.assertFunction(onInput); + const defaultValue = def.value.get('default'); + if (defaultValue) utils.assertString(defaultValue); + const label = def.value.get('label'); + if (label) utils.assertString(label); + const caption = def.value.get('caption'); + if (caption) utils.assertString(caption); + + return { + onInput: async (v) => { + if (onInput) await call(onInput, [utils.jsToVal(v)]); + }, + default: defaultValue?.value, + label: label?.value, + caption: caption?.value, + }; +} + +function getTextareaOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise): Options { + utils.assertObject(def); + + const onInput = def.value.get('onInput'); + if (onInput) utils.assertFunction(onInput); + const defaultValue = def.value.get('default'); + if (defaultValue) utils.assertString(defaultValue); + const label = def.value.get('label'); + if (label) utils.assertString(label); + const caption = def.value.get('caption'); + if (caption) utils.assertString(caption); + + return { + onInput: async (v) => { + if (onInput) await call(onInput, [utils.jsToVal(v)]); + }, + default: defaultValue?.value, + label: label?.value, + caption: caption?.value, + }; +} + +function getNumberInputOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise): Options { + utils.assertObject(def); + + const onInput = def.value.get('onInput'); + if (onInput) utils.assertFunction(onInput); + const defaultValue = def.value.get('default'); + if (defaultValue) utils.assertNumber(defaultValue); + const label = def.value.get('label'); + if (label) utils.assertString(label); + const caption = def.value.get('caption'); + if (caption) utils.assertString(caption); + + return { + onInput: async (v) => { + if (onInput) await call(onInput, [utils.jsToVal(v)]); + }, + default: defaultValue?.value, + label: label?.value, + caption: caption?.value, + }; +} + +function getButtonOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise): Options { + utils.assertObject(def); + + const text = def.value.get('text'); + if (text) utils.assertString(text); + const onClick = def.value.get('onClick'); + if (onClick) utils.assertFunction(onClick); + const primary = def.value.get('primary'); + if (primary) utils.assertBoolean(primary); + const rounded = def.value.get('rounded'); + if (rounded) utils.assertBoolean(rounded); + const disabled = def.value.get('disabled'); + if (disabled) utils.assertBoolean(disabled); + + return { + text: text?.value, + onClick: async () => { + if (onClick) await call(onClick, []); + }, + primary: primary?.value, + rounded: rounded?.value, + disabled: disabled?.value, + }; +} + +function getButtonsOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise): Options { + utils.assertObject(def); + + const buttons = def.value.get('buttons'); + if (buttons) utils.assertArray(buttons); + + return { + buttons: buttons ? buttons.value.map(button => { + utils.assertObject(button); + const text = button.value.get('text'); + utils.assertString(text); + const onClick = button.value.get('onClick'); + utils.assertFunction(onClick); + const primary = button.value.get('primary'); + if (primary) utils.assertBoolean(primary); + const rounded = button.value.get('rounded'); + if (rounded) utils.assertBoolean(rounded); + const disabled = button.value.get('disabled'); + if (disabled) utils.assertBoolean(disabled); + + return { + text: text.value, + onClick: async () => { + await call(onClick, []); + }, + primary: primary?.value, + rounded: rounded?.value, + disabled: disabled?.value, + }; + }) : [], + }; +} + +function getSwitchOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise): Options { + utils.assertObject(def); + + const onChange = def.value.get('onChange'); + if (onChange) utils.assertFunction(onChange); + const defaultValue = def.value.get('default'); + if (defaultValue) utils.assertBoolean(defaultValue); + const label = def.value.get('label'); + if (label) utils.assertString(label); + const caption = def.value.get('caption'); + if (caption) utils.assertString(caption); + + return { + onChange: async (v) => { + if (onChange) await call(onChange, [utils.jsToVal(v)]); + }, + default: defaultValue?.value, + label: label?.value, + caption: caption?.value, + }; +} + +function getSelectOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise): Options { + utils.assertObject(def); + + const items = def.value.get('items'); + if (items) utils.assertArray(items); + const onChange = def.value.get('onChange'); + if (onChange) utils.assertFunction(onChange); + const defaultValue = def.value.get('default'); + if (defaultValue) utils.assertString(defaultValue); + const label = def.value.get('label'); + if (label) utils.assertString(label); + const caption = def.value.get('caption'); + if (caption) utils.assertString(caption); + + return { + items: items ? items.value.map(item => { + utils.assertObject(item); + const text = item.value.get('text'); + utils.assertString(text); + const value = item.value.get('value'); + if (value) utils.assertString(value); + return { + text: text.value, + value: value ? value.value : text.value, + }; + }) : [], + onChange: async (v) => { + if (onChange) await call(onChange, [utils.jsToVal(v)]); + }, + default: defaultValue?.value, + label: label?.value, + caption: caption?.value, + }; +} + +function getFolderOptions(def: values.Value | undefined): Options { + utils.assertObject(def); + + const children = def.value.get('children'); + if (children) utils.assertArray(children); + const title = def.value.get('title'); + if (title) utils.assertString(title); + const opened = def.value.get('opened'); + if (opened) utils.assertBoolean(opened); + + return { + children: children ? children.value.map(v => { + utils.assertObject(v); + const id = v.value.get('id'); + utils.assertString(id); + return id.value; + }) : [], + title: title?.value ?? '', + opened: opened?.value ?? true, + }; +} + +function getPostFormProps(form: values.VObj): PostFormPropsForAsUi { + const text = form.value.get('text'); + utils.assertString(text); + const cw = form.value.get('cw'); + if (cw) utils.assertString(cw); + const visibility = form.value.get('visibility'); + if (visibility) utils.assertString(visibility); + const localOnly = form.value.get('localOnly'); + if (localOnly) utils.assertBoolean(localOnly); + + return { + text: text.value, + cw: cw?.value, + visibility: (visibility?.value && (Misskey.noteVisibilities as readonly string[]).includes(visibility.value)) ? visibility.value as typeof Misskey.noteVisibilities[number] : undefined, + localOnly: localOnly?.value, + }; +} + +function getPostFormButtonOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise): Options { + utils.assertObject(def); + + const text = def.value.get('text'); + if (text) utils.assertString(text); + const primary = def.value.get('primary'); + if (primary) utils.assertBoolean(primary); + const rounded = def.value.get('rounded'); + if (rounded) utils.assertBoolean(rounded); + const form = def.value.get('form'); + if (form) utils.assertObject(form); + + return { + text: text?.value, + primary: primary?.value, + rounded: rounded?.value, + form: form ? getPostFormProps(form) : { + text: '', + }, + }; +} + +function getPostFormOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise): Options { + utils.assertObject(def); + + const form = def.value.get('form'); + if (form) utils.assertObject(form); + + return { + form: form ? getPostFormProps(form) : { + text: '', + }, + }; +} + +export function registerAsUiLib(components: Ref[], done: (root: Ref) => void) { + type OptionsConverter = (def: values.Value | undefined, call: C) => Options; + + const instances = {}; + + function createComponentInstance( + type: T['type'], + def: values.Value | undefined, + id: values.Value | undefined, + getOptions: OptionsConverter, + call: C, + ) { + if (id) utils.assertString(id); + const _id = id?.value ?? uuid(); + const component = ref({ + ...getOptions(def, call), + type, + id: _id, + } as T); + components.push(component); + const instance = values.OBJ(new Map([ + ['id', values.STR(_id)], + ['update', values.FN_NATIVE(([def], opts) => { + utils.assertObject(def); + const updates = getOptions(def, call); + for (const update of def.value.keys()) { + if (!Object.hasOwn(updates, update)) continue; + component.value[update] = updates[update]; + } + })], + ])); + instances[_id] = instance; + return instance; + } + + const rootInstance = createComponentInstance('root', utils.jsToVal({ children: [] }), utils.jsToVal('___root___'), getRootOptions, () => {}); + const rootComponent = components[0] as Ref; + done(rootComponent); + + return { + 'Ui:root': rootInstance, + + 'Ui:patch': values.FN_NATIVE(([id, val], opts) => { + utils.assertString(id); + utils.assertArray(val); + // patch(id.value, val.value, opts.call); // TODO + }), + + 'Ui:get': values.FN_NATIVE(([id], opts) => { + utils.assertString(id); + const instance = instances[id.value]; + if (instance) { + return instance; + } else { + return values.NULL; + } + }), + + // Ui:root.update({ children: [...] }) の糖衣構文 + 'Ui:render': values.FN_NATIVE(([children], opts) => { + utils.assertArray(children); + + rootComponent.value.children = children.value.map(v => { + utils.assertObject(v); + const id = v.value.get('id'); + utils.assertString(id); + return id.value; + }); + }), + + 'Ui:C:container': values.FN_NATIVE(([def, id], opts) => { + return createComponentInstance('container', def, id, getContainerOptions, opts.topCall); + }), + + 'Ui:C:text': values.FN_NATIVE(([def, id], opts) => { + return createComponentInstance('text', def, id, getTextOptions, opts.topCall); + }), + + 'Ui:C:mfm': values.FN_NATIVE(([def, id], opts) => { + return createComponentInstance('mfm', def, id, getMfmOptions, opts.topCall); + }), + + 'Ui:C:textarea': values.FN_NATIVE(([def, id], opts) => { + return createComponentInstance('textarea', def, id, getTextareaOptions, opts.topCall); + }), + + 'Ui:C:textInput': values.FN_NATIVE(([def, id], opts) => { + return createComponentInstance('textInput', def, id, getTextInputOptions, opts.topCall); + }), + + 'Ui:C:numberInput': values.FN_NATIVE(([def, id], opts) => { + return createComponentInstance('numberInput', def, id, getNumberInputOptions, opts.topCall); + }), + + 'Ui:C:button': values.FN_NATIVE(([def, id], opts) => { + return createComponentInstance('button', def, id, getButtonOptions, opts.topCall); + }), + + 'Ui:C:buttons': values.FN_NATIVE(([def, id], opts) => { + return createComponentInstance('buttons', def, id, getButtonsOptions, opts.topCall); + }), + + 'Ui:C:switch': values.FN_NATIVE(([def, id], opts) => { + return createComponentInstance('switch', def, id, getSwitchOptions, opts.topCall); + }), + + 'Ui:C:select': values.FN_NATIVE(([def, id], opts) => { + return createComponentInstance('select', def, id, getSelectOptions, opts.topCall); + }), + + 'Ui:C:folder': values.FN_NATIVE(([def, id], opts) => { + return createComponentInstance('folder', def, id, getFolderOptions, opts.topCall); + }), + + 'Ui:C:postFormButton': values.FN_NATIVE(([def, id], opts) => { + return createComponentInstance('postFormButton', def, id, getPostFormButtonOptions, opts.topCall); + }), + + 'Ui:C:postForm': values.FN_NATIVE(([def, id], opts) => { + return createComponentInstance('postForm', def, id, getPostFormOptions, opts.topCall); + }), + }; +} -- cgit v1.2.3-freya From 8c9ec5827fa2040c8d705d2a01329da593d19fa3 Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Thu, 13 Mar 2025 22:12:23 +0900 Subject: enhance(frontend): improve accounts management --- packages/frontend/src/account.ts | 390 --------------------- packages/frontend/src/accounts.ts | 341 ++++++++++++++++++ packages/frontend/src/aiscript/api.ts | 2 +- packages/frontend/src/boot/common.ts | 10 +- packages/frontend/src/boot/main-boot.ts | 24 +- .../src/components/MkAnnouncementDialog.vue | 5 +- packages/frontend/src/components/MkAuthConfirm.vue | 9 +- packages/frontend/src/components/MkClipPreview.vue | 2 +- .../frontend/src/components/MkCropperDialog.vue | 2 +- packages/frontend/src/components/MkDrive.file.vue | 2 +- packages/frontend/src/components/MkEmojiPicker.vue | 2 +- .../frontend/src/components/MkFollowButton.vue | 2 +- .../frontend/src/components/MkInstanceStats.vue | 2 +- packages/frontend/src/components/MkMediaAudio.vue | 2 +- packages/frontend/src/components/MkMediaImage.vue | 2 +- packages/frontend/src/components/MkMediaVideo.vue | 2 +- packages/frontend/src/components/MkMention.vue | 2 +- packages/frontend/src/components/MkNote.vue | 2 +- .../frontend/src/components/MkNoteDetailed.vue | 2 +- packages/frontend/src/components/MkNoteSub.vue | 2 +- .../frontend/src/components/MkNotification.vue | 2 +- .../frontend/src/components/MkPasswordDialog.vue | 2 +- packages/frontend/src/components/MkPostForm.vue | 5 +- packages/frontend/src/components/MkPreview.vue | 2 +- .../components/MkPushNotificationAllowButton.vue | 3 +- .../src/components/MkReactionsViewer.reaction.vue | 2 +- packages/frontend/src/components/MkSignin.vue | 5 +- .../src/components/MkSignupDialog.form.vue | 4 +- packages/frontend/src/components/MkTimeline.vue | 2 +- .../src/components/MkTokenGenerateWindow.vue | 2 +- .../src/components/MkTutorialDialog.Note.vue | 2 +- .../src/components/MkTutorialDialog.Sensitive.vue | 2 +- packages/frontend/src/components/MkUserInfo.vue | 2 +- packages/frontend/src/components/MkUserPopup.vue | 2 +- .../frontend/src/components/MkUserSelectDialog.vue | 2 +- .../src/components/MkUserSetupDialog.Profile.vue | 2 +- packages/frontend/src/components/global/MkAd.vue | 2 +- .../src/components/global/MkCustomEmoji.vue | 2 +- .../src/components/global/MkPageHeader.vue | 3 +- packages/frontend/src/i.ts | 34 ++ packages/frontend/src/local-storage.ts | 1 - packages/frontend/src/navbar.ts | 2 +- packages/frontend/src/pages/about-misskey.vue | 2 +- packages/frontend/src/pages/about.emojis.vue | 2 +- packages/frontend/src/pages/achievements.vue | 2 +- packages/frontend/src/pages/admin-file.vue | 2 +- packages/frontend/src/pages/admin-user.vue | 2 +- packages/frontend/src/pages/announcement.vue | 5 +- packages/frontend/src/pages/announcements.vue | 5 +- packages/frontend/src/pages/auth.vue | 3 +- .../src/pages/avatar-decoration-edit-dialog.vue | 2 +- packages/frontend/src/pages/avatar-decorations.vue | 2 +- packages/frontend/src/pages/channel.vue | 2 +- packages/frontend/src/pages/clip.vue | 2 +- .../frontend/src/pages/drop-and-fusion.game.vue | 2 +- packages/frontend/src/pages/emojis.emoji.vue | 2 +- packages/frontend/src/pages/flash/flash.vue | 2 +- packages/frontend/src/pages/follow-requests.vue | 2 +- packages/frontend/src/pages/gallery/post.vue | 2 +- packages/frontend/src/pages/instance-info.vue | 2 +- packages/frontend/src/pages/invite.vue | 2 +- packages/frontend/src/pages/my-lists/index.vue | 2 +- packages/frontend/src/pages/my-lists/list.vue | 2 +- packages/frontend/src/pages/note.vue | 2 +- .../frontend/src/pages/page-editor/page-editor.vue | 2 +- packages/frontend/src/pages/page.vue | 2 +- packages/frontend/src/pages/reversi/game.board.vue | 2 +- .../frontend/src/pages/reversi/game.setting.vue | 2 +- packages/frontend/src/pages/reversi/game.vue | 2 +- packages/frontend/src/pages/reversi/index.vue | 2 +- packages/frontend/src/pages/scratchpad.vue | 2 +- packages/frontend/src/pages/search.note.vue | 2 +- .../frontend/src/pages/settings/2fa.qrdialog.vue | 2 +- packages/frontend/src/pages/settings/2fa.vue | 5 +- .../frontend/src/pages/settings/account-data.vue | 2 +- packages/frontend/src/pages/settings/accounts.vue | 103 ++---- .../settings/avatar-decoration.decoration.vue | 2 +- .../pages/settings/avatar-decoration.dialog.vue | 2 +- .../src/pages/settings/avatar-decoration.vue | 2 +- packages/frontend/src/pages/settings/drive.vue | 2 +- packages/frontend/src/pages/settings/email.vue | 2 +- packages/frontend/src/pages/settings/index.vue | 3 +- packages/frontend/src/pages/settings/migration.vue | 2 +- .../pages/settings/mute-block.instance-mute.vue | 2 +- .../frontend/src/pages/settings/mute-block.vue | 2 +- .../frontend/src/pages/settings/notifications.vue | 2 +- packages/frontend/src/pages/settings/other.vue | 3 +- packages/frontend/src/pages/settings/privacy.vue | 2 +- packages/frontend/src/pages/settings/profile.vue | 2 +- packages/frontend/src/pages/signup-complete.vue | 2 +- packages/frontend/src/pages/tag.vue | 2 +- packages/frontend/src/pages/theme-editor.vue | 2 +- packages/frontend/src/pages/timeline.vue | 2 +- packages/frontend/src/pages/user/achievements.vue | 2 +- packages/frontend/src/pages/user/home.vue | 2 +- packages/frontend/src/pages/user/index.vue | 2 +- packages/frontend/src/pages/welcome.setup.vue | 2 +- packages/frontend/src/pizzax.ts | 2 +- packages/frontend/src/preferences.ts | 2 +- packages/frontend/src/preferences/def.ts | 4 + packages/frontend/src/preferences/manager.ts | 2 +- packages/frontend/src/preferences/utility.ts | 2 +- packages/frontend/src/router/definition.ts | 2 +- packages/frontend/src/signout.ts | 54 +++ packages/frontend/src/store.ts | 4 + packages/frontend/src/stream.ts | 2 +- packages/frontend/src/theme-store.ts | 2 +- packages/frontend/src/timelines.ts | 2 +- .../frontend/src/ui/_common_/PreferenceRestore.vue | 2 +- .../frontend/src/ui/_common_/announcements.vue | 2 +- packages/frontend/src/ui/_common_/common.ts | 2 +- packages/frontend/src/ui/_common_/common.vue | 2 +- .../frontend/src/ui/_common_/navbar-for-mobile.vue | 3 +- packages/frontend/src/ui/_common_/navbar.vue | 3 +- packages/frontend/src/ui/_common_/sw-inject.ts | 3 +- packages/frontend/src/ui/classic.header.vue | 4 +- packages/frontend/src/ui/classic.sidebar.vue | 3 +- packages/frontend/src/ui/deck.vue | 2 +- packages/frontend/src/ui/universal.vue | 2 +- packages/frontend/src/use/use-note-capture.ts | 2 +- packages/frontend/src/utility/achievements.ts | 2 +- .../src/utility/autogen/settings-search-index.ts | 7 + packages/frontend/src/utility/check-permissions.ts | 2 +- packages/frontend/src/utility/get-note-menu.ts | 2 +- packages/frontend/src/utility/get-user-menu.ts | 2 +- packages/frontend/src/utility/isFfVisibleForMe.ts | 2 +- packages/frontend/src/utility/misskey-api.ts | 2 +- packages/frontend/src/utility/please-login.ts | 2 +- packages/frontend/src/utility/show-moved-dialog.ts | 2 +- packages/frontend/src/utility/upload.ts | 2 +- packages/frontend/src/widgets/WidgetActivity.vue | 2 +- packages/frontend/src/widgets/WidgetAiscript.vue | 2 +- .../frontend/src/widgets/WidgetAiscriptApp.vue | 2 +- .../src/widgets/WidgetBirthdayFollowings.vue | 2 +- packages/frontend/src/widgets/WidgetButton.vue | 2 +- packages/frontend/src/widgets/WidgetProfile.vue | 2 +- packages/frontend/test/aiscript/api.test.ts | 2 +- 137 files changed, 640 insertions(+), 622 deletions(-) delete mode 100644 packages/frontend/src/account.ts create mode 100644 packages/frontend/src/accounts.ts create mode 100644 packages/frontend/src/i.ts create mode 100644 packages/frontend/src/signout.ts (limited to 'packages/frontend/src/aiscript') diff --git a/packages/frontend/src/account.ts b/packages/frontend/src/account.ts deleted file mode 100644 index c90d4da5ec..0000000000 --- a/packages/frontend/src/account.ts +++ /dev/null @@ -1,390 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { defineAsyncComponent, reactive, ref } from 'vue'; -import * as Misskey from 'misskey-js'; -import { apiUrl } from '@@/js/config.js'; -import type { MenuItem, MenuButton } from '@/types/menu.js'; -import { defaultMemoryStorage } from '@/memory-storage'; -import { showSuspendedDialog } from '@/utility/show-suspended-dialog.js'; -import { i18n } from '@/i18n.js'; -import { miLocalStorage } from '@/local-storage.js'; -import { del, get, set } from '@/utility/idb-proxy.js'; -import { waiting, popup, popupMenu, success, alert } from '@/os.js'; -import { misskeyApi } from '@/utility/misskey-api.js'; -import { unisonReload, reloadChannel } from '@/utility/unison-reload.js'; - -// TODO: 他のタブと永続化されたstateを同期 -// TODO: accountsはpreferences管理にする(tokenは別管理) - -type Account = Misskey.entities.MeDetailed & { token: string }; - -const accountData = miLocalStorage.getItem('account'); - -// TODO: 外部からはreadonlyに -export const $i = accountData ? reactive(JSON.parse(accountData) as Account) : null; - -export const iAmModerator = $i != null && ($i.isAdmin === true || $i.isModerator === true); -export const iAmAdmin = $i != null && $i.isAdmin; - -export function signinRequired() { - if ($i == null) throw new Error('signin required'); - return $i; -} - -export let notesCount = $i == null ? 0 : $i.notesCount; -export function incNotesCount() { - notesCount++; -} - -export async function signout() { - if (!$i) return; - - defaultMemoryStorage.clear(); - - waiting(); - document.cookie.split(';').forEach((cookie) => { - const cookieName = cookie.split('=')[0].trim(); - if (cookieName === 'token') { - document.cookie = `${cookieName}=; max-age=0; path=/`; - } - }); - miLocalStorage.removeItem('account'); - await removeAccount($i.id); - const accounts = await getAccounts(); - - //#region Remove service worker registration - try { - if (navigator.serviceWorker.controller) { - const registration = await navigator.serviceWorker.ready; - const push = await registration.pushManager.getSubscription(); - if (push) { - await window.fetch(`${apiUrl}/sw/unregister`, { - method: 'POST', - body: JSON.stringify({ - i: $i.token, - endpoint: push.endpoint, - }), - headers: { - 'Content-Type': 'application/json', - }, - }); - } - } - - if (accounts.length === 0) { - await navigator.serviceWorker.getRegistrations() - .then(registrations => { - return Promise.all(registrations.map(registration => registration.unregister())); - }); - } - } catch (err) {} - //#endregion - - if (accounts.length > 0) login(accounts[0].token); - else unisonReload('/'); -} - -export async function getAccounts(): Promise<{ id: Account['id'], token: Account['token'] }[]> { - return (await get('accounts')) || []; -} - -export async function addAccount(id: Account['id'], token: Account['token']) { - const accounts = await getAccounts(); - if (!accounts.some(x => x.id === id)) { - await set('accounts', accounts.concat([{ id, token }])); - } -} - -export async function removeAccount(idOrToken: Account['id']) { - const accounts = await getAccounts(); - const i = accounts.findIndex(x => x.id === idOrToken || x.token === idOrToken); - if (i !== -1) accounts.splice(i, 1); - - if (accounts.length > 0) { - await set('accounts', accounts); - } else { - await del('accounts'); - } -} - -function fetchAccount(token: string, id?: string, forceShowDialog?: boolean): Promise { - document.cookie = 'token=; path=/; max-age=0'; - document.cookie = `token=${token}; path=/queue; max-age=86400; SameSite=Strict; Secure`; // bull dashboardの認証とかで使う - - return new Promise((done, fail) => { - window.fetch(`${apiUrl}/i`, { - method: 'POST', - body: JSON.stringify({ - i: token, - }), - headers: { - 'Content-Type': 'application/json', - }, - }) - .then(res => new Promise }>((done2, fail2) => { - if (res.status >= 500 && res.status < 600) { - // サーバーエラー(5xx)の場合をrejectとする - // (認証エラーなど4xxはresolve) - return fail2(res); - } - res.json().then(done2, fail2); - })) - .then(async res => { - if ('error' in res) { - if (res.error.id === 'a8c724b3-6e9c-4b46-b1a8-bc3ed6258370') { - // SUSPENDED - if (forceShowDialog || $i && (token === $i.token || id === $i.id)) { - await showSuspendedDialog(); - } - } else if (res.error.id === 'e5b3b9f0-2b8f-4b9f-9c1f-8c5c1b2e1b1a') { - // USER_IS_DELETED - // アカウントが削除されている - if (forceShowDialog || $i && (token === $i.token || id === $i.id)) { - await alert({ - type: 'error', - title: i18n.ts.accountDeleted, - text: i18n.ts.accountDeletedDescription, - }); - } - } else if (res.error.id === 'b0a7f5f8-dc2f-4171-b91f-de88ad238e14') { - // AUTHENTICATION_FAILED - // トークンが無効化されていたりアカウントが削除されたりしている - if (forceShowDialog || $i && (token === $i.token || id === $i.id)) { - await alert({ - type: 'error', - title: i18n.ts.tokenRevoked, - text: i18n.ts.tokenRevokedDescription, - }); - } - } else { - await alert({ - type: 'error', - title: i18n.ts.failedToFetchAccountInformation, - text: JSON.stringify(res.error), - }); - } - - // rejectかつ理由がtrueの場合、削除対象であることを示す - fail(true); - } else { - (res as Account).token = token; - done(res as Account); - } - }) - .catch(fail); - }); -} - -export function updateAccount(accountData: Account) { - if (!$i) return; - for (const key of Object.keys($i)) { - delete $i[key]; - } - for (const [key, value] of Object.entries(accountData)) { - $i[key] = value; - } - miLocalStorage.setItem('account', JSON.stringify($i)); -} - -export function updateAccountPartial(accountData: Partial) { - if (!$i) return; - for (const [key, value] of Object.entries(accountData)) { - $i[key] = value; - } - miLocalStorage.setItem('account', JSON.stringify($i)); -} - -export async function refreshAccount() { - if (!$i) return; - return fetchAccount($i.token, $i.id) - .then(updateAccount, reason => { - if (reason === true) return signout(); - return; - }); -} - -export async function login(token: Account['token'], redirect?: string) { - const showing = ref(true); - const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkWaitingDialog.vue')), { - success: false, - showing: showing, - }, { - closed: () => dispose(), - }); - if (_DEV_) console.log('logging as token ', token); - const me = await fetchAccount(token, undefined, true) - .catch(reason => { - if (reason === true) { - // 削除対象の場合 - removeAccount(token); - } - - showing.value = false; - throw reason; - }); - miLocalStorage.setItem('account', JSON.stringify(me)); - await addAccount(me.id, token); - - if (redirect) { - // 他のタブは再読み込みするだけ - reloadChannel.postMessage(null); - // このページはredirectで指定された先に移動 - location.href = redirect; - return; - } - - unisonReload(); -} - -export async function openAccountMenu(opts: { - includeCurrentAccount?: boolean; - withExtraOperation: boolean; - active?: Misskey.entities.UserDetailed['id']; - onChoose?: (account: Misskey.entities.UserDetailed) => void; -}, ev: MouseEvent) { - if (!$i) return; - - async function switchAccount(account: Misskey.entities.UserDetailed) { - const storedAccounts = await getAccounts(); - const found = storedAccounts.find(x => x.id === account.id); - if (found == null) return; - switchAccountWithToken(found.token); - } - - function switchAccountWithToken(token: string) { - login(token); - } - - const storedAccounts = await getAccounts().then(accounts => accounts.filter(x => x.id !== $i.id)); - const accountsPromise = misskeyApi('users/show', { userIds: storedAccounts.map(x => x.id) }); - - function createItem(account: Misskey.entities.UserDetailed) { - return { - type: 'user' as const, - user: account, - active: opts.active != null ? opts.active === account.id : false, - action: () => { - if (opts.onChoose) { - opts.onChoose(account); - } else { - switchAccount(account); - } - }, - }; - } - - const accountItemPromises = storedAccounts.map(a => new Promise | MenuButton>(res => { - accountsPromise.then(accounts => { - const account = accounts.find(x => x.id === a.id); - if (account == null) return res({ - type: 'button' as const, - text: a.id, - action: () => { - switchAccountWithToken(a.token); - }, - }); - - res(createItem(account)); - }); - })); - - const menuItems: MenuItem[] = []; - - if (opts.withExtraOperation) { - menuItems.push({ - type: 'link', - text: i18n.ts.profile, - to: `/@${$i.username}`, - avatar: $i, - }, { - type: 'divider', - }); - - if (opts.includeCurrentAccount) { - menuItems.push(createItem($i)); - } - - menuItems.push(...accountItemPromises); - - menuItems.push({ - type: 'parent', - icon: 'ti ti-plus', - text: i18n.ts.addAccount, - children: [{ - text: i18n.ts.existingAccount, - action: () => { - getAccountWithSigninDialog().then(res => { - if (res != null) { - success(); - } - }); - }, - }, { - text: i18n.ts.createAccount, - action: () => { - getAccountWithSignupDialog().then(res => { - if (res != null) { - switchAccountWithToken(res.token); - } - }); - }, - }], - }, { - type: 'link', - icon: 'ti ti-users', - text: i18n.ts.manageAccounts, - to: '/settings/accounts', - }); - } else { - if (opts.includeCurrentAccount) { - menuItems.push(createItem($i)); - } - - menuItems.push(...accountItemPromises); - } - - popupMenu(menuItems, ev.currentTarget ?? ev.target, { - align: 'left', - }); -} - -export function getAccountWithSigninDialog(): Promise<{ id: string, token: string } | null> { - return new Promise((resolve) => { - const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkSigninDialog.vue')), {}, { - done: async (res: Misskey.entities.SigninFlowResponse & { finished: true }) => { - await addAccount(res.id, res.i); - resolve({ id: res.id, token: res.i }); - }, - cancelled: () => { - resolve(null); - }, - closed: () => { - dispose(); - }, - }); - }); -} - -export function getAccountWithSignupDialog(): Promise<{ id: string, token: string } | null> { - return new Promise((resolve) => { - const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkSignupDialog.vue')), {}, { - done: async (res: Misskey.entities.SignupResponse) => { - await addAccount(res.id, res.token); - resolve({ id: res.id, token: res.token }); - }, - cancelled: () => { - resolve(null); - }, - closed: () => { - dispose(); - }, - }); - }); -} - -if (_DEV_) { - (window as any).$i = $i; -} diff --git a/packages/frontend/src/accounts.ts b/packages/frontend/src/accounts.ts new file mode 100644 index 0000000000..2382a8ec32 --- /dev/null +++ b/packages/frontend/src/accounts.ts @@ -0,0 +1,341 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { defineAsyncComponent, ref } from 'vue'; +import * as Misskey from 'misskey-js'; +import { apiUrl, host } from '@@/js/config.js'; +import type { MenuItem } from '@/types/menu.js'; +import { showSuspendedDialog } from '@/utility/show-suspended-dialog.js'; +import { i18n } from '@/i18n.js'; +import { miLocalStorage } from '@/local-storage.js'; +import { waiting, popup, popupMenu, success, alert } from '@/os.js'; +import { unisonReload, reloadChannel } from '@/utility/unison-reload.js'; +import { prefer } from '@/preferences.js'; +import { store } from '@/store.js'; +import { $i } from '@/i.js'; +import { signout } from '@/signout.js'; + +// TODO: 他のタブと永続化されたstateを同期 + +type AccountWithToken = Misskey.entities.MeDetailed & { token: string }; + +export async function getAccounts(): Promise<{ + host: string; + user: Misskey.entities.User; + token: string | null; +}[]> { + const tokens = store.s.accountTokens; + const accounts = prefer.s.accounts; + return accounts.map(([host, user]) => ({ + host, + user, + token: tokens[host + '/' + user.id] ?? null, + })); +} + +async function addAccount(host: string, user: Misskey.entities.User, token: AccountWithToken['token']) { + if (!prefer.s.accounts.some(x => x[0] === host && x[1].id === user.id)) { + store.set('accountTokens', { ...store.s.accountTokens, [host + '/' + user.id]: token }); + prefer.commit('accounts', [...prefer.s.accounts, [host, user]]); + } +} + +export async function removeAccount(host: string, id: AccountWithToken['id']) { + const tokens = JSON.parse(JSON.stringify(store.s.accountTokens)); + delete tokens[host + '/' + id]; + store.set('accountTokens', tokens); + prefer.commit('accounts', prefer.s.accounts.filter(x => x[0] !== host || x[1].id !== id)); +} + +const isAccountDeleted = Symbol('isAccountDeleted'); + +function fetchAccount(token: string, id?: string, forceShowDialog?: boolean): Promise { + return new Promise((done, fail) => { + window.fetch(`${apiUrl}/i`, { + method: 'POST', + body: JSON.stringify({ + i: token, + }), + headers: { + 'Content-Type': 'application/json', + }, + }) + .then(res => new Promise }>((done2, fail2) => { + if (res.status >= 500 && res.status < 600) { + // サーバーエラー(5xx)の場合をrejectとする + // (認証エラーなど4xxはresolve) + return fail2(res); + } + res.json().then(done2, fail2); + })) + .then(async res => { + if ('error' in res) { + if (res.error.id === 'a8c724b3-6e9c-4b46-b1a8-bc3ed6258370') { + // SUSPENDED + if (forceShowDialog || $i && (token === $i.token || id === $i.id)) { + await showSuspendedDialog(); + } + } else if (res.error.id === 'e5b3b9f0-2b8f-4b9f-9c1f-8c5c1b2e1b1a') { + // USER_IS_DELETED + // アカウントが削除されている + if (forceShowDialog || $i && (token === $i.token || id === $i.id)) { + await alert({ + type: 'error', + title: i18n.ts.accountDeleted, + text: i18n.ts.accountDeletedDescription, + }); + } + } else if (res.error.id === 'b0a7f5f8-dc2f-4171-b91f-de88ad238e14') { + // AUTHENTICATION_FAILED + // トークンが無効化されていたりアカウントが削除されたりしている + if (forceShowDialog || $i && (token === $i.token || id === $i.id)) { + await alert({ + type: 'error', + title: i18n.ts.tokenRevoked, + text: i18n.ts.tokenRevokedDescription, + }); + } + } else { + await alert({ + type: 'error', + title: i18n.ts.failedToFetchAccountInformation, + text: JSON.stringify(res.error), + }); + } + + fail(isAccountDeleted); + } else { + done(res); + } + }) + .catch(fail); + }); +} + +export function updateCurrentAccount(accountData: Misskey.entities.MeDetailed) { + if (!$i) return; + const token = $i.token; + for (const key of Object.keys($i)) { + delete $i[key]; + } + for (const [key, value] of Object.entries(accountData)) { + $i[key] = value; + } + prefer.commit('accounts', prefer.s.accounts.map(([host, user]) => { + // TODO: $iのホストも比較したいけど通常null + if (user.id === $i.id) { + return [host, $i]; + } else { + return [host, user]; + } + })); + $i.token = token; + miLocalStorage.setItem('account', JSON.stringify($i)); +} + +export function updateCurrentAccountPartial(accountData: Partial) { + if (!$i) return; + for (const [key, value] of Object.entries(accountData)) { + $i[key] = value; + } + prefer.commit('accounts', prefer.s.accounts.map(([host, user]) => { + // TODO: $iのホストも比較したいけど通常null + if (user.id === $i.id) { + const newUser = JSON.parse(JSON.stringify($i)); + for (const [key, value] of Object.entries(accountData)) { + newUser[key] = value; + } + return [host, newUser]; + } + return [host, user]; + })); + miLocalStorage.setItem('account', JSON.stringify($i)); +} + +export async function refreshCurrentAccount() { + if (!$i) return; + return fetchAccount($i.token, $i.id).then(updateCurrentAccount).catch(reason => { + if (reason === isAccountDeleted) { + removeAccount(host, $i.id); + if (Object.keys(store.s.accountTokens).length > 0) { + login(Object.values(store.s.accountTokens)[0]); + } else { + signout(); + } + } + }); +} + +export async function login(token: AccountWithToken['token'], redirect?: string) { + const showing = ref(true); + const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkWaitingDialog.vue')), { + success: false, + showing: showing, + }, { + closed: () => dispose(), + }); + + const me = await fetchAccount(token, undefined, true).catch(reason => { + showing.value = false; + throw reason; + }); + + miLocalStorage.setItem('account', JSON.stringify({ + ...me, + token, + })); + + await addAccount(host, me, token); + + if (redirect) { + // 他のタブは再読み込みするだけ + reloadChannel.postMessage(null); + // このページはredirectで指定された先に移動 + location.href = redirect; + return; + } + + unisonReload(); +} + +export async function switchAccount(host: string, id: string) { + const token = store.s.accountTokens[host + '/' + id]; + if (token) { + login(token); + } else { + const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkSigninDialog.vue')), {}, { + done: async (res: Misskey.entities.SigninFlowResponse & { finished: true }) => { + store.set('accountTokens', { ...store.s.accountTokens, [host + '/' + res.id]: res.i }); + login(res.i); + }, + closed: () => { + dispose(); + }, + }); + } +} + +export async function openAccountMenu(opts: { + includeCurrentAccount?: boolean; + withExtraOperation: boolean; + active?: Misskey.entities.User['id']; + onChoose?: (account: Misskey.entities.User) => void; +}, ev: MouseEvent) { + if (!$i) return; + + function createItem(host: string, account: Misskey.entities.User): MenuItem { + return { + type: 'user' as const, + user: account, + active: opts.active != null ? opts.active === account.id : false, + action: async () => { + if (opts.onChoose) { + opts.onChoose(account); + } else { + switchAccount(host, account.id); + } + }, + }; + } + + const menuItems: MenuItem[] = []; + + // TODO: $iのホストも比較したいけど通常null + const accountItems = (await getAccounts().then(accounts => accounts.filter(x => x.user.id !== $i.id))).map(a => createItem(a.host, a.user)); + + if (opts.withExtraOperation) { + menuItems.push({ + type: 'link', + text: i18n.ts.profile, + to: `/@${$i.username}`, + avatar: $i, + }, { + type: 'divider', + }); + + if (opts.includeCurrentAccount) { + menuItems.push(createItem(host, $i)); + } + + menuItems.push(...accountItems); + + menuItems.push({ + type: 'parent', + icon: 'ti ti-plus', + text: i18n.ts.addAccount, + children: [{ + text: i18n.ts.existingAccount, + action: () => { + getAccountWithSigninDialog().then(res => { + if (res != null) { + success(); + } + }); + }, + }, { + text: i18n.ts.createAccount, + action: () => { + getAccountWithSignupDialog().then(res => { + if (res != null) { + switchAccount(host, res.id); + } + }); + }, + }], + }, { + type: 'link', + icon: 'ti ti-users', + text: i18n.ts.manageAccounts, + to: '/settings/accounts', + }); + } else { + if (opts.includeCurrentAccount) { + menuItems.push(createItem(host, $i)); + } + + menuItems.push(...accountItems); + } + + popupMenu(menuItems, ev.currentTarget ?? ev.target, { + align: 'left', + }); +} + +export function getAccountWithSigninDialog(): Promise<{ id: string, token: string } | null> { + return new Promise((resolve) => { + const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkSigninDialog.vue')), {}, { + done: async (res: Misskey.entities.SigninFlowResponse & { finished: true }) => { + const user = await fetchAccount(res.i, res.id, true); + await addAccount(host, user, res.i); + resolve({ id: res.id, token: res.i }); + }, + cancelled: () => { + resolve(null); + }, + closed: () => { + dispose(); + }, + }); + }); +} + +export function getAccountWithSignupDialog(): Promise<{ id: string, token: string } | null> { + return new Promise((resolve) => { + const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkSignupDialog.vue')), {}, { + done: async (res: Misskey.entities.SignupResponse) => { + const user = JSON.parse(JSON.stringify(res)); + delete user.token; + await addAccount(host, user, res.token); + resolve({ id: res.id, token: res.token }); + }, + cancelled: () => { + resolve(null); + }, + closed: () => { + dispose(); + }, + }); + }); +} diff --git a/packages/frontend/src/aiscript/api.ts b/packages/frontend/src/aiscript/api.ts index 3acc1127c9..e7e396023d 100644 --- a/packages/frontend/src/aiscript/api.ts +++ b/packages/frontend/src/aiscript/api.ts @@ -9,7 +9,7 @@ import { url, lang } from '@@/js/config.js'; import { assertStringAndIsIn } from './common.js'; import * as os from '@/os.js'; import { misskeyApi } from '@/utility/misskey-api.js'; -import { $i } from '@/account.js'; +import { $i } from '@/i.js'; import { miLocalStorage } from '@/local-storage.js'; import { customEmojis } from '@/custom-emojis.js'; diff --git a/packages/frontend/src/boot/common.ts b/packages/frontend/src/boot/common.ts index 122aa50ac0..73c4256c4b 100644 --- a/packages/frontend/src/boot/common.ts +++ b/packages/frontend/src/boot/common.ts @@ -15,7 +15,7 @@ import components from '@/components/index.js'; import { applyTheme } from '@/theme.js'; import { isDeviceDarkmode } from '@/utility/is-device-darkmode.js'; import { updateI18n, i18n } from '@/i18n.js'; -import { $i, refreshAccount, login } from '@/account.js'; +import { refreshCurrentAccount, login } from '@/accounts.js'; import { store } from '@/store.js'; import { fetchInstance, instance } from '@/instance.js'; import { deviceKind, updateDeviceKind } from '@/utility/device-kind.js'; @@ -29,6 +29,7 @@ import { fetchCustomEmojis } from '@/custom-emojis.js'; import { setupRouter } from '@/router/main.js'; import { createMainRouter } from '@/router/definition.js'; import { prefer } from '@/preferences.js'; +import { $i } from '@/i.js'; export async function common(createVue: () => App) { console.info(`Misskey v${version}`); @@ -38,11 +39,6 @@ export async function common(createVue: () => App) { console.info(`vue ${vueVersion}`); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (window as any).$i = $i; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (window as any).$store = store; - window.addEventListener('error', event => { console.error(event); /* @@ -244,7 +240,7 @@ export async function common(createVue: () => App) { console.log('account cache found. refreshing...'); } - refreshAccount(); + refreshCurrentAccount(); } //#endregion diff --git a/packages/frontend/src/boot/main-boot.ts b/packages/frontend/src/boot/main-boot.ts index be72eeb9e1..64e3a236e8 100644 --- a/packages/frontend/src/boot/main-boot.ts +++ b/packages/frontend/src/boot/main-boot.ts @@ -16,7 +16,7 @@ import { i18n } from '@/i18n.js'; import { alert, confirm, popup, post, toast } from '@/os.js'; import { useStream } from '@/stream.js'; import * as sound from '@/utility/sound.js'; -import { $i, signout, updateAccountPartial } from '@/account.js'; +import { $i } from '@/i.js'; import { instance } from '@/instance.js'; import { ColdDeviceStorage, store } from '@/store.js'; import { reactionPicker } from '@/utility/reaction-picker.js'; @@ -32,6 +32,8 @@ import { misskeyApi } from '@/utility/misskey-api.js'; import { deckStore } from '@/ui/deck/deck-store.js'; import { launchPlugins } from '@/plugin.js'; import { unisonReload } from '@/utility/unison-reload.js'; +import { updateCurrentAccountPartial } from '@/accounts.js'; +import { signout } from '@/signout.js'; export async function mainBoot() { const { isClientUpdated, lastVersion } = await common(() => { @@ -480,11 +482,11 @@ export async function mainBoot() { // 自分の情報が更新されたとき main.on('meUpdated', i => { - updateAccountPartial(i); + updateCurrentAccountPartial(i); }); main.on('readAllNotifications', () => { - updateAccountPartial({ + updateCurrentAccountPartial({ hasUnreadNotification: false, unreadNotificationsCount: 0, }); @@ -492,39 +494,39 @@ export async function mainBoot() { main.on('unreadNotification', () => { const unreadNotificationsCount = ($i?.unreadNotificationsCount ?? 0) + 1; - updateAccountPartial({ + updateCurrentAccountPartial({ hasUnreadNotification: true, unreadNotificationsCount, }); }); main.on('unreadMention', () => { - updateAccountPartial({ hasUnreadMentions: true }); + updateCurrentAccountPartial({ hasUnreadMentions: true }); }); main.on('readAllUnreadMentions', () => { - updateAccountPartial({ hasUnreadMentions: false }); + updateCurrentAccountPartial({ hasUnreadMentions: false }); }); main.on('unreadSpecifiedNote', () => { - updateAccountPartial({ hasUnreadSpecifiedNotes: true }); + updateCurrentAccountPartial({ hasUnreadSpecifiedNotes: true }); }); main.on('readAllUnreadSpecifiedNotes', () => { - updateAccountPartial({ hasUnreadSpecifiedNotes: false }); + updateCurrentAccountPartial({ hasUnreadSpecifiedNotes: false }); }); main.on('readAllAntennas', () => { - updateAccountPartial({ hasUnreadAntenna: false }); + updateCurrentAccountPartial({ hasUnreadAntenna: false }); }); main.on('unreadAntenna', () => { - updateAccountPartial({ hasUnreadAntenna: true }); + updateCurrentAccountPartial({ hasUnreadAntenna: true }); sound.playMisskeySfx('antenna'); }); main.on('readAllAnnouncements', () => { - updateAccountPartial({ hasUnreadAnnouncement: false }); + updateCurrentAccountPartial({ hasUnreadAnnouncement: false }); }); // 個人宛てお知らせが発行されたとき diff --git a/packages/frontend/src/components/MkAnnouncementDialog.vue b/packages/frontend/src/components/MkAnnouncementDialog.vue index 41fd2564d8..582bb137bc 100644 --- a/packages/frontend/src/components/MkAnnouncementDialog.vue +++ b/packages/frontend/src/components/MkAnnouncementDialog.vue @@ -29,7 +29,8 @@ import { misskeyApi } from '@/utility/misskey-api.js'; import MkModal from '@/components/MkModal.vue'; import MkButton from '@/components/MkButton.vue'; import { i18n } from '@/i18n.js'; -import { $i, updateAccountPartial } from '@/account.js'; +import { $i } from '@/i.js'; +import { updateCurrentAccountPartial } from '@/accounts.js'; const props = withDefaults(defineProps<{ announcement: Misskey.entities.Announcement; @@ -51,7 +52,7 @@ async function ok() { modal.value?.close(); misskeyApi('i/read-announcement', { announcementId: props.announcement.id }); - updateAccountPartial({ + updateCurrentAccountPartial({ unreadAnnouncements: $i!.unreadAnnouncements.filter(a => a.id !== props.announcement.id), }); } diff --git a/packages/frontend/src/components/MkAuthConfirm.vue b/packages/frontend/src/components/MkAuthConfirm.vue index 090c31044e..00bf8e68d9 100644 --- a/packages/frontend/src/components/MkAuthConfirm.vue +++ b/packages/frontend/src/components/MkAuthConfirm.vue @@ -117,10 +117,9 @@ SPDX-License-Identifier: AGPL-3.0-only