diff options
Diffstat (limited to 'packages/frontend')
639 files changed, 14271 insertions, 11699 deletions
diff --git a/packages/frontend/.storybook/fake-utils.ts b/packages/frontend/.storybook/fake-utils.ts index c777cbbe72..44e2263ca0 100644 --- a/packages/frontend/.storybook/fake-utils.ts +++ b/packages/frontend/.storybook/fake-utils.ts @@ -131,7 +131,7 @@ export function imageDataUrl(options?: { alpha?: number, } }, seed?: string): string { - const canvas = document.createElement('canvas'); + const canvas = window.document.createElement('canvas'); canvas.width = options?.size?.width ?? 100; canvas.height = options?.size?.height ?? 100; diff --git a/packages/frontend/.storybook/generate.tsx b/packages/frontend/.storybook/generate.tsx index 3cd08191f5..89d4214141 100644 --- a/packages/frontend/.storybook/generate.tsx +++ b/packages/frontend/.storybook/generate.tsx @@ -17,8 +17,52 @@ interface SatisfiesExpression extends estree.BaseExpression { reference: estree.Identifier; } +interface ImportDeclaration extends estree.ImportDeclaration { + kind?: 'type'; +} + const generator = { ...GENERATOR, + ImportDeclaration(node: ImportDeclaration, state: State) { + state.write('import '); + if (node.kind === 'type') state.write('type '); + const { specifiers } = node; + if (specifiers.length > 0) { + let i = 0; + for (; i < specifiers.length; i++) { + if (i > 0) { + state.write(', '); + } + const specifier = specifiers[i]!; + if (specifier.type === 'ImportDefaultSpecifier') { + state.write(specifier.local.name, specifier); + } else if (specifier.type === 'ImportNamespaceSpecifier') { + state.write(`* as ${specifier.local.name}`, specifier); + } else { + break; + } + } + if (i < specifiers.length) { + state.write('{'); + for (; i < specifiers.length; i++) { + const specifier = specifiers[i]! as estree.ImportSpecifier; + const { name } = specifier.imported as estree.Identifier; + state.write(name, specifier); + if (name !== specifier.local.name) { + state.write(` as ${specifier.local.name}`); + } + if (i < specifiers.length - 1) { + state.write(', '); + } + } + state.write('}'); + } + state.write(' from '); + } + this.Literal(node.source, state); + + state.write(';'); + }, SatisfiesExpression(node: SatisfiesExpression, state: State) { switch (node.expression.type) { case 'ArrowFunctionExpression': { @@ -62,7 +106,7 @@ type ToKebab<T extends readonly string[]> = T extends readonly [ : T extends readonly [ infer XH extends string, ...infer XR extends readonly string[] - ] + ] ? `${XH}${XR extends readonly string[] ? `-${ToKebab<XR>}` : ''}` : ''; @@ -132,7 +176,7 @@ function toStories(component: string): Promise<string> { kind={'init' as const} shorthand /> as estree.Property, - ] + ] : []), ]} /> as estree.ObjectExpression; @@ -155,7 +199,8 @@ function toStories(component: string): Promise<string> { /> as estree.ImportSpecifier, ]), ]} - /> as estree.ImportDeclaration, + kind={'type'} + /> as ImportDeclaration, ...(hasMsw ? [ <import-declaration @@ -165,8 +210,8 @@ function toStories(component: string): Promise<string> { local={<identifier name='msw' /> as estree.Identifier} /> as estree.ImportNamespaceSpecifier, ]} - /> as estree.ImportDeclaration, - ] + /> as ImportDeclaration, + ] : []), ...(hasImplStories ? [] @@ -176,8 +221,8 @@ function toStories(component: string): Promise<string> { specifiers={[ <import-default-specifier local={identifier} /> as estree.ImportDefaultSpecifier, ]} - /> as estree.ImportDeclaration, - ]), + /> as ImportDeclaration, + ]), ...(hasMetaStories ? [ <import-declaration @@ -187,7 +232,7 @@ function toStories(component: string): Promise<string> { local={<identifier name='storiesMeta' /> as estree.Identifier} /> as estree.ImportNamespaceSpecifier, ]} - /> as estree.ImportDeclaration, + /> as ImportDeclaration, ] : []), <variable-declaration diff --git a/packages/frontend/.storybook/preview.ts b/packages/frontend/.storybook/preview.ts index d000a28232..fb855c1410 100644 --- a/packages/frontend/.storybook/preview.ts +++ b/packages/frontend/.storybook/preview.ts @@ -21,11 +21,11 @@ let moduleInitialized = false; let unobserve = () => {}; let misskeyOS = null; -function loadTheme(applyTheme: typeof import('../src/scripts/theme')['applyTheme']) { +function loadTheme(applyTheme: typeof import('../src/theme')['applyTheme']) { unobserve(); - const theme = themes[document.documentElement.dataset.misskeyTheme]; + const theme = themes[window.document.documentElement.dataset.misskeyTheme]; if (theme) { - applyTheme(themes[document.documentElement.dataset.misskeyTheme]); + applyTheme(themes[window.document.documentElement.dataset.misskeyTheme]); } else { applyTheme(themes['l-light']); } @@ -42,7 +42,7 @@ function loadTheme(applyTheme: typeof import('../src/scripts/theme')['applyTheme } } }); - observer.observe(document.documentElement, { + observer.observe(window.document.documentElement, { attributes: true, attributeFilter: ['data-misskey-theme'], }); @@ -64,13 +64,13 @@ initialize({ initLocalStorage(); queueMicrotask(() => { Promise.all([ - import('../src/components'), - import('../src/directives'), - import('../src/widgets'), - import('../src/scripts/theme'), - import('../src/store'), - import('../src/os'), - ]).then(([{ default: components }, { default: directives }, { default: widgets }, { applyTheme }, { defaultStore }, os]) => { + import('../src/components/index.js'), + import('../src/directives/index.js'), + import('../src/widgets/index.js'), + import('../src/theme.js'), + import('../src/preferences.js'), + import('../src/os.js'), + ]).then(([{ default: components }, { default: directives }, { default: widgets }, { applyTheme }, { prefer }, os]) => { setup((app) => { moduleInitialized = true; if (app[appInitialized]) { @@ -83,7 +83,7 @@ queueMicrotask(() => { widgets(app); misskeyOS = os; if (isChromatic()) { - defaultStore.set('animation', false); + prefer.commit('animation', false); } }); }); @@ -104,9 +104,9 @@ const preview = { } }).catch(() => {}) : Promise.resolve(); - const resetDefaultStorePromise = import('../src/store').then(({ defaultStore }) => { + const resetDefaultStorePromise = import('../src/store').then(({ store }) => { // @ts-expect-error - defaultStore.init(); + store.init(); }).catch(() => {}); Promise.all([resetIndexedDBPromise, resetDefaultStorePromise]).then(() => { initLocalStorage(); diff --git a/packages/frontend/@types/theme.d.ts b/packages/frontend/@types/theme.d.ts index 70afc356c1..6ac1037493 100644 --- a/packages/frontend/@types/theme.d.ts +++ b/packages/frontend/@types/theme.d.ts @@ -4,7 +4,7 @@ */ declare module '@@/themes/*.json5' { - import { Theme } from '@/scripts/theme.js'; + import { Theme } from '@/theme.js'; const theme: Theme; diff --git a/packages/frontend/assets/bell_3d.png b/packages/frontend/assets/bell_3d.png Binary files differnew file mode 100644 index 0000000000..2598cdd82b --- /dev/null +++ b/packages/frontend/assets/bell_3d.png diff --git a/packages/frontend/assets/cloud_3d.png b/packages/frontend/assets/cloud_3d.png Binary files differnew file mode 100644 index 0000000000..a3a1de12dd --- /dev/null +++ b/packages/frontend/assets/cloud_3d.png diff --git a/packages/frontend/assets/desktop_computer_3d.png b/packages/frontend/assets/desktop_computer_3d.png Binary files differnew file mode 100644 index 0000000000..85e92a02c0 --- /dev/null +++ b/packages/frontend/assets/desktop_computer_3d.png diff --git a/packages/frontend/assets/electric_plug_3d.png b/packages/frontend/assets/electric_plug_3d.png Binary files differnew file mode 100644 index 0000000000..431ef68c85 --- /dev/null +++ b/packages/frontend/assets/electric_plug_3d.png diff --git a/packages/frontend/assets/gear_3d.png b/packages/frontend/assets/gear_3d.png Binary files differnew file mode 100644 index 0000000000..050340b76c --- /dev/null +++ b/packages/frontend/assets/gear_3d.png diff --git a/packages/frontend/assets/link_3d.png b/packages/frontend/assets/link_3d.png Binary files differnew file mode 100644 index 0000000000..b1cb23080a --- /dev/null +++ b/packages/frontend/assets/link_3d.png diff --git a/packages/frontend/assets/locked_with_key_3d.png b/packages/frontend/assets/locked_with_key_3d.png Binary files differnew file mode 100644 index 0000000000..aae99b982d --- /dev/null +++ b/packages/frontend/assets/locked_with_key_3d.png diff --git a/packages/frontend/assets/mens_room_3d.png b/packages/frontend/assets/mens_room_3d.png Binary files differnew file mode 100644 index 0000000000..8b85ca8782 --- /dev/null +++ b/packages/frontend/assets/mens_room_3d.png diff --git a/packages/frontend/assets/musical_note_3d.png b/packages/frontend/assets/musical_note_3d.png Binary files differnew file mode 100644 index 0000000000..0b520311f6 --- /dev/null +++ b/packages/frontend/assets/musical_note_3d.png diff --git a/packages/frontend/assets/package_3d.png b/packages/frontend/assets/package_3d.png Binary files differnew file mode 100644 index 0000000000..582134fd2f --- /dev/null +++ b/packages/frontend/assets/package_3d.png diff --git a/packages/frontend/assets/prohibited_3d.png b/packages/frontend/assets/prohibited_3d.png Binary files differnew file mode 100644 index 0000000000..1f071edd06 --- /dev/null +++ b/packages/frontend/assets/prohibited_3d.png diff --git a/packages/frontend/assets/speaker_high_volume_3d.png b/packages/frontend/assets/speaker_high_volume_3d.png Binary files differnew file mode 100644 index 0000000000..b25aaa91d6 --- /dev/null +++ b/packages/frontend/assets/speaker_high_volume_3d.png diff --git a/packages/frontend/assets/unlocked_3d.png b/packages/frontend/assets/unlocked_3d.png Binary files differnew file mode 100644 index 0000000000..c6ff7a0dc2 --- /dev/null +++ b/packages/frontend/assets/unlocked_3d.png diff --git a/packages/frontend/eslint.config.js b/packages/frontend/eslint.config.js index 8aa70c66a2..1b9a9b68c0 100644 --- a/packages/frontend/eslint.config.js +++ b/packages/frontend/eslint.config.js @@ -50,9 +50,71 @@ export default [ // defineExposeが誤検知されてしまう '@typescript-eslint/no-unused-expressions': 'off', 'import/consistent-type-specifier-style': ['error', 'prefer-top-level'], - // window の禁止理由: グローバルスコープと衝突し、予期せぬ結果を招くため - // e の禁止理由: error や event など、複数のキーワードの頭文字であり分かりにくいため - 'id-denylist': ['error', 'window', 'e'], + // window ... グローバルスコープと衝突し、予期せぬ結果を招くため + // e ... error や event など、複数のキーワードの頭文字であり分かりにくいため + // close ... window.closeと衝突 or 紛らわしい + // open ... window.openと衝突 or 紛らわしい + // fetch ... window.fetchと衝突 or 紛らわしい + // location ... window.locationと衝突 or 紛らわしい + // document ... window.documentと衝突 or 紛らわしい + // history ... window.historyと衝突 or 紛らわしい + // scroll ... window.scrollと衝突 or 紛らわしい + // setTimeout ... window.setTimeoutと衝突 or 紛らわしい + // setInterval ... window.setIntervalと衝突 or 紛らわしい + // clearTimeout ... window.clearTimeoutと衝突 or 紛らわしい + // clearInterval ... window.clearIntervalと衝突 or 紛らわしい + 'id-denylist': ['warn', 'window', 'e', 'close', 'open', 'fetch', 'location', 'document', 'history', 'scroll', 'setTimeout', 'setInterval', 'clearTimeout', 'clearInterval'], + 'no-restricted-globals': [ + 'error', + { + 'name': 'open', + 'message': 'Use `window.open`.', + }, + { + 'name': 'close', + 'message': 'Use `window.close`.', + }, + { + 'name': 'fetch', + 'message': 'Use `window.fetch`.', + }, + { + 'name': 'location', + 'message': 'Use `window.location`.', + }, + { + 'name': 'document', + 'message': 'Use `window.document`.', + }, + { + 'name': 'history', + 'message': 'Use `window.history`.', + }, + { + 'name': 'scroll', + 'message': 'Use `window.scroll`.', + }, + { + 'name': 'setTimeout', + 'message': 'Use `window.setTimeout`.', + }, + { + 'name': 'setInterval', + 'message': 'Use `window.setInterval`.', + }, + { + 'name': 'clearTimeout', + 'message': 'Use `window.clearTimeout`.', + }, + { + 'name': 'clearInterval', + 'message': 'Use `window.clearInterval`.', + }, + { + 'name': 'name', + 'message': 'Use `window.name`. もしくは name という変数名を定義し忘れている', + }, + ], 'no-shadow': ['warn'], 'vue/attributes-order': ['error', { alphabetical: false, diff --git a/packages/frontend/lib/rollup-plugin-unwind-css-module-class-name.test.ts b/packages/frontend/lib/rollup-plugin-unwind-css-module-class-name.test.ts index 5d8cf05fff..ccfa08575b 100644 --- a/packages/frontend/lib/rollup-plugin-unwind-css-module-class-name.test.ts +++ b/packages/frontend/lib/rollup-plugin-unwind-css-module-class-name.test.ts @@ -58,7 +58,7 @@ describe(normalizeClass.name, () => { it('Composition API (standard)', () => { const ast = parse(` -import { c as api, d as defaultStore, i as i18n, aD as notePage, bN as ImgWithBlurhash, bY as getStaticImageUrl, _ as _export_sfc } from './app-!~{001}~.js'; +import { c as api, d as store, i as i18n, aD as notePage, bN as ImgWithBlurhash, bY as getStaticImageUrl, _ as _export_sfc } from './app-!~{001}~.js'; import { M as MkContainer } from './MkContainer-!~{03M}~.js'; import { b as defineComponent, a as ref, e as onMounted, z as resolveComponent, g as openBlock, h as createBlock, i as withCtx, K as createTextVNode, E as toDisplayString, u as unref, l as createBaseVNode, q as normalizeClass, B as createCommentVNode, k as createElementBlock, F as Fragment, C as renderList, A as createVNode } from './vue-!~{002}~.js'; import './photoswipe-!~{003}~.js'; @@ -74,7 +74,7 @@ const _sfc_main = /* @__PURE__ */ defineComponent({ let fetching = ref(true); let images = ref([]); function thumbnail(image) { - return defaultStore.state.disableShowingAnimatedImages ? getStaticImageUrl(image.url) : image.thumbnailUrl; + return store.s.disableShowingAnimatedImages ? getStaticImageUrl(image.url) : image.thumbnailUrl; } onMounted(() => { const image = [ @@ -173,7 +173,7 @@ export { index_photos as default }; `.slice(1), { ecmaVersion: 'latest', sourceType: 'module' }); unwindCssModuleClassName(ast); expect(generate(ast)).toBe(` -import {c as api, d as defaultStore, i as i18n, aD as notePage, bN as ImgWithBlurhash, bY as getStaticImageUrl, _ as _export_sfc} from './app-!~{001}~.js'; +import {c as api, d as store, i as i18n, aD as notePage, bN as ImgWithBlurhash, bY as getStaticImageUrl, _ as _export_sfc} from './app-!~{001}~.js'; import {M as MkContainer} from './MkContainer-!~{03M}~.js'; import {b as defineComponent, a as ref, e as onMounted, z as resolveComponent, g as openBlock, h as createBlock, i as withCtx, K as createTextVNode, E as toDisplayString, u as unref, l as createBaseVNode, q as normalizeClass, B as createCommentVNode, k as createElementBlock, F as Fragment, C as renderList, A as createVNode} from './vue-!~{002}~.js'; import './photoswipe-!~{003}~.js'; @@ -190,7 +190,7 @@ const index_photos = defineComponent({ let fetching = ref(true); let images = ref([]); function thumbnail(image) { - return defaultStore.state.disableShowingAnimatedImages ? getStaticImageUrl(image.url) : image.thumbnailUrl; + return store.s.disableShowingAnimatedImages ? getStaticImageUrl(image.url) : image.thumbnailUrl; } onMounted(() => { const image = ["image/jpeg", "image/webp", "image/avif", "image/png", "image/gif", "image/apng", "image/vnd.mozilla.apng"]; @@ -268,7 +268,7 @@ export {index_photos as default}; it('Composition API (with `useCssModule()`)', () => { const ast = parse(` import { a7 as getCurrentInstance, b as defineComponent, G as useCssModule, a1 as h, H as TransitionGroup } from './!~{002}~.js'; -import { d as defaultStore, aK as toast, b5 as MkAd, i as i18n, _ as _export_sfc } from './app-!~{001}~.js'; +import { d as store, aK as toast, b5 as MkAd, i as i18n, _ as _export_sfc } from './app-!~{001}~.js'; function isDebuggerEnabled(id) { try { @@ -393,7 +393,7 @@ const _sfc_main = defineComponent({ el.style.left = ""; } return () => h( - defaultStore.state.animation ? TransitionGroup : "div", + prefer.s.animation ? TransitionGroup : "div", { class: { [$style["date-separated-list"]]: true, @@ -402,7 +402,7 @@ const _sfc_main = defineComponent({ [$style["direction-down"]]: props.direction === "down", [$style["direction-up"]]: props.direction === "up" }, - ...defaultStore.state.animation ? { + ...prefer.s.animation ? { name: "list", tag: "div", onBeforeLeave, @@ -441,7 +441,7 @@ export { MkDateSeparatedList as M }; unwindCssModuleClassName(ast); expect(generate(ast)).toBe(` import {a7 as getCurrentInstance, b as defineComponent, G as useCssModule, a1 as h, H as TransitionGroup} from './!~{002}~.js'; -import {d as defaultStore, aK as toast, b5 as MkAd, i as i18n, _ as _export_sfc} from './app-!~{001}~.js'; +import {d as store, aK as toast, b5 as MkAd, i as i18n, _ as _export_sfc} from './app-!~{001}~.js'; function isDebuggerEnabled(id) { try { return localStorage.getItem(\`DEBUG_\${id}\`) !== null; @@ -555,7 +555,7 @@ const _sfc_main = defineComponent({ el.style.top = ""; el.style.left = ""; } - return () => h(defaultStore.state.animation ? TransitionGroup : "div", { + return () => h(prefer.s.animation ? TransitionGroup : "div", { class: { [$style["date-separated-list"]]: true, [$style["date-separated-list-nogap"]]: props.noGap, @@ -563,7 +563,7 @@ const _sfc_main = defineComponent({ [$style["direction-down"]]: props.direction === "down", [$style["direction-up"]]: props.direction === "up" }, - ...defaultStore.state.animation ? { + ...prefer.s.animation ? { name: "list", tag: "div", onBeforeLeave, diff --git a/packages/frontend/lib/vite-plugin-create-search-index.ts b/packages/frontend/lib/vite-plugin-create-search-index.ts index 509eb804cb..97f4e589a3 100644 --- a/packages/frontend/lib/vite-plugin-create-search-index.ts +++ b/packages/frontend/lib/vite-plugin-create-search-index.ts @@ -3,78 +3,67 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +/// <reference lib="esnext" /> + import { parse as vueSfcParse } from 'vue/compiler-sfc'; -import type { LogOptions, Plugin } from 'vite'; +import { + createLogger, + EnvironmentModuleGraph, + type LogErrorOptions, + type LogOptions, + normalizePath, + type Plugin, + type PluginOption +} from 'vite'; import fs from 'node:fs'; import { glob } from 'glob'; import JSON5 from 'json5'; -import MagicString from 'magic-string'; +import MagicString, { SourceMap } from 'magic-string'; import path from 'node:path' import { hash, toBase62 } from '../vite.config'; -import { createLogger } from 'vite'; - -interface VueAstNode { - type: number; - tag?: string; - loc?: { - start: { offset: number, line: number, column: number }, - end: { offset: number, line: number, column: number }, - source?: string - }; - props?: Array<{ - name: string; - type: number; - value?: { content?: string }; - arg?: { content?: string }; - exp?: { content?: string; loc?: any }; - }>; - children?: VueAstNode[]; - content?: any; - __markerId?: string; - __children?: string[]; -} +import { minimatch } from 'minimatch'; +import { + type AttributeNode, + type DirectiveNode, + type ElementNode, + ElementTypes, + NodeTypes, + type RootNode, + type SimpleExpressionNode, + type TemplateChildNode, +} from '@vue/compiler-core'; -export type AnalysisResult = { - filePath: string; - usage: SearchIndexItem[]; -} - -export type SearchIndexItem = { +export interface SearchIndexItem { id: string; + parentId?: string; path?: string; label: string; - keywords: string | string[]; + keywords: string[]; icon?: string; inlining?: string[]; - children?: SearchIndexItem[]; -}; +} export type Options = { targetFilePaths: string[], - exportFilePath: string, + mainVirtualModule: string, + modulesToHmrOnUpdate: string[], + fileVirtualModulePrefix?: string, + fileVirtualModuleSuffix?: string, verbose?: boolean, }; -// 関連するノードタイプの定数化 -const NODE_TYPES = { - ELEMENT: 1, - EXPRESSION: 2, - TEXT: 3, - INTERPOLATION: 5, // Mustache -}; - // マーカー関係を表す型 interface MarkerRelation { parentId?: string; markerId: string; - node: VueAstNode; + node: ElementNode; } // ロガー let logger = { info: (msg: string, options?: LogOptions) => { }, warn: (msg: string, options?: LogOptions) => { }, - error: (msg: string, options?: LogOptions) => { }, + error: (msg: string, options?: LogErrorOptions | unknown) => { }, }; let loggerInitialized = false; @@ -99,1212 +88,474 @@ function initLogger(options: Options) { } } -/** - * 解析結果をTypeScriptファイルとして出力する - */ -function outputAnalysisResultAsTS(outputPath: string, analysisResults: AnalysisResult[]): void { - logger.info(`Processing ${analysisResults.length} files for output`); +//region AST Utility - // 新しいツリー構造を構築 - const allMarkers = new Map<string, SearchIndexItem>(); - - // 1. すべてのマーカーを一旦フラットに収集 - for (const file of analysisResults) { - logger.info(`Processing file: ${file.filePath} with ${file.usage.length} markers`); - - for (const marker of file.usage) { - if (marker.id) { - // キーワードとchildren処理を共通化 - const processedMarker = { - ...marker, - keywords: processMarkerProperty(marker.keywords, 'keywords'), - children: processMarkerProperty(marker.children || [], 'children') - }; - - allMarkers.set(marker.id, processedMarker); - } - } - } - - logger.info(`Collected total ${allMarkers.size} unique markers`); - - // 2. 子マーカーIDの収集 - const childIds = collectChildIds(allMarkers); - logger.info(`Found ${childIds.size} child markers`); - - // 3. ルートマーカーの特定(他の誰かの子でないマーカー) - const rootMarkers = identifyRootMarkers(allMarkers, childIds); - logger.info(`Found ${rootMarkers.length} root markers`); - - // 4. 子マーカーの参照を解決 - const resolvedRootMarkers = resolveChildReferences(rootMarkers, allMarkers); - - // 5. デバッグ情報を生成 - const { totalMarkers, totalChildren } = countMarkers(resolvedRootMarkers); - logger.info(`Total markers in tree: ${totalMarkers} (${resolvedRootMarkers.length} roots + ${totalChildren} nested children)`); - - // 6. 結果をTS形式で出力 - writeOutputFile(outputPath, resolvedRootMarkers); -} +type WalkVueNode = RootNode | TemplateChildNode | SimpleExpressionNode; /** - * マーカーのプロパティ(keywordsやchildren)を処理する + * Walks the Vue AST. + * @param nodes + * @param context The context value passed to callback. you can update context for children by returning value in callback + * @param callback Returns false if you don't want to walk inner tree */ -function processMarkerProperty(propValue: any, propType: 'keywords' | 'children'): any { - // 文字列の配列表現を解析 - if (typeof propValue === 'string' && propValue.startsWith('[') && propValue.endsWith(']')) { - try { - // JSON5解析を試みる - return JSON5.parse(propValue.replace(/'/g, '"')); - } catch (e) { - // 解析に失敗した場合 - logger.warn(`Could not parse ${propType}: ${propValue}, using ${propType === 'children' ? 'empty array' : 'as is'}`); - return propType === 'children' ? [] : propValue; +function walkVueElements<C extends {} | null>(nodes: WalkVueNode[], context: C, callback: (node: ElementNode, context: C) => C | undefined | void | false): void { + for (const node of nodes) { + let currentContext = context; + if (node.type === NodeTypes.COMPOUND_EXPRESSION) throw new Error("Unexpected COMPOUND_EXPRESSION"); + if (node.type === NodeTypes.ELEMENT) { + const result = callback(node, context); + if (result === false) return; + if (result !== undefined) currentContext = result; } - } - - return propValue; -} - -/** - * 全マーカーから子IDを収集する - */ -function collectChildIds(allMarkers: Map<string, SearchIndexItem>): Set<string> { - const childIds = new Set<string>(); - - allMarkers.forEach((marker, id) => { - // 通常のchildren処理 - const children = marker.children; - if (Array.isArray(children)) { - children.forEach(childId => { - if (typeof childId === 'string') { - if (!allMarkers.has(childId)) { - logger.warn(`Warning: Child marker ID ${childId} referenced but not found`); - } else { - childIds.add(childId); - } - } - }); - } - - // inlining処理を追加 - if (marker.inlining) { - let inliningIds: string[] = []; - - // 文字列の場合は配列に変換 - if (typeof marker.inlining === 'string') { - try { - const inliningStr = (marker.inlining as string).trim(); - if (inliningStr.startsWith('[') && inliningStr.endsWith(']')) { - inliningIds = JSON5.parse(inliningStr.replace(/'/g, '"')); - logger.info(`Parsed inlining string to array: ${inliningStr} -> ${JSON.stringify(inliningIds)}`); - } else { - inliningIds = [inliningStr]; - } - } catch (e) { - logger.error(`Failed to parse inlining string: ${marker.inlining}`, e); - } - } - // 既に配列の場合 - else if (Array.isArray(marker.inlining)) { - inliningIds = marker.inlining; - } - - // inliningで指定されたIDを子セットに追加 - for (const inlineId of inliningIds) { - if (typeof inlineId === 'string') { - if (!allMarkers.has(inlineId)) { - logger.warn(`Warning: Inlining marker ID ${inlineId} referenced but not found`); - } else { - // inliningで参照されているマーカーも子として扱う - childIds.add(inlineId); - logger.info(`Added inlined marker ${inlineId} as child in collectChildIds`); - } - } - } + if ('children' in node) { + walkVueElements(node.children, currentContext, callback); } - }); - - return childIds; -} - -/** - * ルートマーカー(他の子でないマーカー)を特定する - */ -function identifyRootMarkers( - allMarkers: Map<string, SearchIndexItem>, - childIds: Set<string> -): SearchIndexItem[] { - const rootMarkers: SearchIndexItem[] = []; - - allMarkers.forEach((marker, id) => { - if (!childIds.has(id)) { - rootMarkers.push(marker); - logger.info(`Added root marker to output: ${id} with label ${marker.label}`); - } - }); - - return rootMarkers; + } } -/** - * 子マーカーの参照をIDから実際のオブジェクトに解決する - */ -function resolveChildReferences( - rootMarkers: SearchIndexItem[], - allMarkers: Map<string, SearchIndexItem> -): SearchIndexItem[] { - function resolveChildrenForMarker(marker: SearchIndexItem): SearchIndexItem { - // マーカーのディープコピーを作成 - const resolvedMarker = { ...marker }; - // 明示的に子マーカー配列を作成 - const resolvedChildren: SearchIndexItem[] = []; - - // 通常のchildren処理 - if (Array.isArray(marker.children)) { - for (const childId of marker.children) { - if (typeof childId === 'string') { - const childMarker = allMarkers.get(childId); - if (childMarker) { - // 子マーカーの子も再帰的に解決 - const resolvedChild = resolveChildrenForMarker(childMarker); - resolvedChildren.push(resolvedChild); - logger.info(`Resolved regular child ${childId} for parent ${marker.id}`); - } - } - } - } - - // inlining属性の処理 - let inliningIds: string[] = []; - - // 文字列の場合は配列に変換。例: "['2fa']" -> ['2fa'] - if (typeof marker.inlining === 'string') { - try { - // 文字列形式の配列を実際の配列に変換 - const inliningStr = (marker.inlining as string).trim(); - if (inliningStr.startsWith('[') && inliningStr.endsWith(']')) { - inliningIds = JSON5.parse(inliningStr.replace(/'/g, '"')); - logger.info(`Converted string inlining to array: ${inliningStr} -> ${JSON.stringify(inliningIds)}`); - } else { - // 単一値の場合は配列に - inliningIds = [inliningStr]; - logger.info(`Converted single string inlining to array: ${inliningStr}`); +function findAttribute(props: Array<AttributeNode | DirectiveNode>, name: string): AttributeNode | DirectiveNode | null { + for (const prop of props) { + switch (prop.type) { + case NodeTypes.ATTRIBUTE: + if (prop.name === name) { + return prop; } - } catch (e) { - logger.error(`Failed to parse inlining string: ${marker.inlining}`, e); - } - } - // 既に配列の場合はそのまま使用 - else if (Array.isArray(marker.inlining)) { - inliningIds = marker.inlining; - } - - // インライン指定されたマーカーを子として追加 - for (const inlineId of inliningIds) { - if (typeof inlineId === 'string') { - const inlineMarker = allMarkers.get(inlineId); - if (inlineMarker) { - // インライン指定されたマーカーを再帰的に解決 - const resolvedInline = resolveChildrenForMarker(inlineMarker); - delete resolvedInline.path - resolvedChildren.push(resolvedInline); - logger.info(`Added inlined marker ${inlineId} as child to ${marker.id}`); - } else { - logger.warn(`Inlining target not found: ${inlineId} referenced by ${marker.id}`); + break; + case NodeTypes.DIRECTIVE: + if (prop.name === 'bind' && prop.arg && 'content' in prop.arg && prop.arg.content === name) { + return prop; } - } - } - - // 解決した子が存在する場合のみchildrenプロパティを設定 - if (resolvedChildren.length > 0) { - resolvedMarker.children = resolvedChildren; - } else { - delete resolvedMarker.children; + break; } - - return resolvedMarker; } - - // すべてのルートマーカーの子を解決 - return rootMarkers.map(marker => resolveChildrenForMarker(marker)); + return null; } -/** - * マーカー数を数える(デバッグ用) - */ -function countMarkers(markers: SearchIndexItem[]): { totalMarkers: number, totalChildren: number } { - let totalMarkers = markers.length; - let totalChildren = 0; - - function countNested(items: SearchIndexItem[]): void { - for (const marker of items) { - if (marker.children && Array.isArray(marker.children)) { - totalChildren += marker.children.length; - totalMarkers += marker.children.length; - countNested(marker.children as SearchIndexItem[]); - } - } +function findEndOfStartTagAttributes(node: ElementNode): number { + if (node.children.length > 0) { + // 子要素がある場合、最初の子要素の開始位置を基準にする + const nodeStart = node.loc.start.offset; + const firstChildStart = node.children[0].loc.start.offset; + const endOfStartTag = node.loc.source.lastIndexOf('>', firstChildStart - nodeStart); + if (endOfStartTag === -1) throw new Error("Bug: Failed to find end of start tag"); + return nodeStart + endOfStartTag; + } else { + // 子要素がない場合、自身の終了位置から逆算 + return node.isSelfClosing ? node.loc.end.offset - 1 : node.loc.end.offset; } - - countNested(markers); - return { totalMarkers, totalChildren }; } -/** - * 最終的なTypeScriptファイルを出力 - */ -function writeOutputFile(outputPath: string, resolvedRootMarkers: SearchIndexItem[]): void { - try { - const tsOutput = generateTypeScriptCode(resolvedRootMarkers); - fs.writeFileSync(outputPath, tsOutput, 'utf-8'); - // 強制的に出力させるためにViteロガーを使わない - console.log(`Successfully wrote search index to ${outputPath} with ${resolvedRootMarkers.length} root entries`); - } catch (error) { - logger.error('[create-search-index]: error writing output: ', error); - } -} +//endregion /** * TypeScriptコード生成 */ -function generateTypeScriptCode(resolvedRootMarkers: SearchIndexItem[]): string { - return ` -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -// This file was automatically generated by create-search-index. -// Do not edit this file. - -import { i18n } from '@/i18n.js'; - -export type SearchIndexItem = { - id: string; - path?: string; - label: string; - keywords: string[]; - icon?: string; - children?: SearchIndexItem[]; -}; - -export const searchIndexes: SearchIndexItem[] = ${customStringify(resolvedRootMarkers)} as const; - -export type SearchIndex = typeof searchIndexes; -`; +function generateJavaScriptCode(resolvedRootMarkers: SearchIndexItem[]): string { + return `import { i18n } from '@/i18n.js';\n` + + `export const searchIndexes = ${customStringify(resolvedRootMarkers)};\n`; } /** * オブジェクトを特殊な形式の文字列に変換する * i18n参照を保持しつつ適切な形式に変換 */ -function customStringify(obj: any, depth = 0): string { - const INDENT_STR = '\t'; - - // 配列の処理 - if (Array.isArray(obj)) { - if (obj.length === 0) return '[]'; - const indent = INDENT_STR.repeat(depth); - const childIndent = INDENT_STR.repeat(depth + 1); - - // 配列要素の処理 - const items = obj.map(item => { - // オブジェクト要素 - if (typeof item === 'object' && item !== null) { - return `${childIndent}${customStringify(item, depth + 1)}`; - } - - // i18n参照を含む文字列要素 - if (typeof item === 'string' && item.includes('i18n.ts.')) { - return `${childIndent}${item}`; // クォートなしでそのまま出力 - } - - // その他の要素 - return `${childIndent}${JSON5.stringify(item)}`; - }).join(',\n'); - - return `[\n${items},\n${indent}]`; - } - - // null または非オブジェクト - if (obj === null || typeof obj !== 'object') { - return JSON5.stringify(obj); - } - - // オブジェクトの処理 - const indent = INDENT_STR.repeat(depth); - const childIndent = INDENT_STR.repeat(depth + 1); - - const entries = Object.entries(obj) - // 不要なプロパティを除去 - .filter(([key, value]) => { - if (value === undefined) return false; - if (key === 'children' && Array.isArray(value) && value.length === 0) return false; - if (key === 'inlining') return false; - return true; - }) - // 各プロパティを変換 - .map(([key, value]) => { - // 子要素配列の特殊処理 - if (key === 'children' && Array.isArray(value) && value.length > 0) { - return `${childIndent}${key}: ${customStringify(value, depth + 1)}`; - } - - // ラベルやその他プロパティを処理 - return `${childIndent}${key}: ${formatSpecialProperty(key, value)}`; - }); - - if (entries.length === 0) return '{}'; - return `{\n${entries.join(',\n')},\n${indent}}`; -} - -/** - * 特殊プロパティの書式設定 - */ -function formatSpecialProperty(key: string, value: any): string { - // 値がundefinedの場合は空文字列を返す - if (value === undefined) { - return '""'; - } - - // childrenが配列の場合は特別に処理 - if (key === 'children' && Array.isArray(value)) { - return customStringify(value); - } - - // keywordsが配列の場合、特別に処理 - if (key === 'keywords' && Array.isArray(value)) { - return `[${formatArrayForOutput(value)}]`; - } - - // 文字列値の場合の特別処理 - if (typeof value === 'string') { - // i18n.ts 参照を含む場合 - クォートなしでそのまま出力 - if (isI18nReference(value)) { - logger.info(`Preserving i18n reference in output: ${value}`); - return value; - } - - // keywords が配列リテラルの形式の場合 - if (key === 'keywords' && value.startsWith('[') && value.endsWith(']')) { - return value; - } - } - - // 上記以外は通常の JSON5 文字列として返す - return JSON5.stringify(value); +function customStringify(obj: unknown): string { + return JSON.stringify(obj).replaceAll(/"(.*?)"/g, (all, group) => { + // propertyAccessProxy が i18n 参照を "${i18n.xxx}"のような形に変換してるので、これをそのまま`${i18n.xxx}` + // のような形にすると、実行時にi18nのプロパティにアクセスするようになる。 + // objectのkeyでは``が使えないので、${ が使われている場合にのみ``に置き換えるようにする + return group.includes('${') ? '`' + group + '`' : all; + }); } -/** - * 配列式の文字列表現を生成 - */ -function formatArrayForOutput(items: any[]): string { - return items.map(item => { - // i18n.ts. 参照の文字列はそのままJavaScript式として出力 - if (typeof item === 'string' && isI18nReference(item)) { - logger.info(`Preserving i18n reference in array: ${item}`); - return item; // クォートなしでそのまま - } - - // その他の値はJSON5形式で文字列化 - return JSON5.stringify(item); - }).join(', '); -} +// region extractElementText /** - * 要素ノードからテキスト内容を抽出する - * 各抽出方法を分離して可読性を向上 + * 要素のノードの中身のテキストを抽出する */ -function extractElementText(node: VueAstNode): string | null { - if (!node) return null; - - logger.info(`Extracting text from node type=${node.type}, tag=${node.tag || 'unknown'}`); - - // 1. 直接コンテンツの抽出を試行 - const directContent = extractDirectContent(node); - if (directContent) return directContent; - - // 子要素がない場合は終了 - if (!node.children || !Array.isArray(node.children)) { - return null; - } - - // 2. インターポレーションノードを検索 - const interpolationContent = extractInterpolationContent(node.children); - if (interpolationContent) return interpolationContent; - - // 3. 式ノードを検索 - const expressionContent = extractExpressionContent(node.children); - if (expressionContent) return expressionContent; - - // 4. テキストノードを検索 - const textContent = extractTextContent(node.children); - if (textContent) return textContent; - - // 5. 再帰的に子ノードを探索 - return extractNestedContent(node.children); +function extractElementText(node: ElementNode, id: string): string | null { + return extractElementTextChecked(node, node.tag, id); } -/** - * ノードから直接コンテンツを抽出 - */ -function extractDirectContent(node: VueAstNode): string | null { - if (!node.content) return null; - - const content = typeof node.content === 'string' - ? node.content.trim() - : (node.content.content ? node.content.content.trim() : null); - - if (!content) return null; - logger.info(`Direct node content found: ${content}`); - - // Mustache構文のチェック - const mustachePattern = /^\s*{{\s*(.*?)\s*}}\s*$/; - const mustacheMatch = content.match(mustachePattern); - - if (mustacheMatch && mustacheMatch[1] && isI18nReference(mustacheMatch[1])) { - const extractedContent = mustacheMatch[1].trim(); - logger.info(`Extracted i18n reference from mustache: ${extractedContent}`); - return extractedContent; +function extractElementTextChecked(node: ElementNode, processingNodeName: string, id: string): string | null { + const result: string[] = []; + for (const child of node.children) { + const text = extractElementText2Inner(child, processingNodeName, id); + if (text == null) return null; + result.push(text); } - - // 直接i18n参照を含む場合 - if (isI18nReference(content)) { - logger.info(`Direct i18n reference found: ${content}`); - return content; - } - - // その他のコンテンツ - return content; + return result.join(''); } -/** - * インターポレーションノード(Mustache)からコンテンツを抽出 - */ -function extractInterpolationContent(children: VueAstNode[]): string | null { - for (const child of children) { - if (child.type === NODE_TYPES.INTERPOLATION) { - logger.info(`Found interpolation node (Mustache): ${JSON.stringify(child.content).substring(0, 100)}...`); - - if (child.content && child.content.type === 4 && child.content.content) { - const content = child.content.content.trim(); - logger.info(`Interpolation content: ${content}`); - - if (isI18nReference(content)) { - return content; - } - } else if (child.content && typeof child.content === 'object') { - // オブジェクト形式のcontentを探索 - logger.info(`Complex interpolation node: ${JSON.stringify(child.content).substring(0, 100)}...`); - - if (child.content.content) { - const content = child.content.content.trim(); +function extractElementText2Inner(node: TemplateChildNode, processingNodeName: string, id: string): string | null { + if (node.type === NodeTypes.COMPOUND_EXPRESSION) throw new Error("Unexpected COMPOUND_EXPRESSION"); - if (isI18nReference(content)) { - logger.info(`Found i18n reference in complex interpolation: ${content}`); - return content; - } - } + switch (node.type) { + case NodeTypes.INTERPOLATION: { + const expr = node.content; + if (expr.type === NodeTypes.COMPOUND_EXPRESSION) throw new Error(`Unexpected COMPOUND_EXPRESSION`); + const exprResult = evalExpression(expr.content); + if (typeof exprResult !== 'string') { + logger.error(`Result of interpolation node is not string at line ${id}:${node.loc.start.line}`); + return null; } + return exprResult; } - } - - return null; -} - -/** - * 式ノードからコンテンツを抽出 - */ -function extractExpressionContent(children: VueAstNode[]): string | null { - // i18n.ts. 参照パターンを持つものを優先 - for (const child of children) { - if (child.type === NODE_TYPES.EXPRESSION && child.content) { - const expr = child.content.trim(); - - if (isI18nReference(expr)) { - logger.info(`Found i18n reference in expression node: ${expr}`); - return expr; + case NodeTypes.ELEMENT: + if (node.tagType === ElementTypes.ELEMENT) { + return extractElementTextChecked(node, processingNodeName, id); + } else { + logger.error(`Unexpected ${node.tag} extracting text of ${processingNodeName} ${id}:${node.loc.start.line}`); + return null; } - } - } - - // その他の式 - for (const child of children) { - if (child.type === NODE_TYPES.EXPRESSION && child.content) { - const expr = child.content.trim(); - logger.info(`Found expression: ${expr}`); - return expr; - } + case NodeTypes.TEXT: + return node.content; + case NodeTypes.COMMENT: + // We skip comments + return ''; + case NodeTypes.IF: + case NodeTypes.IF_BRANCH: + case NodeTypes.FOR: + case NodeTypes.TEXT_CALL: + logger.error(`Unexpected controlflow element extracting text of ${processingNodeName} ${id}:${node.loc.start.line}`); + return null; } - - return null; } -/** - * テキストノードからコンテンツを抽出 - */ -function extractTextContent(children: VueAstNode[]): string | null { - for (const child of children) { - if (child.type === NODE_TYPES.TEXT && child.content) { - const text = child.content.trim(); - - if (text) { - logger.info(`Found text node: ${text}`); - - // Mustache構文のチェック - const mustachePattern = /^\s*{{\s*(.*?)\s*}}\s*$/; - const mustacheMatch = text.match(mustachePattern); - - if (mustacheMatch && mustacheMatch[1] && isI18nReference(mustacheMatch[1])) { - logger.info(`Extracted i18n ref from text mustache: ${mustacheMatch[1]}`); - return mustacheMatch[1].trim(); - } - - return text; - } - } - } +// endregion - return null; -} +// region extractUsageInfoFromTemplateAst /** - * 子ノードを再帰的に探索してコンテンツを抽出 + * SearchLabel/SearchKeyword/SearchIconを探して抽出する関数 */ -function extractNestedContent(children: VueAstNode[]): string | null { - for (const child of children) { - if (child.children && Array.isArray(child.children) && child.children.length > 0) { - const nestedContent = extractElementText(child); - - if (nestedContent) { - logger.info(`Found nested content: ${nestedContent}`); - return nestedContent; - } - } else if (child.type === NODE_TYPES.ELEMENT) { - // childrenがなくても内部を調査 - const nestedContent = extractElementText(child); - - if (nestedContent) { - logger.info(`Found content in childless element: ${nestedContent}`); - return nestedContent; - } - } - } - - return null; -} - - -/** - * SearchLabelとSearchKeywordを探して抽出する関数 - */ -function extractLabelsAndKeywords(nodes: VueAstNode[]): { label: string | null, keywords: any[] } { - let label: string | null = null; - const keywords: any[] = []; +function extractSugarTags(nodes: TemplateChildNode[], id: string): { label: string | null, keywords: string[], icon: string | null } { + let label: string | null | undefined = undefined; + let icon: string | null | undefined = undefined; + const keywords: string[] = []; logger.info(`Extracting labels and keywords from ${nodes.length} nodes`); - // 再帰的にSearchLabelとSearchKeywordを探索(ネストされたSearchMarkerは処理しない) - function findComponents(nodes: VueAstNode[]) { - for (const node of nodes) { - if (node.type === NODE_TYPES.ELEMENT) { - logger.info(`Checking element: ${node.tag}`); - - // SearchMarkerの場合は、その子要素は別スコープなのでスキップ - if (node.tag === 'SearchMarker') { - logger.info(`Found nested SearchMarker - skipping its content to maintain scope isolation`); - continue; // このSearchMarkerの中身は処理しない (スコープ分離) + walkVueElements(nodes, null, (node) => { + switch (node.tag) { + case 'SearchMarker': + return false; // SearchMarkerはスキップ + case 'SearchLabel': + if (label !== undefined) { + logger.warn(`Duplicate SearchLabel found, ignoring the second one at ${id}:${node.loc.start.line}`); + break; // 2つ目のSearchLabelは無視 } - // SearchLabelの処理 - if (node.tag === 'SearchLabel') { - logger.info(`Found SearchLabel node, structure: ${JSON.stringify(node).substring(0, 200)}...`); - - // まず完全なノード内容の抽出を試みる - const content = extractElementText(node); - if (content) { - label = content; - logger.info(`SearchLabel content extracted: ${content}`); - } else { - logger.info(`SearchLabel found but extraction failed, trying direct children inspection`); - - // バックアップ: 子直接確認 - type=5のMustacheインターポレーションを重点的に確認 - if (node.children && Array.isArray(node.children)) { - for (const child of node.children) { - // Mustacheインターポレーション - if (child.type === NODE_TYPES.INTERPOLATION && child.content) { - // content内の式を取り出す - const expression = child.content.content || - (child.content.type === 4 ? child.content.content : null) || - JSON.stringify(child.content); - - logger.info(`Interpolation expression: ${expression}`); - if (typeof expression === 'string' && isI18nReference(expression)) { - label = expression.trim(); - logger.info(`Found i18n in interpolation: ${label}`); - break; - } - } - // 式ノード - else if (child.type === NODE_TYPES.EXPRESSION && child.content && isI18nReference(child.content)) { - label = child.content.trim(); - logger.info(`Found i18n in expression: ${label}`); - break; - } - // テキストノードでもMustache構文を探す - else if (child.type === NODE_TYPES.TEXT && child.content) { - const mustacheMatch = child.content.trim().match(/^\s*{{\s*(.*?)\s*}}\s*$/); - if (mustacheMatch && mustacheMatch[1] && isI18nReference(mustacheMatch[1])) { - label = mustacheMatch[1].trim(); - logger.info(`Found i18n in text mustache: ${label}`); - break; - } - } - } - } - } + label = extractElementText(node, id); + return; + case 'SearchKeyword': + const content = extractElementText(node, id); + if (content) { + keywords.push(content); + } + return; + case 'SearchIcon': + if (icon !== undefined) { + logger.warn(`Duplicate SearchIcon found, ignoring the second one at ${id}:${node.loc.start.line}`); + break; // 2つ目のSearchIconは無視 } - // SearchKeywordの処理 - else if (node.tag === 'SearchKeyword') { - logger.info(`Found SearchKeyword node`); - - // まず完全なノード内容の抽出を試みる - const content = extractElementText(node); - if (content) { - keywords.push(content); - logger.info(`SearchKeyword content extracted: ${content}`); - } else { - logger.info(`SearchKeyword found but extraction failed, trying direct children inspection`); - - // バックアップ: 子直接確認 - type=5のMustacheインターポレーションを重点的に確認 - if (node.children && Array.isArray(node.children)) { - for (const child of node.children) { - // Mustacheインターポレーション - if (child.type === NODE_TYPES.INTERPOLATION && child.content) { - // content内の式を取り出す - const expression = child.content.content || - (child.content.type === 4 ? child.content.content : null) || - JSON.stringify(child.content); - logger.info(`Keyword interpolation: ${expression}`); - if (typeof expression === 'string' && isI18nReference(expression)) { - const keyword = expression.trim(); - keywords.push(keyword); - logger.info(`Found i18n keyword in interpolation: ${keyword}`); - break; - } - } - // 式ノード - else if (child.type === NODE_TYPES.EXPRESSION && child.content && isI18nReference(child.content)) { - const keyword = child.content.trim(); - keywords.push(keyword); - logger.info(`Found i18n keyword in expression: ${keyword}`); - break; - } - // テキストノードでもMustache構文を探す - else if (child.type === NODE_TYPES.TEXT && child.content) { - const mustacheMatch = child.content.trim().match(/^\s*{{\s*(.*?)\s*}}\s*$/); - if (mustacheMatch && mustacheMatch[1] && isI18nReference(mustacheMatch[1])) { - const keyword = mustacheMatch[1].trim(); - keywords.push(keyword); - logger.info(`Found i18n keyword in text mustache: ${keyword}`); - break; - } - } - } - } - } + if (node.children.length !== 1) { + logger.error(`SearchIcon must have exactly one child at ${id}:${node.loc.start.line}`); + return; } - // 子要素を再帰的に調査(ただしSearchMarkerは除外) - if (node.children && Array.isArray(node.children)) { - findComponents(node.children); + const iconNode = node.children[0]; + if (iconNode.type !== NodeTypes.ELEMENT) { + logger.error(`SearchIcon must have a child element at ${id}:${node.loc.start.line}`); + return; } - } + icon = getStringProp(findAttribute(iconNode.props, 'class'), id); + return; } - } - findComponents(nodes); + return; + }); // デバッグ情報 - logger.info(`Extraction completed: label=${label}, keywords=[${keywords.join(', ')}]`); - return { label, keywords }; + logger.info(`Extraction completed: label=${label}, keywords=[${keywords.join(', ')}, icon=${icon}]`); + return { label: label ?? null, keywords, icon: icon ?? null }; } +function getStringProp(attr: AttributeNode | DirectiveNode | null, id: string): string | null { + switch (attr?.type) { + case null: + case undefined: + return null; + case NodeTypes.ATTRIBUTE: + return attr.value?.content ?? null; + case NodeTypes.DIRECTIVE: + if (attr.exp == null) return null; + if (attr.exp.type === NodeTypes.COMPOUND_EXPRESSION) throw new Error('Unexpected COMPOUND_EXPRESSION'); + const value = evalExpression(attr.exp.content ?? ''); + if (typeof value !== 'string') { + logger.error(`Expected string value, got ${typeof value} at ${id}:${attr.loc.start.line}`); + return null; + } + return value; + } +} + +function getStringArrayProp(attr: AttributeNode | DirectiveNode | null, id: string): string[] | null { + switch (attr?.type) { + case null: + case undefined: + return null; + case NodeTypes.ATTRIBUTE: + logger.error(`Expected directive, got attribute at ${id}:${attr.loc.start.line}`); + return null; + case NodeTypes.DIRECTIVE: + if (attr.exp == null) return null; + if (attr.exp.type === NodeTypes.COMPOUND_EXPRESSION) throw new Error('Unexpected COMPOUND_EXPRESSION'); + const value = evalExpression(attr.exp.content ?? ''); + if (!Array.isArray(value) || !value.every(x => typeof x === 'string')) { + logger.error(`Expected string array value, got ${typeof value} at ${id}:${attr.loc.start.line}`); + return null; + } + return value; + } +} function extractUsageInfoFromTemplateAst( - templateAst: any, + templateAst: RootNode | undefined, id: string, ): SearchIndexItem[] { const allMarkers: SearchIndexItem[] = []; const markerMap = new Map<string, SearchIndexItem>(); - const childrenIds = new Set<string>(); - const normalizedId = id.replace(/\\/g, '/'); if (!templateAst) return allMarkers; - // マーカーの基本情報を収集 - function collectMarkers(node: VueAstNode, parentId: string | null = null) { - if (node.type === 1 && node.tag === 'SearchMarker') { - // マーカーID取得 - const markerIdProp = node.props?.find((p: any) => p.name === 'markerId'); - const markerId = markerIdProp?.value?.content || - node.__markerId; - - // SearchMarkerにマーカーIDがない場合はエラー - if (markerId == null) { - logger.error(`Marker ID not found for node: ${JSON.stringify(node)}`); - throw new Error(`Marker ID not found in file ${id}`); - } - - // マーカー基本情報 - const markerInfo: SearchIndexItem = { - id: markerId, - children: [], - label: '', // デフォルト値 - keywords: [], - }; - - // 静的プロパティを取得 - if (node.props && Array.isArray(node.props)) { - for (const prop of node.props) { - if (prop.type === 6 && prop.name && prop.name !== 'markerId') { - if (prop.name === 'path') markerInfo.path = prop.value?.content || ''; - else if (prop.name === 'icon') markerInfo.icon = prop.value?.content || ''; - else if (prop.name === 'label') markerInfo.label = prop.value?.content || ''; - } - } - } - - // バインドプロパティを取得 - const bindings = extractNodeBindings(node); - if (bindings.path) markerInfo.path = bindings.path; - if (bindings.icon) markerInfo.icon = bindings.icon; - if (bindings.label) markerInfo.label = bindings.label; - if (bindings.children) markerInfo.children = bindings.children; - if (bindings.inlining) { - markerInfo.inlining = bindings.inlining; - logger.info(`Added inlining ${JSON.stringify(bindings.inlining)} to marker ${markerId}`); - } - if (bindings.keywords) { - if (Array.isArray(bindings.keywords)) { - markerInfo.keywords = bindings.keywords; - } else { - markerInfo.keywords = bindings.keywords || []; - } - } - - //pathがない場合はファイルパスを設定 - if (markerInfo.path == null && parentId == null) { - markerInfo.path = normalizedId.match(/.*(\/(admin|settings)\/[^\/]+)\.vue$/)?.[1]; - } - - // SearchLabelとSearchKeywordを抽出 (AST全体を探索) - if (node.children && Array.isArray(node.children)) { - logger.info(`Processing marker ${markerId} for labels and keywords`); - const extracted = extractLabelsAndKeywords(node.children); - - // SearchLabelからのラベル取得は最優先で適用 - if (extracted.label) { - markerInfo.label = extracted.label; - logger.info(`Using extracted label for ${markerId}: ${extracted.label}`); - } else if (markerInfo.label) { - logger.info(`Using existing label for ${markerId}: ${markerInfo.label}`); - } else { - markerInfo.label = 'Unnamed marker'; - logger.info(`No label found for ${markerId}, using default`); - } + walkVueElements<string | null>([templateAst], null, (node, parentId) => { + if (node.tag !== 'SearchMarker') { + return; + } - // SearchKeywordからのキーワード取得を追加 - if (extracted.keywords.length > 0) { - const existingKeywords = Array.isArray(markerInfo.keywords) ? - [...markerInfo.keywords] : - (markerInfo.keywords ? [markerInfo.keywords] : []); + // マーカーID取得 + const markerIdProp = node.props?.find(p => p.name === 'markerId'); + const markerId = markerIdProp?.type == NodeTypes.ATTRIBUTE ? markerIdProp.value?.content : null; - // i18n参照のキーワードは最優先で追加 - const combinedKeywords = [...existingKeywords]; - for (const kw of extracted.keywords) { - combinedKeywords.push(kw); - logger.info(`Added extracted keyword to ${markerId}: ${kw}`); - } + // SearchMarkerにマーカーIDがない場合はエラー + if (markerId == null) { + logger.error(`Marker ID not found for node: ${JSON.stringify(node)}`); + throw new Error(`Marker ID not found in file ${id}`); + } - markerInfo.keywords = combinedKeywords; - } - } + // マーカー基本情報 + const markerInfo: SearchIndexItem = { + id: markerId, + parentId: parentId ?? undefined, + label: '', // デフォルト値 + keywords: [], + }; - // マーカーを登録 - markerMap.set(markerId, markerInfo); - allMarkers.push(markerInfo); + // バインドプロパティを取得 + const path = getStringProp(findAttribute(node.props, 'path'), id) + const icon = getStringProp(findAttribute(node.props, 'icon'), id) + const label = getStringProp(findAttribute(node.props, 'label'), id) + const inlining = getStringArrayProp(findAttribute(node.props, 'inlining'), id) + const keywords = getStringArrayProp(findAttribute(node.props, 'keywords'), id) - // 親子関係を記録 - if (parentId) { - const parent = markerMap.get(parentId); - if (parent) { - childrenIds.add(markerId); - } - } + if (path) markerInfo.path = path; + if (icon) markerInfo.icon = icon; + if (label) markerInfo.label = label; + if (inlining) markerInfo.inlining = inlining; + if (keywords) markerInfo.keywords = keywords; - // 子ノードを処理 - if (node.children && Array.isArray(node.children)) { - node.children.forEach((child: VueAstNode) => { - collectMarkers(child, markerId); - }); - } + //pathがない場合はファイルパスを設定 + if (markerInfo.path == null && parentId == null) { + markerInfo.path = id.match(/.*(\/(admin|settings)\/[^\/]+)\.vue$/)?.[1]; + } - return markerId; + // SearchLabelとSearchKeywordを抽出 (AST全体を探索) + { + const extracted = extractSugarTags(node.children, id); + if (extracted.label && markerInfo.label) logger.warn(`Duplicate label found for ${markerId} at ${id}:${node.loc.start.line}`); + if (extracted.icon && markerInfo.icon) logger.warn(`Duplicate icon found for ${markerId} at ${id}:${node.loc.start.line}`); + markerInfo.label = extracted.label ?? markerInfo.label ?? ''; + markerInfo.keywords = [...extracted.keywords, ...markerInfo.keywords]; + markerInfo.icon = extracted.icon ?? markerInfo.icon ?? undefined; } - // SearchMarkerでない場合は再帰的に子ノードを処理 - else if (node.children && Array.isArray(node.children)) { - node.children.forEach((child: VueAstNode) => { - collectMarkers(child, parentId); - }); + + if (!markerInfo.label) { + logger.warn(`No label found for ${markerId} at ${id}:${node.loc.start.line}`); } - return null; - } + // マーカーを登録 + markerMap.set(markerId, markerInfo); + allMarkers.push(markerInfo); + return markerId; + }); - // AST解析開始 - collectMarkers(templateAst); return allMarkers; } -// バインドプロパティの処理を修正する関数 -function extractNodeBindings(node: VueAstNode): Record<keyof SearchIndexItem, any> { - const bindings: Record<string, any> = {}; +//endregion - if (!node.props || !Array.isArray(node.props)) return bindings; +//region evalExpression - // バインド式を収集 - for (const prop of node.props) { - if (prop.type === 7 && prop.name === 'bind' && prop.arg?.content) { - const propName = prop.arg.content; - const propContent = prop.exp?.content || ''; - - logger.info(`Processing bind prop ${propName}: ${propContent}`); +/** + * expr を実行します。 + * i18n はそのアクセスを保持するために propertyAccessProxy を使用しています。 + */ +function evalExpression(expr: string): unknown { + const rarResult = Function('i18n', `return ${expr}`)(i18nProxy); + // JSON.stringify を一回通すことで、 AccessProxy を文字列に変換する + // Walk してもいいんだけど横着してJSON.stringifyしてる。ビルド時にしか通らないのであんまりパフォーマンス気にする必要ないんで + return JSON.parse(JSON.stringify(rarResult)); +} - // inliningプロパティの処理を追加 - if (propName === 'inlining') { - try { - const content = propContent.trim(); +const propertyAccessProxySymbol = Symbol('propertyAccessProxySymbol'); - // 配列式の場合 - if (content.startsWith('[') && content.endsWith(']')) { - // 配列要素を解析 - const elements = parseArrayExpression(content); - if (elements.length > 0) { - bindings.inlining = elements; - logger.info(`Parsed inlining array: ${JSON5.stringify(elements)}`); - } else { - bindings.inlining = []; - } - } - // 文字列の場合は配列に変換 - else if (content) { - bindings.inlining = [content]; // 単一の値を配列に - logger.info(`Converting inlining to array: [${content}]`); - } - } catch (e) { - logger.error(`Failed to parse inlining binding: ${propContent}`, e); - } - } - // keywordsの特殊処理 - if (propName === 'keywords') { - try { - const content = propContent.trim(); +type AccessProxy = { + [propertyAccessProxySymbol]: string[], + [k: string]: AccessProxy, +} - // 配列式の場合 - if (content.startsWith('[') && content.endsWith(']')) { - // i18n参照や特殊な式を保持するため、各要素を個別に解析 - const elements = parseArrayExpression(content); - if (elements.length > 0) { - bindings.keywords = elements; - logger.info(`Parsed keywords array: ${JSON5.stringify(elements)}`); - } else { - bindings.keywords = []; - logger.info('Empty keywords array'); - } - } - // その他の式(非配列) - else if (content) { - bindings.keywords = content; // 式をそのまま保持 - logger.info(`Keeping keywords as expression: ${content}`); - } else { - bindings.keywords = []; - logger.info('No keywords provided'); - } - } catch (e) { - logger.error(`Failed to parse keywords binding: ${propContent}`, e); - // エラーが起きても何らかの値を設定 - bindings.keywords = propContent || []; - } - } - // その他のプロパティ - else if (propName === 'label') { - // ラベルの場合も式として保持 - bindings[propName] = propContent; - logger.info(`Set label from bind expression: ${propContent}`); - } - else { - bindings[propName] = propContent; - } +const propertyAccessProxyHandler: ProxyHandler<AccessProxy> = { + get(target: AccessProxy, p: string | symbol): any { + if (p in target) { + return (target as any)[p]; + } + if (p == "toJSON" || p == Symbol.toPrimitive) { + return propertyAccessProxyToJSON; } + if (typeof p == 'string') { + return target[p] = propertyAccessProxy([...target[propertyAccessProxySymbol], p]); + } + return undefined; } - - return bindings; } -// 配列式をパースする補助関数(文字列リテラル処理を改善) -function parseArrayExpression(expr: string): any[] { - try { - // 単純なケースはJSON5でパースを試みる - return JSON5.parse(expr.replace(/'/g, '"')); - } catch (e) { - // 複雑なケース(i18n.ts.xxx などの式を含む場合)は手動パース - logger.info(`Complex array expression, trying manual parsing: ${expr}`); - - // "["と"]"を取り除く - const content = expr.substring(1, expr.length - 1).trim(); - if (!content) return []; - - const result: any[] = []; - let currentItem = ''; - let depth = 0; - let inString = false; - let stringChar = ''; - - // カンマで区切る(ただし文字列内や入れ子の配列内のカンマは無視) - for (let i = 0; i < content.length; i++) { - const char = content[i]; - - if (inString) { - if (char === stringChar && content[i - 1] !== '\\') { - inString = false; - } - currentItem += char; - } else if (char === '"' || char === "'") { - inString = true; - stringChar = char; - currentItem += char; - } else if (char === '[') { - depth++; - currentItem += char; - } else if (char === ']') { - depth--; - currentItem += char; - } else if (char === ',' && depth === 0) { - // 項目の区切りを検出 - const trimmed = currentItem.trim(); +function propertyAccessProxyToJSON(this: AccessProxy, hint: string) { + const expression = this[propertyAccessProxySymbol].reduce((prev, current) => { + if (current.match(/^[a-z][0-9a-z]*$/i)) { + return `${prev}.${current}`; + } else { + return `${prev}['${current}']`; + } + }); + return '$\{' + expression + '}'; +} - // 純粋な文字列リテラルの場合、実際の値に変換 - if ((trimmed.startsWith("'") && trimmed.endsWith("'")) || - (trimmed.startsWith('"') && trimmed.endsWith('"'))) { - try { - result.push(JSON5.parse(trimmed)); - } catch (err) { - result.push(trimmed); - } - } else { - // それ以外の式はそのまま(i18n.ts.xxx など) - result.push(trimmed); - } +/** + * プロパティのアクセスを保持するための Proxy オブジェクトを作成します。 + * + * この関数で生成した proxy は JSON でシリアライズするか、`${}`のように string にすると、 ${property.path} のような形になる。 + * @param path + */ +function propertyAccessProxy(path: string[]): AccessProxy { + const target: AccessProxy = { + [propertyAccessProxySymbol]: path, + }; + return new Proxy(target, propertyAccessProxyHandler); +} - currentItem = ''; - } else { - currentItem += char; - } - } +const i18nProxy = propertyAccessProxy(['i18n']); - // 最後の項目を処理 - if (currentItem.trim()) { - const trimmed = currentItem.trim(); +export function collectFileMarkers(id: string, code: string): SearchIndexItem[] { + try { + const { descriptor, errors } = vueSfcParse(code, { + filename: id, + }); - // 純粋な文字列リテラルの場合、実際の値に変換 - if ((trimmed.startsWith("'") && trimmed.endsWith("'")) || - (trimmed.startsWith('"') && trimmed.endsWith('"'))) { - try { - result.push(JSON5.parse(trimmed)); - } catch (err) { - result.push(trimmed); - } - } else { - // それ以外の式はそのまま(i18n.ts.xxx など) - result.push(trimmed); - } + if (errors.length > 0) { + logger.error(`Compile Error: ${id}, ${errors}`); + return []; // エラーが発生したファイルはスキップ } - logger.info(`Parsed complex array expression: ${expr} -> ${JSON.stringify(result)}`); - return result; + return extractUsageInfoFromTemplateAst(descriptor.template?.ast, id); + } catch (error) { + logger.error(`Error analyzing file ${id}:`, error); } -} -export async function analyzeVueProps(options: Options & { - transformedCodeCache: Record<string, string>, -}): Promise<void> { - initLogger(options); - - const allMarkers: SearchIndexItem[] = []; - - // 対象ファイルパスを glob で展開 - const filePaths = options.targetFilePaths.reduce<string[]>((acc, filePathPattern) => { - const matchedFiles = glob.sync(filePathPattern); - return [...acc, ...matchedFiles]; - }, []); + return []; +} - logger.info(`Found ${filePaths.length} matching files to analyze`); +// endregion - for (const filePath of filePaths) { - const absolutePath = path.join(process.cwd(), filePath); - const id = absolutePath.replace(/\\/g, '/'); // 絶対パスに変換 - const code = options.transformedCodeCache[id]; // options 経由でキャッシュ参照 - if (!code) { // キャッシュミスの場合 - logger.error(`Error: No cached code found for: ${id}.`); // エラーログ - throw new Error(`No cached code found for: ${id}.`); // エラーを投げる - } +type TransformedCode = { + code: string, + map: SourceMap, +}; - try { - const { descriptor, errors } = vueSfcParse(options.transformedCodeCache[id], { - filename: filePath, - }); +export class MarkerIdAssigner { + // key: file id + private cache: Map<string, TransformedCode>; - if (errors.length > 0) { - logger.error(`Compile Error: ${filePath}, ${errors}`); - continue; // エラーが発生したファイルはスキップ - } + constructor() { + this.cache = new Map(); + } - const fileMarkers = extractUsageInfoFromTemplateAst(descriptor.template?.ast, id); + public onInvalidate(id: string) { + this.cache.delete(id); + } - if (fileMarkers && fileMarkers.length > 0) { - allMarkers.push(...fileMarkers); // すべてのマーカーを収集 - logger.info(`Successfully extracted ${fileMarkers.length} markers from ${filePath}`); - } else { - logger.info(`No markers found in ${filePath}`); - } - } catch (error) { - logger.error(`Error analyzing file ${filePath}:`, error); + public processFile(id: string, code: string): TransformedCode { + // try cache first + if (this.cache.has(id)) { + return this.cache.get(id)!; } + const transformed = this.#processImpl(id, code); + this.cache.set(id, transformed); + return transformed; } - // 収集したすべてのマーカー情報を使用 - const analysisResult: AnalysisResult[] = [ - { - filePath: "combined-markers", // すべてのファイルのマーカーを1つのエントリとして扱う - usage: allMarkers, - } - ]; + #processImpl(id: string, code: string): TransformedCode { + const s = new MagicString(code); // magic-string のインスタンスを作成 - outputAnalysisResultAsTS(options.exportFilePath, analysisResult); // すべてのマーカー情報を渡す -} + const parsed = vueSfcParse(code, { filename: id }); + if (!parsed.descriptor.template) { + return { + code, + map: s.generateMap({ source: id, includeContent: true }), + }; + } + const ast = parsed.descriptor.template.ast; // テンプレート AST を取得 + const markerRelations: MarkerRelation[] = []; // MarkerRelation 配列を初期化 -interface MarkerRelation { - parentId?: string; - markerId: string; - node: VueAstNode; -} + if (!ast) { + return { + code: s.toString(), // 変更後のコードを返す + map: s.generateMap({ source: id, includeContent: true }), // ソースマップも生成 (sourceMap: true が必要) + }; + } -async function processVueFile( - code: string, - id: string, - options: Options, - transformedCodeCache: Record<string, string> -): Promise<{ - code: string, - map: any, - transformedCodeCache: Record<string, string> -}> { - const normalizedId = id.replace(/\\/g, '/'); // ファイルパスを正規化 - // すでにキャッシュに存在する場合は、そのまま返す - if (transformedCodeCache[normalizedId] && transformedCodeCache[normalizedId].includes('markerId=')) { - logger.info(`Using cached version for ${id}`); - return { - code: transformedCodeCache[normalizedId], - map: null, - transformedCodeCache - }; - } + walkVueElements<string | null>([ast], null, (node, parentId) => { + if (node.tag !== 'SearchMarker') return; - const s = new MagicString(code); // magic-string のインスタンスを作成 - const parsed = vueSfcParse(code, { filename: id }); - if (!parsed.descriptor.template) { - return { - code, - map: null, - transformedCodeCache - }; - } - const ast = parsed.descriptor.template.ast; // テンプレート AST を取得 - const markerRelations: MarkerRelation[] = []; // MarkerRelation 配列を初期化 + const markerIdProp = findAttribute(node.props, 'markerId'); - if (ast) { - function traverse(node: any, currentParent?: any) { - if (node.type === 1 && node.tag === 'SearchMarker') { - // 行番号はコード先頭からの改行数で取得 - const lineNumber = code.slice(0, node.loc.start.offset).split('\n').length; + let nodeMarkerId: string; + if (markerIdProp != null) { + if (markerIdProp.type !== NodeTypes.ATTRIBUTE) return logger.error(`markerId must be a attribute at ${id}:${markerIdProp.loc.start.line}`); + if (markerIdProp.value == null) return logger.error(`markerId must have a value at ${id}:${markerIdProp.loc.start.line}`); + nodeMarkerId = markerIdProp.value.content; + } else { // ファイルパスと行番号からハッシュ値を生成 // この際実行環境で差が出ないようにファイルパスを正規化 const idKey = id.replace(/\\/g, '/').split('packages/frontend/')[1] - const generatedMarkerId = toBase62(hash(`${idKey}:${lineNumber}`)); - - const props = node.props || []; - const hasMarkerIdProp = props.some((prop: any) => prop.type === 6 && prop.name === 'markerId'); - const nodeMarkerId = hasMarkerIdProp - ? props.find((prop: any) => prop.type === 6 && prop.name === 'markerId')?.value?.content as string - : generatedMarkerId; - node.__markerId = nodeMarkerId; - - // 子マーカーの場合、親ノードに __children を設定しておく - if (currentParent && currentParent.type === 1 && currentParent.tag === 'SearchMarker') { - currentParent.__children = currentParent.__children || []; - currentParent.__children.push(nodeMarkerId); - } + const generatedMarkerId = toBase62(hash(`${idKey}:${node.loc.start.line}`)); - const parentMarkerId = currentParent && currentParent.__markerId; - markerRelations.push({ - parentId: parentMarkerId, - markerId: nodeMarkerId, - node: node, - }); + // markerId attribute を追加 + const endOfStartTag = findEndOfStartTagAttributes(node); + s.appendRight(endOfStartTag, ` markerId="${generatedMarkerId}" data-in-app-search-marker-id="${generatedMarkerId}"`); - if (!hasMarkerIdProp) { - const nodeStart = node.loc.start.offset; - let endOfStartTag; - - if (node.children && node.children.length > 0) { - // 子要素がある場合、最初の子要素の開始位置を基準にする - endOfStartTag = code.lastIndexOf('>', node.children[0].loc.start.offset); - } else if (node.loc.end.offset > nodeStart) { - // 子要素がない場合、自身の終了位置から逆算 - const nodeSource = code.substring(nodeStart, node.loc.end.offset); - // 自己終了タグか通常の終了タグかを判断 - if (nodeSource.includes('/>')) { - endOfStartTag = code.indexOf('/>', nodeStart) - 1; - } else { - endOfStartTag = code.indexOf('>', nodeStart); - } - } - - if (endOfStartTag !== undefined && endOfStartTag !== -1) { - // markerId が既に存在しないことを確認 - const tagText = code.substring(nodeStart, endOfStartTag + 1); - const markerIdRegex = /\s+markerId\s*=\s*["'][^"']*["']/; - - if (!markerIdRegex.test(tagText)) { - s.appendRight(endOfStartTag, ` markerId="${generatedMarkerId}" data-in-app-search-marker-id="${generatedMarkerId}"`); - logger.info(`Adding markerId="${generatedMarkerId}" to ${id}:${lineNumber}`); - } else { - logger.info(`markerId already exists in ${id}:${lineNumber}`); - } - } - } + nodeMarkerId = generatedMarkerId; } - const newParent = node.type === 1 && node.tag === 'SearchMarker' ? node : currentParent; - if (node.children && Array.isArray(node.children)) { - node.children.forEach(child => traverse(child, newParent)); - } - } + markerRelations.push({ + parentId: parentId ?? undefined, + markerId: nodeMarkerId, + node: node, + }); - traverse(ast); // AST を traverse (1段階目: ID 生成と親子関係記録) + return nodeMarkerId; + }) // 2段階目: :children 属性の追加 // 最初に親マーカーごとに子マーカーIDを集約する処理を追加 @@ -1316,181 +567,190 @@ async function processVueFile( if (!parentChildrenMap.has(relation.parentId)) { parentChildrenMap.set(relation.parentId, []); } - parentChildrenMap.get(relation.parentId)?.push(relation.markerId); + parentChildrenMap.get(relation.parentId)!.push(relation.markerId); } }); // 2. 親ごとにまとめて :children 属性を処理 for (const [parentId, childIds] of parentChildrenMap.entries()) { const parentRelation = markerRelations.find(r => r.markerId === parentId); - if (!parentRelation || !parentRelation.node) continue; + if (!parentRelation) continue; const parentNode = parentRelation.node; - const childrenProp = parentNode.props?.find((prop: any) => prop.type === 7 && prop.name === 'bind' && prop.arg?.content === 'children'); - - // 親ノードの開始位置を特定 - const parentNodeStart = parentNode.loc!.start.offset; - const endOfParentStartTag = parentNode.children && parentNode.children.length > 0 - ? code.lastIndexOf('>', parentNode.children[0].loc!.start.offset) - : code.indexOf('>', parentNodeStart); - - if (endOfParentStartTag === -1) continue; - - // 親タグのテキストを取得 - const parentTagText = code.substring(parentNodeStart, endOfParentStartTag + 1); + const childrenProp = findAttribute(parentNode.props, 'children'); + if (childrenProp != null) { + if (childrenProp.type !== NodeTypes.DIRECTIVE) { + console.error(`children prop should be directive (:children) at ${id}:${childrenProp.loc.start.line}`); + continue; + } - if (childrenProp) { // AST で :children 属性が検出された場合、それを更新 - try { - const childrenStart = code.indexOf('[', childrenProp.exp!.loc.start.offset); - const childrenEnd = code.indexOf(']', childrenProp.exp!.loc.start.offset); - if (childrenStart !== -1 && childrenEnd !== -1) { - const childrenArrayStr = code.slice(childrenStart, childrenEnd + 1); - let childrenArray = JSON5.parse(childrenArrayStr.replace(/'/g, '"')); + const childrenValue = getStringArrayProp(childrenProp, id); + if (childrenValue == null) continue; - // 新しいIDを追加(重複は除外) - const newIds = childIds.filter(id => !childrenArray.includes(id)); - if (newIds.length > 0) { - childrenArray = [...childrenArray, ...newIds]; - const updatedChildrenArrayStr = JSON5.stringify(childrenArray).replace(/"/g, "'"); - s.overwrite(childrenStart, childrenEnd + 1, updatedChildrenArrayStr); - logger.info(`Added ${newIds.length} child markerIds to existing :children in ${id}`); - } + const newValue: string[] = [...childrenValue]; + for (const childId of [...childIds]) { + if (!newValue.includes(childId)) { + newValue.push(childId); } - } catch (e) { - logger.error('Error updating :children attribute:', e); } + + const expression = JSON.stringify(newValue).replaceAll(/"/g, "'"); + s.overwrite(childrenProp.exp!.loc.start.offset, childrenProp.exp!.loc.end.offset, expression); + logger.info(`Added ${childIds.length} child markerIds to existing :children in ${id}`); } else { - // AST では検出されなかった場合、タグテキストを調べる - const childrenRegex = /:children\s*=\s*["']\[(.*?)\]["']/; - const childrenMatch = parentTagText.match(childrenRegex); + // :children 属性がまだない場合、新規作成 + const endOfParentStartTag = findEndOfStartTagAttributes(parentNode); + s.appendRight(endOfParentStartTag, ` :children="${JSON5.stringify(childIds).replace(/"/g, "'")}"`); + logger.info(`Created new :children attribute with ${childIds.length} markerIds in ${id}`); + } + } - if (childrenMatch) { - // テキストから :children 属性値を解析して更新 - try { - const childrenContent = childrenMatch[1]; - const childrenArrayStr = `[${childrenContent}]`; - const childrenArray = JSON5.parse(childrenArrayStr.replace(/'/g, '"')); + return { + code: s.toString(), // 変更後のコードを返す + map: s.generateMap({ source: id, includeContent: true }), // ソースマップも生成 (sourceMap: true が必要) + }; + } - // 新しいIDを追加(重複は除外) - const newIds = childIds.filter(id => !childrenArray.includes(id)); - if (newIds.length > 0) { - childrenArray.push(...newIds); + async getOrLoad(id: string) { + // if there already exists a cache, return it + // note cahce will be invalidated on file change so the cache must be up to date + let code = this.getCached(id)?.code; + if (code != null) { + return code; + } - // :children="[...]" の位置を特定して上書き - const attrStart = parentTagText.indexOf(':children='); - if (attrStart > -1) { - const attrValueStart = parentTagText.indexOf('[', attrStart); - const attrValueEnd = parentTagText.indexOf(']', attrValueStart) + 1; - if (attrValueStart > -1 && attrValueEnd > -1) { - const absoluteStart = parentNodeStart + attrValueStart; - const absoluteEnd = parentNodeStart + attrValueEnd; - const updatedArrayStr = JSON5.stringify(childrenArray).replace(/"/g, "'"); - s.overwrite(absoluteStart, absoluteEnd, updatedArrayStr); - logger.info(`Updated existing :children in tag text for ${id}`); - } - } - } - } catch (e) { - logger.error('Error updating :children in tag text:', e); - } - } else { - // :children 属性がまだない場合、新規作成 - s.appendRight(endOfParentStartTag, ` :children="${JSON5.stringify(childIds).replace(/"/g, "'")}"`); - logger.info(`Created new :children attribute with ${childIds.length} markerIds in ${id}`); - } - } + // if no cache found, read and parse the file + const originalCode = await fs.promises.readFile(id, 'utf-8'); + + // Other code may already parsed the file while we were waiting for the file to be read so re-check the cache + code = this.getCached(id)?.code; + if (code != null) { + return code; } - } - const transformedCode = s.toString(); // 変換後のコードを取得 - transformedCodeCache[normalizedId] = transformedCode; // 変換後のコードをキャッシュに保存 + // parse the file + code = this.processFile(id, originalCode)?.code; + return code; + } - return { - code: transformedCode, // 変更後のコードを返す - map: s.generateMap({ source: id, includeContent: true }), // ソースマップも生成 (sourceMap: true が必要) - transformedCodeCache // キャッシュも返す - }; + getCached(id: string) { + return this.cache.get(id); + } } - // Rollup プラグインとして export -export default function pluginCreateSearchIndex(options: Options): Plugin { - let transformedCodeCache: Record<string, string> = {}; // キャッシュオブジェクトをプラグインスコープで定義 - const isDevServer = process.env.NODE_ENV === 'development'; // 開発サーバーかどうか +export default function pluginCreateSearchIndex(options: Options): PluginOption { + const assigner = new MarkerIdAssigner(); + return [ + createSearchIndex(options, assigner), + pluginCreateSearchIndexVirtualModule(options, assigner), + ] +} +function createSearchIndex(options: Options, assigner: MarkerIdAssigner): Plugin { initLogger(options); // ロガーを初期化 + const root = normalizePath(process.cwd()); + + function isTargetFile(id: string): boolean { + const relativePath = path.posix.relative(root, id); + return options.targetFilePaths.some(pat => minimatch(relativePath, pat)) + } return { - name: 'createSearchIndex', + name: 'autoAssignMarkerId', enforce: 'pre', - async buildStart() { - if (!isDevServer) { + watchChange(id) { + assigner.onInvalidate(id); + }, + + async transform(code, id) { + if (!id.endsWith('.vue')) { return; } - const filePaths = options.targetFilePaths.reduce<string[]>((acc, filePathPattern) => { - const matchedFiles = glob.sync(filePathPattern); - return [...acc, ...matchedFiles]; - }, []); - - for (const filePath of filePaths) { - const id = path.resolve(filePath); // 絶対パスに変換 - const code = fs.readFileSync(filePath, 'utf-8'); // ファイル内容を読み込む - const { transformedCodeCache: newCache } = await processVueFile(code, id, options, transformedCodeCache); // processVueFile 関数を呼び出す - transformedCodeCache = newCache; // キャッシュを更新 + if (!isTargetFile(id)) { + return; } - await analyzeVueProps({ ...options, transformedCodeCache }); // 開発サーバー起動時にも analyzeVueProps を実行 + return assigner.processFile(id, code); }, + }; +} - async transform(code, id) { - if (!id.endsWith('.vue')) { - return; - } +export function pluginCreateSearchIndexVirtualModule(options: Options, asigner: MarkerIdAssigner): Plugin { + const searchIndexPrefix = options.fileVirtualModulePrefix ?? 'search-index-individual:'; + const searchIndexSuffix = options.fileVirtualModuleSuffix ?? '.ts'; + const allSearchIndexFile = options.mainVirtualModule; + const root = normalizePath(process.cwd()); - // targetFilePaths にマッチするファイルのみ処理を行う - // glob パターンでマッチング - let isMatch = false; // isMatch の初期値を false に設定 - for (const pattern of options.targetFilePaths) { // パターンごとにマッチング確認 - const globbedFiles = glob.sync(pattern); - for (const globbedFile of globbedFiles) { - const normalizedGlobbedFile = path.resolve(globbedFile); // glob 結果を絶対パスに - const normalizedId = path.resolve(id); // id を絶対パスに - if (normalizedGlobbedFile === normalizedId) { // 絶対パス同士で比較 - isMatch = true; - break; // マッチしたらループを抜ける - } - } - if (isMatch) break; // いずれかのパターンでマッチしたら、outer loop も抜ける + function isTargetFile(id: string): boolean { + const relativePath = path.posix.relative(root, id); + return options.targetFilePaths.some(pat => minimatch(relativePath, pat)) + } + + function parseSearchIndexFileId(id: string): string | null { + const noQuery = id.split('?')[0]; + if (noQuery.startsWith(searchIndexPrefix) && noQuery.endsWith(searchIndexSuffix)) { + const filePath = id.slice(searchIndexPrefix.length).slice(0, -searchIndexSuffix.length); + if (isTargetFile(filePath)) { + return filePath; } + } + return null; + } + return { + name: 'generateSearchIndexVirtualModule', + // hotUpdate hook を vite:vue よりもあとに実行したいため enforce: post + enforce: 'post', - if (!isMatch) { - return; + async resolveId(id) { + if (id == allSearchIndexFile) { + return '\0' + allSearchIndexFile; } - const transformed = await processVueFile(code, id, options, transformedCodeCache); - transformedCodeCache = transformed.transformedCodeCache; // キャッシュを更新 - if (isDevServer) { - await analyzeVueProps({ ...options, transformedCodeCache }); // analyzeVueProps を呼び出す + const searchIndexFilePath = parseSearchIndexFileId(id); + if (searchIndexFilePath != null) { + return id; } - return transformed; + return undefined; }, - async writeBundle() { - await analyzeVueProps({ ...options, transformedCodeCache }); // ビルド時にも analyzeVueProps を実行 + async load(id) { + if (id == '\0' + allSearchIndexFile) { + const files = await Promise.all(options.targetFilePaths.map(async (filePathPattern) => await glob(filePathPattern))).then(paths => paths.flat()); + let generatedFile = ''; + let arrayElements = ''; + for (let file of files) { + const normalizedRelative = normalizePath(file); + const absoluteId = normalizePath(path.join(process.cwd(), normalizedRelative)) + searchIndexSuffix; + const variableName = normalizedRelative.replace(/[\/.-]/g, '_'); + generatedFile += `import { searchIndexes as ${variableName} } from '${searchIndexPrefix}${absoluteId}';\n`; + arrayElements += ` ...${variableName},\n`; + } + generatedFile += `export let searchIndexes = [\n${arrayElements}];\n`; + return generatedFile; + } + + const searchIndexFilePath = parseSearchIndexFileId(id); + if (searchIndexFilePath != null) { + // call load to update the index file when the file is changed + this.addWatchFile(searchIndexFilePath); + + const code = await asigner.getOrLoad(searchIndexFilePath); + return generateJavaScriptCode(collectFileMarkers(searchIndexFilePath, code)); + } + return null; }, - }; -} -// i18n参照を検出するためのヘルパー関数を追加 -function isI18nReference(text: string | null | undefined): boolean { - if (!text) return false; - // ドット記法(i18n.ts.something) - const dotPattern = /i18n\.ts\.\w+/; - // ブラケット記法(i18n.ts['something']) - const bracketPattern = /i18n\.ts\[['"][^'"]+['"]\]/; - return dotPattern.test(text) || bracketPattern.test(text); + hotUpdate(this: { environment: { moduleGraph: EnvironmentModuleGraph } }, { file, modules }) { + if (isTargetFile(file)) { + const updateMods = options.modulesToHmrOnUpdate.map(id => this.environment.moduleGraph.getModuleById(path.posix.join(root, id))).filter(x => x != null); + return [...modules, ...updateMods]; + } + return modules; + } + }; } diff --git a/packages/frontend/package.json b/packages/frontend/package.json index 88b57e57ab..156e6abea2 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -24,10 +24,11 @@ "@rollup/plugin-json": "6.1.0", "@rollup/plugin-replace": "6.0.2", "@rollup/pluginutils": "5.1.4", + "@sentry/vue": "9.8.0", "@syuilo/aiscript": "0.19.0", "@tabler/icons-webfont": "3.31.0", "@twemoji/parser": "15.1.1", - "@vitejs/plugin-vue": "5.2.1", + "@vitejs/plugin-vue": "5.2.3", "@vue/compiler-sfc": "3.5.13", "aiscript-vscode": "github:aiscript-dev/aiscript-vscode#v0.1.15", "analytics": "0.8.16", @@ -37,7 +38,7 @@ "canvas-confetti": "1.9.3", "chart.js": "4.4.8", "chartjs-adapter-date-fns": "3.0.0", - "chartjs-chart-matrix": "2.0.1", + "chartjs-chart-matrix": "2.1.1", "chartjs-plugin-gradient": "0.6.1", "chartjs-plugin-zoom": "2.2.0", "chromatic": "11.27.0", @@ -59,10 +60,10 @@ "misskey-reversi": "workspace:*", "photoswipe": "5.4.4", "punycode.js": "2.3.1", - "rollup": "4.34.9", - "sanitize-html": "2.14.0", - "sass": "1.85.1", - "shiki": "3.1.0", + "rollup": "4.36.0", + "sanitize-html": "2.15.0", + "sass": "1.86.0", + "shiki": "3.2.1", "strict-event-emitter-types": "2.0.0", "textarea-caret": "3.1.0", "three": "0.174.0", @@ -73,55 +74,58 @@ "typescript": "5.8.2", "uuid": "11.1.0", "v-code-diff": "1.13.1", - "vite": "6.2.1", + "vite": "6.2.4", "vue": "3.5.13", - "vuedraggable": "next" + "vuedraggable": "next", + "wanakana": "5.3.1" }, "devDependencies": { "@misskey-dev/summaly": "5.2.0", - "@storybook/addon-actions": "8.6.4", - "@storybook/addon-essentials": "8.6.4", - "@storybook/addon-interactions": "8.6.4", - "@storybook/addon-links": "8.6.4", - "@storybook/addon-mdx-gfm": "8.6.4", - "@storybook/addon-storysource": "8.6.4", - "@storybook/blocks": "8.6.4", - "@storybook/components": "8.6.4", - "@storybook/core-events": "8.6.4", - "@storybook/manager-api": "8.6.4", - "@storybook/preview-api": "8.6.4", - "@storybook/react": "8.6.4", - "@storybook/react-vite": "8.6.4", - "@storybook/test": "8.6.4", - "@storybook/theming": "8.6.4", - "@storybook/types": "8.6.4", - "@storybook/vue3": "8.6.4", - "@storybook/vue3-vite": "8.6.4", + "@storybook/addon-actions": "8.6.7", + "@storybook/addon-essentials": "8.6.7", + "@storybook/addon-interactions": "8.6.7", + "@storybook/addon-links": "8.6.7", + "@storybook/addon-mdx-gfm": "8.6.7", + "@storybook/addon-storysource": "8.6.7", + "@storybook/blocks": "8.6.7", + "@storybook/components": "8.6.7", + "@storybook/core-events": "8.6.7", + "@storybook/manager-api": "8.6.7", + "@storybook/preview-api": "8.6.7", + "@storybook/react": "8.6.7", + "@storybook/react-vite": "8.6.7", + "@storybook/test": "8.6.7", + "@storybook/theming": "8.6.7", + "@storybook/types": "8.6.7", + "@storybook/vue3": "8.6.7", + "@storybook/vue3-vite": "8.6.7", "@testing-library/vue": "8.1.0", "@types/canvas-confetti": "1.9.0", "@types/estree": "1.0.6", "@types/matter-js": "0.19.8", "@types/micromatch": "4.0.9", - "@types/node": "22.13.9", + "@types/node": "22.13.11", "@types/punycode.js": "npm:@types/punycode@2.1.4", "@types/sanitize-html": "2.13.0", "@types/seedrandom": "3.0.8", "@types/throttle-debounce": "5.0.2", "@types/tinycolor2": "1.4.6", "@types/ws": "8.18.0", - "@typescript-eslint/eslint-plugin": "8.26.0", - "@typescript-eslint/parser": "8.26.0", - "@vitest/coverage-v8": "3.0.8", + "@typescript-eslint/eslint-plugin": "8.27.0", + "@typescript-eslint/parser": "8.27.0", + "@vitest/coverage-v8": "3.0.9", + "@vue/compiler-core": "3.5.13", "@vue/runtime-core": "3.5.13", "acorn": "8.14.1", "cross-env": "7.0.3", - "cypress": "14.1.0", + "cypress": "14.2.0", "eslint-plugin-import": "2.31.0", "eslint-plugin-vue": "10.0.0", "fast-glob": "3.3.3", - "happy-dom": "17.3.0", + "happy-dom": "17.4.4", "intersection-observer": "0.12.2", "micromatch": "4.0.8", + "minimatch": "10.0.1", "msw": "2.7.3", "msw-storybook-addon": "2.0.4", "nodemon": "3.1.9", @@ -129,11 +133,12 @@ "react": "19.0.0", "react-dom": "19.0.0", "seedrandom": "3.0.5", - "start-server-and-test": "2.0.10", - "storybook": "8.6.4", + "start-server-and-test": "2.0.11", + "storybook": "8.6.7", "storybook-addon-misskey-theme": "github:misskey-dev/storybook-addon-misskey-theme", + "vite-node": "3.0.9", "vite-plugin-turbosnap": "1.0.3", - "vitest": "3.0.8", + "vitest": "3.0.9", "vitest-fetch-mock": "0.4.5", "vue-component-type-helpers": "2.2.8", "vue-eslint-parser": "10.1.1", diff --git a/packages/frontend/src/_boot_.ts b/packages/frontend/src/_boot_.ts index c90cc6bdd0..3241f2dc92 100644 --- a/packages/frontend/src/_boot_.ts +++ b/packages/frontend/src/_boot_.ts @@ -12,9 +12,9 @@ import '@/style.scss'; import { mainBoot } from '@/boot/main-boot.js'; import { subBoot } from '@/boot/sub-boot.js'; -const subBootPaths = ['/share', '/auth', '/miauth', '/oauth', '/signup-complete']; +const subBootPaths = ['/share', '/auth', '/miauth', '/oauth', '/signup-complete', '/install-extensions']; -if (subBootPaths.some(i => location.pathname === i || location.pathname.startsWith(i + '/'))) { +if (subBootPaths.some(i => window.location.pathname === i || window.location.pathname.startsWith(i + '/'))) { subBoot(); } else { mainBoot(); diff --git a/packages/frontend/src/account.ts b/packages/frontend/src/account.ts deleted file mode 100644 index 17d690cd3a..0000000000 --- a/packages/frontend/src/account.ts +++ /dev/null @@ -1,389 +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 '@/scripts/show-suspended-dialog.js'; -import { i18n } from '@/i18n.js'; -import { miLocalStorage } from '@/local-storage.js'; -import { del, get, set } from '@/scripts/idb-proxy.js'; -import { waiting, popup, popupMenu, success, alert } from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { unisonReload, reloadChannel } from '@/scripts/unison-reload.js'; - -// TODO: 他のタブと永続化されたstateを同期 - -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<Account> { - 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<Account | { error: Record<string, any> }>((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<Account>) { - 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<ReturnType<typeof createItem> | 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..a25f3c51d1 --- /dev/null +++ b/packages/frontend/src/accounts.ts @@ -0,0 +1,339 @@ +/* + * 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'; + +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<Misskey.entities.MeDetailed> { + 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<Misskey.entities.MeDetailed | { error: Record<string, any> }>((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<Misskey.entities.MeDetailed>) { + 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で指定された先に移動 + window.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/scripts/aiscript/api.ts b/packages/frontend/src/aiscript/api.ts index 2c0c8c816e..e7e396023d 100644 --- a/packages/frontend/src/scripts/aiscript/api.ts +++ b/packages/frontend/src/aiscript/api.ts @@ -8,8 +8,8 @@ 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 { misskeyApi } from '@/utility/misskey-api.js'; +import { $i } from '@/i.js'; import { miLocalStorage } from '@/local-storage.js'; import { customEmojis } from '@/custom-emojis.js'; diff --git a/packages/frontend/src/scripts/aiscript/common.ts b/packages/frontend/src/aiscript/common.ts index ba5dfb8368..ba5dfb8368 100644 --- a/packages/frontend/src/scripts/aiscript/common.ts +++ b/packages/frontend/src/aiscript/common.ts diff --git a/packages/frontend/src/scripts/aiscript/ui.ts b/packages/frontend/src/aiscript/ui.ts index 46e193f7c1..46e193f7c1 100644 --- a/packages/frontend/src/scripts/aiscript/ui.ts +++ b/packages/frontend/src/aiscript/ui.ts diff --git a/packages/frontend/src/boot/common.ts b/packages/frontend/src/boot/common.ts index d09b98efe0..c8098b6cf8 100644 --- a/packages/frontend/src/boot/common.ts +++ b/packages/frontend/src/boot/common.ts @@ -5,29 +5,31 @@ import { computed, watch, version as vueVersion } from 'vue'; import { compareVersions } from 'compare-versions'; -import { version, lang, updateLocale, locale } from '@@/js/config.js'; +import { version, lang, updateLocale, locale, apiUrl } from '@@/js/config.js'; +import defaultLightTheme from '@@/themes/l-light.json5'; +import defaultDarkTheme from '@@/themes/d-green-lime.json5'; import type { App } from 'vue'; import widgets from '@/widgets/index.js'; import directives from '@/directives/index.js'; import components from '@/components/index.js'; -import { applyTheme } from '@/scripts/theme.js'; -import { isDeviceDarkmode } from '@/scripts/is-device-darkmode.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 { defaultStore, ColdDeviceStorage } from '@/store.js'; +import { refreshCurrentAccount, login } from '@/accounts.js'; +import { store } from '@/store.js'; import { fetchInstance, instance } from '@/instance.js'; -import { deviceKind, updateDeviceKind } from '@/scripts/device-kind.js'; -import { reloadChannel } from '@/scripts/unison-reload.js'; -import { getUrlWithoutLoginId } from '@/scripts/login-id.js'; -import { getAccountFromId } from '@/scripts/get-account-from-id.js'; +import { deviceKind, updateDeviceKind } from '@/utility/device-kind.js'; +import { reloadChannel } from '@/utility/unison-reload.js'; +import { getUrlWithoutLoginId } from '@/utility/login-id.js'; +import { getAccountFromId } from '@/utility/get-account-from-id.js'; import { deckStore } from '@/ui/deck/deck-store.js'; import { analytics, initAnalytics } from '@/analytics.js'; import { miLocalStorage } from '@/local-storage.js'; 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<Element>) { +export async function common(createVue: () => Promise<App<Element>>) { console.info(`Misskey v${version}`); if (_DEV_) { @@ -35,11 +37,6 @@ export async function common(createVue: () => App<Element>) { 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 = defaultStore; - window.addEventListener('error', event => { console.error(event); /* @@ -98,32 +95,32 @@ export async function common(createVue: () => App<Element>) { //#endregion // タッチデバイスでCSSの:hoverを機能させる - document.addEventListener('touchend', () => {}, { passive: true }); + window.document.addEventListener('touchend', () => {}, { passive: true }); // URLに#pswpを含む場合は取り除く - if (location.hash === '#pswp') { - history.replaceState(null, '', location.href.replace('#pswp', '')); + if (window.location.hash === '#pswp') { + window.history.replaceState(null, '', window.location.href.replace('#pswp', '')); } // 一斉リロード reloadChannel.addEventListener('message', path => { - if (path !== null) location.href = path; - else location.reload(); + if (path !== null) window.location.href = path; + else window.location.reload(); }); // If mobile, insert the viewport meta tag if (['smartphone', 'tablet'].includes(deviceKind)) { - const viewport = document.getElementsByName('viewport').item(0); + const viewport = window.document.getElementsByName('viewport').item(0); viewport.setAttribute('content', `${viewport.getAttribute('content')}, minimum-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover`); } //#region Set lang attr - const html = document.documentElement; + const html = window.document.documentElement; html.setAttribute('lang', lang); //#endregion - await defaultStore.ready; + await store.ready; await deckStore.ready; const fetchInstanceMetaPromise = fetchInstance(); @@ -133,11 +130,11 @@ export async function common(createVue: () => App<Element>) { }); //#region loginId - const params = new URLSearchParams(location.search); + const params = new URLSearchParams(window.location.search); const loginId = params.get('loginId'); if (loginId) { - const target = getUrlWithoutLoginId(location.href); + const target = getUrlWithoutLoginId(window.location.href); if (!$i || $i.id !== loginId) { const account = await getAccountFromId(loginId); @@ -146,81 +143,88 @@ export async function common(createVue: () => App<Element>) { } } - history.replaceState({ misskey: 'loginId' }, '', target); + window.history.replaceState({ misskey: 'loginId' }, '', target); } //#endregion // NOTE: この処理は必ずクライアント更新チェック処理より後に来ること(テーマ再構築のため) - watch(defaultStore.reactiveState.darkMode, (darkMode) => { - applyTheme(darkMode ? ColdDeviceStorage.get('darkTheme') : ColdDeviceStorage.get('lightTheme')); + watch(store.r.darkMode, (darkMode) => { + applyTheme(darkMode + ? (prefer.s.darkTheme ?? defaultDarkTheme) + : (prefer.s.lightTheme ?? defaultLightTheme), + ); }, { immediate: miLocalStorage.getItem('theme') == null }); - document.documentElement.dataset.colorScheme = defaultStore.state.darkMode ? 'dark' : 'light'; + window.document.documentElement.dataset.colorScheme = store.s.darkMode ? 'dark' : 'light'; - const darkTheme = computed(ColdDeviceStorage.makeGetterSetter('darkTheme')); - const lightTheme = computed(ColdDeviceStorage.makeGetterSetter('lightTheme')); + const darkTheme = prefer.model('darkTheme'); + const lightTheme = prefer.model('lightTheme'); watch(darkTheme, (theme) => { - if (defaultStore.state.darkMode) { - applyTheme(theme); + if (store.s.darkMode) { + applyTheme(theme ?? defaultDarkTheme); } }); watch(lightTheme, (theme) => { - if (!defaultStore.state.darkMode) { - applyTheme(theme); + if (!store.s.darkMode) { + applyTheme(theme ?? defaultLightTheme); } }); //#region Sync dark mode - if (ColdDeviceStorage.get('syncDeviceDarkMode')) { - defaultStore.set('darkMode', isDeviceDarkmode()); + if (prefer.s.syncDeviceDarkMode) { + store.set('darkMode', isDeviceDarkmode()); } window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (mql) => { - if (ColdDeviceStorage.get('syncDeviceDarkMode')) { - defaultStore.set('darkMode', mql.matches); + if (prefer.s.syncDeviceDarkMode) { + store.set('darkMode', mql.matches); } }); //#endregion + if (prefer.s.darkTheme && store.s.darkMode) { + if (miLocalStorage.getItem('themeId') !== prefer.s.darkTheme.id) applyTheme(prefer.s.darkTheme); + } else if (prefer.s.lightTheme && !store.s.darkMode) { + if (miLocalStorage.getItem('themeId') !== prefer.s.lightTheme.id) applyTheme(prefer.s.lightTheme); + } + fetchInstanceMetaPromise.then(() => { - if (defaultStore.state.themeInitial) { - if (instance.defaultLightTheme != null) ColdDeviceStorage.set('lightTheme', JSON.parse(instance.defaultLightTheme)); - if (instance.defaultDarkTheme != null) ColdDeviceStorage.set('darkTheme', JSON.parse(instance.defaultDarkTheme)); - defaultStore.set('themeInitial', false); - } + // TODO: instance.defaultLightTheme/instance.defaultDarkThemeが不正な形式だった場合のケア + if (prefer.s.lightTheme == null && instance.defaultLightTheme != null) prefer.commit('lightTheme', JSON.parse(instance.defaultLightTheme)); + if (prefer.s.darkTheme == null && instance.defaultDarkTheme != null) prefer.commit('darkTheme', JSON.parse(instance.defaultDarkTheme)); }); - watch(defaultStore.reactiveState.overridedDeviceKind, (kind) => { + watch(prefer.r.overridedDeviceKind, (kind) => { updateDeviceKind(kind); }, { immediate: true }); - watch(defaultStore.reactiveState.useBlurEffectForModal, v => { - document.documentElement.style.setProperty('--MI-modalBgFilter', v ? 'blur(4px)' : 'none'); + watch(prefer.r.useBlurEffectForModal, v => { + window.document.documentElement.style.setProperty('--MI-modalBgFilter', v ? 'blur(4px)' : 'none'); }, { immediate: true }); - watch(defaultStore.reactiveState.useBlurEffect, v => { + watch(prefer.r.useBlurEffect, v => { if (v) { - document.documentElement.style.removeProperty('--MI-blur'); + window.document.documentElement.style.removeProperty('--MI-blur'); } else { - document.documentElement.style.setProperty('--MI-blur', 'none'); + window.document.documentElement.style.setProperty('--MI-blur', 'none'); } }, { immediate: true }); // Keep screen on - const onVisibilityChange = () => document.addEventListener('visibilitychange', () => { - if (document.visibilityState === 'visible') { + const onVisibilityChange = () => window.document.addEventListener('visibilitychange', () => { + if (window.document.visibilityState === 'visible') { navigator.wakeLock.request('screen'); } }); - if (defaultStore.state.keepScreenOn && 'wakeLock' in navigator) { + if (prefer.s.keepScreenOn && 'wakeLock' in navigator) { navigator.wakeLock.request('screen') .then(onVisibilityChange) .catch(() => { // On WebKit-based browsers, user activation is required to send wake lock request // https://webkit.org/blog/13862/the-user-activation-api/ - document.addEventListener( + window.document.addEventListener( 'click', () => navigator.wakeLock.request('screen').then(onVisibilityChange), { once: true }, @@ -228,13 +232,17 @@ export async function common(createVue: () => App<Element>) { }); } + if (prefer.s.makeEveryTextElementsSelectable) { + window.document.documentElement.classList.add('forceSelectableAll'); + } + //#region Fetch user if ($i && $i.token) { if (_DEV_) { console.log('account cache found. refreshing...'); } - refreshAccount(); + refreshCurrentAccount(); } //#endregion @@ -255,9 +263,7 @@ export async function common(createVue: () => App<Element>) { }); }); - const app = createVue(); - - setupRouter(app, createMainRouter); + const app = await createVue(); if (_DEV_) { app.config.performance = true; @@ -272,19 +278,54 @@ export async function common(createVue: () => App<Element>) { const rootEl = ((): HTMLElement => { const MISSKEY_MOUNT_DIV_ID = 'misskey_app'; - const currentRoot = document.getElementById(MISSKEY_MOUNT_DIV_ID); + const currentRoot = window.document.getElementById(MISSKEY_MOUNT_DIV_ID); if (currentRoot) { console.warn('multiple import detected'); return currentRoot; } - const root = document.createElement('div'); + const root = window.document.createElement('div'); root.id = MISSKEY_MOUNT_DIV_ID; - document.body.appendChild(root); + window.document.body.appendChild(root); return root; })(); + if (instance.sentryForFrontend) { + const Sentry = await import('@sentry/vue'); + Sentry.init({ + app, + integrations: [ + ...(instance.sentryForFrontend.vueIntegration !== undefined ? [ + Sentry.vueIntegration(instance.sentryForFrontend.vueIntegration ?? undefined), + ] : []), + ...(instance.sentryForFrontend.browserTracingIntegration !== undefined ? [ + Sentry.browserTracingIntegration(instance.sentryForFrontend.browserTracingIntegration ?? undefined), + ] : []), + ...(instance.sentryForFrontend.replayIntegration !== undefined ? [ + Sentry.replayIntegration(instance.sentryForFrontend.replayIntegration ?? undefined), + ] : []), + ], + + // Set tracesSampleRate to 1.0 to capture 100% + tracesSampleRate: 1.0, + + // Set `tracePropagationTargets` to control for which URLs distributed tracing should be enabled + ...(instance.sentryForFrontend.browserTracingIntegration !== undefined ? { + tracePropagationTargets: [apiUrl], + } : {}), + + // Capture Replay for 10% of all sessions, + // plus for 100% of sessions with an error + ...(instance.sentryForFrontend.replayIntegration !== undefined ? { + replaysSessionSampleRate: 0.1, + replaysOnErrorSampleRate: 1.0, + } : {}), + + ...instance.sentryForFrontend.options, + }); + } + app.mount(rootEl); // boot.jsのやつを解除 @@ -294,34 +335,37 @@ export async function common(createVue: () => App<Element>) { removeSplash(); //#region Self-XSS 対策メッセージ - console.log( - `%c${i18n.ts._selfXssPrevention.warning}`, - 'color: #f00; background-color: #ff0; font-size: 36px; padding: 4px;', - ); - console.log( - `%c${i18n.ts._selfXssPrevention.title}`, - 'color: #f00; font-weight: 900; font-family: "Hiragino Sans W9", "Hiragino Kaku Gothic ProN", sans-serif; font-size: 24px;', - ); - console.log( - `%c${i18n.ts._selfXssPrevention.description1}`, - 'font-size: 16px; font-weight: 700;', - ); - console.log( - `%c${i18n.ts._selfXssPrevention.description2}`, - 'font-size: 16px;', - 'font-size: 20px; font-weight: 700; color: #f00;', - ); - console.log(i18n.tsx._selfXssPrevention.description3({ link: 'https://misskey-hub.net/docs/for-users/resources/self-xss/' })); + if (!_DEV_) { + console.log( + `%c${i18n.ts._selfXssPrevention.warning}`, + 'color: #f00; background-color: #ff0; font-size: 36px; padding: 4px;', + ); + console.log( + `%c${i18n.ts._selfXssPrevention.title}`, + 'color: #f00; font-weight: 900; font-family: "Hiragino Sans W9", "Hiragino Kaku Gothic ProN", sans-serif; font-size: 24px;', + ); + console.log( + `%c${i18n.ts._selfXssPrevention.description1}`, + 'font-size: 16px; font-weight: 700;', + ); + console.log( + `%c${i18n.ts._selfXssPrevention.description2}`, + 'font-size: 16px;', + 'font-size: 20px; font-weight: 700; color: #f00;', + ); + console.log(i18n.tsx._selfXssPrevention.description3({ link: 'https://misskey-hub.net/docs/for-users/resources/self-xss/' })); + } //#endregion return { isClientUpdated, + lastVersion, app, }; } function removeSplash() { - const splash = document.getElementById('splash'); + const splash = window.document.getElementById('splash'); if (splash) { splash.style.opacity = '0'; splash.style.pointerEvents = 'none'; diff --git a/packages/frontend/src/boot/main-boot.ts b/packages/frontend/src/boot/main-boot.ts index 3a43c6794b..fad6ce3825 100644 --- a/packages/frontend/src/boot/main-boot.ts +++ b/packages/frontend/src/boot/main-boot.ts @@ -5,55 +5,57 @@ import { createApp, defineAsyncComponent, markRaw } from 'vue'; import { ui } from '@@/js/config.js'; -import { common } from './common.js'; import * as Misskey from 'misskey-js'; +import { compareVersions } from 'compare-versions'; +import { common } from './common.js'; import type { Component } from 'vue'; +import type { Keymap } from '@/utility/hotkey.js'; import { i18n } from '@/i18n.js'; -import { alert, confirm, popup, post, toast } from '@/os.js'; +import { alert, confirm, popup, post } from '@/os.js'; import { useStream } from '@/stream.js'; -import * as sound from '@/scripts/sound.js'; -import { $i, signout, updateAccountPartial } from '@/account.js'; +import * as sound from '@/utility/sound.js'; +import { $i } from '@/i.js'; import { instance } from '@/instance.js'; -import { ColdDeviceStorage, defaultStore } from '@/store.js'; -import { reactionPicker } from '@/scripts/reaction-picker.js'; +import { store } from '@/store.js'; +import { reactionPicker } from '@/utility/reaction-picker.js'; import { miLocalStorage } from '@/local-storage.js'; -import { claimAchievement, claimedAchievements } from '@/scripts/achievements.js'; -import { initializeSw } from '@/scripts/initialize-sw.js'; -import { deckStore } from '@/ui/deck/deck-store.js'; -import { emojiPicker } from '@/scripts/emoji-picker.js'; -import { mainRouter } from '@/router/main.js'; -import { makeHotkey } from '@/scripts/hotkey.js'; -import type { Keymap } from '@/scripts/hotkey.js'; +import { claimAchievement, claimedAchievements } from '@/utility/achievements.js'; +import { initializeSw } from '@/utility/initialize-sw.js'; +import { emojiPicker } from '@/utility/emoji-picker.js'; +import { mainRouter } from '@/router.js'; +import { makeHotkey } from '@/utility/hotkey.js'; import { addCustomEmoji, removeCustomEmojis, updateCustomEmojis } from '@/custom-emojis.js'; +import { prefer } from '@/preferences.js'; +import { launchPlugins } from '@/plugin.js'; +import { updateCurrentAccountPartial } from '@/accounts.js'; +import { signout } from '@/signout.js'; +import { migrateOldSettings } from '@/pref-migrate.js'; export async function mainBoot() { - const { isClientUpdated } = await common(() => { + const { isClientUpdated, lastVersion } = await common(async () => { let uiStyle = ui; const searchParams = new URLSearchParams(window.location.search); if (!$i) uiStyle = 'visitor'; if (searchParams.has('zen')) uiStyle = 'zen'; - if (uiStyle === 'deck' && deckStore.state.useSimpleUiForNonRootPages && location.pathname !== '/') uiStyle = 'zen'; + if (uiStyle === 'deck' && prefer.s['deck.useSimpleUiForNonRootPages'] && window.location.pathname !== '/') uiStyle = 'zen'; if (searchParams.has('ui')) uiStyle = searchParams.get('ui'); let rootComponent: Component; switch (uiStyle) { case 'zen': - rootComponent = defineAsyncComponent(() => import('@/ui/zen.vue')); + rootComponent = await import('@/ui/zen.vue').then(x => x.default); break; case 'deck': - rootComponent = defineAsyncComponent(() => import('@/ui/deck.vue')); + rootComponent = await import('@/ui/deck.vue').then(x => x.default); break; case 'visitor': - rootComponent = defineAsyncComponent(() => import('@/ui/visitor.vue')); - break; - case 'classic': - rootComponent = defineAsyncComponent(() => import('@/ui/classic.vue')); + rootComponent = await import('@/ui/visitor.vue').then(x => x.default); break; default: - rootComponent = defineAsyncComponent(() => import('@/ui/universal.vue')); + rootComponent = await import('@/ui/universal.vue').then(x => x.default); break; } @@ -67,15 +69,23 @@ export async function mainBoot() { const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkUpdated.vue')), {}, { closed: () => dispose(), }); + + // prefereces migration + // TODO: そのうち消す + if (lastVersion && (compareVersions('2025.3.2-alpha.0', lastVersion) === 1)) { + console.log('Preferences migration'); + + migrateOldSettings(); + } } const stream = useStream(); let reloadDialogShowing = false; stream.on('_disconnected_', async () => { - if (defaultStore.state.serverDisconnectedBehavior === 'reload') { - location.reload(); - } else if (defaultStore.state.serverDisconnectedBehavior === 'dialog') { + if (prefer.s.serverDisconnectedBehavior === 'reload') { + window.location.reload(); + } else if (prefer.s.serverDisconnectedBehavior === 'dialog') { if (reloadDialogShowing) return; reloadDialogShowing = true; const { canceled } = await confirm({ @@ -85,7 +95,7 @@ export async function mainBoot() { }); reloadDialogShowing = false; if (!canceled) { - location.reload(); + window.location.reload(); } } }); @@ -102,30 +112,24 @@ export async function mainBoot() { removeCustomEmojis(emojiData.emojis); }); - for (const plugin of ColdDeviceStorage.get('plugins').filter(p => p.active)) { - import('@/plugin.js').then(async ({ install }) => { - // Workaround for https://bugs.webkit.org/show_bug.cgi?id=242740 - await new Promise(r => setTimeout(r, 0)); - install(plugin); - }); - } + launchPlugins(); try { - if (defaultStore.state.enableSeasonalScreenEffect) { + if (prefer.s.enableSeasonalScreenEffect) { const month = new Date().getMonth() + 1; - if (defaultStore.state.hemisphere === 'S') { + if (prefer.s.hemisphere === 'S') { // ▼南半球 if (month === 7 || month === 8) { - const SnowfallEffect = (await import('@/scripts/snowfall-effect.js')).SnowfallEffect; + const SnowfallEffect = (await import('@/utility/snowfall-effect.js')).SnowfallEffect; new SnowfallEffect({}).render(); } } else { // ▼北半球 if (month === 12 || month === 1) { - const SnowfallEffect = (await import('@/scripts/snowfall-effect.js')).SnowfallEffect; + const SnowfallEffect = (await import('@/utility/snowfall-effect.js')).SnowfallEffect; new SnowfallEffect({}).render(); } else if (month === 3 || month === 4) { - const SakuraEffect = (await import('@/scripts/snowfall-effect.js')).SnowfallEffect; + const SakuraEffect = (await import('@/utility/snowfall-effect.js')).SnowfallEffect; new SakuraEffect({ sakura: true, }).render(); @@ -138,8 +142,8 @@ export async function mainBoot() { } if ($i) { - defaultStore.loaded.then(() => { - if (defaultStore.state.accountSetupWizard !== -1) { + store.loaded.then(async () => { + if (store.s.accountSetupWizard !== -1) { const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkUserSetupDialog.vue')), {}, { closed: () => dispose(), }); @@ -154,7 +158,7 @@ export async function mainBoot() { }); } - function onAnnouncementCreated (ev: { announcement: Misskey.entities.Announcement }) { + function onAnnouncementCreated(ev: { announcement: Misskey.entities.Announcement }) { const announcement = ev.announcement; if (announcement.display === 'dialog') { const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkAnnouncementDialog.vue')), { @@ -262,7 +266,7 @@ export async function mainBoot() { let lastVisibilityChangedAt = Date.now(); function claimPlainLucky() { - if (document.visibilityState !== 'visible') { + if (window.document.visibilityState !== 'visible') { if (justPlainLuckyTimer != null) window.clearTimeout(justPlainLuckyTimer); return; } @@ -277,7 +281,7 @@ export async function mainBoot() { window.addEventListener('visibilitychange', () => { const now = Date.now(); - if (document.visibilityState === 'visible') { + if (window.document.visibilityState === 'visible') { // タブを高速で切り替えたら取得処理が何度も走るのを防ぐ if ((now - lastVisibilityChangedAt) < 1000 * 10) { justPlainLuckyTimer = window.setTimeout(claimPlainLucky, 1000 * 10); @@ -322,7 +326,7 @@ export async function mainBoot() { const latestDonationInfoShownAt = miLocalStorage.getItem('latestDonationInfoShownAt'); const neverShowDonationInfo = miLocalStorage.getItem('neverShowDonationInfo'); - if (neverShowDonationInfo !== 'true' && (createdAt.getTime() < (Date.now() - (1000 * 60 * 60 * 24 * 3))) && !location.pathname.startsWith('/miauth')) { + if (neverShowDonationInfo !== 'true' && (createdAt.getTime() < (Date.now() - (1000 * 60 * 60 * 24 * 3))) && !window.location.pathname.startsWith('/miauth')) { if (latestDonationInfoShownAt == null || (new Date(latestDonationInfoShownAt).getTime() < (Date.now() - (1000 * 60 * 60 * 24 * 30)))) { const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkDonation.vue')), {}, { closed: () => dispose(), @@ -348,11 +352,11 @@ export async function mainBoot() { // 自分の情報が更新されたとき main.on('meUpdated', i => { - updateAccountPartial(i); + updateCurrentAccountPartial(i); }); main.on('readAllNotifications', () => { - updateAccountPartial({ + updateCurrentAccountPartial({ hasUnreadNotification: false, unreadNotificationsCount: 0, }); @@ -360,39 +364,24 @@ export async function mainBoot() { main.on('unreadNotification', () => { const unreadNotificationsCount = ($i?.unreadNotificationsCount ?? 0) + 1; - updateAccountPartial({ + updateCurrentAccountPartial({ hasUnreadNotification: true, unreadNotificationsCount, }); }); - main.on('unreadMention', () => { - updateAccountPartial({ hasUnreadMentions: true }); - }); - - main.on('readAllUnreadMentions', () => { - updateAccountPartial({ hasUnreadMentions: false }); - }); - - main.on('unreadSpecifiedNote', () => { - updateAccountPartial({ hasUnreadSpecifiedNotes: true }); - }); - - main.on('readAllUnreadSpecifiedNotes', () => { - updateAccountPartial({ hasUnreadSpecifiedNotes: false }); - }); - - main.on('readAllAntennas', () => { - updateAccountPartial({ hasUnreadAntenna: false }); - }); - main.on('unreadAntenna', () => { - updateAccountPartial({ hasUnreadAntenna: true }); + updateCurrentAccountPartial({ hasUnreadAntenna: true }); sound.playMisskeySfx('antenna'); }); + main.on('newChatMessage', () => { + updateCurrentAccountPartial({ hasUnreadChatMessages: true }); + sound.playMisskeySfx('chatMessage'); + }); + main.on('readAllAnnouncements', () => { - updateAccountPartial({ hasUnreadAnnouncement: false }); + updateCurrentAccountPartial({ hasUnreadAnnouncement: false }); }); // 個人宛てお知らせが発行されたとき @@ -412,13 +401,13 @@ export async function mainBoot() { post(); }, 'd': () => { - defaultStore.set('darkMode', !defaultStore.state.darkMode); + store.set('darkMode', !store.s.darkMode); }, 's': () => { mainRouter.push('/search'); }, } as const satisfies Keymap; - document.addEventListener('keydown', makeHotkey(keymap), { passive: false }); + window.document.addEventListener('keydown', makeHotkey(keymap), { passive: false }); initializeSw(); } diff --git a/packages/frontend/src/boot/sub-boot.ts b/packages/frontend/src/boot/sub-boot.ts index 35c84d5568..036142bc4d 100644 --- a/packages/frontend/src/boot/sub-boot.ts +++ b/packages/frontend/src/boot/sub-boot.ts @@ -5,12 +5,11 @@ import { createApp, defineAsyncComponent } from 'vue'; import { common } from './common.js'; -import { emojiPicker } from '@/scripts/emoji-picker.js'; +import { emojiPicker } from '@/utility/emoji-picker.js'; +import UiMinimum from '@/ui/minimum.vue'; export async function subBoot() { - const { isClientUpdated } = await common(() => createApp( - defineAsyncComponent(() => import('@/ui/minimum.vue')), - )); + const { isClientUpdated } = await common(async () => createApp(UiMinimum)); emojiPicker.init(); } diff --git a/packages/frontend/src/cache.ts b/packages/frontend/src/cache.ts index bfe8fbe0e4..70078b410d 100644 --- a/packages/frontend/src/cache.ts +++ b/packages/frontend/src/cache.ts @@ -4,8 +4,8 @@ */ import * as Misskey from 'misskey-js'; -import { Cache } from '@/scripts/cache.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { Cache } from '@/utility/cache.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; export const clipsCache = new Cache<Misskey.entities.Clip[]>(1000 * 60 * 30, () => misskeyApi('clips/list')); export const rolesCache = new Cache(1000 * 60 * 30, () => misskeyApi('admin/roles/list')); diff --git a/packages/frontend/src/components/MkAbuseReport.vue b/packages/frontend/src/components/MkAbuseReport.vue index e48b6ef781..c7252e7c98 100644 --- a/packages/frontend/src/components/MkAbuseReport.vue +++ b/packages/frontend/src/components/MkAbuseReport.vue @@ -35,7 +35,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template #label>{{ i18n.ts.target }}: <MkAcct :user="report.targetUser"/></template> <template #suffix>#{{ report.targetUserId.toUpperCase() }}</template> - <div style="container-type: inline-size;"> + <div style="height: 300px; --MI-stickyTop: 0; --MI-stickyBottom: 0;"> <RouterView :router="targetRouter"/> </div> </MkFolder> @@ -53,7 +53,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template #label>{{ i18n.ts.reporter }}: <MkAcct :user="report.reporter"/></template> <template #suffix>#{{ report.reporterId.toUpperCase() }}</template> - <div style="container-type: inline-size;"> + <div style="height: 300px; --MI-stickyTop: 0; --MI-stickyBottom: 0;"> <RouterView :router="reporterRouter"/> </div> </MkFolder> @@ -88,9 +88,9 @@ import { i18n } from '@/i18n.js'; import { dateString } from '@/filters/date.js'; import MkFolder from '@/components/MkFolder.vue'; import RouterView from '@/components/global/RouterView.vue'; -import { useRouterFactory } from '@/router/supplier'; import MkTextarea from '@/components/MkTextarea.vue'; -import { copyToClipboard } from '@/scripts/copy-to-clipboard.js'; +import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; +import { createRouter } from '@/router.js'; const props = defineProps<{ report: Misskey.entities.AdminAbuseUserReportsResponse[number]; @@ -100,10 +100,9 @@ const emit = defineEmits<{ (ev: 'resolved', reportId: string): void; }>(); -const routerFactory = useRouterFactory(); -const targetRouter = routerFactory(`/admin/user/${props.report.targetUserId}`); +const targetRouter = createRouter(`/admin/user/${props.report.targetUserId}`); targetRouter.init(); -const reporterRouter = routerFactory(`/admin/user/${props.report.reporterId}`); +const reporterRouter = createRouter(`/admin/user/${props.report.reporterId}`); reporterRouter.init(); const moderationNote = ref(props.report.moderationNote ?? ''); @@ -135,7 +134,7 @@ function forward() { function showMenu(ev: MouseEvent) { os.popupMenu([{ - icon: 'ti ti-id', + icon: 'ti ti-hash', text: 'Copy ID', action: () => { copyToClipboard(props.report.id); diff --git a/packages/frontend/src/components/MkAbuseReportWindow.vue b/packages/frontend/src/components/MkAbuseReportWindow.vue index a634a748e9..dbac5e9dd7 100644 --- a/packages/frontend/src/components/MkAbuseReportWindow.vue +++ b/packages/frontend/src/components/MkAbuseReportWindow.vue @@ -30,7 +30,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script setup lang="ts"> -import { ref, shallowRef } from 'vue'; +import { ref, useTemplateRef } from 'vue'; import * as Misskey from 'misskey-js'; import MkWindow from '@/components/MkWindow.vue'; import MkTextarea from '@/components/MkTextarea.vue'; @@ -47,7 +47,7 @@ const emit = defineEmits<{ (ev: 'closed'): void; }>(); -const uiWindow = shallowRef<InstanceType<typeof MkWindow>>(); +const uiWindow = useTemplateRef('uiWindow'); const comment = ref(props.initialComment ?? ''); function send() { diff --git a/packages/frontend/src/components/MkAccountMoved.vue b/packages/frontend/src/components/MkAccountMoved.vue index 0839955d9d..cb8032c019 100644 --- a/packages/frontend/src/components/MkAccountMoved.vue +++ b/packages/frontend/src/components/MkAccountMoved.vue @@ -17,7 +17,7 @@ import * as Misskey from 'misskey-js'; import MkMention from './MkMention.vue'; import { i18n } from '@/i18n.js'; import { host as localHost } from '@@/js/config.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; const user = ref<Misskey.entities.UserLite>(); diff --git a/packages/frontend/src/components/MkAchievements.stories.impl.ts b/packages/frontend/src/components/MkAchievements.stories.impl.ts index bbd3f69d7c..d838997616 100644 --- a/packages/frontend/src/components/MkAchievements.stories.impl.ts +++ b/packages/frontend/src/components/MkAchievements.stories.impl.ts @@ -9,7 +9,7 @@ import { HttpResponse, http } from 'msw'; import { userDetailed } from '../../.storybook/fakes.js'; import { commonHandlers } from '../../.storybook/mocks.js'; import MkAchievements from './MkAchievements.vue'; -import { ACHIEVEMENT_TYPES } from '@/scripts/achievements.js'; +import { ACHIEVEMENT_TYPES } from '@/utility/achievements.js'; export const Empty = { render(args) { return { diff --git a/packages/frontend/src/components/MkAchievements.vue b/packages/frontend/src/components/MkAchievements.vue index c8134416b5..70766634ce 100644 --- a/packages/frontend/src/components/MkAchievements.vue +++ b/packages/frontend/src/components/MkAchievements.vue @@ -55,9 +55,9 @@ SPDX-License-Identifier: AGPL-3.0-only import * as Misskey from 'misskey-js'; import { onMounted, ref, computed } from 'vue'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; -import { ACHIEVEMENT_TYPES, ACHIEVEMENT_BADGES, claimAchievement } from '@/scripts/achievements.js'; +import { ACHIEVEMENT_TYPES, ACHIEVEMENT_BADGES, claimAchievement } from '@/utility/achievements.js'; const props = withDefaults(defineProps<{ user: Misskey.entities.User; diff --git a/packages/frontend/src/components/MkAnalogClock.vue b/packages/frontend/src/components/MkAnalogClock.vue index c8fa6246e0..eac1ea9534 100644 --- a/packages/frontend/src/components/MkAnalogClock.vue +++ b/packages/frontend/src/components/MkAnalogClock.vue @@ -82,7 +82,7 @@ SPDX-License-Identifier: AGPL-3.0-only import { computed, onMounted, onBeforeUnmount, ref } from 'vue'; import tinycolor from 'tinycolor2'; import { globalEvents } from '@/events.js'; -import { defaultIdlingRenderScheduler } from '@/scripts/idle-render.js'; +import { defaultIdlingRenderScheduler } from '@/utility/idle-render.js'; // https://stackoverflow.com/questions/1878907/how-can-i-find-the-difference-between-two-angles const angleDiff = (a: number, b: number) => { @@ -192,7 +192,7 @@ function tick() { tick(); function calcColors() { - const computedStyle = getComputedStyle(document.documentElement); + const computedStyle = getComputedStyle(window.document.documentElement); const dark = tinycolor(computedStyle.getPropertyValue('--MI_THEME-bg')).isDark(); const accent = tinycolor(computedStyle.getPropertyValue('--MI_THEME-accent')).toHexString(); majorGraduationColor.value = dark ? 'rgba(255, 255, 255, 0.3)' : 'rgba(0, 0, 0, 0.3)'; diff --git a/packages/frontend/src/components/MkAnimBg.vue b/packages/frontend/src/components/MkAnimBg.vue index 4bf6125af5..e57fbcdee3 100644 --- a/packages/frontend/src/components/MkAnimBg.vue +++ b/packages/frontend/src/components/MkAnimBg.vue @@ -4,14 +4,14 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<canvas ref="canvasEl" style="width: 100%; height: 100%; pointer-events: none;"></canvas> +<canvas ref="canvasEl" style="display: block; width: 100%; height: 100%; pointer-events: none;"></canvas> </template> <script lang="ts" setup> -import { onMounted, onUnmounted, shallowRef } from 'vue'; +import { onMounted, onUnmounted, useTemplateRef } from 'vue'; import isChromatic from 'chromatic/isChromatic'; -const canvasEl = shallowRef<HTMLCanvasElement>(); +const canvasEl = useTemplateRef('canvasEl'); const props = withDefaults(defineProps<{ scale?: number; diff --git a/packages/frontend/src/components/MkAnnouncementDialog.vue b/packages/frontend/src/components/MkAnnouncementDialog.vue index 3045a47585..6e5b29654b 100644 --- a/packages/frontend/src/components/MkAnnouncementDialog.vue +++ b/packages/frontend/src/components/MkAnnouncementDialog.vue @@ -22,22 +22,23 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted, shallowRef } from 'vue'; +import { onMounted, useTemplateRef } from 'vue'; import * as Misskey from 'misskey-js'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +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; }>(), { }); -const rootEl = shallowRef<HTMLDivElement>(); -const modal = shallowRef<InstanceType<typeof MkModal>>(); +const rootEl = useTemplateRef('rootEl'); +const modal = useTemplateRef('modal'); async function ok() { if (props.announcement.needConfirmationToRead) { @@ -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/MkAntennaEditor.vue b/packages/frontend/src/components/MkAntennaEditor.vue index e622d57f1e..59099d54bd 100644 --- a/packages/frontend/src/components/MkAntennaEditor.vue +++ b/packages/frontend/src/components/MkAntennaEditor.vue @@ -39,6 +39,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkSwitch v-model="localOnly">{{ i18n.ts.localOnly }}</MkSwitch> <MkSwitch v-model="caseSensitive">{{ i18n.ts.caseSensitive }}</MkSwitch> <MkSwitch v-model="withFile">{{ i18n.ts.withFileAntenna }}</MkSwitch> + <MkSwitch v-model="excludeNotesInSensitiveChannel">{{ i18n.ts.excludeNotesInSensitiveChannel }}</MkSwitch> </div> <div :class="$style.actions"> <div class="_buttons"> @@ -53,16 +54,16 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { watch, ref } from 'vue'; import * as Misskey from 'misskey-js'; +import type { DeepPartial } from '@/utility/merge.js'; import MkButton from '@/components/MkButton.vue'; import MkInput from '@/components/MkInput.vue'; import MkTextarea from '@/components/MkTextarea.vue'; import MkSelect from '@/components/MkSelect.vue'; import MkSwitch from '@/components/MkSwitch.vue'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; -import { deepMerge } from '@/scripts/merge.js'; -import type { DeepPartial } from '@/scripts/merge.js'; +import { deepMerge } from '@/utility/merge.js'; type PartialAllowedAntenna = Omit<Misskey.entities.Antenna, 'id' | 'createdAt' | 'updatedAt'> & { id?: string; @@ -86,6 +87,7 @@ const initialAntenna = deepMerge<PartialAllowedAntenna>(props.antenna ?? {}, { caseSensitive: false, localOnly: false, withFile: false, + excludeNotesInSensitiveChannel: false, isActive: true, hasUnreadNote: false, notify: false, @@ -108,6 +110,7 @@ const localOnly = ref<boolean>(initialAntenna.localOnly); const excludeBots = ref<boolean>(initialAntenna.excludeBots); const withReplies = ref<boolean>(initialAntenna.withReplies); const withFile = ref<boolean>(initialAntenna.withFile); +const excludeNotesInSensitiveChannel = ref<boolean>(initialAntenna.excludeNotesInSensitiveChannel); const userLists = ref<Misskey.entities.UserList[] | null>(null); watch(() => src.value, async () => { @@ -124,6 +127,7 @@ async function saveAntenna() { excludeBots: excludeBots.value, withReplies: withReplies.value, withFile: withFile.value, + excludeNotesInSensitiveChannel: excludeNotesInSensitiveChannel.value, caseSensitive: caseSensitive.value, localOnly: localOnly.value, users: users.value.trim().split('\n').map(x => x.trim()), diff --git a/packages/frontend/src/components/MkAntennaEditorDialog.vue b/packages/frontend/src/components/MkAntennaEditorDialog.vue index 6d815d29f3..0ebf5abf4c 100644 --- a/packages/frontend/src/components/MkAntennaEditorDialog.vue +++ b/packages/frontend/src/components/MkAntennaEditorDialog.vue @@ -23,7 +23,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { shallowRef } from 'vue'; +import { useTemplateRef } from 'vue'; import * as Misskey from 'misskey-js'; import MkModalWindow from '@/components/MkModalWindow.vue'; import XAntennaEditor from '@/components/MkAntennaEditor.vue'; @@ -40,7 +40,7 @@ const emit = defineEmits<{ (ev: 'closed'): void, }>(); -const dialog = shallowRef<InstanceType<typeof MkModalWindow>>(); +const dialog = useTemplateRef('dialog'); function onAntennaCreated(newAntenna: Misskey.entities.Antenna) { emit('created', newAntenna); diff --git a/packages/frontend/src/components/MkAsUi.vue b/packages/frontend/src/components/MkAsUi.vue index 5c4d887e0c..20a953c72c 100644 --- a/packages/frontend/src/components/MkAsUi.vue +++ b/packages/frontend/src/components/MkAsUi.vue @@ -71,7 +71,7 @@ import MkInput from '@/components/MkInput.vue'; import MkSwitch from '@/components/MkSwitch.vue'; import MkTextarea from '@/components/MkTextarea.vue'; import MkSelect from '@/components/MkSelect.vue'; -import type { AsUiComponent, AsUiRoot, AsUiPostFormButton } from '@/scripts/aiscript/ui.js'; +import type { AsUiComponent, AsUiRoot, AsUiPostFormButton } from '@/aiscript/ui.js'; import MkFolder from '@/components/MkFolder.vue'; import MkPostForm from '@/components/MkPostForm.vue'; diff --git a/packages/frontend/src/components/MkAuthConfirm.vue b/packages/frontend/src/components/MkAuthConfirm.vue index f78d2d38f0..00bf8e68d9 100644 --- a/packages/frontend/src/components/MkAuthConfirm.vue +++ b/packages/frontend/src/components/MkAuthConfirm.vue @@ -117,14 +117,13 @@ SPDX-License-Identifier: AGPL-3.0-only <script setup lang="ts"> import { ref, computed } from 'vue'; import * as Misskey from 'misskey-js'; - import MkButton from '@/components/MkButton.vue'; - -import { $i, getAccounts, getAccountWithSigninDialog, getAccountWithSignupDialog } from '@/account.js'; +import { $i } from '@/i.js'; +import { getAccounts, getAccountWithSigninDialog, getAccountWithSignupDialog } from '@/accounts.js'; import { i18n } from '@/i18n.js'; import * as os from '@/os.js'; -import { getProxiedImageUrl } from '@/scripts/media-proxy.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { getProxiedImageUrl } from '@/utility/media-proxy.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; const props = defineProps<{ name?: string; @@ -158,7 +157,7 @@ async function init() { const accounts = await getAccounts(); - const accountIdsToFetch = accounts.map(a => a.id).filter(id => !users.value.has(id)); + const accountIdsToFetch = accounts.map(a => a.user.id).filter(id => !users.value.has(id)); if (accountIdsToFetch.length > 0) { const usersRes = await misskeyApi('users/show', { @@ -170,7 +169,7 @@ async function init() { users.value.set(user.id, { ...user, - token: accounts.find(a => a.id === user.id)!.token, + token: accounts.find(a => a.user.id === user.id)!.token, }); } } diff --git a/packages/frontend/src/components/MkAutocomplete.stories.impl.ts b/packages/frontend/src/components/MkAutocomplete.stories.impl.ts index af5dd4784d..64ccb708aa 100644 --- a/packages/frontend/src/components/MkAutocomplete.stories.impl.ts +++ b/packages/frontend/src/components/MkAutocomplete.stories.impl.ts @@ -12,7 +12,7 @@ import { userDetailed } from '../../.storybook/fakes.js'; import { commonHandlers } from '../../.storybook/mocks.js'; import MkAutocomplete from './MkAutocomplete.vue'; import MkInput from './MkInput.vue'; -import { tick } from '@/scripts/test-utils.js'; +import { tick } from '@/utility/test-utils.js'; const common = { render(args) { return { diff --git a/packages/frontend/src/components/MkAutocomplete.vue b/packages/frontend/src/components/MkAutocomplete.vue index 33495c8af6..e51a56fa7b 100644 --- a/packages/frontend/src/components/MkAutocomplete.vue +++ b/packages/frontend/src/components/MkAutocomplete.vue @@ -44,27 +44,28 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts"> -import { markRaw, ref, shallowRef, computed, onUpdated, onMounted, onBeforeUnmount, nextTick, watch } from 'vue'; +import { markRaw, ref, useTemplateRef, computed, onUpdated, onMounted, onBeforeUnmount, nextTick, watch } from 'vue'; import sanitizeHtml from 'sanitize-html'; import { emojilist, getEmojiName } from '@@/js/emojilist.js'; import { char2twemojiFilePath, char2fluentEmojiFilePath } from '@@/js/emoji-base.js'; import { MFM_TAGS, MFM_PARAMS } from '@@/js/const.js'; -import type { EmojiDef } from '@/scripts/search-emoji.js'; -import contains from '@/scripts/contains.js'; +import type { EmojiDef } from '@/utility/search-emoji.js'; +import contains from '@/utility/contains.js'; import { acct } from '@/filters/user.js'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { defaultStore } from '@/store.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { store } from '@/store.js'; import { i18n } from '@/i18n.js'; import { miLocalStorage } from '@/local-storage.js'; import { customEmojis } from '@/custom-emojis.js'; -import { searchEmoji } from '@/scripts/search-emoji.js'; +import { searchEmoji } from '@/utility/search-emoji.js'; +import { prefer } from '@/preferences.js'; const lib = emojilist.filter(x => x.category !== 'flags'); const emojiDb = computed(() => { //#region Unicode Emoji - const char2path = defaultStore.reactiveState.emojiStyle.value === 'twemoji' ? char2twemojiFilePath : char2fluentEmojiFilePath; + const char2path = prefer.r.emojiStyle.value === 'twemoji' ? char2twemojiFilePath : char2fluentEmojiFilePath; const unicodeEmojiDB: EmojiDef[] = lib.map(x => ({ emoji: x.char, @@ -72,7 +73,7 @@ const emojiDb = computed(() => { url: char2path(x.char), })); - for (const index of Object.values(defaultStore.state.additionalUnicodeEmojiIndexes)) { + for (const index of Object.values(store.s.additionalUnicodeEmojiIndexes)) { for (const [emoji, keywords] of Object.entries(index)) { for (const k of keywords) { unicodeEmojiDB.push({ @@ -138,7 +139,7 @@ const emit = defineEmits<{ }>(); const suggests = ref<Element>(); -const rootEl = shallowRef<HTMLDivElement>(); +const rootEl = useTemplateRef('rootEl'); const fetching = ref(true); const users = ref<any[]>([]); @@ -154,10 +155,10 @@ function complete(type: string, value: any) { emit('done', { type, value }); emit('closed'); if (type === 'emoji') { - let recents = defaultStore.state.recentlyUsedEmojis; + let recents = store.s.recentlyUsedEmojis; recents = recents.filter((emoji: any) => emoji !== value); recents.unshift(value); - defaultStore.set('recentlyUsedEmojis', recents.splice(0, 32)); + store.set('recentlyUsedEmojis', recents.splice(0, 32)); } } @@ -237,7 +238,7 @@ function exec() { } else if (props.type === 'emoji') { if (!props.q || props.q === '') { // 最近使った絵文字をサジェスト - emojis.value = defaultStore.state.recentlyUsedEmojis.map(emoji => emojiDb.value.find(dbEmoji => dbEmoji.emoji === emoji)).filter(x => x) as EmojiDef[]; + emojis.value = store.s.recentlyUsedEmojis.map(emoji => emojiDb.value.find(dbEmoji => dbEmoji.emoji === emoji)).filter(x => x) as EmojiDef[]; return; } @@ -358,7 +359,7 @@ onMounted(() => { props.textarea.addEventListener('keydown', onKeydown); - document.body.addEventListener('mousedown', onMousedown); + window.document.body.addEventListener('mousedown', onMousedown); nextTick(() => { exec(); @@ -374,7 +375,7 @@ onMounted(() => { onBeforeUnmount(() => { props.textarea.removeEventListener('keydown', onKeydown); - document.body.removeEventListener('mousedown', onMousedown); + window.document.body.removeEventListener('mousedown', onMousedown); }); </script> @@ -419,7 +420,7 @@ onBeforeUnmount(() => { } &:active { - background: var(--MI_THEME-accentDarken); + background: hsl(from var(--MI_THEME-accent) h s calc(l - 10)); color: #fff !important; } } diff --git a/packages/frontend/src/components/MkAvatars.vue b/packages/frontend/src/components/MkAvatars.vue index 8236d0ddb9..1c44ed60d8 100644 --- a/packages/frontend/src/components/MkAvatars.vue +++ b/packages/frontend/src/components/MkAvatars.vue @@ -15,7 +15,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { onMounted, ref } from 'vue'; import * as Misskey from 'misskey-js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; const props = withDefaults(defineProps<{ userIds: string[]; diff --git a/packages/frontend/src/components/MkButton.vue b/packages/frontend/src/components/MkButton.vue index 311facb4aa..891af7f696 100644 --- a/packages/frontend/src/components/MkButton.vue +++ b/packages/frontend/src/components/MkButton.vue @@ -7,11 +7,11 @@ SPDX-License-Identifier: AGPL-3.0-only <button v-if="!link" ref="el" class="_button" - :class="[$style.root, { [$style.inline]: inline, [$style.primary]: primary, [$style.gradate]: gradate, [$style.danger]: danger, [$style.rounded]: rounded, [$style.full]: full, [$style.small]: small, [$style.large]: large, [$style.transparent]: transparent, [$style.asLike]: asLike }]" + :class="[$style.root, { [$style.inline]: inline, [$style.primary]: primary, [$style.gradate]: gradate, [$style.danger]: danger, [$style.rounded]: rounded, [$style.full]: full, [$style.small]: small, [$style.large]: large, [$style.transparent]: transparent, [$style.asLike]: asLike, [$style.iconOnly]: iconOnly, [$style.wait]: wait }]" :type="type" :name="name" :value="value" - :disabled="disabled" + :disabled="disabled || wait" @click="emit('click', $event)" @mousedown="onMousedown" > @@ -22,7 +22,7 @@ SPDX-License-Identifier: AGPL-3.0-only </button> <MkA v-else class="_button" - :class="[$style.root, { [$style.inline]: inline, [$style.primary]: primary, [$style.gradate]: gradate, [$style.danger]: danger, [$style.rounded]: rounded, [$style.full]: full, [$style.small]: small, [$style.large]: large, [$style.transparent]: transparent, [$style.asLike]: asLike }]" + :class="[$style.root, { [$style.inline]: inline, [$style.primary]: primary, [$style.gradate]: gradate, [$style.danger]: danger, [$style.rounded]: rounded, [$style.full]: full, [$style.small]: small, [$style.large]: large, [$style.transparent]: transparent, [$style.asLike]: asLike, [$style.iconOnly]: iconOnly, [$style.wait]: wait }]" :to="to ?? '#'" :behavior="linkBehavior" @mousedown="onMousedown" @@ -35,7 +35,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { nextTick, onMounted, shallowRef } from 'vue'; +import { nextTick, onMounted, useTemplateRef } from 'vue'; const props = defineProps<{ type?: 'button' | 'submit' | 'reset'; @@ -57,14 +57,15 @@ const props = defineProps<{ name?: string; value?: string; disabled?: boolean; + iconOnly?: boolean; }>(); const emit = defineEmits<{ (ev: 'click', payload: MouseEvent): void; }>(); -const el = shallowRef<HTMLElement | null>(null); -const ripples = shallowRef<HTMLElement | null>(null); +const el = useTemplateRef('el'); +const ripples = useTemplateRef('ripples'); onMounted(() => { if (props.autofocus) { @@ -91,7 +92,7 @@ function onMousedown(evt: MouseEvent): void { const target = evt.target! as HTMLElement; const rect = target.getBoundingClientRect(); - const ripple = document.createElement('div'); + const ripple = window.document.createElement('div'); ripple.classList.add(ripples.value!.dataset.childrenClass!); ripple.style.top = (evt.clientY - rect.top - 1).toString() + 'px'; ripple.style.left = (evt.clientX - rect.left - 1).toString() + 'px'; @@ -147,6 +148,11 @@ function onMousedown(evt: MouseEvent): void { background: var(--MI_THEME-buttonHoverBg); } + &.iconOnly { + padding: 7px; + min-width: auto; + } + &.small { font-size: 90%; padding: 6px 12px; @@ -220,28 +226,28 @@ function onMousedown(evt: MouseEvent): void { background: linear-gradient(90deg, var(--MI_THEME-buttonGradateA), var(--MI_THEME-buttonGradateB)); &:not(:disabled):hover { - background: linear-gradient(90deg, hsl(from var(--MI_THEME-accent) h s calc(l + 5)), hsl(from var(--MI_THEME-accent) h s calc(l + 5))); + background: linear-gradient(90deg, hsl(from var(--MI_THEME-buttonGradateA) h s calc(l + 5)), hsl(from var(--MI_THEME-buttonGradateB) h s calc(l + 5))); } &:not(:disabled):active { - background: linear-gradient(90deg, hsl(from var(--MI_THEME-accent) h s calc(l + 5)), hsl(from var(--MI_THEME-accent) h s calc(l + 5))); + background: linear-gradient(90deg, hsl(from var(--MI_THEME-buttonGradateA) h s calc(l + 5)), hsl(from var(--MI_THEME-buttonGradateB) h s calc(l + 5))); } } &.danger { font-weight: bold; - color: #ff2a2a; + color: var(--MI_THEME-error); &.primary { color: #fff; - background: #ff2a2a; + background: var(--MI_THEME-error); &:not(:disabled):hover { - background: #ff4242; + background: hsl(from var(--MI_THEME-error) h s calc(l + 10)); } &:not(:disabled):active { - background: #d42e2e; + background: hsl(from var(--MI_THEME-error) h s calc(l - 10)); } } } @@ -250,6 +256,10 @@ function onMousedown(evt: MouseEvent): void { opacity: 0.5; } + &.wait { + cursor: wait !important; + } + &:focus-visible { outline-offset: 2px; } diff --git a/packages/frontend/src/components/MkCaptcha.vue b/packages/frontend/src/components/MkCaptcha.vue index 134f8226d4..30940a34a9 100644 --- a/packages/frontend/src/components/MkCaptcha.vue +++ b/packages/frontend/src/components/MkCaptcha.vue @@ -26,8 +26,8 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { ref, shallowRef, computed, onMounted, onBeforeUnmount, watch, onUnmounted } from 'vue'; -import { defaultStore } from '@/store.js'; +import { ref, useTemplateRef, computed, onMounted, onBeforeUnmount, watch, onUnmounted } from 'vue'; +import { store } from '@/store.js'; // APIs provided by Captcha services // see: https://docs.hcaptcha.com/configuration/#javascript-api @@ -69,7 +69,7 @@ const emit = defineEmits<{ const available = ref(false); -const captchaEl = shallowRef<HTMLDivElement | undefined>(); +const captchaEl = useTemplateRef('captchaEl'); const captchaWidgetId = ref<string | undefined>(undefined); const testcaptchaInput = ref(''); const testcaptchaPassed = ref(false); @@ -112,7 +112,7 @@ watch(() => [props.instanceUrl, props.sitekey, props.secretKey], async () => { if (loaded || props.provider === 'mcaptcha' || props.provider === 'testcaptcha') { available.value = true; } else if (src.value !== null) { - (document.getElementById(scriptId.value) ?? document.head.appendChild(Object.assign(document.createElement('script'), { + (window.document.getElementById(scriptId.value) ?? window.document.head.appendChild(Object.assign(window.document.createElement('script'), { async: true, id: scriptId.value, src: src.value, @@ -149,12 +149,12 @@ async function requestRender() { if (captcha.value.render && captchaEl.value instanceof Element && props.sitekey) { // reCAPTCHAのレンダリング重複判定を回避するため、captchaEl配下に仮のdivを用意する. // (同じdivに対して複数回renderを呼び出すとreCAPTCHAはエラーを返すので) - const elem = document.createElement('div'); + const elem = window.document.createElement('div'); captchaEl.value.appendChild(elem); captchaWidgetId.value = captcha.value.render(elem, { sitekey: props.sitekey, - theme: defaultStore.state.darkMode ? 'dark' : 'light', + theme: store.s.darkMode ? 'dark' : 'light', callback: callback, 'expired-callback': () => callback(undefined), 'error-callback': () => callback(undefined), @@ -174,7 +174,7 @@ async function requestRender() { function clearWidget() { if (props.provider === 'mcaptcha') { - const container = document.getElementById('mcaptcha__widget-container'); + const container = window.document.getElementById('mcaptcha__widget-container'); if (container) { container.innerHTML = ''; } diff --git a/packages/frontend/src/components/MkChannelFollowButton.stories.impl.ts b/packages/frontend/src/components/MkChannelFollowButton.stories.impl.ts index a42e80c27a..4304c2e2b7 100644 --- a/packages/frontend/src/components/MkChannelFollowButton.stories.impl.ts +++ b/packages/frontend/src/components/MkChannelFollowButton.stories.impl.ts @@ -2,20 +2,18 @@ * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ - -/* eslint-disable @typescript-eslint/explicit-function-return-type */ -/* eslint-disable import/no-default-export */ -import type { StoryObj } from '@storybook/vue3'; + import { HttpResponse, http } from 'msw'; import { action } from '@storybook/addon-actions'; import { expect, userEvent, within } from '@storybook/test'; import { channel } from '../../.storybook/fakes.js'; import { commonHandlers } from '../../.storybook/mocks.js'; import MkChannelFollowButton from './MkChannelFollowButton.vue'; +import type { StoryObj } from '@storybook/vue3'; import { i18n } from '@/i18n.js'; function sleep(ms: number) { - return new Promise(resolve => setTimeout(resolve, ms)); + return new Promise(resolve => window.setTimeout(resolve, ms)); } export const Default = { diff --git a/packages/frontend/src/components/MkChannelFollowButton.vue b/packages/frontend/src/components/MkChannelFollowButton.vue index d4e4f6179a..1aec8d0c07 100644 --- a/packages/frontend/src/components/MkChannelFollowButton.vue +++ b/packages/frontend/src/components/MkChannelFollowButton.vue @@ -27,7 +27,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { ref } from 'vue'; import * as Misskey from 'misskey-js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; const props = withDefaults(defineProps<{ @@ -103,13 +103,13 @@ async function onClick() { background: var(--MI_THEME-accent); &:hover { - background: var(--MI_THEME-accentLighten); - border-color: var(--MI_THEME-accentLighten); + background: hsl(from var(--MI_THEME-accent) h s calc(l + 10)); + border-color: hsl(from var(--MI_THEME-accent) h s calc(l + 10)); } &:active { - background: var(--MI_THEME-accentDarken); - border-color: var(--MI_THEME-accentDarken); + background: hsl(from var(--MI_THEME-accent) h s calc(l - 10)); + border-color: hsl(from var(--MI_THEME-accent) h s calc(l - 10)); } } diff --git a/packages/frontend/src/components/MkChannelList.vue b/packages/frontend/src/components/MkChannelList.vue index 23705f6ff8..fdb7d2a1c4 100644 --- a/packages/frontend/src/components/MkChannelList.vue +++ b/packages/frontend/src/components/MkChannelList.vue @@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkPagination :pagination="pagination"> <template #empty> <div class="_fullinfo"> - <img :src="infoImageUrl" class="_ghost"/> + <img :src="infoImageUrl" draggable="false"/> <div>{{ i18n.ts.notFound }}</div> </div> </template> @@ -19,9 +19,9 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> +import type { Paging } from '@/components/MkPagination.vue'; import MkChannelPreview from '@/components/MkChannelPreview.vue'; import MkPagination from '@/components/MkPagination.vue'; -import type { Paging } from '@/components/MkPagination.vue'; import { i18n } from '@/i18n.js'; import { infoImageUrl } from '@/instance.js'; diff --git a/packages/frontend/src/components/MkChart.vue b/packages/frontend/src/components/MkChart.vue index d05f4921f6..7e164362c1 100644 --- a/packages/frontend/src/components/MkChart.vue +++ b/packages/frontend/src/components/MkChart.vue @@ -45,23 +45,19 @@ export type ChartSrc = </script> <script lang="ts" setup> -/* eslint-disable id-denylist -- - Chart.js has a `data` attribute in most chart definitions, which triggers the - id-denylist violation when setting it. This is causing about 60+ lint issues. - As this is part of Chart.js's API it makes sense to disable the check here. -*/ -import { onMounted, ref, shallowRef, watch } from 'vue'; + +import { onMounted, ref, useTemplateRef, watch } from 'vue'; import { Chart } from 'chart.js'; import * as Misskey from 'misskey-js'; -import { misskeyApiGet } from '@/scripts/misskey-api.js'; -import { defaultStore } from '@/store.js'; -import { useChartTooltip } from '@/scripts/use-chart-tooltip.js'; -import { chartVLine } from '@/scripts/chart-vline.js'; -import { alpha } from '@/scripts/color.js'; +import { misskeyApiGet } from '@/utility/misskey-api.js'; +import { store } from '@/store.js'; +import { useChartTooltip } from '@/use/use-chart-tooltip.js'; +import { chartVLine } from '@/utility/chart-vline.js'; +import { alpha } from '@/utility/color.js'; import date from '@/filters/date.js'; import bytes from '@/filters/bytes.js'; -import { initChart } from '@/scripts/init-chart.js'; -import { chartLegend } from '@/scripts/chart-legend.js'; +import { initChart } from '@/utility/init-chart.js'; +import { chartLegend } from '@/utility/chart-legend.js'; import MkChartLegend from '@/components/MkChartLegend.vue'; initChart(); @@ -96,7 +92,7 @@ const props = withDefaults(defineProps<{ nowForChromatic: undefined, }); -const legendEl = shallowRef<InstanceType<typeof MkChartLegend>>(); +const legendEl = useTemplateRef('legendEl'); const sum = (...arr) => arr.reduce((r, a) => r.map((b, i) => a[i] + b)); const negate = arr => arr.map(x => -x); @@ -134,7 +130,7 @@ let chartData: { bytes?: boolean; } | null = null; -const chartEl = shallowRef<HTMLCanvasElement | null>(null); +const chartEl = useTemplateRef('chartEl'); const fetching = ref(true); const getDate = (ago: number) => { @@ -161,7 +157,7 @@ const render = () => { chartInstance.destroy(); } - const vLineColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)'; + const vLineColor = store.s.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)'; const maxes = chartData.series.map((x, i) => Math.max(...x.data.map(d => d.y))); @@ -849,7 +845,7 @@ watch(() => [props.src, props.span], fetchAndRender); onMounted(() => { fetchAndRender(); }); -/* eslint-enable id-denylist */ + </script> <style lang="scss" module> diff --git a/packages/frontend/src/components/MkClickerGame.stories.impl.ts b/packages/frontend/src/components/MkClickerGame.stories.impl.ts index eb7e61f294..6e1eb13d61 100644 --- a/packages/frontend/src/components/MkClickerGame.stories.impl.ts +++ b/packages/frontend/src/components/MkClickerGame.stories.impl.ts @@ -2,18 +2,16 @@ * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ - -/* eslint-disable @typescript-eslint/explicit-function-return-type */ -/* eslint-disable import/no-default-export */ -import type { StoryObj } from '@storybook/vue3'; + import { HttpResponse, http } from 'msw'; import { action } from '@storybook/addon-actions'; import { expect, userEvent, within } from '@storybook/test'; import { commonHandlers } from '../../.storybook/mocks.js'; import MkClickerGame from './MkClickerGame.vue'; +import type { StoryObj } from '@storybook/vue3'; function sleep(ms: number) { - return new Promise(resolve => setTimeout(resolve, ms)); + return new Promise(resolve => window.setTimeout(resolve, ms)); } export const Default = { diff --git a/packages/frontend/src/components/MkClickerGame.vue b/packages/frontend/src/components/MkClickerGame.vue index 9a0a9fba05..775964af50 100644 --- a/packages/frontend/src/components/MkClickerGame.vue +++ b/packages/frontend/src/components/MkClickerGame.vue @@ -23,9 +23,9 @@ import { computed, onMounted, onUnmounted, ref } from 'vue'; import MkPlusOneEffect from '@/components/MkPlusOneEffect.vue'; import * as os from '@/os.js'; import { useInterval } from '@@/js/use-interval.js'; -import * as game from '@/scripts/clicker-game.js'; +import * as game from '@/utility/clicker-game.js'; import number from '@/filters/number.js'; -import { claimAchievement } from '@/scripts/achievements.js'; +import { claimAchievement } from '@/utility/achievements.js'; const saveData = game.saveData; const cookies = computed(() => saveData.value?.cookies); diff --git a/packages/frontend/src/components/MkClipPreview.vue b/packages/frontend/src/components/MkClipPreview.vue index 5b09ec90dd..2154c08ab3 100644 --- a/packages/frontend/src/components/MkClipPreview.vue +++ b/packages/frontend/src/components/MkClipPreview.vue @@ -26,7 +26,7 @@ SPDX-License-Identifier: AGPL-3.0-only import * as Misskey from 'misskey-js'; import { computed } from 'vue'; import { i18n } from '@/i18n.js'; -import { $i } from '@/account.js'; +import { $i } from '@/i.js'; import number from '@/filters/number.js'; const props = withDefaults(defineProps<{ diff --git a/packages/frontend/src/components/MkCode.core.vue b/packages/frontend/src/components/MkCode.core.vue index 0d7a67eaec..8b39468d4c 100644 --- a/packages/frontend/src/components/MkCode.core.vue +++ b/packages/frontend/src/components/MkCode.core.vue @@ -12,8 +12,8 @@ SPDX-License-Identifier: AGPL-3.0-only import { computed, ref, watch } from 'vue'; import { bundledLanguagesInfo } from 'shiki/langs'; import type { BundledLanguage } from 'shiki/langs'; -import { getHighlighter, getTheme } from '@/scripts/code-highlighter.js'; -import { defaultStore } from '@/store.js'; +import { getHighlighter, getTheme } from '@/utility/code-highlighter.js'; +import { store } from '@/store.js'; const props = defineProps<{ code: string; @@ -22,7 +22,7 @@ const props = defineProps<{ }>(); const highlighter = await getHighlighter(); -const darkMode = defaultStore.reactiveState.darkMode; +const darkMode = store.r.darkMode; const codeLang = ref<BundledLanguage | 'aiscript'>('js'); const [lightThemeName, darkThemeName] = await Promise.all([ @@ -74,10 +74,8 @@ watch(() => props.lang, (to) => { <style module lang="scss"> .codeBlockRoot :global(.shiki) { padding: 1em; - margin: .5em 0; + margin: 0; overflow: auto; - border-radius: 8px; - border: 1px solid var(--MI_THEME-divider); font-family: Consolas, Monaco, Andale Mono, Ubuntu Mono, monospace; color: var(--shiki-fallback); diff --git a/packages/frontend/src/components/MkCode.vue b/packages/frontend/src/components/MkCode.vue index cb82bfd98b..d2d9f320ee 100644 --- a/packages/frontend/src/components/MkCode.vue +++ b/packages/frontend/src/components/MkCode.vue @@ -10,7 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only </button> <Suspense> <template #fallback> - <MkLoading /> + <MkLoading/> </template> <XCode v-if="show && lang" :code="code" :lang="lang"/> <pre v-else-if="show" :class="$style.codeBlockFallbackRoot"><code :class="$style.codeBlockFallbackCode">{{ code }}</code></pre> @@ -28,9 +28,9 @@ SPDX-License-Identifier: AGPL-3.0-only import { defineAsyncComponent, ref } from 'vue'; import * as os from '@/os.js'; import MkLoading from '@/components/global/MkLoading.vue'; -import { defaultStore } from '@/store.js'; import { i18n } from '@/i18n.js'; -import { copyToClipboard } from '@/scripts/copy-to-clipboard.js'; +import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; +import { prefer } from '@/preferences.js'; const props = withDefaults(defineProps<{ code: string; @@ -42,13 +42,12 @@ const props = withDefaults(defineProps<{ forceShow: false, }); -const show = ref(props.forceShow === true ? true : !defaultStore.state.dataSaver.code); +const show = ref(props.forceShow === true ? true : !prefer.s.dataSaver.code); const XCode = defineAsyncComponent(() => import('@/components/MkCode.core.vue')); function copy() { copyToClipboard(props.code); - os.success(); } </script> diff --git a/packages/frontend/src/components/MkCodeEditor.vue b/packages/frontend/src/components/MkCodeEditor.vue index 5bf2301e72..bdb2ba6a44 100644 --- a/packages/frontend/src/components/MkCodeEditor.vue +++ b/packages/frontend/src/components/MkCodeEditor.vue @@ -32,7 +32,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { ref, watch, toRefs, shallowRef, nextTick } from 'vue'; +import { ref, watch, toRefs, useTemplateRef, nextTick } from 'vue'; import { debounce } from 'throttle-debounce'; import MkButton from '@/components/MkButton.vue'; import { i18n } from '@/i18n.js'; @@ -61,7 +61,7 @@ const { modelValue } = toRefs(props); const v = ref<string>(modelValue.value ?? ''); const focused = ref(false); const changed = ref(false); -const inputEl = shallowRef<HTMLTextAreaElement>(); +const inputEl = useTemplateRef('inputEl'); const focus = () => inputEl.value?.focus(); @@ -140,7 +140,7 @@ watch(v, newValue => { .caption { font-size: 0.85em; padding: 8px 0 0 0; - color: var(--MI_THEME-fgTransparentWeak); + color: color(from var(--MI_THEME-fg) srgb r g b / 0.75); &:empty { display: none; diff --git a/packages/frontend/src/components/MkColorInput.vue b/packages/frontend/src/components/MkColorInput.vue index 55a32664de..80618ebfe4 100644 --- a/packages/frontend/src/components/MkColorInput.vue +++ b/packages/frontend/src/components/MkColorInput.vue @@ -24,7 +24,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { ref, shallowRef, toRefs } from 'vue'; +import { ref, useTemplateRef, toRefs } from 'vue'; const props = defineProps<{ modelValue: string | null; @@ -39,7 +39,7 @@ const emit = defineEmits<{ const { modelValue } = toRefs(props); const v = ref(modelValue.value); -const inputEl = shallowRef<HTMLElement>(); +const inputEl = useTemplateRef('inputEl'); const onInput = () => { emit('update:modelValue', v.value ?? ''); @@ -60,7 +60,7 @@ const onInput = () => { .caption { font-size: 0.85em; padding: 8px 0 0 0; - color: var(--MI_THEME-fgTransparentWeak); + color: color(from var(--MI_THEME-fg) srgb r g b / 0.75); &:empty { display: none; diff --git a/packages/frontend/src/components/MkContainer.vue b/packages/frontend/src/components/MkContainer.vue index 6a278250fa..1993991106 100644 --- a/packages/frontend/src/components/MkContainer.vue +++ b/packages/frontend/src/components/MkContainer.vue @@ -19,10 +19,10 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </header> <Transition - :enterActiveClass="defaultStore.state.animation ? $style.transition_toggle_enterActive : ''" - :leaveActiveClass="defaultStore.state.animation ? $style.transition_toggle_leaveActive : ''" - :enterFromClass="defaultStore.state.animation ? $style.transition_toggle_enterFrom : ''" - :leaveToClass="defaultStore.state.animation ? $style.transition_toggle_leaveTo : ''" + :enterActiveClass="prefer.s.animation ? $style.transition_toggle_enterActive : ''" + :leaveActiveClass="prefer.s.animation ? $style.transition_toggle_leaveActive : ''" + :enterFromClass="prefer.s.animation ? $style.transition_toggle_enterFrom : ''" + :leaveToClass="prefer.s.animation ? $style.transition_toggle_leaveTo : ''" @enter="enter" @afterEnter="afterEnter" @leave="leave" @@ -39,8 +39,8 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted, onUnmounted, ref, shallowRef, watch } from 'vue'; -import { defaultStore } from '@/store.js'; +import { onMounted, onUnmounted, ref, useTemplateRef, watch } from 'vue'; +import { prefer } from '@/preferences.js'; import { i18n } from '@/i18n.js'; const props = withDefaults(defineProps<{ @@ -58,9 +58,9 @@ const props = withDefaults(defineProps<{ maxHeight: null, }); -const rootEl = shallowRef<HTMLElement>(); -const contentEl = shallowRef<HTMLElement>(); -const headerEl = shallowRef<HTMLElement>(); +const rootEl = useTemplateRef('rootEl'); +const contentEl = useTemplateRef('contentEl'); +const headerEl = useTemplateRef('headerEl'); const showBody = ref(props.expanded); const ignoreOmit = ref(false); const omitted = ref(false); @@ -181,11 +181,16 @@ onUnmounted(() => { left: 0; color: var(--MI_THEME-panelHeaderFg); background: var(--MI_THEME-panelHeaderBg); - border-bottom: solid 0.5px var(--MI_THEME-panelHeaderDivider); z-index: 2; line-height: 1.4em; } +@container style(--MI_THEME-panelHeaderBg: var(--MI_THEME-panel)) { + .header { + box-shadow: 0 0.5px 0 0 light-dark(#0002, #fff2); + } +} + .title { margin: 0; padding: 12px 16px; @@ -215,6 +220,14 @@ onUnmounted(() => { .content { --MI-stickyTop: 0px; + /* + 理屈は知らないけど、ここでbackgroundを設定しておかないと + スクロールコンテナーが少なくともChromeにおいて + main thread scrolling になってしまい、パフォーマンスが(多分)落ちる。 + backgroundが透明だと裏側を描画しないといけなくなるとかそういう理由かもしれない + */ + background: var(--MI_THEME-panel); + &.omitted { position: relative; max-height: var(--maxHeight); diff --git a/packages/frontend/src/components/MkContextMenu.vue b/packages/frontend/src/components/MkContextMenu.vue index f51fefa0c0..9c6397a72c 100644 --- a/packages/frontend/src/components/MkContextMenu.vue +++ b/packages/frontend/src/components/MkContextMenu.vue @@ -6,10 +6,10 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <Transition appear - :enterActiveClass="defaultStore.state.animation ? $style.transition_fade_enterActive : ''" - :leaveActiveClass="defaultStore.state.animation ? $style.transition_fade_leaveActive : ''" - :enterFromClass="defaultStore.state.animation ? $style.transition_fade_enterFrom : ''" - :leaveToClass="defaultStore.state.animation ? $style.transition_fade_leaveTo : ''" + :enterActiveClass="prefer.s.animation ? $style.transition_fade_enterActive : ''" + :leaveActiveClass="prefer.s.animation ? $style.transition_fade_leaveActive : ''" + :enterFromClass="prefer.s.animation ? $style.transition_fade_enterFrom : ''" + :leaveToClass="prefer.s.animation ? $style.transition_fade_leaveTo : ''" > <div ref="rootEl" :class="$style.root" :style="{ zIndex }" @contextmenu.prevent.stop="() => {}"> <MkMenu :items="items" :align="'left'" @close="emit('closed')"/> @@ -18,11 +18,11 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted, onBeforeUnmount, shallowRef, ref } from 'vue'; +import { onMounted, onBeforeUnmount, useTemplateRef, ref } from 'vue'; import MkMenu from './MkMenu.vue'; import type { MenuItem } from '@/types/menu.js'; -import contains from '@/scripts/contains.js'; -import { defaultStore } from '@/store.js'; +import contains from '@/utility/contains.js'; +import { prefer } from '@/preferences.js'; import * as os from '@/os.js'; const props = defineProps<{ @@ -34,7 +34,7 @@ const emit = defineEmits<{ (ev: 'closed'): void; }>(); -const rootEl = shallowRef<HTMLDivElement>(); +const rootEl = useTemplateRef('rootEl'); const zIndex = ref<number>(os.claimZIndex('high')); @@ -68,11 +68,11 @@ onMounted(() => { rootEl.value.style.left = `${left}px`; } - document.body.addEventListener('mousedown', onMousedown); + window.document.body.addEventListener('mousedown', onMousedown); }); onBeforeUnmount(() => { - document.body.removeEventListener('mousedown', onMousedown); + window.document.body.removeEventListener('mousedown', onMousedown); }); function onMousedown(evt: Event) { diff --git a/packages/frontend/src/components/MkCropperDialog.stories.impl.ts b/packages/frontend/src/components/MkCropperDialog.stories.impl.ts index 27ce60415b..78cb4120de 100644 --- a/packages/frontend/src/components/MkCropperDialog.stories.impl.ts +++ b/packages/frontend/src/components/MkCropperDialog.stories.impl.ts @@ -3,14 +3,12 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -/* eslint-disable @typescript-eslint/explicit-function-return-type */ -/* eslint-disable import/no-default-export */ -import type { StoryObj } from '@storybook/vue3'; import { HttpResponse, http } from 'msw'; import { action } from '@storybook/addon-actions'; import { file } from '../../.storybook/fakes.js'; import { commonHandlers } from '../../.storybook/mocks.js'; import MkCropperDialog from './MkCropperDialog.vue'; +import type { StoryObj } from '@storybook/vue3'; export const Default = { render(args) { return { @@ -55,7 +53,7 @@ export const Default = { http.get('/proxy/image.webp', async ({ request }) => { const url = new URL(request.url).searchParams.get('url'); if (url === 'https://github.com/misskey-dev/misskey/blob/master/packages/frontend/assets/fedi.jpg?raw=true') { - const image = await (await fetch('client-assets/fedi.jpg')).blob(); + const image = await (await window.fetch('client-assets/fedi.jpg')).blob(); return new HttpResponse(image, { headers: { 'Content-Type': 'image/jpeg', diff --git a/packages/frontend/src/components/MkCropperDialog.vue b/packages/frontend/src/components/MkCropperDialog.vue index 0186cfc2c0..ba21394cbc 100644 --- a/packages/frontend/src/components/MkCropperDialog.vue +++ b/packages/frontend/src/components/MkCropperDialog.vue @@ -31,17 +31,17 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted, shallowRef, ref } from 'vue'; +import { onMounted, useTemplateRef, ref } from 'vue'; import * as Misskey from 'misskey-js'; import Cropper from 'cropperjs'; import tinycolor from 'tinycolor2'; +import { apiUrl } from '@@/js/config.js'; import MkModalWindow from '@/components/MkModalWindow.vue'; import * as os from '@/os.js'; -import { $i } from '@/account.js'; -import { defaultStore } from '@/store.js'; -import { apiUrl } from '@@/js/config.js'; +import { $i } from '@/i.js'; import { i18n } from '@/i18n.js'; -import { getProxiedImageUrl } from '@/scripts/media-proxy.js'; +import { getProxiedImageUrl } from '@/utility/media-proxy.js'; +import { prefer } from '@/preferences.js'; const emit = defineEmits<{ (ev: 'ok', cropped: Misskey.entities.DriveFile): void; @@ -56,8 +56,8 @@ const props = defineProps<{ }>(); const imgUrl = getProxiedImageUrl(props.file.url, undefined, true); -const dialogEl = shallowRef<InstanceType<typeof MkModalWindow>>(); -const imgEl = shallowRef<HTMLImageElement>(); +const dialogEl = useTemplateRef('dialogEl'); +const imgEl = useTemplateRef('imgEl'); let cropper: Cropper | null = null; const loading = ref(true); @@ -81,8 +81,8 @@ const ok = async () => { formData.append('i', $i!.token); if (props.uploadFolder) { formData.append('folderId', props.uploadFolder); - } else if (props.uploadFolder !== null && defaultStore.state.uploadFolder) { - formData.append('folderId', defaultStore.state.uploadFolder); + } else if (props.uploadFolder !== null && prefer.s.uploadFolder) { + formData.append('folderId', prefer.s.uploadFolder); } window.fetch(apiUrl + '/drive/files/create', { @@ -122,7 +122,7 @@ onMounted(() => { cropper = new Cropper(imgEl.value!, { }); - const computedStyle = getComputedStyle(document.documentElement); + const computedStyle = getComputedStyle(window.document.documentElement); const selection = cropper.getCropperSelection()!; selection.themeColor = tinycolor(computedStyle.getPropertyValue('--MI_THEME-accent')).toHexString(); diff --git a/packages/frontend/src/components/MkCustomEmojiDetailedDialog.vue b/packages/frontend/src/components/MkCustomEmojiDetailedDialog.vue index 86d6269c69..54fda6bf7c 100644 --- a/packages/frontend/src/components/MkCustomEmojiDetailedDialog.vue +++ b/packages/frontend/src/components/MkCustomEmojiDetailedDialog.vue @@ -57,14 +57,14 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import * as Misskey from 'misskey-js'; -import { shallowRef } from 'vue'; +import { useTemplateRef } from 'vue'; import MkLink from '@/components/MkLink.vue'; import { i18n } from '@/i18n.js'; import MkModalWindow from '@/components/MkModalWindow.vue'; import MkKeyValue from '@/components/MkKeyValue.vue'; const props = defineProps<{ - emoji: Misskey.entities.EmojiDetailed, + emoji: Misskey.entities.EmojiDetailed, }>(); const emit = defineEmits<{ @@ -73,7 +73,7 @@ const emit = defineEmits<{ (ev: 'closed'): void; }>(); -const dialogEl = shallowRef<InstanceType<typeof MkModalWindow>>(); +const dialogEl = useTemplateRef('dialogEl'); function cancel() { emit('cancel'); diff --git a/packages/frontend/src/components/MkCwButton.vue b/packages/frontend/src/components/MkCwButton.vue index b5f6e78b6c..cc8bbf1104 100644 --- a/packages/frontend/src/components/MkCwButton.vue +++ b/packages/frontend/src/components/MkCwButton.vue @@ -11,7 +11,7 @@ SPDX-License-Identifier: AGPL-3.0-only import { computed } from 'vue'; import * as Misskey from 'misskey-js'; import type { PollEditorModelValue } from '@/components/MkPollEditor.vue'; -import { concat } from '@/scripts/array.js'; +import { concat } from '@/utility/array.js'; import { i18n } from '@/i18n.js'; import MkButton from '@/components/MkButton.vue'; diff --git a/packages/frontend/src/components/MkDateSeparatedList.vue b/packages/frontend/src/components/MkDateSeparatedList.vue index 0d5a16126b..1cf6f0b744 100644 --- a/packages/frontend/src/components/MkDateSeparatedList.vue +++ b/packages/frontend/src/components/MkDateSeparatedList.vue @@ -3,16 +3,18 @@ SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> +<!-- TODO: 親からスタイルを当てにくいことや実装がトリッキーなことを鑑み廃止または使用の縮小(timeline-date-separate.tsを使う) --> + <script lang="ts"> import { defineComponent, h, TransitionGroup, useCssModule } from 'vue'; import type { PropType } from 'vue'; +import type { MisskeyEntity } from '@/types/date-separated-list.js'; import MkAd from '@/components/global/MkAd.vue'; import { isDebuggerEnabled, stackTraceInstances } from '@/debug.js'; -import { i18n } from '@/i18n.js'; import * as os from '@/os.js'; import { instance } from '@/instance.js'; -import { defaultStore } from '@/store.js'; -import type { MisskeyEntity } from '@/types/date-separated-list.js'; +import { prefer } from '@/preferences.js'; +import { getDateText } from '@/utility/timeline-date-separate.js'; export default defineComponent({ props: { @@ -45,15 +47,6 @@ export default defineComponent({ setup(props, { slots, expose }) { const $style = useCssModule(); // カスタムレンダラなので使っても大丈夫 - function getDateText(dateInstance: Date) { - const date = dateInstance.getDate(); - const month = dateInstance.getMonth() + 1; - return i18n.tsx.monthAndDay({ - month: month.toString(), - day: date.toString(), - }); - } - if (props.items.length === 0) return; const renderChildrenImpl = () => props.items.map((item, i) => { @@ -150,7 +143,7 @@ export default defineComponent({ [$style['direction-up']]: props.direction === 'up', }; - return () => defaultStore.state.animation ? h(TransitionGroup, { + return () => prefer.s.animation ? h(TransitionGroup, { class: classes, name: 'list', tag: 'div', @@ -168,21 +161,17 @@ export default defineComponent({ container-type: inline-size; &:global { - > .list-move { - transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1); - } - - &.deny-move-transition > .list-move { - transition: none !important; - } + > .list-move { + transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1); + } - > .list-enter-active { - transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1), opacity 0.7s cubic-bezier(0.23, 1, 0.32, 1); - } + > .list-enter-active { + transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1), opacity 0.7s cubic-bezier(0.23, 1, 0.32, 1); + } - > *:empty { - display: none; - } + > *:empty { + display: none; + } } &:not(.date-separated-list-nogap) > *:not(:last-child) { diff --git a/packages/frontend/src/components/MkDialog.vue b/packages/frontend/src/components/MkDialog.vue index 6c9fa3167a..81d508c161 100644 --- a/packages/frontend/src/components/MkDialog.vue +++ b/packages/frontend/src/components/MkDialog.vue @@ -25,8 +25,8 @@ SPDX-License-Identifier: AGPL-3.0-only <i v-else-if="type === 'question'" :class="$style.iconInner" class="ti ti-help-circle"></i> <MkLoading v-else-if="type === 'waiting'" :class="$style.iconInner" :em="true"/> </div> - <header v-if="title" :class="$style.title"><Mfm :text="title"/></header> - <div v-if="text" :class="$style.text"><Mfm :text="text"/></div> + <header v-if="title" :class="$style.title" class="_selectable"><Mfm :text="title"/></header> + <div v-if="text" :class="$style.text" class="_selectable"><Mfm :text="text"/></div> <MkInput v-if="input" v-model="inputValue" autofocus :type="input.type || 'text'" :placeholder="input.placeholder || undefined" :autocomplete="input.autocomplete" @keydown="onInputKeydown"> <template v-if="input.type === 'password'" #prefix><i class="ti ti-lock"></i></template> <template #caption> @@ -56,7 +56,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { ref, shallowRef, computed } from 'vue'; +import { ref, useTemplateRef, computed } from 'vue'; import MkModal from '@/components/MkModal.vue'; import MkButton from '@/components/MkButton.vue'; import MkInput from '@/components/MkInput.vue'; @@ -117,7 +117,7 @@ const emit = defineEmits<{ (ev: 'closed'): void; }>(); -const modal = shallowRef<InstanceType<typeof MkModal>>(); +const modal = useTemplateRef('modal'); const inputValue = ref<string | number | null>(props.input?.default ?? null); const selectedValue = ref(props.select?.default ?? null); diff --git a/packages/frontend/src/components/MkDigitalClock.vue b/packages/frontend/src/components/MkDigitalClock.vue index 2e2321e6ac..8198356a76 100644 --- a/packages/frontend/src/components/MkDigitalClock.vue +++ b/packages/frontend/src/components/MkDigitalClock.vue @@ -17,7 +17,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { onMounted, onUnmounted, ref, watch } from 'vue'; -import { defaultIdlingRenderScheduler } from '@/scripts/idle-render.js'; +import { defaultIdlingRenderScheduler } from '@/utility/idle-render.js'; const props = withDefaults(defineProps<{ showS?: boolean; diff --git a/packages/frontend/src/components/MkDrive.file.vue b/packages/frontend/src/components/MkDrive.file.vue index e45c3bd9ce..70ab60cfae 100644 --- a/packages/frontend/src/components/MkDrive.file.vue +++ b/packages/frontend/src/components/MkDrive.file.vue @@ -44,10 +44,10 @@ import MkDriveFileThumbnail from '@/components/MkDriveFileThumbnail.vue'; import bytes from '@/filters/bytes.js'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; -import { $i } from '@/account.js'; -import { getDriveFileMenu } from '@/scripts/get-drive-file-menu.js'; -import { deviceKind } from '@/scripts/device-kind.js'; -import { useRouter } from '@/router/supplier.js'; +import { $i } from '@/i.js'; +import { getDriveFileMenu } from '@/utility/get-drive-file-menu.js'; +import { deviceKind } from '@/utility/device-kind.js'; +import { useRouter } from '@/router.js'; const router = useRouter(); @@ -151,11 +151,11 @@ function onDragend() { background: var(--MI_THEME-accent); &:hover { - background: var(--MI_THEME-accentLighten); + background: hsl(from var(--MI_THEME-accent) h s calc(l + 10)); } &:active { - background: var(--MI_THEME-accentDarken); + background: hsl(from var(--MI_THEME-accent) h s calc(l - 10)); } > .label { diff --git a/packages/frontend/src/components/MkDrive.folder.vue b/packages/frontend/src/components/MkDrive.folder.vue index 44e3b59ade..9c72691d21 100644 --- a/packages/frontend/src/components/MkDrive.folder.vue +++ b/packages/frontend/src/components/MkDrive.folder.vue @@ -24,7 +24,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template v-if="!hover"><i :class="$style.icon" class="ti ti-folder ti-fw"></i></template> {{ folder.name }} </p> - <p v-if="defaultStore.state.uploadFolder == folder.id" :class="$style.upload"> + <p v-if="prefer.s.uploadFolder == folder.id" :class="$style.upload"> {{ i18n.ts.uploadFolder }} </p> <button v-if="selectMode" class="_button" :class="$style.checkboxWrapper" @click.prevent.stop="checkboxClicked"> @@ -38,11 +38,11 @@ import { computed, defineAsyncComponent, ref } from 'vue'; import * as Misskey from 'misskey-js'; import type { MenuItem } from '@/types/menu.js'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; -import { defaultStore } from '@/store.js'; -import { claimAchievement } from '@/scripts/achievements.js'; -import { copyToClipboard } from '@/scripts/copy-to-clipboard.js'; +import { claimAchievement } from '@/utility/achievements.js'; +import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; +import { prefer } from '@/preferences.js'; const props = withDefaults(defineProps<{ folder: Misskey.entities.DriveFolder; @@ -244,8 +244,8 @@ function deleteFolder() { misskeyApi('drive/folders/delete', { folderId: props.folder.id, }).then(() => { - if (defaultStore.state.uploadFolder === props.folder.id) { - defaultStore.set('uploadFolder', null); + if (prefer.s.uploadFolder === props.folder.id) { + prefer.commit('uploadFolder', null); } }).catch(err => { switch (err.id) { @@ -266,7 +266,7 @@ function deleteFolder() { } function setAsUploadFolder() { - defaultStore.set('uploadFolder', props.folder.id); + prefer.commit('uploadFolder', props.folder.id); } function onContextmenu(ev: MouseEvent) { @@ -295,9 +295,9 @@ function onContextmenu(ev: MouseEvent) { danger: true, action: deleteFolder, }]; - if (defaultStore.state.devMode) { + if (prefer.s.devMode) { menu = menu.concat([{ type: 'divider' }, { - icon: 'ti ti-id', + icon: 'ti ti-hash', text: i18n.ts.copyFolderId, action: () => { copyToClipboard(props.folder.id); diff --git a/packages/frontend/src/components/MkDrive.navFolder.vue b/packages/frontend/src/components/MkDrive.navFolder.vue index 8df3c86ebf..7433aea061 100644 --- a/packages/frontend/src/components/MkDrive.navFolder.vue +++ b/packages/frontend/src/components/MkDrive.navFolder.vue @@ -20,7 +20,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { ref } from 'vue'; import * as Misskey from 'misskey-js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; const props = defineProps<{ diff --git a/packages/frontend/src/components/MkDrive.vue b/packages/frontend/src/components/MkDrive.vue index 8be6d6f53d..c212167c8f 100644 --- a/packages/frontend/src/components/MkDrive.vue +++ b/packages/frontend/src/components/MkDrive.vue @@ -4,34 +4,37 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div :class="$style.root"> - <nav :class="$style.nav"> - <div :class="$style.navPath" @contextmenu.prevent.stop="() => {}"> - <XNavFolder - :class="[$style.navPathItem, { [$style.navCurrent]: folder == null }]" - :parentFolder="folder" - @move="move" - @upload="upload" - @removeFile="removeFile" - @removeFolder="removeFolder" - /> - <template v-for="f in hierarchyFolders"> - <span :class="[$style.navPathItem, $style.navSeparator]"><i class="ti ti-chevron-right"></i></span> +<MkStickyContainer> + <template #header> + <nav :class="$style.nav"> + <div :class="$style.navPath" @contextmenu.prevent.stop="() => {}"> <XNavFolder - :folder="f" + :class="[$style.navPathItem, { [$style.navCurrent]: folder == null }]" :parentFolder="folder" - :class="[$style.navPathItem]" @move="move" @upload="upload" @removeFile="removeFile" @removeFolder="removeFolder" /> - </template> - <span v-if="folder != null" :class="[$style.navPathItem, $style.navSeparator]"><i class="ti ti-chevron-right"></i></span> - <span v-if="folder != null" :class="[$style.navPathItem, $style.navCurrent]">{{ folder.name }}</span> - </div> - <button class="_button" :class="$style.navMenu" @click="showMenu"><i class="ti ti-dots"></i></button> - </nav> + <template v-for="f in hierarchyFolders"> + <span :class="[$style.navPathItem, $style.navSeparator]"><i class="ti ti-chevron-right"></i></span> + <XNavFolder + :folder="f" + :parentFolder="folder" + :class="[$style.navPathItem]" + @move="move" + @upload="upload" + @removeFile="removeFile" + @removeFolder="removeFolder" + /> + </template> + <span v-if="folder != null" :class="[$style.navPathItem, $style.navSeparator]"><i class="ti ti-chevron-right"></i></span> + <span v-if="folder != null" :class="[$style.navPathItem, $style.navCurrent]">{{ folder.name }}</span> + </div> + <button class="_button" :class="$style.navMenu" @click="showMenu"><i class="ti ti-dots"></i></button> + </nav> + </template> + <div ref="main" :class="[$style.main, { [$style.uploading]: uploadings.length > 0, [$style.fetching]: fetching }]" @@ -91,12 +94,11 @@ SPDX-License-Identifier: AGPL-3.0-only <MkLoading v-if="fetching"/> </div> <div v-if="draghover" :class="$style.dropzone"></div> - <input ref="fileInput" style="display: none;" type="file" accept="*/*" multiple tabindex="-1" @change="onChangeFileInput"/> -</div> +</MkStickyContainer> </template> <script lang="ts" setup> -import { nextTick, onActivated, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue'; +import { nextTick, onActivated, onBeforeUnmount, onMounted, ref, useTemplateRef, watch } from 'vue'; import * as Misskey from 'misskey-js'; import MkButton from './MkButton.vue'; import type { MenuItem } from '@/types/menu.js'; @@ -104,12 +106,13 @@ import XNavFolder from '@/components/MkDrive.navFolder.vue'; import XFolder from '@/components/MkDrive.folder.vue'; import XFile from '@/components/MkDrive.file.vue'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { useStream } from '@/stream.js'; -import { defaultStore } from '@/store.js'; import { i18n } from '@/i18n.js'; -import { uploadFile, uploads } from '@/scripts/upload.js'; -import { claimAchievement } from '@/scripts/achievements.js'; +import { uploadFile, uploads } from '@/utility/upload.js'; +import { claimAchievement } from '@/utility/achievements.js'; +import { prefer } from '@/preferences.js'; +import { chooseFileFromPc } from '@/utility/select-file.js'; const props = withDefaults(defineProps<{ initialFolder?: Misskey.entities.DriveFolder; @@ -129,8 +132,7 @@ const emit = defineEmits<{ (ev: 'open-folder', v: Misskey.entities.DriveFolder): void; }>(); -const loadMoreFiles = shallowRef<InstanceType<typeof MkButton>>(); -const fileInput = shallowRef<HTMLInputElement>(); +const loadMoreFiles = useTemplateRef('loadMoreFiles'); const folder = ref<Misskey.entities.DriveFolder | null>(null); const files = ref<Misskey.entities.DriveFile[]>([]); @@ -142,7 +144,6 @@ const selectedFiles = ref<Misskey.entities.DriveFile[]>([]); const selectedFolders = ref<Misskey.entities.DriveFolder[]>([]); const uploadings = uploads; const connection = useStream().useChannel('drive'); -const keepOriginal = ref<boolean>(defaultStore.state.keepOriginalUploading); // 外部渡しが多いので$refは使わないほうがよい // ドロップされようとしているか const draghover = ref(false); @@ -304,10 +305,6 @@ function onDrop(ev: DragEvent) { //#endregion } -function selectLocalFile() { - fileInput.value?.click(); -} - function urlUpload() { os.inputText({ title: i18n.ts.uploadFromUrl, @@ -383,15 +380,8 @@ function deleteFolder(folderToDelete: Misskey.entities.DriveFolder) { }); } -function onChangeFileInput() { - if (!fileInput.value?.files) return; - for (const file of Array.from(fileInput.value.files)) { - upload(file, folder.value); - } -} - -function upload(file: File, folderToUpload?: Misskey.entities.DriveFolder | null) { - uploadFile(file, (folderToUpload && typeof folderToUpload === 'object') ? folderToUpload.id : null, undefined, keepOriginal.value).then(res => { +function upload(file: File, folderToUpload?: Misskey.entities.DriveFolder | null, keepOriginal?: boolean) { + uploadFile(file, (folderToUpload && typeof folderToUpload === 'object') ? folderToUpload.id : null, undefined, keepOriginal).then(res => { addFile(res, true); }); } @@ -630,16 +620,20 @@ function getMenu() { const menu: MenuItem[] = []; menu.push({ - type: 'switch', - text: i18n.ts.keepOriginalUploading, - ref: keepOriginal, - }, { type: 'divider' }, { text: i18n.ts.addFile, type: 'label', }, { + text: i18n.ts.upload + ' (' + i18n.ts.compress + ')', + icon: 'ti ti-upload', + action: () => { + chooseFileFromPc(true, { keepOriginal: false }); + }, + }, { text: i18n.ts.upload, icon: 'ti ti-upload', - action: () => { selectLocalFile(); }, + action: () => { + chooseFileFromPc(true, { keepOriginal: true }); + }, }, { text: i18n.ts.fromUrl, icon: 'ti ti-link', @@ -716,7 +710,7 @@ function onContextmenu(ev: MouseEvent) { } onMounted(() => { - if (defaultStore.state.enableInfiniteScroll && loadMoreFiles.value) { + if (prefer.s.enableInfiniteScroll && loadMoreFiles.value) { nextTick(() => { ilFilesObserver.observe(loadMoreFiles.value?.$el); }); @@ -737,7 +731,7 @@ onMounted(() => { }); onActivated(() => { - if (defaultStore.state.enableInfiniteScroll) { + if (prefer.s.enableInfiniteScroll) { nextTick(() => { ilFilesObserver.observe(loadMoreFiles.value?.$el); }); @@ -751,22 +745,17 @@ onBeforeUnmount(() => { </script> <style lang="scss" module> -.root { - display: flex; - flex-direction: column; - height: 100%; -} - .nav { display: flex; - z-index: 2; width: 100%; padding: 0 8px; box-sizing: border-box; overflow: auto; font-size: 0.9em; - box-shadow: 0 1px 0 var(--MI_THEME-divider); - user-select: none; + background: color(from var(--MI_THEME-bg) srgb r g b / 0.75); + -webkit-backdrop-filter: var(--MI-blur, blur(15px)); + backdrop-filter: var(--MI-blur, blur(15px)); + border-bottom: solid 0.5px var(--MI_THEME-divider); } .navPath { diff --git a/packages/frontend/src/components/MkDriveSelectDialog.vue b/packages/frontend/src/components/MkDriveSelectDialog.vue index f1ecc27123..1b9455e3f3 100644 --- a/packages/frontend/src/components/MkDriveSelectDialog.vue +++ b/packages/frontend/src/components/MkDriveSelectDialog.vue @@ -24,7 +24,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { ref, shallowRef } from 'vue'; +import { ref, useTemplateRef } from 'vue'; import * as Misskey from 'misskey-js'; import XDrive from '@/components/MkDrive.vue'; import MkModalWindow from '@/components/MkModalWindow.vue'; @@ -43,7 +43,7 @@ const emit = defineEmits<{ (ev: 'closed'): void; }>(); -const dialog = shallowRef<InstanceType<typeof MkModalWindow>>(); +const dialog = useTemplateRef('dialog'); const selected = ref<Misskey.entities.DriveFile[] | Misskey.entities.DriveFolder[]>([]); diff --git a/packages/frontend/src/components/MkEmbedCodeGenDialog.vue b/packages/frontend/src/components/MkEmbedCodeGenDialog.vue index 6e9eb75920..d18fe0ed0c 100644 --- a/packages/frontend/src/components/MkEmbedCodeGenDialog.vue +++ b/packages/frontend/src/components/MkEmbedCodeGenDialog.vue @@ -89,7 +89,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script setup lang="ts"> -import { shallowRef, ref, computed, nextTick, onMounted, onDeactivated, onUnmounted } from 'vue'; +import { useTemplateRef, ref, computed, nextTick, onMounted, onDeactivated, onUnmounted } from 'vue'; import { url } from '@@/js/config.js'; import { embedRouteWithScrollbar } from '@@/js/embed-page.js'; import type { EmbeddableEntity, EmbedParams } from '@@/js/embed-page.js'; @@ -105,8 +105,8 @@ import MkInfo from '@/components/MkInfo.vue'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; -import { copyToClipboard } from '@/scripts/copy-to-clipboard.js'; -import { normalizeEmbedParams, getEmbedCode } from '@/scripts/get-embed-code.js'; +import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; +import { normalizeEmbedParams, getEmbedCode } from '@/utility/get-embed-code.js'; const emit = defineEmits<{ (ev: 'ok'): void; @@ -121,7 +121,7 @@ const props = defineProps<{ }>(); //#region Modalの制御 -const dialogEl = shallowRef<InstanceType<typeof MkModalWindow>>(); +const dialogEl = useTemplateRef('dialogEl'); function cancel() { emit('cancel'); @@ -180,7 +180,7 @@ function applyToPreview() { nextTick(() => { if (currentPreviewUrl === embedPreviewUrl.value) { // URLが変わらなくてもリロード - iframeEl.value?.contentWindow?.location.reload(); + iframeEl.value?.contentWindow?.window.location.reload(); } }); } @@ -194,14 +194,13 @@ function generate() { function doCopy() { copyToClipboard(result.value); - os.success(); } //#endregion //#region プレビューのリサイズ -const resizerRootEl = shallowRef<HTMLDivElement>(); +const resizerRootEl = useTemplateRef('resizerRootEl'); const iframeLoading = ref(true); -const iframeEl = shallowRef<HTMLIFrameElement>(); +const iframeEl = useTemplateRef('iframeEl'); const iframeHeight = ref(0); const iframeScale = ref(1); const iframeStyle = computed(() => { diff --git a/packages/frontend/src/components/MkEmojiPicker.vue b/packages/frontend/src/components/MkEmojiPicker.vue index 62a1000674..d4367f6ee8 100644 --- a/packages/frontend/src/components/MkEmojiPicker.vue +++ b/packages/frontend/src/components/MkEmojiPicker.vue @@ -115,7 +115,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { ref, shallowRef, computed, watch, onMounted } from 'vue'; +import { ref, useTemplateRef, computed, watch, onMounted } from 'vue'; import * as Misskey from 'misskey-js'; import { emojilist, @@ -131,17 +131,18 @@ import type { import XSection from '@/components/MkEmojiPicker.section.vue'; import MkRippleEffect from '@/components/MkRippleEffect.vue'; import * as os from '@/os.js'; -import { isTouchUsing } from '@/scripts/touch.js'; -import { deviceKind } from '@/scripts/device-kind.js'; +import { isTouchUsing } from '@/utility/touch.js'; +import { deviceKind } from '@/utility/device-kind.js'; import { i18n } from '@/i18n.js'; -import { defaultStore } from '@/store.js'; +import { store } from '@/store.js'; import { customEmojiCategories, customEmojis, customEmojisMap } from '@/custom-emojis.js'; -import { $i } from '@/account.js'; -import { checkReactionPermissions } from '@/scripts/check-reaction-permissions.js'; +import { $i } from '@/i.js'; +import { checkReactionPermissions } from '@/utility/check-reaction-permissions.js'; +import { prefer } from '@/preferences.js'; const props = withDefaults(defineProps<{ showPinned?: boolean; - pinnedEmojis?: string[]; + pinnedEmojis?: string[]; maxHeight?: number; asDrawer?: boolean; asWindow?: boolean; @@ -156,15 +157,16 @@ const emit = defineEmits<{ (ev: 'esc'): void; }>(); -const searchEl = shallowRef<HTMLInputElement>(); -const emojisEl = shallowRef<HTMLDivElement>(); +const searchEl = useTemplateRef('searchEl'); +const emojisEl = useTemplateRef('emojisEl'); const { emojiPickerScale, emojiPickerWidth, emojiPickerHeight, - recentlyUsedEmojis, -} = defaultStore.reactiveState; +} = prefer.r; + +const recentlyUsedEmojis = store.r.recentlyUsedEmojis; const recentlyUsedEmojisDef = computed(() => { return recentlyUsedEmojis.value.map(getDef); @@ -317,7 +319,7 @@ watch(q, () => { } if (matches.size >= max) return matches; - for (const index of Object.values(defaultStore.state.additionalUnicodeEmojiIndexes)) { + for (const index of Object.values(store.s.additionalUnicodeEmojiIndexes)) { for (const emoji of emojis) { if (keywords.every(keyword => index[emoji.char].some(k => k.includes(keyword)))) { matches.add(emoji); @@ -334,7 +336,7 @@ watch(q, () => { } if (matches.size >= max) return matches; - for (const index of Object.values(defaultStore.state.additionalUnicodeEmojiIndexes)) { + for (const index of Object.values(store.s.additionalUnicodeEmojiIndexes)) { for (const emoji of emojis) { if (index[emoji.char].some(k => k.startsWith(newQ))) { matches.add(emoji); @@ -351,7 +353,7 @@ watch(q, () => { } if (matches.size >= max) return matches; - for (const index of Object.values(defaultStore.state.additionalUnicodeEmojiIndexes)) { + for (const index of Object.values(store.s.additionalUnicodeEmojiIndexes)) { for (const emoji of emojis) { if (index[emoji.char].some(k => k.includes(newQ))) { matches.add(emoji); @@ -413,7 +415,7 @@ function computeButtonTitle(ev: MouseEvent): void { function chosen(emoji: string | Misskey.entities.EmojiSimple | UnicodeEmojiDef, ev?: MouseEvent) { const el = ev && (ev.currentTarget ?? ev.target) as HTMLElement | null | undefined; - if (el && defaultStore.state.animation) { + if (el && prefer.s.animation) { const rect = el.getBoundingClientRect(); const x = rect.left + (el.offsetWidth / 2); const y = rect.top + (el.offsetHeight / 2); @@ -427,10 +429,10 @@ function chosen(emoji: string | Misskey.entities.EmojiSimple | UnicodeEmojiDef, // 最近使った絵文字更新 if (!pinned.value?.includes(key)) { - let recents = defaultStore.state.recentlyUsedEmojis; + let recents = store.s.recentlyUsedEmojis; recents = recents.filter((emoji) => emoji !== key); recents.unshift(key); - defaultStore.set('recentlyUsedEmojis', recents.splice(0, 32)); + store.set('recentlyUsedEmojis', recents.splice(0, 32)); } } diff --git a/packages/frontend/src/components/MkEmojiPickerDialog.vue b/packages/frontend/src/components/MkEmojiPickerDialog.vue index 21c712b441..662e2a118d 100644 --- a/packages/frontend/src/components/MkEmojiPickerDialog.vue +++ b/packages/frontend/src/components/MkEmojiPickerDialog.vue @@ -8,7 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only ref="modal" v-slot="{ type, maxHeight }" :zPriority="'middle'" - :preferType="defaultStore.state.emojiPickerStyle" + :preferType="prefer.s.emojiPickerStyle" :hasInteractionWithOtherFocusTrappedEls="true" :transparentBg="true" :manualShowing="manualShowing" @@ -37,19 +37,19 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import * as Misskey from 'misskey-js'; -import { shallowRef } from 'vue'; +import { useTemplateRef } from 'vue'; import MkModal from '@/components/MkModal.vue'; import MkEmojiPicker from '@/components/MkEmojiPicker.vue'; -import { defaultStore } from '@/store.js'; +import { prefer } from '@/preferences.js'; const props = withDefaults(defineProps<{ manualShowing?: boolean | null; src?: HTMLElement; showPinned?: boolean; - pinnedEmojis?: string[], + pinnedEmojis?: string[], asReactionPicker?: boolean; targetNote?: Misskey.entities.Note; - choseAndClose?: boolean; + choseAndClose?: boolean; }>(), { manualShowing: null, showPinned: true, @@ -64,8 +64,8 @@ const emit = defineEmits<{ (ev: 'closed'): void; }>(); -const modal = shallowRef<InstanceType<typeof MkModal>>(); -const picker = shallowRef<InstanceType<typeof MkEmojiPicker>>(); +const modal = useTemplateRef('modal'); +const picker = useTemplateRef('picker'); function chosen(emoji: string) { emit('done', emoji); @@ -79,7 +79,7 @@ function opening() { picker.value?.focus(); // 何故かちょっと待たないとフォーカスされない - setTimeout(() => { + window.setTimeout(() => { picker.value?.focus(); }, 10); } diff --git a/packages/frontend/src/components/MkExtensionInstaller.vue b/packages/frontend/src/components/MkExtensionInstaller.vue index d59b20435e..a2247d844b 100644 --- a/packages/frontend/src/components/MkExtensionInstaller.vue +++ b/packages/frontend/src/components/MkExtensionInstaller.vue @@ -11,54 +11,91 @@ SPDX-License-Identifier: AGPL-3.0-only <!-- 拡張用? --> <i v-else class="ti ti-download"></i> </div> - <h2 :class="$style.extInstallerTitle">{{ i18n.ts._externalResourceInstaller[`_${extension.type}`].title }}</h2> - <div :class="$style.extInstallerNormDesc">{{ i18n.ts._externalResourceInstaller.checkVendorBeforeInstall }}</div> - <MkInfo v-if="isPlugin" :warn="true">{{ i18n.ts._plugin.installWarn }}</MkInfo> - <FormSection> - <template #label>{{ i18n.ts._externalResourceInstaller[`_${extension.type}`].metaTitle }}</template> - <div class="_gaps_s"> - <FormSplit> + + <h2 v-if="isPlugin" :class="$style.extInstallerTitle">{{ i18n.ts._externalResourceInstaller._plugin.title }}</h2> + <h2 v-else-if="isTheme" :class="$style.extInstallerTitle">{{ i18n.ts._externalResourceInstaller._theme.title }}</h2> + + <MkInfo :warn="true">{{ i18n.ts._externalResourceInstaller.checkVendorBeforeInstall }}</MkInfo> + + <div v-if="isPlugin" class="_gaps_s"> + <MkFolder :defaultOpen="true"> + <template #icon><i class="ti ti-info-circle"></i></template> + <template #label>{{ i18n.ts.metadata }}</template> + + <div class="_gaps_s"> + <FormSplit> + <MkKeyValue> + <template #key>{{ i18n.ts.name }}</template> + <template #value>{{ extension.meta.name }}</template> + </MkKeyValue> + <MkKeyValue> + <template #key>{{ i18n.ts.author }}</template> + <template #value>{{ extension.meta.author }}</template> + </MkKeyValue> + </FormSplit> + <MkKeyValue> + <template #key>{{ i18n.ts.description }}</template> + <template #value>{{ extension.meta.description ?? i18n.ts.none }}</template> + </MkKeyValue> <MkKeyValue> - <template #key>{{ i18n.ts.name }}</template> - <template #value>{{ extension.meta.name }}</template> + <template #key>{{ i18n.ts.version }}</template> + <template #value>{{ extension.meta.version }}</template> </MkKeyValue> <MkKeyValue> - <template #key>{{ i18n.ts.author }}</template> - <template #value>{{ extension.meta.author }}</template> + <template #key>{{ i18n.ts.permission }}</template> + <template #value> + <ul v-if="extension.meta.permissions && extension.meta.permissions.length > 0" :class="$style.extInstallerKVList"> + <li v-for="permission in extension.meta.permissions" :key="permission">{{ i18n.ts._permissions[permission] }}</li> + </ul> + <template v-else>{{ i18n.ts.none }}</template> + </template> </MkKeyValue> - </FormSplit> - <MkKeyValue v-if="isPlugin"> - <template #key>{{ i18n.ts.description }}</template> - <template #value>{{ extension.meta.description ?? i18n.ts.none }}</template> - </MkKeyValue> - <MkKeyValue v-if="isPlugin"> - <template #key>{{ i18n.ts.version }}</template> - <template #value>{{ extension.meta.version }}</template> - </MkKeyValue> - <MkKeyValue v-if="isPlugin"> - <template #key>{{ i18n.ts.permission }}</template> - <template #value> - <ul v-if="extension.meta.permissions && extension.meta.permissions.length > 0" :class="$style.extInstallerKVList"> - <li v-for="permission in extension.meta.permissions" :key="permission">{{ i18n.ts._permissions[permission] }}</li> - </ul> - <template v-else>{{ i18n.ts.none }}</template> - </template> - </MkKeyValue> - <MkKeyValue v-if="isTheme"> - <template #key>{{ i18n.ts._externalResourceInstaller._meta.base }}</template> - <template #value>{{ i18n.ts[extension.meta.base ?? 'none'] }}</template> - </MkKeyValue> - <MkFolder> - <template #icon><i class="ti ti-code"></i></template> - <template #label>{{ i18n.ts._plugin.viewSource }}</template> + </div> + </MkFolder> + + <MkFolder :withSpacer="false"> + <template #icon><i class="ti ti-code"></i></template> + <template #label>{{ i18n.ts._plugin.viewSource }}</template> + + <MkCode :code="extension.raw"/> + </MkFolder> + </div> + <div v-else-if="isTheme" class="_gaps_s"> + <MkFolder :defaultOpen="true"> + <template #icon><i class="ti ti-info-circle"></i></template> + <template #label>{{ i18n.ts.metadata }}</template> + + <div class="_gaps_s"> + <FormSplit> + <MkKeyValue> + <template #key>{{ i18n.ts.name }}</template> + <template #value>{{ extension.meta.name }}</template> + </MkKeyValue> + <MkKeyValue> + <template #key>{{ i18n.ts.author }}</template> + <template #value>{{ extension.meta.author }}</template> + </MkKeyValue> + </FormSplit> + <MkKeyValue> + <template #key>{{ i18n.ts._externalResourceInstaller._meta.base }}</template> + <template #value>{{ i18n.ts[extension.meta.base ?? 'none'] }}</template> + </MkKeyValue> + </div> + </MkFolder> + + <MkFolder :withSpacer="false"> + <template #icon><i class="ti ti-code"></i></template> + <template #label>{{ i18n.ts._theme.code }}</template> + + <MkCode :code="extension.raw"/> + </MkFolder> + </div> - <MkCode :code="extension.raw"/> - </MkFolder> - </div> - </FormSection> <slot name="additionalInfo"/> + <div class="_buttonsCenter"> - <MkButton primary @click="emits('confirm')"><i class="ti ti-check"></i> {{ i18n.ts.install }}</MkButton> + <MkButton danger rounded large @click="emits('cancel')"><i class="ti ti-x"></i> {{ i18n.ts.cancel }}</MkButton> + <MkButton gradate rounded large @click="emits('confirm')"><i class="ti ti-download"></i> {{ i18n.ts.install }}</MkButton> </div> </div> </template> @@ -105,6 +142,7 @@ const props = defineProps<{ const emits = defineEmits<{ (ev: 'confirm'): void; + (ev: 'cancel'): void; }>(); </script> @@ -112,13 +150,13 @@ const emits = defineEmits<{ .extInstallerRoot { border-radius: var(--MI-radius); background: var(--MI_THEME-panel); - padding: 1.5rem; + padding: 20px; } .extInstallerIconWrapper { width: 48px; height: 48px; - font-size: 24px; + font-size: 20px; line-height: 48px; text-align: center; border-radius: 50%; @@ -135,10 +173,6 @@ const emits = defineEmits<{ margin: 0; } -.extInstallerNormDesc { - text-align: center; -} - .extInstallerKVList { margin-top: 0; margin-bottom: 0; diff --git a/packages/frontend/src/components/MkFeatureBanner.vue b/packages/frontend/src/components/MkFeatureBanner.vue new file mode 100644 index 0000000000..e990ffc8f0 --- /dev/null +++ b/packages/frontend/src/components/MkFeatureBanner.vue @@ -0,0 +1,43 @@ +<!-- +SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div v-panel :class="$style.root"> + <img :class="$style.img" :src="icon"/> + <div :class="$style.text"> + <slot></slot> + </div> +</div> +</template> + +<script setup lang="ts"> +withDefaults(defineProps<{ + icon: string; + color: string; +}>(), { +}); +</script> + +<style module lang="scss"> +.root { + padding: 20px 24px; + text-align: center; + border-radius: var(--MI-radius); + background: linear-gradient(180deg, color(from v-bind(color) srgb r g b / 0.1), color(from v-bind(color) srgb r g b / 0)); +} + +.img { + display: block; + margin: 0 auto; + width: 40px; + aspect-ratio: 1; +} + +.text { + margin-top: 12px; + font-size: 85%; + mix-blend-mode: luminosity; +} +</style> diff --git a/packages/frontend/src/components/MkFileCaptionEditWindow.vue b/packages/frontend/src/components/MkFileCaptionEditWindow.vue index 76bb965101..120d5355b2 100644 --- a/packages/frontend/src/components/MkFileCaptionEditWindow.vue +++ b/packages/frontend/src/components/MkFileCaptionEditWindow.vue @@ -25,7 +25,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { shallowRef, ref } from 'vue'; +import { useTemplateRef, ref } from 'vue'; import * as Misskey from 'misskey-js'; import MkModalWindow from '@/components/MkModalWindow.vue'; import MkTextarea from '@/components/MkTextarea.vue'; @@ -42,7 +42,7 @@ const emit = defineEmits<{ (ev: 'closed'): void; }>(); -const dialog = shallowRef<InstanceType<typeof MkModalWindow>>(); +const dialog = useTemplateRef('dialog'); const caption = ref(props.default); diff --git a/packages/frontend/src/components/MkFoldableSection.vue b/packages/frontend/src/components/MkFoldableSection.vue index fb1b5220fb..b9888d9b64 100644 --- a/packages/frontend/src/components/MkFoldableSection.vue +++ b/packages/frontend/src/components/MkFoldableSection.vue @@ -14,10 +14,10 @@ SPDX-License-Identifier: AGPL-3.0-only </button> </header> <Transition - :enterActiveClass="defaultStore.state.animation ? $style.folderToggleEnterActive : ''" - :leaveActiveClass="defaultStore.state.animation ? $style.folderToggleLeaveActive : ''" - :enterFromClass="defaultStore.state.animation ? $style.folderToggleEnterFrom : ''" - :leaveToClass="defaultStore.state.animation ? $style.folderToggleLeaveTo : ''" + :enterActiveClass="prefer.s.animation ? $style.folderToggleEnterActive : ''" + :leaveActiveClass="prefer.s.animation ? $style.folderToggleLeaveActive : ''" + :enterFromClass="prefer.s.animation ? $style.folderToggleEnterFrom : ''" + :leaveToClass="prefer.s.animation ? $style.folderToggleLeaveTo : ''" @enter="enter" @afterEnter="afterEnter" @leave="leave" @@ -31,10 +31,10 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted, ref, shallowRef, watch } from 'vue'; +import { onMounted, ref, useTemplateRef, watch } from 'vue'; import { miLocalStorage } from '@/local-storage.js'; -import { defaultStore } from '@/store.js'; -import { getBgColor } from '@/scripts/get-bg-color.js'; +import { prefer } from '@/preferences.js'; +import { getBgColor } from '@/utility/get-bg-color.js'; const miLocalStoragePrefix = 'ui:folder:' as const; @@ -46,7 +46,7 @@ const props = withDefaults(defineProps<{ persistKey: null, }); -const rootEl = shallowRef<HTMLElement>(); +const rootEl = useTemplateRef('rootEl'); const parentBg = ref<string | null>(null); // eslint-disable-next-line vue/no-setup-props-reactivity-loss const showBody = ref((props.persistKey && miLocalStorage.getItem(`${miLocalStoragePrefix}${props.persistKey}`)) ? (miLocalStorage.getItem(`${miLocalStoragePrefix}${props.persistKey}`) === 't') : props.expanded); diff --git a/packages/frontend/src/components/MkFolder.vue b/packages/frontend/src/components/MkFolder.vue index 384c0c0b34..8b4bacba69 100644 --- a/packages/frontend/src/components/MkFolder.vue +++ b/packages/frontend/src/components/MkFolder.vue @@ -27,10 +27,10 @@ SPDX-License-Identifier: AGPL-3.0-only <div v-if="openedAtLeastOnce" :class="[$style.body, { [$style.bgSame]: bgSame }]" :style="{ maxHeight: maxHeight ? `${maxHeight}px` : undefined, overflow: maxHeight ? `auto` : undefined }" :aria-hidden="!opened"> <Transition - :enterActiveClass="defaultStore.state.animation ? $style.transition_toggle_enterActive : ''" - :leaveActiveClass="defaultStore.state.animation ? $style.transition_toggle_leaveActive : ''" - :enterFromClass="defaultStore.state.animation ? $style.transition_toggle_enterFrom : ''" - :leaveToClass="defaultStore.state.animation ? $style.transition_toggle_leaveTo : ''" + :enterActiveClass="prefer.s.animation ? $style.transition_toggle_enterActive : ''" + :leaveActiveClass="prefer.s.animation ? $style.transition_toggle_leaveActive : ''" + :enterFromClass="prefer.s.animation ? $style.transition_toggle_enterFrom : ''" + :leaveToClass="prefer.s.animation ? $style.transition_toggle_leaveTo : ''" @enter="enter" @afterEnter="afterEnter" @leave="leave" @@ -56,9 +56,9 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { nextTick, onMounted, ref, shallowRef } from 'vue'; -import { defaultStore } from '@/store.js'; -import { getBgColor } from '@/scripts/get-bg-color.js'; +import { nextTick, onMounted, ref, useTemplateRef } from 'vue'; +import { prefer } from '@/preferences.js'; +import { getBgColor } from '@/utility/get-bg-color.js'; const props = withDefaults(defineProps<{ defaultOpen?: boolean; @@ -74,7 +74,7 @@ const props = withDefaults(defineProps<{ spacerMax: 22, }); -const rootEl = shallowRef<HTMLElement>(); +const rootEl = useTemplateRef('rootEl'); const bgSame = ref(false); const opened = ref(props.defaultOpen); const openedAtLeastOnce = ref(props.defaultOpen); @@ -116,7 +116,7 @@ function toggle() { } onMounted(() => { - const computedStyle = getComputedStyle(document.documentElement); + const computedStyle = getComputedStyle(window.document.documentElement); const parentBg = getBgColor(rootEl.value?.parentElement) ?? 'transparent'; const myBg = computedStyle.getPropertyValue('--MI_THEME-panel'); bgSame.value = parentBg === myBg; @@ -175,7 +175,7 @@ onMounted(() => { } .headerLower { - color: var(--MI_THEME-fgTransparentWeak); + color: color(from var(--MI_THEME-fg) srgb r g b / 0.75); font-size: .85em; padding-left: 4px; } @@ -209,13 +209,13 @@ onMounted(() => { } .headerTextSub { - color: var(--MI_THEME-fgTransparentWeak); + color: color(from var(--MI_THEME-fg) srgb r g b / 0.75); font-size: .85em; } .headerRight { margin-left: auto; - color: var(--MI_THEME-fgTransparentWeak); + color: color(from var(--MI_THEME-fg) srgb r g b / 0.75); white-space: nowrap; } @@ -239,7 +239,7 @@ onMounted(() => { bottom: var(--MI-stickyBottom, 0px); left: 0; padding: 12px; - background: var(--MI_THEME-acrylicBg); + background: color(from var(--MI_THEME-bg) srgb r g b / 0.5); -webkit-backdrop-filter: var(--MI-blur, blur(15px)); backdrop-filter: var(--MI-blur, blur(15px)); background-size: auto auto; diff --git a/packages/frontend/src/components/MkFollowButton.vue b/packages/frontend/src/components/MkFollowButton.vue index c1dc67f776..b65f610986 100644 --- a/packages/frontend/src/components/MkFollowButton.vue +++ b/packages/frontend/src/components/MkFollowButton.vue @@ -39,13 +39,13 @@ import { onBeforeUnmount, onMounted, ref } from 'vue'; import * as Misskey from 'misskey-js'; import { host } from '@@/js/config.js'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { useStream } from '@/stream.js'; import { i18n } from '@/i18n.js'; -import { claimAchievement } from '@/scripts/achievements.js'; -import { pleaseLogin } from '@/scripts/please-login.js'; -import { $i } from '@/account.js'; -import { defaultStore } from '@/store.js'; +import { claimAchievement } from '@/utility/achievements.js'; +import { pleaseLogin } from '@/utility/please-login.js'; +import { $i } from '@/i.js'; +import { prefer } from '@/preferences.js'; const props = withDefaults(defineProps<{ user: Misskey.entities.UserDetailed, @@ -100,7 +100,7 @@ async function onClick() { userId: props.user.id, }); } else { - if (defaultStore.state.alwaysConfirmFollow) { + if (prefer.s.alwaysConfirmFollow) { const { canceled } = await os.confirm({ type: 'question', text: i18n.tsx.followConfirm({ name: props.user.name || props.user.username }), @@ -120,11 +120,11 @@ async function onClick() { } else { await misskeyApi('following/create', { userId: props.user.id, - withReplies: defaultStore.state.defaultWithReplies, + withReplies: prefer.s.defaultFollowWithReplies, }); emit('update:user', { ...props.user, - withReplies: defaultStore.state.defaultWithReplies, + withReplies: prefer.s.defaultFollowWithReplies, }); hasPendingFollowRequestFromYou.value = true; @@ -211,13 +211,13 @@ onBeforeUnmount(() => { background: var(--MI_THEME-accent); &:hover { - background: var(--MI_THEME-accentLighten); - border-color: var(--MI_THEME-accentLighten); + background: hsl(from var(--MI_THEME-accent) h s calc(l + 10)); + border-color: hsl(from var(--MI_THEME-accent) h s calc(l + 10)); } &:active { - background: var(--MI_THEME-accentDarken); - border-color: var(--MI_THEME-accentDarken); + background: hsl(from var(--MI_THEME-accent) h s calc(l - 10)); + border-color: hsl(from var(--MI_THEME-accent) h s calc(l - 10)); } } diff --git a/packages/frontend/src/components/MkFormDialog.file.vue b/packages/frontend/src/components/MkFormDialog.file.vue index ecb6cf882b..0a902f3400 100644 --- a/packages/frontend/src/components/MkFormDialog.file.vue +++ b/packages/frontend/src/components/MkFormDialog.file.vue @@ -15,8 +15,8 @@ import * as Misskey from 'misskey-js'; import { computed, ref } from 'vue'; import { i18n } from '@/i18n.js'; import MkButton from '@/components/MkButton.vue'; -import { selectFile } from '@/scripts/select-file.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { selectFile } from '@/utility/select-file.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; const props = defineProps<{ fileId?: string | null; diff --git a/packages/frontend/src/components/MkFormDialog.vue b/packages/frontend/src/components/MkFormDialog.vue index a639eae208..4756079e76 100644 --- a/packages/frontend/src/components/MkFormDialog.vue +++ b/packages/frontend/src/components/MkFormDialog.vue @@ -63,7 +63,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> </div> <div v-else class="_fullinfo"> - <img :src="infoImageUrl" class="_ghost"/> + <img :src="infoImageUrl" draggable="false"/> <div>{{ i18n.ts.nothing }}</div> </div> </MkSpacer> @@ -71,7 +71,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { reactive, shallowRef } from 'vue'; +import { reactive, useTemplateRef } from 'vue'; import MkInput from './MkInput.vue'; import MkTextarea from './MkTextarea.vue'; import MkSwitch from './MkSwitch.vue'; @@ -80,7 +80,7 @@ import MkRange from './MkRange.vue'; import MkButton from './MkButton.vue'; import MkRadios from './MkRadios.vue'; import XFile from './MkFormDialog.file.vue'; -import type { Form } from '@/scripts/form.js'; +import type { Form } from '@/utility/form.js'; import MkModalWindow from '@/components/MkModalWindow.vue'; import { i18n } from '@/i18n.js'; import { infoImageUrl } from '@/instance.js'; @@ -99,7 +99,7 @@ const emit = defineEmits<{ (ev: 'closed'): void; }>(); -const dialog = shallowRef<InstanceType<typeof MkModalWindow>>(); +const dialog = useTemplateRef('dialog'); const values = reactive({}); for (const item in props.form) { diff --git a/packages/frontend/src/components/MkFukidashi.vue b/packages/frontend/src/components/MkFukidashi.vue index 8b1c56fca4..fba5dc854c 100644 --- a/packages/frontend/src/components/MkFukidashi.vue +++ b/packages/frontend/src/components/MkFukidashi.vue @@ -10,6 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only tail === 'left' ? $style.left : $style.right, negativeMargin === true && $style.negativeMargin, shadow === true && $style.shadow, + accented === true && $style.accented ]" > <div :class="$style.bg"> @@ -30,10 +31,12 @@ withDefaults(defineProps<{ tail?: 'left' | 'right' | 'none'; negativeMargin?: boolean; shadow?: boolean; + accented?: boolean; }>(), { tail: 'right', negativeMargin: false, shadow: false, + accented: false, }); </script> @@ -47,6 +50,10 @@ withDefaults(defineProps<{ min-height: calc(var(--fukidashi-radius) * 2); padding-top: calc(var(--fukidashi-radius) * .13); + &.accented { + --fukidashi-bg: color-mix(in srgb, var(--MI_THEME-accent), var(--MI_THEME-panel) 85%); + } + &.shadow { filter: drop-shadow(0 4px 32px var(--MI_THEME-shadow)); } @@ -77,7 +84,13 @@ withDefaults(defineProps<{ .content { position: relative; - padding: 8px 12px; + padding: 10px 14px; +} + +@container (max-width: 450px) { + .content { + padding: 8px 12px; + } } .tail { diff --git a/packages/frontend/src/components/MkGalleryPostPreview.vue b/packages/frontend/src/components/MkGalleryPostPreview.vue index 22f8355acf..49a6c65170 100644 --- a/packages/frontend/src/components/MkGalleryPostPreview.vue +++ b/packages/frontend/src/components/MkGalleryPostPreview.vue @@ -35,14 +35,14 @@ SPDX-License-Identifier: AGPL-3.0-only import * as Misskey from 'misskey-js'; import { computed, ref } from 'vue'; import ImgWithBlurhash from '@/components/MkImgWithBlurhash.vue'; -import { defaultStore } from '@/store.js'; +import { prefer } from '@/preferences.js'; const props = defineProps<{ post: Misskey.entities.GalleryPost; }>(); const hover = ref(false); -const safe = computed(() => defaultStore.state.nsfw === 'ignore' || defaultStore.state.nsfw === 'respect' && !props.post.isSensitive); +const safe = computed(() => prefer.s.nsfw === 'ignore' || prefer.s.nsfw === 'respect' && !props.post.isSensitive); const show = computed(() => safe.value || hover.value); function enterHover(): void { diff --git a/packages/frontend/src/components/MkHeatmap.vue b/packages/frontend/src/components/MkHeatmap.vue index 0cc0df9911..28bb936755 100644 --- a/packages/frontend/src/components/MkHeatmap.vue +++ b/packages/frontend/src/components/MkHeatmap.vue @@ -13,14 +13,14 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted, nextTick, watch, shallowRef, ref } from 'vue'; +import { onMounted, nextTick, watch, useTemplateRef, ref } from 'vue'; import { Chart } from 'chart.js'; import * as Misskey from 'misskey-js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { defaultStore } from '@/store.js'; -import { useChartTooltip } from '@/scripts/use-chart-tooltip.js'; -import { alpha } from '@/scripts/color.js'; -import { initChart } from '@/scripts/init-chart.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { store } from '@/store.js'; +import { useChartTooltip } from '@/use/use-chart-tooltip.js'; +import { alpha } from '@/utility/color.js'; +import { initChart } from '@/utility/init-chart.js'; initChart(); @@ -35,8 +35,8 @@ const props = withDefaults(defineProps<{ label: '', }); -const rootEl = shallowRef<HTMLDivElement | null>(null); -const chartEl = shallowRef<HTMLCanvasElement | null>(null); +const rootEl = useTemplateRef('rootEl'); +const chartEl = useTemplateRef('chartEl'); const now = new Date(); let chartInstance: Chart | null = null; const fetching = ref(true); @@ -106,7 +106,7 @@ async function renderChart() { await nextTick(); - const color = defaultStore.state.darkMode ? '#b4e900' : '#86b300'; + const color = store.s.darkMode ? '#b4e900' : '#86b300'; // 視覚上の分かりやすさのため上から最も大きい3つの値の平均を最大値とする const max = values.slice().sort((a, b) => b - a).slice(0, 3).reduce((a, b) => a + b, 0) / 3; diff --git a/packages/frontend/src/components/MkHorizontalSwipe.vue b/packages/frontend/src/components/MkHorizontalSwipe.vue index 196c962a06..1d0ffaea11 100644 --- a/packages/frontend/src/components/MkHorizontalSwipe.vue +++ b/packages/frontend/src/components/MkHorizontalSwipe.vue @@ -19,21 +19,20 @@ SPDX-License-Identifier: AGPL-3.0-only :leaveToClass="transitionName === 'swipeAnimationLeft' ? $style.swipeAnimationLeft_leaveTo : $style.swipeAnimationRight_leaveTo" :style="`--swipe: ${pullDistance}px;`" > - <!-- 【注意】slot内の最上位要素に動的にkeyを設定すること --> - <!-- 各最上位要素にユニークなkeyの指定がないとTransitionがうまく動きません --> - <slot></slot> + <div :key="tabModel"> + <slot></slot> + </div> </Transition> </div> </template> <script lang="ts" setup> -import { ref, shallowRef, computed, nextTick, watch } from 'vue'; +import { ref, useTemplateRef, computed, nextTick, watch } from 'vue'; import type { Tab } from '@/components/global/MkPageHeader.tabs.vue'; -import { defaultStore } from '@/store.js'; -import { isHorizontalSwipeSwiping as isSwiping } from '@/scripts/touch.js'; +import { isHorizontalSwipeSwiping as isSwiping } from '@/utility/touch.js'; +import { prefer } from '@/preferences.js'; -const rootEl = shallowRef<HTMLDivElement>(); +const rootEl = useTemplateRef('rootEl'); -// eslint-disable-next-line no-undef const tabModel = defineModel<string>('tab'); const props = defineProps<{ @@ -44,7 +43,7 @@ const emit = defineEmits<{ (ev: 'swiped', newKey: string, direction: 'left' | 'right'): void; }>(); -const shouldAnimate = computed(() => defaultStore.reactiveState.enableHorizontalSwipe.value || defaultStore.reactiveState.animation.value); +const shouldAnimate = computed(() => prefer.r.enableHorizontalSwipe.value || prefer.r.animation.value); // ▼ しきい値 ▼ // @@ -72,7 +71,7 @@ const isSwipingForClass = ref(false); let swipeAborted = false; function touchStart(event: TouchEvent) { - if (!defaultStore.reactiveState.enableHorizontalSwipe.value) return; + if (!prefer.r.enableHorizontalSwipe.value) return; if (event.touches.length !== 1) return; @@ -83,7 +82,7 @@ function touchStart(event: TouchEvent) { } function touchMove(event: TouchEvent) { - if (!defaultStore.reactiveState.enableHorizontalSwipe.value) return; + if (!prefer.r.enableHorizontalSwipe.value) return; if (event.touches.length !== 1) return; @@ -101,7 +100,7 @@ function touchMove(event: TouchEvent) { pullDistance.value = 0; isSwiping.value = false; - setTimeout(() => { + window.setTimeout(() => { isSwipingForClass.value = false; }, 400); @@ -134,7 +133,7 @@ function touchEnd(event: TouchEvent) { return; } - if (!defaultStore.reactiveState.enableHorizontalSwipe.value) return; + if (!prefer.r.enableHorizontalSwipe.value) return; if (event.touches.length !== 0) return; diff --git a/packages/frontend/src/components/MkImgWithBlurhash.vue b/packages/frontend/src/components/MkImgWithBlurhash.vue index c04d0864fb..e3a0a371b4 100644 --- a/packages/frontend/src/components/MkImgWithBlurhash.vue +++ b/packages/frontend/src/components/MkImgWithBlurhash.vue @@ -6,16 +6,42 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div ref="root" :class="['chromatic-ignore', $style.root, { [$style.cover]: cover }]" :title="title ?? ''"> <TransitionGroup - :duration="defaultStore.state.animation && props.transition?.duration || undefined" - :enterActiveClass="defaultStore.state.animation && props.transition?.enterActiveClass || undefined" - :leaveActiveClass="defaultStore.state.animation && (props.transition?.leaveActiveClass ?? $style.transition_leaveActive) || undefined" - :enterFromClass="defaultStore.state.animation && props.transition?.enterFromClass || undefined" - :leaveToClass="defaultStore.state.animation && props.transition?.leaveToClass || undefined" - :enterToClass="defaultStore.state.animation && props.transition?.enterToClass || undefined" - :leaveFromClass="defaultStore.state.animation && props.transition?.leaveFromClass || undefined" + :duration="prefer.s.animation && props.transition?.duration || undefined" + :enterActiveClass="prefer.s.animation && props.transition?.enterActiveClass || undefined" + :leaveActiveClass="prefer.s.animation && (props.transition?.leaveActiveClass ?? $style.transition_leaveActive) || undefined" + :enterFromClass="prefer.s.animation && props.transition?.enterFromClass || undefined" + :leaveToClass="prefer.s.animation && props.transition?.leaveToClass || undefined" + :enterToClass="prefer.s.animation && props.transition?.enterToClass || undefined" + :leaveFromClass="prefer.s.animation && props.transition?.leaveFromClass || undefined" > - <canvas v-show="hide" key="canvas" ref="canvas" :class="$style.canvas" :width="canvasWidth" :height="canvasHeight" :title="title ?? undefined" tabindex="-1"/> - <img v-show="!hide" key="img" ref="img" :height="imgHeight ?? undefined" :width="imgWidth ?? undefined" :class="$style.img" :src="src ?? undefined" :title="title ?? undefined" :alt="alt ?? undefined" loading="eager" decoding="async" tabindex="-1"/> + <canvas + v-show="hide" + key="canvas" + ref="canvas" + :class="$style.canvas" + :width="canvasWidth" + :height="canvasHeight" + :title="title ?? undefined" + draggable="false" + tabindex="-1" + style="-webkit-user-drag: none;" + /> + <img + v-show="!hide" + key="img" + ref="img" + :height="imgHeight ?? undefined" + :width="imgWidth ?? undefined" + :class="$style.img" + :src="src ?? undefined" + :title="title ?? undefined" + :alt="alt ?? undefined" + loading="eager" + decoding="async" + draggable="false" + tabindex="-1" + style="-webkit-user-drag: none;" + /> </TransitionGroup> </div> </template> @@ -29,7 +55,7 @@ import { extractAvgColorFromBlurhash } from '@@/js/extract-avg-color-from-blurha const canvasPromise = new Promise<WorkerMultiDispatch | HTMLCanvasElement>(resolve => { // テスト環境で Web Worker インスタンスは作成できない if (import.meta.env.MODE === 'test') { - const canvas = document.createElement('canvas'); + const canvas = window.document.createElement('canvas'); canvas.width = 64; canvas.height = 64; resolve(canvas); @@ -43,13 +69,11 @@ const canvasPromise = new Promise<WorkerMultiDispatch | HTMLCanvasElement>(resol Math.min(navigator.hardwareConcurrency - 1, 4), ); resolve(workers); - if (_DEV_) console.log('WebGL2 in worker is supported!'); } else { - const canvas = document.createElement('canvas'); + const canvas = window.document.createElement('canvas'); canvas.width = 64; canvas.height = 64; resolve(canvas); - if (_DEV_) console.log('WebGL2 in worker is not supported...'); } testWorker.terminate(); }); @@ -57,10 +81,10 @@ const canvasPromise = new Promise<WorkerMultiDispatch | HTMLCanvasElement>(resol </script> <script lang="ts" setup> -import { computed, nextTick, onMounted, onUnmounted, shallowRef, watch, ref } from 'vue'; +import { computed, nextTick, onMounted, onUnmounted, useTemplateRef, watch, ref } from 'vue'; import { v4 as uuid } from 'uuid'; import { render } from 'buraha'; -import { defaultStore } from '@/store.js'; +import { prefer } from '@/preferences.js'; const props = withDefaults(defineProps<{ transition?: { @@ -94,9 +118,9 @@ const props = withDefaults(defineProps<{ }); const viewId = uuid(); -const canvas = shallowRef<HTMLCanvasElement>(); -const root = shallowRef<HTMLDivElement>(); -const img = shallowRef<HTMLImageElement>(); +const canvas = useTemplateRef('canvas'); +const root = useTemplateRef('root'); +const img = useTemplateRef('img'); const loaded = ref(false); const canvasWidth = ref(64); const canvasHeight = ref(64); diff --git a/packages/frontend/src/components/MkInfo.vue b/packages/frontend/src/components/MkInfo.vue index 90ca1b5a9d..f114ec8a71 100644 --- a/packages/frontend/src/components/MkInfo.vue +++ b/packages/frontend/src/components/MkInfo.vue @@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div :class="[$style.root, { [$style.warn]: warn }]"> +<div :class="[$style.root, { [$style.warn]: warn }]" class="_selectable"> <i v-if="warn" class="ti ti-alert-triangle" :class="$style.i"></i> <i v-else class="ti ti-info-circle" :class="$style.i"></i> <div><slot></slot></div> diff --git a/packages/frontend/src/components/MkInput.vue b/packages/frontend/src/components/MkInput.vue index 739061bce1..b34b7aaf60 100644 --- a/packages/frontend/src/components/MkInput.vue +++ b/packages/frontend/src/components/MkInput.vue @@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div> +<div class="_selectable"> <div :class="$style.label" @click="focus"><slot name="label"></slot></div> <div :class="[$style.input, { [$style.inline]: inline, [$style.disabled]: disabled, [$style.focused]: focused }]"> <div ref="prefixEl" :class="$style.prefix"><slot name="prefix"></slot></div> @@ -44,14 +44,14 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted, onUnmounted, nextTick, ref, shallowRef, watch, computed, toRefs } from 'vue'; -import type { InputHTMLAttributes } from 'vue'; +import { onMounted, onUnmounted, nextTick, ref, useTemplateRef, watch, computed, toRefs } from 'vue'; import { debounce } from 'throttle-debounce'; -import MkButton from '@/components/MkButton.vue'; import { useInterval } from '@@/js/use-interval.js'; +import type { InputHTMLAttributes } from 'vue'; +import type { SuggestionType } from '@/utility/autocomplete.js'; +import MkButton from '@/components/MkButton.vue'; import { i18n } from '@/i18n.js'; -import { Autocomplete } from '@/scripts/autocomplete.js'; -import type { SuggestionType } from '@/scripts/autocomplete.js'; +import { Autocomplete } from '@/utility/autocomplete.js'; const props = defineProps<{ modelValue: string | number | null; @@ -92,9 +92,9 @@ const focused = ref(false); const changed = ref(false); const invalid = ref(false); const filled = computed(() => v.value !== '' && v.value != null); -const inputEl = shallowRef<HTMLInputElement>(); -const prefixEl = shallowRef<HTMLElement>(); -const suffixEl = shallowRef<HTMLElement>(); +const inputEl = useTemplateRef('inputEl'); +const prefixEl = useTemplateRef('prefixEl'); +const suffixEl = useTemplateRef('suffixEl'); const height = props.small ? 33 : props.large ? 39 : @@ -201,7 +201,7 @@ defineExpose({ .caption { font-size: 0.85em; padding: 8px 0 0 0; - color: var(--MI_THEME-fgTransparentWeak); + color: color(from var(--MI_THEME-fg) srgb r g b / 0.75); &:empty { display: none; diff --git a/packages/frontend/src/components/MkInstanceCardMini.stories.impl.ts b/packages/frontend/src/components/MkInstanceCardMini.stories.impl.ts index b9d203ee80..bd69fb2f82 100644 --- a/packages/frontend/src/components/MkInstanceCardMini.stories.impl.ts +++ b/packages/frontend/src/components/MkInstanceCardMini.stories.impl.ts @@ -3,13 +3,12 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -/* eslint-disable @typescript-eslint/explicit-function-return-type */ -import type { StoryObj } from '@storybook/vue3'; import { HttpResponse, http } from 'msw'; import { federationInstance } from '../../.storybook/fakes.js'; import { commonHandlers } from '../../.storybook/mocks.js'; import { getChartResolver } from '../../.storybook/charts.js'; import MkInstanceCardMini from './MkInstanceCardMini.vue'; +import type { StoryObj } from '@storybook/vue3'; export const Default = { render(args) { @@ -48,7 +47,7 @@ export const Default = { const url = new URL(urlStr); if (url.href.startsWith('https://github.com/misskey-dev/misskey/blob/master/packages/frontend/assets/')) { - const image = await (await fetch(`client-assets/${url.pathname.split('/').pop()}`)).blob(); + const image = await (await window.fetch(`client-assets/${url.pathname.split('/').pop()}`)).blob(); return new HttpResponse(image, { headers: { 'Content-Type': 'image/jpeg', diff --git a/packages/frontend/src/components/MkInstanceCardMini.vue b/packages/frontend/src/components/MkInstanceCardMini.vue index b0601cf7f9..7902151921 100644 --- a/packages/frontend/src/components/MkInstanceCardMini.vue +++ b/packages/frontend/src/components/MkInstanceCardMini.vue @@ -18,8 +18,8 @@ SPDX-License-Identifier: AGPL-3.0-only import { ref } from 'vue'; import * as Misskey from 'misskey-js'; import MkMiniChart from '@/components/MkMiniChart.vue'; -import { misskeyApiGet } from '@/scripts/misskey-api.js'; -import { getProxiedImageUrlNullable } from '@/scripts/media-proxy.js'; +import { misskeyApiGet } from '@/utility/misskey-api.js'; +import { getProxiedImageUrlNullable } from '@/utility/media-proxy.js'; const props = defineProps<{ instance: Misskey.entities.FederationInstance; diff --git a/packages/frontend/src/components/MkInstanceStats.vue b/packages/frontend/src/components/MkInstanceStats.vue index c2860ed89b..90391005bc 100644 --- a/packages/frontend/src/components/MkInstanceStats.vue +++ b/packages/frontend/src/components/MkInstanceStats.vue @@ -84,22 +84,22 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted, ref, computed, shallowRef } from 'vue'; +import { onMounted, ref, computed, useTemplateRef } from 'vue'; import { Chart } from 'chart.js'; +import type { HeatmapSource } from '@/components/MkHeatmap.vue'; import MkSelect from '@/components/MkSelect.vue'; import MkChart from '@/components/MkChart.vue'; -import { useChartTooltip } from '@/scripts/use-chart-tooltip.js'; -import { $i } from '@/account.js'; +import { useChartTooltip } from '@/use/use-chart-tooltip.js'; +import { $i } from '@/i.js'; import * as os from '@/os.js'; -import { misskeyApiGet } from '@/scripts/misskey-api.js'; +import { misskeyApiGet } from '@/utility/misskey-api.js'; import { instance } from '@/instance.js'; import { i18n } from '@/i18n.js'; import MkHeatmap from '@/components/MkHeatmap.vue'; -import type { HeatmapSource } from '@/components/MkHeatmap.vue'; import MkFoldableSection from '@/components/MkFoldableSection.vue'; import MkRetentionHeatmap from '@/components/MkRetentionHeatmap.vue'; import MkRetentionLineChart from '@/components/MkRetentionLineChart.vue'; -import { initChart } from '@/scripts/init-chart.js'; +import { initChart } from '@/utility/init-chart.js'; initChart(); @@ -109,8 +109,8 @@ const chartLimit = 500; const chartSpan = ref<'hour' | 'day'>('hour'); const chartSrc = ref('active-users'); const heatmapSrc = ref<HeatmapSource>('active-users'); -const subDoughnutEl = shallowRef<HTMLCanvasElement>(); -const pubDoughnutEl = shallowRef<HTMLCanvasElement>(); +const subDoughnutEl = useTemplateRef('subDoughnutEl'); +const pubDoughnutEl = useTemplateRef('pubDoughnutEl'); const { handler: externalTooltipHandler1 } = useChartTooltip({ position: 'middle', @@ -126,7 +126,7 @@ function createDoughnut(chartEl, tooltip, data) { labels: data.map(x => x.name), datasets: [{ backgroundColor: data.map(x => x.color), - borderColor: getComputedStyle(document.documentElement).getPropertyValue('--MI_THEME-panel'), + borderColor: getComputedStyle(window.document.documentElement).getPropertyValue('--MI_THEME-panel'), borderWidth: 2, hoverOffset: 0, data: data.map(x => x.value), diff --git a/packages/frontend/src/components/MkInstanceTicker.vue b/packages/frontend/src/components/MkInstanceTicker.vue index 1da3f14ad4..eba8a73aec 100644 --- a/packages/frontend/src/components/MkInstanceTicker.vue +++ b/packages/frontend/src/components/MkInstanceTicker.vue @@ -15,7 +15,7 @@ import { computed } from 'vue'; import type { CSSProperties } from 'vue'; import { instanceName as localInstanceName } from '@@/js/config.js'; import { instance as localInstance } from '@/instance.js'; -import { getProxiedImageUrlNullable } from '@/scripts/media-proxy.js'; +import { getProxiedImageUrlNullable } from '@/utility/media-proxy.js'; const props = defineProps<{ host: string | null; diff --git a/packages/frontend/src/components/MkInviteCode.vue b/packages/frontend/src/components/MkInviteCode.vue index 1a71f6574f..ab797459cc 100644 --- a/packages/frontend/src/components/MkInviteCode.vue +++ b/packages/frontend/src/components/MkInviteCode.vue @@ -22,7 +22,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div :class="$style.items"> <div> <div :class="$style.label">{{ i18n.ts.invitationCode }}</div> - <div>{{ invite.code }}</div> + <div class="_selectableAtomic">{{ invite.code }}</div> </div> <div v-if="moderator"> <div :class="$style.label">{{ i18n.ts.inviteCodeCreator }}</div> @@ -64,7 +64,7 @@ import { computed } from 'vue'; import * as Misskey from 'misskey-js'; import MkFolder from '@/components/MkFolder.vue'; import MkButton from '@/components/MkButton.vue'; -import { copyToClipboard } from '@/scripts/copy-to-clipboard.js'; +import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; import { i18n } from '@/i18n.js'; import * as os from '@/os.js'; @@ -90,7 +90,6 @@ function deleteCode() { function copyInviteCode() { copyToClipboard(props.invite.code); - os.success(); } </script> diff --git a/packages/frontend/src/components/MkKeyValue.vue b/packages/frontend/src/components/MkKeyValue.vue index 50c9e16e5e..b4185d2d0a 100644 --- a/packages/frontend/src/components/MkKeyValue.vue +++ b/packages/frontend/src/components/MkKeyValue.vue @@ -8,7 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div :class="$style.key"> <slot name="key"></slot> </div> - <div :class="$style.value"> + <div :class="$style.value" class="_selectable"> <slot name="value"></slot> <button v-if="copy" v-tooltip="i18n.ts.copy" class="_textButton" style="margin-left: 0.5em;" @click="copy_"><i class="ti ti-copy"></i></button> </div> @@ -17,7 +17,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { } from 'vue'; -import { copyToClipboard } from '@/scripts/copy-to-clipboard.js'; +import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; @@ -31,7 +31,6 @@ const props = withDefaults(defineProps<{ const copy_ = () => { copyToClipboard(props.copy); - os.success(); }; </script> diff --git a/packages/frontend/src/components/MkLaunchPad.vue b/packages/frontend/src/components/MkLaunchPad.vue index 32c1a2d172..3e5a88a170 100644 --- a/packages/frontend/src/components/MkLaunchPad.vue +++ b/packages/frontend/src/components/MkLaunchPad.vue @@ -27,11 +27,11 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { shallowRef } from 'vue'; +import { useTemplateRef } from 'vue'; import MkModal from '@/components/MkModal.vue'; import { navbarItemDef } from '@/navbar.js'; -import { defaultStore } from '@/store.js'; -import { deviceKind } from '@/scripts/device-kind.js'; +import { deviceKind } from '@/utility/device-kind.js'; +import { prefer } from '@/preferences.js'; const props = withDefaults(defineProps<{ src?: HTMLElement; @@ -48,9 +48,9 @@ const preferedModalType = (deviceKind === 'desktop' && props.src != null) ? 'pop deviceKind === 'smartphone' ? 'drawer' : 'dialog'; -const modal = shallowRef<InstanceType<typeof MkModal>>(); +const modal = useTemplateRef('modal'); -const menu = defaultStore.state.menu; +const menu = prefer.s.menu; const items = Object.keys(navbarItemDef).filter(k => !menu.includes(k)).map(k => navbarItemDef[k]).filter(def => def.show == null ? true : def.show).map(def => ({ type: def.to ? 'link' : 'button', diff --git a/packages/frontend/src/components/MkLink.vue b/packages/frontend/src/components/MkLink.vue index a276bf68b4..4bac2bcea4 100644 --- a/packages/frontend/src/components/MkLink.vue +++ b/packages/frontend/src/components/MkLink.vue @@ -17,7 +17,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { defineAsyncComponent, ref } from 'vue'; import { url as local } from '@@/js/config.js'; -import { useTooltip } from '@/scripts/use-tooltip.js'; +import { useTooltip } from '@/use/use-tooltip.js'; import * as os from '@/os.js'; import { isEnabledUrlPreview } from '@/instance.js'; import type { MkABehavior } from '@/components/global/MkA.vue'; diff --git a/packages/frontend/src/components/MkMediaAudio.vue b/packages/frontend/src/components/MkMediaAudio.vue index 3d2795b37a..b7052ad918 100644 --- a/packages/frontend/src/components/MkMediaAudio.vue +++ b/packages/frontend/src/components/MkMediaAudio.vue @@ -10,20 +10,20 @@ SPDX-License-Identifier: AGPL-3.0-only tabindex="0" :class="[ $style.audioContainer, - (audio.isSensitive && defaultStore.state.highlightSensitiveMedia) && $style.sensitive, + (audio.isSensitive && prefer.s.highlightSensitiveMedia) && $style.sensitive, ]" @contextmenu.stop @keydown.stop > <button v-if="hide" :class="$style.hidden" @click="show"> <div :class="$style.hiddenTextWrapper"> - <b v-if="audio.isSensitive" style="display: block;"><i class="ti ti-eye-exclamation"></i> {{ i18n.ts.sensitive }}{{ defaultStore.state.dataSaver.media ? ` (${i18n.ts.audio}${audio.size ? ' ' + bytes(audio.size) : ''})` : '' }}</b> - <b v-else style="display: block;"><i class="ti ti-music"></i> {{ defaultStore.state.dataSaver.media && audio.size ? bytes(audio.size) : i18n.ts.audio }}</b> + <b v-if="audio.isSensitive" style="display: block;"><i class="ti ti-eye-exclamation"></i> {{ i18n.ts.sensitive }}{{ prefer.s.dataSaver.media ? ` (${i18n.ts.audio}${audio.size ? ' ' + bytes(audio.size) : ''})` : '' }}</b> + <b v-else style="display: block;"><i class="ti ti-music"></i> {{ prefer.s.dataSaver.media && audio.size ? bytes(audio.size) : i18n.ts.audio }}</b> <span style="display: block;">{{ i18n.ts.clickToShow }}</span> </div> </button> - <div v-else-if="defaultStore.reactiveState.useNativeUIForVideoAudioPlayer.value" :class="$style.nativeAudioContainer"> + <div v-else-if="prefer.s.useNativeUiForVideoAudioPlayer" :class="$style.nativeAudioContainer"> <audio ref="audioEl" preload="metadata" @@ -88,18 +88,18 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { shallowRef, watch, computed, ref, onDeactivated, onActivated, onMounted } from 'vue'; +import { useTemplateRef, watch, computed, ref, onDeactivated, onActivated, onMounted } from 'vue'; import * as Misskey from 'misskey-js'; import type { MenuItem } from '@/types/menu.js'; -import type { Keymap } from '@/scripts/hotkey.js'; -import { copyToClipboard } from '@/scripts/copy-to-clipboard'; -import { defaultStore } from '@/store.js'; +import type { Keymap } from '@/utility/hotkey.js'; +import { copyToClipboard } from '@/utility/copy-to-clipboard'; import { i18n } from '@/i18n.js'; import * as os from '@/os.js'; import bytes from '@/filters/bytes.js'; import { hms } from '@/filters/hms.js'; import MkMediaRange from '@/components/MkMediaRange.vue'; -import { $i, iAmModerator } from '@/account.js'; +import { $i, iAmModerator } from '@/i.js'; +import { prefer } from '@/preferences.js'; const props = defineProps<{ audio: Misskey.entities.DriveFile; @@ -148,17 +148,17 @@ const keymap = { // PlayerElもしくはその子要素にフォーカスがあるかどうか function hasFocus() { if (!playerEl.value) return false; - return playerEl.value === document.activeElement || playerEl.value.contains(document.activeElement); + return playerEl.value === window.document.activeElement || playerEl.value.contains(window.document.activeElement); } -const playerEl = shallowRef<HTMLDivElement>(); -const audioEl = shallowRef<HTMLAudioElement>(); +const playerEl = useTemplateRef('playerEl'); +const audioEl = useTemplateRef('audioEl'); // eslint-disable-next-line vue/no-setup-props-reactivity-loss -const hide = ref((defaultStore.state.nsfw === 'force' || defaultStore.state.dataSaver.media) ? true : (props.audio.isSensitive && defaultStore.state.nsfw !== 'ignore')); +const hide = ref((prefer.s.nsfw === 'force' || prefer.s.dataSaver.media) ? true : (props.audio.isSensitive && prefer.s.nsfw !== 'ignore')); async function show() { - if (props.audio.isSensitive && defaultStore.state.confirmWhenRevealingSensitiveMedia) { + if (props.audio.isSensitive && prefer.s.confirmWhenRevealingSensitiveMedia) { const { canceled } = await os.confirm({ type: 'question', text: i18n.ts.sensitiveMediaRevealConfirm, @@ -240,9 +240,9 @@ function showMenu(ev: MouseEvent) { menu.push({ type: 'divider' }, ...details); } - if (defaultStore.state.devMode) { + if (prefer.s.devMode) { menu.push({ type: 'divider' }, { - icon: 'ti ti-id', + icon: 'ti ti-hash', text: i18n.ts.copyFileId, action: () => { copyToClipboard(props.audio.id); @@ -407,7 +407,7 @@ onDeactivated(() => { elapsedTimeMs.value = 0; durationMs.value = 0; bufferedEnd.value = 0; - hide.value = (defaultStore.state.nsfw === 'force' || defaultStore.state.dataSaver.media) ? true : (props.audio.isSensitive && defaultStore.state.nsfw !== 'ignore'); + hide.value = (prefer.s.nsfw === 'force' || prefer.s.dataSaver.media) ? true : (props.audio.isSensitive && prefer.s.nsfw !== 'ignore'); stopAudioElWatch(); onceInit = false; if (mediaTickFrameId) { diff --git a/packages/frontend/src/components/MkMediaBanner.vue b/packages/frontend/src/components/MkMediaBanner.vue index 3e521e0a03..f23cf507fb 100644 --- a/packages/frontend/src/components/MkMediaBanner.vue +++ b/packages/frontend/src/components/MkMediaBanner.vue @@ -27,9 +27,9 @@ SPDX-License-Identifier: AGPL-3.0-only import { ref } from 'vue'; import * as Misskey from 'misskey-js'; import { i18n } from '@/i18n.js'; -import { defaultStore } from '@/store.js'; import * as os from '@/os.js'; import MkMediaAudio from '@/components/MkMediaAudio.vue'; +import { prefer } from '@/preferences.js'; const props = defineProps<{ media: Misskey.entities.DriveFile; @@ -38,7 +38,7 @@ const props = defineProps<{ const hide = ref(true); async function show() { - if (props.media.isSensitive && defaultStore.state.confirmWhenRevealingSensitiveMedia) { + if (props.media.isSensitive && prefer.s.confirmWhenRevealingSensitiveMedia) { const { canceled } = await os.confirm({ type: 'question', text: i18n.ts.sensitiveMediaRevealConfirm, diff --git a/packages/frontend/src/components/MkMediaImage.vue b/packages/frontend/src/components/MkMediaImage.vue index 8ab990b926..bb42cbecf9 100644 --- a/packages/frontend/src/components/MkMediaImage.vue +++ b/packages/frontend/src/components/MkMediaImage.vue @@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div :class="[hide ? $style.hidden : $style.visible, (image.isSensitive && defaultStore.state.highlightSensitiveMedia) && $style.sensitive]" @click="onclick"> +<div :class="[hide ? $style.hidden : $style.visible, (image.isSensitive && prefer.s.highlightSensitiveMedia) && $style.sensitive]" @click="onclick"> <component :is="disableImageLink ? 'div' : 'a'" v-bind="disableImageLink ? { @@ -19,7 +19,7 @@ SPDX-License-Identifier: AGPL-3.0-only > <ImgWithBlurhash :hash="image.blurhash" - :src="(defaultStore.state.dataSaver.media && hide) ? null : url" + :src="(prefer.s.dataSaver.media && hide) ? null : url" :forceBlurhash="hide" :cover="hide || cover" :alt="image.comment || image.name" @@ -32,8 +32,8 @@ SPDX-License-Identifier: AGPL-3.0-only <template v-if="hide"> <div :class="$style.hiddenText"> <div :class="$style.hiddenTextWrapper"> - <b v-if="image.isSensitive" style="display: block;"><i class="ti ti-eye-exclamation"></i> {{ i18n.ts.sensitive }}{{ defaultStore.state.dataSaver.media ? ` (${i18n.ts.image}${image.size ? ' ' + bytes(image.size) : ''})` : '' }}</b> - <b v-else style="display: block;"><i class="ti ti-photo"></i> {{ defaultStore.state.dataSaver.media && image.size ? bytes(image.size) : i18n.ts.image }}</b> + <b v-if="image.isSensitive" style="display: block;"><i class="ti ti-eye-exclamation"></i> {{ i18n.ts.sensitive }}{{ prefer.s.dataSaver.media ? ` (${i18n.ts.image}${image.size ? ' ' + bytes(image.size) : ''})` : '' }}</b> + <b v-else style="display: block;"><i class="ti ti-photo"></i> {{ prefer.s.dataSaver.media && image.size ? bytes(image.size) : i18n.ts.image }}</b> <span v-if="controls" style="display: block;">{{ i18n.ts.clickToShow }}</span> </div> </div> @@ -54,14 +54,14 @@ SPDX-License-Identifier: AGPL-3.0-only import { watch, ref, computed } from 'vue'; import * as Misskey from 'misskey-js'; import type { MenuItem } from '@/types/menu.js'; -import { copyToClipboard } from '@/scripts/copy-to-clipboard'; -import { getStaticImageUrl } from '@/scripts/media-proxy.js'; +import { copyToClipboard } from '@/utility/copy-to-clipboard'; +import { getStaticImageUrl } from '@/utility/media-proxy.js'; import bytes from '@/filters/bytes.js'; import ImgWithBlurhash from '@/components/MkImgWithBlurhash.vue'; -import { defaultStore } from '@/store.js'; import { i18n } from '@/i18n.js'; import * as os from '@/os.js'; -import { $i, iAmModerator } from '@/account.js'; +import { $i, iAmModerator } from '@/i.js'; +import { prefer } from '@/preferences.js'; const props = withDefaults(defineProps<{ image: Misskey.entities.DriveFile; @@ -77,9 +77,9 @@ const props = withDefaults(defineProps<{ const hide = ref(true); -const url = computed(() => (props.raw || defaultStore.state.loadRawImages) +const url = computed(() => (props.raw || prefer.s.loadRawImages) ? props.image.url - : defaultStore.state.disableShowingAnimatedImages + : prefer.s.disableShowingAnimatedImages ? getStaticImageUrl(props.image.url) : props.image.thumbnailUrl, ); @@ -91,7 +91,7 @@ async function onclick(ev: MouseEvent) { if (hide.value) { ev.stopPropagation(); - if (props.image.isSensitive && defaultStore.state.confirmWhenRevealingSensitiveMedia) { + if (props.image.isSensitive && prefer.s.confirmWhenRevealingSensitiveMedia) { const { canceled } = await os.confirm({ type: 'question', text: i18n.ts.sensitiveMediaRevealConfirm, @@ -105,7 +105,7 @@ async function onclick(ev: MouseEvent) { // Plugin:register_note_view_interruptor を使って書き換えられる可能性があるためwatchする watch(() => props.image, () => { - hide.value = (defaultStore.state.nsfw === 'force' || defaultStore.state.dataSaver.media) ? true : (props.image.isSensitive && defaultStore.state.nsfw !== 'ignore'); + hide.value = (prefer.s.nsfw === 'force' || prefer.s.dataSaver.media) ? true : (props.image.isSensitive && prefer.s.nsfw !== 'ignore'); }, { deep: true, immediate: true, @@ -166,9 +166,9 @@ function showMenu(ev: MouseEvent) { menuItems.push({ type: 'divider' }, ...details); } - if (defaultStore.state.devMode) { + if (prefer.s.devMode) { menuItems.push({ type: 'divider' }, { - icon: 'ti ti-id', + icon: 'ti ti-hash', text: i18n.ts.copyFileId, action: () => { copyToClipboard(props.image.id); @@ -220,7 +220,7 @@ function showMenu(ev: MouseEvent) { position: absolute; border-radius: 6px; background-color: var(--MI_THEME-fg); - color: var(--MI_THEME-accentLighten); + color: hsl(from var(--MI_THEME-accent) h s calc(l + 10)); font-size: 12px; opacity: .5; padding: 5px 8px; @@ -294,7 +294,7 @@ html[data-color-scheme=light] .visible { /* Hardcode to black because either --MI_THEME-bg or --MI_THEME-fg makes it hard to read in dark/light mode */ background-color: black; border-radius: 6px; - color: var(--MI_THEME-accentLighten); + color: hsl(from var(--MI_THEME-accent) h s calc(l + 10)); display: inline-block; font-weight: bold; font-size: 0.8em; diff --git a/packages/frontend/src/components/MkMediaList.vue b/packages/frontend/src/components/MkMediaList.vue index 32766f2029..4a1100c324 100644 --- a/packages/frontend/src/components/MkMediaList.vue +++ b/packages/frontend/src/components/MkMediaList.vue @@ -12,9 +12,9 @@ SPDX-License-Identifier: AGPL-3.0-only :class="[ $style.medias, count === 1 ? [$style.n1, { - [$style.n116_9]: defaultStore.reactiveState.mediaListWithOneImageAppearance.value === '16_9', - [$style.n11_1]: defaultStore.reactiveState.mediaListWithOneImageAppearance.value === '1_1', - [$style.n12_3]: defaultStore.reactiveState.mediaListWithOneImageAppearance.value === '2_3', + [$style.n116_9]: prefer.s.mediaListWithOneImageAppearance === '16_9', + [$style.n11_1]: prefer.s.mediaListWithOneImageAppearance === '1_1', + [$style.n12_3]: prefer.s.mediaListWithOneImageAppearance === '2_3', }] : count === 2 ? $style.n2 : count === 3 ? $style.n3 : count === 4 ? $style.n4 : $style.nMany, ]" > @@ -28,27 +28,27 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { computed, onMounted, onUnmounted, shallowRef } from 'vue'; +import { computed, onMounted, onUnmounted, useTemplateRef } from 'vue'; import * as Misskey from 'misskey-js'; import PhotoSwipeLightbox from 'photoswipe/lightbox'; import PhotoSwipe from 'photoswipe'; import 'photoswipe/style.css'; +import { FILE_TYPE_BROWSERSAFE } from '@@/js/const.js'; import XBanner from '@/components/MkMediaBanner.vue'; import XImage from '@/components/MkMediaImage.vue'; import XVideo from '@/components/MkMediaVideo.vue'; import * as os from '@/os.js'; -import { FILE_TYPE_BROWSERSAFE } from '@@/js/const.js'; -import { defaultStore } from '@/store.js'; -import { focusParent } from '@/scripts/focus.js'; +import { focusParent } from '@/utility/focus.js'; +import { prefer } from '@/preferences.js'; const props = defineProps<{ mediaList: Misskey.entities.DriveFile[]; raw?: boolean; }>(); -const gallery = shallowRef<HTMLDivElement>(); +const gallery = useTemplateRef('gallery'); const pswpZIndex = os.claimZIndex('middle'); -document.documentElement.style.setProperty('--mk-pswp-root-z-index', pswpZIndex.toString()); +window.document.documentElement.style.setProperty('--mk-pswp-root-z-index', pswpZIndex.toString()); const count = computed(() => props.mediaList.filter(media => previewable(media)).length); let lightbox: PhotoSwipeLightbox | null = null; @@ -75,7 +75,7 @@ async function calcAspectRatio() { return `${Math.max(ratio, img.properties.width / img.properties.height).toString()} / 1`; }; - switch (defaultStore.state.mediaListWithOneImageAppearance) { + switch (prefer.s.mediaListWithOneImageAppearance) { case '16_9': gallery.value.style.aspectRatio = ratioMax(16 / 9); break; @@ -166,7 +166,7 @@ onMounted(() => { className: 'pswp__alt-text-container', appendTo: 'wrapper', onInit: (el, pswp) => { - const textBox = document.createElement('p'); + const textBox = window.document.createElement('p'); textBox.className = 'pswp__alt-text _acrylic'; el.appendChild(textBox); @@ -178,19 +178,19 @@ onMounted(() => { }); lightbox.on('afterInit', () => { - activeEl = document.activeElement instanceof HTMLElement ? document.activeElement : null; + activeEl = window.document.activeElement instanceof HTMLElement ? window.document.activeElement : null; focusParent(activeEl, true, true); lightbox?.pswp?.element?.focus({ preventScroll: true, }); - history.pushState(null, '', '#pswp'); + window.history.pushState(null, '', '#pswp'); }); lightbox.on('destroy', () => { focusParent(activeEl, true, false); activeEl = null; if (window.location.hash === '#pswp') { - history.back(); + window.history.back(); } }); @@ -227,7 +227,6 @@ defineExpose({ .container { position: relative; width: 100%; - margin-top: 4px; } .medias { diff --git a/packages/frontend/src/components/MkMediaVideo.vue b/packages/frontend/src/components/MkMediaVideo.vue index 682da22711..0e5f1e28b9 100644 --- a/packages/frontend/src/components/MkMediaVideo.vue +++ b/packages/frontend/src/components/MkMediaVideo.vue @@ -11,7 +11,7 @@ SPDX-License-Identifier: AGPL-3.0-only :class="[ $style.videoContainer, controlsShowing && $style.active, - (video.isSensitive && defaultStore.state.highlightSensitiveMedia) && $style.sensitive, + (video.isSensitive && prefer.s.highlightSensitiveMedia) && $style.sensitive, ]" @mouseover="onMouseOver" @mouseleave="onMouseLeave" @@ -20,13 +20,13 @@ SPDX-License-Identifier: AGPL-3.0-only > <button v-if="hide" :class="$style.hidden" @click="show"> <div :class="$style.hiddenTextWrapper"> - <b v-if="video.isSensitive" style="display: block;"><i class="ti ti-eye-exclamation"></i> {{ i18n.ts.sensitive }}{{ defaultStore.state.dataSaver.media ? ` (${i18n.ts.video}${video.size ? ' ' + bytes(video.size) : ''})` : '' }}</b> - <b v-else style="display: block;"><i class="ti ti-movie"></i> {{ defaultStore.state.dataSaver.media && video.size ? bytes(video.size) : i18n.ts.video }}</b> + <b v-if="video.isSensitive" style="display: block;"><i class="ti ti-eye-exclamation"></i> {{ i18n.ts.sensitive }}{{ prefer.s.dataSaver.media ? ` (${i18n.ts.video}${video.size ? ' ' + bytes(video.size) : ''})` : '' }}</b> + <b v-else style="display: block;"><i class="ti ti-movie"></i> {{ prefer.s.dataSaver.media && video.size ? bytes(video.size) : i18n.ts.video }}</b> <span style="display: block;">{{ i18n.ts.clickToShow }}</span> </div> </button> - <div v-else-if="defaultStore.reactiveState.useNativeUIForVideoAudioPlayer.value" :class="$style.videoRoot"> + <div v-else-if="prefer.s.useNativeUiForVideoAudioPlayer" :class="$style.videoRoot"> <video ref="videoEl" :class="$style.video" @@ -109,20 +109,20 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { ref, shallowRef, computed, watch, onDeactivated, onActivated, onMounted } from 'vue'; +import { ref, useTemplateRef, computed, watch, onDeactivated, onActivated, onMounted } from 'vue'; import * as Misskey from 'misskey-js'; import type { MenuItem } from '@/types/menu.js'; -import type { Keymap } from '@/scripts/hotkey.js'; -import { copyToClipboard } from '@/scripts/copy-to-clipboard'; +import type { Keymap } from '@/utility/hotkey.js'; +import { copyToClipboard } from '@/utility/copy-to-clipboard'; import bytes from '@/filters/bytes.js'; import { hms } from '@/filters/hms.js'; -import { defaultStore } from '@/store.js'; import { i18n } from '@/i18n.js'; import * as os from '@/os.js'; -import { exitFullscreen, requestFullscreen } from '@/scripts/fullscreen.js'; -import hasAudio from '@/scripts/media-has-audio.js'; +import { exitFullscreen, requestFullscreen } from '@/utility/fullscreen.js'; +import hasAudio from '@/utility/media-has-audio.js'; import MkMediaRange from '@/components/MkMediaRange.vue'; -import { $i, iAmModerator } from '@/account.js'; +import { $i, iAmModerator } from '@/i.js'; +import { prefer } from '@/preferences.js'; const props = defineProps<{ video: Misskey.entities.DriveFile; @@ -171,14 +171,14 @@ const keymap = { // PlayerElもしくはその子要素にフォーカスがあるかどうか function hasFocus() { if (!playerEl.value) return false; - return playerEl.value === document.activeElement || playerEl.value.contains(document.activeElement); + return playerEl.value === window.document.activeElement || playerEl.value.contains(window.document.activeElement); } // eslint-disable-next-line vue/no-setup-props-reactivity-loss -const hide = ref((defaultStore.state.nsfw === 'force' || defaultStore.state.dataSaver.media) ? true : (props.video.isSensitive && defaultStore.state.nsfw !== 'ignore')); +const hide = ref((prefer.s.nsfw === 'force' || prefer.s.dataSaver.media) ? true : (props.video.isSensitive && prefer.s.nsfw !== 'ignore')); async function show() { - if (props.video.isSensitive && defaultStore.state.confirmWhenRevealingSensitiveMedia) { + if (props.video.isSensitive && prefer.s.confirmWhenRevealingSensitiveMedia) { const { canceled } = await os.confirm({ type: 'question', text: i18n.ts.sensitiveMediaRevealConfirm, @@ -216,7 +216,7 @@ function showMenu(ev: MouseEvent) { '2.0x': 2, }, }, - ...(document.pictureInPictureEnabled ? [{ + ...(window.document.pictureInPictureEnabled ? [{ text: i18n.ts._mediaControls.pip, icon: 'ti ti-picture-in-picture', action: togglePictureInPicture, @@ -265,9 +265,9 @@ function showMenu(ev: MouseEvent) { menu.push({ type: 'divider' }, ...details); } - if (defaultStore.state.devMode) { + if (prefer.s.devMode) { menu.push({ type: 'divider' }, { - icon: 'ti ti-id', + icon: 'ti ti-hash', text: i18n.ts.copyFileId, action: () => { copyToClipboard(props.video.id); @@ -299,8 +299,8 @@ async function toggleSensitive(file: Misskey.entities.DriveFile) { } // MediaControl: Video State -const videoEl = shallowRef<HTMLVideoElement>(); -const playerEl = shallowRef<HTMLDivElement>(); +const videoEl = useTemplateRef('videoEl'); +const playerEl = useTemplateRef('playerEl'); const isHoverring = ref(false); const controlsShowing = computed(() => { if (!oncePlayed.value) return true; @@ -339,7 +339,7 @@ const bufferedDataRatio = computed(() => { // MediaControl Events function onMouseOver() { if (controlStateTimer) { - clearTimeout(controlStateTimer); + window.clearTimeout(controlStateTimer); } isHoverring.value = true; } @@ -384,8 +384,8 @@ function toggleFullscreen() { function togglePictureInPicture() { if (videoEl.value) { - if (document.pictureInPictureElement) { - document.exitPictureInPicture(); + if (window.document.pictureInPictureElement) { + window.document.exitPictureInPicture(); } else { videoEl.value.requestPictureInPicture(); } @@ -502,7 +502,7 @@ onDeactivated(() => { elapsedTimeMs.value = 0; durationMs.value = 0; bufferedEnd.value = 0; - hide.value = (defaultStore.state.nsfw === 'force' || defaultStore.state.dataSaver.media) ? true : (props.video.isSensitive && defaultStore.state.nsfw !== 'ignore'); + hide.value = (prefer.s.nsfw === 'force' || prefer.s.dataSaver.media) ? true : (props.video.isSensitive && prefer.s.nsfw !== 'ignore'); stopVideoElWatch(); onceInit = false; if (mediaTickFrameId) { @@ -553,7 +553,7 @@ onDeactivated(() => { /* Hardcode to black because either --MI_THEME-bg or --MI_THEME-fg makes it hard to read in dark/light mode */ background-color: black; border-radius: 6px; - color: var(--MI_THEME-accentLighten); + color: hsl(from var(--MI_THEME-accent) h s calc(l + 10)); display: inline-block; font-weight: bold; font-size: 0.8em; @@ -565,7 +565,7 @@ onDeactivated(() => { position: absolute; border-radius: 6px; background-color: var(--MI_THEME-fg); - color: var(--MI_THEME-accentLighten); + color: hsl(from var(--MI_THEME-accent) h s calc(l + 10)); font-size: 12px; opacity: .5; padding: 5px 8px; diff --git a/packages/frontend/src/components/MkMention.vue b/packages/frontend/src/components/MkMention.vue index 87c82f0a89..f2cf33eb65 100644 --- a/packages/frontend/src/components/MkMention.vue +++ b/packages/frontend/src/components/MkMention.vue @@ -18,9 +18,9 @@ import { toUnicode } from 'punycode.js'; import { computed } from 'vue'; import { host as localHost } from '@@/js/config.js'; import type { MkABehavior } from '@/components/global/MkA.vue'; -import { $i } from '@/account.js'; -import { defaultStore } from '@/store.js'; -import { getStaticImageUrl } from '@/scripts/media-proxy.js'; +import { $i } from '@/i.js'; +import { getStaticImageUrl } from '@/utility/media-proxy.js'; +import { prefer } from '@/preferences.js'; const props = defineProps<{ username: string; @@ -36,7 +36,7 @@ const isMe = $i && ( `@${props.username}@${toUnicode(props.host)}` === `@${$i.username}@${toUnicode(localHost)}`.toLowerCase() ); -const avatarUrl = computed(() => defaultStore.state.disableShowingAnimatedImages || defaultStore.state.dataSaver.avatar +const avatarUrl = computed(() => prefer.s.disableShowingAnimatedImages || prefer.s.dataSaver.avatar ? getStaticImageUrl(`/avatar/@${props.username}@${props.host}`) : `/avatar/@${props.username}@${props.host}`, ); diff --git a/packages/frontend/src/components/MkMenu.child.vue b/packages/frontend/src/components/MkMenu.child.vue index 086573ba6d..f7cd72b6c6 100644 --- a/packages/frontend/src/components/MkMenu.child.vue +++ b/packages/frontend/src/components/MkMenu.child.vue @@ -10,7 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { nextTick, onMounted, onUnmounted, provide, shallowRef, watch } from 'vue'; +import { nextTick, onMounted, onUnmounted, provide, useTemplateRef, watch } from 'vue'; import MkMenu from './MkMenu.vue'; import type { MenuItem } from '@/types/menu.js'; @@ -28,7 +28,7 @@ const emit = defineEmits<{ provide('isNestingMenu', true); -const el = shallowRef<HTMLElement>(); +const el = useTemplateRef('el'); const align = 'left'; const SCROLLBAR_THICKNESS = 16; diff --git a/packages/frontend/src/components/MkMenu.vue b/packages/frontend/src/components/MkMenu.vue index d484c1b338..44f5a09404 100644 --- a/packages/frontend/src/components/MkMenu.vue +++ b/packages/frontend/src/components/MkMenu.vue @@ -11,6 +11,7 @@ SPDX-License-Identifier: AGPL-3.0-only [$style.center]: align === 'center', [$style.big]: big, [$style.asDrawer]: asDrawer, + [$style.widthSpecified]: width != null, }" @focusin.passive.stop="() => {}" > @@ -29,12 +30,19 @@ SPDX-License-Identifier: AGPL-3.0-only > <template v-for="item in (items2 ?? [])"> <div v-if="item.type === 'divider'" role="separator" tabindex="-1" :class="$style.divider"></div> + <span v-else-if="item.type === 'label'" role="menuitem" tabindex="-1" :class="[$style.label, $style.item]"> <span style="opacity: 0.7;">{{ item.text }}</span> </span> + <span v-else-if="item.type === 'pending'" role="menuitem" tabindex="0" :class="[$style.pending, $style.item]"> <span><MkEllipsis/></span> </span> + + <div v-else-if="item.type === 'component'" role="menuitem" tabindex="-1" :class="[$style.componentItem]"> + <component :is="item.component" v-bind="item.props"/> + </div> + <MkA v-else-if="item.type === 'link'" role="menuitem" @@ -48,10 +56,14 @@ SPDX-License-Identifier: AGPL-3.0-only <i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]"></i> <MkAvatar v-if="item.avatar" :user="item.avatar" :class="$style.avatar"/> <div :class="$style.item_content"> - <span :class="$style.item_content_text">{{ item.text }}</span> + <div :class="$style.item_content_text"> + <div :class="$style.item_content_text_title">{{ item.text }}</div> + <div v-if="item.caption" :class="$style.item_content_text_caption">{{ item.caption }}</div> + </div> <span v-if="item.indicate" :class="$style.indicator" class="_blink"><i class="_indicatorCircle"></i></span> </div> </MkA> + <a v-else-if="item.type === 'a'" role="menuitem" @@ -67,10 +79,14 @@ SPDX-License-Identifier: AGPL-3.0-only > <i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]"></i> <div :class="$style.item_content"> - <span :class="$style.item_content_text">{{ item.text }}</span> + <div :class="$style.item_content_text"> + <div :class="$style.item_content_text_title">{{ item.text }}</div> + <div v-if="item.caption" :class="$style.item_content_text_caption">{{ item.caption }}</div> + </div> <span v-if="item.indicate" :class="$style.indicator" class="_blink"><i class="_indicatorCircle"></i></span> </div> </a> + <button v-else-if="item.type === 'user'" role="menuitem" @@ -85,6 +101,7 @@ SPDX-License-Identifier: AGPL-3.0-only <span :class="$style.indicator" class="_blink"><i class="_indicatorCircle"></i></span> </div> </button> + <button v-else-if="item.type === 'switch'" role="menuitemcheckbox" @@ -98,10 +115,14 @@ SPDX-License-Identifier: AGPL-3.0-only <i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]"></i> <MkSwitchButton v-else :class="$style.switchButton" :checked="item.ref" :disabled="item.disabled" @toggle="switchItem(item)"/> <div :class="$style.item_content"> - <span :class="[$style.item_content_text, { [$style.switchText]: !item.icon }]">{{ item.text }}</span> + <div :class="[$style.item_content_text, { [$style.switchText]: !item.icon }]"> + <div :class="$style.item_content_text_title">{{ item.text }}</div> + <div v-if="item.caption" :class="$style.item_content_text_caption">{{ item.caption }}</div> + </div> <MkSwitchButton v-if="item.icon" :class="[$style.switchButton, $style.caret]" :checked="item.ref" :disabled="item.disabled" @toggle="switchItem(item)"/> </div> </button> + <button v-else-if="item.type === 'radio'" role="menuitem" @@ -114,10 +135,14 @@ SPDX-License-Identifier: AGPL-3.0-only > <i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]" style="pointer-events: none;"></i> <div :class="$style.item_content"> - <span :class="$style.item_content_text" style="pointer-events: none;">{{ item.text }}</span> + <div :class="$style.item_content_text" style="pointer-events: none;"> + <div :class="$style.item_content_text_title">{{ item.text }}</div> + <div v-if="item.caption" :class="$style.item_content_text_caption">{{ item.caption }}</div> + </div> <span :class="$style.caret" style="pointer-events: none;"><i class="ti ti-chevron-right ti-fw"></i></span> </div> </button> + <button v-else-if="item.type === 'radioOption'" role="menuitemradio" @@ -131,9 +156,13 @@ SPDX-License-Identifier: AGPL-3.0-only <span :class="[$style.radioIcon, { [$style.radioChecked]: unref(item.active) }]"></span> </div> <div :class="$style.item_content"> - <span :class="$style.item_content_text">{{ item.text }}</span> + <div :class="$style.item_content_text"> + <div :class="$style.item_content_text_title">{{ item.text }}</div> + <div v-if="item.caption" :class="$style.item_content_text_caption">{{ item.caption }}</div> + </div> </div> </button> + <button v-else-if="item.type === 'parent'" role="menuitem" @@ -145,12 +174,17 @@ SPDX-License-Identifier: AGPL-3.0-only > <i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]" style="pointer-events: none;"></i> <div :class="$style.item_content"> - <span :class="$style.item_content_text" style="pointer-events: none;">{{ item.text }}</span> + <div :class="$style.item_content_text" style="pointer-events: none;"> + <div :class="$style.item_content_text_title">{{ item.text }}</div> + <div v-if="item.caption" :class="$style.item_content_text_caption">{{ item.caption }}</div> + </div> <span :class="$style.caret" style="pointer-events: none;"><i class="ti ti-chevron-right ti-fw"></i></span> </div> </button> + <button - v-else role="menuitem" + v-else + role="menuitem" tabindex="0" :class="['_button', $style.item, { [$style.danger]: item.danger, [$style.active]: unref(item.active) }]" @click.prevent="unref(item.active) ? close(false) : clicked(item.action, $event)" @@ -160,11 +194,15 @@ SPDX-License-Identifier: AGPL-3.0-only <i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]"></i> <MkAvatar v-if="item.avatar" :user="item.avatar" :class="$style.avatar"/> <div :class="$style.item_content"> - <span :class="$style.item_content_text">{{ item.text }}</span> + <div :class="$style.item_content_text"> + <div :class="$style.item_content_text_title">{{ item.text }}</div> + <div v-if="item.caption" :class="$style.item_content_text_caption">{{ item.caption }}</div> + </div> <span v-if="item.indicate" :class="$style.indicator" class="_blink"><i class="_indicatorCircle"></i></span> </div> </button> </template> + <span v-if="items2 == null || items2.length === 0" tabindex="-1" :class="[$style.none, $style.item]"> <span>{{ i18n.ts.none }}</span> </span> @@ -176,15 +214,15 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts"> -import { computed, defineAsyncComponent, inject, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, unref, watch } from 'vue'; -import MkSwitchButton from '@/components/MkSwitch.button.vue'; +import { computed, defineAsyncComponent, inject, nextTick, onBeforeUnmount, onMounted, ref, useTemplateRef, unref, watch, shallowRef } from 'vue'; import type { MenuItem, InnerMenuItem, MenuPending, MenuAction, MenuSwitch, MenuRadio, MenuRadioOption, MenuParent } from '@/types/menu.js'; +import type { Keymap } from '@/utility/hotkey.js'; +import MkSwitchButton from '@/components/MkSwitch.button.vue'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; -import { isTouchUsing } from '@/scripts/touch.js'; -import type { Keymap } from '@/scripts/hotkey.js'; -import { isFocusable } from '@/scripts/focus.js'; -import { getNodeOrNull } from '@/scripts/get-dom-node-or-null.js'; +import { isTouchUsing } from '@/utility/touch.js'; +import { isFocusable } from '@/utility/focus.js'; +import { getNodeOrNull } from '@/utility/get-dom-node-or-null.js'; const childrenCache = new WeakMap<MenuParent, MenuItem[]>(); </script> @@ -209,11 +247,11 @@ const big = isTouchUsing; const isNestingMenu = inject<boolean>('isNestingMenu', false); -const itemsEl = shallowRef<HTMLElement>(); +const itemsEl = useTemplateRef('itemsEl'); const items2 = ref<InnerMenuItem[]>(); -const child = shallowRef<InstanceType<typeof XChild>>(); +const child = useTemplateRef('child'); const keymap = { 'up|k|shift+tab': { @@ -254,7 +292,7 @@ watch(() => props.items, () => { }); const childMenu = ref<MenuItem[] | null>(); -const childTarget = shallowRef<HTMLElement | null>(); +const childTarget = shallowRef<HTMLElement>(); function closeChild() { childMenu.value = null; @@ -355,10 +393,10 @@ function switchItem(item: MenuSwitch & { ref: any }) { function focusUp() { if (disposed) return; - if (!itemsEl.value?.contains(document.activeElement)) return; + if (!itemsEl.value?.contains(window.document.activeElement)) return; const focusableElements = Array.from(itemsEl.value.children).filter(isFocusable); - const activeIndex = focusableElements.findIndex(el => el === document.activeElement); + const activeIndex = focusableElements.findIndex(el => el === window.document.activeElement); const targetIndex = (activeIndex !== -1 && activeIndex !== 0) ? (activeIndex - 1) : (focusableElements.length - 1); const targetElement = focusableElements.at(targetIndex) ?? itemsEl.value; @@ -367,10 +405,10 @@ function focusUp() { function focusDown() { if (disposed) return; - if (!itemsEl.value?.contains(document.activeElement)) return; + if (!itemsEl.value?.contains(window.document.activeElement)) return; const focusableElements = Array.from(itemsEl.value.children).filter(isFocusable); - const activeIndex = focusableElements.findIndex(el => el === document.activeElement); + const activeIndex = focusableElements.findIndex(el => el === window.document.activeElement); const targetIndex = (activeIndex !== -1 && activeIndex !== (focusableElements.length - 1)) ? (activeIndex + 1) : 0; const targetElement = focusableElements.at(targetIndex) ?? itemsEl.value; @@ -397,9 +435,9 @@ const onGlobalMousedown = (ev: MouseEvent) => { const setupHandlers = () => { if (!isNestingMenu) { - document.addEventListener('focusin', onGlobalFocusin, { passive: true }); + window.document.addEventListener('focusin', onGlobalFocusin, { passive: true }); } - document.addEventListener('mousedown', onGlobalMousedown, { passive: true }); + window.document.addEventListener('mousedown', onGlobalMousedown, { passive: true }); }; let disposed = false; @@ -407,9 +445,9 @@ let disposed = false; const disposeHandlers = () => { disposed = true; if (!isNestingMenu) { - document.removeEventListener('focusin', onGlobalFocusin); + window.document.removeEventListener('focusin', onGlobalFocusin); } - document.removeEventListener('mousedown', onGlobalMousedown); + window.document.removeEventListener('mousedown', onGlobalMousedown); }; onMounted(() => { @@ -435,6 +473,12 @@ onBeforeUnmount(() => { } } + &:not(.asDrawer):not(.widthSpecified) { + > .menu { + max-width: 400px; + } + } + &.big:not(.asDrawer) { > .menu { min-width: 230px; @@ -558,11 +602,11 @@ onBeforeUnmount(() => { } &.danger { - --menuFg: #ff2a2a; + --menuFg: var(--MI_THEME-error); --menuHoverFg: #fff; - --menuHoverBg: #ff4242; + --menuHoverBg: var(--MI_THEME-error); --menuActiveFg: #fff; - --menuActiveBg: #d42e2e; + --menuActiveBg: hsl(from var(--MI_THEME-error) h s calc(l - 10)); } &.radio { @@ -604,10 +648,19 @@ onBeforeUnmount(() => { .item_content_text { max-width: calc(100vw - 4rem); +} + +.item_content_text_title { text-overflow: ellipsis; overflow: hidden; } +.item_content_text_caption { + text-wrap: auto; + font-size: 85%; + opacity: 0.7; +} + .switchButton { margin-left: -2px; --height: 1.35em; diff --git a/packages/frontend/src/components/MkMiniChart.vue b/packages/frontend/src/components/MkMiniChart.vue index 7ea585ecc2..98bd471438 100644 --- a/packages/frontend/src/components/MkMiniChart.vue +++ b/packages/frontend/src/components/MkMiniChart.vue @@ -48,7 +48,7 @@ const polygonPoints = ref(''); const headX = ref<number | null>(null); const headY = ref<number | null>(null); const clock = ref<number | null>(null); -const accent = tinycolor(getComputedStyle(document.documentElement).getPropertyValue('--MI_THEME-accent')); +const accent = tinycolor(getComputedStyle(window.document.documentElement).getPropertyValue('--MI_THEME-accent')); const color = accent.toRgbString(); function draw(): void { diff --git a/packages/frontend/src/components/MkModal.vue b/packages/frontend/src/components/MkModal.vue index 19588003fa..3bcf835ec9 100644 --- a/packages/frontend/src/components/MkModal.vue +++ b/packages/frontend/src/components/MkModal.vue @@ -42,14 +42,15 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { nextTick, normalizeClass, onMounted, onUnmounted, provide, watch, ref, shallowRef, computed } from 'vue'; +import { nextTick, normalizeClass, onMounted, onUnmounted, provide, watch, ref, useTemplateRef, computed } from 'vue'; +import type { Keymap } from '@/utility/hotkey.js'; import * as os from '@/os.js'; -import { isTouchUsing } from '@/scripts/touch.js'; -import { defaultStore } from '@/store.js'; -import { deviceKind } from '@/scripts/device-kind.js'; -import type { Keymap } from '@/scripts/hotkey.js'; -import { focusTrap } from '@/scripts/focus-trap.js'; -import { focusParent } from '@/scripts/focus.js'; +import { isTouchUsing } from '@/utility/touch.js'; +import { deviceKind } from '@/utility/device-kind.js'; +import { focusTrap } from '@/utility/focus-trap.js'; +import { focusParent } from '@/utility/focus.js'; +import { prefer } from '@/preferences.js'; +import { DI } from '@/di.js'; function getFixedContainer(el: Element | null): Element | null { if (el == null || el.tagName === 'BODY') return null; @@ -94,19 +95,19 @@ const emit = defineEmits<{ (ev: 'closed'): void; }>(); -provide('modal', true); +provide(DI.inModal, true); const maxHeight = ref<number>(); const fixed = ref(false); const transformOrigin = ref('center'); const showing = ref(true); -const modalRootEl = shallowRef<HTMLElement>(); -const content = shallowRef<HTMLElement>(); +const modalRootEl = useTemplateRef('modalRootEl'); +const content = useTemplateRef('content'); const zIndex = os.claimZIndex(props.zPriority); const useSendAnime = ref(false); const type = computed<ModalTypes>(() => { if (props.preferType === 'auto') { - if ((defaultStore.state.menuStyle === 'drawer') || (defaultStore.state.menuStyle === 'auto' && isTouchUsing && deviceKind === 'smartphone')) { + if ((prefer.s.menuStyle === 'drawer') || (prefer.s.menuStyle === 'auto' && isTouchUsing && deviceKind === 'smartphone')) { return 'drawer'; } else { return props.src != null ? 'popup' : 'dialog'; @@ -117,7 +118,7 @@ const type = computed<ModalTypes>(() => { }); const isEnableBgTransparent = computed(() => props.transparentBg && (type.value === 'popup')); const transitionName = computed((() => - defaultStore.state.animation + prefer.s.animation ? useSendAnime.value ? 'send' : type.value === 'drawer' diff --git a/packages/frontend/src/components/MkModalWindow.vue b/packages/frontend/src/components/MkModalWindow.vue index f06cfffee4..19989e375b 100644 --- a/packages/frontend/src/components/MkModalWindow.vue +++ b/packages/frontend/src/components/MkModalWindow.vue @@ -22,7 +22,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted, onUnmounted, shallowRef, ref } from 'vue'; +import { onMounted, onUnmounted, useTemplateRef, ref } from 'vue'; import MkModal from './MkModal.vue'; const props = withDefaults(defineProps<{ @@ -47,9 +47,9 @@ const emit = defineEmits<{ (event: 'esc'): void; }>(); -const modal = shallowRef<InstanceType<typeof MkModal>>(); -const rootEl = shallowRef<HTMLElement>(); -const headerEl = shallowRef<HTMLElement>(); +const modal = useTemplateRef('modal'); +const rootEl = useTemplateRef('rootEl'); +const headerEl = useTemplateRef('headerEl'); const bodyWidth = ref(0); const bodyHeight = ref(0); diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue index 8e1d854660..ab70a11b9b 100644 --- a/packages/frontend/src/components/MkNote.vue +++ b/packages/frontend/src/components/MkNote.vue @@ -9,13 +9,11 @@ SPDX-License-Identifier: AGPL-3.0-only v-show="!isDeleted" ref="rootEl" v-hotkey="keymap" - :class="[$style.root, { [$style.showActionsOnlyHover]: defaultStore.state.showNoteActionsOnlyHover, [$style.skipRender]: defaultStore.state.skipNoteRender }]" + :class="[$style.root, { [$style.showActionsOnlyHover]: prefer.s.showNoteActionsOnlyHover, [$style.skipRender]: prefer.s.skipNoteRender }]" :tabindex="isDeleted ? '-1' : '0'" > <MkNoteSub v-if="appearNote.reply && !renoteCollapsed" :note="appearNote.reply" :class="$style.replyTo"/> <div v-if="pinned" :class="$style.tip"><i class="ti ti-pin"></i> {{ i18n.ts.pinnedNote }}</div> - <!--<div v-if="appearNote._prId_" class="tip"><i class="ti ti-speakerphone"></i> {{ i18n.ts.promotion }}<button class="_textButton hide" @click="readPromo()">{{ i18n.ts.hideThisNote }} <i class="ti ti-x"></i></button></div>--> - <!--<div v-if="appearNote._featuredId_" class="tip"><i class="ti ti-bolt"></i> {{ i18n.ts.featured }}</div>--> <div v-if="isRenote" :class="$style.renote"> <div v-if="note.channel" :class="$style.colorBar" :style="{ background: note.channel.color }"></div> <MkAvatar :class="$style.renoteAvatar" :user="note.user" link preview/> @@ -47,7 +45,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> <article v-else :class="$style.article" @contextmenu.stop="onContextmenu"> <div v-if="appearNote.channel" :class="$style.colorBar" :style="{ background: appearNote.channel.color }"></div> - <MkAvatar :class="$style.avatar" :user="appearNote.user" :link="!mock" :preview="!mock"/> + <MkAvatar :class="[$style.avatar, prefer.s.useStickyIcons ? $style.useSticky : null]" :user="appearNote.user" :link="!mock" :preview="!mock"/> <div :class="$style.main"> <MkNoteHeader :note="appearNote" :mini="true"/> <MkInstanceTicker v-if="showTicker" :host="appearNote.user.host" :instance="appearNote.user.instance"/> @@ -76,12 +74,13 @@ SPDX-License-Identifier: AGPL-3.0-only :emojiUrls="appearNote.emojis" :enableEmojiMenu="true" :enableEmojiMenuReaction="true" + class="_selectable" /> <div v-if="translating || translation" :class="$style.translation"> <MkLoading v-if="translating" mini/> <div v-else-if="translation"> <b>{{ i18n.tsx.translatedFrom({ x: translation.sourceLang }) }}: </b> - <Mfm :text="translation.text" :author="appearNote.user" :nyaize="'respect'" :emojiUrls="appearNote.emojis"/> + <Mfm :text="translation.text" :author="appearNote.user" :nyaize="'respect'" :emojiUrls="appearNote.emojis" class="_selectable"/> </div> </div> </div> @@ -130,9 +129,9 @@ SPDX-License-Identifier: AGPL-3.0-only <i v-else-if="appearNote.myReaction != null" class="ti ti-minus" style="color: var(--MI_THEME-accent);"></i> <i v-else-if="appearNote.reactionAcceptance === 'likeOnly'" class="ti ti-heart"></i> <i v-else class="ti ti-plus"></i> - <p v-if="(appearNote.reactionAcceptance === 'likeOnly' || defaultStore.state.showReactionsCount) && appearNote.reactionCount > 0" :class="$style.footerButtonCount">{{ number(appearNote.reactionCount) }}</p> + <p v-if="(appearNote.reactionAcceptance === 'likeOnly' || prefer.s.showReactionsCount) && appearNote.reactionCount > 0" :class="$style.footerButtonCount">{{ number(appearNote.reactionCount) }}</p> </button> - <button v-if="defaultStore.state.showClipButtonInNoteFooter" ref="clipButton" :class="$style.footerButton" class="_button" @mousedown.prevent="clip()"> + <button v-if="prefer.s.showClipButtonInNoteFooter" ref="clipButton" :class="$style.footerButton" class="_button" @mousedown.prevent="clip()"> <i class="ti ti-paperclip"></i> </button> <button ref="menuButton" :class="$style.footerButton" class="_button" @mousedown.prevent="showMenu()"> @@ -177,14 +176,16 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { computed, inject, onMounted, ref, shallowRef, watch, provide } from 'vue'; -import type { Ref } from 'vue'; +import { computed, inject, onMounted, ref, useTemplateRef, watch, provide } from 'vue'; import * as mfm from 'mfm-js'; import * as Misskey from 'misskey-js'; import { isLink } from '@@/js/is-link.js'; import { shouldCollapsed } from '@@/js/collapsed.js'; import { host } from '@@/js/config.js'; +import type { Ref } from 'vue'; import type { MenuItem } from '@/types/menu.js'; +import type { OpenOnRemoteOptions } from '@/utility/please-login.js'; +import type { Keymap } from '@/utility/hotkey.js'; import MkNoteSub from '@/components/MkNoteSub.vue'; import MkNoteHeader from '@/components/MkNoteHeader.vue'; import MkNoteSimple from '@/components/MkNoteSimple.vue'; @@ -196,32 +197,32 @@ import MkPoll from '@/components/MkPoll.vue'; import MkUsersTooltip from '@/components/MkUsersTooltip.vue'; import MkUrlPreview from '@/components/MkUrlPreview.vue'; import MkInstanceTicker from '@/components/MkInstanceTicker.vue'; -import { pleaseLogin } from '@/scripts/please-login.js'; -import type { OpenOnRemoteOptions } from '@/scripts/please-login.js'; -import { checkWordMute } from '@/scripts/check-word-mute.js'; +import { pleaseLogin } from '@/utility/please-login.js'; +import { checkWordMute } from '@/utility/check-word-mute.js'; import { notePage } from '@/filters/note.js'; import { userPage } from '@/filters/user.js'; import number from '@/filters/number.js'; import * as os from '@/os.js'; -import * as sound from '@/scripts/sound.js'; -import { misskeyApi, misskeyApiGet } from '@/scripts/misskey-api.js'; -import { defaultStore, noteViewInterruptors } from '@/store.js'; -import { reactionPicker } from '@/scripts/reaction-picker.js'; -import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm.js'; -import { $i } from '@/account.js'; +import * as sound from '@/utility/sound.js'; +import { misskeyApi, misskeyApiGet } from '@/utility/misskey-api.js'; +import { reactionPicker } from '@/utility/reaction-picker.js'; +import { extractUrlFromMfm } from '@/utility/extract-url-from-mfm.js'; +import { $i } from '@/i.js'; import { i18n } from '@/i18n.js'; -import { getAbuseNoteMenu, getCopyNoteLinkMenu, getNoteClipMenu, getNoteMenu, getRenoteMenu } from '@/scripts/get-note-menu.js'; -import { useNoteCapture } from '@/scripts/use-note-capture.js'; -import { deepClone } from '@/scripts/clone.js'; -import { useTooltip } from '@/scripts/use-tooltip.js'; -import { claimAchievement } from '@/scripts/achievements.js'; -import { getNoteSummary } from '@/scripts/get-note-summary.js'; +import { getAbuseNoteMenu, getCopyNoteLinkMenu, getNoteClipMenu, getNoteMenu, getRenoteMenu } from '@/utility/get-note-menu.js'; +import { useNoteCapture } from '@/use/use-note-capture.js'; +import { deepClone } from '@/utility/clone.js'; +import { useTooltip } from '@/use/use-tooltip.js'; +import { claimAchievement } from '@/utility/achievements.js'; +import { getNoteSummary } from '@/utility/get-note-summary.js'; import MkRippleEffect from '@/components/MkRippleEffect.vue'; -import { showMovedDialog } from '@/scripts/show-moved-dialog.js'; +import { showMovedDialog } from '@/utility/show-moved-dialog.js'; import { isEnabledUrlPreview } from '@/instance.js'; -import type { Keymap } from '@/scripts/hotkey.js'; -import { focusPrev, focusNext } from '@/scripts/focus.js'; -import { getAppearNote } from '@/scripts/get-appear-note.js'; +import { focusPrev, focusNext } from '@/utility/focus.js'; +import { getAppearNote } from '@/utility/get-appear-note.js'; +import { prefer } from '@/preferences.js'; +import { getPluginHandlers } from '@/plugin.js'; +import { DI } from '@/di.js'; const props = withDefaults(defineProps<{ note: Misskey.entities.Note; @@ -232,7 +233,7 @@ const props = withDefaults(defineProps<{ mock: false, }); -provide('mock', props.mock); +provide(DI.mock, props.mock); const emit = defineEmits<{ (ev: 'reaction', emoji: string): void; @@ -247,6 +248,7 @@ const currentClip = inject<Ref<Misskey.entities.Clip> | null>('currentClip', nul const note = ref(deepClone(props.note)); // plugin +const noteViewInterruptors = getPluginHandlers('note_view_interruptor'); if (noteViewInterruptors.length > 0) { onMounted(async () => { let result: Misskey.entities.Note | null = deepClone(note.value); @@ -267,14 +269,14 @@ if (noteViewInterruptors.length > 0) { const isRenote = Misskey.note.isPureRenote(note.value); -const rootEl = shallowRef<HTMLElement>(); -const menuButton = shallowRef<HTMLElement>(); -const renoteButton = shallowRef<HTMLElement>(); -const renoteTime = shallowRef<HTMLElement>(); -const reactButton = shallowRef<HTMLElement>(); -const clipButton = shallowRef<HTMLElement>(); +const rootEl = useTemplateRef('rootEl'); +const menuButton = useTemplateRef('menuButton'); +const renoteButton = useTemplateRef('renoteButton'); +const renoteTime = useTemplateRef('renoteTime'); +const reactButton = useTemplateRef('reactButton'); +const clipButton = useTemplateRef('clipButton'); const appearNote = computed(() => getAppearNote(note.value)); -const galleryEl = shallowRef<InstanceType<typeof MkMediaList>>(); +const galleryEl = useTemplateRef('galleryEl'); const isMyRenote = $i && ($i.id === note.value.userId); const showContent = ref(false); const parsed = computed(() => appearNote.value.text ? mfm.parse(appearNote.value.text) : null); @@ -284,13 +286,13 @@ const collapsed = ref(appearNote.value.cw == null && isLong); const isDeleted = ref(false); const muted = ref(checkMute(appearNote.value, $i?.mutedWords)); const hardMuted = ref(props.withHardMute && checkMute(appearNote.value, $i?.hardMutedWords, true)); -const showSoftWordMutedWord = computed(() => defaultStore.state.showSoftWordMutedWord); +const showSoftWordMutedWord = computed(() => prefer.s.showSoftWordMutedWord); const translation = ref<Misskey.entities.NotesTranslateResponse | null>(null); const translating = ref(false); -const showTicker = (defaultStore.state.instanceTicker === 'always') || (defaultStore.state.instanceTicker === 'remote' && appearNote.value.user.instance); +const showTicker = (prefer.s.instanceTicker === 'always') || (prefer.s.instanceTicker === 'remote' && appearNote.value.user.instance); const canRenote = computed(() => ['public', 'home'].includes(appearNote.value.visibility) || (appearNote.value.visibility === 'followers' && appearNote.value.userId === $i?.id)); const renoteCollapsed = ref( - defaultStore.state.collapseRenotes && isRenote && ( + prefer.s.collapseRenotes && isRenote && ( ($i && ($i.id === note.value.userId || $i.id === appearNote.value.userId)) || // `||` must be `||`! See https://github.com/misskey-dev/misskey/issues/13131 (appearNote.value.myReaction != null) ), @@ -345,7 +347,7 @@ const keymap = { }, 'c': () => { if (renoteCollapsed.value) return; - if (!defaultStore.state.showClipButtonInNoteFooter) return; + if (!prefer.s.showClipButtonInNoteFooter) return; clip(); }, 'o': () => { @@ -375,7 +377,8 @@ const keymap = { }, } as const satisfies Keymap; -provide('react', (reaction: string) => { +provide(DI.mfmEmojiReactCallback, (reaction) => { + sound.playMisskeySfx('reaction'); misskeyApi('notes/reactions/create', { noteId: appearNote.value.id, reaction: reaction, @@ -479,7 +482,7 @@ function react(): void { reaction: '❤️', }); const el = reactButton.value; - if (el && defaultStore.state.animation) { + if (el && prefer.s.animation) { const rect = el.getBoundingClientRect(); const x = rect.left + (el.offsetWidth / 2); const y = rect.top + (el.offsetHeight / 2); @@ -490,7 +493,7 @@ function react(): void { } else { blur(); reactionPicker.show(reactButton.value ?? null, note.value, async (reaction) => { - if (defaultStore.state.confirmOnReact) { + if (prefer.s.confirmOnReact) { const confirm = await os.confirm({ type: 'question', text: i18n.tsx.reactAreYouSure({ emoji: reaction.replace('@.', '') }), @@ -549,7 +552,7 @@ function onContextmenu(ev: MouseEvent): void { if (ev.target && isLink(ev.target as HTMLElement)) return; if (window.getSelection()?.toString() !== '') return; - if (defaultStore.state.useReactionPickerForContextMenu) { + if (prefer.s.useReactionPickerForContextMenu) { ev.preventDefault(); react(); } else { @@ -655,7 +658,6 @@ function emitUpdReaction(emoji: string, delta: number) { <style lang="scss" module> .root { position: relative; - transition: box-shadow 0.1s ease; font-size: 1.05em; overflow: clip; contain: content; @@ -721,6 +723,8 @@ function emitUpdReaction(emoji: string, delta: number) { } .skipRender { + // TODO: これが有効だとTransitionGroupでnoteを追加するときに一瞬がくっとなってしまうのをどうにかしたい + // Transitionが完了するのを待ってからskipRenderを付与すれば解決しそうだけどパフォーマンス的な影響が不明 content-visibility: auto; contain-intrinsic-size: 0 150px; } @@ -847,9 +851,12 @@ function emitUpdReaction(emoji: string, delta: number) { margin: 0 14px 0 0; width: 58px; height: 58px; - position: sticky !important; - top: calc(22px + var(--MI-stickyTop, 0px)); - left: 0; + + &.useSticky { + position: sticky !important; + top: calc(22px + var(--MI-stickyTop, 0px)); + left: 0; + } } .main { @@ -1036,7 +1043,10 @@ function emitUpdReaction(emoji: string, delta: number) { margin: 0 10px 0 0; width: 46px; height: 46px; - top: calc(14px + var(--MI-stickyTop, 0px)); + + &.useSticky { + top: calc(14px + var(--MI-stickyTop, 0px)); + } } } diff --git a/packages/frontend/src/components/MkNoteDetailed.vue b/packages/frontend/src/components/MkNoteDetailed.vue index 27d8a399cc..a26eb808e4 100644 --- a/packages/frontend/src/components/MkNoteDetailed.vue +++ b/packages/frontend/src/components/MkNoteDetailed.vue @@ -97,13 +97,14 @@ SPDX-License-Identifier: AGPL-3.0-only :emojiUrls="appearNote.emojis" :enableEmojiMenu="true" :enableEmojiMenuReaction="true" + class="_selectable" /> <a v-if="appearNote.renote != null" :class="$style.rn">RN:</a> <div v-if="translating || translation" :class="$style.translation"> <MkLoading v-if="translating" mini/> <div v-else-if="translation"> <b>{{ i18n.tsx.translatedFrom({ x: translation.sourceLang }) }}: </b> - <Mfm :text="translation.text" :author="appearNote.user" :nyaize="'respect'" :emojiUrls="appearNote.emojis"/> + <Mfm :text="translation.text" :author="appearNote.user" :nyaize="'respect'" :emojiUrls="appearNote.emojis" class="_selectable"/> </div> </div> <div v-if="appearNote.files && appearNote.files.length > 0"> @@ -146,9 +147,9 @@ SPDX-License-Identifier: AGPL-3.0-only <i v-else-if="appearNote.myReaction != null" class="ti ti-minus" style="color: var(--MI_THEME-accent);"></i> <i v-else-if="appearNote.reactionAcceptance === 'likeOnly'" class="ti ti-heart"></i> <i v-else class="ti ti-plus"></i> - <p v-if="(appearNote.reactionAcceptance === 'likeOnly' || defaultStore.state.showReactionsCount) && appearNote.reactionCount > 0" :class="$style.noteFooterButtonCount">{{ number(appearNote.reactionCount) }}</p> + <p v-if="(appearNote.reactionAcceptance === 'likeOnly' || prefer.s.showReactionsCount) && appearNote.reactionCount > 0" :class="$style.noteFooterButtonCount">{{ number(appearNote.reactionCount) }}</p> </button> - <button v-if="defaultStore.state.showClipButtonInNoteFooter" ref="clipButton" class="_button" :class="$style.noteFooterButton" @mousedown.prevent="clip()"> + <button v-if="prefer.s.showClipButtonInNoteFooter" ref="clipButton" class="_button" :class="$style.noteFooterButton" @mousedown.prevent="clip()"> <i class="ti ti-paperclip"></i> </button> <button ref="menuButton" class="_button" :class="$style.noteFooterButton" @mousedown.prevent="showMenu()"> @@ -210,11 +211,14 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { computed, inject, onMounted, provide, ref, shallowRef } from 'vue'; +import { computed, inject, onMounted, provide, ref, useTemplateRef } from 'vue'; import * as mfm from 'mfm-js'; import * as Misskey from 'misskey-js'; import { isLink } from '@@/js/is-link.js'; import { host } from '@@/js/config.js'; +import type { OpenOnRemoteOptions } from '@/utility/please-login.js'; +import type { Paging } from '@/components/MkPagination.vue'; +import type { Keymap } from '@/utility/hotkey.js'; import MkNoteSub from '@/components/MkNoteSub.vue'; import MkNoteSimple from '@/components/MkNoteSimple.vue'; import MkReactionsViewer from '@/components/MkReactionsViewer.vue'; @@ -225,35 +229,34 @@ import MkPoll from '@/components/MkPoll.vue'; import MkUsersTooltip from '@/components/MkUsersTooltip.vue'; import MkUrlPreview from '@/components/MkUrlPreview.vue'; import MkInstanceTicker from '@/components/MkInstanceTicker.vue'; -import { pleaseLogin } from '@/scripts/please-login.js'; -import type { OpenOnRemoteOptions } from '@/scripts/please-login.js'; -import { checkWordMute } from '@/scripts/check-word-mute.js'; +import { pleaseLogin } from '@/utility/please-login.js'; +import { checkWordMute } from '@/utility/check-word-mute.js'; import { userPage } from '@/filters/user.js'; import { notePage } from '@/filters/note.js'; import number from '@/filters/number.js'; import * as os from '@/os.js'; -import { misskeyApi, misskeyApiGet } from '@/scripts/misskey-api.js'; -import * as sound from '@/scripts/sound.js'; -import { defaultStore, noteViewInterruptors } from '@/store.js'; -import { reactionPicker } from '@/scripts/reaction-picker.js'; -import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm.js'; -import { $i } from '@/account.js'; +import { misskeyApi, misskeyApiGet } from '@/utility/misskey-api.js'; +import * as sound from '@/utility/sound.js'; +import { reactionPicker } from '@/utility/reaction-picker.js'; +import { extractUrlFromMfm } from '@/utility/extract-url-from-mfm.js'; +import { $i } from '@/i.js'; import { i18n } from '@/i18n.js'; -import { getNoteClipMenu, getNoteMenu, getRenoteMenu } from '@/scripts/get-note-menu.js'; -import { useNoteCapture } from '@/scripts/use-note-capture.js'; -import { deepClone } from '@/scripts/clone.js'; -import { useTooltip } from '@/scripts/use-tooltip.js'; -import { claimAchievement } from '@/scripts/achievements.js'; +import { getNoteClipMenu, getNoteMenu, getRenoteMenu } from '@/utility/get-note-menu.js'; +import { useNoteCapture } from '@/use/use-note-capture.js'; +import { deepClone } from '@/utility/clone.js'; +import { useTooltip } from '@/use/use-tooltip.js'; +import { claimAchievement } from '@/utility/achievements.js'; import MkRippleEffect from '@/components/MkRippleEffect.vue'; -import { showMovedDialog } from '@/scripts/show-moved-dialog.js'; +import { showMovedDialog } from '@/utility/show-moved-dialog.js'; import MkUserCardMini from '@/components/MkUserCardMini.vue'; import MkPagination from '@/components/MkPagination.vue'; -import type { Paging } from '@/components/MkPagination.vue'; import MkReactionIcon from '@/components/MkReactionIcon.vue'; import MkButton from '@/components/MkButton.vue'; import { isEnabledUrlPreview } from '@/instance.js'; -import { getAppearNote } from '@/scripts/get-appear-note.js'; -import type { Keymap } from '@/scripts/hotkey.js'; +import { getAppearNote } from '@/utility/get-appear-note.js'; +import { prefer } from '@/preferences.js'; +import { getPluginHandlers } from '@/plugin.js'; +import { DI } from '@/di.js'; const props = withDefaults(defineProps<{ note: Misskey.entities.Note; @@ -267,6 +270,7 @@ const inChannel = inject('inChannel', null); const note = ref(deepClone(props.note)); // plugin +const noteViewInterruptors = getPluginHandlers('note_view_interruptor'); if (noteViewInterruptors.length > 0) { onMounted(async () => { let result: Misskey.entities.Note | null = deepClone(note.value); @@ -287,14 +291,14 @@ if (noteViewInterruptors.length > 0) { const isRenote = Misskey.note.isPureRenote(note.value); -const rootEl = shallowRef<HTMLElement>(); -const menuButton = shallowRef<HTMLElement>(); -const renoteButton = shallowRef<HTMLElement>(); -const renoteTime = shallowRef<HTMLElement>(); -const reactButton = shallowRef<HTMLElement>(); -const clipButton = shallowRef<HTMLElement>(); +const rootEl = useTemplateRef('rootEl'); +const menuButton = useTemplateRef('menuButton'); +const renoteButton = useTemplateRef('renoteButton'); +const renoteTime = useTemplateRef('renoteTime'); +const reactButton = useTemplateRef('reactButton'); +const clipButton = useTemplateRef('clipButton'); const appearNote = computed(() => getAppearNote(note.value)); -const galleryEl = shallowRef<InstanceType<typeof MkMediaList>>(); +const galleryEl = useTemplateRef('galleryEl'); const isMyRenote = $i && ($i.id === note.value.userId); const showContent = ref(false); const isDeleted = ref(false); @@ -303,7 +307,7 @@ const translation = ref<Misskey.entities.NotesTranslateResponse | null>(null); const translating = ref(false); const parsed = appearNote.value.text ? mfm.parse(appearNote.value.text) : null; const urls = parsed ? extractUrlFromMfm(parsed).filter((url) => appearNote.value.renote?.url !== url && appearNote.value.renote?.uri !== url) : null; -const showTicker = (defaultStore.state.instanceTicker === 'always') || (defaultStore.state.instanceTicker === 'remote' && appearNote.value.user.instance); +const showTicker = (prefer.s.instanceTicker === 'always') || (prefer.s.instanceTicker === 'remote' && appearNote.value.user.instance); const conversation = ref<Misskey.entities.Note[]>([]); const replies = ref<Misskey.entities.Note[]>([]); const canRenote = computed(() => ['public', 'home'].includes(appearNote.value.visibility) || appearNote.value.userId === $i?.id); @@ -319,7 +323,7 @@ const keymap = { 'q': () => renote(), 'm': () => showMenu(), 'c': () => { - if (!defaultStore.state.showClipButtonInNoteFooter) return; + if (!prefer.s.showClipButtonInNoteFooter) return; clip(); }, 'o': () => galleryEl.value?.openGallery(), @@ -334,7 +338,8 @@ const keymap = { }, } as const satisfies Keymap; -provide('react', (reaction: string) => { +provide(DI.mfmEmojiReactCallback, (reaction) => { + sound.playMisskeySfx('reaction'); misskeyApi('notes/reactions/create', { noteId: appearNote.value.id, reaction: reaction, @@ -442,7 +447,7 @@ function react(): void { reaction: '❤️', }); const el = reactButton.value; - if (el && defaultStore.state.animation) { + if (el && prefer.s.animation) { const rect = el.getBoundingClientRect(); const x = rect.left + (el.offsetWidth / 2); const y = rect.top + (el.offsetHeight / 2); @@ -453,7 +458,7 @@ function react(): void { } else { blur(); reactionPicker.show(reactButton.value ?? null, note.value, async (reaction) => { - if (defaultStore.state.confirmOnReact) { + if (prefer.s.confirmOnReact) { const confirm = await os.confirm({ type: 'question', text: i18n.tsx.reactAreYouSure({ emoji: reaction.replace('@.', '') }), @@ -497,7 +502,7 @@ function onContextmenu(ev: MouseEvent): void { if (ev.target && isLink(ev.target as HTMLElement)) return; if (window.getSelection()?.toString() !== '') return; - if (defaultStore.state.useReactionPickerForContextMenu) { + if (prefer.s.useReactionPickerForContextMenu) { ev.preventDefault(); react(); } else { diff --git a/packages/frontend/src/components/MkNoteHeader.vue b/packages/frontend/src/components/MkNoteHeader.vue index 750e32a9ff..ea115c2cd8 100644 --- a/packages/frontend/src/components/MkNoteHeader.vue +++ b/packages/frontend/src/components/MkNoteHeader.vue @@ -40,13 +40,13 @@ import * as Misskey from 'misskey-js'; import { i18n } from '@/i18n.js'; import { notePage } from '@/filters/note.js'; import { userPage } from '@/filters/user.js'; -import { defaultStore } from '@/store.js'; +import { DI } from '@/di.js'; defineProps<{ note: Misskey.entities.Note; }>(); -const mock = inject<boolean>('mock', false); +const mock = inject(DI.mock, false); </script> <style lang="scss" module> diff --git a/packages/frontend/src/components/MkNoteMediaGrid.vue b/packages/frontend/src/components/MkNoteMediaGrid.vue index e51ea5a2de..764d9f6a32 100644 --- a/packages/frontend/src/components/MkNoteMediaGrid.vue +++ b/packages/frontend/src/components/MkNoteMediaGrid.vue @@ -7,9 +7,9 @@ SPDX-License-Identifier: AGPL-3.0-only <template v-for="file in note.files"> <div v-if="((( - (defaultStore.state.nsfw === 'force' || file.isSensitive) && - defaultStore.state.nsfw !== 'ignore' - ) || (defaultStore.state.dataSaver.media && file.type.startsWith('image/'))) && + (prefer.s.nsfw === 'force' || file.isSensitive) && + prefer.s.nsfw !== 'ignore' + ) || (prefer.s.dataSaver.media && file.type.startsWith('image/'))) && !showingFiles.has(file.id) )" :class="[$style.filePreview, { [$style.square]: square }]" @@ -18,15 +18,15 @@ SPDX-License-Identifier: AGPL-3.0-only <MkDriveFileThumbnail :file="file" fit="cover" - :highlightWhenSensitive="defaultStore.state.highlightSensitiveMedia" + :highlightWhenSensitive="prefer.s.highlightSensitiveMedia" :forceBlurhash="true" :large="true" :class="$style.file" /> <div :class="$style.sensitive"> <div> - <div v-if="file.isSensitive"><i class="ti ti-eye-exclamation"></i> {{ i18n.ts.sensitive }}{{ defaultStore.state.dataSaver.media && file.size ? ` (${bytes(file.size)})` : '' }}</div> - <div v-else><i class="ti ti-photo"></i> {{ defaultStore.state.dataSaver.media && file.size ? bytes(file.size) : i18n.ts.image }}</div> + <div v-if="file.isSensitive"><i class="ti ti-eye-exclamation"></i> {{ i18n.ts.sensitive }}{{ prefer.s.dataSaver.media && file.size ? ` (${bytes(file.size)})` : '' }}</div> + <div v-else><i class="ti ti-photo"></i> {{ prefer.s.dataSaver.media && file.size ? bytes(file.size) : i18n.ts.image }}</div> <div>{{ i18n.ts.clickToShow }}</div> </div> </div> @@ -35,7 +35,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkDriveFileThumbnail :file="file" fit="cover" - :highlightWhenSensitive="defaultStore.state.highlightSensitiveMedia" + :highlightWhenSensitive="prefer.s.highlightSensitiveMedia" :large="true" :class="$style.file" /> @@ -45,10 +45,10 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { ref } from 'vue'; +import * as Misskey from 'misskey-js'; import { notePage } from '@/filters/note.js'; import { i18n } from '@/i18n.js'; -import * as Misskey from 'misskey-js'; -import { defaultStore } from '@/store.js'; +import { prefer } from '@/preferences.js'; import bytes from '@/filters/bytes.js'; import MkDriveFileThumbnail from '@/components/MkDriveFileThumbnail.vue'; diff --git a/packages/frontend/src/components/MkNoteSub.vue b/packages/frontend/src/components/MkNoteSub.vue index e4bade309b..4fd1c210cb 100644 --- a/packages/frontend/src/components/MkNoteSub.vue +++ b/packages/frontend/src/components/MkNoteSub.vue @@ -46,11 +46,11 @@ import MkNoteHeader from '@/components/MkNoteHeader.vue'; import MkSubNoteContent from '@/components/MkSubNoteContent.vue'; import MkCwButton from '@/components/MkCwButton.vue'; import { notePage } from '@/filters/note.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; -import { $i } from '@/account.js'; +import { $i } from '@/i.js'; import { userPage } from '@/filters/user.js'; -import { checkWordMute } from '@/scripts/check-word-mute.js'; +import { checkWordMute } from '@/utility/check-word-mute.js'; const props = withDefaults(defineProps<{ note: Misskey.entities.Note; diff --git a/packages/frontend/src/components/MkNotes.vue b/packages/frontend/src/components/MkNotes.vue index 344b7c4dd2..ad6210816d 100644 --- a/packages/frontend/src/components/MkNotes.vue +++ b/packages/frontend/src/components/MkNotes.vue @@ -7,38 +7,43 @@ SPDX-License-Identifier: AGPL-3.0-only <MkPagination ref="pagingComponent" :pagination="pagination" :disableAutoLoad="disableAutoLoad"> <template #empty> <div class="_fullinfo"> - <img :src="infoImageUrl" class="_ghost"/> + <img :src="infoImageUrl" draggable="false"/> <div>{{ i18n.ts.noNotes }}</div> </div> </template> <template #default="{ items: notes }"> - <div :class="[$style.root, { [$style.noGap]: noGap }]"> - <MkDateSeparatedList - ref="notes" - v-slot="{ item: note }" - :items="notes" - :direction="pagination.reversed ? 'up' : 'down'" - :reversed="pagination.reversed" - :noGap="noGap" - :ad="true" - :class="$style.notes" - > - <MkNote :key="note._featuredId_ || note._prId_ || note.id" :class="$style.note" :note="note" :withHardMute="true"/> - </MkDateSeparatedList> - </div> + <component + :is="prefer.s.animation ? TransitionGroup : 'div'" :class="[$style.root, { [$style.noGap]: noGap, '_gaps': !noGap }]" + :enterActiveClass="$style.transition_x_enterActive" + :leaveActiveClass="$style.transition_x_leaveActive" + :enterFromClass="$style.transition_x_enterFrom" + :leaveToClass="$style.transition_x_leaveTo" + :moveClass=" $style.transition_x_move" + tag="div" + > + <template v-for="(note, i) in notes" :key="note.id"> + <div v-if="note._shouldInsertAd_" :class="[$style.noteWithAd, { '_gaps': !noGap }]"> + <MkNote :class="$style.note" :note="note" :withHardMute="true"/> + <div :class="$style.ad"> + <MkAd :preferForms="['horizontal', 'horizontal-big']"/> + </div> + </div> + <MkNote v-else :class="$style.note" :note="note" :withHardMute="true"/> + </template> + </component> </template> </MkPagination> </template> <script lang="ts" setup> -import { shallowRef } from 'vue'; +import { useTemplateRef, TransitionGroup } from 'vue'; +import type { Paging } from '@/components/MkPagination.vue'; import MkNote from '@/components/MkNote.vue'; -import MkDateSeparatedList from '@/components/MkDateSeparatedList.vue'; import MkPagination from '@/components/MkPagination.vue'; -import type { Paging } from '@/components/MkPagination.vue'; import { i18n } from '@/i18n.js'; import { infoImageUrl } from '@/instance.js'; +import { prefer } from '@/preferences.js'; const props = defineProps<{ pagination: Paging; @@ -46,7 +51,7 @@ const props = defineProps<{ disableAutoLoad?: boolean; }>(); -const pagingComponent = shallowRef<InstanceType<typeof MkPagination>>(); +const pagingComponent = useTemplateRef('pagingComponent'); defineExpose({ pagingComponent, @@ -54,22 +59,49 @@ defineExpose({ </script> <style lang="scss" module> +.transition_x_move, +.transition_x_enterActive, +.transition_x_leaveActive { + transition: opacity 0.3s cubic-bezier(0,.5,.5,1), transform 0.3s cubic-bezier(0,.5,.5,1) !important; +} +.transition_x_enterFrom, +.transition_x_leaveTo { + opacity: 0; + transform: translateY(-50%); +} +.transition_x_leaveActive { + position: absolute; +} + .root { + container-type: inline-size; + &.noGap { - > .notes { - background: var(--MI_THEME-panel); + background: var(--MI_THEME-panel); + + .note { + border-bottom: solid 0.5px var(--MI_THEME-divider); + } + + .ad { + padding: 8px; + background-size: auto auto; + background-image: repeating-linear-gradient(45deg, transparent, transparent 8px, var(--MI_THEME-bg) 8px, var(--MI_THEME-bg) 14px); + border-bottom: solid 0.5px var(--MI_THEME-divider); } } &:not(.noGap) { - > .notes { - background: var(--MI_THEME-bg); + background: var(--MI_THEME-bg); - .note { - background: var(--MI_THEME-panel); - border-radius: var(--MI-radius); - } + .note { + background: var(--MI_THEME-panel); + border-radius: var(--MI-radius); } } } + +.ad:empty { + display: none; +} </style> diff --git a/packages/frontend/src/components/MkNotification.vue b/packages/frontend/src/components/MkNotification.vue index 80cb9a45bb..13ffd6b7cc 100644 --- a/packages/frontend/src/components/MkNotification.vue +++ b/packages/frontend/src/components/MkNotification.vue @@ -28,6 +28,7 @@ SPDX-License-Identifier: AGPL-3.0-only [$style.t_exportCompleted]: notification.type === 'exportCompleted', [$style.t_login]: notification.type === 'login', [$style.t_createToken]: notification.type === 'createToken', + [$style.t_chatRoomInvitationReceived]: notification.type === 'chatRoomInvitationReceived', [$style.t_roleAssigned]: notification.type === 'roleAssigned' && notification.role.iconUrl == null, }]" > @@ -43,6 +44,7 @@ SPDX-License-Identifier: AGPL-3.0-only <i v-else-if="notification.type === 'exportCompleted'" class="ti ti-archive"></i> <i v-else-if="notification.type === 'login'" class="ti ti-login-2"></i> <i v-else-if="notification.type === 'createToken'" class="ti ti-key"></i> + <i v-else-if="notification.type === 'chatRoomInvitationReceived'" class="ti ti-messages"></i> <template v-else-if="notification.type === 'roleAssigned'"> <img v-if="notification.role.iconUrl" style="height: 1.3em; vertical-align: -22%;" :src="notification.role.iconUrl" alt=""/> <i v-else class="ti ti-badges"></i> @@ -61,6 +63,7 @@ SPDX-License-Identifier: AGPL-3.0-only <span v-if="notification.type === 'pollEnded'">{{ i18n.ts._notification.pollEnded }}</span> <span v-else-if="notification.type === 'note'">{{ i18n.ts._notification.newNote }}: <MkUserName :user="notification.note.user"/></span> <span v-else-if="notification.type === 'roleAssigned'">{{ i18n.ts._notification.roleAssigned }}</span> + <span v-else-if="notification.type === 'chatRoomInvitationReceived'">{{ i18n.ts._notification.chatRoomInvitationReceived }}</span> <span v-else-if="notification.type === 'achievementEarned'">{{ i18n.ts._notification.achievementEarned }}</span> <span v-else-if="notification.type === 'login'">{{ i18n.ts._notification.login }}</span> <span v-else-if="notification.type === 'createToken'">{{ i18n.ts._notification.createToken }}</span> @@ -104,6 +107,9 @@ SPDX-License-Identifier: AGPL-3.0-only <div v-else-if="notification.type === 'roleAssigned'" :class="$style.text"> {{ notification.role.name }} </div> + <div v-else-if="notification.type === 'chatRoomInvitationReceived'" :class="$style.text"> + {{ notification.invitation.room.name }} + </div> <MkA v-else-if="notification.type === 'achievementEarned'" :class="$style.text" to="/my/achievements"> {{ i18n.ts._achievements._types['_' + notification.achievement].title }} </MkA> @@ -164,15 +170,15 @@ import { ref } from 'vue'; import * as Misskey from 'misskey-js'; import MkReactionIcon from '@/components/MkReactionIcon.vue'; import MkButton from '@/components/MkButton.vue'; -import { getNoteSummary } from '@/scripts/get-note-summary.js'; +import { getNoteSummary } from '@/utility/get-note-summary.js'; import { notePage } from '@/filters/note.js'; import { userPage } from '@/filters/user.js'; import { i18n } from '@/i18n.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { signinRequired } from '@/account.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { ensureSignin } from '@/i.js'; import { infoImageUrl } from '@/instance.js'; -const $i = signinRequired(); +const $i = ensureSignin(); const props = withDefaults(defineProps<{ notification: Misskey.entities.Notification; @@ -369,6 +375,12 @@ function getActualReactedUsersCount(notification: Misskey.entities.Notification) pointer-events: none; } +.t_chatRoomInvitationReceived { + padding: 3px; + background: var(--eventOther); + pointer-events: none; +} + .tail { flex: 1; min-width: 0; diff --git a/packages/frontend/src/components/MkNotificationSelectWindow.vue b/packages/frontend/src/components/MkNotificationSelectWindow.vue index a0fb7fea83..d074dceb2f 100644 --- a/packages/frontend/src/components/MkNotificationSelectWindow.vue +++ b/packages/frontend/src/components/MkNotificationSelectWindow.vue @@ -30,13 +30,13 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { ref, shallowRef } from 'vue'; -import type { Ref } from 'vue'; +import { ref, useTemplateRef } from 'vue'; +import { notificationTypes } from '@@/js/const.js'; import MkSwitch from './MkSwitch.vue'; import MkInfo from './MkInfo.vue'; import MkButton from './MkButton.vue'; +import type { Ref } from 'vue'; import MkModalWindow from '@/components/MkModalWindow.vue'; -import { notificationTypes } from '@@/js/const.js'; import { i18n } from '@/i18n.js'; type TypesMap = Record<typeof notificationTypes[number], Ref<boolean>>; @@ -52,7 +52,7 @@ const props = withDefaults(defineProps<{ excludeTypes: () => [], }); -const dialog = shallowRef<InstanceType<typeof MkModalWindow>>(); +const dialog = useTemplateRef('dialog'); const typesMap = notificationTypes.reduce((p, t) => ({ ...p, [t]: ref<boolean>(!props.excludeTypes.includes(t)) }), {} as TypesMap); diff --git a/packages/frontend/src/components/MkNotifications.vue b/packages/frontend/src/components/MkNotifications.vue index 470837ace5..99eca35eb7 100644 --- a/packages/frontend/src/components/MkNotifications.vue +++ b/packages/frontend/src/components/MkNotifications.vue @@ -8,42 +8,51 @@ SPDX-License-Identifier: AGPL-3.0-only <MkPagination ref="pagingComponent" :pagination="pagination"> <template #empty> <div class="_fullinfo"> - <img :src="infoImageUrl" class="_ghost"/> + <img :src="infoImageUrl" draggable="false"/> <div>{{ i18n.ts.noNotifications }}</div> </div> </template> <template #default="{ items: notifications }"> - <MkDateSeparatedList v-slot="{ item: notification }" :class="$style.list" :items="notifications" :noGap="true"> - <MkNote v-if="['reply', 'quote', 'mention'].includes(notification.type)" :key="notification.id + ':note'" :note="notification.note" :withHardMute="true"/> - <XNotification v-else :key="notification.id" :notification="notification" :withTime="true" :full="true" class="_panel"/> - </MkDateSeparatedList> + <component + :is="prefer.s.animation ? TransitionGroup : 'div'" :class="[$style.notifications]" + :enterActiveClass="$style.transition_x_enterActive" + :leaveActiveClass="$style.transition_x_leaveActive" + :enterFromClass="$style.transition_x_enterFrom" + :leaveToClass="$style.transition_x_leaveTo" + :moveClass=" $style.transition_x_move" + tag="div" + > + <template v-for="(notification, i) in notifications" :key="notification.id"> + <MkNote v-if="['reply', 'quote', 'mention'].includes(notification.type)" :class="$style.item" :note="notification.note" :withHardMute="true"/> + <XNotification v-else :class="$style.item" :notification="notification" :withTime="true" :full="true"/> + </template> + </component> </template> </MkPagination> </MkPullToRefresh> </template> <script lang="ts" setup> -import { onUnmounted, onDeactivated, onMounted, computed, shallowRef, onActivated } from 'vue'; +import { onUnmounted, onMounted, computed, useTemplateRef, TransitionGroup } from 'vue'; +import * as Misskey from 'misskey-js'; +import type { notificationTypes } from '@@/js/const.js'; import MkPagination from '@/components/MkPagination.vue'; import XNotification from '@/components/MkNotification.vue'; -import MkDateSeparatedList from '@/components/MkDateSeparatedList.vue'; import MkNote from '@/components/MkNote.vue'; import { useStream } from '@/stream.js'; import { i18n } from '@/i18n.js'; -import type { notificationTypes } from '@@/js/const.js'; import { infoImageUrl } from '@/instance.js'; -import { defaultStore } from '@/store.js'; import MkPullToRefresh from '@/components/MkPullToRefresh.vue'; -import * as Misskey from 'misskey-js'; +import { prefer } from '@/preferences.js'; const props = defineProps<{ excludeTypes?: typeof notificationTypes[number][]; }>(); -const pagingComponent = shallowRef<InstanceType<typeof MkPagination>>(); +const pagingComponent = useTemplateRef('pagingComponent'); -const pagination = computed(() => defaultStore.reactiveState.useGroupedNotifications.value ? { +const pagination = computed(() => prefer.r.useGroupedNotifications.value ? { endpoint: 'i/notifications-grouped' as const, limit: 20, params: computed(() => ({ @@ -59,7 +68,7 @@ const pagination = computed(() => defaultStore.reactiveState.useGroupedNotificat function onNotification(notification) { const isMuted = props.excludeTypes ? props.excludeTypes.includes(notification.type) : false; - if (isMuted || document.visibilityState === 'visible') { + if (isMuted || window.document.visibilityState === 'visible') { useStream().send('readNotification'); } @@ -84,28 +93,36 @@ onMounted(() => { connection.on('notificationFlushed', reload); }); -onActivated(() => { - pagingComponent.value?.reload(); - connection = useStream().useChannel('main'); - connection.on('notification', onNotification); - connection.on('notificationFlushed', reload); -}); - onUnmounted(() => { if (connection) connection.dispose(); }); -onDeactivated(() => { - if (connection) connection.dispose(); -}); - defineExpose({ reload, }); </script> <style lang="scss" module> -.list { +.transition_x_move, +.transition_x_enterActive, +.transition_x_leaveActive { + transition: opacity 0.3s cubic-bezier(0,.5,.5,1), transform 0.3s cubic-bezier(0,.5,.5,1) !important; +} +.transition_x_enterFrom, +.transition_x_leaveTo { + opacity: 0; + transform: translateY(-50%); +} +.transition_x_leaveActive { + position: absolute; +} + +.notifications { + container-type: inline-size; background: var(--MI_THEME-panel); } + +.item { + border-bottom: solid 0.5px var(--MI_THEME-divider); +} </style> diff --git a/packages/frontend/src/components/MkOmit.vue b/packages/frontend/src/components/MkOmit.vue index a05176e2f4..04276b47fe 100644 --- a/packages/frontend/src/components/MkOmit.vue +++ b/packages/frontend/src/components/MkOmit.vue @@ -13,7 +13,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted, onUnmounted, shallowRef, ref } from 'vue'; +import { onMounted, onUnmounted, useTemplateRef, ref } from 'vue'; import { i18n } from '@/i18n.js'; const props = withDefaults(defineProps<{ @@ -22,7 +22,7 @@ const props = withDefaults(defineProps<{ maxHeight: 200, }); -const content = shallowRef<HTMLElement>(); +const content = useTemplateRef('content'); const omitted = ref(false); const ignoreOmit = ref(false); diff --git a/packages/frontend/src/components/MkPageWindow.vue b/packages/frontend/src/components/MkPageWindow.vue index c3fc1961eb..32c2e48b01 100644 --- a/packages/frontend/src/components/MkPageWindow.vue +++ b/packages/frontend/src/components/MkPageWindow.vue @@ -22,29 +22,29 @@ SPDX-License-Identifier: AGPL-3.0-only </template> </template> - <div ref="contents" :class="$style.root" style="container-type: inline-size;"> - <RouterView :key="reloadCount" :router="windowRouter"/> + <div :class="$style.root"> + <StackingRouterView v-if="prefer.s['experimental.stackingRouterView']" :key="reloadCount" :router="windowRouter"/> + <RouterView v-else :key="reloadCount" :router="windowRouter"/> </div> </MkWindow> </template> <script lang="ts" setup> -import { computed, onMounted, onUnmounted, provide, ref, shallowRef } from 'vue'; +import { computed, onMounted, onUnmounted, provide, ref, useTemplateRef } from 'vue'; import { url } from '@@/js/config.js'; -import { getScrollContainer } from '@@/js/scroll.js'; -import type { PageMetadata } from '@/scripts/page-metadata.js'; +import type { PageMetadata } from '@/page.js'; import RouterView from '@/components/global/RouterView.vue'; import MkWindow from '@/components/MkWindow.vue'; -import { popout as _popout } from '@/scripts/popout.js'; -import { copyToClipboard } from '@/scripts/copy-to-clipboard.js'; -import { useScrollPositionManager } from '@/nirax.js'; +import { popout as _popout } from '@/utility/popout.js'; +import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; import { i18n } from '@/i18n.js'; -import { provideMetadataReceiver, provideReactiveMetadata } from '@/scripts/page-metadata.js'; +import { provideMetadataReceiver, provideReactiveMetadata } from '@/page.js'; import { openingWindowsCount } from '@/os.js'; -import { claimAchievement } from '@/scripts/achievements.js'; -import { useRouterFactory } from '@/router/supplier.js'; -import { mainRouter } from '@/router/main.js'; +import { claimAchievement } from '@/utility/achievements.js'; +import { createRouter, mainRouter } from '@/router.js'; import { analytics } from '@/analytics.js'; +import { DI } from '@/di.js'; +import { prefer } from '@/preferences.js'; const props = defineProps<{ initialPath: string; @@ -54,15 +54,12 @@ const emit = defineEmits<{ (ev: 'closed'): void; }>(); -const routerFactory = useRouterFactory(); -const windowRouter = routerFactory(props.initialPath); +const windowRouter = createRouter(props.initialPath); -const contents = shallowRef<HTMLElement | null>(null); const pageMetadata = ref<null | PageMetadata>(null); -const windowEl = shallowRef<InstanceType<typeof MkWindow>>(); -const history = ref<{ path: string; key: string; }[]>([{ - path: windowRouter.getCurrentPath(), - key: windowRouter.getCurrentKey(), +const windowEl = useTemplateRef('windowEl'); +const history = ref<{ path: string; }[]>([{ + path: windowRouter.getCurrentFullPath(), }]); const buttonsLeft = computed(() => { const buttons: Record<string, unknown>[] = []; @@ -100,27 +97,27 @@ function getSearchMarker(path: string) { const searchMarkerId = ref<string | null>(getSearchMarker(props.initialPath)); windowRouter.addListener('push', ctx => { - history.value.push({ path: ctx.path, key: ctx.key }); + history.value.push({ path: ctx.fullPath }); }); windowRouter.addListener('replace', ctx => { history.value.pop(); - history.value.push({ path: ctx.path, key: ctx.key }); + history.value.push({ path: ctx.fullPath }); }); windowRouter.addListener('change', ctx => { - if (_DEV_) console.log('windowRouter: change', ctx.path); - searchMarkerId.value = getSearchMarker(ctx.path); + if (_DEV_) console.log('windowRouter: change', ctx.fullPath); + searchMarkerId.value = getSearchMarker(ctx.fullPath); analytics.page({ - path: ctx.path, - title: ctx.path, + path: ctx.fullPath, + title: ctx.fullPath, }); }); windowRouter.init(); -provide('router', windowRouter); -provide('inAppSearchMarkerId', searchMarkerId); +provide(DI.router, windowRouter); +provide(DI.inAppSearchMarkerId, searchMarkerId); provideMetadataReceiver((metadataGetter) => { const info = metadataGetter(); pageMetadata.value = info; @@ -128,7 +125,7 @@ provideMetadataReceiver((metadataGetter) => { provideReactiveMetadata(pageMetadata); provide('shouldOmitHeaderTitle', true); provide('shouldHeaderThin', true); -provide('forceSpacerMin', true); +provide(DI.forceSpacerMin, true); const contextmenu = computed(() => ([{ icon: 'ti ti-player-eject', @@ -142,20 +139,20 @@ const contextmenu = computed(() => ([{ icon: 'ti ti-external-link', text: i18n.ts.openInNewTab, action: () => { - window.open(url + windowRouter.getCurrentPath(), '_blank', 'noopener'); + window.open(url + windowRouter.getCurrentFullPath(), '_blank', 'noopener'); windowEl.value?.close(); }, }, { icon: 'ti ti-link', text: i18n.ts.copyLink, action: () => { - copyToClipboard(url + windowRouter.getCurrentPath()); + copyToClipboard(url + windowRouter.getCurrentFullPath()); }, }])); function back() { history.value.pop(); - windowRouter.replace(history.value.at(-1)!.path, history.value.at(-1)!.key); + windowRouter.replace(history.value.at(-1)!.path); } function reload() { @@ -167,17 +164,15 @@ function close() { } function expand() { - mainRouter.push(windowRouter.getCurrentPath(), 'forcePage'); + mainRouter.push(windowRouter.getCurrentFullPath(), 'forcePage'); windowEl.value?.close(); } function popout() { - _popout(windowRouter.getCurrentPath(), windowEl.value?.$el); + _popout(windowRouter.getCurrentFullPath(), windowEl.value?.$el); windowEl.value?.close(); } -useScrollPositionManager(() => getScrollContainer(contents.value), windowRouter); - onMounted(() => { analytics.page({ path: props.initialPath, @@ -201,9 +196,7 @@ defineExpose({ <style lang="scss" module> .root { - overscroll-behavior: contain; - - min-height: 100%; + height: 100%; background: var(--MI_THEME-bg); --MI-margin: var(--MI-marginHalf); diff --git a/packages/frontend/src/components/MkPagination.vue b/packages/frontend/src/components/MkPagination.vue index d9135ab517..9adc3d98da 100644 --- a/packages/frontend/src/components/MkPagination.vue +++ b/packages/frontend/src/components/MkPagination.vue @@ -5,54 +5,53 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <Transition - :enterActiveClass="defaultStore.state.animation ? $style.transition_fade_enterActive : ''" - :leaveActiveClass="defaultStore.state.animation ? $style.transition_fade_leaveActive : ''" - :enterFromClass="defaultStore.state.animation ? $style.transition_fade_enterFrom : ''" - :leaveToClass="defaultStore.state.animation ? $style.transition_fade_leaveTo : ''" + :enterActiveClass="prefer.s.animation ? $style.transition_fade_enterActive : ''" + :leaveActiveClass="prefer.s.animation ? $style.transition_fade_leaveActive : ''" + :enterFromClass="prefer.s.animation ? $style.transition_fade_enterFrom : ''" + :leaveToClass="prefer.s.animation ? $style.transition_fade_leaveTo : ''" mode="out-in" > <MkLoading v-if="fetching"/> <MkError v-else-if="error" @retry="init()"/> - <div v-else-if="empty" key="_empty_" class="empty"> + <div v-else-if="empty" key="_empty_"> <slot name="empty"> <div class="_fullinfo"> - <img :src="infoImageUrl" class="_ghost"/> + <img :src="infoImageUrl" draggable="false"/> <div>{{ i18n.ts.nothing }}</div> </div> </slot> </div> - <div v-else ref="rootEl"> - <div v-show="pagination.reversed && more" key="_more_" class="_margin"> - <MkButton v-if="!moreFetching" v-appear="(enableInfiniteScroll && !props.disableAutoLoad) ? appearFetchMoreAhead : null" :class="$style.more" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary rounded @click="fetchMoreAhead"> + <div v-else ref="rootEl" class="_gaps"> + <div v-show="pagination.reversed && more" key="_more_"> + <MkButton v-if="!moreFetching" v-appear="(enableInfiniteScroll && !props.disableAutoLoad) ? appearFetchMoreAhead : null" :class="$style.more" :wait="moreFetching" primary rounded @click="fetchMoreAhead"> {{ i18n.ts.loadMore }} </MkButton> - <MkLoading v-else class="loading"/> + <MkLoading v-else/> </div> <slot :items="Array.from(items.values())" :fetching="fetching || moreFetching"></slot> - <div v-show="!pagination.reversed && more" key="_more_" class="_margin"> - <MkButton v-if="!moreFetching" v-appear="(enableInfiniteScroll && !props.disableAutoLoad) ? appearFetchMore : null" :class="$style.more" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary rounded @click="fetchMore"> + <div v-show="!pagination.reversed && more" key="_more_"> + <MkButton v-if="!moreFetching" v-appear="(enableInfiniteScroll && !props.disableAutoLoad) ? appearFetchMore : null" :class="$style.more" :wait="moreFetching" primary rounded @click="fetchMore"> {{ i18n.ts.loadMore }} </MkButton> - <MkLoading v-else class="loading"/> + <MkLoading v-else/> </div> </div> </Transition> </template> <script lang="ts"> -import { computed, isRef, nextTick, onActivated, onBeforeMount, onBeforeUnmount, onDeactivated, ref, shallowRef, watch } from 'vue'; -import type { ComputedRef } from 'vue'; +import { computed, isRef, nextTick, onActivated, onBeforeMount, onBeforeUnmount, onDeactivated, ref, useTemplateRef, watch } from 'vue'; import * as Misskey from 'misskey-js'; import { useDocumentVisibility } from '@@/js/use-document-visibility.js'; -import { onScrollTop, isTopVisible, getBodyScrollHeight, getScrollContainer, onScrollBottom, scrollToBottom, scroll, isBottomVisible } from '@@/js/scroll.js'; -import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { defaultStore } from '@/store.js'; +import { onScrollTop, isHeadVisible, getBodyScrollHeight, getScrollContainer, onScrollBottom, scrollToBottom, scrollInContainer, isTailVisible } from '@@/js/scroll.js'; +import type { ComputedRef } from 'vue'; import type { MisskeyEntity } from '@/types/date-separated-list.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; +import { prefer } from '@/preferences.js'; const SECOND_FETCH_LIMIT = 30; const TOLERANCE = 16; @@ -75,8 +74,6 @@ export type Paging<E extends keyof Misskey.Endpoints = keyof Misskey.Endpoints> reversed?: boolean; offsetMode?: boolean; - - pageEl?: HTMLElement; }; type MisskeyEntityMap = Map<string, MisskeyEntity>; @@ -107,7 +104,7 @@ const emit = defineEmits<{ (ev: 'status', error: boolean): void; }>(); -const rootEl = shallowRef<HTMLElement>(); +const rootEl = useTemplateRef('rootEl'); // 遡り中かどうか const backed = ref(false); @@ -140,10 +137,9 @@ const empty = computed(() => items.value.size === 0); const error = ref(false); const { enableInfiniteScroll, -} = defaultStore.reactiveState; +} = prefer.r; -const contentEl = computed(() => props.pagination.pageEl ?? rootEl.value); -const scrollableElement = computed(() => contentEl.value ? getScrollContainer(contentEl.value) : document.body); +const scrollableElement = computed(() => rootEl.value ? getScrollContainer(rootEl.value) : window.document.body); const visibility = useDocumentVisibility(); @@ -174,13 +170,13 @@ watch(rootEl, () => { }); }); -watch([backed, contentEl], () => { +watch([backed, rootEl], () => { if (!backed.value) { - if (!contentEl.value) return; + if (!rootEl.value) return; scrollRemove.value = props.pagination.reversed - ? onScrollBottom(contentEl.value, executeQueue, TOLERANCE) - : onScrollTop(contentEl.value, (topVisible) => { if (topVisible) executeQueue(); }, TOLERANCE); + ? onScrollBottom(rootEl.value, executeQueue, TOLERANCE) + : onScrollTop(rootEl.value, (topVisible) => { if (topVisible) executeQueue(); }, TOLERANCE); } else { if (scrollRemove.value) scrollRemove.value(); scrollRemove.value = null; @@ -262,7 +258,7 @@ const fetchMore = async (): Promise<void> => { return nextTick(() => { if (scrollableElement.value) { - scroll(scrollableElement.value, { top: oldScroll + (scrollableElement.value.scrollHeight - oldHeight), behavior: 'instant' }); + scrollInContainer(scrollableElement.value, { top: oldScroll + (scrollableElement.value.scrollHeight - oldHeight), behavior: 'instant' }); } else { window.scroll({ top: oldScroll + (getBodyScrollHeight() - oldHeight), behavior: 'instant' }); } @@ -350,7 +346,7 @@ const appearFetchMoreAhead = async (): Promise<void> => { fetchMoreAppearTimeout(); }; -const isTop = (): boolean => isBackTop.value || (props.pagination.reversed ? isBottomVisible : isTopVisible)(contentEl.value!, TOLERANCE); +const isHead = (): boolean => isBackTop.value || (props.pagination.reversed ? isTailVisible : isHeadVisible)(rootEl.value!, TOLERANCE); watch(visibility, () => { if (visibility.value === 'hidden') { @@ -361,11 +357,11 @@ watch(visibility, () => { BACKGROUND_PAUSE_WAIT_SEC * 1000); } else { // 'visible' if (timerForSetPause) { - clearTimeout(timerForSetPause); + window.clearTimeout(timerForSetPause); timerForSetPause = null; } else { isPausingUpdate = false; - if (isTop()) { + if (isHead()) { executeQueue(); } } @@ -377,16 +373,18 @@ watch(visibility, () => { * ストリーミングから降ってきたアイテムはこれで追加する * @param item アイテム */ -const prepend = (item: MisskeyEntity): void => { +function prepend(item: MisskeyEntity): void { if (items.value.size === 0) { items.value.set(item.id, item); fetching.value = false; return; } - if (isTop() && !isPausingUpdate) unshiftItems([item]); + if (_DEV_) console.log(isHead(), isPausingUpdate); + + if (isHead() && !isPausingUpdate) unshiftItems([item]); else prependQueue(item); -}; +} /** * 新着アイテムをitemsの先頭に追加し、displayLimitを適用する @@ -448,18 +446,18 @@ onDeactivated(() => { }); function toBottom() { - scrollToBottom(contentEl.value!); + scrollToBottom(rootEl.value!); } onBeforeMount(() => { init().then(() => { if (props.pagination.reversed) { nextTick(() => { - setTimeout(toBottom, 800); + window.setTimeout(toBottom, 800); // scrollToBottomでmoreFetchingボタンが画面外まで出るまで // more = trueを遅らせる - setTimeout(() => { + window.setTimeout(() => { moreFetching.value = false; }, 2000); }); @@ -469,11 +467,11 @@ onBeforeMount(() => { onBeforeUnmount(() => { if (timerForSetPause) { - clearTimeout(timerForSetPause); + window.clearTimeout(timerForSetPause); timerForSetPause = null; } if (preventAppearFetchMoreTimer.value) { - clearTimeout(preventAppearFetchMoreTimer.value); + window.clearTimeout(preventAppearFetchMoreTimer.value); preventAppearFetchMoreTimer.value = null; } scrollObserver.value?.disconnect(); diff --git a/packages/frontend/src/components/MkPasswordDialog.vue b/packages/frontend/src/components/MkPasswordDialog.vue index e749725fea..2abf8669ed 100644 --- a/packages/frontend/src/components/MkPasswordDialog.vue +++ b/packages/frontend/src/components/MkPasswordDialog.vue @@ -15,7 +15,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkSpacer :marginMin="20" :marginMax="28"> <div style="padding: 0 0 16px 0; text-align: center;"> - <img src="/fluent-emoji/1f510.png" alt="🔐" style="display: block; margin: 0 auto; width: 48px;"> + <img src="/client-assets/locked_with_key_3d.png" alt="🔐" style="display: block; margin: 0 auto; width: 48px;"> <div style="margin-top: 16px;">{{ i18n.ts.authenticationRequiredToContinue }}</div> </div> @@ -39,14 +39,14 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted, shallowRef, ref } from 'vue'; +import { onMounted, useTemplateRef, ref } from 'vue'; import MkInput from '@/components/MkInput.vue'; import MkButton from '@/components/MkButton.vue'; import MkModalWindow from '@/components/MkModalWindow.vue'; import { i18n } from '@/i18n.js'; -import { signinRequired } from '@/account.js'; +import { ensureSignin } from '@/i.js'; -const $i = signinRequired(); +const $i = ensureSignin(); const emit = defineEmits<{ (ev: 'done', v: { password: string; token: string | null; }): void; @@ -54,8 +54,8 @@ const emit = defineEmits<{ (ev: 'cancelled'): void; }>(); -const dialog = shallowRef<InstanceType<typeof MkModalWindow>>(); -const passwordInput = shallowRef<InstanceType<typeof MkInput>>(); +const dialog = useTemplateRef('dialog'); +const passwordInput = useTemplateRef('passwordInput'); const password = ref(''); const isBackupCode = ref(false); const token = ref<string | null>(null); diff --git a/packages/frontend/src/components/MkPolkadots.vue b/packages/frontend/src/components/MkPolkadots.vue new file mode 100644 index 0000000000..285c4d0b79 --- /dev/null +++ b/packages/frontend/src/components/MkPolkadots.vue @@ -0,0 +1,40 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div :class="[$style.root, accented ? $style.accented : null]"></div> +</template> + +<script lang="ts" setup> +const props = withDefaults(defineProps<{ + accented?: boolean; +}>(), { + accented: false, +}); +</script> + +<style lang="scss" module> +.root { + --c: var(--MI_THEME-divider); + + &.accented { + --c: var(--MI_THEME-accent); + opacity: 0.5; + } + + --dot-size: 2px; + --gap-size: 40px; + --offset: calc(var(--gap-size) / 2); + + height: 200px; + margin-bottom: -200px; + + background-image: linear-gradient(transparent 60%, transparent 100%), radial-gradient(var(--c) var(--dot-size), transparent var(--dot-size)), radial-gradient(var(--c) var(--dot-size), transparent var(--dot-size)); + background-position: 0 0, 0 0, var(--offset) var(--offset); + background-size: 100% 100%, var(--gap-size) var(--gap-size), var(--gap-size) var(--gap-size); + mask-image: linear-gradient(to bottom, black 0%, transparent 100%); + pointer-events: none; +} +</style> diff --git a/packages/frontend/src/components/MkPoll.vue b/packages/frontend/src/components/MkPoll.vue index 1b2b3e48ba..2d3ec45bca 100644 --- a/packages/frontend/src/components/MkPoll.vue +++ b/packages/frontend/src/components/MkPoll.vue @@ -31,11 +31,11 @@ import { computed, ref } from 'vue'; import * as Misskey from 'misskey-js'; import { host } from '@@/js/config.js'; import { useInterval } from '@@/js/use-interval.js'; -import type { OpenOnRemoteOptions } from '@/scripts/please-login.js'; -import { sum } from '@/scripts/array.js'; -import { pleaseLogin } from '@/scripts/please-login.js'; +import type { OpenOnRemoteOptions } from '@/utility/please-login.js'; +import { sum } from '@/utility/array.js'; +import { pleaseLogin } from '@/utility/please-login.js'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; const props = defineProps<{ diff --git a/packages/frontend/src/components/MkPollEditor.vue b/packages/frontend/src/components/MkPollEditor.vue index 3726ddf822..22fe189a63 100644 --- a/packages/frontend/src/components/MkPollEditor.vue +++ b/packages/frontend/src/components/MkPollEditor.vue @@ -58,8 +58,8 @@ import MkInput from './MkInput.vue'; import MkSelect from './MkSelect.vue'; import MkSwitch from './MkSwitch.vue'; import MkButton from './MkButton.vue'; -import { formatDateTimeString } from '@/scripts/format-time-string.js'; -import { addTime } from '@/scripts/time.js'; +import { formatDateTimeString } from '@/utility/format-time-string.js'; +import { addTime } from '@/utility/time.js'; import { i18n } from '@/i18n.js'; export type PollEditorModelValue = { diff --git a/packages/frontend/src/components/MkPopupMenu.vue b/packages/frontend/src/components/MkPopupMenu.vue index df664e49f7..232cc005e1 100644 --- a/packages/frontend/src/components/MkPopupMenu.vue +++ b/packages/frontend/src/components/MkPopupMenu.vue @@ -10,7 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { ref, shallowRef } from 'vue'; +import { ref, useTemplateRef } from 'vue'; import MkModal from './MkModal.vue'; import MkMenu from './MkMenu.vue'; import type { MenuItem } from '@/types/menu.js'; @@ -28,7 +28,7 @@ const emit = defineEmits<{ (ev: 'closing'): void; }>(); -const modal = shallowRef<InstanceType<typeof MkModal>>(); +const modal = useTemplateRef('modal'); const manualShowing = ref(true); const hiding = ref(false); diff --git a/packages/frontend/src/components/MkPostForm.TextCounter.vue b/packages/frontend/src/components/MkPostForm.TextCounter.vue new file mode 100644 index 0000000000..b1d39df5d3 --- /dev/null +++ b/packages/frontend/src/components/MkPostForm.TextCounter.vue @@ -0,0 +1,95 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div :class="[$style.textCountRoot]"> + <div :class="$style.textCountLabel">{{ i18n.ts.textCount }}</div> + <div + :class="[$style.textCount, + { [$style.danger]: textCountPercentage > 100 }, + { [$style.warning]: textCountPercentage > 90 && textCountPercentage <= 100 }, + ]" + > + <div :class="$style.textCountGraph"></div> + <div><span :class="$style.textCountCurrent">{{ number(textLength) }}</span> / {{ number(maxTextLength) }}</div> + </div> +</div> +</template> + +<script lang="ts" setup> +import { computed, useTemplateRef } from 'vue'; +import { instance } from '@/instance.js'; +import { i18n } from '@/i18n.js'; +import number from '@/filters/number.js'; + +const props = defineProps<{ + textLength: number; +}>(); + +const maxTextLength = computed(() => { + return instance ? instance.maxNoteTextLength : 1000; +}); + +const textCountPercentage = computed(() => { + return props.textLength / maxTextLength.value * 100; +}); +</script> + +<style lang="scss" module> +.textCountRoot { + padding: 4px 14px; +} + +.textCountLabel { + font-size: 11px; + opacity: 0.8; + margin-bottom: 4px; +} + +.textCount { + display: flex; + gap: var(--MI-marginHalf); + align-items: center; + font-size: 12px; + --countColor: var(--MI_THEME-accent); + + &.danger { + --countColor: var(--MI_THEME-error); + } + + &.warning { + --countColor: var(--MI_THEME-warn); + } + + .textCountGraph { + position: relative; + width: 24px; + height: 24px; + border-radius: 50%; + background-image: conic-gradient( + var(--countColor) 0% v-bind("Math.min(100, textCountPercentage) + '%'"), + rgba(0, 0, 0, .2) v-bind("Math.min(100, textCountPercentage) + '%'") 100% + ); + + &::after { + content: ''; + position: absolute; + width: 16px; + height: 16px; + border-radius: 50%; + background-color: var(--MI_THEME-popup); + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + } + } + + .textCountCurrent { + color: var(--countColor); + font-weight: 700; + font-size: 18px; + } +} +</style> diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue index b39a4ad708..e43ff65e1d 100644 --- a/packages/frontend/src/components/MkPostForm.vue +++ b/packages/frontend/src/components/MkPostForm.vue @@ -20,7 +20,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> <div :class="$style.headerRight"> <template v-if="!(channel != null && fixed)"> - <button v-if="channel == null" ref="visibilityButton" v-click-anime v-tooltip="i18n.ts.visibility" :class="['_button', $style.headerRightItem, $style.visibility]" @click="setVisibility"> + <button v-if="channel == null" ref="visibilityButton" v-tooltip="i18n.ts.visibility" :class="['_button', $style.headerRightItem, $style.visibility]" @click="setVisibility"> <span v-if="visibility === 'public'"><i class="ti ti-world"></i></span> <span v-if="visibility === 'home'"><i class="ti ti-home"></i></span> <span v-if="visibility === 'followers'"><i class="ti ti-lock"></i></span> @@ -32,15 +32,11 @@ SPDX-License-Identifier: AGPL-3.0-only <span :class="$style.headerRightButtonText">{{ channel.name }}</span> </button> </template> - <button v-click-anime v-tooltip="i18n.ts._visibility.disableFederation" class="_button" :class="[$style.headerRightItem, { [$style.danger]: localOnly }]" :disabled="channel != null || visibility === 'specified'" @click="toggleLocalOnly"> + <button v-tooltip="i18n.ts._visibility.disableFederation" class="_button" :class="[$style.headerRightItem, { [$style.danger]: localOnly }]" :disabled="channel != null || visibility === 'specified'" @click="toggleLocalOnly"> <span v-if="!localOnly"><i class="ti ti-rocket"></i></span> <span v-else><i class="ti ti-rocket-off"></i></span> </button> - <button v-click-anime v-tooltip="i18n.ts.reactionAcceptance" class="_button" :class="[$style.headerRightItem, { [$style.danger]: reactionAcceptance === 'likeOnly' }]" @click="toggleReactionAcceptance"> - <span v-if="reactionAcceptance === 'likeOnly'"><i class="ti ti-heart"></i></span> - <span v-else-if="reactionAcceptance === 'likeOnlyForRemote'"><i class="ti ti-heart-plus"></i></span> - <span v-else><i class="ti ti-icons"></i></span> - </button> + <button ref="otherSettingsButton" v-tooltip="i18n.ts.other" class="_button" :class="$style.headerRightItem" @click="showOtherSettings"><i class="ti ti-dots"></i></button> <button v-click-anime class="_button" :class="$style.submit" :disabled="!canPost" data-cy-open-post-form-submit @click="post"> <div :class="$style.submitInner"> <template v-if="posted"></template> @@ -67,7 +63,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkInfo v-if="hasNotSpecifiedMentions" warn :class="$style.hasNotSpecifiedMentions">{{ i18n.ts.notSpecifiedMentionWarning }} - <button class="_textButton" @click="addMissingMention()">{{ i18n.ts.add }}</button></MkInfo> <div v-show="useCw" :class="$style.cwOuter"> <input ref="cwInputEl" v-model="cw" :class="$style.cw" :placeholder="i18n.ts.annotation" @keydown="onKeydown" @keyup="onKeyup" @compositionend="onCompositionEnd"> - <div v-if="maxCwTextLength - cwTextLength < 20" :class="['_acrylic', $style.cwTextCount, { [$style.cwTextOver]: cwTextLength > maxCwTextLength }]">{{ maxCwTextLength - cwTextLength }}</div> + <div v-if="maxCwTextLength - cwTextLength < 20" :class="['_acrylic', $style.cwTextCount, { [$style.cwTextOver]: cwTextLength > maxCwTextLength }]">{{ maxCwTextLength - cwTextLength }}</div> </div> <div :class="[$style.textOuter, { [$style.withCw]: useCw }]"> <div v-if="channel" :class="$style.colorBar" :style="{ background: channel.color }"></div> @@ -103,42 +99,48 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { inject, watch, nextTick, onMounted, defineAsyncComponent, provide, shallowRef, ref, computed } from 'vue'; -import type { ShallowRef } from 'vue'; +import { inject, watch, nextTick, onMounted, defineAsyncComponent, provide, shallowRef, ref, computed, useTemplateRef } from 'vue'; import * as mfm from 'mfm-js'; import * as Misskey from 'misskey-js'; import insertTextAtCursor from 'insert-text-at-cursor'; import { toASCII } from 'punycode.js'; import { host, url } from '@@/js/config.js'; +import type { ShallowRef } from 'vue'; import type { PostFormProps } from '@/types/post-form.js'; -import MkNoteSimple from '@/components/MkNoteSimple.vue'; +import type { MenuItem } from '@/types/menu.js'; +import type { PollEditorModelValue } from '@/components/MkPollEditor.vue'; import MkNotePreview from '@/components/MkNotePreview.vue'; import XPostFormAttaches from '@/components/MkPostFormAttaches.vue'; +import XTextCounter from '@/components/MkPostForm.TextCounter.vue'; import MkPollEditor from '@/components/MkPollEditor.vue'; -import type { PollEditorModelValue } from '@/components/MkPollEditor.vue'; -import { erase, unique } from '@/scripts/array.js'; -import { extractMentions } from '@/scripts/extract-mentions.js'; -import { formatTimeString } from '@/scripts/format-time-string.js'; -import { Autocomplete } from '@/scripts/autocomplete.js'; +import MkNoteSimple from '@/components/MkNoteSimple.vue'; +import { erase, unique } from '@/utility/array.js'; +import { extractMentions } from '@/utility/extract-mentions.js'; +import { formatTimeString } from '@/utility/format-time-string.js'; +import { Autocomplete } from '@/utility/autocomplete.js'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { selectFiles } from '@/scripts/select-file.js'; -import { defaultStore, notePostInterruptors, postFormActions } from '@/store.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { selectFiles } from '@/utility/select-file.js'; +import { store } from '@/store.js'; import MkInfo from '@/components/MkInfo.vue'; import { i18n } from '@/i18n.js'; import { instance } from '@/instance.js'; -import { signinRequired, notesCount, incNotesCount, getAccounts, openAccountMenu as openAccountMenu_ } from '@/account.js'; -import { uploadFile } from '@/scripts/upload.js'; -import { deepClone } from '@/scripts/clone.js'; +import { ensureSignin, notesCount, incNotesCount } from '@/i.js'; +import { getAccounts, openAccountMenu as openAccountMenu_ } from '@/accounts.js'; +import { uploadFile } from '@/utility/upload.js'; +import { deepClone } from '@/utility/clone.js'; import MkRippleEffect from '@/components/MkRippleEffect.vue'; import { miLocalStorage } from '@/local-storage.js'; -import { claimAchievement } from '@/scripts/achievements.js'; -import { emojiPicker } from '@/scripts/emoji-picker.js'; -import { mfmFunctionPicker } from '@/scripts/mfm-function-picker.js'; +import { claimAchievement } from '@/utility/achievements.js'; +import { emojiPicker } from '@/utility/emoji-picker.js'; +import { mfmFunctionPicker } from '@/utility/mfm-function-picker.js'; +import { prefer } from '@/preferences.js'; +import { getPluginHandlers } from '@/plugin.js'; +import { DI } from '@/di.js'; -const $i = signinRequired(); +const $i = ensureSignin(); -const modal = inject('modal'); +const modal = inject(DI.inModal, false); const props = withDefaults(defineProps<PostFormProps & { fixed?: boolean; @@ -152,7 +154,7 @@ const props = withDefaults(defineProps<PostFormProps & { initialLocalOnly: undefined, }); -provide('mock', props.mock); +provide(DI.mock, props.mock); const emit = defineEmits<{ (ev: 'posted'): void; @@ -163,10 +165,11 @@ const emit = defineEmits<{ (ev: 'fileChangeSensitive', fileId: string, to: boolean): void; }>(); -const textareaEl = shallowRef<HTMLTextAreaElement | null>(null); -const cwInputEl = shallowRef<HTMLInputElement | null>(null); -const hashtagsInputEl = shallowRef<HTMLInputElement | null>(null); -const visibilityButton = shallowRef<HTMLElement>(); +const textareaEl = useTemplateRef('textareaEl'); +const cwInputEl = useTemplateRef('cwInputEl'); +const hashtagsInputEl = useTemplateRef('hashtagsInputEl'); +const visibilityButton = useTemplateRef('visibilityButton'); +const otherSettingsButton = useTemplateRef('otherSettingsButton'); const posting = ref(false); const posted = ref(false); @@ -174,19 +177,18 @@ const text = ref(props.initialText ?? ''); const files = ref(props.initialFiles ?? []); const poll = ref<PollEditorModelValue | null>(null); const useCw = ref<boolean>(!!props.initialCw); -const showPreview = ref(defaultStore.state.showPreview); -watch(showPreview, () => defaultStore.set('showPreview', showPreview.value)); -const showAddMfmFunction = ref(defaultStore.state.enableQuickAddMfmFunction); -watch(showAddMfmFunction, () => defaultStore.set('enableQuickAddMfmFunction', showAddMfmFunction.value)); +const showPreview = ref(store.s.showPreview); +watch(showPreview, () => store.set('showPreview', showPreview.value)); +const showAddMfmFunction = ref(prefer.s.enableQuickAddMfmFunction); +watch(showAddMfmFunction, () => prefer.commit('enableQuickAddMfmFunction', showAddMfmFunction.value)); const cw = ref<string | null>(props.initialCw ?? null); -const localOnly = ref(props.initialLocalOnly ?? (defaultStore.state.rememberNoteVisibility ? defaultStore.state.localOnly : defaultStore.state.defaultNoteLocalOnly)); -const visibility = ref(props.initialVisibility ?? (defaultStore.state.rememberNoteVisibility ? defaultStore.state.visibility : defaultStore.state.defaultNoteVisibility)); +const localOnly = ref(props.initialLocalOnly ?? (prefer.s.rememberNoteVisibility ? store.s.localOnly : prefer.s.defaultNoteLocalOnly)); +const visibility = ref(props.initialVisibility ?? (prefer.s.rememberNoteVisibility ? store.s.visibility : prefer.s.defaultNoteVisibility)); const visibleUsers = ref<Misskey.entities.UserDetailed[]>([]); if (props.initialVisibleUsers) { props.initialVisibleUsers.forEach(u => pushVisibleUser(u)); } -const reactionAcceptance = ref(defaultStore.state.reactionAcceptance); -const autocomplete = ref(null); +const reactionAcceptance = ref(store.s.reactionAcceptance); const draghover = ref(false); const quoteId = ref<string | null>(null); const hasNotSpecifiedMentions = ref(false); @@ -196,6 +198,7 @@ const showingOptions = ref(false); const textAreaReadOnly = ref(false); const justEndedComposition = ref(false); const renoteTargetNote: ShallowRef<PostFormProps['renote'] | null> = shallowRef(props.renote); +const postFormActions = getPluginHandlers('post_form_action'); const draftKey = computed((): string => { let key = props.channel ? `channel:${props.channel.id}` : ''; @@ -263,13 +266,19 @@ const canPost = computed((): boolean => { quoteId.value != null ) && (textLength.value <= maxTextLength.value) && - (cwTextLength.value <= maxCwTextLength) && + ( + useCw.value ? + ( + cw.value != null && cw.value.trim() !== '' && + cwTextLength.value <= maxCwTextLength + ) : true + ) && (files.value.length <= 16) && (!poll.value || poll.value.choices.length >= 2); }); -const withHashtags = computed(defaultStore.makeGetterSetter('postFormWithHashtags')); -const hashtags = computed(defaultStore.makeGetterSetter('postFormHashtags')); +const withHashtags = computed(store.makeGetterSetter('postFormWithHashtags')); +const hashtags = computed(store.makeGetterSetter('postFormHashtags')); watch(text, () => { checkMissingMention(); @@ -357,7 +366,7 @@ if (props.specified) { } // keep cw when reply -if (defaultStore.state.keepCw && props.reply && props.reply.cw) { +if (prefer.s.keepCw && props.reply && props.reply.cw) { useCw.value = true; cw.value = props.reply.cw; } @@ -456,7 +465,7 @@ function replaceFile(file: Misskey.entities.DriveFile, newFile: Misskey.entities function upload(file: File, name?: string): void { if (props.mock) return; - uploadFile(file, defaultStore.state.uploadFolder, name).then(res => { + uploadFile(file, prefer.s.uploadFolder, name).then(res => { files.value.push(res); }); } @@ -477,8 +486,8 @@ function setVisibility() { }, { changeVisibility: v => { visibility.value = v; - if (defaultStore.state.rememberNoteVisibility) { - defaultStore.set('visibility', visibility.value); + if (prefer.s.rememberNoteVisibility) { + store.set('visibility', visibility.value); } }, closed: () => dispose(), @@ -525,8 +534,8 @@ async function toggleLocalOnly() { } localOnly.value = !localOnly.value; - if (defaultStore.state.rememberNoteVisibility) { - defaultStore.set('localOnly', localOnly.value); + if (prefer.s.rememberNoteVisibility) { + store.set('localOnly', localOnly.value); } } @@ -546,6 +555,47 @@ async function toggleReactionAcceptance() { reactionAcceptance.value = select.result; } +//#region その他の設定メニューpopup +function showOtherSettings() { + let reactionAcceptanceIcon = 'ti ti-icons'; + + if (reactionAcceptance.value === 'likeOnly') { + reactionAcceptanceIcon = 'ti ti-heart _love'; + } else if (reactionAcceptance.value === 'likeOnlyForRemote') { + reactionAcceptanceIcon = 'ti ti-heart-plus'; + } + + const menuItems = [{ + type: 'component', + component: XTextCounter, + props: { + textLength: textLength, + }, + }, { type: 'divider' }, { + icon: reactionAcceptanceIcon, + text: i18n.ts.reactionAcceptance, + action: () => { + toggleReactionAcceptance(); + }, + }, { type: 'divider' }, { + icon: 'ti ti-trash', + text: i18n.ts.reset, + danger: true, + action: async () => { + if (props.mock) return; + const { canceled } = await os.confirm({ + type: 'question', + text: i18n.ts.resetAreYouSure, + }); + if (canceled) return; + clear(); + }, + }] satisfies MenuItem[]; + + os.popupMenu(menuItems, otherSettingsButton.value); +} +//#endregion + function pushVisibleUser(user: Misskey.entities.UserDetailed) { if (!visibleUsers.value.some(u => u.username === user.username && u.host === user.host)) { visibleUsers.value.push(user); @@ -594,6 +644,8 @@ function onCompositionEnd(ev: CompositionEvent) { justEndedComposition.value = true; } +const pastedFileName = 'yyyy-MM-dd HH-mm-ss [{{number}}]'; + async function onPaste(ev: ClipboardEvent) { if (props.mock) return; if (!ev.clipboardData) return; @@ -604,7 +656,7 @@ async function onPaste(ev: ClipboardEvent) { if (!file) continue; const lio = file.name.lastIndexOf('.'); const ext = lio >= 0 ? file.name.slice(lio) : ''; - const formatted = `${formatTimeString(new Date(file.lastModified), defaultStore.state.pastedFileName).replace(/{{number}}/g, `${i + 1}`)}${ext}`; + const formatted = `${formatTimeString(new Date(file.lastModified), pastedFileName).replace(/{{number}}/g, `${i + 1}`)}${ext}`; upload(file, formatted); } } @@ -638,7 +690,7 @@ async function onPaste(ev: ClipboardEvent) { return; } - const fileName = formatTimeString(new Date(), defaultStore.state.pastedFileName).replace(/{{number}}/g, '0'); + const fileName = formatTimeString(new Date(), pastedFileName).replace(/{{number}}/g, '0'); const file = new File([paste], `${fileName}.txt`, { type: 'text/plain' }); upload(file, `${fileName}.txt`); }); @@ -740,18 +792,10 @@ function isAnnoying(text: string): boolean { } async function post(ev?: MouseEvent) { - if (useCw.value && (cw.value == null || cw.value.trim() === '')) { - os.alert({ - type: 'error', - text: i18n.ts.cwNotationRequired, - }); - return; - } - if (ev) { const el = (ev.currentTarget ?? ev.target) as HTMLElement | null; - if (el && defaultStore.state.animation) { + if (el && prefer.s.animation) { const rect = el.getBoundingClientRect(); const x = rect.left + (el.offsetWidth / 2); const y = rect.top + (el.offsetHeight / 2); @@ -820,6 +864,7 @@ async function post(ev?: MouseEvent) { } // plugin + const notePostInterruptors = getPluginHandlers('note_post_interruptor'); if (notePostInterruptors.length > 0) { for (const interruptor of notePostInterruptors) { try { @@ -834,7 +879,7 @@ async function post(ev?: MouseEvent) { if (postAccount.value) { const storedAccounts = await getAccounts(); - token = storedAccounts.find(x => x.id === postAccount.value?.id)?.token; + token = storedAccounts.find(x => x.user.id === postAccount.value?.id)?.token; } posting.value = true; @@ -1269,7 +1314,7 @@ html[data-color-scheme=light] .preview { padding: 0 24px; margin: 0; width: 100%; - font-size: 16px; + font-size: 110%; border: none; border-radius: 0; background: transparent; diff --git a/packages/frontend/src/components/MkPostFormAttaches.vue b/packages/frontend/src/components/MkPostFormAttaches.vue index c7774d50b2..e8404cbd4f 100644 --- a/packages/frontend/src/components/MkPostFormAttaches.vue +++ b/packages/frontend/src/components/MkPostFormAttaches.vue @@ -36,12 +36,13 @@ SPDX-License-Identifier: AGPL-3.0-only import { defineAsyncComponent, inject } from 'vue'; import * as Misskey from 'misskey-js'; import type { MenuItem } from '@/types/menu'; -import { defaultStore } from '@/store'; -import { copyToClipboard } from '@/scripts/copy-to-clipboard'; +import { copyToClipboard } from '@/utility/copy-to-clipboard'; import MkDriveFileThumbnail from '@/components/MkDriveFileThumbnail.vue'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; +import { prefer } from '@/preferences.js'; +import { DI } from '@/di.js'; const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default)); @@ -50,7 +51,7 @@ const props = defineProps<{ detachMediaFn?: (id: string) => void; }>(); -const mock = inject<boolean>('mock', false); +const mock = inject(DI.mock, false); const emit = defineEmits<{ (ev: 'update:modelValue', value: Misskey.entities.DriveFile[]): void; @@ -198,9 +199,9 @@ function showFileMenu(file: Misskey.entities.DriveFile, ev: MouseEvent | Keyboar action: () => { detachAndDeleteMedia(file); }, }); - if (defaultStore.state.devMode) { + if (prefer.s.devMode) { menuItems.push({ type: 'divider' }, { - icon: 'ti ti-id', + icon: 'ti ti-hash', text: i18n.ts.copyFileId, action: () => { copyToClipboard(file.id); diff --git a/packages/frontend/src/components/MkPostFormDialog.vue b/packages/frontend/src/components/MkPostFormDialog.vue index 4bef5824fe..c467e29df6 100644 --- a/packages/frontend/src/components/MkPostFormDialog.vue +++ b/packages/frontend/src/components/MkPostFormDialog.vue @@ -25,10 +25,10 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { shallowRef } from 'vue'; +import { useTemplateRef } from 'vue'; +import type { PostFormProps } from '@/types/post-form.js'; import MkModal from '@/components/MkModal.vue'; import MkPostForm from '@/components/MkPostForm.vue'; -import type { PostFormProps } from '@/types/post-form.js'; const props = withDefaults(defineProps<PostFormProps & { instant?: boolean; @@ -42,8 +42,7 @@ const emit = defineEmits<{ (ev: 'closed'): void; }>(); -const modal = shallowRef<InstanceType<typeof MkModal>>(); -const form = shallowRef<InstanceType<typeof MkPostForm>>(); +const modal = useTemplateRef('modal'); function onPosted() { modal.value?.close({ diff --git a/packages/frontend/src/components/MkPreferenceContainer.vue b/packages/frontend/src/components/MkPreferenceContainer.vue new file mode 100644 index 0000000000..70b111513c --- /dev/null +++ b/packages/frontend/src/components/MkPreferenceContainer.vue @@ -0,0 +1,103 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div :class="$style.root" @contextmenu.prevent.stop="showMenu($event, true)"> + <div :class="$style.body"> + <slot></slot> + </div> + <div :class="$style.menu"> + <i v-if="isSyncEnabled" class="ti ti-cloud-cog" style="color: var(--MI_THEME-accent); opacity: 0.7;"></i> + <i v-if="isAccountOverrided" class="ti ti-user-cog" style="color: var(--MI_THEME-accent); opacity: 0.7;"></i> + <div :class="$style.buttons"> + <button class="_button" style="color: var(--MI_THEME-fg)" @click="showMenu($event)"><i class="ti ti-dots"></i></button> + </div> + </div> +</div> +</template> + +<script lang="ts" setup> +import { ref } from 'vue'; +import type { PREF_DEF } from '@/preferences/def.js'; +import * as os from '@/os.js'; +import { prefer } from '@/preferences.js'; + +const props = withDefaults(defineProps<{ + k: keyof typeof PREF_DEF; +}>(), { +}); + +const isAccountOverrided = ref(prefer.isAccountOverrided(props.k)); +const isSyncEnabled = ref(prefer.isSyncEnabled(props.k)); + +function showMenu(ev: MouseEvent, contextmenu?: boolean) { + const i = window.setInterval(() => { + isAccountOverrided.value = prefer.isAccountOverrided(props.k); + isSyncEnabled.value = prefer.isSyncEnabled(props.k); + }, 100); + if (contextmenu) { + os.contextMenu(prefer.getPerPrefMenu(props.k), ev).then(() => { + window.clearInterval(i); + }); + } else { + os.popupMenu(prefer.getPerPrefMenu(props.k), ev.currentTarget ?? ev.target, { + onClosing: () => { + window.clearInterval(i); + }, + }); + } +} +</script> + +<style lang="scss" module> +.root { + position: relative; + display: flex; + + &:hover { + &::before { + content: ''; + position: absolute; + top: -8px; + left: -8px; + width: calc(100% + 16px); + height: calc(100% + 16px); + border-radius: 8px; + background: light-dark(rgba(0, 0, 0, 0.02), rgba(255, 255, 255, 0.02)); + pointer-events: none; + } + + .menu { + .buttons { + opacity: 0.7; + } + } + } + + .body { + flex: 1; + } + + .menu { + display: flex; + gap: 8px; + align-items: center; + margin-left: 12px; + font-size: 12px; + padding-left: 8px; + border-left: solid 1px var(--MI_THEME-divider); + + &:hover { + .buttons { + opacity: 1; + } + } + + .buttons { + opacity: 0.3; + } + } +} +</style> diff --git a/packages/frontend/src/components/MkPreview.vue b/packages/frontend/src/components/MkPreview.vue index 6efd99d14b..d8dfbd1655 100644 --- a/packages/frontend/src/components/MkPreview.vue +++ b/packages/frontend/src/components/MkPreview.vue @@ -43,7 +43,7 @@ import MkTextarea from '@/components/MkTextarea.vue'; import MkRadio from '@/components/MkRadio.vue'; import * as os from '@/os.js'; import * as config from '@@/js/config.js'; -import { $i } from '@/account.js'; +import { $i } from '@/i.js'; const text = ref(''); const flag = ref(true); diff --git a/packages/frontend/src/components/MkPullToRefresh.vue b/packages/frontend/src/components/MkPullToRefresh.vue index 4fb4c6fe56..22ae563d13 100644 --- a/packages/frontend/src/components/MkPullToRefresh.vue +++ b/packages/frontend/src/components/MkPullToRefresh.vue @@ -16,17 +16,16 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </div> </div> - <div :class="{ [$style.slotClip]: isPullStart }"> - <slot/> - </div> + + <slot/> </div> </template> <script lang="ts" setup> -import { onMounted, onUnmounted, ref, shallowRef } from 'vue'; -import { i18n } from '@/i18n.js'; +import { onMounted, onUnmounted, ref, useTemplateRef } from 'vue'; import { getScrollContainer } from '@@/js/scroll.js'; -import { isHorizontalSwipeSwiping } from '@/scripts/touch.js'; +import { i18n } from '@/i18n.js'; +import { isHorizontalSwipeSwiping } from '@/utility/touch.js'; const SCROLL_STOP = 10; const MAX_PULL_DISTANCE = Infinity; @@ -43,7 +42,7 @@ const pullDistance = ref(0); let supportPointerDesktop = false; let startScreenY: number | null = null; -const rootEl = shallowRef<HTMLDivElement>(); +const rootEl = useTemplateRef('rootEl'); let scrollEl: HTMLElement | null = null; let disabled = false; @@ -82,11 +81,11 @@ function moveBySystem(to: number): Promise<void> { return; } const startTime = Date.now(); - let intervalId = setInterval(() => { + let intervalId = window.setInterval(() => { const time = Date.now() - startTime; if (time > RELEASE_TRANSITION_DURATION) { pullDistance.value = to; - clearInterval(intervalId); + window.clearInterval(intervalId); r(); return; } @@ -261,8 +260,4 @@ defineExpose({ margin: 5px 0; } } - -.slotClip { - overflow-y: clip; -} </style> diff --git a/packages/frontend/src/components/MkPushNotificationAllowButton.vue b/packages/frontend/src/components/MkPushNotificationAllowButton.vue index 5e42df4795..9c37eb5e72 100644 --- a/packages/frontend/src/components/MkPushNotificationAllowButton.vue +++ b/packages/frontend/src/components/MkPushNotificationAllowButton.vue @@ -42,12 +42,13 @@ SPDX-License-Identifier: AGPL-3.0-only <script setup lang="ts"> import { ref } from 'vue'; -import { $i, getAccounts } from '@/account.js'; +import { $i } from '@/i.js'; import MkButton from '@/components/MkButton.vue'; import { instance } from '@/instance.js'; import { apiWithDialog, promiseDialog } from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; +import { getAccounts } from '@/accounts.js'; defineProps<{ primary?: boolean; diff --git a/packages/frontend/src/components/MkRadios.vue b/packages/frontend/src/components/MkRadios.vue index 559399d1d4..884890bf70 100644 --- a/packages/frontend/src/components/MkRadios.vue +++ b/packages/frontend/src/components/MkRadios.vue @@ -78,7 +78,7 @@ export default defineComponent({ > .caption { font-size: 0.85em; padding: 8px 0 0 0; - color: var(--MI_THEME-fgTransparentWeak); + color: color(from var(--MI_THEME-fg) srgb r g b / 0.75); &:empty { display: none; diff --git a/packages/frontend/src/components/MkRange.vue b/packages/frontend/src/components/MkRange.vue index 264b559222..4b2e6910db 100644 --- a/packages/frontend/src/components/MkRange.vue +++ b/packages/frontend/src/components/MkRange.vue @@ -33,8 +33,8 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { computed, defineAsyncComponent, onMounted, onUnmounted, ref, shallowRef, watch } from 'vue'; -import { isTouchUsing } from '@/scripts/touch.js'; +import { computed, defineAsyncComponent, onMounted, onUnmounted, ref, useTemplateRef, watch } from 'vue'; +import { isTouchUsing } from '@/utility/touch.js'; import * as os from '@/os.js'; const props = withDefaults(defineProps<{ @@ -58,8 +58,8 @@ const emit = defineEmits<{ (ev: 'dragEnded', value: number): void; }>(); -const containerEl = shallowRef<HTMLElement>(); -const thumbEl = shallowRef<HTMLElement>(); +const containerEl = useTemplateRef('containerEl'); +const thumbEl = useTemplateRef('thumbEl'); const rawValue = ref((props.modelValue - props.min) / (props.max - props.min)); const steppedRawValue = computed(() => { @@ -151,20 +151,21 @@ function onMousedown(ev: MouseEvent | TouchEvent) { closed: () => dispose(), }); - const style = document.createElement('style'); - style.appendChild(document.createTextNode('* { cursor: grabbing !important; } body * { pointer-events: none !important; }')); - document.head.appendChild(style); + const style = window.document.createElement('style'); + style.appendChild(window.document.createTextNode('* { cursor: grabbing !important; } body * { pointer-events: none !important; }')); + window.document.head.appendChild(style); const thumbWidth = getThumbWidth(); const onDrag = (ev: MouseEvent | TouchEvent) => { ev.preventDefault(); + let beforeValue = finalValue.value; const containerRect = containerEl.value!.getBoundingClientRect(); const pointerX = 'touches' in ev && ev.touches.length > 0 ? ev.touches[0].clientX : 'clientX' in ev ? ev.clientX : 0; const pointerPositionOnContainer = pointerX - (containerRect.left + (thumbWidth / 2)); rawValue.value = Math.min(1, Math.max(0, pointerPositionOnContainer / (containerEl.value!.offsetWidth - thumbWidth))); - if (props.continuousUpdate) { + if (props.continuousUpdate && beforeValue !== finalValue.value) { emit('update:modelValue', finalValue.value); } }; @@ -172,7 +173,7 @@ function onMousedown(ev: MouseEvent | TouchEvent) { let beforeValue = finalValue.value; const onMouseup = () => { - document.head.removeChild(style); + window.document.head.removeChild(style); tooltipForDragShowing.value = false; window.removeEventListener('mousemove', onDrag); window.removeEventListener('touchmove', onDrag); @@ -212,7 +213,7 @@ function onMousedown(ev: MouseEvent | TouchEvent) { > .caption { font-size: 0.85em; padding: 8px 0 0 0; - color: var(--MI_THEME-fgTransparentWeak); + color: color(from var(--MI_THEME-fg) srgb r g b / 0.75); &:empty { display: none; @@ -286,7 +287,7 @@ function onMousedown(ev: MouseEvent | TouchEvent) { border-radius: 999px; &:hover { - background: var(--MI_THEME-accentLighten); + background: hsl(from var(--MI_THEME-accent) h s calc(l + 10)); } } } diff --git a/packages/frontend/src/components/MkReactionIcon.vue b/packages/frontend/src/components/MkReactionIcon.vue index c0cbd8a65d..453253f0fc 100644 --- a/packages/frontend/src/components/MkReactionIcon.vue +++ b/packages/frontend/src/components/MkReactionIcon.vue @@ -9,8 +9,8 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { defineAsyncComponent, shallowRef } from 'vue'; -import { useTooltip } from '@/scripts/use-tooltip.js'; +import { defineAsyncComponent, useTemplateRef } from 'vue'; +import { useTooltip } from '@/use/use-tooltip.js'; import * as os from '@/os.js'; const props = defineProps<{ @@ -20,7 +20,7 @@ const props = defineProps<{ withTooltip?: boolean; }>(); -const elRef = shallowRef(); +const elRef = useTemplateRef('elRef'); if (props.withTooltip) { useTooltip(elRef, (showing) => { diff --git a/packages/frontend/src/components/MkReactionsViewer.reaction.vue b/packages/frontend/src/components/MkReactionsViewer.reaction.vue index 41e475eade..9d941a949a 100644 --- a/packages/frontend/src/components/MkReactionsViewer.reaction.vue +++ b/packages/frontend/src/components/MkReactionsViewer.reaction.vue @@ -8,33 +8,34 @@ SPDX-License-Identifier: AGPL-3.0-only ref="buttonEl" v-ripple="canToggle" class="_button" - :class="[$style.root, { [$style.reacted]: note.myReaction == reaction, [$style.canToggle]: canToggle, [$style.small]: defaultStore.state.reactionsDisplaySize === 'small', [$style.large]: defaultStore.state.reactionsDisplaySize === 'large' }]" + :class="[$style.root, { [$style.reacted]: note.myReaction == reaction, [$style.canToggle]: canToggle, [$style.small]: prefer.s.reactionsDisplaySize === 'small', [$style.large]: prefer.s.reactionsDisplaySize === 'large' }]" @click="toggleReaction()" @contextmenu.prevent.stop="menu" > - <MkReactionIcon :class="defaultStore.state.limitWidthOfReaction ? $style.limitWidth : ''" :reaction="reaction" :emojiUrl="note.reactionEmojis[reaction.substring(1, reaction.length - 1)]"/> + <MkReactionIcon style="pointer-events: none;" :class="prefer.s.limitWidthOfReaction ? $style.limitWidth : ''" :reaction="reaction" :emojiUrl="note.reactionEmojis[reaction.substring(1, reaction.length - 1)]"/> <span :class="$style.count">{{ count }}</span> </button> </template> <script lang="ts" setup> -import { computed, inject, onMounted, shallowRef, watch } from 'vue'; +import { computed, inject, onMounted, useTemplateRef, watch } from 'vue'; import * as Misskey from 'misskey-js'; import { getUnicodeEmoji } from '@@/js/emojilist.js'; import MkCustomEmojiDetailedDialog from './MkCustomEmojiDetailedDialog.vue'; import XDetails from '@/components/MkReactionsViewer.details.vue'; import MkReactionIcon from '@/components/MkReactionIcon.vue'; import * as os from '@/os.js'; -import { misskeyApi, misskeyApiGet } from '@/scripts/misskey-api.js'; -import { useTooltip } from '@/scripts/use-tooltip.js'; -import { $i } from '@/account.js'; +import { misskeyApi, misskeyApiGet } from '@/utility/misskey-api.js'; +import { useTooltip } from '@/use/use-tooltip.js'; +import { $i } from '@/i.js'; import MkReactionEffect from '@/components/MkReactionEffect.vue'; -import { claimAchievement } from '@/scripts/achievements.js'; -import { defaultStore } from '@/store.js'; +import { claimAchievement } from '@/utility/achievements.js'; import { i18n } from '@/i18n.js'; -import * as sound from '@/scripts/sound.js'; -import { checkReactionPermissions } from '@/scripts/check-reaction-permissions.js'; +import * as sound from '@/utility/sound.js'; +import { checkReactionPermissions } from '@/utility/check-reaction-permissions.js'; import { customEmojisMap } from '@/custom-emojis.js'; +import { prefer } from '@/preferences.js'; +import { DI } from '@/di.js'; const props = defineProps<{ reaction: string; @@ -43,13 +44,13 @@ const props = defineProps<{ note: Misskey.entities.Note; }>(); -const mock = inject<boolean>('mock', false); +const mock = inject(DI.mock, false); const emit = defineEmits<{ (ev: 'reactionToggled', emoji: string, newCount: number): void; }>(); -const buttonEl = shallowRef<HTMLElement>(); +const buttonEl = useTemplateRef('buttonEl'); const emojiName = computed(() => props.reaction.replace(/:/g, '').replace(/@\./, '')); const emoji = computed(() => customEmojisMap.get(emojiName.value) ?? getUnicodeEmoji(props.reaction)); @@ -90,7 +91,7 @@ async function toggleReaction() { } }); } else { - if (defaultStore.state.confirmOnReact) { + if (prefer.s.confirmOnReact) { const confirm = await os.confirm({ type: 'question', text: i18n.tsx.reactAreYouSure({ emoji: props.reaction.replace('@.', '') }), @@ -135,7 +136,7 @@ async function menu(ev) { } function anime() { - if (document.hidden || !defaultStore.state.animation || buttonEl.value == null) return; + if (window.document.hidden || !prefer.s.animation || buttonEl.value == null) return; const rect = buttonEl.value.getBoundingClientRect(); const x = rect.left + 16; diff --git a/packages/frontend/src/components/MkReactionsViewer.vue b/packages/frontend/src/components/MkReactionsViewer.vue index 63b202f9f3..6e23709be4 100644 --- a/packages/frontend/src/components/MkReactionsViewer.vue +++ b/packages/frontend/src/components/MkReactionsViewer.vue @@ -4,24 +4,27 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<TransitionGroup - :enterActiveClass="defaultStore.state.animation ? $style.transition_x_enterActive : ''" - :leaveActiveClass="defaultStore.state.animation ? $style.transition_x_leaveActive : ''" - :enterFromClass="defaultStore.state.animation ? $style.transition_x_enterFrom : ''" - :leaveToClass="defaultStore.state.animation ? $style.transition_x_leaveTo : ''" - :moveClass="defaultStore.state.animation ? $style.transition_x_move : ''" +<component + :is="prefer.s.animation ? TransitionGroup : 'div'" + :enterActiveClass="$style.transition_x_enterActive" + :leaveActiveClass="$style.transition_x_leaveActive" + :enterFromClass="$style.transition_x_enterFrom" + :leaveToClass="$style.transition_x_leaveTo" + :moveClass="$style.transition_x_move" tag="div" :class="$style.root" > <XReaction v-for="[reaction, count] in reactions" :key="reaction" :reaction="reaction" :count="count" :isInitial="initialReactions.has(reaction)" :note="note" @reactionToggled="onMockToggleReaction"/> <slot v-if="hasMoreReactions" name="more"/> -</TransitionGroup> +</component> </template> <script lang="ts" setup> import * as Misskey from 'misskey-js'; import { inject, watch, ref } from 'vue'; +import { TransitionGroup } from 'vue'; import XReaction from '@/components/MkReactionsViewer.reaction.vue'; -import { defaultStore } from '@/store.js'; +import { prefer } from '@/preferences.js'; +import { DI } from '@/di.js'; const props = withDefaults(defineProps<{ note: Misskey.entities.Note; @@ -30,7 +33,7 @@ const props = withDefaults(defineProps<{ maxNumber: Infinity, }); -const mock = inject<boolean>('mock', false); +const mock = inject(DI.mock, false); const emit = defineEmits<{ (ev: 'mockUpdateMyReaction', emoji: string, delta: number): void; diff --git a/packages/frontend/src/components/MkRemoteEmojiEditDialog.vue b/packages/frontend/src/components/MkRemoteEmojiEditDialog.vue index 873b276b3d..dc9bacf481 100644 --- a/packages/frontend/src/components/MkRemoteEmojiEditDialog.vue +++ b/packages/frontend/src/components/MkRemoteEmojiEditDialog.vue @@ -125,7 +125,7 @@ async function done() { left: 0; padding: 12px; border-top: solid 0.5px var(--MI_THEME-divider); - background: var(--MI_THEME-acrylicBg); + background: color(from var(--MI_THEME-bg) srgb r g b / 0.5); -webkit-backdrop-filter: var(--MI-blur, blur(15px)); backdrop-filter: var(--MI-blur, blur(15px)); } diff --git a/packages/frontend/src/components/MkRetentionHeatmap.vue b/packages/frontend/src/components/MkRetentionHeatmap.vue index 64b573c4d3..1ab2397337 100644 --- a/packages/frontend/src/components/MkRetentionHeatmap.vue +++ b/packages/frontend/src/components/MkRetentionHeatmap.vue @@ -13,18 +13,18 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted, nextTick, shallowRef, ref } from 'vue'; +import { onMounted, nextTick, useTemplateRef, ref } from 'vue'; import { Chart } from 'chart.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { defaultStore } from '@/store.js'; -import { useChartTooltip } from '@/scripts/use-chart-tooltip.js'; -import { alpha } from '@/scripts/color.js'; -import { initChart } from '@/scripts/init-chart.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { store } from '@/store.js'; +import { useChartTooltip } from '@/use/use-chart-tooltip.js'; +import { alpha } from '@/utility/color.js'; +import { initChart } from '@/utility/init-chart.js'; initChart(); -const rootEl = shallowRef<HTMLDivElement | null>(null); -const chartEl = shallowRef<HTMLCanvasElement | null>(null); +const rootEl = useTemplateRef('rootEl'); +const chartEl = useTemplateRef('chartEl'); let chartInstance: Chart | null = null; const fetching = ref(true); @@ -75,7 +75,7 @@ async function renderChart() { await nextTick(); - const color = defaultStore.state.darkMode ? '#b4e900' : '#86b300'; + const color = store.s.darkMode ? '#b4e900' : '#86b300'; const getYYYYMMDD = (date: Date) => { const y = date.getFullYear().toString().padStart(2, '0'); diff --git a/packages/frontend/src/components/MkRetentionLineChart.vue b/packages/frontend/src/components/MkRetentionLineChart.vue index d41793b0fa..ba66ffecc0 100644 --- a/packages/frontend/src/components/MkRetentionLineChart.vue +++ b/packages/frontend/src/components/MkRetentionLineChart.vue @@ -8,19 +8,19 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted, shallowRef } from 'vue'; +import { onMounted, useTemplateRef } from 'vue'; import { Chart } from 'chart.js'; import tinycolor from 'tinycolor2'; -import { defaultStore } from '@/store.js'; -import { useChartTooltip } from '@/scripts/use-chart-tooltip.js'; -import { chartVLine } from '@/scripts/chart-vline.js'; -import { alpha } from '@/scripts/color.js'; -import { initChart } from '@/scripts/init-chart.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { store } from '@/store.js'; +import { useChartTooltip } from '@/use/use-chart-tooltip.js'; +import { chartVLine } from '@/utility/chart-vline.js'; +import { alpha } from '@/utility/color.js'; +import { initChart } from '@/utility/init-chart.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; initChart(); -const chartEl = shallowRef<HTMLCanvasElement | null>(null); +const chartEl = useTemplateRef('chartEl'); const { handler: externalTooltipHandler } = useChartTooltip(); @@ -42,9 +42,9 @@ const getDate = (ymd: string) => { onMounted(async () => { let raw = await misskeyApi('retention', { }); - const vLineColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)'; + const vLineColor = store.s.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)'; - const accent = tinycolor(getComputedStyle(document.documentElement).getPropertyValue('--MI_THEME-accent')); + const accent = tinycolor(getComputedStyle(window.document.documentElement).getPropertyValue('--MI_THEME-accent')); const color = accent.toHex(); if (chartEl.value == null) return; diff --git a/packages/frontend/src/components/MkRoleSelectDialog.vue b/packages/frontend/src/components/MkRoleSelectDialog.vue index 32f35ed5ad..fd56e4902c 100644 --- a/packages/frontend/src/components/MkRoleSelectDialog.vue +++ b/packages/frontend/src/components/MkRoleSelectDialog.vue @@ -11,7 +11,7 @@ SPDX-License-Identifier: AGPL-3.0-only :width="400" :height="500" @close="onCloseModalWindow" - @closed="console.log('MkRoleSelectDialog: closed') ; $emit('dispose')" + @closed="emit('closed')" > <template #header>{{ title }}</template> <MkSpacer :marginMin="20" :marginMax="28"> @@ -49,7 +49,7 @@ import { i18n } from '@/i18n.js'; import MkButton from '@/components/MkButton.vue'; import MkInfo from '@/components/MkInfo.vue'; import MkRolePreview from '@/components/MkRolePreview.vue'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import * as os from '@/os.js'; import MkSpacer from '@/components/global/MkSpacer.vue'; import MkModalWindow from '@/components/MkModalWindow.vue'; @@ -58,7 +58,7 @@ import MkLoading from '@/components/global/MkLoading.vue'; const emit = defineEmits<{ (ev: 'done', value: Misskey.entities.Role[]), (ev: 'close'), - (ev: 'dispose'), + (ev: 'closed'), }>(); const props = withDefaults(defineProps<{ @@ -144,7 +144,7 @@ fetchRoles(); } .roleItemArea { - background-color: var(--MI_THEME-acrylicBg); + background-color: color(from var(--MI_THEME-bg) srgb r g b / 0.5); border-radius: var(--MI-radius); padding: 12px; overflow-y: auto; diff --git a/packages/frontend/src/components/MkSelect.vue b/packages/frontend/src/components/MkSelect.vue index 15717802ec..58a4edfddf 100644 --- a/packages/frontend/src/components/MkSelect.vue +++ b/packages/frontend/src/components/MkSelect.vue @@ -268,7 +268,7 @@ function show() { .caption { font-size: 0.85em; padding: 8px 0 0 0; - color: var(--MI_THEME-fgTransparentWeak); + color: color(from var(--MI_THEME-fg) srgb r g b / 0.75); &:empty { display: none; diff --git a/packages/frontend/src/components/MkSignin.input.vue b/packages/frontend/src/components/MkSignin.input.vue index e98ac9cfd2..aacd1eae2a 100644 --- a/packages/frontend/src/components/MkSignin.input.vue +++ b/packages/frontend/src/components/MkSignin.input.vue @@ -58,7 +58,7 @@ import { toUnicode } from 'punycode.js'; import { query, extractDomain } from '@@/js/url.js'; import { host as configHost } from '@@/js/config.js'; -import type { OpenOnRemoteOptions } from '@/scripts/please-login.js'; +import type { OpenOnRemoteOptions } from '@/utility/please-login.js'; import { i18n } from '@/i18n.js'; import * as os from '@/os.js'; diff --git a/packages/frontend/src/components/MkSignin.vue b/packages/frontend/src/components/MkSignin.vue index 2fb97e8e46..b0fbe3c490 100644 --- a/packages/frontend/src/components/MkSignin.vue +++ b/packages/frontend/src/components/MkSignin.vue @@ -67,20 +67,19 @@ SPDX-License-Identifier: AGPL-3.0-only import { nextTick, onBeforeUnmount, ref, shallowRef, useTemplateRef } from 'vue'; import * as Misskey from 'misskey-js'; import { supported as webAuthnSupported, parseRequestOptionsFromJSON } from '@github/webauthn-json/browser-ponyfill'; - import type { AuthenticationPublicKeyCredential } from '@github/webauthn-json/browser-ponyfill'; -import type { OpenOnRemoteOptions } from '@/scripts/please-login.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { showSuspendedDialog } from '@/scripts/show-suspended-dialog.js'; -import { login } from '@/account.js'; +import type { OpenOnRemoteOptions } from '@/utility/please-login.js'; +import type { PwResponse } from '@/components/MkSignin.password.vue'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { showSuspendedDialog } from '@/utility/show-suspended-dialog.js'; import { i18n } from '@/i18n.js'; import * as os from '@/os.js'; import XInput from '@/components/MkSignin.input.vue'; import XPassword from '@/components/MkSignin.password.vue'; -import type { PwResponse } from '@/components/MkSignin.password.vue'; import XTotp from '@/components/MkSignin.totp.vue'; import XPasskey from '@/components/MkSignin.passkey.vue'; +import { login } from '@/accounts.js'; const emit = defineEmits<{ (ev: 'login', v: Misskey.entities.SigninFlowResponse & { finished: true }): void; diff --git a/packages/frontend/src/components/MkSigninDialog.vue b/packages/frontend/src/components/MkSigninDialog.vue index 676a336ec7..60c99880cd 100644 --- a/packages/frontend/src/components/MkSigninDialog.vue +++ b/packages/frontend/src/components/MkSigninDialog.vue @@ -24,8 +24,8 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import * as Misskey from 'misskey-js'; -import { shallowRef } from 'vue'; -import type { OpenOnRemoteOptions } from '@/scripts/please-login.js'; +import { useTemplateRef } from 'vue'; +import type { OpenOnRemoteOptions } from '@/utility/please-login.js'; import MkSignin from '@/components/MkSignin.vue'; import MkModal from '@/components/MkModal.vue'; import { i18n } from '@/i18n.js'; @@ -46,7 +46,7 @@ const emit = defineEmits<{ (ev: 'cancelled'): void; }>(); -const modal = shallowRef<InstanceType<typeof MkModal>>(); +const modal = useTemplateRef('modal'); function onClose() { emit('cancelled'); @@ -84,7 +84,7 @@ function onLogin(res: Misskey.entities.SigninFlowResponse & { finished: true }) align-items: center; font-weight: bold; backdrop-filter: var(--MI-blur, blur(15px)); - background: var(--MI_THEME-acrylicBg); + background: color(from var(--MI_THEME-bg) srgb r g b / 0.5); z-index: 1; } diff --git a/packages/frontend/src/components/MkSignupDialog.form.vue b/packages/frontend/src/components/MkSignupDialog.form.vue index 4227e1317a..65b879235f 100644 --- a/packages/frontend/src/components/MkSignupDialog.form.vue +++ b/packages/frontend/src/components/MkSignupDialog.form.vue @@ -85,13 +85,13 @@ import * as Misskey from 'misskey-js'; import * as config from '@@/js/config.js'; import MkButton from './MkButton.vue'; import MkInput from './MkInput.vue'; -import MkCaptcha from '@/components/MkCaptcha.vue'; import type { Captcha } from '@/components/MkCaptcha.vue'; +import MkCaptcha from '@/components/MkCaptcha.vue'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { login } from '@/account.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { instance } from '@/instance.js'; import { i18n } from '@/i18n.js'; +import { login } from '@/accounts.js'; const props = withDefaults(defineProps<{ autoSet?: boolean; @@ -267,7 +267,7 @@ async function onSubmit(): Promise<void> { 'testcaptcha-response': testcaptchaResponse.value, }; - const res = await fetch(`${config.apiUrl}/signup`, { + const res = await window.fetch(`${config.apiUrl}/signup`, { method: 'POST', headers: { 'Content-Type': 'application/json', diff --git a/packages/frontend/src/components/MkSignupDialog.vue b/packages/frontend/src/components/MkSignupDialog.vue index 6fb9d77837..5c9047dd43 100644 --- a/packages/frontend/src/components/MkSignupDialog.vue +++ b/packages/frontend/src/components/MkSignupDialog.vue @@ -33,7 +33,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { shallowRef, ref } from 'vue'; +import { useTemplateRef, ref } from 'vue'; import * as Misskey from 'misskey-js'; import XSignup from '@/components/MkSignupDialog.form.vue'; import XServerRules from '@/components/MkSignupDialog.rules.vue'; @@ -52,7 +52,7 @@ const emit = defineEmits<{ (ev: 'closed'): void; }>(); -const dialog = shallowRef<InstanceType<typeof MkModalWindow>>(); +const dialog = useTemplateRef('dialog'); const isAcceptedServerRule = ref(false); diff --git a/packages/frontend/src/components/MkSparkle.vue b/packages/frontend/src/components/MkSparkle.vue index b3fc67c0df..2400c5ec7f 100644 --- a/packages/frontend/src/components/MkSparkle.vue +++ b/packages/frontend/src/components/MkSparkle.vue @@ -56,7 +56,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted, onUnmounted, ref, shallowRef } from 'vue'; +import { onMounted, onUnmounted, ref, useTemplateRef } from 'vue'; const particles = ref<{ id: string, @@ -66,7 +66,7 @@ const particles = ref<{ dur: number, color: string }[]>([]); -const el = shallowRef<HTMLElement>(); +const el = useTemplateRef('el'); const width = ref(0); const height = ref(0); const colors = ['#FF1493', '#00FFFF', '#FFE202', '#FFE202', '#FFE202']; diff --git a/packages/frontend/src/components/MkSuperMenu.vue b/packages/frontend/src/components/MkSuperMenu.vue index d8dec3aa2f..3f8d92a61d 100644 --- a/packages/frontend/src/components/MkSuperMenu.vue +++ b/packages/frontend/src/components/MkSuperMenu.vue @@ -6,16 +6,18 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div ref="rootEl" class="rrevdjwu" :class="{ grid }"> <MkInput - v-model="search" + v-if="searchIndex && searchIndex.length > 0" + v-model="searchQuery" :placeholder="i18n.ts.search" type="search" style="margin-bottom: 16px;" + @input.passive="searchOnInput" @keydown="searchOnKeyDown" > <template #prefix><i class="ti ti-search"></i></template> </MkInput> - <template v-if="search == ''"> + <template v-if="rawSearchQuery == ''"> <div v-for="group in def" class="group"> <div v-if="group.title" class="title">{{ group.title }}</div> @@ -91,23 +93,28 @@ export type SuperMenuDef = { </script> <script lang="ts" setup> -import { useTemplateRef, ref, watch, nextTick } from 'vue'; -import type { SearchIndexItem } from '@/scripts/autogen/settings-search-index.js'; +import { useTemplateRef, ref, watch, nextTick, computed } from 'vue'; +import { getScrollContainer } from '@@/js/scroll.js'; +import type { SearchIndexItem } from '@/utility/settings-search-index.js'; import MkInput from '@/components/MkInput.vue'; import { i18n } from '@/i18n.js'; -import { getScrollContainer } from '@@/js/scroll.js'; -import { useRouter } from '@/router/supplier.js'; +import { useRouter } from '@/router.js'; +import { initIntlString, compareStringIncludes } from '@/utility/intl-string.js'; const props = defineProps<{ def: SuperMenuDef[]; grid?: boolean; - searchIndex: SearchIndexItem[]; + searchIndex?: SearchIndexItem[]; }>(); +initIntlString(); + const router = useRouter(); const rootEl = useTemplateRef('rootEl'); -const search = ref(''); +const searchQuery = ref(''); +const rawSearchQuery = ref(''); + const searchSelectedIndex = ref<null | number>(null); const searchResult = ref<{ id: string; @@ -117,8 +124,13 @@ const searchResult = ref<{ isRoot: boolean; parentLabels: string[]; }[]>([]); +const searchIndexItemByIdComputed = computed(() => props.searchIndex && new Map<string, SearchIndexItem>(props.searchIndex.map(i => [i.id, i]))); -watch(search, (value) => { +watch(searchQuery, (value) => { + rawSearchQuery.value = value; +}); + +watch(rawSearchQuery, (value) => { searchResult.value = []; searchSelectedIndex.value = null; @@ -126,32 +138,49 @@ watch(search, (value) => { return; } - const dive = (items: SearchIndexItem[], parents: SearchIndexItem[] = []) => { - for (const item of items) { - const matched = - item.label.includes(value.toLowerCase()) || - item.keywords.some((x) => x.toLowerCase().includes(value.toLowerCase())); + const searchIndexItemById = searchIndexItemByIdComputed.value; + if (searchIndexItemById != null) { + const addSearchResult = (item: SearchIndexItem) => { + let path: string | undefined = item.path; + let icon: string | undefined = item.icon; + const parentLabels: string[] = []; - if (matched) { - searchResult.value.push({ - id: item.id, - path: item.path ?? parents.find((x) => x.path != null)?.path, - label: item.label, - parentLabels: parents.map((x) => x.label).toReversed(), - icon: item.icon ?? parents.find((x) => x.icon != null)?.icon, - isRoot: parents.length === 0, - }); + for (let current = searchIndexItemById.get(item.parentId ?? ''); + current != null; + current = searchIndexItemById.get(current.parentId ?? '')) { + path ??= current.path; + icon ??= current.icon; + parentLabels.push(current.label); } - if (item.children) { - dive(item.children, [item, ...parents]); + if (_DEV_ && path == null) throw new Error('path is null for ' + item.id); + + searchResult.value.push({ + id: item.id, + path: path ?? '/', // never gets `/` + label: item.label, + parentLabels: parentLabels.toReversed(), + icon, + isRoot: item.parentId == null, + }); + }; + + for (const item of searchIndexItemById.values()) { + if ( + compareStringIncludes(item.label, value) || + item.keywords.some((x) => compareStringIncludes(x, value)) + ) { + addSearchResult(item); } } - }; - - dive(props.searchIndex); + } }); +function searchOnInput(ev: InputEvent) { + searchSelectedIndex.value = null; + rawSearchQuery.value = (ev.target as HTMLInputElement).value; +} + function searchOnKeyDown(ev: KeyboardEvent) { if (ev.isComposing) return; diff --git a/packages/frontend/src/components/MkSwitch.vue b/packages/frontend/src/components/MkSwitch.vue index 797e577fa4..92359b773a 100644 --- a/packages/frontend/src/components/MkSwitch.vue +++ b/packages/frontend/src/components/MkSwitch.vue @@ -100,7 +100,7 @@ const toggle = () => { .caption { margin: 8px 0 0 0; - color: var(--MI_THEME-fgTransparentWeak); + color: color(from var(--MI_THEME-fg) srgb r g b / 0.75); font-size: 0.85em; &:empty { diff --git a/packages/frontend/src/components/MkSystemWebhookEditor.vue b/packages/frontend/src/components/MkSystemWebhookEditor.vue index 7e92726dcb..86e755a3c3 100644 --- a/packages/frontend/src/components/MkSystemWebhookEditor.vue +++ b/packages/frontend/src/components/MkSystemWebhookEditor.vue @@ -92,18 +92,18 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script setup lang="ts"> -import { computed, onMounted, ref, shallowRef, toRefs } from 'vue'; +import { computed, onMounted, ref, useTemplateRef, toRefs } from 'vue'; import * as Misskey from 'misskey-js'; -import MkInput from '@/components/MkInput.vue'; -import MkSwitch from '@/components/MkSwitch.vue'; import type { MkSystemWebhookEditorProps, MkSystemWebhookResult, SystemWebhookEventType, } from '@/components/MkSystemWebhookEditor.impl.js'; +import MkInput from '@/components/MkInput.vue'; +import MkSwitch from '@/components/MkSwitch.vue'; import { i18n } from '@/i18n.js'; import MkButton from '@/components/MkButton.vue'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import MkModalWindow from '@/components/MkModalWindow.vue'; import MkFolder from '@/components/MkFolder.vue'; import * as os from '@/os.js'; @@ -122,7 +122,7 @@ const emit = defineEmits<{ (ev: 'closed'): void; }>(); -const dialogEl = shallowRef<InstanceType<typeof MkModalWindow>>(); +const dialogEl = useTemplateRef('dialogEl'); const props = defineProps<MkSystemWebhookEditorProps>(); @@ -280,7 +280,7 @@ onMounted(async () => { left: 0; padding: 12px; border-top: solid 0.5px var(--MI_THEME-divider); - background: var(--MI_THEME-acrylicBg); + background: color(from var(--MI_THEME-bg) srgb r g b / 0.5); -webkit-backdrop-filter: var(--MI-blur, blur(15px)); backdrop-filter: var(--MI-blur, blur(15px)); } @@ -307,6 +307,6 @@ onMounted(async () => { .description { font-size: 0.85em; padding: 8px 0 0 0; - color: var(--MI_THEME-fgTransparentWeak); + color: color(from var(--MI_THEME-fg) srgb r g b / 0.75); } </style> diff --git a/packages/frontend/src/components/MkTagCloud.vue b/packages/frontend/src/components/MkTagCloud.vue index 87aa046963..9d541c8acb 100644 --- a/packages/frontend/src/components/MkTagCloud.vue +++ b/packages/frontend/src/components/MkTagCloud.vue @@ -15,18 +15,18 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted, watch, onBeforeUnmount, ref, shallowRef } from 'vue'; +import { onMounted, watch, onBeforeUnmount, ref, useTemplateRef } from 'vue'; import tinycolor from 'tinycolor2'; const loaded = !!window.TagCanvas; const SAFE_FOR_HTML_ID = 'abcdefghijklmnopqrstuvwxyz'; -const computedStyle = getComputedStyle(document.documentElement); +const computedStyle = getComputedStyle(window.document.documentElement); const idForCanvas = Array.from({ length: 16 }, () => SAFE_FOR_HTML_ID[Math.floor(Math.random() * SAFE_FOR_HTML_ID.length)]).join(''); const idForTags = Array.from({ length: 16 }, () => SAFE_FOR_HTML_ID[Math.floor(Math.random() * SAFE_FOR_HTML_ID.length)]).join(''); const available = ref(false); -const rootEl = shallowRef<HTMLElement | null>(null); -const canvasEl = shallowRef<HTMLCanvasElement | null>(null); -const tagsEl = shallowRef<HTMLElement | null>(null); +const rootEl = useTemplateRef('rootEl'); +const canvasEl = useTemplateRef('canvasEl'); +const tagsEl = useTemplateRef('tagsEl'); const width = ref(300); watch(available, () => { @@ -57,7 +57,7 @@ onMounted(() => { if (loaded) { available.value = true; } else { - document.head.appendChild(Object.assign(document.createElement('script'), { + window.document.head.appendChild(Object.assign(window.document.createElement('script'), { async: true, src: '/client-assets/tagcanvas.min.js', })).addEventListener('load', () => available.value = true); diff --git a/packages/frontend/src/components/MkTextarea.vue b/packages/frontend/src/components/MkTextarea.vue index 3e8588018c..407ac33add 100644 --- a/packages/frontend/src/components/MkTextarea.vue +++ b/packages/frontend/src/components/MkTextarea.vue @@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div> +<div class="_selectable"> <div :class="$style.label" @click="focus"><slot name="label"></slot></div> <div :class="{ [$style.disabled]: disabled, [$style.focused]: focused, [$style.tall]: tall, [$style.pre]: pre }" style="position: relative;"> <textarea @@ -36,12 +36,12 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted, onUnmounted, nextTick, ref, watch, computed, toRefs, shallowRef } from 'vue'; +import { onMounted, onUnmounted, nextTick, ref, watch, computed, toRefs, useTemplateRef } from 'vue'; import { debounce } from 'throttle-debounce'; +import type { SuggestionType } from '@/utility/autocomplete.js'; import MkButton from '@/components/MkButton.vue'; import { i18n } from '@/i18n.js'; -import { Autocomplete } from '@/scripts/autocomplete.js'; -import type { SuggestionType } from '@/scripts/autocomplete.js'; +import { Autocomplete } from '@/utility/autocomplete.js'; const props = defineProps<{ modelValue: string | null; @@ -75,7 +75,7 @@ const focused = ref(false); const changed = ref(false); const invalid = ref(false); const filled = computed(() => v.value !== '' && v.value != null); -const inputEl = shallowRef<HTMLTextAreaElement>(); +const inputEl = useTemplateRef('inputEl'); const preview = ref(false); let autocompleteWorker: Autocomplete | null = null; @@ -160,7 +160,7 @@ onUnmounted(() => { .caption { font-size: 0.85em; padding: 8px 0 0 0; - color: var(--MI_THEME-fgTransparentWeak); + color: color(from var(--MI_THEME-fg) srgb r g b / 0.75); &:empty { display: none; diff --git a/packages/frontend/src/components/MkThemePreview.vue b/packages/frontend/src/components/MkThemePreview.vue new file mode 100644 index 0000000000..013ab9d6a4 --- /dev/null +++ b/packages/frontend/src/components/MkThemePreview.vue @@ -0,0 +1,112 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<svg viewBox="0 0 200 150"> + <g fill-rule="evenodd"> + <rect width="200" height="150" :fill="themeVariables.bg"/> + <rect width="64" height="150" :fill="themeVariables.navBg"/> + <rect x="64" width="136" height="41" :fill="themeVariables.bg"/> + <path transform="scale(.26458)" d="m439.77 247.19c-43.673 0-78.832 35.157-78.832 78.83v249.98h407.06v-328.81z" :fill="themeVariables.panel"/> + </g> + <circle cx="32" cy="83" r="21" :fill="themeVariables.accentedBg"/> + <g> + <rect x="120" y="88" width="40" height="6" ry="3" :fill="themeVariables.fg"/> + <rect x="170" y="88" width="20" height="6" ry="3" :fill="themeVariables.mention"/> + <rect x="120" y="108" width="20" height="6" ry="3" :fill="themeVariables.hashtag"/> + <rect x="150" y="108" width="40" height="6" ry="3" :fill="themeVariables.fg"/> + <rect x="120" y="128" width="40" height="6" ry="3" :fill="themeVariables.fg"/> + <rect x="170" y="128" width="20" height="6" ry="3" :fill="themeVariables.link"/> + </g> + <path d="m65.498 40.892h137.7" :stroke="themeVariables.divider" stroke-width="0.75"/> + <g transform="matrix(.60823 0 0 .60823 25.45 75.755)" fill="none" :stroke="themeVariables.accent" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"> + <path d="m0 0h24v24h-24z" fill="none" stroke="none"/> + <path d="m5 12h-2l9-9 9 9h-2"/> + <path d="m5 12v7a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-7"/> + <path d="m9 21v-6a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v6"/> + </g> + <g transform="matrix(.61621 0 0 .61621 25.354 117.92)" fill="none" :stroke="themeVariables.fg" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"> + <path d="m0 0h24v24h-24z" fill="none" stroke="none"/> + <path d="m10 5a2 2 0 1 1 4 0 7 7 0 0 1 4 6v3a4 4 0 0 0 2 3h-16a4 4 0 0 0 2-3v-3a7 7 0 0 1 4-6"/> + <path d="m9 17v1a3 3 0 0 0 6 0v-1"/> + </g> + <circle cx="32" cy="32" r="16" :fill="themeVariables.accent"/> + <circle cx="140" cy="20" r="6" :fill="themeVariables.success"/> + <circle cx="160" cy="20" r="6" :fill="themeVariables.warn"/> + <circle cx="180" cy="20" r="6" :fill="themeVariables.error"/> +</svg> +</template> + +<script setup lang="ts"> +import { ref, watch } from 'vue'; +import lightTheme from '@@/themes/_light.json5'; +import darkTheme from '@@/themes/_dark.json5'; +import type { Theme } from '@/theme.js'; +import { compile } from '@/theme.js'; +import { deepClone } from '@/utility/clone.js'; + +const props = defineProps<{ + theme: Theme; +}>(); + +const themeVariables = ref<{ + bg: string; + panel: string; + fg: string; + mention: string; + hashtag: string; + link: string; + divider: string; + accent: string; + accentedBg: string; + navBg: string; + success: string; + warn: string; + error: string; +}>({ + bg: 'var(--MI_THEME-bg)', + panel: 'var(--MI_THEME-panel)', + fg: 'var(--MI_THEME-fg)', + mention: 'var(--MI_THEME-mention)', + hashtag: 'var(--MI_THEME-hashtag)', + link: 'var(--MI_THEME-link)', + divider: 'var(--MI_THEME-divider)', + accent: 'var(--MI_THEME-accent)', + accentedBg: 'var(--MI_THEME-accentedBg)', + navBg: 'var(--MI_THEME-navBg)', + success: 'var(--MI_THEME-success)', + warn: 'var(--MI_THEME-warn)', + error: 'var(--MI_THEME-error)', +}); + +watch(() => props.theme, (theme) => { + if (theme == null) return; + + const _theme = deepClone(theme); + + if (_theme.base != null) { + const base = [lightTheme, darkTheme].find(x => x.id === _theme.base); + if (base) _theme.props = Object.assign({}, base.props, _theme.props); + } + + const compiled = compile(_theme); + + themeVariables.value = { + bg: compiled.bg ?? 'var(--MI_THEME-bg)', + panel: compiled.panel ?? 'var(--MI_THEME-panel)', + fg: compiled.fg ?? 'var(--MI_THEME-fg)', + mention: compiled.mention ?? 'var(--MI_THEME-mention)', + hashtag: compiled.hashtag ?? 'var(--MI_THEME-hashtag)', + link: compiled.link ?? 'var(--MI_THEME-link)', + divider: compiled.divider ?? 'var(--MI_THEME-divider)', + accent: compiled.accent ?? 'var(--MI_THEME-accent)', + accentedBg: compiled.accentedBg ?? 'var(--MI_THEME-accentedBg)', + navBg: compiled.navBg ?? 'var(--MI_THEME-navBg)', + success: compiled.success ?? 'var(--MI_THEME-success)', + warn: compiled.warn ?? 'var(--MI_THEME-warn)', + error: compiled.error ?? 'var(--MI_THEME-error)', + }; +}, { immediate: true }); +</script> diff --git a/packages/frontend/src/components/MkTimeline.vue b/packages/frontend/src/components/MkTimeline.vue index 7bae240ddd..73057e4644 100644 --- a/packages/frontend/src/components/MkTimeline.vue +++ b/packages/frontend/src/components/MkTimeline.vue @@ -9,7 +9,7 @@ SPDX-License-Identifier: AGPL-3.0-only v-if="paginationQuery" ref="tlComponent" :pagination="paginationQuery" - :noGap="!defaultStore.state.showGapBetweenNotesInTimeline" + :noGap="!prefer.s.showGapBetweenNotesInTimeline" @queue="emit('queue', $event)" @status="prComponent?.setDisabled($event)" /> @@ -17,17 +17,17 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { computed, watch, onUnmounted, provide, ref, shallowRef } from 'vue'; +import { computed, watch, onUnmounted, provide, useTemplateRef } from 'vue'; import * as Misskey from 'misskey-js'; import type { BasicTimelineType } from '@/timelines.js'; +import type { Paging } from '@/components/MkPagination.vue'; import MkNotes from '@/components/MkNotes.vue'; import MkPullToRefresh from '@/components/MkPullToRefresh.vue'; import { useStream } from '@/stream.js'; -import * as sound from '@/scripts/sound.js'; -import { $i } from '@/account.js'; +import * as sound from '@/utility/sound.js'; +import { $i } from '@/i.js'; import { instance } from '@/instance.js'; -import { defaultStore } from '@/store.js'; -import type { Paging } from '@/components/MkPagination.vue'; +import { prefer } from '@/preferences.js'; const props = withDefaults(defineProps<{ src: BasicTimelineType | 'mentions' | 'directs' | 'list' | 'antenna' | 'channel' | 'role'; @@ -67,8 +67,8 @@ type TimelineQueryType = { roleId?: string }; -const prComponent = shallowRef<InstanceType<typeof MkPullToRefresh>>(); -const tlComponent = shallowRef<InstanceType<typeof MkNotes>>(); +const prComponent = useTemplateRef('prComponent'); +const tlComponent = useTemplateRef('tlComponent'); let tlNotesCount = 0; @@ -239,7 +239,7 @@ function updatePaginationQuery() { } function refreshEndpointAndChannel() { - if (!defaultStore.state.disableStreamingTimeline) { + if (!prefer.s.disableStreamingTimeline) { disconnectChannel(); connectChannel(); } diff --git a/packages/frontend/src/components/MkToast.vue b/packages/frontend/src/components/MkToast.vue index e256640649..ac795e312c 100644 --- a/packages/frontend/src/components/MkToast.vue +++ b/packages/frontend/src/components/MkToast.vue @@ -6,10 +6,10 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div> <Transition - :enterActiveClass="defaultStore.state.animation ? $style.transition_toast_enterActive : ''" - :leaveActiveClass="defaultStore.state.animation ? $style.transition_toast_leaveActive : ''" - :enterFromClass="defaultStore.state.animation ? $style.transition_toast_enterFrom : ''" - :leaveToClass="defaultStore.state.animation ? $style.transition_toast_leaveTo : ''" + :enterActiveClass="prefer.s.animation ? $style.transition_toast_enterActive : ''" + :leaveActiveClass="prefer.s.animation ? $style.transition_toast_leaveActive : ''" + :enterFromClass="prefer.s.animation ? $style.transition_toast_enterFrom : ''" + :leaveToClass="prefer.s.animation ? $style.transition_toast_leaveTo : ''" appear @afterLeave="emit('closed')" > <div v-if="showing" class="_acrylic" :class="$style.root" :style="{ zIndex }"> @@ -24,7 +24,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { onMounted, ref } from 'vue'; import * as os from '@/os.js'; -import { defaultStore } from '@/store.js'; +import { prefer } from '@/preferences.js'; defineProps<{ message: string; diff --git a/packages/frontend/src/components/MkTokenGenerateWindow.vue b/packages/frontend/src/components/MkTokenGenerateWindow.vue index 73aef68964..b449155edb 100644 --- a/packages/frontend/src/components/MkTokenGenerateWindow.vue +++ b/packages/frontend/src/components/MkTokenGenerateWindow.vue @@ -47,7 +47,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { shallowRef, ref } from 'vue'; +import { useTemplateRef, ref } from 'vue'; import * as Misskey from 'misskey-js'; import MkInput from './MkInput.vue'; import MkSwitch from './MkSwitch.vue'; @@ -55,7 +55,7 @@ import MkButton from './MkButton.vue'; import MkInfo from './MkInfo.vue'; import MkModalWindow from '@/components/MkModalWindow.vue'; import { i18n } from '@/i18n.js'; -import { iAmAdmin } from '@/account.js'; +import { iAmAdmin } from '@/i.js'; const props = withDefaults(defineProps<{ title?: string | null; @@ -77,10 +77,10 @@ const emit = defineEmits<{ const defaultPermissions = Misskey.permissions.filter(p => !p.startsWith('read:admin') && !p.startsWith('write:admin')); const adminPermissions = Misskey.permissions.filter(p => p.startsWith('read:admin') || p.startsWith('write:admin')); -const dialog = shallowRef<InstanceType<typeof MkModalWindow>>(); +const dialog = useTemplateRef('dialog'); const name = ref(props.initialName); -const permissionSwitches = ref(<Record<(typeof Misskey.permissions)[number], boolean>>{}); -const permissionSwitchesForAdmin = ref(<Record<(typeof Misskey.permissions)[number], boolean>>{}); +const permissionSwitches = ref({} as Record<(typeof Misskey.permissions)[number], boolean>); +const permissionSwitchesForAdmin = ref({} as Record<(typeof Misskey.permissions)[number], boolean>); if (props.initialPermissions) { for (const kind of props.initialPermissions) { diff --git a/packages/frontend/src/components/MkTooltip.vue b/packages/frontend/src/components/MkTooltip.vue index 10365d29b1..3fe80f4ab4 100644 --- a/packages/frontend/src/components/MkTooltip.vue +++ b/packages/frontend/src/components/MkTooltip.vue @@ -5,11 +5,12 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <Transition - :enterActiveClass="defaultStore.state.animation ? $style.transition_tooltip_enterActive : ''" - :leaveActiveClass="defaultStore.state.animation ? $style.transition_tooltip_leaveActive : ''" - :enterFromClass="defaultStore.state.animation ? $style.transition_tooltip_enterFrom : ''" - :leaveToClass="defaultStore.state.animation ? $style.transition_tooltip_leaveTo : ''" - appear @afterLeave="emit('closed')" + :enterActiveClass="prefer.s.animation ? $style.transition_tooltip_enterActive : ''" + :leaveActiveClass="prefer.s.animation ? $style.transition_tooltip_leaveActive : ''" + :enterFromClass="prefer.s.animation ? $style.transition_tooltip_enterFrom : ''" + :leaveToClass="prefer.s.animation ? $style.transition_tooltip_leaveTo : ''" + appear :css="prefer.s.animation" + @afterLeave="emit('closed')" > <div v-show="showing" ref="el" :class="$style.root" class="_acrylic _shadow" :style="{ zIndex, maxWidth: maxWidth + 'px' }"> <slot> @@ -23,10 +24,10 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { nextTick, onMounted, onUnmounted, shallowRef } from 'vue'; +import { nextTick, onMounted, onUnmounted, useTemplateRef } from 'vue'; import * as os from '@/os.js'; -import { calcPopupPosition } from '@/scripts/popup-position.js'; -import { defaultStore } from '@/store.js'; +import { calcPopupPosition } from '@/utility/popup-position.js'; +import { prefer } from '@/preferences.js'; const props = withDefaults(defineProps<{ showing: boolean; @@ -51,7 +52,7 @@ const emit = defineEmits<{ // タイミングによっては最初から showing = false な場合があり、その場合に closed 扱いにしないと永久にDOMに残ることになる if (!props.showing) emit('closed'); -const el = shallowRef<HTMLElement>(); +const el = useTemplateRef('el'); const zIndex = os.claimZIndex('high'); function setPosition() { diff --git a/packages/frontend/src/components/MkTutorialDialog.Note.vue b/packages/frontend/src/components/MkTutorialDialog.Note.vue index b26a01737e..59e1b096ae 100644 --- a/packages/frontend/src/components/MkTutorialDialog.Note.vue +++ b/packages/frontend/src/components/MkTutorialDialog.Note.vue @@ -27,7 +27,7 @@ import * as Misskey from 'misskey-js'; import { ref, reactive } from 'vue'; import { i18n } from '@/i18n.js'; import { globalEvents } from '@/events.js'; -import { $i } from '@/account.js'; +import { $i } from '@/i.js'; import MkNote from '@/components/MkNote.vue'; const props = defineProps<{ diff --git a/packages/frontend/src/components/MkTutorialDialog.Sensitive.vue b/packages/frontend/src/components/MkTutorialDialog.Sensitive.vue index f7b60fbc45..8ae6c1ceaa 100644 --- a/packages/frontend/src/components/MkTutorialDialog.Sensitive.vue +++ b/packages/frontend/src/components/MkTutorialDialog.Sensitive.vue @@ -31,7 +31,7 @@ import MkPostForm from '@/components/MkPostForm.vue'; import MkFolder from '@/components/MkFolder.vue'; import MkInfo from '@/components/MkInfo.vue'; import MkNote from '@/components/MkNote.vue'; -import { $i } from '@/account.js'; +import { $i } from '@/i.js'; const emit = defineEmits<{ (ev: 'succeeded'): void; diff --git a/packages/frontend/src/components/MkTutorialDialog.vue b/packages/frontend/src/components/MkTutorialDialog.vue index 11d7c8dc4d..92f71b01af 100644 --- a/packages/frontend/src/components/MkTutorialDialog.vue +++ b/packages/frontend/src/components/MkTutorialDialog.vue @@ -148,7 +148,8 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { ref, shallowRef, watch } from 'vue'; +import { ref, useTemplateRef, watch } from 'vue'; +import { host } from '@@/js/config.js'; import MkModalWindow from '@/components/MkModalWindow.vue'; import MkButton from '@/components/MkButton.vue'; import XNote from '@/components/MkTutorialDialog.Note.vue'; @@ -158,8 +159,7 @@ import XSensitive from '@/components/MkTutorialDialog.Sensitive.vue'; import MkAnimBg from '@/components/MkAnimBg.vue'; import { i18n } from '@/i18n.js'; import { instance } from '@/instance.js'; -import { host } from '@@/js/config.js'; -import { claimAchievement } from '@/scripts/achievements.js'; +import { claimAchievement } from '@/utility/achievements.js'; import * as os from '@/os.js'; const props = defineProps<{ @@ -170,7 +170,7 @@ const emit = defineEmits<{ (ev: 'closed'): void; }>(); -const dialog = shallowRef<InstanceType<typeof MkModalWindow>>(); +const dialog = useTemplateRef('dialog'); // eslint-disable-next-line vue/no-setup-props-reactivity-loss const page = ref(props.initialPage ?? 0); @@ -249,6 +249,7 @@ async function close(skip: boolean) { .pageFooter { position: sticky; + z-index: 1; bottom: 0; left: 0; flex-shrink: 0; diff --git a/packages/frontend/src/components/MkUpdated.vue b/packages/frontend/src/components/MkUpdated.vue index c937b4ce59..79ab464cb0 100644 --- a/packages/frontend/src/components/MkUpdated.vue +++ b/packages/frontend/src/components/MkUpdated.vue @@ -15,15 +15,15 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted, shallowRef } from 'vue'; +import { onMounted, useTemplateRef } from 'vue'; +import { version } from '@@/js/config.js'; import MkModal from '@/components/MkModal.vue'; import MkButton from '@/components/MkButton.vue'; import MkSparkle from '@/components/MkSparkle.vue'; -import { version } from '@@/js/config.js'; import { i18n } from '@/i18n.js'; -import { confetti } from '@/scripts/confetti.js'; +import { confetti } from '@/utility/confetti.js'; -const modal = shallowRef<InstanceType<typeof MkModal>>(); +const modal = useTemplateRef('modal'); function whatIsNew() { modal.value?.close(); diff --git a/packages/frontend/src/components/MkUrlPreview.vue b/packages/frontend/src/components/MkUrlPreview.vue index 063e5dcad2..20dab6f028 100644 --- a/packages/frontend/src/components/MkUrlPreview.vue +++ b/packages/frontend/src/components/MkUrlPreview.vue @@ -34,7 +34,7 @@ SPDX-License-Identifier: AGPL-3.0-only sandbox="allow-popups allow-popups-to-escape-sandbox allow-scripts allow-same-origin" scrolling="no" :style="{ position: 'relative', width: '100%', height: `${tweetHeight}px`, border: 0 }" - :src="`https://platform.twitter.com/embed/index.html?embedId=${embedId}&hideCard=false&hideThread=false&lang=en&theme=${defaultStore.state.darkMode ? 'dark' : 'light'}&id=${tweetId}`" + :src="`https://platform.twitter.com/embed/index.html?embedId=${embedId}&hideCard=false&hideThread=false&lang=en&theme=${store.s.darkMode ? 'dark' : 'light'}&id=${tweetId}`" ></iframe> </div> <div :class="$style.action"> @@ -45,7 +45,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <div v-else> <component :is="self ? 'MkA' : 'a'" :class="[$style.link, { [$style.compact]: compact }]" :[attr]="self ? url.substring(local.length) : url" rel="nofollow noopener" :target="target" :title="url"> - <div v-if="thumbnail && !sensitive" :class="$style.thumbnail" :style="defaultStore.state.dataSaver.urlPreview ? '' : `background-image: url('${thumbnail}')`"> + <div v-if="thumbnail && !sensitive" :class="$style.thumbnail" :style="prefer.s.dataSaver.urlPreview ? '' : `background-image: url('${thumbnail}')`"> </div> <article :class="$style.body"> <header :class="$style.header"> @@ -89,10 +89,11 @@ import { versatileLang } from '@@/js/intl-const.js'; import type { summaly } from '@misskey-dev/summaly'; import { i18n } from '@/i18n.js'; import * as os from '@/os.js'; -import { deviceKind } from '@/scripts/device-kind.js'; +import { deviceKind } from '@/utility/device-kind.js'; import MkButton from '@/components/MkButton.vue'; -import { transformPlayerUrl } from '@/scripts/player-url-transform.js'; -import { defaultStore } from '@/store.js'; +import { transformPlayerUrl } from '@/utility/player-url-transform.js'; +import { store } from '@/store.js'; +import { prefer } from '@/preferences.js'; type SummalyResult = Awaited<ReturnType<typeof summaly>>; @@ -245,6 +246,7 @@ onUnmounted(() => { box-shadow: 0 0 0 1px var(--MI_THEME-divider); border-radius: 8px; overflow: clip; + text-align: left; &:hover { text-decoration: none; diff --git a/packages/frontend/src/components/MkUrlPreviewPopup.vue b/packages/frontend/src/components/MkUrlPreviewPopup.vue index e972973dba..fd36d6a82b 100644 --- a/packages/frontend/src/components/MkUrlPreviewPopup.vue +++ b/packages/frontend/src/components/MkUrlPreviewPopup.vue @@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div :class="$style.root" :style="{ zIndex, top: top + 'px', left: left + 'px' }"> - <Transition :name="defaultStore.state.animation ? '_transition_zoom' : ''" @afterLeave="emit('closed')"> + <Transition :name="prefer.s.animation ? '_transition_zoom' : ''" @afterLeave="emit('closed')"> <MkUrlPreview v-if="showing" class="_popup _shadow" :url="url" :showActions="false"/> </Transition> </div> @@ -15,7 +15,7 @@ SPDX-License-Identifier: AGPL-3.0-only import { onMounted, ref } from 'vue'; import MkUrlPreview from '@/components/MkUrlPreview.vue'; import * as os from '@/os.js'; -import { defaultStore } from '@/store.js'; +import { prefer } from '@/preferences.js'; const props = defineProps<{ showing: boolean; diff --git a/packages/frontend/src/components/MkUserAnnouncementEditDialog.vue b/packages/frontend/src/components/MkUserAnnouncementEditDialog.vue index 34991fa0dd..34e86444ad 100644 --- a/packages/frontend/src/components/MkUserAnnouncementEditDialog.vue +++ b/packages/frontend/src/components/MkUserAnnouncementEditDialog.vue @@ -56,7 +56,7 @@ import MkModalWindow from '@/components/MkModalWindow.vue'; import MkButton from '@/components/MkButton.vue'; import MkInput from '@/components/MkInput.vue'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; import MkTextarea from '@/components/MkTextarea.vue'; import MkSwitch from '@/components/MkSwitch.vue'; diff --git a/packages/frontend/src/components/MkUserCardMini.vue b/packages/frontend/src/components/MkUserCardMini.vue index 7a2e878931..dde2efd8ee 100644 --- a/packages/frontend/src/components/MkUserCardMini.vue +++ b/packages/frontend/src/components/MkUserCardMini.vue @@ -18,7 +18,7 @@ SPDX-License-Identifier: AGPL-3.0-only import * as Misskey from 'misskey-js'; import { onMounted, ref } from 'vue'; import MkMiniChart from '@/components/MkMiniChart.vue'; -import { misskeyApiGet } from '@/scripts/misskey-api.js'; +import { misskeyApiGet } from '@/utility/misskey-api.js'; import { acct } from '@/filters/user.js'; const props = withDefaults(defineProps<{ diff --git a/packages/frontend/src/components/MkUserInfo.vue b/packages/frontend/src/components/MkUserInfo.vue index 0164515a8a..cff531b2ca 100644 --- a/packages/frontend/src/components/MkUserInfo.vue +++ b/packages/frontend/src/components/MkUserInfo.vue @@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div class="_panel" :class="$style.root"> - <div :class="$style.banner" :style="user.bannerUrl ? `background-image: url(${defaultStore.state.disableShowingAnimatedImages ? getStaticImageUrl(user.bannerUrl) : user.bannerUrl})` : ''"></div> + <div :class="$style.banner" :style="user.bannerUrl ? `background-image: url(${prefer.s.disableShowingAnimatedImages ? getStaticImageUrl(user.bannerUrl) : user.bannerUrl})` : ''"></div> <MkAvatar :class="$style.avatar" :user="user" indicator/> <div :class="$style.title"> <MkA :class="$style.name" :to="userPage(user)"><MkUserName :user="user" :nowrap="false"/></MkA> @@ -39,10 +39,10 @@ import MkFollowButton from '@/components/MkFollowButton.vue'; import number from '@/filters/number.js'; import { userPage } from '@/filters/user.js'; import { i18n } from '@/i18n.js'; -import { $i } from '@/account.js'; -import { isFollowingVisibleForMe, isFollowersVisibleForMe } from '@/scripts/isFfVisibleForMe.js'; -import { getStaticImageUrl } from '@/scripts/media-proxy.js'; -import { defaultStore } from '@/store.js'; +import { $i } from '@/i.js'; +import { isFollowingVisibleForMe, isFollowersVisibleForMe } from '@/utility/isFfVisibleForMe.js'; +import { getStaticImageUrl } from '@/utility/media-proxy.js'; +import { prefer } from '@/preferences.js'; defineProps<{ user: Misskey.entities.UserDetailed; diff --git a/packages/frontend/src/components/MkUserList.vue b/packages/frontend/src/components/MkUserList.vue index d1881ec3fc..0d1ffd715f 100644 --- a/packages/frontend/src/components/MkUserList.vue +++ b/packages/frontend/src/components/MkUserList.vue @@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkPagination :pagination="pagination"> <template #empty> <div class="_fullinfo"> - <img :src="infoImageUrl" class="_ghost"/> + <img :src="infoImageUrl" draggable="false"/> <div>{{ i18n.ts.noUsers }}</div> </div> </template> @@ -21,9 +21,9 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> +import type { Paging } from '@/components/MkPagination.vue'; import MkUserInfo from '@/components/MkUserInfo.vue'; import MkPagination from '@/components/MkPagination.vue'; -import type { Paging } from '@/components/MkPagination.vue'; import { i18n } from '@/i18n.js'; import { infoImageUrl } from '@/instance.js'; diff --git a/packages/frontend/src/components/MkUserPopup.vue b/packages/frontend/src/components/MkUserPopup.vue index 740202f28b..17a882a3a6 100644 --- a/packages/frontend/src/components/MkUserPopup.vue +++ b/packages/frontend/src/components/MkUserPopup.vue @@ -5,15 +5,15 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <Transition - :enterActiveClass="defaultStore.state.animation ? $style.transition_popup_enterActive : ''" - :leaveActiveClass="defaultStore.state.animation ? $style.transition_popup_leaveActive : ''" - :enterFromClass="defaultStore.state.animation ? $style.transition_popup_enterFrom : ''" - :leaveToClass="defaultStore.state.animation ? $style.transition_popup_leaveTo : ''" + :enterActiveClass="prefer.s.animation ? $style.transition_popup_enterActive : ''" + :leaveActiveClass="prefer.s.animation ? $style.transition_popup_leaveActive : ''" + :enterFromClass="prefer.s.animation ? $style.transition_popup_enterFrom : ''" + :leaveToClass="prefer.s.animation ? $style.transition_popup_leaveTo : ''" appear @afterLeave="emit('closed')" > <div v-if="showing" :class="$style.root" class="_popup _shadow" :style="{ zIndex, top: top + 'px', left: left + 'px' }" @mouseover="() => { emit('mouseover'); }" @mouseleave="() => { emit('mouseleave'); }"> <div v-if="user != null"> - <div :class="$style.banner" :style="user.bannerUrl ? `background-image: url(${defaultStore.state.disableShowingAnimatedImages ? getStaticImageUrl(user.bannerUrl) : user.bannerUrl})` : ''"> + <div :class="$style.banner" :style="user.bannerUrl ? `background-image: url(${prefer.s.disableShowingAnimatedImages ? getStaticImageUrl(user.bannerUrl) : user.bannerUrl})` : ''"> <span v-if="$i && $i.id != user.id && user.isFollowed" :class="$style.followed">{{ i18n.ts.followsYou }}</span> </div> <svg viewBox="0 0 128 128" :class="$style.avatarBack"> @@ -60,14 +60,14 @@ import * as Misskey from 'misskey-js'; import MkFollowButton from '@/components/MkFollowButton.vue'; import { userPage } from '@/filters/user.js'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { getUserMenu } from '@/scripts/get-user-menu.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { getUserMenu } from '@/utility/get-user-menu.js'; import number from '@/filters/number.js'; import { i18n } from '@/i18n.js'; -import { defaultStore } from '@/store.js'; -import { $i } from '@/account.js'; -import { isFollowingVisibleForMe, isFollowersVisibleForMe } from '@/scripts/isFfVisibleForMe.js'; -import { getStaticImageUrl } from '@/scripts/media-proxy.js'; +import { prefer } from '@/preferences.js'; +import { $i } from '@/i.js'; +import { isFollowingVisibleForMe, isFollowersVisibleForMe } from '@/utility/isFfVisibleForMe.js'; +import { getStaticImageUrl } from '@/utility/media-proxy.js'; const props = defineProps<{ showing: boolean; @@ -220,7 +220,7 @@ onMounted(() => { .statusItemLabel { font-size: 0.7em; - color: var(--MI_THEME-fgTransparentWeak); + color: color(from var(--MI_THEME-fg) srgb r g b / 0.75); } .menu { diff --git a/packages/frontend/src/components/MkUserSelectDialog.vue b/packages/frontend/src/components/MkUserSelectDialog.vue index 1e93d9dbea..057af49a36 100644 --- a/packages/frontend/src/components/MkUserSelectDialog.vue +++ b/packages/frontend/src/components/MkUserSelectDialog.vue @@ -61,16 +61,16 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted, ref, computed, shallowRef } from 'vue'; +import { onMounted, ref, computed, useTemplateRef } from 'vue'; import * as Misskey from 'misskey-js'; import { host as currentHost, hostname } from '@@/js/config.js'; import MkInput from '@/components/MkInput.vue'; import FormSplit from '@/components/form/split.vue'; import MkModalWindow from '@/components/MkModalWindow.vue'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { defaultStore } from '@/store.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { store } from '@/store.js'; import { i18n } from '@/i18n.js'; -import { $i } from '@/account.js'; +import { $i } from '@/i.js'; import { instance } from '@/instance.js'; const emit = defineEmits<{ @@ -94,7 +94,7 @@ const host = ref(''); const users = ref<Misskey.entities.UserLite[]>([]); const recentUsers = ref<Misskey.entities.UserDetailed[]>([]); const selected = ref<Misskey.entities.UserLite | null>(null); -const dialogEl = shallowRef<InstanceType<typeof MkModalWindow>>(); +const dialogEl = useTemplateRef('dialogEl'); function search() { if (username.value === '' && host.value === '') { @@ -128,10 +128,10 @@ async function ok() { dialogEl.value?.close(); // 最近使ったユーザー更新 - let recents = defaultStore.state.recentlyUsedUsers; + let recents = store.s.recentlyUsedUsers; recents = recents.filter(x => x !== selected.value?.id); recents.unshift(selected.value.id); - defaultStore.set('recentlyUsedUsers', recents.splice(0, 16)); + store.set('recentlyUsedUsers', recents.splice(0, 16)); } function cancel() { @@ -141,7 +141,7 @@ function cancel() { onMounted(() => { misskeyApi('users/show', { - userIds: defaultStore.state.recentlyUsedUsers, + userIds: store.s.recentlyUsedUsers, }).then(foundUsers => { let _users = foundUsers; _users = _users.filter((u) => { diff --git a/packages/frontend/src/components/MkUserSetupDialog.Privacy.vue b/packages/frontend/src/components/MkUserSetupDialog.Privacy.vue index 62e5d1da8a..629bc30667 100644 --- a/packages/frontend/src/components/MkUserSetupDialog.Privacy.vue +++ b/packages/frontend/src/components/MkUserSetupDialog.Privacy.vue @@ -49,7 +49,7 @@ import { i18n } from '@/i18n.js'; import MkSwitch from '@/components/MkSwitch.vue'; import MkInfo from '@/components/MkInfo.vue'; import MkFolder from '@/components/MkFolder.vue'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; const isLocked = ref(false); const hideOnlineStatus = ref(false); diff --git a/packages/frontend/src/components/MkUserSetupDialog.Profile.vue b/packages/frontend/src/components/MkUserSetupDialog.Profile.vue index 7cb48f6afb..30925b854c 100644 --- a/packages/frontend/src/components/MkUserSetupDialog.Profile.vue +++ b/packages/frontend/src/components/MkUserSetupDialog.Profile.vue @@ -37,11 +37,11 @@ import MkInput from '@/components/MkInput.vue'; import MkTextarea from '@/components/MkTextarea.vue'; import FormSlot from '@/components/form/slot.vue'; import MkInfo from '@/components/MkInfo.vue'; -import { chooseFileFromPc } from '@/scripts/select-file.js'; +import { chooseFileFromPc } from '@/utility/select-file.js'; import * as os from '@/os.js'; -import { signinRequired } from '@/account.js'; +import { ensureSignin } from '@/i.js'; -const $i = signinRequired(); +const $i = ensureSignin(); const name = ref($i.name ?? ''); const description = ref($i.description ?? ''); diff --git a/packages/frontend/src/components/MkUserSetupDialog.User.vue b/packages/frontend/src/components/MkUserSetupDialog.User.vue index 004edab630..4accc6183b 100644 --- a/packages/frontend/src/components/MkUserSetupDialog.User.vue +++ b/packages/frontend/src/components/MkUserSetupDialog.User.vue @@ -29,7 +29,7 @@ import * as Misskey from 'misskey-js'; import { ref } from 'vue'; import MkButton from '@/components/MkButton.vue'; import { i18n } from '@/i18n.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; const props = defineProps<{ user: Misskey.entities.UserDetailed; diff --git a/packages/frontend/src/components/MkUserSetupDialog.vue b/packages/frontend/src/components/MkUserSetupDialog.vue index b7261129ef..767f5c591a 100644 --- a/packages/frontend/src/components/MkUserSetupDialog.vue +++ b/packages/frontend/src/components/MkUserSetupDialog.vue @@ -128,7 +128,8 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { ref, shallowRef, watch, nextTick, defineAsyncComponent } from 'vue'; +import { ref, useTemplateRef, watch, nextTick, defineAsyncComponent } from 'vue'; +import { host } from '@@/js/config.js'; import MkModalWindow from '@/components/MkModalWindow.vue'; import MkButton from '@/components/MkButton.vue'; import XProfile from '@/components/MkUserSetupDialog.Profile.vue'; @@ -137,22 +138,20 @@ import XPrivacy from '@/components/MkUserSetupDialog.Privacy.vue'; import MkAnimBg from '@/components/MkAnimBg.vue'; import { i18n } from '@/i18n.js'; import { instance } from '@/instance.js'; -import { host } from '@@/js/config.js'; import MkPushNotificationAllowButton from '@/components/MkPushNotificationAllowButton.vue'; -import { defaultStore } from '@/store.js'; +import { store } from '@/store.js'; import * as os from '@/os.js'; const emit = defineEmits<{ (ev: 'closed'): void; }>(); -const dialog = shallowRef<InstanceType<typeof MkModalWindow>>(); - -// eslint-disable-next-line vue/no-setup-props-reactivity-loss -const page = ref(defaultStore.state.accountSetupWizard); +const dialog = useTemplateRef('dialog'); + +const page = ref(store.s.accountSetupWizard); watch(page, () => { - defaultStore.set('accountSetupWizard', page.value); + store.set('accountSetupWizard', page.value); }); async function close(skip: boolean) { @@ -165,11 +164,11 @@ async function close(skip: boolean) { } dialog.value?.close(); - defaultStore.set('accountSetupWizard', -1); + store.set('accountSetupWizard', -1); } function setupComplete() { - defaultStore.set('accountSetupWizard', -1); + store.set('accountSetupWizard', -1); dialog.value?.close(); } @@ -194,7 +193,7 @@ async function later(later: boolean) { } dialog.value?.close(); - defaultStore.set('accountSetupWizard', 0); + store.set('accountSetupWizard', 0); } </script> diff --git a/packages/frontend/src/components/MkVisibilityPicker.vue b/packages/frontend/src/components/MkVisibilityPicker.vue index 650e639c4f..cb402b1a57 100644 --- a/packages/frontend/src/components/MkVisibilityPicker.vue +++ b/packages/frontend/src/components/MkVisibilityPicker.vue @@ -42,12 +42,12 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { nextTick, shallowRef, ref } from 'vue'; +import { nextTick, useTemplateRef, ref } from 'vue'; import * as Misskey from 'misskey-js'; import MkModal from '@/components/MkModal.vue'; import { i18n } from '@/i18n.js'; -const modal = shallowRef<InstanceType<typeof MkModal>>(); +const modal = useTemplateRef('modal'); const props = withDefaults(defineProps<{ currentVisibility: typeof Misskey.noteVisibilities[number]; diff --git a/packages/frontend/src/components/MkVisitorDashboard.ActiveUsersChart.vue b/packages/frontend/src/components/MkVisitorDashboard.ActiveUsersChart.vue index d098dad9a1..79c9e739c4 100644 --- a/packages/frontend/src/components/MkVisitorDashboard.ActiveUsersChart.vue +++ b/packages/frontend/src/components/MkVisitorDashboard.ActiveUsersChart.vue @@ -13,19 +13,19 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted, shallowRef, ref, nextTick } from 'vue'; +import { onMounted, useTemplateRef, ref, nextTick } from 'vue'; import { Chart } from 'chart.js'; import gradient from 'chartjs-plugin-gradient'; import tinycolor from 'tinycolor2'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { defaultStore } from '@/store.js'; -import { useChartTooltip } from '@/scripts/use-chart-tooltip.js'; -import { chartVLine } from '@/scripts/chart-vline.js'; -import { initChart } from '@/scripts/init-chart.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { store } from '@/store.js'; +import { useChartTooltip } from '@/use/use-chart-tooltip.js'; +import { chartVLine } from '@/utility/chart-vline.js'; +import { initChart } from '@/utility/init-chart.js'; initChart(); -const chartEl = shallowRef<HTMLCanvasElement | null>(null); +const chartEl = useTemplateRef('chartEl'); const now = new Date(); let chartInstance: Chart | null = null; const chartLimit = 30; @@ -59,9 +59,9 @@ async function renderChart() { await nextTick(); - const vLineColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)'; + const vLineColor = store.s.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)'; - const computedStyle = getComputedStyle(document.documentElement); + const computedStyle = getComputedStyle(window.document.documentElement); const accent = tinycolor(computedStyle.getPropertyValue('--MI_THEME-accent')).toHexString(); const colorRead = accent; diff --git a/packages/frontend/src/components/MkVisitorDashboard.vue b/packages/frontend/src/components/MkVisitorDashboard.vue index 898ab75fc0..1a4d14a3f0 100644 --- a/packages/frontend/src/components/MkVisitorDashboard.vue +++ b/packages/frontend/src/components/MkVisitorDashboard.vue @@ -62,7 +62,7 @@ import MkTimeline from '@/components/MkTimeline.vue'; import MkInfo from '@/components/MkInfo.vue'; import { instanceName } from '@@/js/config.js'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; import { instance } from '@/instance.js'; import MkNumber from '@/components/MkNumber.vue'; @@ -181,7 +181,7 @@ function showMenu(ev: MouseEvent) { } .statsItemLabel { - color: var(--MI_THEME-fgTransparentWeak); + color: color(from var(--MI_THEME-fg) srgb r g b / 0.75); font-size: 0.9em; } diff --git a/packages/frontend/src/components/MkWaitingDialog.vue b/packages/frontend/src/components/MkWaitingDialog.vue index 34fa6b0723..820cf05e1f 100644 --- a/packages/frontend/src/components/MkWaitingDialog.vue +++ b/packages/frontend/src/components/MkWaitingDialog.vue @@ -14,15 +14,15 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { watch, shallowRef } from 'vue'; +import { watch, useTemplateRef } from 'vue'; import MkModal from '@/components/MkModal.vue'; -const modal = shallowRef<InstanceType<typeof MkModal>>(); +const modal = useTemplateRef('modal'); const props = defineProps<{ success: boolean; showing: boolean; - text?: string; + text?: string | null; }>(); const emit = defineEmits<{ diff --git a/packages/frontend/src/components/MkWindow.vue b/packages/frontend/src/components/MkWindow.vue index 2953f656d4..e5ac791d0b 100644 --- a/packages/frontend/src/components/MkWindow.vue +++ b/packages/frontend/src/components/MkWindow.vue @@ -5,10 +5,10 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <Transition - :enterActiveClass="defaultStore.state.animation ? $style.transition_window_enterActive : ''" - :leaveActiveClass="defaultStore.state.animation ? $style.transition_window_leaveActive : ''" - :enterFromClass="defaultStore.state.animation ? $style.transition_window_enterFrom : ''" - :leaveToClass="defaultStore.state.animation ? $style.transition_window_leaveTo : ''" + :enterActiveClass="prefer.s.animation ? $style.transition_window_enterActive : ''" + :leaveActiveClass="prefer.s.animation ? $style.transition_window_leaveActive : ''" + :enterFromClass="prefer.s.animation ? $style.transition_window_enterFrom : ''" + :leaveToClass="prefer.s.animation ? $style.transition_window_leaveTo : ''" appear @afterLeave="emit('closed')" > @@ -53,12 +53,12 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onBeforeUnmount, onMounted, provide, shallowRef, ref } from 'vue'; +import { onBeforeUnmount, onMounted, provide, useTemplateRef, ref } from 'vue'; import type { MenuItem } from '@/types/menu.js'; -import contains from '@/scripts/contains.js'; +import contains from '@/utility/contains.js'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; -import { defaultStore } from '@/store.js'; +import { prefer } from '@/preferences.js'; type WindowButton = { title: string; @@ -114,7 +114,7 @@ const emit = defineEmits<{ provide('inWindow', true); -const rootEl = shallowRef<HTMLElement | null>(); +const rootEl = useTemplateRef('rootEl'); const showing = ref(true); let beforeClickedAt = 0; const maximized = ref(false); @@ -240,7 +240,7 @@ function onHeaderMousedown(evt: MouseEvent | TouchEvent) { const main = rootEl.value; if (main == null) return; - if (!contains(main, document.activeElement)) main.focus(); + if (!contains(main, window.document.activeElement)) main.focus(); const position = main.getBoundingClientRect(); diff --git a/packages/frontend/src/components/MkYouTubePlayer.vue b/packages/frontend/src/components/MkYouTubePlayer.vue index 1122976436..ab62a5113d 100644 --- a/packages/frontend/src/components/MkYouTubePlayer.vue +++ b/packages/frontend/src/components/MkYouTubePlayer.vue @@ -11,7 +11,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <div class="poamfof"> - <Transition :name="defaultStore.state.animation ? 'fade' : ''" mode="out-in"> + <Transition :name="prefer.s.animation ? 'fade' : ''" mode="out-in"> <div v-if="player.url && (player.url.startsWith('http://') || player.url.startsWith('https://'))" class="player"> <iframe v-if="!fetching" :src="transformPlayerUrl(player.url)" frameborder="0" allow="autoplay; encrypted-media" allowfullscreen></iframe> </div> @@ -25,10 +25,10 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { ref } from 'vue'; -import MkWindow from '@/components/MkWindow.vue'; import { versatileLang } from '@@/js/intl-const.js'; -import { transformPlayerUrl } from '@/scripts/player-url-transform.js'; -import { defaultStore } from '@/store.js'; +import MkWindow from '@/components/MkWindow.vue'; +import { transformPlayerUrl } from '@/utility/player-url-transform.js'; +import { prefer } from '@/preferences.js'; const props = defineProps<{ url: string; diff --git a/packages/frontend/src/components/form/link.vue b/packages/frontend/src/components/form/link.vue index 8fa9e4affb..e60155f4af 100644 --- a/packages/frontend/src/components/form/link.vue +++ b/packages/frontend/src/components/form/link.vue @@ -70,7 +70,7 @@ const props = defineProps<{ margin-right: 0.75em; flex-shrink: 0; text-align: center; - color: var(--MI_THEME-fgTransparentWeak); + color: color(from var(--MI_THEME-fg) srgb r g b / 0.75); &:empty { display: none; diff --git a/packages/frontend/src/components/form/section.vue b/packages/frontend/src/components/form/section.vue index 5fca3acc31..b23ed51a83 100644 --- a/packages/frontend/src/components/form/section.vue +++ b/packages/frontend/src/components/form/section.vue @@ -49,7 +49,7 @@ defineProps<{ .description { font-size: 0.85em; - color: var(--MI_THEME-fgTransparentWeak); + color: color(from var(--MI_THEME-fg) srgb r g b / 0.75); margin: 0 0 8px 0; } </style> diff --git a/packages/frontend/src/components/form/slot.vue b/packages/frontend/src/components/form/slot.vue index da94b7abbb..cc3af9aca4 100644 --- a/packages/frontend/src/components/form/slot.vue +++ b/packages/frontend/src/components/form/slot.vue @@ -35,7 +35,7 @@ function focus() { .caption { font-size: 0.85em; padding: 8px 0 0 0; - color: var(--MI_THEME-fgTransparentWeak); + color: color(from var(--MI_THEME-fg) srgb r g b / 0.75); &:empty { display: none; diff --git a/packages/frontend/src/components/global/MkA.stories.impl.ts b/packages/frontend/src/components/global/MkA.stories.impl.ts index 1ccf105dbb..deb2b8a52b 100644 --- a/packages/frontend/src/components/global/MkA.stories.impl.ts +++ b/packages/frontend/src/components/global/MkA.stories.impl.ts @@ -7,7 +7,7 @@ import { expect, userEvent, within } from '@storybook/test'; import type { StoryObj } from '@storybook/vue3'; import MkA from './MkA.vue'; -import { tick } from '@/scripts/test-utils.js'; +import { tick } from '@/utility/test-utils.js'; export const Default = { render(args) { return { diff --git a/packages/frontend/src/components/global/MkA.vue b/packages/frontend/src/components/global/MkA.vue index 8eacf16d6d..4004db5b12 100644 --- a/packages/frontend/src/components/global/MkA.vue +++ b/packages/frontend/src/components/global/MkA.vue @@ -14,12 +14,12 @@ export type MkABehavior = 'window' | 'browser' | null; </script> <script lang="ts" setup> -import { computed, inject, shallowRef } from 'vue'; +import { computed, inject, useTemplateRef } from 'vue'; import { url } from '@@/js/config.js'; import * as os from '@/os.js'; -import { copyToClipboard } from '@/scripts/copy-to-clipboard.js'; +import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; import { i18n } from '@/i18n.js'; -import { useRouter } from '@/router/supplier.js'; +import { useRouter } from '@/router.js'; const props = withDefaults(defineProps<{ to: string; @@ -32,7 +32,7 @@ const props = withDefaults(defineProps<{ const behavior = props.behavior ?? inject<MkABehavior>('linkNavigationBehavior', null); -const el = shallowRef<HTMLElement>(); +const el = useTemplateRef('el'); defineExpose({ $el: el }); @@ -87,7 +87,7 @@ function openWindow() { function nav(ev: MouseEvent) { if (behavior === 'browser') { - location.href = props.to; + window.location.href = props.to; return; } diff --git a/packages/frontend/src/components/global/MkAd.vue b/packages/frontend/src/components/global/MkAd.vue index 08a78c8d81..2f55700b47 100644 --- a/packages/frontend/src/components/global/MkAd.vue +++ b/packages/frontend/src/components/global/MkAd.vue @@ -36,7 +36,6 @@ SPDX-License-Identifier: AGPL-3.0-only <button class="_textButton" @click="toggleMenu">{{ i18n.ts._ad.back }}</button> </div> </div> -<div v-else></div> </template> <script lang="ts" setup> @@ -45,14 +44,15 @@ import { url as local, host } from '@@/js/config.js'; import { i18n } from '@/i18n.js'; import { instance } from '@/instance.js'; import MkButton from '@/components/MkButton.vue'; -import { defaultStore } from '@/store.js'; +import { store } from '@/store.js'; import * as os from '@/os.js'; -import { $i } from '@/account.js'; +import { $i } from '@/i.js'; +import { prefer } from '@/preferences.js'; type Ad = (typeof instance)['ads'][number]; const props = defineProps<{ - prefer: string[]; + preferForms: string[]; specify?: Ad; }>(); @@ -66,12 +66,12 @@ const choseAd = (): Ad | null => { return props.specify; } - const allAds = instance.ads.map(ad => defaultStore.state.mutedAds.includes(ad.id) ? { + const allAds = instance.ads.map(ad => store.s.mutedAds.includes(ad.id) ? { ...ad, ratio: 0, } : ad); - let ads = allAds.filter(ad => props.prefer.includes(ad.place)); + let ads = allAds.filter(ad => props.preferForms.includes(ad.place)); if (ads.length === 0) { ads = allAds.filter(ad => ad.place === 'square'); @@ -107,12 +107,12 @@ const chosen = ref(choseAd()); const self = computed(() => chosen.value?.url.startsWith(local)); -const shouldHide = ref(!defaultStore.state.forceShowAds && $i && $i.policies.canHideAds && (props.specify == null)); +const shouldHide = ref(!prefer.s.forceShowAds && $i && $i.policies.canHideAds && (props.specify == null)); function reduceFrequency(): void { if (chosen.value == null) return; - if (defaultStore.state.mutedAds.includes(chosen.value.id)) return; - defaultStore.push('mutedAds', chosen.value.id); + if (store.s.mutedAds.includes(chosen.value.id)) return; + store.push('mutedAds', chosen.value.id); os.success(); chosen.value = choseAd(); showMenu.value = false; diff --git a/packages/frontend/src/components/global/MkAvatar.vue b/packages/frontend/src/components/global/MkAvatar.vue index 35c07bc80c..97c2069a2f 100644 --- a/packages/frontend/src/components/global/MkAvatar.vue +++ b/packages/frontend/src/components/global/MkAvatar.vue @@ -34,6 +34,8 @@ SPDX-License-Identifier: AGPL-3.0-only translate: getDecorationOffset(decoration), }" alt="" + draggable="false" + style="-webkit-user-drag: none;" > </template> </component> @@ -45,14 +47,13 @@ import * as Misskey from 'misskey-js'; import { extractAvgColorFromBlurhash } from '@@/js/extract-avg-color-from-blurhash.js'; import MkImgWithBlurhash from '../MkImgWithBlurhash.vue'; import MkA from './MkA.vue'; -import { getStaticImageUrl } from '@/scripts/media-proxy.js'; +import { getStaticImageUrl } from '@/utility/media-proxy.js'; import { acct, userPage } from '@/filters/user.js'; import MkUserOnlineIndicator from '@/components/MkUserOnlineIndicator.vue'; -import { defaultStore } from '@/store.js'; +import { prefer } from '@/preferences.js'; -const animation = ref(defaultStore.state.animation); -const squareAvatars = ref(defaultStore.state.squareAvatars); -const useBlurEffect = ref(defaultStore.state.useBlurEffect); +const animation = ref(prefer.s.animation); +const squareAvatars = ref(prefer.s.squareAvatars); const props = withDefaults(defineProps<{ user: Misskey.entities.User; @@ -75,7 +76,7 @@ const emit = defineEmits<{ (ev: 'click', v: MouseEvent): void; }>(); -const showDecoration = props.forceShowDecoration || defaultStore.state.showAvatarDecorations; +const showDecoration = props.forceShowDecoration || prefer.s.showAvatarDecorations; const bound = computed(() => props.link ? { to: userPage(props.user), target: props.target } @@ -83,7 +84,7 @@ const bound = computed(() => props.link const url = computed(() => { if (props.user.avatarUrl == null) return null; - if (defaultStore.state.disableShowingAnimatedImages || defaultStore.state.dataSaver.avatar) return getStaticImageUrl(props.user.avatarUrl); + if (prefer.s.disableShowingAnimatedImages || prefer.s.dataSaver.avatar) return getStaticImageUrl(props.user.avatarUrl); return props.user.avatarUrl; }); @@ -93,7 +94,7 @@ function onClick(ev: MouseEvent): void { } function getDecorationUrl(decoration: Omit<Misskey.entities.UserDetailed['avatarDecorations'][number], 'id'>) { - if (defaultStore.state.disableShowingAnimatedImages || defaultStore.state.dataSaver.avatar) return getStaticImageUrl(decoration.url); + if (prefer.s.disableShowingAnimatedImages || prefer.s.dataSaver.avatar) return getStaticImageUrl(decoration.url); return decoration.url; } diff --git a/packages/frontend/src/components/global/MkCustomEmoji.vue b/packages/frontend/src/components/global/MkCustomEmoji.vue index ec1d859080..dda45ceaa2 100644 --- a/packages/frontend/src/components/global/MkCustomEmoji.vue +++ b/packages/frontend/src/components/global/MkCustomEmoji.vue @@ -9,6 +9,8 @@ SPDX-License-Identifier: AGPL-3.0-only :class="[$style.root, { [$style.normal]: normal, [$style.noStyle]: noStyle }]" src="/client-assets/dummy.png" :title="alt" + draggable="false" + style="-webkit-user-drag: none;" /> <span v-else-if="errored">:{{ customEmojiName }}:</span> <img @@ -18,6 +20,7 @@ SPDX-License-Identifier: AGPL-3.0-only :alt="alt" :title="alt" decoding="async" + draggable="false" @error="errored = true" @load="errored = false" @click="onClick" @@ -27,16 +30,16 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { computed, defineAsyncComponent, inject, ref } from 'vue'; import type { MenuItem } from '@/types/menu.js'; -import { getProxiedImageUrl, getStaticImageUrl } from '@/scripts/media-proxy.js'; -import { defaultStore } from '@/store.js'; +import { getProxiedImageUrl, getStaticImageUrl } from '@/utility/media-proxy.js'; import { customEmojisMap } from '@/custom-emojis.js'; import * as os from '@/os.js'; -import { misskeyApi, misskeyApiGet } from '@/scripts/misskey-api.js'; -import { copyToClipboard } from '@/scripts/copy-to-clipboard.js'; -import * as sound from '@/scripts/sound.js'; +import { misskeyApi, misskeyApiGet } from '@/utility/misskey-api.js'; +import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; import { i18n } from '@/i18n.js'; import MkCustomEmojiDetailedDialog from '@/components/MkCustomEmojiDetailedDialog.vue'; -import { $i } from '@/account.js'; +import { $i } from '@/i.js'; +import { prefer } from '@/preferences.js'; +import { DI } from '@/di.js'; const props = defineProps<{ name: string; @@ -50,7 +53,7 @@ const props = defineProps<{ fallbackToImage?: boolean; }>(); -const react = inject<((name: string) => void) | null>('react', null); +const react = inject(DI.mfmEmojiReactCallback); const customEmojiName = computed(() => (props.name[0] === ':' ? props.name.substring(1, props.name.length - 1) : props.name).replace('@.', '')); const isLocal = computed(() => !props.host && (customEmojiName.value.endsWith('@.') || !customEmojiName.value.includes('@'))); @@ -77,7 +80,7 @@ const url = computed(() => { false, true, ); - return defaultStore.reactiveState.disableShowingAnimatedImages.value + return prefer.s.disableShowingAnimatedImages ? getStaticImageUrl(proxied) : proxied; }); @@ -97,7 +100,6 @@ function onClick(ev: MouseEvent) { icon: 'ti ti-copy', action: () => { copyToClipboard(`:${props.name}:`); - os.success(); }, }); @@ -107,7 +109,6 @@ function onClick(ev: MouseEvent) { icon: 'ti ti-plus', action: () => { react(`:${props.name}:`); - sound.playMisskeySfx('reaction'); }, }); } @@ -157,6 +158,7 @@ async function edit(name: string) { .root { height: 2em; vertical-align: middle; + -webkit-user-drag: none; transition: transform 0.2s ease; &:hover { diff --git a/packages/frontend/src/components/global/MkEmoji.vue b/packages/frontend/src/components/global/MkEmoji.vue index f0acd3bc27..fa55fd888b 100644 --- a/packages/frontend/src/components/global/MkEmoji.vue +++ b/packages/frontend/src/components/global/MkEmoji.vue @@ -12,12 +12,12 @@ SPDX-License-Identifier: AGPL-3.0-only import { computed, inject } from 'vue'; import { colorizeEmoji, getEmojiName } from '@@/js/emojilist.js'; import { char2fluentEmojiFilePath, char2twemojiFilePath } from '@@/js/emoji-base.js'; -import { defaultStore } from '@/store.js'; +import type { MenuItem } from '@/types/menu.js'; import * as os from '@/os.js'; -import { copyToClipboard } from '@/scripts/copy-to-clipboard.js'; -import * as sound from '@/scripts/sound.js'; +import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; import { i18n } from '@/i18n.js'; -import type { MenuItem } from '@/types/menu.js'; +import { prefer } from '@/preferences.js'; +import { DI } from '@/di.js'; const props = defineProps<{ emoji: string; @@ -25,11 +25,11 @@ const props = defineProps<{ menuReaction?: boolean; }>(); -const react = inject<((name: string) => void) | null>('react', null); +const react = inject(DI.mfmEmojiReactCallback, null); -const char2path = defaultStore.state.emojiStyle === 'twemoji' ? char2twemojiFilePath : char2fluentEmojiFilePath; +const char2path = prefer.s.emojiStyle === 'twemoji' ? char2twemojiFilePath : char2fluentEmojiFilePath; -const useOsNativeEmojis = computed(() => defaultStore.state.emojiStyle === 'native'); +const useOsNativeEmojis = computed(() => prefer.s.emojiStyle === 'native'); const url = computed(() => char2path(props.emoji)); const colorizedNativeEmoji = computed(() => colorizeEmoji(props.emoji)); @@ -50,7 +50,6 @@ function onClick(ev: MouseEvent) { icon: 'ti ti-copy', action: () => { copyToClipboard(props.emoji); - os.success(); }, }); @@ -60,7 +59,6 @@ function onClick(ev: MouseEvent) { icon: 'ti ti-plus', action: () => { react(props.emoji); - sound.playMisskeySfx('reaction'); }, }); } diff --git a/packages/frontend/src/components/global/MkError.vue b/packages/frontend/src/components/global/MkError.vue index c594cc752b..95ed255189 100644 --- a/packages/frontend/src/components/global/MkError.vue +++ b/packages/frontend/src/components/global/MkError.vue @@ -4,9 +4,9 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<Transition :name="defaultStore.state.animation ? '_transition_zoom' : ''" appear> +<Transition :name="prefer.s.animation ? '_transition_zoom' : ''" appear> <div :class="$style.root"> - <img :class="$style.img" :src="serverErrorImageUrl" class="_ghost"/> + <img :class="$style.img" :src="serverErrorImageUrl" draggable="false"/> <p :class="$style.text"><i class="ti ti-alert-triangle"></i> {{ i18n.ts.somethingHappened }}</p> <MkButton :class="$style.button" @click="() => emit('retry')">{{ i18n.ts.retry }}</MkButton> </div> @@ -16,7 +16,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import MkButton from '@/components/MkButton.vue'; import { i18n } from '@/i18n.js'; -import { defaultStore } from '@/store.js'; +import { prefer } from '@/preferences.js'; import { serverErrorImageUrl } from '@/instance.js'; const emit = defineEmits<{ diff --git a/packages/frontend/src/components/global/MkFooterSpacer.vue b/packages/frontend/src/components/global/MkFooterSpacer.vue deleted file mode 100644 index 1a75855fa1..0000000000 --- a/packages/frontend/src/components/global/MkFooterSpacer.vue +++ /dev/null @@ -1,32 +0,0 @@ -<!-- -SPDX-FileCopyrightText: syuilo and misskey-project -SPDX-License-Identifier: AGPL-3.0-only ---> - -<template> -<div :class="[$style.spacer, defaultStore.reactiveState.darkMode.value ? $style.dark : $style.light]"></div> -</template> - -<script lang="ts" setup> -import { defaultStore } from '@/store.js'; -</script> - -<style lang="scss" module> -.spacer { - box-sizing: border-box; - padding: 32px; - margin: 0 auto; - height: 300px; - background-clip: content-box; - background-size: auto auto; - background-color: rgba(255, 255, 255, 0); - - &.light { - background-image: repeating-linear-gradient(135deg, transparent, transparent 16px, #00000010 16px, #00000010 20px ); - } - - &.dark { - background-image: repeating-linear-gradient(135deg, transparent, transparent 16px, #FFFFFF16 16px, #FFFFFF16 20px ); - } -} -</style> diff --git a/packages/frontend/src/components/global/MkLazy.vue b/packages/frontend/src/components/global/MkLazy.vue index f35932ae77..b352296469 100644 --- a/packages/frontend/src/components/global/MkLazy.vue +++ b/packages/frontend/src/components/global/MkLazy.vue @@ -11,9 +11,9 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { nextTick, onMounted, onActivated, onBeforeUnmount, ref, shallowRef } from 'vue'; +import { nextTick, onMounted, onActivated, onBeforeUnmount, ref, useTemplateRef } from 'vue'; -const rootEl = shallowRef<HTMLDivElement>(); +const rootEl = useTemplateRef('rootEl'); const showing = ref(false); const observer = new IntersectionObserver( diff --git a/packages/frontend/src/components/global/MkMfm.ts b/packages/frontend/src/components/global/MkMfm.ts index 48d7e34d76..337e326ccd 100644 --- a/packages/frontend/src/components/global/MkMfm.ts +++ b/packages/frontend/src/components/global/MkMfm.ts @@ -3,11 +3,12 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { h, provide } from 'vue'; -import type { VNode, SetupContext } from 'vue'; +import { h } from 'vue'; import * as mfm from 'mfm-js'; import * as Misskey from 'misskey-js'; import { host } from '@@/js/config.js'; +import type { VNode, SetupContext } from 'vue'; +import type { MkABehavior } from '@/components/global/MkA.vue'; import MkUrl from '@/components/global/MkUrl.vue'; import MkTime from '@/components/global/MkTime.vue'; import MkLink from '@/components/MkLink.vue'; @@ -19,8 +20,7 @@ import MkCodeInline from '@/components/MkCodeInline.vue'; import MkGoogle from '@/components/MkGoogle.vue'; import MkSparkle from '@/components/MkSparkle.vue'; import MkA from '@/components/global/MkA.vue'; -import type { MkABehavior } from '@/components/global/MkA.vue'; -import { defaultStore } from '@/store.js'; +import { prefer } from '@/preferences.js'; function safeParseFloat(str: unknown): number | null { if (typeof str !== 'string' || str === '') return null; @@ -81,7 +81,7 @@ export default function (props: MfmProps, { emit }: { emit: SetupContext<MfmEven return c.match(/^[0-9a-f]{3,6}$/i) ? c : null; }; - const useAnim = defaultStore.state.advancedMfm && defaultStore.state.animatedMfm; + const useAnim = prefer.s.advancedMfm && prefer.s.animatedMfm; /** * Gen Vue Elements from MFM AST @@ -188,17 +188,17 @@ export default function (props: MfmProps, { emit }: { emit: SetupContext<MfmEven } case 'x2': { return h('span', { - class: defaultStore.state.advancedMfm ? 'mfm-x2' : '', + class: prefer.s.advancedMfm ? 'mfm-x2' : '', }, genEl(token.children, scale * 2)); } case 'x3': { return h('span', { - class: defaultStore.state.advancedMfm ? 'mfm-x3' : '', + class: prefer.s.advancedMfm ? 'mfm-x3' : '', }, genEl(token.children, scale * 3)); } case 'x4': { return h('span', { - class: defaultStore.state.advancedMfm ? 'mfm-x4' : '', + class: prefer.s.advancedMfm ? 'mfm-x4' : '', }, genEl(token.children, scale * 4)); } case 'font': { @@ -241,14 +241,14 @@ export default function (props: MfmProps, { emit }: { emit: SetupContext<MfmEven break; } case 'position': { - if (!defaultStore.state.advancedMfm) break; + if (!prefer.s.advancedMfm) break; const x = safeParseFloat(token.props.args.x) ?? 0; const y = safeParseFloat(token.props.args.y) ?? 0; style = `transform: translateX(${x}em) translateY(${y}em);`; break; } case 'scale': { - if (!defaultStore.state.advancedMfm) { + if (!prefer.s.advancedMfm) { style = ''; break; } diff --git a/packages/frontend/src/components/global/MkPageHeader.stories.impl.ts b/packages/frontend/src/components/global/MkPageHeader.stories.impl.ts index c9af5f4ea4..15938d0495 100644 --- a/packages/frontend/src/components/global/MkPageHeader.stories.impl.ts +++ b/packages/frontend/src/components/global/MkPageHeader.stories.impl.ts @@ -2,11 +2,10 @@ * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ - -/* eslint-disable @typescript-eslint/explicit-function-return-type */ + import { waitFor } from '@storybook/test'; -import type { StoryObj } from '@storybook/vue3'; import MkPageHeader from './MkPageHeader.vue'; +import type { StoryObj } from '@storybook/vue3'; export const Empty = { render(args) { return { @@ -29,7 +28,7 @@ export const Empty = { }; }, async play() { - const wait = new Promise((resolve) => setTimeout(resolve, 800)); + const wait = new Promise((resolve) => window.setTimeout(resolve, 800)); await waitFor(async () => await wait); }, args: { diff --git a/packages/frontend/src/components/global/MkPageHeader.tabs.vue b/packages/frontend/src/components/global/MkPageHeader.tabs.vue index aaef8b8fca..255fca8f86 100644 --- a/packages/frontend/src/components/global/MkPageHeader.tabs.vue +++ b/packages/frontend/src/components/global/MkPageHeader.tabs.vue @@ -8,13 +8,13 @@ SPDX-License-Identifier: AGPL-3.0-only <div :class="$style.tabsInner"> <button v-for="t in tabs" :ref="(el) => tabRefs[t.key] = (el as HTMLElement)" v-tooltip.noDelay="t.title" - class="_button" :class="[$style.tab, { [$style.active]: t.key != null && t.key === props.tab, [$style.animate]: defaultStore.reactiveState.animation.value }]" + class="_button" :class="[$style.tab, { [$style.active]: t.key != null && t.key === props.tab, [$style.animate]: prefer.s.animation }]" @mousedown="(ev) => onTabMousedown(t, ev)" @click="(ev) => onTabClick(t, ev)" > <div :class="$style.tabInner"> <i v-if="t.icon" :class="[$style.tabIcon, t.icon]"></i> <div - v-if="!t.iconOnly || (!defaultStore.reactiveState.animation.value && t.key === tab)" + v-if="!t.iconOnly || (!prefer.s.animation && t.key === tab)" :class="$style.tabTitle" > {{ t.title }} @@ -30,7 +30,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> <div ref="tabHighlightEl" - :class="[$style.tabHighlight, { [$style.animate]: defaultStore.reactiveState.animation.value }]" + :class="[$style.tabHighlight, { [$style.animate]: prefer.s.animation }]" ></div> </div> </template> @@ -41,20 +41,20 @@ export type Tab = { onClick?: (ev: MouseEvent) => void; } & ( | { - iconOnly?: false; - title: string; - icon?: string; - } + iconOnly?: false; + title: string; + icon?: string; + } | { - iconOnly: true; - icon: string; - } + iconOnly: true; + icon: string; + } ); </script> <script lang="ts" setup> -import { nextTick, onMounted, onUnmounted, shallowRef, watch } from 'vue'; -import { defaultStore } from '@/store.js'; +import { nextTick, onMounted, onUnmounted, useTemplateRef, watch } from 'vue'; +import { prefer } from '@/preferences.js'; const props = withDefaults(defineProps<{ tabs?: Tab[]; @@ -69,9 +69,9 @@ const emit = defineEmits<{ (ev: 'tabClick', key: string); }>(); -const el = shallowRef<HTMLElement | null>(null); +const el = useTemplateRef('el'); +const tabHighlightEl = useTemplateRef('tabHighlightEl'); const tabRefs: Record<string, HTMLElement | null> = {}; -const tabHighlightEl = shallowRef<HTMLElement | null>(null); function onTabMousedown(tab: Tab, ev: MouseEvent): void { // ユーザビリティの観点からmousedown時にはonClickは呼ばない @@ -133,7 +133,7 @@ async function enter(el: Element) { entering = false; }); - setTimeout(renderTab, 170); + window.setTimeout(renderTab, 170); } function afterEnter(el: Element) { @@ -170,7 +170,7 @@ onMounted(() => { if (props.rootEl) { ro2 = new ResizeObserver((entries, observer) => { - if (document.body.contains(el.value as HTMLElement)) { + if (window.document.body.contains(el.value as HTMLElement)) { nextTick(() => renderTab()); } }); diff --git a/packages/frontend/src/components/global/MkPageHeader.vue b/packages/frontend/src/components/global/MkPageHeader.vue index 1070c0c83b..93f46a866a 100644 --- a/packages/frontend/src/components/global/MkPageHeader.vue +++ b/packages/frontend/src/components/global/MkPageHeader.vue @@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div v-if="show" ref="el" :class="[$style.root]" :style="{ background: bg }"> +<div v-if="show" ref="el" :class="[$style.root]"> <div :class="[$style.upper, { [$style.slim]: narrow, [$style.thin]: thin_ }]"> <div v-if="!thin_ && narrow && props.displayMyAvatar && $i" class="_button" :class="$style.buttonsLeft" @click="openAccountMenu"> <MkAvatar :class="$style.avatar" :user="$i"/> @@ -41,16 +41,16 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted, onUnmounted, ref, inject, shallowRef, computed } from 'vue'; -import tinycolor from 'tinycolor2'; +import { onMounted, onUnmounted, ref, inject, useTemplateRef, computed } from 'vue'; +import { scrollToTop } from '@@/js/scroll.js'; import XTabs from './MkPageHeader.tabs.vue'; import type { Tab } from './MkPageHeader.tabs.vue'; -import { scrollToTop } from '@@/js/scroll.js'; -import { globalEvents } from '@/events.js'; -import { injectReactiveMetadata } from '@/scripts/page-metadata.js'; -import { $i, openAccountMenu as openAccountMenu_ } from '@/account.js'; import type { PageHeaderItem } from '@/types/page-header.js'; -import type { PageMetadata } from '@/scripts/page-metadata.js'; +import type { PageMetadata } from '@/page.js'; +import { globalEvents } from '@/events.js'; +import { openAccountMenu as openAccountMenu_ } from '@/accounts.js'; +import { $i } from '@/i.js'; +import { DI } from '@/di.js'; const props = withDefaults(defineProps<{ overridePageMetadata?: PageMetadata; @@ -68,14 +68,14 @@ const emit = defineEmits<{ (ev: 'update:tab', key: string); }>(); -const injectedPageMetadata = injectReactiveMetadata(); +//const viewId = inject(DI.viewId); +const injectedPageMetadata = inject(DI.pageMetadata, ref(null)); const pageMetadata = computed(() => props.overridePageMetadata ?? injectedPageMetadata.value); const hideTitle = computed(() => inject('shouldOmitHeaderTitle', false) || props.hideTitle); const thin_ = props.thin || inject('shouldHeaderThin', false); -const el = shallowRef<HTMLElement | undefined>(undefined); -const bg = ref<string | undefined>(undefined); +const el = useTemplateRef('el'); const narrow = ref(false); const hasTabs = computed(() => props.tabs.length > 0); const hasActions = computed(() => props.actions && props.actions.length > 0); @@ -103,23 +103,13 @@ function onTabClick(): void { top(); } -const calcBg = () => { - const rawBg = 'var(--MI_THEME-bg)'; - const tinyBg = tinycolor(rawBg.startsWith('var(') ? getComputedStyle(document.documentElement).getPropertyValue(rawBg.slice(4, -1)) : rawBg); - tinyBg.setAlpha(0.85); - bg.value = tinyBg.toRgbString(); -}; - let ro: ResizeObserver | null; onMounted(() => { - calcBg(); - globalEvents.on('themeChanged', calcBg); - if (el.value && el.value.parentElement) { narrow.value = el.value.parentElement.offsetWidth < 500; ro = new ResizeObserver((entries, observer) => { - if (el.value && el.value.parentElement && document.body.contains(el.value as HTMLElement)) { + if (el.value && el.value.parentElement && window.document.body.contains(el.value as HTMLElement)) { narrow.value = el.value.parentElement.offsetWidth < 500; } }); @@ -128,13 +118,13 @@ onMounted(() => { }); onUnmounted(() => { - globalEvents.off('themeChanged', calcBg); if (ro) ro.disconnect(); }); </script> <style lang="scss" module> .root { + background: color(from var(--MI_THEME-bg) srgb r g b / 0.75); -webkit-backdrop-filter: var(--MI-blur, blur(15px)); backdrop-filter: var(--MI-blur, blur(15px)); border-bottom: solid 0.5px var(--MI_THEME-divider); diff --git a/packages/frontend/src/components/global/MkSpacer.vue b/packages/frontend/src/components/global/MkSpacer.vue index db01c10eb0..c3bc37cb92 100644 --- a/packages/frontend/src/components/global/MkSpacer.vue +++ b/packages/frontend/src/components/global/MkSpacer.vue @@ -13,7 +13,8 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { inject } from 'vue'; -import { deviceKind } from '@/scripts/device-kind.js'; +import { deviceKind } from '@/utility/device-kind.js'; +import { DI } from '@/di.js'; const props = withDefaults(defineProps<{ contentMax?: number | null; @@ -25,7 +26,7 @@ const props = withDefaults(defineProps<{ marginMax: 24, }); -const forceSpacerMin = inject('forceSpacerMin', false) || deviceKind === 'smartphone'; +const forceSpacerMin = inject(DI.forceSpacerMin, false) || deviceKind === 'smartphone'; </script> <style lang="scss" module> diff --git a/packages/frontend/src/components/global/MkStickyContainer.vue b/packages/frontend/src/components/global/MkStickyContainer.vue index 7ee3952083..05245716c2 100644 --- a/packages/frontend/src/components/global/MkStickyContainer.vue +++ b/packages/frontend/src/components/global/MkStickyContainer.vue @@ -23,9 +23,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { onMounted, onUnmounted, provide, inject, ref, watch, useTemplateRef } from 'vue'; -import type { Ref } from 'vue'; - -import { CURRENT_STICKY_BOTTOM, CURRENT_STICKY_TOP } from '@@/js/const.js'; +import { DI } from '@/di.js'; const rootEl = useTemplateRef('rootEl'); const headerEl = useTemplateRef('headerEl'); @@ -33,13 +31,13 @@ const footerEl = useTemplateRef('footerEl'); const headerHeight = ref<string | undefined>(); const childStickyTop = ref(0); -const parentStickyTop = inject<Ref<number>>(CURRENT_STICKY_TOP, ref(0)); -provide(CURRENT_STICKY_TOP, childStickyTop); +const parentStickyTop = inject(DI.currentStickyTop, ref(0)); +provide(DI.currentStickyTop, childStickyTop); const footerHeight = ref<string | undefined>(); const childStickyBottom = ref(0); -const parentStickyBottom = inject<Ref<number>>(CURRENT_STICKY_BOTTOM, ref(0)); -provide(CURRENT_STICKY_BOTTOM, childStickyBottom); +const parentStickyBottom = inject(DI.currentStickyBottom, ref(0)); +provide(DI.currentStickyBottom, childStickyBottom); const calc = () => { // コンポーネントが表示されてないけどKeepAliveで残ってる場合などは null になる diff --git a/packages/frontend/src/components/global/MkUrl.vue b/packages/frontend/src/components/global/MkUrl.vue index 3d2036e376..0c248b041d 100644 --- a/packages/frontend/src/components/global/MkUrl.vue +++ b/packages/frontend/src/components/global/MkUrl.vue @@ -29,7 +29,7 @@ import { defineAsyncComponent, ref } from 'vue'; import { toUnicode as decodePunycode } from 'punycode.js'; import { url as local } from '@@/js/config.js'; import * as os from '@/os.js'; -import { useTooltip } from '@/scripts/use-tooltip.js'; +import { useTooltip } from '@/use/use-tooltip.js'; import { isEnabledUrlPreview } from '@/instance.js'; import type { MkABehavior } from '@/components/global/MkA.vue'; diff --git a/packages/frontend/src/components/global/NestedRouterView.vue b/packages/frontend/src/components/global/NestedRouterView.vue new file mode 100644 index 0000000000..af00347db8 --- /dev/null +++ b/packages/frontend/src/components/global/NestedRouterView.vue @@ -0,0 +1,60 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<Suspense :timeout="0"> + <component :is="currentPageComponent" :key="key" v-bind="Object.fromEntries(currentPageProps)"/> + + <template #fallback> + <MkLoading/> + </template> +</Suspense> +</template> + +<script lang="ts" setup> +import { inject, provide, ref, shallowRef } from 'vue'; +import type { Router } from '@/router.js'; +import type { PathResolvedResult } from '@/lib/nirax.js'; +import MkLoadingPage from '@/pages/_loading_.vue'; +import { DI } from '@/di.js'; + +const props = defineProps<{ + router?: Router; +}>(); + +const router = props.router ?? inject(DI.router); + +if (router == null) { + throw new Error('no router provided'); +} + +const currentDepth = inject(DI.routerCurrentDepth, 0); +provide(DI.routerCurrentDepth, currentDepth + 1); + +function resolveNested(current: PathResolvedResult, d = 0): PathResolvedResult | null { + if (d === currentDepth) { + return current; + } else { + if (current.child) { + return resolveNested(current.child, d + 1); + } else { + return null; + } + } +} + +const current = resolveNested(router.current)!; +const currentPageComponent = shallowRef('component' in current.route ? current.route.component : MkLoadingPage); +const currentPageProps = ref(current.props); +const key = ref(router.getCurrentFullPath()); + +router.useListener('change', ({ resolved }) => { + const current = resolveNested(resolved); + if (current == null || 'redirect' in current.route) return; + currentPageComponent.value = current.route.component; + currentPageProps.value = current.props; + key.value = router.getCurrentFullPath(); +}); +</script> diff --git a/packages/frontend/src/components/global/PageWithAnimBg.vue b/packages/frontend/src/components/global/PageWithAnimBg.vue new file mode 100644 index 0000000000..7106ae20cd --- /dev/null +++ b/packages/frontend/src/components/global/PageWithAnimBg.vue @@ -0,0 +1,29 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div> + <MkAnimBg style="position: absolute;"/> + <div class="_pageScrollable" :class="$style.body"> + <slot></slot> + </div> +</div> +</template> + +<script lang="ts" setup> +import MkAnimBg from '@/components/MkAnimBg.vue'; +</script> + +<style lang="scss" module> +.body { + position: absolute; + top: 0; + width: 100%; + height: 100%; + + // _pageScrollable はパフォーマンス上の理由で背景色が設定されているため + background: transparent !important; +} +</style> diff --git a/packages/frontend/src/components/global/PageWithHeader.vue b/packages/frontend/src/components/global/PageWithHeader.vue new file mode 100644 index 0000000000..7ea0b5c97f --- /dev/null +++ b/packages/frontend/src/components/global/PageWithHeader.vue @@ -0,0 +1,53 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div ref="rootEl" :class="[$style.root, reversed ? '_pageScrollableReversed' : '_pageScrollable']"> + <MkStickyContainer> + <template #header><MkPageHeader v-model:tab="tab" :actions="actions" :tabs="tabs"/></template> + <div :class="$style.body"> + <slot></slot> + </div> + <template #footer><slot name="footer"></slot></template> + </MkStickyContainer> +</div> +</template> + +<script lang="ts" setup> +import { useTemplateRef } from 'vue'; +import { scrollInContainer } from '@@/js/scroll.js'; +import type { PageHeaderItem } from '@/types/page-header.js'; +import type { Tab } from './MkPageHeader.tabs.vue'; + +const props = withDefaults(defineProps<{ + tabs?: Tab[]; + actions?: PageHeaderItem[] | null; + thin?: boolean; + hideTitle?: boolean; + displayMyAvatar?: boolean; + reversed?: boolean; +}>(), { + tabs: () => ([] as Tab[]), +}); + +const tab = defineModel<string>('tab'); +const rootEl = useTemplateRef('rootEl'); + +defineExpose({ + scrollToTop: () => { + if (rootEl.value) scrollInContainer(rootEl.value, { top: 0, behavior: 'smooth' }); + }, +}); +</script> + +<style lang="scss" module> +.root { + +} + +.body { + min-height: calc(100cqh - (var(--MI-stickyTop, 0px) + var(--MI-stickyBottom, 0px))); +} +</style> diff --git a/packages/frontend/src/components/global/RouterView.vue b/packages/frontend/src/components/global/RouterView.vue index 3ab3d10a40..78ac6900a3 100644 --- a/packages/frontend/src/components/global/RouterView.vue +++ b/packages/frontend/src/components/global/RouterView.vue @@ -4,98 +4,110 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<KeepAlive - :max="defaultStore.state.numberOfPageCache" - :exclude="pageCacheController" -> - <Suspense :timeout="0"> - <component :is="currentPageComponent" :key="key" v-bind="Object.fromEntries(currentPageProps)"/> +<div ref="rootEl" class="_pageContainer" :class="$style.root"> + <KeepAlive :max="prefer.s.numberOfPageCache"> + <Suspense :timeout="0"> + <component :is="currentPageComponent" :key="key" v-bind="Object.fromEntries(currentPageProps)"/> - <template #fallback> - <MkLoading/> - </template> - </Suspense> -</KeepAlive> + <template #fallback> + <MkLoading/> + </template> + </Suspense> + </KeepAlive> +</div> </template> <script lang="ts" setup> -import { inject, onBeforeUnmount, provide, ref, shallowRef, computed, nextTick } from 'vue'; -import type { IRouter, Resolved, RouteDef } from '@/nirax.js'; -import { defaultStore } from '@/store.js'; -import { globalEvents } from '@/events.js'; +import { inject, nextTick, onMounted, provide, ref, shallowRef, useTemplateRef } from 'vue'; +import type { Router } from '@/router.js'; +import { prefer } from '@/preferences.js'; import MkLoadingPage from '@/pages/_loading_.vue'; +import { DI } from '@/di.js'; +import { randomId } from '@/utility/random-id.js'; +import { deepEqual } from '@/utility/deep-equal.js'; const props = defineProps<{ - router?: IRouter; - nested?: boolean; + router?: Router; }>(); -const router = props.router ?? inject('router'); +const router = props.router ?? inject(DI.router); if (router == null) { throw new Error('no router provided'); } -const currentDepth = inject('routerCurrentDepth', 0); -provide('routerCurrentDepth', currentDepth + 1); +const viewId = randomId(); +provide(DI.viewId, viewId); -function resolveNested(current: Resolved, d = 0): Resolved | null { - if (!props.nested) return current; +const currentDepth = inject(DI.routerCurrentDepth, 0); +provide(DI.routerCurrentDepth, currentDepth + 1); - if (d === currentDepth) { - return current; - } else { - if (current.child) { - return resolveNested(current.child, d + 1); - } else { - return null; - } +const rootEl = useTemplateRef('rootEl'); +onMounted(() => { + if (prefer.s.animation) { + rootEl.value.style.viewTransitionName = viewId; // view-transition-nameにcss varが使えないっぽいため直接代入 } +}); + +// view-transition-newなどの<pt-name-selector>にはcss varが使えず、v-bindできないため直接スタイルを生成 +const viewTransitionStylesTag = window.document.createElement('style'); +viewTransitionStylesTag.textContent = ` +@keyframes ${viewId}-old { + to { transform: scale(0.95); opacity: 0; } } -const current = resolveNested(router.current)!; -const currentPageComponent = shallowRef('component' in current.route ? current.route.component : MkLoadingPage); -const currentPageProps = ref(current.props); -const key = ref(router.getCurrentKey() + JSON.stringify(Object.fromEntries(current.props))); +@keyframes ${viewId}-new { + from { transform: scale(0.95); opacity: 0; } +} -function onChange({ resolved, key: newKey }) { - const current = resolveNested(resolved); - if (current == null || 'redirect' in current.route) return; - currentPageComponent.value = current.route.component; - currentPageProps.value = current.props; - key.value = newKey + JSON.stringify(Object.fromEntries(current.props)); +::view-transition-old(${viewId}) { + animation-duration: 0.2s; + animation-name: ${viewId}-old; +} - nextTick(() => { - // ページ遷移完了後に再びキャッシュを有効化 - if (clearCacheRequested.value) { - clearCacheRequested.value = false; - } - }); +::view-transition-new(${viewId}) { + animation-duration: 0.2s; + animation-name: ${viewId}-new; } +`; -router.addListener('change', onChange); +window.document.head.appendChild(viewTransitionStylesTag); -// #region キャッシュ制御 +const current = router.current!; +const currentPageComponent = shallowRef('component' in current.route ? current.route.component : MkLoadingPage); +const currentPageProps = ref(current.props); +let currentRoutePath = current.route.path; +const key = ref(router.getCurrentFullPath()); -/** - * キャッシュクリアが有効になったら、全キャッシュをクリアする - * - * keepAlive側にwatcherがあるのですぐ消えるとはおもうけど、念のためページ遷移完了まではキャッシュを無効化しておく。 - * キャッシュ有効時向けにexcludeを使いたい場合は、pageCacheControllerに並列に突っ込むのではなく、下に追記すること - */ -const pageCacheController = computed(() => clearCacheRequested.value ? /.*/ : undefined); -const clearCacheRequested = ref(false); +router.useListener('change', ({ resolved }) => { + if (resolved == null || 'redirect' in resolved.route) return; + if (resolved.route.path === currentRoutePath && deepEqual(resolved.props, currentPageProps.value)) return; -globalEvents.on('requestClearPageCache', () => { - if (_DEV_) console.log('clear page cache requested'); - if (!clearCacheRequested.value) { - clearCacheRequested.value = true; + function _() { + currentPageComponent.value = resolved.route.component; + currentPageProps.value = resolved.props; + key.value = router.getCurrentFullPath(); + currentRoutePath = resolved.route.path; } -}); -// #endregion - -onBeforeUnmount(() => { - router.removeListener('change', onChange); + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (prefer.s.animation && window.document.startViewTransition) { + window.document.startViewTransition(() => new Promise((res) => { + _(); + nextTick(() => { + res(); + //setTimeout(res, 100); + }); + })); + } else { + _(); + } }); </script> + +<style lang="scss" module> +.root { + height: 100%; + background-color: var(--MI_THEME-bg); +} +</style> diff --git a/packages/frontend/src/components/global/SearchIcon.vue b/packages/frontend/src/components/global/SearchIcon.vue new file mode 100644 index 0000000000..27a284faf0 --- /dev/null +++ b/packages/frontend/src/components/global/SearchIcon.vue @@ -0,0 +1,14 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<slot></slot> +</template> + +<script lang="ts" setup> +</script> + +<style lang="scss" module> +</style> diff --git a/packages/frontend/src/components/global/SearchMarker.vue b/packages/frontend/src/components/global/SearchMarker.vue index c5ec626cf4..ded1f9a28b 100644 --- a/packages/frontend/src/components/global/SearchMarker.vue +++ b/packages/frontend/src/components/global/SearchMarker.vue @@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div ref="root" :class="[$style.root, { [$style.highlighted]: highlighted }]"> - <slot></slot> + <slot :isParentOfTarget="isParentOfTarget"></slot> </div> </template> @@ -21,7 +21,7 @@ import { useTemplateRef, inject, } from 'vue'; -import type { Ref } from 'vue'; +import { DI } from '@/di.js'; const props = defineProps<{ markerId?: string; @@ -36,13 +36,14 @@ const rootEl = useTemplateRef('root'); const rootElMutationObserver = new MutationObserver(() => { checkChildren(); }); -const injectedSearchMarkerId = inject<Ref<string | null>>('inAppSearchMarkerId'); +const injectedSearchMarkerId = inject(DI.inAppSearchMarkerId, null); const searchMarkerId = computed(() => injectedSearchMarkerId?.value ?? window.location.hash.slice(1)); const highlighted = ref(props.markerId === searchMarkerId.value); +const isParentOfTarget = computed(() => props.children?.includes(searchMarkerId.value)); function checkChildren() { - if (props.children?.includes(searchMarkerId.value)) { - const el = document.querySelector(`[data-in-app-search-marker-id="${searchMarkerId.value}"]`); + if (isParentOfTarget.value) { + const el = window.document.querySelector(`[data-in-app-search-marker-id="${searchMarkerId.value}"]`); highlighted.value = el == null; } } @@ -105,8 +106,8 @@ onBeforeUnmount(dispose); @keyframes blink { 0%, 100% { - background: color(from var(--MI_THEME-accent) srgb r g b / 0.05); - border: 1px solid color(from var(--MI_THEME-accent) srgb r g b / 0.7); + background: color(from var(--MI_THEME-accent) srgb r g b / 0.1); + border: 1px solid color(from var(--MI_THEME-accent) srgb r g b / 0.75); } 50% { background: transparent; diff --git a/packages/frontend/src/components/global/StackingRouterView.vue b/packages/frontend/src/components/global/StackingRouterView.vue new file mode 100644 index 0000000000..c95c74aef3 --- /dev/null +++ b/packages/frontend/src/components/global/StackingRouterView.vue @@ -0,0 +1,243 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<TransitionGroup + :enterActiveClass="prefer.s.animation ? $style.transition_x_enterActive : ''" + :leaveActiveClass="prefer.s.animation ? $style.transition_x_leaveActive : ''" + :enterFromClass="prefer.s.animation ? $style.transition_x_enterFrom : ''" + :leaveToClass="prefer.s.animation ? $style.transition_x_leaveTo : ''" + :moveClass="prefer.s.animation ? $style.transition_x_move : ''" + :duration="200" + tag="div" :class="$style.tabs" +> + <div v-for="(tab, i) in tabs" :key="tab.fullPath" :class="$style.tab" :style="{ '--i': i - 1 }"> + <div v-if="i > 0" :class="$style.tabBg" @click="back()"></div> + <div :class="$style.tabFg" @click.stop="back()"> + <div v-if="i > 0" :class="$style.tabMenu"> + <svg :class="$style.tabMenuShape" viewBox="0 0 24 16"> + <g transform="matrix(2.04108e-17,-0.333333,0.222222,1.36072e-17,21.3333,15.9989)"> + <path d="M23.997,-42C47.903,-23.457 47.997,12 47.997,12L-0.003,12L-0.003,-96C-0.003,-96 0.091,-60.543 23.997,-42Z" style="fill:var(--MI_THEME-panel);"/> + </g> + </svg> + <button :class="$style.tabMenuButton" class="_button" @click.stop="mount"><i class="ti ti-rectangle"></i></button> + <button :class="$style.tabMenuButton" class="_button" @click.stop="back"><i class="ti ti-x"></i></button> + </div> + <div v-if="i > 0" :class="$style.tabBorder"></div> + <div :class="$style.tabContent" class="_pageContainer" @click.stop=""> + <Suspense :timeout="0"> + <component :is="tab.component" v-bind="Object.fromEntries(tab.props)"/> + + <template #fallback> + <MkLoading/> + </template> + </Suspense> + </div> + </div> + </div> +</TransitionGroup> +</template> + +<script lang="ts" setup> +import { inject, provide, shallowRef } from 'vue'; +import type { Router } from '@/router.js'; +import { prefer } from '@/preferences.js'; +import MkLoadingPage from '@/pages/_loading_.vue'; +import { DI } from '@/di.js'; +import { deepEqual } from '@/utility/deep-equal.js'; + +const props = defineProps<{ + router?: Router; +}>(); + +const router = props.router ?? inject(DI.router); + +if (router == null) { + throw new Error('no router provided'); +} + +const currentDepth = inject(DI.routerCurrentDepth, 0); +provide(DI.routerCurrentDepth, currentDepth + 1); + +const tabs = shallowRef([{ + fullPath: router.getCurrentFullPath(), + routePath: router.current.route.path, + component: 'component' in router.current.route ? router.current.route.component : MkLoadingPage, + props: router.current.props, +}]); + +function mount() { + const currentTab = tabs.value[tabs.value.length - 1]; + tabs.value = [currentTab]; +} + +function back() { + const prev = tabs.value[tabs.value.length - 2]; + tabs.value = [...tabs.value.slice(0, tabs.value.length - 1)]; + router.replace(prev.fullPath); +} + +router.useListener('change', ({ resolved }) => { + const currentTab = tabs.value[tabs.value.length - 1]; + const routePath = resolved.route.path; + if (resolved == null || 'redirect' in resolved.route) return; + if (resolved.route.path === currentTab.routePath && deepEqual(resolved.props, currentTab.props)) return; + const fullPath = router.getCurrentFullPath(); + + if (tabs.value.some(tab => tab.routePath === routePath && deepEqual(resolved.props, tab.props))) { + const newTabs = []; + for (const tab of tabs.value) { + newTabs.push(tab); + + if (tab.routePath === routePath && deepEqual(resolved.props, tab.props)) { + break; + } + } + tabs.value = newTabs; + return; + } + + tabs.value = tabs.value.length >= prefer.s.numberOfPageCache ? [ + ...tabs.value.slice(1), + { + fullPath: fullPath, + routePath, + component: resolved.route.component, + props: resolved.props, + }, + ] : [...tabs.value, { + fullPath: fullPath, + routePath, + component: resolved.route.component, + props: resolved.props, + }]; +}); + +router.useListener('replace', ({ fullPath }) => { + const currentTab = tabs.value[tabs.value.length - 1]; + currentTab.fullPath = fullPath; + tabs.value = [...tabs.value.slice(0, tabs.value.length - 1), currentTab]; +}); +</script> + +<style lang="scss" module> +.transition_x_move, +.transition_x_enterActive, +.transition_x_leaveActive { + .tabBg { + transition: opacity 0.2s cubic-bezier(0,.5,.5,1), transform 0.2s cubic-bezier(0,.5,.5,1) !important; + } + + .tabFg { + transition: opacity 0.2s cubic-bezier(0,.5,.5,1), transform 0.2s cubic-bezier(0,.5,.5,1) !important; + } +} +.transition_x_enterFrom, +.transition_x_leaveTo { + .tabBg { + opacity: 0; + } + + .tabFg { + opacity: 0; + transform: translateY(100px); + } +} +.transition_x_leaveActive { + .tabFg { + //position: absolute; + } +} + +.tabs { + position: relative; + width: 100%; + height: 100%; +} + +.tab { + overflow: clip; + + &:first-child { + position: relative; + width: 100%; + height: 100%; + + .tabFg { + position: relative; + width: 100%; + height: 100%; + } + + .tabContent { + position: relative; + width: 100%; + height: 100%; + } + } + + &:not(:first-child) { + position: absolute; + top: 0; + left: 0; + height: 100%; + width: 100%; + + .tabBg { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: #0003; + -webkit-backdrop-filter: var(--MI-blur, blur(3px)); + backdrop-filter: var(--MI-blur, blur(3px)); + } + + .tabFg { + position: absolute; + bottom: 0; + left: 0; + width: 100%; + height: calc(100% - (10px + (20px * var(--i)))); + display: flex; + flex-direction: column; + } + + .tabContent { + flex: 1; + width: 100%; + height: 100%; + background: var(--MI_THEME-bg); + } + } +} + +.tabMenu { + position: relative; + margin-left: auto; + padding: 0 4px; + background: var(--MI_THEME-panel); +} + +.tabMenuShape { + position: absolute; + bottom: -1px; + left: -100%; + height: calc(100% + 1px); + width: 129%; + pointer-events: none; +} + +.tabBorder { + height: 6px; + background: var(--MI_THEME-panel); +} + +.tabMenuButton { + padding: 8px; + font-size: 13px; +} +</style> diff --git a/packages/frontend/src/components/grid/MkDataCell.vue b/packages/frontend/src/components/grid/MkDataCell.vue index c2dc05efe6..55de0df690 100644 --- a/packages/frontend/src/components/grid/MkDataCell.vue +++ b/packages/frontend/src/components/grid/MkDataCell.vue @@ -39,10 +39,12 @@ SPDX-License-Identifier: AGPL-3.0-only {{ cell.value }} </div> <div v-else-if="cellType === 'boolean'"> - <div :class="[$style.bool, { - [$style.boolTrue]: cell.value === true, - 'ti ti-check': cell.value === true, - }]"></div> + <div + :class="[$style.bool, { + [$style.boolTrue]: cell.value === true, + 'ti ti-check': cell.value === true, + }]" + ></div> </div> <div v-else-if="cellType === 'image'"> <img @@ -88,14 +90,14 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script setup lang="ts"> -import { computed, defineAsyncComponent, nextTick, onMounted, onUnmounted, ref, shallowRef, toRefs, watch } from 'vue'; -import { GridEventEmitter } from '@/components/grid/grid.js'; -import { useTooltip } from '@/scripts/use-tooltip.js'; -import * as os from '@/os.js'; -import { equalCellAddress, getCellAddress } from '@/components/grid/grid-utils.js'; +import { computed, defineAsyncComponent, nextTick, onMounted, onUnmounted, ref, useTemplateRef, toRefs, watch } from 'vue'; import type { Size } from '@/components/grid/grid.js'; import type { CellValue, GridCell } from '@/components/grid/cell.js'; import type { GridRowSetting } from '@/components/grid/row.js'; +import { GridEventEmitter } from '@/components/grid/grid.js'; +import { useTooltip } from '@/use/use-tooltip.js'; +import * as os from '@/os.js'; +import { equalCellAddress, getCellAddress } from '@/components/grid/grid-utils.js'; const emit = defineEmits<{ (ev: 'operation:beginEdit', sender: GridCell): void; @@ -111,9 +113,9 @@ const props = defineProps<{ const { cell, bus } = toRefs(props); -const rootEl = shallowRef<InstanceType<typeof HTMLTableCellElement>>(); -const contentAreaEl = shallowRef<InstanceType<typeof HTMLDivElement>>(); -const inputAreaEl = shallowRef<InstanceType<typeof HTMLDivElement>>(); +const rootEl = useTemplateRef('rootEl'); +const contentAreaEl = useTemplateRef('contentAreaEl'); +const inputAreaEl = useTemplateRef('inputAreaEl'); /** 値が編集中かどうか */ const editing = ref<boolean>(false); @@ -343,7 +345,7 @@ $cellHeight: 28px; border: solid 0.5px transparent; &.selected { - border: solid 0.5px var(--MI_THEME-accentLighten); + border: solid 0.5px hsl(from var(--MI_THEME-accent) h s calc(l + 10)); } &.ranged { diff --git a/packages/frontend/src/components/grid/MkGrid.vue b/packages/frontend/src/components/grid/MkGrid.vue index c89e23c135..c37f3df0d3 100644 --- a/packages/frontend/src/components/grid/MkGrid.vue +++ b/packages/frontend/src/components/grid/MkGrid.vue @@ -50,6 +50,12 @@ SPDX-License-Identifier: AGPL-3.0-only <script setup lang="ts"> import { computed, onMounted, ref, toRefs, watch } from 'vue'; +import type { DataSource, GridSetting, GridState, Size } from '@/components/grid/grid.js'; +import type { CellAddress, CellValue, GridCell } from '@/components/grid/cell.js'; +import type { GridContext, GridEvent } from '@/components/grid/grid-event.js'; +import type { GridColumn } from '@/components/grid/column.js'; +import type { GridRow, GridRowSetting } from '@/components/grid/row.js'; +import type { MenuItem } from '@/types/menu.js'; import { GridEventEmitter } from '@/components/grid/grid.js'; import MkDataRow from '@/components/grid/MkDataRow.vue'; import MkHeaderRow from '@/components/grid/MkHeaderRow.vue'; @@ -66,14 +72,7 @@ import { import * as os from '@/os.js'; import { createColumn } from '@/components/grid/column.js'; import { createRow, defaultGridRowSetting, resetRow } from '@/components/grid/row.js'; -import { handleKeyEvent } from '@/scripts/key-event.js'; - -import type { DataSource, GridSetting, GridState, Size } from '@/components/grid/grid.js'; -import type { CellAddress, CellValue, GridCell } from '@/components/grid/cell.js'; -import type { GridContext, GridEvent } from '@/components/grid/grid-event.js'; -import type { GridColumn } from '@/components/grid/column.js'; -import type { GridRow, GridRowSetting } from '@/components/grid/row.js'; -import type { MenuItem } from '@/types/menu.js'; +import { handleKeyEvent } from '@/utility/key-event.js'; type RowHolder = { row: GridRow, @@ -130,7 +129,7 @@ const bus = new GridEventEmitter(); * * @see {@link onResize} */ -const resizeObserver = new ResizeObserver((entries) => setTimeout(() => onResize(entries))); +const resizeObserver = new ResizeObserver((entries) => window.setTimeout(() => onResize(entries))); const rootEl = ref<InstanceType<typeof HTMLTableElement>>(); /** diff --git a/packages/frontend/src/components/grid/grid-utils.ts b/packages/frontend/src/components/grid/grid-utils.ts index 4f48af194c..9e5402354e 100644 --- a/packages/frontend/src/components/grid/grid-utils.ts +++ b/packages/frontend/src/components/grid/grid-utils.ts @@ -10,7 +10,7 @@ import { CELL_ADDRESS_NONE } from '@/components/grid/cell.js'; import type { CellAddress, CellValue, GridCell } from '@/components/grid/cell.js'; import type { GridRow } from '@/components/grid/row.js'; import type { GridContext } from '@/components/grid/grid-event.js'; -import { copyToClipboard } from '@/scripts/copy-to-clipboard.js'; +import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; import type { GridColumn, GridColumnSetting } from '@/components/grid/column.js'; export function isCellElement(elem: HTMLElement): boolean { diff --git a/packages/frontend/src/components/index.ts b/packages/frontend/src/components/index.ts index ebbad3e5b8..34cf598b84 100644 --- a/packages/frontend/src/components/index.ts +++ b/packages/frontend/src/components/index.ts @@ -16,17 +16,21 @@ import MkTime from './global/MkTime.vue'; import MkUrl from './global/MkUrl.vue'; import I18n from './global/I18n.vue'; import RouterView from './global/RouterView.vue'; +import NestedRouterView from './global/NestedRouterView.vue'; +import StackingRouterView from './global/StackingRouterView.vue'; import MkLoading from './global/MkLoading.vue'; import MkError from './global/MkError.vue'; import MkAd from './global/MkAd.vue'; import MkPageHeader from './global/MkPageHeader.vue'; import MkSpacer from './global/MkSpacer.vue'; -import MkFooterSpacer from './global/MkFooterSpacer.vue'; import MkStickyContainer from './global/MkStickyContainer.vue'; import MkLazy from './global/MkLazy.vue'; +import PageWithHeader from './global/PageWithHeader.vue'; +import PageWithAnimBg from './global/PageWithAnimBg.vue'; import SearchMarker from './global/SearchMarker.vue'; import SearchLabel from './global/SearchLabel.vue'; import SearchKeyword from './global/SearchKeyword.vue'; +import SearchIcon from './global/SearchIcon.vue'; import type { App } from 'vue'; @@ -39,6 +43,8 @@ export default function(app: App) { export const components = { I18n: I18n, RouterView: RouterView, + NestedRouterView: NestedRouterView, + StackingRouterView: StackingRouterView, Mfm: Mfm, MkA: MkA, MkAcct: MkAcct, @@ -55,18 +61,22 @@ export const components = { MkAd: MkAd, MkPageHeader: MkPageHeader, MkSpacer: MkSpacer, - MkFooterSpacer: MkFooterSpacer, MkStickyContainer: MkStickyContainer, MkLazy: MkLazy, + PageWithHeader: PageWithHeader, + PageWithAnimBg: PageWithAnimBg, SearchMarker: SearchMarker, SearchLabel: SearchLabel, SearchKeyword: SearchKeyword, + SearchIcon: SearchIcon, }; declare module '@vue/runtime-core' { export interface GlobalComponents { I18n: typeof I18n; RouterView: typeof RouterView; + NestedRouterView: typeof NestedRouterView; + StackingRouterView: typeof StackingRouterView; Mfm: typeof Mfm; MkA: typeof MkA; MkAcct: typeof MkAcct; @@ -83,11 +93,13 @@ declare module '@vue/runtime-core' { MkAd: typeof MkAd; MkPageHeader: typeof MkPageHeader; MkSpacer: typeof MkSpacer; - MkFooterSpacer: typeof MkFooterSpacer; MkStickyContainer: typeof MkStickyContainer; MkLazy: typeof MkLazy; + PageWithHeader: typeof PageWithHeader; + PageWithAnimBg: typeof PageWithAnimBg; SearchMarker: typeof SearchMarker; SearchLabel: typeof SearchLabel; SearchKeyword: typeof SearchKeyword; + SearchIcon: typeof SearchIcon; } } diff --git a/packages/frontend/src/components/page/page.note.vue b/packages/frontend/src/components/page/page.note.vue index 84436e7adb..df26874c17 100644 --- a/packages/frontend/src/components/page/page.note.vue +++ b/packages/frontend/src/components/page/page.note.vue @@ -15,7 +15,7 @@ import { onMounted, ref } from 'vue'; import * as Misskey from 'misskey-js'; import MkNote from '@/components/MkNote.vue'; import MkNoteDetailed from '@/components/MkNoteDetailed.vue'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; const props = defineProps<{ block: Misskey.entities.PageBlock, diff --git a/packages/frontend/src/components/page/page.text.vue b/packages/frontend/src/components/page/page.text.vue index e0c7956f6e..7702e250e4 100644 --- a/packages/frontend/src/components/page/page.text.vue +++ b/packages/frontend/src/components/page/page.text.vue @@ -16,7 +16,7 @@ SPDX-License-Identifier: AGPL-3.0-only import { defineAsyncComponent } from 'vue'; import * as mfm from 'mfm-js'; import * as Misskey from 'misskey-js'; -import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm.js'; +import { extractUrlFromMfm } from '@/utility/extract-url-from-mfm.js'; import { isEnabledUrlPreview } from '@/instance.js'; const MkUrlPreview = defineAsyncComponent(() => import('@/components/MkUrlPreview.vue')); diff --git a/packages/frontend/src/custom-emojis.ts b/packages/frontend/src/custom-emojis.ts index 0d03282cee..45d4b40fd7 100644 --- a/packages/frontend/src/custom-emojis.ts +++ b/packages/frontend/src/custom-emojis.ts @@ -5,8 +5,8 @@ import { shallowRef, computed, markRaw, watch } from 'vue'; import * as Misskey from 'misskey-js'; -import { misskeyApi, misskeyApiGet } from '@/scripts/misskey-api.js'; -import { get, set } from '@/scripts/idb-proxy.js'; +import { misskeyApi, misskeyApiGet } from '@/utility/misskey-api.js'; +import { get, set } from '@/utility/idb-proxy.js'; const storageCache = await get('emojis'); export const customEmojis = shallowRef<Misskey.entities.EmojiSimple[]>(Array.isArray(storageCache) ? storageCache : []); diff --git a/packages/frontend/src/deck.ts b/packages/frontend/src/deck.ts new file mode 100644 index 0000000000..9df56c52df --- /dev/null +++ b/packages/frontend/src/deck.ts @@ -0,0 +1,353 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { notificationTypes } from 'misskey-js'; +import { ref } from 'vue'; +import { v4 as uuid } from 'uuid'; +import { i18n } from './i18n.js'; +import type { BasicTimelineType } from '@/timelines.js'; +import type { SoundStore } from '@/preferences/def.js'; +import type { MenuItem } from '@/types/menu.js'; +import { deepClone } from '@/utility/clone.js'; +import { prefer } from '@/preferences.js'; +import * as os from '@/os.js'; + +export type DeckProfile = { + name: string; + id: string; + columns: Column[]; + layout: Column['id'][][]; +}; + +type ColumnWidget = { + name: string; + id: string; + data: Record<string, any>; +}; + +export const columnTypes = [ + 'main', + 'widgets', + 'notifications', + 'tl', + 'antenna', + 'list', + 'channel', + 'mentions', + 'direct', + 'roleTimeline', +] as const; + +export type ColumnType = typeof columnTypes[number]; + +export type Column = { + id: string; + type: ColumnType; + name: string | null; + width: number; + widgets?: ColumnWidget[]; + active?: boolean; + flexible?: boolean; + antennaId?: string; + listId?: string; + channelId?: string; + roleId?: string; + excludeTypes?: typeof notificationTypes[number][]; + tl?: BasicTimelineType; + withRenotes?: boolean; + withReplies?: boolean; + withSensitive?: boolean; + onlyFiles?: boolean; + soundSetting?: SoundStore; +}; + +const _currentProfile = prefer.s['deck.profiles'].find(p => p.name === prefer.s['deck.profile']); +const __currentProfile = _currentProfile ? deepClone(_currentProfile) : null; +export const columns = ref(__currentProfile ? __currentProfile.columns : []); +export const layout = ref(__currentProfile ? __currentProfile.layout : []); + +if (prefer.s['deck.profile'] == null) { + addProfile('Main'); +} + +export function forceSaveCurrentDeckProfile() { + const currentProfile = prefer.s['deck.profiles'].find(p => p.name === prefer.s['deck.profile']); + if (currentProfile == null) return; + + const newProfile = deepClone(currentProfile); + newProfile.columns = columns.value; + newProfile.layout = layout.value; + + const newProfiles = prefer.s['deck.profiles'].filter(p => p.name !== prefer.s['deck.profile']); + newProfiles.push(newProfile); + prefer.commit('deck.profiles', newProfiles); +} + +export const saveCurrentDeckProfile = () => { + forceSaveCurrentDeckProfile(); +}; + +function switchProfile(profile: DeckProfile) { + prefer.commit('deck.profile', profile.name); + const currentProfile = deepClone(profile); + columns.value = currentProfile.columns; + layout.value = currentProfile.layout; + forceSaveCurrentDeckProfile(); +} + +function addProfile(name: string) { + if (name.trim() === '') return; + if (prefer.s['deck.profiles'].find(p => p.name === name)) return; + + const newProfile: DeckProfile = { + id: uuid(), + name, + columns: [], + layout: [], + }; + prefer.commit('deck.profiles', [...prefer.s['deck.profiles'], newProfile]); + switchProfile(newProfile); +} + +function createFirstProfile() { + addProfile('Main'); +} + +export function deleteProfile(name: string): void { + const newProfiles = prefer.s['deck.profiles'].filter(p => p.name !== name); + prefer.commit('deck.profiles', newProfiles); + + if (prefer.s['deck.profiles'].length === 0) { + createFirstProfile(); + } else { + switchProfile(prefer.s['deck.profiles'][0]); + } +} + +export function addColumn(column: Column) { + if (column.name === undefined) column.name = null; + columns.value.push(column); + layout.value.push([column.id]); + saveCurrentDeckProfile(); +} + +export function removeColumn(id: Column['id']) { + columns.value = columns.value.filter(c => c.id !== id); + layout.value = layout.value.map(ids => ids.filter(_id => _id !== id)).filter(ids => ids.length > 0); + saveCurrentDeckProfile(); +} + +export function swapColumn(a: Column['id'], b: Column['id']) { + const aX = layout.value.findIndex(ids => ids.indexOf(a) !== -1); + const aY = layout.value[aX].findIndex(id => id === a); + const bX = layout.value.findIndex(ids => ids.indexOf(b) !== -1); + const bY = layout.value[bX].findIndex(id => id === b); + const newLayout = deepClone(layout.value); + newLayout[aX][aY] = b; + newLayout[bX][bY] = a; + layout.value = newLayout; + saveCurrentDeckProfile(); +} + +export function swapLeftColumn(id: Column['id']) { + const newLayout = deepClone(layout.value); + layout.value.some((ids, i) => { + if (ids.includes(id)) { + const left = layout.value[i - 1]; + if (left) { + newLayout[i - 1] = layout.value[i]; + newLayout[i] = left; + layout.value = newLayout; + } + return true; + } + return false; + }); + saveCurrentDeckProfile(); +} + +export function swapRightColumn(id: Column['id']) { + const newLayout = deepClone(layout.value); + layout.value.some((ids, i) => { + if (ids.includes(id)) { + const right = layout.value[i + 1]; + if (right) { + newLayout[i + 1] = layout.value[i]; + newLayout[i] = right; + layout.value = newLayout; + } + return true; + } + return false; + }); + saveCurrentDeckProfile(); +} + +export function swapUpColumn(id: Column['id']) { + const newLayout = deepClone(layout.value); + const idsIndex = layout.value.findIndex(ids => ids.includes(id)); + const ids = deepClone(layout.value[idsIndex]); + ids.some((x, i) => { + if (x === id) { + const up = ids[i - 1]; + if (up) { + ids[i - 1] = id; + ids[i] = up; + + newLayout[idsIndex] = ids; + layout.value = newLayout; + } + return true; + } + return false; + }); + saveCurrentDeckProfile(); +} + +export function swapDownColumn(id: Column['id']) { + const newLayout = deepClone(layout.value); + const idsIndex = layout.value.findIndex(ids => ids.includes(id)); + const ids = deepClone(layout.value[idsIndex]); + ids.some((x, i) => { + if (x === id) { + const down = ids[i + 1]; + if (down) { + ids[i + 1] = id; + ids[i] = down; + + newLayout[idsIndex] = ids; + layout.value = newLayout; + } + return true; + } + return false; + }); + saveCurrentDeckProfile(); +} + +export function stackLeftColumn(id: Column['id']) { + let newLayout = deepClone(layout.value); + const i = layout.value.findIndex(ids => ids.includes(id)); + newLayout = newLayout.map(ids => ids.filter(_id => _id !== id)); + newLayout[i - 1].push(id); + newLayout = newLayout.filter(ids => ids.length > 0); + layout.value = newLayout; + saveCurrentDeckProfile(); +} + +export function popRightColumn(id: Column['id']) { + let newLayout = deepClone(layout.value); + const i = layout.value.findIndex(ids => ids.includes(id)); + const affected = newLayout[i]; + newLayout = newLayout.map(ids => ids.filter(_id => _id !== id)); + newLayout.splice(i + 1, 0, [id]); + newLayout = newLayout.filter(ids => ids.length > 0); + layout.value = newLayout; + + const newColumns = deepClone(columns.value); + for (const column of newColumns) { + if (affected.includes(column.id)) { + column.active = true; + } + } + columns.value = newColumns; + + saveCurrentDeckProfile(); +} + +export function addColumnWidget(id: Column['id'], widget: ColumnWidget) { + const newColumns = deepClone(columns.value); + const columnIndex = columns.value.findIndex(c => c.id === id); + const column = deepClone(columns.value[columnIndex]); + if (column == null) return; + if (column.widgets == null) column.widgets = []; + column.widgets.unshift(widget); + newColumns[columnIndex] = column; + columns.value = newColumns; + saveCurrentDeckProfile(); +} + +export function removeColumnWidget(id: Column['id'], widget: ColumnWidget) { + const newColumns = deepClone(columns.value); + const columnIndex = columns.value.findIndex(c => c.id === id); + const column = deepClone(columns.value[columnIndex]); + if (column == null) return; + if (column.widgets == null) column.widgets = []; + column.widgets = column.widgets.filter(w => w.id !== widget.id); + newColumns[columnIndex] = column; + columns.value = newColumns; + saveCurrentDeckProfile(); +} + +export function setColumnWidgets(id: Column['id'], widgets: ColumnWidget[]) { + const newColumns = deepClone(columns.value); + const columnIndex = columns.value.findIndex(c => c.id === id); + const column = deepClone(columns.value[columnIndex]); + if (column == null) return; + column.widgets = widgets; + newColumns[columnIndex] = column; + columns.value = newColumns; + saveCurrentDeckProfile(); +} + +export function updateColumnWidget(id: Column['id'], widgetId: string, widgetData: any) { + const newColumns = deepClone(columns.value); + const columnIndex = columns.value.findIndex(c => c.id === id); + const column = deepClone(columns.value[columnIndex]); + if (column == null) return; + if (column.widgets == null) column.widgets = []; + column.widgets = column.widgets.map(w => w.id === widgetId ? { + ...w, + data: widgetData, + } : w); + newColumns[columnIndex] = column; + columns.value = newColumns; + saveCurrentDeckProfile(); +} + +export function updateColumn(id: Column['id'], column: Partial<Column>) { + const newColumns = deepClone(columns.value); + const columnIndex = columns.value.findIndex(c => c.id === id); + const currentColumn = deepClone(columns.value[columnIndex]); + if (currentColumn == null) return; + for (const [k, v] of Object.entries(column)) { + currentColumn[k] = v; + } + newColumns[columnIndex] = currentColumn; + columns.value = newColumns; + saveCurrentDeckProfile(); +} + +export function switchProfileMenu(ev: MouseEvent) { + const items: MenuItem[] = prefer.s['deck.profile'] ? [{ + text: prefer.s['deck.profile'], + active: true, + action: () => {}, + }] : []; + + const profiles = prefer.s['deck.profiles']; + + items.push(...(profiles.filter(p => p.name !== prefer.s['deck.profile']).map(p => ({ + text: p.name, + action: () => { + switchProfile(p); + }, + }))), { type: 'divider' as const }, { + text: i18n.ts._deck.newProfile, + icon: 'ti ti-plus', + action: async () => { + const { canceled, result: name } = await os.inputText({ + title: i18n.ts._deck.profile, + minLength: 1, + }); + + if (canceled || name == null || name.trim() === '') return; + + addProfile(name); + }, + }); + + os.popupMenu(items, ev.currentTarget ?? ev.target); +} diff --git a/packages/frontend/src/di.ts b/packages/frontend/src/di.ts new file mode 100644 index 0000000000..58a2cce207 --- /dev/null +++ b/packages/frontend/src/di.ts @@ -0,0 +1,21 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import type { InjectionKey, Ref } from 'vue'; +import type { Router } from '@/router.js'; + +export const DI = { + routerCurrentDepth: Symbol() as InjectionKey<number>, + router: Symbol() as InjectionKey<Router>, + mock: Symbol() as InjectionKey<boolean>, + pageMetadata: Symbol() as InjectionKey<Ref<Record<string, any> | null>>, + viewId: Symbol() as InjectionKey<string>, + currentStickyTop: Symbol() as InjectionKey<Ref<number>>, + currentStickyBottom: Symbol() as InjectionKey<Ref<number>>, + mfmEmojiReactCallback: Symbol() as InjectionKey<(emoji: string) => void>, + inModal: Symbol() as InjectionKey<boolean>, + inAppSearchMarkerId: Symbol() as InjectionKey<Ref<string | null>>, + forceSpacerMin: Symbol() as InjectionKey<boolean>, +}; diff --git a/packages/frontend/src/directives/adaptive-bg.ts b/packages/frontend/src/directives/adaptive-bg.ts index 98e3d91b29..a68cd1b18b 100644 --- a/packages/frontend/src/directives/adaptive-bg.ts +++ b/packages/frontend/src/directives/adaptive-bg.ts @@ -4,7 +4,7 @@ */ import type { Directive } from 'vue'; -import { getBgColor } from '@/scripts/get-bg-color.js'; +import { getBgColor } from '@/utility/get-bg-color.js'; export default { mounted(src, binding, vn) { diff --git a/packages/frontend/src/directives/adaptive-border.ts b/packages/frontend/src/directives/adaptive-border.ts index 4037076cae..8072a1ffd9 100644 --- a/packages/frontend/src/directives/adaptive-border.ts +++ b/packages/frontend/src/directives/adaptive-border.ts @@ -4,18 +4,33 @@ */ import type { Directive } from 'vue'; -import { getBgColor } from '@/scripts/get-bg-color.js'; +import { getBgColor } from '@/utility/get-bg-color.js'; +import { globalEvents } from '@/events.js'; + +const handlerMap = new WeakMap<any, any>(); export default { mounted(src, binding, vn) { - const parentBg = getBgColor(src.parentElement) ?? 'transparent'; + function calc() { + const parentBg = getBgColor(src.parentElement) ?? 'transparent'; - const myBg = window.getComputedStyle(src).backgroundColor; + const myBg = window.getComputedStyle(src).backgroundColor; - if (parentBg === myBg) { - src.style.borderColor = 'var(--MI_THEME-divider)'; - } else { - src.style.borderColor = myBg; + if (parentBg === myBg) { + src.style.borderColor = 'var(--MI_THEME-divider)'; + } else { + src.style.borderColor = myBg; + } } + + handlerMap.set(src, calc); + + calc(); + + globalEvents.on('themeChanged', calc); + }, + + unmounted(src, binding, vn) { + globalEvents.off('themeChanged', handlerMap.get(src)); }, } as Directive; diff --git a/packages/frontend/src/directives/click-anime.ts b/packages/frontend/src/directives/click-anime.ts index 60242837f2..c34f351fb3 100644 --- a/packages/frontend/src/directives/click-anime.ts +++ b/packages/frontend/src/directives/click-anime.ts @@ -4,11 +4,11 @@ */ import type { Directive } from 'vue'; -import { defaultStore } from '@/store.js'; +import { prefer } from '@/preferences.js'; export default { mounted(el: HTMLElement, binding, vn) { - if (!defaultStore.state.animation) return; + if (!prefer.s.animation) return; const target = el.children[0]; diff --git a/packages/frontend/src/directives/hotkey.ts b/packages/frontend/src/directives/hotkey.ts index ec00652381..63637ab2ba 100644 --- a/packages/frontend/src/directives/hotkey.ts +++ b/packages/frontend/src/directives/hotkey.ts @@ -4,7 +4,7 @@ */ import type { Directive } from 'vue'; -import { makeHotkey } from '@/scripts/hotkey.js'; +import { makeHotkey } from '@/utility/hotkey.js'; export default { mounted(el, binding) { @@ -13,7 +13,7 @@ export default { el._keyHandler = makeHotkey(binding.value); if (el._hotkey_global) { - document.addEventListener('keydown', el._keyHandler, { passive: false }); + window.document.addEventListener('keydown', el._keyHandler, { passive: false }); } else { el.addEventListener('keydown', el._keyHandler, { passive: false }); } @@ -21,7 +21,7 @@ export default { unmounted(el) { if (el._hotkey_global) { - document.removeEventListener('keydown', el._keyHandler); + window.document.removeEventListener('keydown', el._keyHandler); } else { el.removeEventListener('keydown', el._keyHandler); } diff --git a/packages/frontend/src/directives/panel.ts b/packages/frontend/src/directives/panel.ts index 19fd374861..0af19e6ca3 100644 --- a/packages/frontend/src/directives/panel.ts +++ b/packages/frontend/src/directives/panel.ts @@ -4,13 +4,13 @@ */ import type { Directive } from 'vue'; -import { getBgColor } from '@/scripts/get-bg-color.js'; +import { getBgColor } from '@/utility/get-bg-color.js'; export default { mounted(src, binding, vn) { const parentBg = getBgColor(src.parentElement) ?? 'transparent'; - const myBg = getComputedStyle(document.documentElement).getPropertyValue('--MI_THEME-panel'); + const myBg = getComputedStyle(window.document.documentElement).getPropertyValue('--MI_THEME-panel'); if (parentBg === myBg) { src.style.backgroundColor = 'var(--MI_THEME-bg)'; diff --git a/packages/frontend/src/directives/ripple.ts b/packages/frontend/src/directives/ripple.ts index 99845c57c3..614cd37011 100644 --- a/packages/frontend/src/directives/ripple.ts +++ b/packages/frontend/src/directives/ripple.ts @@ -4,14 +4,14 @@ */ import MkRippleEffect from '@/components/MkRippleEffect.vue'; -import { defaultStore } from '@/store.js'; +import { prefer } from '@/preferences.js'; import { popup } from '@/os.js'; export default { mounted(el, binding, vn) { // 明示的に false であればバインドしない if (binding.value === false) return; - if (!defaultStore.state.animation) return; + if (!prefer.s.animation) return; el.addEventListener('click', () => { const rect = el.getBoundingClientRect(); diff --git a/packages/frontend/src/directives/tooltip.ts b/packages/frontend/src/directives/tooltip.ts index 6bfe6ac31d..750acd0588 100644 --- a/packages/frontend/src/directives/tooltip.ts +++ b/packages/frontend/src/directives/tooltip.ts @@ -8,7 +8,7 @@ import { defineAsyncComponent, ref } from 'vue'; import type { Directive } from 'vue'; -import { isTouchUsing } from '@/scripts/touch.js'; +import { isTouchUsing } from '@/utility/touch.js'; import { popup, alert } from '@/os.js'; const start = isTouchUsing ? 'touchstart' : 'mouseenter'; @@ -47,7 +47,7 @@ export default { } self.show = () => { - if (!document.body.contains(el)) return; + if (!window.document.body.contains(el)) return; if (self._close) return; if (self.text == null) return; diff --git a/packages/frontend/src/directives/user-preview.ts b/packages/frontend/src/directives/user-preview.ts index 43a93a0865..94deea82c7 100644 --- a/packages/frontend/src/directives/user-preview.ts +++ b/packages/frontend/src/directives/user-preview.ts @@ -31,7 +31,7 @@ export class UserPreview { } private show() { - if (!document.body.contains(this.el)) return; + if (!window.document.body.contains(this.el)) return; if (this.promise) return; const showing = ref(true); @@ -58,7 +58,7 @@ export class UserPreview { }; this.checkTimer = window.setInterval(() => { - if (!document.body.contains(this.el)) { + if (!window.document.body.contains(this.el)) { window.clearTimeout(this.showTimer); window.clearTimeout(this.hideTimer); this.close(); diff --git a/packages/frontend/src/events.ts b/packages/frontend/src/events.ts index d476aec04a..dfd3d4120c 100644 --- a/packages/frontend/src/events.ts +++ b/packages/frontend/src/events.ts @@ -7,7 +7,7 @@ import { EventEmitter } from 'eventemitter3'; import * as Misskey from 'misskey-js'; export const globalEvents = new EventEmitter<{ + themeChanging: () => void; themeChanged: () => void; clientNotification: (notification: Misskey.entities.Notification) => void; - requestClearPageCache: () => void; }>(); diff --git a/packages/frontend/src/filters/number.ts b/packages/frontend/src/filters/number.ts index 10fb64deb4..479afd58d4 100644 --- a/packages/frontend/src/filters/number.ts +++ b/packages/frontend/src/filters/number.ts @@ -5,4 +5,4 @@ import { numberFormat } from '@@/js/intl-const.js'; -export default n => n == null ? 'N/A' : numberFormat.format(n); +export default (n?: number) => n == null ? 'N/A' : numberFormat.format(n); diff --git a/packages/frontend/src/i.ts b/packages/frontend/src/i.ts new file mode 100644 index 0000000000..a71ed1671f --- /dev/null +++ b/packages/frontend/src/i.ts @@ -0,0 +1,34 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { reactive } from 'vue'; +import * as Misskey from 'misskey-js'; +import { miLocalStorage } from '@/local-storage.js'; + +// TODO: 他のタブと永続化されたstateを同期 + +type AccountWithToken = Misskey.entities.MeDetailed & { token: string }; + +const accountData = miLocalStorage.getItem('account'); + +// TODO: 外部からはreadonlyに +export const $i = accountData ? reactive(JSON.parse(accountData) as AccountWithToken) : null; + +export const iAmModerator = $i != null && ($i.isAdmin === true || $i.isModerator === true); +export const iAmAdmin = $i != null && $i.isAdmin; + +export function ensureSignin() { + if ($i == null) throw new Error('signin required'); + return $i; +} + +export let notesCount = $i == null ? 0 : $i.notesCount; +export function incNotesCount() { + notesCount++; +} + +if (_DEV_) { + (window as any).$i = $i; +} diff --git a/packages/frontend/src/instance.ts b/packages/frontend/src/instance.ts index 71cb42b30c..e75e3dfd34 100644 --- a/packages/frontend/src/instance.ts +++ b/packages/frontend/src/instance.ts @@ -5,14 +5,14 @@ import { computed, reactive } from 'vue'; import * as Misskey from 'misskey-js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { miLocalStorage } from '@/local-storage.js'; import { DEFAULT_INFO_IMAGE_URL, DEFAULT_NOT_FOUND_IMAGE_URL, DEFAULT_SERVER_ERROR_IMAGE_URL } from '@@/js/const.js'; // TODO: 他のタブと永続化されたstateを同期 //#region loader -const providedMetaEl = document.getElementById('misskey_meta'); +const providedMetaEl = window.document.getElementById('misskey_meta'); let cachedMeta = miLocalStorage.getItem('instance') ? JSON.parse(miLocalStorage.getItem('instance')!) : null; let cachedAt = miLocalStorage.getItem('instanceCachedAt') ? parseInt(miLocalStorage.getItem('instanceCachedAt')!) : 0; diff --git a/packages/frontend/src/nirax.ts b/packages/frontend/src/lib/nirax.ts index ea3f1fb01a..a166df9eb0 100644 --- a/packages/frontend/src/nirax.ts +++ b/packages/frontend/src/lib/nirax.ts @@ -5,7 +5,7 @@ // NIRAX --- A lightweight router -import { onMounted, shallowRef } from 'vue'; +import { onBeforeUnmount, onMounted, shallowRef } from 'vue'; import { EventEmitter } from 'eventemitter3'; import type { Component, ShallowRef } from 'vue'; @@ -23,7 +23,6 @@ interface RouteDefBase { loginRequired?: boolean; name?: string; hash?: string; - globalCacheKey?: string; children?: RouteDef[]; } @@ -46,31 +45,28 @@ type ParsedPath = (string | { optional?: boolean; })[]; -export type RouterEvent = { +export type RouterEvents = { change: (ctx: { - beforePath: string; - path: string; - resolved: Resolved; - key: string; + beforeFullPath: string; + fullPath: string; + resolved: PathResolvedResult; }) => void; replace: (ctx: { - path: string; - key: string; + fullPath: string; }) => void; push: (ctx: { - beforePath: string; - path: string; + beforeFullPath: string; + fullPath: string; route: RouteDef | null; props: Map<string, string> | null; - key: string; }) => void; same: () => void; }; -export type Resolved = { +export type PathResolvedResult = { route: RouteDef; props: Map<string, string | boolean>; - child?: Resolved; + child?: PathResolvedResult; redirected?: boolean; /** @internal */ @@ -106,124 +102,39 @@ function parsePath(path: string): ParsedPath { return res; } -export interface IRouter extends EventEmitter<RouterEvent> { - current: Resolved; - currentRef: ShallowRef<Resolved>; - currentRoute: ShallowRef<RouteDef>; - navHook: ((path: string, flag?: RouterFlag) => boolean) | null; - - /** - * ルートの初期化(eventListenerの定義後に必ず呼び出すこと) - */ - init(): void; - - resolve(path: string): Resolved | null; - - getCurrentPath(): string; - - getCurrentKey(): string; - - push(path: string, flag?: RouterFlag): void; - - replace(path: string, key?: string | null): void; - - /** @see EventEmitter */ - eventNames(): Array<EventEmitter.EventNames<RouterEvent>>; - - /** @see EventEmitter */ - listeners<T extends EventEmitter.EventNames<RouterEvent>>( - event: T - ): Array<EventEmitter.EventListener<RouterEvent, T>>; - - /** @see EventEmitter */ - listenerCount( - event: EventEmitter.EventNames<RouterEvent> - ): number; - - /** @see EventEmitter */ - emit<T extends EventEmitter.EventNames<RouterEvent>>( - event: T, - ...args: EventEmitter.EventArgs<RouterEvent, T> - ): boolean; - - /** @see EventEmitter */ - on<T extends EventEmitter.EventNames<RouterEvent>>( - event: T, - fn: EventEmitter.EventListener<RouterEvent, T>, - context?: any - ): this; - - /** @see EventEmitter */ - addListener<T extends EventEmitter.EventNames<RouterEvent>>( - event: T, - fn: EventEmitter.EventListener<RouterEvent, T>, - context?: any - ): this; - - /** @see EventEmitter */ - once<T extends EventEmitter.EventNames<RouterEvent>>( - event: T, - fn: EventEmitter.EventListener<RouterEvent, T>, - context?: any - ): this; - - /** @see EventEmitter */ - removeListener<T extends EventEmitter.EventNames<RouterEvent>>( - event: T, - fn?: EventEmitter.EventListener<RouterEvent, T>, - context?: any, - once?: boolean | undefined - ): this; - - /** @see EventEmitter */ - off<T extends EventEmitter.EventNames<RouterEvent>>( - event: T, - fn?: EventEmitter.EventListener<RouterEvent, T>, - context?: any, - once?: boolean | undefined - ): this; - - /** @see EventEmitter */ - removeAllListeners( - event?: EventEmitter.EventNames<RouterEvent> - ): this; -} - -export class Router extends EventEmitter<RouterEvent> implements IRouter { - private routes: RouteDef[]; - public current: Resolved; - public currentRef: ShallowRef<Resolved>; +export class Nirax<DEF extends RouteDef[]> extends EventEmitter<RouterEvents> { + private routes: DEF; + public current: PathResolvedResult; + public currentRef: ShallowRef<PathResolvedResult>; public currentRoute: ShallowRef<RouteDef>; - private currentPath: string; + private currentFullPath: string; // /foo/bar?baz=qux#hash private isLoggedIn: boolean; private notFoundPageComponent: Component; - private currentKey = Date.now().toString(); private redirectCount = 0; - public navHook: ((path: string, flag?: RouterFlag) => boolean) | null = null; + public navHook: ((fullPath: string, flag?: RouterFlag) => boolean) | null = null; - constructor(routes: Router['routes'], currentPath: Router['currentPath'], isLoggedIn: boolean, notFoundPageComponent: Component) { + constructor(routes: DEF, currentFullPath: Nirax<DEF>['currentFullPath'], isLoggedIn: boolean, notFoundPageComponent: Component) { super(); this.routes = routes; - this.current = this.resolve(currentPath)!; + this.current = this.resolve(currentFullPath)!; this.currentRef = shallowRef(this.current); this.currentRoute = shallowRef(this.current.route); - this.currentPath = currentPath; + this.currentFullPath = currentFullPath; this.isLoggedIn = isLoggedIn; this.notFoundPageComponent = notFoundPageComponent; } public init() { - const res = this.navigate(this.currentPath, null, false); + const res = this.navigate(this.currentFullPath, false); this.emit('replace', { - path: res._parsedRoute.fullPath, - key: this.currentKey, + fullPath: res._parsedRoute.fullPath, }); } - public resolve(path: string): Resolved | null { - const fullPath = path; + public resolve(fullPath: string): PathResolvedResult | null { + let path = fullPath; let queryString: string | null = null; let hash: string | null = null; if (path[0] === '/') path = path.substring(1); @@ -242,7 +153,7 @@ export class Router extends EventEmitter<RouterEvent> implements IRouter { hash, }; - function check(routes: RouteDef[], _parts: string[]): Resolved | null { + function check(routes: RouteDef[], _parts: string[]): PathResolvedResult | null { forEachRouteLoop: for (const route of routes) { let parts = [..._parts]; @@ -345,28 +256,30 @@ export class Router extends EventEmitter<RouterEvent> implements IRouter { return check(this.routes, _parts); } - private navigate(path: string, key: string | null | undefined, emitChange = true, _redirected = false): Resolved { - const beforePath = this.currentPath; - this.currentPath = path; + private navigate(fullPath: string, emitChange = true, _redirected = false): PathResolvedResult { + const beforeFullPath = this.currentFullPath; + this.currentFullPath = fullPath; - const res = this.resolve(this.currentPath); + const res = this.resolve(this.currentFullPath); if (res == null) { - throw new Error('no route found for: ' + path); + throw new Error('no route found for: ' + fullPath); } - if ('redirect' in res.route) { - let redirectPath: string; - if (typeof res.route.redirect === 'function') { - redirectPath = res.route.redirect(res.props); - } else { - redirectPath = res.route.redirect + (res._parsedRoute.queryString ? '?' + res._parsedRoute.queryString : '') + (res._parsedRoute.hash ? '#' + res._parsedRoute.hash : ''); - } - if (_DEV_) console.log('Redirecting to: ', redirectPath); - if (_redirected && this.redirectCount++ > 10) { - throw new Error('redirect loop detected'); + for (let current: PathResolvedResult | undefined = res; current; current = current.child) { + if ('redirect' in current.route) { + let redirectPath: string; + if (typeof current.route.redirect === 'function') { + redirectPath = current.route.redirect(current.props); + } else { + redirectPath = current.route.redirect + (current._parsedRoute.queryString ? '?' + current._parsedRoute.queryString : '') + (current._parsedRoute.hash ? '#' + current._parsedRoute.hash : ''); + } + if (_DEV_) console.log('Redirecting to: ', redirectPath); + if (_redirected && this.redirectCount++ > 10) { + throw new Error('redirect loop detected'); + } + return this.navigate(redirectPath, emitChange, true); } - return this.navigate(redirectPath, null, emitChange, true); } if (res.route.loginRequired && !this.isLoggedIn) { @@ -374,19 +287,15 @@ export class Router extends EventEmitter<RouterEvent> implements IRouter { res.props.set('showLoginPopup', true); } - const isSamePath = beforePath === path; - if (isSamePath && key == null) key = this.currentKey; this.current = res; this.currentRef.value = res; this.currentRoute.value = res.route; - this.currentKey = res.route.globalCacheKey ?? key ?? path; if (emitChange && res.route.path !== '/:(*)') { this.emit('change', { - beforePath, - path, + beforeFullPath, + fullPath, resolved: res, - key: this.currentKey, }); } @@ -397,70 +306,45 @@ export class Router extends EventEmitter<RouterEvent> implements IRouter { }; } - public getCurrentPath() { - return this.currentPath; + public getCurrentFullPath() { + return this.currentFullPath; } - public getCurrentKey() { - return this.currentKey; - } - - public push(path: string, flag?: RouterFlag) { - const beforePath = this.currentPath; - if (path === beforePath) { + public push(fullPath: string, flag?: RouterFlag) { + const beforeFullPath = this.currentFullPath; + if (fullPath === beforeFullPath) { this.emit('same'); return; } if (this.navHook) { - const cancel = this.navHook(path, flag); + const cancel = this.navHook(fullPath, flag); if (cancel) return; } - const res = this.navigate(path, null); + const res = this.navigate(fullPath); if (res.route.path === '/:(*)') { - location.href = path; + window.location.href = fullPath; } else { this.emit('push', { - beforePath, - path: res._parsedRoute.fullPath, + beforeFullPath, + fullPath: res._parsedRoute.fullPath, route: res.route, props: res.props, - key: this.currentKey, }); } } - public replace(path: string, key?: string | null) { - const res = this.navigate(path, key); + public replace(fullPath: string) { + const res = this.navigate(fullPath); this.emit('replace', { - path: res._parsedRoute.fullPath, - key: this.currentKey, + fullPath: res._parsedRoute.fullPath, }); } -} - -export function useScrollPositionManager(getScrollContainer: () => HTMLElement | null, router: IRouter) { - const scrollPosStore = new Map<string, number>(); - - onMounted(() => { - const scrollContainer = getScrollContainer(); - if (scrollContainer == null) return; - scrollContainer.addEventListener('scroll', () => { - scrollPosStore.set(router.getCurrentKey(), scrollContainer.scrollTop); - }, { passive: true }); + public useListener<E extends keyof RouterEvents, L = RouterEvents[E]>(event: E, listener: L) { + this.addListener(event, listener); - router.addListener('change', ctx => { - const scrollPos = scrollPosStore.get(ctx.key) ?? 0; - scrollContainer.scroll({ top: scrollPos, behavior: 'instant' }); - if (scrollPos !== 0) { - window.setTimeout(() => { // 遷移直後はタイミングによってはコンポーネントが復元し切ってない可能性も考えられるため少し時間を空けて再度スクロール - scrollContainer.scroll({ top: scrollPos, behavior: 'instant' }); - }, 100); - } - }); - - router.addListener('same', () => { - scrollContainer.scroll({ top: 0, behavior: 'smooth' }); + onBeforeUnmount(() => { + this.removeListener(event, listener); }); - }); + } } diff --git a/packages/frontend/src/pizzax.ts b/packages/frontend/src/lib/pizzax.ts index 918b81b204..a232ced75e 100644 --- a/packages/frontend/src/pizzax.ts +++ b/packages/frontend/src/lib/pizzax.ts @@ -8,13 +8,13 @@ import { onUnmounted, ref, watch } from 'vue'; import { BroadcastChannel } from 'broadcast-channel'; import type { Ref } from 'vue'; -import { $i } from '@/account.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { get, set } from '@/scripts/idb-proxy.js'; -import { defaultStore } from '@/store.js'; +import { $i } from '@/i.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { get, set } from '@/utility/idb-proxy.js'; +import { store } from '@/store.js'; import { useStream } from '@/stream.js'; -import { deepClone } from '@/scripts/clone.js'; -import { deepMerge } from '@/scripts/merge.js'; +import { deepClone } from '@/utility/clone.js'; +import { deepMerge } from '@/utility/merge.js'; type StateDef = Record<string, { where: 'account' | 'device' | 'deviceAccount'; @@ -33,7 +33,7 @@ type PizzaxChannelMessage<T extends StateDef> = { userId?: string; }; -export class Storage<T extends StateDef> { +export class Pizzax<T extends StateDef> { public readonly ready: Promise<void>; public readonly loaded: Promise<void>; @@ -45,8 +45,15 @@ export class Storage<T extends StateDef> { public readonly def: T; // TODO: これが実装されたらreadonlyにしたい: https://github.com/microsoft/TypeScript/issues/37487 - public readonly state: State<T>; - public readonly reactiveState: ReactiveState<T>; + /** + * static / state の略 (static が予約語のため) + */ + public readonly s: State<T>; + + /** + * reactive の略 + */ + public readonly r: ReactiveState<T>; private pizzaxChannel: BroadcastChannel<PizzaxChannelMessage<T>>; @@ -70,12 +77,12 @@ export class Storage<T extends StateDef> { this.pizzaxChannel = new BroadcastChannel(`pizzax::${key}`); - this.state = {} as State<T>; - this.reactiveState = {} as ReactiveState<T>; + this.s = {} as State<T>; + this.r = {} as ReactiveState<T>; for (const [k, v] of Object.entries(def) as [keyof T, T[keyof T]['default']][]) { - this.state[k] = v.default; - this.reactiveState[k] = ref(v.default); + this.s[k] = v.default; + this.r[k] = ref(v.default); } this.ready = this.init(); @@ -106,13 +113,13 @@ export class Storage<T extends StateDef> { for (const [k, v] of Object.entries(this.def) as [keyof T, T[keyof T]['default']][]) { if (v.where === 'device' && Object.prototype.hasOwnProperty.call(deviceState, k)) { - this.reactiveState[k].value = this.state[k] = this.mergeState<T[keyof T]['default']>(deviceState[k], v.default); + this.r[k].value = this.s[k] = this.mergeState<T[keyof T]['default']>(deviceState[k], v.default); } else if (v.where === 'account' && $i && Object.prototype.hasOwnProperty.call(registryCache, k)) { - this.reactiveState[k].value = this.state[k] = this.mergeState<T[keyof T]['default']>(registryCache[k], v.default); + this.r[k].value = this.s[k] = this.mergeState<T[keyof T]['default']>(registryCache[k], v.default); } else if (v.where === 'deviceAccount' && Object.prototype.hasOwnProperty.call(deviceAccountState, k)) { - this.reactiveState[k].value = this.state[k] = this.mergeState<T[keyof T]['default']>(deviceAccountState[k], v.default); + this.r[k].value = this.s[k] = this.mergeState<T[keyof T]['default']>(deviceAccountState[k], v.default); } else { - this.reactiveState[k].value = this.state[k] = v.default; + this.r[k].value = this.s[k] = v.default; } } @@ -120,7 +127,7 @@ export class Storage<T extends StateDef> { // アカウント変更すればunisonReloadが効くため、このreturnが発火することは // まずないと思うけど一応弾いておく if (where === 'deviceAccount' && !($i && userId !== $i.id)) return; - this.reactiveState[key].value = this.state[key] = value; + this.r[key].value = this.s[key] = value; }); if ($i) { @@ -128,9 +135,9 @@ export class Storage<T extends StateDef> { // streamingのuser storage updateイベントを監視して更新 connection.on('registryUpdated', ({ scope, key, value }: { scope?: string[], key: keyof T, value: T[typeof key]['default'] }) => { - if (!scope || scope.length !== 2 || scope[0] !== 'client' || scope[1] !== this.key || this.state[key] === value) return; + if (!scope || scope.length !== 2 || scope[0] !== 'client' || scope[1] !== this.key || this.s[key] === value) return; - this.reactiveState[key].value = this.state[key] = value; + this.r[key].value = this.s[key] = value; this.addIdbSetJob(async () => { const cache = await get(this.registryCacheKeyName); @@ -148,7 +155,7 @@ export class Storage<T extends StateDef> { if ($i) { // api関数と循環参照なので一応setTimeoutしておく window.setTimeout(async () => { - await defaultStore.ready; + await store.ready; misskeyApi('i/registry/get-all', { scope: ['client', this.key] }) .then(kvs => { @@ -156,10 +163,10 @@ export class Storage<T extends StateDef> { for (const [k, v] of Object.entries(this.def) as [keyof T, T[keyof T]['default']][]) { if (v.where === 'account') { if (Object.prototype.hasOwnProperty.call(kvs, k)) { - this.reactiveState[k].value = this.state[k] = (kvs as Partial<T>)[k]; + this.r[k].value = this.s[k] = (kvs as Partial<T>)[k]; cache[k] = (kvs as Partial<T>)[k]; } else { - this.reactiveState[k].value = this.state[k] = v.default; + this.r[k].value = this.s[k] = v.default; } } } @@ -179,7 +186,7 @@ export class Storage<T extends StateDef> { // (JSON.parse(JSON.stringify(value))の代わり) const rawValue = deepClone(value); - this.reactiveState[key].value = this.state[key] = rawValue; + this.r[key].value = this.s[key] = rawValue; return this.addIdbSetJob(async () => { switch (this.def[key].where) { @@ -224,7 +231,7 @@ export class Storage<T extends StateDef> { } public push<K extends keyof T>(key: K, value: ArrayElement<T[K]['default']>): void { - const currentState = this.state[key]; + const currentState = this.s[key]; this.set(key, [...currentState, value]); } @@ -237,6 +244,7 @@ export class Storage<T extends StateDef> { * 特定のキーの、簡易的なgetter/setterを作ります * 主にvue上で設定コントロールのmodelとして使う用 */ + // TODO: 廃止 public makeGetterSetter<K extends keyof T, R = T[K]['default']>( key: K, getter?: (v: T[K]['default']) => R, @@ -245,9 +253,9 @@ export class Storage<T extends StateDef> { get: () => R; set: (value: R) => void; } { - const valueRef = ref(this.state[key]); + const valueRef = ref(this.s[key]); - const stop = watch(this.reactiveState[key], val => { + const stop = watch(this.r[key], val => { valueRef.value = val; }); diff --git a/packages/frontend/src/local-storage.ts b/packages/frontend/src/local-storage.ts index 9c8863f863..78fba9f7b4 100644 --- a/packages/frontend/src/local-storage.ts +++ b/packages/frontend/src/local-storage.ts @@ -9,7 +9,6 @@ export type Keys = ( 'instance' | 'instanceCachedAt' | 'account' | - 'accounts' | 'latestDonationInfoShownAt' | 'neverShowDonationInfo' | 'neverShowLocalOnlyInfo' | @@ -18,8 +17,6 @@ export type Keys = ( 'lang' | 'drafts' | 'hashtags' | - 'wallpaper' | - 'theme' | 'colorScheme' | 'useSystemFont' | 'fontSize' | @@ -28,13 +25,17 @@ export type Keys = ( 'locale' | 'localeVersion' | 'theme' | + 'themeId' | 'customCss' | - 'message_drafts' | + 'chatMessageDrafts' | 'scratchpad' | 'debug' | + 'preferences' | + 'latestPreferencesUpdate' | + 'hidePreferencesRestoreSuggestion' | `miux:${string}` | `ui:folder:${string}` | - `themes:${string}` | + `themes:${string}` | // DEPRECATED `aiscript:${string}` | 'lastEmojisFetchedAt' | // DEPRECATED, stored in indexeddb (13.9.0~) 'emojis' | // DEPRECATED, stored in indexeddb (13.9.0~); diff --git a/packages/frontend/src/navbar.ts b/packages/frontend/src/navbar.ts index 096d404a57..c0fe0f2b85 100644 --- a/packages/frontend/src/navbar.ts +++ b/packages/frontend/src/navbar.ts @@ -4,15 +4,15 @@ */ import { computed, reactive } from 'vue'; -import { clearCache } from './scripts/clear-cache.js'; -import { $i } from '@/account.js'; +import { ui } from '@@/js/config.js'; +import { clearCache } from './utility/clear-cache.js'; +import { $i } from '@/i.js'; import { miLocalStorage } from '@/local-storage.js'; import { openInstanceMenu, openToolsMenu } from '@/ui/_common_/common.js'; -import { lookup } from '@/scripts/lookup.js'; +import { lookup } from '@/utility/lookup.js'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; -import { ui } from '@@/js/config.js'; -import { unisonReload } from '@/scripts/unison-reload.js'; +import { unisonReload } from '@/utility/unison-reload.js'; export const navbarItemDef = reactive({ notifications: { @@ -110,6 +110,13 @@ export const navbarItemDef = reactive({ icon: 'ti ti-device-tv', to: '/channels', }, + chat: { + title: i18n.ts.chat, + icon: 'ti ti-messages', + to: '/chat', + show: computed(() => $i != null && $i.policies.chatAvailability !== 'unavailable'), + indicated: computed(() => $i != null && $i.hasUnreadChatMessages), + }, achievements: { title: i18n.ts.achievements, icon: 'ti ti-medal', @@ -139,13 +146,6 @@ export const navbarItemDef = reactive({ miLocalStorage.setItem('ui', 'deck'); unisonReload(); }, - }, { - text: i18n.ts.classic, - active: ui === 'classic', - action: () => { - miLocalStorage.setItem('ui', 'classic'); - unisonReload(); - }, }], ev.currentTarget ?? ev.target); }, }, @@ -167,7 +167,7 @@ export const navbarItemDef = reactive({ title: i18n.ts.reload, icon: 'ti ti-refresh', action: (ev) => { - location.reload(); + window.location.reload(); }, }, profile: { diff --git a/packages/frontend/src/os.ts b/packages/frontend/src/os.ts index 6e48366092..813b49635d 100644 --- a/packages/frontend/src/os.ts +++ b/packages/frontend/src/os.ts @@ -6,30 +6,30 @@ // TODO: なんでもかんでもos.tsに突っ込むのやめたいのでよしなに分割する import { markRaw, ref, defineAsyncComponent, nextTick } from 'vue'; -import type { Component, Ref } from 'vue'; import { EventEmitter } from 'eventemitter3'; import * as Misskey from 'misskey-js'; +import type { Component, Ref } from 'vue'; import type { ComponentProps as CP } from 'vue-component-type-helpers'; -import type { Form, GetFormResultType } from '@/scripts/form.js'; +import type { Form, GetFormResultType } from '@/utility/form.js'; import type { MenuItem } from '@/types/menu.js'; import type { PostFormProps } from '@/types/post-form.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { defaultStore } from '@/store.js'; +import type MkRoleSelectDialog_TypeReferenceOnly from '@/components/MkRoleSelectDialog.vue'; +import type MkEmojiPickerDialog_TypeReferenceOnly from '@/components/MkEmojiPickerDialog.vue'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { prefer } from '@/preferences.js'; import { i18n } from '@/i18n.js'; import MkPostFormDialog from '@/components/MkPostFormDialog.vue'; import MkWaitingDialog from '@/components/MkWaitingDialog.vue'; import MkPageWindow from '@/components/MkPageWindow.vue'; import MkToast from '@/components/MkToast.vue'; import MkDialog from '@/components/MkDialog.vue'; -import MkPasswordDialog from '@/components/MkPasswordDialog.vue'; -import MkEmojiPickerDialog from '@/components/MkEmojiPickerDialog.vue'; import MkPopupMenu from '@/components/MkPopupMenu.vue'; import MkContextMenu from '@/components/MkContextMenu.vue'; -import { copyToClipboard } from '@/scripts/copy-to-clipboard.js'; -import { pleaseLogin } from '@/scripts/please-login.js'; -import { showMovedDialog } from '@/scripts/show-moved-dialog.js'; -import { getHTMLElementOrNull } from '@/scripts/get-dom-node-or-null.js'; -import { focusParent } from '@/scripts/focus.js'; +import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; +import { pleaseLogin } from '@/utility/please-login.js'; +import { showMovedDialog } from '@/utility/show-moved-dialog.js'; +import { getHTMLElementOrNull } from '@/utility/get-dom-node-or-null.js'; +import { focusParent } from '@/utility/focus.js'; export const openingWindowsCount = ref(0); @@ -63,7 +63,6 @@ export const apiWithDialog = (<E extends keyof Misskey.Endpoints, P extends Miss }); if (result === 'copy') { copyToClipboard(`Endpoint: ${endpoint}\nInfo: ${JSON.stringify(err.info)}\nDate: ${date}`); - success(); } return; } else if (err.code === 'RATE_LIMIT_EXCEEDED') { @@ -182,7 +181,7 @@ type EmitsExtractor<T> = { export function popup<T extends Component>( component: T, props: ComponentProps<T>, - events: ComponentEmit<T> = {} as ComponentEmit<T>, + events: Partial<ComponentEmit<T>> = {}, ): { dispose: () => void } { markRaw(component); @@ -461,7 +460,7 @@ export function authenticateDialog(): Promise<{ canceled: false; result: { password: string; token: string | null; }; }> { return new Promise(resolve => { - const { dispose } = popup(MkPasswordDialog, {}, { + const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkPasswordDialog.vue')), {}, { done: result => { resolve(result ? { canceled: false, result } : { canceled: true, result: undefined }); }, @@ -548,12 +547,13 @@ export function success(): Promise<void> { }); } -export function waiting(): Promise<void> { +export function waiting(text?: string | null): Promise<void> { return new Promise(resolve => { const showing = ref(true); const { dispose } = popup(MkWaitingDialog, { success: false, showing: showing, + text, }, { done: () => resolve(), closed: () => dispose(), @@ -618,30 +618,26 @@ export async function selectDriveFolder(multiple: boolean): Promise<Misskey.enti }); } -export async function selectRole(params: { - initialRoleIds?: string[], - title?: string, - infoMessage?: string, - publicOnly?: boolean, -}): Promise< +export async function selectRole(params: ComponentProps<typeof MkRoleSelectDialog_TypeReferenceOnly>): Promise< { canceled: true; result: undefined; } | { canceled: false; result: Misskey.entities.Role[] } > { return new Promise((resolve) => { - popup(defineAsyncComponent(() => import('@/components/MkRoleSelectDialog.vue')), params, { + const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkRoleSelectDialog.vue')), params, { done: roles => { resolve({ canceled: false, result: roles }); }, close: () => { resolve({ canceled: true, result: undefined }); }, - }, 'dispose'); + closed: () => dispose(), + }); }); } -export async function pickEmoji(src: HTMLElement, opts: ComponentProps<typeof MkEmojiPickerDialog>): Promise<string> { +export async function pickEmoji(src: HTMLElement, opts: ComponentProps<typeof MkEmojiPickerDialog_TypeReferenceOnly>): Promise<string> { return new Promise(resolve => { - const { dispose } = popup(MkEmojiPickerDialog, { + const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkEmojiPickerDialog.vue')), { src, ...opts, }, { @@ -676,7 +672,11 @@ export function popupMenu(items: MenuItem[], src?: HTMLElement | EventTarget | n width?: number; onClosing?: () => void; }): Promise<void> { - let returnFocusTo = getHTMLElementOrNull(src) ?? getHTMLElementOrNull(document.activeElement); + if (!(src instanceof HTMLElement)) { + src = null; + } + + let returnFocusTo = getHTMLElementOrNull(src) ?? getHTMLElementOrNull(window.document.activeElement); return new Promise(resolve => nextTick(() => { const { dispose } = popup(MkPopupMenu, { items, @@ -699,13 +699,13 @@ export function popupMenu(items: MenuItem[], src?: HTMLElement | EventTarget | n export function contextMenu(items: MenuItem[], ev: MouseEvent): Promise<void> { if ( - defaultStore.state.contextMenu === 'native' || - (defaultStore.state.contextMenu === 'appWithShift' && !ev.shiftKey) + prefer.s.contextMenu === 'native' || + (prefer.s.contextMenu === 'appWithShift' && !ev.shiftKey) ) { return Promise.resolve(); } - let returnFocusTo = getHTMLElementOrNull(ev.currentTarget ?? ev.target) ?? getHTMLElementOrNull(document.activeElement); + let returnFocusTo = getHTMLElementOrNull(ev.currentTarget ?? ev.target) ?? getHTMLElementOrNull(window.document.activeElement); ev.preventDefault(); return new Promise(resolve => nextTick(() => { const { dispose } = popup(MkContextMenu, { diff --git a/packages/frontend/src/scripts/page-metadata.ts b/packages/frontend/src/page.ts index 671751147c..0107f17be4 100644 --- a/packages/frontend/src/scripts/page-metadata.ts +++ b/packages/frontend/src/page.ts @@ -5,6 +5,7 @@ import * as Misskey from 'misskey-js'; import { inject, isRef, onActivated, onBeforeUnmount, provide, ref, toValue, watch } from 'vue'; +import { DI } from './di.js'; import type { MaybeRefOrGetter, Ref } from 'vue'; export type PageMetadata = { @@ -31,11 +32,8 @@ const METADATA_KEY = Symbol('MetadataKey'); const setMetadata = (v: Ref<PageMetadata | null>): void => { provide<Ref<PageMetadata | null>>(METADATA_KEY, v); }; -const getMetadata = (): Ref<PageMetadata | null> | undefined => { - return inject<Ref<PageMetadata | null>>(METADATA_KEY); -}; -export const definePageMetadata = (maybeRefOrGetterMetadata: MaybeRefOrGetter<PageMetadata>): void => { +export const definePage = (maybeRefOrGetterMetadata: MaybeRefOrGetter<PageMetadata>): void => { const metadataRef = ref(toValue(maybeRefOrGetterMetadata)); const metadataGetter = () => metadataRef.value; const receiver = getReceiver(); @@ -55,6 +53,8 @@ export const definePageMetadata = (maybeRefOrGetterMetadata: MaybeRefOrGetter<Pa onActivated(() => { receiver?.(metadataGetter); }); + + provide(DI.pageMetadata, metadataRef); }; export const provideMetadataReceiver = (receiver: PageMetadataReceiver): void => { @@ -64,8 +64,3 @@ export const provideMetadataReceiver = (receiver: PageMetadataReceiver): void => export const provideReactiveMetadata = (metadataRef: Ref<PageMetadata | null>): void => { setMetadata(metadataRef); }; - -export const injectReactiveMetadata = (): Ref<PageMetadata | null> => { - const metadataRef = getMetadata(); - return isRef(metadataRef) ? metadataRef : ref(null); -}; diff --git a/packages/frontend/src/pages/_error_.vue b/packages/frontend/src/pages/_error_.vue index f09a8e4285..791267f5ca 100644 --- a/packages/frontend/src/pages/_error_.vue +++ b/packages/frontend/src/pages/_error_.vue @@ -5,9 +5,9 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <MkLoading v-if="!loaded"/> -<Transition :name="defaultStore.state.animation ? '_transition_zoom' : ''" appear> +<Transition :name="prefer.s.animation ? '_transition_zoom' : ''" appear> <div v-show="loaded" :class="$style.root"> - <img :src="serverErrorImageUrl" class="_ghost" :class="$style.img"/> + <img :src="serverErrorImageUrl" draggable="false" :class="$style.img"/> <div class="_gaps"> <div><b><i class="ti ti-alert-triangle"></i> {{ i18n.ts.pageLoadError }}</b></div> <div v-if="meta && (version === meta.version)">{{ i18n.ts.pageLoadErrorDescription }}</div> @@ -27,15 +27,15 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { ref, computed } from 'vue'; import * as Misskey from 'misskey-js'; +import { version } from '@@/js/config.js'; import MkButton from '@/components/MkButton.vue'; import MkLink from '@/components/MkLink.vue'; -import { version } from '@@/js/config.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { unisonReload } from '@/scripts/unison-reload.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { unisonReload } from '@/utility/unison-reload.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; import { miLocalStorage } from '@/local-storage.js'; -import { defaultStore } from '@/store.js'; +import { prefer } from '@/preferences.js'; import { serverErrorImageUrl } from '@/instance.js'; const props = withDefaults(defineProps<{ @@ -67,7 +67,7 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.error, icon: 'ti ti-alert-triangle', })); diff --git a/packages/frontend/src/pages/about-misskey.vue b/packages/frontend/src/pages/about-misskey.vue index 29772ae00a..481088fc30 100644 --- a/packages/frontend/src/pages/about-misskey.vue +++ b/packages/frontend/src/pages/about-misskey.vue @@ -4,8 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> +<PageWithHeader :actions="headerActions" :tabs="headerTabs"> <div style="overflow: clip;"> <MkSpacer :contentMax="600" :marginMin="20"> <div class="_gaps_m znqjceqz"> @@ -130,24 +129,24 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </MkSpacer> </div> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> -import { nextTick, onBeforeUnmount, ref, shallowRef, computed } from 'vue'; +import { nextTick, onBeforeUnmount, ref, useTemplateRef, computed } from 'vue'; import { version } from '@@/js/config.js'; import FormLink from '@/components/form/link.vue'; import FormSection from '@/components/form/section.vue'; import MkButton from '@/components/MkButton.vue'; import MkInfo from '@/components/MkInfo.vue'; -import { physics } from '@/scripts/physics.js'; +import { physics } from '@/utility/physics.js'; import { i18n } from '@/i18n.js'; import { instance } from '@/instance.js'; -import { defaultStore } from '@/store.js'; import * as os from '@/os.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; -import { claimAchievement, claimedAchievements } from '@/scripts/achievements.js'; -import { $i } from '@/account.js'; +import { definePage } from '@/page.js'; +import { claimAchievement, claimedAchievements } from '@/utility/achievements.js'; +import { $i } from '@/i.js'; +import { prefer } from '@/preferences.js'; const patronsWithIcon = [{ name: 'カイヤン', @@ -403,10 +402,10 @@ const easterEggEmojis = ref<{ emoji: string }[]>([]); const easterEggEngine = ref<{ stop: () => void } | null>(null); -const containerEl = shallowRef<HTMLElement>(); +const containerEl = useTemplateRef('containerEl'); function iconLoaded() { - const emojis = defaultStore.state.reactions; + const emojis = prefer.s.emojiPalettes[0].emojis; const containerWidth = containerEl.value.offsetWidth; for (let i = 0; i < 32; i++) { easterEggEmojis.value.push({ @@ -450,7 +449,7 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.aboutMisskey, icon: null, })); diff --git a/packages/frontend/src/pages/about.emojis.vue b/packages/frontend/src/pages/about.emojis.vue index d7d526f3ba..b166dfd940 100644 --- a/packages/frontend/src/pages/about.emojis.vue +++ b/packages/frontend/src/pages/about.emojis.vue @@ -44,7 +44,7 @@ import MkInput from '@/components/MkInput.vue'; import MkFoldableSection from '@/components/MkFoldableSection.vue'; import { customEmojis, customEmojiCategories, getCustomEmojiTags } from '@/custom-emojis.js'; import { i18n } from '@/i18n.js'; -import { $i } from '@/account.js'; +import { $i } from '@/i.js'; const customEmojiTags = getCustomEmojiTags(); const q = ref(''); diff --git a/packages/frontend/src/pages/about.overview.vue b/packages/frontend/src/pages/about.overview.vue index e5e57c05c4..0b9eee7d49 100644 --- a/packages/frontend/src/pages/about.overview.vue +++ b/packages/frontend/src/pages/about.overview.vue @@ -130,7 +130,7 @@ import { host, version } from '@@/js/config.js'; import { i18n } from '@/i18n.js'; import { instance } from '@/instance.js'; import number from '@/filters/number.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import FormLink from '@/components/form/link.vue'; import FormSection from '@/components/form/section.vue'; import FormSplit from '@/components/form/split.vue'; diff --git a/packages/frontend/src/pages/about.vue b/packages/frontend/src/pages/about.vue index ef0fd39ffe..b4315a5cfa 100644 --- a/packages/frontend/src/pages/about.vue +++ b/packages/frontend/src/pages/about.vue @@ -4,8 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> +<PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"> <MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs"> <MkSpacer v-if="tab === 'overview'" :contentMax="600" :marginMin="20"> <XOverview/> @@ -20,15 +19,15 @@ SPDX-License-Identifier: AGPL-3.0-only <MkInstanceStats/> </MkSpacer> </MkHorizontalSwipe> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> import { computed, defineAsyncComponent, ref, watch } from 'vue'; import { instance } from '@/instance.js'; import { i18n } from '@/i18n.js'; -import { claimAchievement } from '@/scripts/achievements.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { claimAchievement } from '@/utility/achievements.js'; +import { definePage } from '@/page.js'; import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue'; const XOverview = defineAsyncComponent(() => import('@/pages/about.overview.vue')); @@ -81,7 +80,7 @@ const headerTabs = computed(() => { return items; }); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.instanceInfo, icon: 'ti ti-info-circle', })); diff --git a/packages/frontend/src/pages/achievements.vue b/packages/frontend/src/pages/achievements.vue index 77ab473ea2..423e709da4 100644 --- a/packages/frontend/src/pages/achievements.vue +++ b/packages/frontend/src/pages/achievements.vue @@ -4,21 +4,20 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader/></template> +<PageWithHeader> <MkSpacer :contentMax="1200"> <MkAchievements :user="$i"/> </MkSpacer> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> import { onActivated, onDeactivated, onMounted, onUnmounted } from 'vue'; import MkAchievements from '@/components/MkAchievements.vue'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; -import { $i } from '@/account.js'; -import { claimAchievement } from '@/scripts/achievements.js'; +import { definePage } from '@/page.js'; +import { $i } from '@/i.js'; +import { claimAchievement } from '@/utility/achievements.js'; let timer: number | null; @@ -48,7 +47,7 @@ onDeactivated(() => { } }); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.achievements, icon: 'ti ti-medal', })); diff --git a/packages/frontend/src/pages/admin-file.vue b/packages/frontend/src/pages/admin-file.vue index 7ccb996fff..1e3e106842 100644 --- a/packages/frontend/src/pages/admin-file.vue +++ b/packages/frontend/src/pages/admin-file.vue @@ -4,8 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> +<PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"> <MkSpacer v-if="file" :contentMax="600" :marginMin="16" :marginMax="32"> <div v-if="tab === 'overview'" class="cxqhhsmd _gaps_m"> <a class="thumbnail" :href="file.url" target="_blank"> @@ -67,7 +66,7 @@ SPDX-License-Identifier: AGPL-3.0-only </MkObjectView> </div> </MkSpacer> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> @@ -83,10 +82,10 @@ import MkUserCardMini from '@/components/MkUserCardMini.vue'; import MkInfo from '@/components/MkInfo.vue'; import bytes from '@/filters/bytes.js'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; -import { iAmAdmin, iAmModerator } from '@/account.js'; +import { definePage } from '@/page.js'; +import { iAmAdmin, iAmModerator } from '@/i.js'; const tab = ref('overview'); const file = ref<Misskey.entities.DriveFile | null>(null); @@ -161,7 +160,7 @@ const headerTabs = computed(() => [{ icon: 'ti ti-code', }]); -definePageMetadata(() => ({ +definePage(() => ({ title: file.value ? `${i18n.ts.file}: ${file.value.name}` : i18n.ts.file, icon: 'ti ti-file', })); diff --git a/packages/frontend/src/pages/admin-user.vue b/packages/frontend/src/pages/admin-user.vue index 578945bf07..9d92ccda60 100644 --- a/packages/frontend/src/pages/admin-user.vue +++ b/packages/frontend/src/pages/admin-user.vue @@ -4,8 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> +<PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"> <MkSpacer :contentMax="600" :marginMin="16" :marginMax="32"> <FormSuspense :p="init"> <div v-if="tab === 'overview'" class="_gaps_m"> @@ -208,7 +207,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </FormSuspense> </MkSpacer> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> @@ -229,11 +228,11 @@ import FormSuspense from '@/components/form/suspense.vue'; import MkFileListForAdmin from '@/components/MkFileListForAdmin.vue'; import MkInfo from '@/components/MkInfo.vue'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { acct } from '@/filters/user.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; import { i18n } from '@/i18n.js'; -import { iAmAdmin, $i, iAmModerator } from '@/account.js'; +import { iAmAdmin, $i, iAmModerator } from '@/i.js'; import MkRolePreview from '@/components/MkRolePreview.vue'; import MkPagination from '@/components/MkPagination.vue'; @@ -545,7 +544,7 @@ const headerTabs = computed(() => isSystem.value ? [{ icon: 'ti ti-code', }]); -definePageMetadata(() => ({ +definePage(() => ({ title: user.value ? acct(user.value) : i18n.ts.userInfo, icon: 'ti ti-user-exclamation', })); @@ -663,7 +662,7 @@ definePageMetadata(() => ({ .roleItemSub { padding: 6px 12px; font-size: 85%; - color: var(--MI_THEME-fgTransparentWeak); + color: color(from var(--MI_THEME-fg) srgb r g b / 0.75); } .roleUnassign { diff --git a/packages/frontend/src/pages/admin/RolesEditorFormula.vue b/packages/frontend/src/pages/admin/RolesEditorFormula.vue index 4762ef3f97..6c47e6397f 100644 --- a/packages/frontend/src/pages/admin/RolesEditorFormula.vue +++ b/packages/frontend/src/pages/admin/RolesEditorFormula.vue @@ -71,7 +71,7 @@ import MkInput from '@/components/MkInput.vue'; import MkSelect from '@/components/MkSelect.vue'; import MkButton from '@/components/MkButton.vue'; import { i18n } from '@/i18n.js'; -import { deepClone } from '@/scripts/clone.js'; +import { deepClone } from '@/utility/clone.js'; import { rolesCache } from '@/cache.js'; const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default)); diff --git a/packages/frontend/src/pages/admin/_header_.vue b/packages/frontend/src/pages/admin/_header_.vue index 9b1bf51f58..529d438c4a 100644 --- a/packages/frontend/src/pages/admin/_header_.vue +++ b/packages/frontend/src/pages/admin/_header_.vue @@ -33,13 +33,13 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { computed, onMounted, onUnmounted, ref, shallowRef, watch, nextTick } from 'vue'; +import { computed, onMounted, onUnmounted, ref, useTemplateRef, watch, nextTick, inject } from 'vue'; import tinycolor from 'tinycolor2'; -import { popupMenu } from '@/os.js'; import { scrollToTop } from '@@/js/scroll.js'; +import { popupMenu } from '@/os.js'; import MkButton from '@/components/MkButton.vue'; import { globalEvents } from '@/events.js'; -import { injectReactiveMetadata } from '@/scripts/page-metadata.js'; +import { DI } from '@/di.js'; type Tab = { key?: string | null; @@ -66,11 +66,11 @@ const emit = defineEmits<{ (ev: 'update:tab', key: string); }>(); -const pageMetadata = injectReactiveMetadata(); +const pageMetadata = inject(DI.pageMetadata, ref(null)); -const el = shallowRef<HTMLElement>(null); +const el = useTemplateRef('el'); +const tabHighlightEl = useTemplateRef('tabHighlightEl'); const tabRefs = {}; -const tabHighlightEl = shallowRef<HTMLElement | null>(null); const bg = ref<string | null>(null); const height = ref(0); const hasTabs = computed(() => { @@ -119,15 +119,15 @@ function onTabClick(tab: Tab, ev: MouseEvent): void { } const calcBg = () => { - const rawBg = pageMetadata.value?.bg ?? 'var(--MI_THEME-bg)'; - const tinyBg = tinycolor(rawBg.startsWith('var(') ? getComputedStyle(document.documentElement).getPropertyValue(rawBg.slice(4, -1)) : rawBg); + const rawBg = pageMetadata.value.bg ?? 'var(--MI_THEME-bg)'; + const tinyBg = tinycolor(rawBg.startsWith('var(') ? getComputedStyle(window.document.documentElement).getPropertyValue(rawBg.slice(4, -1)) : rawBg); tinyBg.setAlpha(0.85); bg.value = tinyBg.toRgbString(); }; onMounted(() => { calcBg(); - globalEvents.on('themeChanged', calcBg); + globalEvents.on('themeChanging', calcBg); watch(() => [props.tab, props.tabs], () => { nextTick(() => { @@ -147,7 +147,7 @@ onMounted(() => { }); onUnmounted(() => { - globalEvents.off('themeChanged', calcBg); + globalEvents.off('themeChanging', calcBg); }); </script> diff --git a/packages/frontend/src/pages/admin/abuse-report/notification-recipient.editor.vue b/packages/frontend/src/pages/admin/abuse-report/notification-recipient.editor.vue index 5f683c7a1d..10925fa4ab 100644 --- a/packages/frontend/src/pages/admin/abuse-report/notification-recipient.editor.vue +++ b/packages/frontend/src/pages/admin/abuse-report/notification-recipient.editor.vue @@ -71,16 +71,16 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { computed, onMounted, ref, shallowRef, toRefs } from 'vue'; +import { computed, onMounted, ref, useTemplateRef, toRefs } from 'vue'; import { entities } from 'misskey-js'; +import type { MkSystemWebhookResult } from '@/components/MkSystemWebhookEditor.impl.js'; import MkButton from '@/components/MkButton.vue'; import MkModalWindow from '@/components/MkModalWindow.vue'; import { i18n } from '@/i18n.js'; import MkInput from '@/components/MkInput.vue'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import MkSelect from '@/components/MkSelect.vue'; import { showSystemWebhookEditorDialog } from '@/components/MkSystemWebhookEditor.impl.js'; -import type { MkSystemWebhookResult } from '@/components/MkSystemWebhookEditor.impl.js'; import MkSwitch from '@/components/MkSwitch.vue'; import MkDivider from '@/components/MkDivider.vue'; import * as os from '@/os.js'; @@ -100,7 +100,7 @@ const props = defineProps<{ const { mode, id } = toRefs(props); -const dialogEl = shallowRef<InstanceType<typeof MkModalWindow>>(); +const dialogEl = useTemplateRef('dialogEl'); const loading = ref<number>(0); @@ -296,7 +296,7 @@ onMounted(async () => { left: 0; padding: 12px; border-top: solid 0.5px var(--MI_THEME-divider); - background: var(--MI_THEME-acrylicBg); + background: color(from var(--MI_THEME-bg) srgb r g b / 0.5); -webkit-backdrop-filter: var(--MI-blur, blur(15px)); backdrop-filter: var(--MI-blur, blur(15px)); } diff --git a/packages/frontend/src/pages/admin/abuse-report/notification-recipient.vue b/packages/frontend/src/pages/admin/abuse-report/notification-recipient.vue index f5249261be..ee87fae606 100644 --- a/packages/frontend/src/pages/admin/abuse-report/notification-recipient.vue +++ b/packages/frontend/src/pages/admin/abuse-report/notification-recipient.vue @@ -49,7 +49,7 @@ import { entities } from 'misskey-js'; import { computed, defineAsyncComponent, onMounted, ref } from 'vue'; import XRecipient from './notification-recipient.item.vue'; import XHeader from '@/pages/admin/_header_.vue'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import MkInput from '@/components/MkInput.vue'; import MkSelect from '@/components/MkSelect.vue'; import MkButton from '@/components/MkButton.vue'; diff --git a/packages/frontend/src/pages/admin/abuses.vue b/packages/frontend/src/pages/admin/abuses.vue index 22173bb888..08e06ce4b4 100644 --- a/packages/frontend/src/pages/admin/abuses.vue +++ b/packages/frontend/src/pages/admin/abuses.vue @@ -12,7 +12,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkButton link to="/admin/abuse-report-notification-recipient" primary>{{ i18n.ts.notificationSetting }}</MkButton> </div> - <MkInfo v-if="!defaultStore.reactiveState.abusesTutorial.value" closable @close="closeTutorial()"> + <MkInfo v-if="!store.r.abusesTutorial.value" closable @close="closeTutorial()"> {{ i18n.ts._abuseUserReport.resolveTutorial }} </MkInfo> @@ -59,18 +59,18 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { computed, shallowRef, ref } from 'vue'; +import { computed, useTemplateRef, ref } from 'vue'; import XHeader from './_header_.vue'; import MkSelect from '@/components/MkSelect.vue'; import MkPagination from '@/components/MkPagination.vue'; import XAbuseReport from '@/components/MkAbuseReport.vue'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; import MkButton from '@/components/MkButton.vue'; import MkInfo from '@/components/MkInfo.vue'; -import { defaultStore } from '@/store.js'; +import { store } from '@/store.js'; -const reports = shallowRef<InstanceType<typeof MkPagination>>(); +const reports = useTemplateRef('reports'); const state = ref('unresolved'); const reporterOrigin = ref('combined'); @@ -93,14 +93,14 @@ function resolved(reportId) { } function closeTutorial() { - defaultStore.set('abusesTutorial', false); + store.set('abusesTutorial', false); } const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.abuseReports, icon: 'ti ti-exclamation-circle', })); diff --git a/packages/frontend/src/pages/admin/ads.vue b/packages/frontend/src/pages/admin/ads.vue index 0d67359e47..ebc3d23296 100644 --- a/packages/frontend/src/pages/admin/ads.vue +++ b/packages/frontend/src/pages/admin/ads.vue @@ -96,9 +96,9 @@ import MkFolder from '@/components/MkFolder.vue'; import MkSelect from '@/components/MkSelect.vue'; import FormSplit from '@/components/form/split.vue'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; const ads = ref<Misskey.entities.Ad[]>([]); @@ -255,7 +255,7 @@ const headerActions = computed(() => [{ const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.ads, icon: 'ti ti-ad', })); diff --git a/packages/frontend/src/pages/admin/announcements.vue b/packages/frontend/src/pages/admin/announcements.vue index e420586017..f6b331455f 100644 --- a/packages/frontend/src/pages/admin/announcements.vue +++ b/packages/frontend/src/pages/admin/announcements.vue @@ -94,9 +94,9 @@ import MkSwitch from '@/components/MkSwitch.vue'; import MkRadios from '@/components/MkRadios.vue'; import MkInfo from '@/components/MkInfo.vue'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; import MkFolder from '@/components/MkFolder.vue'; import MkTextarea from '@/components/MkTextarea.vue'; @@ -199,7 +199,7 @@ const headerActions = computed(() => [{ const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.announcements, icon: 'ti ti-speakerphone', })); diff --git a/packages/frontend/src/pages/admin/bot-protection.vue b/packages/frontend/src/pages/admin/bot-protection.vue index 2d314c822d..2bd734f7d3 100644 --- a/packages/frontend/src/pages/admin/bot-protection.vue +++ b/packages/frontend/src/pages/admin/bot-protection.vue @@ -160,10 +160,10 @@ import MkRadios from '@/components/MkRadios.vue'; import MkInput from '@/components/MkInput.vue'; import FormSlot from '@/components/form/slot.vue'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { fetchInstance } from '@/instance.js'; import { i18n } from '@/i18n.js'; -import { useForm } from '@/scripts/use-form.js'; +import { useForm } from '@/use/use-form.js'; import MkFormFooter from '@/components/MkFormFooter.vue'; import MkFolder from '@/components/MkFolder.vue'; import MkInfo from '@/components/MkInfo.vue'; diff --git a/packages/frontend/src/pages/admin/branding.vue b/packages/frontend/src/pages/admin/branding.vue index 95f82c1f24..0ac45914e8 100644 --- a/packages/frontend/src/pages/admin/branding.vue +++ b/packages/frontend/src/pages/admin/branding.vue @@ -111,10 +111,10 @@ import MkInput from '@/components/MkInput.vue'; import MkTextarea from '@/components/MkTextarea.vue'; import FormSuspense from '@/components/form/suspense.vue'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { instance, fetchInstance } from '@/instance.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; import MkButton from '@/components/MkButton.vue'; import MkColorInput from '@/components/MkColorInput.vue'; import { host } from '@@/js/config.js'; @@ -175,7 +175,7 @@ function save() { const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.branding, icon: 'ti ti-paint', })); diff --git a/packages/frontend/src/pages/admin/custom-emojis-manager.local.list.vue b/packages/frontend/src/pages/admin/custom-emojis-manager.local.list.vue index 06d13cda75..260177c894 100644 --- a/packages/frontend/src/pages/admin/custom-emojis-manager.local.list.vue +++ b/packages/frontend/src/pages/admin/custom-emojis-manager.local.list.vue @@ -85,9 +85,9 @@ import MkGrid from '@/components/grid/MkGrid.vue'; import { i18n } from '@/i18n.js'; import MkButton from '@/components/MkButton.vue'; import { validators } from '@/components/grid/cell-validators.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import MkPagingButtons from '@/components/MkPagingButtons.vue'; -import { selectFile } from '@/scripts/select-file.js'; +import { selectFile } from '@/utility/select-file.js'; import { copyGridDataToClipboard, removeDataFromGrid } from '@/components/grid/grid-utils.js'; import { useLoading } from '@/components/hook/useLoading.js'; diff --git a/packages/frontend/src/pages/admin/custom-emojis-manager.local.register.vue b/packages/frontend/src/pages/admin/custom-emojis-manager.local.register.vue index d6ee8ea49c..eff7efd0fa 100644 --- a/packages/frontend/src/pages/admin/custom-emojis-manager.local.register.vue +++ b/packages/frontend/src/pages/admin/custom-emojis-manager.local.register.vue @@ -18,11 +18,6 @@ SPDX-License-Identifier: AGPL-3.0-only </option> </MkSelect> - <MkSwitch v-model="keepOriginalUploading"> - <template #label>{{ i18n.ts.keepOriginalUploading }}</template> - <template #caption>{{ i18n.ts.keepOriginalUploadingDescription }}</template> - </MkSwitch> - <MkSwitch v-model="directoryToCategory"> <template #label>{{ i18n.ts._customEmojisManager._local._register.directoryToCategoryLabel }}</template> <template #caption>{{ i18n.ts._customEmojisManager._local._register.directoryToCategoryCaption }}</template> @@ -78,7 +73,12 @@ SPDX-License-Identifier: AGPL-3.0-only /* eslint-disable @typescript-eslint/no-non-null-assertion */ import * as Misskey from 'misskey-js'; import { onMounted, ref, useCssModule } from 'vue'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import type { RequestLogItem } from '@/pages/admin/custom-emojis-manager.impl.js'; +import type { GridCellValidationEvent, GridCellValueChangeEvent, GridEvent } from '@/components/grid/grid-event.js'; +import type { DroppedFile } from '@/utility/file-drop.js'; +import type { GridSetting } from '@/components/grid/grid.js'; +import type { GridRow } from '@/components/grid/row.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { emptyStrToEmptyArray, emptyStrToNull, @@ -88,22 +88,17 @@ import MkGrid from '@/components/grid/MkGrid.vue'; import { i18n } from '@/i18n.js'; import MkSelect from '@/components/MkSelect.vue'; import MkSwitch from '@/components/MkSwitch.vue'; -import { defaultStore } from '@/store.js'; import MkFolder from '@/components/MkFolder.vue'; import MkButton from '@/components/MkButton.vue'; import * as os from '@/os.js'; import { validators } from '@/components/grid/cell-validators.js'; -import { chooseFileFromDrive, chooseFileFromPc } from '@/scripts/select-file.js'; -import { uploadFile } from '@/scripts/upload.js'; -import { extractDroppedItems, flattenDroppedFiles } from '@/scripts/file-drop.js'; +import { chooseFileFromDrive, chooseFileFromPc } from '@/utility/select-file.js'; +import { uploadFile } from '@/utility/upload.js'; +import { extractDroppedItems, flattenDroppedFiles } from '@/utility/file-drop.js'; import XRegisterLogs from '@/pages/admin/custom-emojis-manager.logs.vue'; import { copyGridDataToClipboard } from '@/components/grid/grid-utils.js'; -import type { RequestLogItem } from '@/pages/admin/custom-emojis-manager.impl.js'; -import type { GridCellValidationEvent, GridCellValueChangeEvent, GridEvent } from '@/components/grid/grid-event.js'; -import type { DroppedFile } from '@/scripts/file-drop.js'; -import type { GridSetting } from '@/components/grid/grid.js'; -import type { GridRow } from '@/components/grid/row.js'; +import { prefer } from '@/preferences.js'; const MAXIMUM_EMOJI_REGISTER_COUNT = 100; @@ -244,8 +239,7 @@ function setupGrid(): GridSetting { const uploadFolders = ref<FolderItem[]>([]); const gridItems = ref<GridItem[]>([]); -const selectedFolderId = ref(defaultStore.state.uploadFolder); -const keepOriginalUploading = ref(defaultStore.state.keepOriginalUploading); +const selectedFolderId = ref(prefer.s.uploadFolder); const directoryToCategory = ref<boolean>(false); const registerButtonDisabled = ref<boolean>(false); const requestLogs = ref<RequestLogItem[]>([]); @@ -338,7 +332,7 @@ async function onDrop(ev: DragEvent) { it.file, selectedFolderId.value, it.file.name.replace(/\.[^.]+$/, ''), - keepOriginalUploading.value, + true, ), }), ), @@ -373,7 +367,7 @@ async function onFileSelectClicked() { true, { uploadFolder: selectedFolderId.value, - keepOriginal: keepOriginalUploading.value, + keepOriginal: true, // 拡張子は消す nameConverter: (file) => file.name.replace(/\.[a-zA-Z0-9]+$/, ''), }, diff --git a/packages/frontend/src/pages/admin/custom-emojis-manager.remote.vue b/packages/frontend/src/pages/admin/custom-emojis-manager.remote.vue index 609d445d79..c868a700f1 100644 --- a/packages/frontend/src/pages/admin/custom-emojis-manager.remote.vue +++ b/packages/frontend/src/pages/admin/custom-emojis-manager.remote.vue @@ -143,7 +143,7 @@ SPDX-License-Identifier: AGPL-3.0-only import { computed, onMounted, ref, useCssModule } from 'vue'; import * as Misskey from 'misskey-js'; import MkRemoteEmojiEditDialog from '@/components/MkRemoteEmojiEditDialog.vue'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; import MkButton from '@/components/MkButton.vue'; import MkInput from '@/components/MkInput.vue'; @@ -152,7 +152,7 @@ import { emptyStrToUndefined, gridSortOrderKeys } from '@/pages/admin/custom-emo import MkFolder from '@/components/MkFolder.vue'; import XRegisterLogs from '@/pages/admin/custom-emojis-manager.logs.vue'; import * as os from '@/os.js'; -import { deviceKind } from '@/scripts/device-kind.js'; +import { deviceKind } from '@/utility/device-kind.js'; import MkPagingButtons from '@/components/MkPagingButtons.vue'; import MkSortOrderEditor from '@/components/MkSortOrderEditor.vue'; import { useLoading } from '@/components/hook/useLoading.js'; diff --git a/packages/frontend/src/pages/admin/custom-emojis-manager2.vue b/packages/frontend/src/pages/admin/custom-emojis-manager2.vue index fb930064ff..7667206fa8 100644 --- a/packages/frontend/src/pages/admin/custom-emojis-manager2.vue +++ b/packages/frontend/src/pages/admin/custom-emojis-manager2.vue @@ -18,7 +18,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script setup lang="ts"> import { computed, ref } from 'vue'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; import XGridLocalComponent from '@/pages/admin/custom-emojis-manager.local.vue'; import XGridRemoteComponent from '@/pages/admin/custom-emojis-manager.remote.vue'; import MkPageHeader from '@/components/global/MkPageHeader.vue'; @@ -36,7 +36,7 @@ const headerTabs = computed(() => [{ title: i18n.ts.remote, }]); -definePageMetadata(computed(() => ({ +definePage(computed(() => ({ title: i18n.ts.customEmojis, icon: 'ti ti-icons', needWideArea: true, diff --git a/packages/frontend/src/pages/admin/database.vue b/packages/frontend/src/pages/admin/database.vue index e092efd92c..6691142a64 100644 --- a/packages/frontend/src/pages/admin/database.vue +++ b/packages/frontend/src/pages/admin/database.vue @@ -4,8 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> +<PageWithHeader :actions="headerActions" :tabs="headerTabs"> <MkSpacer :contentMax="800" :marginMin="16" :marginMax="32"> <FormSuspense v-slot="{ result: database }" :p="databasePromiseFactory"> <MkKeyValue v-for="table in database" :key="table[0]" oneline style="margin: 1em 0;"> @@ -14,18 +13,18 @@ SPDX-License-Identifier: AGPL-3.0-only </MkKeyValue> </FormSuspense> </MkSpacer> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> import { computed } from 'vue'; import FormSuspense from '@/components/form/suspense.vue'; import MkKeyValue from '@/components/MkKeyValue.vue'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import bytes from '@/filters/bytes.js'; import number from '@/filters/number.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; const databasePromiseFactory = () => misskeyApi('admin/get-table-stats').then(res => Object.entries(res).sort((a, b) => b[1].size - a[1].size)); @@ -33,7 +32,7 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.database, icon: 'ti ti-database', })); diff --git a/packages/frontend/src/pages/admin/email-settings.vue b/packages/frontend/src/pages/admin/email-settings.vue index 5b60e67dac..ab584ba9da 100644 --- a/packages/frontend/src/pages/admin/email-settings.vue +++ b/packages/frontend/src/pages/admin/email-settings.vue @@ -73,10 +73,10 @@ import FormSuspense from '@/components/form/suspense.vue'; import FormSplit from '@/components/form/split.vue'; import FormSection from '@/components/form/section.vue'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { fetchInstance, instance } from '@/instance.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; import MkButton from '@/components/MkButton.vue'; const enableEmail = ref<boolean>(false); @@ -130,7 +130,7 @@ function save() { const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.emailServer, icon: 'ti ti-mail', })); diff --git a/packages/frontend/src/pages/admin/external-services.vue b/packages/frontend/src/pages/admin/external-services.vue index a312ecce12..a6557114dc 100644 --- a/packages/frontend/src/pages/admin/external-services.vue +++ b/packages/frontend/src/pages/admin/external-services.vue @@ -49,10 +49,10 @@ import MkButton from '@/components/MkButton.vue'; import MkSwitch from '@/components/MkSwitch.vue'; import FormSuspense from '@/components/form/suspense.vue'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { fetchInstance } from '@/instance.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; import MkFolder from '@/components/MkFolder.vue'; const deeplAuthKey = ref<string>(''); @@ -88,7 +88,7 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.externalServices, icon: 'ti ti-link', })); diff --git a/packages/frontend/src/pages/admin/federation.vue b/packages/frontend/src/pages/admin/federation.vue index e7b9fd8621..7f6424225b 100644 --- a/packages/frontend/src/pages/admin/federation.vue +++ b/packages/frontend/src/pages/admin/federation.vue @@ -67,7 +67,7 @@ import MkPagination from '@/components/MkPagination.vue'; import MkInstanceCardMini from '@/components/MkInstanceCardMini.vue'; import FormSplit from '@/components/form/split.vue'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; const host = ref(''); const state = ref('federating'); @@ -112,7 +112,7 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.federation, icon: 'ti ti-whirl', })); diff --git a/packages/frontend/src/pages/admin/files.vue b/packages/frontend/src/pages/admin/files.vue index 4cc859227f..e15724c2a7 100644 --- a/packages/frontend/src/pages/admin/files.vue +++ b/packages/frontend/src/pages/admin/files.vue @@ -42,9 +42,9 @@ import MkInput from '@/components/MkInput.vue'; import MkSelect from '@/components/MkSelect.vue'; import MkFileListForAdmin from '@/components/MkFileListForAdmin.vue'; import * as os from '@/os.js'; -import { lookupFile } from '@/scripts/admin-lookup.js'; +import { lookupFile } from '@/utility/admin-lookup.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; const origin = ref('local'); const type = ref<string | null>(null); @@ -85,7 +85,7 @@ const headerActions = computed(() => [{ const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.files, icon: 'ti ti-cloud', })); diff --git a/packages/frontend/src/pages/admin/index.vue b/packages/frontend/src/pages/admin/index.vue index 672ee8da18..8d03838a8f 100644 --- a/packages/frontend/src/pages/admin/index.vue +++ b/packages/frontend/src/pages/admin/index.vue @@ -25,25 +25,25 @@ SPDX-License-Identifier: AGPL-3.0-only </MkSpacer> </div> <div v-if="!(narrow && currentPage?.route.name == null)" class="main"> - <RouterView nested/> + <NestedRouterView/> </div> </div> </template> <script lang="ts" setup> import { onActivated, onMounted, onUnmounted, provide, watch, ref, computed } from 'vue'; +import type { SuperMenuDef } from '@/components/MkSuperMenu.vue'; +import type { PageMetadata } from '@/page.js'; import { i18n } from '@/i18n.js'; import MkSuperMenu from '@/components/MkSuperMenu.vue'; -import type { SuperMenuDef } from '@/components/MkSuperMenu.vue'; import MkInfo from '@/components/MkInfo.vue'; import { instance } from '@/instance.js'; -import { lookup } from '@/scripts/lookup.js'; +import { lookup } from '@/utility/lookup.js'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { lookupUser, lookupUserByEmail, lookupFile } from '@/scripts/admin-lookup.js'; -import { definePageMetadata, provideMetadataReceiver, provideReactiveMetadata } from '@/scripts/page-metadata.js'; -import type { PageMetadata } from '@/scripts/page-metadata.js'; -import { useRouter } from '@/router/supplier.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { lookupUser, lookupUserByEmail, lookupFile } from '@/utility/admin-lookup.js'; +import { definePage, provideMetadataReceiver, provideReactiveMetadata } from '@/page.js'; +import { useRouter } from '@/router.js'; const isEmpty = (x: string | null) => x == null || x === ''; @@ -318,7 +318,7 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => INFO.value); +definePage(() => INFO.value); defineExpose({ header: { @@ -332,7 +332,6 @@ defineExpose({ &.wide { display: flex; margin: 0 auto; - height: 100%; > .nav { position: sticky; @@ -342,7 +341,7 @@ defineExpose({ box-sizing: border-box; border-right: solid 0.5px var(--MI_THEME-divider); overflow: auto; - height: 100dvh; + height: 100cqh; } > .main { diff --git a/packages/frontend/src/pages/admin/invites.vue b/packages/frontend/src/pages/admin/invites.vue index 5189e12899..6e6476b027 100644 --- a/packages/frontend/src/pages/admin/invites.vue +++ b/packages/frontend/src/pages/admin/invites.vue @@ -55,22 +55,22 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { computed, ref, shallowRef } from 'vue'; +import { computed, ref, useTemplateRef } from 'vue'; import XHeader from './_header_.vue'; +import type { Paging } from '@/components/MkPagination.vue'; import { i18n } from '@/i18n.js'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import MkButton from '@/components/MkButton.vue'; import MkFolder from '@/components/MkFolder.vue'; import MkSelect from '@/components/MkSelect.vue'; import MkInput from '@/components/MkInput.vue'; import MkSwitch from '@/components/MkSwitch.vue'; import MkPagination from '@/components/MkPagination.vue'; -import type { Paging } from '@/components/MkPagination.vue'; import MkInviteCode from '@/components/MkInviteCode.vue'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; -const pagingComponent = shallowRef<InstanceType<typeof MkPagination>>(); +const pagingComponent = useTemplateRef('pagingComponent'); const type = ref('all'); const sort = ref('+createdAt'); @@ -114,7 +114,7 @@ function deleted(id: string) { const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.invite, icon: 'ti ti-user-plus', })); diff --git a/packages/frontend/src/pages/admin/moderation.vue b/packages/frontend/src/pages/admin/moderation.vue index ac1fe7783c..3507758b6e 100644 --- a/packages/frontend/src/pages/admin/moderation.vue +++ b/packages/frontend/src/pages/admin/moderation.vue @@ -134,10 +134,10 @@ import MkInput from '@/components/MkInput.vue'; import MkTextarea from '@/components/MkTextarea.vue'; import FormSuspense from '@/components/form/suspense.vue'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { fetchInstance } from '@/instance.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; import MkButton from '@/components/MkButton.vue'; import FormLink from '@/components/form/link.vue'; import MkFolder from '@/components/MkFolder.vue'; @@ -259,7 +259,7 @@ function save_mediaSilencedHosts() { const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.moderation, icon: 'ti ti-shield', })); diff --git a/packages/frontend/src/pages/admin/modlog.ModLog.vue b/packages/frontend/src/pages/admin/modlog.ModLog.vue index 9bbe5f2e42..7ab9417267 100644 --- a/packages/frontend/src/pages/admin/modlog.ModLog.vue +++ b/packages/frontend/src/pages/admin/modlog.ModLog.vue @@ -40,6 +40,7 @@ SPDX-License-Identifier: AGPL-3.0-only 'deletePage', 'deleteFlash', 'deleteGalleryPost', + 'deleteChatRoom', ].includes(log.type) }" >{{ i18n.ts._moderationLogTypes[log.type] }}</b> @@ -80,6 +81,7 @@ SPDX-License-Identifier: AGPL-3.0-only <span v-else-if="log.type === 'deletePage'">: @{{ log.info.pageUserUsername }}</span> <span v-else-if="log.type === 'deleteFlash'">: @{{ log.info.flashUserUsername }}</span> <span v-else-if="log.type === 'deleteGalleryPost'">: @{{ log.info.postUserUsername }}</span> + <span v-else-if="log.type === 'deleteChatRoom'">: @{{ log.info.room.name }}</span> </template> <template #icon> <MkAvatar :user="log.user" :class="$style.avatar"/> diff --git a/packages/frontend/src/pages/admin/modlog.vue b/packages/frontend/src/pages/admin/modlog.vue index c9eaf07531..1fb2c4b726 100644 --- a/packages/frontend/src/pages/admin/modlog.vue +++ b/packages/frontend/src/pages/admin/modlog.vue @@ -30,7 +30,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { computed, shallowRef, ref } from 'vue'; +import { computed, useTemplateRef, ref } from 'vue'; import * as Misskey from 'misskey-js'; import XHeader from './_header_.vue'; import XModLog from './modlog.ModLog.vue'; @@ -38,10 +38,10 @@ import MkSelect from '@/components/MkSelect.vue'; import MkInput from '@/components/MkInput.vue'; import MkPagination from '@/components/MkPagination.vue'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; import MkDateSeparatedList from '@/components/MkDateSeparatedList.vue'; -const logs = shallowRef<InstanceType<typeof MkPagination>>(); +const logs = useTemplateRef('logs'); const type = ref<string | null>(null); const moderatorId = ref(''); @@ -59,7 +59,7 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.moderationLogs, icon: 'ti ti-list-search', })); diff --git a/packages/frontend/src/pages/admin/object-storage.vue b/packages/frontend/src/pages/admin/object-storage.vue index d5a664934c..da96eb4881 100644 --- a/packages/frontend/src/pages/admin/object-storage.vue +++ b/packages/frontend/src/pages/admin/object-storage.vue @@ -90,10 +90,10 @@ import MkInput from '@/components/MkInput.vue'; import FormSuspense from '@/components/form/suspense.vue'; import FormSplit from '@/components/form/split.vue'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { fetchInstance } from '@/instance.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; import MkButton from '@/components/MkButton.vue'; const useObjectStorage = ref<boolean>(false); @@ -149,7 +149,7 @@ function save() { const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.objectStorage, icon: 'ti ti-cloud', })); diff --git a/packages/frontend/src/pages/admin/overview.active-users.vue b/packages/frontend/src/pages/admin/overview.active-users.vue index 79dd6fd5fd..5b7f669f6b 100644 --- a/packages/frontend/src/pages/admin/overview.active-users.vue +++ b/packages/frontend/src/pages/admin/overview.active-users.vue @@ -13,18 +13,18 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted, shallowRef, ref } from 'vue'; +import { onMounted, useTemplateRef, ref } from 'vue'; import { Chart } from 'chart.js'; import gradient from 'chartjs-plugin-gradient'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { defaultStore } from '@/store.js'; -import { useChartTooltip } from '@/scripts/use-chart-tooltip.js'; -import { chartVLine } from '@/scripts/chart-vline.js'; -import { initChart } from '@/scripts/init-chart.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { store } from '@/store.js'; +import { useChartTooltip } from '@/use/use-chart-tooltip.js'; +import { chartVLine } from '@/utility/chart-vline.js'; +import { initChart } from '@/utility/init-chart.js'; initChart(); -const chartEl = shallowRef<HTMLCanvasElement>(null); +const chartEl = useTemplateRef('chartEl'); const now = new Date(); let chartInstance: Chart = null; const chartLimit = 7; @@ -54,7 +54,7 @@ async function renderChart() { const raw = await misskeyApi('charts/active-users', { limit: chartLimit, span: 'day' }); - const vLineColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)'; + const vLineColor = store.s.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)'; const colorRead = '#3498db'; const colorWrite = '#2ecc71'; diff --git a/packages/frontend/src/pages/admin/overview.ap-requests.vue b/packages/frontend/src/pages/admin/overview.ap-requests.vue index 570fcddc07..4c06d94d6d 100644 --- a/packages/frontend/src/pages/admin/overview.ap-requests.vue +++ b/packages/frontend/src/pages/admin/overview.ap-requests.vue @@ -20,22 +20,22 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted, shallowRef, ref } from 'vue'; +import { onMounted, useTemplateRef, ref } from 'vue'; import { Chart } from 'chart.js'; import gradient from 'chartjs-plugin-gradient'; import isChromatic from 'chromatic'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { useChartTooltip } from '@/scripts/use-chart-tooltip.js'; -import { chartVLine } from '@/scripts/chart-vline.js'; -import { defaultStore } from '@/store.js'; -import { alpha } from '@/scripts/color.js'; -import { initChart } from '@/scripts/init-chart.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { useChartTooltip } from '@/use/use-chart-tooltip.js'; +import { chartVLine } from '@/utility/chart-vline.js'; +import { store } from '@/store.js'; +import { alpha } from '@/utility/color.js'; +import { initChart } from '@/utility/init-chart.js'; initChart(); const chartLimit = 50; -const chartEl = shallowRef<HTMLCanvasElement>(); -const chartEl2 = shallowRef<HTMLCanvasElement>(); +const chartEl = useTemplateRef('chartEl'); +const chartEl2 = useTemplateRef('chartEl2'); const fetching = ref(true); const { handler: externalTooltipHandler } = useChartTooltip(); @@ -68,7 +68,7 @@ onMounted(async () => { const raw = await misskeyApi('charts/ap-request', { limit: chartLimit, span: 'day' }); - const vLineColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)'; + const vLineColor = store.s.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)'; const succColor = '#87e000'; const failColor = '#ff4400'; diff --git a/packages/frontend/src/pages/admin/overview.federation.vue b/packages/frontend/src/pages/admin/overview.federation.vue index 7d80f8c2e3..6d6d431863 100644 --- a/packages/frontend/src/pages/admin/overview.federation.vue +++ b/packages/frontend/src/pages/admin/overview.federation.vue @@ -50,11 +50,11 @@ import { onMounted, ref } from 'vue'; import XPie from './overview.pie.vue'; import type { InstanceForPie } from './overview.pie.vue'; import * as os from '@/os.js'; -import { misskeyApiGet } from '@/scripts/misskey-api.js'; +import { misskeyApiGet } from '@/utility/misskey-api.js'; import number from '@/filters/number.js'; import MkNumberDiff from '@/components/MkNumberDiff.vue'; import { i18n } from '@/i18n.js'; -import { useChartTooltip } from '@/scripts/use-chart-tooltip.js'; +import { useChartTooltip } from '@/use/use-chart-tooltip.js'; const topSubInstancesForPie = ref<InstanceForPie[] | null>(null); const topPubInstancesForPie = ref<InstanceForPie[] | null>(null); diff --git a/packages/frontend/src/pages/admin/overview.instances.vue b/packages/frontend/src/pages/admin/overview.instances.vue index 292e2e1dbc..c8291459fe 100644 --- a/packages/frontend/src/pages/admin/overview.instances.vue +++ b/packages/frontend/src/pages/admin/overview.instances.vue @@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div> - <Transition :name="defaultStore.state.animation ? '_transition_zoom' : ''" mode="out-in"> + <Transition :name="prefer.s.animation ? '_transition_zoom' : ''" mode="out-in"> <MkLoading v-if="fetching"/> <div v-else :class="$style.instances"> <MkA v-for="(instance, i) in instances" :key="instance.id" v-tooltip.mfm.noDelay="`${instance.name}\n${instance.host}\n${instance.softwareName} ${instance.softwareVersion}`" :to="`/instance-info/${instance.host}`" :class="$style.instance"> @@ -18,11 +18,11 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { ref } from 'vue'; -import { misskeyApi } from '@/scripts/misskey-api.js'; import * as Misskey from 'misskey-js'; import { useInterval } from '@@/js/use-interval.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import MkInstanceCardMini from '@/components/MkInstanceCardMini.vue'; -import { defaultStore } from '@/store.js'; +import { prefer } from '@/preferences.js'; const instances = ref<Misskey.entities.FederationInstance[]>([]); const fetching = ref(true); diff --git a/packages/frontend/src/pages/admin/overview.moderators.vue b/packages/frontend/src/pages/admin/overview.moderators.vue index f0691534c8..fb2c5ea13c 100644 --- a/packages/frontend/src/pages/admin/overview.moderators.vue +++ b/packages/frontend/src/pages/admin/overview.moderators.vue @@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div> - <Transition :name="defaultStore.state.animation ? '_transition_zoom' : ''" mode="out-in"> + <Transition :name="prefer.s.animation ? '_transition_zoom' : ''" mode="out-in"> <MkLoading v-if="fetching"/> <div v-else :class="$style.root" class="_panel"> <MkA v-for="user in moderators" :key="user.id" class="user" :to="`/admin/user/${user.id}`"> @@ -18,9 +18,9 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { onMounted, ref } from 'vue'; -import { misskeyApi } from '@/scripts/misskey-api.js'; import * as Misskey from 'misskey-js'; -import { defaultStore } from '@/store.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { prefer } from '@/preferences.js'; const moderators = ref<Misskey.entities.UserDetailed[] | null>(null); const fetching = ref(true); diff --git a/packages/frontend/src/pages/admin/overview.pie.vue b/packages/frontend/src/pages/admin/overview.pie.vue index a21ec6c464..86c5eff4da 100644 --- a/packages/frontend/src/pages/admin/overview.pie.vue +++ b/packages/frontend/src/pages/admin/overview.pie.vue @@ -8,10 +8,10 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted, shallowRef } from 'vue'; +import { onMounted, useTemplateRef } from 'vue'; import { Chart } from 'chart.js'; -import { useChartTooltip } from '@/scripts/use-chart-tooltip.js'; -import { initChart } from '@/scripts/init-chart.js'; +import { useChartTooltip } from '@/use/use-chart-tooltip.js'; +import { initChart } from '@/utility/init-chart.js'; export type InstanceForPie = { name: string, @@ -26,7 +26,7 @@ const props = defineProps<{ data: InstanceForPie[]; }>(); -const chartEl = shallowRef<HTMLCanvasElement>(null); +const chartEl = useTemplateRef('chartEl'); const { handler: externalTooltipHandler } = useChartTooltip({ position: 'middle', @@ -41,7 +41,7 @@ onMounted(() => { labels: props.data.map(x => x.name), datasets: [{ backgroundColor: props.data.map(x => x.color), - borderColor: getComputedStyle(document.documentElement).getPropertyValue('--MI_THEME-panel'), + borderColor: getComputedStyle(window.document.documentElement).getPropertyValue('--MI_THEME-panel'), borderWidth: 2, hoverOffset: 0, data: props.data.map(x => x.value), diff --git a/packages/frontend/src/pages/admin/overview.queue.chart.vue b/packages/frontend/src/pages/admin/overview.queue.chart.vue index 2efc17c888..6fc941a848 100644 --- a/packages/frontend/src/pages/admin/overview.queue.chart.vue +++ b/packages/frontend/src/pages/admin/overview.queue.chart.vue @@ -8,13 +8,13 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted, shallowRef } from 'vue'; +import { onMounted, useTemplateRef } from 'vue'; import { Chart } from 'chart.js'; -import { defaultStore } from '@/store.js'; -import { useChartTooltip } from '@/scripts/use-chart-tooltip.js'; -import { chartVLine } from '@/scripts/chart-vline.js'; -import { alpha } from '@/scripts/color.js'; -import { initChart } from '@/scripts/init-chart.js'; +import { store } from '@/store.js'; +import { useChartTooltip } from '@/use/use-chart-tooltip.js'; +import { chartVLine } from '@/utility/chart-vline.js'; +import { alpha } from '@/utility/color.js'; +import { initChart } from '@/utility/init-chart.js'; initChart(); @@ -22,7 +22,7 @@ const props = defineProps<{ type: string; }>(); -const chartEl = shallowRef<HTMLCanvasElement>(null); +const chartEl = useTemplateRef('chartEl'); const { handler: externalTooltipHandler } = useChartTooltip(); @@ -67,7 +67,7 @@ const color = '?' as never; onMounted(() => { - const vLineColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)'; + const vLineColor = store.s.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)'; chartInstance = new Chart(chartEl.value, { type: 'line', diff --git a/packages/frontend/src/pages/admin/overview.queue.vue b/packages/frontend/src/pages/admin/overview.queue.vue index de6b254412..cf07cddced 100644 --- a/packages/frontend/src/pages/admin/overview.queue.vue +++ b/packages/frontend/src/pages/admin/overview.queue.vue @@ -35,7 +35,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { markRaw, onMounted, onUnmounted, ref, shallowRef } from 'vue'; +import { markRaw, onMounted, onUnmounted, ref, useTemplateRef } from 'vue'; import * as Misskey from 'misskey-js'; import XChart from './overview.queue.chart.vue'; import type { ApQueueDomain } from '@/pages/admin/queue.vue'; @@ -48,10 +48,10 @@ const activeSincePrevTick = ref(0); const active = ref(0); const delayed = ref(0); const waiting = ref(0); -const chartProcess = shallowRef<InstanceType<typeof XChart>>(); -const chartActive = shallowRef<InstanceType<typeof XChart>>(); -const chartDelayed = shallowRef<InstanceType<typeof XChart>>(); -const chartWaiting = shallowRef<InstanceType<typeof XChart>>(); +const chartProcess = useTemplateRef('chartProcess'); +const chartActive = useTemplateRef('chartActive'); +const chartDelayed = useTemplateRef('chartDelayed'); +const chartWaiting = useTemplateRef('chartWaiting'); const props = defineProps<{ domain: ApQueueDomain; diff --git a/packages/frontend/src/pages/admin/overview.stats.vue b/packages/frontend/src/pages/admin/overview.stats.vue index 222e9f4673..fd8145b308 100644 --- a/packages/frontend/src/pages/admin/overview.stats.vue +++ b/packages/frontend/src/pages/admin/overview.stats.vue @@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div> - <Transition :name="defaultStore.state.animation ? '_transition_zoom' : ''" mode="out-in"> + <Transition :name="prefer.s.animation ? '_transition_zoom' : ''" mode="out-in"> <MkLoading v-if="fetching"/> <div v-else :class="$style.root"> <div class="item _panel users"> @@ -63,12 +63,12 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { onMounted, ref } from 'vue'; import * as Misskey from 'misskey-js'; -import { misskeyApi, misskeyApiGet } from '@/scripts/misskey-api.js'; +import { misskeyApi, misskeyApiGet } from '@/utility/misskey-api.js'; import MkNumberDiff from '@/components/MkNumberDiff.vue'; import MkNumber from '@/components/MkNumber.vue'; import { i18n } from '@/i18n.js'; import { customEmojis } from '@/custom-emojis.js'; -import { defaultStore } from '@/store.js'; +import { prefer } from '@/preferences.js'; const stats = ref<Misskey.entities.StatsResponse | null>(null); const usersComparedToThePrevDay = ref<number>(); diff --git a/packages/frontend/src/pages/admin/overview.users.vue b/packages/frontend/src/pages/admin/overview.users.vue index 8c9d7a8197..6a39f4561f 100644 --- a/packages/frontend/src/pages/admin/overview.users.vue +++ b/packages/frontend/src/pages/admin/overview.users.vue @@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div :class="$style.root"> - <Transition :name="defaultStore.state.animation ? '_transition_zoom' : ''" mode="out-in"> + <Transition :name="prefer.s.animation ? '_transition_zoom' : ''" mode="out-in"> <MkLoading v-if="fetching"/> <div v-else class="users"> <MkA v-for="(user, i) in newUsers" :key="user.id" :to="`/admin/user/${user.id}`" class="user"> @@ -18,11 +18,11 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { ref } from 'vue'; -import { misskeyApi } from '@/scripts/misskey-api.js'; import * as Misskey from 'misskey-js'; import { useInterval } from '@@/js/use-interval.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import MkUserCardMini from '@/components/MkUserCardMini.vue'; -import { defaultStore } from '@/store.js'; +import { prefer } from '@/preferences.js'; const newUsers = ref<Misskey.entities.UserDetailed[] | null>(null); const fetching = ref(true); diff --git a/packages/frontend/src/pages/admin/overview.vue b/packages/frontend/src/pages/admin/overview.vue index 1de4dc0dc8..616815a6a6 100644 --- a/packages/frontend/src/pages/admin/overview.vue +++ b/packages/frontend/src/pages/admin/overview.vue @@ -65,7 +65,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { markRaw, onMounted, onBeforeUnmount, nextTick, shallowRef, ref, computed } from 'vue'; +import { markRaw, onMounted, onBeforeUnmount, nextTick, shallowRef, ref, computed, useTemplateRef } from 'vue'; import * as Misskey from 'misskey-js'; import XFederation from './overview.federation.vue'; import XInstances from './overview.instances.vue'; @@ -79,13 +79,13 @@ import XModerators from './overview.moderators.vue'; import XHeatmap from './overview.heatmap.vue'; import type { InstanceForPie } from './overview.pie.vue'; import * as os from '@/os.js'; -import { misskeyApi, misskeyApiGet } from '@/scripts/misskey-api.js'; +import { misskeyApi, misskeyApiGet } from '@/utility/misskey-api.js'; import { useStream } from '@/stream.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; import MkFoldableSection from '@/components/MkFoldableSection.vue'; -const rootEl = shallowRef<HTMLElement>(); +const rootEl = useTemplateRef('rootEl'); const serverInfo = ref<Misskey.entities.ServerInfoResponse | null>(null); const topSubInstancesForPie = ref<InstanceForPie[] | null>(null); const topPubInstancesForPie = ref<InstanceForPie[] | null>(null); @@ -184,7 +184,7 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.dashboard, icon: 'ti ti-dashboard', })); diff --git a/packages/frontend/src/pages/admin/performance.vue b/packages/frontend/src/pages/admin/performance.vue index 12338f0bf9..6bb0918fea 100644 --- a/packages/frontend/src/pages/admin/performance.vue +++ b/packages/frontend/src/pages/admin/performance.vue @@ -111,15 +111,15 @@ SPDX-License-Identifier: AGPL-3.0-only import { ref, computed } from 'vue'; import XHeader from './_header_.vue'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { fetchInstance } from '@/instance.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; import MkSwitch from '@/components/MkSwitch.vue'; import MkFolder from '@/components/MkFolder.vue'; import MkInput from '@/components/MkInput.vue'; import MkLink from '@/components/MkLink.vue'; -import { useForm } from '@/scripts/use-form.js'; +import { useForm } from '@/use/use-form.js'; import MkFormFooter from '@/components/MkFormFooter.vue'; const meta = await misskeyApi('admin/meta'); @@ -202,7 +202,7 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.other, icon: 'ti ti-adjustments', })); diff --git a/packages/frontend/src/pages/admin/queue.chart.chart.vue b/packages/frontend/src/pages/admin/queue.chart.chart.vue index cc18898172..5dd2887024 100644 --- a/packages/frontend/src/pages/admin/queue.chart.chart.vue +++ b/packages/frontend/src/pages/admin/queue.chart.chart.vue @@ -8,13 +8,13 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted, shallowRef } from 'vue'; +import { onMounted, useTemplateRef } from 'vue'; import { Chart } from 'chart.js'; -import { defaultStore } from '@/store.js'; -import { useChartTooltip } from '@/scripts/use-chart-tooltip.js'; -import { chartVLine } from '@/scripts/chart-vline.js'; -import { alpha } from '@/scripts/color.js'; -import { initChart } from '@/scripts/init-chart.js'; +import { store } from '@/store.js'; +import { useChartTooltip } from '@/use/use-chart-tooltip.js'; +import { chartVLine } from '@/utility/chart-vline.js'; +import { alpha } from '@/utility/color.js'; +import { initChart } from '@/utility/init-chart.js'; initChart(); @@ -22,7 +22,7 @@ const props = defineProps<{ type: string; }>(); -const chartEl = shallowRef<HTMLCanvasElement>(null); +const chartEl = useTemplateRef('chartEl'); const { handler: externalTooltipHandler } = useChartTooltip(); @@ -67,7 +67,7 @@ const color = '?' as never; onMounted(() => { - const vLineColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)'; + const vLineColor = store.s.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)'; chartInstance = new Chart(chartEl.value, { type: 'line', diff --git a/packages/frontend/src/pages/admin/queue.chart.vue b/packages/frontend/src/pages/admin/queue.chart.vue index 7c171ba0e1..1ba02d6e0e 100644 --- a/packages/frontend/src/pages/admin/queue.chart.vue +++ b/packages/frontend/src/pages/admin/queue.chart.vue @@ -48,12 +48,12 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { markRaw, onMounted, onUnmounted, ref, shallowRef } from 'vue'; +import { markRaw, onMounted, onUnmounted, ref, useTemplateRef } from 'vue'; import * as Misskey from 'misskey-js'; import XChart from './queue.chart.chart.vue'; import type { ApQueueDomain } from '@/pages/admin/queue.vue'; import number from '@/filters/number.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { useStream } from '@/stream.js'; import { i18n } from '@/i18n.js'; import MkFolder from '@/components/MkFolder.vue'; @@ -65,10 +65,10 @@ const active = ref(0); const delayed = ref(0); const waiting = ref(0); const jobs = ref<Misskey.Endpoints[`admin/queue/${ApQueueDomain}-delayed`]['res']>([]); -const chartProcess = shallowRef<InstanceType<typeof XChart>>(); -const chartActive = shallowRef<InstanceType<typeof XChart>>(); -const chartDelayed = shallowRef<InstanceType<typeof XChart>>(); -const chartWaiting = shallowRef<InstanceType<typeof XChart>>(); +const chartProcess = useTemplateRef('chartProcess'); +const chartActive = useTemplateRef('chartActive'); +const chartDelayed = useTemplateRef('chartDelayed'); +const chartWaiting = useTemplateRef('chartWaiting'); const props = defineProps<{ domain: ApQueueDomain; diff --git a/packages/frontend/src/pages/admin/queue.vue b/packages/frontend/src/pages/admin/queue.vue index 1a26c00ddb..b5aee1e51e 100644 --- a/packages/frontend/src/pages/admin/queue.vue +++ b/packages/frontend/src/pages/admin/queue.vue @@ -17,13 +17,13 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { ref, computed } from 'vue'; -import type { Ref } from 'vue'; +import * as config from '@@/js/config.js'; import XQueue from './queue.chart.vue'; import XHeader from './_header_.vue'; +import type { Ref } from 'vue'; import * as os from '@/os.js'; -import * as config from '@@/js/config.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; import MkButton from '@/components/MkButton.vue'; export type ApQueueDomain = 'deliver' | 'inbox'; @@ -54,14 +54,7 @@ function promoteAllQueues() { }); } -const headerActions = computed(() => [{ - asFullButton: true, - icon: 'ti ti-external-link', - text: i18n.ts.dashboard, - handler: () => { - window.open(config.url + '/queue', '_blank', 'noopener'); - }, -}]); +const headerActions = computed(() => []); const headerTabs = computed(() => [{ key: 'deliver', @@ -71,7 +64,7 @@ const headerTabs = computed(() => [{ title: 'Inbox', }]); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.jobQueue, icon: 'ti ti-clock-play', })); diff --git a/packages/frontend/src/pages/admin/relays.vue b/packages/frontend/src/pages/admin/relays.vue index 17e99e6593..a6280e7075 100644 --- a/packages/frontend/src/pages/admin/relays.vue +++ b/packages/frontend/src/pages/admin/relays.vue @@ -29,9 +29,9 @@ import * as Misskey from 'misskey-js'; import XHeader from './_header_.vue'; import MkButton from '@/components/MkButton.vue'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; const relays = ref<Misskey.entities.AdminRelaysListResponse>([]); @@ -84,7 +84,7 @@ const headerActions = computed(() => [{ const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.relays, icon: 'ti ti-planet', })); diff --git a/packages/frontend/src/pages/admin/roles.edit.vue b/packages/frontend/src/pages/admin/roles.edit.vue index 2b4006c3f7..7741064685 100644 --- a/packages/frontend/src/pages/admin/roles.edit.vue +++ b/packages/frontend/src/pages/admin/roles.edit.vue @@ -28,12 +28,12 @@ import { v4 as uuid } from 'uuid'; import XHeader from './_header_.vue'; import XEditor from './roles.editor.vue'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; import MkButton from '@/components/MkButton.vue'; import { rolesCache } from '@/cache.js'; -import { useRouter } from '@/router/supplier.js'; +import { useRouter } from '@/router.js'; const router = useRouter(); @@ -87,7 +87,7 @@ async function save() { const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: role.value ? `${i18n.ts._role.edit}: ${role.value.name}` : i18n.ts._role.new, icon: 'ti ti-badge', })); diff --git a/packages/frontend/src/pages/admin/roles.editor.vue b/packages/frontend/src/pages/admin/roles.editor.vue index d05f52334e..930a63f5a9 100644 --- a/packages/frontend/src/pages/admin/roles.editor.vue +++ b/packages/frontend/src/pages/admin/roles.editor.vue @@ -52,6 +52,11 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </MkFolder> + <MkSwitch v-model="role.preserveAssignmentOnMoveAccount" :readonly="readonly"> + <template #label>{{ i18n.ts._role.preserveAssignmentOnMoveAccount }}</template> + <template #caption>{{ i18n.ts._role.preserveAssignmentOnMoveAccount_description }}</template> + </MkSwitch> + <MkSwitch v-model="role.canEditMembersByModerator" :readonly="readonly"> <template #label>{{ i18n.ts._role.canEditMembersByModerator }}</template> <template #caption>{{ i18n.ts._role.descriptionOfCanEditMembersByModerator }}</template> @@ -160,6 +165,29 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </MkFolder> + <MkFolder v-if="matchQuery([i18n.ts._role._options.chatAvailability, 'chatAvailability'])"> + <template #label>{{ i18n.ts._role._options.chatAvailability }}</template> + <template #suffix> + <span v-if="role.policies.chatAvailability.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span> + <span v-else>{{ role.policies.chatAvailability.value === 'available' ? i18n.ts.yes : role.policies.chatAvailability.value === 'readonly' ? i18n.ts.readonly : i18n.ts.no }}</span> + <span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.chatAvailability)"></i></span> + </template> + <div class="_gaps"> + <MkSwitch v-model="role.policies.chatAvailability.useDefault" :readonly="readonly"> + <template #label>{{ i18n.ts._role.useBaseValue }}</template> + </MkSwitch> + <MkSelect v-model="role.policies.chatAvailability.value" :disabled="role.policies.chatAvailability.useDefault" :readonly="readonly"> + <template #label>{{ i18n.ts.enable }}</template> + <option value="available">{{ i18n.ts.enabled }}</option> + <option value="readonly">{{ i18n.ts.readonly }}</option> + <option value="unavailable">{{ i18n.ts.disabled }}</option> + </MkSelect> + <MkRange v-model="role.policies.chatAvailability.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''"> + <template #label>{{ i18n.ts._role.priority }}</template> + </MkRange> + </div> + </MkFolder> + <MkFolder v-if="matchQuery([i18n.ts._role._options.mentionMax, 'mentionLimit'])"> <template #label>{{ i18n.ts._role._options.mentionMax }}</template> <template #suffix> @@ -710,7 +738,7 @@ import MkRange from '@/components/MkRange.vue'; import FormSlot from '@/components/form/slot.vue'; import { i18n } from '@/i18n.js'; import { instance } from '@/instance.js'; -import { deepClone } from '@/scripts/clone.js'; +import { deepClone } from '@/utility/clone.js'; const emit = defineEmits<{ (ev: 'update:modelValue', v: any): void; diff --git a/packages/frontend/src/pages/admin/roles.role.vue b/packages/frontend/src/pages/admin/roles.role.vue index 1c237a69b4..a978927471 100644 --- a/packages/frontend/src/pages/admin/roles.role.vue +++ b/packages/frontend/src/pages/admin/roles.role.vue @@ -28,7 +28,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkPagination :pagination="usersPagination"> <template #empty> <div class="_fullinfo"> - <img :src="infoImageUrl" class="_ghost"/> + <img :src="infoImageUrl" draggable="false"/> <div>{{ i18n.ts.noUsers }}</div> </div> </template> @@ -67,15 +67,15 @@ import XHeader from './_header_.vue'; import XEditor from './roles.editor.vue'; import MkFolder from '@/components/MkFolder.vue'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; import MkButton from '@/components/MkButton.vue'; import MkUserCardMini from '@/components/MkUserCardMini.vue'; import MkInfo from '@/components/MkInfo.vue'; import MkPagination from '@/components/MkPagination.vue'; import { infoImageUrl } from '@/instance.js'; -import { useRouter } from '@/router/supplier.js'; +import { useRouter } from '@/router.js'; const router = useRouter(); @@ -170,7 +170,7 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: `${i18n.ts.role}: ${role.name}`, icon: 'ti ti-badge', })); @@ -184,7 +184,7 @@ definePageMetadata(() => ({ .userItemSub { padding: 6px 12px; font-size: 85%; - color: var(--MI_THEME-fgTransparentWeak); + color: color(from var(--MI_THEME-fg) srgb r g b / 0.75); } .userItemMainBody { diff --git a/packages/frontend/src/pages/admin/roles.vue b/packages/frontend/src/pages/admin/roles.vue index 782c1256de..7c950957cf 100644 --- a/packages/frontend/src/pages/admin/roles.vue +++ b/packages/frontend/src/pages/admin/roles.vue @@ -51,6 +51,17 @@ SPDX-License-Identifier: AGPL-3.0-only </MkSwitch> </MkFolder> + <MkFolder v-if="matchQuery([i18n.ts._role._options.chatAvailability, 'chatAvailability'])"> + <template #label>{{ i18n.ts._role._options.chatAvailability }}</template> + <template #suffix>{{ policies.chatAvailability === 'available' ? i18n.ts.yes : policies.chatAvailability === 'readonly' ? i18n.ts.readonly : i18n.ts.no }}</template> + <MkSelect v-model="policies.chatAvailability"> + <template #label>{{ i18n.ts.enable }}</template> + <option value="available">{{ i18n.ts.enabled }}</option> + <option value="readonly">{{ i18n.ts.readonly }}</option> + <option value="unavailable">{{ i18n.ts.disabled }}</option> + </MkSelect> + </MkFolder> + <MkFolder v-if="matchQuery([i18n.ts._role._options.mentionMax, 'mentionLimit'])"> <template #label>{{ i18n.ts._role._options.mentionMax }}</template> <template #suffix>{{ policies.mentionLimit }}</template> @@ -287,15 +298,16 @@ import MkInput from '@/components/MkInput.vue'; import MkFolder from '@/components/MkFolder.vue'; import MkSwitch from '@/components/MkSwitch.vue'; import MkButton from '@/components/MkButton.vue'; +import MkSelect from '@/components/MkSelect.vue'; import MkRange from '@/components/MkRange.vue'; import MkRolePreview from '@/components/MkRolePreview.vue'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; import { instance, fetchInstance } from '@/instance.js'; import MkFoldableSection from '@/components/MkFoldableSection.vue'; -import { useRouter } from '@/router/supplier.js'; +import { useRouter } from '@/router.js'; const router = useRouter(); const baseRoleQ = ref(''); @@ -338,7 +350,7 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.roles, icon: 'ti ti-badges', })); diff --git a/packages/frontend/src/pages/admin/security.vue b/packages/frontend/src/pages/admin/security.vue index 975a4a1265..13f57b8549 100644 --- a/packages/frontend/src/pages/admin/security.vue +++ b/packages/frontend/src/pages/admin/security.vue @@ -131,11 +131,11 @@ import MkRange from '@/components/MkRange.vue'; import MkInput from '@/components/MkInput.vue'; import MkTextarea from '@/components/MkTextarea.vue'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { fetchInstance } from '@/instance.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; -import { useForm } from '@/scripts/use-form.js'; +import { definePage } from '@/page.js'; +import { useForm } from '@/use/use-form.js'; import MkFormFooter from '@/components/MkFormFooter.vue'; const meta = await misskeyApi('admin/meta'); @@ -206,7 +206,7 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.security, icon: 'ti ti-lock', })); diff --git a/packages/frontend/src/pages/admin/server-rules.vue b/packages/frontend/src/pages/admin/server-rules.vue index cd50f92143..b8722d4112 100644 --- a/packages/frontend/src/pages/admin/server-rules.vue +++ b/packages/frontend/src/pages/admin/server-rules.vue @@ -46,7 +46,7 @@ import XHeader from './_header_.vue'; import * as os from '@/os.js'; import { fetchInstance, instance } from '@/instance.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; import MkButton from '@/components/MkButton.vue'; import MkInput from '@/components/MkInput.vue'; @@ -67,7 +67,7 @@ const remove = (index: number): void => { const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.serverRules, icon: 'ti ti-checkbox', })); diff --git a/packages/frontend/src/pages/admin/settings.vue b/packages/frontend/src/pages/admin/settings.vue index aed593fc54..6362ebd446 100644 --- a/packages/frontend/src/pages/admin/settings.vue +++ b/packages/frontend/src/pages/admin/settings.vue @@ -266,14 +266,14 @@ import MkTextarea from '@/components/MkTextarea.vue'; import MkInfo from '@/components/MkInfo.vue'; import FormSplit from '@/components/form/split.vue'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { fetchInstance, instance } from '@/instance.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; import MkButton from '@/components/MkButton.vue'; import MkFolder from '@/components/MkFolder.vue'; import MkKeyValue from '@/components/MkKeyValue.vue'; -import { useForm } from '@/scripts/use-form.js'; +import { useForm } from '@/use/use-form.js'; import MkFormFooter from '@/components/MkFormFooter.vue'; import MkRadios from '@/components/MkRadios.vue'; @@ -391,7 +391,7 @@ const proxyAccountForm = useForm({ const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.general, icon: 'ti ti-settings', })); @@ -400,6 +400,6 @@ definePageMetadata(() => ({ <style lang="scss" module> .subCaption { font-size: 0.85em; - color: var(--MI_THEME-fgTransparentWeak); + color: color(from var(--MI_THEME-fg) srgb r g b / 0.75); } </style> diff --git a/packages/frontend/src/pages/admin/system-webhook.item.vue b/packages/frontend/src/pages/admin/system-webhook.item.vue index 45f0fff107..b53667e98c 100644 --- a/packages/frontend/src/pages/admin/system-webhook.item.vue +++ b/packages/frontend/src/pages/admin/system-webhook.item.vue @@ -75,6 +75,6 @@ function onDeleteClick() { margin-right: 0.75em; flex-shrink: 0; text-align: center; - color: var(--MI_THEME-fgTransparentWeak); + color: color(from var(--MI_THEME-fg) srgb r g b / 0.75); } </style> diff --git a/packages/frontend/src/pages/admin/system-webhook.vue b/packages/frontend/src/pages/admin/system-webhook.vue index c59abda24a..d8eb9b92ee 100644 --- a/packages/frontend/src/pages/admin/system-webhook.vue +++ b/packages/frontend/src/pages/admin/system-webhook.vue @@ -30,11 +30,11 @@ import { computed, onMounted, ref } from 'vue'; import { entities } from 'misskey-js'; import XItem from './system-webhook.item.vue'; import FormSection from '@/components/form/section.vue'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; import { i18n } from '@/i18n.js'; import XHeader from '@/pages/admin/_header_.vue'; import MkButton from '@/components/MkButton.vue'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { showSystemWebhookEditorDialog } from '@/components/MkSystemWebhookEditor.impl.js'; import * as os from '@/os.js'; @@ -82,7 +82,7 @@ onMounted(async () => { await fetchWebhooks(); }); -definePageMetadata(() => ({ +definePage(() => ({ title: 'SystemWebhook', icon: 'ti ti-webhook', })); diff --git a/packages/frontend/src/pages/admin/users.vue b/packages/frontend/src/pages/admin/users.vue index 91104b676d..a44951a947 100644 --- a/packages/frontend/src/pages/admin/users.vue +++ b/packages/frontend/src/pages/admin/users.vue @@ -60,7 +60,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { computed, shallowRef, ref, watchEffect } from 'vue'; +import { computed, useTemplateRef, ref, watchEffect } from 'vue'; import XHeader from './_header_.vue'; import { defaultMemoryStorage } from '@/memory-storage'; import MkButton from '@/components/MkButton.vue'; @@ -68,9 +68,9 @@ import MkInput from '@/components/MkInput.vue'; import MkSelect from '@/components/MkSelect.vue'; import MkPagination from '@/components/MkPagination.vue'; import * as os from '@/os.js'; -import { lookupUser } from '@/scripts/admin-lookup.js'; +import { lookupUser } from '@/utility/admin-lookup.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; import MkUserCardMini from '@/components/MkUserCardMini.vue'; import { dateString } from '@/filters/date.js'; @@ -82,7 +82,7 @@ type SearchQuery = { hostname?: string; }; -const paginationComponent = shallowRef<InstanceType<typeof MkPagination>>(); +const paginationComponent = useTemplateRef('paginationComponent'); const storedQuery = JSON.parse(defaultMemoryStorage.getItem('admin-users-query') ?? '{}') as SearchQuery; const sort = ref(storedQuery.sort ?? '+createdAt'); @@ -169,7 +169,7 @@ watchEffect(() => { })); }); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.users, icon: 'ti ti-users', })); diff --git a/packages/frontend/src/pages/ads.vue b/packages/frontend/src/pages/ads.vue index b31807f9f5..700ac0bd1a 100644 --- a/packages/frontend/src/pages/ads.vue +++ b/packages/frontend/src/pages/ads.vue @@ -4,23 +4,21 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader/></template> - +<PageWithHeader> <MkSpacer :contentMax="500"> <div class="_gaps"> <MkAd v-for="ad in instance.ads" :key="ad.id" :specify="ad"/> </div> </MkSpacer> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; import { i18n } from '@/i18n.js'; import { instance } from '@/instance.js'; -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.ads, icon: 'ti ti-ad', })); diff --git a/packages/frontend/src/pages/announcement.vue b/packages/frontend/src/pages/announcement.vue index 56c10fb292..2e0c7d2f42 100644 --- a/packages/frontend/src/pages/announcement.vue +++ b/packages/frontend/src/pages/announcement.vue @@ -4,14 +4,13 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> +<PageWithHeader :actions="headerActions" :tabs="headerTabs"> <MkSpacer :contentMax="800"> <Transition - :enterActiveClass="defaultStore.state.animation ? $style.fadeEnterActive : ''" - :leaveActiveClass="defaultStore.state.animation ? $style.fadeLeaveActive : ''" - :enterFromClass="defaultStore.state.animation ? $style.fadeEnterFrom : ''" - :leaveToClass="defaultStore.state.animation ? $style.fadeLeaveTo : ''" + :enterActiveClass="prefer.s.animation ? $style.fadeEnterActive : ''" + :leaveActiveClass="prefer.s.animation ? $style.fadeLeaveActive : ''" + :enterFromClass="prefer.s.animation ? $style.fadeEnterFrom : ''" + :leaveToClass="prefer.s.animation ? $style.fadeLeaveTo : ''" mode="out-in" > <div v-if="announcement" :key="announcement.id" class="_panel" :class="$style.announcement"> @@ -44,7 +43,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkLoading v-else/> </Transition> </MkSpacer> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> @@ -52,11 +51,12 @@ import { ref, computed, watch } from 'vue'; import * as Misskey from 'misskey-js'; import MkButton from '@/components/MkButton.vue'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; -import { $i, updateAccountPartial } from '@/account.js'; -import { defaultStore } from '@/store.js'; +import { definePage } from '@/page.js'; +import { $i } from '@/i.js'; +import { prefer } from '@/preferences.js'; +import { updateCurrentAccountPartial } from '@/accounts.js'; const props = defineProps<{ announcementId: string; @@ -90,7 +90,7 @@ async function read(target: Misskey.entities.Announcement): Promise<void> { target.isRead = true; await misskeyApi('i/read-announcement', { announcementId: target.id }); if ($i) { - updateAccountPartial({ + updateCurrentAccountPartial({ unreadAnnouncements: $i.unreadAnnouncements.filter((a: { id: string; }) => a.id !== target.id), }); } @@ -102,7 +102,7 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: announcement.value ? announcement.value.title : i18n.ts.announcements, icon: 'ti ti-speakerphone', })); diff --git a/packages/frontend/src/pages/announcements.vue b/packages/frontend/src/pages/announcements.vue index 75c0fd98dc..1902267a6a 100644 --- a/packages/frontend/src/pages/announcements.vue +++ b/packages/frontend/src/pages/announcements.vue @@ -4,11 +4,10 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> +<PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"> <MkSpacer :contentMax="800"> <MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs"> - <div :key="tab" class="_gaps"> + <div class="_gaps"> <MkInfo v-if="$i && $i.hasUnreadAnnouncement && tab === 'current'" warn>{{ i18n.ts.youHaveUnreadAnnouncements }}</MkInfo> <MkPagination ref="paginationEl" :key="tab" v-slot="{items}" :pagination="tab === 'current' ? paginationCurrent : paginationPast" class="_gaps"> <section v-for="announcement in items" :key="announcement.id" class="_panel" :class="$style.announcement"> @@ -43,7 +42,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </MkHorizontalSwipe> </MkSpacer> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> @@ -53,10 +52,11 @@ import MkButton from '@/components/MkButton.vue'; import MkInfo from '@/components/MkInfo.vue'; import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; -import { $i, updateAccountPartial } from '@/account.js'; +import { definePage } from '@/page.js'; +import { $i } from '@/i.js'; +import { updateCurrentAccountPartial } from '@/accounts.js'; const paginationCurrent = { endpoint: 'announcements' as const, @@ -94,7 +94,7 @@ async function read(target) { return a; }); misskeyApi('i/read-announcement', { announcementId: target.id }); - updateAccountPartial({ + updateCurrentAccountPartial({ unreadAnnouncements: $i!.unreadAnnouncements.filter(a => a.id !== target.id), }); } @@ -111,7 +111,7 @@ const headerTabs = computed(() => [{ icon: 'ti ti-point', }]); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.announcements, icon: 'ti ti-speakerphone', })); diff --git a/packages/frontend/src/pages/antenna-timeline.vue b/packages/frontend/src/pages/antenna-timeline.vue index a01bafd996..030a2a46ad 100644 --- a/packages/frontend/src/pages/antenna-timeline.vue +++ b/packages/frontend/src/pages/antenna-timeline.vue @@ -4,8 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> +<PageWithHeader :actions="headerActions" :tabs="headerTabs"> <MkSpacer :contentMax="800"> <div ref="rootEl"> <div v-if="queue > 0" :class="$style.new"><button class="_buttonPrimary" :class="$style.newButton" @click="top()">{{ i18n.ts.newNoteRecived }}</button></div> @@ -20,19 +19,19 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </div> </MkSpacer> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> -import { computed, watch, ref, shallowRef } from 'vue'; +import { computed, watch, ref, useTemplateRef } from 'vue'; import * as Misskey from 'misskey-js'; +import { scrollInContainer } from '@@/js/scroll.js'; import MkTimeline from '@/components/MkTimeline.vue'; -import { scroll } from '@@/js/scroll.js'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { definePage } from '@/page.js'; import { i18n } from '@/i18n.js'; -import { useRouter } from '@/router/supplier.js'; +import { useRouter } from '@/router.js'; const router = useRouter(); @@ -42,15 +41,15 @@ const props = defineProps<{ const antenna = ref<Misskey.entities.Antenna | null>(null); const queue = ref(0); -const rootEl = shallowRef<HTMLElement>(); -const tlEl = shallowRef<InstanceType<typeof MkTimeline>>(); +const rootEl = useTemplateRef('rootEl'); +const tlEl = useTemplateRef('tlEl'); function queueUpdated(q) { queue.value = q; } function top() { - scroll(rootEl.value, { top: 0 }); + scrollInContainer(rootEl.value, { top: 0 }); } async function timetravel() { @@ -88,7 +87,7 @@ const headerActions = computed(() => antenna.value ? [{ const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: antenna.value ? antenna.value.name : i18n.ts.antennas, icon: 'ti ti-antenna', })); diff --git a/packages/frontend/src/pages/api-console.vue b/packages/frontend/src/pages/api-console.vue index 9bddb0a9d2..c2e877f3c7 100644 --- a/packages/frontend/src/pages/api-console.vue +++ b/packages/frontend/src/pages/api-console.vue @@ -4,8 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> +<PageWithHeader :actions="headerActions" :tabs="headerTabs"> <MkSpacer :contentMax="700"> <div class="_gaps_m"> <div class="_gaps_m"> @@ -30,7 +29,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </div> </MkSpacer> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> @@ -41,8 +40,8 @@ import MkButton from '@/components/MkButton.vue'; import MkInput from '@/components/MkInput.vue'; import MkTextarea from '@/components/MkTextarea.vue'; import MkSwitch from '@/components/MkSwitch.vue'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { definePage } from '@/page.js'; const body = ref('{}'); const endpoint = ref(''); @@ -87,7 +86,7 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: 'API console', icon: 'ti ti-terminal-2', })); diff --git a/packages/frontend/src/pages/auth.form.vue b/packages/frontend/src/pages/auth.form.vue index f4fb2ef4d5..5b1fd1a386 100644 --- a/packages/frontend/src/pages/auth.form.vue +++ b/packages/frontend/src/pages/auth.form.vue @@ -23,7 +23,7 @@ SPDX-License-Identifier: AGPL-3.0-only import { computed } from 'vue'; import * as Misskey from 'misskey-js'; import MkButton from '@/components/MkButton.vue'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; const props = defineProps<{ @@ -38,7 +38,7 @@ const emit = defineEmits<{ const app = computed(() => props.session.app); const name = computed(() => { - const el = document.createElement('div'); + const el = window.document.createElement('div'); el.textContent = app.value.name; return el.innerHTML; }); diff --git a/packages/frontend/src/pages/auth.vue b/packages/frontend/src/pages/auth.vue index 4170b4f73e..f0c36f4264 100644 --- a/packages/frontend/src/pages/auth.vue +++ b/packages/frontend/src/pages/auth.vue @@ -4,8 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> +<PageWithHeader :actions="headerActions" :tabs="headerTabs"> <MkSpacer :contentMax="500"> <div v-if="state == 'fetch-session-error'"> <p>{{ i18n.ts.somethingHappened }}</p> @@ -38,7 +37,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkSignin @login="onLogin"/> </div> </MkSpacer> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> @@ -46,10 +45,11 @@ import { onMounted, ref, computed } from 'vue'; import * as Misskey from 'misskey-js'; import XForm from './auth.form.vue'; import MkSignin from '@/components/MkSignin.vue'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { $i, login } from '@/account.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { $i } from '@/i.js'; +import { definePage } from '@/page.js'; import { i18n } from '@/i18n.js'; +import { login } from '@/accounts.js'; const props = defineProps<{ token: string; @@ -63,7 +63,7 @@ function accepted() { if (session.value && session.value.app.callbackUrl) { const url = new URL(session.value.app.callbackUrl); if (['javascript:', 'file:', 'data:', 'mailto:', 'tel:', 'vbscript:'].includes(url.protocol)) throw new Error('invalid url'); - location.href = `${session.value.app.callbackUrl}?token=${session.value.token}`; + window.location.href = `${session.value.app.callbackUrl}?token=${session.value.token}`; } } @@ -97,7 +97,7 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts._auth.shareAccessTitle, icon: 'ti ti-apps', })); diff --git a/packages/frontend/src/pages/avatar-decoration-edit-dialog.vue b/packages/frontend/src/pages/avatar-decoration-edit-dialog.vue index a834f1c5fd..5a5e305f80 100644 --- a/packages/frontend/src/pages/avatar-decoration-edit-dialog.vue +++ b/packages/frontend/src/pages/avatar-decoration-edit-dialog.vue @@ -68,14 +68,14 @@ import MkInput from '@/components/MkInput.vue'; import MkInfo from '@/components/MkInfo.vue'; import MkFolder from '@/components/MkFolder.vue'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; import MkSwitch from '@/components/MkSwitch.vue'; import MkRolePreview from '@/components/MkRolePreview.vue'; import MkTextarea from '@/components/MkTextarea.vue'; -import { signinRequired } from '@/account.js'; +import { ensureSignin } from '@/i.js'; -const $i = signinRequired(); +const $i = ensureSignin(); const props = defineProps<{ avatarDecoration?: any, @@ -213,7 +213,7 @@ async function del() { left: 0; padding: 12px; border-top: solid 0.5px var(--MI_THEME-divider); - background: var(--MI_THEME-acrylicBg); + background: color(from var(--MI_THEME-bg) srgb r g b / 0.5); -webkit-backdrop-filter: var(--MI-blur, blur(15px)); backdrop-filter: var(--MI-blur, blur(15px)); } diff --git a/packages/frontend/src/pages/avatar-decorations.vue b/packages/frontend/src/pages/avatar-decorations.vue index a5cafb1678..2bab449089 100644 --- a/packages/frontend/src/pages/avatar-decorations.vue +++ b/packages/frontend/src/pages/avatar-decorations.vue @@ -4,8 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> +<PageWithHeader :actions="headerActions" :tabs="headerTabs"> <MkSpacer :contentMax="900"> <div class="_gaps"> <div :class="$style.decorations"> @@ -22,19 +21,19 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </div> </MkSpacer> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> import { ref, computed, defineAsyncComponent } from 'vue'; import * as Misskey from 'misskey-js'; -import { signinRequired } from '@/account.js'; +import { ensureSignin } from '@/i.js'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; -const $i = signinRequired(); +const $i = ensureSignin(); const avatarDecorations = ref<Misskey.entities.AdminAvatarDecorationsListResponse>([]); @@ -86,7 +85,7 @@ const headerActions = computed(() => [{ const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.avatarDecorations, icon: 'ti ti-sparkles', })); diff --git a/packages/frontend/src/pages/channel-editor.vue b/packages/frontend/src/pages/channel-editor.vue index 6d8274a55c..084fee15cf 100644 --- a/packages/frontend/src/pages/channel-editor.vue +++ b/packages/frontend/src/pages/channel-editor.vue @@ -4,8 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> +<PageWithHeader :actions="headerActions" :tabs="headerTabs"> <MkSpacer :contentMax="700"> <div v-if="channelId == null || channel != null" class="_gaps_m"> <MkInput v-model="name"> @@ -65,7 +64,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </div> </MkSpacer> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> @@ -74,15 +73,15 @@ import * as Misskey from 'misskey-js'; import MkButton from '@/components/MkButton.vue'; import MkInput from '@/components/MkInput.vue'; import MkColorInput from '@/components/MkColorInput.vue'; -import { selectFile } from '@/scripts/select-file.js'; +import { selectFile } from '@/utility/select-file.js'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { definePage } from '@/page.js'; import { i18n } from '@/i18n.js'; import MkFolder from '@/components/MkFolder.vue'; import MkSwitch from '@/components/MkSwitch.vue'; import MkTextarea from '@/components/MkTextarea.vue'; -import { useRouter } from '@/router/supplier.js'; +import { useRouter } from '@/router.js'; const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default)); @@ -202,7 +201,7 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: props.channelId ? i18n.ts._channel.edit : i18n.ts._channel.create, icon: 'ti ti-device-tv', })); diff --git a/packages/frontend/src/pages/channel.vue b/packages/frontend/src/pages/channel.vue index 4a91165d50..a62e035198 100644 --- a/packages/frontend/src/pages/channel.vue +++ b/packages/frontend/src/pages/channel.vue @@ -4,11 +4,10 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> - <MkSpacer :contentMax="700" :class="$style.main"> +<PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"> + <MkSpacer :contentMax="700"> <MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs"> - <div v-if="channel && tab === 'overview'" key="overview" class="_gaps"> + <div v-if="channel && tab === 'overview'" class="_gaps"> <div class="_panel" :class="$style.bannerContainer"> <XChannelFollowButton :channel="channel" :full="true" :class="$style.subscribe"/> <MkButton v-if="favorited" v-tooltip="i18n.ts.unfavorite" asLike class="button" rounded primary :class="$style.favorite" @click="unfavorite()"><i class="ti ti-star"></i></MkButton> @@ -33,18 +32,18 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </MkFoldableSection> </div> - <div v-if="channel && tab === 'timeline'" key="timeline" class="_gaps"> + <div v-if="channel && tab === 'timeline'" class="_gaps"> <MkInfo v-if="channel.isArchived" warn>{{ i18n.ts.thisChannelArchived }}</MkInfo> <!-- スマホ・タブレットの場合、キーボードが表示されると投稿が見づらくなるので、デスクトップ場合のみ自動でフォーカスを当てる --> - <MkPostForm v-if="$i && defaultStore.reactiveState.showFixedPostFormInChannel.value" :channel="channel" class="post-form _panel" fixed :autofocus="deviceKind === 'desktop'"/> + <MkPostForm v-if="$i && prefer.r.showFixedPostFormInChannel.value" :channel="channel" class="post-form _panel" fixed :autofocus="deviceKind === 'desktop'"/> <MkTimeline :key="channelId" src="channel" :channel="channelId" @before="before" @after="after" @note="miLocalStorage.setItemAsJson(`channelLastReadedAt:${channel.id}`, Date.now())"/> </div> - <div v-else-if="tab === 'featured'" key="featured"> + <div v-else-if="tab === 'featured'"> <MkNotes :pagination="featuredPagination"/> </div> - <div v-else-if="tab === 'search'" key="search"> + <div v-else-if="tab === 'search'"> <div v-if="notesSearchAvailable" class="_gaps"> <div> <MkInput v-model="searchQuery" @enter="search()"> @@ -69,37 +68,37 @@ SPDX-License-Identifier: AGPL-3.0-only </MkSpacer> </div> </template> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> import { computed, watch, ref } from 'vue'; import * as Misskey from 'misskey-js'; +import { url } from '@@/js/config.js'; +import type { PageHeaderItem } from '@/types/page-header.js'; import MkPostForm from '@/components/MkPostForm.vue'; import MkTimeline from '@/components/MkTimeline.vue'; import XChannelFollowButton from '@/components/MkChannelFollowButton.vue'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { $i, iAmModerator } from '@/account.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { $i, iAmModerator } from '@/i.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; -import { deviceKind } from '@/scripts/device-kind.js'; +import { definePage } from '@/page.js'; +import { deviceKind } from '@/utility/device-kind.js'; import MkNotes from '@/components/MkNotes.vue'; -import { url } from '@@/js/config.js'; import { favoritedChannelsCache } from '@/cache.js'; import MkButton from '@/components/MkButton.vue'; import MkInput from '@/components/MkInput.vue'; -import { defaultStore } from '@/store.js'; +import { prefer } from '@/preferences.js'; import MkNote from '@/components/MkNote.vue'; import MkInfo from '@/components/MkInfo.vue'; import MkFoldableSection from '@/components/MkFoldableSection.vue'; import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue'; -import type { PageHeaderItem } from '@/types/page-header.js'; -import { isSupportShare } from '@/scripts/navigator.js'; -import { copyToClipboard } from '@/scripts/copy-to-clipboard.js'; -import { notesSearchAvailable } from '@/scripts/check-permissions.js'; +import { isSupportShare } from '@/utility/navigator.js'; +import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; +import { notesSearchAvailable } from '@/utility/check-permissions.js'; import { miLocalStorage } from '@/local-storage.js'; -import { useRouter } from '@/router/supplier.js'; +import { useRouter } from '@/router.js'; const router = useRouter(); @@ -210,7 +209,6 @@ const headerActions = computed(() => { return; } copyToClipboard(`${url}/channels/${channel.value.id}`); - os.success(); }, }); @@ -265,21 +263,17 @@ const headerTabs = computed(() => [{ icon: 'ti ti-search', }]); -definePageMetadata(() => ({ +definePage(() => ({ title: channel.value ? channel.value.name : i18n.ts.channel, icon: 'ti ti-device-tv', })); </script> <style lang="scss" module> -.main { - min-height: calc(100cqh - (var(--MI-stickyTop, 0px) + var(--MI-stickyBottom, 0px))); -} - .footer { -webkit-backdrop-filter: var(--MI-blur, blur(15px)); backdrop-filter: var(--MI-blur, blur(15px)); - background: var(--MI_THEME-acrylicBg); + background: color(from var(--MI_THEME-bg) srgb r g b / 0.5); border-top: solid 0.5px var(--MI_THEME-divider); } diff --git a/packages/frontend/src/pages/channels.vue b/packages/frontend/src/pages/channels.vue index 6830c1ace4..76800aaf70 100644 --- a/packages/frontend/src/pages/channels.vue +++ b/packages/frontend/src/pages/channels.vue @@ -4,11 +4,10 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> +<PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"> <MkSpacer :contentMax="1200"> <MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs"> - <div v-if="tab === 'search'" key="search" :class="$style.searchRoot"> + <div v-if="tab === 'search'" :class="$style.searchRoot"> <div class="_gaps"> <MkInput v-model="searchQuery" :large="true" :autofocus="true" type="search" @enter="search"> <template #prefix><i class="ti ti-search"></i></template> @@ -25,28 +24,28 @@ SPDX-License-Identifier: AGPL-3.0-only <MkChannelList :key="key" :pagination="channelPagination"/> </MkFoldableSection> </div> - <div v-if="tab === 'featured'" key="featured"> + <div v-if="tab === 'featured'"> <MkPagination v-slot="{items}" :pagination="featuredPagination"> <div :class="$style.root"> <MkChannelPreview v-for="channel in items" :key="channel.id" :channel="channel"/> </div> </MkPagination> </div> - <div v-else-if="tab === 'favorites'" key="favorites"> + <div v-else-if="tab === 'favorites'"> <MkPagination v-slot="{items}" :pagination="favoritesPagination"> <div :class="$style.root"> <MkChannelPreview v-for="channel in items" :key="channel.id" :channel="channel"/> </div> </MkPagination> </div> - <div v-else-if="tab === 'following'" key="following"> + <div v-else-if="tab === 'following'"> <MkPagination v-slot="{items}" :pagination="followingPagination"> <div :class="$style.root"> <MkChannelPreview v-for="channel in items" :key="channel.id" :channel="channel"/> </div> </MkPagination> </div> - <div v-else-if="tab === 'owned'" key="owned"> + <div v-else-if="tab === 'owned'"> <MkButton class="new" @click="create()"><i class="ti ti-plus"></i></MkButton> <MkPagination v-slot="{items}" :pagination="ownedPagination"> <div :class="$style.root"> @@ -56,7 +55,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </MkHorizontalSwipe> </MkSpacer> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> @@ -69,9 +68,9 @@ import MkRadios from '@/components/MkRadios.vue'; import MkButton from '@/components/MkButton.vue'; import MkFoldableSection from '@/components/MkFoldableSection.vue'; import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; import { i18n } from '@/i18n.js'; -import { useRouter } from '@/router/supplier.js'; +import { useRouter } from '@/router.js'; const router = useRouter(); @@ -161,7 +160,7 @@ const headerTabs = computed(() => [{ icon: 'ti ti-edit', }]); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.channel, icon: 'ti ti-device-tv', })); diff --git a/packages/frontend/src/pages/chat/XMessage.vue b/packages/frontend/src/pages/chat/XMessage.vue new file mode 100644 index 0000000000..def6ec7d14 --- /dev/null +++ b/packages/frontend/src/pages/chat/XMessage.vue @@ -0,0 +1,325 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div :class="[$style.root, { [$style.isMe]: isMe }]"> + <MkAvatar :class="$style.avatar" :user="message.fromUser!" :link="!isMe" :preview="false"/> + <div :class="$style.body" @contextmenu.stop="onContextmenu"> + <div :class="$style.header"><MkUserName v-if="!isMe && prefer.s['chat.showSenderName'] && message.fromUser != null" :user="message.fromUser"/></div> + <MkFukidashi :class="$style.fukidashi" :tail="isMe ? 'right' : 'left'" :accented="isMe"> + <Mfm + v-if="message.text" + ref="text" + class="_selectable" + :text="message.text" + :i="$i" + :nyaize="'respect'" + :enableEmojiMenu="true" + :enableEmojiMenuReaction="true" + /> + <MkMediaList v-if="message.file" :mediaList="[message.file]" :class="$style.file"/> + </MkFukidashi> + <MkUrlPreview v-for="url in urls" :key="url" :url="url" style="margin: 8px 0;"/> + <div :class="$style.footer"> + <button class="_textButton" style="color: currentColor;" @click="showMenu"><i class="ti ti-dots-circle-horizontal"></i></button> + <MkTime :class="$style.time" :time="message.createdAt"/> + <MkA v-if="isSearchResult && 'toRoom' in message && message.toRoom != null" :to="`/chat/room/${message.toRoomId}`">{{ message.toRoom.name }}</MkA> + <MkA v-if="isSearchResult && 'toUser' in message && message.toUser != null && isMe" :to="`/chat/user/${message.toUserId}`">@{{ message.toUser.username }}</MkA> + </div> + <TransitionGroup + :enterActiveClass="prefer.s.animation ? $style.transition_reaction_enterActive : ''" + :leaveActiveClass="prefer.s.animation ? $style.transition_reaction_leaveActive : ''" + :enterFromClass="prefer.s.animation ? $style.transition_reaction_enterFrom : ''" + :leaveToClass="prefer.s.animation ? $style.transition_reaction_leaveTo : ''" + :moveClass="prefer.s.animation ? $style.transition_reaction_move : ''" + tag="div" :class="$style.reactions" + > + <div v-for="record in message.reactions" :key="record.reaction + record.user.id" :class="[$style.reaction, record.user.id === $i.id ? $style.reactionMy : null]" @click="onReactionClick(record)"> + <MkAvatar :user="record.user" :link="false" :class="$style.reactionAvatar"/> + <MkReactionIcon + :withTooltip="true" + :reaction="record.reaction.replace(/^:(\w+):$/, ':$1@.:')" + :noStyle="true" + :class="$style.reactionIcon" + /> + </div> + </TransitionGroup> + </div> +</div> +</template> + +<script lang="ts" setup> +import { computed, defineAsyncComponent, provide } from 'vue'; +import * as mfm from 'mfm-js'; +import * as Misskey from 'misskey-js'; +import { url } from '@@/js/config.js'; +import { isLink } from '@@/js/is-link.js'; +import type { MenuItem } from '@/types/menu.js'; +import type { NormalizedChatMessage } from './room.vue'; +import { extractUrlFromMfm } from '@/utility/extract-url-from-mfm.js'; +import MkUrlPreview from '@/components/MkUrlPreview.vue'; +import { ensureSignin } from '@/i.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { i18n } from '@/i18n.js'; +import MkFukidashi from '@/components/MkFukidashi.vue'; +import * as os from '@/os.js'; +import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; +import MkMediaList from '@/components/MkMediaList.vue'; +import { reactionPicker } from '@/utility/reaction-picker.js'; +import * as sound from '@/utility/sound.js'; +import MkReactionIcon from '@/components/MkReactionIcon.vue'; +import { prefer } from '@/preferences.js'; +import { DI } from '@/di.js'; +import { getHTMLElementOrNull } from '@/utility/get-dom-node-or-null.js'; + +const $i = ensureSignin(); + +const props = defineProps<{ + message: NormalizedChatMessage | Misskey.entities.ChatMessage; + isSearchResult?: boolean; +}>(); + +const isMe = computed(() => props.message.fromUserId === $i.id); +const urls = computed(() => props.message.text ? extractUrlFromMfm(mfm.parse(props.message.text)) : []); + +provide(DI.mfmEmojiReactCallback, (reaction) => { + if ($i.policies.chatAvailability !== 'available') return; + + sound.playMisskeySfx('reaction'); + misskeyApi('chat/messages/react', { + messageId: props.message.id, + reaction: reaction, + }); +}); + +function react(ev: MouseEvent) { + if ($i.policies.chatAvailability !== 'available') return; + + const targetEl = getHTMLElementOrNull(ev.currentTarget ?? ev.target); + if (!targetEl) return; + + reactionPicker.show(targetEl, null, async (reaction) => { + sound.playMisskeySfx('reaction'); + misskeyApi('chat/messages/react', { + messageId: props.message.id, + reaction: reaction, + }); + }); +} + +function onReactionClick(record: Misskey.entities.ChatMessage['reactions'][0]) { + if ($i.policies.chatAvailability !== 'available') return; + + if (record.user.id === $i.id) { + misskeyApi('chat/messages/unreact', { + messageId: props.message.id, + reaction: record.reaction, + }); + } else { + if (!props.message.reactions.some(r => r.user.id === $i.id && r.reaction === record.reaction)) { + sound.playMisskeySfx('reaction'); + misskeyApi('chat/messages/react', { + messageId: props.message.id, + reaction: record.reaction, + }); + } + } +} + +function onContextmenu(ev: MouseEvent) { + if (ev.target && isLink(ev.target as HTMLElement)) return; + if (window.getSelection()?.toString() !== '') return; + + showMenu(ev, true); +} + +function showMenu(ev: MouseEvent, contextmenu = false) { + const menu: MenuItem[] = []; + + if (!isMe.value && $i.policies.chatAvailability === 'available') { + menu.push({ + text: i18n.ts.reaction, + icon: 'ti ti-mood-plus', + action: (ev) => { + react(ev); + }, + }); + + menu.push({ + type: 'divider', + }); + } + + menu.push({ + text: i18n.ts.copyContent, + icon: 'ti ti-copy', + action: () => { + copyToClipboard(props.message.text ?? ''); + }, + }); + + menu.push({ + type: 'divider', + }); + + if (isMe.value && $i.policies.chatAvailability === 'available') { + menu.push({ + text: i18n.ts.delete, + icon: 'ti ti-trash', + danger: true, + action: () => { + misskeyApi('chat/messages/delete', { + messageId: props.message.id, + }); + }, + }); + } + + if (!isMe.value && props.message.fromUser != null) { + menu.push({ + text: i18n.ts.reportAbuse, + icon: 'ti ti-exclamation-circle', + action: () => { + const localUrl = `${url}/chat/messages/${props.message.id}`; + const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkAbuseReportWindow.vue')), { + user: props.message.fromUser!, + initialComment: `${localUrl}\n-----\n`, + }, { + closed: () => dispose(), + }); + }, + }); + } + + if (contextmenu) { + os.contextMenu(menu, ev); + } else { + os.popupMenu(menu, ev.currentTarget ?? ev.target); + } +} +</script> + +<style lang="scss" module> +.transition_reaction_move, +.transition_reaction_enterActive, +.transition_reaction_leaveActive { + transition: opacity 0.2s cubic-bezier(0,.5,.5,1), transform 0.2s cubic-bezier(0,.5,.5,1) !important; +} +.transition_reaction_enterFrom, +.transition_reaction_leaveTo { + opacity: 0; + transform: scale(0.7); +} +.transition_reaction_leaveActive { + position: absolute; +} + +.root { + position: relative; + display: flex; + + &.isMe { + flex-direction: row-reverse; + text-align: right; + + .footer { + flex-direction: row-reverse; + } + } +} + +.avatar { + position: sticky; + top: calc(16px + var(--MI-stickyTop, 0px)); + display: block; + width: 50px; + height: 50px; +} + +@container (max-width: 450px) { + .root { + &.isMe { + .avatar { + display: none; + } + } + } + + .avatar { + width: 42px; + height: 42px; + } + + .fukidashi { + font-size: 90%; + } +} + +.body { + margin: 0 12px; +} + +.header { + min-height: 4px; // fukidashiの位置調整も兼ねるため + font-size: 80%; +} + +.fukidashi { + text-align: left; +} + +.content { + overflow: clip; + overflow-wrap: break-word; + word-break: break-word; +} + +.footer { + display: flex; + flex-direction: row; + gap: 0.5em; + margin-top: 4px; + font-size: 75%; +} + +.time { + opacity: 0.5; +} + +.reactions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + margin-top: 8px; + + &:empty { + display: none; + } +} + +.reaction { + display: flex; + align-items: center; + border: solid 1px var(--MI_THEME-divider); + border-radius: 999px; + padding: 8px; + + &.reactionMy { + border-color: var(--MI_THEME-accent); + } +} + +.reactionAvatar { + width: 24px; + height: 24px; + margin-right: 8px; +} + +.reactionIcon { + width: 24px; + height: 24px; +} +</style> diff --git a/packages/frontend/src/pages/chat/XRoom.vue b/packages/frontend/src/pages/chat/XRoom.vue new file mode 100644 index 0000000000..b063a0cdd1 --- /dev/null +++ b/packages/frontend/src/pages/chat/XRoom.vue @@ -0,0 +1,41 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<MkA :to="`/chat/room/${room.id}`" class="_panel _gaps_s" :class="$style.root"> + <div :class="$style.header"> + <div style="font-weight: bold;">{{ room.name }}</div> + <MkAvatar :user="room.owner" :link="false" :class="$style.headerAvatar"/> + </div> + <hr> + <div>{{ room.description }}</div> +</MkA> +</template> + +<script lang="ts" setup> +import * as Misskey from 'misskey-js'; + +const props = defineProps<{ + room: Misskey.entities.ChatRoom; +}>(); + +</script> + +<style lang="scss" module> +.root { + padding: 16px; +} + +.header { + display: flex; + align-items: center; +} + +.headerAvatar { + width: 30px; + height: 30px; + margin-left: auto; +} +</style> diff --git a/packages/frontend/src/pages/chat/home.home.vue b/packages/frontend/src/pages/chat/home.home.vue new file mode 100644 index 0000000000..a8ed891de0 --- /dev/null +++ b/packages/frontend/src/pages/chat/home.home.vue @@ -0,0 +1,286 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div class="_gaps"> + <MkButton v-if="$i.policies.chatAvailability === 'available'" primary gradate rounded :class="$style.start" @click="start"><i class="ti ti-plus"></i> {{ i18n.ts.startChat }}</MkButton> + + <MkInfo v-else>{{ $i.policies.chatAvailability === 'readonly' ? i18n.ts._chat.chatIsReadOnlyForThisAccountOrServer : i18n.ts._chat.chatNotAvailableForThisAccountOrServer }}</MkInfo> + + <MkAd :preferForms="['horizontal', 'horizontal-big']"/> + + <MkInput + v-model="searchQuery" + :placeholder="i18n.ts._chat.searchMessages" + type="search" + > + <template #prefix><i class="ti ti-search"></i></template> + </MkInput> + + <MkButton v-if="searchQuery.length > 0" primary rounded @click="search">{{ i18n.ts.search }}</MkButton> + + <MkFoldableSection v-if="searched"> + <template #header>{{ i18n.ts.searchResult }}</template> + + <div class="_gaps_s"> + <div v-for="message in searchResults" :key="message.id" :class="$style.searchResultItem"> + <XMessage :message="message" :isSearchResult="true"/> + </div> + </div> + </MkFoldableSection> + + <MkFoldableSection> + <template #header>{{ i18n.ts._chat.history }}</template> + + <div v-if="history.length > 0" class="_gaps_s"> + <MkA + v-for="item in history" + :key="item.id" + :class="[$style.message, { [$style.isMe]: item.isMe, [$style.isRead]: item.message.isRead }]" + class="_panel" + :to="item.message.toRoomId ? `/chat/room/${item.message.toRoomId}` : `/chat/user/${item.other!.id}`" + > + <MkAvatar v-if="item.message.toRoomId" :class="$style.messageAvatar" :user="item.message.fromUser" indicator :preview="false"/> + <MkAvatar v-else-if="item.other" :class="$style.messageAvatar" :user="item.other" indicator :preview="false"/> + <div :class="$style.messageBody"> + <header v-if="item.message.toRoom" :class="$style.messageHeader"> + <span :class="$style.messageHeaderName"><i class="ti ti-users"></i> {{ item.message.toRoom.name }}</span> + <MkTime :time="item.message.createdAt" :class="$style.messageHeaderTime"/> + </header> + <header v-else :class="$style.messageHeader"> + <MkUserName :class="$style.messageHeaderName" :user="item.other!"/> + <MkAcct :class="$style.messageHeaderUsername" :user="item.other!"/> + <MkTime :time="item.message.createdAt" :class="$style.messageHeaderTime"/> + </header> + <div :class="$style.messageBodyText"><span v-if="item.isMe" :class="$style.youSaid">{{ i18n.ts.you }}:</span>{{ item.message.text }}</div> + </div> + </MkA> + </div> + <div v-if="!initializing && history.length == 0" class="_fullinfo"> + <div>{{ i18n.ts._chat.noHistory }}</div> + </div> + <MkLoading v-if="initializing"/> + </MkFoldableSection> +</div> +</template> + +<script lang="ts" setup> +import { onActivated, onDeactivated, onMounted, ref } from 'vue'; +import * as Misskey from 'misskey-js'; +import { useInterval } from '@@/js/use-interval.js'; +import XMessage from './XMessage.vue'; +import MkButton from '@/components/MkButton.vue'; +import { i18n } from '@/i18n.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { ensureSignin } from '@/i.js'; +import { useRouter } from '@/router.js'; +import * as os from '@/os.js'; +import { updateCurrentAccountPartial } from '@/accounts.js'; +import MkInput from '@/components/MkInput.vue'; +import MkFoldableSection from '@/components/MkFoldableSection.vue'; +import MkInfo from '@/components/MkInfo.vue'; + +const $i = ensureSignin(); + +const router = useRouter(); + +const initializing = ref(true); +const fetching = ref(false); +const history = ref<{ + id: string; + message: Misskey.entities.ChatMessage; + other: Misskey.entities.ChatMessage['fromUser'] | Misskey.entities.ChatMessage['toUser'] | null; + isMe: boolean; +}[]>([]); + +const searchQuery = ref(''); +const searched = ref(false); +const searchResults = ref<Misskey.entities.ChatMessage[]>([]); + +function start(ev: MouseEvent) { + os.popupMenu([{ + text: i18n.ts._chat.individualChat, + caption: i18n.ts._chat.individualChat_description, + icon: 'ti ti-user', + action: () => { startUser(); }, + }, { type: 'divider' }, { + type: 'parent', + text: i18n.ts._chat.roomChat, + caption: i18n.ts._chat.roomChat_description, + icon: 'ti ti-users-group', + children: [{ + text: i18n.ts._chat.createRoom, + icon: 'ti ti-plus', + action: () => { createRoom(); }, + }], + }], ev.currentTarget ?? ev.target); +} + +async function startUser() { + // TODO: localOnly は連合に対応したら消す + os.selectUser({ localOnly: true }).then(user => { + router.push(`/chat/user/${user.id}`); + }); +} + +async function createRoom() { + const { canceled, result } = await os.inputText({ + title: i18n.ts.name, + minLength: 1, + }); + if (canceled) return; + + const room = await misskeyApi('chat/rooms/create', { + name: result, + }); + + router.push(`/chat/room/${room.id}`); +} + +async function search() { + const res = await misskeyApi('chat/messages/search', { + query: searchQuery.value, + }); + + searchResults.value = res; + searched.value = true; +} + +async function fetchHistory() { + if (fetching.value) return; + + fetching.value = true; + + const [userMessages, roomMessages] = await Promise.all([ + misskeyApi('chat/history', { room: false }), + misskeyApi('chat/history', { room: true }), + ]); + + history.value = [...userMessages, ...roomMessages] + .toSorted((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) + .map(m => ({ + id: m.id, + message: m, + other: (!('room' in m) || m.room == null) ? (m.fromUserId === $i.id ? m.toUser : m.fromUser) : null, + isMe: m.fromUserId === $i.id, + })); + + fetching.value = false; + initializing.value = false; + + updateCurrentAccountPartial({ hasUnreadChatMessages: false }); +} + +let isActivated = true; + +onActivated(() => { + isActivated = true; +}); + +onDeactivated(() => { + isActivated = false; +}); + +useInterval(() => { + // TODO: DOM的にバックグラウンドになっていないかどうかも考慮する + if (!window.document.hidden && isActivated) { + fetchHistory(); + } +}, 1000 * 10, { + immediate: false, + afterMounted: true, +}); + +onActivated(() => { + fetchHistory(); +}); + +onMounted(() => { + fetchHistory(); +}); +</script> + +<style lang="scss" module> +.start { + margin: 0 auto; +} + +.message { + position: relative; + display: flex; + padding: 16px 24px; + + &.isRead, + &.isMe { + opacity: 0.8; + } + + &:not(.isMe):not(.isRead) { + &::before { + content: ''; + position: absolute; + top: 8px; + right: 8px; + width: 8px; + height: 8px; + border-radius: 100%; + background-color: var(--MI_THEME-accent); + } + } +} + +.messageAvatar { + width: 50px; + height: 50px; + margin: 0 16px 0 0; +} + +.messageBody { + flex: 1; + min-width: 0; +} + +.messageHeader { + display: flex; + align-items: center; + margin-bottom: 2px; + white-space: nowrap; + overflow: clip; +} + +.messageHeaderName { + margin: 0; + padding: 0; + overflow: hidden; + text-overflow: ellipsis; + font-size: 1em; + font-weight: bold; +} + +.messageHeaderUsername { + margin: 0 8px; +} + +.messageHeaderTime { + margin-left: auto; +} + +.messageBodyText { + overflow: hidden; + overflow-wrap: break-word; + font-size: 1.1em; +} + +.youSaid { + font-weight: bold; + margin-right: 0.5em; +} + +.searchResultItem { + padding: 12px; + border: solid 1px var(--MI_THEME-divider); + border-radius: 12px; +} +</style> diff --git a/packages/frontend/src/pages/chat/home.invitations.vue b/packages/frontend/src/pages/chat/home.invitations.vue new file mode 100644 index 0000000000..82b22ea9dd --- /dev/null +++ b/packages/frontend/src/pages/chat/home.invitations.vue @@ -0,0 +1,93 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div class="_gaps"> + <div v-if="invitations.length > 0" class="_gaps_s"> + <MkFolder v-for="invitation in invitations" :key="invitation.id" :defaultOpen="true"> + <template #icon><i class="ti ti-users-group"></i></template> + <template #label>{{ invitation.room.name }}</template> + <template #suffix><MkTime :time="invitation.createdAt"/></template> + <template #footer> + <div class="_buttons"> + <MkButton primary @click="join(invitation)"><i class="ti ti-plus"></i> {{ i18n.ts._chat.join }}</MkButton> + <MkButton danger @click="ignore(invitation)"><i class="ti ti-x"></i> {{ i18n.ts._chat.ignore }}</MkButton> + </div> + </template> + + <div :class="$style.invitationBody"> + <MkAvatar :user="invitation.room.owner" :class="$style.invitationBodyAvatar" link/> + <div style="flex: 1;" class="_gaps_s"> + <MkUserName :user="invitation.room.owner"/> + <hr> + <div>{{ invitation.room.description === '' ? i18n.ts.noDescription : invitation.room.description }}</div> + </div> + </div> + </MkFolder> + </div> + <div v-if="!fetching && invitations.length == 0" class="_fullinfo"> + <div>{{ i18n.ts._chat.noInvitations }}</div> + </div> + <MkLoading v-if="fetching"/> +</div> +</template> + +<script lang="ts" setup> +import { onMounted, ref } from 'vue'; +import * as Misskey from 'misskey-js'; +import MkButton from '@/components/MkButton.vue'; +import { i18n } from '@/i18n.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { useRouter } from '@/router.js'; +import MkFolder from '@/components/MkFolder.vue'; + +const router = useRouter(); + +const fetching = ref(true); +const invitations = ref<Misskey.entities.ChatRoomInvitation[]>([]); + +async function fetchInvitations() { + fetching.value = true; + + const res = await misskeyApi('chat/rooms/invitations/inbox'); + + invitations.value = res; + + fetching.value = false; +} + +async function join(invitation: Misskey.entities.ChatRoomInvitation) { + await misskeyApi('chat/rooms/join', { + roomId: invitation.room.id, + }); + + router.push(`/chat/room/${invitation.room.id}`); +} + +async function ignore(invitation: Misskey.entities.ChatRoomInvitation) { + await misskeyApi('chat/rooms/invitations/ignore', { + roomId: invitation.room.id, + }); + + invitations.value = invitations.value.filter(i => i.id !== invitation.id); +} + +onMounted(() => { + fetchInvitations(); +}); +</script> + +<style lang="scss" module> +.invitationBody { + display: flex; + align-items: center; +} + +.invitationBodyAvatar { + margin-right: 12px; + width: 45px; + height: 45px; +} +</style> diff --git a/packages/frontend/src/pages/chat/home.joiningRooms.vue b/packages/frontend/src/pages/chat/home.joiningRooms.vue new file mode 100644 index 0000000000..f9fd6bfd55 --- /dev/null +++ b/packages/frontend/src/pages/chat/home.joiningRooms.vue @@ -0,0 +1,45 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div class="_gaps"> + <div v-if="memberships.length > 0" class="_gaps_s"> + <XRoom v-for="membership in memberships" :key="membership.id" :room="membership.room!"/> + </div> + <div v-if="!fetching && memberships.length == 0" class="_fullinfo"> + <div>{{ i18n.ts._chat.noRooms }}</div> + </div> + <MkLoading v-if="fetching"/> +</div> +</template> + +<script lang="ts" setup> +import { onMounted, ref } from 'vue'; +import * as Misskey from 'misskey-js'; +import XRoom from './XRoom.vue'; +import { i18n } from '@/i18n.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; + +const fetching = ref(true); +const memberships = ref<Misskey.entities.ChatRoomMembership[]>([]); + +async function fetchRooms() { + fetching.value = true; + + const res = await misskeyApi('chat/rooms/joining'); + + memberships.value = res; + + fetching.value = false; +} + +onMounted(() => { + fetchRooms(); +}); +</script> + +<style lang="scss" module> + +</style> diff --git a/packages/frontend/src/pages/chat/home.ownedRooms.vue b/packages/frontend/src/pages/chat/home.ownedRooms.vue new file mode 100644 index 0000000000..ce7da15563 --- /dev/null +++ b/packages/frontend/src/pages/chat/home.ownedRooms.vue @@ -0,0 +1,46 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div class="_gaps"> + <div v-if="rooms.length > 0" class="_gaps_s"> + <XRoom v-for="room in rooms" :key="room.id" :room="room"/> + </div> + <div v-if="!fetching && rooms.length == 0" class="_fullinfo"> + <div>{{ i18n.ts._chat.noRooms }}</div> + </div> + <MkLoading v-if="fetching"/> +</div> +</template> + +<script lang="ts" setup> +import { onMounted, ref } from 'vue'; +import * as Misskey from 'misskey-js'; +import XRoom from './XRoom.vue'; +import { i18n } from '@/i18n.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; + +const fetching = ref(true); +const rooms = ref<Misskey.entities.ChatRoom[]>([]); + +async function fetchRooms() { + fetching.value = true; + + const res = await misskeyApi('chat/rooms/owned', { + }); + + rooms.value = res; + + fetching.value = false; +} + +onMounted(() => { + fetchRooms(); +}); +</script> + +<style lang="scss" module> + +</style> diff --git a/packages/frontend/src/pages/chat/home.vue b/packages/frontend/src/pages/chat/home.vue new file mode 100644 index 0000000000..e29ab28f2d --- /dev/null +++ b/packages/frontend/src/pages/chat/home.vue @@ -0,0 +1,60 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"> + <MkPolkadots v-if="tab === 'home'" accented/> + <MkSpacer :contentMax="700"> + <MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs"> + <XHome v-if="tab === 'home'"/> + <XInvitations v-else-if="tab === 'invitations'"/> + <XJoiningRooms v-else-if="tab === 'joiningRooms'"/> + <XOwnedRooms v-else-if="tab === 'ownedRooms'"/> + </MkHorizontalSwipe> + </MkSpacer> +</PageWithHeader> +</template> + +<script lang="ts" setup> +import { computed, ref } from 'vue'; +import XHome from './home.home.vue'; +import XInvitations from './home.invitations.vue'; +import XJoiningRooms from './home.joiningRooms.vue'; +import XOwnedRooms from './home.ownedRooms.vue'; +import { i18n } from '@/i18n.js'; +import { definePage } from '@/page.js'; +import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue'; +import MkPolkadots from '@/components/MkPolkadots.vue'; + +const tab = ref('home'); + +const headerActions = computed(() => []); + +const headerTabs = computed(() => [{ + key: 'home', + title: i18n.ts._chat.home, + icon: 'ti ti-home', +}, { + key: 'invitations', + title: i18n.ts._chat.invitations, + icon: 'ti ti-ticket', +}, { + key: 'joiningRooms', + title: i18n.ts._chat.joiningRooms, + icon: 'ti ti-users-group', +}, { + key: 'ownedRooms', + title: i18n.ts._chat.yourRooms, + icon: 'ti ti-settings', +}]); + +definePage(() => ({ + title: i18n.ts.chat + ' (beta)', + icon: 'ti ti-messages', +})); +</script> + +<style lang="scss" module> +</style> diff --git a/packages/frontend/src/pages/chat/message.vue b/packages/frontend/src/pages/chat/message.vue new file mode 100644 index 0000000000..3ac90a93fd --- /dev/null +++ b/packages/frontend/src/pages/chat/message.vue @@ -0,0 +1,51 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<PageWithHeader> + <MkSpacer :contentMax="700"> + <div v-if="initializing || message == null"> + <MkLoading/> + </div> + <div v-else> + <XMessage :message="message" :isSearchResult="true"/> + </div> + </MkSpacer> +</PageWithHeader> +</template> + +<script lang="ts" setup> +import { ref, onMounted } from 'vue'; +import * as Misskey from 'misskey-js'; +import XMessage from './XMessage.vue'; +import { i18n } from '@/i18n.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { definePage } from '@/page.js'; + +const props = defineProps<{ + messageId?: string; +}>(); + +const initializing = ref(true); +const message = ref<Misskey.entities.ChatMessage | null>(); + +async function initialize() { + initializing.value = true; + + message.value = await misskeyApi('chat/messages/show', { + messageId: props.messageId, + }); + + initializing.value = false; +} + +onMounted(() => { + initialize(); +}); + +definePage({ + title: i18n.ts.chat, +}); +</script> diff --git a/packages/frontend/src/pages/chat/room.form.vue b/packages/frontend/src/pages/chat/room.form.vue new file mode 100644 index 0000000000..9389b16ce7 --- /dev/null +++ b/packages/frontend/src/pages/chat/room.form.vue @@ -0,0 +1,350 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div + :class="$style.root" + @dragover.stop="onDragover" + @drop.stop="onDrop" +> + <textarea + ref="textareaEl" + v-model="text" + :class="$style.textarea" + class="_acrylic" + :placeholder="i18n.ts.inputMessageHere" + :readonly="textareaReadOnly" + @keydown="onKeydown" + @paste="onPaste" + ></textarea> + <footer :class="$style.footer"> + <div v-if="file" :class="$style.file" @click="file = null">{{ file.name }}</div> + <div :class="$style.buttons"> + <button class="_button" :class="$style.button" @click="chooseFile"><i class="ti ti-photo-plus"></i></button> + <button class="_button" :class="$style.button" @click="insertEmoji"><i class="ti ti-mood-happy"></i></button> + <button class="_button" :class="[$style.button, $style.send]" :disabled="!canSend || sending" :title="i18n.ts.send" @click="send"> + <template v-if="!sending"><i class="ti ti-send"></i></template><template v-if="sending"><MkLoading :em="true"/></template> + </button> + </div> + </footer> + <input ref="fileEl" style="display: none;" type="file" @change="onChangeFile"/> +</div> +</template> + +<script lang="ts" setup> +import { onMounted, watch, ref, shallowRef, computed, nextTick, readonly, onBeforeUnmount } from 'vue'; +import * as Misskey from 'misskey-js'; +//import insertTextAtCursor from 'insert-text-at-cursor'; +import { formatTimeString } from '@/utility/format-time-string.js'; +import { selectFile } from '@/utility/select-file.js'; +import * as os from '@/os.js'; +import { i18n } from '@/i18n.js'; +import { uploadFile } from '@/utility/upload.js'; +import { miLocalStorage } from '@/local-storage.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { prefer } from '@/preferences.js'; +import { Autocomplete } from '@/utility/autocomplete.js'; +import { emojiPicker } from '@/utility/emoji-picker.js'; + +const props = defineProps<{ + user?: Misskey.entities.UserDetailed | null; + room?: Misskey.entities.ChatRoom | null; +}>(); + +const textareaEl = shallowRef<HTMLTextAreaElement>(); +const fileEl = shallowRef<HTMLInputElement>(); + +const text = ref<string>(''); +const file = ref<Misskey.entities.DriveFile | null>(null); +const sending = ref(false); +const textareaReadOnly = ref(false); +let autocompleteInstance: Autocomplete | null = null; + +const canSend = computed(() => (text.value != null && text.value !== '') || file.value != null); + +function getDraftKey() { + return props.user ? 'user:' + props.user.id : 'room:' + props.room?.id; +} + +watch([text, file], saveDraft); + +async function onPaste(ev: ClipboardEvent) { + if (!ev.clipboardData) return; + + const pastedFileName = 'yyyy-MM-dd HH-mm-ss [{{number}}]'; + + const clipboardData = ev.clipboardData; + const items = clipboardData.items; + + if (items.length === 1) { + if (items[0].kind === 'file') { + const pastedFile = items[0].getAsFile(); + if (!pastedFile) return; + const lio = pastedFile.name.lastIndexOf('.'); + const ext = lio >= 0 ? pastedFile.name.slice(lio) : ''; + const formatted = formatTimeString(new Date(pastedFile.lastModified), pastedFileName).replace(/{{number}}/g, '1') + ext; + if (formatted) upload(pastedFile, formatted); + } + } else { + if (items[0].kind === 'file') { + os.alert({ + type: 'error', + text: i18n.ts.onlyOneFileCanBeAttached, + }); + } + } +} + +function onDragover(ev: DragEvent) { + if (!ev.dataTransfer) return; + + const isFile = ev.dataTransfer.items[0].kind === 'file'; + const isDriveFile = ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FILE_; + if (isFile || isDriveFile) { + ev.preventDefault(); + switch (ev.dataTransfer.effectAllowed) { + case 'all': + case 'uninitialized': + case 'copy': + case 'copyLink': + case 'copyMove': + ev.dataTransfer.dropEffect = 'copy'; + break; + case 'linkMove': + case 'move': + ev.dataTransfer.dropEffect = 'move'; + break; + default: + ev.dataTransfer.dropEffect = 'none'; + break; + } + } +} + +function onDrop(ev: DragEvent): void { + if (!ev.dataTransfer) return; + + // ファイルだったら + if (ev.dataTransfer.files.length === 1) { + ev.preventDefault(); + upload(ev.dataTransfer.files[0]); + return; + } else if (ev.dataTransfer.files.length > 1) { + ev.preventDefault(); + os.alert({ + type: 'error', + text: i18n.ts.onlyOneFileCanBeAttached, + }); + return; + } + + //#region ドライブのファイル + const driveFile = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_); + if (driveFile != null && driveFile !== '') { + file.value = JSON.parse(driveFile); + ev.preventDefault(); + } + //#endregion +} + +function onKeydown(ev: KeyboardEvent) { + if (ev.key === 'Enter') { + if (prefer.s['chat.sendOnEnter']) { + if (!(ev.ctrlKey || ev.metaKey || ev.shiftKey)) { + send(); + } + } else { + if ((ev.ctrlKey || ev.metaKey)) { + send(); + } + } + } +} + +function chooseFile(ev: MouseEvent) { + selectFile(ev.currentTarget ?? ev.target, i18n.ts.selectFile).then(selectedFile => { + file.value = selectedFile; + }); +} + +function onChangeFile() { + if (fileEl.value == null || fileEl.value.files == null) return; + + if (fileEl.value.files[0]) upload(fileEl.value.files[0]); +} + +function upload(fileToUpload: File, name?: string) { + uploadFile(fileToUpload, prefer.s.uploadFolder, name).then(res => { + file.value = res; + }); +} + +function send() { + if (!canSend.value) return; + + sending.value = true; + + if (props.user) { + misskeyApi('chat/messages/create-to-user', { + toUserId: props.user.id, + text: text.value ? text.value : undefined, + fileId: file.value ? file.value.id : undefined, + }).then(message => { + clear(); + }).catch(err => { + console.error(err); + }).then(() => { + sending.value = false; + }); + } else if (props.room) { + misskeyApi('chat/messages/create-to-room', { + toRoomId: props.room.id, + text: text.value ? text.value : undefined, + fileId: file.value ? file.value.id : undefined, + }).then(message => { + clear(); + }).catch(err => { + console.error(err); + }).then(() => { + sending.value = false; + }); + } +} + +function clear() { + text.value = ''; + file.value = null; + deleteDraft(); +} + +function saveDraft() { + const drafts = JSON.parse(miLocalStorage.getItem('chatMessageDrafts') || '{}'); + + drafts[getDraftKey()] = { + updatedAt: new Date(), + data: { + text: text.value, + file: file.value, + }, + }; + + miLocalStorage.setItem('chatMessageDrafts', JSON.stringify(drafts)); +} + +function deleteDraft() { + const drafts = JSON.parse(miLocalStorage.getItem('chatMessageDrafts') || '{}'); + + delete drafts[getDraftKey()]; + + miLocalStorage.setItem('chatMessageDrafts', JSON.stringify(drafts)); +} + +async function insertEmoji(ev: MouseEvent) { + textareaReadOnly.value = true; + const target = ev.currentTarget ?? ev.target; + if (target == null) return; + + // emojiPickerはダイアログが閉じずにtextareaとやりとりするので、 + // focustrapをかけているとinsertTextAtCursorが効かない + // そのため、投稿フォームのテキストに直接注入する + // See: https://github.com/misskey-dev/misskey/pull/14282 + // https://github.com/misskey-dev/misskey/issues/14274 + + let pos = textareaEl.value?.selectionStart ?? 0; + let posEnd = textareaEl.value?.selectionEnd ?? text.value.length; + emojiPicker.show( + target as HTMLElement, + emoji => { + const textBefore = text.value.substring(0, pos); + const textAfter = text.value.substring(posEnd); + text.value = textBefore + emoji + textAfter; + pos += emoji.length; + posEnd += emoji.length; + }, + () => { + textareaReadOnly.value = false; + nextTick(() => focus()); + }, + ); +} + +onMounted(() => { + if (textareaEl.value != null) { + autocompleteInstance = new Autocomplete(textareaEl.value, text); + } + + // 書きかけの投稿を復元 + const draft = JSON.parse(miLocalStorage.getItem('chatMessageDrafts') || '{}')[getDraftKey()]; + if (draft) { + text.value = draft.data.text; + file.value = draft.data.file; + } +}); + +onBeforeUnmount(() => { + if (autocompleteInstance) { + autocompleteInstance.detach(); + autocompleteInstance = null; + } +}); +</script> + +<style lang="scss" module> +.root { + position: relative; + border-bottom: none; + border-radius: 14px 14px 0 0; + overflow: clip; +} + +.textarea { + cursor: auto; + display: block; + width: 100%; + min-width: 100%; + max-width: 100%; + min-height: 80px; + margin: 0; + padding: 16px 16px 0 16px; + resize: none; + font-size: 1em; + font-family: inherit; + outline: none; + border: none; + border-radius: 0; + box-shadow: none; + box-sizing: border-box; + color: var(--MI_THEME-fg); + field-sizing: content; +} + +.footer { + position: sticky; + bottom: 0; + background: var(--MI_THEME-panel); +} + +.file { + padding: 8px; + cursor: pointer; +} + +.buttons { + display: flex; +} + +.button { + height: 50px; + aspect-ratio: 1; + + &:hover { + color: var(--MI_THEME-accent); + } +} +.send { + margin-left: auto; + color: var(--MI_THEME-accent); +} +</style> diff --git a/packages/frontend/src/pages/chat/room.info.vue b/packages/frontend/src/pages/chat/room.info.vue new file mode 100644 index 0000000000..2f091388a0 --- /dev/null +++ b/packages/frontend/src/pages/chat/room.info.vue @@ -0,0 +1,99 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div class="_gaps"> + <MkInput v-model="name_" :disabled="!isOwner"> + <template #label>{{ i18n.ts.name }}</template> + </MkInput> + + <MkTextarea v-model="description_" :disabled="!isOwner"> + <template #label>{{ i18n.ts.description }}</template> + </MkTextarea> + + <MkButton v-if="isOwner" primary @click="save">{{ i18n.ts.save }}</MkButton> + + <hr> + + <MkButton v-if="isOwner || ($i.isAdmin || $i.isModerator)" danger @click="del">{{ i18n.ts._chat.deleteRoom }}</MkButton> + + <MkSwitch v-if="!isOwner" v-model="isMuted"> + <template #label>{{ i18n.ts._chat.muteThisRoom }}</template> + </MkSwitch> +</div> +</template> + +<script lang="ts" setup> +import { computed, ref, watch } from 'vue'; +import * as Misskey from 'misskey-js'; +import MkButton from '@/components/MkButton.vue'; +import { i18n } from '@/i18n.js'; +import * as os from '@/os.js'; +import { ensureSignin } from '@/i.js'; +import MkInput from '@/components/MkInput.vue'; +import MkTextarea from '@/components/MkTextarea.vue'; +import MkSwitch from '@/components/MkSwitch.vue'; +import { useRouter } from '@/router.js'; + +const router = useRouter(); +const $i = ensureSignin(); + +const props = defineProps<{ + room: Misskey.entities.ChatRoom; +}>(); + +const isOwner = computed(() => { + return props.room.ownerId === $i.id; +}); + +const name_ = ref(props.room.name); +const description_ = ref(props.room.description); + +function save() { + os.apiWithDialog('chat/rooms/update', { + roomId: props.room.id, + name: name_.value, + description: description_.value, + }); +} + +async function del() { + const { canceled } = await os.confirm({ + type: 'warning', + text: i18n.tsx.deleteAreYouSure({ x: name_.value }), + }); + if (canceled) return; + + await os.apiWithDialog('chat/rooms/delete', { + roomId: props.room.id, + }); + router.push('/chat'); +} + +const isMuted = ref(props.room.isMuted ?? false); + +watch(isMuted, async () => { + await os.apiWithDialog('chat/rooms/mute', { + roomId: props.room.id, + mute: isMuted.value, + }); +}); +</script> + +<style lang="scss" module> +.membership { + display: flex; +} + +.membershipBody { + flex: 1; + min-width: 0; + margin-right: 8px; + + &:hover { + text-decoration: none; + } +} +</style> diff --git a/packages/frontend/src/pages/chat/room.members.vue b/packages/frontend/src/pages/chat/room.members.vue new file mode 100644 index 0000000000..5a574068cb --- /dev/null +++ b/packages/frontend/src/pages/chat/room.members.vue @@ -0,0 +1,98 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div class="_gaps"> + <MkButton v-if="isOwner" primary rounded style="margin: 0 auto;" @click="emit('inviteUser')"><i class="ti ti-plus"></i> {{ i18n.ts._chat.inviteUser }}</MkButton> + + <MkA :class="$style.membershipBody" :to="`${userPage(room.owner)}`"> + <MkUserCardMini :user="room.owner"/> + </MkA> + + <hr v-if="memberships.length > 0"> + + <div v-for="membership in memberships" :key="membership.id" :class="$style.membership"> + <MkA :class="$style.membershipBody" :to="`${userPage(membership.user!)}`"> + <MkUserCardMini :user="membership.user!"/> + </MkA> + </div> + + <template v-if="isOwner"> + <hr> + + <div>{{ i18n.ts._chat.sentInvitations }}</div> + + <div v-for="invitation in invitations" :key="invitation.id" :class="$style.invitation"> + <MkA :class="$style.invitationBody" :to="`${userPage(invitation.user)}`"> + <MkUserCardMini :user="invitation.user"/> + </MkA> + </div> + </template> +</div> +</template> + +<script lang="ts" setup> +import { computed, onMounted, ref } from 'vue'; +import * as Misskey from 'misskey-js'; +import MkButton from '@/components/MkButton.vue'; +import { i18n } from '@/i18n.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import MkUserCardMini from '@/components/MkUserCardMini.vue'; +import { userPage } from '@/filters/user.js'; +import { ensureSignin } from '@/i.js'; + +const $i = ensureSignin(); + +const props = defineProps<{ + room: Misskey.entities.ChatRoom; +}>(); + +const emit = defineEmits<{ + (ev: 'inviteUser'): void, +}>(); + +const isOwner = computed(() => { + return props.room.ownerId === $i.id; +}); + +const memberships = ref<Misskey.entities.ChatRoomMembership[]>([]); +const invitations = ref<Misskey.entities.ChatRoomInvitation[]>([]); + +onMounted(async () => { + memberships.value = await misskeyApi('chat/rooms/members', { + roomId: props.room.id, + limit: 50, + }); + + if (isOwner.value) { + invitations.value = await misskeyApi('chat/rooms/invitations/outbox', { + roomId: props.room.id, + limit: 50, + }); + } +}); +</script> + +<style lang="scss" module> +.membership { + display: flex; +} + +.membershipBody { + flex: 1; + min-width: 0; + margin-right: 8px; +} + +.invitation { + display: flex; +} + +.invitationBody { + flex: 1; + min-width: 0; + margin-right: 8px; +} +</style> diff --git a/packages/frontend/src/pages/chat/room.search.vue b/packages/frontend/src/pages/chat/room.search.vue new file mode 100644 index 0000000000..20b6e22a46 --- /dev/null +++ b/packages/frontend/src/pages/chat/room.search.vue @@ -0,0 +1,73 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div class="_gaps"> + <MkInput + v-model="searchQuery" + :placeholder="i18n.ts._chat.searchMessages" + type="search" + @enter="search()" + > + <template #prefix><i class="ti ti-search"></i></template> + </MkInput> + + <MkButton primary rounded @click="search">{{ i18n.ts.search }}</MkButton> + + <MkFoldableSection v-if="searched"> + <template #header>{{ i18n.ts.searchResult }}</template> + + <div v-if="searchResults.length > 0" class="_gaps_s"> + <div v-for="message in searchResults" :key="message.id" :class="$style.searchResultItem"> + <XMessage :message="message" :user="message.fromUser" :isSearchResult="true"/> + </div> + </div> + <div v-else class="_fullinfo"> + <img :src="infoImageUrl" draggable="false"/> + <div>{{ i18n.ts.notFound }}</div> + </div> + </MkFoldableSection> +</div> +</template> + +<script lang="ts" setup> +import { ref } from 'vue'; +import * as Misskey from 'misskey-js'; +import XMessage from './XMessage.vue'; +import MkButton from '@/components/MkButton.vue'; +import { i18n } from '@/i18n.js'; +import { infoImageUrl } from '@/instance.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import MkInput from '@/components/MkInput.vue'; +import MkFoldableSection from '@/components/MkFoldableSection.vue'; + +const props = defineProps<{ + userId?: string; + roomId?: string; +}>(); + +const searchQuery = ref(''); +const searched = ref(false); +const searchResults = ref<Misskey.entities.ChatMessage[]>([]); + +async function search() { + const res = await misskeyApi('chat/messages/search', { + query: searchQuery.value, + roomId: props.roomId, + userId: props.userId, + }); + + searchResults.value = res; + searched.value = true; +} +</script> + +<style lang="scss" module> +.searchResultItem { + padding: 12px; + border: solid 1px var(--MI_THEME-divider); + border-radius: 12px; +} +</style> diff --git a/packages/frontend/src/pages/chat/room.vue b/packages/frontend/src/pages/chat/room.vue new file mode 100644 index 0000000000..8b351c1ec8 --- /dev/null +++ b/packages/frontend/src/pages/chat/room.vue @@ -0,0 +1,517 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<PageWithHeader v-model:tab="tab" :reversed="tab === 'chat'" :tabs="headerTabs" :actions="headerActions"> + <MkSpacer v-if="tab === 'chat'" :contentMax="700"> + <div class="_gaps"> + <div v-if="initializing"> + <MkLoading/> + </div> + + <div v-else-if="messages.length === 0"> + <div class="_gaps" style="text-align: center;"> + <div>{{ i18n.ts._chat.noMessagesYet }}</div> + <template v-if="user"> + <div v-if="user.chatScope === 'followers'">{{ i18n.ts._chat.thisUserAllowsChatOnlyFromFollowers }}</div> + <div v-else-if="user.chatScope === 'following'">{{ i18n.ts._chat.thisUserAllowsChatOnlyFromFollowing }}</div> + <div v-else-if="user.chatScope === 'mutual'">{{ i18n.ts._chat.thisUserAllowsChatOnlyFromMutualFollowing }}</div> + <div v-else-if="user.chatScope === 'none'">{{ i18n.ts._chat.thisUserNotAllowedChatAnyone }}</div> + </template> + <template v-else-if="room"> + <div>{{ i18n.ts._chat.inviteUserToChat }}</div> + </template> + </div> + </div> + + <div v-else ref="timelineEl" class="_gaps"> + <div v-if="canFetchMore"> + <MkButton :class="$style.more" :wait="moreFetching" primary rounded @click="fetchMore">{{ i18n.ts.loadMore }}</MkButton> + </div> + + <TransitionGroup + :enterActiveClass="prefer.s.animation ? $style.transition_x_enterActive : ''" + :leaveActiveClass="prefer.s.animation ? $style.transition_x_leaveActive : ''" + :enterFromClass="prefer.s.animation ? $style.transition_x_enterFrom : ''" + :leaveToClass="prefer.s.animation ? $style.transition_x_leaveTo : ''" + :moveClass="prefer.s.animation ? $style.transition_x_move : ''" + tag="div" class="_gaps" + > + <template v-for="item in timeline.toReversed()" :key="item.id"> + <XMessage v-if="item.type === 'item'" :message="item.data"/> + <div v-else-if="item.type === 'date'" :class="$style.dateDivider"> + <span><i class="ti ti-chevron-up"></i> {{ item.nextText }}</span> + <span style="height: 1em; width: 1px; background: var(--MI_THEME-divider);"></span> + <span>{{ item.prevText }} <i class="ti ti-chevron-down"></i></span> + </div> + </template> + </TransitionGroup> + </div> + + <div v-if="user && (!user.canChat || user.host !== null)"> + <MkInfo warn>{{ i18n.ts._chat.chatNotAvailableInOtherAccount }}</MkInfo> + </div> + + <MkInfo v-if="$i.policies.chatAvailability !== 'available'" warn>{{ $i.policies.chatAvailability === 'readonly' ? i18n.ts._chat.chatIsReadOnlyForThisAccountOrServer : i18n.ts._chat.chatNotAvailableForThisAccountOrServer }}</MkInfo> + </div> + </MkSpacer> + + <MkSpacer v-else-if="tab === 'search'" :contentMax="700"> + <XSearch :userId="userId" :roomId="roomId"/> + </MkSpacer> + + <MkSpacer v-else-if="tab === 'members'" :contentMax="700"> + <XMembers v-if="room != null" :room="room" @inviteUser="inviteUser"/> + </MkSpacer> + + <MkSpacer v-else-if="tab === 'info'" :contentMax="700"> + <XInfo v-if="room != null" :room="room"/> + </MkSpacer> + + <template #footer> + <div v-if="tab === 'chat'" :class="$style.footer"> + <div class="_gaps"> + <Transition name="fade"> + <div v-show="showIndicator" :class="$style.new"> + <button class="_buttonPrimary" :class="$style.newButton" @click="onIndicatorClick"> + <i class="fas ti-fw fa-arrow-circle-down" :class="$style.newIcon"></i>{{ i18n.ts._chat.newMessage }} + </button> + </div> + </Transition> + <XForm v-if="!initializing" :user="user" :room="room" :class="$style.form"/> + </div> + </div> + </template> +</PageWithHeader> +</template> + +<script lang="ts" setup> +import { ref, useTemplateRef, computed, onMounted, onBeforeUnmount, onDeactivated, onActivated } from 'vue'; +import * as Misskey from 'misskey-js'; +import { getScrollContainer } from '@@/js/scroll.js'; +import XMessage from './XMessage.vue'; +import XForm from './room.form.vue'; +import XSearch from './room.search.vue'; +import XMembers from './room.members.vue'; +import XInfo from './room.info.vue'; +import type { MenuItem } from '@/types/menu.js'; +import type { PageHeaderItem } from '@/types/page-header.js'; +import * as os from '@/os.js'; +import { useStream } from '@/stream.js'; +import * as sound from '@/utility/sound.js'; +import { i18n } from '@/i18n.js'; +import { ensureSignin } from '@/i.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { definePage } from '@/page.js'; +import { prefer } from '@/preferences.js'; +import MkButton from '@/components/MkButton.vue'; +import { useRouter } from '@/router.js'; +import { useMutationObserver } from '@/use/use-mutation-observer.js'; +import MkInfo from '@/components/MkInfo.vue'; +import { makeDateSeparatedTimelineComputedRef } from '@/utility/timeline-date-separate.js'; + +const $i = ensureSignin(); +const router = useRouter(); + +const props = defineProps<{ + userId?: string; + roomId?: string; +}>(); + +export type NormalizedChatMessage = Omit<Misskey.entities.ChatMessageLite, 'fromUser' | 'reactions'> & { + fromUser: Misskey.entities.UserLite; + reactions: (Misskey.entities.ChatMessageLite['reactions'][number] & { + user: Misskey.entities.UserLite; + })[]; +}; + +const initializing = ref(true); +const moreFetching = ref(false); +const messages = ref<NormalizedChatMessage[]>([]); +const canFetchMore = ref(false); +const user = ref<Misskey.entities.UserDetailed | null>(null); +const room = ref<Misskey.entities.ChatRoom | null>(null); +const connection = ref<Misskey.IChannelConnection<Misskey.Channels['chatUser']> | Misskey.IChannelConnection<Misskey.Channels['chatRoom']> | null>(null); +const showIndicator = ref(false); +const timelineEl = useTemplateRef('timelineEl'); +const timeline = makeDateSeparatedTimelineComputedRef(messages); + +const SCROLL_HEAD_THRESHOLD = 200; + +// column-reverseなので本来はスクロール位置の最下部への追従は不要なはずだが、おそらくブラウザのバグにより、最下部にスクロールした状態でも追従されない場合がある(スクロール位置が少数になることがあるのが関わっていそう) +// そのため補助としてMutationObserverを使って追従を行う +useMutationObserver(timelineEl, { + subtree: true, + childList: true, + attributes: false, +}, () => { + const scrollContainer = getScrollContainer(timelineEl.value)!; + // column-reverseなのでscrollTopは負になる + if (-scrollContainer.scrollTop < SCROLL_HEAD_THRESHOLD) { + scrollContainer.scrollTo({ + top: 0, + behavior: 'instant', + }); + } +}); + +function normalizeMessage(message: Misskey.entities.ChatMessageLite | Misskey.entities.ChatMessage): NormalizedChatMessage { + return { + ...message, + fromUser: message.fromUser ?? (message.fromUserId === $i.id ? $i : user.value!), + reactions: message.reactions.map(record => ({ + ...record, + user: record.user ?? (message.fromUserId === $i.id ? user.value! : $i), + })), + }; +} + +async function initialize() { + const LIMIT = 20; + + initializing.value = true; + + if (props.userId) { + const [u, m] = await Promise.all([ + misskeyApi('users/show', { userId: props.userId }), + misskeyApi('chat/messages/user-timeline', { userId: props.userId, limit: LIMIT }), + ]); + + user.value = u; + messages.value = m.map(x => normalizeMessage(x)); + + if (messages.value.length === LIMIT) { + canFetchMore.value = true; + } + + connection.value = useStream().useChannel('chatUser', { + otherId: user.value.id, + }); + connection.value.on('message', onMessage); + connection.value.on('deleted', onDeleted); + connection.value.on('react', onReact); + connection.value.on('unreact', onUnreact); + } else { + const [r, m] = await Promise.all([ + misskeyApi('chat/rooms/show', { roomId: props.roomId }), + misskeyApi('chat/messages/room-timeline', { roomId: props.roomId, limit: LIMIT }), + ]); + + room.value = r as Misskey.entities.ChatRoomsShowResponse; + messages.value = (m as Misskey.entities.ChatMessagesRoomTimelineResponse).map(x => normalizeMessage(x)); + + if (messages.value.length === LIMIT) { + canFetchMore.value = true; + } + + connection.value = useStream().useChannel('chatRoom', { + roomId: room.value.id, + }); + connection.value.on('message', onMessage); + connection.value.on('deleted', onDeleted); + connection.value.on('react', onReact); + connection.value.on('unreact', onUnreact); + } + + window.document.addEventListener('visibilitychange', onVisibilitychange); + + initializing.value = false; +} + +let isActivated = true; + +onActivated(() => { + isActivated = true; +}); + +onDeactivated(() => { + isActivated = false; +}); + +async function fetchMore() { + const LIMIT = 30; + + moreFetching.value = true; + + const newMessages = props.userId ? await misskeyApi('chat/messages/user-timeline', { + userId: user.value!.id, + limit: LIMIT, + untilId: messages.value[messages.value.length - 1].id, + }) : await misskeyApi('chat/messages/room-timeline', { + roomId: room.value!.id, + limit: LIMIT, + untilId: messages.value[messages.value.length - 1].id, + }); + + messages.value.push(...newMessages.map(x => normalizeMessage(x))); + + canFetchMore.value = newMessages.length === LIMIT; + moreFetching.value = false; +} + +function onMessage(message: Misskey.entities.ChatMessageLite) { + sound.playMisskeySfx('chatMessage'); + + messages.value.unshift(normalizeMessage(message)); + + // TODO: DOM的にバックグラウンドになっていないかどうかも考慮する + if (message.fromUserId !== $i.id && !window.document.hidden && isActivated) { + connection.value?.send('read', { + id: message.id, + }); + } + + if (message.fromUserId !== $i.id) { + //notifyNewMessage(); + } +} + +function onDeleted(id: string) { + const index = messages.value.findIndex(m => m.id === id); + if (index !== -1) { + messages.value.splice(index, 1); + } +} + +function onReact(ctx: Parameters<Misskey.Channels['chatUser']['events']['react']>[0] | Parameters<Misskey.Channels['chatRoom']['events']['react']>[0]) { + const message = messages.value.find(m => m.id === ctx.messageId); + if (message) { + if (room.value == null) { // 1on1の時はuserは省略される + message.reactions.push({ + reaction: ctx.reaction, + user: message.fromUserId === $i.id ? user.value! : $i, + }); + } else { + message.reactions.push({ + reaction: ctx.reaction, + user: ctx.user!, + }); + } + } +} + +function onUnreact(ctx: Parameters<Misskey.Channels['chatUser']['events']['unreact']>[0] | Parameters<Misskey.Channels['chatRoom']['events']['unreact']>[0]) { + const message = messages.value.find(m => m.id === ctx.messageId); + if (message) { + const index = message.reactions.findIndex(r => r.reaction === ctx.reaction && r.user.id === ctx.user!.id); + if (index !== -1) { + message.reactions.splice(index, 1); + } + } +} + +function onIndicatorClick() { + showIndicator.value = false; +} + +function notifyNewMessage() { + showIndicator.value = true; +} + +function onVisibilitychange() { + if (window.document.hidden) return; + // TODO +} + +onMounted(() => { + initialize(); +}); + +onBeforeUnmount(() => { + connection.value?.dispose(); + window.document.removeEventListener('visibilitychange', onVisibilitychange); +}); + +async function inviteUser() { + if (room.value == null) return; + + const invitee = await os.selectUser({ includeSelf: false, localOnly: true }); + os.apiWithDialog('chat/rooms/invitations/create', { + roomId: room.value.id, + userId: invitee.id, + }); +} + +async function leaveRoom() { + if (room.value == null) return; + + const { canceled } = await os.confirm({ + type: 'warning', + text: i18n.ts.areYouSure, + }); + if (canceled) return; + + misskeyApi('chat/rooms/leave', { + roomId: room.value.id, + }); + router.push('/chat'); +} + +function showMenu(ev: MouseEvent) { + const menuItems: MenuItem[] = []; + + if (room.value) { + if (room.value.ownerId === $i.id) { + menuItems.push({ + text: i18n.ts._chat.inviteUser, + icon: 'ti ti-user-plus', + action: () => { + inviteUser(); + }, + }); + } else { + menuItems.push({ + text: i18n.ts._chat.leave, + icon: 'ti ti-x', + action: () => { + leaveRoom(); + }, + }); + } + } + + os.popupMenu(menuItems, ev.currentTarget ?? ev.target); +} + +const tab = ref('chat'); + +const headerTabs = computed(() => room.value ? [{ + key: 'chat', + title: i18n.ts.chat, + icon: 'ti ti-messages', +}, { + key: 'members', + title: i18n.ts._chat.members, + icon: 'ti ti-users', +}, { + key: 'search', + title: i18n.ts.search, + icon: 'ti ti-search', +}, { + key: 'info', + title: i18n.ts.info, + icon: 'ti ti-info-circle', +}] : [{ + key: 'chat', + title: i18n.ts.chat, + icon: 'ti ti-messages', +}, { + key: 'search', + title: i18n.ts.search, + icon: 'ti ti-search', +}]); + +const headerActions = computed<PageHeaderItem[]>(() => [{ + icon: 'ti ti-dots', + text: '', + handler: showMenu, +}]); + +definePage(computed(() => { + if (!initializing.value) { + if (user.value) { + return { + userName: user.value, + title: user.value.name ?? user.value.username, + avatar: user.value, + }; + } else if (room.value) { + return { + title: room.value.name, + icon: 'ti ti-users', + }; + } else { + return { + title: i18n.ts.chat, + }; + } + } else { + return { + title: i18n.ts.chat, + }; + } +})); +</script> + +<style lang="scss" module> +.transition_x_move, +.transition_x_enterActive, +.transition_x_leaveActive { + transition: opacity 0.2s cubic-bezier(0,.5,.5,1), transform 0.2s cubic-bezier(0,.5,.5,1) !important; +} +.transition_x_enterFrom, +.transition_x_leaveTo { + opacity: 0; + transform: translateY(80px); +} +.transition_x_leaveActive { + position: absolute; +} + +.root { +} + +.more { + margin: 0 auto; +} + +.footer { + width: 100%; + padding-top: 8px; +} + +.new { + width: 100%; + padding-bottom: 8px; + text-align: center; +} + +.newButton { + display: inline-block; + margin: 0; + padding: 0 12px; + line-height: 32px; + font-size: 12px; + border-radius: 16px; +} + +.newIcon { + display: inline-block; + margin-right: 8px; +} + +.footer { + +} + +.form { + margin: 0 auto; + width: 100%; + max-width: 700px; +} + +.fade-enter-active, .fade-leave-active { + transition: opacity 0.1s; +} + +.fade-enter-from, .fade-leave-to { + transition: opacity 0.5s; + opacity: 0; +} + +.dateDivider { + display: flex; + font-size: 85%; + align-items: center; + justify-content: center; + gap: 0.5em; + opacity: 0.75; + border: solid 0.5px var(--MI_THEME-divider); + border-radius: 999px; + width: fit-content; + padding: 0.5em 1em; + margin: 0 auto; +} +</style> diff --git a/packages/frontend/src/pages/clicker.vue b/packages/frontend/src/pages/clicker.vue index 9e9b5e8688..479204f39b 100644 --- a/packages/frontend/src/pages/clicker.vue +++ b/packages/frontend/src/pages/clicker.vue @@ -4,19 +4,18 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader/></template> +<PageWithHeader> <MkSpacer :contentMax="800"> <MkClickerGame/> </MkSpacer> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> import MkClickerGame from '@/components/MkClickerGame.vue'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; -definePageMetadata(() => ({ +definePage(() => ({ title: '🍪👈', icon: 'ti ti-cookie', })); diff --git a/packages/frontend/src/pages/clip.vue b/packages/frontend/src/pages/clip.vue index 224bc51599..a2fb02462e 100644 --- a/packages/frontend/src/pages/clip.vue +++ b/packages/frontend/src/pages/clip.vue @@ -4,8 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader :actions="headerActions"/></template> +<PageWithHeader :actions="headerActions"> <MkSpacer :contentMax="800"> <div v-if="clip" class="_gaps"> <div class="_panel"> @@ -27,7 +26,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkNotes :pagination="pagination" :detail="true"/> </div> </MkSpacer> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> @@ -36,16 +35,16 @@ import * as Misskey from 'misskey-js'; import { url } from '@@/js/config.js'; import type { MenuItem } from '@/types/menu.js'; import MkNotes from '@/components/MkNotes.vue'; -import { $i } from '@/account.js'; +import { $i } from '@/i.js'; import { i18n } from '@/i18n.js'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { definePage } from '@/page.js'; import MkButton from '@/components/MkButton.vue'; import { clipsCache } from '@/cache.js'; -import { isSupportShare } from '@/scripts/navigator.js'; -import { copyToClipboard } from '@/scripts/copy-to-clipboard.js'; -import { genEmbedCode } from '@/scripts/get-embed-code.js'; +import { isSupportShare } from '@/utility/navigator.js'; +import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; +import { genEmbedCode } from '@/utility/get-embed-code.js'; import { assertServerContext, serverContext } from '@/server-context.js'; // contextは非ログイン状態の情報しかないためログイン時は利用できない @@ -148,11 +147,10 @@ const headerActions = computed(() => clip.value && isOwned.value ? [{ text: i18n.ts.copyUrl, action: () => { copyToClipboard(`${url}/clips/${clip.value!.id}`); - os.success(); }, }, { icon: 'ti ti-code', - text: i18n.ts.genEmbedCode, + text: i18n.ts.embed, action: () => { genEmbedCode('clips', clip.value!.id); }, @@ -193,7 +191,7 @@ const headerActions = computed(() => clip.value && isOwned.value ? [{ }, }] : null); -definePageMetadata(() => ({ +definePage(() => ({ title: clip.value ? clip.value.name : i18n.ts.clip, icon: 'ti ti-paperclip', })); diff --git a/packages/frontend/src/pages/contact.vue b/packages/frontend/src/pages/contact.vue index 1f2bee5a77..39d70cafc7 100644 --- a/packages/frontend/src/pages/contact.vue +++ b/packages/frontend/src/pages/contact.vue @@ -4,8 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader/></template> +<PageWithHeader> <MkSpacer :contentMax="600" :marginMin="20"> <div class="_gaps_m"> <MkKeyValue :copy="instance.maintainerName"> @@ -31,17 +30,17 @@ SPDX-License-Identifier: AGPL-3.0-only </MkKeyValue> </div> </MkSpacer> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> import { i18n } from '@/i18n.js'; import { instance } from '@/instance.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; import MkKeyValue from '@/components/MkKeyValue.vue'; import MkLink from '@/components/MkLink.vue'; -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.inquiry, icon: 'ti ti-help-circle', })); diff --git a/packages/frontend/src/pages/custom-emojis-manager.vue b/packages/frontend/src/pages/custom-emojis-manager.vue index 82c6d8df4e..22748e770a 100644 --- a/packages/frontend/src/pages/custom-emojis-manager.vue +++ b/packages/frontend/src/pages/custom-emojis-manager.vue @@ -4,91 +4,88 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div> - <MkStickyContainer> - <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> - <MkSpacer :contentMax="900"> - <div class="ogwlenmc"> - <div v-if="tab === 'local'" class="local"> - <MkInput v-model="query" :debounce="true" type="search" autocapitalize="off"> +<PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"> + <MkSpacer :contentMax="900"> + <div class="ogwlenmc"> + <div v-if="tab === 'local'" class="local"> + <MkInput v-model="query" :debounce="true" type="search" autocapitalize="off"> + <template #prefix><i class="ti ti-search"></i></template> + <template #label>{{ i18n.ts.search }}</template> + </MkInput> + <MkSwitch v-model="selectMode" style="margin: 8px 0;"> + <template #label>Select mode</template> + </MkSwitch> + <div v-if="selectMode" class="_buttons"> + <MkButton inline @click="selectAll">Select all</MkButton> + <MkButton inline @click="setCategoryBulk">Set category</MkButton> + <MkButton inline @click="setTagBulk">Set tag</MkButton> + <MkButton inline @click="addTagBulk">Add tag</MkButton> + <MkButton inline @click="removeTagBulk">Remove tag</MkButton> + <MkButton inline @click="setLicenseBulk">Set License</MkButton> + <MkButton inline danger @click="delBulk">Delete</MkButton> + </div> + <MkPagination ref="emojisPaginationComponent" :pagination="pagination"> + <template #empty><span>{{ i18n.ts.noCustomEmojis }}</span></template> + <template #default="{items}"> + <div class="ldhfsamy"> + <button v-for="emoji in items" :key="emoji.id" class="emoji _panel _button" :class="{ selected: selectedEmojis.includes(emoji.id) }" @click="selectMode ? toggleSelect(emoji) : edit(emoji)"> + <img :src="emoji.url" class="img" :alt="emoji.name"/> + <div class="body"> + <div class="name _monospace">{{ emoji.name }}</div> + <div class="info">{{ emoji.category }}</div> + </div> + </button> + </div> + </template> + </MkPagination> + </div> + + <div v-else-if="tab === 'remote'" class="remote"> + <FormSplit> + <MkInput v-model="queryRemote" :debounce="true" type="search" autocapitalize="off"> <template #prefix><i class="ti ti-search"></i></template> <template #label>{{ i18n.ts.search }}</template> </MkInput> - <MkSwitch v-model="selectMode" style="margin: 8px 0;"> - <template #label>Select mode</template> - </MkSwitch> - <div v-if="selectMode" class="_buttons"> - <MkButton inline @click="selectAll">Select all</MkButton> - <MkButton inline @click="setCategoryBulk">Set category</MkButton> - <MkButton inline @click="setTagBulk">Set tag</MkButton> - <MkButton inline @click="addTagBulk">Add tag</MkButton> - <MkButton inline @click="removeTagBulk">Remove tag</MkButton> - <MkButton inline @click="setLicenseBulk">Set License</MkButton> - <MkButton inline danger @click="delBulk">Delete</MkButton> - </div> - <MkPagination ref="emojisPaginationComponent" :pagination="pagination"> - <template #empty><span>{{ i18n.ts.noCustomEmojis }}</span></template> - <template #default="{items}"> - <div class="ldhfsamy"> - <button v-for="emoji in items" :key="emoji.id" class="emoji _panel _button" :class="{ selected: selectedEmojis.includes(emoji.id) }" @click="selectMode ? toggleSelect(emoji) : edit(emoji)"> - <img :src="emoji.url" class="img" :alt="emoji.name"/> - <div class="body"> - <div class="name _monospace">{{ emoji.name }}</div> - <div class="info">{{ emoji.category }}</div> - </div> - </button> - </div> - </template> - </MkPagination> - </div> - - <div v-else-if="tab === 'remote'" class="remote"> - <FormSplit> - <MkInput v-model="queryRemote" :debounce="true" type="search" autocapitalize="off"> - <template #prefix><i class="ti ti-search"></i></template> - <template #label>{{ i18n.ts.search }}</template> - </MkInput> - <MkInput v-model="host" :debounce="true"> - <template #label>{{ i18n.ts.host }}</template> - </MkInput> - </FormSplit> - <MkPagination :pagination="remotePagination"> - <template #empty><span>{{ i18n.ts.noCustomEmojis }}</span></template> - <template #default="{items}"> - <div class="ldhfsamy"> - <div v-for="emoji in items" :key="emoji.id" class="emoji _panel _button" @click="remoteMenu(emoji, $event)"> - <img :src="getProxiedImageUrl(emoji.url, 'emoji')" class="img" :alt="emoji.name"/> - <div class="body"> - <div class="name _monospace">{{ emoji.name }}</div> - <div class="info">{{ emoji.host }}</div> - </div> + <MkInput v-model="host" :debounce="true"> + <template #label>{{ i18n.ts.host }}</template> + </MkInput> + </FormSplit> + <MkPagination :pagination="remotePagination"> + <template #empty><span>{{ i18n.ts.noCustomEmojis }}</span></template> + <template #default="{items}"> + <div class="ldhfsamy"> + <div v-for="emoji in items" :key="emoji.id" class="emoji _panel _button" @click="remoteMenu(emoji, $event)"> + <img :src="getProxiedImageUrl(emoji.url, 'emoji')" class="img" :alt="emoji.name"/> + <div class="body"> + <div class="name _monospace">{{ emoji.name }}</div> + <div class="info">{{ emoji.host }}</div> </div> </div> - </template> - </MkPagination> - </div> + </div> + </template> + </MkPagination> </div> - </MkSpacer> - </MkStickyContainer> -</div> + </div> + </MkSpacer> +</PageWithHeader> </template> <script lang="ts" setup> -import { computed, defineAsyncComponent, ref, shallowRef } from 'vue'; +import { computed, defineAsyncComponent, ref, useTemplateRef } from 'vue'; import MkButton from '@/components/MkButton.vue'; import MkInput from '@/components/MkInput.vue'; import MkPagination from '@/components/MkPagination.vue'; import MkRemoteEmojiEditDialog from '@/components/MkRemoteEmojiEditDialog.vue'; import MkSwitch from '@/components/MkSwitch.vue'; import FormSplit from '@/components/form/split.vue'; -import { selectFile } from '@/scripts/select-file.js'; +import { selectFile } from '@/utility/select-file.js'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { getProxiedImageUrl } from '@/scripts/media-proxy.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { getProxiedImageUrl } from '@/utility/media-proxy.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; -const emojisPaginationComponent = shallowRef<InstanceType<typeof MkPagination>>(); +const emojisPaginationComponent = useTemplateRef('emojisPaginationComponent'); const tab = ref('local'); const query = ref<string | null>(null); @@ -326,7 +323,7 @@ const headerTabs = computed(() => [{ title: i18n.ts.remote, }]); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.customEmojis, icon: 'ti ti-icons', })); diff --git a/packages/frontend/src/pages/drive.file.info.vue b/packages/frontend/src/pages/drive.file.info.vue index dfcc82c77b..5390a48be5 100644 --- a/packages/frontend/src/pages/drive.file.info.vue +++ b/packages/frontend/src/pages/drive.file.info.vue @@ -69,7 +69,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </div> <div v-else class="_fullinfo"> - <img :src="infoImageUrl" class="_ghost"/> + <img :src="infoImageUrl" draggable="false"/> <div>{{ i18n.ts.nothing }}</div> </div> </div> @@ -85,8 +85,8 @@ import bytes from '@/filters/bytes.js'; import { infoImageUrl } from '@/instance.js'; import { i18n } from '@/i18n.js'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { useRouter } from '@/router/supplier.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { useRouter } from '@/router.js'; const router = useRouter(); diff --git a/packages/frontend/src/pages/drive.file.vue b/packages/frontend/src/pages/drive.file.vue index 5711ec8b3a..3063d5a4d6 100644 --- a/packages/frontend/src/pages/drive.file.vue +++ b/packages/frontend/src/pages/drive.file.vue @@ -10,11 +10,11 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs"> - <MkSpacer v-if="tab === 'info'" key="info" :contentMax="800"> + <MkSpacer v-if="tab === 'info'" :contentMax="800"> <XFileInfo :fileId="fileId"/> </MkSpacer> - <MkSpacer v-else-if="tab === 'notes'" key="notes" :contentMax="800"> + <MkSpacer v-else-if="tab === 'notes'" :contentMax="800"> <XNotes :fileId="fileId"/> </MkSpacer> </MkHorizontalSwipe> @@ -24,7 +24,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { computed, ref, defineAsyncComponent } from 'vue'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue'; const props = defineProps<{ @@ -48,7 +48,7 @@ const headerTabs = computed(() => [{ icon: 'ti ti-pencil', }]); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts._fileViewer.title, icon: 'ti ti-file', })); diff --git a/packages/frontend/src/pages/drive.vue b/packages/frontend/src/pages/drive.vue index 25e140f67f..bee54f3fd2 100644 --- a/packages/frontend/src/pages/drive.vue +++ b/packages/frontend/src/pages/drive.vue @@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div> - <XDrive ref="drive" @cd="x => folder = x"/> + <XDrive @cd="x => folder = x"/> </div> </template> @@ -14,7 +14,7 @@ import { computed, ref } from 'vue'; import * as Misskey from 'misskey-js'; import XDrive from '@/components/MkDrive.vue'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; const folder = ref<Misskey.entities.DriveFolder | null>(null); @@ -22,7 +22,7 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: folder.value ? folder.value.name : i18n.ts.drive, icon: 'ti ti-cloud', hideHeader: true, diff --git a/packages/frontend/src/pages/drop-and-fusion.game.vue b/packages/frontend/src/pages/drop-and-fusion.game.vue index 10099e6291..b8b0d6aef6 100644 --- a/packages/frontend/src/pages/drop-and-fusion.game.vue +++ b/packages/frontend/src/pages/drop-and-fusion.game.vue @@ -56,7 +56,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> <div ref="containerEl" :class="[$style.gameContainer, { [$style.gameOver]: isGameOver && !replaying }]" @contextmenu.stop.prevent @click.stop.prevent="onClick" @touchmove.stop.prevent="onTouchmove" @touchend="onTouchend" @mousemove="onMousemove"> - <img v-if="defaultStore.state.darkMode" src="/client-assets/drop-and-fusion/frame-dark.svg" :class="$style.mainFrameImg"/> + <img v-if="store.s.darkMode" src="/client-assets/drop-and-fusion/frame-dark.svg" :class="$style.mainFrameImg"/> <img v-else src="/client-assets/drop-and-fusion/frame-light.svg" :class="$style.mainFrameImg"/> <canvas ref="canvasEl" :class="$style.canvas"/> <Transition @@ -191,27 +191,28 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { computed, onDeactivated, onMounted, onUnmounted, ref, shallowRef, watch } from 'vue'; +import { computed, onDeactivated, onMounted, onUnmounted, ref, shallowRef, watch, useTemplateRef } from 'vue'; import * as Matter from 'matter-js'; import * as Misskey from 'misskey-js'; import { DropAndFusionGame } from 'misskey-bubble-game'; +import { useInterval } from '@@/js/use-interval.js'; +import { apiUrl } from '@@/js/config.js'; import type { Mono } from 'misskey-bubble-game'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; import MkRippleEffect from '@/components/MkRippleEffect.vue'; import * as os from '@/os.js'; import MkNumber from '@/components/MkNumber.vue'; import MkPlusOneEffect from '@/components/MkPlusOneEffect.vue'; import MkButton from '@/components/MkButton.vue'; -import { claimAchievement } from '@/scripts/achievements.js'; -import { defaultStore } from '@/store.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { claimAchievement } from '@/utility/achievements.js'; +import { store } from '@/store.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; -import { useInterval } from '@@/js/use-interval.js'; -import { apiUrl } from '@@/js/config.js'; -import { $i } from '@/account.js'; -import * as sound from '@/scripts/sound.js'; +import { $i } from '@/i.js'; +import * as sound from '@/utility/sound.js'; import MkRange from '@/components/MkRange.vue'; -import { copyToClipboard } from '@/scripts/copy-to-clipboard.js'; +import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; +import { prefer } from '@/preferences.js'; type FrontendMonoDefinition = { id: string; @@ -566,8 +567,8 @@ let game = new DropAndFusionGame({ }); attachGameEvents(); -const containerEl = shallowRef<HTMLElement>(); -const canvasEl = shallowRef<HTMLCanvasElement>(); +const containerEl = useTemplateRef('containerEl'); +const canvasEl = useTemplateRef('canvasEl'); const dropperX = ref(0); const currentPick = shallowRef<{ id: string; mono: Mono } | null>(null); const stock = shallowRef<{ id: string; mono: Mono }[]>([]); @@ -586,8 +587,8 @@ const showConfig = ref(false); const replaying = ref(false); const replayPlaybackRate = ref(1); const currentFrame = ref(0); -const bgmVolume = ref(defaultStore.state.dropAndFusion.bgmVolume); -const sfxVolume = ref(defaultStore.state.dropAndFusion.sfxVolume); +const bgmVolume = ref(prefer.s['game.dropAndFusion'].bgmVolume); +const sfxVolume = ref(prefer.s['game.dropAndFusion'].sfxVolume); watch(replayPlaybackRate, (newValue) => { game.replayPlaybackRate = newValue; @@ -623,7 +624,7 @@ function loadMonoTextures() { if (renderer.textures[mono.img]) return; let src = mono.img; - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (monoTextureUrls[mono.img]) { src = monoTextureUrls[mono.img]; // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition @@ -631,7 +632,7 @@ function loadMonoTextures() { src = URL.createObjectURL(monoTextures[mono.img]); monoTextureUrls[mono.img] = src; } else { - const res = await fetch(mono.img); + const res = await window.fetch(mono.img); const blob = await res.blob(); monoTextures[mono.img] = blob; src = URL.createObjectURL(blob); @@ -649,7 +650,6 @@ function loadMonoTextures() { function getTextureImageUrl(mono: Mono) { const def = monoDefinitions.value.find(x => x.id === mono.id)!; - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (monoTextureUrls[def.img]) { return monoTextureUrls[def.img]; @@ -849,17 +849,16 @@ function exportLog() { l: DropAndFusionGame.serializeLogs(logs), }); copyToClipboard(data); - os.success(); } function updateSettings< - K extends keyof typeof defaultStore.state.dropAndFusion, - V extends typeof defaultStore.state.dropAndFusion[K], + K extends keyof typeof prefer.s['game.dropAndFusion'], + V extends typeof prefer.s['game.dropAndFusion'][K], >(key: K, value: V) { const changes: { [P in K]?: V } = {}; changes[key] = value; - defaultStore.set('dropAndFusion', { - ...defaultStore.state.dropAndFusion, + prefer.commit('game.dropAndFusion', { + ...prefer.s['game.dropAndFusion'], ...changes, }); } @@ -876,7 +875,7 @@ function loadImage(url: string) { function getGameImageDriveFile() { return new Promise<Misskey.entities.DriveFile | null>(res => { - const dcanvas = document.createElement('canvas'); + const dcanvas = window.document.createElement('canvas'); dcanvas.width = game.GAME_WIDTH; dcanvas.height = game.GAME_HEIGHT; const ctx = dcanvas.getContext('2d'); @@ -909,8 +908,8 @@ function getGameImageDriveFile() { formData.append('name', `bubble-game-${Date.now()}.png`); formData.append('isSensitive', 'false'); formData.append('i', $i.token); - if (defaultStore.state.uploadFolder) { - formData.append('folderId', defaultStore.state.uploadFolder); + if (prefer.s.uploadFolder) { + formData.append('folderId', prefer.s.uploadFolder); } window.fetch(apiUrl + '/drive/files/create', { @@ -1229,7 +1228,7 @@ onDeactivated(() => { bgmNodes?.soundSource.stop(); }); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.bubbleGame, icon: 'ti ti-apple', })); diff --git a/packages/frontend/src/pages/drop-and-fusion.vue b/packages/frontend/src/pages/drop-and-fusion.vue index 54352c9b0d..7f571a7c36 100644 --- a/packages/frontend/src/pages/drop-and-fusion.vue +++ b/packages/frontend/src/pages/drop-and-fusion.vue @@ -88,12 +88,12 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { computed, ref, watch } from 'vue'; import XGame from './drop-and-fusion.game.vue'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; import MkButton from '@/components/MkButton.vue'; import { i18n } from '@/i18n.js'; import MkSelect from '@/components/MkSelect.vue'; import MkSwitch from '@/components/MkSwitch.vue'; -import { misskeyApiGet } from '@/scripts/misskey-api.js'; +import { misskeyApiGet } from '@/utility/misskey-api.js'; const gameMode = ref<'normal' | 'square' | 'yen' | 'sweets' | 'space'>('normal'); const gameStarted = ref(false); @@ -121,7 +121,7 @@ function onGameEnd() { gameStarted.value = false; } -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.bubbleGame, icon: 'ti ti-device-gamepad', })); diff --git a/packages/frontend/src/pages/emoji-edit-dialog.vue b/packages/frontend/src/pages/emoji-edit-dialog.vue index da3315cff5..d0d8970309 100644 --- a/packages/frontend/src/pages/emoji-edit-dialog.vue +++ b/packages/frontend/src/pages/emoji-edit-dialog.vue @@ -87,11 +87,11 @@ import MkInput from '@/components/MkInput.vue'; import MkInfo from '@/components/MkInfo.vue'; import MkFolder from '@/components/MkFolder.vue'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; import { customEmojiCategories } from '@/custom-emojis.js'; import MkSwitch from '@/components/MkSwitch.vue'; -import { selectFile } from '@/scripts/select-file.js'; +import { selectFile } from '@/utility/select-file.js'; import MkRolePreview from '@/components/MkRolePreview.vue'; const props = defineProps<{ @@ -245,7 +245,7 @@ async function del() { left: 0; padding: 12px; border-top: solid 0.5px var(--MI_THEME-divider); - background: var(--MI_THEME-acrylicBg); + background: color(from var(--MI_THEME-bg) srgb r g b / 0.5); -webkit-backdrop-filter: var(--MI-blur, blur(15px)); backdrop-filter: var(--MI-blur, blur(15px)); } diff --git a/packages/frontend/src/pages/emojis.emoji.vue b/packages/frontend/src/pages/emojis.emoji.vue index 979d50966e..d5570eb20a 100644 --- a/packages/frontend/src/pages/emojis.emoji.vue +++ b/packages/frontend/src/pages/emojis.emoji.vue @@ -18,14 +18,14 @@ import * as Misskey from 'misskey-js'; import { defineAsyncComponent } from 'vue'; import type { MenuItem } from '@/types/menu.js'; import * as os from '@/os.js'; -import { misskeyApiGet } from '@/scripts/misskey-api.js'; -import { copyToClipboard } from '@/scripts/copy-to-clipboard.js'; +import { misskeyApiGet } from '@/utility/misskey-api.js'; +import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; import { i18n } from '@/i18n.js'; import MkCustomEmojiDetailedDialog from '@/components/MkCustomEmojiDetailedDialog.vue'; -import { $i } from '@/account.js'; +import { $i } from '@/i.js'; const props = defineProps<{ - emoji: Misskey.entities.EmojiSimple; + emoji: Misskey.entities.EmojiSimple; }>(); function menu(ev) { @@ -38,7 +38,6 @@ function menu(ev) { icon: 'ti ti-copy', action: () => { copyToClipboard(`:${props.emoji.name}:`); - os.success(); }, }, { text: i18n.ts.info, diff --git a/packages/frontend/src/pages/explore.roles.vue b/packages/frontend/src/pages/explore.roles.vue index 389cd23ad2..ffefeb9618 100644 --- a/packages/frontend/src/pages/explore.roles.vue +++ b/packages/frontend/src/pages/explore.roles.vue @@ -15,7 +15,7 @@ SPDX-License-Identifier: AGPL-3.0-only import { ref } from 'vue'; import * as Misskey from 'misskey-js'; import MkRolePreview from '@/components/MkRolePreview.vue'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; const roles = ref<Misskey.entities.Role[] | null>(null); diff --git a/packages/frontend/src/pages/explore.users.vue b/packages/frontend/src/pages/explore.users.vue index 56ae08b322..c0618b9fce 100644 --- a/packages/frontend/src/pages/explore.users.vue +++ b/packages/frontend/src/pages/explore.users.vue @@ -63,12 +63,12 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { watch, ref, shallowRef, computed } from 'vue'; +import { watch, ref, useTemplateRef, computed } from 'vue'; import * as Misskey from 'misskey-js'; import MkUserList from '@/components/MkUserList.vue'; import MkFoldableSection from '@/components/MkFoldableSection.vue'; import MkTab from '@/components/MkTab.vue'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { instance } from '@/instance.js'; import { i18n } from '@/i18n.js'; @@ -77,7 +77,7 @@ const props = defineProps<{ }>(); const origin = ref('local'); -const tagsEl = shallowRef<InstanceType<typeof MkFoldableSection>>(); +const tagsEl = useTemplateRef('tagsEl'); const tagsLocal = ref<Misskey.entities.Hashtag[]>([]); const tagsRemote = ref<Misskey.entities.Hashtag[]>([]); diff --git a/packages/frontend/src/pages/explore.vue b/packages/frontend/src/pages/explore.vue index b1a8183d9b..85b9fe4932 100644 --- a/packages/frontend/src/pages/explore.vue +++ b/packages/frontend/src/pages/explore.vue @@ -4,30 +4,29 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> +<PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"> <MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs"> - <div v-if="tab === 'featured'" key="featured"> + <div v-if="tab === 'featured'"> <XFeatured/> </div> - <div v-else-if="tab === 'users'" key="users"> + <div v-else-if="tab === 'users'"> <XUsers/> </div> - <div v-else-if="tab === 'roles'" key="roles"> + <div v-else-if="tab === 'roles'"> <XRoles/> </div> </MkHorizontalSwipe> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> -import { computed, watch, ref, shallowRef } from 'vue'; +import { computed, watch, ref, useTemplateRef } from 'vue'; import XFeatured from './explore.featured.vue'; import XUsers from './explore.users.vue'; import XRoles from './explore.roles.vue'; import MkFoldableSection from '@/components/MkFoldableSection.vue'; import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; import { i18n } from '@/i18n.js'; const props = withDefaults(defineProps<{ @@ -38,7 +37,7 @@ const props = withDefaults(defineProps<{ }); const tab = ref(props.initialTab); -const tagsEl = shallowRef<InstanceType<typeof MkFoldableSection>>(); +const tagsEl = useTemplateRef('tagsEl'); watch(() => props.tag, () => { if (tagsEl.value) tagsEl.value.toggleContent(props.tag == null); @@ -60,7 +59,7 @@ const headerTabs = computed(() => [{ title: i18n.ts.roles, }]); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.explore, icon: 'ti ti-hash', })); diff --git a/packages/frontend/src/pages/favorites.vue b/packages/frontend/src/pages/favorites.vue index 6716566101..c9cb75f8f1 100644 --- a/packages/frontend/src/pages/favorites.vue +++ b/packages/frontend/src/pages/favorites.vue @@ -4,13 +4,12 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader/></template> +<PageWithHeader> <MkSpacer :contentMax="800"> <MkPagination :pagination="pagination"> <template #empty> <div class="_fullinfo"> - <img :src="infoImageUrl" class="_ghost"/> + <img :src="infoImageUrl" draggable="false"/> <div>{{ i18n.ts.noNotes }}</div> </div> </template> @@ -22,7 +21,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> </MkPagination> </MkSpacer> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> @@ -30,7 +29,7 @@ import MkPagination from '@/components/MkPagination.vue'; import MkNote from '@/components/MkNote.vue'; import MkDateSeparatedList from '@/components/MkDateSeparatedList.vue'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; import { infoImageUrl } from '@/instance.js'; const pagination = { @@ -38,7 +37,7 @@ const pagination = { limit: 10, }; -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.favorites, icon: 'ti ti-star', })); diff --git a/packages/frontend/src/pages/flash/flash-edit.vue b/packages/frontend/src/pages/flash/flash-edit.vue index d84ec4873b..825a3be7c1 100644 --- a/packages/frontend/src/pages/flash/flash-edit.vue +++ b/packages/frontend/src/pages/flash/flash-edit.vue @@ -4,8 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> +<PageWithHeader :actions="headerActions" :tabs="headerTabs"> <MkSpacer :contentMax="700"> <div class="_gaps"> <MkInput v-model="title"> @@ -37,7 +36,7 @@ SPDX-License-Identifier: AGPL-3.0-only </MkSpacer> </div> </template> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> @@ -46,14 +45,14 @@ import * as Misskey from 'misskey-js'; import { AISCRIPT_VERSION } from '@syuilo/aiscript'; import MkButton from '@/components/MkButton.vue'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; import MkTextarea from '@/components/MkTextarea.vue'; import MkCodeEditor from '@/components/MkCodeEditor.vue'; import MkInput from '@/components/MkInput.vue'; import MkSelect from '@/components/MkSelect.vue'; -import { useRouter } from '@/router/supplier.js'; +import { useRouter } from '@/router.js'; const PRESET_DEFAULT = `/// @ ${AISCRIPT_VERSION} @@ -461,14 +460,14 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: flash.value ? `${i18n.ts._play.edit}: ${flash.value.title}` : i18n.ts._play.new, })); </script> <style lang="scss" module> .footer { backdrop-filter: var(--MI-blur, blur(15px)); - background: var(--MI_THEME-acrylicBg); + background: color(from var(--MI_THEME-bg) srgb r g b / 0.5); border-top: solid .5px var(--MI_THEME-divider); } </style> diff --git a/packages/frontend/src/pages/flash/flash-index.vue b/packages/frontend/src/pages/flash/flash-index.vue index 2b85489706..98ab587b55 100644 --- a/packages/frontend/src/pages/flash/flash-index.vue +++ b/packages/frontend/src/pages/flash/flash-index.vue @@ -4,11 +4,10 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> +<PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"> <MkSpacer :contentMax="700"> <MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs"> - <div v-if="tab === 'featured'" key="featured"> + <div v-if="tab === 'featured'"> <MkPagination v-slot="{items}" :pagination="featuredFlashsPagination"> <div class="_gaps_s"> <MkFlashPreview v-for="flash in items" :key="flash.id" :flash="flash"/> @@ -16,7 +15,7 @@ SPDX-License-Identifier: AGPL-3.0-only </MkPagination> </div> - <div v-else-if="tab === 'my'" key="my"> + <div v-else-if="tab === 'my'"> <div class="_gaps"> <MkButton gradate rounded style="margin: 0 auto;" @click="create()"><i class="ti ti-plus"></i></MkButton> <MkPagination v-slot="{items}" :pagination="myFlashsPagination"> @@ -27,7 +26,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </div> - <div v-else-if="tab === 'liked'" key="liked"> + <div v-else-if="tab === 'liked'"> <MkPagination v-slot="{items}" :pagination="likedFlashsPagination"> <div class="_gaps_s"> <MkFlashPreview v-for="like in items" :key="like.flash.id" :flash="like.flash"/> @@ -36,7 +35,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </MkHorizontalSwipe> </MkSpacer> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> @@ -46,8 +45,8 @@ import MkPagination from '@/components/MkPagination.vue'; import MkButton from '@/components/MkButton.vue'; import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; -import { useRouter } from '@/router/supplier.js'; +import { definePage } from '@/page.js'; +import { useRouter } from '@/router.js'; const router = useRouter(); @@ -91,7 +90,7 @@ const headerTabs = computed(() => [{ icon: 'ti ti-heart', }]); -definePageMetadata(() => ({ +definePage(() => ({ title: 'Play', icon: 'ti ti-player-play', })); diff --git a/packages/frontend/src/pages/flash/flash.vue b/packages/frontend/src/pages/flash/flash.vue index 6294a3f4a2..2873822573 100644 --- a/packages/frontend/src/pages/flash/flash.vue +++ b/packages/frontend/src/pages/flash/flash.vue @@ -4,12 +4,11 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> +<PageWithHeader :actions="headerActions" :tabs="headerTabs"> <MkSpacer :contentMax="700"> - <Transition :name="defaultStore.state.animation ? 'fade' : ''" mode="out-in"> + <Transition :name="prefer.s.animation ? 'fade' : ''" mode="out-in"> <div v-if="flash" :key="flash.id"> - <Transition :name="defaultStore.state.animation ? 'zoom' : ''" mode="out-in"> + <Transition :name="prefer.s.animation ? 'zoom' : ''" mode="out-in"> <div v-if="started" :class="$style.started"> <div class="main _panel"> <MkAsUi v-if="root" :component="root" :components="components"/> @@ -52,13 +51,13 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </div> <MkA v-if="$i && $i.id === flash.userId" :to="`/play/${flash.id}/edit`" style="color: var(--MI_THEME-accent);">{{ i18n.ts._play.editThisPage }}</MkA> - <MkAd :prefer="['horizontal', 'horizontal-big']"/> + <MkAd :preferForms="['horizontal', 'horizontal-big']"/> </div> <MkError v-else-if="error" @retry="fetchFlash()"/> <MkLoading v-else/> </Transition> </MkSpacer> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> @@ -67,23 +66,23 @@ import * as Misskey from 'misskey-js'; import { Interpreter, Parser, values } from '@syuilo/aiscript'; import { url } from '@@/js/config.js'; import type { Ref } from 'vue'; -import type { AsUiComponent, AsUiRoot } from '@/scripts/aiscript/ui.js'; +import type { AsUiComponent, AsUiRoot } from '@/aiscript/ui.js'; import type { MenuItem } from '@/types/menu.js'; import MkButton from '@/components/MkButton.vue'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; import MkAsUi from '@/components/MkAsUi.vue'; -import { registerAsUiLib } from '@/scripts/aiscript/ui.js'; -import { aiScriptReadline, createAiScriptEnv } from '@/scripts/aiscript/api.js'; +import { registerAsUiLib } from '@/aiscript/ui.js'; +import { aiScriptReadline, createAiScriptEnv } from '@/aiscript/api.js'; import MkFolder from '@/components/MkFolder.vue'; import MkCode from '@/components/MkCode.vue'; -import { defaultStore } from '@/store.js'; -import { $i } from '@/account.js'; -import { isSupportShare } from '@/scripts/navigator.js'; -import { copyToClipboard } from '@/scripts/copy-to-clipboard.js'; -import { pleaseLogin } from '@/scripts/please-login.js'; +import { prefer } from '@/preferences.js'; +import { $i } from '@/i.js'; +import { isSupportShare } from '@/utility/navigator.js'; +import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; +import { pleaseLogin } from '@/utility/please-login.js'; const props = defineProps<{ id: string; @@ -129,7 +128,6 @@ function copyLink() { if (!flash.value) return; copyToClipboard(`${url}/play/${flash.value.id}`); - os.success(); } function shareWithNavigator() { @@ -304,7 +302,7 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: flash.value ? flash.value.title : 'Play', ...flash.value ? { avatar: flash.value.user, diff --git a/packages/frontend/src/pages/follow-requests.vue b/packages/frontend/src/pages/follow-requests.vue index 863ae018ba..36643b1acb 100644 --- a/packages/frontend/src/pages/follow-requests.vue +++ b/packages/frontend/src/pages/follow-requests.vue @@ -4,60 +4,57 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> +<PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"> <MkSpacer :contentMax="800"> <MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs"> - <div :key="tab" class="_gaps"> - <MkPagination ref="paginationComponent" :pagination="pagination"> - <template #empty> - <div class="_fullinfo"> - <img :src="infoImageUrl" class="_ghost"/> - <div>{{ i18n.ts.noFollowRequests }}</div> - </div> - </template> - <template #default="{items}"> - <div class="mk-follow-requests _gaps"> - <div v-for="req in items" :key="req.id" class="user _panel"> - <MkAvatar class="avatar" :user="displayUser(req)" indicator link preview/> - <div class="body"> - <div class="name"> - <MkA v-user-preview="displayUser(req).id" class="name" :to="userPage(displayUser(req))"><MkUserName :user="displayUser(req)"/></MkA> - <p class="acct">@{{ acct(displayUser(req)) }}</p> - </div> - <div v-if="tab === 'list'" class="commands"> - <MkButton class="command" rounded primary @click="accept(displayUser(req))"><i class="ti ti-check"/> {{ i18n.ts.accept }}</MkButton> - <MkButton class="command" rounded danger @click="reject(displayUser(req))"><i class="ti ti-x"/> {{ i18n.ts.reject }}</MkButton> - </div> - <div v-else class="commands"> - <MkButton class="command" rounded danger @click="cancel(displayUser(req))"><i class="ti ti-x"/> {{ i18n.ts.cancel }}</MkButton> - </div> + <MkPagination ref="paginationComponent" :pagination="pagination"> + <template #empty> + <div class="_fullinfo"> + <img :src="infoImageUrl" draggable="false"/> + <div>{{ i18n.ts.noFollowRequests }}</div> + </div> + </template> + <template #default="{items}"> + <div class="mk-follow-requests _gaps"> + <div v-for="req in items" :key="req.id" class="user _panel"> + <MkAvatar class="avatar" :user="displayUser(req)" indicator link preview/> + <div class="body"> + <div class="name"> + <MkA v-user-preview="displayUser(req).id" class="name" :to="userPage(displayUser(req))"><MkUserName :user="displayUser(req)"/></MkA> + <p class="acct">@{{ acct(displayUser(req)) }}</p> + </div> + <div v-if="tab === 'list'" class="commands"> + <MkButton class="command" rounded primary @click="accept(displayUser(req))"><i class="ti ti-check"/> {{ i18n.ts.accept }}</MkButton> + <MkButton class="command" rounded danger @click="reject(displayUser(req))"><i class="ti ti-x"/> {{ i18n.ts.reject }}</MkButton> + </div> + <div v-else class="commands"> + <MkButton class="command" rounded danger @click="cancel(displayUser(req))"><i class="ti ti-x"/> {{ i18n.ts.cancel }}</MkButton> </div> </div> </div> - </template> - </MkPagination> - </div> + </div> + </template> + </MkPagination> </MkHorizontalSwipe> </MkSpacer> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> import * as Misskey from 'misskey-js'; -import { shallowRef, computed, ref } from 'vue'; -import MkPagination from '@/components/MkPagination.vue'; +import { useTemplateRef, computed, ref } from 'vue'; import type { Paging } from '@/components/MkPagination.vue'; +import MkPagination from '@/components/MkPagination.vue'; import MkButton from '@/components/MkButton.vue'; import { userPage, acct } from '@/filters/user.js'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; import { infoImageUrl } from '@/instance.js'; -import { $i } from '@/account.js'; +import { $i } from '@/i.js'; import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue'; -const paginationComponent = shallowRef<InstanceType<typeof MkPagination>>(); +const paginationComponent = useTemplateRef('paginationComponent'); const pagination = computed<Paging>(() => tab.value === 'list' ? { endpoint: 'following/requests/list', @@ -105,7 +102,7 @@ const headerTabs = computed(() => [ const tab = ref($i?.isLocked ? 'list' : 'sent'); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.followRequests, icon: 'ti ti-user-plus', })); @@ -146,12 +143,10 @@ definePageMetadata(() => ({ } > .name { - font-size: 16px; line-height: 24px; } > .acct { - font-size: 15px; line-height: 16px; opacity: 0.7; } @@ -164,7 +159,6 @@ definePageMetadata(() => ({ overflow: hidden; text-overflow: ellipsis; opacity: 0.7; - font-size: 14px; padding-right: 40px; padding-left: 8px; box-sizing: border-box; diff --git a/packages/frontend/src/pages/gallery/edit.vue b/packages/frontend/src/pages/gallery/edit.vue index 70f8b2c31d..7831e084a2 100644 --- a/packages/frontend/src/pages/gallery/edit.vue +++ b/packages/frontend/src/pages/gallery/edit.vue @@ -4,8 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> +<PageWithHeader :actions="headerActions" :tabs="headerTabs"> <MkSpacer :contentMax="800" :marginMin="16" :marginMax="32"> <FormSuspense :p="init" class="_gaps"> <MkInput v-model="title"> @@ -34,7 +33,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </FormSuspense> </MkSpacer> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> @@ -45,12 +44,12 @@ import MkInput from '@/components/MkInput.vue'; import MkTextarea from '@/components/MkTextarea.vue'; import MkSwitch from '@/components/MkSwitch.vue'; import FormSuspense from '@/components/form/suspense.vue'; -import { selectFiles } from '@/scripts/select-file.js'; +import { selectFiles } from '@/utility/select-file.js'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { definePage } from '@/page.js'; import { i18n } from '@/i18n.js'; -import { useRouter } from '@/router/supplier.js'; +import { useRouter } from '@/router.js'; const router = useRouter(); @@ -122,7 +121,7 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: props.postId ? i18n.ts.edit : i18n.ts.postToGallery, icon: 'ti ti-pencil', })); diff --git a/packages/frontend/src/pages/gallery/index.vue b/packages/frontend/src/pages/gallery/index.vue index f396fd2c0c..4cf3fca83b 100644 --- a/packages/frontend/src/pages/gallery/index.vue +++ b/packages/frontend/src/pages/gallery/index.vue @@ -4,11 +4,10 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> +<PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"> <MkSpacer :contentMax="1400"> <MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs"> - <div v-if="tab === 'explore'" key="explore"> + <div v-if="tab === 'explore'"> <MkFoldableSection class="_margin"> <template #header><i class="ti ti-clock"></i>{{ i18n.ts.recentPosts }}</template> <MkPagination v-slot="{items}" :pagination="recentPostsPagination" :disableAutoLoad="true"> @@ -26,14 +25,14 @@ SPDX-License-Identifier: AGPL-3.0-only </MkPagination> </MkFoldableSection> </div> - <div v-else-if="tab === 'liked'" key="liked"> + <div v-else-if="tab === 'liked'"> <MkPagination v-slot="{items}" :pagination="likedPostsPagination"> <div :class="$style.items"> <MkGalleryPostPreview v-for="like in items" :key="like.id" :post="like.post" class="post"/> </div> </MkPagination> </div> - <div v-else-if="tab === 'my'" key="my"> + <div v-else-if="tab === 'my'"> <MkA to="/gallery/new" class="_link" style="margin: 16px;"><i class="ti ti-plus"></i> {{ i18n.ts.postToGallery }}</MkA> <MkPagination v-slot="{items}" :pagination="myPostsPagination"> <div :class="$style.items"> @@ -43,7 +42,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </MkHorizontalSwipe> </MkSpacer> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> @@ -52,9 +51,9 @@ import MkFoldableSection from '@/components/MkFoldableSection.vue'; import MkPagination from '@/components/MkPagination.vue'; import MkGalleryPostPreview from '@/components/MkGalleryPostPreview.vue'; import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; import { i18n } from '@/i18n.js'; -import { useRouter } from '@/router/supplier.js'; +import { useRouter } from '@/router.js'; const router = useRouter(); @@ -119,7 +118,7 @@ const headerTabs = computed(() => [{ icon: 'ti ti-edit', }]); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.gallery, icon: 'ti ti-icons', })); diff --git a/packages/frontend/src/pages/gallery/post.vue b/packages/frontend/src/pages/gallery/post.vue index feb4c60611..6b37a0b470 100644 --- a/packages/frontend/src/pages/gallery/post.vue +++ b/packages/frontend/src/pages/gallery/post.vue @@ -4,11 +4,10 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> +<PageWithHeader :actions="headerActions" :tabs="headerTabs"> <MkSpacer :contentMax="1000" :marginMin="16" :marginMax="32"> <div class="_root"> - <Transition :name="defaultStore.state.animation ? 'fade' : ''" mode="out-in"> + <Transition :name="prefer.s.animation ? 'fade' : ''" mode="out-in"> <div v-if="post" class="rkxwuolj"> <div class="files"> <div v-for="file in post.files" :key="file.id" class="file"> @@ -43,7 +42,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkFollowButton v-if="!$i || $i.id != post.user.id" v-model:user="post.user" :inline="true" :transparent="false" :full="true" large class="koudoku"/> </div> </div> - <MkAd :prefer="['horizontal', 'horizontal-big']"/> + <MkAd :preferForms="['horizontal', 'horizontal-big']"/> <MkContainer :max-height="300" :foldable="true" class="other"> <template #icon><i class="ti ti-clock"></i></template> <template #header>{{ i18n.ts.recentPosts }}</template> @@ -59,28 +58,28 @@ SPDX-License-Identifier: AGPL-3.0-only </Transition> </div> </MkSpacer> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> import { computed, watch, ref, defineAsyncComponent } from 'vue'; import * as Misskey from 'misskey-js'; +import { url } from '@@/js/config.js'; +import type { MenuItem } from '@/types/menu.js'; import MkButton from '@/components/MkButton.vue'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import MkContainer from '@/components/MkContainer.vue'; import MkPagination from '@/components/MkPagination.vue'; import MkGalleryPostPreview from '@/components/MkGalleryPostPreview.vue'; import MkFollowButton from '@/components/MkFollowButton.vue'; -import { url } from '@@/js/config.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; -import { defaultStore } from '@/store.js'; -import { $i } from '@/account.js'; -import { isSupportShare } from '@/scripts/navigator.js'; -import { copyToClipboard } from '@/scripts/copy-to-clipboard.js'; -import { useRouter } from '@/router/supplier.js'; -import type { MenuItem } from '@/types/menu.js'; +import { definePage } from '@/page.js'; +import { prefer } from '@/preferences.js'; +import { $i } from '@/i.js'; +import { isSupportShare } from '@/utility/navigator.js'; +import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; +import { useRouter } from '@/router.js'; const router = useRouter(); @@ -111,7 +110,6 @@ function fetchPost() { function copyLink() { copyToClipboard(`${url}/gallery/${post.value.id}`); - os.success(); } function share() { @@ -208,7 +206,7 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: post.value ? post.value.title : i18n.ts.gallery, ...post.value ? { avatar: post.value.user, diff --git a/packages/frontend/src/pages/games.vue b/packages/frontend/src/pages/games.vue index 998b8be0f3..7436c13332 100644 --- a/packages/frontend/src/pages/games.vue +++ b/packages/frontend/src/pages/games.vue @@ -4,8 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader/></template> +<PageWithHeader> <MkSpacer :contentMax="800"> <div class="_gaps"> <div class="_panel" :class="$style.link"> @@ -20,14 +19,14 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </div> </MkSpacer> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; -definePageMetadata(() => ({ +definePage(() => ({ title: 'Misskey Games', icon: 'ti ti-device-gamepad', })); diff --git a/packages/frontend/src/pages/install-extensions.vue b/packages/frontend/src/pages/install-extensions.vue index 58f8b865bb..bf57b0c231 100644 --- a/packages/frontend/src/pages/install-extensions.vue +++ b/packages/frontend/src/pages/install-extensions.vue @@ -4,14 +4,12 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> - <MkSpacer :contentMax="500"> +<PageWithAnimBg> + <MkSpacer :contentMax="550" :marginMax="50"> <MkLoading v-if="uiPhase === 'fetching'"/> - <MkExtensionInstaller v-else-if="uiPhase === 'confirm' && data" :extension="data" @confirm="install()"> + <MkExtensionInstaller v-else-if="uiPhase === 'confirm' && data" :extension="data" @confirm="install()" @cancel="close_()"> <template #additionalInfo> <FormSection> - <template #label>{{ i18n.ts._externalResourceInstaller._vendorInfo.title }}</template> <div class="_gaps_s"> <MkKeyValue> <template #key>{{ i18n.ts._externalResourceInstaller._vendorInfo.endpoint }}</template> @@ -35,31 +33,30 @@ SPDX-License-Identifier: AGPL-3.0-only <h2 :class="$style.extInstallerTitle">{{ errorKV?.title }}</h2> <div :class="$style.extInstallerNormDesc">{{ errorKV?.description }}</div> <div class="_buttonsCenter"> - <MkButton @click="goBack()">{{ i18n.ts.goBack }}</MkButton> - <MkButton @click="goToMisskey()">{{ i18n.ts.goToMisskey }}</MkButton> + <MkButton @click="close_()">{{ i18n.ts.close }}</MkButton> </div> </div> </MkSpacer> -</MkStickyContainer> +</PageWithAnimBg> </template> <script lang="ts" setup> -import { ref, computed, onActivated, onDeactivated, nextTick } from 'vue'; +import { ref, computed, nextTick } from 'vue'; +import type { Extension } from '@/components/MkExtensionInstaller.vue'; +import type { AiScriptPluginMeta } from '@/plugin.js'; import MkLoading from '@/components/global/MkLoading.vue'; import MkExtensionInstaller from '@/components/MkExtensionInstaller.vue'; -import type { Extension } from '@/components/MkExtensionInstaller.vue'; import MkButton from '@/components/MkButton.vue'; import MkKeyValue from '@/components/MkKeyValue.vue'; import MkUrl from '@/components/global/MkUrl.vue'; import FormSection from '@/components/form/section.vue'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { parsePluginMeta, installPlugin } from '@/scripts/install-plugin.js'; -import type { AiScriptPluginMeta } from '@/scripts/install-plugin.js'; -import { parseThemeCode, installTheme } from '@/scripts/install-theme.js'; -import { unisonReload } from '@/scripts/unison-reload.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { parsePluginMeta, installPlugin } from '@/plugin.js'; +import { parseThemeCode, installTheme } from '@/theme.js'; +import { unisonReload } from '@/utility/unison-reload.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; const uiPhase = ref<'fetching' | 'confirm' | 'error'>('fetching'); const errorKV = ref<{ @@ -75,12 +72,12 @@ const hash = ref<string | null>(null); const data = ref<Extension | null>(null); -function goBack(): void { - history.back(); -} - -function goToMisskey(): void { - location.href = '/'; +function close_(): void { + if (window.history.length === 1) { + window.close(); + } else { + window.history.back(); + } } async function fetch() { @@ -207,9 +204,9 @@ async function install() { try { await installPlugin(data.value.raw, data.value.meta as AiScriptPluginMeta); os.success(); - nextTick(() => { - unisonReload('/'); - }); + window.setTimeout(() => { + close_(); + }, 3000); } catch (err) { errorKV.value = { title: i18n.ts._externalResourceInstaller._errors._pluginInstallFailed.title, @@ -223,28 +220,18 @@ async function install() { if (!data.value.meta) return; await installTheme(data.value.raw); os.success(); - nextTick(() => { - location.href = '/settings/theme'; - }); + window.setTimeout(() => { + close_(); + }, 3000); } } -onActivated(() => { - const urlParams = new URLSearchParams(window.location.search); - url.value = urlParams.get('url'); - hash.value = urlParams.get('hash'); - fetch(); -}); - -onDeactivated(() => { - uiPhase.value = 'fetching'; -}); - -const headerActions = computed(() => []); - -const headerTabs = computed(() => []); +const urlParams = new URLSearchParams(window.location.search); +url.value = urlParams.get('url'); +hash.value = urlParams.get('hash'); +fetch(); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts._externalResourceInstaller.title, icon: 'ti ti-download', })); diff --git a/packages/frontend/src/pages/instance-info.vue b/packages/frontend/src/pages/instance-info.vue index 35d1c7dcd1..66ddf627e4 100644 --- a/packages/frontend/src/pages/instance-info.vue +++ b/packages/frontend/src/pages/instance-info.vue @@ -4,11 +4,10 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> +<PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"> <MkSpacer v-if="instance" :contentMax="600" :marginMin="16" :marginMax="32"> <MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs"> - <div v-if="tab === 'overview'" key="overview" class="_gaps_m"> + <div v-if="tab === 'overview'" class="_gaps_m"> <div class="fnfelxur"> <img :src="faviconUrl" alt="" class="icon"/> <span class="name">{{ instance.name || `(${i18n.ts.unknown})` }}</span> @@ -91,7 +90,7 @@ SPDX-License-Identifier: AGPL-3.0-only <FormLink :to="`https://${host}/manifest.json`" external style="margin-bottom: 8px;">manifest.json</FormLink> </FormSection> </div> - <div v-else-if="tab === 'chart'" key="chart" class="_gaps_m"> + <div v-else-if="tab === 'chart'" class="_gaps_m"> <div class="cmhjzshl"> <div class="selects"> <MkSelect v-model="chartSrc" style="margin: 0 10px 0 0; flex: 1;"> @@ -116,27 +115,28 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </div> </div> - <div v-else-if="tab === 'users'" key="users" class="_gaps_m"> + <div v-else-if="tab === 'users'" class="_gaps_m"> <MkPagination v-slot="{items}" :pagination="usersPagination" style="display: grid; grid-template-columns: repeat(auto-fill,minmax(270px,1fr)); grid-gap: 12px;"> <MkA v-for="user in items" :key="user.id" v-tooltip.mfm="`Last posted: ${dateString(user.updatedAt)}`" class="user" :to="`/admin/user/${user.id}`"> <MkUserCardMini :user="user"/> </MkA> </MkPagination> </div> - <div v-else-if="tab === 'raw'" key="raw" class="_gaps_m"> + <div v-else-if="tab === 'raw'" class="_gaps_m"> <MkObjectView tall :value="instance"> </MkObjectView> </div> </MkHorizontalSwipe> </MkSpacer> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> import { ref, computed, watch } from 'vue'; import * as Misskey from 'misskey-js'; -import MkChart from '@/components/MkChart.vue'; import type { ChartSrc } from '@/components/MkChart.vue'; +import type { Paging } from '@/components/MkPagination.vue'; +import MkChart from '@/components/MkChart.vue'; import MkObjectView from '@/components/MkObjectView.vue'; import FormLink from '@/components/form/link.vue'; import MkLink from '@/components/MkLink.vue'; @@ -146,16 +146,15 @@ import MkKeyValue from '@/components/MkKeyValue.vue'; import MkSelect from '@/components/MkSelect.vue'; import MkSwitch from '@/components/MkSwitch.vue'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import number from '@/filters/number.js'; -import { iAmModerator, iAmAdmin } from '@/account.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { iAmModerator, iAmAdmin } from '@/i.js'; +import { definePage } from '@/page.js'; import { i18n } from '@/i18n.js'; import MkUserCardMini from '@/components/MkUserCardMini.vue'; import MkPagination from '@/components/MkPagination.vue'; -import type { Paging } from '@/components/MkPagination.vue'; import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue'; -import { getProxiedImageUrlNullable } from '@/scripts/media-proxy.js'; +import { getProxiedImageUrlNullable } from '@/utility/media-proxy.js'; import { dateString } from '@/filters/date.js'; import MkTextarea from '@/components/MkTextarea.vue'; @@ -299,7 +298,7 @@ const headerTabs = computed(() => [{ icon: 'ti ti-code', }]); -definePageMetadata(() => ({ +definePage(() => ({ title: props.host, icon: 'ti ti-server', })); diff --git a/packages/frontend/src/pages/invite.vue b/packages/frontend/src/pages/invite.vue index 49c0d931c5..f6df0ffab2 100644 --- a/packages/frontend/src/pages/invite.vue +++ b/packages/frontend/src/pages/invite.vue @@ -4,11 +4,10 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader/></template> +<PageWithHeader> <MkSpacer v-if="!instance.disableRegistration || !($i && ($i.isAdmin || $i.policies.canInvite))" :contentMax="1200"> <div :class="$style.root"> - <img :class="$style.img" :src="serverErrorImageUrl" class="_ghost"/> + <img :class="$style.img" :src="serverErrorImageUrl" draggable="false"/> <div :class="$style.text"> <i class="ti ti-alert-triangle"></i> {{ i18n.ts.nothing }} @@ -30,24 +29,24 @@ SPDX-License-Identifier: AGPL-3.0-only </MkPagination> </div> </MkSpacer> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> -import { computed, ref, shallowRef } from 'vue'; +import { computed, ref, useTemplateRef } from 'vue'; import * as Misskey from 'misskey-js'; +import type { Paging } from '@/components/MkPagination.vue'; import { i18n } from '@/i18n.js'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import MkButton from '@/components/MkButton.vue'; import MkPagination from '@/components/MkPagination.vue'; -import type { Paging } from '@/components/MkPagination.vue'; import MkInviteCode from '@/components/MkInviteCode.vue'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; import { serverErrorImageUrl, instance } from '@/instance.js'; -import { $i } from '@/account.js'; +import { $i } from '@/i.js'; -const pagingComponent = shallowRef<InstanceType<typeof MkPagination>>(); +const pagingComponent = useTemplateRef('pagingComponent'); const currentInviteLimit = ref<null | number>(null); const inviteLimit = (($i != null && $i.policies.inviteLimit) || (($i == null && instance.policies.inviteLimit))) as number; const inviteLimitCycle = (($i != null && $i.policies.inviteLimitCycle) || ($i == null && instance.policies.inviteLimitCycle)) as number; @@ -92,7 +91,7 @@ async function update() { update(); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.invite, icon: 'ti ti-user-plus', })); diff --git a/packages/frontend/src/pages/list.vue b/packages/frontend/src/pages/list.vue index 0ff1854154..d8d006776d 100644 --- a/packages/frontend/src/pages/list.vue +++ b/packages/frontend/src/pages/list.vue @@ -4,18 +4,17 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> +<PageWithHeader :actions="headerActions" :tabs="headerTabs"> <MkSpacer v-if="error != null" :contentMax="1200"> <div :class="$style.root"> - <img :class="$style.img" :src="serverErrorImageUrl" class="_ghost"/> + <img :class="$style.img" :src="serverErrorImageUrl" draggable="false"/> <p :class="$style.text"> <i class="ti ti-alert-triangle"></i> {{ i18n.ts.nothing }} </p> </div> </MkSpacer> - <MkSpacer v-else-if="list" :contentMax="700" :class="$style.main"> + <MkSpacer v-else-if="list" :contentMax="700"> <div v-if="list" class="members _margin"> <div :class="$style.member_text">{{ i18n.ts.members }}</div> <div class="_gaps_s"> @@ -30,19 +29,19 @@ SPDX-License-Identifier: AGPL-3.0-only <MkButton v-if="!list.isLiked" v-tooltip="i18n.ts.like" inline :class="$style.button" asLike @click="like()"><i class="ti ti-heart"></i><span v-if="1 > 0" class="count">{{ list.likedCount }}</span></MkButton> <MkButton inline @click="create()"><i class="ti ti-download" :class="$style.import"></i>{{ i18n.ts.import }}</MkButton> </MkSpacer> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> import { watch, computed, ref } from 'vue'; import * as Misskey from 'misskey-js'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { userPage } from '@/filters/user.js'; import { i18n } from '@/i18n.js'; import MkUserCardMini from '@/components/MkUserCardMini.vue'; import MkButton from '@/components/MkButton.vue'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; import { serverErrorImageUrl } from '@/instance.js'; const props = defineProps<{ @@ -101,16 +100,12 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: list.value ? list.value.name : i18n.ts.lists, icon: 'ti ti-list', })); </script> <style lang="scss" module> -.main { - min-height: calc(100cqh - (var(--MI-stickyTop, 0px) + var(--MI-stickyBottom, 0px))); -} - .userItem { display: flex; } diff --git a/packages/frontend/src/pages/lookup.vue b/packages/frontend/src/pages/lookup.vue index 6f10c69640..623c2a6779 100644 --- a/packages/frontend/src/pages/lookup.vue +++ b/packages/frontend/src/pages/lookup.vue @@ -4,8 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> +<PageWithHeader :actions="headerActions" :tabs="headerTabs"> <MkSpacer :contentMax="800"> <div v-if="state === 'done'" class="_buttonsCenter"> <MkButton @click="close">{{ i18n.ts.close }}</MkButton> @@ -15,23 +14,23 @@ SPDX-License-Identifier: AGPL-3.0-only <MkLoading/> </div> </MkSpacer> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> import { computed, ref } from 'vue'; import * as Misskey from 'misskey-js'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; -import { mainRouter } from '@/router/main.js'; +import { definePage } from '@/page.js'; +import { mainRouter } from '@/router.js'; import MkButton from '@/components/MkButton.vue'; const state = ref<'fetching' | 'done'>('fetching'); function fetch() { - const params = new URL(location.href).searchParams; + const params = new URL(window.location.href).searchParams; // acctのほうはdeprecated let uri = params.get('uri') ?? params.get('acct'); @@ -76,12 +75,12 @@ function close(): void { // 閉じなければ100ms後タイムラインに window.setTimeout(() => { - location.href = '/'; + window.location.href = '/'; }, 100); } function goToMisskey(): void { - location.href = '/'; + window.location.href = '/'; } fetch(); @@ -90,7 +89,7 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata({ +definePage({ title: i18n.ts.lookup, icon: 'ti ti-world-search', }); diff --git a/packages/frontend/src/pages/miauth.vue b/packages/frontend/src/pages/miauth.vue index ab060587c5..d4296d428b 100644 --- a/packages/frontend/src/pages/miauth.vue +++ b/packages/frontend/src/pages/miauth.vue @@ -4,8 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div> - <MkAnimBg style="position: fixed; top: 0;"/> +<PageWithAnimBg> <div :class="$style.formContainer"> <div :class="$style.form"> <MkAuthConfirm @@ -25,19 +24,16 @@ SPDX-License-Identifier: AGPL-3.0-only </MkAuthConfirm> </div> </div> -</div> +</PageWithAnimBg> </template> <script lang="ts" setup> import { computed, useTemplateRef } from 'vue'; import * as Misskey from 'misskey-js'; - -import MkAnimBg from '@/components/MkAnimBg.vue'; import MkAuthConfirm from '@/components/MkAuthConfirm.vue'; - import { i18n } from '@/i18n.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { definePage } from '@/page.js'; const props = defineProps<{ session: string; @@ -64,7 +60,7 @@ async function onAccept(token: string) { const cbUrl = new URL(props.callback); if (['javascript:', 'file:', 'data:', 'mailto:', 'tel:', 'vbscript:'].includes(cbUrl.protocol)) throw new Error('invalid url'); cbUrl.searchParams.set('session', props.session); - location.href = cbUrl.toString(); + window.location.href = cbUrl.toString(); } else { authRoot.value?.showUI('success'); } @@ -77,7 +73,7 @@ function onDeny() { authRoot.value?.showUI('denied'); } -definePageMetadata(() => ({ +definePage(() => ({ title: 'MiAuth', icon: 'ti ti-apps', })); diff --git a/packages/frontend/src/pages/my-antennas/create.vue b/packages/frontend/src/pages/my-antennas/create.vue index 2b8518747f..8f331f1333 100644 --- a/packages/frontend/src/pages/my-antennas/create.vue +++ b/packages/frontend/src/pages/my-antennas/create.vue @@ -4,19 +4,17 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> - +<PageWithHeader :actions="headerActions" :tabs="headerTabs"> <MkAntennaEditor @created="onAntennaCreated"/> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> import { computed } from 'vue'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; import { antennasCache } from '@/cache.js'; -import { useRouter } from '@/router/supplier.js'; +import { useRouter } from '@/router.js'; import MkAntennaEditor from '@/components/MkAntennaEditor.vue'; const router = useRouter(); @@ -29,7 +27,7 @@ function onAntennaCreated() { const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.createAntenna, icon: 'ti ti-antenna', })); diff --git a/packages/frontend/src/pages/my-antennas/edit.vue b/packages/frontend/src/pages/my-antennas/edit.vue index 9f927cd1a0..f449e83c1f 100644 --- a/packages/frontend/src/pages/my-antennas/edit.vue +++ b/packages/frontend/src/pages/my-antennas/edit.vue @@ -4,22 +4,20 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> - +<PageWithHeader :actions="headerActions" :tabs="headerTabs"> <MkAntennaEditor v-if="antenna" :antenna="antenna" @updated="onAntennaUpdated"/> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> import { ref, computed } from 'vue'; import * as Misskey from 'misskey-js'; import MkAntennaEditor from '@/components/MkAntennaEditor.vue'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; import { antennasCache } from '@/cache.js'; -import { useRouter } from '@/router/supplier.js'; +import { useRouter } from '@/router.js'; const router = useRouter(); @@ -41,7 +39,7 @@ misskeyApi('antennas/show', { antennaId: props.antennaId }).then((antennaRespons const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.editAntenna, icon: 'ti ti-antenna', })); diff --git a/packages/frontend/src/pages/my-antennas/index.vue b/packages/frontend/src/pages/my-antennas/index.vue index f387740728..38f6071cec 100644 --- a/packages/frontend/src/pages/my-antennas/index.vue +++ b/packages/frontend/src/pages/my-antennas/index.vue @@ -4,13 +4,12 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> +<PageWithHeader :actions="headerActions" :tabs="headerTabs"> <MkSpacer :contentMax="700"> <div> <div v-if="antennas.length === 0" class="empty"> <div class="_fullinfo"> - <img :src="infoImageUrl" class="_ghost"/> + <img :src="infoImageUrl" draggable="false"/> <div>{{ i18n.ts.nothing }}</div> </div> </div> @@ -24,14 +23,14 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </div> </MkSpacer> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> import { onActivated, computed } from 'vue'; import MkButton from '@/components/MkButton.vue'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; import { antennasCache } from '@/cache.js'; import { infoImageUrl } from '@/instance.js'; @@ -55,7 +54,7 @@ const headerActions = computed(() => [{ const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.manageAntennas, icon: 'ti ti-antenna', })); diff --git a/packages/frontend/src/pages/my-clips/index.vue b/packages/frontend/src/pages/my-clips/index.vue index acf37a9a2f..1525bbef9b 100644 --- a/packages/frontend/src/pages/my-clips/index.vue +++ b/packages/frontend/src/pages/my-clips/index.vue @@ -4,35 +4,34 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> +<PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"> <MkSpacer :contentMax="700"> <MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs"> - <div v-if="tab === 'my'" key="my" class="_gaps"> + <div v-if="tab === 'my'" class="_gaps"> <MkButton primary rounded class="add" @click="create"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton> <MkPagination v-slot="{ items }" ref="pagingComponent" :pagination="pagination" class="_gaps"> <MkClipPreview v-for="item in items" :key="item.id" :clip="item" :noUserInfo="true"/> </MkPagination> </div> - <div v-else-if="tab === 'favorites'" key="favorites" class="_gaps"> + <div v-else-if="tab === 'favorites'" class="_gaps"> <MkClipPreview v-for="item in favorites" :key="item.id" :clip="item"/> </div> </MkHorizontalSwipe> </MkSpacer> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> -import { watch, ref, shallowRef, computed } from 'vue'; +import { watch, ref, useTemplateRef, computed } from 'vue'; import * as Misskey from 'misskey-js'; import MkPagination from '@/components/MkPagination.vue'; import MkButton from '@/components/MkButton.vue'; import MkClipPreview from '@/components/MkClipPreview.vue'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; import { clipsCache } from '@/cache.js'; import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue'; @@ -46,7 +45,7 @@ const tab = ref('my'); const favorites = ref<Misskey.entities.Clip[] | null>(null); -const pagingComponent = shallowRef<InstanceType<typeof MkPagination>>(); +const pagingComponent = useTemplateRef('pagingComponent'); watch(tab, async () => { favorites.value = await misskeyApi('clips/my-favorites'); @@ -100,7 +99,7 @@ const headerTabs = computed(() => [{ icon: 'ti ti-heart', }]); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.clip, icon: 'ti ti-paperclip', })); diff --git a/packages/frontend/src/pages/my-lists/index.vue b/packages/frontend/src/pages/my-lists/index.vue index 6cbcca73c2..c9660a11d3 100644 --- a/packages/frontend/src/pages/my-lists/index.vue +++ b/packages/frontend/src/pages/my-lists/index.vue @@ -4,13 +4,12 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> +<PageWithHeader :actions="headerActions" :tabs="headerTabs"> <MkSpacer :contentMax="700"> <div class="_gaps"> <div v-if="items.length === 0" class="empty"> <div class="_fullinfo"> - <img :src="infoImageUrl" class="_ghost"/> + <img :src="infoImageUrl" draggable="false"/> <div>{{ i18n.ts.nothing }}</div> </div> </div> @@ -25,7 +24,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </div> </MkSpacer> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> @@ -34,12 +33,12 @@ import MkButton from '@/components/MkButton.vue'; import MkAvatars from '@/components/MkAvatars.vue'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; import { userListsCache } from '@/cache.js'; import { infoImageUrl } from '@/instance.js'; -import { signinRequired } from '@/account.js'; +import { ensureSignin } from '@/i.js'; -const $i = signinRequired(); +const $i = ensureSignin(); const items = computed(() => userListsCache.value.value ?? []); @@ -71,7 +70,7 @@ const headerActions = computed(() => [{ const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.manageLists, icon: 'ti ti-list', })); diff --git a/packages/frontend/src/pages/my-lists/list.vue b/packages/frontend/src/pages/my-lists/list.vue index 69e404bd85..c187435af9 100644 --- a/packages/frontend/src/pages/my-lists/list.vue +++ b/packages/frontend/src/pages/my-lists/list.vue @@ -4,9 +4,8 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> - <MkSpacer :contentMax="700" :class="$style.main"> +<PageWithHeader :actions="headerActions" :tabs="headerTabs"> + <MkSpacer :contentMax="700"> <div v-if="list" class="_gaps"> <MkFolder> <template #label>{{ i18n.ts.settings }}</template> @@ -49,7 +48,7 @@ SPDX-License-Identifier: AGPL-3.0-only </MkFolder> </div> </MkSpacer> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> @@ -57,8 +56,8 @@ import { computed, ref, watch } from 'vue'; import * as Misskey from 'misskey-js'; import MkButton from '@/components/MkButton.vue'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { definePage } from '@/page.js'; import { i18n } from '@/i18n.js'; import { userPage } from '@/filters/user.js'; import MkUserCardMini from '@/components/MkUserCardMini.vue'; @@ -66,16 +65,16 @@ import MkSwitch from '@/components/MkSwitch.vue'; import MkFolder from '@/components/MkFolder.vue'; import MkInput from '@/components/MkInput.vue'; import { userListsCache } from '@/cache.js'; -import { signinRequired } from '@/account.js'; -import { defaultStore } from '@/store.js'; +import { ensureSignin } from '@/i.js'; import MkPagination from '@/components/MkPagination.vue'; -import { mainRouter } from '@/router/main.js'; +import { mainRouter } from '@/router.js'; +import { prefer } from '@/preferences.js'; -const $i = signinRequired(); +const $i = ensureSignin(); const { enableInfiniteScroll, -} = defaultStore.reactiveState; +} = prefer.r; const props = defineProps<{ listId: string; @@ -191,17 +190,13 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: list.value ? list.value.name : i18n.ts.lists, icon: 'ti ti-list', })); </script> <style lang="scss" module> -.main { - min-height: calc(100cqh - (var(--MI-stickyTop, 0px) + var(--MI-stickyBottom, 0px))); -} - .userItem { display: flex; } diff --git a/packages/frontend/src/pages/not-found.vue b/packages/frontend/src/pages/not-found.vue index 6a2d01b6fa..684a3bb5bd 100644 --- a/packages/frontend/src/pages/not-found.vue +++ b/packages/frontend/src/pages/not-found.vue @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div> <div class="_fullinfo"> - <img :src="notFoundImageUrl" class="_ghost"/> + <img :src="notFoundImageUrl" draggable="false"/> <div>{{ i18n.ts.notFoundDescription }}</div> </div> </div> @@ -15,8 +15,8 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { computed } from 'vue'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; -import { pleaseLogin } from '@/scripts/please-login.js'; +import { definePage } from '@/page.js'; +import { pleaseLogin } from '@/utility/please-login.js'; import { notFoundImageUrl } from '@/instance.js'; const props = defineProps<{ @@ -31,7 +31,7 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.notFound, icon: 'ti ti-alert-triangle', })); diff --git a/packages/frontend/src/pages/note.vue b/packages/frontend/src/pages/note.vue index 0791c1343b..a685dec200 100644 --- a/packages/frontend/src/pages/note.vue +++ b/packages/frontend/src/pages/note.vue @@ -4,11 +4,10 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> +<PageWithHeader :actions="headerActions" :tabs="headerTabs"> <MkSpacer :contentMax="800"> <div> - <Transition :name="defaultStore.state.animation ? 'fade' : ''" mode="out-in"> + <Transition :name="prefer.s.animation ? 'fade' : ''" mode="out-in"> <div v-if="note"> <div v-if="showNext" class="_margin"> <MkNotes class="" :pagination="showNext === 'channel' ? nextChannelPagination : nextUserPagination" :noGap="true" :disableAutoLoad="true"/> @@ -44,7 +43,7 @@ SPDX-License-Identifier: AGPL-3.0-only </Transition> </div> </MkSpacer> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> @@ -56,16 +55,16 @@ import MkNoteDetailed from '@/components/MkNoteDetailed.vue'; import MkNotes from '@/components/MkNotes.vue'; import MkRemoteCaution from '@/components/MkRemoteCaution.vue'; import MkButton from '@/components/MkButton.vue'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { definePage } from '@/page.js'; import { i18n } from '@/i18n.js'; import { dateString } from '@/filters/date.js'; import MkClipPreview from '@/components/MkClipPreview.vue'; -import { defaultStore } from '@/store.js'; -import { pleaseLogin } from '@/scripts/please-login.js'; -import { getAppearNote } from '@/scripts/get-appear-note.js'; +import { prefer } from '@/preferences.js'; +import { pleaseLogin } from '@/utility/please-login.js'; +import { getAppearNote } from '@/utility/get-appear-note.js'; import { serverContext, assertServerContext } from '@/server-context.js'; -import { $i } from '@/account.js'; +import { $i } from '@/i.js'; // contextは非ログイン状態の情報しかないためログイン時は利用できない const CTX_NOTE = !$i && assertServerContext(serverContext, 'note') ? serverContext.note : null; @@ -165,7 +164,7 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.note, ...note.value ? { subtitle: dateString(note.value.createdAt), diff --git a/packages/frontend/src/pages/notifications.vue b/packages/frontend/src/pages/notifications.vue index 46ee501c76..0a2bc02de5 100644 --- a/packages/frontend/src/pages/notifications.vue +++ b/packages/frontend/src/pages/notifications.vue @@ -4,33 +4,30 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> +<PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"> <MkSpacer :contentMax="800"> - <MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs"> - <div v-if="tab === 'all'" key="all"> - <XNotifications :class="$style.notifications" :excludeTypes="excludeTypes"/> - </div> - <div v-else-if="tab === 'mentions'" key="mention"> - <MkNotes :pagination="mentionsPagination"/> - </div> - <div v-else-if="tab === 'directNotes'" key="directNotes"> - <MkNotes :pagination="directNotesPagination"/> - </div> - </MkHorizontalSwipe> + <div v-if="tab === 'all'"> + <XNotifications :class="$style.notifications" :excludeTypes="excludeTypes"/> + </div> + <div v-else-if="tab === 'mentions'"> + <MkNotes :pagination="mentionsPagination"/> + </div> + <div v-else-if="tab === 'directNotes'"> + <MkNotes :pagination="directNotesPagination"/> + </div> </MkSpacer> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> import { computed, ref } from 'vue'; +import { notificationTypes } from '@@/js/const.js'; import XNotifications from '@/components/MkNotifications.vue'; import MkNotes from '@/components/MkNotes.vue'; import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; -import { notificationTypes } from '@@/js/const.js'; +import { definePage } from '@/page.js'; const tab = ref('all'); const includeTypes = ref<string[] | null>(null); @@ -94,7 +91,7 @@ const headerTabs = computed(() => [{ icon: 'ti ti-mail', }]); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.notifications, icon: 'ti ti-bell', })); diff --git a/packages/frontend/src/pages/oauth.vue b/packages/frontend/src/pages/oauth.vue index 860c884d13..49fdd25ff3 100644 --- a/packages/frontend/src/pages/oauth.vue +++ b/packages/frontend/src/pages/oauth.vue @@ -4,8 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div> - <MkAnimBg style="position: fixed; top: 0;"/> +<PageWithAnimBg> <div :class="$style.formContainer"> <div :class="$style.form"> <MkAuthConfirm @@ -19,51 +18,50 @@ SPDX-License-Identifier: AGPL-3.0-only /> </div> </div> -</div> +</PageWithAnimBg> </template> <script lang="ts" setup> import * as Misskey from 'misskey-js'; -import MkAnimBg from '@/components/MkAnimBg.vue'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; import MkAuthConfirm from '@/components/MkAuthConfirm.vue'; -const transactionIdMeta = document.querySelector<HTMLMetaElement>('meta[name="misskey:oauth:transaction-id"]'); +const transactionIdMeta = window.document.querySelector<HTMLMetaElement>('meta[name="misskey:oauth:transaction-id"]'); if (transactionIdMeta) { transactionIdMeta.remove(); } -const name = document.querySelector<HTMLMetaElement>('meta[name="misskey:oauth:client-name"]')?.content; -const logo = document.querySelector<HTMLMetaElement>('meta[name="misskey:oauth:client-logo"]')?.content; -const permissions = document.querySelector<HTMLMetaElement>('meta[name="misskey:oauth:scope"]')?.content.split(' ').filter((p): p is typeof Misskey.permissions[number] => (Misskey.permissions as readonly string[]).includes(p)) ?? []; +const name = window.document.querySelector<HTMLMetaElement>('meta[name="misskey:oauth:client-name"]')?.content; +const logo = window.document.querySelector<HTMLMetaElement>('meta[name="misskey:oauth:client-logo"]')?.content; +const permissions = window.document.querySelector<HTMLMetaElement>('meta[name="misskey:oauth:scope"]')?.content.split(' ').filter((p): p is typeof Misskey.permissions[number] => (Misskey.permissions as readonly string[]).includes(p)) ?? []; function doPost(token: string, decision: 'accept' | 'deny') { - const form = document.createElement('form'); + const form = window.document.createElement('form'); form.action = '/oauth/decision'; form.method = 'post'; form.acceptCharset = 'utf-8'; - const loginToken = document.createElement('input'); + const loginToken = window.document.createElement('input'); loginToken.type = 'hidden'; loginToken.name = 'login_token'; loginToken.value = token; form.appendChild(loginToken); - const transactionId = document.createElement('input'); + const transactionId = window.document.createElement('input'); transactionId.type = 'hidden'; transactionId.name = 'transaction_id'; transactionId.value = transactionIdMeta?.content ?? ''; form.appendChild(transactionId); if (decision === 'deny') { - const cancel = document.createElement('input'); + const cancel = window.document.createElement('input'); cancel.type = 'hidden'; cancel.name = 'cancel'; cancel.value = 'cancel'; form.appendChild(cancel); } - document.body.appendChild(form); + window.document.body.appendChild(form); form.submit(); } @@ -75,7 +73,7 @@ function onDeny(token: string) { doPost(token, 'deny'); } -definePageMetadata(() => ({ +definePage(() => ({ title: 'OAuth', icon: 'ti ti-apps', })); diff --git a/packages/frontend/src/pages/page-editor/els/page-editor.el.image.vue b/packages/frontend/src/pages/page-editor/els/page-editor.el.image.vue index c3ad6657b0..1b98425719 100644 --- a/packages/frontend/src/pages/page-editor/els/page-editor.el.image.vue +++ b/packages/frontend/src/pages/page-editor/els/page-editor.el.image.vue @@ -26,7 +26,7 @@ import * as Misskey from 'misskey-js'; import XContainer from '../page-editor.container.vue'; import MkDriveFileThumbnail from '@/components/MkDriveFileThumbnail.vue'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; const props = defineProps<{ diff --git a/packages/frontend/src/pages/page-editor/els/page-editor.el.note.vue b/packages/frontend/src/pages/page-editor/els/page-editor.el.note.vue index 36e03b4790..f275ec9517 100644 --- a/packages/frontend/src/pages/page-editor/els/page-editor.el.note.vue +++ b/packages/frontend/src/pages/page-editor/els/page-editor.el.note.vue @@ -30,7 +30,7 @@ import MkInput from '@/components/MkInput.vue'; import MkSwitch from '@/components/MkSwitch.vue'; import MkNote from '@/components/MkNote.vue'; import MkNoteDetailed from '@/components/MkNoteDetailed.vue'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; const props = defineProps<{ diff --git a/packages/frontend/src/pages/page-editor/els/page-editor.el.section.vue b/packages/frontend/src/pages/page-editor/els/page-editor.el.section.vue index 3fed07f7e8..4d1a3716e7 100644 --- a/packages/frontend/src/pages/page-editor/els/page-editor.el.section.vue +++ b/packages/frontend/src/pages/page-editor/els/page-editor.el.section.vue @@ -21,14 +21,14 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> - + import { defineAsyncComponent, inject, onMounted, watch, ref } from 'vue'; import * as Misskey from 'misskey-js'; import { v4 as uuid } from 'uuid'; import XContainer from '../page-editor.container.vue'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; -import { deepClone } from '@/scripts/clone.js'; +import { deepClone } from '@/utility/clone.js'; import MkButton from '@/components/MkButton.vue'; import { getPageBlockList } from '@/pages/page-editor/common.js'; diff --git a/packages/frontend/src/pages/page-editor/els/page-editor.el.text.vue b/packages/frontend/src/pages/page-editor/els/page-editor.el.text.vue index 5795b46c00..4a980ce472 100644 --- a/packages/frontend/src/pages/page-editor/els/page-editor.el.text.vue +++ b/packages/frontend/src/pages/page-editor/els/page-editor.el.text.vue @@ -15,12 +15,11 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> - -import { watch, ref, shallowRef, onMounted, onUnmounted } from 'vue'; +import { watch, ref, useTemplateRef, onMounted, onUnmounted } from 'vue'; import * as Misskey from 'misskey-js'; import XContainer from '../page-editor.container.vue'; import { i18n } from '@/i18n.js'; -import { Autocomplete } from '@/scripts/autocomplete.js'; +import { Autocomplete } from '@/utility/autocomplete.js'; const props = defineProps<{ modelValue: Misskey.entities.PageBlock & { type: 'text' } @@ -33,7 +32,7 @@ const emit = defineEmits<{ let autocomplete: Autocomplete; const text = ref(props.modelValue.text ?? ''); -const inputEl = shallowRef<HTMLTextAreaElement | null>(null); +const inputEl = useTemplateRef('inputEl'); watch(text, () => { emit('update:modelValue', { diff --git a/packages/frontend/src/pages/page-editor/page-editor.vue b/packages/frontend/src/pages/page-editor/page-editor.vue index c08cfebab3..355a8a65da 100644 --- a/packages/frontend/src/pages/page-editor/page-editor.vue +++ b/packages/frontend/src/pages/page-editor/page-editor.vue @@ -4,8 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> +<PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"> <MkSpacer :contentMax="700"> <div class="jqqmcavi"> <MkButton v-if="pageId" class="button" inline link :to="`/@${ author.username }/pages/${ currentName }`"><i class="ti ti-external-link"></i> {{ i18n.ts._pages.viewPage }}</MkButton> @@ -57,26 +56,26 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </div> </MkSpacer> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> import { computed, provide, watch, ref } from 'vue'; import * as Misskey from 'misskey-js'; import { v4 as uuid } from 'uuid'; +import { url } from '@@/js/config.js'; import XBlocks from './page-editor.blocks.vue'; import MkButton from '@/components/MkButton.vue'; import MkSelect from '@/components/MkSelect.vue'; import MkSwitch from '@/components/MkSwitch.vue'; import MkInput from '@/components/MkInput.vue'; -import { url } from '@@/js/config.js'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { selectFile } from '@/scripts/select-file.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { selectFile } from '@/utility/select-file.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; -import { $i } from '@/account.js'; -import { mainRouter } from '@/router/main.js'; +import { definePage } from '@/page.js'; +import { $i } from '@/i.js'; +import { mainRouter } from '@/router.js'; import { getPageBlockList } from '@/pages/page-editor/common.js'; const props = defineProps<{ @@ -264,10 +263,10 @@ const headerTabs = computed(() => [{ icon: 'ti ti-note', }]); -definePageMetadata(() => ({ +definePage(() => ({ title: props.initPageId ? i18n.ts._pages.editPage - : props.initPageName && props.initUser ? i18n.ts._pages.readPage - : i18n.ts._pages.newPage, + : props.initPageName && props.initUser ? i18n.ts._pages.readPage + : i18n.ts._pages.newPage, icon: 'ti ti-pencil', })); </script> diff --git a/packages/frontend/src/pages/page.vue b/packages/frontend/src/pages/page.vue index d9ad7babb7..6c2eced4e6 100644 --- a/packages/frontend/src/pages/page.vue +++ b/packages/frontend/src/pages/page.vue @@ -4,14 +4,13 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> +<PageWithHeader :actions="headerActions" :tabs="headerTabs"> <MkSpacer :contentMax="800"> <Transition - :enterActiveClass="defaultStore.state.animation ? $style.fadeEnterActive : ''" - :leaveActiveClass="defaultStore.state.animation ? $style.fadeLeaveActive : ''" - :enterFromClass="defaultStore.state.animation ? $style.fadeEnterFrom : ''" - :leaveToClass="defaultStore.state.animation ? $style.fadeLeaveTo : ''" + :enterActiveClass="prefer.s.animation ? $style.fadeEnterActive : ''" + :leaveActiveClass="prefer.s.animation ? $style.fadeLeaveActive : ''" + :enterFromClass="prefer.s.animation ? $style.fadeEnterFrom : ''" + :leaveToClass="prefer.s.animation ? $style.fadeLeaveTo : ''" mode="out-in" > <div v-if="page" :key="page.id" class="_gaps"> @@ -81,7 +80,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div v-if="page.createdAt != page.updatedAt"><i class="ti ti-clock-edit"></i> {{ i18n.ts.updatedAt }}: <MkTime :time="page.updatedAt" mode="detail"/></div> </div> </div> - <MkAd :prefer="['horizontal', 'horizontal-big']"/> + <MkAd :preferForms="['horizontal', 'horizontal-big']"/> <MkContainer :max-height="300" :foldable="true" class="other"> <template #icon><i class="ti ti-clock"></i></template> <template #header>{{ i18n.ts.recentPosts }}</template> @@ -94,17 +93,18 @@ SPDX-License-Identifier: AGPL-3.0-only <MkLoading v-else/> </Transition> </MkSpacer> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> import { computed, watch, ref, defineAsyncComponent } from 'vue'; import * as Misskey from 'misskey-js'; +import { url } from '@@/js/config.js'; +import type { MenuItem } from '@/types/menu.js'; import XPage from '@/components/page/page.vue'; import MkButton from '@/components/MkButton.vue'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { url } from '@@/js/config.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import MkMediaImage from '@/components/MkMediaImage.vue'; import MkImgWithBlurhash from '@/components/MkImgWithBlurhash.vue'; import MkFollowButton from '@/components/MkFollowButton.vue'; @@ -112,16 +112,16 @@ import MkContainer from '@/components/MkContainer.vue'; import MkPagination from '@/components/MkPagination.vue'; import MkPagePreview from '@/components/MkPagePreview.vue'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; -import { pageViewInterruptors, defaultStore } from '@/store.js'; -import { deepClone } from '@/scripts/clone.js'; -import { $i } from '@/account.js'; -import { isSupportShare } from '@/scripts/navigator.js'; +import { definePage } from '@/page.js'; +import { deepClone } from '@/utility/clone.js'; +import { $i } from '@/i.js'; +import { isSupportShare } from '@/utility/navigator.js'; import { instance } from '@/instance.js'; -import { getStaticImageUrl } from '@/scripts/media-proxy.js'; -import { copyToClipboard } from '@/scripts/copy-to-clipboard.js'; -import { useRouter } from '@/router/supplier.js'; -import type { MenuItem } from '@/types/menu.js'; +import { getStaticImageUrl } from '@/utility/media-proxy.js'; +import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; +import { useRouter } from '@/router.js'; +import { prefer } from '@/preferences.js'; +import { getPluginHandlers } from '@/plugin.js'; const router = useRouter(); @@ -150,6 +150,7 @@ function fetchPage() { page.value = _page; // plugin + const pageViewInterruptors = getPluginHandlers('page_view_interruptor'); if (pageViewInterruptors.length > 0) { let result = deepClone(_page); for (const interruptor of pageViewInterruptors) { @@ -188,7 +189,6 @@ function copyLink() { if (!page.value) return; copyToClipboard(`${url}/@${page.value.user.username}/pages/${page.value.name}`); - os.success(); } function shareWithNote() { @@ -318,7 +318,7 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: page.value ? page.value.title || page.value.name : i18n.ts.pages, ...page.value ? { avatar: page.value.user, diff --git a/packages/frontend/src/pages/pages.vue b/packages/frontend/src/pages/pages.vue index 4ef9d3b091..c99d7f1a0f 100644 --- a/packages/frontend/src/pages/pages.vue +++ b/packages/frontend/src/pages/pages.vue @@ -4,11 +4,10 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> +<PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"> <MkSpacer :contentMax="700"> <MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs"> - <div v-if="tab === 'featured'" key="featured"> + <div v-if="tab === 'featured'"> <MkPagination v-slot="{items}" :pagination="featuredPagesPagination"> <div class="_gaps"> <MkPagePreview v-for="page in items" :key="page.id" :page="page"/> @@ -16,7 +15,7 @@ SPDX-License-Identifier: AGPL-3.0-only </MkPagination> </div> - <div v-else-if="tab === 'my'" key="my" class="_gaps"> + <div v-else-if="tab === 'my'" class="_gaps"> <MkButton class="new" @click="create()"><i class="ti ti-plus"></i></MkButton> <MkPagination v-slot="{items}" :pagination="myPagesPagination"> <div class="_gaps"> @@ -25,7 +24,7 @@ SPDX-License-Identifier: AGPL-3.0-only </MkPagination> </div> - <div v-else-if="tab === 'liked'" key="liked"> + <div v-else-if="tab === 'liked'"> <MkPagination v-slot="{items}" :pagination="likedPagesPagination"> <div class="_gaps"> <MkPagePreview v-for="like in items" :key="like.page.id" :page="like.page"/> @@ -34,7 +33,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </MkHorizontalSwipe> </MkSpacer> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> @@ -44,8 +43,8 @@ import MkPagination from '@/components/MkPagination.vue'; import MkButton from '@/components/MkButton.vue'; import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; -import { useRouter } from '@/router/supplier.js'; +import { definePage } from '@/page.js'; +import { useRouter } from '@/router.js'; const router = useRouter(); @@ -88,7 +87,7 @@ const headerTabs = computed(() => [{ icon: 'ti ti-heart', }]); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.pages, icon: 'ti ti-note', })); diff --git a/packages/frontend/src/pages/preview.vue b/packages/frontend/src/pages/preview.vue index 8e07b190aa..78167500f4 100644 --- a/packages/frontend/src/pages/preview.vue +++ b/packages/frontend/src/pages/preview.vue @@ -13,13 +13,13 @@ SPDX-License-Identifier: AGPL-3.0-only import { computed } from 'vue'; import MkSample from '@/components/MkPreview.vue'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(computed(() => ({ +definePage(computed(() => ({ title: i18n.ts.preview, icon: 'ti ti-eye', }))); diff --git a/packages/frontend/src/pages/registry.keys.vue b/packages/frontend/src/pages/registry.keys.vue index 4cacbd0906..373394de55 100644 --- a/packages/frontend/src/pages/registry.keys.vue +++ b/packages/frontend/src/pages/registry.keys.vue @@ -4,8 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> +<PageWithHeader :actions="headerActions" :tabs="headerTabs"> <MkSpacer :contentMax="600" :marginMin="16"> <div class="_gaps_m"> <FormSplit> @@ -29,16 +28,16 @@ SPDX-License-Identifier: AGPL-3.0-only </FormSection> </div> </MkSpacer> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> import { watch, computed, ref } from 'vue'; import JSON5 from 'json5'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; import FormLink from '@/components/form/link.vue'; import FormSection from '@/components/form/section.vue'; import MkButton from '@/components/MkButton.vue'; @@ -96,7 +95,7 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.registry, icon: 'ti ti-adjustments', })); diff --git a/packages/frontend/src/pages/registry.value.vue b/packages/frontend/src/pages/registry.value.vue index c40d13f664..7c0a7f20bb 100644 --- a/packages/frontend/src/pages/registry.value.vue +++ b/packages/frontend/src/pages/registry.value.vue @@ -4,8 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> +<PageWithHeader :actions="headerActions" :tabs="headerTabs"> <MkSpacer :contentMax="600" :marginMin="16"> <div class="_gaps_m"> <FormInfo warn>{{ i18n.ts.editTheseSettingsMayBreakAccount }}</FormInfo> @@ -41,16 +40,16 @@ SPDX-License-Identifier: AGPL-3.0-only </template> </div> </MkSpacer> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> import { watch, computed, ref } from 'vue'; import JSON5 from 'json5'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; import MkButton from '@/components/MkButton.vue'; import MkKeyValue from '@/components/MkKeyValue.vue'; import MkCodeEditor from '@/components/MkCodeEditor.vue'; @@ -123,7 +122,7 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.registry, icon: 'ti ti-adjustments', })); diff --git a/packages/frontend/src/pages/registry.vue b/packages/frontend/src/pages/registry.vue index c641874b17..c60833920b 100644 --- a/packages/frontend/src/pages/registry.vue +++ b/packages/frontend/src/pages/registry.vue @@ -4,8 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> +<PageWithHeader :actions="headerActions" :tabs="headerTabs"> <MkSpacer :contentMax="600" :marginMin="16"> <MkButton primary @click="createKey">{{ i18n.ts._registry.createKey }}</MkButton> @@ -18,7 +17,7 @@ SPDX-License-Identifier: AGPL-3.0-only </FormSection> </div> </MkSpacer> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> @@ -26,9 +25,9 @@ import { ref, computed } from 'vue'; import * as Misskey from 'misskey-js'; import JSON5 from 'json5'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; import FormLink from '@/components/form/link.vue'; import FormSection from '@/components/form/section.vue'; import MkButton from '@/components/MkButton.vue'; @@ -73,7 +72,7 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.registry, icon: 'ti ti-adjustments', })); diff --git a/packages/frontend/src/pages/reset-password.vue b/packages/frontend/src/pages/reset-password.vue index 6d24029535..0a7726a7f8 100644 --- a/packages/frontend/src/pages/reset-password.vue +++ b/packages/frontend/src/pages/reset-password.vue @@ -4,8 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> +<PageWithHeader :actions="headerActions" :tabs="headerTabs"> <MkSpacer v-if="token" :contentMax="700" :marginMin="16" :marginMax="32"> <div class="_gaps_m"> <MkInput v-model="password" type="password"> @@ -16,7 +15,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkButton primary @click="save">{{ i18n.ts.save }}</MkButton> </div> </MkSpacer> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> @@ -25,8 +24,8 @@ import MkInput from '@/components/MkInput.vue'; import MkButton from '@/components/MkButton.vue'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; -import { mainRouter } from '@/router/main.js'; +import { definePage } from '@/page.js'; +import { mainRouter } from '@/router.js'; const props = defineProps<{ token?: string; @@ -55,7 +54,7 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.resetPassword, icon: 'ti ti-lock', })); diff --git a/packages/frontend/src/pages/reversi/game.board.vue b/packages/frontend/src/pages/reversi/game.board.vue index 429f502133..b7434bff9f 100644 --- a/packages/frontend/src/pages/reversi/game.board.vue +++ b/packages/frontend/src/pages/reversi/game.board.vue @@ -145,21 +145,21 @@ SPDX-License-Identifier: AGPL-3.0-only import { computed, onActivated, onDeactivated, onMounted, onUnmounted, ref, shallowRef, triggerRef, watch } from 'vue'; import * as Misskey from 'misskey-js'; import * as Reversi from 'misskey-reversi'; +import { useInterval } from '@@/js/use-interval.js'; +import { url } from '@@/js/config.js'; import MkButton from '@/components/MkButton.vue'; import MkFolder from '@/components/MkFolder.vue'; import MkSwitch from '@/components/MkSwitch.vue'; -import { deepClone } from '@/scripts/clone.js'; -import { useInterval } from '@@/js/use-interval.js'; -import { signinRequired } from '@/account.js'; -import { url } from '@@/js/config.js'; +import { deepClone } from '@/utility/clone.js'; +import { ensureSignin } from '@/i.js'; import { i18n } from '@/i18n.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { userPage } from '@/filters/user.js'; -import * as sound from '@/scripts/sound.js'; +import * as sound from '@/utility/sound.js'; import * as os from '@/os.js'; -import { confetti } from '@/scripts/confetti.js'; +import { confetti } from '@/utility/confetti.js'; -const $i = signinRequired(); +const $i = ensureSignin(); const props = defineProps<{ game: Misskey.entities.ReversiGameDetailed; @@ -301,7 +301,7 @@ if (!props.game.isEnded) { if (iAmPlayer.value) { if ((isMyTurn.value && myTurnTimerRmain.value === 0) || (!isMyTurn.value && opTurnTimerRmain.value === 0)) { - props.connection!.send('claimTimeIsUp', {}); + props.connection!.send('claimTimeIsUp', {}); } } }, TIMER_INTERVAL_SEC * 1000, { immediate: false, afterMounted: true }); @@ -424,7 +424,7 @@ function autoplay() { const tick = () => { const log = logs[i]; const time = log.time - previousLog.time; - setTimeout(() => { + window.setTimeout(() => { i++; logPos.value++; previousLog = log; diff --git a/packages/frontend/src/pages/reversi/game.setting.vue b/packages/frontend/src/pages/reversi/game.setting.vue index 437a1a2294..957b1cfc3d 100644 --- a/packages/frontend/src/pages/reversi/game.setting.vue +++ b/packages/frontend/src/pages/reversi/game.setting.vue @@ -114,17 +114,17 @@ import { computed, watch, ref, onMounted, shallowRef, onUnmounted } from 'vue'; import * as Misskey from 'misskey-js'; import * as Reversi from 'misskey-reversi'; import { i18n } from '@/i18n.js'; -import { signinRequired } from '@/account.js'; -import { deepClone } from '@/scripts/clone.js'; +import { ensureSignin } from '@/i.js'; +import { deepClone } from '@/utility/clone.js'; import MkButton from '@/components/MkButton.vue'; import MkRadios from '@/components/MkRadios.vue'; import MkSwitch from '@/components/MkSwitch.vue'; import MkFolder from '@/components/MkFolder.vue'; import * as os from '@/os.js'; import type { MenuItem } from '@/types/menu.js'; -import { useRouter } from '@/router/supplier.js'; +import { useRouter } from '@/router.js'; -const $i = signinRequired(); +const $i = ensureSignin(); const router = useRouter(); @@ -292,7 +292,7 @@ onUnmounted(() => { .footer { -webkit-backdrop-filter: var(--MI-blur, blur(15px)); backdrop-filter: var(--MI-blur, blur(15px)); - background: var(--MI_THEME-acrylicBg); + background: color(from var(--MI_THEME-bg) srgb r g b / 0.5); border-top: solid 0.5px var(--MI_THEME-divider); } </style> diff --git a/packages/frontend/src/pages/reversi/game.vue b/packages/frontend/src/pages/reversi/game.vue index 10ea3717ab..a447572cc0 100644 --- a/packages/frontend/src/pages/reversi/game.vue +++ b/packages/frontend/src/pages/reversi/game.vue @@ -14,17 +14,17 @@ import { computed, watch, ref, onMounted, shallowRef, onUnmounted } from 'vue'; import * as Misskey from 'misskey-js'; import GameSetting from './game.setting.vue'; import GameBoard from './game.board.vue'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { definePage } from '@/page.js'; import { useStream } from '@/stream.js'; -import { signinRequired } from '@/account.js'; -import { useRouter } from '@/router/supplier.js'; +import { ensureSignin } from '@/i.js'; +import { useRouter } from '@/router.js'; import * as os from '@/os.js'; import { url } from '@@/js/config.js'; import { i18n } from '@/i18n.js'; import { useInterval } from '@@/js/use-interval.js'; -const $i = signinRequired(); +const $i = ensureSignin(); const router = useRouter(); @@ -114,7 +114,7 @@ onUnmounted(() => { } }); -definePageMetadata(() => ({ +definePage(() => ({ title: 'Reversi', icon: 'ti ti-device-gamepad', })); diff --git a/packages/frontend/src/pages/reversi/index.vue b/packages/frontend/src/pages/reversi/index.vue index d608a2411c..e3f01d9938 100644 --- a/packages/frontend/src/pages/reversi/index.vue +++ b/packages/frontend/src/pages/reversi/index.vue @@ -107,19 +107,19 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { onDeactivated, onMounted, onUnmounted, ref } from 'vue'; import * as Misskey from 'misskey-js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { definePage } from '@/page.js'; import { useStream } from '@/stream.js'; import MkButton from '@/components/MkButton.vue'; import MkFolder from '@/components/MkFolder.vue'; import { i18n } from '@/i18n.js'; -import { $i } from '@/account.js'; +import { $i } from '@/i.js'; import MkPagination from '@/components/MkPagination.vue'; -import { useRouter } from '@/router/supplier.js'; +import { useRouter } from '@/router.js'; import * as os from '@/os.js'; import { useInterval } from '@@/js/use-interval.js'; -import { pleaseLogin } from '@/scripts/please-login.js'; -import * as sound from '@/scripts/sound.js'; +import { pleaseLogin } from '@/utility/please-login.js'; +import * as sound from '@/utility/sound.js'; const myGamesPagination = { endpoint: 'reversi/games' as const, @@ -261,7 +261,7 @@ onUnmounted(() => { cancelMatching(); }); -definePageMetadata(() => ({ +definePage(() => ({ title: 'Reversi', icon: 'ti ti-device-gamepad', })); diff --git a/packages/frontend/src/pages/role.vue b/packages/frontend/src/pages/role.vue index 46e510b49b..86398b731e 100644 --- a/packages/frontend/src/pages/role.vue +++ b/packages/frontend/src/pages/role.vue @@ -4,11 +4,10 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader v-model:tab="tab" :tabs="headerTabs"/></template> +<PageWithHeader v-model:tab="tab" :tabs="headerTabs"> <MkSpacer v-if="error != null" :contentMax="1200"> <div :class="$style.root"> - <img :class="$style.img" :src="serverErrorImageUrl" class="_ghost"/> + <img :class="$style.img" :src="serverErrorImageUrl" draggable="false"/> <p :class="$style.text"> <i class="ti ti-alert-triangle"></i> {{ error }} @@ -20,7 +19,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div v-if="role">{{ role.description }}</div> <MkUserList v-if="visible" :pagination="users" :extractor="(item) => item.user"/> <div v-else-if="!visible" class="_fullinfo"> - <img :src="infoImageUrl" class="_ghost"/> + <img :src="infoImageUrl" draggable="false"/> <div>{{ i18n.ts.nothing }}</div> </div> </div> @@ -28,22 +27,22 @@ SPDX-License-Identifier: AGPL-3.0-only <MkSpacer v-else-if="tab === 'timeline'" :contentMax="700"> <MkTimeline v-if="visible" ref="timeline" src="role" :role="props.roleId"/> <div v-else-if="!visible" class="_fullinfo"> - <img :src="infoImageUrl" class="_ghost"/> + <img :src="infoImageUrl" draggable="false"/> <div>{{ i18n.ts.nothing }}</div> </div> </MkSpacer> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> import { computed, watch, ref } from 'vue'; import * as Misskey from 'misskey-js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { instanceName } from '@@/js/config.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import MkUserList from '@/components/MkUserList.vue'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; import { i18n } from '@/i18n.js'; import MkTimeline from '@/components/MkTimeline.vue'; -import { instanceName } from '@@/js/config.js'; import { serverErrorImageUrl, infoImageUrl } from '@/instance.js'; const props = withDefaults(defineProps<{ @@ -93,7 +92,7 @@ const headerTabs = computed(() => [{ title: i18n.ts.timeline, }]); -definePageMetadata(() => ({ +definePage(() => ({ title: role.value ? role.value.name : (error.value ?? i18n.ts.role), icon: 'ti ti-badge', })); diff --git a/packages/frontend/src/pages/scratchpad.vue b/packages/frontend/src/pages/scratchpad.vue index 22c06bfc85..ac1a7c6e1e 100644 --- a/packages/frontend/src/pages/scratchpad.vue +++ b/packages/frontend/src/pages/scratchpad.vue @@ -4,9 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader/></template> - +<PageWithHeader> <MkSpacer :contentMax="800"> <div :class="$style.root"> <div class="_gaps_s"> @@ -53,29 +51,28 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </div> </MkSpacer> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> import { onDeactivated, onUnmounted, ref, watch, computed } from 'vue'; -import type { Ref } from 'vue'; import { Interpreter, Parser, utils } from '@syuilo/aiscript'; +import type { Ref } from 'vue'; +import type { AsUiComponent } from '@/aiscript/ui.js'; +import type { AsUiRoot } from '@/aiscript/ui.js'; import MkContainer from '@/components/MkContainer.vue'; import MkButton from '@/components/MkButton.vue'; import MkTextarea from '@/components/MkTextarea.vue'; import MkCodeEditor from '@/components/MkCodeEditor.vue'; -import { aiScriptReadline, createAiScriptEnv } from '@/scripts/aiscript/api.js'; +import { aiScriptReadline, createAiScriptEnv } from '@/aiscript/api.js'; import * as os from '@/os.js'; -import { $i } from '@/account.js'; +import { $i } from '@/i.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; -import { registerAsUiLib } from '@/scripts/aiscript/ui.js'; -import type { AsUiComponent } from '@/scripts/aiscript/ui.js'; +import { definePage } from '@/page.js'; +import { registerAsUiLib } from '@/aiscript/ui.js'; import MkAsUi from '@/components/MkAsUi.vue'; import { miLocalStorage } from '@/local-storage.js'; -import { claimAchievement } from '@/scripts/achievements.js'; - -import type { AsUiRoot } from '@/scripts/aiscript/ui.js'; +import { claimAchievement } from '@/utility/achievements.js'; const parser = new Parser(); let aiscript: Interpreter; @@ -103,7 +100,7 @@ function stringifyUiProps(uiProps) { return JSON.stringify( { ...uiProps, type: undefined, id: undefined }, (k, v) => typeof v === 'function' ? '<function>' : v, - 2 + 2, ); } @@ -202,7 +199,7 @@ const showns = computed(() => { return result; }); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.scratchpad, icon: 'ti ti-terminal-2', })); @@ -250,7 +247,7 @@ definePageMetadata(() => ({ } .uiInspectorUnShown { - color: var(--MI_THEME-fgTransparent); + color: color(from var(--MI_THEME-fg) srgb r g b / 0.5); } .uiInspectorType { diff --git a/packages/frontend/src/pages/search.note.vue b/packages/frontend/src/pages/search.note.vue index 32a35f8110..17cf272a36 100644 --- a/packages/frontend/src/pages/search.note.vue +++ b/packages/frontend/src/pages/search.note.vue @@ -114,14 +114,14 @@ SPDX-License-Identifier: AGPL-3.0-only import { computed, ref, shallowRef, toRef } from 'vue'; import type * as Misskey from 'misskey-js'; import type { Paging } from '@/components/MkPagination.vue'; -import { $i } from '@/account.js'; +import { $i } from '@/i.js'; import { host as localHost } from '@@/js/config.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 { apLookup } from '@/scripts/lookup.js'; -import { useRouter } from '@/router/supplier.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { apLookup } from '@/utility/lookup.js'; +import { useRouter } from '@/router.js'; import MkButton from '@/components/MkButton.vue'; import MkFoldableSection from '@/components/MkFoldableSection.vue'; import MkInput from '@/components/MkInput.vue'; @@ -333,7 +333,7 @@ async function search() { width: 100%; height: 100%; padding: 12px; - border: 2px dashed var(--MI_THEME-fgTransparent); + border: 2px dashed color(from var(--MI_THEME-fg) srgb r g b / 0.5); } .userSelectButtonInner { diff --git a/packages/frontend/src/pages/search.user.vue b/packages/frontend/src/pages/search.user.vue index 2b8faf5465..101de6a64f 100644 --- a/packages/frontend/src/pages/search.user.vue +++ b/packages/frontend/src/pages/search.user.vue @@ -36,8 +36,8 @@ import { i18n } from '@/i18n.js'; import { instance } from '@/instance.js'; import * as os from '@/os.js'; import MkFoldableSection from '@/components/MkFoldableSection.vue'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { useRouter } from '@/router/supplier.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { useRouter } from '@/router.js'; const props = withDefaults(defineProps<{ query?: string, diff --git a/packages/frontend/src/pages/search.vue b/packages/frontend/src/pages/search.vue index 38d7548fa8..e0cb2dcbab 100644 --- a/packages/frontend/src/pages/search.vue +++ b/packages/frontend/src/pages/search.vue @@ -4,11 +4,9 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> - +<PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"> <MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs"> - <MkSpacer v-if="tab === 'note'" key="note" :contentMax="800"> + <MkSpacer v-if="tab === 'note'" :contentMax="800"> <div v-if="notesSearchAvailable || ignoreNotesSearchAvailable"> <XNote v-bind="props"/> </div> @@ -17,18 +15,18 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </MkSpacer> - <MkSpacer v-else-if="tab === 'user'" key="user" :contentMax="800"> + <MkSpacer v-else-if="tab === 'user'" :contentMax="800"> <XUser v-bind="props"/> </MkSpacer> </MkHorizontalSwipe> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> import { computed, defineAsyncComponent, ref, toRef } from 'vue'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; -import { notesSearchAvailable } from '@/scripts/check-permissions.js'; +import { definePage } from '@/page.js'; +import { notesSearchAvailable } from '@/utility/check-permissions.js'; import MkInfo from '@/components/MkInfo.vue'; import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue'; @@ -68,7 +66,7 @@ const headerTabs = computed(() => [{ icon: 'ti ti-users', }]); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.search, icon: 'ti ti-search', })); diff --git a/packages/frontend/src/pages/settings/2fa.qrdialog.vue b/packages/frontend/src/pages/settings/2fa.qrdialog.vue index 18c82ffdf6..03f973a33e 100644 --- a/packages/frontend/src/pages/settings/2fa.qrdialog.vue +++ b/packages/frontend/src/pages/settings/2fa.qrdialog.vue @@ -106,7 +106,8 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { shallowRef, ref } from 'vue'; +import { hostname, port } from '@@/js/config'; +import { useTemplateRef, ref } from 'vue'; import MkButton from '@/components/MkButton.vue'; import MkModalWindow from '@/components/MkModalWindow.vue'; import MkKeyValue from '@/components/MkKeyValue.vue'; @@ -116,10 +117,10 @@ import * as os from '@/os.js'; import MkFolder from '@/components/MkFolder.vue'; import MkInfo from '@/components/MkInfo.vue'; import MkLink from '@/components/MkLink.vue'; -import { confetti } from '@/scripts/confetti.js'; -import { signinRequired } from '@/account.js'; +import { confetti } from '@/utility/confetti.js'; +import { ensureSignin } from '@/i.js'; -const $i = signinRequired(); +const $i = ensureSignin(); defineProps<{ twoFactorData: { @@ -132,7 +133,7 @@ const emit = defineEmits<{ (ev: 'closed'): void; }>(); -const dialog = shallowRef<InstanceType<typeof MkModalWindow>>(); +const dialog = useTemplateRef('dialog'); const page = ref(0); const token = ref<string | number | null>(null); const backupCodes = ref<string[]>(); @@ -159,9 +160,9 @@ async function tokenDone() { function downloadBackupCodes() { if (backupCodes.value !== undefined) { const txtBlob = new Blob([backupCodes.value.join('\n')], { type: 'text/plain' }); - const dummya = document.createElement('a'); + const dummya = window.document.createElement('a'); dummya.href = URL.createObjectURL(txtBlob); - dummya.download = `${$i.username}-2fa-backup-codes.txt`; + dummya.download = `${$i.username}@${hostname}` + (port !== '' ? `_${port}` : '') + '-2fa-backup-codes.txt'; dummya.click(); } } diff --git a/packages/frontend/src/pages/settings/2fa.vue b/packages/frontend/src/pages/settings/2fa.vue index 806599e801..f47ffc984e 100644 --- a/packages/frontend/src/pages/settings/2fa.vue +++ b/packages/frontend/src/pages/settings/2fa.vue @@ -92,10 +92,11 @@ import FormSection from '@/components/form/section.vue'; import MkFolder from '@/components/MkFolder.vue'; import MkLink from '@/components/MkLink.vue'; import * as os from '@/os.js'; -import { signinRequired, updateAccountPartial } from '@/account.js'; +import { ensureSignin } from '@/i.js'; import { i18n } from '@/i18n.js'; +import { updateCurrentAccountPartial } from '@/accounts.js'; -const $i = signinRequired(); +const $i = ensureSignin(); // メモ: 各エンドポイントはmeUpdatedを発行するため、refreshAccountは不要 @@ -131,7 +132,7 @@ async function unregisterTOTP(): Promise<void> { password: auth.result.password, token: auth.result.token, }).then(res => { - updateAccountPartial({ + updateCurrentAccountPartial({ twoFactorEnabled: false, }); }).catch(error => { diff --git a/packages/frontend/src/pages/settings/accessibility.vue b/packages/frontend/src/pages/settings/accessibility.vue deleted file mode 100644 index b703be1fe1..0000000000 --- a/packages/frontend/src/pages/settings/accessibility.vue +++ /dev/null @@ -1,91 +0,0 @@ -<!-- -SPDX-FileCopyrightText: syuilo and misskey-project -SPDX-License-Identifier: AGPL-3.0-only ---> - -<template> -<SearchMarker path="/settings/accessibility" :label="i18n.ts.accessibility" :keywords="['accessibility']" icon="ti ti-accessible"> - <div class="_gaps_m"> - <div class="_gaps_s"> - <SearchMarker :keywords="['animation', 'motion', 'reduce']"> - <MkSwitch v-model="reduceAnimation"> - <template #label><SearchLabel>{{ i18n.ts.reduceUiAnimation }}</SearchLabel></template> - </MkSwitch> - </SearchMarker> - - <SearchMarker :keywords="['disable', 'animation', 'image', 'photo', 'picture', 'media', 'thumbnail', 'gif']"> - <MkSwitch v-model="disableShowingAnimatedImages"> - <template #label><SearchLabel>{{ i18n.ts.disableShowingAnimatedImages }}</SearchLabel></template> - </MkSwitch> - </SearchMarker> - - <SearchMarker :keywords="['mfm', 'enable', 'show', 'animated']"> - <MkSwitch v-model="animatedMfm"> - <template #label><SearchLabel>{{ i18n.ts.enableAnimatedMfm }}</SearchLabel></template> - </MkSwitch> - </SearchMarker> - - <SearchMarker :keywords="['swipe', 'horizontal', 'tab']"> - <MkSwitch v-model="enableHorizontalSwipe"> - <template #label><SearchLabel>{{ i18n.ts.enableHorizontalSwipe }}</SearchLabel></template> - </MkSwitch> - </SearchMarker> - - <SearchMarker :keywords="['keep', 'screen', 'display', 'on']"> - <MkSwitch v-model="keepScreenOn"> - <template #label><SearchLabel>{{ i18n.ts.keepScreenOn }}</SearchLabel></template> - </MkSwitch> - </SearchMarker> - - <SearchMarker :keywords="['native', 'system', 'video', 'audio', 'player', 'media']"> - <MkSwitch v-model="useNativeUIForVideoAudioPlayer"> - <template #label><SearchLabel>{{ i18n.ts.useNativeUIForVideoAudioPlayer }}</SearchLabel></template> - </MkSwitch> - </SearchMarker> - </div> - - <SearchMarker :keywords="['contextmenu', 'system', 'native']"> - <MkSelect v-model="contextMenu"> - <template #label><SearchLabel>{{ i18n.ts._contextMenu.title }}</SearchLabel></template> - <option value="app">{{ i18n.ts._contextMenu.app }}</option> - <option value="appWithShift">{{ i18n.ts._contextMenu.appWithShift }}</option> - <option value="native">{{ i18n.ts._contextMenu.native }}</option> - </MkSelect> - </SearchMarker> - </div> -</SearchMarker> -</template> - -<script lang="ts" setup> -import { computed, ref, watch } from 'vue'; -import MkSwitch from '@/components/MkSwitch.vue'; -import MkSelect from '@/components/MkSelect.vue'; -import { defaultStore } from '@/store.js'; -import { reloadAsk } from '@/scripts/reload-ask.js'; -import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; - -const reduceAnimation = computed(defaultStore.makeGetterSetter('animation', v => !v, v => !v)); -const animatedMfm = computed(defaultStore.makeGetterSetter('animatedMfm')); -const disableShowingAnimatedImages = computed(defaultStore.makeGetterSetter('disableShowingAnimatedImages')); -const keepScreenOn = computed(defaultStore.makeGetterSetter('keepScreenOn')); -const enableHorizontalSwipe = computed(defaultStore.makeGetterSetter('enableHorizontalSwipe')); -const useNativeUIForVideoAudioPlayer = computed(defaultStore.makeGetterSetter('useNativeUIForVideoAudioPlayer')); -const contextMenu = computed(defaultStore.makeGetterSetter('contextMenu')); - -watch([ - keepScreenOn, - contextMenu, -], async () => { - await reloadAsk({ reason: i18n.ts.reloadToApplySetting, unison: true }); -}); - -const headerActions = computed(() => []); - -const headerTabs = computed(() => []); - -definePageMetadata(() => ({ - title: i18n.ts.accessibility, - icon: 'ti ti-accessible', -})); -</script> diff --git a/packages/frontend/src/pages/settings/account-data.vue b/packages/frontend/src/pages/settings/account-data.vue new file mode 100644 index 0000000000..14bea577a3 --- /dev/null +++ b/packages/frontend/src/pages/settings/account-data.vue @@ -0,0 +1,277 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<SearchMarker path="/settings/account-data" :label="i18n.ts._settings.accountData" :keywords="['import', 'export', 'data', 'archive']" icon="ti ti-package"> + <div class="_gaps_m"> + <MkFeatureBanner icon="/client-assets/package_3d.png" color="#ff9100"> + <SearchKeyword>{{ i18n.ts._settings.accountDataBanner }}</SearchKeyword> + </MkFeatureBanner> + + <div class="_gaps_s"> + <SearchMarker :keywords="['notes']"> + <MkFolder> + <template #icon><i class="ti ti-pencil"></i></template> + <template #label><SearchLabel>{{ i18n.ts._exportOrImport.allNotes }}</SearchLabel></template> + <MkFolder :defaultOpen="true"> + <template #label>{{ i18n.ts.export }}</template> + <template #icon><i class="ti ti-download"></i></template> + <MkButton primary :class="$style.button" inline @click="exportNotes()"><i class="ti ti-download"></i> {{ i18n.ts.export }}</MkButton> + </MkFolder> + </MkFolder> + </SearchMarker> + + <SearchMarker :keywords="['favorite', 'notes']"> + <MkFolder> + <template #icon><i class="ti ti-star"></i></template> + <template #label><SearchLabel>{{ i18n.ts._exportOrImport.favoritedNotes }}</SearchLabel></template> + <MkFolder :defaultOpen="true"> + <template #label>{{ i18n.ts.export }}</template> + <template #icon><i class="ti ti-download"></i></template> + <MkButton primary :class="$style.button" inline @click="exportFavorites()"><i class="ti ti-download"></i> {{ i18n.ts.export }}</MkButton> + </MkFolder> + </MkFolder> + </SearchMarker> + + <SearchMarker :keywords="['clip', 'notes']"> + <MkFolder> + <template #icon><i class="ti ti-star"></i></template> + <template #label><SearchLabel>{{ i18n.ts._exportOrImport.clips }}</SearchLabel></template> + <MkFolder :defaultOpen="true"> + <template #label>{{ i18n.ts.export }}</template> + <template #icon><i class="ti ti-download"></i></template> + <MkButton primary :class="$style.button" inline @click="exportClips()"><i class="ti ti-download"></i> {{ i18n.ts.export }}</MkButton> + </MkFolder> + </MkFolder> + </SearchMarker> + + <SearchMarker :keywords="['following', 'users']"> + <MkFolder> + <template #icon><i class="ti ti-users"></i></template> + <template #label><SearchLabel>{{ i18n.ts._exportOrImport.followingList }}</SearchLabel></template> + <div class="_gaps_s"> + <MkFolder :defaultOpen="true"> + <template #label>{{ i18n.ts.export }}</template> + <template #icon><i class="ti ti-download"></i></template> + <div class="_gaps_s"> + <MkSwitch v-model="excludeMutingUsers"> + {{ i18n.ts._exportOrImport.excludeMutingUsers }} + </MkSwitch> + <MkSwitch v-model="excludeInactiveUsers"> + {{ i18n.ts._exportOrImport.excludeInactiveUsers }} + </MkSwitch> + <MkButton primary :class="$style.button" inline @click="exportFollowing()"><i class="ti ti-download"></i> {{ i18n.ts.export }}</MkButton> + </div> + </MkFolder> + <MkFolder v-if="$i && !$i.movedTo && $i.policies.canImportFollowing" :defaultOpen="true"> + <template #label>{{ i18n.ts.import }}</template> + <template #icon><i class="ti ti-upload"></i></template> + <MkSwitch v-model="withReplies"> + {{ i18n.ts._exportOrImport.withReplies }} + </MkSwitch> + <MkButton primary :class="$style.button" inline @click="importFollowing($event)"><i class="ti ti-upload"></i> {{ i18n.ts.import }}</MkButton> + </MkFolder> + </div> + </MkFolder> + </SearchMarker> + + <SearchMarker :keywords="['user', 'lists']"> + <MkFolder> + <template #icon><i class="ti ti-users"></i></template> + <template #label><SearchLabel>{{ i18n.ts._exportOrImport.userLists }}</SearchLabel></template> + <div class="_gaps_s"> + <MkFolder :defaultOpen="true"> + <template #label>{{ i18n.ts.export }}</template> + <template #icon><i class="ti ti-download"></i></template> + <MkButton primary :class="$style.button" inline @click="exportUserLists()"><i class="ti ti-download"></i> {{ i18n.ts.export }}</MkButton> + </MkFolder> + <MkFolder v-if="$i && !$i.movedTo && $i.policies.canImportUserLists" :defaultOpen="true"> + <template #label>{{ i18n.ts.import }}</template> + <template #icon><i class="ti ti-upload"></i></template> + <MkButton primary :class="$style.button" inline @click="importUserLists($event)"><i class="ti ti-upload"></i> {{ i18n.ts.import }}</MkButton> + </MkFolder> + </div> + </MkFolder> + </SearchMarker> + + <SearchMarker :keywords="['mute', 'users']"> + <MkFolder> + <template #icon><i class="ti ti-user-off"></i></template> + <template #label><SearchLabel>{{ i18n.ts._exportOrImport.muteList }}</SearchLabel></template> + <div class="_gaps_s"> + <MkFolder :defaultOpen="true"> + <template #label>{{ i18n.ts.export }}</template> + <template #icon><i class="ti ti-download"></i></template> + <MkButton primary :class="$style.button" inline @click="exportMuting()"><i class="ti ti-download"></i> {{ i18n.ts.export }}</MkButton> + </MkFolder> + <MkFolder v-if="$i && !$i.movedTo && $i.policies.canImportMuting" :defaultOpen="true"> + <template #label>{{ i18n.ts.import }}</template> + <template #icon><i class="ti ti-upload"></i></template> + <MkButton primary :class="$style.button" inline @click="importMuting($event)"><i class="ti ti-upload"></i> {{ i18n.ts.import }}</MkButton> + </MkFolder> + </div> + </MkFolder> + </SearchMarker> + + <SearchMarker :keywords="['block', 'users']"> + <MkFolder> + <template #icon><i class="ti ti-user-off"></i></template> + <template #label><SearchLabel>{{ i18n.ts._exportOrImport.blockingList }}</SearchLabel></template> + <div class="_gaps_s"> + <MkFolder :defaultOpen="true"> + <template #label>{{ i18n.ts.export }}</template> + <template #icon><i class="ti ti-download"></i></template> + <MkButton primary :class="$style.button" inline @click="exportBlocking()"><i class="ti ti-download"></i> {{ i18n.ts.export }}</MkButton> + </MkFolder> + <MkFolder v-if="$i && !$i.movedTo && $i.policies.canImportBlocking" :defaultOpen="true"> + <template #label>{{ i18n.ts.import }}</template> + <template #icon><i class="ti ti-upload"></i></template> + <MkButton primary :class="$style.button" inline @click="importBlocking($event)"><i class="ti ti-upload"></i> {{ i18n.ts.import }}</MkButton> + </MkFolder> + </div> + </MkFolder> + </SearchMarker> + + <SearchMarker :keywords="['antennas']"> + <MkFolder> + <template #icon><i class="ti ti-antenna"></i></template> + <template #label><SearchLabel>{{ i18n.ts.antennas }}</SearchLabel></template> + <div class="_gaps_s"> + <MkFolder :defaultOpen="true"> + <template #label>{{ i18n.ts.export }}</template> + <template #icon><i class="ti ti-download"></i></template> + <MkButton primary :class="$style.button" inline @click="exportAntennas()"><i class="ti ti-download"></i> {{ i18n.ts.export }}</MkButton> + </MkFolder> + <MkFolder v-if="$i && !$i.movedTo && $i.policies.canImportAntennas" :defaultOpen="true"> + <template #label>{{ i18n.ts.import }}</template> + <template #icon><i class="ti ti-upload"></i></template> + <MkButton primary :class="$style.button" inline @click="importAntennas($event)"><i class="ti ti-upload"></i> {{ i18n.ts.import }}</MkButton> + </MkFolder> + </div> + </MkFolder> + </SearchMarker> + </div> + </div> +</SearchMarker> +</template> + +<script lang="ts" setup> +import { ref, computed } from 'vue'; +import MkButton from '@/components/MkButton.vue'; +import MkFolder from '@/components/MkFolder.vue'; +import MkSwitch from '@/components/MkSwitch.vue'; +import * as os from '@/os.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { selectFile } from '@/utility/select-file.js'; +import { i18n } from '@/i18n.js'; +import { definePage } from '@/page.js'; +import { $i } from '@/i.js'; +import MkFeatureBanner from '@/components/MkFeatureBanner.vue'; +import { prefer } from '@/preferences.js'; + +const excludeMutingUsers = ref(false); +const excludeInactiveUsers = ref(false); +const withReplies = ref(prefer.s.defaultFollowWithReplies); + +const onExportSuccess = () => { + os.alert({ + type: 'info', + text: i18n.ts.exportRequested, + }); +}; + +const onImportSuccess = () => { + os.alert({ + type: 'info', + text: i18n.ts.importRequested, + }); +}; + +const onError = (ev) => { + os.alert({ + type: 'error', + text: ev.message, + }); +}; + +const exportNotes = () => { + misskeyApi('i/export-notes', {}).then(onExportSuccess).catch(onError); +}; + +const exportFavorites = () => { + misskeyApi('i/export-favorites', {}).then(onExportSuccess).catch(onError); +}; + +const exportClips = () => { + misskeyApi('i/export-clips', {}).then(onExportSuccess).catch(onError); +}; + +const exportFollowing = () => { + misskeyApi('i/export-following', { + excludeMuting: excludeMutingUsers.value, + excludeInactive: excludeInactiveUsers.value, + }) + .then(onExportSuccess).catch(onError); +}; + +const exportBlocking = () => { + misskeyApi('i/export-blocking', {}).then(onExportSuccess).catch(onError); +}; + +const exportUserLists = () => { + misskeyApi('i/export-user-lists', {}).then(onExportSuccess).catch(onError); +}; + +const exportMuting = () => { + misskeyApi('i/export-mute', {}).then(onExportSuccess).catch(onError); +}; + +const exportAntennas = () => { + misskeyApi('i/export-antennas', {}).then(onExportSuccess).catch(onError); +}; + +const importFollowing = async (ev) => { + const file = await selectFile(ev.currentTarget ?? ev.target); + misskeyApi('i/import-following', { + fileId: file.id, + withReplies: withReplies.value, + }).then(onImportSuccess).catch(onError); +}; + +const importUserLists = async (ev) => { + const file = await selectFile(ev.currentTarget ?? ev.target); + misskeyApi('i/import-user-lists', { fileId: file.id }).then(onImportSuccess).catch(onError); +}; + +const importMuting = async (ev) => { + const file = await selectFile(ev.currentTarget ?? ev.target); + misskeyApi('i/import-muting', { fileId: file.id }).then(onImportSuccess).catch(onError); +}; + +const importBlocking = async (ev) => { + const file = await selectFile(ev.currentTarget ?? ev.target); + misskeyApi('i/import-blocking', { fileId: file.id }).then(onImportSuccess).catch(onError); +}; + +const importAntennas = async (ev) => { + const file = await selectFile(ev.currentTarget ?? ev.target); + misskeyApi('i/import-antennas', { fileId: file.id }).then(onImportSuccess).catch(onError); +}; + +const headerActions = computed(() => []); + +const headerTabs = computed(() => []); + +definePage(() => ({ + title: i18n.ts._settings.accountData, + icon: 'ti ti-package', +})); +</script> + +<style module> +.button { + margin-right: 16px; +} +</style> diff --git a/packages/frontend/src/pages/settings/accounts.vue b/packages/frontend/src/pages/settings/accounts.vue index 4a7301f405..2fd0a021da 100644 --- a/packages/frontend/src/pages/settings/accounts.vue +++ b/packages/frontend/src/pages/settings/accounts.vue @@ -4,80 +4,50 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div class=""> - <FormSuspense :p="init"> - <div class="_gaps"> - <div class="_buttons"> - <MkButton primary @click="addAccount"><i class="ti ti-plus"></i> {{ i18n.ts.addAccount }}</MkButton> - <MkButton @click="init"><i class="ti ti-refresh"></i> {{ i18n.ts.reloadAccountsList }}</MkButton> - </div> - - <template v-for="[id, user] in accounts"> - <MkUserCardMini v-if="user != null" :key="user.id" :user="user" :class="$style.user" @click.prevent="menu(user, $event)"/> - <button v-else v-panel class="_button" :class="$style.unknownUser" @click="menu(id, $event)"> - <div :class="$style.unknownUserAvatarMock"><i class="ti ti-user-question"></i></div> - <div> - <div :class="$style.unknownUserTitle">{{ i18n.ts.unknown }}</div> - <div :class="$style.unknownUserSub">ID: <span class="_monospace">{{ id }}</span></div> - </div> - </button> - </template> +<SearchMarker path="/settings/accounts" :label="i18n.ts.accounts" :keywords="['accounts']" icon="ti ti-users"> + <div class="_gaps"> + <div class="_buttons"> + <MkButton primary @click="addAccount"><i class="ti ti-plus"></i> {{ i18n.ts.addAccount }}</MkButton> + <!--<MkButton @click="refreshAllAccounts"><i class="ti ti-refresh"></i></MkButton>--> </div> - </FormSuspense> -</div> + + <MkUserCardMini v-for="x in accounts" :key="x[0] + x[1].id" :user="x[1]" :class="$style.user" @click.prevent="menu(x[0], x[1], $event)"/> + </div> +</SearchMarker> </template> <script lang="ts" setup> import { ref, computed } from 'vue'; import * as Misskey from 'misskey-js'; -import FormSuspense from '@/components/form/suspense.vue'; +import type { MenuItem } from '@/types/menu.js'; import MkButton from '@/components/MkButton.vue'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { getAccounts, removeAccount as _removeAccount, login, $i, getAccountWithSigninDialog, getAccountWithSignupDialog } from '@/account.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { $i } from '@/i.js'; +import { switchAccount, removeAccount, login, getAccountWithSigninDialog, getAccountWithSignupDialog } from '@/accounts.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; import MkUserCardMini from '@/components/MkUserCardMini.vue'; -import type { MenuItem } from '@/types/menu.js'; +import { prefer } from '@/preferences.js'; -const storedAccounts = ref<{ id: string, token: string }[] | null>(null); -const accounts = ref(new Map<string, Misskey.entities.UserDetailed | null>()); +const accounts = prefer.r.accounts; -const init = async () => { - getAccounts().then(accounts => { - storedAccounts.value = accounts.filter(x => x.id !== $i!.id); - - return misskeyApi('users/show', { - userIds: storedAccounts.value.map(x => x.id), - }); - }).then(response => { - if (storedAccounts.value == null) return; - accounts.value = new Map(storedAccounts.value.map(x => [x.id, response.find((y: Misskey.entities.UserDetailed) => y.id === x.id) ?? null])); - }); -}; +function refreshAllAccounts() { + // TODO +} -function menu(account: Misskey.entities.UserDetailed | string, ev: MouseEvent) { +function menu(host: string, account: Misskey.entities.UserDetailed, ev: MouseEvent) { let menu: MenuItem[]; - if (typeof account === 'string') { - menu = [{ - text: i18n.ts.logout, - icon: 'ti ti-trash', - danger: true, - action: () => removeAccount(account), - }]; - } else { - menu = [{ - text: i18n.ts.switch, - icon: 'ti ti-switch-horizontal', - action: () => switchAccount(account.id), - }, { - text: i18n.ts.logout, - icon: 'ti ti-trash', - danger: true, - action: () => removeAccount(account.id), - }]; - } + menu = [{ + text: i18n.ts.switch, + icon: 'ti ti-switch-horizontal', + action: () => switchAccount(host, account.id), + }, { + text: i18n.ts.remove, + icon: 'ti ti-trash', + action: () => removeAccount(host, account.id), + }]; os.popupMenu(menu, ev.currentTarget ?? ev.target); } @@ -92,16 +62,10 @@ function addAccount(ev: MouseEvent) { }], ev.currentTarget ?? ev.target); } -async function removeAccount(id: string) { - await _removeAccount(id); - accounts.value.delete(id); -} - function addExistingAccount() { getAccountWithSigninDialog().then((res) => { if (res != null) { os.success(); - init(); } }); } @@ -109,26 +73,16 @@ function addExistingAccount() { function createAccount() { getAccountWithSignupDialog().then((res) => { if (res != null) { - switchAccountWithToken(res.token); + login(res.token); } }); } -async function switchAccount(id: string) { - const fetchedAccounts = await getAccounts(); - const token = fetchedAccounts.find(x => x.id === id)!.token; - switchAccountWithToken(token); -} - -function switchAccountWithToken(token: string) { - login(token); -} - const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.accounts, icon: 'ti ti-users', })); diff --git a/packages/frontend/src/pages/settings/api.vue b/packages/frontend/src/pages/settings/api.vue deleted file mode 100644 index b35d406a98..0000000000 --- a/packages/frontend/src/pages/settings/api.vue +++ /dev/null @@ -1,53 +0,0 @@ -<!-- -SPDX-FileCopyrightText: syuilo and misskey-project -SPDX-License-Identifier: AGPL-3.0-only ---> - -<template> -<div class="_gaps_m"> - <MkButton primary @click="generateToken">{{ i18n.ts.generateAccessToken }}</MkButton> - <FormLink to="/settings/apps">{{ i18n.ts.manageAccessTokens }}</FormLink> - <FormLink to="/api-console" :behavior="isDesktop ? 'window' : null">API console</FormLink> -</div> -</template> - -<script lang="ts" setup> -import { defineAsyncComponent, ref, computed } from 'vue'; -import FormLink from '@/components/form/link.vue'; -import MkButton from '@/components/MkButton.vue'; -import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; - -const isDesktop = ref(window.innerWidth >= 1100); - -function generateToken() { - const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkTokenGenerateWindow.vue')), {}, { - done: async result => { - const { name, permissions } = result; - const { token } = await misskeyApi('miauth/gen-token', { - session: null, - name: name, - permission: permissions, - }); - - os.alert({ - type: 'success', - title: i18n.ts.token, - text: token, - }); - }, - closed: () => dispose(), - }); -} - -const headerActions = computed(() => []); - -const headerTabs = computed(() => []); - -definePageMetadata(() => ({ - title: 'API', - icon: 'ti ti-api', -})); -</script> diff --git a/packages/frontend/src/pages/settings/appearance.vue b/packages/frontend/src/pages/settings/appearance.vue deleted file mode 100644 index 465c2a38c2..0000000000 --- a/packages/frontend/src/pages/settings/appearance.vue +++ /dev/null @@ -1,287 +0,0 @@ -<!-- -SPDX-FileCopyrightText: syuilo and misskey-project -SPDX-License-Identifier: AGPL-3.0-only ---> - -<template> -<SearchMarker path="/settings/appearance" :label="i18n.ts.appearance" :keywords="['appearance']" icon="ti ti-device-desktop"> - <div class="_gaps_m"> - <FormSection first> - <div class="_gaps_m"> - <div class="_gaps_s"> - <SearchMarker :keywords="['blur']"> - <MkSwitch v-model="useBlurEffect"> - <template #label><SearchLabel>{{ i18n.ts.useBlurEffect }}</SearchLabel></template> - </MkSwitch> - </SearchMarker> - - <SearchMarker :keywords="['blur', 'modal']"> - <MkSwitch v-model="useBlurEffectForModal"> - <template #label><SearchLabel>{{ i18n.ts.useBlurEffectForModal }}</SearchLabel></template> - </MkSwitch> - </SearchMarker> - - <SearchMarker :keywords="['highlight', 'sensitive', 'nsfw', 'image', 'photo', 'picture', 'media', 'thumbnail']"> - <MkSwitch v-model="highlightSensitiveMedia"> - <template #label><SearchLabel>{{ i18n.ts.highlightSensitiveMedia }}</SearchLabel></template> - </MkSwitch> - </SearchMarker> - - <SearchMarker :keywords="['avatar', 'icon', 'square']"> - <MkSwitch v-model="squareAvatars"> - <template #label><SearchLabel>{{ i18n.ts.squareAvatars }}</SearchLabel></template> - </MkSwitch> - </SearchMarker> - - <SearchMarker :keywords="['avatar', 'icon', 'decoration', 'show']"> - <MkSwitch v-model="showAvatarDecorations"> - <template #label><SearchLabel>{{ i18n.ts.showAvatarDecorations }}</SearchLabel></template> - </MkSwitch> - </SearchMarker> - - <SearchMarker :keywords="['note', 'timeline', 'gap']"> - <MkSwitch v-model="showGapBetweenNotesInTimeline"> - <template #label><SearchLabel>{{ i18n.ts.showGapBetweenNotesInTimeline }}</SearchLabel></template> - </MkSwitch> - </SearchMarker> - - <SearchMarker :keywords="['font', 'system', 'native']"> - <MkSwitch v-model="useSystemFont"> - <template #label><SearchLabel>{{ i18n.ts.useSystemFont }}</SearchLabel></template> - </MkSwitch> - </SearchMarker> - - <SearchMarker :keywords="['effect', 'show']"> - <MkSwitch v-model="enableSeasonalScreenEffect"> - <template #label><SearchLabel>{{ i18n.ts.seasonalScreenEffect }}</SearchLabel></template> - </MkSwitch> - </SearchMarker> - </div> - - <SearchMarker :keywords="['menu', 'style', 'popup', 'drawer']"> - <MkSelect v-model="menuStyle"> - <template #label><SearchLabel>{{ i18n.ts.menuStyle }}</SearchLabel></template> - <option value="auto">{{ i18n.ts.auto }}</option> - <option value="popup">{{ i18n.ts.popup }}</option> - <option value="drawer">{{ i18n.ts.drawer }}</option> - </MkSelect> - </SearchMarker> - - <SearchMarker :keywords="['emoji', 'style', 'native', 'system', 'fluent', 'twemoji']"> - <div> - <MkRadios v-model="emojiStyle"> - <template #label><SearchLabel>{{ i18n.ts.emojiStyle }}</SearchLabel></template> - <option value="native">{{ i18n.ts.native }}</option> - <option value="fluentEmoji">Fluent Emoji</option> - <option value="twemoji">Twemoji</option> - </MkRadios> - <div style="margin: 8px 0 0 0; font-size: 1.5em;"><Mfm :key="emojiStyle" text="🍮🍦🍭🍩🍰🍫🍬🥞🍪"/></div> - </div> - </SearchMarker> - - <SearchMarker :keywords="['font', 'size']"> - <MkRadios v-model="fontSize"> - <template #label><SearchLabel>{{ i18n.ts.fontSize }}</SearchLabel></template> - <option :value="null"><span style="font-size: 14px;">Aa</span></option> - <option value="1"><span style="font-size: 15px;">Aa</span></option> - <option value="2"><span style="font-size: 16px;">Aa</span></option> - <option value="3"><span style="font-size: 17px;">Aa</span></option> - </MkRadios> - </SearchMarker> - </div> - </FormSection> - - <SearchMarker :keywords="['note', 'display']"> - <FormSection> - <template #label><SearchLabel>{{ i18n.ts.displayOfNote }}</SearchLabel></template> - - <div class="_gaps_m"> - <SearchMarker :keywords="['reaction', 'size', 'scale', 'display']"> - <MkRadios v-model="reactionsDisplaySize"> - <template #label><SearchLabel>{{ i18n.ts.reactionsDisplaySize }}</SearchLabel></template> - <option value="small">{{ i18n.ts.small }}</option> - <option value="medium">{{ i18n.ts.medium }}</option> - <option value="large">{{ i18n.ts.large }}</option> - </MkRadios> - </SearchMarker> - - <SearchMarker :keywords="['reaction', 'size', 'scale', 'display', 'width', 'limit']"> - <MkSwitch v-model="limitWidthOfReaction"> - <template #label><SearchLabel>{{ i18n.ts.limitWidthOfReaction }}</SearchLabel></template> - </MkSwitch> - </SearchMarker> - - <SearchMarker :keywords="['attachment', 'image', 'photo', 'picture', 'media', 'thumbnail', 'list', 'size', 'height']"> - <MkRadios v-model="mediaListWithOneImageAppearance"> - <template #label><SearchLabel>{{ i18n.ts.mediaListWithOneImageAppearance }}</SearchLabel></template> - <option value="expand">{{ i18n.ts.default }}</option> - <option value="16_9">{{ i18n.tsx.limitTo({ x: '16:9' }) }}</option> - <option value="1_1">{{ i18n.tsx.limitTo({ x: '1:1' }) }}</option> - <option value="2_3">{{ i18n.tsx.limitTo({ x: '2:3' }) }}</option> - </MkRadios> - </SearchMarker> - - <SearchMarker :keywords="['ticker', 'information', 'label', 'instance', 'server', 'host', 'federation']"> - <MkSelect v-if="instance.federation !== 'none'" v-model="instanceTicker"> - <template #label><SearchLabel>{{ i18n.ts.instanceTicker }}</SearchLabel></template> - <option value="none">{{ i18n.ts._instanceTicker.none }}</option> - <option value="remote">{{ i18n.ts._instanceTicker.remote }}</option> - <option value="always">{{ i18n.ts._instanceTicker.always }}</option> - </MkSelect> - </SearchMarker> - - <SearchMarker :keywords="['attachment', 'image', 'photo', 'picture', 'media', 'thumbnail', 'nsfw', 'sensitive', 'display', 'show', 'hide', 'visibility']"> - <MkSelect v-model="nsfw"> - <template #label><SearchLabel>{{ i18n.ts.displayOfSensitiveMedia }}</SearchLabel></template> - <option value="respect">{{ i18n.ts._displayOfSensitiveMedia.respect }}</option> - <option value="ignore">{{ i18n.ts._displayOfSensitiveMedia.ignore }}</option> - <option value="force">{{ i18n.ts._displayOfSensitiveMedia.force }}</option> - </MkSelect> - </SearchMarker> - </div> - </FormSection> - </SearchMarker> - - <SearchMarker :keywords="['notification', 'display']"> - <FormSection> - <template #label><SearchLabel>{{ i18n.ts.notificationDisplay }}</SearchLabel></template> - - <div class="_gaps_m"> - <SearchMarker :keywords="['position']"> - <MkRadios v-model="notificationPosition"> - <template #label><SearchLabel>{{ i18n.ts.position }}</SearchLabel></template> - <option value="leftTop"><i class="ti ti-align-box-left-top"></i> {{ i18n.ts.leftTop }}</option> - <option value="rightTop"><i class="ti ti-align-box-right-top"></i> {{ i18n.ts.rightTop }}</option> - <option value="leftBottom"><i class="ti ti-align-box-left-bottom"></i> {{ i18n.ts.leftBottom }}</option> - <option value="rightBottom"><i class="ti ti-align-box-right-bottom"></i> {{ i18n.ts.rightBottom }}</option> - </MkRadios> - </SearchMarker> - - <SearchMarker :keywords="['stack', 'axis', 'direction']"> - <MkRadios v-model="notificationStackAxis"> - <template #label><SearchLabel>{{ i18n.ts.stackAxis }}</SearchLabel></template> - <option value="vertical"><i class="ti ti-carousel-vertical"></i> {{ i18n.ts.vertical }}</option> - <option value="horizontal"><i class="ti ti-carousel-horizontal"></i> {{ i18n.ts.horizontal }}</option> - </MkRadios> - </SearchMarker> - - <MkButton @click="testNotification">{{ i18n.ts._notification.checkNotificationBehavior }}</MkButton> - </div> - </FormSection> - </SearchMarker> - - <FormSection> - <FormLink to="/settings/custom-css"><template #icon><i class="ti ti-code"></i></template>{{ i18n.ts.customCss }}</FormLink> - </FormSection> - </div> -</SearchMarker> -</template> - -<script lang="ts" setup> -import { computed, ref, watch } from 'vue'; -import * as Misskey from 'misskey-js'; -import MkSwitch from '@/components/MkSwitch.vue'; -import MkSelect from '@/components/MkSelect.vue'; -import MkRadios from '@/components/MkRadios.vue'; -import { defaultStore } from '@/store.js'; -import { reloadAsk } from '@/scripts/reload-ask.js'; -import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; -import { miLocalStorage } from '@/local-storage.js'; -import FormLink from '@/components/form/link.vue'; -import { globalEvents } from '@/events.js'; -import { claimAchievement } from '@/scripts/achievements.js'; -import MkButton from '@/components/MkButton.vue'; -import FormSection from '@/components/form/section.vue'; -import { instance } from '@/instance.js'; - -const fontSize = ref(miLocalStorage.getItem('fontSize')); -const useSystemFont = ref(miLocalStorage.getItem('useSystemFont') != null); - -const showAvatarDecorations = computed(defaultStore.makeGetterSetter('showAvatarDecorations')); -const emojiStyle = computed(defaultStore.makeGetterSetter('emojiStyle')); -const menuStyle = computed(defaultStore.makeGetterSetter('menuStyle')); -const useBlurEffectForModal = computed(defaultStore.makeGetterSetter('useBlurEffectForModal')); -const useBlurEffect = computed(defaultStore.makeGetterSetter('useBlurEffect')); -const highlightSensitiveMedia = computed(defaultStore.makeGetterSetter('highlightSensitiveMedia')); -const squareAvatars = computed(defaultStore.makeGetterSetter('squareAvatars')); -const enableSeasonalScreenEffect = computed(defaultStore.makeGetterSetter('enableSeasonalScreenEffect')); -const showGapBetweenNotesInTimeline = computed(defaultStore.makeGetterSetter('showGapBetweenNotesInTimeline')); -const mediaListWithOneImageAppearance = computed(defaultStore.makeGetterSetter('mediaListWithOneImageAppearance')); -const reactionsDisplaySize = computed(defaultStore.makeGetterSetter('reactionsDisplaySize')); -const limitWidthOfReaction = computed(defaultStore.makeGetterSetter('limitWidthOfReaction')); -const notificationPosition = computed(defaultStore.makeGetterSetter('notificationPosition')); -const notificationStackAxis = computed(defaultStore.makeGetterSetter('notificationStackAxis')); -const nsfw = computed(defaultStore.makeGetterSetter('nsfw')); -const instanceTicker = computed(defaultStore.makeGetterSetter('instanceTicker')); - -watch(fontSize, () => { - if (fontSize.value == null) { - miLocalStorage.removeItem('fontSize'); - } else { - miLocalStorage.setItem('fontSize', fontSize.value); - } -}); - -watch(useSystemFont, () => { - if (useSystemFont.value) { - miLocalStorage.setItem('useSystemFont', 't'); - } else { - miLocalStorage.removeItem('useSystemFont'); - } -}); - -watch([ - fontSize, - useSystemFont, - squareAvatars, - highlightSensitiveMedia, - enableSeasonalScreenEffect, - showGapBetweenNotesInTimeline, - mediaListWithOneImageAppearance, - reactionsDisplaySize, - limitWidthOfReaction, - mediaListWithOneImageAppearance, - reactionsDisplaySize, - limitWidthOfReaction, - instanceTicker, -], async () => { - await reloadAsk({ reason: i18n.ts.reloadToApplySetting, unison: true }); -}); - -let smashCount = 0; -let smashTimer: number | null = null; - -function testNotification(): void { - const notification: Misskey.entities.Notification = { - id: Math.random().toString(), - createdAt: new Date().toUTCString(), - isRead: false, - type: 'test', - }; - - globalEvents.emit('clientNotification', notification); - - // セルフ通知破壊 実績関連 - smashCount++; - if (smashCount >= 10) { - claimAchievement('smashTestNotificationButton'); - smashCount = 0; - } - if (smashTimer) { - clearTimeout(smashTimer); - } - smashTimer = window.setTimeout(() => { - smashCount = 0; - }, 300); -} - -const headerActions = computed(() => []); - -const headerTabs = computed(() => []); - -definePageMetadata(() => ({ - title: i18n.ts.appearance, - icon: 'ti ti-device-desktop', -})); -</script> diff --git a/packages/frontend/src/pages/settings/apps.vue b/packages/frontend/src/pages/settings/apps.vue index 6515503505..c72179b9a1 100644 --- a/packages/frontend/src/pages/settings/apps.vue +++ b/packages/frontend/src/pages/settings/apps.vue @@ -8,7 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only <FormPagination ref="list" :pagination="pagination"> <template #empty> <div class="_fullinfo"> - <img :src="infoImageUrl" class="_ghost"/> + <img :src="infoImageUrl" draggable="false"/> <div>{{ i18n.ts.nothing }}</div> </div> </template> @@ -57,9 +57,9 @@ SPDX-License-Identifier: AGPL-3.0-only import { ref, computed } from 'vue'; import * as Misskey from 'misskey-js'; import FormPagination from '@/components/MkPagination.vue'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; import MkKeyValue from '@/components/MkKeyValue.vue'; import MkButton from '@/components/MkButton.vue'; import MkFolder from '@/components/MkFolder.vue'; @@ -86,7 +86,7 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.installedApps, icon: 'ti ti-plug', })); diff --git a/packages/frontend/src/pages/settings/avatar-decoration.decoration.vue b/packages/frontend/src/pages/settings/avatar-decoration.decoration.vue index 3c9914b4e2..44b1556d28 100644 --- a/packages/frontend/src/pages/settings/avatar-decoration.decoration.vue +++ b/packages/frontend/src/pages/settings/avatar-decoration.decoration.vue @@ -16,9 +16,9 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { computed } from 'vue'; -import { signinRequired } from '@/account.js'; +import { ensureSignin } from '@/i.js'; -const $i = signinRequired(); +const $i = ensureSignin(); const props = defineProps<{ active?: boolean; diff --git a/packages/frontend/src/pages/settings/avatar-decoration.dialog.vue b/packages/frontend/src/pages/settings/avatar-decoration.dialog.vue index 40542ad5b2..0f02d95d71 100644 --- a/packages/frontend/src/pages/settings/avatar-decoration.dialog.vue +++ b/packages/frontend/src/pages/settings/avatar-decoration.dialog.vue @@ -45,15 +45,15 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { shallowRef, ref, computed } from 'vue'; +import { useTemplateRef, ref, computed } from 'vue'; import MkButton from '@/components/MkButton.vue'; import MkModalWindow from '@/components/MkModalWindow.vue'; import MkSwitch from '@/components/MkSwitch.vue'; import { i18n } from '@/i18n.js'; import MkRange from '@/components/MkRange.vue'; -import { signinRequired } from '@/account.js'; +import { ensureSignin } from '@/i.js'; -const $i = signinRequired(); +const $i = ensureSignin(); const props = defineProps<{ usingIndex: number | null; @@ -82,7 +82,7 @@ const emit = defineEmits<{ (ev: 'detach'): void; }>(); -const dialog = shallowRef<InstanceType<typeof MkModalWindow>>(); +const dialog = useTemplateRef('dialog'); const exceeded = computed(() => ($i.policies.avatarDecorationLimit - $i.avatarDecorations.length) <= 0); const locked = computed(() => props.decoration.roleIdsThatCanBeUsedThisDecoration.length > 0 && !$i.roles.some(r => props.decoration.roleIdsThatCanBeUsedThisDecoration.includes(r.id))); const angle = ref((props.usingIndex != null ? $i.avatarDecorations[props.usingIndex].angle : null) ?? 0); diff --git a/packages/frontend/src/pages/settings/avatar-decoration.vue b/packages/frontend/src/pages/settings/avatar-decoration.vue index 79be2b9b1e..14c3a03d2b 100644 --- a/packages/frontend/src/pages/settings/avatar-decoration.vue +++ b/packages/frontend/src/pages/settings/avatar-decoration.vue @@ -52,13 +52,13 @@ import * as Misskey from 'misskey-js'; import XDecoration from './avatar-decoration.decoration.vue'; import MkButton from '@/components/MkButton.vue'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; -import { signinRequired } from '@/account.js'; +import { ensureSignin } from '@/i.js'; import MkInfo from '@/components/MkInfo.vue'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; -const $i = signinRequired(); +const $i = ensureSignin(); const loading = ref(true); const avatarDecorations = ref<Misskey.entities.GetAvatarDecorationsResponse>([]); @@ -131,7 +131,7 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.avatarDecorations, icon: 'ti ti-sparkles', })); diff --git a/packages/frontend/src/pages/settings/connect.vue b/packages/frontend/src/pages/settings/connect.vue new file mode 100644 index 0000000000..280ee546dc --- /dev/null +++ b/packages/frontend/src/pages/settings/connect.vue @@ -0,0 +1,112 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<SearchMarker path="/settings/connect" :label="i18n.ts._settings.serviceConnection" :keywords="['app', 'service', 'connect', 'webhook', 'api', 'token']" icon="ti ti-link"> + <div class="_gaps_m"> + <MkFeatureBanner icon="/client-assets/link_3d.png" color="#ff0088"> + <SearchKeyword>{{ i18n.ts._settings.serviceConnectionBanner }}</SearchKeyword> + </MkFeatureBanner> + + <SearchMarker :keywords="['api', 'app', 'token', 'accessToken']"> + <FormSection> + <template #label><i class="ti ti-api"></i> <SearchLabel>{{ i18n.ts._settings.api }}</SearchLabel></template> + + <div class="_gaps_m"> + <MkButton primary @click="generateToken">{{ i18n.ts.generateAccessToken }}</MkButton> + <FormLink to="/settings/apps">{{ i18n.ts.manageAccessTokens }}</FormLink> + <FormLink to="/api-console" :behavior="isDesktop ? 'window' : null">API console</FormLink> + </div> + </FormSection> + </SearchMarker> + + <SearchMarker :keywords="['webhook']"> + <FormSection> + <template #label><i class="ti ti-webhook"></i> <SearchLabel>{{ i18n.ts._settings.webhook }}</SearchLabel></template> + + <div class="_gaps_m"> + <FormLink :to="`/settings/webhook/new`"> + {{ i18n.ts._webhookSettings.createWebhook }} + </FormLink> + + <MkFolder :defaultOpen="true"> + <template #label>{{ i18n.ts.manage }}</template> + + <MkPagination :pagination="pagination"> + <template #default="{items}"> + <div class="_gaps"> + <FormLink v-for="webhook in items" :key="webhook.id" :to="`/settings/webhook/edit/${webhook.id}`"> + <template #icon> + <i v-if="webhook.active === false" class="ti ti-player-pause"></i> + <i v-else-if="webhook.latestStatus === null" class="ti ti-circle"></i> + <i v-else-if="[200, 201, 204].includes(webhook.latestStatus)" class="ti ti-check" :style="{ color: 'var(--MI_THEME-success)' }"></i> + <i v-else class="ti ti-alert-triangle" :style="{ color: 'var(--MI_THEME-error)' }"></i> + </template> + {{ webhook.name || webhook.url }} + <template #suffix> + <MkTime v-if="webhook.latestSentAt" :time="webhook.latestSentAt"></MkTime> + </template> + </FormLink> + </div> + </template> + </MkPagination> + </MkFolder> + </div> + </FormSection> + </SearchMarker> + </div> +</SearchMarker> +</template> + +<script lang="ts" setup> +import { computed, ref, defineAsyncComponent } from 'vue'; +import MkPagination from '@/components/MkPagination.vue'; +import FormSection from '@/components/form/section.vue'; +import FormLink from '@/components/form/link.vue'; +import { definePage } from '@/page.js'; +import { i18n } from '@/i18n.js'; +import MkFeatureBanner from '@/components/MkFeatureBanner.vue'; +import * as os from '@/os.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import MkButton from '@/components/MkButton.vue'; +import MkFolder from '@/components/MkFolder.vue'; + +const isDesktop = ref(window.innerWidth >= 1100); + +const pagination = { + endpoint: 'i/webhooks/list' as const, + limit: 100, + noPaging: true, +}; + +function generateToken() { + const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkTokenGenerateWindow.vue')), {}, { + done: async result => { + const { name, permissions } = result; + const { token } = await misskeyApi('miauth/gen-token', { + session: null, + name: name, + permission: permissions, + }); + + os.alert({ + type: 'success', + title: i18n.ts.token, + text: token, + }); + }, + closed: () => dispose(), + }); +} + +const headerActions = computed(() => []); + +const headerTabs = computed(() => []); + +definePage(() => ({ + title: i18n.ts._settings.serviceConnection, + icon: 'ti ti-link', +})); +</script> diff --git a/packages/frontend/src/pages/settings/custom-css.vue b/packages/frontend/src/pages/settings/custom-css.vue index cf05e75acc..9b0e04860e 100644 --- a/packages/frontend/src/pages/settings/custom-css.vue +++ b/packages/frontend/src/pages/settings/custom-css.vue @@ -18,9 +18,9 @@ import { ref, watch, computed } from 'vue'; import MkCodeEditor from '@/components/MkCodeEditor.vue'; import FormInfo from '@/components/MkInfo.vue'; import * as os from '@/os.js'; -import { unisonReload } from '@/scripts/unison-reload.js'; +import { unisonReload } from '@/utility/unison-reload.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; import { miLocalStorage } from '@/local-storage.js'; const localCustomCss = ref(miLocalStorage.getItem('customCss') ?? ''); @@ -45,7 +45,7 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.customCss, icon: 'ti ti-code', })); diff --git a/packages/frontend/src/pages/settings/deck.vue b/packages/frontend/src/pages/settings/deck.vue index e574ec7dc0..39055268d4 100644 --- a/packages/frontend/src/pages/settings/deck.vue +++ b/packages/frontend/src/pages/settings/deck.vue @@ -4,39 +4,140 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div class="_gaps_m"> - <MkSwitch v-model="useSimpleUiForNonRootPages">{{ i18n.ts._deck.useSimpleUiForNonRootPages }}</MkSwitch> +<SearchMarker path="/settings/deck" :label="i18n.ts.deck" :keywords="['deck', 'ui']" icon="ti ti-columns"> + <div class="_gaps_m"> + <SearchMarker :keywords="['sync', 'profiles', 'devices']"> + <MkSwitch :modelValue="profilesSyncEnabled" @update:modelValue="changeProfilesSyncEnabled"> + <template #label><i class="ti ti-cloud-cog"></i> <SearchLabel>{{ i18n.ts._deck.enableSyncBetweenDevicesForProfiles }}</SearchLabel></template> + </MkSwitch> + </SearchMarker> - <MkSwitch v-model="navWindow">{{ i18n.ts.defaultNavigationBehaviour }}: {{ i18n.ts.openInWindow }}</MkSwitch> + <hr> - <MkSwitch v-model="alwaysShowMainColumn">{{ i18n.ts._deck.alwaysShowMainColumn }}</MkSwitch> + <SearchMarker :keywords="['ui', 'root', 'page']"> + <MkPreferenceContainer k="deck.useSimpleUiForNonRootPages"> + <MkSwitch v-model="useSimpleUiForNonRootPages"> + <template #label><SearchLabel>{{ i18n.ts._deck.useSimpleUiForNonRootPages }}</SearchLabel></template> + </MkSwitch> + </MkPreferenceContainer> + </SearchMarker> - <MkRadios v-model="columnAlign"> - <template #label>{{ i18n.ts._deck.columnAlign }}</template> - <option value="left">{{ i18n.ts.left }}</option> - <option value="center">{{ i18n.ts.center }}</option> - </MkRadios> -</div> + <SearchMarker :keywords="['default', 'navigation', 'behaviour', 'window']"> + <MkPreferenceContainer k="deck.navWindow"> + <MkSwitch v-model="navWindow"> + <template #label><SearchLabel>{{ i18n.ts.defaultNavigationBehaviour }}</SearchLabel>: {{ i18n.ts.openInWindow }}</template> + </MkSwitch> + </MkPreferenceContainer> + </SearchMarker> + + <SearchMarker :keywords="['always', 'show', 'main', 'column']"> + <MkPreferenceContainer k="deck.alwaysShowMainColumn"> + <MkSwitch v-model="alwaysShowMainColumn"> + <template #label><SearchLabel>{{ i18n.ts._deck.alwaysShowMainColumn }}</SearchLabel></template> + </MkSwitch> + </MkPreferenceContainer> + </SearchMarker> + + <SearchMarker :keywords="['column', 'align']"> + <MkPreferenceContainer k="deck.columnAlign"> + <MkRadios v-model="columnAlign"> + <template #label><SearchLabel>{{ i18n.ts._deck.columnAlign }}</SearchLabel></template> + <option value="left">{{ i18n.ts.left }}</option> + <option value="center">{{ i18n.ts.center }}</option> + </MkRadios> + </MkPreferenceContainer> + </SearchMarker> + + <SearchMarker :keywords="['menu', 'position']"> + <MkPreferenceContainer k="deck.menuPosition"> + <MkRadios v-model="menuPosition"> + <template #label><SearchLabel>{{ i18n.ts._deck.deckMenuPosition }}</SearchLabel></template> + <option value="right">{{ i18n.ts.right }}</option> + <option value="bottom">{{ i18n.ts.bottom }}</option> + </MkRadios> + </MkPreferenceContainer> + </SearchMarker> + + <SearchMarker :keywords="['navbar', 'position']"> + <MkPreferenceContainer k="deck.navbarPosition"> + <MkRadios v-model="navbarPosition"> + <template #label><SearchLabel>{{ i18n.ts._deck.navbarPosition }}</SearchLabel></template> + <option value="left">{{ i18n.ts.left }}</option> + <option value="top">{{ i18n.ts.top }}</option> + <option value="bottom">{{ i18n.ts.bottom }}</option> + </MkRadios> + </MkPreferenceContainer> + </SearchMarker> + + <SearchMarker :keywords="['column', 'gap', 'margin']"> + <MkPreferenceContainer k="deck.columnGap"> + <MkRange v-model="columnGap" :min="3" :max="100" :step="1" :continuousUpdate="true"> + <template #label><SearchLabel>{{ i18n.ts._deck.columnGap }}</SearchLabel></template> + </MkRange> + </MkPreferenceContainer> + </SearchMarker> + + <SearchMarker :keywords="['wallpaper']"> + <MkPreferenceContainer k="deck.wallpaper"> + <MkButton v-if="wallpaper == null" @click="setWallpaper"><SearchLabel>{{ i18n.ts.setWallpaper }}</SearchLabel></MkButton> + <MkButton v-else @click="wallpaper = null">{{ i18n.ts.removeWallpaper }}</MkButton> + </MkPreferenceContainer> + </SearchMarker> + </div> +</SearchMarker> </template> <script lang="ts" setup> -import { computed } from 'vue'; +import { computed, ref, watch } from 'vue'; import MkSwitch from '@/components/MkSwitch.vue'; import MkRadios from '@/components/MkRadios.vue'; -import { deckStore } from '@/ui/deck/deck-store.js'; +import MkRange from '@/components/MkRange.vue'; +import MkButton from '@/components/MkButton.vue'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; +import { prefer } from '@/preferences.js'; +import MkPreferenceContainer from '@/components/MkPreferenceContainer.vue'; +import { reloadAsk } from '@/utility/reload-ask.js'; +import { selectFile } from '@/utility/select-file.js'; + +const navWindow = prefer.model('deck.navWindow'); +const useSimpleUiForNonRootPages = prefer.model('deck.useSimpleUiForNonRootPages'); +const alwaysShowMainColumn = prefer.model('deck.alwaysShowMainColumn'); +const columnAlign = prefer.model('deck.columnAlign'); +const columnGap = prefer.model('deck.columnGap'); +const menuPosition = prefer.model('deck.menuPosition'); +const navbarPosition = prefer.model('deck.navbarPosition'); +const wallpaper = prefer.model('deck.wallpaper'); + +watch(wallpaper, async () => { + await reloadAsk({ reason: i18n.ts.reloadToApplySetting, unison: true }); +}); + +function setWallpaper(ev: MouseEvent) { + selectFile(ev.currentTarget ?? ev.target, null).then(file => { + wallpaper.value = file.url; + }); +} + +const profilesSyncEnabled = ref(prefer.isSyncEnabled('deck.profiles')); -const navWindow = computed(deckStore.makeGetterSetter('navWindow')); -const useSimpleUiForNonRootPages = computed(deckStore.makeGetterSetter('useSimpleUiForNonRootPages')); -const alwaysShowMainColumn = computed(deckStore.makeGetterSetter('alwaysShowMainColumn')); -const columnAlign = computed(deckStore.makeGetterSetter('columnAlign')); +function changeProfilesSyncEnabled(value: boolean) { + if (value) { + prefer.enableSync('deck.profiles').then((res) => { + if (res == null) return; + if (res.enabled) profilesSyncEnabled.value = true; + }); + } else { + prefer.disableSync('deck.profiles'); + profilesSyncEnabled.value = false; + } +} const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.deck, icon: 'ti ti-columns', })); diff --git a/packages/frontend/src/pages/settings/drive-cleaner.vue b/packages/frontend/src/pages/settings/drive-cleaner.vue index c5657fce68..6b73560174 100644 --- a/packages/frontend/src/pages/settings/drive-cleaner.vue +++ b/packages/frontend/src/pages/settings/drive-cleaner.vue @@ -52,14 +52,14 @@ import { computed, ref, watch } from 'vue'; import type { StyleValue } from 'vue'; import tinycolor from 'tinycolor2'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import MkPagination from '@/components/MkPagination.vue'; import MkDriveFileThumbnail from '@/components/MkDriveFileThumbnail.vue'; import { i18n } from '@/i18n.js'; import bytes from '@/filters/bytes.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; import MkSelect from '@/components/MkSelect.vue'; -import { getDriveFileMenu } from '@/scripts/get-drive-file-menu.js'; +import { getDriveFileMenu } from '@/utility/get-drive-file-menu.js'; const sortMode = ref('+size'); const pagination = { @@ -118,7 +118,7 @@ function onContextMenu(ev: MouseEvent, file): void { os.contextMenu(getDriveFileMenu(file), ev); } -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.drivecleaner, icon: 'ti ti-trash', })); diff --git a/packages/frontend/src/pages/settings/drive.vue b/packages/frontend/src/pages/settings/drive.vue index 0138aac1c5..2130cbc868 100644 --- a/packages/frontend/src/pages/settings/drive.vue +++ b/packages/frontend/src/pages/settings/drive.vue @@ -6,6 +6,10 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <SearchMarker path="/settings/drive" :label="i18n.ts.drive" :keywords="['drive']" icon="ti ti-cloud"> <div class="_gaps_m"> + <MkFeatureBanner icon="/client-assets/cloud_3d.png" color="#0059ff"> + <SearchKeyword>{{ i18n.ts._settings.driveBanner }}</SearchKeyword> + </MkFeatureBanner> + <SearchMarker :keywords="['capacity', 'usage']"> <FormSection first> <template #label><SearchLabel>{{ i18n.ts.usageAmount }}</SearchLabel></template> @@ -49,18 +53,13 @@ SPDX-License-Identifier: AGPL-3.0-only {{ i18n.ts.drivecleaner }} </FormLink> - <SearchMarker :keywords="['keep', 'original', 'raw', 'upload']"> - <MkSwitch v-model="keepOriginalUploading"> - <template #label><SearchLabel>{{ i18n.ts.keepOriginalUploading }}</SearchLabel></template> - <template #caption><SearchKeyword>{{ i18n.ts.keepOriginalUploadingDescription }}</SearchKeyword></template> - </MkSwitch> - </SearchMarker> - <SearchMarker :keywords="['keep', 'original', 'filename']"> - <MkSwitch v-model="keepOriginalFilename"> - <template #label><SearchLabel>{{ i18n.ts.keepOriginalFilename }}</SearchLabel></template> - <template #caption><SearchKeyword>{{ i18n.ts.keepOriginalFilenameDescription }}</SearchKeyword></template> - </MkSwitch> + <MkPreferenceContainer k="keepOriginalFilename"> + <MkSwitch v-model="keepOriginalFilename"> + <template #label><SearchLabel>{{ i18n.ts.keepOriginalFilename }}</SearchLabel></template> + <template #caption><SearchKeyword>{{ i18n.ts.keepOriginalFilenameDescription }}</SearchKeyword></template> + </MkSwitch> + </MkPreferenceContainer> </SearchMarker> <SearchMarker :keywords="['always', 'default', 'mark', 'nsfw', 'sensitive', 'media', 'file']"> @@ -91,15 +90,17 @@ import FormSection from '@/components/form/section.vue'; import MkKeyValue from '@/components/MkKeyValue.vue'; import FormSplit from '@/components/form/split.vue'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import bytes from '@/filters/bytes.js'; -import { defaultStore } from '@/store.js'; import MkChart from '@/components/MkChart.vue'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; -import { signinRequired } from '@/account.js'; +import { definePage } from '@/page.js'; +import { ensureSignin } from '@/i.js'; +import { prefer } from '@/preferences.js'; +import MkPreferenceContainer from '@/components/MkPreferenceContainer.vue'; +import MkFeatureBanner from '@/components/MkFeatureBanner.vue'; -const $i = signinRequired(); +const $i = ensureSignin(); const fetching = ref(true); const usage = ref<number | null>(null); @@ -120,8 +121,7 @@ const meterStyle = computed(() => { }; }); -const keepOriginalUploading = computed(defaultStore.makeGetterSetter('keepOriginalUploading')); -const keepOriginalFilename = computed(defaultStore.makeGetterSetter('keepOriginalFilename')); +const keepOriginalFilename = prefer.model('keepOriginalFilename'); misskeyApi('drive').then(info => { capacity.value = info.capacity; @@ -129,9 +129,9 @@ misskeyApi('drive').then(info => { fetching.value = false; }); -if (defaultStore.state.uploadFolder) { +if (prefer.s.uploadFolder) { misskeyApi('drive/folders/show', { - folderId: defaultStore.state.uploadFolder, + folderId: prefer.s.uploadFolder, }).then(response => { uploadFolder.value = response; }); @@ -139,11 +139,11 @@ if (defaultStore.state.uploadFolder) { function chooseUploadFolder() { os.selectDriveFolder(false).then(async folder => { - defaultStore.set('uploadFolder', folder[0] ? folder[0].id : null); + prefer.commit('uploadFolder', folder[0] ? folder[0].id : null); os.success(); - if (defaultStore.state.uploadFolder) { + if (prefer.s.uploadFolder) { uploadFolder.value = await misskeyApi('drive/folders/show', { - folderId: defaultStore.state.uploadFolder, + folderId: prefer.s.uploadFolder, }); } else { uploadFolder.value = null; @@ -169,7 +169,7 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.drive, icon: 'ti ti-cloud', })); diff --git a/packages/frontend/src/pages/settings/email.vue b/packages/frontend/src/pages/settings/email.vue index e7a8fc5634..fb8f51041e 100644 --- a/packages/frontend/src/pages/settings/email.vue +++ b/packages/frontend/src/pages/settings/email.vue @@ -66,13 +66,13 @@ import MkInput from '@/components/MkInput.vue'; import MkSwitch from '@/components/MkSwitch.vue'; import MkDisableSection from '@/components/MkDisableSection.vue'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { signinRequired } from '@/account.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { ensureSignin } from '@/i.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; import { instance } from '@/instance.js'; -const $i = signinRequired(); +const $i = ensureSignin(); const emailAddress = ref($i.email); @@ -125,7 +125,7 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.email, icon: 'ti ti-mail', })); diff --git a/packages/frontend/src/pages/settings/emoji-palette.palette.vue b/packages/frontend/src/pages/settings/emoji-palette.palette.vue new file mode 100644 index 0000000000..b624d424f3 --- /dev/null +++ b/packages/frontend/src/pages/settings/emoji-palette.palette.vue @@ -0,0 +1,166 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<MkFolder :defaultOpen="true"> + <template #icon><i class="ti ti-palette"></i></template> + <template #label>{{ palette.name === '' ? '(' + i18n.ts.noName + ')' : palette.name }}</template> + <template #footer> + <div class="_buttons"> + <MkButton @click="rename"><i class="ti ti-pencil"></i> {{ i18n.ts.rename }}</MkButton> + <MkButton @click="copy"><i class="ti ti-copy"></i> {{ i18n.ts.copy }}</MkButton> + <MkButton danger @click="paste"><i class="ti ti-clipboard"></i> {{ i18n.ts.paste }}</MkButton> + <MkButton danger iconOnly style="margin-left: auto;" @click="del"><i class="ti ti-trash"></i></MkButton> + </div> + </template> + + <div> + <div v-panel style="border-radius: 6px;"> + <Sortable + v-model="emojis" + :class="$style.emojis" + :itemKey="item => item" + :animation="150" + :delay="100" + :delayOnTouchOnly="true" + :group="{ name: 'SortableEmojiPalettes' }" + > + <template #item="{element}"> + <button class="_button" :class="$style.emojisItem" @click="remove(element, $event)"> + <MkCustomEmoji v-if="element[0] === ':'" :name="element" :normal="true" :fallbackToImage="true"/> + <MkEmoji v-else :emoji="element" :normal="true"/> + </button> + </template> + <template #footer> + <button class="_button" :class="$style.emojisAdd" @click="pick"> + <i class="ti ti-plus"></i> + </button> + </template> + </Sortable> + </div> + <div :class="$style.editorCaption">{{ i18n.ts.reactionSettingDescription2 }}</div> + </div> +</MkFolder> +</template> + +<script lang="ts" setup> +import { ref, watch } from 'vue'; +import Sortable from 'vuedraggable'; +import MkButton from '@/components/MkButton.vue'; +import * as os from '@/os.js'; +import { i18n } from '@/i18n.js'; +import { deepClone } from '@/utility/clone.js'; +import MkCustomEmoji from '@/components/global/MkCustomEmoji.vue'; +import MkEmoji from '@/components/global/MkEmoji.vue'; +import MkFolder from '@/components/MkFolder.vue'; +import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; + +const props = defineProps<{ + palette: { + id: string; + name: string; + emojis: string[]; + }; +}>(); + +const emit = defineEmits<{ + (ev: 'updateEmojis', emojis: string[]): void, + (ev: 'updateName', name: string): void, + (ev: 'del'): void, +}>(); + +const emojis = ref<string[]>(deepClone(props.palette.emojis)); + +watch(emojis, () => { + emit('updateEmojis', emojis.value); +}, { deep: true }); + +function remove(reaction: string, ev: MouseEvent) { + os.popupMenu([{ + text: i18n.ts.remove, + action: () => { + emojis.value = emojis.value.filter(x => x !== reaction); + }, + }], getHTMLElement(ev)); +} + +function pick(ev: MouseEvent) { + os.pickEmoji(getHTMLElement(ev), { + showPinned: false, + }).then(it => { + const emoji = it; + if (!emojis.value.includes(emoji)) { + emojis.value.push(emoji); + } + }); +} + +function getHTMLElement(ev: MouseEvent): HTMLElement { + const target = ev.currentTarget ?? ev.target; + return target as HTMLElement; +} + +function rename() { + os.inputText({ + title: i18n.ts.rename, + default: props.palette.name, + }).then(({ canceled, result: name }) => { + if (canceled) return; + if (name != null) { + emit('updateName', name); + } + }); +} + +function copy() { + copyToClipboard(emojis.value.join(' ')); +} + +function paste() { + // TODO: validate + navigator.clipboard.readText().then(text => { + emojis.value = text.split(' '); + }); +} + +function del(ev: MouseEvent) { + os.popupMenu([{ + text: i18n.ts.delete, + action: () => { + emit('del'); + }, + }], ev.currentTarget ?? ev.target); +} +</script> + +<style lang="scss" module> +.tab { + margin: calc(var(--MI-margin) / 2) 0; + padding: calc(var(--MI-margin) / 2) 0; + background: var(--MI_THEME-bg); +} + +.emojis { + padding: 12px; + font-size: 1.1em; +} + +.emojisItem { + display: inline-block; + padding: 8px; + cursor: move; +} + +.emojisAdd { + display: inline-block; + padding: 8px; +} + +.editorCaption { + font-size: 0.85em; + padding: 8px 0 0 0; + color: color(from var(--MI_THEME-fg) srgb r g b / 0.75); +} +</style> diff --git a/packages/frontend/src/pages/settings/emoji-palette.vue b/packages/frontend/src/pages/settings/emoji-palette.vue new file mode 100644 index 0000000000..2fcecca2bd --- /dev/null +++ b/packages/frontend/src/pages/settings/emoji-palette.vue @@ -0,0 +1,251 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<SearchMarker path="/settings/emoji-palette" :label="i18n.ts.emojiPalette" :keywords="['emoji', 'palette']" icon="ti ti-mood-happy"> + <div class="_gaps_m"> + <FormSection first> + <template #label>{{ i18n.ts._emojiPalette.palettes }}</template> + + <div class="_gaps_s"> + <XPalette + v-for="palette in prefer.r.emojiPalettes.value" + :key="palette.id" + :palette="palette" + @updateEmojis="emojis => updatePaletteEmojis(palette.id, emojis)" + @updateName="name => updatePaletteName(palette.id, name)" + @del="delPalette(palette.id)" + /> + <MkButton primary rounded style="margin: auto;" @click="addPalette"><i class="ti ti-plus"></i></MkButton> + </div> + </FormSection> + + <FormSection> + <div class="_gaps_m"> + <SearchMarker :keywords="['sync', 'palettes', 'devices']"> + <MkSwitch :modelValue="palettesSyncEnabled" @update:modelValue="changePalettesSyncEnabled"> + <template #label><i class="ti ti-cloud-cog"></i> <SearchLabel>{{ i18n.ts._emojiPalette.enableSyncBetweenDevicesForPalettes }}</SearchLabel></template> + </MkSwitch> + </SearchMarker> + </div> + </FormSection> + + <FormSection> + <div class="_gaps_m"> + <SearchMarker :keywords="['main', 'palette']"> + <MkPreferenceContainer k="emojiPaletteForMain"> + <MkSelect v-model="emojiPaletteForMain"> + <template #label><SearchLabel>{{ i18n.ts._emojiPalette.paletteForMain }}</SearchLabel></template> + <option key="-" :value="null">({{ i18n.ts.auto }})</option> + <option v-for="palette in prefer.r.emojiPalettes.value" :key="palette.id" :value="palette.id">{{ palette.name === '' ? '(' + i18n.ts.noName + ')' : palette.name }}</option> + </MkSelect> + </MkPreferenceContainer> + </SearchMarker> + + <SearchMarker :keywords="['reaction', 'palette']"> + <MkPreferenceContainer k="emojiPaletteForReaction"> + <MkSelect v-model="emojiPaletteForReaction"> + <template #label><SearchLabel>{{ i18n.ts._emojiPalette.paletteForReaction }}</SearchLabel></template> + <option key="-" :value="null">({{ i18n.ts.auto }})</option> + <option v-for="palette in prefer.r.emojiPalettes.value" :key="palette.id" :value="palette.id">{{ palette.name === '' ? '(' + i18n.ts.noName + ')' : palette.name }}</option> + </MkSelect> + </MkPreferenceContainer> + </SearchMarker> + </div> + </FormSection> + + <SearchMarker :keywords="['emoji', 'picker', 'display']"> + <FormSection> + <template #label><SearchLabel>{{ i18n.ts.emojiPickerDisplay }}</SearchLabel></template> + + <div class="_gaps_m"> + <SearchMarker :keywords="['emoji', 'picker', 'scale', 'size']"> + <MkPreferenceContainer k="emojiPickerScale"> + <MkRadios v-model="emojiPickerScale"> + <template #label><SearchLabel>{{ i18n.ts.size }}</SearchLabel></template> + <option :value="1">{{ i18n.ts.small }}</option> + <option :value="2">{{ i18n.ts.medium }}</option> + <option :value="3">{{ i18n.ts.large }}</option> + </MkRadios> + </MkPreferenceContainer> + </SearchMarker> + + <SearchMarker :keywords="['emoji', 'picker', 'width', 'column', 'size']"> + <MkPreferenceContainer k="emojiPickerWidth"> + <MkRadios v-model="emojiPickerWidth"> + <template #label><SearchLabel>{{ i18n.ts.numberOfColumn }}</SearchLabel></template> + <option :value="1">5</option> + <option :value="2">6</option> + <option :value="3">7</option> + <option :value="4">8</option> + <option :value="5">9</option> + </MkRadios> + </MkPreferenceContainer> + </SearchMarker> + + <SearchMarker :keywords="['emoji', 'picker', 'height', 'size']"> + <MkPreferenceContainer k="emojiPickerHeight"> + <MkRadios v-model="emojiPickerHeight"> + <template #label><SearchLabel>{{ i18n.ts.height }}</SearchLabel></template> + <option :value="1">{{ i18n.ts.small }}</option> + <option :value="2">{{ i18n.ts.medium }}</option> + <option :value="3">{{ i18n.ts.large }}</option> + <option :value="4">{{ i18n.ts.large }}+</option> + </MkRadios> + </MkPreferenceContainer> + </SearchMarker> + + <SearchMarker :keywords="['emoji', 'picker', 'style']"> + <MkPreferenceContainer k="emojiPickerStyle"> + <MkSelect v-model="emojiPickerStyle"> + <template #label><SearchLabel>{{ i18n.ts.style }}</SearchLabel></template> + <template #caption>{{ i18n.ts.needReloadToApply }}</template> + <option value="auto">{{ i18n.ts.auto }}</option> + <option value="popup">{{ i18n.ts.popup }}</option> + <option value="drawer">{{ i18n.ts.drawer }}</option> + </MkSelect> + </MkPreferenceContainer> + </SearchMarker> + + <MkButton @click="previewPicker"><i class="ti ti-eye"></i> {{ i18n.ts.preview }}</MkButton> + </div> + </FormSection> + </SearchMarker> + </div> +</SearchMarker> +</template> + +<script lang="ts" setup> +import { computed, ref, watch } from 'vue'; +import { v4 as uuid } from 'uuid'; +import XPalette from './emoji-palette.palette.vue'; +import MkRadios from '@/components/MkRadios.vue'; +import MkButton from '@/components/MkButton.vue'; +import FormSection from '@/components/form/section.vue'; +import MkSelect from '@/components/MkSelect.vue'; +import * as os from '@/os.js'; +import { i18n } from '@/i18n.js'; +import { definePage } from '@/page.js'; +import MkFolder from '@/components/MkFolder.vue'; +import { prefer } from '@/preferences.js'; +import MkPreferenceContainer from '@/components/MkPreferenceContainer.vue'; +import MkSwitch from '@/components/MkSwitch.vue'; +import { emojiPicker } from '@/utility/emoji-picker.js'; + +const emojiPaletteForReaction = prefer.model('emojiPaletteForReaction'); +const emojiPaletteForMain = prefer.model('emojiPaletteForMain'); +const emojiPickerScale = prefer.model('emojiPickerScale'); +const emojiPickerWidth = prefer.model('emojiPickerWidth'); +const emojiPickerHeight = prefer.model('emojiPickerHeight'); +const emojiPickerStyle = prefer.model('emojiPickerStyle'); + +const palettesSyncEnabled = ref(prefer.isSyncEnabled('emojiPalettes')); + +function changePalettesSyncEnabled(value: boolean) { + if (value) { + prefer.enableSync('emojiPalettes').then((res) => { + if (res == null) return; + if (res.enabled) palettesSyncEnabled.value = true; + }); + } else { + prefer.disableSync('emojiPalettes'); + palettesSyncEnabled.value = false; + } +} + +function addPalette() { + prefer.commit('emojiPalettes', [ + ...prefer.s.emojiPalettes, + { + id: uuid(), + name: '', + emojis: [], + }, + ]); +} + +function updatePaletteEmojis(id: string, emojis: string[]) { + prefer.commit('emojiPalettes', prefer.s.emojiPalettes.map(palette => { + if (palette.id === id) { + return { + ...palette, + emojis, + }; + } else { + return palette; + } + })); +} + +function updatePaletteName(id: string, name: string) { + prefer.commit('emojiPalettes', prefer.s.emojiPalettes.map(palette => { + if (palette.id === id) { + return { + ...palette, + name, + }; + } else { + return palette; + } + })); +} + +function delPalette(id: string) { + if (prefer.s.emojiPalettes.length === 1) { + addPalette(); + } + prefer.commit('emojiPalettes', prefer.s.emojiPalettes.filter(palette => palette.id !== id)); + if (prefer.s.emojiPaletteForMain === id) { + prefer.commit('emojiPaletteForMain', null); + } + if (prefer.s.emojiPaletteForReaction === id) { + prefer.commit('emojiPaletteForReaction', null); + } +} + +function getHTMLElement(ev: MouseEvent): HTMLElement { + const target = ev.currentTarget ?? ev.target; + return target as HTMLElement; +} + +function previewPicker(ev: MouseEvent) { + emojiPicker.show(getHTMLElement(ev)); +} + +definePage(() => ({ + title: i18n.ts.emojiPalette, + icon: 'ti ti-mood-happy', +})); +</script> + +<style lang="scss" module> +.tab { + margin: calc(var(--MI-margin) / 2) 0; + padding: calc(var(--MI-margin) / 2) 0; + background: var(--MI_THEME-bg); +} + +.emojis { + padding: 12px; + font-size: 1.1em; +} + +.emojisItem { + display: inline-block; + padding: 8px; + cursor: move; +} + +.emojisAdd { + display: inline-block; + padding: 8px; +} + +.editorCaption { + font-size: 0.85em; + padding: 8px 0 0 0; + color: color(from var(--MI_THEME-fg) srgb r g b / 0.75); +} +</style> diff --git a/packages/frontend/src/pages/settings/emoji-picker.vue b/packages/frontend/src/pages/settings/emoji-picker.vue deleted file mode 100644 index b16c943676..0000000000 --- a/packages/frontend/src/pages/settings/emoji-picker.vue +++ /dev/null @@ -1,278 +0,0 @@ -<!-- -SPDX-FileCopyrightText: syuilo and misskey-project -SPDX-License-Identifier: AGPL-3.0-only ---> - -<template> -<div class="_gaps_m"> - <MkFolder :defaultOpen="true"> - <template #icon><i class="ti ti-pin"></i></template> - <template #label>{{ i18n.ts.pinned }} ({{ i18n.ts.reaction }})</template> - <template #caption>{{ i18n.ts.pinnedEmojisForReactionSettingDescription }}</template> - - <div class="_gaps"> - <div> - <div v-panel style="border-radius: 6px;"> - <Sortable - v-model="pinnedEmojisForReaction" - :class="$style.emojis" - :itemKey="item => item" - :animation="150" - :delay="100" - :delayOnTouchOnly="true" - > - <template #item="{element}"> - <button class="_button" :class="$style.emojisItem" @click="removeReaction(element, $event)"> - <MkCustomEmoji v-if="element[0] === ':'" :name="element" :normal="true" :fallbackToImage="true"/> - <MkEmoji v-else :emoji="element" :normal="true"/> - </button> - </template> - <template #footer> - <button class="_button" :class="$style.emojisAdd" @click="chooseReaction"> - <i class="ti ti-plus"></i> - </button> - </template> - </Sortable> - </div> - <div :class="$style.editorCaption">{{ i18n.ts.reactionSettingDescription2 }}</div> - </div> - - <div class="_buttons"> - <MkButton inline @click="previewReaction"><i class="ti ti-eye"></i> {{ i18n.ts.preview }}</MkButton> - <MkButton inline danger @click="setDefaultReaction"><i class="ti ti-reload"></i> {{ i18n.ts.default }}</MkButton> - <MkButton inline danger @click="overwriteFromPinnedEmojis"><i class="ti ti-copy"></i> {{ i18n.ts.overwriteFromPinnedEmojis }}</MkButton> - </div> - </div> - </MkFolder> - - <MkFolder> - <template #icon><i class="ti ti-pin"></i></template> - <template #label>{{ i18n.ts.pinned }} ({{ i18n.ts.general }})</template> - <template #caption>{{ i18n.ts.pinnedEmojisSettingDescription }}</template> - - <div class="_gaps"> - <div> - <div v-panel style="border-radius: 6px;"> - <Sortable - v-model="pinnedEmojis" - :class="$style.emojis" - :itemKey="item => item" - :animation="150" - :delay="100" - :delayOnTouchOnly="true" - > - <template #item="{element}"> - <button class="_button" :class="$style.emojisItem" @click="removeEmoji(element, $event)"> - <MkCustomEmoji v-if="element[0] === ':'" :name="element" :normal="true" :fallbackToImage="true"/> - <MkEmoji v-else :emoji="element" :normal="true"/> - </button> - </template> - <template #footer> - <button class="_button" :class="$style.emojisAdd" @click="chooseEmoji"> - <i class="ti ti-plus"></i> - </button> - </template> - </Sortable> - </div> - <div :class="$style.editorCaption">{{ i18n.ts.reactionSettingDescription2 }}</div> - </div> - - <div class="_buttons"> - <MkButton inline @click="previewEmoji"><i class="ti ti-eye"></i> {{ i18n.ts.preview }}</MkButton> - <MkButton inline danger @click="setDefaultEmoji"><i class="ti ti-reload"></i> {{ i18n.ts.default }}</MkButton> - <MkButton inline danger @click="overwriteFromPinnedEmojisForReaction"><i class="ti ti-copy"></i> {{ i18n.ts.overwriteFromPinnedEmojisForReaction }}</MkButton> - </div> - </div> - </MkFolder> - - <FormSection> - <template #label>{{ i18n.ts.emojiPickerDisplay }}</template> - - <div class="_gaps_m"> - <MkRadios v-model="emojiPickerScale"> - <template #label>{{ i18n.ts.size }}</template> - <option :value="1">{{ i18n.ts.small }}</option> - <option :value="2">{{ i18n.ts.medium }}</option> - <option :value="3">{{ i18n.ts.large }}</option> - </MkRadios> - - <MkRadios v-model="emojiPickerWidth"> - <template #label>{{ i18n.ts.numberOfColumn }}</template> - <option :value="1">5</option> - <option :value="2">6</option> - <option :value="3">7</option> - <option :value="4">8</option> - <option :value="5">9</option> - </MkRadios> - - <MkRadios v-model="emojiPickerHeight"> - <template #label>{{ i18n.ts.height }}</template> - <option :value="1">{{ i18n.ts.small }}</option> - <option :value="2">{{ i18n.ts.medium }}</option> - <option :value="3">{{ i18n.ts.large }}</option> - <option :value="4">{{ i18n.ts.large }}+</option> - </MkRadios> - - <MkSelect v-model="emojiPickerStyle"> - <template #label>{{ i18n.ts.style }}</template> - <template #caption>{{ i18n.ts.needReloadToApply }}</template> - <option value="auto">{{ i18n.ts.auto }}</option> - <option value="popup">{{ i18n.ts.popup }}</option> - <option value="drawer">{{ i18n.ts.drawer }}</option> - </MkSelect> - </div> - </FormSection> -</div> -</template> - -<script lang="ts" setup> -import { computed, ref, watch } from 'vue'; -import type { Ref } from 'vue'; -import Sortable from 'vuedraggable'; -import MkRadios from '@/components/MkRadios.vue'; -import MkButton from '@/components/MkButton.vue'; -import FormSection from '@/components/form/section.vue'; -import MkSelect from '@/components/MkSelect.vue'; -import * as os from '@/os.js'; -import { defaultStore } from '@/store.js'; -import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; -import { deepClone } from '@/scripts/clone.js'; -import { reactionPicker } from '@/scripts/reaction-picker.js'; -import { emojiPicker } from '@/scripts/emoji-picker.js'; -import MkCustomEmoji from '@/components/global/MkCustomEmoji.vue'; -import MkEmoji from '@/components/global/MkEmoji.vue'; -import MkFolder from '@/components/MkFolder.vue'; - -const pinnedEmojisForReaction: Ref<string[]> = ref(deepClone(defaultStore.state.reactions)); -const pinnedEmojis: Ref<string[]> = ref(deepClone(defaultStore.state.pinnedEmojis)); - -const emojiPickerScale = computed(defaultStore.makeGetterSetter('emojiPickerScale')); -const emojiPickerWidth = computed(defaultStore.makeGetterSetter('emojiPickerWidth')); -const emojiPickerHeight = computed(defaultStore.makeGetterSetter('emojiPickerHeight')); -const emojiPickerStyle = computed(defaultStore.makeGetterSetter('emojiPickerStyle')); - -const removeReaction = (reaction: string, ev: MouseEvent) => remove(pinnedEmojisForReaction, reaction, ev); -const chooseReaction = (ev: MouseEvent) => pickEmoji(pinnedEmojisForReaction, ev); -const setDefaultReaction = () => setDefault(pinnedEmojisForReaction); - -const removeEmoji = (reaction: string, ev: MouseEvent) => remove(pinnedEmojis, reaction, ev); -const chooseEmoji = (ev: MouseEvent) => pickEmoji(pinnedEmojis, ev); -const setDefaultEmoji = () => setDefault(pinnedEmojis); - -function previewReaction(ev: MouseEvent) { - reactionPicker.show(getHTMLElement(ev), null); -} - -function previewEmoji(ev: MouseEvent) { - emojiPicker.show(getHTMLElement(ev)); -} - -async function overwriteFromPinnedEmojis() { - const { canceled } = await os.confirm({ - type: 'warning', - text: i18n.ts.overwriteContentConfirm, - }); - - if (canceled) { - return; - } - - pinnedEmojisForReaction.value = [...pinnedEmojis.value]; -} - -async function overwriteFromPinnedEmojisForReaction() { - const { canceled } = await os.confirm({ - type: 'warning', - text: i18n.ts.overwriteContentConfirm, - }); - - if (canceled) { - return; - } - - pinnedEmojis.value = [...pinnedEmojisForReaction.value]; -} - -function remove(itemsRef: Ref<string[]>, reaction: string, ev: MouseEvent) { - os.popupMenu([{ - text: i18n.ts.remove, - action: () => { - itemsRef.value = itemsRef.value.filter(x => x !== reaction); - }, - }], getHTMLElement(ev)); -} - -async function setDefault(itemsRef: Ref<string[]>) { - const { canceled } = await os.confirm({ - type: 'warning', - text: i18n.ts.resetAreYouSure, - }); - if (canceled) return; - - itemsRef.value = deepClone(defaultStore.def.reactions.default); -} - -async function pickEmoji(itemsRef: Ref<string[]>, ev: MouseEvent) { - os.pickEmoji(getHTMLElement(ev), { - showPinned: false, - }).then(it => { - const emoji = it; - if (!itemsRef.value.includes(emoji)) { - itemsRef.value.push(emoji); - } - }); -} - -function getHTMLElement(ev: MouseEvent): HTMLElement { - const target = ev.currentTarget ?? ev.target; - return target as HTMLElement; -} - -watch(pinnedEmojisForReaction, () => { - defaultStore.set('reactions', pinnedEmojisForReaction.value); -}, { - deep: true, -}); - -watch(pinnedEmojis, () => { - defaultStore.set('pinnedEmojis', pinnedEmojis.value); -}, { - deep: true, -}); - -definePageMetadata(() => ({ - title: i18n.ts.emojiPicker, - icon: 'ti ti-mood-happy', -})); -</script> - -<style lang="scss" module> -.tab { - margin: calc(var(--MI-margin) / 2) 0; - padding: calc(var(--MI-margin) / 2) 0; - background: var(--MI_THEME-bg); -} - -.emojis { - padding: 12px; - font-size: 1.1em; -} - -.emojisItem { - display: inline-block; - padding: 8px; - cursor: move; -} - -.emojisAdd { - display: inline-block; - padding: 8px; -} - -.editorCaption { - font-size: 0.85em; - padding: 8px 0 0 0; - color: var(--MI_THEME-fgTransparentWeak); -} -</style> diff --git a/packages/frontend/src/pages/settings/import-export.vue b/packages/frontend/src/pages/settings/import-export.vue deleted file mode 100644 index 6b67a9a1a8..0000000000 --- a/packages/frontend/src/pages/settings/import-export.vue +++ /dev/null @@ -1,263 +0,0 @@ -<!-- -SPDX-FileCopyrightText: syuilo and misskey-project -SPDX-License-Identifier: AGPL-3.0-only ---> - -<template> -<SearchMarker path="/settings/import-export" :label="i18n.ts.importAndExport" :keywords="['import', 'export', 'data']" icon="ti ti-package"> - <div class="_gaps_m"> - <SearchMarker :keywords="['notes']"> - <FormSection first> - <template #label><i class="ti ti-pencil"></i> <SearchLabel>{{ i18n.ts._exportOrImport.allNotes }}</SearchLabel></template> - <MkFolder> - <template #label>{{ i18n.ts.export }}</template> - <template #icon><i class="ti ti-download"></i></template> - <MkButton primary :class="$style.button" inline @click="exportNotes()"><i class="ti ti-download"></i> {{ i18n.ts.export }}</MkButton> - </MkFolder> - </FormSection> - </SearchMarker> - - <SearchMarker :keywords="['favorite', 'notes']"> - <FormSection> - <template #label><i class="ti ti-star"></i> <SearchLabel>{{ i18n.ts._exportOrImport.favoritedNotes }}</SearchLabel></template> - <MkFolder> - <template #label>{{ i18n.ts.export }}</template> - <template #icon><i class="ti ti-download"></i></template> - <MkButton primary :class="$style.button" inline @click="exportFavorites()"><i class="ti ti-download"></i> {{ i18n.ts.export }}</MkButton> - </MkFolder> - </FormSection> - </SearchMarker> - - <SearchMarker :keywords="['clip', 'notes']"> - <FormSection> - <template #label><i class="ti ti-star"></i> <SearchLabel>{{ i18n.ts._exportOrImport.clips }}</SearchLabel></template> - <MkFolder> - <template #label>{{ i18n.ts.export }}</template> - <template #icon><i class="ti ti-download"></i></template> - <MkButton primary :class="$style.button" inline @click="exportClips()"><i class="ti ti-download"></i> {{ i18n.ts.export }}</MkButton> - </MkFolder> - </FormSection> - </SearchMarker> - - <SearchMarker :keywords="['following', 'users']"> - <FormSection> - <template #label><i class="ti ti-users"></i> <SearchLabel>{{ i18n.ts._exportOrImport.followingList }}</SearchLabel></template> - <div class="_gaps_s"> - <MkFolder> - <template #label>{{ i18n.ts.export }}</template> - <template #icon><i class="ti ti-download"></i></template> - <div class="_gaps_s"> - <MkSwitch v-model="excludeMutingUsers"> - {{ i18n.ts._exportOrImport.excludeMutingUsers }} - </MkSwitch> - <MkSwitch v-model="excludeInactiveUsers"> - {{ i18n.ts._exportOrImport.excludeInactiveUsers }} - </MkSwitch> - <MkButton primary :class="$style.button" inline @click="exportFollowing()"><i class="ti ti-download"></i> {{ i18n.ts.export }}</MkButton> - </div> - </MkFolder> - <MkFolder v-if="$i && !$i.movedTo && $i.policies.canImportFollowing"> - <template #label>{{ i18n.ts.import }}</template> - <template #icon><i class="ti ti-upload"></i></template> - <MkSwitch v-model="withReplies"> - {{ i18n.ts._exportOrImport.withReplies }} - </MkSwitch> - <MkButton primary :class="$style.button" inline @click="importFollowing($event)"><i class="ti ti-upload"></i> {{ i18n.ts.import }}</MkButton> - </MkFolder> - </div> - </FormSection> - </SearchMarker> - - <SearchMarker :keywords="['user', 'lists']"> - <FormSection> - <template #label><i class="ti ti-users"></i> <SearchLabel>{{ i18n.ts._exportOrImport.userLists }}</SearchLabel></template> - <div class="_gaps_s"> - <MkFolder> - <template #label>{{ i18n.ts.export }}</template> - <template #icon><i class="ti ti-download"></i></template> - <MkButton primary :class="$style.button" inline @click="exportUserLists()"><i class="ti ti-download"></i> {{ i18n.ts.export }}</MkButton> - </MkFolder> - <MkFolder v-if="$i && !$i.movedTo && $i.policies.canImportUserLists"> - <template #label>{{ i18n.ts.import }}</template> - <template #icon><i class="ti ti-upload"></i></template> - <MkButton primary :class="$style.button" inline @click="importUserLists($event)"><i class="ti ti-upload"></i> {{ i18n.ts.import }}</MkButton> - </MkFolder> - </div> - </FormSection> - </SearchMarker> - - <SearchMarker :keywords="['mute', 'users']"> - <FormSection> - <template #label><i class="ti ti-user-off"></i> <SearchLabel>{{ i18n.ts._exportOrImport.muteList }}</SearchLabel></template> - <div class="_gaps_s"> - <MkFolder> - <template #label>{{ i18n.ts.export }}</template> - <template #icon><i class="ti ti-download"></i></template> - <MkButton primary :class="$style.button" inline @click="exportMuting()"><i class="ti ti-download"></i> {{ i18n.ts.export }}</MkButton> - </MkFolder> - <MkFolder v-if="$i && !$i.movedTo && $i.policies.canImportMuting"> - <template #label>{{ i18n.ts.import }}</template> - <template #icon><i class="ti ti-upload"></i></template> - <MkButton primary :class="$style.button" inline @click="importMuting($event)"><i class="ti ti-upload"></i> {{ i18n.ts.import }}</MkButton> - </MkFolder> - </div> - </FormSection> - </SearchMarker> - - <SearchMarker :keywords="['block', 'users']"> - <FormSection> - <template #label><i class="ti ti-user-off"></i> <SearchLabel>{{ i18n.ts._exportOrImport.blockingList }}</SearchLabel></template> - <div class="_gaps_s"> - <MkFolder> - <template #label>{{ i18n.ts.export }}</template> - <template #icon><i class="ti ti-download"></i></template> - <MkButton primary :class="$style.button" inline @click="exportBlocking()"><i class="ti ti-download"></i> {{ i18n.ts.export }}</MkButton> - </MkFolder> - <MkFolder v-if="$i && !$i.movedTo && $i.policies.canImportBlocking"> - <template #label>{{ i18n.ts.import }}</template> - <template #icon><i class="ti ti-upload"></i></template> - <MkButton primary :class="$style.button" inline @click="importBlocking($event)"><i class="ti ti-upload"></i> {{ i18n.ts.import }}</MkButton> - </MkFolder> - </div> - </FormSection> - </SearchMarker> - - <SearchMarker :keywords="['antennas']"> - <FormSection> - <template #label><i class="ti ti-antenna"></i> <SearchLabel>{{ i18n.ts.antennas }}</SearchLabel></template> - <div class="_gaps_s"> - <MkFolder> - <template #label>{{ i18n.ts.export }}</template> - <template #icon><i class="ti ti-download"></i></template> - <MkButton primary :class="$style.button" inline @click="exportAntennas()"><i class="ti ti-download"></i> {{ i18n.ts.export }}</MkButton> - </MkFolder> - <MkFolder v-if="$i && !$i.movedTo && $i.policies.canImportAntennas"> - <template #label>{{ i18n.ts.import }}</template> - <template #icon><i class="ti ti-upload"></i></template> - <MkButton primary :class="$style.button" inline @click="importAntennas($event)"><i class="ti ti-upload"></i> {{ i18n.ts.import }}</MkButton> - </MkFolder> - </div> - </FormSection> - </SearchMarker> - </div> -</SearchMarker> -</template> - -<script lang="ts" setup> -import { ref, computed } from 'vue'; -import MkButton from '@/components/MkButton.vue'; -import FormSection from '@/components/form/section.vue'; -import MkFolder from '@/components/MkFolder.vue'; -import MkSwitch from '@/components/MkSwitch.vue'; -import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { selectFile } from '@/scripts/select-file.js'; -import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; -import { $i } from '@/account.js'; -import { defaultStore } from '@/store.js'; - -const excludeMutingUsers = ref(false); -const excludeInactiveUsers = ref(false); -const withReplies = ref(defaultStore.state.defaultWithReplies); - -const onExportSuccess = () => { - os.alert({ - type: 'info', - text: i18n.ts.exportRequested, - }); -}; - -const onImportSuccess = () => { - os.alert({ - type: 'info', - text: i18n.ts.importRequested, - }); -}; - -const onError = (ev) => { - os.alert({ - type: 'error', - text: ev.message, - }); -}; - -const exportNotes = () => { - misskeyApi('i/export-notes', {}).then(onExportSuccess).catch(onError); -}; - -const exportFavorites = () => { - misskeyApi('i/export-favorites', {}).then(onExportSuccess).catch(onError); -}; - -const exportClips = () => { - misskeyApi('i/export-clips', {}).then(onExportSuccess).catch(onError); -}; - -const exportFollowing = () => { - misskeyApi('i/export-following', { - excludeMuting: excludeMutingUsers.value, - excludeInactive: excludeInactiveUsers.value, - }) - .then(onExportSuccess).catch(onError); -}; - -const exportBlocking = () => { - misskeyApi('i/export-blocking', {}).then(onExportSuccess).catch(onError); -}; - -const exportUserLists = () => { - misskeyApi('i/export-user-lists', {}).then(onExportSuccess).catch(onError); -}; - -const exportMuting = () => { - misskeyApi('i/export-mute', {}).then(onExportSuccess).catch(onError); -}; - -const exportAntennas = () => { - misskeyApi('i/export-antennas', {}).then(onExportSuccess).catch(onError); -}; - -const importFollowing = async (ev) => { - const file = await selectFile(ev.currentTarget ?? ev.target); - misskeyApi('i/import-following', { - fileId: file.id, - withReplies: withReplies.value, - }).then(onImportSuccess).catch(onError); -}; - -const importUserLists = async (ev) => { - const file = await selectFile(ev.currentTarget ?? ev.target); - misskeyApi('i/import-user-lists', { fileId: file.id }).then(onImportSuccess).catch(onError); -}; - -const importMuting = async (ev) => { - const file = await selectFile(ev.currentTarget ?? ev.target); - misskeyApi('i/import-muting', { fileId: file.id }).then(onImportSuccess).catch(onError); -}; - -const importBlocking = async (ev) => { - const file = await selectFile(ev.currentTarget ?? ev.target); - misskeyApi('i/import-blocking', { fileId: file.id }).then(onImportSuccess).catch(onError); -}; - -const importAntennas = async (ev) => { - const file = await selectFile(ev.currentTarget ?? ev.target); - misskeyApi('i/import-antennas', { fileId: file.id }).then(onImportSuccess).catch(onError); -}; - -const headerActions = computed(() => []); - -const headerTabs = computed(() => []); - -definePageMetadata(() => ({ - title: i18n.ts.importAndExport, - icon: 'ti ti-package', -})); -</script> - -<style module> -.button { - margin-right: 16px; -} -</style> diff --git a/packages/frontend/src/pages/settings/index.vue b/packages/frontend/src/pages/settings/index.vue index 458605d545..5921a8c812 100644 --- a/packages/frontend/src/pages/settings/index.vue +++ b/packages/frontend/src/pages/settings/index.vue @@ -4,43 +4,48 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> +<PageWithHeader :tabs="headerTabs" :actions="headerActions"> <MkSpacer :contentMax="900" :marginMin="20" :marginMax="32"> <div ref="el" class="vvcocwet" :class="{ wide: !narrow }"> <div class="body"> <div v-if="!narrow || currentPage?.route.name == null" class="nav"> - <div class="baaadecd"> + <div class="_gaps_s"> <MkInfo v-if="emailNotConfigured" warn class="info">{{ i18n.ts.emailNotConfiguredWarning }} <MkA to="/settings/email" class="_link">{{ i18n.ts.configure }}</MkA></MkInfo> + <MkInfo v-if="!store.r.enablePreferencesAutoCloudBackup.value && store.r.showPreferencesAutoCloudBackupSuggestion.value" class="info"> + <div>{{ i18n.ts._preferencesBackup.autoPreferencesBackupIsNotEnabledForThisDevice }}</div> + <div><button class="_textButton" @click="enableAutoBackup">{{ i18n.ts.enable }}</button> | <button class="_textButton" @click="skipAutoBackup">{{ i18n.ts.skip }}</button></div> + </MkInfo> <MkSuperMenu :def="menuDef" :grid="narrow" :searchIndex="SETTING_INDEX"></MkSuperMenu> </div> </div> <div v-if="!(narrow && currentPage?.route.name == null)" class="main"> - <div class="bkzroven" style="container-type: inline-size;"> - <RouterView nested/> + <div style="container-type: inline-size;"> + <NestedRouterView/> </div> </div> </div> </div> </MkSpacer> - <MkFooterSpacer/> -</mkstickycontainer> +</PageWithHeader> </template> <script setup lang="ts"> -import { computed, onActivated, onMounted, onUnmounted, ref, shallowRef, watch } from 'vue'; -import type { PageMetadata } from '@/scripts/page-metadata.js'; +import { computed, onActivated, onMounted, onUnmounted, ref, useTemplateRef, watch } from 'vue'; +import type { PageMetadata } from '@/page.js'; import type { SuperMenuDef } from '@/components/MkSuperMenu.vue'; import { i18n } from '@/i18n.js'; import MkInfo from '@/components/MkInfo.vue'; import MkSuperMenu from '@/components/MkSuperMenu.vue'; -import { signout, $i } from '@/account.js'; -import { clearCache } from '@/scripts/clear-cache.js'; +import { $i } from '@/i.js'; +import { clearCache } from '@/utility/clear-cache.js'; import { instance } from '@/instance.js'; -import { definePageMetadata, provideMetadataReceiver, provideReactiveMetadata } from '@/scripts/page-metadata.js'; +import { definePage, provideMetadataReceiver, provideReactiveMetadata } from '@/page.js'; import * as os from '@/os.js'; -import { useRouter } from '@/router/supplier.js'; -import { searchIndexes } from '@/scripts/autogen/settings-search-index.js'; +import { useRouter } from '@/router.js'; +import { searchIndexes } from '@/utility/settings-search-index.js'; +import { enableAutoBackup, getPreferencesProfileMenu } from '@/preferences/utility.js'; +import { store } from '@/store.js'; +import { signout } from '@/signout.js'; const SETTING_INDEX = searchIndexes; // TODO: lazy load @@ -50,7 +55,7 @@ const indexInfo = { hideHeader: true, }; const INFO = ref<PageMetadata>(indexInfo); -const el = shallowRef<HTMLElement | null>(null); +const el = useTemplateRef('el'); const childInfo = ref<null | PageMetadata>(null); const router = useRouter(); @@ -65,6 +70,10 @@ const ro = new ResizeObserver((entries, observer) => { narrow.value = entries[0].borderBoxSize[0].inlineSize < NARROW_THRESHOLD; }); +function skipAutoBackup() { + store.set('showPreferencesAutoCloudBackupSuggestion', false); +} + const menuDef = computed<SuperMenuDef[]>(() => [{ items: [{ icon: 'ti ti-user', @@ -77,16 +86,6 @@ const menuDef = computed<SuperMenuDef[]>(() => [{ to: '/settings/privacy', active: currentPage.value?.route.name === 'privacy', }, { - icon: 'ti ti-mood-happy', - text: i18n.ts.emojiPicker, - to: '/settings/emoji-picker', - active: currentPage.value?.route.name === 'emojiPicker', - }, { - icon: 'ti ti-cloud', - text: i18n.ts.drive, - to: '/settings/drive', - active: currentPage.value?.route.name === 'drive', - }, { icon: 'ti ti-bell', text: i18n.ts.notifications, to: '/settings/notifications', @@ -114,21 +113,16 @@ const menuDef = computed<SuperMenuDef[]>(() => [{ to: '/settings/theme', active: currentPage.value?.route.name === 'theme', }, { - icon: 'ti ti-device-desktop', - text: i18n.ts.appearance, - to: '/settings/appearance', - active: currentPage.value?.route.name === 'appearance', + icon: 'ti ti-mood-happy', + text: i18n.ts.emojiPalette, + to: '/settings/emoji-palette', + active: currentPage.value?.route.name === 'emoji-palette', }, { icon: 'ti ti-music', text: i18n.ts.sounds, to: '/settings/sounds', active: currentPage.value?.route.name === 'sounds', }, { - icon: 'ti ti-accessible', - text: i18n.ts.accessibility, - to: '/settings/accessibility', - active: currentPage.value?.route.name === 'accessibility', - }, { icon: 'ti ti-plug', text: i18n.ts.plugins, to: '/settings/plugin', @@ -136,30 +130,25 @@ const menuDef = computed<SuperMenuDef[]>(() => [{ }], }, { items: [{ - icon: 'ti ti-badges', - text: i18n.ts.roles, - to: '/settings/roles', - active: currentPage.value?.route.name === 'roles', + icon: 'ti ti-cloud', + text: i18n.ts.drive, + to: '/settings/drive', + active: currentPage.value?.route.name === 'drive', }, { icon: 'ti ti-ban', text: i18n.ts.muteAndBlock, to: '/settings/mute-block', active: currentPage.value?.route.name === 'mute-block', }, { - icon: 'ti ti-api', - text: 'API', - to: '/settings/api', - active: currentPage.value?.route.name === 'api', - }, { - icon: 'ti ti-webhook', - text: 'Webhook', - to: '/settings/webhook', - active: currentPage.value?.route.name === 'webhook', + icon: 'ti ti-link', + text: i18n.ts._settings.serviceConnection, + to: '/settings/connect', + active: currentPage.value?.route.name === 'connect', }, { icon: 'ti ti-package', - text: i18n.ts.importAndExport, - to: '/settings/import-export', - active: currentPage.value?.route.name === 'import-export', + text: i18n.ts._settings.accountData, + to: '/settings/account-data', + active: currentPage.value?.route.name === 'account-data', }, { icon: 'ti ti-dots', text: i18n.ts.other, @@ -168,10 +157,12 @@ const menuDef = computed<SuperMenuDef[]>(() => [{ }], }, { items: [{ - icon: 'ti ti-device-floppy', - text: i18n.ts.preferencesBackups, - to: '/settings/preferences-backups', - active: currentPage.value?.route.name === 'preferences-backups', + type: 'button', + icon: 'ti ti-settings-2', + text: i18n.ts.preferencesProfile, + action: async (ev: MouseEvent) => { + os.popupMenu(getPreferencesProfileMenu(), ev.currentTarget ?? ev.target); + }, }, { type: 'button', icon: 'ti ti-trash', @@ -240,37 +231,13 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => INFO.value); +definePage(() => INFO.value); // w 890 // h 700 </script> <style lang="scss" scoped> .vvcocwet { - > .body { - > .nav { - .baaadecd { - > .info { - margin: 16px 0; - } - - > .accounts { - > .avatar { - display: block; - width: 50px; - height: 50px; - margin: 8px auto 16px auto; - } - } - } - } - - > .main { - .bkzroven { - } - } - } - &.wide { > .body { display: flex; diff --git a/packages/frontend/src/pages/settings/migration.vue b/packages/frontend/src/pages/settings/migration.vue index 1c00d64d73..902db9116c 100644 --- a/packages/frontend/src/pages/settings/migration.vue +++ b/packages/frontend/src/pages/settings/migration.vue @@ -66,12 +66,12 @@ import MkButton from '@/components/MkButton.vue'; import MkFolder from '@/components/MkFolder.vue'; import MkUserInfo from '@/components/MkUserInfo.vue'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; -import { signinRequired } from '@/account.js'; -import { unisonReload } from '@/scripts/unison-reload.js'; +import { ensureSignin } from '@/i.js'; +import { unisonReload } from '@/utility/unison-reload.js'; -const $i = signinRequired(); +const $i = ensureSignin(); const moveToAccount = ref(''); const movedTo = ref<Misskey.entities.UserDetailed>(); diff --git a/packages/frontend/src/pages/settings/mute-block.instance-mute.vue b/packages/frontend/src/pages/settings/mute-block.instance-mute.vue index d1fde2fc1c..a0a40e4c72 100644 --- a/packages/frontend/src/pages/settings/mute-block.instance-mute.vue +++ b/packages/frontend/src/pages/settings/mute-block.instance-mute.vue @@ -19,11 +19,11 @@ import { ref, watch } from 'vue'; import MkTextarea from '@/components/MkTextarea.vue'; import MkInfo from '@/components/MkInfo.vue'; import MkButton from '@/components/MkButton.vue'; -import { signinRequired } from '@/account.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { ensureSignin } from '@/i.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; -const $i = signinRequired(); +const $i = ensureSignin(); const instanceMutes = ref($i.mutedInstances.join('\n')); const changed = ref(false); diff --git a/packages/frontend/src/pages/settings/mute-block.vue b/packages/frontend/src/pages/settings/mute-block.vue index 4aac2a25bd..fc9cd8f892 100644 --- a/packages/frontend/src/pages/settings/mute-block.vue +++ b/packages/frontend/src/pages/settings/mute-block.vue @@ -6,167 +6,172 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <SearchMarker path="/settings/mute-block" :label="i18n.ts.muteAndBlock" icon="ti ti-ban" :keywords="['mute', 'block']"> <div class="_gaps_m"> - <SearchMarker - :label="i18n.ts.wordMute" - :keywords="['note', 'word', 'soft', 'mute', 'hide']" - > - <MkFolder> - <template #icon><i class="ti ti-message-off"></i></template> - <template #label>{{ i18n.ts.wordMute }}</template> + <MkFeatureBanner icon="/client-assets/prohibited_3d.png" color="#ff2600"> + <SearchKeyword>{{ i18n.ts._settings.muteAndBlockBanner }}</SearchKeyword> + </MkFeatureBanner> - <div class="_gaps_m"> - <MkInfo>{{ i18n.ts.wordMuteDescription }}</MkInfo> + <div class="_gaps_s"> + <SearchMarker + :label="i18n.ts.wordMute" + :keywords="['note', 'word', 'soft', 'mute', 'hide']" + > + <MkFolder> + <template #icon><i class="ti ti-message-off"></i></template> + <template #label>{{ i18n.ts.wordMute }}</template> - <SearchMarker - :label="i18n.ts.showMutedWord" - :keywords="['show']" - > - <MkSwitch v-model="showSoftWordMutedWord">{{ i18n.ts.showMutedWord }}</MkSwitch> - </SearchMarker> + <div class="_gaps_m"> + <MkInfo>{{ i18n.ts.wordMuteDescription }}</MkInfo> - <XWordMute :muted="$i.mutedWords" @save="saveMutedWords"/> - </div> - </MkFolder> - </SearchMarker> + <SearchMarker + :label="i18n.ts.showMutedWord" + :keywords="['show']" + > + <MkSwitch v-model="showSoftWordMutedWord">{{ i18n.ts.showMutedWord }}</MkSwitch> + </SearchMarker> - <SearchMarker - :label="i18n.ts.hardWordMute" - :keywords="['note', 'word', 'hard', 'mute', 'hide']" - > - <MkFolder> - <template #icon><i class="ti ti-message-off"></i></template> - <template #label>{{ i18n.ts.hardWordMute }}</template> + <XWordMute :muted="$i.mutedWords" @save="saveMutedWords"/> + </div> + </MkFolder> + </SearchMarker> - <div class="_gaps_m"> - <MkInfo>{{ i18n.ts.hardWordMuteDescription }}</MkInfo> - <XWordMute :muted="$i.hardMutedWords" @save="saveHardMutedWords"/> - </div> - </MkFolder> - </SearchMarker> + <SearchMarker + :label="i18n.ts.hardWordMute" + :keywords="['note', 'word', 'hard', 'mute', 'hide']" + > + <MkFolder> + <template #icon><i class="ti ti-message-off"></i></template> + <template #label>{{ i18n.ts.hardWordMute }}</template> - <SearchMarker - :label="i18n.ts.instanceMute" - :keywords="['note', 'server', 'instance', 'host', 'federation', 'mute', 'hide']" - > - <MkFolder v-if="instance.federation !== 'none'"> - <template #icon><i class="ti ti-planet-off"></i></template> - <template #label>{{ i18n.ts.instanceMute }}</template> + <div class="_gaps_m"> + <MkInfo>{{ i18n.ts.hardWordMuteDescription }}</MkInfo> + <XWordMute :muted="$i.hardMutedWords" @save="saveHardMutedWords"/> + </div> + </MkFolder> + </SearchMarker> - <XInstanceMute/> - </MkFolder> - </SearchMarker> + <SearchMarker + :label="i18n.ts.instanceMute" + :keywords="['note', 'server', 'instance', 'host', 'federation', 'mute', 'hide']" + > + <MkFolder v-if="instance.federation !== 'none'"> + <template #icon><i class="ti ti-planet-off"></i></template> + <template #label>{{ i18n.ts.instanceMute }}</template> - <SearchMarker - :label="`${i18n.ts.mutedUsers} (${ i18n.ts.renote })`" - :keywords="['renote', 'mute', 'hide', 'user']" - > - <MkFolder> - <template #icon><i class="ti ti-repeat-off"></i></template> - <template #label>{{ i18n.ts.mutedUsers }} ({{ i18n.ts.renote }})</template> + <XInstanceMute/> + </MkFolder> + </SearchMarker> - <MkPagination :pagination="renoteMutingPagination"> - <template #empty> - <div class="_fullinfo"> - <img :src="infoImageUrl" class="_ghost"/> - <div>{{ i18n.ts.noUsers }}</div> - </div> - </template> + <SearchMarker + :keywords="['renote', 'mute', 'hide', 'user']" + > + <MkFolder> + <template #icon><i class="ti ti-repeat-off"></i></template> + <template #label><SearchLabel>{{ i18n.ts.mutedUsers }} ({{ i18n.ts.renote }})</SearchLabel></template> - <template #default="{ items }"> - <div class="_gaps_s"> - <div v-for="item in items" :key="item.mutee.id" :class="[$style.userItem, { [$style.userItemOpend]: expandedRenoteMuteItems.includes(item.id) }]"> - <div :class="$style.userItemMain"> - <MkA :class="$style.userItemMainBody" :to="userPage(item.mutee)"> - <MkUserCardMini :user="item.mutee"/> - </MkA> - <button class="_button" :class="$style.userToggle" @click="toggleRenoteMuteItem(item)"><i :class="$style.chevron" class="ti ti-chevron-down"></i></button> - <button class="_button" :class="$style.remove" @click="unrenoteMute(item.mutee, $event)"><i class="ti ti-x"></i></button> - </div> - <div v-if="expandedRenoteMuteItems.includes(item.id)" :class="$style.userItemSub"> - <div>Muted at: <MkTime :time="item.createdAt" mode="detail"/></div> + <MkPagination :pagination="renoteMutingPagination"> + <template #empty> + <div class="_fullinfo"> + <img :src="infoImageUrl" draggable="false"/> + <div>{{ i18n.ts.noUsers }}</div> + </div> + </template> + + <template #default="{ items }"> + <div class="_gaps_s"> + <div v-for="item in items" :key="item.mutee.id" :class="[$style.userItem, { [$style.userItemOpend]: expandedRenoteMuteItems.includes(item.id) }]"> + <div :class="$style.userItemMain"> + <MkA :class="$style.userItemMainBody" :to="userPage(item.mutee)"> + <MkUserCardMini :user="item.mutee"/> + </MkA> + <button class="_button" :class="$style.userToggle" @click="toggleRenoteMuteItem(item)"><i :class="$style.chevron" class="ti ti-chevron-down"></i></button> + <button class="_button" :class="$style.remove" @click="unrenoteMute(item.mutee, $event)"><i class="ti ti-x"></i></button> + </div> + <div v-if="expandedRenoteMuteItems.includes(item.id)" :class="$style.userItemSub"> + <div>Muted at: <MkTime :time="item.createdAt" mode="detail"/></div> + </div> </div> </div> - </div> - </template> - </MkPagination> - </MkFolder> - </SearchMarker> + </template> + </MkPagination> + </MkFolder> + </SearchMarker> - <SearchMarker - :label="i18n.ts.mutedUsers" - :keywords="['note', 'mute', 'hide', 'user']" - > - <MkFolder> - <template #icon><i class="ti ti-eye-off"></i></template> - <template #label>{{ i18n.ts.mutedUsers }}</template> + <SearchMarker + :label="i18n.ts.mutedUsers" + :keywords="['note', 'mute', 'hide', 'user']" + > + <MkFolder> + <template #icon><i class="ti ti-eye-off"></i></template> + <template #label>{{ i18n.ts.mutedUsers }}</template> - <MkPagination :pagination="mutingPagination"> - <template #empty> - <div class="_fullinfo"> - <img :src="infoImageUrl" class="_ghost"/> - <div>{{ i18n.ts.noUsers }}</div> - </div> - </template> + <MkPagination :pagination="mutingPagination"> + <template #empty> + <div class="_fullinfo"> + <img :src="infoImageUrl" draggable="false"/> + <div>{{ i18n.ts.noUsers }}</div> + </div> + </template> - <template #default="{ items }"> - <div class="_gaps_s"> - <div v-for="item in items" :key="item.mutee.id" :class="[$style.userItem, { [$style.userItemOpend]: expandedMuteItems.includes(item.id) }]"> - <div :class="$style.userItemMain"> - <MkA :class="$style.userItemMainBody" :to="userPage(item.mutee)"> - <MkUserCardMini :user="item.mutee"/> - </MkA> - <button class="_button" :class="$style.userToggle" @click="toggleMuteItem(item)"><i :class="$style.chevron" class="ti ti-chevron-down"></i></button> - <button class="_button" :class="$style.remove" @click="unmute(item.mutee, $event)"><i class="ti ti-x"></i></button> - </div> - <div v-if="expandedMuteItems.includes(item.id)" :class="$style.userItemSub"> - <div>Muted at: <MkTime :time="item.createdAt" mode="detail"/></div> - <div v-if="item.expiresAt">Period: {{ new Date(item.expiresAt).toLocaleString() }}</div> - <div v-else>Period: {{ i18n.ts.indefinitely }}</div> + <template #default="{ items }"> + <div class="_gaps_s"> + <div v-for="item in items" :key="item.mutee.id" :class="[$style.userItem, { [$style.userItemOpend]: expandedMuteItems.includes(item.id) }]"> + <div :class="$style.userItemMain"> + <MkA :class="$style.userItemMainBody" :to="userPage(item.mutee)"> + <MkUserCardMini :user="item.mutee"/> + </MkA> + <button class="_button" :class="$style.userToggle" @click="toggleMuteItem(item)"><i :class="$style.chevron" class="ti ti-chevron-down"></i></button> + <button class="_button" :class="$style.remove" @click="unmute(item.mutee, $event)"><i class="ti ti-x"></i></button> + </div> + <div v-if="expandedMuteItems.includes(item.id)" :class="$style.userItemSub"> + <div>Muted at: <MkTime :time="item.createdAt" mode="detail"/></div> + <div v-if="item.expiresAt">Period: {{ new Date(item.expiresAt).toLocaleString() }}</div> + <div v-else>Period: {{ i18n.ts.indefinitely }}</div> + </div> </div> </div> - </div> - </template> - </MkPagination> - </MkFolder> - </SearchMarker> + </template> + </MkPagination> + </MkFolder> + </SearchMarker> - <SearchMarker - :label="i18n.ts.blockedUsers" - :keywords="['block', 'user']" - > - <MkFolder> - <template #icon><i class="ti ti-ban"></i></template> - <template #label>{{ i18n.ts.blockedUsers }}</template> + <SearchMarker + :label="i18n.ts.blockedUsers" + :keywords="['block', 'user']" + > + <MkFolder> + <template #icon><i class="ti ti-ban"></i></template> + <template #label>{{ i18n.ts.blockedUsers }}</template> - <MkPagination :pagination="blockingPagination"> - <template #empty> - <div class="_fullinfo"> - <img :src="infoImageUrl" class="_ghost"/> - <div>{{ i18n.ts.noUsers }}</div> - </div> - </template> + <MkPagination :pagination="blockingPagination"> + <template #empty> + <div class="_fullinfo"> + <img :src="infoImageUrl" draggable="false"/> + <div>{{ i18n.ts.noUsers }}</div> + </div> + </template> - <template #default="{ items }"> - <div class="_gaps_s"> - <div v-for="item in items" :key="item.blockee.id" :class="[$style.userItem, { [$style.userItemOpend]: expandedBlockItems.includes(item.id) }]"> - <div :class="$style.userItemMain"> - <MkA :class="$style.userItemMainBody" :to="userPage(item.blockee)"> - <MkUserCardMini :user="item.blockee"/> - </MkA> - <button class="_button" :class="$style.userToggle" @click="toggleBlockItem(item)"><i :class="$style.chevron" class="ti ti-chevron-down"></i></button> - <button class="_button" :class="$style.remove" @click="unblock(item.blockee, $event)"><i class="ti ti-x"></i></button> - </div> - <div v-if="expandedBlockItems.includes(item.id)" :class="$style.userItemSub"> - <div>Blocked at: <MkTime :time="item.createdAt" mode="detail"/></div> - <div v-if="item.expiresAt">Period: {{ new Date(item.expiresAt).toLocaleString() }}</div> - <div v-else>Period: {{ i18n.ts.indefinitely }}</div> + <template #default="{ items }"> + <div class="_gaps_s"> + <div v-for="item in items" :key="item.blockee.id" :class="[$style.userItem, { [$style.userItemOpend]: expandedBlockItems.includes(item.id) }]"> + <div :class="$style.userItemMain"> + <MkA :class="$style.userItemMainBody" :to="userPage(item.blockee)"> + <MkUserCardMini :user="item.blockee"/> + </MkA> + <button class="_button" :class="$style.userToggle" @click="toggleBlockItem(item)"><i :class="$style.chevron" class="ti ti-chevron-down"></i></button> + <button class="_button" :class="$style.remove" @click="unblock(item.blockee, $event)"><i class="ti ti-x"></i></button> + </div> + <div v-if="expandedBlockItems.includes(item.id)" :class="$style.userItemSub"> + <div>Blocked at: <MkTime :time="item.createdAt" mode="detail"/></div> + <div v-if="item.expiresAt">Period: {{ new Date(item.expiresAt).toLocaleString() }}</div> + <div v-else>Period: {{ i18n.ts.indefinitely }}</div> + </div> </div> </div> - </div> - </template> - </MkPagination> - </MkFolder> - </SearchMarker> + </template> + </MkPagination> + </MkFolder> + </SearchMarker> + </div> </div> </SearchMarker> </template> @@ -178,18 +183,19 @@ import XWordMute from './mute-block.word-mute.vue'; import MkPagination from '@/components/MkPagination.vue'; import { userPage } from '@/filters/user.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; import MkUserCardMini from '@/components/MkUserCardMini.vue'; import * as os from '@/os.js'; import { instance, infoImageUrl } from '@/instance.js'; -import { signinRequired } from '@/account.js'; +import { ensureSignin } from '@/i.js'; import MkInfo from '@/components/MkInfo.vue'; import MkFolder from '@/components/MkFolder.vue'; import MkSwitch from '@/components/MkSwitch.vue'; -import { defaultStore } from '@/store'; -import { reloadAsk } from '@/scripts/reload-ask.js'; +import { reloadAsk } from '@/utility/reload-ask.js'; +import { prefer } from '@/preferences.js'; +import MkFeatureBanner from '@/components/MkFeatureBanner.vue'; -const $i = signinRequired(); +const $i = ensureSignin(); const renoteMutingPagination = { endpoint: 'renote-mute/list' as const, @@ -210,7 +216,7 @@ const expandedRenoteMuteItems = ref([]); const expandedMuteItems = ref([]); const expandedBlockItems = ref([]); -const showSoftWordMutedWord = computed(defaultStore.makeGetterSetter('showSoftWordMutedWord')); +const showSoftWordMutedWord = prefer.model('showSoftWordMutedWord'); watch([ showSoftWordMutedWord, @@ -287,7 +293,7 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.muteAndBlock, icon: 'ti ti-ban', })); @@ -301,7 +307,7 @@ definePageMetadata(() => ({ .userItemSub { padding: 6px 12px; font-size: 85%; - color: var(--MI_THEME-fgTransparentWeak); + color: color(from var(--MI_THEME-fg) srgb r g b / 0.75); } .userItemMainBody { diff --git a/packages/frontend/src/pages/settings/navbar.vue b/packages/frontend/src/pages/settings/navbar.vue index c38cdc4fc2..b322b03a21 100644 --- a/packages/frontend/src/pages/settings/navbar.vue +++ b/packages/frontend/src/pages/settings/navbar.vue @@ -4,45 +4,53 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div class="_gaps_m"> - <FormSlot> - <template #label>{{ i18n.ts.navbar }}</template> - <MkContainer :showHeader="false"> - <Sortable - v-model="items" - itemKey="id" - :animation="150" - :handle="'.' + $style.itemHandle" - @start="e => e.item.classList.add('active')" - @end="e => e.item.classList.remove('active')" - > - <template #item="{element,index}"> - <div - v-if="element.type === '-' || navbarItemDef[element.type]" - :class="$style.item" - > - <button class="_button" :class="$style.itemHandle"><i class="ti ti-menu"></i></button> - <i class="ti-fw" :class="[$style.itemIcon, navbarItemDef[element.type]?.icon]"></i><span :class="$style.itemText">{{ navbarItemDef[element.type]?.title ?? i18n.ts.divider }}</span> - <button class="_button" :class="$style.itemRemove" @click="removeItem(index)"><i class="ti ti-x"></i></button> - </div> - </template> - </Sortable> - </MkContainer> - </FormSlot> - <div class="_buttons"> - <MkButton @click="addItem"><i class="ti ti-plus"></i> {{ i18n.ts.addItem }}</MkButton> - <MkButton danger @click="reset"><i class="ti ti-reload"></i> {{ i18n.ts.default }}</MkButton> - <MkButton primary class="save" @click="save"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton> - </div> +<SearchMarker path="/settings/navbar" :label="i18n.ts.navbar" icon="ti ti-list" :keywords="['navbar', 'menu', 'sidebar']"> + <div class="_gaps_m"> + <FormSlot> + <template #label>{{ i18n.ts.navbar }}</template> + <MkContainer :showHeader="false"> + <Sortable + v-model="items" + itemKey="id" + :animation="150" + :handle="'.' + $style.itemHandle" + @start="e => e.item.classList.add('active')" + @end="e => e.item.classList.remove('active')" + > + <template #item="{element,index}"> + <div + v-if="element.type === '-' || navbarItemDef[element.type]" + :class="$style.item" + > + <button class="_button" :class="$style.itemHandle"><i class="ti ti-menu"></i></button> + <i class="ti-fw" :class="[$style.itemIcon, navbarItemDef[element.type]?.icon]"></i><span :class="$style.itemText">{{ navbarItemDef[element.type]?.title ?? i18n.ts.divider }}</span> + <button class="_button" :class="$style.itemRemove" @click="removeItem(index)"><i class="ti ti-x"></i></button> + </div> + </template> + </Sortable> + </MkContainer> + </FormSlot> + <div class="_buttons"> + <MkButton @click="addItem"><i class="ti ti-plus"></i> {{ i18n.ts.addItem }}</MkButton> + <MkButton danger @click="reset"><i class="ti ti-reload"></i> {{ i18n.ts.default }}</MkButton> + <MkButton primary class="save" @click="save"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton> + </div> + + <MkRadios v-model="menuDisplay"> + <template #label>{{ i18n.ts.display }}</template> + <option value="sideFull">{{ i18n.ts._menuDisplay.sideFull }}</option> + <option value="sideIcon">{{ i18n.ts._menuDisplay.sideIcon }}</option> + </MkRadios> - <MkRadios v-model="menuDisplay"> - <template #label>{{ i18n.ts.display }}</template> - <option value="sideFull">{{ i18n.ts._menuDisplay.sideFull }}</option> - <option value="sideIcon">{{ i18n.ts._menuDisplay.sideIcon }}</option> - <option value="top">{{ i18n.ts._menuDisplay.top }}</option> - <!-- <MkRadio v-model="menuDisplay" value="hide" disabled>{{ i18n.ts._menuDisplay.hide }}</MkRadio>--> <!-- TODO: サイドバーを完全に隠せるようにすると、別途ハンバーガーボタンのようなものをUIに表示する必要があり面倒 --> - </MkRadios> -</div> + <SearchMarker :keywords="['navbar', 'sidebar', 'toggle', 'button', 'sub']"> + <MkPreferenceContainer k="showNavbarSubButtons"> + <MkSwitch v-model="showNavbarSubButtons"> + <template #label><SearchLabel>{{ i18n.ts._settings.showNavbarSubButtons }}</SearchLabel></template> + </MkSwitch> + </MkPreferenceContainer> + </SearchMarker> + </div> +</SearchMarker> </template> <script lang="ts" setup> @@ -51,24 +59,29 @@ import MkRadios from '@/components/MkRadios.vue'; import MkButton from '@/components/MkButton.vue'; import FormSlot from '@/components/form/slot.vue'; import MkContainer from '@/components/MkContainer.vue'; +import MkSwitch from '@/components/MkSwitch.vue'; +import MkPreferenceContainer from '@/components/MkPreferenceContainer.vue'; import * as os from '@/os.js'; import { navbarItemDef } from '@/navbar.js'; -import { defaultStore } from '@/store.js'; -import { reloadAsk } from '@/scripts/reload-ask.js'; +import { store } from '@/store.js'; +import { reloadAsk } from '@/utility/reload-ask.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; +import { prefer } from '@/preferences.js'; +import { PREF_DEF } from '@/preferences/def.js'; const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default)); -const items = ref(defaultStore.state.menu.map(x => ({ +const items = ref(prefer.s.menu.map(x => ({ id: Math.random().toString(), type: x, }))); -const menuDisplay = computed(defaultStore.makeGetterSetter('menuDisplay')); +const menuDisplay = computed(store.makeGetterSetter('menuDisplay')); +const showNavbarSubButtons = prefer.model('showNavbarSubButtons'); async function addItem() { - const menu = Object.keys(navbarItemDef).filter(k => !defaultStore.state.menu.includes(k)); + const menu = Object.keys(navbarItemDef).filter(k => !prefer.s.menu.includes(k)); const { canceled, result: item } = await os.select({ title: i18n.ts.addItem, items: [...menu.map(k => ({ @@ -89,12 +102,11 @@ function removeItem(index: number) { } async function save() { - defaultStore.set('menu', items.value.map(x => x.type)); - await reloadAsk({ reason: i18n.ts.reloadToApplySetting, unison: true }); + prefer.commit('menu', items.value.map(x => x.type)); } function reset() { - items.value = defaultStore.def.menu.default.map(x => ({ + items.value = PREF_DEF.menu.default.map(x => ({ id: Math.random().toString(), type: x, })); @@ -104,7 +116,7 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.navbar, icon: 'ti ti-list', })); diff --git a/packages/frontend/src/pages/settings/notifications.vue b/packages/frontend/src/pages/settings/notifications.vue index 1e7436bf9c..e42e6613ac 100644 --- a/packages/frontend/src/pages/settings/notifications.vue +++ b/packages/frontend/src/pages/settings/notifications.vue @@ -4,65 +4,71 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div class="_gaps_m"> - <FormSection first> - <template #label>{{ i18n.ts.notificationRecieveConfig }}</template> - <div class="_gaps_s"> - <MkFolder v-for="type in notificationTypes.filter(x => !nonConfigurableNotificationTypes.includes(x))" :key="type"> - <template #label>{{ i18n.ts._notification._types[type] }}</template> - <template #suffix> - {{ - $i.notificationRecieveConfig[type]?.type === 'never' ? i18n.ts.none : - $i.notificationRecieveConfig[type]?.type === 'following' ? i18n.ts.following : - $i.notificationRecieveConfig[type]?.type === 'follower' ? i18n.ts.followers : - $i.notificationRecieveConfig[type]?.type === 'mutualFollow' ? i18n.ts.mutualFollow : - $i.notificationRecieveConfig[type]?.type === 'followingOrFollower' ? i18n.ts.followingOrFollower : - $i.notificationRecieveConfig[type]?.type === 'list' ? i18n.ts.userList : - i18n.ts.all - }} - </template> +<SearchMarker path="/settings/notifications" :label="i18n.ts.notifications" :keywords="['notifications']" icon="ti ti-bell"> + <div class="_gaps_m"> + <MkFeatureBanner icon="/client-assets/bell_3d.png" color="#ffff00"> + <SearchKeyword>{{ i18n.ts._settings.notificationsBanner }}</SearchKeyword> + </MkFeatureBanner> - <XNotificationConfig - :userLists="userLists" - :value="$i.notificationRecieveConfig[type] ?? { type: 'all' }" - :configurableTypes="onlyOnOrOffNotificationTypes.includes(type) ? ['all', 'never'] : undefined" - @update="(res) => updateReceiveConfig(type, res)" - /> - </MkFolder> - </div> - </FormSection> - <FormSection> - <div class="_gaps_m"> - <FormLink @click="readAllNotifications">{{ i18n.ts.markAsReadAllNotifications }}</FormLink> - <FormLink @click="readAllUnreadNotes">{{ i18n.ts.markAsReadAllUnreadNotes }}</FormLink> - </div> - </FormSection> - <FormSection> - <div class="_gaps_m"> - <FormLink @click="testNotification">{{ i18n.ts._notification.sendTestNotification }}</FormLink> - <FormLink @click="flushNotification">{{ i18n.ts._notification.flushNotification }}</FormLink> - </div> - </FormSection> - <FormSection> - <template #label>{{ i18n.ts.pushNotification }}</template> + <FormSection first> + <template #label>{{ i18n.ts.notificationRecieveConfig }}</template> + <div class="_gaps_s"> + <MkFolder v-for="type in notificationTypes.filter(x => !nonConfigurableNotificationTypes.includes(x))" :key="type"> + <template #label>{{ i18n.ts._notification._types[type] }}</template> + <template #suffix> + {{ + $i.notificationRecieveConfig[type]?.type === 'never' ? i18n.ts.none : + $i.notificationRecieveConfig[type]?.type === 'following' ? i18n.ts.following : + $i.notificationRecieveConfig[type]?.type === 'follower' ? i18n.ts.followers : + $i.notificationRecieveConfig[type]?.type === 'mutualFollow' ? i18n.ts.mutualFollow : + $i.notificationRecieveConfig[type]?.type === 'followingOrFollower' ? i18n.ts.followingOrFollower : + $i.notificationRecieveConfig[type]?.type === 'list' ? i18n.ts.userList : + i18n.ts.all + }} + </template> - <div class="_gaps_m"> - <MkPushNotificationAllowButton ref="allowButton"/> - <MkSwitch :disabled="!pushRegistrationInServer" :modelValue="sendReadMessage" @update:modelValue="onChangeSendReadMessage"> - <template #label>{{ i18n.ts.sendPushNotificationReadMessage }}</template> - <template #caption> - <I18n :src="i18n.ts.sendPushNotificationReadMessageCaption"> - <template #emptyPushNotificationMessage>{{ i18n.ts._notification.emptyPushNotificationMessage }}</template> - </I18n> - </template> - </MkSwitch> - </div> - </FormSection> -</div> + <XNotificationConfig + :userLists="userLists" + :value="$i.notificationRecieveConfig[type] ?? { type: 'all' }" + :configurableTypes="onlyOnOrOffNotificationTypes.includes(type) ? ['all', 'never'] : undefined" + @update="(res) => updateReceiveConfig(type, res)" + /> + </MkFolder> + </div> + </FormSection> + <FormSection> + <div class="_gaps_m"> + <FormLink @click="readAllNotifications">{{ i18n.ts.markAsReadAllNotifications }}</FormLink> + </div> + </FormSection> + <FormSection> + <div class="_gaps_m"> + <FormLink @click="testNotification">{{ i18n.ts._notification.sendTestNotification }}</FormLink> + <FormLink @click="flushNotification">{{ i18n.ts._notification.flushNotification }}</FormLink> + </div> + </FormSection> + <FormSection> + <template #label>{{ i18n.ts.pushNotification }}</template> + + <div class="_gaps_m"> + <MkPushNotificationAllowButton ref="allowButton"/> + <MkSwitch :disabled="!pushRegistrationInServer" :modelValue="sendReadMessage" @update:modelValue="onChangeSendReadMessage"> + <template #label>{{ i18n.ts.sendPushNotificationReadMessage }}</template> + <template #caption> + <I18n :src="i18n.ts.sendPushNotificationReadMessageCaption"> + <template #emptyPushNotificationMessage>{{ i18n.ts._notification.emptyPushNotificationMessage }}</template> + </I18n> + </template> + </MkSwitch> + </div> + </FormSection> + </div> +</SearchMarker> </template> <script lang="ts" setup> -import { shallowRef, computed } from 'vue'; +import { useTemplateRef, computed } from 'vue'; +import { notificationTypes } from '@@/js/const.js'; import XNotificationConfig from './notifications.notification-config.vue'; import type { NotificationConfig } from './notifications.notification-config.vue'; import FormLink from '@/components/form/link.vue'; @@ -70,28 +76,24 @@ import FormSection from '@/components/form/section.vue'; import MkFolder from '@/components/MkFolder.vue'; import MkSwitch from '@/components/MkSwitch.vue'; import * as os from '@/os.js'; -import { signinRequired } from '@/account.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { ensureSignin } from '@/i.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; import MkPushNotificationAllowButton from '@/components/MkPushNotificationAllowButton.vue'; -import { notificationTypes } from '@@/js/const.js'; +import MkFeatureBanner from '@/components/MkFeatureBanner.vue'; -const $i = signinRequired(); +const $i = ensureSignin(); const nonConfigurableNotificationTypes = ['note', 'roleAssigned', 'followRequestAccepted', 'test', 'exportCompleted'] satisfies (typeof notificationTypes[number])[] as string[]; const onlyOnOrOffNotificationTypes = ['app', 'achievementEarned', 'login', 'createToken'] satisfies (typeof notificationTypes[number])[] as string[]; -const allowButton = shallowRef<InstanceType<typeof MkPushNotificationAllowButton>>(); +const allowButton = useTemplateRef('allowButton'); const pushRegistrationInServer = computed(() => allowButton.value?.pushRegistrationInServer); const sendReadMessage = computed(() => pushRegistrationInServer.value?.sendReadMessage || false); const userLists = await misskeyApi('users/lists/list'); -async function readAllUnreadNotes() { - await os.apiWithDialog('i/read-all-unread-notes'); -} - async function readAllNotifications() { await os.apiWithDialog('notifications/mark-all-as-read'); } @@ -138,7 +140,7 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.notifications, icon: 'ti ti-bell', })); diff --git a/packages/frontend/src/pages/settings/other.vue b/packages/frontend/src/pages/settings/other.vue index 9742c548e7..83a6aa167c 100644 --- a/packages/frontend/src/pages/settings/other.vue +++ b/packages/frontend/src/pages/settings/other.vue @@ -16,92 +16,114 @@ SPDX-License-Identifier: AGPL-3.0-only <MkSwitch v-model="reportError">{{ i18n.ts.sendErrorReports }}<template #caption>{{ i18n.ts.sendErrorReportsDescription }}</template></MkSwitch> --> - <FormSection first> - <div class="_gaps_s"> - <SearchMarker :keywords="['account', 'info']"> - <MkFolder> - <template #icon><i class="ti ti-info-circle"></i></template> - <template #label><SearchLabel>{{ i18n.ts.accountInfo }}</SearchLabel></template> + <div class="_gaps_s"> + <SearchMarker :keywords="['account', 'info']"> + <MkFolder> + <template #icon><SearchIcon><i class="ti ti-info-circle"></i></SearchIcon></template> + <template #label><SearchLabel>{{ i18n.ts.accountInfo }}</SearchLabel></template> - <div class="_gaps_m"> - <MkKeyValue> - <template #key>ID</template> - <template #value><span class="_monospace">{{ $i.id }}</span></template> - </MkKeyValue> + <div class="_gaps_m"> + <MkKeyValue> + <template #key>ID</template> + <template #value><span class="_monospace">{{ $i.id }}</span></template> + </MkKeyValue> - <MkKeyValue> - <template #key>{{ i18n.ts.registeredDate }}</template> - <template #value><MkTime :time="$i.createdAt" mode="detail"/></template> - </MkKeyValue> - </div> - </MkFolder> - </SearchMarker> + <MkKeyValue> + <template #key>{{ i18n.ts.registeredDate }}</template> + <template #value><MkTime :time="$i.createdAt" mode="detail"/></template> + </MkKeyValue> - <SearchMarker :keywords="['account', 'move', 'migration']"> - <MkFolder> - <template #icon><i class="ti ti-plane"></i></template> - <template #label><SearchLabel>{{ i18n.ts.accountMigration }}</SearchLabel></template> + <SearchMarker :keywords="['role', 'policy']"> + <MkFolder> + <template #icon><i class="ti ti-badges"></i></template> + <template #label><SearchLabel>{{ i18n.ts._role.policies }}</SearchLabel></template> - <XMigration/> - </MkFolder> - </SearchMarker> + <div class="_gaps_s"> + <div v-for="policy in Object.keys($i.policies)" :key="policy"> + {{ policy }} ... {{ $i.policies[policy] }} + </div> + </div> + </MkFolder> + </SearchMarker> + </div> + </MkFolder> + </SearchMarker> - <SearchMarker :keywords="['account', 'close', 'delete']"> - <MkFolder> - <template #icon><i class="ti ti-alert-triangle"></i></template> - <template #label><SearchLabel>{{ i18n.ts.closeAccount }}</SearchLabel></template> + <SearchMarker :keywords="['roles']"> + <MkFolder> + <template #icon><SearchIcon><i class="ti ti-badges"></i></SearchIcon></template> + <template #label><SearchLabel>{{ i18n.ts.rolesAssignedToMe }}</SearchLabel></template> - <div class="_gaps_m"> - <FormInfo warn>{{ i18n.ts._accountDelete.mayTakeTime }}</FormInfo> - <FormInfo>{{ i18n.ts._accountDelete.sendEmail }}</FormInfo> - <MkButton v-if="!$i.isDeleted" danger @click="deleteAccount"><SearchKeyword>{{ i18n.ts._accountDelete.requestAccountDelete }}</SearchKeyword></MkButton> - <MkButton v-else disabled>{{ i18n.ts._accountDelete.inProgress }}</MkButton> - </div> - </MkFolder> - </SearchMarker> + <MkRolePreview v-for="role in $i.roles" :key="role.id" :role="role" :forModeration="false"/> + </MkFolder> + </SearchMarker> - <SearchMarker :keywords="['experimental', 'feature', 'flags']"> - <MkFolder> - <template #icon><i class="ti ti-flask"></i></template> - <template #label><SearchLabel>{{ i18n.ts.experimentalFeatures }}</SearchLabel></template> + <SearchMarker :keywords="['account', 'move', 'migration']"> + <MkFolder> + <template #icon><SearchIcon><i class="ti ti-plane"></i></SearchIcon></template> + <template #label><SearchLabel>{{ i18n.ts.accountMigration }}</SearchLabel></template> - <div class="_gaps_m"> - <MkSwitch v-model="enableCondensedLine"> - <template #label>Enable condensed line</template> - </MkSwitch> - <MkSwitch v-model="skipNoteRender"> - <template #label>Enable note render skipping</template> - </MkSwitch> - </div> - </MkFolder> - </SearchMarker> + <XMigration/> + </MkFolder> + </SearchMarker> - <SearchMarker :keywords="['developer', 'mode', 'debug']"> - <MkFolder> - <template #icon><i class="ti ti-code"></i></template> - <template #label><SearchLabel>{{ i18n.ts.developer }}</SearchLabel></template> + <SearchMarker :keywords="['account', 'close', 'delete']"> + <MkFolder> + <template #icon><SearchIcon><i class="ti ti-alert-triangle"></i></SearchIcon></template> + <template #label><SearchLabel>{{ i18n.ts.closeAccount }}</SearchLabel></template> - <div class="_gaps_m"> - <MkSwitch v-model="devMode"> - <template #label>{{ i18n.ts.devMode }}</template> - </MkSwitch> - </div> - </MkFolder> - </SearchMarker> - </div> - </FormSection> + <div class="_gaps_m"> + <FormInfo warn>{{ i18n.ts._accountDelete.mayTakeTime }}</FormInfo> + <FormInfo>{{ i18n.ts._accountDelete.sendEmail }}</FormInfo> + <MkButton v-if="!$i.isDeleted" danger @click="deleteAccount"><SearchKeyword>{{ i18n.ts._accountDelete.requestAccountDelete }}</SearchKeyword></MkButton> + <MkButton v-else disabled>{{ i18n.ts._accountDelete.inProgress }}</MkButton> + </div> + </MkFolder> + </SearchMarker> - <FormSection> - <FormLink to="/registry"><template #icon><i class="ti ti-adjustments"></i></template>{{ i18n.ts.registry }}</FormLink> - </FormSection> + <SearchMarker :keywords="['experimental', 'feature', 'flags']"> + <MkFolder> + <template #icon><SearchIcon><i class="ti ti-flask"></i></SearchIcon></template> + <template #label><SearchLabel>{{ i18n.ts.experimentalFeatures }}</SearchLabel></template> - <FormSection> - <div class="_gaps_s"> - <MkSwitch v-model="defaultWithReplies">{{ i18n.ts.withRepliesByDefaultForNewlyFollowed }}</MkSwitch> - <MkButton danger @click="updateRepliesAll(true)"><i class="ti ti-messages"></i> {{ i18n.ts.showRepliesToOthersInTimelineAll }}</MkButton> - <MkButton danger @click="updateRepliesAll(false)"><i class="ti ti-messages-off"></i> {{ i18n.ts.hideRepliesToOthersInTimelineAll }}</MkButton> - </div> - </FormSection> + <div class="_gaps_m"> + <MkSwitch v-model="enableCondensedLine"> + <template #label>Enable condensed line</template> + </MkSwitch> + <MkSwitch v-model="skipNoteRender"> + <template #label>Enable note render skipping</template> + </MkSwitch> + <MkSwitch v-model="stackingRouterView"> + <template #label>Enable stacking router view</template> + </MkSwitch> + </div> + </MkFolder> + </SearchMarker> + + <SearchMarker :keywords="['developer', 'mode', 'debug']"> + <MkFolder> + <template #icon><SearchIcon><i class="ti ti-code"></i></SearchIcon></template> + <template #label><SearchLabel>{{ i18n.ts.developer }}</SearchLabel></template> + + <div class="_gaps_m"> + <MkSwitch v-model="devMode"> + <template #label>{{ i18n.ts.devMode }}</template> + </MkSwitch> + </div> + </MkFolder> + </SearchMarker> + </div> + + <hr> + + <FormLink to="/registry"><template #icon><i class="ti ti-adjustments"></i></template>{{ i18n.ts.registry }}</FormLink> + + <hr> + + <FormSlot> + <MkButton danger @click="migrate"><i class="ti ti-refresh"></i> {{ i18n.ts.migrateOldSettings }}</MkButton> + <template #caption>{{ i18n.ts.migrateOldSettings_description }}</template> + </FormSlot> </div> </SearchMarker> </template> @@ -115,22 +137,26 @@ import MkFolder from '@/components/MkFolder.vue'; import FormInfo from '@/components/MkInfo.vue'; import MkKeyValue from '@/components/MkKeyValue.vue'; import MkButton from '@/components/MkButton.vue'; +import FormSlot from '@/components/form/slot.vue'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { defaultStore } from '@/store.js'; -import { signout, signinRequired } from '@/account.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { ensureSignin } from '@/i.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; -import { reloadAsk } from '@/scripts/reload-ask.js'; +import { definePage } from '@/page.js'; +import { reloadAsk } from '@/utility/reload-ask.js'; import FormSection from '@/components/form/section.vue'; +import { prefer } from '@/preferences.js'; +import MkRolePreview from '@/components/MkRolePreview.vue'; +import { signout } from '@/signout.js'; +import { migrateOldSettings } from '@/pref-migrate.js'; -const $i = signinRequired(); +const $i = ensureSignin(); -const reportError = computed(defaultStore.makeGetterSetter('reportError')); -const enableCondensedLine = computed(defaultStore.makeGetterSetter('enableCondensedLine')); -const skipNoteRender = computed(defaultStore.makeGetterSetter('skipNoteRender')); -const devMode = computed(defaultStore.makeGetterSetter('devMode')); -const defaultWithReplies = computed(defaultStore.makeGetterSetter('defaultWithReplies')); +const reportError = prefer.model('reportError'); +const enableCondensedLine = prefer.model('enableCondensedLine'); +const skipNoteRender = prefer.model('skipNoteRender'); +const devMode = prefer.model('devMode'); +const stackingRouterView = prefer.model('experimental.stackingRouterView'); watch(skipNoteRender, async () => { await reloadAsk({ reason: i18n.ts.reloadToApplySetting, unison: true }); @@ -160,21 +186,15 @@ async function deleteAccount() { await signout(); } -async function updateRepliesAll(withReplies: boolean) { - const { canceled } = await os.confirm({ - type: 'warning', - text: withReplies ? i18n.ts.confirmShowRepliesAll : i18n.ts.confirmHideRepliesAll, - }); - if (canceled) return; - - misskeyApi('following/update-all', { withReplies }); +function migrate() { + migrateOldSettings(); } const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.other, icon: 'ti ti-dots', })); diff --git a/packages/frontend/src/pages/settings/plugin.install.vue b/packages/frontend/src/pages/settings/plugin.install.vue index 3ab26e80d9..22b53b4b96 100644 --- a/packages/frontend/src/pages/settings/plugin.install.vue +++ b/packages/frontend/src/pages/settings/plugin.install.vue @@ -12,7 +12,7 @@ SPDX-License-Identifier: AGPL-3.0-only </MkCodeEditor> <div> - <MkButton :disabled="code == null" primary inline @click="install"><i class="ti ti-check"></i> {{ i18n.ts.install }}</MkButton> + <MkButton :disabled="code == null || code.trim() === ''" primary inline @click="install"><i class="ti ti-check"></i> {{ i18n.ts.install }}</MkButton> </div> </div> </template> @@ -23,11 +23,12 @@ import MkCodeEditor from '@/components/MkCodeEditor.vue'; import MkButton from '@/components/MkButton.vue'; import FormInfo from '@/components/MkInfo.vue'; import * as os from '@/os.js'; -import { installPlugin } from '@/scripts/install-plugin.js'; -import { unisonReload } from '@/scripts/unison-reload.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; +import { installPlugin } from '@/plugin.js'; +import { useRouter } from '@/router.js'; +const router = useRouter(); const code = ref<string | null>(null); async function install() { @@ -36,10 +37,9 @@ async function install() { try { await installPlugin(code.value); os.success(); + code.value = null; - nextTick(() => { - unisonReload(); - }); + router.push('/settings/plugin'); } catch (err) { os.alert({ type: 'error', @@ -53,7 +53,7 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts._plugin.install, icon: 'ti ti-download', })); diff --git a/packages/frontend/src/pages/settings/plugin.vue b/packages/frontend/src/pages/settings/plugin.vue index 3c3dcfe41e..16d5947ad2 100644 --- a/packages/frontend/src/pages/settings/plugin.vue +++ b/packages/frontend/src/pages/settings/plugin.vue @@ -4,76 +4,97 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div class="_gaps_m"> - <FormLink to="/settings/plugin/install"><template #icon><i class="ti ti-download"></i></template>{{ i18n.ts._plugin.install }}</FormLink> +<SearchMarker path="/settings/plugin" :label="i18n.ts.plugins" :keywords="['plugin', 'addon', 'extension']" icon="ti ti-plug"> + <div class="_gaps_m"> + <MkFeatureBanner icon="/client-assets/electric_plug_3d.png" color="#ffbb00"> + <SearchKeyword>{{ i18n.ts._settings.pluginBanner }}</SearchKeyword> + </MkFeatureBanner> - <FormSection> - <template #label>{{ i18n.ts.manage }}</template> - <div class="_gaps_s"> - <div v-for="plugin in plugins" :key="plugin.id" class="_panel _gaps_m" style="padding: 20px;"> - <div class="_gaps_s"> - <span style="display: flex; align-items: center;"><b>{{ plugin.name }}</b><span style="margin-left: auto;">v{{ plugin.version }}</span></span> - <MkSwitch :modelValue="plugin.active" @update:modelValue="changeActive(plugin, $event)">{{ i18n.ts.makeActive }}</MkSwitch> - </div> + <FormLink to="/settings/plugin/install"><template #icon><i class="ti ti-download"></i></template>{{ i18n.ts._plugin.install }}</FormLink> - <div class="_gaps_s"> - <MkKeyValue> - <template #key>{{ i18n.ts.author }}</template> - <template #value>{{ plugin.author }}</template> - </MkKeyValue> - <MkKeyValue> - <template #key>{{ i18n.ts.description }}</template> - <template #value>{{ plugin.description }}</template> - </MkKeyValue> - <MkKeyValue> - <template #key>{{ i18n.ts.permission }}</template> - <template #value> - <ul style="margin-top: 0; margin-bottom: 0;"> - <li v-for="permission in plugin.permissions" :key="permission">{{ i18n.ts._permissions[permission] }}</li> - <li v-if="!plugin.permissions || plugin.permissions.length === 0">{{ i18n.ts.none }}</li> - </ul> - </template> - </MkKeyValue> - </div> - - <div class="_buttons"> - <MkButton v-if="plugin.config" inline @click="config(plugin)"><i class="ti ti-settings"></i> {{ i18n.ts.settings }}</MkButton> - <MkButton inline danger @click="uninstall(plugin)"><i class="ti ti-trash"></i> {{ i18n.ts.uninstall }}</MkButton> - </div> + <FormSection> + <template #label>{{ i18n.ts.manage }}</template> + <div class="_gaps_s"> + <MkFolder v-for="plugin in plugins" :key="plugin.installId"> + <template #icon><i class="ti ti-plug"></i></template> + <template #suffix> + <i v-if="plugin.active" class="ti ti-player-play" style="color: var(--MI_THEME-success);"></i> + <i v-else class="ti ti-player-pause" style="opacity: 0.7;"></i> + </template> + <template #label> + <div :style="plugin.active ? '' : 'opacity: 0.7;'"> + {{ plugin.name }} + <span style="margin-left: 1em; opacity: 0.7;">v{{ plugin.version }}</span> + </div> + </template> + <template #caption> + {{ plugin.description }} + </template> + <template #footer> + <div class="_buttons"> + <MkButton :disabled="!plugin.active" @click="reload(plugin)"><i class="ti ti-refresh"></i> {{ i18n.ts.reload }}</MkButton> + <MkButton danger @click="uninstall(plugin)"><i class="ti ti-trash"></i> {{ i18n.ts.uninstall }}</MkButton> + <MkButton v-if="plugin.config" style="margin-left: auto;" @click="config(plugin)"><i class="ti ti-settings"></i> {{ i18n.ts.settings }}</MkButton> + </div> + </template> - <MkFolder> - <template #icon><i class="ti ti-terminal-2"></i></template> - <template #label>{{ i18n.ts._plugin.viewLog }}</template> + <div class="_gaps_m"> + <div class="_gaps_s"> + <MkSwitch :modelValue="plugin.active" @update:modelValue="changeActive(plugin, $event)">{{ i18n.ts.makeActive }}</MkSwitch> + </div> - <div class="_gaps_s"> - <div class="_buttons"> - <MkButton inline @click="copy(pluginLogs.get(plugin.id)?.join('\n'))"><i class="ti ti-copy"></i> {{ i18n.ts.copy }}</MkButton> + <div class="_gaps_s"> + <MkKeyValue> + <template #key>{{ i18n.ts.author }}</template> + <template #value>{{ plugin.author }}</template> + </MkKeyValue> + <MkKeyValue> + <template #key>{{ i18n.ts.description }}</template> + <template #value>{{ plugin.description }}</template> + </MkKeyValue> + <MkKeyValue> + <template #key>{{ i18n.ts.permission }}</template> + <template #value> + <ul style="margin-top: 0; margin-bottom: 0;"> + <li v-for="permission in plugin.permissions" :key="permission">{{ i18n.ts._permissions[permission] }}</li> + <li v-if="!plugin.permissions || plugin.permissions.length === 0">{{ i18n.ts.none }}</li> + </ul> + </template> + </MkKeyValue> </div> - <MkCode :code="pluginLogs.get(plugin.id)?.join('\n') ?? ''"/> - </div> - </MkFolder> + <div class="_gaps_s"> + <MkFolder> + <template #icon><i class="ti ti-terminal-2"></i></template> + <template #label>{{ i18n.ts.logs }}</template> - <MkFolder> - <template #icon><i class="ti ti-code"></i></template> - <template #label>{{ i18n.ts._plugin.viewSource }}</template> + <div> + <div v-for="log in pluginLogs.get(plugin.installId)" :class="[$style.log, { [$style.isSystemLog]: log.isSystem }]"> + <div class="_monospace">{{ timeToHhMmSs(log.at) }} {{ log.message }}</div> + </div> + </div> + </MkFolder> - <div class="_gaps_s"> - <div class="_buttons"> - <MkButton inline @click="copy(plugin.src)"><i class="ti ti-copy"></i> {{ i18n.ts.copy }}</MkButton> - </div> + <MkFolder :withSpacer="false"> + <template #icon><i class="ti ti-code"></i></template> + <template #label>{{ i18n.ts._plugin.viewSource }}</template> - <MkCode :code="plugin.src ?? ''" lang="is"/> + <div class="_gaps_s"> + <MkCode :code="plugin.src ?? ''" lang="ais"/> + </div> + </MkFolder> + </div> </div> </MkFolder> </div> - </div> - </FormSection> -</div> + </FormSection> + </div> +</SearchMarker> </template> <script lang="ts" setup> import { nextTick, ref, computed } from 'vue'; +import type { Plugin } from '@/plugin.js'; import FormLink from '@/components/form/link.vue'; import MkSwitch from '@/components/MkSwitch.vue'; import FormSection from '@/components/form/section.vue'; @@ -81,66 +102,58 @@ import MkButton from '@/components/MkButton.vue'; import MkCode from '@/components/MkCode.vue'; import MkFolder from '@/components/MkFolder.vue'; import MkKeyValue from '@/components/MkKeyValue.vue'; -import * as os from '@/os.js'; -import { copyToClipboard } from '@/scripts/copy-to-clipboard.js'; -import { ColdDeviceStorage } from '@/store.js'; -import { unisonReload } from '@/scripts/unison-reload.js'; +import MkFeatureBanner from '@/components/MkFeatureBanner.vue'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; -import { pluginLogs } from '@/plugin.js'; +import { definePage } from '@/page.js'; +import { changePluginActive, configPlugin, pluginLogs, uninstallPlugin, reloadPlugin } from '@/plugin.js'; +import { prefer } from '@/preferences.js'; +import * as os from '@/os.js'; -const plugins = ref(ColdDeviceStorage.get('plugins')); +const plugins = prefer.r.plugins; -async function uninstall(plugin) { - ColdDeviceStorage.set('plugins', plugins.value.filter(x => x.id !== plugin.id)); - await os.apiWithDialog('i/revoke-token', { - token: plugin.token, - }); - nextTick(() => { - unisonReload(); +async function uninstall(plugin: Plugin) { + const { canceled } = await os.confirm({ + type: 'warning', + text: i18n.tsx.removeAreYouSure({ x: plugin.name }), }); -} + if (canceled) return; + + await uninstallPlugin(plugin); -function copy(text) { - copyToClipboard(text ?? ''); os.success(); } -// TODO: この処理をstore側にactionとして移動し、設定画面を開くAiScriptAPIを実装できるようにする -async function config(plugin) { - const config = plugin.config; - for (const key in plugin.configData) { - config[key].default = plugin.configData[key]; - } - - const { canceled, result } = await os.form(plugin.name, config); - if (canceled) return; - - const coldPlugins = ColdDeviceStorage.get('plugins'); - coldPlugins.find(p => p.id === plugin.id)!.configData = result; - ColdDeviceStorage.set('plugins', coldPlugins); +function reload(plugin: Plugin) { + reloadPlugin(plugin); +} - nextTick(() => { - location.reload(); - }); +async function config(plugin: Plugin) { + await configPlugin(plugin); } -function changeActive(plugin, active) { - const coldPlugins = ColdDeviceStorage.get('plugins'); - coldPlugins.find(p => p.id === plugin.id)!.active = active; - ColdDeviceStorage.set('plugins', coldPlugins); +function changeActive(plugin: Plugin, active: boolean) { + changePluginActive(plugin, active); +} - nextTick(() => { - location.reload(); - }); +function timeToHhMmSs(unixtime: number) { + return new Date(unixtime).toTimeString().split(' ')[0]; } const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.plugins, icon: 'ti ti-plug', })); </script> + +<style module> +.log { +} + +.isSystemLog { + opacity: 0.5; +} +</style> diff --git a/packages/frontend/src/pages/settings/preferences-backups.vue b/packages/frontend/src/pages/settings/preferences-backups.vue deleted file mode 100644 index 7388e014ed..0000000000 --- a/packages/frontend/src/pages/settings/preferences-backups.vue +++ /dev/null @@ -1,465 +0,0 @@ -<!-- -SPDX-FileCopyrightText: syuilo and misskey-project -SPDX-License-Identifier: AGPL-3.0-only ---> - -<template> -<div class="_gaps_m"> - <div :class="$style.buttons"> - <MkButton inline primary @click="saveNew">{{ i18n.ts._preferencesBackups.saveNew }}</MkButton> - <MkButton inline @click="loadFile">{{ i18n.ts._preferencesBackups.loadFile }}</MkButton> - </div> - - <FormSection> - <template #label>{{ i18n.ts._preferencesBackups.list }}</template> - <template v-if="profiles && Object.keys(profiles).length > 0"> - <div class="_gaps_s"> - <div - v-for="(profile, id) in profiles" - :key="id" - class="_panel" - :class="$style.profile" - @click="$event => menu($event, id)" - @contextmenu.prevent.stop="$event => menu($event, id)" - > - <div :class="$style.profileName">{{ profile.name }}</div> - <div :class="$style.profileTime">{{ i18n.tsx._preferencesBackups.createdAt({ date: (new Date(profile.createdAt)).toLocaleDateString(), time: (new Date(profile.createdAt)).toLocaleTimeString() }) }}</div> - <div v-if="profile.updatedAt" :class="$style.profileTime">{{ i18n.tsx._preferencesBackups.updatedAt({ date: (new Date(profile.updatedAt)).toLocaleDateString(), time: (new Date(profile.updatedAt)).toLocaleTimeString() }) }}</div> - </div> - </div> - </template> - <div v-else-if="profiles"> - <MkInfo>{{ i18n.ts._preferencesBackups.noBackups }}</MkInfo> - </div> - <MkLoading v-else/> - </FormSection> -</div> -</template> - -<script lang="ts" setup> -import { onMounted, onUnmounted, ref } from 'vue'; -import { v4 as uuid } from 'uuid'; -import { version, host } from '@@/js/config.js'; -import FormSection from '@/components/form/section.vue'; -import MkButton from '@/components/MkButton.vue'; -import MkInfo from '@/components/MkInfo.vue'; -import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { ColdDeviceStorage, defaultStore } from '@/store.js'; -import { unisonReload } from '@/scripts/unison-reload.js'; -import { useStream } from '@/stream.js'; -import { $i } from '@/account.js'; -import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; -import { miLocalStorage } from '@/local-storage.js'; - -const defaultStoreSaveKeys: (keyof typeof defaultStore['state'])[] = [ - 'collapseRenotes', - 'menu', - 'visibility', - 'localOnly', - 'statusbars', - 'widgets', - 'tl', - 'pinnedUserLists', - 'overridedDeviceKind', - 'serverDisconnectedBehavior', - 'nsfw', - 'highlightSensitiveMedia', - 'animation', - 'animatedMfm', - 'advancedMfm', - 'showReactionsCount', - 'loadRawImages', - 'imageNewTab', - 'dataSaver', - 'disableShowingAnimatedImages', - 'emojiStyle', - 'menuStyle', - 'useBlurEffectForModal', - 'useBlurEffect', - 'showFixedPostForm', - 'showFixedPostFormInChannel', - 'enableInfiniteScroll', - 'useReactionPickerForContextMenu', - 'showGapBetweenNotesInTimeline', - 'instanceTicker', - 'emojiPickerScale', - 'emojiPickerWidth', - 'emojiPickerHeight', - 'emojiPickerStyle', - 'defaultSideView', - 'menuDisplay', - 'reportError', - 'squareAvatars', - 'showAvatarDecorations', - 'numberOfPageCache', - 'showNoteActionsOnlyHover', - 'showClipButtonInNoteFooter', - 'reactionsDisplaySize', - 'forceShowAds', - 'aiChanMode', - 'devMode', - 'mediaListWithOneImageAppearance', - 'notificationPosition', - 'notificationStackAxis', - 'keepScreenOn', - 'defaultWithReplies', - 'disableStreamingTimeline', - 'useGroupedNotifications', - 'sound_masterVolume', - 'sound_note', - 'sound_noteMy', - 'sound_notification', -]; -const coldDeviceStorageSaveKeys: (keyof typeof ColdDeviceStorage.default)[] = [ - 'lightTheme', - 'darkTheme', - 'syncDeviceDarkMode', - 'plugins', -]; - -const scope = ['clientPreferencesProfiles']; - -const profileProps = ['name', 'createdAt', 'updatedAt', 'misskeyVersion', 'settings', 'host']; - -type Profile = { - name: string; - createdAt: string; - updatedAt: string | null; - misskeyVersion: string; - host: string; - settings: { - hot: Record<keyof typeof defaultStoreSaveKeys, unknown>; - cold: Record<keyof typeof coldDeviceStorageSaveKeys, unknown>; - fontSize: string | null; - useSystemFont: 't' | null; - wallpaper: string | null; - }; -}; - -const connection = $i && useStream().useChannel('main'); - -const profiles = ref<Record<string, Profile> | null>(null); - -misskeyApi('i/registry/get-all', { scope }) - .then(res => { - profiles.value = res || {}; - }); - -function isObject(value: unknown): value is Record<string, unknown> { - return value != null && typeof value === 'object' && !Array.isArray(value); -} - -function validate(profile: any): void { - if (!isObject(profile)) throw new Error('not an object'); - - // Check if unnecessary properties exist - if (Object.keys(profile).some(key => !profileProps.includes(key))) throw new Error('Unnecessary properties exist'); - - if (!profile.name) throw new Error('Missing required prop: name'); - if (!profile.misskeyVersion) throw new Error('Missing required prop: misskeyVersion'); - - // Check if createdAt and updatedAt is Date - // https://zenn.dev/lollipop_onl/articles/eoz-judge-js-invalid-date - if (!profile.createdAt || Number.isNaN(new Date(profile.createdAt as any).getTime())) throw new Error('createdAt is falsy or not Date'); - if (profile.updatedAt) { - if (Number.isNaN(new Date(profile.updatedAt as any).getTime())) { - throw new Error('updatedAt is not Date'); - } - } else if (profile.updatedAt !== null) { - throw new Error('updatedAt is not null'); - } - - if (!profile.settings) throw new Error('Missing required prop: settings'); - if (!isObject(profile.settings)) throw new Error('Invalid prop: settings'); -} - -function getSettings(): Profile['settings'] { - const hot = {} as Record<keyof typeof defaultStoreSaveKeys, unknown>; - for (const key of defaultStoreSaveKeys) { - hot[key] = defaultStore.state[key]; - } - - const cold = {} as Record<keyof typeof coldDeviceStorageSaveKeys, unknown>; - for (const key of coldDeviceStorageSaveKeys) { - cold[key] = ColdDeviceStorage.get(key); - } - - return { - hot, - cold, - fontSize: miLocalStorage.getItem('fontSize'), - useSystemFont: miLocalStorage.getItem('useSystemFont') as 't' | null, - wallpaper: miLocalStorage.getItem('wallpaper'), - }; -} - -async function saveNew(): Promise<void> { - if (!profiles.value) return; - - const { canceled, result: name } = await os.inputText({ - title: i18n.ts._preferencesBackups.inputName, - default: '', - }); - if (canceled) return; - - if (Object.values(profiles.value).some(x => x.name === name)) { - return os.alert({ - title: i18n.ts._preferencesBackups.cannotSave, - text: i18n.tsx._preferencesBackups.nameAlreadyExists({ name }), - }); - } - - const id = uuid(); - const profile: Profile = { - name, - createdAt: (new Date()).toISOString(), - updatedAt: null, - misskeyVersion: version, - host, - settings: getSettings(), - }; - await os.apiWithDialog('i/registry/set', { scope, key: id, value: profile }); -} - -function loadFile(): void { - const input = document.createElement('input'); - input.type = 'file'; - input.multiple = false; - input.onchange = async () => { - if (!profiles.value) return; - if (!input.files || input.files.length === 0) return; - - const file = input.files[0]; - - if (file.type !== 'application/json') { - return os.alert({ - type: 'error', - title: i18n.ts._preferencesBackups.cannotLoad, - text: i18n.ts._preferencesBackups.invalidFile, - }); - } - - let profile: Profile; - try { - profile = JSON.parse(await file.text()) as unknown as Profile; - validate(profile); - } catch (err) { - return os.alert({ - type: 'error', - title: i18n.ts._preferencesBackups.cannotLoad, - text: (err as any)?.message ?? '', - }); - } - - const id = uuid(); - await os.apiWithDialog('i/registry/set', { scope, key: id, value: profile }); - - // 一応廃棄 - (window as any).__misskey_input_ref__ = null; - }; - - // https://qiita.com/fukasawah/items/b9dc732d95d99551013d - // iOS Safari で正常に動かす為のおまじない - (window as any).__misskey_input_ref__ = input; - - input.click(); -} - -async function applyProfile(id: string): Promise<void> { - if (!profiles.value) return; - - const profile = profiles.value[id]; - - const { canceled: cancel1 } = await os.confirm({ - type: 'warning', - title: i18n.ts._preferencesBackups.apply, - text: i18n.tsx._preferencesBackups.applyConfirm({ name: profile.name }), - }); - if (cancel1) return; - - // TODO: バージョン or ホストが違ったらさらに警告を表示 - - const settings = profile.settings; - - // defaultStore - for (const key of defaultStoreSaveKeys) { - if (settings.hot[key] !== undefined) { - defaultStore.set(key, settings.hot[key]); - } - } - - // coldDeviceStorage - for (const key of coldDeviceStorageSaveKeys) { - if (settings.cold[key] !== undefined) { - ColdDeviceStorage.set(key, settings.cold[key]); - } - } - - // fontSize - if (settings.fontSize) { - miLocalStorage.setItem('fontSize', settings.fontSize); - } else { - miLocalStorage.removeItem('fontSize'); - } - - // useSystemFont - if (settings.useSystemFont) { - miLocalStorage.setItem('useSystemFont', settings.useSystemFont); - } else { - miLocalStorage.removeItem('useSystemFont'); - } - - // wallpaper - if (settings.wallpaper != null) { - miLocalStorage.setItem('wallpaper', settings.wallpaper); - } else { - miLocalStorage.removeItem('wallpaper'); - } - - const { canceled: cancel2 } = await os.confirm({ - type: 'info', - text: i18n.ts.reloadToApplySetting, - }); - if (cancel2) return; - - unisonReload(); -} - -async function deleteProfile(id: string): Promise<void> { - if (!profiles.value) return; - - const { canceled } = await os.confirm({ - type: 'info', - title: i18n.ts.delete, - text: i18n.tsx.deleteAreYouSure({ x: profiles.value[id].name }), - }); - if (canceled) return; - - await os.apiWithDialog('i/registry/remove', { scope, key: id }); - delete profiles.value[id]; -} - -async function save(id: string): Promise<void> { - if (!profiles.value) return; - - const { name, createdAt } = profiles.value[id]; - - const { canceled } = await os.confirm({ - type: 'info', - title: i18n.ts._preferencesBackups.save, - text: i18n.tsx._preferencesBackups.saveConfirm({ name }), - }); - if (canceled) return; - - const profile: Profile = { - name, - createdAt, - updatedAt: (new Date()).toISOString(), - misskeyVersion: version, - host, - settings: getSettings(), - }; - await os.apiWithDialog('i/registry/set', { scope, key: id, value: profile }); -} - -async function rename(id: string): Promise<void> { - if (!profiles.value) return; - - const { canceled: cancel1, result: name } = await os.inputText({ - title: i18n.ts._preferencesBackups.inputName, - default: '', - }); - if (cancel1 || profiles.value[id].name === name) return; - - if (Object.values(profiles.value).some(x => x.name === name)) { - return os.alert({ - title: i18n.ts._preferencesBackups.cannotSave, - text: i18n.tsx._preferencesBackups.nameAlreadyExists({ name }), - }); - } - - const registry = Object.assign({}, { ...profiles.value[id] }); - - const { canceled: cancel2 } = await os.confirm({ - type: 'info', - title: i18n.ts.rename, - text: i18n.tsx._preferencesBackups.renameConfirm({ old: registry.name, new: name }), - }); - if (cancel2) return; - - registry.name = name; - await os.apiWithDialog('i/registry/set', { scope, key: id, value: registry }); -} - -function menu(ev: MouseEvent, profileId: string) { - if (!profiles.value) return; - - return os.popupMenu([{ - text: i18n.ts._preferencesBackups.apply, - icon: 'ti ti-check', - action: () => applyProfile(profileId), - }, { - type: 'a', - text: i18n.ts.download, - icon: 'ti ti-download', - href: URL.createObjectURL(new Blob([JSON.stringify(profiles.value[profileId], null, 2)], { type: 'application/json' })), - download: `${profiles.value[profileId].name}.json`, - }, { type: 'divider' }, { - text: i18n.ts.rename, - icon: 'ti ti-forms', - action: () => rename(profileId), - }, { - text: i18n.ts._preferencesBackups.save, - icon: 'ti ti-device-floppy', - action: () => save(profileId), - }, { type: 'divider' }, { - text: i18n.ts.delete, - icon: 'ti ti-trash', - action: () => deleteProfile(profileId), - danger: true, - }], (ev.currentTarget ?? ev.target ?? undefined) as unknown as HTMLElement | undefined); -} - -onMounted(() => { - // streamingのuser storage updateイベントを監視して更新 - connection?.on('registryUpdated', ({ scope: recievedScope, key, value }) => { - if (!recievedScope || recievedScope.length !== scope.length || recievedScope[0] !== scope[0]) return; - if (!profiles.value) return; - - profiles.value[key] = value; - }); -}); - -onUnmounted(() => { - connection?.off('registryUpdated'); -}); - -definePageMetadata(() => ({ - title: i18n.ts.preferencesBackups, - icon: 'ti ti-device-floppy', -})); -</script> - -<style lang="scss" module> -.buttons { - display: flex; - gap: var(--MI-margin); - flex-wrap: wrap; -} - -.profile { - padding: 20px; - cursor: pointer; - - &Name { - font-weight: 700; - } - - &Time { - font-size: .85em; - opacity: .7; - } -} -</style> diff --git a/packages/frontend/src/pages/settings/preferences.vue b/packages/frontend/src/pages/settings/preferences.vue index fe718bfa69..f96accf68a 100644 --- a/packages/frontend/src/pages/settings/preferences.vue +++ b/packages/frontend/src/pages/settings/preferences.vue @@ -6,274 +6,711 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <SearchMarker path="/settings/preferences" :label="i18n.ts.preferences" :keywords="['general', 'preferences']" icon="ti ti-adjustments"> <div class="_gaps_m"> - <SearchMarker :keywords="['language']"> - <MkSelect v-model="lang"> - <template #label><SearchLabel>{{ i18n.ts.uiLanguage }}</SearchLabel></template> - <option v-for="x in langs" :key="x[0]" :value="x[0]">{{ x[1] }}</option> - <template #caption> - <I18n :src="i18n.ts.i18nInfo" tag="span"> - <template #link> - <MkLink url="https://crowdin.com/project/misskey">Crowdin</MkLink> - </template> - </I18n> - </template> - </MkSelect> - </SearchMarker> + <MkFeatureBanner icon="/client-assets/gear_3d.png" color="#00ff9d"> + <SearchKeyword>{{ i18n.ts._settings.preferencesBanner }}</SearchKeyword> + </MkFeatureBanner> - <SearchMarker :keywords="['device', 'type', 'kind', 'smartphone', 'tablet', 'desktop']"> - <MkRadios v-model="overridedDeviceKind"> - <template #label><SearchLabel>{{ i18n.ts.overridedDeviceKind }}</SearchLabel></template> - <option :value="null">{{ i18n.ts.auto }}</option> - <option value="smartphone"><i class="ti ti-device-mobile"/> {{ i18n.ts.smartphone }}</option> - <option value="tablet"><i class="ti ti-device-tablet"/> {{ i18n.ts.tablet }}</option> - <option value="desktop"><i class="ti ti-device-desktop"/> {{ i18n.ts.desktop }}</option> - </MkRadios> - </SearchMarker> + <div class="_gaps_s"> + <SearchMarker v-slot="slotProps" :keywords="['general']"> + <MkFolder :defaultOpen="slotProps.isParentOfTarget"> + <template #label><SearchLabel>{{ i18n.ts.general }}</SearchLabel></template> + <template #icon><SearchIcon><i class="ti ti-settings"></i></SearchIcon></template> - <FormSection> - <div class="_gaps_s"> - <SearchMarker :keywords="['post', 'form', 'timeline']"> - <MkSwitch v-model="showFixedPostForm"> - <template #label><SearchLabel>{{ i18n.ts.showFixedPostForm }}</SearchLabel></template> - </MkSwitch> - </SearchMarker> + <div class="_gaps_m"> + <SearchMarker :keywords="['language']"> + <MkSelect v-model="lang"> + <template #label><SearchLabel>{{ i18n.ts.uiLanguage }}</SearchLabel></template> + <option v-for="x in langs" :key="x[0]" :value="x[0]">{{ x[1] }}</option> + <template #caption> + <I18n :src="i18n.ts.i18nInfo" tag="span"> + <template #link> + <MkLink url="https://crowdin.com/project/misskey">Crowdin</MkLink> + </template> + </I18n> + </template> + </MkSelect> + </SearchMarker> - <SearchMarker :keywords="['post', 'form', 'timeline', 'channel']"> - <MkSwitch v-model="showFixedPostFormInChannel"> - <template #label><SearchLabel>{{ i18n.ts.showFixedPostFormInChannel }}</SearchLabel></template> - </MkSwitch> - </SearchMarker> + <SearchMarker :keywords="['device', 'type', 'kind', 'smartphone', 'tablet', 'desktop']"> + <MkRadios v-model="overridedDeviceKind"> + <template #label><SearchLabel>{{ i18n.ts.overridedDeviceKind }}</SearchLabel></template> + <option :value="null">{{ i18n.ts.auto }}</option> + <option value="smartphone"><i class="ti ti-device-mobile"/> {{ i18n.ts.smartphone }}</option> + <option value="tablet"><i class="ti ti-device-tablet"/> {{ i18n.ts.tablet }}</option> + <option value="desktop"><i class="ti ti-device-desktop"/> {{ i18n.ts.desktop }}</option> + </MkRadios> + </SearchMarker> - <SearchMarker :keywords="['pinned', 'list']"> - <MkFolder> - <template #label><SearchLabel>{{ i18n.ts.pinnedList }}</SearchLabel></template> - <!-- 複数ピン止め管理できるようにしたいけどめんどいので一旦ひとつのみ --> - <MkButton v-if="defaultStore.reactiveState.pinnedUserLists.value.length === 0" @click="setPinnedList()">{{ i18n.ts.add }}</MkButton> - <MkButton v-else danger @click="removePinnedList()"><i class="ti ti-trash"></i> {{ i18n.ts.remove }}</MkButton> - </MkFolder> - </SearchMarker> + <div class="_gaps_s"> + <SearchMarker :keywords="['avatar', 'icon', 'decoration', 'show']"> + <MkPreferenceContainer k="showAvatarDecorations"> + <MkSwitch v-model="showAvatarDecorations"> + <template #label><SearchLabel>{{ i18n.ts.showAvatarDecorations }}</SearchLabel></template> + </MkSwitch> + </MkPreferenceContainer> + </SearchMarker> - <SearchMarker :keywords="['mfm', 'enable', 'show', 'advanced', 'picker', 'form', 'function', 'fn']"> - <MkSwitch v-model="enableQuickAddMfmFunction"> - <template #label><SearchLabel>{{ i18n.ts.enableQuickAddMfmFunction }}</SearchLabel></template> - </MkSwitch> - </SearchMarker> - </div> - </FormSection> + <SearchMarker :keywords="['follow', 'confirm', 'always']"> + <MkPreferenceContainer k="alwaysConfirmFollow"> + <MkSwitch v-model="alwaysConfirmFollow"> + <template #label><SearchLabel>{{ i18n.ts.alwaysConfirmFollow }}</SearchLabel></template> + </MkSwitch> + </MkPreferenceContainer> + </SearchMarker> - <SearchMarker :keywords="['note']"> - <FormSection> - <template #label><SearchLabel>{{ i18n.ts.note }}</SearchLabel></template> + <SearchMarker :keywords="['highlight', 'sensitive', 'nsfw', 'image', 'photo', 'picture', 'media', 'thumbnail']"> + <MkPreferenceContainer k="highlightSensitiveMedia"> + <MkSwitch v-model="highlightSensitiveMedia"> + <template #label><SearchLabel>{{ i18n.ts.highlightSensitiveMedia }}</SearchLabel></template> + </MkSwitch> + </MkPreferenceContainer> + </SearchMarker> - <div class="_gaps_m"> - <div class="_gaps_s"> - <SearchMarker :keywords="['renote']"> - <MkSwitch v-model="collapseRenotes"> - <template #label><SearchLabel>{{ i18n.ts.collapseRenotes }}</SearchLabel></template> - <template #caption><SearchKeyword>{{ i18n.ts.collapseRenotesDescription }}</SearchKeyword></template> - </MkSwitch> - </SearchMarker> + <SearchMarker :keywords="['sensitive', 'nsfw', 'media', 'image', 'photo', 'picture', 'attachment', 'confirm']"> + <MkPreferenceContainer k="confirmWhenRevealingSensitiveMedia"> + <MkSwitch v-model="confirmWhenRevealingSensitiveMedia"> + <template #label><SearchLabel>{{ i18n.ts.confirmWhenRevealingSensitiveMedia }}</SearchLabel></template> + </MkSwitch> + </MkPreferenceContainer> + </SearchMarker> - <SearchMarker :keywords="['hover', 'show', 'footer', 'action']"> - <MkSwitch v-model="showNoteActionsOnlyHover"> - <template #label><SearchLabel>{{ i18n.ts.showNoteActionsOnlyHover }}</SearchLabel></template> - </MkSwitch> + <SearchMarker :keywords="['mfm', 'enable', 'show', 'advanced']"> + <MkPreferenceContainer k="advancedMfm"> + <MkSwitch v-model="advancedMfm"> + <template #label><SearchLabel>{{ i18n.ts.enableAdvancedMfm }}</SearchLabel></template> + </MkSwitch> + </MkPreferenceContainer> + </SearchMarker> + + <SearchMarker :keywords="['auto', 'load', 'auto', 'more', 'scroll']"> + <MkPreferenceContainer k="enableInfiniteScroll"> + <MkSwitch v-model="enableInfiniteScroll"> + <template #label><SearchLabel>{{ i18n.ts.enableInfiniteScroll }}</SearchLabel></template> + </MkSwitch> + </MkPreferenceContainer> + </SearchMarker> + </div> + + <SearchMarker :keywords="['emoji', 'style', 'native', 'system', 'fluent', 'twemoji']"> + <MkPreferenceContainer k="emojiStyle"> + <div> + <MkRadios v-model="emojiStyle"> + <template #label><SearchLabel>{{ i18n.ts.emojiStyle }}</SearchLabel></template> + <option value="native">{{ i18n.ts.native }}</option> + <option value="fluentEmoji">Fluent Emoji</option> + <option value="twemoji">Twemoji</option> + </MkRadios> + <div style="margin: 8px 0 0 0; font-size: 1.5em;"><Mfm :key="emojiStyle" text="🍮🍦🍭🍩🍰🍫🍬🥞🍪"/></div> + </div> + </MkPreferenceContainer> </SearchMarker> + </div> + </MkFolder> + </SearchMarker> - <SearchMarker :keywords="['footer', 'action', 'clip', 'show']"> - <MkSwitch v-model="showClipButtonInNoteFooter"> - <template #label><SearchLabel>{{ i18n.ts.showClipButtonInNoteFooter }}</SearchLabel></template> - </MkSwitch> + <SearchMarker v-slot="slotProps" :keywords="['timeline', 'note']"> + <MkFolder :defaultOpen="slotProps.isParentOfTarget"> + <template #label><SearchLabel>{{ i18n.ts._settings.timelineAndNote }}</SearchLabel></template> + <template #icon><SearchIcon><i class="ti ti-notes"></i></SearchIcon></template> + + <div class="_gaps_m"> + <div class="_gaps_s"> + <SearchMarker :keywords="['post', 'form', 'timeline']"> + <MkPreferenceContainer k="showFixedPostForm"> + <MkSwitch v-model="showFixedPostForm"> + <template #label><SearchLabel>{{ i18n.ts.showFixedPostForm }}</SearchLabel></template> + </MkSwitch> + </MkPreferenceContainer> + </SearchMarker> + + <SearchMarker :keywords="['post', 'form', 'timeline', 'channel']"> + <MkPreferenceContainer k="showFixedPostFormInChannel"> + <MkSwitch v-model="showFixedPostFormInChannel"> + <template #label><SearchLabel>{{ i18n.ts.showFixedPostFormInChannel }}</SearchLabel></template> + </MkSwitch> + </MkPreferenceContainer> + </SearchMarker> + + <SearchMarker :keywords="['renote']"> + <MkPreferenceContainer k="collapseRenotes"> + <MkSwitch v-model="collapseRenotes"> + <template #label><SearchLabel>{{ i18n.ts.collapseRenotes }}</SearchLabel></template> + <template #caption><SearchKeyword>{{ i18n.ts.collapseRenotesDescription }}</SearchKeyword></template> + </MkSwitch> + </MkPreferenceContainer> + </SearchMarker> + + <SearchMarker :keywords="['note', 'timeline', 'gap']"> + <MkPreferenceContainer k="showGapBetweenNotesInTimeline"> + <MkSwitch v-model="showGapBetweenNotesInTimeline"> + <template #label><SearchLabel>{{ i18n.ts.showGapBetweenNotesInTimeline }}</SearchLabel></template> + </MkSwitch> + </MkPreferenceContainer> + </SearchMarker> + + <SearchMarker :keywords="['disable', 'streaming', 'timeline']"> + <MkPreferenceContainer k="disableStreamingTimeline"> + <MkSwitch v-model="disableStreamingTimeline"> + <template #label><SearchLabel>{{ i18n.ts.disableStreamingTimeline }}</SearchLabel></template> + </MkSwitch> + </MkPreferenceContainer> + </SearchMarker> + + <SearchMarker :keywords="['pinned', 'list']"> + <MkFolder> + <template #label><SearchLabel>{{ i18n.ts.pinnedList }}</SearchLabel></template> + <!-- 複数ピン止め管理できるようにしたいけどめんどいので一旦ひとつのみ --> + <MkButton v-if="prefer.r.pinnedUserLists.value.length === 0" @click="setPinnedList()">{{ i18n.ts.add }}</MkButton> + <MkButton v-else danger @click="removePinnedList()"><i class="ti ti-trash"></i> {{ i18n.ts.remove }}</MkButton> + </MkFolder> + </SearchMarker> + </div> + + <hr> + + <div class="_gaps_m"> + <div class="_gaps_s"> + <SearchMarker :keywords="['hover', 'show', 'footer', 'action']"> + <MkPreferenceContainer k="showNoteActionsOnlyHover"> + <MkSwitch v-model="showNoteActionsOnlyHover"> + <template #label><SearchLabel>{{ i18n.ts.showNoteActionsOnlyHover }}</SearchLabel></template> + </MkSwitch> + </MkPreferenceContainer> + </SearchMarker> + + <SearchMarker :keywords="['footer', 'action', 'clip', 'show']"> + <MkPreferenceContainer k="showClipButtonInNoteFooter"> + <MkSwitch v-model="showClipButtonInNoteFooter"> + <template #label><SearchLabel>{{ i18n.ts.showClipButtonInNoteFooter }}</SearchLabel></template> + </MkSwitch> + </MkPreferenceContainer> + </SearchMarker> + + <SearchMarker :keywords="['reaction', 'count', 'show']"> + <MkPreferenceContainer k="showReactionsCount"> + <MkSwitch v-model="showReactionsCount"> + <template #label><SearchLabel>{{ i18n.ts.showReactionsCount }}</SearchLabel></template> + </MkSwitch> + </MkPreferenceContainer> + </SearchMarker> + + <SearchMarker :keywords="['reaction', 'confirm']"> + <MkPreferenceContainer k="confirmOnReact"> + <MkSwitch v-model="confirmOnReact"> + <template #label><SearchLabel>{{ i18n.ts.confirmOnReact }}</SearchLabel></template> + </MkSwitch> + </MkPreferenceContainer> + </SearchMarker> + + <SearchMarker :keywords="['image', 'photo', 'picture', 'media', 'thumbnail', 'quality', 'raw', 'attachment']"> + <MkPreferenceContainer k="loadRawImages"> + <MkSwitch v-model="loadRawImages"> + <template #label><SearchLabel>{{ i18n.ts.loadRawImages }}</SearchLabel></template> + </MkSwitch> + </MkPreferenceContainer> + </SearchMarker> + + <SearchMarker :keywords="['reaction', 'picker', 'contextmenu', 'open']"> + <MkPreferenceContainer k="useReactionPickerForContextMenu"> + <MkSwitch v-model="useReactionPickerForContextMenu"> + <template #label><SearchLabel>{{ i18n.ts.useReactionPickerForContextMenu }}</SearchLabel></template> + </MkSwitch> + </MkPreferenceContainer> + </SearchMarker> + </div> + + <SearchMarker :keywords="['reaction', 'size', 'scale', 'display']"> + <MkPreferenceContainer k="reactionsDisplaySize"> + <MkRadios v-model="reactionsDisplaySize"> + <template #label><SearchLabel>{{ i18n.ts.reactionsDisplaySize }}</SearchLabel></template> + <option value="small">{{ i18n.ts.small }}</option> + <option value="medium">{{ i18n.ts.medium }}</option> + <option value="large">{{ i18n.ts.large }}</option> + </MkRadios> + </MkPreferenceContainer> + </SearchMarker> + + <SearchMarker :keywords="['reaction', 'size', 'scale', 'display', 'width', 'limit']"> + <MkPreferenceContainer k="limitWidthOfReaction"> + <MkSwitch v-model="limitWidthOfReaction"> + <template #label><SearchLabel>{{ i18n.ts.limitWidthOfReaction }}</SearchLabel></template> + </MkSwitch> + </MkPreferenceContainer> + </SearchMarker> + + <SearchMarker :keywords="['attachment', 'image', 'photo', 'picture', 'media', 'thumbnail', 'list', 'size', 'height']"> + <MkPreferenceContainer k="mediaListWithOneImageAppearance"> + <MkRadios v-model="mediaListWithOneImageAppearance"> + <template #label><SearchLabel>{{ i18n.ts.mediaListWithOneImageAppearance }}</SearchLabel></template> + <option value="expand">{{ i18n.ts.default }}</option> + <option value="16_9">{{ i18n.tsx.limitTo({ x: '16:9' }) }}</option> + <option value="1_1">{{ i18n.tsx.limitTo({ x: '1:1' }) }}</option> + <option value="2_3">{{ i18n.tsx.limitTo({ x: '2:3' }) }}</option> + </MkRadios> + </MkPreferenceContainer> + </SearchMarker> + + <SearchMarker :keywords="['ticker', 'information', 'label', 'instance', 'server', 'host', 'federation']"> + <MkPreferenceContainer k="instanceTicker"> + <MkSelect v-if="instance.federation !== 'none'" v-model="instanceTicker"> + <template #label><SearchLabel>{{ i18n.ts.instanceTicker }}</SearchLabel></template> + <option value="none">{{ i18n.ts._instanceTicker.none }}</option> + <option value="remote">{{ i18n.ts._instanceTicker.remote }}</option> + <option value="always">{{ i18n.ts._instanceTicker.always }}</option> + </MkSelect> + </MkPreferenceContainer> + </SearchMarker> + + <SearchMarker :keywords="['attachment', 'image', 'photo', 'picture', 'media', 'thumbnail', 'nsfw', 'sensitive', 'display', 'show', 'hide', 'visibility']"> + <MkPreferenceContainer k="nsfw"> + <MkSelect v-model="nsfw"> + <template #label><SearchLabel>{{ i18n.ts.displayOfSensitiveMedia }}</SearchLabel></template> + <option value="respect">{{ i18n.ts._displayOfSensitiveMedia.respect }}</option> + <option value="ignore">{{ i18n.ts._displayOfSensitiveMedia.ignore }}</option> + <option value="force">{{ i18n.ts._displayOfSensitiveMedia.force }}</option> + </MkSelect> + </MkPreferenceContainer> + </SearchMarker> + </div> + </div> + </MkFolder> + </SearchMarker> + + <SearchMarker v-slot="slotProps" :keywords="['post', 'form']"> + <MkFolder :defaultOpen="slotProps.isParentOfTarget"> + <template #label><SearchLabel>{{ i18n.ts.postForm }}</SearchLabel></template> + <template #icon><SearchIcon><i class="ti ti-edit"></i></SearchIcon></template> + + <div class="_gaps_m"> + <div class="_gaps_s"> + <SearchMarker :keywords="['remember', 'keep', 'note', 'cw']"> + <MkPreferenceContainer k="keepCw"> + <MkSwitch v-model="keepCw"> + <template #label><SearchLabel>{{ i18n.ts.keepCw }}</SearchLabel></template> + </MkSwitch> + </MkPreferenceContainer> + </SearchMarker> + + <SearchMarker :keywords="['remember', 'keep', 'note', 'visibility']"> + <MkPreferenceContainer k="rememberNoteVisibility"> + <MkSwitch v-model="rememberNoteVisibility"> + <template #label><SearchLabel>{{ i18n.ts.rememberNoteVisibility }}</SearchLabel></template> + </MkSwitch> + </MkPreferenceContainer> + </SearchMarker> + + <SearchMarker :keywords="['mfm', 'enable', 'show', 'advanced', 'picker', 'form', 'function', 'fn']"> + <MkPreferenceContainer k="enableQuickAddMfmFunction"> + <MkSwitch v-model="enableQuickAddMfmFunction"> + <template #label><SearchLabel>{{ i18n.ts.enableQuickAddMfmFunction }}</SearchLabel></template> + </MkSwitch> + </MkPreferenceContainer> + </SearchMarker> + </div> + + <SearchMarker :keywords="['default', 'note', 'visibility']"> + <MkDisableSection :disabled="rememberNoteVisibility"> + <MkFolder> + <template #label><SearchLabel>{{ i18n.ts.defaultNoteVisibility }}</SearchLabel></template> + <template v-if="defaultNoteVisibility === 'public'" #suffix>{{ i18n.ts._visibility.public }}</template> + <template v-else-if="defaultNoteVisibility === 'home'" #suffix>{{ i18n.ts._visibility.home }}</template> + <template v-else-if="defaultNoteVisibility === 'followers'" #suffix>{{ i18n.ts._visibility.followers }}</template> + <template v-else-if="defaultNoteVisibility === 'specified'" #suffix>{{ i18n.ts._visibility.specified }}</template> + + <div class="_gaps_m"> + <MkPreferenceContainer k="defaultNoteVisibility"> + <MkSelect v-model="defaultNoteVisibility"> + <option value="public">{{ i18n.ts._visibility.public }}</option> + <option value="home">{{ i18n.ts._visibility.home }}</option> + <option value="followers">{{ i18n.ts._visibility.followers }}</option> + <option value="specified">{{ i18n.ts._visibility.specified }}</option> + </MkSelect> + </MkPreferenceContainer> + + <MkPreferenceContainer k="defaultNoteLocalOnly"> + <MkSwitch v-model="defaultNoteLocalOnly">{{ i18n.ts._visibility.disableFederation }}</MkSwitch> + </MkPreferenceContainer> + </div> + </MkFolder> + </MkDisableSection> </SearchMarker> + </div> + </MkFolder> + </SearchMarker> - <SearchMarker :keywords="['mfm', 'enable', 'show', 'advanced']"> - <MkSwitch v-model="advancedMfm"> - <template #label><SearchLabel>{{ i18n.ts.enableAdvancedMfm }}</SearchLabel></template> - </MkSwitch> + <SearchMarker v-slot="slotProps" :keywords="['notification']"> + <MkFolder :defaultOpen="slotProps.isParentOfTarget"> + <template #label><SearchLabel>{{ i18n.ts.notifications }}</SearchLabel></template> + <template #icon><SearchIcon><i class="ti ti-bell"></i></SearchIcon></template> + + <div class="_gaps_m"> + <SearchMarker :keywords="['group']"> + <MkPreferenceContainer k="useGroupedNotifications"> + <MkSwitch v-model="useGroupedNotifications"> + <template #label><SearchLabel>{{ i18n.ts.useGroupedNotifications }}</SearchLabel></template> + </MkSwitch> + </MkPreferenceContainer> </SearchMarker> - <SearchMarker :keywords="['reaction', 'count', 'show']"> - <MkSwitch v-model="showReactionsCount"> - <template #label><SearchLabel>{{ i18n.ts.showReactionsCount }}</SearchLabel></template> - </MkSwitch> + <SearchMarker :keywords="['position']"> + <MkPreferenceContainer k="notificationPosition"> + <MkRadios v-model="notificationPosition"> + <template #label><SearchLabel>{{ i18n.ts.position }}</SearchLabel></template> + <option value="leftTop"><i class="ti ti-align-box-left-top"></i> {{ i18n.ts.leftTop }}</option> + <option value="rightTop"><i class="ti ti-align-box-right-top"></i> {{ i18n.ts.rightTop }}</option> + <option value="leftBottom"><i class="ti ti-align-box-left-bottom"></i> {{ i18n.ts.leftBottom }}</option> + <option value="rightBottom"><i class="ti ti-align-box-right-bottom"></i> {{ i18n.ts.rightBottom }}</option> + </MkRadios> + </MkPreferenceContainer> </SearchMarker> - <SearchMarker :keywords="['image', 'photo', 'picture', 'media', 'thumbnail', 'quality', 'raw', 'attachment']"> - <MkSwitch v-model="loadRawImages"> - <template #label><SearchLabel>{{ i18n.ts.loadRawImages }}</SearchLabel></template> - </MkSwitch> + <SearchMarker :keywords="['stack', 'axis', 'direction']"> + <MkPreferenceContainer k="notificationStackAxis"> + <MkRadios v-model="notificationStackAxis"> + <template #label><SearchLabel>{{ i18n.ts.stackAxis }}</SearchLabel></template> + <option value="vertical"><i class="ti ti-carousel-vertical"></i> {{ i18n.ts.vertical }}</option> + <option value="horizontal"><i class="ti ti-carousel-horizontal"></i> {{ i18n.ts.horizontal }}</option> + </MkRadios> + </MkPreferenceContainer> </SearchMarker> + + <MkButton @click="testNotification">{{ i18n.ts._notification.checkNotificationBehavior }}</MkButton> </div> - </div> - </FormSection> - </SearchMarker> + </MkFolder> + </SearchMarker> - <SearchMarker :keywords="['notification']"> - <FormSection> - <template #label><SearchLabel>{{ i18n.ts.notifications }}</SearchLabel></template> + <template v-if="$i.policies.chatAvailability !== 'unavailable'"> + <SearchMarker v-slot="slotProps" :keywords="['chat', 'messaging']"> + <MkFolder :defaultOpen="slotProps.isParentOfTarget"> + <template #label><SearchLabel>{{ i18n.ts.chat }}</SearchLabel></template> + <template #icon><SearchIcon><i class="ti ti-messages"></i></SearchIcon></template> - <div class="_gaps_m"> - <SearchMarker :keywords="['group']"> - <MkSwitch v-model="useGroupedNotifications"> - <template #label><SearchLabel>{{ i18n.ts.useGroupedNotifications }}</SearchLabel></template> - </MkSwitch> - </SearchMarker> - </div> - </FormSection> - </SearchMarker> + <div class="_gaps_s"> + <SearchMarker :keywords="['show', 'sender', 'name']"> + <MkPreferenceContainer k="chat.showSenderName"> + <MkSwitch v-model="chatShowSenderName"> + <template #label><SearchLabel>{{ i18n.ts._settings._chat.showSenderName }}</SearchLabel></template> + </MkSwitch> + </MkPreferenceContainer> + </SearchMarker> - <SearchMarker :keywords="['behavior']"> - <FormSection> - <template #label><SearchLabel>{{ i18n.ts.behavior }}</SearchLabel></template> + <SearchMarker :keywords="['send', 'enter', 'newline']"> + <MkPreferenceContainer k="chat.sendOnEnter"> + <MkSwitch v-model="chatSendOnEnter"> + <template #label><SearchLabel>{{ i18n.ts._settings._chat.sendOnEnter }}</SearchLabel></template> + <template #caption> + <div class="_gaps_s"> + <div> + <b>{{ i18n.ts._settings.ifOn }}:</b> + <div>{{ i18n.ts._chat.send }}: Enter</div> + <div>{{ i18n.ts._chat.newline }}: Shift + Enter</div> + </div> + <div> + <b>{{ i18n.ts._settings.ifOff }}:</b> + <div>{{ i18n.ts._chat.send }}: Ctrl + Enter</div> + <div>{{ i18n.ts._chat.newline }}: Enter</div> + </div> + </div> + </template> + </MkSwitch> + </MkPreferenceContainer> + </SearchMarker> + </div> + </MkFolder> + </SearchMarker> + </template> - <div class="_gaps_m"> - <div class="_gaps_s"> - <SearchMarker :keywords="['image', 'photo', 'picture', 'media', 'thumbnail', 'new', 'tab']"> - <MkSwitch v-model="imageNewTab"> - <template #label><SearchLabel>{{ i18n.ts.openImageInNewTab }}</SearchLabel></template> - </MkSwitch> + <SearchMarker v-slot="slotProps" :keywords="['accessibility']"> + <MkFolder :defaultOpen="slotProps.isParentOfTarget"> + <template #label><SearchLabel>{{ i18n.ts.accessibility }}</SearchLabel></template> + <template #icon><SearchIcon><i class="ti ti-accessible"></i></SearchIcon></template> + + <div class="_gaps_m"> + <MkFeatureBanner icon="/client-assets/mens_room_3d.png" color="#0011ff"> + <SearchKeyword>{{ i18n.ts._settings.accessibilityBanner }}</SearchKeyword> + </MkFeatureBanner> + + <div class="_gaps_s"> + <SearchMarker :keywords="['animation', 'motion', 'reduce']"> + <MkPreferenceContainer k="animation"> + <MkSwitch v-model="reduceAnimation"> + <template #label><SearchLabel>{{ i18n.ts.reduceUiAnimation }}</SearchLabel></template> + </MkSwitch> + </MkPreferenceContainer> + </SearchMarker> + + <SearchMarker :keywords="['disable', 'animation', 'image', 'photo', 'picture', 'media', 'thumbnail', 'gif']"> + <MkPreferenceContainer k="disableShowingAnimatedImages"> + <MkSwitch v-model="disableShowingAnimatedImages"> + <template #label><SearchLabel>{{ i18n.ts.disableShowingAnimatedImages }}</SearchLabel></template> + </MkSwitch> + </MkPreferenceContainer> + </SearchMarker> + + <SearchMarker :keywords="['mfm', 'enable', 'show', 'animated']"> + <MkPreferenceContainer k="animatedMfm"> + <MkSwitch v-model="animatedMfm"> + <template #label><SearchLabel>{{ i18n.ts.enableAnimatedMfm }}</SearchLabel></template> + </MkSwitch> + </MkPreferenceContainer> + </SearchMarker> + + <SearchMarker :keywords="['swipe', 'horizontal', 'tab']"> + <MkPreferenceContainer k="enableHorizontalSwipe"> + <MkSwitch v-model="enableHorizontalSwipe"> + <template #label><SearchLabel>{{ i18n.ts.enableHorizontalSwipe }}</SearchLabel></template> + </MkSwitch> + </MkPreferenceContainer> + </SearchMarker> + + <SearchMarker :keywords="['keep', 'screen', 'display', 'on']"> + <MkPreferenceContainer k="keepScreenOn"> + <MkSwitch v-model="keepScreenOn"> + <template #label><SearchLabel>{{ i18n.ts.keepScreenOn }}</SearchLabel></template> + </MkSwitch> + </MkPreferenceContainer> + </SearchMarker> + + <SearchMarker :keywords="['native', 'system', 'video', 'audio', 'player', 'media']"> + <MkPreferenceContainer k="useNativeUiForVideoAudioPlayer"> + <MkSwitch v-model="useNativeUiForVideoAudioPlayer"> + <template #label><SearchLabel>{{ i18n.ts.useNativeUIForVideoAudioPlayer }}</SearchLabel></template> + </MkSwitch> + </MkPreferenceContainer> + </SearchMarker> + + <SearchMarker :keywords="['text', 'selectable']"> + <MkPreferenceContainer k="makeEveryTextElementsSelectable"> + <MkSwitch v-model="makeEveryTextElementsSelectable"> + <template #label><SearchLabel>{{ i18n.ts._settings.makeEveryTextElementsSelectable }}</SearchLabel></template> + <template #caption>{{ i18n.ts._settings.makeEveryTextElementsSelectable_description }}</template> + </MkSwitch> + </MkPreferenceContainer> + </SearchMarker> + </div> + + <SearchMarker :keywords="['menu', 'style', 'popup', 'drawer']"> + <MkPreferenceContainer k="menuStyle"> + <MkSelect v-model="menuStyle"> + <template #label><SearchLabel>{{ i18n.ts.menuStyle }}</SearchLabel></template> + <option value="auto">{{ i18n.ts.auto }}</option> + <option value="popup">{{ i18n.ts.popup }}</option> + <option value="drawer">{{ i18n.ts.drawer }}</option> + </MkSelect> + </MkPreferenceContainer> </SearchMarker> - <SearchMarker :keywords="['reaction', 'picker', 'contextmenu', 'open']"> - <MkSwitch v-model="useReactionPickerForContextMenu"> - <template #label><SearchLabel>{{ i18n.ts.useReactionPickerForContextMenu }}</SearchLabel></template> - </MkSwitch> + <SearchMarker :keywords="['contextmenu', 'system', 'native']"> + <MkPreferenceContainer k="contextMenu"> + <MkSelect v-model="contextMenu"> + <template #label><SearchLabel>{{ i18n.ts._contextMenu.title }}</SearchLabel></template> + <option value="app">{{ i18n.ts._contextMenu.app }}</option> + <option value="appWithShift">{{ i18n.ts._contextMenu.appWithShift }}</option> + <option value="native">{{ i18n.ts._contextMenu.native }}</option> + </MkSelect> + </MkPreferenceContainer> </SearchMarker> - <SearchMarker :keywords="['load', 'auto', 'more']"> - <MkSwitch v-model="enableInfiniteScroll"> - <template #label><SearchLabel>{{ i18n.ts.enableInfiniteScroll }}</SearchLabel></template> - </MkSwitch> + <SearchMarker :keywords="['font', 'size']"> + <MkRadios v-model="fontSize"> + <template #label><SearchLabel>{{ i18n.ts.fontSize }}</SearchLabel></template> + <option :value="null"><span style="font-size: 14px;">Aa</span></option> + <option value="1"><span style="font-size: 15px;">Aa</span></option> + <option value="2"><span style="font-size: 16px;">Aa</span></option> + <option value="3"><span style="font-size: 17px;">Aa</span></option> + </MkRadios> </SearchMarker> - <SearchMarker :keywords="['disable', 'streaming', 'timeline']"> - <MkSwitch v-model="disableStreamingTimeline"> - <template #label><SearchLabel>{{ i18n.ts.disableStreamingTimeline }}</SearchLabel></template> + <SearchMarker :keywords="['font', 'system', 'native']"> + <MkSwitch v-model="useSystemFont"> + <template #label><SearchLabel>{{ i18n.ts.useSystemFont }}</SearchLabel></template> </MkSwitch> </SearchMarker> + </div> + </MkFolder> + </SearchMarker> - <SearchMarker :keywords="['follow', 'confirm', 'always']"> - <MkSwitch v-model="alwaysConfirmFollow"> - <template #label><SearchLabel>{{ i18n.ts.alwaysConfirmFollow }}</SearchLabel></template> - </MkSwitch> + <SearchMarker v-slot="slotProps" :keywords="['performance']"> + <MkFolder :defaultOpen="slotProps.isParentOfTarget"> + <template #label><SearchLabel>{{ i18n.ts.performance }}</SearchLabel></template> + <template #icon><SearchIcon><i class="ti ti-battery-vertical-eco"></i></SearchIcon></template> + + <div class="_gaps_s"> + <SearchMarker :keywords="['blur']"> + <MkPreferenceContainer k="useBlurEffect"> + <MkSwitch v-model="useBlurEffect"> + <template #label><SearchLabel>{{ i18n.ts.useBlurEffect }}</SearchLabel></template> + <template #caption><SearchKeyword>{{ i18n.ts.turnOffToImprovePerformance }}</SearchKeyword></template> + </MkSwitch> + </MkPreferenceContainer> </SearchMarker> - <SearchMarker :keywords="['sensitive', 'nsfw', 'media', 'image', 'photo', 'picture', 'attachment', 'confirm']"> - <MkSwitch v-model="confirmWhenRevealingSensitiveMedia"> - <template #label><SearchLabel>{{ i18n.ts.confirmWhenRevealingSensitiveMedia }}</SearchLabel></template> - </MkSwitch> + <SearchMarker :keywords="['blur', 'modal']"> + <MkPreferenceContainer k="useBlurEffectForModal"> + <MkSwitch v-model="useBlurEffectForModal"> + <template #label><SearchLabel>{{ i18n.ts.useBlurEffectForModal }}</SearchLabel></template> + <template #caption><SearchKeyword>{{ i18n.ts.turnOffToImprovePerformance }}</SearchKeyword></template> + </MkSwitch> + </MkPreferenceContainer> </SearchMarker> - <SearchMarker :keywords="['reaction', 'confirm']"> - <MkSwitch v-model="confirmOnReact"> - <template #label><SearchLabel>{{ i18n.ts.confirmOnReact }}</SearchLabel></template> - </MkSwitch> + <SearchMarker :keywords="['sticky']"> + <MkPreferenceContainer k="useStickyIcons"> + <MkSwitch v-model="useStickyIcons"> + <template #label><SearchLabel>{{ i18n.ts._settings.useStickyIcons }}</SearchLabel></template> + <template #caption><SearchKeyword>{{ i18n.ts.turnOffToImprovePerformance }}</SearchKeyword></template> + </MkSwitch> + </MkPreferenceContainer> </SearchMarker> </div> + </MkFolder> + </SearchMarker> - <SearchMarker :keywords="['server', 'disconnect', 'reconnect', 'reload', 'streaming']"> - <MkSelect v-model="serverDisconnectedBehavior"> - <template #label><SearchLabel>{{ i18n.ts.whenServerDisconnected }}</SearchLabel></template> - <option value="reload">{{ i18n.ts._serverDisconnectedBehavior.reload }}</option> - <option value="dialog">{{ i18n.ts._serverDisconnectedBehavior.dialog }}</option> - <option value="quiet">{{ i18n.ts._serverDisconnectedBehavior.quiet }}</option> - </MkSelect> - </SearchMarker> + <SearchMarker v-slot="slotProps" :keywords="['datasaver']"> + <MkFolder :defaultOpen="slotProps.isParentOfTarget"> + <template #label><SearchLabel>{{ i18n.ts.dataSaver }}</SearchLabel></template> + <template #icon><SearchIcon><i class="ti ti-antenna-bars-3"></i></SearchIcon></template> - <SearchMarker :keywords="['cache', 'page']"> - <MkRange v-model="numberOfPageCache" :min="1" :max="10" :step="1" easing> - <template #label><SearchLabel>{{ i18n.ts.numberOfPageCache }}</SearchLabel></template> - <template #caption>{{ i18n.ts.numberOfPageCacheDescription }}</template> - </MkRange> - </SearchMarker> + <div class="_gaps_m"> + <MkInfo>{{ i18n.ts.reloadRequiredToApplySettings }}</MkInfo> - <SearchMarker :label="i18n.ts.dataSaver" :keywords="['datasaver']"> - <MkFolder> - <template #label><SearchLabel>{{ i18n.ts.dataSaver }}</SearchLabel></template> + <div class="_buttons"> + <MkButton inline @click="enableAllDataSaver">{{ i18n.ts.enableAll }}</MkButton> + <MkButton inline @click="disableAllDataSaver">{{ i18n.ts.disableAll }}</MkButton> + </div> + <div class="_gaps_m"> + <MkSwitch v-model="dataSaver.media"> + {{ i18n.ts._dataSaver._media.title }} + <template #caption>{{ i18n.ts._dataSaver._media.description }}</template> + </MkSwitch> + <MkSwitch v-model="dataSaver.avatar"> + {{ i18n.ts._dataSaver._avatar.title }} + <template #caption>{{ i18n.ts._dataSaver._avatar.description }}</template> + </MkSwitch> + <MkSwitch v-model="dataSaver.urlPreview"> + {{ i18n.ts._dataSaver._urlPreview.title }} + <template #caption>{{ i18n.ts._dataSaver._urlPreview.description }}</template> + </MkSwitch> + <MkSwitch v-model="dataSaver.code"> + {{ i18n.ts._dataSaver._code.title }} + <template #caption>{{ i18n.ts._dataSaver._code.description }}</template> + </MkSwitch> + </div> + </div> + </MkFolder> + </SearchMarker> - <div class="_gaps_m"> - <MkInfo>{{ i18n.ts.reloadRequiredToApplySettings }}</MkInfo> + <SearchMarker v-slot="slotProps" :keywords="['other']"> + <MkFolder :defaultOpen="slotProps.isParentOfTarget"> + <template #label><SearchLabel>{{ i18n.ts.other }}</SearchLabel></template> + <template #icon><SearchIcon><i class="ti ti-settings-cog"></i></SearchIcon></template> - <div class="_buttons"> - <MkButton inline @click="enableAllDataSaver">{{ i18n.ts.enableAll }}</MkButton> - <MkButton inline @click="disableAllDataSaver">{{ i18n.ts.disableAll }}</MkButton> - </div> - <div class="_gaps_m"> - <MkSwitch v-model="dataSaver.media"> - {{ i18n.ts._dataSaver._media.title }} - <template #caption>{{ i18n.ts._dataSaver._media.description }}</template> + <div class="_gaps_m"> + <div class="_gaps_s"> + <SearchMarker :keywords="['avatar', 'icon', 'square']"> + <MkPreferenceContainer k="squareAvatars"> + <MkSwitch v-model="squareAvatars"> + <template #label><SearchLabel>{{ i18n.ts.squareAvatars }}</SearchLabel></template> </MkSwitch> - <MkSwitch v-model="dataSaver.avatar"> - {{ i18n.ts._dataSaver._avatar.title }} - <template #caption>{{ i18n.ts._dataSaver._avatar.description }}</template> + </MkPreferenceContainer> + </SearchMarker> + + <SearchMarker :keywords="['effect', 'show']"> + <MkPreferenceContainer k="enableSeasonalScreenEffect"> + <MkSwitch v-model="enableSeasonalScreenEffect"> + <template #label><SearchLabel>{{ i18n.ts.seasonalScreenEffect }}</SearchLabel></template> </MkSwitch> - <MkSwitch v-model="dataSaver.urlPreview"> - {{ i18n.ts._dataSaver._urlPreview.title }} - <template #caption>{{ i18n.ts._dataSaver._urlPreview.description }}</template> + </MkPreferenceContainer> + </SearchMarker> + + <SearchMarker :keywords="['image', 'photo', 'picture', 'media', 'thumbnail', 'new', 'tab']"> + <MkPreferenceContainer k="imageNewTab"> + <MkSwitch v-model="imageNewTab"> + <template #label><SearchLabel>{{ i18n.ts.openImageInNewTab }}</SearchLabel></template> </MkSwitch> - <MkSwitch v-model="dataSaver.code"> - {{ i18n.ts._dataSaver._code.title }} - <template #caption>{{ i18n.ts._dataSaver._code.description }}</template> + </MkPreferenceContainer> + </SearchMarker> + + <SearchMarker :keywords="['follow', 'replies']"> + <MkPreferenceContainer k="defaultFollowWithReplies"> + <MkSwitch v-model="defaultFollowWithReplies"> + <template #label><SearchLabel>{{ i18n.ts.withRepliesByDefaultForNewlyFollowed }}</SearchLabel></template> </MkSwitch> - </div> - </div> - </MkFolder> - </SearchMarker> - </div> - </FormSection> - </SearchMarker> + </MkPreferenceContainer> + </SearchMarker> + </div> - <SearchMarker> - <FormSection> - <template #label><SearchLabel>{{ i18n.ts.other }}</SearchLabel></template> + <SearchMarker :keywords="['server', 'disconnect', 'reconnect', 'reload', 'streaming']"> + <MkPreferenceContainer k="serverDisconnectedBehavior"> + <MkSelect v-model="serverDisconnectedBehavior"> + <template #label><SearchLabel>{{ i18n.ts.whenServerDisconnected }}</SearchLabel></template> + <option value="reload">{{ i18n.ts._serverDisconnectedBehavior.reload }}</option> + <option value="dialog">{{ i18n.ts._serverDisconnectedBehavior.dialog }}</option> + <option value="quiet">{{ i18n.ts._serverDisconnectedBehavior.quiet }}</option> + </MkSelect> + </MkPreferenceContainer> + </SearchMarker> - <div class="_gaps"> - <SearchMarker :keywords="['ad', 'show']"> - <MkSwitch v-model="forceShowAds"> - <template #label><SearchLabel>{{ i18n.ts.forceShowAds }}</SearchLabel></template> - </MkSwitch> - </SearchMarker> + <SearchMarker :keywords="['cache', 'page']"> + <MkPreferenceContainer k="numberOfPageCache"> + <MkRange v-model="numberOfPageCache" :min="1" :max="10" :step="1" easing> + <template #label><SearchLabel>{{ i18n.ts.numberOfPageCache }}</SearchLabel></template> + <template #caption>{{ i18n.ts.numberOfPageCacheDescription }}</template> + </MkRange> + </MkPreferenceContainer> + </SearchMarker> - <SearchMarker> - <MkRadios v-model="hemisphere"> - <template #label><SearchLabel>{{ i18n.ts.hemisphere }}</SearchLabel></template> - <option value="N">{{ i18n.ts._hemisphere.N }}</option> - <option value="S">{{ i18n.ts._hemisphere.S }}</option> - <template #caption>{{ i18n.ts._hemisphere.caption }}</template> - </MkRadios> - </SearchMarker> + <SearchMarker :keywords="['ad', 'show']"> + <MkPreferenceContainer k="forceShowAds"> + <MkSwitch v-model="forceShowAds"> + <template #label><SearchLabel>{{ i18n.ts.forceShowAds }}</SearchLabel></template> + </MkSwitch> + </MkPreferenceContainer> + </SearchMarker> - <SearchMarker :keywords="['emoji', 'dictionary', 'additional', 'extra']"> - <MkFolder> - <template #label><SearchLabel>{{ i18n.ts.additionalEmojiDictionary }}</SearchLabel></template> - <div class="_buttons"> - <template v-for="lang in emojiIndexLangs" :key="lang"> - <MkButton v-if="defaultStore.reactiveState.additionalUnicodeEmojiIndexes.value[lang]" danger @click="removeEmojiIndex(lang)"><i class="ti ti-trash"></i> {{ i18n.ts.remove }} ({{ getEmojiIndexLangName(lang) }})</MkButton> - <MkButton v-else @click="downloadEmojiIndex(lang)"><i class="ti ti-download"></i> {{ getEmojiIndexLangName(lang) }}{{ defaultStore.reactiveState.additionalUnicodeEmojiIndexes.value[lang] ? ` (${ i18n.ts.installed })` : '' }}</MkButton> - </template> - </div> - </MkFolder> - </SearchMarker> + <SearchMarker> + <MkPreferenceContainer k="hemisphere"> + <MkRadios v-model="hemisphere"> + <template #label><SearchLabel>{{ i18n.ts.hemisphere }}</SearchLabel></template> + <option value="N">{{ i18n.ts._hemisphere.N }}</option> + <option value="S">{{ i18n.ts._hemisphere.S }}</option> + <template #caption>{{ i18n.ts._hemisphere.caption }}</template> + </MkRadios> + </MkPreferenceContainer> + </SearchMarker> - <FormLink to="/settings/navbar">{{ i18n.ts.navbar }}</FormLink> - <FormLink to="/settings/statusbar">{{ i18n.ts.statusbar }}</FormLink> - </div> - </FormSection> - </SearchMarker> + <SearchMarker :keywords="['emoji', 'dictionary', 'additional', 'extra']"> + <MkFolder> + <template #label><SearchLabel>{{ i18n.ts.additionalEmojiDictionary }}</SearchLabel></template> + <div class="_buttons"> + <template v-for="lang in emojiIndexLangs" :key="lang"> + <MkButton v-if="store.r.additionalUnicodeEmojiIndexes.value[lang]" danger @click="removeEmojiIndex(lang)"><i class="ti ti-trash"></i> {{ i18n.ts.remove }} ({{ getEmojiIndexLangName(lang) }})</MkButton> + <MkButton v-else @click="downloadEmojiIndex(lang)"><i class="ti ti-download"></i> {{ getEmojiIndexLangName(lang) }}{{ store.r.additionalUnicodeEmojiIndexes.value[lang] ? ` (${ i18n.ts.installed })` : '' }}</MkButton> + </template> + </div> + </MkFolder> + </SearchMarker> + </div> + </MkFolder> + </SearchMarker> + </div> + + <hr> - <FormSection> - <div class="_gaps"> - <FormLink to="/settings/deck">{{ i18n.ts.deck }}</FormLink> - </div> - </FormSection> + <div class="_gaps_s"> + <FormLink to="/settings/navbar"><template #icon><i class="ti ti-list"></i></template>{{ i18n.ts.navbar }}</FormLink> + <FormLink to="/settings/statusbar"><template #icon><i class="ti ti-list"></i></template>{{ i18n.ts.statusbar }}</FormLink> + <FormLink to="/settings/deck"><template #icon><i class="ti ti-columns"></i></template>{{ i18n.ts.deck }}</FormLink> + <FormLink to="/settings/custom-css"><template #icon><i class="ti ti-code"></i></template>{{ i18n.ts.customCss }}</FormLink> + </div> </div> </SearchMarker> </template> <script lang="ts" setup> import { computed, ref, watch } from 'vue'; -import * as Misskey from 'misskey-js'; import { langs } from '@@/js/config.js'; +import * as Misskey from 'misskey-js'; import MkSwitch from '@/components/MkSwitch.vue'; import MkSelect from '@/components/MkSelect.vue'; import MkRadios from '@/components/MkRadios.vue'; @@ -284,41 +721,83 @@ import FormSection from '@/components/form/section.vue'; import FormLink from '@/components/form/link.vue'; import MkLink from '@/components/MkLink.vue'; import MkInfo from '@/components/MkInfo.vue'; -import { defaultStore } from '@/store.js'; +import { store } from '@/store.js'; import * as os from '@/os.js'; -import { instance } from '@/instance.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { reloadAsk } from '@/scripts/reload-ask.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { reloadAsk } from '@/utility/reload-ask.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; import { miLocalStorage } from '@/local-storage.js'; +import { prefer } from '@/preferences.js'; +import MkPreferenceContainer from '@/components/MkPreferenceContainer.vue'; +import MkFeatureBanner from '@/components/MkFeatureBanner.vue'; +import { globalEvents } from '@/events.js'; +import { claimAchievement } from '@/utility/achievements.js'; +import { instance } from '@/instance.js'; +import { ensureSignin } from '@/i.js'; + +const $i = ensureSignin(); const lang = ref(miLocalStorage.getItem('lang')); -const dataSaver = ref(defaultStore.state.dataSaver); +const dataSaver = ref(prefer.s.dataSaver); + +const overridedDeviceKind = prefer.model('overridedDeviceKind'); +const keepCw = prefer.model('keepCw'); +const serverDisconnectedBehavior = prefer.model('serverDisconnectedBehavior'); +const hemisphere = prefer.model('hemisphere'); +const showNoteActionsOnlyHover = prefer.model('showNoteActionsOnlyHover'); +const showClipButtonInNoteFooter = prefer.model('showClipButtonInNoteFooter'); +const collapseRenotes = prefer.model('collapseRenotes'); +const advancedMfm = prefer.model('advancedMfm'); +const showReactionsCount = prefer.model('showReactionsCount'); +const enableQuickAddMfmFunction = prefer.model('enableQuickAddMfmFunction'); +const forceShowAds = prefer.model('forceShowAds'); +const loadRawImages = prefer.model('loadRawImages'); +const imageNewTab = prefer.model('imageNewTab'); +const showFixedPostForm = prefer.model('showFixedPostForm'); +const showFixedPostFormInChannel = prefer.model('showFixedPostFormInChannel'); +const numberOfPageCache = prefer.model('numberOfPageCache'); +const enableInfiniteScroll = prefer.model('enableInfiniteScroll'); +const useReactionPickerForContextMenu = prefer.model('useReactionPickerForContextMenu'); +const disableStreamingTimeline = prefer.model('disableStreamingTimeline'); +const useGroupedNotifications = prefer.model('useGroupedNotifications'); +const alwaysConfirmFollow = prefer.model('alwaysConfirmFollow'); +const confirmWhenRevealingSensitiveMedia = prefer.model('confirmWhenRevealingSensitiveMedia'); +const confirmOnReact = prefer.model('confirmOnReact'); +const defaultNoteVisibility = prefer.model('defaultNoteVisibility'); +const defaultNoteLocalOnly = prefer.model('defaultNoteLocalOnly'); +const rememberNoteVisibility = prefer.model('rememberNoteVisibility'); +const showGapBetweenNotesInTimeline = prefer.model('showGapBetweenNotesInTimeline'); +const notificationPosition = prefer.model('notificationPosition'); +const notificationStackAxis = prefer.model('notificationStackAxis'); +const instanceTicker = prefer.model('instanceTicker'); +const highlightSensitiveMedia = prefer.model('highlightSensitiveMedia'); +const mediaListWithOneImageAppearance = prefer.model('mediaListWithOneImageAppearance'); +const reactionsDisplaySize = prefer.model('reactionsDisplaySize'); +const limitWidthOfReaction = prefer.model('limitWidthOfReaction'); +const squareAvatars = prefer.model('squareAvatars'); +const enableSeasonalScreenEffect = prefer.model('enableSeasonalScreenEffect'); +const showAvatarDecorations = prefer.model('showAvatarDecorations'); +const nsfw = prefer.model('nsfw'); +const emojiStyle = prefer.model('emojiStyle'); +const useBlurEffectForModal = prefer.model('useBlurEffectForModal'); +const useBlurEffect = prefer.model('useBlurEffect'); +const defaultFollowWithReplies = prefer.model('defaultFollowWithReplies'); +const chatShowSenderName = prefer.model('chat.showSenderName'); +const chatSendOnEnter = prefer.model('chat.sendOnEnter'); +const useStickyIcons = prefer.model('useStickyIcons'); +const reduceAnimation = prefer.model('animation', v => !v, v => !v); +const animatedMfm = prefer.model('animatedMfm'); +const disableShowingAnimatedImages = prefer.model('disableShowingAnimatedImages'); +const keepScreenOn = prefer.model('keepScreenOn'); +const enableHorizontalSwipe = prefer.model('enableHorizontalSwipe'); +const useNativeUiForVideoAudioPlayer = prefer.model('useNativeUiForVideoAudioPlayer'); +const contextMenu = prefer.model('contextMenu'); +const menuStyle = prefer.model('menuStyle'); +const makeEveryTextElementsSelectable = prefer.model('makeEveryTextElementsSelectable'); -const hemisphere = computed(defaultStore.makeGetterSetter('hemisphere')); -const overridedDeviceKind = computed(defaultStore.makeGetterSetter('overridedDeviceKind')); -const serverDisconnectedBehavior = computed(defaultStore.makeGetterSetter('serverDisconnectedBehavior')); -const showNoteActionsOnlyHover = computed(defaultStore.makeGetterSetter('showNoteActionsOnlyHover')); -const showClipButtonInNoteFooter = computed(defaultStore.makeGetterSetter('showClipButtonInNoteFooter')); -const collapseRenotes = computed(defaultStore.makeGetterSetter('collapseRenotes')); -const advancedMfm = computed(defaultStore.makeGetterSetter('advancedMfm')); -const showReactionsCount = computed(defaultStore.makeGetterSetter('showReactionsCount')); -const enableQuickAddMfmFunction = computed(defaultStore.makeGetterSetter('enableQuickAddMfmFunction')); -const forceShowAds = computed(defaultStore.makeGetterSetter('forceShowAds')); -const loadRawImages = computed(defaultStore.makeGetterSetter('loadRawImages')); -const imageNewTab = computed(defaultStore.makeGetterSetter('imageNewTab')); -const showFixedPostForm = computed(defaultStore.makeGetterSetter('showFixedPostForm')); -const showFixedPostFormInChannel = computed(defaultStore.makeGetterSetter('showFixedPostFormInChannel')); -const numberOfPageCache = computed(defaultStore.makeGetterSetter('numberOfPageCache')); -const enableInfiniteScroll = computed(defaultStore.makeGetterSetter('enableInfiniteScroll')); -const useReactionPickerForContextMenu = computed(defaultStore.makeGetterSetter('useReactionPickerForContextMenu')); -const disableStreamingTimeline = computed(defaultStore.makeGetterSetter('disableStreamingTimeline')); -const useGroupedNotifications = computed(defaultStore.makeGetterSetter('useGroupedNotifications')); -const alwaysConfirmFollow = computed(defaultStore.makeGetterSetter('alwaysConfirmFollow')); -const confirmWhenRevealingSensitiveMedia = computed(defaultStore.makeGetterSetter('confirmWhenRevealingSensitiveMedia')); -const confirmOnReact = computed(defaultStore.makeGetterSetter('confirmOnReact')); -const contextMenu = computed(defaultStore.makeGetterSetter('contextMenu')); +const fontSize = ref(miLocalStorage.getItem('fontSize')); +const useSystemFont = ref(miLocalStorage.getItem('useSystemFont') != null); watch(lang, () => { miLocalStorage.setItem('lang', lang.value as string); @@ -326,6 +805,22 @@ watch(lang, () => { miLocalStorage.removeItem('localeVersion'); }); +watch(fontSize, () => { + if (fontSize.value == null) { + miLocalStorage.removeItem('fontSize'); + } else { + miLocalStorage.setItem('fontSize', fontSize.value); + } +}); + +watch(useSystemFont, () => { + if (useSystemFont.value) { + miLocalStorage.setItem('useSystemFont', 't'); + } else { + miLocalStorage.removeItem('useSystemFont'); + } +}); + watch([ hemisphere, lang, @@ -335,7 +830,24 @@ watch([ disableStreamingTimeline, alwaysConfirmFollow, confirmWhenRevealingSensitiveMedia, + showGapBetweenNotesInTimeline, + mediaListWithOneImageAppearance, + reactionsDisplaySize, + limitWidthOfReaction, + mediaListWithOneImageAppearance, + reactionsDisplaySize, + limitWidthOfReaction, + instanceTicker, + squareAvatars, + highlightSensitiveMedia, + enableSeasonalScreenEffect, + chatShowSenderName, + useStickyIcons, + keepScreenOn, contextMenu, + fontSize, + useSystemFont, + makeEveryTextElementsSelectable, ], async () => { await reloadAsk({ reason: i18n.ts.reloadToApplySetting, unison: true }); }); @@ -356,7 +868,7 @@ function getEmojiIndexLangName(targetLang: typeof emojiIndexLangs[number]) { function downloadEmojiIndex(lang: typeof emojiIndexLangs[number]) { async function main() { - const currentIndexes = defaultStore.state.additionalUnicodeEmojiIndexes; + const currentIndexes = store.s.additionalUnicodeEmojiIndexes; function download() { switch (lang) { @@ -368,7 +880,7 @@ function downloadEmojiIndex(lang: typeof emojiIndexLangs[number]) { } currentIndexes[lang] = await download(); - await defaultStore.set('additionalUnicodeEmojiIndexes', currentIndexes); + await store.set('additionalUnicodeEmojiIndexes', currentIndexes); } os.promiseDialog(main()); @@ -376,9 +888,9 @@ function downloadEmojiIndex(lang: typeof emojiIndexLangs[number]) { function removeEmojiIndex(lang: string) { async function main() { - const currentIndexes = defaultStore.state.additionalUnicodeEmojiIndexes; + const currentIndexes = store.s.additionalUnicodeEmojiIndexes; delete currentIndexes[lang]; - await defaultStore.set('additionalUnicodeEmojiIndexes', currentIndexes); + await store.set('additionalUnicodeEmojiIndexes', currentIndexes); } os.promiseDialog(main()); @@ -393,16 +905,17 @@ async function setPinnedList() { })), }); if (canceled) return; + if (list == null) return; - defaultStore.set('pinnedUserLists', [list]); + prefer.commit('pinnedUserLists', [list]); } function removePinnedList() { - defaultStore.set('pinnedUserLists', []); + prefer.commit('pinnedUserLists', []); } function enableAllDataSaver() { - const g = { ...defaultStore.state.dataSaver }; + const g = { ...prefer.s.dataSaver }; Object.keys(g).forEach((key) => { g[key] = true; }); @@ -410,7 +923,7 @@ function enableAllDataSaver() { } function disableAllDataSaver() { - const g = { ...defaultStore.state.dataSaver }; + const g = { ...prefer.s.dataSaver }; Object.keys(g).forEach((key) => { g[key] = false; }); @@ -418,16 +931,43 @@ function disableAllDataSaver() { } watch(dataSaver, (to) => { - defaultStore.set('dataSaver', to); + prefer.commit('dataSaver', to); }, { deep: true, }); +let smashCount = 0; +let smashTimer: number | null = null; + +function testNotification(): void { + const notification: Misskey.entities.Notification = { + id: Math.random().toString(), + createdAt: new Date().toUTCString(), + isRead: false, + type: 'test', + }; + + globalEvents.emit('clientNotification', notification); + + // セルフ通知破壊 実績関連 + smashCount++; + if (smashCount >= 10) { + claimAchievement('smashTestNotificationButton'); + smashCount = 0; + } + if (smashTimer) { + window.clearTimeout(smashTimer); + } + smashTimer = window.setTimeout(() => { + smashCount = 0; + }, 300); +} + const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.general, icon: 'ti ti-adjustments', })); diff --git a/packages/frontend/src/pages/settings/privacy.vue b/packages/frontend/src/pages/settings/privacy.vue index cd0d54a73b..4e6425667e 100644 --- a/packages/frontend/src/pages/settings/privacy.vue +++ b/packages/frontend/src/pages/settings/privacy.vue @@ -6,6 +6,10 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <SearchMarker path="/settings/privacy" :label="i18n.ts.privacy" :keywords="['privacy']" icon="ti ti-lock-open"> <div class="_gaps_m"> + <MkFeatureBanner icon="/client-assets/unlocked_3d.png" color="#aeff00"> + <SearchKeyword>{{ i18n.ts._settings.privacyBanner }}</SearchKeyword> + </MkFeatureBanner> + <SearchMarker :keywords="['follow', 'lock']"> <MkSwitch v-model="isLocked" @update:modelValue="save()"> <template #label><SearchLabel>{{ i18n.ts.makeFollowManuallyApprove }}</SearchLabel></template> @@ -74,6 +78,27 @@ SPDX-License-Identifier: AGPL-3.0-only </MkSwitch> </SearchMarker> + <SearchMarker :keywords="['chat']"> + <FormSection> + <template #label><SearchLabel>{{ i18n.ts.chat }}</SearchLabel></template> + + <div class="_gaps_m"> + <MkInfo v-if="$i.policies.chatAvailability === 'unavailable'">{{ i18n.ts._chat.chatNotAvailableForThisAccountOrServer }}</MkInfo> + <SearchMarker :keywords="['chat']"> + <MkSelect v-model="chatScope" @update:modelValue="save()"> + <template #label><SearchLabel>{{ i18n.ts._chat.chatAllowedUsers }}</SearchLabel></template> + <option value="everyone">{{ i18n.ts._chat._chatAllowedUsers.everyone }}</option> + <option value="followers">{{ i18n.ts._chat._chatAllowedUsers.followers }}</option> + <option value="following">{{ i18n.ts._chat._chatAllowedUsers.following }}</option> + <option value="mutual">{{ i18n.ts._chat._chatAllowedUsers.mutual }}</option> + <option value="none">{{ i18n.ts._chat._chatAllowedUsers.none }}</option> + <template #caption>{{ i18n.ts._chat.chatAllowedUsers_note }}</template> + </MkSelect> + </SearchMarker> + </div> + </FormSection> + </SearchMarker> + <SearchMarker :keywords="['lockdown']"> <FormSection> <template #label><SearchLabel>{{ i18n.ts.lockdown }}</SearchLabel><span class="_beta">{{ i18n.ts.beta }}</span></template> @@ -85,7 +110,6 @@ SPDX-License-Identifier: AGPL-3.0-only <template #caption> <div>{{ i18n.ts._accountSettings.requireSigninToViewContentsDescription1 }}</div> <div><i class="ti ti-alert-triangle" style="color: var(--MI_THEME-warn);"></i> {{ i18n.ts._accountSettings.requireSigninToViewContentsDescription2 }}</div> - <div v-if="instance.federation !== 'none'"><i class="ti ti-alert-triangle" style="color: var(--MI_THEME-warn);"></i> {{ i18n.ts._accountSettings.requireSigninToViewContentsDescription3 }}</div> </template> </MkSwitch> </SearchMarker> @@ -123,7 +147,6 @@ SPDX-License-Identifier: AGPL-3.0-only <template #caption> <div><SearchKeyword>{{ i18n.ts._accountSettings.makeNotesFollowersOnlyBeforeDescription }}</SearchKeyword></div> - <div v-if="instance.federation !== 'none'"><i class="ti ti-alert-triangle" style="color: var(--MI_THEME-warn);"></i> {{ i18n.ts._accountSettings.mayNotEffectForFederatedNotes }}</div> </template> </FormSlot> </SearchMarker> @@ -161,49 +184,14 @@ SPDX-License-Identifier: AGPL-3.0-only <template #caption> <div><SearchKeyword>{{ i18n.ts._accountSettings.makeNotesHiddenBeforeDescription }}</SearchKeyword></div> - <div v-if="instance.federation !== 'none'"><i class="ti ti-alert-triangle" style="color: var(--MI_THEME-warn);"></i> {{ i18n.ts._accountSettings.mayNotEffectForFederatedNotes }}</div> </template> </FormSlot> </SearchMarker> + + <MkInfo warn>{{ i18n.ts._accountSettings.mayNotEffectSomeSituations }}</MkInfo> </div> </FormSection> </SearchMarker> - - <FormSection> - <div class="_gaps_m"> - <SearchMarker :keywords="['remember', 'keep', 'note', 'visibility']"> - <MkSwitch v-model="rememberNoteVisibility" @update:modelValue="save()"> - <template #label><SearchLabel>{{ i18n.ts.rememberNoteVisibility }}</SearchLabel></template> - </MkSwitch> - </SearchMarker> - - <SearchMarker :keywords="['default', 'note', 'visibility']"> - <MkFolder v-if="!rememberNoteVisibility"> - <template #label><SearchLabel>{{ i18n.ts.defaultNoteVisibility }}</SearchLabel></template> - <template v-if="defaultNoteVisibility === 'public'" #suffix>{{ i18n.ts._visibility.public }}</template> - <template v-else-if="defaultNoteVisibility === 'home'" #suffix>{{ i18n.ts._visibility.home }}</template> - <template v-else-if="defaultNoteVisibility === 'followers'" #suffix>{{ i18n.ts._visibility.followers }}</template> - <template v-else-if="defaultNoteVisibility === 'specified'" #suffix>{{ i18n.ts._visibility.specified }}</template> - - <div class="_gaps_m"> - <MkSelect v-model="defaultNoteVisibility"> - <option value="public">{{ i18n.ts._visibility.public }}</option> - <option value="home">{{ i18n.ts._visibility.home }}</option> - <option value="followers">{{ i18n.ts._visibility.followers }}</option> - <option value="specified">{{ i18n.ts._visibility.specified }}</option> - </MkSelect> - <MkSwitch v-model="defaultNoteLocalOnly">{{ i18n.ts._visibility.disableFederation }}</MkSwitch> - </div> - </MkFolder> - </SearchMarker> - </div> - </FormSection> - - <SearchMarker :keywords="['remember', 'keep', 'note', 'cw']"> - <MkSwitch v-model="keepCw" @update:modelValue="save()"> - <template #label><SearchLabel>{{ i18n.ts.keepCw }}</SearchLabel></template> - </MkSwitch> - </SearchMarker> </div> </SearchMarker> </template> @@ -214,19 +202,20 @@ import MkSwitch from '@/components/MkSwitch.vue'; import MkSelect from '@/components/MkSelect.vue'; import FormSection from '@/components/form/section.vue'; import MkFolder from '@/components/MkFolder.vue'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { defaultStore } from '@/store.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; import { instance } from '@/instance.js'; -import { signinRequired } from '@/account.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { ensureSignin } from '@/i.js'; +import { definePage } from '@/page.js'; import FormSlot from '@/components/form/slot.vue'; -import { formatDateTimeString } from '@/scripts/format-time-string.js'; +import { formatDateTimeString } from '@/utility/format-time-string.js'; import MkInput from '@/components/MkInput.vue'; import * as os from '@/os.js'; import MkDisableSection from '@/components/MkDisableSection.vue'; +import MkInfo from '@/components/MkInfo.vue'; +import MkFeatureBanner from '@/components/MkFeatureBanner.vue'; -const $i = signinRequired(); +const $i = ensureSignin(); const isLocked = ref($i.isLocked); const autoAcceptFollowed = ref($i.autoAcceptFollowed); @@ -240,11 +229,7 @@ const hideOnlineStatus = ref($i.hideOnlineStatus); const publicReactions = ref($i.publicReactions); const followingVisibility = ref($i.followingVisibility); const followersVisibility = ref($i.followersVisibility); - -const defaultNoteVisibility = computed(defaultStore.makeGetterSetter('defaultNoteVisibility')); -const defaultNoteLocalOnly = computed(defaultStore.makeGetterSetter('defaultNoteLocalOnly')); -const rememberNoteVisibility = computed(defaultStore.makeGetterSetter('rememberNoteVisibility')); -const keepCw = computed(defaultStore.makeGetterSetter('keepCw')); +const chatScope = ref($i.chatScope); const makeNotesFollowersOnlyBefore_type = computed(() => { if (makeNotesFollowersOnlyBefore.value == null) { @@ -297,6 +282,7 @@ function save() { publicReactions: !!publicReactions.value, followingVisibility: followingVisibility.value, followersVisibility: followersVisibility.value, + chatScope: chatScope.value, }); } @@ -304,7 +290,7 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.privacy, icon: 'ti ti-lock-open', })); diff --git a/packages/frontend/src/pages/settings/profile.vue b/packages/frontend/src/pages/settings/profile.vue index 51148a1f72..30b7cf9a86 100644 --- a/packages/frontend/src/pages/settings/profile.vue +++ b/packages/frontend/src/pages/settings/profile.vue @@ -161,23 +161,22 @@ import MkSelect from '@/components/MkSelect.vue'; import FormSplit from '@/components/form/split.vue'; import MkFolder from '@/components/MkFolder.vue'; import FormSlot from '@/components/form/slot.vue'; -import { selectFile } from '@/scripts/select-file.js'; +import { selectFile } from '@/utility/select-file.js'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; -import { signinRequired } from '@/account.js'; -import { langmap } from '@/scripts/langmap.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; -import { claimAchievement } from '@/scripts/achievements.js'; -import { defaultStore } from '@/store.js'; -import { globalEvents } from '@/events.js'; +import { ensureSignin } from '@/i.js'; +import { langmap } from '@/utility/langmap.js'; +import { definePage } from '@/page.js'; +import { claimAchievement } from '@/utility/achievements.js'; +import { store } from '@/store.js'; import MkInfo from '@/components/MkInfo.vue'; import MkTextarea from '@/components/MkTextarea.vue'; -const $i = signinRequired(); +const $i = ensureSignin(); const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default)); -const reactionAcceptance = computed(defaultStore.makeGetterSetter('reactionAcceptance')); +const reactionAcceptance = computed(store.makeGetterSetter('reactionAcceptance')); function assertVaildLang(lang: string | null): lang is keyof typeof langmap { return lang != null && lang in langmap; @@ -223,7 +222,6 @@ function saveFields() { os.apiWithDialog('i/update', { fields: fields.value.filter(field => field.name !== '' && field.value !== '').map(field => ({ name: field.name, value: field.value })), }); - globalEvents.emit('requestClearPageCache'); } function save() { @@ -249,7 +247,6 @@ function save() { text: i18n.ts.yourNameContainsProhibitedWordsDescription, }, }); - globalEvents.emit('requestClearPageCache'); claimAchievement('profileFilled'); if (profile.name === 'syuilo' || profile.name === 'しゅいろ') { claimAchievement('setNameToSyuilo'); @@ -281,7 +278,6 @@ function changeAvatar(ev) { }); $i.avatarId = i.avatarId; $i.avatarUrl = i.avatarUrl; - globalEvents.emit('requestClearPageCache'); claimAchievement('profileFilled'); }); } @@ -308,7 +304,6 @@ function changeBanner(ev) { }); $i.bannerId = i.bannerId; $i.bannerUrl = i.bannerUrl; - globalEvents.emit('requestClearPageCache'); }); } @@ -316,7 +311,7 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.profile, icon: 'ti ti-user', })); diff --git a/packages/frontend/src/pages/settings/roles.vue b/packages/frontend/src/pages/settings/roles.vue deleted file mode 100644 index 5346a58a79..0000000000 --- a/packages/frontend/src/pages/settings/roles.vue +++ /dev/null @@ -1,48 +0,0 @@ -<!-- -SPDX-FileCopyrightText: syuilo and misskey-project -SPDX-License-Identifier: AGPL-3.0-only ---> - -<template> -<div class="_gaps_m"> - <FormSection first> - <template #label>{{ i18n.ts.rolesAssignedToMe }}</template> - <div class="_gaps_s"> - <MkRolePreview v-for="role in $i.roles" :key="role.id" :role="role" :forModeration="false"/> - </div> - </FormSection> - <FormSection> - <template #label>{{ i18n.ts._role.policies }}</template> - <div class="_gaps_s"> - <div v-for="policy in Object.keys($i.policies)" :key="policy"> - {{ policy }} ... {{ $i.policies[policy] }} - </div> - </div> - </FormSection> -</div> -</template> - -<script lang="ts" setup> -import { computed } from 'vue'; -import FormSection from '@/components/form/section.vue'; -import * as os from '@/os.js'; -import { i18n } from '@/i18n.js'; -import { signinRequired } from '@/account.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; -import MkRolePreview from '@/components/MkRolePreview.vue'; - -const $i = signinRequired(); - -const headerActions = computed(() => []); - -const headerTabs = computed(() => []); - -definePageMetadata(() => ({ - title: i18n.ts.roles, - icon: 'ti ti-badges', -})); -</script> - -<style lang="scss" module> - -</style> diff --git a/packages/frontend/src/pages/settings/security.vue b/packages/frontend/src/pages/settings/security.vue index f365146e0a..391118effd 100644 --- a/packages/frontend/src/pages/settings/security.vue +++ b/packages/frontend/src/pages/settings/security.vue @@ -6,6 +6,10 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <SearchMarker path="/settings/security" :label="i18n.ts.security" :keywords="['security']" icon="ti ti-lock" :inlining="['2fa']"> <div class="_gaps_m"> + <MkFeatureBanner icon="/client-assets/locked_with_key_3d.png" color="#ffbf00"> + <SearchKeyword>{{ i18n.ts._settings.securityBanner }}</SearchKeyword> + </MkFeatureBanner> + <SearchMarker :keywords="['password']"> <FormSection first> <template #label><SearchLabel>{{ i18n.ts.password }}</SearchLabel></template> @@ -56,9 +60,10 @@ import FormSlot from '@/components/form/slot.vue'; import MkButton from '@/components/MkButton.vue'; import MkPagination from '@/components/MkPagination.vue'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; +import MkFeatureBanner from '@/components/MkFeatureBanner.vue'; const pagination = { endpoint: 'i/signin-history' as const, @@ -112,7 +117,7 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.security, icon: 'ti ti-lock', })); diff --git a/packages/frontend/src/pages/settings/sounds.sound.vue b/packages/frontend/src/pages/settings/sounds.sound.vue index 56f65e2309..1bac19fe47 100644 --- a/packages/frontend/src/pages/settings/sounds.sound.vue +++ b/packages/frontend/src/pages/settings/sounds.sound.vue @@ -32,15 +32,15 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { ref, computed, watch } from 'vue'; -import type { SoundType } from '@/scripts/sound.js'; +import type { SoundType } from '@/utility/sound.js'; import MkSelect from '@/components/MkSelect.vue'; import MkButton from '@/components/MkButton.vue'; import MkRange from '@/components/MkRange.vue'; import { i18n } from '@/i18n.js'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { playMisskeySfxFile, soundsTypes, getSoundDuration } from '@/scripts/sound.js'; -import { selectFile } from '@/scripts/select-file.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { playMisskeySfxFile, soundsTypes, getSoundDuration } from '@/utility/sound.js'; +import { selectFile } from '@/utility/select-file.js'; const props = defineProps<{ type: SoundType; diff --git a/packages/frontend/src/pages/settings/sounds.vue b/packages/frontend/src/pages/settings/sounds.vue index 1df2d89277..4461ee1ab1 100644 --- a/packages/frontend/src/pages/settings/sounds.vue +++ b/packages/frontend/src/pages/settings/sounds.vue @@ -6,22 +6,32 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <SearchMarker path="/settings/sounds" :label="i18n.ts.sounds" :keywords="['sounds']" icon="ti ti-music"> <div class="_gaps_m"> + <MkFeatureBanner icon="/client-assets/speaker_high_volume_3d.png" color="#ff006f"> + <SearchKeyword>{{ i18n.ts._settings.soundsBanner }}</SearchKeyword> + </MkFeatureBanner> + <SearchMarker :keywords="['mute']"> - <MkSwitch v-model="notUseSound"> - <template #label><SearchLabel>{{ i18n.ts.notUseSound }}</SearchLabel></template> - </MkSwitch> + <MkPreferenceContainer k="sound.notUseSound"> + <MkSwitch v-model="notUseSound"> + <template #label><SearchLabel>{{ i18n.ts.notUseSound }}</SearchLabel></template> + </MkSwitch> + </MkPreferenceContainer> </SearchMarker> <SearchMarker :keywords="['active', 'mute']"> - <MkSwitch v-model="useSoundOnlyWhenActive"> - <template #label><SearchLabel>{{ i18n.ts.useSoundOnlyWhenActive }}</SearchLabel></template> - </MkSwitch> + <MkPreferenceContainer k="sound.useSoundOnlyWhenActive"> + <MkSwitch v-model="useSoundOnlyWhenActive"> + <template #label><SearchLabel>{{ i18n.ts.useSoundOnlyWhenActive }}</SearchLabel></template> + </MkSwitch> + </MkPreferenceContainer> </SearchMarker> <SearchMarker :keywords="['volume', 'master']"> - <MkRange v-model="masterVolume" :min="0" :max="1" :step="0.05" :textConverter="(v) => `${Math.floor(v * 100)}%`"> - <template #label><SearchLabel>{{ i18n.ts.masterVolume }}</SearchLabel></template> - </MkRange> + <MkPreferenceContainer k="sound.masterVolume"> + <MkRange v-model="masterVolume" :min="0" :max="1" :step="0.05" :textConverter="(v) => `${Math.floor(v * 100)}%`"> + <template #label><SearchLabel>{{ i18n.ts.masterVolume }}</SearchLabel></template> + </MkRange> + </MkPreferenceContainer> </SearchMarker> <FormSection> @@ -51,27 +61,31 @@ SPDX-License-Identifier: AGPL-3.0-only import { computed, ref } from 'vue'; import XSound from './sounds.sound.vue'; import type { Ref } from 'vue'; -import type { SoundType, OperationType } from '@/scripts/sound.js'; -import type { SoundStore } from '@/store.js'; +import type { SoundType, OperationType } from '@/utility/sound.js'; +import type { SoundStore } from '@/preferences/def.js'; +import { prefer } from '@/preferences.js'; import MkRange from '@/components/MkRange.vue'; import MkButton from '@/components/MkButton.vue'; import FormSection from '@/components/form/section.vue'; import MkFolder from '@/components/MkFolder.vue'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; -import { operationTypes } from '@/scripts/sound.js'; -import { defaultStore } from '@/store.js'; +import { definePage } from '@/page.js'; +import { operationTypes } from '@/utility/sound.js'; import MkSwitch from '@/components/MkSwitch.vue'; +import MkPreferenceContainer from '@/components/MkPreferenceContainer.vue'; +import { PREF_DEF } from '@/preferences/def.js'; +import MkFeatureBanner from '@/components/MkFeatureBanner.vue'; -const notUseSound = computed(defaultStore.makeGetterSetter('sound_notUseSound')); -const useSoundOnlyWhenActive = computed(defaultStore.makeGetterSetter('sound_useSoundOnlyWhenActive')); -const masterVolume = computed(defaultStore.makeGetterSetter('sound_masterVolume')); +const notUseSound = prefer.model('sound.notUseSound'); +const useSoundOnlyWhenActive = prefer.model('sound.useSoundOnlyWhenActive'); +const masterVolume = prefer.model('sound.masterVolume'); const sounds = ref<Record<OperationType, Ref<SoundStore>>>({ - note: defaultStore.reactiveState.sound_note, - noteMy: defaultStore.reactiveState.sound_noteMy, - notification: defaultStore.reactiveState.sound_notification, - reaction: defaultStore.reactiveState.sound_reaction, + note: prefer.r['sound.on.note'], + noteMy: prefer.r['sound.on.noteMy'], + notification: prefer.r['sound.on.notification'], + reaction: prefer.r['sound.on.reaction'], + chatMessage: prefer.r['sound.on.chatMessage'], }); function getSoundTypeName(f: SoundType): string { @@ -93,14 +107,14 @@ async function updated(type: keyof typeof sounds.value, sound) { volume: sound.volume, }; - defaultStore.set(`sound_${type}`, v); + prefer.commit(`sound.on.${type}`, v); sounds.value[type] = v; } function reset() { for (const sound of Object.keys(sounds.value) as Array<keyof typeof sounds.value>) { - const v = defaultStore.def[`sound_${sound}`].default; - defaultStore.set(`sound_${sound}`, v); + const v = PREF_DEF[`sound.on.${sound}`].default; + prefer.commit(`sound.on.${sound}`, v); sounds.value[sound] = v; } } @@ -109,7 +123,7 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.sounds, icon: 'ti ti-music', })); diff --git a/packages/frontend/src/pages/settings/statusbar.statusbar.vue b/packages/frontend/src/pages/settings/statusbar.statusbar.vue index 140b6beb14..dbb640123a 100644 --- a/packages/frontend/src/pages/settings/statusbar.statusbar.vue +++ b/packages/frontend/src/pages/settings/statusbar.statusbar.vue @@ -94,17 +94,17 @@ import MkSwitch from '@/components/MkSwitch.vue'; import MkRadios from '@/components/MkRadios.vue'; import MkButton from '@/components/MkButton.vue'; import MkRange from '@/components/MkRange.vue'; -import { defaultStore } from '@/store.js'; import { i18n } from '@/i18n.js'; import { instance } from '@/instance.js'; -import { deepClone } from '@/scripts/clone.js'; +import { deepClone } from '@/utility/clone.js'; +import { prefer } from '@/preferences.js'; const props = defineProps<{ _id: string; userLists: Misskey.entities.UserList[] | null; }>(); -const statusbar = reactive(deepClone(defaultStore.state.statusbars.find(x => x.id === props._id))); +const statusbar = reactive(deepClone(prefer.s.statusbars.find(x => x.id === props._id))); watch(() => statusbar.type, () => { if (statusbar.type === 'rss') { @@ -134,13 +134,13 @@ watch(() => statusbar.type, () => { watch(statusbar, save); async function save() { - const i = defaultStore.state.statusbars.findIndex(x => x.id === props._id); - const statusbars = deepClone(defaultStore.state.statusbars); + const i = prefer.s.statusbars.findIndex(x => x.id === props._id); + const statusbars = deepClone(prefer.s.statusbars); statusbars[i] = deepClone(statusbar); - defaultStore.set('statusbars', statusbars); + prefer.commit('statusbars', statusbars); } function del() { - defaultStore.set('statusbars', defaultStore.state.statusbars.filter(x => x.id !== props._id)); + prefer.commit('statusbars', prefer.s.statusbars.filter(x => x.id !== props._id)); } </script> diff --git a/packages/frontend/src/pages/settings/statusbar.vue b/packages/frontend/src/pages/settings/statusbar.vue index 1ae3de7994..7e6a536216 100644 --- a/packages/frontend/src/pages/settings/statusbar.vue +++ b/packages/frontend/src/pages/settings/statusbar.vue @@ -21,12 +21,12 @@ import { v4 as uuid } from 'uuid'; import XStatusbar from './statusbar.statusbar.vue'; import MkFolder from '@/components/MkFolder.vue'; import MkButton from '@/components/MkButton.vue'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { defaultStore } from '@/store.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; +import { prefer } from '@/preferences.js'; -const statusbars = defaultStore.reactiveState.statusbars; +const statusbars = prefer.r.statusbars; const userLists = ref<Misskey.entities.UserList[] | null>(null); @@ -37,20 +37,20 @@ onMounted(() => { }); async function add() { - defaultStore.push('statusbars', { + prefer.commit('statusbars', [...statusbars.value, { id: uuid(), type: null, black: false, size: 'medium', props: {}, - }); + }]); } const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.statusbar, icon: 'ti ti-list', })); diff --git a/packages/frontend/src/pages/settings/theme.install.vue b/packages/frontend/src/pages/settings/theme.install.vue index 4f05d3784c..ac95279402 100644 --- a/packages/frontend/src/pages/settings/theme.install.vue +++ b/packages/frontend/src/pages/settings/theme.install.vue @@ -10,8 +10,8 @@ SPDX-License-Identifier: AGPL-3.0-only </MkCodeEditor> <div class="_buttons"> - <MkButton :disabled="installThemeCode == null" inline @click="() => previewTheme(installThemeCode)"><i class="ti ti-eye"></i> {{ i18n.ts.preview }}</MkButton> - <MkButton :disabled="installThemeCode == null" primary inline @click="() => install(installThemeCode)"><i class="ti ti-check"></i> {{ i18n.ts.install }}</MkButton> + <MkButton :disabled="installThemeCode == null || installThemeCode.trim() === ''" inline @click="() => previewTheme(installThemeCode)"><i class="ti ti-eye"></i> {{ i18n.ts.preview }}</MkButton> + <MkButton :disabled="installThemeCode == null || installThemeCode.trim() === ''" primary inline @click="() => install(installThemeCode)"><i class="ti ti-check"></i> {{ i18n.ts.install }}</MkButton> </div> </div> </template> @@ -20,11 +20,13 @@ SPDX-License-Identifier: AGPL-3.0-only import { ref, computed } from 'vue'; import MkCodeEditor from '@/components/MkCodeEditor.vue'; import MkButton from '@/components/MkButton.vue'; -import { parseThemeCode, previewTheme, installTheme } from '@/scripts/install-theme.js'; +import { parseThemeCode, previewTheme, installTheme } from '@/theme.js'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; +import { useRouter } from '@/router.js'; +const router = useRouter(); const installThemeCode = ref<string | null>(null); async function install(code: string): Promise<void> { @@ -35,6 +37,8 @@ async function install(code: string): Promise<void> { type: 'success', text: i18n.tsx._theme.installed({ name: theme.name }), }); + installThemeCode.value = null; + router.push('/settings/theme'); } catch (err) { switch (err.message.toLowerCase()) { case 'this theme is already installed': @@ -59,7 +63,7 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts._theme.install, icon: 'ti ti-download', })); diff --git a/packages/frontend/src/pages/settings/theme.manage.vue b/packages/frontend/src/pages/settings/theme.manage.vue index f63f15fc13..fcd0b293e0 100644 --- a/packages/frontend/src/pages/settings/theme.manage.vue +++ b/packages/frontend/src/pages/settings/theme.manage.vue @@ -33,19 +33,18 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { computed, ref } from 'vue'; import JSON5 from 'json5'; +import type { Theme } from '@/theme.js'; import MkTextarea from '@/components/MkTextarea.vue'; import MkSelect from '@/components/MkSelect.vue'; import MkInput from '@/components/MkInput.vue'; import MkButton from '@/components/MkButton.vue'; -import { getBuiltinThemesRef } from '@/scripts/theme.js'; -import type { Theme } from '@/scripts/theme.js'; -import { copyToClipboard } from '@/scripts/copy-to-clipboard.js'; +import { getBuiltinThemesRef, getThemesRef, removeTheme } from '@/theme.js'; +import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; import * as os from '@/os.js'; -import { getThemes, removeTheme } from '@/theme-store.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; -const installedThemes = ref(getThemes()); +const installedThemes = getThemesRef(); const builtinThemes = getBuiltinThemesRef(); const selectedThemeId = ref<string | null>(null); @@ -63,7 +62,6 @@ const selectedThemeCode = computed(() => { function copyThemeCode() { copyToClipboard(selectedThemeCode.value); - os.success(); } function uninstall() { @@ -77,7 +75,7 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts._theme.manage, icon: 'ti ti-tool', })); diff --git a/packages/frontend/src/pages/settings/theme.vue b/packages/frontend/src/pages/settings/theme.vue index b0e4ce13d5..45b97e19c4 100644 --- a/packages/frontend/src/pages/settings/theme.vue +++ b/packages/frontend/src/pages/settings/theme.vue @@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <SearchMarker path="/settings/theme" :label="i18n.ts.theme" :keywords="['theme']" icon="ti ti-palette"> - <div class="_gaps_m rsljpzjq"> + <div class="_gaps_m"> <div v-adaptive-border class="rfqxtzch _panel"> <div class="toggle"> <div class="toggleWrapper"> @@ -36,25 +36,157 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </div> - <div class="selects"> - <div class="select"> + <div class="_gaps"> + <template v-if="!darkMode"> <SearchMarker :keywords="['light', 'theme']"> - <MkSelect v-model="lightThemeId" large :items="lightThemeSelectorItems"> + <MkFolder :defaultOpen="true" :max-height="500"> + <template #icon><i class="ti ti-sun"></i></template> <template #label><SearchLabel>{{ i18n.ts.themeForLightMode }}</SearchLabel></template> - <template #prefix><i class="ti ti-sun"></i></template> - </MkSelect> + <template #caption>{{ lightThemeName }}</template> + + <div class="_gaps_m"> + <FormSection v-if="instanceLightTheme != null" first> + <template #label>{{ i18n.ts._theme.instanceTheme }}</template> + <div :class="$style.themeSelect"> + <div :class="$style.themeItemOuter"> + <input + :id="`themeRadio_${instanceLightTheme.id}`" + v-model="lightThemeId" + type="radio" + name="lightTheme" + :class="$style.themeRadio" + :value="instanceLightTheme.id" + /> + <label :for="`themeRadio_${instanceLightTheme.id}`" :class="$style.themeItemRoot" class="_button"> + <MkThemePreview :theme="instanceLightTheme" :class="$style.themeItemPreview"/> + <div :class="$style.themeItemCaption">{{ instanceLightTheme.name }}</div> + </label> + </div> + </div> + </FormSection> + + <FormSection v-if="installedLightThemes.length > 0" :first="instanceLightTheme == null"> + <template #label>{{ i18n.ts._theme.installedThemes }}</template> + <div :class="$style.themeSelect"> + <div v-for="theme in installedLightThemes" :class="$style.themeItemOuter"> + <input + :id="`themeRadio_${theme.id}`" + v-model="lightThemeId" + type="radio" + name="lightTheme" + :class="$style.themeRadio" + :value="theme.id" + /> + <label :for="`themeRadio_${theme.id}`" :class="$style.themeItemRoot" class="_button"> + <MkThemePreview :theme="theme" :class="$style.themeItemPreview"/> + <div :class="$style.themeItemCaption">{{ theme.name }}</div> + </label> + </div> + </div> + </FormSection> + + <FormSection :first="installedLightThemes.length === 0 && instanceLightTheme == null"> + <template #label>{{ i18n.ts._theme.builtinThemes }}</template> + <div :class="$style.themeSelect"> + <div v-for="theme in builtinLightThemes" :class="$style.themeItemOuter"> + <input + :id="`themeRadio_${theme.id}`" + v-model="lightThemeId" + type="radio" + name="lightTheme" + :class="$style.themeRadio" + :value="theme.id" + /> + <label :for="`themeRadio_${theme.id}`" :class="$style.themeItemRoot" class="_button"> + <MkThemePreview :theme="theme" :class="$style.themeItemPreview"/> + <div :class="$style.themeItemCaption">{{ theme.name }}</div> + </label> + </div> + </div> + </FormSection> + </div> + </MkFolder> </SearchMarker> - </div> - <div class="select"> + </template> + <template v-else> <SearchMarker :keywords="['dark', 'theme']"> - <MkSelect v-model="darkThemeId" large :items="darkThemeSelectorItems"> + <MkFolder :defaultOpen="true" :max-height="500"> + <template #icon><i class="ti ti-moon"></i></template> <template #label><SearchLabel>{{ i18n.ts.themeForDarkMode }}</SearchLabel></template> - <template #prefix><i class="ti ti-moon"></i></template> - </MkSelect> + <template #caption>{{ darkThemeName }}</template> + + <div class="_gaps_m"> + <FormSection v-if="instanceDarkTheme != null" first> + <template #label>{{ i18n.ts._theme.instanceTheme }}</template> + <div :class="$style.themeSelect"> + <div :class="$style.themeItemOuter"> + <input + :id="`themeRadio_${instanceDarkTheme.id}`" + v-model="darkThemeId" + type="radio" + name="darkTheme" + :class="$style.themeRadio" + :value="instanceDarkTheme.id" + /> + <label :for="`themeRadio_${instanceDarkTheme.id}`" :class="$style.themeItemRoot" class="_button"> + <MkThemePreview :theme="instanceDarkTheme" :class="$style.themeItemPreview"/> + <div :class="$style.themeItemCaption">{{ instanceDarkTheme.name }}</div> + </label> + </div> + </div> + </FormSection> + + <FormSection v-if="installedDarkThemes.length > 0" :first="instanceDarkTheme == null"> + <template #label>{{ i18n.ts._theme.installedThemes }}</template> + <div :class="$style.themeSelect"> + <div v-for="theme in installedDarkThemes" :class="$style.themeItemOuter"> + <input + :id="`themeRadio_${theme.id}`" + v-model="darkThemeId" + type="radio" + name="darkTheme" + :class="$style.themeRadio" + :value="theme.id" + /> + <label :for="`themeRadio_${theme.id}`" :class="$style.themeItemRoot" class="_button"> + <MkThemePreview :theme="theme" :class="$style.themeItemPreview"/> + <div :class="$style.themeItemCaption">{{ theme.name }}</div> + </label> + </div> + </div> + </FormSection> + + <FormSection :first="installedDarkThemes.length === 0 && instanceDarkTheme == null"> + <template #label>{{ i18n.ts._theme.builtinThemes }}</template> + <div :class="$style.themeSelect"> + <div v-for="theme in builtinDarkThemes" :class="$style.themeItemOuter"> + <input + :id="`themeRadio_${theme.id}`" + v-model="darkThemeId" + type="radio" + name="darkTheme" + :class="$style.themeRadio" + :value="theme.id" + /> + <label :for="`themeRadio_${theme.id}`" :class="$style.themeItemRoot" class="_button"> + <MkThemePreview :theme="theme" :class="$style.themeItemPreview"/> + <div :class="$style.themeItemCaption">{{ theme.name }}</div> + </label> + </div> + </div> + </FormSection> + </div> + </MkFolder> </SearchMarker> - </div> + </template> </div> + <SearchMarker :keywords="['sync', 'themes', 'devices']"> + <MkSwitch :modelValue="themesSyncEnabled" @update:modelValue="changeThemesSyncEnabled"> + <template #label><i class="ti ti-cloud-cog"></i> <SearchLabel>{{ i18n.ts._settings.enableSyncThemesBetweenDevices }}</SearchLabel></template> + </MkSwitch> + </SearchMarker> + <FormSection> <div class="_formLinksGrid"> <FormLink to="/settings/theme/manage"><template #icon><i class="ti ti-tool"></i></template>{{ i18n.ts._theme.manage }}<template #suffix>{{ themesCount }}</template></FormLink> @@ -63,183 +195,153 @@ SPDX-License-Identifier: AGPL-3.0-only <FormLink to="/theme-editor"><template #icon><i class="ti ti-paint"></i></template>{{ i18n.ts._theme.make }}</FormLink> </div> </FormSection> - - <SearchMarker :keywords="['wallpaper']"> - <MkButton v-if="wallpaper == null" @click="setWallpaper"><SearchLabel>{{ i18n.ts.setWallpaper }}</SearchLabel></MkButton> - <MkButton v-else @click="wallpaper = null">{{ i18n.ts.removeWallpaper }}</MkButton> - </SearchMarker> </div> </SearchMarker> </template> <script lang="ts" setup> -import { computed, onActivated, ref, watch } from 'vue'; +import { computed, ref, watch } from 'vue'; import JSON5 from 'json5'; -import type { MkSelectItem } from '@/components/MkSelect.vue'; +import defaultLightTheme from '@@/themes/l-light.json5'; +import defaultDarkTheme from '@@/themes/d-green-lime.json5'; +import type { Theme } from '@/theme.js'; import MkSwitch from '@/components/MkSwitch.vue'; -import MkSelect from '@/components/MkSelect.vue'; import FormSection from '@/components/form/section.vue'; import FormLink from '@/components/form/link.vue'; -import MkButton from '@/components/MkButton.vue'; -import { getBuiltinThemesRef } from '@/scripts/theme.js'; -import { selectFile } from '@/scripts/select-file.js'; -import { isDeviceDarkmode } from '@/scripts/is-device-darkmode.js'; -import { ColdDeviceStorage, defaultStore } from '@/store.js'; +import MkFolder from '@/components/MkFolder.vue'; +import MkThemePreview from '@/components/MkThemePreview.vue'; +import { getBuiltinThemesRef, getThemesRef } from '@/theme.js'; +import { isDeviceDarkmode } from '@/utility/is-device-darkmode.js'; +import { store } from '@/store.js'; import { i18n } from '@/i18n.js'; import { instance } from '@/instance.js'; -import { uniqueBy } from '@/scripts/array.js'; -import { fetchThemes, getThemes } from '@/theme-store.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; -import { miLocalStorage } from '@/local-storage.js'; -import { reloadAsk } from '@/scripts/reload-ask.js'; -import * as os from '@/os.js'; +import { uniqueBy } from '@/utility/array.js'; +import { definePage } from '@/page.js'; +import { prefer } from '@/preferences.js'; -const installedThemes = ref(getThemes()); +const installedThemes = getThemesRef(); const builtinThemes = getBuiltinThemesRef(); -const instanceDarkTheme = computed(() => instance.defaultDarkTheme ? JSON5.parse(instance.defaultDarkTheme) : null); +const instanceDarkTheme = computed<Theme | null>(() => instance.defaultDarkTheme ? JSON5.parse(instance.defaultDarkTheme) : null); const installedDarkThemes = computed(() => installedThemes.value.filter(t => t.base === 'dark' || t.kind === 'dark')); const builtinDarkThemes = computed(() => builtinThemes.value.filter(t => t.base === 'dark' || t.kind === 'dark')); -const instanceLightTheme = computed(() => instance.defaultLightTheme ? JSON5.parse(instance.defaultLightTheme) : null); +const instanceLightTheme = computed<Theme | null>(() => instance.defaultLightTheme ? JSON5.parse(instance.defaultLightTheme) : null); const installedLightThemes = computed(() => installedThemes.value.filter(t => t.base === 'light' || t.kind === 'light')); const builtinLightThemes = computed(() => builtinThemes.value.filter(t => t.base === 'light' || t.kind === 'light')); const themes = computed(() => uniqueBy([instanceDarkTheme.value, instanceLightTheme.value, ...builtinThemes.value, ...installedThemes.value].filter(x => x != null), theme => theme.id)); -const lightThemeSelectorItems = computed(() => { - const items = [] as MkSelectItem[]; - if (instanceLightTheme.value) { - items.push({ - type: 'option', - value: instanceLightTheme.value.id, - label: instanceLightTheme.value.name, - }); - } - if (installedLightThemes.value.length > 0) { - items.push({ - type: 'group', - label: i18n.ts._theme.installedThemes, - items: installedLightThemes.value.map(x => ({ - type: 'option', - value: x.id, - label: x.name, - })), - }); - } - items.push({ - type: 'group', - label: i18n.ts._theme.builtinThemes, - items: builtinLightThemes.value.map(x => ({ - type: 'option', - value: x.id, - label: x.name, - })), - }); - return items; -}); - -const darkThemeSelectorItems = computed(() => { - const items = [] as MkSelectItem[]; - if (instanceDarkTheme.value) { - items.push({ - type: 'option', - value: instanceDarkTheme.value.id, - label: instanceDarkTheme.value.name, - }); - } - if (installedDarkThemes.value.length > 0) { - items.push({ - type: 'group', - label: i18n.ts._theme.installedThemes, - items: installedDarkThemes.value.map(x => ({ - type: 'option', - value: x.id, - label: x.name, - })), - }); - } - items.push({ - type: 'group', - label: i18n.ts._theme.builtinThemes, - items: builtinDarkThemes.value.map(x => ({ - type: 'option', - value: x.id, - label: x.name, - })), - }); - return items; -}); - -const darkTheme = ColdDeviceStorage.ref('darkTheme'); +const darkTheme = prefer.r.darkTheme; +const darkThemeName = computed(() => darkTheme.value?.name ?? defaultDarkTheme.name); const darkThemeId = computed({ get() { - return darkTheme.value.id; + return darkTheme.value ? darkTheme.value.id : defaultDarkTheme.id; }, set(id) { const t = themes.value.find(x => x.id === id); if (t) { // テーマエディタでテーマを作成したときなどは、themesに反映されないため undefined になる - ColdDeviceStorage.set('darkTheme', t); + prefer.commit('darkTheme', t); } }, }); -const lightTheme = ColdDeviceStorage.ref('lightTheme'); +const lightTheme = prefer.r.lightTheme; +const lightThemeName = computed(() => lightTheme.value?.name ?? defaultLightTheme.name); const lightThemeId = computed({ get() { - return lightTheme.value.id; + return lightTheme.value ? lightTheme.value.id : defaultLightTheme.id; }, set(id) { const t = themes.value.find(x => x.id === id); if (t) { // テーマエディタでテーマを作成したときなどは、themesに反映されないため undefined になる - ColdDeviceStorage.set('lightTheme', t); + prefer.commit('lightTheme', t); } }, }); -const darkMode = computed(defaultStore.makeGetterSetter('darkMode')); -const syncDeviceDarkMode = computed(ColdDeviceStorage.makeGetterSetter('syncDeviceDarkMode')); -const wallpaper = ref(miLocalStorage.getItem('wallpaper')); +const darkMode = computed(store.makeGetterSetter('darkMode')); +const syncDeviceDarkMode = prefer.model('syncDeviceDarkMode'); const themesCount = installedThemes.value.length; watch(syncDeviceDarkMode, () => { if (syncDeviceDarkMode.value) { - defaultStore.set('darkMode', isDeviceDarkmode()); + store.set('darkMode', isDeviceDarkmode()); } }); -watch(wallpaper, async () => { - if (wallpaper.value == null) { - miLocalStorage.removeItem('wallpaper'); +const themesSyncEnabled = ref(prefer.isSyncEnabled('themes')); + +function changeThemesSyncEnabled(value: boolean) { + if (value) { + prefer.enableSync('themes').then((res) => { + if (res == null) return; + if (res.enabled) themesSyncEnabled.value = true; + }); } else { - miLocalStorage.setItem('wallpaper', wallpaper.value); + prefer.disableSync('themes'); + themesSyncEnabled.value = false; } - await reloadAsk({ reason: i18n.ts.reloadToApplySetting, unison: true }); -}); - -onActivated(() => { - fetchThemes().then(() => { - installedThemes.value = getThemes(); - }); -}); - -fetchThemes().then(() => { - installedThemes.value = getThemes(); -}); - -function setWallpaper(event) { - selectFile(event.currentTarget ?? event.target, null).then(file => { - wallpaper.value = file.url; - }); } const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.theme, icon: 'ti ti-palette', })); </script> +<style module> +.themeSelect { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + gap: var(--MI-margin); +} + +.themeItemOuter { + position: relative; +} + +.themeRadio { + position: absolute; + clip: rect(0, 0, 0, 0); + pointer-events: none; +} + +.themeItemRoot { + position: relative; + display: block; + overflow: clip; + box-sizing: border-box; + border: 2px solid var(--MI_THEME-divider); + border-radius: var(--MI-radius); +} + +.themeRadio:focus-visible + .themeItemRoot { + outline: 2px solid var(--MI_THEME-focus); + outline-offset: 2px; +} + +.themeRadio:checked + .themeItemRoot { + border-color: var(--MI_THEME-accent); +} + +.themeItemPreview { + display: block; + width: calc(100% + 2px); + height: auto; + margin-left: -1px; + border-bottom: 1px solid var(--MI_THEME-divider); +} + +.themeItemCaption { + box-sizing: border-box; + padding: 8px 12px; + text-align: center; + font-size: 80%; +} +</style> + <style lang="scss" scoped> .rfqxtzch { border-radius: 6px; @@ -475,17 +577,4 @@ definePageMetadata(() => ({ border-top: solid 0.5px var(--MI_THEME-divider); } } - -.rsljpzjq { - > .selects { - display: flex; - gap: 1.5em var(--MI-margin); - flex-wrap: wrap; - - > .select { - flex: 1; - min-width: 280px; - } - } -} </style> diff --git a/packages/frontend/src/pages/settings/webhook.edit.vue b/packages/frontend/src/pages/settings/webhook.edit.vue index 22b008fb61..877d2deb90 100644 --- a/packages/frontend/src/pages/settings/webhook.edit.vue +++ b/packages/frontend/src/pages/settings/webhook.edit.vue @@ -76,10 +76,10 @@ import FormSection from '@/components/form/section.vue'; import MkSwitch from '@/components/MkSwitch.vue'; import MkButton from '@/components/MkButton.vue'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; -import { useRouter } from '@/router/supplier.js'; +import { definePage } from '@/page.js'; +import { useRouter } from '@/router.js'; const router = useRouter(); @@ -155,7 +155,7 @@ const headerActions = computed(() => []); // eslint-disable-next-line @typescript-eslint/no-unused-vars const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: 'Edit webhook', icon: 'ti ti-webhook', })); @@ -184,6 +184,6 @@ definePageMetadata(() => ({ .description { font-size: 0.85em; padding: 8px 0 0 0; - color: var(--MI_THEME-fgTransparentWeak); + color: color(from var(--MI_THEME-fg) srgb r g b / 0.75); } </style> diff --git a/packages/frontend/src/pages/settings/webhook.new.vue b/packages/frontend/src/pages/settings/webhook.new.vue index 727c4df2d6..e853f967cb 100644 --- a/packages/frontend/src/pages/settings/webhook.new.vue +++ b/packages/frontend/src/pages/settings/webhook.new.vue @@ -46,7 +46,7 @@ import MkSwitch from '@/components/MkSwitch.vue'; import MkButton from '@/components/MkButton.vue'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; const name = ref(''); const url = ref(''); @@ -82,7 +82,7 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: 'Create new webhook', icon: 'ti ti-webhook', })); diff --git a/packages/frontend/src/pages/settings/webhook.vue b/packages/frontend/src/pages/settings/webhook.vue deleted file mode 100644 index af8b7ca945..0000000000 --- a/packages/frontend/src/pages/settings/webhook.vue +++ /dev/null @@ -1,57 +0,0 @@ -<!-- -SPDX-FileCopyrightText: syuilo and misskey-project -SPDX-License-Identifier: AGPL-3.0-only ---> - -<template> -<div class="_gaps_m"> - <FormLink :to="`/settings/webhook/new`"> - {{ i18n.ts._webhookSettings.createWebhook }} - </FormLink> - - <FormSection> - <MkPagination :pagination="pagination"> - <template #default="{items}"> - <div class="_gaps"> - <FormLink v-for="webhook in items" :key="webhook.id" :to="`/settings/webhook/edit/${webhook.id}`"> - <template #icon> - <i v-if="webhook.active === false" class="ti ti-player-pause"></i> - <i v-else-if="webhook.latestStatus === null" class="ti ti-circle"></i> - <i v-else-if="[200, 201, 204].includes(webhook.latestStatus)" class="ti ti-check" :style="{ color: 'var(--MI_THEME-success)' }"></i> - <i v-else class="ti ti-alert-triangle" :style="{ color: 'var(--MI_THEME-error)' }"></i> - </template> - {{ webhook.name || webhook.url }} - <template #suffix> - <MkTime v-if="webhook.latestSentAt" :time="webhook.latestSentAt"></MkTime> - </template> - </FormLink> - </div> - </template> - </MkPagination> - </FormSection> -</div> -</template> - -<script lang="ts" setup> -import { computed } from 'vue'; -import MkPagination from '@/components/MkPagination.vue'; -import FormSection from '@/components/form/section.vue'; -import FormLink from '@/components/form/link.vue'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; -import { i18n } from '@/i18n.js'; - -const pagination = { - endpoint: 'i/webhooks/list' as const, - limit: 100, - noPaging: true, -}; - -const headerActions = computed(() => []); - -const headerTabs = computed(() => []); - -definePageMetadata(() => ({ - title: 'Webhook', - icon: 'ti ti-webhook', -})); -</script> diff --git a/packages/frontend/src/pages/share.vue b/packages/frontend/src/pages/share.vue index 37f6558d64..57afdb9121 100644 --- a/packages/frontend/src/pages/share.vue +++ b/packages/frontend/src/pages/share.vue @@ -4,8 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> +<PageWithHeader :actions="headerActions" :tabs="headerTabs"> <MkSpacer :contentMax="800"> <MkPostForm v-if="state === 'writing'" @@ -26,7 +25,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkButton @click="goToMisskey">{{ i18n.ts.goToMisskey }}</MkButton> </div> </MkSpacer> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> @@ -37,9 +36,9 @@ import * as Misskey from 'misskey-js'; import MkButton from '@/components/MkButton.vue'; import MkPostForm from '@/components/MkPostForm.vue'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; -import { postMessageToParentWindow } from '@/scripts/post-message.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { definePage } from '@/page.js'; +import { postMessageToParentWindow } from '@/utility/post-message.js'; import { i18n } from '@/i18n.js'; const urlParams = new URLSearchParams(window.location.search); @@ -182,12 +181,12 @@ function close(): void { // 閉じなければ100ms後タイムラインに window.setTimeout(() => { - location.href = '/'; + window.location.href = '/'; }, 100); } function goToMisskey(): void { - location.href = '/'; + window.location.href = '/'; } function onPosted(): void { @@ -199,7 +198,7 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.share, icon: 'ti ti-share', })); diff --git a/packages/frontend/src/pages/signup-complete.vue b/packages/frontend/src/pages/signup-complete.vue index 14fb96d4f1..15954ccc82 100644 --- a/packages/frontend/src/pages/signup-complete.vue +++ b/packages/frontend/src/pages/signup-complete.vue @@ -4,8 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div> - <MkAnimBg style="position: fixed; top: 0;"/> +<PageWithAnimBg> <div :class="$style.formContainer"> <form :class="$style.form" class="_panel" @submit.prevent="submit()"> <div :class="$style.banner"> @@ -21,17 +20,16 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </form> </div> -</div> +</PageWithAnimBg> </template> <script lang="ts" setup> import { ref } from 'vue'; import MkButton from '@/components/MkButton.vue'; -import MkAnimBg from '@/components/MkAnimBg.vue'; -import { login } from '@/account.js'; import { i18n } from '@/i18n.js'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { login } from '@/accounts.js'; const submitting = ref(false); @@ -64,8 +62,8 @@ function submit() { min-height: 100svh; padding: 32px 32px 64px 32px; box-sizing: border-box; -display: grid; -place-content: center; + display: grid; + place-content: center; } .form { diff --git a/packages/frontend/src/pages/tag.vue b/packages/frontend/src/pages/tag.vue index b669e25179..f67777be4e 100644 --- a/packages/frontend/src/pages/tag.vue +++ b/packages/frontend/src/pages/tag.vue @@ -4,8 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> +<PageWithHeader :actions="headerActions" :tabs="headerTabs"> <MkSpacer :contentMax="800"> <MkNotes ref="notes" class="" :pagination="pagination"/> </MkSpacer> @@ -16,19 +15,19 @@ SPDX-License-Identifier: AGPL-3.0-only </MkSpacer> </div> </template> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> import { computed, ref } from 'vue'; import MkNotes from '@/components/MkNotes.vue'; import MkButton from '@/components/MkButton.vue'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; import { i18n } from '@/i18n.js'; -import { $i } from '@/account.js'; -import { defaultStore } from '@/store.js'; +import { $i } from '@/i.js'; +import { store } from '@/store.js'; import * as os from '@/os.js'; -import { genEmbedCode } from '@/scripts/get-embed-code.js'; +import { genEmbedCode } from '@/utility/get-embed-code.js'; const props = defineProps<{ tag: string; @@ -44,11 +43,11 @@ const pagination = { const notes = ref<InstanceType<typeof MkNotes>>(); async function post() { - defaultStore.set('postFormHashtags', props.tag); - defaultStore.set('postFormWithHashtags', true); + store.set('postFormHashtags', props.tag); + store.set('postFormWithHashtags', true); await os.post(); - defaultStore.set('postFormHashtags', ''); - defaultStore.set('postFormWithHashtags', false); + store.set('postFormHashtags', ''); + store.set('postFormWithHashtags', false); notes.value?.pagingComponent?.reload(); } @@ -57,18 +56,18 @@ const headerActions = computed(() => [{ label: i18n.ts.more, handler: (ev: MouseEvent) => { os.popupMenu([{ - text: i18n.ts.genEmbedCode, + text: i18n.ts.embed, icon: 'ti ti-code', action: () => { genEmbedCode('tags', props.tag); }, }], ev.currentTarget ?? ev.target); - } + }, }]); const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: props.tag, icon: 'ti ti-hash', })); @@ -78,7 +77,7 @@ definePageMetadata(() => ({ .footer { -webkit-backdrop-filter: var(--MI-blur, blur(15px)); backdrop-filter: var(--MI-blur, blur(15px)); - background: var(--MI_THEME-acrylicBg); + background: color(from var(--MI_THEME-bg) srgb r g b / 0.5); border-top: solid 0.5px var(--MI_THEME-divider); display: flex; } diff --git a/packages/frontend/src/pages/theme-editor.vue b/packages/frontend/src/pages/theme-editor.vue index 76567cc403..b16edffc29 100644 --- a/packages/frontend/src/pages/theme-editor.vue +++ b/packages/frontend/src/pages/theme-editor.vue @@ -4,8 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> +<PageWithHeader :actions="headerActions" :tabs="headerTabs"> <MkSpacer :contentMax="800" :marginMin="16" :marginMax="32"> <div class="cwepdizn _gaps_m"> <MkFolder :defaultOpen="true"> @@ -69,7 +68,7 @@ SPDX-License-Identifier: AGPL-3.0-only </MkFolder> </div> </MkSpacer> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> @@ -78,24 +77,22 @@ import { toUnicode } from 'punycode.js'; import tinycolor from 'tinycolor2'; import { v4 as uuid } from 'uuid'; import JSON5 from 'json5'; - import lightTheme from '@@/themes/_light.json5'; import darkTheme from '@@/themes/_dark.json5'; +import { host } from '@@/js/config.js'; +import type { Theme } from '@/theme.js'; import MkButton from '@/components/MkButton.vue'; import MkCodeEditor from '@/components/MkCodeEditor.vue'; import MkTextarea from '@/components/MkTextarea.vue'; import MkFolder from '@/components/MkFolder.vue'; - -import { $i } from '@/account.js'; -import { applyTheme } from '@/scripts/theme.js'; -import type { Theme } from '@/scripts/theme.js'; -import { host } from '@@/js/config.js'; +import { $i } from '@/i.js'; +import { addTheme, applyTheme } from '@/theme.js'; import * as os from '@/os.js'; -import { ColdDeviceStorage, defaultStore } from '@/store.js'; -import { addTheme } from '@/theme-store.js'; +import { store } from '@/store.js'; import { i18n } from '@/i18n.js'; -import { useLeaveGuard } from '@/scripts/use-leave-guard.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { useLeaveGuard } from '@/use/use-leave-guard.js'; +import { definePage } from '@/page.js'; +import { prefer } from '@/preferences.js'; const bgColors = [ { color: '#f5f5f5', kind: 'light', forPreview: '#f5f5f5' }, @@ -201,10 +198,10 @@ async function saveAs() { if (description.value) theme.value.desc = description.value; await addTheme(theme.value); applyTheme(theme.value); - if (defaultStore.state.darkMode) { - ColdDeviceStorage.set('darkTheme', theme.value); + if (store.s.darkMode) { + prefer.commit('darkTheme', theme.value); } else { - ColdDeviceStorage.set('lightTheme', theme.value); + prefer.commit('lightTheme', theme.value); } changed.value = false; os.alert({ @@ -229,7 +226,7 @@ const headerActions = computed(() => [{ const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.themeEditor, icon: 'ti ti-palette', })); diff --git a/packages/frontend/src/pages/timeline.vue b/packages/frontend/src/pages/timeline.vue index 044a1908ab..644b2d3d13 100644 --- a/packages/frontend/src/pages/timeline.vue +++ b/packages/frontend/src/pages/timeline.vue @@ -4,80 +4,82 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader v-model:tab="src" :actions="headerActions" :tabs="$i ? headerTabs : headerTabsWhenNotLogin" :displayMyAvatar="true"/></template> - <MkSpacer :contentMax="800"> - <MkHorizontalSwipe v-model:tab="src" :tabs="$i ? headerTabs : headerTabsWhenNotLogin"> - <div :key="src" ref="rootEl"> - <MkInfo v-if="isBasicTimeline(src) && !defaultStore.reactiveState.timelineTutorials.value[src]" style="margin-bottom: var(--MI-margin);" closable @close="closeTutorial()"> - {{ i18n.ts._timelineDescription[src] }} - </MkInfo> - <MkPostForm v-if="defaultStore.reactiveState.showFixedPostForm.value" :class="$style.postForm" class="post-form _panel" fixed style="margin-bottom: var(--MI-margin);"/> - <div v-if="queue > 0" :class="$style.new"><button class="_buttonPrimary" :class="$style.newButton" @click="top()">{{ i18n.ts.newNoteRecived }}</button></div> - <div :class="$style.tl"> - <MkTimeline - ref="tlComponent" - :key="src + withRenotes + withReplies + onlyFiles + withSensitive" - :src="src.split(':')[0]" - :list="src.split(':')[1]" - :withRenotes="withRenotes" - :withReplies="withReplies" - :withSensitive="withSensitive" - :onlyFiles="onlyFiles" - :sound="true" - @queue="queueUpdated" - /> - </div> - </div> - </MkHorizontalSwipe> - </MkSpacer> -</MkStickyContainer> +<div ref="rootEl" class="_pageScrollable"> + <MkStickyContainer> + <template #header><MkPageHeader v-model:tab="src" :displayMyAvatar="true" :actions="headerActions" :tabs="$i ? headerTabs : headerTabsWhenNotLogin"/></template> + <MkSpacer :contentMax="800"> + <MkInfo v-if="isBasicTimeline(src) && !store.r.timelineTutorials.value[src]" style="margin-bottom: var(--MI-margin);" closable @close="closeTutorial()"> + {{ i18n.ts._timelineDescription[src] }} + </MkInfo> + <MkPostForm v-if="prefer.r.showFixedPostForm.value" :class="$style.postForm" class="_panel" fixed style="margin-bottom: var(--MI-margin);"/> + <div v-if="queue > 0" :class="$style.new"><button class="_buttonPrimary" :class="$style.newButton" @click="top()">{{ i18n.ts.newNoteRecived }}</button></div> + <MkTimeline + ref="tlComponent" + :key="src + withRenotes + withReplies + onlyFiles + withSensitive" + :class="$style.tl" + :src="src.split(':')[0]" + :list="src.split(':')[1]" + :withRenotes="withRenotes" + :withReplies="withReplies" + :withSensitive="withSensitive" + :onlyFiles="onlyFiles" + :sound="true" + @queue="queueUpdated" + /> + </MkSpacer> + </MkStickyContainer> +</div> </template> <script lang="ts" setup> -import { computed, watch, provide, shallowRef, ref, onMounted, onActivated } from 'vue'; +import { computed, watch, provide, useTemplateRef, ref, onMounted, onActivated } from 'vue'; +import { scrollInContainer } from '@@/js/scroll.js'; import type { Tab } from '@/components/global/MkPageHeader.tabs.vue'; +import type { MenuItem } from '@/types/menu.js'; +import type { BasicTimelineType } from '@/timelines.js'; import MkTimeline from '@/components/MkTimeline.vue'; import MkInfo from '@/components/MkInfo.vue'; import MkPostForm from '@/components/MkPostForm.vue'; -import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue'; -import { scroll } from '@@/js/scroll.js'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { defaultStore } from '@/store.js'; +import { store } from '@/store.js'; import { i18n } from '@/i18n.js'; -import { $i } from '@/account.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { $i } from '@/i.js'; +import { definePage } from '@/page.js'; import { antennasCache, userListsCache, favoritedChannelsCache } from '@/cache.js'; -import { deviceKind } from '@/scripts/device-kind.js'; -import { deepMerge } from '@/scripts/merge.js'; -import type { MenuItem } from '@/types/menu.js'; +import { deviceKind } from '@/utility/device-kind.js'; +import { deepMerge } from '@/utility/merge.js'; import { miLocalStorage } from '@/local-storage.js'; import { availableBasicTimelines, hasWithReplies, isAvailableBasicTimeline, isBasicTimeline, basicTimelineIconClass } from '@/timelines.js'; -import type { BasicTimelineType } from '@/timelines.js'; +import { prefer } from '@/preferences.js'; +import { useRouter } from '@/router.js'; provide('shouldOmitHeaderTitle', true); -const tlComponent = shallowRef<InstanceType<typeof MkTimeline>>(); -const rootEl = shallowRef<HTMLElement>(); +const tlComponent = useTemplateRef('tlComponent'); +const rootEl = useTemplateRef('rootEl'); + +const router = useRouter(); +router.useListener('same', () => { + top(); +}); type TimelinePageSrc = BasicTimelineType | `list:${string}`; const queue = ref(0); const srcWhenNotSignin = ref<'local' | 'global'>(isAvailableBasicTimeline('local') ? 'local' : 'global'); const src = computed<TimelinePageSrc>({ - get: () => ($i ? defaultStore.reactiveState.tl.value.src : srcWhenNotSignin.value), + get: () => ($i ? store.r.tl.value.src : srcWhenNotSignin.value), set: (x) => saveSrc(x), }); const withRenotes = computed<boolean>({ - get: () => defaultStore.reactiveState.tl.value.filter.withRenotes, + get: () => store.r.tl.value.filter.withRenotes, set: (x) => saveTlFilter('withRenotes', x), }); // computed内での無限ループを防ぐためのフラグ const localSocialTLFilterSwitchStore = ref<'withReplies' | 'onlyFiles' | false>( - defaultStore.reactiveState.tl.value.filter.withReplies ? 'withReplies' : - defaultStore.reactiveState.tl.value.filter.onlyFiles ? 'onlyFiles' : + store.r.tl.value.filter.withReplies ? 'withReplies' : + store.r.tl.value.filter.onlyFiles ? 'onlyFiles' : false, ); @@ -87,7 +89,7 @@ const withReplies = computed<boolean>({ if (['local', 'social'].includes(src.value) && localSocialTLFilterSwitchStore.value === 'onlyFiles') { return false; } else { - return defaultStore.reactiveState.tl.value.filter.withReplies; + return store.r.tl.value.filter.withReplies; } }, set: (x) => saveTlFilter('withReplies', x), @@ -97,7 +99,7 @@ const onlyFiles = computed<boolean>({ if (['local', 'social'].includes(src.value) && localSocialTLFilterSwitchStore.value === 'withReplies') { return false; } else { - return defaultStore.reactiveState.tl.value.filter.onlyFiles; + return store.r.tl.value.filter.onlyFiles; } }, set: (x) => saveTlFilter('onlyFiles', x), @@ -114,7 +116,7 @@ watch([withReplies, onlyFiles], ([withRepliesTo, onlyFilesTo]) => { }); const withSensitive = computed<boolean>({ - get: () => defaultStore.reactiveState.tl.value.filter.withSensitive, + get: () => store.r.tl.value.filter.withSensitive, set: (x) => saveTlFilter('withSensitive', x), }); @@ -127,7 +129,7 @@ function queueUpdated(q: number): void { } function top(): void { - if (rootEl.value) scroll(rootEl.value, { top: 0 }); + if (rootEl.value) scrollInContainer(rootEl.value, { top: 0, behavior: 'instant' }); } async function chooseList(ev: MouseEvent): Promise<void> { @@ -195,23 +197,23 @@ async function chooseChannel(ev: MouseEvent): Promise<void> { } function saveSrc(newSrc: TimelinePageSrc): void { - const out = deepMerge({ src: newSrc }, defaultStore.state.tl); + const out = deepMerge({ src: newSrc }, store.s.tl); if (newSrc.startsWith('userList:')) { const id = newSrc.substring('userList:'.length); - out.userList = defaultStore.reactiveState.pinnedUserLists.value.find(l => l.id === id) ?? null; + out.userList = prefer.r.pinnedUserLists.value.find(l => l.id === id) ?? null; } - defaultStore.set('tl', out); + store.set('tl', out); if (['local', 'global'].includes(newSrc)) { srcWhenNotSignin.value = newSrc as 'local' | 'global'; } } -function saveTlFilter(key: keyof typeof defaultStore.state.tl.filter, newValue: boolean) { +function saveTlFilter(key: keyof typeof store.s.tl.filter, newValue: boolean) { if (key !== 'withReplies' || $i) { - const out = deepMerge({ filter: { [key]: newValue } }, defaultStore.state.tl); - defaultStore.set('tl', out); + const out = deepMerge({ filter: { [key]: newValue } }, store.s.tl); + store.set('tl', out); } } @@ -230,9 +232,9 @@ function focus(): void { function closeTutorial(): void { if (!isBasicTimeline(src.value)) return; - const before = defaultStore.state.timelineTutorials; + const before = store.s.timelineTutorials; before[src.value] = true; - defaultStore.set('timelineTutorials', before); + store.set('timelineTutorials', before); } function switchTlIfNeeded() { @@ -298,7 +300,7 @@ const headerActions = computed(() => { return tmp; }); -const headerTabs = computed(() => [...(defaultStore.reactiveState.pinnedUserLists.value.map(l => ({ +const headerTabs = computed(() => [...(prefer.r.pinnedUserLists.value.map(l => ({ key: 'list:' + l.id, title: l.name, icon: 'ti ti-star', @@ -332,7 +334,7 @@ const headerTabsWhenNotLogin = computed(() => [...availableBasicTimelines().map( iconOnly: true, }))] as Tab[]); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.timeline, icon: isBasicTimeline(src.value) ? basicTimelineIconClass(src.value) : 'ti ti-home', })); diff --git a/packages/frontend/src/pages/user-list-timeline.vue b/packages/frontend/src/pages/user-list-timeline.vue index 3efeb46c0a..53081b0f16 100644 --- a/packages/frontend/src/pages/user-list-timeline.vue +++ b/packages/frontend/src/pages/user-list-timeline.vue @@ -4,8 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> +<PageWithHeader :actions="headerActions" :tabs="headerTabs"> <MkSpacer :contentMax="800"> <div ref="rootEl"> <div v-if="queue > 0" :class="$style.new"><button class="_buttonPrimary" :class="$style.newButton" @click="top()">{{ i18n.ts.newNoteRecived }}</button></div> @@ -20,18 +19,18 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </div> </MkSpacer> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> -import { computed, watch, ref, shallowRef } from 'vue'; +import { computed, watch, ref, useTemplateRef } from 'vue'; import * as Misskey from 'misskey-js'; +import { scrollInContainer } from '@@/js/scroll.js'; import MkTimeline from '@/components/MkTimeline.vue'; -import { scroll } from '@@/js/scroll.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { definePage } from '@/page.js'; import { i18n } from '@/i18n.js'; -import { useRouter } from '@/router/supplier.js'; +import { useRouter } from '@/router.js'; const router = useRouter(); @@ -41,8 +40,8 @@ const props = defineProps<{ const list = ref<Misskey.entities.UserList | null>(null); const queue = ref(0); -const tlEl = shallowRef<InstanceType<typeof MkTimeline>>(); -const rootEl = shallowRef<HTMLElement>(); +const tlEl = useTemplateRef('tlEl'); +const rootEl = useTemplateRef('rootEl'); watch(() => props.listId, async () => { list.value = await misskeyApi('users/lists/show', { @@ -55,7 +54,7 @@ function queueUpdated(q) { } function top() { - scroll(rootEl.value, { top: 0 }); + scrollInContainer(rootEl.value, { top: 0 }); } function settings() { @@ -70,7 +69,7 @@ const headerActions = computed(() => list.value ? [{ const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: list.value ? list.value.name : i18n.ts.lists, icon: 'ti ti-list', })); diff --git a/packages/frontend/src/pages/user-tag.vue b/packages/frontend/src/pages/user-tag.vue index a77493fe47..d1dc721a4b 100644 --- a/packages/frontend/src/pages/user-tag.vue +++ b/packages/frontend/src/pages/user-tag.vue @@ -4,21 +4,19 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader/></template> - +<PageWithHeader> <MkSpacer :contentMax="1200"> <div class="_gaps_s"> <MkUserList :pagination="tagUsers"/> </div> </MkSpacer> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> import { computed } from 'vue'; import MkUserList from '@/components/MkUserList.vue'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; const props = defineProps<{ tag: string; @@ -34,7 +32,7 @@ const tagUsers = computed(() => ({ }, })); -definePageMetadata(() => ({ +definePage(() => ({ title: props.tag, icon: 'ti ti-user-search', })); diff --git a/packages/frontend/src/pages/user/achievements.vue b/packages/frontend/src/pages/user/achievements.vue index 403e74904c..8f13e959e1 100644 --- a/packages/frontend/src/pages/user/achievements.vue +++ b/packages/frontend/src/pages/user/achievements.vue @@ -13,8 +13,8 @@ SPDX-License-Identifier: AGPL-3.0-only import { onActivated, onDeactivated, onMounted, onUnmounted } from 'vue'; import * as Misskey from 'misskey-js'; import MkAchievements from '@/components/MkAchievements.vue'; -import { claimAchievement } from '@/scripts/achievements.js'; -import { $i } from '@/account.js'; +import { claimAchievement } from '@/utility/achievements.js'; +import { $i } from '@/i.js'; const props = defineProps<{ user: Misskey.entities.User; diff --git a/packages/frontend/src/pages/user/activity.following.vue b/packages/frontend/src/pages/user/activity.following.vue index 7b74ea67ca..f5d2002669 100644 --- a/packages/frontend/src/pages/user/activity.following.vue +++ b/packages/frontend/src/pages/user/activity.following.vue @@ -14,17 +14,17 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted, shallowRef, ref } from 'vue'; +import { onMounted, useTemplateRef, ref } from 'vue'; import { Chart } from 'chart.js'; -import type { ChartDataset } from 'chart.js'; import * as Misskey from 'misskey-js'; import gradient from 'chartjs-plugin-gradient'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { defaultStore } from '@/store.js'; -import { useChartTooltip } from '@/scripts/use-chart-tooltip.js'; -import { chartVLine } from '@/scripts/chart-vline.js'; -import { initChart } from '@/scripts/init-chart.js'; -import { chartLegend } from '@/scripts/chart-legend.js'; +import type { ChartDataset } from 'chart.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { store } from '@/store.js'; +import { useChartTooltip } from '@/use/use-chart-tooltip.js'; +import { chartVLine } from '@/utility/chart-vline.js'; +import { initChart } from '@/utility/init-chart.js'; +import { chartLegend } from '@/utility/chart-legend.js'; import MkChartLegend from '@/components/MkChartLegend.vue'; initChart(); @@ -33,8 +33,8 @@ const props = defineProps<{ user: Misskey.entities.User; }>(); -const chartEl = shallowRef<HTMLCanvasElement>(null); -const legendEl = shallowRef<InstanceType<typeof MkChartLegend>>(); +const chartEl = useTemplateRef('chartEl'); +const legendEl = useTemplateRef('legendEl'); const now = new Date(); let chartInstance: Chart = null; const chartLimit = 30; @@ -64,7 +64,7 @@ async function renderChart() { const raw = await misskeyApi('charts/user/following', { userId: props.user.id, limit: chartLimit, span: 'day' }); - const vLineColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)'; + const vLineColor = store.s.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)'; const colorFollowLocal = '#008FFB'; const colorFollowRemote = '#008FFB88'; diff --git a/packages/frontend/src/pages/user/activity.notes.vue b/packages/frontend/src/pages/user/activity.notes.vue index 8c7484ae08..01c62810d4 100644 --- a/packages/frontend/src/pages/user/activity.notes.vue +++ b/packages/frontend/src/pages/user/activity.notes.vue @@ -14,17 +14,17 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted, shallowRef, ref } from 'vue'; +import { onMounted, useTemplateRef, ref } from 'vue'; import { Chart } from 'chart.js'; -import type { ChartDataset } from 'chart.js'; import * as Misskey from 'misskey-js'; import gradient from 'chartjs-plugin-gradient'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { defaultStore } from '@/store.js'; -import { useChartTooltip } from '@/scripts/use-chart-tooltip.js'; -import { chartVLine } from '@/scripts/chart-vline.js'; -import { initChart } from '@/scripts/init-chart.js'; -import { chartLegend } from '@/scripts/chart-legend.js'; +import type { ChartDataset } from 'chart.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { store } from '@/store.js'; +import { useChartTooltip } from '@/use/use-chart-tooltip.js'; +import { chartVLine } from '@/utility/chart-vline.js'; +import { initChart } from '@/utility/init-chart.js'; +import { chartLegend } from '@/utility/chart-legend.js'; import MkChartLegend from '@/components/MkChartLegend.vue'; initChart(); @@ -33,8 +33,8 @@ const props = defineProps<{ user: Misskey.entities.User; }>(); -const chartEl = shallowRef<HTMLCanvasElement>(null); -const legendEl = shallowRef<InstanceType<typeof MkChartLegend>>(); +const chartEl = useTemplateRef('chartEl'); +const legendEl = useTemplateRef('legendEl'); const now = new Date(); let chartInstance: Chart = null; const chartLimit = 50; @@ -64,7 +64,7 @@ async function renderChart() { const raw = await misskeyApi('charts/user/notes', { userId: props.user.id, limit: chartLimit, span: 'day' }); - const vLineColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)'; + const vLineColor = store.s.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)'; const colorNormal = '#008FFB'; const colorReply = '#FEB019'; diff --git a/packages/frontend/src/pages/user/activity.pv.vue b/packages/frontend/src/pages/user/activity.pv.vue index a073626cbb..ed12b1b5c7 100644 --- a/packages/frontend/src/pages/user/activity.pv.vue +++ b/packages/frontend/src/pages/user/activity.pv.vue @@ -14,17 +14,17 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted, shallowRef, ref } from 'vue'; +import { onMounted, useTemplateRef, ref } from 'vue'; import { Chart } from 'chart.js'; -import type { ChartDataset } from 'chart.js'; import * as Misskey from 'misskey-js'; import gradient from 'chartjs-plugin-gradient'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { defaultStore } from '@/store.js'; -import { useChartTooltip } from '@/scripts/use-chart-tooltip.js'; -import { chartVLine } from '@/scripts/chart-vline.js'; -import { initChart } from '@/scripts/init-chart.js'; -import { chartLegend } from '@/scripts/chart-legend.js'; +import type { ChartDataset } from 'chart.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { store } from '@/store.js'; +import { useChartTooltip } from '@/use/use-chart-tooltip.js'; +import { chartVLine } from '@/utility/chart-vline.js'; +import { initChart } from '@/utility/init-chart.js'; +import { chartLegend } from '@/utility/chart-legend.js'; import MkChartLegend from '@/components/MkChartLegend.vue'; initChart(); @@ -33,8 +33,8 @@ const props = defineProps<{ user: Misskey.entities.User; }>(); -const chartEl = shallowRef<HTMLCanvasElement>(null); -const legendEl = shallowRef<InstanceType<typeof MkChartLegend>>(); +const chartEl = useTemplateRef('chartEl'); +const legendEl = useTemplateRef('legendEl'); const now = new Date(); let chartInstance: Chart = null; const chartLimit = 30; @@ -64,7 +64,7 @@ async function renderChart() { const raw = await misskeyApi('charts/user/pv', { userId: props.user.id, limit: chartLimit, span: 'day' }); - const vLineColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)'; + const vLineColor = store.s.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)'; const colorUser = '#3498db'; const colorVisitor = '#2ecc71'; diff --git a/packages/frontend/src/pages/user/followers.vue b/packages/frontend/src/pages/user/followers.vue index 70883242e5..4379d4680a 100644 --- a/packages/frontend/src/pages/user/followers.vue +++ b/packages/frontend/src/pages/user/followers.vue @@ -4,8 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> +<PageWithHeader :actions="headerActions" :tabs="headerTabs"> <MkSpacer :contentMax="1000"> <Transition name="fade" mode="out-in"> <div v-if="user"> @@ -15,15 +14,15 @@ SPDX-License-Identifier: AGPL-3.0-only <MkLoading v-else/> </Transition> </MkSpacer> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> import { computed, watch, ref } from 'vue'; import * as Misskey from 'misskey-js'; import XFollowList from './follow-list.vue'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { definePage } from '@/page.js'; import { i18n } from '@/i18n.js'; const props = withDefaults(defineProps<{ @@ -52,7 +51,7 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.user, icon: 'ti ti-user', ...user.value ? { diff --git a/packages/frontend/src/pages/user/following.vue b/packages/frontend/src/pages/user/following.vue index 37b25f694f..5ed66b5afd 100644 --- a/packages/frontend/src/pages/user/following.vue +++ b/packages/frontend/src/pages/user/following.vue @@ -4,8 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> +<PageWithHeader :actions="headerActions" :tabs="headerTabs"> <MkSpacer :contentMax="1000"> <Transition name="fade" mode="out-in"> <div v-if="user"> @@ -15,15 +14,15 @@ SPDX-License-Identifier: AGPL-3.0-only <MkLoading v-else/> </Transition> </MkSpacer> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> import { computed, watch, ref } from 'vue'; import * as Misskey from 'misskey-js'; import XFollowList from './follow-list.vue'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { definePage } from '@/page.js'; import { i18n } from '@/i18n.js'; const props = withDefaults(defineProps<{ @@ -52,7 +51,7 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.user, icon: 'ti ti-user', ...user.value ? { diff --git a/packages/frontend/src/pages/user/home.vue b/packages/frontend/src/pages/user/home.vue index 8ebcf975b7..961b042873 100644 --- a/packages/frontend/src/pages/user/home.vue +++ b/packages/frontend/src/pages/user/home.vue @@ -171,20 +171,20 @@ import MkTextarea from '@/components/MkTextarea.vue'; import MkOmit from '@/components/MkOmit.vue'; import MkInfo from '@/components/MkInfo.vue'; import MkButton from '@/components/MkButton.vue'; -import { getUserMenu } from '@/scripts/get-user-menu.js'; +import { getUserMenu } from '@/utility/get-user-menu.js'; import number from '@/filters/number.js'; import { userPage } from '@/filters/user.js'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; -import { defaultStore } from '@/store.js'; -import { $i, iAmModerator } from '@/account.js'; +import { $i, iAmModerator } from '@/i.js'; import { dateString } from '@/filters/date.js'; -import { confetti } from '@/scripts/confetti.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { isFollowingVisibleForMe, isFollowersVisibleForMe } from '@/scripts/isFfVisibleForMe.js'; -import { useRouter } from '@/router/supplier.js'; -import { getStaticImageUrl } from '@/scripts/media-proxy.js'; +import { confetti } from '@/utility/confetti.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { isFollowingVisibleForMe, isFollowersVisibleForMe } from '@/utility/isFfVisibleForMe.js'; +import { useRouter } from '@/router.js'; +import { getStaticImageUrl } from '@/utility/media-proxy.js'; import MkSparkle from '@/components/MkSparkle.vue'; +import { prefer } from '@/preferences.js'; function calcAge(birthdate: string): number { const date = new Date(birthdate); @@ -236,7 +236,7 @@ watch(moderationNote, async () => { const style = computed(() => { if (props.user.bannerUrl == null) return {}; - if (defaultStore.state.disableShowingAnimatedImages) { + if (prefer.s.disableShowingAnimatedImages) { return { backgroundImage: `url(${ getStaticImageUrl(props.user.bannerUrl) })`, }; @@ -520,7 +520,7 @@ onUnmounted(() => { > .heading { text-align: left; - color: var(--MI_THEME-fgTransparent); + color: color(from var(--MI_THEME-fg) srgb r g b / 0.5); line-height: 1.5; font-size: 85%; } diff --git a/packages/frontend/src/pages/user/index.files.vue b/packages/frontend/src/pages/user/index.files.vue index b5e5f29ade..6c3b8408fb 100644 --- a/packages/frontend/src/pages/user/index.files.vue +++ b/packages/frontend/src/pages/user/index.files.vue @@ -20,7 +20,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { onMounted, ref } from 'vue'; import * as Misskey from 'misskey-js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import MkContainer from '@/components/MkContainer.vue'; import { i18n } from '@/i18n.js'; import MkNoteMediaGrid from '@/components/MkNoteMediaGrid.vue'; diff --git a/packages/frontend/src/pages/user/index.vue b/packages/frontend/src/pages/user/index.vue index c43f6c76d9..58f44d7591 100644 --- a/packages/frontend/src/pages/user/index.vue +++ b/packages/frontend/src/pages/user/index.vue @@ -4,41 +4,38 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> - <div> - <div v-if="user"> - <MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs"> - <XHome v-if="tab === 'home'" key="home" :user="user" @unfoldFiles="() => { tab = 'files'; }"/> - <MkSpacer v-else-if="tab === 'notes'" key="notes" :contentMax="800" style="padding-top: 0"> - <XTimeline :user="user"/> - </MkSpacer> - <XFiles v-else-if="tab === 'files'" :user="user"/> - <XActivity v-else-if="tab === 'activity'" key="activity" :user="user"/> - <XAchievements v-else-if="tab === 'achievements'" key="achievements" :user="user"/> - <XReactions v-else-if="tab === 'reactions'" key="reactions" :user="user"/> - <XClips v-else-if="tab === 'clips'" key="clips" :user="user"/> - <XLists v-else-if="tab === 'lists'" key="lists" :user="user"/> - <XPages v-else-if="tab === 'pages'" key="pages" :user="user"/> - <XFlashs v-else-if="tab === 'flashs'" key="flashs" :user="user"/> - <XGallery v-else-if="tab === 'gallery'" key="gallery" :user="user"/> - <XRaw v-else-if="tab === 'raw'" key="raw" :user="user"/> - </MkHorizontalSwipe> - </div> - <MkError v-else-if="error" @retry="fetchUser()"/> - <MkLoading v-else/> +<PageWithHeader v-model:tab="tab" :tabs="headerTabs" :actions="headerActions"> + <div v-if="user"> + <MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs"> + <XHome v-if="tab === 'home'" :user="user" @unfoldFiles="() => { tab = 'files'; }"/> + <MkSpacer v-else-if="tab === 'notes'" :contentMax="800" style="padding-top: 0"> + <XTimeline :user="user"/> + </MkSpacer> + <XFiles v-else-if="tab === 'files'" :user="user"/> + <XActivity v-else-if="tab === 'activity'" :user="user"/> + <XAchievements v-else-if="tab === 'achievements'" :user="user"/> + <XReactions v-else-if="tab === 'reactions'" :user="user"/> + <XClips v-else-if="tab === 'clips'" :user="user"/> + <XLists v-else-if="tab === 'lists'" :user="user"/> + <XPages v-else-if="tab === 'pages'" :user="user"/> + <XFlashs v-else-if="tab === 'flashs'" :user="user"/> + <XGallery v-else-if="tab === 'gallery'" :user="user"/> + <XRaw v-else-if="tab === 'raw'" :user="user"/> + </MkHorizontalSwipe> </div> -</MkStickyContainer> + <MkError v-else-if="error" @retry="fetchUser()"/> + <MkLoading v-else/> +</PageWithHeader> </template> <script lang="ts" setup> import { defineAsyncComponent, computed, watch, ref } from 'vue'; import * as Misskey from 'misskey-js'; import { acct as getAcct } from '@/filters/user.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { definePage } from '@/page.js'; import { i18n } from '@/i18n.js'; -import { $i } from '@/account.js'; +import { $i } from '@/i.js'; import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue'; import { serverContext, assertServerContext } from '@/server-context.js'; @@ -147,7 +144,7 @@ const headerTabs = computed(() => user.value ? [{ icon: 'ti ti-code', }] : []); -definePageMetadata(() => ({ +definePage(() => ({ title: i18n.ts.user, icon: 'ti ti-user', ...user.value ? { diff --git a/packages/frontend/src/pages/welcome.entrance.a.vue b/packages/frontend/src/pages/welcome.entrance.a.vue index 34c5c3ce6c..d131c17340 100644 --- a/packages/frontend/src/pages/welcome.entrance.a.vue +++ b/packages/frontend/src/pages/welcome.entrance.a.vue @@ -4,31 +4,24 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div v-if="meta" class="rsqzvsbo"> - <MkFeaturedPhotos class="bg"/> - <XTimeline class="tl"/> - <div class="shape1"></div> - <div class="shape2"></div> - <div class="logo-wrapper"> - <div class="powered-by">Powered by</div> - <img :src="misskeysvg" class="misskey"/> +<div v-if="meta" :class="$style.root"> + <MkFeaturedPhotos :class="$style.bg"/> + <XTimeline :class="$style.tl"/> + <div :class="$style.shape1"></div> + <div :class="$style.shape2"></div> + <div :class="$style.logoWrapper"> + <div :class="$style.poweredBy">Powered by</div> + <img :src="misskeysvg" :class="$style.misskey"/> </div> - <div class="emojis"> - <MkEmoji :normal="true" :noStyle="true" emoji="👍"/> - <MkEmoji :normal="true" :noStyle="true" emoji="❤"/> - <MkEmoji :normal="true" :noStyle="true" emoji="😆"/> - <MkEmoji :normal="true" :noStyle="true" emoji="🎉"/> - <MkEmoji :normal="true" :noStyle="true" emoji="🍮"/> - </div> - <div class="contents"> + <div :class="$style.contents"> <MkVisitorDashboard/> </div> - <div v-if="instances && instances.length > 0" class="federation"> + <div v-if="instances && instances.length > 0" :class="$style.federation"> <MarqueeText :duration="40"> <MkA v-for="instance in instances" :key="instance.id" :class="$style.federationInstance" :to="`/instance-info/${instance.host}`" behavior="window"> <!--<MkInstanceCardMini :instance="instance"/>--> - <img v-if="instance.iconUrl" class="icon" :src="getInstanceIcon(instance)" alt=""/> - <span class="name _monospace">{{ instance.host }}</span> + <img v-if="instance.iconUrl" :class="$style.federationInstanceIcon" :src="getInstanceIcon(instance)" alt=""/> + <span class="_monospace">{{ instance.host }}</span> </MkA> </MarqueeText> </div> @@ -42,9 +35,9 @@ import XTimeline from './welcome.timeline.vue'; import MarqueeText from '@/components/MkMarquee.vue'; import MkFeaturedPhotos from '@/components/MkFeaturedPhotos.vue'; import misskeysvg from '/client-assets/misskey.svg'; -import { misskeyApiGet } from '@/scripts/misskey-api.js'; +import { misskeyApiGet } from '@/utility/misskey-api.js'; import MkVisitorDashboard from '@/components/MkVisitorDashboard.vue'; -import { getProxiedImageUrl } from '@/scripts/media-proxy.js'; +import { getProxiedImageUrl } from '@/utility/media-proxy.js'; import { instance as meta } from '@/instance.js'; const instances = ref<Misskey.entities.FederationInstance[]>(); @@ -66,122 +59,111 @@ misskeyApiGet('federation/instances', { }); </script> -<style lang="scss" scoped> -.rsqzvsbo { - > .bg { - position: fixed; - top: 0; - right: 0; - width: 80vw; // 100%からshapeの幅を引いている - height: 100vh; - } +<style lang="scss" module> +.root { + height: 100cqh; + overflow: auto; + overscroll-behavior: contain; +} - > .tl { - position: fixed; - top: 0; - bottom: 0; - right: 64px; - margin: auto; - padding: 128px 0; - width: 500px; - height: calc(100% - 256px); - overflow: hidden; - -webkit-mask-image: linear-gradient(0deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 128px, rgba(0,0,0,1) calc(100% - 128px), rgba(0,0,0,0) 100%); - mask-image: linear-gradient(0deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 128px, rgba(0,0,0,1) calc(100% - 128px), rgba(0,0,0,0) 100%); +.bg { + position: fixed; + top: 0; + right: 0; + width: 80vw; // 100%からshapeの幅を引いている + height: 100vh; +} - @media (max-width: 1200px) { - display: none; - } - } +.tl { + position: fixed; + top: 0; + bottom: 0; + right: 64px; + margin: auto; + padding: 128px 0; + width: 500px; + height: calc(100% - 256px); + overflow: hidden; + -webkit-mask-image: linear-gradient(0deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 128px, rgba(0,0,0,1) calc(100% - 128px), rgba(0,0,0,0) 100%); + mask-image: linear-gradient(0deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 128px, rgba(0,0,0,1) calc(100% - 128px), rgba(0,0,0,0) 100%); - > .shape1 { - position: fixed; - top: 0; - left: 0; - width: 100vw; - height: 100vh; - background: var(--MI_THEME-accent); - clip-path: polygon(0% 0%, 45% 0%, 20% 100%, 0% 100%); + @media (max-width: 1200px) { + display: none; } - > .shape2 { - position: fixed; - top: 0; - left: 0; - width: 100vw; - height: 100vh; - background: var(--MI_THEME-accent); - clip-path: polygon(0% 0%, 25% 0%, 35% 100%, 0% 100%); - opacity: 0.5; - } - - > .logo-wrapper { - position: fixed; - top: 36px; - left: 36px; - flex: auto; - color: #fff; - user-select: none; - pointer-events: none; +} - > .powered-by { - margin-bottom: 2px; - } +.shape1 { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background: var(--MI_THEME-accent); + clip-path: polygon(0% 0%, 45% 0%, 20% 100%, 0% 100%); +} +.shape2 { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background: var(--MI_THEME-accent); + clip-path: polygon(0% 0%, 25% 0%, 35% 100%, 0% 100%); + opacity: 0.5; +} - > .misskey { - width: 140px; - @media (max-width: 450px) { - width: 130px; - } - } - } +.logoWrapper { + position: fixed; + top: 36px; + left: 36px; + flex: auto; + color: #fff; + user-select: none; + pointer-events: none; +} - > .emojis { - position: fixed; - bottom: 32px; - left: 35px; +.poweredBy { + margin-bottom: 2px; +} - > * { - margin-right: 8px; - } +.misskey { + width: 120px; - @media (max-width: 1200px) { - display: none; - } + @media (max-width: 450px) { + width: 100px; } +} - > .contents { - position: relative; - width: min(430px, calc(100% - 32px)); - margin-left: 128px; - padding: 100px 0 100px 0; +.contents { + position: relative; + width: min(430px, calc(100% - 32px)); + margin-left: 128px; + padding: 100px 0 100px 0; - @media (max-width: 1200px) { - margin: auto; - } + @media (max-width: 1200px) { + margin: auto; } +} - > .federation { - position: fixed; - bottom: 16px; - left: 0; - right: 0; - margin: auto; - background: var(--MI_THEME-acrylicPanel); - -webkit-backdrop-filter: var(--MI-blur, blur(15px)); - backdrop-filter: var(--MI-blur, blur(15px)); - border-radius: 999px; - overflow: clip; - width: 800px; - padding: 8px 0; +.federation { + position: fixed; + bottom: 16px; + left: 0; + right: 0; + margin: auto; + background: color(from var(--MI_THEME-panel) srgb r g b / 0.5); + -webkit-backdrop-filter: var(--MI-blur, blur(15px)); + backdrop-filter: var(--MI-blur, blur(15px)); + border-radius: 999px; + overflow: clip; + width: 800px; + padding: 8px 0; - @media (max-width: 900px) { - display: none; - } + @media (max-width: 900px) { + display: none; } } -</style> -<style lang="scss" module> .federationInstance { display: inline-flex; align-items: center; @@ -190,13 +172,13 @@ misskeyApiGet('federation/instances', { margin: 0 10px 0 0; background: var(--MI_THEME-panel); border-radius: 999px; +} - > :global(.icon) { - display: inline-block; - width: 20px; - height: 20px; - margin-right: 5px; - border-radius: 999px; - } +.federationInstanceIcon { + display: inline-block; + width: 20px; + height: 20px; + margin-right: 5px; + border-radius: 999px; } </style> diff --git a/packages/frontend/src/pages/welcome.setup.vue b/packages/frontend/src/pages/welcome.setup.vue index 33cc139a45..69a654595a 100644 --- a/packages/frontend/src/pages/welcome.setup.vue +++ b/packages/frontend/src/pages/welcome.setup.vue @@ -4,8 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div> - <MkAnimBg style="position: fixed; top: 0;"/> +<PageWithAnimBg> <div :class="$style.formContainer"> <form :class="$style.form" class="_panel" @submit.prevent="submit()"> <div :class="$style.title"> @@ -35,7 +34,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </form> </div> -</div> +</PageWithAnimBg> </template> <script lang="ts" setup> @@ -44,10 +43,9 @@ import { host, version } from '@@/js/config.js'; import MkButton from '@/components/MkButton.vue'; import MkInput from '@/components/MkInput.vue'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { login } from '@/account.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; -import MkAnimBg from '@/components/MkAnimBg.vue'; +import { login } from '@/accounts.js'; const username = ref(''); const password = ref(''); diff --git a/packages/frontend/src/pages/welcome.timeline.note.vue b/packages/frontend/src/pages/welcome.timeline.note.vue index 8fb84fd58f..680fe08c14 100644 --- a/packages/frontend/src/pages/welcome.timeline.note.vue +++ b/packages/frontend/src/pages/welcome.timeline.note.vue @@ -34,7 +34,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { ref, shallowRef, onUpdated, onMounted } from 'vue'; +import { ref, useTemplateRef, onUpdated, onMounted } from 'vue'; import * as Misskey from 'misskey-js'; import MkReactionsViewer from '@/components/MkReactionsViewer.vue'; import MkMediaList from '@/components/MkMediaList.vue'; @@ -45,7 +45,7 @@ defineProps<{ note: Misskey.entities.Note; }>(); -const noteTextEl = shallowRef<HTMLDivElement>(); +const noteTextEl = useTemplateRef('noteTextEl'); const shouldCollapse = ref(false); const showContent = ref(false); diff --git a/packages/frontend/src/pages/welcome.timeline.vue b/packages/frontend/src/pages/welcome.timeline.vue index 9be3a80a9e..6108a8a698 100644 --- a/packages/frontend/src/pages/welcome.timeline.vue +++ b/packages/frontend/src/pages/welcome.timeline.vue @@ -21,15 +21,15 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import * as Misskey from 'misskey-js'; -import { onUpdated, ref, shallowRef } from 'vue'; -import XNote from '@/pages/welcome.timeline.note.vue'; -import { misskeyApiGet } from '@/scripts/misskey-api.js'; +import { onUpdated, ref, useTemplateRef } from 'vue'; import { getScrollContainer } from '@@/js/scroll.js'; +import XNote from '@/pages/welcome.timeline.note.vue'; +import { misskeyApiGet } from '@/utility/misskey-api.js'; const notes = ref<Misskey.entities.Note[]>([]); const isScrolling = ref(false); const scrollState = ref<null | 'intro' | 'loop'>(null); -const notesMainContainerEl = shallowRef<HTMLElement>(); +const notesMainContainerEl = useTemplateRef('notesMainContainerEl'); misskeyApiGet('notes/featured').then(_notes => { notes.value = _notes; diff --git a/packages/frontend/src/pages/welcome.vue b/packages/frontend/src/pages/welcome.vue index 38d257506c..d3e571c053 100644 --- a/packages/frontend/src/pages/welcome.vue +++ b/packages/frontend/src/pages/welcome.vue @@ -16,7 +16,7 @@ import * as Misskey from 'misskey-js'; import XSetup from './welcome.setup.vue'; import XEntrance from './welcome.entrance.a.vue'; import { instanceName } from '@@/js/config.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { definePage } from '@/page.js'; import { fetchInstance } from '@/instance.js'; const instance = ref<Misskey.entities.MetaDetailed | null>(null); @@ -29,7 +29,7 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); -definePageMetadata(() => ({ +definePage(() => ({ title: instanceName, icon: null, })); diff --git a/packages/frontend/src/plugin.ts b/packages/frontend/src/plugin.ts index e319a8c398..1b51850e77 100644 --- a/packages/frontend/src/plugin.ts +++ b/packages/frontend/src/plugin.ts @@ -3,180 +3,430 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { ref } from 'vue'; +import { ref, defineAsyncComponent } from 'vue'; import { Interpreter, Parser, utils, values } from '@syuilo/aiscript'; -import { aiScriptReadline, createAiScriptEnv } from '@/scripts/aiscript/api.js'; -import { inputText } from '@/os.js'; -import { noteActions, notePostInterruptors, noteViewInterruptors, postFormActions, userActions, pageViewInterruptors } from '@/store.js'; -import type { Plugin } from '@/store.js'; +import { compareVersions } from 'compare-versions'; +import { v4 as uuid } from 'uuid'; +import * as Misskey from 'misskey-js'; +import { aiScriptReadline, createAiScriptEnv } from '@/aiscript/api.js'; +import { store } from '@/store.js'; +import * as os from '@/os.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { i18n } from '@/i18n.js'; +import { prefer } from '@/preferences.js'; + +export type Plugin = { + installId: string; + name: string; + active: boolean; + config?: Record<string, { default: any }>; + configData: Record<string, any>; + src: string | null; + version: string; + author?: string; + description?: string; + permissions?: string[]; +}; + +export type AiScriptPluginMeta = { + name: string; + version: string; + author: string; + description?: string; + permissions?: string[]; + config?: Record<string, any>; +}; const parser = new Parser(); -const pluginContexts = new Map<string, Interpreter>(); -export const pluginLogs = ref(new Map<string, string[]>()); -export async function install(plugin: Plugin): Promise<void> { +export function isSupportedAiScriptVersion(version: string): boolean { + try { + return (compareVersions(version, '0.12.0') >= 0); + } catch (err) { + return false; + } +} + +export async function parsePluginMeta(code: string): Promise<AiScriptPluginMeta> { + if (!code) { + throw new Error('code is required'); + } + + const lv = utils.getLangVersion(code); + if (lv == null) { + throw new Error('No language version annotation found'); + } else if (!isSupportedAiScriptVersion(lv)) { + throw new Error(`Aiscript version '${lv}' is not supported`); + } + + let ast; + try { + ast = parser.parse(code); + } catch (err) { + throw new Error('Aiscript syntax error'); + } + + const meta = Interpreter.collectMetadata(ast); + if (meta == null) { + throw new Error('Meta block not found'); + } + + const metadata = meta.get(null); + if (metadata == null) { + throw new Error('Metadata not found'); + } + + const { name, version, author, description, permissions, config } = metadata; + if (name == null || version == null || author == null) { + throw new Error('Required property not found'); + } + + return { + name, + version, + author, + description, + permissions, + config, + }; +} + +export async function authorizePlugin(plugin: Plugin) { + if (plugin.permissions == null || plugin.permissions.length === 0) return; + if (Object.hasOwn(store.s.pluginTokens, plugin.installId)) return; + + const token = await new Promise<string>((res, rej) => { + const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkTokenGenerateWindow.vue')), { + title: i18n.ts.tokenRequested, + information: i18n.ts.pluginTokenRequestedDescription, + initialName: plugin.name, + initialPermissions: plugin.permissions, + }, { + done: async result => { + const { name, permissions } = result; + const { token } = await misskeyApi('miauth/gen-token', { + session: null, + name: name, + permission: permissions, + }); + res(token); + }, + closed: () => dispose(), + }); + }); + + store.set('pluginTokens', { + ...store.s.pluginTokens, + [plugin.installId]: token, + }); +} + +export async function installPlugin(code: string, meta?: AiScriptPluginMeta) { + if (!code) return; + + let realMeta: AiScriptPluginMeta; + if (!meta) { + realMeta = await parsePluginMeta(code); + } else { + realMeta = meta; + } + + if (prefer.s.plugins.some(x => x.name === realMeta.name)) { + throw new Error('Plugin already installed'); + } + + const installId = uuid(); + + const plugin = { + ...realMeta, + installId, + active: true, + configData: {}, + src: code, + }; + + prefer.commit('plugins', prefer.s.plugins.concat(plugin)); + + await authorizePlugin(plugin); + + await launchPlugin(installId); +} + +export async function uninstallPlugin(plugin: Plugin) { + abortPlugin(plugin); + prefer.commit('plugins', prefer.s.plugins.filter(x => x.installId !== plugin.installId)); + if (Object.hasOwn(store.s.pluginTokens, plugin.installId)) { + await os.apiWithDialog('i/revoke-token', { + token: store.s.pluginTokens[plugin.installId], + }); + const pluginTokens = { ...store.s.pluginTokens }; + delete pluginTokens[plugin.installId]; + store.set('pluginTokens', pluginTokens); + } +} + +const pluginContexts = new Map<Plugin['installId'], Interpreter>(); + +export const pluginLogs = ref(new Map<Plugin['installId'], { + at: number; + message: string; + isSystem?: boolean; + isError?: boolean; +}[]>()); + +type HandlerDef = { + post_form_action: { + title: string, + handler: <T>(form: T, update: (key: unknown, value: unknown) => void) => void; + }; + user_action: { + title: string, + handler: (user: Misskey.entities.UserDetailed) => void; + }; + note_action: { + title: string, + handler: (note: Misskey.entities.Note) => void; + }; + note_view_interruptor: { + handler: (note: Misskey.entities.Note) => unknown; + }; + note_post_interruptor: { + handler: (note: FIXME) => unknown; + }; + page_view_interruptor: { + handler: (page: Misskey.entities.Page) => unknown; + }; +}; + +type PluginHandler<K extends keyof HandlerDef> = { + pluginInstallId: string; + type: K; + ctx: HandlerDef[K]; +}; + +let pluginHandlers: PluginHandler<keyof HandlerDef>[] = []; + +function addPluginHandler<K extends keyof HandlerDef>(installId: Plugin['installId'], type: K, ctx: PluginHandler<K>['ctx']) { + pluginLogs.value.get(installId)!.push({ + at: Date.now(), + isSystem: true, + message: `Handler registered: ${type}`, + }); + pluginHandlers.push({ pluginInstallId: installId, type, ctx }); +} + +export function launchPlugins() { + for (const plugin of prefer.s.plugins) { + if (plugin.active) { + launchPlugin(plugin.installId); + } + } +} + +async function launchPlugin(id: Plugin['installId']): Promise<void> { + const plugin = prefer.s.plugins.find(x => x.installId === id); + if (!plugin) return; + // 後方互換性のため if (plugin.src == null) return; + pluginLogs.value.set(plugin.installId, []); + + function systemLog(message: string, isError = false): void { + pluginLogs.value.get(plugin.installId)?.push({ + at: Date.now(), + isSystem: true, + message, + isError, + }); + } + + systemLog('Starting plugin...'); + + await authorizePlugin(plugin); + const aiscript = new Interpreter(createPluginEnv({ plugin: plugin, - storageKey: 'plugins:' + plugin.id, + storageKey: 'plugins:' + plugin.installId, }), { in: aiScriptReadline, out: (value): void => { - console.log(value); - pluginLogs.value.get(plugin.id).push(utils.reprValue(value)); + pluginLogs.value.get(plugin.installId)!.push({ + at: Date.now(), + message: utils.reprValue(value), + }); }, log: (): void => { }, err: (err): void => { - pluginLogs.value.get(plugin.id).push(`${err}`); + pluginLogs.value.get(plugin.installId)!.push({ + at: Date.now(), + message: `${err}`, + isError: true, + }); throw err; // install時のtry-catchに反応させる }, }); - initPlugin({ plugin, aiscript }); + pluginContexts.set(plugin.installId, aiscript); aiscript.exec(parser.parse(plugin.src)).then( () => { console.info('Plugin installed:', plugin.name, 'v' + plugin.version); + systemLog('Plugin started'); }, (err) => { console.error('Plugin install failed:', plugin.name, 'v' + plugin.version); + systemLog(`${err}`, true); throw err; }, ); } +export function abortPlugin(plugin: Plugin): void { + const pluginContext = pluginContexts.get(plugin.installId); + if (!pluginContext) return; + + pluginContext.abort(); + pluginContexts.delete(plugin.installId); + pluginLogs.value.delete(plugin.installId); + pluginHandlers = pluginHandlers.filter(x => x.pluginInstallId !== plugin.installId); +} + +export function reloadPlugin(plugin: Plugin): void { + abortPlugin(plugin); + launchPlugin(plugin.installId); +} + +export async function configPlugin(plugin: Plugin) { + if (plugin.config == null) { + throw new Error('This plugin does not have a config'); + } + + const config = plugin.config; + for (const key in plugin.configData) { + config[key].default = plugin.configData[key]; + } + + const { canceled, result } = await os.form(plugin.name, config); + if (canceled) return; + + prefer.commit('plugins', prefer.s.plugins.map(x => x.installId === plugin.installId ? { ...x, configData: result } : x)); + + reloadPlugin(plugin); +} + +export function changePluginActive(plugin: Plugin, active: boolean) { + prefer.commit('plugins', prefer.s.plugins.map(x => x.installId === plugin.installId ? { ...x, active } : x)); + + if (active) { + launchPlugin(plugin.installId); + } else { + abortPlugin(plugin); + } +} + function createPluginEnv(opts: { plugin: Plugin; storageKey: string }): Record<string, values.Value> { + const id = opts.plugin.installId; + const config = new Map<string, values.Value>(); for (const [k, v] of Object.entries(opts.plugin.config ?? {})) { config.set(k, utils.jsToVal(typeof opts.plugin.configData[k] !== 'undefined' ? opts.plugin.configData[k] : v.default)); } - return { - ...createAiScriptEnv({ ...opts, token: opts.plugin.token }), - //#region Deprecated - 'Mk:register_post_form_action': values.FN_NATIVE(([title, handler]) => { - utils.assertString(title); - registerPostFormAction({ pluginId: opts.plugin.id, title: title.value, handler }); - }), - 'Mk:register_user_action': values.FN_NATIVE(([title, handler]) => { - utils.assertString(title); - registerUserAction({ pluginId: opts.plugin.id, title: title.value, handler }); - }), - 'Mk:register_note_action': values.FN_NATIVE(([title, handler]) => { - utils.assertString(title); - registerNoteAction({ pluginId: opts.plugin.id, title: title.value, handler }); - }), - //#endregion - 'Plugin:register_post_form_action': values.FN_NATIVE(([title, handler]) => { + function withContext<T>(fn: (ctx: Interpreter) => T): T { + const ctx = pluginContexts.get(id); + if (!ctx) throw new Error('Plugin context not found'); + return fn(ctx); + } + + const env: Record<string, values.Value> = { + ...createAiScriptEnv({ ...opts, token: store.s.pluginTokens[id] }), + + 'Plugin:register:post_form_action': values.FN_NATIVE(([title, handler]) => { utils.assertString(title); - registerPostFormAction({ pluginId: opts.plugin.id, title: title.value, handler }); + utils.assertFunction(handler); + addPluginHandler(id, 'post_form_action', { + title: title.value, + handler: withContext(ctx => (form, update) => { + ctx.execFn(handler, [utils.jsToVal(form), values.FN_NATIVE(([key, value]) => { + if (!key || !value) { + return; + } + update(utils.valToJs(key), utils.valToJs(value)); + })]); + }), + }); }), - 'Plugin:register_user_action': values.FN_NATIVE(([title, handler]) => { + + 'Plugin:register:user_action': values.FN_NATIVE(([title, handler]) => { utils.assertString(title); - registerUserAction({ pluginId: opts.plugin.id, title: title.value, handler }); + utils.assertFunction(handler); + addPluginHandler(id, 'user_action', { + title: title.value, + handler: withContext(ctx => (user) => { + ctx.execFn(handler, [utils.jsToVal(user)]); + }), + }); }), - 'Plugin:register_note_action': values.FN_NATIVE(([title, handler]) => { + + 'Plugin:register:note_action': values.FN_NATIVE(([title, handler]) => { utils.assertString(title); - registerNoteAction({ pluginId: opts.plugin.id, title: title.value, handler }); + utils.assertFunction(handler); + addPluginHandler(id, 'note_action', { + title: title.value, + handler: withContext(ctx => (note) => { + ctx.execFn(handler, [utils.jsToVal(note)]); + }), + }); }), - 'Plugin:register_note_view_interruptor': values.FN_NATIVE(([handler]) => { - registerNoteViewInterruptor({ pluginId: opts.plugin.id, handler }); + + 'Plugin:register:note_view_interruptor': values.FN_NATIVE(([handler]) => { + utils.assertFunction(handler); + addPluginHandler(id, 'note_view_interruptor', { + handler: withContext(ctx => async (note) => { + return utils.valToJs(await ctx.execFn(handler, [utils.jsToVal(note)])); + }), + }); }), - 'Plugin:register_note_post_interruptor': values.FN_NATIVE(([handler]) => { - registerNotePostInterruptor({ pluginId: opts.plugin.id, handler }); + + 'Plugin:register:note_post_interruptor': values.FN_NATIVE(([handler]) => { + utils.assertFunction(handler); + addPluginHandler(id, 'note_post_interruptor', { + handler: withContext(ctx => async (note) => { + return utils.valToJs(await ctx.execFn(handler, [utils.jsToVal(note)])); + }), + }); }), - 'Plugin:register_page_view_interruptor': values.FN_NATIVE(([handler]) => { - registerPageViewInterruptor({ pluginId: opts.plugin.id, handler }); + + 'Plugin:register:page_view_interruptor': values.FN_NATIVE(([handler]) => { + utils.assertFunction(handler); + addPluginHandler(id, 'page_view_interruptor', { + handler: withContext(ctx => async (page) => { + return utils.valToJs(await ctx.execFn(handler, [utils.jsToVal(page)])); + }), + }); }), + 'Plugin:open_url': values.FN_NATIVE(([url]) => { utils.assertString(url); window.open(url.value, '_blank', 'noopener'); }), + 'Plugin:config': values.OBJ(config), }; -} - -function initPlugin({ plugin, aiscript }): void { - pluginContexts.set(plugin.id, aiscript); - pluginLogs.value.set(plugin.id, []); -} - -function registerPostFormAction({ pluginId, title, handler }): void { - postFormActions.push({ - title, handler: (form, update) => { - const pluginContext = pluginContexts.get(pluginId); - if (!pluginContext) { - return; - } - pluginContext.execFn(handler, [utils.jsToVal(form), values.FN_NATIVE(([key, value]) => { - if (!key || !value) { - return; - } - update(utils.valToJs(key), utils.valToJs(value)); - })]); - }, - }); -} - -function registerUserAction({ pluginId, title, handler }): void { - userActions.push({ - title, handler: (user) => { - const pluginContext = pluginContexts.get(pluginId); - if (!pluginContext) { - return; - } - pluginContext.execFn(handler, [utils.jsToVal(user)]); - }, - }); -} - -function registerNoteAction({ pluginId, title, handler }): void { - noteActions.push({ - title, handler: (note) => { - const pluginContext = pluginContexts.get(pluginId); - if (!pluginContext) { - return; - } - pluginContext.execFn(handler, [utils.jsToVal(note)]); - }, - }); -} -function registerNoteViewInterruptor({ pluginId, handler }): void { - noteViewInterruptors.push({ - handler: async (note) => { - const pluginContext = pluginContexts.get(pluginId); - if (!pluginContext) { - return; - } - return utils.valToJs(await pluginContext.execFn(handler, [utils.jsToVal(note)])); - }, - }); -} + // 後方互換性のため + env['Plugin:register_post_form_action'] = env['Plugin:register:post_form_action']; + env['Plugin:register_user_action'] = env['Plugin:register:user_action']; + env['Plugin:register_note_action'] = env['Plugin:register:note_action']; + env['Plugin:register_note_view_interruptor'] = env['Plugin:register:note_view_interruptor']; + env['Plugin:register_note_post_interruptor'] = env['Plugin:register:note_post_interruptor']; + env['Plugin:register_page_view_interruptor'] = env['Plugin:register:page_view_interruptor']; -function registerNotePostInterruptor({ pluginId, handler }): void { - notePostInterruptors.push({ - handler: async (note) => { - const pluginContext = pluginContexts.get(pluginId); - if (!pluginContext) { - return; - } - return utils.valToJs(await pluginContext.execFn(handler, [utils.jsToVal(note)])); - }, - }); + return env; } -function registerPageViewInterruptor({ pluginId, handler }): void { - pageViewInterruptors.push({ - handler: async (page) => { - const pluginContext = pluginContexts.get(pluginId); - if (!pluginContext) { - return; - } - return utils.valToJs(await pluginContext.execFn(handler, [utils.jsToVal(page)])); - }, - }); +export function getPluginHandlers<K extends keyof HandlerDef>(type: K): HandlerDef[K][] { + return pluginHandlers.filter((x): x is PluginHandler<K> => x.type === type).map(x => x.ctx); } diff --git a/packages/frontend/src/pref-migrate.ts b/packages/frontend/src/pref-migrate.ts new file mode 100644 index 0000000000..414bb9c5aa --- /dev/null +++ b/packages/frontend/src/pref-migrate.ts @@ -0,0 +1,146 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { v4 as uuid } from 'uuid'; +import type { DeckProfile } from '@/deck.js'; +import { ColdDeviceStorage, store } from '@/store.js'; +import { prefer } from '@/preferences.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { deckStore } from '@/ui/deck/deck-store.js'; +import { unisonReload } from '@/utility/unison-reload.js'; +import * as os from '@/os.js'; +import { i18n } from '@/i18n.js'; + +// TODO: そのうち消す +export function migrateOldSettings() { + os.waiting(i18n.ts.settingsMigrating); + + store.loaded.then(async () => { + misskeyApi('i/registry/get', { scope: ['client'], key: 'themes' }).catch(() => []).then((themes: any) => { + if (themes.length > 0) { + prefer.commit('themes', themes); + } + }); + + const plugins = ColdDeviceStorage.get('plugins'); + prefer.commit('plugins', plugins.map(p => ({ + ...p, + installId: (p as any).id, + id: undefined, + }))); + + prefer.commit('deck.profile', deckStore.s.profile); + misskeyApi('i/registry/keys', { + scope: ['client', 'deck', 'profiles'], + }).then(async keys => { + const profiles: DeckProfile[] = []; + for (const key of keys) { + const deck = await misskeyApi('i/registry/get', { + scope: ['client', 'deck', 'profiles'], + key: key, + }); + profiles.push({ + id: uuid(), + name: key, + columns: deck.columns, + layout: deck.layout, + }); + } + prefer.commit('deck.profiles', profiles); + }); + + prefer.commit('lightTheme', ColdDeviceStorage.get('lightTheme')); + prefer.commit('darkTheme', ColdDeviceStorage.get('darkTheme')); + prefer.commit('syncDeviceDarkMode', ColdDeviceStorage.get('syncDeviceDarkMode')); + prefer.commit('emojiPalettes', [{ + id: 'reactions', + name: '', + emojis: store.s.reactions, + }, { + id: 'pinnedEmojis', + name: '', + emojis: store.s.pinnedEmojis, + }]); + prefer.commit('emojiPaletteForMain', 'pinnedEmojis'); + prefer.commit('emojiPaletteForReaction', 'reactions'); + prefer.commit('overridedDeviceKind', store.s.overridedDeviceKind); + prefer.commit('widgets', store.s.widgets); + prefer.commit('keepCw', store.s.keepCw); + prefer.commit('collapseRenotes', store.s.collapseRenotes); + prefer.commit('rememberNoteVisibility', store.s.rememberNoteVisibility); + prefer.commit('uploadFolder', store.s.uploadFolder); + prefer.commit('menu', [...store.s.menu, 'chat']); + prefer.commit('statusbars', store.s.statusbars); + prefer.commit('pinnedUserLists', store.s.pinnedUserLists); + prefer.commit('serverDisconnectedBehavior', store.s.serverDisconnectedBehavior); + prefer.commit('nsfw', store.s.nsfw); + prefer.commit('highlightSensitiveMedia', store.s.highlightSensitiveMedia); + prefer.commit('animation', store.s.animation); + prefer.commit('animatedMfm', store.s.animatedMfm); + prefer.commit('advancedMfm', store.s.advancedMfm); + prefer.commit('showReactionsCount', store.s.showReactionsCount); + prefer.commit('enableQuickAddMfmFunction', store.s.enableQuickAddMfmFunction); + prefer.commit('loadRawImages', store.s.loadRawImages); + prefer.commit('imageNewTab', store.s.imageNewTab); + prefer.commit('disableShowingAnimatedImages', store.s.disableShowingAnimatedImages); + prefer.commit('emojiStyle', store.s.emojiStyle); + prefer.commit('menuStyle', store.s.menuStyle); + prefer.commit('useBlurEffectForModal', store.s.useBlurEffectForModal); + prefer.commit('useBlurEffect', store.s.useBlurEffect); + prefer.commit('showFixedPostForm', store.s.showFixedPostForm); + prefer.commit('showFixedPostFormInChannel', store.s.showFixedPostFormInChannel); + prefer.commit('enableInfiniteScroll', store.s.enableInfiniteScroll); + prefer.commit('useReactionPickerForContextMenu', store.s.useReactionPickerForContextMenu); + prefer.commit('showGapBetweenNotesInTimeline', store.s.showGapBetweenNotesInTimeline); + prefer.commit('instanceTicker', store.s.instanceTicker); + prefer.commit('emojiPickerScale', store.s.emojiPickerScale); + prefer.commit('emojiPickerWidth', store.s.emojiPickerWidth); + prefer.commit('emojiPickerHeight', store.s.emojiPickerHeight); + prefer.commit('emojiPickerStyle', store.s.emojiPickerStyle); + prefer.commit('reportError', store.s.reportError); + prefer.commit('squareAvatars', store.s.squareAvatars); + prefer.commit('showAvatarDecorations', store.s.showAvatarDecorations); + prefer.commit('numberOfPageCache', store.s.numberOfPageCache); + prefer.commit('showNoteActionsOnlyHover', store.s.showNoteActionsOnlyHover); + prefer.commit('showClipButtonInNoteFooter', store.s.showClipButtonInNoteFooter); + prefer.commit('reactionsDisplaySize', store.s.reactionsDisplaySize); + prefer.commit('limitWidthOfReaction', store.s.limitWidthOfReaction); + prefer.commit('forceShowAds', store.s.forceShowAds); + prefer.commit('aiChanMode', store.s.aiChanMode); + prefer.commit('devMode', store.s.devMode); + prefer.commit('mediaListWithOneImageAppearance', store.s.mediaListWithOneImageAppearance); + prefer.commit('notificationPosition', store.s.notificationPosition); + prefer.commit('notificationStackAxis', store.s.notificationStackAxis); + prefer.commit('enableCondensedLine', store.s.enableCondensedLine); + prefer.commit('keepScreenOn', store.s.keepScreenOn); + prefer.commit('disableStreamingTimeline', store.s.disableStreamingTimeline); + prefer.commit('useGroupedNotifications', store.s.useGroupedNotifications); + prefer.commit('dataSaver', store.s.dataSaver); + prefer.commit('enableSeasonalScreenEffect', store.s.enableSeasonalScreenEffect); + prefer.commit('enableHorizontalSwipe', store.s.enableHorizontalSwipe); + prefer.commit('useNativeUiForVideoAudioPlayer', store.s.useNativeUIForVideoAudioPlayer); + prefer.commit('keepOriginalFilename', store.s.keepOriginalFilename); + prefer.commit('alwaysConfirmFollow', store.s.alwaysConfirmFollow); + prefer.commit('confirmWhenRevealingSensitiveMedia', store.s.confirmWhenRevealingSensitiveMedia); + prefer.commit('contextMenu', store.s.contextMenu); + prefer.commit('skipNoteRender', store.s.skipNoteRender); + prefer.commit('showSoftWordMutedWord', store.s.showSoftWordMutedWord); + prefer.commit('confirmOnReact', store.s.confirmOnReact); + prefer.commit('defaultFollowWithReplies', store.s.defaultWithReplies); + prefer.commit('sound.masterVolume', store.s.sound_masterVolume); + prefer.commit('sound.notUseSound', store.s.sound_notUseSound); + prefer.commit('sound.useSoundOnlyWhenActive', store.s.sound_useSoundOnlyWhenActive); + prefer.commit('sound.on.note', store.s.sound_note as any); + prefer.commit('sound.on.noteMy', store.s.sound_noteMy as any); + prefer.commit('sound.on.notification', store.s.sound_notification as any); + prefer.commit('sound.on.reaction', store.s.sound_reaction as any); + prefer.commit('defaultNoteVisibility', store.s.defaultNoteVisibility); + prefer.commit('defaultNoteLocalOnly', store.s.defaultNoteLocalOnly); + + window.setTimeout(() => { + unisonReload(); + }, 10000); + }); +} diff --git a/packages/frontend/src/preferences.ts b/packages/frontend/src/preferences.ts new file mode 100644 index 0000000000..73c89e23af --- /dev/null +++ b/packages/frontend/src/preferences.ts @@ -0,0 +1,150 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import type { PreferencesProfile, StorageProvider } from '@/preferences/manager.js'; +import { cloudBackup } from '@/preferences/utility.js'; +import { miLocalStorage } from '@/local-storage.js'; +import { isSameScope, PreferencesManager } from '@/preferences/manager.js'; +import { store } from '@/store.js'; +import { $i } from '@/i.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { TAB_ID } from '@/tab-id.js'; + +function createPrefManager(storageProvider: StorageProvider) { + let profile: PreferencesProfile; + + const savedProfileRaw = miLocalStorage.getItem('preferences'); + if (savedProfileRaw == null) { + profile = PreferencesManager.newProfile(); + miLocalStorage.setItem('preferences', JSON.stringify(profile)); + } else { + profile = PreferencesManager.normalizeProfile(JSON.parse(savedProfileRaw)); + } + + return new PreferencesManager(profile, storageProvider); +} + +const syncGroup = 'default'; + +const storageProvider: StorageProvider = { + save: (ctx) => { + miLocalStorage.setItem('preferences', JSON.stringify(ctx.profile)); + miLocalStorage.setItem('latestPreferencesUpdate', `${TAB_ID}/${Date.now()}`); + }, + + cloudGet: async (ctx) => { + // TODO: この取得方法だとアカウントが変わると保存場所も変わってしまうので改修する + // 例えば複数アカウントある場合でも設定値を保存するための「プライマリアカウント」を設定できるようにするとか + try { + const cloudData = await misskeyApi('i/registry/get', { + scope: ['client', 'preferences', 'sync'], + key: syncGroup + ':' + ctx.key, + }) as [any, any][]; + const target = cloudData.find(([scope]) => isSameScope(scope, ctx.scope)); + if (target == null) return null; + return { + value: target[1], + }; + } catch (err: any) { + if (err.code === 'NO_SUCH_KEY') { // TODO: いちいちエラーキャッチするのは面倒なのでキーが無くてもエラーにならない maybe-get のようなエンドポイントをバックエンドに実装する + return null; + } else { + throw err; + } + } + }, + + cloudSet: async (ctx) => { + let cloudData: [any, any][] = []; + try { + cloudData = await misskeyApi('i/registry/get', { + scope: ['client', 'preferences', 'sync'], + key: syncGroup + ':' + ctx.key, + }) as [any, any][]; + } catch (err: any) { + if (err.code === 'NO_SUCH_KEY') { // TODO: いちいちエラーキャッチするのは面倒なのでキーが無くてもエラーにならない maybe-get のようなエンドポイントをバックエンドに実装する + cloudData = []; + } else { + throw err; + } + } + + const i = cloudData.findIndex(([scope]) => isSameScope(scope, ctx.scope)); + + if (i === -1) { + cloudData.push([ctx.scope, ctx.value]); + } else { + cloudData[i] = [ctx.scope, ctx.value]; + } + + await misskeyApi('i/registry/set', { + scope: ['client', 'preferences', 'sync'], + key: syncGroup + ':' + ctx.key, + value: cloudData, + }); + }, + + cloudGets: async (ctx) => { + // TODO: 値の取得を1つのリクエストで済ませたい(バックエンド側でAPIの新設が必要) + const fetchings = ctx.needs.map(need => storageProvider.cloudGet(need).then(res => [need.key, res] as const)); + const cloudDatas = await Promise.all(fetchings); + + const res = {} as Partial<Record<string, any>>; + for (const cloudData of cloudDatas) { + if (cloudData[1] != null) { + res[cloudData[0]] = cloudData[1].value; + } + } + + return res; + }, +}; + +export const prefer = createPrefManager(storageProvider); + +let latestSyncedAt = Date.now(); + +function syncBetweenTabs() { + const latest = miLocalStorage.getItem('latestPreferencesUpdate'); + if (latest == null) return; + + const latestTab = latest.split('/')[0]; + const latestAt = parseInt(latest.split('/')[1]); + + if (latestTab === TAB_ID) return; + if (latestAt <= latestSyncedAt) return; + + prefer.rewriteProfile(PreferencesManager.normalizeProfile(JSON.parse(miLocalStorage.getItem('preferences')!))); + + latestSyncedAt = Date.now(); + + if (_DEV_) console.log('prefer:synced'); +} + +window.setInterval(syncBetweenTabs, 5000); + +window.document.addEventListener('visibilitychange', () => { + if (window.document.visibilityState === 'visible') { + syncBetweenTabs(); + } +}); + +let latestBackupAt = 0; + +window.setInterval(() => { + if ($i == null) return; + if (!store.s.enablePreferencesAutoCloudBackup) return; + if (window.document.visibilityState !== 'visible') return; // 同期されていない古い値がバックアップされるのを防ぐ + if (prefer.profile.modifiedAt <= latestBackupAt) return; + + cloudBackup().then(() => { + latestBackupAt = Date.now(); + }); +}, 1000 * 60 * 3); + +if (_DEV_) { + (window as any).prefer = prefer; + (window as any).cloudBackup = cloudBackup; +} diff --git a/packages/frontend/src/preferences/def.ts b/packages/frontend/src/preferences/def.ts new file mode 100644 index 0000000000..bd96431fad --- /dev/null +++ b/packages/frontend/src/preferences/def.ts @@ -0,0 +1,406 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import * as Misskey from 'misskey-js'; +import { hemisphere } from '@@/js/intl-const.js'; +import type { Theme } from '@/theme.js'; +import type { SoundType } from '@/utility/sound.js'; +import type { Plugin } from '@/plugin.js'; +import type { DeviceKind } from '@/utility/device-kind.js'; +import type { DeckProfile } from '@/deck.js'; +import type { PreferencesDefinition } from './manager.js'; +import { DEFAULT_DEVICE_KIND } from '@/utility/device-kind.js'; + +/** サウンド設定 */ +export type SoundStore = { + type: Exclude<SoundType, '_driveFile_'>; + volume: number; +} | { + type: '_driveFile_'; + + /** ドライブのファイルID */ + fileId: string; + + /** ファイルURL(こちらが優先される) */ + fileUrl: string; + + volume: number; +}; + +// NOTE: デフォルト値は他の設定の状態に依存してはならない(依存していた場合、ユーザーがその設定項目単体で「初期値にリセット」した場合不具合の原因になる) + +export const PREF_DEF = { + // TODO: 持つのはホストやユーザーID、ユーザー名など最低限にしといて、その他のプロフィール情報はpreferences外で管理した方が綺麗そう + // 現状だと、updateCurrentAccount/updateCurrentAccountPartialが呼ばれるたびに「設定」へのcommitが行われて不自然(明らかに設定の更新とは捉えにくい)だし + accounts: { + default: [] as [host: string, user: Misskey.entities.User][], + }, + + pinnedUserLists: { + accountDependent: true, + default: [] as Misskey.entities.UserList[], + }, + uploadFolder: { + accountDependent: true, + default: null as string | null, + }, + widgets: { + accountDependent: true, + default: [{ + name: 'calendar', + id: 'a', place: 'right', data: {}, + }, { + name: 'notifications', + id: 'b', place: 'right', data: {}, + }, { + name: 'trends', + id: 'c', place: 'right', data: {}, + }] as { + name: string; + id: string; + place: string | null; + data: Record<string, any>; + }[], + }, + 'deck.profile': { + accountDependent: true, + default: null as string | null, + }, + 'deck.profiles': { + accountDependent: true, + default: [] as DeckProfile[], + }, + + emojiPalettes: { + serverDependent: true, + default: [{ + id: 'a', + name: '', + emojis: ['👍', '❤️', '😆', '🤔', '😮', '🎉', '💢', '😥', '😇', '🍮'], + }] as { + id: string; + name: string; + emojis: string[]; + }[], + }, + emojiPaletteForReaction: { + serverDependent: true, + default: null as string | null, + }, + emojiPaletteForMain: { + serverDependent: true, + default: null as string | null, + }, + + overridedDeviceKind: { + default: null as DeviceKind | null, + }, + themes: { + default: [] as Theme[], + }, + lightTheme: { + default: null as Theme | null, + }, + darkTheme: { + default: null as Theme | null, + }, + syncDeviceDarkMode: { + default: true, + }, + defaultNoteVisibility: { + default: 'public' as (typeof Misskey.noteVisibilities)[number], + }, + defaultNoteLocalOnly: { + default: false, + }, + keepCw: { + default: true, + }, + rememberNoteVisibility: { + default: false, + }, + reportError: { + default: false, + }, + collapseRenotes: { + default: true, + }, + menu: { + default: [ + 'notifications', + 'clips', + 'drive', + 'followRequests', + 'chat', + '-', + 'explore', + 'announcements', + 'channels', + 'search', + '-', + 'ui', + ], + }, + statusbars: { + default: [] as { + name: string; + id: string; + type: string; + size: 'verySmall' | 'small' | 'medium' | 'large' | 'veryLarge'; + black: boolean; + props: Record<string, any>; + }[], + }, + serverDisconnectedBehavior: { + default: 'quiet' as 'quiet' | 'reload' | 'dialog', + }, + nsfw: { + default: 'respect' as 'respect' | 'force' | 'ignore', + }, + highlightSensitiveMedia: { + default: false, + }, + animation: { + default: !window.matchMedia('(prefers-reduced-motion)').matches, + }, + animatedMfm: { + default: !window.matchMedia('(prefers-reduced-motion)').matches, + }, + advancedMfm: { + default: true, + }, + showReactionsCount: { + default: false, + }, + enableQuickAddMfmFunction: { + default: false, + }, + loadRawImages: { + default: false, + }, + imageNewTab: { + default: false, + }, + disableShowingAnimatedImages: { + default: window.matchMedia('(prefers-reduced-motion)').matches, + }, + emojiStyle: { + default: 'twemoji', // twemoji / fluentEmoji / native + }, + menuStyle: { + default: 'auto' as 'auto' | 'popup' | 'drawer', + }, + useBlurEffectForModal: { + default: DEFAULT_DEVICE_KIND === 'desktop', + }, + useBlurEffect: { + default: DEFAULT_DEVICE_KIND === 'desktop', + }, + useStickyIcons: { + default: true, + }, + showFixedPostForm: { + default: false, + }, + showFixedPostFormInChannel: { + default: false, + }, + enableInfiniteScroll: { + default: true, + }, + useReactionPickerForContextMenu: { + default: false, + }, + showGapBetweenNotesInTimeline: { + default: false, + }, + instanceTicker: { + default: 'remote' as 'none' | 'remote' | 'always', + }, + emojiPickerScale: { + default: 2, + }, + emojiPickerWidth: { + default: 2, + }, + emojiPickerHeight: { + default: 3, + }, + emojiPickerStyle: { + default: 'auto' as 'auto' | 'popup' | 'drawer', + }, + squareAvatars: { + default: false, + }, + showAvatarDecorations: { + default: true, + }, + numberOfPageCache: { + default: 3, + }, + showNoteActionsOnlyHover: { + default: false, + }, + showClipButtonInNoteFooter: { + default: false, + }, + reactionsDisplaySize: { + default: 'medium' as 'small' | 'medium' | 'large', + }, + limitWidthOfReaction: { + default: true, + }, + forceShowAds: { + default: false, + }, + aiChanMode: { + default: false, + }, + devMode: { + default: false, + }, + mediaListWithOneImageAppearance: { + default: 'expand' as 'expand' | '16_9' | '1_1' | '2_3', + }, + notificationPosition: { + default: 'rightBottom' as 'leftTop' | 'leftBottom' | 'rightTop' | 'rightBottom', + }, + notificationStackAxis: { + default: 'horizontal' as 'vertical' | 'horizontal', + }, + enableCondensedLine: { + default: true, + }, + keepScreenOn: { + default: false, + }, + disableStreamingTimeline: { + default: false, + }, + useGroupedNotifications: { + default: true, + }, + dataSaver: { + default: { + media: false, + avatar: false, + urlPreview: false, + code: false, + } as Record<string, boolean>, + }, + hemisphere: { + default: hemisphere as 'N' | 'S', + }, + enableSeasonalScreenEffect: { + default: false, + }, + enableHorizontalSwipe: { + default: true, + }, + useNativeUiForVideoAudioPlayer: { + default: false, + }, + keepOriginalFilename: { + default: true, + }, + alwaysConfirmFollow: { + default: true, + }, + confirmWhenRevealingSensitiveMedia: { + default: false, + }, + contextMenu: { + default: 'app' as 'app' | 'appWithShift' | 'native', + }, + skipNoteRender: { + default: true, + }, + showSoftWordMutedWord: { + default: false, + }, + confirmOnReact: { + default: false, + }, + defaultFollowWithReplies: { + default: false, + }, + makeEveryTextElementsSelectable: { + default: DEFAULT_DEVICE_KIND === 'desktop', + }, + showNavbarSubButtons: { + default: true, + }, + plugins: { + default: [] as Plugin[], + }, + + 'sound.masterVolume': { + default: 0.3, + }, + 'sound.notUseSound': { + default: false, + }, + 'sound.useSoundOnlyWhenActive': { + default: false, + }, + 'sound.on.note': { + default: { type: 'syuilo/n-aec', volume: 1 } as SoundStore, + }, + 'sound.on.noteMy': { + default: { type: 'syuilo/n-cea-4va', volume: 1 } as SoundStore, + }, + 'sound.on.notification': { + default: { type: 'syuilo/n-ea', volume: 1 } as SoundStore, + }, + 'sound.on.reaction': { + default: { type: 'syuilo/bubble2', volume: 1 } as SoundStore, + }, + 'sound.on.chatMessage': { + default: { type: 'syuilo/waon', volume: 1 } as SoundStore, + }, + + 'deck.alwaysShowMainColumn': { + default: true, + }, + 'deck.navWindow': { + default: true, + }, + 'deck.useSimpleUiForNonRootPages': { + default: true, + }, + 'deck.columnAlign': { + default: 'center' as 'left' | 'right' | 'center', + }, + 'deck.columnGap': { + default: 6, + }, + 'deck.menuPosition': { + default: 'bottom' as 'right' | 'bottom', + }, + 'deck.navbarPosition': { + default: 'left' as 'left' | 'top' | 'bottom', + }, + 'deck.wallpaper': { + default: null as string | null, + }, + + 'chat.showSenderName': { + default: false, + }, + 'chat.sendOnEnter': { + default: false, + }, + + 'game.dropAndFusion': { + default: { + bgmVolume: 0.25, + sfxVolume: 1, + }, + }, + + 'experimental.stackingRouterView': { + default: false, + }, +} satisfies PreferencesDefinition; diff --git a/packages/frontend/src/preferences/manager.ts b/packages/frontend/src/preferences/manager.ts new file mode 100644 index 0000000000..f96aa2f368 --- /dev/null +++ b/packages/frontend/src/preferences/manager.ts @@ -0,0 +1,476 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { computed, onUnmounted, ref, watch } from 'vue'; +import { v4 as uuid } from 'uuid'; +import { host, version } from '@@/js/config.js'; +import { PREF_DEF } from './def.js'; +import type { Ref, WritableComputedRef } from 'vue'; +import type { MenuItem } from '@/types/menu.js'; +import { $i } from '@/i.js'; +import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; +import { i18n } from '@/i18n.js'; +import * as os from '@/os.js'; +import { deepEqual } from '@/utility/deep-equal.js'; + +// NOTE: 明示的な設定値のひとつとして null もあり得るため、設定が存在しないかどうかを判定する目的で null で比較したり ?? を使ってはいけない + +//type DottedToNested<T extends Record<string, any>> = { +// [K in keyof T as K extends string ? K extends `${infer A}.${infer B}` ? A : K : K]: K extends `${infer A}.${infer B}` ? DottedToNested<{ [key in B]: T[K] }> : T[K]; +//}; + +type PREF = typeof PREF_DEF; +type ValueOf<K extends keyof PREF> = PREF[K]['default']; + +type Scope = Partial<{ + server: string | null; // host + account: string | null; // userId + device: string | null; // 将来のため +}>; + +type ValueMeta = Partial<{ + sync: boolean; +}>; + +type PrefRecord<K extends keyof PREF> = [scope: Scope, value: ValueOf<K>, meta: ValueMeta]; + +function parseScope(scope: Scope): { + server: string | null; + account: string | null; + device: string | null; +} { + return { + server: scope.server ?? null, + account: scope.account ?? null, + device: scope.device ?? null, + }; +} + +function makeScope(scope: Partial<{ + server: string | null; + account: string | null; + device: string | null; +}>): Scope { + const c = {} as Scope; + if (scope.server != null) c.server = scope.server; + if (scope.account != null) c.account = scope.account; + if (scope.device != null) c.device = scope.device; + return c; +} + +export function isSameScope(a: Scope, b: Scope): boolean { + // null と undefined (キー無し) は区別したくないので == で比較 + // eslint-disable-next-line eqeqeq + return a.server == b.server && a.account == b.account && a.device == b.device; +} + +export type PreferencesProfile = { + id: string; + version: string; + type: 'main'; + modifiedAt: number; + name: string; + preferences: { + [K in keyof PREF]: PrefRecord<K>[]; + }; +}; + +export type StorageProvider = { + save: (ctx: { profile: PreferencesProfile; }) => void; + cloudGets: <K extends keyof PREF>(ctx: { needs: { key: K; scope: Scope; }[] }) => Promise<Partial<Record<K, ValueOf<K>>>>; + cloudGet: <K extends keyof PREF>(ctx: { key: K; scope: Scope; }) => Promise<{ value: ValueOf<K>; } | null>; + cloudSet: <K extends keyof PREF>(ctx: { key: K; scope: Scope; value: ValueOf<K>; }) => Promise<void>; +}; + +export type PreferencesDefinition = Record<string, { + default: any; + accountDependent?: boolean; + serverDependent?: boolean; +}>; + +export class PreferencesManager { + private storageProvider: StorageProvider; + public profile: PreferencesProfile; + public cloudReady: Promise<void>; + + /** + * static / state の略 (static が予約語のため) + */ + public s = {} as { + [K in keyof PREF]: ValueOf<K>; + }; + + /** + * reactive の略 + */ + public r = {} as { + [K in keyof PREF]: Ref<ValueOf<K>>; + }; + + constructor(profile: PreferencesProfile, storageProvider: StorageProvider) { + this.profile = profile; + this.storageProvider = storageProvider; + + const states = this.genStates(); + + for (const key in states) { + this.s[key] = states[key]; + this.r[key] = ref(this.s[key]); + } + + this.cloudReady = this.fetchCloudValues(); + + // TODO: 定期的にクラウドの値をフェッチ + } + + private isAccountDependentKey<K extends keyof PREF>(key: K): boolean { + return (PREF_DEF as PreferencesDefinition)[key].accountDependent === true; + } + + private isServerDependentKey<K extends keyof PREF>(key: K): boolean { + return (PREF_DEF as PreferencesDefinition)[key].serverDependent === true; + } + + private rewriteRawState<K extends keyof PREF>(key: K, value: ValueOf<K>) { + const v = JSON.parse(JSON.stringify(value)); // deep copy 兼 vueのプロキシ解除 + this.r[key].value = this.s[key] = v; + } + + public commit<K extends keyof PREF>(key: K, value: ValueOf<K>) { + const v = JSON.parse(JSON.stringify(value)); // deep copy 兼 vueのプロキシ解除 + + if (deepEqual(this.s[key], v)) { + if (_DEV_) console.log('(skip) prefer:commit', key, v); + return; + } + + if (_DEV_) console.log('prefer:commit', key, v); + + this.rewriteRawState(key, v); + + const record = this.getMatchedRecordOf(key); + + if (parseScope(record[0]).account == null && this.isAccountDependentKey(key)) { + this.profile.preferences[key].push([makeScope({ + server: host, + account: $i!.id, + }), v, {}]); + this.save(); + return; + } + + if (parseScope(record[0]).server == null && this.isServerDependentKey(key)) { + this.profile.preferences[key].push([makeScope({ + server: host, + }), v, {}]); + this.save(); + return; + } + + record[1] = v; + this.save(); + + if (record[2].sync) { + // awaitの必要なし + // TODO: リクエストを間引く + this.storageProvider.cloudSet({ key, scope: record[0], value: record[1] }); + } + } + + /** + * 特定のキーの、簡易的なcomputed refを作ります + * 主にvue上で設定コントロールのmodelとして使う用 + */ + public model<K extends keyof PREF, V extends ValueOf<K> = ValueOf<K>>( + key: K, + getter?: (v: ValueOf<K>) => V, + setter?: (v: V) => ValueOf<K>, + ): WritableComputedRef<V> { + const valueRef = ref(this.s[key]); + + const stop = watch(this.r[key], val => { + valueRef.value = val; + }); + + // NOTE: vueコンポーネント内で呼ばれない限りは、onUnmounted は無意味なのでメモリリークする + onUnmounted(() => { + stop(); + }); + + // TODO: VueのcustomRef使うと良い感じになるかも + return computed({ + get: () => { + if (getter) { + return getter(valueRef.value); + } else { + return valueRef.value; + } + }, + set: (value) => { + const val = setter ? setter(value) : value; + this.commit(key, val); + valueRef.value = val; + }, + }); + } + + private genStates() { + const states = {} as { [K in keyof PREF]: ValueOf<K> }; + for (const _key in PREF_DEF) { + const key = _key as keyof PREF; + const record = this.getMatchedRecordOf(key); + (states[key] as any) = record[1]; + } + + return states; + } + + private async fetchCloudValues() { + const needs = [] as { key: keyof PREF; scope: Scope; }[]; + for (const _key in PREF_DEF) { + const key = _key as keyof PREF; + const record = this.getMatchedRecordOf(key); + if (record[2].sync) { + needs.push({ + key, + scope: record[0], + }); + } + } + + const cloudValues = await this.storageProvider.cloudGets({ needs }); + + for (const _key in PREF_DEF) { + const key = _key as keyof PREF; + const record = this.getMatchedRecordOf(key); + if (record[2].sync && Object.hasOwn(cloudValues, key) && cloudValues[key] !== undefined) { + const cloudValue = cloudValues[key]; + if (!deepEqual(cloudValue, record[1])) { + this.rewriteRawState(key, cloudValue); + record[1] = cloudValue; + if (_DEV_) console.log('cloud fetched', key, cloudValue); + } + } + } + + this.save(); + if (_DEV_) console.log('cloud fetch completed'); + } + + public static newProfile(): PreferencesProfile { + const data = {} as PreferencesProfile['preferences']; + for (const key in PREF_DEF) { + data[key] = [[makeScope({}), PREF_DEF[key].default, {}]]; + } + return { + id: uuid(), + version: version, + type: 'main', + modifiedAt: Date.now(), + name: '', + preferences: data, + }; + } + + public static normalizeProfile(profileLike: any): PreferencesProfile { + const data = {} as PreferencesProfile['preferences']; + for (const key in PREF_DEF) { + const records = profileLike.preferences[key]; + if (records == null || records.length === 0) { + data[key] = [[makeScope({}), PREF_DEF[key].default, {}]]; + continue; + } else { + data[key] = records; + + // alpha段階ではmetaが無かったのでマイグレート + // TODO: そのうち消す + for (const record of data[key] as any[][]) { + if (record.length === 2) { + record.push({}); + } + } + } + } + + return { + ...profileLike, + preferences: data, + }; + } + + public save() { + this.profile.modifiedAt = Date.now(); + this.profile.version = version; + this.storageProvider.save({ profile: this.profile }); + } + + public getMatchedRecordOf<K extends keyof PREF>(key: K): PrefRecord<K> { + const records = this.profile.preferences[key]; + + if ($i == null) return records.find(([scope, v]) => parseScope(scope).account == null)!; + + const accountOverrideRecord = records.find(([scope, v]) => parseScope(scope).server === host && parseScope(scope).account === $i!.id); + if (accountOverrideRecord) return accountOverrideRecord; + + const serverOverrideRecord = records.find(([scope, v]) => parseScope(scope).server === host && parseScope(scope).account == null); + if (serverOverrideRecord) return serverOverrideRecord; + + const record = records.find(([scope, v]) => parseScope(scope).account == null); + return record!; + } + + public isAccountOverrided<K extends keyof PREF>(key: K): boolean { + if ($i == null) return false; + return this.profile.preferences[key].some(([scope, v]) => parseScope(scope).server === host && parseScope(scope).account === $i!.id) ?? false; + } + + public setAccountOverride<K extends keyof PREF>(key: K) { + if ($i == null) return; + if (this.isAccountDependentKey(key)) throw new Error('already account-dependent'); + if (this.isAccountOverrided(key)) return; + + const records = this.profile.preferences[key]; + records.push([makeScope({ + server: host, + account: $i!.id, + }), this.s[key], {}]); + + this.save(); + } + + public clearAccountOverride<K extends keyof PREF>(key: K) { + if ($i == null) return; + if (this.isAccountDependentKey(key)) throw new Error('cannot clear override for this account-dependent property'); + + const records = this.profile.preferences[key]; + + const index = records.findIndex(([scope, v]) => parseScope(scope).server === host && parseScope(scope).account === $i!.id); + if (index === -1) return; + + records.splice(index, 1); + + this.rewriteRawState(key, this.getMatchedRecordOf(key)[1]); + + this.save(); + } + + public isSyncEnabled<K extends keyof PREF>(key: K): boolean { + return this.getMatchedRecordOf(key)[2].sync ?? false; + } + + public async enableSync<K extends keyof PREF>(key: K): Promise<{ enabled: boolean; } | null> { + if (this.isSyncEnabled(key)) return Promise.resolve(null); + + const record = this.getMatchedRecordOf(key); + + const existing = await this.storageProvider.cloudGet({ key, scope: record[0] }); + if (existing != null && !deepEqual(existing.value, record[1])) { + const { canceled, result } = await os.select({ + title: i18n.ts.preferenceSyncConflictTitle, + text: i18n.ts.preferenceSyncConflictText, + items: [{ + text: i18n.ts.preferenceSyncConflictChoiceServer, + value: 'remote', + }, { + text: i18n.ts.preferenceSyncConflictChoiceDevice, + value: 'local', + }, { + text: i18n.ts.preferenceSyncConflictChoiceCancel, + value: null, + }], + default: 'remote', + }); + if (canceled || result == null) return { enabled: false }; + + if (result === 'remote') { + this.commit(key, existing.value); + } else if (result === 'local') { + // nop + } + } + + record[2].sync = true; + this.save(); + + // awaitの必要性は無い + this.storageProvider.cloudSet({ key, scope: record[0], value: this.s[key] }); + + return { enabled: true }; + } + + public disableSync<K extends keyof PREF>(key: K) { + if (!this.isSyncEnabled(key)) return; + + const record = this.getMatchedRecordOf(key); + delete record[2].sync; + this.save(); + } + + public renameProfile(name: string) { + this.profile.name = name; + this.save(); + } + + public rewriteProfile(profile: PreferencesProfile) { + this.profile = profile; + const states = this.genStates(); + for (const _key in states) { + const key = _key as keyof PREF; + this.rewriteRawState(key, states[key]); + } + + this.fetchCloudValues(); + } + + public getPerPrefMenu<K extends keyof PREF>(key: K): MenuItem[] { + const overrideByAccount = ref(this.isAccountOverrided(key)); + watch(overrideByAccount, () => { + if (overrideByAccount.value) { + this.setAccountOverride(key); + } else { + this.clearAccountOverride(key); + } + }); + + const sync = ref(this.isSyncEnabled(key)); + watch(sync, () => { + if (sync.value) { + this.enableSync(key).then((res) => { + if (res == null) return; + if (!res.enabled) sync.value = false; + }); + } else { + this.disableSync(key); + } + }); + + return [{ + icon: 'ti ti-copy', + text: i18n.ts.copyPreferenceId, + action: () => { + copyToClipboard(key); + }, + }, { + icon: 'ti ti-refresh', + text: i18n.ts.resetToDefaultValue, + danger: true, + action: () => { + this.commit(key, PREF_DEF[key].default); + }, + }, { + type: 'divider', + }, { + type: 'switch', + icon: 'ti ti-user-cog', + text: i18n.ts.overrideByAccount, + ref: overrideByAccount, + }, { + type: 'switch', + icon: 'ti ti-cloud-cog', + text: i18n.ts.syncBetweenDevices, + ref: sync, + }]; + } +} diff --git a/packages/frontend/src/preferences/utility.ts b/packages/frontend/src/preferences/utility.ts new file mode 100644 index 0000000000..adba908c3c --- /dev/null +++ b/packages/frontend/src/preferences/utility.ts @@ -0,0 +1,226 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { ref, watch } from 'vue'; +import type { PreferencesProfile } from './manager.js'; +import type { MenuItem } from '@/types/menu.js'; +import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; +import { i18n } from '@/i18n.js'; +import { miLocalStorage } from '@/local-storage.js'; +import { prefer } from '@/preferences.js'; +import * as os from '@/os.js'; +import { store } from '@/store.js'; +import { $i } from '@/i.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { unisonReload } from '@/utility/unison-reload.js'; + +function canAutoBackup() { + return prefer.profile.name != null && prefer.profile.name.trim() !== ''; +} + +export function getPreferencesProfileMenu(): MenuItem[] { + const autoBackupEnabled = ref(store.s.enablePreferencesAutoCloudBackup); + + watch(autoBackupEnabled, () => { + if (autoBackupEnabled.value) { + if (!canAutoBackup()) { + autoBackupEnabled.value = false; + os.alert({ + type: 'warning', + title: i18n.ts._preferencesBackup.youNeedToNameYourProfileToEnableAutoBackup, + }); + return; + } + + store.set('enablePreferencesAutoCloudBackup', true); + } else { + store.set('enablePreferencesAutoCloudBackup', false); + } + }); + + const menu: MenuItem[] = [{ + type: 'label', + text: prefer.profile.name || `(${i18n.ts.noName})`, + }, { + text: i18n.ts.rename, + icon: 'ti ti-pencil', + action: () => { + renameProfile(); + }, + }, { + type: 'switch', + icon: 'ti ti-cloud-up', + text: i18n.ts._preferencesBackup.autoBackup, + ref: autoBackupEnabled, + }, { + text: i18n.ts.export, + icon: 'ti ti-download', + action: () => { + exportCurrentProfile(); + }, + }, { + type: 'divider', + }, { + text: i18n.ts._preferencesBackup.restoreFromBackup, + icon: 'ti ti-cloud-down', + action: () => { + restoreFromCloudBackup(); + }, + }, { + text: i18n.ts.import, + icon: 'ti ti-upload', + action: () => { + importProfile(); + }, + }]; + + if (prefer.s.devMode) { + menu.push({ + type: 'divider', + }, { + text: 'Copy profile as text', + icon: 'ti ti-clipboard', + action: () => { + copyToClipboard(JSON.stringify(prefer.profile, null, '\t')); + }, + }); + } + + return menu; +} + +async function renameProfile() { + const { canceled, result: name } = await os.inputText({ + title: i18n.ts._preferencesProfile.profileName, + text: i18n.ts._preferencesProfile.profileNameDescription + '\n' + i18n.ts._preferencesProfile.profileNameDescription2, + placeholder: prefer.profile.name || null, + default: prefer.profile.name || null, + }); + if (canceled || name == null || name.trim() === '') return; + + prefer.renameProfile(name); +} + +function exportCurrentProfile() { + const p = prefer.profile; + const txtBlob = new Blob([JSON.stringify(p)], { type: 'text/plain' }); + const dummya = window.document.createElement('a'); + dummya.href = URL.createObjectURL(txtBlob); + dummya.download = `${p.name || p.id}.misskeypreferences`; + dummya.click(); +} + +function importProfile() { + const input = window.document.createElement('input'); + input.type = 'file'; + input.accept = '.misskeypreferences'; + input.onchange = async () => { + if (input.files == null || input.files.length === 0) return; + + const file = input.files[0]; + const txt = await file.text(); + const profile = JSON.parse(txt) as PreferencesProfile; + + miLocalStorage.setItem('preferences', JSON.stringify(profile)); + miLocalStorage.setItem('hidePreferencesRestoreSuggestion', 'true'); + shouldSuggestRestoreBackup.value = false; + unisonReload(); + }; + + input.click(); +} + +export async function cloudBackup() { + if ($i == null) return; + if (!canAutoBackup()) { + throw new Error('Profile name is not set'); + } + + await misskeyApi('i/registry/set', { + scope: ['client', 'preferences', 'backups'], + key: prefer.profile.name, + value: prefer.profile, + }); +} + +export async function restoreFromCloudBackup() { + if ($i == null) return; + + // TODO: 更新日時でソートして取得したい + const keys = await misskeyApi('i/registry/keys', { + scope: ['client', 'preferences', 'backups'], + }); + + if (_DEV_) console.log(keys); + + if (keys.length === 0) { + os.alert({ + type: 'warning', + title: i18n.ts._preferencesBackup.noBackupsFoundTitle, + text: i18n.ts._preferencesBackup.noBackupsFoundDescription, + }); + return; + } + + const select = await os.select({ + title: i18n.ts._preferencesBackup.selectBackupToRestore, + items: keys.map(k => ({ + text: k, + value: k, + })), + }); + if (select.canceled) return; + if (select.result == null) return; + + const profile = await misskeyApi('i/registry/get', { + scope: ['client', 'preferences', 'backups'], + key: select.result, + }); + + if (_DEV_) console.log(profile); + + miLocalStorage.setItem('preferences', JSON.stringify(profile)); + miLocalStorage.setItem('hidePreferencesRestoreSuggestion', 'true'); + store.set('enablePreferencesAutoCloudBackup', true); + shouldSuggestRestoreBackup.value = false; + unisonReload(); +} + +export async function enableAutoBackup() { + if (!canAutoBackup()) { + await renameProfile(); + } + + if (!canAutoBackup()) { + return; + } + + store.set('enablePreferencesAutoCloudBackup', true); +} + +export const shouldSuggestRestoreBackup = ref(false); + +if ($i != null) { + if (new Date($i.createdAt).getTime() > (Date.now() - 1000 * 60 * 30)) { // アカウント作成直後は意味ないので除外 + miLocalStorage.setItem('hidePreferencesRestoreSuggestion', 'true'); + } else { + if (miLocalStorage.getItem('hidePreferencesRestoreSuggestion') !== 'true') { + misskeyApi('i/registry/keys', { + scope: ['client', 'preferences', 'backups'], + }).then(keys => { + if (keys.length === 0) { + miLocalStorage.setItem('hidePreferencesRestoreSuggestion', 'true'); + } else { + shouldSuggestRestoreBackup.value = true; + } + }); + } + } +} + +export function hideRestoreBackupSuggestion() { + miLocalStorage.setItem('hidePreferencesRestoreSuggestion', 'true'); + shouldSuggestRestoreBackup.value = false; +} diff --git a/packages/frontend/src/router/definition.ts b/packages/frontend/src/router.definition.ts index c6ee128f5f..d59b160b8b 100644 --- a/packages/frontend/src/router/definition.ts +++ b/packages/frontend/src/router.definition.ts @@ -5,9 +5,8 @@ import { defineAsyncComponent } from 'vue'; import type { AsyncComponentLoader } from 'vue'; -import type { IRouter, RouteDef } from '@/nirax.js'; -import { Router } from '@/nirax.js'; -import { $i, iAmModerator } from '@/account.js'; +import type { RouteDef } from '@/lib/nirax.js'; +import { $i, iAmModerator } from '@/i.js'; import MkLoading from '@/pages/_loading_.vue'; import MkError from '@/pages/_error_.vue'; @@ -17,7 +16,11 @@ export const page = (loader: AsyncComponentLoader) => defineAsyncComponent({ errorComponent: MkError, }); -const routes: RouteDef[] = [{ +function chatPage(...args: Parameters<typeof page>) { + return $i?.policies.chatAvailability !== 'unavailable' ? page(...args) : page(() => import('@/pages/not-found.vue')); +} + +export const ROUTE_DEF = [{ path: '/@:username/pages/:pageName(*)', component: page(() => import('@/pages/page.vue')), }, { @@ -42,6 +45,22 @@ const routes: RouteDef[] = [{ path: '/clips/:clipId', component: page(() => import('@/pages/clip.vue')), }, { + path: '/chat', + component: chatPage(() => import('@/pages/chat/home.vue')), + loginRequired: true, +}, { + path: '/chat/user/:userId', + component: chatPage(() => import('@/pages/chat/room.vue')), + loginRequired: true, +}, { + path: '/chat/room/:roomId', + component: chatPage(() => import('@/pages/chat/room.vue')), + loginRequired: true, +}, { + path: '/chat/messages/:messageId', + component: chatPage(() => import('@/pages/chat/message.vue')), + loginRequired: true, +}, { path: '/instance-info/:host', component: page(() => import('@/pages/instance-info.vue')), }, { @@ -58,17 +77,13 @@ const routes: RouteDef[] = [{ name: 'avatarDecoration', component: page(() => import('@/pages/settings/avatar-decoration.vue')), }, { - path: '/roles', - name: 'roles', - component: page(() => import('@/pages/settings/roles.vue')), - }, { path: '/privacy', name: 'privacy', component: page(() => import('@/pages/settings/privacy.vue')), }, { - path: '/emoji-picker', - name: 'emojiPicker', - component: page(() => import('@/pages/settings/emoji-picker.vue')), + path: '/emoji-palette', + name: 'emoji-palette', + component: page(() => import('@/pages/settings/emoji-palette.vue')), }, { path: '/drive', name: 'drive', @@ -106,10 +121,6 @@ const routes: RouteDef[] = [{ name: 'theme', component: page(() => import('@/pages/settings/theme.vue')), }, { - path: '/appearance', - name: 'appearance', - component: page(() => import('@/pages/settings/appearance.vue')), - }, { path: '/navbar', name: 'navbar', component: page(() => import('@/pages/settings/navbar.vue')), @@ -122,10 +133,6 @@ const routes: RouteDef[] = [{ name: 'sounds', component: page(() => import('@/pages/settings/sounds.vue')), }, { - path: '/accessibility', - name: 'accessibility', - component: page(() => import('@/pages/settings/accessibility.vue')), - }, { path: '/plugin/install', name: 'plugin', component: page(() => import('@/pages/settings/plugin.install.vue')), @@ -134,42 +141,34 @@ const routes: RouteDef[] = [{ name: 'plugin', component: page(() => import('@/pages/settings/plugin.vue')), }, { - path: '/import-export', - name: 'import-export', - component: page(() => import('@/pages/settings/import-export.vue')), + path: '/account-data', + name: 'account-data', + component: page(() => import('@/pages/settings/account-data.vue')), }, { path: '/mute-block', name: 'mute-block', component: page(() => import('@/pages/settings/mute-block.vue')), }, { - path: '/api', - name: 'api', - component: page(() => import('@/pages/settings/api.vue')), + path: '/connect', + name: 'connect', + component: page(() => import('@/pages/settings/connect.vue')), }, { path: '/apps', - name: 'api', + name: 'connect', component: page(() => import('@/pages/settings/apps.vue')), }, { path: '/webhook/edit/:webhookId', - name: 'webhook', + name: 'connect', component: page(() => import('@/pages/settings/webhook.edit.vue')), }, { path: '/webhook/new', - name: 'webhook', + name: 'connect', component: page(() => import('@/pages/settings/webhook.new.vue')), }, { - path: '/webhook', - name: 'webhook', - component: page(() => import('@/pages/settings/webhook.vue')), - }, { path: '/deck', name: 'deck', component: page(() => import('@/pages/settings/deck.vue')), }, { - path: '/preferences-backups', - name: 'preferences-backups', - component: page(() => import('@/pages/settings/preferences-backups.vue')), - }, { path: '/custom-css', name: 'preferences', component: page(() => import('@/pages/settings/custom-css.vue')), @@ -583,7 +582,6 @@ const routes: RouteDef[] = [{ name: 'index', path: '/', component: $i ? page(() => import('@/pages/timeline.vue')) : page(() => import('@/pages/welcome.vue')), - globalCacheKey: 'index', }, { // テスト用リダイレクト設定。ログイン中ユーザのプロフィールにリダイレクトする path: '/redirect-test', @@ -592,8 +590,4 @@ const routes: RouteDef[] = [{ }, { path: '/:(*)', component: page(() => import('@/pages/not-found.vue')), -}]; - -export function createMainRouter(path: string): IRouter { - return new Router(routes, path, !!$i, page(() => import('@/pages/not-found.vue'))); -} +}] satisfies RouteDef[]; diff --git a/packages/frontend/src/router.ts b/packages/frontend/src/router.ts new file mode 100644 index 0000000000..97ca63f50d --- /dev/null +++ b/packages/frontend/src/router.ts @@ -0,0 +1,46 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { inject } from 'vue'; +import { page } from '@/router.definition.js'; +import { $i } from '@/i.js'; +import { Nirax } from '@/lib/nirax.js'; +import { ROUTE_DEF } from '@/router.definition.js'; +import { analytics } from '@/analytics.js'; +import { DI } from '@/di.js'; + +export type Router = Nirax<typeof ROUTE_DEF>; + +export function createRouter(fullPath: string): Router { + return new Nirax(ROUTE_DEF, fullPath, !!$i, page(() => import('@/pages/not-found.vue'))); +} + +export const mainRouter = createRouter(window.location.pathname + window.location.search + window.location.hash); + +window.addEventListener('popstate', (event) => { + mainRouter.replace(window.location.pathname + window.location.search + window.location.hash); +}); + +mainRouter.addListener('push', ctx => { + window.history.pushState({ }, '', ctx.fullPath); +}); + +mainRouter.addListener('replace', ctx => { + window.history.replaceState({ }, '', ctx.fullPath); +}); + +mainRouter.addListener('change', ctx => { + if (_DEV_) console.log('mainRouter: change', ctx.fullPath); + analytics.page({ + path: ctx.fullPath, + title: ctx.fullPath, + }); +}); + +mainRouter.init(); + +export function useRouter(): Router { + return inject(DI.router, null) ?? mainRouter; +} diff --git a/packages/frontend/src/router/main.ts b/packages/frontend/src/router/main.ts deleted file mode 100644 index 3932a8bac8..0000000000 --- a/packages/frontend/src/router/main.ts +++ /dev/null @@ -1,203 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { EventEmitter } from 'eventemitter3'; -import type { IRouter, Resolved, RouteDef, RouterEvent, RouterFlag } from '@/nirax.js'; - -import type { App, ShallowRef } from 'vue'; -import { analytics } from '@/analytics.js'; - -/** - * {@link Router}による画面遷移を可能とするために{@link mainRouter}をセットアップする。 - * また、{@link Router}のインスタンスを作成するためのファクトリも{@link provide}経由で公開する(`routerFactory`というキーで取得可能) - */ -export function setupRouter(app: App, routerFactory: ((path: string) => IRouter)): void { - app.provide('routerFactory', routerFactory); - - const mainRouter = routerFactory(location.pathname + location.search + location.hash); - - window.addEventListener('popstate', (event) => { - mainRouter.replace(location.pathname + location.search + location.hash, event.state?.key); - }); - - mainRouter.addListener('push', ctx => { - window.history.pushState({ key: ctx.key }, '', ctx.path); - }); - - mainRouter.addListener('replace', ctx => { - window.history.replaceState({ key: ctx.key }, '', ctx.path); - }); - - mainRouter.addListener('change', ctx => { - console.log('mainRouter: change', ctx.path); - analytics.page({ - path: ctx.path, - title: ctx.path, - }); - }); - - mainRouter.init(); - - setMainRouter(mainRouter); -} - -function getMainRouter(): IRouter { - const router = mainRouterHolder; - if (!router) { - throw new Error('mainRouter is not found.'); - } - - return router; -} - -/** - * メインルータを設定する。一度設定すると、それ以降は変更できない。 - * {@link setupRouter}から呼び出されることのみを想定している。 - */ -export function setMainRouter(router: IRouter) { - if (mainRouterHolder) { - throw new Error('mainRouter is already exists.'); - } - - mainRouterHolder = router; -} - -/** - * {@link mainRouter}用のプロキシ実装。 - * {@link mainRouter}は起動シーケンスの一部にて初期化されるため、僅かにundefinedになる期間がある。 - * その僅かな期間のためだけに型をundefined込みにしたくないのでこのクラスを緩衝材として使用する。 - */ -class MainRouterProxy implements IRouter { - private supplier: () => IRouter; - - constructor(supplier: () => IRouter) { - this.supplier = supplier; - } - - get current(): Resolved { - return this.supplier().current; - } - - get currentRef(): ShallowRef<Resolved> { - return this.supplier().currentRef; - } - - get currentRoute(): ShallowRef<RouteDef> { - return this.supplier().currentRoute; - } - - get navHook(): ((path: string, flag?: RouterFlag) => boolean) | null { - return this.supplier().navHook; - } - - set navHook(value) { - this.supplier().navHook = value; - } - - getCurrentKey(): string { - return this.supplier().getCurrentKey(); - } - - getCurrentPath(): string { - return this.supplier().getCurrentPath(); - } - - push(path: string, flag?: RouterFlag): void { - this.supplier().push(path, flag); - } - - replace(path: string, key?: string | null): void { - this.supplier().replace(path, key); - } - - resolve(path: string): Resolved | null { - return this.supplier().resolve(path); - } - - init(): void { - this.supplier().init(); - } - - eventNames(): Array<EventEmitter.EventNames<RouterEvent>> { - return this.supplier().eventNames(); - } - - listeners<T extends EventEmitter.EventNames<RouterEvent>>( - event: T, - ): Array<EventEmitter.EventListener<RouterEvent, T>> { - return this.supplier().listeners(event); - } - - listenerCount( - event: EventEmitter.EventNames<RouterEvent>, - ): number { - return this.supplier().listenerCount(event); - } - - emit<T extends EventEmitter.EventNames<RouterEvent>>( - event: T, - ...args: EventEmitter.EventArgs<RouterEvent, T> - ): boolean { - return this.supplier().emit(event, ...args); - } - - on<T extends EventEmitter.EventNames<RouterEvent>>( - event: T, - fn: EventEmitter.EventListener<RouterEvent, T>, - context?: any, - ): this { - this.supplier().on(event, fn, context); - return this; - } - - addListener<T extends EventEmitter.EventNames<RouterEvent>>( - event: T, - fn: EventEmitter.EventListener<RouterEvent, T>, - context?: any, - ): this { - this.supplier().addListener(event, fn, context); - return this; - } - - once<T extends EventEmitter.EventNames<RouterEvent>>( - event: T, - fn: EventEmitter.EventListener<RouterEvent, T>, - context?: any, - ): this { - this.supplier().once(event, fn, context); - return this; - } - - removeListener<T extends EventEmitter.EventNames<RouterEvent>>( - event: T, - fn?: EventEmitter.EventListener<RouterEvent, T>, - context?: any, - once?: boolean, - ): this { - this.supplier().removeListener(event, fn, context, once); - return this; - } - - off<T extends EventEmitter.EventNames<RouterEvent>>( - event: T, - fn?: EventEmitter.EventListener<RouterEvent, T>, - context?: any, - once?: boolean, - ): this { - this.supplier().off(event, fn, context, once); - return this; - } - - removeAllListeners( - event?: EventEmitter.EventNames<RouterEvent>, - ): this { - this.supplier().removeAllListeners(event); - return this; - } -} - -let mainRouterHolder: IRouter | null = null; - -export const mainRouter: IRouter = new MainRouterProxy(getMainRouter); diff --git a/packages/frontend/src/router/supplier.ts b/packages/frontend/src/router/supplier.ts deleted file mode 100644 index 87f8829854..0000000000 --- a/packages/frontend/src/router/supplier.ts +++ /dev/null @@ -1,31 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { inject } from 'vue'; -import { Router } from '@/nirax.js'; -import type { IRouter } from '@/nirax.js'; -import { mainRouter } from '@/router/main.js'; - -/** - * メインの{@link Router}を取得する。 - * あらかじめ{@link setupRouter}を実行しておく必要がある({@link provide}により{@link IRouter}のインスタンスを注入可能であるならばこの限りではない) - */ -export function useRouter(): IRouter { - return inject<Router | null>('router', null) ?? mainRouter; -} - -/** - * 任意の{@link Router}を取得するためのファクトリを取得する。 - * あらかじめ{@link setupRouter}を実行しておく必要がある。 - */ -export function useRouterFactory(): (path: string) => IRouter { - const factory = inject<(path: string) => IRouter>('routerFactory'); - if (!factory) { - console.error('routerFactory is not defined.'); - throw new Error('routerFactory is not defined.'); - } - - return factory; -} diff --git a/packages/frontend/src/scripts/autogen/settings-search-index.ts b/packages/frontend/src/scripts/autogen/settings-search-index.ts deleted file mode 100644 index c62272b271..0000000000 --- a/packages/frontend/src/scripts/autogen/settings-search-index.ts +++ /dev/null @@ -1,815 +0,0 @@ - -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -// This file was automatically generated by create-search-index. -// Do not edit this file. - -import { i18n } from '@/i18n.js'; - -export type SearchIndexItem = { - id: string; - path?: string; - label: string; - keywords: string[]; - icon?: string; - children?: SearchIndexItem[]; -}; - -export const searchIndexes: SearchIndexItem[] = [ - { - id: 'flXd1LC7r', - children: [ - { - id: 'hB11H5oul', - label: i18n.ts.syncDeviceDarkMode, - keywords: ['sync', 'device', 'dark', 'light', 'mode'], - }, - { - id: 'fDbLtIKeo', - label: i18n.ts.themeForLightMode, - keywords: ['light', 'theme'], - }, - { - id: 'eLOwK5Ia2', - label: i18n.ts.themeForDarkMode, - keywords: ['dark', 'theme'], - }, - { - id: 'ujvMfyzUr', - label: i18n.ts.setWallpaper, - keywords: ['wallpaper'], - }, - ], - label: i18n.ts.theme, - keywords: ['theme'], - path: '/settings/theme', - icon: 'ti ti-palette', - }, - { - id: '6fFIRXUww', - children: [ - { - id: 'nO7NnzqiC', - label: i18n.ts.notUseSound, - keywords: ['mute'], - }, - { - id: 'xy5OOBB4A', - label: i18n.ts.useSoundOnlyWhenActive, - keywords: ['active', 'mute'], - }, - { - id: '9MxYVIf7k', - label: i18n.ts.masterVolume, - keywords: ['volume', 'master'], - }, - ], - label: i18n.ts.sounds, - keywords: ['sounds'], - path: '/settings/sounds', - icon: 'ti ti-music', - }, - { - id: '5BjnxMfYV', - children: [ - { - id: '3UqdSCaFw', - children: [ - { - id: '75QPEg57v', - label: i18n.ts.changePassword, - keywords: [], - }, - ], - label: i18n.ts.password, - keywords: ['password'], - }, - { - id: '2fa', - children: [ - { - id: 'qCXM0HtJ7', - label: i18n.ts.totp, - keywords: ['totp', 'app', i18n.ts.totpDescription], - }, - { - id: '3g1RePuD9', - label: i18n.ts.securityKeyAndPasskey, - keywords: ['security', 'key', 'passkey'], - }, - { - id: 'pFRud5u8k', - label: i18n.ts.passwordLessLogin, - keywords: ['password', 'less', 'key', 'passkey', 'login', 'signin', i18n.ts.passwordLessLoginDescription], - }, - ], - label: i18n.ts['2fa'], - keywords: ['2fa'], - }, - ], - label: i18n.ts.security, - keywords: ['security'], - path: '/settings/security', - icon: 'ti ti-lock', - }, - { - id: 'w4L6myH61', - children: [ - { - id: 'ru8DrOn3J', - label: i18n.ts._profile.changeBanner, - keywords: ['banner', 'change'], - }, - { - id: 'CCnD8Apnu', - label: i18n.ts._profile.changeAvatar, - keywords: ['avatar', 'icon', 'change'], - }, - { - id: 'yFEVCJxFX', - label: i18n.ts._profile.name, - keywords: ['name'], - }, - { - id: '2O1S5reaB', - label: i18n.ts._profile.description, - keywords: ['description', 'bio'], - }, - { - id: 'pWi4OLS8g', - label: i18n.ts.location, - keywords: ['location', 'locale'], - }, - { - id: 'oLO5X6Wtw', - label: i18n.ts.birthday, - keywords: ['birthday', 'birthdate', 'age'], - }, - { - id: 'm2trKwPgq', - label: i18n.ts.language, - keywords: ['language', 'locale'], - }, - { - id: 'kfDZxCDp9', - label: i18n.ts._profile.metadataEdit, - keywords: ['metadata'], - }, - { - id: 'uPt3MFymp', - label: i18n.ts._profile.followedMessage, - keywords: ['follow', 'message', i18n.ts._profile.followedMessageDescription], - }, - { - id: 'wuGg0tBjw', - label: i18n.ts.reactionAcceptance, - keywords: ['reaction'], - }, - { - id: 'EezPpmMnf', - children: [ - { - id: 'f2cRLh8ad', - label: i18n.ts.flagAsCat, - keywords: ['cat'], - }, - { - id: 'eVoViiF3h', - label: i18n.ts.flagAsBot, - keywords: ['bot'], - }, - ], - label: i18n.ts.advancedSettings, - keywords: [], - }, - ], - label: i18n.ts.profile, - keywords: ['profile'], - path: '/settings/profile', - icon: 'ti ti-user', - }, - { - id: '2rp9ka5Ht', - children: [ - { - id: 'qBUSKPxLW', - label: i18n.ts.makeFollowManuallyApprove, - keywords: ['follow', 'lock', i18n.ts.lockedAccountInfo], - }, - { - id: '3LZBlZCej', - label: i18n.ts.autoAcceptFollowed, - keywords: ['follow', 'auto', 'accept'], - }, - { - id: '9gOp28wKG', - label: i18n.ts.makeReactionsPublic, - keywords: ['reaction', 'public', i18n.ts.makeReactionsPublicDescription], - }, - { - id: 'CjAkqMhct', - label: i18n.ts.followingVisibility, - keywords: ['following', 'visibility'], - }, - { - id: '4nEwI6LYt', - label: i18n.ts.followersVisibility, - keywords: ['follower', 'visibility'], - }, - { - id: 'naMp37wTL', - label: i18n.ts.hideOnlineStatus, - keywords: ['online', 'status', i18n.ts.hideOnlineStatusDescription], - }, - { - id: 'p0dCVR0UP', - label: i18n.ts.noCrawle, - keywords: ['crawle', 'index', 'search', i18n.ts.noCrawleDescription], - }, - { - id: 'aceURmNPq', - label: i18n.ts.preventAiLearning, - keywords: ['crawle', 'ai', i18n.ts.preventAiLearningDescription], - }, - { - id: 'ahABA0j7u', - label: i18n.ts.makeExplorable, - keywords: ['explore', i18n.ts.makeExplorableDescription], - }, - { - id: 'cyeDbLN8N', - children: [ - { - id: 'xEYlOghao', - label: i18n.ts._accountSettings.requireSigninToViewContents, - keywords: ['login', 'signin'], - }, - { - id: 'sMmYFCS60', - label: i18n.ts._accountSettings.makeNotesFollowersOnlyBefore, - keywords: ['follower', i18n.ts._accountSettings.makeNotesFollowersOnlyBeforeDescription], - }, - { - id: '2prkeWRSd', - label: i18n.ts._accountSettings.makeNotesHiddenBefore, - keywords: ['hidden', i18n.ts._accountSettings.makeNotesHiddenBeforeDescription], - }, - ], - label: i18n.ts.lockdown, - keywords: ['lockdown'], - }, - { - id: '37QLEyrtk', - label: i18n.ts.rememberNoteVisibility, - keywords: ['remember', 'keep', 'note', 'visibility'], - }, - { - id: 'rhKwScbVS', - label: i18n.ts.defaultNoteVisibility, - keywords: ['default', 'note', 'visibility'], - }, - { - id: '3EmXVyevo', - label: i18n.ts.keepCw, - keywords: ['remember', 'keep', 'note', 'cw'], - }, - ], - label: i18n.ts.privacy, - keywords: ['privacy'], - path: '/settings/privacy', - icon: 'ti ti-lock-open', - }, - { - id: '3yCAv0IsZ', - children: [ - { - id: 'x1GWSQnPw', - label: i18n.ts.uiLanguage, - keywords: ['language'], - }, - { - id: 'EOSa4rtt3', - label: i18n.ts.overridedDeviceKind, - keywords: ['device', 'type', 'kind', 'smartphone', 'tablet', 'desktop'], - }, - { - id: 'm9LhX8BG8', - label: i18n.ts.showFixedPostForm, - keywords: ['post', 'form', 'timeline'], - }, - { - id: '9ra14w32V', - label: i18n.ts.showFixedPostFormInChannel, - keywords: ['post', 'form', 'timeline', 'channel'], - }, - { - id: '84MdeDWL1', - label: i18n.ts.pinnedList, - keywords: ['pinned', 'list'], - }, - { - id: 'fYdWhBbrN', - label: i18n.ts.enableQuickAddMfmFunction, - keywords: ['mfm', 'enable', 'show', 'advanced', 'picker', 'form', 'function', 'fn'], - }, - { - id: '4huRldNp5', - children: [ - { - id: 'puIqj1a8b', - label: i18n.ts.collapseRenotes, - keywords: ['renote', i18n.ts.collapseRenotesDescription], - }, - { - id: 'wqpOC22Zm', - label: i18n.ts.showNoteActionsOnlyHover, - keywords: ['hover', 'show', 'footer', 'action'], - }, - { - id: 'cjfAtxMzP', - label: i18n.ts.showClipButtonInNoteFooter, - keywords: ['footer', 'action', 'clip', 'show'], - }, - { - id: 'khzxoCjtp', - label: i18n.ts.enableAdvancedMfm, - keywords: ['mfm', 'enable', 'show', 'advanced'], - }, - { - id: 'uJkoVjTmF', - label: i18n.ts.showReactionsCount, - keywords: ['reaction', 'count', 'show'], - }, - { - id: '9gTCaLkIf', - label: i18n.ts.loadRawImages, - keywords: ['image', 'photo', 'picture', 'media', 'thumbnail', 'quality', 'raw', 'attachment'], - }, - ], - label: i18n.ts.note, - keywords: ['note'], - }, - { - id: '5G6O6qdis', - children: [ - { - id: 'sYTvqUbhP', - label: i18n.ts.useGroupedNotifications, - keywords: ['group'], - }, - ], - label: i18n.ts.notifications, - keywords: ['notification'], - }, - { - id: 'c3xhLyXZ5', - children: [ - { - id: 'FbhoeuRAD', - label: i18n.ts.openImageInNewTab, - keywords: ['image', 'photo', 'picture', 'media', 'thumbnail', 'new', 'tab'], - }, - { - id: 'qixh85g2N', - label: i18n.ts.useReactionPickerForContextMenu, - keywords: ['reaction', 'picker', 'contextmenu', 'open'], - }, - { - id: 'd2H4E5ys6', - label: i18n.ts.enableInfiniteScroll, - keywords: ['load', 'auto', 'more'], - }, - { - id: 'jC7LtTnmc', - label: i18n.ts.disableStreamingTimeline, - keywords: ['disable', 'streaming', 'timeline'], - }, - { - id: '8xazEqlgZ', - label: i18n.ts.alwaysConfirmFollow, - keywords: ['follow', 'confirm', 'always'], - }, - { - id: 'wZqrDQZar', - label: i18n.ts.confirmWhenRevealingSensitiveMedia, - keywords: ['sensitive', 'nsfw', 'media', 'image', 'photo', 'picture', 'attachment', 'confirm'], - }, - { - id: '5QTUzrpT3', - label: i18n.ts.confirmOnReact, - keywords: ['reaction', 'confirm'], - }, - { - id: 'nygexkaUk', - label: i18n.ts.whenServerDisconnected, - keywords: ['server', 'disconnect', 'reconnect', 'reload', 'streaming'], - }, - { - id: 'whKYKvaQB', - label: i18n.ts.numberOfPageCache, - keywords: ['cache', 'page'], - }, - { - id: 'lBbtAg0Hm', - label: i18n.ts.dataSaver, - keywords: ['datasaver'], - }, - ], - label: i18n.ts.behavior, - keywords: ['behavior'], - }, - { - id: 'y2v7CV9zs', - children: [ - { - id: 'k1qTdyfzM', - label: i18n.ts.forceShowAds, - keywords: ['ad', 'show'], - }, - { - id: 'e9As4Us48', - label: i18n.ts.hemisphere, - keywords: [], - }, - { - id: 'zvM13vl26', - label: i18n.ts.additionalEmojiDictionary, - keywords: ['emoji', 'dictionary', 'additional', 'extra'], - }, - ], - label: i18n.ts.other, - keywords: [], - }, - ], - label: i18n.ts.preferences, - keywords: ['general', 'preferences'], - path: '/settings/preferences', - icon: 'ti ti-adjustments', - }, - { - id: 'F1uK9ssiY', - children: [ - { - id: 'msAcN6u3S', - label: i18n.ts.accountInfo, - keywords: ['account', 'info'], - }, - { - id: 'ts8DgdnZV', - label: i18n.ts.accountMigration, - keywords: ['account', 'move', 'migration'], - }, - { - id: '4BG7nBECm', - label: i18n.ts.closeAccount, - keywords: ['account', 'close', 'delete', i18n.ts._accountDelete.requestAccountDelete], - }, - { - id: '2qI6ruPgi', - label: i18n.ts.experimentalFeatures, - keywords: ['experimental', 'feature', 'flags'], - }, - { - id: 'cIeaax47o', - label: i18n.ts.developer, - keywords: ['developer', 'mode', 'debug'], - }, - ], - label: i18n.ts.other, - keywords: ['other'], - path: '/settings/other', - icon: 'ti ti-dots', - }, - { - id: '3icEvyv2D', - children: [ - { - id: 'Tyt3gZTy', - children: [ - { - id: '9b7ZURyAt', - label: i18n.ts.showMutedWord, - keywords: ['show'], - }, - ], - label: i18n.ts.wordMute, - keywords: ['note', 'word', 'soft', 'mute', 'hide'], - }, - { - id: 'kdMk41II0', - label: i18n.ts.hardWordMute, - keywords: ['note', 'word', 'hard', 'mute', 'hide'], - }, - { - id: 'mjORQamAK', - label: i18n.ts.instanceMute, - keywords: ['note', 'server', 'instance', 'host', 'federation', 'mute', 'hide'], - }, - { - id: '1ZT7S9FZd', - label: `${i18n.ts.mutedUsers} (${ i18n.ts.renote })`, - keywords: ['renote', 'mute', 'hide', 'user'], - }, - { - id: 'ANrPit3kQ', - label: i18n.ts.mutedUsers, - keywords: ['note', 'mute', 'hide', 'user'], - }, - { - id: 'bPAE4lfno', - label: i18n.ts.blockedUsers, - keywords: ['block', 'user'], - }, - ], - label: i18n.ts.muteAndBlock, - keywords: ['mute', 'block'], - path: '/settings/mute-block', - icon: 'ti ti-ban', - }, - { - id: 'qE2vLlMkF', - children: [ - { - id: 'hPPEzjvZC', - label: i18n.ts._exportOrImport.allNotes, - keywords: ['notes'], - }, - { - id: 'AFaeHsCUB', - label: i18n.ts._exportOrImport.favoritedNotes, - keywords: ['favorite', 'notes'], - }, - { - id: 'xyCPmQiRo', - label: i18n.ts._exportOrImport.clips, - keywords: ['clip', 'notes'], - }, - { - id: 'Ch7hWAGUy', - label: i18n.ts._exportOrImport.followingList, - keywords: ['following', 'users'], - }, - { - id: 'AwPgFboEx', - label: i18n.ts._exportOrImport.userLists, - keywords: ['user', 'lists'], - }, - { - id: 'nporiHshC', - label: i18n.ts._exportOrImport.muteList, - keywords: ['mute', 'users'], - }, - { - id: 'BsCzR7vNw', - label: i18n.ts._exportOrImport.blockingList, - keywords: ['block', 'users'], - }, - { - id: 'dvf4IgYrQ', - label: i18n.ts.antennas, - keywords: ['antennas'], - }, - ], - label: i18n.ts.importAndExport, - keywords: ['import', 'export', 'data'], - path: '/settings/import-export', - icon: 'ti ti-package', - }, - { - id: '3Tcxw4Fwl', - children: [ - { - id: 'iIai9O65I', - label: i18n.ts.emailAddress, - keywords: ['email', 'address'], - }, - { - id: 'i6cC6oi0m', - label: i18n.ts.receiveAnnouncementFromInstance, - keywords: ['announcement', 'email'], - }, - { - id: 'C1YTinP11', - label: i18n.ts.emailNotification, - keywords: ['notification', 'email'], - }, - ], - label: i18n.ts.email, - keywords: ['email'], - path: '/settings/email', - icon: 'ti ti-mail', - }, - { - id: 'tnYoppRiv', - children: [ - { - id: 'ncIq6TAR2', - label: i18n.ts.usageAmount, - keywords: ['capacity', 'usage'], - }, - { - id: '2c4CQSvSr', - label: i18n.ts.statistics, - keywords: ['statistics', 'usage'], - }, - { - id: 'pepHELHMt', - label: i18n.ts.uploadFolder, - keywords: ['default', 'upload', 'folder'], - }, - { - id: 'xqOWrABxV', - label: i18n.ts.keepOriginalUploading, - keywords: ['keep', 'original', 'raw', 'upload', i18n.ts.keepOriginalUploadingDescription], - }, - { - id: 'oqUiI5w0s', - label: i18n.ts.keepOriginalFilename, - keywords: ['keep', 'original', 'filename', i18n.ts.keepOriginalFilenameDescription], - }, - { - id: 'Aszkikq9n', - label: i18n.ts.alwaysMarkSensitive, - keywords: ['always', 'default', 'mark', 'nsfw', 'sensitive', 'media', 'file'], - }, - { - id: 'iGlVjsfVj', - label: i18n.ts.enableAutoSensitive, - keywords: ['auto', 'nsfw', 'sensitive', 'media', 'file', i18n.ts.enableAutoSensitiveDescription], - }, - ], - label: i18n.ts.drive, - keywords: ['drive'], - path: '/settings/drive', - icon: 'ti ti-cloud', - }, - { - id: 'gtaOSdIJB', - label: i18n.ts.avatarDecorations, - keywords: ['avatar', 'icon', 'decoration'], - path: '/settings/avatar-decoration', - icon: 'ti ti-sparkles', - }, - { - id: 'AqPvMgn3A', - children: [ - { - id: 'j5gTtuMWP', - label: i18n.ts.useBlurEffect, - keywords: ['blur'], - }, - { - id: 'vbZvyLDC1', - label: i18n.ts.useBlurEffectForModal, - keywords: ['blur', 'modal'], - }, - { - id: '6fLNMTwNt', - label: i18n.ts.highlightSensitiveMedia, - keywords: ['highlight', 'sensitive', 'nsfw', 'image', 'photo', 'picture', 'media', 'thumbnail'], - }, - { - id: 'hhvF8Z4pF', - label: i18n.ts.squareAvatars, - keywords: ['avatar', 'icon', 'square'], - }, - { - id: 'DsS2CwjYE', - label: i18n.ts.showAvatarDecorations, - keywords: ['avatar', 'icon', 'decoration', 'show'], - }, - { - id: 'pWZ0ypy2g', - label: i18n.ts.showGapBetweenNotesInTimeline, - keywords: ['note', 'timeline', 'gap'], - }, - { - id: 'AfRMcC6IM', - label: i18n.ts.useSystemFont, - keywords: ['font', 'system', 'native'], - }, - { - id: 'jD0qbxlzN', - label: i18n.ts.seasonalScreenEffect, - keywords: ['effect', 'show'], - }, - { - id: 'EdYo3hOK', - label: i18n.ts.menuStyle, - keywords: ['menu', 'style', 'popup', 'drawer'], - }, - { - id: '9mSlX0EkD', - label: i18n.ts.emojiStyle, - keywords: ['emoji', 'style', 'native', 'system', 'fluent', 'twemoji'], - }, - { - id: '44UmMwmUe', - label: i18n.ts.fontSize, - keywords: ['font', 'size'], - }, - { - id: 'vFB0pLzck', - children: [ - { - id: 'pc7IpPEU4', - label: i18n.ts.reactionsDisplaySize, - keywords: ['reaction', 'size', 'scale', 'display'], - }, - { - id: 'siOW5aSwp', - label: i18n.ts.limitWidthOfReaction, - keywords: ['reaction', 'size', 'scale', 'display', 'width', 'limit'], - }, - { - id: 'dDUvhk13F', - label: i18n.ts.mediaListWithOneImageAppearance, - keywords: ['attachment', 'image', 'photo', 'picture', 'media', 'thumbnail', 'list', 'size', 'height'], - }, - { - id: 'CLxNL1Rp0', - label: i18n.ts.instanceTicker, - keywords: ['ticker', 'information', 'label', 'instance', 'server', 'host', 'federation'], - }, - { - id: 'dP2KWDYzD', - label: i18n.ts.displayOfSensitiveMedia, - keywords: ['attachment', 'image', 'photo', 'picture', 'media', 'thumbnail', 'nsfw', 'sensitive', 'display', 'show', 'hide', 'visibility'], - }, - ], - label: i18n.ts.displayOfNote, - keywords: ['note', 'display'], - }, - { - id: 'dVOzi22IW', - children: [ - { - id: 'aoF4ufUwn', - label: i18n.ts.position, - keywords: ['position'], - }, - { - id: 'sKK2XSS69', - label: i18n.ts.stackAxis, - keywords: ['stack', 'axis', 'direction'], - }, - ], - label: i18n.ts.notificationDisplay, - keywords: ['notification', 'display'], - }, - ], - label: i18n.ts.appearance, - keywords: ['appearance'], - path: '/settings/appearance', - icon: 'ti ti-device-desktop', - }, - { - id: 'f08Mi1Uwn', - children: [ - { - id: '7ov7ceoij', - label: i18n.ts.reduceUiAnimation, - keywords: ['animation', 'motion', 'reduce'], - }, - { - id: 'RhYwm8At', - label: i18n.ts.disableShowingAnimatedImages, - keywords: ['disable', 'animation', 'image', 'photo', 'picture', 'media', 'thumbnail', 'gif'], - }, - { - id: '5mZxz2cru', - label: i18n.ts.enableAnimatedMfm, - keywords: ['mfm', 'enable', 'show', 'animated'], - }, - { - id: 'bgjamYEis', - label: i18n.ts.enableHorizontalSwipe, - keywords: ['swipe', 'horizontal', 'tab'], - }, - { - id: 'yPEpJigqY', - label: i18n.ts.keepScreenOn, - keywords: ['keep', 'screen', 'display', 'on'], - }, - { - id: 'oxwiGKMu0', - label: i18n.ts.useNativeUIForVideoAudioPlayer, - keywords: ['native', 'system', 'video', 'audio', 'player', 'media'], - }, - { - id: 'n90tffyiU', - label: i18n.ts._contextMenu.title, - keywords: ['contextmenu', 'system', 'native'], - }, - ], - label: i18n.ts.accessibility, - keywords: ['accessibility'], - path: '/settings/accessibility', - icon: 'ti ti-accessible', - }, -] as const; - -export type SearchIndex = typeof searchIndexes; diff --git a/packages/frontend/src/scripts/install-plugin.ts b/packages/frontend/src/scripts/install-plugin.ts deleted file mode 100644 index 37f473b6de..0000000000 --- a/packages/frontend/src/scripts/install-plugin.ts +++ /dev/null @@ -1,131 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { defineAsyncComponent } from 'vue'; -import { compareVersions } from 'compare-versions'; -import { v4 as uuid } from 'uuid'; -import { Interpreter, Parser, utils } from '@syuilo/aiscript'; -import type { Plugin } from '@/store.js'; -import { ColdDeviceStorage } from '@/store.js'; -import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { i18n } from '@/i18n.js'; - -export type AiScriptPluginMeta = { - name: string; - version: string; - author: string; - description?: string; - permissions?: string[]; - config?: Record<string, any>; -}; - -const parser = new Parser(); - -export function savePlugin({ id, meta, src, token }: { - id: string; - meta: AiScriptPluginMeta; - src: string; - token: string; -}) { - ColdDeviceStorage.set('plugins', ColdDeviceStorage.get('plugins').concat({ - ...meta, - id, - active: true, - configData: {}, - token: token, - src: src, - } as Plugin)); -} - -export function isSupportedAiScriptVersion(version: string): boolean { - try { - return (compareVersions(version, '0.12.0') >= 0); - } catch (err) { - return false; - } -} - -export async function parsePluginMeta(code: string): Promise<AiScriptPluginMeta> { - if (!code) { - throw new Error('code is required'); - } - - const lv = utils.getLangVersion(code); - if (lv == null) { - throw new Error('No language version annotation found'); - } else if (!isSupportedAiScriptVersion(lv)) { - throw new Error(`Aiscript version '${lv}' is not supported`); - } - - let ast; - try { - ast = parser.parse(code); - } catch (err) { - throw new Error('Aiscript syntax error'); - } - - const meta = Interpreter.collectMetadata(ast); - if (meta == null) { - throw new Error('Meta block not found'); - } - - const metadata = meta.get(null); - if (metadata == null) { - throw new Error('Metadata not found'); - } - - const { name, version, author, description, permissions, config } = metadata; - if (name == null || version == null || author == null) { - throw new Error('Required property not found'); - } - - return { - name, - version, - author, - description, - permissions, - config, - }; -} - -export async function installPlugin(code: string, meta?: AiScriptPluginMeta) { - if (!code) return; - - let realMeta: AiScriptPluginMeta; - if (!meta) { - realMeta = await parsePluginMeta(code); - } else { - realMeta = meta; - } - - const token = realMeta.permissions == null || realMeta.permissions.length === 0 ? null : await new Promise((res, rej) => { - const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkTokenGenerateWindow.vue')), { - title: i18n.ts.tokenRequested, - information: i18n.ts.pluginTokenRequestedDescription, - initialName: realMeta.name, - initialPermissions: realMeta.permissions, - }, { - done: async result => { - const { name, permissions } = result; - const { token } = await misskeyApi('miauth/gen-token', { - session: null, - name: name, - permission: permissions, - }); - res(token); - }, - closed: () => dispose(), - }); - }); - - savePlugin({ - id: uuid(), - meta: realMeta, - token, - src: code, - }); -} diff --git a/packages/frontend/src/scripts/install-theme.ts b/packages/frontend/src/scripts/install-theme.ts deleted file mode 100644 index cc32adcc6a..0000000000 --- a/packages/frontend/src/scripts/install-theme.ts +++ /dev/null @@ -1,38 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import JSON5 from 'json5'; -import { addTheme, getThemes } from '@/theme-store.js'; -import { applyTheme, validateTheme } from '@/scripts/theme.js'; -import type { Theme } from '@/scripts/theme.js'; - -export function parseThemeCode(code: string): Theme { - let theme; - - try { - theme = JSON5.parse(code); - } catch (err) { - throw new Error('Failed to parse theme json'); - } - if (!validateTheme(theme)) { - throw new Error('This theme is invaild'); - } - if (getThemes().some(t => t.id === theme.id)) { - throw new Error('This theme is already installed'); - } - - return theme; -} - -export function previewTheme(code: string): void { - const theme = parseThemeCode(code); - if (theme) applyTheme(theme, false); -} - -export async function installTheme(code: string): Promise<void> { - const theme = parseThemeCode(code); - if (!theme) return; - await addTheme(theme); -} diff --git a/packages/frontend/src/server-context.ts b/packages/frontend/src/server-context.ts index e79d3fa314..744bfa4b7b 100644 --- a/packages/frontend/src/server-context.ts +++ b/packages/frontend/src/server-context.ts @@ -5,7 +5,7 @@ import * as Misskey from 'misskey-js'; -const providedContextEl = document.getElementById('misskey_clientCtx'); +const providedContextEl = window.document.getElementById('misskey_clientCtx'); export type ServerContext = { clip?: Misskey.entities.Clip; diff --git a/packages/frontend/src/signout.ts b/packages/frontend/src/signout.ts new file mode 100644 index 0000000000..c9f9278369 --- /dev/null +++ b/packages/frontend/src/signout.ts @@ -0,0 +1,57 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { apiUrl } from '@@/js/config.js'; +import { defaultMemoryStorage } from '@/memory-storage'; +import { waiting } from '@/os.js'; +import { unisonReload, reloadChannel } from '@/utility/unison-reload.js'; +import { $i } from '@/i.js'; + +export async function signout() { + if (!$i) return; + + // TODO: preferの自動バックアップがオンの場合、いろいろ消す前に強制バックアップ + + waiting(); + + localStorage.clear(); + defaultMemoryStorage.clear(); + + const idbPromises = ['MisskeyClient', 'keyval-store'].map((name, i, arr) => new Promise<void>((res, rej) => { + const delidb = indexedDB.deleteDatabase(name); + delidb.onsuccess = () => res(); + delidb.onerror = e => rej(e); + })); + + await Promise.all(idbPromises); + + //#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', + }, + }); + } + } + + await navigator.serviceWorker.getRegistrations() + .then(registrations => { + return Promise.all(registrations.map(registration => registration.unregister())); + }); + } catch (err) {} + //#endregion + + unisonReload('/'); +} diff --git a/packages/frontend/src/store.ts b/packages/frontend/src/store.ts index 128fce315f..fc1d463674 100644 --- a/packages/frontend/src/store.ts +++ b/packages/frontend/src/store.ts @@ -5,69 +5,19 @@ import { markRaw, ref } from 'vue'; import * as Misskey from 'misskey-js'; -import { hemisphere } from '@@/js/intl-const.js'; import lightTheme from '@@/themes/l-light.json5'; import darkTheme from '@@/themes/d-green-lime.json5'; -import type { SoundType } from '@/scripts/sound.js'; -import type { Ast } from '@syuilo/aiscript'; -import type { DeviceKind } from '@/scripts/device-kind.js'; -import { DEFAULT_DEVICE_KIND } from '@/scripts/device-kind.js'; +import { hemisphere } from '@@/js/intl-const.js'; +import type { DeviceKind } from '@/utility/device-kind.js'; +import type { Plugin } from '@/plugin.js'; import { miLocalStorage } from '@/local-storage.js'; -import { Storage } from '@/pizzax.js'; - -interface PostFormAction { - title: string, - handler: <T>(form: T, update: (key: unknown, value: unknown) => void) => void; -} - -interface UserAction { - title: string, - handler: (user: Misskey.entities.UserDetailed) => void; -} - -interface NoteAction { - title: string, - handler: (note: Misskey.entities.Note) => void; -} - -interface NoteViewInterruptor { - handler: (note: Misskey.entities.Note) => unknown; -} - -interface NotePostInterruptor { - handler: (note: FIXME) => unknown; -} - -interface PageViewInterruptor { - handler: (page: Misskey.entities.Page) => unknown; -} - -/** サウンド設定 */ -export type SoundStore = { - type: Exclude<SoundType, '_driveFile_'>; - volume: number; -} | { - type: '_driveFile_'; - - /** ドライブのファイルID */ - fileId: string; +import { Pizzax } from '@/lib/pizzax.js'; +import { DEFAULT_DEVICE_KIND } from '@/utility/device-kind.js'; - /** ファイルURL(こちらが優先される) */ - fileUrl: string; - - volume: number; -}; - -export const postFormActions: PostFormAction[] = []; -export const userActions: UserAction[] = []; -export const noteActions: NoteAction[] = []; -export const noteViewInterruptors: NoteViewInterruptor[] = []; -export const notePostInterruptors: NotePostInterruptor[] = []; -export const pageViewInterruptors: PageViewInterruptor[] = []; - -// TODO: それぞれいちいちwhereとかdefaultというキーを付けなきゃいけないの冗長なのでなんとかする(ただ型定義が面倒になりそう) -// あと、現行の定義の仕方なら「whereが何であるかに関わらずキー名の重複不可」という制約を付けられるメリットもあるからそのメリットを引き継ぐ方法も考えないといけない -export const defaultStore = markRaw(new Storage('base', { +/** + * 「状態」を管理するストア(not「設定」) + */ +export const store = markRaw(new Pizzax('base', { accountSetupWizard: { where: 'account', default: 0, @@ -85,59 +35,147 @@ export const defaultStore = markRaw(new Storage('base', { where: 'account', default: false, }, - keepCw: { + memo: { where: 'account', - default: true, + default: null, }, - collapseRenotes: { + reactionAcceptance: { + where: 'account', + default: 'nonSensitiveOnly' as 'likeOnly' | 'likeOnlyForRemote' | 'nonSensitiveOnly' | 'nonSensitiveOnlyForLocalLikeOnlyForRemote' | null, + }, + mutedAds: { where: 'account', + default: [] as string[], + }, + visibility: { + where: 'deviceAccount', + default: 'public' as (typeof Misskey.noteVisibilities)[number], + }, + localOnly: { + where: 'deviceAccount', + default: false, + }, + showPreview: { + where: 'device', + default: false, + }, + tl: { + where: 'deviceAccount', + default: { + src: 'home' as 'home' | 'local' | 'social' | 'global' | `list:${string}`, + userList: null as Misskey.entities.UserList | null, + filter: { + withReplies: true, + withRenotes: true, + withSensitive: true, + onlyFiles: false, + }, + }, + }, + darkMode: { + where: 'device', + default: false, + }, + recentlyUsedEmojis: { + where: 'device', + default: [] as string[], + }, + recentlyUsedUsers: { + where: 'device', + default: [] as string[], + }, + menuDisplay: { + where: 'device', + default: 'sideFull' as 'sideFull' | 'sideIcon' | 'top', + }, + postFormWithHashtags: { + where: 'device', + default: false, + }, + postFormHashtags: { + where: 'device', + default: '', + }, + additionalUnicodeEmojiIndexes: { + where: 'device', + default: {} as Record<string, Record<string, string[]>>, + }, + pluginTokens: { + where: 'deviceAccount', + default: {} as Record<string, string>, // plugin id, token + }, + accountTokens: { + where: 'device', + default: {} as Record<string, string>, // host/userId, token + }, + + enablePreferencesAutoCloudBackup: { + where: 'device', + default: false, + }, + showPreferencesAutoCloudBackupSuggestion: { + where: 'device', default: true, }, - rememberNoteVisibility: { + + //#region TODO: そのうち消す (preferに移行済み) + defaultWithReplies: { where: 'account', default: false, }, - defaultNoteVisibility: { + reactions: { where: 'account', - default: 'public' as (typeof Misskey.noteVisibilities)[number], + default: ['👍', '❤️', '😆', '🤔', '😮', '🎉', '💢', '😥', '😇', '🍮'], }, - defaultNoteLocalOnly: { + pinnedEmojis: { where: 'account', - default: false, + default: [], }, - uploadFolder: { + widgets: { where: 'account', - default: null as string | null, + default: [] as { + name: string; + id: string; + place: string | null; + data: Record<string, any>; + }[], + }, + overridedDeviceKind: { + where: 'device', + default: null as DeviceKind | null, }, - pastedFileName: { + defaultSideView: { + where: 'device', + default: false, + }, + defaultNoteVisibility: { where: 'account', - default: 'yyyy-MM-dd HH-mm-ss [{{number}}]', + default: 'public' as (typeof Misskey.noteVisibilities)[number], }, - keepOriginalUploading: { + defaultNoteLocalOnly: { where: 'account', default: false, }, - memo: { + keepCw: { where: 'account', - default: null, + default: true, }, - reactions: { + collapseRenotes: { where: 'account', - default: ['👍', '❤️', '😆', '🤔', '😮', '🎉', '💢', '😥', '😇', '🍮'], + default: true, }, - pinnedEmojis: { + rememberNoteVisibility: { where: 'account', - default: [], + default: false, }, - reactionAcceptance: { + uploadFolder: { where: 'account', - default: 'nonSensitiveOnly' as 'likeOnly' | 'likeOnlyForRemote' | 'nonSensitiveOnly' | 'nonSensitiveOnlyForLocalLikeOnlyForRemote' | null, + default: null as string | null, }, - mutedAds: { + keepOriginalUploading: { where: 'account', - default: [] as string[], + default: false, }, - menu: { where: 'deviceAccount', default: [ @@ -153,18 +191,6 @@ export const defaultStore = markRaw(new Storage('base', { 'ui', ], }, - visibility: { - where: 'deviceAccount', - default: 'public' as (typeof Misskey.noteVisibilities)[number], - }, - localOnly: { - where: 'deviceAccount', - default: false, - }, - showPreview: { - where: 'device', - default: false, - }, statusbars: { where: 'deviceAccount', default: [] as { @@ -176,37 +202,10 @@ export const defaultStore = markRaw(new Storage('base', { props: Record<string, any>; }[], }, - widgets: { - where: 'account', - default: [] as { - name: string; - id: string; - place: string | null; - data: Record<string, any>; - }[], - }, - tl: { - where: 'deviceAccount', - default: { - src: 'home' as 'home' | 'local' | 'social' | 'global' | `list:${string}`, - userList: null as Misskey.entities.UserList | null, - filter: { - withReplies: true, - withRenotes: true, - withSensitive: true, - onlyFiles: false, - }, - }, - }, pinnedUserLists: { where: 'deviceAccount', default: [] as Misskey.entities.UserList[], }, - - overridedDeviceKind: { - where: 'device', - default: null as DeviceKind | null, - }, serverDisconnectedBehavior: { where: 'device', default: 'quiet' as 'quiet' | 'reload' | 'dialog', @@ -287,10 +286,6 @@ export const defaultStore = markRaw(new Storage('base', { where: 'device', default: false, }, - darkMode: { - where: 'device', - default: false, - }, instanceTicker: { where: 'device', default: 'remote' as 'none' | 'remote' | 'always', @@ -311,22 +306,6 @@ export const defaultStore = markRaw(new Storage('base', { where: 'device', default: 'auto' as 'auto' | 'popup' | 'drawer', }, - recentlyUsedEmojis: { - where: 'device', - default: [] as string[], - }, - recentlyUsedUsers: { - where: 'device', - default: [] as string[], - }, - defaultSideView: { - where: 'device', - default: false, - }, - menuDisplay: { - where: 'device', - default: 'sideFull' as 'sideFull' | 'sideIcon' | 'top', - }, reportError: { where: 'device', default: false, @@ -339,18 +318,6 @@ export const defaultStore = markRaw(new Storage('base', { where: 'device', default: true, }, - postFormWithHashtags: { - where: 'device', - default: false, - }, - postFormHashtags: { - where: 'device', - default: '', - }, - themeInitial: { - where: 'device', - default: true, - }, numberOfPageCache: { where: 'device', default: 3, @@ -399,18 +366,10 @@ export const defaultStore = markRaw(new Storage('base', { where: 'device', default: true, }, - additionalUnicodeEmojiIndexes: { - where: 'device', - default: {} as Record<string, Record<string, string[]>>, - }, keepScreenOn: { where: 'device', default: false, }, - defaultWithReplies: { - where: 'account', - default: false, - }, disableStreamingTimeline: { where: 'device', default: false, @@ -432,17 +391,6 @@ export const defaultStore = markRaw(new Storage('base', { where: 'device', default: false, }, - dropAndFusion: { - where: 'device', - default: { - bgmVolume: 0.25, - sfxVolume: 1, - }, - }, - hemisphere: { - where: 'device', - default: hemisphere as 'N' | 'S', - }, enableHorizontalSwipe: { where: 'device', default: true, @@ -479,7 +427,10 @@ export const defaultStore = markRaw(new Storage('base', { where: 'device', default: false, }, - + hemisphere: { + where: 'device', + default: hemisphere as 'N' | 'S', + }, sound_masterVolume: { where: 'device', default: 0.3, @@ -494,56 +445,49 @@ export const defaultStore = markRaw(new Storage('base', { }, sound_note: { where: 'device', - default: { type: 'syuilo/n-aec', volume: 1 } as SoundStore, + default: { type: 'syuilo/n-aec', volume: 1 }, }, sound_noteMy: { where: 'device', - default: { type: 'syuilo/n-cea-4va', volume: 1 } as SoundStore, + default: { type: 'syuilo/n-cea-4va', volume: 1 }, }, sound_notification: { where: 'device', - default: { type: 'syuilo/n-ea', volume: 1 } as SoundStore, + default: { type: 'syuilo/n-ea', volume: 1 }, }, sound_reaction: { where: 'device', - default: { type: 'syuilo/bubble2', volume: 1 } as SoundStore, + default: { type: 'syuilo/bubble2', volume: 1 }, }, + dropAndFusion: { + where: 'device', + default: { + bgmVolume: 0.25, + sfxVolume: 1, + }, + }, + //#endregion })); // TODO: 他のタブと永続化されたstateを同期 const PREFIX = 'miux:' as const; -export type Plugin = { - id: string; - name: string; - active: boolean; - config?: Record<string, { default: any }>; - configData: Record<string, any>; - token: string; - src: string | null; - version: string; - ast: Ast.Node[]; - author?: string; - description?: string; - permissions?: string[]; -}; - interface Watcher { key: string; callback: (value: unknown) => void; } +// TODO: 消す(preferに移行済みのため) /** * 常にメモリにロードしておく必要がないような設定情報を保管するストレージ(非リアクティブ) */ - export class ColdDeviceStorage { public static default = { - lightTheme, - darkTheme, - syncDeviceDarkMode: true, - plugins: [] as Plugin[], + lightTheme, // TODO: 消す(preferに移行済みのため) + darkTheme, // TODO: 消す(preferに移行済みのため) + syncDeviceDarkMode: true, // TODO: 消す(preferに移行済みのため) + plugins: [] as Plugin[], // TODO: 消す(preferに移行済みのため) }; public static watchers: Watcher[] = []; diff --git a/packages/frontend/src/stream.ts b/packages/frontend/src/stream.ts index e63dac951c..25544d9d88 100644 --- a/packages/frontend/src/stream.ts +++ b/packages/frontend/src/stream.ts @@ -5,10 +5,10 @@ import * as Misskey from 'misskey-js'; import { markRaw } from 'vue'; -import { $i } from '@/account.js'; +import { $i } from '@/i.js'; import { wsOrigin } from '@@/js/config.js'; // TODO: No WebsocketモードでStreamMockが使えそう -//import { StreamMock } from '@/scripts/stream-mock.js'; +//import { StreamMock } from '@/utility/stream-mock.js'; // heart beat interval in ms const HEART_BEAT_INTERVAL = 1000 * 60; @@ -29,10 +29,10 @@ export function useStream(): Misskey.IStream { timeoutHeartBeat = window.setTimeout(heartbeat, HEART_BEAT_INTERVAL); // send heartbeat right now when last send time is over HEART_BEAT_INTERVAL - document.addEventListener('visibilitychange', () => { + window.document.addEventListener('visibilitychange', () => { if ( !stream - || document.visibilityState !== 'visible' + || window.document.visibilityState !== 'visible' || Date.now() - lastHeartbeatCall < HEART_BEAT_INTERVAL ) return; heartbeat(); @@ -42,7 +42,7 @@ export function useStream(): Misskey.IStream { } function heartbeat(): void { - if (stream != null && document.visibilityState === 'visible') { + if (stream != null && window.document.visibilityState === 'visible') { stream.heartbeat(); } lastHeartbeatCall = Date.now(); diff --git a/packages/frontend/src/style.scss b/packages/frontend/src/style.scss index 48aacf10bc..f3979fab1d 100644 --- a/packages/frontend/src/style.scss +++ b/packages/frontend/src/style.scss @@ -28,9 +28,6 @@ } html { - background-color: var(--MI_THEME-bg); - color: var(--MI_THEME-fg); - accent-color: var(--MI_THEME-accent); overflow: auto; overflow-wrap: break-word; font-family: 'Hiragino Maru Gothic Pro', "BIZ UDGothic", Roboto, HelveticaNeue, Arial, sans-serif; @@ -39,6 +36,11 @@ html { text-size-adjust: 100%; tab-size: 2; -webkit-text-size-adjust: 100%; + touch-action: manipulation; + scroll-behavior: smooth; + background-color: var(--MI_THEME-bg); + color: var(--MI_THEME-fg); + accent-color: var(--MI_THEME-accent); &, * { scrollbar-color: var(--MI_THEME-scrollbarHandle) transparent; @@ -81,6 +83,11 @@ html { &.useSystemFont { font-family: system-ui; } + + &:not(.forceSelectableAll) { + user-select: none; + -webkit-user-select: none; + } } html._themeChanging_ { @@ -89,11 +96,19 @@ html._themeChanging_ { } } -html, body { - touch-action: manipulation; +html, +body, +#misskey_app { margin: 0; padding: 0; - scroll-behavior: smooth; + width: 100%; + height: 100%; + overscroll-behavior: none; +} + +body { + /* NOTE: htmlにも overflow: clip を設定したいところだが、設定すると何故か少なくともChromeで html が main thread scrolling になりパフォーマンスが(多分)落ちる */ + overflow: clip; } a { @@ -108,10 +123,6 @@ a { outline-offset: 2px; } - &:hover { - text-decoration: underline; - } - &[target="_blank"] { -webkit-touch-callout: default; } @@ -120,6 +131,8 @@ a { textarea, input { tap-highlight-color: transparent; -webkit-tap-highlight-color: transparent; + user-select: text; + -webkit-user-select: text; } optgroup, option { @@ -128,8 +141,9 @@ optgroup, option { } hr { - margin: var(--MI-margin) 0 var(--MI-margin) 0; + margin: 0; border: none; + border-radius: 999px; height: 1px; background: var(--MI_THEME-divider); } @@ -162,6 +176,37 @@ rt { text-align: center; } +._pageContainer { + container-type: size; + contain: strict; + overflow: auto; + overscroll-behavior: contain; +} + +._pageScrollable { + height: 100%; + overflow: clip; + overflow-y: scroll; + overscroll-behavior: contain; + + /* + 理屈は知らないけど、ここでbackgroundを設定しておかないと + スクロールコンテナーが少なくともChromeにおいて + main thread scrolling になってしまい、パフォーマンスが(多分)落ちる。 + backgroundが透明だと裏側を描画しないといけなくなるとかそういう理由かもしれない + */ + background: var(--MI_THEME-bg); +} + +._pageScrollableReversed { + height: 100%; + overflow: clip; + overflow-y: scroll; + overscroll-behavior: contain; + display: flex; + flex-direction: column-reverse; +} + ._indicatorCircle { display: inline-block; width: 1em; @@ -183,6 +228,16 @@ rt { padding: 0.3em 0.5em; } +._selectable { + user-select: text; + -webkit-user-select: text; +} + +._selectableAtomic { + user-select: all; + -webkit-user-select: all; +} + ._noSelect { user-select: none; -webkit-user-select: none; @@ -196,11 +251,6 @@ rt { text-overflow: ellipsis; } -._ghost { - @extend ._noSelect; - pointer-events: none; -} - ._modalBg { position: fixed; top: 0; @@ -299,13 +349,13 @@ rt { ._gaps_m { display: flex; flex-direction: column; - gap: 1.5em; + gap: 21px; } ._gaps_s { display: flex; flex-direction: column; - gap: 0.75em; + gap: 10px; } ._gaps { @@ -348,7 +398,7 @@ rt { } ._acrylic { - background: var(--MI_THEME-acrylicPanel); + background: color(from var(--MI_THEME-panel) srgb r g b / 0.5); -webkit-backdrop-filter: var(--MI-blur, blur(15px)); backdrop-filter: var(--MI-blur, blur(15px)); } @@ -423,6 +473,10 @@ rt { color: var(--MI_THEME-link); } +._love { + color: var(--MI_THEME-love); +} + ._caption { font-size: 0.8em; opacity: 0.7; diff --git a/packages/frontend/src/tab-id.ts b/packages/frontend/src/tab-id.ts new file mode 100644 index 0000000000..49b69f72d2 --- /dev/null +++ b/packages/frontend/src/tab-id.ts @@ -0,0 +1,11 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { v4 as uuid } from 'uuid'; + +// HMR有効時にバグか知らんけど複数回実行されるのでその対策 +export const TAB_ID = window.sessionStorage.getItem('TAB_ID') ?? uuid(); +window.sessionStorage.setItem('TAB_ID', TAB_ID); +if (_DEV_) console.log('TAB_ID', TAB_ID); diff --git a/packages/frontend/src/theme-store.ts b/packages/frontend/src/theme-store.ts deleted file mode 100644 index fb010ae426..0000000000 --- a/packages/frontend/src/theme-store.ts +++ /dev/null @@ -1,48 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { getBuiltinThemes } from '@/scripts/theme.js'; -import type { Theme } from '@/scripts/theme.js'; -import { miLocalStorage } from '@/local-storage.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { $i } from '@/account.js'; - -const lsCacheKey = $i ? `themes:${$i.id}` as const : null; - -export function getThemes(): Theme[] { - if ($i == null) return []; - return JSON.parse(miLocalStorage.getItem(lsCacheKey!) ?? '[]'); -} - -export async function fetchThemes(): Promise<void> { - if ($i == null) return; - - try { - const themes = await misskeyApi('i/registry/get', { scope: ['client'], key: 'themes' }); - miLocalStorage.setItem(lsCacheKey!, JSON.stringify(themes)); - } catch (err) { - if (err.code === 'NO_SUCH_KEY') return; - throw err; - } -} - -export async function addTheme(theme: Theme): Promise<void> { - if ($i == null) return; - const builtinThemes = await getBuiltinThemes(); - if (builtinThemes.some(t => t.id === theme.id)) { - throw new Error('builtin theme'); - } - await fetchThemes(); - const themes = getThemes().concat(theme); - await misskeyApi('i/registry/set', { scope: ['client'], key: 'themes', value: themes }); - miLocalStorage.setItem(lsCacheKey!, JSON.stringify(themes)); -} - -export async function removeTheme(theme: Theme): Promise<void> { - if ($i == null) return; - const themes = getThemes().filter(t => t.id !== theme.id); - await misskeyApi('i/registry/set', { scope: ['client'], key: 'themes', value: themes }); - miLocalStorage.setItem(lsCacheKey!, JSON.stringify(themes)); -} diff --git a/packages/frontend/src/scripts/theme.ts b/packages/frontend/src/theme.ts index 1a3909c132..268f879d17 100644 --- a/packages/frontend/src/scripts/theme.ts +++ b/packages/frontend/src/theme.ts @@ -7,10 +7,14 @@ import { ref } from 'vue'; import tinycolor from 'tinycolor2'; import lightTheme from '@@/themes/_light.json5'; import darkTheme from '@@/themes/_dark.json5'; -import { deepClone } from './clone.js'; +import JSON5 from 'json5'; +import type { Ref } from 'vue'; import type { BundledTheme } from 'shiki/themes'; +import { deepClone } from '@/utility/clone.js'; import { globalEvents } from '@/events.js'; import { miLocalStorage } from '@/local-storage.js'; +import { $i } from '@/i.js'; +import { prefer } from '@/preferences.js'; export type Theme = { id: string; @@ -55,26 +59,52 @@ export const getBuiltinThemes = () => Promise.all( ].map(name => import(`@@/themes/${name}.json5`).then(({ default: _default }): Theme => _default)), ); -export const getBuiltinThemesRef = () => { +export function getBuiltinThemesRef() { const builtinThemes = ref<Theme[]>([]); getBuiltinThemes().then(themes => builtinThemes.value = themes); return builtinThemes; -}; +} + +export function getThemesRef(): Ref<Theme[]> { + return prefer.r.themes; +} + +export async function addTheme(theme: Theme): Promise<void> { + if ($i == null) return; + const builtinThemes = await getBuiltinThemes(); + if (builtinThemes.some(t => t.id === theme.id)) { + throw new Error('builtin theme'); + } + const themes = prefer.s.themes; + if (themes.some(t => t.id === theme.id)) { + throw new Error('already exists'); + } + prefer.commit('themes', [...themes, theme]); +} + +export async function removeTheme(theme: Theme): Promise<void> { + if ($i == null) return; + const themes = prefer.s.themes.filter(t => t.id !== theme.id); + prefer.commit('themes', themes); +} let timeout: number | null = null; export function applyTheme(theme: Theme, persist = true) { if (timeout) window.clearTimeout(timeout); - document.documentElement.classList.add('_themeChanging_'); + window.document.documentElement.classList.add('_themeChanging_'); timeout = window.setTimeout(() => { - document.documentElement.classList.remove('_themeChanging_'); + window.document.documentElement.classList.remove('_themeChanging_'); + + // 色計算など再度行えるようにクライアント全体に通知 + globalEvents.emit('themeChanged'); }, 1000); const colorScheme = theme.base === 'dark' ? 'dark' : 'light'; - document.documentElement.dataset.colorScheme = colorScheme; + window.document.documentElement.dataset.colorScheme = colorScheme; // Deep copy const _theme = deepClone(theme); @@ -86,7 +116,7 @@ export function applyTheme(theme: Theme, persist = true) { const props = compile(_theme); - for (const tag of document.head.children) { + for (const tag of window.document.head.children) { if (tag.tagName === 'META' && tag.getAttribute('name') === 'theme-color') { tag.setAttribute('content', props['htmlThemeColor']); break; @@ -94,21 +124,22 @@ export function applyTheme(theme: Theme, persist = true) { } for (const [k, v] of Object.entries(props)) { - document.documentElement.style.setProperty(`--MI_THEME-${k}`, v.toString()); + window.document.documentElement.style.setProperty(`--MI_THEME-${k}`, v.toString()); } - document.documentElement.style.setProperty('color-scheme', colorScheme); + window.document.documentElement.style.setProperty('color-scheme', colorScheme); if (persist) { miLocalStorage.setItem('theme', JSON.stringify(props)); + miLocalStorage.setItem('themeId', theme.id); miLocalStorage.setItem('colorScheme', colorScheme); } // 色計算など再度行えるようにクライアント全体に通知 - globalEvents.emit('themeChanged'); + globalEvents.emit('themeChanging'); } -function compile(theme: Theme): Record<string, string> { +export function compile(theme: Theme): Record<string, string> { function getColor(val: string): tinycolor.Instance { if (val[0] === '@') { // ref (prop) return getColor(theme.props[val.substring(1)]); @@ -155,3 +186,32 @@ export function validateTheme(theme: Record<string, any>): boolean { if (theme.props == null || typeof theme.props !== 'object') return false; return true; } + +export function parseThemeCode(code: string): Theme { + let theme; + + try { + theme = JSON5.parse(code); + } catch (err) { + throw new Error('Failed to parse theme json'); + } + if (!validateTheme(theme)) { + throw new Error('This theme is invaild'); + } + if (prefer.s.themes.some(t => t.id === theme.id)) { + throw new Error('This theme is already installed'); + } + + return theme; +} + +export function previewTheme(code: string): void { + const theme = parseThemeCode(code); + if (theme) applyTheme(theme, false); +} + +export async function installTheme(code: string): Promise<void> { + const theme = parseThemeCode(code); + if (!theme) return; + await addTheme(theme); +} diff --git a/packages/frontend/src/timelines.ts b/packages/frontend/src/timelines.ts index 94eda3545e..a39ccd481d 100644 --- a/packages/frontend/src/timelines.ts +++ b/packages/frontend/src/timelines.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { $i } from '@/account.js'; +import { $i } from '@/i.js'; import { instance } from '@/instance.js'; export const basicTimelineTypes = [ diff --git a/packages/frontend/src/types/menu.ts b/packages/frontend/src/types/menu.ts index 7cadef136d..820759ce61 100644 --- a/packages/frontend/src/types/menu.ts +++ b/packages/frontend/src/types/menu.ts @@ -4,7 +4,10 @@ */ import * as Misskey from 'misskey-js'; -import type { ComputedRef, Ref } from 'vue'; +import type { Component, ComputedRef, Ref } from 'vue'; +import type { ComponentProps as CP } from 'vue-component-type-helpers'; + +type ComponentProps<T extends Component> = { [K in keyof CP<T>]: CP<T>[K] | Ref<CP<T>[K]> }; type MenuRadioOptionsDef = Record<string, any>; @@ -12,19 +15,20 @@ export type MenuAction = (ev: MouseEvent) => void; export type MenuDivider = { type: 'divider' }; export type MenuNull = undefined; -export type MenuLabel = { type: 'label', text: string }; -export type MenuLink = { type: 'link', to: string, text: string, icon?: string, indicate?: boolean, avatar?: Misskey.entities.User }; -export type MenuA = { type: 'a', href: string, target?: string, download?: string, text: string, icon?: string, indicate?: boolean }; +export type MenuLabel = { type: 'label', text: string, caption?: string }; +export type MenuLink = { type: 'link', to: string, text: string, caption?: string, icon?: string, indicate?: boolean, avatar?: Misskey.entities.User }; +export type MenuA = { type: 'a', href: string, target?: string, download?: string, text: string, caption?: string, icon?: string, indicate?: boolean }; export type MenuUser = { type: 'user', user: Misskey.entities.User, active?: boolean, indicate?: boolean, action: MenuAction }; -export type MenuSwitch = { type: 'switch', ref: Ref<boolean>, text: string, icon?: string, disabled?: boolean | Ref<boolean> }; -export type MenuButton = { type?: 'button', text: string, icon?: string, indicate?: boolean, danger?: boolean, active?: boolean | ComputedRef<boolean>, avatar?: Misskey.entities.User; action: MenuAction }; -export type MenuRadio = { type: 'radio', text: string, icon?: string, ref: Ref<MenuRadioOptionsDef[keyof MenuRadioOptionsDef]>, options: MenuRadioOptionsDef, disabled?: boolean | Ref<boolean> }; -export type MenuRadioOption = { type: 'radioOption', text: string, action: MenuAction; active?: boolean | ComputedRef<boolean> }; -export type MenuParent = { type: 'parent', text: string, icon?: string, children: MenuItem[] | (() => Promise<MenuItem[]> | MenuItem[]) }; +export type MenuSwitch = { type: 'switch', ref: Ref<boolean>, text: string, caption?: string, icon?: string, disabled?: boolean | Ref<boolean> }; +export type MenuButton = { type?: 'button', text: string, caption?: string, icon?: string, indicate?: boolean, danger?: boolean, active?: boolean | ComputedRef<boolean>, avatar?: Misskey.entities.User; action: MenuAction }; +export type MenuRadio = { type: 'radio', text: string, caption?: string, icon?: string, ref: Ref<MenuRadioOptionsDef[keyof MenuRadioOptionsDef]>, options: MenuRadioOptionsDef, disabled?: boolean | Ref<boolean> }; +export type MenuRadioOption = { type: 'radioOption', text: string, caption?: string, action: MenuAction; active?: boolean | ComputedRef<boolean> }; +export type MenuComponent<T extends Component = any> = { type: 'component', component: T, props?: ComponentProps<T> }; +export type MenuParent = { type: 'parent', text: string, caption?: string, icon?: string, children: MenuItem[] | (() => Promise<MenuItem[]> | MenuItem[]) }; export type MenuPending = { type: 'pending' }; -type OuterMenuItem = MenuDivider | MenuNull | MenuLabel | MenuLink | MenuA | MenuUser | MenuSwitch | MenuButton | MenuRadio | MenuRadioOption | MenuParent; -type OuterPromiseMenuItem = Promise<MenuLabel | MenuLink | MenuA | MenuUser | MenuSwitch | MenuButton | MenuParent>; +type OuterMenuItem = MenuDivider | MenuNull | MenuLabel | MenuLink | MenuA | MenuUser | MenuSwitch | MenuButton | MenuRadio | MenuRadioOption | MenuComponent | MenuParent; +type OuterPromiseMenuItem = Promise<MenuLabel | MenuLink | MenuA | MenuUser | MenuSwitch | MenuButton | MenuComponent | MenuParent>; export type MenuItem = OuterMenuItem | OuterPromiseMenuItem; -export type InnerMenuItem = MenuDivider | MenuPending | MenuLabel | MenuLink | MenuA | MenuUser | MenuSwitch | MenuButton | MenuRadio | MenuRadioOption | MenuParent; +export type InnerMenuItem = MenuDivider | MenuPending | MenuLabel | MenuLink | MenuA | MenuUser | MenuSwitch | MenuButton | MenuRadio | MenuRadioOption | MenuComponent | MenuParent; diff --git a/packages/frontend/src/ui/_common_/PreferenceRestore.vue b/packages/frontend/src/ui/_common_/PreferenceRestore.vue new file mode 100644 index 0000000000..5fd9f5e44b --- /dev/null +++ b/packages/frontend/src/ui/_common_/PreferenceRestore.vue @@ -0,0 +1,64 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div :class="$style.root"> + <span :class="$style.icon"> + <i class="ti ti-info-circle"></i> + </span> + <span :class="$style.title">{{ i18n.ts._preferencesBackup.backupFound }}</span> + <span :class="$style.body"><button class="_textButton" @click="restore">{{ i18n.ts.restore }}</button> | <button class="_textButton" @click="skip">{{ i18n.ts.skip }}</button></span> +</div> +</template> + +<script lang="ts" setup> +import { $i } from '@/i.js'; +import { i18n } from '@/i18n.js'; +import { hideRestoreBackupSuggestion, restoreFromCloudBackup } from '@/preferences/utility.js'; + +function restore() { + restoreFromCloudBackup(); +} + +function skip() { + hideRestoreBackupSuggestion(); +} +</script> + +<style lang="scss" module> +.root { + --height: 24px; + font-size: 0.85em; + display: flex; + vertical-align: bottom; + width: 100%; + line-height: var(--height); + height: var(--height); + overflow: clip; + contain: strict; + background: var(--MI_THEME-panel); +} + +.icon { + margin-left: 10px; +} + +.title { + padding: 0 10px; + font-weight: bold; + + &:empty { + display: none; + } +} + +.body { + min-width: 0; + flex: 1; + overflow: clip; + white-space: nowrap; + text-overflow: ellipsis; +} +</style> diff --git a/packages/frontend/src/ui/_common_/announcements.vue b/packages/frontend/src/ui/_common_/announcements.vue index d153dc8726..f9af8e1ee7 100644 --- a/packages/frontend/src/ui/_common_/announcements.vue +++ b/packages/frontend/src/ui/_common_/announcements.vue @@ -24,7 +24,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { $i } from '@/account.js'; +import { $i } from '@/i.js'; </script> <style lang="scss" module> diff --git a/packages/frontend/src/ui/_common_/common.ts b/packages/frontend/src/ui/_common_/common.ts index 8e5ba8927a..819e1fa42f 100644 --- a/packages/frontend/src/ui/_common_/common.ts +++ b/packages/frontend/src/ui/_common_/common.ts @@ -9,7 +9,7 @@ import * as os from '@/os.js'; import { instance } from '@/instance.js'; import { host } from '@@/js/config.js'; import { i18n } from '@/i18n.js'; -import { $i } from '@/account.js'; +import { $i } from '@/i.js'; function toolsMenuItems(): MenuItem[] { return [{ diff --git a/packages/frontend/src/ui/_common_/common.vue b/packages/frontend/src/ui/_common_/common.vue index d145b9b6c6..d7d89d3f5c 100644 --- a/packages/frontend/src/ui/_common_/common.vue +++ b/packages/frontend/src/ui/_common_/common.vue @@ -4,6 +4,59 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> +<Transition + :enterActiveClass="prefer.s.animation ? $style.transition_menuDrawerBg_enterActive : ''" + :leaveActiveClass="prefer.s.animation ? $style.transition_menuDrawerBg_leaveActive : ''" + :enterFromClass="prefer.s.animation ? $style.transition_menuDrawerBg_enterFrom : ''" + :leaveToClass="prefer.s.animation ? $style.transition_menuDrawerBg_leaveTo : ''" +> + <div + v-if="drawerMenuShowing" + :class="$style.menuDrawerBg" + class="_modalBg" + @click="drawerMenuShowing = false" + @touchstart.passive="drawerMenuShowing = false" + ></div> +</Transition> + +<Transition + :enterActiveClass="prefer.s.animation ? $style.transition_menuDrawer_enterActive : ''" + :leaveActiveClass="prefer.s.animation ? $style.transition_menuDrawer_leaveActive : ''" + :enterFromClass="prefer.s.animation ? $style.transition_menuDrawer_enterFrom : ''" + :leaveToClass="prefer.s.animation ? $style.transition_menuDrawer_leaveTo : ''" +> + <div v-if="drawerMenuShowing" :class="$style.menuDrawer"> + <XDrawerMenu/> + </div> +</Transition> + +<Transition + :enterActiveClass="prefer.s.animation ? $style.transition_widgetsDrawerBg_enterActive : ''" + :leaveActiveClass="prefer.s.animation ? $style.transition_widgetsDrawerBg_leaveActive : ''" + :enterFromClass="prefer.s.animation ? $style.transition_widgetsDrawerBg_enterFrom : ''" + :leaveToClass="prefer.s.animation ? $style.transition_widgetsDrawerBg_leaveTo : ''" +> + <div + v-if="widgetsShowing" + :class="$style.widgetsDrawerBg" + class="_modalBg" + @click="widgetsShowing = false" + @touchstart.passive="widgetsShowing = false" + ></div> +</Transition> + +<Transition + :enterActiveClass="prefer.s.animation ? $style.transition_widgetsDrawer_enterActive : ''" + :leaveActiveClass="prefer.s.animation ? $style.transition_widgetsDrawer_leaveActive : ''" + :enterFromClass="prefer.s.animation ? $style.transition_widgetsDrawer_enterFrom : ''" + :leaveToClass="prefer.s.animation ? $style.transition_widgetsDrawer_leaveTo : ''" +> + <div v-if="widgetsShowing" :class="$style.widgetsDrawer"> + <button class="_button" :class="$style.widgetsCloseButton" @click="widgetsShowing = false"><i class="ti ti-x"></i></button> + <XWidgets/> + </div> +</Transition> + <component :is="popup.component" v-for="popup in popups" @@ -14,26 +67,27 @@ SPDX-License-Identifier: AGPL-3.0-only <XUpload v-if="uploads.length > 0"/> -<TransitionGroup +<component + :is="prefer.s.animation ? TransitionGroup : 'div'" tag="div" :class="[$style.notifications, { - [$style.notificationsPosition_leftTop]: defaultStore.state.notificationPosition === 'leftTop', - [$style.notificationsPosition_leftBottom]: defaultStore.state.notificationPosition === 'leftBottom', - [$style.notificationsPosition_rightTop]: defaultStore.state.notificationPosition === 'rightTop', - [$style.notificationsPosition_rightBottom]: defaultStore.state.notificationPosition === 'rightBottom', - [$style.notificationsStackAxis_vertical]: defaultStore.state.notificationStackAxis === 'vertical', - [$style.notificationsStackAxis_horizontal]: defaultStore.state.notificationStackAxis === 'horizontal', + [$style.notificationsPosition_leftTop]: prefer.s.notificationPosition === 'leftTop', + [$style.notificationsPosition_leftBottom]: prefer.s.notificationPosition === 'leftBottom', + [$style.notificationsPosition_rightTop]: prefer.s.notificationPosition === 'rightTop', + [$style.notificationsPosition_rightBottom]: prefer.s.notificationPosition === 'rightBottom', + [$style.notificationsStackAxis_vertical]: prefer.s.notificationStackAxis === 'vertical', + [$style.notificationsStackAxis_horizontal]: prefer.s.notificationStackAxis === 'horizontal', }]" - :moveClass="defaultStore.state.animation ? $style.transition_notification_move : ''" - :enterActiveClass="defaultStore.state.animation ? $style.transition_notification_enterActive : ''" - :leaveActiveClass="defaultStore.state.animation ? $style.transition_notification_leaveActive : ''" - :enterFromClass="defaultStore.state.animation ? $style.transition_notification_enterFrom : ''" - :leaveToClass="defaultStore.state.animation ? $style.transition_notification_leaveTo : ''" + :moveClass="$style.transition_notification_move" + :enterActiveClass="$style.transition_notification_enterActive" + :leaveActiveClass="$style.transition_notification_leaveActive" + :enterFromClass="$style.transition_notification_enterFrom" + :leaveToClass="$style.transition_notification_leaveTo" > <div v-for="notification in notifications" :key="notification.id" :class="$style.notification"> <XNotification :notification="notification"/> </div> -</TransitionGroup> +</component> <XStreamIndicator/> @@ -45,29 +99,34 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { defineAsyncComponent, ref } from 'vue'; +import { defineAsyncComponent, ref, TransitionGroup } from 'vue'; import * as Misskey from 'misskey-js'; import { swInject } from './sw-inject.js'; import XNotification from './notification.vue'; import { popups } from '@/os.js'; -import { pendingApiRequestsCount } from '@/scripts/misskey-api.js'; -import { uploads } from '@/scripts/upload.js'; -import * as sound from '@/scripts/sound.js'; -import { $i } from '@/account.js'; +import { pendingApiRequestsCount } from '@/utility/misskey-api.js'; +import { uploads } from '@/utility/upload.js'; +import * as sound from '@/utility/sound.js'; +import { $i } from '@/i.js'; import { useStream } from '@/stream.js'; import { i18n } from '@/i18n.js'; -import { defaultStore } from '@/store.js'; +import { prefer } from '@/preferences.js'; import { globalEvents } from '@/events.js'; +import XDrawerMenu from '@/ui/_common_/navbar-for-mobile.vue'; const XStreamIndicator = defineAsyncComponent(() => import('./stream-indicator.vue')); const XUpload = defineAsyncComponent(() => import('./upload.vue')); +const XWidgets = defineAsyncComponent(() => import('./widgets.vue')); + +const drawerMenuShowing = defineModel<boolean>('drawerMenuShowing'); +const widgetsShowing = defineModel<boolean>('widgetsShowing'); const dev = _DEV_; const notifications = ref<Misskey.entities.Notification[]>([]); function onNotification(notification: Misskey.entities.Notification, isClient = false) { - if (document.visibilityState === 'visible') { + if (window.document.visibilityState === 'visible') { if (!isClient && notification.type !== 'test') { // サーバーサイドのテスト通知の際は自動で既読をつけない(テストできないので) useStream().send('readNotification'); @@ -99,6 +158,50 @@ if ($i) { </script> <style lang="scss" module> +.transition_menuDrawerBg_enterActive, +.transition_menuDrawerBg_leaveActive { + opacity: 1; + transition: opacity 300ms cubic-bezier(0.23, 1, 0.32, 1); +} +.transition_menuDrawerBg_enterFrom, +.transition_menuDrawerBg_leaveTo { + opacity: 0; +} + +.transition_menuDrawer_enterActive, +.transition_menuDrawer_leaveActive { + opacity: 1; + transform: translateX(0); + transition: transform 300ms cubic-bezier(0.23, 1, 0.32, 1), opacity 300ms cubic-bezier(0.23, 1, 0.32, 1); +} +.transition_menuDrawer_enterFrom, +.transition_menuDrawer_leaveTo { + opacity: 0; + transform: translateX(-240px); +} + +.transition_widgetsDrawerBg_enterActive, +.transition_widgetsDrawerBg_leaveActive { + opacity: 1; + transition: opacity 300ms cubic-bezier(0.23, 1, 0.32, 1); +} +.transition_widgetsDrawerBg_enterFrom, +.transition_widgetsDrawerBg_leaveTo { + opacity: 0; +} + +.transition_widgetsDrawer_enterActive, +.transition_widgetsDrawer_leaveActive { + opacity: 1; + transform: translateX(0); + transition: transform 300ms cubic-bezier(0.23, 1, 0.32, 1), opacity 300ms cubic-bezier(0.23, 1, 0.32, 1); +} +.transition_widgetsDrawer_enterFrom, +.transition_widgetsDrawer_leaveTo { + opacity: 0; + transform: translateX(-240px); +} + .transition_notification_move, .transition_notification_enterActive, .transition_notification_leaveActive { @@ -113,6 +216,54 @@ if ($i) { transform: translateX(-250px); } +.menuDrawerBg { + z-index: 1001; +} + +.menuDrawer { + position: fixed; + top: 0; + left: 0; + z-index: 1001; + height: 100dvh; + width: 240px; + box-sizing: border-box; + contain: strict; + overflow: auto; + overscroll-behavior: contain; + background: var(--MI_THEME-navBg); +} + +.widgetsDrawerBg { + z-index: 1001; +} + +.widgetsDrawer { + position: fixed; + top: 0; + left: 0; + z-index: 1001; + width: 310px; + height: 100dvh; + padding: var(--MI-margin) var(--MI-margin) calc(var(--MI-margin) + env(safe-area-inset-bottom, 0px)) !important; + box-sizing: border-box; + overflow: auto; + overscroll-behavior: contain; + background: var(--MI_THEME-bg); +} + +.widgetsCloseButton { + padding: 8px; + display: block; + margin: 0 auto; +} + +@media (min-width: 370px) { + .widgetsCloseButton { + display: none; + } +} + .notifications { position: fixed; z-index: 3900000; diff --git a/packages/frontend/src/ui/_common_/mobile-footer-menu.vue b/packages/frontend/src/ui/_common_/mobile-footer-menu.vue new file mode 100644 index 0000000000..37b70847ca --- /dev/null +++ b/packages/frontend/src/ui/_common_/mobile-footer-menu.vue @@ -0,0 +1,144 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div ref="rootEl" :class="$style.root"> + <button :class="$style.item" class="_button" @click="drawerMenuShowing = true"> + <div :class="$style.itemInner"> + <i :class="$style.itemIcon" class="ti ti-menu-2"></i><span v-if="menuIndicated" :class="$style.itemIndicator" class="_blink"><i class="_indicatorCircle"></i></span> + </div> + </button> + + <button :class="$style.item" class="_button" @click="mainRouter.push('/')"> + <div :class="$style.itemInner"> + <i :class="$style.itemIcon" class="ti ti-home"></i> + </div> + </button> + + <button :class="$style.item" class="_button" @click="mainRouter.push('/my/notifications')"> + <div :class="$style.itemInner"> + <i :class="$style.itemIcon" class="ti ti-bell"></i> + <span v-if="$i?.hasUnreadNotification" :class="$style.itemIndicator" class="_blink"> + <span class="_indicateCounter" :class="$style.itemIndicateValueIcon">{{ $i.unreadNotificationsCount > 99 ? '99+' : $i.unreadNotificationsCount }}</span> + </span> + </div> + </button> + + <button :class="$style.item" class="_button" @click="widgetsShowing = true"> + <div :class="$style.itemInner"> + <i :class="$style.itemIcon" class="ti ti-apps"></i> + </div> + </button> + + <button :class="[$style.item, $style.post]" class="_button" @click="os.post()"> + <div :class="$style.itemInner"> + <i :class="$style.itemIcon" class="ti ti-pencil"></i> + </div> + </button> +</div> +</template> + +<script lang="ts" setup> +import { computed, ref, useTemplateRef, watch } from 'vue'; +import { $i } from '@/i.js'; +import * as os from '@/os.js'; +import { mainRouter } from '@/router.js'; +import { navbarItemDef } from '@/navbar.js'; + +const drawerMenuShowing = defineModel<boolean>('drawerMenuShowing'); +const widgetsShowing = defineModel<boolean>('widgetsShowing'); + +const rootEl = useTemplateRef('rootEl'); + +const menuIndicated = computed(() => { + for (const def in navbarItemDef) { + if (def === 'notifications') continue; // 通知は下にボタンとして表示されてるから + if (navbarItemDef[def].indicated) return true; + } + return false; +}); + +const rootElHeight = ref(0); + +watch(rootEl, () => { + if (rootEl.value) { + rootElHeight.value = rootEl.value.offsetHeight; + window.document.body.style.setProperty('--MI-minBottomSpacing', 'var(--MI-minBottomSpacingMobile)'); + } else { + rootElHeight.value = 0; + window.document.body.style.setProperty('--MI-minBottomSpacing', '0px'); + } +}, { + immediate: true, +}); +</script> + +<style lang="scss" module> +.root { + padding: 12px 12px max(12px, env(safe-area-inset-bottom, 0px)) 12px; + display: grid; + grid-template-columns: 1fr 1fr 1fr 1fr 1fr; + grid-gap: 8px; + width: 100%; + box-sizing: border-box; + background: var(--MI_THEME-bg); + border-top: solid 0.5px var(--MI_THEME-divider); +} + +.item { + &.post { + .itemInner { + background: linear-gradient(90deg, var(--MI_THEME-buttonGradateA), var(--MI_THEME-buttonGradateB)); + color: var(--MI_THEME-fgOnAccent); + + &:hover { + background: linear-gradient(90deg, hsl(from var(--MI_THEME-accent) h s calc(l + 5)), hsl(from var(--MI_THEME-accent) h s calc(l + 5))); + } + + &:active { + background: linear-gradient(90deg, hsl(from var(--MI_THEME-accent) h s calc(l + 5)), hsl(from var(--MI_THEME-accent) h s calc(l + 5))); + } + } + } +} + +.itemInner { + position: relative; + padding: 0; + aspect-ratio: 1; + width: 100%; + max-width: 50px; + margin: auto; + align-content: center; + border-radius: 100%; + background: var(--MI_THEME-panel); + color: var(--MI_THEME-fg); + + &:hover { + background: var(--MI_THEME-panelHighlight); + } + + &:active { + background: hsl(from var(--MI_THEME-panel) h s calc(l - 2)); + } +} + +.itemIcon { + font-size: 14px; +} + +.itemIndicator { + position: absolute; + top: 0; + left: 0; + color: var(--MI_THEME-indicator); + font-size: 16px; + + &:has(.itemIndicateValueIcon) { + animation: none; + font-size: 12px; + } +} +</style> diff --git a/packages/frontend/src/ui/_common_/navbar-for-mobile.vue b/packages/frontend/src/ui/_common_/navbar-for-mobile.vue index 44253e93bd..e0cd58439e 100644 --- a/packages/frontend/src/ui/_common_/navbar-for-mobile.vue +++ b/packages/frontend/src/ui/_common_/navbar-for-mobile.vue @@ -15,7 +15,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkA :class="$style.item" :activeClass="$style.active" to="/" exact> <i :class="$style.itemIcon" class="ti ti-home ti-fw"></i><span :class="$style.itemText">{{ i18n.ts.timeline }}</span> </MkA> - <template v-for="item in menu"> + <template v-for="item in prefer.r.menu.value"> <div v-if="item === '-'" :class="$style.divider"></div> <component :is="navbarItemDef[item].to ? 'MkA' : 'button'" v-else-if="navbarItemDef[item] && (navbarItemDef[item].show !== false)" class="_button" :class="[$style.item, { [$style.active]: navbarItemDef[item].active }]" :activeClass="$style.active" :to="navbarItemDef[item].to" v-on="navbarItemDef[item].action ? { click: navbarItemDef[item].action } : {}"> <i class="ti-fw" :class="[$style.itemIcon, navbarItemDef[item].icon]"></i><span :class="$style.itemText">{{ navbarItemDef[item].title }}</span> @@ -49,19 +49,19 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { computed, defineAsyncComponent, toRef } from 'vue'; +import { computed, defineAsyncComponent } from 'vue'; import { openInstanceMenu } from './common.js'; import * as os from '@/os.js'; import { navbarItemDef } from '@/navbar.js'; -import { $i, openAccountMenu as openAccountMenu_ } from '@/account.js'; -import { defaultStore } from '@/store.js'; +import { prefer } from '@/preferences.js'; import { i18n } from '@/i18n.js'; import { instance } from '@/instance.js'; +import { openAccountMenu as openAccountMenu_ } from '@/accounts.js'; +import { $i } from '@/i.js'; -const menu = toRef(defaultStore.state, 'menu'); const otherMenuItemIndicated = computed(() => { for (const def in navbarItemDef) { - if (menu.value.includes(def)) continue; + if (prefer.r.menu.value.includes(def)) continue; if (navbarItemDef[def].indicated) return true; } return false; @@ -158,7 +158,7 @@ function more() { &:hover, &.active { &::before { - background: var(--MI_THEME-accentLighten); + background: hsl(from var(--MI_THEME-accent) h s calc(l + 10)); } } } @@ -220,7 +220,7 @@ function more() { &:hover { text-decoration: none; - color: var(--MI_THEME-navHoverFg); + color: light-dark(hsl(from var(--MI_THEME-navFg) h s calc(l - 17)), hsl(from var(--MI_THEME-navFg) h s calc(l + 17))); } &.active { diff --git a/packages/frontend/src/ui/classic.header.vue b/packages/frontend/src/ui/_common_/navbar-h.vue index f4633314ae..13fc592e70 100644 --- a/packages/frontend/src/ui/classic.header.vue +++ b/packages/frontend/src/ui/_common_/navbar-h.vue @@ -8,7 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div class="body"> <div class="left"> <button v-click-anime class="item _button instance" @click="openInstanceMenu"> - <img :src="instance.iconUrl ?? instance.faviconUrl ?? '/favicon.ico'" class="_ghost"/> + <img :src="instance.iconUrl ?? instance.faviconUrl ?? '/favicon.ico'" draggable="false"/> </button> <MkA v-click-anime v-tooltip="i18n.ts.timeline" class="item index" activeClass="active" to="/" exact> <i class="ti ti-home ti-fw"></i> @@ -48,20 +48,21 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { computed, defineAsyncComponent, onMounted, ref } from 'vue'; -import { openInstanceMenu } from './_common_/common.js'; +import { openInstanceMenu } from './common.js'; import * as os from '@/os.js'; import { navbarItemDef } from '@/navbar.js'; -import { openAccountMenu as openAccountMenu_, $i } from '@/account.js'; import MkButton from '@/components/MkButton.vue'; -import { defaultStore } from '@/store.js'; import { instance } from '@/instance.js'; import { i18n } from '@/i18n.js'; +import { prefer } from '@/preferences.js'; +import { openAccountMenu as openAccountMenu_ } from '@/accounts.js'; +import { $i } from '@/i.js'; const WINDOW_THRESHOLD = 1400; const settingsWindowed = ref(window.innerWidth > WINDOW_THRESHOLD); -const menu = ref(defaultStore.state.menu); -// const menuDisplay = computed(defaultStore.makeGetterSetter('menuDisplay')); +const menu = ref(prefer.s.menu); +// const menuDisplay = computed(store.makeGetterSetter('menuDisplay')); const otherNavItemIndicated = computed<boolean>(() => { for (const def in navbarItemDef) { if (menu.value.includes(def)) continue; @@ -104,7 +105,9 @@ onMounted(() => { z-index: 1000; width: 100%; height: $height; - background-color: var(--MI_THEME-bg); + background: color(from var(--MI_THEME-bg) srgb r g b / 0.75); + -webkit-backdrop-filter: var(--MI-blur, blur(15px)); + backdrop-filter: var(--MI-blur, blur(15px)); > .body { max-width: 1380px; @@ -146,7 +149,7 @@ onMounted(() => { &:hover { text-decoration: none; - color: var(--MI_THEME-navHoverFg); + color: light-dark(hsl(from var(--MI_THEME-navFg) h s calc(l - 17)), hsl(from var(--MI_THEME-navFg) h s calc(l + 17))); } &.active { diff --git a/packages/frontend/src/ui/_common_/navbar.vue b/packages/frontend/src/ui/_common_/navbar.vue index fec8666dc1..9c6cdecf5c 100644 --- a/packages/frontend/src/ui/_common_/navbar.vue +++ b/packages/frontend/src/ui/_common_/navbar.vue @@ -9,14 +9,14 @@ SPDX-License-Identifier: AGPL-3.0-only <div :class="$style.top"> <div :class="$style.banner" :style="{ backgroundImage: `url(${ instance.bannerUrl })` }"></div> <button v-tooltip.noDelay.right="instance.name ?? i18n.ts.instance" class="_button" :class="$style.instance" @click="openInstanceMenu"> - <img :src="instance.iconUrl || instance.faviconUrl || '/favicon.ico'" alt="" :class="$style.instanceIcon"/> + <img :src="instance.iconUrl || instance.faviconUrl || '/favicon.ico'" alt="" :class="$style.instanceIcon" style="viewTransitionName: navbar-serverIcon;"/> </button> </div> <div :class="$style.middle"> <MkA v-tooltip.noDelay.right="i18n.ts.timeline" :class="$style.item" :activeClass="$style.active" to="/" exact> - <i :class="$style.itemIcon" class="ti ti-home ti-fw"></i><span :class="$style.itemText">{{ i18n.ts.timeline }}</span> + <i :class="$style.itemIcon" class="ti ti-home ti-fw" style="viewTransitionName: navbar-homeIcon;"></i><span :class="$style.itemText">{{ i18n.ts.timeline }}</span> </MkA> - <template v-for="item in menu"> + <template v-for="item in prefer.r.menu.value"> <div v-if="item === '-'" :class="$style.divider"></div> <component :is="navbarItemDef[item].to ? 'MkA' : 'button'" @@ -28,7 +28,7 @@ SPDX-License-Identifier: AGPL-3.0-only :to="navbarItemDef[item].to" v-on="navbarItemDef[item].action ? { click: navbarItemDef[item].action } : {}" > - <i class="ti-fw" :class="[$style.itemIcon, navbarItemDef[item].icon]"></i><span :class="$style.itemText">{{ navbarItemDef[item].title }}</span> + <i class="ti-fw" :class="[$style.itemIcon, navbarItemDef[item].icon]" :style="{ viewTransitionName: 'navbar-item-' + item }"></i><span :class="$style.itemText">{{ navbarItemDef[item].title }}</span> <span v-if="navbarItemDef[item].indicated" :class="$style.itemIndicator" class="_blink"> <span v-if="navbarItemDef[item].indicateValue" class="_indicateCounter" :class="$style.itemIndicateValueIcon">{{ navbarItemDef[item].indicateValue }}</span> <i v-else class="_indicatorCircle"></i> @@ -37,22 +37,25 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <div :class="$style.divider"></div> <MkA v-if="$i != null && ($i.isAdmin || $i.isModerator)" v-tooltip.noDelay.right="i18n.ts.controlPanel" :class="$style.item" :activeClass="$style.active" to="/admin"> - <i :class="$style.itemIcon" class="ti ti-dashboard ti-fw"></i><span :class="$style.itemText">{{ i18n.ts.controlPanel }}</span> + <i :class="$style.itemIcon" class="ti ti-dashboard ti-fw" style="viewTransitionName: navbar-controlPanel;"></i><span :class="$style.itemText">{{ i18n.ts.controlPanel }}</span> </MkA> <button class="_button" :class="$style.item" @click="more"> - <i :class="$style.itemIcon" class="ti ti-grid-dots ti-fw"></i><span :class="$style.itemText">{{ i18n.ts.more }}</span> + <i :class="$style.itemIcon" class="ti ti-grid-dots ti-fw" style="viewTransitionName: navbar-more;"></i><span :class="$style.itemText">{{ i18n.ts.more }}</span> <span v-if="otherMenuItemIndicated" :class="$style.itemIndicator" class="_blink"><i class="_indicatorCircle"></i></span> </button> <MkA v-tooltip.noDelay.right="i18n.ts.settings" :class="$style.item" :activeClass="$style.active" to="/settings"> - <i :class="$style.itemIcon" class="ti ti-settings ti-fw"></i><span :class="$style.itemText">{{ i18n.ts.settings }}</span> + <i :class="$style.itemIcon" class="ti ti-settings ti-fw" style="viewTransitionName: navbar-settings;"></i><span :class="$style.itemText">{{ i18n.ts.settings }}</span> </MkA> </div> <div :class="$style.bottom"> + <button v-if="showWidgetButton" class="_button" :class="[$style.widget]" @click="() => emit('widgetButtonClick')"> + <i class="ti ti-apps ti-fw"></i> + </button> <button v-tooltip.noDelay.right="i18n.ts.note" class="_button" :class="[$style.post]" data-cy-open-post-form @click="() => { os.post(); }"> <i class="ti ti-pencil ti-fw" :class="$style.postIcon"></i><span :class="$style.postText">{{ i18n.ts.note }}</span> </button> <button v-if="$i != null" v-tooltip.noDelay.right="`${i18n.ts.account}: @${$i.username}`" class="_button" :class="[$style.account]" @click="openAccountMenu"> - <MkAvatar :user="$i" :class="$style.avatar"/><MkAcct class="_nowrap" :class="$style.acct" :user="$i"/> + <MkAvatar :user="$i" :class="$style.avatar" style="viewTransitionName: navbar-avatar;"/><MkAcct class="_nowrap" :class="$style.acct" :user="$i"/> </button> </div> </div> @@ -65,7 +68,7 @@ SPDX-License-Identifier: AGPL-3.0-only </svg> --> - <div :class="$style.subButtons"> + <div v-if="!forceIconOnly && prefer.r.showNavbarSubButtons.value" :class="$style.subButtons"> <div :class="[$style.subButton, $style.menuEditButton]"> <svg viewBox="0 0 16 64" :class="$style.subButtonShape"> <g transform="matrix(0.333333,0,0,0.222222,0.000895785,21.3333)"> @@ -74,9 +77,9 @@ SPDX-License-Identifier: AGPL-3.0-only </svg> <button class="_button" :class="$style.subButtonClickable" @click="menuEdit"><i :class="$style.subButtonIcon" class="ti ti-settings-2"></i></button> </div> - <div v-if="!forceIconOnly" :class="$style.subButtonGapFill"></div> - <div v-if="!forceIconOnly" :class="$style.subButtonGapFillDivider"></div> - <div v-if="!forceIconOnly" :class="[$style.subButton, $style.toggleButton]"> + <div :class="$style.subButtonGapFill"></div> + <div :class="$style.subButtonGapFillDivider"></div> + <div :class="[$style.subButton, $style.toggleButton]"> <svg viewBox="0 0 16 64" :class="$style.subButtonShape"> <g transform="matrix(0.333333,0,0,0.222222,0.000895785,21.3333)"> <path d="M47.488,7.995C47.79,10.11 47.943,12.266 47.943,14.429C47.997,26.989 47.997,84 47.997,84C47.997,84 44.018,118.246 23.997,133.5C-0.374,152.07 -0.003,192 -0.003,192L-0.003,-96C-0.003,-96 0.151,-56.216 23.997,-37.5C40.861,-24.265 46.043,-1.243 47.488,7.995Z" style="fill:var(--MI_THEME-navBg);"/> @@ -93,24 +96,33 @@ import { computed, defineAsyncComponent, ref, watch } from 'vue'; import { openInstanceMenu } from './common.js'; import * as os from '@/os.js'; import { navbarItemDef } from '@/navbar.js'; -import { $i, openAccountMenu as openAccountMenu_ } from '@/account.js'; -import { defaultStore } from '@/store.js'; +import { store } from '@/store.js'; import { i18n } from '@/i18n.js'; import { instance } from '@/instance.js'; -import { getHTMLElementOrNull } from '@/scripts/get-dom-node-or-null.js'; -import { useRouter } from '@/router/supplier.js'; +import { getHTMLElementOrNull } from '@/utility/get-dom-node-or-null.js'; +import { useRouter } from '@/router.js'; +import { prefer } from '@/preferences.js'; +import { openAccountMenu as openAccountMenu_ } from '@/accounts.js'; +import { $i } from '@/i.js'; const router = useRouter(); +const props = defineProps<{ + showWidgetButton?: boolean; +}>(); + +const emit = defineEmits<{ + (ev: 'widgetButtonClick'): void; +}>(); + const forceIconOnly = ref(window.innerWidth <= 1279); const iconOnly = computed(() => { - return forceIconOnly.value || (defaultStore.reactiveState.menuDisplay.value === 'sideIcon'); + return forceIconOnly.value || (store.r.menuDisplay.value === 'sideIcon'); }); -const menu = computed(() => defaultStore.state.menu); const otherMenuItemIndicated = computed(() => { for (const def in navbarItemDef) { - if (menu.value.includes(def)) continue; + if (prefer.r.menu.value.includes(def)) continue; if (navbarItemDef[def].indicated) return true; } return false; @@ -122,12 +134,18 @@ function calcViewState() { window.addEventListener('resize', calcViewState); -watch(defaultStore.reactiveState.menuDisplay, () => { +watch(store.r.menuDisplay, () => { calcViewState(); }); function toggleIconOnly() { - defaultStore.set('menuDisplay', iconOnly.value ? 'sideFull' : 'sideIcon'); + if (window.document.startViewTransition && prefer.s.animation) { + window.document.startViewTransition(() => { + store.set('menuDisplay', iconOnly.value ? 'sideFull' : 'sideIcon'); + }); + } else { + store.set('menuDisplay', iconOnly.value ? 'sideFull' : 'sideIcon'); + } } function openAccountMenu(ev: MouseEvent) { @@ -363,7 +381,7 @@ function menuEdit() { &:hover, &.active { &::before { - background: var(--MI_THEME-accentLighten); + background: hsl(from var(--MI_THEME-accent) h s calc(l + 10)); } } } @@ -437,7 +455,7 @@ function menuEdit() { &:hover { text-decoration: none; - color: var(--MI_THEME-navHoverFg); + color: light-dark(hsl(from var(--MI_THEME-navFg) h s calc(l - 17)), hsl(from var(--MI_THEME-navFg) h s calc(l + 17))); } &.active { @@ -552,6 +570,14 @@ function menuEdit() { backdrop-filter: var(--MI-blur, blur(8px)); } + .widget { + display: block; + position: relative; + width: 100%; + height: 52px; + text-align: center; + } + .post { display: block; position: relative; @@ -585,7 +611,7 @@ function menuEdit() { &:hover, &.active { &::before { - background: var(--MI_THEME-accentLighten); + background: hsl(from var(--MI_THEME-accent) h s calc(l + 10)); } } } diff --git a/packages/frontend/src/ui/_common_/statusbar-federation.vue b/packages/frontend/src/ui/_common_/statusbar-federation.vue index e234bb3a33..16e72fa227 100644 --- a/packages/frontend/src/ui/_common_/statusbar-federation.vue +++ b/packages/frontend/src/ui/_common_/statusbar-federation.vue @@ -34,9 +34,9 @@ SPDX-License-Identifier: AGPL-3.0-only import { ref } from 'vue'; import * as Misskey from 'misskey-js'; import MarqueeText from '@/components/MkMarquee.vue'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { useInterval } from '@@/js/use-interval.js'; -import { getProxiedImageUrlNullable } from '@/scripts/media-proxy.js'; +import { getProxiedImageUrlNullable } from '@/utility/media-proxy.js'; const props = defineProps<{ display?: 'marquee' | 'oneByOne'; diff --git a/packages/frontend/src/ui/_common_/statusbar-rss.vue b/packages/frontend/src/ui/_common_/statusbar-rss.vue index da8fa8bb21..4da89a181e 100644 --- a/packages/frontend/src/ui/_common_/statusbar-rss.vue +++ b/packages/frontend/src/ui/_common_/statusbar-rss.vue @@ -31,7 +31,7 @@ import { ref } from 'vue'; import * as Misskey from 'misskey-js'; import MarqueeText from '@/components/MkMarquee.vue'; import { useInterval } from '@@/js/use-interval.js'; -import { shuffle } from '@/scripts/shuffle.js'; +import { shuffle } from '@/utility/shuffle.js'; const props = defineProps<{ url?: string; diff --git a/packages/frontend/src/ui/_common_/statusbar-user-list.vue b/packages/frontend/src/ui/_common_/statusbar-user-list.vue index 078b595dca..c5bee51162 100644 --- a/packages/frontend/src/ui/_common_/statusbar-user-list.vue +++ b/packages/frontend/src/ui/_common_/statusbar-user-list.vue @@ -34,9 +34,9 @@ SPDX-License-Identifier: AGPL-3.0-only import { ref, watch } from 'vue'; import * as Misskey from 'misskey-js'; import MarqueeText from '@/components/MkMarquee.vue'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { useInterval } from '@@/js/use-interval.js'; -import { getNoteSummary } from '@/scripts/get-note-summary.js'; +import { getNoteSummary } from '@/utility/get-note-summary.js'; import { notePage } from '@/filters/note.js'; const props = defineProps<{ diff --git a/packages/frontend/src/ui/_common_/statusbars.vue b/packages/frontend/src/ui/_common_/statusbars.vue index ed881bef22..a8d87599e6 100644 --- a/packages/frontend/src/ui/_common_/statusbars.vue +++ b/packages/frontend/src/ui/_common_/statusbars.vue @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div :class="$style.root"> <div - v-for="x in defaultStore.reactiveState.statusbars.value" :key="x.id" :class="[$style.item, { [$style.black]: x.black, + v-for="x in prefer.r.statusbars.value" :key="x.id" :class="[$style.item, { [$style.black]: x.black, [$style.verySmall]: x.size === 'verySmall', [$style.small]: x.size === 'small', [$style.large]: x.size === 'large', @@ -24,7 +24,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { defineAsyncComponent } from 'vue'; import { instance } from '@/instance.js'; -import { defaultStore } from '@/store.js'; +import { prefer } from '@/preferences.js'; const XRss = defineAsyncComponent(() => import('./statusbar-rss.vue')); const XFederation = defineAsyncComponent(() => import('./statusbar-federation.vue')); const XUserList = defineAsyncComponent(() => import('./statusbar-user-list.vue')); diff --git a/packages/frontend/src/ui/_common_/stream-indicator.vue b/packages/frontend/src/ui/_common_/stream-indicator.vue index cc62a28b14..5f7600881f 100644 --- a/packages/frontend/src/ui/_common_/stream-indicator.vue +++ b/packages/frontend/src/ui/_common_/stream-indicator.vue @@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div v-if="hasDisconnected && defaultStore.state.serverDisconnectedBehavior === 'quiet'" :class="$style.root" class="_panel _shadow" @click="resetDisconnected"> +<div v-if="hasDisconnected && prefer.s.serverDisconnectedBehavior === 'quiet'" :class="$style.root" class="_panel _shadow" @click="resetDisconnected"> <div><i class="ti ti-alert-triangle"></i> {{ i18n.ts.disconnectedFromServer }}</div> <div :class="$style.command" class="_buttons"> <MkButton small primary @click="reload">{{ i18n.ts.reload }}</MkButton> @@ -19,7 +19,7 @@ import { useStream } from '@/stream.js'; import { i18n } from '@/i18n.js'; import MkButton from '@/components/MkButton.vue'; import * as os from '@/os.js'; -import { defaultStore } from '@/store.js'; +import { prefer } from '@/preferences.js'; const zIndex = os.claimZIndex('high'); @@ -34,7 +34,7 @@ function resetDisconnected() { } function reload() { - location.reload(); + window.location.reload(); } useStream().on('_disconnected_', onDisconnected); diff --git a/packages/frontend/src/ui/_common_/sw-inject.ts b/packages/frontend/src/ui/_common_/sw-inject.ts index ff851ad99f..1459881ba1 100644 --- a/packages/frontend/src/ui/_common_/sw-inject.ts +++ b/packages/frontend/src/ui/_common_/sw-inject.ts @@ -4,11 +4,12 @@ */ import { post } from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { $i, login } from '@/account.js'; -import { getAccountFromId } from '@/scripts/get-account-from-id.js'; -import { deepClone } from '@/scripts/clone.js'; -import { mainRouter } from '@/router/main.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { $i } from '@/i.js'; +import { getAccountFromId } from '@/utility/get-account-from-id.js'; +import { deepClone } from '@/utility/clone.js'; +import { mainRouter } from '@/router.js'; +import { login } from '@/accounts.js'; export function swInject() { navigator.serviceWorker.addEventListener('message', async ev => { diff --git a/packages/frontend/src/ui/_common_/upload.vue b/packages/frontend/src/ui/_common_/upload.vue index c7d1387eae..3e5653e46d 100644 --- a/packages/frontend/src/ui/_common_/upload.vue +++ b/packages/frontend/src/ui/_common_/upload.vue @@ -25,7 +25,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { } from 'vue'; import * as os from '@/os.js'; -import { uploads } from '@/scripts/upload.js'; +import { uploads } from '@/utility/upload.js'; import { i18n } from '@/i18n.js'; const zIndex = os.claimZIndex('high'); diff --git a/packages/frontend/src/ui/universal.widgets.vue b/packages/frontend/src/ui/_common_/widgets.vue index fc0a4475d2..1a6d62e19b 100644 --- a/packages/frontend/src/ui/universal.widgets.vue +++ b/packages/frontend/src/ui/_common_/widgets.vue @@ -19,7 +19,7 @@ const editMode = ref(false); <script lang="ts" setup> import XWidgets from '@/components/MkWidgets.vue'; import { i18n } from '@/i18n.js'; -import { defaultStore } from '@/store.js'; +import { prefer } from '@/preferences.js'; const props = withDefaults(defineProps<{ // null = 全てのウィジェットを表示 @@ -31,24 +31,24 @@ const props = withDefaults(defineProps<{ }); const widgets = computed(() => { - if (props.place === null) return defaultStore.reactiveState.widgets.value; - if (props.place === 'left') return defaultStore.reactiveState.widgets.value.filter(w => w.place === 'left'); - return defaultStore.reactiveState.widgets.value.filter(w => w.place !== 'left'); + if (props.place === null) return prefer.r.widgets.value; + if (props.place === 'left') return prefer.r.widgets.value.filter(w => w.place === 'left'); + return prefer.r.widgets.value.filter(w => w.place !== 'left'); }); function addWidget(widget) { - defaultStore.set('widgets', [{ + prefer.commit('widgets', [{ ...widget, place: props.place, - }, ...defaultStore.state.widgets]); + }, ...prefer.s.widgets]); } function removeWidget(widget) { - defaultStore.set('widgets', defaultStore.state.widgets.filter(w => w.id !== widget.id)); + prefer.commit('widgets', prefer.s.widgets.filter(w => w.id !== widget.id)); } function updateWidget({ id, data }) { - defaultStore.set('widgets', defaultStore.state.widgets.map(w => w.id === id ? { + prefer.commit('widgets', prefer.s.widgets.map(w => w.id === id ? { ...w, data, place: props.place, @@ -57,18 +57,18 @@ function updateWidget({ id, data }) { function updateWidgets(thisWidgets) { if (props.place === null) { - defaultStore.set('widgets', thisWidgets); + prefer.commit('widgets', thisWidgets); return; } if (props.place === 'left') { - defaultStore.set('widgets', [ + prefer.commit('widgets', [ ...thisWidgets.map(w => ({ ...w, place: 'left' })), - ...defaultStore.state.widgets.filter(w => w.place !== 'left' && !thisWidgets.some(t => w.id === t.id)), + ...prefer.s.widgets.filter(w => w.place !== 'left' && !thisWidgets.some(t => w.id === t.id)), ]); return; } - defaultStore.set('widgets', [ - ...defaultStore.state.widgets.filter(w => w.place === 'left' && !thisWidgets.some(t => w.id === t.id)), + prefer.commit('widgets', [ + ...prefer.s.widgets.filter(w => w.place === 'left' && !thisWidgets.some(t => w.id === t.id)), ...thisWidgets.map(w => ({ ...w, place: 'right' })), ]); } diff --git a/packages/frontend/src/ui/classic.sidebar.vue b/packages/frontend/src/ui/classic.sidebar.vue deleted file mode 100644 index 5acef0bef8..0000000000 --- a/packages/frontend/src/ui/classic.sidebar.vue +++ /dev/null @@ -1,243 +0,0 @@ -<!-- -SPDX-FileCopyrightText: syuilo and misskey-project -SPDX-License-Identifier: AGPL-3.0-only ---> - -<template> -<div class="npcljfve" :class="{ iconOnly }"> - <button v-click-anime class="item _button account" @click="openAccountMenu"> - <MkAvatar :user="$i" class="avatar"/><MkAcct class="text" :user="$i"/> - </button> - <div class="post" data-cy-open-post-form @click="os.post"> - <MkButton class="button" gradate full rounded> - <i class="ti ti-pencil ti-fw"></i><span v-if="!iconOnly" class="text">{{ i18n.ts.note }}</span> - </MkButton> - </div> - <div class="divider"></div> - <MkA v-click-anime class="item index" activeClass="active" to="/" exact> - <i class="ti ti-home ti-fw"></i><span class="text">{{ i18n.ts.timeline }}</span> - </MkA> - <template v-for="item in menu"> - <div v-if="item === '-'" class="divider"></div> - <component :is="navbarItemDef[item].to ? 'MkA' : 'button'" v-else-if="navbarItemDef[item] && (navbarItemDef[item].show !== false)" v-click-anime class="item _button" :class="item" activeClass="active" :to="navbarItemDef[item].to" v-on="navbarItemDef[item].action ? { click: navbarItemDef[item].action } : {}"> - <i class="ti-fw" :class="navbarItemDef[item].icon"></i><span class="text">{{ navbarItemDef[item].title }}</span> - <span v-if="navbarItemDef[item].indicated" class="indicator _blink"> - <span v-if="navbarItemDef[item].indicateValue" class="_indicateCounter itemIndicateValueIcon">{{ navbarItemDef[item].indicateValue }}</span> - <i v-else class="_indicatorCircle"></i> - </span> - </component> - </template> - <div class="divider"></div> - <MkA v-if="$i.isAdmin || $i.isModerator" v-click-anime class="item" activeClass="active" to="/admin" :behavior="settingsWindowed ? 'window' : null"> - <i class="ti ti-dashboard ti-fw"></i><span class="text">{{ i18n.ts.controlPanel }}</span> - </MkA> - <button v-click-anime class="item _button" @click="more"> - <i class="ti ti-dots ti-fw"></i><span class="text">{{ i18n.ts.more }}</span> - <span v-if="otherNavItemIndicated" class="indicator _blink"><i class="_indicatorCircle"></i></span> - </button> - <MkA v-click-anime class="item" activeClass="active" to="/settings" :behavior="settingsWindowed ? 'window' : null"> - <i class="ti ti-settings ti-fw"></i><span class="text">{{ i18n.ts.settings }}</span> - </MkA> - <div class="divider"></div> - <div class="about"> - <button v-click-anime class="item _button" @click="openInstanceMenu"> - <img :src="instance.iconUrl ?? instance.faviconUrl ?? '/favicon.ico'" class="_ghost"/> - </button> - </div> - <!--<MisskeyLogo class="misskey"/>--> -</div> -</template> - -<script lang="ts" setup> -import { defineAsyncComponent, computed, watch, ref, shallowRef } from 'vue'; -import { openInstanceMenu } from './_common_/common.js'; -// import { host } from '@@/js/config.js'; -import * as os from '@/os.js'; -import { navbarItemDef } from '@/navbar.js'; -import { openAccountMenu as openAccountMenu_, $i } from '@/account.js'; -import MkButton from '@/components/MkButton.vue'; -// import { StickySidebar } from '@/scripts/sticky-sidebar.js'; -// import { mainRouter } from '@/router.js'; -//import MisskeyLogo from '@assets/client/misskey.svg'; -import { defaultStore } from '@/store.js'; -import { instance } from '@/instance.js'; -import { i18n } from '@/i18n.js'; - -const WINDOW_THRESHOLD = 1400; - -const menu = ref(defaultStore.state.menu); -const menuDisplay = computed(defaultStore.makeGetterSetter('menuDisplay')); -const otherNavItemIndicated = computed<boolean>(() => { - for (const def in navbarItemDef) { - if (menu.value.includes(def)) continue; - if (navbarItemDef[def].indicated) return true; - } - return false; -}); -const el = shallowRef<HTMLElement>(); -// let accounts = $ref([]); -// let connection = $ref(null); -const iconOnly = ref(false); -const settingsWindowed = ref(false); - -function calcViewState() { - iconOnly.value = (window.innerWidth <= WINDOW_THRESHOLD) || (menuDisplay.value === 'sideIcon'); - settingsWindowed.value = (window.innerWidth > WINDOW_THRESHOLD); -} - -function more(ev: MouseEvent) { - const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkLaunchPad.vue')), { - src: ev.currentTarget ?? ev.target, - }, { - closed: () => dispose(), - }); -} - -function openAccountMenu(ev: MouseEvent) { - openAccountMenu_({ - withExtraOperation: true, - }, ev); -} - -watch(defaultStore.reactiveState.menuDisplay, () => { - calcViewState(); -}); - -</script> - -<style lang="scss" scoped> -.npcljfve { - $ui-font-size: 1em; // TODO: どこかに集約したい - $nav-icon-only-width: 78px; // TODO: どこかに集約したい - $avatar-size: 32px; - $avatar-margin: 8px; - - padding: 0 16px; - box-sizing: border-box; - width: 260px; - - &.iconOnly { - flex: 0 0 $nav-icon-only-width; - width: $nav-icon-only-width !important; - - > .divider { - margin: 8px auto; - width: calc(100% - 32px); - } - - > .post { - > .button { - width: 46px; - height: 46px; - padding: 0; - } - } - - > .item { - padding-left: 0; - width: 100%; - text-align: center; - font-size: $ui-font-size * 1.1; - line-height: 3.7rem; - - > i, - > .avatar { - margin-right: 0; - } - - > i { - left: 10px; - } - - > .text { - display: none; - } - } - } - - > .divider { - margin: 10px 0; - border-top: solid 0.5px var(--MI_THEME-divider); - } - - > .post { - position: sticky; - top: 0; - z-index: 1; - padding: 16px 0; - background: var(--MI_THEME-bg); - - > .button { - min-width: 0; - } - } - - > .about { - fill: currentColor; - padding: 8px 0 16px 0; - text-align: center; - - > .item { - display: block; - width: 32px; - margin: 0 auto; - - img { - display: block; - width: 100%; - } - } - } - - > .item { - position: relative; - display: block; - font-size: $ui-font-size; - line-height: 2.6rem; - text-overflow: ellipsis; - overflow: hidden; - white-space: nowrap; - width: 100%; - text-align: left; - box-sizing: border-box; - - > i { - width: 32px; - } - - > i, - > .avatar { - margin-right: $avatar-margin; - } - - > .avatar { - width: $avatar-size; - height: $avatar-size; - vertical-align: middle; - } - - > .indicator { - position: absolute; - top: 0; - left: 0; - color: var(--MI_THEME-navIndicator); - font-size: 8px; - - &:has(.itemIndicateValueIcon) { - animation: none; - left: auto; - right: 20px; - } - } - - &:hover { - text-decoration: none; - color: var(--MI_THEME-navHoverFg); - } - - &.active { - color: var(--MI_THEME-navActive); - } - } -} -</style> diff --git a/packages/frontend/src/ui/classic.vue b/packages/frontend/src/ui/classic.vue deleted file mode 100644 index da5059bb59..0000000000 --- a/packages/frontend/src/ui/classic.vue +++ /dev/null @@ -1,334 +0,0 @@ -<!-- -SPDX-FileCopyrightText: syuilo and misskey-project -SPDX-License-Identifier: AGPL-3.0-only ---> - -<template> -<div class="gbhvwtnk" :class="{ wallpaper }" :style="`--globalHeaderHeight:${globalHeaderHeight}px`"> - <XHeaderMenu v-if="showMenuOnTop" v-get-size="(w, h) => globalHeaderHeight = h"/> - - <div class="columns" :class="{ fullView, withGlobalHeader: showMenuOnTop }"> - <div v-if="!showMenuOnTop" class="sidebar"> - <XSidebar/> - </div> - <div v-else-if="!pageMetadata?.needWideArea" ref="widgetsLeft" class="widgets left"> - <XWidgets place="left" :marginTop="'var(--MI-margin)'" @mounted="attachSticky(widgetsLeft)"/> - </div> - - <main class="main" @contextmenu.stop="onContextmenu"> - <div class="content" style="container-type: inline-size;"> - <RouterView/> - </div> - </main> - - <div v-if="isDesktop && !pageMetadata?.needWideArea" ref="widgetsRight" class="widgets right"> - <XWidgets :place="showMenuOnTop ? 'right' : null" :marginTop="showMenuOnTop ? '0' : 'var(--MI-margin)'" @mounted="attachSticky(widgetsRight)"/> - </div> - </div> - - <Transition :name="defaultStore.state.animation ? 'tray-back' : ''"> - <div - v-if="widgetsShowing" - class="tray-back _modalBg" - @click="widgetsShowing = false" - @touchstart.passive="widgetsShowing = false" - ></div> - </Transition> - - <Transition :name="defaultStore.state.animation ? 'tray' : ''"> - <XWidgets v-if="widgetsShowing" class="tray"/> - </Transition> - - <iframe v-if="defaultStore.state.aiChanMode" ref="live2d" class="ivnzpscs" src="https://misskey-dev.github.io/mascot-web/?scale=2&y=1.4"></iframe> - - <XCommon/> -</div> -</template> - -<script lang="ts" setup> -import { defineAsyncComponent, onMounted, provide, ref, computed, shallowRef } from 'vue'; -import XSidebar from './classic.sidebar.vue'; -import XCommon from './_common_/common.vue'; -import { instanceName } from '@@/js/config.js'; -import { StickySidebar } from '@/scripts/sticky-sidebar.js'; -import * as os from '@/os.js'; -import { provideMetadataReceiver, provideReactiveMetadata } from '@/scripts/page-metadata.js'; -import type { PageMetadata } from '@/scripts/page-metadata.js'; -import { defaultStore } from '@/store.js'; -import { i18n } from '@/i18n.js'; -import { miLocalStorage } from '@/local-storage.js'; -import { mainRouter } from '@/router/main.js'; -import { isLink } from '@@/js/is-link.js'; - -const XHeaderMenu = defineAsyncComponent(() => import('./classic.header.vue')); -const XWidgets = defineAsyncComponent(() => import('./universal.widgets.vue')); - -const isRoot = computed(() => mainRouter.currentRoute.value.name === 'index'); - -const DESKTOP_THRESHOLD = 1100; - -const isDesktop = ref(window.innerWidth >= DESKTOP_THRESHOLD); - -const pageMetadata = ref<null | PageMetadata>(null); -const widgetsShowing = ref(false); -const fullView = ref(false); -const globalHeaderHeight = ref(0); -const wallpaper = miLocalStorage.getItem('wallpaper') != null; -const showMenuOnTop = computed(() => defaultStore.state.menuDisplay === 'top'); -const live2d = shallowRef<HTMLIFrameElement>(); -const widgetsLeft = ref<HTMLElement>(); -const widgetsRight = ref<HTMLElement>(); - -provide('router', mainRouter); -provideMetadataReceiver((metadataGetter) => { - const info = metadataGetter(); - pageMetadata.value = info; - if (pageMetadata.value) { - if (isRoot.value && pageMetadata.value.title === instanceName) { - document.title = pageMetadata.value.title; - } else { - document.title = `${pageMetadata.value.title} | ${instanceName}`; - } - } -}); -provideReactiveMetadata(pageMetadata); -provide('shouldHeaderThin', showMenuOnTop.value); -provide('forceSpacerMin', true); - -function attachSticky(el: HTMLElement) { - const sticky = new StickySidebar(el, 0, defaultStore.state.menuDisplay === 'top' ? 60 : 0); // TODO: ヘッダーの高さを60pxと決め打ちしているのを直す - window.addEventListener('scroll', () => { - sticky.calc(window.scrollY); - }, { passive: true }); -} - -function top() { - window.scroll({ top: 0, behavior: 'smooth' }); -} - -function onContextmenu(ev: MouseEvent) { - if (isLink(ev.target)) return; - if (['INPUT', 'TEXTAREA', 'IMG', 'VIDEO', 'CANVAS'].includes(ev.target.tagName) || ev.target.attributes['contenteditable']) return; - if (window.getSelection().toString() !== '') return; - const path = mainRouter.getCurrentPath(); - os.contextMenu([{ - type: 'label', - text: path, - }, { - icon: fullView.value ? 'ti ti-minimize' : 'ti ti-maximize', - text: fullView.value ? i18n.ts.quitFullView : i18n.ts.fullView, - action: () => { - fullView.value = !fullView.value; - }, - }, { - icon: 'ti ti-window-maximize', - text: i18n.ts.openInWindow, - action: () => { - os.pageWindow(path); - }, - }], ev); -} - -function onAiClick(ev) { - //if (this.live2d) this.live2d.click(ev); -} - -if (window.innerWidth < 1024) { - const currentUI = miLocalStorage.getItem('ui'); - miLocalStorage.setItem('ui_temp', currentUI ?? 'default'); - miLocalStorage.setItem('ui', 'default'); - location.reload(); -} - -document.documentElement.style.overflowY = 'scroll'; - -defaultStore.loaded.then(() => { - if (defaultStore.state.widgets.length === 0) { - defaultStore.set('widgets', [{ - name: 'calendar', - id: 'a', place: null, data: {}, - }, { - name: 'notifications', - id: 'b', place: null, data: {}, - }, { - name: 'trends', - id: 'c', place: null, data: {}, - }]); - } -}); - -onMounted(() => { - window.addEventListener('resize', () => { - isDesktop.value = (window.innerWidth >= DESKTOP_THRESHOLD); - }, { passive: true }); - - if (defaultStore.state.aiChanMode) { - const iframeRect = live2d.value.getBoundingClientRect(); - window.addEventListener('mousemove', ev => { - live2d.value.contentWindow.postMessage({ - type: 'moveCursor', - body: { - x: ev.clientX - iframeRect.left, - y: ev.clientY - iframeRect.top, - }, - }, '*'); - }, { passive: true }); - window.addEventListener('touchmove', ev => { - live2d.value.contentWindow.postMessage({ - type: 'moveCursor', - body: { - x: ev.touches[0].clientX - iframeRect.left, - y: ev.touches[0].clientY - iframeRect.top, - }, - }, '*'); - }, { passive: true }); - } -}); -</script> - -<style lang="scss" scoped> -.tray-enter-active, -.tray-leave-active { - opacity: 1; - transform: translateX(0); - transition: transform 300ms cubic-bezier(0.23, 1, 0.32, 1), opacity 300ms cubic-bezier(0.23, 1, 0.32, 1); -} -.tray-enter-from, -.tray-leave-active { - opacity: 0; - transform: translateX(240px); -} - -.tray-back-enter-active, -.tray-back-leave-active { - opacity: 1; - transition: opacity 300ms cubic-bezier(0.23, 1, 0.32, 1); -} -.tray-back-enter-from, -.tray-back-leave-active { - opacity: 0; -} - -.gbhvwtnk { - $ui-font-size: 1em; - $widgets-hide-threshold: 1200px; - - min-height: 100dvh; - box-sizing: border-box; - - &.wallpaper { - background: var(--MI_THEME-wallpaperOverlay); - //backdrop-filter: var(--MI-blur, blur(4px)); - } - - > .columns { - display: flex; - justify-content: center; - max-width: 100%; - //margin: 32px 0; - - &.fullView { - margin: 0; - - > .sidebar { - display: none; - } - - > .widgets { - display: none; - } - - > .main { - margin: 0; - border-radius: 0; - box-shadow: none; - width: 100%; - } - } - - > .main { - min-width: 0; - width: 750px; - margin: 0 16px 0 0; - border-left: solid 1px var(--MI_THEME-divider); - border-right: solid 1px var(--MI_THEME-divider); - border-radius: 0; - overflow: clip; - --MI-margin: 12px; - } - - > .widgets { - //--MI_THEME-panelBorder: none; - width: 300px; - padding-bottom: calc(var(--MI-margin) + env(safe-area-inset-bottom, 0px)); - - @media (max-width: $widgets-hide-threshold) { - display: none; - } - - &.left { - margin-right: 16px; - } - } - - > .sidebar { - margin-top: 16px; - } - - &.withGlobalHeader { - > .main { - margin-top: 0; - border: solid 1px var(--MI_THEME-divider); - border-radius: var(--MI-radius); - --MI-stickyTop: var(--globalHeaderHeight); - } - - > .widgets { - --MI-stickyTop: var(--globalHeaderHeight); - margin-top: 0; - } - } - - @media (max-width: 850px) { - margin: 0; - - > .sidebar { - border-right: solid 0.5px var(--MI_THEME-divider); - } - - > .main { - margin: 0; - border-radius: 0; - box-shadow: none; - width: 100%; - } - } - } - - > .tray-back { - z-index: 1001; - } - - > .tray { - position: fixed; - top: 0; - right: 0; - z-index: 1001; - height: 100dvh; - padding: var(--MI-margin) var(--MI-margin) calc(var(--MI-margin) + env(safe-area-inset-bottom, 0px)); - box-sizing: border-box; - overflow: auto; - background: var(--MI_THEME-bg); - } - - > .ivnzpscs { - position: fixed; - bottom: 0; - right: 0; - width: 300px; - height: 600px; - border: none; - pointer-events: none; - } -} -</style> diff --git a/packages/frontend/src/ui/deck.vue b/packages/frontend/src/ui/deck.vue index 8e99c457ad..96961d951f 100644 --- a/packages/frontend/src/ui/deck.vue +++ b/packages/frontend/src/ui/deck.vue @@ -4,110 +4,89 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div :class="[$style.root, { [$style.rootIsMobile]: isMobile }]"> - <XSidebar v-if="!isMobile"/> +<div :class="[$style.root, { [$style.withWallpaper]: withWallpaper }]"> + <XSidebar v-if="!isMobile && prefer.r['deck.navbarPosition'].value === 'left'"/> <div :class="$style.main"> + <XNavbarH v-if="!isMobile && prefer.r['deck.navbarPosition'].value === 'top'"/> + <XAnnouncements v-if="$i"/> <XStatusBars/> - <div ref="columnsEl" :class="[$style.sections, { [$style.center]: deckStore.reactiveState.columnAlign.value === 'center', [$style.snapScroll]: snapScroll }]" @contextmenu.self.prevent="onContextmenu" @wheel.self="onWheel"> - <!-- sectionを利用しているのは、deck.vue側でcolumnに対してfirst-of-typeを効かせるため --> - <section - v-for="ids in layout" - :class="$style.section" - :style="columns.filter(c => ids.includes(c.id)).some(c => c.flexible) ? { flex: 1, minWidth: '350px' } : { width: Math.max(...columns.filter(c => ids.includes(c.id)).map(c => c.width)) + 'px' }" - @wheel.self="onWheel" - > - <component - :is="columnComponents[columns.find(c => c.id === id)!.type] ?? XTlColumn" - v-for="id in ids" - :ref="id" - :key="id" - :class="$style.column" - :column="columns.find(c => c.id === id)!" - :isStacked="ids.length > 1" - @headerWheel="onWheel" - /> - </section> - <div v-if="layout.length === 0" class="_panel" :class="$style.onboarding"> - <div>{{ i18n.ts._deck.introduction }}</div> - <MkButton primary style="margin: 1em auto;" @click="addColumn">{{ i18n.ts._deck.addColumn }}</MkButton> - <div>{{ i18n.ts._deck.introduction2 }}</div> + <div :class="$style.columnsWrapper"> + <!-- passive: https://bugs.webkit.org/show_bug.cgi?id=281300 --> + <div ref="columnsEl" :class="[$style.columns, { [$style.center]: prefer.r['deck.columnAlign'].value === 'center', [$style.snapScroll]: snapScroll }]" @contextmenu.self.prevent="onContextmenu" @wheel.passive.self="onWheel"> + <!-- sectionを利用しているのは、deck.vue側でcolumnに対してfirst-of-typeを効かせるため --> + <section + v-for="ids in layout" + :class="$style.section" + :style="columns.filter(c => ids.includes(c.id)).some(c => c.flexible) ? { flex: 1, minWidth: '350px' } : { width: Math.max(...columns.filter(c => ids.includes(c.id)).map(c => c.width)) + 'px' }" + @wheel.passive.self="onWheel" + > + <component + :is="columnComponents[columns.find(c => c.id === id)!.type] ?? XTlColumn" + v-for="id in ids" + :ref="id" + :key="id" + :class="[$style.column, { '_shadow': withWallpaper }]" + :column="columns.find(c => c.id === id)!" + :isStacked="ids.length > 1" + @headerWheel="onWheel" + /> + </section> + <div v-if="layout.length === 0" class="_panel" :class="$style.onboarding"> + <div>{{ i18n.ts._deck.introduction }}</div> + <div>{{ i18n.ts._deck.introduction2 }}</div> + </div> </div> - <div :class="$style.sideMenu"> + + <div v-if="prefer.r['deck.menuPosition'].value === 'right'" :class="$style.sideMenu"> <div :class="$style.sideMenuTop"> - <button v-tooltip.noDelay.left="`${i18n.ts._deck.profile}: ${deckStore.state.profile}`" :class="$style.sideMenuButton" class="_button" @click="changeProfile"><i class="ti ti-caret-down"></i></button> + <button v-tooltip.noDelay.left="`${i18n.ts._deck.profile}: ${prefer.s['deck.profile']}`" :class="$style.sideMenuButton" class="_button" @click="switchProfileMenu"><i class="ti ti-caret-down"></i></button> <button v-tooltip.noDelay.left="i18n.ts._deck.deleteProfile" :class="$style.sideMenuButton" class="_button" @click="deleteProfile"><i class="ti ti-trash"></i></button> </div> <div :class="$style.sideMenuMiddle"> <button v-tooltip.noDelay.left="i18n.ts._deck.addColumn" :class="$style.sideMenuButton" class="_button" @click="addColumn"><i class="ti ti-plus"></i></button> </div> <div :class="$style.sideMenuBottom"> - <button v-tooltip.noDelay.left="i18n.ts.settings" :class="$style.sideMenuButton" class="_button" @click="showSettings"><i class="ti ti-settings"></i></button> + <button v-tooltip.noDelay.left="i18n.ts.settings" :class="$style.sideMenuButton" class="_button" @click="showSettings"><i class="ti ti-settings-2"></i></button> </div> </div> </div> - </div> - <div v-if="isMobile" :class="$style.nav"> - <button :class="$style.navButton" class="_button" @click="drawerMenuShowing = true"><i :class="$style.navButtonIcon" class="ti ti-menu-2"></i><span v-if="menuIndicated" :class="$style.navButtonIndicator" class="_blink"><i class="_indicatorCircle"></i></span></button> - <button :class="$style.navButton" class="_button" @click="mainRouter.push('/')"><i :class="$style.navButtonIcon" class="ti ti-home"></i></button> - <button :class="$style.navButton" class="_button" @click="mainRouter.push('/my/notifications')"> - <i :class="$style.navButtonIcon" class="ti ti-bell"></i> - <span v-if="$i?.hasUnreadNotification" :class="$style.navButtonIndicator" class="_blink"> - <span class="_indicateCounter" :class="$style.itemIndicateValueIcon">{{ $i.unreadNotificationsCount > 99 ? '99+' : $i.unreadNotificationsCount }}</span> - </span> - </button> - <button :class="$style.postButton" class="_button" @click="os.post()"><i :class="$style.navButtonIcon" class="ti ti-pencil"></i></button> - </div> + <div v-if="prefer.r['deck.menuPosition'].value === 'bottom'" :class="$style.bottomMenu"> + <div :class="$style.bottomMenuLeft"> + <button v-tooltip.noDelay.left="`${i18n.ts._deck.profile}: ${prefer.s['deck.profile']}`" :class="$style.bottomMenuButton" class="_button" @click="switchProfileMenu"><i class="ti ti-caret-down"></i></button> + <button v-tooltip.noDelay.left="i18n.ts._deck.deleteProfile" :class="$style.bottomMenuButton" class="_button" @click="deleteProfile"><i class="ti ti-trash"></i></button> + </div> + <div :class="$style.bottomMenuMiddle"> + <button v-tooltip.noDelay.left="i18n.ts._deck.addColumn" :class="$style.bottomMenuButton" class="_button" @click="addColumn"><i class="ti ti-plus"></i></button> + </div> + <div :class="$style.bottomMenuRight"> + <button v-tooltip.noDelay.left="i18n.ts.settings" :class="$style.bottomMenuButton" class="_button" @click="showSettings"><i class="ti ti-settings-2"></i></button> + </div> + </div> - <Transition - :enterActiveClass="defaultStore.state.animation ? $style.transition_menuDrawerBg_enterActive : ''" - :leaveActiveClass="defaultStore.state.animation ? $style.transition_menuDrawerBg_leaveActive : ''" - :enterFromClass="defaultStore.state.animation ? $style.transition_menuDrawerBg_enterFrom : ''" - :leaveToClass="defaultStore.state.animation ? $style.transition_menuDrawerBg_leaveTo : ''" - > - <div - v-if="drawerMenuShowing" - :class="$style.menuBg" - class="_modalBg" - @click="drawerMenuShowing = false" - @touchstart.passive="drawerMenuShowing = false" - ></div> - </Transition> + <XNavbarH v-if="!isMobile && prefer.r['deck.navbarPosition'].value === 'bottom'"/> - <Transition - :enterActiveClass="defaultStore.state.animation ? $style.transition_menuDrawer_enterActive : ''" - :leaveActiveClass="defaultStore.state.animation ? $style.transition_menuDrawer_leaveActive : ''" - :enterFromClass="defaultStore.state.animation ? $style.transition_menuDrawer_enterFrom : ''" - :leaveToClass="defaultStore.state.animation ? $style.transition_menuDrawer_leaveTo : ''" - > - <div v-if="drawerMenuShowing" :class="$style.menu"> - <XDrawerMenu/> - </div> - </Transition> + <XMobileFooterMenu v-if="isMobile" v-model:drawerMenuShowing="drawerMenuShowing" v-model:widgetsShowing="widgetsShowing"/> + </div> - <XCommon/> + <XCommon v-model:drawerMenuShowing="drawerMenuShowing" v-model:widgetsShowing="widgetsShowing"/> </div> </template> <script lang="ts" setup> -import { computed, defineAsyncComponent, ref, watch, shallowRef } from 'vue'; +import { defineAsyncComponent, ref, useTemplateRef } from 'vue'; import { v4 as uuid } from 'uuid'; import XCommon from './_common_/common.vue'; -import { deckStore, columnTypes, addColumn as addColumnToStore, forceSaveDeck, loadDeck, getProfiles, deleteProfile as deleteProfile_ } from './deck/deck-store.js'; -import type { ColumnType } from './deck/deck-store.js'; -import type { MenuItem } from '@/types/menu.js'; import XSidebar from '@/ui/_common_/navbar.vue'; -import XDrawerMenu from '@/ui/_common_/navbar-for-mobile.vue'; -import MkButton from '@/components/MkButton.vue'; +import XNavbarH from '@/ui/_common_/navbar-h.vue'; +import XMobileFooterMenu from '@/ui/_common_/mobile-footer-menu.vue'; import * as os from '@/os.js'; -import { navbarItemDef } from '@/navbar.js'; -import { $i } from '@/account.js'; +import { $i } from '@/i.js'; import { i18n } from '@/i18n.js'; -import { unisonReload } from '@/scripts/unison-reload.js'; -import { deviceKind } from '@/scripts/device-kind.js'; -import { defaultStore } from '@/store.js'; +import { deviceKind } from '@/utility/device-kind.js'; +import { prefer } from '@/preferences.js'; import XMainColumn from '@/ui/deck/main-column.vue'; import XTlColumn from '@/ui/deck/tl-column.vue'; import XAntennaColumn from '@/ui/deck/antenna-column.vue'; @@ -118,7 +97,9 @@ import XWidgetsColumn from '@/ui/deck/widgets-column.vue'; import XMentionsColumn from '@/ui/deck/mentions-column.vue'; import XDirectColumn from '@/ui/deck/direct-column.vue'; import XRoleTimelineColumn from '@/ui/deck/role-timeline-column.vue'; -import { mainRouter } from '@/router/main.js'; +import { mainRouter } from '@/router.js'; +import { columns, layout, columnTypes, switchProfileMenu, addColumn as addColumnToStore, deleteProfile as deleteProfile_ } from '@/deck.js'; + const XStatusBars = defineAsyncComponent(() => import('@/ui/_common_/statusbars.vue')); const XAnnouncements = defineAsyncComponent(() => import('@/ui/_common_/announcements.vue')); @@ -137,8 +118,8 @@ const columnComponents = { mainRouter.navHook = (path, flag): boolean => { if (flag === 'forcePage') return false; - const noMainColumn = !deckStore.state.columns.some(x => x.type === 'main'); - if (deckStore.state.navWindow || noMainColumn) { + const noMainColumn = !columns.value.some(x => x.type === 'main'); + if (prefer.s['deck.navWindow'] || noMainColumn) { os.pageWindow(path); return true; } @@ -150,8 +131,12 @@ window.addEventListener('resize', () => { isMobile.value = window.innerWidth <= 500; }); -const snapScroll = deviceKind === 'smartphone' || deviceKind === 'tablet'; +// ポインターイベント非対応用に初期値はUAから出す +const snapScroll = ref(deviceKind === 'smartphone' || deviceKind === 'tablet'); +const withWallpaper = prefer.s['deck.wallpaper'] != null; const drawerMenuShowing = ref(false); +const widgetsShowing = ref(false); +const gap = prefer.r['deck.columnGap']; /* const route = 'TODO'; @@ -160,21 +145,11 @@ watch(route, () => { }); */ -const columns = deckStore.reactiveState.columns; -const layout = deckStore.reactiveState.layout; -const menuIndicated = computed(() => { - if ($i == null) return false; - for (const def in navbarItemDef) { - if (navbarItemDef[def].indicated) return true; - } - return false; -}); - function showSettings() { os.pageWindow('/settings/deck'); } -const columnsEl = shallowRef<HTMLElement>(); +const columnsEl = useTemplateRef('columnsEl'); const addColumn = async (ev) => { const { canceled, result: column } = await os.select({ @@ -201,136 +176,61 @@ const onContextmenu = (ev) => { }], ev); }; +// タッチでスクロールしてるときはスナップスクロールを有効にする +function pointerEvent(ev: PointerEvent) { + snapScroll.value = ev.pointerType === 'touch'; +} + +window.document.addEventListener('pointerdown', pointerEvent, { passive: true }); + function onWheel(ev: WheelEvent) { + // WheelEvent はマウスからしか発火しないのでスナップスクロールは無効化する + snapScroll.value = false; if (ev.deltaX === 0 && columnsEl.value != null) { columnsEl.value.scrollLeft += ev.deltaY; } } -document.documentElement.style.overflowY = 'hidden'; -document.documentElement.style.scrollBehavior = 'auto'; - -loadDeck(); - -function changeProfile(ev: MouseEvent) { - let items: MenuItem[] = [{ - text: deckStore.state.profile, - active: true, - action: () => {}, - }]; - getProfiles().then(profiles => { - items.push(...(profiles.filter(k => k !== deckStore.state.profile).map(k => ({ - text: k, - action: () => { - deckStore.set('profile', k); - unisonReload(); - }, - }))), { type: 'divider' as const }, { - text: i18n.ts._deck.newProfile, - icon: 'ti ti-plus', - action: async () => { - const { canceled, result: name } = await os.inputText({ - title: i18n.ts._deck.profile, - minLength: 1, - }); - - if (canceled || name == null) return; - - os.promiseDialog((async () => { - await deckStore.set('profile', name); - await forceSaveDeck(); - })(), () => { - unisonReload(); - }); - }, - }); - }).then(() => { - os.popupMenu(items, ev.currentTarget ?? ev.target); - }); -} - async function deleteProfile() { + if (prefer.s['deck.profile'] == null) return; + const { canceled } = await os.confirm({ type: 'warning', - text: i18n.tsx.deleteAreYouSure({ x: deckStore.state.profile }), + text: i18n.tsx.deleteAreYouSure({ x: prefer.s['deck.profile'] }), }); if (canceled) return; - os.promiseDialog((async () => { - if (deckStore.state.profile === 'default') { - await deckStore.set('columns', []); - await deckStore.set('layout', []); - await forceSaveDeck(); - } else { - await deleteProfile_(deckStore.state.profile); - } - await deckStore.set('profile', 'default'); - })(), () => { - unisonReload(); - }); -} -</script> + await deleteProfile_(prefer.s['deck.profile']); -<style> -html, -body { - width: 100%; - height: 100%; - overflow: clip; - position: fixed; - top: 0; - left: 0; - overscroll-behavior: none; + os.success(); } -#misskey_app { - width: 100%; - height: 100%; - overflow: clip; - position: absolute; - top: 0; - left: 0; -} -</style> - -<style lang="scss" module> -.transition_menuDrawerBg_enterActive, -.transition_menuDrawerBg_leaveActive { - opacity: 1; - transition: opacity 300ms cubic-bezier(0.23, 1, 0.32, 1); -} -.transition_menuDrawerBg_enterFrom, -.transition_menuDrawerBg_leaveTo { - opacity: 0; -} +window.document.documentElement.style.overflowY = 'hidden'; +window.document.documentElement.style.scrollBehavior = 'auto'; -.transition_menuDrawer_enterActive, -.transition_menuDrawer_leaveActive { - opacity: 1; - transform: translateX(0); - transition: transform 300ms cubic-bezier(0.23, 1, 0.32, 1), opacity 300ms cubic-bezier(0.23, 1, 0.32, 1); -} -.transition_menuDrawer_enterFrom, -.transition_menuDrawer_leaveTo { - opacity: 0; - transform: translateX(-240px); +if (prefer.s['deck.wallpaper'] != null) { + window.document.documentElement.style.backgroundImage = `url(${prefer.s['deck.wallpaper']})`; } +</script> +<style lang="scss" module> .root { $nav-hide-threshold: 650px; // TODO: どこかに集約したい --MI-margin: var(--MI-marginHalf); - --columnGap: 6px; + --columnGap: v-bind("gap + 'px'"); display: flex; height: 100dvh; box-sizing: border-box; flex: 1; -} -.rootIsMobile { - padding-bottom: 100px; + &.withWallpaper { + .main { + background: transparent; + } + } } .main { @@ -338,15 +238,26 @@ body { min-width: 0; display: flex; flex-direction: column; + background: var(--MI_THEME-deckBg); } -.sections { +.columnsWrapper { + flex: 1; + display: flex; + flex-direction: row; + + // これがないと狭い画面でマージンが広いデッキを表示したときにナビゲーションフッターが画面の外に追いやられて操作不能になる場合がある + min-height: 0; +} + +.columns { flex: 1; display: flex; overflow-x: auto; overflow-y: clip; overscroll-behavior: contain; - background: var(--MI_THEME-deckBg); + padding: var(--columnGap); + gap: var(--columnGap); &.center { > .section:first-of-type { @@ -366,15 +277,10 @@ body { .section { display: flex; flex-direction: column; - scroll-snap-align: start; flex-shrink: 0; - padding-top: var(--columnGap); - padding-bottom: var(--columnGap); - padding-left: var(--columnGap); - - > .column:not(:last-of-type) { - margin-bottom: var(--columnGap); - } + gap: var(--columnGap); + scroll-snap-align: start; + scroll-margin-left: var(--columnGap); } .onboarding { @@ -413,90 +319,30 @@ body { margin-top: auto; } -.menuBg { - z-index: 1001; -} - -.menu { - position: fixed; - top: 0; - left: 0; - z-index: 1001; - height: 100dvh; - width: 240px; - box-sizing: border-box; - contain: strict; - overflow: auto; - overscroll-behavior: contain; - background: var(--MI_THEME-navBg); -} - -.nav { - position: fixed; - z-index: 1000; - bottom: 0; - left: 0; - padding: 12px 12px max(12px, env(safe-area-inset-bottom, 0px)) 12px; - display: grid; - grid-template-columns: 1fr 1fr 1fr 1fr; - grid-gap: 8px; - width: 100%; - box-sizing: border-box; - -webkit-backdrop-filter: var(--MI-blur, blur(32px)); - backdrop-filter: var(--MI-blur, blur(32px)); - background-color: var(--MI_THEME-header); - border-top: solid 0.5px var(--MI_THEME-divider); +.bottomMenu { + flex-shrink: 0; + display: flex; + flex-direction: row; + justify-content: center; + height: 32px; } -.navButton { - position: relative; - padding: 0; +.bottomMenuButton { + display: block; + height: 100%; aspect-ratio: 1; - width: 100%; - max-width: 60px; - margin: auto; - border-radius: 100%; - background: var(--MI_THEME-panel); - color: var(--MI_THEME-fg); - - &:hover { - background: var(--MI_THEME-panelHighlight); - } - - &:active { - background: hsl(from var(--MI_THEME-panel) h s calc(l - 2)); - } } -.postButton { - composes: navButton; - background: linear-gradient(90deg, var(--MI_THEME-buttonGradateA), var(--MI_THEME-buttonGradateB)); - color: var(--MI_THEME-fgOnAccent); - - &:hover { - background: linear-gradient(90deg, hsl(from var(--MI_THEME-accent) h s calc(l + 5)), hsl(from var(--MI_THEME-accent) h s calc(l + 5))); - } - - &:active { - background: linear-gradient(90deg, hsl(from var(--MI_THEME-accent) h s calc(l + 5)), hsl(from var(--MI_THEME-accent) h s calc(l + 5))); - } +.bottomMenuLeft { + margin-right: auto; } -.navButtonIcon { - font-size: 18px; - vertical-align: middle; +.bottomMenuMiddle { + margin-left: auto; + margin-right: auto; } -.navButtonIndicator { - position: absolute; - top: 0; - left: 0; - color: var(--MI_THEME-indicator); - font-size: 16px; - - &:has(.itemIndicateValueIcon) { - animation: none; - font-size: 12px; - } +.bottomMenuRight { + margin-left: auto; } </style> diff --git a/packages/frontend/src/ui/deck/antenna-column.vue b/packages/frontend/src/ui/deck/antenna-column.vue index b79cd8408b..194b56c842 100644 --- a/packages/frontend/src/ui/deck/antenna-column.vue +++ b/packages/frontend/src/ui/deck/antenna-column.vue @@ -14,27 +14,27 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted, ref, shallowRef, watch, defineAsyncComponent } from 'vue'; -import type { entities as MisskeyEntities } from 'misskey-js'; +import { onMounted, ref, useTemplateRef, watch, defineAsyncComponent } from 'vue'; import XColumn from './column.vue'; -import { updateColumn } from './deck-store.js'; -import type { Column } from './deck-store.js'; +import type { entities as MisskeyEntities } from 'misskey-js'; +import type { Column } from '@/deck.js'; +import type { MenuItem } from '@/types/menu.js'; +import type { SoundStore } from '@/preferences/def.js'; +import { updateColumn } from '@/deck.js'; import MkTimeline from '@/components/MkTimeline.vue'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; -import type { MenuItem } from '@/types/menu.js'; import { antennasCache } from '@/cache.js'; -import type { SoundStore } from '@/store.js'; import { soundSettingsButton } from '@/ui/deck/tl-note-notification.js'; -import * as sound from '@/scripts/sound.js'; +import * as sound from '@/utility/sound.js'; const props = defineProps<{ column: Column; isStacked: boolean; }>(); -const timeline = shallowRef<InstanceType<typeof MkTimeline>>(); +const timeline = useTemplateRef('timeline'); const soundSetting = ref<SoundStore>(props.column.soundSetting ?? { type: null, volume: 1 }); const antennaName = ref<string | null>(null); diff --git a/packages/frontend/src/ui/deck/channel-column.vue b/packages/frontend/src/ui/deck/channel-column.vue index 9e07c06639..c2644da707 100644 --- a/packages/frontend/src/ui/deck/channel-column.vue +++ b/packages/frontend/src/ui/deck/channel-column.vue @@ -19,28 +19,28 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted, ref, shallowRef, watch } from 'vue'; +import { onMounted, ref, shallowRef, watch, useTemplateRef } from 'vue'; import * as Misskey from 'misskey-js'; import XColumn from './column.vue'; -import { updateColumn } from './deck-store.js'; -import type { Column } from './deck-store.js'; +import type { Column } from '@/deck.js'; +import type { MenuItem } from '@/types/menu.js'; +import type { SoundStore } from '@/preferences/def.js'; +import { updateColumn } from '@/deck.js'; import MkTimeline from '@/components/MkTimeline.vue'; import MkButton from '@/components/MkButton.vue'; import * as os from '@/os.js'; import { favoritedChannelsCache } from '@/cache.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; -import type { MenuItem } from '@/types/menu.js'; -import type { SoundStore } from '@/store.js'; import { soundSettingsButton } from '@/ui/deck/tl-note-notification.js'; -import * as sound from '@/scripts/sound.js'; +import * as sound from '@/utility/sound.js'; const props = defineProps<{ column: Column; isStacked: boolean; }>(); -const timeline = shallowRef<InstanceType<typeof MkTimeline>>(); +const timeline = useTemplateRef('timeline'); const channel = shallowRef<Misskey.entities.Channel>(); const soundSetting = ref<SoundStore>(props.column.soundSetting ?? { type: null, volume: 1 }); diff --git a/packages/frontend/src/ui/deck/column.vue b/packages/frontend/src/ui/deck/column.vue index f23e33c748..b6e4c7dc7c 100644 --- a/packages/frontend/src/ui/deck/column.vue +++ b/packages/frontend/src/ui/deck/column.vue @@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div - :class="[$style.root, { [$style.paged]: isMainColumn, [$style.naked]: naked, [$style.active]: active, [$style.draghover]: draghover, [$style.dragging]: dragging, [$style.dropready]: dropready }]" + :class="[$style.root, { [$style.paged]: isMainColumn, [$style.naked]: naked, [$style.active]: active, [$style.draghover]: draghover, [$style.dragging]: dragging, [$style.dropready]: dropready, [$style.withWallpaper]: withWallpaper }]" @dragover.prevent.stop="onDragover" @dragleave="onDragleave" @drop.prevent.stop="onDrop" @@ -17,7 +17,7 @@ SPDX-License-Identifier: AGPL-3.0-only @dragstart="onDragstart" @dragend="onDragend" @contextmenu.prevent.stop="onContextmenu" - @wheel="emit('headerWheel', $event)" + @wheel.passive="emit('headerWheel', $event)" > <svg viewBox="0 0 256 128" :class="$style.tabShape"> <g transform="matrix(6.2431,0,0,6.2431,-677.417,-29.3839)"> @@ -42,16 +42,20 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onBeforeUnmount, onMounted, provide, watch, shallowRef, ref, computed } from 'vue'; -import { updateColumn, swapLeftColumn, swapRightColumn, swapUpColumn, swapDownColumn, stackLeftColumn, popRightColumn, removeColumn, swapColumn } from './deck-store.js'; +import { onBeforeUnmount, onMounted, provide, watch, useTemplateRef, ref, computed } from 'vue'; +import type { Column } from '@/deck.js'; +import type { MenuItem } from '@/types/menu.js'; +import { updateColumn, swapLeftColumn, swapRightColumn, swapUpColumn, swapDownColumn, stackLeftColumn, popRightColumn, removeColumn, swapColumn } from '@/deck.js'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; -import type { Column } from './deck-store.js'; -import type { MenuItem } from '@/types/menu.js'; +import { prefer } from '@/preferences.js'; +import { DI } from '@/di.js'; provide('shouldHeaderThin', true); provide('shouldOmitHeaderTitle', true); -provide('forceSpacerMin', true); +provide(DI.forceSpacerMin, true); + +const withWallpaper = prefer.s['deck.wallpaper'] != null; const props = withDefaults(defineProps<{ column: Column; @@ -68,7 +72,7 @@ const emit = defineEmits<{ (ev: 'headerWheel', ctx: WheelEvent): void; }>(); -const body = shallowRef<HTMLDivElement | null>(); +const body = useTemplateRef('body'); const dragging = ref(false); watch(dragging, v => os.deckGlobalEvents.emit(v ? 'column.dragStart' : 'column.dragEnd')); @@ -100,7 +104,7 @@ function onOtherDragEnd() { function toggleActive() { if (!props.isStacked) return; updateColumn(props.column.id, { - active: !props.column.active, + active: props.column.active == null ? false : !props.column.active, }); } @@ -108,9 +112,7 @@ function getMenu() { const menuItems: MenuItem[] = []; if (props.menu) { - menuItems.push(...props.menu, { - type: 'divider', - }); + menuItems.push(...props.menu); } if (props.refresher) { @@ -125,6 +127,12 @@ function getMenu() { }); } + if (menuItems.length > 0) { + menuItems.push({ + type: 'divider', + }); + } + menuItems.push({ icon: 'ti ti-settings', text: i18n.ts._deck.configureColumn, @@ -153,6 +161,21 @@ function getMenu() { }, }); + const flexibleRef = ref(props.column.flexible ?? false); + + watch(flexibleRef, flexible => { + updateColumn(props.column.id, { + flexible, + }); + }); + + menuItems.push({ + type: 'switch', + icon: 'ti ti-arrows-horizontal', + text: i18n.ts._deck.flexible, + ref: flexibleRef, + }); + const moveToMenuItems: MenuItem[] = []; moveToMenuItems.push({ @@ -333,9 +356,7 @@ function onDrop(ev) { } &.naked { - background: var(--MI_THEME-acrylicBg) !important; - -webkit-backdrop-filter: var(--MI-blur, blur(10px)); - backdrop-filter: var(--MI-blur, blur(10px)); + background: color(from var(--MI_THEME-bg) srgb r g b / 0.5) !important; > .header { background: transparent; @@ -353,12 +374,27 @@ function onDrop(ev) { } } + &.withWallpaper { + &.naked { + background: color(from var(--MI_THEME-bg) srgb r g b / 0.75) !important; + -webkit-backdrop-filter: var(--MI-blur, blur(10px)); + backdrop-filter: var(--MI-blur, blur(10px)); + + > .header { + color: light-dark(#000000bf, #ffffffbf); + } + } + + .tabShape { + display: none; + } + } + &.paged { background: var(--MI_THEME-bg) !important; > .body { background: var(--MI_THEME-bg) !important; - overflow-y: scroll !important; scrollbar-color: var(--MI_THEME-scrollbarHandle) transparent; &::-webkit-scrollbar-track { @@ -378,11 +414,16 @@ function onDrop(ev) { font-size: 0.9em; color: var(--MI_THEME-panelHeaderFg); background: var(--MI_THEME-panelHeaderBg); - box-shadow: 0 1px 0 0 var(--MI_THEME-panelHeaderDivider); cursor: pointer; user-select: none; } +@container style(--MI_THEME-panelHeaderBg: var(--MI_THEME-panel)) { + .header { + box-shadow: 0 0.5px 0 0 light-dark(#0002, #fff2); + } +} + .color { position: absolute; top: 12px; diff --git a/packages/frontend/src/ui/deck/deck-store.ts b/packages/frontend/src/ui/deck/deck-store.ts index 9055ea6d43..c58b5d7aad 100644 --- a/packages/frontend/src/ui/deck/deck-store.ts +++ b/packages/frontend/src/ui/deck/deck-store.ts @@ -3,58 +3,12 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { throttle } from 'throttle-debounce'; import { markRaw } from 'vue'; -import { notificationTypes } from 'misskey-js'; -import type { BasicTimelineType } from '@/timelines.js'; -import { Storage } from '@/pizzax.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { deepClone } from '@/scripts/clone.js'; -import type { SoundStore } from '@/store.js'; +import type { Column } from '@/deck.js'; +import { Pizzax } from '@/lib/pizzax.js'; -type ColumnWidget = { - name: string; - id: string; - data: Record<string, any>; -}; - -export const columnTypes = [ - 'main', - 'widgets', - 'notifications', - 'tl', - 'antenna', - 'list', - 'channel', - 'mentions', - 'direct', - 'roleTimeline', -] as const; - -export type ColumnType = typeof columnTypes[number]; - -export type Column = { - id: string; - type: ColumnType; - name: string | null; - width: number; - widgets?: ColumnWidget[]; - active?: boolean; - flexible?: boolean; - antennaId?: string; - listId?: string; - channelId?: string; - roleId?: string; - excludeTypes?: typeof notificationTypes[number][]; - tl?: BasicTimelineType; - withRenotes?: boolean; - withReplies?: boolean; - withSensitive?: boolean; - onlyFiles?: boolean; - soundSetting?: SoundStore; -}; - -export const deckStore = markRaw(new Storage('deck', { +// TODO: 消す(移行済みのため) +export const deckStore = markRaw(new Pizzax('deck', { profile: { where: 'deviceAccount', default: 'default', @@ -67,272 +21,4 @@ export const deckStore = markRaw(new Storage('deck', { where: 'deviceAccount', default: [] as Column['id'][][], }, - columnAlign: { - where: 'deviceAccount', - default: 'left' as 'left' | 'right' | 'center', - }, - alwaysShowMainColumn: { - where: 'deviceAccount', - default: true, - }, - navWindow: { - where: 'deviceAccount', - default: true, - }, - useSimpleUiForNonRootPages: { - where: 'deviceAccount', - default: true, - }, })); - -export const loadDeck = async () => { - let deck; - - try { - deck = await misskeyApi('i/registry/get', { - scope: ['client', 'deck', 'profiles'], - key: deckStore.state.profile, - }); - } catch (err) { - if (typeof err === 'object' && err != null && 'code' in err && err.code === 'NO_SUCH_KEY') { - // 後方互換性のため - if (deckStore.state.profile === 'default') { - saveDeck(); - return; - } - - deckStore.set('columns', []); - deckStore.set('layout', []); - return; - } - throw err; - } - - deckStore.set('columns', deck.columns); - deckStore.set('layout', deck.layout); -}; - -export async function forceSaveDeck() { - await misskeyApi('i/registry/set', { - scope: ['client', 'deck', 'profiles'], - key: deckStore.state.profile, - value: { - columns: deckStore.reactiveState.columns.value, - layout: deckStore.reactiveState.layout.value, - }, - }); -} - -// TODO: deckがloadされていない状態でsaveすると意図せず上書きが発生するので対策する -export const saveDeck = throttle(1000, () => { - forceSaveDeck(); -}); - -export async function getProfiles(): Promise<string[]> { - return await misskeyApi('i/registry/keys', { - scope: ['client', 'deck', 'profiles'], - }); -} - -export async function deleteProfile(key: string): Promise<void> { - return await misskeyApi('i/registry/remove', { - scope: ['client', 'deck', 'profiles'], - key: key, - }); -} - -export function addColumn(column: Column) { - if (column.name === undefined) column.name = null; - deckStore.push('columns', column); - deckStore.push('layout', [column.id]); - saveDeck(); -} - -export function removeColumn(id: Column['id']) { - deckStore.set('columns', deckStore.state.columns.filter(c => c.id !== id)); - deckStore.set('layout', deckStore.state.layout - .map(ids => ids.filter(_id => _id !== id)) - .filter(ids => ids.length > 0)); - saveDeck(); -} - -export function swapColumn(a: Column['id'], b: Column['id']) { - const aX = deckStore.state.layout.findIndex(ids => ids.indexOf(a) !== -1); - const aY = deckStore.state.layout[aX].findIndex(id => id === a); - const bX = deckStore.state.layout.findIndex(ids => ids.indexOf(b) !== -1); - const bY = deckStore.state.layout[bX].findIndex(id => id === b); - const layout = deepClone(deckStore.state.layout); - layout[aX][aY] = b; - layout[bX][bY] = a; - deckStore.set('layout', layout); - saveDeck(); -} - -export function swapLeftColumn(id: Column['id']) { - const layout = deepClone(deckStore.state.layout); - deckStore.state.layout.some((ids, i) => { - if (ids.includes(id)) { - const left = deckStore.state.layout[i - 1]; - if (left) { - layout[i - 1] = deckStore.state.layout[i]; - layout[i] = left; - deckStore.set('layout', layout); - } - return true; - } - return false; - }); - saveDeck(); -} - -export function swapRightColumn(id: Column['id']) { - const layout = deepClone(deckStore.state.layout); - deckStore.state.layout.some((ids, i) => { - if (ids.includes(id)) { - const right = deckStore.state.layout[i + 1]; - if (right) { - layout[i + 1] = deckStore.state.layout[i]; - layout[i] = right; - deckStore.set('layout', layout); - } - return true; - } - return false; - }); - saveDeck(); -} - -export function swapUpColumn(id: Column['id']) { - const layout = deepClone(deckStore.state.layout); - const idsIndex = deckStore.state.layout.findIndex(ids => ids.includes(id)); - const ids = deepClone(deckStore.state.layout[idsIndex]); - ids.some((x, i) => { - if (x === id) { - const up = ids[i - 1]; - if (up) { - ids[i - 1] = id; - ids[i] = up; - - layout[idsIndex] = ids; - deckStore.set('layout', layout); - } - return true; - } - return false; - }); - saveDeck(); -} - -export function swapDownColumn(id: Column['id']) { - const layout = deepClone(deckStore.state.layout); - const idsIndex = deckStore.state.layout.findIndex(ids => ids.includes(id)); - const ids = deepClone(deckStore.state.layout[idsIndex]); - ids.some((x, i) => { - if (x === id) { - const down = ids[i + 1]; - if (down) { - ids[i + 1] = id; - ids[i] = down; - - layout[idsIndex] = ids; - deckStore.set('layout', layout); - } - return true; - } - return false; - }); - saveDeck(); -} - -export function stackLeftColumn(id: Column['id']) { - let layout = deepClone(deckStore.state.layout); - const i = deckStore.state.layout.findIndex(ids => ids.includes(id)); - layout = layout.map(ids => ids.filter(_id => _id !== id)); - layout[i - 1].push(id); - layout = layout.filter(ids => ids.length > 0); - deckStore.set('layout', layout); - saveDeck(); -} - -export function popRightColumn(id: Column['id']) { - let layout = deepClone(deckStore.state.layout); - const i = deckStore.state.layout.findIndex(ids => ids.includes(id)); - const affected = layout[i]; - layout = layout.map(ids => ids.filter(_id => _id !== id)); - layout.splice(i + 1, 0, [id]); - layout = layout.filter(ids => ids.length > 0); - deckStore.set('layout', layout); - - const columns = deepClone(deckStore.state.columns); - for (const column of columns) { - if (affected.includes(column.id)) { - column.active = true; - } - } - deckStore.set('columns', columns); - - saveDeck(); -} - -export function addColumnWidget(id: Column['id'], widget: ColumnWidget) { - const columns = deepClone(deckStore.state.columns); - const columnIndex = deckStore.state.columns.findIndex(c => c.id === id); - const column = deepClone(deckStore.state.columns[columnIndex]); - if (column == null) return; - if (column.widgets == null) column.widgets = []; - column.widgets.unshift(widget); - columns[columnIndex] = column; - deckStore.set('columns', columns); - saveDeck(); -} - -export function removeColumnWidget(id: Column['id'], widget: ColumnWidget) { - const columns = deepClone(deckStore.state.columns); - const columnIndex = deckStore.state.columns.findIndex(c => c.id === id); - const column = deepClone(deckStore.state.columns[columnIndex]); - if (column == null) return; - if (column.widgets == null) column.widgets = []; - column.widgets = column.widgets.filter(w => w.id !== widget.id); - columns[columnIndex] = column; - deckStore.set('columns', columns); - saveDeck(); -} - -export function setColumnWidgets(id: Column['id'], widgets: ColumnWidget[]) { - const columns = deepClone(deckStore.state.columns); - const columnIndex = deckStore.state.columns.findIndex(c => c.id === id); - const column = deepClone(deckStore.state.columns[columnIndex]); - if (column == null) return; - column.widgets = widgets; - columns[columnIndex] = column; - deckStore.set('columns', columns); - saveDeck(); -} - -export function updateColumnWidget(id: Column['id'], widgetId: string, widgetData: any) { - const columns = deepClone(deckStore.state.columns); - const columnIndex = deckStore.state.columns.findIndex(c => c.id === id); - const column = deepClone(deckStore.state.columns[columnIndex]); - if (column == null) return; - if (column.widgets == null) column.widgets = []; - column.widgets = column.widgets.map(w => w.id === widgetId ? { - ...w, - data: widgetData, - } : w); - columns[columnIndex] = column; - deckStore.set('columns', columns); - saveDeck(); -} - -export function updateColumn(id: Column['id'], column: Partial<Column>) { - const columns = deepClone(deckStore.state.columns); - const columnIndex = deckStore.state.columns.findIndex(c => c.id === id); - const currentColumn = deepClone(deckStore.state.columns[columnIndex]); - if (currentColumn == null) return; - for (const [k, v] of Object.entries(column)) { - currentColumn[k] = v; - } - columns[columnIndex] = currentColumn; - deckStore.set('columns', columns); - saveDeck(); -} diff --git a/packages/frontend/src/ui/deck/direct-column.vue b/packages/frontend/src/ui/deck/direct-column.vue index 2cecd6c669..772188d773 100644 --- a/packages/frontend/src/ui/deck/direct-column.vue +++ b/packages/frontend/src/ui/deck/direct-column.vue @@ -14,7 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { ref } from 'vue'; import XColumn from './column.vue'; -import type { Column } from './deck-store.js'; +import type { Column } from '@/deck.js'; import MkNotes from '@/components/MkNotes.vue'; import { i18n } from '@/i18n.js'; diff --git a/packages/frontend/src/ui/deck/list-column.vue b/packages/frontend/src/ui/deck/list-column.vue index 83961d02bc..a8f17feb23 100644 --- a/packages/frontend/src/ui/deck/list-column.vue +++ b/packages/frontend/src/ui/deck/list-column.vue @@ -14,27 +14,27 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { watch, shallowRef, ref, onMounted } from 'vue'; -import type { entities as MisskeyEntities } from 'misskey-js'; +import { watch, useTemplateRef, ref, onMounted } from 'vue'; import XColumn from './column.vue'; -import { updateColumn } from './deck-store.js'; -import type { Column } from './deck-store.js'; +import type { entities as MisskeyEntities } from 'misskey-js'; +import type { Column } from '@/deck.js'; +import type { MenuItem } from '@/types/menu.js'; +import type { SoundStore } from '@/preferences/def.js'; +import { updateColumn } from '@/deck.js'; import MkTimeline from '@/components/MkTimeline.vue'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; -import type { MenuItem } from '@/types/menu.js'; -import type { SoundStore } from '@/store.js'; import { userListsCache } from '@/cache.js'; import { soundSettingsButton } from '@/ui/deck/tl-note-notification.js'; -import * as sound from '@/scripts/sound.js'; +import * as sound from '@/utility/sound.js'; const props = defineProps<{ column: Column; isStacked: boolean; }>(); -const timeline = shallowRef<InstanceType<typeof MkTimeline>>(); +const timeline = useTemplateRef('timeline'); const withRenotes = ref(props.column.withRenotes ?? true); const soundSetting = ref<SoundStore>(props.column.soundSetting ?? { type: null, volume: 1 }); const listName = ref<string | null>(null); diff --git a/packages/frontend/src/ui/deck/main-column.vue b/packages/frontend/src/ui/deck/main-column.vue index 45c39a5cad..78454d2e49 100644 --- a/packages/frontend/src/ui/deck/main-column.vue +++ b/packages/frontend/src/ui/deck/main-column.vue @@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<XColumn v-if="deckStore.state.alwaysShowMainColumn || mainRouter.currentRoute.value.name !== 'index'" :column="column" :isStacked="isStacked"> +<XColumn v-if="prefer.s['deck.alwaysShowMainColumn'] || mainRouter.currentRoute.value.name !== 'index'" :column="column" :isStacked="isStacked"> <template #header> <template v-if="pageMetadata"> <i :class="pageMetadata.icon"></i> @@ -12,35 +12,34 @@ SPDX-License-Identifier: AGPL-3.0-only </template> </template> - <div ref="contents"> - <RouterView @contextmenu.stop="onContextmenu"/> + <div style="height: 100%;"> + <StackingRouterView v-if="prefer.s['experimental.stackingRouterView']" @contextmenu.stop="onContextmenu"/> + <RouterView v-else @contextmenu.stop="onContextmenu"/> </div> </XColumn> </template> <script lang="ts" setup> import { provide, shallowRef, ref } from 'vue'; +import { isLink } from '@@/js/is-link.js'; import XColumn from './column.vue'; -import { deckStore } from '@/ui/deck/deck-store.js'; -import type { Column } from '@/ui/deck/deck-store.js'; +import type { Column } from '@/deck.js'; +import type { PageMetadata } from '@/page.js'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; -import { provideMetadataReceiver, provideReactiveMetadata } from '@/scripts/page-metadata.js'; -import type { PageMetadata } from '@/scripts/page-metadata.js'; -import { useScrollPositionManager } from '@/nirax.js'; -import { getScrollContainer } from '@@/js/scroll.js'; -import { isLink } from '@@/js/is-link.js'; -import { mainRouter } from '@/router/main.js'; +import { provideMetadataReceiver, provideReactiveMetadata } from '@/page.js'; +import { mainRouter } from '@/router.js'; +import { prefer } from '@/preferences.js'; +import { DI } from '@/di.js'; defineProps<{ column: Column; isStacked: boolean; }>(); -const contents = shallowRef<HTMLElement>(); const pageMetadata = ref<null | PageMetadata>(null); -provide('router', mainRouter); +provide(DI.router, mainRouter); provideMetadataReceiver((metadataGetter) => { const info = metadataGetter(); pageMetadata.value = info; @@ -70,6 +69,4 @@ function onContextmenu(ev: MouseEvent) { }, }], ev); } - -useScrollPositionManager(() => getScrollContainer(contents.value), mainRouter); </script> diff --git a/packages/frontend/src/ui/deck/mentions-column.vue b/packages/frontend/src/ui/deck/mentions-column.vue index 233fba554b..ffd0307940 100644 --- a/packages/frontend/src/ui/deck/mentions-column.vue +++ b/packages/frontend/src/ui/deck/mentions-column.vue @@ -14,7 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { ref } from 'vue'; import XColumn from './column.vue'; -import type { Column } from './deck-store.js'; +import type { Column } from '@/deck.js'; import MkNotes from '@/components/MkNotes.vue'; import { i18n } from '../../i18n.js'; diff --git a/packages/frontend/src/ui/deck/notifications-column.vue b/packages/frontend/src/ui/deck/notifications-column.vue index c0303e86dc..8378dddfef 100644 --- a/packages/frontend/src/ui/deck/notifications-column.vue +++ b/packages/frontend/src/ui/deck/notifications-column.vue @@ -12,10 +12,10 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { defineAsyncComponent, shallowRef } from 'vue'; +import { defineAsyncComponent, useTemplateRef } from 'vue'; import XColumn from './column.vue'; -import { updateColumn } from './deck-store.js'; -import type { Column } from './deck-store.js'; +import type { Column } from '@/deck.js'; +import { updateColumn } from '@/deck.js'; import XNotifications from '@/components/MkNotifications.vue'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; @@ -25,7 +25,7 @@ const props = defineProps<{ isStacked: boolean; }>(); -const notificationsComponent = shallowRef<InstanceType<typeof XNotifications>>(); +const notificationsComponent = useTemplateRef('notificationsComponent'); function func() { const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkNotificationSelectWindow.vue')), { diff --git a/packages/frontend/src/ui/deck/role-timeline-column.vue b/packages/frontend/src/ui/deck/role-timeline-column.vue index 5b1420570d..468b3e49e0 100644 --- a/packages/frontend/src/ui/deck/role-timeline-column.vue +++ b/packages/frontend/src/ui/deck/role-timeline-column.vue @@ -14,25 +14,25 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { computed, onMounted, ref, shallowRef, watch } from 'vue'; +import { onMounted, ref, useTemplateRef, watch } from 'vue'; import XColumn from './column.vue'; -import { updateColumn } from './deck-store.js'; -import type { Column } from './deck-store.js'; +import type { Column } from '@/deck.js'; +import type { MenuItem } from '@/types/menu.js'; +import type { SoundStore } from '@/preferences/def.js'; +import { updateColumn } from '@/deck.js'; import MkTimeline from '@/components/MkTimeline.vue'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; -import type { MenuItem } from '@/types/menu.js'; -import type { SoundStore } from '@/store.js'; import { soundSettingsButton } from '@/ui/deck/tl-note-notification.js'; -import * as sound from '@/scripts/sound.js'; +import * as sound from '@/utility/sound.js'; const props = defineProps<{ column: Column; isStacked: boolean; }>(); -const timeline = shallowRef<InstanceType<typeof MkTimeline>>(); +const timeline = useTemplateRef('timeline'); const soundSetting = ref<SoundStore>(props.column.soundSetting ?? { type: null, volume: 1 }); const roleName = ref<string | null>(null); diff --git a/packages/frontend/src/ui/deck/tl-column.vue b/packages/frontend/src/ui/deck/tl-column.vue index b9b3746abf..6759135654 100644 --- a/packages/frontend/src/ui/deck/tl-column.vue +++ b/packages/frontend/src/ui/deck/tl-column.vue @@ -32,26 +32,25 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted, watch, ref, shallowRef, computed } from 'vue'; +import { onMounted, watch, ref, useTemplateRef, computed } from 'vue'; import XColumn from './column.vue'; -import { removeColumn, updateColumn } from './deck-store.js'; -import type { Column } from './deck-store.js'; +import type { Column } from '@/deck.js'; import type { MenuItem } from '@/types/menu.js'; +import type { SoundStore } from '@/preferences/def.js'; +import { removeColumn, updateColumn } from '@/deck.js'; import MkTimeline from '@/components/MkTimeline.vue'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; import { hasWithReplies, isAvailableBasicTimeline, basicTimelineIconClass } from '@/timelines.js'; -import { instance } from '@/instance.js'; -import type { SoundStore } from '@/store.js'; import { soundSettingsButton } from '@/ui/deck/tl-note-notification.js'; -import * as sound from '@/scripts/sound.js'; +import * as sound from '@/utility/sound.js'; const props = defineProps<{ column: Column; isStacked: boolean; }>(); -const timeline = shallowRef<InstanceType<typeof MkTimeline>>(); +const timeline = useTemplateRef('timeline'); const soundSetting = ref<SoundStore>(props.column.soundSetting ?? { type: null, volume: 1 }); const withRenotes = ref(props.column.withRenotes ?? true); diff --git a/packages/frontend/src/ui/deck/tl-note-notification.ts b/packages/frontend/src/ui/deck/tl-note-notification.ts index 03d4b3a580..728c0d0d29 100644 --- a/packages/frontend/src/ui/deck/tl-note-notification.ts +++ b/packages/frontend/src/ui/deck/tl-note-notification.ts @@ -5,9 +5,9 @@ import * as Misskey from 'misskey-js'; import type { Ref } from 'vue'; -import type { SoundStore } from '@/store.js'; -import type { SoundType } from '@/scripts/sound.js'; -import { getSoundDuration, playMisskeySfxFile, soundsTypes } from '@/scripts/sound.js'; +import type { SoundType } from '@/utility/sound.js'; +import type { SoundStore } from '@/preferences/def.js'; +import { getSoundDuration, playMisskeySfxFile, soundsTypes } from '@/utility/sound.js'; import { i18n } from '@/i18n.js'; import * as os from '@/os.js'; diff --git a/packages/frontend/src/ui/deck/widgets-column.vue b/packages/frontend/src/ui/deck/widgets-column.vue index 20284d8c9f..4e84ef0ba0 100644 --- a/packages/frontend/src/ui/deck/widgets-column.vue +++ b/packages/frontend/src/ui/deck/widgets-column.vue @@ -17,8 +17,8 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { ref } from 'vue'; import XColumn from './column.vue'; -import { addColumnWidget, removeColumnWidget, setColumnWidgets, updateColumnWidget } from './deck-store.js'; -import type { Column } from './deck-store.js'; +import { addColumnWidget, removeColumnWidget, setColumnWidgets, updateColumnWidget } from '@/deck.js'; +import type { Column } from '@/deck.js'; import XWidgets from '@/components/MkWidgets.vue'; import { i18n } from '@/i18n.js'; diff --git a/packages/frontend/src/ui/minimum.vue b/packages/frontend/src/ui/minimum.vue index 5cbcd69b2c..ec20ac1114 100644 --- a/packages/frontend/src/ui/minimum.vue +++ b/packages/frontend/src/ui/minimum.vue @@ -5,9 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div :class="$style.root"> - <div style="container-type: inline-size;"> - <RouterView/> - </div> + <RouterView/> <XCommon/> </div> @@ -15,36 +13,35 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { computed, provide, ref } from 'vue'; -import XCommon from './_common_/common.vue'; -import { provideMetadataReceiver, provideReactiveMetadata } from '@/scripts/page-metadata.js'; -import type { PageMetadata } from '@/scripts/page-metadata.js'; import { instanceName } from '@@/js/config.js'; -import { mainRouter } from '@/router/main.js'; +import XCommon from './_common_/common.vue'; +import type { PageMetadata } from '@/page.js'; +import { provideMetadataReceiver, provideReactiveMetadata } from '@/page.js'; +import { mainRouter } from '@/router.js'; +import { DI } from '@/di.js'; const isRoot = computed(() => mainRouter.currentRoute.value.name === 'index'); const pageMetadata = ref<null | PageMetadata>(null); -provide('router', mainRouter); +provide(DI.router, mainRouter); provideMetadataReceiver((metadataGetter) => { const info = metadataGetter(); pageMetadata.value = info; if (pageMetadata.value) { if (isRoot.value && pageMetadata.value.title === instanceName) { - document.title = pageMetadata.value.title; + window.document.title = pageMetadata.value.title; } else { - document.title = `${pageMetadata.value.title} | ${instanceName}`; + window.document.title = `${pageMetadata.value.title} | ${instanceName}`; } } }); provideReactiveMetadata(pageMetadata); - -document.documentElement.style.overflowY = 'scroll'; </script> <style lang="scss" module> .root { - min-height: 100dvh; - box-sizing: border-box; + position: relative; + height: 100dvh; } </style> diff --git a/packages/frontend/src/ui/universal.vue b/packages/frontend/src/ui/universal.vue index 25f47a2d55..940cf72e28 100644 --- a/packages/frontend/src/ui/universal.vue +++ b/packages/frontend/src/ui/universal.vue @@ -5,117 +5,47 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div :class="$style.root"> - <XSidebar v-if="!isMobile" :class="$style.sidebar"/> + <XSidebar v-if="!isMobile" :class="$style.sidebar" :showWidgetButton="!isDesktop" @widgetButtonClick="widgetsShowing = true"/> - <MkStickyContainer ref="contents" :class="$style.contents" style="container-type: inline-size;" @contextmenu.stop="onContextmenu"> - <template #header> - <div> - <XAnnouncements v-if="$i"/> - <XStatusBars :class="$style.statusbars"/> - </div> - </template> - <RouterView/> - <div :class="$style.spacer"></div> - </MkStickyContainer> + <div :class="$style.contents" @contextmenu.stop="onContextmenu"> + <div> + <XPreferenceRestore v-if="shouldSuggestRestoreBackup"/> + <XAnnouncements v-if="$i"/> + <XStatusBars :class="$style.statusbars"/> + </div> + <StackingRouterView v-if="prefer.s['experimental.stackingRouterView']" :class="$style.content"/> + <RouterView v-else :class="$style.content"/> + <XMobileFooterMenu v-if="isMobile" ref="navFooter" v-model:drawerMenuShowing="drawerMenuShowing" v-model:widgetsShowing="widgetsShowing"/> + </div> <div v-if="isDesktop && !pageMetadata?.needWideArea" :class="$style.widgets"> <XWidgets/> </div> - <button v-if="!isDesktop && !pageMetadata?.needWideArea && !isMobile" :class="$style.widgetButton" class="_button" @click="widgetsShowing = true"><i class="ti ti-apps"></i></button> - - <div v-if="isMobile" ref="navFooter" :class="$style.nav"> - <button :class="$style.navButton" class="_button" @click="drawerMenuShowing = true"><i :class="$style.navButtonIcon" class="ti ti-menu-2"></i><span v-if="menuIndicated" :class="$style.navButtonIndicator" class="_blink"><i class="_indicatorCircle"></i></span></button> - <button :class="$style.navButton" class="_button" @click="isRoot ? top() : mainRouter.push('/')"><i :class="$style.navButtonIcon" class="ti ti-home"></i></button> - <button :class="$style.navButton" class="_button" @click="mainRouter.push('/my/notifications')"> - <i :class="$style.navButtonIcon" class="ti ti-bell"></i> - <span v-if="$i?.hasUnreadNotification" :class="$style.navButtonIndicator" class="_blink"> - <span class="_indicateCounter" :class="$style.itemIndicateValueIcon">{{ $i.unreadNotificationsCount > 99 ? '99+' : $i.unreadNotificationsCount }}</span> - </span> - </button> - <button :class="$style.navButton" class="_button" @click="widgetsShowing = true"><i :class="$style.navButtonIcon" class="ti ti-apps"></i></button> - <button :class="$style.postButton" class="_button" @click="os.post()"><i :class="$style.navButtonIcon" class="ti ti-pencil"></i></button> - </div> - - <Transition - :enterActiveClass="defaultStore.state.animation ? $style.transition_menuDrawerBg_enterActive : ''" - :leaveActiveClass="defaultStore.state.animation ? $style.transition_menuDrawerBg_leaveActive : ''" - :enterFromClass="defaultStore.state.animation ? $style.transition_menuDrawerBg_enterFrom : ''" - :leaveToClass="defaultStore.state.animation ? $style.transition_menuDrawerBg_leaveTo : ''" - > - <div - v-if="drawerMenuShowing" - :class="$style.menuDrawerBg" - class="_modalBg" - @click="drawerMenuShowing = false" - @touchstart.passive="drawerMenuShowing = false" - ></div> - </Transition> - - <Transition - :enterActiveClass="defaultStore.state.animation ? $style.transition_menuDrawer_enterActive : ''" - :leaveActiveClass="defaultStore.state.animation ? $style.transition_menuDrawer_leaveActive : ''" - :enterFromClass="defaultStore.state.animation ? $style.transition_menuDrawer_enterFrom : ''" - :leaveToClass="defaultStore.state.animation ? $style.transition_menuDrawer_leaveTo : ''" - > - <div v-if="drawerMenuShowing" :class="$style.menuDrawer"> - <XDrawerMenu/> - </div> - </Transition> - - <Transition - :enterActiveClass="defaultStore.state.animation ? $style.transition_widgetsDrawerBg_enterActive : ''" - :leaveActiveClass="defaultStore.state.animation ? $style.transition_widgetsDrawerBg_leaveActive : ''" - :enterFromClass="defaultStore.state.animation ? $style.transition_widgetsDrawerBg_enterFrom : ''" - :leaveToClass="defaultStore.state.animation ? $style.transition_widgetsDrawerBg_leaveTo : ''" - > - <div - v-if="widgetsShowing" - :class="$style.widgetsDrawerBg" - class="_modalBg" - @click="widgetsShowing = false" - @touchstart.passive="widgetsShowing = false" - ></div> - </Transition> - - <Transition - :enterActiveClass="defaultStore.state.animation ? $style.transition_widgetsDrawer_enterActive : ''" - :leaveActiveClass="defaultStore.state.animation ? $style.transition_widgetsDrawer_leaveActive : ''" - :enterFromClass="defaultStore.state.animation ? $style.transition_widgetsDrawer_enterFrom : ''" - :leaveToClass="defaultStore.state.animation ? $style.transition_widgetsDrawer_leaveTo : ''" - > - <div v-if="widgetsShowing" :class="$style.widgetsDrawer"> - <button class="_button" :class="$style.widgetsCloseButton" @click="widgetsShowing = false"><i class="ti ti-x"></i></button> - <XWidgets/> - </div> - </Transition> - - <XCommon/> + <XCommon v-model:drawerMenuShowing="drawerMenuShowing" v-model:widgetsShowing="widgetsShowing"/> </div> </template> <script lang="ts" setup> -import { defineAsyncComponent, provide, onMounted, computed, ref, watch, shallowRef } from 'vue'; +import { defineAsyncComponent, provide, onMounted, computed, ref } from 'vue'; import { instanceName } from '@@/js/config.js'; -import { CURRENT_STICKY_BOTTOM } from '@@/js/const.js'; import { isLink } from '@@/js/is-link.js'; import XCommon from './_common_/common.vue'; -import type { Ref } from 'vue'; -import type MkStickyContainer from '@/components/global/MkStickyContainer.vue'; -import type { PageMetadata } from '@/scripts/page-metadata.js'; -import XDrawerMenu from '@/ui/_common_/navbar-for-mobile.vue'; +import type { PageMetadata } from '@/page.js'; +import XMobileFooterMenu from '@/ui/_common_/mobile-footer-menu.vue'; +import XPreferenceRestore from '@/ui/_common_/PreferenceRestore.vue'; import * as os from '@/os.js'; -import { defaultStore } from '@/store.js'; -import { navbarItemDef } from '@/navbar.js'; import { i18n } from '@/i18n.js'; -import { $i } from '@/account.js'; -import { provideMetadataReceiver, provideReactiveMetadata } from '@/scripts/page-metadata.js'; -import { deviceKind } from '@/scripts/device-kind.js'; +import { $i } from '@/i.js'; +import { provideMetadataReceiver, provideReactiveMetadata } from '@/page.js'; +import { deviceKind } from '@/utility/device-kind.js'; import { miLocalStorage } from '@/local-storage.js'; -import { useScrollPositionManager } from '@/nirax.js'; -import { mainRouter } from '@/router/main.js'; +import { mainRouter } from '@/router.js'; +import { prefer } from '@/preferences.js'; +import { shouldSuggestRestoreBackup } from '@/preferences/utility.js'; +import { DI } from '@/di.js'; -const XWidgets = defineAsyncComponent(() => import('./universal.widgets.vue')); +const XWidgets = defineAsyncComponent(() => import('./_common_/widgets.vue')); const XSidebar = defineAsyncComponent(() => import('@/ui/_common_/navbar.vue')); const XStatusBars = defineAsyncComponent(() => import('@/ui/_common_/statusbars.vue')); const XAnnouncements = defineAsyncComponent(() => import('@/ui/_common_/announcements.vue')); @@ -134,31 +64,21 @@ window.addEventListener('resize', () => { const pageMetadata = ref<null | PageMetadata>(null); const widgetsShowing = ref(false); -const navFooter = shallowRef<HTMLElement>(); -const contents = shallowRef<InstanceType<typeof MkStickyContainer>>(); -provide('router', mainRouter); +provide(DI.router, mainRouter); provideMetadataReceiver((metadataGetter) => { const info = metadataGetter(); pageMetadata.value = info; if (pageMetadata.value) { if (isRoot.value && pageMetadata.value.title === instanceName) { - document.title = pageMetadata.value.title; + window.document.title = pageMetadata.value.title; } else { - document.title = `${pageMetadata.value.title} | ${instanceName}`; + window.document.title = `${pageMetadata.value.title} | ${instanceName}`; } } }); provideReactiveMetadata(pageMetadata); -const menuIndicated = computed(() => { - for (const def in navbarItemDef) { - if (def === 'notifications') continue; // 通知は下にボタンとして表示されてるから - if (navbarItemDef[def].indicated) return true; - } - return false; -}); - const drawerMenuShowing = ref(false); mainRouter.on('change', () => { @@ -170,25 +90,10 @@ if (window.innerWidth > 1024) { if (tempUI) { miLocalStorage.setItem('ui', tempUI); miLocalStorage.removeItem('ui_temp'); - location.reload(); + window.location.reload(); } } -defaultStore.loaded.then(() => { - if (defaultStore.state.widgets.length === 0) { - defaultStore.set('widgets', [{ - name: 'calendar', - id: 'a', place: 'right', data: {}, - }, { - name: 'notifications', - id: 'b', place: 'right', data: {}, - }, { - name: 'trends', - id: 'c', place: 'right', data: {}, - }]); - } -}); - onMounted(() => { if (!isDesktop.value) { window.addEventListener('resize', () => { @@ -201,7 +106,7 @@ const onContextmenu = (ev) => { if (isLink(ev.target)) return; if (['INPUT', 'TEXTAREA', 'IMG', 'VIDEO', 'CANVAS'].includes(ev.target.tagName) || ev.target.attributes['contenteditable']) return; if (window.getSelection()?.toString() !== '') return; - const path = mainRouter.getCurrentPath(); + const path = mainRouter.getCurrentFullPath(); os.contextMenu([{ type: 'label', text: path, @@ -213,104 +118,12 @@ const onContextmenu = (ev) => { }, }], ev); }; - -function top() { - contents.value.rootEl.scrollTo({ - top: 0, - behavior: 'smooth', - }); -} - -const navFooterHeight = ref(0); -provide<Ref<number>>(CURRENT_STICKY_BOTTOM, navFooterHeight); - -watch(navFooter, () => { - if (navFooter.value) { - navFooterHeight.value = navFooter.value.offsetHeight; - document.body.style.setProperty('--MI-stickyBottom', `${navFooterHeight.value}px`); - document.body.style.setProperty('--MI-minBottomSpacing', 'var(--MI-minBottomSpacingMobile)'); - } else { - navFooterHeight.value = 0; - document.body.style.setProperty('--MI-stickyBottom', '0px'); - document.body.style.setProperty('--MI-minBottomSpacing', '0px'); - } -}, { - immediate: true, -}); - -useScrollPositionManager(() => contents.value.rootEl, mainRouter); </script> -<style> -html, -body { - width: 100%; - height: 100%; - overflow: clip; - position: fixed; - top: 0; - left: 0; - overscroll-behavior: none; -} - -#misskey_app { - width: 100%; - height: 100%; - overflow: clip; - position: absolute; - top: 0; - left: 0; -} -</style> - <style lang="scss" module> $ui-font-size: 1em; // TODO: どこかに集約したい $widgets-hide-threshold: 1090px; -.transition_menuDrawerBg_enterActive, -.transition_menuDrawerBg_leaveActive { - opacity: 1; - transition: opacity 300ms cubic-bezier(0.23, 1, 0.32, 1); -} -.transition_menuDrawerBg_enterFrom, -.transition_menuDrawerBg_leaveTo { - opacity: 0; -} - -.transition_menuDrawer_enterActive, -.transition_menuDrawer_leaveActive { - opacity: 1; - transform: translateX(0); - transition: transform 300ms cubic-bezier(0.23, 1, 0.32, 1), opacity 300ms cubic-bezier(0.23, 1, 0.32, 1); -} -.transition_menuDrawer_enterFrom, -.transition_menuDrawer_leaveTo { - opacity: 0; - transform: translateX(-240px); -} - -.transition_widgetsDrawerBg_enterActive, -.transition_widgetsDrawerBg_leaveActive { - opacity: 1; - transition: opacity 300ms cubic-bezier(0.23, 1, 0.32, 1); -} -.transition_widgetsDrawerBg_enterFrom, -.transition_widgetsDrawerBg_leaveTo { - opacity: 0; -} - -.transition_widgetsDrawer_enterActive, -.transition_widgetsDrawer_leaveActive { - opacity: 1; - transform: translateX(0); - transition: transform 300ms cubic-bezier(0.23, 1, 0.32, 1), opacity 300ms cubic-bezier(0.23, 1, 0.32, 1); -} -.transition_widgetsDrawer_enterFrom, -.transition_widgetsDrawer_leaveTo { - opacity: 0; - transform: translateX(240px); -} - .root { height: 100dvh; overflow: clip; @@ -324,15 +137,23 @@ $widgets-hide-threshold: 1090px; } .contents { + display: flex; + flex-direction: column; flex: 1; height: 100%; min-width: 0; - overflow: auto; - overflow-y: scroll; - overscroll-behavior: contain; background: var(--MI_THEME-bg); - scroll-padding-top: 60px; // TODO: ちゃんと計算する - scroll-padding-bottom: 60px; // TODO: ちゃんと計算する +} + +.content { + flex: 1; + min-height: 0; +} + +.statusbars { + position: sticky; + top: 0; + left: 0; } .widgets { @@ -348,145 +169,4 @@ $widgets-hide-threshold: 1090px; display: none; } } - -.widgetButton { - display: block; - position: fixed; - z-index: 1000; - bottom: 32px; - right: 32px; - width: 64px; - height: 64px; - border-radius: 100%; - box-shadow: 0 3px 5px -1px rgba(0, 0, 0, 0.2), 0 6px 10px 0 rgba(0, 0, 0, 0.14), 0 1px 18px 0 rgba(0, 0, 0, 0.12); - font-size: 22px; - background: var(--MI_THEME-panel); -} - -.widgetsDrawerBg { - z-index: 1001; -} - -.widgetsDrawer { - position: fixed; - top: 0; - right: 0; - z-index: 1001; - width: 310px; - height: 100dvh; - padding: var(--MI-margin) var(--MI-margin) calc(var(--MI-margin) + env(safe-area-inset-bottom, 0px)) !important; - box-sizing: border-box; - overflow: auto; - overscroll-behavior: contain; - background: var(--MI_THEME-bg); -} - -.widgetsCloseButton { - padding: 8px; - display: block; - margin: 0 auto; -} - -@media (min-width: 370px) { - .widgetsCloseButton { - display: none; - } -} - -.nav { - position: fixed; - z-index: 1000; - bottom: 0; - left: 0; - padding: 12px 12px max(12px, env(safe-area-inset-bottom, 0px)) 12px; - display: grid; - grid-template-columns: 1fr 1fr 1fr 1fr 1fr; - grid-gap: 8px; - width: 100%; - box-sizing: border-box; - -webkit-backdrop-filter: var(--MI-blur, blur(24px)); - backdrop-filter: var(--MI-blur, blur(24px)); - background-color: var(--MI_THEME-header); - border-top: solid 0.5px var(--MI_THEME-divider); -} - -.navButton { - position: relative; - padding: 0; - aspect-ratio: 1; - width: 100%; - max-width: 60px; - margin: auto; - border-radius: 100%; - background: var(--MI_THEME-panel); - color: var(--MI_THEME-fg); - - &:hover { - background: var(--MI_THEME-panelHighlight); - } - - &:active { - background: hsl(from var(--MI_THEME-panel) h s calc(l - 2)); - } -} - -.postButton { - composes: navButton; - background: linear-gradient(90deg, var(--MI_THEME-buttonGradateA), var(--MI_THEME-buttonGradateB)); - color: var(--MI_THEME-fgOnAccent); - - &:hover { - background: linear-gradient(90deg, hsl(from var(--MI_THEME-accent) h s calc(l + 5)), hsl(from var(--MI_THEME-accent) h s calc(l + 5))); - } - - &:active { - background: linear-gradient(90deg, hsl(from var(--MI_THEME-accent) h s calc(l + 5)), hsl(from var(--MI_THEME-accent) h s calc(l + 5))); - } -} - -.navButtonIcon { - font-size: 18px; - vertical-align: middle; -} - -.navButtonIndicator { - position: absolute; - top: 0; - left: 0; - color: var(--MI_THEME-indicator); - font-size: 16px; - - &:has(.itemIndicateValueIcon) { - animation: none; - font-size: 12px; - } -} - -.menuDrawerBg { - z-index: 1001; -} - -.menuDrawer { - position: fixed; - top: 0; - left: 0; - z-index: 1001; - height: 100dvh; - width: 240px; - box-sizing: border-box; - contain: strict; - overflow: auto; - overscroll-behavior: contain; - background: var(--MI_THEME-navBg); -} - -.statusbars { - position: sticky; - top: 0; - left: 0; -} - -.spacer { - height: calc(var(--MI-minBottomSpacing)); -} </style> diff --git a/packages/frontend/src/ui/visitor.vue b/packages/frontend/src/ui/visitor.vue index 8bcb260677..3e07959458 100644 --- a/packages/frontend/src/ui/visitor.vue +++ b/packages/frontend/src/ui/visitor.vue @@ -4,84 +4,40 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div class="mk-app"> +<div :class="$style.root"> <a v-if="isRoot" href="https://github.com/misskey-dev/misskey" target="_blank" class="github-corner" aria-label="View source on GitHub"><svg width="80" height="80" viewBox="0 0 250 250" style="fill:var(--MI_THEME-panel); color:var(--MI_THEME-fg); position: fixed; z-index: 10; top: 0; border: 0; right: 0;" aria-hidden="true"><path d="M0,0 L115,115 L130,115 L142,142 L250,250 L250,0 Z"></path><path d="M128.3,109.0 C113.8,99.7 119.0,89.6 119.0,89.6 C122.0,82.7 120.5,78.6 120.5,78.6 C119.2,72.0 123.4,76.3 123.4,76.3 C127.3,80.9 125.5,87.3 125.5,87.3 C122.9,97.6 130.6,101.9 134.4,103.2" fill="currentColor" style="transform-origin: 130px 106px;" class="octo-arm"></path><path d="M115.0,115.0 C114.9,115.1 118.7,116.5 119.8,115.4 L133.7,101.6 C136.9,99.2 139.9,98.4 142.2,98.6 C133.8,88.0 127.5,74.4 143.8,58.0 C148.5,53.4 154.0,51.2 159.7,51.0 C160.3,49.4 163.2,43.6 171.4,40.1 C171.4,40.1 176.1,42.5 178.8,56.2 C183.1,58.6 187.2,61.8 190.9,65.4 C194.5,69.0 197.7,73.2 200.1,77.6 C213.8,80.2 216.3,84.9 216.3,84.9 C212.7,93.1 206.9,96.0 205.4,96.6 C205.1,102.4 203.0,107.8 198.3,112.5 C181.9,128.9 168.3,122.5 157.7,114.1 C157.9,116.9 156.7,120.9 152.7,124.9 L141.0,136.5 C139.8,137.7 141.6,141.9 141.8,141.8 Z" fill="currentColor" class="octo-body"></path></svg></a> - <div v-if="!narrow && !isRoot" class="side"> - <div class="banner" :style="{ backgroundImage: instance.backgroundImageUrl ? `url(${ instance.backgroundImageUrl })` : 'none' }"></div> - <div class="dashboard"> + <div v-if="!narrow && !isRoot" :class="$style.side"> + <div :class="$style.banner" :style="{ backgroundImage: instance.backgroundImageUrl ? `url(${ instance.backgroundImageUrl })` : 'none' }"></div> + <div :class="$style.dashboard"> <MkVisitorDashboard/> </div> </div> - <div class="main"> - <div v-if="!isRoot" class="header"> - <div v-if="narrow === false" class="wide"> - <MkA to="/" class="link" activeClass="active"><i class="ti ti-home icon"></i> {{ i18n.ts.home }}</MkA> - <MkA v-if="isTimelineAvailable" to="/timeline" class="link" activeClass="active"><i class="ti ti-message icon"></i> {{ i18n.ts.timeline }}</MkA> - <MkA to="/explore" class="link" activeClass="active"><i class="ti ti-hash icon"></i> {{ i18n.ts.explore }}</MkA> - <MkA to="/channels" class="link" activeClass="active"><i class="ti ti-device-tv icon"></i> {{ i18n.ts.channel }}</MkA> - </div> - <div v-else-if="narrow === true" class="narrow"> - <button class="menu _button" @click="showMenu = true"> - <i class="ti ti-menu-2 icon"></i> - </button> - </div> - </div> - <div class="contents"> - <main v-if="!isRoot" style="container-type: inline-size;"> - <RouterView/> - </main> - <main v-else> - <RouterView/> - </main> + <div :class="$style.main"> + <button v-if="!isRoot" :class="$style.homeButton" class="_button" @click="goHome"> + <i class="ti ti-home"></i> + </button> + <div :class="$style.content"> + <RouterView/> </div> </div> - - <Transition :name="'tray-back'"> - <div - v-if="showMenu" - class="menu-back _modalBg" - @click="showMenu = false" - @touchstart.passive="showMenu = false" - ></div> - </Transition> - - <Transition :name="'tray'"> - <div v-if="showMenu" class="menu"> - <MkA to="/" class="link" activeClass="active"><i class="ti ti-home icon"></i>{{ i18n.ts.home }}</MkA> - <MkA v-if="isTimelineAvailable" to="/timeline" class="link" activeClass="active"><i class="ti ti-message icon"></i>{{ i18n.ts.timeline }}</MkA> - <MkA to="/explore" class="link" activeClass="active"><i class="ti ti-hash icon"></i>{{ i18n.ts.explore }}</MkA> - <MkA to="/announcements" class="link" activeClass="active"><i class="ti ti-speakerphone icon"></i>{{ i18n.ts.announcements }}</MkA> - <MkA to="/channels" class="link" activeClass="active"><i class="ti ti-device-tv icon"></i>{{ i18n.ts.channel }}</MkA> - <div class="divider"></div> - <MkA to="/pages" class="link" activeClass="active"><i class="ti ti-news icon"></i>{{ i18n.ts.pages }}</MkA> - <MkA to="/play" class="link" activeClass="active"><i class="ti ti-player-play icon"></i>Play</MkA> - <MkA to="/gallery" class="link" activeClass="active"><i class="ti ti-icons icon"></i>{{ i18n.ts.gallery }}</MkA> - <div class="action"> - <button class="_buttonPrimary" @click="signup()">{{ i18n.ts.signup }}</button> - <button class="_button" @click="signin()">{{ i18n.ts.login }}</button> - </div> - </div> - </Transition> </div> <XCommon/> </template> <script lang="ts" setup> import { onMounted, provide, ref, computed } from 'vue'; -import XCommon from './_common_/common.vue'; import { instanceName } from '@@/js/config.js'; +import XCommon from './_common_/common.vue'; +import type { PageMetadata } from '@/page.js'; import * as os from '@/os.js'; import { instance } from '@/instance.js'; -import XSigninDialog from '@/components/MkSigninDialog.vue'; -import XSignupDialog from '@/components/MkSignupDialog.vue'; -import { ColdDeviceStorage, defaultStore } from '@/store.js'; -import { provideMetadataReceiver, provideReactiveMetadata } from '@/scripts/page-metadata.js'; -import type { PageMetadata } from '@/scripts/page-metadata.js'; +import { provideMetadataReceiver, provideReactiveMetadata } from '@/page.js'; import { i18n } from '@/i18n.js'; import MkVisitorDashboard from '@/components/MkVisitorDashboard.vue'; -import { mainRouter } from '@/router/main.js'; +import { mainRouter } from '@/router.js'; +import { DI } from '@/di.js'; const isRoot = computed(() => mainRouter.currentRoute.value.name === 'index'); @@ -89,57 +45,25 @@ const DESKTOP_THRESHOLD = 1100; const pageMetadata = ref<null | PageMetadata>(null); -provide('router', mainRouter); +provide(DI.router, mainRouter); provideMetadataReceiver((metadataGetter) => { const info = metadataGetter(); pageMetadata.value = info; if (pageMetadata.value) { if (isRoot.value && pageMetadata.value.title === instanceName) { - document.title = pageMetadata.value.title; + window.document.title = pageMetadata.value.title; } else { - document.title = `${pageMetadata.value.title} | ${instanceName}`; + window.document.title = `${pageMetadata.value.title} | ${instanceName}`; } } }); provideReactiveMetadata(pageMetadata); -const announcements = { - endpoint: 'announcements', - limit: 10, -}; - -const isTimelineAvailable = ref(instance.policies?.ltlAvailable || instance.policies?.gtlAvailable); - -const showMenu = ref(false); const isDesktop = ref(window.innerWidth >= DESKTOP_THRESHOLD); const narrow = ref(window.innerWidth < 1280); -const keymap = computed(() => { - return { - 'd': () => { - if (ColdDeviceStorage.get('syncDeviceDarkMode')) return; - defaultStore.set('darkMode', !defaultStore.state.darkMode); - }, - 's': () => { - mainRouter.push('/search'); - }, - }; -}); - -function signin() { - const { dispose } = os.popup(XSigninDialog, { - autoSet: true, - }, { - closed: () => dispose(), - }); -} - -function signup() { - const { dispose } = os.popup(XSignupDialog, { - autoSet: true, - }, { - closed: () => dispose(), - }); +function goHome() { + mainRouter.push('/'); } onMounted(() => { @@ -149,149 +73,64 @@ onMounted(() => { }, { passive: true }); } }); - -defineExpose({ - showMenu: showMenu, -}); </script> <style> .github-corner:hover .octo-arm{animation:octocat-wave 560ms ease-in-out}@keyframes octocat-wave{0%,100%{transform:rotate(0)}20%,60%{transform:rotate(-25deg)}40%,80%{transform:rotate(10deg)}}@media (max-width:500px){.github-corner:hover .octo-arm{animation:none}.github-corner .octo-arm{animation:octocat-wave 560ms ease-in-out}} </style> -<style lang="scss" scoped> -.tray-enter-active, -.tray-leave-active { - opacity: 1; - transform: translateX(0); - transition: transform 300ms cubic-bezier(0.23, 1, 0.32, 1), opacity 300ms cubic-bezier(0.23, 1, 0.32, 1); -} -.tray-enter-from, -.tray-leave-active { - opacity: 0; - transform: translateX(-240px); -} - -.tray-back-enter-active, -.tray-back-leave-active { - opacity: 1; - transition: opacity 300ms cubic-bezier(0.23, 1, 0.32, 1); -} -.tray-back-enter-from, -.tray-back-leave-active { - opacity: 0; +<style lang="scss" module> +.root { + display: flex; + height: 100dvh; + overflow: clip; } -.mk-app { +.main { display: flex; - min-height: 100vh; - - > .side { - position: sticky; - top: 0; - left: 0; - width: 500px; - height: 100vh; - background: var(--MI_THEME-accent); - - > .banner { - position: absolute; - top: 0; - left: 0; - width: 100%; - aspect-ratio: 1.5; - background-position: center; - background-size: cover; - -webkit-mask-image: linear-gradient(rgba(0, 0, 0, 1.0), transparent); - mask-image: linear-gradient(rgba(0, 0, 0, 1.0), transparent); - } - - > .dashboard { - position: relative; - padding: 32px; - box-sizing: border-box; - max-height: 100%; - overflow: auto; - } - } - - > .main { - flex: 1; - min-width: 0; - - > .header { - background: var(--MI_THEME-panel); - - > .wide { - line-height: 50px; - padding: 0 16px; - - > .link { - padding: 0 16px; - } - } - - > .narrow { - > .menu { - padding: 16px; - } - } - } - } - - > .menu-back { - position: fixed; - z-index: 1001; - top: 0; - left: 0; - width: 100vw; - height: 100vh; - } - - > .menu { - position: fixed; - z-index: 1001; - top: 0; - left: 0; - width: 240px; - height: 100vh; - background: var(--MI_THEME-panel); - - > .link { - display: block; - padding: 16px; - - > .icon { - margin-right: 1em; - } - } + flex-direction: column; + flex: 1; + min-width: 0; +} - > .divider { - margin: 8px auto; - width: calc(100% - 32px); - border-top: solid 0.5px var(--MI_THEME-divider); - } +.homeButton { + position: fixed; + z-index: 1000; + bottom: 16px; + right: 16px; + width: 60px; + height: 60px; + background: var(--MI_THEME-panel); + border-radius: 999px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); +} - > .action { - padding: 16px; +.side { + position: relative; + width: 500px; + overflow-y: scroll; + background: var(--MI_THEME-accent); +} - > button { - display: block; - width: 100%; - padding: 10px; - box-sizing: border-box; - text-align: center; - border-radius: 999px; +.banner { + position: absolute; + top: 0; + left: 0; + width: 100%; + aspect-ratio: 1.5; + background-position: center; + background-size: cover; + -webkit-mask-image: linear-gradient(rgba(0, 0, 0, 1.0), transparent); + mask-image: linear-gradient(rgba(0, 0, 0, 1.0), transparent); +} - &._button { - background: var(--MI_THEME-panel); - } +.dashboard { + padding: 32px; +} - &:first-child { - margin-bottom: 16px; - } - } - } - } +.content { + display: flex; + flex-direction: column; + height: 100dvh; } </style> diff --git a/packages/frontend/src/ui/zen.vue b/packages/frontend/src/ui/zen.vue index 2e31d056c1..800aef8696 100644 --- a/packages/frontend/src/ui/zen.vue +++ b/packages/frontend/src/ui/zen.vue @@ -4,78 +4,73 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div :class="showBottom ? $style.rootWithBottom : $style.root"> - <div style="container-type: inline-size;"> - <RouterView/> +<div :class="$style.root"> + <div :class="$style.contents"> + <!-- + デッキUIが設定されている場合はデッキUIに戻れるようにする (ただし?zenが明示された場合は表示しない) + See https://github.com/misskey-dev/misskey/issues/10905 + --> + <button v-if="showDeckNav" class="_buttonPrimary" :class="$style.deckNav" @click="goToDeck">{{ i18n.ts.goToDeck }}</button> + + <div style="flex: 1; min-height: 0;"> + <RouterView/> + </div> </div> <XCommon/> </div> - -<!-- - デッキUIが設定されている場合はデッキUIに戻れるようにする (ただし?zenが明示された場合は表示しない) - See https://github.com/misskey-dev/misskey/issues/10905 ---> -<div v-if="showBottom" :class="$style.bottom"> - <button v-tooltip="i18n.ts.goToMisskey" :class="['_button', '_shadow', $style.button]" @click="goToMisskey"><i class="ti ti-home"></i></button> -</div> </template> <script lang="ts" setup> import { computed, provide, ref } from 'vue'; -import XCommon from './_common_/common.vue'; -import { provideMetadataReceiver, provideReactiveMetadata } from '@/scripts/page-metadata.js'; -import type { PageMetadata } from '@/scripts/page-metadata.js'; import { instanceName, ui } from '@@/js/config.js'; +import XCommon from './_common_/common.vue'; +import type { PageMetadata } from '@/page.js'; +import { provideMetadataReceiver, provideReactiveMetadata } from '@/page.js'; import { i18n } from '@/i18n.js'; -import { mainRouter } from '@/router/main.js'; +import { mainRouter } from '@/router.js'; +import { DI } from '@/di.js'; const isRoot = computed(() => mainRouter.currentRoute.value.name === 'index'); const pageMetadata = ref<null | PageMetadata>(null); -const showBottom = !(new URLSearchParams(location.search)).has('zen') && ui === 'deck'; +const showDeckNav = !(new URLSearchParams(window.location.search)).has('zen') && ui === 'deck'; -provide('router', mainRouter); +provide(DI.router, mainRouter); provideMetadataReceiver((metadataGetter) => { const info = metadataGetter(); pageMetadata.value = info; if (pageMetadata.value) { if (isRoot.value && pageMetadata.value.title === instanceName) { - document.title = pageMetadata.value.title; + window.document.title = pageMetadata.value.title; } else { - document.title = `${pageMetadata.value.title} | ${instanceName}`; + window.document.title = `${pageMetadata.value.title} | ${instanceName}`; } } }); provideReactiveMetadata(pageMetadata); -function goToMisskey() { +function goToDeck() { window.location.href = '/'; } - -document.documentElement.style.overflowY = 'scroll'; </script> <style lang="scss" module> .root { - min-height: 100dvh; - box-sizing: border-box; } -.rootWithBottom { - min-height: calc(100dvh - (60px + (var(--MI-margin) * 2) + env(safe-area-inset-bottom, 0px))); - box-sizing: border-box; +.contents { + display: flex; + flex-direction: column; + height: 100dvh; } -.bottom { - height: calc(60px + (var(--MI-margin) * 2) + env(safe-area-inset-bottom, 0px)); - width: 100%; - margin-top: auto; +.deckNav { + padding: 4px; } .button { - position: fixed !important; padding: 0; aspect-ratio: 1; width: 100%; diff --git a/packages/frontend/src/scripts/use-chart-tooltip.ts b/packages/frontend/src/use/use-chart-tooltip.ts index bba64fc6ee..bba64fc6ee 100644 --- a/packages/frontend/src/scripts/use-chart-tooltip.ts +++ b/packages/frontend/src/use/use-chart-tooltip.ts diff --git a/packages/frontend/src/scripts/use-form.ts b/packages/frontend/src/use/use-form.ts index 26cca839c3..26cca839c3 100644 --- a/packages/frontend/src/scripts/use-form.ts +++ b/packages/frontend/src/use/use-form.ts diff --git a/packages/frontend/src/scripts/use-leave-guard.ts b/packages/frontend/src/use/use-leave-guard.ts index 395c12a756..395c12a756 100644 --- a/packages/frontend/src/scripts/use-leave-guard.ts +++ b/packages/frontend/src/use/use-leave-guard.ts diff --git a/packages/frontend/src/use/use-mutation-observer.ts b/packages/frontend/src/use/use-mutation-observer.ts new file mode 100644 index 0000000000..7b774022dc --- /dev/null +++ b/packages/frontend/src/use/use-mutation-observer.ts @@ -0,0 +1,21 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { onUnmounted, watch } from 'vue'; +import type { Ref } from 'vue'; + +export function useMutationObserver(targetNodeRef: Ref<HTMLElement | null | undefined>, options: MutationObserverInit, callback: MutationCallback): void { + const observer = new MutationObserver(callback); + + watch(targetNodeRef, (targetNode) => { + if (targetNode) { + observer.observe(targetNode, options); + } + }, { immediate: true }); + + onUnmounted(() => { + observer.disconnect(); + }); +} diff --git a/packages/frontend/src/scripts/use-note-capture.ts b/packages/frontend/src/use/use-note-capture.ts index 0bc10e90e4..97aec4c1f0 100644 --- a/packages/frontend/src/scripts/use-note-capture.ts +++ b/packages/frontend/src/use/use-note-capture.ts @@ -7,7 +7,7 @@ import { onUnmounted } from 'vue'; import type { Ref, ShallowRef } from 'vue'; import * as Misskey from 'misskey-js'; import { useStream } from '@/stream.js'; -import { $i } from '@/account.js'; +import { $i } from '@/i.js'; export function useNoteCapture(props: { rootEl: ShallowRef<HTMLElement | undefined>; @@ -86,7 +86,7 @@ export function useNoteCapture(props: { function capture(withHandler = false): void { if (connection) { // TODO: このノートがストリーミング経由で流れてきた場合のみ sr する - connection.send(document.body.contains(props.rootEl.value ?? null as Node | null) ? 'sr' : 's', { id: note.value.id }); + connection.send(window.document.body.contains(props.rootEl.value ?? null as Node | null) ? 'sr' : 's', { id: note.value.id }); if (pureNote.value.id !== note.value.id) connection.send('s', { id: pureNote.value.id }); if (withHandler) connection.on('noteUpdated', onStreamNoteUpdated); } diff --git a/packages/frontend/src/scripts/use-tooltip.ts b/packages/frontend/src/use/use-tooltip.ts index d9ddfc8b5d..af76a3a1e8 100644 --- a/packages/frontend/src/scripts/use-tooltip.ts +++ b/packages/frontend/src/use/use-tooltip.ts @@ -29,7 +29,7 @@ export function useTooltip( if (!isHovering) return; if (elRef.value == null) return; const el = elRef.value instanceof Element ? elRef.value : elRef.value.$el; - if (!document.body.contains(el)) return; // openしようとしたときに既に元要素がDOMから消えている場合があるため + if (!window.document.body.contains(el)) return; // openしようとしたときに既に元要素がDOMから消えている場合があるため const showing = ref(true); onShow(showing); @@ -38,7 +38,7 @@ export function useTooltip( }; autoHidingTimer = window.setInterval(() => { - if (elRef.value == null || !document.body.contains(elRef.value instanceof Element ? elRef.value : elRef.value.$el)) { + if (elRef.value == null || !window.document.body.contains(elRef.value instanceof Element ? elRef.value : elRef.value.$el)) { if (!isHovering) return; isHovering = false; window.clearTimeout(timeoutId); diff --git a/packages/frontend/src/scripts/achievements.ts b/packages/frontend/src/utility/achievements.ts index f5d0ab559f..06b445ab0d 100644 --- a/packages/frontend/src/scripts/achievements.ts +++ b/packages/frontend/src/utility/achievements.ts @@ -3,8 +3,8 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { $i } from '@/account.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { $i } from '@/i.js'; export const ACHIEVEMENT_TYPES = [ 'notes1', @@ -497,7 +497,7 @@ export async function claimAchievement(type: typeof ACHIEVEMENT_TYPES[number]) { if (claimedAchievements.includes(type)) return; claimingQueue.add(type); claimedAchievements.push(type); - await new Promise(resolve => setTimeout(resolve, (claimingQueue.size - 1) * 500)); + await new Promise(resolve => window.setTimeout(resolve, (claimingQueue.size - 1) * 500)); window.setTimeout(() => { claimingQueue.delete(type); }, 500); diff --git a/packages/frontend/src/scripts/admin-lookup.ts b/packages/frontend/src/utility/admin-lookup.ts index 1b57b853c9..7405e229fe 100644 --- a/packages/frontend/src/scripts/admin-lookup.ts +++ b/packages/frontend/src/utility/admin-lookup.ts @@ -6,7 +6,7 @@ import * as Misskey from 'misskey-js'; import { i18n } from '@/i18n.js'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; export async function lookupUser() { const { canceled, result } = await os.inputText({ diff --git a/packages/frontend/src/scripts/array.ts b/packages/frontend/src/utility/array.ts index f2feb29dfc..f2feb29dfc 100644 --- a/packages/frontend/src/scripts/array.ts +++ b/packages/frontend/src/utility/array.ts diff --git a/packages/frontend/src/scripts/autocomplete.ts b/packages/frontend/src/utility/autocomplete.ts index 9a603b848c..9a603b848c 100644 --- a/packages/frontend/src/scripts/autocomplete.ts +++ b/packages/frontend/src/utility/autocomplete.ts diff --git a/packages/frontend/src/scripts/cache.ts b/packages/frontend/src/utility/cache.ts index 0fbdf34d5d..0fbdf34d5d 100644 --- a/packages/frontend/src/scripts/cache.ts +++ b/packages/frontend/src/utility/cache.ts diff --git a/packages/frontend/src/scripts/chart-legend.ts b/packages/frontend/src/utility/chart-legend.ts index e701d18dd2..e701d18dd2 100644 --- a/packages/frontend/src/scripts/chart-legend.ts +++ b/packages/frontend/src/utility/chart-legend.ts diff --git a/packages/frontend/src/scripts/chart-vline.ts b/packages/frontend/src/utility/chart-vline.ts index 465ca591c6..465ca591c6 100644 --- a/packages/frontend/src/scripts/chart-vline.ts +++ b/packages/frontend/src/utility/chart-vline.ts diff --git a/packages/frontend/src/scripts/check-permissions.ts b/packages/frontend/src/utility/check-permissions.ts index ed86529d5b..2de8fd2cd1 100644 --- a/packages/frontend/src/scripts/check-permissions.ts +++ b/packages/frontend/src/utility/check-permissions.ts @@ -4,7 +4,7 @@ */ import { instance } from '@/instance.js'; -import { $i } from '@/account.js'; +import { $i } from '@/i.js'; export const notesSearchAvailable = ( // FIXME: instance.policies would be null in Vitest diff --git a/packages/frontend/src/scripts/check-reaction-permissions.ts b/packages/frontend/src/utility/check-reaction-permissions.ts index 281ea2520e..281ea2520e 100644 --- a/packages/frontend/src/scripts/check-reaction-permissions.ts +++ b/packages/frontend/src/utility/check-reaction-permissions.ts diff --git a/packages/frontend/src/scripts/check-word-mute.ts b/packages/frontend/src/utility/check-word-mute.ts index 98fea1bced..98fea1bced 100644 --- a/packages/frontend/src/scripts/check-word-mute.ts +++ b/packages/frontend/src/utility/check-word-mute.ts diff --git a/packages/frontend/src/scripts/clear-cache.ts b/packages/frontend/src/utility/clear-cache.ts index 71d1232710..b6ae254727 100644 --- a/packages/frontend/src/scripts/clear-cache.ts +++ b/packages/frontend/src/utility/clear-cache.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { unisonReload } from '@/scripts/unison-reload.js'; +import { unisonReload } from '@/utility/unison-reload.js'; import * as os from '@/os.js'; import { miLocalStorage } from '@/local-storage.js'; import { fetchCustomEmojis } from '@/custom-emojis.js'; diff --git a/packages/frontend/src/scripts/clicker-game.ts b/packages/frontend/src/utility/clicker-game.ts index f9c4bc1829..0544be7757 100644 --- a/packages/frontend/src/scripts/clicker-game.ts +++ b/packages/frontend/src/utility/clicker-game.ts @@ -4,7 +4,7 @@ */ import { ref, computed } from 'vue'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; type SaveData = { gameVersion: number; diff --git a/packages/frontend/src/scripts/clone.ts b/packages/frontend/src/utility/clone.ts index ea8eea14b5..ea8eea14b5 100644 --- a/packages/frontend/src/scripts/clone.ts +++ b/packages/frontend/src/utility/clone.ts diff --git a/packages/frontend/src/scripts/code-highlighter.ts b/packages/frontend/src/utility/code-highlighter.ts index 4d57dcd944..4f2aff9d4c 100644 --- a/packages/frontend/src/scripts/code-highlighter.ts +++ b/packages/frontend/src/utility/code-highlighter.ts @@ -10,18 +10,20 @@ import { bundledThemesInfo } from 'shiki/themes'; import { bundledLanguagesInfo } from 'shiki/langs'; import lightTheme from '@@/themes/_light.json5'; import darkTheme from '@@/themes/_dark.json5'; +import defaultLightTheme from '@@/themes/l-light.json5'; +import defaultDarkTheme from '@@/themes/d-green-lime.json5'; import { unique } from './array.js'; import { deepClone } from './clone.js'; import { deepMerge } from './merge.js'; import type { HighlighterCore, LanguageRegistration, ThemeRegistration, ThemeRegistrationRaw } from 'shiki/core'; -import { ColdDeviceStorage } from '@/store.js'; +import { prefer } from '@/preferences.js'; let _highlighter: HighlighterCore | null = null; export async function getTheme(mode: 'light' | 'dark', getName: true): Promise<string>; export async function getTheme(mode: 'light' | 'dark', getName?: false): Promise<ThemeRegistration | ThemeRegistrationRaw>; export async function getTheme(mode: 'light' | 'dark', getName = false): Promise<ThemeRegistration | ThemeRegistrationRaw | string | null> { - const theme = deepClone(ColdDeviceStorage.get(mode === 'light' ? 'lightTheme' : 'darkTheme')); + const theme = deepClone(mode === 'light' ? prefer.s.lightTheme ?? defaultLightTheme : prefer.s.darkTheme ?? defaultDarkTheme); if (theme.base) { const base = [lightTheme, darkTheme].find(x => x.id === theme.base); @@ -77,19 +79,19 @@ async function initHighlighter() { ], }); - ColdDeviceStorage.watch('lightTheme', async () => { - const newTheme = await getTheme('light'); - if (newTheme.name && !highlighter.getLoadedThemes().includes(newTheme.name)) { - highlighter.loadTheme(newTheme); - } - }); - - ColdDeviceStorage.watch('darkTheme', async () => { - const newTheme = await getTheme('dark'); - if (newTheme.name && !highlighter.getLoadedThemes().includes(newTheme.name)) { - highlighter.loadTheme(newTheme); - } - }); + // TODO + //watch('lightTheme', async () => { + // const newTheme = await getTheme('light'); + // if (newTheme.name && !highlighter.getLoadedThemes().includes(newTheme.name)) { + // highlighter.loadTheme(newTheme); + // } + //}); + //watch('darkTheme', async () => { + // const newTheme = await getTheme('dark'); + // if (newTheme.name && !highlighter.getLoadedThemes().includes(newTheme.name)) { + // highlighter.loadTheme(newTheme); + // } + //}); _highlighter = highlighter; diff --git a/packages/frontend/src/scripts/collect-page-vars.ts b/packages/frontend/src/utility/collect-page-vars.ts index 5096c0669e..5096c0669e 100644 --- a/packages/frontend/src/scripts/collect-page-vars.ts +++ b/packages/frontend/src/utility/collect-page-vars.ts diff --git a/packages/frontend/src/scripts/color.ts b/packages/frontend/src/utility/color.ts index a11255ffd1..a11255ffd1 100644 --- a/packages/frontend/src/scripts/color.ts +++ b/packages/frontend/src/utility/color.ts diff --git a/packages/frontend/src/scripts/confetti.ts b/packages/frontend/src/utility/confetti.ts index 8e53a6ceeb..c19149875f 100644 --- a/packages/frontend/src/scripts/confetti.ts +++ b/packages/frontend/src/utility/confetti.ts @@ -15,11 +15,11 @@ export function confetti(options: { duration?: number; } = {}) { return Math.random() * (max - min) + min; } - const interval = setInterval(() => { + const interval = window.setInterval(() => { const timeLeft = animationEnd - Date.now(); if (timeLeft <= 0) { - return clearInterval(interval); + return window.clearInterval(interval); } const particleCount = 50 * (timeLeft / duration); diff --git a/packages/frontend/src/scripts/contains.ts b/packages/frontend/src/utility/contains.ts index 6137c06e85..6137c06e85 100644 --- a/packages/frontend/src/scripts/contains.ts +++ b/packages/frontend/src/utility/contains.ts diff --git a/packages/frontend/src/scripts/copy-to-clipboard.ts b/packages/frontend/src/utility/copy-to-clipboard.ts index 7e0bb25606..08a759588e 100644 --- a/packages/frontend/src/scripts/copy-to-clipboard.ts +++ b/packages/frontend/src/utility/copy-to-clipboard.ts @@ -3,9 +3,15 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import * as os from '@/os.js'; +import { i18n } from '@/i18n.js'; + /** * Clipboardに値をコピー(TODO: 文字列以外も対応) */ export function copyToClipboard(input: string | null) { - if (input) navigator.clipboard.writeText(input); + if (input) { + navigator.clipboard.writeText(input); + os.toast(i18n.ts.copiedToClipboard); + } }; diff --git a/packages/frontend/src/utility/deep-equal.ts b/packages/frontend/src/utility/deep-equal.ts new file mode 100644 index 0000000000..2859641dc7 --- /dev/null +++ b/packages/frontend/src/utility/deep-equal.ts @@ -0,0 +1,40 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +type JsonLike = string | number | boolean | null | undefined | JsonLike[] | { [key: string]: JsonLike } | Map<string, JsonLike>; + +export function deepEqual(a: JsonLike, b: JsonLike): boolean { + if (a === b) return true; + if (typeof a !== typeof b) return false; + + if (a === null) return b === null; + + if (a === undefined) return b === undefined; + + if (Array.isArray(a) && Array.isArray(b)) { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + if (!deepEqual(a[i], b[i])) return false; + } + return true; + } else if (a instanceof Map && b instanceof Map) { + if (a.size !== b.size) return false; + for (const [k, v] of a) { + if (!deepEqual(v, b.get(k))) return false; + } + return true; + } else if (((typeof a) === 'object') && ((typeof b) === 'object')) { + const aks = Object.keys(a); + const bks = Object.keys(b as { [key: string]: JsonLike }); + if (aks.length !== bks.length) return false; + for (let i = 0; i < aks.length; i++) { + const k = aks[i]; + if (!deepEqual(a[k], (b as { [key: string]: JsonLike })[k])) return false; + } + return true; + } + + return false; +} diff --git a/packages/frontend/src/scripts/device-kind.ts b/packages/frontend/src/utility/device-kind.ts index 7aadb617ca..7aadb617ca 100644 --- a/packages/frontend/src/scripts/device-kind.ts +++ b/packages/frontend/src/utility/device-kind.ts diff --git a/packages/frontend/src/scripts/emoji-picker.ts b/packages/frontend/src/utility/emoji-picker.ts index e704b5fd6f..6279786b2d 100644 --- a/packages/frontend/src/scripts/emoji-picker.ts +++ b/packages/frontend/src/utility/emoji-picker.ts @@ -3,10 +3,10 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { defineAsyncComponent, ref } from 'vue'; +import { defineAsyncComponent, ref, watch } from 'vue'; import type { Ref } from 'vue'; import { popup } from '@/os.js'; -import { defaultStore } from '@/store.js'; +import { prefer } from '@/preferences.js'; /** * 絵文字ピッカーを表示する。 @@ -25,7 +25,14 @@ class EmojiPicker { } public async init() { - const emojisRef = defaultStore.reactiveState.pinnedEmojis; + const emojisRef = ref<string[]>([]); + + watch([prefer.r.emojiPaletteForMain, prefer.r.emojiPalettes], () => { + emojisRef.value = prefer.s.emojiPaletteForMain == null ? prefer.s.emojiPalettes[0].emojis : prefer.s.emojiPalettes.find(palette => palette.id === prefer.s.emojiPaletteForMain)?.emojis ?? []; + }, { + immediate: true, + }); + await popup(defineAsyncComponent(() => import('@/components/MkEmojiPickerDialog.vue')), { src: this.src, pinnedEmojis: emojisRef, diff --git a/packages/frontend/src/scripts/extract-mentions.ts b/packages/frontend/src/utility/extract-mentions.ts index d518562053..d518562053 100644 --- a/packages/frontend/src/scripts/extract-mentions.ts +++ b/packages/frontend/src/utility/extract-mentions.ts diff --git a/packages/frontend/src/scripts/extract-url-from-mfm.ts b/packages/frontend/src/utility/extract-url-from-mfm.ts index d5654ba850..570823d5b5 100644 --- a/packages/frontend/src/scripts/extract-url-from-mfm.ts +++ b/packages/frontend/src/utility/extract-url-from-mfm.ts @@ -4,7 +4,7 @@ */ import * as mfm from 'mfm-js'; -import { unique } from '@/scripts/array.js'; +import { unique } from '@/utility/array.js'; // unique without hash // [ http://a/#1, http://a/#2, http://b/#3 ] => [ http://a/#1, http://b/#3 ] diff --git a/packages/frontend/src/scripts/file-drop.ts b/packages/frontend/src/utility/file-drop.ts index 4259fe25e9..4259fe25e9 100644 --- a/packages/frontend/src/scripts/file-drop.ts +++ b/packages/frontend/src/utility/file-drop.ts diff --git a/packages/frontend/src/scripts/focus-trap.ts b/packages/frontend/src/utility/focus-trap.ts index fb7caea830..13d3bc56d2 100644 --- a/packages/frontend/src/scripts/focus-trap.ts +++ b/packages/frontend/src/utility/focus-trap.ts @@ -2,7 +2,7 @@ * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ -import { getHTMLElementOrNull } from '@/scripts/get-dom-node-or-null.js'; +import { getHTMLElementOrNull } from '@/utility/get-dom-node-or-null.js'; const focusTrapElements = new Set<HTMLElement>(); const ignoreElements = [ @@ -50,7 +50,7 @@ function releaseFocusTrap(el: HTMLElement): void { const highestZIndexElement = getHighestZIndexElement(); - if (el.parentElement != null && el !== document.body) { + if (el.parentElement != null && el !== window.document.body) { el.parentElement.childNodes.forEach((siblingNode) => { const siblingEl = getHTMLElementOrNull(siblingNode); if (!siblingEl) return; @@ -104,7 +104,7 @@ export function focusTrap(el: HTMLElement, hasInteractionWithOtherFocusTrappedEl el.inert = false; } - if (el.parentElement != null && el !== document.body) { + if (el.parentElement != null && el !== window.document.body) { el.parentElement.childNodes.forEach((siblingNode) => { const siblingEl = getHTMLElementOrNull(siblingNode); if (!siblingEl) return; diff --git a/packages/frontend/src/scripts/focus.ts b/packages/frontend/src/utility/focus.ts index 81278b17ea..cbbe8226d7 100644 --- a/packages/frontend/src/scripts/focus.ts +++ b/packages/frontend/src/utility/focus.ts @@ -4,7 +4,7 @@ */ import { getScrollPosition, getScrollContainer, getStickyBottom, getStickyTop } from '@@/js/scroll.js'; -import { getElementOrNull, getNodeOrNull } from '@/scripts/get-dom-node-or-null.js'; +import { getElementOrNull, getNodeOrNull } from '@/utility/get-dom-node-or-null.js'; type MaybeHTMLElement = EventTarget | Node | Element | HTMLElement; @@ -58,7 +58,7 @@ export const focusParent = (input: MaybeHTMLElement | null | undefined, self = f const focusOrScroll = (element: HTMLElement, scroll: boolean) => { if (scroll) { - const scrollContainer = getScrollContainer(element) ?? document.documentElement; + const scrollContainer = getScrollContainer(element) ?? window.document.documentElement; const scrollContainerTop = getScrollPosition(scrollContainer); const stickyTop = getStickyTop(element, scrollContainer); const stickyBottom = getStickyBottom(element, scrollContainer); @@ -74,7 +74,7 @@ const focusOrScroll = (element: HTMLElement, scroll: boolean) => { scrollContainer.scrollTo({ top: scrollTo, behavior: 'instant' }); } - if (document.activeElement !== element) { + if (window.document.activeElement !== element) { element.focus({ preventScroll: true }); } }; diff --git a/packages/frontend/src/scripts/form.ts b/packages/frontend/src/utility/form.ts index 1032e97ac9..1032e97ac9 100644 --- a/packages/frontend/src/scripts/form.ts +++ b/packages/frontend/src/utility/form.ts diff --git a/packages/frontend/src/scripts/format-time-string.ts b/packages/frontend/src/utility/format-time-string.ts index d383f143e1..d383f143e1 100644 --- a/packages/frontend/src/scripts/format-time-string.ts +++ b/packages/frontend/src/utility/format-time-string.ts diff --git a/packages/frontend/src/scripts/fullscreen.ts b/packages/frontend/src/utility/fullscreen.ts index 7a0a018ef3..6702393cf1 100644 --- a/packages/frontend/src/scripts/fullscreen.ts +++ b/packages/frontend/src/utility/fullscreen.ts @@ -35,8 +35,8 @@ export const requestFullscreen = ({ videoEl, playerEl, options }: RequestFullscr export const exitFullscreen = ({ videoEl }: ExitFullscreenProps) => { // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (document.exitFullscreen != null) { - document.exitFullscreen(); + if (window.document.exitFullscreen != null) { + window.document.exitFullscreen(); return; } if (videoEl.webkitExitFullscreen != null) { diff --git a/packages/frontend/src/scripts/get-account-from-id.ts b/packages/frontend/src/utility/get-account-from-id.ts index 40afa10f2d..5d9662a747 100644 --- a/packages/frontend/src/scripts/get-account-from-id.ts +++ b/packages/frontend/src/utility/get-account-from-id.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { get } from '@/scripts/idb-proxy.js'; +import { get } from '@/utility/idb-proxy.js'; export async function getAccountFromId(id: string) { const accounts = await get('accounts') as { token: string; id: string; }[]; diff --git a/packages/frontend/src/scripts/get-appear-note.ts b/packages/frontend/src/utility/get-appear-note.ts index 40ce80eac9..40ce80eac9 100644 --- a/packages/frontend/src/scripts/get-appear-note.ts +++ b/packages/frontend/src/utility/get-appear-note.ts diff --git a/packages/frontend/src/scripts/get-bg-color.ts b/packages/frontend/src/utility/get-bg-color.ts index ccf60b454f..ccf60b454f 100644 --- a/packages/frontend/src/scripts/get-bg-color.ts +++ b/packages/frontend/src/utility/get-bg-color.ts diff --git a/packages/frontend/src/scripts/get-dom-node-or-null.ts b/packages/frontend/src/utility/get-dom-node-or-null.ts index fbf54675fd..fbf54675fd 100644 --- a/packages/frontend/src/scripts/get-dom-node-or-null.ts +++ b/packages/frontend/src/utility/get-dom-node-or-null.ts diff --git a/packages/frontend/src/scripts/get-drive-file-menu.ts b/packages/frontend/src/utility/get-drive-file-menu.ts index c8ab9238d3..3c6cbba002 100644 --- a/packages/frontend/src/scripts/get-drive-file-menu.ts +++ b/packages/frontend/src/utility/get-drive-file-menu.ts @@ -5,12 +5,12 @@ import * as Misskey from 'misskey-js'; import { defineAsyncComponent } from 'vue'; +import type { MenuItem } from '@/types/menu.js'; import { i18n } from '@/i18n.js'; -import { copyToClipboard } from '@/scripts/copy-to-clipboard.js'; +import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import type { MenuItem } from '@/types/menu.js'; -import { defaultStore } from '@/store.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { prefer } from '@/preferences.js'; function rename(file: Misskey.entities.DriveFile) { os.inputText({ @@ -65,7 +65,6 @@ function toggleSensitive(file: Misskey.entities.DriveFile) { function copyUrl(file: Misskey.entities.DriveFile) { copyToClipboard(file.url); - os.success(); } /* @@ -148,9 +147,9 @@ export function getDriveFileMenu(file: Misskey.entities.DriveFile, folder?: Miss action: () => deleteFile(file), }); - if (defaultStore.state.devMode) { + if (prefer.s.devMode) { menuItems.push({ type: 'divider' }, { - icon: 'ti ti-id', + icon: 'ti ti-hash', text: i18n.ts.copyFileId, action: () => { copyToClipboard(file.id); diff --git a/packages/frontend/src/scripts/get-embed-code.ts b/packages/frontend/src/utility/get-embed-code.ts index 158ab9c7f8..d458e64f19 100644 --- a/packages/frontend/src/scripts/get-embed-code.ts +++ b/packages/frontend/src/utility/get-embed-code.ts @@ -4,11 +4,11 @@ */ import { defineAsyncComponent } from 'vue'; import { v4 as uuid } from 'uuid'; -import type { EmbedParams, EmbeddableEntity } from '@@/js/embed-page.js'; import { url } from '@@/js/config.js'; -import * as os from '@/os.js'; -import { copyToClipboard } from '@/scripts/copy-to-clipboard.js'; import { defaultEmbedParams, embedRouteWithScrollbar } from '@@/js/embed-page.js'; +import type { EmbedParams, EmbeddableEntity } from '@@/js/embed-page.js'; +import * as os from '@/os.js'; +import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; const MOBILE_THRESHOLD = 500; @@ -74,7 +74,6 @@ export function genEmbedCode(entity: EmbeddableEntity, id: string, params?: Embe // PCじゃない場合はコードカスタマイズ画面を出さずにそのままコピー if (window.innerWidth < MOBILE_THRESHOLD) { copyToClipboard(getEmbedCode(`/embed/${entity}/${id}`, _params)); - os.success(); } else { const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkEmbedCodeGenDialog.vue')), { entity, diff --git a/packages/frontend/src/scripts/get-note-menu.ts b/packages/frontend/src/utility/get-note-menu.ts index 84122c0e60..dd8bdf43d7 100644 --- a/packages/frontend/src/scripts/get-note-menu.ts +++ b/packages/frontend/src/utility/get-note-menu.ts @@ -4,25 +4,27 @@ */ import { defineAsyncComponent } from 'vue'; -import type { Ref, ShallowRef } from 'vue'; import * as Misskey from 'misskey-js'; import { url } from '@@/js/config.js'; import { claimAchievement } from './achievements.js'; +import type { Ref, ShallowRef } from 'vue'; import type { MenuItem } from '@/types/menu.js'; -import { $i } from '@/account.js'; +import { $i } from '@/i.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 { defaultStore, noteActions } from '@/store.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; +import { store } from '@/store.js'; import { miLocalStorage } from '@/local-storage.js'; -import { getUserMenu } from '@/scripts/get-user-menu.js'; +import { getUserMenu } from '@/utility/get-user-menu.js'; import { clipsCache, favoritedChannelsCache } from '@/cache.js'; import MkRippleEffect from '@/components/MkRippleEffect.vue'; -import { isSupportShare } from '@/scripts/navigator.js'; -import { getAppearNote } from '@/scripts/get-appear-note.js'; -import { genEmbedCode } from '@/scripts/get-embed-code.js'; +import { isSupportShare } from '@/utility/navigator.js'; +import { getAppearNote } from '@/utility/get-appear-note.js'; +import { genEmbedCode } from '@/utility/get-embed-code.js'; +import { prefer } from '@/preferences.js'; +import { getPluginHandlers } from '@/plugin.js'; export async function getNoteClipMenu(props: { note: Misskey.entities.Note; @@ -155,7 +157,6 @@ export function getCopyNoteLinkMenu(note: Misskey.entities.Note, text: string): text, action: (): void => { copyToClipboard(`${url}/notes/${note.id}`); - os.success(); }, }; } @@ -235,7 +236,6 @@ export function getNoteMenu(props: { function copyContent(): void { copyToClipboard(appearNote.text); - os.success(); } function togglePin(pin: boolean): void { @@ -322,7 +322,6 @@ export function getNoteMenu(props: { text: i18n.ts.copyRemoteLink, action: () => { copyToClipboard(appearNote.url ?? appearNote.uri); - os.success(); }, }, { icon: 'ti ti-external-link', @@ -332,7 +331,7 @@ export function getNoteMenu(props: { }, }); } else { - menuItems.push(getNoteEmbedCodeMenu(appearNote, i18n.ts.genEmbedCode)); + menuItems.push(getNoteEmbedCodeMenu(appearNote, i18n.ts.embed)); } if (isSupportShare()) { @@ -481,7 +480,6 @@ export function getNoteMenu(props: { text: i18n.ts.copyRemoteLink, action: () => { copyToClipboard(appearNote.url ?? appearNote.uri); - os.success(); }, }, { icon: 'ti ti-external-link', @@ -491,10 +489,11 @@ export function getNoteMenu(props: { }, }); } else { - menuItems.push(getNoteEmbedCodeMenu(appearNote, i18n.ts.genEmbedCode)); + menuItems.push(getNoteEmbedCodeMenu(appearNote, i18n.ts.embed)); } } + const noteActions = getPluginHandlers('note_action'); if (noteActions.length > 0) { menuItems.push({ type: 'divider' }); @@ -507,13 +506,12 @@ export function getNoteMenu(props: { }))); } - if (defaultStore.state.devMode) { + if (prefer.s.devMode) { menuItems.push({ type: 'divider' }, { - icon: 'ti ti-id', + icon: 'ti ti-hash', text: i18n.ts.copyNoteId, action: () => { copyToClipboard(appearNote.id); - os.success(); }, }); } @@ -558,7 +556,7 @@ export function getRenoteMenu(props: { icon: 'ti ti-repeat', action: () => { const el = props.renoteButton.value; - if (el && defaultStore.state.animation) { + if (el && prefer.s.animation) { const rect = el.getBoundingClientRect(); const x = rect.left + (el.offsetWidth / 2); const y = rect.top + (el.offsetHeight / 2); @@ -596,7 +594,7 @@ export function getRenoteMenu(props: { icon: 'ti ti-repeat', action: () => { const el = props.renoteButton.value; - if (el && defaultStore.state.animation) { + if (el && prefer.s.animation) { const rect = el.getBoundingClientRect(); const x = rect.left + (el.offsetWidth / 2); const y = rect.top + (el.offsetHeight / 2); @@ -605,8 +603,8 @@ export function getRenoteMenu(props: { }); } - const configuredVisibility = defaultStore.state.rememberNoteVisibility ? defaultStore.state.visibility : defaultStore.state.defaultNoteVisibility; - const localOnly = defaultStore.state.rememberNoteVisibility ? defaultStore.state.localOnly : defaultStore.state.defaultNoteLocalOnly; + const configuredVisibility = prefer.s.rememberNoteVisibility ? store.s.visibility : prefer.s.defaultNoteVisibility; + const localOnly = prefer.s.rememberNoteVisibility ? store.s.localOnly : prefer.s.defaultNoteLocalOnly; let visibility = appearNote.visibility; visibility = smallerVisibility(visibility, configuredVisibility); @@ -647,7 +645,7 @@ export function getRenoteMenu(props: { text: channel.name, action: () => { const el = props.renoteButton.value; - if (el && defaultStore.state.animation) { + if (el && prefer.s.animation) { const rect = el.getBoundingClientRect(); const x = rect.left + (el.offsetWidth / 2); const y = rect.top + (el.offsetHeight / 2); diff --git a/packages/frontend/src/scripts/get-note-summary.ts b/packages/frontend/src/utility/get-note-summary.ts index 6fd9947ac1..6fd9947ac1 100644 --- a/packages/frontend/src/scripts/get-note-summary.ts +++ b/packages/frontend/src/utility/get-note-summary.ts diff --git a/packages/frontend/src/scripts/get-user-menu.ts b/packages/frontend/src/utility/get-user-menu.ts index 8f7c3ba3be..563e45c446 100644 --- a/packages/frontend/src/scripts/get-user-menu.ts +++ b/packages/frontend/src/utility/get-user-menu.ts @@ -6,21 +6,22 @@ import { toUnicode } from 'punycode.js'; import { defineAsyncComponent, ref, watch } from 'vue'; import * as Misskey from 'misskey-js'; -import { i18n } from '@/i18n.js'; -import { copyToClipboard } from '@/scripts/copy-to-clipboard.js'; import { host, url } from '@@/js/config.js'; +import type { Router } from '@/router.js'; +import type { MenuItem } from '@/types/menu.js'; +import { i18n } from '@/i18n.js'; +import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { defaultStore, userActions } from '@/store.js'; -import { $i, iAmModerator } from '@/account.js'; -import { notesSearchAvailable, canSearchNonLocalNotes } from '@/scripts/check-permissions.js'; -import type { IRouter } from '@/nirax.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { $i, iAmModerator } from '@/i.js'; +import { notesSearchAvailable, canSearchNonLocalNotes } from '@/utility/check-permissions.js'; import { antennasCache, rolesCache, userListsCache } from '@/cache.js'; -import { mainRouter } from '@/router/main.js'; -import { genEmbedCode } from '@/scripts/get-embed-code.js'; -import type { MenuItem } from '@/types/menu.js'; +import { mainRouter } from '@/router.js'; +import { genEmbedCode } from '@/utility/get-embed-code.js'; +import { prefer } from '@/preferences.js'; +import { getPluginHandlers } from '@/plugin.js'; -export function getUserMenu(user: Misskey.entities.UserDetailed, router: IRouter = mainRouter) { +export function getUserMenu(user: Misskey.entities.UserDetailed, router: Router = mainRouter) { const meId = $i ? $i.id : null; const cleanups = [] as (() => void)[]; @@ -150,24 +151,6 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: IRouter const menuItems: MenuItem[] = []; - menuItems.push({ - icon: 'ti ti-at', - text: i18n.ts.copyUsername, - action: () => { - copyToClipboard(`@${user.username}@${user.host ?? host}`); - }, - }); - - if (notesSearchAvailable && (user.host == null || canSearchNonLocalNotes)) { - menuItems.push({ - icon: 'ti ti-search', - text: i18n.ts.searchThisUsersNotes, - action: () => { - router.push(`/search?username=${encodeURIComponent(user.username)}${user.host != null ? '&host=' + encodeURIComponent(user.host) : ''}`); - }, - }); - } - if (iAmModerator) { menuItems.push({ icon: 'ti ti-user-exclamation', @@ -175,10 +158,27 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: IRouter action: () => { router.push(`/admin/user/${user.id}`); }, - }); + }, { type: 'divider' }); } menuItems.push({ + icon: 'ti ti-at', + text: i18n.ts.copyUsername, + action: () => { + copyToClipboard(`@${user.username}@${user.host ?? host}`); + }, + }); + + menuItems.push({ + icon: 'ti ti-share', + text: i18n.ts.copyProfileUrl, + action: () => { + const canonical = user.host === null ? `@${user.username}` : `@${user.username}@${toUnicode(user.host)}`; + copyToClipboard(`${url}/${canonical}`); + }, + }); + + menuItems.push({ icon: 'ti ti-rss', text: i18n.ts.copyRSS, action: () => { @@ -198,7 +198,7 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: IRouter } else { menuItems.push({ icon: 'ti ti-code', - text: i18n.ts.genEmbedCode, + text: i18n.ts.embed, type: 'parent', children: [{ text: i18n.ts.noteOfThisUser, @@ -209,24 +209,18 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: IRouter }); } - menuItems.push({ - icon: 'ti ti-share', - text: i18n.ts.copyProfileUrl, - action: () => { - const canonical = user.host === null ? `@${user.username}` : `@${user.username}@${toUnicode(user.host)}`; - copyToClipboard(`${url}/${canonical}`); - }, - }); - - if ($i) { + if (notesSearchAvailable && (user.host == null || canSearchNonLocalNotes)) { menuItems.push({ - icon: 'ti ti-mail', - text: i18n.ts.sendMessage, + icon: 'ti ti-search', + text: i18n.ts.searchThisUsersNotes, action: () => { - const canonical = user.host === null ? `@${user.username}` : `@${user.username}@${user.host}`; - os.post({ specified: user, initialText: `${canonical} ` }); + router.push(`/search?username=${encodeURIComponent(user.username)}${user.host != null ? '&host=' + encodeURIComponent(user.host) : ''}`); }, - }, { type: 'divider' }, { + }); + } + + if ($i) { + menuItems.push({ type: 'divider' }, { icon: 'ti ti-pencil', text: i18n.ts.editMemo, action: editMemo, @@ -251,7 +245,7 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: IRouter listId: list.id, userId: user.id, }).then(() => { - list.userIds?.splice(list.userIds?.indexOf(user.id), 1); + list.userIds?.splice(list.userIds.indexOf(user.id), 1); }); } })); @@ -362,6 +356,24 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: IRouter //} menuItems.push({ type: 'divider' }, { + icon: 'ti ti-mail', + text: i18n.ts.sendMessage, + action: () => { + const canonical = user.host === null ? `@${user.username}` : `@${user.username}@${user.host}`; + os.post({ specified: user, initialText: `${canonical} ` }); + }, + }); + + if ($i.policies.chatAvailability === 'available' && user.canChat && user.host == null) { + menuItems.push({ + type: 'link', + icon: 'ti ti-messages', + text: i18n.ts._chat.chatWithThisUser, + to: `/chat/user/${user.id}`, + }); + } + + menuItems.push({ type: 'divider' }, { icon: user.isMuted ? 'ti ti-eye' : 'ti ti-eye-off', text: user.isMuted ? i18n.ts.unmute : i18n.ts.mute, action: toggleMute, @@ -398,9 +410,9 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: IRouter }); } - if (defaultStore.state.devMode) { + if (prefer.s.devMode) { menuItems.push({ type: 'divider' }, { - icon: 'ti ti-id', + icon: 'ti ti-hash', text: i18n.ts.copyUserId, action: () => { copyToClipboard(user.id); @@ -418,6 +430,7 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: IRouter }); } + const userActions = getPluginHandlers('user_action'); if (userActions.length > 0) { menuItems.push({ type: 'divider' }, ...userActions.map(action => ({ icon: 'ti ti-plug', diff --git a/packages/frontend/src/scripts/get-user-name.ts b/packages/frontend/src/utility/get-user-name.ts index 56e91abba0..56e91abba0 100644 --- a/packages/frontend/src/scripts/get-user-name.ts +++ b/packages/frontend/src/utility/get-user-name.ts diff --git a/packages/frontend/src/scripts/hotkey.ts b/packages/frontend/src/utility/hotkey.ts index 04fb235694..d728cdfcb0 100644 --- a/packages/frontend/src/scripts/hotkey.ts +++ b/packages/frontend/src/utility/hotkey.ts @@ -2,7 +2,7 @@ * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ -import { getHTMLElementOrNull } from "@/scripts/get-dom-node-or-null.js"; +import { getHTMLElementOrNull } from '@/utility/get-dom-node-or-null.js'; //#region types export type Keymap = Record<string, CallbackFunction | CallbackObject>; @@ -54,9 +54,9 @@ export const makeHotkey = (keymap: Keymap) => { const actions = parseKeymap(keymap); return (ev: KeyboardEvent) => { if ('pswp' in window && window.pswp != null) return; - if (document.activeElement != null) { - if (IGNORE_ELEMENTS.includes(document.activeElement.tagName.toLowerCase())) return; - if (getHTMLElementOrNull(document.activeElement)?.isContentEditable) return; + if (window.document.activeElement != null) { + if (IGNORE_ELEMENTS.includes(window.document.activeElement.tagName.toLowerCase())) return; + if (getHTMLElementOrNull(window.document.activeElement)?.isContentEditable) return; } for (const action of actions) { if (matchPatterns(ev, action)) { @@ -136,7 +136,7 @@ let lastHotKeyStoreTimer: number | null = null; const storePattern = (ev: KeyboardEvent, callback: CallbackFunction) => { if (lastHotKeyStoreTimer != null) { - clearTimeout(lastHotKeyStoreTimer); + window.clearTimeout(lastHotKeyStoreTimer); } latestHotkey = { diff --git a/packages/frontend/src/scripts/idb-proxy.ts b/packages/frontend/src/utility/idb-proxy.ts index 20f51660c7..20f51660c7 100644 --- a/packages/frontend/src/scripts/idb-proxy.ts +++ b/packages/frontend/src/utility/idb-proxy.ts diff --git a/packages/frontend/src/scripts/idle-render.ts b/packages/frontend/src/utility/idle-render.ts index 6adfedcb9f..32daa1df02 100644 --- a/packages/frontend/src/scripts/idle-render.ts +++ b/packages/frontend/src/utility/idle-render.ts @@ -5,7 +5,7 @@ const requestIdleCallback: typeof globalThis.requestIdleCallback = globalThis.requestIdleCallback ?? ((callback) => { const start = performance.now(); - const timeoutId = setTimeout(() => { + const timeoutId = window.setTimeout(() => { callback({ didTimeout: false, // polyfill でタイムアウト発火することはない timeRemaining() { @@ -17,7 +17,7 @@ const requestIdleCallback: typeof globalThis.requestIdleCallback = globalThis.re return timeoutId; }); const cancelIdleCallback: typeof globalThis.cancelIdleCallback = globalThis.cancelIdleCallback ?? ((timeoutId) => { - clearTimeout(timeoutId); + window.clearTimeout(timeoutId); }); class IdlingRenderScheduler { diff --git a/packages/frontend/src/scripts/init-chart.ts b/packages/frontend/src/utility/init-chart.ts index 41e1636aa7..260899c1d7 100644 --- a/packages/frontend/src/scripts/init-chart.ts +++ b/packages/frontend/src/utility/init-chart.ts @@ -24,7 +24,7 @@ import { import gradient from 'chartjs-plugin-gradient'; import zoomPlugin from 'chartjs-plugin-zoom'; import { MatrixController, MatrixElement } from 'chartjs-chart-matrix'; -import { defaultStore } from '@/store.js'; +import { store } from '@/store.js'; import 'chartjs-adapter-date-fns'; export function initChart() { @@ -50,9 +50,9 @@ export function initChart() { ); // フォントカラー - Chart.defaults.color = getComputedStyle(document.documentElement).getPropertyValue('--MI_THEME-fg'); + Chart.defaults.color = getComputedStyle(window.document.documentElement).getPropertyValue('--MI_THEME-fg'); - Chart.defaults.borderColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'; + Chart.defaults.borderColor = store.s.darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'; Chart.defaults.animation = false; } diff --git a/packages/frontend/src/scripts/initialize-sw.ts b/packages/frontend/src/utility/initialize-sw.ts index 867ebf19ed..867ebf19ed 100644 --- a/packages/frontend/src/scripts/initialize-sw.ts +++ b/packages/frontend/src/utility/initialize-sw.ts diff --git a/packages/frontend/src/scripts/intl-const.ts b/packages/frontend/src/utility/intl-const.ts index 385f59ec39..385f59ec39 100644 --- a/packages/frontend/src/scripts/intl-const.ts +++ b/packages/frontend/src/utility/intl-const.ts diff --git a/packages/frontend/src/utility/intl-string.ts b/packages/frontend/src/utility/intl-string.ts new file mode 100644 index 0000000000..cf715513a5 --- /dev/null +++ b/packages/frontend/src/utility/intl-string.ts @@ -0,0 +1,99 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { versatileLang } from '@@/js/intl-const.js'; +import type { toHiragana as toHiraganaType } from 'wanakana'; + +let toHiragana: typeof toHiraganaType = (str?: string) => str ?? ''; +let isWanakanaLoaded = false; + +/** + * ローマ字変換のセットアップ(日本語以外の環境で読み込まないのでlazy-loading) + * + * ここの比較系関数を使う際は事前に呼び出す必要がある + */ +export async function initIntlString(forceWanakana = false) { + if ((!versatileLang.includes('ja') && !forceWanakana) || isWanakanaLoaded) return; + const { toHiragana: _toHiragana } = await import('wanakana'); + toHiragana = _toHiragana; + isWanakanaLoaded = true; +} + +/** + * - 全角英数字を半角に + * - 半角カタカナを全角に + * - 濁点・半濁点がリガチャになっている(例: `か` + `゛` )ひらがな・カタカナを結合 + * - 異体字を正規化 + * - 小文字に揃える + * - 文字列のトリム + */ +export function normalizeString(str: string) { + const segmenter = new Intl.Segmenter(versatileLang, { granularity: 'grapheme' }); + return [...segmenter.segment(str)].map(({ segment }) => segment.normalize('NFKC')).join('').toLowerCase().trim(); +} + +// https://qiita.com/non-caffeine/items/77360dda05c8ce510084 +const hyphens = [ + 0x002d, // hyphen-minus + 0x02d7, // modifier letter minus sign + 0x1173, // hangul jongseong eu + 0x1680, // ogham space mark + 0x1b78, // balinese musical symbol left-hand open pang + 0x2010, // hyphen + 0x2011, // non-breaking hyphen + 0x2012, // figure dash + 0x2013, // en dash + 0x2014, // em dash + 0x2015, // horizontal bar + 0x2043, // hyphen bullet + 0x207b, // superscript minus + 0x2212, // minus sign + 0x25ac, // black rectangle + 0x2500, // box drawings light horizontal + 0x2501, // box drawings heavy horizontal + 0x2796, // heavy minus sign + 0x30fc, // katakana-hiragana prolonged sound mark + 0x3161, // hangul letter eu + 0xfe58, // small em dash + 0xfe63, // small hyphen-minus + 0xff0d, // fullwidth hyphen-minus + 0xff70, // halfwidth katakana-hiragana prolonged sound mark + 0x10110, // aegean number ten + 0x10191, // roman uncia sign +]; + +const hyphensCodePoints = hyphens.map(code => `\\u{${code.toString(16).padStart(4, '0')}}`); +const hyphensRegex = new RegExp(`[${hyphensCodePoints.join('')}]`, 'ug'); + +/** ハイフンを統一(ローマ字半角入力時に`ー`と`-`が判定できない問題の調整) */ +export function normalizeHyphens(str: string) { + return str.replace(hyphensRegex, '\u002d'); +} + +/** + * `normalizeString` に加えて、カタカナ・ローマ字をひらがなに揃え、ハイフンを統一 + * + * (ローマ字じゃないものもローマ字として認識され変換されるので、文字列比較の際は `normalizeString` を併用する必要あり) + */ +export function normalizeStringWithHiragana(str: string) { + return normalizeHyphens(toHiragana(normalizeString(str), { convertLongVowelMark: false })); +} + +/** aとbが同じかどうか */ +export function compareStringEquals(a: string, b: string) { + if (a === b) return true; // まったく同じ場合はtrue。なお、ノーマライズ前後で文字数が変化することがあるため、文字数が違うからといってfalseにはできない + if (normalizeString(a) === normalizeString(b)) return true; + if (normalizeStringWithHiragana(a) === normalizeStringWithHiragana(b)) return true; + return false; +} + +/** baseにqueryが含まれているかどうか */ +export function compareStringIncludes(base: string, query: string) { + if (base === query) return true; // まったく同じ場合は含まれていると考えてよいのでtrue + if (base.includes(query)) return true; + if (normalizeString(base).includes(normalizeString(query))) return true; + if (normalizeStringWithHiragana(base).includes(normalizeStringWithHiragana(query))) return true; + return false; +} diff --git a/packages/frontend/src/scripts/is-device-darkmode.ts b/packages/frontend/src/utility/is-device-darkmode.ts index 4f487c7cb9..4f487c7cb9 100644 --- a/packages/frontend/src/scripts/is-device-darkmode.ts +++ b/packages/frontend/src/utility/is-device-darkmode.ts diff --git a/packages/frontend/src/scripts/isFfVisibleForMe.ts b/packages/frontend/src/utility/isFfVisibleForMe.ts index e28e5725bc..48ef1c4e49 100644 --- a/packages/frontend/src/scripts/isFfVisibleForMe.ts +++ b/packages/frontend/src/utility/isFfVisibleForMe.ts @@ -4,7 +4,7 @@ */ import * as Misskey from 'misskey-js'; -import { $i } from '@/account.js'; +import { $i } from '@/i.js'; export function isFollowingVisibleForMe(user: Misskey.entities.UserDetailed): boolean { if ($i && ($i.id === user.id || $i.isAdmin || $i.isModerator)) return true; diff --git a/packages/frontend/src/scripts/key-event.ts b/packages/frontend/src/utility/key-event.ts index 020a6c2174..020a6c2174 100644 --- a/packages/frontend/src/scripts/key-event.ts +++ b/packages/frontend/src/utility/key-event.ts diff --git a/packages/frontend/src/scripts/langmap.ts b/packages/frontend/src/utility/langmap.ts index b32de15963..b32de15963 100644 --- a/packages/frontend/src/scripts/langmap.ts +++ b/packages/frontend/src/utility/langmap.ts diff --git a/packages/frontend/src/scripts/login-id.ts b/packages/frontend/src/utility/login-id.ts index b52735caa0..b52735caa0 100644 --- a/packages/frontend/src/scripts/login-id.ts +++ b/packages/frontend/src/utility/login-id.ts diff --git a/packages/frontend/src/scripts/lookup.ts b/packages/frontend/src/utility/lookup.ts index 02f589c7ca..90611094fa 100644 --- a/packages/frontend/src/scripts/lookup.ts +++ b/packages/frontend/src/utility/lookup.ts @@ -3,11 +3,11 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import type { Router } from '@/router.js'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; -import { Router } from '@/nirax.js'; -import { mainRouter } from '@/router/main.js'; +import { mainRouter } from '@/router.js'; export async function lookup(router?: Router) { const _router = router ?? mainRouter; diff --git a/packages/frontend/src/scripts/media-has-audio.ts b/packages/frontend/src/utility/media-has-audio.ts index 4bf3ee5d97..4bf3ee5d97 100644 --- a/packages/frontend/src/scripts/media-has-audio.ts +++ b/packages/frontend/src/utility/media-has-audio.ts diff --git a/packages/frontend/src/scripts/media-proxy.ts b/packages/frontend/src/utility/media-proxy.ts index 78eba35ead..78eba35ead 100644 --- a/packages/frontend/src/scripts/media-proxy.ts +++ b/packages/frontend/src/utility/media-proxy.ts diff --git a/packages/frontend/src/scripts/merge.ts b/packages/frontend/src/utility/merge.ts index 004b6d42a4..004b6d42a4 100644 --- a/packages/frontend/src/scripts/merge.ts +++ b/packages/frontend/src/utility/merge.ts diff --git a/packages/frontend/src/scripts/mfm-function-picker.ts b/packages/frontend/src/utility/mfm-function-picker.ts index a2f777f623..a2f777f623 100644 --- a/packages/frontend/src/scripts/mfm-function-picker.ts +++ b/packages/frontend/src/utility/mfm-function-picker.ts diff --git a/packages/frontend/src/scripts/misskey-api.ts b/packages/frontend/src/utility/misskey-api.ts index dc07ad477b..72ba54ade3 100644 --- a/packages/frontend/src/scripts/misskey-api.ts +++ b/packages/frontend/src/utility/misskey-api.ts @@ -6,7 +6,7 @@ import * as Misskey from 'misskey-js'; import { ref } from 'vue'; import { apiUrl } from '@@/js/config.js'; -import { $i } from '@/account.js'; +import { $i } from '@/i.js'; export const pendingApiRequestsCount = ref(0); export type Endpoint = keyof Misskey.Endpoints; diff --git a/packages/frontend/src/scripts/navigator.ts b/packages/frontend/src/utility/navigator.ts index ffc0a457f4..ffc0a457f4 100644 --- a/packages/frontend/src/scripts/navigator.ts +++ b/packages/frontend/src/utility/navigator.ts diff --git a/packages/frontend/src/scripts/physics.ts b/packages/frontend/src/utility/physics.ts index 8a4e9319b3..5de34fd094 100644 --- a/packages/frontend/src/scripts/physics.ts +++ b/packages/frontend/src/utility/physics.ts @@ -28,7 +28,7 @@ export function physics(container: HTMLElement) { // create renderer const render = Matter.Render.create({ engine: engine, - //element: document.getElementById('debug'), + //element: window.document.getElementById('debug'), options: { width: containerWidth, height: containerHeight, diff --git a/packages/frontend/src/scripts/player-url-transform.ts b/packages/frontend/src/utility/player-url-transform.ts index 39c6df6500..39c6df6500 100644 --- a/packages/frontend/src/scripts/player-url-transform.ts +++ b/packages/frontend/src/utility/player-url-transform.ts diff --git a/packages/frontend/src/scripts/please-login.ts b/packages/frontend/src/utility/please-login.ts index a8a330eb6d..9253105f48 100644 --- a/packages/frontend/src/scripts/please-login.ts +++ b/packages/frontend/src/utility/please-login.ts @@ -4,7 +4,7 @@ */ import { defineAsyncComponent } from 'vue'; -import { $i } from '@/account.js'; +import { $i } from '@/i.js'; import { instance } from '@/instance.js'; import { i18n } from '@/i18n.js'; import { popup } from '@/os.js'; diff --git a/packages/frontend/src/scripts/popout.ts b/packages/frontend/src/utility/popout.ts index 5b141222e8..5b141222e8 100644 --- a/packages/frontend/src/scripts/popout.ts +++ b/packages/frontend/src/utility/popout.ts diff --git a/packages/frontend/src/scripts/popup-position.ts b/packages/frontend/src/utility/popup-position.ts index 3dad41a8b3..3dad41a8b3 100644 --- a/packages/frontend/src/scripts/popup-position.ts +++ b/packages/frontend/src/utility/popup-position.ts diff --git a/packages/frontend/src/scripts/post-message.ts b/packages/frontend/src/utility/post-message.ts index 11b6f52ddd..11b6f52ddd 100644 --- a/packages/frontend/src/scripts/post-message.ts +++ b/packages/frontend/src/utility/post-message.ts diff --git a/packages/frontend/src/utility/random-id.ts b/packages/frontend/src/utility/random-id.ts new file mode 100644 index 0000000000..4e5943a97f --- /dev/null +++ b/packages/frontend/src/utility/random-id.ts @@ -0,0 +1,15 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +const CHARS = 'abcdefghijklmnopqrstuvwxyz'; // CSSの<custom-ident>などで使われることもあるのでa-z以外使うな + +export function randomId(length = 32, characters = CHARS) { + let result = ''; + const charactersLength = characters.length; + for ( let i = 0; i < length; i++ ) { + result += characters.charAt(Math.floor(Math.random() * charactersLength)); + } + return result; +} diff --git a/packages/frontend/src/scripts/reaction-picker.ts b/packages/frontend/src/utility/reaction-picker.ts index c142b3ed2a..7c159fa2da 100644 --- a/packages/frontend/src/scripts/reaction-picker.ts +++ b/packages/frontend/src/utility/reaction-picker.ts @@ -4,10 +4,10 @@ */ import * as Misskey from 'misskey-js'; -import { defineAsyncComponent, ref } from 'vue'; +import { defineAsyncComponent, ref, watch } from 'vue'; import type { Ref } from 'vue'; import { popup } from '@/os.js'; -import { defaultStore } from '@/store.js'; +import { prefer } from '@/preferences.js'; class ReactionPicker { private src: Ref<HTMLElement | null> = ref(null); @@ -21,7 +21,14 @@ class ReactionPicker { } public async init() { - const reactionsRef = defaultStore.reactiveState.reactions; + const reactionsRef = ref<string[]>([]); + + watch([prefer.r.emojiPaletteForReaction, prefer.r.emojiPalettes], () => { + reactionsRef.value = prefer.s.emojiPaletteForReaction == null ? prefer.s.emojiPalettes[0].emojis : prefer.s.emojiPalettes.find(palette => palette.id === prefer.s.emojiPaletteForReaction)?.emojis ?? []; + }, { + immediate: true, + }); + await popup(defineAsyncComponent(() => import('@/components/MkEmojiPickerDialog.vue')), { src: this.src, pinnedEmojis: reactionsRef, diff --git a/packages/frontend/src/scripts/reload-ask.ts b/packages/frontend/src/utility/reload-ask.ts index 733d91b85a..7c7ea113d4 100644 --- a/packages/frontend/src/scripts/reload-ask.ts +++ b/packages/frontend/src/utility/reload-ask.ts @@ -5,7 +5,7 @@ import { i18n } from '@/i18n.js'; import * as os from '@/os.js'; -import { unisonReload } from '@/scripts/unison-reload.js'; +import { unisonReload } from '@/utility/unison-reload.js'; let isReloadConfirming = false; @@ -35,6 +35,6 @@ export async function reloadAsk(opts: { if (opts.unison) { unisonReload(); } else { - location.reload(); + window.location.reload(); } } diff --git a/packages/frontend/src/scripts/search-emoji.ts b/packages/frontend/src/utility/search-emoji.ts index 371f69b9a7..371f69b9a7 100644 --- a/packages/frontend/src/scripts/search-emoji.ts +++ b/packages/frontend/src/utility/search-emoji.ts diff --git a/packages/frontend/src/scripts/select-file.ts b/packages/frontend/src/utility/select-file.ts index c25b4d73bd..731ef58302 100644 --- a/packages/frontend/src/scripts/select-file.ts +++ b/packages/frontend/src/utility/select-file.ts @@ -6,11 +6,11 @@ import { ref } from 'vue'; import * as Misskey from 'misskey-js'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { useStream } from '@/stream.js'; import { i18n } from '@/i18n.js'; -import { defaultStore } from '@/store.js'; -import { uploadFile } from '@/scripts/upload.js'; +import { uploadFile } from '@/utility/upload.js'; +import { prefer } from '@/preferences.js'; export function chooseFileFromPc( multiple: boolean, @@ -20,12 +20,12 @@ export function chooseFileFromPc( nameConverter?: (file: File) => string | undefined; }, ): Promise<Misskey.entities.DriveFile[]> { - const uploadFolder = options?.uploadFolder ?? defaultStore.state.uploadFolder; - const keepOriginal = options?.keepOriginal ?? defaultStore.state.keepOriginalUploading; + const uploadFolder = options?.uploadFolder ?? prefer.s.uploadFolder; + const keepOriginal = options?.keepOriginal ?? false; const nameConverter = options?.nameConverter ?? (() => undefined); return new Promise((res, rej) => { - const input = document.createElement('input'); + const input = window.document.createElement('input'); input.type = 'file'; input.multiple = multiple; input.onchange = () => { @@ -82,7 +82,7 @@ export function chooseFileFromUrl(): Promise<Misskey.entities.DriveFile> { misskeyApi('drive/files/upload-from-url', { url: url, - folderId: defaultStore.state.uploadFolder, + folderId: prefer.s.uploadFolder, marker, }); @@ -96,19 +96,17 @@ export function chooseFileFromUrl(): Promise<Misskey.entities.DriveFile> { function select(src: HTMLElement | EventTarget | null, label: string | null, multiple: boolean): Promise<Misskey.entities.DriveFile[]> { return new Promise((res, rej) => { - const keepOriginal = ref(defaultStore.state.keepOriginalUploading); - os.popupMenu([label ? { text: label, type: 'label', } : undefined, { - type: 'switch', - text: i18n.ts.keepOriginalUploading, - ref: keepOriginal, + text: i18n.ts.upload + ' (' + i18n.ts.compress + ')', + icon: 'ti ti-upload', + action: () => chooseFileFromPc(multiple, { keepOriginal: false }).then(files => res(files)), }, { text: i18n.ts.upload, icon: 'ti ti-upload', - action: () => chooseFileFromPc(multiple, { keepOriginal: keepOriginal.value }).then(files => res(files)), + action: () => chooseFileFromPc(multiple, { keepOriginal: true }).then(files => res(files)), }, { text: i18n.ts.fromDrive, icon: 'ti ti-cloud', diff --git a/packages/frontend/src/utility/settings-search-index.ts b/packages/frontend/src/utility/settings-search-index.ts new file mode 100644 index 0000000000..7ed97ed34f --- /dev/null +++ b/packages/frontend/src/utility/settings-search-index.ts @@ -0,0 +1,35 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { searchIndexes as generated } from 'search-index:settings'; +import type { GeneratedSearchIndexItem } from 'search-index:settings'; + +export type SearchIndexItem = { + id: string; + parentId?: string; + path?: string; + label: string; + keywords: string[]; + icon?: string; +}; + +const rootMods = new Map(generated.map(item => [item.id, item])); + +// link inlining here +for (const item of generated) { + if (item.inlining) { + for (const id of item.inlining) { + const inline = rootMods.get(id); + if (inline) { + inline.parentId = item.id; + } else { + console.log('[Settings Search Index] Failed to inline', id); + } + } + } +} + +export const searchIndexes: SearchIndexItem[] = generated; + diff --git a/packages/frontend/src/scripts/show-moved-dialog.ts b/packages/frontend/src/utility/show-moved-dialog.ts index 35b3ef79d8..db21b028cd 100644 --- a/packages/frontend/src/scripts/show-moved-dialog.ts +++ b/packages/frontend/src/utility/show-moved-dialog.ts @@ -4,7 +4,7 @@ */ import * as os from '@/os.js'; -import { $i } from '@/account.js'; +import { $i } from '@/i.js'; import { i18n } from '@/i18n.js'; export function showMovedDialog() { diff --git a/packages/frontend/src/scripts/show-suspended-dialog.ts b/packages/frontend/src/utility/show-suspended-dialog.ts index 8b89dbb936..8b89dbb936 100644 --- a/packages/frontend/src/scripts/show-suspended-dialog.ts +++ b/packages/frontend/src/utility/show-suspended-dialog.ts diff --git a/packages/frontend/src/scripts/shuffle.ts b/packages/frontend/src/utility/shuffle.ts index 1f6ef1928c..1f6ef1928c 100644 --- a/packages/frontend/src/scripts/shuffle.ts +++ b/packages/frontend/src/utility/shuffle.ts diff --git a/packages/frontend/src/scripts/snowfall-effect.ts b/packages/frontend/src/utility/snowfall-effect.ts index d88bdb6660..5c86969876 100644 --- a/packages/frontend/src/scripts/snowfall-effect.ts +++ b/packages/frontend/src/utility/snowfall-effect.ts @@ -156,7 +156,7 @@ export class SnowfallEffect { easing: 0.0005, }; /** - * @throws {Error} - Thrown when it fails to get WebGL context for the canvas + * @throws {Error} - Thrown when it fails to get WebGL context for the canvas */ constructor(options: { sakura?: boolean; @@ -172,7 +172,7 @@ export class SnowfallEffect { const gl = canvas.getContext('webgl2', { antialias: true }); if (gl == null) throw new Error('Failed to get WebGL context'); - document.body.append(canvas); + window.document.body.append(canvas); this.canvas = canvas; this.gl = gl; @@ -190,7 +190,7 @@ export class SnowfallEffect { } private initCanvas(): HTMLCanvasElement { - const canvas = document.createElement('canvas'); + const canvas = window.document.createElement('canvas'); Object.assign(canvas.style, { position: 'fixed', diff --git a/packages/frontend/src/scripts/sound.ts b/packages/frontend/src/utility/sound.ts index 2008afe045..d3f82a37f2 100644 --- a/packages/frontend/src/scripts/sound.ts +++ b/packages/frontend/src/utility/sound.ts @@ -3,8 +3,9 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import type { SoundStore } from '@/store.js'; -import { defaultStore } from '@/store.js'; +import type { SoundStore } from '@/preferences/def.js'; +import { prefer } from '@/preferences.js'; +import { PREF_DEF } from '@/preferences/def.js'; let ctx: AudioContext; const cache = new Map<string, AudioBuffer>(); @@ -76,6 +77,7 @@ export const operationTypes = [ 'note', 'notification', 'reaction', + 'chatMessage', ] as const; /** サウンドの種類 */ @@ -107,7 +109,7 @@ export async function loadAudio(url: string, options?: { useCache?: boolean; }) let response: Response; try { - response = await fetch(url); + response = await window.fetch(url); } catch (err) { return; } @@ -127,11 +129,11 @@ export async function loadAudio(url: string, options?: { useCache?: boolean; }) * @param type スプライトの種類を指定 */ export function playMisskeySfx(operationType: OperationType) { - const sound = defaultStore.state[`sound_${operationType}`]; + const sound = prefer.s[`sound.on.${operationType}`]; playMisskeySfxFile(sound).then((succeed) => { if (!succeed && sound.type === '_driveFile_') { // ドライブファイルが存在しない場合はデフォルトのサウンドを再生する - const soundName = defaultStore.def[`sound_${operationType}`].default.type as Exclude<SoundType, '_driveFile_'>; + const soundName = PREF_DEF[`sound_${operationType}`].default.type as Exclude<SoundType, '_driveFile_'>; if (_DEV_) console.log(`Failed to play sound: ${sound.fileUrl}, so play default sound: ${soundName}`); playMisskeySfxFileInternal({ type: soundName, @@ -156,7 +158,7 @@ export async function playMisskeySfxFile(soundStore: SoundStore): Promise<boolea canPlay = false; return await playMisskeySfxFileInternal(soundStore).finally(() => { // ごく短時間に音が重複しないように - setTimeout(() => { + window.setTimeout(() => { canPlay = true; }, 25); }); @@ -166,7 +168,7 @@ async function playMisskeySfxFileInternal(soundStore: SoundStore): Promise<boole if (soundStore.type === null || (soundStore.type === '_driveFile_' && !soundStore.fileUrl)) { return false; } - const masterVolume = defaultStore.state.sound_masterVolume; + const masterVolume = prefer.s['sound.masterVolume']; if (isMute() || masterVolume === 0 || soundStore.volume === 0) { return true; // ミュート時は成功として扱う } @@ -198,10 +200,10 @@ export function createSourceNode(buffer: AudioBuffer, opts: { pan?: number; playbackRate?: number; }): { - soundSource: AudioBufferSourceNode; - panNode: StereoPannerNode; - gainNode: GainNode; -} { + soundSource: AudioBufferSourceNode; + panNode: StereoPannerNode; + gainNode: GainNode; + } { const panNode = ctx.createStereoPanner(); panNode.pan.value = opts.pan ?? 0; @@ -225,13 +227,13 @@ export function createSourceNode(buffer: AudioBuffer, opts: { * @param file ファイルのURL(ドライブIDではない) */ export async function getSoundDuration(file: string): Promise<number> { - const audioEl = document.createElement('audio'); + const audioEl = window.document.createElement('audio'); audioEl.src = file; return new Promise((resolve) => { - const si = setInterval(() => { + const si = window.setInterval(() => { if (audioEl.readyState > 0) { resolve(audioEl.duration * 1000); - clearInterval(si); + window.clearInterval(si); audioEl.remove(); } }, 100); @@ -242,13 +244,13 @@ export async function getSoundDuration(file: string): Promise<number> { * ミュートすべきかどうかを判断する */ export function isMute(): boolean { - if (defaultStore.state.sound_notUseSound) { + if (prefer.s['sound.notUseSound']) { // サウンドを出力しない return true; } // noinspection RedundantIfStatementJS - if (defaultStore.state.sound_useSoundOnlyWhenActive && document.visibilityState === 'hidden') { + if (prefer.s['sound.useSoundOnlyWhenActive'] && window.document.visibilityState === 'hidden') { // ブラウザがアクティブな時のみサウンドを出力する return true; } diff --git a/packages/frontend/src/scripts/sticky-sidebar.ts b/packages/frontend/src/utility/sticky-sidebar.ts index 50f1e6ecc8..867c9b8324 100644 --- a/packages/frontend/src/scripts/sticky-sidebar.ts +++ b/packages/frontend/src/utility/sticky-sidebar.ts @@ -18,7 +18,7 @@ export class StickySidebar { this.container = container; this.el = this.container.children[0] as HTMLElement; this.el.style.position = 'sticky'; - this.spacer = document.createElement('div'); + this.spacer = window.document.createElement('div'); this.container.prepend(this.spacer); this.marginTop = marginTop; this.offsetTop = this.container.getBoundingClientRect().top; diff --git a/packages/frontend/src/scripts/stream-mock.ts b/packages/frontend/src/utility/stream-mock.ts index 9b1b368de4..9b1b368de4 100644 --- a/packages/frontend/src/scripts/stream-mock.ts +++ b/packages/frontend/src/utility/stream-mock.ts diff --git a/packages/frontend/src/scripts/test-utils.ts b/packages/frontend/src/utility/test-utils.ts index 52bb2d94e0..54742c1a9e 100644 --- a/packages/frontend/src/scripts/test-utils.ts +++ b/packages/frontend/src/utility/test-utils.ts @@ -5,5 +5,5 @@ export async function tick(): Promise<void> { // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - await new Promise((globalThis.requestIdleCallback ?? setTimeout) as never); + await new Promise((globalThis.requestIdleCallback ?? window.setTimeout) as never); } diff --git a/packages/frontend/src/scripts/theme-editor.ts b/packages/frontend/src/utility/theme-editor.ts index 0206e378bf..ea07e5f2ff 100644 --- a/packages/frontend/src/scripts/theme-editor.ts +++ b/packages/frontend/src/utility/theme-editor.ts @@ -5,8 +5,8 @@ import { v4 as uuid } from 'uuid'; -import { themeProps } from './theme.js'; -import type { Theme } from './theme.js'; +import type { Theme } from '@/theme.js'; +import { themeProps } from '@/theme.js'; export type Default = null; export type Color = string; diff --git a/packages/frontend/src/scripts/time.ts b/packages/frontend/src/utility/time.ts index 275b67ed00..275b67ed00 100644 --- a/packages/frontend/src/scripts/time.ts +++ b/packages/frontend/src/utility/time.ts diff --git a/packages/frontend/src/utility/timeline-date-separate.ts b/packages/frontend/src/utility/timeline-date-separate.ts new file mode 100644 index 0000000000..e1bc9790b9 --- /dev/null +++ b/packages/frontend/src/utility/timeline-date-separate.ts @@ -0,0 +1,63 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { computed } from 'vue'; +import type { Ref } from 'vue'; + +export function getDateText(dateInstance: Date) { + const date = dateInstance.getDate(); + const month = dateInstance.getMonth() + 1; + return `${month.toString()}/${date.toString()}`; +} + +export type DateSeparetedTimelineItem<T> = { + id: string; + type: 'item'; + data: T; +} | { + id: string; + type: 'date'; + prev: Date; + prevText: string; + next: Date; + nextText: string; +}; + +export function makeDateSeparatedTimelineComputedRef<T extends { id: string; createdAt: string; }>(items: Ref<T[]>) { + return computed<DateSeparetedTimelineItem<T>[]>(() => { + const tl: DateSeparetedTimelineItem<T>[] = []; + for (let i = 0; i < items.value.length; i++) { + const item = items.value[i]; + + const date = new Date(item.createdAt); + const nextDate = items.value[i + 1] ? new Date(items.value[i + 1].createdAt) : null; + + tl.push({ + id: item.id, + type: 'item', + data: item, + }); + + if ( + i !== items.value.length - 1 && + nextDate != null && ( + date.getFullYear() !== nextDate.getFullYear() || + date.getMonth() !== nextDate.getMonth() || + date.getDate() !== nextDate.getDate() + ) + ) { + tl.push({ + id: `date-${item.id}`, + type: 'date', + prev: date, + prevText: getDateText(date), + next: nextDate, + nextText: getDateText(nextDate), + }); + } + } + return tl; + }); +} diff --git a/packages/frontend/src/scripts/timezones.ts b/packages/frontend/src/utility/timezones.ts index c7582e06da..c7582e06da 100644 --- a/packages/frontend/src/scripts/timezones.ts +++ b/packages/frontend/src/utility/timezones.ts diff --git a/packages/frontend/src/scripts/touch.ts b/packages/frontend/src/utility/touch.ts index 13c9d648dc..adc2e4c093 100644 --- a/packages/frontend/src/scripts/touch.ts +++ b/packages/frontend/src/utility/touch.ts @@ -4,7 +4,7 @@ */ import { ref } from 'vue'; -import { deviceKind } from '@/scripts/device-kind.js'; +import { deviceKind } from '@/utility/device-kind.js'; const isTouchSupported = 'maxTouchPoints' in navigator && navigator.maxTouchPoints > 0; diff --git a/packages/frontend/src/scripts/unison-reload.ts b/packages/frontend/src/utility/unison-reload.ts index a24941d02e..c4804192f8 100644 --- a/packages/frontend/src/scripts/unison-reload.ts +++ b/packages/frontend/src/utility/unison-reload.ts @@ -12,9 +12,9 @@ export const reloadChannel = new BroadcastChannel<string | null>('reload'); export function unisonReload(path?: string) { if (path !== undefined) { reloadChannel.postMessage(path); - location.href = path; + window.location.href = path; } else { reloadChannel.postMessage(null); - location.reload(); + window.location.reload(); } } diff --git a/packages/frontend/src/scripts/upload.ts b/packages/frontend/src/utility/upload.ts index 713573a377..b43fea8e15 100644 --- a/packages/frontend/src/scripts/upload.ts +++ b/packages/frontend/src/utility/upload.ts @@ -7,13 +7,13 @@ import { reactive, ref } from 'vue'; import * as Misskey from 'misskey-js'; import { v4 as uuid } from 'uuid'; import { readAndCompressImage } from '@misskey-dev/browser-image-resizer'; -import { getCompressionConfig } from './upload/compress-config.js'; -import { defaultStore } from '@/store.js'; import { apiUrl } from '@@/js/config.js'; -import { $i } from '@/account.js'; +import { getCompressionConfig } from './upload/compress-config.js'; +import { $i } from '@/i.js'; import { alert } from '@/os.js'; import { i18n } from '@/i18n.js'; import { instance } from '@/instance.js'; +import { prefer } from '@/preferences.js'; type Uploading = { id: string; @@ -32,9 +32,9 @@ const mimeTypeMap = { export function uploadFile( file: File, - folder?: string | Misskey.entities.DriveFolder, + folder?: string | Misskey.entities.DriveFolder | null, name?: string, - keepOriginal: boolean = defaultStore.state.keepOriginalUploading, + keepOriginal = false, ): Promise<Misskey.entities.DriveFile> { if ($i == null) throw new Error('Not logged in'); @@ -59,7 +59,7 @@ export function uploadFile( const ctx = reactive<Uploading>({ id, - name: defaultStore.state.keepOriginalFilename ? filename : id + extension, + name: prefer.s.keepOriginalFilename ? filename : id + extension, progressMax: undefined, progressValue: undefined, img: window.URL.createObjectURL(file), diff --git a/packages/frontend/src/scripts/upload/compress-config.ts b/packages/frontend/src/utility/upload/compress-config.ts index 3046b7f518..3046b7f518 100644 --- a/packages/frontend/src/scripts/upload/compress-config.ts +++ b/packages/frontend/src/utility/upload/compress-config.ts diff --git a/packages/frontend/src/scripts/upload/isWebpSupported.ts b/packages/frontend/src/utility/upload/isWebpSupported.ts index 2511236ecc..affd81fd57 100644 --- a/packages/frontend/src/scripts/upload/isWebpSupported.ts +++ b/packages/frontend/src/utility/upload/isWebpSupported.ts @@ -6,7 +6,7 @@ let isWebpSupportedCache: boolean | undefined; export function isWebpSupported() { if (isWebpSupportedCache === undefined) { - const canvas = document.createElement('canvas'); + const canvas = window.document.createElement('canvas'); canvas.width = 1; canvas.height = 1; isWebpSupportedCache = canvas.toDataURL('image/webp').startsWith('data:image/webp'); diff --git a/packages/frontend/src/utility/virtual.d.ts b/packages/frontend/src/utility/virtual.d.ts new file mode 100644 index 0000000000..63dc4372b7 --- /dev/null +++ b/packages/frontend/src/utility/virtual.d.ts @@ -0,0 +1,18 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +declare module 'search-index:settings' { + export type GeneratedSearchIndexItem = { + id: string; + parentId?: string; + path?: string; + label: string; + keywords: string[]; + icon?: string; + inlining?: string[]; + }; + + export const searchIndexes: GeneratedSearchIndexItem[]; +} diff --git a/packages/frontend/src/widgets/WidgetActivity.vue b/packages/frontend/src/widgets/WidgetActivity.vue index cf1110da2b..db03d1406c 100644 --- a/packages/frontend/src/widgets/WidgetActivity.vue +++ b/packages/frontend/src/widgets/WidgetActivity.vue @@ -25,10 +25,10 @@ import { useWidgetPropsManager } from './widget.js'; import type { WidgetComponentProps, WidgetComponentEmits, WidgetComponentExpose } from './widget.js'; import XCalendar from './WidgetActivity.calendar.vue'; import XChart from './WidgetActivity.chart.vue'; -import type { GetFormResultType } from '@/scripts/form.js'; -import { misskeyApiGet } from '@/scripts/misskey-api.js'; +import type { GetFormResultType } from '@/utility/form.js'; +import { misskeyApiGet } from '@/utility/misskey-api.js'; import MkContainer from '@/components/MkContainer.vue'; -import { $i } from '@/account.js'; +import { $i } from '@/i.js'; import { i18n } from '@/i18n.js'; const name = 'activity'; diff --git a/packages/frontend/src/widgets/WidgetAichan.vue b/packages/frontend/src/widgets/WidgetAichan.vue index 9b04c463ba..2bc7facc88 100644 --- a/packages/frontend/src/widgets/WidgetAichan.vue +++ b/packages/frontend/src/widgets/WidgetAichan.vue @@ -10,10 +10,10 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted, onUnmounted, shallowRef } from 'vue'; +import { onMounted, onUnmounted, useTemplateRef } from 'vue'; import { useWidgetPropsManager } from './widget.js'; import type { WidgetComponentProps, WidgetComponentEmits, WidgetComponentExpose } from './widget.js'; -import type { GetFormResultType } from '@/scripts/form.js'; +import type { GetFormResultType } from '@/utility/form.js'; const name = 'ai'; @@ -35,7 +35,7 @@ const { widgetProps, configure } = useWidgetPropsManager(name, emit, ); -const live2d = shallowRef<HTMLIFrameElement>(); +const live2d = useTemplateRef('live2d'); const touched = () => { //if (this.live2d) this.live2d.changeExpression('gurugurume'); diff --git a/packages/frontend/src/widgets/WidgetAiscript.vue b/packages/frontend/src/widgets/WidgetAiscript.vue index 80573d2dc4..c46fd81466 100644 --- a/packages/frontend/src/widgets/WidgetAiscript.vue +++ b/packages/frontend/src/widgets/WidgetAiscript.vue @@ -23,11 +23,11 @@ import { ref } from 'vue'; import { Interpreter, Parser, utils } from '@syuilo/aiscript'; import { useWidgetPropsManager } from './widget.js'; import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js'; -import type { GetFormResultType } from '@/scripts/form.js'; +import type { GetFormResultType } from '@/utility/form.js'; import * as os from '@/os.js'; import MkContainer from '@/components/MkContainer.vue'; -import { aiScriptReadline, createAiScriptEnv } from '@/scripts/aiscript/api.js'; -import { $i } from '@/account.js'; +import { aiScriptReadline, createAiScriptEnv } from '@/aiscript/api.js'; +import { $i } from '@/i.js'; import { i18n } from '@/i18n.js'; const name = 'aiscript'; diff --git a/packages/frontend/src/widgets/WidgetAiscriptApp.vue b/packages/frontend/src/widgets/WidgetAiscriptApp.vue index f0f81a4a89..429b0e0ffb 100644 --- a/packages/frontend/src/widgets/WidgetAiscriptApp.vue +++ b/packages/frontend/src/widgets/WidgetAiscriptApp.vue @@ -18,14 +18,14 @@ import type { Ref } from 'vue'; import { Interpreter, Parser } from '@syuilo/aiscript'; import { useWidgetPropsManager } from './widget.js'; import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js'; -import type { GetFormResultType } from '@/scripts/form.js'; +import type { GetFormResultType } from '@/utility/form.js'; import * as os from '@/os.js'; -import { aiScriptReadline, createAiScriptEnv } from '@/scripts/aiscript/api.js'; -import { $i } from '@/account.js'; +import { aiScriptReadline, createAiScriptEnv } from '@/aiscript/api.js'; +import { $i } from '@/i.js'; import MkAsUi from '@/components/MkAsUi.vue'; import MkContainer from '@/components/MkContainer.vue'; -import { registerAsUiLib } from '@/scripts/aiscript/ui.js'; -import type { AsUiComponent, AsUiRoot } from '@/scripts/aiscript/ui.js'; +import { registerAsUiLib } from '@/aiscript/ui.js'; +import type { AsUiComponent, AsUiRoot } from '@/aiscript/ui.js'; const name = 'aiscriptApp'; diff --git a/packages/frontend/src/widgets/WidgetBirthdayFollowings.vue b/packages/frontend/src/widgets/WidgetBirthdayFollowings.vue index 98e186f836..6fe743aed2 100644 --- a/packages/frontend/src/widgets/WidgetBirthdayFollowings.vue +++ b/packages/frontend/src/widgets/WidgetBirthdayFollowings.vue @@ -15,7 +15,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkAvatar v-for="user in users" :key="user.id" :user="user.followee" link preview></MkAvatar> </div> <div v-else :class="$style.bdayFFallback"> - <img :src="infoImageUrl" class="_ghost" :class="$style.bdayFFallbackImage"/> + <img :src="infoImageUrl" draggable="false" :class="$style.bdayFFallbackImage"/> <div>{{ i18n.ts.nothing }}</div> </div> </div> @@ -28,12 +28,12 @@ import * as Misskey from 'misskey-js'; import { useInterval } from '@@/js/use-interval.js'; import { useWidgetPropsManager } from './widget.js'; import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js'; -import type { GetFormResultType } from '@/scripts/form.js'; +import type { GetFormResultType } from '@/utility/form.js'; import MkContainer from '@/components/MkContainer.vue'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; import { infoImageUrl } from '@/instance.js'; -import { $i } from '@/account.js'; +import { $i } from '@/i.js'; const name = i18n.ts._widgets.birthdayFollowings; diff --git a/packages/frontend/src/widgets/WidgetButton.vue b/packages/frontend/src/widgets/WidgetButton.vue index 3e455bee3b..4afe735a22 100644 --- a/packages/frontend/src/widgets/WidgetButton.vue +++ b/packages/frontend/src/widgets/WidgetButton.vue @@ -15,10 +15,10 @@ SPDX-License-Identifier: AGPL-3.0-only import { Interpreter, Parser } from '@syuilo/aiscript'; import { useWidgetPropsManager } from './widget.js'; import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js'; -import type { GetFormResultType } from '@/scripts/form.js'; +import type { GetFormResultType } from '@/utility/form.js'; import * as os from '@/os.js'; -import { aiScriptReadline, createAiScriptEnv } from '@/scripts/aiscript/api.js'; -import { $i } from '@/account.js'; +import { aiScriptReadline, createAiScriptEnv } from '@/aiscript/api.js'; +import { $i } from '@/i.js'; import MkButton from '@/components/MkButton.vue'; const name = 'button'; diff --git a/packages/frontend/src/widgets/WidgetCalendar.vue b/packages/frontend/src/widgets/WidgetCalendar.vue index 94169d5e40..54f78469b2 100644 --- a/packages/frontend/src/widgets/WidgetCalendar.vue +++ b/packages/frontend/src/widgets/WidgetCalendar.vue @@ -41,7 +41,7 @@ SPDX-License-Identifier: AGPL-3.0-only import { ref } from 'vue'; import { useWidgetPropsManager } from './widget.js'; import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js'; -import type { GetFormResultType } from '@/scripts/form.js'; +import type { GetFormResultType } from '@/utility/form.js'; import { i18n } from '@/i18n.js'; import { useInterval } from '@@/js/use-interval.js'; diff --git a/packages/frontend/src/widgets/WidgetClicker.vue b/packages/frontend/src/widgets/WidgetClicker.vue index 2b3663d35b..87ffd3d732 100644 --- a/packages/frontend/src/widgets/WidgetClicker.vue +++ b/packages/frontend/src/widgets/WidgetClicker.vue @@ -14,7 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { useWidgetPropsManager } from './widget.js'; import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js'; -import type { GetFormResultType } from '@/scripts/form.js'; +import type { GetFormResultType } from '@/utility/form.js'; import MkContainer from '@/components/MkContainer.vue'; import MkClickerGame from '@/components/MkClickerGame.vue'; diff --git a/packages/frontend/src/widgets/WidgetClock.vue b/packages/frontend/src/widgets/WidgetClock.vue index 2d3f57a92f..826ecf6e02 100644 --- a/packages/frontend/src/widgets/WidgetClock.vue +++ b/packages/frontend/src/widgets/WidgetClock.vue @@ -32,11 +32,11 @@ SPDX-License-Identifier: AGPL-3.0-only import { computed } from 'vue'; import { useWidgetPropsManager } from './widget.js'; import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js'; -import type { GetFormResultType } from '@/scripts/form.js'; +import type { GetFormResultType } from '@/utility/form.js'; import MkContainer from '@/components/MkContainer.vue'; import MkAnalogClock from '@/components/MkAnalogClock.vue'; import MkDigitalClock from '@/components/MkDigitalClock.vue'; -import { timezones } from '@/scripts/timezones.js'; +import { timezones } from '@/utility/timezones.js'; import { i18n } from '@/i18n.js'; const name = 'clock'; diff --git a/packages/frontend/src/widgets/WidgetDigitalClock.vue b/packages/frontend/src/widgets/WidgetDigitalClock.vue index 6e0c9e6dfc..d79ec79d4f 100644 --- a/packages/frontend/src/widgets/WidgetDigitalClock.vue +++ b/packages/frontend/src/widgets/WidgetDigitalClock.vue @@ -17,8 +17,8 @@ SPDX-License-Identifier: AGPL-3.0-only import { computed } from 'vue'; import { useWidgetPropsManager } from './widget.js'; import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js'; -import type { GetFormResultType } from '@/scripts/form.js'; -import { timezones } from '@/scripts/timezones.js'; +import type { GetFormResultType } from '@/utility/form.js'; +import { timezones } from '@/utility/timezones.js'; import MkDigitalClock from '@/components/MkDigitalClock.vue'; const name = 'digitalClock'; diff --git a/packages/frontend/src/widgets/WidgetFederation.vue b/packages/frontend/src/widgets/WidgetFederation.vue index 89716575a9..ca6f27bd09 100644 --- a/packages/frontend/src/widgets/WidgetFederation.vue +++ b/packages/frontend/src/widgets/WidgetFederation.vue @@ -10,7 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div class="wbrkwalb"> <MkLoading v-if="fetching"/> - <TransitionGroup v-else tag="div" :name="defaultStore.state.animation ? 'chart' : ''" class="instances"> + <TransitionGroup v-else tag="div" :name="prefer.s.animation ? 'chart' : ''" class="instances"> <div v-for="(instance, i) in instances" :key="instance.id" class="instance"> <img :src="getInstanceIcon(instance)" alt=""/> <div class="body"> @@ -27,16 +27,16 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { ref } from 'vue'; import * as Misskey from 'misskey-js'; +import { useInterval } from '@@/js/use-interval.js'; import { useWidgetPropsManager } from './widget.js'; import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js'; -import type { GetFormResultType } from '@/scripts/form.js'; +import type { GetFormResultType } from '@/utility/form.js'; import MkContainer from '@/components/MkContainer.vue'; import MkMiniChart from '@/components/MkMiniChart.vue'; -import { misskeyApi, misskeyApiGet } from '@/scripts/misskey-api.js'; -import { useInterval } from '@@/js/use-interval.js'; +import { misskeyApi, misskeyApiGet } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; -import { getProxiedImageUrlNullable } from '@/scripts/media-proxy.js'; -import { defaultStore } from '@/store.js'; +import { getProxiedImageUrlNullable } from '@/utility/media-proxy.js'; +import { prefer } from '@/preferences.js'; const name = 'federation'; diff --git a/packages/frontend/src/widgets/WidgetInstanceCloud.vue b/packages/frontend/src/widgets/WidgetInstanceCloud.vue index aee9066731..0c9f98f9e3 100644 --- a/packages/frontend/src/widgets/WidgetInstanceCloud.vue +++ b/packages/frontend/src/widgets/WidgetInstanceCloud.vue @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <MkContainer :naked="widgetProps.transparent" :showHeader="false" class="mkw-instance-cloud"> <div class=""> - <MkTagCloud v-if="activeInstances"> + <MkTagCloud v-if="activeInstances" ref="cloud"> <li v-for="instance in activeInstances" :key="instance.id"> <a @click.prevent="onInstanceClick(instance)"> <img style="width: 32px;" :src="getInstanceIcon(instance)"> @@ -18,17 +18,17 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { shallowRef } from 'vue'; +import { shallowRef, useTemplateRef } from 'vue'; import * as Misskey from 'misskey-js'; +import { useInterval } from '@@/js/use-interval.js'; import { useWidgetPropsManager } from './widget.js'; import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js'; -import type { GetFormResultType } from '@/scripts/form.js'; +import type { GetFormResultType } from '@/utility/form.js'; import MkContainer from '@/components/MkContainer.vue'; import MkTagCloud from '@/components/MkTagCloud.vue'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { useInterval } from '@@/js/use-interval.js'; -import { getProxiedImageUrlNullable } from '@/scripts/media-proxy.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { getProxiedImageUrlNullable } from '@/utility/media-proxy.js'; const name = 'instanceCloud'; @@ -50,7 +50,7 @@ const { widgetProps, configure } = useWidgetPropsManager(name, emit, ); -const cloud = shallowRef<InstanceType<typeof MkTagCloud> | null>(); +const cloud = useTemplateRef('cloud'); const activeInstances = shallowRef<Misskey.entities.FederationInstance[] | null>(null); function onInstanceClick(i) { diff --git a/packages/frontend/src/widgets/WidgetInstanceInfo.vue b/packages/frontend/src/widgets/WidgetInstanceInfo.vue index 69832332b1..8d721298d5 100644 --- a/packages/frontend/src/widgets/WidgetInstanceInfo.vue +++ b/packages/frontend/src/widgets/WidgetInstanceInfo.vue @@ -22,7 +22,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { useWidgetPropsManager } from './widget.js'; import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js'; -import type { GetFormResultType } from '@/scripts/form.js'; +import type { GetFormResultType } from '@/utility/form.js'; import { host } from '@@/js/config.js'; import { instance } from '@/instance.js'; diff --git a/packages/frontend/src/widgets/WidgetJobQueue.vue b/packages/frontend/src/widgets/WidgetJobQueue.vue index 84ba05b5d3..485e532d51 100644 --- a/packages/frontend/src/widgets/WidgetJobQueue.vue +++ b/packages/frontend/src/widgets/WidgetJobQueue.vue @@ -54,12 +54,12 @@ SPDX-License-Identifier: AGPL-3.0-only import { onUnmounted, reactive, ref } from 'vue'; import { useWidgetPropsManager } from './widget.js'; import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js'; -import type { GetFormResultType } from '@/scripts/form.js'; +import type { GetFormResultType } from '@/utility/form.js'; import { useStream } from '@/stream.js'; import kmg from '@/filters/kmg.js'; -import * as sound from '@/scripts/sound.js'; -import { deepClone } from '@/scripts/clone.js'; -import { defaultStore } from '@/store.js'; +import * as sound from '@/utility/sound.js'; +import { deepClone } from '@/utility/clone.js'; +import { prefer } from '@/preferences.js'; const name = 'jobQueue'; @@ -104,7 +104,7 @@ const prev = reactive({} as typeof current); const jammedAudioBuffer = ref<AudioBuffer | null>(null); const jammedSoundNodePlaying = ref<boolean>(false); -if (defaultStore.state.sound_masterVolume) { +if (prefer.s['sound.masterVolume']) { sound.loadAudio('/client-assets/sounds/syuilo/queue-jammed.mp3').then(buf => { if (!buf) throw new Error('[WidgetJobQueue] Failed to initialize AudioBuffer'); jammedAudioBuffer.value = buf; diff --git a/packages/frontend/src/widgets/WidgetMemo.vue b/packages/frontend/src/widgets/WidgetMemo.vue index 65ab7a7075..3df5c5bfd7 100644 --- a/packages/frontend/src/widgets/WidgetMemo.vue +++ b/packages/frontend/src/widgets/WidgetMemo.vue @@ -19,9 +19,9 @@ SPDX-License-Identifier: AGPL-3.0-only import { ref, watch } from 'vue'; import { useWidgetPropsManager } from './widget.js'; import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js'; -import type { GetFormResultType } from '@/scripts/form.js'; +import type { GetFormResultType } from '@/utility/form.js'; import MkContainer from '@/components/MkContainer.vue'; -import { defaultStore } from '@/store.js'; +import { store } from '@/store.js'; import { i18n } from '@/i18n.js'; const name = 'memo'; @@ -48,12 +48,12 @@ const { widgetProps, configure } = useWidgetPropsManager(name, emit, ); -const text = ref<string | null>(defaultStore.state.memo); +const text = ref<string | null>(store.s.memo); const changed = ref(false); let timeoutId; const saveMemo = () => { - defaultStore.set('memo', text.value); + store.set('memo', text.value); changed.value = false; }; @@ -63,7 +63,7 @@ const onChange = () => { timeoutId = window.setTimeout(saveMemo, 1000); }; -watch(() => defaultStore.reactiveState.memo, newText => { +watch(() => store.r.memo, newText => { text.value = newText.value; }); diff --git a/packages/frontend/src/widgets/WidgetNotifications.vue b/packages/frontend/src/widgets/WidgetNotifications.vue index 8aaed2624d..c5e1324ef5 100644 --- a/packages/frontend/src/widgets/WidgetNotifications.vue +++ b/packages/frontend/src/widgets/WidgetNotifications.vue @@ -19,7 +19,7 @@ SPDX-License-Identifier: AGPL-3.0-only import { defineAsyncComponent } from 'vue'; import { useWidgetPropsManager } from './widget.js'; import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js'; -import type { GetFormResultType } from '@/scripts/form.js'; +import type { GetFormResultType } from '@/utility/form.js'; import MkContainer from '@/components/MkContainer.vue'; import XNotifications from '@/components/MkNotifications.vue'; import * as os from '@/os.js'; diff --git a/packages/frontend/src/widgets/WidgetOnlineUsers.vue b/packages/frontend/src/widgets/WidgetOnlineUsers.vue index 4a3cdb9ba3..ce1871420a 100644 --- a/packages/frontend/src/widgets/WidgetOnlineUsers.vue +++ b/packages/frontend/src/widgets/WidgetOnlineUsers.vue @@ -17,8 +17,8 @@ SPDX-License-Identifier: AGPL-3.0-only import { ref } from 'vue'; import { useWidgetPropsManager } from './widget.js'; import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js'; -import type { GetFormResultType } from '@/scripts/form.js'; -import { misskeyApi, misskeyApiGet } from '@/scripts/misskey-api.js'; +import type { GetFormResultType } from '@/utility/form.js'; +import { misskeyApi, misskeyApiGet } from '@/utility/misskey-api.js'; import { useInterval } from '@@/js/use-interval.js'; import { i18n } from '@/i18n.js'; import number from '@/filters/number.js'; @@ -73,6 +73,6 @@ defineExpose<WidgetComponentExpose>({ } .text { - color: var(--MI_THEME-fgTransparentWeak); + color: color(from var(--MI_THEME-fg) srgb r g b / 0.75); } </style> diff --git a/packages/frontend/src/widgets/WidgetPhotos.vue b/packages/frontend/src/widgets/WidgetPhotos.vue index 6d13ba09cc..5d6e2ed48f 100644 --- a/packages/frontend/src/widgets/WidgetPhotos.vue +++ b/packages/frontend/src/widgets/WidgetPhotos.vue @@ -26,12 +26,12 @@ import { onUnmounted, ref } from 'vue'; import * as Misskey from 'misskey-js'; import { useWidgetPropsManager } from './widget.js'; import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js'; -import type { GetFormResultType } from '@/scripts/form.js'; +import type { GetFormResultType } from '@/utility/form.js'; import { useStream } from '@/stream.js'; -import { getStaticImageUrl } from '@/scripts/media-proxy.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { getStaticImageUrl } from '@/utility/media-proxy.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import MkContainer from '@/components/MkContainer.vue'; -import { defaultStore } from '@/store.js'; +import { prefer } from '@/preferences.js'; import { i18n } from '@/i18n.js'; const name = 'photos'; @@ -70,7 +70,7 @@ const onDriveFileCreated = (file) => { }; const thumbnail = (image: Misskey.entities.DriveFile): string => { - return defaultStore.state.disableShowingAnimatedImages + return prefer.s.disableShowingAnimatedImages ? getStaticImageUrl(image.url) : image.thumbnailUrl ?? image.url; }; diff --git a/packages/frontend/src/widgets/WidgetPostForm.vue b/packages/frontend/src/widgets/WidgetPostForm.vue index b0a62d1be2..3170eab305 100644 --- a/packages/frontend/src/widgets/WidgetPostForm.vue +++ b/packages/frontend/src/widgets/WidgetPostForm.vue @@ -11,7 +11,7 @@ SPDX-License-Identifier: AGPL-3.0-only import { } from 'vue'; import { useWidgetPropsManager } from './widget.js'; import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js'; -import type { GetFormResultType } from '@/scripts/form.js'; +import type { GetFormResultType } from '@/utility/form.js'; import MkPostForm from '@/components/MkPostForm.vue'; const name = 'postForm'; diff --git a/packages/frontend/src/widgets/WidgetProfile.vue b/packages/frontend/src/widgets/WidgetProfile.vue index 9f006945ab..3fe8378a39 100644 --- a/packages/frontend/src/widgets/WidgetProfile.vue +++ b/packages/frontend/src/widgets/WidgetProfile.vue @@ -24,8 +24,8 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { useWidgetPropsManager } from './widget.js'; import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js'; -import type { GetFormResultType } from '@/scripts/form.js'; -import { $i } from '@/account.js'; +import type { GetFormResultType } from '@/utility/form.js'; +import { $i } from '@/i.js'; import { userPage } from '@/filters/user.js'; const name = 'profile'; diff --git a/packages/frontend/src/widgets/WidgetRss.vue b/packages/frontend/src/widgets/WidgetRss.vue index 0d7ce55be3..132eb0a629 100644 --- a/packages/frontend/src/widgets/WidgetRss.vue +++ b/packages/frontend/src/widgets/WidgetRss.vue @@ -12,7 +12,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div class="ekmkgxbj"> <MkLoading v-if="fetching"/> <div v-else-if="(!items || items.length === 0) && widgetProps.showHeader" class="_fullinfo"> - <img :src="infoImageUrl" class="_ghost"/> + <img :src="infoImageUrl" draggable="false"/> <div>{{ i18n.ts.nothing }}</div> </div> <div v-else :class="$style.feed"> @@ -25,13 +25,13 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { ref, watch, computed } from 'vue'; import * as Misskey from 'misskey-js'; +import { url as base } from '@@/js/config.js'; +import { useInterval } from '@@/js/use-interval.js'; import { useWidgetPropsManager } from './widget.js'; import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js'; -import type { GetFormResultType } from '@/scripts/form.js'; +import type { GetFormResultType } from '@/utility/form.js'; import MkContainer from '@/components/MkContainer.vue'; -import { url as base } from '@@/js/config.js'; import { i18n } from '@/i18n.js'; -import { useInterval } from '@@/js/use-interval.js'; import { infoImageUrl } from '@/instance.js'; const name = 'rss'; @@ -77,7 +77,7 @@ const fetchEndpoint = computed(() => { const intervalClear = ref<(() => void) | undefined>(); const tick = () => { - if (document.visibilityState === 'hidden' && rawItems.value.length !== 0) return; + if (window.document.visibilityState === 'hidden' && rawItems.value.length !== 0) return; window.fetch(fetchEndpoint.value, {}) .then(res => res.json()) diff --git a/packages/frontend/src/widgets/WidgetRssTicker.vue b/packages/frontend/src/widgets/WidgetRssTicker.vue index 5ecc1ab022..b5be4d35c2 100644 --- a/packages/frontend/src/widgets/WidgetRssTicker.vue +++ b/packages/frontend/src/widgets/WidgetRssTicker.vue @@ -32,9 +32,9 @@ import * as Misskey from 'misskey-js'; import { useWidgetPropsManager } from './widget.js'; import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js'; import MarqueeText from '@/components/MkMarquee.vue'; -import type { GetFormResultType } from '@/scripts/form.js'; +import type { GetFormResultType } from '@/utility/form.js'; import MkContainer from '@/components/MkContainer.vue'; -import { shuffle } from '@/scripts/shuffle.js'; +import { shuffle } from '@/utility/shuffle.js'; import { url as base } from '@@/js/config.js'; import { useInterval } from '@@/js/use-interval.js'; @@ -108,7 +108,7 @@ const intervalClear = ref<(() => void) | undefined>(); const key = ref(0); const tick = () => { - if (document.visibilityState === 'hidden' && rawItems.value.length !== 0) return; + if (window.document.visibilityState === 'hidden' && rawItems.value.length !== 0) return; window.fetch(fetchEndpoint.value, {}) .then(res => res.json()) diff --git a/packages/frontend/src/widgets/WidgetSlideshow.vue b/packages/frontend/src/widgets/WidgetSlideshow.vue index 67d35d71db..2ccbb7a28f 100644 --- a/packages/frontend/src/widgets/WidgetSlideshow.vue +++ b/packages/frontend/src/widgets/WidgetSlideshow.vue @@ -17,14 +17,14 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted, ref, shallowRef } from 'vue'; +import { onMounted, ref, useTemplateRef } from 'vue'; import * as Misskey from 'misskey-js'; +import { useInterval } from '@@/js/use-interval.js'; import { useWidgetPropsManager } from './widget.js'; import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js'; -import type { GetFormResultType } from '@/scripts/form.js'; +import type { GetFormResultType } from '@/utility/form.js'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { useInterval } from '@@/js/use-interval.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; const name = 'slideshow'; @@ -54,8 +54,8 @@ const { widgetProps, configure, save } = useWidgetPropsManager(name, const images = ref<Misskey.entities.DriveFile[]>([]); const fetching = ref(true); -const slideA = shallowRef<HTMLElement>(); -const slideB = shallowRef<HTMLElement>(); +const slideA = useTemplateRef('slideA'); +const slideB = useTemplateRef('slideB'); const change = () => { if (images.value.length === 0) return; diff --git a/packages/frontend/src/widgets/WidgetTimeline.vue b/packages/frontend/src/widgets/WidgetTimeline.vue index 02db9454a8..47dec05303 100644 --- a/packages/frontend/src/widgets/WidgetTimeline.vue +++ b/packages/frontend/src/widgets/WidgetTimeline.vue @@ -34,9 +34,9 @@ SPDX-License-Identifier: AGPL-3.0-only import { ref } from 'vue'; import { useWidgetPropsManager } from './widget.js'; import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js'; -import type { GetFormResultType } from '@/scripts/form.js'; +import type { GetFormResultType } from '@/utility/form.js'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import MkContainer from '@/components/MkContainer.vue'; import MkTimeline from '@/components/MkTimeline.vue'; import { i18n } from '@/i18n.js'; diff --git a/packages/frontend/src/widgets/WidgetTrends.vue b/packages/frontend/src/widgets/WidgetTrends.vue index 3354912c07..db09031c33 100644 --- a/packages/frontend/src/widgets/WidgetTrends.vue +++ b/packages/frontend/src/widgets/WidgetTrends.vue @@ -10,7 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div class="wbrkwala"> <MkLoading v-if="fetching"/> - <TransitionGroup v-else tag="div" :name="defaultStore.state.animation ? 'chart' : ''" class="tags"> + <TransitionGroup v-else tag="div" :name="prefer.s.animation ? 'chart' : ''" class="tags"> <div v-for="stat in stats" :key="stat.tag"> <div class="tag"> <MkA class="a" :to="`/tags/${ encodeURIComponent(stat.tag) }`" :title="stat.tag">#{{ stat.tag }}</MkA> @@ -26,15 +26,15 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { ref } from 'vue'; import * as Misskey from 'misskey-js'; +import { useInterval } from '@@/js/use-interval.js'; import { useWidgetPropsManager } from './widget.js'; import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js'; -import type { GetFormResultType } from '@/scripts/form.js'; +import type { GetFormResultType } from '@/utility/form.js'; import MkContainer from '@/components/MkContainer.vue'; import MkMiniChart from '@/components/MkMiniChart.vue'; -import { misskeyApiGet } from '@/scripts/misskey-api.js'; -import { useInterval } from '@@/js/use-interval.js'; +import { misskeyApiGet } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; -import { defaultStore } from '@/store.js'; +import { prefer } from '@/preferences.js'; const name = 'hashtags'; diff --git a/packages/frontend/src/widgets/WidgetUnixClock.vue b/packages/frontend/src/widgets/WidgetUnixClock.vue index f85d27d6aa..f51ef12a2a 100644 --- a/packages/frontend/src/widgets/WidgetUnixClock.vue +++ b/packages/frontend/src/widgets/WidgetUnixClock.vue @@ -19,7 +19,7 @@ SPDX-License-Identifier: AGPL-3.0-only import { onUnmounted, ref, watch } from 'vue'; import { useWidgetPropsManager } from './widget.js'; import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js'; -import type { GetFormResultType } from '@/scripts/form.js'; +import type { GetFormResultType } from '@/utility/form.js'; const name = 'unixClock'; diff --git a/packages/frontend/src/widgets/WidgetUserList.vue b/packages/frontend/src/widgets/WidgetUserList.vue index 805e14c669..eb86732817 100644 --- a/packages/frontend/src/widgets/WidgetUserList.vue +++ b/packages/frontend/src/widgets/WidgetUserList.vue @@ -28,10 +28,10 @@ import { ref } from 'vue'; import * as Misskey from 'misskey-js'; import { useWidgetPropsManager } from './widget.js'; import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js'; -import type { GetFormResultType } from '@/scripts/form.js'; +import type { GetFormResultType } from '@/utility/form.js'; import MkContainer from '@/components/MkContainer.vue'; import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { useInterval } from '@@/js/use-interval.js'; import { i18n } from '@/i18n.js'; import MkButton from '@/components/MkButton.vue'; diff --git a/packages/frontend/src/widgets/server-metric/index.vue b/packages/frontend/src/widgets/server-metric/index.vue index 3264c8dc04..9026fefb20 100644 --- a/packages/frontend/src/widgets/server-metric/index.vue +++ b/packages/frontend/src/widgets/server-metric/index.vue @@ -30,8 +30,8 @@ import XCpu from './cpu.vue'; import XMemory from './mem.vue'; import XDisk from './disk.vue'; import MkContainer from '@/components/MkContainer.vue'; -import type { GetFormResultType } from '@/scripts/form.js'; -import { misskeyApiGet } from '@/scripts/misskey-api.js'; +import type { GetFormResultType } from '@/utility/form.js'; +import { misskeyApiGet } from '@/utility/misskey-api.js'; import { useStream } from '@/stream.js'; import { i18n } from '@/i18n.js'; diff --git a/packages/frontend/src/widgets/widget.ts b/packages/frontend/src/widgets/widget.ts index 98e1e44cd8..de4c369cbb 100644 --- a/packages/frontend/src/widgets/widget.ts +++ b/packages/frontend/src/widgets/widget.ts @@ -5,9 +5,9 @@ import { reactive, watch } from 'vue'; import { throttle } from 'throttle-debounce'; -import type { Form, GetFormResultType } from '@/scripts/form.js'; +import type { Form, GetFormResultType } from '@/utility/form.js'; import * as os from '@/os.js'; -import { deepClone } from '@/scripts/clone.js'; +import { deepClone } from '@/utility/clone.js'; export type Widget<P extends Record<string, unknown>> = { id: string; diff --git a/packages/frontend/test/aiscript/api.test.ts b/packages/frontend/test/aiscript/api.test.ts index 2a15a74249..ad24625b96 100644 --- a/packages/frontend/test/aiscript/api.test.ts +++ b/packages/frontend/test/aiscript/api.test.ts @@ -4,7 +4,7 @@ */ import { miLocalStorage } from '@/local-storage.js'; -import { aiScriptReadline, createAiScriptEnv } from '@/scripts/aiscript/api.js'; +import { aiScriptReadline, createAiScriptEnv } from '@/aiscript/api.js'; import { errors, Interpreter, Parser, values } from '@syuilo/aiscript'; import { afterAll, @@ -33,11 +33,11 @@ async function exe(script: string): Promise<values.Value[]> { return outputs; } -let $iMock = vi.hoisted<Partial<typeof import('@/account.js').$i> | null >( +let $iMock = vi.hoisted<Partial<typeof import('@/i.js').$i> | null >( () => null ); -vi.mock('@/account.js', () => { +vi.mock('@/i.js', () => { return { get $i() { return $iMock; @@ -59,7 +59,7 @@ vi.mock('@/os.js', () => { const misskeyApiMock = vi.hoisted(() => vi.fn()); -vi.mock('@/scripts/misskey-api.js', () => { +vi.mock('@/utility/misskey-api.js', () => { return { misskeyApi: misskeyApiMock }; }); diff --git a/packages/frontend/test/aiscript/common.test.ts b/packages/frontend/test/aiscript/common.test.ts index acc48826ea..c0c978001b 100644 --- a/packages/frontend/test/aiscript/common.test.ts +++ b/packages/frontend/test/aiscript/common.test.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { assertStringAndIsIn } from "@/scripts/aiscript/common.js"; +import { assertStringAndIsIn } from "@/aiscript/common.js"; import { values } from "@syuilo/aiscript"; import { describe, expect, test } from "vitest"; diff --git a/packages/frontend/test/aiscript/ui.test.ts b/packages/frontend/test/aiscript/ui.test.ts index 5f77edbb49..44a50aaa62 100644 --- a/packages/frontend/test/aiscript/ui.test.ts +++ b/packages/frontend/test/aiscript/ui.test.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { registerAsUiLib } from '@/scripts/aiscript/ui.js'; +import { registerAsUiLib } from '@/aiscript/ui.js'; import { errors, Interpreter, Parser, values } from '@syuilo/aiscript'; import { describe, expect, test } from 'vitest'; import { type Ref, ref } from 'vue'; @@ -19,7 +19,7 @@ import type { AsUiText, AsUiTextarea, AsUiTextInput, -} from '@/scripts/aiscript/ui.js'; +} from '@/aiscript/ui.js'; type ExeResult = { root: AsUiRoot; diff --git a/packages/frontend/test/autocomplete.test.ts b/packages/frontend/test/autocomplete.test.ts index 394ac3a821..38be35813f 100644 --- a/packages/frontend/test/autocomplete.test.ts +++ b/packages/frontend/test/autocomplete.test.ts @@ -4,7 +4,7 @@ */ import { assert, describe, test } from 'vitest'; -import { searchEmoji } from '@/scripts/search-emoji.js'; +import { searchEmoji } from '@/utility/search-emoji.js'; describe('emoji autocomplete', () => { test('名前の完全一致は名前の前方一致より優先される', async () => { diff --git a/packages/frontend/test/emoji.test.ts b/packages/frontend/test/emoji.test.ts index cf686efd0d..ffdc858b75 100644 --- a/packages/frontend/test/emoji.test.ts +++ b/packages/frontend/test/emoji.test.ts @@ -5,7 +5,7 @@ import { describe, test, assert, afterEach } from 'vitest'; import { render, cleanup, type RenderResult } from '@testing-library/vue'; -import { defaultStoreState } from './init.js'; +import { preferState } from './init.js'; import { getEmojiName } from '@@/js/emojilist.js'; import { components } from '@/components/index.js'; import { directives } from '@/directives/index.js'; @@ -21,12 +21,12 @@ describe('Emoji', () => { afterEach(() => { cleanup(); - defaultStoreState.emojiStyle = ''; + preferState.emojiStyle = ''; }); describe('MkEmoji', () => { test('Should render selector-less heart with color in native mode', async () => { - defaultStoreState.emojiStyle = 'native'; + preferState.emojiStyle = 'native'; const mkEmoji = await renderEmoji('\u2764'); // monochrome heart assert.ok(mkEmoji.queryByText('\u2764\uFE0F')); // colored heart assert.ok(!mkEmoji.queryByText('\u2764')); diff --git a/packages/frontend/test/init.ts b/packages/frontend/test/init.ts index 0cde571dcb..3b6b4d581b 100644 --- a/packages/frontend/test/init.ts +++ b/packages/frontend/test/init.ts @@ -17,7 +17,7 @@ updateI18n(locales['en-US']); // XXX: misskey-js panics if WebSocket is not defined vi.stubGlobal('WebSocket', class WebSocket extends EventTarget { static CLOSING = 2; }); -export const defaultStoreState: Record<string, unknown> = { +export const preferState: Record<string, unknown> = { // なんかtestがうまいこと動かないのでここに書く dataSaver: { @@ -29,11 +29,11 @@ export const defaultStoreState: Record<string, unknown> = { }; -// XXX: defaultStore somehow becomes undefined in vitest? -vi.mock('@/store.js', () => { +// XXX: store somehow becomes undefined in vitest? +vi.mock('@/preferences.js', () => { return { - defaultStore: { - state: defaultStoreState, + prefer: { + s: preferState, }, }; }); diff --git a/packages/frontend/test/intl-string.test.ts b/packages/frontend/test/intl-string.test.ts new file mode 100644 index 0000000000..b52824db86 --- /dev/null +++ b/packages/frontend/test/intl-string.test.ts @@ -0,0 +1,142 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { assert, beforeEach, describe, test } from 'vitest'; +import { + normalizeString, + initIntlString, + normalizeStringWithHiragana, + compareStringEquals, + compareStringIncludes, +} from '@/utility/intl-string.js'; + +// 共通のテストを実行するヘルパー関数 +const runCommonTests = (normalizeFn: (str: string) => string) => { + test('全角英数字が半角の小文字になる', () => { + // ローマ字にならないようにする + const input = 'B123'; + const expected = 'b123'; + assert.strictEqual(normalizeFn(input), expected); + }); + test('濁点・半濁点が正しく結合される', () => { + const input = 'か\u3099'; + const expected = 'が'; + assert.strictEqual(normalizeFn(input), expected); + }); + test('小文字に揃う', () => { + // ローマ字にならないようにする + const input = 'tSt'; + const expected = 'tst'; + assert.strictEqual(normalizeFn(input), expected); + }); + test('文字列の前後の空白が削除される', () => { + const input = ' tst '; + const expected = 'tst'; + assert.strictEqual(normalizeFn(input), expected); + }); +}; + +describe('normalize string', () => { + runCommonTests(normalizeString); + + test('異体字の正規化 (ligature)', () => { + const input = 'fi'; + const expected = 'fi'; + assert.strictEqual(normalizeString(input), expected); + }); + + test('半角カタカナは全角に変換される', () => { + const input = 'カタカナ'; + const expected = 'カタカナ'; + assert.strictEqual(normalizeString(input), expected); + }); +}); + +// normalizeStringWithHiraganaのテスト +describe('normalize string with hiragana', () => { + beforeEach(async () => { + await initIntlString(true); + }); + + // 共通テスト + describe('共通のnormalizeStringテスト', () => { + runCommonTests(normalizeStringWithHiragana); + }); + + test('半角カタカナがひらがなに変換される', () => { + const input = 'カタカナ'; + const expected = 'かたかな'; + assert.strictEqual(normalizeStringWithHiragana(input), expected); + }); + + // normalizeStringWithHiragana特有のテスト + test('カタカナがひらがなに変換される・伸ばし棒はハイフンに変換される', () => { + const input = 'カタカナひーらがーな'; + const expected = 'かたかなひ-らが-な'; + assert.strictEqual(normalizeStringWithHiragana(input), expected); + }); + + test('ローマ字がひらがなに変換される', () => { + const input = 'ro-majimohiragananinarimasu'; + const expected = 'ろ-まじもひらがなになります'; + assert.strictEqual(normalizeStringWithHiragana(input), expected); + }); +}); + +describe('compareStringEquals', () => { + beforeEach(async () => { + await initIntlString(true); + }); + + test('完全一致ならtrue', () => { + assert.isTrue(compareStringEquals('テスト', 'テスト')); + }); + + test('大文字・小文字の違いを無視', () => { + assert.isTrue(compareStringEquals('TeSt', 'test')); + }); + + test('全角・半角の違いを無視', () => { + assert.isTrue(compareStringEquals('ABC', 'abc')); + }); + + test('カタカナとひらがなの違いを無視', () => { + assert.isTrue(compareStringEquals('カタカナ', 'かたかな')); + }); + + test('ローマ字をひらがなと比較可能', () => { + assert.isTrue(compareStringEquals('hiragana', 'ひらがな')); + }); + + test('異なる文字列はfalse', () => { + assert.isFalse(compareStringEquals('テスト', 'サンプル')); + }); +}); + +describe('compareStringIncludes', () => { + test('部分一致ならtrue', () => { + assert.isTrue(compareStringIncludes('これはテストです', 'テスト')); + }); + + test('大文字・小文字の違いを無視', () => { + assert.isTrue(compareStringIncludes('This is a Test', 'test')); + }); + + test('全角・半角の違いを無視', () => { + assert.isTrue(compareStringIncludes('ABCDE', 'abc')); + }); + + test('カタカナとひらがなの違いを無視', () => { + assert.isTrue(compareStringIncludes('カタカナのテスト', 'かたかな')); + }); + + test('ローマ字をひらがなと比較可能', () => { + assert.isTrue(compareStringIncludes('これはhiraganaのテスト', 'ひらがな')); + }); + + test('異なる文字列はfalse', () => { + assert.isFalse(compareStringIncludes('これはテストです', 'サンプル')); + }); +}); diff --git a/packages/frontend/test/scroll.test.ts b/packages/frontend/test/scroll.test.ts index 32a5a1c558..34e7e64313 100644 --- a/packages/frontend/test/scroll.test.ts +++ b/packages/frontend/test/scroll.test.ts @@ -12,10 +12,10 @@ describe('Scroll', () => { /* 動作しない(happy-domのバグ?) test('Initial onScrollTop callback for connected elements', () => { const { document } = new Window(); - const div = document.createElement('div'); + const div = window.document.createElement('div'); assert.strictEqual(div.scrollTop, 0); - document.body.append(div); + window.document.body.append(div); let called = false; onScrollTop(div as any as HTMLElement, () => called = true); @@ -26,7 +26,7 @@ describe('Scroll', () => { test('No onScrollTop callback for disconnected elements', () => { const { document } = new Window(); - const div = document.createElement('div'); + const div = window.document.createElement('div'); assert.strictEqual(div.scrollTop, 0); let called = false; @@ -40,10 +40,10 @@ describe('Scroll', () => { /* 動作しない(happy-domのバグ?) test('Initial onScrollBottom callback for connected elements', () => { const { document } = new Window(); - const div = document.createElement('div'); + const div = window.document.createElement('div'); assert.strictEqual(div.scrollTop, 0); - document.body.append(div); + window.document.body.append(div); let called = false; onScrollBottom(div as any as HTMLElement, () => called = true); @@ -54,7 +54,7 @@ describe('Scroll', () => { test('No onScrollBottom callback for disconnected elements', () => { const { document } = new Window(); - const div = document.createElement('div'); + const div = window.document.createElement('div'); assert.strictEqual(div.scrollTop, 0); let called = false; diff --git a/packages/frontend/vite-node.config.ts b/packages/frontend/vite-node.config.ts new file mode 100644 index 0000000000..c049f46e10 --- /dev/null +++ b/packages/frontend/vite-node.config.ts @@ -0,0 +1,3 @@ +import { defineConfig } from 'vite'; + +export default defineConfig({}); diff --git a/packages/frontend/vite.config.ts b/packages/frontend/vite.config.ts index 1c094e272b..aa7bf24174 100644 --- a/packages/frontend/vite.config.ts +++ b/packages/frontend/vite.config.ts @@ -1,7 +1,8 @@ import path from 'path'; import pluginReplace from '@rollup/plugin-replace'; import pluginVue from '@vitejs/plugin-vue'; -import { type UserConfig, defineConfig } from 'vite'; +import { defineConfig } from 'vite'; +import type { UserConfig } from 'vite'; import * as yaml from 'js-yaml'; import { promises as fsp } from 'fs'; @@ -11,6 +12,7 @@ import packageInfo from './package.json' with { type: 'json' }; import pluginUnwindCssModuleClassName from './lib/rollup-plugin-unwind-css-module-class-name.js'; import pluginJson5 from './vite.json5.js'; import pluginCreateSearchIndex from './lib/vite-plugin-create-search-index.js'; +import type { Options as SearchIndexOptions } from './lib/vite-plugin-create-search-index.js'; const url = process.env.NODE_ENV === 'development' ? yaml.load(await fsp.readFile('../../.config/default.yml', 'utf-8')).url : null; const host = url ? (new URL(url)).hostname : undefined; @@ -18,6 +20,16 @@ const host = url ? (new URL(url)).hostname : undefined; const extensions = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.json', '.json5', '.svg', '.sass', '.scss', '.css', '.vue']; /** + * 検索インデックスの生成設定 + */ +export const searchIndexes = [{ + targetFilePaths: ['src/pages/settings/*.vue'], + mainVirtualModule: 'search-index:settings', + modulesToHmrOnUpdate: ['src/pages/settings/index.vue'], + verbose: process.env.FRONTEND_SEARCH_INDEX_VERBOSE === 'true', +}] satisfies SearchIndexOptions[]; + +/** * Misskeyのフロントエンドにバンドルせず、CDNなどから別途読み込むリソースを記述する。 * CDNを使わずにバンドルしたい場合、以下の配列から該当要素を削除orコメントアウトすればOK */ @@ -84,11 +96,7 @@ export function getConfig(): UserConfig { }, plugins: [ - pluginCreateSearchIndex({ - targetFilePaths: ['src/pages/settings/*.vue'], - exportFilePath: './src/scripts/autogen/settings-search-index.ts', - verbose: process.env.FRONTEND_SEARCH_INDEX_VERBOSE === 'true', - }), + ...searchIndexes.map(options => pluginCreateSearchIndex(options)), pluginVue(), pluginUnwindCssModuleClassName(), pluginJson5(), |