summaryrefslogtreecommitdiff
path: root/packages/frontend
diff options
context:
space:
mode:
authorsyuilo <Syuilotan@yahoo.co.jp>2023-04-08 21:27:21 +0900
committerGitHub <noreply@github.com>2023-04-08 21:27:21 +0900
commita096f621cf5a47c3330935c2b9b5bfe54dfc0091 (patch)
treeb3b6a1a1ce5105091bebc80b96cfd5a73402da80 /packages/frontend
parentMerge pull request #10402 from misskey-dev/develop (diff)
parent[ci skip] Update CHANGELOG.md (diff)
downloadmisskey-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')
-rw-r--r--packages/frontend/.gitignore1
-rw-r--r--packages/frontend/.storybook/.gitignore7
-rw-r--r--packages/frontend/.storybook/changes.ts80
-rw-r--r--packages/frontend/.storybook/fakes.ts116
-rw-r--r--packages/frontend/.storybook/generate.tsx406
-rw-r--r--packages/frontend/.storybook/main.ts41
-rw-r--r--packages/frontend/.storybook/manager.ts12
-rw-r--r--packages/frontend/.storybook/mocks.ts16
-rw-r--r--packages/frontend/.storybook/preload-locale.ts9
-rw-r--r--packages/frontend/.storybook/preload-theme.ts39
-rw-r--r--packages/frontend/.storybook/preview-head.html10
-rw-r--r--packages/frontend/.storybook/preview.ts113
-rw-r--r--packages/frontend/.storybook/tsconfig.json27
-rw-r--r--packages/frontend/@types/vue.d.ts16
-rw-r--r--packages/frontend/package.json90
-rw-r--r--packages/frontend/public/mockServiceWorker.js303
-rw-r--r--packages/frontend/src/components/MkAccountMoved.vue32
-rw-r--r--packages/frontend/src/components/MkAnalogClock.stories.impl.ts28
-rw-r--r--packages/frontend/src/components/MkButton.stories.impl.ts30
-rw-r--r--packages/frontend/src/components/MkCaptcha.stories.impl.ts2
-rw-r--r--packages/frontend/src/components/MkContainer.vue13
-rw-r--r--packages/frontend/src/components/MkContextMenu.vue9
-rw-r--r--packages/frontend/src/components/MkDialog.vue11
-rw-r--r--packages/frontend/src/components/MkDonation.vue3
-rw-r--r--packages/frontend/src/components/MkFoldableSection.vue4
-rw-r--r--packages/frontend/src/components/MkFolder.vue9
-rw-r--r--packages/frontend/src/components/MkFormDialog.vue14
-rw-r--r--packages/frontend/src/components/MkGalleryPostPreview.stories.impl.ts85
-rw-r--r--packages/frontend/src/components/MkGalleryPostPreview.vue39
-rw-r--r--packages/frontend/src/components/MkGoogle.vue3
-rw-r--r--packages/frontend/src/components/MkMediaBanner.vue5
-rw-r--r--packages/frontend/src/components/MkMediaVideo.vue5
-rw-r--r--packages/frontend/src/components/MkMention.vue3
-rw-r--r--packages/frontend/src/components/MkMenu.vue20
-rw-r--r--packages/frontend/src/components/MkModalPageWindow.vue2
-rw-r--r--packages/frontend/src/components/MkNote.vue13
-rw-r--r--packages/frontend/src/components/MkNoteDetailed.vue6
-rw-r--r--packages/frontend/src/components/MkNoteHeader.vue2
-rw-r--r--packages/frontend/src/components/MkNotePreview.vue6
-rw-r--r--packages/frontend/src/components/MkNoteSimple.vue1
-rw-r--r--packages/frontend/src/components/MkNoteSub.vue1
-rw-r--r--packages/frontend/src/components/MkNotes.vue4
-rw-r--r--packages/frontend/src/components/MkNotification.vue32
-rw-r--r--packages/frontend/src/components/MkNotifications.vue39
-rw-r--r--packages/frontend/src/components/MkOmit.vue5
-rw-r--r--packages/frontend/src/components/MkPagination.vue44
-rw-r--r--packages/frontend/src/components/MkPoll.vue4
-rw-r--r--packages/frontend/src/components/MkPollEditor.vue2
-rw-r--r--packages/frontend/src/components/MkPostForm.vue400
-rw-r--r--packages/frontend/src/components/MkPostFormAttaches.vue6
-rw-r--r--packages/frontend/src/components/MkReactionsViewer.reaction.vue13
-rw-r--r--packages/frontend/src/components/MkReactionsViewer.vue21
-rw-r--r--packages/frontend/src/components/MkSample.vue2
-rw-r--r--packages/frontend/src/components/MkSubNoteContent.vue3
-rw-r--r--packages/frontend/src/components/MkTimeline.vue3
-rw-r--r--packages/frontend/src/components/MkToast.vue9
-rw-r--r--packages/frontend/src/components/MkTokenGenerateWindow.vue8
-rw-r--r--packages/frontend/src/components/MkTooltip.vue9
-rw-r--r--packages/frontend/src/components/MkUrlPreview.vue5
-rw-r--r--packages/frontend/src/components/MkUrlPreviewPopup.vue3
-rw-r--r--packages/frontend/src/components/MkUserInfo.vue3
-rw-r--r--packages/frontend/src/components/MkUserPopup.vue18
-rw-r--r--packages/frontend/src/components/MkVisibilityPicker.vue76
-rw-r--r--packages/frontend/src/components/MkWindow.vue81
-rw-r--r--packages/frontend/src/components/MkYouTubePlayer.vue (renamed from packages/frontend/src/components/MkYoutubePlayer.vue)3
-rw-r--r--packages/frontend/src/components/form/suspense.vue10
-rw-r--r--packages/frontend/src/components/global/MkA.stories.impl.ts47
-rw-r--r--packages/frontend/src/components/global/MkAcct.stories.impl.ts43
-rw-r--r--packages/frontend/src/components/global/MkAcct.vue4
-rw-r--r--packages/frontend/src/components/global/MkAd.stories.impl.ts120
-rw-r--r--packages/frontend/src/components/global/MkAd.vue7
-rw-r--r--packages/frontend/src/components/global/MkAvatar.stories.impl.ts66
-rw-r--r--packages/frontend/src/components/global/MkAvatar.vue61
-rw-r--r--packages/frontend/src/components/global/MkCustomEmoji.stories.impl.ts45
-rw-r--r--packages/frontend/src/components/global/MkEllipsis.stories.impl.ts32
-rw-r--r--packages/frontend/src/components/global/MkEllipsis.vue16
-rw-r--r--packages/frontend/src/components/global/MkEmoji.stories.impl.ts31
-rw-r--r--packages/frontend/src/components/global/MkError.stories.impl.ts34
-rw-r--r--packages/frontend/src/components/global/MkError.stories.meta.ts5
-rw-r--r--packages/frontend/src/components/global/MkError.vue3
-rw-r--r--packages/frontend/src/components/global/MkLoading.stories.impl.ts60
-rw-r--r--packages/frontend/src/components/global/MkLoading.vue8
-rw-r--r--packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.stories.impl.ts74
-rw-r--r--packages/frontend/src/components/global/MkPageHeader.stories.impl.ts99
-rw-r--r--packages/frontend/src/components/global/MkPageHeader.tabs.stories.impl.ts3
-rw-r--r--packages/frontend/src/components/global/MkPageHeader.tabs.vue18
-rw-r--r--packages/frontend/src/components/global/MkPageHeader.vue20
-rw-r--r--packages/frontend/src/components/global/MkStickyContainer.stories.impl.ts3
-rw-r--r--packages/frontend/src/components/global/MkTime.stories.impl.ts312
-rw-r--r--packages/frontend/src/components/global/MkTime.vue6
-rw-r--r--packages/frontend/src/components/global/MkUrl.stories.impl.ts77
-rw-r--r--packages/frontend/src/components/global/MkUserName.stories.impl.ts57
-rw-r--r--packages/frontend/src/components/global/RouterView.stories.impl.ts3
-rw-r--r--packages/frontend/src/components/page/page.note.vue12
-rw-r--r--packages/frontend/src/components/page/page.post.vue8
-rw-r--r--packages/frontend/src/components/page/page.text.vue6
-rw-r--r--packages/frontend/src/directives/user-preview.ts16
-rw-r--r--packages/frontend/src/index.mdx12
-rw-r--r--packages/frontend/src/init.ts22
-rw-r--r--packages/frontend/src/local-storage.ts1
-rw-r--r--packages/frontend/src/os.ts2
-rw-r--r--packages/frontend/src/pages/_error_.vue3
-rw-r--r--packages/frontend/src/pages/about-misskey.vue1
-rw-r--r--packages/frontend/src/pages/about.emojis.vue9
-rw-r--r--packages/frontend/src/pages/about.vue17
-rw-r--r--packages/frontend/src/pages/admin/ads.vue44
-rw-r--r--packages/frontend/src/pages/admin/announcements.vue9
-rw-r--r--packages/frontend/src/pages/admin/index.vue4
-rw-r--r--packages/frontend/src/pages/admin/object-storage.vue6
-rw-r--r--packages/frontend/src/pages/admin/overview.instances.vue3
-rw-r--r--packages/frontend/src/pages/admin/overview.moderators.vue3
-rw-r--r--packages/frontend/src/pages/admin/overview.stats.vue3
-rw-r--r--packages/frontend/src/pages/admin/overview.users.vue3
-rw-r--r--packages/frontend/src/pages/admin/relays.vue2
-rw-r--r--packages/frontend/src/pages/announcements.vue3
-rw-r--r--packages/frontend/src/pages/antenna-timeline.vue2
-rw-r--r--packages/frontend/src/pages/auth.form.vue28
-rw-r--r--packages/frontend/src/pages/auth.vue2
-rw-r--r--packages/frontend/src/pages/channel-editor.vue83
-rw-r--r--packages/frontend/src/pages/channel.vue39
-rw-r--r--packages/frontend/src/pages/channels.vue25
-rw-r--r--packages/frontend/src/pages/clip.vue2
-rw-r--r--packages/frontend/src/pages/favorites.vue4
-rw-r--r--packages/frontend/src/pages/flash/flash.vue6
-rw-r--r--packages/frontend/src/pages/gallery/post.vue4
-rw-r--r--packages/frontend/src/pages/miauth.vue8
-rw-r--r--packages/frontend/src/pages/my-antennas/index.vue1
-rw-r--r--packages/frontend/src/pages/note.vue7
-rw-r--r--packages/frontend/src/pages/notifications.vue9
-rw-r--r--packages/frontend/src/pages/page-editor/els/page-editor.el.image.vue3
-rw-r--r--packages/frontend/src/pages/page-editor/els/page-editor.el.note.vue17
-rw-r--r--packages/frontend/src/pages/page-editor/els/page-editor.el.text.vue3
-rw-r--r--packages/frontend/src/pages/page-editor/page-editor.container.vue6
-rw-r--r--packages/frontend/src/pages/page-editor/page-editor.vue28
-rw-r--r--packages/frontend/src/pages/page.vue5
-rw-r--r--packages/frontend/src/pages/search.vue21
-rw-r--r--packages/frontend/src/pages/settings/apps.vue2
-rw-r--r--packages/frontend/src/pages/settings/delete-account.vue2
-rw-r--r--packages/frontend/src/pages/settings/general.vue2
-rw-r--r--packages/frontend/src/pages/settings/index.vue17
-rw-r--r--packages/frontend/src/pages/settings/migration.vue73
-rw-r--r--packages/frontend/src/pages/settings/sounds.vue2
-rw-r--r--packages/frontend/src/pages/timeline.vue6
-rw-r--r--packages/frontend/src/pages/user-info.vue2
-rw-r--r--packages/frontend/src/pages/user/activity.following.vue5
-rw-r--r--packages/frontend/src/pages/user/activity.heatmap.vue3
-rw-r--r--packages/frontend/src/pages/user/activity.notes.vue5
-rw-r--r--packages/frontend/src/pages/user/activity.pv.vue5
-rw-r--r--packages/frontend/src/pages/user/home.vue8
-rw-r--r--packages/frontend/src/pages/user/index.activity.vue2
-rw-r--r--packages/frontend/src/pages/user/index.photos.vue5
-rw-r--r--packages/frontend/src/pages/welcome.entrance.a.vue2
-rw-r--r--packages/frontend/src/pages/welcome.entrance.b.vue28
-rw-r--r--packages/frontend/src/pages/welcome.entrance.c.vue36
-rw-r--r--packages/frontend/src/pages/welcome.setup.vue8
-rw-r--r--packages/frontend/src/pages/welcome.timeline.vue13
-rw-r--r--packages/frontend/src/router.ts4
-rw-r--r--packages/frontend/src/scripts/achievements.ts3
-rw-r--r--packages/frontend/src/scripts/get-note-menu.ts12
-rw-r--r--packages/frontend/src/scripts/hpml/evaluator.ts9
-rw-r--r--packages/frontend/src/scripts/hpml/index.ts3
-rw-r--r--packages/frontend/src/scripts/hpml/type-checker.ts9
-rw-r--r--packages/frontend/src/scripts/test-utils.ts6
-rw-r--r--packages/frontend/src/store.ts6
-rw-r--r--packages/frontend/src/style.scss1
-rw-r--r--packages/frontend/src/ui/_common_/common.vue15
-rw-r--r--packages/frontend/src/ui/_common_/navbar-for-mobile.vue7
-rw-r--r--packages/frontend/src/ui/_common_/navbar.vue7
-rw-r--r--packages/frontend/src/ui/_common_/stream-indicator.vue3
-rw-r--r--packages/frontend/src/ui/classic.header.vue114
-rw-r--r--packages/frontend/src/ui/classic.sidebar.vue139
-rw-r--r--packages/frontend/src/ui/classic.vue6
-rw-r--r--packages/frontend/src/ui/deck.vue17
-rw-r--r--packages/frontend/src/ui/deck/channel-column.vue15
-rw-r--r--packages/frontend/src/ui/universal.vue32
-rw-r--r--packages/frontend/src/ui/visitor/a.vue19
-rw-r--r--packages/frontend/src/ui/visitor/b.vue23
-rw-r--r--packages/frontend/src/ui/visitor/header.vue16
-rw-r--r--packages/frontend/src/ui/visitor/kanban.vue14
-rw-r--r--packages/frontend/src/widgets/WidgetCalendar.vue8
-rw-r--r--packages/frontend/src/widgets/WidgetFederation.vue3
-rw-r--r--packages/frontend/src/widgets/WidgetInstanceInfo.vue7
-rw-r--r--packages/frontend/src/widgets/WidgetSlideshow.vue2
-rw-r--r--packages/frontend/src/widgets/WidgetTimeline.vue2
-rw-r--r--packages/frontend/src/widgets/WidgetTrends.vue5
-rw-r--r--packages/frontend/test/note.test.ts21
-rw-r--r--packages/frontend/test/url-preview.test.ts3
-rw-r--r--packages/frontend/tsconfig.json3
-rw-r--r--packages/frontend/vite.config.ts23
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,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',
+ 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}&amp;hideCard=false&amp;hideThread=false&amp;lang=en&amp;theme=${$store.state.darkMode ? 'dark' : 'light'}&amp;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}&amp;hideCard=false&amp;hideThread=false&amp;lang=en&amp;theme=${defaultStore.state.darkMode ? 'dark' : 'light'}&amp;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;