From c548ec9906947c72743e611254a6557e8e8d057c Mon Sep 17 00:00:00 2001
From: かっこかり <67428053+kakkokari-gtyih@users.noreply.github.com>
Date: Wed, 5 Feb 2025 19:01:44 +0900
Subject: refactor(frontend): verbatimModuleSyntaxを有効化 (#15323)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* wip
* wip
* wip
* wip
* revert unnecessary changes
* wip
* refactor(frontend): enforce verbatimModuleSyntax
* fix
* refactor(frontend-shared): enforce verbatimModuleSyntax
* wip
* refactor(frontend-embed): enforce verbatimModuleSyntax
* enforce consistent-type-imports
* fix lint config
* attemt to fix ci
* fix lint
* fix
* fix
* fix
---
packages/frontend/src/boot/common.ts | 3 +-
packages/frontend/src/boot/main-boot.ts | 5 +-
.../components/MkAbuseReportWindow.stories.impl.ts | 2 +-
.../src/components/MkAccountMoved.stories.impl.ts | 2 +-
.../src/components/MkAchievements.stories.impl.ts | 2 +-
.../src/components/MkAnalogClock.stories.impl.ts | 2 +-
.../MkAnnouncementDialog.stories.impl.ts | 2 +-
.../src/components/MkAntennaEditor.stories.impl.ts | 2 +-
.../MkAntennaEditorDialog.stories.impl.ts | 2 +-
packages/frontend/src/components/MkAsUi.vue | 5 +-
.../src/components/MkAutocomplete.stories.impl.ts | 2 +-
.../frontend/src/components/MkAutocomplete.vue | 3 +-
.../src/components/MkAvatars.stories.impl.ts | 2 +-
.../src/components/MkButton.stories.impl.ts | 2 +-
.../MkChannelFollowButton.stories.impl.ts | 2 +-
.../src/components/MkChannelList.stories.impl.ts | 2 +-
packages/frontend/src/components/MkChannelList.vue | 3 +-
.../components/MkChannelPreview.stories.impl.ts | 2 +-
.../src/components/MkChart.stories.impl.ts | 2 +-
packages/frontend/src/components/MkChartLegend.vue | 3 +-
.../src/components/MkClickerGame.stories.impl.ts | 2 +-
.../src/components/MkClipPreview.stories.impl.ts | 2 +-
.../frontend/src/components/MkCode.stories.impl.ts | 2 +-
.../src/components/MkCodeEditor.stories.impl.ts | 2 +-
.../src/components/MkCodeInline.stories.impl.ts | 2 +-
.../src/components/MkColorInput.stories.impl.ts | 2 +-
.../src/components/MkContextMenu.stories.impl.ts | 2 +-
.../src/components/MkCropperDialog.stories.impl.ts | 2 +-
.../MkCustomEmojiDetailedDialog.stories.impl.ts | 2 +-
.../src/components/MkCwButton.stories.impl.ts | 2 +-
.../src/components/MkDateSeparatedList.vue | 5 +-
.../src/components/MkDialog.stories.impl.ts | 2 +-
.../src/components/MkDigitalClock.stories.impl.ts | 2 +-
.../src/components/MkDonation.stories.impl.ts | 2 +-
.../src/components/MkDrive.file.stories.impl.ts | 2 +-
.../src/components/MkDrive.folder.stories.impl.ts | 2 +-
.../src/components/MkDrive.stories.impl.ts | 2 +-
.../MkDriveFileThumbnail.stories.impl.ts | 2 +-
.../src/components/MkEmojiPicker.section.vue | 6 +-
.../src/components/MkEmojiPicker.stories.impl.ts | 2 +-
packages/frontend/src/components/MkEmojiPicker.vue | 6 +-
.../MkExtensionInstaller.stories.impl.ts | 2 +-
.../src/components/MkFlashPreview.stories.impl.ts | 2 +-
.../MkGalleryPostPreview.stories.impl.ts | 2 +-
packages/frontend/src/components/MkInput.vue | 6 +-
.../components/MkInstanceCardMini.stories.impl.ts | 2 +-
.../frontend/src/components/MkInstanceStats.vue | 3 +-
.../frontend/src/components/MkInstanceTicker.vue | 3 +-
.../src/components/MkInviteCode.stories.impl.ts | 2 +-
packages/frontend/src/components/MkLink.vue | 2 +-
packages/frontend/src/components/MkMediaAudio.vue | 2 +-
packages/frontend/src/components/MkMediaRange.vue | 5 +-
packages/frontend/src/components/MkMediaVideo.vue | 2 +-
packages/frontend/src/components/MkMention.vue | 2 +-
packages/frontend/src/components/MkMenu.vue | 4 +-
packages/frontend/src/components/MkModal.vue | 2 +-
packages/frontend/src/components/MkNote.vue | 8 ++-
.../frontend/src/components/MkNoteDetailed.vue | 8 ++-
.../frontend/src/components/MkNoteMediaGrid.vue | 70 +++++++++++-----------
packages/frontend/src/components/MkNotes.vue | 3 +-
.../src/components/MkNotificationSelectWindow.vue | 3 +-
.../frontend/src/components/MkNotifications.vue | 2 +-
packages/frontend/src/components/MkPageWindow.vue | 3 +-
packages/frontend/src/components/MkPagination.vue | 5 +-
packages/frontend/src/components/MkPostForm.vue | 6 +-
packages/frontend/src/components/MkRadios.vue | 3 +-
.../components/MkRoleSelectDialog.stories.impl.ts | 2 +-
packages/frontend/src/components/MkSelect.vue | 3 +-
packages/frontend/src/components/MkSignin.vue | 3 +-
.../src/components/MkSignupDialog.form.vue | 3 +-
.../MkSignupDialog.rules.stories.impl.ts | 2 +-
.../frontend/src/components/MkSortOrderEditor.vue | 4 +-
packages/frontend/src/components/MkSuperMenu.vue | 2 +-
.../frontend/src/components/MkSwitch.button.vue | 3 +-
packages/frontend/src/components/MkSwitch.vue | 3 +-
.../src/components/MkSystemWebhookEditor.vue | 2 +-
.../src/components/MkTagItem.stories.impl.ts | 2 +-
packages/frontend/src/components/MkTextarea.vue | 3 +-
packages/frontend/src/components/MkTimeline.vue | 2 +-
packages/frontend/src/components/MkUserList.vue | 3 +-
.../MkUserSetupDialog.Follow.stories.impl.ts | 2 +-
.../src/components/MkUserSetupDialog.Follow.vue | 3 +-
.../MkUserSetupDialog.Privacy.stories.impl.ts | 2 +-
.../MkUserSetupDialog.Profile.stories.impl.ts | 2 +-
.../MkUserSetupDialog.User.stories.impl.ts | 2 +-
.../components/MkUserSetupDialog.stories.impl.ts | 2 +-
.../src/components/global/MkA.stories.impl.ts | 2 +-
.../src/components/global/MkAcct.stories.impl.ts | 2 +-
.../src/components/global/MkAd.stories.impl.ts | 2 +-
.../src/components/global/MkAvatar.stories.impl.ts | 2 +-
.../global/MkCondensedLine.stories.impl.ts | 2 +-
.../global/MkCustomEmoji.stories.impl.ts | 2 +-
.../components/global/MkEllipsis.stories.impl.ts | 2 +-
.../src/components/global/MkEmoji.stories.impl.ts | 2 +-
.../src/components/global/MkError.stories.impl.ts | 2 +-
.../src/components/global/MkError.stories.meta.ts | 2 +-
.../components/global/MkLoading.stories.impl.ts | 2 +-
.../src/components/global/MkMfm.stories.impl.ts | 4 +-
packages/frontend/src/components/global/MkMfm.ts | 6 +-
.../components/global/MkPageHeader.stories.impl.ts | 2 +-
.../src/components/global/MkPageHeader.vue | 3 +-
.../src/components/global/MkStickyContainer.vue | 3 +-
.../src/components/global/MkTime.stories.impl.ts | 2 +-
.../src/components/global/MkUrl.stories.impl.ts | 2 +-
packages/frontend/src/components/global/MkUrl.vue | 2 +-
.../components/global/MkUserName.stories.impl.ts | 2 +-
.../frontend/src/components/global/RouterView.vue | 2 +-
.../frontend/src/components/grid/MkDataCell.vue | 7 ++-
.../frontend/src/components/grid/MkDataRow.vue | 7 ++-
.../src/components/grid/MkGrid.stories.impl.ts | 8 +--
packages/frontend/src/components/grid/MkGrid.vue | 17 ++++--
.../frontend/src/components/grid/MkHeaderCell.vue | 5 +-
.../frontend/src/components/grid/MkHeaderRow.vue | 7 ++-
.../frontend/src/components/grid/MkNumberCell.vue | 3 +-
.../src/components/grid/cell-validators.ts | 6 +-
packages/frontend/src/components/grid/cell.ts | 12 ++--
packages/frontend/src/components/grid/column.ts | 12 ++--
.../frontend/src/components/grid/grid-event.ts | 10 ++--
.../frontend/src/components/grid/grid-utils.ts | 14 +++--
packages/frontend/src/components/grid/grid.ts | 6 +-
packages/frontend/src/components/grid/row.ts | 10 ++--
packages/frontend/src/components/index.ts | 2 +-
packages/frontend/src/debug.ts | 3 +-
packages/frontend/src/directives/adaptive-bg.ts | 2 +-
.../frontend/src/directives/adaptive-border.ts | 2 +-
packages/frontend/src/directives/anim.ts | 2 +-
packages/frontend/src/directives/appear.ts | 2 +-
packages/frontend/src/directives/click-anime.ts | 2 +-
packages/frontend/src/directives/follow-append.ts | 2 +-
packages/frontend/src/directives/get-size.ts | 2 +-
packages/frontend/src/directives/hotkey.ts | 2 +-
packages/frontend/src/directives/index.ts | 2 +-
packages/frontend/src/directives/panel.ts | 2 +-
packages/frontend/src/directives/tooltip.ts | 3 +-
packages/frontend/src/directives/user-preview.ts | 3 +-
packages/frontend/src/nirax.ts | 3 +-
packages/frontend/src/os.ts | 3 +-
packages/frontend/src/pages/about.federation.vue | 3 +-
.../abuse-report/notification-recipient.editor.vue | 3 +-
.../frontend/src/pages/admin/bot-protection.vue | 2 +-
.../admin/custom-emojis-manager.local.list.vue | 7 ++-
.../admin/custom-emojis-manager.local.register.vue | 12 ++--
.../pages/admin/custom-emojis-manager.remote.vue | 15 ++---
.../admin/custom-emojis-manager2.stories.impl.ts | 2 +-
packages/frontend/src/pages/admin/index.vue | 3 +-
packages/frontend/src/pages/admin/invites.vue | 3 +-
.../admin/overview.ap-requests.stories.impl.ts | 2 +-
.../src/pages/admin/overview.federation.vue | 3 +-
packages/frontend/src/pages/admin/queue.vue | 3 +-
packages/frontend/src/pages/api-console.vue | 2 +-
packages/frontend/src/pages/channel.vue | 2 +-
packages/frontend/src/pages/drive.file.notes.vue | 2 +-
.../frontend/src/pages/drop-and-fusion.game.vue | 3 +-
packages/frontend/src/pages/flash/flash.vue | 9 ++-
packages/frontend/src/pages/follow-requests.vue | 3 +-
packages/frontend/src/pages/install-extensions.vue | 6 +-
packages/frontend/src/pages/instance-info.vue | 6 +-
packages/frontend/src/pages/invite.vue | 5 +-
packages/frontend/src/pages/scratchpad.vue | 8 ++-
packages/frontend/src/pages/search.stories.impl.ts | 2 +-
packages/frontend/src/pages/settings/accounts.vue | 4 +-
.../frontend/src/pages/settings/drive-cleaner.vue | 3 +-
.../frontend/src/pages/settings/emoji-picker.vue | 3 +-
packages/frontend/src/pages/settings/index.vue | 6 +-
.../frontend/src/pages/settings/notifications.vue | 3 +-
packages/frontend/src/pages/settings/sounds.vue | 3 +-
.../frontend/src/pages/settings/theme.manage.vue | 3 +-
packages/frontend/src/pages/theme-editor.vue | 3 +-
.../frontend/src/pages/user/activity.following.vue | 3 +-
.../frontend/src/pages/user/activity.notes.vue | 3 +-
packages/frontend/src/pages/user/activity.pv.vue | 3 +-
.../frontend/src/pages/user/home.stories.impl.ts | 2 +-
packages/frontend/src/pizzax.ts | 3 +-
packages/frontend/src/plugin.ts | 3 +-
packages/frontend/src/router/definition.ts | 3 +-
packages/frontend/src/router/main.ts | 2 +-
packages/frontend/src/router/supplier.ts | 3 +-
packages/frontend/src/scripts/aiscript/common.ts | 3 +-
packages/frontend/src/scripts/aiscript/ui.ts | 3 +-
packages/frontend/src/scripts/autocomplete.ts | 3 +-
packages/frontend/src/scripts/chart-legend.ts | 2 +-
packages/frontend/src/scripts/chart-vline.ts | 2 +-
.../src/scripts/check-reaction-permissions.ts | 2 +-
packages/frontend/src/scripts/emoji-picker.ts | 3 +-
packages/frontend/src/scripts/get-note-menu.ts | 3 +-
packages/frontend/src/scripts/get-user-menu.ts | 2 +-
packages/frontend/src/scripts/install-theme.ts | 3 +-
.../frontend/src/scripts/mfm-function-picker.ts | 3 +-
packages/frontend/src/scripts/page-metadata.ts | 3 +-
packages/frontend/src/scripts/reaction-picker.ts | 3 +-
packages/frontend/src/scripts/theme-editor.ts | 3 +-
packages/frontend/src/scripts/use-form.ts | 3 +-
packages/frontend/src/scripts/use-leave-guard.ts | 2 +-
packages/frontend/src/scripts/use-note-capture.ts | 3 +-
packages/frontend/src/scripts/use-tooltip.ts | 3 +-
packages/frontend/src/store.ts | 3 +-
packages/frontend/src/theme-store.ts | 3 +-
packages/frontend/src/types/menu.ts | 2 +-
packages/frontend/src/ui/classic.vue | 3 +-
packages/frontend/src/ui/deck/antenna-column.vue | 5 +-
packages/frontend/src/ui/deck/channel-column.vue | 5 +-
packages/frontend/src/ui/deck/column.vue | 3 +-
packages/frontend/src/ui/deck/deck-store.ts | 2 +-
packages/frontend/src/ui/deck/direct-column.vue | 2 +-
packages/frontend/src/ui/deck/list-column.vue | 5 +-
packages/frontend/src/ui/deck/main-column.vue | 6 +-
packages/frontend/src/ui/deck/mentions-column.vue | 2 +-
.../frontend/src/ui/deck/notifications-column.vue | 3 +-
.../frontend/src/ui/deck/role-timeline-column.vue | 5 +-
packages/frontend/src/ui/deck/tl-column.vue | 5 +-
.../frontend/src/ui/deck/tl-note-notification.ts | 7 ++-
packages/frontend/src/ui/deck/widgets-column.vue | 3 +-
packages/frontend/src/ui/minimum.vue | 3 +-
packages/frontend/src/ui/universal.vue | 6 +-
packages/frontend/src/ui/visitor.vue | 3 +-
packages/frontend/src/ui/zen.vue | 3 +-
packages/frontend/src/widgets/WidgetActivity.vue | 5 +-
packages/frontend/src/widgets/WidgetAichan.vue | 5 +-
packages/frontend/src/widgets/WidgetAiscript.vue | 5 +-
.../frontend/src/widgets/WidgetAiscriptApp.vue | 11 ++--
.../src/widgets/WidgetBirthdayFollowings.vue | 5 +-
packages/frontend/src/widgets/WidgetButton.vue | 5 +-
packages/frontend/src/widgets/WidgetCalendar.vue | 5 +-
packages/frontend/src/widgets/WidgetClicker.vue | 5 +-
packages/frontend/src/widgets/WidgetClock.vue | 5 +-
.../frontend/src/widgets/WidgetDigitalClock.vue | 5 +-
packages/frontend/src/widgets/WidgetFederation.vue | 5 +-
.../frontend/src/widgets/WidgetInstanceCloud.vue | 5 +-
.../frontend/src/widgets/WidgetInstanceInfo.vue | 5 +-
packages/frontend/src/widgets/WidgetJobQueue.vue | 5 +-
packages/frontend/src/widgets/WidgetMemo.vue | 5 +-
.../frontend/src/widgets/WidgetNotifications.vue | 5 +-
.../frontend/src/widgets/WidgetOnlineUsers.vue | 5 +-
packages/frontend/src/widgets/WidgetPhotos.vue | 5 +-
packages/frontend/src/widgets/WidgetPostForm.vue | 5 +-
packages/frontend/src/widgets/WidgetProfile.vue | 5 +-
packages/frontend/src/widgets/WidgetRss.vue | 5 +-
packages/frontend/src/widgets/WidgetRssTicker.vue | 5 +-
packages/frontend/src/widgets/WidgetSlideshow.vue | 5 +-
packages/frontend/src/widgets/WidgetTimeline.vue | 5 +-
packages/frontend/src/widgets/WidgetTrends.vue | 5 +-
packages/frontend/src/widgets/WidgetUnixClock.vue | 5 +-
packages/frontend/src/widgets/WidgetUserList.vue | 5 +-
packages/frontend/src/widgets/index.ts | 3 +-
.../frontend/src/widgets/server-metric/index.vue | 5 +-
packages/frontend/src/widgets/widget.ts | 2 +-
246 files changed, 552 insertions(+), 402 deletions(-)
(limited to 'packages/frontend/src')
diff --git a/packages/frontend/src/boot/common.ts b/packages/frontend/src/boot/common.ts
index ae6b1aee26..1d8e40a12d 100644
--- a/packages/frontend/src/boot/common.ts
+++ b/packages/frontend/src/boot/common.ts
@@ -3,7 +3,8 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
-import { computed, watch, version as vueVersion, App } from 'vue';
+import { computed, watch, version as vueVersion } from 'vue';
+import type { App } from 'vue';
import { compareVersions } from 'compare-versions';
import { version, lang, updateLocale, locale } from '@@/js/config.js';
import widgets from '@/widgets/index.js';
diff --git a/packages/frontend/src/boot/main-boot.ts b/packages/frontend/src/boot/main-boot.ts
index 874e97f3a4..3a43c6794b 100644
--- a/packages/frontend/src/boot/main-boot.ts
+++ b/packages/frontend/src/boot/main-boot.ts
@@ -6,7 +6,7 @@
import { createApp, defineAsyncComponent, markRaw } from 'vue';
import { ui } from '@@/js/config.js';
import { common } from './common.js';
-import type * as Misskey from 'misskey-js';
+import * as Misskey from 'misskey-js';
import type { Component } from 'vue';
import { i18n } from '@/i18n.js';
import { alert, confirm, popup, post, toast } from '@/os.js';
@@ -22,7 +22,8 @@ import { initializeSw } from '@/scripts/initialize-sw.js';
import { deckStore } from '@/ui/deck/deck-store.js';
import { emojiPicker } from '@/scripts/emoji-picker.js';
import { mainRouter } from '@/router/main.js';
-import { type Keymap, makeHotkey } from '@/scripts/hotkey.js';
+import { makeHotkey } from '@/scripts/hotkey.js';
+import type { Keymap } from '@/scripts/hotkey.js';
import { addCustomEmoji, removeCustomEmojis, updateCustomEmojis } from '@/custom-emojis.js';
export async function mainBoot() {
diff --git a/packages/frontend/src/components/MkAbuseReportWindow.stories.impl.ts b/packages/frontend/src/components/MkAbuseReportWindow.stories.impl.ts
index 9df957f3ec..b62096bbe9 100644
--- a/packages/frontend/src/components/MkAbuseReportWindow.stories.impl.ts
+++ b/packages/frontend/src/components/MkAbuseReportWindow.stories.impl.ts
@@ -5,7 +5,7 @@
/* eslint-disable @typescript-eslint/explicit-function-return-type */
import { action } from '@storybook/addon-actions';
-import { StoryObj } from '@storybook/vue3';
+import type { StoryObj } from '@storybook/vue3';
import { HttpResponse, http } from 'msw';
import { userDetailed } from '../../.storybook/fakes.js';
import { commonHandlers } from '../../.storybook/mocks.js';
diff --git a/packages/frontend/src/components/MkAccountMoved.stories.impl.ts b/packages/frontend/src/components/MkAccountMoved.stories.impl.ts
index cad26de6e2..b907b5b25a 100644
--- a/packages/frontend/src/components/MkAccountMoved.stories.impl.ts
+++ b/packages/frontend/src/components/MkAccountMoved.stories.impl.ts
@@ -5,7 +5,7 @@
/* eslint-disable @typescript-eslint/explicit-function-return-type */
import { action } from '@storybook/addon-actions';
-import { StoryObj } from '@storybook/vue3';
+import type { StoryObj } from '@storybook/vue3';
import { HttpResponse, http } from 'msw';
import { commonHandlers } from '../../.storybook/mocks.js';
import { userDetailed } from '../../.storybook/fakes.js';
diff --git a/packages/frontend/src/components/MkAchievements.stories.impl.ts b/packages/frontend/src/components/MkAchievements.stories.impl.ts
index 7614da51da..bbd3f69d7c 100644
--- a/packages/frontend/src/components/MkAchievements.stories.impl.ts
+++ b/packages/frontend/src/components/MkAchievements.stories.impl.ts
@@ -4,7 +4,7 @@
*/
/* eslint-disable @typescript-eslint/explicit-function-return-type */
-import { StoryObj } from '@storybook/vue3';
+import type { StoryObj } from '@storybook/vue3';
import { HttpResponse, http } from 'msw';
import { userDetailed } from '../../.storybook/fakes.js';
import { commonHandlers } from '../../.storybook/mocks.js';
diff --git a/packages/frontend/src/components/MkAnalogClock.stories.impl.ts b/packages/frontend/src/components/MkAnalogClock.stories.impl.ts
index 270ca40825..a01d91ad20 100644
--- a/packages/frontend/src/components/MkAnalogClock.stories.impl.ts
+++ b/packages/frontend/src/components/MkAnalogClock.stories.impl.ts
@@ -4,7 +4,7 @@
*/
/* eslint-disable @typescript-eslint/explicit-function-return-type */
-import { StoryObj } from '@storybook/vue3';
+import type { StoryObj } from '@storybook/vue3';
import isChromatic from 'chromatic/isChromatic';
import MkAnalogClock from './MkAnalogClock.vue';
export const Default = {
diff --git a/packages/frontend/src/components/MkAnnouncementDialog.stories.impl.ts b/packages/frontend/src/components/MkAnnouncementDialog.stories.impl.ts
index bf3ddb935b..627cb0c4ff 100644
--- a/packages/frontend/src/components/MkAnnouncementDialog.stories.impl.ts
+++ b/packages/frontend/src/components/MkAnnouncementDialog.stories.impl.ts
@@ -5,7 +5,7 @@
/* eslint-disable @typescript-eslint/explicit-function-return-type */
import { action } from '@storybook/addon-actions';
-import { StoryObj } from '@storybook/vue3';
+import type { StoryObj } from '@storybook/vue3';
import { HttpResponse, http } from 'msw';
import { commonHandlers } from '../../.storybook/mocks.js';
import MkAnnouncementDialog from './MkAnnouncementDialog.vue';
diff --git a/packages/frontend/src/components/MkAntennaEditor.stories.impl.ts b/packages/frontend/src/components/MkAntennaEditor.stories.impl.ts
index 1749e07a4e..4d921a4c48 100644
--- a/packages/frontend/src/components/MkAntennaEditor.stories.impl.ts
+++ b/packages/frontend/src/components/MkAntennaEditor.stories.impl.ts
@@ -5,7 +5,7 @@
/* eslint-disable @typescript-eslint/explicit-function-return-type */
import { action } from '@storybook/addon-actions';
-import { StoryObj } from '@storybook/vue3';
+import type { StoryObj } from '@storybook/vue3';
import { HttpResponse, http } from 'msw';
import { commonHandlers } from '../../.storybook/mocks.js';
import MkAntennaEditor from './MkAntennaEditor.vue';
diff --git a/packages/frontend/src/components/MkAntennaEditorDialog.stories.impl.ts b/packages/frontend/src/components/MkAntennaEditorDialog.stories.impl.ts
index 1c6ca83b47..5878b52fb9 100644
--- a/packages/frontend/src/components/MkAntennaEditorDialog.stories.impl.ts
+++ b/packages/frontend/src/components/MkAntennaEditorDialog.stories.impl.ts
@@ -5,7 +5,7 @@
/* eslint-disable @typescript-eslint/explicit-function-return-type */
import { action } from '@storybook/addon-actions';
-import { StoryObj } from '@storybook/vue3';
+import type { StoryObj } from '@storybook/vue3';
import { HttpResponse, http } from 'msw';
import { commonHandlers } from '../../.storybook/mocks.js';
import MkAntennaEditorDialog from './MkAntennaEditorDialog.vue';
diff --git a/packages/frontend/src/components/MkAsUi.vue b/packages/frontend/src/components/MkAsUi.vue
index 365b767bd6..5c4d887e0c 100644
--- a/packages/frontend/src/components/MkAsUi.vue
+++ b/packages/frontend/src/components/MkAsUi.vue
@@ -63,14 +63,15 @@ SPDX-License-Identifier: AGPL-3.0-only
+
diff --git a/packages/frontend/src/components/MkPostFormAttaches.vue b/packages/frontend/src/components/MkPostFormAttaches.vue
index 1336b61356..33fe82b759 100644
--- a/packages/frontend/src/components/MkPostFormAttaches.vue
+++ b/packages/frontend/src/components/MkPostFormAttaches.vue
@@ -22,20 +22,24 @@ SPDX-License-Identifier: AGPL-3.0-only
-
{{ 16 - props.modelValue.length }}/16
+
+ {{ 16 - props.modelValue.length }}/16
+
--
cgit v1.2.3-freya
From 9ffe504c7f75490822a3efdb39b70a8f0d046bcf Mon Sep 17 00:00:00 2001
From: claustra01 <108509532+claustra01@users.noreply.github.com>
Date: Sun, 16 Feb 2025 19:34:50 +0900
Subject: enhance(frontend): CWの注釈で入力済みの文字数を表示する (#15070)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* enhance: CW注釈の文字数表示
* update: CHANGELOG.md
* chore: maxCwTextLengthをただのconstにする
* fix: 投稿ボタンのdisable判定条件
---------
Co-authored-by: かっこかり <67428053+kakkokari-gtyih@users.noreply.github.com>
---
CHANGELOG.md | 2 ++
packages/frontend/src/components/MkPostForm.vue | 34 ++++++++++++++++++++++++-
2 files changed, 35 insertions(+), 1 deletion(-)
(limited to 'packages/frontend/src')
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 885c8270da..57100aaf3b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,10 +12,12 @@
- Enhance: 開発者モードでメニューからファイルIDをコピー出来るように `#15441'
- Enhance: ノートに埋め込まれたメディアのコンテキストメニューから管理者用のファイル管理画面を開けるように ( #15440 )
- Enhance: リアクションする際に確認ダイアログを表示できるように
+- Enhance: CWの注釈で入力済みの文字数を表示
- Fix: コンディショナルロールを手動で割り当てできる導線を削除 `#13529`
- Fix: 埋め込みプレイヤーから外部ページに移動できない問題を修正
- Fix: Play の再読込時に UI が以前の状態を引き継いでしまう問題を修正 `#14378`
- Fix: カスタム絵文字管理画面(beta)にてisSensitive/localOnlyの絞り込みが上手くいかない問題の修正 ( #15445 )
+- Fix: CWの注釈が100文字を超えている場合、ノート投稿ボタンを非アクティブに
### Server
- Fix: `following/invalidate`でフォロワーを解除しようとしているユーザーの情報を返すように
diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue
index 49ed4197de..ad0a332f99 100644
--- a/packages/frontend/src/components/MkPostForm.vue
+++ b/packages/frontend/src/components/MkPostForm.vue
@@ -65,7 +65,10 @@ SPDX-License-Identifier: AGPL-3.0-only
{{ i18n.ts.notSpecifiedMentionWarning }} -
-
+
+
+
{{ maxCwTextLength - cwTextLength }}
+
@@ -244,6 +247,12 @@ const maxTextLength = computed((): number => {
return instance ? instance.maxNoteTextLength : 1000;
});
+const cwTextLength = computed((): number => {
+ return cw.value?.length ?? 0;
+});
+
+const maxCwTextLength = 100;
+
const canPost = computed((): boolean => {
return !props.mock && !posting.value && !posted.value &&
(
@@ -254,6 +263,7 @@ const canPost = computed((): boolean => {
quoteId.value != null
) &&
(textLength.value <= maxTextLength.value) &&
+ (cwTextLength.value <= maxCwTextLength) &&
(files.value.length <= 16) &&
(!poll.value || poll.value.choices.length >= 2);
});
@@ -1273,12 +1283,34 @@ html[data-color-scheme=light] .preview {
}
}
+.cwOuter {
+ width: 100%;
+ position: relative;
+}
+
.cw {
z-index: 1;
padding-bottom: 8px;
border-bottom: solid 0.5px var(--MI_THEME-divider);
}
+.cwTextCount {
+ position: absolute;
+ top: 0;
+ right: 2px;
+ padding: 2px 6px;
+ font-size: .9em;
+ color: var(--MI_THEME-warn);
+ border-radius: 6px;
+ max-width: 100%;
+ min-width: 1.6em;
+ text-align: center;
+
+ &.cwTextOver {
+ color: #ff2a2a;
+ }
+}
+
.hashtags {
z-index: 1;
padding-top: 8px;
--
cgit v1.2.3-freya
From 34f8345bc8330e0e53ab9e043a4b6db7b150636f Mon Sep 17 00:00:00 2001
From: syuilo <4439005+syuilo@users.noreply.github.com>
Date: Mon, 17 Feb 2025 14:38:15 +0900
Subject: clean up dev logs
---
packages/frontend-shared/js/scroll.ts | 1 -
packages/frontend/src/components/global/MkA.vue | 2 +-
packages/frontend/src/nirax.ts | 4 +---
packages/frontend/src/pizzax.ts | 13 ++++---------
4 files changed, 6 insertions(+), 14 deletions(-)
(limited to 'packages/frontend/src')
diff --git a/packages/frontend-shared/js/scroll.ts b/packages/frontend-shared/js/scroll.ts
index 4f2e9105c3..508864b12c 100644
--- a/packages/frontend-shared/js/scroll.ts
+++ b/packages/frontend-shared/js/scroll.ts
@@ -134,7 +134,6 @@ export function scrollToBottom(
export function isTopVisible(el: HTMLElement, tolerance = 1): boolean {
const scrollTop = getScrollPosition(el);
- if (_DEV_) console.log(scrollTop, tolerance, scrollTop <= tolerance);
return scrollTop <= tolerance;
}
diff --git a/packages/frontend/src/components/global/MkA.vue b/packages/frontend/src/components/global/MkA.vue
index 87fa9c8252..8eacf16d6d 100644
--- a/packages/frontend/src/components/global/MkA.vue
+++ b/packages/frontend/src/components/global/MkA.vue
@@ -15,9 +15,9 @@ export type MkABehavior = 'window' | 'browser' | null;
diff --git a/packages/frontend/src/pages/note.vue b/packages/frontend/src/pages/note.vue
index e6b4a0b222..0791c1343b 100644
--- a/packages/frontend/src/pages/note.vue
+++ b/packages/frontend/src/pages/note.vue
@@ -63,6 +63,7 @@ import { dateString } from '@/filters/date.js';
import MkClipPreview from '@/components/MkClipPreview.vue';
import { defaultStore } from '@/store.js';
import { pleaseLogin } from '@/scripts/please-login.js';
+import { getAppearNote } from '@/scripts/get-appear-note.js';
import { serverContext, assertServerContext } from '@/server-context.js';
import { $i } from '@/account.js';
@@ -132,10 +133,11 @@ function fetchNote() {
noteId: props.noteId,
}).then(res => {
note.value = res;
+ const appearNote = getAppearNote(res);
// 古いノートは被クリップ数をカウントしていないので、2023-10-01以前のものは強制的にnotes/clipsを叩く
- if (note.value.clippedCount > 0 || new Date(note.value.createdAt).getTime() < new Date('2023-10-01').getTime()) {
+ if ((appearNote.clippedCount ?? 0) > 0 || new Date(appearNote.createdAt).getTime() < new Date('2023-10-01').getTime()) {
misskeyApi('notes/clips', {
- noteId: note.value.id,
+ noteId: appearNote.id,
}).then((_clips) => {
clips.value = _clips;
});
@@ -170,7 +172,7 @@ definePageMetadata(() => ({
avatar: note.value.user,
path: `/notes/${note.value.id}`,
share: {
- title: i18n.tsx.noteOf({ user: note.value.user.name }),
+ title: i18n.tsx.noteOf({ user: note.value.user.name ?? note.value.user.username }),
text: note.value.text,
},
} : {},
--
cgit v1.2.3-freya
From 15b0345335397ae6df8c85871793adab49343ec7 Mon Sep 17 00:00:00 2001
From: おさむのひと <46447427+samunohito@users.noreply.github.com>
Date: Wed, 26 Feb 2025 16:28:35 +0900
Subject: enhance(frontend):
コントロールパネルのユーザ検索で入力された情報をページ遷移で損なわないように
(#15438)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* enhance(frontend): コントロールパネルのユーザ検索で入力された情報をページ遷移で損なわないように
* sessionStorageよりも更に短命な方法で持つように変更
* add comment
---------
Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com>
---
CHANGELOG.md | 1 +
packages/frontend/src/account.ts | 5 ++-
packages/frontend/src/memory-storage.ts | 57 +++++++++++++++++++++++++++++
packages/frontend/src/pages/admin/users.vue | 44 +++++++++++++++++++---
4 files changed, 100 insertions(+), 7 deletions(-)
create mode 100644 packages/frontend/src/memory-storage.ts
(limited to 'packages/frontend/src')
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d51369f4bb..940a4309bc 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,6 +13,7 @@
- Enhance: 開発者モードでメニューからファイルIDをコピー出来るように `#15441'
- Enhance: ノートに埋め込まれたメディアのコンテキストメニューから管理者用のファイル管理画面を開けるように ( #15440 )
- Enhance: リアクションする際に確認ダイアログを表示できるように
+- Enhance: コントロールパネルのユーザ検索で入力された情報をページ遷移で損なわないように `#15437`
- Enhance: CWの注釈で入力済みの文字数を表示
- Fix: ノートページで、クリップ一覧が表示されないことがある問題を修正
- Fix: コンディショナルロールを手動で割り当てできる導線を削除 `#13529`
diff --git a/packages/frontend/src/account.ts b/packages/frontend/src/account.ts
index 9006150bc8..17d690cd3a 100644
--- a/packages/frontend/src/account.ts
+++ b/packages/frontend/src/account.ts
@@ -7,6 +7,7 @@ import { defineAsyncComponent, reactive, ref } from 'vue';
import * as Misskey from 'misskey-js';
import { apiUrl } from '@@/js/config.js';
import type { MenuItem, MenuButton } from '@/types/menu.js';
+import { defaultMemoryStorage } from '@/memory-storage';
import { showSuspendedDialog } from '@/scripts/show-suspended-dialog.js';
import { i18n } from '@/i18n.js';
import { miLocalStorage } from '@/local-storage.js';
@@ -40,6 +41,8 @@ export function incNotesCount() {
export async function signout() {
if (!$i) return;
+ defaultMemoryStorage.clear();
+
waiting();
document.cookie.split(';').forEach((cookie) => {
const cookieName = cookie.split('=')[0].trim();
@@ -107,7 +110,7 @@ export async function removeAccount(idOrToken: Account['id']) {
}
function fetchAccount(token: string, id?: string, forceShowDialog?: boolean): Promise
{
- document.cookie = "token=; path=/; max-age=0";
+ document.cookie = 'token=; path=/; max-age=0';
document.cookie = `token=${token}; path=/queue; max-age=86400; SameSite=Strict; Secure`; // bull dashboardの認証とかで使う
return new Promise((done, fail) => {
diff --git a/packages/frontend/src/memory-storage.ts b/packages/frontend/src/memory-storage.ts
new file mode 100644
index 0000000000..df0dc1308f
--- /dev/null
+++ b/packages/frontend/src/memory-storage.ts
@@ -0,0 +1,57 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+export type MemoryStorage = {
+ has: (key: string) => boolean;
+ getItem: (key: string) => T | null;
+ setItem: (key: string, value: unknown) => void;
+ removeItem: (key: string) => void;
+ clear: () => void;
+ size: number;
+};
+
+class MemoryStorageImpl implements MemoryStorage {
+ private readonly storage: Map;
+
+ constructor() {
+ this.storage = new Map();
+ }
+
+ has(key: string): boolean {
+ return this.storage.has(key);
+ }
+
+ getItem(key: string): T | null {
+ return this.storage.has(key) ? this.storage.get(key) as T : null;
+ }
+
+ setItem(key: string, value: unknown): void {
+ this.storage.set(key, value);
+ }
+
+ removeItem(key: string): void {
+ this.storage.delete(key);
+ }
+
+ clear(): void {
+ this.storage.clear();
+ }
+
+ get size(): number {
+ return this.storage.size;
+ }
+}
+
+export function createMemoryStorage(): MemoryStorage {
+ return new MemoryStorageImpl();
+}
+
+/**
+ * SessionStorageよりも更に短い期間でクリアされるストレージです
+ * - ブラウザの再読み込みやタブの閉じると内容が揮発します
+ * - このストレージは他のタブと共有されません
+ * - アカウント切り替えやログアウトを行うと内容が揮発します
+ */
+export const defaultMemoryStorage: MemoryStorage = createMemoryStorage();
diff --git a/packages/frontend/src/pages/admin/users.vue b/packages/frontend/src/pages/admin/users.vue
index 870c3ce88b..91104b676d 100644
--- a/packages/frontend/src/pages/admin/users.vue
+++ b/packages/frontend/src/pages/admin/users.vue
@@ -9,6 +9,9 @@ SPDX-License-Identifier: AGPL-3.0-only
+
+ {{ i18n.ts.reset }}
+
{{ i18n.ts.sort }}
@@ -57,8 +60,10 @@ SPDX-License-Identifier: AGPL-3.0-only
diff --git a/packages/frontend/src/pages/search.user.vue b/packages/frontend/src/pages/search.user.vue
index e8bc4cd6d3..2b8faf5465 100644
--- a/packages/frontend/src/pages/search.user.vue
+++ b/packages/frontend/src/pages/search.user.vue
@@ -19,7 +19,7 @@ SPDX-License-Identifier: AGPL-3.0-only
{{ i18n.ts.searchResult }}
-
+
@@ -49,14 +49,16 @@ const props = withDefaults(defineProps<{
const router = useRouter();
-const key = ref('');
+const key = ref(0);
+const userPagination = ref
>();
+
const searchQuery = ref(toRef(props, 'query').value);
const searchOrigin = ref(toRef(props, 'origin').value);
-const userPagination = ref();
async function search() {
const query = searchQuery.value.toString().trim();
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (query == null || query === '') return;
//#region AP lookup
@@ -76,6 +78,7 @@ async function search() {
if (res.type === 'User') {
router.push(`/@${res.object.username}@${res.object.host}`);
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
} else if (res.type === 'Note') {
router.push(`/notes/${res.object.id}`);
}
@@ -118,6 +121,6 @@ async function search() {
},
};
- key.value = query;
+ key.value++;
}
diff --git a/packages/frontend/src/scripts/gen-search-query.ts b/packages/frontend/src/scripts/gen-search-query.ts
deleted file mode 100644
index a85ee01e26..0000000000
--- a/packages/frontend/src/scripts/gen-search-query.ts
+++ /dev/null
@@ -1,35 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import * as Misskey from 'misskey-js';
-import { host as localHost } from '@@/js/config.js';
-
-export async function genSearchQuery(v: any, q: string) {
- let host: string;
- let userId: string;
- if (q.split(' ').some(x => x.startsWith('@'))) {
- for (const at of q.split(' ').filter(x => x.startsWith('@')).map(x => x.substring(1))) {
- if (at.includes('.')) {
- if (at === localHost || at === '.') {
- host = null;
- } else {
- host = at;
- }
- } else {
- const user = await v.api('users/show', Misskey.acct.parse(at)).catch(x => null);
- if (user) {
- userId = user.id;
- } else {
- // todo: show error
- }
- }
- }
- }
- return {
- query: q.split(' ').filter(x => !x.startsWith('/') && !x.startsWith('@')).join(' '),
- host: host,
- userId: userId,
- };
-}
--
cgit v1.2.3-freya
From 616cccf2511337fc181d0b6aa693b7091c7ba57b Mon Sep 17 00:00:00 2001
From: syuilo <4439005+syuilo@users.noreply.github.com>
Date: Sun, 2 Mar 2025 20:06:20 +0900
Subject: enhance(backend): refine system account (#15530)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* wip
* wip
* wip
* Update SystemAccountService.ts
* Update 1740121393164-system-accounts.js
* Update DeleteAccountService.ts
* wip
* wip
* wip
* wip
* Update 1740121393164-system-accounts.js
* Update RepositoryModule.ts
* wip
* wip
* wip
* Update ApRendererService.ts
* wip
* wip
* Update SystemAccountService.ts
* fix tests
* fix tests
* fix tests
* fix tests
* fix tests
* fix tests
* add print logs
* ログが長すぎて出てないかもしれない
* fix migration
* refactor
* fix fed-tests
* Update RelayService.ts
* merge
* Update user.test.ts
* chore: emit log
* fix: tweak sleep duration
* fix: exit 1
* fix: wait for misskey processes to become healthy
* fix: longer sleep for user deletion
* fix: make sleep longer again
* デッドロック解消の試み
https://github.com/misskey-dev/misskey/issues/15005
* Revert "デッドロック解消の試み"
This reverts commit 266141f66fb584371bbb56ef7eba04e14bcff94d.
* wip
* Update SystemAccountService.ts
---------
Co-authored-by: おさむのひと <46447427+samunohito@users.noreply.github.com>
Co-authored-by: zyoshoka <107108195+zyoshoka@users.noreply.github.com>
---
.github/workflows/test-federation.yml | 16 ++
CHANGELOG.md | 3 +-
cypress/e2e/basic.cy.ts | 2 +-
locales/index.d.ts | 4 +
locales/ja-JP.yml | 1 +
package.json | 4 +-
.../migration/1740121393164-system-accounts.js | 37 +++++
.../migration/1740129169650-system-accounts-2.js | 18 +++
.../migration/1740133121105-system-accounts-3.js | 23 +++
packages/backend/src/GlobalModule.ts | 2 +-
packages/backend/src/core/AbuseReportService.ts | 6 +-
packages/backend/src/core/AccountMoveService.ts | 14 +-
packages/backend/src/core/CoreModule.ts | 24 +--
.../backend/src/core/CreateSystemUserService.ts | 86 -----------
packages/backend/src/core/DeleteAccountService.ts | 14 +-
packages/backend/src/core/InstanceActorService.ts | 57 -------
packages/backend/src/core/MetaService.ts | 23 +--
packages/backend/src/core/ProxyAccountService.ts | 28 ----
packages/backend/src/core/RelayService.ts | 33 +---
packages/backend/src/core/RoleService.ts | 22 +--
packages/backend/src/core/SignupService.ts | 15 +-
packages/backend/src/core/SystemAccountService.ts | 172 +++++++++++++++++++++
packages/backend/src/core/UserListService.ts | 10 +-
packages/backend/src/core/WebhookTestService.ts | 1 -
.../src/core/activitypub/ApRendererService.ts | 43 +++++-
.../src/core/activitypub/ApResolverService.ts | 16 +-
.../backend/src/core/entities/MetaEntityService.ts | 12 +-
.../backend/src/core/entities/UserEntityService.ts | 10 +-
packages/backend/src/di-symbols.ts | 1 +
packages/backend/src/models/Meta.ts | 26 ++--
packages/backend/src/models/RepositoryModule.ts | 13 +-
packages/backend/src/models/SystemAccount.ts | 31 ++++
packages/backend/src/models/User.ts | 6 -
packages/backend/src/models/_.ts | 3 +
packages/backend/src/postgres.ts | 2 +
.../backend/src/server/NodeinfoServerService.ts | 8 +-
packages/backend/src/server/api/ApiCallService.ts | 4 +-
packages/backend/src/server/api/endpoint-list.ts | 1 +
.../server/api/endpoints/admin/accounts/create.ts | 13 +-
.../server/api/endpoints/admin/accounts/delete.ts | 4 -
.../backend/src/server/api/endpoints/admin/meta.ts | 8 +-
.../server/api/endpoints/admin/reset-password.ts | 7 +-
.../src/server/api/endpoints/admin/update-meta.ts | 5 -
.../api/endpoints/admin/update-proxy-account.ts | 62 ++++++++
.../backend/src/server/api/endpoints/i/move.ts | 9 +-
.../backend/src/server/api/endpoints/reset-db.ts | 19 ++-
packages/backend/src/types.ts | 29 ++--
packages/backend/test-federation/compose.yml | 4 +
.../test-federation/test/abuse-report.test.ts | 2 +-
packages/backend/test-federation/test/user.test.ts | 7 +-
packages/backend/test-federation/test/utils.ts | 2 +-
packages/backend/test/misc/mock-resolver.ts | 9 +-
.../test/unit/AbuseReportNotificationService.ts | 6 +-
packages/backend/test/unit/FlashService.ts | 6 +-
packages/backend/test/unit/RelayService.ts | 25 ++-
packages/backend/test/unit/RoleService.ts | 22 ++-
packages/backend/test/unit/SystemWebhookService.ts | 2 +-
packages/backend/test/unit/UserSearchService.ts | 2 +-
packages/backend/test/unit/UserWebhookService.ts | 2 +-
packages/backend/test/unit/WebhookTestService.ts | 4 +-
.../CheckModeratorsActivityProcessorService.ts | 4 +-
packages/frontend/src/pages/admin-user.vue | 44 ++++--
.../frontend/src/pages/admin/modlog.ModLog.vue | 5 +
packages/frontend/src/pages/admin/settings.vue | 32 ++--
packages/frontend/src/pages/user/home.vue | 3 +-
packages/frontend/test/home.test.ts | 2 -
packages/misskey-js/etc/misskey-js.api.md | 8 +
packages/misskey-js/src/autogen/apiClientJSDoc.ts | 11 ++
packages/misskey-js/src/autogen/endpoint.ts | 3 +
packages/misskey-js/src/autogen/entities.ts | 2 +
packages/misskey-js/src/autogen/types.ts | 66 +++++++-
71 files changed, 782 insertions(+), 438 deletions(-)
create mode 100644 packages/backend/migration/1740121393164-system-accounts.js
create mode 100644 packages/backend/migration/1740129169650-system-accounts-2.js
create mode 100644 packages/backend/migration/1740133121105-system-accounts-3.js
delete mode 100644 packages/backend/src/core/CreateSystemUserService.ts
delete mode 100644 packages/backend/src/core/InstanceActorService.ts
delete mode 100644 packages/backend/src/core/ProxyAccountService.ts
create mode 100644 packages/backend/src/core/SystemAccountService.ts
create mode 100644 packages/backend/src/models/SystemAccount.ts
create mode 100644 packages/backend/src/server/api/endpoints/admin/update-proxy-account.ts
(limited to 'packages/frontend/src')
diff --git a/.github/workflows/test-federation.yml b/.github/workflows/test-federation.yml
index e7b5d7b098..0b71325de3 100644
--- a/.github/workflows/test-federation.yml
+++ b/.github/workflows/test-federation.yml
@@ -62,14 +62,30 @@ jobs:
bash ./setup.sh
sudo chmod 644 ./certificates/*.test.key
- name: Start servers
+ id: start_servers
+ continue-on-error: true
# https://github.com/docker/compose/issues/1294#issuecomment-374847206
run: |
cd packages/backend/test-federation
docker compose up -d --scale tester=0
+ - name: Print start_servers error
+ if: ${{ steps.start_servers.outcome == 'failure' }}
+ run: |
+ cd packages/backend/test-federation
+ docker compose logs | tail -n 300
+ exit 1
- name: Test
+ id: test
+ continue-on-error: true
run: |
cd packages/backend/test-federation
docker compose run --no-deps tester
+ - name: Log
+ if: ${{ steps.test.outcome == 'failure' }}
+ run: |
+ cd packages/backend/test-federation
+ docker compose logs
+ exit 1
- name: Stop servers
run: |
cd packages/backend/test-federation
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ab7eda0154..5e5703537b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,7 +1,8 @@
## Unreleased
### General
--
+- Enhance: プロキシアカウントをシステムアカウントとして作成するように
+- Fix: システムアカウントが削除できる問題を修正
### Client
-
diff --git a/cypress/e2e/basic.cy.ts b/cypress/e2e/basic.cy.ts
index d2efbf709c..6471f96504 100644
--- a/cypress/e2e/basic.cy.ts
+++ b/cypress/e2e/basic.cy.ts
@@ -233,7 +233,7 @@ describe('After user setup', () => {
cy.get('[data-cy-post-form-text]').type('Hello, Misskey!');
cy.get('[data-cy-open-post-form-submit]').click();
- cy.contains('Hello, Misskey!');
+ cy.contains('Hello, Misskey!', { timeout: 15000 });
});
it('open note form with hotkey', () => {
diff --git a/locales/index.d.ts b/locales/index.d.ts
index 74e3cdeceb..dfdc5a3b37 100644
--- a/locales/index.d.ts
+++ b/locales/index.d.ts
@@ -10058,6 +10058,10 @@ export interface Locale extends ILocale {
* ギャラリーの投稿を削除
*/
"deleteGalleryPost": string;
+ /**
+ * プロキシアカウントの説明を更新
+ */
+ "updateProxyAccountDescription": string;
};
"_fileViewer": {
/**
diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml
index 270b5fc265..c3d6fa5a41 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -2664,6 +2664,7 @@ _moderationLogTypes:
deletePage: "ページを削除"
deleteFlash: "Playを削除"
deleteGalleryPost: "ギャラリーの投稿を削除"
+ updateProxyAccountDescription: "プロキシアカウントの説明を更新"
_fileViewer:
title: "ファイルの詳細"
diff --git a/package.json b/package.json
index 201b30ec36..9e154e3ddd 100644
--- a/package.json
+++ b/package.json
@@ -25,7 +25,7 @@
"build-storybook": "pnpm --filter frontend build-storybook",
"build-misskey-js-with-types": "pnpm build-pre && pnpm --filter backend... --filter=!misskey-js build && pnpm --filter backend generate-api-json --no-build && ncp packages/backend/built/api.json packages/misskey-js/generator/api.json && pnpm --filter misskey-js update-autogen-code && pnpm --filter misskey-js build && pnpm --filter misskey-js api",
"start": "pnpm check:connect && cd packages/backend && node ./built/boot/entry.js",
- "start:test": "cd packages/backend && cross-env NODE_ENV=test node ./built/boot/entry.js",
+ "start:test": "ncp ./.github/misskey/test.yml ./.config/test.yml && cd packages/backend && cross-env NODE_ENV=test node ./built/boot/entry.js",
"init": "pnpm migrate",
"migrate": "cd packages/backend && pnpm migrate",
"revert": "cd packages/backend && pnpm revert",
@@ -37,7 +37,7 @@
"cy:open": "pnpm cypress open --browser --e2e --config-file=cypress.config.ts",
"cy:run": "pnpm cypress run",
"e2e": "pnpm start-server-and-test start:test http://localhost:61812 cy:run",
- "e2e-dev-container": "cp ./.config/cypress-devcontainer.yml ./.config/test.yml && pnpm start-server-and-test start:test http://localhost:61812 cy:run",
+ "e2e-dev-container": "ncp ./.config/cypress-devcontainer.yml ./.config/test.yml && pnpm start-server-and-test start:test http://localhost:61812 cy:run",
"jest": "cd packages/backend && pnpm jest",
"jest-and-coverage": "cd packages/backend && pnpm jest-and-coverage",
"test": "pnpm -r test",
diff --git a/packages/backend/migration/1740121393164-system-accounts.js b/packages/backend/migration/1740121393164-system-accounts.js
new file mode 100644
index 0000000000..9490cb2b64
--- /dev/null
+++ b/packages/backend/migration/1740121393164-system-accounts.js
@@ -0,0 +1,37 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+export class SystemAccounts1740121393164 {
+ name = 'SystemAccounts1740121393164'
+
+ async up(queryRunner) {
+ await queryRunner.query(`CREATE TABLE "system_account" ("id" character varying(32) NOT NULL, "userId" character varying(32) NOT NULL, "type" character varying(256) NOT NULL, CONSTRAINT "PK_edb56f4aaf9ddd50ee556da97ba" PRIMARY KEY ("id"))`);
+ await queryRunner.query(`CREATE INDEX "IDX_41a3c87a37aea616ee459369e1" ON "system_account" ("userId") `);
+ await queryRunner.query(`CREATE UNIQUE INDEX "IDX_c362033aee0ea51011386a5a7e" ON "system_account" ("type") `);
+ await queryRunner.query(`ALTER TABLE "system_account" ADD CONSTRAINT "FK_41a3c87a37aea616ee459369e12" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
+
+ const instanceActor = await queryRunner.query(`SELECT "id" FROM "user" WHERE "username" = 'instance.actor'`);
+ if (instanceActor.length > 0) {
+ await queryRunner.query(`INSERT INTO "system_account" ("id", "userId", "type") VALUES ('${instanceActor[0].id}', '${instanceActor[0].id}', 'actor')`);
+ }
+
+ const relayActor = await queryRunner.query(`SELECT "id" FROM "user" WHERE "username" = 'relay.actor'`);
+ if (relayActor.length > 0) {
+ await queryRunner.query(`INSERT INTO "system_account" ("id", "userId", "type") VALUES ('${relayActor[0].id}', '${relayActor[0].id}', 'relay')`);
+ }
+
+ const meta = await queryRunner.query(`SELECT "proxyAccountId" FROM "meta" ORDER BY "id" DESC LIMIT 1`);
+ if (!meta && meta.length >= 1 && meta[0].proxyAccountId) {
+ await queryRunner.query(`INSERT INTO "system_account" ("id", "userId", "type") VALUES ('${meta[0].proxyAccountId}', '${meta[0].proxyAccountId}', 'proxy')`);
+ }
+ }
+
+ async down(queryRunner) {
+ await queryRunner.query(`ALTER TABLE "system_account" DROP CONSTRAINT "FK_41a3c87a37aea616ee459369e12"`);
+ await queryRunner.query(`DROP INDEX "public"."IDX_c362033aee0ea51011386a5a7e"`);
+ await queryRunner.query(`DROP INDEX "public"."IDX_41a3c87a37aea616ee459369e1"`);
+ await queryRunner.query(`DROP TABLE "system_account"`);
+ }
+}
diff --git a/packages/backend/migration/1740129169650-system-accounts-2.js b/packages/backend/migration/1740129169650-system-accounts-2.js
new file mode 100644
index 0000000000..07270855bf
--- /dev/null
+++ b/packages/backend/migration/1740129169650-system-accounts-2.js
@@ -0,0 +1,18 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+export class SystemAccounts21740129169650 {
+ name = 'SystemAccounts21740129169650'
+
+ async up(queryRunner) {
+ await queryRunner.query(`ALTER TABLE "meta" DROP CONSTRAINT "FK_ab1bc0c1e209daa77b8e8d212ad"`);
+ await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "proxyAccountId"`);
+ }
+
+ async down(queryRunner) {
+ await queryRunner.query(`ALTER TABLE "meta" ADD "proxyAccountId" character varying(32)`);
+ await queryRunner.query(`ALTER TABLE "meta" ADD CONSTRAINT "FK_ab1bc0c1e209daa77b8e8d212ad" FOREIGN KEY ("proxyAccountId") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE NO ACTION`);
+ }
+}
diff --git a/packages/backend/migration/1740133121105-system-accounts-3.js b/packages/backend/migration/1740133121105-system-accounts-3.js
new file mode 100644
index 0000000000..02f9207cdc
--- /dev/null
+++ b/packages/backend/migration/1740133121105-system-accounts-3.js
@@ -0,0 +1,23 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+export class SystemAccounts31740133121105 {
+ name = 'SystemAccounts31740133121105'
+
+ async up(queryRunner) {
+ await queryRunner.query(`ALTER TABLE "meta" ADD "rootUserId" character varying(32)`);
+ await queryRunner.query(`ALTER TABLE "meta" ADD CONSTRAINT "FK_c80e4079d632f95eac06a9d28cc" FOREIGN KEY ("rootUserId") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE NO ACTION`);
+
+ const users = await queryRunner.query(`SELECT "id" FROM "user" WHERE "isRoot" = true LIMIT 1`);
+ if (users.length > 0) {
+ await queryRunner.query(`UPDATE "meta" SET "rootUserId" = $1`, [users[0].id]);
+ }
+ }
+
+ async down(queryRunner) {
+ await queryRunner.query(`ALTER TABLE "meta" DROP CONSTRAINT "FK_c80e4079d632f95eac06a9d28cc"`);
+ await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "rootUserId"`);
+ }
+}
diff --git a/packages/backend/src/GlobalModule.ts b/packages/backend/src/GlobalModule.ts
index ace7f7841c..5544eeeddd 100644
--- a/packages/backend/src/GlobalModule.ts
+++ b/packages/backend/src/GlobalModule.ts
@@ -133,7 +133,7 @@ const $meta: Provider = {
for (const key in body.after) {
(meta as any)[key] = (body.after as any)[key];
}
- meta.proxyAccount = null; // joinなカラムは通常取ってこないので
+ meta.rootUser = null; // joinなカラムは通常取ってこないので
break;
}
default:
diff --git a/packages/backend/src/core/AbuseReportService.ts b/packages/backend/src/core/AbuseReportService.ts
index 0b022d3b08..846d2c8ebd 100644
--- a/packages/backend/src/core/AbuseReportService.ts
+++ b/packages/backend/src/core/AbuseReportService.ts
@@ -10,9 +10,9 @@ import { bindThis } from '@/decorators.js';
import type { AbuseUserReportsRepository, MiAbuseUserReport, MiUser, UsersRepository } from '@/models/_.js';
import { AbuseReportNotificationService } from '@/core/AbuseReportNotificationService.js';
import { QueueService } from '@/core/QueueService.js';
-import { InstanceActorService } from '@/core/InstanceActorService.js';
import { ApRendererService } from '@/core/activitypub/ApRendererService.js';
import { ModerationLogService } from '@/core/ModerationLogService.js';
+import { SystemAccountService } from '@/core/SystemAccountService.js';
import { IdService } from './IdService.js';
@Injectable()
@@ -27,7 +27,7 @@ export class AbuseReportService {
private idService: IdService,
private abuseReportNotificationService: AbuseReportNotificationService,
private queueService: QueueService,
- private instanceActorService: InstanceActorService,
+ private systemAccountService: SystemAccountService,
private apRendererService: ApRendererService,
private moderationLogService: ModerationLogService,
) {
@@ -136,7 +136,7 @@ export class AbuseReportService {
forwarded: true,
});
- const actor = await this.instanceActorService.getInstanceActor();
+ const actor = await this.systemAccountService.fetch('actor');
const targetUser = await this.usersRepository.findOneByOrFail({ id: report.targetUserId });
const flag = this.apRendererService.renderFlag(actor, targetUser.uri!, report.comment);
diff --git a/packages/backend/src/core/AccountMoveService.ts b/packages/backend/src/core/AccountMoveService.ts
index 24d11f29ff..0fbb9bcd80 100644
--- a/packages/backend/src/core/AccountMoveService.ts
+++ b/packages/backend/src/core/AccountMoveService.ts
@@ -20,10 +20,10 @@ import { ApPersonService } from '@/core/activitypub/models/ApPersonService.js';
import { ApDeliverManagerService } from '@/core/activitypub/ApDeliverManagerService.js';
import { ApRendererService } from '@/core/activitypub/ApRendererService.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
-import { ProxyAccountService } from '@/core/ProxyAccountService.js';
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
import InstanceChart from '@/core/chart/charts/instance.js';
import PerUserFollowingChart from '@/core/chart/charts/per-user-following.js';
+import { SystemAccountService } from '@/core/SystemAccountService.js';
@Injectable()
export class AccountMoveService {
@@ -55,12 +55,12 @@ export class AccountMoveService {
private apRendererService: ApRendererService,
private apDeliverManagerService: ApDeliverManagerService,
private globalEventService: GlobalEventService,
- private proxyAccountService: ProxyAccountService,
private perUserFollowingChart: PerUserFollowingChart,
private federatedInstanceService: FederatedInstanceService,
private instanceChart: InstanceChart,
private relayService: RelayService,
private queueService: QueueService,
+ private systemAccountService: SystemAccountService,
) {
}
@@ -126,11 +126,11 @@ export class AccountMoveService {
}
// follow the new account
- const proxy = await this.proxyAccountService.fetch();
+ const proxy = await this.systemAccountService.fetch('proxy');
const followings = await this.followingsRepository.findBy({
followeeId: src.id,
followerHost: IsNull(), // follower is local
- followerId: proxy ? Not(proxy.id) : undefined,
+ followerId: Not(proxy.id),
});
const followJobs = followings.map(following => ({
from: { id: following.followerId },
@@ -250,10 +250,8 @@ export class AccountMoveService {
// Have the proxy account follow the new account in the same way as UserListService.push
if (this.userEntityService.isRemoteUser(dst)) {
- const proxy = await this.proxyAccountService.fetch();
- if (proxy) {
- this.queueService.createFollowJob([{ from: { id: proxy.id }, to: { id: dst.id } }]);
- }
+ const proxy = await this.systemAccountService.fetch('proxy');
+ this.queueService.createFollowJob([{ from: { id: proxy.id }, to: { id: dst.id } }]);
}
}
diff --git a/packages/backend/src/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts
index 734d135648..dc85a23e5b 100644
--- a/packages/backend/src/core/CoreModule.ts
+++ b/packages/backend/src/core/CoreModule.ts
@@ -24,7 +24,6 @@ import { AppLockService } from './AppLockService.js';
import { AchievementService } from './AchievementService.js';
import { AvatarDecorationService } from './AvatarDecorationService.js';
import { CaptchaService } from './CaptchaService.js';
-import { CreateSystemUserService } from './CreateSystemUserService.js';
import { CustomEmojiService } from './CustomEmojiService.js';
import { DeleteAccountService } from './DeleteAccountService.js';
import { DownloadService } from './DownloadService.js';
@@ -37,7 +36,7 @@ import { HashtagService } from './HashtagService.js';
import { HttpRequestService } from './HttpRequestService.js';
import { IdService } from './IdService.js';
import { ImageProcessingService } from './ImageProcessingService.js';
-import { InstanceActorService } from './InstanceActorService.js';
+import { SystemAccountService } from './SystemAccountService.js';
import { InternalStorageService } from './InternalStorageService.js';
import { MetaService } from './MetaService.js';
import { MfmService } from './MfmService.js';
@@ -69,7 +68,6 @@ import { UserSuspendService } from './UserSuspendService.js';
import { UserAuthService } from './UserAuthService.js';
import { VideoProcessingService } from './VideoProcessingService.js';
import { UserWebhookService } from './UserWebhookService.js';
-import { ProxyAccountService } from './ProxyAccountService.js';
import { UtilityService } from './UtilityService.js';
import { FileInfoService } from './FileInfoService.js';
import { SearchService } from './SearchService.js';
@@ -167,7 +165,6 @@ const $AppLockService: Provider = { provide: 'AppLockService', useExisting: AppL
const $AchievementService: Provider = { provide: 'AchievementService', useExisting: AchievementService };
const $AvatarDecorationService: Provider = { provide: 'AvatarDecorationService', useExisting: AvatarDecorationService };
const $CaptchaService: Provider = { provide: 'CaptchaService', useExisting: CaptchaService };
-const $CreateSystemUserService: Provider = { provide: 'CreateSystemUserService', useExisting: CreateSystemUserService };
const $CustomEmojiService: Provider = { provide: 'CustomEmojiService', useExisting: CustomEmojiService };
const $DeleteAccountService: Provider = { provide: 'DeleteAccountService', useExisting: DeleteAccountService };
const $DownloadService: Provider = { provide: 'DownloadService', useExisting: DownloadService };
@@ -180,7 +177,6 @@ const $HashtagService: Provider = { provide: 'HashtagService', useExisting: Hash
const $HttpRequestService: Provider = { provide: 'HttpRequestService', useExisting: HttpRequestService };
const $IdService: Provider = { provide: 'IdService', useExisting: IdService };
const $ImageProcessingService: Provider = { provide: 'ImageProcessingService', useExisting: ImageProcessingService };
-const $InstanceActorService: Provider = { provide: 'InstanceActorService', useExisting: InstanceActorService };
const $InternalStorageService: Provider = { provide: 'InternalStorageService', useExisting: InternalStorageService };
const $MetaService: Provider = { provide: 'MetaService', useExisting: MetaService };
const $MfmService: Provider = { provide: 'MfmService', useExisting: MfmService };
@@ -191,7 +187,7 @@ const $NotePiningService: Provider = { provide: 'NotePiningService', useExisting
const $NoteReadService: Provider = { provide: 'NoteReadService', useExisting: NoteReadService };
const $NotificationService: Provider = { provide: 'NotificationService', useExisting: NotificationService };
const $PollService: Provider = { provide: 'PollService', useExisting: PollService };
-const $ProxyAccountService: Provider = { provide: 'ProxyAccountService', useExisting: ProxyAccountService };
+const $SystemAccountService: Provider = { provide: 'SystemAccountService', useExisting: SystemAccountService };
const $PushNotificationService: Provider = { provide: 'PushNotificationService', useExisting: PushNotificationService };
const $QueryService: Provider = { provide: 'QueryService', useExisting: QueryService };
const $ReactionService: Provider = { provide: 'ReactionService', useExisting: ReactionService };
@@ -318,7 +314,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
AchievementService,
AvatarDecorationService,
CaptchaService,
- CreateSystemUserService,
CustomEmojiService,
DeleteAccountService,
DownloadService,
@@ -331,7 +326,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
HttpRequestService,
IdService,
ImageProcessingService,
- InstanceActorService,
InternalStorageService,
MetaService,
MfmService,
@@ -342,7 +336,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
NoteReadService,
NotificationService,
PollService,
- ProxyAccountService,
+ SystemAccountService,
PushNotificationService,
QueryService,
ReactionService,
@@ -465,7 +459,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$AchievementService,
$AvatarDecorationService,
$CaptchaService,
- $CreateSystemUserService,
$CustomEmojiService,
$DeleteAccountService,
$DownloadService,
@@ -478,7 +471,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$HttpRequestService,
$IdService,
$ImageProcessingService,
- $InstanceActorService,
$InternalStorageService,
$MetaService,
$MfmService,
@@ -489,7 +481,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$NoteReadService,
$NotificationService,
$PollService,
- $ProxyAccountService,
+ $SystemAccountService,
$PushNotificationService,
$QueryService,
$ReactionService,
@@ -613,7 +605,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
AchievementService,
AvatarDecorationService,
CaptchaService,
- CreateSystemUserService,
CustomEmojiService,
DeleteAccountService,
DownloadService,
@@ -626,7 +617,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
HttpRequestService,
IdService,
ImageProcessingService,
- InstanceActorService,
InternalStorageService,
MetaService,
MfmService,
@@ -637,7 +627,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
NoteReadService,
NotificationService,
PollService,
- ProxyAccountService,
+ SystemAccountService,
PushNotificationService,
QueryService,
ReactionService,
@@ -759,7 +749,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$AchievementService,
$AvatarDecorationService,
$CaptchaService,
- $CreateSystemUserService,
$CustomEmojiService,
$DeleteAccountService,
$DownloadService,
@@ -772,7 +761,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$HttpRequestService,
$IdService,
$ImageProcessingService,
- $InstanceActorService,
$InternalStorageService,
$MetaService,
$MfmService,
@@ -783,7 +771,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$NoteReadService,
$NotificationService,
$PollService,
- $ProxyAccountService,
+ $SystemAccountService,
$PushNotificationService,
$QueryService,
$ReactionService,
diff --git a/packages/backend/src/core/CreateSystemUserService.ts b/packages/backend/src/core/CreateSystemUserService.ts
deleted file mode 100644
index 7ef75edb3c..0000000000
--- a/packages/backend/src/core/CreateSystemUserService.ts
+++ /dev/null
@@ -1,86 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import { randomUUID } from 'node:crypto';
-import { Inject, Injectable } from '@nestjs/common';
-import bcrypt from 'bcryptjs';
-import { IsNull, DataSource } from 'typeorm';
-import { genRsaKeyPair } from '@/misc/gen-key-pair.js';
-import { MiUser } from '@/models/User.js';
-import { MiUserProfile } from '@/models/UserProfile.js';
-import { IdService } from '@/core/IdService.js';
-import { MiUserKeypair } from '@/models/UserKeypair.js';
-import { MiUsedUsername } from '@/models/UsedUsername.js';
-import { DI } from '@/di-symbols.js';
-import { generateNativeUserToken } from '@/misc/token.js';
-import { bindThis } from '@/decorators.js';
-
-@Injectable()
-export class CreateSystemUserService {
- constructor(
- @Inject(DI.db)
- private db: DataSource,
-
- private idService: IdService,
- ) {
- }
-
- @bindThis
- public async createSystemUser(username: string): Promise {
- const password = randomUUID();
-
- // Generate hash of password
- const salt = await bcrypt.genSalt(8);
- const hash = await bcrypt.hash(password, salt);
-
- // Generate secret
- const secret = generateNativeUserToken();
-
- const keyPair = await genRsaKeyPair();
-
- let account!: MiUser;
-
- // Start transaction
- await this.db.transaction(async transactionalEntityManager => {
- const exist = await transactionalEntityManager.findOneBy(MiUser, {
- usernameLower: username.toLowerCase(),
- host: IsNull(),
- });
-
- if (exist) throw new Error('the user is already exists');
-
- account = await transactionalEntityManager.insert(MiUser, {
- id: this.idService.gen(),
- username: username,
- usernameLower: username.toLowerCase(),
- host: null,
- token: secret,
- isRoot: false,
- isLocked: true,
- isExplorable: false,
- isBot: true,
- }).then(x => transactionalEntityManager.findOneByOrFail(MiUser, x.identifiers[0]));
-
- await transactionalEntityManager.insert(MiUserKeypair, {
- publicKey: keyPair.publicKey,
- privateKey: keyPair.privateKey,
- userId: account.id,
- });
-
- await transactionalEntityManager.insert(MiUserProfile, {
- userId: account.id,
- autoAcceptFollowed: false,
- password: hash,
- });
-
- await transactionalEntityManager.insert(MiUsedUsername, {
- createdAt: new Date(),
- username: username.toLowerCase(),
- });
- });
-
- return account;
- }
-}
diff --git a/packages/backend/src/core/DeleteAccountService.ts b/packages/backend/src/core/DeleteAccountService.ts
index 7f1b8f3efb..483f14ce7f 100644
--- a/packages/backend/src/core/DeleteAccountService.ts
+++ b/packages/backend/src/core/DeleteAccountService.ts
@@ -5,7 +5,7 @@
import { Inject, Injectable } from '@nestjs/common';
import { Not, IsNull } from 'typeorm';
-import type { FollowingsRepository, MiUser, UsersRepository } from '@/models/_.js';
+import type { FollowingsRepository, MiMeta, MiUser, UsersRepository } from '@/models/_.js';
import { QueueService } from '@/core/QueueService.js';
import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js';
@@ -13,10 +13,14 @@ import { GlobalEventService } from '@/core/GlobalEventService.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { ApRendererService } from '@/core/activitypub/ApRendererService.js';
import { ModerationLogService } from '@/core/ModerationLogService.js';
+import { SystemAccountService } from '@/core/SystemAccountService.js';
@Injectable()
export class DeleteAccountService {
constructor(
+ @Inject(DI.meta)
+ private meta: MiMeta,
+
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
@@ -28,6 +32,7 @@ export class DeleteAccountService {
private queueService: QueueService,
private globalEventService: GlobalEventService,
private moderationLogService: ModerationLogService,
+ private systemAccountService: SystemAccountService,
) {
}
@@ -36,8 +41,13 @@ export class DeleteAccountService {
id: string;
host: string | null;
}, moderator?: MiUser): Promise {
+ if (this.meta.rootUserId === user.id) throw new Error('cannot delete a root account');
+
const _user = await this.usersRepository.findOneByOrFail({ id: user.id });
- if (_user.isRoot) throw new Error('cannot delete a root account');
+
+ if (user.host === null && _user.username.includes('.')) {
+ throw new Error('cannot delete a system account');
+ }
if (moderator != null) {
this.moderationLogService.log(moderator, 'deleteAccount', {
diff --git a/packages/backend/src/core/InstanceActorService.ts b/packages/backend/src/core/InstanceActorService.ts
deleted file mode 100644
index 22c47297a3..0000000000
--- a/packages/backend/src/core/InstanceActorService.ts
+++ /dev/null
@@ -1,57 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import { Inject, Injectable } from '@nestjs/common';
-import { IsNull, Not } from 'typeorm';
-import type { MiLocalUser } from '@/models/User.js';
-import type { UsersRepository } from '@/models/_.js';
-import { MemorySingleCache } from '@/misc/cache.js';
-import { DI } from '@/di-symbols.js';
-import { CreateSystemUserService } from '@/core/CreateSystemUserService.js';
-import { bindThis } from '@/decorators.js';
-
-const ACTOR_USERNAME = 'instance.actor' as const;
-
-@Injectable()
-export class InstanceActorService {
- private cache: MemorySingleCache;
-
- constructor(
- @Inject(DI.usersRepository)
- private usersRepository: UsersRepository,
-
- private createSystemUserService: CreateSystemUserService,
- ) {
- this.cache = new MemorySingleCache(Infinity);
- }
-
- @bindThis
- public async realLocalUsersPresent(): Promise {
- return await this.usersRepository.existsBy({
- host: IsNull(),
- username: Not(ACTOR_USERNAME),
- });
- }
-
- @bindThis
- public async getInstanceActor(): Promise {
- const cached = this.cache.get();
- if (cached) return cached;
-
- const user = await this.usersRepository.findOneBy({
- host: IsNull(),
- username: ACTOR_USERNAME,
- }) as MiLocalUser | undefined;
-
- if (user) {
- this.cache.set(user);
- return user;
- } else {
- const created = await this.createSystemUserService.createSystemUser(ACTOR_USERNAME) as MiLocalUser;
- this.cache.set(created);
- return created;
- }
- }
-}
diff --git a/packages/backend/src/core/MetaService.ts b/packages/backend/src/core/MetaService.ts
index 3d88d0aefe..40e7439f5f 100644
--- a/packages/backend/src/core/MetaService.ts
+++ b/packages/backend/src/core/MetaService.ts
@@ -53,7 +53,7 @@ export class MetaService implements OnApplicationShutdown {
case 'metaUpdated': {
this.cache = { // TODO: このあたりのデシリアライズ処理は各modelファイル内に関数としてexportしたい
...(body.after),
- proxyAccount: null, // joinなカラムは通常取ってこないので
+ rootUser: null, // joinなカラムは通常取ってこないので
};
break;
}
@@ -113,17 +113,20 @@ export class MetaService implements OnApplicationShutdown {
if (before) {
await transactionalEntityManager.update(MiMeta, before.id, data);
-
- const metas = await transactionalEntityManager.find(MiMeta, {
- order: {
- id: 'DESC',
- },
- });
-
- return metas[0];
} else {
- return await transactionalEntityManager.save(MiMeta, data);
+ await transactionalEntityManager.save(MiMeta, {
+ ...data,
+ id: 'x',
+ });
}
+
+ const afters = await transactionalEntityManager.find(MiMeta, {
+ order: {
+ id: 'DESC',
+ },
+ });
+
+ return afters[0];
});
if (data.hiddenTags) {
diff --git a/packages/backend/src/core/ProxyAccountService.ts b/packages/backend/src/core/ProxyAccountService.ts
deleted file mode 100644
index c3ff2a68d3..0000000000
--- a/packages/backend/src/core/ProxyAccountService.ts
+++ /dev/null
@@ -1,28 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import { Inject, Injectable } from '@nestjs/common';
-import type { MiMeta, UsersRepository } from '@/models/_.js';
-import type { MiLocalUser } from '@/models/User.js';
-import { DI } from '@/di-symbols.js';
-import { bindThis } from '@/decorators.js';
-
-@Injectable()
-export class ProxyAccountService {
- constructor(
- @Inject(DI.meta)
- private meta: MiMeta,
-
- @Inject(DI.usersRepository)
- private usersRepository: UsersRepository,
- ) {
- }
-
- @bindThis
- public async fetch(): Promise {
- if (this.meta.proxyAccountId == null) return null;
- return await this.usersRepository.findOneByOrFail({ id: this.meta.proxyAccountId }) as MiLocalUser;
- }
-}
diff --git a/packages/backend/src/core/RelayService.ts b/packages/backend/src/core/RelayService.ts
index db32114346..9120de1f9f 100644
--- a/packages/backend/src/core/RelayService.ts
+++ b/packages/backend/src/core/RelayService.ts
@@ -4,53 +4,34 @@
*/
import { Inject, Injectable } from '@nestjs/common';
-import { IsNull } from 'typeorm';
-import type { MiLocalUser, MiUser } from '@/models/User.js';
-import type { RelaysRepository, UsersRepository } from '@/models/_.js';
+import type { MiUser } from '@/models/User.js';
+import type { RelaysRepository } from '@/models/_.js';
import { IdService } from '@/core/IdService.js';
import { MemorySingleCache } from '@/misc/cache.js';
import type { MiRelay } from '@/models/Relay.js';
import { QueueService } from '@/core/QueueService.js';
-import { CreateSystemUserService } from '@/core/CreateSystemUserService.js';
import { ApRendererService } from '@/core/activitypub/ApRendererService.js';
import { DI } from '@/di-symbols.js';
import { deepClone } from '@/misc/clone.js';
import { bindThis } from '@/decorators.js';
-
-const ACTOR_USERNAME = 'relay.actor' as const;
+import { SystemAccountService } from '@/core/SystemAccountService.js';
@Injectable()
export class RelayService {
private relaysCache: MemorySingleCache;
constructor(
- @Inject(DI.usersRepository)
- private usersRepository: UsersRepository,
-
@Inject(DI.relaysRepository)
private relaysRepository: RelaysRepository,
private idService: IdService,
private queueService: QueueService,
- private createSystemUserService: CreateSystemUserService,
+ private systemAccountService: SystemAccountService,
private apRendererService: ApRendererService,
) {
this.relaysCache = new MemorySingleCache(1000 * 60 * 10); // 10m
}
- @bindThis
- private async getRelayActor(): Promise {
- const user = await this.usersRepository.findOneBy({
- host: IsNull(),
- username: ACTOR_USERNAME,
- });
-
- if (user) return user as MiLocalUser;
-
- const created = await this.createSystemUserService.createSystemUser(ACTOR_USERNAME);
- return created as MiLocalUser;
- }
-
@bindThis
public async addRelay(inbox: string): Promise {
const relay = await this.relaysRepository.insertOne({
@@ -59,8 +40,8 @@ export class RelayService {
status: 'requesting',
});
- const relayActor = await this.getRelayActor();
- const follow = await this.apRendererService.renderFollowRelay(relay, relayActor);
+ const relayActor = await this.systemAccountService.fetch('relay');
+ const follow = this.apRendererService.renderFollowRelay(relay, relayActor);
const activity = this.apRendererService.addContext(follow);
this.queueService.deliver(relayActor, activity, relay.inbox, false);
@@ -77,7 +58,7 @@ export class RelayService {
throw new Error('relay not found');
}
- const relayActor = await this.getRelayActor();
+ const relayActor = await this.systemAccountService.fetch('relay');
const follow = this.apRendererService.renderFollowRelay(relay, relayActor);
const undo = this.apRendererService.renderUndo(follow, relayActor);
const activity = this.apRendererService.addContext(undo);
diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts
index 5af6b05942..01f3e0c116 100644
--- a/packages/backend/src/core/RoleService.ts
+++ b/packages/backend/src/core/RoleService.ts
@@ -101,7 +101,6 @@ export const DEFAULT_POLICIES: RolePolicies = {
@Injectable()
export class RoleService implements OnApplicationShutdown, OnModuleInit {
- private rootUserIdCache: MemorySingleCache;
private rolesCache: MemorySingleCache;
private roleAssignmentByUserIdCache: MemoryKVCache;
private notificationService: NotificationService;
@@ -137,7 +136,6 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
private moderationLogService: ModerationLogService,
private fanoutTimelineService: FanoutTimelineService,
) {
- this.rootUserIdCache = new MemorySingleCache(1000 * 60 * 60 * 24 * 7); // 1week. rootユーザのIDは不変なので長めに
this.rolesCache = new MemorySingleCache(1000 * 60 * 60); // 1h
this.roleAssignmentByUserIdCache = new MemoryKVCache(1000 * 60 * 5); // 5m
@@ -406,15 +404,15 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
}
@bindThis
- public async isModerator(user: { id: MiUser['id']; isRoot: MiUser['isRoot'] } | null): Promise {
+ public async isModerator(user: { id: MiUser['id'] } | null): Promise {
if (user == null) return false;
- return user.isRoot || (await this.getUserRoles(user.id)).some(r => r.isModerator || r.isAdministrator);
+ return (this.meta.rootUserId === user.id) || (await this.getUserRoles(user.id)).some(r => r.isModerator || r.isAdministrator);
}
@bindThis
- public async isAdministrator(user: { id: MiUser['id']; isRoot: MiUser['isRoot'] } | null): Promise {
+ public async isAdministrator(user: { id: MiUser['id'] } | null): Promise {
if (user == null) return false;
- return user.isRoot || (await this.getUserRoles(user.id)).some(r => r.isAdministrator);
+ return (this.meta.rootUserId === user.id) || (await this.getUserRoles(user.id)).some(r => r.isAdministrator);
}
@bindThis
@@ -463,16 +461,8 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
.map(a => a.userId),
);
- if (includeRoot) {
- const rootUserId = await this.rootUserIdCache.fetch(async () => {
- const it = await this.usersRepository.createQueryBuilder('users')
- .select('id')
- .where({ isRoot: true })
- .getRawOne<{ id: string }>();
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
- return it!.id;
- });
- resultSet.add(rootUserId);
+ if (includeRoot && this.meta.rootUserId) {
+ resultSet.add(this.meta.rootUserId);
}
return [...resultSet].sort((x, y) => x.localeCompare(y));
diff --git a/packages/backend/src/core/SignupService.ts b/packages/backend/src/core/SignupService.ts
index d2f09ea15d..5462cb0b13 100644
--- a/packages/backend/src/core/SignupService.ts
+++ b/packages/backend/src/core/SignupService.ts
@@ -16,11 +16,12 @@ import { MiUserKeypair } from '@/models/UserKeypair.js';
import { MiUsedUsername } from '@/models/UsedUsername.js';
import { generateNativeUserToken } from '@/misc/token.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
-import { InstanceActorService } from '@/core/InstanceActorService.js';
import { bindThis } from '@/decorators.js';
import UsersChart from '@/core/chart/charts/users.js';
import { UtilityService } from '@/core/UtilityService.js';
import { UserService } from '@/core/UserService.js';
+import { SystemAccountService } from '@/core/SystemAccountService.js';
+import { MetaService } from '@/core/MetaService.js';
@Injectable()
export class SignupService {
@@ -41,7 +42,8 @@ export class SignupService {
private userService: UserService,
private userEntityService: UserEntityService,
private idService: IdService,
- private instanceActorService: InstanceActorService,
+ private systemAccountService: SystemAccountService,
+ private metaService: MetaService,
private usersChart: UsersChart,
) {
}
@@ -86,9 +88,7 @@ export class SignupService {
throw new Error('USED_USERNAME');
}
- const isTheFirstUser = !await this.instanceActorService.realLocalUsersPresent();
-
- if (!opts.ignorePreservedUsernames && !isTheFirstUser) {
+ if (!opts.ignorePreservedUsernames && this.meta.rootUserId != null) {
const isPreserved = this.meta.preservedUsernames.map(x => x.toLowerCase()).includes(username.toLowerCase());
if (isPreserved) {
throw new Error('USED_USERNAME');
@@ -129,7 +129,6 @@ export class SignupService {
usernameLower: username.toLowerCase(),
host: this.utilityService.toPunyNullable(host),
token: secret,
- isRoot: isTheFirstUser,
}));
await transactionalEntityManager.save(new MiUserKeypair({
@@ -153,6 +152,10 @@ export class SignupService {
this.usersChart.update(account, true);
this.userService.notifySystemWebhook(account, 'userCreated');
+ if (this.meta.rootUserId == null) {
+ await this.metaService.update({ rootUserId: account.id });
+ }
+
return { account, secret };
}
}
diff --git a/packages/backend/src/core/SystemAccountService.ts b/packages/backend/src/core/SystemAccountService.ts
new file mode 100644
index 0000000000..1e050c3054
--- /dev/null
+++ b/packages/backend/src/core/SystemAccountService.ts
@@ -0,0 +1,172 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { randomUUID } from 'node:crypto';
+import { Inject, Injectable } from '@nestjs/common';
+import { DataSource, IsNull } from 'typeorm';
+import bcrypt from 'bcryptjs';
+import { MiLocalUser, MiUser } from '@/models/User.js';
+import { MiSystemAccount, MiUsedUsername, MiUserKeypair, MiUserProfile, type UsersRepository, type SystemAccountsRepository } from '@/models/_.js';
+import type { MiMeta, UserProfilesRepository } from '@/models/_.js';
+import { MemoryKVCache } from '@/misc/cache.js';
+import { DI } from '@/di-symbols.js';
+import { bindThis } from '@/decorators.js';
+import { generateNativeUserToken } from '@/misc/token.js';
+import { IdService } from '@/core/IdService.js';
+import { genRsaKeyPair } from '@/misc/gen-key-pair.js';
+
+export const SYSTEM_ACCOUNT_TYPES = ['actor', 'relay', 'proxy'] as const;
+
+@Injectable()
+export class SystemAccountService {
+ private cache: MemoryKVCache;
+
+ constructor(
+ @Inject(DI.db)
+ private db: DataSource,
+
+ @Inject(DI.meta)
+ private meta: MiMeta,
+
+ @Inject(DI.systemAccountsRepository)
+ private systemAccountsRepository: SystemAccountsRepository,
+
+ @Inject(DI.usersRepository)
+ private usersRepository: UsersRepository,
+
+ @Inject(DI.userProfilesRepository)
+ private userProfilesRepository: UserProfilesRepository,
+
+ private idService: IdService,
+ ) {
+ this.cache = new MemoryKVCache(1000 * 60 * 10); // 10m
+ }
+
+ @bindThis
+ public async list(): Promise {
+ const accounts = await this.systemAccountsRepository.findBy({});
+
+ return accounts;
+ }
+
+ @bindThis
+ public async fetch(type: typeof SYSTEM_ACCOUNT_TYPES[number]): Promise {
+ const cached = this.cache.get(type);
+ if (cached) return cached;
+
+ const systemAccount = await this.systemAccountsRepository.findOne({
+ where: { type: type },
+ relations: ['user'],
+ });
+
+ if (systemAccount) {
+ this.cache.set(type, systemAccount.user as MiLocalUser);
+ return systemAccount.user as MiLocalUser;
+ } else {
+ const created = await this.createCorrespondingUser(type, {
+ username: `system.${type}`, // NOTE: (できれば避けたいが) . が含まれるかどうかでシステムアカウントかどうかを判定している処理もあるので変えないように
+ name: this.meta.name,
+ });
+ this.cache.set(type, created);
+ return created;
+ }
+ }
+
+ @bindThis
+ private async createCorrespondingUser(type: typeof SYSTEM_ACCOUNT_TYPES[number], extra: {
+ username: MiUser['username'];
+ name?: MiUser['name'];
+ }): Promise {
+ const password = randomUUID();
+
+ // Generate hash of password
+ const salt = await bcrypt.genSalt(8);
+ const hash = await bcrypt.hash(password, salt);
+
+ // Generate secret
+ const secret = generateNativeUserToken();
+
+ const keyPair = await genRsaKeyPair();
+
+ let account!: MiUser;
+
+ // Start transaction
+ await this.db.transaction(async transactionalEntityManager => {
+ const exist = await transactionalEntityManager.findOneBy(MiUser, {
+ usernameLower: extra.username.toLowerCase(),
+ host: IsNull(),
+ });
+
+ if (exist) {
+ account = exist;
+ return;
+ }
+
+ account = await transactionalEntityManager.insert(MiUser, {
+ id: this.idService.gen(),
+ username: extra.username,
+ usernameLower: extra.username.toLowerCase(),
+ host: null,
+ token: secret,
+ isLocked: true,
+ isExplorable: false,
+ isBot: true,
+ name: extra.name,
+ }).then(x => transactionalEntityManager.findOneByOrFail(MiUser, x.identifiers[0]));
+
+ await transactionalEntityManager.insert(MiUserKeypair, {
+ publicKey: keyPair.publicKey,
+ privateKey: keyPair.privateKey,
+ userId: account.id,
+ });
+
+ await transactionalEntityManager.insert(MiUserProfile, {
+ userId: account.id,
+ autoAcceptFollowed: false,
+ password: hash,
+ });
+
+ await transactionalEntityManager.insert(MiUsedUsername, {
+ createdAt: new Date(),
+ username: extra.username.toLowerCase(),
+ });
+
+ await transactionalEntityManager.insert(MiSystemAccount, {
+ id: this.idService.gen(),
+ userId: account.id,
+ type: type,
+ });
+ });
+
+ return account as MiLocalUser;
+ }
+
+ @bindThis
+ public async updateCorrespondingUserProfile(type: typeof SYSTEM_ACCOUNT_TYPES[number], extra: {
+ name?: string;
+ description?: MiUserProfile['description'];
+ }): Promise {
+ const user = await this.fetch(type);
+
+ const updates = {} as Partial;
+ if (extra.name !== undefined) updates.name = extra.name;
+
+ if (Object.keys(updates).length > 0) {
+ await this.usersRepository.update(user.id, updates);
+ }
+
+ const profileUpdates = {} as Partial;
+ if (extra.description !== undefined) profileUpdates.description = extra.description;
+
+ if (Object.keys(profileUpdates).length > 0) {
+ await this.userProfilesRepository.update(user.id, profileUpdates);
+ }
+
+ const updated = await this.usersRepository.findOneByOrFail({ id: user.id }) as MiLocalUser;
+ this.cache.set(type, updated);
+
+ return updated;
+ }
+}
diff --git a/packages/backend/src/core/UserListService.ts b/packages/backend/src/core/UserListService.ts
index 6333356fe9..f0a8768c8f 100644
--- a/packages/backend/src/core/UserListService.ts
+++ b/packages/backend/src/core/UserListService.ts
@@ -15,11 +15,11 @@ import type { GlobalEvents } from '@/core/GlobalEventService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import { DI } from '@/di-symbols.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
-import { ProxyAccountService } from '@/core/ProxyAccountService.js';
import { bindThis } from '@/decorators.js';
import { QueueService } from '@/core/QueueService.js';
import { RedisKVCache } from '@/misc/cache.js';
import { RoleService } from '@/core/RoleService.js';
+import { SystemAccountService } from '@/core/SystemAccountService.js';
@Injectable()
export class UserListService implements OnApplicationShutdown, OnModuleInit {
@@ -43,8 +43,8 @@ export class UserListService implements OnApplicationShutdown, OnModuleInit {
private userEntityService: UserEntityService,
private idService: IdService,
private globalEventService: GlobalEventService,
- private proxyAccountService: ProxyAccountService,
private queueService: QueueService,
+ private systemAccountService: SystemAccountService,
) {
this.membersCache = new RedisKVCache>(this.redisClient, 'userListMembers', {
lifetime: 1000 * 60 * 30, // 30m
@@ -111,10 +111,8 @@ export class UserListService implements OnApplicationShutdown, OnModuleInit {
// このインスタンス内にこのリモートユーザーをフォローしているユーザーがいなくても投稿を受け取るためにダミーのユーザーがフォローしたということにする
if (this.userEntityService.isRemoteUser(target)) {
- const proxy = await this.proxyAccountService.fetch();
- if (proxy) {
- this.queueService.createFollowJob([{ from: { id: proxy.id }, to: { id: target.id } }]);
- }
+ const proxy = await this.systemAccountService.fetch('proxy');
+ this.queueService.createFollowJob([{ from: { id: proxy.id }, to: { id: target.id } }]);
}
}
diff --git a/packages/backend/src/core/WebhookTestService.ts b/packages/backend/src/core/WebhookTestService.ts
index 555a39f71c..f83dec67bf 100644
--- a/packages/backend/src/core/WebhookTestService.ts
+++ b/packages/backend/src/core/WebhookTestService.ts
@@ -73,7 +73,6 @@ function generateDummyUser(override?: Partial): MiUser {
isLocked: false,
isBot: false,
isCat: true,
- isRoot: false,
isExplorable: true,
isHibernated: false,
isDeleted: false,
diff --git a/packages/backend/src/core/activitypub/ApRendererService.ts b/packages/backend/src/core/activitypub/ApRendererService.ts
index 8688015aff..83a095cdea 100644
--- a/packages/backend/src/core/activitypub/ApRendererService.ts
+++ b/packages/backend/src/core/activitypub/ApRendererService.ts
@@ -23,7 +23,7 @@ import { MfmService } from '@/core/MfmService.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js';
import type { MiUserKeypair } from '@/models/UserKeypair.js';
-import type { UsersRepository, UserProfilesRepository, NotesRepository, DriveFilesRepository, PollsRepository } from '@/models/_.js';
+import type { UsersRepository, UserProfilesRepository, NotesRepository, DriveFilesRepository, PollsRepository, MiMeta } from '@/models/_.js';
import { bindThis } from '@/decorators.js';
import { CustomEmojiService } from '@/core/CustomEmojiService.js';
import { IdService } from '@/core/IdService.js';
@@ -39,6 +39,9 @@ export class ApRendererService {
@Inject(DI.config)
private config: Config,
+ @Inject(DI.meta)
+ private meta: MiMeta,
+
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
@@ -186,7 +189,7 @@ export class ApRendererService {
url: emoji.publicUrl || emoji.originalUrl,
},
_misskey_license: {
- freeText: emoji.license
+ freeText: emoji.license,
},
};
}
@@ -255,6 +258,38 @@ export class ApRendererService {
};
}
+ @bindThis
+ public renderIdenticon(user: MiLocalUser): IApImage {
+ return {
+ type: 'Image',
+ url: this.userEntityService.getIdenticonUrl(user),
+ sensitive: false,
+ name: null,
+ };
+ }
+
+ @bindThis
+ public renderSystemAvatar(user: MiLocalUser): IApImage {
+ if (this.meta.iconUrl == null) return this.renderIdenticon(user);
+ return {
+ type: 'Image',
+ url: this.meta.iconUrl,
+ sensitive: false,
+ name: null,
+ };
+ }
+
+ @bindThis
+ public renderSystemBanner(): IApImage | null {
+ if (this.meta.bannerUrl == null) return null;
+ return {
+ type: 'Image',
+ url: this.meta.bannerUrl,
+ sensitive: false,
+ name: null,
+ };
+ }
+
@bindThis
public renderKey(user: MiLocalUser, key: MiUserKeypair, postfix?: string): IKey {
return {
@@ -503,8 +538,8 @@ export class ApRendererService {
_misskey_requireSigninToViewContents: user.requireSigninToViewContents,
_misskey_makeNotesFollowersOnlyBefore: user.makeNotesFollowersOnlyBefore,
_misskey_makeNotesHiddenBefore: user.makeNotesHiddenBefore,
- icon: avatar ? this.renderImage(avatar) : null,
- image: banner ? this.renderImage(banner) : null,
+ icon: avatar ? this.renderImage(avatar) : isSystem ? this.renderSystemAvatar(user) : this.renderIdenticon(user),
+ image: banner ? this.renderImage(banner) : isSystem ? this.renderSystemBanner() : null,
tag,
manuallyApprovesFollowers: user.isLocked,
discoverable: user.isExplorable,
diff --git a/packages/backend/src/core/activitypub/ApResolverService.ts b/packages/backend/src/core/activitypub/ApResolverService.ts
index fb963294cb..2534899ad1 100644
--- a/packages/backend/src/core/activitypub/ApResolverService.ts
+++ b/packages/backend/src/core/activitypub/ApResolverService.ts
@@ -6,7 +6,6 @@
import { Inject, Injectable } from '@nestjs/common';
import { IsNull, Not } from 'typeorm';
import type { MiLocalUser, MiRemoteUser } from '@/models/User.js';
-import { InstanceActorService } from '@/core/InstanceActorService.js';
import type { NotesRepository, PollsRepository, NoteReactionsRepository, UsersRepository, FollowRequestsRepository, MiMeta } from '@/models/_.js';
import type { Config } from '@/config.js';
import { HttpRequestService } from '@/core/HttpRequestService.js';
@@ -15,13 +14,14 @@ import { UtilityService } from '@/core/UtilityService.js';
import { bindThis } from '@/decorators.js';
import { LoggerService } from '@/core/LoggerService.js';
import type Logger from '@/logger.js';
+import { SystemAccountService } from '@/core/SystemAccountService.js';
+import { IdentifiableError } from '@/misc/identifiable-error.js';
import { isCollectionOrOrderedCollection } from './type.js';
import { ApDbResolverService } from './ApDbResolverService.js';
import { ApRendererService } from './ApRendererService.js';
import { ApRequestService } from './ApRequestService.js';
-import type { IObject, ICollection, IOrderedCollection } from './type.js';
-import { IdentifiableError } from '@/misc/identifiable-error.js';
import { FetchAllowSoftFailMask } from './misc/check-against-url.js';
+import type { IObject, ICollection, IOrderedCollection } from './type.js';
export class Resolver {
private history: Set;
@@ -37,7 +37,7 @@ export class Resolver {
private noteReactionsRepository: NoteReactionsRepository,
private followRequestsRepository: FollowRequestsRepository,
private utilityService: UtilityService,
- private instanceActorService: InstanceActorService,
+ private systemAccountService: SystemAccountService,
private apRequestService: ApRequestService,
private httpRequestService: HttpRequestService,
private apRendererService: ApRendererService,
@@ -105,7 +105,7 @@ export class Resolver {
}
if (this.config.signToActivityPubGet && !this.user) {
- this.user = await this.instanceActorService.getInstanceActor();
+ this.user = await this.systemAccountService.fetch('actor');
}
const object = (this.user
@@ -119,7 +119,7 @@ export class Resolver {
) {
throw new IdentifiableError('72180409-793c-4973-868e-5a118eb5519b', 'invalid response');
}
-
+
return object;
}
@@ -202,7 +202,7 @@ export class ApResolverService {
private followRequestsRepository: FollowRequestsRepository,
private utilityService: UtilityService,
- private instanceActorService: InstanceActorService,
+ private systemAccountService: SystemAccountService,
private apRequestService: ApRequestService,
private httpRequestService: HttpRequestService,
private apRendererService: ApRendererService,
@@ -222,7 +222,7 @@ export class ApResolverService {
this.noteReactionsRepository,
this.followRequestsRepository,
this.utilityService,
- this.instanceActorService,
+ this.systemAccountService,
this.apRequestService,
this.httpRequestService,
this.apRendererService,
diff --git a/packages/backend/src/core/entities/MetaEntityService.ts b/packages/backend/src/core/entities/MetaEntityService.ts
index 7ad6071ceb..08717bd066 100644
--- a/packages/backend/src/core/entities/MetaEntityService.ts
+++ b/packages/backend/src/core/entities/MetaEntityService.ts
@@ -11,8 +11,7 @@ import type { MiMeta } from '@/models/Meta.js';
import type { AdsRepository } from '@/models/_.js';
import { MAX_NOTE_TEXT_LENGTH } from '@/const.js';
import { bindThis } from '@/decorators.js';
-import { UserEntityService } from '@/core/entities/UserEntityService.js';
-import { InstanceActorService } from '@/core/InstanceActorService.js';
+import { SystemAccountService } from '@/core/SystemAccountService.js';
import type { Config } from '@/config.js';
import { DI } from '@/di-symbols.js';
import { DEFAULT_POLICIES } from '@/core/RoleService.js';
@@ -29,8 +28,7 @@ export class MetaEntityService {
@Inject(DI.adsRepository)
private adsRepository: AdsRepository,
- private userEntityService: UserEntityService,
- private instanceActorService: InstanceActorService,
+ private systemAccountService: SystemAccountService,
) { }
@bindThis
@@ -149,14 +147,14 @@ export class MetaEntityService {
const packed = await this.pack(instance);
- const proxyAccount = instance.proxyAccountId ? await this.userEntityService.pack(instance.proxyAccountId).catch(() => null) : null;
+ const proxyAccount = await this.systemAccountService.fetch('proxy');
const packDetailed: Packed<'MetaDetailed'> = {
...packed,
cacheRemoteFiles: instance.cacheRemoteFiles,
cacheRemoteSensitiveFiles: instance.cacheRemoteSensitiveFiles,
- requireSetup: !await this.instanceActorService.realLocalUsersPresent(),
- proxyAccountName: proxyAccount ? proxyAccount.username : null,
+ requireSetup: this.meta.rootUserId == null,
+ proxyAccountName: proxyAccount.username,
features: {
localTimeline: instance.policies.ltlAvailable,
globalTimeline: instance.policies.gtlAvailable,
diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts
index fbd3892dd4..69f698d9cb 100644
--- a/packages/backend/src/core/entities/UserEntityService.ts
+++ b/packages/backend/src/core/entities/UserEntityService.ts
@@ -28,6 +28,7 @@ import type {
FollowingsRepository,
FollowRequestsRepository,
MiFollowing,
+ MiMeta,
MiUserNotePining,
MiUserProfile,
MutingsRepository,
@@ -100,6 +101,9 @@ export class UserEntityService implements OnModuleInit {
@Inject(DI.config)
private config: Config,
+ @Inject(DI.meta)
+ private meta: MiMeta,
+
@Inject(DI.redis)
private redisClient: Redis.Redis,
@@ -381,7 +385,11 @@ export class UserEntityService implements OnModuleInit {
@bindThis
public getIdenticonUrl(user: MiUser): string {
- return `${this.config.url}/identicon/${user.username.toLowerCase()}@${user.host ?? this.config.host}`;
+ if ((user.host == null || user.host === this.config.host) && user.username.includes('.') && this.meta.iconUrl) { // ローカルのシステムアカウントの場合
+ return this.meta.iconUrl;
+ } else {
+ return `${this.config.url}/identicon/${user.username.toLowerCase()}@${user.host ?? this.config.host}`;
+ }
}
@bindThis
diff --git a/packages/backend/src/di-symbols.ts b/packages/backend/src/di-symbols.ts
index e599fc7b37..a306aac1a1 100644
--- a/packages/backend/src/di-symbols.ts
+++ b/packages/backend/src/di-symbols.ts
@@ -74,6 +74,7 @@ export const DI = {
registryItemsRepository: Symbol('registryItemsRepository'),
webhooksRepository: Symbol('webhooksRepository'),
systemWebhooksRepository: Symbol('systemWebhooksRepository'),
+ systemAccountsRepository: Symbol('systemAccountsRepository'),
adsRepository: Symbol('adsRepository'),
passwordResetRequestsRepository: Symbol('passwordResetRequestsRepository'),
retentionAggregationsRepository: Symbol('retentionAggregationsRepository'),
diff --git a/packages/backend/src/models/Meta.ts b/packages/backend/src/models/Meta.ts
index 9df2f74984..1fbf5371bc 100644
--- a/packages/backend/src/models/Meta.ts
+++ b/packages/backend/src/models/Meta.ts
@@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
-import { Entity, Column, PrimaryColumn, ManyToOne, JoinColumn } from 'typeorm';
+import { Entity, Column, PrimaryColumn, ManyToOne } from 'typeorm';
import { id } from './util/id.js';
import { MiUser } from './User.js';
@@ -15,6 +15,18 @@ export class MiMeta {
})
public id: string;
+ @Column({
+ ...id(),
+ nullable: true,
+ })
+ public rootUserId: MiUser['id'] | null;
+
+ @ManyToOne(type => MiUser, {
+ onDelete: 'SET NULL',
+ nullable: true,
+ })
+ public rootUser: MiUser | null;
+
@Column('varchar', {
length: 1024, nullable: true,
})
@@ -172,18 +184,6 @@ export class MiMeta {
})
public cacheRemoteSensitiveFiles: boolean;
- @Column({
- ...id(),
- nullable: true,
- })
- public proxyAccountId: MiUser['id'] | null;
-
- @ManyToOne(type => MiUser, {
- onDelete: 'SET NULL',
- })
- @JoinColumn()
- public proxyAccount: MiUser | null;
-
@Column('boolean', {
default: false,
})
diff --git a/packages/backend/src/models/RepositoryModule.ts b/packages/backend/src/models/RepositoryModule.ts
index ea0f88baba..04a9df6cfb 100644
--- a/packages/backend/src/models/RepositoryModule.ts
+++ b/packages/backend/src/models/RepositoryModule.ts
@@ -3,7 +3,6 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
-import type { Provider } from '@nestjs/common';
import { Module } from '@nestjs/common';
import { DI } from '@/di-symbols.js';
import {
@@ -63,6 +62,7 @@ import {
MiRoleAssignment,
MiSignin,
MiSwSubscription,
+ MiSystemAccount,
MiSystemWebhook,
MiUsedUsername,
MiUser,
@@ -77,8 +77,9 @@ import {
MiUserProfile,
MiUserPublickey,
MiUserSecurityKey,
- MiWebhook
+ MiWebhook,
} from './_.js';
+import type { Provider } from '@nestjs/common';
import type { DataSource } from 'typeorm';
const $usersRepository: Provider = {
@@ -285,6 +286,12 @@ const $swSubscriptionsRepository: Provider = {
inject: [DI.db],
};
+const $systemAccountsRepository: Provider = {
+ provide: DI.systemAccountsRepository,
+ useFactory: (db: DataSource) => db.getRepository(MiSystemAccount),
+ inject: [DI.db],
+};
+
const $hashtagsRepository: Provider = {
provide: DI.hashtagsRepository,
useFactory: (db: DataSource) => db.getRepository(MiHashtag).extend(miRepository as MiRepository),
@@ -532,6 +539,7 @@ const $reversiGamesRepository: Provider = {
$renoteMutingsRepository,
$blockingsRepository,
$swSubscriptionsRepository,
+ $systemAccountsRepository,
$hashtagsRepository,
$abuseUserReportsRepository,
$abuseReportNotificationRecipientRepository,
@@ -603,6 +611,7 @@ const $reversiGamesRepository: Provider = {
$renoteMutingsRepository,
$blockingsRepository,
$swSubscriptionsRepository,
+ $systemAccountsRepository,
$hashtagsRepository,
$abuseUserReportsRepository,
$abuseReportNotificationRecipientRepository,
diff --git a/packages/backend/src/models/SystemAccount.ts b/packages/backend/src/models/SystemAccount.ts
new file mode 100644
index 0000000000..f32880b81d
--- /dev/null
+++ b/packages/backend/src/models/SystemAccount.ts
@@ -0,0 +1,31 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Column, Entity, Index, JoinColumn, ManyToOne, PrimaryColumn } from 'typeorm';
+import { Serialized } from '@/types.js';
+import { id } from './util/id.js';
+import { MiUser } from './User.js';
+
+@Entity('system_account')
+@Index(['type'], { unique: true })
+export class MiSystemAccount {
+ @PrimaryColumn(id())
+ public id: string;
+
+ @Index()
+ @Column(id())
+ public userId: MiUser['id'];
+
+ @ManyToOne(type => MiUser, {
+ onDelete: 'CASCADE',
+ })
+ @JoinColumn()
+ public user: MiUser | null;
+
+ @Column('varchar', {
+ length: 256,
+ })
+ public type: string;
+}
diff --git a/packages/backend/src/models/User.ts b/packages/backend/src/models/User.ts
index 549d78a22c..630240efde 100644
--- a/packages/backend/src/models/User.ts
+++ b/packages/backend/src/models/User.ts
@@ -184,12 +184,6 @@ export class MiUser {
})
public isCat: boolean;
- @Column('boolean', {
- default: false,
- comment: 'Whether the User is the root.',
- })
- public isRoot: boolean;
-
@Index()
@Column('boolean', {
default: true,
diff --git a/packages/backend/src/models/_.ts b/packages/backend/src/models/_.ts
index c72bdaa727..fa15760c00 100644
--- a/packages/backend/src/models/_.ts
+++ b/packages/backend/src/models/_.ts
@@ -56,6 +56,7 @@ import { MiRegistryItem } from '@/models/RegistryItem.js';
import { MiRelay } from '@/models/Relay.js';
import { MiSignin } from '@/models/Signin.js';
import { MiSwSubscription } from '@/models/SwSubscription.js';
+import { MiSystemAccount } from '@/models/SystemAccount.js';
import { MiUsedUsername } from '@/models/UsedUsername.js';
import { MiUser } from '@/models/User.js';
import { MiUserIp } from '@/models/UserIp.js';
@@ -171,6 +172,7 @@ export {
MiRelay,
MiSignin,
MiSwSubscription,
+ MiSystemAccount,
MiUsedUsername,
MiUser,
MiUserIp,
@@ -242,6 +244,7 @@ export type RegistryItemsRepository = Repository & MiRepository<
export type RelaysRepository = Repository & MiRepository;
export type SigninsRepository = Repository & MiRepository;
export type SwSubscriptionsRepository = Repository & MiRepository;
+export type SystemAccountsRepository = Repository & MiRepository;
export type UsedUsernamesRepository = Repository & MiRepository;
export type UsersRepository = Repository & MiRepository;
export type UserIpsRepository = Repository & MiRepository;
diff --git a/packages/backend/src/postgres.ts b/packages/backend/src/postgres.ts
index 8a0b7d97d7..043332d4b5 100644
--- a/packages/backend/src/postgres.ts
+++ b/packages/backend/src/postgres.ts
@@ -82,6 +82,7 @@ import { MiReversiGame } from '@/models/ReversiGame.js';
import { Config } from '@/config.js';
import MisskeyLogger from '@/logger.js';
import { bindThis } from '@/decorators.js';
+import { MiSystemAccount } from './models/SystemAccount.js';
pg.types.setTypeParser(20, Number);
@@ -206,6 +207,7 @@ export const entities = [
MiEmoji,
MiHashtag,
MiSwSubscription,
+ MiSystemAccount,
MiAbuseUserReport,
MiAbuseReportNotificationRecipient,
MiRegistrationTicket,
diff --git a/packages/backend/src/server/NodeinfoServerService.ts b/packages/backend/src/server/NodeinfoServerService.ts
index 9a641007ee..239ef82dec 100644
--- a/packages/backend/src/server/NodeinfoServerService.ts
+++ b/packages/backend/src/server/NodeinfoServerService.ts
@@ -9,11 +9,11 @@ import type { Config } from '@/config.js';
import { MetaService } from '@/core/MetaService.js';
import { MAX_NOTE_TEXT_LENGTH } from '@/const.js';
import { MemorySingleCache } from '@/misc/cache.js';
-import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { bindThis } from '@/decorators.js';
import NotesChart from '@/core/chart/charts/notes.js';
import UsersChart from '@/core/chart/charts/users.js';
import { DEFAULT_POLICIES } from '@/core/RoleService.js';
+import { SystemAccountService } from '@/core/SystemAccountService.js';
import type { FastifyInstance, FastifyPluginOptions } from 'fastify';
const nodeinfo2_1path = '/nodeinfo/2.1';
@@ -26,7 +26,7 @@ export class NodeinfoServerService {
@Inject(DI.config)
private config: Config,
- private userEntityService: UserEntityService,
+ private systemAccountService: SystemAccountService,
private metaService: MetaService,
private notesChart: NotesChart,
private usersChart: UsersChart,
@@ -70,7 +70,7 @@ export class NodeinfoServerService {
const activeHalfyear = null;
const activeMonth = null;
- const proxyAccount = meta.proxyAccountId ? await this.userEntityService.pack(meta.proxyAccountId).catch(() => null) : null;
+ const proxyAccount = await this.systemAccountService.fetch('proxy');
const basePolicies = { ...DEFAULT_POLICIES, ...meta.policies };
@@ -123,7 +123,7 @@ export class NodeinfoServerService {
maxNoteTextLength: MAX_NOTE_TEXT_LENGTH,
enableEmail: meta.enableEmail,
enableServiceWorker: meta.enableServiceWorker,
- proxyAccountName: proxyAccount ? proxyAccount.username : null,
+ proxyAccountName: proxyAccount.username,
themeColor: meta.themeColor ?? '#86b300',
},
};
diff --git a/packages/backend/src/server/api/ApiCallService.ts b/packages/backend/src/server/api/ApiCallService.ts
index aad833f126..9399aa61b0 100644
--- a/packages/backend/src/server/api/ApiCallService.ts
+++ b/packages/backend/src/server/api/ApiCallService.ts
@@ -371,7 +371,7 @@ export class ApiCallService implements OnApplicationShutdown {
}
}
- if ((ep.meta.requireModerator || ep.meta.requireAdmin) && !user!.isRoot) {
+ if ((ep.meta.requireModerator || ep.meta.requireAdmin) && (this.meta.rootUserId !== user!.id)) {
const myRoles = await this.roleService.getUserRoles(user!.id);
if (ep.meta.requireModerator && !myRoles.some(r => r.isModerator || r.isAdministrator)) {
throw new ApiError({
@@ -391,7 +391,7 @@ export class ApiCallService implements OnApplicationShutdown {
}
}
- if (ep.meta.requireRolePolicy != null && !user!.isRoot) {
+ if (ep.meta.requireRolePolicy != null && (this.meta.rootUserId !== user!.id)) {
const myRoles = await this.roleService.getUserRoles(user!.id);
const policies = await this.roleService.getUserPolicies(user!.id);
if (!policies[ep.meta.requireRolePolicy] && !myRoles.some(r => r.isAdministrator)) {
diff --git a/packages/backend/src/server/api/endpoint-list.ts b/packages/backend/src/server/api/endpoint-list.ts
index 28f7cfea04..560d3f6587 100644
--- a/packages/backend/src/server/api/endpoint-list.ts
+++ b/packages/backend/src/server/api/endpoint-list.ts
@@ -100,6 +100,7 @@ export * as 'admin/unset-user-banner' from './endpoints/admin/unset-user-banner.
export * as 'admin/unsuspend-user' from './endpoints/admin/unsuspend-user.js';
export * as 'admin/update-abuse-user-report' from './endpoints/admin/update-abuse-user-report.js';
export * as 'admin/update-meta' from './endpoints/admin/update-meta.js';
+export * as 'admin/update-proxy-account' from './endpoints/admin/update-proxy-account.js';
export * as 'admin/update-user-note' from './endpoints/admin/update-user-note.js';
export * as 'announcements' from './endpoints/announcements.js';
export * as 'announcements/show' from './endpoints/announcements/show.js';
diff --git a/packages/backend/src/server/api/endpoints/admin/accounts/create.ts b/packages/backend/src/server/api/endpoints/admin/accounts/create.ts
index d30131a62f..06047b58a6 100644
--- a/packages/backend/src/server/api/endpoints/admin/accounts/create.ts
+++ b/packages/backend/src/server/api/endpoints/admin/accounts/create.ts
@@ -4,12 +4,10 @@
*/
import { Inject, Injectable } from '@nestjs/common';
-import { IsNull } from 'typeorm';
import { Endpoint } from '@/server/api/endpoint-base.js';
-import type { UsersRepository } from '@/models/_.js';
+import type { MiMeta, UsersRepository } from '@/models/_.js';
import { SignupService } from '@/core/SignupService.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
-import { InstanceActorService } from '@/core/InstanceActorService.js';
import { localUsernameSchema, passwordSchema } from '@/models/User.js';
import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
@@ -62,18 +60,19 @@ export default class extends Endpoint { // eslint-
@Inject(DI.config)
private config: Config,
+ @Inject(DI.meta)
+ private serverSettings: MiMeta,
+
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
private userEntityService: UserEntityService,
private signupService: SignupService,
- private instanceActorService: InstanceActorService,
) {
super(meta, paramDef, async (ps, _me, token) => {
const me = _me ? await this.usersRepository.findOneByOrFail({ id: _me.id }) : null;
- const realUsers = await this.instanceActorService.realLocalUsersPresent();
- if (!realUsers && me == null && token == null) {
+ if (this.serverSettings.rootUserId == null && me == null && token == null) {
// 初回セットアップの場合
if (this.config.setupPassword != null) {
// 初期パスワードが設定されている場合
@@ -85,7 +84,7 @@ export default class extends Endpoint { // eslint-
// 初期パスワードが設定されていないのに初期パスワードが入力された場合
throw new ApiError(meta.errors.wrongInitialPassword);
}
- } else if ((realUsers && !me?.isRoot) || token !== null) {
+ } else if ((this.serverSettings.rootUserId != null && (this.serverSettings.rootUserId !== me?.id)) || token !== null) {
// 初回セットアップではなく、管理者でない場合 or 外部トークンを使用している場合
throw new ApiError(meta.errors.accessDenied);
}
diff --git a/packages/backend/src/server/api/endpoints/admin/accounts/delete.ts b/packages/backend/src/server/api/endpoints/admin/accounts/delete.ts
index ece1984cff..d04f52dd64 100644
--- a/packages/backend/src/server/api/endpoints/admin/accounts/delete.ts
+++ b/packages/backend/src/server/api/endpoints/admin/accounts/delete.ts
@@ -42,10 +42,6 @@ export default class extends Endpoint { // eslint-
throw new Error('user not found');
}
- if (user.isRoot) {
- throw new Error('cannot delete a root account');
- }
-
await this.deleteAccoountService.deleteAccount(user, me);
});
}
diff --git a/packages/backend/src/server/api/endpoints/admin/meta.ts b/packages/backend/src/server/api/endpoints/admin/meta.ts
index 9d5691a427..53e2b2b237 100644
--- a/packages/backend/src/server/api/endpoints/admin/meta.ts
+++ b/packages/backend/src/server/api/endpoints/admin/meta.ts
@@ -9,6 +9,7 @@ import { MetaService } from '@/core/MetaService.js';
import type { Config } from '@/config.js';
import { DI } from '@/di-symbols.js';
import { DEFAULT_POLICIES } from '@/core/RoleService.js';
+import { SystemAccountService } from '@/core/SystemAccountService.js';
export const meta = {
tags: ['meta'],
@@ -237,7 +238,7 @@ export const meta = {
},
proxyAccountId: {
type: 'string',
- optional: false, nullable: true,
+ optional: false, nullable: false,
format: 'id',
},
email: {
@@ -545,10 +546,13 @@ export default class extends Endpoint { // eslint-
private config: Config,
private metaService: MetaService,
+ private systemAccountService: SystemAccountService,
) {
super(meta, paramDef, async () => {
const instance = await this.metaService.fetch(true);
+ const proxy = await this.systemAccountService.fetch('proxy');
+
return {
maintainerName: instance.maintainerName,
maintainerEmail: instance.maintainerEmail,
@@ -613,7 +617,7 @@ export default class extends Endpoint { // eslint-
sensitiveMediaDetectionSensitivity: instance.sensitiveMediaDetectionSensitivity,
setSensitiveFlagAutomatically: instance.setSensitiveFlagAutomatically,
enableSensitiveMediaDetectionForVideos: instance.enableSensitiveMediaDetectionForVideos,
- proxyAccountId: instance.proxyAccountId,
+ proxyAccountId: proxy.id,
email: instance.email,
smtpSecure: instance.smtpSecure,
smtpHost: instance.smtpHost,
diff --git a/packages/backend/src/server/api/endpoints/admin/reset-password.ts b/packages/backend/src/server/api/endpoints/admin/reset-password.ts
index 53db096c1d..fc246631c2 100644
--- a/packages/backend/src/server/api/endpoints/admin/reset-password.ts
+++ b/packages/backend/src/server/api/endpoints/admin/reset-password.ts
@@ -6,7 +6,7 @@
import { Inject, Injectable } from '@nestjs/common';
import bcrypt from 'bcryptjs';
import { Endpoint } from '@/server/api/endpoint-base.js';
-import type { UsersRepository, UserProfilesRepository } from '@/models/_.js';
+import type { UsersRepository, UserProfilesRepository, MiMeta } from '@/models/_.js';
import { DI } from '@/di-symbols.js';
import { secureRndstr } from '@/misc/secure-rndstr.js';
import { ModerationLogService } from '@/core/ModerationLogService.js';
@@ -43,6 +43,9 @@ export const paramDef = {
@Injectable()
export default class extends Endpoint { // eslint-disable-line import/no-default-export
constructor(
+ @Inject(DI.meta)
+ private serverSettings: MiMeta,
+
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
@@ -58,7 +61,7 @@ export default class extends Endpoint { // eslint-
throw new Error('user not found');
}
- if (user.isRoot) {
+ if (this.serverSettings.rootUserId === user.id) {
throw new Error('cannot reset password of root');
}
diff --git a/packages/backend/src/server/api/endpoints/admin/update-meta.ts b/packages/backend/src/server/api/endpoints/admin/update-meta.ts
index 45c012cb0a..bc05587668 100644
--- a/packages/backend/src/server/api/endpoints/admin/update-meta.ts
+++ b/packages/backend/src/server/api/endpoints/admin/update-meta.ts
@@ -89,7 +89,6 @@ export const paramDef = {
sensitiveMediaDetectionSensitivity: { type: 'string', enum: ['medium', 'low', 'high', 'veryLow', 'veryHigh'] },
setSensitiveFlagAutomatically: { type: 'boolean' },
enableSensitiveMediaDetectionForVideos: { type: 'boolean' },
- proxyAccountId: { type: 'string', format: 'misskey:id', nullable: true },
maintainerName: { type: 'string', nullable: true },
maintainerEmail: { type: 'string', nullable: true },
langs: {
@@ -394,10 +393,6 @@ export default class extends Endpoint { // eslint-
set.enableSensitiveMediaDetectionForVideos = ps.enableSensitiveMediaDetectionForVideos;
}
- if (ps.proxyAccountId !== undefined) {
- set.proxyAccountId = ps.proxyAccountId;
- }
-
if (ps.maintainerName !== undefined) {
set.maintainerName = ps.maintainerName;
}
diff --git a/packages/backend/src/server/api/endpoints/admin/update-proxy-account.ts b/packages/backend/src/server/api/endpoints/admin/update-proxy-account.ts
new file mode 100644
index 0000000000..6c9612c71a
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/admin/update-proxy-account.ts
@@ -0,0 +1,62 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Injectable } from '@nestjs/common';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import {
+ descriptionSchema,
+} from '@/models/User.js';
+import { UserEntityService } from '@/core/entities/UserEntityService.js';
+import { ModerationLogService } from '@/core/ModerationLogService.js';
+import { SystemAccountService } from '@/core/SystemAccountService.js';
+
+export const meta = {
+ tags: ['admin'],
+
+ requireCredential: true,
+ requireModerator: true,
+ kind: 'write:admin:account',
+
+ res: {
+ type: 'object',
+ nullable: false, optional: false,
+ ref: 'UserDetailed',
+ },
+} as const;
+
+export const paramDef = {
+ type: 'object',
+ properties: {
+ description: { ...descriptionSchema, nullable: true },
+ },
+} as const;
+
+@Injectable()
+export default class extends Endpoint { // eslint-disable-line import/no-default-export
+ constructor(
+ private userEntityService: UserEntityService,
+ private moderationLogService: ModerationLogService,
+ private systemAccountService: SystemAccountService,
+ ) {
+ super(meta, paramDef, async (ps, me) => {
+ const proxy = await this.systemAccountService.updateCorrespondingUserProfile('proxy', {
+ description: ps.description,
+ });
+
+ const updated = await this.userEntityService.pack(proxy.id, proxy, {
+ schema: 'MeDetailed',
+ });
+
+ if (ps.description !== undefined) {
+ this.moderationLogService.log(me, 'updateProxyAccountDescription', {
+ before: null, //TODO
+ after: ps.description,
+ });
+ }
+
+ return updated;
+ });
+ }
+}
diff --git a/packages/backend/src/server/api/endpoints/i/move.ts b/packages/backend/src/server/api/endpoints/i/move.ts
index 1bd641232c..7852b5a2e1 100644
--- a/packages/backend/src/server/api/endpoints/i/move.ts
+++ b/packages/backend/src/server/api/endpoints/i/move.ts
@@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
-import { Injectable } from '@nestjs/common';
+import { Inject, Injectable } from '@nestjs/common';
import ms from 'ms';
import { Endpoint } from '@/server/api/endpoint-base.js';
@@ -19,6 +19,8 @@ import { ApPersonService } from '@/core/activitypub/models/ApPersonService.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import * as Acct from '@/misc/acct.js';
+import { DI } from '@/di-symbols.js';
+import { MiMeta } from '@/models/_.js';
export const meta = {
tags: ['users'],
@@ -81,6 +83,9 @@ export const paramDef = {
@Injectable()
export default class extends Endpoint { // eslint-disable-line import/no-default-export
constructor(
+ @Inject(DI.meta)
+ private serverSettings: MiMeta,
+
private remoteUserResolveService: RemoteUserResolveService,
private apiLoggerService: ApiLoggerService,
private accountMoveService: AccountMoveService,
@@ -92,7 +97,7 @@ export default class extends Endpoint { // eslint-
// check parameter
if (!ps.moveToAccount) throw new ApiError(meta.errors.noSuchUser);
// abort if user is the root
- if (me.isRoot) throw new ApiError(meta.errors.rootForbidden);
+ if (this.serverSettings.rootUserId === me.id) throw new ApiError(meta.errors.rootForbidden);
// abort if user has already moved
if (me.movedToUri) throw new ApiError(meta.errors.alreadyMoved);
diff --git a/packages/backend/src/server/api/endpoints/reset-db.ts b/packages/backend/src/server/api/endpoints/reset-db.ts
index 67d5fabd86..552362b64a 100644
--- a/packages/backend/src/server/api/endpoints/reset-db.ts
+++ b/packages/backend/src/server/api/endpoints/reset-db.ts
@@ -6,9 +6,12 @@
import { Inject, Injectable } from '@nestjs/common';
import { DataSource } from 'typeorm';
import * as Redis from 'ioredis';
+import { LoggerService } from '@/core/LoggerService.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { DI } from '@/di-symbols.js';
import { resetDb } from '@/misc/reset-db.js';
+import { MetaService } from '@/core/MetaService.js';
+import { GlobalEventService } from '@/core/GlobalEventService.js';
export const meta = {
tags: ['non-productive'],
@@ -36,13 +39,27 @@ export default class extends Endpoint { // eslint-
@Inject(DI.redis)
private redisClient: Redis.Redis,
+
+ private loggerService: LoggerService,
+ private metaService: MetaService,
+ private globalEventService: GlobalEventService,
) {
super(meta, paramDef, async (ps, me) => {
if (process.env.NODE_ENV !== 'test') throw new Error('NODE_ENV is not a test');
- await redisClient.flushdb();
+ const logger = this.loggerService.getLogger('reset-db');
+ logger.info('---- Resetting database...');
+
+ await this.redisClient.flushdb();
await resetDb(this.db);
+ // DIコンテナで管理しているmetaのインスタンスには上記のリセット処理が届かないため、
+ // 初期値を流して明示的にリフレッシュする
+ const meta = await this.metaService.fetch(true);
+ this.globalEventService.publishInternalEvent('metaUpdated', { after: meta });
+
+ logger.info('---- Database reset complete.');
+
await new Promise(resolve => setTimeout(resolve, 1000));
});
}
diff --git a/packages/backend/src/types.ts b/packages/backend/src/types.ts
index bf409031c8..c6b1035554 100644
--- a/packages/backend/src/types.ts
+++ b/packages/backend/src/types.ts
@@ -122,6 +122,7 @@ export const moderationLogTypes = [
'deletePage',
'deleteFlash',
'deleteGalleryPost',
+ 'updateProxyAccountDescription',
] as const;
export type ModerationLogPayloads = {
@@ -374,25 +375,29 @@ export type ModerationLogPayloads = {
postUserUsername: string;
post: any;
};
+ updateProxyAccountDescription: {
+ before: string | null;
+ after: string | null;
+ };
};
export type Serialized = {
[K in keyof T]:
- T[K] extends Date
- ? string
- : T[K] extends (Date | null)
- ? (string | null)
- : T[K] extends Record
- ? Serialized
- : T[K] extends (Record | null)
+ T[K] extends Date
+ ? string
+ : T[K] extends (Date | null)
+ ? (string | null)
+ : T[K] extends Record
+ ? Serialized
+ : T[K] extends (Record | null)
? (Serialized | null)
- : T[K] extends (Record | undefined)
+ : T[K] extends (Record | undefined)
? (Serialized | undefined)
- : T[K];
+ : T[K];
};
export type FilterUnionByProperty<
- Union,
- Property extends string | number | symbol,
- Condition
+ Union,
+ Property extends string | number | symbol,
+ Condition,
> = Union extends Record ? Union : never;
diff --git a/packages/backend/test-federation/compose.yml b/packages/backend/test-federation/compose.yml
index a5a7223982..ed39109aab 100644
--- a/packages/backend/test-federation/compose.yml
+++ b/packages/backend/test-federation/compose.yml
@@ -20,8 +20,12 @@ services:
depends_on:
a.test:
condition: service_healthy
+ misskey.a.test:
+ condition: service_healthy
b.test:
condition: service_healthy
+ misskey.b.test:
+ condition: service_healthy
environment:
- NODE_ENV=development
- NODE_EXTRA_CA_CERTS=/usr/local/share/ca-certificates/rootCA.crt
diff --git a/packages/backend/test-federation/test/abuse-report.test.ts b/packages/backend/test-federation/test/abuse-report.test.ts
index b54d6222b4..ddc8e4f9d0 100644
--- a/packages/backend/test-federation/test/abuse-report.test.ts
+++ b/packages/backend/test-federation/test/abuse-report.test.ts
@@ -35,7 +35,7 @@ describe('Abuse report', () => {
const reportsInB = await bModerator.client.request('admin/abuse-user-reports', {});
const reportInB = reportsInB.filter(report => report.comment.includes(comment))[0];
// NOTE: reporter is not Alice, and is not moderator in A
- strictEqual(reportInB.reporter.url, 'https://a.test/@instance.actor');
+ strictEqual(reportInB.reporter.url, 'https://a.test/@system.actor');
strictEqual(reportInB.targetUserId, bob.id);
// NOTE: cannot forward multiple times
diff --git a/packages/backend/test-federation/test/user.test.ts b/packages/backend/test-federation/test/user.test.ts
index 76605e61d4..83dcb8df44 100644
--- a/packages/backend/test-federation/test/user.test.ts
+++ b/packages/backend/test-federation/test/user.test.ts
@@ -37,6 +37,7 @@ describe('User', () => {
'id',
'host',
'avatarUrl',
+ 'avatarBlurhash',
'instance',
'badgeRoles',
'url',
@@ -379,7 +380,8 @@ describe('User', () => {
strictEqual(followers.length, 1); // followed by Bob
await alice.client.request('i/delete-account', { password: alice.password });
- await sleep();
+ // NOTE: user deletion query is slow
+ await sleep(4000);
const following = await bob.client.request('users/following', { userId: bob.id });
strictEqual(following.length, 0); // no following relation
@@ -477,7 +479,8 @@ describe('User', () => {
strictEqual(followers.length, 1); // followed by Bob
await aAdmin.client.request('admin/suspend-user', { userId: alice.id });
- await sleep();
+ // NOTE: user deletion query is slow
+ await sleep(4000);
const following = await bob.client.request('users/following', { userId: bob.id });
strictEqual(following.length, 0); // no following relation
diff --git a/packages/backend/test-federation/test/utils.ts b/packages/backend/test-federation/test/utils.ts
index db8da5025a..2779eb7e81 100644
--- a/packages/backend/test-federation/test/utils.ts
+++ b/packages/backend/test-federation/test/utils.ts
@@ -36,7 +36,7 @@ export type Request = <
type Host = 'a.test' | 'b.test';
-export async function sleep(ms = 200): Promise {
+export async function sleep(ms = 250): Promise {
return new Promise(resolve => setTimeout(resolve, ms));
}
diff --git a/packages/backend/test/misc/mock-resolver.ts b/packages/backend/test/misc/mock-resolver.ts
index c8f3db8aac..53ff4feb7e 100644
--- a/packages/backend/test/misc/mock-resolver.ts
+++ b/packages/backend/test/misc/mock-resolver.ts
@@ -7,14 +7,10 @@ import type { Config } from '@/config.js';
import type { ApDbResolverService } from '@/core/activitypub/ApDbResolverService.js';
import type { ApRendererService } from '@/core/activitypub/ApRendererService.js';
import type { ApRequestService } from '@/core/activitypub/ApRequestService.js';
-import { Resolver } from '@/core/activitypub/ApResolverService.js';
import type { IObject } from '@/core/activitypub/type.js';
import type { HttpRequestService } from '@/core/HttpRequestService.js';
-import type { InstanceActorService } from '@/core/InstanceActorService.js';
import type { LoggerService } from '@/core/LoggerService.js';
-import type { MetaService } from '@/core/MetaService.js';
import type { UtilityService } from '@/core/UtilityService.js';
-import { bindThis } from '@/decorators.js';
import type {
FollowRequestsRepository,
MiMeta,
@@ -23,6 +19,9 @@ import type {
PollsRepository,
UsersRepository,
} from '@/models/_.js';
+import { SystemAccountService } from '@/core/SystemAccountService.js';
+import { bindThis } from '@/decorators.js';
+import { Resolver } from '@/core/activitypub/ApResolverService.js';
type MockResponse = {
type: string;
@@ -43,7 +42,7 @@ export class MockResolver extends Resolver {
{} as NoteReactionsRepository,
{} as FollowRequestsRepository,
{} as UtilityService,
- {} as InstanceActorService,
+ {} as SystemAccountService,
{} as ApRequestService,
{} as HttpRequestService,
{} as ApRendererService,
diff --git a/packages/backend/test/unit/AbuseReportNotificationService.ts b/packages/backend/test/unit/AbuseReportNotificationService.ts
index 1326003c5e..6d555326fb 100644
--- a/packages/backend/test/unit/AbuseReportNotificationService.ts
+++ b/packages/backend/test/unit/AbuseReportNotificationService.ts
@@ -149,9 +149,9 @@ describe('AbuseReportNotificationService', () => {
});
beforeEach(async () => {
- root = await createUser({ username: 'root', usernameLower: 'root', isRoot: true });
- alice = await createUser({ username: 'alice', usernameLower: 'alice', isRoot: false });
- bob = await createUser({ username: 'bob', usernameLower: 'bob', isRoot: false });
+ root = await createUser({ username: 'root', usernameLower: 'root' });
+ alice = await createUser({ username: 'alice', usernameLower: 'alice' });
+ bob = await createUser({ username: 'bob', usernameLower: 'bob' });
systemWebhook1 = await createWebhook();
systemWebhook2 = await createWebhook();
diff --git a/packages/backend/test/unit/FlashService.ts b/packages/backend/test/unit/FlashService.ts
index 12ffaf3421..f2d9832f50 100644
--- a/packages/backend/test/unit/FlashService.ts
+++ b/packages/backend/test/unit/FlashService.ts
@@ -79,9 +79,9 @@ describe('FlashService', () => {
userProfilesRepository = app.get(DI.userProfilesRepository);
idService = app.get(IdService);
- root = await createUser({ username: 'root', usernameLower: 'root', isRoot: true });
- alice = await createUser({ username: 'alice', usernameLower: 'alice', isRoot: false });
- bob = await createUser({ username: 'bob', usernameLower: 'bob', isRoot: false });
+ root = await createUser({ username: 'root', usernameLower: 'root' });
+ alice = await createUser({ username: 'alice', usernameLower: 'alice' });
+ bob = await createUser({ username: 'bob', usernameLower: 'bob' });
});
afterEach(async () => {
diff --git a/packages/backend/test/unit/RelayService.ts b/packages/backend/test/unit/RelayService.ts
index 3b3d212c30..074430dd31 100644
--- a/packages/backend/test/unit/RelayService.ts
+++ b/packages/backend/test/unit/RelayService.ts
@@ -3,24 +3,21 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
-import { UtilityService } from '@/core/UtilityService.js';
-
process.env.NODE_ENV = 'test';
import { jest } from '@jest/globals';
-import { ModuleMocker } from 'jest-mock';
import { Test } from '@nestjs/testing';
-import { GlobalModule } from '@/GlobalModule.js';
-import { RelayService } from '@/core/RelayService.js';
+import { ModuleMocker } from 'jest-mock';
+import type { TestingModule } from '@nestjs/testing';
+import type { MockFunctionMetadata } from 'jest-mock';
import { ApRendererService } from '@/core/activitypub/ApRendererService.js';
-import { CreateSystemUserService } from '@/core/CreateSystemUserService.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
-import { QueueService } from '@/core/QueueService.js';
import { IdService } from '@/core/IdService.js';
-import type { RelaysRepository } from '@/models/_.js';
-import { DI } from '@/di-symbols.js';
-import type { TestingModule } from '@nestjs/testing';
-import type { MockFunctionMetadata } from 'jest-mock';
+import { QueueService } from '@/core/QueueService.js';
+import { RelayService } from '@/core/RelayService.js';
+import { SystemAccountService } from '@/core/SystemAccountService.js';
+import { GlobalModule } from '@/GlobalModule.js';
+import { UtilityService } from '@/core/UtilityService.js';
const moduleMocker = new ModuleMocker(global);
@@ -28,8 +25,6 @@ describe('RelayService', () => {
let app: TestingModule;
let relayService: RelayService;
let queueService: jest.Mocked;
- let relaysRepository: RelaysRepository;
- let userEntityService: UserEntityService;
beforeAll(async () => {
app = await Test.createTestingModule({
@@ -38,10 +33,10 @@ describe('RelayService', () => {
],
providers: [
IdService,
- CreateSystemUserService,
ApRendererService,
RelayService,
UserEntityService,
+ SystemAccountService,
UtilityService,
],
})
@@ -61,8 +56,6 @@ describe('RelayService', () => {
relayService = app.get(RelayService);
queueService = app.get(QueueService) as jest.Mocked;
- relaysRepository = app.get(DI.relaysRepository);
- userEntityService = app.get(UserEntityService);
});
afterAll(async () => {
diff --git a/packages/backend/test/unit/RoleService.ts b/packages/backend/test/unit/RoleService.ts
index 9c1b1008d6..553ff0982a 100644
--- a/packages/backend/test/unit/RoleService.ts
+++ b/packages/backend/test/unit/RoleService.ts
@@ -57,6 +57,12 @@ describe('RoleService', () => {
return await usersRepository.findOneByOrFail(x.identifiers[0]);
}
+ async function createRoot(data: Partial = {}) {
+ const user = await createUser(data);
+ meta.rootUserId = user.id;
+ return user;
+ }
+
async function createRole(data: Partial = {}) {
const x = await rolesRepository.insert({
id: genAidx(Date.now()),
@@ -279,7 +285,7 @@ describe('RoleService', () => {
describe('getModeratorIds', () => {
test('includeAdmins = false, includeRoot = false, excludeExpire = false', async () => {
const [adminUser1, adminUser2, modeUser1, modeUser2, normalUser1, normalUser2, rootUser] = await Promise.all([
- createUser(), createUser(), createUser(), createUser(), createUser(), createUser(), createUser({ isRoot: true }),
+ createUser(), createUser(), createUser(), createUser(), createUser(), createUser(), createRoot(),
]);
const role1 = await createRole({ name: 'admin', isAdministrator: true });
@@ -305,7 +311,7 @@ describe('RoleService', () => {
test('includeAdmins = false, includeRoot = false, excludeExpire = true', async () => {
const [adminUser1, adminUser2, modeUser1, modeUser2, normalUser1, normalUser2, rootUser] = await Promise.all([
- createUser(), createUser(), createUser(), createUser(), createUser(), createUser(), createUser({ isRoot: true }),
+ createUser(), createUser(), createUser(), createUser(), createUser(), createUser(), createRoot(),
]);
const role1 = await createRole({ name: 'admin', isAdministrator: true });
@@ -331,7 +337,7 @@ describe('RoleService', () => {
test('includeAdmins = true, includeRoot = false, excludeExpire = false', async () => {
const [adminUser1, adminUser2, modeUser1, modeUser2, normalUser1, normalUser2, rootUser] = await Promise.all([
- createUser(), createUser(), createUser(), createUser(), createUser(), createUser(), createUser({ isRoot: true }),
+ createUser(), createUser(), createUser(), createUser(), createUser(), createUser(), createRoot(),
]);
const role1 = await createRole({ name: 'admin', isAdministrator: true });
@@ -357,7 +363,7 @@ describe('RoleService', () => {
test('includeAdmins = true, includeRoot = false, excludeExpire = true', async () => {
const [adminUser1, adminUser2, modeUser1, modeUser2, normalUser1, normalUser2, rootUser] = await Promise.all([
- createUser(), createUser(), createUser(), createUser(), createUser(), createUser(), createUser({ isRoot: true }),
+ createUser(), createUser(), createUser(), createUser(), createUser(), createUser(), createRoot(),
]);
const role1 = await createRole({ name: 'admin', isAdministrator: true });
@@ -383,7 +389,7 @@ describe('RoleService', () => {
test('includeAdmins = false, includeRoot = true, excludeExpire = false', async () => {
const [adminUser1, adminUser2, modeUser1, modeUser2, normalUser1, normalUser2, rootUser] = await Promise.all([
- createUser(), createUser(), createUser(), createUser(), createUser(), createUser(), createUser({ isRoot: true }),
+ createUser(), createUser(), createUser(), createUser(), createUser(), createUser(), createRoot(),
]);
const role1 = await createRole({ name: 'admin', isAdministrator: true });
@@ -409,7 +415,7 @@ describe('RoleService', () => {
test('root has moderator role', async () => {
const [adminUser1, modeUser1, normalUser1, rootUser] = await Promise.all([
- createUser(), createUser(), createUser(), createUser({ isRoot: true }),
+ createUser(), createUser(), createUser(), createRoot(),
]);
const role1 = await createRole({ name: 'admin', isAdministrator: true });
@@ -433,7 +439,7 @@ describe('RoleService', () => {
test('root has administrator role', async () => {
const [adminUser1, modeUser1, normalUser1, rootUser] = await Promise.all([
- createUser(), createUser(), createUser(), createUser({ isRoot: true }),
+ createUser(), createUser(), createUser(), createRoot(),
]);
const role1 = await createRole({ name: 'admin', isAdministrator: true });
@@ -457,7 +463,7 @@ describe('RoleService', () => {
test('root has moderator role(expire)', async () => {
const [adminUser1, modeUser1, normalUser1, rootUser] = await Promise.all([
- createUser(), createUser(), createUser(), createUser({ isRoot: true }),
+ createUser(), createUser(), createUser(), createRoot(),
]);
const role1 = await createRole({ name: 'admin', isAdministrator: true });
diff --git a/packages/backend/test/unit/SystemWebhookService.ts b/packages/backend/test/unit/SystemWebhookService.ts
index fee4acb305..61187e9f2a 100644
--- a/packages/backend/test/unit/SystemWebhookService.ts
+++ b/packages/backend/test/unit/SystemWebhookService.ts
@@ -97,7 +97,7 @@ describe('SystemWebhookService', () => {
}
async function beforeEachImpl() {
- root = await createUser({ isRoot: true, username: 'root', usernameLower: 'root' });
+ root = await createUser({ username: 'root', usernameLower: 'root' });
}
async function afterEachImpl() {
diff --git a/packages/backend/test/unit/UserSearchService.ts b/packages/backend/test/unit/UserSearchService.ts
index 7ea325d420..66a7f39ff1 100644
--- a/packages/backend/test/unit/UserSearchService.ts
+++ b/packages/backend/test/unit/UserSearchService.ts
@@ -113,7 +113,7 @@ describe('UserSearchService', () => {
});
beforeEach(async () => {
- root = await createUser({ username: 'root', usernameLower: 'root', isRoot: true });
+ root = await createUser({ username: 'root', usernameLower: 'root' });
alice = await createUser({ username: 'Alice', usernameLower: 'alice' });
alyce = await createUser({ username: 'Alyce', usernameLower: 'alyce' });
alycia = await createUser({ username: 'Alycia', usernameLower: 'alycia' });
diff --git a/packages/backend/test/unit/UserWebhookService.ts b/packages/backend/test/unit/UserWebhookService.ts
index db8f96df28..a2a85e9489 100644
--- a/packages/backend/test/unit/UserWebhookService.ts
+++ b/packages/backend/test/unit/UserWebhookService.ts
@@ -91,7 +91,7 @@ describe('UserWebhookService', () => {
}
async function beforeEachImpl() {
- root = await createUser({ isRoot: true, username: 'root', usernameLower: 'root' });
+ root = await createUser({ username: 'root', usernameLower: 'root' });
}
async function afterEachImpl() {
diff --git a/packages/backend/test/unit/WebhookTestService.ts b/packages/backend/test/unit/WebhookTestService.ts
index be84ae9b84..45bc932469 100644
--- a/packages/backend/test/unit/WebhookTestService.ts
+++ b/packages/backend/test/unit/WebhookTestService.ts
@@ -88,8 +88,8 @@ describe('WebhookTestService', () => {
});
beforeEach(async () => {
- root = await createUser({ username: 'root', usernameLower: 'root', isRoot: true });
- alice = await createUser({ username: 'alice', usernameLower: 'alice', isRoot: false });
+ root = await createUser({ username: 'root', usernameLower: 'root' });
+ alice = await createUser({ username: 'alice', usernameLower: 'alice' });
userWebhookService.fetchWebhooks.mockReturnValue(Promise.resolve([
{ id: 'dummy-webhook', active: true, userId: alice.id } as MiWebhook,
diff --git a/packages/backend/test/unit/queue/processors/CheckModeratorsActivityProcessorService.ts b/packages/backend/test/unit/queue/processors/CheckModeratorsActivityProcessorService.ts
index d96e6b916a..07618e7762 100644
--- a/packages/backend/test/unit/queue/processors/CheckModeratorsActivityProcessorService.ts
+++ b/packages/backend/test/unit/queue/processors/CheckModeratorsActivityProcessorService.ts
@@ -316,7 +316,7 @@ describe('CheckModeratorsActivityProcessorService', () => {
createUser({}, { email: 'user2@example.com', emailVerified: false }),
createUser({}, { email: null, emailVerified: false }),
createUser({}, { email: 'user4@example.com', emailVerified: true }),
- createUser({ isRoot: true }, { email: 'root@example.com', emailVerified: true }),
+ createUser({}, { email: 'root@example.com', emailVerified: true }),
]);
mockModeratorRole([user1, user2, user3, root]);
@@ -349,7 +349,7 @@ describe('CheckModeratorsActivityProcessorService', () => {
createUser({}, { email: 'user2@example.com', emailVerified: false }),
createUser({}, { email: null, emailVerified: false }),
createUser({}, { email: 'user4@example.com', emailVerified: true }),
- createUser({ isRoot: true }, { email: 'root@example.com', emailVerified: true }),
+ createUser({}, { email: 'root@example.com', emailVerified: true }),
]);
mockModeratorRole([user1, user2, user3, root]);
diff --git a/packages/frontend/src/pages/admin-user.vue b/packages/frontend/src/pages/admin-user.vue
index fa08c213e2..578945bf07 100644
--- a/packages/frontend/src/pages/admin-user.vue
+++ b/packages/frontend/src/pages/admin-user.vue
@@ -22,7 +22,7 @@ SPDX-License-Identifier: AGPL-3.0-only
- {{ i18n.ts.isSystemAccount }}
+ {{ i18n.ts.isSystemAccount }}
{{ i18n.ts.instanceInfo }}
@@ -37,21 +37,23 @@ SPDX-License-Identifier: AGPL-3.0-only
{{ ips[0].ip }}
-->
-
- {{ i18n.ts.createdAt }}
-
-
-
- {{ i18n.ts.lastActiveDate }}
-
-
-
- {{ i18n.ts.email }}
- {{ info.email }}
-
+
+
+ {{ i18n.ts.createdAt }}
+
+
+
+ {{ i18n.ts.lastActiveDate }}
+
+
+
+ {{ i18n.ts.email }}
+ {{ info.email }}
+
+
-
+
{{ i18n.ts.moderationNote }}
{{ i18n.ts.moderationNoteDescription }}
@@ -92,7 +94,7 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
-
+
{{ i18n.ts.suspend }}
@@ -252,6 +254,7 @@ const ap = ref
(null);
const moderator = ref(false);
const silenced = ref(false);
const suspended = ref(false);
+const isSystem = ref(false);
const moderationNote = ref('');
const filesPagination = {
endpoint: 'admin/drive/files' as const,
@@ -288,6 +291,7 @@ function createFetcher() {
silenced.value = info.value.isSilenced;
suspended.value = info.value.isSuspended;
moderationNote.value = info.value.moderationNote;
+ isSystem.value = user.value.host == null && user.value.username.includes('.');
watch(moderationNote, async () => {
await misskeyApi('admin/update-user-note', { userId: user.value.id, text: moderationNote.value });
@@ -507,7 +511,15 @@ watch(user, () => {
const headerActions = computed(() => []);
-const headerTabs = computed(() => [{
+const headerTabs = computed(() => isSystem.value ? [{
+ key: 'overview',
+ title: i18n.ts.overview,
+ icon: 'ti ti-info-circle',
+}, {
+ key: 'raw',
+ title: 'Raw',
+ icon: 'ti ti-code',
+}] : [{
key: 'overview',
title: i18n.ts.overview,
icon: 'ti ti-info-circle',
diff --git a/packages/frontend/src/pages/admin/modlog.ModLog.vue b/packages/frontend/src/pages/admin/modlog.ModLog.vue
index 1e144394fb..9bbe5f2e42 100644
--- a/packages/frontend/src/pages/admin/modlog.ModLog.vue
+++ b/packages/frontend/src/pages/admin/modlog.ModLog.vue
@@ -170,6 +170,11 @@ SPDX-License-Identifier: AGPL-3.0-only
+
+
+
+
+
raw
diff --git a/packages/frontend/src/pages/admin/settings.vue b/packages/frontend/src/pages/admin/settings.vue
index ea7603a45a..aed593fc54 100644
--- a/packages/frontend/src/pages/admin/settings.vue
+++ b/packages/frontend/src/pages/admin/settings.vue
@@ -238,15 +238,17 @@ SPDX-License-Identifier: AGPL-3.0-only
{{ i18n.ts.proxyAccount }}
+
+
+
{{ i18n.ts.proxyAccountDescription }}
-
- {{ i18n.ts.proxyAccount }}
- {{ proxyAccount ? `@${proxyAccount.username}` : i18n.ts.none }}
-
- {{ i18n.ts.selectAccount }}
+
+ {{ i18n.ts._profile.description }}
+ {{ i18n.ts._profile.youCanIncludeHashtags }}
+
@@ -256,7 +258,7 @@ SPDX-License-Identifier: AGPL-3.0-only
-
-
diff --git a/locales/index.d.ts b/locales/index.d.ts
index 947b577792..6810d204cb 100644
--- a/locales/index.d.ts
+++ b/locales/index.d.ts
@@ -4971,7 +4971,7 @@ export interface Locale extends ILocale {
*/
"disableStreamingTimeline": string;
/**
- * 通知をグルーピングして表示する
+ * 通知をグルーピング
*/
"useGroupedNotifications": string;
/**
@@ -5270,6 +5270,14 @@ export interface Locale extends ILocale {
* このメディアのセンシティブ指定を解除しますか?
*/
"unmarkAsSensitiveConfirm": string;
+ /**
+ * 環境設定
+ */
+ "preferences": string;
+ /**
+ * アクセシビリティ
+ */
+ "accessibility": string;
"_accountSettings": {
/**
* コンテンツの表示にログインを必須にする
diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml
index fbe4d98896..7a5d2f795e 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -1238,7 +1238,7 @@ releaseToRefresh: "離してリロード"
refreshing: "リロード中"
pullDownToRefresh: "引っ張ってリロード"
disableStreamingTimeline: "タイムラインのリアルタイム更新を無効にする"
-useGroupedNotifications: "通知をグルーピングして表示する"
+useGroupedNotifications: "通知をグルーピング"
signupPendingError: "メールアドレスの確認中に問題が発生しました。リンクの有効期限が切れている可能性があります。"
cwNotationRequired: "「内容を隠す」がオンの場合は注釈の記述が必要です。"
doReaction: "リアクションする"
@@ -1313,6 +1313,8 @@ confirmOnReact: "リアクションする際に確認する"
reactAreYouSure: "\" {emoji} \" をリアクションしますか?"
markAsSensitiveConfirm: "このメディアをセンシティブとして設定しますか?"
unmarkAsSensitiveConfirm: "このメディアのセンシティブ指定を解除しますか?"
+preferences: "環境設定"
+accessibility: "アクセシビリティ"
_accountSettings:
requireSigninToViewContents: "コンテンツの表示にログインを必須にする"
diff --git a/packages/frontend/.storybook/main.ts b/packages/frontend/.storybook/main.ts
index 9f318cf449..c1119c2523 100644
--- a/packages/frontend/.storybook/main.ts
+++ b/packages/frontend/.storybook/main.ts
@@ -39,6 +39,10 @@ const config = {
if (~replacePluginForIsChromatic) {
config.plugins?.splice(replacePluginForIsChromatic, 1);
}
+
+ //pluginsからcreateSearchIndexを削除、複数あるかもしれないので全て削除
+ config.plugins = config.plugins?.filter((plugin: Plugin) => plugin && plugin.name !== 'createSearchIndex') ?? [];
+
return mergeConfig(config, {
plugins: [
{
diff --git a/packages/frontend/lib/vite-plugin-create-search-index.ts b/packages/frontend/lib/vite-plugin-create-search-index.ts
new file mode 100644
index 0000000000..509eb804cb
--- /dev/null
+++ b/packages/frontend/lib/vite-plugin-create-search-index.ts
@@ -0,0 +1,1496 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { parse as vueSfcParse } from 'vue/compiler-sfc';
+import type { LogOptions, Plugin } from 'vite';
+import fs from 'node:fs';
+import { glob } from 'glob';
+import JSON5 from 'json5';
+import MagicString from 'magic-string';
+import path from 'node:path'
+import { hash, toBase62 } from '../vite.config';
+import { createLogger } from 'vite';
+
+interface VueAstNode {
+ type: number;
+ tag?: string;
+ loc?: {
+ start: { offset: number, line: number, column: number },
+ end: { offset: number, line: number, column: number },
+ source?: string
+ };
+ props?: Array<{
+ name: string;
+ type: number;
+ value?: { content?: string };
+ arg?: { content?: string };
+ exp?: { content?: string; loc?: any };
+ }>;
+ children?: VueAstNode[];
+ content?: any;
+ __markerId?: string;
+ __children?: string[];
+}
+
+export type AnalysisResult = {
+ filePath: string;
+ usage: SearchIndexItem[];
+}
+
+export type SearchIndexItem = {
+ id: string;
+ path?: string;
+ label: string;
+ keywords: string | string[];
+ icon?: string;
+ inlining?: string[];
+ children?: SearchIndexItem[];
+};
+
+export type Options = {
+ targetFilePaths: string[],
+ exportFilePath: string,
+ verbose?: boolean,
+};
+
+// 関連するノードタイプの定数化
+const NODE_TYPES = {
+ ELEMENT: 1,
+ EXPRESSION: 2,
+ TEXT: 3,
+ INTERPOLATION: 5, // Mustache
+};
+
+// マーカー関係を表す型
+interface MarkerRelation {
+ parentId?: string;
+ markerId: string;
+ node: VueAstNode;
+}
+
+// ロガー
+let logger = {
+ info: (msg: string, options?: LogOptions) => { },
+ warn: (msg: string, options?: LogOptions) => { },
+ error: (msg: string, options?: LogOptions) => { },
+};
+let loggerInitialized = false;
+
+function initLogger(options: Options) {
+ if (loggerInitialized) return;
+ loggerInitialized = true;
+ const viteLogger = createLogger(options.verbose ? 'info' : 'warn');
+
+ logger.info = (msg, options) => {
+ msg = `[create-search-index] ${msg}`;
+ viteLogger.info(msg, options);
+ }
+
+ logger.warn = (msg, options) => {
+ msg = `[create-search-index] ${msg}`;
+ viteLogger.warn(msg, options);
+ }
+
+ logger.error = (msg, options) => {
+ msg = `[create-search-index] ${msg}`;
+ viteLogger.error(msg, options);
+ }
+}
+
+/**
+ * 解析結果をTypeScriptファイルとして出力する
+ */
+function outputAnalysisResultAsTS(outputPath: string, analysisResults: AnalysisResult[]): void {
+ logger.info(`Processing ${analysisResults.length} files for output`);
+
+ // 新しいツリー構造を構築
+ const allMarkers = new Map();
+
+ // 1. すべてのマーカーを一旦フラットに収集
+ for (const file of analysisResults) {
+ logger.info(`Processing file: ${file.filePath} with ${file.usage.length} markers`);
+
+ for (const marker of file.usage) {
+ if (marker.id) {
+ // キーワードとchildren処理を共通化
+ const processedMarker = {
+ ...marker,
+ keywords: processMarkerProperty(marker.keywords, 'keywords'),
+ children: processMarkerProperty(marker.children || [], 'children')
+ };
+
+ allMarkers.set(marker.id, processedMarker);
+ }
+ }
+ }
+
+ logger.info(`Collected total ${allMarkers.size} unique markers`);
+
+ // 2. 子マーカーIDの収集
+ const childIds = collectChildIds(allMarkers);
+ logger.info(`Found ${childIds.size} child markers`);
+
+ // 3. ルートマーカーの特定(他の誰かの子でないマーカー)
+ const rootMarkers = identifyRootMarkers(allMarkers, childIds);
+ logger.info(`Found ${rootMarkers.length} root markers`);
+
+ // 4. 子マーカーの参照を解決
+ const resolvedRootMarkers = resolveChildReferences(rootMarkers, allMarkers);
+
+ // 5. デバッグ情報を生成
+ const { totalMarkers, totalChildren } = countMarkers(resolvedRootMarkers);
+ logger.info(`Total markers in tree: ${totalMarkers} (${resolvedRootMarkers.length} roots + ${totalChildren} nested children)`);
+
+ // 6. 結果をTS形式で出力
+ writeOutputFile(outputPath, resolvedRootMarkers);
+}
+
+/**
+ * マーカーのプロパティ(keywordsやchildren)を処理する
+ */
+function processMarkerProperty(propValue: any, propType: 'keywords' | 'children'): any {
+ // 文字列の配列表現を解析
+ if (typeof propValue === 'string' && propValue.startsWith('[') && propValue.endsWith(']')) {
+ try {
+ // JSON5解析を試みる
+ return JSON5.parse(propValue.replace(/'/g, '"'));
+ } catch (e) {
+ // 解析に失敗した場合
+ logger.warn(`Could not parse ${propType}: ${propValue}, using ${propType === 'children' ? 'empty array' : 'as is'}`);
+ return propType === 'children' ? [] : propValue;
+ }
+ }
+
+ return propValue;
+}
+
+/**
+ * 全マーカーから子IDを収集する
+ */
+function collectChildIds(allMarkers: Map): Set {
+ const childIds = new Set();
+
+ allMarkers.forEach((marker, id) => {
+ // 通常のchildren処理
+ const children = marker.children;
+ if (Array.isArray(children)) {
+ children.forEach(childId => {
+ if (typeof childId === 'string') {
+ if (!allMarkers.has(childId)) {
+ logger.warn(`Warning: Child marker ID ${childId} referenced but not found`);
+ } else {
+ childIds.add(childId);
+ }
+ }
+ });
+ }
+
+ // inlining処理を追加
+ if (marker.inlining) {
+ let inliningIds: string[] = [];
+
+ // 文字列の場合は配列に変換
+ if (typeof marker.inlining === 'string') {
+ try {
+ const inliningStr = (marker.inlining as string).trim();
+ if (inliningStr.startsWith('[') && inliningStr.endsWith(']')) {
+ inliningIds = JSON5.parse(inliningStr.replace(/'/g, '"'));
+ logger.info(`Parsed inlining string to array: ${inliningStr} -> ${JSON.stringify(inliningIds)}`);
+ } else {
+ inliningIds = [inliningStr];
+ }
+ } catch (e) {
+ logger.error(`Failed to parse inlining string: ${marker.inlining}`, e);
+ }
+ }
+ // 既に配列の場合
+ else if (Array.isArray(marker.inlining)) {
+ inliningIds = marker.inlining;
+ }
+
+ // inliningで指定されたIDを子セットに追加
+ for (const inlineId of inliningIds) {
+ if (typeof inlineId === 'string') {
+ if (!allMarkers.has(inlineId)) {
+ logger.warn(`Warning: Inlining marker ID ${inlineId} referenced but not found`);
+ } else {
+ // inliningで参照されているマーカーも子として扱う
+ childIds.add(inlineId);
+ logger.info(`Added inlined marker ${inlineId} as child in collectChildIds`);
+ }
+ }
+ }
+ }
+ });
+
+ return childIds;
+}
+
+/**
+ * ルートマーカー(他の子でないマーカー)を特定する
+ */
+function identifyRootMarkers(
+ allMarkers: Map,
+ childIds: Set
+): SearchIndexItem[] {
+ const rootMarkers: SearchIndexItem[] = [];
+
+ allMarkers.forEach((marker, id) => {
+ if (!childIds.has(id)) {
+ rootMarkers.push(marker);
+ logger.info(`Added root marker to output: ${id} with label ${marker.label}`);
+ }
+ });
+
+ return rootMarkers;
+}
+
+/**
+ * 子マーカーの参照をIDから実際のオブジェクトに解決する
+ */
+function resolveChildReferences(
+ rootMarkers: SearchIndexItem[],
+ allMarkers: Map
+): SearchIndexItem[] {
+ function resolveChildrenForMarker(marker: SearchIndexItem): SearchIndexItem {
+ // マーカーのディープコピーを作成
+ const resolvedMarker = { ...marker };
+ // 明示的に子マーカー配列を作成
+ const resolvedChildren: SearchIndexItem[] = [];
+
+ // 通常のchildren処理
+ if (Array.isArray(marker.children)) {
+ for (const childId of marker.children) {
+ if (typeof childId === 'string') {
+ const childMarker = allMarkers.get(childId);
+ if (childMarker) {
+ // 子マーカーの子も再帰的に解決
+ const resolvedChild = resolveChildrenForMarker(childMarker);
+ resolvedChildren.push(resolvedChild);
+ logger.info(`Resolved regular child ${childId} for parent ${marker.id}`);
+ }
+ }
+ }
+ }
+
+ // inlining属性の処理
+ let inliningIds: string[] = [];
+
+ // 文字列の場合は配列に変換。例: "['2fa']" -> ['2fa']
+ if (typeof marker.inlining === 'string') {
+ try {
+ // 文字列形式の配列を実際の配列に変換
+ const inliningStr = (marker.inlining as string).trim();
+ if (inliningStr.startsWith('[') && inliningStr.endsWith(']')) {
+ inliningIds = JSON5.parse(inliningStr.replace(/'/g, '"'));
+ logger.info(`Converted string inlining to array: ${inliningStr} -> ${JSON.stringify(inliningIds)}`);
+ } else {
+ // 単一値の場合は配列に
+ inliningIds = [inliningStr];
+ logger.info(`Converted single string inlining to array: ${inliningStr}`);
+ }
+ } catch (e) {
+ logger.error(`Failed to parse inlining string: ${marker.inlining}`, e);
+ }
+ }
+ // 既に配列の場合はそのまま使用
+ else if (Array.isArray(marker.inlining)) {
+ inliningIds = marker.inlining;
+ }
+
+ // インライン指定されたマーカーを子として追加
+ for (const inlineId of inliningIds) {
+ if (typeof inlineId === 'string') {
+ const inlineMarker = allMarkers.get(inlineId);
+ if (inlineMarker) {
+ // インライン指定されたマーカーを再帰的に解決
+ const resolvedInline = resolveChildrenForMarker(inlineMarker);
+ delete resolvedInline.path
+ resolvedChildren.push(resolvedInline);
+ logger.info(`Added inlined marker ${inlineId} as child to ${marker.id}`);
+ } else {
+ logger.warn(`Inlining target not found: ${inlineId} referenced by ${marker.id}`);
+ }
+ }
+ }
+
+ // 解決した子が存在する場合のみchildrenプロパティを設定
+ if (resolvedChildren.length > 0) {
+ resolvedMarker.children = resolvedChildren;
+ } else {
+ delete resolvedMarker.children;
+ }
+
+ return resolvedMarker;
+ }
+
+ // すべてのルートマーカーの子を解決
+ return rootMarkers.map(marker => resolveChildrenForMarker(marker));
+}
+
+/**
+ * マーカー数を数える(デバッグ用)
+ */
+function countMarkers(markers: SearchIndexItem[]): { totalMarkers: number, totalChildren: number } {
+ let totalMarkers = markers.length;
+ let totalChildren = 0;
+
+ function countNested(items: SearchIndexItem[]): void {
+ for (const marker of items) {
+ if (marker.children && Array.isArray(marker.children)) {
+ totalChildren += marker.children.length;
+ totalMarkers += marker.children.length;
+ countNested(marker.children as SearchIndexItem[]);
+ }
+ }
+ }
+
+ countNested(markers);
+ return { totalMarkers, totalChildren };
+}
+
+/**
+ * 最終的なTypeScriptファイルを出力
+ */
+function writeOutputFile(outputPath: string, resolvedRootMarkers: SearchIndexItem[]): void {
+ try {
+ const tsOutput = generateTypeScriptCode(resolvedRootMarkers);
+ fs.writeFileSync(outputPath, tsOutput, 'utf-8');
+ // 強制的に出力させるためにViteロガーを使わない
+ console.log(`Successfully wrote search index to ${outputPath} with ${resolvedRootMarkers.length} root entries`);
+ } catch (error) {
+ logger.error('[create-search-index]: error writing output: ', error);
+ }
+}
+
+/**
+ * TypeScriptコード生成
+ */
+function generateTypeScriptCode(resolvedRootMarkers: SearchIndexItem[]): string {
+ return `
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+// This file was automatically generated by create-search-index.
+// Do not edit this file.
+
+import { i18n } from '@/i18n.js';
+
+export type SearchIndexItem = {
+ id: string;
+ path?: string;
+ label: string;
+ keywords: string[];
+ icon?: string;
+ children?: SearchIndexItem[];
+};
+
+export const searchIndexes: SearchIndexItem[] = ${customStringify(resolvedRootMarkers)} as const;
+
+export type SearchIndex = typeof searchIndexes;
+`;
+}
+
+/**
+ * オブジェクトを特殊な形式の文字列に変換する
+ * i18n参照を保持しつつ適切な形式に変換
+ */
+function customStringify(obj: any, depth = 0): string {
+ const INDENT_STR = '\t';
+
+ // 配列の処理
+ if (Array.isArray(obj)) {
+ if (obj.length === 0) return '[]';
+ const indent = INDENT_STR.repeat(depth);
+ const childIndent = INDENT_STR.repeat(depth + 1);
+
+ // 配列要素の処理
+ const items = obj.map(item => {
+ // オブジェクト要素
+ if (typeof item === 'object' && item !== null) {
+ return `${childIndent}${customStringify(item, depth + 1)}`;
+ }
+
+ // i18n参照を含む文字列要素
+ if (typeof item === 'string' && item.includes('i18n.ts.')) {
+ return `${childIndent}${item}`; // クォートなしでそのまま出力
+ }
+
+ // その他の要素
+ return `${childIndent}${JSON5.stringify(item)}`;
+ }).join(',\n');
+
+ return `[\n${items},\n${indent}]`;
+ }
+
+ // null または非オブジェクト
+ if (obj === null || typeof obj !== 'object') {
+ return JSON5.stringify(obj);
+ }
+
+ // オブジェクトの処理
+ const indent = INDENT_STR.repeat(depth);
+ const childIndent = INDENT_STR.repeat(depth + 1);
+
+ const entries = Object.entries(obj)
+ // 不要なプロパティを除去
+ .filter(([key, value]) => {
+ if (value === undefined) return false;
+ if (key === 'children' && Array.isArray(value) && value.length === 0) return false;
+ if (key === 'inlining') return false;
+ return true;
+ })
+ // 各プロパティを変換
+ .map(([key, value]) => {
+ // 子要素配列の特殊処理
+ if (key === 'children' && Array.isArray(value) && value.length > 0) {
+ return `${childIndent}${key}: ${customStringify(value, depth + 1)}`;
+ }
+
+ // ラベルやその他プロパティを処理
+ return `${childIndent}${key}: ${formatSpecialProperty(key, value)}`;
+ });
+
+ if (entries.length === 0) return '{}';
+ return `{\n${entries.join(',\n')},\n${indent}}`;
+}
+
+/**
+ * 特殊プロパティの書式設定
+ */
+function formatSpecialProperty(key: string, value: any): string {
+ // 値がundefinedの場合は空文字列を返す
+ if (value === undefined) {
+ return '""';
+ }
+
+ // childrenが配列の場合は特別に処理
+ if (key === 'children' && Array.isArray(value)) {
+ return customStringify(value);
+ }
+
+ // keywordsが配列の場合、特別に処理
+ if (key === 'keywords' && Array.isArray(value)) {
+ return `[${formatArrayForOutput(value)}]`;
+ }
+
+ // 文字列値の場合の特別処理
+ if (typeof value === 'string') {
+ // i18n.ts 参照を含む場合 - クォートなしでそのまま出力
+ if (isI18nReference(value)) {
+ logger.info(`Preserving i18n reference in output: ${value}`);
+ return value;
+ }
+
+ // keywords が配列リテラルの形式の場合
+ if (key === 'keywords' && value.startsWith('[') && value.endsWith(']')) {
+ return value;
+ }
+ }
+
+ // 上記以外は通常の JSON5 文字列として返す
+ return JSON5.stringify(value);
+}
+
+/**
+ * 配列式の文字列表現を生成
+ */
+function formatArrayForOutput(items: any[]): string {
+ return items.map(item => {
+ // i18n.ts. 参照の文字列はそのままJavaScript式として出力
+ if (typeof item === 'string' && isI18nReference(item)) {
+ logger.info(`Preserving i18n reference in array: ${item}`);
+ return item; // クォートなしでそのまま
+ }
+
+ // その他の値はJSON5形式で文字列化
+ return JSON5.stringify(item);
+ }).join(', ');
+}
+
+/**
+ * 要素ノードからテキスト内容を抽出する
+ * 各抽出方法を分離して可読性を向上
+ */
+function extractElementText(node: VueAstNode): string | null {
+ if (!node) return null;
+
+ logger.info(`Extracting text from node type=${node.type}, tag=${node.tag || 'unknown'}`);
+
+ // 1. 直接コンテンツの抽出を試行
+ const directContent = extractDirectContent(node);
+ if (directContent) return directContent;
+
+ // 子要素がない場合は終了
+ if (!node.children || !Array.isArray(node.children)) {
+ return null;
+ }
+
+ // 2. インターポレーションノードを検索
+ const interpolationContent = extractInterpolationContent(node.children);
+ if (interpolationContent) return interpolationContent;
+
+ // 3. 式ノードを検索
+ const expressionContent = extractExpressionContent(node.children);
+ if (expressionContent) return expressionContent;
+
+ // 4. テキストノードを検索
+ const textContent = extractTextContent(node.children);
+ if (textContent) return textContent;
+
+ // 5. 再帰的に子ノードを探索
+ return extractNestedContent(node.children);
+}
+/**
+ * ノードから直接コンテンツを抽出
+ */
+function extractDirectContent(node: VueAstNode): string | null {
+ if (!node.content) return null;
+
+ const content = typeof node.content === 'string'
+ ? node.content.trim()
+ : (node.content.content ? node.content.content.trim() : null);
+
+ if (!content) return null;
+
+ logger.info(`Direct node content found: ${content}`);
+
+ // Mustache構文のチェック
+ const mustachePattern = /^\s*{{\s*(.*?)\s*}}\s*$/;
+ const mustacheMatch = content.match(mustachePattern);
+
+ if (mustacheMatch && mustacheMatch[1] && isI18nReference(mustacheMatch[1])) {
+ const extractedContent = mustacheMatch[1].trim();
+ logger.info(`Extracted i18n reference from mustache: ${extractedContent}`);
+ return extractedContent;
+ }
+
+ // 直接i18n参照を含む場合
+ if (isI18nReference(content)) {
+ logger.info(`Direct i18n reference found: ${content}`);
+ return content;
+ }
+
+ // その他のコンテンツ
+ return content;
+}
+
+/**
+ * インターポレーションノード(Mustache)からコンテンツを抽出
+ */
+function extractInterpolationContent(children: VueAstNode[]): string | null {
+ for (const child of children) {
+ if (child.type === NODE_TYPES.INTERPOLATION) {
+ logger.info(`Found interpolation node (Mustache): ${JSON.stringify(child.content).substring(0, 100)}...`);
+
+ if (child.content && child.content.type === 4 && child.content.content) {
+ const content = child.content.content.trim();
+ logger.info(`Interpolation content: ${content}`);
+
+ if (isI18nReference(content)) {
+ return content;
+ }
+ } else if (child.content && typeof child.content === 'object') {
+ // オブジェクト形式のcontentを探索
+ logger.info(`Complex interpolation node: ${JSON.stringify(child.content).substring(0, 100)}...`);
+
+ if (child.content.content) {
+ const content = child.content.content.trim();
+
+ if (isI18nReference(content)) {
+ logger.info(`Found i18n reference in complex interpolation: ${content}`);
+ return content;
+ }
+ }
+ }
+ }
+ }
+
+ return null;
+}
+
+/**
+ * 式ノードからコンテンツを抽出
+ */
+function extractExpressionContent(children: VueAstNode[]): string | null {
+ // i18n.ts. 参照パターンを持つものを優先
+ for (const child of children) {
+ if (child.type === NODE_TYPES.EXPRESSION && child.content) {
+ const expr = child.content.trim();
+
+ if (isI18nReference(expr)) {
+ logger.info(`Found i18n reference in expression node: ${expr}`);
+ return expr;
+ }
+ }
+ }
+
+ // その他の式
+ for (const child of children) {
+ if (child.type === NODE_TYPES.EXPRESSION && child.content) {
+ const expr = child.content.trim();
+ logger.info(`Found expression: ${expr}`);
+ return expr;
+ }
+ }
+
+ return null;
+}
+
+/**
+ * テキストノードからコンテンツを抽出
+ */
+function extractTextContent(children: VueAstNode[]): string | null {
+ for (const child of children) {
+ if (child.type === NODE_TYPES.TEXT && child.content) {
+ const text = child.content.trim();
+
+ if (text) {
+ logger.info(`Found text node: ${text}`);
+
+ // Mustache構文のチェック
+ const mustachePattern = /^\s*{{\s*(.*?)\s*}}\s*$/;
+ const mustacheMatch = text.match(mustachePattern);
+
+ if (mustacheMatch && mustacheMatch[1] && isI18nReference(mustacheMatch[1])) {
+ logger.info(`Extracted i18n ref from text mustache: ${mustacheMatch[1]}`);
+ return mustacheMatch[1].trim();
+ }
+
+ return text;
+ }
+ }
+ }
+
+ return null;
+}
+
+/**
+ * 子ノードを再帰的に探索してコンテンツを抽出
+ */
+function extractNestedContent(children: VueAstNode[]): string | null {
+ for (const child of children) {
+ if (child.children && Array.isArray(child.children) && child.children.length > 0) {
+ const nestedContent = extractElementText(child);
+
+ if (nestedContent) {
+ logger.info(`Found nested content: ${nestedContent}`);
+ return nestedContent;
+ }
+ } else if (child.type === NODE_TYPES.ELEMENT) {
+ // childrenがなくても内部を調査
+ const nestedContent = extractElementText(child);
+
+ if (nestedContent) {
+ logger.info(`Found content in childless element: ${nestedContent}`);
+ return nestedContent;
+ }
+ }
+ }
+
+ return null;
+}
+
+
+/**
+ * SearchLabelとSearchKeywordを探して抽出する関数
+ */
+function extractLabelsAndKeywords(nodes: VueAstNode[]): { label: string | null, keywords: any[] } {
+ let label: string | null = null;
+ const keywords: any[] = [];
+
+ logger.info(`Extracting labels and keywords from ${nodes.length} nodes`);
+
+ // 再帰的にSearchLabelとSearchKeywordを探索(ネストされたSearchMarkerは処理しない)
+ function findComponents(nodes: VueAstNode[]) {
+ for (const node of nodes) {
+ if (node.type === NODE_TYPES.ELEMENT) {
+ logger.info(`Checking element: ${node.tag}`);
+
+ // SearchMarkerの場合は、その子要素は別スコープなのでスキップ
+ if (node.tag === 'SearchMarker') {
+ logger.info(`Found nested SearchMarker - skipping its content to maintain scope isolation`);
+ continue; // このSearchMarkerの中身は処理しない (スコープ分離)
+ }
+
+ // SearchLabelの処理
+ if (node.tag === 'SearchLabel') {
+ logger.info(`Found SearchLabel node, structure: ${JSON.stringify(node).substring(0, 200)}...`);
+
+ // まず完全なノード内容の抽出を試みる
+ const content = extractElementText(node);
+ if (content) {
+ label = content;
+ logger.info(`SearchLabel content extracted: ${content}`);
+ } else {
+ logger.info(`SearchLabel found but extraction failed, trying direct children inspection`);
+
+ // バックアップ: 子直接確認 - type=5のMustacheインターポレーションを重点的に確認
+ if (node.children && Array.isArray(node.children)) {
+ for (const child of node.children) {
+ // Mustacheインターポレーション
+ if (child.type === NODE_TYPES.INTERPOLATION && child.content) {
+ // content内の式を取り出す
+ const expression = child.content.content ||
+ (child.content.type === 4 ? child.content.content : null) ||
+ JSON.stringify(child.content);
+
+ logger.info(`Interpolation expression: ${expression}`);
+ if (typeof expression === 'string' && isI18nReference(expression)) {
+ label = expression.trim();
+ logger.info(`Found i18n in interpolation: ${label}`);
+ break;
+ }
+ }
+ // 式ノード
+ else if (child.type === NODE_TYPES.EXPRESSION && child.content && isI18nReference(child.content)) {
+ label = child.content.trim();
+ logger.info(`Found i18n in expression: ${label}`);
+ break;
+ }
+ // テキストノードでもMustache構文を探す
+ else if (child.type === NODE_TYPES.TEXT && child.content) {
+ const mustacheMatch = child.content.trim().match(/^\s*{{\s*(.*?)\s*}}\s*$/);
+ if (mustacheMatch && mustacheMatch[1] && isI18nReference(mustacheMatch[1])) {
+ label = mustacheMatch[1].trim();
+ logger.info(`Found i18n in text mustache: ${label}`);
+ break;
+ }
+ }
+ }
+ }
+ }
+ }
+ // SearchKeywordの処理
+ else if (node.tag === 'SearchKeyword') {
+ logger.info(`Found SearchKeyword node`);
+
+ // まず完全なノード内容の抽出を試みる
+ const content = extractElementText(node);
+ if (content) {
+ keywords.push(content);
+ logger.info(`SearchKeyword content extracted: ${content}`);
+ } else {
+ logger.info(`SearchKeyword found but extraction failed, trying direct children inspection`);
+
+ // バックアップ: 子直接確認 - type=5のMustacheインターポレーションを重点的に確認
+ if (node.children && Array.isArray(node.children)) {
+ for (const child of node.children) {
+ // Mustacheインターポレーション
+ if (child.type === NODE_TYPES.INTERPOLATION && child.content) {
+ // content内の式を取り出す
+ const expression = child.content.content ||
+ (child.content.type === 4 ? child.content.content : null) ||
+ JSON.stringify(child.content);
+
+ logger.info(`Keyword interpolation: ${expression}`);
+ if (typeof expression === 'string' && isI18nReference(expression)) {
+ const keyword = expression.trim();
+ keywords.push(keyword);
+ logger.info(`Found i18n keyword in interpolation: ${keyword}`);
+ break;
+ }
+ }
+ // 式ノード
+ else if (child.type === NODE_TYPES.EXPRESSION && child.content && isI18nReference(child.content)) {
+ const keyword = child.content.trim();
+ keywords.push(keyword);
+ logger.info(`Found i18n keyword in expression: ${keyword}`);
+ break;
+ }
+ // テキストノードでもMustache構文を探す
+ else if (child.type === NODE_TYPES.TEXT && child.content) {
+ const mustacheMatch = child.content.trim().match(/^\s*{{\s*(.*?)\s*}}\s*$/);
+ if (mustacheMatch && mustacheMatch[1] && isI18nReference(mustacheMatch[1])) {
+ const keyword = mustacheMatch[1].trim();
+ keywords.push(keyword);
+ logger.info(`Found i18n keyword in text mustache: ${keyword}`);
+ break;
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // 子要素を再帰的に調査(ただしSearchMarkerは除外)
+ if (node.children && Array.isArray(node.children)) {
+ findComponents(node.children);
+ }
+ }
+ }
+ }
+
+ findComponents(nodes);
+
+ // デバッグ情報
+ logger.info(`Extraction completed: label=${label}, keywords=[${keywords.join(', ')}]`);
+ return { label, keywords };
+}
+
+
+function extractUsageInfoFromTemplateAst(
+ templateAst: any,
+ id: string,
+): SearchIndexItem[] {
+ const allMarkers: SearchIndexItem[] = [];
+ const markerMap = new Map();
+ const childrenIds = new Set();
+ const normalizedId = id.replace(/\\/g, '/');
+
+ if (!templateAst) return allMarkers;
+
+ // マーカーの基本情報を収集
+ function collectMarkers(node: VueAstNode, parentId: string | null = null) {
+ if (node.type === 1 && node.tag === 'SearchMarker') {
+ // マーカーID取得
+ const markerIdProp = node.props?.find((p: any) => p.name === 'markerId');
+ const markerId = markerIdProp?.value?.content ||
+ node.__markerId;
+
+ // SearchMarkerにマーカーIDがない場合はエラー
+ if (markerId == null) {
+ logger.error(`Marker ID not found for node: ${JSON.stringify(node)}`);
+ throw new Error(`Marker ID not found in file ${id}`);
+ }
+
+ // マーカー基本情報
+ const markerInfo: SearchIndexItem = {
+ id: markerId,
+ children: [],
+ label: '', // デフォルト値
+ keywords: [],
+ };
+
+ // 静的プロパティを取得
+ if (node.props && Array.isArray(node.props)) {
+ for (const prop of node.props) {
+ if (prop.type === 6 && prop.name && prop.name !== 'markerId') {
+ if (prop.name === 'path') markerInfo.path = prop.value?.content || '';
+ else if (prop.name === 'icon') markerInfo.icon = prop.value?.content || '';
+ else if (prop.name === 'label') markerInfo.label = prop.value?.content || '';
+ }
+ }
+ }
+
+ // バインドプロパティを取得
+ const bindings = extractNodeBindings(node);
+ if (bindings.path) markerInfo.path = bindings.path;
+ if (bindings.icon) markerInfo.icon = bindings.icon;
+ if (bindings.label) markerInfo.label = bindings.label;
+ if (bindings.children) markerInfo.children = bindings.children;
+ if (bindings.inlining) {
+ markerInfo.inlining = bindings.inlining;
+ logger.info(`Added inlining ${JSON.stringify(bindings.inlining)} to marker ${markerId}`);
+ }
+ if (bindings.keywords) {
+ if (Array.isArray(bindings.keywords)) {
+ markerInfo.keywords = bindings.keywords;
+ } else {
+ markerInfo.keywords = bindings.keywords || [];
+ }
+ }
+
+ //pathがない場合はファイルパスを設定
+ if (markerInfo.path == null && parentId == null) {
+ markerInfo.path = normalizedId.match(/.*(\/(admin|settings)\/[^\/]+)\.vue$/)?.[1];
+ }
+
+ // SearchLabelとSearchKeywordを抽出 (AST全体を探索)
+ if (node.children && Array.isArray(node.children)) {
+ logger.info(`Processing marker ${markerId} for labels and keywords`);
+ const extracted = extractLabelsAndKeywords(node.children);
+
+ // SearchLabelからのラベル取得は最優先で適用
+ if (extracted.label) {
+ markerInfo.label = extracted.label;
+ logger.info(`Using extracted label for ${markerId}: ${extracted.label}`);
+ } else if (markerInfo.label) {
+ logger.info(`Using existing label for ${markerId}: ${markerInfo.label}`);
+ } else {
+ markerInfo.label = 'Unnamed marker';
+ logger.info(`No label found for ${markerId}, using default`);
+ }
+
+ // SearchKeywordからのキーワード取得を追加
+ if (extracted.keywords.length > 0) {
+ const existingKeywords = Array.isArray(markerInfo.keywords) ?
+ [...markerInfo.keywords] :
+ (markerInfo.keywords ? [markerInfo.keywords] : []);
+
+ // i18n参照のキーワードは最優先で追加
+ const combinedKeywords = [...existingKeywords];
+ for (const kw of extracted.keywords) {
+ combinedKeywords.push(kw);
+ logger.info(`Added extracted keyword to ${markerId}: ${kw}`);
+ }
+
+ markerInfo.keywords = combinedKeywords;
+ }
+ }
+
+ // マーカーを登録
+ markerMap.set(markerId, markerInfo);
+ allMarkers.push(markerInfo);
+
+ // 親子関係を記録
+ if (parentId) {
+ const parent = markerMap.get(parentId);
+ if (parent) {
+ childrenIds.add(markerId);
+ }
+ }
+
+ // 子ノードを処理
+ if (node.children && Array.isArray(node.children)) {
+ node.children.forEach((child: VueAstNode) => {
+ collectMarkers(child, markerId);
+ });
+ }
+
+ return markerId;
+ }
+ // SearchMarkerでない場合は再帰的に子ノードを処理
+ else if (node.children && Array.isArray(node.children)) {
+ node.children.forEach((child: VueAstNode) => {
+ collectMarkers(child, parentId);
+ });
+ }
+
+ return null;
+ }
+
+ // AST解析開始
+ collectMarkers(templateAst);
+ return allMarkers;
+}
+
+// バインドプロパティの処理を修正する関数
+function extractNodeBindings(node: VueAstNode): Record {
+ const bindings: Record = {};
+
+ if (!node.props || !Array.isArray(node.props)) return bindings;
+
+ // バインド式を収集
+ for (const prop of node.props) {
+ if (prop.type === 7 && prop.name === 'bind' && prop.arg?.content) {
+ const propName = prop.arg.content;
+ const propContent = prop.exp?.content || '';
+
+ logger.info(`Processing bind prop ${propName}: ${propContent}`);
+
+ // inliningプロパティの処理を追加
+ if (propName === 'inlining') {
+ try {
+ const content = propContent.trim();
+
+ // 配列式の場合
+ if (content.startsWith('[') && content.endsWith(']')) {
+ // 配列要素を解析
+ const elements = parseArrayExpression(content);
+ if (elements.length > 0) {
+ bindings.inlining = elements;
+ logger.info(`Parsed inlining array: ${JSON5.stringify(elements)}`);
+ } else {
+ bindings.inlining = [];
+ }
+ }
+ // 文字列の場合は配列に変換
+ else if (content) {
+ bindings.inlining = [content]; // 単一の値を配列に
+ logger.info(`Converting inlining to array: [${content}]`);
+ }
+ } catch (e) {
+ logger.error(`Failed to parse inlining binding: ${propContent}`, e);
+ }
+ }
+ // keywordsの特殊処理
+ if (propName === 'keywords') {
+ try {
+ const content = propContent.trim();
+
+ // 配列式の場合
+ if (content.startsWith('[') && content.endsWith(']')) {
+ // i18n参照や特殊な式を保持するため、各要素を個別に解析
+ const elements = parseArrayExpression(content);
+ if (elements.length > 0) {
+ bindings.keywords = elements;
+ logger.info(`Parsed keywords array: ${JSON5.stringify(elements)}`);
+ } else {
+ bindings.keywords = [];
+ logger.info('Empty keywords array');
+ }
+ }
+ // その他の式(非配列)
+ else if (content) {
+ bindings.keywords = content; // 式をそのまま保持
+ logger.info(`Keeping keywords as expression: ${content}`);
+ } else {
+ bindings.keywords = [];
+ logger.info('No keywords provided');
+ }
+ } catch (e) {
+ logger.error(`Failed to parse keywords binding: ${propContent}`, e);
+ // エラーが起きても何らかの値を設定
+ bindings.keywords = propContent || [];
+ }
+ }
+ // その他のプロパティ
+ else if (propName === 'label') {
+ // ラベルの場合も式として保持
+ bindings[propName] = propContent;
+ logger.info(`Set label from bind expression: ${propContent}`);
+ }
+ else {
+ bindings[propName] = propContent;
+ }
+ }
+ }
+
+ return bindings;
+}
+
+// 配列式をパースする補助関数(文字列リテラル処理を改善)
+function parseArrayExpression(expr: string): any[] {
+ try {
+ // 単純なケースはJSON5でパースを試みる
+ return JSON5.parse(expr.replace(/'/g, '"'));
+ } catch (e) {
+ // 複雑なケース(i18n.ts.xxx などの式を含む場合)は手動パース
+ logger.info(`Complex array expression, trying manual parsing: ${expr}`);
+
+ // "["と"]"を取り除く
+ const content = expr.substring(1, expr.length - 1).trim();
+ if (!content) return [];
+
+ const result: any[] = [];
+ let currentItem = '';
+ let depth = 0;
+ let inString = false;
+ let stringChar = '';
+
+ // カンマで区切る(ただし文字列内や入れ子の配列内のカンマは無視)
+ for (let i = 0; i < content.length; i++) {
+ const char = content[i];
+
+ if (inString) {
+ if (char === stringChar && content[i - 1] !== '\\') {
+ inString = false;
+ }
+ currentItem += char;
+ } else if (char === '"' || char === "'") {
+ inString = true;
+ stringChar = char;
+ currentItem += char;
+ } else if (char === '[') {
+ depth++;
+ currentItem += char;
+ } else if (char === ']') {
+ depth--;
+ currentItem += char;
+ } else if (char === ',' && depth === 0) {
+ // 項目の区切りを検出
+ const trimmed = currentItem.trim();
+
+ // 純粋な文字列リテラルの場合、実際の値に変換
+ if ((trimmed.startsWith("'") && trimmed.endsWith("'")) ||
+ (trimmed.startsWith('"') && trimmed.endsWith('"'))) {
+ try {
+ result.push(JSON5.parse(trimmed));
+ } catch (err) {
+ result.push(trimmed);
+ }
+ } else {
+ // それ以外の式はそのまま(i18n.ts.xxx など)
+ result.push(trimmed);
+ }
+
+ currentItem = '';
+ } else {
+ currentItem += char;
+ }
+ }
+
+ // 最後の項目を処理
+ if (currentItem.trim()) {
+ const trimmed = currentItem.trim();
+
+ // 純粋な文字列リテラルの場合、実際の値に変換
+ if ((trimmed.startsWith("'") && trimmed.endsWith("'")) ||
+ (trimmed.startsWith('"') && trimmed.endsWith('"'))) {
+ try {
+ result.push(JSON5.parse(trimmed));
+ } catch (err) {
+ result.push(trimmed);
+ }
+ } else {
+ // それ以外の式はそのまま(i18n.ts.xxx など)
+ result.push(trimmed);
+ }
+ }
+
+ logger.info(`Parsed complex array expression: ${expr} -> ${JSON.stringify(result)}`);
+ return result;
+ }
+}
+
+export async function analyzeVueProps(options: Options & {
+ transformedCodeCache: Record,
+}): Promise {
+ initLogger(options);
+
+ const allMarkers: SearchIndexItem[] = [];
+
+ // 対象ファイルパスを glob で展開
+ const filePaths = options.targetFilePaths.reduce((acc, filePathPattern) => {
+ const matchedFiles = glob.sync(filePathPattern);
+ return [...acc, ...matchedFiles];
+ }, []);
+
+ logger.info(`Found ${filePaths.length} matching files to analyze`);
+
+ for (const filePath of filePaths) {
+ const absolutePath = path.join(process.cwd(), filePath);
+ const id = absolutePath.replace(/\\/g, '/'); // 絶対パスに変換
+ const code = options.transformedCodeCache[id]; // options 経由でキャッシュ参照
+ if (!code) { // キャッシュミスの場合
+ logger.error(`Error: No cached code found for: ${id}.`); // エラーログ
+ throw new Error(`No cached code found for: ${id}.`); // エラーを投げる
+ }
+
+ try {
+ const { descriptor, errors } = vueSfcParse(options.transformedCodeCache[id], {
+ filename: filePath,
+ });
+
+ if (errors.length > 0) {
+ logger.error(`Compile Error: ${filePath}, ${errors}`);
+ continue; // エラーが発生したファイルはスキップ
+ }
+
+ const fileMarkers = extractUsageInfoFromTemplateAst(descriptor.template?.ast, id);
+
+ if (fileMarkers && fileMarkers.length > 0) {
+ allMarkers.push(...fileMarkers); // すべてのマーカーを収集
+ logger.info(`Successfully extracted ${fileMarkers.length} markers from ${filePath}`);
+ } else {
+ logger.info(`No markers found in ${filePath}`);
+ }
+ } catch (error) {
+ logger.error(`Error analyzing file ${filePath}:`, error);
+ }
+ }
+
+ // 収集したすべてのマーカー情報を使用
+ const analysisResult: AnalysisResult[] = [
+ {
+ filePath: "combined-markers", // すべてのファイルのマーカーを1つのエントリとして扱う
+ usage: allMarkers,
+ }
+ ];
+
+ outputAnalysisResultAsTS(options.exportFilePath, analysisResult); // すべてのマーカー情報を渡す
+}
+
+interface MarkerRelation {
+ parentId?: string;
+ markerId: string;
+ node: VueAstNode;
+}
+
+async function processVueFile(
+ code: string,
+ id: string,
+ options: Options,
+ transformedCodeCache: Record
+): Promise<{
+ code: string,
+ map: any,
+ transformedCodeCache: Record
+}> {
+ const normalizedId = id.replace(/\\/g, '/'); // ファイルパスを正規化
+ // すでにキャッシュに存在する場合は、そのまま返す
+ if (transformedCodeCache[normalizedId] && transformedCodeCache[normalizedId].includes('markerId=')) {
+ logger.info(`Using cached version for ${id}`);
+ return {
+ code: transformedCodeCache[normalizedId],
+ map: null,
+ transformedCodeCache
+ };
+ }
+
+ const s = new MagicString(code); // magic-string のインスタンスを作成
+ const parsed = vueSfcParse(code, { filename: id });
+ if (!parsed.descriptor.template) {
+ return {
+ code,
+ map: null,
+ transformedCodeCache
+ };
+ }
+ const ast = parsed.descriptor.template.ast; // テンプレート AST を取得
+ const markerRelations: MarkerRelation[] = []; // MarkerRelation 配列を初期化
+
+ if (ast) {
+ function traverse(node: any, currentParent?: any) {
+ if (node.type === 1 && node.tag === 'SearchMarker') {
+ // 行番号はコード先頭からの改行数で取得
+ const lineNumber = code.slice(0, node.loc.start.offset).split('\n').length;
+ // ファイルパスと行番号からハッシュ値を生成
+ // この際実行環境で差が出ないようにファイルパスを正規化
+ const idKey = id.replace(/\\/g, '/').split('packages/frontend/')[1]
+ const generatedMarkerId = toBase62(hash(`${idKey}:${lineNumber}`));
+
+ const props = node.props || [];
+ const hasMarkerIdProp = props.some((prop: any) => prop.type === 6 && prop.name === 'markerId');
+ const nodeMarkerId = hasMarkerIdProp
+ ? props.find((prop: any) => prop.type === 6 && prop.name === 'markerId')?.value?.content as string
+ : generatedMarkerId;
+ node.__markerId = nodeMarkerId;
+
+ // 子マーカーの場合、親ノードに __children を設定しておく
+ if (currentParent && currentParent.type === 1 && currentParent.tag === 'SearchMarker') {
+ currentParent.__children = currentParent.__children || [];
+ currentParent.__children.push(nodeMarkerId);
+ }
+
+ const parentMarkerId = currentParent && currentParent.__markerId;
+ markerRelations.push({
+ parentId: parentMarkerId,
+ markerId: nodeMarkerId,
+ node: node,
+ });
+
+ if (!hasMarkerIdProp) {
+ const nodeStart = node.loc.start.offset;
+ let endOfStartTag;
+
+ if (node.children && node.children.length > 0) {
+ // 子要素がある場合、最初の子要素の開始位置を基準にする
+ endOfStartTag = code.lastIndexOf('>', node.children[0].loc.start.offset);
+ } else if (node.loc.end.offset > nodeStart) {
+ // 子要素がない場合、自身の終了位置から逆算
+ const nodeSource = code.substring(nodeStart, node.loc.end.offset);
+ // 自己終了タグか通常の終了タグかを判断
+ if (nodeSource.includes('/>')) {
+ endOfStartTag = code.indexOf('/>', nodeStart) - 1;
+ } else {
+ endOfStartTag = code.indexOf('>', nodeStart);
+ }
+ }
+
+ if (endOfStartTag !== undefined && endOfStartTag !== -1) {
+ // markerId が既に存在しないことを確認
+ const tagText = code.substring(nodeStart, endOfStartTag + 1);
+ const markerIdRegex = /\s+markerId\s*=\s*["'][^"']*["']/;
+
+ if (!markerIdRegex.test(tagText)) {
+ s.appendRight(endOfStartTag, ` markerId="${generatedMarkerId}" data-in-app-search-marker-id="${generatedMarkerId}"`);
+ logger.info(`Adding markerId="${generatedMarkerId}" to ${id}:${lineNumber}`);
+ } else {
+ logger.info(`markerId already exists in ${id}:${lineNumber}`);
+ }
+ }
+ }
+ }
+
+ const newParent = node.type === 1 && node.tag === 'SearchMarker' ? node : currentParent;
+ if (node.children && Array.isArray(node.children)) {
+ node.children.forEach(child => traverse(child, newParent));
+ }
+ }
+
+ traverse(ast); // AST を traverse (1段階目: ID 生成と親子関係記録)
+
+ // 2段階目: :children 属性の追加
+ // 最初に親マーカーごとに子マーカーIDを集約する処理を追加
+ const parentChildrenMap = new Map();
+
+ // 1. まず親ごとのすべての子マーカーIDを収集
+ markerRelations.forEach(relation => {
+ if (relation.parentId) {
+ if (!parentChildrenMap.has(relation.parentId)) {
+ parentChildrenMap.set(relation.parentId, []);
+ }
+ parentChildrenMap.get(relation.parentId)?.push(relation.markerId);
+ }
+ });
+
+ // 2. 親ごとにまとめて :children 属性を処理
+ for (const [parentId, childIds] of parentChildrenMap.entries()) {
+ const parentRelation = markerRelations.find(r => r.markerId === parentId);
+ if (!parentRelation || !parentRelation.node) continue;
+
+ const parentNode = parentRelation.node;
+ const childrenProp = parentNode.props?.find((prop: any) => prop.type === 7 && prop.name === 'bind' && prop.arg?.content === 'children');
+
+ // 親ノードの開始位置を特定
+ const parentNodeStart = parentNode.loc!.start.offset;
+ const endOfParentStartTag = parentNode.children && parentNode.children.length > 0
+ ? code.lastIndexOf('>', parentNode.children[0].loc!.start.offset)
+ : code.indexOf('>', parentNodeStart);
+
+ if (endOfParentStartTag === -1) continue;
+
+ // 親タグのテキストを取得
+ const parentTagText = code.substring(parentNodeStart, endOfParentStartTag + 1);
+
+ if (childrenProp) {
+ // AST で :children 属性が検出された場合、それを更新
+ try {
+ const childrenStart = code.indexOf('[', childrenProp.exp!.loc.start.offset);
+ const childrenEnd = code.indexOf(']', childrenProp.exp!.loc.start.offset);
+ if (childrenStart !== -1 && childrenEnd !== -1) {
+ const childrenArrayStr = code.slice(childrenStart, childrenEnd + 1);
+ let childrenArray = JSON5.parse(childrenArrayStr.replace(/'/g, '"'));
+
+ // 新しいIDを追加(重複は除外)
+ const newIds = childIds.filter(id => !childrenArray.includes(id));
+ if (newIds.length > 0) {
+ childrenArray = [...childrenArray, ...newIds];
+ const updatedChildrenArrayStr = JSON5.stringify(childrenArray).replace(/"/g, "'");
+ s.overwrite(childrenStart, childrenEnd + 1, updatedChildrenArrayStr);
+ logger.info(`Added ${newIds.length} child markerIds to existing :children in ${id}`);
+ }
+ }
+ } catch (e) {
+ logger.error('Error updating :children attribute:', e);
+ }
+ } else {
+ // AST では検出されなかった場合、タグテキストを調べる
+ const childrenRegex = /:children\s*=\s*["']\[(.*?)\]["']/;
+ const childrenMatch = parentTagText.match(childrenRegex);
+
+ if (childrenMatch) {
+ // テキストから :children 属性値を解析して更新
+ try {
+ const childrenContent = childrenMatch[1];
+ const childrenArrayStr = `[${childrenContent}]`;
+ const childrenArray = JSON5.parse(childrenArrayStr.replace(/'/g, '"'));
+
+ // 新しいIDを追加(重複は除外)
+ const newIds = childIds.filter(id => !childrenArray.includes(id));
+ if (newIds.length > 0) {
+ childrenArray.push(...newIds);
+
+ // :children="[...]" の位置を特定して上書き
+ const attrStart = parentTagText.indexOf(':children=');
+ if (attrStart > -1) {
+ const attrValueStart = parentTagText.indexOf('[', attrStart);
+ const attrValueEnd = parentTagText.indexOf(']', attrValueStart) + 1;
+ if (attrValueStart > -1 && attrValueEnd > -1) {
+ const absoluteStart = parentNodeStart + attrValueStart;
+ const absoluteEnd = parentNodeStart + attrValueEnd;
+ const updatedArrayStr = JSON5.stringify(childrenArray).replace(/"/g, "'");
+ s.overwrite(absoluteStart, absoluteEnd, updatedArrayStr);
+ logger.info(`Updated existing :children in tag text for ${id}`);
+ }
+ }
+ }
+ } catch (e) {
+ logger.error('Error updating :children in tag text:', e);
+ }
+ } else {
+ // :children 属性がまだない場合、新規作成
+ s.appendRight(endOfParentStartTag, ` :children="${JSON5.stringify(childIds).replace(/"/g, "'")}"`);
+ logger.info(`Created new :children attribute with ${childIds.length} markerIds in ${id}`);
+ }
+ }
+ }
+ }
+
+ const transformedCode = s.toString(); // 変換後のコードを取得
+ transformedCodeCache[normalizedId] = transformedCode; // 変換後のコードをキャッシュに保存
+
+ return {
+ code: transformedCode, // 変更後のコードを返す
+ map: s.generateMap({ source: id, includeContent: true }), // ソースマップも生成 (sourceMap: true が必要)
+ transformedCodeCache // キャッシュも返す
+ };
+}
+
+
+// Rollup プラグインとして export
+export default function pluginCreateSearchIndex(options: Options): Plugin {
+ let transformedCodeCache: Record = {}; // キャッシュオブジェクトをプラグインスコープで定義
+ const isDevServer = process.env.NODE_ENV === 'development'; // 開発サーバーかどうか
+
+ initLogger(options); // ロガーを初期化
+
+ return {
+ name: 'createSearchIndex',
+ enforce: 'pre',
+
+ async buildStart() {
+ if (!isDevServer) {
+ return;
+ }
+
+ const filePaths = options.targetFilePaths.reduce((acc, filePathPattern) => {
+ const matchedFiles = glob.sync(filePathPattern);
+ return [...acc, ...matchedFiles];
+ }, []);
+
+ for (const filePath of filePaths) {
+ const id = path.resolve(filePath); // 絶対パスに変換
+ const code = fs.readFileSync(filePath, 'utf-8'); // ファイル内容を読み込む
+ const { transformedCodeCache: newCache } = await processVueFile(code, id, options, transformedCodeCache); // processVueFile 関数を呼び出す
+ transformedCodeCache = newCache; // キャッシュを更新
+ }
+
+ await analyzeVueProps({ ...options, transformedCodeCache }); // 開発サーバー起動時にも analyzeVueProps を実行
+ },
+
+ async transform(code, id) {
+ if (!id.endsWith('.vue')) {
+ return;
+ }
+
+ // targetFilePaths にマッチするファイルのみ処理を行う
+ // glob パターンでマッチング
+ let isMatch = false; // isMatch の初期値を false に設定
+ for (const pattern of options.targetFilePaths) { // パターンごとにマッチング確認
+ const globbedFiles = glob.sync(pattern);
+ for (const globbedFile of globbedFiles) {
+ const normalizedGlobbedFile = path.resolve(globbedFile); // glob 結果を絶対パスに
+ const normalizedId = path.resolve(id); // id を絶対パスに
+ if (normalizedGlobbedFile === normalizedId) { // 絶対パス同士で比較
+ isMatch = true;
+ break; // マッチしたらループを抜ける
+ }
+ }
+ if (isMatch) break; // いずれかのパターンでマッチしたら、outer loop も抜ける
+ }
+
+
+ if (!isMatch) {
+ return;
+ }
+
+ const transformed = await processVueFile(code, id, options, transformedCodeCache);
+ transformedCodeCache = transformed.transformedCodeCache; // キャッシュを更新
+ if (isDevServer) {
+ await analyzeVueProps({ ...options, transformedCodeCache }); // analyzeVueProps を呼び出す
+ }
+ return transformed;
+ },
+
+ async writeBundle() {
+ await analyzeVueProps({ ...options, transformedCodeCache }); // ビルド時にも analyzeVueProps を実行
+ },
+ };
+}
+
+// i18n参照を検出するためのヘルパー関数を追加
+function isI18nReference(text: string | null | undefined): boolean {
+ if (!text) return false;
+ // ドット記法(i18n.ts.something)
+ const dotPattern = /i18n\.ts\.\w+/;
+ // ブラケット記法(i18n.ts['something'])
+ const bracketPattern = /i18n\.ts\[['"][^'"]+['"]\]/;
+ return dotPattern.test(text) || bracketPattern.test(text);
+}
diff --git a/packages/frontend/package.json b/packages/frontend/package.json
index 2bf7728d0a..5b40a8cb9f 100644
--- a/packages/frontend/package.json
+++ b/packages/frontend/package.json
@@ -51,6 +51,7 @@
"insert-text-at-cursor": "0.3.0",
"is-file-animated": "1.0.2",
"json5": "2.2.3",
+ "magic-string": "0.30.17",
"matter-js": "0.20.0",
"mfm-js": "0.24.0",
"misskey-bubble-game": "workspace:*",
diff --git a/packages/frontend/src/components/MkDisableSection.vue b/packages/frontend/src/components/MkDisableSection.vue
new file mode 100644
index 0000000000..f596d5e3b5
--- /dev/null
+++ b/packages/frontend/src/components/MkDisableSection.vue
@@ -0,0 +1,41 @@
+
+
+
+
+
+
+
+
+
diff --git a/packages/frontend/src/components/MkPageWindow.vue b/packages/frontend/src/components/MkPageWindow.vue
index e725d2a15d..c3fc1961eb 100644
--- a/packages/frontend/src/components/MkPageWindow.vue
+++ b/packages/frontend/src/components/MkPageWindow.vue
@@ -91,6 +91,14 @@ const buttonsRight = computed(() => {
});
const reloadCount = ref(0);
+function getSearchMarker(path: string) {
+ const hash = path.split('#')[1];
+ if (hash == null) return null;
+ return hash;
+}
+
+const searchMarkerId = ref(getSearchMarker(props.initialPath));
+
windowRouter.addListener('push', ctx => {
history.value.push({ path: ctx.path, key: ctx.key });
});
@@ -101,7 +109,8 @@ windowRouter.addListener('replace', ctx => {
});
windowRouter.addListener('change', ctx => {
- console.log('windowRouter: change', ctx.path);
+ if (_DEV_) console.log('windowRouter: change', ctx.path);
+ searchMarkerId.value = getSearchMarker(ctx.path);
analytics.page({
path: ctx.path,
title: ctx.path,
@@ -111,6 +120,7 @@ windowRouter.addListener('change', ctx => {
windowRouter.init();
provide('router', windowRouter);
+provide('inAppSearchMarkerId', searchMarkerId);
provideMetadataReceiver((metadataGetter) => {
const info = metadataGetter();
pageMetadata.value = info;
diff --git a/packages/frontend/src/components/MkSuperMenu.vue b/packages/frontend/src/components/MkSuperMenu.vue
index 397aa68ed6..d8dec3aa2f 100644
--- a/packages/frontend/src/components/MkSuperMenu.vue
+++ b/packages/frontend/src/components/MkSuperMenu.vue
@@ -4,27 +4,60 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
-
-
-
{{ group.title }}
-
-
-
-
-
- {{ item.text }}
-
-
-
-
- {{ item.text }}
-
-
+
+
+
+
+
+
+
+
{{ group.title }}
+
+
+
+
+
+
+
+
+
+
+ {{ item.label }}
+
+
+ {{ item.parentLabels.join(' > ') }}
+
+ {{ item.label }}
+
+
+
-
+
@@ -58,10 +91,98 @@ export type SuperMenuDef = {
diff --git a/packages/frontend/src/components/global/SearchKeyword.vue b/packages/frontend/src/components/global/SearchKeyword.vue
new file mode 100644
index 0000000000..27a284faf0
--- /dev/null
+++ b/packages/frontend/src/components/global/SearchKeyword.vue
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
diff --git a/packages/frontend/src/components/global/SearchLabel.vue b/packages/frontend/src/components/global/SearchLabel.vue
new file mode 100644
index 0000000000..27a284faf0
--- /dev/null
+++ b/packages/frontend/src/components/global/SearchLabel.vue
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
diff --git a/packages/frontend/src/components/global/SearchMarker.vue b/packages/frontend/src/components/global/SearchMarker.vue
new file mode 100644
index 0000000000..c5ec626cf4
--- /dev/null
+++ b/packages/frontend/src/components/global/SearchMarker.vue
@@ -0,0 +1,116 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/frontend/src/components/index.ts b/packages/frontend/src/components/index.ts
index 0252bf0252..ebbad3e5b8 100644
--- a/packages/frontend/src/components/index.ts
+++ b/packages/frontend/src/components/index.ts
@@ -3,8 +3,6 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
-import type { App } from 'vue';
-
import Mfm from './global/MkMfm.js';
import MkA from './global/MkA.vue';
import MkAcct from './global/MkAcct.vue';
@@ -26,6 +24,11 @@ import MkSpacer from './global/MkSpacer.vue';
import MkFooterSpacer from './global/MkFooterSpacer.vue';
import MkStickyContainer from './global/MkStickyContainer.vue';
import MkLazy from './global/MkLazy.vue';
+import SearchMarker from './global/SearchMarker.vue';
+import SearchLabel from './global/SearchLabel.vue';
+import SearchKeyword from './global/SearchKeyword.vue';
+
+import type { App } from 'vue';
export default function(app: App) {
for (const [key, value] of Object.entries(components)) {
@@ -55,6 +58,9 @@ export const components = {
MkFooterSpacer: MkFooterSpacer,
MkStickyContainer: MkStickyContainer,
MkLazy: MkLazy,
+ SearchMarker: SearchMarker,
+ SearchLabel: SearchLabel,
+ SearchKeyword: SearchKeyword,
};
declare module '@vue/runtime-core' {
@@ -80,5 +86,8 @@ declare module '@vue/runtime-core' {
MkFooterSpacer: typeof MkFooterSpacer;
MkStickyContainer: typeof MkStickyContainer;
MkLazy: typeof MkLazy;
+ SearchMarker: typeof SearchMarker;
+ SearchLabel: typeof SearchLabel;
+ SearchKeyword: typeof SearchKeyword;
}
}
diff --git a/packages/frontend/src/pages/settings/2fa.vue b/packages/frontend/src/pages/settings/2fa.vue
index 776f59dda3..806599e801 100644
--- a/packages/frontend/src/pages/settings/2fa.vue
+++ b/packages/frontend/src/pages/settings/2fa.vue
@@ -4,74 +4,82 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
-
- {{ i18n.ts['2fa'] }}
-
-
-
- {{ i18n.ts._2fa.backupCodeUsedWarning }}
-
-
- {{ i18n.ts._2fa.backupCodesExhaustedWarning }}
-
-
-
-
- {{ i18n.ts.totp }}
- {{ i18n.ts.totpDescription }}
-
-
-
-
-
- {{ i18n.ts._2fa.renewTOTP }}
- {{ i18n.ts._2fa.whyTOTPOnlyRenew }}
-
-
{{ i18n.ts.unregister }}
-
-
-
- {{ i18n.ts._2fa.registerTOTP }}
- {{ i18n.ts.learnMore }}
-
-
-
-
-
- {{ i18n.ts.securityKeyAndPasskey }}
-
-
- {{ i18n.ts._2fa.securityKeyInfo }}
-
-
-
- {{ i18n.ts._2fa.securityKeyNotSupported }}
-
-
-
- {{ i18n.ts._2fa.registerTOTPBeforeKey }}
-
-
-
- {{ i18n.ts._2fa.registerSecurityKey }}
-
- {{ key.name }}
-
-
- {{ i18n.ts.rename }}
- {{ i18n.ts.unregister }}
-
-
-
-
-
-
-
updatePasswordLessLogin(v)">
- {{ i18n.ts.passwordLessLogin }}
- {{ i18n.ts.passwordLessLoginDescription }}
-
-
-
+
+
+ {{ i18n.ts['2fa'] }}
+
+
+
+ {{ i18n.ts._2fa.backupCodeUsedWarning }}
+
+
+ {{ i18n.ts._2fa.backupCodesExhaustedWarning }}
+
+
+
+
+
+ {{ i18n.ts.totp }}
+ {{ i18n.ts.totpDescription }}
+
+
+
+
+
+ {{ i18n.ts._2fa.renewTOTP }}
+ {{ i18n.ts._2fa.whyTOTPOnlyRenew }}
+
+
{{ i18n.ts.unregister }}
+
+
+
+ {{ i18n.ts._2fa.registerTOTP }}
+ {{ i18n.ts.learnMore }}
+
+
+
+
+
+
+
+ {{ i18n.ts.securityKeyAndPasskey }}
+
+
+ {{ i18n.ts._2fa.securityKeyInfo }}
+
+
+
+ {{ i18n.ts._2fa.securityKeyNotSupported }}
+
+
+
+ {{ i18n.ts._2fa.registerTOTPBeforeKey }}
+
+
+
+ {{ i18n.ts._2fa.registerSecurityKey }}
+
+ {{ key.name }}
+
+
+ {{ i18n.ts.rename }}
+ {{ i18n.ts.unregister }}
+
+
+
+
+
+
+
+
+ updatePasswordLessLogin(v)">
+ {{ i18n.ts.passwordLessLogin }}
+ {{ i18n.ts.passwordLessLoginDescription }}
+
+
+
+
+
diff --git a/packages/frontend/src/pages/settings/appearance.vue b/packages/frontend/src/pages/settings/appearance.vue
new file mode 100644
index 0000000000..465c2a38c2
--- /dev/null
+++ b/packages/frontend/src/pages/settings/appearance.vue
@@ -0,0 +1,287 @@
+
+
+
+
+
+
+
+
+
+
+ {{ i18n.ts.useBlurEffect }}
+
+
+
+
+
+ {{ i18n.ts.useBlurEffectForModal }}
+
+
+
+
+
+ {{ i18n.ts.highlightSensitiveMedia }}
+
+
+
+
+
+ {{ i18n.ts.squareAvatars }}
+
+
+
+
+
+ {{ i18n.ts.showAvatarDecorations }}
+
+
+
+
+
+ {{ i18n.ts.showGapBetweenNotesInTimeline }}
+
+
+
+
+
+ {{ i18n.ts.useSystemFont }}
+
+
+
+
+
+ {{ i18n.ts.seasonalScreenEffect }}
+
+
+
+
+
+
+ {{ i18n.ts.menuStyle }}
+
+
+
+
+
+
+
+
+
+ {{ i18n.ts.emojiStyle }}
+
+
+
+
+
+
+
+
+
+
+ {{ i18n.ts.fontSize }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ i18n.ts.displayOfNote }}
+
+
+
+
+ {{ i18n.ts.reactionsDisplaySize }}
+
+
+
+
+
+
+
+
+ {{ i18n.ts.limitWidthOfReaction }}
+
+
+
+
+
+ {{ i18n.ts.mediaListWithOneImageAppearance }}
+
+
+
+
+
+
+
+
+
+ {{ i18n.ts.instanceTicker }}
+
+
+
+
+
+
+
+
+ {{ i18n.ts.displayOfSensitiveMedia }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ i18n.ts.notificationDisplay }}
+
+
+
+
+ {{ i18n.ts.position }}
+
+
+
+
+
+
+
+
+
+ {{ i18n.ts.stackAxis }}
+
+
+
+
+
+ {{ i18n.ts._notification.checkNotificationBehavior }}
+
+
+
+
+
+ {{ i18n.ts.customCss }}
+
+
+
+
+
+
diff --git a/packages/frontend/src/pages/settings/avatar-decoration.vue b/packages/frontend/src/pages/settings/avatar-decoration.vue
index 9fca306f9f..79be2b9b1e 100644
--- a/packages/frontend/src/pages/settings/avatar-decoration.vue
+++ b/packages/frontend/src/pages/settings/avatar-decoration.vue
@@ -4,44 +4,46 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
-
-
-
{{ i18n.tsx._profile.avatarDecorationMax({ max: $i.policies.avatarDecorationLimit }) }} ({{ i18n.tsx.remainingN({ n: $i.policies.avatarDecorationLimit - $i.avatarDecorations.length }) }})
-
-
-
-
-
{{ i18n.ts.inUse }}
+
+
+
+
{{ i18n.tsx._profile.avatarDecorationMax({ max: $i.policies.avatarDecorationLimit }) }} ({{ i18n.tsx.remainingN({ n: $i.policies.avatarDecorationLimit - $i.avatarDecorations.length }) }})
+
+
+
+
+
{{ i18n.ts.inUse }}
+
+
+
+
+
+
{{ i18n.ts.detachAll }}
+
-
-
{{ i18n.ts.detachAll }}
-
-
-
-
-
-
+
diff --git a/packages/frontend/src/pages/settings/import-export.vue b/packages/frontend/src/pages/settings/import-export.vue
index 5acbc50756..6b67a9a1a8 100644
--- a/packages/frontend/src/pages/settings/import-export.vue
+++ b/packages/frontend/src/pages/settings/import-export.vue
@@ -4,118 +4,143 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
-
-
- {{ i18n.ts._exportOrImport.allNotes }}
-
- {{ i18n.ts.export }}
-
- {{ i18n.ts.export }}
-
-
-
- {{ i18n.ts._exportOrImport.favoritedNotes }}
-
- {{ i18n.ts.export }}
-
- {{ i18n.ts.export }}
-
-
-
- {{ i18n.ts._exportOrImport.clips }}
-
- {{ i18n.ts.export }}
-
- {{ i18n.ts.export }}
-
-
-
- {{ i18n.ts._exportOrImport.followingList }}
-
-
- {{ i18n.ts.export }}
-
+
+
+
+
+ {{ i18n.ts._exportOrImport.allNotes }}
+
+ {{ i18n.ts.export }}
+
+ {{ i18n.ts.export }}
+
+
+
+
+
+
+ {{ i18n.ts._exportOrImport.favoritedNotes }}
+
+ {{ i18n.ts.export }}
+
+ {{ i18n.ts.export }}
+
+
+
+
+
+
+ {{ i18n.ts._exportOrImport.clips }}
+
+ {{ i18n.ts.export }}
+
+ {{ i18n.ts.export }}
+
+
+
+
+
+
+ {{ i18n.ts._exportOrImport.followingList }}
+
+
+ {{ i18n.ts.export }}
+
+
+
+ {{ i18n.ts._exportOrImport.excludeMutingUsers }}
+
+
+ {{ i18n.ts._exportOrImport.excludeInactiveUsers }}
+
+ {{ i18n.ts.export }}
+
+
+
+ {{ i18n.ts.import }}
+
+
+ {{ i18n.ts._exportOrImport.withReplies }}
+
+ {{ i18n.ts.import }}
+
+
+
+
+
+
+
+ {{ i18n.ts._exportOrImport.userLists }}
+
+
+ {{ i18n.ts.export }}
+
+ {{ i18n.ts.export }}
+
+
+ {{ i18n.ts.import }}
+
+ {{ i18n.ts.import }}
+
+
+
+
+
+
+
+ {{ i18n.ts._exportOrImport.muteList }}
+
+
+ {{ i18n.ts.export }}
+
+ {{ i18n.ts.export }}
+
+
+ {{ i18n.ts.import }}
+
+ {{ i18n.ts.import }}
+
+
+
+
+
+
+
+ {{ i18n.ts._exportOrImport.blockingList }}
+
+
+ {{ i18n.ts.export }}
+
+ {{ i18n.ts.export }}
+
+
+ {{ i18n.ts.import }}
+
+ {{ i18n.ts.import }}
+
+
+
+
+
+
+
+ {{ i18n.ts.antennas }}
-
- {{ i18n.ts._exportOrImport.excludeMutingUsers }}
-
-
- {{ i18n.ts._exportOrImport.excludeInactiveUsers }}
-
- {{ i18n.ts.export }}
+
+ {{ i18n.ts.export }}
+
+ {{ i18n.ts.export }}
+
+
+ {{ i18n.ts.import }}
+
+ {{ i18n.ts.import }}
+
-
-
- {{ i18n.ts.import }}
-
-
- {{ i18n.ts._exportOrImport.withReplies }}
-
- {{ i18n.ts.import }}
-
-
-
-
- {{ i18n.ts._exportOrImport.userLists }}
-
-
- {{ i18n.ts.export }}
-
- {{ i18n.ts.export }}
-
-
- {{ i18n.ts.import }}
-
- {{ i18n.ts.import }}
-
-
-
-
- {{ i18n.ts._exportOrImport.muteList }}
-
-
- {{ i18n.ts.export }}
-
- {{ i18n.ts.export }}
-
-
- {{ i18n.ts.import }}
-
- {{ i18n.ts.import }}
-
-
-
-
- {{ i18n.ts._exportOrImport.blockingList }}
-
-
- {{ i18n.ts.export }}
-
- {{ i18n.ts.export }}
-
-
- {{ i18n.ts.import }}
-
- {{ i18n.ts.import }}
-
-
-
-
- {{ i18n.ts.antennas }}
-
-
- {{ i18n.ts.export }}
-
- {{ i18n.ts.export }}
-
-
- {{ i18n.ts.import }}
-
- {{ i18n.ts.import }}
-
-
-
-
+
+
+
+
diff --git a/packages/frontend/src/components/MkReactionsViewer.reaction.vue b/packages/frontend/src/components/MkReactionsViewer.reaction.vue
index 41e475eade..2e453aeb8f 100644
--- a/packages/frontend/src/components/MkReactionsViewer.reaction.vue
+++ b/packages/frontend/src/components/MkReactionsViewer.reaction.vue
@@ -8,11 +8,11 @@ SPDX-License-Identifier: AGPL-3.0-only
ref="buttonEl"
v-ripple="canToggle"
class="_button"
- :class="[$style.root, { [$style.reacted]: note.myReaction == reaction, [$style.canToggle]: canToggle, [$style.small]: defaultStore.state.reactionsDisplaySize === 'small', [$style.large]: defaultStore.state.reactionsDisplaySize === 'large' }]"
+ :class="[$style.root, { [$style.reacted]: note.myReaction == reaction, [$style.canToggle]: canToggle, [$style.small]: prefer.s.reactionsDisplaySize === 'small', [$style.large]: prefer.s.reactionsDisplaySize === 'large' }]"
@click="toggleReaction()"
@contextmenu.prevent.stop="menu"
>
-
+
{{ count }}
@@ -30,11 +30,11 @@ import { useTooltip } from '@/scripts/use-tooltip.js';
import { $i } from '@/account.js';
import MkReactionEffect from '@/components/MkReactionEffect.vue';
import { claimAchievement } from '@/scripts/achievements.js';
-import { defaultStore } from '@/store.js';
import { i18n } from '@/i18n.js';
import * as sound from '@/scripts/sound.js';
import { checkReactionPermissions } from '@/scripts/check-reaction-permissions.js';
import { customEmojisMap } from '@/custom-emojis.js';
+import { prefer } from '@/preferences.js';
const props = defineProps<{
reaction: string;
@@ -90,7 +90,7 @@ async function toggleReaction() {
}
});
} else {
- if (defaultStore.state.confirmOnReact) {
+ if (prefer.s.confirmOnReact) {
const confirm = await os.confirm({
type: 'question',
text: i18n.tsx.reactAreYouSure({ emoji: props.reaction.replace('@.', '') }),
@@ -135,7 +135,7 @@ async function menu(ev) {
}
function anime() {
- if (document.hidden || !defaultStore.state.animation || buttonEl.value == null) return;
+ if (document.hidden || !prefer.s.animation || buttonEl.value == null) return;
const rect = buttonEl.value.getBoundingClientRect();
const x = rect.left + 16;
diff --git a/packages/frontend/src/components/MkReactionsViewer.vue b/packages/frontend/src/components/MkReactionsViewer.vue
index 63b202f9f3..bb60db8d34 100644
--- a/packages/frontend/src/components/MkReactionsViewer.vue
+++ b/packages/frontend/src/components/MkReactionsViewer.vue
@@ -5,11 +5,11 @@ SPDX-License-Identifier: AGPL-3.0-only
@@ -21,7 +21,7 @@ SPDX-License-Identifier: AGPL-3.0-only
import * as Misskey from 'misskey-js';
import { inject, watch, ref } from 'vue';
import XReaction from '@/components/MkReactionsViewer.reaction.vue';
-import { defaultStore } from '@/store.js';
+import { prefer } from '@/preferences.js';
const props = withDefaults(defineProps<{
note: Misskey.entities.Note;
diff --git a/packages/frontend/src/components/MkRetentionHeatmap.vue b/packages/frontend/src/components/MkRetentionHeatmap.vue
index 64b573c4d3..3ca680d8f2 100644
--- a/packages/frontend/src/components/MkRetentionHeatmap.vue
+++ b/packages/frontend/src/components/MkRetentionHeatmap.vue
@@ -16,7 +16,7 @@ SPDX-License-Identifier: AGPL-3.0-only
import { onMounted, nextTick, shallowRef, ref } from 'vue';
import { Chart } from 'chart.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
-import { defaultStore } from '@/store.js';
+import { store } from '@/store.js';
import { useChartTooltip } from '@/scripts/use-chart-tooltip.js';
import { alpha } from '@/scripts/color.js';
import { initChart } from '@/scripts/init-chart.js';
@@ -75,7 +75,7 @@ async function renderChart() {
await nextTick();
- const color = defaultStore.state.darkMode ? '#b4e900' : '#86b300';
+ const color = store.state.darkMode ? '#b4e900' : '#86b300';
const getYYYYMMDD = (date: Date) => {
const y = date.getFullYear().toString().padStart(2, '0');
diff --git a/packages/frontend/src/components/MkRetentionLineChart.vue b/packages/frontend/src/components/MkRetentionLineChart.vue
index d41793b0fa..13cee1b1cd 100644
--- a/packages/frontend/src/components/MkRetentionLineChart.vue
+++ b/packages/frontend/src/components/MkRetentionLineChart.vue
@@ -11,7 +11,7 @@ SPDX-License-Identifier: AGPL-3.0-only
import { onMounted, shallowRef } from 'vue';
import { Chart } from 'chart.js';
import tinycolor from 'tinycolor2';
-import { defaultStore } from '@/store.js';
+import { store } from '@/store.js';
import { useChartTooltip } from '@/scripts/use-chart-tooltip.js';
import { chartVLine } from '@/scripts/chart-vline.js';
import { alpha } from '@/scripts/color.js';
@@ -42,7 +42,7 @@ const getDate = (ymd: string) => {
onMounted(async () => {
let raw = await misskeyApi('retention', { });
- const vLineColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)';
+ const vLineColor = store.state.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)';
const accent = tinycolor(getComputedStyle(document.documentElement).getPropertyValue('--MI_THEME-accent'));
const color = accent.toHex();
diff --git a/packages/frontend/src/components/MkTimeline.vue b/packages/frontend/src/components/MkTimeline.vue
index 7bae240ddd..3cc9b69341 100644
--- a/packages/frontend/src/components/MkTimeline.vue
+++ b/packages/frontend/src/components/MkTimeline.vue
@@ -9,7 +9,7 @@ SPDX-License-Identifier: AGPL-3.0-only
v-if="paginationQuery"
ref="tlComponent"
:pagination="paginationQuery"
- :noGap="!defaultStore.state.showGapBetweenNotesInTimeline"
+ :noGap="!prefer.s.showGapBetweenNotesInTimeline"
@queue="emit('queue', $event)"
@status="prComponent?.setDisabled($event)"
/>
@@ -20,14 +20,14 @@ SPDX-License-Identifier: AGPL-3.0-only
import { computed, watch, onUnmounted, provide, ref, shallowRef } from 'vue';
import * as Misskey from 'misskey-js';
import type { BasicTimelineType } from '@/timelines.js';
+import type { Paging } from '@/components/MkPagination.vue';
import MkNotes from '@/components/MkNotes.vue';
import MkPullToRefresh from '@/components/MkPullToRefresh.vue';
import { useStream } from '@/stream.js';
import * as sound from '@/scripts/sound.js';
import { $i } from '@/account.js';
import { instance } from '@/instance.js';
-import { defaultStore } from '@/store.js';
-import type { Paging } from '@/components/MkPagination.vue';
+import { prefer } from '@/preferences.js';
const props = withDefaults(defineProps<{
src: BasicTimelineType | 'mentions' | 'directs' | 'list' | 'antenna' | 'channel' | 'role';
@@ -239,7 +239,7 @@ function updatePaginationQuery() {
}
function refreshEndpointAndChannel() {
- if (!defaultStore.state.disableStreamingTimeline) {
+ if (!prefer.s.disableStreamingTimeline) {
disconnectChannel();
connectChannel();
}
diff --git a/packages/frontend/src/components/MkToast.vue b/packages/frontend/src/components/MkToast.vue
index e256640649..ac795e312c 100644
--- a/packages/frontend/src/components/MkToast.vue
+++ b/packages/frontend/src/components/MkToast.vue
@@ -6,10 +6,10 @@ SPDX-License-Identifier: AGPL-3.0-only
@@ -24,7 +24,7 @@ SPDX-License-Identifier: AGPL-3.0-only
diff --git a/packages/frontend/src/components/MkVisitorDashboard.ActiveUsersChart.vue b/packages/frontend/src/components/MkVisitorDashboard.ActiveUsersChart.vue
index d098dad9a1..ac48b11c3f 100644
--- a/packages/frontend/src/components/MkVisitorDashboard.ActiveUsersChart.vue
+++ b/packages/frontend/src/components/MkVisitorDashboard.ActiveUsersChart.vue
@@ -18,7 +18,7 @@ import { Chart } from 'chart.js';
import gradient from 'chartjs-plugin-gradient';
import tinycolor from 'tinycolor2';
import { misskeyApi } from '@/scripts/misskey-api.js';
-import { defaultStore } from '@/store.js';
+import { store } from '@/store.js';
import { useChartTooltip } from '@/scripts/use-chart-tooltip.js';
import { chartVLine } from '@/scripts/chart-vline.js';
import { initChart } from '@/scripts/init-chart.js';
@@ -59,7 +59,7 @@ async function renderChart() {
await nextTick();
- const vLineColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)';
+ const vLineColor = store.state.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)';
const computedStyle = getComputedStyle(document.documentElement);
const accent = tinycolor(computedStyle.getPropertyValue('--MI_THEME-accent')).toHexString();
diff --git a/packages/frontend/src/components/MkWindow.vue b/packages/frontend/src/components/MkWindow.vue
index 2953f656d4..4759d217e8 100644
--- a/packages/frontend/src/components/MkWindow.vue
+++ b/packages/frontend/src/components/MkWindow.vue
@@ -5,10 +5,10 @@ SPDX-License-Identifier: AGPL-3.0-only
@@ -58,7 +58,7 @@ import type { MenuItem } from '@/types/menu.js';
import contains from '@/scripts/contains.js';
import * as os from '@/os.js';
import { i18n } from '@/i18n.js';
-import { defaultStore } from '@/store.js';
+import { prefer } from '@/preferences.js';
type WindowButton = {
title: string;
diff --git a/packages/frontend/src/components/MkYouTubePlayer.vue b/packages/frontend/src/components/MkYouTubePlayer.vue
index 1122976436..1c069d65fc 100644
--- a/packages/frontend/src/components/MkYouTubePlayer.vue
+++ b/packages/frontend/src/components/MkYouTubePlayer.vue
@@ -11,7 +11,7 @@ SPDX-License-Identifier: AGPL-3.0-only
-
+
@@ -25,10 +25,10 @@ SPDX-License-Identifier: AGPL-3.0-only
diff --git a/packages/frontend/src/pages/settings/preferences.vue b/packages/frontend/src/pages/settings/preferences.vue
index fe718bfa69..2df621eaa6 100644
--- a/packages/frontend/src/pages/settings/preferences.vue
+++ b/packages/frontend/src/pages/settings/preferences.vue
@@ -33,30 +33,36 @@ SPDX-License-Identifier: AGPL-3.0-only
-
- {{ i18n.ts.showFixedPostForm }}
-
+
+
+ {{ i18n.ts.showFixedPostForm }}
+
+
-
- {{ i18n.ts.showFixedPostFormInChannel }}
-
+
+
+ {{ i18n.ts.showFixedPostFormInChannel }}
+
+
{{ i18n.ts.pinnedList }}
- {{ i18n.ts.add }}
+ {{ i18n.ts.add }}
{{ i18n.ts.remove }}
-
- {{ i18n.ts.enableQuickAddMfmFunction }}
-
+
+
+ {{ i18n.ts.enableQuickAddMfmFunction }}
+
+
@@ -68,40 +74,52 @@ SPDX-License-Identifier: AGPL-3.0-only
-
- {{ i18n.ts.collapseRenotes }}
- {{ i18n.ts.collapseRenotesDescription }}
-
+
+
+ {{ i18n.ts.collapseRenotes }}
+ {{ i18n.ts.collapseRenotesDescription }}
+
+
-
- {{ i18n.ts.showNoteActionsOnlyHover }}
-
+
+
+ {{ i18n.ts.showNoteActionsOnlyHover }}
+
+
-
- {{ i18n.ts.showClipButtonInNoteFooter }}
-
+
+
+ {{ i18n.ts.showClipButtonInNoteFooter }}
+
+
-
- {{ i18n.ts.enableAdvancedMfm }}
-
+
+
+ {{ i18n.ts.enableAdvancedMfm }}
+
+
-
- {{ i18n.ts.showReactionsCount }}
-
+
+
+ {{ i18n.ts.showReactionsCount }}
+
+
-
- {{ i18n.ts.loadRawImages }}
-
+
+
+ {{ i18n.ts.loadRawImages }}
+
+
@@ -114,9 +132,11 @@ SPDX-License-Identifier: AGPL-3.0-only
-
- {{ i18n.ts.useGroupedNotifications }}
-
+
+
+ {{ i18n.ts.useGroupedNotifications }}
+
+
@@ -129,62 +149,88 @@ SPDX-License-Identifier: AGPL-3.0-only
-
- {{ i18n.ts.openImageInNewTab }}
-
+
+
+ {{ i18n.ts.openImageInNewTab }}
+
+
-
- {{ i18n.ts.useReactionPickerForContextMenu }}
-
+
+
+ {{ i18n.ts.useReactionPickerForContextMenu }}
+
+
-
- {{ i18n.ts.enableInfiniteScroll }}
-
+
+
+ {{ i18n.ts.enableInfiniteScroll }}
+
+
-
- {{ i18n.ts.disableStreamingTimeline }}
-
+
+
+ {{ i18n.ts.disableStreamingTimeline }}
+
+
-
- {{ i18n.ts.alwaysConfirmFollow }}
-
+
+
+ {{ i18n.ts.alwaysConfirmFollow }}
+
+
-
- {{ i18n.ts.confirmWhenRevealingSensitiveMedia }}
-
+
+
+ {{ i18n.ts.confirmWhenRevealingSensitiveMedia }}
+
+
-
- {{ i18n.ts.confirmOnReact }}
-
+
+
+ {{ i18n.ts.confirmOnReact }}
+
+
+
+
+
+
+
+ {{ i18n.ts.keepCw }}
+
+
-
- {{ i18n.ts.whenServerDisconnected }}
-
-
-
-
+
+
+ {{ i18n.ts.whenServerDisconnected }}
+
+
+
+
+
-
- {{ i18n.ts.numberOfPageCache }}
- {{ i18n.ts.numberOfPageCacheDescription }}
-
+
+
+ {{ i18n.ts.numberOfPageCache }}
+ {{ i18n.ts.numberOfPageCacheDescription }}
+
+
@@ -229,18 +275,22 @@ SPDX-License-Identifier: AGPL-3.0-only
-
- {{ i18n.ts.forceShowAds }}
-
+
+
+ {{ i18n.ts.forceShowAds }}
+
+
-
- {{ i18n.ts.hemisphere }}
-
-
- {{ i18n.ts._hemisphere.caption }}
-
+
+
+ {{ i18n.ts.hemisphere }}
+
+
+ {{ i18n.ts._hemisphere.caption }}
+
+
@@ -248,8 +298,8 @@ SPDX-License-Identifier: AGPL-3.0-only
{{ i18n.ts.additionalEmojiDictionary }}
- {{ i18n.ts.remove }} ({{ getEmojiIndexLangName(lang) }})
- {{ getEmojiIndexLangName(lang) }}{{ defaultStore.reactiveState.additionalUnicodeEmojiIndexes.value[lang] ? ` (${ i18n.ts.installed })` : '' }}
+ {{ i18n.ts.remove }} ({{ getEmojiIndexLangName(lang) }})
+ {{ getEmojiIndexLangName(lang) }}{{ store.reactiveState.additionalUnicodeEmojiIndexes.value[lang] ? ` (${ i18n.ts.installed })` : '' }}
@@ -272,7 +322,6 @@ SPDX-License-Identifier: AGPL-3.0-only
diff --git a/packages/frontend/src/pages/settings/statusbar.vue b/packages/frontend/src/pages/settings/statusbar.vue
index 1ae3de7994..068d28bc4e 100644
--- a/packages/frontend/src/pages/settings/statusbar.vue
+++ b/packages/frontend/src/pages/settings/statusbar.vue
@@ -22,11 +22,11 @@ import XStatusbar from './statusbar.statusbar.vue';
import MkFolder from '@/components/MkFolder.vue';
import MkButton from '@/components/MkButton.vue';
import { misskeyApi } from '@/scripts/misskey-api.js';
-import { defaultStore } from '@/store.js';
import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { prefer } from '@/preferences.js';
-const statusbars = defaultStore.reactiveState.statusbars;
+const statusbars = prefer.r.statusbars;
const userLists = ref(null);
@@ -37,13 +37,13 @@ onMounted(() => {
});
async function add() {
- defaultStore.push('statusbars', {
+ prefer.set('statusbars', [...statusbars.value, {
id: uuid(),
type: null,
black: false,
size: 'medium',
props: {},
- });
+ }]);
}
const headerActions = computed(() => []);
diff --git a/packages/frontend/src/pages/settings/theme.install.vue b/packages/frontend/src/pages/settings/theme.install.vue
index 4f05d3784c..b19b12aaab 100644
--- a/packages/frontend/src/pages/settings/theme.install.vue
+++ b/packages/frontend/src/pages/settings/theme.install.vue
@@ -20,7 +20,7 @@ SPDX-License-Identifier: AGPL-3.0-only
import { ref, computed } from 'vue';
import MkCodeEditor from '@/components/MkCodeEditor.vue';
import MkButton from '@/components/MkButton.vue';
-import { parseThemeCode, previewTheme, installTheme } from '@/scripts/install-theme.js';
+import { parseThemeCode, previewTheme, installTheme } from '@/scripts/theme.js';
import * as os from '@/os.js';
import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
diff --git a/packages/frontend/src/pages/settings/theme.vue b/packages/frontend/src/pages/settings/theme.vue
index b0e4ce13d5..41de2aa6a6 100644
--- a/packages/frontend/src/pages/settings/theme.vue
+++ b/packages/frontend/src/pages/settings/theme.vue
@@ -75,6 +75,8 @@ SPDX-License-Identifier: AGPL-3.0-only
+
+
diff --git a/packages/frontend/src/ui/_common_/common.vue b/packages/frontend/src/ui/_common_/common.vue
index d145b9b6c6..51645f9676 100644
--- a/packages/frontend/src/ui/_common_/common.vue
+++ b/packages/frontend/src/ui/_common_/common.vue
@@ -17,18 +17,18 @@ SPDX-License-Identifier: AGPL-3.0-only
@@ -56,7 +56,7 @@ import * as sound from '@/scripts/sound.js';
import { $i } from '@/account.js';
import { useStream } from '@/stream.js';
import { i18n } from '@/i18n.js';
-import { defaultStore } from '@/store.js';
+import { prefer } from '@/preferences.js';
import { globalEvents } from '@/events.js';
const XStreamIndicator = defineAsyncComponent(() => import('./stream-indicator.vue'));
diff --git a/packages/frontend/src/ui/_common_/navbar-for-mobile.vue b/packages/frontend/src/ui/_common_/navbar-for-mobile.vue
index 44253e93bd..698e9d8d47 100644
--- a/packages/frontend/src/ui/_common_/navbar-for-mobile.vue
+++ b/packages/frontend/src/ui/_common_/navbar-for-mobile.vue
@@ -54,11 +54,11 @@ import { openInstanceMenu } from './common.js';
import * as os from '@/os.js';
import { navbarItemDef } from '@/navbar.js';
import { $i, openAccountMenu as openAccountMenu_ } from '@/account.js';
-import { defaultStore } from '@/store.js';
+import { prefer } from '@/preferences.js';
import { i18n } from '@/i18n.js';
import { instance } from '@/instance.js';
-const menu = toRef(defaultStore.state, 'menu');
+const menu = toRef(prefer.s, 'menu');
const otherMenuItemIndicated = computed(() => {
for (const def in navbarItemDef) {
if (menu.value.includes(def)) continue;
diff --git a/packages/frontend/src/ui/_common_/navbar.vue b/packages/frontend/src/ui/_common_/navbar.vue
index fec8666dc1..1fb99f9f22 100644
--- a/packages/frontend/src/ui/_common_/navbar.vue
+++ b/packages/frontend/src/ui/_common_/navbar.vue
@@ -94,20 +94,21 @@ import { openInstanceMenu } from './common.js';
import * as os from '@/os.js';
import { navbarItemDef } from '@/navbar.js';
import { $i, openAccountMenu as openAccountMenu_ } from '@/account.js';
-import { defaultStore } from '@/store.js';
+import { store } from '@/store.js';
import { i18n } from '@/i18n.js';
import { instance } from '@/instance.js';
import { getHTMLElementOrNull } from '@/scripts/get-dom-node-or-null.js';
import { useRouter } from '@/router/supplier.js';
+import { prefer } from '@/preferences.js';
const router = useRouter();
const forceIconOnly = ref(window.innerWidth <= 1279);
const iconOnly = computed(() => {
- return forceIconOnly.value || (defaultStore.reactiveState.menuDisplay.value === 'sideIcon');
+ return forceIconOnly.value || (store.reactiveState.menuDisplay.value === 'sideIcon');
});
-const menu = computed(() => defaultStore.state.menu);
+const menu = computed(() => prefer.s.menu);
const otherMenuItemIndicated = computed(() => {
for (const def in navbarItemDef) {
if (menu.value.includes(def)) continue;
@@ -122,12 +123,12 @@ function calcViewState() {
window.addEventListener('resize', calcViewState);
-watch(defaultStore.reactiveState.menuDisplay, () => {
+watch(store.reactiveState.menuDisplay, () => {
calcViewState();
});
function toggleIconOnly() {
- defaultStore.set('menuDisplay', iconOnly.value ? 'sideFull' : 'sideIcon');
+ store.set('menuDisplay', iconOnly.value ? 'sideFull' : 'sideIcon');
}
function openAccountMenu(ev: MouseEvent) {
diff --git a/packages/frontend/src/ui/_common_/statusbars.vue b/packages/frontend/src/ui/_common_/statusbars.vue
index ed881bef22..a8d87599e6 100644
--- a/packages/frontend/src/ui/_common_/statusbars.vue
+++ b/packages/frontend/src/ui/_common_/statusbars.vue
@@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only
import { defineAsyncComponent } from 'vue';
import { instance } from '@/instance.js';
-import { defaultStore } from '@/store.js';
+import { prefer } from '@/preferences.js';
const XRss = defineAsyncComponent(() => import('./statusbar-rss.vue'));
const XFederation = defineAsyncComponent(() => import('./statusbar-federation.vue'));
const XUserList = defineAsyncComponent(() => import('./statusbar-user-list.vue'));
diff --git a/packages/frontend/src/ui/_common_/stream-indicator.vue b/packages/frontend/src/ui/_common_/stream-indicator.vue
index cc62a28b14..1eb809d198 100644
--- a/packages/frontend/src/ui/_common_/stream-indicator.vue
+++ b/packages/frontend/src/ui/_common_/stream-indicator.vue
@@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
-
+
{{ i18n.ts.disconnectedFromServer }}
{{ i18n.ts.reload }}
@@ -19,7 +19,7 @@ import { useStream } from '@/stream.js';
import { i18n } from '@/i18n.js';
import MkButton from '@/components/MkButton.vue';
import * as os from '@/os.js';
-import { defaultStore } from '@/store.js';
+import { prefer } from '@/preferences.js';
const zIndex = os.claimZIndex('high');
diff --git a/packages/frontend/src/ui/classic.header.vue b/packages/frontend/src/ui/classic.header.vue
index f4633314ae..39b40754ff 100644
--- a/packages/frontend/src/ui/classic.header.vue
+++ b/packages/frontend/src/ui/classic.header.vue
@@ -53,15 +53,16 @@ import * as os from '@/os.js';
import { navbarItemDef } from '@/navbar.js';
import { openAccountMenu as openAccountMenu_, $i } from '@/account.js';
import MkButton from '@/components/MkButton.vue';
-import { defaultStore } from '@/store.js';
+import { store } from '@/store.js';
import { instance } from '@/instance.js';
import { i18n } from '@/i18n.js';
+import { prefer } from '@/preferences.js';
const WINDOW_THRESHOLD = 1400;
const settingsWindowed = ref(window.innerWidth > WINDOW_THRESHOLD);
-const menu = ref(defaultStore.state.menu);
-// const menuDisplay = computed(defaultStore.makeGetterSetter('menuDisplay'));
+const menu = ref(prefer.s.menu);
+// const menuDisplay = computed(store.makeGetterSetter('menuDisplay'));
const otherNavItemIndicated = computed(() => {
for (const def in navbarItemDef) {
if (menu.value.includes(def)) continue;
diff --git a/packages/frontend/src/ui/classic.sidebar.vue b/packages/frontend/src/ui/classic.sidebar.vue
index 5acef0bef8..c11771f028 100644
--- a/packages/frontend/src/ui/classic.sidebar.vue
+++ b/packages/frontend/src/ui/classic.sidebar.vue
@@ -59,14 +59,15 @@ import MkButton from '@/components/MkButton.vue';
// import { StickySidebar } from '@/scripts/sticky-sidebar.js';
// import { mainRouter } from '@/router.js';
//import MisskeyLogo from '@assets/client/misskey.svg';
-import { defaultStore } from '@/store.js';
+import { store } from '@/store.js';
import { instance } from '@/instance.js';
import { i18n } from '@/i18n.js';
+import { prefer } from '@/preferences.js';
const WINDOW_THRESHOLD = 1400;
-const menu = ref(defaultStore.state.menu);
-const menuDisplay = computed(defaultStore.makeGetterSetter('menuDisplay'));
+const menu = ref(prefer.s.menu);
+const menuDisplay = computed(store.makeGetterSetter('menuDisplay'));
const otherNavItemIndicated = computed(() => {
for (const def in navbarItemDef) {
if (menu.value.includes(def)) continue;
@@ -99,7 +100,7 @@ function openAccountMenu(ev: MouseEvent) {
}, ev);
}
-watch(defaultStore.reactiveState.menuDisplay, () => {
+watch(store.reactiveState.menuDisplay, () => {
calcViewState();
});
diff --git a/packages/frontend/src/ui/classic.vue b/packages/frontend/src/ui/classic.vue
index da5059bb59..63c60a3d6f 100644
--- a/packages/frontend/src/ui/classic.vue
+++ b/packages/frontend/src/ui/classic.vue
@@ -26,7 +26,7 @@ SPDX-License-Identifier: AGPL-3.0-only
-
+
-
+
-
+
@@ -47,18 +47,19 @@ SPDX-License-Identifier: AGPL-3.0-only
diff --git a/packages/frontend/src/components/MkModal.vue b/packages/frontend/src/components/MkModal.vue
index 521c851d8b..8b3086d55e 100644
--- a/packages/frontend/src/components/MkModal.vue
+++ b/packages/frontend/src/components/MkModal.vue
@@ -43,12 +43,12 @@ SPDX-License-Identifier: AGPL-3.0-only
`,
- ];
- return iframeCode.join('\n');
-}
-
-/**
- * 埋め込みコードを生成してコピーする(カスタマイズ機能つき)
- *
- * カスタマイズ機能がいらない場合(事前にパラメータを指定する場合)は getEmbedCode を直接使ってください
- */
-export function genEmbedCode(entity: EmbeddableEntity, id: string, params?: EmbedParams) {
- const _params = { ...params };
-
- if (embedRouteWithScrollbar.includes(entity) && _params.maxHeight == null) {
- _params.maxHeight = 700;
- }
-
- // PCじゃない場合はコードカスタマイズ画面を出さずにそのままコピー
- if (window.innerWidth < MOBILE_THRESHOLD) {
- copyToClipboard(getEmbedCode(`/embed/${entity}/${id}`, _params));
- os.success();
- } else {
- const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkEmbedCodeGenDialog.vue')), {
- entity,
- id,
- params: _params,
- }, {
- closed: () => dispose(),
- });
- }
-}
diff --git a/packages/frontend/src/scripts/get-note-menu.ts b/packages/frontend/src/scripts/get-note-menu.ts
deleted file mode 100644
index 8ce4a81bd4..0000000000
--- a/packages/frontend/src/scripts/get-note-menu.ts
+++ /dev/null
@@ -1,685 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import { defineAsyncComponent } from 'vue';
-import type { Ref, ShallowRef } from 'vue';
-import * as Misskey from 'misskey-js';
-import { url } from '@@/js/config.js';
-import { claimAchievement } from './achievements.js';
-import type { MenuItem } from '@/types/menu.js';
-import { $i } from '@/account.js';
-import { i18n } from '@/i18n.js';
-import { instance } from '@/instance.js';
-import * as os from '@/os.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
-import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
-import { store, noteActions } from '@/store.js';
-import { miLocalStorage } from '@/local-storage.js';
-import { getUserMenu } from '@/scripts/get-user-menu.js';
-import { clipsCache, favoritedChannelsCache } from '@/cache.js';
-import MkRippleEffect from '@/components/MkRippleEffect.vue';
-import { isSupportShare } from '@/scripts/navigator.js';
-import { getAppearNote } from '@/scripts/get-appear-note.js';
-import { genEmbedCode } from '@/scripts/get-embed-code.js';
-import { prefer } from '@/preferences.js';
-
-export async function getNoteClipMenu(props: {
- note: Misskey.entities.Note;
- isDeleted: Ref;
- currentClip?: Misskey.entities.Clip;
-}) {
- function getClipName(clip: Misskey.entities.Clip) {
- if ($i && clip.userId === $i.id && clip.notesCount != null) {
- return `${clip.name} (${clip.notesCount}/${$i.policies.noteEachClipsLimit})`;
- } else {
- return clip.name;
- }
- }
-
- const appearNote = getAppearNote(props.note);
-
- const clips = await clipsCache.fetch();
- const menu: MenuItem[] = [...clips.map(clip => ({
- text: getClipName(clip),
- action: () => {
- claimAchievement('noteClipped1');
- os.promiseDialog(
- misskeyApi('clips/add-note', { clipId: clip.id, noteId: appearNote.id }),
- null,
- async (err) => {
- if (err.id === '734806c4-542c-463a-9311-15c512803965') {
- const confirm = await os.confirm({
- type: 'warning',
- text: i18n.tsx.confirmToUnclipAlreadyClippedNote({ name: clip.name }),
- });
- if (!confirm.canceled) {
- os.apiWithDialog('clips/remove-note', { clipId: clip.id, noteId: appearNote.id }).then(() => {
- clipsCache.set(clips.map(c => {
- if (c.id === clip.id) {
- return {
- ...c,
- notesCount: Math.max(0, ((c.notesCount ?? 0) - 1)),
- };
- } else {
- return c;
- }
- }));
- });
- if (props.currentClip?.id === clip.id) props.isDeleted.value = true;
- }
- } else if (err.id === 'f0dba960-ff73-4615-8df4-d6ac5d9dc118') {
- os.alert({
- type: 'error',
- text: i18n.ts.clipNoteLimitExceeded,
- });
- } else {
- os.alert({
- type: 'error',
- text: err.message + '\n' + err.id,
- });
- }
- },
- ).then(() => {
- clipsCache.set(clips.map(c => {
- if (c.id === clip.id) {
- return {
- ...c,
- notesCount: (c.notesCount ?? 0) + 1,
- };
- } else {
- return c;
- }
- }));
- });
- },
- })), { type: 'divider' }, {
- icon: 'ti ti-plus',
- text: i18n.ts.createNew,
- action: async () => {
- const { canceled, result } = await os.form(i18n.ts.createNewClip, {
- name: {
- type: 'string',
- default: null,
- label: i18n.ts.name,
- },
- description: {
- type: 'string',
- required: false,
- default: null,
- multiline: true,
- label: i18n.ts.description,
- },
- isPublic: {
- type: 'boolean',
- label: i18n.ts.public,
- default: false,
- },
- });
- if (canceled) return;
-
- const clip = await os.apiWithDialog('clips/create', result);
-
- clipsCache.delete();
-
- claimAchievement('noteClipped1');
- os.apiWithDialog('clips/add-note', { clipId: clip.id, noteId: appearNote.id });
- },
- }];
-
- return menu;
-}
-
-export function getAbuseNoteMenu(note: Misskey.entities.Note, text: string): MenuItem {
- return {
- icon: 'ti ti-exclamation-circle',
- text,
- action: (): void => {
- const localUrl = `${url}/notes/${note.id}`;
- let noteInfo = '';
- if (note.url ?? note.uri != null) noteInfo = `Note: ${note.url ?? note.uri}\n`;
- noteInfo += `Local Note: ${localUrl}\n`;
- const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkAbuseReportWindow.vue')), {
- user: note.user,
- initialComment: `${noteInfo}-----\n`,
- }, {
- closed: () => dispose(),
- });
- },
- };
-}
-
-export function getCopyNoteLinkMenu(note: Misskey.entities.Note, text: string): MenuItem {
- return {
- icon: 'ti ti-link',
- text,
- action: (): void => {
- copyToClipboard(`${url}/notes/${note.id}`);
- os.success();
- },
- };
-}
-
-function getNoteEmbedCodeMenu(note: Misskey.entities.Note, text: string): MenuItem | undefined {
- if (note.url != null || note.uri != null) return undefined;
- if (['specified', 'followers'].includes(note.visibility)) return undefined;
-
- return {
- icon: 'ti ti-code',
- text,
- action: (): void => {
- genEmbedCode('notes', note.id);
- },
- };
-}
-
-export function getNoteMenu(props: {
- note: Misskey.entities.Note;
- translation: Ref;
- translating: Ref;
- isDeleted: Ref;
- currentClip?: Misskey.entities.Clip;
-}) {
- const appearNote = getAppearNote(props.note);
-
- const cleanups = [] as (() => void)[];
-
- function del(): void {
- os.confirm({
- type: 'warning',
- text: i18n.ts.noteDeleteConfirm,
- }).then(({ canceled }) => {
- if (canceled) return;
-
- misskeyApi('notes/delete', {
- noteId: appearNote.id,
- });
-
- if (Date.now() - new Date(appearNote.createdAt).getTime() < 1000 * 60 && appearNote.userId === $i.id) {
- claimAchievement('noteDeletedWithin1min');
- }
- });
- }
-
- function delEdit(): void {
- os.confirm({
- type: 'warning',
- text: i18n.ts.deleteAndEditConfirm,
- }).then(({ canceled }) => {
- if (canceled) return;
-
- misskeyApi('notes/delete', {
- noteId: appearNote.id,
- });
-
- os.post({ initialNote: appearNote, renote: appearNote.renote, reply: appearNote.reply, channel: appearNote.channel });
-
- if (Date.now() - new Date(appearNote.createdAt).getTime() < 1000 * 60 && appearNote.userId === $i.id) {
- claimAchievement('noteDeletedWithin1min');
- }
- });
- }
-
- function toggleFavorite(favorite: boolean): void {
- claimAchievement('noteFavorited1');
- os.apiWithDialog(favorite ? 'notes/favorites/create' : 'notes/favorites/delete', {
- noteId: appearNote.id,
- });
- }
-
- function toggleThreadMute(mute: boolean): void {
- os.apiWithDialog(mute ? 'notes/thread-muting/create' : 'notes/thread-muting/delete', {
- noteId: appearNote.id,
- });
- }
-
- function copyContent(): void {
- copyToClipboard(appearNote.text);
- os.success();
- }
-
- function togglePin(pin: boolean): void {
- os.apiWithDialog(pin ? 'i/pin' : 'i/unpin', {
- noteId: appearNote.id,
- }, undefined, {
- '72dab508-c64d-498f-8740-a8eec1ba385a': {
- text: i18n.ts.pinLimitExceeded,
- },
- });
- }
-
- async function unclip(): Promise {
- if (!props.currentClip) return;
- os.apiWithDialog('clips/remove-note', { clipId: props.currentClip.id, noteId: appearNote.id });
- props.isDeleted.value = true;
- }
-
- async function promote(): Promise {
- const { canceled, result: days } = await os.inputNumber({
- title: i18n.ts.numberOfDays,
- });
-
- if (canceled || days == null) return;
-
- os.apiWithDialog('admin/promo/create', {
- noteId: appearNote.id,
- expiresAt: Date.now() + (86400000 * days),
- });
- }
-
- function share(): void {
- navigator.share({
- title: i18n.tsx.noteOf({ user: appearNote.user.name ?? appearNote.user.username }),
- text: appearNote.text ?? '',
- url: `${url}/notes/${appearNote.id}`,
- });
- }
-
- function openDetail(): void {
- os.pageWindow(`/notes/${appearNote.id}`);
- }
-
- async function translate(): Promise {
- if (props.translation.value != null) return;
- props.translating.value = true;
- const res = await misskeyApi('notes/translate', {
- noteId: appearNote.id,
- targetLang: miLocalStorage.getItem('lang') ?? navigator.language,
- });
- props.translating.value = false;
- props.translation.value = res;
- }
-
- const menuItems: MenuItem[] = [];
-
- if ($i) {
- const statePromise = misskeyApi('notes/state', {
- noteId: appearNote.id,
- });
-
- if (props.currentClip?.userId === $i.id) {
- menuItems.push({
- icon: 'ti ti-backspace',
- text: i18n.ts.unclip,
- danger: true,
- action: unclip,
- }, { type: 'divider' });
- }
-
- menuItems.push({
- icon: 'ti ti-info-circle',
- text: i18n.ts.details,
- action: openDetail,
- }, {
- icon: 'ti ti-copy',
- text: i18n.ts.copyContent,
- action: copyContent,
- }, getCopyNoteLinkMenu(appearNote, i18n.ts.copyLink));
-
- if (appearNote.url || appearNote.uri) {
- menuItems.push({
- icon: 'ti ti-link',
- text: i18n.ts.copyRemoteLink,
- action: () => {
- copyToClipboard(appearNote.url ?? appearNote.uri);
- os.success();
- },
- }, {
- icon: 'ti ti-external-link',
- text: i18n.ts.showOnRemote,
- action: () => {
- window.open(appearNote.url ?? appearNote.uri, '_blank', 'noopener');
- },
- });
- } else {
- menuItems.push(getNoteEmbedCodeMenu(appearNote, i18n.ts.genEmbedCode));
- }
-
- if (isSupportShare()) {
- menuItems.push({
- icon: 'ti ti-share',
- text: i18n.ts.share,
- action: share,
- });
- }
-
- if ($i.policies.canUseTranslator && instance.translatorAvailable) {
- menuItems.push({
- icon: 'ti ti-language-hiragana',
- text: i18n.ts.translate,
- action: translate,
- });
- }
-
- menuItems.push({ type: 'divider' });
-
- menuItems.push(statePromise.then(state => state.isFavorited ? {
- icon: 'ti ti-star-off',
- text: i18n.ts.unfavorite,
- action: () => toggleFavorite(false),
- } : {
- icon: 'ti ti-star',
- text: i18n.ts.favorite,
- action: () => toggleFavorite(true),
- }));
-
- menuItems.push({
- type: 'parent',
- icon: 'ti ti-paperclip',
- text: i18n.ts.clip,
- children: () => getNoteClipMenu(props),
- });
-
- menuItems.push(statePromise.then(state => state.isMutedThread ? {
- icon: 'ti ti-message-off',
- text: i18n.ts.unmuteThread,
- action: () => toggleThreadMute(false),
- } : {
- icon: 'ti ti-message-off',
- text: i18n.ts.muteThread,
- action: () => toggleThreadMute(true),
- }));
-
- if (appearNote.userId === $i.id) {
- if (($i.pinnedNoteIds ?? []).includes(appearNote.id)) {
- menuItems.push({
- icon: 'ti ti-pinned-off',
- text: i18n.ts.unpin,
- action: () => togglePin(false),
- });
- } else {
- menuItems.push({
- icon: 'ti ti-pin',
- text: i18n.ts.pin,
- action: () => togglePin(true),
- });
- }
- }
-
- menuItems.push({
- type: 'parent',
- icon: 'ti ti-user',
- text: i18n.ts.user,
- children: async () => {
- const user = appearNote.userId === $i?.id ? $i : await misskeyApi('users/show', { userId: appearNote.userId });
- const { menu, cleanup } = getUserMenu(user);
- cleanups.push(cleanup);
- return menu;
- },
- });
-
- if (appearNote.userId !== $i.id) {
- menuItems.push({ type: 'divider' });
- menuItems.push(getAbuseNoteMenu(appearNote, i18n.ts.reportAbuse));
- }
-
- if (appearNote.channel && (appearNote.channel.userId === $i.id || $i.isModerator || $i.isAdmin)) {
- menuItems.push({ type: 'divider' });
- menuItems.push({
- type: 'parent',
- icon: 'ti ti-device-tv',
- text: i18n.ts.channel,
- children: async () => {
- const channelChildMenu = [] as MenuItem[];
-
- const channel = await misskeyApi('channels/show', { channelId: appearNote.channel!.id });
-
- if (channel.pinnedNoteIds.includes(appearNote.id)) {
- channelChildMenu.push({
- icon: 'ti ti-pinned-off',
- text: i18n.ts.unpin,
- action: () => os.apiWithDialog('channels/update', {
- channelId: appearNote.channel!.id,
- pinnedNoteIds: channel.pinnedNoteIds.filter(id => id !== appearNote.id),
- }),
- });
- } else {
- channelChildMenu.push({
- icon: 'ti ti-pin',
- text: i18n.ts.pin,
- action: () => os.apiWithDialog('channels/update', {
- channelId: appearNote.channel!.id,
- pinnedNoteIds: [...channel.pinnedNoteIds, appearNote.id],
- }),
- });
- }
- return channelChildMenu;
- },
- });
- }
-
- if (appearNote.userId === $i.id || $i.isModerator || $i.isAdmin) {
- menuItems.push({ type: 'divider' });
- if (appearNote.userId === $i.id) {
- menuItems.push({
- icon: 'ti ti-edit',
- text: i18n.ts.deleteAndEdit,
- action: delEdit,
- });
- }
- menuItems.push({
- icon: 'ti ti-trash',
- text: i18n.ts.delete,
- danger: true,
- action: del,
- });
- }
- } else {
- menuItems.push({
- icon: 'ti ti-info-circle',
- text: i18n.ts.details,
- action: openDetail,
- }, {
- icon: 'ti ti-copy',
- text: i18n.ts.copyContent,
- action: copyContent,
- }, getCopyNoteLinkMenu(appearNote, i18n.ts.copyLink));
-
- if (appearNote.url || appearNote.uri) {
- menuItems.push({
- icon: 'ti ti-link',
- text: i18n.ts.copyRemoteLink,
- action: () => {
- copyToClipboard(appearNote.url ?? appearNote.uri);
- os.success();
- },
- }, {
- icon: 'ti ti-external-link',
- text: i18n.ts.showOnRemote,
- action: () => {
- window.open(appearNote.url ?? appearNote.uri, '_blank', 'noopener');
- },
- });
- } else {
- menuItems.push(getNoteEmbedCodeMenu(appearNote, i18n.ts.genEmbedCode));
- }
- }
-
- if (noteActions.length > 0) {
- menuItems.push({ type: 'divider' });
-
- menuItems.push(...noteActions.map(action => ({
- icon: 'ti ti-plug',
- text: action.title,
- action: () => {
- action.handler(appearNote);
- },
- })));
- }
-
- if (prefer.s.devMode) {
- menuItems.push({ type: 'divider' }, {
- icon: 'ti ti-id',
- text: i18n.ts.copyNoteId,
- action: () => {
- copyToClipboard(appearNote.id);
- os.success();
- },
- });
- }
-
- const cleanup = () => {
- if (_DEV_) console.log('note menu cleanup', cleanups);
- for (const cl of cleanups) {
- cl();
- }
- };
-
- return {
- menu: menuItems,
- cleanup,
- };
-}
-
-type Visibility = (typeof Misskey.noteVisibilities)[number];
-
-function smallerVisibility(a: Visibility, b: Visibility): Visibility {
- if (a === 'specified' || b === 'specified') return 'specified';
- if (a === 'followers' || b === 'followers') return 'followers';
- if (a === 'home' || b === 'home') return 'home';
- // if (a === 'public' || b === 'public')
- return 'public';
-}
-
-export function getRenoteMenu(props: {
- note: Misskey.entities.Note;
- renoteButton: ShallowRef;
- mock?: boolean;
-}) {
- const appearNote = getAppearNote(props.note);
-
- const channelRenoteItems: MenuItem[] = [];
- const normalRenoteItems: MenuItem[] = [];
- const normalExternalChannelRenoteItems: MenuItem[] = [];
-
- if (appearNote.channel) {
- channelRenoteItems.push(...[{
- text: i18n.ts.inChannelRenote,
- icon: 'ti ti-repeat',
- action: () => {
- const el = props.renoteButton.value;
- if (el && prefer.s.animation) {
- const rect = el.getBoundingClientRect();
- const x = rect.left + (el.offsetWidth / 2);
- const y = rect.top + (el.offsetHeight / 2);
- const { dispose } = os.popup(MkRippleEffect, { x, y }, {
- end: () => dispose(),
- });
- }
-
- if (!props.mock) {
- misskeyApi('notes/create', {
- renoteId: appearNote.id,
- channelId: appearNote.channelId,
- }).then(() => {
- os.toast(i18n.ts.renoted);
- });
- }
- },
- }, {
- text: i18n.ts.inChannelQuote,
- icon: 'ti ti-quote',
- action: () => {
- if (!props.mock) {
- os.post({
- renote: appearNote,
- channel: appearNote.channel,
- });
- }
- },
- }]);
- }
-
- if (!appearNote.channel || appearNote.channel.allowRenoteToExternal) {
- normalRenoteItems.push(...[{
- text: i18n.ts.renote,
- icon: 'ti ti-repeat',
- action: () => {
- const el = props.renoteButton.value;
- if (el && prefer.s.animation) {
- const rect = el.getBoundingClientRect();
- const x = rect.left + (el.offsetWidth / 2);
- const y = rect.top + (el.offsetHeight / 2);
- const { dispose } = os.popup(MkRippleEffect, { x, y }, {
- end: () => dispose(),
- });
- }
-
- const configuredVisibility = prefer.s.rememberNoteVisibility ? store.state.visibility : prefer.s.defaultNoteVisibility;
- const localOnly = prefer.s.rememberNoteVisibility ? store.state.localOnly : prefer.s.defaultNoteLocalOnly;
-
- let visibility = appearNote.visibility;
- visibility = smallerVisibility(visibility, configuredVisibility);
- if (appearNote.channel?.isSensitive) {
- visibility = smallerVisibility(visibility, 'home');
- }
-
- if (!props.mock) {
- misskeyApi('notes/create', {
- localOnly,
- visibility,
- renoteId: appearNote.id,
- }).then(() => {
- os.toast(i18n.ts.renoted);
- });
- }
- },
- }, (props.mock) ? undefined : {
- text: i18n.ts.quote,
- icon: 'ti ti-quote',
- action: () => {
- os.post({
- renote: appearNote,
- });
- },
- }]);
-
- normalExternalChannelRenoteItems.push({
- type: 'parent',
- icon: 'ti ti-repeat',
- text: appearNote.channel ? i18n.ts.renoteToOtherChannel : i18n.ts.renoteToChannel,
- children: async () => {
- const channels = await favoritedChannelsCache.fetch();
- return channels.filter((channel) => {
- if (!appearNote.channelId) return true;
- return channel.id !== appearNote.channelId;
- }).map((channel) => ({
- text: channel.name,
- action: () => {
- const el = props.renoteButton.value;
- if (el && prefer.s.animation) {
- const rect = el.getBoundingClientRect();
- const x = rect.left + (el.offsetWidth / 2);
- const y = rect.top + (el.offsetHeight / 2);
- const { dispose } = os.popup(MkRippleEffect, { x, y }, {
- end: () => dispose(),
- });
- }
-
- if (!props.mock) {
- misskeyApi('notes/create', {
- renoteId: appearNote.id,
- channelId: channel.id,
- }).then(() => {
- os.toast(i18n.tsx.renotedToX({ name: channel.name }));
- });
- }
- },
- }));
- },
- });
- }
-
- const renoteItems = [
- ...normalRenoteItems,
- ...(channelRenoteItems.length > 0 && normalRenoteItems.length > 0) ? [{ type: 'divider' }] as MenuItem[] : [],
- ...channelRenoteItems,
- ...(normalExternalChannelRenoteItems.length > 0 && (normalRenoteItems.length > 0 || channelRenoteItems.length > 0)) ? [{ type: 'divider' }] as MenuItem[] : [],
- ...normalExternalChannelRenoteItems,
- ];
-
- return {
- menu: renoteItems,
- };
-}
diff --git a/packages/frontend/src/scripts/get-note-summary.ts b/packages/frontend/src/scripts/get-note-summary.ts
deleted file mode 100644
index 6fd9947ac1..0000000000
--- a/packages/frontend/src/scripts/get-note-summary.ts
+++ /dev/null
@@ -1,64 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import * as Misskey from 'misskey-js';
-import { i18n } from '@/i18n.js';
-
-/**
- * 投稿を表す文字列を取得します。
- * @param {*} note (packされた)投稿
- */
-export const getNoteSummary = (note?: Misskey.entities.Note | null): string => {
- if (note == null) {
- return '';
- }
-
- if (note.deletedAt) {
- return `(${i18n.ts.deletedNote})`;
- }
-
- if (note.isHidden) {
- return `(${i18n.ts.invisibleNote})`;
- }
-
- let summary = '';
-
- // 本文
- if (note.cw != null) {
- summary += note.cw;
- } else {
- summary += note.text ? note.text : '';
- }
-
- // ファイルが添付されているとき
- if ((note.files || []).length !== 0) {
- summary += ` (${i18n.tsx.withNFiles({ n: note.files.length })})`;
- }
-
- // 投票が添付されているとき
- if (note.poll) {
- summary += ` (${i18n.ts.poll})`;
- }
-
- // 返信のとき
- if (note.replyId) {
- if (note.reply) {
- summary += `\n\nRE: ${getNoteSummary(note.reply)}`;
- } else {
- summary += '\n\nRE: ...';
- }
- }
-
- // Renoteのとき
- if (note.renoteId) {
- if (note.renote) {
- summary += `\n\nRN: ${getNoteSummary(note.renote)}`;
- } else {
- summary += '\n\nRN: ...';
- }
- }
-
- return summary.trim();
-};
diff --git a/packages/frontend/src/scripts/get-user-menu.ts b/packages/frontend/src/scripts/get-user-menu.ts
deleted file mode 100644
index 6892c3a4e4..0000000000
--- a/packages/frontend/src/scripts/get-user-menu.ts
+++ /dev/null
@@ -1,441 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import { toUnicode } from 'punycode.js';
-import { defineAsyncComponent, ref, watch } from 'vue';
-import * as Misskey from 'misskey-js';
-import { host, url } from '@@/js/config.js';
-import type { IRouter } from '@/nirax.js';
-import type { MenuItem } from '@/types/menu.js';
-import { i18n } from '@/i18n.js';
-import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
-import * as os from '@/os.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
-import { userActions } from '@/store.js';
-import { $i, iAmModerator } from '@/account.js';
-import { notesSearchAvailable, canSearchNonLocalNotes } from '@/scripts/check-permissions.js';
-import { antennasCache, rolesCache, userListsCache } from '@/cache.js';
-import { mainRouter } from '@/router/main.js';
-import { genEmbedCode } from '@/scripts/get-embed-code.js';
-import { prefer } from '@/preferences.js';
-
-export function getUserMenu(user: Misskey.entities.UserDetailed, router: IRouter = mainRouter) {
- const meId = $i ? $i.id : null;
-
- const cleanups = [] as (() => void)[];
-
- async function toggleMute() {
- if (user.isMuted) {
- os.apiWithDialog('mute/delete', {
- userId: user.id,
- }).then(() => {
- user.isMuted = false;
- });
- } else {
- const { canceled, result: period } = await os.select({
- title: i18n.ts.mutePeriod,
- items: [{
- value: 'indefinitely', text: i18n.ts.indefinitely,
- }, {
- value: 'tenMinutes', text: i18n.ts.tenMinutes,
- }, {
- value: 'oneHour', text: i18n.ts.oneHour,
- }, {
- value: 'oneDay', text: i18n.ts.oneDay,
- }, {
- value: 'oneWeek', text: i18n.ts.oneWeek,
- }],
- default: 'indefinitely',
- });
- if (canceled) return;
-
- const expiresAt = period === 'indefinitely' ? null
- : period === 'tenMinutes' ? Date.now() + (1000 * 60 * 10)
- : period === 'oneHour' ? Date.now() + (1000 * 60 * 60)
- : period === 'oneDay' ? Date.now() + (1000 * 60 * 60 * 24)
- : period === 'oneWeek' ? Date.now() + (1000 * 60 * 60 * 24 * 7)
- : null;
-
- os.apiWithDialog('mute/create', {
- userId: user.id,
- expiresAt,
- }).then(() => {
- user.isMuted = true;
- });
- }
- }
-
- async function toggleRenoteMute() {
- os.apiWithDialog(user.isRenoteMuted ? 'renote-mute/delete' : 'renote-mute/create', {
- userId: user.id,
- }).then(() => {
- user.isRenoteMuted = !user.isRenoteMuted;
- });
- }
-
- async function toggleBlock() {
- if (!await getConfirmed(user.isBlocking ? i18n.ts.unblockConfirm : i18n.ts.blockConfirm)) return;
-
- os.apiWithDialog(user.isBlocking ? 'blocking/delete' : 'blocking/create', {
- userId: user.id,
- }).then(() => {
- user.isBlocking = !user.isBlocking;
- });
- }
-
- async function toggleNotify() {
- os.apiWithDialog('following/update', {
- userId: user.id,
- notify: user.notify === 'normal' ? 'none' : 'normal',
- }).then(() => {
- user.notify = user.notify === 'normal' ? 'none' : 'normal';
- });
- }
-
- function reportAbuse() {
- const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkAbuseReportWindow.vue')), {
- user: user,
- }, {
- closed: () => dispose(),
- });
- }
-
- async function getConfirmed(text: string): Promise {
- const confirm = await os.confirm({
- type: 'warning',
- title: 'confirm',
- text,
- });
-
- return !confirm.canceled;
- }
-
- async function userInfoUpdate() {
- os.apiWithDialog('federation/update-remote-user', {
- userId: user.id,
- });
- }
-
- async function invalidateFollow() {
- if (!await getConfirmed(i18n.ts.breakFollowConfirm)) return;
-
- os.apiWithDialog('following/invalidate', {
- userId: user.id,
- }).then(() => {
- user.isFollowed = !user.isFollowed;
- });
- }
-
- async function editMemo(): Promise {
- const userDetailed = await misskeyApi('users/show', {
- userId: user.id,
- });
- const { canceled, result } = await os.form(i18n.ts.editMemo, {
- memo: {
- type: 'string',
- required: true,
- multiline: true,
- label: i18n.ts.memo,
- default: userDetailed.memo,
- },
- });
- if (canceled) return;
-
- os.apiWithDialog('users/update-memo', {
- memo: result.memo,
- userId: user.id,
- });
- }
-
- const menuItems: MenuItem[] = [];
-
- menuItems.push({
- icon: 'ti ti-at',
- text: i18n.ts.copyUsername,
- action: () => {
- copyToClipboard(`@${user.username}@${user.host ?? host}`);
- },
- });
-
- if (notesSearchAvailable && (user.host == null || canSearchNonLocalNotes)) {
- menuItems.push({
- icon: 'ti ti-search',
- text: i18n.ts.searchThisUsersNotes,
- action: () => {
- router.push(`/search?username=${encodeURIComponent(user.username)}${user.host != null ? '&host=' + encodeURIComponent(user.host) : ''}`);
- },
- });
- }
-
- if (iAmModerator) {
- menuItems.push({
- icon: 'ti ti-user-exclamation',
- text: i18n.ts.moderation,
- action: () => {
- router.push(`/admin/user/${user.id}`);
- },
- });
- }
-
- menuItems.push({
- icon: 'ti ti-rss',
- text: i18n.ts.copyRSS,
- action: () => {
- copyToClipboard(`${user.host ?? host}/@${user.username}.atom`);
- },
- });
-
- if (user.host != null && user.url != null) {
- menuItems.push({
- icon: 'ti ti-external-link',
- text: i18n.ts.showOnRemote,
- action: () => {
- if (user.url == null) return;
- window.open(user.url, '_blank', 'noopener');
- },
- });
- } else {
- menuItems.push({
- icon: 'ti ti-code',
- text: i18n.ts.genEmbedCode,
- type: 'parent',
- children: [{
- text: i18n.ts.noteOfThisUser,
- action: () => {
- genEmbedCode('user-timeline', user.id);
- },
- }], // TODO: ユーザーカードの埋め込みなど
- });
- }
-
- menuItems.push({
- icon: 'ti ti-share',
- text: i18n.ts.copyProfileUrl,
- action: () => {
- const canonical = user.host === null ? `@${user.username}` : `@${user.username}@${toUnicode(user.host)}`;
- copyToClipboard(`${url}/${canonical}`);
- },
- });
-
- if ($i) {
- menuItems.push({
- icon: 'ti ti-mail',
- text: i18n.ts.sendMessage,
- action: () => {
- const canonical = user.host === null ? `@${user.username}` : `@${user.username}@${user.host}`;
- os.post({ specified: user, initialText: `${canonical} ` });
- },
- }, { type: 'divider' }, {
- icon: 'ti ti-pencil',
- text: i18n.ts.editMemo,
- action: editMemo,
- }, {
- type: 'parent',
- icon: 'ti ti-list',
- text: i18n.ts.addToList,
- children: async () => {
- const lists = await userListsCache.fetch();
- return lists.map(list => {
- const isListed = ref(list.userIds?.includes(user.id) ?? false);
- cleanups.push(watch(isListed, () => {
- if (isListed.value) {
- os.apiWithDialog('users/lists/push', {
- listId: list.id,
- userId: user.id,
- }).then(() => {
- list.userIds?.push(user.id);
- });
- } else {
- os.apiWithDialog('users/lists/pull', {
- listId: list.id,
- userId: user.id,
- }).then(() => {
- list.userIds?.splice(list.userIds.indexOf(user.id), 1);
- });
- }
- }));
-
- return {
- type: 'switch',
- text: list.name,
- ref: isListed,
- };
- });
- },
- }, {
- type: 'parent',
- icon: 'ti ti-antenna',
- text: i18n.ts.addToAntenna,
- children: async () => {
- const antennas = await antennasCache.fetch();
- const canonical = user.host === null ? `@${user.username}` : `@${user.username}@${toUnicode(user.host)}`;
- return antennas.filter((a) => a.src === 'users').map(antenna => ({
- text: antenna.name,
- action: async () => {
- await os.apiWithDialog('antennas/update', {
- antennaId: antenna.id,
- name: antenna.name,
- keywords: antenna.keywords,
- excludeKeywords: antenna.excludeKeywords,
- src: antenna.src,
- userListId: antenna.userListId,
- users: [...antenna.users, canonical],
- caseSensitive: antenna.caseSensitive,
- withReplies: antenna.withReplies,
- withFile: antenna.withFile,
- notify: antenna.notify,
- });
- antennasCache.delete();
- },
- }));
- },
- });
- }
-
- if ($i && meId !== user.id) {
- if (iAmModerator) {
- menuItems.push({
- type: 'parent',
- icon: 'ti ti-badges',
- text: i18n.ts.roles,
- children: async () => {
- const roles = await rolesCache.fetch();
-
- return roles.filter(r => r.target === 'manual').map(r => ({
- text: r.name,
- action: async () => {
- const { canceled, result: period } = await os.select({
- title: i18n.ts.period + ': ' + r.name,
- items: [{
- value: 'indefinitely', text: i18n.ts.indefinitely,
- }, {
- value: 'oneHour', text: i18n.ts.oneHour,
- }, {
- value: 'oneDay', text: i18n.ts.oneDay,
- }, {
- value: 'oneWeek', text: i18n.ts.oneWeek,
- }, {
- value: 'oneMonth', text: i18n.ts.oneMonth,
- }],
- default: 'indefinitely',
- });
- if (canceled) return;
-
- const expiresAt = period === 'indefinitely' ? null
- : period === 'oneHour' ? Date.now() + (1000 * 60 * 60)
- : period === 'oneDay' ? Date.now() + (1000 * 60 * 60 * 24)
- : period === 'oneWeek' ? Date.now() + (1000 * 60 * 60 * 24 * 7)
- : period === 'oneMonth' ? Date.now() + (1000 * 60 * 60 * 24 * 30)
- : null;
-
- os.apiWithDialog('admin/roles/assign', { roleId: r.id, userId: user.id, expiresAt });
- },
- }));
- },
- });
- }
-
- // フォローしたとしても user.isFollowing はリアルタイム更新されないので不便なため
- //if (user.isFollowing) {
- const withRepliesRef = ref(user.withReplies ?? false);
-
- menuItems.push({
- type: 'switch',
- icon: 'ti ti-messages',
- text: i18n.ts.showRepliesToOthersInTimeline,
- ref: withRepliesRef,
- }, {
- icon: user.notify === 'none' ? 'ti ti-bell' : 'ti ti-bell-off',
- text: user.notify === 'none' ? i18n.ts.notifyNotes : i18n.ts.unnotifyNotes,
- action: toggleNotify,
- });
-
- watch(withRepliesRef, (withReplies) => {
- misskeyApi('following/update', {
- userId: user.id,
- withReplies,
- }).then(() => {
- user.withReplies = withReplies;
- });
- });
- //}
-
- menuItems.push({ type: 'divider' }, {
- icon: user.isMuted ? 'ti ti-eye' : 'ti ti-eye-off',
- text: user.isMuted ? i18n.ts.unmute : i18n.ts.mute,
- action: toggleMute,
- }, {
- icon: user.isRenoteMuted ? 'ti ti-repeat' : 'ti ti-repeat-off',
- text: user.isRenoteMuted ? i18n.ts.renoteUnmute : i18n.ts.renoteMute,
- action: toggleRenoteMute,
- }, {
- icon: 'ti ti-ban',
- text: user.isBlocking ? i18n.ts.unblock : i18n.ts.block,
- action: toggleBlock,
- });
-
- if (user.isFollowed) {
- menuItems.push({
- icon: 'ti ti-link-off',
- text: i18n.ts.breakFollow,
- action: invalidateFollow,
- });
- }
-
- menuItems.push({ type: 'divider' }, {
- icon: 'ti ti-exclamation-circle',
- text: i18n.ts.reportAbuse,
- action: reportAbuse,
- });
- }
-
- if (user.host !== null) {
- menuItems.push({ type: 'divider' }, {
- icon: 'ti ti-refresh',
- text: i18n.ts.updateRemoteUser,
- action: userInfoUpdate,
- });
- }
-
- if (prefer.s.devMode) {
- menuItems.push({ type: 'divider' }, {
- icon: 'ti ti-id',
- text: i18n.ts.copyUserId,
- action: () => {
- copyToClipboard(user.id);
- },
- });
- }
-
- if ($i && meId === user.id) {
- menuItems.push({ type: 'divider' }, {
- icon: 'ti ti-pencil',
- text: i18n.ts.editProfile,
- action: () => {
- router.push('/settings/profile');
- },
- });
- }
-
- if (userActions.length > 0) {
- menuItems.push({ type: 'divider' }, ...userActions.map(action => ({
- icon: 'ti ti-plug',
- text: action.title,
- action: () => {
- action.handler(user);
- },
- })));
- }
-
- return {
- menu: menuItems,
- cleanup: () => {
- if (_DEV_) console.log('user menu cleanup', cleanups);
- for (const cl of cleanups) {
- cl();
- }
- },
- };
-}
diff --git a/packages/frontend/src/scripts/get-user-name.ts b/packages/frontend/src/scripts/get-user-name.ts
deleted file mode 100644
index 56e91abba0..0000000000
--- a/packages/frontend/src/scripts/get-user-name.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-export default function(user: { name?: string | null, username: string }): string {
- return user.name === '' ? user.username : user.name ?? user.username;
-}
diff --git a/packages/frontend/src/scripts/hotkey.ts b/packages/frontend/src/scripts/hotkey.ts
deleted file mode 100644
index 04fb235694..0000000000
--- a/packages/frontend/src/scripts/hotkey.ts
+++ /dev/null
@@ -1,172 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-import { getHTMLElementOrNull } from "@/scripts/get-dom-node-or-null.js";
-
-//#region types
-export type Keymap = Record;
-
-type CallbackFunction = (ev: KeyboardEvent) => unknown;
-
-type CallbackObject = {
- callback: CallbackFunction;
- allowRepeat?: boolean;
-};
-
-type Pattern = {
- which: string[];
- ctrl: boolean;
- alt: boolean;
- shift: boolean;
-};
-
-type Action = {
- patterns: Pattern[];
- callback: CallbackFunction;
- options: Required>;
-};
-//#endregion
-
-//#region consts
-const KEY_ALIASES = {
- 'esc': 'Escape',
- 'enter': 'Enter',
- 'space': ' ',
- 'up': 'ArrowUp',
- 'down': 'ArrowDown',
- 'left': 'ArrowLeft',
- 'right': 'ArrowRight',
- 'plus': ['+', ';'],
-};
-
-const MODIFIER_KEYS = ['ctrl', 'alt', 'shift'];
-
-const IGNORE_ELEMENTS = ['input', 'textarea'];
-//#endregion
-
-//#region store
-let latestHotkey: Pattern & { callback: CallbackFunction } | null = null;
-//#endregion
-
-//#region impl
-export const makeHotkey = (keymap: Keymap) => {
- const actions = parseKeymap(keymap);
- return (ev: KeyboardEvent) => {
- if ('pswp' in window && window.pswp != null) return;
- if (document.activeElement != null) {
- if (IGNORE_ELEMENTS.includes(document.activeElement.tagName.toLowerCase())) return;
- if (getHTMLElementOrNull(document.activeElement)?.isContentEditable) return;
- }
- for (const action of actions) {
- if (matchPatterns(ev, action)) {
- ev.preventDefault();
- ev.stopPropagation();
- action.callback(ev);
- storePattern(ev, action.callback);
- }
- }
- };
-};
-
-const parseKeymap = (keymap: Keymap) => {
- return Object.entries(keymap).map(([rawPatterns, rawCallback]) => {
- const patterns = parsePatterns(rawPatterns);
- const callback = parseCallback(rawCallback);
- const options = parseOptions(rawCallback);
- return { patterns, callback, options } as const satisfies Action;
- });
-};
-
-const parsePatterns = (rawPatterns: keyof Keymap) => {
- return rawPatterns.split('|').map(part => {
- const keys = part.split('+').map(trimLower);
- const which = parseKeyCode(keys.findLast(x => !MODIFIER_KEYS.includes(x)));
- const ctrl = keys.includes('ctrl');
- const alt = keys.includes('alt');
- const shift = keys.includes('shift');
- return { which, ctrl, alt, shift } as const satisfies Pattern;
- });
-};
-
-const parseCallback = (rawCallback: Keymap[keyof Keymap]) => {
- if (typeof rawCallback === 'object') {
- return rawCallback.callback;
- }
- return rawCallback;
-};
-
-const parseOptions = (rawCallback: Keymap[keyof Keymap]) => {
- const defaultOptions = {
- allowRepeat: false,
- } as const satisfies Action['options'];
- if (typeof rawCallback === 'object') {
- const { callback, ...rawOptions } = rawCallback;
- const options = { ...defaultOptions, ...rawOptions };
- return { ...options } as const satisfies Action['options'];
- }
- return { ...defaultOptions } as const satisfies Action['options'];
-};
-
-const matchPatterns = (ev: KeyboardEvent, action: Action) => {
- const { patterns, options, callback } = action;
- if (ev.repeat && !options.allowRepeat) return false;
- const key = ev.key.toLowerCase();
- return patterns.some(({ which, ctrl, shift, alt }) => {
- if (
- options.allowRepeat === false &&
- latestHotkey != null &&
- latestHotkey.which.includes(key) &&
- latestHotkey.ctrl === ctrl &&
- latestHotkey.alt === alt &&
- latestHotkey.shift === shift &&
- latestHotkey.callback === callback
- ) {
- return false;
- }
- if (!which.includes(key)) return false;
- if (ctrl !== (ev.ctrlKey || ev.metaKey)) return false;
- if (alt !== ev.altKey) return false;
- if (shift !== ev.shiftKey) return false;
- return true;
- });
-};
-
-let lastHotKeyStoreTimer: number | null = null;
-
-const storePattern = (ev: KeyboardEvent, callback: CallbackFunction) => {
- if (lastHotKeyStoreTimer != null) {
- clearTimeout(lastHotKeyStoreTimer);
- }
-
- latestHotkey = {
- which: [ev.key.toLowerCase()],
- ctrl: ev.ctrlKey || ev.metaKey,
- alt: ev.altKey,
- shift: ev.shiftKey,
- callback,
- };
-
- lastHotKeyStoreTimer = window.setTimeout(() => {
- latestHotkey = null;
- }, 500);
-};
-
-const parseKeyCode = (input?: string | null) => {
- if (input == null) return [];
- const raw = getValueByKey(KEY_ALIASES, input);
- if (raw == null) return [input];
- if (typeof raw === 'string') return [trimLower(raw)];
- return raw.map(trimLower);
-};
-
-const getValueByKey = <
- T extends Record,
- K extends keyof T | keyof any,
- R extends K extends keyof T ? T[K] : T[keyof T] | undefined,
->(obj: T, key: K) => {
- return obj[key] as R;
-};
-
-const trimLower = (str: string) => str.trim().toLowerCase();
-//#endregion
diff --git a/packages/frontend/src/scripts/idb-proxy.ts b/packages/frontend/src/scripts/idb-proxy.ts
deleted file mode 100644
index 20f51660c7..0000000000
--- a/packages/frontend/src/scripts/idb-proxy.ts
+++ /dev/null
@@ -1,53 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-// FirefoxのプライベートモードなどではindexedDBが使用不可能なので、
-// indexedDBが使えない環境ではlocalStorageを使う
-import {
- get as iget,
- set as iset,
- del as idel,
-} from 'idb-keyval';
-import { miLocalStorage } from '@/local-storage.js';
-
-const PREFIX = 'idbfallback::';
-
-let idbAvailable = typeof window !== 'undefined' ? !!(window.indexedDB && typeof window.indexedDB.open === 'function') : true;
-
-// iframe.contentWindow.indexedDB.deleteDatabase() がchromeのバグで使用できないため、indexedDBを無効化している。
-// バグが治って再度有効化するのであれば、cypressのコマンド内のコメントアウトを外すこと
-// see https://github.com/misskey-dev/misskey/issues/13605#issuecomment-2053652123
-// eslint-disable-next-line @typescript-eslint/ban-ts-comment
-// @ts-expect-error
-if (window.Cypress) {
- idbAvailable = false;
- console.log('Cypress detected. It will use localStorage.');
-}
-
-if (idbAvailable) {
- await iset('idb-test', 'test')
- .catch(err => {
- console.error('idb error', err);
- console.error('indexedDB is unavailable. It will use localStorage.');
- idbAvailable = false;
- });
-} else {
- console.error('indexedDB is unavailable. It will use localStorage.');
-}
-
-export async function get(key: string) {
- if (idbAvailable) return iget(key);
- return miLocalStorage.getItemAsJson(`${PREFIX}${key}`);
-}
-
-export async function set(key: string, val: any) {
- if (idbAvailable) return iset(key, val);
- return miLocalStorage.setItemAsJson(`${PREFIX}${key}`, val);
-}
-
-export async function del(key: string) {
- if (idbAvailable) return idel(key);
- return miLocalStorage.removeItem(`${PREFIX}${key}`);
-}
diff --git a/packages/frontend/src/scripts/idle-render.ts b/packages/frontend/src/scripts/idle-render.ts
deleted file mode 100644
index 6adfedcb9f..0000000000
--- a/packages/frontend/src/scripts/idle-render.ts
+++ /dev/null
@@ -1,60 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-const requestIdleCallback: typeof globalThis.requestIdleCallback = globalThis.requestIdleCallback ?? ((callback) => {
- const start = performance.now();
- const timeoutId = setTimeout(() => {
- callback({
- didTimeout: false, // polyfill でタイムアウト発火することはない
- timeRemaining() {
- const diff = performance.now() - start;
- return Math.max(0, 50 - diff); //
- },
- });
- });
- return timeoutId;
-});
-const cancelIdleCallback: typeof globalThis.cancelIdleCallback = globalThis.cancelIdleCallback ?? ((timeoutId) => {
- clearTimeout(timeoutId);
-});
-
-class IdlingRenderScheduler {
- #renderers: Set;
- #rafId: number;
- #ricId: number;
-
- constructor() {
- this.#renderers = new Set();
- this.#rafId = 0;
- this.#ricId = requestIdleCallback((deadline) => this.#schedule(deadline));
- }
-
- #schedule(deadline: IdleDeadline): void {
- if (deadline.timeRemaining()) {
- this.#rafId = requestAnimationFrame((time) => {
- for (const renderer of this.#renderers) {
- renderer(time);
- }
- });
- }
- this.#ricId = requestIdleCallback((arg) => this.#schedule(arg));
- }
-
- add(renderer: FrameRequestCallback): void {
- this.#renderers.add(renderer);
- }
-
- delete(renderer: FrameRequestCallback): void {
- this.#renderers.delete(renderer);
- }
-
- dispose(): void {
- this.#renderers.clear();
- cancelAnimationFrame(this.#rafId);
- cancelIdleCallback(this.#ricId);
- }
-}
-
-export const defaultIdlingRenderScheduler = new IdlingRenderScheduler();
diff --git a/packages/frontend/src/scripts/init-chart.ts b/packages/frontend/src/scripts/init-chart.ts
deleted file mode 100644
index 037b0d9567..0000000000
--- a/packages/frontend/src/scripts/init-chart.ts
+++ /dev/null
@@ -1,58 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import {
- Chart,
- ArcElement,
- LineElement,
- BarElement,
- PointElement,
- BarController,
- LineController,
- DoughnutController,
- CategoryScale,
- LinearScale,
- TimeScale,
- Legend,
- Title,
- Tooltip,
- SubTitle,
- Filler,
-} from 'chart.js';
-import gradient from 'chartjs-plugin-gradient';
-import zoomPlugin from 'chartjs-plugin-zoom';
-import { MatrixController, MatrixElement } from 'chartjs-chart-matrix';
-import { store } from '@/store.js';
-import 'chartjs-adapter-date-fns';
-
-export function initChart() {
- Chart.register(
- ArcElement,
- LineElement,
- BarElement,
- PointElement,
- BarController,
- LineController,
- DoughnutController,
- CategoryScale,
- LinearScale,
- TimeScale,
- Legend,
- Title,
- Tooltip,
- SubTitle,
- Filler,
- MatrixController, MatrixElement,
- zoomPlugin,
- gradient,
- );
-
- // フォントカラー
- Chart.defaults.color = getComputedStyle(document.documentElement).getPropertyValue('--MI_THEME-fg');
-
- Chart.defaults.borderColor = store.state.darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)';
-
- Chart.defaults.animation = false;
-}
diff --git a/packages/frontend/src/scripts/initialize-sw.ts b/packages/frontend/src/scripts/initialize-sw.ts
deleted file mode 100644
index 867ebf19ed..0000000000
--- a/packages/frontend/src/scripts/initialize-sw.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import { lang } from '@@/js/config.js';
-
-export async function initializeSw() {
- if (!('serviceWorker' in navigator)) return;
-
- navigator.serviceWorker.register('/sw.js', { scope: '/', type: 'classic' });
- navigator.serviceWorker.ready.then(registration => {
- registration.active?.postMessage({
- msg: 'initialize',
- lang,
- });
- });
-}
diff --git a/packages/frontend/src/scripts/intl-const.ts b/packages/frontend/src/scripts/intl-const.ts
deleted file mode 100644
index 385f59ec39..0000000000
--- a/packages/frontend/src/scripts/intl-const.ts
+++ /dev/null
@@ -1,50 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import { lang } from '@@/js/config.js';
-
-export const versatileLang = (lang ?? 'ja-JP').replace('ja-KS', 'ja-JP');
-
-let _dateTimeFormat: Intl.DateTimeFormat;
-try {
- _dateTimeFormat = new Intl.DateTimeFormat(versatileLang, {
- year: 'numeric',
- month: 'numeric',
- day: 'numeric',
- hour: 'numeric',
- minute: 'numeric',
- second: 'numeric',
- });
-} catch (err) {
- console.warn(err);
- if (_DEV_) console.log('[Intl] Fallback to en-US');
-
- // Fallback to en-US
- _dateTimeFormat = new Intl.DateTimeFormat('en-US', {
- year: 'numeric',
- month: 'numeric',
- day: 'numeric',
- hour: 'numeric',
- minute: 'numeric',
- second: 'numeric',
- });
-}
-export const dateTimeFormat = _dateTimeFormat;
-
-export const timeZone = dateTimeFormat.resolvedOptions().timeZone;
-
-export const hemisphere = /^(australia|pacific|antarctica|indian)\//i.test(timeZone) ? 'S' : 'N';
-
-let _numberFormat: Intl.NumberFormat;
-try {
- _numberFormat = new Intl.NumberFormat(versatileLang);
-} catch (err) {
- console.warn(err);
- if (_DEV_) console.log('[Intl] Fallback to en-US');
-
- // Fallback to en-US
- _numberFormat = new Intl.NumberFormat('en-US');
-}
-export const numberFormat = _numberFormat;
diff --git a/packages/frontend/src/scripts/intl-string.ts b/packages/frontend/src/scripts/intl-string.ts
deleted file mode 100644
index a5b5bbb592..0000000000
--- a/packages/frontend/src/scripts/intl-string.ts
+++ /dev/null
@@ -1,97 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import { versatileLang } from '@@/js/intl-const.js';
-import type { toHiragana as toHiraganaType } from 'wanakana';
-
-let toHiragana: typeof toHiraganaType = (str?: string) => str ?? '';
-let isWanakanaLoaded = false;
-
-/**
- * ローマ字変換のセットアップ(日本語以外の環境で読み込まないのでlazy-loading)
- *
- * ここの比較系関数を使う際は事前に呼び出す必要がある
- */
-export async function initIntlString(forceWanakana = false) {
- if ((!versatileLang.includes('ja') && !forceWanakana) || isWanakanaLoaded) return;
- const { toHiragana: _toHiragana } = await import('wanakana');
- toHiragana = _toHiragana;
- isWanakanaLoaded = true;
-}
-
-/**
- * - 全角英数字を半角に
- * - 半角カタカナを全角に
- * - 濁点・半濁点がリガチャになっている(例: `か` + `゛` )ひらがな・カタカナを結合
- * - 異体字を正規化
- * - 小文字に揃える
- * - 文字列のトリム
- */
-export function normalizeString(str: string) {
- const segmenter = new Intl.Segmenter(versatileLang, { granularity: 'grapheme' });
- return [...segmenter.segment(str)].map(({ segment }) => segment.normalize('NFKC')).join('').toLowerCase().trim();
-}
-
-// https://qiita.com/non-caffeine/items/77360dda05c8ce510084
-const hyphens = [
- 0x002d, // hyphen-minus
- 0x02d7, // modifier letter minus sign
- 0x1173, // hangul jongseong eu
- 0x1680, // ogham space mark
- 0x1b78, // balinese musical symbol left-hand open pang
- 0x2010, // hyphen
- 0x2011, // non-breaking hyphen
- 0x2012, // figure dash
- 0x2013, // en dash
- 0x2014, // em dash
- 0x2015, // horizontal bar
- 0x2043, // hyphen bullet
- 0x207b, // superscript minus
- 0x2212, // minus sign
- 0x25ac, // black rectangle
- 0x2500, // box drawings light horizontal
- 0x2501, // box drawings heavy horizontal
- 0x2796, // heavy minus sign
- 0x30fc, // katakana-hiragana prolonged sound mark
- 0x3161, // hangul letter eu
- 0xfe58, // small em dash
- 0xfe63, // small hyphen-minus
- 0xff0d, // fullwidth hyphen-minus
- 0xff70, // halfwidth katakana-hiragana prolonged sound mark
- 0x10110, // aegean number ten
- 0x10191, // roman uncia sign
-];
-
-const hyphensCodePoints = hyphens.map(code => `\\u{${code.toString(16).padStart(4, '0')}}`);
-
-/** ハイフンを統一(ローマ字半角入力時に`ー`と`-`が判定できない問題の調整) */
-export function normalizeHyphens(str: string) {
- return str.replace(new RegExp(`[${hyphensCodePoints.join('')}]`, 'ug'), '\u002d');
-}
-
-/**
- * `normalizeString` に加えて、カタカナ・ローマ字をひらがなに揃え、ハイフンを統一
- *
- * (ローマ字じゃないものもローマ字として認識され変換されるので、文字列比較の際は `normalizeString` を併用する必要あり)
- */
-export function normalizeStringWithHiragana(str: string) {
- return normalizeHyphens(toHiragana(normalizeString(str), { convertLongVowelMark: false }));
-}
-
-/** aとbが同じかどうか */
-export function compareStringEquals(a: string, b: string) {
- return (
- normalizeString(a) === normalizeString(b) ||
- normalizeStringWithHiragana(a) === normalizeStringWithHiragana(b)
- );
-}
-
-/** baseにqueryが含まれているかどうか */
-export function compareStringIncludes(base: string, query: string) {
- return (
- normalizeString(base).includes(normalizeString(query)) ||
- normalizeStringWithHiragana(base).includes(normalizeStringWithHiragana(query))
- );
-}
diff --git a/packages/frontend/src/scripts/is-device-darkmode.ts b/packages/frontend/src/scripts/is-device-darkmode.ts
deleted file mode 100644
index 4f487c7cb9..0000000000
--- a/packages/frontend/src/scripts/is-device-darkmode.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-export function isDeviceDarkmode() {
- return window.matchMedia('(prefers-color-scheme: dark)').matches;
-}
diff --git a/packages/frontend/src/scripts/isFfVisibleForMe.ts b/packages/frontend/src/scripts/isFfVisibleForMe.ts
deleted file mode 100644
index e28e5725bc..0000000000
--- a/packages/frontend/src/scripts/isFfVisibleForMe.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import * as Misskey from 'misskey-js';
-import { $i } from '@/account.js';
-
-export function isFollowingVisibleForMe(user: Misskey.entities.UserDetailed): boolean {
- if ($i && ($i.id === user.id || $i.isAdmin || $i.isModerator)) return true;
-
- if (user.followingVisibility === 'private') return false;
- if (user.followingVisibility === 'followers' && !user.isFollowing) return false;
-
- return true;
-}
-export function isFollowersVisibleForMe(user: Misskey.entities.UserDetailed): boolean {
- if ($i && ($i.id === user.id || $i.isAdmin || $i.isModerator)) return true;
-
- if (user.followersVisibility === 'private') return false;
- if (user.followersVisibility === 'followers' && !user.isFollowing) return false;
-
- return true;
-}
diff --git a/packages/frontend/src/scripts/key-event.ts b/packages/frontend/src/scripts/key-event.ts
deleted file mode 100644
index 020a6c2174..0000000000
--- a/packages/frontend/src/scripts/key-event.ts
+++ /dev/null
@@ -1,153 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-/**
- * {@link KeyboardEvent.code} の値を表す文字列。不足分は適宜追加する
- * @see https://developer.mozilla.org/en-US/docs/Web/API/UI_Events/Keyboard_event_code_values
- */
-export type KeyCode = (
- | 'Backspace'
- | 'Tab'
- | 'Enter'
- | 'Shift'
- | 'Control'
- | 'Alt'
- | 'Pause'
- | 'CapsLock'
- | 'Escape'
- | 'Space'
- | 'PageUp'
- | 'PageDown'
- | 'End'
- | 'Home'
- | 'ArrowLeft'
- | 'ArrowUp'
- | 'ArrowRight'
- | 'ArrowDown'
- | 'Insert'
- | 'Delete'
- | 'Digit0'
- | 'Digit1'
- | 'Digit2'
- | 'Digit3'
- | 'Digit4'
- | 'Digit5'
- | 'Digit6'
- | 'Digit7'
- | 'Digit8'
- | 'Digit9'
- | 'KeyA'
- | 'KeyB'
- | 'KeyC'
- | 'KeyD'
- | 'KeyE'
- | 'KeyF'
- | 'KeyG'
- | 'KeyH'
- | 'KeyI'
- | 'KeyJ'
- | 'KeyK'
- | 'KeyL'
- | 'KeyM'
- | 'KeyN'
- | 'KeyO'
- | 'KeyP'
- | 'KeyQ'
- | 'KeyR'
- | 'KeyS'
- | 'KeyT'
- | 'KeyU'
- | 'KeyV'
- | 'KeyW'
- | 'KeyX'
- | 'KeyY'
- | 'KeyZ'
- | 'MetaLeft'
- | 'MetaRight'
- | 'ContextMenu'
- | 'F1'
- | 'F2'
- | 'F3'
- | 'F4'
- | 'F5'
- | 'F6'
- | 'F7'
- | 'F8'
- | 'F9'
- | 'F10'
- | 'F11'
- | 'F12'
- | 'NumLock'
- | 'ScrollLock'
- | 'Semicolon'
- | 'Equal'
- | 'Comma'
- | 'Minus'
- | 'Period'
- | 'Slash'
- | 'Backquote'
- | 'BracketLeft'
- | 'Backslash'
- | 'BracketRight'
- | 'Quote'
- | 'Meta'
- | 'AltGraph'
-);
-
-/**
- * 修飾キーを表す文字列。不足分は適宜追加する。
- */
-export type KeyModifier = (
- | 'Shift'
- | 'Control'
- | 'Alt'
- | 'Meta'
-);
-
-/**
- * 押下されたキー以外の状態を表す文字列。不足分は適宜追加する。
- */
-export type KeyState = (
- | 'composing'
- | 'repeat'
-);
-
-export type KeyEventHandler = {
- modifiers?: KeyModifier[];
- states?: KeyState[];
- code: KeyCode | 'any';
- handler: (event: KeyboardEvent) => void;
-};
-
-export function handleKeyEvent(event: KeyboardEvent, handlers: KeyEventHandler[]) {
- function checkModifier(ev: KeyboardEvent, modifiers? : KeyModifier[]) {
- if (modifiers) {
- return modifiers.every(modifier => ev.getModifierState(modifier));
- }
- return true;
- }
-
- function checkState(ev: KeyboardEvent, states?: KeyState[]) {
- if (states) {
- return states.every(state => ev.getModifierState(state));
- }
- return true;
- }
-
- let hit = false;
- for (const handler of handlers.filter(it => it.code === event.code)) {
- if (checkModifier(event, handler.modifiers) && checkState(event, handler.states)) {
- handler.handler(event);
- hit = true;
- break;
- }
- }
-
- if (!hit) {
- for (const handler of handlers.filter(it => it.code === 'any')) {
- handler.handler(event);
- }
- }
-}
diff --git a/packages/frontend/src/scripts/langmap.ts b/packages/frontend/src/scripts/langmap.ts
deleted file mode 100644
index b32de15963..0000000000
--- a/packages/frontend/src/scripts/langmap.ts
+++ /dev/null
@@ -1,671 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-// TODO: sharedに置いてバックエンドのと統合したい
-export const langmap = {
- 'ach': {
- nativeName: 'Lwo',
- },
- 'ady': {
- nativeName: 'Адыгэбзэ',
- },
- 'af': {
- nativeName: 'Afrikaans',
- },
- 'af-NA': {
- nativeName: 'Afrikaans (Namibia)',
- },
- 'af-ZA': {
- nativeName: 'Afrikaans (South Africa)',
- },
- 'ak': {
- nativeName: 'Tɕɥi',
- },
- 'ar': {
- nativeName: 'العربية',
- },
- 'ar-AR': {
- nativeName: 'العربية',
- },
- 'ar-MA': {
- nativeName: 'العربية',
- },
- 'ar-SA': {
- nativeName: 'العربية (السعودية)',
- },
- 'ay-BO': {
- nativeName: 'Aymar aru',
- },
- 'az': {
- nativeName: 'Azərbaycan dili',
- },
- 'az-AZ': {
- nativeName: 'Azərbaycan dili',
- },
- 'be-BY': {
- nativeName: 'Беларуская',
- },
- 'bg': {
- nativeName: 'Български',
- },
- 'bg-BG': {
- nativeName: 'Български',
- },
- 'bn': {
- nativeName: 'বাংলা',
- },
- 'bn-IN': {
- nativeName: 'বাংলা (ভারত)',
- },
- 'bn-BD': {
- nativeName: 'বাংলা(বাংলাদেশ)',
- },
- 'br': {
- nativeName: 'Brezhoneg',
- },
- 'bs-BA': {
- nativeName: 'Bosanski',
- },
- 'ca': {
- nativeName: 'Català',
- },
- 'ca-ES': {
- nativeName: 'Català',
- },
- 'cak': {
- nativeName: 'Maya Kaqchikel',
- },
- 'ck-US': {
- nativeName: 'ᏣᎳᎩ (tsalagi)',
- },
- 'cs': {
- nativeName: 'Čeština',
- },
- 'cs-CZ': {
- nativeName: 'Čeština',
- },
- 'cy': {
- nativeName: 'Cymraeg',
- },
- 'cy-GB': {
- nativeName: 'Cymraeg',
- },
- 'da': {
- nativeName: 'Dansk',
- },
- 'da-DK': {
- nativeName: 'Dansk',
- },
- 'de': {
- nativeName: 'Deutsch',
- },
- 'de-AT': {
- nativeName: 'Deutsch (Österreich)',
- },
- 'de-DE': {
- nativeName: 'Deutsch (Deutschland)',
- },
- 'de-CH': {
- nativeName: 'Deutsch (Schweiz)',
- },
- 'dsb': {
- nativeName: 'Dolnoserbšćina',
- },
- 'el': {
- nativeName: 'Ελληνικά',
- },
- 'el-GR': {
- nativeName: 'Ελληνικά',
- },
- 'en': {
- nativeName: 'English',
- },
- 'en-GB': {
- nativeName: 'English (UK)',
- },
- 'en-AU': {
- nativeName: 'English (Australia)',
- },
- 'en-CA': {
- nativeName: 'English (Canada)',
- },
- 'en-IE': {
- nativeName: 'English (Ireland)',
- },
- 'en-IN': {
- nativeName: 'English (India)',
- },
- 'en-PI': {
- nativeName: 'English (Pirate)',
- },
- 'en-SG': {
- nativeName: 'English (Singapore)',
- },
- 'en-UD': {
- nativeName: 'English (Upside Down)',
- },
- 'en-US': {
- nativeName: 'English (US)',
- },
- 'en-ZA': {
- nativeName: 'English (South Africa)',
- },
- 'en@pirate': {
- nativeName: 'English (Pirate)',
- },
- 'eo': {
- nativeName: 'Esperanto',
- },
- 'eo-EO': {
- nativeName: 'Esperanto',
- },
- 'es': {
- nativeName: 'Español',
- },
- 'es-AR': {
- nativeName: 'Español (Argentine)',
- },
- 'es-419': {
- nativeName: 'Español (Latinoamérica)',
- },
- 'es-CL': {
- nativeName: 'Español (Chile)',
- },
- 'es-CO': {
- nativeName: 'Español (Colombia)',
- },
- 'es-EC': {
- nativeName: 'Español (Ecuador)',
- },
- 'es-ES': {
- nativeName: 'Español (España)',
- },
- 'es-LA': {
- nativeName: 'Español (Latinoamérica)',
- },
- 'es-NI': {
- nativeName: 'Español (Nicaragua)',
- },
- 'es-MX': {
- nativeName: 'Español (México)',
- },
- 'es-US': {
- nativeName: 'Español (Estados Unidos)',
- },
- 'es-VE': {
- nativeName: 'Español (Venezuela)',
- },
- 'et': {
- nativeName: 'eesti keel',
- },
- 'et-EE': {
- nativeName: 'Eesti (Estonia)',
- },
- 'eu': {
- nativeName: 'Euskara',
- },
- 'eu-ES': {
- nativeName: 'Euskara',
- },
- 'fa': {
- nativeName: 'فارسی',
- },
- 'fa-IR': {
- nativeName: 'فارسی',
- },
- 'fb-LT': {
- nativeName: 'Leet Speak',
- },
- 'ff': {
- nativeName: 'Fulah',
- },
- 'fi': {
- nativeName: 'Suomi',
- },
- 'fi-FI': {
- nativeName: 'Suomi',
- },
- 'fo': {
- nativeName: 'Føroyskt',
- },
- 'fo-FO': {
- nativeName: 'Føroyskt (Færeyjar)',
- },
- 'fr': {
- nativeName: 'Français',
- },
- 'fr-CA': {
- nativeName: 'Français (Canada)',
- },
- 'fr-FR': {
- nativeName: 'Français (France)',
- },
- 'fr-BE': {
- nativeName: 'Français (Belgique)',
- },
- 'fr-CH': {
- nativeName: 'Français (Suisse)',
- },
- 'fy-NL': {
- nativeName: 'Frysk',
- },
- 'ga': {
- nativeName: 'Gaeilge',
- },
- 'ga-IE': {
- nativeName: 'Gaeilge',
- },
- 'gd': {
- nativeName: 'Gàidhlig',
- },
- 'gl': {
- nativeName: 'Galego',
- },
- 'gl-ES': {
- nativeName: 'Galego',
- },
- 'gn-PY': {
- nativeName: 'Avañe\'ẽ',
- },
- 'gu-IN': {
- nativeName: 'ગુજરાતી',
- },
- 'gv': {
- nativeName: 'Gaelg',
- },
- 'gx-GR': {
- nativeName: 'Ἑλληνική ἀρχαία',
- },
- 'he': {
- nativeName: 'עברית',
- },
- 'he-IL': {
- nativeName: 'עברית',
- },
- 'hi': {
- nativeName: 'हिन्दी',
- },
- 'hi-IN': {
- nativeName: 'हिन्दी',
- },
- 'hr': {
- nativeName: 'Hrvatski',
- },
- 'hr-HR': {
- nativeName: 'Hrvatski',
- },
- 'hsb': {
- nativeName: 'Hornjoserbšćina',
- },
- 'ht': {
- nativeName: 'Kreyòl',
- },
- 'hu': {
- nativeName: 'Magyar',
- },
- 'hu-HU': {
- nativeName: 'Magyar',
- },
- 'hy': {
- nativeName: 'Հայերեն',
- },
- 'hy-AM': {
- nativeName: 'Հայերեն (Հայաստան)',
- },
- 'id': {
- nativeName: 'Bahasa Indonesia',
- },
- 'id-ID': {
- nativeName: 'Bahasa Indonesia',
- },
- 'is': {
- nativeName: 'Íslenska',
- },
- 'is-IS': {
- nativeName: 'Íslenska (Iceland)',
- },
- 'it': {
- nativeName: 'Italiano',
- },
- 'it-IT': {
- nativeName: 'Italiano',
- },
- 'ja': {
- nativeName: '日本語',
- },
- 'ja-JP': {
- nativeName: '日本語 (日本)',
- },
- 'jv-ID': {
- nativeName: 'Basa Jawa',
- },
- 'ka-GE': {
- nativeName: 'ქართული',
- },
- 'kk-KZ': {
- nativeName: 'Қазақша',
- },
- 'km': {
- nativeName: 'ភាសាខ្មែរ',
- },
- 'kl': {
- nativeName: 'kalaallisut',
- },
- 'km-KH': {
- nativeName: 'ភាសាខ្មែរ',
- },
- 'kab': {
- nativeName: 'Taqbaylit',
- },
- 'kn': {
- nativeName: 'ಕನ್ನಡ',
- },
- 'kn-IN': {
- nativeName: 'ಕನ್ನಡ (India)',
- },
- 'ko': {
- nativeName: '한국어',
- },
- 'ko-KR': {
- nativeName: '한국어 (한국)',
- },
- 'ku-TR': {
- nativeName: 'Kurdî',
- },
- 'kw': {
- nativeName: 'Kernewek',
- },
- 'la': {
- nativeName: 'Latin',
- },
- 'la-VA': {
- nativeName: 'Latin',
- },
- 'lb': {
- nativeName: 'Lëtzebuergesch',
- },
- 'li-NL': {
- nativeName: 'Lèmbörgs',
- },
- 'lt': {
- nativeName: 'Lietuvių',
- },
- 'lt-LT': {
- nativeName: 'Lietuvių',
- },
- 'lv': {
- nativeName: 'Latviešu',
- },
- 'lv-LV': {
- nativeName: 'Latviešu',
- },
- 'mai': {
- nativeName: 'मैथिली, মৈথিলী',
- },
- 'mg-MG': {
- nativeName: 'Malagasy',
- },
- 'mk': {
- nativeName: 'Македонски',
- },
- 'mk-MK': {
- nativeName: 'Македонски (Македонски)',
- },
- 'ml': {
- nativeName: 'മലയാളം',
- },
- 'ml-IN': {
- nativeName: 'മലയാളം',
- },
- 'mn-MN': {
- nativeName: 'Монгол',
- },
- 'mr': {
- nativeName: 'मराठी',
- },
- 'mr-IN': {
- nativeName: 'मराठी',
- },
- 'ms': {
- nativeName: 'Bahasa Melayu',
- },
- 'ms-MY': {
- nativeName: 'Bahasa Melayu',
- },
- 'mt': {
- nativeName: 'Malti',
- },
- 'mt-MT': {
- nativeName: 'Malti',
- },
- 'my': {
- nativeName: 'ဗမာစကာ',
- },
- 'no': {
- nativeName: 'Norsk',
- },
- 'nb': {
- nativeName: 'Norsk (bokmål)',
- },
- 'nb-NO': {
- nativeName: 'Norsk (bokmål)',
- },
- 'ne': {
- nativeName: 'नेपाली',
- },
- 'ne-NP': {
- nativeName: 'नेपाली',
- },
- 'nl': {
- nativeName: 'Nederlands',
- },
- 'nl-BE': {
- nativeName: 'Nederlands (België)',
- },
- 'nl-NL': {
- nativeName: 'Nederlands (Nederland)',
- },
- 'nn-NO': {
- nativeName: 'Norsk (nynorsk)',
- },
- 'oc': {
- nativeName: 'Occitan',
- },
- 'or-IN': {
- nativeName: 'ଓଡ଼ିଆ',
- },
- 'pa': {
- nativeName: 'ਪੰਜਾਬੀ',
- },
- 'pa-IN': {
- nativeName: 'ਪੰਜਾਬੀ (ਭਾਰਤ ਨੂੰ)',
- },
- 'pl': {
- nativeName: 'Polski',
- },
- 'pl-PL': {
- nativeName: 'Polski',
- },
- 'ps-AF': {
- nativeName: 'پښتو',
- },
- 'pt': {
- nativeName: 'Português',
- },
- 'pt-BR': {
- nativeName: 'Português (Brasil)',
- },
- 'pt-PT': {
- nativeName: 'Português (Portugal)',
- },
- 'qu-PE': {
- nativeName: 'Qhichwa',
- },
- 'rm-CH': {
- nativeName: 'Rumantsch',
- },
- 'ro': {
- nativeName: 'Română',
- },
- 'ro-RO': {
- nativeName: 'Română',
- },
- 'ru': {
- nativeName: 'Русский',
- },
- 'ru-RU': {
- nativeName: 'Русский',
- },
- 'sa-IN': {
- nativeName: 'संस्कृतम्',
- },
- 'se-NO': {
- nativeName: 'Davvisámegiella',
- },
- 'sh': {
- nativeName: 'српскохрватски',
- },
- 'si-LK': {
- nativeName: 'සිංහල',
- },
- 'sk': {
- nativeName: 'Slovenčina',
- },
- 'sk-SK': {
- nativeName: 'Slovenčina (Slovakia)',
- },
- 'sl': {
- nativeName: 'Slovenščina',
- },
- 'sl-SI': {
- nativeName: 'Slovenščina',
- },
- 'so-SO': {
- nativeName: 'Soomaaliga',
- },
- 'sq': {
- nativeName: 'Shqip',
- },
- 'sq-AL': {
- nativeName: 'Shqip',
- },
- 'sr': {
- nativeName: 'Српски',
- },
- 'sr-RS': {
- nativeName: 'Српски (Serbia)',
- },
- 'su': {
- nativeName: 'Basa Sunda',
- },
- 'sv': {
- nativeName: 'Svenska',
- },
- 'sv-SE': {
- nativeName: 'Svenska',
- },
- 'sw': {
- nativeName: 'Kiswahili',
- },
- 'sw-KE': {
- nativeName: 'Kiswahili',
- },
- 'ta': {
- nativeName: 'தமிழ்',
- },
- 'ta-IN': {
- nativeName: 'தமிழ்',
- },
- 'te': {
- nativeName: 'తెలుగు',
- },
- 'te-IN': {
- nativeName: 'తెలుగు',
- },
- 'tg': {
- nativeName: 'забо́ни тоҷикӣ́',
- },
- 'tg-TJ': {
- nativeName: 'тоҷикӣ',
- },
- 'th': {
- nativeName: 'ภาษาไทย',
- },
- 'th-TH': {
- nativeName: 'ภาษาไทย (ประเทศไทย)',
- },
- 'fil': {
- nativeName: 'Filipino',
- },
- 'tlh': {
- nativeName: 'tlhIngan-Hol',
- },
- 'tr': {
- nativeName: 'Türkçe',
- },
- 'tr-TR': {
- nativeName: 'Türkçe',
- },
- 'tt-RU': {
- nativeName: 'татарча',
- },
- 'uk': {
- nativeName: 'Українська',
- },
- 'uk-UA': {
- nativeName: 'Українська',
- },
- 'ur': {
- nativeName: 'اردو',
- },
- 'ur-PK': {
- nativeName: 'اردو',
- },
- 'uz': {
- nativeName: 'O\'zbek',
- },
- 'uz-UZ': {
- nativeName: 'O\'zbek',
- },
- 'vi': {
- nativeName: 'Tiếng Việt',
- },
- 'vi-VN': {
- nativeName: 'Tiếng Việt',
- },
- 'xh-ZA': {
- nativeName: 'isiXhosa',
- },
- 'yi': {
- nativeName: 'ייִדיש',
- },
- 'yi-DE': {
- nativeName: 'ייִדיש (German)',
- },
- 'zh': {
- nativeName: '中文',
- },
- 'zh-Hans': {
- nativeName: '中文简体',
- },
- 'zh-Hant': {
- nativeName: '中文繁體',
- },
- 'zh-CN': {
- nativeName: '中文(中国大陆)',
- },
- 'zh-HK': {
- nativeName: '中文(香港)',
- },
- 'zh-SG': {
- nativeName: '中文(新加坡)',
- },
- 'zh-TW': {
- nativeName: '中文(台灣)',
- },
- 'zu-ZA': {
- nativeName: 'isiZulu',
- },
-};
diff --git a/packages/frontend/src/scripts/login-id.ts b/packages/frontend/src/scripts/login-id.ts
deleted file mode 100644
index b52735caa0..0000000000
--- a/packages/frontend/src/scripts/login-id.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-export function getUrlWithLoginId(url: string, loginId: string) {
- const u = new URL(url, origin);
- u.searchParams.append('loginId', loginId);
- return u.toString();
-}
-
-export function getUrlWithoutLoginId(url: string) {
- const u = new URL(url);
- u.searchParams.delete('loginId');
- return u.toString();
-}
diff --git a/packages/frontend/src/scripts/lookup.ts b/packages/frontend/src/scripts/lookup.ts
deleted file mode 100644
index 02f589c7ca..0000000000
--- a/packages/frontend/src/scripts/lookup.ts
+++ /dev/null
@@ -1,84 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import * as os from '@/os.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
-import { i18n } from '@/i18n.js';
-import { Router } from '@/nirax.js';
-import { mainRouter } from '@/router/main.js';
-
-export async function lookup(router?: Router) {
- const _router = router ?? mainRouter;
-
- const { canceled, result: temp } = await os.inputText({
- title: i18n.ts.lookup,
- });
- const query = temp ? temp.trim() : '';
- if (canceled || query.length <= 1) return;
-
- if (query.startsWith('@') && !query.includes(' ')) {
- _router.push(`/${query}`);
- return;
- }
-
- if (query.startsWith('#')) {
- _router.push(`/tags/${encodeURIComponent(query.substring(1))}`);
- return;
- }
-
- if (query.startsWith('https://')) {
- const res = await apLookup(query);
-
- if (res.type === 'User') {
- _router.push(`/@${res.object.username}@${res.object.host}`);
- } else if (res.type === 'Note') {
- _router.push(`/notes/${res.object.id}`);
- }
-
- return;
- }
-}
-
-export async function apLookup(query: string) {
- const promise = misskeyApi('ap/show', {
- uri: query,
- });
-
- os.promiseDialog(promise, null, (err) => {
- let title = i18n.ts.somethingHappened;
- let text = err.message + '\n' + err.id;
-
- switch (err.id) {
- case '974b799e-1a29-4889-b706-18d4dd93e266':
- title = i18n.ts._remoteLookupErrors._federationNotAllowed.title;
- text = i18n.ts._remoteLookupErrors._federationNotAllowed.description;
- break;
- case '1a5eab56-e47b-48c2-8d5e-217b897d70db':
- title = i18n.ts._remoteLookupErrors._uriInvalid.title;
- text = i18n.ts._remoteLookupErrors._uriInvalid.description;
- break;
- case '81b539cf-4f57-4b29-bc98-032c33c0792e':
- title = i18n.ts._remoteLookupErrors._requestFailed.title;
- text = i18n.ts._remoteLookupErrors._requestFailed.description;
- break;
- case '70193c39-54f3-4813-82f0-70a680f7495b':
- title = i18n.ts._remoteLookupErrors._responseInvalid.title;
- text = i18n.ts._remoteLookupErrors._responseInvalid.description;
- break;
- case 'dc94d745-1262-4e63-a17d-fecaa57efc82':
- title = i18n.ts._remoteLookupErrors._noSuchObject.title;
- text = i18n.ts._remoteLookupErrors._noSuchObject.description;
- break;
- }
-
- os.alert({
- type: 'error',
- title,
- text,
- });
- }, i18n.ts.fetchingAsApObject);
-
- return await promise;
-}
diff --git a/packages/frontend/src/scripts/media-has-audio.ts b/packages/frontend/src/scripts/media-has-audio.ts
deleted file mode 100644
index 4bf3ee5d97..0000000000
--- a/packages/frontend/src/scripts/media-has-audio.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-export default async function hasAudio(media: HTMLMediaElement) {
- const cloned = media.cloneNode() as HTMLMediaElement;
- cloned.muted = (cloned as typeof cloned & Partial).playsInline = true;
- cloned.play();
- await new Promise((resolve) => cloned.addEventListener('playing', resolve));
- const result = !!(cloned as any).audioTracks?.length || (cloned as any).mozHasAudio || !!(cloned as any).webkitAudioDecodedByteCount;
- cloned.remove();
- return result;
-}
diff --git a/packages/frontend/src/scripts/media-proxy.ts b/packages/frontend/src/scripts/media-proxy.ts
deleted file mode 100644
index 78eba35ead..0000000000
--- a/packages/frontend/src/scripts/media-proxy.ts
+++ /dev/null
@@ -1,34 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import { MediaProxy } from '@@/js/media-proxy.js';
-import { url } from '@@/js/config.js';
-import { instance } from '@/instance.js';
-
-let _mediaProxy: MediaProxy | null = null;
-
-export function getProxiedImageUrl(...args: Parameters): string {
- if (_mediaProxy == null) {
- _mediaProxy = new MediaProxy(instance, url);
- }
-
- return _mediaProxy.getProxiedImageUrl(...args);
-}
-
-export function getProxiedImageUrlNullable(...args: Parameters): string | null {
- if (_mediaProxy == null) {
- _mediaProxy = new MediaProxy(instance, url);
- }
-
- return _mediaProxy.getProxiedImageUrlNullable(...args);
-}
-
-export function getStaticImageUrl(...args: Parameters): string {
- if (_mediaProxy == null) {
- _mediaProxy = new MediaProxy(instance, url);
- }
-
- return _mediaProxy.getStaticImageUrl(...args);
-}
diff --git a/packages/frontend/src/scripts/merge.ts b/packages/frontend/src/scripts/merge.ts
deleted file mode 100644
index 004b6d42a4..0000000000
--- a/packages/frontend/src/scripts/merge.ts
+++ /dev/null
@@ -1,35 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import { deepClone } from './clone.js';
-import type { Cloneable } from './clone.js';
-
-export type DeepPartial = {
- [P in keyof T]?: T[P] extends Record ? DeepPartial : T[P];
-};
-
-function isPureObject(value: unknown): value is Record {
- return typeof value === 'object' && value !== null && !Array.isArray(value);
-}
-
-/**
- * valueにないキーをdefからもらう(再帰的)\
- * nullはそのまま、undefinedはdefの値
- **/
-export function deepMerge>(value: DeepPartial, def: X): X {
- if (isPureObject(value) && isPureObject(def)) {
- const result = deepClone(value as Cloneable) as X;
- for (const [k, v] of Object.entries(def) as [keyof X, X[keyof X]][]) {
- if (!Object.prototype.hasOwnProperty.call(value, k) || value[k] === undefined) {
- result[k] = v;
- } else if (isPureObject(v) && isPureObject(result[k])) {
- const child = deepClone(result[k] as Cloneable) as DeepPartial>;
- result[k] = deepMerge(child, v);
- }
- }
- return result;
- }
- throw new Error('deepMerge: value and def must be pure objects');
-}
diff --git a/packages/frontend/src/scripts/mfm-function-picker.ts b/packages/frontend/src/scripts/mfm-function-picker.ts
deleted file mode 100644
index a2f777f623..0000000000
--- a/packages/frontend/src/scripts/mfm-function-picker.ts
+++ /dev/null
@@ -1,57 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import { nextTick } from 'vue';
-import type { Ref } from 'vue';
-import * as os from '@/os.js';
-import { i18n } from '@/i18n.js';
-import { MFM_TAGS } from '@@/js/const.js';
-import type { MenuItem } from '@/types/menu.js';
-
-/**
- * MFMの装飾のリストを表示する
- */
-export function mfmFunctionPicker(src: HTMLElement | EventTarget | null, textArea: HTMLInputElement | HTMLTextAreaElement, textRef: Ref) {
- os.popupMenu([{
- text: i18n.ts.addMfmFunction,
- type: 'label',
- }, ...getFunctionList(textArea, textRef)], src);
-}
-
-function getFunctionList(textArea: HTMLInputElement | HTMLTextAreaElement, textRef: Ref): MenuItem[] {
- return MFM_TAGS.map(tag => ({
- text: tag,
- icon: 'ti ti-icons',
- action: () => add(textArea, textRef, tag),
- }));
-}
-
-function add(textArea: HTMLInputElement | HTMLTextAreaElement, textRef: Ref, type: string) {
- const caretStart: number = textArea.selectionStart as number;
- const caretEnd: number = textArea.selectionEnd as number;
-
- MFM_TAGS.forEach(tag => {
- if (type === tag) {
- if (caretStart === caretEnd) {
- // 単純にFunctionを追加
- const trimmedText = `${textRef.value.substring(0, caretStart)}$[${type} ]${textRef.value.substring(caretEnd)}`;
- textRef.value = trimmedText;
- } else {
- // 選択範囲を囲むようにFunctionを追加
- const trimmedText = `${textRef.value.substring(0, caretStart)}$[${type} ${textRef.value.substring(caretStart, caretEnd)}]${textRef.value.substring(caretEnd)}`;
- textRef.value = trimmedText;
- }
- }
- });
-
- const nextCaretStart: number = caretStart + 3 + type.length;
- const nextCaretEnd: number = caretEnd + 3 + type.length;
-
- // キャレットを戻す
- nextTick(() => {
- textArea.focus();
- textArea.setSelectionRange(nextCaretStart, nextCaretEnd);
- });
-}
diff --git a/packages/frontend/src/scripts/misskey-api.ts b/packages/frontend/src/scripts/misskey-api.ts
deleted file mode 100644
index dc07ad477b..0000000000
--- a/packages/frontend/src/scripts/misskey-api.ts
+++ /dev/null
@@ -1,116 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import * as Misskey from 'misskey-js';
-import { ref } from 'vue';
-import { apiUrl } from '@@/js/config.js';
-import { $i } from '@/account.js';
-export const pendingApiRequestsCount = ref(0);
-
-export type Endpoint = keyof Misskey.Endpoints;
-
-export type Request = Misskey.Endpoints[E]['req'];
-
-export type AnyRequest =
- (E extends Endpoint ? Request : never) | object;
-
-export type Response> =
- E extends Endpoint
- ? P extends Request ? Misskey.api.SwitchCaseResponseType : never
- : object;
-
-// Implements Misskey.api.ApiClient.request
-export function misskeyApi<
- ResT = void,
- E extends Endpoint | NonNullable = Endpoint,
- P extends AnyRequest = E extends Endpoint ? Request : never,
- _ResT = ResT extends void ? Response : ResT,
->(
- endpoint: E,
- data: P & { i?: string | null; } = {} as any,
- token?: string | null | undefined,
- signal?: AbortSignal,
-): Promise<_ResT> {
- if (endpoint.includes('://')) throw new Error('invalid endpoint');
- pendingApiRequestsCount.value++;
-
- const onFinally = () => {
- pendingApiRequestsCount.value--;
- };
-
- const promise = new Promise<_ResT>((resolve, reject) => {
- // Append a credential
- if ($i) data.i = $i.token;
- if (token !== undefined) data.i = token;
-
- // Send request
- window.fetch(`${apiUrl}/${endpoint}`, {
- method: 'POST',
- body: JSON.stringify(data),
- credentials: 'omit',
- cache: 'no-cache',
- headers: {
- 'Content-Type': 'application/json',
- },
- signal,
- }).then(async (res) => {
- const body = res.status === 204 ? null : await res.json();
-
- if (res.status === 200) {
- resolve(body);
- } else if (res.status === 204) {
- resolve(undefined as _ResT); // void -> undefined
- } else {
- reject(body.error);
- }
- }).catch(reject);
- });
-
- promise.then(onFinally, onFinally);
-
- return promise;
-}
-
-// Implements Misskey.api.ApiClient.request
-export function misskeyApiGet<
- ResT = void,
- E extends keyof Misskey.Endpoints = keyof Misskey.Endpoints,
- P extends Misskey.Endpoints[E]['req'] = Misskey.Endpoints[E]['req'],
- _ResT = ResT extends void ? Misskey.api.SwitchCaseResponseType : ResT,
->(
- endpoint: E,
- data: P = {} as any,
-): Promise<_ResT> {
- pendingApiRequestsCount.value++;
-
- const onFinally = () => {
- pendingApiRequestsCount.value--;
- };
-
- const query = new URLSearchParams(data as any);
-
- const promise = new Promise<_ResT>((resolve, reject) => {
- // Send request
- window.fetch(`${apiUrl}/${endpoint}?${query}`, {
- method: 'GET',
- credentials: 'omit',
- cache: 'default',
- }).then(async (res) => {
- const body = res.status === 204 ? null : await res.json();
-
- if (res.status === 200) {
- resolve(body);
- } else if (res.status === 204) {
- resolve(undefined as _ResT); // void -> undefined
- } else {
- reject(body.error);
- }
- }).catch(reject);
- });
-
- promise.then(onFinally, onFinally);
-
- return promise;
-}
diff --git a/packages/frontend/src/scripts/navigator.ts b/packages/frontend/src/scripts/navigator.ts
deleted file mode 100644
index ffc0a457f4..0000000000
--- a/packages/frontend/src/scripts/navigator.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-export function isSupportShare(): boolean {
- return 'share' in navigator;
-}
diff --git a/packages/frontend/src/scripts/page-metadata.ts b/packages/frontend/src/scripts/page-metadata.ts
deleted file mode 100644
index 671751147c..0000000000
--- a/packages/frontend/src/scripts/page-metadata.ts
+++ /dev/null
@@ -1,71 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import * as Misskey from 'misskey-js';
-import { inject, isRef, onActivated, onBeforeUnmount, provide, ref, toValue, watch } from 'vue';
-import type { MaybeRefOrGetter, Ref } from 'vue';
-
-export type PageMetadata = {
- title: string;
- subtitle?: string;
- icon?: string | null;
- avatar?: Misskey.entities.User | null;
- userName?: Misskey.entities.User | null;
- needWideArea?: boolean;
-};
-
-type PageMetadataGetter = () => PageMetadata;
-type PageMetadataReceiver = (getter: PageMetadataGetter) => void;
-
-const RECEIVER_KEY = Symbol('ReceiverKey');
-const setReceiver = (v: PageMetadataReceiver): void => {
- provide(RECEIVER_KEY, v);
-};
-const getReceiver = (): PageMetadataReceiver | undefined => {
- return inject(RECEIVER_KEY);
-};
-
-const METADATA_KEY = Symbol('MetadataKey');
-const setMetadata = (v: Ref): void => {
- provide[>(METADATA_KEY, v);
-};
-const getMetadata = (): Ref | undefined => {
- return inject][>(METADATA_KEY);
-};
-
-export const definePageMetadata = (maybeRefOrGetterMetadata: MaybeRefOrGetter): void => {
- const metadataRef = ref(toValue(maybeRefOrGetterMetadata));
- const metadataGetter = () => metadataRef.value;
- const receiver = getReceiver();
-
- // setup handler
- receiver?.(metadataGetter);
-
- // update handler
- onBeforeUnmount(watch(
- () => toValue(maybeRefOrGetterMetadata),
- (metadata) => {
- metadataRef.value = metadata;
- receiver?.(metadataGetter);
- },
- { deep: true },
- ));
- onActivated(() => {
- receiver?.(metadataGetter);
- });
-};
-
-export const provideMetadataReceiver = (receiver: PageMetadataReceiver): void => {
- setReceiver(receiver);
-};
-
-export const provideReactiveMetadata = (metadataRef: Ref): void => {
- setMetadata(metadataRef);
-};
-
-export const injectReactiveMetadata = (): Ref => {
- const metadataRef = getMetadata();
- return isRef(metadataRef) ? metadataRef : ref(null);
-};
diff --git a/packages/frontend/src/scripts/physics.ts b/packages/frontend/src/scripts/physics.ts
deleted file mode 100644
index 8a4e9319b3..0000000000
--- a/packages/frontend/src/scripts/physics.ts
+++ /dev/null
@@ -1,157 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import * as Matter from 'matter-js';
-
-export function physics(container: HTMLElement) {
- const containerWidth = container.offsetWidth;
- const containerHeight = container.offsetHeight;
- const containerCenterX = containerWidth / 2;
-
- // サイズ固定化(要らないかも?)
- container.style.position = 'relative';
- container.style.boxSizing = 'border-box';
- container.style.width = `${containerWidth}px`;
- container.style.height = `${containerHeight}px`;
-
- // create engine
- const engine = Matter.Engine.create({
- constraintIterations: 4,
- positionIterations: 8,
- velocityIterations: 8,
- });
-
- const world = engine.world;
-
- // create renderer
- const render = Matter.Render.create({
- engine: engine,
- //element: document.getElementById('debug'),
- options: {
- width: containerWidth,
- height: containerHeight,
- background: 'transparent', // transparent to hide
- wireframeBackground: 'transparent', // transparent to hide
- },
- });
-
- // Disable to hide debug
- Matter.Render.run(render);
-
- // create runner
- const runner = Matter.Runner.create();
- Matter.Runner.run(runner, engine);
-
- const groundThickness = 1024;
- const ground = Matter.Bodies.rectangle(containerCenterX, containerHeight + (groundThickness / 2), containerWidth, groundThickness, {
- isStatic: true,
- restitution: 0.1,
- friction: 2,
- });
-
- //const wallRight = Matter.Bodies.rectangle(window.innerWidth+50, window.innerHeight/2, 100, window.innerHeight, wallopts);
- //const wallLeft = Matter.Bodies.rectangle(-50, window.innerHeight/2, 100, window.innerHeight, wallopts);
-
- Matter.World.add(world, [
- ground,
- //wallRight,
- //wallLeft,
- ]);
-
- const objEls = Array.from(container.children) as HTMLElement[];
- const objs: Matter.Body[] = [];
- for (const objEl of objEls) {
- const left = objEl.dataset.physicsX ? parseInt(objEl.dataset.physicsX) : objEl.offsetLeft;
- const top = objEl.dataset.physicsY ? parseInt(objEl.dataset.physicsY) : objEl.offsetTop;
-
- let obj: Matter.Body;
- if (objEl.classList.contains('_physics_circle_')) {
- obj = Matter.Bodies.circle(
- left + (objEl.offsetWidth / 2),
- top + (objEl.offsetHeight / 2),
- Math.max(objEl.offsetWidth, objEl.offsetHeight) / 2,
- {
- restitution: 0.5,
- },
- );
- } else {
- const style = window.getComputedStyle(objEl);
- obj = Matter.Bodies.rectangle(
- left + (objEl.offsetWidth / 2),
- top + (objEl.offsetHeight / 2),
- objEl.offsetWidth,
- objEl.offsetHeight,
- {
- chamfer: { radius: parseInt(style.borderRadius || '0', 10) },
- restitution: 0.5,
- },
- );
- }
- objEl.id = obj.id.toString();
- objs.push(obj);
- }
-
- Matter.World.add(engine.world, objs);
-
- // Add mouse control
-
- const mouse = Matter.Mouse.create(container);
- const mouseConstraint = Matter.MouseConstraint.create(engine, {
- mouse: mouse,
- constraint: {
- stiffness: 0.1,
- render: {
- visible: false,
- },
- },
- });
-
- Matter.World.add(engine.world, mouseConstraint);
-
- // keep the mouse in sync with rendering
- render.mouse = mouse;
-
- for (const objEl of objEls) {
- objEl.style.position = 'absolute';
- objEl.style.top = '0';
- objEl.style.left = '0';
- objEl.style.margin = '0';
- }
-
- window.requestAnimationFrame(update);
-
- let stop = false;
-
- function update() {
- for (const objEl of objEls) {
- const obj = objs.find(obj => obj.id.toString() === objEl.id.toString());
- if (obj == null) continue;
-
- const x = (obj.position.x - objEl.offsetWidth / 2);
- const y = (obj.position.y - objEl.offsetHeight / 2);
- const angle = obj.angle;
- objEl.style.transform = `translate(${x}px, ${y}px) rotate(${angle}rad)`;
- }
-
- if (!stop) {
- window.requestAnimationFrame(update);
- }
- }
-
- // 奈落に落ちたオブジェクトは消す
- const intervalId = window.setInterval(() => {
- for (const obj of objs) {
- if (obj.position.y > (containerHeight + 1024)) Matter.World.remove(world, obj);
- }
- }, 1000 * 10);
-
- return {
- stop: () => {
- stop = true;
- Matter.Runner.stop(runner);
- window.clearInterval(intervalId);
- },
- };
-}
diff --git a/packages/frontend/src/scripts/player-url-transform.ts b/packages/frontend/src/scripts/player-url-transform.ts
deleted file mode 100644
index 39c6df6500..0000000000
--- a/packages/frontend/src/scripts/player-url-transform.ts
+++ /dev/null
@@ -1,26 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-import { hostname } from '@@/js/config.js';
-
-export function transformPlayerUrl(url: string): string {
- const urlObj = new URL(url);
- if (!['https:', 'http:'].includes(urlObj.protocol)) throw new Error('Invalid protocol');
-
- const urlParams = new URLSearchParams(urlObj.search);
-
- if (urlObj.hostname === 'player.twitch.tv') {
- // TwitchはCSPの制約あり
- // https://dev.twitch.tv/docs/embed/video-and-clips/
- urlParams.set('parent', hostname);
- urlParams.set('allowfullscreen', '');
- urlParams.set('autoplay', 'true');
- } else {
- urlParams.set('autoplay', '1');
- urlParams.set('auto_play', '1');
- }
- urlObj.search = urlParams.toString();
-
- return urlObj.toString();
-}
diff --git a/packages/frontend/src/scripts/please-login.ts b/packages/frontend/src/scripts/please-login.ts
deleted file mode 100644
index a8a330eb6d..0000000000
--- a/packages/frontend/src/scripts/please-login.ts
+++ /dev/null
@@ -1,76 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import { defineAsyncComponent } from 'vue';
-import { $i } from '@/account.js';
-import { instance } from '@/instance.js';
-import { i18n } from '@/i18n.js';
-import { popup } from '@/os.js';
-
-export type OpenOnRemoteOptions = {
- /**
- * 外部のMisskey Webで特定のパスを開く
- */
- type: 'web';
-
- /**
- * 内部パス(例: `/settings`)
- */
- path: string;
-} | {
- /**
- * 外部のMisskey Webで照会する
- */
- type: 'lookup';
-
- /**
- * 照会したいエンティティのURL
- *
- * (例: `https://misskey.example.com/notes/abcdexxxxyz`)
- */
- url: string;
-} | {
- /**
- * 外部のMisskeyでノートする
- */
- type: 'share';
-
- /**
- * `/share` ページに渡すクエリストリング
- *
- * @see https://go.misskey-hub.net/spec/share/
- */
- params: Record;
-};
-
-export function pleaseLogin(opts: {
- path?: string;
- message?: string;
- openOnRemote?: OpenOnRemoteOptions;
-} = {}) {
- if ($i) return;
-
- let _openOnRemote: OpenOnRemoteOptions | undefined = undefined;
-
- // 連合できる場合と、(連合ができなくても)共有する場合は外部連携オプションを設定
- if (opts.openOnRemote != null && (instance.federation !== 'none' || opts.openOnRemote.type === 'share')) {
- _openOnRemote = opts.openOnRemote;
- }
-
- const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkSigninDialog.vue')), {
- autoSet: true,
- message: opts.message ?? (_openOnRemote ? i18n.ts.signinOrContinueOnRemote : i18n.ts.signinRequired),
- openOnRemote: _openOnRemote,
- }, {
- cancelled: () => {
- if (opts.path) {
- window.location.href = opts.path;
- }
- },
- closed: () => dispose(),
- });
-
- throw new Error('signin required');
-}
diff --git a/packages/frontend/src/scripts/popout.ts b/packages/frontend/src/scripts/popout.ts
deleted file mode 100644
index 5b141222e8..0000000000
--- a/packages/frontend/src/scripts/popout.ts
+++ /dev/null
@@ -1,28 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import { appendQuery } from '@@/js/url.js';
-import * as config from '@@/js/config.js';
-
-export function popout(path: string, w?: HTMLElement) {
- let url = path.startsWith('http://') || path.startsWith('https://') ? path : config.url + path;
- url = appendQuery(url, 'zen');
- if (w) {
- const position = w.getBoundingClientRect();
- const width = parseInt(getComputedStyle(w, '').width, 10);
- const height = parseInt(getComputedStyle(w, '').height, 10);
- const x = window.screenX + position.left;
- const y = window.screenY + position.top;
- window.open(url, url,
- `width=${width}, height=${height}, top=${y}, left=${x}`);
- } else {
- const width = 400;
- const height = 500;
- const x = window.top.outerHeight / 2 + window.top.screenY - (height / 2);
- const y = window.top.outerWidth / 2 + window.top.screenX - (width / 2);
- window.open(url, url,
- `width=${width}, height=${height}, top=${x}, left=${y}`);
- }
-}
diff --git a/packages/frontend/src/scripts/popup-position.ts b/packages/frontend/src/scripts/popup-position.ts
deleted file mode 100644
index 3dad41a8b3..0000000000
--- a/packages/frontend/src/scripts/popup-position.ts
+++ /dev/null
@@ -1,161 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-export function calcPopupPosition(el: HTMLElement, props: {
- anchorElement?: HTMLElement | null;
- innerMargin: number;
- direction: 'top' | 'bottom' | 'left' | 'right';
- align: 'top' | 'bottom' | 'left' | 'right' | 'center';
- alignOffset?: number;
- x?: number;
- y?: number;
-}): { top: number; left: number; transformOrigin: string; } {
- const contentWidth = el.offsetWidth;
- const contentHeight = el.offsetHeight;
-
- let rect: DOMRect;
-
- if (props.anchorElement) {
- rect = props.anchorElement.getBoundingClientRect();
- }
-
- const calcPosWhenTop = () => {
- let left: number;
- let top: number;
-
- if (props.anchorElement) {
- left = rect.left + window.scrollX + (props.anchorElement.offsetWidth / 2);
- top = (rect.top + window.scrollY - contentHeight) - props.innerMargin;
- } else {
- left = props.x;
- top = (props.y - contentHeight) - props.innerMargin;
- }
-
- left -= (el.offsetWidth / 2);
-
- if (left + contentWidth - window.scrollX > window.innerWidth) {
- left = window.innerWidth - contentWidth + window.scrollX - 1;
- }
-
- return [left, top];
- };
-
- const calcPosWhenBottom = () => {
- let left: number;
- let top: number;
-
- if (props.anchorElement) {
- left = rect.left + window.scrollX + (props.anchorElement.offsetWidth / 2);
- top = (rect.top + window.scrollY + props.anchorElement.offsetHeight) + props.innerMargin;
- } else {
- left = props.x;
- top = (props.y) + props.innerMargin;
- }
-
- left -= (el.offsetWidth / 2);
-
- if (left + contentWidth - window.scrollX > window.innerWidth) {
- left = window.innerWidth - contentWidth + window.scrollX - 1;
- }
-
- return [left, top];
- };
-
- const calcPosWhenLeft = () => {
- let left: number;
- let top: number;
-
- if (props.anchorElement) {
- left = (rect.left + window.scrollX - contentWidth) - props.innerMargin;
- top = rect.top + window.scrollY + (props.anchorElement.offsetHeight / 2);
- } else {
- left = (props.x - contentWidth) - props.innerMargin;
- top = props.y;
- }
-
- top -= (el.offsetHeight / 2);
-
- if (top + contentHeight - window.scrollY > window.innerHeight) {
- top = window.innerHeight - contentHeight + window.scrollY - 1;
- }
-
- return [left, top];
- };
-
- const calcPosWhenRight = () => {
- let left: number;
- let top: number;
-
- if (props.anchorElement) {
- left = (rect.left + props.anchorElement.offsetWidth + window.scrollX) + props.innerMargin;
-
- if (props.align === 'top') {
- top = rect.top + window.scrollY;
- if (props.alignOffset != null) top += props.alignOffset;
- } else if (props.align === 'bottom') {
- // TODO
- } else { // center
- top = rect.top + window.scrollY + (props.anchorElement.offsetHeight / 2);
- top -= (el.offsetHeight / 2);
- }
- } else {
- left = props.x + props.innerMargin;
- top = props.y;
- top -= (el.offsetHeight / 2);
- }
-
- if (top + contentHeight - window.scrollY > window.innerHeight) {
- top = window.innerHeight - contentHeight + window.scrollY - 1;
- }
-
- return [left, top];
- };
-
- const calc = (): {
- left: number;
- top: number;
- transformOrigin: string;
- } => {
- switch (props.direction) {
- case 'top': {
- const [left, top] = calcPosWhenTop();
-
- // ツールチップを上に向かって表示するスペースがなければ下に向かって出す
- if (top - window.scrollY < 0) {
- const [left, top] = calcPosWhenBottom();
- return { left, top, transformOrigin: 'center top' };
- }
-
- return { left, top, transformOrigin: 'center bottom' };
- }
-
- case 'bottom': {
- const [left, top] = calcPosWhenBottom();
- // TODO: ツールチップを下に向かって表示するスペースがなければ上に向かって出す
- return { left, top, transformOrigin: 'center top' };
- }
-
- case 'left': {
- const [left, top] = calcPosWhenLeft();
-
- // ツールチップを左に向かって表示するスペースがなければ右に向かって出す
- if (left - window.scrollX < 0) {
- const [left, top] = calcPosWhenRight();
- return { left, top, transformOrigin: 'left center' };
- }
-
- return { left, top, transformOrigin: 'right center' };
- }
-
- case 'right': {
- const [left, top] = calcPosWhenRight();
- // TODO: ツールチップを右に向かって表示するスペースがなければ左に向かって出す
- return { left, top, transformOrigin: 'left center' };
- }
- }
- };
-
- return calc();
-}
diff --git a/packages/frontend/src/scripts/post-message.ts b/packages/frontend/src/scripts/post-message.ts
deleted file mode 100644
index 11b6f52ddd..0000000000
--- a/packages/frontend/src/scripts/post-message.ts
+++ /dev/null
@@ -1,25 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-export const postMessageEventTypes = [
- 'misskey:shareForm:shareCompleted',
-] as const;
-
-export type PostMessageEventType = typeof postMessageEventTypes[number];
-
-export type MiPostMessageEvent = {
- type: PostMessageEventType;
- payload?: any;
-};
-
-/**
- * 親フレームにイベントを送信
- */
-export function postMessageToParentWindow(type: PostMessageEventType, payload?: any): void {
- window.parent.postMessage({
- type,
- payload,
- }, '*');
-}
diff --git a/packages/frontend/src/scripts/reaction-picker.ts b/packages/frontend/src/scripts/reaction-picker.ts
deleted file mode 100644
index 81f6c02dcf..0000000000
--- a/packages/frontend/src/scripts/reaction-picker.ts
+++ /dev/null
@@ -1,54 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import * as Misskey from 'misskey-js';
-import { defineAsyncComponent, ref } from 'vue';
-import type { Ref } from 'vue';
-import { popup } from '@/os.js';
-import { store } from '@/store.js';
-
-class ReactionPicker {
- private src: Ref = ref(null);
- private manualShowing = ref(false);
- private targetNote: Ref = ref(null);
- private onChosen?: (reaction: string) => void;
- private onClosed?: () => void;
-
- constructor() {
- // nop
- }
-
- public async init() {
- const reactionsRef = store.reactiveState.reactions;
- await popup(defineAsyncComponent(() => import('@/components/MkEmojiPickerDialog.vue')), {
- src: this.src,
- pinnedEmojis: reactionsRef,
- asReactionPicker: true,
- targetNote: this.targetNote,
- manualShowing: this.manualShowing,
- }, {
- done: reaction => {
- if (this.onChosen) this.onChosen(reaction);
- },
- close: () => {
- this.manualShowing.value = false;
- },
- closed: () => {
- this.src.value = null;
- if (this.onClosed) this.onClosed();
- },
- });
- }
-
- public show(src: HTMLElement | null, targetNote: Misskey.entities.Note | null, onChosen?: ReactionPicker['onChosen'], onClosed?: ReactionPicker['onClosed']) {
- this.src.value = src;
- this.targetNote.value = targetNote;
- this.manualShowing.value = true;
- this.onChosen = onChosen;
- this.onClosed = onClosed;
- }
-}
-
-export const reactionPicker = new ReactionPicker();
diff --git a/packages/frontend/src/scripts/reload-ask.ts b/packages/frontend/src/scripts/reload-ask.ts
deleted file mode 100644
index 733d91b85a..0000000000
--- a/packages/frontend/src/scripts/reload-ask.ts
+++ /dev/null
@@ -1,40 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import { i18n } from '@/i18n.js';
-import * as os from '@/os.js';
-import { unisonReload } from '@/scripts/unison-reload.js';
-
-let isReloadConfirming = false;
-
-export async function reloadAsk(opts: {
- unison?: boolean;
- reason?: string;
-}) {
- if (isReloadConfirming) {
- return;
- }
-
- isReloadConfirming = true;
-
- const { canceled } = await os.confirm(opts.reason == null ? {
- type: 'info',
- text: i18n.ts.reloadConfirm,
- } : {
- type: 'info',
- title: i18n.ts.reloadConfirm,
- text: opts.reason,
- }).finally(() => {
- isReloadConfirming = false;
- });
-
- if (canceled) return;
-
- if (opts.unison) {
- unisonReload();
- } else {
- location.reload();
- }
-}
diff --git a/packages/frontend/src/scripts/search-emoji.ts b/packages/frontend/src/scripts/search-emoji.ts
deleted file mode 100644
index 371f69b9a7..0000000000
--- a/packages/frontend/src/scripts/search-emoji.ts
+++ /dev/null
@@ -1,106 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-export type EmojiDef = {
- emoji: string;
- name: string;
- url: string;
- aliasOf?: string;
-} | {
- emoji: string;
- name: string;
- aliasOf?: string;
- isCustomEmoji?: true;
-};
-type EmojiScore = { emoji: EmojiDef, score: number };
-
-export function searchEmoji(query: string | null, emojiDb: EmojiDef[], max = 30): EmojiDef[] {
- if (!query) {
- return [];
- }
-
- const matched = new Map();
- // 完全一致(エイリアスなし)
- emojiDb.some(x => {
- if (x.name === query && !x.aliasOf) {
- matched.set(x.name, { emoji: x, score: query.length + 3 });
- }
- return matched.size === max;
- });
-
- // 完全一致(エイリアス込み)
- if (matched.size < max) {
- emojiDb.some(x => {
- if (x.name === query && !matched.has(x.aliasOf ?? x.name)) {
- matched.set(x.aliasOf ?? x.name, { emoji: x, score: query.length + 2 });
- }
- return matched.size === max;
- });
- }
-
- // 前方一致(エイリアスなし)
- if (matched.size < max) {
- emojiDb.some(x => {
- if (x.name.startsWith(query) && !x.aliasOf && !matched.has(x.name)) {
- matched.set(x.name, { emoji: x, score: query.length + 1 });
- }
- return matched.size === max;
- });
- }
-
- // 前方一致(エイリアス込み)
- if (matched.size < max) {
- emojiDb.some(x => {
- if (x.name.startsWith(query) && !matched.has(x.aliasOf ?? x.name)) {
- matched.set(x.aliasOf ?? x.name, { emoji: x, score: query.length });
- }
- return matched.size === max;
- });
- }
-
- // 部分一致(エイリアス込み)
- if (matched.size < max) {
- emojiDb.some(x => {
- if (x.name.includes(query) && !matched.has(x.aliasOf ?? x.name)) {
- matched.set(x.aliasOf ?? x.name, { emoji: x, score: query.length - 1 });
- }
- return matched.size === max;
- });
- }
-
- // 簡易あいまい検索(3文字以上)
- if (matched.size < max && query.length > 3) {
- const queryChars = [...query];
- const hitEmojis = new Map();
-
- for (const x of emojiDb) {
- // 文字列の位置を進めながら、クエリの文字を順番に探す
-
- let pos = 0;
- let hit = 0;
- for (const c of queryChars) {
- pos = x.name.indexOf(c, pos);
- if (pos <= -1) break;
- hit++;
- }
-
- // 半分以上の文字が含まれていればヒットとする
- if (hit > Math.ceil(queryChars.length / 2) && hit - 2 > (matched.get(x.aliasOf ?? x.name)?.score ?? 0)) {
- hitEmojis.set(x.aliasOf ?? x.name, { emoji: x, score: hit - 2 });
- }
- }
-
- // ヒットしたものを全部追加すると雑多になるので、先頭の6件程度だけにしておく(6件=オートコンプリートのポップアップのサイズ分)
- [...hitEmojis.values()]
- .sort((x, y) => y.score - x.score)
- .slice(0, 6)
- .forEach(it => matched.set(it.emoji.name, it));
- }
-
- return [...matched.values()]
- .sort((x, y) => y.score - x.score)
- .slice(0, max)
- .map(it => it.emoji);
-}
diff --git a/packages/frontend/src/scripts/select-file.ts b/packages/frontend/src/scripts/select-file.ts
deleted file mode 100644
index 42b34f54f5..0000000000
--- a/packages/frontend/src/scripts/select-file.ts
+++ /dev/null
@@ -1,130 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import { ref } from 'vue';
-import * as Misskey from 'misskey-js';
-import * as os from '@/os.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
-import { useStream } from '@/stream.js';
-import { i18n } from '@/i18n.js';
-import { uploadFile } from '@/scripts/upload.js';
-import { prefer } from '@/preferences.js';
-
-export function chooseFileFromPc(
- multiple: boolean,
- options?: {
- uploadFolder?: string | null;
- keepOriginal?: boolean;
- nameConverter?: (file: File) => string | undefined;
- },
-): Promise {
- const uploadFolder = options?.uploadFolder ?? prefer.s.uploadFolder;
- const keepOriginal = options?.keepOriginal ?? prefer.s.keepOriginalUploading;
- const nameConverter = options?.nameConverter ?? (() => undefined);
-
- return new Promise((res, rej) => {
- const input = document.createElement('input');
- input.type = 'file';
- input.multiple = multiple;
- input.onchange = () => {
- if (!input.files) return res([]);
- const promises = Array.from(
- input.files,
- file => uploadFile(file, uploadFolder, nameConverter(file), keepOriginal),
- );
-
- Promise.all(promises).then(driveFiles => {
- res(driveFiles);
- }).catch(err => {
- // アップロードのエラーは uploadFile 内でハンドリングされているためアラートダイアログを出したりはしてはいけない
- });
-
- // 一応廃棄
- (window as any).__misskey_input_ref__ = null;
- };
-
- // https://qiita.com/fukasawah/items/b9dc732d95d99551013d
- // iOS Safari で正常に動かす為のおまじない
- (window as any).__misskey_input_ref__ = input;
-
- input.click();
- });
-}
-
-export function chooseFileFromDrive(multiple: boolean): Promise {
- return new Promise((res, rej) => {
- os.selectDriveFile(multiple).then(files => {
- res(files);
- });
- });
-}
-
-export function chooseFileFromUrl(): Promise {
- return new Promise((res, rej) => {
- os.inputText({
- title: i18n.ts.uploadFromUrl,
- type: 'url',
- placeholder: i18n.ts.uploadFromUrlDescription,
- }).then(({ canceled, result: url }) => {
- if (canceled) return;
-
- const marker = Math.random().toString(); // TODO: UUIDとか使う
-
- const connection = useStream().useChannel('main');
- connection.on('urlUploadFinished', urlResponse => {
- if (urlResponse.marker === marker) {
- res(urlResponse.file);
- connection.dispose();
- }
- });
-
- misskeyApi('drive/files/upload-from-url', {
- url: url,
- folderId: prefer.s.uploadFolder,
- marker,
- });
-
- os.alert({
- title: i18n.ts.uploadFromUrlRequested,
- text: i18n.ts.uploadFromUrlMayTakeTime,
- });
- });
- });
-}
-
-function select(src: HTMLElement | EventTarget | null, label: string | null, multiple: boolean): Promise {
- return new Promise((res, rej) => {
- const keepOriginal = ref(prefer.s.keepOriginalUploading);
-
- os.popupMenu([label ? {
- text: label,
- type: 'label',
- } : undefined, {
- type: 'switch',
- text: i18n.ts.keepOriginalUploading,
- ref: keepOriginal,
- }, {
- text: i18n.ts.upload,
- icon: 'ti ti-upload',
- action: () => chooseFileFromPc(multiple, { keepOriginal: keepOriginal.value }).then(files => res(files)),
- }, {
- text: i18n.ts.fromDrive,
- icon: 'ti ti-cloud',
- action: () => chooseFileFromDrive(multiple).then(files => res(files)),
- }, {
- text: i18n.ts.fromUrl,
- icon: 'ti ti-link',
- action: () => chooseFileFromUrl().then(file => res([file])),
- }], src);
- });
-}
-
-export function selectFile(src: HTMLElement | EventTarget | null, label: string | null = null): Promise {
- return select(src, label, false).then(files => files[0]);
-}
-
-export function selectFiles(src: HTMLElement | EventTarget | null, label: string | null = null): Promise {
- return select(src, label, true);
-}
diff --git a/packages/frontend/src/scripts/show-moved-dialog.ts b/packages/frontend/src/scripts/show-moved-dialog.ts
deleted file mode 100644
index 35b3ef79d8..0000000000
--- a/packages/frontend/src/scripts/show-moved-dialog.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import * as os from '@/os.js';
-import { $i } from '@/account.js';
-import { i18n } from '@/i18n.js';
-
-export function showMovedDialog() {
- if (!$i) return;
- if (!$i.movedTo) return;
-
- os.alert({
- type: 'error',
- title: i18n.ts.accountMovedShort,
- text: i18n.ts.operationForbidden,
- });
-
- throw new Error('account moved');
-}
diff --git a/packages/frontend/src/scripts/show-suspended-dialog.ts b/packages/frontend/src/scripts/show-suspended-dialog.ts
deleted file mode 100644
index 8b89dbb936..0000000000
--- a/packages/frontend/src/scripts/show-suspended-dialog.ts
+++ /dev/null
@@ -1,15 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import * as os from '@/os.js';
-import { i18n } from '@/i18n.js';
-
-export function showSuspendedDialog() {
- return os.alert({
- type: 'error',
- title: i18n.ts.yourAccountSuspendedTitle,
- text: i18n.ts.yourAccountSuspendedDescription,
- });
-}
diff --git a/packages/frontend/src/scripts/shuffle.ts b/packages/frontend/src/scripts/shuffle.ts
deleted file mode 100644
index 1f6ef1928c..0000000000
--- a/packages/frontend/src/scripts/shuffle.ts
+++ /dev/null
@@ -1,25 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-/**
- * 配列をシャッフル (破壊的)
- */
-export function shuffle(array: T): T {
- let currentIndex = array.length;
- let randomIndex: number;
-
- // While there remain elements to shuffle.
- while (currentIndex !== 0) {
- // Pick a remaining element.
- randomIndex = Math.floor(Math.random() * currentIndex);
- currentIndex--;
-
- // And swap it with the current element.
- [array[currentIndex], array[randomIndex]] = [
- array[randomIndex], array[currentIndex]];
- }
-
- return array;
-}
diff --git a/packages/frontend/src/scripts/snowfall-effect.ts b/packages/frontend/src/scripts/snowfall-effect.ts
deleted file mode 100644
index d88bdb6660..0000000000
--- a/packages/frontend/src/scripts/snowfall-effect.ts
+++ /dev/null
@@ -1,490 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-export class SnowfallEffect {
- private VERTEX_SOURCE = `#version 300 es
- in vec4 a_position;
- in vec4 a_color;
- in vec3 a_rotation;
- in vec3 a_speed;
- in float a_size;
- out vec4 v_color;
- out float v_rotation;
- uniform float u_time;
- uniform mat4 u_projection;
- uniform vec3 u_worldSize;
- uniform float u_gravity;
- uniform float u_wind;
- uniform float u_spin_factor;
- uniform float u_turbulence;
-
- void main() {
- v_color = a_color;
- v_rotation = a_rotation.x + (u_time * u_spin_factor) * a_rotation.y;
-
- vec3 pos = a_position.xyz;
-
- pos.x = mod(pos.x + u_time + u_wind * a_speed.x, u_worldSize.x * 2.0) - u_worldSize.x;
- pos.y = mod(pos.y - u_time * a_speed.y * u_gravity, u_worldSize.y * 2.0) - u_worldSize.y;
-
- pos.x += sin(u_time * a_speed.z * u_turbulence) * a_rotation.z;
- pos.z += cos(u_time * a_speed.z * u_turbulence) * a_rotation.z;
-
- gl_Position = u_projection * vec4(pos.xyz, a_position.w);
- gl_PointSize = (a_size / gl_Position.w) * 100.0;
- }
- `;
-
- private FRAGMENT_SOURCE = `#version 300 es
- precision highp float;
-
- in vec4 v_color;
- in float v_rotation;
- uniform sampler2D u_texture;
- out vec4 out_color;
-
- void main() {
- vec2 rotated = vec2(
- cos(v_rotation) * (gl_PointCoord.x - 0.5) + sin(v_rotation) * (gl_PointCoord.y - 0.5) + 0.5,
- cos(v_rotation) * (gl_PointCoord.y - 0.5) - sin(v_rotation) * (gl_PointCoord.x - 0.5) + 0.5
- );
-
- vec4 snowflake = texture(u_texture, rotated);
-
- out_color = vec4(snowflake.rgb * v_color.xyz, snowflake.a * v_color.a);
- }
- `;
-
- private gl: WebGLRenderingContext;
- private program: WebGLProgram;
- private canvas: HTMLCanvasElement;
- private buffers: Record;
- private uniforms: Record;
- private texture: WebGLTexture;
- private camera: {
- fov: number;
- near: number;
- far: number;
- aspect: number;
- z: number;
- };
- private wind: {
- current: number;
- force: number;
- target: number;
- min: number;
- max: number;
- easing: number;
- };
- private time: {
- start: number;
- previous: number;
- } = {
- start: 0,
- previous: 0,
- };
- private raf = 0;
-
- private density: number = 1 / 90;
- private depth = 100;
- private count = 1000;
- private gravity = 100;
- private speed: number = 1 / 10000;
- private color: number[] = [1, 1, 1];
- private opacity = 1;
- private size = 4;
- private snowflake = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAAXNSR0IArs4c6QAAAAlwSFlzAAALEwAACxMBAJqcGAAAAVlpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IlhNUCBDb3JlIDUuNC4wIj4KICAgPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4KICAgICAgPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIKICAgICAgICAgICAgeG1sbnM6dGlmZj0iaHR0cDovL25zLmFkb2JlLmNvbS90aWZmLzEuMC8iPgogICAgICAgICA8dGlmZjpPcmllbnRhdGlvbj4xPC90aWZmOk9yaWVudGF0aW9uPgogICAgICA8L3JkZjpEZXNjcmlwdGlvbj4KICAgPC9yZGY6UkRGPgo8L3g6eG1wbWV0YT4KTMInWQAAErRJREFUeAHdmgnYlmPax5MShaxRKRElPmXJXpaSsRxDU0bTZ+kt65RloiRDltEMQsxYKmS+zzYjxCCamCzV2LchResMIxFRQ1G93+93Pdf5dL9v7zuf4/hm0fc/jt9znddy3/e1nNd53c/7vHXq/AtVWVnZA/bzkaQjoWG298DeMdvrmP6/EIOqC4fBsbAx7Arz4TaYBPXgWVDnO2jSBrB2T0IMIA9mCmmoE8aonPkR6WPZHlp9xSlfeyeBzq9bHBD5feEdUGfDXBgBqnde+a2wvw/dYdNctvZNAp1PnTaFttA6JgP7eVgBM0CNzgO9HNvy0AcYDda6SaDTdXOnz8X+IkZDugAGQmOYA+ob6Ah/MIOMDRPhJjgJ6uV7pXtWt81/50SnY/Wvwn4ZDHAvwJ9ATYcxyaqsnEnqZCyCPaE80BgYZXG/5A3VyyP/b08LHa11z9KmFUwA5eqruRBHYX1s8WSI1Xcbme8Mt8PWUCU+kF8XbFN+dtH+p06OD4IU8EjD/VOZ5bnezq0XHcHuC2oV7BDlkVIWq56uIX8UjAO31GRIMYW0Vo/xXtSXJyTuXVO6xk1qalRTmQ9AfqzEvog2XYpllnsd6Qr4unCPT7NtByu0uU7vuAaOoy1JuvfXpJdTvSX0gI1gCXwGZdFmEFxoQb7Wid8s7lNu+I8wuHGsTqz2zpQ9DAa5R6HC55A2gvCMXthvwi25bjx26H0M9/9f4Rnok9s0zulFlC2HzzP9cnld8nH/p7DVrbmuIfYs6JLz9U3/z+KGadDeCDsmwre7GyEifn/su8HVSsL2HeBn8CK8AW+B7u9R5yrPgyOjvSn5DWAaXAG2UU7CE9Ayt4k4sR1lX4LaLdd9gn2ftsL+Vtuh1Dp/elH1C8lvCdUj8kDK3gbP8XdhCnSC86rcsNSR9pQvhc/gVlB9bUfqoFNAy/mLrUROrpMwCtpBxBbTtLqkF4K6IF9rf57I9pnYekx5AS0P1VhopXso9pR5buC7+kewU86nFcB+BT4EXdIvNO73sRBubGTXLZtTtgp+DEb++bACdqBuJOlAaMMzLVM3whegNznQDtCb+pW5b8YY76euB5+7pxm0IbzCfS8m3Zf2q4T8/+4JNArXGoptpxz8LqDmQJq0Qnostt/sfIn5GygD4/Zeq7B7wljQO2yjB/QGj0Pjxz4wGdqXrkjXtCT/ISyDa6EPpHrSraFjvnecFpMoMx40Br3xSlD262rYObevddHTs2kYwWUG9uP5It/f1eU5Xw9btwoXPALbwYXcg+unG/KB3Rq8n9ddAOpn4Kr8BAaBcltcDo9D7Ouavig1o34x7F94xqPk74eLQH0MH8HvwS3SLPe9iheEG6f70KiuLpZv6sxG/Va5bFJOabaO7ucAvGEbeAH+AN1hV7iDOidQFz4A2oJb6D1YDhXZHkTqpL8EbqHDYRtwW20AsdIb8syl5N2e6dTAPB2mWYa+hE4Qk7I59iMwFZ70GlJlfyuTVfygs7Hyw7HbwI0w3Tak14BqEtdg7wVdIx8pZbtBUbrjZeA3vUPBANkU+sEehev8O4Db6QpwYm+D8II0KPKHwUFeQ3oLDIMN4WgID1yOPQ+MAXMhNAtju3ztmtuAypiAw7EXwo/Am+0NfUG5mknYc6GfGVIjsoFNuyuoh8COuDcd2LmwA9jWE8bB3Q7N4XrwWAz5XOXR+Tx4n6FgdHeB6sF/w2QwhlSXdXvl/jixx4NH8GW5LDzb7GrR4ES4F5QddB99CieAwStOAPegdUZ2B71F3AXbQSn3vJ1bYaYWrayh3NUPTcbYFExVW3CfXwlvgfoavMbnDAY9dxGo6dCt0LeaB54H4UydDEPA2R4PDlrFLB9XuNmTlO+Xr7X9ZNBr9J4+EN8AMcv6ButpMND9FM6EnTOHkLrSnvtzwbbq3vwMB2ow/qWFSC8ZC++ZQaldbquH2afQWbl8TdcvVtC6LtipifAuOKt6gA9Tzqgzb5R2gP1hX3DVtZVHVvdklY5DA5beIkVPuZn8LOgAnWEfeAaUkxCan/voBNkfF+U5cFu5z5XlxZU20OmZtgm1K45VO4naNCukrcBZVk/CD+E/YBjoYjXJY8Zg9DxsDrbbBHTRotxOrug4eBs+hHgWZtKzfHrdXHBi9gDvqzxFHNA5KVfyBCf0ExgB7nkXStLLEKkniNf0AzUs5+ublkVFKiC9FBZAvGxshT0NnN3zoSUYSJQPcjAvm0HmjcIPemNS96F6E36drFLwugx7EEzNZV/l9IjoEPkW4B7eFtYH9QKcBcfA/aCWgpPQOT+zMbb9fS3nDbYR2MdgV0S5aVlUhLs0w45IHi7sqnnGJ2E7CXqHWgZXgJ1y8KqpDUmfSLmSV5yB/XrpDqVP8ofmehNdOv7I0ShfP4yyJdl2a4SchI1gCXgkHgljYfvc1i3cs/SU1A9jQRpfri/b0Sal1RrtSj4ULyHprY5C6+6E1+EBULq0E+DK7A96iwqX0z4td8B3dCdob5gD3UB3j9fUcNuDKFOvgc+bZAZFf4Zgu/q/AGPMgfm+5ShPWay+k6I31BwAvVDRYL2cuqfUVTkfnTqvVFx5ai7/MXn3tp1UrtRkDWRsaAMjzaD08uJ1irz7+8ps/6ZYj90V3FKrQBkvmubULbN7vs7tZRyJV9w0ePLbQ4PcJspqXnkbhbgoGk/AVptZRxpB0hU7Mpc1x34cdgKPm1dzeTts9XPwlFAO5Au4BDbO7ZycO7J9A/Zh2b4A2+ucALefWpTrflDKVq4kHQBOoi9PO1qvsDeGd6AxXAJbQ5VxlFrW8EnDcJlTsOPcjElxL7WNy7AduC4f2+A/rSN/Hyg7YMBTxgqPUT3F2HAqtIb58GvQW86GqyG+ff4UWz0FBuH4UhaTal1vmAGfg98dfP4d4HPGwmwYAg+D2/J7uU0ap/YaolHZVbBj5d1DaSK8ADsmqiH2JIhgNRhbPZrbhSdZ5heVJGw7477VfYuaagMK2sM8iMloga1HXAt/AeWELgQnR/0Z7k3W6pe3xTn/JamTFPGnPMZSj6p90rA8YOziwHcnH/EgTovJlJ0LPSHkyrTKmZNJ+8KrYKBsCQeB0pWdBFNleieMgzjL44jejTK1CPSY0CiMdyOT09g6ni5O3Ceg51U4VNLaPSA3SDNEwwiKFdgHgANNrpjb7UVejYTYCuZ92DR42HYh8gfDJfAMqBi4dqxk+RrKGkD0YXNsA6AT5qCUXhBe5CR0gPCC4dhqKFwI1m1qX0hr94CotDE4aAd3PCyBX4Jyn+sNL5tBDsRAp3S7b5KVYwa2A0nHaO5AXBeDtnlMxizsW+HomLh8zX9R5sTeBSEn/cqc2Tvak9eDXCyP2PgbYWzn2gefHxT7+0Qu/h18DO7XmPWYcYqSXuHz2myb6G7RNs7meLgeMxXugbiPA3clQx0xtgNPGN819L7+oCzvm6zSx+EkI+Du3Pe0LbOd/jqc7dhG9Wib+mJ5jaJBuL8e4B5aAMpAomKlb8d+KZWUVnw+dgzKSdDtvKaLDyJ1ReZB7O0J2EV5Xwd8OsTJExNpu7Q1SJ8zgy7K93UCX4P4mr4udoyhPGDKygOP+tomIFarMw2d+cfgF2DnDVAGoBvzw33YTHgPDoXQ7Fx/Wy6YkdMrcrmrehO4Pz3WvP90cIVPgonwITg4973yu0XTZK0+ZQaQd+K816twVAwKO71ZRj9zeg7lcVzXHghpVN4n2G3BAHQ1NILx4MBjoppgLwL3Ww8IHZsf6vGk3O8fwx9heK7rhD0o2zdg75JtT6GzQQ8KzcZwElSr3M5J85ktYCzEG+Gx2NNzm/Cm5pSp+K2gfLrZbg3RcB2IQcZN1qPM3+l06SjbAltX/TiXe1wtg7+AdR+AcgIs7xUPw94XxuTrnOD4E1bEoe9Rptw+DWGOGeQi7JOs1SfKKfk+epcakPNxbI8uFVdem8vT6aJdq7jASYjOFPdQDP4Q6t+Em8HVutmbkbYH9Tv4LcQW+H6ujy9Wrtxc6A7vQnznb5TbHUPZ0mw7CeoaOBAegmfBIKw8WZzs34M/oNiPGPzB2KHdrVMUlD29VFLLpw2jMWmnaIbdDNxXur+dWgVumTMglI4zMgbUEV5LmjqW7XnRkDS9qhbu/xZlZ8LWuc3UfM22Of80aVcYDJ/lstdIWxXu0TGXm/TO19vveHWuOglUxOo6iMfyBe7JOEp01ech9puuuBCMA8pVcUUNUB5lqgMYwJyE1oXOGTh9v1gO6kmogKEwHtREMHYofz5zAl3lJ2AWqJfgfohJiKB8HWWfg54YA9Zr1fn5Xmm80SdvHhNwVmq2umF8vWxA+WRwwE9BPNhOulrq0nxz97j6Go6DF8HYcBfYyer6MwWuoINeDG6roq4iE97QCtsJuxWc2JrkCeKEbgX7waOgnLiavxdQEWfohtgRwCrygIoxoQv1K0FNgR7gAKPTB+dr5lAWMliqmbAb7AzbgCs42vYK21NmOiwHJ9atpdxqDlhdA75QdYJT4XUYDfbBiVRe5ySoZTAbBpeekp6T4lo5uFnBz0fpJ6P8E9SJufEdXHipdRA/mw2hzmvfhrfgfjCKPwJnwn2g3igldb4hNaD5a6/fz7eHVuAb2wPwPs+4DB7E/hTagd64BbgoC6Ab9IAfgn+OX0p/ppAaGxZjnw6+Ep8DK8Cj0IDrmHw3GaeN9EZ/AlxFfk1RuVGUYu8K00D9Fa6EvrAUVKzO29gXg9vC1VW3g540w0xBcU2hKJnz+FxYvTCXWaduK/StuTZlLcD6JjnfEvsb6A56m32z78q4FMGw1gA4lEa60WmwMeiSnsljIBSDmEOBE3RdfvggbMuMIbNhItgJtbyUpE9ddjA0Bid1sderXDaQ1OdPAO9zH6hDcpuG2Ml7SQfArHRx6Xpf3JTluySrsrIP6Seg9/iMqsEvF6YZoXIDeAZCRmpneAHEnnLQnaEuXATX53schR3n/e7YyuvOT1bpnyV107Io3xZ6QWs4EirAyXkEqqvK3xa9CQ0c5C5xQ+zN8kWjcr2xZxTsBHfmsipbP671ZmW3wHYA58DdEPobhtwVF2HfBE9H3pT8xjkdja3iiDK4PQBO8Dx4B9wiH8JKeANcKTUW9IITwKNMeYrcArfDhVDsb1pVyty26le5D97/zWzrzVUGXyVjI0WjHUgq4CjoAuGiRuuJkN7mSJX7cn+uaZNyfBBgDHZqXvqsU2cZ6aPwChgE/ap8M9wLbSH+0DKOaw18z8N12GPAyf4BfADbwBmwCbxAHY9NvxQXx2GgVLZXPvurZDE0rqk5+NmAm8U2aIbdH9yDalgpSS80ltlB29fPqW9c8XLUHnsIuGquqt8gN7edwtazrOsAn4MysLryX8BD4Ap3y+0dZROIwPsl9h/hHjgit4lXdrdvHN8dc91wyk7JdvIS7VpF46Jb2ZGz4WJIRyBpBKQW3oR8lZuSvwQMhKtAfQUpYuf27cgbNx6EEeDAzgMHPwYMYi2gEcSfxC7B9qicDMoo/1vQI8p9IG88WAY/yeVpYrJdHpf5vytu4Ky7X46xIamrvjDb52OrG3K+HrZt4xq9wYEZPGPVfp7bhsdE2os2ylV6J1n5mbYPUX4S7AkGX+OAk2t6mm1Iw3PtQ+O4LuooK26RYvW3s7nBLZDiAGlbUHYiRV/S5AWk28DTEFqB4eo+B+n1M55Ivhu4kspj92uYCm6Px0Gv61lor0fcDQNBrQQnOr71lVeYsm894L/bkBuFe/u93eBngJtJMlwTDIDKyfDt6n3se8Dt8jHoNU0o70waq34obZ8lPx4coG+LbifrP6Pt0aQvwn65LFzcAHY8ZUtgAnwExp2WoMpeQLvaA12p7bf/pLPFmS3a/ajr750cfE43wX4YYmU9wi7IddHBCsrc69vm8uuwQydYVhQVvmsUn7s+ebfD0GhXrI+yf2jqA4oPKdo+iHxMwHbYRmgjta4cUTqCWXkg0UHatIR4SxxWKK9PeXhgKiZfxWOthzXuGff4p6b54bH3Y3W3pNxJcK8ebgdI44iys0G0N/8qKGOAGg9Ni50n3yjy2GkxSKtMRtT/21I7Fg/H9lRIX6qK5YX6zSjvDL4BGiBfBnUNmFdzwfKX4Ct40OtJv1sDj0Hlzrk6xbM3tob7uCf4amyk96VHvQg7gltGzQG9wpcwX6BCesfJ3/kJiMmgs+Gm4errUeZqF+Up4IoOzoWLcmqETyLve/2BsKkFpGUvK7VYCz6j06RbQx+ogHhN3Qdb3QF+a/wVKF94OhSHR77sWcXytcKm82usHGW9QE2B3skq/QB7APaqnJ9NuvaufnF1GIhxYH3LSAeA+hM0hMfgNzATdHvjgDHDv+qkP8gW77XW2gwmYsJe2F3zZDgxI7NteTo+/1WD/B9Au3Zjh2RyrgAAAABJRU5ErkJggg==';
- private mode = 'snow';
-
- private INITIAL_BUFFERS = () => ({
- position: { size: 3, value: [] },
- color: { size: 4, value: [] },
- size: { size: 1, value: [] },
- rotation: { size: 3, value: [] },
- speed: { size: 3, value: [] },
- });
-
- private INITIAL_UNIFORMS = () => ({
- time: { type: 'float', value: 0 },
- worldSize: { type: 'vec3', value: [0, 0, 0] },
- gravity: { type: 'float', value: this.gravity },
- wind: { type: 'float', value: 0 },
- spin_factor: { type: 'float', value: this.mode === 'sakura' ? 8 : 1 },
- turbulence: { type: 'float', value: this.mode === 'sakura' ? 2 : 1 },
- projection: {
- type: 'mat4',
- value: [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1],
- },
- });
-
- private UNIFORM_SETTERS = {
- int: 'uniform1i',
- float: 'uniform1f',
- vec2: 'uniform2fv',
- vec3: 'uniform3fv',
- vec4: 'uniform4fv',
- mat2: 'uniformMatrix2fv',
- mat3: 'uniformMatrix3fv',
- mat4: 'uniformMatrix4fv',
- };
-
- private CAMERA = {
- fov: 60,
- near: 5,
- far: 10000,
- aspect: 1,
- z: 100,
- };
-
- private WIND = {
- current: 0,
- force: 0.01,
- target: 0.01,
- min: 0,
- max: 0.125,
- easing: 0.0005,
- };
- /**
- * @throws {Error} - Thrown when it fails to get WebGL context for the canvas
- */
- constructor(options: {
- sakura?: boolean;
- }) {
- if (options.sakura) {
- this.mode = 'sakura';
- this.snowflake = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAFEmlUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4KPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iWE1QIENvcmUgNS41LjAiPgogPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4KICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIgogICAgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIgogICAgeG1sbnM6cGhvdG9zaG9wPSJodHRwOi8vbnMuYWRvYmUuY29tL3Bob3Rvc2hvcC8xLjAvIgogICAgeG1sbnM6ZXhpZj0iaHR0cDovL25zLmFkb2JlLmNvbS9leGlmLzEuMC8iCiAgICB4bWxuczp0aWZmPSJodHRwOi8vbnMuYWRvYmUuY29tL3RpZmYvMS4wLyIKICAgIHhtbG5zOnhtcE1NPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvbW0vIgogICAgeG1sbnM6c3RFdnQ9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZUV2ZW50IyIKICAgeG1wOkNyZWF0ZURhdGU9IjIwMjQtMDItMDFUMTQ6Mzk6NTYrMDkwMCIKICAgeG1wOk1vZGlmeURhdGU9IjIwMjQtMDItMDFUMTQ6NDU6MzQrMDk6MDAiCiAgIHhtcDpNZXRhZGF0YURhdGU9IjIwMjQtMDItMDFUMTQ6NDU6MzQrMDk6MDAiCiAgIHBob3Rvc2hvcDpEYXRlQ3JlYXRlZD0iMjAyNC0wMi0wMVQxNDozOTo1NiswOTAwIgogICBwaG90b3Nob3A6Q29sb3JNb2RlPSIzIgogICBwaG90b3Nob3A6SUNDUHJvZmlsZT0ic1JHQiBJRUM2MTk2Ni0yLjEiCiAgIGV4aWY6UGl4ZWxYRGltZW5zaW9uPSI2NCIKICAgZXhpZjpQaXhlbFlEaW1lbnNpb249IjY0IgogICBleGlmOkNvbG9yU3BhY2U9IjEiCiAgIHRpZmY6SW1hZ2VXaWR0aD0iNjQiCiAgIHRpZmY6SW1hZ2VMZW5ndGg9IjY0IgogICB0aWZmOlJlc29sdXRpb25Vbml0PSIyIgogICB0aWZmOlhSZXNvbHV0aW9uPSI3Mi8xIgogICB0aWZmOllSZXNvbHV0aW9uPSI3Mi8xIj4KICAgPHhtcE1NOkhpc3Rvcnk+CiAgICA8cmRmOlNlcT4KICAgICA8cmRmOmxpCiAgICAgIHN0RXZ0OmFjdGlvbj0icHJvZHVjZWQiCiAgICAgIHN0RXZ0OnNvZnR3YXJlQWdlbnQ9IkFmZmluaXR5IFBob3RvIDIgMi4zLjEiCiAgICAgIHN0RXZ0OndoZW49IjIwMjQtMDItMDFUMTQ6NDU6MzQrMDk6MDAiLz4KICAgIDwvcmRmOlNlcT4KICAgPC94bXBNTTpIaXN0b3J5PgogIDwvcmRmOkRlc2NyaXB0aW9uPgogPC9yZGY6UkRGPgo8L3g6eG1wbWV0YT4KPD94cGFja2V0IGVuZD0iciI/PhldI30AAAGBaUNDUHNSR0IgSUVDNjE5NjYtMi4xAAAokXWRu0sDQRCHP6Mh4oOIWlhYBPHRJBIjiDYWEV+gFjGCr+ZyuUuEJB53JyK2gq2gINr4KvQv0FawFgRFEcTaWtFG5ZwzgQQxs+zst7+dGXZnwRPPqFmrKgzZnG3GRqOB2bn5gO8FDxV46aJRUS1jcnokTln7uJdYsduQW6t83L9Wm9QsFSqqhQdVw7SFx4QnVm3D5R3hZjWtJIXPhIOmXFD4ztUTeX5xOZXnL5fNeGwIPA3CgVQJJ0pYTZtZYXk57dnMilq4j/uSOi03My1rm8xWLGKMEiXAOMMM0UcPA+L7CBGhW3aUyQ//5k+xLLmqeIM1TJZIkcYmKOqKVNdk1UXXZGRYc/v/t6+W3hvJV6+LgvfZcd46wLcN31uO83nkON/HUPkEl7li/vIh9L+LvlXU2g/AvwHnV0UtsQsXm9DyaCim8itVyvToOryeQv0cNN1AzUK+Z4VzTh4gvi5fdQ17+9Ap8f7FHyc6Z8kcDq1+AAAACXBIWXMAAAsTAAALEwEAmpwYAAADwElEQVR4nO2bT4hWVRjGf75TkhoEkhSa/9ocRIIwCsrE1pVnLbkYdFdGgQRS6caVm3CVy2oRuqmQ2yJXKTJh4GqCGs/CJCcLccAJ/yDpnGnxHYeZ4TrNfOc55y78nuWdc3/ve57v+b65f86BgQaqotiE5bEJKxYx7onYhOU1egKwGkViE/YCN4Cx2ITNC4xbDVwAJmMT9tXobVnpArEJe4CvZx0aB7aZdxPzxhkwArw66/Ae8+5Eyf6KJiA2YRPw+bzD64EjLcP3MXfyAMdjEzYWaG1GxRIQmzAEnAVeb/nzFPCSeTeaxj4FBOCZlrEjwBvm3VSJPksm4BPaJw8wBHwXm/BibMIW4HvaJ09ifFygP6BQAtKkfgEeEyHvAy+YdxdFvBmVSsBBdJMnsQ4KeTOSJyA2YT1wCXhcjL4HPG/e/amElkjAAfSTJzEPqKHSBKQLmSvAKiV3lm4BG8y7GyqgOgHvU27yAE+mGjLJEhCbsBL4A3haxXyIJoCN5t0dBUyZgF2UnzypxtsqmNKAt4SsarUkX4F0I3ONOgkAuA48a97FXJAqAa9Qb/IAa4CXFSCVATXjL635yBuQ/RsQm7AWuCroZamaBtaZd3/nQBQJeFPA6EfLFLUVBrwmYPSr7bkAhQHPCRj9al0uQGHAWgGjs9oKA7I/hS5rZ/0XSC86JDclGVph3t3t9+TcBHT56T9QVg+5BnT5/X+grB4GCcgs/sgnYCjzfIWyesg14Hrm+Qpl9ZBrwMT/DymurB4GCeiyuEidGnCN3n15V5pOPfStLAPMu1vAWA4jU7+Zd7dzAIqboREBo7PaCgN+EjA6qz1IQDbAu9/prQeorUvm3eVciOqx+JcizlL0hQKiMuAreiu/amkq1cyWxADz7ipwWsFapH4w7/5SgJRvh+cviCyp4yqQeonMOWCHktmic+bdThVMvUSmyFK2kjWkBph354FTSuY8nTLvflYCSyyT+xD4pwB3EvhADZUbYN5dAfarucB+825cDS25WvwksFuEO2nevSNizVHJ1eLvAoplrePAewJOq4oZYN5NAsPkPTCZBoYTq4iK7hgx734EjmUgjpl3Z1T9tKnGpqlP6e+p0Vg6t6iKG5De3A6ztJul+/Si3/db38WqyrY58+4CcHQJpxxN5xRXFQOSjgCjixg3SvuusiKqZoB59y+964KbCwy7Cew27+7V6apuAkibnhbaEbq3xMaohVTVAADz7hvgMHN/FKeAQ+bdt7X7Kb519mGKTdgKfEbvYucj8+7XLvr4DxAA134c0w/5AAAAAElFTkSuQmCC';
- this.size = 10;
- this.density = 1 / 280;
- }
-
- const canvas = this.initCanvas();
- const gl = canvas.getContext('webgl2', { antialias: true });
- if (gl == null) throw new Error('Failed to get WebGL context');
-
- document.body.append(canvas);
-
- this.canvas = canvas;
- this.gl = gl;
- this.program = this.initProgram();
- this.buffers = this.initBuffers();
- this.uniforms = this.initUniforms();
- this.texture = this.initTexture();
- this.camera = this.initCamera();
- this.wind = this.initWind();
-
- this.resize = this.resize.bind(this);
- this.update = this.update.bind(this);
-
- window.addEventListener('resize', () => this.resize());
- }
-
- private initCanvas(): HTMLCanvasElement {
- const canvas = document.createElement('canvas');
-
- Object.assign(canvas.style, {
- position: 'fixed',
- top: 0,
- left: 0,
- width: '100vw',
- height: '100vh',
- background: 'transparent',
- 'pointer-events': 'none',
- 'z-index': 2147483647,
- });
-
- return canvas;
- }
-
- private initCamera() {
- return { ...this.CAMERA };
- }
-
- private initWind() {
- return { ...this.WIND };
- }
-
- private initShader(type, source): WebGLShader {
- const { gl } = this;
- const shader = gl.createShader(type);
- if (shader == null) throw new Error('Failed to create shader');
-
- gl.shaderSource(shader, source);
- gl.compileShader(shader);
-
- return shader;
- }
-
- private initProgram(): WebGLProgram {
- const { gl } = this;
- const vertex = this.initShader(gl.VERTEX_SHADER, this.VERTEX_SOURCE);
- const fragment = this.initShader(gl.FRAGMENT_SHADER, this.FRAGMENT_SOURCE);
- const program = gl.createProgram();
- if (program == null) throw new Error('Failed to create program');
-
- gl.attachShader(program, vertex);
- gl.attachShader(program, fragment);
- gl.linkProgram(program);
- gl.useProgram(program);
-
- return program;
- }
-
- private initBuffers(): SnowfallEffect['buffers'] {
- const { gl, program } = this;
- const buffers = this.INITIAL_BUFFERS() as unknown as SnowfallEffect['buffers'];
-
- for (const [name, buffer] of Object.entries(buffers)) {
- buffer.location = gl.getAttribLocation(program, `a_${name}`);
- buffer.ref = gl.createBuffer()!;
-
- gl.bindBuffer(gl.ARRAY_BUFFER, buffer.ref);
- gl.enableVertexAttribArray(buffer.location);
- gl.vertexAttribPointer(
- buffer.location,
- buffer.size,
- gl.FLOAT,
- false,
- 0,
- 0,
- );
- }
-
- return buffers;
- }
-
- private updateBuffers() {
- const { buffers } = this;
-
- for (const name of Object.keys(buffers)) {
- this.setBuffer(name);
- }
- }
-
- private setBuffer(name: string, value?) {
- const { gl, buffers } = this;
- const buffer = buffers[name];
-
- buffer.value = new Float32Array(value ?? buffer.value);
-
- gl.bindBuffer(gl.ARRAY_BUFFER, buffer.ref);
- gl.bufferData(gl.ARRAY_BUFFER, buffer.value, gl.STATIC_DRAW);
- }
-
- private initUniforms(): SnowfallEffect['uniforms'] {
- const { gl, program } = this;
- const uniforms = this.INITIAL_UNIFORMS() as unknown as SnowfallEffect['uniforms'];
-
- for (const [name, uniform] of Object.entries(uniforms)) {
- uniform.location = gl.getUniformLocation(program, `u_${name}`)!;
- }
-
- return uniforms;
- }
-
- private updateUniforms() {
- const { uniforms } = this;
-
- for (const name of Object.keys(uniforms)) {
- this.setUniform(name);
- }
- }
-
- private setUniform(name: string, value?) {
- const { gl, uniforms } = this;
- const uniform = uniforms[name];
- const setter = this.UNIFORM_SETTERS[uniform.type];
- const isMatrix = /^mat[2-4]$/i.test(uniform.type);
-
- uniform.value = value ?? uniform.value;
-
- if (isMatrix) {
- gl[setter](uniform.location, false, uniform.value);
- } else {
- gl[setter](uniform.location, uniform.value);
- }
- }
-
- private initTexture() {
- const { gl } = this;
- const texture = gl.createTexture();
- if (texture == null) throw new Error('Failed to create texture');
- const image = new Image();
-
- gl.bindTexture(gl.TEXTURE_2D, texture);
- gl.texImage2D(
- gl.TEXTURE_2D,
- 0,
- gl.RGBA,
- 1,
- 1,
- 0,
- gl.RGBA,
- gl.UNSIGNED_BYTE,
- new Uint8Array([0, 0, 0, 0]),
- );
-
- image.onload = () => {
- gl.bindTexture(gl.TEXTURE_2D, texture);
- gl.texImage2D(
- gl.TEXTURE_2D,
- 0,
- gl.RGBA,
- gl.RGBA,
- gl.UNSIGNED_BYTE,
- image,
- );
- gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
- gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
- gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
- gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
- };
-
- image.src = this.snowflake;
-
- return texture;
- }
-
- private initSnowflakes(vw: number, vh: number, dpi: number) {
- const position: number[] = [];
- const color: number[] = [];
- const size: number[] = [];
- const rotation: number[] = [];
- const speed: number[] = [];
-
- const height = 1 / this.density;
- const width = (vw / vh) * height;
- const depth = this.depth;
- const count = this.count;
- const length = (vw / vh) * count;
-
- for (let i = 0; i < length; ++i) {
- position.push(
- -width + Math.random() * width * 2,
- -height + Math.random() * height * 2,
- Math.random() * depth * 2,
- );
-
- speed.push(1 + Math.random(), 1 + Math.random(), Math.random() * 10);
-
- rotation.push(
- Math.random() * 2 * Math.PI,
- Math.random() * 20,
- Math.random() * 10,
- );
-
- color.push(...this.color, 0.1 + Math.random() * this.opacity);
- //size.push((this.size * Math.random() * this.size * vh * dpi) / 1000);
- size.push((this.size * vh * dpi) / 1000);
- }
-
- this.setUniform('worldSize', [width, height, depth]);
-
- this.setBuffer('position', position);
- this.setBuffer('color', color);
- this.setBuffer('rotation', rotation);
- this.setBuffer('size', size);
- this.setBuffer('speed', speed);
- }
-
- private setProjection(aspect: number) {
- const { camera } = this;
-
- camera.aspect = aspect;
-
- const fovRad = (camera.fov * Math.PI) / 180;
- const f = Math.tan(Math.PI * 0.5 - 0.5 * fovRad);
- const rangeInv = 1.0 / (camera.near - camera.far);
-
- const m0 = f / camera.aspect;
- const m5 = f;
- const m10 = (camera.near + camera.far) * rangeInv;
- const m11 = -1;
- const m14 = camera.near * camera.far * rangeInv * 2 + camera.z;
- const m15 = camera.z;
-
- return [m0, 0, 0, 0, 0, m5, 0, 0, 0, 0, m10, m11, 0, 0, m14, m15];
- }
-
- public render() {
- const { gl } = this;
-
- gl.enable(gl.BLEND);
- gl.enable(gl.CULL_FACE);
- gl.blendFunc(gl.SRC_ALPHA, gl.ONE);
- gl.disable(gl.DEPTH_TEST);
-
- this.updateBuffers();
- this.updateUniforms();
- this.resize(true);
-
- this.time = {
- start: window.performance.now(),
- previous: window.performance.now(),
- };
-
- if (this.raf) window.cancelAnimationFrame(this.raf);
- this.raf = window.requestAnimationFrame(this.update);
-
- return this;
- }
-
- private resize(updateSnowflakes = false) {
- const { canvas, gl } = this;
- const vw = canvas.offsetWidth;
- const vh = canvas.offsetHeight;
- const aspect = vw / vh;
- const dpi = window.devicePixelRatio;
-
- canvas.width = vw * dpi;
- canvas.height = vh * dpi;
-
- gl.viewport(0, 0, vw * dpi, vh * dpi);
- gl.clearColor(0, 0, 0, 0);
-
- if (updateSnowflakes === true) {
- this.initSnowflakes(vw, vh, dpi);
- }
-
- this.setUniform('projection', this.setProjection(aspect));
- }
-
- private update(timestamp: number) {
- const { gl, buffers, wind } = this;
- const elapsed = (timestamp - this.time.start) * this.speed;
- const delta = timestamp - this.time.previous;
-
- gl.clear(gl.COLOR_BUFFER_BIT);
- gl.drawArrays(
- gl.POINTS,
- 0,
- buffers.position.value.length / buffers.position.size,
- );
-
- if (Math.random() > 0.995) {
- wind.target =
- (wind.min + Math.random() * (wind.max - wind.min)) *
- (Math.random() > 0.5 ? -1 : 1);
- }
-
- wind.force += (wind.target - wind.force) * wind.easing;
- wind.current += wind.force * (delta * 0.2);
-
- this.setUniform('wind', wind.current);
- this.setUniform('time', elapsed);
-
- this.time.previous = timestamp;
-
- this.raf = window.requestAnimationFrame(this.update);
- }
-}
diff --git a/packages/frontend/src/scripts/sound.ts b/packages/frontend/src/scripts/sound.ts
deleted file mode 100644
index 436c2b75f0..0000000000
--- a/packages/frontend/src/scripts/sound.ts
+++ /dev/null
@@ -1,258 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import type { SoundStore } from '@/preferences/def.js';
-import { prefer } from '@/preferences.js';
-import { PREF_DEF } from '@/preferences/def.js';
-
-let ctx: AudioContext;
-const cache = new Map();
-let canPlay = true;
-
-export const soundsTypes = [
- // 音声なし
- null,
-
- // ドライブの音声
- '_driveFile_',
-
- // プリインストール
- 'syuilo/n-aec',
- 'syuilo/n-aec-4va',
- 'syuilo/n-aec-4vb',
- 'syuilo/n-aec-8va',
- 'syuilo/n-aec-8vb',
- 'syuilo/n-cea',
- 'syuilo/n-cea-4va',
- 'syuilo/n-cea-4vb',
- 'syuilo/n-cea-8va',
- 'syuilo/n-cea-8vb',
- 'syuilo/n-eca',
- 'syuilo/n-eca-4va',
- 'syuilo/n-eca-4vb',
- 'syuilo/n-eca-8va',
- 'syuilo/n-eca-8vb',
- 'syuilo/n-ea',
- 'syuilo/n-ea-4va',
- 'syuilo/n-ea-4vb',
- 'syuilo/n-ea-8va',
- 'syuilo/n-ea-8vb',
- 'syuilo/n-ea-harmony',
- 'syuilo/up',
- 'syuilo/down',
- 'syuilo/pope1',
- 'syuilo/pope2',
- 'syuilo/waon',
- 'syuilo/popo',
- 'syuilo/triple',
- 'syuilo/bubble1',
- 'syuilo/bubble2',
- 'syuilo/poi1',
- 'syuilo/poi2',
- 'syuilo/pirori',
- 'syuilo/pirori-wet',
- 'syuilo/pirori-square-wet',
- 'syuilo/square-pico',
- 'syuilo/reverved',
- 'syuilo/ryukyu',
- 'syuilo/kick',
- 'syuilo/snare',
- 'syuilo/queue-jammed',
- 'aisha/1',
- 'aisha/2',
- 'aisha/3',
- 'noizenecio/kick_gaba1',
- 'noizenecio/kick_gaba2',
- 'noizenecio/kick_gaba3',
- 'noizenecio/kick_gaba4',
- 'noizenecio/kick_gaba5',
- 'noizenecio/kick_gaba6',
- 'noizenecio/kick_gaba7',
-] as const;
-
-export const operationTypes = [
- 'noteMy',
- 'note',
- 'notification',
- 'reaction',
-] as const;
-
-/** サウンドの種類 */
-export type SoundType = typeof soundsTypes[number];
-
-/** スプライトの種類 */
-export type OperationType = typeof operationTypes[number];
-
-/**
- * 音声を読み込む
- * @param url url
- * @param options `useCache`: デフォルトは`true` 一度再生した音声はキャッシュする
- */
-export async function loadAudio(url: string, options?: { useCache?: boolean; }) {
- // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
- if (ctx == null) {
- ctx = new AudioContext();
-
- window.addEventListener('beforeunload', () => {
- ctx.close();
- });
- }
- if (options?.useCache ?? true) {
- if (cache.has(url)) {
- return cache.get(url) as AudioBuffer;
- }
- }
-
- let response: Response;
-
- try {
- response = await fetch(url);
- } catch (err) {
- return;
- }
-
- const arrayBuffer = await response.arrayBuffer();
- const audioBuffer = await ctx.decodeAudioData(arrayBuffer);
-
- if (options?.useCache ?? true) {
- cache.set(url, audioBuffer);
- }
-
- return audioBuffer;
-}
-
-/**
- * 既定のスプライトを再生する
- * @param type スプライトの種類を指定
- */
-export function playMisskeySfx(operationType: OperationType) {
- const sound = prefer.s[`sound.on.${operationType}`];
- playMisskeySfxFile(sound).then((succeed) => {
- if (!succeed && sound.type === '_driveFile_') {
- // ドライブファイルが存在しない場合はデフォルトのサウンドを再生する
- const soundName = PREF_DEF[`sound_${operationType}`].default.type as Exclude;
- if (_DEV_) console.log(`Failed to play sound: ${sound.fileUrl}, so play default sound: ${soundName}`);
- playMisskeySfxFileInternal({
- type: soundName,
- volume: sound.volume,
- });
- }
- });
-}
-
-/**
- * サウンド設定形式で指定された音声を再生する
- * @param soundStore サウンド設定
- */
-export async function playMisskeySfxFile(soundStore: SoundStore): Promise {
- // 連続して再生しない
- if (!canPlay) return false;
- // ユーザーアクティベーションが必要な場合はそれがない場合は再生しない
- if ('userActivation' in navigator && !navigator.userActivation.hasBeenActive) return false;
- // サウンドがない場合は再生しない
- if (soundStore.type === null || soundStore.type === '_driveFile_' && !soundStore.fileUrl) return false;
-
- canPlay = false;
- return await playMisskeySfxFileInternal(soundStore).finally(() => {
- // ごく短時間に音が重複しないように
- setTimeout(() => {
- canPlay = true;
- }, 25);
- });
-}
-
-async function playMisskeySfxFileInternal(soundStore: SoundStore): Promise {
- if (soundStore.type === null || (soundStore.type === '_driveFile_' && !soundStore.fileUrl)) {
- return false;
- }
- const masterVolume = prefer.s['sound.masterVolume'];
- if (isMute() || masterVolume === 0 || soundStore.volume === 0) {
- return true; // ミュート時は成功として扱う
- }
- const url = soundStore.type === '_driveFile_' ? soundStore.fileUrl : `/client-assets/sounds/${soundStore.type}.mp3`;
- const buffer = await loadAudio(url).catch(() => {
- return undefined;
- });
- if (!buffer) return false;
- const volume = soundStore.volume * masterVolume;
- createSourceNode(buffer, { volume }).soundSource.start();
- return true;
-}
-
-export async function playUrl(url: string, opts: {
- volume?: number;
- pan?: number;
- playbackRate?: number;
-}) {
- if (opts.volume === 0) {
- return;
- }
- const buffer = await loadAudio(url);
- if (!buffer) return;
- createSourceNode(buffer, opts).soundSource.start();
-}
-
-export function createSourceNode(buffer: AudioBuffer, opts: {
- volume?: number;
- pan?: number;
- playbackRate?: number;
-}): {
- soundSource: AudioBufferSourceNode;
- panNode: StereoPannerNode;
- gainNode: GainNode;
- } {
- const panNode = ctx.createStereoPanner();
- panNode.pan.value = opts.pan ?? 0;
-
- const gainNode = ctx.createGain();
-
- gainNode.gain.value = opts.volume ?? 1;
-
- const soundSource = ctx.createBufferSource();
- soundSource.buffer = buffer;
- soundSource.playbackRate.value = opts.playbackRate ?? 1;
- soundSource
- .connect(panNode)
- .connect(gainNode)
- .connect(ctx.destination);
-
- return { soundSource, panNode, gainNode };
-}
-
-/**
- * 音声の長さをミリ秒で取得する
- * @param file ファイルのURL(ドライブIDではない)
- */
-export async function getSoundDuration(file: string): Promise {
- const audioEl = document.createElement('audio');
- audioEl.src = file;
- return new Promise((resolve) => {
- const si = setInterval(() => {
- if (audioEl.readyState > 0) {
- resolve(audioEl.duration * 1000);
- clearInterval(si);
- audioEl.remove();
- }
- }, 100);
- });
-}
-
-/**
- * ミュートすべきかどうかを判断する
- */
-export function isMute(): boolean {
- if (prefer.s['sound.notUseSound']) {
- // サウンドを出力しない
- return true;
- }
-
- // noinspection RedundantIfStatementJS
- if (prefer.s['sound.useSoundOnlyWhenActive'] && document.visibilityState === 'hidden') {
- // ブラウザがアクティブな時のみサウンドを出力する
- return true;
- }
-
- return false;
-}
diff --git a/packages/frontend/src/scripts/sticky-sidebar.ts b/packages/frontend/src/scripts/sticky-sidebar.ts
deleted file mode 100644
index 50f1e6ecc8..0000000000
--- a/packages/frontend/src/scripts/sticky-sidebar.ts
+++ /dev/null
@@ -1,55 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-export class StickySidebar {
- private lastScrollTop = 0;
- private container: HTMLElement;
- private el: HTMLElement;
- private spacer: HTMLElement;
- private marginTop: number;
- private isTop = false;
- private isBottom = false;
- private offsetTop: number;
- private globalHeaderHeight = 59;
-
- constructor(container: StickySidebar['container'], marginTop = 0, globalHeaderHeight = 0) {
- this.container = container;
- this.el = this.container.children[0] as HTMLElement;
- this.el.style.position = 'sticky';
- this.spacer = document.createElement('div');
- this.container.prepend(this.spacer);
- this.marginTop = marginTop;
- this.offsetTop = this.container.getBoundingClientRect().top;
- this.globalHeaderHeight = globalHeaderHeight;
- }
-
- public calc(scrollTop: number) {
- if (scrollTop > this.lastScrollTop) { // downscroll
- const overflow = Math.max(0, this.globalHeaderHeight + (this.el.clientHeight + this.marginTop) - window.innerHeight);
- this.el.style.bottom = null;
- this.el.style.top = `${-overflow + this.marginTop + this.globalHeaderHeight}px`;
-
- this.isBottom = (scrollTop + window.innerHeight) >= (this.el.offsetTop + this.el.clientHeight);
-
- if (this.isTop) {
- this.isTop = false;
- this.spacer.style.marginTop = `${Math.max(0, this.globalHeaderHeight + this.lastScrollTop + this.marginTop - this.offsetTop)}px`;
- }
- } else { // upscroll
- const overflow = this.globalHeaderHeight + (this.el.clientHeight + this.marginTop) - window.innerHeight;
- this.el.style.top = null;
- this.el.style.bottom = `${-overflow}px`;
-
- this.isTop = scrollTop + this.marginTop + this.globalHeaderHeight <= this.el.offsetTop;
-
- if (this.isBottom) {
- this.isBottom = false;
- this.spacer.style.marginTop = `${this.globalHeaderHeight + this.lastScrollTop + this.marginTop - this.offsetTop - overflow}px`;
- }
- }
-
- this.lastScrollTop = scrollTop <= 0 ? 0 : scrollTop;
- }
-}
diff --git a/packages/frontend/src/scripts/stream-mock.ts b/packages/frontend/src/scripts/stream-mock.ts
deleted file mode 100644
index 9b1b368de4..0000000000
--- a/packages/frontend/src/scripts/stream-mock.ts
+++ /dev/null
@@ -1,81 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import { EventEmitter } from 'eventemitter3';
-import * as Misskey from 'misskey-js';
-import type { Channels, StreamEvents, IStream, IChannelConnection } from 'misskey-js';
-
-type AnyOf> = T[keyof T];
-type OmitFirst = T extends [any, ...infer R] ? R : never;
-
-/**
- * Websocket無効化時に使うStreamのモック(なにもしない)
- */
-export class StreamMock extends EventEmitter implements IStream {
- public readonly state = 'initializing';
-
- constructor(...args: ConstructorParameters) {
- super();
- // do nothing
- }
-
- public useChannel(channel: C, params?: Channels[C]['params'], name?: string): ChannelConnectionMock {
- return new ChannelConnectionMock(this, channel, name);
- }
-
- public removeSharedConnection(connection: any): void {
- // do nothing
- }
-
- public removeSharedConnectionPool(pool: any): void {
- // do nothing
- }
-
- public disconnectToChannel(): void {
- // do nothing
- }
-
- public send(typeOrPayload: string): void;
- public send(typeOrPayload: string, payload: any): void;
- public send(typeOrPayload: Record | any[]): void;
- public send(typeOrPayload: string | Record | any[], payload?: any): void {
- // do nothing
- }
-
- public ping(): void {
- // do nothing
- }
-
- public heartbeat(): void {
- // do nothing
- }
-
- public close(): void {
- // do nothing
- }
-}
-
-class ChannelConnectionMock = any> extends EventEmitter implements IChannelConnection {
- public id = '';
- public name?: string; // for debug
- public inCount = 0; // for debug
- public outCount = 0; // for debug
- public channel: string;
-
- constructor(stream: IStream, ...args: OmitFirst>>) {
- super();
-
- this.channel = args[0];
- this.name = args[1];
- }
-
- public send(type: T, body: Channel['receives'][T]): void {
- // do nothing
- }
-
- public dispose(): void {
- // do nothing
- }
-}
diff --git a/packages/frontend/src/scripts/test-utils.ts b/packages/frontend/src/scripts/test-utils.ts
deleted file mode 100644
index 52bb2d94e0..0000000000
--- a/packages/frontend/src/scripts/test-utils.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-export async function tick(): Promise {
- // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
- await new Promise((globalThis.requestIdleCallback ?? setTimeout) as never);
-}
diff --git a/packages/frontend/src/scripts/theme-editor.ts b/packages/frontend/src/scripts/theme-editor.ts
deleted file mode 100644
index 0206e378bf..0000000000
--- a/packages/frontend/src/scripts/theme-editor.ts
+++ /dev/null
@@ -1,87 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import { v4 as uuid } from 'uuid';
-
-import { themeProps } from './theme.js';
-import type { Theme } from './theme.js';
-
-export type Default = null;
-export type Color = string;
-export type FuncName = 'alpha' | 'darken' | 'lighten';
-export type Func = { type: 'func'; name: FuncName; arg: number; value: string; };
-export type RefProp = { type: 'refProp'; key: string; };
-export type RefConst = { type: 'refConst'; key: string; };
-export type Css = { type: 'css'; value: string; };
-
-export type ThemeValue = Color | Func | RefProp | RefConst | Css | Default;
-
-export type ThemeViewModel = [ string, ThemeValue ][];
-
-export const fromThemeString = (str?: string) : ThemeValue => {
- if (!str) return null;
- if (str.startsWith(':')) {
- const parts = str.slice(1).split('<');
- const name = parts[0] as FuncName;
- const arg = parseFloat(parts[1]);
- const value = parts[2].startsWith('@') ? parts[2].slice(1) : '';
- return { type: 'func', name, arg, value };
- } else if (str.startsWith('@')) {
- return {
- type: 'refProp',
- key: str.slice(1),
- };
- } else if (str.startsWith('$')) {
- return {
- type: 'refConst',
- key: str.slice(1),
- };
- } else if (str.startsWith('"')) {
- return {
- type: 'css',
- value: str.substring(1).trim(),
- };
- } else {
- return str;
- }
-};
-
-export const toThemeString = (value: Color | Func | RefProp | RefConst | Css) => {
- if (typeof value === 'string') return value;
- switch (value.type) {
- case 'func': return `:${value.name}<${value.arg}<@${value.value}`;
- case 'refProp': return `@${value.key}`;
- case 'refConst': return `$${value.key}`;
- case 'css': return `" ${value.value}`;
- }
-};
-
-export const convertToMisskeyTheme = (vm: ThemeViewModel, name: string, desc: string, author: string, base: 'dark' | 'light'): Theme => {
- const props = { } as { [key: string]: string };
- for (const [key, value] of vm) {
- if (value === null) continue;
- props[key] = toThemeString(value);
- }
-
- return {
- id: uuid(),
- name, desc, author, props, base,
- };
-};
-
-export const convertToViewModel = (theme: Theme): ThemeViewModel => {
- const vm: ThemeViewModel = [];
- // プロパティの登録
- vm.push(...themeProps.map(key => [key, fromThemeString(theme.props[key])] as [ string, ThemeValue ]));
-
- // 定数の登録
- const consts = Object
- .keys(theme.props)
- .filter(k => k.startsWith('$'))
- .map(k => [k, fromThemeString(theme.props[k])] as [ string, ThemeValue ]);
-
- vm.push(...consts);
- return vm;
-};
diff --git a/packages/frontend/src/scripts/theme.ts b/packages/frontend/src/scripts/theme.ts
deleted file mode 100644
index 851ba41e61..0000000000
--- a/packages/frontend/src/scripts/theme.ts
+++ /dev/null
@@ -1,189 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import { ref } from 'vue';
-import tinycolor from 'tinycolor2';
-import lightTheme from '@@/themes/_light.json5';
-import darkTheme from '@@/themes/_dark.json5';
-import JSON5 from 'json5';
-import { deepClone } from './clone.js';
-import type { BundledTheme } from 'shiki/themes';
-import { globalEvents } from '@/events.js';
-import { miLocalStorage } from '@/local-storage.js';
-import { addTheme, getThemes } from '@/theme-store.js';
-
-export type Theme = {
- id: string;
- name: string;
- author: string;
- desc?: string;
- base?: 'dark' | 'light';
- props: Record;
- codeHighlighter?: {
- base: BundledTheme;
- overrides?: Record;
- } | {
- base: '_none_';
- overrides: Record;
- };
-};
-
-export const themeProps = Object.keys(lightTheme.props).filter(key => !key.startsWith('X'));
-
-export const getBuiltinThemes = () => Promise.all(
- [
- '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',
- ].map(name => import(`@@/themes/${name}.json5`).then(({ default: _default }): Theme => _default)),
-);
-
-export const getBuiltinThemesRef = () => {
- const builtinThemes = ref([]);
- getBuiltinThemes().then(themes => builtinThemes.value = themes);
- return builtinThemes;
-};
-
-let timeout: number | null = null;
-
-export function applyTheme(theme: Theme, persist = true) {
- if (timeout) window.clearTimeout(timeout);
-
- document.documentElement.classList.add('_themeChanging_');
-
- timeout = window.setTimeout(() => {
- document.documentElement.classList.remove('_themeChanging_');
- }, 1000);
-
- const colorScheme = theme.base === 'dark' ? 'dark' : 'light';
-
- document.documentElement.dataset.colorScheme = colorScheme;
-
- // Deep copy
- const _theme = deepClone(theme);
-
- if (_theme.base) {
- const base = [lightTheme, darkTheme].find(x => x.id === _theme.base);
- if (base) _theme.props = Object.assign({}, base.props, _theme.props);
- }
-
- const props = compile(_theme);
-
- for (const tag of document.head.children) {
- if (tag.tagName === 'META' && tag.getAttribute('name') === 'theme-color') {
- tag.setAttribute('content', props['htmlThemeColor']);
- break;
- }
- }
-
- for (const [k, v] of Object.entries(props)) {
- document.documentElement.style.setProperty(`--MI_THEME-${k}`, v.toString());
- }
-
- document.documentElement.style.setProperty('color-scheme', colorScheme);
-
- if (persist) {
- miLocalStorage.setItem('theme', JSON.stringify(props));
- miLocalStorage.setItem('themeId', theme.id);
- miLocalStorage.setItem('colorScheme', colorScheme);
- }
-
- // 色計算など再度行えるようにクライアント全体に通知
- globalEvents.emit('themeChanged');
-}
-
-function compile(theme: Theme): Record {
- function getColor(val: string): tinycolor.Instance {
- if (val[0] === '@') { // ref (prop)
- return getColor(theme.props[val.substring(1)]);
- } else if (val[0] === '$') { // ref (const)
- return getColor(theme.props[val]);
- } else if (val[0] === ':') { // func
- const parts = val.split('<');
- const func = parts.shift().substring(1);
- const arg = parseFloat(parts.shift());
- const color = getColor(parts.join('<'));
-
- switch (func) {
- case 'darken': return color.darken(arg);
- case 'lighten': return color.lighten(arg);
- case 'alpha': return color.setAlpha(arg);
- case 'hue': return color.spin(arg);
- case 'saturate': return color.saturate(arg);
- }
- }
-
- // other case
- return tinycolor(val);
- }
-
- const props = {};
-
- for (const [k, v] of Object.entries(theme.props)) {
- if (k.startsWith('$')) continue; // ignore const
-
- props[k] = v.startsWith('"') ? v.replace(/^"\s*/, '') : genValue(getColor(v));
- }
-
- return props;
-}
-
-function genValue(c: tinycolor.Instance): string {
- return c.toRgbString();
-}
-
-export function validateTheme(theme: Record): boolean {
- if (theme.id == null || typeof theme.id !== 'string') return false;
- if (theme.name == null || typeof theme.name !== 'string') return false;
- if (theme.base == null || !['light', 'dark'].includes(theme.base)) return false;
- if (theme.props == null || typeof theme.props !== 'object') return false;
- return true;
-}
-
-export function parseThemeCode(code: string): Theme {
- let theme;
-
- try {
- theme = JSON5.parse(code);
- } catch (err) {
- throw new Error('Failed to parse theme json');
- }
- if (!validateTheme(theme)) {
- throw new Error('This theme is invaild');
- }
- if (getThemes().some(t => t.id === theme.id)) {
- throw new Error('This theme is already installed');
- }
-
- return theme;
-}
-
-export function previewTheme(code: string): void {
- const theme = parseThemeCode(code);
- if (theme) applyTheme(theme, false);
-}
-
-export async function installTheme(code: string): Promise {
- const theme = parseThemeCode(code);
- if (!theme) return;
- await addTheme(theme);
-}
diff --git a/packages/frontend/src/scripts/time.ts b/packages/frontend/src/scripts/time.ts
deleted file mode 100644
index 275b67ed00..0000000000
--- a/packages/frontend/src/scripts/time.ts
+++ /dev/null
@@ -1,45 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-const dateTimeIntervals = {
- 'day': 86400000,
- 'hour': 3600000,
- 'ms': 1,
-};
-
-export function dateUTC(time: number[]): Date {
- const d =
- time.length === 2 ? Date.UTC(time[0], time[1])
- : time.length === 3 ? Date.UTC(time[0], time[1], time[2])
- : time.length === 4 ? Date.UTC(time[0], time[1], time[2], time[3])
- : time.length === 5 ? Date.UTC(time[0], time[1], time[2], time[3], time[4])
- : time.length === 6 ? Date.UTC(time[0], time[1], time[2], time[3], time[4], time[5])
- : time.length === 7 ? Date.UTC(time[0], time[1], time[2], time[3], time[4], time[5], time[6])
- : null;
-
- if (!d) throw new Error('wrong number of arguments');
-
- return new Date(d);
-}
-
-export function isTimeSame(a: Date, b: Date): boolean {
- return a.getTime() === b.getTime();
-}
-
-export function isTimeBefore(a: Date, b: Date): boolean {
- return (a.getTime() - b.getTime()) < 0;
-}
-
-export function isTimeAfter(a: Date, b: Date): boolean {
- return (a.getTime() - b.getTime()) > 0;
-}
-
-export function addTime(x: Date, value: number, span: keyof typeof dateTimeIntervals = 'ms'): Date {
- return new Date(x.getTime() + (value * dateTimeIntervals[span]));
-}
-
-export function subtractTime(x: Date, value: number, span: keyof typeof dateTimeIntervals = 'ms'): Date {
- return new Date(x.getTime() - (value * dateTimeIntervals[span]));
-}
diff --git a/packages/frontend/src/scripts/timezones.ts b/packages/frontend/src/scripts/timezones.ts
deleted file mode 100644
index c7582e06da..0000000000
--- a/packages/frontend/src/scripts/timezones.ts
+++ /dev/null
@@ -1,54 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-export const timezones = [{
- name: 'UTC',
- abbrev: 'UTC',
- offset: 0,
-}, {
- name: 'Europe/Berlin',
- abbrev: 'CET',
- offset: 60,
-}, {
- name: 'Asia/Tokyo',
- abbrev: 'JST',
- offset: 540,
-}, {
- name: 'Asia/Seoul',
- abbrev: 'KST',
- offset: 540,
-}, {
- name: 'Asia/Shanghai',
- abbrev: 'CST',
- offset: 480,
-}, {
- name: 'Australia/Sydney',
- abbrev: 'AEST',
- offset: 600,
-}, {
- name: 'Australia/Darwin',
- abbrev: 'ACST',
- offset: 570,
-}, {
- name: 'Australia/Perth',
- abbrev: 'AWST',
- offset: 480,
-}, {
- name: 'America/New_York',
- abbrev: 'EST',
- offset: -300,
-}, {
- name: 'America/Mexico_City',
- abbrev: 'CST',
- offset: -360,
-}, {
- name: 'America/Phoenix',
- abbrev: 'MST',
- offset: -420,
-}, {
- name: 'America/Los_Angeles',
- abbrev: 'PST',
- offset: -480,
-}];
diff --git a/packages/frontend/src/scripts/touch.ts b/packages/frontend/src/scripts/touch.ts
deleted file mode 100644
index 13c9d648dc..0000000000
--- a/packages/frontend/src/scripts/touch.ts
+++ /dev/null
@@ -1,22 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import { ref } from 'vue';
-import { deviceKind } from '@/scripts/device-kind.js';
-
-const isTouchSupported = 'maxTouchPoints' in navigator && navigator.maxTouchPoints > 0;
-
-export let isTouchUsing = deviceKind === 'tablet' || deviceKind === 'smartphone';
-
-if (isTouchSupported && !isTouchUsing) {
- window.addEventListener('touchstart', () => {
- // maxTouchPointsなどでの判定だけだと、「タッチ機能付きディスプレイを使っているがマウスでしか操作しない」場合にも
- // タッチで使っていると判定されてしまうため、実際に一度でもタッチされたらtrueにする
- isTouchUsing = true;
- }, { passive: true });
-}
-
-/** (MkHorizontalSwipe) 横スワイプ中か? */
-export const isHorizontalSwipeSwiping = ref(false);
diff --git a/packages/frontend/src/scripts/unison-reload.ts b/packages/frontend/src/scripts/unison-reload.ts
deleted file mode 100644
index a24941d02e..0000000000
--- a/packages/frontend/src/scripts/unison-reload.ts
+++ /dev/null
@@ -1,20 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-// SafariがBroadcastChannel未実装なのでライブラリを使う
-import { BroadcastChannel } from 'broadcast-channel';
-
-export const reloadChannel = new BroadcastChannel('reload');
-
-// BroadcastChannelを用いて、クライアントが一斉にreloadするようにします。
-export function unisonReload(path?: string) {
- if (path !== undefined) {
- reloadChannel.postMessage(path);
- location.href = path;
- } else {
- reloadChannel.postMessage(null);
- location.reload();
- }
-}
diff --git a/packages/frontend/src/scripts/upload.ts b/packages/frontend/src/scripts/upload.ts
deleted file mode 100644
index d105a318a7..0000000000
--- a/packages/frontend/src/scripts/upload.ts
+++ /dev/null
@@ -1,162 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import { reactive, ref } from 'vue';
-import * as Misskey from 'misskey-js';
-import { v4 as uuid } from 'uuid';
-import { readAndCompressImage } from '@misskey-dev/browser-image-resizer';
-import { apiUrl } from '@@/js/config.js';
-import { getCompressionConfig } from './upload/compress-config.js';
-import { $i } from '@/account.js';
-import { alert } from '@/os.js';
-import { i18n } from '@/i18n.js';
-import { instance } from '@/instance.js';
-import { prefer } from '@/preferences.js';
-
-type Uploading = {
- id: string;
- name: string;
- progressMax: number | undefined;
- progressValue: number | undefined;
- img: string;
-};
-export const uploads = ref([]);
-
-const mimeTypeMap = {
- 'image/webp': 'webp',
- 'image/jpeg': 'jpg',
- 'image/png': 'png',
-} as const;
-
-export function uploadFile(
- file: File,
- folder?: string | Misskey.entities.DriveFolder,
- name?: string,
- keepOriginal: boolean = prefer.s.keepOriginalUploading,
-): Promise {
- if ($i == null) throw new Error('Not logged in');
-
- const _folder = typeof folder === 'string' ? folder : folder?.id;
-
- if (file.size > instance.maxFileSize) {
- alert({
- type: 'error',
- title: i18n.ts.failedToUpload,
- text: i18n.ts.cannotUploadBecauseExceedsFileSizeLimit,
- });
- return Promise.reject();
- }
-
- return new Promise((resolve, reject) => {
- const id = uuid();
-
- const reader = new FileReader();
- reader.onload = async (): Promise => {
- const filename = name ?? file.name ?? 'untitled';
- const extension = filename.split('.').length > 1 ? '.' + filename.split('.').pop() : '';
-
- const ctx = reactive({
- id,
- name: prefer.s.keepOriginalFilename ? filename : id + extension,
- progressMax: undefined,
- progressValue: undefined,
- img: window.URL.createObjectURL(file),
- });
-
- uploads.value.push(ctx);
-
- const config = !keepOriginal ? await getCompressionConfig(file) : undefined;
- let resizedImage: Blob | undefined;
- if (config) {
- try {
- const resized = await readAndCompressImage(file, config);
- if (resized.size < file.size || file.type === 'image/webp') {
- // The compression may not always reduce the file size
- // (and WebP is not browser safe yet)
- resizedImage = resized;
- }
- if (_DEV_) {
- const saved = ((1 - resized.size / file.size) * 100).toFixed(2);
- console.log(`Image compression: before ${file.size} bytes, after ${resized.size} bytes, saved ${saved}%`);
- }
-
- ctx.name = file.type !== config.mimeType ? `${ctx.name}.${mimeTypeMap[config.mimeType]}` : ctx.name;
- } catch (err) {
- console.error('Failed to resize image', err);
- }
- }
-
- const formData = new FormData();
- formData.append('i', $i!.token);
- formData.append('force', 'true');
- formData.append('file', resizedImage ?? file);
- formData.append('name', ctx.name);
- if (_folder) formData.append('folderId', _folder);
-
- const xhr = new XMLHttpRequest();
- xhr.open('POST', apiUrl + '/drive/files/create', true);
- xhr.onload = ((ev: ProgressEvent) => {
- if (xhr.status !== 200 || ev.target == null || ev.target.response == null) {
- // TODO: 消すのではなくて(ネットワーク的なエラーなら)再送できるようにしたい
- uploads.value = uploads.value.filter(x => x.id !== id);
-
- if (xhr.status === 413) {
- alert({
- type: 'error',
- title: i18n.ts.failedToUpload,
- text: i18n.ts.cannotUploadBecauseExceedsFileSizeLimit,
- });
- } else if (ev.target?.response) {
- const res = JSON.parse(ev.target.response);
- if (res.error?.id === 'bec5bd69-fba3-43c9-b4fb-2894b66ad5d2') {
- alert({
- type: 'error',
- title: i18n.ts.failedToUpload,
- text: i18n.ts.cannotUploadBecauseInappropriate,
- });
- } else if (res.error?.id === 'd08dbc37-a6a9-463a-8c47-96c32ab5f064') {
- alert({
- type: 'error',
- title: i18n.ts.failedToUpload,
- text: i18n.ts.cannotUploadBecauseNoFreeSpace,
- });
- } else {
- alert({
- type: 'error',
- title: i18n.ts.failedToUpload,
- text: `${res.error?.message}\n${res.error?.code}\n${res.error?.id}`,
- });
- }
- } else {
- alert({
- type: 'error',
- title: 'Failed to upload',
- text: `${JSON.stringify(ev.target?.response)}, ${JSON.stringify(xhr.response)}`,
- });
- }
-
- reject();
- return;
- }
-
- const driveFile = JSON.parse(ev.target.response);
-
- resolve(driveFile);
-
- uploads.value = uploads.value.filter(x => x.id !== id);
- }) as (ev: ProgressEvent) => any;
-
- xhr.upload.onprogress = ev => {
- if (ev.lengthComputable) {
- ctx.progressMax = ev.total;
- ctx.progressValue = ev.loaded;
- }
- };
-
- xhr.send(formData);
- };
- reader.readAsArrayBuffer(file);
- });
-}
diff --git a/packages/frontend/src/scripts/upload/compress-config.ts b/packages/frontend/src/scripts/upload/compress-config.ts
deleted file mode 100644
index 3046b7f518..0000000000
--- a/packages/frontend/src/scripts/upload/compress-config.ts
+++ /dev/null
@@ -1,36 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import isAnimated from 'is-file-animated';
-import { isWebpSupported } from './isWebpSupported.js';
-import type { BrowserImageResizerConfigWithConvertedOutput } from '@misskey-dev/browser-image-resizer';
-
-const compressTypeMap = {
- 'image/jpeg': { quality: 0.90, mimeType: 'image/webp' },
- 'image/png': { quality: 1, mimeType: 'image/webp' },
- 'image/webp': { quality: 0.90, mimeType: 'image/webp' },
- 'image/svg+xml': { quality: 1, mimeType: 'image/webp' },
-} as const;
-
-const compressTypeMapFallback = {
- 'image/jpeg': { quality: 0.85, mimeType: 'image/jpeg' },
- 'image/png': { quality: 1, mimeType: 'image/png' },
- 'image/webp': { quality: 0.85, mimeType: 'image/jpeg' },
- 'image/svg+xml': { quality: 1, mimeType: 'image/png' },
-} as const;
-
-export async function getCompressionConfig(file: File): Promise {
- const imgConfig = (isWebpSupported() ? compressTypeMap : compressTypeMapFallback)[file.type];
- if (!imgConfig || await isAnimated(file)) {
- return;
- }
-
- return {
- maxWidth: 2048,
- maxHeight: 2048,
- debug: true,
- ...imgConfig,
- };
-}
diff --git a/packages/frontend/src/scripts/upload/isWebpSupported.ts b/packages/frontend/src/scripts/upload/isWebpSupported.ts
deleted file mode 100644
index 2511236ecc..0000000000
--- a/packages/frontend/src/scripts/upload/isWebpSupported.ts
+++ /dev/null
@@ -1,15 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-let isWebpSupportedCache: boolean | undefined;
-export function isWebpSupported() {
- if (isWebpSupportedCache === undefined) {
- const canvas = document.createElement('canvas');
- canvas.width = 1;
- canvas.height = 1;
- isWebpSupportedCache = canvas.toDataURL('image/webp').startsWith('data:image/webp');
- }
- return isWebpSupportedCache;
-}
diff --git a/packages/frontend/src/scripts/use-chart-tooltip.ts b/packages/frontend/src/scripts/use-chart-tooltip.ts
deleted file mode 100644
index bba64fc6ee..0000000000
--- a/packages/frontend/src/scripts/use-chart-tooltip.ts
+++ /dev/null
@@ -1,63 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import { onUnmounted, onDeactivated, ref } from 'vue';
-import * as os from '@/os.js';
-import MkChartTooltip from '@/components/MkChartTooltip.vue';
-
-export function useChartTooltip(opts: { position: 'top' | 'middle' } = { position: 'top' }) {
- const tooltipShowing = ref(false);
- const tooltipX = ref(0);
- const tooltipY = ref(0);
- const tooltipTitle = ref(null);
- const tooltipSeries = ref<{
- backgroundColor: string;
- borderColor: string;
- text: string;
- }[] | null>(null);
- const { dispose: disposeTooltipComponent } = os.popup(MkChartTooltip, {
- showing: tooltipShowing,
- x: tooltipX,
- y: tooltipY,
- title: tooltipTitle,
- series: tooltipSeries,
- }, {});
-
- onUnmounted(() => {
- disposeTooltipComponent();
- });
-
- onDeactivated(() => {
- tooltipShowing.value = false;
- });
-
- function handler(context) {
- if (context.tooltip.opacity === 0) {
- tooltipShowing.value = false;
- return;
- }
-
- tooltipTitle.value = context.tooltip.title[0];
- tooltipSeries.value = context.tooltip.body.map((b, i) => ({
- backgroundColor: context.tooltip.labelColors[i].backgroundColor,
- borderColor: context.tooltip.labelColors[i].borderColor,
- text: b.lines[0],
- }));
-
- const rect = context.chart.canvas.getBoundingClientRect();
-
- tooltipShowing.value = true;
- tooltipX.value = rect.left + window.scrollX + context.tooltip.caretX;
- if (opts.position === 'top') {
- tooltipY.value = rect.top + window.scrollY;
- } else if (opts.position === 'middle') {
- tooltipY.value = rect.top + window.scrollY + context.tooltip.caretY;
- }
- }
-
- return {
- handler,
- };
-}
diff --git a/packages/frontend/src/scripts/use-form.ts b/packages/frontend/src/scripts/use-form.ts
deleted file mode 100644
index 26cca839c3..0000000000
--- a/packages/frontend/src/scripts/use-form.ts
+++ /dev/null
@@ -1,56 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import { computed, reactive, watch } from 'vue';
-import type { Reactive } from 'vue';
-
-function copy(v: T): T {
- return JSON.parse(JSON.stringify(v));
-}
-
-function unwrapReactive(v: Reactive): T {
- return JSON.parse(JSON.stringify(v));
-}
-
-export function useForm>(initialState: T, save: (newState: T) => Promise) {
- const currentState = reactive(copy(initialState));
- const previousState = reactive(copy(initialState));
-
- const modifiedStates = reactive>({} as any);
- for (const key in currentState) {
- modifiedStates[key] = false;
- }
- const modified = computed(() => Object.values(modifiedStates).some(v => v));
- const modifiedCount = computed(() => Object.values(modifiedStates).filter(v => v).length);
-
- watch([currentState, previousState], () => {
- for (const key in modifiedStates) {
- modifiedStates[key] = currentState[key] !== previousState[key];
- }
- }, { deep: true });
-
- async function _save() {
- await save(unwrapReactive(currentState));
- for (const key in currentState) {
- previousState[key] = copy(currentState[key]);
- }
- }
-
- function discard() {
- for (const key in currentState) {
- currentState[key] = copy(previousState[key]);
- }
- }
-
- return {
- state: currentState,
- savedState: previousState,
- modifiedStates,
- modified,
- modifiedCount,
- save: _save,
- discard,
- };
-}
diff --git a/packages/frontend/src/scripts/use-leave-guard.ts b/packages/frontend/src/scripts/use-leave-guard.ts
deleted file mode 100644
index 395c12a756..0000000000
--- a/packages/frontend/src/scripts/use-leave-guard.ts
+++ /dev/null
@@ -1,50 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import type { Ref } from 'vue';
-
-export function useLeaveGuard(enabled: Ref) {
- /* TODO
- const setLeaveGuard = inject('setLeaveGuard');
-
- if (setLeaveGuard) {
- setLeaveGuard(async () => {
- if (!enabled.value) return false;
-
- const { canceled } = await os.confirm({
- type: 'warning',
- text: i18n.ts.leaveConfirm,
- });
-
- return canceled;
- });
- } else {
- onBeforeRouteLeave(async (to, from) => {
- if (!enabled.value) return true;
-
- const { canceled } = await os.confirm({
- type: 'warning',
- text: i18n.ts.leaveConfirm,
- });
-
- return !canceled;
- });
- }
- */
-
- /*
- function onBeforeLeave(ev: BeforeUnloadEvent) {
- if (enabled.value) {
- ev.preventDefault();
- ev.returnValue = '';
- }
- }
-
- window.addEventListener('beforeunload', onBeforeLeave);
- onUnmounted(() => {
- window.removeEventListener('beforeunload', onBeforeLeave);
- });
- */
-}
diff --git a/packages/frontend/src/scripts/use-note-capture.ts b/packages/frontend/src/scripts/use-note-capture.ts
deleted file mode 100644
index 0bc10e90e4..0000000000
--- a/packages/frontend/src/scripts/use-note-capture.ts
+++ /dev/null
@@ -1,124 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import { onUnmounted } from 'vue';
-import type { Ref, ShallowRef } from 'vue';
-import * as Misskey from 'misskey-js';
-import { useStream } from '@/stream.js';
-import { $i } from '@/account.js';
-
-export function useNoteCapture(props: {
- rootEl: ShallowRef;
- note: Ref;
- pureNote: Ref;
- isDeletedRef: Ref;
-}) {
- const note = props.note;
- const pureNote = props.pureNote;
- const connection = $i ? useStream() : null;
-
- function onStreamNoteUpdated(noteData): void {
- const { type, id, body } = noteData;
-
- if ((id !== note.value.id) && (id !== pureNote.value.id)) return;
-
- switch (type) {
- case 'reacted': {
- const reaction = body.reaction;
-
- if (body.emoji && !(body.emoji.name in note.value.reactionEmojis)) {
- note.value.reactionEmojis[body.emoji.name] = body.emoji.url;
- }
-
- // TODO: reactionsプロパティがない場合ってあったっけ? なければ || {} は消せる
- const currentCount = (note.value.reactions || {})[reaction] || 0;
-
- note.value.reactions[reaction] = currentCount + 1;
- note.value.reactionCount += 1;
-
- if ($i && (body.userId === $i.id)) {
- note.value.myReaction = reaction;
- }
- break;
- }
-
- case 'unreacted': {
- const reaction = body.reaction;
-
- // TODO: reactionsプロパティがない場合ってあったっけ? なければ || {} は消せる
- const currentCount = (note.value.reactions || {})[reaction] || 0;
-
- note.value.reactions[reaction] = Math.max(0, currentCount - 1);
- note.value.reactionCount = Math.max(0, note.value.reactionCount - 1);
- if (note.value.reactions[reaction] === 0) delete note.value.reactions[reaction];
-
- if ($i && (body.userId === $i.id)) {
- note.value.myReaction = null;
- }
- break;
- }
-
- case 'pollVoted': {
- const choice = body.choice;
-
- const choices = [...note.value.poll.choices];
- choices[choice] = {
- ...choices[choice],
- votes: choices[choice].votes + 1,
- ...($i && (body.userId === $i.id) ? {
- isVoted: true,
- } : {}),
- };
-
- note.value.poll.choices = choices;
- break;
- }
-
- case 'deleted': {
- props.isDeletedRef.value = true;
- break;
- }
- }
- }
-
- function capture(withHandler = false): void {
- if (connection) {
- // TODO: このノートがストリーミング経由で流れてきた場合のみ sr する
- connection.send(document.body.contains(props.rootEl.value ?? null as Node | null) ? 'sr' : 's', { id: note.value.id });
- if (pureNote.value.id !== note.value.id) connection.send('s', { id: pureNote.value.id });
- if (withHandler) connection.on('noteUpdated', onStreamNoteUpdated);
- }
- }
-
- function decapture(withHandler = false): void {
- if (connection) {
- connection.send('un', {
- id: note.value.id,
- });
- if (pureNote.value.id !== note.value.id) {
- connection.send('un', {
- id: pureNote.value.id,
- });
- }
- if (withHandler) connection.off('noteUpdated', onStreamNoteUpdated);
- }
- }
-
- function onStreamConnected() {
- capture(false);
- }
-
- capture(true);
- if (connection) {
- connection.on('_connected_', onStreamConnected);
- }
-
- onUnmounted(() => {
- decapture(true);
- if (connection) {
- connection.off('_connected_', onStreamConnected);
- }
- });
-}
diff --git a/packages/frontend/src/scripts/use-tooltip.ts b/packages/frontend/src/scripts/use-tooltip.ts
deleted file mode 100644
index d9ddfc8b5d..0000000000
--- a/packages/frontend/src/scripts/use-tooltip.ts
+++ /dev/null
@@ -1,106 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import { ref, watch, onUnmounted } from 'vue';
-import type { Ref } from 'vue';
-
-export function useTooltip(
- elRef: Ref,
- onShow: (showing: Ref) => void,
- delay = 300,
-): void {
- let isHovering = false;
-
- // iOS(Androidも?)では、要素をタップした直後に(おせっかいで)mouseoverイベントを発火させたりするため、それを無視するためのフラグ
- // 無視しないと、画面に触れてないのにツールチップが出たりし、ユーザビリティが損なわれる
- // TODO: 一度でもタップすると二度とマウスでツールチップ出せなくなるのをどうにかする 定期的にfalseに戻すとか...?
- let shouldIgnoreMouseover = false;
-
- let timeoutId: number;
-
- let changeShowingState: (() => void) | null;
-
- let autoHidingTimer;
-
- const open = () => {
- close();
- if (!isHovering) return;
- if (elRef.value == null) return;
- const el = elRef.value instanceof Element ? elRef.value : elRef.value.$el;
- if (!document.body.contains(el)) return; // openしようとしたときに既に元要素がDOMから消えている場合があるため
-
- const showing = ref(true);
- onShow(showing);
- changeShowingState = () => {
- showing.value = false;
- };
-
- autoHidingTimer = window.setInterval(() => {
- if (elRef.value == null || !document.body.contains(elRef.value instanceof Element ? elRef.value : elRef.value.$el)) {
- if (!isHovering) return;
- isHovering = false;
- window.clearTimeout(timeoutId);
- close();
- window.clearInterval(autoHidingTimer);
- }
- }, 1000);
- };
-
- const close = () => {
- if (changeShowingState != null) {
- changeShowingState();
- changeShowingState = null;
- }
- };
-
- const onMouseover = () => {
- if (isHovering) return;
- if (shouldIgnoreMouseover) return;
- isHovering = true;
- timeoutId = window.setTimeout(open, delay);
- };
-
- const onMouseleave = () => {
- if (!isHovering) return;
- isHovering = false;
- window.clearTimeout(timeoutId);
- window.clearInterval(autoHidingTimer);
- close();
- };
-
- const onTouchstart = () => {
- shouldIgnoreMouseover = true;
- if (isHovering) return;
- isHovering = true;
- timeoutId = window.setTimeout(open, delay);
- };
-
- const onTouchend = () => {
- if (!isHovering) return;
- isHovering = false;
- window.clearTimeout(timeoutId);
- window.clearInterval(autoHidingTimer);
- close();
- };
-
- const stop = watch(elRef, () => {
- if (elRef.value) {
- stop();
- const el = elRef.value instanceof Element ? elRef.value : elRef.value.$el;
- el.addEventListener('mouseover', onMouseover, { passive: true });
- el.addEventListener('mouseleave', onMouseleave, { passive: true });
- el.addEventListener('touchstart', onTouchstart, { passive: true });
- el.addEventListener('touchend', onTouchend, { passive: true });
- el.addEventListener('click', close, { passive: true });
- }
- }, {
- immediate: true,
- flush: 'post',
- });
-
- onUnmounted(() => {
- close();
- });
-}
diff --git a/packages/frontend/src/store.ts b/packages/frontend/src/store.ts
index 6e4b4cd0c1..b6bf0e5fba 100644
--- a/packages/frontend/src/store.ts
+++ b/packages/frontend/src/store.ts
@@ -8,12 +8,12 @@ import * as Misskey from 'misskey-js';
import lightTheme from '@@/themes/l-light.json5';
import darkTheme from '@@/themes/d-green-lime.json5';
import { hemisphere } from '@@/js/intl-const.js';
-import type { DeviceKind } from '@/scripts/device-kind.js';
+import type { DeviceKind } from '@/utility/device-kind.js';
import type { Plugin } from '@/plugin.js';
import type { Column } from '@/deck.js';
import { miLocalStorage } from '@/local-storage.js';
import { Storage } from '@/pizzax.js';
-import { DEFAULT_DEVICE_KIND } from '@/scripts/device-kind.js';
+import { DEFAULT_DEVICE_KIND } from '@/utility/device-kind.js';
interface PostFormAction {
title: string,
diff --git a/packages/frontend/src/stream.ts b/packages/frontend/src/stream.ts
index e63dac951c..e194e96a7f 100644
--- a/packages/frontend/src/stream.ts
+++ b/packages/frontend/src/stream.ts
@@ -8,7 +8,7 @@ import { markRaw } from 'vue';
import { $i } from '@/account.js';
import { wsOrigin } from '@@/js/config.js';
// TODO: No WebsocketモードでStreamMockが使えそう
-//import { StreamMock } from '@/scripts/stream-mock.js';
+//import { StreamMock } from '@/utility/stream-mock.js';
// heart beat interval in ms
const HEART_BEAT_INTERVAL = 1000 * 60;
diff --git a/packages/frontend/src/theme-store.ts b/packages/frontend/src/theme-store.ts
index 09c665a2ab..93fbe395f9 100644
--- a/packages/frontend/src/theme-store.ts
+++ b/packages/frontend/src/theme-store.ts
@@ -3,8 +3,8 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
-import type { Theme } from '@/scripts/theme.js';
-import { getBuiltinThemes } from '@/scripts/theme.js';
+import type { Theme } from '@/utility/theme.js';
+import { getBuiltinThemes } from '@/utility/theme.js';
import { $i } from '@/account.js';
import { prefer } from '@/preferences.js';
diff --git a/packages/frontend/src/ui/_common_/common.vue b/packages/frontend/src/ui/_common_/common.vue
index 51645f9676..e218cd8c62 100644
--- a/packages/frontend/src/ui/_common_/common.vue
+++ b/packages/frontend/src/ui/_common_/common.vue
@@ -50,9 +50,9 @@ import * as Misskey from 'misskey-js';
import { swInject } from './sw-inject.js';
import XNotification from './notification.vue';
import { popups } from '@/os.js';
-import { pendingApiRequestsCount } from '@/scripts/misskey-api.js';
-import { uploads } from '@/scripts/upload.js';
-import * as sound from '@/scripts/sound.js';
+import { pendingApiRequestsCount } from '@/utility/misskey-api.js';
+import { uploads } from '@/utility/upload.js';
+import * as sound from '@/utility/sound.js';
import { $i } from '@/account.js';
import { useStream } from '@/stream.js';
import { i18n } from '@/i18n.js';
diff --git a/packages/frontend/src/ui/_common_/navbar.vue b/packages/frontend/src/ui/_common_/navbar.vue
index 1fb99f9f22..1797007f4a 100644
--- a/packages/frontend/src/ui/_common_/navbar.vue
+++ b/packages/frontend/src/ui/_common_/navbar.vue
@@ -97,7 +97,7 @@ import { $i, openAccountMenu as openAccountMenu_ } from '@/account.js';
import { store } from '@/store.js';
import { i18n } from '@/i18n.js';
import { instance } from '@/instance.js';
-import { getHTMLElementOrNull } from '@/scripts/get-dom-node-or-null.js';
+import { getHTMLElementOrNull } from '@/utility/get-dom-node-or-null.js';
import { useRouter } from '@/router/supplier.js';
import { prefer } from '@/preferences.js';
diff --git a/packages/frontend/src/ui/_common_/statusbar-federation.vue b/packages/frontend/src/ui/_common_/statusbar-federation.vue
index e234bb3a33..16e72fa227 100644
--- a/packages/frontend/src/ui/_common_/statusbar-federation.vue
+++ b/packages/frontend/src/ui/_common_/statusbar-federation.vue
@@ -34,9 +34,9 @@ SPDX-License-Identifier: AGPL-3.0-only
import { ref } from 'vue';
import * as Misskey from 'misskey-js';
import MarqueeText from '@/components/MkMarquee.vue';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
import { useInterval } from '@@/js/use-interval.js';
-import { getProxiedImageUrlNullable } from '@/scripts/media-proxy.js';
+import { getProxiedImageUrlNullable } from '@/utility/media-proxy.js';
const props = defineProps<{
display?: 'marquee' | 'oneByOne';
diff --git a/packages/frontend/src/ui/_common_/statusbar-rss.vue b/packages/frontend/src/ui/_common_/statusbar-rss.vue
index da8fa8bb21..4da89a181e 100644
--- a/packages/frontend/src/ui/_common_/statusbar-rss.vue
+++ b/packages/frontend/src/ui/_common_/statusbar-rss.vue
@@ -31,7 +31,7 @@ import { ref } from 'vue';
import * as Misskey from 'misskey-js';
import MarqueeText from '@/components/MkMarquee.vue';
import { useInterval } from '@@/js/use-interval.js';
-import { shuffle } from '@/scripts/shuffle.js';
+import { shuffle } from '@/utility/shuffle.js';
const props = defineProps<{
url?: string;
diff --git a/packages/frontend/src/ui/_common_/statusbar-user-list.vue b/packages/frontend/src/ui/_common_/statusbar-user-list.vue
index 078b595dca..c5bee51162 100644
--- a/packages/frontend/src/ui/_common_/statusbar-user-list.vue
+++ b/packages/frontend/src/ui/_common_/statusbar-user-list.vue
@@ -34,9 +34,9 @@ SPDX-License-Identifier: AGPL-3.0-only
import { ref, watch } from 'vue';
import * as Misskey from 'misskey-js';
import MarqueeText from '@/components/MkMarquee.vue';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
import { useInterval } from '@@/js/use-interval.js';
-import { getNoteSummary } from '@/scripts/get-note-summary.js';
+import { getNoteSummary } from '@/utility/get-note-summary.js';
import { notePage } from '@/filters/note.js';
const props = defineProps<{
diff --git a/packages/frontend/src/ui/_common_/sw-inject.ts b/packages/frontend/src/ui/_common_/sw-inject.ts
index ff851ad99f..df392c6532 100644
--- a/packages/frontend/src/ui/_common_/sw-inject.ts
+++ b/packages/frontend/src/ui/_common_/sw-inject.ts
@@ -4,10 +4,10 @@
*/
import { post } from '@/os.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
import { $i, login } from '@/account.js';
-import { getAccountFromId } from '@/scripts/get-account-from-id.js';
-import { deepClone } from '@/scripts/clone.js';
+import { getAccountFromId } from '@/utility/get-account-from-id.js';
+import { deepClone } from '@/utility/clone.js';
import { mainRouter } from '@/router/main.js';
export function swInject() {
diff --git a/packages/frontend/src/ui/_common_/upload.vue b/packages/frontend/src/ui/_common_/upload.vue
index c7d1387eae..3e5653e46d 100644
--- a/packages/frontend/src/ui/_common_/upload.vue
+++ b/packages/frontend/src/ui/_common_/upload.vue
@@ -25,7 +25,7 @@ SPDX-License-Identifier: AGPL-3.0-only
`,
+ ];
+ return iframeCode.join('\n');
+}
+
+/**
+ * 埋め込みコードを生成してコピーする(カスタマイズ機能つき)
+ *
+ * カスタマイズ機能がいらない場合(事前にパラメータを指定する場合)は getEmbedCode を直接使ってください
+ */
+export function genEmbedCode(entity: EmbeddableEntity, id: string, params?: EmbedParams) {
+ const _params = { ...params };
+
+ if (embedRouteWithScrollbar.includes(entity) && _params.maxHeight == null) {
+ _params.maxHeight = 700;
+ }
+
+ // PCじゃない場合はコードカスタマイズ画面を出さずにそのままコピー
+ if (window.innerWidth < MOBILE_THRESHOLD) {
+ copyToClipboard(getEmbedCode(`/embed/${entity}/${id}`, _params));
+ os.success();
+ } else {
+ const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkEmbedCodeGenDialog.vue')), {
+ entity,
+ id,
+ params: _params,
+ }, {
+ closed: () => dispose(),
+ });
+ }
+}
diff --git a/packages/frontend/src/utility/get-note-menu.ts b/packages/frontend/src/utility/get-note-menu.ts
new file mode 100644
index 0000000000..c95eaa20dd
--- /dev/null
+++ b/packages/frontend/src/utility/get-note-menu.ts
@@ -0,0 +1,685 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { defineAsyncComponent } from 'vue';
+import type { Ref, ShallowRef } from 'vue';
+import * as Misskey from 'misskey-js';
+import { url } from '@@/js/config.js';
+import { claimAchievement } from './achievements.js';
+import type { MenuItem } from '@/types/menu.js';
+import { $i } from '@/account.js';
+import { i18n } from '@/i18n.js';
+import { instance } from '@/instance.js';
+import * as os from '@/os.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
+import { copyToClipboard } from '@/utility/copy-to-clipboard.js';
+import { store, noteActions } from '@/store.js';
+import { miLocalStorage } from '@/local-storage.js';
+import { getUserMenu } from '@/utility/get-user-menu.js';
+import { clipsCache, favoritedChannelsCache } from '@/cache.js';
+import MkRippleEffect from '@/components/MkRippleEffect.vue';
+import { isSupportShare } from '@/utility/navigator.js';
+import { getAppearNote } from '@/utility/get-appear-note.js';
+import { genEmbedCode } from '@/utility/get-embed-code.js';
+import { prefer } from '@/preferences.js';
+
+export async function getNoteClipMenu(props: {
+ note: Misskey.entities.Note;
+ isDeleted: Ref;
+ currentClip?: Misskey.entities.Clip;
+}) {
+ function getClipName(clip: Misskey.entities.Clip) {
+ if ($i && clip.userId === $i.id && clip.notesCount != null) {
+ return `${clip.name} (${clip.notesCount}/${$i.policies.noteEachClipsLimit})`;
+ } else {
+ return clip.name;
+ }
+ }
+
+ const appearNote = getAppearNote(props.note);
+
+ const clips = await clipsCache.fetch();
+ const menu: MenuItem[] = [...clips.map(clip => ({
+ text: getClipName(clip),
+ action: () => {
+ claimAchievement('noteClipped1');
+ os.promiseDialog(
+ misskeyApi('clips/add-note', { clipId: clip.id, noteId: appearNote.id }),
+ null,
+ async (err) => {
+ if (err.id === '734806c4-542c-463a-9311-15c512803965') {
+ const confirm = await os.confirm({
+ type: 'warning',
+ text: i18n.tsx.confirmToUnclipAlreadyClippedNote({ name: clip.name }),
+ });
+ if (!confirm.canceled) {
+ os.apiWithDialog('clips/remove-note', { clipId: clip.id, noteId: appearNote.id }).then(() => {
+ clipsCache.set(clips.map(c => {
+ if (c.id === clip.id) {
+ return {
+ ...c,
+ notesCount: Math.max(0, ((c.notesCount ?? 0) - 1)),
+ };
+ } else {
+ return c;
+ }
+ }));
+ });
+ if (props.currentClip?.id === clip.id) props.isDeleted.value = true;
+ }
+ } else if (err.id === 'f0dba960-ff73-4615-8df4-d6ac5d9dc118') {
+ os.alert({
+ type: 'error',
+ text: i18n.ts.clipNoteLimitExceeded,
+ });
+ } else {
+ os.alert({
+ type: 'error',
+ text: err.message + '\n' + err.id,
+ });
+ }
+ },
+ ).then(() => {
+ clipsCache.set(clips.map(c => {
+ if (c.id === clip.id) {
+ return {
+ ...c,
+ notesCount: (c.notesCount ?? 0) + 1,
+ };
+ } else {
+ return c;
+ }
+ }));
+ });
+ },
+ })), { type: 'divider' }, {
+ icon: 'ti ti-plus',
+ text: i18n.ts.createNew,
+ action: async () => {
+ const { canceled, result } = await os.form(i18n.ts.createNewClip, {
+ name: {
+ type: 'string',
+ default: null,
+ label: i18n.ts.name,
+ },
+ description: {
+ type: 'string',
+ required: false,
+ default: null,
+ multiline: true,
+ label: i18n.ts.description,
+ },
+ isPublic: {
+ type: 'boolean',
+ label: i18n.ts.public,
+ default: false,
+ },
+ });
+ if (canceled) return;
+
+ const clip = await os.apiWithDialog('clips/create', result);
+
+ clipsCache.delete();
+
+ claimAchievement('noteClipped1');
+ os.apiWithDialog('clips/add-note', { clipId: clip.id, noteId: appearNote.id });
+ },
+ }];
+
+ return menu;
+}
+
+export function getAbuseNoteMenu(note: Misskey.entities.Note, text: string): MenuItem {
+ return {
+ icon: 'ti ti-exclamation-circle',
+ text,
+ action: (): void => {
+ const localUrl = `${url}/notes/${note.id}`;
+ let noteInfo = '';
+ if (note.url ?? note.uri != null) noteInfo = `Note: ${note.url ?? note.uri}\n`;
+ noteInfo += `Local Note: ${localUrl}\n`;
+ const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkAbuseReportWindow.vue')), {
+ user: note.user,
+ initialComment: `${noteInfo}-----\n`,
+ }, {
+ closed: () => dispose(),
+ });
+ },
+ };
+}
+
+export function getCopyNoteLinkMenu(note: Misskey.entities.Note, text: string): MenuItem {
+ return {
+ icon: 'ti ti-link',
+ text,
+ action: (): void => {
+ copyToClipboard(`${url}/notes/${note.id}`);
+ os.success();
+ },
+ };
+}
+
+function getNoteEmbedCodeMenu(note: Misskey.entities.Note, text: string): MenuItem | undefined {
+ if (note.url != null || note.uri != null) return undefined;
+ if (['specified', 'followers'].includes(note.visibility)) return undefined;
+
+ return {
+ icon: 'ti ti-code',
+ text,
+ action: (): void => {
+ genEmbedCode('notes', note.id);
+ },
+ };
+}
+
+export function getNoteMenu(props: {
+ note: Misskey.entities.Note;
+ translation: Ref;
+ translating: Ref;
+ isDeleted: Ref;
+ currentClip?: Misskey.entities.Clip;
+}) {
+ const appearNote = getAppearNote(props.note);
+
+ const cleanups = [] as (() => void)[];
+
+ function del(): void {
+ os.confirm({
+ type: 'warning',
+ text: i18n.ts.noteDeleteConfirm,
+ }).then(({ canceled }) => {
+ if (canceled) return;
+
+ misskeyApi('notes/delete', {
+ noteId: appearNote.id,
+ });
+
+ if (Date.now() - new Date(appearNote.createdAt).getTime() < 1000 * 60 && appearNote.userId === $i.id) {
+ claimAchievement('noteDeletedWithin1min');
+ }
+ });
+ }
+
+ function delEdit(): void {
+ os.confirm({
+ type: 'warning',
+ text: i18n.ts.deleteAndEditConfirm,
+ }).then(({ canceled }) => {
+ if (canceled) return;
+
+ misskeyApi('notes/delete', {
+ noteId: appearNote.id,
+ });
+
+ os.post({ initialNote: appearNote, renote: appearNote.renote, reply: appearNote.reply, channel: appearNote.channel });
+
+ if (Date.now() - new Date(appearNote.createdAt).getTime() < 1000 * 60 && appearNote.userId === $i.id) {
+ claimAchievement('noteDeletedWithin1min');
+ }
+ });
+ }
+
+ function toggleFavorite(favorite: boolean): void {
+ claimAchievement('noteFavorited1');
+ os.apiWithDialog(favorite ? 'notes/favorites/create' : 'notes/favorites/delete', {
+ noteId: appearNote.id,
+ });
+ }
+
+ function toggleThreadMute(mute: boolean): void {
+ os.apiWithDialog(mute ? 'notes/thread-muting/create' : 'notes/thread-muting/delete', {
+ noteId: appearNote.id,
+ });
+ }
+
+ function copyContent(): void {
+ copyToClipboard(appearNote.text);
+ os.success();
+ }
+
+ function togglePin(pin: boolean): void {
+ os.apiWithDialog(pin ? 'i/pin' : 'i/unpin', {
+ noteId: appearNote.id,
+ }, undefined, {
+ '72dab508-c64d-498f-8740-a8eec1ba385a': {
+ text: i18n.ts.pinLimitExceeded,
+ },
+ });
+ }
+
+ async function unclip(): Promise {
+ if (!props.currentClip) return;
+ os.apiWithDialog('clips/remove-note', { clipId: props.currentClip.id, noteId: appearNote.id });
+ props.isDeleted.value = true;
+ }
+
+ async function promote(): Promise {
+ const { canceled, result: days } = await os.inputNumber({
+ title: i18n.ts.numberOfDays,
+ });
+
+ if (canceled || days == null) return;
+
+ os.apiWithDialog('admin/promo/create', {
+ noteId: appearNote.id,
+ expiresAt: Date.now() + (86400000 * days),
+ });
+ }
+
+ function share(): void {
+ navigator.share({
+ title: i18n.tsx.noteOf({ user: appearNote.user.name ?? appearNote.user.username }),
+ text: appearNote.text ?? '',
+ url: `${url}/notes/${appearNote.id}`,
+ });
+ }
+
+ function openDetail(): void {
+ os.pageWindow(`/notes/${appearNote.id}`);
+ }
+
+ async function translate(): Promise {
+ if (props.translation.value != null) return;
+ props.translating.value = true;
+ const res = await misskeyApi('notes/translate', {
+ noteId: appearNote.id,
+ targetLang: miLocalStorage.getItem('lang') ?? navigator.language,
+ });
+ props.translating.value = false;
+ props.translation.value = res;
+ }
+
+ const menuItems: MenuItem[] = [];
+
+ if ($i) {
+ const statePromise = misskeyApi('notes/state', {
+ noteId: appearNote.id,
+ });
+
+ if (props.currentClip?.userId === $i.id) {
+ menuItems.push({
+ icon: 'ti ti-backspace',
+ text: i18n.ts.unclip,
+ danger: true,
+ action: unclip,
+ }, { type: 'divider' });
+ }
+
+ menuItems.push({
+ icon: 'ti ti-info-circle',
+ text: i18n.ts.details,
+ action: openDetail,
+ }, {
+ icon: 'ti ti-copy',
+ text: i18n.ts.copyContent,
+ action: copyContent,
+ }, getCopyNoteLinkMenu(appearNote, i18n.ts.copyLink));
+
+ if (appearNote.url || appearNote.uri) {
+ menuItems.push({
+ icon: 'ti ti-link',
+ text: i18n.ts.copyRemoteLink,
+ action: () => {
+ copyToClipboard(appearNote.url ?? appearNote.uri);
+ os.success();
+ },
+ }, {
+ icon: 'ti ti-external-link',
+ text: i18n.ts.showOnRemote,
+ action: () => {
+ window.open(appearNote.url ?? appearNote.uri, '_blank', 'noopener');
+ },
+ });
+ } else {
+ menuItems.push(getNoteEmbedCodeMenu(appearNote, i18n.ts.genEmbedCode));
+ }
+
+ if (isSupportShare()) {
+ menuItems.push({
+ icon: 'ti ti-share',
+ text: i18n.ts.share,
+ action: share,
+ });
+ }
+
+ if ($i.policies.canUseTranslator && instance.translatorAvailable) {
+ menuItems.push({
+ icon: 'ti ti-language-hiragana',
+ text: i18n.ts.translate,
+ action: translate,
+ });
+ }
+
+ menuItems.push({ type: 'divider' });
+
+ menuItems.push(statePromise.then(state => state.isFavorited ? {
+ icon: 'ti ti-star-off',
+ text: i18n.ts.unfavorite,
+ action: () => toggleFavorite(false),
+ } : {
+ icon: 'ti ti-star',
+ text: i18n.ts.favorite,
+ action: () => toggleFavorite(true),
+ }));
+
+ menuItems.push({
+ type: 'parent',
+ icon: 'ti ti-paperclip',
+ text: i18n.ts.clip,
+ children: () => getNoteClipMenu(props),
+ });
+
+ menuItems.push(statePromise.then(state => state.isMutedThread ? {
+ icon: 'ti ti-message-off',
+ text: i18n.ts.unmuteThread,
+ action: () => toggleThreadMute(false),
+ } : {
+ icon: 'ti ti-message-off',
+ text: i18n.ts.muteThread,
+ action: () => toggleThreadMute(true),
+ }));
+
+ if (appearNote.userId === $i.id) {
+ if (($i.pinnedNoteIds ?? []).includes(appearNote.id)) {
+ menuItems.push({
+ icon: 'ti ti-pinned-off',
+ text: i18n.ts.unpin,
+ action: () => togglePin(false),
+ });
+ } else {
+ menuItems.push({
+ icon: 'ti ti-pin',
+ text: i18n.ts.pin,
+ action: () => togglePin(true),
+ });
+ }
+ }
+
+ menuItems.push({
+ type: 'parent',
+ icon: 'ti ti-user',
+ text: i18n.ts.user,
+ children: async () => {
+ const user = appearNote.userId === $i?.id ? $i : await misskeyApi('users/show', { userId: appearNote.userId });
+ const { menu, cleanup } = getUserMenu(user);
+ cleanups.push(cleanup);
+ return menu;
+ },
+ });
+
+ if (appearNote.userId !== $i.id) {
+ menuItems.push({ type: 'divider' });
+ menuItems.push(getAbuseNoteMenu(appearNote, i18n.ts.reportAbuse));
+ }
+
+ if (appearNote.channel && (appearNote.channel.userId === $i.id || $i.isModerator || $i.isAdmin)) {
+ menuItems.push({ type: 'divider' });
+ menuItems.push({
+ type: 'parent',
+ icon: 'ti ti-device-tv',
+ text: i18n.ts.channel,
+ children: async () => {
+ const channelChildMenu = [] as MenuItem[];
+
+ const channel = await misskeyApi('channels/show', { channelId: appearNote.channel!.id });
+
+ if (channel.pinnedNoteIds.includes(appearNote.id)) {
+ channelChildMenu.push({
+ icon: 'ti ti-pinned-off',
+ text: i18n.ts.unpin,
+ action: () => os.apiWithDialog('channels/update', {
+ channelId: appearNote.channel!.id,
+ pinnedNoteIds: channel.pinnedNoteIds.filter(id => id !== appearNote.id),
+ }),
+ });
+ } else {
+ channelChildMenu.push({
+ icon: 'ti ti-pin',
+ text: i18n.ts.pin,
+ action: () => os.apiWithDialog('channels/update', {
+ channelId: appearNote.channel!.id,
+ pinnedNoteIds: [...channel.pinnedNoteIds, appearNote.id],
+ }),
+ });
+ }
+ return channelChildMenu;
+ },
+ });
+ }
+
+ if (appearNote.userId === $i.id || $i.isModerator || $i.isAdmin) {
+ menuItems.push({ type: 'divider' });
+ if (appearNote.userId === $i.id) {
+ menuItems.push({
+ icon: 'ti ti-edit',
+ text: i18n.ts.deleteAndEdit,
+ action: delEdit,
+ });
+ }
+ menuItems.push({
+ icon: 'ti ti-trash',
+ text: i18n.ts.delete,
+ danger: true,
+ action: del,
+ });
+ }
+ } else {
+ menuItems.push({
+ icon: 'ti ti-info-circle',
+ text: i18n.ts.details,
+ action: openDetail,
+ }, {
+ icon: 'ti ti-copy',
+ text: i18n.ts.copyContent,
+ action: copyContent,
+ }, getCopyNoteLinkMenu(appearNote, i18n.ts.copyLink));
+
+ if (appearNote.url || appearNote.uri) {
+ menuItems.push({
+ icon: 'ti ti-link',
+ text: i18n.ts.copyRemoteLink,
+ action: () => {
+ copyToClipboard(appearNote.url ?? appearNote.uri);
+ os.success();
+ },
+ }, {
+ icon: 'ti ti-external-link',
+ text: i18n.ts.showOnRemote,
+ action: () => {
+ window.open(appearNote.url ?? appearNote.uri, '_blank', 'noopener');
+ },
+ });
+ } else {
+ menuItems.push(getNoteEmbedCodeMenu(appearNote, i18n.ts.genEmbedCode));
+ }
+ }
+
+ if (noteActions.length > 0) {
+ menuItems.push({ type: 'divider' });
+
+ menuItems.push(...noteActions.map(action => ({
+ icon: 'ti ti-plug',
+ text: action.title,
+ action: () => {
+ action.handler(appearNote);
+ },
+ })));
+ }
+
+ if (prefer.s.devMode) {
+ menuItems.push({ type: 'divider' }, {
+ icon: 'ti ti-id',
+ text: i18n.ts.copyNoteId,
+ action: () => {
+ copyToClipboard(appearNote.id);
+ os.success();
+ },
+ });
+ }
+
+ const cleanup = () => {
+ if (_DEV_) console.log('note menu cleanup', cleanups);
+ for (const cl of cleanups) {
+ cl();
+ }
+ };
+
+ return {
+ menu: menuItems,
+ cleanup,
+ };
+}
+
+type Visibility = (typeof Misskey.noteVisibilities)[number];
+
+function smallerVisibility(a: Visibility, b: Visibility): Visibility {
+ if (a === 'specified' || b === 'specified') return 'specified';
+ if (a === 'followers' || b === 'followers') return 'followers';
+ if (a === 'home' || b === 'home') return 'home';
+ // if (a === 'public' || b === 'public')
+ return 'public';
+}
+
+export function getRenoteMenu(props: {
+ note: Misskey.entities.Note;
+ renoteButton: ShallowRef;
+ mock?: boolean;
+}) {
+ const appearNote = getAppearNote(props.note);
+
+ const channelRenoteItems: MenuItem[] = [];
+ const normalRenoteItems: MenuItem[] = [];
+ const normalExternalChannelRenoteItems: MenuItem[] = [];
+
+ if (appearNote.channel) {
+ channelRenoteItems.push(...[{
+ text: i18n.ts.inChannelRenote,
+ icon: 'ti ti-repeat',
+ action: () => {
+ const el = props.renoteButton.value;
+ if (el && prefer.s.animation) {
+ const rect = el.getBoundingClientRect();
+ const x = rect.left + (el.offsetWidth / 2);
+ const y = rect.top + (el.offsetHeight / 2);
+ const { dispose } = os.popup(MkRippleEffect, { x, y }, {
+ end: () => dispose(),
+ });
+ }
+
+ if (!props.mock) {
+ misskeyApi('notes/create', {
+ renoteId: appearNote.id,
+ channelId: appearNote.channelId,
+ }).then(() => {
+ os.toast(i18n.ts.renoted);
+ });
+ }
+ },
+ }, {
+ text: i18n.ts.inChannelQuote,
+ icon: 'ti ti-quote',
+ action: () => {
+ if (!props.mock) {
+ os.post({
+ renote: appearNote,
+ channel: appearNote.channel,
+ });
+ }
+ },
+ }]);
+ }
+
+ if (!appearNote.channel || appearNote.channel.allowRenoteToExternal) {
+ normalRenoteItems.push(...[{
+ text: i18n.ts.renote,
+ icon: 'ti ti-repeat',
+ action: () => {
+ const el = props.renoteButton.value;
+ if (el && prefer.s.animation) {
+ const rect = el.getBoundingClientRect();
+ const x = rect.left + (el.offsetWidth / 2);
+ const y = rect.top + (el.offsetHeight / 2);
+ const { dispose } = os.popup(MkRippleEffect, { x, y }, {
+ end: () => dispose(),
+ });
+ }
+
+ const configuredVisibility = prefer.s.rememberNoteVisibility ? store.state.visibility : prefer.s.defaultNoteVisibility;
+ const localOnly = prefer.s.rememberNoteVisibility ? store.state.localOnly : prefer.s.defaultNoteLocalOnly;
+
+ let visibility = appearNote.visibility;
+ visibility = smallerVisibility(visibility, configuredVisibility);
+ if (appearNote.channel?.isSensitive) {
+ visibility = smallerVisibility(visibility, 'home');
+ }
+
+ if (!props.mock) {
+ misskeyApi('notes/create', {
+ localOnly,
+ visibility,
+ renoteId: appearNote.id,
+ }).then(() => {
+ os.toast(i18n.ts.renoted);
+ });
+ }
+ },
+ }, (props.mock) ? undefined : {
+ text: i18n.ts.quote,
+ icon: 'ti ti-quote',
+ action: () => {
+ os.post({
+ renote: appearNote,
+ });
+ },
+ }]);
+
+ normalExternalChannelRenoteItems.push({
+ type: 'parent',
+ icon: 'ti ti-repeat',
+ text: appearNote.channel ? i18n.ts.renoteToOtherChannel : i18n.ts.renoteToChannel,
+ children: async () => {
+ const channels = await favoritedChannelsCache.fetch();
+ return channels.filter((channel) => {
+ if (!appearNote.channelId) return true;
+ return channel.id !== appearNote.channelId;
+ }).map((channel) => ({
+ text: channel.name,
+ action: () => {
+ const el = props.renoteButton.value;
+ if (el && prefer.s.animation) {
+ const rect = el.getBoundingClientRect();
+ const x = rect.left + (el.offsetWidth / 2);
+ const y = rect.top + (el.offsetHeight / 2);
+ const { dispose } = os.popup(MkRippleEffect, { x, y }, {
+ end: () => dispose(),
+ });
+ }
+
+ if (!props.mock) {
+ misskeyApi('notes/create', {
+ renoteId: appearNote.id,
+ channelId: channel.id,
+ }).then(() => {
+ os.toast(i18n.tsx.renotedToX({ name: channel.name }));
+ });
+ }
+ },
+ }));
+ },
+ });
+ }
+
+ const renoteItems = [
+ ...normalRenoteItems,
+ ...(channelRenoteItems.length > 0 && normalRenoteItems.length > 0) ? [{ type: 'divider' }] as MenuItem[] : [],
+ ...channelRenoteItems,
+ ...(normalExternalChannelRenoteItems.length > 0 && (normalRenoteItems.length > 0 || channelRenoteItems.length > 0)) ? [{ type: 'divider' }] as MenuItem[] : [],
+ ...normalExternalChannelRenoteItems,
+ ];
+
+ return {
+ menu: renoteItems,
+ };
+}
diff --git a/packages/frontend/src/utility/get-note-summary.ts b/packages/frontend/src/utility/get-note-summary.ts
new file mode 100644
index 0000000000..6fd9947ac1
--- /dev/null
+++ b/packages/frontend/src/utility/get-note-summary.ts
@@ -0,0 +1,64 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import * as Misskey from 'misskey-js';
+import { i18n } from '@/i18n.js';
+
+/**
+ * 投稿を表す文字列を取得します。
+ * @param {*} note (packされた)投稿
+ */
+export const getNoteSummary = (note?: Misskey.entities.Note | null): string => {
+ if (note == null) {
+ return '';
+ }
+
+ if (note.deletedAt) {
+ return `(${i18n.ts.deletedNote})`;
+ }
+
+ if (note.isHidden) {
+ return `(${i18n.ts.invisibleNote})`;
+ }
+
+ let summary = '';
+
+ // 本文
+ if (note.cw != null) {
+ summary += note.cw;
+ } else {
+ summary += note.text ? note.text : '';
+ }
+
+ // ファイルが添付されているとき
+ if ((note.files || []).length !== 0) {
+ summary += ` (${i18n.tsx.withNFiles({ n: note.files.length })})`;
+ }
+
+ // 投票が添付されているとき
+ if (note.poll) {
+ summary += ` (${i18n.ts.poll})`;
+ }
+
+ // 返信のとき
+ if (note.replyId) {
+ if (note.reply) {
+ summary += `\n\nRE: ${getNoteSummary(note.reply)}`;
+ } else {
+ summary += '\n\nRE: ...';
+ }
+ }
+
+ // Renoteのとき
+ if (note.renoteId) {
+ if (note.renote) {
+ summary += `\n\nRN: ${getNoteSummary(note.renote)}`;
+ } else {
+ summary += '\n\nRN: ...';
+ }
+ }
+
+ return summary.trim();
+};
diff --git a/packages/frontend/src/utility/get-user-menu.ts b/packages/frontend/src/utility/get-user-menu.ts
new file mode 100644
index 0000000000..d739976cb1
--- /dev/null
+++ b/packages/frontend/src/utility/get-user-menu.ts
@@ -0,0 +1,441 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { toUnicode } from 'punycode.js';
+import { defineAsyncComponent, ref, watch } from 'vue';
+import * as Misskey from 'misskey-js';
+import { host, url } from '@@/js/config.js';
+import type { IRouter } from '@/nirax.js';
+import type { MenuItem } from '@/types/menu.js';
+import { i18n } from '@/i18n.js';
+import { copyToClipboard } from '@/utility/copy-to-clipboard.js';
+import * as os from '@/os.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
+import { userActions } from '@/store.js';
+import { $i, iAmModerator } from '@/account.js';
+import { notesSearchAvailable, canSearchNonLocalNotes } from '@/utility/check-permissions.js';
+import { antennasCache, rolesCache, userListsCache } from '@/cache.js';
+import { mainRouter } from '@/router/main.js';
+import { genEmbedCode } from '@/utility/get-embed-code.js';
+import { prefer } from '@/preferences.js';
+
+export function getUserMenu(user: Misskey.entities.UserDetailed, router: IRouter = mainRouter) {
+ const meId = $i ? $i.id : null;
+
+ const cleanups = [] as (() => void)[];
+
+ async function toggleMute() {
+ if (user.isMuted) {
+ os.apiWithDialog('mute/delete', {
+ userId: user.id,
+ }).then(() => {
+ user.isMuted = false;
+ });
+ } else {
+ const { canceled, result: period } = await os.select({
+ title: i18n.ts.mutePeriod,
+ items: [{
+ value: 'indefinitely', text: i18n.ts.indefinitely,
+ }, {
+ value: 'tenMinutes', text: i18n.ts.tenMinutes,
+ }, {
+ value: 'oneHour', text: i18n.ts.oneHour,
+ }, {
+ value: 'oneDay', text: i18n.ts.oneDay,
+ }, {
+ value: 'oneWeek', text: i18n.ts.oneWeek,
+ }],
+ default: 'indefinitely',
+ });
+ if (canceled) return;
+
+ const expiresAt = period === 'indefinitely' ? null
+ : period === 'tenMinutes' ? Date.now() + (1000 * 60 * 10)
+ : period === 'oneHour' ? Date.now() + (1000 * 60 * 60)
+ : period === 'oneDay' ? Date.now() + (1000 * 60 * 60 * 24)
+ : period === 'oneWeek' ? Date.now() + (1000 * 60 * 60 * 24 * 7)
+ : null;
+
+ os.apiWithDialog('mute/create', {
+ userId: user.id,
+ expiresAt,
+ }).then(() => {
+ user.isMuted = true;
+ });
+ }
+ }
+
+ async function toggleRenoteMute() {
+ os.apiWithDialog(user.isRenoteMuted ? 'renote-mute/delete' : 'renote-mute/create', {
+ userId: user.id,
+ }).then(() => {
+ user.isRenoteMuted = !user.isRenoteMuted;
+ });
+ }
+
+ async function toggleBlock() {
+ if (!await getConfirmed(user.isBlocking ? i18n.ts.unblockConfirm : i18n.ts.blockConfirm)) return;
+
+ os.apiWithDialog(user.isBlocking ? 'blocking/delete' : 'blocking/create', {
+ userId: user.id,
+ }).then(() => {
+ user.isBlocking = !user.isBlocking;
+ });
+ }
+
+ async function toggleNotify() {
+ os.apiWithDialog('following/update', {
+ userId: user.id,
+ notify: user.notify === 'normal' ? 'none' : 'normal',
+ }).then(() => {
+ user.notify = user.notify === 'normal' ? 'none' : 'normal';
+ });
+ }
+
+ function reportAbuse() {
+ const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkAbuseReportWindow.vue')), {
+ user: user,
+ }, {
+ closed: () => dispose(),
+ });
+ }
+
+ async function getConfirmed(text: string): Promise {
+ const confirm = await os.confirm({
+ type: 'warning',
+ title: 'confirm',
+ text,
+ });
+
+ return !confirm.canceled;
+ }
+
+ async function userInfoUpdate() {
+ os.apiWithDialog('federation/update-remote-user', {
+ userId: user.id,
+ });
+ }
+
+ async function invalidateFollow() {
+ if (!await getConfirmed(i18n.ts.breakFollowConfirm)) return;
+
+ os.apiWithDialog('following/invalidate', {
+ userId: user.id,
+ }).then(() => {
+ user.isFollowed = !user.isFollowed;
+ });
+ }
+
+ async function editMemo(): Promise {
+ const userDetailed = await misskeyApi('users/show', {
+ userId: user.id,
+ });
+ const { canceled, result } = await os.form(i18n.ts.editMemo, {
+ memo: {
+ type: 'string',
+ required: true,
+ multiline: true,
+ label: i18n.ts.memo,
+ default: userDetailed.memo,
+ },
+ });
+ if (canceled) return;
+
+ os.apiWithDialog('users/update-memo', {
+ memo: result.memo,
+ userId: user.id,
+ });
+ }
+
+ const menuItems: MenuItem[] = [];
+
+ menuItems.push({
+ icon: 'ti ti-at',
+ text: i18n.ts.copyUsername,
+ action: () => {
+ copyToClipboard(`@${user.username}@${user.host ?? host}`);
+ },
+ });
+
+ if (notesSearchAvailable && (user.host == null || canSearchNonLocalNotes)) {
+ menuItems.push({
+ icon: 'ti ti-search',
+ text: i18n.ts.searchThisUsersNotes,
+ action: () => {
+ router.push(`/search?username=${encodeURIComponent(user.username)}${user.host != null ? '&host=' + encodeURIComponent(user.host) : ''}`);
+ },
+ });
+ }
+
+ if (iAmModerator) {
+ menuItems.push({
+ icon: 'ti ti-user-exclamation',
+ text: i18n.ts.moderation,
+ action: () => {
+ router.push(`/admin/user/${user.id}`);
+ },
+ });
+ }
+
+ menuItems.push({
+ icon: 'ti ti-rss',
+ text: i18n.ts.copyRSS,
+ action: () => {
+ copyToClipboard(`${user.host ?? host}/@${user.username}.atom`);
+ },
+ });
+
+ if (user.host != null && user.url != null) {
+ menuItems.push({
+ icon: 'ti ti-external-link',
+ text: i18n.ts.showOnRemote,
+ action: () => {
+ if (user.url == null) return;
+ window.open(user.url, '_blank', 'noopener');
+ },
+ });
+ } else {
+ menuItems.push({
+ icon: 'ti ti-code',
+ text: i18n.ts.genEmbedCode,
+ type: 'parent',
+ children: [{
+ text: i18n.ts.noteOfThisUser,
+ action: () => {
+ genEmbedCode('user-timeline', user.id);
+ },
+ }], // TODO: ユーザーカードの埋め込みなど
+ });
+ }
+
+ menuItems.push({
+ icon: 'ti ti-share',
+ text: i18n.ts.copyProfileUrl,
+ action: () => {
+ const canonical = user.host === null ? `@${user.username}` : `@${user.username}@${toUnicode(user.host)}`;
+ copyToClipboard(`${url}/${canonical}`);
+ },
+ });
+
+ if ($i) {
+ menuItems.push({
+ icon: 'ti ti-mail',
+ text: i18n.ts.sendMessage,
+ action: () => {
+ const canonical = user.host === null ? `@${user.username}` : `@${user.username}@${user.host}`;
+ os.post({ specified: user, initialText: `${canonical} ` });
+ },
+ }, { type: 'divider' }, {
+ icon: 'ti ti-pencil',
+ text: i18n.ts.editMemo,
+ action: editMemo,
+ }, {
+ type: 'parent',
+ icon: 'ti ti-list',
+ text: i18n.ts.addToList,
+ children: async () => {
+ const lists = await userListsCache.fetch();
+ return lists.map(list => {
+ const isListed = ref(list.userIds?.includes(user.id) ?? false);
+ cleanups.push(watch(isListed, () => {
+ if (isListed.value) {
+ os.apiWithDialog('users/lists/push', {
+ listId: list.id,
+ userId: user.id,
+ }).then(() => {
+ list.userIds?.push(user.id);
+ });
+ } else {
+ os.apiWithDialog('users/lists/pull', {
+ listId: list.id,
+ userId: user.id,
+ }).then(() => {
+ list.userIds?.splice(list.userIds.indexOf(user.id), 1);
+ });
+ }
+ }));
+
+ return {
+ type: 'switch',
+ text: list.name,
+ ref: isListed,
+ };
+ });
+ },
+ }, {
+ type: 'parent',
+ icon: 'ti ti-antenna',
+ text: i18n.ts.addToAntenna,
+ children: async () => {
+ const antennas = await antennasCache.fetch();
+ const canonical = user.host === null ? `@${user.username}` : `@${user.username}@${toUnicode(user.host)}`;
+ return antennas.filter((a) => a.src === 'users').map(antenna => ({
+ text: antenna.name,
+ action: async () => {
+ await os.apiWithDialog('antennas/update', {
+ antennaId: antenna.id,
+ name: antenna.name,
+ keywords: antenna.keywords,
+ excludeKeywords: antenna.excludeKeywords,
+ src: antenna.src,
+ userListId: antenna.userListId,
+ users: [...antenna.users, canonical],
+ caseSensitive: antenna.caseSensitive,
+ withReplies: antenna.withReplies,
+ withFile: antenna.withFile,
+ notify: antenna.notify,
+ });
+ antennasCache.delete();
+ },
+ }));
+ },
+ });
+ }
+
+ if ($i && meId !== user.id) {
+ if (iAmModerator) {
+ menuItems.push({
+ type: 'parent',
+ icon: 'ti ti-badges',
+ text: i18n.ts.roles,
+ children: async () => {
+ const roles = await rolesCache.fetch();
+
+ return roles.filter(r => r.target === 'manual').map(r => ({
+ text: r.name,
+ action: async () => {
+ const { canceled, result: period } = await os.select({
+ title: i18n.ts.period + ': ' + r.name,
+ items: [{
+ value: 'indefinitely', text: i18n.ts.indefinitely,
+ }, {
+ value: 'oneHour', text: i18n.ts.oneHour,
+ }, {
+ value: 'oneDay', text: i18n.ts.oneDay,
+ }, {
+ value: 'oneWeek', text: i18n.ts.oneWeek,
+ }, {
+ value: 'oneMonth', text: i18n.ts.oneMonth,
+ }],
+ default: 'indefinitely',
+ });
+ if (canceled) return;
+
+ const expiresAt = period === 'indefinitely' ? null
+ : period === 'oneHour' ? Date.now() + (1000 * 60 * 60)
+ : period === 'oneDay' ? Date.now() + (1000 * 60 * 60 * 24)
+ : period === 'oneWeek' ? Date.now() + (1000 * 60 * 60 * 24 * 7)
+ : period === 'oneMonth' ? Date.now() + (1000 * 60 * 60 * 24 * 30)
+ : null;
+
+ os.apiWithDialog('admin/roles/assign', { roleId: r.id, userId: user.id, expiresAt });
+ },
+ }));
+ },
+ });
+ }
+
+ // フォローしたとしても user.isFollowing はリアルタイム更新されないので不便なため
+ //if (user.isFollowing) {
+ const withRepliesRef = ref(user.withReplies ?? false);
+
+ menuItems.push({
+ type: 'switch',
+ icon: 'ti ti-messages',
+ text: i18n.ts.showRepliesToOthersInTimeline,
+ ref: withRepliesRef,
+ }, {
+ icon: user.notify === 'none' ? 'ti ti-bell' : 'ti ti-bell-off',
+ text: user.notify === 'none' ? i18n.ts.notifyNotes : i18n.ts.unnotifyNotes,
+ action: toggleNotify,
+ });
+
+ watch(withRepliesRef, (withReplies) => {
+ misskeyApi('following/update', {
+ userId: user.id,
+ withReplies,
+ }).then(() => {
+ user.withReplies = withReplies;
+ });
+ });
+ //}
+
+ menuItems.push({ type: 'divider' }, {
+ icon: user.isMuted ? 'ti ti-eye' : 'ti ti-eye-off',
+ text: user.isMuted ? i18n.ts.unmute : i18n.ts.mute,
+ action: toggleMute,
+ }, {
+ icon: user.isRenoteMuted ? 'ti ti-repeat' : 'ti ti-repeat-off',
+ text: user.isRenoteMuted ? i18n.ts.renoteUnmute : i18n.ts.renoteMute,
+ action: toggleRenoteMute,
+ }, {
+ icon: 'ti ti-ban',
+ text: user.isBlocking ? i18n.ts.unblock : i18n.ts.block,
+ action: toggleBlock,
+ });
+
+ if (user.isFollowed) {
+ menuItems.push({
+ icon: 'ti ti-link-off',
+ text: i18n.ts.breakFollow,
+ action: invalidateFollow,
+ });
+ }
+
+ menuItems.push({ type: 'divider' }, {
+ icon: 'ti ti-exclamation-circle',
+ text: i18n.ts.reportAbuse,
+ action: reportAbuse,
+ });
+ }
+
+ if (user.host !== null) {
+ menuItems.push({ type: 'divider' }, {
+ icon: 'ti ti-refresh',
+ text: i18n.ts.updateRemoteUser,
+ action: userInfoUpdate,
+ });
+ }
+
+ if (prefer.s.devMode) {
+ menuItems.push({ type: 'divider' }, {
+ icon: 'ti ti-id',
+ text: i18n.ts.copyUserId,
+ action: () => {
+ copyToClipboard(user.id);
+ },
+ });
+ }
+
+ if ($i && meId === user.id) {
+ menuItems.push({ type: 'divider' }, {
+ icon: 'ti ti-pencil',
+ text: i18n.ts.editProfile,
+ action: () => {
+ router.push('/settings/profile');
+ },
+ });
+ }
+
+ if (userActions.length > 0) {
+ menuItems.push({ type: 'divider' }, ...userActions.map(action => ({
+ icon: 'ti ti-plug',
+ text: action.title,
+ action: () => {
+ action.handler(user);
+ },
+ })));
+ }
+
+ return {
+ menu: menuItems,
+ cleanup: () => {
+ if (_DEV_) console.log('user menu cleanup', cleanups);
+ for (const cl of cleanups) {
+ cl();
+ }
+ },
+ };
+}
diff --git a/packages/frontend/src/utility/get-user-name.ts b/packages/frontend/src/utility/get-user-name.ts
new file mode 100644
index 0000000000..56e91abba0
--- /dev/null
+++ b/packages/frontend/src/utility/get-user-name.ts
@@ -0,0 +1,8 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+export default function(user: { name?: string | null, username: string }): string {
+ return user.name === '' ? user.username : user.name ?? user.username;
+}
diff --git a/packages/frontend/src/utility/hotkey.ts b/packages/frontend/src/utility/hotkey.ts
new file mode 100644
index 0000000000..fe62139a74
--- /dev/null
+++ b/packages/frontend/src/utility/hotkey.ts
@@ -0,0 +1,172 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+import { getHTMLElementOrNull } from "@/utility/get-dom-node-or-null.js";
+
+//#region types
+export type Keymap = Record;
+
+type CallbackFunction = (ev: KeyboardEvent) => unknown;
+
+type CallbackObject = {
+ callback: CallbackFunction;
+ allowRepeat?: boolean;
+};
+
+type Pattern = {
+ which: string[];
+ ctrl: boolean;
+ alt: boolean;
+ shift: boolean;
+};
+
+type Action = {
+ patterns: Pattern[];
+ callback: CallbackFunction;
+ options: Required>;
+};
+//#endregion
+
+//#region consts
+const KEY_ALIASES = {
+ 'esc': 'Escape',
+ 'enter': 'Enter',
+ 'space': ' ',
+ 'up': 'ArrowUp',
+ 'down': 'ArrowDown',
+ 'left': 'ArrowLeft',
+ 'right': 'ArrowRight',
+ 'plus': ['+', ';'],
+};
+
+const MODIFIER_KEYS = ['ctrl', 'alt', 'shift'];
+
+const IGNORE_ELEMENTS = ['input', 'textarea'];
+//#endregion
+
+//#region store
+let latestHotkey: Pattern & { callback: CallbackFunction } | null = null;
+//#endregion
+
+//#region impl
+export const makeHotkey = (keymap: Keymap) => {
+ const actions = parseKeymap(keymap);
+ return (ev: KeyboardEvent) => {
+ if ('pswp' in window && window.pswp != null) return;
+ if (document.activeElement != null) {
+ if (IGNORE_ELEMENTS.includes(document.activeElement.tagName.toLowerCase())) return;
+ if (getHTMLElementOrNull(document.activeElement)?.isContentEditable) return;
+ }
+ for (const action of actions) {
+ if (matchPatterns(ev, action)) {
+ ev.preventDefault();
+ ev.stopPropagation();
+ action.callback(ev);
+ storePattern(ev, action.callback);
+ }
+ }
+ };
+};
+
+const parseKeymap = (keymap: Keymap) => {
+ return Object.entries(keymap).map(([rawPatterns, rawCallback]) => {
+ const patterns = parsePatterns(rawPatterns);
+ const callback = parseCallback(rawCallback);
+ const options = parseOptions(rawCallback);
+ return { patterns, callback, options } as const satisfies Action;
+ });
+};
+
+const parsePatterns = (rawPatterns: keyof Keymap) => {
+ return rawPatterns.split('|').map(part => {
+ const keys = part.split('+').map(trimLower);
+ const which = parseKeyCode(keys.findLast(x => !MODIFIER_KEYS.includes(x)));
+ const ctrl = keys.includes('ctrl');
+ const alt = keys.includes('alt');
+ const shift = keys.includes('shift');
+ return { which, ctrl, alt, shift } as const satisfies Pattern;
+ });
+};
+
+const parseCallback = (rawCallback: Keymap[keyof Keymap]) => {
+ if (typeof rawCallback === 'object') {
+ return rawCallback.callback;
+ }
+ return rawCallback;
+};
+
+const parseOptions = (rawCallback: Keymap[keyof Keymap]) => {
+ const defaultOptions = {
+ allowRepeat: false,
+ } as const satisfies Action['options'];
+ if (typeof rawCallback === 'object') {
+ const { callback, ...rawOptions } = rawCallback;
+ const options = { ...defaultOptions, ...rawOptions };
+ return { ...options } as const satisfies Action['options'];
+ }
+ return { ...defaultOptions } as const satisfies Action['options'];
+};
+
+const matchPatterns = (ev: KeyboardEvent, action: Action) => {
+ const { patterns, options, callback } = action;
+ if (ev.repeat && !options.allowRepeat) return false;
+ const key = ev.key.toLowerCase();
+ return patterns.some(({ which, ctrl, shift, alt }) => {
+ if (
+ options.allowRepeat === false &&
+ latestHotkey != null &&
+ latestHotkey.which.includes(key) &&
+ latestHotkey.ctrl === ctrl &&
+ latestHotkey.alt === alt &&
+ latestHotkey.shift === shift &&
+ latestHotkey.callback === callback
+ ) {
+ return false;
+ }
+ if (!which.includes(key)) return false;
+ if (ctrl !== (ev.ctrlKey || ev.metaKey)) return false;
+ if (alt !== ev.altKey) return false;
+ if (shift !== ev.shiftKey) return false;
+ return true;
+ });
+};
+
+let lastHotKeyStoreTimer: number | null = null;
+
+const storePattern = (ev: KeyboardEvent, callback: CallbackFunction) => {
+ if (lastHotKeyStoreTimer != null) {
+ clearTimeout(lastHotKeyStoreTimer);
+ }
+
+ latestHotkey = {
+ which: [ev.key.toLowerCase()],
+ ctrl: ev.ctrlKey || ev.metaKey,
+ alt: ev.altKey,
+ shift: ev.shiftKey,
+ callback,
+ };
+
+ lastHotKeyStoreTimer = window.setTimeout(() => {
+ latestHotkey = null;
+ }, 500);
+};
+
+const parseKeyCode = (input?: string | null) => {
+ if (input == null) return [];
+ const raw = getValueByKey(KEY_ALIASES, input);
+ if (raw == null) return [input];
+ if (typeof raw === 'string') return [trimLower(raw)];
+ return raw.map(trimLower);
+};
+
+const getValueByKey = <
+ T extends Record,
+ K extends keyof T | keyof any,
+ R extends K extends keyof T ? T[K] : T[keyof T] | undefined,
+>(obj: T, key: K) => {
+ return obj[key] as R;
+};
+
+const trimLower = (str: string) => str.trim().toLowerCase();
+//#endregion
diff --git a/packages/frontend/src/utility/idb-proxy.ts b/packages/frontend/src/utility/idb-proxy.ts
new file mode 100644
index 0000000000..20f51660c7
--- /dev/null
+++ b/packages/frontend/src/utility/idb-proxy.ts
@@ -0,0 +1,53 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+// FirefoxのプライベートモードなどではindexedDBが使用不可能なので、
+// indexedDBが使えない環境ではlocalStorageを使う
+import {
+ get as iget,
+ set as iset,
+ del as idel,
+} from 'idb-keyval';
+import { miLocalStorage } from '@/local-storage.js';
+
+const PREFIX = 'idbfallback::';
+
+let idbAvailable = typeof window !== 'undefined' ? !!(window.indexedDB && typeof window.indexedDB.open === 'function') : true;
+
+// iframe.contentWindow.indexedDB.deleteDatabase() がchromeのバグで使用できないため、indexedDBを無効化している。
+// バグが治って再度有効化するのであれば、cypressのコマンド内のコメントアウトを外すこと
+// see https://github.com/misskey-dev/misskey/issues/13605#issuecomment-2053652123
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-expect-error
+if (window.Cypress) {
+ idbAvailable = false;
+ console.log('Cypress detected. It will use localStorage.');
+}
+
+if (idbAvailable) {
+ await iset('idb-test', 'test')
+ .catch(err => {
+ console.error('idb error', err);
+ console.error('indexedDB is unavailable. It will use localStorage.');
+ idbAvailable = false;
+ });
+} else {
+ console.error('indexedDB is unavailable. It will use localStorage.');
+}
+
+export async function get(key: string) {
+ if (idbAvailable) return iget(key);
+ return miLocalStorage.getItemAsJson(`${PREFIX}${key}`);
+}
+
+export async function set(key: string, val: any) {
+ if (idbAvailable) return iset(key, val);
+ return miLocalStorage.setItemAsJson(`${PREFIX}${key}`, val);
+}
+
+export async function del(key: string) {
+ if (idbAvailable) return idel(key);
+ return miLocalStorage.removeItem(`${PREFIX}${key}`);
+}
diff --git a/packages/frontend/src/utility/idle-render.ts b/packages/frontend/src/utility/idle-render.ts
new file mode 100644
index 0000000000..6adfedcb9f
--- /dev/null
+++ b/packages/frontend/src/utility/idle-render.ts
@@ -0,0 +1,60 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+const requestIdleCallback: typeof globalThis.requestIdleCallback = globalThis.requestIdleCallback ?? ((callback) => {
+ const start = performance.now();
+ const timeoutId = setTimeout(() => {
+ callback({
+ didTimeout: false, // polyfill でタイムアウト発火することはない
+ timeRemaining() {
+ const diff = performance.now() - start;
+ return Math.max(0, 50 - diff); //
+ },
+ });
+ });
+ return timeoutId;
+});
+const cancelIdleCallback: typeof globalThis.cancelIdleCallback = globalThis.cancelIdleCallback ?? ((timeoutId) => {
+ clearTimeout(timeoutId);
+});
+
+class IdlingRenderScheduler {
+ #renderers: Set;
+ #rafId: number;
+ #ricId: number;
+
+ constructor() {
+ this.#renderers = new Set();
+ this.#rafId = 0;
+ this.#ricId = requestIdleCallback((deadline) => this.#schedule(deadline));
+ }
+
+ #schedule(deadline: IdleDeadline): void {
+ if (deadline.timeRemaining()) {
+ this.#rafId = requestAnimationFrame((time) => {
+ for (const renderer of this.#renderers) {
+ renderer(time);
+ }
+ });
+ }
+ this.#ricId = requestIdleCallback((arg) => this.#schedule(arg));
+ }
+
+ add(renderer: FrameRequestCallback): void {
+ this.#renderers.add(renderer);
+ }
+
+ delete(renderer: FrameRequestCallback): void {
+ this.#renderers.delete(renderer);
+ }
+
+ dispose(): void {
+ this.#renderers.clear();
+ cancelAnimationFrame(this.#rafId);
+ cancelIdleCallback(this.#ricId);
+ }
+}
+
+export const defaultIdlingRenderScheduler = new IdlingRenderScheduler();
diff --git a/packages/frontend/src/utility/init-chart.ts b/packages/frontend/src/utility/init-chart.ts
new file mode 100644
index 0000000000..037b0d9567
--- /dev/null
+++ b/packages/frontend/src/utility/init-chart.ts
@@ -0,0 +1,58 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import {
+ Chart,
+ ArcElement,
+ LineElement,
+ BarElement,
+ PointElement,
+ BarController,
+ LineController,
+ DoughnutController,
+ CategoryScale,
+ LinearScale,
+ TimeScale,
+ Legend,
+ Title,
+ Tooltip,
+ SubTitle,
+ Filler,
+} from 'chart.js';
+import gradient from 'chartjs-plugin-gradient';
+import zoomPlugin from 'chartjs-plugin-zoom';
+import { MatrixController, MatrixElement } from 'chartjs-chart-matrix';
+import { store } from '@/store.js';
+import 'chartjs-adapter-date-fns';
+
+export function initChart() {
+ Chart.register(
+ ArcElement,
+ LineElement,
+ BarElement,
+ PointElement,
+ BarController,
+ LineController,
+ DoughnutController,
+ CategoryScale,
+ LinearScale,
+ TimeScale,
+ Legend,
+ Title,
+ Tooltip,
+ SubTitle,
+ Filler,
+ MatrixController, MatrixElement,
+ zoomPlugin,
+ gradient,
+ );
+
+ // フォントカラー
+ Chart.defaults.color = getComputedStyle(document.documentElement).getPropertyValue('--MI_THEME-fg');
+
+ Chart.defaults.borderColor = store.state.darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)';
+
+ Chart.defaults.animation = false;
+}
diff --git a/packages/frontend/src/utility/initialize-sw.ts b/packages/frontend/src/utility/initialize-sw.ts
new file mode 100644
index 0000000000..867ebf19ed
--- /dev/null
+++ b/packages/frontend/src/utility/initialize-sw.ts
@@ -0,0 +1,18 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { lang } from '@@/js/config.js';
+
+export async function initializeSw() {
+ if (!('serviceWorker' in navigator)) return;
+
+ navigator.serviceWorker.register('/sw.js', { scope: '/', type: 'classic' });
+ navigator.serviceWorker.ready.then(registration => {
+ registration.active?.postMessage({
+ msg: 'initialize',
+ lang,
+ });
+ });
+}
diff --git a/packages/frontend/src/utility/intl-const.ts b/packages/frontend/src/utility/intl-const.ts
new file mode 100644
index 0000000000..385f59ec39
--- /dev/null
+++ b/packages/frontend/src/utility/intl-const.ts
@@ -0,0 +1,50 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { lang } from '@@/js/config.js';
+
+export const versatileLang = (lang ?? 'ja-JP').replace('ja-KS', 'ja-JP');
+
+let _dateTimeFormat: Intl.DateTimeFormat;
+try {
+ _dateTimeFormat = new Intl.DateTimeFormat(versatileLang, {
+ year: 'numeric',
+ month: 'numeric',
+ day: 'numeric',
+ hour: 'numeric',
+ minute: 'numeric',
+ second: 'numeric',
+ });
+} catch (err) {
+ console.warn(err);
+ if (_DEV_) console.log('[Intl] Fallback to en-US');
+
+ // Fallback to en-US
+ _dateTimeFormat = new Intl.DateTimeFormat('en-US', {
+ year: 'numeric',
+ month: 'numeric',
+ day: 'numeric',
+ hour: 'numeric',
+ minute: 'numeric',
+ second: 'numeric',
+ });
+}
+export const dateTimeFormat = _dateTimeFormat;
+
+export const timeZone = dateTimeFormat.resolvedOptions().timeZone;
+
+export const hemisphere = /^(australia|pacific|antarctica|indian)\//i.test(timeZone) ? 'S' : 'N';
+
+let _numberFormat: Intl.NumberFormat;
+try {
+ _numberFormat = new Intl.NumberFormat(versatileLang);
+} catch (err) {
+ console.warn(err);
+ if (_DEV_) console.log('[Intl] Fallback to en-US');
+
+ // Fallback to en-US
+ _numberFormat = new Intl.NumberFormat('en-US');
+}
+export const numberFormat = _numberFormat;
diff --git a/packages/frontend/src/utility/intl-string.ts b/packages/frontend/src/utility/intl-string.ts
new file mode 100644
index 0000000000..a5b5bbb592
--- /dev/null
+++ b/packages/frontend/src/utility/intl-string.ts
@@ -0,0 +1,97 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { versatileLang } from '@@/js/intl-const.js';
+import type { toHiragana as toHiraganaType } from 'wanakana';
+
+let toHiragana: typeof toHiraganaType = (str?: string) => str ?? '';
+let isWanakanaLoaded = false;
+
+/**
+ * ローマ字変換のセットアップ(日本語以外の環境で読み込まないのでlazy-loading)
+ *
+ * ここの比較系関数を使う際は事前に呼び出す必要がある
+ */
+export async function initIntlString(forceWanakana = false) {
+ if ((!versatileLang.includes('ja') && !forceWanakana) || isWanakanaLoaded) return;
+ const { toHiragana: _toHiragana } = await import('wanakana');
+ toHiragana = _toHiragana;
+ isWanakanaLoaded = true;
+}
+
+/**
+ * - 全角英数字を半角に
+ * - 半角カタカナを全角に
+ * - 濁点・半濁点がリガチャになっている(例: `か` + `゛` )ひらがな・カタカナを結合
+ * - 異体字を正規化
+ * - 小文字に揃える
+ * - 文字列のトリム
+ */
+export function normalizeString(str: string) {
+ const segmenter = new Intl.Segmenter(versatileLang, { granularity: 'grapheme' });
+ return [...segmenter.segment(str)].map(({ segment }) => segment.normalize('NFKC')).join('').toLowerCase().trim();
+}
+
+// https://qiita.com/non-caffeine/items/77360dda05c8ce510084
+const hyphens = [
+ 0x002d, // hyphen-minus
+ 0x02d7, // modifier letter minus sign
+ 0x1173, // hangul jongseong eu
+ 0x1680, // ogham space mark
+ 0x1b78, // balinese musical symbol left-hand open pang
+ 0x2010, // hyphen
+ 0x2011, // non-breaking hyphen
+ 0x2012, // figure dash
+ 0x2013, // en dash
+ 0x2014, // em dash
+ 0x2015, // horizontal bar
+ 0x2043, // hyphen bullet
+ 0x207b, // superscript minus
+ 0x2212, // minus sign
+ 0x25ac, // black rectangle
+ 0x2500, // box drawings light horizontal
+ 0x2501, // box drawings heavy horizontal
+ 0x2796, // heavy minus sign
+ 0x30fc, // katakana-hiragana prolonged sound mark
+ 0x3161, // hangul letter eu
+ 0xfe58, // small em dash
+ 0xfe63, // small hyphen-minus
+ 0xff0d, // fullwidth hyphen-minus
+ 0xff70, // halfwidth katakana-hiragana prolonged sound mark
+ 0x10110, // aegean number ten
+ 0x10191, // roman uncia sign
+];
+
+const hyphensCodePoints = hyphens.map(code => `\\u{${code.toString(16).padStart(4, '0')}}`);
+
+/** ハイフンを統一(ローマ字半角入力時に`ー`と`-`が判定できない問題の調整) */
+export function normalizeHyphens(str: string) {
+ return str.replace(new RegExp(`[${hyphensCodePoints.join('')}]`, 'ug'), '\u002d');
+}
+
+/**
+ * `normalizeString` に加えて、カタカナ・ローマ字をひらがなに揃え、ハイフンを統一
+ *
+ * (ローマ字じゃないものもローマ字として認識され変換されるので、文字列比較の際は `normalizeString` を併用する必要あり)
+ */
+export function normalizeStringWithHiragana(str: string) {
+ return normalizeHyphens(toHiragana(normalizeString(str), { convertLongVowelMark: false }));
+}
+
+/** aとbが同じかどうか */
+export function compareStringEquals(a: string, b: string) {
+ return (
+ normalizeString(a) === normalizeString(b) ||
+ normalizeStringWithHiragana(a) === normalizeStringWithHiragana(b)
+ );
+}
+
+/** baseにqueryが含まれているかどうか */
+export function compareStringIncludes(base: string, query: string) {
+ return (
+ normalizeString(base).includes(normalizeString(query)) ||
+ normalizeStringWithHiragana(base).includes(normalizeStringWithHiragana(query))
+ );
+}
diff --git a/packages/frontend/src/utility/is-device-darkmode.ts b/packages/frontend/src/utility/is-device-darkmode.ts
new file mode 100644
index 0000000000..4f487c7cb9
--- /dev/null
+++ b/packages/frontend/src/utility/is-device-darkmode.ts
@@ -0,0 +1,8 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+export function isDeviceDarkmode() {
+ return window.matchMedia('(prefers-color-scheme: dark)').matches;
+}
diff --git a/packages/frontend/src/utility/isFfVisibleForMe.ts b/packages/frontend/src/utility/isFfVisibleForMe.ts
new file mode 100644
index 0000000000..e28e5725bc
--- /dev/null
+++ b/packages/frontend/src/utility/isFfVisibleForMe.ts
@@ -0,0 +1,24 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import * as Misskey from 'misskey-js';
+import { $i } from '@/account.js';
+
+export function isFollowingVisibleForMe(user: Misskey.entities.UserDetailed): boolean {
+ if ($i && ($i.id === user.id || $i.isAdmin || $i.isModerator)) return true;
+
+ if (user.followingVisibility === 'private') return false;
+ if (user.followingVisibility === 'followers' && !user.isFollowing) return false;
+
+ return true;
+}
+export function isFollowersVisibleForMe(user: Misskey.entities.UserDetailed): boolean {
+ if ($i && ($i.id === user.id || $i.isAdmin || $i.isModerator)) return true;
+
+ if (user.followersVisibility === 'private') return false;
+ if (user.followersVisibility === 'followers' && !user.isFollowing) return false;
+
+ return true;
+}
diff --git a/packages/frontend/src/utility/key-event.ts b/packages/frontend/src/utility/key-event.ts
new file mode 100644
index 0000000000..020a6c2174
--- /dev/null
+++ b/packages/frontend/src/utility/key-event.ts
@@ -0,0 +1,153 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+/**
+ * {@link KeyboardEvent.code} の値を表す文字列。不足分は適宜追加する
+ * @see https://developer.mozilla.org/en-US/docs/Web/API/UI_Events/Keyboard_event_code_values
+ */
+export type KeyCode = (
+ | 'Backspace'
+ | 'Tab'
+ | 'Enter'
+ | 'Shift'
+ | 'Control'
+ | 'Alt'
+ | 'Pause'
+ | 'CapsLock'
+ | 'Escape'
+ | 'Space'
+ | 'PageUp'
+ | 'PageDown'
+ | 'End'
+ | 'Home'
+ | 'ArrowLeft'
+ | 'ArrowUp'
+ | 'ArrowRight'
+ | 'ArrowDown'
+ | 'Insert'
+ | 'Delete'
+ | 'Digit0'
+ | 'Digit1'
+ | 'Digit2'
+ | 'Digit3'
+ | 'Digit4'
+ | 'Digit5'
+ | 'Digit6'
+ | 'Digit7'
+ | 'Digit8'
+ | 'Digit9'
+ | 'KeyA'
+ | 'KeyB'
+ | 'KeyC'
+ | 'KeyD'
+ | 'KeyE'
+ | 'KeyF'
+ | 'KeyG'
+ | 'KeyH'
+ | 'KeyI'
+ | 'KeyJ'
+ | 'KeyK'
+ | 'KeyL'
+ | 'KeyM'
+ | 'KeyN'
+ | 'KeyO'
+ | 'KeyP'
+ | 'KeyQ'
+ | 'KeyR'
+ | 'KeyS'
+ | 'KeyT'
+ | 'KeyU'
+ | 'KeyV'
+ | 'KeyW'
+ | 'KeyX'
+ | 'KeyY'
+ | 'KeyZ'
+ | 'MetaLeft'
+ | 'MetaRight'
+ | 'ContextMenu'
+ | 'F1'
+ | 'F2'
+ | 'F3'
+ | 'F4'
+ | 'F5'
+ | 'F6'
+ | 'F7'
+ | 'F8'
+ | 'F9'
+ | 'F10'
+ | 'F11'
+ | 'F12'
+ | 'NumLock'
+ | 'ScrollLock'
+ | 'Semicolon'
+ | 'Equal'
+ | 'Comma'
+ | 'Minus'
+ | 'Period'
+ | 'Slash'
+ | 'Backquote'
+ | 'BracketLeft'
+ | 'Backslash'
+ | 'BracketRight'
+ | 'Quote'
+ | 'Meta'
+ | 'AltGraph'
+);
+
+/**
+ * 修飾キーを表す文字列。不足分は適宜追加する。
+ */
+export type KeyModifier = (
+ | 'Shift'
+ | 'Control'
+ | 'Alt'
+ | 'Meta'
+);
+
+/**
+ * 押下されたキー以外の状態を表す文字列。不足分は適宜追加する。
+ */
+export type KeyState = (
+ | 'composing'
+ | 'repeat'
+);
+
+export type KeyEventHandler = {
+ modifiers?: KeyModifier[];
+ states?: KeyState[];
+ code: KeyCode | 'any';
+ handler: (event: KeyboardEvent) => void;
+};
+
+export function handleKeyEvent(event: KeyboardEvent, handlers: KeyEventHandler[]) {
+ function checkModifier(ev: KeyboardEvent, modifiers? : KeyModifier[]) {
+ if (modifiers) {
+ return modifiers.every(modifier => ev.getModifierState(modifier));
+ }
+ return true;
+ }
+
+ function checkState(ev: KeyboardEvent, states?: KeyState[]) {
+ if (states) {
+ return states.every(state => ev.getModifierState(state));
+ }
+ return true;
+ }
+
+ let hit = false;
+ for (const handler of handlers.filter(it => it.code === event.code)) {
+ if (checkModifier(event, handler.modifiers) && checkState(event, handler.states)) {
+ handler.handler(event);
+ hit = true;
+ break;
+ }
+ }
+
+ if (!hit) {
+ for (const handler of handlers.filter(it => it.code === 'any')) {
+ handler.handler(event);
+ }
+ }
+}
diff --git a/packages/frontend/src/utility/langmap.ts b/packages/frontend/src/utility/langmap.ts
new file mode 100644
index 0000000000..b32de15963
--- /dev/null
+++ b/packages/frontend/src/utility/langmap.ts
@@ -0,0 +1,671 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+// TODO: sharedに置いてバックエンドのと統合したい
+export const langmap = {
+ 'ach': {
+ nativeName: 'Lwo',
+ },
+ 'ady': {
+ nativeName: 'Адыгэбзэ',
+ },
+ 'af': {
+ nativeName: 'Afrikaans',
+ },
+ 'af-NA': {
+ nativeName: 'Afrikaans (Namibia)',
+ },
+ 'af-ZA': {
+ nativeName: 'Afrikaans (South Africa)',
+ },
+ 'ak': {
+ nativeName: 'Tɕɥi',
+ },
+ 'ar': {
+ nativeName: 'العربية',
+ },
+ 'ar-AR': {
+ nativeName: 'العربية',
+ },
+ 'ar-MA': {
+ nativeName: 'العربية',
+ },
+ 'ar-SA': {
+ nativeName: 'العربية (السعودية)',
+ },
+ 'ay-BO': {
+ nativeName: 'Aymar aru',
+ },
+ 'az': {
+ nativeName: 'Azərbaycan dili',
+ },
+ 'az-AZ': {
+ nativeName: 'Azərbaycan dili',
+ },
+ 'be-BY': {
+ nativeName: 'Беларуская',
+ },
+ 'bg': {
+ nativeName: 'Български',
+ },
+ 'bg-BG': {
+ nativeName: 'Български',
+ },
+ 'bn': {
+ nativeName: 'বাংলা',
+ },
+ 'bn-IN': {
+ nativeName: 'বাংলা (ভারত)',
+ },
+ 'bn-BD': {
+ nativeName: 'বাংলা(বাংলাদেশ)',
+ },
+ 'br': {
+ nativeName: 'Brezhoneg',
+ },
+ 'bs-BA': {
+ nativeName: 'Bosanski',
+ },
+ 'ca': {
+ nativeName: 'Català',
+ },
+ 'ca-ES': {
+ nativeName: 'Català',
+ },
+ 'cak': {
+ nativeName: 'Maya Kaqchikel',
+ },
+ 'ck-US': {
+ nativeName: 'ᏣᎳᎩ (tsalagi)',
+ },
+ 'cs': {
+ nativeName: 'Čeština',
+ },
+ 'cs-CZ': {
+ nativeName: 'Čeština',
+ },
+ 'cy': {
+ nativeName: 'Cymraeg',
+ },
+ 'cy-GB': {
+ nativeName: 'Cymraeg',
+ },
+ 'da': {
+ nativeName: 'Dansk',
+ },
+ 'da-DK': {
+ nativeName: 'Dansk',
+ },
+ 'de': {
+ nativeName: 'Deutsch',
+ },
+ 'de-AT': {
+ nativeName: 'Deutsch (Österreich)',
+ },
+ 'de-DE': {
+ nativeName: 'Deutsch (Deutschland)',
+ },
+ 'de-CH': {
+ nativeName: 'Deutsch (Schweiz)',
+ },
+ 'dsb': {
+ nativeName: 'Dolnoserbšćina',
+ },
+ 'el': {
+ nativeName: 'Ελληνικά',
+ },
+ 'el-GR': {
+ nativeName: 'Ελληνικά',
+ },
+ 'en': {
+ nativeName: 'English',
+ },
+ 'en-GB': {
+ nativeName: 'English (UK)',
+ },
+ 'en-AU': {
+ nativeName: 'English (Australia)',
+ },
+ 'en-CA': {
+ nativeName: 'English (Canada)',
+ },
+ 'en-IE': {
+ nativeName: 'English (Ireland)',
+ },
+ 'en-IN': {
+ nativeName: 'English (India)',
+ },
+ 'en-PI': {
+ nativeName: 'English (Pirate)',
+ },
+ 'en-SG': {
+ nativeName: 'English (Singapore)',
+ },
+ 'en-UD': {
+ nativeName: 'English (Upside Down)',
+ },
+ 'en-US': {
+ nativeName: 'English (US)',
+ },
+ 'en-ZA': {
+ nativeName: 'English (South Africa)',
+ },
+ 'en@pirate': {
+ nativeName: 'English (Pirate)',
+ },
+ 'eo': {
+ nativeName: 'Esperanto',
+ },
+ 'eo-EO': {
+ nativeName: 'Esperanto',
+ },
+ 'es': {
+ nativeName: 'Español',
+ },
+ 'es-AR': {
+ nativeName: 'Español (Argentine)',
+ },
+ 'es-419': {
+ nativeName: 'Español (Latinoamérica)',
+ },
+ 'es-CL': {
+ nativeName: 'Español (Chile)',
+ },
+ 'es-CO': {
+ nativeName: 'Español (Colombia)',
+ },
+ 'es-EC': {
+ nativeName: 'Español (Ecuador)',
+ },
+ 'es-ES': {
+ nativeName: 'Español (España)',
+ },
+ 'es-LA': {
+ nativeName: 'Español (Latinoamérica)',
+ },
+ 'es-NI': {
+ nativeName: 'Español (Nicaragua)',
+ },
+ 'es-MX': {
+ nativeName: 'Español (México)',
+ },
+ 'es-US': {
+ nativeName: 'Español (Estados Unidos)',
+ },
+ 'es-VE': {
+ nativeName: 'Español (Venezuela)',
+ },
+ 'et': {
+ nativeName: 'eesti keel',
+ },
+ 'et-EE': {
+ nativeName: 'Eesti (Estonia)',
+ },
+ 'eu': {
+ nativeName: 'Euskara',
+ },
+ 'eu-ES': {
+ nativeName: 'Euskara',
+ },
+ 'fa': {
+ nativeName: 'فارسی',
+ },
+ 'fa-IR': {
+ nativeName: 'فارسی',
+ },
+ 'fb-LT': {
+ nativeName: 'Leet Speak',
+ },
+ 'ff': {
+ nativeName: 'Fulah',
+ },
+ 'fi': {
+ nativeName: 'Suomi',
+ },
+ 'fi-FI': {
+ nativeName: 'Suomi',
+ },
+ 'fo': {
+ nativeName: 'Føroyskt',
+ },
+ 'fo-FO': {
+ nativeName: 'Føroyskt (Færeyjar)',
+ },
+ 'fr': {
+ nativeName: 'Français',
+ },
+ 'fr-CA': {
+ nativeName: 'Français (Canada)',
+ },
+ 'fr-FR': {
+ nativeName: 'Français (France)',
+ },
+ 'fr-BE': {
+ nativeName: 'Français (Belgique)',
+ },
+ 'fr-CH': {
+ nativeName: 'Français (Suisse)',
+ },
+ 'fy-NL': {
+ nativeName: 'Frysk',
+ },
+ 'ga': {
+ nativeName: 'Gaeilge',
+ },
+ 'ga-IE': {
+ nativeName: 'Gaeilge',
+ },
+ 'gd': {
+ nativeName: 'Gàidhlig',
+ },
+ 'gl': {
+ nativeName: 'Galego',
+ },
+ 'gl-ES': {
+ nativeName: 'Galego',
+ },
+ 'gn-PY': {
+ nativeName: 'Avañe\'ẽ',
+ },
+ 'gu-IN': {
+ nativeName: 'ગુજરાતી',
+ },
+ 'gv': {
+ nativeName: 'Gaelg',
+ },
+ 'gx-GR': {
+ nativeName: 'Ἑλληνική ἀρχαία',
+ },
+ 'he': {
+ nativeName: 'עברית',
+ },
+ 'he-IL': {
+ nativeName: 'עברית',
+ },
+ 'hi': {
+ nativeName: 'हिन्दी',
+ },
+ 'hi-IN': {
+ nativeName: 'हिन्दी',
+ },
+ 'hr': {
+ nativeName: 'Hrvatski',
+ },
+ 'hr-HR': {
+ nativeName: 'Hrvatski',
+ },
+ 'hsb': {
+ nativeName: 'Hornjoserbšćina',
+ },
+ 'ht': {
+ nativeName: 'Kreyòl',
+ },
+ 'hu': {
+ nativeName: 'Magyar',
+ },
+ 'hu-HU': {
+ nativeName: 'Magyar',
+ },
+ 'hy': {
+ nativeName: 'Հայերեն',
+ },
+ 'hy-AM': {
+ nativeName: 'Հայերեն (Հայաստան)',
+ },
+ 'id': {
+ nativeName: 'Bahasa Indonesia',
+ },
+ 'id-ID': {
+ nativeName: 'Bahasa Indonesia',
+ },
+ 'is': {
+ nativeName: 'Íslenska',
+ },
+ 'is-IS': {
+ nativeName: 'Íslenska (Iceland)',
+ },
+ 'it': {
+ nativeName: 'Italiano',
+ },
+ 'it-IT': {
+ nativeName: 'Italiano',
+ },
+ 'ja': {
+ nativeName: '日本語',
+ },
+ 'ja-JP': {
+ nativeName: '日本語 (日本)',
+ },
+ 'jv-ID': {
+ nativeName: 'Basa Jawa',
+ },
+ 'ka-GE': {
+ nativeName: 'ქართული',
+ },
+ 'kk-KZ': {
+ nativeName: 'Қазақша',
+ },
+ 'km': {
+ nativeName: 'ភាសាខ្មែរ',
+ },
+ 'kl': {
+ nativeName: 'kalaallisut',
+ },
+ 'km-KH': {
+ nativeName: 'ភាសាខ្មែរ',
+ },
+ 'kab': {
+ nativeName: 'Taqbaylit',
+ },
+ 'kn': {
+ nativeName: 'ಕನ್ನಡ',
+ },
+ 'kn-IN': {
+ nativeName: 'ಕನ್ನಡ (India)',
+ },
+ 'ko': {
+ nativeName: '한국어',
+ },
+ 'ko-KR': {
+ nativeName: '한국어 (한국)',
+ },
+ 'ku-TR': {
+ nativeName: 'Kurdî',
+ },
+ 'kw': {
+ nativeName: 'Kernewek',
+ },
+ 'la': {
+ nativeName: 'Latin',
+ },
+ 'la-VA': {
+ nativeName: 'Latin',
+ },
+ 'lb': {
+ nativeName: 'Lëtzebuergesch',
+ },
+ 'li-NL': {
+ nativeName: 'Lèmbörgs',
+ },
+ 'lt': {
+ nativeName: 'Lietuvių',
+ },
+ 'lt-LT': {
+ nativeName: 'Lietuvių',
+ },
+ 'lv': {
+ nativeName: 'Latviešu',
+ },
+ 'lv-LV': {
+ nativeName: 'Latviešu',
+ },
+ 'mai': {
+ nativeName: 'मैथिली, মৈথিলী',
+ },
+ 'mg-MG': {
+ nativeName: 'Malagasy',
+ },
+ 'mk': {
+ nativeName: 'Македонски',
+ },
+ 'mk-MK': {
+ nativeName: 'Македонски (Македонски)',
+ },
+ 'ml': {
+ nativeName: 'മലയാളം',
+ },
+ 'ml-IN': {
+ nativeName: 'മലയാളം',
+ },
+ 'mn-MN': {
+ nativeName: 'Монгол',
+ },
+ 'mr': {
+ nativeName: 'मराठी',
+ },
+ 'mr-IN': {
+ nativeName: 'मराठी',
+ },
+ 'ms': {
+ nativeName: 'Bahasa Melayu',
+ },
+ 'ms-MY': {
+ nativeName: 'Bahasa Melayu',
+ },
+ 'mt': {
+ nativeName: 'Malti',
+ },
+ 'mt-MT': {
+ nativeName: 'Malti',
+ },
+ 'my': {
+ nativeName: 'ဗမာစကာ',
+ },
+ 'no': {
+ nativeName: 'Norsk',
+ },
+ 'nb': {
+ nativeName: 'Norsk (bokmål)',
+ },
+ 'nb-NO': {
+ nativeName: 'Norsk (bokmål)',
+ },
+ 'ne': {
+ nativeName: 'नेपाली',
+ },
+ 'ne-NP': {
+ nativeName: 'नेपाली',
+ },
+ 'nl': {
+ nativeName: 'Nederlands',
+ },
+ 'nl-BE': {
+ nativeName: 'Nederlands (België)',
+ },
+ 'nl-NL': {
+ nativeName: 'Nederlands (Nederland)',
+ },
+ 'nn-NO': {
+ nativeName: 'Norsk (nynorsk)',
+ },
+ 'oc': {
+ nativeName: 'Occitan',
+ },
+ 'or-IN': {
+ nativeName: 'ଓଡ଼ିଆ',
+ },
+ 'pa': {
+ nativeName: 'ਪੰਜਾਬੀ',
+ },
+ 'pa-IN': {
+ nativeName: 'ਪੰਜਾਬੀ (ਭਾਰਤ ਨੂੰ)',
+ },
+ 'pl': {
+ nativeName: 'Polski',
+ },
+ 'pl-PL': {
+ nativeName: 'Polski',
+ },
+ 'ps-AF': {
+ nativeName: 'پښتو',
+ },
+ 'pt': {
+ nativeName: 'Português',
+ },
+ 'pt-BR': {
+ nativeName: 'Português (Brasil)',
+ },
+ 'pt-PT': {
+ nativeName: 'Português (Portugal)',
+ },
+ 'qu-PE': {
+ nativeName: 'Qhichwa',
+ },
+ 'rm-CH': {
+ nativeName: 'Rumantsch',
+ },
+ 'ro': {
+ nativeName: 'Română',
+ },
+ 'ro-RO': {
+ nativeName: 'Română',
+ },
+ 'ru': {
+ nativeName: 'Русский',
+ },
+ 'ru-RU': {
+ nativeName: 'Русский',
+ },
+ 'sa-IN': {
+ nativeName: 'संस्कृतम्',
+ },
+ 'se-NO': {
+ nativeName: 'Davvisámegiella',
+ },
+ 'sh': {
+ nativeName: 'српскохрватски',
+ },
+ 'si-LK': {
+ nativeName: 'සිංහල',
+ },
+ 'sk': {
+ nativeName: 'Slovenčina',
+ },
+ 'sk-SK': {
+ nativeName: 'Slovenčina (Slovakia)',
+ },
+ 'sl': {
+ nativeName: 'Slovenščina',
+ },
+ 'sl-SI': {
+ nativeName: 'Slovenščina',
+ },
+ 'so-SO': {
+ nativeName: 'Soomaaliga',
+ },
+ 'sq': {
+ nativeName: 'Shqip',
+ },
+ 'sq-AL': {
+ nativeName: 'Shqip',
+ },
+ 'sr': {
+ nativeName: 'Српски',
+ },
+ 'sr-RS': {
+ nativeName: 'Српски (Serbia)',
+ },
+ 'su': {
+ nativeName: 'Basa Sunda',
+ },
+ 'sv': {
+ nativeName: 'Svenska',
+ },
+ 'sv-SE': {
+ nativeName: 'Svenska',
+ },
+ 'sw': {
+ nativeName: 'Kiswahili',
+ },
+ 'sw-KE': {
+ nativeName: 'Kiswahili',
+ },
+ 'ta': {
+ nativeName: 'தமிழ்',
+ },
+ 'ta-IN': {
+ nativeName: 'தமிழ்',
+ },
+ 'te': {
+ nativeName: 'తెలుగు',
+ },
+ 'te-IN': {
+ nativeName: 'తెలుగు',
+ },
+ 'tg': {
+ nativeName: 'забо́ни тоҷикӣ́',
+ },
+ 'tg-TJ': {
+ nativeName: 'тоҷикӣ',
+ },
+ 'th': {
+ nativeName: 'ภาษาไทย',
+ },
+ 'th-TH': {
+ nativeName: 'ภาษาไทย (ประเทศไทย)',
+ },
+ 'fil': {
+ nativeName: 'Filipino',
+ },
+ 'tlh': {
+ nativeName: 'tlhIngan-Hol',
+ },
+ 'tr': {
+ nativeName: 'Türkçe',
+ },
+ 'tr-TR': {
+ nativeName: 'Türkçe',
+ },
+ 'tt-RU': {
+ nativeName: 'татарча',
+ },
+ 'uk': {
+ nativeName: 'Українська',
+ },
+ 'uk-UA': {
+ nativeName: 'Українська',
+ },
+ 'ur': {
+ nativeName: 'اردو',
+ },
+ 'ur-PK': {
+ nativeName: 'اردو',
+ },
+ 'uz': {
+ nativeName: 'O\'zbek',
+ },
+ 'uz-UZ': {
+ nativeName: 'O\'zbek',
+ },
+ 'vi': {
+ nativeName: 'Tiếng Việt',
+ },
+ 'vi-VN': {
+ nativeName: 'Tiếng Việt',
+ },
+ 'xh-ZA': {
+ nativeName: 'isiXhosa',
+ },
+ 'yi': {
+ nativeName: 'ייִדיש',
+ },
+ 'yi-DE': {
+ nativeName: 'ייִדיש (German)',
+ },
+ 'zh': {
+ nativeName: '中文',
+ },
+ 'zh-Hans': {
+ nativeName: '中文简体',
+ },
+ 'zh-Hant': {
+ nativeName: '中文繁體',
+ },
+ 'zh-CN': {
+ nativeName: '中文(中国大陆)',
+ },
+ 'zh-HK': {
+ nativeName: '中文(香港)',
+ },
+ 'zh-SG': {
+ nativeName: '中文(新加坡)',
+ },
+ 'zh-TW': {
+ nativeName: '中文(台灣)',
+ },
+ 'zu-ZA': {
+ nativeName: 'isiZulu',
+ },
+};
diff --git a/packages/frontend/src/utility/login-id.ts b/packages/frontend/src/utility/login-id.ts
new file mode 100644
index 0000000000..b52735caa0
--- /dev/null
+++ b/packages/frontend/src/utility/login-id.ts
@@ -0,0 +1,16 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+export function getUrlWithLoginId(url: string, loginId: string) {
+ const u = new URL(url, origin);
+ u.searchParams.append('loginId', loginId);
+ return u.toString();
+}
+
+export function getUrlWithoutLoginId(url: string) {
+ const u = new URL(url);
+ u.searchParams.delete('loginId');
+ return u.toString();
+}
diff --git a/packages/frontend/src/utility/lookup.ts b/packages/frontend/src/utility/lookup.ts
new file mode 100644
index 0000000000..d3a2d854a0
--- /dev/null
+++ b/packages/frontend/src/utility/lookup.ts
@@ -0,0 +1,84 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import * as os from '@/os.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
+import { i18n } from '@/i18n.js';
+import { Router } from '@/nirax.js';
+import { mainRouter } from '@/router/main.js';
+
+export async function lookup(router?: Router) {
+ const _router = router ?? mainRouter;
+
+ const { canceled, result: temp } = await os.inputText({
+ title: i18n.ts.lookup,
+ });
+ const query = temp ? temp.trim() : '';
+ if (canceled || query.length <= 1) return;
+
+ if (query.startsWith('@') && !query.includes(' ')) {
+ _router.push(`/${query}`);
+ return;
+ }
+
+ if (query.startsWith('#')) {
+ _router.push(`/tags/${encodeURIComponent(query.substring(1))}`);
+ return;
+ }
+
+ if (query.startsWith('https://')) {
+ const res = await apLookup(query);
+
+ if (res.type === 'User') {
+ _router.push(`/@${res.object.username}@${res.object.host}`);
+ } else if (res.type === 'Note') {
+ _router.push(`/notes/${res.object.id}`);
+ }
+
+ return;
+ }
+}
+
+export async function apLookup(query: string) {
+ const promise = misskeyApi('ap/show', {
+ uri: query,
+ });
+
+ os.promiseDialog(promise, null, (err) => {
+ let title = i18n.ts.somethingHappened;
+ let text = err.message + '\n' + err.id;
+
+ switch (err.id) {
+ case '974b799e-1a29-4889-b706-18d4dd93e266':
+ title = i18n.ts._remoteLookupErrors._federationNotAllowed.title;
+ text = i18n.ts._remoteLookupErrors._federationNotAllowed.description;
+ break;
+ case '1a5eab56-e47b-48c2-8d5e-217b897d70db':
+ title = i18n.ts._remoteLookupErrors._uriInvalid.title;
+ text = i18n.ts._remoteLookupErrors._uriInvalid.description;
+ break;
+ case '81b539cf-4f57-4b29-bc98-032c33c0792e':
+ title = i18n.ts._remoteLookupErrors._requestFailed.title;
+ text = i18n.ts._remoteLookupErrors._requestFailed.description;
+ break;
+ case '70193c39-54f3-4813-82f0-70a680f7495b':
+ title = i18n.ts._remoteLookupErrors._responseInvalid.title;
+ text = i18n.ts._remoteLookupErrors._responseInvalid.description;
+ break;
+ case 'dc94d745-1262-4e63-a17d-fecaa57efc82':
+ title = i18n.ts._remoteLookupErrors._noSuchObject.title;
+ text = i18n.ts._remoteLookupErrors._noSuchObject.description;
+ break;
+ }
+
+ os.alert({
+ type: 'error',
+ title,
+ text,
+ });
+ }, i18n.ts.fetchingAsApObject);
+
+ return await promise;
+}
diff --git a/packages/frontend/src/utility/media-has-audio.ts b/packages/frontend/src/utility/media-has-audio.ts
new file mode 100644
index 0000000000..4bf3ee5d97
--- /dev/null
+++ b/packages/frontend/src/utility/media-has-audio.ts
@@ -0,0 +1,14 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+export default async function hasAudio(media: HTMLMediaElement) {
+ const cloned = media.cloneNode() as HTMLMediaElement;
+ cloned.muted = (cloned as typeof cloned & Partial).playsInline = true;
+ cloned.play();
+ await new Promise((resolve) => cloned.addEventListener('playing', resolve));
+ const result = !!(cloned as any).audioTracks?.length || (cloned as any).mozHasAudio || !!(cloned as any).webkitAudioDecodedByteCount;
+ cloned.remove();
+ return result;
+}
diff --git a/packages/frontend/src/utility/media-proxy.ts b/packages/frontend/src/utility/media-proxy.ts
new file mode 100644
index 0000000000..78eba35ead
--- /dev/null
+++ b/packages/frontend/src/utility/media-proxy.ts
@@ -0,0 +1,34 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { MediaProxy } from '@@/js/media-proxy.js';
+import { url } from '@@/js/config.js';
+import { instance } from '@/instance.js';
+
+let _mediaProxy: MediaProxy | null = null;
+
+export function getProxiedImageUrl(...args: Parameters): string {
+ if (_mediaProxy == null) {
+ _mediaProxy = new MediaProxy(instance, url);
+ }
+
+ return _mediaProxy.getProxiedImageUrl(...args);
+}
+
+export function getProxiedImageUrlNullable(...args: Parameters): string | null {
+ if (_mediaProxy == null) {
+ _mediaProxy = new MediaProxy(instance, url);
+ }
+
+ return _mediaProxy.getProxiedImageUrlNullable(...args);
+}
+
+export function getStaticImageUrl(...args: Parameters): string {
+ if (_mediaProxy == null) {
+ _mediaProxy = new MediaProxy(instance, url);
+ }
+
+ return _mediaProxy.getStaticImageUrl(...args);
+}
diff --git a/packages/frontend/src/utility/merge.ts b/packages/frontend/src/utility/merge.ts
new file mode 100644
index 0000000000..004b6d42a4
--- /dev/null
+++ b/packages/frontend/src/utility/merge.ts
@@ -0,0 +1,35 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { deepClone } from './clone.js';
+import type { Cloneable } from './clone.js';
+
+export type DeepPartial = {
+ [P in keyof T]?: T[P] extends Record ? DeepPartial : T[P];
+};
+
+function isPureObject(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
+}
+
+/**
+ * valueにないキーをdefからもらう(再帰的)\
+ * nullはそのまま、undefinedはdefの値
+ **/
+export function deepMerge>(value: DeepPartial, def: X): X {
+ if (isPureObject(value) && isPureObject(def)) {
+ const result = deepClone(value as Cloneable) as X;
+ for (const [k, v] of Object.entries(def) as [keyof X, X[keyof X]][]) {
+ if (!Object.prototype.hasOwnProperty.call(value, k) || value[k] === undefined) {
+ result[k] = v;
+ } else if (isPureObject(v) && isPureObject(result[k])) {
+ const child = deepClone(result[k] as Cloneable) as DeepPartial>;
+ result[k] = deepMerge(child, v);
+ }
+ }
+ return result;
+ }
+ throw new Error('deepMerge: value and def must be pure objects');
+}
diff --git a/packages/frontend/src/utility/mfm-function-picker.ts b/packages/frontend/src/utility/mfm-function-picker.ts
new file mode 100644
index 0000000000..a2f777f623
--- /dev/null
+++ b/packages/frontend/src/utility/mfm-function-picker.ts
@@ -0,0 +1,57 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { nextTick } from 'vue';
+import type { Ref } from 'vue';
+import * as os from '@/os.js';
+import { i18n } from '@/i18n.js';
+import { MFM_TAGS } from '@@/js/const.js';
+import type { MenuItem } from '@/types/menu.js';
+
+/**
+ * MFMの装飾のリストを表示する
+ */
+export function mfmFunctionPicker(src: HTMLElement | EventTarget | null, textArea: HTMLInputElement | HTMLTextAreaElement, textRef: Ref) {
+ os.popupMenu([{
+ text: i18n.ts.addMfmFunction,
+ type: 'label',
+ }, ...getFunctionList(textArea, textRef)], src);
+}
+
+function getFunctionList(textArea: HTMLInputElement | HTMLTextAreaElement, textRef: Ref): MenuItem[] {
+ return MFM_TAGS.map(tag => ({
+ text: tag,
+ icon: 'ti ti-icons',
+ action: () => add(textArea, textRef, tag),
+ }));
+}
+
+function add(textArea: HTMLInputElement | HTMLTextAreaElement, textRef: Ref, type: string) {
+ const caretStart: number = textArea.selectionStart as number;
+ const caretEnd: number = textArea.selectionEnd as number;
+
+ MFM_TAGS.forEach(tag => {
+ if (type === tag) {
+ if (caretStart === caretEnd) {
+ // 単純にFunctionを追加
+ const trimmedText = `${textRef.value.substring(0, caretStart)}$[${type} ]${textRef.value.substring(caretEnd)}`;
+ textRef.value = trimmedText;
+ } else {
+ // 選択範囲を囲むようにFunctionを追加
+ const trimmedText = `${textRef.value.substring(0, caretStart)}$[${type} ${textRef.value.substring(caretStart, caretEnd)}]${textRef.value.substring(caretEnd)}`;
+ textRef.value = trimmedText;
+ }
+ }
+ });
+
+ const nextCaretStart: number = caretStart + 3 + type.length;
+ const nextCaretEnd: number = caretEnd + 3 + type.length;
+
+ // キャレットを戻す
+ nextTick(() => {
+ textArea.focus();
+ textArea.setSelectionRange(nextCaretStart, nextCaretEnd);
+ });
+}
diff --git a/packages/frontend/src/utility/misskey-api.ts b/packages/frontend/src/utility/misskey-api.ts
new file mode 100644
index 0000000000..dc07ad477b
--- /dev/null
+++ b/packages/frontend/src/utility/misskey-api.ts
@@ -0,0 +1,116 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import * as Misskey from 'misskey-js';
+import { ref } from 'vue';
+import { apiUrl } from '@@/js/config.js';
+import { $i } from '@/account.js';
+export const pendingApiRequestsCount = ref(0);
+
+export type Endpoint = keyof Misskey.Endpoints;
+
+export type Request = Misskey.Endpoints[E]['req'];
+
+export type AnyRequest =
+ (E extends Endpoint ? Request : never) | object;
+
+export type Response> =
+ E extends Endpoint
+ ? P extends Request ? Misskey.api.SwitchCaseResponseType : never
+ : object;
+
+// Implements Misskey.api.ApiClient.request
+export function misskeyApi<
+ ResT = void,
+ E extends Endpoint | NonNullable = Endpoint,
+ P extends AnyRequest = E extends Endpoint ? Request : never,
+ _ResT = ResT extends void ? Response : ResT,
+>(
+ endpoint: E,
+ data: P & { i?: string | null; } = {} as any,
+ token?: string | null | undefined,
+ signal?: AbortSignal,
+): Promise<_ResT> {
+ if (endpoint.includes('://')) throw new Error('invalid endpoint');
+ pendingApiRequestsCount.value++;
+
+ const onFinally = () => {
+ pendingApiRequestsCount.value--;
+ };
+
+ const promise = new Promise<_ResT>((resolve, reject) => {
+ // Append a credential
+ if ($i) data.i = $i.token;
+ if (token !== undefined) data.i = token;
+
+ // Send request
+ window.fetch(`${apiUrl}/${endpoint}`, {
+ method: 'POST',
+ body: JSON.stringify(data),
+ credentials: 'omit',
+ cache: 'no-cache',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ signal,
+ }).then(async (res) => {
+ const body = res.status === 204 ? null : await res.json();
+
+ if (res.status === 200) {
+ resolve(body);
+ } else if (res.status === 204) {
+ resolve(undefined as _ResT); // void -> undefined
+ } else {
+ reject(body.error);
+ }
+ }).catch(reject);
+ });
+
+ promise.then(onFinally, onFinally);
+
+ return promise;
+}
+
+// Implements Misskey.api.ApiClient.request
+export function misskeyApiGet<
+ ResT = void,
+ E extends keyof Misskey.Endpoints = keyof Misskey.Endpoints,
+ P extends Misskey.Endpoints[E]['req'] = Misskey.Endpoints[E]['req'],
+ _ResT = ResT extends void ? Misskey.api.SwitchCaseResponseType : ResT,
+>(
+ endpoint: E,
+ data: P = {} as any,
+): Promise<_ResT> {
+ pendingApiRequestsCount.value++;
+
+ const onFinally = () => {
+ pendingApiRequestsCount.value--;
+ };
+
+ const query = new URLSearchParams(data as any);
+
+ const promise = new Promise<_ResT>((resolve, reject) => {
+ // Send request
+ window.fetch(`${apiUrl}/${endpoint}?${query}`, {
+ method: 'GET',
+ credentials: 'omit',
+ cache: 'default',
+ }).then(async (res) => {
+ const body = res.status === 204 ? null : await res.json();
+
+ if (res.status === 200) {
+ resolve(body);
+ } else if (res.status === 204) {
+ resolve(undefined as _ResT); // void -> undefined
+ } else {
+ reject(body.error);
+ }
+ }).catch(reject);
+ });
+
+ promise.then(onFinally, onFinally);
+
+ return promise;
+}
diff --git a/packages/frontend/src/utility/navigator.ts b/packages/frontend/src/utility/navigator.ts
new file mode 100644
index 0000000000..ffc0a457f4
--- /dev/null
+++ b/packages/frontend/src/utility/navigator.ts
@@ -0,0 +1,8 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+export function isSupportShare(): boolean {
+ return 'share' in navigator;
+}
diff --git a/packages/frontend/src/utility/page-metadata.ts b/packages/frontend/src/utility/page-metadata.ts
new file mode 100644
index 0000000000..671751147c
--- /dev/null
+++ b/packages/frontend/src/utility/page-metadata.ts
@@ -0,0 +1,71 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import * as Misskey from 'misskey-js';
+import { inject, isRef, onActivated, onBeforeUnmount, provide, ref, toValue, watch } from 'vue';
+import type { MaybeRefOrGetter, Ref } from 'vue';
+
+export type PageMetadata = {
+ title: string;
+ subtitle?: string;
+ icon?: string | null;
+ avatar?: Misskey.entities.User | null;
+ userName?: Misskey.entities.User | null;
+ needWideArea?: boolean;
+};
+
+type PageMetadataGetter = () => PageMetadata;
+type PageMetadataReceiver = (getter: PageMetadataGetter) => void;
+
+const RECEIVER_KEY = Symbol('ReceiverKey');
+const setReceiver = (v: PageMetadataReceiver): void => {
+ provide(RECEIVER_KEY, v);
+};
+const getReceiver = (): PageMetadataReceiver | undefined => {
+ return inject(RECEIVER_KEY);
+};
+
+const METADATA_KEY = Symbol('MetadataKey');
+const setMetadata = (v: Ref): void => {
+ provide][>(METADATA_KEY, v);
+};
+const getMetadata = (): Ref | undefined => {
+ return inject][>(METADATA_KEY);
+};
+
+export const definePageMetadata = (maybeRefOrGetterMetadata: MaybeRefOrGetter): void => {
+ const metadataRef = ref(toValue(maybeRefOrGetterMetadata));
+ const metadataGetter = () => metadataRef.value;
+ const receiver = getReceiver();
+
+ // setup handler
+ receiver?.(metadataGetter);
+
+ // update handler
+ onBeforeUnmount(watch(
+ () => toValue(maybeRefOrGetterMetadata),
+ (metadata) => {
+ metadataRef.value = metadata;
+ receiver?.(metadataGetter);
+ },
+ { deep: true },
+ ));
+ onActivated(() => {
+ receiver?.(metadataGetter);
+ });
+};
+
+export const provideMetadataReceiver = (receiver: PageMetadataReceiver): void => {
+ setReceiver(receiver);
+};
+
+export const provideReactiveMetadata = (metadataRef: Ref): void => {
+ setMetadata(metadataRef);
+};
+
+export const injectReactiveMetadata = (): Ref => {
+ const metadataRef = getMetadata();
+ return isRef(metadataRef) ? metadataRef : ref(null);
+};
diff --git a/packages/frontend/src/utility/physics.ts b/packages/frontend/src/utility/physics.ts
new file mode 100644
index 0000000000..8a4e9319b3
--- /dev/null
+++ b/packages/frontend/src/utility/physics.ts
@@ -0,0 +1,157 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import * as Matter from 'matter-js';
+
+export function physics(container: HTMLElement) {
+ const containerWidth = container.offsetWidth;
+ const containerHeight = container.offsetHeight;
+ const containerCenterX = containerWidth / 2;
+
+ // サイズ固定化(要らないかも?)
+ container.style.position = 'relative';
+ container.style.boxSizing = 'border-box';
+ container.style.width = `${containerWidth}px`;
+ container.style.height = `${containerHeight}px`;
+
+ // create engine
+ const engine = Matter.Engine.create({
+ constraintIterations: 4,
+ positionIterations: 8,
+ velocityIterations: 8,
+ });
+
+ const world = engine.world;
+
+ // create renderer
+ const render = Matter.Render.create({
+ engine: engine,
+ //element: document.getElementById('debug'),
+ options: {
+ width: containerWidth,
+ height: containerHeight,
+ background: 'transparent', // transparent to hide
+ wireframeBackground: 'transparent', // transparent to hide
+ },
+ });
+
+ // Disable to hide debug
+ Matter.Render.run(render);
+
+ // create runner
+ const runner = Matter.Runner.create();
+ Matter.Runner.run(runner, engine);
+
+ const groundThickness = 1024;
+ const ground = Matter.Bodies.rectangle(containerCenterX, containerHeight + (groundThickness / 2), containerWidth, groundThickness, {
+ isStatic: true,
+ restitution: 0.1,
+ friction: 2,
+ });
+
+ //const wallRight = Matter.Bodies.rectangle(window.innerWidth+50, window.innerHeight/2, 100, window.innerHeight, wallopts);
+ //const wallLeft = Matter.Bodies.rectangle(-50, window.innerHeight/2, 100, window.innerHeight, wallopts);
+
+ Matter.World.add(world, [
+ ground,
+ //wallRight,
+ //wallLeft,
+ ]);
+
+ const objEls = Array.from(container.children) as HTMLElement[];
+ const objs: Matter.Body[] = [];
+ for (const objEl of objEls) {
+ const left = objEl.dataset.physicsX ? parseInt(objEl.dataset.physicsX) : objEl.offsetLeft;
+ const top = objEl.dataset.physicsY ? parseInt(objEl.dataset.physicsY) : objEl.offsetTop;
+
+ let obj: Matter.Body;
+ if (objEl.classList.contains('_physics_circle_')) {
+ obj = Matter.Bodies.circle(
+ left + (objEl.offsetWidth / 2),
+ top + (objEl.offsetHeight / 2),
+ Math.max(objEl.offsetWidth, objEl.offsetHeight) / 2,
+ {
+ restitution: 0.5,
+ },
+ );
+ } else {
+ const style = window.getComputedStyle(objEl);
+ obj = Matter.Bodies.rectangle(
+ left + (objEl.offsetWidth / 2),
+ top + (objEl.offsetHeight / 2),
+ objEl.offsetWidth,
+ objEl.offsetHeight,
+ {
+ chamfer: { radius: parseInt(style.borderRadius || '0', 10) },
+ restitution: 0.5,
+ },
+ );
+ }
+ objEl.id = obj.id.toString();
+ objs.push(obj);
+ }
+
+ Matter.World.add(engine.world, objs);
+
+ // Add mouse control
+
+ const mouse = Matter.Mouse.create(container);
+ const mouseConstraint = Matter.MouseConstraint.create(engine, {
+ mouse: mouse,
+ constraint: {
+ stiffness: 0.1,
+ render: {
+ visible: false,
+ },
+ },
+ });
+
+ Matter.World.add(engine.world, mouseConstraint);
+
+ // keep the mouse in sync with rendering
+ render.mouse = mouse;
+
+ for (const objEl of objEls) {
+ objEl.style.position = 'absolute';
+ objEl.style.top = '0';
+ objEl.style.left = '0';
+ objEl.style.margin = '0';
+ }
+
+ window.requestAnimationFrame(update);
+
+ let stop = false;
+
+ function update() {
+ for (const objEl of objEls) {
+ const obj = objs.find(obj => obj.id.toString() === objEl.id.toString());
+ if (obj == null) continue;
+
+ const x = (obj.position.x - objEl.offsetWidth / 2);
+ const y = (obj.position.y - objEl.offsetHeight / 2);
+ const angle = obj.angle;
+ objEl.style.transform = `translate(${x}px, ${y}px) rotate(${angle}rad)`;
+ }
+
+ if (!stop) {
+ window.requestAnimationFrame(update);
+ }
+ }
+
+ // 奈落に落ちたオブジェクトは消す
+ const intervalId = window.setInterval(() => {
+ for (const obj of objs) {
+ if (obj.position.y > (containerHeight + 1024)) Matter.World.remove(world, obj);
+ }
+ }, 1000 * 10);
+
+ return {
+ stop: () => {
+ stop = true;
+ Matter.Runner.stop(runner);
+ window.clearInterval(intervalId);
+ },
+ };
+}
diff --git a/packages/frontend/src/utility/player-url-transform.ts b/packages/frontend/src/utility/player-url-transform.ts
new file mode 100644
index 0000000000..39c6df6500
--- /dev/null
+++ b/packages/frontend/src/utility/player-url-transform.ts
@@ -0,0 +1,26 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+import { hostname } from '@@/js/config.js';
+
+export function transformPlayerUrl(url: string): string {
+ const urlObj = new URL(url);
+ if (!['https:', 'http:'].includes(urlObj.protocol)) throw new Error('Invalid protocol');
+
+ const urlParams = new URLSearchParams(urlObj.search);
+
+ if (urlObj.hostname === 'player.twitch.tv') {
+ // TwitchはCSPの制約あり
+ // https://dev.twitch.tv/docs/embed/video-and-clips/
+ urlParams.set('parent', hostname);
+ urlParams.set('allowfullscreen', '');
+ urlParams.set('autoplay', 'true');
+ } else {
+ urlParams.set('autoplay', '1');
+ urlParams.set('auto_play', '1');
+ }
+ urlObj.search = urlParams.toString();
+
+ return urlObj.toString();
+}
diff --git a/packages/frontend/src/utility/please-login.ts b/packages/frontend/src/utility/please-login.ts
new file mode 100644
index 0000000000..a8a330eb6d
--- /dev/null
+++ b/packages/frontend/src/utility/please-login.ts
@@ -0,0 +1,76 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { defineAsyncComponent } from 'vue';
+import { $i } from '@/account.js';
+import { instance } from '@/instance.js';
+import { i18n } from '@/i18n.js';
+import { popup } from '@/os.js';
+
+export type OpenOnRemoteOptions = {
+ /**
+ * 外部のMisskey Webで特定のパスを開く
+ */
+ type: 'web';
+
+ /**
+ * 内部パス(例: `/settings`)
+ */
+ path: string;
+} | {
+ /**
+ * 外部のMisskey Webで照会する
+ */
+ type: 'lookup';
+
+ /**
+ * 照会したいエンティティのURL
+ *
+ * (例: `https://misskey.example.com/notes/abcdexxxxyz`)
+ */
+ url: string;
+} | {
+ /**
+ * 外部のMisskeyでノートする
+ */
+ type: 'share';
+
+ /**
+ * `/share` ページに渡すクエリストリング
+ *
+ * @see https://go.misskey-hub.net/spec/share/
+ */
+ params: Record;
+};
+
+export function pleaseLogin(opts: {
+ path?: string;
+ message?: string;
+ openOnRemote?: OpenOnRemoteOptions;
+} = {}) {
+ if ($i) return;
+
+ let _openOnRemote: OpenOnRemoteOptions | undefined = undefined;
+
+ // 連合できる場合と、(連合ができなくても)共有する場合は外部連携オプションを設定
+ if (opts.openOnRemote != null && (instance.federation !== 'none' || opts.openOnRemote.type === 'share')) {
+ _openOnRemote = opts.openOnRemote;
+ }
+
+ const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkSigninDialog.vue')), {
+ autoSet: true,
+ message: opts.message ?? (_openOnRemote ? i18n.ts.signinOrContinueOnRemote : i18n.ts.signinRequired),
+ openOnRemote: _openOnRemote,
+ }, {
+ cancelled: () => {
+ if (opts.path) {
+ window.location.href = opts.path;
+ }
+ },
+ closed: () => dispose(),
+ });
+
+ throw new Error('signin required');
+}
diff --git a/packages/frontend/src/utility/popout.ts b/packages/frontend/src/utility/popout.ts
new file mode 100644
index 0000000000..5b141222e8
--- /dev/null
+++ b/packages/frontend/src/utility/popout.ts
@@ -0,0 +1,28 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { appendQuery } from '@@/js/url.js';
+import * as config from '@@/js/config.js';
+
+export function popout(path: string, w?: HTMLElement) {
+ let url = path.startsWith('http://') || path.startsWith('https://') ? path : config.url + path;
+ url = appendQuery(url, 'zen');
+ if (w) {
+ const position = w.getBoundingClientRect();
+ const width = parseInt(getComputedStyle(w, '').width, 10);
+ const height = parseInt(getComputedStyle(w, '').height, 10);
+ const x = window.screenX + position.left;
+ const y = window.screenY + position.top;
+ window.open(url, url,
+ `width=${width}, height=${height}, top=${y}, left=${x}`);
+ } else {
+ const width = 400;
+ const height = 500;
+ const x = window.top.outerHeight / 2 + window.top.screenY - (height / 2);
+ const y = window.top.outerWidth / 2 + window.top.screenX - (width / 2);
+ window.open(url, url,
+ `width=${width}, height=${height}, top=${x}, left=${y}`);
+ }
+}
diff --git a/packages/frontend/src/utility/popup-position.ts b/packages/frontend/src/utility/popup-position.ts
new file mode 100644
index 0000000000..3dad41a8b3
--- /dev/null
+++ b/packages/frontend/src/utility/popup-position.ts
@@ -0,0 +1,161 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+export function calcPopupPosition(el: HTMLElement, props: {
+ anchorElement?: HTMLElement | null;
+ innerMargin: number;
+ direction: 'top' | 'bottom' | 'left' | 'right';
+ align: 'top' | 'bottom' | 'left' | 'right' | 'center';
+ alignOffset?: number;
+ x?: number;
+ y?: number;
+}): { top: number; left: number; transformOrigin: string; } {
+ const contentWidth = el.offsetWidth;
+ const contentHeight = el.offsetHeight;
+
+ let rect: DOMRect;
+
+ if (props.anchorElement) {
+ rect = props.anchorElement.getBoundingClientRect();
+ }
+
+ const calcPosWhenTop = () => {
+ let left: number;
+ let top: number;
+
+ if (props.anchorElement) {
+ left = rect.left + window.scrollX + (props.anchorElement.offsetWidth / 2);
+ top = (rect.top + window.scrollY - contentHeight) - props.innerMargin;
+ } else {
+ left = props.x;
+ top = (props.y - contentHeight) - props.innerMargin;
+ }
+
+ left -= (el.offsetWidth / 2);
+
+ if (left + contentWidth - window.scrollX > window.innerWidth) {
+ left = window.innerWidth - contentWidth + window.scrollX - 1;
+ }
+
+ return [left, top];
+ };
+
+ const calcPosWhenBottom = () => {
+ let left: number;
+ let top: number;
+
+ if (props.anchorElement) {
+ left = rect.left + window.scrollX + (props.anchorElement.offsetWidth / 2);
+ top = (rect.top + window.scrollY + props.anchorElement.offsetHeight) + props.innerMargin;
+ } else {
+ left = props.x;
+ top = (props.y) + props.innerMargin;
+ }
+
+ left -= (el.offsetWidth / 2);
+
+ if (left + contentWidth - window.scrollX > window.innerWidth) {
+ left = window.innerWidth - contentWidth + window.scrollX - 1;
+ }
+
+ return [left, top];
+ };
+
+ const calcPosWhenLeft = () => {
+ let left: number;
+ let top: number;
+
+ if (props.anchorElement) {
+ left = (rect.left + window.scrollX - contentWidth) - props.innerMargin;
+ top = rect.top + window.scrollY + (props.anchorElement.offsetHeight / 2);
+ } else {
+ left = (props.x - contentWidth) - props.innerMargin;
+ top = props.y;
+ }
+
+ top -= (el.offsetHeight / 2);
+
+ if (top + contentHeight - window.scrollY > window.innerHeight) {
+ top = window.innerHeight - contentHeight + window.scrollY - 1;
+ }
+
+ return [left, top];
+ };
+
+ const calcPosWhenRight = () => {
+ let left: number;
+ let top: number;
+
+ if (props.anchorElement) {
+ left = (rect.left + props.anchorElement.offsetWidth + window.scrollX) + props.innerMargin;
+
+ if (props.align === 'top') {
+ top = rect.top + window.scrollY;
+ if (props.alignOffset != null) top += props.alignOffset;
+ } else if (props.align === 'bottom') {
+ // TODO
+ } else { // center
+ top = rect.top + window.scrollY + (props.anchorElement.offsetHeight / 2);
+ top -= (el.offsetHeight / 2);
+ }
+ } else {
+ left = props.x + props.innerMargin;
+ top = props.y;
+ top -= (el.offsetHeight / 2);
+ }
+
+ if (top + contentHeight - window.scrollY > window.innerHeight) {
+ top = window.innerHeight - contentHeight + window.scrollY - 1;
+ }
+
+ return [left, top];
+ };
+
+ const calc = (): {
+ left: number;
+ top: number;
+ transformOrigin: string;
+ } => {
+ switch (props.direction) {
+ case 'top': {
+ const [left, top] = calcPosWhenTop();
+
+ // ツールチップを上に向かって表示するスペースがなければ下に向かって出す
+ if (top - window.scrollY < 0) {
+ const [left, top] = calcPosWhenBottom();
+ return { left, top, transformOrigin: 'center top' };
+ }
+
+ return { left, top, transformOrigin: 'center bottom' };
+ }
+
+ case 'bottom': {
+ const [left, top] = calcPosWhenBottom();
+ // TODO: ツールチップを下に向かって表示するスペースがなければ上に向かって出す
+ return { left, top, transformOrigin: 'center top' };
+ }
+
+ case 'left': {
+ const [left, top] = calcPosWhenLeft();
+
+ // ツールチップを左に向かって表示するスペースがなければ右に向かって出す
+ if (left - window.scrollX < 0) {
+ const [left, top] = calcPosWhenRight();
+ return { left, top, transformOrigin: 'left center' };
+ }
+
+ return { left, top, transformOrigin: 'right center' };
+ }
+
+ case 'right': {
+ const [left, top] = calcPosWhenRight();
+ // TODO: ツールチップを右に向かって表示するスペースがなければ左に向かって出す
+ return { left, top, transformOrigin: 'left center' };
+ }
+ }
+ };
+
+ return calc();
+}
diff --git a/packages/frontend/src/utility/post-message.ts b/packages/frontend/src/utility/post-message.ts
new file mode 100644
index 0000000000..11b6f52ddd
--- /dev/null
+++ b/packages/frontend/src/utility/post-message.ts
@@ -0,0 +1,25 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+export const postMessageEventTypes = [
+ 'misskey:shareForm:shareCompleted',
+] as const;
+
+export type PostMessageEventType = typeof postMessageEventTypes[number];
+
+export type MiPostMessageEvent = {
+ type: PostMessageEventType;
+ payload?: any;
+};
+
+/**
+ * 親フレームにイベントを送信
+ */
+export function postMessageToParentWindow(type: PostMessageEventType, payload?: any): void {
+ window.parent.postMessage({
+ type,
+ payload,
+ }, '*');
+}
diff --git a/packages/frontend/src/utility/reaction-picker.ts b/packages/frontend/src/utility/reaction-picker.ts
new file mode 100644
index 0000000000..81f6c02dcf
--- /dev/null
+++ b/packages/frontend/src/utility/reaction-picker.ts
@@ -0,0 +1,54 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import * as Misskey from 'misskey-js';
+import { defineAsyncComponent, ref } from 'vue';
+import type { Ref } from 'vue';
+import { popup } from '@/os.js';
+import { store } from '@/store.js';
+
+class ReactionPicker {
+ private src: Ref = ref(null);
+ private manualShowing = ref(false);
+ private targetNote: Ref = ref(null);
+ private onChosen?: (reaction: string) => void;
+ private onClosed?: () => void;
+
+ constructor() {
+ // nop
+ }
+
+ public async init() {
+ const reactionsRef = store.reactiveState.reactions;
+ await popup(defineAsyncComponent(() => import('@/components/MkEmojiPickerDialog.vue')), {
+ src: this.src,
+ pinnedEmojis: reactionsRef,
+ asReactionPicker: true,
+ targetNote: this.targetNote,
+ manualShowing: this.manualShowing,
+ }, {
+ done: reaction => {
+ if (this.onChosen) this.onChosen(reaction);
+ },
+ close: () => {
+ this.manualShowing.value = false;
+ },
+ closed: () => {
+ this.src.value = null;
+ if (this.onClosed) this.onClosed();
+ },
+ });
+ }
+
+ public show(src: HTMLElement | null, targetNote: Misskey.entities.Note | null, onChosen?: ReactionPicker['onChosen'], onClosed?: ReactionPicker['onClosed']) {
+ this.src.value = src;
+ this.targetNote.value = targetNote;
+ this.manualShowing.value = true;
+ this.onChosen = onChosen;
+ this.onClosed = onClosed;
+ }
+}
+
+export const reactionPicker = new ReactionPicker();
diff --git a/packages/frontend/src/utility/reload-ask.ts b/packages/frontend/src/utility/reload-ask.ts
new file mode 100644
index 0000000000..057f57471a
--- /dev/null
+++ b/packages/frontend/src/utility/reload-ask.ts
@@ -0,0 +1,40 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { i18n } from '@/i18n.js';
+import * as os from '@/os.js';
+import { unisonReload } from '@/utility/unison-reload.js';
+
+let isReloadConfirming = false;
+
+export async function reloadAsk(opts: {
+ unison?: boolean;
+ reason?: string;
+}) {
+ if (isReloadConfirming) {
+ return;
+ }
+
+ isReloadConfirming = true;
+
+ const { canceled } = await os.confirm(opts.reason == null ? {
+ type: 'info',
+ text: i18n.ts.reloadConfirm,
+ } : {
+ type: 'info',
+ title: i18n.ts.reloadConfirm,
+ text: opts.reason,
+ }).finally(() => {
+ isReloadConfirming = false;
+ });
+
+ if (canceled) return;
+
+ if (opts.unison) {
+ unisonReload();
+ } else {
+ location.reload();
+ }
+}
diff --git a/packages/frontend/src/utility/search-emoji.ts b/packages/frontend/src/utility/search-emoji.ts
new file mode 100644
index 0000000000..371f69b9a7
--- /dev/null
+++ b/packages/frontend/src/utility/search-emoji.ts
@@ -0,0 +1,106 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+export type EmojiDef = {
+ emoji: string;
+ name: string;
+ url: string;
+ aliasOf?: string;
+} | {
+ emoji: string;
+ name: string;
+ aliasOf?: string;
+ isCustomEmoji?: true;
+};
+type EmojiScore = { emoji: EmojiDef, score: number };
+
+export function searchEmoji(query: string | null, emojiDb: EmojiDef[], max = 30): EmojiDef[] {
+ if (!query) {
+ return [];
+ }
+
+ const matched = new Map();
+ // 完全一致(エイリアスなし)
+ emojiDb.some(x => {
+ if (x.name === query && !x.aliasOf) {
+ matched.set(x.name, { emoji: x, score: query.length + 3 });
+ }
+ return matched.size === max;
+ });
+
+ // 完全一致(エイリアス込み)
+ if (matched.size < max) {
+ emojiDb.some(x => {
+ if (x.name === query && !matched.has(x.aliasOf ?? x.name)) {
+ matched.set(x.aliasOf ?? x.name, { emoji: x, score: query.length + 2 });
+ }
+ return matched.size === max;
+ });
+ }
+
+ // 前方一致(エイリアスなし)
+ if (matched.size < max) {
+ emojiDb.some(x => {
+ if (x.name.startsWith(query) && !x.aliasOf && !matched.has(x.name)) {
+ matched.set(x.name, { emoji: x, score: query.length + 1 });
+ }
+ return matched.size === max;
+ });
+ }
+
+ // 前方一致(エイリアス込み)
+ if (matched.size < max) {
+ emojiDb.some(x => {
+ if (x.name.startsWith(query) && !matched.has(x.aliasOf ?? x.name)) {
+ matched.set(x.aliasOf ?? x.name, { emoji: x, score: query.length });
+ }
+ return matched.size === max;
+ });
+ }
+
+ // 部分一致(エイリアス込み)
+ if (matched.size < max) {
+ emojiDb.some(x => {
+ if (x.name.includes(query) && !matched.has(x.aliasOf ?? x.name)) {
+ matched.set(x.aliasOf ?? x.name, { emoji: x, score: query.length - 1 });
+ }
+ return matched.size === max;
+ });
+ }
+
+ // 簡易あいまい検索(3文字以上)
+ if (matched.size < max && query.length > 3) {
+ const queryChars = [...query];
+ const hitEmojis = new Map();
+
+ for (const x of emojiDb) {
+ // 文字列の位置を進めながら、クエリの文字を順番に探す
+
+ let pos = 0;
+ let hit = 0;
+ for (const c of queryChars) {
+ pos = x.name.indexOf(c, pos);
+ if (pos <= -1) break;
+ hit++;
+ }
+
+ // 半分以上の文字が含まれていればヒットとする
+ if (hit > Math.ceil(queryChars.length / 2) && hit - 2 > (matched.get(x.aliasOf ?? x.name)?.score ?? 0)) {
+ hitEmojis.set(x.aliasOf ?? x.name, { emoji: x, score: hit - 2 });
+ }
+ }
+
+ // ヒットしたものを全部追加すると雑多になるので、先頭の6件程度だけにしておく(6件=オートコンプリートのポップアップのサイズ分)
+ [...hitEmojis.values()]
+ .sort((x, y) => y.score - x.score)
+ .slice(0, 6)
+ .forEach(it => matched.set(it.emoji.name, it));
+ }
+
+ return [...matched.values()]
+ .sort((x, y) => y.score - x.score)
+ .slice(0, max)
+ .map(it => it.emoji);
+}
diff --git a/packages/frontend/src/utility/select-file.ts b/packages/frontend/src/utility/select-file.ts
new file mode 100644
index 0000000000..1bee4986f6
--- /dev/null
+++ b/packages/frontend/src/utility/select-file.ts
@@ -0,0 +1,130 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { ref } from 'vue';
+import * as Misskey from 'misskey-js';
+import * as os from '@/os.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
+import { useStream } from '@/stream.js';
+import { i18n } from '@/i18n.js';
+import { uploadFile } from '@/utility/upload.js';
+import { prefer } from '@/preferences.js';
+
+export function chooseFileFromPc(
+ multiple: boolean,
+ options?: {
+ uploadFolder?: string | null;
+ keepOriginal?: boolean;
+ nameConverter?: (file: File) => string | undefined;
+ },
+): Promise {
+ const uploadFolder = options?.uploadFolder ?? prefer.s.uploadFolder;
+ const keepOriginal = options?.keepOriginal ?? prefer.s.keepOriginalUploading;
+ const nameConverter = options?.nameConverter ?? (() => undefined);
+
+ return new Promise((res, rej) => {
+ const input = document.createElement('input');
+ input.type = 'file';
+ input.multiple = multiple;
+ input.onchange = () => {
+ if (!input.files) return res([]);
+ const promises = Array.from(
+ input.files,
+ file => uploadFile(file, uploadFolder, nameConverter(file), keepOriginal),
+ );
+
+ Promise.all(promises).then(driveFiles => {
+ res(driveFiles);
+ }).catch(err => {
+ // アップロードのエラーは uploadFile 内でハンドリングされているためアラートダイアログを出したりはしてはいけない
+ });
+
+ // 一応廃棄
+ (window as any).__misskey_input_ref__ = null;
+ };
+
+ // https://qiita.com/fukasawah/items/b9dc732d95d99551013d
+ // iOS Safari で正常に動かす為のおまじない
+ (window as any).__misskey_input_ref__ = input;
+
+ input.click();
+ });
+}
+
+export function chooseFileFromDrive(multiple: boolean): Promise {
+ return new Promise((res, rej) => {
+ os.selectDriveFile(multiple).then(files => {
+ res(files);
+ });
+ });
+}
+
+export function chooseFileFromUrl(): Promise {
+ return new Promise((res, rej) => {
+ os.inputText({
+ title: i18n.ts.uploadFromUrl,
+ type: 'url',
+ placeholder: i18n.ts.uploadFromUrlDescription,
+ }).then(({ canceled, result: url }) => {
+ if (canceled) return;
+
+ const marker = Math.random().toString(); // TODO: UUIDとか使う
+
+ const connection = useStream().useChannel('main');
+ connection.on('urlUploadFinished', urlResponse => {
+ if (urlResponse.marker === marker) {
+ res(urlResponse.file);
+ connection.dispose();
+ }
+ });
+
+ misskeyApi('drive/files/upload-from-url', {
+ url: url,
+ folderId: prefer.s.uploadFolder,
+ marker,
+ });
+
+ os.alert({
+ title: i18n.ts.uploadFromUrlRequested,
+ text: i18n.ts.uploadFromUrlMayTakeTime,
+ });
+ });
+ });
+}
+
+function select(src: HTMLElement | EventTarget | null, label: string | null, multiple: boolean): Promise {
+ return new Promise((res, rej) => {
+ const keepOriginal = ref(prefer.s.keepOriginalUploading);
+
+ os.popupMenu([label ? {
+ text: label,
+ type: 'label',
+ } : undefined, {
+ type: 'switch',
+ text: i18n.ts.keepOriginalUploading,
+ ref: keepOriginal,
+ }, {
+ text: i18n.ts.upload,
+ icon: 'ti ti-upload',
+ action: () => chooseFileFromPc(multiple, { keepOriginal: keepOriginal.value }).then(files => res(files)),
+ }, {
+ text: i18n.ts.fromDrive,
+ icon: 'ti ti-cloud',
+ action: () => chooseFileFromDrive(multiple).then(files => res(files)),
+ }, {
+ text: i18n.ts.fromUrl,
+ icon: 'ti ti-link',
+ action: () => chooseFileFromUrl().then(file => res([file])),
+ }], src);
+ });
+}
+
+export function selectFile(src: HTMLElement | EventTarget | null, label: string | null = null): Promise {
+ return select(src, label, false).then(files => files[0]);
+}
+
+export function selectFiles(src: HTMLElement | EventTarget | null, label: string | null = null): Promise {
+ return select(src, label, true);
+}
diff --git a/packages/frontend/src/utility/show-moved-dialog.ts b/packages/frontend/src/utility/show-moved-dialog.ts
new file mode 100644
index 0000000000..35b3ef79d8
--- /dev/null
+++ b/packages/frontend/src/utility/show-moved-dialog.ts
@@ -0,0 +1,21 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import * as os from '@/os.js';
+import { $i } from '@/account.js';
+import { i18n } from '@/i18n.js';
+
+export function showMovedDialog() {
+ if (!$i) return;
+ if (!$i.movedTo) return;
+
+ os.alert({
+ type: 'error',
+ title: i18n.ts.accountMovedShort,
+ text: i18n.ts.operationForbidden,
+ });
+
+ throw new Error('account moved');
+}
diff --git a/packages/frontend/src/utility/show-suspended-dialog.ts b/packages/frontend/src/utility/show-suspended-dialog.ts
new file mode 100644
index 0000000000..8b89dbb936
--- /dev/null
+++ b/packages/frontend/src/utility/show-suspended-dialog.ts
@@ -0,0 +1,15 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import * as os from '@/os.js';
+import { i18n } from '@/i18n.js';
+
+export function showSuspendedDialog() {
+ return os.alert({
+ type: 'error',
+ title: i18n.ts.yourAccountSuspendedTitle,
+ text: i18n.ts.yourAccountSuspendedDescription,
+ });
+}
diff --git a/packages/frontend/src/utility/shuffle.ts b/packages/frontend/src/utility/shuffle.ts
new file mode 100644
index 0000000000..1f6ef1928c
--- /dev/null
+++ b/packages/frontend/src/utility/shuffle.ts
@@ -0,0 +1,25 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+/**
+ * 配列をシャッフル (破壊的)
+ */
+export function shuffle(array: T): T {
+ let currentIndex = array.length;
+ let randomIndex: number;
+
+ // While there remain elements to shuffle.
+ while (currentIndex !== 0) {
+ // Pick a remaining element.
+ randomIndex = Math.floor(Math.random() * currentIndex);
+ currentIndex--;
+
+ // And swap it with the current element.
+ [array[currentIndex], array[randomIndex]] = [
+ array[randomIndex], array[currentIndex]];
+ }
+
+ return array;
+}
diff --git a/packages/frontend/src/utility/snowfall-effect.ts b/packages/frontend/src/utility/snowfall-effect.ts
new file mode 100644
index 0000000000..d88bdb6660
--- /dev/null
+++ b/packages/frontend/src/utility/snowfall-effect.ts
@@ -0,0 +1,490 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+export class SnowfallEffect {
+ private VERTEX_SOURCE = `#version 300 es
+ in vec4 a_position;
+ in vec4 a_color;
+ in vec3 a_rotation;
+ in vec3 a_speed;
+ in float a_size;
+ out vec4 v_color;
+ out float v_rotation;
+ uniform float u_time;
+ uniform mat4 u_projection;
+ uniform vec3 u_worldSize;
+ uniform float u_gravity;
+ uniform float u_wind;
+ uniform float u_spin_factor;
+ uniform float u_turbulence;
+
+ void main() {
+ v_color = a_color;
+ v_rotation = a_rotation.x + (u_time * u_spin_factor) * a_rotation.y;
+
+ vec3 pos = a_position.xyz;
+
+ pos.x = mod(pos.x + u_time + u_wind * a_speed.x, u_worldSize.x * 2.0) - u_worldSize.x;
+ pos.y = mod(pos.y - u_time * a_speed.y * u_gravity, u_worldSize.y * 2.0) - u_worldSize.y;
+
+ pos.x += sin(u_time * a_speed.z * u_turbulence) * a_rotation.z;
+ pos.z += cos(u_time * a_speed.z * u_turbulence) * a_rotation.z;
+
+ gl_Position = u_projection * vec4(pos.xyz, a_position.w);
+ gl_PointSize = (a_size / gl_Position.w) * 100.0;
+ }
+ `;
+
+ private FRAGMENT_SOURCE = `#version 300 es
+ precision highp float;
+
+ in vec4 v_color;
+ in float v_rotation;
+ uniform sampler2D u_texture;
+ out vec4 out_color;
+
+ void main() {
+ vec2 rotated = vec2(
+ cos(v_rotation) * (gl_PointCoord.x - 0.5) + sin(v_rotation) * (gl_PointCoord.y - 0.5) + 0.5,
+ cos(v_rotation) * (gl_PointCoord.y - 0.5) - sin(v_rotation) * (gl_PointCoord.x - 0.5) + 0.5
+ );
+
+ vec4 snowflake = texture(u_texture, rotated);
+
+ out_color = vec4(snowflake.rgb * v_color.xyz, snowflake.a * v_color.a);
+ }
+ `;
+
+ private gl: WebGLRenderingContext;
+ private program: WebGLProgram;
+ private canvas: HTMLCanvasElement;
+ private buffers: Record;
+ private uniforms: Record;
+ private texture: WebGLTexture;
+ private camera: {
+ fov: number;
+ near: number;
+ far: number;
+ aspect: number;
+ z: number;
+ };
+ private wind: {
+ current: number;
+ force: number;
+ target: number;
+ min: number;
+ max: number;
+ easing: number;
+ };
+ private time: {
+ start: number;
+ previous: number;
+ } = {
+ start: 0,
+ previous: 0,
+ };
+ private raf = 0;
+
+ private density: number = 1 / 90;
+ private depth = 100;
+ private count = 1000;
+ private gravity = 100;
+ private speed: number = 1 / 10000;
+ private color: number[] = [1, 1, 1];
+ private opacity = 1;
+ private size = 4;
+ private snowflake = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAAXNSR0IArs4c6QAAAAlwSFlzAAALEwAACxMBAJqcGAAAAVlpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IlhNUCBDb3JlIDUuNC4wIj4KICAgPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4KICAgICAgPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIKICAgICAgICAgICAgeG1sbnM6dGlmZj0iaHR0cDovL25zLmFkb2JlLmNvbS90aWZmLzEuMC8iPgogICAgICAgICA8dGlmZjpPcmllbnRhdGlvbj4xPC90aWZmOk9yaWVudGF0aW9uPgogICAgICA8L3JkZjpEZXNjcmlwdGlvbj4KICAgPC9yZGY6UkRGPgo8L3g6eG1wbWV0YT4KTMInWQAAErRJREFUeAHdmgnYlmPax5MShaxRKRElPmXJXpaSsRxDU0bTZ+kt65RloiRDltEMQsxYKmS+zzYjxCCamCzV2LchResMIxFRQ1G93+93Pdf5dL9v7zuf4/hm0fc/jt9znddy3/e1nNd53c/7vHXq/AtVWVnZA/bzkaQjoWG298DeMdvrmP6/EIOqC4fBsbAx7Arz4TaYBPXgWVDnO2jSBrB2T0IMIA9mCmmoE8aonPkR6WPZHlp9xSlfeyeBzq9bHBD5feEdUGfDXBgBqnde+a2wvw/dYdNctvZNAp1PnTaFttA6JgP7eVgBM0CNzgO9HNvy0AcYDda6SaDTdXOnz8X+IkZDugAGQmOYA+ob6Ah/MIOMDRPhJjgJ6uV7pXtWt81/50SnY/Wvwn4ZDHAvwJ9ATYcxyaqsnEnqZCyCPaE80BgYZXG/5A3VyyP/b08LHa11z9KmFUwA5eqruRBHYX1s8WSI1Xcbme8Mt8PWUCU+kF8XbFN+dtH+p06OD4IU8EjD/VOZ5bnezq0XHcHuC2oV7BDlkVIWq56uIX8UjAO31GRIMYW0Vo/xXtSXJyTuXVO6xk1qalRTmQ9AfqzEvog2XYpllnsd6Qr4unCPT7NtByu0uU7vuAaOoy1JuvfXpJdTvSX0gI1gCXwGZdFmEFxoQb7Wid8s7lNu+I8wuHGsTqz2zpQ9DAa5R6HC55A2gvCMXthvwi25bjx26H0M9/9f4Rnok9s0zulFlC2HzzP9cnld8nH/p7DVrbmuIfYs6JLz9U3/z+KGadDeCDsmwre7GyEifn/su8HVSsL2HeBn8CK8AW+B7u9R5yrPgyOjvSn5DWAaXAG2UU7CE9Ayt4k4sR1lX4LaLdd9gn2ftsL+Vtuh1Dp/elH1C8lvCdUj8kDK3gbP8XdhCnSC86rcsNSR9pQvhc/gVlB9bUfqoFNAy/mLrUROrpMwCtpBxBbTtLqkF4K6IF9rf57I9pnYekx5AS0P1VhopXso9pR5buC7+kewU86nFcB+BT4EXdIvNO73sRBubGTXLZtTtgp+DEb++bACdqBuJOlAaMMzLVM3whegNznQDtCb+pW5b8YY76euB5+7pxm0IbzCfS8m3Zf2q4T8/+4JNArXGoptpxz8LqDmQJq0Qnostt/sfIn5GygD4/Zeq7B7wljQO2yjB/QGj0Pjxz4wGdqXrkjXtCT/ISyDa6EPpHrSraFjvnecFpMoMx40Br3xSlD262rYObevddHTs2kYwWUG9uP5It/f1eU5Xw9btwoXPALbwYXcg+unG/KB3Rq8n9ddAOpn4Kr8BAaBcltcDo9D7Ouavig1o34x7F94xqPk74eLQH0MH8HvwS3SLPe9iheEG6f70KiuLpZv6sxG/Va5bFJOabaO7ucAvGEbeAH+AN1hV7iDOidQFz4A2oJb6D1YDhXZHkTqpL8EbqHDYRtwW20AsdIb8syl5N2e6dTAPB2mWYa+hE4Qk7I59iMwFZ70GlJlfyuTVfygs7Hyw7HbwI0w3Tak14BqEtdg7wVdIx8pZbtBUbrjZeA3vUPBANkU+sEehev8O4Db6QpwYm+D8II0KPKHwUFeQ3oLDIMN4WgID1yOPQ+MAXMhNAtju3ztmtuAypiAw7EXwo/Am+0NfUG5mknYc6GfGVIjsoFNuyuoh8COuDcd2LmwA9jWE8bB3Q7N4XrwWAz5XOXR+Tx4n6FgdHeB6sF/w2QwhlSXdXvl/jixx4NH8GW5LDzb7GrR4ES4F5QddB99CieAwStOAPegdUZ2B71F3AXbQSn3vJ1bYaYWrayh3NUPTcbYFExVW3CfXwlvgfoavMbnDAY9dxGo6dCt0LeaB54H4UydDEPA2R4PDlrFLB9XuNmTlO+Xr7X9ZNBr9J4+EN8AMcv6ButpMND9FM6EnTOHkLrSnvtzwbbq3vwMB2ow/qWFSC8ZC++ZQaldbquH2afQWbl8TdcvVtC6LtipifAuOKt6gA9Tzqgzb5R2gP1hX3DVtZVHVvdklY5DA5beIkVPuZn8LOgAnWEfeAaUkxCan/voBNkfF+U5cFu5z5XlxZU20OmZtgm1K45VO4naNCukrcBZVk/CD+E/YBjoYjXJY8Zg9DxsDrbbBHTRotxOrug4eBs+hHgWZtKzfHrdXHBi9gDvqzxFHNA5KVfyBCf0ExgB7nkXStLLEKkniNf0AzUs5+ublkVFKiC9FBZAvGxshT0NnN3zoSUYSJQPcjAvm0HmjcIPemNS96F6E36drFLwugx7EEzNZV/l9IjoEPkW4B7eFtYH9QKcBcfA/aCWgpPQOT+zMbb9fS3nDbYR2MdgV0S5aVlUhLs0w45IHi7sqnnGJ2E7CXqHWgZXgJ1y8KqpDUmfSLmSV5yB/XrpDqVP8ofmehNdOv7I0ShfP4yyJdl2a4SchI1gCXgkHgljYfvc1i3cs/SU1A9jQRpfri/b0Sal1RrtSj4ULyHprY5C6+6E1+EBULq0E+DK7A96iwqX0z4td8B3dCdob5gD3UB3j9fUcNuDKFOvgc+bZAZFf4Zgu/q/AGPMgfm+5ShPWay+k6I31BwAvVDRYL2cuqfUVTkfnTqvVFx5ai7/MXn3tp1UrtRkDWRsaAMjzaD08uJ1irz7+8ps/6ZYj90V3FKrQBkvmubULbN7vs7tZRyJV9w0ePLbQ4PcJspqXnkbhbgoGk/AVptZRxpB0hU7Mpc1x34cdgKPm1dzeTts9XPwlFAO5Au4BDbO7ZycO7J9A/Zh2b4A2+ucALefWpTrflDKVq4kHQBOoi9PO1qvsDeGd6AxXAJbQ5VxlFrW8EnDcJlTsOPcjElxL7WNy7AduC4f2+A/rSN/Hyg7YMBTxgqPUT3F2HAqtIb58GvQW86GqyG+ff4UWz0FBuH4UhaTal1vmAGfg98dfP4d4HPGwmwYAg+D2/J7uU0ap/YaolHZVbBj5d1DaSK8ADsmqiH2JIhgNRhbPZrbhSdZ5heVJGw7477VfYuaagMK2sM8iMloga1HXAt/AeWELgQnR/0Z7k3W6pe3xTn/JamTFPGnPMZSj6p90rA8YOziwHcnH/EgTovJlJ0LPSHkyrTKmZNJ+8KrYKBsCQeB0pWdBFNleieMgzjL44jejTK1CPSY0CiMdyOT09g6ni5O3Ceg51U4VNLaPSA3SDNEwwiKFdgHgANNrpjb7UVejYTYCuZ92DR42HYh8gfDJfAMqBi4dqxk+RrKGkD0YXNsA6AT5qCUXhBe5CR0gPCC4dhqKFwI1m1qX0hr94CotDE4aAd3PCyBX4Jyn+sNL5tBDsRAp3S7b5KVYwa2A0nHaO5AXBeDtnlMxizsW+HomLh8zX9R5sTeBSEn/cqc2Tvak9eDXCyP2PgbYWzn2gefHxT7+0Qu/h18DO7XmPWYcYqSXuHz2myb6G7RNs7meLgeMxXugbiPA3clQx0xtgNPGN819L7+oCzvm6zSx+EkI+Du3Pe0LbOd/jqc7dhG9Wib+mJ5jaJBuL8e4B5aAMpAomKlb8d+KZWUVnw+dgzKSdDtvKaLDyJ1ReZB7O0J2EV5Xwd8OsTJExNpu7Q1SJ8zgy7K93UCX4P4mr4udoyhPGDKygOP+tomIFarMw2d+cfgF2DnDVAGoBvzw33YTHgPDoXQ7Fx/Wy6YkdMrcrmrehO4Pz3WvP90cIVPgonwITg4973yu0XTZK0+ZQaQd+K816twVAwKO71ZRj9zeg7lcVzXHghpVN4n2G3BAHQ1NILx4MBjoppgLwL3Ww8IHZsf6vGk3O8fwx9heK7rhD0o2zdg75JtT6GzQQ8KzcZwElSr3M5J85ktYCzEG+Gx2NNzm/Cm5pSp+K2gfLrZbg3RcB2IQcZN1qPM3+l06SjbAltX/TiXe1wtg7+AdR+AcgIs7xUPw94XxuTrnOD4E1bEoe9Rptw+DWGOGeQi7JOs1SfKKfk+epcakPNxbI8uFVdem8vT6aJdq7jASYjOFPdQDP4Q6t+Em8HVutmbkbYH9Tv4LcQW+H6ujy9Wrtxc6A7vQnznb5TbHUPZ0mw7CeoaOBAegmfBIKw8WZzs34M/oNiPGPzB2KHdrVMUlD29VFLLpw2jMWmnaIbdDNxXur+dWgVumTMglI4zMgbUEV5LmjqW7XnRkDS9qhbu/xZlZ8LWuc3UfM22Of80aVcYDJ/lstdIWxXu0TGXm/TO19vveHWuOglUxOo6iMfyBe7JOEp01ech9puuuBCMA8pVcUUNUB5lqgMYwJyE1oXOGTh9v1gO6kmogKEwHtREMHYofz5zAl3lJ2AWqJfgfohJiKB8HWWfg54YA9Zr1fn5Xmm80SdvHhNwVmq2umF8vWxA+WRwwE9BPNhOulrq0nxz97j6Go6DF8HYcBfYyer6MwWuoINeDG6roq4iE97QCtsJuxWc2JrkCeKEbgX7waOgnLiavxdQEWfohtgRwCrygIoxoQv1K0FNgR7gAKPTB+dr5lAWMliqmbAb7AzbgCs42vYK21NmOiwHJ9atpdxqDlhdA75QdYJT4XUYDfbBiVRe5ySoZTAbBpeekp6T4lo5uFnBz0fpJ6P8E9SJufEdXHipdRA/mw2hzmvfhrfgfjCKPwJnwn2g3igldb4hNaD5a6/fz7eHVuAb2wPwPs+4DB7E/hTagd64BbgoC6Ab9IAfgn+OX0p/ppAaGxZjnw6+Ep8DK8Cj0IDrmHw3GaeN9EZ/AlxFfk1RuVGUYu8K00D9Fa6EvrAUVKzO29gXg9vC1VW3g540w0xBcU2hKJnz+FxYvTCXWaduK/StuTZlLcD6JjnfEvsb6A56m32z78q4FMGw1gA4lEa60WmwMeiSnsljIBSDmEOBE3RdfvggbMuMIbNhItgJtbyUpE9ddjA0Bid1sderXDaQ1OdPAO9zH6hDcpuG2Ml7SQfArHRx6Xpf3JTluySrsrIP6Seg9/iMqsEvF6YZoXIDeAZCRmpneAHEnnLQnaEuXATX53schR3n/e7YyuvOT1bpnyV107Io3xZ6QWs4EirAyXkEqqvK3xa9CQ0c5C5xQ+zN8kWjcr2xZxTsBHfmsipbP671ZmW3wHYA58DdEPobhtwVF2HfBE9H3pT8xjkdja3iiDK4PQBO8Dx4B9wiH8JKeANcKTUW9IITwKNMeYrcArfDhVDsb1pVyty26le5D97/zWzrzVUGXyVjI0WjHUgq4CjoAuGiRuuJkN7mSJX7cn+uaZNyfBBgDHZqXvqsU2cZ6aPwChgE/ap8M9wLbSH+0DKOaw18z8N12GPAyf4BfADbwBmwCbxAHY9NvxQXx2GgVLZXPvurZDE0rqk5+NmAm8U2aIbdH9yDalgpSS80ltlB29fPqW9c8XLUHnsIuGquqt8gN7edwtazrOsAn4MysLryX8BD4Ap3y+0dZROIwPsl9h/hHjgit4lXdrdvHN8dc91wyk7JdvIS7VpF46Jb2ZGz4WJIRyBpBKQW3oR8lZuSvwQMhKtAfQUpYuf27cgbNx6EEeDAzgMHPwYMYi2gEcSfxC7B9qicDMoo/1vQI8p9IG88WAY/yeVpYrJdHpf5vytu4Ky7X46xIamrvjDb52OrG3K+HrZt4xq9wYEZPGPVfp7bhsdE2os2ylV6J1n5mbYPUX4S7AkGX+OAk2t6mm1Iw3PtQ+O4LuooK26RYvW3s7nBLZDiAGlbUHYiRV/S5AWk28DTEFqB4eo+B+n1M55Ivhu4kspj92uYCm6Px0Gv61lor0fcDQNBrQQnOr71lVeYsm894L/bkBuFe/u93eBngJtJMlwTDIDKyfDt6n3se8Dt8jHoNU0o70waq34obZ8lPx4coG+LbifrP6Pt0aQvwn65LFzcAHY8ZUtgAnwExp2WoMpeQLvaA12p7bf/pLPFmS3a/ajr750cfE43wX4YYmU9wi7IddHBCsrc69vm8uuwQydYVhQVvmsUn7s+ebfD0GhXrI+yf2jqA4oPKdo+iHxMwHbYRmgjta4cUTqCWXkg0UHatIR4SxxWKK9PeXhgKiZfxWOthzXuGff4p6b54bH3Y3W3pNxJcK8ebgdI44iys0G0N/8qKGOAGg9Ni50n3yjy2GkxSKtMRtT/21I7Fg/H9lRIX6qK5YX6zSjvDL4BGiBfBnUNmFdzwfKX4Ct40OtJv1sDj0Hlzrk6xbM3tob7uCf4amyk96VHvQg7gltGzQG9wpcwX6BCesfJ3/kJiMmgs+Gm4errUeZqF+Up4IoOzoWLcmqETyLve/2BsKkFpGUvK7VYCz6j06RbQx+ogHhN3Qdb3QF+a/wVKF94OhSHR77sWcXytcKm82usHGW9QE2B3skq/QB7APaqnJ9NuvaufnF1GIhxYH3LSAeA+hM0hMfgNzATdHvjgDHDv+qkP8gW77XW2gwmYsJe2F3zZDgxI7NteTo+/1WD/B9Au3Zjh2RyrgAAAABJRU5ErkJggg==';
+ private mode = 'snow';
+
+ private INITIAL_BUFFERS = () => ({
+ position: { size: 3, value: [] },
+ color: { size: 4, value: [] },
+ size: { size: 1, value: [] },
+ rotation: { size: 3, value: [] },
+ speed: { size: 3, value: [] },
+ });
+
+ private INITIAL_UNIFORMS = () => ({
+ time: { type: 'float', value: 0 },
+ worldSize: { type: 'vec3', value: [0, 0, 0] },
+ gravity: { type: 'float', value: this.gravity },
+ wind: { type: 'float', value: 0 },
+ spin_factor: { type: 'float', value: this.mode === 'sakura' ? 8 : 1 },
+ turbulence: { type: 'float', value: this.mode === 'sakura' ? 2 : 1 },
+ projection: {
+ type: 'mat4',
+ value: [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1],
+ },
+ });
+
+ private UNIFORM_SETTERS = {
+ int: 'uniform1i',
+ float: 'uniform1f',
+ vec2: 'uniform2fv',
+ vec3: 'uniform3fv',
+ vec4: 'uniform4fv',
+ mat2: 'uniformMatrix2fv',
+ mat3: 'uniformMatrix3fv',
+ mat4: 'uniformMatrix4fv',
+ };
+
+ private CAMERA = {
+ fov: 60,
+ near: 5,
+ far: 10000,
+ aspect: 1,
+ z: 100,
+ };
+
+ private WIND = {
+ current: 0,
+ force: 0.01,
+ target: 0.01,
+ min: 0,
+ max: 0.125,
+ easing: 0.0005,
+ };
+ /**
+ * @throws {Error} - Thrown when it fails to get WebGL context for the canvas
+ */
+ constructor(options: {
+ sakura?: boolean;
+ }) {
+ if (options.sakura) {
+ this.mode = 'sakura';
+ this.snowflake = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAFEmlUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4KPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iWE1QIENvcmUgNS41LjAiPgogPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4KICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIgogICAgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIgogICAgeG1sbnM6cGhvdG9zaG9wPSJodHRwOi8vbnMuYWRvYmUuY29tL3Bob3Rvc2hvcC8xLjAvIgogICAgeG1sbnM6ZXhpZj0iaHR0cDovL25zLmFkb2JlLmNvbS9leGlmLzEuMC8iCiAgICB4bWxuczp0aWZmPSJodHRwOi8vbnMuYWRvYmUuY29tL3RpZmYvMS4wLyIKICAgIHhtbG5zOnhtcE1NPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvbW0vIgogICAgeG1sbnM6c3RFdnQ9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZUV2ZW50IyIKICAgeG1wOkNyZWF0ZURhdGU9IjIwMjQtMDItMDFUMTQ6Mzk6NTYrMDkwMCIKICAgeG1wOk1vZGlmeURhdGU9IjIwMjQtMDItMDFUMTQ6NDU6MzQrMDk6MDAiCiAgIHhtcDpNZXRhZGF0YURhdGU9IjIwMjQtMDItMDFUMTQ6NDU6MzQrMDk6MDAiCiAgIHBob3Rvc2hvcDpEYXRlQ3JlYXRlZD0iMjAyNC0wMi0wMVQxNDozOTo1NiswOTAwIgogICBwaG90b3Nob3A6Q29sb3JNb2RlPSIzIgogICBwaG90b3Nob3A6SUNDUHJvZmlsZT0ic1JHQiBJRUM2MTk2Ni0yLjEiCiAgIGV4aWY6UGl4ZWxYRGltZW5zaW9uPSI2NCIKICAgZXhpZjpQaXhlbFlEaW1lbnNpb249IjY0IgogICBleGlmOkNvbG9yU3BhY2U9IjEiCiAgIHRpZmY6SW1hZ2VXaWR0aD0iNjQiCiAgIHRpZmY6SW1hZ2VMZW5ndGg9IjY0IgogICB0aWZmOlJlc29sdXRpb25Vbml0PSIyIgogICB0aWZmOlhSZXNvbHV0aW9uPSI3Mi8xIgogICB0aWZmOllSZXNvbHV0aW9uPSI3Mi8xIj4KICAgPHhtcE1NOkhpc3Rvcnk+CiAgICA8cmRmOlNlcT4KICAgICA8cmRmOmxpCiAgICAgIHN0RXZ0OmFjdGlvbj0icHJvZHVjZWQiCiAgICAgIHN0RXZ0OnNvZnR3YXJlQWdlbnQ9IkFmZmluaXR5IFBob3RvIDIgMi4zLjEiCiAgICAgIHN0RXZ0OndoZW49IjIwMjQtMDItMDFUMTQ6NDU6MzQrMDk6MDAiLz4KICAgIDwvcmRmOlNlcT4KICAgPC94bXBNTTpIaXN0b3J5PgogIDwvcmRmOkRlc2NyaXB0aW9uPgogPC9yZGY6UkRGPgo8L3g6eG1wbWV0YT4KPD94cGFja2V0IGVuZD0iciI/PhldI30AAAGBaUNDUHNSR0IgSUVDNjE5NjYtMi4xAAAokXWRu0sDQRCHP6Mh4oOIWlhYBPHRJBIjiDYWEV+gFjGCr+ZyuUuEJB53JyK2gq2gINr4KvQv0FawFgRFEcTaWtFG5ZwzgQQxs+zst7+dGXZnwRPPqFmrKgzZnG3GRqOB2bn5gO8FDxV46aJRUS1jcnokTln7uJdYsduQW6t83L9Wm9QsFSqqhQdVw7SFx4QnVm3D5R3hZjWtJIXPhIOmXFD4ztUTeX5xOZXnL5fNeGwIPA3CgVQJJ0pYTZtZYXk57dnMilq4j/uSOi03My1rm8xWLGKMEiXAOMMM0UcPA+L7CBGhW3aUyQ//5k+xLLmqeIM1TJZIkcYmKOqKVNdk1UXXZGRYc/v/t6+W3hvJV6+LgvfZcd46wLcN31uO83nkON/HUPkEl7li/vIh9L+LvlXU2g/AvwHnV0UtsQsXm9DyaCim8itVyvToOryeQv0cNN1AzUK+Z4VzTh4gvi5fdQ17+9Ap8f7FHyc6Z8kcDq1+AAAACXBIWXMAAAsTAAALEwEAmpwYAAADwElEQVR4nO2bT4hWVRjGf75TkhoEkhSa/9ocRIIwCsrE1pVnLbkYdFdGgQRS6caVm3CVy2oRuqmQ2yJXKTJh4GqCGs/CJCcLccAJ/yDpnGnxHYeZ4TrNfOc55y78nuWdc3/ve57v+b65f86BgQaqotiE5bEJKxYx7onYhOU1egKwGkViE/YCN4Cx2ITNC4xbDVwAJmMT9tXobVnpArEJe4CvZx0aB7aZdxPzxhkwArw66/Ae8+5Eyf6KJiA2YRPw+bzD64EjLcP3MXfyAMdjEzYWaG1GxRIQmzAEnAVeb/nzFPCSeTeaxj4FBOCZlrEjwBvm3VSJPksm4BPaJw8wBHwXm/BibMIW4HvaJ09ifFygP6BQAtKkfgEeEyHvAy+YdxdFvBmVSsBBdJMnsQ4KeTOSJyA2YT1wCXhcjL4HPG/e/amElkjAAfSTJzEPqKHSBKQLmSvAKiV3lm4BG8y7GyqgOgHvU27yAE+mGjLJEhCbsBL4A3haxXyIJoCN5t0dBUyZgF2UnzypxtsqmNKAt4SsarUkX4F0I3ONOgkAuA48a97FXJAqAa9Qb/IAa4CXFSCVATXjL635yBuQ/RsQm7AWuCroZamaBtaZd3/nQBQJeFPA6EfLFLUVBrwmYPSr7bkAhQHPCRj9al0uQGHAWgGjs9oKA7I/hS5rZ/0XSC86JDclGVph3t3t9+TcBHT56T9QVg+5BnT5/X+grB4GCcgs/sgnYCjzfIWyesg14Hrm+Qpl9ZBrwMT/DymurB4GCeiyuEidGnCN3n15V5pOPfStLAPMu1vAWA4jU7+Zd7dzAIqboREBo7PaCgN+EjA6qz1IQDbAu9/prQeorUvm3eVciOqx+JcizlL0hQKiMuAreiu/amkq1cyWxADz7ipwWsFapH4w7/5SgJRvh+cviCyp4yqQeonMOWCHktmic+bdThVMvUSmyFK2kjWkBph354FTSuY8nTLvflYCSyyT+xD4pwB3EvhADZUbYN5dAfarucB+825cDS25WvwksFuEO2nevSNizVHJ1eLvAoplrePAewJOq4oZYN5NAsPkPTCZBoYTq4iK7hgx734EjmUgjpl3Z1T9tKnGpqlP6e+p0Vg6t6iKG5De3A6ztJul+/Si3/db38WqyrY58+4CcHQJpxxN5xRXFQOSjgCjixg3SvuusiKqZoB59y+964KbCwy7Cew27+7V6apuAkibnhbaEbq3xMaohVTVAADz7hvgMHN/FKeAQ+bdt7X7Kb519mGKTdgKfEbvYucj8+7XLvr4DxAA134c0w/5AAAAAElFTkSuQmCC';
+ this.size = 10;
+ this.density = 1 / 280;
+ }
+
+ const canvas = this.initCanvas();
+ const gl = canvas.getContext('webgl2', { antialias: true });
+ if (gl == null) throw new Error('Failed to get WebGL context');
+
+ document.body.append(canvas);
+
+ this.canvas = canvas;
+ this.gl = gl;
+ this.program = this.initProgram();
+ this.buffers = this.initBuffers();
+ this.uniforms = this.initUniforms();
+ this.texture = this.initTexture();
+ this.camera = this.initCamera();
+ this.wind = this.initWind();
+
+ this.resize = this.resize.bind(this);
+ this.update = this.update.bind(this);
+
+ window.addEventListener('resize', () => this.resize());
+ }
+
+ private initCanvas(): HTMLCanvasElement {
+ const canvas = document.createElement('canvas');
+
+ Object.assign(canvas.style, {
+ position: 'fixed',
+ top: 0,
+ left: 0,
+ width: '100vw',
+ height: '100vh',
+ background: 'transparent',
+ 'pointer-events': 'none',
+ 'z-index': 2147483647,
+ });
+
+ return canvas;
+ }
+
+ private initCamera() {
+ return { ...this.CAMERA };
+ }
+
+ private initWind() {
+ return { ...this.WIND };
+ }
+
+ private initShader(type, source): WebGLShader {
+ const { gl } = this;
+ const shader = gl.createShader(type);
+ if (shader == null) throw new Error('Failed to create shader');
+
+ gl.shaderSource(shader, source);
+ gl.compileShader(shader);
+
+ return shader;
+ }
+
+ private initProgram(): WebGLProgram {
+ const { gl } = this;
+ const vertex = this.initShader(gl.VERTEX_SHADER, this.VERTEX_SOURCE);
+ const fragment = this.initShader(gl.FRAGMENT_SHADER, this.FRAGMENT_SOURCE);
+ const program = gl.createProgram();
+ if (program == null) throw new Error('Failed to create program');
+
+ gl.attachShader(program, vertex);
+ gl.attachShader(program, fragment);
+ gl.linkProgram(program);
+ gl.useProgram(program);
+
+ return program;
+ }
+
+ private initBuffers(): SnowfallEffect['buffers'] {
+ const { gl, program } = this;
+ const buffers = this.INITIAL_BUFFERS() as unknown as SnowfallEffect['buffers'];
+
+ for (const [name, buffer] of Object.entries(buffers)) {
+ buffer.location = gl.getAttribLocation(program, `a_${name}`);
+ buffer.ref = gl.createBuffer()!;
+
+ gl.bindBuffer(gl.ARRAY_BUFFER, buffer.ref);
+ gl.enableVertexAttribArray(buffer.location);
+ gl.vertexAttribPointer(
+ buffer.location,
+ buffer.size,
+ gl.FLOAT,
+ false,
+ 0,
+ 0,
+ );
+ }
+
+ return buffers;
+ }
+
+ private updateBuffers() {
+ const { buffers } = this;
+
+ for (const name of Object.keys(buffers)) {
+ this.setBuffer(name);
+ }
+ }
+
+ private setBuffer(name: string, value?) {
+ const { gl, buffers } = this;
+ const buffer = buffers[name];
+
+ buffer.value = new Float32Array(value ?? buffer.value);
+
+ gl.bindBuffer(gl.ARRAY_BUFFER, buffer.ref);
+ gl.bufferData(gl.ARRAY_BUFFER, buffer.value, gl.STATIC_DRAW);
+ }
+
+ private initUniforms(): SnowfallEffect['uniforms'] {
+ const { gl, program } = this;
+ const uniforms = this.INITIAL_UNIFORMS() as unknown as SnowfallEffect['uniforms'];
+
+ for (const [name, uniform] of Object.entries(uniforms)) {
+ uniform.location = gl.getUniformLocation(program, `u_${name}`)!;
+ }
+
+ return uniforms;
+ }
+
+ private updateUniforms() {
+ const { uniforms } = this;
+
+ for (const name of Object.keys(uniforms)) {
+ this.setUniform(name);
+ }
+ }
+
+ private setUniform(name: string, value?) {
+ const { gl, uniforms } = this;
+ const uniform = uniforms[name];
+ const setter = this.UNIFORM_SETTERS[uniform.type];
+ const isMatrix = /^mat[2-4]$/i.test(uniform.type);
+
+ uniform.value = value ?? uniform.value;
+
+ if (isMatrix) {
+ gl[setter](uniform.location, false, uniform.value);
+ } else {
+ gl[setter](uniform.location, uniform.value);
+ }
+ }
+
+ private initTexture() {
+ const { gl } = this;
+ const texture = gl.createTexture();
+ if (texture == null) throw new Error('Failed to create texture');
+ const image = new Image();
+
+ gl.bindTexture(gl.TEXTURE_2D, texture);
+ gl.texImage2D(
+ gl.TEXTURE_2D,
+ 0,
+ gl.RGBA,
+ 1,
+ 1,
+ 0,
+ gl.RGBA,
+ gl.UNSIGNED_BYTE,
+ new Uint8Array([0, 0, 0, 0]),
+ );
+
+ image.onload = () => {
+ gl.bindTexture(gl.TEXTURE_2D, texture);
+ gl.texImage2D(
+ gl.TEXTURE_2D,
+ 0,
+ gl.RGBA,
+ gl.RGBA,
+ gl.UNSIGNED_BYTE,
+ image,
+ );
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
+ };
+
+ image.src = this.snowflake;
+
+ return texture;
+ }
+
+ private initSnowflakes(vw: number, vh: number, dpi: number) {
+ const position: number[] = [];
+ const color: number[] = [];
+ const size: number[] = [];
+ const rotation: number[] = [];
+ const speed: number[] = [];
+
+ const height = 1 / this.density;
+ const width = (vw / vh) * height;
+ const depth = this.depth;
+ const count = this.count;
+ const length = (vw / vh) * count;
+
+ for (let i = 0; i < length; ++i) {
+ position.push(
+ -width + Math.random() * width * 2,
+ -height + Math.random() * height * 2,
+ Math.random() * depth * 2,
+ );
+
+ speed.push(1 + Math.random(), 1 + Math.random(), Math.random() * 10);
+
+ rotation.push(
+ Math.random() * 2 * Math.PI,
+ Math.random() * 20,
+ Math.random() * 10,
+ );
+
+ color.push(...this.color, 0.1 + Math.random() * this.opacity);
+ //size.push((this.size * Math.random() * this.size * vh * dpi) / 1000);
+ size.push((this.size * vh * dpi) / 1000);
+ }
+
+ this.setUniform('worldSize', [width, height, depth]);
+
+ this.setBuffer('position', position);
+ this.setBuffer('color', color);
+ this.setBuffer('rotation', rotation);
+ this.setBuffer('size', size);
+ this.setBuffer('speed', speed);
+ }
+
+ private setProjection(aspect: number) {
+ const { camera } = this;
+
+ camera.aspect = aspect;
+
+ const fovRad = (camera.fov * Math.PI) / 180;
+ const f = Math.tan(Math.PI * 0.5 - 0.5 * fovRad);
+ const rangeInv = 1.0 / (camera.near - camera.far);
+
+ const m0 = f / camera.aspect;
+ const m5 = f;
+ const m10 = (camera.near + camera.far) * rangeInv;
+ const m11 = -1;
+ const m14 = camera.near * camera.far * rangeInv * 2 + camera.z;
+ const m15 = camera.z;
+
+ return [m0, 0, 0, 0, 0, m5, 0, 0, 0, 0, m10, m11, 0, 0, m14, m15];
+ }
+
+ public render() {
+ const { gl } = this;
+
+ gl.enable(gl.BLEND);
+ gl.enable(gl.CULL_FACE);
+ gl.blendFunc(gl.SRC_ALPHA, gl.ONE);
+ gl.disable(gl.DEPTH_TEST);
+
+ this.updateBuffers();
+ this.updateUniforms();
+ this.resize(true);
+
+ this.time = {
+ start: window.performance.now(),
+ previous: window.performance.now(),
+ };
+
+ if (this.raf) window.cancelAnimationFrame(this.raf);
+ this.raf = window.requestAnimationFrame(this.update);
+
+ return this;
+ }
+
+ private resize(updateSnowflakes = false) {
+ const { canvas, gl } = this;
+ const vw = canvas.offsetWidth;
+ const vh = canvas.offsetHeight;
+ const aspect = vw / vh;
+ const dpi = window.devicePixelRatio;
+
+ canvas.width = vw * dpi;
+ canvas.height = vh * dpi;
+
+ gl.viewport(0, 0, vw * dpi, vh * dpi);
+ gl.clearColor(0, 0, 0, 0);
+
+ if (updateSnowflakes === true) {
+ this.initSnowflakes(vw, vh, dpi);
+ }
+
+ this.setUniform('projection', this.setProjection(aspect));
+ }
+
+ private update(timestamp: number) {
+ const { gl, buffers, wind } = this;
+ const elapsed = (timestamp - this.time.start) * this.speed;
+ const delta = timestamp - this.time.previous;
+
+ gl.clear(gl.COLOR_BUFFER_BIT);
+ gl.drawArrays(
+ gl.POINTS,
+ 0,
+ buffers.position.value.length / buffers.position.size,
+ );
+
+ if (Math.random() > 0.995) {
+ wind.target =
+ (wind.min + Math.random() * (wind.max - wind.min)) *
+ (Math.random() > 0.5 ? -1 : 1);
+ }
+
+ wind.force += (wind.target - wind.force) * wind.easing;
+ wind.current += wind.force * (delta * 0.2);
+
+ this.setUniform('wind', wind.current);
+ this.setUniform('time', elapsed);
+
+ this.time.previous = timestamp;
+
+ this.raf = window.requestAnimationFrame(this.update);
+ }
+}
diff --git a/packages/frontend/src/utility/sound.ts b/packages/frontend/src/utility/sound.ts
new file mode 100644
index 0000000000..436c2b75f0
--- /dev/null
+++ b/packages/frontend/src/utility/sound.ts
@@ -0,0 +1,258 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import type { SoundStore } from '@/preferences/def.js';
+import { prefer } from '@/preferences.js';
+import { PREF_DEF } from '@/preferences/def.js';
+
+let ctx: AudioContext;
+const cache = new Map();
+let canPlay = true;
+
+export const soundsTypes = [
+ // 音声なし
+ null,
+
+ // ドライブの音声
+ '_driveFile_',
+
+ // プリインストール
+ 'syuilo/n-aec',
+ 'syuilo/n-aec-4va',
+ 'syuilo/n-aec-4vb',
+ 'syuilo/n-aec-8va',
+ 'syuilo/n-aec-8vb',
+ 'syuilo/n-cea',
+ 'syuilo/n-cea-4va',
+ 'syuilo/n-cea-4vb',
+ 'syuilo/n-cea-8va',
+ 'syuilo/n-cea-8vb',
+ 'syuilo/n-eca',
+ 'syuilo/n-eca-4va',
+ 'syuilo/n-eca-4vb',
+ 'syuilo/n-eca-8va',
+ 'syuilo/n-eca-8vb',
+ 'syuilo/n-ea',
+ 'syuilo/n-ea-4va',
+ 'syuilo/n-ea-4vb',
+ 'syuilo/n-ea-8va',
+ 'syuilo/n-ea-8vb',
+ 'syuilo/n-ea-harmony',
+ 'syuilo/up',
+ 'syuilo/down',
+ 'syuilo/pope1',
+ 'syuilo/pope2',
+ 'syuilo/waon',
+ 'syuilo/popo',
+ 'syuilo/triple',
+ 'syuilo/bubble1',
+ 'syuilo/bubble2',
+ 'syuilo/poi1',
+ 'syuilo/poi2',
+ 'syuilo/pirori',
+ 'syuilo/pirori-wet',
+ 'syuilo/pirori-square-wet',
+ 'syuilo/square-pico',
+ 'syuilo/reverved',
+ 'syuilo/ryukyu',
+ 'syuilo/kick',
+ 'syuilo/snare',
+ 'syuilo/queue-jammed',
+ 'aisha/1',
+ 'aisha/2',
+ 'aisha/3',
+ 'noizenecio/kick_gaba1',
+ 'noizenecio/kick_gaba2',
+ 'noizenecio/kick_gaba3',
+ 'noizenecio/kick_gaba4',
+ 'noizenecio/kick_gaba5',
+ 'noizenecio/kick_gaba6',
+ 'noizenecio/kick_gaba7',
+] as const;
+
+export const operationTypes = [
+ 'noteMy',
+ 'note',
+ 'notification',
+ 'reaction',
+] as const;
+
+/** サウンドの種類 */
+export type SoundType = typeof soundsTypes[number];
+
+/** スプライトの種類 */
+export type OperationType = typeof operationTypes[number];
+
+/**
+ * 音声を読み込む
+ * @param url url
+ * @param options `useCache`: デフォルトは`true` 一度再生した音声はキャッシュする
+ */
+export async function loadAudio(url: string, options?: { useCache?: boolean; }) {
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+ if (ctx == null) {
+ ctx = new AudioContext();
+
+ window.addEventListener('beforeunload', () => {
+ ctx.close();
+ });
+ }
+ if (options?.useCache ?? true) {
+ if (cache.has(url)) {
+ return cache.get(url) as AudioBuffer;
+ }
+ }
+
+ let response: Response;
+
+ try {
+ response = await fetch(url);
+ } catch (err) {
+ return;
+ }
+
+ const arrayBuffer = await response.arrayBuffer();
+ const audioBuffer = await ctx.decodeAudioData(arrayBuffer);
+
+ if (options?.useCache ?? true) {
+ cache.set(url, audioBuffer);
+ }
+
+ return audioBuffer;
+}
+
+/**
+ * 既定のスプライトを再生する
+ * @param type スプライトの種類を指定
+ */
+export function playMisskeySfx(operationType: OperationType) {
+ const sound = prefer.s[`sound.on.${operationType}`];
+ playMisskeySfxFile(sound).then((succeed) => {
+ if (!succeed && sound.type === '_driveFile_') {
+ // ドライブファイルが存在しない場合はデフォルトのサウンドを再生する
+ const soundName = PREF_DEF[`sound_${operationType}`].default.type as Exclude;
+ if (_DEV_) console.log(`Failed to play sound: ${sound.fileUrl}, so play default sound: ${soundName}`);
+ playMisskeySfxFileInternal({
+ type: soundName,
+ volume: sound.volume,
+ });
+ }
+ });
+}
+
+/**
+ * サウンド設定形式で指定された音声を再生する
+ * @param soundStore サウンド設定
+ */
+export async function playMisskeySfxFile(soundStore: SoundStore): Promise {
+ // 連続して再生しない
+ if (!canPlay) return false;
+ // ユーザーアクティベーションが必要な場合はそれがない場合は再生しない
+ if ('userActivation' in navigator && !navigator.userActivation.hasBeenActive) return false;
+ // サウンドがない場合は再生しない
+ if (soundStore.type === null || soundStore.type === '_driveFile_' && !soundStore.fileUrl) return false;
+
+ canPlay = false;
+ return await playMisskeySfxFileInternal(soundStore).finally(() => {
+ // ごく短時間に音が重複しないように
+ setTimeout(() => {
+ canPlay = true;
+ }, 25);
+ });
+}
+
+async function playMisskeySfxFileInternal(soundStore: SoundStore): Promise {
+ if (soundStore.type === null || (soundStore.type === '_driveFile_' && !soundStore.fileUrl)) {
+ return false;
+ }
+ const masterVolume = prefer.s['sound.masterVolume'];
+ if (isMute() || masterVolume === 0 || soundStore.volume === 0) {
+ return true; // ミュート時は成功として扱う
+ }
+ const url = soundStore.type === '_driveFile_' ? soundStore.fileUrl : `/client-assets/sounds/${soundStore.type}.mp3`;
+ const buffer = await loadAudio(url).catch(() => {
+ return undefined;
+ });
+ if (!buffer) return false;
+ const volume = soundStore.volume * masterVolume;
+ createSourceNode(buffer, { volume }).soundSource.start();
+ return true;
+}
+
+export async function playUrl(url: string, opts: {
+ volume?: number;
+ pan?: number;
+ playbackRate?: number;
+}) {
+ if (opts.volume === 0) {
+ return;
+ }
+ const buffer = await loadAudio(url);
+ if (!buffer) return;
+ createSourceNode(buffer, opts).soundSource.start();
+}
+
+export function createSourceNode(buffer: AudioBuffer, opts: {
+ volume?: number;
+ pan?: number;
+ playbackRate?: number;
+}): {
+ soundSource: AudioBufferSourceNode;
+ panNode: StereoPannerNode;
+ gainNode: GainNode;
+ } {
+ const panNode = ctx.createStereoPanner();
+ panNode.pan.value = opts.pan ?? 0;
+
+ const gainNode = ctx.createGain();
+
+ gainNode.gain.value = opts.volume ?? 1;
+
+ const soundSource = ctx.createBufferSource();
+ soundSource.buffer = buffer;
+ soundSource.playbackRate.value = opts.playbackRate ?? 1;
+ soundSource
+ .connect(panNode)
+ .connect(gainNode)
+ .connect(ctx.destination);
+
+ return { soundSource, panNode, gainNode };
+}
+
+/**
+ * 音声の長さをミリ秒で取得する
+ * @param file ファイルのURL(ドライブIDではない)
+ */
+export async function getSoundDuration(file: string): Promise {
+ const audioEl = document.createElement('audio');
+ audioEl.src = file;
+ return new Promise((resolve) => {
+ const si = setInterval(() => {
+ if (audioEl.readyState > 0) {
+ resolve(audioEl.duration * 1000);
+ clearInterval(si);
+ audioEl.remove();
+ }
+ }, 100);
+ });
+}
+
+/**
+ * ミュートすべきかどうかを判断する
+ */
+export function isMute(): boolean {
+ if (prefer.s['sound.notUseSound']) {
+ // サウンドを出力しない
+ return true;
+ }
+
+ // noinspection RedundantIfStatementJS
+ if (prefer.s['sound.useSoundOnlyWhenActive'] && document.visibilityState === 'hidden') {
+ // ブラウザがアクティブな時のみサウンドを出力する
+ return true;
+ }
+
+ return false;
+}
diff --git a/packages/frontend/src/utility/sticky-sidebar.ts b/packages/frontend/src/utility/sticky-sidebar.ts
new file mode 100644
index 0000000000..50f1e6ecc8
--- /dev/null
+++ b/packages/frontend/src/utility/sticky-sidebar.ts
@@ -0,0 +1,55 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+export class StickySidebar {
+ private lastScrollTop = 0;
+ private container: HTMLElement;
+ private el: HTMLElement;
+ private spacer: HTMLElement;
+ private marginTop: number;
+ private isTop = false;
+ private isBottom = false;
+ private offsetTop: number;
+ private globalHeaderHeight = 59;
+
+ constructor(container: StickySidebar['container'], marginTop = 0, globalHeaderHeight = 0) {
+ this.container = container;
+ this.el = this.container.children[0] as HTMLElement;
+ this.el.style.position = 'sticky';
+ this.spacer = document.createElement('div');
+ this.container.prepend(this.spacer);
+ this.marginTop = marginTop;
+ this.offsetTop = this.container.getBoundingClientRect().top;
+ this.globalHeaderHeight = globalHeaderHeight;
+ }
+
+ public calc(scrollTop: number) {
+ if (scrollTop > this.lastScrollTop) { // downscroll
+ const overflow = Math.max(0, this.globalHeaderHeight + (this.el.clientHeight + this.marginTop) - window.innerHeight);
+ this.el.style.bottom = null;
+ this.el.style.top = `${-overflow + this.marginTop + this.globalHeaderHeight}px`;
+
+ this.isBottom = (scrollTop + window.innerHeight) >= (this.el.offsetTop + this.el.clientHeight);
+
+ if (this.isTop) {
+ this.isTop = false;
+ this.spacer.style.marginTop = `${Math.max(0, this.globalHeaderHeight + this.lastScrollTop + this.marginTop - this.offsetTop)}px`;
+ }
+ } else { // upscroll
+ const overflow = this.globalHeaderHeight + (this.el.clientHeight + this.marginTop) - window.innerHeight;
+ this.el.style.top = null;
+ this.el.style.bottom = `${-overflow}px`;
+
+ this.isTop = scrollTop + this.marginTop + this.globalHeaderHeight <= this.el.offsetTop;
+
+ if (this.isBottom) {
+ this.isBottom = false;
+ this.spacer.style.marginTop = `${this.globalHeaderHeight + this.lastScrollTop + this.marginTop - this.offsetTop - overflow}px`;
+ }
+ }
+
+ this.lastScrollTop = scrollTop <= 0 ? 0 : scrollTop;
+ }
+}
diff --git a/packages/frontend/src/utility/stream-mock.ts b/packages/frontend/src/utility/stream-mock.ts
new file mode 100644
index 0000000000..9b1b368de4
--- /dev/null
+++ b/packages/frontend/src/utility/stream-mock.ts
@@ -0,0 +1,81 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { EventEmitter } from 'eventemitter3';
+import * as Misskey from 'misskey-js';
+import type { Channels, StreamEvents, IStream, IChannelConnection } from 'misskey-js';
+
+type AnyOf> = T[keyof T];
+type OmitFirst = T extends [any, ...infer R] ? R : never;
+
+/**
+ * Websocket無効化時に使うStreamのモック(なにもしない)
+ */
+export class StreamMock extends EventEmitter implements IStream {
+ public readonly state = 'initializing';
+
+ constructor(...args: ConstructorParameters) {
+ super();
+ // do nothing
+ }
+
+ public useChannel(channel: C, params?: Channels[C]['params'], name?: string): ChannelConnectionMock {
+ return new ChannelConnectionMock(this, channel, name);
+ }
+
+ public removeSharedConnection(connection: any): void {
+ // do nothing
+ }
+
+ public removeSharedConnectionPool(pool: any): void {
+ // do nothing
+ }
+
+ public disconnectToChannel(): void {
+ // do nothing
+ }
+
+ public send(typeOrPayload: string): void;
+ public send(typeOrPayload: string, payload: any): void;
+ public send(typeOrPayload: Record | any[]): void;
+ public send(typeOrPayload: string | Record | any[], payload?: any): void {
+ // do nothing
+ }
+
+ public ping(): void {
+ // do nothing
+ }
+
+ public heartbeat(): void {
+ // do nothing
+ }
+
+ public close(): void {
+ // do nothing
+ }
+}
+
+class ChannelConnectionMock = any> extends EventEmitter implements IChannelConnection {
+ public id = '';
+ public name?: string; // for debug
+ public inCount = 0; // for debug
+ public outCount = 0; // for debug
+ public channel: string;
+
+ constructor(stream: IStream, ...args: OmitFirst>>) {
+ super();
+
+ this.channel = args[0];
+ this.name = args[1];
+ }
+
+ public send(type: T, body: Channel['receives'][T]): void {
+ // do nothing
+ }
+
+ public dispose(): void {
+ // do nothing
+ }
+}
diff --git a/packages/frontend/src/utility/test-utils.ts b/packages/frontend/src/utility/test-utils.ts
new file mode 100644
index 0000000000..52bb2d94e0
--- /dev/null
+++ b/packages/frontend/src/utility/test-utils.ts
@@ -0,0 +1,9 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+export async function tick(): Promise {
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+ await new Promise((globalThis.requestIdleCallback ?? setTimeout) as never);
+}
diff --git a/packages/frontend/src/utility/theme-editor.ts b/packages/frontend/src/utility/theme-editor.ts
new file mode 100644
index 0000000000..0206e378bf
--- /dev/null
+++ b/packages/frontend/src/utility/theme-editor.ts
@@ -0,0 +1,87 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { v4 as uuid } from 'uuid';
+
+import { themeProps } from './theme.js';
+import type { Theme } from './theme.js';
+
+export type Default = null;
+export type Color = string;
+export type FuncName = 'alpha' | 'darken' | 'lighten';
+export type Func = { type: 'func'; name: FuncName; arg: number; value: string; };
+export type RefProp = { type: 'refProp'; key: string; };
+export type RefConst = { type: 'refConst'; key: string; };
+export type Css = { type: 'css'; value: string; };
+
+export type ThemeValue = Color | Func | RefProp | RefConst | Css | Default;
+
+export type ThemeViewModel = [ string, ThemeValue ][];
+
+export const fromThemeString = (str?: string) : ThemeValue => {
+ if (!str) return null;
+ if (str.startsWith(':')) {
+ const parts = str.slice(1).split('<');
+ const name = parts[0] as FuncName;
+ const arg = parseFloat(parts[1]);
+ const value = parts[2].startsWith('@') ? parts[2].slice(1) : '';
+ return { type: 'func', name, arg, value };
+ } else if (str.startsWith('@')) {
+ return {
+ type: 'refProp',
+ key: str.slice(1),
+ };
+ } else if (str.startsWith('$')) {
+ return {
+ type: 'refConst',
+ key: str.slice(1),
+ };
+ } else if (str.startsWith('"')) {
+ return {
+ type: 'css',
+ value: str.substring(1).trim(),
+ };
+ } else {
+ return str;
+ }
+};
+
+export const toThemeString = (value: Color | Func | RefProp | RefConst | Css) => {
+ if (typeof value === 'string') return value;
+ switch (value.type) {
+ case 'func': return `:${value.name}<${value.arg}<@${value.value}`;
+ case 'refProp': return `@${value.key}`;
+ case 'refConst': return `$${value.key}`;
+ case 'css': return `" ${value.value}`;
+ }
+};
+
+export const convertToMisskeyTheme = (vm: ThemeViewModel, name: string, desc: string, author: string, base: 'dark' | 'light'): Theme => {
+ const props = { } as { [key: string]: string };
+ for (const [key, value] of vm) {
+ if (value === null) continue;
+ props[key] = toThemeString(value);
+ }
+
+ return {
+ id: uuid(),
+ name, desc, author, props, base,
+ };
+};
+
+export const convertToViewModel = (theme: Theme): ThemeViewModel => {
+ const vm: ThemeViewModel = [];
+ // プロパティの登録
+ vm.push(...themeProps.map(key => [key, fromThemeString(theme.props[key])] as [ string, ThemeValue ]));
+
+ // 定数の登録
+ const consts = Object
+ .keys(theme.props)
+ .filter(k => k.startsWith('$'))
+ .map(k => [k, fromThemeString(theme.props[k])] as [ string, ThemeValue ]);
+
+ vm.push(...consts);
+ return vm;
+};
diff --git a/packages/frontend/src/utility/theme.ts b/packages/frontend/src/utility/theme.ts
new file mode 100644
index 0000000000..851ba41e61
--- /dev/null
+++ b/packages/frontend/src/utility/theme.ts
@@ -0,0 +1,189 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { ref } from 'vue';
+import tinycolor from 'tinycolor2';
+import lightTheme from '@@/themes/_light.json5';
+import darkTheme from '@@/themes/_dark.json5';
+import JSON5 from 'json5';
+import { deepClone } from './clone.js';
+import type { BundledTheme } from 'shiki/themes';
+import { globalEvents } from '@/events.js';
+import { miLocalStorage } from '@/local-storage.js';
+import { addTheme, getThemes } from '@/theme-store.js';
+
+export type Theme = {
+ id: string;
+ name: string;
+ author: string;
+ desc?: string;
+ base?: 'dark' | 'light';
+ props: Record;
+ codeHighlighter?: {
+ base: BundledTheme;
+ overrides?: Record;
+ } | {
+ base: '_none_';
+ overrides: Record;
+ };
+};
+
+export const themeProps = Object.keys(lightTheme.props).filter(key => !key.startsWith('X'));
+
+export const getBuiltinThemes = () => Promise.all(
+ [
+ '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',
+ ].map(name => import(`@@/themes/${name}.json5`).then(({ default: _default }): Theme => _default)),
+);
+
+export const getBuiltinThemesRef = () => {
+ const builtinThemes = ref([]);
+ getBuiltinThemes().then(themes => builtinThemes.value = themes);
+ return builtinThemes;
+};
+
+let timeout: number | null = null;
+
+export function applyTheme(theme: Theme, persist = true) {
+ if (timeout) window.clearTimeout(timeout);
+
+ document.documentElement.classList.add('_themeChanging_');
+
+ timeout = window.setTimeout(() => {
+ document.documentElement.classList.remove('_themeChanging_');
+ }, 1000);
+
+ const colorScheme = theme.base === 'dark' ? 'dark' : 'light';
+
+ document.documentElement.dataset.colorScheme = colorScheme;
+
+ // Deep copy
+ const _theme = deepClone(theme);
+
+ if (_theme.base) {
+ const base = [lightTheme, darkTheme].find(x => x.id === _theme.base);
+ if (base) _theme.props = Object.assign({}, base.props, _theme.props);
+ }
+
+ const props = compile(_theme);
+
+ for (const tag of document.head.children) {
+ if (tag.tagName === 'META' && tag.getAttribute('name') === 'theme-color') {
+ tag.setAttribute('content', props['htmlThemeColor']);
+ break;
+ }
+ }
+
+ for (const [k, v] of Object.entries(props)) {
+ document.documentElement.style.setProperty(`--MI_THEME-${k}`, v.toString());
+ }
+
+ document.documentElement.style.setProperty('color-scheme', colorScheme);
+
+ if (persist) {
+ miLocalStorage.setItem('theme', JSON.stringify(props));
+ miLocalStorage.setItem('themeId', theme.id);
+ miLocalStorage.setItem('colorScheme', colorScheme);
+ }
+
+ // 色計算など再度行えるようにクライアント全体に通知
+ globalEvents.emit('themeChanged');
+}
+
+function compile(theme: Theme): Record {
+ function getColor(val: string): tinycolor.Instance {
+ if (val[0] === '@') { // ref (prop)
+ return getColor(theme.props[val.substring(1)]);
+ } else if (val[0] === '$') { // ref (const)
+ return getColor(theme.props[val]);
+ } else if (val[0] === ':') { // func
+ const parts = val.split('<');
+ const func = parts.shift().substring(1);
+ const arg = parseFloat(parts.shift());
+ const color = getColor(parts.join('<'));
+
+ switch (func) {
+ case 'darken': return color.darken(arg);
+ case 'lighten': return color.lighten(arg);
+ case 'alpha': return color.setAlpha(arg);
+ case 'hue': return color.spin(arg);
+ case 'saturate': return color.saturate(arg);
+ }
+ }
+
+ // other case
+ return tinycolor(val);
+ }
+
+ const props = {};
+
+ for (const [k, v] of Object.entries(theme.props)) {
+ if (k.startsWith('$')) continue; // ignore const
+
+ props[k] = v.startsWith('"') ? v.replace(/^"\s*/, '') : genValue(getColor(v));
+ }
+
+ return props;
+}
+
+function genValue(c: tinycolor.Instance): string {
+ return c.toRgbString();
+}
+
+export function validateTheme(theme: Record]