diff options
| author | syuilo <Syuilotan@yahoo.co.jp> | 2023-04-08 21:27:21 +0900 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2023-04-08 21:27:21 +0900 |
| commit | a096f621cf5a47c3330935c2b9b5bfe54dfc0091 (patch) | |
| tree | b3b6a1a1ce5105091bebc80b96cfd5a73402da80 /packages/frontend | |
| parent | Merge pull request #10402 from misskey-dev/develop (diff) | |
| parent | [ci skip] Update CHANGELOG.md (diff) | |
| download | misskey-a096f621cf5a47c3330935c2b9b5bfe54dfc0091.tar.gz misskey-a096f621cf5a47c3330935c2b9b5bfe54dfc0091.tar.bz2 misskey-a096f621cf5a47c3330935c2b9b5bfe54dfc0091.zip | |
Merge pull request #10506 from misskey-dev/develop
13.11.0
Diffstat (limited to 'packages/frontend')
189 files changed, 3988 insertions, 976 deletions
diff --git a/packages/frontend/.gitignore b/packages/frontend/.gitignore new file mode 100644 index 0000000000..1aa0ac14e8 --- /dev/null +++ b/packages/frontend/.gitignore @@ -0,0 +1 @@ +/storybook-static diff --git a/packages/frontend/.storybook/.gitignore b/packages/frontend/.storybook/.gitignore new file mode 100644 index 0000000000..e421532a54 --- /dev/null +++ b/packages/frontend/.storybook/.gitignore @@ -0,0 +1,7 @@ +/changes.js +/generate.js +/preload-locale.js +/locale.ts +/main.js +/preload-theme.js +/themes.ts diff --git a/packages/frontend/.storybook/changes.ts b/packages/frontend/.storybook/changes.ts new file mode 100644 index 0000000000..f0827331f7 --- /dev/null +++ b/packages/frontend/.storybook/changes.ts @@ -0,0 +1,80 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import micromatch from 'micromatch'; +import main from './main'; + +interface Stats { + readonly modules: readonly { + readonly id: string; + readonly name: string; + readonly reasons: readonly { + readonly moduleName: string; + }[]; + }[]; +} + +fs.readFile( + path.resolve(__dirname, '../storybook-static/preview-stats.json') +).then((buffer) => { + const stats: Stats = JSON.parse(buffer.toString()); + const keys = new Set(stats.modules.map((stat) => stat.id)); + const map = new Map( + Array.from(keys, (key) => [ + key, + new Set( + stats.modules + .filter((stat) => stat.id === key) + .flatMap((stat) => stat.reasons) + .map((reason) => reason.moduleName) + ), + ]) + ); + const modules = new Set( + process.argv + .slice(2) + .map((arg) => + path.relative( + path.resolve(__dirname, '..'), + path.resolve(__dirname, '../../..', arg) + ) + ) + .map((path) => (path.startsWith('.') ? path : `./${path}`)) + ); + if ( + micromatch(Array.from(modules), [ + '../../assets/**', + '../../fluent-emojis/**', + '../../locales/**', + '../../misskey-assets/**', + 'assets/**', + 'public/**', + '../../pnpm-lock.yaml', + ]).length + ) { + return; + } + for (;;) { + const oldSize = modules.size; + for (const module of Array.from(modules)) { + if (map.has(module)) { + for (const dependency of Array.from(map.get(module)!)) { + modules.add(dependency); + } + } + } + if (modules.size === oldSize) { + break; + } + } + const stories = micromatch( + Array.from(modules), + main.stories.map((story) => `./${path.relative('..', story)}`) + ); + if (stories.length) { + for (const story of stories) { + process.stdout.write(` --only-story-files ${story}`); + } + } else { + process.stdout.write(` --skip`); + } +}); diff --git a/packages/frontend/.storybook/fakes.ts b/packages/frontend/.storybook/fakes.ts new file mode 100644 index 0000000000..23b82a8ac5 --- /dev/null +++ b/packages/frontend/.storybook/fakes.ts @@ -0,0 +1,116 @@ +import type { entities } from 'misskey-js' + +export function abuseUserReport() { + return { + id: 'someabusereportid', + createdAt: '2016-12-28T22:49:51.000Z', + comment: 'This user is a spammer!', + resolved: false, + reporterId: 'reporterid', + targetUserId: 'targetuserid', + assigneeId: 'assigneeid', + reporter: userDetailed('reporterid', 'reporter', 'misskey-hub.net', 'Reporter'), + targetUser: userDetailed('targetuserid', 'target', 'misskey-hub.net', 'Target'), + assignee: userDetailed('assigneeid', 'assignee', 'misskey-hub.net', 'Assignee'), + me: null, + forwarded: false, + }; +} + +export function galleryPost(isSensitive = false) { + return { + id: 'somepostid', + createdAt: '2016-12-28T22:49:51.000Z', + updatedAt: '2016-12-28T22:49:51.000Z', + userid: 'someuserid', + user: userDetailed(), + title: 'Some post title', + description: 'Some post description', + fileIds: ['somefileid'], + files: [ + file(isSensitive), + ], + isSensitive, + likedCount: 0, + isLiked: false, + } +} + +export function file(isSensitive = false) { + return { + id: 'somefileid', + createdAt: '2016-12-28T22:49:51.000Z', + name: 'somefile.jpg', + type: 'image/jpeg', + md5: 'f6fc51c73dc21b1fb85ead2cdf57530a', + size: 77752, + isSensitive, + blurhash: 'eQAmoa^-MH8w9ZIvNLSvo^$*MwRPbwtSxutRozjEiwR.RjWBoeozog', + properties: { + width: 1024, + height: 270 + }, + url: 'https://github.com/misskey-dev/misskey/blob/master/packages/frontend/assets/fedi.jpg?raw=true', + thumbnailUrl: 'https://github.com/misskey-dev/misskey/blob/master/packages/frontend/assets/fedi.jpg?raw=true', + comment: null, + folderId: null, + folder: null, + userId: null, + user: null, + }; +} + +export function userDetailed(id = 'someuserid', username = 'miskist', host = 'misskey-hub.net', name = 'Misskey User'): entities.UserDetailed { + return { + id, + username, + host, + name, + onlineStatus: 'unknown', + avatarUrl: 'https://github.com/misskey-dev/misskey/blob/master/packages/frontend/assets/about-icon.png?raw=true', + avatarBlurhash: 'eQFRshof5NWBRi},juayfPju53WB?0ofs;s*a{ofjuay^SoMEJR%ay', + emojis: [], + bannerBlurhash: 'eQA^IW^-MH8w9tE8I=S^o{$*R4RikXtSxutRozjEnNR.RQadoyozog', + bannerColor: '#000000', + bannerUrl: 'https://github.com/misskey-dev/misskey/blob/master/packages/frontend/assets/fedi.jpg?raw=true', + birthday: '2014-06-20', + createdAt: '2016-12-28T22:49:51.000Z', + description: 'I am a cool user!', + ffVisibility: 'public', + fields: [ + { + name: 'Website', + value: 'https://misskey-hub.net', + }, + ], + followersCount: 1024, + followingCount: 16, + hasPendingFollowRequestFromYou: false, + hasPendingFollowRequestToYou: false, + isAdmin: false, + isBlocked: false, + isBlocking: false, + isBot: false, + isCat: false, + isFollowed: false, + isFollowing: false, + isLocked: false, + isModerator: false, + isMuted: false, + isSilenced: false, + isSuspended: false, + lang: 'en', + location: 'Fediverse', + notesCount: 65536, + pinnedNoteIds: [], + pinnedNotes: [], + pinnedPage: null, + pinnedPageId: null, + publicReactions: false, + securityKeys: false, + twoFactorEnabled: false, + updatedAt: null, + uri: null, + url: null, + }; +} diff --git a/packages/frontend/.storybook/generate.tsx b/packages/frontend/.storybook/generate.tsx new file mode 100644 index 0000000000..b3bbeeb51c --- /dev/null +++ b/packages/frontend/.storybook/generate.tsx @@ -0,0 +1,406 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { writeFile } from 'node:fs/promises'; +import { basename, dirname } from 'node:path/posix'; +import { GENERATOR, type State, generate } from 'astring'; +import type * as estree from 'estree'; +import glob from 'fast-glob'; +import { format } from 'prettier'; + +interface SatisfiesExpression extends estree.BaseExpression { + type: 'SatisfiesExpression'; + expression: estree.Expression; + reference: estree.Identifier; +} + +const generator = { + ...GENERATOR, + SatisfiesExpression(node: SatisfiesExpression, state: State) { + switch (node.expression.type) { + case 'ArrowFunctionExpression': { + state.write('('); + this[node.expression.type](node.expression, state); + state.write(')'); + break; + } + default: { + // @ts-ignore + this[node.expression.type](node.expression, state); + break; + } + } + state.write(' satisfies ', node as unknown as estree.Expression); + this[node.reference.type](node.reference, state); + }, +}; + +type SplitCamel< + T extends string, + YC extends string = '', + YN extends readonly string[] = [] +> = T extends `${infer XH}${infer XR}` + ? XR extends '' + ? [...YN, Uncapitalize<`${YC}${XH}`>] + : XH extends Uppercase<XH> + ? SplitCamel<XR, Lowercase<XH>, [...YN, YC]> + : SplitCamel<XR, `${YC}${XH}`, YN> + : YN; + +// @ts-ignore +type SplitKebab<T extends string> = T extends `${infer XH}-${infer XR}` + ? [XH, ...SplitKebab<XR>] + : [T]; + +type ToKebab<T extends readonly string[]> = T extends readonly [ + infer XO extends string +] + ? XO + : T extends readonly [ + infer XH extends string, + ...infer XR extends readonly string[] + ] + ? `${XH}${XR extends readonly string[] ? `-${ToKebab<XR>}` : ''}` + : ''; + +// @ts-ignore +type ToPascal<T extends readonly string[]> = T extends readonly [ + infer XH extends string, + ...infer XR extends readonly string[] +] + ? `${Capitalize<XH>}${ToPascal<XR>}` + : ''; + +function h<T extends estree.Node>( + component: T['type'], + props: Omit<T, 'type'> +): T { + const type = component.replace(/(?:^|-)([a-z])/g, (_, c) => c.toUpperCase()); + return Object.assign(props || {}, { type }) as T; +} + +declare global { + namespace JSX { + type Element = estree.Node; + type ElementClass = never; + type ElementAttributesProperty = never; + type ElementChildrenAttribute = never; + type IntrinsicAttributes = never; + type IntrinsicClassAttributes<T> = never; + type IntrinsicElements = { + [T in keyof typeof generator as ToKebab<SplitCamel<Uncapitalize<T>>>]: { + [K in keyof Omit< + Parameters<(typeof generator)[T]>[0], + 'type' + >]?: Parameters<(typeof generator)[T]>[0][K]; + }; + }; + } +} + +function toStories(component: string): string { + const msw = `${component.slice(0, -'.vue'.length)}.msw`; + const implStories = `${component.slice(0, -'.vue'.length)}.stories.impl`; + const metaStories = `${component.slice(0, -'.vue'.length)}.stories.meta`; + const hasMsw = existsSync(`${msw}.ts`); + const hasImplStories = existsSync(`${implStories}.ts`); + const hasMetaStories = existsSync(`${metaStories}.ts`); + const base = basename(component); + const dir = dirname(component); + const literal = + <literal + value={component + .slice('src/'.length, -'.vue'.length) + .replace(/\./g, '/')} + /> as estree.Literal; + const identifier = + <identifier + name={base + .slice(0, -'.vue'.length) + .replace(/[-.]|^(?=\d)/g, '_') + .replace(/(?<=^[^A-Z_]*$)/, '_')} + /> as estree.Identifier; + const parameters = ( + <object-expression + properties={[ + <property + key={<identifier name='layout' /> as estree.Identifier} + value={<literal value={`${dir}/`.startsWith('src/pages/') ? 'fullscreen' : 'centered'}/> as estree.Literal} + kind={'init' as const} + /> as estree.Property, + ...(hasMsw + ? [ + <property + key={<identifier name='msw' /> as estree.Identifier} + value={<identifier name='msw' /> as estree.Identifier} + kind={'init' as const} + shorthand + /> as estree.Property, + ] + : []), + ]} + /> + ) as estree.ObjectExpression; + const program = ( + <program + body={[ + <import-declaration + source={<literal value='@storybook/vue3' /> as estree.Literal} + specifiers={[ + <import-specifier + local={<identifier name='Meta' /> as estree.Identifier} + imported={<identifier name='Meta' /> as estree.Identifier} + /> as estree.ImportSpecifier, + ...(hasImplStories + ? [] + : [ + <import-specifier + local={<identifier name='StoryObj' /> as estree.Identifier} + imported={<identifier name='StoryObj' /> as estree.Identifier} + /> as estree.ImportSpecifier, + ]), + ]} + /> as estree.ImportDeclaration, + ...(hasMsw + ? [ + <import-declaration + source={<literal value={`./${basename(msw)}`} /> as estree.Literal} + specifiers={[ + <import-namespace-specifier + local={<identifier name='msw' /> as estree.Identifier} + /> as estree.ImportNamespaceSpecifier, + ]} + /> as estree.ImportDeclaration, + ] + : []), + ...(hasImplStories + ? [] + : [ + <import-declaration + source={<literal value={`./${base}`} /> as estree.Literal} + specifiers={[ + <import-default-specifier local={identifier} /> as estree.ImportDefaultSpecifier, + ]} + /> as estree.ImportDeclaration, + ]), + ...(hasMetaStories + ? [ + <import-declaration + source={<literal value={`./${basename(metaStories)}`} /> as estree.Literal} + specifiers={[ + <import-namespace-specifier + local={<identifier name='storiesMeta' /> as estree.Identifier} + /> as estree.ImportNamespaceSpecifier, + ]} + /> as estree.ImportDeclaration, + ] + : []), + <variable-declaration + kind={'const' as const} + declarations={[ + <variable-declarator + id={<identifier name='meta' /> as estree.Identifier} + init={ + <satisfies-expression + expression={ + <object-expression + properties={[ + <property + key={<identifier name='title' /> as estree.Identifier} + value={literal} + kind={'init' as const} + /> as estree.Property, + <property + key={<identifier name='component' /> as estree.Identifier} + value={identifier} + kind={'init' as const} + /> as estree.Property, + ...(hasMetaStories + ? [ + <spread-element + argument={<identifier name='storiesMeta' /> as estree.Identifier} + /> as estree.SpreadElement, + ] + : []) + ]} + /> as estree.ObjectExpression + } + reference={<identifier name={`Meta<typeof ${identifier.name}>`} /> as estree.Identifier} + /> as estree.Expression + } + /> as estree.VariableDeclarator, + ]} + /> as estree.VariableDeclaration, + ...(hasImplStories + ? [] + : [ + <export-named-declaration + declaration={ + <variable-declaration + kind={'const' as const} + declarations={[ + <variable-declarator + id={<identifier name='Default' /> as estree.Identifier} + init={ + <satisfies-expression + expression={ + <object-expression + properties={[ + <property + key={<identifier name='render' /> as estree.Identifier} + value={ + <function-expression + params={[ + <identifier name='args' /> as estree.Identifier, + ]} + body={ + <block-statement + body={[ + <return-statement + argument={ + <object-expression + properties={[ + <property + key={<identifier name='components' /> as estree.Identifier} + value={ + <object-expression + properties={[ + <property key={identifier} value={identifier} kind={'init' as const} shorthand /> as estree.Property, + ]} + /> as estree.ObjectExpression + } + kind={'init' as const} + /> as estree.Property, + <property + key={<identifier name='setup' /> as estree.Identifier} + value={ + <function-expression + params={[]} + body={ + <block-statement + body={[ + <return-statement + argument={ + <object-expression + properties={[ + <property + key={<identifier name='args' /> as estree.Identifier} + value={<identifier name='args' /> as estree.Identifier} + kind={'init' as const} + shorthand + /> as estree.Property, + ]} + /> as estree.ObjectExpression + } + /> as estree.ReturnStatement, + ]} + /> as estree.BlockStatement + } + /> as estree.FunctionExpression + } + method + kind={'init' as const} + /> as estree.Property, + <property + key={<identifier name='computed' /> as estree.Identifier} + value={ + <object-expression + properties={[ + <property + key={<identifier name='props' /> as estree.Identifier} + value={ + <function-expression + params={[]} + body={ + <block-statement + body={[ + <return-statement + argument={ + <object-expression + properties={[ + <spread-element + argument={ + <member-expression + object={<this-expression /> as estree.ThisExpression} + property={<identifier name='args' /> as estree.Identifier} + /> as estree.MemberExpression + } + /> as estree.SpreadElement, + ]} + /> as estree.ObjectExpression + } + /> as estree.ReturnStatement, + ]} + /> as estree.BlockStatement + } + /> as estree.FunctionExpression + } + method + kind={'init' as const} + /> as estree.Property, + ]} + /> as estree.ObjectExpression + } + kind={'init' as const} + /> as estree.Property, + <property + key={<identifier name='template' /> as estree.Identifier} + value={<literal value={`<${identifier.name} v-bind="props" />`} /> as estree.Literal} + kind={'init' as const} + /> as estree.Property, + ]} + /> as estree.ObjectExpression + } + /> as estree.ReturnStatement, + ]} + /> as estree.BlockStatement + } + /> as estree.FunctionExpression + } + method + kind={'init' as const} + /> as estree.Property, + <property + key={<identifier name='parameters' /> as estree.Identifier} + value={parameters} + kind={'init' as const} + /> as estree.Property, + ]} + /> as estree.ObjectExpression + } + reference={<identifier name={`StoryObj<typeof ${identifier.name}>`} /> as estree.Identifier} + /> as estree.Expression + } + /> as estree.VariableDeclarator, + ]} + /> as estree.VariableDeclaration + } + /> as estree.ExportNamedDeclaration, + ]), + <export-default-declaration + declaration={(<identifier name='meta' />) as estree.Identifier} + /> as estree.ExportDefaultDeclaration, + ]} + /> + ) as estree.Program; + return format( + '/* eslint-disable @typescript-eslint/explicit-function-return-type */\n' + + '/* eslint-disable import/no-default-export */\n' + + generate(program, { generator }) + + (hasImplStories ? readFileSync(`${implStories}.ts`, 'utf-8') : ''), + { + parser: 'babel-ts', + singleQuote: true, + useTabs: true, + } + ); +} + +// glob('src/{components,pages,ui,widgets}/**/*.vue') +Promise.all([ + glob('src/components/global/*.vue'), + glob('src/components/MkGalleryPostPreview.vue'), +]) + .then((globs) => globs.flat()) + .then((components) => Promise.all(components.map((component) => { + const stories = component.replace(/\.vue$/, '.stories.ts'); + return writeFile(stories, toStories(component)); + }))); diff --git a/packages/frontend/.storybook/main.ts b/packages/frontend/.storybook/main.ts new file mode 100644 index 0000000000..45db48fa1d --- /dev/null +++ b/packages/frontend/.storybook/main.ts @@ -0,0 +1,41 @@ +import { resolve } from 'node:path'; +import type { StorybookConfig } from '@storybook/vue3-vite'; +import { mergeConfig } from 'vite'; +import turbosnap from 'vite-plugin-turbosnap'; +const config = { + stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'], + addons: [ + '@storybook/addon-essentials', + '@storybook/addon-interactions', + '@storybook/addon-links', + '@storybook/addon-storysource', + resolve(__dirname, '../node_modules/storybook-addon-misskey-theme'), + ], + framework: { + name: '@storybook/vue3-vite', + options: {}, + }, + docs: { + autodocs: 'tag', + }, + core: { + disableTelemetry: true, + }, + async viteFinal(config) { + return mergeConfig(config, { + plugins: [ + turbosnap({ + rootDir: config.root ?? process.cwd(), + }), + ], + build: { + target: [ + 'chrome108', + 'firefox109', + 'safari16', + ], + }, + }); + }, +} satisfies StorybookConfig; +export default config; diff --git a/packages/frontend/.storybook/manager.ts b/packages/frontend/.storybook/manager.ts new file mode 100644 index 0000000000..5653deee84 --- /dev/null +++ b/packages/frontend/.storybook/manager.ts @@ -0,0 +1,12 @@ +import { addons } from '@storybook/manager-api'; +import { create } from '@storybook/theming/create'; + +addons.setConfig({ + theme: create({ + base: 'dark', + brandTitle: 'Misskey Storybook', + brandUrl: 'https://misskey-hub.net', + brandImage: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAhgAAABgCAYAAAEobTsDAAAACXBIWXMAACxKAAAsSgF3enRNAAA4fklEQVR42uydaZBU1RXHX/frjWkG2ZR9HXDYkaCAEI0awSX5lA/G0i/5lLXKUoEwgAYjoIPgCMNioUAYUEipJKYs1mFREWXpASpEU1gMZaosNRY1KUsQFPDknMw7cOs/78573TSmwXerfvX63X057/S9r8+97RCRE9FMyVYs6oxS6gx2CSZu3Ltyz8QkrFQbUSwcs+FLD42npYd+QF4HJBfsSpIwb/d4enbvbeIfU4gdfg5RYH3Yimne33zzzY1YXmtpzp49O7EYnZFYtH8ILTlYyfSlp+qz9PT2LFWtT0hg6r5pgx/XynjSQ3I10UprxfEe/c2wL774oqN3m9Myzp8//wKWgXkY6ShE+aZrUn/fzlh9tIwW7etBixs6M+X02F8yNH29QzV7xghm410vo4Ty7bffbvf8XA0XP71XP72in9lYLYNJsGT8WvwgrXTUr4z4rnLmzJlJmDfeSxzs3BadseJIioTaXBsmTjNedmjGOoelpILmbqsgiaOV9DJJMim5CuqHiP+ePXuu03C9wueEYHbyxx9/fL3kr2ga6SCNB/mkWFLuVj9vMJJYlsTRthidHzM7w11+0CVBe3Lm+ubOmPW39jTr9fak/kbG6dOnT8/Uz0eOHBnM14wgfuovnDt37jW990vrkTI7eePGjV0ljuan8Xw+Z7jjbparpNE6aNiXX375O/O+qanpHu3cFp2hsGvDJIz7jJcowZQxcR0NrYwW7KUta4U2JupP7Ay/jHaKmT+mhTRB5WYVqIe2LXnhMc1Lu9s7QwvQwtt6ZE00vK6urgd5Tv3NzgAy2BHr16/vjg1uhbaKpazCO0MfE8voWSpmqThWUKVCsUtHWR5kFewIlIySnQBF0/GoM0pzOh7r0ieeNQMrRjqD5dq1X7aDhJdqI4reGUsP3cLrkjE8FR9xXu51XdJrUKxbDa9Lnn57HIm/z7S60Vg/OAFQHsoay2kKKkPTXHJnLNo3kqfgQ7kzKnjG2Y5kXSL0GRrv++iqsX+Vr1SoJK4XdJEkrhHWAtV6r1frOsW+/pEpeBXUwbr2wDBbOt/OkHUJwx3SlelAszZ2oGkvOdSzMlYp65Jn3xlDUMmEonN+nv6+KlfBb93gc98IU2LFFTRvLU/j4hrGrwwemOmWcjGPWIu1ia5Lat7Jnq/NJWnqKodkOt6jMj5I1yZaOK5L9N6Pr7766n5zTSLrDQ07ceLEWCN9wkTzV8w8zDiCLhI1XlCdNA8yp+LmdNxYl6SZRNVLzinpjFTGaSfrEgEWaSnbOgGuaduVG3Fc73ExZjQs09raRNciWLauV9DPLE8lxnehxpQZ93G593otyyRh9gnrEibPdQmL8k7LrLBl/oouvLBMOzgLxrIS2hmXOhXP4FTctjYxK0ee40Y9DtPkwHWJOImf79rE0iHaGW5enSHoY4IdUtDaxL5wSlmkowjrkpaLNFMySnYCFBEtTSIiwYi4UvD9Dp27ZczBpYd+xEvYCcw4ncQ7SocuTueaN+OkcHhi4f4fn15y8G5e5d1Ftz/Y+2fWn7osrtjvB8ysLwf6Ux+1dI1Xw7sOvwYnWBhYIG5kbuCBHs4Mpqlru2/lsNS8HRlC+gyN9ete2WbErPqRNGvbSH2FH0NInP0nyXoVkCsBW1tUWIpVjj5Mxax7wYKx5lhbEmreHUCLG/qxYPTma3fmOvrjpnY0Z1szj6xyaMoah3oOig2s3X8bKb+sGb4SKhMXjM50/ZAwfnczz4yP74/0d2h5beIXbn623YeNwwveTsY94tJF13T06NHO0B6skxLDMqUcWx0MwYhj3U+dOnWTcd8U0H51ORDuar0PFIxVH2ToAu9naNGBTiwU1zBZqs2lGZemskDMWO/QdKYnv/p55s1hJMzb1YzZOebA21636BsS+TlZP9uchGMc7sBnxE8x4/JPzGP0M4L5obCiH6RPIma5CtobmO/fWMAbsG0mJ0+evAv8Q/eNJR9XAeGJKVbBePFwkgzEs/yhFxNv1OYcks9Va5vfBerP8z0q3UGzN3Uj4cmNzRhrYtdnsFKIhpn3X3/99QyM99lnn42TKz8tv7XlYcsP49vjtLRfMIG0aQTLYkOQ34BlTFLD2Cxhh372e4UnpgJooqCmDJrGjGurg9hfmG2B8XBROHwnVfwmfMTzB2IkzN8e+5ckRJOFx1gghJkvO+KRmfgL95GZr6ZIcBNOW3zCjA5FJ1piheQBtOGGPkjg8I0XOk0v71X1XpFyNB4L3cpPP/30J5onvCNNCzaB0nB5t2qrA2IL88tjw4YN3aCdodpvvlqFuBmxmfGzgZF+Mt7yxS/LCy6cU5hvBwWUcBQEBd7WhaEsCGJ37NixCWY6MAvBt4FCOgCoO5CnXU3INmbDgu3Ctql2VMEQLqdgKHHUGH6mJyYBgtEWCOiQcPY7SoBgpACbYCNl+RBS6LPh+8UuFOEE4/JrDRQOJQ1YhKQoZJV8BQLnF0qQkF9Wwv9Sh23CdiUEFYrot5KI6JV4RCQYEZFgRESCEREJRkQJCwa7+IBRZSP56trCr+vllKnBtOfnDhzd8UYJK9XGRhQsGLrN+XZq3lIwnsraxTugUJi2GFVr4rvYL7m4YRLHv5tqc5PITzjAUv27sGEQN+1y2mPY7EquSnuMPkOS3dVAR20yZOBVKBbsTJ7XfScK+2cXvHsr1ey7nebvuYU0vmDbK4/+GFYkwXCKSYg659TvUttillMSgtF3WLqCNx/JwQiyAYkZylRKTVMPPZ/a5GOoI2FtHt86nJ6ov4Fmbh56RrQOdiTYISDaActL1TAnbFvEX2woilxOrJgUpjGGpSpeONKDag8ME8stZiDTbKwzd5tsyrrInC1l7FdGIjTpMre8dv9dH6L1VtCBEhZDk5hgU9N6mERr4Xgv5yeQ4dDSSgfUL08ABSBuQ8uxDDa2ETer1Qd8VTViezBPdBimG9rCaYzh8Qq14Ko9UEFygsjihp5MV6Yzzd560YJrSp1Dk1c7JMJgWHDJfcLSmVbrLbFG8rE4atJ7DQ9r+WSzxML4WJ4ONsZBdM8XGt4oKOyKDqjdCs1+j2VjPGyLaeGFgsU2Hfeh4AcKhu5YrN3XmWpz/zPpYzoy19DC/Vl6YnMHmrK62Xpr2tpm4x3ZyajwfVoLs5+60hJTcGzWVNKgICHD9DrBDRknbjera2nJpYMvDuwbrCfEsLYboAMcrh3BfipwGo5xVEPYjuZRWhEMt8I07at5txMLRwc5asc7ZSZJj650qEpOmvmzI1tcJfN2hmmf3GdQKEAwkog8dRKuZmhB5mtBJnGmX1izPikXj+nJx7RPT8yRATfzlbraBtVunmive1DfBJkXKiD0McUyx3ArdFuvwlpCbT154GPnTNM++SyCIdt9jS2/GdseZb+fsMFWMglmaIGYal398LMMmt7b4ki5NntLICnmelJ3RC3HUFgkHWgUBrY8e2779u1d8jU3xHCMg+1AwQg07esz1K3ws/mU86imvhTfJp9nrGspGKbNJwoGSG0KkY7AML+4x48fr0RbSUxj+ywuIL4Khg6CVUAhbdoEy7LvO29ptwrpwf7Tam6YDlMHFDDUIoGCkS6LZWXfu/J8zj2pg+td0zNbCka5ntclSBzUFtoxOri6V138VN1C48TvP2g3aX6W9Gh/aabH/LxzvbDzMnqvxrQWA1oFhS2tdfr888/vNfPBc8iwTn55eF8pG8x7PUtMkX7RuiveHOKgn32p2R+opdFIxyoYkkiMgBVZcUC4O3Wl8281Br62V6xSCp/5mhgCNyOForbQioQ1nvUzdjX3/H/00UfjMdzvgDYTMhzaYUL6tCBC3JoxMIHztM4DUC4Ickv/1gyepc1+YSoc6uRerbdEQDRukKFzaAsu8WRSXiEZy+vthFZCzxDwCst6VzesMXCwQXB48jWFI3ZhzOFQKMLbfOLABNereHai9nah5vjeWonLqSTEzsxTnqrvykpcvx4iK/ECBaOYVuKYB/m4AIvqSxYMKCvwyS+QtkgpC0a0fSDaPtC6YFi0hlVzFP/rxC4cIbVFykK62AJS/C0RwUKBc4xoX0m0ryTaVxIRERHZAkdEREQKIyIiolQp2YpFRERECiMiIuIK5tI2YhVlM5c9ztWy0y8i4mohnwc8Ub1j3D+WHb6Dlh66jbmV+SF17ZfsFmZvu8Tp1t/ptfCtGJnMft05rMfEeiT5mPrzSw/dQ8sO30tynVw3bg37uwUojzuZHNldPbNc4pXqAPkwmmlilpdo/fKlP1PN5GBcppVofb/XOGEedCZlKAlmPHMzM5a5iRmtlrJxv/SxmJN69s0EtYb+Ri1/I8//bUG1uYnMJFp0YCI9t/8O4m30Z82DOGxYNnU2MfWIuX0MN4CW6oBBVV8p0TpaMTYQo2vUcbnCzpXob1bzaifMALt9h8uRCBNQSTCjmJHef5nI1sSBn+iWAY/k7DdSn8zfmaYg5L9OOH72yR2j6am3xlL12zdT9e7xJP/1OnfXGLrvD/2WeXnHUAD9QGELAwqy+kfLo3CEHJfl5DnexVeB4Qhsyehoi/f/apfsa44UBmwdkaMyljTcREsaRjOjWDmMZEYww5ghzCDmemYA05+q1nXcPaUus7d6exlV7wiH/P+xWsbxUJT/dHKXxffP6bH5hnvaPSCKBGcw+QiapAuiQGWjAqPfijk91iIM3kzmFWOmUy9KKyiNbvYPqQDrjbyrzHD9Vsd0XppGLBe+/ZdbHuDij4s9r5wQplzpL509FjQWGDecWx4wPlXm+MCYWsvVesEYW+ssilnj6HL28iqMoamKtY3lpDz33gBeKlTS4oYLSkLP2mF6sX8Phrc2by6nOVvLee97CzgsSw+vcOjhlQxfew9xRWGk+o+8ZujihjsJOKezixDEBRTMfPAGkozBibc2E5H4ODA6OBZBN11O8vMUTzWEVec7c/LJ4xXJ23tgcua3tS0f9Zd0mJfXNzlsf9B44NE12L+FENDP8MBAn17iWEgcwZQV9VOkj4PGXuLZznmypcU8sB7iQPmRhEs88feToaIqDDlap+7DLJms/KCcFu7tS7W5PqaSYLow1zKdmA4c3p6e2NSentzSnmZvbWby6hhNrZOTVppPXfl987/sVcpMYuHeCeQDK6kJpLOMMIIJneLmi7FDeDvkmwujiCQdxAutyOSYHYgD6a1hjZYwy0MbXIbUpZU2vlqAUnYFPR3H5ox/Q3SBOJ65BUcEITFQLEUbCz1qKWwfQLvyjodK2vJlNy/MDBnPT0UuUWG4FX/6ZxvyY8E7XWnR/i6sGFRJdGTaM+2YtkwZh2UYOY0nQZNXsYJY69D0dRf/eVEUh/z7IpdVvmD3KPJj/tuj9MWoazbMJpB45FA+6D8t6l98+ikSEdZ8lQ4KmPFPkW5YWlOE+eTJ/wP386B8JI7Wu4CHxbWQQPDkIp4NvUg+DrZPtlA88remYRRsMcYCj+ASV+i4IVIPjZtnHlifuI2wy+4C32G4FSvfT5ONZawUntvbnmoPtFASTJJxqWpdTA73kxmFKAk5y01AhdGueucgMnl6x0VAYVgE1KowkiFJ6NFZqGx0IPN18k2MDwYfuDNQp4w2d/DgwWtbO4sNHzTwDwTjo7/kWYSHJWEhGZYA5Y/j5SrS70H1K2QsCul3KtAFj729PuEVWPA7o7wVxoq/p6gVJGLHmvcyPNu4oCSYGOOQhE2rY+XAykJP7DL/wldO7urhKYy5W3uTyZwtF1CFkWhNUVg6N+lDCtCDJJ+BmUFSOXHixFjI87Igfz1snkZme3hsD5akt+UN7bTng+cWKoUrKazjfOn3IPToPU0XUqnoLNH+sBc+Fqn/snc+rU0EYRjfNUk3m9RSCmJVsEgQ23pQEfTgP1QQBC+ClyLoB5DSc7EFPXjTi9AiBQ8VpXqIHuo38OTFinjsd8gt4KXrvMJbpi+ZvJvJWLLpE/hRmp1/2Xnn2ZlJ9llCOrN18lNU+k3BrwzRnrJEEQxVOHJuepYaa1vlrAtsKJXY9+Pb7y2+l2IhBaN0hgTj+dcjmc2zzV0UwXAruhaQdsdrAd1LufYAlu9J/0m9Hr1+qseaHcyJ4yKduxwhGPy+FpwVBdl+bmPihq0c9XNuDygyXe0iniM5+yJhRP2JfE66fUwiPUplPHTKIwQyYfTzoAuYY3yUmBCCQZmrb4zhq4vrDw7N8bcYju+tK5fuxo+WPpDl416e0hLlXbTrf7X8pZ794/Nenqyk36QDoI28crCXZq8v9v50wc+El8+iJ09PQh6Tnpz0THXpNU51UuDRXzk1lvU7jok0+kv6f8r80p9UC05FKGwSMrqV7eGBS3R53n+iQWmlV6wLs+cx3akvqB/pr7TI5D6UiM3IH4Qrj7Tc5PooTrT6lLa4BGxE4FrmScFgdMGQsAFwtR5NvNiMf61+jzLDzp3HMQ2GmhQLl2hQ2qv34wUjEjuGbP51vDWSRuNUtjXgq9OXo1uLH+M/i5/ibGEt/l1JojEWC21m0S1QFao5SW1MBz3kIOegZ19aDTNVnjVXm7eUh4PNXB1fNpvNY1o7fNor293JX9ZVjuvKmR/3eadBw+eABy6dBxJXOq6hDyodpS9SDUpnfwbKq+WhNPRZWTyo/o2NjeP5rWr9+03MOBhNOFgwdKQho1VoHKgMmaYs/eOUpUhFoAaqgodnb1BSHyjoKHi18q3fDjTz1OshIL7nP/Wkag9Yj/y1/cXfg9K/v9wzjjyCMdAWfXIqFFowAghFPTC1ftne3r7SwYV/2fzu4h5BYmIfo/+1+pUA9KZfobCvzHIZE0IYAvZjPTR6f+nCoQkGA8H4/4IxqhA0QFzp19fXT7Tb7Vf2epqM9Vut1nyP9aSCgRAMnk2xaJh9kaVQV/x9ZjQPEAzPm5cC7mFUuzHAghEkIHsVjAB7GkGXJAVYKohywsaLr1BAMJSZRr9r6gIsTbyEwyMAg25+FlBAguIbF6GFYmj2MLSlicdMI8i3JgUI1LpO+BlF3q9VQ8z8hoSaRojN6LybnUMjGACAAvlhAAAABAMAAMEAAEAwAAAQDABAkRjYhgEAIBgAgAIzsA0DAEAwAAAFZmAbBgAYAsEwr5jBk9sBOFjkTsg3ek2eKo03LqQzlhlv3KPbVrlxLmocnYoO800undKcvjhx/uTM2CSlgXAAUCDBoEE8NZueXf15O1vZumm4YbiW/WXvzIKjOM4AvDu7O7urAySbw7KEBJIJt4wPQGDHBocAhlRsUqmC4IeEB8AkfsAHGBD3fR8CY05zGhwuGwfCseJGBowk4wBJ4Yp4igJFqOUBqniIw5//X22L5md7ujUWIAFd9ZXXM709PfN3f/3PLDATvuxwogZvVPeP/8LzzaKjXpDJbOlpKcSD+AZNaDfuk+/ehGVn+8ZYWt4HQin+ZCGWGrIVnEsE+biuBsfhnCqQ9Drav5oyFImwt7ivQHLraH8fa4xkEQx7gsvOvoGS6I68hryKdEO6wpTd7cuENJxkMXWXdX7BEQsS4fN77Ko3rDXIKyr9JUqiN7bdJyaLJWW9YfGZnsD+oWHtLQ+4KPQmrDp+KxQTYH1+Wzh7kbJRTJ7cooKnXghDrPoTv3rhSDyrQF6Jy6IA6Yy8DINnZC6VpcFlMWSOtWr+ET+oGLXedwDrhcbt6lKy8PQbMUEUlfYCksci/LzgVHdolBVqQPJyKYyIgkQlWocHZvRREIYq2yMUIllRh4UhZ0aeRx2TyRcQWUWVLLoiXZBOyEvIC8jzUPDr1P7ieYMsm25vWb+ddzgAOrBu0pRI5/NzSn4O80++DgtO94iJYh5+nn3iFWjWLiWT2lMJgsPfLO4Evdk8kTQYdWHApstvE4c6Oqg0camQ3zyuqkdvLwep0BvYnWL4sM6rvgv8fgjDTpBVIC8iHfG2IR/pgLSDjDx/rpAG4svI9ebOPRQEE7B+cpf+jX8/7XAnmHmsAGYd74Z0hRlHu8DUQy/R/lBtCUP3Hf69JylxzTG5xmyfVhpPhFFvhJEwq4jLoj3SFmmN/AyC4aoXG9n439kHQ2AKZRjE6K/bXp1wIB8mRTrCxMjzMGF/B8jvmfYW9QOxeOAMhWHpUA3oJ7igFuNS1+NR1/sneFDCsBB7aXlnWFr2MvIiSuGurAJpg7RCWiJ5SAsYMMaeO7M4CWYdNEZ+XV9ym9dT+g6Ylrm7f2HGajvsTZff2v4AhLFC+u4w3XGofel5SIRub1gd0++WIrMMUm+qV6FrG19j+FT8XCqQKLKVtknHHgZYxDa+qtN+p/Nk+90IY7ZJXHS3kdRfw+NWIFtrIxainrroBUIPc/nxDM4hIsWej9cofTYZo8jQ+yqMojMvwqdnW8Dy77Px7ewdoKi0HSwpq84qkOeQXKQ5kg1LSrNg2r5kmBFJhpnFagq3BWDcDps+C2EEkFCPQTmDF57sdX7c9lcPP/VsOEvOLgwGh0VohGG6qkXFNsUAVBQ2kF0MODGROfzXA0WdKKhLlK2MEacVkyaZm34KNBmDpcd95sHkyK6b+1iAYaGxpxCFsggh6GJPcnA47jAhVVOh1aowVl9oDBsrUmOs+6EBLDrVGqVxd1ZRLYuyLORZWHiqCUzdlwrTD6SiOO7l/TUeGBFn5EafEIa9+Ntf/GdJWU+Q6TW4+UCRYTwwYfBBrR4wpRRAMTC5NHSyoP7R9+m7XEKmwuD7Fe2PlqVhIgwmi4p4G7OAlRoKI8qF7Abqj5G0uARrIRYUV6pH8J+AZVTi4kKhTIb1JarJzobxPlOsuOT4GKW6XE73RRgb/pkCMp+UNYXFp/Mwk8iNyyIHaYaTOxPJQJoijWHusXSURgOYth85cIexW234YK0HPtqArPfA+5956GBJPd7J+kPRt90hESLLMB2YUhDHkGxqgtMqiG/ZKhb78G1bnRSDOar6PguWxZEHIR5rG9/PhKGYRGpR4vaV7A3ixYna101qGoRsRbNM4NdXPlecOL0M4mMR+OrHp/k5KPCyerUQC3cZk+6a0niSrulKvl8XFyYkpUxNZP+ThLH+h2TgLCjB25MzOSiG6qwCeQZpgjRCnkLSYXqkIUzZlwZT91cxZW8D+BAFMWqjB0Z/7oGPN6E01sWEkTzvWNcLi069AonIaZ/aDOv4H7YwVAE1qscnvP67szXCMJYRF4uJMHQZAG/DEB8hpOpQovgqxAE8NjWdrJS68zruY+FeGCRYXT2+IJkdS11H9TyNssT7Kox1F5MgEQtPZqE0MqGotDqrQJ6OyyINaYCkwOS9DWHS3nSYvC8dPliDstiAstjsgbFbqqQxcn1MGKnzjnf614KSzpCIFvmp+QphqAakLAx/TWCB8QmoLbZdiTwpHUTicyszWo3d9o1gk10lS5+LWzeBT4FfBgf1KtAUfEfswESv8sNbg3dFHSEXXR9rJxbq66WCZSpGbXJhmvTXRCokSCYMJa6EsfYfYUjEiu+TURpNURp3ZRVIQyQVSUaZhJEgEoBRm7wkh5gkxmyJC2PzHWHMOdqxct7xFyARzfOTSRiBhyMM88HBBzMPHEvDyy5evNioNoRBK3J9FAaH4sWyDx5Hn2l/MR1/jq22li4W91sYdNvhJjY89nVaGJ/9PQQqFn6TDotOP41CuCurQJJwWwixET/MiFjw0Vq6BYnL4ot7hTH7UPvKOUfag8zsw1U075D0PAlDJwoHYQRM4MJgIjEuqjbKy8sb8/3yAKHV1ERmN2/e7K2WnLkU6ZiadtxNFr0gAjp49iHiI0MrNuuDsURJEO5jYX7dKVbgsuhizzGJCQlHJRWNOMyEseZCEJyYfyINf25tiGK4J6tAfJiBWNXPLcbQrYhCGDOLW1XOOtQaZGYerCKnffiREIZpKk4T4XEXRlwa26UUfaziXFiqbn4bYB4L98IQ2aZJ4cetl8JYfc4GJ5aXB68vOJmCYqjOKoQsEC+MWOH996j1KAvMLgo3I1vibMZtn6NI1lUJY/r+3MoZkTyQmX4gBgojJIShS339BBtkAY7q5bQomDmq77JJFqgtrl271oWOlWDAlPG60tPvPmw7W4kVaM6Ft+NudVULAgfr8JKSkiZ0zU2R+npJcS5R3g9aKDR9cxELdd+oiHHEoVjx2Jihjz1HFxONMLTiMBPG32xwollrK6/g7cDgRaeD4hYEsRAPvDbQGtK0hTd39EbMKIQsFMKYuje7cvr+bJCZtq+KnHbB+ywM/cTj+2of8z7wQUMDuq4Lg08uU0Aqqkku9l+9erWlSd/cxMKtMKh/j5UwVp0NgBPZbawcmvAtOlpdFp/x/YhZBWYbnv+1KvD2oO0ZeVb2WBRDoUYYk3dnVE79awbITNlTRXZbWycMP6EQhunAjEoPyQ7y/XJ6bNIeBTZRG5hS/s7pe5cuXWoljsPbUG2nNsU++r6uX6rzVE8Qvdg02EJqrP9BJ/bs2fOMqq8yTIDqyWUeiyCBzzlai7ai0eibvH/sGgQ5vH98PPL64pj450y68u2sH7YbgfFnKnzB/anC8CL2yu/84ABOZisL64XiJAnEtoznrEyRXTgJY+LXjSon724MMpP+EgOPEch3LwxzWTgJobi4uKlpuzS4RV31NjXmwtBPdkW9ByoMfv2omAhDjsuVK1cKVOck30rSMwllv8xjERTUljAUxwuq2rx161ZhvRTG8jLfjyvKfaCC5MAekPHPSU7CGLnO81+skzJ+Z9qlibvSIMZXd9Osja8VE4bq4RpPuS+pAkQXXqx6cqFVTTWAsf51US++CoQ4N27c+JP0IK5cbMd0ua/Ur+u0TTdJxDaTwYsSGaQbwGIfE0ZINwFMbxU4GjFHpfZDHIqdqj+G57ZK3s9jb9IPOd58H8H6F1Jx7ty5Njz2pu3xfTSO5O2a+NkM/kzFT3BhCNwII9B7sDV8eakFKvi/VcH+VqkPCRVu8sC4zQyUxVjcXtDPO4Sk8qvh9szxXyZDjJ13I6RkLAxXRT8oeVAp+LQSUBBJFPT/TkFn+ylTmUuDl8Cn6avZgN+uEwaHtcGL+LMAf3xYwuDHkMVKfSFM48JJIBhbl73wWFAcCX4dMYPZwWNJ8Hpy/2mfqq7YrzoejaV6JwxpwoeLSrzRT894gdMkx9tc/NzpJJ3GzbytEwnjw5Wey9Q+EkSSCrfbEGPbHQbPCOwUUtLdirDf8E1LVGQLBoQJPvF5of2iLocmB2gKPpia57QC0YplsprxUl5e3pb6wISRsG98QDJsnsVpCMpwMfDC2g6ZQJkhX8V11CAWYRW8Pr+2Mij6dwz+dux74rY+0XF27NiRwduVxge/bkGGeEbGhGEoDgAwyjKQpH5DvSOXnfbcRmDyTm+pHfY0jHfCZyCdoB3ypL1X5C0vRFEgt1/9jXcEtSs9jbeR5CHzvfvH/NkLyO1ub3vfFXVMnl04rWwaQoaEZWgwUXDEIMVVao0IqA5c6cfJE4c+04ASA6A20A1y6gMfeAKdMBjGwuC3UbR6CwGL6yCvsG6EwRYALZpYGMWSzkF89/Lly/14HS4OcTx2zkmMsAa3cQsQ7oWh/7UkgITYidiIz/A1A754/TB7KOqv7tD/2buflyjCOI7js6NTuzteOpWlFz0YFBQUHiozrfwR0tFTkBkJUYfo1KFAiMgOkVAudMsu3YrqECFJ1qFYLD116tgt8Q8IdHoe2Y3pgXHGx8fledb3wOuyu+58Z+fZzzzPM7PO/+sJK/KVxxpSAiOhK6wdEIUExRop6EuvM6ossmFnWa9GgOh+/gUd6pyH9udVe2FGRYXW/kq6nMBYYKg3GVImNf3N/r2QSwiXQHmdT2CsT375ZQ8nrU55NIx9sYr1EhjxIQSBsTWBYe8/GxXFKYwGhoGgCA0rblZ8/JpUp7jK8qDyupT1pweGDv2gSJ8XMhEMBvdjqEs/QNKDIy0wqgiMOg4MOYZWZ/bFWH5IEuPjO+rzcr2uBkZ87K9OUBIYBIZLgdGUwnQDCePEkORllGHZwHoKcTYEhpwcTDo1a2KIUGNNWRAYBIbRwFAtLS1dED2M2fjwQ/xc++H09PQ++bzLgaGeZZDbWS6XD8h6CQwCQ2HstGp+PRYHhpEGqREYmzrNanrS04HJSOV9zLYX3aAgMAgMAoPAIDCyBkbWoYnuEMWByU+t4NBogEYv4HIwQIzSbRemg6KeT6sSGAQGgUFg6E1+agxNjFwq7kBDDdOZH4Jk/fGZiaFinSimMHJBXdYLtggMAoPAsBuBAQAAIFlbGAAAcJe1hQEAAHdZWxgAAHCXtYUBAAB3WVsYAABwl7WFAQAAd1lbGAAAcJe1hQEAAHdZWxgAAHCXtYUBAAB31eJ+AL6UdL+1Gt2XwFdsqIas22LrTgYAwOkORuwg3JAPc8Xz11qu35/pnJ9a6F0tLfZEUws90ZPv3avjrw997h/dfSnY6QWxA3XOdA078l7Qf9Ebuf3C+zj5yVsRojVz3srdV97XoSve1Xzo5eM1KKqdicajg81d42+6308tDP4pLZ6LKlYezJ4u9422DTcG/r9tobMBANjuTHcsGls7Ch2TX7p+lxZ7I0F2KoRTQrdwUugSTgjHhWPRxIfDP5rbgpbYndlyujVUOwN72732e29zPx/N+VEWE+9yv5rbvNZqDTHB8K39Nx5/6xMdo35hQNQ8qBhYe1y+5uazzud+Q64WHY2n0dYsy5X3PmNrg3XMEWFZUJd5YZelNW8XY8JMpL/MC2OWbhtQHx2M2IE9ODuy53LpL3tn09NEEAbgfu7WxWoCHLxZKYnQSgVaKMVaFUu8q5FfYLyZKCEaI4kikdpSisjFxJM3CV68aARbq4hCSZP+gJ692mhM7Ude37fuJhvDst2ykAX2TZ5sM1+dw0zmmZlNVl4qkAAygPiRfqQPxl62v2FstdMEi7A4K5UbljMcGn1hej/z0QyNELpqHBF/Fnjk/smpxNoQPM2EYW5jmCQCufSPLP/ENMqjMon1Ibi36E9TfbUkg+qLAYAF2N1YKBQKzfo1UANsHUsa7fO+Qjx3qtXqDdihwLbviv9Lny+KZS8vmhdejfZTRyGqnVw0HTVy89nQHwVSgfh4enl64PLt1pv1LND/XWEwV26Zx+IpK2yH6aT1N2c3HBa+vz+VHszHVs9C/Os5mFm7UBOI2fWLMJshwvS7lpbAvPi38zC9GoLoShAcHns7CRdiVl0wNo+8IAFKKZfLwwAQQfJypxulUsmn1YGsUSIgEfop0e4gGuNbxfNKpXJNbg5RGbnTQ5IYXTAUEYZNQqN91VGIWqcXjKOLbatPKgSx8ApSgXQjp2E+60G6YC7j+tkZsPWLFmmTxLsRVnfQNBBLMr9iKQbU4LjbeALbbSKuP+t4/Tjth8inADxZOQPRL0FA4eAJ0ZPSanmRz4NAZSeWfd8tjJGrVzCUCoHM7kkVZBbGDal6+g5OkjByh9B3Z/KoOV9ICqTGcaNSTlBdakNKWMRlD/J8qWeDowvG/kVNwXBuQyqQU4gbcSGdSAdMvnXk7M2mVn6xtiBm/mm1txhaxhfZXDTJgoqIBYNjbKYjo69cuQdL3TDxoRceJX0wmeoTQ2mY54WHyz0w/s7z45jT1oZ1WeGqZy8KhgC1CxJRLBadf9s79+AqqjOA3937fgRCQEIgCXkQSAIkVRGw0vqY1rbaTv9prdr+YztTrbY+8AFEFASBAPIIOlMBeQgIFkWtNoAkiA8imATGgm1FScY/yOhYe5mpIuO09ev3wS4uN3v23HPP3svuzZ6Z3wzcu3cfZ/f7zu887s1ATJgezm6wUmFIwHYbZXw1KyY9wfAEY6Aj9WF9egQJVUwgwZiCYjAZuUxjEiIkFcg4ZCxSozEGfrti2HpFPTNtEfEHfdFfLwqtW7w3ClkABUOt0KZIIvpUSXFVuP7WNZX7Z7XWQtPOOnhwZz0QTURrHdy1dcwHtVcUXEPbImHG6AUn4KQFQ7UJxQiJBGvKRO8Beng4lVzEC02LGKYYe2j/Tq0PZ8AXDKeer9PJW8EYPSFU/fihSfB496WwqvsS5GLkW/j/RlwI2aAxEWFKBaFLBVKNVCGVSAV+bjQ07y2EBbtjsHBPDBa1naW53V5G158RjIhxsacmGnFkEFKIDNEo1F5LIFGzkQu3Cwanl7FdcnSkB9IvSenEbWwMxEsb1UMG96sn09EfhuytBrHSnCqCZtuIXgPt0+QZaZO7n/bDOKduW+NFvkHtsfF6k7yOQO7jQn5xOivvOTXncGJGn7pLQnqlh7Zn5QTBuk0iP3efYIwPVbd0jocNxwphc2/BOZ7+cBCs6qqClQfroaWrDqWjDmWhFiGpQNhSgYxGypEypBQ/MwpaOkvg0VcLkAQs2HOWhW1izH0lAnc/5YO71n7DPesVmL8rBuXjUwQDqWosnNDcftUR+qqqCXh91/7ryhvLf0bbpgqGDUlDNSIhGFLgMdaASTl16tRl3IRr/yr+JAWcYMOcBOkiPn8PJkVUVASkgrtIl3FObZlcg2EdgnR9ysUHG4tnLqk/u7nEQtZXZ0mmkg6IC61BtbUkdVF3Ts7hxwzJPEgUPXdQLINkMbseBwtGoHrT8QSwWPveEFjeUQMr3xmLDXINjmwgh9KSCmQkUqIxAimGZR1DYd6uApi/m2QD2VMAC9qsoW3u2+yHu9ehUGzwwfSNGhtIMHxA0lFWp1bpoxHhmD++qG3akce7roE0wOu6+lRV4+Aao2Tki2CwEiMdmyMYejAkMw1a7dhJzjSNaILppvNKs9eT5IiCvGCIJ+XtvARL75s0OqvtEAzG+pzVep1aPAfdNk+5qTys1hIZpzXsEA6Z86Fn1e71UvQMOCsu7J8icUbO4cdMSiwm6RhCx2fHcE+m+6K6cIVgVExUq5/+MA6WfBCHlQdKYcWBKmjprIRV3ZUoCxVIWlKBDEcuQoYhQ5EiaN5XCI/sHAzzdg+G+a+eAUWiP3NejsN0FIt7USbuf9oHD2z2wYwtBP2bXjv7Xuk4ZSytpSDuWdf4Yss73wERlr897Z/BiBplfANGOmEyAnoWHSvbgEn5+uuv27k9SHYSUkVgNbgY8GMs6quHkchtGcHJ8H59X6IRWiw3AiV+D3m91hMnTgwVORe6X4xrW5OFBt1PHDt2bBhdJ2RWevCzz1GcaRLiF4BzPzjPMh/FQv5vdWpccDou3M87OecYYkZ65IzuE28UR3LBc5tI+3RBBYMEIh2efLcQG+JyHM0ow15/GY5mGKQC4UgFMgQpRAYjNAVDIxmDYW4rysauQpSN85nxTADuXe+D+zZ+IxYzt/pglsbMZ+h1ev+MYIyjNRXEsv1TT6x4eyqIsLxjKlQ2FkzU12MMZMGgQBIITpmElGRtz7L2TI9NCZVgJ2N5waB64523fOKVFwx+HYgLVLYEw8inn35aQ6Jm0/RAD8UfCYyIYBAM4ekRlLUiKxF1alzICoaTcw7BEgK7YpZkQbRtYe3LJYLhr974fgzSZf3fY9gYF8PyAyUoGiXQ0lUCq7pHIGlJBVKAJJA4EsPPxXAfUVj8RgIWtA+CRfsSsOi1MExfr49aaGLxzFmpaNp2llkIycaMzbQNCYZKglFALH3z0r5l+yeBCI+9NQkqGxINqd8mYSGaIC0EI5BtLBonvxn0XjZkiJJnutNElPScJmXY67iW3hOoN73B8Gej7uh4vGtgnY/dU27CwsAnkC7YCE3GofXb8NyWCIx2cGKR+3wkGSM5fg2ewHbzhNGJcaFDsQAmhfc5B+Qcy/1y6la6fjKZzsvGdFROBWPDP6IgyhNdg2FZx3BYefAiaOm8CFZ1DUNZSEsqNLGIIhEkjISQIBKAh15QYLo+arHJMGpBYkE8myIYW84JRq0mGIOW7GvoW/pGI4iw5PVGqJgYJ8GI5JNgfPHFFz8QCRpWT8KOpPTVV1/9QqTnl05jgdscoqQrMAwuJRgSn5GCrk1WMPTzyTfBSFdCsKe/ljf6QfHCEwx9NMUqrjiSsFggDmyJCycLxoXIORIxLFs/6sASjAn+6vV/i0AmrD0Sxd7/EFybUQQrO4dAS1chSoKJVBBsqUD8KCsqjlgYRi02p4xaEM+mJxjNe+v6Fu+rByuaX+sHCkas0SgYMkKRgWAEbSZAUFJlDD1OZgpJjotVw3D48OGL6BokV5GvZVwvE1bjIypydjaSDMHI6Boc0LAEBAnaCV7H74BRPvvssyky8k4NpmCDByQsIvWXq7iw+3l3es7hxr18/fhzJCuKCNn7FgkKxrr3wiBDy8E4rs0YhFMmBSgZCRSGOMKVCkRFFJj7sgL3rvPB/RvOjlrM3IICgXLRpMmFKVtpG22KZOP5grFwT03fovYasGJhW39GT4jmlWBQ8gAsIg0TnRPkqNACPEqUosH75Zdf3igxHJ6kevEEY2ALBkEiIRAfIvGSNLtmWu8BJoWkQ7b+7I8L+wXDDTnHE4wsCMZTR0IgAy4Uvbjucv/3lrwR//fKgzFo6YyiaFhKBeKDx/b7TlZMVK4aWaNc8sBGlIVNKA0oF02aXDxIbDNh6zeCMRMFgz47yiAYj+6u6Fu4pxKsWPBqf0aPjxgFI8ATCpEEwEgsTQIJMcQhaKSjo2M4azgYvz0wlnUcTDI/NBtupffcAl079VDpvAFLJnUPJoXqRuIzUlDjYdYQSpxPgENORYozupDs7e0dx48BcbAXvxdMisi9oZEAs4Ys9RrxtV6JadJcxkWIh1meoMLKR4Qbco5dMUyfkYkTOcGQFw5bBWPtX4MgQ1mtWon7KiSuvClw2/IDodMtnUGUjH5SQeDrvv/99C7/DP0zI6qUChQFlAu2WPAFw39OMOa1jup7dFcpWDF/Z3/Kx4fyQjD0nlmmwcIaNnaqUPCgXh2YFIlEw2lo7E+U1DgNBMGgRpDR8+zNoWAkRe8Pe5Eie6qSXruAIzghzD9LwaRkQzDcknM8wbBbMMb7q9e8GwAZyuvVMv0PjWnEkUTRSHVUw9Xq9dNu8N/QcLVy/dDSM9slUrctqVZLtZELTS7kBGPuK8V981qLwYpH/tIPvI6gjGAErOD3FuShXp5V7+STTz6Zms5+6LyykeDp+JS8jY0vnTPn+Pq89u0yx2YlDuHt+cdJmjQkz2ejl02v23bN8okzKAPz/rOvNywLPVOMuFyawX0Kmt17EibG6FMvrz5sjouwGWBSksnkj3h1R9uASRGof9mcE04HHK29PDXn4FRVbQb1ERJAWsBkp1tkBcO2v6ZaWusfufqwH2QYUakOpYY5teJSHwTG+5FhpcpQzugFVzBKqv3lJBjEwy8V9c19uQismPPnfqBgBBrcJhjUQFMDBlqxSswiMBaQJXmSwpcWfoKkY3MaRyFYDYmdQqLT3t5eTPXEqTvh8x5IgmF8BizqMZwpra2tI1giTq9nGItBmno0G6VgiYdAfUjHhYhg0XuZCobeeHORzzlhHqdPn36QlXM8wciNYASR6Jzn/W8+eUiFTJi5yd9K+0BCjF/AZF2kqksOEr1lgbIrU8H4TbPyumFkJHHnmkTHwy8VwHm8yOU/hcVKkeGHtvwyUyKMIe6cFmqAKJlmmIgjn3/++R2MJHySglfbd4QFvc8KcirUu2B9ls6ddWw6L4EeapKRlG+mbeR6dmzwdxmeslho1kv1QtefmrSp90zvm9UVQzAihOw1yCZOCaFgEaZ7BHrh12OEBb1P2+HXNg+DXtgjF2EJuOdMRUaQGHFBJZnScEZYUPxQHFnERSQdzPZBr6Xej6NHj9bp8UD3gJU3LnTOIRjf8LmOWx/iIzwhDkLTLQzBYJILwdCnScKhqK+geZf/6JNdKojw0J/UtxTVF+P/NVL+OfiDvvgdK5XO2SgNs7dyoKkUpGmLD25foXQHQr4CkhSNWEm1Mnb2jugpBM7xvDVTfhK42ShKsoLB6vnxi7xU6L0ISSI6OAf4GNhYKAHo++Y1DFoSs61QD1WXLlnBEOhFJYFTeI0GJemBJhgpopi1Qo2feHzwe86cEbswA8Fhf/nCaPi50DOZYQyeZOwzJznHEwxhwZCXDCRWXqdc3Lxbff+PnQpY8cgL6qERlUqNz8eQC4lzKB6tTLjzCeUISYQVtM3wcqXecA4h47QLEr/uVnVO03OB/yLQtN2cWxb6X4nEfMMNchGwY0qElVApwWhTGkkZiaB9UNAYGoyIzURT2bFjRwme/2zRRp+2p4RBn6f9ZAL1bDIcBaKe6Tp2kmDLjd7jpeMK9Owse1d0HdSgpfZE6Rj0GtUvfo2uPvX6P/744+vNrotVX/o14ELDHbRfwuoaRIa0ccj2Jv1e0H71IWxJwukOzdOzL9uYaqNgkWxB9cIQmYidyMYFPVf0vMjAGBFhjjp99NFH3+bvVz7n4NqXZdu2bRtJbYQJUTPo3Iwxg8/bL9M5T969oX1RPGYSb4T27Z9efXE35yvF8sIBAHZKhj5dEtYqK44UDK9QR1Y2KFMqG9RJw0rVEfQaEte2Caf+YbBsnMPQkWpJWa06GbmsCP/NOQd9PwGDaMSQxKBhvpLScb4pZbW+7xZX+sZo+0kYBCVoFKVsCAaHsBz2C0aeEXEYUREoyUJKoddEr1dGMLKL+PPuVEj8zaTGuI0Xr1knxiEqRe7jLZhCwPGCoZOyLiJgbBAZCzQDxpPK3jkQ/HNgYJSNoDEpGfeVejMkFnUGrXBAQhUO0DzD6QmPCfXCwKTYer25F5CwCC5ocCMEjRKZ9aad/Hy5lLjNxNjkPr5Ef/fI0YJhJPVArBMAAFecg3EfnBW2KuEJRt4mrGguoCHV1J6rNjUUy4TOzs7xZsPD2JD93hMMZwmG2UJcKrRWwRMMTzA8wchjsCgs8lQw7E5QcYcTcwI0f2uxwG+2YU44bsXJkyf/QGLB+AGiX8lfv7xg5JILLRS0LobWEdG6hdR5eRJI1n3X19OI4oIG2ZI8yCeS8WW/cIgLBhtPMDzB8ATDhYJBHD9+/Ao7vv3CWNQZJzzBSCHro1L8whi5inqC4cp84gmGhycYDhaMhAwuSCDxdMBh8x+jGLwgIxX4teNpWbieqBWeYJw/QkHCkO79olGqPJxCcBoJO/EEwxMMTzA8wXCdYDi4x+UJRn6veXDqc+kJhicY+QcWJV0c+jXViAyeYHDIcYLOen3kfookJIMLFnk6VSTcsijS8nxckF9yKhSeYLgMTzA8wfAEwxMMTzA8wfAEwxMMNwiG1JSJC77G5/YhWLcl7ASHrCbAAfjDW56AuIt8X8QpJBTe11Q9wfAEwxMMTzA8wfBwZrx5guEJhqsXfXKEQy7hukBABjpxEfJgCsTWP3bmgilFj/xaVJvDH6bjx1de/VT4QMQTDE8wPMHwBMMTDNfgCUYOBeP/FczIFptfb3AAAAAASUVORK5CYII=', + brandTarget: '_blank', + }), +}); diff --git a/packages/frontend/.storybook/mocks.ts b/packages/frontend/.storybook/mocks.ts new file mode 100644 index 0000000000..41c3c5c4d9 --- /dev/null +++ b/packages/frontend/.storybook/mocks.ts @@ -0,0 +1,16 @@ +import { type SharedOptions, rest } from 'msw'; + +export const onUnhandledRequest = ((req, print) => { + if (req.url.hostname !== 'localhost' || /^\/(?:client-assets\/|fluent-emojis?\/|iframe.html$|node_modules\/|src\/|sb-|static-assets\/|vite\/)/.test(req.url.pathname)) { + return + } + print.warning() +}) satisfies SharedOptions['onUnhandledRequest']; + +export const commonHandlers = [ + rest.get('/twemoji/:codepoints.svg', async (req, res, ctx) => { + const { codepoints } = req.params; + const value = await fetch(`https://unpkg.com/@discordapp/twemoji@14.1.2/dist/svg/${codepoints}.svg`).then((response) => response.blob()); + return res(ctx.set('Content-Type', 'image/svg+xml'), ctx.body(value)); + }), +]; diff --git a/packages/frontend/.storybook/preload-locale.ts b/packages/frontend/.storybook/preload-locale.ts new file mode 100644 index 0000000000..a54164742a --- /dev/null +++ b/packages/frontend/.storybook/preload-locale.ts @@ -0,0 +1,9 @@ +import { writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import * as locales from '../../../locales'; + +writeFile( + resolve(__dirname, 'locale.ts'), + `export default ${JSON.stringify(locales['ja-JP'], undefined, 2)} as const;`, + 'utf8', +) diff --git a/packages/frontend/.storybook/preload-theme.ts b/packages/frontend/.storybook/preload-theme.ts new file mode 100644 index 0000000000..1ff8f71ecd --- /dev/null +++ b/packages/frontend/.storybook/preload-theme.ts @@ -0,0 +1,39 @@ +import { readFile, writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import * as JSON5 from 'json5'; + +const keys = [ + '_dark', + '_light', + 'l-light', + 'l-coffee', + 'l-apricot', + 'l-rainy', + 'l-botanical', + 'l-vivid', + 'l-cherry', + 'l-sushi', + 'l-u0', + 'd-dark', + 'd-persimmon', + 'd-astro', + 'd-future', + 'd-botanical', + 'd-green-lime', + 'd-green-orange', + 'd-cherry', + 'd-ice', + 'd-u0', +] + +Promise.all(keys.map((key) => readFile(resolve(__dirname, `../src/themes/${key}.json5`), 'utf8'))).then((sources) => { + writeFile( + resolve(__dirname, './themes.ts'), + `export default ${JSON.stringify( + Object.fromEntries(sources.map((source, i) => [keys[i], JSON5.parse(source)])), + undefined, + 2, + )} as const;`, + 'utf8' + ); +}); diff --git a/packages/frontend/.storybook/preview-head.html b/packages/frontend/.storybook/preview-head.html new file mode 100644 index 0000000000..64e537b931 --- /dev/null +++ b/packages/frontend/.storybook/preview-head.html @@ -0,0 +1,10 @@ +<link rel="stylesheet" href="https://unpkg.com/@tabler/icons-webfont@2.12.0/tabler-icons.min.css"> +<link rel="stylesheet" href="https://unpkg.com/@fontsource/m-plus-rounded-1c/index.css"> +<style> + html { + font-family: 'Hiragino Maru Gothic Pro', 'BIZ UDGothic', Roboto, HelveticaNeue, Arial, 'M PLUS Rounded 1c', sans-serif; + } +</style> +<script> + window.global = window; +</script> diff --git a/packages/frontend/.storybook/preview.ts b/packages/frontend/.storybook/preview.ts new file mode 100644 index 0000000000..b2974276ab --- /dev/null +++ b/packages/frontend/.storybook/preview.ts @@ -0,0 +1,113 @@ +import { addons } from '@storybook/addons'; +import { FORCE_REMOUNT } from '@storybook/core-events'; +import { type Preview, setup } from '@storybook/vue3'; +import isChromatic from 'chromatic/isChromatic'; +import { initialize, mswDecorator } from 'msw-storybook-addon'; +import locale from './locale'; +import { commonHandlers, onUnhandledRequest } from './mocks'; +import themes from './themes'; +import '../src/style.scss'; + +const appInitialized = Symbol(); + +let moduleInitialized = false; +let unobserve = () => {}; +let misskeyOS = null; + +function loadTheme(applyTheme: typeof import('../src/scripts/theme')['applyTheme']) { + unobserve(); + const theme = themes[document.documentElement.dataset.misskeyTheme]; + if (theme) { + applyTheme(themes[document.documentElement.dataset.misskeyTheme]); + } else if (isChromatic()) { + applyTheme(themes['l-light']); + } + const observer = new MutationObserver((entries) => { + for (const entry of entries) { + if (entry.attributeName === 'data-misskey-theme') { + const target = entry.target as HTMLElement; + const theme = themes[target.dataset.misskeyTheme]; + if (theme) { + applyTheme(themes[target.dataset.misskeyTheme]); + } else { + target.removeAttribute('style'); + } + } + } + }); + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ['data-misskey-theme'], + }); + unobserve = () => observer.disconnect(); +} + +initialize({ + onUnhandledRequest, +}); +localStorage.setItem("locale", JSON.stringify(locale)); +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]) => { + setup((app) => { + moduleInitialized = true; + if (app[appInitialized]) { + return; + } + app[appInitialized] = true; + loadTheme(applyTheme); + components(app); + directives(app); + widgets(app); + misskeyOS = os; + if (isChromatic()) { + defaultStore.set('animation', false); + } + }); + }); +}); + +const preview = { + decorators: [ + (Story, context) => { + const story = Story(); + if (!moduleInitialized) { + const channel = addons.getChannel(); + (globalThis.requestIdleCallback || setTimeout)(() => { + channel.emit(FORCE_REMOUNT, { storyId: context.id }); + }); + } + return story; + }, + mswDecorator, + (Story, context) => { + return { + setup() { + return { + context, + popups: misskeyOS.popups, + }; + }, + template: + '<component :is="popup.component" v-for="popup in popups" :key="popup.id" v-bind="popup.props" v-on="popup.events"/>' + + '<story />', + }; + }, + ], + parameters: { + controls: { + exclude: /^__/, + }, + msw: { + handlers: commonHandlers, + }, + }, +} satisfies Preview; + +export default preview; diff --git a/packages/frontend/.storybook/tsconfig.json b/packages/frontend/.storybook/tsconfig.json new file mode 100644 index 0000000000..2db2f1eabe --- /dev/null +++ b/packages/frontend/.storybook/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "strict": true, + "allowUnusedLabels": false, + "allowUnreachableCode": false, + "exactOptionalPropertyTypes": true, + "noFallthroughCasesInSwitch": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + "noPropertyAccessFromIndexSignature": true, + "noUncheckedIndexedAccess": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "checkJs": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "jsx": "react", + "jsxFactory": "h" + }, + "files": [ + "./changes.ts", + "./generate.tsx", + "./preload-locale.ts", + "./preload-theme.ts" + ] +} diff --git a/packages/frontend/@types/vue.d.ts b/packages/frontend/@types/vue.d.ts deleted file mode 100644 index 9c9c34ccc5..0000000000 --- a/packages/frontend/@types/vue.d.ts +++ /dev/null @@ -1,16 +0,0 @@ -/// <reference types="vue/macros-global" /> - -import type { $i } from '@/account'; -import type { defaultStore } from '@/store'; -import type { instance } from '@/instance'; -import type { i18n } from '@/i18n'; - -declare module 'vue' { - interface ComponentCustomProperties { - $i: typeof $i; - $store: typeof defaultStore; - $instance: typeof instance; - $t: typeof i18n['t']; - $ts: typeof i18n['ts']; - } -} diff --git a/packages/frontend/package.json b/packages/frontend/package.json index 54404c8c53..79fb626a9a 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -4,6 +4,9 @@ "scripts": { "watch": "vite", "build": "vite build", + "storybook-dev": "chokidar 'src/**/*.{mdx,ts,vue}' -d 1000 -t 1000 --initial -i '**/*.stories.ts' -c 'pkill -f node_modules/storybook/index.js; node_modules/.bin/tsc -p .storybook && node .storybook/generate.js && node .storybook/preload-locale.js && node .storybook/preload-theme.js && node_modules/.bin/storybook dev -p 6006 --ci'", + "build-storybook": "tsc -p .storybook && node .storybook/generate.js && node .storybook/preload-locale.js && node .storybook/preload-theme.js && storybook build", + "chromatic": "chromatic", "test": "vitest --run", "test-and-coverage": "vitest --run --coverage", "typecheck": "vue-tsc --noEmit", @@ -11,15 +14,14 @@ "lint": "pnpm typecheck && pnpm eslint" }, "dependencies": { - "@discordapp/twemoji": "14.0.2", + "@discordapp/twemoji": "14.1.2", "@rollup/plugin-alias": "4.0.3", "@rollup/plugin-json": "6.0.0", "@rollup/pluginutils": "5.0.2", "@syuilo/aiscript": "0.13.1", - "@tabler/icons-webfont": "2.10.0", - "@vitejs/plugin-vue": "4.0.0", + "@tabler/icons-webfont": "2.12.0", + "@vitejs/plugin-vue": "4.1.0", "@vue/compiler-sfc": "3.2.47", - "autobind-decorator": "2.4.0", "autosize": "5.0.2", "blurhash": "2.0.5", "broadcast-channel": "4.20.2", @@ -29,78 +31,112 @@ "chartjs-adapter-date-fns": "3.0.0", "chartjs-chart-matrix": "2.0.1", "chartjs-plugin-gradient": "0.6.1", - "chartjs-plugin-zoom": "2.0.0", + "chartjs-plugin-zoom": "2.0.1", "compare-versions": "5.0.1", "cropperjs": "2.0.0-beta.2", "date-fns": "2.29.3", "escape-regexp": "0.0.1", "eventemitter3": "5.0.0", - "gsap": "3.11.4", + "gsap": "3.11.5", "idb-keyval": "6.2.0", "insert-text-at-cursor": "0.3.0", "is-file-animated": "1.0.2", "json5": "2.2.3", "matter-js": "0.19.0", "mfm-js": "0.23.3", - "misskey-js": "0.0.15", - "photoswipe": "5.3.6", + "misskey-js": "workspace:*", + "photoswipe": "5.3.7", "prismjs": "1.29.0", "punycode": "2.3.0", "querystring": "0.2.1", "rndstr": "1.0.0", - "rollup": "3.19.0", + "rollup": "3.20.2", "s-age": "1.1.2", "sanitize-html": "2.10.0", - "sass": "1.58.3", + "sass": "1.60.0", "seedrandom": "3.0.5", "strict-event-emitter-types": "2.0.0", "syuilo-password-strength": "0.0.1", "textarea-caret": "3.1.0", - "three": "0.150.1", + "three": "0.151.3", "throttle-debounce": "5.0.0", "tinycolor2": "1.6.0", - "tsc-alias": "1.8.3", - "tsconfig-paths": "4.1.2", + "tsc-alias": "1.8.5", + "tsconfig-paths": "4.2.0", "twemoji-parser": "14.0.0", - "typescript": "4.9.5", + "typescript": "5.0.3", "uuid": "9.0.0", "vanilla-tilt": "1.8.0", - "vite": "4.1.4", + "vite": "4.2.1", "vue": "3.2.47", "vue-plyr": "7.0.0", "vue-prism-editor": "2.0.0-alpha.2", "vuedraggable": "next" }, "devDependencies": { - "@testing-library/vue": "^6.6.1", + "@storybook/addon-essentials": "7.0.2", + "@storybook/addon-interactions": "7.0.2", + "@storybook/addon-links": "7.0.2", + "@storybook/addon-storysource": "7.0.2", + "@storybook/addons": "7.0.2", + "@storybook/blocks": "7.0.2", + "@storybook/core-events": "7.0.2", + "@storybook/jest": "0.1.0", + "@storybook/manager-api": "7.0.2", + "@storybook/preview-api": "7.0.2", + "@storybook/react": "7.0.2", + "@storybook/react-vite": "7.0.2", + "@storybook/testing-library": "0.0.14-next.1", + "@storybook/theming": "7.0.2", + "@storybook/types": "7.0.2", + "@storybook/vue3": "7.0.2", + "@storybook/vue3-vite": "7.0.2", + "@testing-library/jest-dom": "5.16.5", + "@testing-library/vue": "7.0.0", "@types/escape-regexp": "0.0.1", + "@types/estree": "1.0.0", "@types/gulp": "4.0.10", "@types/gulp-rename": "2.0.1", "@types/matter-js": "0.18.2", - "@types/node": "18.15.0", + "@types/micromatch": "3.1.1", + "@types/node": "18.15.11", "@types/punycode": "2.1.0", - "@types/sanitize-html": "2.8.1", + "@types/sanitize-html": "2.9.0", "@types/seedrandom": "3.0.5", + "@types/testing-library__jest-dom": "^5.14.5", "@types/throttle-debounce": "5.0.0", "@types/tinycolor2": "1.4.3", "@types/uuid": "9.0.1", "@types/websocket": "1.0.5", "@types/ws": "8.5.4", - "@typescript-eslint/eslint-plugin": "5.54.1", - "@typescript-eslint/parser": "5.54.1", - "@vitest/coverage-c8": "^0.29.2", + "@typescript-eslint/eslint-plugin": "5.57.1", + "@typescript-eslint/parser": "5.57.1", + "@vitest/coverage-c8": "^0.29.8", "@vue/runtime-core": "3.2.47", + "astring": "1.8.4", + "chokidar-cli": "3.0.0", + "chromatic": "6.17.3", "cross-env": "7.0.3", - "cypress": "12.7.0", - "eslint": "8.35.0", + "cypress": "12.9.0", + "eslint": "8.37.0", "eslint-plugin-import": "2.27.5", - "eslint-plugin-vue": "9.9.0", + "eslint-plugin-vue": "9.10.0", + "fast-glob": "3.2.12", "happy-dom": "8.9.0", + "micromatch": "3.1.10", + "msw": "1.2.1", + "msw-storybook-addon": "1.8.0", + "prettier": "2.8.7", + "react": "18.2.0", + "react-dom": "18.2.0", "start-server-and-test": "2.0.0", + "storybook": "7.0.2", + "storybook-addon-misskey-theme": "github:misskey-dev/storybook-addon-misskey-theme", "summaly": "github:misskey-dev/summaly", - "vitest": "^0.29.2", - "vitest-fetch-mock": "^0.2.2", - "vue-eslint-parser": "9.1.0", + "vite-plugin-turbosnap": "^1.0.1", + "vitest": "0.29.8", + "vitest-fetch-mock": "0.2.2", + "vue-eslint-parser": "9.1.1", "vue-tsc": "1.2.0" } } diff --git a/packages/frontend/public/mockServiceWorker.js b/packages/frontend/public/mockServiceWorker.js new file mode 100644 index 0000000000..e915a1eb08 --- /dev/null +++ b/packages/frontend/public/mockServiceWorker.js @@ -0,0 +1,303 @@ +/* eslint-disable */ +/* tslint:disable */ + +/** + * Mock Service Worker (1.1.0). + * @see https://github.com/mswjs/msw + * - Please do NOT modify this file. + * - Please do NOT serve this file on production. + */ + +const INTEGRITY_CHECKSUM = '3d6b9f06410d179a7f7404d4bf4c3c70' +const activeClientIds = new Set() + +self.addEventListener('install', function () { + self.skipWaiting() +}) + +self.addEventListener('activate', function (event) { + event.waitUntil(self.clients.claim()) +}) + +self.addEventListener('message', async function (event) { + const clientId = event.source.id + + if (!clientId || !self.clients) { + return + } + + const client = await self.clients.get(clientId) + + if (!client) { + return + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }) + + switch (event.data) { + case 'KEEPALIVE_REQUEST': { + sendToClient(client, { + type: 'KEEPALIVE_RESPONSE', + }) + break + } + + case 'INTEGRITY_CHECK_REQUEST': { + sendToClient(client, { + type: 'INTEGRITY_CHECK_RESPONSE', + payload: INTEGRITY_CHECKSUM, + }) + break + } + + case 'MOCK_ACTIVATE': { + activeClientIds.add(clientId) + + sendToClient(client, { + type: 'MOCKING_ENABLED', + payload: true, + }) + break + } + + case 'MOCK_DEACTIVATE': { + activeClientIds.delete(clientId) + break + } + + case 'CLIENT_CLOSED': { + activeClientIds.delete(clientId) + + const remainingClients = allClients.filter((client) => { + return client.id !== clientId + }) + + // Unregister itself when there are no more clients + if (remainingClients.length === 0) { + self.registration.unregister() + } + + break + } + } +}) + +self.addEventListener('fetch', function (event) { + const { request } = event + const accept = request.headers.get('accept') || '' + + // Bypass server-sent events. + if (accept.includes('text/event-stream')) { + return + } + + // Bypass navigation requests. + if (request.mode === 'navigate') { + return + } + + // Opening the DevTools triggers the "only-if-cached" request + // that cannot be handled by the worker. Bypass such requests. + if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') { + return + } + + // Bypass all requests when there are no active clients. + // Prevents the self-unregistered worked from handling requests + // after it's been deleted (still remains active until the next reload). + if (activeClientIds.size === 0) { + return + } + + // Generate unique request ID. + const requestId = Math.random().toString(16).slice(2) + + event.respondWith( + handleRequest(event, requestId).catch((error) => { + if (error.name === 'NetworkError') { + console.warn( + '[MSW] Successfully emulated a network error for the "%s %s" request.', + request.method, + request.url, + ) + return + } + + // At this point, any exception indicates an issue with the original request/response. + console.error( + `\ +[MSW] Caught an exception from the "%s %s" request (%s). This is probably not a problem with Mock Service Worker. There is likely an additional logging output above.`, + request.method, + request.url, + `${error.name}: ${error.message}`, + ) + }), + ) +}) + +async function handleRequest(event, requestId) { + const client = await resolveMainClient(event) + const response = await getResponse(event, client, requestId) + + // Send back the response clone for the "response:*" life-cycle events. + // Ensure MSW is active and ready to handle the message, otherwise + // this message will pend indefinitely. + if (client && activeClientIds.has(client.id)) { + ;(async function () { + const clonedResponse = response.clone() + sendToClient(client, { + type: 'RESPONSE', + payload: { + requestId, + type: clonedResponse.type, + ok: clonedResponse.ok, + status: clonedResponse.status, + statusText: clonedResponse.statusText, + body: + clonedResponse.body === null ? null : await clonedResponse.text(), + headers: Object.fromEntries(clonedResponse.headers.entries()), + redirected: clonedResponse.redirected, + }, + }) + })() + } + + return response +} + +// Resolve the main client for the given event. +// Client that issues a request doesn't necessarily equal the client +// that registered the worker. It's with the latter the worker should +// communicate with during the response resolving phase. +async function resolveMainClient(event) { + const client = await self.clients.get(event.clientId) + + if (client?.frameType === 'top-level') { + return client + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }) + + return allClients + .filter((client) => { + // Get only those clients that are currently visible. + return client.visibilityState === 'visible' + }) + .find((client) => { + // Find the client ID that's recorded in the + // set of clients that have registered the worker. + return activeClientIds.has(client.id) + }) +} + +async function getResponse(event, client, requestId) { + const { request } = event + const clonedRequest = request.clone() + + function passthrough() { + // Clone the request because it might've been already used + // (i.e. its body has been read and sent to the client). + const headers = Object.fromEntries(clonedRequest.headers.entries()) + + // Remove MSW-specific request headers so the bypassed requests + // comply with the server's CORS preflight check. + // Operate with the headers as an object because request "Headers" + // are immutable. + delete headers['x-msw-bypass'] + + return fetch(clonedRequest, { headers }) + } + + // Bypass mocking when the client is not active. + if (!client) { + return passthrough() + } + + // Bypass initial page load requests (i.e. static assets). + // The absence of the immediate/parent client in the map of the active clients + // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet + // and is not ready to handle requests. + if (!activeClientIds.has(client.id)) { + return passthrough() + } + + // Bypass requests with the explicit bypass header. + // Such requests can be issued by "ctx.fetch()". + if (request.headers.get('x-msw-bypass') === 'true') { + return passthrough() + } + + // Notify the client that a request has been intercepted. + const clientMessage = await sendToClient(client, { + type: 'REQUEST', + payload: { + id: requestId, + url: request.url, + method: request.method, + headers: Object.fromEntries(request.headers.entries()), + cache: request.cache, + mode: request.mode, + credentials: request.credentials, + destination: request.destination, + integrity: request.integrity, + redirect: request.redirect, + referrer: request.referrer, + referrerPolicy: request.referrerPolicy, + body: await request.text(), + bodyUsed: request.bodyUsed, + keepalive: request.keepalive, + }, + }) + + switch (clientMessage.type) { + case 'MOCK_RESPONSE': { + return respondWithMock(clientMessage.data) + } + + case 'MOCK_NOT_FOUND': { + return passthrough() + } + + case 'NETWORK_ERROR': { + const { name, message } = clientMessage.data + const networkError = new Error(message) + networkError.name = name + + // Rejecting a "respondWith" promise emulates a network error. + throw networkError + } + } + + return passthrough() +} + +function sendToClient(client, message) { + return new Promise((resolve, reject) => { + const channel = new MessageChannel() + + channel.port1.onmessage = (event) => { + if (event.data && event.data.error) { + return reject(event.data.error) + } + + resolve(event.data) + } + + client.postMessage(message, [channel.port2]) + }) +} + +function sleep(timeMs) { + return new Promise((resolve) => { + setTimeout(resolve, timeMs) + }) +} + +async function respondWithMock(response) { + await sleep(response.delay) + return new Response(response.body, response) +} diff --git a/packages/frontend/src/components/MkAccountMoved.vue b/packages/frontend/src/components/MkAccountMoved.vue new file mode 100644 index 0000000000..fd472de6c1 --- /dev/null +++ b/packages/frontend/src/components/MkAccountMoved.vue @@ -0,0 +1,32 @@ +<template> +<div :class="$style.root"> + <i class="ti ti-plane-departure" style="margin-right: 8px;"></i> + {{ i18n.ts.accountMoved }} + <MkMention :class="$style.link" :username="acct" :host="host ?? localHost"/> +</div> +</template> + +<script lang="ts" setup> +import MkMention from './MkMention.vue'; +import { i18n } from '@/i18n'; +import { host as localHost } from '@/config'; + +defineProps<{ + acct: string; + host: string; +}>(); +</script> + +<style lang="scss" module> +.root { + padding: 16px; + font-size: 90%; + background: var(--infoWarnBg); + color: var(--error); + border-radius: var(--radius); +} + +.link { + margin-left: 4px; +} +</style> diff --git a/packages/frontend/src/components/MkAnalogClock.stories.impl.ts b/packages/frontend/src/components/MkAnalogClock.stories.impl.ts new file mode 100644 index 0000000000..05190aa268 --- /dev/null +++ b/packages/frontend/src/components/MkAnalogClock.stories.impl.ts @@ -0,0 +1,28 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import { StoryObj } from '@storybook/vue3'; +import MkAnalogClock from './MkAnalogClock.vue'; +export const Default = { + render(args) { + return { + components: { + MkAnalogClock, + }, + setup() { + return { + args, + }; + }, + computed: { + props() { + return { + ...this.args, + }; + }, + }, + template: '<MkAnalogClock v-bind="props" />', + }; + }, + parameters: { + layout: 'fullscreen', + }, +} satisfies StoryObj<typeof MkAnalogClock>; diff --git a/packages/frontend/src/components/MkButton.stories.impl.ts b/packages/frontend/src/components/MkButton.stories.impl.ts new file mode 100644 index 0000000000..e1c1c54d10 --- /dev/null +++ b/packages/frontend/src/components/MkButton.stories.impl.ts @@ -0,0 +1,30 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +/* eslint-disable import/no-default-export */ +/* eslint-disable import/no-duplicates */ +import { StoryObj } from '@storybook/vue3'; +import MkButton from './MkButton.vue'; +export const Default = { + render(args) { + return { + components: { + MkButton, + }, + setup() { + return { + args, + }; + }, + computed: { + props() { + return { + ...this.args, + }; + }, + }, + template: '<MkButton v-bind="props">Text</MkButton>', + }; + }, + parameters: { + layout: 'centered', + }, +} satisfies StoryObj<typeof MkButton>; diff --git a/packages/frontend/src/components/MkCaptcha.stories.impl.ts b/packages/frontend/src/components/MkCaptcha.stories.impl.ts new file mode 100644 index 0000000000..6ac437a277 --- /dev/null +++ b/packages/frontend/src/components/MkCaptcha.stories.impl.ts @@ -0,0 +1,2 @@ +import MkCaptcha from './MkCaptcha.vue'; +void MkCaptcha; diff --git a/packages/frontend/src/components/MkContainer.vue b/packages/frontend/src/components/MkContainer.vue index 833fa9d382..1834224b8d 100644 --- a/packages/frontend/src/components/MkContainer.vue +++ b/packages/frontend/src/components/MkContainer.vue @@ -14,10 +14,10 @@ </div> </header> <Transition - :enter-active-class="$store.state.animation ? $style.transition_toggle_enterActive : ''" - :leave-active-class="$store.state.animation ? $style.transition_toggle_leaveActive : ''" - :enter-from-class="$store.state.animation ? $style.transition_toggle_enterFrom : ''" - :leave-to-class="$store.state.animation ? $style.transition_toggle_leaveTo : ''" + :enter-active-class="defaultStore.state.animation ? $style.transition_toggle_enterActive : ''" + :leave-active-class="defaultStore.state.animation ? $style.transition_toggle_leaveActive : ''" + :enter-from-class="defaultStore.state.animation ? $style.transition_toggle_enterFrom : ''" + :leave-to-class="defaultStore.state.animation ? $style.transition_toggle_leaveTo : ''" @enter="enter" @after-enter="afterEnter" @leave="leave" @@ -26,7 +26,7 @@ <div v-show="showBody" ref="content" :class="[$style.content, { [$style.omitted]: omitted }]"> <slot></slot> <button v-if="omitted" :class="$style.fade" class="_button" @click="() => { ignoreOmit = true; omitted = false; }"> - <span :class="$style.fadeLabel">{{ $ts.showMore }}</span> + <span :class="$style.fadeLabel">{{ i18n.ts.showMore }}</span> </button> </div> </Transition> @@ -35,6 +35,8 @@ <script lang="ts"> import { defineComponent } from 'vue'; +import { defaultStore } from '@/store'; +import { i18n } from '@/i18n'; export default defineComponent({ props: { @@ -79,6 +81,7 @@ export default defineComponent({ showBody: this.expanded, omitted: null, ignoreOmit: false, + defaultStore, }; }, mounted() { diff --git a/packages/frontend/src/components/MkContextMenu.vue b/packages/frontend/src/components/MkContextMenu.vue index 21cccaabde..b81c806b0c 100644 --- a/packages/frontend/src/components/MkContextMenu.vue +++ b/packages/frontend/src/components/MkContextMenu.vue @@ -1,10 +1,10 @@ <template> <Transition appear - :enter-active-class="$store.state.animation ? $style.transition_fade_enterActive : ''" - :leave-active-class="$store.state.animation ? $style.transition_fade_leaveActive : ''" - :enter-from-class="$store.state.animation ? $style.transition_fade_enterFrom : ''" - :leave-to-class="$store.state.animation ? $style.transition_fade_leaveTo : ''" + :enter-active-class="defaultStore.state.animation ? $style.transition_fade_enterActive : ''" + :leave-active-class="defaultStore.state.animation ? $style.transition_fade_leaveActive : ''" + :enter-from-class="defaultStore.state.animation ? $style.transition_fade_enterFrom : ''" + :leave-to-class="defaultStore.state.animation ? $style.transition_fade_leaveTo : ''" > <div ref="rootEl" :class="$style.root" :style="{ zIndex }" @contextmenu.prevent.stop="() => {}"> <MkMenu :items="items" :align="'left'" @close="$emit('closed')"/> @@ -17,6 +17,7 @@ import { onMounted, onBeforeUnmount } from 'vue'; import MkMenu from './MkMenu.vue'; import { MenuItem } from './types/menu.vue'; import contains from '@/scripts/contains'; +import { defaultStore } from '@/store'; import * as os from '@/os'; const props = defineProps<{ diff --git a/packages/frontend/src/components/MkDialog.vue b/packages/frontend/src/components/MkDialog.vue index 863ea702cd..93c1f89199 100644 --- a/packages/frontend/src/components/MkDialog.vue +++ b/packages/frontend/src/components/MkDialog.vue @@ -17,8 +17,8 @@ <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> - <span v-if="okButtonDisabled && disabledReason === 'charactersExceeded'" v-text="i18n.t('_dialog.charactersExceeded', { current: (inputValue as string).length, max: input.maxLength ?? 'NaN' })" /> - <span v-else-if="okButtonDisabled && disabledReason === 'charactersBelow'" v-text="i18n.t('_dialog.charactersBelow', { current: (inputValue as string).length, min: input.minLength ?? 'NaN' })" /> + <span v-if="okButtonDisabled && disabledReason === 'charactersExceeded'" v-text="i18n.t('_dialog.charactersExceeded', { current: (inputValue as string).length, max: input.maxLength ?? 'NaN' })"/> + <span v-else-if="okButtonDisabled && disabledReason === 'charactersBelow'" v-text="i18n.t('_dialog.charactersBelow', { current: (inputValue as string).length, min: input.minLength ?? 'NaN' })"/> </template> </MkInput> <MkSelect v-if="select" v-model="selectedValue" autofocus> @@ -32,11 +32,11 @@ </template> </MkSelect> <div v-if="(showOkButton || showCancelButton) && !actions" :class="$style.buttons"> - <MkButton v-if="showOkButton" inline primary :autofocus="!input && !select" :disabled="okButtonDisabled" @click="ok">{{ okText ?? ((showCancelButton || input || select) ? i18n.ts.ok : i18n.ts.gotIt) }}</MkButton> - <MkButton v-if="showCancelButton || input || select" inline @click="cancel">{{ cancelText ?? i18n.ts.cancel }}</MkButton> + <MkButton v-if="showOkButton" inline primary rounded :autofocus="!input && !select" :disabled="okButtonDisabled" @click="ok">{{ okText ?? ((showCancelButton || input || select) ? i18n.ts.ok : i18n.ts.gotIt) }}</MkButton> + <MkButton v-if="showCancelButton || input || select" inline rounded @click="cancel">{{ cancelText ?? i18n.ts.cancel }}</MkButton> </div> <div v-if="actions" :class="$style.buttons"> - <MkButton v-for="action in actions" :key="action.text" inline :primary="action.primary" @click="() => { action.callback(); modal?.close(); }">{{ action.text }}</MkButton> + <MkButton v-for="action in actions" :key="action.text" inline rounded :primary="action.primary" :danger="action.danger" @click="() => { action.callback(); modal?.close(); }">{{ action.text }}</MkButton> </div> </div> </MkModal> @@ -84,6 +84,7 @@ const props = withDefaults(defineProps<{ actions?: { text: string; primary?: boolean, + danger?: boolean, callback: (...args: any[]) => void; }[]; showOkButton?: boolean; diff --git a/packages/frontend/src/components/MkDonation.vue b/packages/frontend/src/components/MkDonation.vue index 9baa90ebfe..b5ae4c6c48 100644 --- a/packages/frontend/src/components/MkDonation.vue +++ b/packages/frontend/src/components/MkDonation.vue @@ -14,7 +14,7 @@ <div :class="$style.text"> <I18n :src="i18n.ts.pleaseDonate" tag="span"> <template #host> - {{ $instance.name ?? host }} + {{ instance.name ?? host }} </template> </I18n> <div style="margin-top: 0.2em;"> @@ -37,6 +37,7 @@ import { host } from '@/config'; import { i18n } from '@/i18n'; import * as os from '@/os'; import { miLocalStorage } from '@/local-storage'; +import { instance } from '@/instance'; const emit = defineEmits<{ (ev: 'closed'): void; diff --git a/packages/frontend/src/components/MkFoldableSection.vue b/packages/frontend/src/components/MkFoldableSection.vue index d4b1bee9e4..475e01c8d4 100644 --- a/packages/frontend/src/components/MkFoldableSection.vue +++ b/packages/frontend/src/components/MkFoldableSection.vue @@ -9,7 +9,7 @@ </button> </header> <Transition - :name="$store.state.animation ? 'folder-toggle' : ''" + :name="defaultStore.state.animation ? 'folder-toggle' : ''" @enter="enter" @after-enter="afterEnter" @leave="leave" @@ -26,6 +26,7 @@ import { defineComponent } from 'vue'; import tinycolor from 'tinycolor2'; import { miLocalStorage } from '@/local-storage'; +import { defaultStore } from '@/store'; const miLocalStoragePrefix = 'ui:folder:' as const; @@ -44,6 +45,7 @@ export default defineComponent({ }, data() { return { + defaultStore, bg: null, showBody: (this.persistKey && miLocalStorage.getItem(`${miLocalStoragePrefix}${this.persistKey}`)) ? (miLocalStorage.getItem(`${miLocalStoragePrefix}${this.persistKey}`) === 't') : this.expanded, }; diff --git a/packages/frontend/src/components/MkFolder.vue b/packages/frontend/src/components/MkFolder.vue index 2748a9e491..58cc0de5c8 100644 --- a/packages/frontend/src/components/MkFolder.vue +++ b/packages/frontend/src/components/MkFolder.vue @@ -22,10 +22,10 @@ <div v-if="openedAtLeastOnce" :class="[$style.body, { [$style.bgSame]: bgSame }]" :style="{ maxHeight: maxHeight ? `${maxHeight}px` : null, overflow: maxHeight ? `auto` : null }"> <Transition - :enter-active-class="$store.state.animation ? $style.transition_toggle_enterActive : ''" - :leave-active-class="$store.state.animation ? $style.transition_toggle_leaveActive : ''" - :enter-from-class="$store.state.animation ? $style.transition_toggle_enterFrom : ''" - :leave-to-class="$store.state.animation ? $style.transition_toggle_leaveTo : ''" + :enter-active-class="defaultStore.state.animation ? $style.transition_toggle_enterActive : ''" + :leave-active-class="defaultStore.state.animation ? $style.transition_toggle_leaveActive : ''" + :enter-from-class="defaultStore.state.animation ? $style.transition_toggle_enterFrom : ''" + :leave-to-class="defaultStore.state.animation ? $style.transition_toggle_leaveTo : ''" @enter="enter" @after-enter="afterEnter" @leave="leave" @@ -46,6 +46,7 @@ <script lang="ts" setup> import { nextTick, onMounted } from 'vue'; +import { defaultStore } from '@/store'; const props = withDefaults(defineProps<{ defaultOpen?: boolean; diff --git a/packages/frontend/src/components/MkFormDialog.vue b/packages/frontend/src/components/MkFormDialog.vue index 971bb806af..979df2e7c1 100644 --- a/packages/frontend/src/components/MkFormDialog.vue +++ b/packages/frontend/src/components/MkFormDialog.vue @@ -18,15 +18,15 @@ <div class="_gaps_m"> <template v-for="item in Object.keys(form).filter(item => !form[item].hidden)"> <MkInput v-if="form[item].type === 'number'" v-model="values[item]" type="number" :step="form[item].step || 1"> - <template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ $ts.optional }})</span></template> + <template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ i18n.ts.optional }})</span></template> <template v-if="form[item].description" #caption>{{ form[item].description }}</template> </MkInput> <MkInput v-else-if="form[item].type === 'string' && !form[item].multiline" v-model="values[item]" type="text"> - <template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ $ts.optional }})</span></template> + <template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ i18n.ts.optional }})</span></template> <template v-if="form[item].description" #caption>{{ form[item].description }}</template> </MkInput> <MkTextarea v-else-if="form[item].type === 'string' && form[item].multiline" v-model="values[item]"> - <template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ $ts.optional }})</span></template> + <template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ i18n.ts.optional }})</span></template> <template v-if="form[item].description" #caption>{{ form[item].description }}</template> </MkTextarea> <MkSwitch v-else-if="form[item].type === 'boolean'" v-model="values[item]"> @@ -34,15 +34,15 @@ <template v-if="form[item].description" #caption>{{ form[item].description }}</template> </MkSwitch> <MkSelect v-else-if="form[item].type === 'enum'" v-model="values[item]"> - <template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ $ts.optional }})</span></template> + <template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ i18n.ts.optional }})</span></template> <option v-for="item in form[item].enum" :key="item.value" :value="item.value">{{ item.label }}</option> </MkSelect> <MkRadios v-else-if="form[item].type === 'radio'" v-model="values[item]"> - <template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ $ts.optional }})</span></template> + <template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ i18n.ts.optional }})</span></template> <option v-for="item in form[item].options" :key="item.value" :value="item.value">{{ item.label }}</option> </MkRadios> <MkRange v-else-if="form[item].type === 'range'" v-model="values[item]" :min="form[item].min" :max="form[item].max" :step="form[item].step" :text-converter="form[item].textConverter"> - <template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ $ts.optional }})</span></template> + <template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ i18n.ts.optional }})</span></template> <template v-if="form[item].description" #caption>{{ form[item].description }}</template> </MkRange> <MkButton v-else-if="form[item].type === 'button'" @click="form[item].action($event, values)"> @@ -64,6 +64,7 @@ import MkRange from './MkRange.vue'; import MkButton from './MkButton.vue'; import MkRadios from './MkRadios.vue'; import MkModalWindow from '@/components/MkModalWindow.vue'; +import { i18n } from '@/i18n'; export default defineComponent({ components: { @@ -93,6 +94,7 @@ export default defineComponent({ data() { return { values: {}, + i18n, }; }, diff --git a/packages/frontend/src/components/MkGalleryPostPreview.stories.impl.ts b/packages/frontend/src/components/MkGalleryPostPreview.stories.impl.ts new file mode 100644 index 0000000000..e46a708192 --- /dev/null +++ b/packages/frontend/src/components/MkGalleryPostPreview.stories.impl.ts @@ -0,0 +1,85 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import { expect } from '@storybook/jest'; +import { userEvent, waitFor, within } from '@storybook/testing-library'; +import { StoryObj } from '@storybook/vue3'; +import { galleryPost } from '../../.storybook/fakes'; +import MkGalleryPostPreview from './MkGalleryPostPreview.vue'; +export const Default = { + render(args) { + return { + components: { + MkGalleryPostPreview, + }, + setup() { + return { + args, + }; + }, + computed: { + props() { + return { + ...this.args, + }; + }, + }, + template: '<MkGalleryPostPreview v-bind="props" />', + }; + }, + async play({ canvasElement }) { + const canvas = within(canvasElement); + const links = canvas.getAllByRole('link'); + await expect(links).toHaveLength(2); + await expect(links[0]).toHaveAttribute('href', `/gallery/${galleryPost().id}`); + await expect(links[1]).toHaveAttribute('href', `/@${galleryPost().user.username}@${galleryPost().user.host}`); + }, + args: { + post: galleryPost(), + }, + decorators: [ + () => ({ + template: '<div style="width:260px"><story /></div>', + }), + ], + parameters: { + layout: 'centered', + }, +} satisfies StoryObj<typeof MkGalleryPostPreview>; +export const Hover = { + ...Default, + async play(context) { + await Default.play(context); + const canvas = within(context.canvasElement); + const links = canvas.getAllByRole('link'); + await waitFor(() => userEvent.hover(links[0])); + }, +} satisfies StoryObj<typeof MkGalleryPostPreview>; +export const HoverThenUnhover = { + ...Default, + async play(context) { + await Hover.play(context); + const canvas = within(context.canvasElement); + const links = canvas.getAllByRole('link'); + await waitFor(() => userEvent.unhover(links[0])); + }, +} satisfies StoryObj<typeof MkGalleryPostPreview>; +export const Sensitive = { + ...Default, + args: { + ...Default.args, + post: galleryPost(true), + }, +} satisfies StoryObj<typeof MkGalleryPostPreview>; +export const SensitiveHover = { + ...Hover, + args: { + ...Hover.args, + post: galleryPost(true), + }, +} satisfies StoryObj<typeof MkGalleryPostPreview>; +export const SensitiveHoverThenUnhover = { + ...HoverThenUnhover, + args: { + ...HoverThenUnhover.args, + post: galleryPost(true), + }, +} satisfies StoryObj<typeof MkGalleryPostPreview>; diff --git a/packages/frontend/src/components/MkGalleryPostPreview.vue b/packages/frontend/src/components/MkGalleryPostPreview.vue index 2c5032119f..944f5ad97b 100644 --- a/packages/frontend/src/components/MkGalleryPostPreview.vue +++ b/packages/frontend/src/components/MkGalleryPostPreview.vue @@ -1,7 +1,10 @@ <template> -<MkA :to="`/gallery/${post.id}`" class="ttasepnz _panel" tabindex="-1"> +<MkA :to="`/gallery/${post.id}`" class="ttasepnz _panel" tabindex="-1" @pointerenter="enterHover" @pointerleave="leaveHover"> <div class="thumbnail"> - <ImgWithBlurhash class="img" :src="post.files[0].thumbnailUrl" :hash="post.files[0].blurhash"/> + <ImgWithBlurhash class="img" :hash="post.files[0].blurhash"/> + <Transition> + <ImgWithBlurhash v-if="show" class="img layered" :src="post.files[0].thumbnailUrl" :hash="post.files[0].blurhash"/> + </Transition> </div> <article> <header> @@ -15,12 +18,25 @@ </template> <script lang="ts" setup> -import { } from 'vue'; +import * as misskey from 'misskey-js'; +import { computed, ref } from 'vue'; import ImgWithBlurhash from '@/components/MkImgWithBlurhash.vue'; +import { defaultStore } from '@/store'; const props = defineProps<{ - post: any; + post: misskey.entities.GalleryPost; }>(); + +const hover = ref(false); +const show = computed(() => defaultStore.state.nsfw === 'ignore' || defaultStore.state.nsfw === 'respect' && !props.post.isSensitive || hover.value); + +function enterHover(): void { + hover.value = true; +} + +function leaveHover(): void { + hover.value = false; +} </script> <style lang="scss" scoped> @@ -56,6 +72,21 @@ const props = defineProps<{ width: 100%; height: 100%; object-fit: cover; + + &.layered { + position: absolute; + top: 0; + + &.v-enter-active, + &.v-leave-active { + transition: opacity 0.5s ease; + } + + &.v-enter-from, + &.v-leave-to { + opacity: 0; + } + } } } diff --git a/packages/frontend/src/components/MkGoogle.vue b/packages/frontend/src/components/MkGoogle.vue index 007728176e..227054d963 100644 --- a/packages/frontend/src/components/MkGoogle.vue +++ b/packages/frontend/src/components/MkGoogle.vue @@ -1,12 +1,13 @@ <template> <div :class="$style.root"> <input v-model="query" :class="$style.input" type="search" :placeholder="q"> - <button :class="$style.button" @click="search"><i class="ti ti-search"></i> {{ $ts.searchByGoogle }}</button> + <button :class="$style.button" @click="search"><i class="ti ti-search"></i> {{ i18n.ts.searchByGoogle }}</button> </div> </template> <script lang="ts" setup> import { ref } from 'vue'; +import { i18n } from '@/i18n'; const props = defineProps<{ q: string; diff --git a/packages/frontend/src/components/MkMediaBanner.vue b/packages/frontend/src/components/MkMediaBanner.vue index c0401a6455..1576144f6b 100644 --- a/packages/frontend/src/components/MkMediaBanner.vue +++ b/packages/frontend/src/components/MkMediaBanner.vue @@ -2,8 +2,8 @@ <div class="mk-media-banner"> <div v-if="media.isSensitive && hide" class="sensitive" @click="hide = false"> <span class="icon"><i class="ti ti-alert-triangle"></i></span> - <b>{{ $ts.sensitive }}</b> - <span>{{ $ts.clickToShow }}</span> + <b>{{ i18n.ts.sensitive }}</b> + <span>{{ i18n.ts.clickToShow }}</span> </div> <div v-else-if="media.type.startsWith('audio') && media.type !== 'audio/midi'" class="audio"> <VuePlyr :options="{ volume: 0.5 }"> @@ -33,6 +33,7 @@ import * as misskey from 'misskey-js'; import VuePlyr from 'vue-plyr'; import { ColdDeviceStorage } from '@/store'; import 'vue-plyr/dist/vue-plyr.css'; +import { i18n } from '@/i18n'; const props = withDefaults(defineProps<{ media: misskey.entities.DriveFile; diff --git a/packages/frontend/src/components/MkMediaVideo.vue b/packages/frontend/src/components/MkMediaVideo.vue index 979c3eed28..e02a7af09e 100644 --- a/packages/frontend/src/components/MkMediaVideo.vue +++ b/packages/frontend/src/components/MkMediaVideo.vue @@ -1,8 +1,8 @@ <template> <div v-if="hide" class="icozogqfvdetwohsdglrbswgrejoxbdj" @click="hide = false"> <div> - <b><i class="ti ti-alert-triangle"></i> {{ $ts.sensitive }}</b> - <span>{{ $ts.clickToShow }}</span> + <b><i class="ti ti-alert-triangle"></i> {{ i18n.ts.sensitive }}</b> + <span>{{ i18n.ts.clickToShow }}</span> </div> </div> <div v-else class="kkjnbbplepmiyuadieoenjgutgcmtsvu"> @@ -28,6 +28,7 @@ import * as misskey from 'misskey-js'; import VuePlyr from 'vue-plyr'; import { defaultStore } from '@/store'; import 'vue-plyr/dist/vue-plyr.css'; +import { i18n } from '@/i18n'; const props = defineProps<{ video: misskey.entities.DriveFile; diff --git a/packages/frontend/src/components/MkMention.vue b/packages/frontend/src/components/MkMention.vue index f586eeff4d..481c3710ca 100644 --- a/packages/frontend/src/components/MkMention.vue +++ b/packages/frontend/src/components/MkMention.vue @@ -3,7 +3,7 @@ <img :class="$style.icon" :src="`/avatar/@${username}@${host}`" alt=""> <span> <span :class="$style.username">@{{ username }}</span> - <span v-if="(host != localHost) || $store.state.showFullAcct" :class="$style.host">@{{ toUnicode(host) }}</span> + <span v-if="(host != localHost) || defaultStore.state.showFullAcct" :class="$style.host">@{{ toUnicode(host) }}</span> </span> </MkA> </template> @@ -14,6 +14,7 @@ import { } from 'vue'; import tinycolor from 'tinycolor2'; import { host as localHost } from '@/config'; import { $i } from '@/account'; +import { defaultStore } from '@/store'; const props = defineProps<{ username: string; diff --git a/packages/frontend/src/components/MkMenu.vue b/packages/frontend/src/components/MkMenu.vue index 9e3022896c..e513a65a32 100644 --- a/packages/frontend/src/components/MkMenu.vue +++ b/packages/frontend/src/components/MkMenu.vue @@ -1,5 +1,5 @@ <template> -<div> +<div role="menu"> <div ref="itemsEl" v-hotkey="keymap" class="_popup _shadow" @@ -8,37 +8,37 @@ @contextmenu.self="e => e.preventDefault()" > <template v-for="(item, i) in items2"> - <div v-if="item === null" :class="$style.divider"></div> - <span v-else-if="item.type === 'label'" :class="[$style.label, $style.item]"> + <div v-if="item === null" role="separator" :class="$style.divider"></div> + <span v-else-if="item.type === 'label'" role="menuitem" :class="[$style.label, $style.item]"> <span>{{ item.text }}</span> </span> - <span v-else-if="item.type === 'pending'" :tabindex="i" :class="[$style.pending, $style.item]"> + <span v-else-if="item.type === 'pending'" role="menuitem" :tabindex="i" :class="[$style.pending, $style.item]"> <span><MkEllipsis/></span> </span> - <MkA v-else-if="item.type === 'link'" :to="item.to" :tabindex="i" class="_button" :class="$style.item" @click.passive="close(true)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)"> + <MkA v-else-if="item.type === 'link'" role="menuitem" :to="item.to" :tabindex="i" class="_button" :class="$style.item" @click.passive="close(true)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)"> <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"/> <span>{{ item.text }}</span> <span v-if="item.indicate" :class="$style.indicator"><i class="_indicatorCircle"></i></span> </MkA> - <a v-else-if="item.type === 'a'" :href="item.href" :target="item.target" :download="item.download" :tabindex="i" class="_button" :class="$style.item" @click="close(true)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)"> + <a v-else-if="item.type === 'a'" role="menuitem" :href="item.href" :target="item.target" :download="item.download" :tabindex="i" class="_button" :class="$style.item" @click="close(true)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)"> <i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]"></i> <span>{{ item.text }}</span> <span v-if="item.indicate" :class="$style.indicator"><i class="_indicatorCircle"></i></span> </a> - <button v-else-if="item.type === 'user'" :tabindex="i" class="_button" :class="[$style.item, { [$style.active]: item.active }]" :disabled="item.active" @click="clicked(item.action, $event)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)"> + <button v-else-if="item.type === 'user'" role="menuitem" :tabindex="i" class="_button" :class="[$style.item, { [$style.active]: item.active }]" :disabled="item.active" @click="clicked(item.action, $event)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)"> <MkAvatar :user="item.user" :class="$style.avatar"/><MkUserName :user="item.user"/> <span v-if="item.indicate" :class="$style.indicator"><i class="_indicatorCircle"></i></span> </button> - <span v-else-if="item.type === 'switch'" :tabindex="i" :class="$style.item" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)"> + <span v-else-if="item.type === 'switch'" role="menuitemcheckbox" :tabindex="i" :class="$style.item" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)"> <MkSwitch v-model="item.ref" :disabled="item.disabled" class="form-switch">{{ item.text }}</MkSwitch> </span> - <button v-else-if="item.type === 'parent'" :tabindex="i" class="_button" :class="[$style.item, $style.parent, { [$style.childShowing]: childShowingItem === item }]" @mouseenter="showChildren(item, $event)"> + <button v-else-if="item.type === 'parent'" role="menuitem" :tabindex="i" class="_button" :class="[$style.item, $style.parent, { [$style.childShowing]: childShowingItem === item }]" @mouseenter="showChildren(item, $event)"> <i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]"></i> <span>{{ item.text }}</span> <span :class="$style.caret"><i class="ti ti-chevron-right ti-fw"></i></span> </button> - <button v-else :tabindex="i" class="_button" :class="[$style.item, { [$style.danger]: item.danger, [$style.active]: item.active }]" :disabled="item.active" @click="clicked(item.action, $event)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)"> + <button v-else :tabindex="i" class="_button" role="menuitem" :class="[$style.item, { [$style.danger]: item.danger, [$style.active]: item.active }]" :disabled="item.active" @click="clicked(item.action, $event)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)"> <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"/> <span>{{ item.text }}</span> diff --git a/packages/frontend/src/components/MkModalPageWindow.vue b/packages/frontend/src/components/MkModalPageWindow.vue index 68a3eda3d8..b38865f525 100644 --- a/packages/frontend/src/components/MkModalPageWindow.vue +++ b/packages/frontend/src/components/MkModalPageWindow.vue @@ -2,7 +2,7 @@ <MkModal ref="modal" @click="$emit('click')" @closed="$emit('closed')"> <div ref="rootEl" class="hrmcaedk" :style="{ width: `${width}px`, height: (height ? `min(${height}px, 100%)` : '100%') }"> <div class="header" @contextmenu="onContextmenu"> - <button v-if="history.length > 0" v-tooltip="$ts.goBack" class="_button" @click="back()"><i class="ti ti-arrow-left"></i></button> + <button v-if="history.length > 0" v-tooltip="i18n.ts.goBack" class="_button" @click="back()"><i class="ti ti-arrow-left"></i></button> <span v-else style="display: inline-block; width: 20px"></span> <span v-if="pageMetadata?.value" class="title"> <i v-if="pageMetadata?.value.icon" class="icon" :class="pageMetadata?.value.icon"></i> diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue index 72c6e55df1..36ec778a14 100644 --- a/packages/frontend/src/components/MkNote.vue +++ b/packages/frontend/src/components/MkNote.vue @@ -31,7 +31,7 @@ <i v-else-if="note.visibility === 'followers'" class="ti ti-lock"></i> <i v-else-if="note.visibility === 'specified'" ref="specified" class="ti ti-mail"></i> </span> - <span v-if="note.localOnly" style="margin-left: 0.5em;" :title="i18n.ts._visibility['disableFederation']"><i class="ti ti-world-off"></i></span> + <span v-if="note.localOnly" style="margin-left: 0.5em;" :title="i18n.ts._visibility['disableFederation']"><i class="ti ti-rocket-off"></i></span> <span v-if="note.channel" style="margin-left: 0.5em;" :title="note.channel.name"><i class="ti ti-device-tv"></i></span> </div> </div> @@ -57,7 +57,7 @@ <div v-if="translating || translation" :class="$style.translation"> <MkLoading v-if="translating" mini/> <div v-else :class="$style.translated"> - <b>{{ $t('translatedFrom', { x: translation.sourceLang }) }}: </b> + <b>{{ i18n.t('translatedFrom', { x: translation.sourceLang }) }}: </b> <Mfm :text="translation.text" :author="appearNote.user" :i="$i" :emoji-urls="appearNote.emojis"/> </div> </div> @@ -169,6 +169,7 @@ const props = defineProps<{ }>(); const inChannel = inject('inChannel', null); +const currentClip = inject<Ref<misskey.entities.Clip> | null>('currentClip', null); let note = $ref(deepClone(props.note)); @@ -370,8 +371,6 @@ function undoReact(note): void { }); } -const currentClipPage = inject<Ref<misskey.entities.Clip> | null>('currentClipPage', null); - function onContextmenu(ev: MouseEvent): void { const isLink = (el: HTMLElement) => { if (el.tagName === 'A') return true; @@ -386,18 +385,18 @@ function onContextmenu(ev: MouseEvent): void { ev.preventDefault(); react(); } else { - os.contextMenu(getNoteMenu({ note: note, translating, translation, menuButton, isDeleted, currentClipPage }), ev).then(focus); + os.contextMenu(getNoteMenu({ note: note, translating, translation, menuButton, isDeleted, currentClip: currentClip?.value }), ev).then(focus); } } function menu(viaKeyboard = false): void { - os.popupMenu(getNoteMenu({ note: note, translating, translation, menuButton, isDeleted, currentClipPage }), menuButton.value, { + os.popupMenu(getNoteMenu({ note: note, translating, translation, menuButton, isDeleted, currentClip: currentClip?.value }), menuButton.value, { viaKeyboard, }).then(focus); } async function clip() { - os.popupMenu(await getNoteClipMenu({ note: note, isDeleted, currentClipPage }), clipButton.value).then(focus); + os.popupMenu(await getNoteClipMenu({ note: note, isDeleted, currentClip: currentClip?.value }), clipButton.value).then(focus); } function showRenoteMenu(viaKeyboard = false): void { diff --git a/packages/frontend/src/components/MkNoteDetailed.vue b/packages/frontend/src/components/MkNoteDetailed.vue index 715fd3a9a8..b9ab366850 100644 --- a/packages/frontend/src/components/MkNoteDetailed.vue +++ b/packages/frontend/src/components/MkNoteDetailed.vue @@ -30,7 +30,7 @@ <i v-else-if="note.visibility === 'followers'" class="ti ti-lock"></i> <i v-else-if="note.visibility === 'specified'" ref="specified" class="ti ti-mail"></i> </span> - <span v-if="note.localOnly" style="margin-left: 0.5em;" :title="i18n.ts._visibility['disableFederation']"><i class="ti ti-world-off"></i></span> + <span v-if="note.localOnly" style="margin-left: 0.5em;" :title="i18n.ts._visibility['disableFederation']"><i class="ti ti-rocket-off"></i></span> </div> </div> <article class="article" @contextmenu.stop="onContextmenu"> @@ -48,7 +48,7 @@ <i v-else-if="appearNote.visibility === 'followers'" class="ti ti-lock"></i> <i v-else-if="appearNote.visibility === 'specified'" ref="specified" class="ti ti-mail"></i> </span> - <span v-if="appearNote.localOnly" style="margin-left: 0.5em;" :title="i18n.ts._visibility['disableFederation']"><i class="ti ti-world-off"></i></span> + <span v-if="appearNote.localOnly" style="margin-left: 0.5em;" :title="i18n.ts._visibility['disableFederation']"><i class="ti ti-rocket-off"></i></span> </div> </div> <div class="username"><MkAcct :user="appearNote.user"/></div> @@ -70,7 +70,7 @@ <div v-if="translating || translation" class="translation"> <MkLoading v-if="translating" mini/> <div v-else class="translated"> - <b>{{ $t('translatedFrom', { x: translation.sourceLang }) }}: </b> + <b>{{ i18n.t('translatedFrom', { x: translation.sourceLang }) }}: </b> <Mfm :text="translation.text" :author="appearNote.user" :i="$i" :emoji-urls="appearNote.emojis"/> </div> </div> diff --git a/packages/frontend/src/components/MkNoteHeader.vue b/packages/frontend/src/components/MkNoteHeader.vue index 15d7ea2e14..e468650430 100644 --- a/packages/frontend/src/components/MkNoteHeader.vue +++ b/packages/frontend/src/components/MkNoteHeader.vue @@ -17,7 +17,7 @@ <i v-else-if="note.visibility === 'followers'" class="ti ti-lock"></i> <i v-else-if="note.visibility === 'specified'" ref="specified" class="ti ti-mail"></i> </span> - <span v-if="note.localOnly" style="margin-left: 0.5em;" :title="i18n.ts._visibility['disableFederation']"><i class="ti ti-world-off"></i></span> + <span v-if="note.localOnly" style="margin-left: 0.5em;" :title="i18n.ts._visibility['disableFederation']"><i class="ti ti-rocket-off"></i></span> <span v-if="note.channel" style="margin-left: 0.5em;" :title="note.channel.name"><i class="ti ti-device-tv"></i></span> </div> </header> diff --git a/packages/frontend/src/components/MkNotePreview.vue b/packages/frontend/src/components/MkNotePreview.vue index 1cc01386ba..6b55c27869 100644 --- a/packages/frontend/src/components/MkNotePreview.vue +++ b/packages/frontend/src/components/MkNotePreview.vue @@ -3,7 +3,7 @@ <MkAvatar :class="$style.avatar" :user="$i" link preview/> <div :class="$style.main"> <div :class="$style.header"> - <MkUserName :user="$i"/> + <MkUserName :user="$i" :nowrap="true"/> </div> <div> <div :class="$style.content"> @@ -16,6 +16,7 @@ <script lang="ts" setup> import { } from 'vue'; +import { $i } from '@/account'; const props = defineProps<{ text: string; @@ -49,6 +50,9 @@ const props = defineProps<{ .header { margin-bottom: 2px; font-weight: bold; + width: 100%; + overflow: clip; + text-overflow: ellipsis; } @container (min-width: 350px) { diff --git a/packages/frontend/src/components/MkNoteSimple.vue b/packages/frontend/src/components/MkNoteSimple.vue index 2b541e6094..bd27a43b61 100644 --- a/packages/frontend/src/components/MkNoteSimple.vue +++ b/packages/frontend/src/components/MkNoteSimple.vue @@ -22,6 +22,7 @@ import * as misskey from 'misskey-js'; import MkNoteHeader from '@/components/MkNoteHeader.vue'; import MkSubNoteContent from '@/components/MkSubNoteContent.vue'; import MkCwButton from '@/components/MkCwButton.vue'; +import { $i } from '@/account'; const props = defineProps<{ note: misskey.entities.Note; diff --git a/packages/frontend/src/components/MkNoteSub.vue b/packages/frontend/src/components/MkNoteSub.vue index ab6d62fba5..c293641355 100644 --- a/packages/frontend/src/components/MkNoteSub.vue +++ b/packages/frontend/src/components/MkNoteSub.vue @@ -33,6 +33,7 @@ import MkCwButton from '@/components/MkCwButton.vue'; import { notePage } from '@/filters/note'; import * as os from '@/os'; import { i18n } from '@/i18n'; +import { $i } from '@/account'; 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 f9952e4245..a4e949c898 100644 --- a/packages/frontend/src/components/MkNotes.vue +++ b/packages/frontend/src/components/MkNotes.vue @@ -19,7 +19,7 @@ :ad="true" :class="$style.notes" > - <XNote :key="note._featuredId_ || note._prId_ || note.id" :class="$style.note" :note="note"/> + <MkNote :key="note._featuredId_ || note._prId_ || note.id" :class="$style.note" :note="note"/> </MkDateSeparatedList> </div> </template> @@ -28,7 +28,7 @@ <script lang="ts" setup> import { shallowRef } from 'vue'; -import XNote from '@/components/MkNote.vue'; +import MkNote from '@/components/MkNote.vue'; import MkDateSeparatedList from '@/components/MkDateSeparatedList.vue'; import MkPagination, { Paging } from '@/components/MkPagination.vue'; import { i18n } from '@/i18n'; diff --git a/packages/frontend/src/components/MkNotification.vue b/packages/frontend/src/components/MkNotification.vue index b60967de02..efae687e66 100644 --- a/packages/frontend/src/components/MkNotification.vue +++ b/packages/frontend/src/components/MkNotification.vue @@ -83,7 +83,7 @@ </template> <script lang="ts" setup> -import { ref, shallowRef, onMounted, onUnmounted, watch } from 'vue'; +import { ref, shallowRef } from 'vue'; import * as misskey from 'misskey-js'; import MkReactionIcon from '@/components/MkReactionIcon.vue'; import MkFollowButton from '@/components/MkFollowButton.vue'; @@ -94,7 +94,6 @@ import { notePage } from '@/filters/note'; import { userPage } from '@/filters/user'; import { i18n } from '@/i18n'; import * as os from '@/os'; -import { stream } from '@/stream'; import { useTooltip } from '@/scripts/use-tooltip'; import { $i } from '@/account'; @@ -110,35 +109,6 @@ const props = withDefaults(defineProps<{ const elRef = shallowRef<HTMLElement>(null); const reactionRef = ref(null); -let readObserver: IntersectionObserver | undefined; -let connection; - -onMounted(() => { - if (!props.notification.isRead) { - readObserver = new IntersectionObserver((entries, observer) => { - if (!entries.some(entry => entry.isIntersecting)) return; - stream.send('readNotification', { - id: props.notification.id, - }); - observer.disconnect(); - }); - - readObserver.observe(elRef.value); - - connection = stream.useChannel('main'); - connection.on('readAllNotifications', () => readObserver.disconnect()); - - watch(props.notification.isRead, () => { - readObserver.disconnect(); - }); - } -}); - -onUnmounted(() => { - if (readObserver) readObserver.disconnect(); - if (connection) connection.dispose(); -}); - const followRequestDone = ref(false); const acceptFollowRequest = () => { diff --git a/packages/frontend/src/components/MkNotifications.vue b/packages/frontend/src/components/MkNotifications.vue index 93b1c37055..1aea95fe0e 100644 --- a/packages/frontend/src/components/MkNotifications.vue +++ b/packages/frontend/src/components/MkNotifications.vue @@ -9,7 +9,7 @@ <template #default="{ items: notifications }"> <MkDateSeparatedList v-slot="{ item: notification }" :class="$style.list" :items="notifications" :no-gap="true"> - <XNote v-if="['reply', 'quote', 'mention'].includes(notification.type)" :key="notification.id" :note="notification.note"/> + <MkNote v-if="['reply', 'quote', 'mention'].includes(notification.type)" :key="notification.id" :note="notification.note"/> <XNotification v-else :key="notification.id" :notification="notification" :with-time="true" :full="true" class="_panel notification"/> </MkDateSeparatedList> </template> @@ -21,7 +21,7 @@ import { onUnmounted, onMounted, computed, shallowRef } from 'vue'; import MkPagination, { Paging } from '@/components/MkPagination.vue'; import XNotification from '@/components/MkNotification.vue'; import MkDateSeparatedList from '@/components/MkDateSeparatedList.vue'; -import XNote from '@/components/MkNote.vue'; +import MkNote from '@/components/MkNote.vue'; import { stream } from '@/stream'; import { $i } from '@/account'; import { i18n } from '@/i18n'; @@ -29,7 +29,6 @@ import { notificationTypes } from '@/const'; const props = defineProps<{ includeTypes?: typeof notificationTypes[number][]; - unreadOnly?: boolean; }>(); const pagingComponent = shallowRef<InstanceType<typeof MkPagination>>(); @@ -40,23 +39,17 @@ const pagination: Paging = { params: computed(() => ({ includeTypes: props.includeTypes ?? undefined, excludeTypes: props.includeTypes ? undefined : $i.mutingNotificationTypes, - unreadOnly: props.unreadOnly, })), }; const onNotification = (notification) => { const isMuted = props.includeTypes ? !props.includeTypes.includes(notification.type) : $i.mutingNotificationTypes.includes(notification.type); if (isMuted || document.visibilityState === 'visible') { - stream.send('readNotification', { - id: notification.id, - }); + stream.send('readNotification'); } if (!isMuted) { - pagingComponent.value.prepend({ - ...notification, - isRead: document.visibilityState === 'visible', - }); + pagingComponent.value.prepend(notification); } }; @@ -65,30 +58,6 @@ let connection; onMounted(() => { connection = stream.useChannel('main'); connection.on('notification', onNotification); - connection.on('readAllNotifications', () => { - if (pagingComponent.value) { - for (const item of pagingComponent.value.queue) { - item.isRead = true; - } - for (const item of pagingComponent.value.items) { - item.isRead = true; - } - } - }); - connection.on('readNotifications', notificationIds => { - if (pagingComponent.value) { - for (let i = 0; i < pagingComponent.value.queue.length; i++) { - if (notificationIds.includes(pagingComponent.value.queue[i].id)) { - pagingComponent.value.queue[i].isRead = true; - } - } - for (let i = 0; i < (pagingComponent.value.items || []).length; i++) { - if (notificationIds.includes(pagingComponent.value.items[i].id)) { - pagingComponent.value.items[i].isRead = true; - } - } - } - }); }); onUnmounted(() => { diff --git a/packages/frontend/src/components/MkOmit.vue b/packages/frontend/src/components/MkOmit.vue index a806d92b22..0f148022bf 100644 --- a/packages/frontend/src/components/MkOmit.vue +++ b/packages/frontend/src/components/MkOmit.vue @@ -2,16 +2,17 @@ <div ref="content" :class="[$style.content, { [$style.omitted]: omitted }]"> <slot></slot> <button v-if="omitted" :class="$style.fade" class="_button" @click="() => { ignoreOmit = true; omitted = false; }"> - <span :class="$style.fadeLabel">{{ $ts.showMore }}</span> + <span :class="$style.fadeLabel">{{ i18n.ts.showMore }}</span> </button> </div> </template> <script lang="ts" setup> import { onMounted } from 'vue'; +import { i18n } from '@/i18n'; const props = withDefaults(defineProps<{ - maxHeight: number; + maxHeight?: number; }>(), { maxHeight: 200, }); diff --git a/packages/frontend/src/components/MkPagination.vue b/packages/frontend/src/components/MkPagination.vue index a1a61a6fd6..cd8af560e4 100644 --- a/packages/frontend/src/components/MkPagination.vue +++ b/packages/frontend/src/components/MkPagination.vue @@ -1,9 +1,9 @@ <template> <Transition - :enter-active-class="$store.state.animation ? $style.transition_fade_enterActive : ''" - :leave-active-class="$store.state.animation ? $style.transition_fade_leaveActive : ''" - :enter-from-class="$store.state.animation ? $style.transition_fade_enterFrom : ''" - :leave-to-class="$store.state.animation ? $style.transition_fade_leaveTo : ''" + :enter-active-class="defaultStore.state.animation ? $style.transition_fade_enterActive : ''" + :leave-active-class="defaultStore.state.animation ? $style.transition_fade_leaveActive : ''" + :enter-from-class="defaultStore.state.animation ? $style.transition_fade_enterFrom : ''" + :leave-to-class="defaultStore.state.animation ? $style.transition_fade_leaveTo : ''" mode="out-in" > <MkLoading v-if="fetching"/> @@ -163,21 +163,22 @@ async function init(): Promise<void> { const params = props.pagination.params ? isRef(props.pagination.params) ? props.pagination.params.value : props.pagination.params : {}; await os.api(props.pagination.endpoint, { ...params, - limit: props.pagination.noPaging ? (props.pagination.limit || 10) : (props.pagination.limit || 10) + 1, + limit: props.pagination.limit ?? 10, }).then(res => { for (let i = 0; i < res.length; i++) { const item = res[i]; if (i === 3) item._shouldInsertAd_ = true; } - if (!props.pagination.noPaging && (res.length > (props.pagination.limit || 10))) { - res.pop(); - if (props.pagination.reversed) moreFetching.value = true; + + if (res.length === 0 || props.pagination.noPaging) { items.value = res; - more.value = true; + more.value = false; } else { + if (props.pagination.reversed) moreFetching.value = true; items.value = res; - more.value = false; + more.value = true; } + offset.value = res.length; error.value = false; fetching.value = false; @@ -198,7 +199,7 @@ const fetchMore = async (): Promise<void> => { const params = props.pagination.params ? isRef(props.pagination.params) ? props.pagination.params.value : props.pagination.params : {}; await os.api(props.pagination.endpoint, { ...params, - limit: SECOND_FETCH_LIMIT + 1, + limit: SECOND_FETCH_LIMIT, ...(props.pagination.offsetMode ? { offset: offset.value, } : { @@ -227,28 +228,26 @@ const fetchMore = async (): Promise<void> => { }); }; - if (res.length > SECOND_FETCH_LIMIT) { - res.pop(); - + if (res.length === 0) { if (props.pagination.reversed) { reverseConcat(res).then(() => { - more.value = true; + more.value = false; moreFetching.value = false; }); } else { items.value = items.value.concat(res); - more.value = true; + more.value = false; moreFetching.value = false; } } else { if (props.pagination.reversed) { reverseConcat(res).then(() => { - more.value = false; + more.value = true; moreFetching.value = false; }); } else { items.value = items.value.concat(res); - more.value = false; + more.value = true; moreFetching.value = false; } } @@ -264,20 +263,19 @@ const fetchMoreAhead = async (): Promise<void> => { const params = props.pagination.params ? isRef(props.pagination.params) ? props.pagination.params.value : props.pagination.params : {}; await os.api(props.pagination.endpoint, { ...params, - limit: SECOND_FETCH_LIMIT + 1, + limit: SECOND_FETCH_LIMIT, ...(props.pagination.offsetMode ? { offset: offset.value, } : { sinceId: items.value[items.value.length - 1].id, }), }).then(res => { - if (res.length > SECOND_FETCH_LIMIT) { - res.pop(); + if (res.length === 0) { items.value = items.value.concat(res); - more.value = true; + more.value = false; } else { items.value = items.value.concat(res); - more.value = false; + more.value = true; } offset.value += res.length; moreFetching.value = false; diff --git a/packages/frontend/src/components/MkPoll.vue b/packages/frontend/src/components/MkPoll.vue index fcbd8ad351..0810061ff9 100644 --- a/packages/frontend/src/components/MkPoll.vue +++ b/packages/frontend/src/components/MkPoll.vue @@ -6,12 +6,12 @@ <span> <template v-if="choice.isVoted"><i class="ti ti-check"></i></template> <Mfm :text="choice.text" :plain="true"/> - <span v-if="showResult" class="votes">({{ $t('_poll.votesCount', { n: choice.votes }) }})</span> + <span v-if="showResult" class="votes">({{ i18n.t('_poll.votesCount', { n: choice.votes }) }})</span> </span> </li> </ul> <p v-if="!readOnly"> - <span>{{ $t('_poll.totalVotes', { n: total }) }}</span> + <span>{{ i18n.t('_poll.totalVotes', { n: total }) }}</span> <span> · </span> <a v-if="!closed && !isVoted" @click="showResult = !showResult">{{ showResult ? i18n.ts._poll.vote : i18n.ts._poll.showResult }}</a> <span v-if="isVoted">{{ i18n.ts._poll.voted }}</span> diff --git a/packages/frontend/src/components/MkPollEditor.vue b/packages/frontend/src/components/MkPollEditor.vue index 9567c58b99..471ec39169 100644 --- a/packages/frontend/src/components/MkPollEditor.vue +++ b/packages/frontend/src/components/MkPollEditor.vue @@ -5,7 +5,7 @@ </p> <ul> <li v-for="(choice, i) in choices" :key="i"> - <MkInput class="input" small :model-value="choice" :placeholder="$t('_poll.choiceN', { n: i + 1 })" @update:model-value="onInput(i, $event)"> + <MkInput class="input" small :model-value="choice" :placeholder="i18n.t('_poll.choiceN', { n: i + 1 })" @update:model-value="onInput(i, $event)"> </MkInput> <button class="_button" @click="remove(i)"> <i class="ti ti-x"></i> diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue index b1800f3af7..10cb7d96cc 100644 --- a/packages/frontend/src/components/MkPostForm.vue +++ b/packages/frontend/src/components/MkPostForm.vue @@ -7,20 +7,35 @@ @drop.stop="onDrop" > <header :class="$style.header"> - <button v-if="!fixed" :class="$style.cancel" class="_button" @click="cancel"><i class="ti ti-x"></i></button> - <button v-click-anime v-tooltip="i18n.ts.switchAccount" :class="$style.account" class="_button" @click="openAccountMenu"> - <MkAvatar :user="postAccount ?? $i" :class="$style.avatar"/> - </button> + <div :class="$style.headerLeft"> + <button v-if="!fixed" :class="$style.cancel" class="_button" @click="cancel"><i class="ti ti-x"></i></button> + <button v-click-anime v-tooltip="i18n.ts.switchAccount" :class="$style.account" class="_button" @click="openAccountMenu"> + <MkAvatar :user="postAccount ?? $i" :class="$style.avatar"/> + </button> + </div> <div :class="$style.headerRight"> - <span :class="[$style.textCount, { [$style.textOver]: textLength > maxTextLength }]">{{ maxTextLength - textLength }}</span> - <span v-if="localOnly" :class="$style.localOnly"><i class="ti ti-world-off"></i></span> - <button ref="visibilityButton" v-tooltip="i18n.ts.visibility" class="_button" :class="$style.visibility" :disabled="channel != null" @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> - <span v-if="visibility === 'specified'"><i class="ti ti-mail"></i></span> + <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"> + <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> + <span v-if="visibility === 'specified'"><i class="ti ti-mail"></i></span> + <span :class="$style.headerRightButtonText">{{ i18n.ts._visibility[visibility] }}</span> + </button> + <button v-else :class="['_button', $style.headerRightItem, $style.visibility]" disabled> + <span><i class="ti ti-device-tv"></i></span> + <span :class="$style.headerRightButtonText">{{ channel.name }}</span> + </button> + </template> + <button v-click-anime v-tooltip="i18n.ts._visibility.disableFederation" :class="['_button', $style.headerRightItem, $style.localOnly, { [$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', $style.headerRightItem, $style.reactionAcceptance, { [$style.danger]: reactionAcceptance }]" @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 v-tooltip="i18n.ts.previewNoteText" class="_button" :class="[$style.previewButton, { [$style.previewButtonActive]: showPreview }]" @click="showPreview = !showPreview"><i class="ti ti-eye"></i></button> <button v-click-anime class="_button" :class="[$style.submit, { [$style.submitPosting]: posting }]" :disabled="!canPost" data-cy-open-post-form-submit @click="post"> <div :class="$style.submitInner"> <template v-if="posted"></template> @@ -31,50 +46,49 @@ </button> </div> </header> - <div :class="[$style.form]"> - <MkNoteSimple v-if="reply" :class="$style.targetNote" :note="reply"/> - <MkNoteSimple v-if="renote" :class="$style.targetNote" :note="renote"/> - <div v-if="quoteId" :class="$style.withQuote"><i class="ti ti-quote"></i> {{ i18n.ts.quoteAttached }}<button @click="quoteId = null"><i class="ti ti-x"></i></button></div> - <div v-if="visibility === 'specified'" :class="$style.toSpecified"> - <span style="margin-right: 8px;">{{ i18n.ts.recipient }}</span> - <div :class="$style.visibleUsers"> - <span v-for="u in visibleUsers" :key="u.id" :class="$style.visibleUser"> - <MkAcct :user="u"/> - <button class="_button" style="padding: 4px 8px;" @click="removeVisibleUser(u)"><i class="ti ti-x"></i></button> - </span> - <button class="_buttonPrimary" style="padding: 4px; border-radius: 8px;" @click="addVisibleUser"><i class="ti ti-plus ti-fw"></i></button> - </div> - </div> - <MkInfo v-if="localOnly && channel == null" warn :class="$style.disableFederationWarn">{{ i18n.ts.disableFederationWarn }}</MkInfo> - <MkInfo v-if="hasNotSpecifiedMentions" warn :class="$style.hasNotSpecifiedMentions">{{ i18n.ts.notSpecifiedMentionWarning }} - <button class="_textButton" @click="addMissingMention()">{{ i18n.ts.add }}</button></MkInfo> - <input v-show="useCw" ref="cwInputEl" v-model="cw" :class="$style.cw" :placeholder="i18n.ts.annotation" @keydown="onKeydown"> - <textarea ref="textareaEl" v-model="text" :class="[$style.text, { [$style.withCw]: useCw }]" :disabled="posting || posted" :placeholder="placeholder" data-cy-post-form-text @keydown="onKeydown" @paste="onPaste" @compositionupdate="onCompositionUpdate" @compositionend="onCompositionEnd"/> - <input v-show="withHashtags" ref="hashtagsInputEl" v-model="hashtags" :class="$style.hashtags" :placeholder="i18n.ts.hashtags" list="hashtags"> - <XPostFormAttaches v-model="files" :class="$style.attaches" @detach="detachFile" @change-sensitive="updateFileSensitive" @change-name="updateFileName"/> - <MkPollEditor v-if="poll" v-model="poll" @destroyed="poll = null"/> - <XNotePreview v-if="showPreview" :class="$style.preview" :text="text"/> - <div v-if="showingOptions" style="padding: 0 16px;"> - <MkSelect v-model="reactionAcceptance" small> - <template #label>{{ i18n.ts.reactionAcceptance }}</template> - <option :value="null">{{ i18n.ts.all }}</option> - <option value="likeOnly">{{ i18n.ts.likeOnly }}</option> - <option value="likeOnlyForRemote">{{ i18n.ts.likeOnlyForRemote }}</option> - </MkSelect> + <MkNoteSimple v-if="reply" :class="$style.targetNote" :note="reply"/> + <MkNoteSimple v-if="renote" :class="$style.targetNote" :note="renote"/> + <div v-if="quoteId" :class="$style.withQuote"><i class="ti ti-quote"></i> {{ i18n.ts.quoteAttached }}<button @click="quoteId = null"><i class="ti ti-x"></i></button></div> + <div v-if="visibility === 'specified'" :class="$style.toSpecified"> + <span style="margin-right: 8px;">{{ i18n.ts.recipient }}</span> + <div :class="$style.visibleUsers"> + <span v-for="u in visibleUsers" :key="u.id" :class="$style.visibleUser"> + <MkAcct :user="u"/> + <button class="_button" style="padding: 4px 8px;" @click="removeVisibleUser(u)"><i class="ti ti-x"></i></button> + </span> + <button class="_buttonPrimary" style="padding: 4px; border-radius: 8px;" @click="addVisibleUser"><i class="ti ti-plus ti-fw"></i></button> </div> - <button v-tooltip="i18n.ts.emoji" class="_button" :class="$style.emojiButton" @click="insertEmoji"><i class="ti ti-mood-happy"></i></button> - <footer :class="$style.footer"> + </div> + <MkInfo v-if="hasNotSpecifiedMentions" warn :class="$style.hasNotSpecifiedMentions">{{ i18n.ts.notSpecifiedMentionWarning }} - <button class="_textButton" @click="addMissingMention()">{{ i18n.ts.add }}</button></MkInfo> + <input v-show="useCw" ref="cwInputEl" v-model="cw" :class="$style.cw" :placeholder="i18n.ts.annotation" @keydown="onKeydown"> + <div :class="[$style.textOuter, { [$style.withCw]: useCw }]"> + <textarea ref="textareaEl" v-model="text" :class="[$style.text]" :disabled="posting || posted" :placeholder="placeholder" data-cy-post-form-text @keydown="onKeydown" @paste="onPaste" @compositionupdate="onCompositionUpdate" @compositionend="onCompositionEnd"/> + <div v-if="maxTextLength - textLength < 100" :class="['_acrylic', $style.textCount, { [$style.textOver]: textLength > maxTextLength }]">{{ maxTextLength - textLength }}</div> + </div> + <input v-show="withHashtags" ref="hashtagsInputEl" v-model="hashtags" :class="$style.hashtags" :placeholder="i18n.ts.hashtags" list="hashtags"> + <XPostFormAttaches v-model="files" :class="$style.attaches" @detach="detachFile" @change-sensitive="updateFileSensitive" @change-name="updateFileName"/> + <MkPollEditor v-if="poll" v-model="poll" @destroyed="poll = null"/> + <MkNotePreview v-if="showPreview" :class="$style.preview" :text="text"/> + <div v-if="showingOptions" style="padding: 8px 16px;"> + </div> + <footer :class="$style.footer"> + <div :class="$style.footerLeft"> <button v-tooltip="i18n.ts.attachFile" class="_button" :class="$style.footerButton" @click="chooseFileFrom"><i class="ti ti-photo-plus"></i></button> <button v-tooltip="i18n.ts.poll" class="_button" :class="[$style.footerButton, { [$style.footerButtonActive]: poll }]" @click="togglePoll"><i class="ti ti-chart-arrows"></i></button> <button v-tooltip="i18n.ts.useCw" class="_button" :class="[$style.footerButton, { [$style.footerButtonActive]: useCw }]" @click="useCw = !useCw"><i class="ti ti-eye-off"></i></button> <button v-tooltip="i18n.ts.mention" class="_button" :class="$style.footerButton" @click="insertMention"><i class="ti ti-at"></i></button> <button v-tooltip="i18n.ts.hashtags" class="_button" :class="[$style.footerButton, { [$style.footerButtonActive]: withHashtags }]" @click="withHashtags = !withHashtags"><i class="ti ti-hash"></i></button> <button v-if="postFormActions.length > 0" v-tooltip="i18n.ts.plugin" class="_button" :class="$style.footerButton" @click="showActions"><i class="ti ti-plug"></i></button> - <button v-tooltip="i18n.ts.more" class="_button" :class="$style.footerButton" @click="showingOptions = !showingOptions"><i class="ti ti-dots"></i></button> - </footer> - <datalist id="hashtags"> - <option v-for="hashtag in recentHashtags" :key="hashtag" :value="hashtag"/> - </datalist> - </div> + <button v-tooltip="i18n.ts.emoji" :class="['_button', $style.footerButton]" @click="insertEmoji"><i class="ti ti-mood-happy"></i></button> + </div> + <div :class="$style.footerRight"> + <button v-tooltip="i18n.ts.previewNoteText" class="_button" :class="[$style.footerButton, { [$style.previewButtonActive]: showPreview }]" @click="showPreview = !showPreview"><i class="ti ti-eye"></i></button> + <!--<button v-tooltip="i18n.ts.more" class="_button" :class="$style.footerButton" @click="showingOptions = !showingOptions"><i class="ti ti-dots"></i></button>--> + </div> + </footer> + <datalist id="hashtags"> + <option v-for="hashtag in recentHashtags" :key="hashtag" :value="hashtag"/> + </datalist> </div> </template> @@ -85,9 +99,8 @@ import * as misskey from 'misskey-js'; import insertTextAtCursor from 'insert-text-at-cursor'; import { toASCII } from 'punycode/'; import * as Acct from 'misskey-js/built/acct'; -import MkSelect from './MkSelect.vue'; import MkNoteSimple from '@/components/MkNoteSimple.vue'; -import XNotePreview from '@/components/MkNotePreview.vue'; +import MkNotePreview from '@/components/MkNotePreview.vue'; import XPostFormAttaches from '@/components/MkPostFormAttaches.vue'; import MkPollEditor from '@/components/MkPollEditor.vue'; import { host, url } from '@/config'; @@ -113,7 +126,7 @@ const modal = inject('modal'); const props = withDefaults(defineProps<{ reply?: misskey.entities.Note; renote?: misskey.entities.Note; - channel?: any; // TODO + channel?: misskey.entities.Channel; // TODO mention?: misskey.entities.User; specified?: misskey.entities.User; initialText?: string; @@ -401,13 +414,14 @@ function upload(file: File, name?: string) { function setVisibility() { if (props.channel) { - // TODO: information dialog + visibility = 'public'; + localOnly = true; // TODO: チャンネルが連合するようになった折には消す return; } os.popup(defineAsyncComponent(() => import('@/components/MkVisibilityPicker.vue')), { currentVisibility: visibility, - currentLocalOnly: localOnly, + localOnly: localOnly, src: visibilityButton, }, { changeVisibility: v => { @@ -416,15 +430,65 @@ function setVisibility() { defaultStore.set('visibility', visibility); } }, - changeLocalOnly: v => { - localOnly = v; - if (defaultStore.state.rememberNoteVisibility) { - defaultStore.set('localOnly', localOnly); - } - }, }, 'closed'); } +async function toggleLocalOnly() { + if (props.channel) { + visibility = 'public'; + localOnly = true; // TODO: チャンネルが連合するようになった折には消す + return; + } + + const neverShowInfo = miLocalStorage.getItem('neverShowLocalOnlyInfo'); + + if (!localOnly && neverShowInfo !== 'true') { + const confirm = await os.actions({ + type: 'question', + title: i18n.ts.disableFederationConfirm, + text: i18n.ts.disableFederationConfirmWarn, + actions: [ + { + value: 'yes' as const, + text: i18n.ts.disableFederationOk, + primary: true, + }, + { + value: 'neverShow' as const, + text: `${i18n.ts.disableFederationOk} (${i18n.ts.neverShow})`, + danger: true, + }, + { + value: 'no' as const, + text: i18n.ts.cancel, + }, + ], + }); + if (confirm.canceled) return; + if (confirm.result === 'no') return; + + if (confirm.result === 'neverShow') { + miLocalStorage.setItem('neverShowLocalOnlyInfo', 'true'); + } + } + + localOnly = !localOnly; +} + +async function toggleReactionAcceptance() { + const select = await os.select({ + title: i18n.ts.reactionAcceptance, + items: [ + { value: null, text: i18n.ts.all }, + { value: 'likeOnly' as const, text: i18n.ts.likeOnly }, + { value: 'likeOnlyForRemote' as const, text: i18n.ts.likeOnlyForRemote }, + ], + default: reactionAcceptance, + }); + if (select.canceled) return; + reactionAcceptance = select.result; +} + function pushVisibleUser(user) { if (!visibleUsers.some(u => u.username === user.username && u.host === user.host)) { visibleUsers.push(user); @@ -591,7 +655,8 @@ async function post(ev?: MouseEvent) { text.includes('$[x4') || text.includes('$[scale') || text.includes('$[position'); - if (annoying) { + + if (annoying && visibility === 'public') { const { canceled, result } = await os.actions({ type: 'warning', text: i18n.ts.thisPostMayBeAnnoying, @@ -817,6 +882,7 @@ defineExpose({ <style lang="scss" module> .root { position: relative; + container-type: inline-size; &.modal { width: 100%; @@ -824,21 +890,29 @@ defineExpose({ } } +//#region header .header { z-index: 1000; - height: 66px; + min-height: 50px; + display: flex; + flex-wrap: nowrap; + gap: 4px; +} + +.headerLeft { + display: grid; + grid-template-columns: repeat(2, minmax(36px, 50px)); + grid-template-rows: minmax(40px, 100%); } .cancel { padding: 0; font-size: 1em; - width: 64px; - line-height: 66px; + height: 100%; } .account { height: 100%; - aspect-ratio: 1/1; display: inline-flex; vertical-align: bottom; } @@ -846,55 +920,23 @@ defineExpose({ .avatar { width: 28px; height: 28px; - margin: auto; + margin: auto 0; } .headerRight { - position: absolute; - top: 0; - right: 0; -} - -.textCount { - opacity: 0.7; - line-height: 66px; -} - -.visibility { - height: 34px; - width: 34px; - margin: 0 0 0 8px; - - & + .localOnly { - margin-left: 0 !important; - } -} - -.localOnly { - margin: 0 0 0 12px; - opacity: 0.7; -} - -.previewButton { - display: inline-block; - padding: 0; - margin: 0 8px 0 0; - font-size: 16px; - width: 34px; - height: 34px; - border-radius: 6px; - - &:hover { - background: var(--X5); - } - - &.previewButtonActive { - color: var(--accent); - } + display: flex; + min-height: 48px; + font-size: 0.9em; + flex-wrap: nowrap; + align-items: center; + margin-left: auto; + gap: 4px; + overflow: clip; + padding-left: 4px; } .submit { - margin: 16px 16px 16px 0; + margin: 12px 12px 12px 6px; vertical-align: bottom; &:disabled { @@ -922,17 +964,48 @@ defineExpose({ padding: 0 12px; line-height: 34px; font-weight: bold; - border-radius: 4px; - font-size: 0.9em; + border-radius: 6px; min-width: 90px; box-sizing: border-box; color: var(--fgOnAccent); background: linear-gradient(90deg, var(--buttonGradateA), var(--buttonGradateB)); } -.form { +.headerRightItem { + margin: 0; + padding: 8px; + border-radius: 6px; + + &:hover { + background: var(--X5); + } + + &:disabled { + background: none; + } + + &.danger { + color: #ff2a2a; + } +} + +.headerRightButtonText { + padding-left: 6px; } +.visibility { + overflow: clip; + text-overflow: ellipsis; + white-space: nowrap; + + &:enabled { + > .headerRightButtonText { + opacity: 0.8; + } + } +} +//#endregion + .preview { padding: 16px 20px 0 20px; } @@ -966,10 +1039,6 @@ defineExpose({ background: var(--X4); } -.disableFederationWarn { - margin: 0 20px 16px 20px; -} - .hasNotSpecifiedMentions { margin: 0 20px 16px 20px; } @@ -1011,18 +1080,61 @@ defineExpose({ border-top: solid 0.5px var(--divider); } +.textOuter { + width: 100%; + position: relative; + + &.withCw { + padding-top: 8px; + } +} + .text { max-width: 100%; min-width: 100%; + width: 100%; min-height: 90px; + height: 100%; +} - &.withCw { - padding-top: 8px; +.textCount { + position: absolute; + top: 0; + right: 2px; + padding: 4px 6px; + font-size: .9em; + color: var(--warn); + border-radius: 6px; + min-width: 1.6em; + text-align: center; + + &.textOver { + color: #ff2a2a; } } .footer { + display: flex; padding: 0 16px 16px 16px; + font-size: 1em; +} + +.footerLeft { + flex: 1; + display: grid; + grid-auto-flow: row; + grid-template-columns: repeat(auto-fill, minmax(42px, 1fr)); + grid-auto-rows: 46px; +} + +.footerRight { + flex: 0.3; + margin-left: auto; + display: grid; + grid-auto-flow: row; + grid-template-columns: repeat(auto-fill, minmax(42px, 1fr)); + grid-auto-rows: 46px; + direction: rtl; } .footerButton { @@ -1030,8 +1142,8 @@ defineExpose({ padding: 0; margin: 0; font-size: 1em; - width: 46px; - height: 46px; + width: auto; + height: 100%; border-radius: 6px; &:hover { @@ -1043,42 +1155,34 @@ defineExpose({ } } -.emojiButton { - position: absolute; - top: 55px; - right: 13px; - display: inline-block; - padding: 0; - margin: 0; - font-size: 1em; - width: 32px; - height: 32px; +.previewButtonActive { + color: var(--accent); } @container (max-width: 500px) { - .header { - height: 50px; + .headerRight { + font-size: .9em; + } - > .cancel { - width: 50px; - line-height: 50px; - } + .headerRightButtonText { + display: none; + } - > .headerRight { - > .textCount { - line-height: 50px; - } + .visibility { + overflow: initial; + } - > .submit { - margin: 8px; - } - } + .submit { + margin: 8px 8px 8px 4px; } .toSpecified { padding: 6px 16px; } + .preview { + padding: 16px 14px 0 14px; + } .cw, .hashtags, .text { @@ -1094,11 +1198,13 @@ defineExpose({ } } -@container (max-width: 310px) { - .footerButton { +@container (max-width: 330px) { + .headerRight { + gap: 0; + } + + .footer { font-size: 14px; - width: 44px; - height: 44px; } } </style> diff --git a/packages/frontend/src/components/MkPostFormAttaches.vue b/packages/frontend/src/components/MkPostFormAttaches.vue index 5fb820f03f..760c6e5d08 100644 --- a/packages/frontend/src/components/MkPostFormAttaches.vue +++ b/packages/frontend/src/components/MkPostFormAttaches.vue @@ -24,19 +24,19 @@ const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.d const props = defineProps<{ modelValue: any[]; - detachMediaFn: () => void; + detachMediaFn?: (id: string) => void; }>(); const emit = defineEmits<{ (ev: 'update:modelValue', value: any[]): void; - (ev: 'detach'): void; + (ev: 'detach', id: string): void; (ev: 'changeSensitive'): void; (ev: 'changeName'): void; }>(); let menuShowing = false; -function detachMedia(id) { +function detachMedia(id: string) { if (props.detachMediaFn) { props.detachMediaFn(id); } else { diff --git a/packages/frontend/src/components/MkReactionsViewer.reaction.vue b/packages/frontend/src/components/MkReactionsViewer.reaction.vue index fd0f42e9fc..9480af5102 100644 --- a/packages/frontend/src/components/MkReactionsViewer.reaction.vue +++ b/packages/frontend/src/components/MkReactionsViewer.reaction.vue @@ -3,7 +3,7 @@ ref="buttonEl" v-ripple="canToggle" class="_button" - :class="[$style.root, { [$style.reacted]: note.myReaction == reaction, [$style.canToggle]: canToggle }]" + :class="[$style.root, { [$style.reacted]: note.myReaction == reaction, [$style.canToggle]: canToggle, [$style.large]: defaultStore.state.largeNoteReactions }]" @click="toggleReaction()" > <MkReactionIcon :class="$style.icon" :reaction="reaction" :emoji-url="note.reactionEmojis[reaction.substr(1, reaction.length - 2)]"/> @@ -118,6 +118,17 @@ useTooltip(buttonEl, async (showing) => { cursor: default; } + &.large { + height: 42px; + font-size: 1.5em; + border-radius: 6px; + + > .count { + font-size: 0.7em; + line-height: 42px; + } + } + &.reacted { background: var(--accent); diff --git a/packages/frontend/src/components/MkReactionsViewer.vue b/packages/frontend/src/components/MkReactionsViewer.vue index 76faffe926..3219c8a92c 100644 --- a/packages/frontend/src/components/MkReactionsViewer.vue +++ b/packages/frontend/src/components/MkReactionsViewer.vue @@ -1,27 +1,28 @@ <template> <TransitionGroup - :enter-active-class="$store.state.animation ? $style.transition_x_enterActive : ''" - :leave-active-class="$store.state.animation ? $style.transition_x_leaveActive : ''" - :enter-from-class="$store.state.animation ? $style.transition_x_enterFrom : ''" - :leave-to-class="$store.state.animation ? $style.transition_x_leaveTo : ''" - :move-class="$store.state.animation ? $style.transition_x_move : ''" + :enter-active-class="defaultStore.state.animation ? $style.transition_x_enterActive : ''" + :leave-active-class="defaultStore.state.animation ? $style.transition_x_leaveActive : ''" + :enter-from-class="defaultStore.state.animation ? $style.transition_x_enterFrom : ''" + :leave-to-class="defaultStore.state.animation ? $style.transition_x_leaveTo : ''" + :move-class="defaultStore.state.animation ? $style.transition_x_move : ''" tag="div" :class="$style.root" > <XReaction v-for="[reaction, count] in reactions" :key="reaction" :reaction="reaction" :count="count" :is-initial="initialReactions.has(reaction)" :note="note"/> - <slot v-if="hasMoreReactions" name="more" /> + <slot v-if="hasMoreReactions" name="more"/> </TransitionGroup> </template> <script lang="ts" setup> import * as misskey from 'misskey-js'; -import XReaction from '@/components/MkReactionsViewer.reaction.vue'; import { watch } from 'vue'; +import XReaction from '@/components/MkReactionsViewer.reaction.vue'; +import { defaultStore } from '@/store'; const props = withDefaults(defineProps<{ - note: misskey.entities.Note; - maxNumber?: number; + note: misskey.entities.Note; + maxNumber?: number; }>(), { - maxNumber: Infinity, + maxNumber: Infinity, }); const initialReactions = new Set(Object.keys(props.note.reactions)); diff --git a/packages/frontend/src/components/MkSample.vue b/packages/frontend/src/components/MkSample.vue index 8b7fc2ef76..7a3bc20888 100644 --- a/packages/frontend/src/components/MkSample.vue +++ b/packages/frontend/src/components/MkSample.vue @@ -36,6 +36,7 @@ import MkTextarea from '@/components/MkTextarea.vue'; import MkRadio from '@/components/MkRadio.vue'; import * as os from '@/os'; import * as config from '@/config'; +import { $i } from '@/account'; export default defineComponent({ components: { @@ -51,6 +52,7 @@ export default defineComponent({ text: '', flag: true, radio: 'misskey', + $i, mfm: `Hello world! This is an @example mention. BTW you are @${this.$i ? this.$i.username : 'guest'}.\nAlso, here is ${config.url} and [example link](${config.url}). for more details, see https://example.com.\nAs you know #misskey is open-source software.`, }; }, diff --git a/packages/frontend/src/components/MkSubNoteContent.vue b/packages/frontend/src/components/MkSubNoteContent.vue index 9f90f5eecb..1ac7107aa7 100644 --- a/packages/frontend/src/components/MkSubNoteContent.vue +++ b/packages/frontend/src/components/MkSubNoteContent.vue @@ -8,7 +8,7 @@ <MkA v-if="note.renoteId" :class="$style.rp" :to="`/notes/${note.renoteId}`">RN: ...</MkA> </div> <details v-if="note.files.length > 0"> - <summary>({{ $t('withNFiles', { n: note.files.length }) }})</summary> + <summary>({{ i18n.t('withNFiles', { n: note.files.length }) }})</summary> <MkMediaList :media-list="note.files"/> </details> <details v-if="note.poll"> @@ -27,6 +27,7 @@ import * as misskey from 'misskey-js'; import MkMediaList from '@/components/MkMediaList.vue'; import MkPoll from '@/components/MkPoll.vue'; import { i18n } from '@/i18n'; +import { $i } from '@/account'; const props = defineProps<{ note: misskey.entities.Note; diff --git a/packages/frontend/src/components/MkTimeline.vue b/packages/frontend/src/components/MkTimeline.vue index 87f7c61a92..6741e7a18b 100644 --- a/packages/frontend/src/components/MkTimeline.vue +++ b/packages/frontend/src/components/MkTimeline.vue @@ -1,5 +1,5 @@ <template> -<MkNotes ref="tlComponent" :no-gap="!$store.state.showGapBetweenNotesInTimeline" :pagination="pagination" @queue="emit('queue', $event)"/> +<MkNotes ref="tlComponent" :no-gap="!defaultStore.state.showGapBetweenNotesInTimeline" :pagination="pagination" @queue="emit('queue', $event)"/> </template> <script lang="ts" setup> @@ -8,6 +8,7 @@ import MkNotes from '@/components/MkNotes.vue'; import { stream } from '@/stream'; import * as sound from '@/scripts/sound'; import { $i } from '@/account'; +import { defaultStore } from '@/store'; const props = defineProps<{ src: string; diff --git a/packages/frontend/src/components/MkToast.vue b/packages/frontend/src/components/MkToast.vue index 6d59702569..ad53c7f289 100644 --- a/packages/frontend/src/components/MkToast.vue +++ b/packages/frontend/src/components/MkToast.vue @@ -1,10 +1,10 @@ <template> <div> <Transition - :enter-active-class="$store.state.animation ? $style.transition_toast_enterActive : ''" - :leave-active-class="$store.state.animation ? $style.transition_toast_leaveActive : ''" - :enter-from-class="$store.state.animation ? $style.transition_toast_enterFrom : ''" - :leave-to-class="$store.state.animation ? $style.transition_toast_leaveTo : ''" + :enter-active-class="defaultStore.state.animation ? $style.transition_toast_enterActive : ''" + :leave-active-class="defaultStore.state.animation ? $style.transition_toast_leaveActive : ''" + :enter-from-class="defaultStore.state.animation ? $style.transition_toast_enterFrom : ''" + :leave-to-class="defaultStore.state.animation ? $style.transition_toast_leaveTo : ''" appear @after-leave="emit('closed')" > <div v-if="showing" class="_acrylic" :class="$style.root" :style="{ zIndex }"> @@ -19,6 +19,7 @@ <script lang="ts" setup> import { onMounted } from 'vue'; import * as os from '@/os'; +import { defaultStore } from '@/store'; defineProps<{ message: string; diff --git a/packages/frontend/src/components/MkTokenGenerateWindow.vue b/packages/frontend/src/components/MkTokenGenerateWindow.vue index 6035c20d23..56be044405 100644 --- a/packages/frontend/src/components/MkTokenGenerateWindow.vue +++ b/packages/frontend/src/components/MkTokenGenerateWindow.vue @@ -10,7 +10,7 @@ @closed="$emit('closed')" @ok="ok()" > - <template #header>{{ title || $ts.generateAccessToken }}</template> + <template #header>{{ title || i18n.ts.generateAccessToken }}</template> <MkSpacer :margin-min="20" :margin-max="28"> <div class="_gaps_m"> @@ -19,15 +19,15 @@ </div> <div> <MkInput v-model="name"> - <template #label>{{ $ts.name }}</template> + <template #label>{{ i18n.ts.name }}</template> </MkInput> </div> - <div><b>{{ $ts.permission }}</b></div> + <div><b>{{ i18n.ts.permission }}</b></div> <div class="_buttons"> <MkButton inline @click="disableAll">{{ i18n.ts.disableAll }}</MkButton> <MkButton inline @click="enableAll">{{ i18n.ts.enableAll }}</MkButton> </div> - <MkSwitch v-for="kind in (initialPermissions || kinds)" :key="kind" v-model="permissions[kind]">{{ $t(`_permissions.${kind}`) }}</MkSwitch> + <MkSwitch v-for="kind in (initialPermissions || kinds)" :key="kind" v-model="permissions[kind]">{{ i18n.t(`_permissions.${kind}`) }}</MkSwitch> </div> </MkSpacer> </MkModalWindow> diff --git a/packages/frontend/src/components/MkTooltip.vue b/packages/frontend/src/components/MkTooltip.vue index 0b0556de39..2d34b090ed 100644 --- a/packages/frontend/src/components/MkTooltip.vue +++ b/packages/frontend/src/components/MkTooltip.vue @@ -1,9 +1,9 @@ <template> <Transition - :enter-active-class="$store.state.animation ? $style.transition_tooltip_enterActive : ''" - :leave-active-class="$store.state.animation ? $style.transition_tooltip_leaveActive : ''" - :enter-from-class="$store.state.animation ? $style.transition_tooltip_enterFrom : ''" - :leave-to-class="$store.state.animation ? $style.transition_tooltip_leaveTo : ''" + :enter-active-class="defaultStore.state.animation ? $style.transition_tooltip_enterActive : ''" + :leave-active-class="defaultStore.state.animation ? $style.transition_tooltip_leaveActive : ''" + :enter-from-class="defaultStore.state.animation ? $style.transition_tooltip_enterFrom : ''" + :leave-to-class="defaultStore.state.animation ? $style.transition_tooltip_leaveTo : ''" appear @after-leave="emit('closed')" > <div v-show="showing" ref="el" :class="$style.root" class="_acrylic _shadow" :style="{ zIndex, maxWidth: maxWidth + 'px' }"> @@ -19,6 +19,7 @@ import { nextTick, onMounted, onUnmounted, shallowRef } from 'vue'; import * as os from '@/os'; import { calcPopupPosition } from '@/scripts/popup-position'; +import { defaultStore } from '@/store'; const props = withDefaults(defineProps<{ showing: boolean; diff --git a/packages/frontend/src/components/MkUrlPreview.vue b/packages/frontend/src/components/MkUrlPreview.vue index 094709e093..9c5622b1c5 100644 --- a/packages/frontend/src/components/MkUrlPreview.vue +++ b/packages/frontend/src/components/MkUrlPreview.vue @@ -23,7 +23,7 @@ </template> <template v-else-if="tweetId && tweetExpanded"> <div ref="twitter" :class="$style.twitter"> - <iframe ref="tweet" scrolling="no" frameborder="no" :style="{ position: 'relative', width: '100%', height: `${tweetHeight}px` }" :src="`https://platform.twitter.com/embed/index.html?embedId=${embedId}&hideCard=false&hideThread=false&lang=en&theme=${$store.state.darkMode ? 'dark' : 'light'}&id=${tweetId}`"></iframe> + <iframe ref="tweet" scrolling="no" frameborder="no" :style="{ position: 'relative', width: '100%', height: `${tweetHeight}px` }" :src="`https://platform.twitter.com/embed/index.html?embedId=${embedId}&hideCard=false&hideThread=false&lang=en&theme=${defaultStore.state.darkMode ? 'dark' : 'light'}&id=${tweetId}`"></iframe> </div> <div :class="$style.action"> <MkButton :small="true" inline @click="tweetExpanded = false"> @@ -77,6 +77,7 @@ import * as os from '@/os'; import { deviceKind } from '@/scripts/device-kind'; import MkButton from '@/components/MkButton.vue'; import { versatileLang } from '@/scripts/intl-const'; +import { defaultStore } from '@/store'; type SummalyResult = Awaited<ReturnType<typeof summaly>>; @@ -149,7 +150,7 @@ function adjustTweetHeight(message: any) { } const openPlayer = (): void => { - os.popup(defineAsyncComponent(() => import('@/components/MkYoutubePlayer.vue')), { + os.popup(defineAsyncComponent(() => import('@/components/MkYouTubePlayer.vue')), { url: requestUrl.href, }); }; diff --git a/packages/frontend/src/components/MkUrlPreviewPopup.vue b/packages/frontend/src/components/MkUrlPreviewPopup.vue index a0ad3c7fdd..e244be3e96 100644 --- a/packages/frontend/src/components/MkUrlPreviewPopup.vue +++ b/packages/frontend/src/components/MkUrlPreviewPopup.vue @@ -1,6 +1,6 @@ <template> <div class="fgmtyycl" :style="{ zIndex, top: top + 'px', left: left + 'px' }"> - <Transition :name="$store.state.animation ? '_transition_zoom' : ''" @after-leave="emit('closed')"> + <Transition :name="defaultStore.state.animation ? '_transition_zoom' : ''" @after-leave="emit('closed')"> <MkUrlPreview v-if="showing" class="_popup _shadow" :url="url"/> </Transition> </div> @@ -10,6 +10,7 @@ import { onMounted } from 'vue'; import MkUrlPreview from '@/components/MkUrlPreview.vue'; import * as os from '@/os'; +import { defaultStore } from '@/store'; const props = defineProps<{ showing: boolean; diff --git a/packages/frontend/src/components/MkUserInfo.vue b/packages/frontend/src/components/MkUserInfo.vue index 1486423b3d..5086c1b319 100644 --- a/packages/frontend/src/components/MkUserInfo.vue +++ b/packages/frontend/src/components/MkUserInfo.vue @@ -6,7 +6,7 @@ <MkA class="name" :to="userPage(user)"><MkUserName :user="user" :nowrap="false"/></MkA> <p class="username"><MkAcct :user="user"/></p> </div> - <span v-if="$i && $i.id !== user.id && user.isFollowed" class="followed">{{ $ts.followsYou }}</span> + <span v-if="$i && $i.id !== user.id && user.isFollowed" class="followed">{{ i18n.ts.followsYou }}</span> <div class="description"> <div v-if="user.description" class="mfm"> <Mfm :text="user.description" :author="user" :i="$i"/> @@ -33,6 +33,7 @@ import * as misskey from 'misskey-js'; import MkFollowButton from '@/components/MkFollowButton.vue'; import { userPage } from '@/filters/user'; import { i18n } from '@/i18n'; +import { $i } from '@/account'; defineProps<{ user: misskey.entities.UserDetailed; diff --git a/packages/frontend/src/components/MkUserPopup.vue b/packages/frontend/src/components/MkUserPopup.vue index 93e914f6dd..8ca0355448 100644 --- a/packages/frontend/src/components/MkUserPopup.vue +++ b/packages/frontend/src/components/MkUserPopup.vue @@ -1,15 +1,15 @@ <template> <Transition - :enter-active-class="$store.state.animation ? $style.transition_popup_enterActive : ''" - :leave-active-class="$store.state.animation ? $style.transition_popup_leaveActive : ''" - :enter-from-class="$store.state.animation ? $style.transition_popup_enterFrom : ''" - :leave-to-class="$store.state.animation ? $style.transition_popup_leaveTo : ''" + :enter-active-class="defaultStore.state.animation ? $style.transition_popup_enterActive : ''" + :leave-active-class="defaultStore.state.animation ? $style.transition_popup_leaveActive : ''" + :enter-from-class="defaultStore.state.animation ? $style.transition_popup_enterFrom : ''" + :leave-to-class="defaultStore.state.animation ? $style.transition_popup_leaveTo : ''" appear @after-leave="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(${user.bannerUrl})` : ''"> - <span v-if="$i && $i.id != user.id && user.isFollowed" :class="$style.followed">{{ $ts.followsYou }}</span> + <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"> <g transform="matrix(1.6,0,0,1.6,-38.4,-51.2)"> @@ -27,15 +27,15 @@ </div> <div :class="$style.status"> <div :class="$style.statusItem"> - <div :class="$style.statusItemLabel">{{ $ts.notes }}</div> + <div :class="$style.statusItemLabel">{{ i18n.ts.notes }}</div> <div>{{ number(user.notesCount) }}</div> </div> <div :class="$style.statusItem"> - <div :class="$style.statusItemLabel">{{ $ts.following }}</div> + <div :class="$style.statusItemLabel">{{ i18n.ts.following }}</div> <div>{{ number(user.followingCount) }}</div> </div> <div :class="$style.statusItem"> - <div :class="$style.statusItemLabel">{{ $ts.followers }}</div> + <div :class="$style.statusItemLabel">{{ i18n.ts.followers }}</div> <div>{{ number(user.followersCount) }}</div> </div> </div> @@ -59,6 +59,8 @@ import * as os from '@/os'; import { getUserMenu } from '@/scripts/get-user-menu'; import number from '@/filters/number'; import { i18n } from '@/i18n'; +import { defaultStore } from '@/store'; +import { $i } from '@/account'; const props = defineProps<{ showing: boolean; diff --git a/packages/frontend/src/components/MkVisibilityPicker.vue b/packages/frontend/src/components/MkVisibilityPicker.vue index 703c75c7d0..c181d84bc0 100644 --- a/packages/frontend/src/components/MkVisibilityPicker.vue +++ b/packages/frontend/src/components/MkVisibilityPicker.vue @@ -1,6 +1,9 @@ <template> -<MkModal ref="modal" :z-priority="'high'" :src="src" @click="modal.close()" @closed="emit('closed')"> - <div class="_popup" :class="$style.root"> +<MkModal ref="modal" v-slot="{ type }" :z-priority="'high'" :src="src" @click="modal.close()" @closed="emit('closed')"> + <div class="_popup" :class="{ [$style.root]: true, [$style.asDrawer]: type === 'drawer' }"> + <div :class="[$style.label, $style.item]"> + {{ i18n.ts.visibility }} + </div> <button key="public" class="_button" :class="[$style.item, { [$style.active]: v === 'public' }]" data-index="1" @click="choose('public')"> <div :class="$style.icon"><i class="ti ti-world"></i></div> <div :class="$style.body"> @@ -29,21 +32,12 @@ <span :class="$style.itemDescription">{{ i18n.ts._visibility.specifiedDescription }}</span> </div> </button> - <div :class="$style.divider"></div> - <button key="localOnly" class="_button" :class="[$style.item, $style.localOnly, { [$style.active]: localOnly }]" data-index="5" @click="localOnly = !localOnly"> - <div :class="$style.icon"><i class="ti ti-world-off"></i></div> - <div :class="$style.body"> - <span :class="$style.itemTitle">{{ i18n.ts._visibility.disableFederation }}</span> - <span :class="$style.itemDescription">{{ i18n.ts._visibility.disableFederationDescription }}</span> - </div> - <div :class="$style.toggle"><i :class="localOnly ? 'ti ti-toggle-right' : 'ti ti-toggle-left'"></i></div> - </button> </div> </MkModal> </template> <script lang="ts" setup> -import { nextTick, watch } from 'vue'; +import { nextTick } from 'vue'; import * as misskey from 'misskey-js'; import MkModal from '@/components/MkModal.vue'; import { i18n } from '@/i18n'; @@ -52,42 +46,58 @@ const modal = $shallowRef<InstanceType<typeof MkModal>>(); const props = withDefaults(defineProps<{ currentVisibility: typeof misskey.noteVisibilities[number]; - currentLocalOnly: boolean; + localOnly: boolean; src?: HTMLElement; }>(), { }); const emit = defineEmits<{ (ev: 'changeVisibility', v: typeof misskey.noteVisibilities[number]): void; - (ev: 'changeLocalOnly', v: boolean): void; (ev: 'closed'): void; }>(); let v = $ref(props.currentVisibility); -let localOnly = $ref(props.currentLocalOnly); - -watch($$(localOnly), () => { - emit('changeLocalOnly', localOnly); -}); function choose(visibility: typeof misskey.noteVisibilities[number]): void { v = visibility; emit('changeVisibility', visibility); nextTick(() => { - modal.close(); + if (modal) modal.close(); }); } </script> <style lang="scss" module> .root { - width: 240px; + min-width: 240px; padding: 8px 0; + + &.asDrawer { + padding: 12px 0 max(env(safe-area-inset-bottom, 0px), 12px) 0; + width: 100%; + border-radius: 24px; + border-bottom-right-radius: 0; + border-bottom-left-radius: 0; + + .label { + pointer-events: none; + font-size: 12px; + padding-bottom: 4px; + opacity: 0.7; + } + + .item { + font-size: 14px; + padding: 10px 24px; + } + } } -.divider { - margin: 8px 0; - border-top: solid 0.5px var(--divider); +.label { + pointer-events: none; + font-size: 10px; + padding-bottom: 4px; + opacity: 0.7; } .item { @@ -107,13 +117,7 @@ function choose(visibility: typeof misskey.noteVisibilities[number]): void { } &.active { - color: var(--fgOnAccent); - background: var(--accent); - } - - &.localOnly.active { color: var(--accent); - background: inherit; } } @@ -144,16 +148,4 @@ function choose(visibility: typeof misskey.noteVisibilities[number]): void { .itemDescription { opacity: 0.6; } - -.toggle { - display: flex; - justify-content: center; - align-items: center; - margin-left: 10px; - width: 16px; - top: 0; - bottom: 0; - margin-top: auto; - margin-bottom: auto; -} </style> diff --git a/packages/frontend/src/components/MkWindow.vue b/packages/frontend/src/components/MkWindow.vue index e7ad2b9a43..687abed632 100644 --- a/packages/frontend/src/components/MkWindow.vue +++ b/packages/frontend/src/components/MkWindow.vue @@ -1,9 +1,9 @@ <template> <Transition - :enter-active-class="$store.state.animation ? $style.transition_window_enterActive : ''" - :leave-active-class="$store.state.animation ? $style.transition_window_leaveActive : ''" - :enter-from-class="$store.state.animation ? $style.transition_window_enterFrom : ''" - :leave-to-class="$store.state.animation ? $style.transition_window_leaveTo : ''" + :enter-active-class="defaultStore.state.animation ? $style.transition_window_enterActive : ''" + :leave-active-class="defaultStore.state.animation ? $style.transition_window_leaveActive : ''" + :enter-from-class="defaultStore.state.animation ? $style.transition_window_enterFrom : ''" + :leave-to-class="defaultStore.state.animation ? $style.transition_window_leaveTo : ''" appear @after-leave="$emit('closed')" > @@ -11,15 +11,21 @@ <div :class="$style.body" class="_shadow" @mousedown="onBodyMousedown" @keydown="onKeydown"> <div :class="[$style.header, { [$style.mini]: mini }]" @contextmenu.prevent.stop="onContextmenu"> <span :class="$style.headerLeft"> - <button v-for="button in buttonsLeft" v-tooltip="button.title" class="_button" :class="[$style.headerButton, { [$style.highlighted]: button.highlighted }]" @click="button.onClick"><i :class="button.icon"></i></button> + <template v-if="!minimized"> + <button v-for="button in buttonsLeft" v-tooltip="button.title" class="_button" :class="[$style.headerButton, { [$style.highlighted]: button.highlighted }]" @click="button.onClick"><i :class="button.icon"></i></button> + </template> </span> <span :class="$style.headerTitle" @mousedown.prevent="onHeaderMousedown" @touchstart.prevent="onHeaderMousedown"> <slot name="header"></slot> </span> <span :class="$style.headerRight"> - <button v-for="button in buttonsRight" v-tooltip="button.title" class="_button" :class="[$style.headerButton, { [$style.highlighted]: button.highlighted }]" @click="button.onClick"><i :class="button.icon"></i></button> + <template v-if="!minimized"> + <button v-for="button in buttonsRight" v-tooltip="button.title" class="_button" :class="[$style.headerButton, { [$style.highlighted]: button.highlighted }]" @click="button.onClick"><i :class="button.icon"></i></button> + </template> + <button v-if="canResize && minimized" v-tooltip="i18n.ts.windowRestore" class="_button" :class="$style.headerButton" @click="unMinimize()"><i class="ti ti-maximize"></i></button> + <button v-else-if="canResize && !maximized" v-tooltip="i18n.ts.windowMinimize" class="_button" :class="$style.headerButton" @click="minimize()"><i class="ti ti-minimize"></i></button> <button v-if="canResize && maximized" v-tooltip="i18n.ts.windowRestore" class="_button" :class="$style.headerButton" @click="unMaximize()"><i class="ti ti-picture-in-picture"></i></button> - <button v-else-if="canResize && !maximized" v-tooltip="i18n.ts.windowMaximize" class="_button" :class="$style.headerButton" @click="maximize()"><i class="ti ti-rectangle"></i></button> + <button v-else-if="canResize && !maximized && !minimized" v-tooltip="i18n.ts.windowMaximize" class="_button" :class="$style.headerButton" @click="maximize()"><i class="ti ti-rectangle"></i></button> <button v-if="closeButton" v-tooltip="i18n.ts.close" class="_button" :class="$style.headerButton" @click="close()"><i class="ti ti-x"></i></button> </span> </div> @@ -27,7 +33,7 @@ <slot></slot> </div> </div> - <template v-if="canResize"> + <template v-if="canResize && !minimized"> <div :class="$style.handleTop" @mousedown.prevent="onTopHandleMousedown"></div> <div :class="$style.handleRight" @mousedown.prevent="onRightHandleMousedown"></div> <div :class="$style.handleBottom" @mousedown.prevent="onBottomHandleMousedown"></div> @@ -47,6 +53,7 @@ import contains from '@/scripts/contains'; import * as os from '@/os'; import { MenuItem } from '@/types/menu'; import { i18n } from '@/i18n'; +import { defaultStore } from '@/store'; const minHeight = 50; const minWidth = 250; @@ -99,10 +106,11 @@ let rootEl = $shallowRef<HTMLElement | null>(); let showing = $ref(true); let beforeClickedAt = 0; let maximized = $ref(false); -let unMaximizedTop = ''; -let unMaximizedLeft = ''; -let unMaximizedWidth = ''; -let unMaximizedHeight = ''; +let minimized = $ref(false); +let unResizedTop = ''; +let unResizedLeft = ''; +let unResizedWidth = ''; +let unResizedHeight = ''; function close() { showing = false; @@ -131,10 +139,10 @@ function top() { function maximize() { maximized = true; - unMaximizedTop = rootEl.style.top; - unMaximizedLeft = rootEl.style.left; - unMaximizedWidth = rootEl.style.width; - unMaximizedHeight = rootEl.style.height; + unResizedTop = rootEl.style.top; + unResizedLeft = rootEl.style.left; + unResizedWidth = rootEl.style.width; + unResizedHeight = rootEl.style.height; rootEl.style.top = '0'; rootEl.style.left = '0'; rootEl.style.width = '100%'; @@ -143,10 +151,35 @@ function maximize() { function unMaximize() { maximized = false; - rootEl.style.top = unMaximizedTop; - rootEl.style.left = unMaximizedLeft; - rootEl.style.width = unMaximizedWidth; - rootEl.style.height = unMaximizedHeight; + rootEl.style.top = unResizedTop; + rootEl.style.left = unResizedLeft; + rootEl.style.width = unResizedWidth; + rootEl.style.height = unResizedHeight; +} + +function minimize() { + minimized = true; + unResizedWidth = rootEl.style.width; + unResizedHeight = rootEl.style.height; + rootEl.style.width = minWidth + 'px'; + rootEl.style.height = props.mini ? '32px' : '39px'; +} + +function unMinimize() { + const main = rootEl; + if (main == null) return; + + minimized = false; + rootEl.style.width = unResizedWidth; + rootEl.style.height = unResizedHeight; + const browserWidth = window.innerWidth; + const browserHeight = window.innerHeight; + const windowWidth = main.offsetWidth; + const windowHeight = main.offsetHeight; + + const position = main.getBoundingClientRect(); + if (position.top + windowHeight > browserHeight) main.style.top = browserHeight - windowHeight + 'px'; + if (position.left + windowWidth > browserWidth) main.style.left = browserWidth - windowWidth + 'px'; } function onBodyMousedown() { @@ -154,7 +187,11 @@ function onBodyMousedown() { } function onDblClick() { - maximize(); + if (minimized) { + unMinimize(); + } else { + maximize(); + } } function onHeaderMousedown(evt: MouseEvent) { @@ -186,7 +223,7 @@ function onHeaderMousedown(evt: MouseEvent) { const clickX = evt.touches && evt.touches.length > 0 ? evt.touches[0].clientX : evt.clientX; const clickY = evt.touches && evt.touches.length > 0 ? evt.touches[0].clientY : evt.clientY; - const moveBaseX = beforeMaximized ? parseInt(unMaximizedWidth, 10) / 2 : clickX - position.left; // TODO: parseIntやめる + const moveBaseX = beforeMaximized ? parseInt(unResizedWidth, 10) / 2 : clickX - position.left; // TODO: parseIntやめる const moveBaseY = beforeMaximized ? 20 : clickY - position.top; const browserWidth = window.innerWidth; const browserHeight = window.innerHeight; diff --git a/packages/frontend/src/components/MkYoutubePlayer.vue b/packages/frontend/src/components/MkYouTubePlayer.vue index 460b038838..4d765fe2f7 100644 --- a/packages/frontend/src/components/MkYoutubePlayer.vue +++ b/packages/frontend/src/components/MkYouTubePlayer.vue @@ -6,7 +6,7 @@ </template> <div class="poamfof"> - <Transition :name="$store.state.animation ? 'fade' : ''" mode="out-in"> + <Transition :name="defaultStore.state.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="player.url + (player.url.match(/\?/) ? '&autoplay=1&auto_play=1' : '?autoplay=1&auto_play=1')" frameborder="0" allow="autoplay; encrypted-media" allowfullscreen/> </div> @@ -21,6 +21,7 @@ <script lang="ts" setup> import MkWindow from '@/components/MkWindow.vue'; import { versatileLang } from '@/scripts/intl-const'; +import { defaultStore } from '@/store'; const props = defineProps<{ url: string; diff --git a/packages/frontend/src/components/form/suspense.vue b/packages/frontend/src/components/form/suspense.vue index 936e12aa7b..3a44c3da3d 100644 --- a/packages/frontend/src/components/form/suspense.vue +++ b/packages/frontend/src/components/form/suspense.vue @@ -1,5 +1,5 @@ <template> -<Transition :name="$store.state.animation ? 'fade' : ''" mode="out-in"> +<Transition :name="defaultStore.state.animation ? 'fade' : ''" mode="out-in"> <div v-if="pending"> <MkLoading/> </div> @@ -8,8 +8,8 @@ </div> <div v-else> <div class="wszdbhzo"> - <div><i class="ti ti-alert-triangle"></i> {{ $ts.somethingHappened }}</div> - <MkButton inline class="retry" @click="retry"><i class="ti ti-reload"></i> {{ $ts.retry }}</MkButton> + <div><i class="ti ti-alert-triangle"></i> {{ i18n.ts.somethingHappened }}</div> + <MkButton inline class="retry" @click="retry"><i class="ti ti-reload"></i> {{ i18n.ts.retry }}</MkButton> </div> </div> </Transition> @@ -18,6 +18,8 @@ <script lang="ts"> import { defineComponent, PropType, ref, watch } from 'vue'; import MkButton from '@/components/MkButton.vue'; +import { defaultStore } from '@/store'; +import { i18n } from '@/i18n'; export default defineComponent({ components: { @@ -72,6 +74,8 @@ export default defineComponent({ rejected, result, retry, + defaultStore, + i18n, }; }, }); diff --git a/packages/frontend/src/components/global/MkA.stories.impl.ts b/packages/frontend/src/components/global/MkA.stories.impl.ts new file mode 100644 index 0000000000..639ed19af2 --- /dev/null +++ b/packages/frontend/src/components/global/MkA.stories.impl.ts @@ -0,0 +1,47 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import { expect } from '@storybook/jest'; +import { userEvent, within } from '@storybook/testing-library'; +import { StoryObj } from '@storybook/vue3'; +import MkA from './MkA.vue'; +import { tick } from '@/scripts/test-utils'; +export const Default = { + render(args) { + return { + components: { + MkA, + }, + setup() { + return { + args, + }; + }, + computed: { + props() { + return { + ...this.args, + }; + }, + }, + template: '<MkA v-bind="props">Misskey</MkA>', + }; + }, + async play({ canvasElement }) { + const canvas = within(canvasElement); + const a = canvas.getByRole<HTMLAnchorElement>('link'); + await expect(a.href).toMatch(/^https?:\/\/.*#test$/); + await userEvent.click(a, { button: 2 }); + await tick(); + const menu = canvas.getByRole('menu'); + await expect(menu).toBeInTheDocument(); + await userEvent.click(a, { button: 0 }); + a.blur(); + await tick(); + await expect(menu).not.toBeInTheDocument(); + }, + args: { + to: '#test', + }, + parameters: { + layout: 'centered', + }, +} satisfies StoryObj<typeof MkA>; diff --git a/packages/frontend/src/components/global/MkAcct.stories.impl.ts b/packages/frontend/src/components/global/MkAcct.stories.impl.ts new file mode 100644 index 0000000000..d5e3fc3568 --- /dev/null +++ b/packages/frontend/src/components/global/MkAcct.stories.impl.ts @@ -0,0 +1,43 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import { StoryObj } from '@storybook/vue3'; +import { userDetailed } from '../../../.storybook/fakes'; +import MkAcct from './MkAcct.vue'; +export const Default = { + render(args) { + return { + components: { + MkAcct, + }, + setup() { + return { + args, + }; + }, + computed: { + props() { + return { + ...this.args, + }; + }, + }, + template: '<MkAcct v-bind="props" />', + }; + }, + args: { + user: { + ...userDetailed(), + host: null, + }, + }, + parameters: { + layout: 'centered', + }, +} satisfies StoryObj<typeof MkAcct>; +export const Detail = { + ...Default, + args: { + ...Default.args, + user: userDetailed(), + detail: true, + }, +} satisfies StoryObj<typeof MkAcct>; diff --git a/packages/frontend/src/components/global/MkAcct.vue b/packages/frontend/src/components/global/MkAcct.vue index 2a43ded9e1..2b9f892fc6 100644 --- a/packages/frontend/src/components/global/MkAcct.vue +++ b/packages/frontend/src/components/global/MkAcct.vue @@ -1,7 +1,7 @@ <template> <span> <span>@{{ user.username }}</span> - <span v-if="user.host || detail || $store.state.showFullAcct" style="opacity: 0.5;">@{{ user.host || host }}</span> + <span v-if="user.host || detail || defaultStore.state.showFullAcct" style="opacity: 0.5;">@{{ user.host || host }}</span> </span> </template> @@ -9,6 +9,7 @@ import * as misskey from 'misskey-js'; import { toUnicode } from 'punycode/'; import { host as hostRaw } from '@/config'; +import { defaultStore } from '@/store'; defineProps<{ user: misskey.entities.UserDetailed; @@ -17,4 +18,3 @@ defineProps<{ const host = toUnicode(hostRaw); </script> - diff --git a/packages/frontend/src/components/global/MkAd.stories.impl.ts b/packages/frontend/src/components/global/MkAd.stories.impl.ts new file mode 100644 index 0000000000..7d8a42a03c --- /dev/null +++ b/packages/frontend/src/components/global/MkAd.stories.impl.ts @@ -0,0 +1,120 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import { expect } from '@storybook/jest'; +import { userEvent, within } from '@storybook/testing-library'; +import { StoryObj } from '@storybook/vue3'; +import { i18n } from '@/i18n'; +import MkAd from './MkAd.vue'; +const common = { + render(args) { + return { + components: { + MkAd, + }, + setup() { + return { + args, + }; + }, + computed: { + props() { + return { + ...this.args, + }; + }, + }, + template: '<MkAd v-bind="props" />', + }; + }, + async play({ canvasElement, args }) { + const canvas = within(canvasElement); + const a = canvas.getByRole<HTMLAnchorElement>('link'); + await expect(a.href).toMatch(/^https?:\/\/.*#test$/); + const img = within(a).getByRole('img'); + await expect(img).toBeInTheDocument(); + let buttons = canvas.getAllByRole<HTMLButtonElement>('button'); + await expect(buttons).toHaveLength(1); + const i = buttons[0]; + await expect(i).toBeInTheDocument(); + await userEvent.click(i); + await expect(a).not.toBeInTheDocument(); + await expect(i).not.toBeInTheDocument(); + buttons = canvas.getAllByRole<HTMLButtonElement>('button'); + await expect(buttons).toHaveLength(args.__hasReduce ? 2 : 1); + const reduce = args.__hasReduce ? buttons[0] : null; + const back = buttons[args.__hasReduce ? 1 : 0]; + if (reduce) { + await expect(reduce).toBeInTheDocument(); + await expect(reduce).toHaveTextContent(i18n.ts._ad.reduceFrequencyOfThisAd); + } + await expect(back).toBeInTheDocument(); + await expect(back).toHaveTextContent(i18n.ts._ad.back); + await userEvent.click(back); + if (reduce) { + await expect(reduce).not.toBeInTheDocument(); + } + await expect(back).not.toBeInTheDocument(); + const aAgain = canvas.getByRole<HTMLAnchorElement>('link'); + await expect(aAgain).toBeInTheDocument(); + const imgAgain = within(aAgain).getByRole('img'); + await expect(imgAgain).toBeInTheDocument(); + }, + args: { + prefer: [], + specify: { + id: 'someadid', + radio: 1, + url: '#test', + }, + __hasReduce: true, + }, + parameters: { + layout: 'centered', + }, +} satisfies StoryObj<typeof MkAd>; +export const Square = { + ...common, + args: { + ...common.args, + specify: { + ...common.args.specify, + place: 'square', + imageUrl: + 'https://github.com/misskey-dev/misskey/blob/master/packages/frontend/assets/about-icon.png?raw=true', + }, + }, +} satisfies StoryObj<typeof MkAd>; +export const Horizontal = { + ...common, + args: { + ...common.args, + specify: { + ...common.args.specify, + place: 'horizontal', + imageUrl: + 'https://github.com/misskey-dev/misskey/blob/master/packages/frontend/assets/fedi.jpg?raw=true', + }, + }, +} satisfies StoryObj<typeof MkAd>; +export const HorizontalBig = { + ...common, + args: { + ...common.args, + specify: { + ...common.args.specify, + place: 'horizontal-big', + imageUrl: + 'https://github.com/misskey-dev/misskey/blob/master/packages/frontend/assets/fedi.jpg?raw=true', + }, + }, +} satisfies StoryObj<typeof MkAd>; +export const ZeroRatio = { + ...Square, + args: { + ...Square.args, + specify: { + ...Square.args.specify, + ratio: 0, + }, + __hasReduce: false, + }, +} satisfies StoryObj<typeof MkAd>; diff --git a/packages/frontend/src/components/global/MkAd.vue b/packages/frontend/src/components/global/MkAd.vue index e0304c8bc5..5799f99d5f 100644 --- a/packages/frontend/src/components/global/MkAd.vue +++ b/packages/frontend/src/components/global/MkAd.vue @@ -9,9 +9,9 @@ <div v-else :class="$style.menu"> <div :class="$style.menuContainer"> <div>Ads by {{ host }}</div> - <!--<MkButton class="button" primary>{{ $ts._ad.like }}</MkButton>--> - <MkButton v-if="chosen.ratio !== 0" :class="$style.menuButton" @click="reduceFrequency">{{ $ts._ad.reduceFrequencyOfThisAd }}</MkButton> - <button class="_textButton" @click="toggleMenu">{{ $ts._ad.back }}</button> + <!--<MkButton class="button" primary>{{ i18n.ts._ad.like }}</MkButton>--> + <MkButton v-if="chosen.ratio !== 0" :class="$style.menuButton" @click="reduceFrequency">{{ i18n.ts._ad.reduceFrequencyOfThisAd }}</MkButton> + <button class="_textButton" @click="toggleMenu">{{ i18n.ts._ad.back }}</button> </div> </div> </div> @@ -20,6 +20,7 @@ <script lang="ts" setup> import { ref } from 'vue'; +import { i18n } from '@/i18n'; import { instance } from '@/instance'; import { host } from '@/config'; import MkButton from '@/components/MkButton.vue'; diff --git a/packages/frontend/src/components/global/MkAvatar.stories.impl.ts b/packages/frontend/src/components/global/MkAvatar.stories.impl.ts new file mode 100644 index 0000000000..3c69c80825 --- /dev/null +++ b/packages/frontend/src/components/global/MkAvatar.stories.impl.ts @@ -0,0 +1,66 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import { StoryObj } from '@storybook/vue3'; +import { userDetailed } from '../../../.storybook/fakes'; +import MkAvatar from './MkAvatar.vue'; +const common = { + render(args) { + return { + components: { + MkAvatar, + }, + setup() { + return { + args, + }; + }, + computed: { + props() { + return { + ...this.args, + }; + }, + }, + template: '<MkAvatar v-bind="props" />', + }; + }, + args: { + user: userDetailed(), + }, + decorators: [ + (Story, context) => ({ + // eslint-disable-next-line quotes + template: `<div :style="{ display: 'grid', width: '${context.args.size}px', height: '${context.args.size}px' }"><story/></div>`, + }), + ], + parameters: { + layout: 'centered', + }, +} satisfies StoryObj<typeof MkAvatar>; +export const ProfilePage = { + ...common, + args: { + ...common.args, + size: 120, + indicator: true, + }, +} satisfies StoryObj<typeof MkAvatar>; +export const ProfilePageCat = { + ...ProfilePage, + args: { + ...ProfilePage.args, + user: { + ...userDetailed(), + isCat: true, + }, + }, + parameters: { + ...ProfilePage.parameters, + chromatic: { + /* Your story couldn’t be captured because it exceeds our 25,000,000px limit. Its dimensions are 5,504,893x5,504,892px. Possible ways to resolve: + * * Separate pages into components + * * Minimize the number of very large elements in a story + */ + disableSnapshot: true, + }, + }, +} satisfies StoryObj<typeof MkAvatar>; diff --git a/packages/frontend/src/components/global/MkAvatar.vue b/packages/frontend/src/components/global/MkAvatar.vue index 814ab53d27..8497b8443b 100644 --- a/packages/frontend/src/components/global/MkAvatar.vue +++ b/packages/frontend/src/components/global/MkAvatar.vue @@ -1,15 +1,19 @@ <template> -<component :is="link ? MkA : 'span'" v-user-preview="preview ? user.id : undefined" v-bind="bound" class="_noSelect" :class="[$style.root, { [$style.cat]: user.isCat, [$style.square]: squareAvatars }]" :style="{ color }" :title="acct(user)" @click="onClick"> +<component :is="link ? MkA : 'span'" v-user-preview="preview ? user.id : undefined" v-bind="bound" class="_noSelect" :class="[$style.root, { [$style.animation]: animation, [$style.cat]: user.isCat, [$style.square]: squareAvatars }]" :style="{ color }" :title="acct(user)" @click="onClick"> <img :class="$style.inner" :src="url" decoding="async"/> <MkUserOnlineIndicator v-if="indicator" :class="$style.indicator" :user="user"/> <div v-if="user.isCat" :class="[$style.ears, { [$style.mask]: useBlurEffect }]"> <div :class="$style.earLeft"> - <div v-if="useBlurEffect" :class="$style.layer"> + <div v-if="false" :class="$style.layer"> + <div :class="$style.plot" :style="{ backgroundImage: `url(${JSON.stringify(url)})` }"/> + <div :class="$style.plot" :style="{ backgroundImage: `url(${JSON.stringify(url)})` }"/> <div :class="$style.plot" :style="{ backgroundImage: `url(${JSON.stringify(url)})` }"/> </div> </div> <div :class="$style.earRight"> - <div v-if="useBlurEffect" :class="$style.layer"> + <div v-if="false" :class="$style.layer"> + <div :class="$style.plot" :style="{ backgroundImage: `url(${JSON.stringify(url)})` }"/> + <div :class="$style.plot" :style="{ backgroundImage: `url(${JSON.stringify(url)})` }"/> <div :class="$style.plot" :style="{ backgroundImage: `url(${JSON.stringify(url)})` }"/> </div> </div> @@ -27,6 +31,7 @@ import { acct, userPage } from '@/filters/user'; import MkUserOnlineIndicator from '@/components/MkUserOnlineIndicator.vue'; import { defaultStore } from '@/store'; +const animation = $ref(defaultStore.state.animation); const squareAvatars = $ref(defaultStore.state.squareAvatars); const useBlurEffect = $ref(defaultStore.state.useBlurEffect); @@ -86,6 +91,18 @@ watch(() => props.user.avatarBlurhash, () => { to { transform: rotate(-37.6deg) skew(-30deg); } } +@keyframes eartightleft { + from { transform: rotate(37.6deg) skew(30deg); } + 50% { transform: rotate(37.4deg) skew(30deg); } + to { transform: rotate(37.6deg) skew(30deg); } +} + +@keyframes eartightright { + from { transform: rotate(-37.6deg) skew(-30deg); } + 50% { transform: rotate(-37.4deg) skew(-30deg); } + to { transform: rotate(-37.6deg) skew(-30deg); } +} + .root { position: relative; display: inline-block; @@ -135,6 +152,7 @@ watch(() => props.user.avatarBlurhash, () => { width: 100%; height: 100%; padding: 50%; + pointer-events: none; &.mask { -webkit-mask: @@ -144,6 +162,14 @@ watch(() => props.user.avatarBlurhash, () => { mask: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><filter id="a"><feGaussianBlur in="SourceGraphic" stdDeviation="1"/></filter><circle cx="16" cy="16" r="15" filter="url(%23a)"/></svg>') exclude center / 50% 50%, linear-gradient(#fff, #fff); // polyfill of `image(#fff)` + + > .earLeft { + animation: eartightleft 6s infinite; + } + + > .earRight { + animation: eartightright 6s infinite; + } } > .earLeft, @@ -173,11 +199,21 @@ watch(() => props.user.avatarBlurhash, () => { > .plot { contain: strict; + position: absolute; width: 100%; height: 100%; clip-path: path('M0 0H1V1H0z'); transform: scale(32767); transform-origin: 0 0; + opacity: 0.5; + + &:first-child { + opacity: 1; + } + + &:last-child { + opacity: calc(1 / 3); + } } } } @@ -199,6 +235,14 @@ watch(() => props.user.avatarBlurhash, () => { > .plot { background-position: 20% 10%; /* ~= 37.5deg */ + + &:first-child { + background-position-x: 21%; + } + + &:last-child { + background-position-y: 11%; + } } } } @@ -219,13 +263,22 @@ watch(() => props.user.avatarBlurhash, () => { -38.5857864376%); /* 40 - 2 * sqrt(2) */ > .plot { + position: absolute; background-position: 80% 10%; /* ~= 37.5deg */ + + &:first-child { + background-position-x: 79%; + } + + &:last-child { + background-position-y: 11%; + } } } } } - &:hover { + &.animation:hover { > .ears { > .earLeft { animation: earwiggleleft 1s infinite; diff --git a/packages/frontend/src/components/global/MkCustomEmoji.stories.impl.ts b/packages/frontend/src/components/global/MkCustomEmoji.stories.impl.ts new file mode 100644 index 0000000000..36ab85b579 --- /dev/null +++ b/packages/frontend/src/components/global/MkCustomEmoji.stories.impl.ts @@ -0,0 +1,45 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import { StoryObj } from '@storybook/vue3'; +import MkCustomEmoji from './MkCustomEmoji.vue'; +export const Default = { + render(args) { + return { + components: { + MkCustomEmoji, + }, + setup() { + return { + args, + }; + }, + computed: { + props() { + return { + ...this.args, + }; + }, + }, + template: '<MkCustomEmoji v-bind="props" />', + }; + }, + args: { + name: 'mi', + url: 'https://github.com/misskey-dev/misskey/blob/master/packages/frontend/assets/about-icon.png?raw=true', + }, + parameters: { + layout: 'centered', + }, +} satisfies StoryObj<typeof MkCustomEmoji>; +export const Normal = { + ...Default, + args: { + ...Default.args, + normal: true, + }, +} satisfies StoryObj<typeof MkCustomEmoji>; +export const Missing = { + ...Default, + args: { + name: Default.args.name, + }, +} satisfies StoryObj<typeof MkCustomEmoji>; diff --git a/packages/frontend/src/components/global/MkEllipsis.stories.impl.ts b/packages/frontend/src/components/global/MkEllipsis.stories.impl.ts new file mode 100644 index 0000000000..65405a9bc8 --- /dev/null +++ b/packages/frontend/src/components/global/MkEllipsis.stories.impl.ts @@ -0,0 +1,32 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import { StoryObj } from '@storybook/vue3'; +import isChromatic from 'chromatic/isChromatic'; +import MkEllipsis from './MkEllipsis.vue'; +export const Default = { + render(args) { + return { + components: { + MkEllipsis, + }, + setup() { + return { + args, + }; + }, + computed: { + props() { + return { + ...this.args, + }; + }, + }, + template: '<MkEllipsis v-bind="props" />', + }; + }, + args: { + static: isChromatic(), + }, + parameters: { + layout: 'centered', + }, +} satisfies StoryObj<typeof MkEllipsis>; diff --git a/packages/frontend/src/components/global/MkEllipsis.vue b/packages/frontend/src/components/global/MkEllipsis.vue index b3cf69c075..c8f6cd3394 100644 --- a/packages/frontend/src/components/global/MkEllipsis.vue +++ b/packages/frontend/src/components/global/MkEllipsis.vue @@ -1,9 +1,19 @@ <template> -<span :class="$style.root"> +<span :class="[$style.root, { [$style.static]: static }]"> <span :class="$style.dot">.</span><span :class="$style.dot">.</span><span :class="$style.dot">.</span> </span> </template> +<script lang="ts" setup> +import { } from 'vue'; + +const props = withDefaults(defineProps<{ + static?: boolean; +}>(), { + static: false, +}); +</script> + <style lang="scss" module> @keyframes ellipsis { 0%, 80%, 100% { @@ -15,7 +25,9 @@ } .root { - + &.static > .dot { + animation-play-state: paused; + } } .dot { diff --git a/packages/frontend/src/components/global/MkEmoji.stories.impl.ts b/packages/frontend/src/components/global/MkEmoji.stories.impl.ts new file mode 100644 index 0000000000..f9900375f7 --- /dev/null +++ b/packages/frontend/src/components/global/MkEmoji.stories.impl.ts @@ -0,0 +1,31 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import { StoryObj } from '@storybook/vue3'; +import MkEmoji from './MkEmoji.vue'; +export const Default = { + render(args) { + return { + components: { + MkEmoji, + }, + setup() { + return { + args, + }; + }, + computed: { + props() { + return { + ...this.args, + }; + }, + }, + template: '<MkEmoji v-bind="props" />', + }; + }, + args: { + emoji: '❤', + }, + parameters: { + layout: 'centered', + }, +} satisfies StoryObj<typeof MkEmoji>; diff --git a/packages/frontend/src/components/global/MkError.stories.impl.ts b/packages/frontend/src/components/global/MkError.stories.impl.ts new file mode 100644 index 0000000000..60ac5c91ad --- /dev/null +++ b/packages/frontend/src/components/global/MkError.stories.impl.ts @@ -0,0 +1,34 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import { expect } from '@storybook/jest'; +import { waitFor } from '@storybook/testing-library'; +import { StoryObj } from '@storybook/vue3'; +import MkError from './MkError.vue'; +export const Default = { + render(args) { + return { + components: { + MkError, + }, + setup() { + return { + args, + }; + }, + computed: { + props() { + return { + ...this.args, + }; + }, + }, + template: '<MkError v-bind="props" />', + }; + }, + async play({ canvasElement }) { + await expect(canvasElement.firstElementChild).not.toBeNull(); + await waitFor(async () => expect(canvasElement.firstElementChild?.classList).not.toContain('_transition_zoom-enter-active')); + }, + parameters: { + layout: 'centered', + }, +} satisfies StoryObj<typeof MkError>; diff --git a/packages/frontend/src/components/global/MkError.stories.meta.ts b/packages/frontend/src/components/global/MkError.stories.meta.ts new file mode 100644 index 0000000000..51d763ada7 --- /dev/null +++ b/packages/frontend/src/components/global/MkError.stories.meta.ts @@ -0,0 +1,5 @@ +export const argTypes = { + retry: { + action: 'retry', + }, +}; diff --git a/packages/frontend/src/components/global/MkError.vue b/packages/frontend/src/components/global/MkError.vue index 7390a9dfb9..513ef21d35 100644 --- a/packages/frontend/src/components/global/MkError.vue +++ b/packages/frontend/src/components/global/MkError.vue @@ -1,5 +1,5 @@ <template> -<Transition :name="$store.state.animation ? '_transition_zoom' : ''" appear> +<Transition :name="defaultStore.state.animation ? '_transition_zoom' : ''" appear> <div :class="$style.root"> <img :class="$style.img" src="https://xn--931a.moe/assets/error.jpg" class="_ghost"/> <p :class="$style.text"><i class="ti ti-alert-triangle"></i> {{ i18n.ts.somethingHappened }}</p> @@ -11,6 +11,7 @@ <script lang="ts" setup> import MkButton from '@/components/MkButton.vue'; import { i18n } from '@/i18n'; +import { defaultStore } from '@/store'; const emit = defineEmits<{ (ev: 'retry'): void; diff --git a/packages/frontend/src/components/global/MkLoading.stories.impl.ts b/packages/frontend/src/components/global/MkLoading.stories.impl.ts new file mode 100644 index 0000000000..9dcc0cdea1 --- /dev/null +++ b/packages/frontend/src/components/global/MkLoading.stories.impl.ts @@ -0,0 +1,60 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import { StoryObj } from '@storybook/vue3'; +import isChromatic from 'chromatic/isChromatic'; +import MkLoading from './MkLoading.vue'; +export const Default = { + render(args) { + return { + components: { + MkLoading, + }, + setup() { + return { + args, + }; + }, + computed: { + props() { + return { + ...this.args, + }; + }, + }, + template: '<MkLoading v-bind="props" />', + }; + }, + args: { + static: isChromatic(), + }, + parameters: { + layout: 'centered', + }, +} satisfies StoryObj<typeof MkLoading>; +export const Inline = { + ...Default, + args: { + ...Default.args, + inline: true, + }, +} satisfies StoryObj<typeof MkLoading>; +export const Colored = { + ...Default, + args: { + ...Default.args, + colored: true, + }, +} satisfies StoryObj<typeof MkLoading>; +export const Mini = { + ...Default, + args: { + ...Default.args, + mini: true, + }, +} satisfies StoryObj<typeof MkLoading>; +export const Em = { + ...Default, + args: { + ...Default.args, + em: true, + }, +} satisfies StoryObj<typeof MkLoading>; diff --git a/packages/frontend/src/components/global/MkLoading.vue b/packages/frontend/src/components/global/MkLoading.vue index 64e12e3b44..4311f9fe8a 100644 --- a/packages/frontend/src/components/global/MkLoading.vue +++ b/packages/frontend/src/components/global/MkLoading.vue @@ -6,7 +6,7 @@ <circle cx="64" cy="64" r="64" style="fill:none;stroke:currentColor;stroke-width:21.33px;"/> </g> </svg> - <svg :class="[$style.spinner, $style.fg]" viewBox="0 0 168 168" xmlns="http://www.w3.org/2000/svg"> + <svg :class="[$style.spinner, $style.fg, { [$style.static]: static }]" viewBox="0 0 168 168" xmlns="http://www.w3.org/2000/svg"> <g transform="matrix(1.125,0,0,1.125,12,12)"> <path d="M128,64C128,28.654 99.346,0 64,0C99.346,0 128,28.654 128,64Z" style="fill:none;stroke:currentColor;stroke-width:21.33px;"/> </g> @@ -19,11 +19,13 @@ import { } from 'vue'; const props = withDefaults(defineProps<{ + static?: boolean; inline?: boolean; colored?: boolean; mini?: boolean; em?: boolean; }>(), { + static: false, inline: false, colored: true, mini: false, @@ -97,5 +99,9 @@ const props = withDefaults(defineProps<{ .fg { animation: spinner 0.5s linear infinite; + + &.static { + animation-play-state: paused; + } } </style> diff --git a/packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.stories.impl.ts b/packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.stories.impl.ts new file mode 100644 index 0000000000..f6811b6747 --- /dev/null +++ b/packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.stories.impl.ts @@ -0,0 +1,74 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import { StoryObj } from '@storybook/vue3'; +import MkMisskeyFlavoredMarkdown from './MkMisskeyFlavoredMarkdown.vue'; +import { within } from '@storybook/testing-library'; +import { expect } from '@storybook/jest'; +export const Default = { + render(args) { + return { + components: { + MkMisskeyFlavoredMarkdown, + }, + setup() { + return { + args, + }; + }, + computed: { + props() { + return { + ...this.args, + }; + }, + }, + template: '<MkMisskeyFlavoredMarkdown v-bind="props" />', + }; + }, + async play({ canvasElement, args }) { + const canvas = within(canvasElement); + if (args.plain) { + const aiHelloMiskist = canvas.getByText('@ai *Hello*, #Miskist!'); + await expect(aiHelloMiskist).toBeInTheDocument(); + } else { + const ai = canvas.getByText('@ai'); + await expect(ai).toBeInTheDocument(); + await expect(ai.closest('a')).toHaveAttribute('href', '/@ai'); + const hello = canvas.getByText('Hello'); + await expect(hello).toBeInTheDocument(); + await expect(hello.style.fontStyle).toBe('oblique'); + const miskist = canvas.getByText('#Miskist'); + await expect(miskist).toBeInTheDocument(); + await expect(miskist).toHaveAttribute('href', args.isNote ?? true ? '/tags/Miskist' : '/user-tags/Miskist'); + } + const heart = canvas.getByAltText('❤'); + await expect(heart).toBeInTheDocument(); + await expect(heart).toHaveAttribute('src', '/twemoji/2764.svg'); + }, + args: { + text: '@ai *Hello*, #Miskist! ❤', + }, + parameters: { + layout: 'centered', + }, +} satisfies StoryObj<typeof MkMisskeyFlavoredMarkdown>; +export const Plain = { + ...Default, + args: { + ...Default.args, + plain: true, + }, +} satisfies StoryObj<typeof MkMisskeyFlavoredMarkdown>; +export const Nowrap = { + ...Default, + args: { + ...Default.args, + nowrap: true, + }, +} satisfies StoryObj<typeof MkMisskeyFlavoredMarkdown>; +export const IsNotNote = { + ...Default, + args: { + ...Default.args, + isNote: false, + }, +} satisfies StoryObj<typeof MkMisskeyFlavoredMarkdown>; diff --git a/packages/frontend/src/components/global/MkPageHeader.stories.impl.ts b/packages/frontend/src/components/global/MkPageHeader.stories.impl.ts new file mode 100644 index 0000000000..7485f3b82f --- /dev/null +++ b/packages/frontend/src/components/global/MkPageHeader.stories.impl.ts @@ -0,0 +1,99 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import { waitFor } from '@storybook/testing-library'; +import { StoryObj } from '@storybook/vue3'; +import MkPageHeader from './MkPageHeader.vue'; +export const Empty = { + render(args) { + return { + components: { + MkPageHeader, + }, + setup() { + return { + args, + }; + }, + computed: { + props() { + return { + ...this.args, + }; + }, + }, + template: '<MkPageHeader v-bind="props" />', + }; + }, + async play() { + const wait = new Promise((resolve) => setTimeout(resolve, 800)); + await waitFor(async () => await wait); + }, + args: { + static: true, + tabs: [], + }, + parameters: { + layout: 'centered', + }, +} satisfies StoryObj<typeof MkPageHeader>; +export const OneTab = { + ...Empty, + args: { + ...Empty.args, + tab: 'sometabkey', + tabs: [ + { + key: 'sometabkey', + title: 'Some Tab Title', + }, + ], + }, +} satisfies StoryObj<typeof MkPageHeader>; +export const Icon = { + ...OneTab, + args: { + ...OneTab.args, + tabs: [ + { + ...OneTab.args.tabs[0], + icon: 'ti ti-home', + }, + ], + }, +} satisfies StoryObj<typeof MkPageHeader>; +export const IconOnly = { + ...Icon, + args: { + ...Icon.args, + tabs: [ + { + ...Icon.args.tabs[0], + title: undefined, + iconOnly: true, + }, + ], + }, +} satisfies StoryObj<typeof MkPageHeader>; +export const SomeTabs = { + ...Empty, + args: { + ...Empty.args, + tab: 'princess', + tabs: [ + { + key: 'princess', + title: 'Princess', + icon: 'ti ti-crown', + }, + { + key: 'fairy', + title: 'Fairy', + icon: 'ti ti-snowflake', + }, + { + key: 'angel', + title: 'Angel', + icon: 'ti ti-feather', + }, + ], + }, +} satisfies StoryObj<typeof MkPageHeader>; diff --git a/packages/frontend/src/components/global/MkPageHeader.tabs.stories.impl.ts b/packages/frontend/src/components/global/MkPageHeader.tabs.stories.impl.ts new file mode 100644 index 0000000000..6d4460d593 --- /dev/null +++ b/packages/frontend/src/components/global/MkPageHeader.tabs.stories.impl.ts @@ -0,0 +1,3 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import MkPageHeader_tabs from './MkPageHeader.tabs.vue'; +void MkPageHeader_tabs; diff --git a/packages/frontend/src/components/global/MkPageHeader.tabs.vue b/packages/frontend/src/components/global/MkPageHeader.tabs.vue index 42760da08f..9e1da64e61 100644 --- a/packages/frontend/src/components/global/MkPageHeader.tabs.vue +++ b/packages/frontend/src/components/global/MkPageHeader.tabs.vue @@ -33,14 +33,18 @@ <script lang="ts"> export type Tab = { key: string; - title: string; - icon?: string; - iconOnly?: boolean; onClick?: (ev: MouseEvent) => void; -} & { - iconOnly: true; - iccn: string; -}; +} & ( + | { + iconOnly?: false; + title: string; + icon?: string; + } + | { + iconOnly: true; + icon: string; + } +); </script> <script lang="ts" setup> diff --git a/packages/frontend/src/components/global/MkPageHeader.vue b/packages/frontend/src/components/global/MkPageHeader.vue index 4d968db6a3..710edd797a 100644 --- a/packages/frontend/src/components/global/MkPageHeader.vue +++ b/packages/frontend/src/components/global/MkPageHeader.vue @@ -8,7 +8,9 @@ <template v-if="metadata"> <div v-if="!hideTitle" :class="$style.titleContainer" @click="top"> - <MkAvatar v-if="metadata.avatar" :class="$style.titleAvatar" :user="metadata.avatar" indicator/> + <div v-if="metadata.avatar" :class="$style.titleAvatarContainer"> + <MkAvatar :class="$style.titleAvatar" :user="metadata.avatar" indicator/> + </div> <i v-else-if="metadata.icon" :class="[$style.titleIcon, metadata.icon]"></i> <div :class="$style.title"> @@ -241,7 +243,7 @@ onUnmounted(() => { display: flex; align-items: center; max-width: min(30vw, 400px); - overflow: auto; + overflow: clip; white-space: nowrap; text-align: left; font-weight: bold; @@ -249,13 +251,19 @@ onUnmounted(() => { margin-left: 24px; } -.titleAvatar { +.titleAvatarContainer { $size: 32px; - display: inline-block; + contain: strict; + overflow: clip; width: $size; height: $size; - vertical-align: bottom; - margin: 0 8px; + padding: 8px; + flex-shrink: 0; +} + +.titleAvatar { + width: 100%; + height: 100%; pointer-events: none; } diff --git a/packages/frontend/src/components/global/MkStickyContainer.stories.impl.ts b/packages/frontend/src/components/global/MkStickyContainer.stories.impl.ts new file mode 100644 index 0000000000..97b8cc0c5b --- /dev/null +++ b/packages/frontend/src/components/global/MkStickyContainer.stories.impl.ts @@ -0,0 +1,3 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import MkStickyContainer from './MkStickyContainer.vue'; +void MkStickyContainer; diff --git a/packages/frontend/src/components/global/MkTime.stories.impl.ts b/packages/frontend/src/components/global/MkTime.stories.impl.ts new file mode 100644 index 0000000000..b72601b1ff --- /dev/null +++ b/packages/frontend/src/components/global/MkTime.stories.impl.ts @@ -0,0 +1,312 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import { expect } from '@storybook/jest'; +import { StoryObj } from '@storybook/vue3'; +import MkTime from './MkTime.vue'; +import { i18n } from '@/i18n'; +import { dateTimeFormat } from '@/scripts/intl-const'; +const now = new Date('2023-04-01T00:00:00.000Z'); +const future = new Date(8640000000000000); +const oneHourAgo = new Date(now.getTime() - 3600000); +const oneDayAgo = new Date(now.getTime() - 86400000); +const oneWeekAgo = new Date(now.getTime() - 604800000); +const oneMonthAgo = new Date(now.getTime() - 2592000000); +const oneYearAgo = new Date(now.getTime() - 31536000000); +export const Empty = { + render(args) { + return { + components: { + MkTime, + }, + setup() { + return { + args, + }; + }, + computed: { + props() { + return { + ...this.args, + }; + }, + }, + template: '<MkTime v-bind="props" />', + }; + }, + async play({ canvasElement }) { + await expect(canvasElement).toHaveTextContent(i18n.ts._ago.invalid); + }, + args: { + }, + parameters: { + layout: 'centered', + }, +} satisfies StoryObj<typeof MkTime>; +export const RelativeFuture = { + ...Empty, + async play({ canvasElement }) { + await expect(canvasElement).toHaveTextContent(i18n.ts._ago.future); + }, + args: { + ...Empty.args, + time: future, + }, +} satisfies StoryObj<typeof MkTime>; +export const AbsoluteFuture = { + ...Empty, + async play({ canvasElement, args }) { + await expect(canvasElement).toHaveTextContent(dateTimeFormat.format(args.time)); + }, + args: { + ...Empty.args, + time: future, + mode: 'absolute', + }, +} satisfies StoryObj<typeof MkTime>; +export const DetailFuture = { + ...Empty, + async play(context) { + await AbsoluteFuture.play(context); + await expect(context.canvasElement).toHaveTextContent(' ('); + await RelativeFuture.play(context); + await expect(context.canvasElement).toHaveTextContent(')'); + }, + args: { + ...Empty.args, + time: future, + mode: 'detail', + }, +} satisfies StoryObj<typeof MkTime>; +export const RelativeNow = { + ...Empty, + async play({ canvasElement }) { + await expect(canvasElement).toHaveTextContent(i18n.ts._ago.justNow); + }, + args: { + ...Empty.args, + time: now, + origin: now, + mode: 'relative', + }, +} satisfies StoryObj<typeof MkTime>; +export const AbsoluteNow = { + ...Empty, + async play({ canvasElement, args }) { + await expect(canvasElement).toHaveTextContent(dateTimeFormat.format(args.time)); + }, + args: { + ...Empty.args, + time: now, + origin: now, + mode: 'absolute', + }, +} satisfies StoryObj<typeof MkTime>; +export const DetailNow = { + ...Empty, + async play(context) { + await AbsoluteNow.play(context); + await expect(context.canvasElement).toHaveTextContent(' ('); + await RelativeNow.play(context); + await expect(context.canvasElement).toHaveTextContent(')'); + }, + args: { + ...Empty.args, + time: now, + origin: now, + mode: 'detail', + }, +} satisfies StoryObj<typeof MkTime>; +export const RelativeOneHourAgo = { + ...Empty, + async play({ canvasElement }) { + await expect(canvasElement).toHaveTextContent(i18n.t('_ago.hoursAgo', { n: 1 })); + }, + args: { + ...Empty.args, + time: oneHourAgo, + origin: now, + mode: 'relative', + }, +} satisfies StoryObj<typeof MkTime>; +export const AbsoluteOneHourAgo = { + ...Empty, + async play({ canvasElement, args }) { + await expect(canvasElement).toHaveTextContent(dateTimeFormat.format(args.time)); + }, + args: { + ...Empty.args, + time: oneHourAgo, + origin: now, + mode: 'absolute', + }, +} satisfies StoryObj<typeof MkTime>; +export const DetailOneHourAgo = { + ...Empty, + async play(context) { + await AbsoluteOneHourAgo.play(context); + await expect(context.canvasElement).toHaveTextContent(' ('); + await RelativeOneHourAgo.play(context); + await expect(context.canvasElement).toHaveTextContent(')'); + }, + args: { + ...Empty.args, + time: oneHourAgo, + origin: now, + mode: 'detail', + }, +} satisfies StoryObj<typeof MkTime>; +export const RelativeOneDayAgo = { + ...Empty, + async play({ canvasElement }) { + await expect(canvasElement).toHaveTextContent(i18n.t('_ago.daysAgo', { n: 1 })); + }, + args: { + ...Empty.args, + time: oneDayAgo, + origin: now, + mode: 'relative', + }, +} satisfies StoryObj<typeof MkTime>; +export const AbsoluteOneDayAgo = { + ...Empty, + async play({ canvasElement, args }) { + await expect(canvasElement).toHaveTextContent(dateTimeFormat.format(args.time)); + }, + args: { + ...Empty.args, + time: oneDayAgo, + origin: now, + mode: 'absolute', + }, +} satisfies StoryObj<typeof MkTime>; +export const DetailOneDayAgo = { + ...Empty, + async play(context) { + await AbsoluteOneDayAgo.play(context); + await expect(context.canvasElement).toHaveTextContent(' ('); + await RelativeOneDayAgo.play(context); + await expect(context.canvasElement).toHaveTextContent(')'); + }, + args: { + ...Empty.args, + time: oneDayAgo, + origin: now, + mode: 'detail', + }, +} satisfies StoryObj<typeof MkTime>; +export const RelativeOneWeekAgo = { + ...Empty, + async play({ canvasElement }) { + await expect(canvasElement).toHaveTextContent(i18n.t('_ago.weeksAgo', { n: 1 })); + }, + args: { + ...Empty.args, + time: oneWeekAgo, + origin: now, + mode: 'relative', + }, +} satisfies StoryObj<typeof MkTime>; +export const AbsoluteOneWeekAgo = { + ...Empty, + async play({ canvasElement, args }) { + await expect(canvasElement).toHaveTextContent(dateTimeFormat.format(args.time)); + }, + args: { + ...Empty.args, + time: oneWeekAgo, + origin: now, + mode: 'absolute', + }, +} satisfies StoryObj<typeof MkTime>; +export const DetailOneWeekAgo = { + ...Empty, + async play(context) { + await AbsoluteOneWeekAgo.play(context); + await expect(context.canvasElement).toHaveTextContent(' ('); + await RelativeOneWeekAgo.play(context); + await expect(context.canvasElement).toHaveTextContent(')'); + }, + args: { + ...Empty.args, + time: oneWeekAgo, + origin: now, + mode: 'detail', + }, +} satisfies StoryObj<typeof MkTime>; +export const RelativeOneMonthAgo = { + ...Empty, + async play({ canvasElement }) { + await expect(canvasElement).toHaveTextContent(i18n.t('_ago.monthsAgo', { n: 1 })); + }, + args: { + ...Empty.args, + time: oneMonthAgo, + origin: now, + mode: 'relative', + }, +} satisfies StoryObj<typeof MkTime>; +export const AbsoluteOneMonthAgo = { + ...Empty, + async play({ canvasElement, args }) { + await expect(canvasElement).toHaveTextContent(dateTimeFormat.format(args.time)); + }, + args: { + ...Empty.args, + time: oneMonthAgo, + origin: now, + mode: 'absolute', + }, +} satisfies StoryObj<typeof MkTime>; +export const DetailOneMonthAgo = { + ...Empty, + async play(context) { + await AbsoluteOneMonthAgo.play(context); + await expect(context.canvasElement).toHaveTextContent(' ('); + await RelativeOneMonthAgo.play(context); + await expect(context.canvasElement).toHaveTextContent(')'); + }, + args: { + ...Empty.args, + time: oneMonthAgo, + origin: now, + mode: 'detail', + }, +} satisfies StoryObj<typeof MkTime>; +export const RelativeOneYearAgo = { + ...Empty, + async play({ canvasElement }) { + await expect(canvasElement).toHaveTextContent(i18n.t('_ago.yearsAgo', { n: 1 })); + }, + args: { + ...Empty.args, + time: oneYearAgo, + origin: now, + mode: 'relative', + }, +} satisfies StoryObj<typeof MkTime>; +export const AbsoluteOneYearAgo = { + ...Empty, + async play({ canvasElement, args }) { + await expect(canvasElement).toHaveTextContent(dateTimeFormat.format(args.time)); + }, + args: { + ...Empty.args, + time: oneYearAgo, + origin: now, + mode: 'absolute', + }, +} satisfies StoryObj<typeof MkTime>; +export const DetailOneYearAgo = { + ...Empty, + async play(context) { + await AbsoluteOneYearAgo.play(context); + await expect(context.canvasElement).toHaveTextContent(' ('); + await RelativeOneYearAgo.play(context); + await expect(context.canvasElement).toHaveTextContent(')'); + }, + args: { + ...Empty.args, + time: oneYearAgo, + origin: now, + mode: 'detail', + }, +} satisfies StoryObj<typeof MkTime>; diff --git a/packages/frontend/src/components/global/MkTime.vue b/packages/frontend/src/components/global/MkTime.vue index 3fa8bb9adc..99169512db 100644 --- a/packages/frontend/src/components/global/MkTime.vue +++ b/packages/frontend/src/components/global/MkTime.vue @@ -14,8 +14,10 @@ import { dateTimeFormat } from '@/scripts/intl-const'; const props = withDefaults(defineProps<{ time: Date | string | number | null; + origin?: Date | null; mode?: 'relative' | 'absolute' | 'detail'; }>(), { + origin: null, mode: 'relative', }); @@ -25,7 +27,7 @@ const _time = props.time == null ? NaN : const invalid = Number.isNaN(_time); const absolute = !invalid ? dateTimeFormat.format(_time) : i18n.ts._ago.invalid; -let now = $ref((new Date()).getTime()); +let now = $ref((props.origin ?? new Date()).getTime()); const relative = $computed<string>(() => { if (props.mode === 'absolute') return ''; // absoluteではrelativeを使わないので計算しない if (invalid) return i18n.ts._ago.invalid; @@ -46,7 +48,7 @@ const relative = $computed<string>(() => { let tickId: number; function tick() { - now = (new Date()).getTime(); + now = props.origin ?? (new Date()).getTime(); const ago = (now - _time) / 1000/*ms*/; const next = ago < 60 ? 10000 : ago < 3600 ? 60000 : 180000; diff --git a/packages/frontend/src/components/global/MkUrl.stories.impl.ts b/packages/frontend/src/components/global/MkUrl.stories.impl.ts new file mode 100644 index 0000000000..c5875d4779 --- /dev/null +++ b/packages/frontend/src/components/global/MkUrl.stories.impl.ts @@ -0,0 +1,77 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import { expect } from '@storybook/jest'; +import { userEvent, waitFor, within } from '@storybook/testing-library'; +import { StoryObj } from '@storybook/vue3'; +import { rest } from 'msw'; +import { commonHandlers } from '../../../.storybook/mocks'; +import MkUrl from './MkUrl.vue'; +export const Default = { + render(args) { + return { + components: { + MkUrl, + }, + setup() { + return { + args, + }; + }, + computed: { + props() { + return { + ...this.args, + }; + }, + }, + template: '<MkUrl v-bind="props">Text</MkUrl>', + }; + }, + async play({ canvasElement }) { + const canvas = within(canvasElement); + const a = canvas.getByRole<HTMLAnchorElement>('link'); + await expect(a).toHaveAttribute('href', 'https://misskey-hub.net/'); + await waitFor(() => userEvent.hover(a)); + /* + await tick(); // FIXME: wait for network request + const anchors = canvas.getAllByRole<HTMLAnchorElement>('link'); + const popup = anchors.find(anchor => anchor !== a)!; // eslint-disable-line @typescript-eslint/no-non-null-assertion + await expect(popup).toBeInTheDocument(); + await expect(popup).toHaveAttribute('href', 'https://misskey-hub.net/'); + await expect(popup).toHaveTextContent('Misskey Hub'); + await expect(popup).toHaveTextContent('Misskeyはオープンソースの分散型ソーシャルネットワーキングプラットフォームです。'); + await expect(popup).toHaveTextContent('misskey-hub.net'); + const icon = within(popup).getByRole('img'); + await expect(icon).toBeInTheDocument(); + await expect(icon).toHaveAttribute('src', 'https://misskey-hub.net/favicon.ico'); + */ + await waitFor(() => userEvent.unhover(a)); + }, + args: { + url: 'https://misskey-hub.net/', + }, + parameters: { + layout: 'centered', + msw: { + handlers: [ + ...commonHandlers, + rest.get('/url', (req, res, ctx) => { + return res(ctx.json({ + title: 'Misskey Hub', + icon: 'https://misskey-hub.net/favicon.ico', + description: 'Misskeyはオープンソースの分散型ソーシャルネットワーキングプラットフォームです。', + thumbnail: null, + player: { + url: null, + width: null, + height: null, + allow: [], + }, + sitename: 'misskey-hub.net', + sensitive: false, + url: 'https://misskey-hub.net/', + })); + }), + ], + }, + }, +} satisfies StoryObj<typeof MkUrl>; diff --git a/packages/frontend/src/components/global/MkUserName.stories.impl.ts b/packages/frontend/src/components/global/MkUserName.stories.impl.ts new file mode 100644 index 0000000000..fa4f0f3b72 --- /dev/null +++ b/packages/frontend/src/components/global/MkUserName.stories.impl.ts @@ -0,0 +1,57 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import { expect } from '@storybook/jest'; +import { userEvent, within } from '@storybook/testing-library'; +import { StoryObj } from '@storybook/vue3'; +import { userDetailed } from '../../../.storybook/fakes'; +import MkUserName from './MkUserName.vue'; +export const Default = { + render(args) { + return { + components: { + MkUserName, + }, + setup() { + return { + args, + }; + }, + computed: { + props() { + return { + ...this.args, + }; + }, + }, + template: '<MkUserName v-bind="props"/>', + }; + }, + async play({ canvasElement }) { + await expect(canvasElement).toHaveTextContent(userDetailed().name); + }, + args: { + user: userDetailed(), + }, + parameters: { + layout: 'centered', + }, +} satisfies StoryObj<typeof MkUserName>; +export const Anonymous = { + ...Default, + async play({ canvasElement }) { + await expect(canvasElement).toHaveTextContent(userDetailed().username); + }, + args: { + ...Default.args, + user: { + ...userDetailed(), + name: null, + }, + }, +} satisfies StoryObj<typeof MkUserName>; +export const Wrap = { + ...Default, + args: { + ...Default.args, + nowrap: false, + }, +} satisfies StoryObj<typeof MkUserName>; diff --git a/packages/frontend/src/components/global/RouterView.stories.impl.ts b/packages/frontend/src/components/global/RouterView.stories.impl.ts new file mode 100644 index 0000000000..7910b8b3cb --- /dev/null +++ b/packages/frontend/src/components/global/RouterView.stories.impl.ts @@ -0,0 +1,3 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import RouterView from './RouterView.vue'; +void RouterView; diff --git a/packages/frontend/src/components/page/page.note.vue b/packages/frontend/src/components/page/page.note.vue index 7d5c484a1b..8c65dabf08 100644 --- a/packages/frontend/src/components/page/page.note.vue +++ b/packages/frontend/src/components/page/page.note.vue @@ -1,21 +1,21 @@ <template> <div class="voxdxuby"> - <XNote v-if="note && !block.detailed" :key="note.id + ':normal'" v-model:note="note"/> - <XNoteDetailed v-if="note && block.detailed" :key="note.id + ':detail'" v-model:note="note"/> + <MkNote v-if="note && !block.detailed" :key="note.id + ':normal'" v-model:note="note"/> + <MkNoteDetailed v-if="note && block.detailed" :key="note.id + ':detail'" v-model:note="note"/> </div> </template> <script lang="ts"> import { defineComponent, onMounted, PropType, Ref, ref } from 'vue'; -import XNote from '@/components/MkNote.vue'; -import XNoteDetailed from '@/components/MkNoteDetailed.vue'; +import MkNote from '@/components/MkNote.vue'; +import MkNoteDetailed from '@/components/MkNoteDetailed.vue'; import * as os from '@/os'; import { NoteBlock } from '@/scripts/hpml/block'; export default defineComponent({ components: { - XNote, - XNoteDetailed, + MkNote, + MkNoteDetailed, }, props: { block: { diff --git a/packages/frontend/src/components/page/page.post.vue b/packages/frontend/src/components/page/page.post.vue index 6de0a78694..55da610cb6 100644 --- a/packages/frontend/src/components/page/page.post.vue +++ b/packages/frontend/src/components/page/page.post.vue @@ -16,6 +16,8 @@ import { apiUrl } from '@/config'; import * as os from '@/os'; import { PostBlock } from '@/scripts/hpml/block'; import { Hpml } from '@/scripts/hpml/evaluator'; +import { defaultStore } from '@/store'; +import { $i } from '@/account'; export default defineComponent({ components: { @@ -54,9 +56,9 @@ export default defineComponent({ canvas.toBlob(blob => { const formData = new FormData(); formData.append('file', blob); - formData.append('i', this.$i.token); - if (this.$store.state.uploadFolder) { - formData.append('folderId', this.$store.state.uploadFolder); + formData.append('i', $i.token); + if (defaultStore.state.uploadFolder) { + formData.append('folderId', defaultStore.state.uploadFolder); } window.fetch(apiUrl + '/drive/files/create', { diff --git a/packages/frontend/src/components/page/page.text.vue b/packages/frontend/src/components/page/page.text.vue index 689c484521..e0e4959efa 100644 --- a/packages/frontend/src/components/page/page.text.vue +++ b/packages/frontend/src/components/page/page.text.vue @@ -6,11 +6,12 @@ </template> <script lang="ts"> -import { TextBlock } from '@/scripts/hpml/block'; -import { Hpml } from '@/scripts/hpml/evaluator'; import { defineAsyncComponent, defineComponent, PropType } from 'vue'; import * as mfm from 'mfm-js'; +import { TextBlock } from '@/scripts/hpml/block'; +import { Hpml } from '@/scripts/hpml/evaluator'; import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm'; +import { $i } from '@/account'; export default defineComponent({ components: { @@ -29,6 +30,7 @@ export default defineComponent({ data() { return { text: this.hpml.interpolate(this.block.text), + $i, }; }, computed: { diff --git a/packages/frontend/src/directives/user-preview.ts b/packages/frontend/src/directives/user-preview.ts index 2f5936de3d..ae12f2670a 100644 --- a/packages/frontend/src/directives/user-preview.ts +++ b/packages/frontend/src/directives/user-preview.ts @@ -1,5 +1,4 @@ import { defineAsyncComponent, Directive, ref } from 'vue'; -import autobind from 'autobind-decorator'; import { popup } from '@/os'; export class UserPreview { @@ -15,9 +14,16 @@ export class UserPreview { this.user = user; this.attach(); + + this.show = this.show.bind(this); + this.close = this.close.bind(this); + this.onMouseover = this.onMouseover.bind(this); + this.onMouseleave = this.onMouseleave.bind(this); + this.onClick = this.onClick.bind(this); + this.attach = this.attach.bind(this); + this.detach = this.detach.bind(this); } - @autobind private show() { if (!document.body.contains(this.el)) return; if (this.promise) return; @@ -53,7 +59,6 @@ export class UserPreview { }, 1000); } - @autobind private close() { if (this.promise) { window.clearInterval(this.checkTimer); @@ -62,34 +67,29 @@ export class UserPreview { } } - @autobind private onMouseover() { window.clearTimeout(this.showTimer); window.clearTimeout(this.hideTimer); this.showTimer = window.setTimeout(this.show, 500); } - @autobind private onMouseleave() { window.clearTimeout(this.showTimer); window.clearTimeout(this.hideTimer); this.hideTimer = window.setTimeout(this.close, 500); } - @autobind private onClick() { window.clearTimeout(this.showTimer); this.close(); } - @autobind public attach() { this.el.addEventListener('mouseover', this.onMouseover); this.el.addEventListener('mouseleave', this.onMouseleave); this.el.addEventListener('click', this.onClick); } - @autobind public detach() { this.el.removeEventListener('mouseover', this.onMouseover); this.el.removeEventListener('mouseleave', this.onMouseleave); diff --git a/packages/frontend/src/index.mdx b/packages/frontend/src/index.mdx new file mode 100644 index 0000000000..e30dea2928 --- /dev/null +++ b/packages/frontend/src/index.mdx @@ -0,0 +1,12 @@ +import { Meta } from '@storybook/blocks' + +<Meta title="index" /> + +# Welcome to Misskey Storybook + +This project uses [Storybook](https://storybook.js.org/) to develop and document components. +You can find more information about the usage of Storybook in this project in the CONTRIBUTING.md file placed in the root of this repository. + +The Misskey Storybook is under development and not all components are documented yet. +Contributions are welcome! Please refer to [#10336](https://github.com/misskey-dev/misskey/issues/10336) for more information. +Thank you for your support! diff --git a/packages/frontend/src/init.ts b/packages/frontend/src/init.ts index a2dff87e8e..5b3e7ec932 100644 --- a/packages/frontend/src/init.ts +++ b/packages/frontend/src/init.ts @@ -187,7 +187,7 @@ try { } catch (err) {} const app = createApp( - window.location.search === '?zen' ? defineAsyncComponent(() => import('@/ui/zen.vue')) : + new URLSearchParams(window.location.search).has('zen') ? defineAsyncComponent(() => import('@/ui/zen.vue')) : !$i ? defineAsyncComponent(() => import('@/ui/visitor.vue')) : ui === 'deck' ? defineAsyncComponent(() => import('@/ui/deck.vue')) : ui === 'classic' ? defineAsyncComponent(() => import('@/ui/classic.vue')) : @@ -198,15 +198,6 @@ if (_DEV_) { app.config.performance = true; } -// TODO: 廃止 -app.config.globalProperties = { - $i, - $store: defaultStore, - $instance: instance, - $t: i18n.t, - $ts: i18n.ts, -}; - widgets(app); directives(app); components(app); @@ -356,7 +347,7 @@ const hotkeys = { }, 's': (): void => { mainRouter.push('/search'); - } + }, }; if ($i) { @@ -522,15 +513,6 @@ if ($i) { updateAccount({ hasUnreadAnnouncement: false }); }); - main.on('readAllChannels', () => { - updateAccount({ hasUnreadChannel: false }); - }); - - main.on('unreadChannel', () => { - updateAccount({ hasUnreadChannel: true }); - sound.play('channel'); - }); - // トークンが再生成されたとき // このままではMisskeyが利用できないので強制的にサインアウトさせる main.on('myTokenRegenerated', () => { diff --git a/packages/frontend/src/local-storage.ts b/packages/frontend/src/local-storage.ts index 38462c8a65..9a288f264c 100644 --- a/packages/frontend/src/local-storage.ts +++ b/packages/frontend/src/local-storage.ts @@ -6,6 +6,7 @@ type Keys = 'accounts' | 'latestDonationInfoShownAt' | 'neverShowDonationInfo' | + 'neverShowLocalOnlyInfo' | 'lastUsed' | 'lang' | 'drafts' | diff --git a/packages/frontend/src/os.ts b/packages/frontend/src/os.ts index f0af9f081b..962f9cdd98 100644 --- a/packages/frontend/src/os.ts +++ b/packages/frontend/src/os.ts @@ -215,6 +215,7 @@ export function actions<T extends { value: string; text: string; primary?: boolean, + danger?: boolean, }[]>(props: { type: 'error' | 'info' | 'success' | 'warning' | 'waiting' | 'question'; title?: string | null; @@ -229,6 +230,7 @@ export function actions<T extends { actions: props.actions.map(a => ({ text: a.text, primary: a.primary, + danger: a.danger, callback: () => { resolve({ canceled: false, result: a.value }); }, diff --git a/packages/frontend/src/pages/_error_.vue b/packages/frontend/src/pages/_error_.vue index 5001b5a8b4..f53fec7d94 100644 --- a/packages/frontend/src/pages/_error_.vue +++ b/packages/frontend/src/pages/_error_.vue @@ -1,6 +1,6 @@ <template> <MkLoading v-if="!loaded"/> -<Transition :name="$store.state.animation ? '_transition_zoom' : ''" appear> +<Transition :name="defaultStore.state.animation ? '_transition_zoom' : ''" appear> <div v-show="loaded" class="mjndxjch"> <img src="https://xn--931a.moe/assets/error.jpg" class="_ghost"/> <p><b><i class="ti ti-alert-triangle"></i> {{ i18n.ts.pageLoadError }}</b></p> @@ -27,6 +27,7 @@ import { unisonReload } from '@/scripts/unison-reload'; import { i18n } from '@/i18n'; import { definePageMetadata } from '@/scripts/page-metadata'; import { miLocalStorage } from '@/local-storage'; +import { defaultStore } from '@/store'; const props = withDefaults(defineProps<{ error?: Error; diff --git a/packages/frontend/src/pages/about-misskey.vue b/packages/frontend/src/pages/about-misskey.vue index 60f61ed293..7e0696f8bc 100644 --- a/packages/frontend/src/pages/about-misskey.vue +++ b/packages/frontend/src/pages/about-misskey.vue @@ -217,6 +217,7 @@ const patrons = [ '氷月氷華里', 'Ebise Lutica', '巣黒るい@リスケモ男の娘VTuber!', + 'ふぇいぽむ', ]; let thereIsTreasure = $ref($i && !claimedAchievements.includes('foundTreasure')); diff --git a/packages/frontend/src/pages/about.emojis.vue b/packages/frontend/src/pages/about.emojis.vue index 7f3b4fd937..d461430234 100644 --- a/packages/frontend/src/pages/about.emojis.vue +++ b/packages/frontend/src/pages/about.emojis.vue @@ -3,7 +3,7 @@ <MkButton v-if="$i && ($i.isModerator || $i.policies.canManageCustomEmojis)" primary link to="/custom-emojis-manager">{{ i18n.ts.manageCustomEmojis }}</MkButton> <div class="query"> - <MkInput v-model="q" class="" :placeholder="$ts.search"> + <MkInput v-model="q" class="" :placeholder="i18n.ts.search"> <template #prefix><i class="ti ti-search"></i></template> </MkInput> @@ -15,14 +15,14 @@ </div> <MkFoldableSection v-if="searchEmojis" class="emojis"> - <template #header>{{ $ts.searchResult }}</template> + <template #header>{{ i18n.ts.searchResult }}</template> <div class="zuvgdzyt"> <XEmoji v-for="emoji in searchEmojis" :key="emoji.name" class="emoji" :emoji="emoji"/> </div> </MkFoldableSection> <MkFoldableSection v-for="category in customEmojiCategories" v-once :key="category" class="emojis"> - <template #header>{{ category || $ts.other }}</template> + <template #header>{{ category || i18n.ts.other }}</template> <div class="zuvgdzyt"> <XEmoji v-for="emoji in customEmojis.filter(e => e.category === category)" :key="emoji.name" class="emoji" :emoji="emoji"/> </div> @@ -32,13 +32,14 @@ <script lang="ts" setup> import { watch } from 'vue'; +import * as Misskey from 'misskey-js'; import XEmoji from './emojis.emoji.vue'; import MkButton from '@/components/MkButton.vue'; import MkInput from '@/components/MkInput.vue'; import MkFoldableSection from '@/components/MkFoldableSection.vue'; import { customEmojis, customEmojiCategories, getCustomEmojiTags } from '@/custom-emojis'; import { i18n } from '@/i18n'; -import * as Misskey from 'misskey-js'; +import { $i } from '@/account'; const customEmojiTags = getCustomEmojiTags(); let q = $ref(''); diff --git a/packages/frontend/src/pages/about.vue b/packages/frontend/src/pages/about.vue index be0c1828a3..d54d93eaee 100644 --- a/packages/frontend/src/pages/about.vue +++ b/packages/frontend/src/pages/about.vue @@ -3,18 +3,18 @@ <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> <MkSpacer v-if="tab === 'overview'" :content-max="600" :margin-min="20"> <div class="_gaps_m"> - <div class="fwhjspax" :style="{ backgroundImage: `url(${ $instance.bannerUrl })` }"> + <div class="fwhjspax" :style="{ backgroundImage: `url(${ instance.bannerUrl })` }"> <div class="content"> - <img :src="$instance.iconUrl ?? $instance.faviconUrl ?? '/favicon.ico'" alt="" class="icon"/> + <img :src="instance.iconUrl ?? instance.faviconUrl ?? '/favicon.ico'" alt="" class="icon"/> <div class="name"> - <b>{{ $instance.name ?? host }}</b> + <b>{{ instance.name ?? host }}</b> </div> </div> </div> <MkKeyValue> <template #key>{{ i18n.ts.description }}</template> - <template #value><div v-html="$instance.description"></div></template> + <template #value><div v-html="instance.description"></div></template> </MkKeyValue> <FormSection> @@ -23,7 +23,7 @@ <template #key>Misskey</template> <template #value>{{ version }}</template> </MkKeyValue> - <div v-html="i18n.t('poweredByMisskeyDescription', { name: $instance.name ?? host })"> + <div v-html="i18n.t('poweredByMisskeyDescription', { name: instance.name ?? host })"> </div> <FormLink to="/about-misskey">{{ i18n.ts.aboutMisskey }}</FormLink> </div> @@ -34,14 +34,14 @@ <FormSplit> <MkKeyValue> <template #key>{{ i18n.ts.administrator }}</template> - <template #value>{{ $instance.maintainerName }}</template> + <template #value>{{ instance.maintainerName }}</template> </MkKeyValue> <MkKeyValue> <template #key>{{ i18n.ts.contact }}</template> - <template #value>{{ $instance.maintainerEmail }}</template> + <template #value>{{ instance.maintainerEmail }}</template> </MkKeyValue> </FormSplit> - <FormLink v-if="$instance.tosUrl" :to="$instance.tosUrl" external>{{ i18n.ts.tos }}</FormLink> + <FormLink v-if="instance.tosUrl" :to="instance.tosUrl" external>{{ i18n.ts.tos }}</FormLink> </div> </FormSection> @@ -101,6 +101,7 @@ import number from '@/filters/number'; import { i18n } from '@/i18n'; import { definePageMetadata } from '@/scripts/page-metadata'; import { claimAchievement } from '@/scripts/achievements'; +import { instance } from '@/instance'; const props = withDefaults(defineProps<{ initialTab?: string; diff --git a/packages/frontend/src/pages/admin/ads.vue b/packages/frontend/src/pages/admin/ads.vue index 828bfe6007..803e8cb7b0 100644 --- a/packages/frontend/src/pages/admin/ads.vue +++ b/packages/frontend/src/pages/admin/ads.vue @@ -113,16 +113,37 @@ function remove(ad) { function save(ad) { if (ad.id == null) { - os.apiWithDialog('admin/ad/create', { + os.api('admin/ad/create', { ...ad, expiresAt: new Date(ad.expiresAt).getTime(), startsAt: new Date(ad.startsAt).getTime(), + }).then(() => { + os.alert({ + type: 'success', + text: i18n.ts.saved, + }); + refresh(); + }).catch(err => { + os.alert({ + type: 'error', + text: err, + }); }); } else { - os.apiWithDialog('admin/ad/update', { + os.api('admin/ad/update', { ...ad, expiresAt: new Date(ad.expiresAt).getTime(), startsAt: new Date(ad.startsAt).getTime(), + }).then(() => { + os.alert({ + type: 'success', + text: i18n.ts.saved, + }); + }).catch(err => { + os.alert({ + type: 'error', + text: err, + }); }); } } @@ -141,6 +162,25 @@ function more() { })); }); } + +function refresh() { + os.api('admin/ad/list').then(adsResponse => { + ads = adsResponse.map(r => { + const exdate = new Date(r.expiresAt); + const stdate = new Date(r.startsAt); + exdate.setMilliseconds(exdate.getMilliseconds() - localTimeDiff); + stdate.setMilliseconds(stdate.getMilliseconds() - localTimeDiff); + return { + ...r, + expiresAt: exdate.toISOString().slice(0, 16), + startsAt: stdate.toISOString().slice(0, 16), + }; + }); + }); +} + +refresh(); + const headerActions = $computed(() => [{ asFullButton: true, icon: 'ti ti-plus', diff --git a/packages/frontend/src/pages/admin/announcements.vue b/packages/frontend/src/pages/admin/announcements.vue index d5d177bf76..b76e4b9114 100644 --- a/packages/frontend/src/pages/admin/announcements.vue +++ b/packages/frontend/src/pages/admin/announcements.vue @@ -69,6 +69,7 @@ function save(announcement) { type: 'success', text: i18n.ts.saved, }); + refresh(); }).catch(err => { os.alert({ type: 'error', @@ -90,6 +91,14 @@ function save(announcement) { } } +function refresh() { + os.api('admin/announcements/list').then(announcementResponse => { + announcements = announcementResponse; + }); +} + +refresh(); + const headerActions = $computed(() => [{ asFullButton: true, icon: 'ti ti-plus', diff --git a/packages/frontend/src/pages/admin/index.vue b/packages/frontend/src/pages/admin/index.vue index 8aae39cba1..963393d7e5 100644 --- a/packages/frontend/src/pages/admin/index.vue +++ b/packages/frontend/src/pages/admin/index.vue @@ -4,7 +4,7 @@ <MkSpacer :content-max="700" :margin-min="16"> <div class="lxpfedzu"> <div class="banner"> - <img :src="$instance.iconUrl || '/favicon.ico'" alt="" class="icon"/> + <img :src="instance.iconUrl || '/favicon.ico'" alt="" class="icon"/> </div> <MkInfo v-if="thereIsUnresolvedAbuseReport" warn class="info">{{ i18n.ts.thereIsUnresolvedAbuseReportWarning }} <MkA to="/admin/abuses" class="_link">{{ i18n.ts.check }}</MkA></MkInfo> @@ -221,7 +221,7 @@ onUnmounted(() => { }); watch(router.currentRef, (to) => { - if (to.route.path === "/admin" && to.child?.route.name == null && !narrow) { + if (to.route.path === '/admin' && to.child?.route.name == null && !narrow) { router.replace('/admin/overview'); } }); diff --git a/packages/frontend/src/pages/admin/object-storage.vue b/packages/frontend/src/pages/admin/object-storage.vue index cbe38b2d81..704b27c174 100644 --- a/packages/frontend/src/pages/admin/object-storage.vue +++ b/packages/frontend/src/pages/admin/object-storage.vue @@ -7,7 +7,7 @@ <MkSwitch v-model="useObjectStorage">{{ i18n.ts.useObjectStorage }}</MkSwitch> <template v-if="useObjectStorage"> - <MkInput v-model="objectStorageBaseUrl"> + <MkInput v-model="objectStorageBaseUrl" :placeholder="'https://example.com'"> <template #label>{{ i18n.ts.objectStorageBaseUrl }}</template> <template #caption>{{ i18n.ts.objectStorageBaseUrlDesc }}</template> </MkInput> @@ -22,8 +22,9 @@ <template #caption>{{ i18n.ts.objectStoragePrefixDesc }}</template> </MkInput> - <MkInput v-model="objectStorageEndpoint"> + <MkInput v-model="objectStorageEndpoint" :placeholder="'example.com'"> <template #label>{{ i18n.ts.objectStorageEndpoint }}</template> + <template #prefix>https://</template> <template #caption>{{ i18n.ts.objectStorageEndpointDesc }}</template> </MkInput> @@ -60,6 +61,7 @@ <MkSwitch v-model="objectStorageS3ForcePathStyle"> <template #label>s3ForcePathStyle</template> + <template #caption>{{ i18n.ts.s3ForcePathStyleDesc }}</template> </MkSwitch> </template> </div> diff --git a/packages/frontend/src/pages/admin/overview.instances.vue b/packages/frontend/src/pages/admin/overview.instances.vue index 7d530d6b95..6c2ffd4742 100644 --- a/packages/frontend/src/pages/admin/overview.instances.vue +++ b/packages/frontend/src/pages/admin/overview.instances.vue @@ -1,6 +1,6 @@ <template> <div class="wbrkwale"> - <Transition :name="$store.state.animation ? '_transition_zoom' : ''" mode="out-in"> + <Transition :name="defaultStore.state.animation ? '_transition_zoom' : ''" mode="out-in"> <MkLoading v-if="fetching"/> <div v-else class="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="instance"> @@ -16,6 +16,7 @@ import { ref } from 'vue'; import * as os from '@/os'; import { useInterval } from '@/scripts/use-interval'; import MkInstanceCardMini from '@/components/MkInstanceCardMini.vue'; +import { defaultStore } from '@/store'; const instances = ref([]); 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 ff689b8bf9..fee6a1394e 100644 --- a/packages/frontend/src/pages/admin/overview.moderators.vue +++ b/packages/frontend/src/pages/admin/overview.moderators.vue @@ -1,6 +1,6 @@ <template> <div> - <Transition :name="$store.state.animation ? '_transition_zoom' : ''" mode="out-in"> + <Transition :name="defaultStore.state.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="`/user-info/${user.id}`"> @@ -14,6 +14,7 @@ <script lang="ts" setup> import { onMounted } from 'vue'; import * as os from '@/os'; +import { defaultStore } from '@/store'; let moderators: any = $ref(null); let fetching = $ref(true); diff --git a/packages/frontend/src/pages/admin/overview.stats.vue b/packages/frontend/src/pages/admin/overview.stats.vue index 3dc1ed8ec5..142e70c698 100644 --- a/packages/frontend/src/pages/admin/overview.stats.vue +++ b/packages/frontend/src/pages/admin/overview.stats.vue @@ -1,6 +1,6 @@ <template> <div> - <Transition :name="$store.state.animation ? '_transition_zoom' : ''" mode="out-in"> + <Transition :name="defaultStore.state.animation ? '_transition_zoom' : ''" mode="out-in"> <MkLoading v-if="fetching"/> <div v-else :class="$style.root"> <div class="item _panel users"> @@ -62,6 +62,7 @@ import MkNumberDiff from '@/components/MkNumberDiff.vue'; import MkNumber from '@/components/MkNumber.vue'; import { i18n } from '@/i18n'; import { customEmojis } from '@/custom-emojis'; +import { defaultStore } from '@/store'; let stats: any = $ref(null); let usersComparedToThePrevDay = $ref<number>(); diff --git a/packages/frontend/src/pages/admin/overview.users.vue b/packages/frontend/src/pages/admin/overview.users.vue index 3379d064cd..5df7b468f3 100644 --- a/packages/frontend/src/pages/admin/overview.users.vue +++ b/packages/frontend/src/pages/admin/overview.users.vue @@ -1,6 +1,6 @@ <template> <div :class="$style.root"> - <Transition :name="$store.state.animation ? '_transition_zoom' : ''" mode="out-in"> + <Transition :name="defaultStore.state.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="`/user-info/${user.id}`" class="user"> @@ -15,6 +15,7 @@ import * as os from '@/os'; import { useInterval } from '@/scripts/use-interval'; import MkUserCardMini from '@/components/MkUserCardMini.vue'; +import { defaultStore } from '@/store'; let newUsers = $ref(null); let fetching = $ref(true); diff --git a/packages/frontend/src/pages/admin/relays.vue b/packages/frontend/src/pages/admin/relays.vue index 55d33e0158..7ebcdfc583 100644 --- a/packages/frontend/src/pages/admin/relays.vue +++ b/packages/frontend/src/pages/admin/relays.vue @@ -9,7 +9,7 @@ <i v-if="relay.status === 'accepted'" class="ti ti-check icon accepted"></i> <i v-else-if="relay.status === 'rejected'" class="ti ti-ban icon rejected"></i> <i v-else class="ti ti-clock icon requesting"></i> - <span>{{ $t(`_relayStatus.${relay.status}`) }}</span> + <span>{{ i18n.t(`_relayStatus.${relay.status}`) }}</span> </div> <MkButton class="button" inline danger @click="remove(relay.inbox)"><i class="ti ti-trash"></i> {{ i18n.ts.remove }}</MkButton> </div> diff --git a/packages/frontend/src/pages/announcements.vue b/packages/frontend/src/pages/announcements.vue index 131f6d11ea..16a0ee8373 100644 --- a/packages/frontend/src/pages/announcements.vue +++ b/packages/frontend/src/pages/announcements.vue @@ -10,7 +10,7 @@ <img v-if="announcement.imageUrl" :src="announcement.imageUrl"/> </div> <div v-if="$i && !announcement.isRead" class="footer"> - <MkButton primary @click="read(items, announcement, i)"><i class="ti ti-check"></i> {{ $ts.gotIt }}</MkButton> + <MkButton primary @click="read(items, announcement, i)"><i class="ti ti-check"></i> {{ i18n.ts.gotIt }}</MkButton> </div> </section> </MkPagination> @@ -25,6 +25,7 @@ import MkButton from '@/components/MkButton.vue'; import * as os from '@/os'; import { i18n } from '@/i18n'; import { definePageMetadata } from '@/scripts/page-metadata'; +import { $i } from '@/account'; const pagination = { endpoint: 'announcements' as const, diff --git a/packages/frontend/src/pages/antenna-timeline.vue b/packages/frontend/src/pages/antenna-timeline.vue index cf803d6c7f..62e8178af1 100644 --- a/packages/frontend/src/pages/antenna-timeline.vue +++ b/packages/frontend/src/pages/antenna-timeline.vue @@ -2,7 +2,7 @@ <MkStickyContainer> <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> <div ref="rootEl" v-hotkey.global="keymap" class="tqmomfks"> - <div v-if="queue > 0" class="new"><button class="_buttonPrimary" @click="top()">{{ $ts.newNoteRecived }}</button></div> + <div v-if="queue > 0" class="new"><button class="_buttonPrimary" @click="top()">{{ i18n.ts.newNoteRecived }}</button></div> <div class="tl"> <MkTimeline ref="tlEl" :key="antennaId" diff --git a/packages/frontend/src/pages/auth.form.vue b/packages/frontend/src/pages/auth.form.vue index f8484185f5..40a6d782b0 100644 --- a/packages/frontend/src/pages/auth.form.vue +++ b/packages/frontend/src/pages/auth.form.vue @@ -1,25 +1,25 @@ <template> - <section> - <div v-if="app.permission.length > 0"> - <p>{{ $t('_auth.permission', { name }) }}</p> - <ul> - <li v-for="p in app.permission" :key="p">{{ $t(`_permissions.${p}`) }}</li> - </ul> - </div> - <div>{{ i18n.t('_auth.shareAccess', { name: `${name} (${app.id})` }) }}</div> - <div :class="$style.buttons"> - <MkButton inline @click="cancel">{{ i18n.ts.cancel }}</MkButton> - <MkButton inline primary @click="accept">{{ i18n.ts.accept }}</MkButton> - </div> - </section> +<section> + <div v-if="app.permission.length > 0"> + <p>{{ i18n.t('_auth.permission', { name }) }}</p> + <ul> + <li v-for="p in app.permission" :key="p">{{ i18n.t(`_permissions.${p}`) }}</li> + </ul> + </div> + <div>{{ i18n.t('_auth.shareAccess', { name: `${name} (${app.id})` }) }}</div> + <div :class="$style.buttons"> + <MkButton inline @click="cancel">{{ i18n.ts.cancel }}</MkButton> + <MkButton inline primary @click="accept">{{ i18n.ts.accept }}</MkButton> + </div> +</section> </template> <script lang="ts" setup> import { } from 'vue'; +import { AuthSession } from 'misskey-js/built/entities'; import MkButton from '@/components/MkButton.vue'; import * as os from '@/os'; import { i18n } from '@/i18n'; -import { AuthSession } from 'misskey-js/built/entities'; const props = defineProps<{ session: AuthSession; diff --git a/packages/frontend/src/pages/auth.vue b/packages/frontend/src/pages/auth.vue index 4f8afb9ea2..2f40e7ded6 100644 --- a/packages/frontend/src/pages/auth.vue +++ b/packages/frontend/src/pages/auth.vue @@ -20,7 +20,7 @@ <h1>{{ i18n.ts._auth.denied }}</h1> </div> <div v-if="state == 'accepted' && session"> - <h1>{{ session.app.isAuthorized ? $t('already-authorized') : i18n.ts.allowed }}</h1> + <h1>{{ session.app.isAuthorized ? i18n.t('already-authorized') : i18n.ts.allowed }}</h1> <p v-if="session.app.callbackUrl"> {{ i18n.ts._auth.callback }} <MkEllipsis/> diff --git a/packages/frontend/src/pages/channel-editor.vue b/packages/frontend/src/pages/channel-editor.vue index 38c5b1e082..667caab966 100644 --- a/packages/frontend/src/pages/channel-editor.vue +++ b/packages/frontend/src/pages/channel-editor.vue @@ -2,7 +2,7 @@ <MkStickyContainer> <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> <MkSpacer :content-max="700"> - <div class="_gaps_m"> + <div v-if="channel" class="_gaps_m"> <MkInput v-model="name"> <template #label>{{ i18n.ts.name }}</template> </MkInput> @@ -11,13 +11,37 @@ <template #label>{{ i18n.ts.description }}</template> </MkTextarea> - <div class="banner"> + <div> <MkButton v-if="bannerId == null" @click="setBannerImage"><i class="ti ti-plus"></i> {{ i18n.ts._channel.setBanner }}</MkButton> <div v-else-if="bannerUrl"> <img :src="bannerUrl" style="width: 100%;"/> <MkButton @click="removeBannerImage()"><i class="ti ti-trash"></i> {{ i18n.ts._channel.removeBanner }}</MkButton> </div> </div> + + <MkFolder :default-open="true"> + <template #label>{{ i18n.ts.pinnedNotes }}</template> + + <div class="_gaps"> + <MkButton primary rounded @click="addPinnedNote()"><i class="ti ti-plus"></i></MkButton> + + <Sortable + v-model="pinnedNotes" + item-key="id" + :handle="'.' + $style.pinnedNoteHandle" + :animation="150" + > + <template #item="{element,index}"> + <div :class="$style.pinnedNote"> + <button class="_button" :class="$style.pinnedNoteHandle"><i class="ti ti-menu"></i></button> + {{ element.id }} + <button class="_button" :class="$style.pinnedNoteRemove" @click="removePinnedNote(index)"><i class="ti ti-x"></i></button> + </div> + </template> + </Sortable> + </div> + </MkFolder> + <div> <MkButton primary @click="save()"><i class="ti ti-device-floppy"></i> {{ channelId ? i18n.ts.save : i18n.ts.create }}</MkButton> </div> @@ -27,7 +51,7 @@ </template> <script lang="ts" setup> -import { computed, watch } from 'vue'; +import { computed, ref, watch, defineAsyncComponent } from 'vue'; import MkTextarea from '@/components/MkTextarea.vue'; import MkButton from '@/components/MkButton.vue'; import MkInput from '@/components/MkInput.vue'; @@ -36,6 +60,9 @@ import * as os from '@/os'; import { useRouter } from '@/router'; import { definePageMetadata } from '@/scripts/page-metadata'; import { i18n } from '@/i18n'; +import MkFolder from '@/components/MkFolder.vue'; + +const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default)); const router = useRouter(); @@ -48,6 +75,7 @@ let name = $ref(null); let description = $ref(null); let bannerUrl = $ref<string | null>(null); let bannerId = $ref<string | null>(null); +const pinnedNotes = ref([]); watch(() => bannerId, async () => { if (bannerId == null) { @@ -70,15 +98,36 @@ async function fetchChannel() { description = channel.description; bannerId = channel.bannerId; bannerUrl = channel.bannerUrl; + pinnedNotes.value = channel.pinnedNoteIds.map(id => ({ + id, + })); } fetchChannel(); +async function addPinnedNote() { + const { canceled, result: value } = await os.inputText({ + title: i18n.ts.noteIdOrUrl, + }); + if (canceled) return; + const note = await os.apiWithDialog('notes/show', { + noteId: value.includes('/') ? value.split('/').pop() : value, + }); + pinnedNotes.value = [{ + id: note.id, + }, ...pinnedNotes.value]; +} + +function removePinnedNote(index: number) { + pinnedNotes.value.splice(index, 1); +} + function save() { const params = { name: name, description: description, bannerId: bannerId, + pinnedNoteIds: pinnedNotes.value.map(x => x.id), }; if (props.channelId) { @@ -117,6 +166,32 @@ definePageMetadata(computed(() => props.channelId ? { })); </script> -<style lang="scss" scoped> +<style lang="scss" module> +.pinnedNote { + position: relative; + display: block; + line-height: 2.85rem; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + color: var(--navFg); +} + +.pinnedNoteRemove { + position: absolute; + z-index: 10000; + width: 32px; + height: 32px; + color: #ff2a2a; + right: 8px; + opacity: 0.8; +} +.pinnedNoteHandle { + cursor: move; + width: 32px; + height: 32px; + margin: 0 8px; + opacity: 0.5; +} </style> diff --git a/packages/frontend/src/pages/channel.vue b/packages/frontend/src/pages/channel.vue index 76f11faab8..437c1fae31 100644 --- a/packages/frontend/src/pages/channel.vue +++ b/packages/frontend/src/pages/channel.vue @@ -16,6 +16,16 @@ <Mfm :text="channel.description" :is-note="false" :i="$i"/> </div> </div> + + <MkButton v-if="favorited" v-tooltip="i18n.ts.unfavorite" as-like class="button" rounded primary @click="unfavorite()"><i class="ti ti-star"></i></MkButton> + <MkButton v-else v-tooltip="i18n.ts.favorite" as-like class="button" rounded @click="favorite()"><i class="ti ti-star"></i></MkButton> + + <MkFoldableSection> + <template #header><i class="ti ti-pin ti-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.pinnedNotes }}</template> + <div v-if="channel.pinnedNotes.length > 0" class="_gaps"> + <MkNote v-for="note in channel.pinnedNotes" :key="note.id" class="_panel" :note="note"/> + </div> + </MkFoldableSection> </div> <div v-if="channel && tab === 'timeline'" class="_gaps"> <!-- スマホ・タブレットの場合、キーボードが表示されると投稿が見づらくなるので、デスクトップ場合のみ自動でフォーカスを当てる --> @@ -54,6 +64,8 @@ import MkNotes from '@/components/MkNotes.vue'; import { url } from '@/config'; import MkButton from '@/components/MkButton.vue'; import { defaultStore } from '@/store'; +import MkNote from '@/components/MkNote.vue'; +import MkFoldableSection from '@/components/MkFoldableSection.vue'; const router = useRouter(); @@ -63,6 +75,7 @@ const props = defineProps<{ let tab = $ref('timeline'); let channel = $ref(null); +let favorited = $ref(false); const featuredPagination = $computed(() => ({ endpoint: 'notes/featured' as const, limit: 10, @@ -76,6 +89,7 @@ watch(() => props.channelId, async () => { channel = await os.api('channels/show', { channelId: props.channelId, }); + favorited = channel.isFavorited; }, { immediate: true }); function edit() { @@ -84,9 +98,28 @@ function edit() { function openPostForm() { os.post({ - channel: { - id: channel.id, - }, + channel, + }); +} + +function favorite() { + os.apiWithDialog('channels/favorite', { + channelId: channel.id, + }).then(() => { + favorited = true; + }); +} + +async function unfavorite() { + const confirm = await os.confirm({ + type: 'warning', + text: i18n.ts.unfavoriteConfirm, + }); + if (confirm.canceled) return; + os.apiWithDialog('channels/unfavorite', { + channelId: channel.id, + }).then(() => { + favorited = false; }); } diff --git a/packages/frontend/src/pages/channels.vue b/packages/frontend/src/pages/channels.vue index 3550c7f436..fd1d2d03cf 100644 --- a/packages/frontend/src/pages/channels.vue +++ b/packages/frontend/src/pages/channels.vue @@ -2,17 +2,22 @@ <MkStickyContainer> <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> <MkSpacer :content-max="700"> - <div v-if="tab === 'featured'" class="grwlizim featured"> + <div v-if="tab === 'featured'"> <MkPagination v-slot="{items}" :pagination="featuredPagination"> <MkChannelPreview v-for="channel in items" :key="channel.id" class="_margin" :channel="channel"/> </MkPagination> </div> - <div v-else-if="tab === 'following'" class="grwlizim following"> + <div v-else-if="tab === 'favorites'"> + <MkPagination v-slot="{items}" :pagination="favoritesPagination"> + <MkChannelPreview v-for="channel in items" :key="channel.id" class="_margin" :channel="channel"/> + </MkPagination> + </div> + <div v-else-if="tab === 'following'"> <MkPagination v-slot="{items}" :pagination="followingPagination"> <MkChannelPreview v-for="channel in items" :key="channel.id" class="_margin" :channel="channel"/> </MkPagination> </div> - <div v-else-if="tab === 'owned'" class="grwlizim 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"> <MkChannelPreview v-for="channel in items" :key="channel.id" class="_margin" :channel="channel"/> @@ -39,13 +44,17 @@ const featuredPagination = { endpoint: 'channels/featured' as const, noPaging: true, }; +const favoritesPagination = { + endpoint: 'channels/my-favorites' as const, + limit: 100, +}; const followingPagination = { endpoint: 'channels/followed' as const, - limit: 5, + limit: 10, }; const ownedPagination = { endpoint: 'channels/owned' as const, - limit: 5, + limit: 10, }; function create() { @@ -63,9 +72,13 @@ const headerTabs = $computed(() => [{ title: i18n.ts._channel.featured, icon: 'ti ti-comet', }, { + key: 'favorites', + title: i18n.ts.favorites, + icon: 'ti ti-star', +}, { key: 'following', title: i18n.ts._channel.following, - icon: 'ti ti-heart', + icon: 'ti ti-eye', }, { key: 'owned', title: i18n.ts._channel.owned, diff --git a/packages/frontend/src/pages/clip.vue b/packages/frontend/src/pages/clip.vue index 2b64de088a..e3ac3f4c9b 100644 --- a/packages/frontend/src/pages/clip.vue +++ b/packages/frontend/src/pages/clip.vue @@ -57,7 +57,7 @@ watch(() => props.clipId, async () => { immediate: true, }); -provide('currentClipPage', $$(clip)); +provide('currentClip', $$(clip)); function favorite() { os.apiWithDialog('clips/favorite', { diff --git a/packages/frontend/src/pages/favorites.vue b/packages/frontend/src/pages/favorites.vue index 07dd768499..0dc9b9dc8f 100644 --- a/packages/frontend/src/pages/favorites.vue +++ b/packages/frontend/src/pages/favorites.vue @@ -12,7 +12,7 @@ <template #default="{ items }"> <MkDateSeparatedList v-slot="{ item }" :items="items" :direction="'down'" :no-gap="false" :ad="false"> - <XNote :key="item.id" :note="item.note" :class="$style.note"/> + <MkNote :key="item.id" :note="item.note" :class="$style.note"/> </MkDateSeparatedList> </template> </MkPagination> @@ -22,7 +22,7 @@ <script lang="ts" setup> import MkPagination from '@/components/MkPagination.vue'; -import XNote from '@/components/MkNote.vue'; +import MkNote from '@/components/MkNote.vue'; import MkDateSeparatedList from '@/components/MkDateSeparatedList.vue'; import { i18n } from '@/i18n'; import { definePageMetadata } from '@/scripts/page-metadata'; diff --git a/packages/frontend/src/pages/flash/flash.vue b/packages/frontend/src/pages/flash/flash.vue index 76201aa85f..961ef4b751 100644 --- a/packages/frontend/src/pages/flash/flash.vue +++ b/packages/frontend/src/pages/flash/flash.vue @@ -2,9 +2,9 @@ <MkStickyContainer> <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> <MkSpacer :content-max="700"> - <Transition :name="$store.state.animation ? 'fade' : ''" mode="out-in"> + <Transition :name="defaultStore.state.animation ? 'fade' : ''" mode="out-in"> <div v-if="flash" :key="flash.id"> - <Transition :name="$store.state.animation ? 'zoom' : ''" mode="out-in"> + <Transition :name="defaultStore.state.animation ? 'zoom' : ''" mode="out-in"> <div v-if="started" :class="$style.started"> <div class="main _panel"> <MkAsUi v-if="root" :component="root" :components="components"/> @@ -63,6 +63,8 @@ import { AsUiComponent, AsUiRoot, registerAsUiLib } from '@/scripts/aiscript/ui' import { createAiScriptEnv } from '@/scripts/aiscript/api'; import MkFolder from '@/components/MkFolder.vue'; import MkCode from '@/components/MkCode.vue'; +import { defaultStore } from '@/store'; +import { $i } from '@/account'; const props = defineProps<{ id: string; diff --git a/packages/frontend/src/pages/gallery/post.vue b/packages/frontend/src/pages/gallery/post.vue index 4bf7c8c514..e0f3c105e1 100644 --- a/packages/frontend/src/pages/gallery/post.vue +++ b/packages/frontend/src/pages/gallery/post.vue @@ -3,7 +3,7 @@ <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> <MkSpacer :content-max="1000" :margin-min="16" :margin-max="32"> <div class="_root"> - <Transition :name="$store.state.animation ? 'fade' : ''" mode="out-in"> + <Transition :name="defaultStore.state.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"> @@ -67,6 +67,8 @@ import { url } from '@/config'; import { useRouter } from '@/router'; import { i18n } from '@/i18n'; import { definePageMetadata } from '@/scripts/page-metadata'; +import { defaultStore } from '@/store'; +import { $i } from '@/account'; const router = useRouter(); diff --git a/packages/frontend/src/pages/miauth.vue b/packages/frontend/src/pages/miauth.vue index 915adff277..8e0624f555 100644 --- a/packages/frontend/src/pages/miauth.vue +++ b/packages/frontend/src/pages/miauth.vue @@ -1,6 +1,6 @@ <template> <MkStickyContainer> - <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs" /></template> + <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> <MkSpacer :content-max="800"> <div v-if="$i"> <div v-if="state == 'waiting'"> @@ -15,13 +15,13 @@ </div> <div v-else> <div v-if="_permissions.length > 0"> - <p v-if="name">{{ $t('_auth.permission', { name }) }}</p> + <p v-if="name">{{ i18n.t('_auth.permission', { name }) }}</p> <p v-else>{{ i18n.ts._auth.permissionAsk }}</p> <ul> - <li v-for="p in _permissions" :key="p">{{ $t(`_permissions.${p}`) }}</li> + <li v-for="p in _permissions" :key="p">{{ i18n.t(`_permissions.${p}`) }}</li> </ul> </div> - <div v-if="name">{{ $t('_auth.shareAccess', { name }) }}</div> + <div v-if="name">{{ i18n.t('_auth.shareAccess', { name }) }}</div> <div v-else>{{ i18n.ts._auth.shareAccessAsk }}</div> <div :class="$style.buttons"> <MkButton inline @click="deny">{{ i18n.ts.cancel }}</MkButton> diff --git a/packages/frontend/src/pages/my-antennas/index.vue b/packages/frontend/src/pages/my-antennas/index.vue index 9daf23f9b5..f1764b1aad 100644 --- a/packages/frontend/src/pages/my-antennas/index.vue +++ b/packages/frontend/src/pages/my-antennas/index.vue @@ -24,6 +24,7 @@ import { definePageMetadata } from '@/scripts/page-metadata'; const pagination = { endpoint: 'antennas/list' as const, + noPaging: true, limit: 10, }; diff --git a/packages/frontend/src/pages/note.vue b/packages/frontend/src/pages/note.vue index 45efe655fb..d9baa1096a 100644 --- a/packages/frontend/src/pages/note.vue +++ b/packages/frontend/src/pages/note.vue @@ -3,7 +3,7 @@ <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> <MkSpacer :content-max="800"> <div class="fcuexfpr"> - <Transition :name="$store.state.animation ? 'fade' : ''" mode="out-in"> + <Transition :name="defaultStore.state.animation ? 'fade' : ''" mode="out-in"> <div v-if="note" class="note"> <div v-if="showNext" class="_margin"> <MkNotes class="" :pagination="nextPagination" :no-gap="true"/> @@ -13,7 +13,7 @@ <MkButton v-if="!showNext && hasNext" class="load next" @click="showNext = true"><i class="ti ti-chevron-up"></i></MkButton> <div class="note _margin _gaps_s"> <MkRemoteCaution v-if="note.user.host != null" :href="note.url ?? note.uri"/> - <XNoteDetailed :key="note.id" v-model:note="note" class="note"/> + <MkNoteDetailed :key="note.id" v-model:note="note" class="note"/> </div> <div v-if="clips && clips.length > 0" class="clips _margin"> <div class="title">{{ i18n.ts.clip }}</div> @@ -41,7 +41,7 @@ <script lang="ts" setup> import { computed, watch } from 'vue'; import * as misskey from 'misskey-js'; -import XNoteDetailed from '@/components/MkNoteDetailed.vue'; +import MkNoteDetailed from '@/components/MkNoteDetailed.vue'; import MkNotes from '@/components/MkNotes.vue'; import MkRemoteCaution from '@/components/MkRemoteCaution.vue'; import MkButton from '@/components/MkButton.vue'; @@ -50,6 +50,7 @@ import { definePageMetadata } from '@/scripts/page-metadata'; import { i18n } from '@/i18n'; import { dateString } from '@/filters/date'; import MkClipPreview from '@/components/MkClipPreview.vue'; +import { defaultStore } from '@/store'; const props = defineProps<{ noteId: string; diff --git a/packages/frontend/src/pages/notifications.vue b/packages/frontend/src/pages/notifications.vue index a5c7cdaa71..1789606cd8 100644 --- a/packages/frontend/src/pages/notifications.vue +++ b/packages/frontend/src/pages/notifications.vue @@ -2,8 +2,8 @@ <MkStickyContainer> <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> <MkSpacer :content-max="800"> - <div v-if="tab === 'all' || tab === 'unread'"> - <XNotifications class="notifications" :include-types="includeTypes" :unread-only="unreadOnly"/> + <div v-if="tab === 'all'"> + <XNotifications class="notifications" :include-types="includeTypes"/> </div> <div v-else-if="tab === 'mentions'"> <MkNotes :pagination="mentionsPagination"/> @@ -26,7 +26,6 @@ import { notificationTypes } from '@/const'; let tab = $ref('all'); let includeTypes = $ref<string[] | null>(null); -let unreadOnly = $computed(() => tab === 'unread'); const mentionsPagination = { endpoint: 'notes/mentions' as const, @@ -77,10 +76,6 @@ const headerTabs = $computed(() => [{ title: i18n.ts.all, icon: 'ti ti-point', }, { - key: 'unread', - title: i18n.ts.unread, - icon: 'ti ti-loader', -}, { key: 'mentions', title: i18n.ts.mentions, icon: 'ti ti-at', 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 fe230ad095..ffeb8ba285 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 @@ -1,7 +1,7 @@ <template> <!-- eslint-disable vue/no-mutating-props --> <XContainer :draggable="true" @remove="() => $emit('remove')"> - <template #header><i class="ti ti-photo"></i> {{ $ts._pages.blocks.image }}</template> + <template #header><i class="ti ti-photo"></i> {{ i18n.ts._pages.blocks.image }}</template> <template #func> <button @click="choose()"> <i class="ti ti-folder"></i> @@ -20,6 +20,7 @@ import { onMounted } from 'vue'; import XContainer from '../page-editor.container.vue'; import MkDriveFileThumbnail from '@/components/MkDriveFileThumbnail.vue'; import * as os from '@/os'; +import { i18n } from '@/i18n'; const props = defineProps<{ modelValue: any 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 d8a7eb85aa..a388a8d0c1 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 @@ -1,17 +1,17 @@ <template> <!-- eslint-disable vue/no-mutating-props --> <XContainer :draggable="true" @remove="() => $emit('remove')"> - <template #header><i class="ti ti-note"></i> {{ $ts._pages.blocks.note }}</template> + <template #header><i class="ti ti-note"></i> {{ i18n.ts._pages.blocks.note }}</template> <section style="padding: 0 16px 0 16px;"> <MkInput v-model="id"> - <template #label>{{ $ts._pages.blocks._note.id }}</template> - <template #caption>{{ $ts._pages.blocks._note.idDescription }}</template> + <template #label>{{ i18n.ts._pages.blocks._note.id }}</template> + <template #caption>{{ i18n.ts._pages.blocks._note.idDescription }}</template> </MkInput> - <MkSwitch v-model="props.modelValue.detailed"><span>{{ $ts._pages.blocks._note.detailed }}</span></MkSwitch> + <MkSwitch v-model="props.modelValue.detailed"><span>{{ i18n.ts._pages.blocks._note.detailed }}</span></MkSwitch> - <XNote v-if="note && !props.modelValue.detailed" :key="note.id + ':normal'" v-model:note="note" style="margin-bottom: 16px;"/> - <XNoteDetailed v-if="note && props.modelValue.detailed" :key="note.id + ':detail'" v-model:note="note" style="margin-bottom: 16px;"/> + <MkNote v-if="note && !props.modelValue.detailed" :key="note.id + ':normal'" v-model:note="note" style="margin-bottom: 16px;"/> + <MkNoteDetailed v-if="note && props.modelValue.detailed" :key="note.id + ':detail'" v-model:note="note" style="margin-bottom: 16px;"/> </section> </XContainer> </template> @@ -22,9 +22,10 @@ import { watch } from 'vue'; import XContainer from '../page-editor.container.vue'; import MkInput from '@/components/MkInput.vue'; import MkSwitch from '@/components/MkSwitch.vue'; -import XNote from '@/components/MkNote.vue'; -import XNoteDetailed from '@/components/MkNoteDetailed.vue'; +import MkNote from '@/components/MkNote.vue'; +import MkNoteDetailed from '@/components/MkNoteDetailed.vue'; import * as os from '@/os'; +import { i18n } from '@/i18n'; const props = defineProps<{ modelValue: any 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 ee494b7574..bf21ae3c67 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 @@ -1,7 +1,7 @@ <template> <!-- eslint-disable vue/no-mutating-props --> <XContainer :draggable="true" @remove="() => $emit('remove')"> - <template #header><i class="ti ti-align-left"></i> {{ $ts._pages.blocks.text }}</template> + <template #header><i class="ti ti-align-left"></i> {{ i18n.ts._pages.blocks.text }}</template> <section class="vckmsadr"> <textarea v-model="text"></textarea> @@ -13,6 +13,7 @@ /* eslint-disable vue/no-mutating-props */ import { watch } from 'vue'; import XContainer from '../page-editor.container.vue'; +import { i18n } from '@/i18n'; const props = defineProps<{ modelValue: any diff --git a/packages/frontend/src/pages/page-editor/page-editor.container.vue b/packages/frontend/src/pages/page-editor/page-editor.container.vue index 15cdda5efb..dd733403af 100644 --- a/packages/frontend/src/pages/page-editor/page-editor.container.vue +++ b/packages/frontend/src/pages/page-editor/page-editor.container.vue @@ -16,8 +16,8 @@ </button> </div> </header> - <p v-show="showBody" v-if="error != null" class="error">{{ $t('_pages.script.typeError', { slot: error.arg + 1, expect: $t(`script.types.${error.expect}`), actual: $t(`script.types.${error.actual}`) }) }}</p> - <p v-show="showBody" v-if="warn != null" class="warn">{{ $t('_pages.script.thereIsEmptySlot', { slot: warn.slot + 1 }) }}</p> + <p v-show="showBody" v-if="error != null" class="error">{{ i18n.t('_pages.script.typeError', { slot: error.arg + 1, expect: i18n.t(`script.types.${error.expect}`), actual: i18n.t(`script.types.${error.actual}`) }) }}</p> + <p v-show="showBody" v-if="warn != null" class="warn">{{ i18n.t('_pages.script.thereIsEmptySlot', { slot: warn.slot + 1 }) }}</p> <div v-show="showBody" class="body"> <slot></slot> </div> @@ -26,6 +26,7 @@ <script lang="ts"> import { defineComponent } from 'vue'; +import { i18n } from '@/i18n'; export default defineComponent({ props: { @@ -54,6 +55,7 @@ export default defineComponent({ data() { return { showBody: this.expanded, + i18n, }; }, methods: { diff --git a/packages/frontend/src/pages/page-editor/page-editor.vue b/packages/frontend/src/pages/page-editor/page-editor.vue index c4b37c91c6..bcf30e23a7 100644 --- a/packages/frontend/src/pages/page-editor/page-editor.vue +++ b/packages/frontend/src/pages/page-editor/page-editor.vue @@ -3,42 +3,42 @@ <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> <MkSpacer :content-max="700"> <div class="jqqmcavi"> - <MkButton v-if="pageId" class="button" inline link :to="`/@${ author.username }/pages/${ currentName }`"><i class="ti ti-external-link"></i> {{ $ts._pages.viewPage }}</MkButton> - <MkButton v-if="!readonly" inline primary class="button" @click="save"><i class="ti ti-device-floppy"></i> {{ $ts.save }}</MkButton> - <MkButton v-if="pageId" inline class="button" @click="duplicate"><i class="ti ti-copy"></i> {{ $ts.duplicate }}</MkButton> - <MkButton v-if="pageId && !readonly" inline class="button" danger @click="del"><i class="ti ti-trash"></i> {{ $ts.delete }}</MkButton> + <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> + <MkButton v-if="!readonly" inline primary class="button" @click="save"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton> + <MkButton v-if="pageId" inline class="button" @click="duplicate"><i class="ti ti-copy"></i> {{ i18n.ts.duplicate }}</MkButton> + <MkButton v-if="pageId && !readonly" inline class="button" danger @click="del"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton> </div> <div v-if="tab === 'settings'"> <div class="_gaps_m"> <MkInput v-model="title"> - <template #label>{{ $ts._pages.title }}</template> + <template #label>{{ i18n.ts._pages.title }}</template> </MkInput> <MkInput v-model="summary"> - <template #label>{{ $ts._pages.summary }}</template> + <template #label>{{ i18n.ts._pages.summary }}</template> </MkInput> <MkInput v-model="name"> <template #prefix>{{ url }}/@{{ author.username }}/pages/</template> - <template #label>{{ $ts._pages.url }}</template> + <template #label>{{ i18n.ts._pages.url }}</template> </MkInput> - <MkSwitch v-model="alignCenter">{{ $ts._pages.alignCenter }}</MkSwitch> + <MkSwitch v-model="alignCenter">{{ i18n.ts._pages.alignCenter }}</MkSwitch> <MkSelect v-model="font"> - <template #label>{{ $ts._pages.font }}</template> - <option value="serif">{{ $ts._pages.fontSerif }}</option> - <option value="sans-serif">{{ $ts._pages.fontSansSerif }}</option> + <template #label>{{ i18n.ts._pages.font }}</template> + <option value="serif">{{ i18n.ts._pages.fontSerif }}</option> + <option value="sans-serif">{{ i18n.ts._pages.fontSansSerif }}</option> </MkSelect> - <MkSwitch v-model="hideTitleWhenPinned">{{ $ts._pages.hideTitleWhenPinned }}</MkSwitch> + <MkSwitch v-model="hideTitleWhenPinned">{{ i18n.ts._pages.hideTitleWhenPinned }}</MkSwitch> <div class="eyeCatch"> - <MkButton v-if="eyeCatchingImageId == null && !readonly" @click="setEyeCatchingImage"><i class="ti ti-plus"></i> {{ $ts._pages.eyeCatchingImageSet }}</MkButton> + <MkButton v-if="eyeCatchingImageId == null && !readonly" @click="setEyeCatchingImage"><i class="ti ti-plus"></i> {{ i18n.ts._pages.eyeCatchingImageSet }}</MkButton> <div v-else-if="eyeCatchingImage"> <img :src="eyeCatchingImage.url" :alt="eyeCatchingImage.name" style="max-width: 100%;"/> - <MkButton v-if="!readonly" @click="removeEyeCatchingImage()"><i class="ti ti-trash"></i> {{ $ts._pages.eyeCatchingImageRemove }}</MkButton> + <MkButton v-if="!readonly" @click="removeEyeCatchingImage()"><i class="ti ti-trash"></i> {{ i18n.ts._pages.eyeCatchingImageRemove }}</MkButton> </div> </div> </div> diff --git a/packages/frontend/src/pages/page.vue b/packages/frontend/src/pages/page.vue index b26255ce61..5a0f58c8df 100644 --- a/packages/frontend/src/pages/page.vue +++ b/packages/frontend/src/pages/page.vue @@ -2,7 +2,7 @@ <MkStickyContainer> <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> <MkSpacer :content-max="700"> - <Transition :name="$store.state.animation ? 'fade' : ''" mode="out-in"> + <Transition :name="defaultStore.state.animation ? 'fade' : ''" mode="out-in"> <div v-if="page" :key="page.id" class="xcukqgmh"> <div class="main"> <!-- @@ -75,8 +75,9 @@ import MkPagination from '@/components/MkPagination.vue'; import MkPagePreview from '@/components/MkPagePreview.vue'; import { i18n } from '@/i18n'; import { definePageMetadata } from '@/scripts/page-metadata'; -import { pageViewInterruptors } from '@/store'; +import { pageViewInterruptors, defaultStore } from '@/store'; import { deepClone } from '@/scripts/clone'; +import { $i } from '@/account'; const props = defineProps<{ pageName: string; diff --git a/packages/frontend/src/pages/search.vue b/packages/frontend/src/pages/search.vue index cc6f8cc0cc..5523d5cf4d 100644 --- a/packages/frontend/src/pages/search.vue +++ b/packages/frontend/src/pages/search.vue @@ -56,6 +56,9 @@ import MkFoldableSection from '@/components/MkFoldableSection.vue'; import { $i } from '@/account'; import { instance } from '@/instance'; import MkInfo from '@/components/MkInfo.vue'; +import { useRouter } from '@/router'; + +const router = useRouter(); const props = defineProps<{ query: string; @@ -84,6 +87,24 @@ async function search() { if (query == null || query === '') return; + if (query.startsWith('https://')) { + const promise = os.api('ap/show', { + uri: query, + }); + + os.promiseDialog(promise, null, null, i18n.ts.fetchingAsApObject); + + const res = await promise; + + if (res.type === 'User') { + router.push(`/@${res.object.username}@${res.object.host}`); + } else if (res.type === 'Note') { + router.push(`/notes/${res.object.id}`); + } + + return; + } + if (tab === 'note') { notePagination = { endpoint: 'notes/search', diff --git a/packages/frontend/src/pages/settings/apps.vue b/packages/frontend/src/pages/settings/apps.vue index 861414cef8..955d812154 100644 --- a/packages/frontend/src/pages/settings/apps.vue +++ b/packages/frontend/src/pages/settings/apps.vue @@ -24,7 +24,7 @@ <details> <summary>{{ i18n.ts.details }}</summary> <ul> - <li v-for="p in token.permission" :key="p">{{ $t(`_permissions.${p}`) }}</li> + <li v-for="p in token.permission" :key="p">{{ i18n.t(`_permissions.${p}`) }}</li> </ul> </details> <div class="actions"> diff --git a/packages/frontend/src/pages/settings/delete-account.vue b/packages/frontend/src/pages/settings/delete-account.vue index bbd5513954..c6e79165c5 100644 --- a/packages/frontend/src/pages/settings/delete-account.vue +++ b/packages/frontend/src/pages/settings/delete-account.vue @@ -11,7 +11,7 @@ import FormInfo from '@/components/MkInfo.vue'; import MkButton from '@/components/MkButton.vue'; import * as os from '@/os'; -import { signout } from '@/account'; +import { signout, $i } from '@/account'; import { i18n } from '@/i18n'; import { definePageMetadata } from '@/scripts/page-metadata'; diff --git a/packages/frontend/src/pages/settings/general.vue b/packages/frontend/src/pages/settings/general.vue index dd62a32530..f88e934e1d 100644 --- a/packages/frontend/src/pages/settings/general.vue +++ b/packages/frontend/src/pages/settings/general.vue @@ -48,6 +48,7 @@ <div class="_gaps_s"> <MkSwitch v-model="showNoteActionsOnlyHover">{{ i18n.ts.showNoteActionsOnlyHover }}</MkSwitch> <MkSwitch v-model="showClipButtonInNoteFooter">{{ i18n.ts.showClipButtonInNoteFooter }}</MkSwitch> + <MkSwitch v-model="largeNoteReactions">{{ i18n.ts.largeNoteReactions }}</MkSwitch> <MkSwitch v-model="collapseRenotes">{{ i18n.ts.collapseRenotes }}</MkSwitch> <MkSwitch v-model="advancedMfm">{{ i18n.ts.enableAdvancedMfm }}</MkSwitch> <MkSwitch v-if="advancedMfm" v-model="animatedMfm">{{ i18n.ts.enableAnimatedMfm }}</MkSwitch> @@ -145,6 +146,7 @@ const overridedDeviceKind = computed(defaultStore.makeGetterSetter('overridedDev const serverDisconnectedBehavior = computed(defaultStore.makeGetterSetter('serverDisconnectedBehavior')); const showNoteActionsOnlyHover = computed(defaultStore.makeGetterSetter('showNoteActionsOnlyHover')); const showClipButtonInNoteFooter = computed(defaultStore.makeGetterSetter('showClipButtonInNoteFooter')); +const largeNoteReactions = computed(defaultStore.makeGetterSetter('largeNoteReactions')); const collapseRenotes = computed(defaultStore.makeGetterSetter('collapseRenotes')); const reduceAnimation = computed(defaultStore.makeGetterSetter('animation', v => !v, v => !v)); const useBlurEffectForModal = computed(defaultStore.makeGetterSetter('useBlurEffectForModal')); diff --git a/packages/frontend/src/pages/settings/index.vue b/packages/frontend/src/pages/settings/index.vue index ae36466eec..17af7417fd 100644 --- a/packages/frontend/src/pages/settings/index.vue +++ b/packages/frontend/src/pages/settings/index.vue @@ -130,11 +130,6 @@ const menuDef = computed(() => [{ }, { title: i18n.ts.otherSettings, items: [{ - icon: 'ti ti-package', - text: i18n.ts.importAndExport, - to: '/settings/import-export', - active: currentPage?.route.name === 'import-export', - }, { icon: 'ti ti-badges', text: i18n.ts.roles, to: '/settings/roles', @@ -165,6 +160,16 @@ const menuDef = computed(() => [{ to: '/settings/webhook', active: currentPage?.route.name === 'webhook', }, { + icon: 'ti ti-package', + text: i18n.ts.importAndExport, + to: '/settings/import-export', + active: currentPage?.route.name === 'import-export', + }, /*{ + icon: 'ti ti-plane', + text: i18n.ts.accountMigration, + to: '/settings/migration', + active: currentPage?.route.name === 'migration', + },*/ { icon: 'ti ti-dots', text: i18n.ts.other, to: '/settings/other', @@ -231,7 +236,7 @@ onUnmounted(() => { }); watch(router.currentRef, (to) => { - if (to.route.name === "settings" && to.child?.route.name == null && !narrow) { + if (to.route.name === 'settings' && to.child?.route.name == null && !narrow) { router.replace('/settings/profile'); } }); diff --git a/packages/frontend/src/pages/settings/migration.vue b/packages/frontend/src/pages/settings/migration.vue new file mode 100644 index 0000000000..2ef8af7481 --- /dev/null +++ b/packages/frontend/src/pages/settings/migration.vue @@ -0,0 +1,73 @@ +<template> +<div class="_gaps_m"> + <FormSection first> + <template #label>{{ i18n.ts._accountMigration.moveTo }}</template> + <MkInput v-model="moveToAccount" manual-save> + <template #prefix><i class="ti ti-plane-departure"></i></template> + <template #label>{{ i18n.ts._accountMigration.moveToLabel }}</template> + </MkInput> + </FormSection> + <FormInfo warn>{{ i18n.ts._accountMigration.moveAccountDescription }}</FormInfo> + + <FormSection> + <template #label>{{ i18n.ts._accountMigration.moveFrom }}</template> + <MkInput v-model="accountAlias" manual-save> + <template #prefix><i class="ti ti-plane-arrival"></i></template> + <template #label>{{ i18n.ts._accountMigration.moveFromLabel }}</template> + </MkInput> + </FormSection> + <FormInfo warn>{{ i18n.ts._accountMigration.moveFromDescription }}</FormInfo> +</div> +</template> + +<script lang="ts" setup> +import { ref, watch } from 'vue'; +import FormSection from '@/components/form/section.vue'; +import FormInfo from '@/components/MkInfo.vue'; +import MkInput from '@/components/MkInput.vue'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const moveToAccount = ref(''); +const accountAlias = ref(''); + +async function move(): Promise<void> { + const account = moveToAccount.value; + const confirm = await os.confirm({ + type: 'warning', + text: i18n.t('migrationConfirm', { account: account.toString() }), + }); + if (confirm.canceled) return; + os.apiWithDialog('i/move', { + moveToAccount: account, + }); +} + +async function save(): Promise<void> { + const account = accountAlias.value; + os.apiWithDialog('i/known-as', { + alsoKnownAs: account, + }); +} + +watch(accountAlias, async () => { + await save(); +}); + +watch(moveToAccount, async () => { + await move(); +}); + +definePageMetadata({ + title: i18n.ts.accountMigration, + icon: 'ti ti-plane', +}); +</script> + +<style lang="scss"> +.description { + font-size: .85em; + padding: 1rem; +} +</style> diff --git a/packages/frontend/src/pages/settings/sounds.vue b/packages/frontend/src/pages/settings/sounds.vue index 006a2377d4..8855a275c6 100644 --- a/packages/frontend/src/pages/settings/sounds.vue +++ b/packages/frontend/src/pages/settings/sounds.vue @@ -8,7 +8,7 @@ <template #label>{{ i18n.ts.sounds }}</template> <div class="_gaps_s"> <MkFolder v-for="type in Object.keys(sounds)" :key="type"> - <template #label>{{ $t('_sfx.' + type) }}</template> + <template #label>{{ i18n.t('_sfx.' + type) }}</template> <template #suffix>{{ sounds[type].type ?? i18n.ts.none }}</template> <XSound :type="sounds[type].type" :volume="sounds[type].volume" @update="(res) => updated(type, res)"/> diff --git a/packages/frontend/src/pages/timeline.vue b/packages/frontend/src/pages/timeline.vue index d982a76d03..9f13f7a1dd 100644 --- a/packages/frontend/src/pages/timeline.vue +++ b/packages/frontend/src/pages/timeline.vue @@ -3,8 +3,8 @@ <template #header><MkPageHeader v-model:tab="src" :actions="headerActions" :tabs="$i ? headerTabs : headerTabsWhenNotLogin" :display-my-avatar="true"/></template> <MkSpacer :content-max="800"> <div ref="rootEl" v-hotkey.global="keymap"> - <XTutorial v-if="$i && $store.reactiveState.tutorial.value != -1" class="_panel" style="margin-bottom: var(--margin);"/> - <MkPostForm v-if="$store.reactiveState.showFixedPostForm.value" :class="$style.postForm" class="post-form _panel" fixed style="margin-bottom: var(--margin);"/> + <XTutorial v-if="$i && defaultStore.reactiveState.tutorial.value != -1" class="_panel" style="margin-bottom: var(--margin);"/> + <MkPostForm v-if="defaultStore.reactiveState.showFixedPostForm.value" :class="$style.postForm" class="post-form _panel" fixed style="margin-bottom: var(--margin);"/> <div v-if="queue > 0" :class="$style.new"><button class="_buttonPrimary" @click="top()">{{ i18n.ts.newNoteRecived }}</button></div> <div :class="$style.tl"> @@ -83,7 +83,7 @@ async function chooseAntenna(ev: MouseEvent): Promise<void> { } async function chooseChannel(ev: MouseEvent): Promise<void> { - const channels = await os.api('channels/followed', { + const channels = await os.api('channels/my-favorites', { limit: 100, }); const items = channels.map(channel => ({ diff --git a/packages/frontend/src/pages/user-info.vue b/packages/frontend/src/pages/user-info.vue index 571f058240..94718d1533 100644 --- a/packages/frontend/src/pages/user-info.vue +++ b/packages/frontend/src/pages/user-info.vue @@ -192,7 +192,7 @@ import { url } from '@/config'; import { userPage, acct } from '@/filters/user'; import { definePageMetadata } from '@/scripts/page-metadata'; import { i18n } from '@/i18n'; -import { iAmAdmin, iAmModerator } from '@/account'; +import { iAmAdmin, iAmModerator, $i } from '@/account'; import MkRolePreview from '@/components/MkRolePreview.vue'; const props = withDefaults(defineProps<{ diff --git a/packages/frontend/src/pages/user/activity.following.vue b/packages/frontend/src/pages/user/activity.following.vue index 54360024f3..1c7c991aac 100644 --- a/packages/frontend/src/pages/user/activity.following.vue +++ b/packages/frontend/src/pages/user/activity.following.vue @@ -77,7 +77,10 @@ async function renderChart() { barPercentage: 0.7, categoryPercentage: 0.7, fill: true, - } satisfies ChartDataset, extra); + /* @see <https://github.com/misskey-dev/misskey/pull/10365#discussion_r1155511107> + } satisfies ChartData, extra); + */ + }, extra); } chartInstance = new Chart(chartEl, { diff --git a/packages/frontend/src/pages/user/activity.heatmap.vue b/packages/frontend/src/pages/user/activity.heatmap.vue index 2dcb754c9b..ada0166eda 100644 --- a/packages/frontend/src/pages/user/activity.heatmap.vue +++ b/packages/frontend/src/pages/user/activity.heatmap.vue @@ -113,6 +113,9 @@ async function renderChart() { const a = c.chart.chartArea ?? {}; return (a.bottom - a.top) / 7 - marginEachCell; }, + /* @see <https://github.com/misskey-dev/misskey/pull/10365#discussion_r1155511107> + }] satisfies ChartData[], + */ }], }, options: { diff --git a/packages/frontend/src/pages/user/activity.notes.vue b/packages/frontend/src/pages/user/activity.notes.vue index 7dd02ad6d4..8a946aebac 100644 --- a/packages/frontend/src/pages/user/activity.notes.vue +++ b/packages/frontend/src/pages/user/activity.notes.vue @@ -76,7 +76,10 @@ async function renderChart() { borderRadius: 4, barPercentage: 0.9, fill: true, - } satisfies ChartDataset, extra); + /* @see <https://github.com/misskey-dev/misskey/pull/10365#discussion_r1155511107> + } satisfies ChartData, extra); + */ + }, extra); } chartInstance = new Chart(chartEl, { diff --git a/packages/frontend/src/pages/user/activity.pv.vue b/packages/frontend/src/pages/user/activity.pv.vue index 6a7506e388..0e9c581e1e 100644 --- a/packages/frontend/src/pages/user/activity.pv.vue +++ b/packages/frontend/src/pages/user/activity.pv.vue @@ -77,7 +77,10 @@ async function renderChart() { barPercentage: 0.7, categoryPercentage: 0.7, fill: true, - } satisfies ChartDataset, extra); + /* @see <https://github.com/misskey-dev/misskey/pull/10365#discussion_r1155511107> + } satisfies ChartData, extra); + */ + }, extra); } chartInstance = new Chart(chartEl, { diff --git a/packages/frontend/src/pages/user/home.vue b/packages/frontend/src/pages/user/home.vue index 7efaaebf5d..8c3478d8f2 100644 --- a/packages/frontend/src/pages/user/home.vue +++ b/packages/frontend/src/pages/user/home.vue @@ -7,6 +7,7 @@ <!-- <div class="punished" v-if="user.isSilenced"><i class="ti ti-alert-triangle" style="margin-right: 8px;"></i> {{ i18n.ts.userSilenced }}</div> --> <div class="profile _gaps"> + <MkAccountMoved v-if="user.movedToUri" :host="user.movedToUri.host" :acct="user.movedToUri.username"/> <MkRemoteCaution v-if="user.host != null" :href="user.url ?? user.uri!" class="warn"/> <div :key="user.id" class="main _panel"> @@ -57,7 +58,7 @@ </dl> <dl v-if="user.birthday" class="field"> <dt class="name"><i class="ti ti-cake ti-fw"></i> {{ i18n.ts.birthday }}</dt> - <dd class="value">{{ user.birthday.replace('-', '/').replace('-', '/') }} ({{ $t('yearsOld', { age }) }})</dd> + <dd class="value">{{ user.birthday.replace('-', '/').replace('-', '/') }} ({{ i18n.t('yearsOld', { age }) }})</dd> </dl> <dl class="field"> <dt class="name"><i class="ti ti-calendar ti-fw"></i> {{ i18n.ts.registeredDate }}</dt> @@ -93,7 +94,7 @@ <div class="contents _gaps"> <div v-if="user.pinnedNotes.length > 0" class="_gaps"> - <XNote v-for="note in user.pinnedNotes" :key="note.id" class="note _panel" :note="note" :pinned="true"/> + <MkNote v-for="note in user.pinnedNotes" :key="note.id" class="note _panel" :note="note" :pinned="true"/> </div> <MkInfo v-else-if="$i && $i.id === user.id">{{ i18n.ts.userPagePinTip }}</MkInfo> <template v-if="narrow"> @@ -115,8 +116,9 @@ import { defineAsyncComponent, computed, onMounted, onUnmounted } from 'vue'; import calcAge from 's-age'; import * as misskey from 'misskey-js'; -import XNote from '@/components/MkNote.vue'; +import MkNote from '@/components/MkNote.vue'; import MkFollowButton from '@/components/MkFollowButton.vue'; +import MkAccountMoved from '@/components/MkAccountMoved.vue'; import MkRemoteCaution from '@/components/MkRemoteCaution.vue'; import MkOmit from '@/components/MkOmit.vue'; import MkInfo from '@/components/MkInfo.vue'; diff --git a/packages/frontend/src/pages/user/index.activity.vue b/packages/frontend/src/pages/user/index.activity.vue index 8ff3374446..2d9ee85bc4 100644 --- a/packages/frontend/src/pages/user/index.activity.vue +++ b/packages/frontend/src/pages/user/index.activity.vue @@ -1,7 +1,7 @@ <template> <MkContainer> <template #icon><i class="ti ti-chart-line"></i></template> - <template #header>{{ $ts.activity }}</template> + <template #header>{{ i18n.ts.activity }}</template> <template #func="{ buttonStyleClass }"> <button class="_button" :class="buttonStyleClass" @click="showMenu"> <i class="ti ti-dots"></i> diff --git a/packages/frontend/src/pages/user/index.photos.vue b/packages/frontend/src/pages/user/index.photos.vue index 85f6591eee..3b0b250f24 100644 --- a/packages/frontend/src/pages/user/index.photos.vue +++ b/packages/frontend/src/pages/user/index.photos.vue @@ -1,7 +1,7 @@ <template> <MkContainer :max-height="300" :foldable="true"> <template #icon><i class="ti ti-photo"></i></template> - <template #header>{{ $ts.images }}</template> + <template #header>{{ i18n.ts.images }}</template> <div :class="$style.root"> <MkLoading v-if="fetching"/> <div v-if="!fetching && images.length > 0" :class="$style.stream"> @@ -14,7 +14,7 @@ <ImgWithBlurhash :hash="image.file.blurhash" :src="thumbnail(image.file)" :title="image.file.name"/> </MkA> </div> - <p v-if="!fetching && images.length == 0" :class="$style.empty">{{ $ts.nothing }}</p> + <p v-if="!fetching && images.length == 0" :class="$style.empty">{{ i18n.ts.nothing }}</p> </div> </MkContainer> </template> @@ -28,6 +28,7 @@ import * as os from '@/os'; import MkContainer from '@/components/MkContainer.vue'; import ImgWithBlurhash from '@/components/MkImgWithBlurhash.vue'; import { defaultStore } from '@/store'; +import { i18n } from '@/i18n'; const props = defineProps<{ user: misskey.entities.UserDetailed; diff --git a/packages/frontend/src/pages/welcome.entrance.a.vue b/packages/frontend/src/pages/welcome.entrance.a.vue index b6f9b3eb23..4d8d76db18 100644 --- a/packages/frontend/src/pages/welcome.entrance.a.vue +++ b/packages/frontend/src/pages/welcome.entrance.a.vue @@ -14,7 +14,7 @@ </div> <div class="contents"> <div class="main"> - <img :src="$instance.iconUrl || $instance.faviconUrl || '/favicon.ico'" alt="" class="icon"/> + <img :src="instance.iconUrl || instance.faviconUrl || '/favicon.ico'" alt="" class="icon"/> <button class="_button _acrylic menu" @click="showMenu"><i class="ti ti-dots"></i></button> <div class="fg"> <h1> diff --git a/packages/frontend/src/pages/welcome.entrance.b.vue b/packages/frontend/src/pages/welcome.entrance.b.vue index 8230adaf1f..03bf174710 100644 --- a/packages/frontend/src/pages/welcome.entrance.b.vue +++ b/packages/frontend/src/pages/welcome.entrance.b.vue @@ -10,22 +10,22 @@ </h1> <div class="about"> <!-- eslint-disable-next-line vue/no-v-html --> - <div class="desc" v-html="meta.description || $ts.headlineMisskey"></div> + <div class="desc" v-html="meta.description || i18n.ts.headlineMisskey"></div> </div> <div class="action"> - <MkButton class="signup" inline gradate @click="signup()">{{ $ts.signup }}</MkButton> - <MkButton class="signin" inline @click="signin()">{{ $ts.login }}</MkButton> + <MkButton class="signup" inline gradate @click="signup()">{{ i18n.ts.signup }}</MkButton> + <MkButton class="signin" inline @click="signin()">{{ i18n.ts.login }}</MkButton> </div> <div v-if="onlineUsersCount && stats" class="status"> <div> - <I18n :src="$ts.nUsers" text-tag="span" class="users"> + <I18n :src="i18n.ts.nUsers" text-tag="span" class="users"> <template #n><b>{{ number(stats.originalUsersCount) }}</b></template> </I18n> - <I18n :src="$ts.nNotes" text-tag="span" class="notes"> + <I18n :src="i18n.ts.nNotes" text-tag="span" class="notes"> <template #n><b>{{ number(stats.originalNotesCount) }}</b></template> </I18n> </div> - <I18n :src="$ts.onlineUsersCount" text-tag="span" class="online"> + <I18n :src="i18n.ts.onlineUsersCount" text-tag="span" class="online"> <template #n><b>{{ onlineUsersCount }}</b></template> </I18n> </div> @@ -38,20 +38,21 @@ <script lang="ts"> import { defineComponent } from 'vue'; import { toUnicode } from 'punycode/'; +import XTimeline from './welcome.timeline.vue'; import XSigninDialog from '@/components/MkSigninDialog.vue'; import XSignupDialog from '@/components/MkSignupDialog.vue'; import MkButton from '@/components/MkButton.vue'; -import XNote from '@/components/MkNote.vue'; +import MkNote from '@/components/MkNote.vue'; import MkFeaturedPhotos from '@/components/MkFeaturedPhotos.vue'; -import XTimeline from './welcome.timeline.vue'; import { host, instanceName } from '@/config'; import * as os from '@/os'; import number from '@/filters/number'; +import { i18n } from '@/i18n'; export default defineComponent({ components: { MkButton, - XNote, + MkNote, XTimeline, MkFeaturedPhotos, }, @@ -64,6 +65,7 @@ export default defineComponent({ stats: null, tags: [], onlineUsersCount: null, + i18n, }; }, @@ -103,22 +105,22 @@ export default defineComponent({ showMenu(ev) { os.popupMenu([{ - text: this.$t('aboutX', { x: instanceName }), + text: i18n.t('aboutX', { x: instanceName }), icon: 'ti ti-info-circle', action: () => { os.pageWindow('/about'); }, }, { - text: this.$ts.aboutMisskey, + text: i18n.ts.aboutMisskey, icon: 'ti ti-info-circle', action: () => { os.pageWindow('/about-misskey'); }, }, null, { - text: this.$ts.help, + text: i18n.ts.help, icon: 'ti ti-question-circle', action: () => { - window.open(`https://misskey-hub.net/help.md`, '_blank'); + window.open('https://misskey-hub.net/help.md', '_blank'); }, }], ev.currentTarget ?? ev.target); }, diff --git a/packages/frontend/src/pages/welcome.entrance.c.vue b/packages/frontend/src/pages/welcome.entrance.c.vue index d2d07bb1f0..eca4e5764d 100644 --- a/packages/frontend/src/pages/welcome.entrance.c.vue +++ b/packages/frontend/src/pages/welcome.entrance.c.vue @@ -22,22 +22,22 @@ </h1> <div class="about"> <!-- eslint-disable-next-line vue/no-v-html --> - <div class="desc" v-html="meta.description || $ts.headlineMisskey"></div> + <div class="desc" v-html="meta.description || i18n.ts.headlineMisskey"></div> </div> <div class="action"> - <MkButton inline gradate @click="signup()">{{ $ts.signup }}</MkButton> - <MkButton inline @click="signin()">{{ $ts.login }}</MkButton> + <MkButton inline gradate @click="signup()">{{ i18n.ts.signup }}</MkButton> + <MkButton inline @click="signin()">{{ i18n.ts.login }}</MkButton> </div> <div v-if="onlineUsersCount && stats" class="status"> <div> - <I18n :src="$ts.nUsers" text-tag="span" class="users"> + <I18n :src="i18n.ts.nUsers" text-tag="span" class="users"> <template #n><b>{{ number(stats.originalUsersCount) }}</b></template> </I18n> - <I18n :src="$ts.nNotes" text-tag="span" class="notes"> + <I18n :src="i18n.ts.nNotes" text-tag="span" class="notes"> <template #n><b>{{ number(stats.originalNotesCount) }}</b></template> </I18n> </div> - <I18n :src="$ts.onlineUsersCount" text-tag="span" class="online"> + <I18n :src="i18n.ts.onlineUsersCount" text-tag="span" class="online"> <template #n><b>{{ onlineUsersCount }}</b></template> </I18n> </div> @@ -45,10 +45,10 @@ </div> </div> <nav class="nav"> - <MkA to="/announcements">{{ $ts.announcements }}</MkA> - <MkA to="/explore">{{ $ts.explore }}</MkA> - <MkA to="/channels">{{ $ts.channel }}</MkA> - <MkA to="/featured">{{ $ts.featured }}</MkA> + <MkA to="/announcements">{{ i18n.ts.announcements }}</MkA> + <MkA to="/explore">{{ i18n.ts.explore }}</MkA> + <MkA to="/channels">{{ i18n.ts.channel }}</MkA> + <MkA to="/featured">{{ i18n.ts.featured }}</MkA> </nav> </div> </div> @@ -58,20 +58,21 @@ <script lang="ts"> import { defineComponent } from 'vue'; import { toUnicode } from 'punycode/'; +import XTimeline from './welcome.timeline.vue'; import XSigninDialog from '@/components/MkSigninDialog.vue'; import XSignupDialog from '@/components/MkSignupDialog.vue'; import MkButton from '@/components/MkButton.vue'; -import XNote from '@/components/MkNote.vue'; +import MkNote from '@/components/MkNote.vue'; import MkFeaturedPhotos from '@/components/MkFeaturedPhotos.vue'; -import XTimeline from './welcome.timeline.vue'; import { host, instanceName } from '@/config'; import * as os from '@/os'; import number from '@/filters/number'; +import { i18n } from '@/i18n'; export default defineComponent({ components: { MkButton, - XNote, + MkNote, MkFeaturedPhotos, XTimeline, }, @@ -84,6 +85,7 @@ export default defineComponent({ stats: null, tags: [], onlineUsersCount: null, + i18n, }; }, @@ -123,22 +125,22 @@ export default defineComponent({ showMenu(ev) { os.popupMenu([{ - text: this.$t('aboutX', { x: instanceName }), + text: i18n.t('aboutX', { x: instanceName }), icon: 'ti ti-info-circle', action: () => { os.pageWindow('/about'); }, }, { - text: this.$ts.aboutMisskey, + text: i18n.ts.aboutMisskey, icon: 'ti ti-info-circle', action: () => { os.pageWindow('/about-misskey'); }, }, null, { - text: this.$ts.help, + text: i18n.ts.help, icon: 'ti ti-question-circle', action: () => { - window.open(`https://misskey-hub.net/help.md`, '_blank'); + window.open('https://misskey-hub.net/help.md', '_blank'); }, }], ev.currentTarget ?? ev.target); }, diff --git a/packages/frontend/src/pages/welcome.setup.vue b/packages/frontend/src/pages/welcome.setup.vue index 8b43fa368b..212d156a83 100644 --- a/packages/frontend/src/pages/welcome.setup.vue +++ b/packages/frontend/src/pages/welcome.setup.vue @@ -2,19 +2,19 @@ <form class="mk-setup" @submit.prevent="submit()"> <h1>Welcome to Misskey!</h1> <div class="_gaps_m"> - <p>{{ $ts.intro }}</p> + <p>{{ i18n.ts.intro }}</p> <MkInput v-model="username" pattern="^[a-zA-Z0-9_]{1,20}$" :spellcheck="false" required data-cy-admin-username> - <template #label>{{ $ts.username }}</template> + <template #label>{{ i18n.ts.username }}</template> <template #prefix>@</template> <template #suffix>@{{ host }}</template> </MkInput> <MkInput v-model="password" type="password" data-cy-admin-password> - <template #label>{{ $ts.password }}</template> + <template #label>{{ i18n.ts.password }}</template> <template #prefix><i class="ti ti-lock"></i></template> </MkInput> <div class="bottom"> <MkButton gradate type="submit" :disabled="submitting" data-cy-admin-ok> - {{ submitting ? $ts.processing : $ts.done }}<MkEllipsis v-if="submitting"/> + {{ submitting ? i18n.ts.processing : i18n.ts.done }}<MkEllipsis v-if="submitting"/> </MkButton> </div> </div> diff --git a/packages/frontend/src/pages/welcome.timeline.vue b/packages/frontend/src/pages/welcome.timeline.vue index c34d43dc1c..6a507ee1ed 100644 --- a/packages/frontend/src/pages/welcome.timeline.vue +++ b/packages/frontend/src/pages/welcome.timeline.vue @@ -5,30 +5,31 @@ <div class="_panel" :class="$style.content"> <div :class="$style.body"> <MkA v-if="note.replyId" class="reply" :to="`/notes/${note.replyId}`"><i class="ti ti-arrow-back-up"></i></MkA> - <Mfm v-if="note.text" :text="note.text" :author="note.user" :i="$i" /> + <Mfm v-if="note.text" :text="note.text" :author="note.user" :i="$i"/> <MkA v-if="note.renoteId" class="rp" :to="`/notes/${note.renoteId}`">RN: ...</MkA> </div> <div v-if="note.files.length > 0" :class="$style.richcontent"> - <MkMediaList :media-list="note.files" /> + <MkMediaList :media-list="note.files"/> </div> <div v-if="note.poll"> - <MkPoll :note="note" :readOnly="true" /> + <MkPoll :note="note" :read-only="true"/> </div> </div> - <MkReactionsViewer ref="reactionsViewer" :note="note" /> + <MkReactionsViewer ref="reactionsViewer" :note="note"/> </div> </div> </div> </template> <script lang="ts" setup> +import { Note } from 'misskey-js/built/entities'; +import { onUpdated } from 'vue'; import MkReactionsViewer from '@/components/MkReactionsViewer.vue'; import MkMediaList from '@/components/MkMediaList.vue'; import MkPoll from '@/components/MkPoll.vue'; import * as os from '@/os'; -import { Note } from 'misskey-js/built/entities'; -import { onUpdated } from 'vue'; import { getScrollContainer } from '@/scripts/scroll'; +import { $i } from '@/account'; let notes = $ref<Note[]>([]); let isScrolling = $ref(false); diff --git a/packages/frontend/src/router.ts b/packages/frontend/src/router.ts index c8077edd28..0769ec2614 100644 --- a/packages/frontend/src/router.ts +++ b/packages/frontend/src/router.ts @@ -162,6 +162,10 @@ export const routes = [{ name: 'preferences-backups', component: page(() => import('./pages/settings/preferences-backups.vue')), }, { + path: '/migration', + name: 'migration', + component: page(() => import('./pages/settings/migration.vue')) + }, { path: '/custom-css', name: 'general', component: page(() => import('./pages/settings/custom-css.vue')), diff --git a/packages/frontend/src/scripts/achievements.ts b/packages/frontend/src/scripts/achievements.ts index c77f8e12d3..25e8b71a12 100644 --- a/packages/frontend/src/scripts/achievements.ts +++ b/packages/frontend/src/scripts/achievements.ts @@ -443,11 +443,14 @@ export const ACHIEVEMENT_BADGES = { bg: 'linear-gradient(0deg, rgb(144, 224, 255), rgb(255, 168, 252))', frame: 'bronze', }, +/* @see <https://github.com/misskey-dev/misskey/pull/10365#discussion_r1155511107> } as const satisfies Record<typeof ACHIEVEMENT_TYPES[number], { img: string; bg: string | null; frame: 'bronze' | 'silver' | 'gold' | 'platinum'; }>; + */ +} as const; export const claimedAchievements: typeof ACHIEVEMENT_TYPES[number][] = ($i && $i.achievements) ? $i.achievements.map(x => x.name) : []; diff --git a/packages/frontend/src/scripts/get-note-menu.ts b/packages/frontend/src/scripts/get-note-menu.ts index 00f2523bf9..d91f0b0eb6 100644 --- a/packages/frontend/src/scripts/get-note-menu.ts +++ b/packages/frontend/src/scripts/get-note-menu.ts @@ -15,7 +15,7 @@ import { clipsCache } from '@/cache'; export async function getNoteClipMenu(props: { note: misskey.entities.Note; isDeleted: Ref<boolean>; - currentClipPage?: Ref<misskey.entities.Clip>; + currentClip?: misskey.entities.Clip; }) { const isRenote = ( props.note.renote != null && @@ -42,7 +42,7 @@ export async function getNoteClipMenu(props: { }); if (!confirm.canceled) { os.apiWithDialog('clips/remove-note', { clipId: clip.id, noteId: appearNote.id }); - if (props.currentClipPage?.value.id === clip.id) props.isDeleted.value = true; + if (props.currentClip?.id === clip.id) props.isDeleted.value = true; } } else { os.alert({ @@ -92,7 +92,7 @@ export function getNoteMenu(props: { translation: Ref<any>; translating: Ref<boolean>; isDeleted: Ref<boolean>; - currentClipPage?: Ref<misskey.entities.Clip>; + currentClip?: misskey.entities.Clip; }) { const isRenote = ( props.note.renote != null && @@ -176,7 +176,7 @@ export function getNoteMenu(props: { } async function unclip(): Promise<void> { - os.apiWithDialog('clips/remove-note', { clipId: props.currentClipPage.value.id, noteId: appearNote.id }); + os.apiWithDialog('clips/remove-note', { clipId: props.currentClip.id, noteId: appearNote.id }); props.isDeleted.value = true; } @@ -230,7 +230,7 @@ export function getNoteMenu(props: { menu = [ ...( - props.currentClipPage?.value.userId === $i.id ? [{ + props.currentClip?.userId === $i.id ? [{ icon: 'ti ti-backspace', text: i18n.ts.unclip, danger: true, @@ -294,7 +294,7 @@ export function getNoteMenu(props: { text: i18n.ts.muteThread, action: () => toggleThreadMute(true), }), - appearNote.userId === $i.id ? ($i.pinnedNoteIds || []).includes(appearNote.id) ? { + appearNote.userId === $i.id ? ($i.pinnedNoteIds ?? []).includes(appearNote.id) ? { icon: 'ti ti-pinned-off', text: i18n.ts.unpin, action: () => togglePin(false), diff --git a/packages/frontend/src/scripts/hpml/evaluator.ts b/packages/frontend/src/scripts/hpml/evaluator.ts index 7bddd3f62d..9adfba7f27 100644 --- a/packages/frontend/src/scripts/hpml/evaluator.ts +++ b/packages/frontend/src/scripts/hpml/evaluator.ts @@ -1,4 +1,3 @@ -import autobind from 'autobind-decorator'; import { ref, Ref, unref } from 'vue'; import { collectPageVars } from '../collect-page-vars'; import { initHpmlLib } from './lib'; @@ -51,7 +50,6 @@ export class Hpml { this.eval(); } - @autobind public eval() { try { this.vars.value = this.evaluateVars(); @@ -60,7 +58,6 @@ export class Hpml { } } - @autobind public interpolate(str: string) { if (str == null) return null; return str.replace(/{(.+?)}/g, match => { @@ -69,12 +66,10 @@ export class Hpml { }); } - @autobind public registerCanvas(id: string, canvas: any) { this.canvases[id] = canvas; } - @autobind public updatePageVar(name: string, value: any) { const pageVar = this.pageVars.find(v => v.name === name); if (pageVar !== undefined) { @@ -84,13 +79,11 @@ export class Hpml { } } - @autobind public updateRandomSeed(seed: string) { this.opts.randomSeed = seed; this.envVars.SEED = seed; } - @autobind private _interpolateScope(str: string, scope: HpmlScope) { return str.replace(/{(.+?)}/g, match => { const v = scope.getState(match.slice(1, -1).trim()); @@ -98,7 +91,6 @@ export class Hpml { }); } - @autobind public evaluateVars(): Record<string, any> { const values: Record<string, any> = {}; @@ -117,7 +109,6 @@ export class Hpml { return values; } - @autobind private evaluate(expr: Expr, scope: HpmlScope): any { if (isLiteralValue(expr)) { if (expr.type === null) { diff --git a/packages/frontend/src/scripts/hpml/index.ts b/packages/frontend/src/scripts/hpml/index.ts index 587c6a36c8..994f286b9f 100644 --- a/packages/frontend/src/scripts/hpml/index.ts +++ b/packages/frontend/src/scripts/hpml/index.ts @@ -2,7 +2,6 @@ * Hpml */ -import autobind from 'autobind-decorator'; import { Hpml } from './evaluator'; import { funcDefs } from './lib'; @@ -61,7 +60,6 @@ export class HpmlScope { this.name = name ?? 'anonymous'; } - @autobind public createChildScope(states: Record<string, any>, name?: HpmlScope['name']): HpmlScope { const layer = [states, ...this.layerdStates]; return new HpmlScope(layer, name); @@ -71,7 +69,6 @@ export class HpmlScope { * 指定した名前の変数の値を取得します * @param name 変数名 */ - @autobind public getState(name: string): any { for (const later of this.layerdStates) { const state = later[name]; diff --git a/packages/frontend/src/scripts/hpml/type-checker.ts b/packages/frontend/src/scripts/hpml/type-checker.ts index 692826fc90..ea8133f297 100644 --- a/packages/frontend/src/scripts/hpml/type-checker.ts +++ b/packages/frontend/src/scripts/hpml/type-checker.ts @@ -1,4 +1,3 @@ -import autobind from 'autobind-decorator'; import { isLiteralValue } from './expr'; import { funcDefs } from './lib'; import { envVarsDef } from '.'; @@ -23,7 +22,6 @@ export class HpmlTypeChecker { this.pageVars = pageVars; } - @autobind public typeCheck(v: Expr): TypeError | null { if (isLiteralValue(v)) return null; @@ -61,7 +59,6 @@ export class HpmlTypeChecker { return null; } - @autobind public getExpectedType(v: Expr, slot: number): Type { const def = funcDefs[v.type ?? '']; if (def == null) { @@ -89,7 +86,6 @@ export class HpmlTypeChecker { } } - @autobind public infer(v: Expr): Type { if (v.type === null) return null; if (v.type === 'text') return 'string'; @@ -144,7 +140,6 @@ export class HpmlTypeChecker { } } - @autobind public getVarByName(name: string): Variable { const v = this.variables.find(x => x.name === name); if (v !== undefined) { @@ -154,25 +149,21 @@ export class HpmlTypeChecker { } } - @autobind public getVarsByType(type: Type): Variable[] { if (type == null) return this.variables; return this.variables.filter(x => (this.infer(x) === null) || (this.infer(x) === type)); } - @autobind public getEnvVarsByType(type: Type): string[] { if (type == null) return Object.keys(envVarsDef); return Object.entries(envVarsDef).filter(([k, v]) => v === null || type === v).map(([k, v]) => k); } - @autobind public getPageVarsByType(type: Type): string[] { if (type == null) return this.pageVars.map(v => v.name); return this.pageVars.filter(v => type === v.type).map(v => v.name); } - @autobind public isUsedName(name: string) { if (this.variables.some(v => v.name === name)) { return true; diff --git a/packages/frontend/src/scripts/test-utils.ts b/packages/frontend/src/scripts/test-utils.ts new file mode 100644 index 0000000000..3e018f2d7e --- /dev/null +++ b/packages/frontend/src/scripts/test-utils.ts @@ -0,0 +1,6 @@ +/// <reference types="@testing-library/jest-dom"/> + +export async function tick(): Promise<void> { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + await new Promise((globalThis.requestIdleCallback ?? setTimeout) as never); +} diff --git a/packages/frontend/src/store.ts b/packages/frontend/src/store.ts index c3cf48afc4..e5558829d4 100644 --- a/packages/frontend/src/store.ts +++ b/packages/frontend/src/store.ts @@ -88,7 +88,7 @@ export const defaultStore = markRaw(new Storage('base', { }, reactionAcceptance: { where: 'account', - default: null, + default: null as 'likeOnly' | 'likeOnlyForRemote' | null, }, mutedWords: { where: 'account', @@ -294,6 +294,10 @@ export const defaultStore = markRaw(new Storage('base', { where: 'device', default: false, }, + largeNoteReactions: { + where: 'device', + default: false, + }, aiChanMode: { where: 'device', default: false, diff --git a/packages/frontend/src/style.scss b/packages/frontend/src/style.scss index 3634e02745..20254d335e 100644 --- a/packages/frontend/src/style.scss +++ b/packages/frontend/src/style.scss @@ -127,6 +127,7 @@ hr { } .ti { + width: 1.28em; vertical-align: -12%; line-height: 1em; diff --git a/packages/frontend/src/ui/_common_/common.vue b/packages/frontend/src/ui/_common_/common.vue index 976345f9ee..5a32c076a4 100644 --- a/packages/frontend/src/ui/_common_/common.vue +++ b/packages/frontend/src/ui/_common_/common.vue @@ -11,11 +11,11 @@ <TransitionGroup tag="div" :class="$style.notifications" - :move-class="$store.state.animation ? $style.transition_notification_move : ''" - :enter-active-class="$store.state.animation ? $style.transition_notification_enterActive : ''" - :leave-active-class="$store.state.animation ? $style.transition_notification_leaveActive : ''" - :enter-from-class="$store.state.animation ? $style.transition_notification_enterFrom : ''" - :leave-to-class="$store.state.animation ? $style.transition_notification_leaveTo : ''" + :move-class="defaultStore.state.animation ? $style.transition_notification_move : ''" + :enter-active-class="defaultStore.state.animation ? $style.transition_notification_enterActive : ''" + :leave-active-class="defaultStore.state.animation ? $style.transition_notification_leaveActive : ''" + :enter-from-class="defaultStore.state.animation ? $style.transition_notification_enterFrom : ''" + :leave-to-class="defaultStore.state.animation ? $style.transition_notification_leaveTo : ''" > <XNotification v-for="notification in notifications" :key="notification.id" :notification="notification" :class="$style.notification"/> </TransitionGroup> @@ -40,6 +40,7 @@ import * as sound from '@/scripts/sound'; import { $i } from '@/account'; import { stream } from '@/stream'; import { i18n } from '@/i18n'; +import { defaultStore } from '@/store'; const XStreamIndicator = defineAsyncComponent(() => import('./stream-indicator.vue')); const XUpload = defineAsyncComponent(() => import('./upload.vue')); @@ -52,9 +53,7 @@ function onNotification(notification) { if ($i.mutingNotificationTypes.includes(notification.type)) return; if (document.visibilityState === 'visible') { - stream.send('readNotification', { - id: notification.id, - }); + stream.send('readNotification'); notifications.unshift(notification); window.setTimeout(() => { diff --git a/packages/frontend/src/ui/_common_/navbar-for-mobile.vue b/packages/frontend/src/ui/_common_/navbar-for-mobile.vue index 935aceea7c..7a94a0c3ee 100644 --- a/packages/frontend/src/ui/_common_/navbar-for-mobile.vue +++ b/packages/frontend/src/ui/_common_/navbar-for-mobile.vue @@ -2,9 +2,9 @@ <div class="kmwsukvl"> <div class="body"> <div class="top"> - <div class="banner" :style="{ backgroundImage: `url(${ $instance.bannerUrl })` }"></div> + <div class="banner" :style="{ backgroundImage: `url(${ instance.bannerUrl })` }"></div> <button v-click-anime class="item _button instance" @click="openInstanceMenu"> - <img :src="$instance.iconUrl || $instance.faviconUrl || '/favicon.ico'" alt="" class="icon"/> + <img :src="instance.iconUrl || instance.faviconUrl || '/favicon.ico'" alt="" class="icon"/> </button> </div> <div class="middle"> @@ -47,9 +47,10 @@ import { computed, defineAsyncComponent, toRef } from 'vue'; import { openInstanceMenu } from './common'; import * as os from '@/os'; import { navbarItemDef } from '@/navbar'; -import { openAccountMenu as openAccountMenu_ } from '@/account'; +import { $i, openAccountMenu as openAccountMenu_ } from '@/account'; import { defaultStore } from '@/store'; import { i18n } from '@/i18n'; +import { instance } from '@/instance'; const menu = toRef(defaultStore.state, 'menu'); const otherMenuItemIndicated = computed(() => { diff --git a/packages/frontend/src/ui/_common_/navbar.vue b/packages/frontend/src/ui/_common_/navbar.vue index 3c161f6797..3b4b161422 100644 --- a/packages/frontend/src/ui/_common_/navbar.vue +++ b/packages/frontend/src/ui/_common_/navbar.vue @@ -2,9 +2,9 @@ <div class="mvcprjjd" :class="{ iconOnly }"> <div class="body"> <div class="top"> - <div class="banner" :style="{ backgroundImage: `url(${ $instance.bannerUrl })` }"></div> - <button v-click-anime v-tooltip.noDelay.right="$instance.name ?? i18n.ts.instance" class="item _button instance" @click="openInstanceMenu"> - <img :src="$instance.iconUrl || $instance.faviconUrl || '/favicon.ico'" alt="" class="icon"/> + <div class="banner" :style="{ backgroundImage: `url(${ instance.bannerUrl })` }"></div> + <button v-click-anime v-tooltip.noDelay.right="instance.name ?? i18n.ts.instance" class="item _button instance" @click="openInstanceMenu"> + <img :src="instance.iconUrl || instance.faviconUrl || '/favicon.ico'" alt="" class="icon"/> </button> </div> <div class="middle"> @@ -60,6 +60,7 @@ import { navbarItemDef } from '@/navbar'; import { $i, openAccountMenu as openAccountMenu_ } from '@/account'; import { defaultStore } from '@/store'; import { i18n } from '@/i18n'; +import { instance } from '@/instance'; const iconOnly = ref(false); diff --git a/packages/frontend/src/ui/_common_/stream-indicator.vue b/packages/frontend/src/ui/_common_/stream-indicator.vue index b46422d6cd..2a856e2a45 100644 --- a/packages/frontend/src/ui/_common_/stream-indicator.vue +++ b/packages/frontend/src/ui/_common_/stream-indicator.vue @@ -1,5 +1,5 @@ <template> -<div v-if="hasDisconnected && $store.state.serverDisconnectedBehavior === 'quiet'" :class="$style.root" class="_panel _shadow" @click="resetDisconnected"> +<div v-if="hasDisconnected && defaultStore.state.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 :class="$style.commandButton" small primary @click="reload">{{ i18n.ts.reload }}</MkButton> @@ -14,6 +14,7 @@ import { stream } from '@/stream'; import { i18n } from '@/i18n'; import MkButton from '@/components/MkButton.vue'; import * as os from '@/os'; +import { defaultStore } from '@/store'; const zIndex = os.claimZIndex('high'); diff --git a/packages/frontend/src/ui/classic.header.vue b/packages/frontend/src/ui/classic.header.vue index 3dfb371d32..daea775552 100644 --- a/packages/frontend/src/ui/classic.header.vue +++ b/packages/frontend/src/ui/classic.header.vue @@ -3,9 +3,9 @@ <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'" class="_ghost"/> </button> - <MkA v-click-anime v-tooltip="$ts.timeline" class="item index" active-class="active" to="/" exact> + <MkA v-click-anime v-tooltip="i18n.ts.timeline" class="item index" active-class="active" to="/" exact> <i class="ti ti-home ti-fw"></i> </MkA> <template v-for="item in menu"> @@ -16,7 +16,7 @@ </component> </template> <div class="divider"></div> - <MkA v-if="$i.isAdmin || $i.isModerator" v-click-anime v-tooltip="$ts.controlPanel" class="item" active-class="active" to="/admin" :behavior="settingsWindowed ? 'modalWindow' : null"> + <MkA v-if="$i.isAdmin || $i.isModerator" v-click-anime v-tooltip="i18n.ts.controlPanel" class="item" active-class="active" to="/admin" :behavior="settingsWindowed ? 'modalWindow' : null"> <i class="ti ti-dashboard ti-fw"></i> </MkA> <button v-click-anime class="item _button" @click="more"> @@ -25,13 +25,13 @@ </button> </div> <div class="right"> - <MkA v-click-anime v-tooltip="$ts.settings" class="item" active-class="active" to="/settings" :behavior="settingsWindowed ? 'modalWindow' : null"> + <MkA v-click-anime v-tooltip="i18n.ts.settings" class="item" active-class="active" to="/settings" :behavior="settingsWindowed ? 'modalWindow' : null"> <i class="ti ti-settings ti-fw"></i> </MkA> <button v-click-anime class="item _button account" @click="openAccountMenu"> <MkAvatar :user="$i" class="avatar"/><MkAcct class="acct" :user="$i"/> </button> - <div class="post" @click="post"> + <div class="post" @click="os.post()"> <MkButton class="button" gradate full rounded> <i class="ti ti-pencil ti-fw"></i> </MkButton> @@ -41,86 +41,50 @@ </div> </template> -<script lang="ts"> -import { defineAsyncComponent, defineComponent } from 'vue'; +<script lang="ts" setup> +import { computed, defineAsyncComponent, onMounted } from 'vue'; import { openInstanceMenu } from './_common_/common'; -import { host } from '@/config'; import * as os from '@/os'; import { navbarItemDef } from '@/navbar'; -import { openAccountMenu } from '@/account'; +import { openAccountMenu as openAccountMenu_, $i } from '@/account'; import MkButton from '@/components/MkButton.vue'; -import { mainRouter } from '@/router'; +import { defaultStore } from '@/store'; +import { instance } from '@/instance'; +import { i18n } from '@/i18n'; -export default defineComponent({ - components: { - MkButton, - }, +const WINDOW_THRESHOLD = 1400; - data() { - return { - host: host, - accounts: [], - connection: null, - navbarItemDef: navbarItemDef, - settingsWindowed: false, - }; - }, - - computed: { - menu(): string[] { - return this.$store.state.menu; - }, - - otherNavItemIndicated(): boolean { - for (const def in this.navbarItemDef) { - if (this.menu.includes(def)) continue; - if (this.navbarItemDef[def].indicated) return true; - } - return false; - }, - }, - - watch: { - '$store.reactiveState.menuDisplay.value'() { - this.calcViewState(); - }, - }, - - created() { - window.addEventListener('resize', this.calcViewState); - this.calcViewState(); - }, - - methods: { - openInstanceMenu, - - calcViewState() { - this.settingsWindowed = (window.innerWidth > 1400); - }, - - post() { - os.post(); - }, +let settingsWindowed = $ref(window.innerWidth > WINDOW_THRESHOLD); +let menu = $ref(defaultStore.state.menu); +// const menuDisplay = computed(defaultStore.makeGetterSetter('menuDisplay')); +let otherNavItemIndicated = computed<boolean>(() => { + for (const def in navbarItemDef) { + if (menu.includes(def)) continue; + if (navbarItemDef[def].indicated) return true; + } + return false; +}); - search() { - mainRouter.push('/search'); - }, +function more(ev: MouseEvent) { + os.popup(defineAsyncComponent(() => import('@/components/MkLaunchPad.vue')), { + src: ev.currentTarget ?? ev.target, + anchor: { x: 'center', y: 'bottom' }, + }, { + }, 'closed'); +} - more(ev) { - os.popup(defineAsyncComponent(() => import('@/components/MkLaunchPad.vue')), { - src: ev.currentTarget ?? ev.target, - anchor: { x: 'center', y: 'bottom' }, - }, { - }, 'closed'); - }, +function openAccountMenu(ev: MouseEvent) { + openAccountMenu_({ + withExtraOperation: true, + }, ev); +} - openAccountMenu: (ev) => { - openAccountMenu({ - withExtraOperation: true, - }, ev); - }, - }, +onMounted(() => { + window.addEventListener('resize', () => { + settingsWindowed = (window.innerWidth >= WINDOW_THRESHOLD); + }, { passive: true }); }); + </script> <style lang="scss" scoped> diff --git a/packages/frontend/src/ui/classic.sidebar.vue b/packages/frontend/src/ui/classic.sidebar.vue index 6fff233ac5..73db14c65e 100644 --- a/packages/frontend/src/ui/classic.sidebar.vue +++ b/packages/frontend/src/ui/classic.sidebar.vue @@ -3,14 +3,14 @@ <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="post"> + <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">{{ $ts.note }}</span> + <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" active-class="active" to="/" exact> - <i class="ti ti-home ti-fw"></i><span class="text">{{ $ts.timeline }}</span> + <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> @@ -21,121 +21,78 @@ </template> <div class="divider"></div> <MkA v-if="$i.isAdmin || $i.isModerator" v-click-anime class="item" active-class="active" to="/admin" :behavior="settingsWindowed ? 'modalWindow' : null"> - <i class="ti ti-dashboard ti-fw"></i><span class="text">{{ $ts.controlPanel }}</span> + <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">{{ $ts.more }}</span> + <i class="ti ti-dots ti-fw"></i><span class="text">{{ i18n.ts.more }}</span> <span v-if="otherNavItemIndicated" class="indicator"><i class="_indicatorCircle"></i></span> </button> <MkA v-click-anime class="item" active-class="active" to="/settings" :behavior="settingsWindowed ? 'modalWindow' : null"> - <i class="ti ti-settings ti-fw"></i><span class="text">{{ $ts.settings }}</span> + <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"/> + <img :src="instance.iconUrl ?? instance.faviconUrl ?? '/favicon.ico'" class="_ghost"/> </button> </div> <!--<MisskeyLogo class="misskey"/>--> </div> </template> -<script lang="ts"> -import { defineAsyncComponent, defineComponent } from 'vue'; +<script lang="ts" setup> +import { defineAsyncComponent, onMounted, computed, watch, nextTick } from 'vue'; import { openInstanceMenu } from './_common_/common'; -import { host } from '@/config'; +// import { host } from '@/config'; import * as os from '@/os'; import { navbarItemDef } from '@/navbar'; -import { openAccountMenu } from '@/account'; +import { openAccountMenu as openAccountMenu_, $i } from '@/account'; import MkButton from '@/components/MkButton.vue'; -import { StickySidebar } from '@/scripts/sticky-sidebar'; -import { mainRouter } from '@/router'; +// import { StickySidebar } from '@/scripts/sticky-sidebar'; +// import { mainRouter } from '@/router'; //import MisskeyLogo from '@assets/client/misskey.svg'; +import { defaultStore } from '@/store'; +import { instance } from '@/instance'; +import { i18n } from '@/i18n'; -export default defineComponent({ - components: { - MkButton, - //MisskeyLogo, - }, +const WINDOW_THRESHOLD = 1400; - data() { - return { - host: host, - accounts: [], - connection: null, - navbarItemDef: navbarItemDef, - iconOnly: false, - settingsWindowed: false, - }; - }, - - computed: { - menu(): string[] { - return this.$store.state.menu; - }, - - otherNavItemIndicated(): boolean { - for (const def in this.navbarItemDef) { - if (this.menu.includes(def)) continue; - if (this.navbarItemDef[def].indicated) return true; - } - return false; - }, - }, - - watch: { - '$store.reactiveState.menuDisplay.value'() { - this.calcViewState(); - }, - - iconOnly() { - this.$nextTick(() => { - this.$emit('change-view-mode'); - }); - }, - }, - - created() { - window.addEventListener('resize', this.calcViewState); - this.calcViewState(); - }, - - mounted() { - const sticky = new StickySidebar(this.$el.parentElement, 16); - window.addEventListener('scroll', () => { - sticky.calc(window.scrollY); - }, { passive: true }); - }, - - methods: { - openInstanceMenu, - - calcViewState() { - this.iconOnly = (window.innerWidth <= 1400) || (this.$store.state.menuDisplay === 'sideIcon'); - this.settingsWindowed = (window.innerWidth > 1400); - }, +const menu = $ref(defaultStore.state.menu); +const menuDisplay = computed(defaultStore.makeGetterSetter('menuDisplay')); +const otherNavItemIndicated = computed<boolean>(() => { + for (const def in navbarItemDef) { + if (menu.includes(def)) continue; + if (navbarItemDef[def].indicated) return true; + } + return false; +}); +let el = $shallowRef<HTMLElement>(); +// let accounts = $ref([]); +// let connection = $ref(null); +let iconOnly = $ref(false); +let settingsWindowed = $ref(false); - post() { - os.post(); - }, +function calcViewState() { + iconOnly = (window.innerWidth <= WINDOW_THRESHOLD) || (menuDisplay.value === 'sideIcon'); + settingsWindowed = (window.innerWidth > WINDOW_THRESHOLD); +} - search() { - mainRouter.push('/search'); - }, +function more(ev: MouseEvent) { + os.popup(defineAsyncComponent(() => import('@/components/MkLaunchPad.vue')), { + src: ev.currentTarget ?? ev.target, + }, {}, 'closed'); +} - more(ev) { - os.popup(defineAsyncComponent(() => import('@/components/MkLaunchPad.vue')), { - src: ev.currentTarget ?? ev.target, - }, {}, 'closed'); - }, +function openAccountMenu(ev: MouseEvent) { + openAccountMenu_({ + withExtraOperation: true, + }, ev); +} - openAccountMenu: (ev) => { - openAccountMenu({ - withExtraOperation: true, - }, ev); - }, - }, +watch(defaultStore.reactiveState.menuDisplay, () => { + calcViewState(); }); + </script> <style lang="scss" scoped> diff --git a/packages/frontend/src/ui/classic.vue b/packages/frontend/src/ui/classic.vue index a359463d9b..4838272a9e 100644 --- a/packages/frontend/src/ui/classic.vue +++ b/packages/frontend/src/ui/classic.vue @@ -21,7 +21,7 @@ </div> </div> - <Transition :name="$store.state.animation ? 'tray-back' : ''"> + <Transition :name="defaultStore.state.animation ? 'tray-back' : ''"> <div v-if="widgetsShowing" class="tray-back _modalBg" @@ -30,11 +30,11 @@ ></div> </Transition> - <Transition :name="$store.state.animation ? 'tray' : ''"> + <Transition :name="defaultStore.state.animation ? 'tray' : ''"> <XWidgets v-if="widgetsShowing" class="tray"/> </Transition> - <iframe v-if="$store.state.aiChanMode" ref="live2d" class="ivnzpscs" src="https://misskey-dev.github.io/mascot-web/?scale=2&y=1.4"></iframe> + <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> diff --git a/packages/frontend/src/ui/deck.vue b/packages/frontend/src/ui/deck.vue index be168b4282..4db7c9413a 100644 --- a/packages/frontend/src/ui/deck.vue +++ b/packages/frontend/src/ui/deck.vue @@ -53,10 +53,10 @@ </div> <Transition - :enter-active-class="$store.state.animation ? $style.transition_menuDrawerBg_enterActive : ''" - :leave-active-class="$store.state.animation ? $style.transition_menuDrawerBg_leaveActive : ''" - :enter-from-class="$store.state.animation ? $style.transition_menuDrawerBg_enterFrom : ''" - :leave-to-class="$store.state.animation ? $style.transition_menuDrawerBg_leaveTo : ''" + :enter-active-class="defaultStore.state.animation ? $style.transition_menuDrawerBg_enterActive : ''" + :leave-active-class="defaultStore.state.animation ? $style.transition_menuDrawerBg_leaveActive : ''" + :enter-from-class="defaultStore.state.animation ? $style.transition_menuDrawerBg_enterFrom : ''" + :leave-to-class="defaultStore.state.animation ? $style.transition_menuDrawerBg_leaveTo : ''" > <div v-if="drawerMenuShowing" @@ -68,10 +68,10 @@ </Transition> <Transition - :enter-active-class="$store.state.animation ? $style.transition_menuDrawer_enterActive : ''" - :leave-active-class="$store.state.animation ? $style.transition_menuDrawer_leaveActive : ''" - :enter-from-class="$store.state.animation ? $style.transition_menuDrawer_enterFrom : ''" - :leave-to-class="$store.state.animation ? $style.transition_menuDrawer_leaveTo : ''" + :enter-active-class="defaultStore.state.animation ? $style.transition_menuDrawer_enterActive : ''" + :leave-active-class="defaultStore.state.animation ? $style.transition_menuDrawer_leaveActive : ''" + :enter-from-class="defaultStore.state.animation ? $style.transition_menuDrawer_enterFrom : ''" + :leave-to-class="defaultStore.state.animation ? $style.transition_menuDrawer_leaveTo : ''" > <div v-if="drawerMenuShowing" :class="$style.menu"> <XDrawerMenu/> @@ -99,6 +99,7 @@ import { i18n } from '@/i18n'; import { mainRouter } from '@/router'; import { unisonReload } from '@/scripts/unison-reload'; import { deviceKind } from '@/scripts/device-kind'; +import { defaultStore } from '@/store'; const XStatusBars = defineAsyncComponent(() => import('@/ui/_common_/statusbars.vue')); mainRouter.navHook = (path, flag): boolean => { diff --git a/packages/frontend/src/ui/deck/channel-column.vue b/packages/frontend/src/ui/deck/channel-column.vue index b81d6729e6..ff0cba33ac 100644 --- a/packages/frontend/src/ui/deck/channel-column.vue +++ b/packages/frontend/src/ui/deck/channel-column.vue @@ -14,13 +14,13 @@ </template> <script lang="ts" setup> -import { } from 'vue'; import XColumn from './column.vue'; import { updateColumn, Column } from './deck-store'; import MkTimeline from '@/components/MkTimeline.vue'; import MkButton from '@/components/MkButton.vue'; import * as os from '@/os'; import { i18n } from '@/i18n'; +import * as misskey from 'misskey-js'; const props = defineProps<{ column: Column; @@ -33,6 +33,7 @@ const emit = defineEmits<{ }>(); let timeline = $shallowRef<InstanceType<typeof MkTimeline>>(); +let channel = $shallowRef<misskey.entities.Channel>(); if (props.column.channelId == null) { setChannel(); @@ -56,11 +57,15 @@ async function setChannel() { }); } -function post() { +async function post() { + if (!channel || channel.id !== props.column.channelId) { + channel = await os.api('channels/show', { + channelId: props.column.channelId, + }); + } + os.post({ - channel: { - id: props.column.channelId, - }, + channel, }); } diff --git a/packages/frontend/src/ui/universal.vue b/packages/frontend/src/ui/universal.vue index 11d1c85e38..ab3d01532b 100644 --- a/packages/frontend/src/ui/universal.vue +++ b/packages/frontend/src/ui/universal.vue @@ -27,10 +27,10 @@ </div> <Transition - :enter-active-class="$store.state.animation ? $style.transition_menuDrawerBg_enterActive : ''" - :leave-active-class="$store.state.animation ? $style.transition_menuDrawerBg_leaveActive : ''" - :enter-from-class="$store.state.animation ? $style.transition_menuDrawerBg_enterFrom : ''" - :leave-to-class="$store.state.animation ? $style.transition_menuDrawerBg_leaveTo : ''" + :enter-active-class="defaultStore.state.animation ? $style.transition_menuDrawerBg_enterActive : ''" + :leave-active-class="defaultStore.state.animation ? $style.transition_menuDrawerBg_leaveActive : ''" + :enter-from-class="defaultStore.state.animation ? $style.transition_menuDrawerBg_enterFrom : ''" + :leave-to-class="defaultStore.state.animation ? $style.transition_menuDrawerBg_leaveTo : ''" > <div v-if="drawerMenuShowing" @@ -42,10 +42,10 @@ </Transition> <Transition - :enter-active-class="$store.state.animation ? $style.transition_menuDrawer_enterActive : ''" - :leave-active-class="$store.state.animation ? $style.transition_menuDrawer_leaveActive : ''" - :enter-from-class="$store.state.animation ? $style.transition_menuDrawer_enterFrom : ''" - :leave-to-class="$store.state.animation ? $style.transition_menuDrawer_leaveTo : ''" + :enter-active-class="defaultStore.state.animation ? $style.transition_menuDrawer_enterActive : ''" + :leave-active-class="defaultStore.state.animation ? $style.transition_menuDrawer_leaveActive : ''" + :enter-from-class="defaultStore.state.animation ? $style.transition_menuDrawer_enterFrom : ''" + :leave-to-class="defaultStore.state.animation ? $style.transition_menuDrawer_leaveTo : ''" > <div v-if="drawerMenuShowing" :class="$style.menuDrawer"> <XDrawerMenu/> @@ -53,10 +53,10 @@ </Transition> <Transition - :enter-active-class="$store.state.animation ? $style.transition_widgetsDrawerBg_enterActive : ''" - :leave-active-class="$store.state.animation ? $style.transition_widgetsDrawerBg_leaveActive : ''" - :enter-from-class="$store.state.animation ? $style.transition_widgetsDrawerBg_enterFrom : ''" - :leave-to-class="$store.state.animation ? $style.transition_widgetsDrawerBg_leaveTo : ''" + :enter-active-class="defaultStore.state.animation ? $style.transition_widgetsDrawerBg_enterActive : ''" + :leave-active-class="defaultStore.state.animation ? $style.transition_widgetsDrawerBg_leaveActive : ''" + :enter-from-class="defaultStore.state.animation ? $style.transition_widgetsDrawerBg_enterFrom : ''" + :leave-to-class="defaultStore.state.animation ? $style.transition_widgetsDrawerBg_leaveTo : ''" > <div v-if="widgetsShowing" @@ -68,10 +68,10 @@ </Transition> <Transition - :enter-active-class="$store.state.animation ? $style.transition_widgetsDrawer_enterActive : ''" - :leave-active-class="$store.state.animation ? $style.transition_widgetsDrawer_leaveActive : ''" - :enter-from-class="$store.state.animation ? $style.transition_widgetsDrawer_enterFrom : ''" - :leave-to-class="$store.state.animation ? $style.transition_widgetsDrawer_leaveTo : ''" + :enter-active-class="defaultStore.state.animation ? $style.transition_widgetsDrawer_enterActive : ''" + :leave-active-class="defaultStore.state.animation ? $style.transition_widgetsDrawer_leaveActive : ''" + :enter-from-class="defaultStore.state.animation ? $style.transition_widgetsDrawer_enterFrom : ''" + :leave-to-class="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> diff --git a/packages/frontend/src/ui/visitor/a.vue b/packages/frontend/src/ui/visitor/a.vue index 023b7fdb94..4761036075 100644 --- a/packages/frontend/src/ui/visitor/a.vue +++ b/packages/frontend/src/ui/visitor/a.vue @@ -1,19 +1,19 @@ <template> <div class="mk-app"> - <div v-if="mainRouter.currentRoute?.name === 'index'" class="banner" :style="{ backgroundImage: `url(${ $instance.bannerUrl })` }"> + <div v-if="mainRouter.currentRoute?.name === 'index'" class="banner" :style="{ backgroundImage: `url(${ instance.bannerUrl })` }"> <div> <h1 v-if="meta"><img v-if="meta.logoImageUrl" class="logo" :src="meta.logoImageUrl"><span v-else class="text">{{ instanceName }}</span></h1> <div v-if="meta" class="about"> <!-- eslint-disable-next-line vue/no-v-html --> - <div class="desc" v-html="meta.description || $ts.introMisskey"></div> + <div class="desc" v-html="meta.description || i18n.ts.introMisskey"></div> </div> <div class="action"> - <button class="_button primary" @click="signup()">{{ $ts.signup }}</button> - <button class="_button" @click="signin()">{{ $ts.login }}</button> + <button class="_button primary" @click="signup()">{{ i18n.ts.signup }}</button> + <button class="_button" @click="signin()">{{ i18n.ts.login }}</button> </div> </div> </div> - <div v-else class="banner-mini" :style="{ backgroundImage: `url(${ $instance.bannerUrl })` }"> + <div v-else class="banner-mini" :style="{ backgroundImage: `url(${ instance.bannerUrl })` }"> <div> <h1 v-if="meta"><img v-if="meta.logoImageUrl" class="logo" :src="meta.logoImageUrl"><span v-else class="text">{{ instanceName }}</span></h1> </div> @@ -42,8 +42,10 @@ import XHeader from './header.vue'; import { host, instanceName } from '@/config'; import * as os from '@/os'; import MkButton from '@/components/MkButton.vue'; -import { ColdDeviceStorage } from '@/store'; +import { defaultStore, ColdDeviceStorage } from '@/store'; import { mainRouter } from '@/router'; +import { instance } from '@/instance'; +import { i18n } from '@/i18n'; const DESKTOP_THRESHOLD = 1100; @@ -66,6 +68,9 @@ export default defineComponent({ }, mainRouter, isDesktop: window.innerWidth >= DESKTOP_THRESHOLD, + defaultStore, + instance, + i18n, }; }, @@ -74,7 +79,7 @@ export default defineComponent({ return { 'd': () => { if (ColdDeviceStorage.get('syncDeviceDarkMode')) return; - this.$store.set('darkMode', !this.$store.state.darkMode); + this.defaultStore.set('darkMode', !this.defaultStore.state.darkMode); }, 's': () => { mainRouter.push('/search'); diff --git a/packages/frontend/src/ui/visitor/b.vue b/packages/frontend/src/ui/visitor/b.vue index e2168768e8..5287a670c5 100644 --- a/packages/frontend/src/ui/visitor/b.vue +++ b/packages/frontend/src/ui/visitor/b.vue @@ -24,7 +24,7 @@ </div> </div> - <Transition :name="$store.state.animation ? 'tray-back' : ''"> + <Transition :name="'tray-back'"> <div v-if="showMenu" class="menu-back _modalBg" @@ -33,20 +33,20 @@ ></div> </Transition> - <Transition :name="$store.state.animation ? 'tray' : ''"> + <Transition :name="'tray'"> <div v-if="showMenu" class="menu"> - <MkA to="/" class="link" active-class="active"><i class="ti ti-home icon"></i>{{ $ts.home }}</MkA> - <MkA v-if="isTimelineAvailable" to="/timeline" class="link" active-class="active"><i class="ti ti-message icon"></i>{{ $ts.timeline }}</MkA> - <MkA to="/explore" class="link" active-class="active"><i class="ti ti-hash icon"></i>{{ $ts.explore }}</MkA> - <MkA to="/announcements" class="link" active-class="active"><i class="ti ti-speakerphone icon"></i>{{ $ts.announcements }}</MkA> - <MkA to="/channels" class="link" active-class="active"><i class="ti ti-device-tv icon"></i>{{ $ts.channel }}</MkA> + <MkA to="/" class="link" active-class="active"><i class="ti ti-home icon"></i>{{ i18n.ts.home }}</MkA> + <MkA v-if="isTimelineAvailable" to="/timeline" class="link" active-class="active"><i class="ti ti-message icon"></i>{{ i18n.ts.timeline }}</MkA> + <MkA to="/explore" class="link" active-class="active"><i class="ti ti-hash icon"></i>{{ i18n.ts.explore }}</MkA> + <MkA to="/announcements" class="link" active-class="active"><i class="ti ti-speakerphone icon"></i>{{ i18n.ts.announcements }}</MkA> + <MkA to="/channels" class="link" active-class="active"><i class="ti ti-device-tv icon"></i>{{ i18n.ts.channel }}</MkA> <div class="divider"></div> - <MkA to="/pages" class="link" active-class="active"><i class="ti ti-news icon"></i>{{ $ts.pages }}</MkA> + <MkA to="/pages" class="link" active-class="active"><i class="ti ti-news icon"></i>{{ i18n.ts.pages }}</MkA> <MkA to="/play" class="link" active-class="active"><i class="ti ti-player-play icon"></i>Play</MkA> - <MkA to="/gallery" class="link" active-class="active"><i class="ti ti-icons icon"></i>{{ $ts.gallery }}</MkA> + <MkA to="/gallery" class="link" active-class="active"><i class="ti ti-icons icon"></i>{{ i18n.ts.gallery }}</MkA> <div class="action"> - <button class="_buttonPrimary" @click="signup()">{{ $ts.signup }}</button> - <button class="_button" @click="signin()">{{ $ts.login }}</button> + <button class="_buttonPrimary" @click="signup()">{{ i18n.ts.signup }}</button> + <button class="_button" @click="signin()">{{ i18n.ts.login }}</button> </div> </div> </Transition> @@ -65,6 +65,7 @@ import XSignupDialog from '@/components/MkSignupDialog.vue'; import { ColdDeviceStorage, defaultStore } from '@/store'; import { mainRouter } from '@/router'; import { PageMetadata, provideMetadataReceiver } from '@/scripts/page-metadata'; +import { i18n } from '@/i18n'; const DESKTOP_THRESHOLD = 1100; diff --git a/packages/frontend/src/ui/visitor/header.vue b/packages/frontend/src/ui/visitor/header.vue index aaa7e77e90..7de81f6431 100644 --- a/packages/frontend/src/ui/visitor/header.vue +++ b/packages/frontend/src/ui/visitor/header.vue @@ -2,14 +2,14 @@ <div class="sqxihjet"> <div v-if="narrow === false" class="wide"> <div class="content"> - <MkA to="/" class="link" active-class="active"><i class="ti ti-home icon"></i>{{ $ts.home }}</MkA> - <MkA v-if="isTimelineAvailable" to="/timeline" class="link" active-class="active"><i class="ti ti-message icon"></i>{{ $ts.timeline }}</MkA> - <MkA to="/explore" class="link" active-class="active"><i class="ti ti-hash icon"></i>{{ $ts.explore }}</MkA> - <MkA to="/channels" class="link" active-class="active"><i class="ti ti-device-tv icon"></i>{{ $ts.channel }}</MkA> + <MkA to="/" class="link" active-class="active"><i class="ti ti-home icon"></i>{{ i18n.ts.home }}</MkA> + <MkA v-if="isTimelineAvailable" to="/timeline" class="link" active-class="active"><i class="ti ti-message icon"></i>{{ i18n.ts.timeline }}</MkA> + <MkA to="/explore" class="link" active-class="active"><i class="ti ti-hash icon"></i>{{ i18n.ts.explore }}</MkA> + <MkA to="/channels" class="link" active-class="active"><i class="ti ti-device-tv icon"></i>{{ i18n.ts.channel }}</MkA> <div class="right"> - <button class="_button search" @click="search()"><i class="ti ti-search icon"></i><span>{{ $ts.search }}</span></button> - <button class="_buttonPrimary signup" @click="signup()">{{ $ts.signup }}</button> - <button class="_button login" @click="signin()">{{ $ts.login }}</button> + <button class="_button search" @click="search()"><i class="ti ti-search icon"></i><span>{{ i18n.ts.search }}</span></button> + <button class="_buttonPrimary signup" @click="signup()">{{ i18n.ts.signup }}</button> + <button class="_button login" @click="signin()">{{ i18n.ts.login }}</button> </div> </div> </div> @@ -28,6 +28,7 @@ import XSignupDialog from '@/components/MkSignupDialog.vue'; import * as os from '@/os'; import { instance } from '@/instance'; import { mainRouter } from '@/router'; +import { i18n } from '@/i18n'; export default defineComponent({ data() { @@ -35,6 +36,7 @@ export default defineComponent({ narrow: null, showMenu: false, isTimelineAvailable: instance.policies.ltlAvailable || instance.policies.gtlAvailable, + i18n, }; }, diff --git a/packages/frontend/src/ui/visitor/kanban.vue b/packages/frontend/src/ui/visitor/kanban.vue index 05ded834ee..ce7fcfe944 100644 --- a/packages/frontend/src/ui/visitor/kanban.vue +++ b/packages/frontend/src/ui/visitor/kanban.vue @@ -1,6 +1,6 @@ <!-- eslint-disable vue/no-v-html --> <template> -<div class="rwqkcmrc" :style="{ backgroundImage: transparent ? 'none' : `url(${ $instance.backgroundImageUrl })` }"> +<div class="rwqkcmrc" :style="{ backgroundImage: transparent ? 'none' : `url(${ instance.backgroundImageUrl })` }"> <div class="back" :class="{ transparent }"></div> <div class="contents"> <div class="wrapper"> @@ -9,14 +9,14 @@ </h1> <template v-if="full"> <div v-if="meta" class="about"> - <div class="desc" v-html="meta.description || $ts.introMisskey"></div> + <div class="desc" v-html="meta.description || i18n.ts.introMisskey"></div> </div> <div class="action"> - <button class="_buttonPrimary" @click="signup()">{{ $ts.signup }}</button> - <button class="_button" @click="signin()">{{ $ts.login }}</button> + <button class="_buttonPrimary" @click="signup()">{{ i18n.ts.signup }}</button> + <button class="_button" @click="signin()">{{ i18n.ts.login }}</button> </div> <div class="announcements panel"> - <header>{{ $ts.announcements }}</header> + <header>{{ i18n.ts.announcements }}</header> <MkPagination v-slot="{items}" :pagination="announcements" class="list"> <section v-for="announcement in items" :key="announcement.id" class="item"> <div class="title">{{ announcement.title }}</div> @@ -45,6 +45,8 @@ import MkPagination from '@/components/MkPagination.vue'; import XSigninDialog from '@/components/MkSigninDialog.vue'; import XSignupDialog from '@/components/MkSignupDialog.vue'; import MkButton from '@/components/MkButton.vue'; +import { instance } from '@/instance'; +import { i18n } from '@/i18n'; export default defineComponent({ components: { @@ -81,6 +83,8 @@ export default defineComponent({ endpoint: 'announcements', limit: 10, }, + instance, + i18n, }; }, diff --git a/packages/frontend/src/widgets/WidgetCalendar.vue b/packages/frontend/src/widgets/WidgetCalendar.vue index de2e4b179d..58d0732263 100644 --- a/packages/frontend/src/widgets/WidgetCalendar.vue +++ b/packages/frontend/src/widgets/WidgetCalendar.vue @@ -2,11 +2,11 @@ <div :class="[$style.root, { _panel: !widgetProps.transparent }]" data-cy-mkw-calendar> <div :class="[$style.calendar, { [$style.isHoliday]: isHoliday }]"> <p :class="$style.monthAndYear"> - <span :class="$style.year">{{ $t('yearX', { year }) }}</span> - <span :class="$style.month">{{ $t('monthX', { month }) }}</span> + <span :class="$style.year">{{ i18n.t('yearX', { year }) }}</span> + <span :class="$style.month">{{ i18n.t('monthX', { month }) }}</span> </p> - <p v-if="month === 1 && day === 1" class="day">🎉{{ $t('dayX', { day }) }}<span style="display: inline-block; transform: scaleX(-1);">🎉</span></p> - <p v-else :class="$style.day">{{ $t('dayX', { day }) }}</p> + <p v-if="month === 1 && day === 1" class="day">🎉{{ i18n.t('dayX', { day }) }}<span style="display: inline-block; transform: scaleX(-1);">🎉</span></p> + <p v-else :class="$style.day">{{ i18n.t('dayX', { day }) }}</p> <p :class="$style.weekDay">{{ weekDay }}</p> </div> <div :class="$style.info"> diff --git a/packages/frontend/src/widgets/WidgetFederation.vue b/packages/frontend/src/widgets/WidgetFederation.vue index 7dcd5cb42e..2033b074e0 100644 --- a/packages/frontend/src/widgets/WidgetFederation.vue +++ b/packages/frontend/src/widgets/WidgetFederation.vue @@ -5,7 +5,7 @@ <div class="wbrkwalb"> <MkLoading v-if="fetching"/> - <TransitionGroup v-else tag="div" :name="$store.state.animation ? 'chart' : ''" class="instances"> + <TransitionGroup v-else tag="div" :name="defaultStore.state.animation ? 'chart' : ''" class="instances"> <div v-for="(instance, i) in instances" :key="instance.id" class="instance"> <img :src="getInstanceIcon(instance)" alt=""/> <div class="body"> @@ -29,6 +29,7 @@ import * as os from '@/os'; import { useInterval } from '@/scripts/use-interval'; import { i18n } from '@/i18n'; import { getProxiedImageUrlNullable } from '@/scripts/media-proxy'; +import { defaultStore } from '@/store'; const name = 'federation'; diff --git a/packages/frontend/src/widgets/WidgetInstanceInfo.vue b/packages/frontend/src/widgets/WidgetInstanceInfo.vue index 3a3b071b7d..d702fd2cb0 100644 --- a/packages/frontend/src/widgets/WidgetInstanceInfo.vue +++ b/packages/frontend/src/widgets/WidgetInstanceInfo.vue @@ -1,12 +1,12 @@ <template> <div class="_panel"> - <div :class="$style.container" :style="{ backgroundImage: $instance.bannerUrl ? `url(${ $instance.bannerUrl })` : null }"> + <div :class="$style.container" :style="{ backgroundImage: instance.bannerUrl ? `url(${ instance.bannerUrl })` : null }"> <div :class="$style.iconContainer"> - <img :src="$instance.iconUrl ?? $instance.faviconUrl ?? '/favicon.ico'" alt="" :class="$style.icon"/> + <img :src="instance.iconUrl ?? instance.faviconUrl ?? '/favicon.ico'" alt="" :class="$style.icon"/> </div> <div :class="$style.bodyContainer"> <div :class="$style.body"> - <MkA :class="$style.name" to="/about" behavior="window">{{ $instance.name }}</MkA> + <MkA :class="$style.name" to="/about" behavior="window">{{ instance.name }}</MkA> <div :class="$style.host">{{ host }}</div> </div> </div> @@ -18,6 +18,7 @@ import { useWidgetPropsManager, Widget, WidgetComponentExpose } from './widget'; import { GetFormResultType } from '@/scripts/form'; import { host } from '@/config'; +import { instance } from '@/instance'; const name = 'instanceInfo'; diff --git a/packages/frontend/src/widgets/WidgetSlideshow.vue b/packages/frontend/src/widgets/WidgetSlideshow.vue index 22a0024271..915e7aaaf4 100644 --- a/packages/frontend/src/widgets/WidgetSlideshow.vue +++ b/packages/frontend/src/widgets/WidgetSlideshow.vue @@ -4,7 +4,7 @@ <p v-if="widgetProps.folderId == null"> {{ i18n.ts.folder }} </p> - <p v-if="widgetProps.folderId != null && images.length === 0 && !fetching">{{ $t('no-image') }}</p> + <p v-if="widgetProps.folderId != null && images.length === 0 && !fetching">{{ i18n.t('no-image') }}</p> <div ref="slideA" class="slide a"></div> <div ref="slideB" class="slide b"></div> </div> diff --git a/packages/frontend/src/widgets/WidgetTimeline.vue b/packages/frontend/src/widgets/WidgetTimeline.vue index 0f6f25b0a9..71ee75f6cb 100644 --- a/packages/frontend/src/widgets/WidgetTimeline.vue +++ b/packages/frontend/src/widgets/WidgetTimeline.vue @@ -10,7 +10,7 @@ </template> <template #header> <button class="_button" @click="choose"> - <span>{{ widgetProps.src === 'list' ? widgetProps.list.name : widgetProps.src === 'antenna' ? widgetProps.antenna.name : $t('_timelines.' + widgetProps.src) }}</span> + <span>{{ widgetProps.src === 'list' ? widgetProps.list.name : widgetProps.src === 'antenna' ? widgetProps.antenna.name : i18n.t('_timelines.' + widgetProps.src) }}</span> <i :class="menuOpened ? 'ti ti-chevron-up' : 'ti ti-chevron-down'" style="margin-left: 8px;"></i> </button> </template> diff --git a/packages/frontend/src/widgets/WidgetTrends.vue b/packages/frontend/src/widgets/WidgetTrends.vue index fc8a310ece..01450a7ab5 100644 --- a/packages/frontend/src/widgets/WidgetTrends.vue +++ b/packages/frontend/src/widgets/WidgetTrends.vue @@ -5,11 +5,11 @@ <div class="wbrkwala"> <MkLoading v-if="fetching"/> - <TransitionGroup v-else tag="div" :name="$store.state.animation ? 'chart' : ''" class="tags"> + <TransitionGroup v-else tag="div" :name="defaultStore.state.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> - <p>{{ $t('nUsersMentioned', { n: stat.usersCount }) }}</p> + <p>{{ i18n.t('nUsersMentioned', { n: stat.usersCount }) }}</p> </div> <MkMiniChart class="chart" :src="stat.chart"/> </div> @@ -27,6 +27,7 @@ import MkMiniChart from '@/components/MkMiniChart.vue'; import * as os from '@/os'; import { useInterval } from '@/scripts/use-interval'; import { i18n } from '@/i18n'; +import { defaultStore } from '@/store'; const name = 'hashtags'; diff --git a/packages/frontend/test/note.test.ts b/packages/frontend/test/note.test.ts index f7c47ec100..bdb1a8281a 100644 --- a/packages/frontend/test/note.test.ts +++ b/packages/frontend/test/note.test.ts @@ -2,14 +2,31 @@ import { describe, test, assert, afterEach } from 'vitest'; import { render, cleanup, type RenderResult } from '@testing-library/vue'; import './init'; import type { DriveFile } from 'misskey-js/built/entities'; +import { components } from '@/components'; import { directives } from '@/directives'; import MkMediaImage from '@/components/MkMediaImage.vue'; describe('MkMediaImage', () => { const renderMediaImage = (image: Partial<DriveFile>): RenderResult => { return render(MkMediaImage, { - props: { image }, - global: { directives }, + props: { + image: { + id: 'xxxxxxxx', + createdAt: (new Date()).toJSON(), + isSensitive: false, + name: 'example.png', + thumbnailUrl: null, + url: '', + type: 'application/octet-stream', + size: 1, + md5: '15eca7fba0480996e2245f5185bf39f2', + blurhash: null, + comment: null, + properties: {}, + ...image, + } as DriveFile, + }, + global: { directives, components }, }); }; diff --git a/packages/frontend/test/url-preview.test.ts b/packages/frontend/test/url-preview.test.ts index 205982a40a..4cb37e6584 100644 --- a/packages/frontend/test/url-preview.test.ts +++ b/packages/frontend/test/url-preview.test.ts @@ -2,6 +2,7 @@ import { describe, test, assert, afterEach } from 'vitest'; import { render, cleanup, type RenderResult } from '@testing-library/vue'; import './init'; import type { summaly } from 'summaly'; +import { components } from '@/components'; import { directives } from '@/directives'; import MkUrlPreview from '@/components/MkUrlPreview.vue'; @@ -27,7 +28,7 @@ describe('MkMediaImage', () => { const result = render(MkUrlPreview, { props: { url: summary.url }, - global: { directives }, + global: { directives, components }, }); await new Promise<void>(resolve => { diff --git a/packages/frontend/tsconfig.json b/packages/frontend/tsconfig.json index 54e5219b56..4d582daa3c 100644 --- a/packages/frontend/tsconfig.json +++ b/packages/frontend/tsconfig.json @@ -43,5 +43,8 @@ ".eslintrc.js", "./**/*.ts", "./**/*.vue" + ], + "exclude": [ + ".storybook/**/*", ] } diff --git a/packages/frontend/vite.config.ts b/packages/frontend/vite.config.ts index a90ee55268..425f3aa45d 100644 --- a/packages/frontend/vite.config.ts +++ b/packages/frontend/vite.config.ts @@ -1,7 +1,6 @@ import path from 'path'; import pluginVue from '@vitejs/plugin-vue'; -import { defineConfig } from 'vite'; -import { configDefaults as vitestConfigDefaults } from 'vitest/config'; +import { type UserConfig, defineConfig } from 'vite'; import locales from '../../locales'; import meta from '../../package.json'; @@ -38,7 +37,7 @@ function toBase62(n: number): string { return result; } -export default defineConfig(({ command, mode }) => { +export function getConfig(): UserConfig { return { base: '/vite/', @@ -62,7 +61,7 @@ export default defineConfig(({ command, mode }) => { css: { modules: { - generateScopedName: (name, filename, css) => { + generateScopedName(name, filename, _css): string { const id = (path.relative(__dirname, filename.split('?')[0]) + '-' + name).replace(/[\\\/\.\?&=]/g, '-').replace(/(src-|vue-)/g, ''); if (process.env.NODE_ENV === 'production') { return 'x' + toBase62(hash(id)).substring(0, 4); @@ -86,6 +85,11 @@ export default defineConfig(({ command, mode }) => { __VUE_PROD_DEVTOOLS__: false, }, + // https://vitejs.dev/guide/dep-pre-bundling.html#monorepos-and-linked-dependencies + optimizeDeps: { + include: ['misskey-js'], + }, + build: { target: [ 'chrome108', @@ -110,6 +114,11 @@ export default defineConfig(({ command, mode }) => { emptyOutDir: false, sourcemap: process.env.NODE_ENV === 'development', reportCompressedSize: false, + + // https://vitejs.dev/guide/dep-pre-bundling.html#monorepos-and-linked-dependencies + commonjsOptions: { + include: [/misskey-js/, /node_modules/], + }, }, test: { @@ -122,4 +131,8 @@ export default defineConfig(({ command, mode }) => { }, }, }; -}); +} + +const config = defineConfig(({ command, mode }) => getConfig()); + +export default config; |