diff options
| author | dakkar <dakkar@thenautilus.net> | 2025-03-02 18:57:27 +0000 |
|---|---|---|
| committer | dakkar <dakkar@thenautilus.net> | 2025-03-02 18:57:27 +0000 |
| commit | 0b5e197afb5f9c2519c22324706c3b27d5d3eea3 (patch) | |
| tree | c73d3940938e4fd8cc515377f9334c4a48679c7b /packages/frontend/src/scripts | |
| parent | merge: pin corepack version (!885) (diff) | |
| parent | merge: Add/fix moderation logs for many endpoints (resolves #911 and #969) (!... (diff) | |
| download | sharkey-0b5e197afb5f9c2519c22324706c3b27d5d3eea3.tar.gz sharkey-0b5e197afb5f9c2519c22324706c3b27d5d3eea3.tar.bz2 sharkey-0b5e197afb5f9c2519c22324706c3b27d5d3eea3.zip | |
Merge branch 'develop' into release/2025.2.2
Diffstat (limited to 'packages/frontend/src/scripts')
21 files changed, 558 insertions, 102 deletions
diff --git a/packages/frontend/src/scripts/aiscript/api.ts b/packages/frontend/src/scripts/aiscript/api.ts index 46aed49330..e203c51bba 100644 --- a/packages/frontend/src/scripts/aiscript/api.ts +++ b/packages/frontend/src/scripts/aiscript/api.ts @@ -3,14 +3,24 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { utils, values } from '@syuilo/aiscript'; +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 '@/scripts/misskey-api.js'; import { $i } from '@/account.js'; import { miLocalStorage } from '@/local-storage.js'; import { customEmojis } from '@/custom-emojis.js'; -import { url, lang } from '@@/js/config.js'; + +const DIALOG_TYPES = [ + 'error', + 'info', + 'success', + 'warning', + 'waiting', + 'question', +] as const; export function aiScriptReadline(q: string): Promise<string> { return new Promise(ok => { @@ -22,15 +32,20 @@ export function aiScriptReadline(q: string): Promise<string> { }); } -export function createAiScriptEnv(opts) { +export function createAiScriptEnv(opts: { storageKey: string, token?: string }) { return { USER_ID: $i ? values.STR($i.id) : values.NULL, - USER_NAME: $i ? values.STR($i.name) : 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, @@ -39,6 +54,11 @@ export function createAiScriptEnv(opts) { 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, @@ -48,14 +68,20 @@ export function createAiScriptEnv(opts) { }), 'Mk:api': values.FN_NATIVE(async ([ep, param, token]) => { utils.assertString(ep); - if (ep.value.includes('://')) throw new Error('invalid endpoint'); + 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; - return misskeyApi(ep.value, utils.valToJs(param), actualToken).then(res => { + 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)); @@ -75,12 +101,18 @@ export function createAiScriptEnv(opts) { */ '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(JSON.parse(miLocalStorage.getItem(`aiscript:${opts.storageKey}:${key.value}`))); + 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); diff --git a/packages/frontend/src/scripts/aiscript/common.ts b/packages/frontend/src/scripts/aiscript/common.ts new file mode 100644 index 0000000000..de6fa1d633 --- /dev/null +++ b/packages/frontend/src/scripts/aiscript/common.ts @@ -0,0 +1,15 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { errors, utils, type values } from '@syuilo/aiscript'; + +export function assertStringAndIsIn<A extends readonly string[]>(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/scripts/aiscript/ui.ts b/packages/frontend/src/scripts/aiscript/ui.ts index 2b386bebb8..ca92b27ff5 100644 --- a/packages/frontend/src/scripts/aiscript/ui.ts +++ b/packages/frontend/src/scripts/aiscript/ui.ts @@ -7,6 +7,15 @@ import { utils, values } from '@syuilo/aiscript'; import { v4 as uuid } from 'uuid'; import { ref, 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; @@ -21,13 +30,13 @@ export type AsUiRoot = AsUiComponentBase & { export type AsUiContainer = AsUiComponentBase & { type: 'container'; children?: AsUiComponent['id'][]; - align?: 'left' | 'center' | 'right'; + align?: Align; bgColor?: string; fgColor?: string; - font?: 'serif' | 'sans-serif' | 'monospace'; + font?: Font; borderWidth?: number; borderColor?: string; - borderStyle?: 'hidden' | 'dotted' | 'dashed' | 'solid' | 'double' | 'groove' | 'ridge' | 'inset' | 'outset'; + borderStyle?: BorderStyle; borderRadius?: number; padding?: number; rounded?: boolean; @@ -40,7 +49,7 @@ export type AsUiText = AsUiComponentBase & { size?: number; bold?: boolean; color?: string; - font?: 'serif' | 'sans-serif' | 'monospace'; + font?: Font; }; export type AsUiMfm = AsUiComponentBase & { @@ -49,14 +58,14 @@ export type AsUiMfm = AsUiComponentBase & { size?: number; bold?: boolean; color?: string; - font?: 'serif' | 'sans-serif' | 'monospace'; - onClickEv?: (evId: string) => void + font?: Font; + onClickEv?: (evId: string) => Promise<void>; }; export type AsUiButton = AsUiComponentBase & { type: 'button'; text?: string; - onClick?: () => void; + onClick?: () => Promise<void>; primary?: boolean; rounded?: boolean; disabled?: boolean; @@ -69,7 +78,7 @@ export type AsUiButtons = AsUiComponentBase & { export type AsUiSwitch = AsUiComponentBase & { type: 'switch'; - onChange?: (v: boolean) => void; + onChange?: (v: boolean) => Promise<void>; default?: boolean; label?: string; caption?: string; @@ -77,7 +86,7 @@ export type AsUiSwitch = AsUiComponentBase & { export type AsUiTextarea = AsUiComponentBase & { type: 'textarea'; - onInput?: (v: string) => void; + onInput?: (v: string) => Promise<void>; default?: string; label?: string; caption?: string; @@ -85,7 +94,7 @@ export type AsUiTextarea = AsUiComponentBase & { export type AsUiTextInput = AsUiComponentBase & { type: 'textInput'; - onInput?: (v: string) => void; + onInput?: (v: string) => Promise<void>; default?: string; label?: string; caption?: string; @@ -93,7 +102,7 @@ export type AsUiTextInput = AsUiComponentBase & { export type AsUiNumberInput = AsUiComponentBase & { type: 'numberInput'; - onInput?: (v: number) => void; + onInput?: (v: number) => Promise<void>; default?: number; label?: string; caption?: string; @@ -105,7 +114,7 @@ export type AsUiSelect = AsUiComponentBase & { text: string; value: string; }[]; - onChange?: (v: string) => void; + onChange?: (v: string) => Promise<void>; default?: string; label?: string; caption?: string; @@ -140,11 +149,15 @@ export type AsUiPostForm = AsUiComponentBase & { export type AsUiComponent = AsUiRoot | AsUiContainer | AsUiText | AsUiMfm | AsUiButton | AsUiButtons | AsUiSwitch | AsUiTextarea | AsUiTextInput | AsUiNumberInput | AsUiSelect | AsUiFolder | AsUiPostFormButton | AsUiPostForm; +type Options<T extends AsUiComponent> = T extends AsUiButtons + ? Omit<T, 'id' | 'type' | 'buttons'> & { 'buttons'?: Options<AsUiButton>[] } + : Omit<T, 'id' | 'type'>; + export function patch(id: string, def: values.Value, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>) { // TODO } -function getRootOptions(def: values.Value | undefined): Omit<AsUiRoot, 'id' | 'type'> { +function getRootOptions(def: values.Value | undefined): Options<AsUiRoot> { utils.assertObject(def); const children = def.value.get('children'); @@ -153,30 +166,32 @@ function getRootOptions(def: values.Value | undefined): Omit<AsUiRoot, 'id' | 't return { children: children.value.map(v => { utils.assertObject(v); - return v.value.get('id').value; + const id = v.value.get('id'); + utils.assertString(id); + return id.value; }), }; } -function getContainerOptions(def: values.Value | undefined): Omit<AsUiContainer, 'id' | 'type'> { +function getContainerOptions(def: values.Value | undefined): Options<AsUiContainer> { utils.assertObject(def); const children = def.value.get('children'); if (children) utils.assertArray(children); const align = def.value.get('align'); - if (align) utils.assertString(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) utils.assertString(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) utils.assertString(borderStyle); + if (borderStyle) assertStringAndIsIn(borderStyle, BORDER_STYLES); const borderRadius = def.value.get('borderRadius'); if (borderRadius) utils.assertNumber(borderRadius); const padding = def.value.get('padding'); @@ -189,7 +204,9 @@ function getContainerOptions(def: values.Value | undefined): Omit<AsUiContainer, return { children: children ? children.value.map(v => { utils.assertObject(v); - return v.value.get('id').value; + const id = v.value.get('id'); + utils.assertString(id); + return id.value; }) : [], align: align?.value, fgColor: fgColor?.value, @@ -205,7 +222,7 @@ function getContainerOptions(def: values.Value | undefined): Omit<AsUiContainer, }; } -function getTextOptions(def: values.Value | undefined): Omit<AsUiText, 'id' | 'type'> { +function getTextOptions(def: values.Value | undefined): Options<AsUiText> { utils.assertObject(def); const text = def.value.get('text'); @@ -217,7 +234,7 @@ function getTextOptions(def: values.Value | undefined): Omit<AsUiText, 'id' | 't const color = def.value.get('color'); if (color) utils.assertString(color); const font = def.value.get('font'); - if (font) utils.assertString(font); + if (font) assertStringAndIsIn(font, FONTS); return { text: text?.value, @@ -228,7 +245,7 @@ function getTextOptions(def: values.Value | undefined): Omit<AsUiText, 'id' | 't }; } -function getMfmOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Omit<AsUiMfm, 'id' | 'type'> { +function getMfmOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Options<AsUiMfm> { utils.assertObject(def); const text = def.value.get('text'); @@ -240,7 +257,7 @@ function getMfmOptions(def: values.Value | undefined, call: (fn: values.VFn, arg const color = def.value.get('color'); if (color) utils.assertString(color); const font = def.value.get('font'); - if (font) utils.assertString(font); + if (font) assertStringAndIsIn(font, FONTS); const onClickEv = def.value.get('onClickEv'); if (onClickEv) utils.assertFunction(onClickEv); @@ -250,13 +267,13 @@ function getMfmOptions(def: values.Value | undefined, call: (fn: values.VFn, arg bold: bold?.value, color: color?.value, font: font?.value, - onClickEv: (evId: string) => { - if (onClickEv) call(onClickEv, [values.STR(evId)]); + 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<values.Value>): Omit<AsUiTextInput, 'id' | 'type'> { +function getTextInputOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Options<AsUiTextInput> { utils.assertObject(def); const onInput = def.value.get('onInput'); @@ -269,8 +286,8 @@ function getTextInputOptions(def: values.Value | undefined, call: (fn: values.VF if (caption) utils.assertString(caption); return { - onInput: (v) => { - if (onInput) call(onInput, [utils.jsToVal(v)]); + onInput: async (v) => { + if (onInput) await call(onInput, [utils.jsToVal(v)]); }, default: defaultValue?.value, label: label?.value, @@ -278,7 +295,7 @@ function getTextInputOptions(def: values.Value | undefined, call: (fn: values.VF }; } -function getTextareaOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Omit<AsUiTextarea, 'id' | 'type'> { +function getTextareaOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Options<AsUiTextarea> { utils.assertObject(def); const onInput = def.value.get('onInput'); @@ -291,8 +308,8 @@ function getTextareaOptions(def: values.Value | undefined, call: (fn: values.VFn if (caption) utils.assertString(caption); return { - onInput: (v) => { - if (onInput) call(onInput, [utils.jsToVal(v)]); + onInput: async (v) => { + if (onInput) await call(onInput, [utils.jsToVal(v)]); }, default: defaultValue?.value, label: label?.value, @@ -300,7 +317,7 @@ function getTextareaOptions(def: values.Value | undefined, call: (fn: values.VFn }; } -function getNumberInputOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Omit<AsUiNumberInput, 'id' | 'type'> { +function getNumberInputOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Options<AsUiNumberInput> { utils.assertObject(def); const onInput = def.value.get('onInput'); @@ -313,8 +330,8 @@ function getNumberInputOptions(def: values.Value | undefined, call: (fn: values. if (caption) utils.assertString(caption); return { - onInput: (v) => { - if (onInput) call(onInput, [utils.jsToVal(v)]); + onInput: async (v) => { + if (onInput) await call(onInput, [utils.jsToVal(v)]); }, default: defaultValue?.value, label: label?.value, @@ -322,7 +339,7 @@ function getNumberInputOptions(def: values.Value | undefined, call: (fn: values. }; } -function getButtonOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Omit<AsUiButton, 'id' | 'type'> { +function getButtonOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Options<AsUiButton> { utils.assertObject(def); const text = def.value.get('text'); @@ -338,8 +355,8 @@ function getButtonOptions(def: values.Value | undefined, call: (fn: values.VFn, return { text: text?.value, - onClick: () => { - if (onClick) call(onClick, []); + onClick: async () => { + if (onClick) await call(onClick, []); }, primary: primary?.value, rounded: rounded?.value, @@ -347,7 +364,7 @@ function getButtonOptions(def: values.Value | undefined, call: (fn: values.VFn, }; } -function getButtonsOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Omit<AsUiButtons, 'id' | 'type'> { +function getButtonsOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Options<AsUiButtons> { utils.assertObject(def); const buttons = def.value.get('buttons'); @@ -369,8 +386,8 @@ function getButtonsOptions(def: values.Value | undefined, call: (fn: values.VFn, return { text: text.value, - onClick: () => { - call(onClick, []); + onClick: async () => { + await call(onClick, []); }, primary: primary?.value, rounded: rounded?.value, @@ -380,7 +397,7 @@ function getButtonsOptions(def: values.Value | undefined, call: (fn: values.VFn, }; } -function getSwitchOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Omit<AsUiSwitch, 'id' | 'type'> { +function getSwitchOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Options<AsUiSwitch> { utils.assertObject(def); const onChange = def.value.get('onChange'); @@ -393,8 +410,8 @@ function getSwitchOptions(def: values.Value | undefined, call: (fn: values.VFn, if (caption) utils.assertString(caption); return { - onChange: (v) => { - if (onChange) call(onChange, [utils.jsToVal(v)]); + onChange: async (v) => { + if (onChange) await call(onChange, [utils.jsToVal(v)]); }, default: defaultValue?.value, label: label?.value, @@ -402,7 +419,7 @@ function getSwitchOptions(def: values.Value | undefined, call: (fn: values.VFn, }; } -function getSelectOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Omit<AsUiSelect, 'id' | 'type'> { +function getSelectOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Options<AsUiSelect> { utils.assertObject(def); const items = def.value.get('items'); @@ -428,8 +445,8 @@ function getSelectOptions(def: values.Value | undefined, call: (fn: values.VFn, value: value ? value.value : text.value, }; }) : [], - onChange: (v) => { - if (onChange) call(onChange, [utils.jsToVal(v)]); + onChange: async (v) => { + if (onChange) await call(onChange, [utils.jsToVal(v)]); }, default: defaultValue?.value, label: label?.value, @@ -437,7 +454,7 @@ function getSelectOptions(def: values.Value | undefined, call: (fn: values.VFn, }; } -function getFolderOptions(def: values.Value | undefined): Omit<AsUiFolder, 'id' | 'type'> { +function getFolderOptions(def: values.Value | undefined): Options<AsUiFolder> { utils.assertObject(def); const children = def.value.get('children'); @@ -450,7 +467,9 @@ function getFolderOptions(def: values.Value | undefined): Omit<AsUiFolder, 'id' return { children: children ? children.value.map(v => { utils.assertObject(v); - return v.value.get('id').value; + const id = v.value.get('id'); + utils.assertString(id); + return id.value; }) : [], title: title?.value ?? '', opened: opened?.value ?? true, @@ -475,7 +494,7 @@ function getPostFormProps(form: values.VObj): PostFormPropsForAsUi { }; } -function getPostFormButtonOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Omit<AsUiPostFormButton, 'id' | 'type'> { +function getPostFormButtonOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Options<AsUiPostFormButton> { utils.assertObject(def); const text = def.value.get('text'); @@ -497,7 +516,7 @@ function getPostFormButtonOptions(def: values.Value | undefined, call: (fn: valu }; } -function getPostFormOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Omit<AsUiPostForm, 'id' | 'type'> { +function getPostFormOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Options<AsUiPostForm> { utils.assertObject(def); const form = def.value.get('form'); @@ -511,18 +530,26 @@ function getPostFormOptions(def: values.Value | undefined, call: (fn: values.VFn } export function registerAsUiLib(components: Ref<AsUiComponent>[], done: (root: Ref<AsUiRoot>) => void) { + type OptionsConverter<T extends AsUiComponent, C> = (def: values.Value | undefined, call: C) => Options<T>; + const instances = {}; - function createComponentInstance(type: AsUiComponent['type'], def: values.Value | undefined, id: values.Value | undefined, getOptions: (def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>) => any, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>) { + function createComponentInstance<T extends AsUiComponent, C>( + type: T['type'], + def: values.Value | undefined, + id: values.Value | undefined, + getOptions: OptionsConverter<T, C>, + 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([ + const instance = values.OBJ(new Map<string, values.Value>([ ['id', values.STR(_id)], ['update', values.FN_NATIVE(([def], opts) => { utils.assertObject(def); @@ -547,7 +574,7 @@ export function registerAsUiLib(components: Ref<AsUiComponent>[], done: (root: R 'Ui:patch': values.FN_NATIVE(([id, val], opts) => { utils.assertString(id); utils.assertArray(val); - patch(id.value, val.value, opts.call); + // patch(id.value, val.value, opts.call); // TODO }), 'Ui:get': values.FN_NATIVE(([id], opts) => { @@ -566,7 +593,9 @@ export function registerAsUiLib(components: Ref<AsUiComponent>[], done: (root: R rootComponent.value.children = children.value.map(v => { utils.assertObject(v); - return v.value.get('id').value; + const id = v.value.get('id'); + utils.assertString(id); + return id.value; }); }), diff --git a/packages/frontend/src/scripts/autocomplete.ts b/packages/frontend/src/scripts/autocomplete.ts index d942402ffc..8a3a6bf6db 100644 --- a/packages/frontend/src/scripts/autocomplete.ts +++ b/packages/frontend/src/scripts/autocomplete.ts @@ -5,7 +5,7 @@ import { nextTick, Ref, ref, defineAsyncComponent } from 'vue'; import getCaretCoordinates from 'textarea-caret'; -import { toASCII } from 'punycode/'; +import { toASCII } from 'punycode.js'; import { popup } from '@/os.js'; export type SuggestionType = 'user' | 'hashtag' | 'emoji' | 'mfmTag' | 'mfmParam'; diff --git a/packages/frontend/src/scripts/boost-quote.ts b/packages/frontend/src/scripts/boost-quote.ts index 4e025f5d4f..feb949772b 100644 --- a/packages/frontend/src/scripts/boost-quote.ts +++ b/packages/frontend/src/scripts/boost-quote.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { ref, Ref } from 'vue'; +import { ref, Ref, computed, ComputedRef } from 'vue'; import * as Misskey from 'misskey-js'; import { i18n } from '@/i18n.js'; import { defaultStore } from '@/store.js'; @@ -79,3 +79,11 @@ export function boostMenuItems(appearNote: Ref<Misskey.entities.Note>, renote: ( } as MenuItem, ]; } + +export function computeRenoteTooltip(renoted: Ref<boolean>): ComputedRef<string> { + return computed(() => { + if (renoted.value) return i18n.ts.unrenote; + if (defaultStore.state.showVisibilitySelectorOnBoost) return i18n.ts.renote; + return i18n.ts.renoteShift; + }); +} diff --git a/packages/frontend/src/scripts/check-word-mute.ts b/packages/frontend/src/scripts/check-word-mute.ts index 6525c207f7..194ef0f420 100644 --- a/packages/frontend/src/scripts/check-word-mute.ts +++ b/packages/frontend/src/scripts/check-word-mute.ts @@ -4,7 +4,7 @@ */ import * as Misskey from 'misskey-js'; -export function checkWordMute(note: Misskey.entities.Note, me: Misskey.entities.UserLite | null | undefined, mutedWords: Array<string | string[]>): boolean { +export function checkWordMute(note: Misskey.entities.Note, me: Misskey.entities.UserLite | null | undefined, mutedWords: Array<string | string[]>): Array<string | string[]> | false { // 自分自身 if (me && (note.userId === me.id)) return false; @@ -13,7 +13,7 @@ export function checkWordMute(note: Misskey.entities.Note, me: Misskey.entities. if (text === '') return false; - const matched = mutedWords.some(filter => { + const matched = mutedWords.filter(filter => { if (Array.isArray(filter)) { // Clean up const filteredFilter = filter.filter(keyword => keyword !== ''); @@ -36,7 +36,7 @@ export function checkWordMute(note: Misskey.entities.Note, me: Misskey.entities. } }); - if (matched) return true; + if (matched.length > 0) return matched; } return false; diff --git a/packages/frontend/src/scripts/code-highlighter.ts b/packages/frontend/src/scripts/code-highlighter.ts index 6710d9826e..4d57dcd944 100644 --- a/packages/frontend/src/scripts/code-highlighter.ts +++ b/packages/frontend/src/scripts/code-highlighter.ts @@ -3,7 +3,8 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { createHighlighterCore, loadWasm } from 'shiki/core'; +import { createHighlighterCore } from 'shiki/core'; +import { createOnigurumaEngine } from 'shiki/engine/oniguruma'; import darkPlus from 'shiki/themes/dark-plus.mjs'; import { bundledThemesInfo } from 'shiki/themes'; import { bundledLanguagesInfo } from 'shiki/langs'; @@ -60,8 +61,6 @@ export async function getHighlighter(): Promise<HighlighterCore> { } async function initHighlighter() { - await loadWasm(import('shiki/onig.wasm?init')); - // テーマの重複を消す const themes = unique([ darkPlus, @@ -70,6 +69,7 @@ async function initHighlighter() { const jsLangInfo = bundledLanguagesInfo.find(t => t.id === 'javascript'); const highlighter = await createHighlighterCore({ + engine: createOnigurumaEngine(() => import('shiki/onig.wasm?init')), themes, langs: [ ...(jsLangInfo ? [async () => await jsLangInfo.import()] : []), diff --git a/packages/frontend/src/scripts/file-drop.ts b/packages/frontend/src/scripts/file-drop.ts new file mode 100644 index 0000000000..c2e863c0dc --- /dev/null +++ b/packages/frontend/src/scripts/file-drop.ts @@ -0,0 +1,121 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export type DroppedItem = DroppedFile | DroppedDirectory; + +export type DroppedFile = { + isFile: true; + path: string; + file: File; +}; + +export type DroppedDirectory = { + isFile: false; + path: string; + children: DroppedItem[]; +} + +export async function extractDroppedItems(ev: DragEvent): Promise<DroppedItem[]> { + const dropItems = ev.dataTransfer?.items; + if (!dropItems || dropItems.length === 0) { + return []; + } + + const apiTestItem = dropItems[0]; + if ('webkitGetAsEntry' in apiTestItem) { + return readDataTransferItems(dropItems); + } else { + // webkitGetAsEntryに対応していない場合はfilesから取得する(ディレクトリのサポートは出来ない) + const dropFiles = ev.dataTransfer.files; + if (dropFiles.length === 0) { + return []; + } + + const droppedFiles = Array.of<DroppedFile>(); + for (let i = 0; i < dropFiles.length; i++) { + const file = dropFiles.item(i); + if (file) { + droppedFiles.push({ + isFile: true, + path: file.name, + file, + }); + } + } + + return droppedFiles; + } +} + +/** + * ドラッグ&ドロップされたファイルのリストからディレクトリ構造とファイルへの参照({@link File})を取得する。 + */ +export async function readDataTransferItems(itemList: DataTransferItemList): Promise<DroppedItem[]> { + async function readEntry(entry: FileSystemEntry): Promise<DroppedItem> { + if (entry.isFile) { + return { + isFile: true, + path: entry.fullPath, + file: await readFile(entry as FileSystemFileEntry), + }; + } else { + return { + isFile: false, + path: entry.fullPath, + children: await readDirectory(entry as FileSystemDirectoryEntry), + }; + } + } + + function readFile(fileSystemFileEntry: FileSystemFileEntry): Promise<File> { + return new Promise((resolve, reject) => { + fileSystemFileEntry.file(resolve, reject); + }); + } + + function readDirectory(fileSystemDirectoryEntry: FileSystemDirectoryEntry): Promise<DroppedItem[]> { + return new Promise(async (resolve) => { + const allEntries = Array.of<FileSystemEntry>(); + const reader = fileSystemDirectoryEntry.createReader(); + while (true) { + const entries = await new Promise<FileSystemEntry[]>((res, rej) => reader.readEntries(res, rej)); + if (entries.length === 0) { + break; + } + allEntries.push(...entries); + } + + resolve(await Promise.all(allEntries.map(readEntry))); + }); + } + + // 扱いにくいので配列に変換 + const items = Array.of<DataTransferItem>(); + for (let i = 0; i < itemList.length; i++) { + items.push(itemList[i]); + } + + return Promise.all( + items + .map(it => it.webkitGetAsEntry()) + .filter(it => it) + .map(it => readEntry(it!)), + ); +} + +/** + * {@link DroppedItem}のリストからディレクトリを再帰的に検索し、ファイルのリストを取得する。 + */ +export function flattenDroppedFiles(items: DroppedItem[]): DroppedFile[] { + const result = Array.of<DroppedFile>(); + for (const item of items) { + if (item.isFile) { + result.push(item); + } else { + result.push(...flattenDroppedFiles(item.children)); + } + } + return result; +} diff --git a/packages/frontend/src/scripts/get-note-menu.ts b/packages/frontend/src/scripts/get-note-menu.ts index c56fd185b6..9112daf49f 100644 --- a/packages/frontend/src/scripts/get-note-menu.ts +++ b/packages/frontend/src/scripts/get-note-menu.ts @@ -5,19 +5,19 @@ import { defineAsyncComponent, Ref, ShallowRef } from 'vue'; import * as Misskey from 'misskey-js'; +import { url } from '@@/js/config.js'; import { claimAchievement } from './achievements.js'; +import type { MenuItem } from '@/types/menu.js'; import { $i } from '@/account.js'; import { i18n } from '@/i18n.js'; import { instance } from '@/instance.js'; import * as os from '@/os.js'; import { misskeyApi } from '@/scripts/misskey-api.js'; import { copyToClipboard } from '@/scripts/copy-to-clipboard.js'; -import { url } from '@@/js/config.js'; import { defaultStore, noteActions } from '@/store.js'; import { miLocalStorage } from '@/local-storage.js'; import { getUserMenu } from '@/scripts/get-user-menu.js'; import { clipsCache, favoritedChannelsCache } from '@/cache.js'; -import type { MenuItem } from '@/types/menu.js'; import MkRippleEffect from '@/components/MkRippleEffect.vue'; import { isSupportShare } from '@/scripts/navigator.js'; import { getAppearNote } from '@/scripts/get-appear-note.js'; @@ -205,7 +205,7 @@ export function getNoteMenu(props: { noteId: appearNote.id, }); - if (Date.now() - new Date(appearNote.createdAt).getTime() < 1000 * 60) { + if (Date.now() - new Date(appearNote.createdAt).getTime() < 1000 * 60 && appearNote.userId === $i.id) { claimAchievement('noteDeletedWithin1min'); } }); @@ -224,7 +224,7 @@ export function getNoteMenu(props: { os.post({ initialNote: appearNote, renote: appearNote.renote, reply: appearNote.reply, channel: appearNote.channel }); - if (Date.now() - new Date(appearNote.createdAt).getTime() < 1000 * 60) { + if (Date.now() - new Date(appearNote.createdAt).getTime() < 1000 * 60 && appearNote.userId === $i.id) { claimAchievement('noteDeletedWithin1min'); } }); @@ -259,11 +259,6 @@ export function getNoteMenu(props: { os.success(); } - function copyLink(): void { - copyToClipboard(`${url}/notes/${appearNote.id}`); - os.success(); - } - function togglePin(pin: boolean): void { os.apiWithDialog(pin ? 'i/pin' : 'i/unpin', { noteId: appearNote.id, @@ -347,6 +342,13 @@ export function getNoteMenu(props: { getCopyNoteOriginLinkMenu(appearNote, 'Copy link (Origin)') ); menuItems.push({ + icon: 'ti ti-link', + text: i18n.ts.copyRemoteLink, + action: () => { + copyToClipboard(appearNote.url ?? appearNote.uri); + os.success(); + }, + }, { icon: 'ti ti-external-link', text: i18n.ts.showOnRemote, action: () => { @@ -508,6 +510,13 @@ export function getNoteMenu(props: { getCopyNoteOriginLinkMenu(appearNote, 'Copy link (Origin)') ); menuItems.push({ + icon: 'ti ti-link', + text: i18n.ts.copyRemoteLink, + action: () => { + copyToClipboard(appearNote.url ?? appearNote.uri); + os.success(); + }, + }, { icon: 'ti ti-external-link', text: i18n.ts.showOnRemote, action: () => { diff --git a/packages/frontend/src/scripts/get-note-summary.ts b/packages/frontend/src/scripts/get-note-summary.ts index 58d486bf9b..4e093bcf4c 100644 --- a/packages/frontend/src/scripts/get-note-summary.ts +++ b/packages/frontend/src/scripts/get-note-summary.ts @@ -4,6 +4,7 @@ */ import * as Misskey from 'misskey-js'; +import { appendContentWarning } from '@@/js/append-content-warning.js'; import { i18n } from '@/i18n.js'; /** @@ -25,9 +26,15 @@ export const getNoteSummary = (note?: Misskey.entities.Note | null): string => { let summary = ''; + // Append mandatory CW, if applicable + let cw = note.cw; + if (note.user.mandatoryCW) { + cw = appendContentWarning(cw, note.user.mandatoryCW); + } + // 本文 - if (note.cw != null) { - summary += `CW: ${note.cw}`; + if (cw != null) { + summary += `CW: ${cw}`; } else if (note.text) { summary += note.text; } diff --git a/packages/frontend/src/scripts/get-user-menu.ts b/packages/frontend/src/scripts/get-user-menu.ts index 090cffe203..2fbdaf5d3c 100644 --- a/packages/frontend/src/scripts/get-user-menu.ts +++ b/packages/frontend/src/scripts/get-user-menu.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { toUnicode } from 'punycode'; +import { toUnicode } from 'punycode.js'; import { defineAsyncComponent, ref, watch } from 'vue'; import * as Misskey from 'misskey-js'; import { i18n } from '@/i18n.js'; diff --git a/packages/frontend/src/scripts/key-event.ts b/packages/frontend/src/scripts/key-event.ts new file mode 100644 index 0000000000..a72776d48c --- /dev/null +++ b/packages/frontend/src/scripts/key-event.ts @@ -0,0 +1,153 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** + * {@link KeyboardEvent.code} の値を表す文字列。不足分は適宜追加する + * @see https://developer.mozilla.org/en-US/docs/Web/API/UI_Events/Keyboard_event_code_values + */ +export type KeyCode = + | 'Backspace' + | 'Tab' + | 'Enter' + | 'Shift' + | 'Control' + | 'Alt' + | 'Pause' + | 'CapsLock' + | 'Escape' + | 'Space' + | 'PageUp' + | 'PageDown' + | 'End' + | 'Home' + | 'ArrowLeft' + | 'ArrowUp' + | 'ArrowRight' + | 'ArrowDown' + | 'Insert' + | 'Delete' + | 'Digit0' + | 'Digit1' + | 'Digit2' + | 'Digit3' + | 'Digit4' + | 'Digit5' + | 'Digit6' + | 'Digit7' + | 'Digit8' + | 'Digit9' + | 'KeyA' + | 'KeyB' + | 'KeyC' + | 'KeyD' + | 'KeyE' + | 'KeyF' + | 'KeyG' + | 'KeyH' + | 'KeyI' + | 'KeyJ' + | 'KeyK' + | 'KeyL' + | 'KeyM' + | 'KeyN' + | 'KeyO' + | 'KeyP' + | 'KeyQ' + | 'KeyR' + | 'KeyS' + | 'KeyT' + | 'KeyU' + | 'KeyV' + | 'KeyW' + | 'KeyX' + | 'KeyY' + | 'KeyZ' + | 'MetaLeft' + | 'MetaRight' + | 'ContextMenu' + | 'F1' + | 'F2' + | 'F3' + | 'F4' + | 'F5' + | 'F6' + | 'F7' + | 'F8' + | 'F9' + | 'F10' + | 'F11' + | 'F12' + | 'NumLock' + | 'ScrollLock' + | 'Semicolon' + | 'Equal' + | 'Comma' + | 'Minus' + | 'Period' + | 'Slash' + | 'Backquote' + | 'BracketLeft' + | 'Backslash' + | 'BracketRight' + | 'Quote' + | 'Meta' + | 'AltGraph' + ; + +/** + * 修飾キーを表す文字列。不足分は適宜追加する。 + */ +export type KeyModifier = + | 'Shift' + | 'Control' + | 'Alt' + | 'Meta' + ; + +/** + * 押下されたキー以外の状態を表す文字列。不足分は適宜追加する。 + */ +export type KeyState = + | 'composing' + | 'repeat' + ; + +export type KeyEventHandler = { + modifiers?: KeyModifier[]; + states?: KeyState[]; + code: KeyCode | 'any'; + handler: (event: KeyboardEvent) => void; +} + +export function handleKeyEvent(event: KeyboardEvent, handlers: KeyEventHandler[]) { + function checkModifier(ev: KeyboardEvent, modifiers? : KeyModifier[]) { + if (modifiers) { + return modifiers.every(modifier => ev.getModifierState(modifier)); + } + return true; + } + + function checkState(ev: KeyboardEvent, states?: KeyState[]) { + if (states) { + return states.every(state => ev.getModifierState(state)); + } + return true; + } + + let hit = false; + for (const handler of handlers.filter(it => it.code === event.code)) { + if (checkModifier(event, handler.modifiers) && checkState(event, handler.states)) { + handler.handler(event); + hit = true; + break; + } + } + + if (!hit) { + for (const handler of handlers.filter(it => it.code === 'any')) { + handler.handler(event); + } + } +} diff --git a/packages/frontend/src/scripts/lookup.ts b/packages/frontend/src/scripts/lookup.ts index e20b23f166..54ec2ce39b 100644 --- a/packages/frontend/src/scripts/lookup.ts +++ b/packages/frontend/src/scripts/lookup.ts @@ -33,7 +33,43 @@ export async function lookup(router?: Router) { uri: query, }); - os.promiseDialog(promise, null, null, i18n.ts.fetchingAsApObject); + os.promiseDialog(promise, null, (err) => { + let title = i18n.ts.somethingHappened; + let text = err.message + '\n' + err.id; + + switch (err.id) { + case '974b799e-1a29-4889-b706-18d4dd93e266': + title = i18n.ts._remoteLookupErrors._federationNotAllowed.title; + text = i18n.ts._remoteLookupErrors._federationNotAllowed.description; + break; + case '1a5eab56-e47b-48c2-8d5e-217b897d70db': + title = i18n.ts._remoteLookupErrors._uriInvalid.title; + text = i18n.ts._remoteLookupErrors._uriInvalid.description; + break; + case '81b539cf-4f57-4b29-bc98-032c33c0792e': + title = i18n.ts._remoteLookupErrors._requestFailed.title; + text = i18n.ts._remoteLookupErrors._requestFailed.description; + break; + case '70193c39-54f3-4813-82f0-70a680f7495b': + title = i18n.ts._remoteLookupErrors._responseInvalid.title; + text = i18n.ts._remoteLookupErrors._responseInvalid.description; + break; + case 'a2c9c61a-cb72-43ab-a964-3ca5fddb410a': + title = i18n.ts._remoteLookupErrors._responseInvalid.title; + text = i18n.ts._remoteLookupErrors._responseInvalidIdHostNotMatch.description; + break; + case 'dc94d745-1262-4e63-a17d-fecaa57efc82': + title = i18n.ts._remoteLookupErrors._noSuchObject.title; + text = i18n.ts._remoteLookupErrors._noSuchObject.description; + break; + } + + os.alert({ + type: 'error', + title, + text, + }); + }, i18n.ts.fetchingAsApObject); const res = await promise; diff --git a/packages/frontend/src/scripts/merge.ts b/packages/frontend/src/scripts/merge.ts index 89fdda0cbb..004b6d42a4 100644 --- a/packages/frontend/src/scripts/merge.ts +++ b/packages/frontend/src/scripts/merge.ts @@ -7,10 +7,10 @@ import { deepClone } from './clone.js'; import type { Cloneable } from './clone.js'; export type DeepPartial<T> = { - [P in keyof T]?: T[P] extends Record<string | number | symbol, unknown> ? DeepPartial<T[P]> : T[P]; + [P in keyof T]?: T[P] extends Record<PropertyKey, unknown> ? DeepPartial<T[P]> : T[P]; }; -function isPureObject(value: unknown): value is Record<string | number | symbol, unknown> { +function isPureObject(value: unknown): value is Record<PropertyKey, unknown> { return typeof value === 'object' && value !== null && !Array.isArray(value); } @@ -18,14 +18,14 @@ function isPureObject(value: unknown): value is Record<string | number | symbol, * valueにないキーをdefからもらう(再帰的)\ * nullはそのまま、undefinedはdefの値 **/ -export function deepMerge<X extends object>(value: DeepPartial<X>, def: X): X { +export function deepMerge<X extends Record<PropertyKey, unknown>>(value: DeepPartial<X>, def: X): X { if (isPureObject(value) && isPureObject(def)) { const result = deepClone(value as Cloneable) as X; for (const [k, v] of Object.entries(def) as [keyof X, X[keyof X]][]) { if (!Object.prototype.hasOwnProperty.call(value, k) || value[k] === undefined) { result[k] = v; } else if (isPureObject(v) && isPureObject(result[k])) { - const child = deepClone(result[k] as Cloneable) as DeepPartial<X[keyof X] & Record<string | number | symbol, unknown>>; + const child = deepClone(result[k] as Cloneable) as DeepPartial<X[keyof X] & Record<PropertyKey, unknown>>; result[k] = deepMerge<typeof v>(child, v); } } diff --git a/packages/frontend/src/scripts/misskey-api.ts b/packages/frontend/src/scripts/misskey-api.ts index e7a92e2d5c..dc07ad477b 100644 --- a/packages/frontend/src/scripts/misskey-api.ts +++ b/packages/frontend/src/scripts/misskey-api.ts @@ -9,12 +9,24 @@ import { apiUrl } from '@@/js/config.js'; import { $i } from '@/account.js'; export const pendingApiRequestsCount = ref(0); +export type Endpoint = keyof Misskey.Endpoints; + +export type Request<E extends Endpoint> = Misskey.Endpoints[E]['req']; + +export type AnyRequest<E extends Endpoint | (string & unknown)> = + (E extends Endpoint ? Request<E> : never) | object; + +export type Response<E extends Endpoint | (string & unknown), P extends AnyRequest<E>> = + E extends Endpoint + ? P extends Request<E> ? Misskey.api.SwitchCaseResponseType<E, P> : never + : object; + // Implements Misskey.api.ApiClient.request export function misskeyApi< ResT = void, - E extends keyof Misskey.Endpoints = keyof Misskey.Endpoints, - P extends Misskey.Endpoints[E]['req'] = Misskey.Endpoints[E]['req'], - _ResT = ResT extends void ? Misskey.api.SwitchCaseResponseType<E, P> : ResT, + E extends Endpoint | NonNullable<string> = Endpoint, + P extends AnyRequest<E> = E extends Endpoint ? Request<E> : never, + _ResT = ResT extends void ? Response<E, P> : ResT, >( endpoint: E, data: P & { i?: string | null; } = {} as any, diff --git a/packages/frontend/src/scripts/please-login.ts b/packages/frontend/src/scripts/please-login.ts index 43dcf11936..a8a330eb6d 100644 --- a/packages/frontend/src/scripts/please-login.ts +++ b/packages/frontend/src/scripts/please-login.ts @@ -5,6 +5,7 @@ import { defineAsyncComponent } from 'vue'; import { $i } from '@/account.js'; +import { instance } from '@/instance.js'; import { i18n } from '@/i18n.js'; import { popup } from '@/os.js'; @@ -51,10 +52,17 @@ export function pleaseLogin(opts: { } = {}) { if ($i) return; + let _openOnRemote: OpenOnRemoteOptions | undefined = undefined; + + // 連合できる場合と、(連合ができなくても)共有する場合は外部連携オプションを設定 + if (opts.openOnRemote != null && (instance.federation !== 'none' || opts.openOnRemote.type === 'share')) { + _openOnRemote = opts.openOnRemote; + } + const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkSigninDialog.vue')), { autoSet: true, - message: opts.message ?? (opts.openOnRemote ? i18n.ts.signinOrContinueOnRemote : i18n.ts.signinRequired), - openOnRemote: opts.openOnRemote, + message: opts.message ?? (_openOnRemote ? i18n.ts.signinOrContinueOnRemote : i18n.ts.signinRequired), + openOnRemote: _openOnRemote, }, { cancelled: () => { if (opts.path) { diff --git a/packages/frontend/src/scripts/sanitize-html.ts b/packages/frontend/src/scripts/sanitize-html.ts index 6e1a46c746..fc9db9bbdb 100644 --- a/packages/frontend/src/scripts/sanitize-html.ts +++ b/packages/frontend/src/scripts/sanitize-html.ts @@ -13,6 +13,7 @@ export default function sanitizeHtml(str: string | null): string | null { ...original.defaults.allowedAttributes, a: original.defaults.allowedAttributes.a.concat(['style']), img: original.defaults.allowedAttributes.img.concat(['style']), + '*': (original.defaults.allowedAttributes['*'] || []).concat(['style']), }, }); } diff --git a/packages/frontend/src/scripts/select-file.ts b/packages/frontend/src/scripts/select-file.ts index b037aa8acc..c25b4d73bd 100644 --- a/packages/frontend/src/scripts/select-file.ts +++ b/packages/frontend/src/scripts/select-file.ts @@ -12,14 +12,28 @@ import { i18n } from '@/i18n.js'; import { defaultStore } from '@/store.js'; import { uploadFile } from '@/scripts/upload.js'; -export function chooseFileFromPc(multiple: boolean, keepOriginal = false): Promise<Misskey.entities.DriveFile[]> { +export function chooseFileFromPc( + multiple: boolean, + options?: { + uploadFolder?: string | null; + keepOriginal?: boolean; + nameConverter?: (file: File) => string | undefined; + }, +): Promise<Misskey.entities.DriveFile[]> { + const uploadFolder = options?.uploadFolder ?? defaultStore.state.uploadFolder; + const keepOriginal = options?.keepOriginal ?? defaultStore.state.keepOriginalUploading; + const nameConverter = options?.nameConverter ?? (() => undefined); + return new Promise((res, rej) => { const input = document.createElement('input'); input.type = 'file'; input.multiple = multiple; input.onchange = () => { if (!input.files) return res([]); - const promises = Array.from(input.files, file => uploadFile(file, defaultStore.state.uploadFolder, undefined, keepOriginal)); + const promises = Array.from( + input.files, + file => uploadFile(file, uploadFolder, nameConverter(file), keepOriginal), + ); Promise.all(promises).then(driveFiles => { res(driveFiles); @@ -94,7 +108,7 @@ function select(src: HTMLElement | EventTarget | null, label: string | null, mul }, { text: i18n.ts.upload, icon: 'ti ti-upload', - action: () => chooseFileFromPc(multiple, keepOriginal.value).then(files => res(files)), + action: () => chooseFileFromPc(multiple, { keepOriginal: keepOriginal.value }).then(files => res(files)), }, { text: i18n.ts.fromDrive, icon: 'ti ti-cloud', diff --git a/packages/frontend/src/scripts/sound.ts b/packages/frontend/src/scripts/sound.ts index 05f82fce7d..2008afe045 100644 --- a/packages/frontend/src/scripts/sound.ts +++ b/packages/frontend/src/scripts/sound.ts @@ -93,6 +93,10 @@ export async function loadAudio(url: string, options?: { useCache?: boolean; }) // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (ctx == null) { ctx = new AudioContext(); + + window.addEventListener('beforeunload', () => { + ctx.close(); + }); } if (options?.useCache ?? true) { if (cache.has(url)) { diff --git a/packages/frontend/src/scripts/use-note-capture.ts b/packages/frontend/src/scripts/use-note-capture.ts index 89aa023f23..d15d9043c2 100644 --- a/packages/frontend/src/scripts/use-note-capture.ts +++ b/packages/frontend/src/scripts/use-note-capture.ts @@ -7,7 +7,6 @@ import { onUnmounted, Ref, ShallowRef } from 'vue'; import * as Misskey from 'misskey-js'; import { useStream } from '@/stream.js'; import { $i } from '@/account.js'; -import * as os from '@/os.js'; import { misskeyApi } from './misskey-api.js'; export function useNoteCapture(props: { diff --git a/packages/frontend/src/scripts/warning-external-website.ts b/packages/frontend/src/scripts/warning-external-website.ts index 67158c6438..0c9b5ba806 100644 --- a/packages/frontend/src/scripts/warning-external-website.ts +++ b/packages/frontend/src/scripts/warning-external-website.ts @@ -8,13 +8,21 @@ import { defaultStore } from '@/store.js'; import * as os from '@/os.js'; import MkUrlWarningDialog from '@/components/MkUrlWarningDialog.vue'; -const extractDomain = /^(https?:\/\/|\/\/)?([^@/\s]+@)?(www\.)?([^:/\s]+)/i; const isRegExp = /^\/(.+)\/(.*)$/; +function extractHostname(maybeUrl: string): URL | null { + try { + const url = new URL(maybeUrl); + return url.host; + } catch { + return null; + } +} + export async function warningExternalWebsite(url: string) { - const domain = extractDomain.exec(url)?.[4]; + const hostname = extractHostname(url); - if (!domain) return false; + if (!hostname) return false; const isTrustedByInstance = instance.trustedLinkUrlPatterns.some(expression => { const r = isRegExp.exec(expression); @@ -24,11 +32,11 @@ export async function warningExternalWebsite(url: string) { } else if (expression.includes(' ')) { return expression.split(' ').every(keyword => url.includes(keyword)); } else { - return domain.endsWith(expression); + return `.${hostname}`.endsWith(`.${expression}`); } }); - const isTrustedByUser = defaultStore.reactiveState.trustedDomains.value.includes(domain); + const isTrustedByUser = defaultStore.reactiveState.trustedDomains.value.includes(hostname); const isDisabledByUser = !defaultStore.reactiveState.warnExternalUrl.value; if (!isTrustedByInstance && !isTrustedByUser && !isDisabledByUser) { @@ -44,7 +52,7 @@ export async function warningExternalWebsite(url: string) { }); if (confirm.canceled) return false; - + return window.open(url, '_blank', 'nofollow noopener popup=false'); } |