summaryrefslogtreecommitdiff
path: root/packages
diff options
context:
space:
mode:
authorsyuilo <Syuilotan@yahoo.co.jp>2023-05-09 09:17:34 +0900
committerGitHub <noreply@github.com>2023-05-09 09:17:34 +0900
commit94690c835e3179e3fd616758ad00a8b66d844a0a (patch)
tree3171356ca8298aa6caae7c95df7232844163f913 /packages
parentMerge pull request #10608 from misskey-dev/develop (diff)
parent[ci skip] 13.12.0 (diff)
downloadmisskey-94690c835e3179e3fd616758ad00a8b66d844a0a.tar.gz
misskey-94690c835e3179e3fd616758ad00a8b66d844a0a.tar.bz2
misskey-94690c835e3179e3fd616758ad00a8b66d844a0a.zip
Merge pull request #10774 from misskey-dev/develop
Release: 13.12.0
Diffstat (limited to 'packages')
-rw-r--r--packages/backend/migration/1680702787050-UserMemo.js18
-rw-r--r--packages/backend/migration/1681400427971-serverRules.js11
-rw-r--r--packages/backend/migration/1681870960239-RoleTLSetting.js12
-rw-r--r--packages/backend/migration/1682190963894-movedAt.js13
-rw-r--r--packages/backend/migration/1682754135458-preservedUsernames.js11
-rw-r--r--packages/backend/migration/1682985520254-channelColor.js11
-rw-r--r--packages/backend/migration/1683328299359-channelArchive.js13
-rw-r--r--packages/backend/package.json100
-rw-r--r--packages/backend/src/GlobalModule.ts28
-rw-r--r--packages/backend/src/boot/common.ts2
-rw-r--r--packages/backend/src/config.ts20
-rw-r--r--packages/backend/src/const.ts5
-rw-r--r--packages/backend/src/core/AccountMoveService.ts327
-rw-r--r--packages/backend/src/core/AchievementService.ts1
-rw-r--r--packages/backend/src/core/AntennaService.ts2
-rw-r--r--packages/backend/src/core/AppLockService.ts2
-rw-r--r--packages/backend/src/core/CacheService.ts2
-rw-r--r--packages/backend/src/core/CoreModule.ts7
-rw-r--r--packages/backend/src/core/CustomEmojiService.ts18
-rw-r--r--packages/backend/src/core/DownloadService.ts10
-rw-r--r--packages/backend/src/core/DriveService.ts46
-rw-r--r--packages/backend/src/core/FederatedInstanceService.ts27
-rw-r--r--packages/backend/src/core/FetchInstanceMetadataService.ts4
-rw-r--r--packages/backend/src/core/FileInfoService.ts25
-rw-r--r--packages/backend/src/core/GlobalEventService.ts2
-rw-r--r--packages/backend/src/core/MetaService.ts2
-rw-r--r--packages/backend/src/core/NoteCreateService.ts18
-rw-r--r--packages/backend/src/core/NotificationService.ts4
-rw-r--r--packages/backend/src/core/PushNotificationService.ts2
-rw-r--r--packages/backend/src/core/QueueModule.ts2
-rw-r--r--packages/backend/src/core/QueueService.ts33
-rw-r--r--packages/backend/src/core/RemoteUserResolveService.ts10
-rw-r--r--packages/backend/src/core/RoleService.ts5
-rw-r--r--packages/backend/src/core/SearchService.ts178
-rw-r--r--packages/backend/src/core/SignupService.ts23
-rw-r--r--packages/backend/src/core/UserFollowingService.ts207
-rw-r--r--packages/backend/src/core/UserKeypairService.ts2
-rw-r--r--packages/backend/src/core/UserSuspendService.ts4
-rw-r--r--packages/backend/src/core/WebfingerService.ts3
-rw-r--r--packages/backend/src/core/WebhookService.ts2
-rw-r--r--packages/backend/src/core/activitypub/ApDbResolverService.ts8
-rw-r--r--packages/backend/src/core/activitypub/ApInboxService.ts60
-rw-r--r--packages/backend/src/core/activitypub/ApRendererService.ts66
-rw-r--r--packages/backend/src/core/activitypub/ApResolverService.ts4
-rw-r--r--packages/backend/src/core/activitypub/models/ApImageService.ts5
-rw-r--r--packages/backend/src/core/activitypub/models/ApNoteService.ts16
-rw-r--r--packages/backend/src/core/activitypub/models/ApPersonService.ts145
-rw-r--r--packages/backend/src/core/entities/ChannelEntityService.ts2
-rw-r--r--packages/backend/src/core/entities/NoteEntityService.ts1
-rw-r--r--packages/backend/src/core/entities/NotificationEntityService.ts14
-rw-r--r--packages/backend/src/core/entities/RoleEntityService.ts1
-rw-r--r--packages/backend/src/core/entities/UserEntityService.ts58
-rw-r--r--packages/backend/src/daemons/ServerStatsService.ts2
-rw-r--r--packages/backend/src/di-symbols.ts2
-rw-r--r--packages/backend/src/misc/cache.ts6
-rw-r--r--packages/backend/src/misc/check-https.ts4
-rw-r--r--packages/backend/src/misc/check-word-mute.ts20
-rw-r--r--packages/backend/src/models/RepositoryModule.ts10
-rw-r--r--packages/backend/src/models/entities/Channel.ts12
-rw-r--r--packages/backend/src/models/entities/Meta.ts12
-rw-r--r--packages/backend/src/models/entities/Role.ts5
-rw-r--r--packages/backend/src/models/entities/User.ts18
-rw-r--r--packages/backend/src/models/entities/UserMemo.ts42
-rw-r--r--packages/backend/src/models/index.ts3
-rw-r--r--packages/backend/src/models/json-schema/channel.ts8
-rw-r--r--packages/backend/src/models/json-schema/user.ts13
-rw-r--r--packages/backend/src/postgres.ts2
-rw-r--r--packages/backend/src/queue/DbQueueProcessorsService.ts6
-rw-r--r--packages/backend/src/queue/QueueProcessorModule.ts4
-rw-r--r--packages/backend/src/queue/RelationshipQueueProcessorsService.ts2
-rw-r--r--packages/backend/src/queue/processors/DeliverProcessorService.ts15
-rw-r--r--packages/backend/src/queue/processors/ExportAntennasProcessorService.ts103
-rw-r--r--packages/backend/src/queue/processors/ExportBlockingProcessorService.ts2
-rw-r--r--packages/backend/src/queue/processors/ExportFavoritesProcessorService.ts2
-rw-r--r--packages/backend/src/queue/processors/ExportFollowingProcessorService.ts2
-rw-r--r--packages/backend/src/queue/processors/ExportMutingProcessorService.ts2
-rw-r--r--packages/backend/src/queue/processors/ExportNotesProcessorService.ts2
-rw-r--r--packages/backend/src/queue/processors/ExportUserListsProcessorService.ts2
-rw-r--r--packages/backend/src/queue/processors/ImportAntennasProcessorService.ts96
-rw-r--r--packages/backend/src/queue/processors/InboxProcessorService.ts11
-rw-r--r--packages/backend/src/queue/processors/RelationshipProcessorService.ts3
-rw-r--r--packages/backend/src/queue/types.ts12
-rw-r--r--packages/backend/src/server/ActivityPubServerService.ts6
-rw-r--r--packages/backend/src/server/FileServerService.ts6
-rw-r--r--packages/backend/src/server/WellKnownServerService.ts4
-rw-r--r--packages/backend/src/server/api/ApiCallService.ts11
-rw-r--r--packages/backend/src/server/api/EndpointsModule.ts20
-rw-r--r--packages/backend/src/server/api/RateLimiterService.ts2
-rw-r--r--packages/backend/src/server/api/SignupApiService.ts7
-rw-r--r--packages/backend/src/server/api/StreamingApiServerService.ts2
-rw-r--r--packages/backend/src/server/api/endpoints.ts18
-rw-r--r--packages/backend/src/server/api/endpoints/admin/accounts/create.ts1
-rw-r--r--packages/backend/src/server/api/endpoints/admin/emoji/list.ts16
-rw-r--r--packages/backend/src/server/api/endpoints/admin/emoji/set-license-bulk.ts37
-rw-r--r--packages/backend/src/server/api/endpoints/admin/federation/remove-all-following.ts4
-rw-r--r--packages/backend/src/server/api/endpoints/admin/federation/update-instance.ts4
-rw-r--r--packages/backend/src/server/api/endpoints/admin/meta.ts9
-rw-r--r--packages/backend/src/server/api/endpoints/admin/roles/create.ts4
-rw-r--r--packages/backend/src/server/api/endpoints/admin/roles/update.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/admin/server-info.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/admin/show-user.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/admin/update-meta.ts10
-rw-r--r--packages/backend/src/server/api/endpoints/antennas/create.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/antennas/notes.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/antennas/update.ts4
-rw-r--r--packages/backend/src/server/api/endpoints/channels/create.ts4
-rw-r--r--packages/backend/src/server/api/endpoints/channels/favorite.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/channels/featured.ts1
-rw-r--r--packages/backend/src/server/api/endpoints/channels/follow.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/channels/owned.ts3
-rw-r--r--packages/backend/src/server/api/endpoints/channels/search.ts19
-rw-r--r--packages/backend/src/server/api/endpoints/channels/timeline.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/channels/unfavorite.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/channels/unfollow.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/channels/update.ts4
-rw-r--r--packages/backend/src/server/api/endpoints/clips/add-note.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/clips/create.ts4
-rw-r--r--packages/backend/src/server/api/endpoints/clips/favorite.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/clips/remove-note.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/clips/unfavorite.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/clips/update.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/drive/files/create.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/drive/files/update.ts13
-rw-r--r--packages/backend/src/server/api/endpoints/drive/files/upload-from-url.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/flash/create.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/flash/like.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/flash/unlike.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/flash/update.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/following/create.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/gallery/posts/create.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/gallery/posts/like.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/gallery/posts/unlike.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/gallery/posts/update.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/hashtags/users.ts3
-rw-r--r--packages/backend/src/server/api/endpoints/i/claim-achievement.ts1
-rw-r--r--packages/backend/src/server/api/endpoints/i/export-antennas.ts30
-rw-r--r--packages/backend/src/server/api/endpoints/i/import-antennas.ts84
-rw-r--r--packages/backend/src/server/api/endpoints/i/import-blocking.ts11
-rw-r--r--packages/backend/src/server/api/endpoints/i/import-following.ts11
-rw-r--r--packages/backend/src/server/api/endpoints/i/import-muting.ts11
-rw-r--r--packages/backend/src/server/api/endpoints/i/import-user-lists.ts11
-rw-r--r--packages/backend/src/server/api/endpoints/i/known-as.ts92
-rw-r--r--packages/backend/src/server/api/endpoints/i/move.ts70
-rw-r--r--packages/backend/src/server/api/endpoints/i/notifications.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/i/pin.ts1
-rw-r--r--packages/backend/src/server/api/endpoints/i/update.ts85
-rw-r--r--packages/backend/src/server/api/endpoints/meta.ts7
-rw-r--r--packages/backend/src/server/api/endpoints/mute/create.ts1
-rw-r--r--packages/backend/src/server/api/endpoints/notes/create.ts4
-rw-r--r--packages/backend/src/server/api/endpoints/notes/favorites/create.ts1
-rw-r--r--packages/backend/src/server/api/endpoints/notes/polls/vote.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/notes/reactions/create.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/notes/search.ts41
-rw-r--r--packages/backend/src/server/api/endpoints/pages/create.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/pages/like.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/pages/unlike.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/pages/update.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/renote-mute/create.ts1
-rw-r--r--packages/backend/src/server/api/endpoints/reset-db.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/roles/notes.ts7
-rw-r--r--packages/backend/src/server/api/endpoints/username/available.ts9
-rw-r--r--packages/backend/src/server/api/endpoints/users.ts5
-rw-r--r--packages/backend/src/server/api/endpoints/users/lists/create.ts4
-rw-r--r--packages/backend/src/server/api/endpoints/users/lists/pull.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/users/lists/push.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/users/update-memo.ts85
-rw-r--r--packages/backend/src/server/web/UrlPreviewService.ts4
-rw-r--r--packages/backend/src/server/web/views/base.pug2
-rw-r--r--packages/backend/test/e2e/endpoints.ts198
-rw-r--r--packages/backend/test/e2e/move.ts456
-rw-r--r--packages/backend/test/e2e/note.ts66
-rw-r--r--packages/backend/test/e2e/users.ts71
-rw-r--r--packages/backend/test/resources/kick_gaba7.aacbin0 -> 7291 bytes
-rw-r--r--packages/backend/test/resources/kick_gaba7.flacbin0 -> 108793 bytes
-rw-r--r--packages/backend/test/resources/kick_gaba7.mp3bin0 -> 19853 bytes
-rw-r--r--packages/backend/test/resources/kick_gaba7.wavbin0 -> 87630 bytes
-rw-r--r--packages/backend/test/resources/kick_gaba7.webmbin0 -> 8879 bytes
-rw-r--r--packages/backend/test/unit/FileInfoService.ts409
-rw-r--r--packages/backend/test/unit/RelayService.ts10
-rw-r--r--packages/backend/test/unit/misc/check-word-mute.ts49
-rw-r--r--packages/frontend/.storybook/changes.ts1
-rw-r--r--packages/frontend/.storybook/generate.tsx15
-rw-r--r--packages/frontend/.storybook/main.ts6
-rw-r--r--packages/frontend/.storybook/mocks.ts10
-rw-r--r--packages/frontend/.storybook/preview-head.html2
-rw-r--r--packages/frontend/.storybook/preview.ts36
-rw-r--r--packages/frontend/.vscode/storybook.code-snippets84
-rw-r--r--packages/frontend/package.json91
-rw-r--r--packages/frontend/src/components/MkAbuseReport.stories.impl.ts49
-rw-r--r--packages/frontend/src/components/MkAbuseReportWindow.stories.impl.ts49
-rw-r--r--packages/frontend/src/components/MkAccountMoved.stories.impl.ts33
-rw-r--r--packages/frontend/src/components/MkAccountMoved.vue16
-rw-r--r--packages/frontend/src/components/MkAchievements.stories.impl.ts56
-rw-r--r--packages/frontend/src/components/MkAnalogClock.stories.impl.ts9
-rw-r--r--packages/frontend/src/components/MkAnalogClock.vue12
-rw-r--r--packages/frontend/src/components/MkAsUi.stories.impl.ts2
-rw-r--r--packages/frontend/src/components/MkAutocomplete.stories.impl.ts176
-rw-r--r--packages/frontend/src/components/MkAvatars.stories.impl.ts46
-rw-r--r--packages/frontend/src/components/MkButton.stories.impl.ts53
-rw-r--r--packages/frontend/src/components/MkColorInput.vue110
-rw-r--r--packages/frontend/src/components/MkContainer.vue169
-rw-r--r--packages/frontend/src/components/MkDialog.vue8
-rw-r--r--packages/frontend/src/components/MkFolder.vue10
-rw-r--r--packages/frontend/src/components/MkFollowButton.vue2
-rw-r--r--packages/frontend/src/components/MkGalleryPostPreview.stories.impl.ts8
-rw-r--r--packages/frontend/src/components/MkGalleryPostPreview.vue52
-rw-r--r--packages/frontend/src/components/MkImgWithBlurhash.vue100
-rw-r--r--packages/frontend/src/components/MkInfo.vue1
-rw-r--r--packages/frontend/src/components/MkInput.vue194
-rw-r--r--packages/frontend/src/components/MkMediaImage.vue50
-rw-r--r--packages/frontend/src/components/MkMediaList.vue17
-rw-r--r--packages/frontend/src/components/MkMediaVideo.vue7
-rw-r--r--packages/frontend/src/components/MkModal.vue6
-rw-r--r--packages/frontend/src/components/MkModalWindow.vue9
-rw-r--r--packages/frontend/src/components/MkNote.vue34
-rw-r--r--packages/frontend/src/components/MkNoteDetailed.vue5
-rw-r--r--packages/frontend/src/components/MkNumberDiff.vue51
-rw-r--r--packages/frontend/src/components/MkOmit.vue2
-rw-r--r--packages/frontend/src/components/MkPostForm.vue13
-rw-r--r--packages/frontend/src/components/MkRadio.vue2
-rw-r--r--packages/frontend/src/components/MkRadios.vue14
-rw-r--r--packages/frontend/src/components/MkRange.vue6
-rw-r--r--packages/frontend/src/components/MkReactedUsersDialog.vue4
-rw-r--r--packages/frontend/src/components/MkReactionsViewer.details.vue19
-rw-r--r--packages/frontend/src/components/MkRenotedUsersDialog.vue65
-rw-r--r--packages/frontend/src/components/MkRetentionHeatmap.vue26
-rw-r--r--packages/frontend/src/components/MkSample.vue2
-rw-r--r--packages/frontend/src/components/MkSignup.vue263
-rw-r--r--packages/frontend/src/components/MkSignupDialog.form.vue272
-rw-r--r--packages/frontend/src/components/MkSignupDialog.rules.stories.impl.ts94
-rw-r--r--packages/frontend/src/components/MkSignupDialog.rules.vue124
-rw-r--r--packages/frontend/src/components/MkSignupDialog.vue45
-rw-r--r--packages/frontend/src/components/MkSwitch.vue2
-rw-r--r--packages/frontend/src/components/MkUserInfo.vue196
-rw-r--r--packages/frontend/src/components/MkUserList.vue6
-rw-r--r--packages/frontend/src/components/MkUserSetupDialog.Follow.stories.impl.ts51
-rw-r--r--packages/frontend/src/components/MkUserSetupDialog.Follow.vue63
-rw-r--r--packages/frontend/src/components/MkUserSetupDialog.Profile.stories.impl.ts31
-rw-r--r--packages/frontend/src/components/MkUserSetupDialog.Profile.vue101
-rw-r--r--packages/frontend/src/components/MkUserSetupDialog.User.stories.impl.ts32
-rw-r--r--packages/frontend/src/components/MkUserSetupDialog.User.vue101
-rw-r--r--packages/frontend/src/components/MkUserSetupDialog.stories.impl.ts51
-rw-r--r--packages/frontend/src/components/MkUserSetupDialog.vue145
-rw-r--r--packages/frontend/src/components/MkVisitorDashboard.ActiveUsersChart.vue157
-rw-r--r--packages/frontend/src/components/MkVisitorDashboard.vue227
-rw-r--r--packages/frontend/src/components/MkWidgets.vue4
-rw-r--r--packages/frontend/src/components/MkWindow.vue4
-rw-r--r--packages/frontend/src/components/global/MkAcct.stories.impl.ts32
-rw-r--r--packages/frontend/src/components/global/MkAcct.vue7
-rw-r--r--packages/frontend/src/components/global/MkAvatar.vue4
-rw-r--r--packages/frontend/src/components/global/MkCondensedLine.stories.impl.ts39
-rw-r--r--packages/frontend/src/components/global/MkCondensedLine.vue65
-rw-r--r--packages/frontend/src/components/global/MkError.stories.impl.ts10
-rw-r--r--packages/frontend/src/components/global/MkPageHeader.vue2
-rw-r--r--packages/frontend/src/components/global/MkTime.vue3
-rw-r--r--packages/frontend/src/components/index.ts3
-rw-r--r--packages/frontend/src/config.ts11
-rw-r--r--packages/frontend/src/const.ts6
-rw-r--r--packages/frontend/src/directives/container.ts21
-rw-r--r--packages/frontend/src/directives/index.ts2
-rw-r--r--packages/frontend/src/init.ts61
-rw-r--r--packages/frontend/src/local-storage.ts7
-rw-r--r--packages/frontend/src/os.ts14
-rw-r--r--packages/frontend/src/pages/about-misskey.vue19
-rw-r--r--packages/frontend/src/pages/about.emojis.vue10
-rw-r--r--packages/frontend/src/pages/about.vue84
-rw-r--r--packages/frontend/src/pages/admin/email-settings.vue4
-rw-r--r--packages/frontend/src/pages/admin/moderation.vue45
-rw-r--r--packages/frontend/src/pages/admin/roles.edit.vue1
-rw-r--r--packages/frontend/src/pages/admin/roles.editor.vue34
-rw-r--r--packages/frontend/src/pages/admin/roles.vue8
-rw-r--r--packages/frontend/src/pages/admin/server-rules.vue128
-rw-r--r--packages/frontend/src/pages/admin/settings.vue29
-rw-r--r--packages/frontend/src/pages/channel-editor.vue28
-rw-r--r--packages/frontend/src/pages/channel.vue38
-rw-r--r--packages/frontend/src/pages/channels.vue2
-rw-r--r--packages/frontend/src/pages/custom-emojis-manager.vue15
-rw-r--r--packages/frontend/src/pages/flash/flash-edit.vue19
-rw-r--r--packages/frontend/src/pages/gallery/edit.vue12
-rw-r--r--packages/frontend/src/pages/my-lists/list.vue2
-rw-r--r--packages/frontend/src/pages/page-editor/els/page-editor.el.image.vue4
-rw-r--r--packages/frontend/src/pages/role.vue42
-rw-r--r--packages/frontend/src/pages/settings/account-stats.vue (renamed from packages/frontend/src/pages/settings/account-info.vue)14
-rw-r--r--packages/frontend/src/pages/settings/delete-account.vue52
-rw-r--r--packages/frontend/src/pages/settings/drive.vue7
-rw-r--r--packages/frontend/src/pages/settings/general.vue120
-rw-r--r--packages/frontend/src/pages/settings/import-export.vue33
-rw-r--r--packages/frontend/src/pages/settings/index.vue6
-rw-r--r--packages/frontend/src/pages/settings/migration.vue118
-rw-r--r--packages/frontend/src/pages/settings/other.vue107
-rw-r--r--packages/frontend/src/pages/settings/preferences-backups.vue1
-rw-r--r--packages/frontend/src/pages/settings/profile.vue116
-rw-r--r--packages/frontend/src/pages/timeline.tutorial.vue69
-rw-r--r--packages/frontend/src/pages/timeline.vue2
-rw-r--r--packages/frontend/src/pages/user/home.vue118
-rw-r--r--packages/frontend/src/pages/welcome.entrance.a.vue156
-rw-r--r--packages/frontend/src/pages/welcome.entrance.b.vue239
-rw-r--r--packages/frontend/src/pages/welcome.entrance.c.vue308
-rw-r--r--packages/frontend/src/pages/welcome.setup.vue57
-rw-r--r--packages/frontend/src/pages/welcome.timeline.vue2
-rw-r--r--packages/frontend/src/router.ts14
-rw-r--r--packages/frontend/src/scripts/achievements.ts7
-rw-r--r--packages/frontend/src/scripts/aiscript/api.ts1
-rw-r--r--packages/frontend/src/scripts/get-drive-file-menu.ts12
-rw-r--r--packages/frontend/src/scripts/get-note-menu.ts14
-rw-r--r--packages/frontend/src/scripts/get-user-menu.ts27
-rw-r--r--packages/frontend/src/scripts/please-login.ts2
-rw-r--r--packages/frontend/src/scripts/select-file.ts120
-rw-r--r--packages/frontend/src/scripts/show-moved-dialog.ts16
-rw-r--r--packages/frontend/src/scripts/upload.ts8
-rw-r--r--packages/frontend/src/store.ts30
-rw-r--r--packages/frontend/src/themes/d-botanical.json53
-rw-r--r--packages/frontend/src/themes/d-dark.json53
-rw-r--r--packages/frontend/src/themes/d-future.json53
-rw-r--r--packages/frontend/src/themes/d-green-lime.json53
-rw-r--r--packages/frontend/src/themes/d-green-orange.json53
-rw-r--r--packages/frontend/src/ui/_common_/common.ts2
-rw-r--r--packages/frontend/src/ui/_common_/common.vue108
-rw-r--r--packages/frontend/src/ui/deck/channel-column.vue2
-rw-r--r--packages/frontend/src/ui/deck/column.vue11
-rw-r--r--packages/frontend/src/ui/universal.vue2
-rw-r--r--packages/frontend/src/ui/universal.widgets.vue2
-rw-r--r--packages/frontend/src/ui/visitor.vue289
-rw-r--r--packages/frontend/src/ui/visitor/a.vue263
-rw-r--r--packages/frontend/src/ui/visitor/b.vue266
-rw-r--r--packages/frontend/src/ui/visitor/header.vue211
-rw-r--r--packages/frontend/src/ui/visitor/kanban.vue261
-rw-r--r--packages/frontend/tsconfig.json2
-rw-r--r--packages/frontend/vite.config.ts18
-rw-r--r--packages/misskey-js/etc/misskey-js.api.md10
-rw-r--r--packages/misskey-js/package.json20
-rw-r--r--packages/misskey-js/src/api.types.ts2
-rw-r--r--packages/misskey-js/src/entities.ts5
-rw-r--r--packages/sw/package.json8
-rw-r--r--packages/sw/src/sw.ts4
335 files changed, 8061 insertions, 3868 deletions
diff --git a/packages/backend/migration/1680702787050-UserMemo.js b/packages/backend/migration/1680702787050-UserMemo.js
new file mode 100644
index 0000000000..7446bf8da5
--- /dev/null
+++ b/packages/backend/migration/1680702787050-UserMemo.js
@@ -0,0 +1,18 @@
+export class UserMemo1680702787050 {
+ name = 'UserMemo1680702787050'
+
+ async up(queryRunner) {
+ await queryRunner.query(`CREATE TABLE "user_memo" ("id" character varying(32) NOT NULL, "userId" character varying(32) NOT NULL, "targetUserId" character varying(32) NOT NULL, "memo" character varying(2048) NOT NULL, CONSTRAINT "PK_e9aaa58f7d3699a84d79078f4d9" PRIMARY KEY ("id")); COMMENT ON COLUMN "user_memo"."userId" IS 'The ID of author.'; COMMENT ON COLUMN "user_memo"."targetUserId" IS 'The ID of target user.'; COMMENT ON COLUMN "user_memo"."memo" IS 'Memo.'`);
+ await queryRunner.query(`CREATE INDEX "IDX_650b49c5639b5840ee6a2b8f83" ON "user_memo" ("userId") `);
+ await queryRunner.query(`CREATE INDEX "IDX_66ac4a82894297fd09ba61f3d3" ON "user_memo" ("targetUserId") `);
+ await queryRunner.query(`CREATE UNIQUE INDEX "IDX_faef300913c738265638ba3ebc" ON "user_memo" ("userId", "targetUserId") `);
+ await queryRunner.query(`ALTER TABLE "user_memo" ADD CONSTRAINT "FK_650b49c5639b5840ee6a2b8f83e" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
+ await queryRunner.query(`ALTER TABLE "user_memo" ADD CONSTRAINT "FK_66ac4a82894297fd09ba61f3d35" FOREIGN KEY ("targetUserId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
+ }
+
+ async down(queryRunner) {
+ await queryRunner.query(`ALTER TABLE "user_memo" DROP CONSTRAINT "FK_66ac4a82894297fd09ba61f3d35"`);
+ await queryRunner.query(`ALTER TABLE "user_memo" DROP CONSTRAINT "FK_650b49c5639b5840ee6a2b8f83e"`);
+ await queryRunner.query(`DROP TABLE "user_memo"`);
+ }
+}
diff --git a/packages/backend/migration/1681400427971-serverRules.js b/packages/backend/migration/1681400427971-serverRules.js
new file mode 100644
index 0000000000..2364e8e1d2
--- /dev/null
+++ b/packages/backend/migration/1681400427971-serverRules.js
@@ -0,0 +1,11 @@
+export class ServerRules1681400427971 {
+ name = 'ServerRules1681400427971'
+
+ async up(queryRunner) {
+ await queryRunner.query(`ALTER TABLE "meta" ADD "serverRules" character varying(280) array NOT NULL DEFAULT '{}'`);
+ }
+
+ async down(queryRunner) {
+ await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "serverRules"`);
+ }
+}
diff --git a/packages/backend/migration/1681870960239-RoleTLSetting.js b/packages/backend/migration/1681870960239-RoleTLSetting.js
new file mode 100644
index 0000000000..2280f44eaa
--- /dev/null
+++ b/packages/backend/migration/1681870960239-RoleTLSetting.js
@@ -0,0 +1,12 @@
+export class RoleTLSetting1681870960239 {
+ name = 'RoleTLSetting1681870960239'
+
+ async up(queryRunner) {
+ await queryRunner.query(`ALTER TABLE "role" ADD "isExplorable" boolean NOT NULL DEFAULT false`);
+ }
+
+ async down(queryRunner) {
+ await queryRunner.query(`ALTER TABLE "role" DROP COLUMN "isExplorable"`);
+ }
+
+}
diff --git a/packages/backend/migration/1682190963894-movedAt.js b/packages/backend/migration/1682190963894-movedAt.js
new file mode 100644
index 0000000000..1f8f030a5c
--- /dev/null
+++ b/packages/backend/migration/1682190963894-movedAt.js
@@ -0,0 +1,13 @@
+export class MovedAt1682190963894 {
+ name = 'MovedAt1682190963894'
+
+ async up(queryRunner) {
+ await queryRunner.query(`ALTER TABLE "user" ADD "movedAt" TIMESTAMP WITH TIME ZONE`);
+ await queryRunner.query(`COMMENT ON COLUMN "user"."movedAt" IS 'When the user moved to another account'`);
+ }
+
+ async down(queryRunner) {
+ await queryRunner.query(`COMMENT ON COLUMN "user"."movedAt" IS 'When the user moved to another account'`);
+ await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "movedAt"`);
+ }
+}
diff --git a/packages/backend/migration/1682754135458-preservedUsernames.js b/packages/backend/migration/1682754135458-preservedUsernames.js
new file mode 100644
index 0000000000..46a0826f43
--- /dev/null
+++ b/packages/backend/migration/1682754135458-preservedUsernames.js
@@ -0,0 +1,11 @@
+export class PreservedUsernames1682754135458 {
+ name = 'PreservedUsernames1682754135458'
+
+ async up(queryRunner) {
+ await queryRunner.query(`ALTER TABLE "meta" ADD "preservedUsernames" character varying(1024) array NOT NULL DEFAULT '{ "admin", "administrator", "root", "system", "maintainer", "host", "mod", "moderator", "owner", "superuser", "staff", "auth", "i", "me", "everyone", "all", "mention", "mentions", "example", "user", "users", "account", "accounts", "official", "help", "helps", "support", "supports", "info", "information", "informations", "announce", "announces", "announcement", "announcements", "notice", "notification", "notifications", "dev", "developer", "developers", "tech", "misskey" }'`);
+ }
+
+ async down(queryRunner) {
+ await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "preservedUsernames"`);
+ }
+}
diff --git a/packages/backend/migration/1682985520254-channelColor.js b/packages/backend/migration/1682985520254-channelColor.js
new file mode 100644
index 0000000000..294b7372b2
--- /dev/null
+++ b/packages/backend/migration/1682985520254-channelColor.js
@@ -0,0 +1,11 @@
+export class ChannelColor1682985520254 {
+ name = 'ChannelColor1682985520254'
+
+ async up(queryRunner) {
+ await queryRunner.query(`ALTER TABLE "channel" ADD "color" character varying(16) NOT NULL DEFAULT '#86b300'`);
+ }
+
+ async down(queryRunner) {
+ await queryRunner.query(`ALTER TABLE "channel" DROP COLUMN "color"`);
+ }
+}
diff --git a/packages/backend/migration/1683328299359-channelArchive.js b/packages/backend/migration/1683328299359-channelArchive.js
new file mode 100644
index 0000000000..83695ff537
--- /dev/null
+++ b/packages/backend/migration/1683328299359-channelArchive.js
@@ -0,0 +1,13 @@
+export class ChannelArchive1683328299359 {
+ name = 'ChannelArchive1683328299359'
+
+ async up(queryRunner) {
+ await queryRunner.query(`ALTER TABLE "channel" ADD "isArchived" boolean NOT NULL DEFAULT false`);
+ await queryRunner.query(`CREATE INDEX "IDX_cc7c72974f1b2f385a8921f094" ON "channel" ("isArchived") `);
+ }
+
+ async down(queryRunner) {
+ await queryRunner.query(`DROP INDEX "public"."IDX_cc7c72974f1b2f385a8921f094"`);
+ await queryRunner.query(`ALTER TABLE "channel" DROP COLUMN "isArchived"`);
+ }
+}
diff --git a/packages/backend/package.json b/packages/backend/package.json
index 875774bbd5..e0ece2bfe5 100644
--- a/packages/backend/package.json
+++ b/packages/backend/package.json
@@ -23,33 +23,44 @@
},
"optionalDependencies": {
"@swc/core-android-arm64": "1.3.11",
- "@swc/core-darwin-arm64": "1.3.46",
- "@swc/core-darwin-x64": "1.3.46",
- "@swc/core-linux-arm-gnueabihf": "1.3.46",
- "@swc/core-linux-arm64-gnu": "1.3.46",
- "@swc/core-linux-arm64-musl": "1.3.46",
- "@swc/core-linux-x64-gnu": "1.3.46",
- "@swc/core-linux-x64-musl": "1.3.46",
- "@swc/core-win32-arm64-msvc": "1.3.46",
- "@swc/core-win32-ia32-msvc": "1.3.46",
- "@swc/core-win32-x64-msvc": "1.3.46",
- "@tensorflow/tfjs": "4.2.0",
- "@tensorflow/tfjs-node": "4.2.0"
+ "@swc/core-darwin-arm64": "1.3.56",
+ "@swc/core-darwin-x64": "1.3.56",
+ "@swc/core-linux-arm-gnueabihf": "1.3.56",
+ "@swc/core-linux-arm64-gnu": "1.3.56",
+ "@swc/core-linux-arm64-musl": "1.3.56",
+ "@swc/core-linux-x64-gnu": "1.3.56",
+ "@swc/core-linux-x64-musl": "1.3.56",
+ "@swc/core-win32-arm64-msvc": "1.3.56",
+ "@swc/core-win32-ia32-msvc": "1.3.56",
+ "@swc/core-win32-x64-msvc": "1.3.56",
+ "@tensorflow/tfjs": "4.4.0",
+ "@tensorflow/tfjs-node": "4.4.0",
+ "slacc-android-arm-eabi": "0.0.7",
+ "slacc-android-arm64": "0.0.7",
+ "slacc-darwin-arm64": "0.0.7",
+ "slacc-darwin-universal": "0.0.7",
+ "slacc-darwin-x64": "0.0.7",
+ "slacc-linux-arm-gnueabihf": "0.0.7",
+ "slacc-linux-arm64-gnu": "0.0.7",
+ "slacc-linux-arm64-musl": "0.0.7",
+ "slacc-linux-x64-gnu": "0.0.7",
+ "slacc-win32-arm64-msvc": "0.0.7",
+ "slacc-win32-x64-msvc": "0.0.7"
},
"dependencies": {
- "@aws-sdk/client-s3": "3.306.0",
- "@aws-sdk/lib-storage": "3.306.0",
- "@aws-sdk/node-http-handler": "3.306.0",
- "@bull-board/api": "5.0.0",
- "@bull-board/fastify": "5.0.0",
- "@bull-board/ui": "5.0.0",
+ "@aws-sdk/client-s3": "3.321.1",
+ "@aws-sdk/lib-storage": "3.321.1",
+ "@aws-sdk/node-http-handler": "3.321.1",
+ "@bull-board/api": "5.1.2",
+ "@bull-board/fastify": "5.1.2",
+ "@bull-board/ui": "5.1.2",
"@discordapp/twemoji": "14.1.2",
"@fastify/accepts": "4.1.0",
"@fastify/cookie": "8.3.0",
"@fastify/cors": "8.2.1",
"@fastify/http-proxy": "9.0.0",
- "@fastify/multipart": "7.5.0",
- "@fastify/static": "6.10.0",
+ "@fastify/multipart": "7.6.0",
+ "@fastify/static": "6.10.1",
"@fastify/view": "7.4.1",
"@nestjs/common": "9.4.0",
"@nestjs/core": "9.4.0",
@@ -57,7 +68,7 @@
"@peertube/http-signature": "1.7.0",
"@sinonjs/fake-timers": "10.0.2",
"@swc/cli": "0.1.62",
- "@swc/core": "1.3.46",
+ "@swc/core": "1.3.56",
"accepts": "1.3.8",
"ajv": "8.12.0",
"archiver": "5.3.1",
@@ -73,25 +84,26 @@
"cli-highlight": "2.1.11",
"color-convert": "2.0.1",
"content-disposition": "0.5.4",
- "date-fns": "2.29.3",
+ "date-fns": "2.30.0",
"deep-email-validator": "0.1.21",
"escape-regexp": "0.0.1",
- "fastify": "4.15.0",
+ "fastify": "4.17.0",
"feed": "4.2.2",
- "file-type": "18.2.1",
+ "file-type": "18.3.0",
"fluent-ffmpeg": "2.1.2",
"form-data": "4.0.0",
"got": "12.6.0",
- "happy-dom": "8.9.0",
+ "happy-dom": "9.10.2",
"hpagent": "1.2.0",
- "ioredis": "4.28.5",
+ "ioredis": "5.3.2",
"ip-cidr": "3.1.0",
"is-svg": "4.3.2",
"js-yaml": "4.1.0",
"jsdom": "21.1.1",
"json5": "2.2.3",
"jsonld": "8.1.1",
- "jsrsasign": "10.7.0",
+ "meilisearch": "0.32.3",
+ "jsrsasign": "10.8.6",
"mfm-js": "0.23.3",
"mime-types": "2.1.35",
"misskey-js": "workspace:*",
@@ -111,7 +123,7 @@
"pug": "3.0.2",
"punycode": "2.3.0",
"pureimage": "0.3.17",
- "qrcode": "1.5.1",
+ "qrcode": "1.5.3",
"random-seed": "0.3.0",
"ratelimiter": "3.4.1",
"re2": "1.18.0",
@@ -119,37 +131,38 @@
"reflect-metadata": "0.1.13",
"rename": "1.0.4",
"rndstr": "1.0.0",
- "rss-parser": "3.12.0",
- "rxjs": "7.8.0",
+ "rss-parser": "3.13.0",
+ "rxjs": "7.8.1",
"s-age": "1.1.2",
"sanitize-html": "2.10.0",
"seedrandom": "3.0.5",
- "semver": "7.3.8",
- "sharp": "0.32.0",
+ "semver": "7.5.0",
+ "sharp": "0.32.1",
"sharp-read-bmp": "github:misskey-dev/sharp-read-bmp",
+ "slacc": "0.0.7",
"strict-event-emitter-types": "2.0.0",
"stringz": "2.1.0",
"summaly": "github:misskey-dev/summaly",
"systeminformation": "5.17.12",
"tinycolor2": "1.6.0",
"tmp": "0.2.1",
- "tsc-alias": "1.8.5",
+ "tsc-alias": "1.8.6",
"tsconfig-paths": "4.2.0",
"twemoji-parser": "14.0.0",
- "typeorm": "0.3.13",
- "typescript": "5.0.3",
+ "typeorm": "0.3.15",
+ "typescript": "5.0.4",
"ulid": "2.3.0",
"unzipper": "0.10.11",
"uuid": "9.0.0",
"vary": "1.1.2",
- "web-push": "3.5.0",
+ "web-push": "3.6.1",
"websocket": "1.0.34",
"ws": "8.13.0",
"xev": "3.0.2"
},
"devDependencies": {
"@jest/globals": "29.5.0",
- "@swc/jest": "0.2.24",
+ "@swc/jest": "0.2.26",
"@types/accepts": "1.3.5",
"@types/archiver": "5.3.2",
"@types/bcryptjs": "2.4.2",
@@ -159,14 +172,13 @@
"@types/content-disposition": "0.5.5",
"@types/escape-regexp": "0.0.1",
"@types/fluent-ffmpeg": "2.1.21",
- "@types/ioredis": "4.28.10",
- "@types/jest": "29.5.0",
+ "@types/jest": "29.5.1",
"@types/js-yaml": "4.0.5",
"@types/jsdom": "21.1.1",
"@types/jsonld": "1.5.8",
"@types/jsrsasign": "10.5.8",
"@types/mime-types": "2.1.1",
- "@types/node": "18.15.11",
+ "@types/node": "18.16.3",
"@types/node-fetch": "3.0.3",
"@types/nodemailer": "6.4.7",
"@types/oauth": "0.9.1",
@@ -180,7 +192,7 @@
"@types/rename": "1.0.4",
"@types/sanitize-html": "2.9.0",
"@types/semver": "7.3.13",
- "@types/sharp": "0.31.1",
+ "@types/sharp": "0.32.0",
"@types/sinonjs__fake-timers": "8.1.2",
"@types/tinycolor2": "1.4.3",
"@types/tmp": "0.2.3",
@@ -190,11 +202,11 @@
"@types/web-push": "3.3.2",
"@types/websocket": "1.0.5",
"@types/ws": "8.5.4",
- "@typescript-eslint/eslint-plugin": "5.57.1",
- "@typescript-eslint/parser": "5.57.1",
+ "@typescript-eslint/eslint-plugin": "5.59.2",
+ "@typescript-eslint/parser": "5.59.2",
"aws-sdk-client-mock": "^2.1.1",
"cross-env": "7.0.3",
- "eslint": "8.37.0",
+ "eslint": "8.39.0",
"eslint-plugin-import": "2.27.5",
"execa": "6.1.0",
"jest": "29.5.0",
diff --git a/packages/backend/src/GlobalModule.ts b/packages/backend/src/GlobalModule.ts
index 174d0d8beb..5fb4e8ef3c 100644
--- a/packages/backend/src/GlobalModule.ts
+++ b/packages/backend/src/GlobalModule.ts
@@ -1,7 +1,8 @@
import { setTimeout } from 'node:timers/promises';
import { Global, Inject, Module } from '@nestjs/common';
-import Redis from 'ioredis';
+import * as Redis from 'ioredis';
import { DataSource } from 'typeorm';
+import { MeiliSearch } from 'meilisearch';
import { DI } from './di-symbols.js';
import { loadConfig } from './config.js';
import { createPostgresDataSource } from './postgres.js';
@@ -22,10 +23,25 @@ const $db: Provider = {
inject: [DI.config],
};
+const $meilisearch: Provider = {
+ provide: DI.meilisearch,
+ useFactory: (config) => {
+ if (config.meilisearch) {
+ return new MeiliSearch({
+ host: `${config.meilisearch.ssl ? 'https' : 'http' }://${config.meilisearch.host}:${config.meilisearch.port}`,
+ apiKey: config.meilisearch.apiKey,
+ });
+ } else {
+ return null;
+ }
+ },
+ inject: [DI.config],
+};
+
const $redis: Provider = {
provide: DI.redis,
useFactory: (config) => {
- return new Redis({
+ return new Redis.Redis({
port: config.redis.port,
host: config.redis.host,
family: config.redis.family == null ? 0 : config.redis.family,
@@ -40,7 +56,7 @@ const $redis: Provider = {
const $redisForPub: Provider = {
provide: DI.redisForPub,
useFactory: (config) => {
- const redis = new Redis({
+ const redis = new Redis.Redis({
port: config.redisForPubsub.port,
host: config.redisForPubsub.host,
family: config.redisForPubsub.family == null ? 0 : config.redisForPubsub.family,
@@ -56,7 +72,7 @@ const $redisForPub: Provider = {
const $redisForSub: Provider = {
provide: DI.redisForSub,
useFactory: (config) => {
- const redis = new Redis({
+ const redis = new Redis.Redis({
port: config.redisForPubsub.port,
host: config.redisForPubsub.host,
family: config.redisForPubsub.family == null ? 0 : config.redisForPubsub.family,
@@ -73,8 +89,8 @@ const $redisForSub: Provider = {
@Global()
@Module({
imports: [RepositoryModule],
- providers: [$config, $db, $redis, $redisForPub, $redisForSub],
- exports: [$config, $db, $redis, $redisForPub, $redisForSub, RepositoryModule],
+ providers: [$config, $db, $meilisearch, $redis, $redisForPub, $redisForSub],
+ exports: [$config, $db, $meilisearch, $redis, $redisForPub, $redisForSub, RepositoryModule],
})
export class GlobalModule implements OnApplicationShutdown {
constructor(
diff --git a/packages/backend/src/boot/common.ts b/packages/backend/src/boot/common.ts
index 279a1fe59d..45ded5495c 100644
--- a/packages/backend/src/boot/common.ts
+++ b/packages/backend/src/boot/common.ts
@@ -34,4 +34,6 @@ export async function jobQueue() {
jobQueue.get(QueueProcessorService).start();
jobQueue.get(ChartManagementService).start();
+
+ return jobQueue;
}
diff --git a/packages/backend/src/config.ts b/packages/backend/src/config.ts
index e4f7601fa9..b41fb603bb 100644
--- a/packages/backend/src/config.ts
+++ b/packages/backend/src/config.ts
@@ -4,7 +4,7 @@
import * as fs from 'node:fs';
import { fileURLToPath } from 'node:url';
-import { dirname } from 'node:path';
+import { dirname, resolve } from 'node:path';
import * as yaml from 'js-yaml';
/**
@@ -57,13 +57,11 @@ export type Source = {
db?: number;
prefix?: string;
};
- elasticsearch: {
+ meilisearch?: {
host: string;
- port: number;
+ port: string;
+ apiKey: string;
ssl?: boolean;
- user?: string;
- pass?: string;
- index?: string;
};
proxy?: string;
@@ -84,8 +82,10 @@ export type Source = {
deliverJobConcurrency?: number;
inboxJobConcurrency?: number;
+ relashionshipJobConcurrency?: number;
deliverJobPerSec?: number;
inboxJobPerSec?: number;
+ relashionshipJobPerSec?: number;
deliverJobMaxAttempts?: number;
inboxJobMaxAttempts?: number;
@@ -132,9 +132,11 @@ const dir = `${_dirname}/../../../.config`;
/**
* Path of configuration file
*/
-const path = process.env.NODE_ENV === 'test'
- ? `${dir}/test.yml`
- : `${dir}/default.yml`;
+const path = process.env.MISSKEY_CONFIG_YML
+ ? resolve(dir, process.env.MISSKEY_CONFIG_YML)
+ : process.env.NODE_ENV === 'test'
+ ? resolve(dir, 'test.yml')
+ : resolve(dir, 'default.yml');
export function loadConfig() {
const meta = JSON.parse(fs.readFileSync(`${_dirname}/../../../built/meta.json`, 'utf-8'));
diff --git a/packages/backend/src/const.ts b/packages/backend/src/const.ts
index 6c7f214214..ee1a9a3093 100644
--- a/packages/backend/src/const.ts
+++ b/packages/backend/src/const.ts
@@ -56,6 +56,11 @@ export const FILE_TYPE_BROWSERSAFE = [
'audio/webm',
'audio/aac',
+
+ // see https://github.com/misskey-dev/misskey/pull/10686
+ 'audio/flac',
+ 'audio/wav',
+ // backward compatibility
'audio/x-flac',
'audio/vnd.wave',
];
diff --git a/packages/backend/src/core/AccountMoveService.ts b/packages/backend/src/core/AccountMoveService.ts
index 3f2a19b771..ab11785e28 100644
--- a/packages/backend/src/core/AccountMoveService.ts
+++ b/packages/backend/src/core/AccountMoveService.ts
@@ -1,55 +1,90 @@
import { Inject, Injectable } from '@nestjs/common';
-import { IsNull } from 'typeorm';
+import { IsNull, In, MoreThan, Not } from 'typeorm';
import { bindThis } from '@/decorators.js';
import { DI } from '@/di-symbols.js';
-import type { LocalUser } from '@/models/entities/User.js';
-import { User } from '@/models/entities/User.js';
-import type { FollowingsRepository, UsersRepository } from '@/models/index.js';
+import type { Config } from '@/config.js';
+import type { LocalUser, RemoteUser } from '@/models/entities/User.js';
+import type { BlockingsRepository, FollowingsRepository, InstancesRepository, Muting, MutingsRepository, UserListJoiningsRepository, UsersRepository } from '@/models/index.js';
+import type { RelationshipJobData, ThinUser } from '@/queue/types.js';
+import type { User } from '@/models/entities/User.js';
+import { IdService } from '@/core/IdService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
-import { UserFollowingService } from '@/core/UserFollowingService.js';
+import { QueueService } from '@/core/QueueService.js';
+import { RelayService } from '@/core/RelayService.js';
+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 { AccountUpdateService } from '@/core/AccountUpdateService.js';
-import { RelayService } from '@/core/RelayService.js';
+import { CacheService } from '@/core/CacheService.js';
+import { ProxyAccountService } from '@/core/ProxyAccountService.js';
+import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
+import { MetaService } from '@/core/MetaService.js';
+import InstanceChart from '@/core/chart/charts/instance.js';
+import PerUserFollowingChart from '@/core/chart/charts/per-user-following.js';
@Injectable()
export class AccountMoveService {
constructor(
+ @Inject(DI.config)
+ private config: Config,
+
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
@Inject(DI.followingsRepository)
private followingsRepository: FollowingsRepository,
+ @Inject(DI.blockingsRepository)
+ private blockingsRepository: BlockingsRepository,
+
+ @Inject(DI.mutingsRepository)
+ private mutingsRepository: MutingsRepository,
+
+ @Inject(DI.userListJoiningsRepository)
+ private userListJoiningsRepository: UserListJoiningsRepository,
+
+ @Inject(DI.instancesRepository)
+ private instancesRepository: InstancesRepository,
+
private userEntityService: UserEntityService,
+ private idService: IdService,
+ private apPersonService: ApPersonService,
private apRendererService: ApRendererService,
private apDeliverManagerService: ApDeliverManagerService,
private globalEventService: GlobalEventService,
- private userFollowingService: UserFollowingService,
- private accountUpdateService: AccountUpdateService,
+ private proxyAccountService: ProxyAccountService,
+ private perUserFollowingChart: PerUserFollowingChart,
+ private federatedInstanceService: FederatedInstanceService,
+ private instanceChart: InstanceChart,
+ private metaService: MetaService,
private relayService: RelayService,
+ private cacheService: CacheService,
+ private queueService: QueueService,
) {
}
/**
- * Move a local account to a remote account.
+ * Move a local account to a new account.
*
* After delivering Move activity, its local followers unfollow the old account and then follow the new one.
*/
@bindThis
- public async moveToRemote(src: LocalUser, dst: User): Promise<unknown> {
- // Make sure that the destination is a remote account.
- if (this.userEntityService.isLocalUser(dst)) throw new Error('move destiantion is not remote');
- if (!dst.uri) throw new Error('destination uri is empty');
+ public async moveFromLocal(src: LocalUser, dst: LocalUser | RemoteUser): Promise<unknown> {
+ const srcUri = this.userEntityService.getUserUri(src);
+ const dstUri = this.userEntityService.getUserUri(dst);
// add movedToUri to indicate that the user has moved
- const update = {} as Partial<User>;
- update.alsoKnownAs = src.alsoKnownAs?.concat([dst.uri]) ?? [dst.uri];
- update.movedToUri = dst.uri;
+ const update = {} as Partial<LocalUser>;
+ update.alsoKnownAs = src.alsoKnownAs?.includes(dstUri) ? src.alsoKnownAs : src.alsoKnownAs?.concat([dstUri]) ?? [dstUri];
+ update.movedToUri = dstUri;
+ update.movedAt = new Date();
await this.usersRepository.update(src.id, update);
+ Object.assign(src, update);
+
+ // Update cache
+ this.cacheService.uriPersonCache.set(srcUri, src);
const srcPerson = await this.apRendererService.renderPerson(src);
const updateAct = this.apRendererService.addContext(this.apRendererService.renderUpdate(srcPerson, src));
@@ -64,51 +99,249 @@ export class AccountMoveService {
const iObj = await this.userEntityService.pack<true, true>(src.id, src, { detail: true, includeSecrets: true });
this.globalEventService.publishMainStream(src.id, 'meUpdated', iObj);
- // follow the new account and unfollow the old one
- const followings = await this.followingsRepository.find({
- relations: {
- follower: true,
- },
+ // Unfollow after 24 hours
+ const followings = await this.followingsRepository.findBy({
+ followerId: src.id,
+ });
+ this.queueService.createDelayedUnfollowJob(followings.map(following => ({
+ from: { id: src.id },
+ to: { id: following.followeeId },
+ })), process.env.NODE_ENV === 'test' ? 10000 : 1000 * 60 * 60 * 24);
+
+ await this.postMoveProcess(src, dst);
+
+ return iObj;
+ }
+
+ @bindThis
+ public async postMoveProcess(src: User, dst: User): Promise<void> {
+ // Copy blockings and mutings, and update lists
+ try {
+ await Promise.all([
+ this.copyBlocking(src, dst),
+ this.copyMutings(src, dst),
+ this.updateLists(src, dst),
+ ]);
+ } catch {
+ /* skip if any error happens */
+ }
+
+ // follow the new account
+ const proxy = await this.proxyAccountService.fetch();
+ const followings = await this.followingsRepository.findBy({
+ followeeId: src.id,
+ followerHost: IsNull(), // follower is local
+ followerId: proxy ? Not(proxy.id) : undefined,
+ });
+ const followJobs = followings.map(following => ({
+ from: { id: following.followerId },
+ to: { id: dst.id },
+ })) as RelationshipJobData[];
+
+ // Decrease following count instead of unfollowing.
+ try {
+ await this.adjustFollowingCounts(followJobs.map(job => job.from.id), src);
+ } catch {
+ /* skip if any error happens */
+ }
+
+ // Should be queued because this can cause a number of follow per one move.
+ this.queueService.createFollowJob(followJobs);
+ }
+
+ @bindThis
+ public async copyBlocking(src: ThinUser, dst: ThinUser): Promise<void> {
+ // Followers shouldn't overlap with blockers, but the destination account, different from the blockee (i.e., old account), may have followed the local user before moving.
+ // So block the destination account here.
+ const srcBlockings = await this.blockingsRepository.findBy({ blockeeId: src.id });
+ const dstBlockings = await this.blockingsRepository.findBy({ blockeeId: dst.id });
+ const blockerIds = dstBlockings.map(blocking => blocking.blockerId);
+ // reblock the destination account
+ const blockJobs: RelationshipJobData[] = [];
+ for (const blocking of srcBlockings) {
+ if (blockerIds.includes(blocking.blockerId)) continue; // skip if already blocked
+ blockJobs.push({ from: { id: blocking.blockerId }, to: { id: dst.id } });
+ }
+ // no need to unblock the old account because it may be still functional
+ this.queueService.createBlockJob(blockJobs);
+ }
+
+ @bindThis
+ public async copyMutings(src: ThinUser, dst: ThinUser): Promise<void> {
+ // Insert new mutings with the same values except mutee
+ const oldMutings = await this.mutingsRepository.findBy([
+ { muteeId: src.id, expiresAt: IsNull() },
+ { muteeId: src.id, expiresAt: MoreThan(new Date()) },
+ ]);
+ if (oldMutings.length === 0) return;
+
+ // Check if the destination account is already indefinitely muted by the muter
+ const existingMutingsMuterUserIds = await this.mutingsRepository.findBy(
+ { muteeId: dst.id, expiresAt: IsNull() },
+ ).then(mutings => mutings.map(muting => muting.muterId));
+
+ const newMutings: Map<string, { muterId: string; muteeId: string; createdAt: Date; expiresAt: Date | null; }> = new Map();
+
+ // 重複しないようにIDを生成
+ const genId = (): string => {
+ let id: string;
+ do {
+ id = this.idService.genId();
+ } while (newMutings.has(id));
+ return id;
+ };
+ for (const muting of oldMutings) {
+ if (existingMutingsMuterUserIds.includes(muting.muterId)) continue; // skip if already muted indefinitely
+ newMutings.set(genId(), {
+ ...muting,
+ createdAt: new Date(),
+ muteeId: dst.id,
+ });
+ }
+
+ const arrayToInsert = Array.from(newMutings.entries()).map(entry => ({ ...entry[1], id: entry[0] }));
+ await this.mutingsRepository.insert(arrayToInsert);
+ }
+
+ /**
+ * Update lists while moving accounts.
+ * - No removal of the old account from the lists
+ * - Users number limit is not checked
+ *
+ * @param src ThinUser (old account)
+ * @param dst User (new account)
+ * @returns Promise<void>
+ */
+ @bindThis
+ public async updateLists(src: ThinUser, dst: User): Promise<void> {
+ // Return if there is no list to be updated.
+ const oldJoinings = await this.userListJoiningsRepository.find({
where: {
- followeeId: src.id,
- followerHost: IsNull(), // follower is local
+ userId: src.id,
},
});
- for (const following of followings) {
- if (!following.follower) continue;
- try {
- await this.userFollowingService.follow(following.follower, dst);
- await this.userFollowingService.unfollow(following.follower, src);
- } catch {
- /* empty */
+ if (oldJoinings.length === 0) return;
+
+ const existingUserListIds = await this.userListJoiningsRepository.find({
+ where: {
+ userId: dst.id,
+ },
+ }).then(joinings => joinings.map(joining => joining.userListId));
+
+ const newJoinings: Map<string, { createdAt: Date; userId: string; userListId: string; }> = new Map();
+
+ // 重複しないようにIDを生成
+ const genId = (): string => {
+ let id: string;
+ do {
+ id = this.idService.genId();
+ } while (newJoinings.has(id));
+ return id;
+ };
+ for (const joining of oldJoinings) {
+ if (existingUserListIds.includes(joining.userListId)) continue; // skip if dst exists in this user's list
+ newJoinings.set(genId(), {
+ createdAt: new Date(),
+ userId: dst.id,
+ userListId: joining.userListId,
+ });
+ }
+
+ const arrayToInsert = Array.from(newJoinings.entries()).map(entry => ({ ...entry[1], id: entry[0] }));
+ await this.userListJoiningsRepository.insert(arrayToInsert);
+
+ // 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 } }]);
}
}
+ }
- return iObj;
+ @bindThis
+ private async adjustFollowingCounts(localFollowerIds: string[], oldAccount: User): Promise<void> {
+ if (localFollowerIds.length === 0) return;
+
+ // Set the old account's following and followers counts to 0.
+ await this.usersRepository.update({ id: oldAccount.id }, { followersCount: 0, followingCount: 0 });
+
+ // Decrease following counts of local followers by 1.
+ await this.usersRepository.decrement({ id: In(localFollowerIds) }, 'followingCount', 1);
+
+ // Decrease follower counts of local followees by 1.
+ const oldFollowings = await this.followingsRepository.findBy({ followerId: oldAccount.id });
+ if (oldFollowings.length > 0) {
+ await this.usersRepository.decrement({ id: In(oldFollowings.map(following => following.followeeId)) }, 'followersCount', 1);
+ }
+
+ // Update instance stats by decreasing remote followers count by the number of local followers who were following the old account.
+ if (this.userEntityService.isRemoteUser(oldAccount)) {
+ this.federatedInstanceService.fetch(oldAccount.host).then(async i => {
+ this.instancesRepository.decrement({ id: i.id }, 'followersCount', localFollowerIds.length);
+ if ((await this.metaService.fetch()).enableChartsForFederatedInstances) {
+ this.instanceChart.updateFollowers(i.host, false);
+ }
+ });
+ }
+
+ // FIXME: expensive?
+ for (const followerId of localFollowerIds) {
+ this.perUserFollowingChart.update({ id: followerId, host: null }, oldAccount, false);
+ }
}
/**
- * Create an alias of an old remote account.
+ * dstユーザーのalsoKnownAsをfetchPersonしていき、本当にmovedToUrlをdstに指定するユーザーが存在するのかを調べる
*
- * The user's new profile will be published to the followers.
+ * @param dst movedToUrlを指定するユーザー
+ * @param check
+ * @param instant checkがtrueであるユーザーが最初に見つかったら即座にreturnするかどうか
+ * @returns Promise<LocalUser | RemoteUser | null>
*/
@bindThis
- public async createAlias(me: LocalUser, updates: Partial<User>): Promise<unknown> {
- await this.usersRepository.update(me.id, updates);
+ public async validateAlsoKnownAs(
+ dst: LocalUser | RemoteUser,
+ check: (oldUser: LocalUser | RemoteUser | null, newUser: LocalUser | RemoteUser) => boolean | Promise<boolean> = () => true,
+ instant = false,
+ ): Promise<LocalUser | RemoteUser | null> {
+ let resultUser: LocalUser | RemoteUser | null = null;
- // Publish meUpdated event
- const iObj = await this.userEntityService.pack<true, true>(me.id, me, {
- detail: true,
- includeSecrets: true,
- });
- this.globalEventService.publishMainStream(me.id, 'meUpdated', iObj);
-
- if (me.isLocked === false) {
- await this.userFollowingService.acceptAllFollowRequests(me);
+ if (this.userEntityService.isRemoteUser(dst)) {
+ if ((new Date()).getTime() - (dst.lastFetchedAt?.getTime() ?? 0) > 10 * 1000) {
+ await this.apPersonService.updatePerson(dst.uri);
+ }
+ dst = await this.apPersonService.fetchPerson(dst.uri) ?? dst;
}
- this.accountUpdateService.publishToFollowers(me.id);
+ if (!dst.alsoKnownAs || dst.alsoKnownAs.length === 0) return null;
- return iObj;
+ const dstUri = this.userEntityService.getUserUri(dst);
+
+ for (const srcUri of dst.alsoKnownAs) {
+ try {
+ let src = await this.apPersonService.fetchPerson(srcUri);
+ if (!src) continue; // oldAccountを探してもこのサーバーに存在しない場合はフォロー関係もないということなのでスルー
+
+ if (this.userEntityService.isRemoteUser(dst)) {
+ if ((new Date()).getTime() - (src.lastFetchedAt?.getTime() ?? 0) > 10 * 1000) {
+ await this.apPersonService.updatePerson(srcUri);
+ }
+
+ src = await this.apPersonService.fetchPerson(srcUri) ?? src;
+ }
+
+ if (src.movedToUri === dstUri) {
+ if (await check(resultUser, src)) {
+ resultUser = src;
+ }
+ if (instant && resultUser) return resultUser;
+ }
+ } catch {
+ /* skip if any error happens */
+ }
+ }
+
+ return resultUser;
}
}
diff --git a/packages/backend/src/core/AchievementService.ts b/packages/backend/src/core/AchievementService.ts
index 1ca38d8bb0..9e223f1492 100644
--- a/packages/backend/src/core/AchievementService.ts
+++ b/packages/backend/src/core/AchievementService.ts
@@ -64,6 +64,7 @@ export const ACHIEVEMENT_TYPES = [
'iLoveMisskey',
'foundTreasure',
'client30min',
+ 'client60min',
'noteDeletedWithin1min',
'postedAtLateNight',
'postedAt0min0sec',
diff --git a/packages/backend/src/core/AntennaService.ts b/packages/backend/src/core/AntennaService.ts
index 166c78f479..2d4226a32d 100644
--- a/packages/backend/src/core/AntennaService.ts
+++ b/packages/backend/src/core/AntennaService.ts
@@ -1,5 +1,5 @@
import { Inject, Injectable } from '@nestjs/common';
-import Redis from 'ioredis';
+import * as Redis from 'ioredis';
import type { Antenna } from '@/models/entities/Antenna.js';
import type { Note } from '@/models/entities/Note.js';
import type { User } from '@/models/entities/User.js';
diff --git a/packages/backend/src/core/AppLockService.ts b/packages/backend/src/core/AppLockService.ts
index ee179b7f01..8dd805552b 100644
--- a/packages/backend/src/core/AppLockService.ts
+++ b/packages/backend/src/core/AppLockService.ts
@@ -1,7 +1,7 @@
import { promisify } from 'node:util';
import { Inject, Injectable } from '@nestjs/common';
import redisLock from 'redis-lock';
-import Redis from 'ioredis';
+import * as Redis from 'ioredis';
import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js';
diff --git a/packages/backend/src/core/CacheService.ts b/packages/backend/src/core/CacheService.ts
index 561face5c3..cf1e81ffc8 100644
--- a/packages/backend/src/core/CacheService.ts
+++ b/packages/backend/src/core/CacheService.ts
@@ -1,5 +1,5 @@
import { Inject, Injectable } from '@nestjs/common';
-import Redis from 'ioredis';
+import * as Redis from 'ioredis';
import type { BlockingsRepository, ChannelFollowingsRepository, FollowingsRepository, MutingsRepository, RenoteMutingsRepository, UserProfile, UserProfilesRepository, UsersRepository } from '@/models/index.js';
import { MemoryKVCache, RedisKVCache } from '@/misc/cache.js';
import type { LocalUser, User } from '@/models/entities/User.js';
diff --git a/packages/backend/src/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts
index 8775536e4a..d3a1b1b024 100644
--- a/packages/backend/src/core/CoreModule.ts
+++ b/packages/backend/src/core/CoreModule.ts
@@ -50,6 +50,7 @@ import { WebhookService } from './WebhookService.js';
import { ProxyAccountService } from './ProxyAccountService.js';
import { UtilityService } from './UtilityService.js';
import { FileInfoService } from './FileInfoService.js';
+import { SearchService } from './SearchService.js';
import { ChartLoggerService } from './chart/ChartLoggerService.js';
import FederationChart from './chart/charts/federation.js';
import NotesChart from './chart/charts/notes.js';
@@ -171,6 +172,8 @@ const $VideoProcessingService: Provider = { provide: 'VideoProcessingService', u
const $WebhookService: Provider = { provide: 'WebhookService', useExisting: WebhookService };
const $UtilityService: Provider = { provide: 'UtilityService', useExisting: UtilityService };
const $FileInfoService: Provider = { provide: 'FileInfoService', useExisting: FileInfoService };
+const $SearchService: Provider = { provide: 'SearchService', useExisting: SearchService };
+
const $ChartLoggerService: Provider = { provide: 'ChartLoggerService', useExisting: ChartLoggerService };
const $FederationChart: Provider = { provide: 'FederationChart', useExisting: FederationChart };
const $NotesChart: Provider = { provide: 'NotesChart', useExisting: NotesChart };
@@ -295,6 +298,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
WebhookService,
UtilityService,
FileInfoService,
+ SearchService,
ChartLoggerService,
FederationChart,
NotesChart,
@@ -413,6 +417,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$WebhookService,
$UtilityService,
$FileInfoService,
+ $SearchService,
$ChartLoggerService,
$FederationChart,
$NotesChart,
@@ -532,6 +537,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
WebhookService,
UtilityService,
FileInfoService,
+ SearchService,
FederationChart,
NotesChart,
UsersChart,
@@ -649,6 +655,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$WebhookService,
$UtilityService,
$FileInfoService,
+ $SearchService,
$FederationChart,
$NotesChart,
$UsersChart,
diff --git a/packages/backend/src/core/CustomEmojiService.ts b/packages/backend/src/core/CustomEmojiService.ts
index 185171dee2..93557ce617 100644
--- a/packages/backend/src/core/CustomEmojiService.ts
+++ b/packages/backend/src/core/CustomEmojiService.ts
@@ -1,6 +1,6 @@
import { Inject, Injectable } from '@nestjs/common';
import { DataSource, In, IsNull } from 'typeorm';
-import Redis from 'ioredis';
+import * as Redis from 'ioredis';
import { DI } from '@/di-symbols.js';
import { IdService } from '@/core/IdService.js';
import { EmojiEntityService } from '@/core/entities/EmojiEntityService.js';
@@ -197,6 +197,22 @@ export class CustomEmojiService {
emojis: await this.emojiEntityService.packDetailedMany(ids),
});
}
+
+ @bindThis
+ public async setLicenseBulk(ids: Emoji['id'][], license: string | null) {
+ await this.emojisRepository.update({
+ id: In(ids),
+ }, {
+ updatedAt: new Date(),
+ license: license,
+ });
+
+ this.localEmojisCache.refresh();
+
+ this.globalEventService.publishBroadcastStream('emojiUpdated', {
+ emojis: await this.emojiEntityService.packDetailedMany(ids),
+ });
+ }
@bindThis
public async delete(id: Emoji['id']) {
diff --git a/packages/backend/src/core/DownloadService.ts b/packages/backend/src/core/DownloadService.ts
index bd999c67da..bd535c6032 100644
--- a/packages/backend/src/core/DownloadService.ts
+++ b/packages/backend/src/core/DownloadService.ts
@@ -86,9 +86,13 @@ export class DownloadService {
const contentDisposition = res.headers['content-disposition'];
if (contentDisposition != null) {
- const parsed = parse(contentDisposition);
- if (parsed.parameters.filename) {
- filename = parsed.parameters.filename;
+ try {
+ const parsed = parse(contentDisposition);
+ if (parsed.parameters.filename) {
+ filename = parsed.parameters.filename;
+ }
+ } catch (e) {
+ this.logger.warn(`Failed to parse content-disposition: ${contentDisposition}`, { stack: e });
}
}
}).on('downloadProgress', (progress: Got.Progress) => {
diff --git a/packages/backend/src/core/DriveService.ts b/packages/backend/src/core/DriveService.ts
index c6258474ec..1483b55469 100644
--- a/packages/backend/src/core/DriveService.ts
+++ b/packages/backend/src/core/DriveService.ts
@@ -59,6 +59,8 @@ type AddFileArgs = {
uri?: string | null;
/** Mark file as sensitive */
sensitive?: boolean | null;
+ /** Extension to force */
+ ext?: string | null;
requestIp?: string | null;
requestHeaders?: Record<string, string> | null;
@@ -125,7 +127,7 @@ export class DriveService {
/***
* Save file
* @param path Path for original
- * @param name Name for original
+ * @param name Name for original (should be extention corrected)
* @param type Content-Type for original
* @param hash Hash for original
* @param size Size for original
@@ -151,7 +153,7 @@ export class DriveService {
}
// 拡張子からContent-Typeを設定してそうな挙動を示すオブジェクトストレージ (upcloud?) も存在するので、
- // 許可されているファイル形式でしか拡張子をつけない
+ // 許可されているファイル形式でしかURLに拡張子をつけない
if (!FILE_TYPE_BROWSERSAFE.includes(type)) {
ext = '';
}
@@ -173,7 +175,7 @@ export class DriveService {
//#region Uploads
this.registerLogger.info(`uploading original: ${key}`);
const uploads = [
- this.upload(key, fs.createReadStream(path), type, ext, name),
+ this.upload(key, fs.createReadStream(path), type, null, name),
];
if (alts.webpublic) {
@@ -189,7 +191,7 @@ export class DriveService {
thumbnailUrl = `${ baseUrl }/${ thumbnailKey }`;
this.registerLogger.info(`uploading thumbnail: ${thumbnailKey}`);
- uploads.push(this.upload(thumbnailKey, alts.thumbnail.data, alts.thumbnail.type, alts.thumbnail.ext));
+ uploads.push(this.upload(thumbnailKey, alts.thumbnail.data, alts.thumbnail.type, alts.thumbnail.ext, `${name}.thumbnail`));
}
await Promise.all(uploads);
@@ -396,8 +398,9 @@ export class DriveService {
);
}
+ // Expire oldest file (without avatar or banner) of remote user
@bindThis
- private async deleteOldFile(user: RemoteUser) {
+ private async expireOldFile(user: RemoteUser, driveCapacity: number) {
const q = this.driveFilesRepository.createQueryBuilder('file')
.where('file.userId = :userId', { userId: user.id })
.andWhere('file.isLink = FALSE');
@@ -410,12 +413,17 @@ export class DriveService {
q.andWhere('file.id != :bannerId', { bannerId: user.bannerId });
}
+ //This selete is hard coded, be careful if change database schema
+ q.addSelect('SUM("file"."size") OVER (ORDER BY "file"."id" DESC ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)', 'acc_usage');
q.orderBy('file.id', 'ASC');
- const oldFile = await q.getOne();
+ const fileList = await q.getRawMany();
+ const exceedFileIds = fileList.filter((x: any) => x.acc_usage > driveCapacity).map((x: any) => x.file_id);
- if (oldFile) {
- this.deleteFile(oldFile, true);
+ for (const fileId of exceedFileIds) {
+ const file = await this.driveFilesRepository.findOneBy({ id: fileId });
+ if (file == null) continue;
+ this.deleteFile(file, true);
}
}
@@ -437,10 +445,16 @@ export class DriveService {
sensitive = null,
requestIp = null,
requestHeaders = null,
+ ext = null,
}: AddFileArgs): Promise<DriveFile> {
let skipNsfwCheck = false;
const instance = await this.metaService.fetch();
- if (user == null) skipNsfwCheck = true;
+ const userRoleNSFW = user && (await this.roleService.getUserPolicies(user.id)).alwaysMarkNsfw;
+ if (user == null) {
+ skipNsfwCheck = true;
+ } else if (userRoleNSFW) {
+ skipNsfwCheck = true;
+ }
if (instance.sensitiveMediaDetection === 'none') skipNsfwCheck = true;
if (user && instance.sensitiveMediaDetection === 'local' && this.userEntityService.isRemoteUser(user)) skipNsfwCheck = true;
if (user && instance.sensitiveMediaDetection === 'remote' && this.userEntityService.isLocalUser(user)) skipNsfwCheck = true;
@@ -468,7 +482,7 @@ export class DriveService {
// DriveFile.nameは256文字, validateFileNameは200文字制限であるため、
// extを付加してデータベースの文字数制限に当たることはまずない
(name && this.driveFileEntityService.validateFileName(name)) ? name : 'untitled',
- info.type.ext,
+ ext ?? info.type.ext,
);
if (user && !force) {
@@ -489,22 +503,19 @@ export class DriveService {
//#region Check drive usage
if (user && !isLink) {
const usage = await this.driveFileEntityService.calcDriveUsageOf(user);
+ const isLocalUser = this.userEntityService.isLocalUser(user);
const policies = await this.roleService.getUserPolicies(user.id);
const driveCapacity = 1024 * 1024 * policies.driveCapacityMb;
this.registerLogger.debug('drive capacity override applied');
this.registerLogger.debug(`overrideCap: ${driveCapacity}bytes, usage: ${usage}bytes, u+s: ${usage + info.size}bytes`);
- this.registerLogger.debug(`drive usage is ${usage} (max: ${driveCapacity})`);
-
// If usage limit exceeded
- if (usage + info.size > driveCapacity) {
- if (this.userEntityService.isLocalUser(user)) {
+ if (driveCapacity < usage + info.size) {
+ if (isLocalUser) {
throw new IdentifiableError('c6244ed2-a39a-4e1c-bf93-f0fbd7764fa6', 'No free space.');
- } else {
- // (アバターまたはバナーを含まず)最も古いファイルを削除する
- this.deleteOldFile(await this.usersRepository.findOneByOrFail({ id: user.id }) as RemoteUser);
}
+ await this.expireOldFile(await this.usersRepository.findOneByOrFail({ id: user.id }) as RemoteUser, driveCapacity - info.size);
}
}
//#endregion
@@ -565,6 +576,7 @@ export class DriveService {
if (info.sensitive && profile!.autoSensitive) file.isSensitive = true;
if (info.sensitive && instance.setSensitiveFlagAutomatically) file.isSensitive = true;
+ if (userRoleNSFW) file.isSensitive = true;
if (url !== null) {
file.src = url;
diff --git a/packages/backend/src/core/FederatedInstanceService.ts b/packages/backend/src/core/FederatedInstanceService.ts
index 56660ae0d0..2049bd4c60 100644
--- a/packages/backend/src/core/FederatedInstanceService.ts
+++ b/packages/backend/src/core/FederatedInstanceService.ts
@@ -1,5 +1,5 @@
import { Inject, Injectable } from '@nestjs/common';
-import Redis from 'ioredis';
+import * as Redis from 'ioredis';
import type { InstancesRepository } from '@/models/index.js';
import type { Instance } from '@/models/entities/Instance.js';
import { MemoryKVCache, RedisKVCache } from '@/misc/cache.js';
@@ -23,8 +23,8 @@ export class FederatedInstanceService {
private idService: IdService,
) {
this.federatedInstanceCache = new RedisKVCache<Instance | null>(this.redisClient, 'federatedInstance', {
- lifetime: 1000 * 60 * 60 * 24, // 24h
- memoryCacheLifetime: 1000 * 60 * 30, // 30m
+ lifetime: 1000 * 60 * 30, // 30m
+ memoryCacheLifetime: 1000 * 60 * 3, // 3m
fetcher: (key) => this.instancesRepository.findOneBy({ host: key }),
toRedisConverter: (value) => JSON.stringify(value),
fromRedisConverter: (value) => {
@@ -65,15 +65,18 @@ export class FederatedInstanceService {
}
@bindThis
- public async updateCachePartial(host: string, data: Partial<Instance>): Promise<void> {
- host = this.utilityService.toPuny(host);
-
- const cached = await this.federatedInstanceCache.get(host);
- if (cached == null) return;
+ public async update(id: Instance['id'], data: Partial<Instance>): Promise<void> {
+ const result = await this.instancesRepository.createQueryBuilder().update()
+ .set(data)
+ .where('id = :id', { id })
+ .returning('*')
+ .execute()
+ .then((response) => {
+ return response.raw[0];
+ });
+
+ const updated = result.raw[0];
- this.federatedInstanceCache.set(host, {
- ...cached,
- ...data,
- });
+ this.federatedInstanceCache.set(updated.host, updated);
}
}
diff --git a/packages/backend/src/core/FetchInstanceMetadataService.ts b/packages/backend/src/core/FetchInstanceMetadataService.ts
index bbc8b4332e..8103d5afe9 100644
--- a/packages/backend/src/core/FetchInstanceMetadataService.ts
+++ b/packages/backend/src/core/FetchInstanceMetadataService.ts
@@ -10,6 +10,7 @@ import { DI } from '@/di-symbols.js';
import { LoggerService } from '@/core/LoggerService.js';
import { HttpRequestService } from '@/core/HttpRequestService.js';
import { bindThis } from '@/decorators.js';
+import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
import type { DOMWindow } from 'jsdom';
type NodeInfo = {
@@ -42,6 +43,7 @@ export class FetchInstanceMetadataService {
private appLockService: AppLockService,
private httpRequestService: HttpRequestService,
private loggerService: LoggerService,
+ private federatedInstanceService: FederatedInstanceService,
) {
this.logger = this.loggerService.getLogger('metadata', 'cyan');
}
@@ -96,7 +98,7 @@ export class FetchInstanceMetadataService {
if (favicon) updates.faviconUrl = favicon;
if (themeColor) updates.themeColor = themeColor;
- await this.instancesRepository.update(instance.id, updates);
+ await this.federatedInstanceService.update(instance.id, updates);
this.logger.succ(`Successfuly updated metadata of ${instance.host}`);
} catch (e) {
diff --git a/packages/backend/src/core/FileInfoService.ts b/packages/backend/src/core/FileInfoService.ts
index e39b134b7e..b6cae5ea75 100644
--- a/packages/backend/src/core/FileInfoService.ts
+++ b/packages/backend/src/core/FileInfoService.ts
@@ -5,7 +5,7 @@ import * as stream from 'node:stream';
import * as util from 'node:util';
import { Injectable } from '@nestjs/common';
import { FSWatcher } from 'chokidar';
-import { fileTypeFromFile } from 'file-type';
+import * as fileType from 'file-type';
import FFmpeg from 'fluent-ffmpeg';
import isSvg from 'is-svg';
import probeImageSize from 'probe-image-size';
@@ -301,21 +301,34 @@ export class FileInfoService {
return fs.promises.access(path).then(() => true, () => false);
}
+ @bindThis
+ public fixMime(mime: string | fileType.MimeType): string {
+ // see https://github.com/misskey-dev/misskey/pull/10686
+ if (mime === "audio/x-flac") {
+ return "audio/flac";
+ }
+ if (mime === "audio/vnd.wave") {
+ return "audio/wav";
+ }
+
+ return mime;
+ }
+
/**
* Detect MIME Type and extension
*/
@bindThis
public async detectType(path: string): Promise<{
- mime: string;
- ext: string | null;
-}> {
+ mime: string;
+ ext: string | null;
+ }> {
// Check 0 byte
const fileSize = await this.getFileSize(path);
if (fileSize === 0) {
return TYPE_OCTET_STREAM;
}
- const type = await fileTypeFromFile(path);
+ const type = await fileType.fileTypeFromFile(path);
if (type) {
// XMLはSVGかもしれない
@@ -324,7 +337,7 @@ export class FileInfoService {
}
return {
- mime: type.mime,
+ mime: this.fixMime(type.mime),
ext: type.ext,
};
}
diff --git a/packages/backend/src/core/GlobalEventService.ts b/packages/backend/src/core/GlobalEventService.ts
index 2c2687a90c..0ed5241148 100644
--- a/packages/backend/src/core/GlobalEventService.ts
+++ b/packages/backend/src/core/GlobalEventService.ts
@@ -1,5 +1,5 @@
import { Inject, Injectable } from '@nestjs/common';
-import Redis from 'ioredis';
+import * as Redis from 'ioredis';
import type { User } from '@/models/entities/User.js';
import type { Note } from '@/models/entities/Note.js';
import type { UserList } from '@/models/entities/UserList.js';
diff --git a/packages/backend/src/core/MetaService.ts b/packages/backend/src/core/MetaService.ts
index 1322927c2c..0b861be8d0 100644
--- a/packages/backend/src/core/MetaService.ts
+++ b/packages/backend/src/core/MetaService.ts
@@ -1,6 +1,6 @@
import { Inject, Injectable } from '@nestjs/common';
import { DataSource } from 'typeorm';
-import Redis from 'ioredis';
+import * as Redis from 'ioredis';
import { DI } from '@/di-symbols.js';
import { Meta } from '@/models/entities/Meta.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts
index 79629cb2a8..364976e4a7 100644
--- a/packages/backend/src/core/NoteCreateService.ts
+++ b/packages/backend/src/core/NoteCreateService.ts
@@ -1,7 +1,7 @@
import { setImmediate } from 'node:timers/promises';
import * as mfm from 'mfm-js';
import { In, DataSource } from 'typeorm';
-import Redis from 'ioredis';
+import * as Redis from 'ioredis';
import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
import { extractMentions } from '@/misc/extract-mentions.js';
import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mfm.js';
@@ -46,6 +46,7 @@ import { bindThis } from '@/decorators.js';
import { DB_MAX_NOTE_TEXT_LENGTH } from '@/const.js';
import { RoleService } from '@/core/RoleService.js';
import { MetaService } from '@/core/MetaService.js';
+import { SearchService } from '@/core/SearchService.js';
const mutedWordsCache = new MemorySingleCache<{ userId: UserProfile['userId']; mutedWords: UserProfile['mutedWords']; }[]>(1000 * 60 * 5);
@@ -198,6 +199,7 @@ export class NoteCreateService implements OnApplicationShutdown {
private apRendererService: ApRendererService,
private roleService: RoleService,
private metaService: MetaService,
+ private searchService: SearchService,
private notesChart: NotesChart,
private perUserNotesChart: PerUserNotesChart,
private activeUsersChart: ActiveUsersChart,
@@ -728,17 +730,9 @@ export class NoteCreateService implements OnApplicationShutdown {
@bindThis
private index(note: Note) {
- if (note.text == null || this.config.elasticsearch == null) return;
- /*
- es!.index({
- index: this.config.elasticsearch.index ?? 'misskey_note',
- id: note.id.toString(),
- body: {
- text: normalizeForSearch(note.text),
- userId: note.userId,
- userHost: note.userHost,
- },
- });*/
+ if (note.text == null && note.cw == null) return;
+
+ this.searchService.indexNote(note);
}
@bindThis
diff --git a/packages/backend/src/core/NotificationService.ts b/packages/backend/src/core/NotificationService.ts
index 6691c42836..a245908c98 100644
--- a/packages/backend/src/core/NotificationService.ts
+++ b/packages/backend/src/core/NotificationService.ts
@@ -1,5 +1,5 @@
import { setTimeout } from 'node:timers/promises';
-import Redis from 'ioredis';
+import * as Redis from 'ioredis';
import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
import { In } from 'typeorm';
import { DI } from '@/di-symbols.js';
@@ -111,7 +111,7 @@ export class NotificationService implements OnApplicationShutdown {
// 2秒経っても(今回作成した)通知が既読にならなかったら「未読の通知がありますよ」イベントを発行する
setTimeout(2000, 'unread notification', { signal: this.#shutdownController.signal }).then(async () => {
const latestReadNotificationId = await this.redisClient.get(`latestReadNotification:${notifieeId}`);
- if (latestReadNotificationId && (latestReadNotificationId >= await redisIdPromise)) return;
+ if (latestReadNotificationId && (latestReadNotificationId >= (await redisIdPromise)!)) return;
this.globalEventService.publishMainStream(notifieeId, 'unreadNotification', packed);
this.pushNotificationService.pushNotification(notifieeId, 'notification', packed);
diff --git a/packages/backend/src/core/PushNotificationService.ts b/packages/backend/src/core/PushNotificationService.ts
index 9b44cf6413..a4c569bdec 100644
--- a/packages/backend/src/core/PushNotificationService.ts
+++ b/packages/backend/src/core/PushNotificationService.ts
@@ -1,6 +1,6 @@
import { Inject, Injectable } from '@nestjs/common';
import push from 'web-push';
-import Redis from 'ioredis';
+import * as Redis from 'ioredis';
import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
import type { Packed } from '@/misc/json-schema';
diff --git a/packages/backend/src/core/QueueModule.ts b/packages/backend/src/core/QueueModule.ts
index bac85d7a15..d4905a5f88 100644
--- a/packages/backend/src/core/QueueModule.ts
+++ b/packages/backend/src/core/QueueModule.ts
@@ -78,7 +78,7 @@ const $db: Provider = {
const $relationship: Provider = {
provide: 'queue:relationship',
- useFactory: (config: Config) => q(config, 'relationship'),
+ useFactory: (config: Config) => q(config, 'relationship', config.relashionshipJobPerSec ?? 64),
inject: [DI.config],
};
diff --git a/packages/backend/src/core/QueueService.ts b/packages/backend/src/core/QueueService.ts
index 375ac49911..b4ffffecc0 100644
--- a/packages/backend/src/core/QueueService.ts
+++ b/packages/backend/src/core/QueueService.ts
@@ -1,15 +1,16 @@
import { Inject, Injectable } from '@nestjs/common';
import { v4 as uuid } from 'uuid';
+import Bull from 'bull';
import type { IActivity } from '@/core/activitypub/type.js';
import type { DriveFile } from '@/models/entities/DriveFile.js';
import type { Webhook, webhookEventTypes } from '@/models/entities/Webhook.js';
import type { Config } from '@/config.js';
import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js';
+import type { Antenna } from '@/server/api/endpoints/i/import-antennas.js';
import type { DbQueue, DeliverQueue, EndedPollNotificationQueue, InboxQueue, ObjectStorageQueue, RelationshipQueue, SystemQueue, WebhookDeliverQueue } from './QueueModule.js';
import type { DbJobData, RelationshipJobData, ThinUser } from '../queue/types.js';
import type httpSignature from '@peertube/http-signature';
-import Bull from 'bull';
@Injectable()
export class QueueService {
@@ -153,6 +154,16 @@ export class QueueService {
}
@bindThis
+ public createExportAntennasJob(user: ThinUser) {
+ return this.dbQueue.add('exportAntennas', {
+ user: { id: user.id },
+ }, {
+ removeOnComplete: true,
+ removeOnFail: true,
+ });
+ }
+
+ @bindThis
public createImportFollowingJob(user: ThinUser, fileId: DriveFile['id']) {
return this.dbQueue.add('importFollowing', {
user: { id: user.id },
@@ -236,6 +247,17 @@ export class QueueService {
}
@bindThis
+ public createImportAntennasJob(user: ThinUser, antenna: Antenna) {
+ return this.dbQueue.add('importAntennas', {
+ user: { id: user.id },
+ antenna,
+ }, {
+ removeOnComplete: true,
+ removeOnFail: true,
+ });
+ }
+
+ @bindThis
public createDeleteAccountJob(user: ThinUser, opts: { soft?: boolean; } = {}) {
return this.dbQueue.add('deleteAccount', {
user: { id: user.id },
@@ -259,6 +281,12 @@ export class QueueService {
}
@bindThis
+ public createDelayedUnfollowJob(followings: { from: ThinUser, to: ThinUser, requestId?: string }[], delay: number) {
+ const jobs = followings.map(rel => this.generateRelationshipJobData('unfollow', rel, { delay }));
+ return this.relationshipQueue.addBulk(jobs);
+ }
+
+ @bindThis
public createBlockJob(blockings: { from: ThinUser, to: ThinUser, silent?: boolean }[]) {
const jobs = blockings.map(rel => this.generateRelationshipJobData('block', rel));
return this.relationshipQueue.addBulk(jobs);
@@ -271,7 +299,7 @@ export class QueueService {
}
@bindThis
- private generateRelationshipJobData(name: 'follow' | 'unfollow' | 'block' | 'unblock', data: RelationshipJobData): {
+ private generateRelationshipJobData(name: 'follow' | 'unfollow' | 'block' | 'unblock', data: RelationshipJobData, opts: Bull.JobOptions = {}): {
name: string,
data: RelationshipJobData,
opts: Bull.JobOptions,
@@ -287,6 +315,7 @@ export class QueueService {
opts: {
removeOnComplete: true,
removeOnFail: true,
+ ...opts,
},
};
}
diff --git a/packages/backend/src/core/RemoteUserResolveService.ts b/packages/backend/src/core/RemoteUserResolveService.ts
index b72dce5180..ff68c24219 100644
--- a/packages/backend/src/core/RemoteUserResolveService.ts
+++ b/packages/backend/src/core/RemoteUserResolveService.ts
@@ -4,7 +4,7 @@ import chalk from 'chalk';
import { IsNull } from 'typeorm';
import { DI } from '@/di-symbols.js';
import type { UsersRepository } from '@/models/index.js';
-import type { RemoteUser, User } from '@/models/entities/User.js';
+import type { LocalUser, RemoteUser } from '@/models/entities/User.js';
import type { Config } from '@/config.js';
import type Logger from '@/logger.js';
import { UtilityService } from '@/core/UtilityService.js';
@@ -33,7 +33,7 @@ export class RemoteUserResolveService {
}
@bindThis
- public async resolveUser(username: string, host: string | null): Promise<User> {
+ public async resolveUser(username: string, host: string | null): Promise<LocalUser | RemoteUser> {
const usernameLower = username.toLowerCase();
if (host == null) {
@@ -44,7 +44,7 @@ export class RemoteUserResolveService {
} else {
return u;
}
- });
+ }) as LocalUser;
}
host = this.utilityService.toPuny(host);
@@ -57,7 +57,7 @@ export class RemoteUserResolveService {
} else {
return u;
}
- });
+ }) as LocalUser;
}
const user = await this.usersRepository.findOneBy({ usernameLower, host }) as RemoteUser | null;
@@ -109,7 +109,7 @@ export class RemoteUserResolveService {
if (u == null) {
throw new Error('user not found');
} else {
- return u;
+ return u as LocalUser | RemoteUser;
}
});
}
diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts
index 2a4271aa98..68087ccc3b 100644
--- a/packages/backend/src/core/RoleService.ts
+++ b/packages/backend/src/core/RoleService.ts
@@ -1,5 +1,5 @@
import { Inject, Injectable } from '@nestjs/common';
-import Redis from 'ioredis';
+import * as Redis from 'ioredis';
import { In } from 'typeorm';
import type { Role, RoleAssignment, RoleAssignmentsRepository, RolesRepository, UsersRepository } from '@/models/index.js';
import { MemoryKVCache, MemorySingleCache } from '@/misc/cache.js';
@@ -25,6 +25,7 @@ export type RolePolicies = {
canSearchNotes: boolean;
canHideAds: boolean;
driveCapacityMb: number;
+ alwaysMarkNsfw: boolean;
pinLimit: number;
antennaLimit: number;
wordMuteLimit: number;
@@ -45,6 +46,7 @@ export const DEFAULT_POLICIES: RolePolicies = {
canSearchNotes: false,
canHideAds: false,
driveCapacityMb: 100,
+ alwaysMarkNsfw: false,
pinLimit: 5,
antennaLimit: 5,
wordMuteLimit: 200,
@@ -279,6 +281,7 @@ export class RoleService implements OnApplicationShutdown {
canSearchNotes: calc('canSearchNotes', vs => vs.some(v => v === true)),
canHideAds: calc('canHideAds', vs => vs.some(v => v === true)),
driveCapacityMb: calc('driveCapacityMb', vs => Math.max(...vs)),
+ alwaysMarkNsfw: calc('alwaysMarkNsfw', vs => vs.some(v => v === true)),
pinLimit: calc('pinLimit', vs => Math.max(...vs)),
antennaLimit: calc('antennaLimit', vs => Math.max(...vs)),
wordMuteLimit: calc('wordMuteLimit', vs => Math.max(...vs)),
diff --git a/packages/backend/src/core/SearchService.ts b/packages/backend/src/core/SearchService.ts
new file mode 100644
index 0000000000..e68fde088d
--- /dev/null
+++ b/packages/backend/src/core/SearchService.ts
@@ -0,0 +1,178 @@
+import { Inject, Injectable } from '@nestjs/common';
+import { In } from 'typeorm';
+import { DI } from '@/di-symbols.js';
+import type { Config } from '@/config.js';
+import { bindThis } from '@/decorators.js';
+import { Note } from '@/models/entities/Note.js';
+import { User } from '@/models/index.js';
+import type { NotesRepository } from '@/models/index.js';
+import { sqlLikeEscape } from '@/misc/sql-like-escape.js';
+import { QueryService } from '@/core/QueryService.js';
+import { IdService } from '@/core/IdService.js';
+import type { Index, MeiliSearch } from 'meilisearch';
+
+type K = string;
+type V = string | number | boolean;
+type Q =
+ { op: '=', k: K, v: V } |
+ { op: '!=', k: K, v: V } |
+ { op: '>', k: K, v: number } |
+ { op: '<', k: K, v: number } |
+ { op: '>=', k: K, v: number } |
+ { op: '<=', k: K, v: number } |
+ { op: 'and', qs: Q[] } |
+ { op: 'or', qs: Q[] } |
+ { op: 'not', q: Q };
+
+function compileValue(value: V): string {
+ if (typeof value === 'string') {
+ return `'${value}'`; // TODO: escape
+ } else if (typeof value === 'number') {
+ return value.toString();
+ } else if (typeof value === 'boolean') {
+ return value.toString();
+ }
+ throw new Error('unrecognized value');
+}
+
+function compileQuery(q: Q): string {
+ switch (q.op) {
+ case '=': return `(${q.k} = ${compileValue(q.v)})`;
+ case '!=': return `(${q.k} != ${compileValue(q.v)})`;
+ case '>': return `(${q.k} > ${compileValue(q.v)})`;
+ case '<': return `(${q.k} < ${compileValue(q.v)})`;
+ case '>=': return `(${q.k} >= ${compileValue(q.v)})`;
+ case '<=': return `(${q.k} <= ${compileValue(q.v)})`;
+ case 'and': return q.qs.length === 0 ? '' : `(${ q.qs.map(_q => compileQuery(_q)).join(' AND ') })`;
+ case 'or': return q.qs.length === 0 ? '' : `(${ q.qs.map(_q => compileQuery(_q)).join(' OR ') })`;
+ case 'not': return `(NOT ${compileQuery(q.q)})`;
+ default: throw new Error('unrecognized query operator');
+ }
+}
+
+@Injectable()
+export class SearchService {
+ private meilisearchNoteIndex: Index | null = null;
+
+ constructor(
+ @Inject(DI.config)
+ private config: Config,
+
+ @Inject(DI.meilisearch)
+ private meilisearch: MeiliSearch | null,
+
+ @Inject(DI.notesRepository)
+ private notesRepository: NotesRepository,
+
+ private queryService: QueryService,
+ private idService: IdService,
+ ) {
+ if (meilisearch) {
+ this.meilisearchNoteIndex = meilisearch.index('notes');
+ this.meilisearchNoteIndex.updateSettings({
+ searchableAttributes: [
+ 'text',
+ 'cw',
+ ],
+ sortableAttributes: [
+ 'createdAt',
+ ],
+ filterableAttributes: [
+ 'createdAt',
+ 'userId',
+ 'userHost',
+ 'channelId',
+ ],
+ typoTolerance: {
+ enabled: false,
+ },
+ pagination: {
+ maxTotalHits: 10000,
+ },
+ });
+ }
+ }
+
+ @bindThis
+ public async indexNote(note: Note): Promise<void> {
+ if (note.text == null && note.cw == null) return;
+ if (!['home', 'public'].includes(note.visibility)) return;
+
+ if (this.meilisearch) {
+ this.meilisearchNoteIndex!.addDocuments([{
+ id: note.id,
+ createdAt: note.createdAt.getTime(),
+ userId: note.userId,
+ userHost: note.userHost,
+ channelId: note.channelId,
+ cw: note.cw,
+ text: note.text,
+ }], {
+ primaryKey: 'id',
+ });
+ }
+ }
+
+ @bindThis
+ public async searchNote(q: string, me: User | null, opts: {
+ userId?: Note['userId'] | null;
+ channelId?: Note['channelId'] | null;
+ host?: string | null;
+ }, pagination: {
+ untilId?: Note['id'];
+ sinceId?: Note['id'];
+ limit?: number;
+ }): Promise<Note[]> {
+ if (this.meilisearch) {
+ const filter: Q = {
+ op: 'and',
+ qs: [],
+ };
+ if (pagination.untilId) filter.qs.push({ op: '<', k: 'createdAt', v: this.idService.parse(pagination.untilId).date.getTime() });
+ if (pagination.sinceId) filter.qs.push({ op: '>', k: 'createdAt', v: this.idService.parse(pagination.sinceId).date.getTime() });
+ if (opts.userId) filter.qs.push({ op: '=', k: 'userId', v: opts.userId });
+ if (opts.channelId) filter.qs.push({ op: '=', k: 'channelId', v: opts.channelId });
+ if (opts.host) {
+ if (opts.host === '.') {
+ // TODO: Meilisearchが2023/05/07現在値がNULLかどうかのクエリが書けない
+ } else {
+ filter.qs.push({ op: '=', k: 'userHost', v: opts.host });
+ }
+ }
+ const res = await this.meilisearchNoteIndex!.search(q, {
+ sort: ['createdAt:desc'],
+ matchingStrategy: 'all',
+ attributesToRetrieve: ['id', 'createdAt'],
+ filter: compileQuery(filter),
+ limit: pagination.limit,
+ });
+ if (res.hits.length === 0) return [];
+ const notes = await this.notesRepository.findBy({
+ id: In(res.hits.map(x => x.id)),
+ });
+ return notes.sort((a, b) => a.id > b.id ? -1 : 1);
+ } else {
+ const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), pagination.sinceId, pagination.untilId);
+
+ if (opts.userId) {
+ query.andWhere('note.userId = :userId', { userId: opts.userId });
+ } else if (opts.channelId) {
+ query.andWhere('note.channelId = :channelId', { channelId: opts.channelId });
+ }
+
+ query
+ .andWhere('note.text ILIKE :q', { q: `%${ sqlLikeEscape(q) }%` })
+ .innerJoinAndSelect('note.user', 'user')
+ .leftJoinAndSelect('note.reply', 'reply')
+ .leftJoinAndSelect('note.renote', 'renote')
+ .leftJoinAndSelect('reply.user', 'replyUser')
+ .leftJoinAndSelect('renote.user', 'renoteUser');
+
+ this.queryService.generateVisibilityQuery(query, me);
+ if (me) this.queryService.generateMutedUserQuery(query, me);
+ if (me) this.queryService.generateBlockedUserQuery(query, me);
+
+ return await query.take(pagination.limit).getMany();
+ }
+ }
+}
diff --git a/packages/backend/src/core/SignupService.ts b/packages/backend/src/core/SignupService.ts
index d7bc05b8bd..29eb65fda4 100644
--- a/packages/backend/src/core/SignupService.ts
+++ b/packages/backend/src/core/SignupService.ts
@@ -13,8 +13,9 @@ import { UsedUsername } from '@/models/entities/UsedUsername.js';
import generateUserToken from '@/misc/generate-native-user-token.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { bindThis } from '@/decorators.js';
-import UsersChart from './chart/charts/users.js';
-import { UtilityService } from './UtilityService.js';
+import UsersChart from '@/core/chart/charts/users.js';
+import { UtilityService } from '@/core/UtilityService.js';
+import { MetaService } from '@/core/MetaService.js';
@Injectable()
export class SignupService {
@@ -34,6 +35,7 @@ export class SignupService {
private utilityService: UtilityService,
private userEntityService: UserEntityService,
private idService: IdService,
+ private metaService: MetaService,
private usersChart: UsersChart,
) {
}
@@ -44,6 +46,7 @@ export class SignupService {
password?: string | null;
passwordHash?: UserProfile['password'] | null;
host?: string | null;
+ ignorePreservedUsernames?: boolean;
}) {
const { username, password, passwordHash, host } = opts;
let hash = passwordHash;
@@ -76,7 +79,17 @@ export class SignupService {
if (await this.usedUsernamesRepository.findOneBy({ username: username.toLowerCase() })) {
throw new Error('USED_USERNAME');
}
-
+
+ const isTheFirstUser = (await this.usersRepository.countBy({ host: IsNull() })) === 0;
+
+ if (!opts.ignorePreservedUsernames && !isTheFirstUser) {
+ const instance = await this.metaService.fetch(true);
+ const isPreserved = instance.preservedUsernames.map(x => x.toLowerCase()).includes(username.toLowerCase());
+ if (isPreserved) {
+ throw new Error('USED_USERNAME');
+ }
+ }
+
const keyPair = await new Promise<string[]>((res, rej) =>
generateKeyPair('rsa', {
modulusLength: 4096,
@@ -112,9 +125,7 @@ export class SignupService {
usernameLower: username.toLowerCase(),
host: this.utilityService.toPunyNullable(host),
token: secret,
- isRoot: (await this.usersRepository.countBy({
- host: IsNull(),
- })) === 0,
+ isRoot: isTheFirstUser,
}));
await transactionalEntityManager.save(new UserKeypair({
diff --git a/packages/backend/src/core/UserFollowingService.ts b/packages/backend/src/core/UserFollowingService.ts
index a8eded6733..7d90bc2c08 100644
--- a/packages/backend/src/core/UserFollowingService.ts
+++ b/packages/backend/src/core/UserFollowingService.ts
@@ -1,6 +1,6 @@
import { Inject, Injectable, OnModuleInit, forwardRef } from '@nestjs/common';
import { ModuleRef } from '@nestjs/core';
-import type { LocalUser, RemoteUser, User } from '@/models/entities/User.js';
+import type { LocalUser, PartialLocalUser, PartialRemoteUser, RemoteUser, User } from '@/models/entities/User.js';
import { IdentifiableError } from '@/misc/identifiable-error.js';
import { QueueService } from '@/core/QueueService.js';
import PerUserFollowingChart from '@/core/chart/charts/per-user-following.js';
@@ -22,6 +22,8 @@ import { MetaService } from '@/core/MetaService.js';
import { CacheService } from '@/core/CacheService.js';
import type { Config } from '@/config.js';
import Logger from '../logger.js';
+import { IsNull } from 'typeorm';
+import { AccountMoveService } from '@/core/AccountMoveService.js';
const logger = new Logger('following/create');
@@ -73,6 +75,7 @@ export class UserFollowingService implements OnModuleInit {
private federatedInstanceService: FederatedInstanceService,
private webhookService: WebhookService,
private apRendererService: ApRendererService,
+ private accountMoveService: AccountMoveService,
private perUserFollowingChart: PerUserFollowingChart,
private instanceChart: InstanceChart,
) {
@@ -87,7 +90,7 @@ export class UserFollowingService implements OnModuleInit {
const [follower, followee] = await Promise.all([
this.usersRepository.findOneByOrFail({ id: _follower.id }),
this.usersRepository.findOneByOrFail({ id: _followee.id }),
- ]);
+ ]) as [LocalUser | RemoteUser, LocalUser | RemoteUser];
// check blocking
const [blocking, blocked] = await Promise.all([
@@ -137,6 +140,20 @@ export class UserFollowingService implements OnModuleInit {
if (followed) autoAccept = true;
}
+ // Automatically accept if the follower is an account who has moved and the locked followee had accepted the old account.
+ if (followee.isLocked && !autoAccept) {
+ autoAccept = !!(await this.accountMoveService.validateAlsoKnownAs(
+ follower,
+ (oldSrc, newSrc) => this.followingsRepository.exist({
+ where: {
+ followeeId: followee.id,
+ followerId: newSrc.id,
+ },
+ }),
+ true,
+ ));
+ }
+
if (!autoAccept) {
await this.createFollowRequest(follower, followee, requestId);
return;
@@ -210,32 +227,40 @@ export class UserFollowingService implements OnModuleInit {
this.globalEventService.publishInternalEvent('follow', { followerId: follower.id, followeeId: followee.id });
- //#region Increment counts
- await Promise.all([
- this.usersRepository.increment({ id: follower.id }, 'followingCount', 1),
- this.usersRepository.increment({ id: followee.id }, 'followersCount', 1),
+ const [followeeUser, followerUser] = await Promise.all([
+ this.usersRepository.findOneByOrFail({ id: followee.id }),
+ this.usersRepository.findOneByOrFail({ id: follower.id }),
]);
- //#endregion
- //#region Update instance stats
- if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) {
- this.federatedInstanceService.fetch(follower.host).then(async i => {
- this.instancesRepository.increment({ id: i.id }, 'followingCount', 1);
- if ((await this.metaService.fetch()).enableChartsForFederatedInstances) {
- this.instanceChart.updateFollowing(i.host, true);
- }
- });
- } else if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) {
- this.federatedInstanceService.fetch(followee.host).then(async i => {
- this.instancesRepository.increment({ id: i.id }, 'followersCount', 1);
- if ((await this.metaService.fetch()).enableChartsForFederatedInstances) {
- this.instanceChart.updateFollowers(i.host, true);
- }
- });
- }
- //#endregion
+ // Neither followee nor follower has moved.
+ if (!followeeUser.movedToUri && !followerUser.movedToUri) {
+ //#region Increment counts
+ await Promise.all([
+ this.usersRepository.increment({ id: follower.id }, 'followingCount', 1),
+ this.usersRepository.increment({ id: followee.id }, 'followersCount', 1),
+ ]);
+ //#endregion
+
+ //#region Update instance stats
+ if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) {
+ this.federatedInstanceService.fetch(follower.host).then(async i => {
+ this.instancesRepository.increment({ id: i.id }, 'followingCount', 1);
+ if ((await this.metaService.fetch()).enableChartsForFederatedInstances) {
+ this.instanceChart.updateFollowing(i.host, true);
+ }
+ });
+ } else if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) {
+ this.federatedInstanceService.fetch(followee.host).then(async i => {
+ this.instancesRepository.increment({ id: i.id }, 'followersCount', 1);
+ if ((await this.metaService.fetch()).enableChartsForFederatedInstances) {
+ this.instanceChart.updateFollowers(i.host, true);
+ }
+ });
+ }
+ //#endregion
- this.perUserFollowingChart.update(follower, followee, true);
+ this.perUserFollowingChart.update(follower, followee, true);
+ }
// Publish follow event
if (this.userEntityService.isLocalUser(follower) && !silent) {
@@ -283,12 +308,18 @@ export class UserFollowingService implements OnModuleInit {
},
silent = false,
): Promise<void> {
- const following = await this.followingsRepository.findOneBy({
- followerId: follower.id,
- followeeId: followee.id,
+ const following = await this.followingsRepository.findOne({
+ relations: {
+ follower: true,
+ followee: true,
+ },
+ where: {
+ followerId: follower.id,
+ followeeId: followee.id,
+ }
});
- if (following == null) {
+ if (following === null || !following.follower || !following.followee) {
logger.warn('フォロー解除がリクエストされましたがフォローしていませんでした');
return;
}
@@ -297,7 +328,7 @@ export class UserFollowingService implements OnModuleInit {
this.cacheService.userFollowingsCache.refresh(follower.id);
- this.decrementFollowing(follower, followee);
+ this.decrementFollowing(following.follower, following.followee);
// Publish unfollow event
if (!silent && this.userEntityService.isLocalUser(follower)) {
@@ -316,50 +347,87 @@ export class UserFollowingService implements OnModuleInit {
}
if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) {
- const content = this.apRendererService.addContext(this.apRendererService.renderUndo(this.apRendererService.renderFollow(follower, followee), follower));
+ const content = this.apRendererService.addContext(this.apRendererService.renderUndo(this.apRendererService.renderFollow(follower as PartialLocalUser, followee as PartialRemoteUser), follower));
this.queueService.deliver(follower, content, followee.inbox, false);
}
if (this.userEntityService.isLocalUser(followee) && this.userEntityService.isRemoteUser(follower)) {
// local user has null host
- const content = this.apRendererService.addContext(this.apRendererService.renderReject(this.apRendererService.renderFollow(follower, followee), followee));
+ const content = this.apRendererService.addContext(this.apRendererService.renderReject(this.apRendererService.renderFollow(follower as PartialRemoteUser, followee as PartialLocalUser), followee));
this.queueService.deliver(followee, content, follower.inbox, false);
}
}
@bindThis
private async decrementFollowing(
- follower: { id: User['id']; host: User['host']; },
- followee: { id: User['id']; host: User['host']; },
+ follower: User,
+ followee: User,
): Promise<void> {
this.globalEventService.publishInternalEvent('unfollow', { followerId: follower.id, followeeId: followee.id });
- //#region Decrement following / followers counts
- await Promise.all([
- this.usersRepository.decrement({ id: follower.id }, 'followingCount', 1),
- this.usersRepository.decrement({ id: followee.id }, 'followersCount', 1),
- ]);
- //#endregion
+ // Neither followee nor follower has moved.
+ if (!follower.movedToUri && !followee.movedToUri) {
+ //#region Decrement following / followers counts
+ await Promise.all([
+ this.usersRepository.decrement({ id: follower.id }, 'followingCount', 1),
+ this.usersRepository.decrement({ id: followee.id }, 'followersCount', 1),
+ ]);
+ //#endregion
- //#region Update instance stats
- if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) {
- this.federatedInstanceService.fetch(follower.host).then(async i => {
- this.instancesRepository.decrement({ id: i.id }, 'followingCount', 1);
- if ((await this.metaService.fetch()).enableChartsForFederatedInstances) {
- this.instanceChart.updateFollowing(i.host, false);
- }
- });
- } else if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) {
- this.federatedInstanceService.fetch(followee.host).then(async i => {
- this.instancesRepository.decrement({ id: i.id }, 'followersCount', 1);
- if ((await this.metaService.fetch()).enableChartsForFederatedInstances) {
- this.instanceChart.updateFollowers(i.host, false);
- }
- });
- }
- //#endregion
+ //#region Update instance stats
+ if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) {
+ this.federatedInstanceService.fetch(follower.host).then(async i => {
+ this.instancesRepository.decrement({ id: i.id }, 'followingCount', 1);
+ if ((await this.metaService.fetch()).enableChartsForFederatedInstances) {
+ this.instanceChart.updateFollowing(i.host, false);
+ }
+ });
+ } else if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) {
+ this.federatedInstanceService.fetch(followee.host).then(async i => {
+ this.instancesRepository.decrement({ id: i.id }, 'followersCount', 1);
+ if ((await this.metaService.fetch()).enableChartsForFederatedInstances) {
+ this.instanceChart.updateFollowers(i.host, false);
+ }
+ });
+ }
+ //#endregion
- this.perUserFollowingChart.update(follower, followee, false);
+ this.perUserFollowingChart.update(follower, followee, false);
+ } else {
+ // Adjust following/followers counts
+ for (const user of [follower, followee]) {
+ if (user.movedToUri) continue; // No need to update if the user has already moved.
+
+ const nonMovedFollowees = await this.followingsRepository.count({
+ relations: {
+ followee: true,
+ },
+ where: {
+ followerId: user.id,
+ followee: {
+ movedToUri: IsNull(),
+ }
+ }
+ });
+ const nonMovedFollowers = await this.followingsRepository.count({
+ relations: {
+ follower: true,
+ },
+ where: {
+ followeeId: user.id,
+ follower: {
+ movedToUri: IsNull(),
+ }
+ }
+ });
+ await this.usersRepository.update(
+ { id: user.id },
+ { followingCount: nonMovedFollowees, followersCount: nonMovedFollowers },
+ );
+ }
+
+ // TODO: adjust charts
+ }
}
@bindThis
@@ -415,7 +483,7 @@ export class UserFollowingService implements OnModuleInit {
}
if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) {
- const content = this.apRendererService.addContext(this.apRendererService.renderFollow(follower, followee, requestId ?? `${this.config.url}/follows/${followRequest.id}`));
+ const content = this.apRendererService.addContext(this.apRendererService.renderFollow(follower as PartialLocalUser, followee as PartialRemoteUser, requestId ?? `${this.config.url}/follows/${followRequest.id}`));
this.queueService.deliver(follower, content, followee.inbox, false);
}
}
@@ -430,7 +498,7 @@ export class UserFollowingService implements OnModuleInit {
},
): Promise<void> {
if (this.userEntityService.isRemoteUser(followee)) {
- const content = this.apRendererService.addContext(this.apRendererService.renderUndo(this.apRendererService.renderFollow(follower, followee), follower));
+ const content = this.apRendererService.addContext(this.apRendererService.renderUndo(this.apRendererService.renderFollow(follower as PartialLocalUser | PartialRemoteUser, followee as PartialRemoteUser), follower));
if (this.userEntityService.isLocalUser(follower)) { // 本来このチェックは不要だけどTSに怒られるので
this.queueService.deliver(follower, content, followee.inbox, false);
@@ -475,7 +543,7 @@ export class UserFollowingService implements OnModuleInit {
await this.insertFollowingDoc(followee, follower);
if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) {
- const content = this.apRendererService.addContext(this.apRendererService.renderAccept(this.apRendererService.renderFollow(follower, followee, request.requestId!), followee));
+ const content = this.apRendererService.addContext(this.apRendererService.renderAccept(this.apRendererService.renderFollow(follower, followee as PartialLocalUser, request.requestId!), followee));
this.queueService.deliver(followee, content, follower.inbox, false);
}
@@ -562,15 +630,22 @@ export class UserFollowingService implements OnModuleInit {
*/
@bindThis
private async removeFollow(followee: Both, follower: Both): Promise<void> {
- const following = await this.followingsRepository.findOneBy({
- followeeId: followee.id,
- followerId: follower.id,
+ const following = await this.followingsRepository.findOne({
+ relations: {
+ followee: true,
+ follower: true,
+ },
+ where: {
+ followeeId: followee.id,
+ followerId: follower.id,
+ }
});
- if (!following) return;
+ if (!following || !following.followee || !following.follower) return;
await this.followingsRepository.delete(following.id);
- this.decrementFollowing(follower, followee);
+
+ this.decrementFollowing(following.follower, following.followee);
}
/**
diff --git a/packages/backend/src/core/UserKeypairService.ts b/packages/backend/src/core/UserKeypairService.ts
index 22a9fb2b8e..72c35c529c 100644
--- a/packages/backend/src/core/UserKeypairService.ts
+++ b/packages/backend/src/core/UserKeypairService.ts
@@ -1,5 +1,5 @@
import { Inject, Injectable } from '@nestjs/common';
-import Redis from 'ioredis';
+import * as Redis from 'ioredis';
import type { User } from '@/models/entities/User.js';
import type { UserKeypairsRepository } from '@/models/index.js';
import { RedisKVCache } from '@/misc/cache.js';
diff --git a/packages/backend/src/core/UserSuspendService.ts b/packages/backend/src/core/UserSuspendService.ts
index d00bb89c76..b197d335d8 100644
--- a/packages/backend/src/core/UserSuspendService.ts
+++ b/packages/backend/src/core/UserSuspendService.ts
@@ -35,7 +35,7 @@ export class UserSuspendService {
if (this.userEntityService.isLocalUser(user)) {
// 知り得る全SharedInboxにDelete配信
- const content = this.apRendererService.addContext(this.apRendererService.renderDelete(`${this.config.url}/users/${user.id}`, user));
+ const content = this.apRendererService.addContext(this.apRendererService.renderDelete(this.userEntityService.genLocalUserUri(user.id), user));
const queue: string[] = [];
@@ -65,7 +65,7 @@ export class UserSuspendService {
if (this.userEntityService.isLocalUser(user)) {
// 知り得る全SharedInboxにUndo Delete配信
- const content = this.apRendererService.addContext(this.apRendererService.renderUndo(this.apRendererService.renderDelete(`${this.config.url}/users/${user.id}`, user), user));
+ const content = this.apRendererService.addContext(this.apRendererService.renderUndo(this.apRendererService.renderDelete(this.userEntityService.genLocalUserUri(user.id), user), user));
const queue: string[] = [];
diff --git a/packages/backend/src/core/WebfingerService.ts b/packages/backend/src/core/WebfingerService.ts
index 69df2d0c1b..3ee7990643 100644
--- a/packages/backend/src/core/WebfingerService.ts
+++ b/packages/backend/src/core/WebfingerService.ts
@@ -43,7 +43,8 @@ export class WebfingerService {
const m = query.match(/^([^@]+)@(.*)/);
if (m) {
const hostname = m[2];
- return `https://${hostname}/.well-known/webfinger?` + urlQuery({ resource: `acct:${query}` });
+ const useHttp = process.env.MISSKEY_WEBFINGER_USE_HTTP && process.env.MISSKEY_WEBFINGER_USE_HTTP.toLowerCase() === 'true';
+ return `http${useHttp ? '' : 's'}://${hostname}/.well-known/webfinger?${urlQuery({ resource: `acct:${query}` })}`;
}
throw new Error(`Invalid query (${query})`);
diff --git a/packages/backend/src/core/WebhookService.ts b/packages/backend/src/core/WebhookService.ts
index 926115613b..57baade777 100644
--- a/packages/backend/src/core/WebhookService.ts
+++ b/packages/backend/src/core/WebhookService.ts
@@ -1,5 +1,5 @@
import { Inject, Injectable } from '@nestjs/common';
-import Redis from 'ioredis';
+import * as Redis from 'ioredis';
import type { WebhooksRepository } from '@/models/index.js';
import type { Webhook } from '@/models/entities/Webhook.js';
import { DI } from '@/di-symbols.js';
diff --git a/packages/backend/src/core/activitypub/ApDbResolverService.ts b/packages/backend/src/core/activitypub/ApDbResolverService.ts
index 4b032be89a..2b404ebeca 100644
--- a/packages/backend/src/core/activitypub/ApDbResolverService.ts
+++ b/packages/backend/src/core/activitypub/ApDbResolverService.ts
@@ -8,7 +8,7 @@ import type { UserPublickey } from '@/models/entities/UserPublickey.js';
import { CacheService } from '@/core/CacheService.js';
import type { Note } from '@/models/entities/Note.js';
import { bindThis } from '@/decorators.js';
-import { RemoteUser, User } from '@/models/entities/User.js';
+import { LocalUser, RemoteUser } from '@/models/entities/User.js';
import { getApId } from './type.js';
import { ApPersonService } from './models/ApPersonService.js';
import type { IObject } from './type.js';
@@ -101,7 +101,7 @@ export class ApDbResolverService {
* AP Person => Misskey User in DB
*/
@bindThis
- public async getUserFromApId(value: string | IObject): Promise<User | null> {
+ public async getUserFromApId(value: string | IObject): Promise<LocalUser | RemoteUser | null> {
const parsed = this.parseUri(value);
if (parsed.local) {
@@ -109,11 +109,11 @@ export class ApDbResolverService {
return await this.cacheService.userByIdCache.fetchMaybe(parsed.id, () => this.usersRepository.findOneBy({
id: parsed.id,
- }).then(x => x ?? undefined)) ?? null;
+ }).then(x => x ?? undefined)) as LocalUser | undefined ?? null;
} else {
return await this.cacheService.uriPersonCache.fetch(parsed.uri, () => this.usersRepository.findOneBy({
uri: parsed.uri,
- }));
+ })) as RemoteUser | null;
}
}
diff --git a/packages/backend/src/core/activitypub/ApInboxService.ts b/packages/backend/src/core/activitypub/ApInboxService.ts
index 3fca0bb1fd..efef777fb0 100644
--- a/packages/backend/src/core/activitypub/ApInboxService.ts
+++ b/packages/backend/src/core/activitypub/ApInboxService.ts
@@ -1,5 +1,5 @@
import { Inject, Injectable } from '@nestjs/common';
-import { In, IsNull } from 'typeorm';
+import { In } from 'typeorm';
import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
import { UserFollowingService } from '@/core/UserFollowingService.js';
@@ -13,13 +13,15 @@ import { concat, toArray, toSingle, unique } from '@/misc/prelude/array.js';
import { AppLockService } from '@/core/AppLockService.js';
import type Logger from '@/logger.js';
import { MetaService } from '@/core/MetaService.js';
+import { AccountMoveService } from '@/core/AccountMoveService.js';
import { IdService } from '@/core/IdService.js';
import { StatusError } from '@/misc/status-error.js';
import { UtilityService } from '@/core/UtilityService.js';
+import { CacheService } from '@/core/CacheService.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { QueueService } from '@/core/QueueService.js';
-import type { UsersRepository, NotesRepository, FollowingsRepository, AbuseUserReportsRepository, FollowRequestsRepository } from '@/models/index.js';
+import type { UsersRepository, NotesRepository, FollowingsRepository, AbuseUserReportsRepository, FollowRequestsRepository, } from '@/models/index.js';
import { bindThis } from '@/decorators.js';
import type { RemoteUser } from '@/models/entities/User.js';
import { getApHrefNullable, getApId, getApIds, getApType, getOneApHrefNullable, isAccept, isActor, isAdd, isAnnounce, isBlock, isCollection, isCollectionOrOrderedCollection, isCreate, isDelete, isFlag, isFollow, isLike, isMove, isPost, isReject, isRemove, isTombstone, isUndo, isUpdate, validActor, validPost } from './type.js';
@@ -76,6 +78,8 @@ export class ApInboxService {
private apNoteService: ApNoteService,
private apPersonService: ApPersonService,
private apQuestionService: ApQuestionService,
+ private accountMoveService: AccountMoveService,
+ private cacheService: CacheService,
private queueService: QueueService,
) {
this.logger = this.apLoggerService.logger;
@@ -140,7 +144,7 @@ export class ApInboxService {
} else if (isFlag(activity)) {
await this.flag(actor, activity);
} else if (isMove(activity)) {
- //await this.move(actor, activity);
+ await this.move(actor, activity);
} else {
this.logger.warn(`unrecognized activity type: ${activity.type}`);
}
@@ -158,6 +162,7 @@ export class ApInboxService {
return 'skip: フォローしようとしているユーザーはローカルユーザーではありません';
}
+ // don't queue because the sender may attempt again when timeout
await this.userFollowingService.follow(actor, followee, activity.id);
return 'ok';
}
@@ -596,6 +601,7 @@ export class ApInboxService {
throw e;
});
+ // don't queue because the sender may attempt again when timeout
if (isFollow(object)) return await this.undoFollow(actor, object);
if (isBlock(object)) return await this.undoBlock(actor, object);
if (isLike(object)) return await this.undoLike(actor, object);
@@ -736,53 +742,7 @@ export class ApInboxService {
// fetch the new and old accounts
const targetUri = getApHrefNullable(activity.target);
if (!targetUri) return 'skip: invalid activity target';
- let new_acc = await this.apPersonService.resolvePerson(targetUri);
- let old_acc = await this.apPersonService.resolvePerson(actor.uri);
- // update them if they're remote
- if (new_acc.uri) await this.apPersonService.updatePerson(new_acc.uri);
- if (old_acc.uri) await this.apPersonService.updatePerson(old_acc.uri);
-
- // retrieve updated users
- new_acc = await this.apPersonService.resolvePerson(targetUri);
- old_acc = await this.apPersonService.resolvePerson(actor.uri);
-
- // check if alsoKnownAs of the new account is valid
- let isValidMove = true;
- if (old_acc.uri) {
- if (!new_acc.alsoKnownAs?.includes(old_acc.uri)) {
- isValidMove = false;
- }
- } else if (!new_acc.alsoKnownAs?.includes(old_acc.id)) {
- isValidMove = false;
- }
- if (!isValidMove) {
- return 'skip: accounts invalid';
- }
-
- // add target uri to movedToUri in order to indicate that the user has moved
- await this.usersRepository.update(old_acc.id, { movedToUri: targetUri });
-
- // follow the new account and unfollow the old one
- const followings = await this.followingsRepository.find({
- relations: {
- follower: true,
- },
- where: {
- followeeId: old_acc.id,
- followerHost: IsNull(), // follower is local
- },
- });
- for (const following of followings) {
- if (!following.follower) continue;
- try {
- await this.userFollowingService.follow(following.follower, new_acc);
- await this.userFollowingService.unfollow(following.follower, old_acc);
- } catch {
- /* empty */
- }
- }
-
- return 'ok';
+ return await this.apPersonService.updatePerson(actor.uri) ?? 'skip: nothing to do';
}
}
diff --git a/packages/backend/src/core/activitypub/ApRendererService.ts b/packages/backend/src/core/activitypub/ApRendererService.ts
index 0b22aa9bcf..60e19bfca5 100644
--- a/packages/backend/src/core/activitypub/ApRendererService.ts
+++ b/packages/backend/src/core/activitypub/ApRendererService.ts
@@ -5,7 +5,7 @@ import { v4 as uuid } from 'uuid';
import * as mfm from 'mfm-js';
import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
-import type { LocalUser, RemoteUser, User } from '@/models/entities/User.js';
+import type { PartialLocalUser, LocalUser, PartialRemoteUser, RemoteUser, User } from '@/models/entities/User.js';
import type { IMentionedRemoteUsers, Note } from '@/models/entities/Note.js';
import type { Blocking } from '@/models/entities/Blocking.js';
import type { Relay } from '@/models/entities/Relay.js';
@@ -66,7 +66,7 @@ export class ApRendererService {
public renderAccept(object: any, user: { id: User['id']; host: null }): IAccept {
return {
type: 'Accept',
- actor: `${this.config.url}/users/${user.id}`,
+ actor: this.userEntityService.genLocalUserUri(user.id),
object,
};
}
@@ -75,7 +75,7 @@ export class ApRendererService {
public renderAdd(user: LocalUser, target: any, object: any): IAdd {
return {
type: 'Add',
- actor: `${this.config.url}/users/${user.id}`,
+ actor: this.userEntityService.genLocalUserUri(user.id),
target,
object,
};
@@ -83,7 +83,7 @@ export class ApRendererService {
@bindThis
public renderAnnounce(object: any, note: Note): IAnnounce {
- const attributedTo = `${this.config.url}/users/${note.userId}`;
+ const attributedTo = this.userEntityService.genLocalUserUri(note.userId);
let to: string[] = [];
let cc: string[] = [];
@@ -103,7 +103,7 @@ export class ApRendererService {
return {
id: `${this.config.url}/notes/${note.id}/activity`,
- actor: `${this.config.url}/users/${note.userId}`,
+ actor: this.userEntityService.genLocalUserUri(note.userId),
type: 'Announce',
published: note.createdAt.toISOString(),
to,
@@ -126,7 +126,7 @@ export class ApRendererService {
return {
type: 'Block',
id: `${this.config.url}/blocks/${block.id}`,
- actor: `${this.config.url}/users/${block.blockerId}`,
+ actor: this.userEntityService.genLocalUserUri(block.blockerId),
object: block.blockee.uri,
};
}
@@ -135,7 +135,7 @@ export class ApRendererService {
public renderCreate(object: IObject, note: Note): ICreate {
const activity = {
id: `${this.config.url}/notes/${note.id}/activity`,
- actor: `${this.config.url}/users/${note.userId}`,
+ actor: this.userEntityService.genLocalUserUri(note.userId),
type: 'Create',
published: note.createdAt.toISOString(),
object,
@@ -151,7 +151,7 @@ export class ApRendererService {
public renderDelete(object: IObject | string, user: { id: User['id']; host: null }): IDelete {
return {
type: 'Delete',
- actor: `${this.config.url}/users/${user.id}`,
+ actor: this.userEntityService.genLocalUserUri(user.id),
object,
published: new Date().toISOString(),
};
@@ -188,7 +188,7 @@ export class ApRendererService {
public renderFlag(user: LocalUser, object: IObject | string, content: string): IFlag {
return {
type: 'Flag',
- actor: `${this.config.url}/users/${user.id}`,
+ actor: this.userEntityService.genLocalUserUri(user.id),
content,
object,
};
@@ -199,7 +199,7 @@ export class ApRendererService {
return {
id: `${this.config.url}/activities/follow-relay/${relay.id}`,
type: 'Follow',
- actor: `${this.config.url}/users/${relayActor.id}`,
+ actor: this.userEntityService.genLocalUserUri(relayActor.id),
object: 'https://www.w3.org/ns/activitystreams#Public',
};
}
@@ -210,21 +210,21 @@ export class ApRendererService {
*/
@bindThis
public async renderFollowUser(id: User['id']) {
- const user = await this.usersRepository.findOneByOrFail({ id: id });
- return this.userEntityService.isLocalUser(user) ? `${this.config.url}/users/${user.id}` : user.uri;
+ const user = await this.usersRepository.findOneByOrFail({ id: id }) as PartialLocalUser | PartialRemoteUser;
+ return this.userEntityService.getUserUri(user);
}
@bindThis
public renderFollow(
- follower: { id: User['id']; host: User['host']; uri: User['host'] },
- followee: { id: User['id']; host: User['host']; uri: User['host'] },
+ follower: PartialLocalUser | PartialRemoteUser,
+ followee: PartialLocalUser | PartialRemoteUser,
requestId?: string,
): IFollow {
return {
id: requestId ?? `${this.config.url}/follows/${follower.id}/${followee.id}`,
type: 'Follow',
- actor: this.userEntityService.isLocalUser(follower) ? `${this.config.url}/users/${follower.id}` : follower.uri!,
- object: this.userEntityService.isLocalUser(followee) ? `${this.config.url}/users/${followee.id}` : followee.uri!,
+ actor: this.userEntityService.getUserUri(follower)!,
+ object: this.userEntityService.getUserUri(followee)!,
};
}
@@ -252,7 +252,7 @@ export class ApRendererService {
return {
id: `${this.config.url}/users/${user.id}${postfix ?? '/publickey'}`,
type: 'Key',
- owner: `${this.config.url}/users/${user.id}`,
+ owner: this.userEntityService.genLocalUserUri(user.id),
publicKeyPem: createPublicKey(key.publicKey).export({
type: 'spki',
format: 'pem',
@@ -284,21 +284,21 @@ export class ApRendererService {
}
@bindThis
- public renderMention(mention: User): IApMention {
+ public renderMention(mention: PartialLocalUser | PartialRemoteUser): IApMention {
return {
type: 'Mention',
- href: this.userEntityService.isRemoteUser(mention) ? mention.uri! : `${this.config.url}/users/${(mention as LocalUser).id}`,
+ href: this.userEntityService.getUserUri(mention)!,
name: this.userEntityService.isRemoteUser(mention) ? `@${mention.username}@${mention.host}` : `@${(mention as LocalUser).username}`,
};
}
@bindThis
public renderMove(
- src: { id: User['id']; host: User['host']; uri: User['host'] },
- dst: { id: User['id']; host: User['host']; uri: User['host'] },
+ src: PartialLocalUser | PartialRemoteUser,
+ dst: PartialLocalUser | PartialRemoteUser,
): IMove {
- const actor = this.userEntityService.isLocalUser(src) ? `${this.config.url}/users/${src.id}` : src.uri!;
- const target = this.userEntityService.isLocalUser(dst) ? `${this.config.url}/users/${dst.id}` : dst.uri!;
+ const actor = this.userEntityService.getUserUri(src)!;
+ const target = this.userEntityService.getUserUri(dst)!;
return {
id: `${this.config.url}/moves/${src.id}/${dst.id}`,
actor,
@@ -351,7 +351,7 @@ export class ApRendererService {
}
}
- const attributedTo = `${this.config.url}/users/${note.userId}`;
+ const attributedTo = this.userEntityService.genLocalUserUri(note.userId);
const mentions = (JSON.parse(note.mentionedRemoteUsers) as IMentionedRemoteUsers).map(x => x.uri);
@@ -376,7 +376,7 @@ export class ApRendererService {
}) : [];
const hashtagTags = (note.tags ?? []).map(tag => this.renderHashtag(tag));
- const mentionTags = mentionedUsers.map(u => this.renderMention(u));
+ const mentionTags = mentionedUsers.map(u => this.renderMention(u as LocalUser | RemoteUser));
const files = await getPromisedFiles(note.fileIds);
@@ -450,7 +450,7 @@ export class ApRendererService {
@bindThis
public async renderPerson(user: LocalUser) {
- const id = `${this.config.url}/users/${user.id}`;
+ const id = this.userEntityService.genLocalUserUri(user.id);
const isSystem = !!user.username.match(/\./);
const [avatar, banner, profile] = await Promise.all([
@@ -538,7 +538,7 @@ export class ApRendererService {
return {
type: 'Question',
id: `${this.config.url}/questions/${note.id}`,
- actor: `${this.config.url}/users/${user.id}`,
+ actor: this.userEntityService.genLocalUserUri(user.id),
content: note.text ?? '',
[poll.multiple ? 'anyOf' : 'oneOf']: poll.choices.map((text, i) => ({
name: text,
@@ -555,7 +555,7 @@ export class ApRendererService {
public renderReject(object: any, user: { id: User['id'] }): IReject {
return {
type: 'Reject',
- actor: `${this.config.url}/users/${user.id}`,
+ actor: this.userEntityService.genLocalUserUri(user.id),
object,
};
}
@@ -564,7 +564,7 @@ export class ApRendererService {
public renderRemove(user: { id: User['id'] }, target: any, object: any): IRemove {
return {
type: 'Remove',
- actor: `${this.config.url}/users/${user.id}`,
+ actor: this.userEntityService.genLocalUserUri(user.id),
target,
object,
};
@@ -585,7 +585,7 @@ export class ApRendererService {
return {
type: 'Undo',
...(id ? { id } : {}),
- actor: `${this.config.url}/users/${user.id}`,
+ actor: this.userEntityService.genLocalUserUri(user.id),
object,
published: new Date().toISOString(),
};
@@ -595,7 +595,7 @@ export class ApRendererService {
public renderUpdate(object: any, user: { id: User['id'] }): IUpdate {
return {
id: `${this.config.url}/users/${user.id}#updates/${new Date().getTime()}`,
- actor: `${this.config.url}/users/${user.id}`,
+ actor: this.userEntityService.genLocalUserUri(user.id),
type: 'Update',
to: ['https://www.w3.org/ns/activitystreams#Public'],
object,
@@ -607,14 +607,14 @@ export class ApRendererService {
public renderVote(user: { id: User['id'] }, vote: PollVote, note: Note, poll: Poll, pollOwner: RemoteUser): ICreate {
return {
id: `${this.config.url}/users/${user.id}#votes/${vote.id}/activity`,
- actor: `${this.config.url}/users/${user.id}`,
+ actor: this.userEntityService.genLocalUserUri(user.id),
type: 'Create',
to: [pollOwner.uri],
published: new Date().toISOString(),
object: {
id: `${this.config.url}/users/${user.id}#votes/${vote.id}`,
type: 'Note',
- attributedTo: `${this.config.url}/users/${user.id}`,
+ attributedTo: this.userEntityService.genLocalUserUri(user.id),
to: [pollOwner.uri],
inReplyTo: note.uri,
name: poll.choices[vote.choice],
diff --git a/packages/backend/src/core/activitypub/ApResolverService.ts b/packages/backend/src/core/activitypub/ApResolverService.ts
index df7bb46405..d3e0345c9c 100644
--- a/packages/backend/src/core/activitypub/ApResolverService.ts
+++ b/packages/backend/src/core/activitypub/ApResolverService.ts
@@ -1,5 +1,5 @@
import { Inject, Injectable } from '@nestjs/common';
-import type { LocalUser } from '@/models/entities/User.js';
+import type { LocalUser, RemoteUser } from '@/models/entities/User.js';
import { InstanceActorService } from '@/core/InstanceActorService.js';
import type { NotesRepository, PollsRepository, NoteReactionsRepository, UsersRepository } from '@/models/index.js';
import type { Config } from '@/config.js';
@@ -151,7 +151,7 @@ export class Resolver {
return Promise.all(
[parsed.id, parsed.rest].map(id => this.usersRepository.findOneByOrFail({ id })),
)
- .then(([follower, followee]) => this.apRendererService.addContext(this.apRendererService.renderFollow(follower, followee, url)));
+ .then(([follower, followee]) => this.apRendererService.addContext(this.apRendererService.renderFollow(follower as LocalUser | RemoteUser, followee as LocalUser | RemoteUser, url)));
default:
throw new Error(`resolveLocal: type ${parsed.type} unhandled`);
}
diff --git a/packages/backend/src/core/activitypub/models/ApImageService.ts b/packages/backend/src/core/activitypub/models/ApImageService.ts
index 3b671af127..0043907c21 100644
--- a/packages/backend/src/core/activitypub/models/ApImageService.ts
+++ b/packages/backend/src/core/activitypub/models/ApImageService.ts
@@ -12,6 +12,7 @@ import type Logger from '@/logger.js';
import { bindThis } from '@/decorators.js';
import { ApResolverService } from '../ApResolverService.js';
import { ApLoggerService } from '../ApLoggerService.js';
+import { checkHttps } from '@/misc/check-https.js';
@Injectable()
export class ApImageService {
@@ -48,8 +49,8 @@ export class ApImageService {
throw new Error('invalid image: url not privided');
}
- if (!image.url.startsWith('https://')) {
- throw new Error('invalid image: unexpected shcema of url: ' + image.url);
+ if (!checkHttps(image.url)) {
+ throw new Error('invalid image: unexpected schema of url: ' + image.url);
}
this.logger.info(`Creating the Image: ${image.url}`);
diff --git a/packages/backend/src/core/activitypub/models/ApNoteService.ts b/packages/backend/src/core/activitypub/models/ApNoteService.ts
index 5ca5f6e843..87a9db405f 100644
--- a/packages/backend/src/core/activitypub/models/ApNoteService.ts
+++ b/packages/backend/src/core/activitypub/models/ApNoteService.ts
@@ -32,6 +32,7 @@ import { ApQuestionService } from './ApQuestionService.js';
import { ApImageService } from './ApImageService.js';
import type { Resolver } from '../ApResolverService.js';
import type { IObject, IPost } from '../type.js';
+import { checkHttps } from '@/misc/check-https.js';
@Injectable()
export class ApNoteService {
@@ -71,7 +72,7 @@ export class ApNoteService {
}
@bindThis
- public validateNote(object: any, uri: string) {
+ public validateNote(object: IObject, uri: string) {
const expectHost = this.utilityService.extractDbHost(uri);
if (object == null) {
@@ -85,9 +86,10 @@ export class ApNoteService {
if (object.id && this.utilityService.extractDbHost(object.id) !== expectHost) {
return new Error(`invalid Note: id has different host. expected: ${expectHost}, actual: ${this.utilityService.extractDbHost(object.id)}`);
}
-
- if (object.attributedTo && this.utilityService.extractDbHost(getOneApId(object.attributedTo)) !== expectHost) {
- return new Error(`invalid Note: attributedTo has different host. expected: ${expectHost}, actual: ${this.utilityService.extractDbHost(object.attributedTo)}`);
+
+ const actualHost = object.attributedTo && this.utilityService.extractDbHost(getOneApId(object.attributedTo));
+ if (object.attributedTo && actualHost !== expectHost) {
+ return new Error(`invalid Note: attributedTo has different host. expected: ${expectHost}, actual: ${actualHost}`);
}
return null;
@@ -129,13 +131,13 @@ export class ApNoteService {
this.logger.debug(`Note fetched: ${JSON.stringify(note, null, 2)}`);
- if (note.id && !note.id.startsWith('https://')) {
+ if (note.id && !checkHttps(note.id)) {
throw new Error('unexpected shcema of note.id: ' + note.id);
}
const url = getOneApHrefNullable(note.url);
- if (url && !url.startsWith('https://')) {
+ if (url && !checkHttps(url)) {
throw new Error('unexpected shcema of note url: ' + url);
}
@@ -148,7 +150,7 @@ export class ApNoteService {
if (actor.isSuspended) {
throw new Error('actor has been suspended');
}
-
+
const noteAudience = await this.apAudienceService.parseAudience(actor, note.to, note.cc, resolver);
let visibility = noteAudience.visibility;
const visibleUsers = noteAudience.visibleUsers;
diff --git a/packages/backend/src/core/activitypub/models/ApPersonService.ts b/packages/backend/src/core/activitypub/models/ApPersonService.ts
index 21797cfcb7..eea1d1b848 100644
--- a/packages/backend/src/core/activitypub/models/ApPersonService.ts
+++ b/packages/backend/src/core/activitypub/models/ApPersonService.ts
@@ -3,9 +3,9 @@ import promiseLimit from 'promise-limit';
import { DataSource } from 'typeorm';
import { ModuleRef } from '@nestjs/core';
import { DI } from '@/di-symbols.js';
-import type { FollowingsRepository, InstancesRepository, UserProfilesRepository, UserPublickeysRepository, UsersRepository } from '@/models/index.js';
+import type { BlockingsRepository, MutingsRepository, FollowingsRepository, InstancesRepository, UserProfilesRepository, UserPublickeysRepository, UsersRepository } from '@/models/index.js';
import type { Config } from '@/config.js';
-import type { RemoteUser } from '@/models/entities/User.js';
+import type { LocalUser, RemoteUser } from '@/models/entities/User.js';
import { User } from '@/models/entities/User.js';
import { truncate } from '@/misc/truncate.js';
import type { CacheService } from '@/core/CacheService.js';
@@ -42,6 +42,8 @@ import type { ApLoggerService } from '../ApLoggerService.js';
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
import type { ApImageService } from './ApImageService.js';
import type { IActor, IObject } from '../type.js';
+import type { AccountMoveService } from '@/core/AccountMoveService.js';
+import { checkHttps } from '@/misc/check-https.js';
const nameLength = 128;
const summaryLength = 2048;
@@ -66,6 +68,7 @@ export class ApPersonService implements OnModuleInit {
private usersChart: UsersChart;
private instanceChart: InstanceChart;
private apLoggerService: ApLoggerService;
+ private accountMoveService: AccountMoveService;
private logger: Logger;
constructor(
@@ -131,9 +134,16 @@ export class ApPersonService implements OnModuleInit {
this.usersChart = this.moduleRef.get('UsersChart');
this.instanceChart = this.moduleRef.get('InstanceChart');
this.apLoggerService = this.moduleRef.get('ApLoggerService');
+ this.accountMoveService = this.moduleRef.get('AccountMoveService');
this.logger = this.apLoggerService.logger;
}
+ private punyHost(url: string): string {
+ const urlObj = new URL(url);
+ const host = `${this.utilityService.toPuny(urlObj.hostname)}${urlObj.port.length > 0 ? ':' + urlObj.port : ''}`;
+ return host;
+ }
+
/**
* Validate and convert to actor object
* @param x Fetched object
@@ -141,7 +151,7 @@ export class ApPersonService implements OnModuleInit {
*/
@bindThis
private validateActor(x: IObject, uri: string): IActor {
- const expectHost = this.utilityService.toPuny(new URL(uri).hostname);
+ const expectHost = this.punyHost(uri);
if (x == null) {
throw new Error('invalid Actor: object is null');
@@ -182,7 +192,7 @@ export class ApPersonService implements OnModuleInit {
x.summary = truncate(x.summary, summaryLength);
}
- const idHost = this.utilityService.toPuny(new URL(x.id!).hostname);
+ const idHost = this.punyHost(x.id);
if (idHost !== expectHost) {
throw new Error('invalid Actor: id has different host');
}
@@ -192,7 +202,7 @@ export class ApPersonService implements OnModuleInit {
throw new Error('invalid Actor: publicKey.id is not a string');
}
- const publicKeyIdHost = this.utilityService.toPuny(new URL(x.publicKey.id).hostname);
+ const publicKeyIdHost = this.punyHost(x.publicKey.id);
if (publicKeyIdHost !== expectHost) {
throw new Error('invalid Actor: publicKey.id has different host');
}
@@ -202,27 +212,27 @@ export class ApPersonService implements OnModuleInit {
}
/**
- * Personをフェッチします。
+ * uriからUser(Person)をフェッチします。
*
- * Misskeyに対象のPersonが登録されていればそれを返します。
+ * Misskeyに対象のPersonが登録されていればそれを返し、登録がなければnullを返します。
*/
@bindThis
- public async fetchPerson(uri: string, resolver?: Resolver): Promise<User | null> {
+ public async fetchPerson(uri: string): Promise<LocalUser | RemoteUser | null> {
if (typeof uri !== 'string') throw new Error('uri is not string');
- const cached = this.cacheService.uriPersonCache.get(uri);
+ const cached = this.cacheService.uriPersonCache.get(uri) as LocalUser | RemoteUser | null;
if (cached) return cached;
// URIがこのサーバーを指しているならデータベースからフェッチ
- if (uri.startsWith(this.config.url + '/')) {
+ if (uri.startsWith(`${this.config.url}/`)) {
const id = uri.split('/').pop();
- const u = await this.usersRepository.findOneBy({ id });
+ const u = await this.usersRepository.findOneBy({ id }) as LocalUser;
if (u) this.cacheService.uriPersonCache.set(uri, u);
return u;
}
//#region このサーバーに既に登録されていたらそれを返す
- const exist = await this.usersRepository.findOneBy({ uri });
+ const exist = await this.usersRepository.findOneBy({ uri }) as LocalUser | RemoteUser;
if (exist) {
this.cacheService.uriPersonCache.set(uri, exist);
@@ -237,7 +247,7 @@ export class ApPersonService implements OnModuleInit {
* Personを作成します。
*/
@bindThis
- public async createPerson(uri: string, resolver?: Resolver): Promise<User> {
+ public async createPerson(uri: string, resolver?: Resolver): Promise<RemoteUser> {
if (typeof uri !== 'string') throw new Error('uri is not string');
if (uri.startsWith(this.config.url)) {
@@ -252,7 +262,7 @@ export class ApPersonService implements OnModuleInit {
this.logger.info(`Creating the Person: ${person.id}`);
- const host = this.utilityService.toPuny(new URL(object.id).hostname);
+ const host = this.punyHost(object.id);
const { fields } = this.analyzeAttachments(person.attachment ?? []);
@@ -264,8 +274,8 @@ export class ApPersonService implements OnModuleInit {
const url = getOneApHrefNullable(person.url);
- if (url && !url.startsWith('https://')) {
- throw new Error('unexpected shcema of person url: ' + url);
+ if (url && !checkHttps(url)) {
+ throw new Error('unexpected schema of person url: ' + url);
}
// Create user
@@ -282,6 +292,7 @@ export class ApPersonService implements OnModuleInit {
name: truncate(person.name, nameLength),
isLocked: !!person.manuallyApprovesFollowers,
movedToUri: person.movedTo,
+ movedAt: person.movedTo ? new Date() : null,
alsoKnownAs: person.alsoKnownAs,
isExplorable: !!person.discoverable,
username: person.preferredUsername,
@@ -404,23 +415,26 @@ export class ApPersonService implements OnModuleInit {
/**
* Personの情報を更新します。
* Misskeyに対象のPersonが登録されていなければ無視します。
+ * もしアカウントの移行が確認された場合、アカウント移行処理を行います。
+ *
* @param uri URI of Person
* @param resolver Resolver
* @param hint Hint of Person object (この値が正当なPersonの場合、Remote resolveをせずに更新に利用します)
+ * @param movePreventUris ここに指定されたURIがPersonのmovedToに指定されていたり10回より多く回っている場合これ以上アカウント移行を行わない(無限ループ防止)
*/
@bindThis
- public async updatePerson(uri: string, resolver?: Resolver | null, hint?: IObject): Promise<void> {
+ public async updatePerson(uri: string, resolver?: Resolver | null, hint?: IObject, movePreventUris: string[] = []): Promise<string | void> {
if (typeof uri !== 'string') throw new Error('uri is not string');
// URIがこのサーバーを指しているならスキップ
- if (uri.startsWith(this.config.url + '/')) {
+ if (uri.startsWith(`${this.config.url}/`)) {
return;
}
//#region このサーバーに既に登録されているか
- const exist = await this.usersRepository.findOneBy({ uri }) as RemoteUser;
+ const exist = await this.usersRepository.findOneBy({ uri }) as RemoteUser | null;
- if (exist == null) {
+ if (exist === null) {
return;
}
//#endregion
@@ -459,8 +473,8 @@ export class ApPersonService implements OnModuleInit {
const url = getOneApHrefNullable(person.url);
- if (url && !url.startsWith('https://')) {
- throw new Error('unexpected shcema of person url: ' + url);
+ if (url && !checkHttps(url)) {
+ throw new Error('unexpected schema of person url: ' + url);
}
const updates = {
@@ -478,7 +492,16 @@ export class ApPersonService implements OnModuleInit {
movedToUri: person.movedTo ?? null,
alsoKnownAs: person.alsoKnownAs ?? null,
isExplorable: !!person.discoverable,
- } as Partial<User>;
+ } as Partial<RemoteUser> & Pick<RemoteUser, 'isBot' | 'isCat' | 'isLocked' | 'movedToUri' | 'alsoKnownAs' | 'isExplorable'>;
+
+ const moving =
+ // 移行先がない→ある
+ (!exist.movedToUri && updates.movedToUri) ||
+ // 移行先がある→別のもの
+ (exist.movedToUri !== updates.movedToUri && exist.movedToUri && updates.movedToUri);
+ // 移行先がある→ない、ない→ないは無視
+
+ if (moving) updates.movedAt = new Date();
if (avatar) {
updates.avatarId = avatar.id;
@@ -523,6 +546,31 @@ export class ApPersonService implements OnModuleInit {
});
await this.updateFeatured(exist.id, resolver).catch(err => this.logger.error(err));
+
+ const updated = { ...exist, ...updates };
+
+ this.cacheService.uriPersonCache.set(uri, updated);
+
+ // 移行処理を行う
+ if (updated.movedAt && (
+ // 初めて移行する場合はmovedAtがnullなので移行処理を許可
+ exist.movedAt == null ||
+ // 以前のmovingから14日以上経過した場合のみ移行処理を許可
+ // (Mastodonのクールダウン期間は30日だが若干緩めに設定しておく)
+ exist.movedAt.getTime() + 1000 * 60 * 60 * 24 * 14 < updated.movedAt.getTime()
+ )) {
+ this.logger.info(`Start to process Move of @${updated.username}@${updated.host} (${uri})`);
+ return this.processRemoteMove(updated, movePreventUris)
+ .then(result => {
+ this.logger.info(`Processing Move Finished [${result}] @${updated.username}@${updated.host} (${uri})`);
+ return result;
+ })
+ .catch(e => {
+ this.logger.info(`Processing Move Failed @${updated.username}@${updated.host} (${uri})`, { stack: e });
+ });
+ }
+
+ return 'skip';
}
/**
@@ -532,7 +580,7 @@ export class ApPersonService implements OnModuleInit {
* リモートサーバーからフェッチしてMisskeyに登録しそれを返します。
*/
@bindThis
- public async resolvePerson(uri: string, resolver?: Resolver): Promise<User> {
+ public async resolvePerson(uri: string, resolver?: Resolver): Promise<LocalUser | RemoteUser> {
if (typeof uri !== 'string') throw new Error('uri is not string');
//#region このサーバーに既に登録されていたらそれを返す
@@ -607,4 +655,53 @@ export class ApPersonService implements OnModuleInit {
}
});
}
+
+ /**
+ * リモート由来のアカウント移行処理を行います
+ * @param src 移行元アカウント(リモートかつupdatePerson後である必要がある、というかこれ自体がupdatePersonで呼ばれる前提)
+ * @param movePreventUris ここに列挙されたURIにsrc.movedToUriが含まれる場合、移行処理はしない(無限ループ防止)
+ */
+ @bindThis
+ private async processRemoteMove(src: RemoteUser, movePreventUris: string[] = []): Promise<string> {
+ if (!src.movedToUri) return 'skip: no movedToUri';
+ if (src.uri === src.movedToUri) return 'skip: movedTo itself (src)'; // ???
+ if (movePreventUris.length > 10) return 'skip: too many moves';
+
+ // まずサーバー内で検索して様子見
+ let dst = await this.fetchPerson(src.movedToUri);
+
+ if (dst && this.userEntityService.isLocalUser(dst)) {
+ // targetがローカルユーザーだった場合データベースから引っ張ってくる
+ dst = await this.usersRepository.findOneByOrFail({ uri: src.movedToUri }) as LocalUser;
+ } else if (dst) {
+ if (movePreventUris.includes(src.movedToUri)) return 'skip: circular move';
+
+ // targetを見つけたことがあるならtargetをupdatePersonする
+ await this.updatePerson(src.movedToUri, undefined, undefined, [...movePreventUris, src.uri]);
+ dst = await this.fetchPerson(src.movedToUri) ?? dst;
+ } else {
+ if (src.movedToUri.startsWith(`${this.config.url}/`)) {
+ // ローカルユーザーっぽいのにfetchPersonで見つからないということはmovedToUriが間違っている
+ return 'failed: movedTo is local but not found';
+ }
+
+ // targetが知らない人だったらresolvePerson
+ // (uriが存在しなかったり応答がなかったりする場合resolvePersonはthrow Errorする)
+ dst = await this.resolvePerson(src.movedToUri);
+ }
+
+ if (dst.movedToUri === dst.uri) return 'skip: movedTo itself (dst)'; // ???
+ if (src.movedToUri !== dst.uri) return 'skip: missmatch uri'; // ???
+ if (dst.movedToUri === src.uri) return 'skip: dst.movedToUri === src.uri';
+ if (!dst.alsoKnownAs || dst.alsoKnownAs.length === 0) {
+ return 'skip: dst.alsoKnownAs is empty';
+ }
+ if (!dst.alsoKnownAs?.includes(src.uri)) {
+ return 'skip: alsoKnownAs does not include from.uri';
+ }
+
+ await this.accountMoveService.postMoveProcess(src, dst);
+
+ return 'ok';
+ }
}
diff --git a/packages/backend/src/core/entities/ChannelEntityService.ts b/packages/backend/src/core/entities/ChannelEntityService.ts
index 987002606f..15ffd44861 100644
--- a/packages/backend/src/core/entities/ChannelEntityService.ts
+++ b/packages/backend/src/core/entities/ChannelEntityService.ts
@@ -74,6 +74,8 @@ export class ChannelEntityService {
userId: channel.userId,
bannerUrl: banner ? this.driveFileEntityService.getPublicUrl(banner) : null,
pinnedNoteIds: channel.pinnedNoteIds,
+ color: channel.color,
+ isArchived: channel.isArchived,
usersCount: channel.usersCount,
notesCount: channel.notesCount,
diff --git a/packages/backend/src/core/entities/NoteEntityService.ts b/packages/backend/src/core/entities/NoteEntityService.ts
index 26debd6adc..32269a4101 100644
--- a/packages/backend/src/core/entities/NoteEntityService.ts
+++ b/packages/backend/src/core/entities/NoteEntityService.ts
@@ -335,6 +335,7 @@ export class NoteEntityService implements OnModuleInit {
channel: channel ? {
id: channel.id,
name: channel.name,
+ color: channel.color,
} : undefined,
mentions: note.mentions.length > 0 ? note.mentions : undefined,
uri: note.uri ?? undefined,
diff --git a/packages/backend/src/core/entities/NotificationEntityService.ts b/packages/backend/src/core/entities/NotificationEntityService.ts
index 0dc63d969f..d76b863957 100644
--- a/packages/backend/src/core/entities/NotificationEntityService.ts
+++ b/packages/backend/src/core/entities/NotificationEntityService.ts
@@ -2,7 +2,7 @@ import { Inject, Injectable } from '@nestjs/common';
import { ModuleRef } from '@nestjs/core';
import { In } from 'typeorm';
import { DI } from '@/di-symbols.js';
-import type { AccessTokensRepository, NoteReactionsRepository, NotesRepository, User, UsersRepository } from '@/models/index.js';
+import type { AccessTokensRepository, FollowRequestsRepository, NoteReactionsRepository, NotesRepository, User, UsersRepository } from '@/models/index.js';
import { awaitAll } from '@/misc/prelude/await-all.js';
import type { Notification } from '@/models/entities/Notification.js';
import type { Note } from '@/models/entities/Note.js';
@@ -35,6 +35,9 @@ export class NotificationEntityService implements OnModuleInit {
@Inject(DI.noteReactionsRepository)
private noteReactionsRepository: NoteReactionsRepository,
+ @Inject(DI.followRequestsRepository)
+ private followRequestsRepository: FollowRequestsRepository,
+
@Inject(DI.accessTokensRepository)
private accessTokensRepository: AccessTokensRepository,
@@ -131,6 +134,15 @@ export class NotificationEntityService implements OnModuleInit {
});
const packedUsers = new Map(packedUsersArray.map(p => [p.id, p]));
+ // 既に解決されたフォローリクエストの通知を除外
+ const followRequestNotifications = validNotifications.filter(x => x.type === 'receiveFollowRequest');
+ if (followRequestNotifications.length > 0) {
+ const reqs = await this.followRequestsRepository.find({
+ where: { followerId: In(followRequestNotifications.map(x => x.notifierId!)) },
+ });
+ validNotifications = validNotifications.filter(x => (x.type !== 'receiveFollowRequest') || reqs.some(r => r.followerId === x.notifierId));
+ }
+
return await Promise.all(validNotifications.map(x => this.pack(x, meId, {}, {
packedNotes,
packedUsers,
diff --git a/packages/backend/src/core/entities/RoleEntityService.ts b/packages/backend/src/core/entities/RoleEntityService.ts
index e111a10b77..54818782dd 100644
--- a/packages/backend/src/core/entities/RoleEntityService.ts
+++ b/packages/backend/src/core/entities/RoleEntityService.ts
@@ -59,6 +59,7 @@ export class RoleEntityService {
isPublic: role.isPublic,
isAdministrator: role.isAdministrator,
isModerator: role.isModerator,
+ isExplorable: role.isExplorable,
asBadge: role.asBadge,
canEditMembersByModerator: role.canEditMembersByModerator,
displayOrder: role.displayOrder,
diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts
index e02f7535d4..a7f62c05f7 100644
--- a/packages/backend/src/core/entities/UserEntityService.ts
+++ b/packages/backend/src/core/entities/UserEntityService.ts
@@ -1,6 +1,6 @@
import { Inject, Injectable } from '@nestjs/common';
import { In, Not } from 'typeorm';
-import Redis from 'ioredis';
+import * as Redis from 'ioredis';
import Ajv from 'ajv';
import { ModuleRef } from '@nestjs/core';
import { DI } from '@/di-symbols.js';
@@ -9,10 +9,9 @@ import type { Packed } from '@/misc/json-schema.js';
import type { Promiseable } from '@/misc/prelude/await-all.js';
import { awaitAll } from '@/misc/prelude/await-all.js';
import { USER_ACTIVE_THRESHOLD, USER_ONLINE_THRESHOLD } from '@/const.js';
-import type { Instance } from '@/models/entities/Instance.js';
-import type { LocalUser, RemoteUser, User } from '@/models/entities/User.js';
+import type { LocalUser, PartialLocalUser, PartialRemoteUser, RemoteUser, User } from '@/models/entities/User.js';
import { birthdaySchema, descriptionSchema, localUsernameSchema, locationSchema, nameSchema, passwordSchema } from '@/models/entities/User.js';
-import type { UsersRepository, UserSecurityKeysRepository, FollowingsRepository, FollowRequestsRepository, BlockingsRepository, MutingsRepository, DriveFilesRepository, NoteUnreadsRepository, ChannelFollowingsRepository, UserNotePiningsRepository, UserProfilesRepository, InstancesRepository, AnnouncementReadsRepository, AnnouncementsRepository, PagesRepository, UserProfile, RenoteMutingsRepository } from '@/models/index.js';
+import type { UsersRepository, UserSecurityKeysRepository, FollowingsRepository, FollowRequestsRepository, BlockingsRepository, MutingsRepository, DriveFilesRepository, NoteUnreadsRepository, ChannelFollowingsRepository, UserNotePiningsRepository, UserProfilesRepository, InstancesRepository, AnnouncementReadsRepository, AnnouncementsRepository, PagesRepository, UserProfile, RenoteMutingsRepository, UserMemoRepository } from '@/models/index.js';
import { bindThis } from '@/decorators.js';
import { RoleService } from '@/core/RoleService.js';
import { ApPersonService } from '@/core/activitypub/models/ApPersonService.js';
@@ -35,13 +34,13 @@ type IsMeAndIsUserDetailed<ExpectsMe extends boolean | null, Detailed extends bo
const ajv = new Ajv();
function isLocalUser(user: User): user is LocalUser;
-function isLocalUser<T extends { host: User['host'] }>(user: T): user is T & { host: null; };
+function isLocalUser<T extends { host: User['host'] }>(user: T): user is (T & { host: null; });
function isLocalUser(user: User | { host: User['host'] }): boolean {
return user.host == null;
}
function isRemoteUser(user: User): user is RemoteUser;
-function isRemoteUser<T extends { host: User['host'] }>(user: T): user is T & { host: string; };
+function isRemoteUser<T extends { host: User['host'] }>(user: T): user is (T & { host: string; });
function isRemoteUser(user: User | { host: User['host'] }): boolean {
return !isLocalUser(user);
}
@@ -113,6 +112,9 @@ export class UserEntityService implements OnModuleInit {
@Inject(DI.pagesRepository)
private pagesRepository: PagesRepository,
+
+ @Inject(DI.userMemosRepository)
+ private userMemosRepository: UserMemoRepository,
//private noteEntityService: NoteEntityService,
//private driveFileEntityService: DriveFileEntityService,
@@ -277,9 +279,20 @@ export class UserEntityService implements OnModuleInit {
return `${this.config.url}/identicon/${user.username.toLowerCase()}@${user.host ?? this.config.host}`;
}
+ @bindThis
+ public getUserUri(user: LocalUser | PartialLocalUser | RemoteUser | PartialRemoteUser): string {
+ return this.isRemoteUser(user)
+ ? user.uri : this.genLocalUserUri(user.id);
+ }
+
+ @bindThis
+ public genLocalUserUri(userId: string): string {
+ return `${this.config.url}/users/${userId}`;
+ }
+
public async pack<ExpectsMe extends boolean | null = null, D extends boolean = false>(
src: User['id'] | User,
- me?: { id: User['id'] } | null | undefined,
+ me?: { id: User['id']; } | null | undefined,
options?: {
detail?: D,
includeSecrets?: boolean,
@@ -293,26 +306,9 @@ export class UserEntityService implements OnModuleInit {
const user = typeof src === 'object' ? src : await this.usersRepository.findOneByOrFail({ id: src });
- // migration
- if (user.avatarId != null && user.avatarUrl === null) {
- const avatar = await this.driveFilesRepository.findOneByOrFail({ id: user.avatarId });
- user.avatarUrl = this.driveFileEntityService.getPublicUrl(avatar, 'avatar');
- this.usersRepository.update(user.id, {
- avatarUrl: user.avatarUrl,
- avatarBlurhash: avatar.blurhash,
- });
- }
- if (user.bannerId != null && user.bannerUrl === null) {
- const banner = await this.driveFilesRepository.findOneByOrFail({ id: user.bannerId });
- user.bannerUrl = this.driveFileEntityService.getPublicUrl(banner);
- this.usersRepository.update(user.id, {
- bannerUrl: user.bannerUrl,
- bannerBlurhash: banner.blurhash,
- });
- }
-
const meId = me ? me.id : null;
const isMe = meId === user.id;
+ const iAmModerator = me ? await this.roleService.isModerator(me as User) : false;
const relation = meId && !isMe && opts.detail ? await this.getRelation(meId, user.id) : null;
const pins = opts.detail ? await this.userNotePiningsRepository.createQueryBuilder('pin')
@@ -366,8 +362,11 @@ export class UserEntityService implements OnModuleInit {
...(opts.detail ? {
url: profile!.url,
uri: user.uri,
- movedToUri: user.movedToUri ? await this.apPersonService.resolvePerson(user.movedToUri) : null,
- alsoKnownAs: user.alsoKnownAs,
+ movedTo: user.movedToUri ? this.apPersonService.resolvePerson(user.movedToUri).then(user => user.id).catch(() => null) : null,
+ alsoKnownAs: user.alsoKnownAs
+ ? Promise.all(user.alsoKnownAs.map(uri => this.apPersonService.fetchPerson(uri).then(user => user?.id).catch(() => null)))
+ .then(xs => xs.length === 0 ? null : xs.filter(x => x != null) as string[])
+ : null,
createdAt: user.createdAt.toISOString(),
updatedAt: user.updatedAt ? user.updatedAt.toISOString() : null,
lastFetchedAt: user.lastFetchedAt ? user.lastFetchedAt.toISOString() : null,
@@ -409,6 +408,11 @@ export class UserEntityService implements OnModuleInit {
isAdministrator: role.isAdministrator,
displayOrder: role.displayOrder,
}))),
+ memo: meId == null ? null : await this.userMemosRepository.findOneBy({
+ userId: meId,
+ targetUserId: user.id,
+ }).then(row => row?.memo ?? null),
+ moderationNote: iAmModerator ? (profile!.moderationNote ?? '') : undefined,
} : {}),
...(opts.detail && isMe ? {
diff --git a/packages/backend/src/daemons/ServerStatsService.ts b/packages/backend/src/daemons/ServerStatsService.ts
index 1688bb2ba7..bb190cf60f 100644
--- a/packages/backend/src/daemons/ServerStatsService.ts
+++ b/packages/backend/src/daemons/ServerStatsService.ts
@@ -40,7 +40,7 @@ export class ServerStatsService implements OnApplicationShutdown {
const stats = {
cpu: roundCpu(cpu),
mem: {
- used: round(memStats.used - memStats.buffers - memStats.cached),
+ used: round(memStats.total - memStats.available),
active: round(memStats.active),
},
net: {
diff --git a/packages/backend/src/di-symbols.ts b/packages/backend/src/di-symbols.ts
index d4b1fb31b1..c06c7a7159 100644
--- a/packages/backend/src/di-symbols.ts
+++ b/packages/backend/src/di-symbols.ts
@@ -1,6 +1,7 @@
export const DI = {
config: Symbol('config'),
db: Symbol('db'),
+ meilisearch: Symbol('meilisearch'),
redis: Symbol('redis'),
redisForPub: Symbol('redisForPub'),
redisForSub: Symbol('redisForSub'),
@@ -70,5 +71,6 @@ export const DI = {
roleAssignmentsRepository: Symbol('roleAssignmentsRepository'),
flashsRepository: Symbol('flashsRepository'),
flashLikesRepository: Symbol('flashLikesRepository'),
+ userMemosRepository: Symbol('userMemosRepository'),
//#endregion
};
diff --git a/packages/backend/src/misc/cache.ts b/packages/backend/src/misc/cache.ts
index f413246a1f..5610929648 100644
--- a/packages/backend/src/misc/cache.ts
+++ b/packages/backend/src/misc/cache.ts
@@ -1,4 +1,4 @@
-import Redis from 'ioredis';
+import * as Redis from 'ioredis';
import { bindThis } from '@/decorators.js';
export class RedisKVCache<T> {
@@ -38,7 +38,7 @@ export class RedisKVCache<T> {
await this.redisClient.set(
`kvcache:${this.name}:${key}`,
this.toRedisConverter(value),
- 'ex', Math.round(this.lifetime / 1000),
+ 'EX', Math.round(this.lifetime / 1000),
);
}
}
@@ -122,7 +122,7 @@ export class RedisSingleCache<T> {
await this.redisClient.set(
`singlecache:${this.name}`,
this.toRedisConverter(value),
- 'ex', Math.round(this.lifetime / 1000),
+ 'EX', Math.round(this.lifetime / 1000),
);
}
}
diff --git a/packages/backend/src/misc/check-https.ts b/packages/backend/src/misc/check-https.ts
new file mode 100644
index 0000000000..b33f019973
--- /dev/null
+++ b/packages/backend/src/misc/check-https.ts
@@ -0,0 +1,4 @@
+export function checkHttps(url: string) {
+ return url.startsWith('https://') ||
+ (url.startsWith('http://') && process.env.NODE_ENV !== 'production');
+}
diff --git a/packages/backend/src/misc/check-word-mute.ts b/packages/backend/src/misc/check-word-mute.ts
index e8c66683cc..910bebfcfe 100644
--- a/packages/backend/src/misc/check-word-mute.ts
+++ b/packages/backend/src/misc/check-word-mute.ts
@@ -1,3 +1,4 @@
+import { AhoCorasick } from 'slacc';
import RE2 from 're2';
import type { Note } from '@/models/entities/Note.js';
import type { User } from '@/models/entities/User.js';
@@ -12,6 +13,8 @@ type UserLike = {
id: User['id'];
};
+const acCache = new Map<string, AhoCorasick>();
+
export async function checkWordMute(note: NoteLike, me: UserLike | null | undefined, mutedWords: Array<string | string[]>): Promise<boolean> {
// 自分自身
if (me && (note.userId === me.id)) return false;
@@ -21,7 +24,22 @@ export async function checkWordMute(note: NoteLike, me: UserLike | null | undefi
if (text === '') return false;
- const matched = mutedWords.some(filter => {
+ const acable = mutedWords.filter(filter => Array.isArray(filter) && filter.length === 1).map(filter => filter[0]).sort();
+ const unacable = mutedWords.filter(filter => !Array.isArray(filter) || filter.length !== 1);
+ const acCacheKey = acable.join('\n');
+ const ac = acCache.get(acCacheKey) ?? AhoCorasick.withPatterns(acable);
+ acCache.delete(acCacheKey);
+ for (const obsoleteKeys of acCache.keys()) {
+ if (acCache.size > 1000) {
+ acCache.delete(obsoleteKeys);
+ }
+ }
+ acCache.set(acCacheKey, ac);
+ if (ac.isMatch(text)) {
+ return true;
+ }
+
+ const matched = unacable.some(filter => {
if (Array.isArray(filter)) {
return filter.every(keyword => text.includes(keyword));
} else {
diff --git a/packages/backend/src/models/RepositoryModule.ts b/packages/backend/src/models/RepositoryModule.ts
index 7be7b81904..588c98b58d 100644
--- a/packages/backend/src/models/RepositoryModule.ts
+++ b/packages/backend/src/models/RepositoryModule.ts
@@ -1,6 +1,6 @@
import { Module } from '@nestjs/common';
import { DI } from '@/di-symbols.js';
-import { User, Note, Announcement, AnnouncementRead, App, NoteFavorite, NoteThreadMuting, NoteReaction, NoteUnread, Poll, PollVote, UserProfile, UserKeypair, UserPending, AttestationChallenge, UserSecurityKey, UserPublickey, UserList, UserListJoining, UserNotePining, UserIp, UsedUsername, Following, FollowRequest, Instance, Emoji, DriveFile, DriveFolder, Meta, Muting, RenoteMuting, Blocking, SwSubscription, Hashtag, AbuseUserReport, RegistrationTicket, AuthSession, AccessToken, Signin, Page, PageLike, GalleryPost, GalleryLike, ModerationLog, Clip, ClipNote, Antenna, PromoNote, PromoRead, Relay, MutedNote, Channel, ChannelFollowing, ChannelFavorite, RegistryItem, Webhook, Ad, PasswordResetRequest, RetentionAggregation, FlashLike, Flash, Role, RoleAssignment, ClipFavorite } from './index.js';
+import { User, Note, Announcement, AnnouncementRead, App, NoteFavorite, NoteThreadMuting, NoteReaction, NoteUnread, Poll, PollVote, UserProfile, UserKeypair, UserPending, AttestationChallenge, UserSecurityKey, UserPublickey, UserList, UserListJoining, UserNotePining, UserIp, UsedUsername, Following, FollowRequest, Instance, Emoji, DriveFile, DriveFolder, Meta, Muting, RenoteMuting, Blocking, SwSubscription, Hashtag, AbuseUserReport, RegistrationTicket, AuthSession, AccessToken, Signin, Page, PageLike, GalleryPost, GalleryLike, ModerationLog, Clip, ClipNote, Antenna, PromoNote, PromoRead, Relay, MutedNote, Channel, ChannelFollowing, ChannelFavorite, RegistryItem, Webhook, Ad, PasswordResetRequest, RetentionAggregation, FlashLike, Flash, Role, RoleAssignment, ClipFavorite, UserMemo } from './index.js';
import type { DataSource } from 'typeorm';
import type { Provider } from '@nestjs/common';
@@ -388,6 +388,12 @@ const $roleAssignmentsRepository: Provider = {
inject: [DI.db],
};
+const $userMemosRepository: Provider = {
+ provide: DI.userMemosRepository,
+ useFactory: (db: DataSource) => db.getRepository(UserMemo),
+ inject: [DI.db],
+};
+
@Module({
imports: [
],
@@ -456,6 +462,7 @@ const $roleAssignmentsRepository: Provider = {
$roleAssignmentsRepository,
$flashsRepository,
$flashLikesRepository,
+ $userMemosRepository,
],
exports: [
$usersRepository,
@@ -522,6 +529,7 @@ const $roleAssignmentsRepository: Provider = {
$roleAssignmentsRepository,
$flashsRepository,
$flashLikesRepository,
+ $userMemosRepository,
],
})
export class RepositoryModule {}
diff --git a/packages/backend/src/models/entities/Channel.ts b/packages/backend/src/models/entities/Channel.ts
index 2d346fdf9d..d7c4583da3 100644
--- a/packages/backend/src/models/entities/Channel.ts
+++ b/packages/backend/src/models/entities/Channel.ts
@@ -64,6 +64,18 @@ export class Channel {
})
public pinnedNoteIds: string[];
+ @Column('varchar', {
+ length: 16,
+ default: '#86b300',
+ })
+ public color: string;
+
+ @Index()
+ @Column('boolean', {
+ default: false,
+ })
+ public isArchived: boolean;
+
@Index()
@Column('integer', {
default: 0,
diff --git a/packages/backend/src/models/entities/Meta.ts b/packages/backend/src/models/entities/Meta.ts
index 2e4f90b57f..6d44e4edc7 100644
--- a/packages/backend/src/models/entities/Meta.ts
+++ b/packages/backend/src/models/entities/Meta.ts
@@ -405,4 +405,16 @@ export class Meta {
default: { },
})
public policies: Record<string, any>;
+
+ @Column('varchar', {
+ length: 280,
+ array: true,
+ default: '{}',
+ })
+ public serverRules: string[];
+
+ @Column('varchar', {
+ length: 1024, array: true, default: '{ "admin", "administrator", "root", "system", "maintainer", "host", "mod", "moderator", "owner", "superuser", "staff", "auth", "i", "me", "everyone", "all", "mention", "mentions", "example", "user", "users", "account", "accounts", "official", "help", "helps", "support", "supports", "info", "information", "informations", "announce", "announces", "announcement", "announcements", "notice", "notification", "notifications", "dev", "developer", "developers", "tech", "misskey" }',
+ })
+ public preservedUsernames: string[];
}
diff --git a/packages/backend/src/models/entities/Role.ts b/packages/backend/src/models/entities/Role.ts
index eca9bcf270..61f40d59da 100644
--- a/packages/backend/src/models/entities/Role.ts
+++ b/packages/backend/src/models/entities/Role.ts
@@ -154,6 +154,11 @@ export class Role {
@Column('boolean', {
default: false,
})
+ public isExplorable: boolean;
+
+ @Column('boolean', {
+ default: false,
+ })
public canEditMembersByModerator: boolean;
// UIに表示する際の並び順用(大きいほど先頭)
diff --git a/packages/backend/src/models/entities/User.ts b/packages/backend/src/models/entities/User.ts
index 04dfa21107..8e10f999b6 100644
--- a/packages/backend/src/models/entities/User.ts
+++ b/packages/backend/src/models/entities/User.ts
@@ -75,6 +75,12 @@ export class User {
})
public movedToUri: string | null;
+ @Column('timestamp with time zone', {
+ nullable: true,
+ comment: 'When the user moved to another account',
+ })
+ public movedAt: Date | null;
+
@Column('simple-array', {
nullable: true,
comment: 'URIs the user is known as too',
@@ -253,11 +259,23 @@ export type LocalUser = User & {
uri: null;
}
+export type PartialLocalUser = Partial<User> & {
+ id: User['id'];
+ host: null;
+ uri: null;
+}
+
export type RemoteUser = User & {
host: string;
uri: string;
}
+export type PartialRemoteUser = Partial<User> & {
+ id: User['id'];
+ host: string;
+ uri: string;
+}
+
export const localUsernameSchema = { type: 'string', pattern: /^\w{1,20}$/.toString().slice(1, -1) } as const;
export const passwordSchema = { type: 'string', minLength: 1 } as const;
export const nameSchema = { type: 'string', minLength: 1, maxLength: 50 } as const;
diff --git a/packages/backend/src/models/entities/UserMemo.ts b/packages/backend/src/models/entities/UserMemo.ts
new file mode 100644
index 0000000000..7dc34b4346
--- /dev/null
+++ b/packages/backend/src/models/entities/UserMemo.ts
@@ -0,0 +1,42 @@
+import { Column, Entity, Index, JoinColumn, ManyToOne, PrimaryColumn } from 'typeorm';
+import { id } from '../id.js';
+import { User } from './User.js';
+
+@Entity()
+@Index(['userId', 'targetUserId'], { unique: true })
+export class UserMemo {
+ @PrimaryColumn(id())
+ public id: string;
+
+ @Index()
+ @Column({
+ ...id(),
+ comment: 'The ID of author.',
+ })
+ public userId: User['id'];
+
+ @ManyToOne(type => User, {
+ onDelete: 'CASCADE',
+ })
+ @JoinColumn()
+ public user: User | null;
+
+ @Index()
+ @Column({
+ ...id(),
+ comment: 'The ID of target user.',
+ })
+ public targetUserId: User['id'];
+
+ @ManyToOne(type => User, {
+ onDelete: 'CASCADE',
+ })
+ @JoinColumn()
+ public targetUser: User | null;
+
+ @Column('varchar', {
+ length: 2048,
+ comment: 'Memo.',
+ })
+ public memo: string;
+}
diff --git a/packages/backend/src/models/index.ts b/packages/backend/src/models/index.ts
index 48d6e15f2a..b8ba28db9b 100644
--- a/packages/backend/src/models/index.ts
+++ b/packages/backend/src/models/index.ts
@@ -55,6 +55,7 @@ import { UserPending } from '@/models/entities/UserPending.js';
import { UserProfile } from '@/models/entities/UserProfile.js';
import { UserPublickey } from '@/models/entities/UserPublickey.js';
import { UserSecurityKey } from '@/models/entities/UserSecurityKey.js';
+import { UserMemo } from '@/models/entities/UserMemo.js';
import { Webhook } from '@/models/entities/Webhook.js';
import { Channel } from '@/models/entities/Channel.js';
import { RetentionAggregation } from '@/models/entities/RetentionAggregation.js';
@@ -129,6 +130,7 @@ export {
RoleAssignment,
Flash,
FlashLike,
+ UserMemo,
};
export type AbuseUserReportsRepository = Repository<AbuseUserReport>;
@@ -195,3 +197,4 @@ export type RolesRepository = Repository<Role>;
export type RoleAssignmentsRepository = Repository<RoleAssignment>;
export type FlashsRepository = Repository<Flash>;
export type FlashLikesRepository = Repository<FlashLike>;
+export type UserMemoRepository = Repository<UserMemo>;
diff --git a/packages/backend/src/models/json-schema/channel.ts b/packages/backend/src/models/json-schema/channel.ts
index 745b39a6ba..fd61a70c0e 100644
--- a/packages/backend/src/models/json-schema/channel.ts
+++ b/packages/backend/src/models/json-schema/channel.ts
@@ -30,6 +30,10 @@ export const packedChannelSchema = {
format: 'url',
nullable: true, optional: false,
},
+ isArchived: {
+ type: 'boolean',
+ optional: false, nullable: false,
+ },
notesCount: {
type: 'number',
nullable: false, optional: false,
@@ -59,5 +63,9 @@ export const packedChannelSchema = {
format: 'id',
},
},
+ color: {
+ type: 'string',
+ optional: false, nullable: false,
+ },
},
} as const;
diff --git a/packages/backend/src/models/json-schema/user.ts b/packages/backend/src/models/json-schema/user.ts
index 7d40979e3d..529c1303d1 100644
--- a/packages/backend/src/models/json-schema/user.ts
+++ b/packages/backend/src/models/json-schema/user.ts
@@ -80,9 +80,14 @@ export const packedUserDetailedNotMeOnlySchema = {
},
alsoKnownAs: {
type: 'array',
- format: 'uri',
nullable: true,
optional: false,
+ items: {
+ type: 'string',
+ format: 'id',
+ nullable: false,
+ optional: false,
+ },
},
createdAt: {
type: 'string',
@@ -143,6 +148,7 @@ export const packedUserDetailedNotMeOnlySchema = {
fields: {
type: 'array',
nullable: false, optional: false,
+ maxItems: 16,
items: {
type: 'object',
nullable: false, optional: false,
@@ -156,7 +162,6 @@ export const packedUserDetailedNotMeOnlySchema = {
nullable: false, optional: false,
},
},
- maxLength: 4,
},
},
followersCount: {
@@ -250,6 +255,10 @@ export const packedUserDetailedNotMeOnlySchema = {
type: 'boolean',
nullable: false, optional: true,
},
+ memo: {
+ type: 'string',
+ nullable: false, optional: true,
+ },
//#endregion
},
} as const;
diff --git a/packages/backend/src/postgres.ts b/packages/backend/src/postgres.ts
index bb21ed827e..f3d404e6c9 100644
--- a/packages/backend/src/postgres.ts
+++ b/packages/backend/src/postgres.ts
@@ -70,6 +70,7 @@ import { Role } from '@/models/entities/Role.js';
import { RoleAssignment } from '@/models/entities/RoleAssignment.js';
import { Flash } from '@/models/entities/Flash.js';
import { FlashLike } from '@/models/entities/FlashLike.js';
+import { UserMemo } from '@/models/entities/UserMemo.js';
import { Config } from '@/config.js';
import MisskeyLogger from '@/logger.js';
@@ -183,6 +184,7 @@ export const entities = [
RoleAssignment,
Flash,
FlashLike,
+ UserMemo,
...charts,
];
diff --git a/packages/backend/src/queue/DbQueueProcessorsService.ts b/packages/backend/src/queue/DbQueueProcessorsService.ts
index 233a36dd04..df8ac3a301 100644
--- a/packages/backend/src/queue/DbQueueProcessorsService.ts
+++ b/packages/backend/src/queue/DbQueueProcessorsService.ts
@@ -9,11 +9,13 @@ import { ExportFollowingProcessorService } from './processors/ExportFollowingPro
import { ExportMutingProcessorService } from './processors/ExportMutingProcessorService.js';
import { ExportBlockingProcessorService } from './processors/ExportBlockingProcessorService.js';
import { ExportUserListsProcessorService } from './processors/ExportUserListsProcessorService.js';
+import { ExportAntennasProcessorService } from './processors/ExportAntennasProcessorService.js';
import { ImportFollowingProcessorService } from './processors/ImportFollowingProcessorService.js';
import { ImportMutingProcessorService } from './processors/ImportMutingProcessorService.js';
import { ImportBlockingProcessorService } from './processors/ImportBlockingProcessorService.js';
import { ImportUserListsProcessorService } from './processors/ImportUserListsProcessorService.js';
import { ImportCustomEmojisProcessorService } from './processors/ImportCustomEmojisProcessorService.js';
+import { ImportAntennasProcessorService } from './processors/ImportAntennasProcessorService.js';
import { DeleteAccountProcessorService } from './processors/DeleteAccountProcessorService.js';
import { ExportFavoritesProcessorService } from './processors/ExportFavoritesProcessorService.js';
import type Bull from 'bull';
@@ -32,11 +34,13 @@ export class DbQueueProcessorsService {
private exportMutingProcessorService: ExportMutingProcessorService,
private exportBlockingProcessorService: ExportBlockingProcessorService,
private exportUserListsProcessorService: ExportUserListsProcessorService,
+ private exportAntennasProcessorService: ExportAntennasProcessorService,
private importFollowingProcessorService: ImportFollowingProcessorService,
private importMutingProcessorService: ImportMutingProcessorService,
private importBlockingProcessorService: ImportBlockingProcessorService,
private importUserListsProcessorService: ImportUserListsProcessorService,
private importCustomEmojisProcessorService: ImportCustomEmojisProcessorService,
+ private importAntennasProcessorService: ImportAntennasProcessorService,
private deleteAccountProcessorService: DeleteAccountProcessorService,
) {
}
@@ -51,6 +55,7 @@ export class DbQueueProcessorsService {
q.process('exportMuting', (job, done) => this.exportMutingProcessorService.process(job, done));
q.process('exportBlocking', (job, done) => this.exportBlockingProcessorService.process(job, done));
q.process('exportUserLists', (job, done) => this.exportUserListsProcessorService.process(job, done));
+ q.process('exportAntennas', (job, done) => this.exportAntennasProcessorService.process(job, done));
q.process('importFollowing', (job, done) => this.importFollowingProcessorService.process(job, done));
q.process('importFollowingToDb', (job) => this.importFollowingProcessorService.processDb(job));
q.process('importMuting', (job, done) => this.importMutingProcessorService.process(job, done));
@@ -58,6 +63,7 @@ export class DbQueueProcessorsService {
q.process('importBlockingToDb', (job) => this.importBlockingProcessorService.processDb(job));
q.process('importUserLists', (job, done) => this.importUserListsProcessorService.process(job, done));
q.process('importCustomEmojis', (job, done) => this.importCustomEmojisProcessorService.process(job, done));
+ q.process('importAntennas', (job, done) => this.importAntennasProcessorService.process(job, done));
q.process('deleteAccount', (job) => this.deleteAccountProcessorService.process(job));
}
}
diff --git a/packages/backend/src/queue/QueueProcessorModule.ts b/packages/backend/src/queue/QueueProcessorModule.ts
index 4db9b38547..3d4cc77321 100644
--- a/packages/backend/src/queue/QueueProcessorModule.ts
+++ b/packages/backend/src/queue/QueueProcessorModule.ts
@@ -24,11 +24,13 @@ import { ExportFollowingProcessorService } from './processors/ExportFollowingPro
import { ExportMutingProcessorService } from './processors/ExportMutingProcessorService.js';
import { ExportNotesProcessorService } from './processors/ExportNotesProcessorService.js';
import { ExportUserListsProcessorService } from './processors/ExportUserListsProcessorService.js';
+import { ExportAntennasProcessorService } from './processors/ExportAntennasProcessorService.js';
import { ImportBlockingProcessorService } from './processors/ImportBlockingProcessorService.js';
import { ImportCustomEmojisProcessorService } from './processors/ImportCustomEmojisProcessorService.js';
import { ImportFollowingProcessorService } from './processors/ImportFollowingProcessorService.js';
import { ImportMutingProcessorService } from './processors/ImportMutingProcessorService.js';
import { ImportUserListsProcessorService } from './processors/ImportUserListsProcessorService.js';
+import { ImportAntennasProcessorService } from './processors/ImportAntennasProcessorService.js';
import { ResyncChartsProcessorService } from './processors/ResyncChartsProcessorService.js';
import { TickChartsProcessorService } from './processors/TickChartsProcessorService.js';
import { AggregateRetentionProcessorService } from './processors/AggregateRetentionProcessorService.js';
@@ -55,11 +57,13 @@ import { RelationshipProcessorService } from './processors/RelationshipProcessor
ExportMutingProcessorService,
ExportBlockingProcessorService,
ExportUserListsProcessorService,
+ ExportAntennasProcessorService,
ImportFollowingProcessorService,
ImportMutingProcessorService,
ImportBlockingProcessorService,
ImportUserListsProcessorService,
ImportCustomEmojisProcessorService,
+ ImportAntennasProcessorService,
DeleteAccountProcessorService,
DeleteFileProcessorService,
CleanRemoteFilesProcessorService,
diff --git a/packages/backend/src/queue/RelationshipQueueProcessorsService.ts b/packages/backend/src/queue/RelationshipQueueProcessorsService.ts
index af086fa4e7..736b4fa80d 100644
--- a/packages/backend/src/queue/RelationshipQueueProcessorsService.ts
+++ b/packages/backend/src/queue/RelationshipQueueProcessorsService.ts
@@ -17,7 +17,7 @@ export class RelationshipQueueProcessorsService {
@bindThis
public start(q: Bull.Queue): void {
- const maxJobs = (this.config.deliverJobConcurrency ?? 128) / 4; // conservative?
+ const maxJobs = this.config.relashionshipJobConcurrency ?? 16;
q.process('follow', maxJobs, (job) => this.relationshipProcessorService.processFollow(job));
q.process('unfollow', maxJobs, (job) => this.relationshipProcessorService.processUnfollow(job));
q.process('block', maxJobs, (job) => this.relationshipProcessorService.processBlock(job));
diff --git a/packages/backend/src/queue/processors/DeliverProcessorService.ts b/packages/backend/src/queue/processors/DeliverProcessorService.ts
index 0e99b7bcd2..f293bd4d7e 100644
--- a/packages/backend/src/queue/processors/DeliverProcessorService.ts
+++ b/packages/backend/src/queue/processors/DeliverProcessorService.ts
@@ -79,10 +79,7 @@ export class DeliverProcessorService {
// Update stats
this.federatedInstanceService.fetch(host).then(i => {
if (i.isNotResponding) {
- this.instancesRepository.update(i.id, {
- isNotResponding: false,
- });
- this.federatedInstanceService.updateCachePartial(host, {
+ this.federatedInstanceService.update(i.id, {
isNotResponding: false,
});
}
@@ -101,10 +98,7 @@ export class DeliverProcessorService {
// Update stats
this.federatedInstanceService.fetch(host).then(i => {
if (!i.isNotResponding) {
- this.instancesRepository.update(i.id, {
- isNotResponding: true,
- });
- this.federatedInstanceService.updateCachePartial(host, {
+ this.federatedInstanceService.update(i.id, {
isNotResponding: true,
});
}
@@ -123,10 +117,7 @@ export class DeliverProcessorService {
// 相手が閉鎖していることを明示しているため、配送停止する
if (job.data.isSharedInbox && res.statusCode === 410) {
this.federatedInstanceService.fetch(host).then(i => {
- this.instancesRepository.update(i.id, {
- isSuspended: true,
- });
- this.federatedInstanceService.updateCachePartial(host, {
+ this.federatedInstanceService.update(i.id, {
isSuspended: true,
});
});
diff --git a/packages/backend/src/queue/processors/ExportAntennasProcessorService.ts b/packages/backend/src/queue/processors/ExportAntennasProcessorService.ts
new file mode 100644
index 0000000000..894903e79b
--- /dev/null
+++ b/packages/backend/src/queue/processors/ExportAntennasProcessorService.ts
@@ -0,0 +1,103 @@
+import fs from 'node:fs';
+import { Inject, Injectable } from '@nestjs/common';
+import { format as DateFormat } from 'date-fns';
+import { In } from 'typeorm';
+import { DI } from '@/di-symbols.js';
+import type { AntennasRepository, UsersRepository, UserListJoiningsRepository, User } from '@/models/index.js';
+import type { Config } from '@/config.js';
+import Logger from '@/logger.js';
+import { DriveService } from '@/core/DriveService.js';
+import { bindThis } from '@/decorators.js';
+import { createTemp } from '@/misc/create-temp.js';
+import { UtilityService } from '@/core/UtilityService.js';
+import { QueueLoggerService } from '../QueueLoggerService.js';
+import type { DBExportAntennasData } from '../types.js';
+import type Bull from 'bull';
+
+@Injectable()
+export class ExportAntennasProcessorService {
+ private logger: Logger;
+
+ constructor (
+ @Inject(DI.config)
+ private config: Config,
+
+ @Inject(DI.usersRepository)
+ private usersRepository: UsersRepository,
+
+ @Inject(DI.antennasRepository)
+ private antennsRepository: AntennasRepository,
+
+ @Inject(DI.userListJoiningsRepository)
+ private userListJoiningsRepository: UserListJoiningsRepository,
+
+ private driveService: DriveService,
+ private utilityService: UtilityService,
+ private queueLoggerService: QueueLoggerService,
+ ) {
+ this.logger = this.queueLoggerService.logger.createSubLogger('export-antennas');
+ }
+
+ @bindThis
+ public async process(job: Bull.Job<DBExportAntennasData>, done: () => void): Promise<void> {
+ const user = await this.usersRepository.findOneBy({ id: job.data.user.id });
+ if (user == null) {
+ done();
+ return;
+ }
+ const [path, cleanup] = await createTemp();
+ const stream = fs.createWriteStream(path, { flags: 'a' });
+ const write = (input: string): Promise<void> => {
+ return new Promise((resolve, reject) => {
+ stream.write(input, err => {
+ if (err) {
+ this.logger.error(err);
+ reject();
+ } else {
+ resolve();
+ }
+ });
+ });
+ };
+ try {
+ const antennas = await this.antennsRepository.findBy({ userId: job.data.user.id });
+ write('[');
+ for (const [index, antenna] of antennas.entries()) {
+ let users: User[] | undefined;
+ if (antenna.userListId !== null) {
+ const joinings = await this.userListJoiningsRepository.findBy({ userListId: antenna.userListId });
+ users = await this.usersRepository.findBy({
+ id: In(joinings.map(j => j.userId)),
+ });
+ }
+ write(JSON.stringify({
+ name: antenna.name,
+ src: antenna.src,
+ keywords: antenna.keywords,
+ excludeKeywords: antenna.excludeKeywords,
+ users: antenna.users,
+ userListAccts: typeof users !== 'undefined' ? users.map((u) => {
+ return this.utilityService.getFullApAccount(u.username, u.host); // acct
+ }) : null,
+ caseSensitive: antenna.caseSensitive,
+ withReplies: antenna.withReplies,
+ withFile: antenna.withFile,
+ notify: antenna.notify,
+ }));
+ if (antennas.length - 1 !== index) {
+ write(', ');
+ }
+ }
+ write(']');
+ stream.end();
+
+ const fileName = 'antennas-' + DateFormat(new Date(), 'yyyy-MM-dd-HH-mm-ss') + '.json';
+ const driveFile = await this.driveService.addFile({ user, path, name: fileName, force: true, ext: 'json' });
+ this.logger.succ('Exported to: ' + driveFile.id);
+ } finally {
+ cleanup();
+ done();
+ }
+ }
+}
+
diff --git a/packages/backend/src/queue/processors/ExportBlockingProcessorService.ts b/packages/backend/src/queue/processors/ExportBlockingProcessorService.ts
index a020006732..c7b54070d6 100644
--- a/packages/backend/src/queue/processors/ExportBlockingProcessorService.ts
+++ b/packages/backend/src/queue/processors/ExportBlockingProcessorService.ts
@@ -106,7 +106,7 @@ export class ExportBlockingProcessorService {
this.logger.succ(`Exported to: ${path}`);
const fileName = 'blocking-' + dateFormat(new Date(), 'yyyy-MM-dd-HH-mm-ss') + '.csv';
- const driveFile = await this.driveService.addFile({ user, path, name: fileName, force: true });
+ const driveFile = await this.driveService.addFile({ user, path, name: fileName, force: true, ext: 'csv' });
this.logger.succ(`Exported to: ${driveFile.id}`);
} finally {
diff --git a/packages/backend/src/queue/processors/ExportFavoritesProcessorService.ts b/packages/backend/src/queue/processors/ExportFavoritesProcessorService.ts
index daefcdf2f5..f2f2383a88 100644
--- a/packages/backend/src/queue/processors/ExportFavoritesProcessorService.ts
+++ b/packages/backend/src/queue/processors/ExportFavoritesProcessorService.ts
@@ -121,7 +121,7 @@ export class ExportFavoritesProcessorService {
this.logger.succ(`Exported to: ${path}`);
const fileName = 'favorites-' + dateFormat(new Date(), 'yyyy-MM-dd-HH-mm-ss') + '.json';
- const driveFile = await this.driveService.addFile({ user, path, name: fileName, force: true });
+ const driveFile = await this.driveService.addFile({ user, path, name: fileName, force: true, ext: 'json' });
this.logger.succ(`Exported to: ${driveFile.id}`);
} finally {
diff --git a/packages/backend/src/queue/processors/ExportFollowingProcessorService.ts b/packages/backend/src/queue/processors/ExportFollowingProcessorService.ts
index 59443de57f..fa9c1ac1ea 100644
--- a/packages/backend/src/queue/processors/ExportFollowingProcessorService.ts
+++ b/packages/backend/src/queue/processors/ExportFollowingProcessorService.ts
@@ -110,7 +110,7 @@ export class ExportFollowingProcessorService {
this.logger.succ(`Exported to: ${path}`);
const fileName = 'following-' + dateFormat(new Date(), 'yyyy-MM-dd-HH-mm-ss') + '.csv';
- const driveFile = await this.driveService.addFile({ user, path, name: fileName, force: true });
+ const driveFile = await this.driveService.addFile({ user, path, name: fileName, force: true, ext: 'csv' });
this.logger.succ(`Exported to: ${driveFile.id}`);
} finally {
diff --git a/packages/backend/src/queue/processors/ExportMutingProcessorService.ts b/packages/backend/src/queue/processors/ExportMutingProcessorService.ts
index a2a718b892..b14bf5f5b1 100644
--- a/packages/backend/src/queue/processors/ExportMutingProcessorService.ts
+++ b/packages/backend/src/queue/processors/ExportMutingProcessorService.ts
@@ -110,7 +110,7 @@ export class ExportMutingProcessorService {
this.logger.succ(`Exported to: ${path}`);
const fileName = 'mute-' + dateFormat(new Date(), 'yyyy-MM-dd-HH-mm-ss') + '.csv';
- const driveFile = await this.driveService.addFile({ user, path, name: fileName, force: true });
+ const driveFile = await this.driveService.addFile({ user, path, name: fileName, force: true, ext: 'csv' });
this.logger.succ(`Exported to: ${driveFile.id}`);
} finally {
diff --git a/packages/backend/src/queue/processors/ExportNotesProcessorService.ts b/packages/backend/src/queue/processors/ExportNotesProcessorService.ts
index 1aa20d6f1d..e4f12ad101 100644
--- a/packages/backend/src/queue/processors/ExportNotesProcessorService.ts
+++ b/packages/backend/src/queue/processors/ExportNotesProcessorService.ts
@@ -117,7 +117,7 @@ export class ExportNotesProcessorService {
this.logger.succ(`Exported to: ${path}`);
const fileName = 'notes-' + dateFormat(new Date(), 'yyyy-MM-dd-HH-mm-ss') + '.json';
- const driveFile = await this.driveService.addFile({ user, path, name: fileName, force: true });
+ const driveFile = await this.driveService.addFile({ user, path, name: fileName, force: true, ext: 'json' });
this.logger.succ(`Exported to: ${driveFile.id}`);
} finally {
diff --git a/packages/backend/src/queue/processors/ExportUserListsProcessorService.ts b/packages/backend/src/queue/processors/ExportUserListsProcessorService.ts
index ce8ed2f5e8..54bde44044 100644
--- a/packages/backend/src/queue/processors/ExportUserListsProcessorService.ts
+++ b/packages/backend/src/queue/processors/ExportUserListsProcessorService.ts
@@ -86,7 +86,7 @@ export class ExportUserListsProcessorService {
this.logger.succ(`Exported to: ${path}`);
const fileName = 'user-lists-' + dateFormat(new Date(), 'yyyy-MM-dd-HH-mm-ss') + '.csv';
- const driveFile = await this.driveService.addFile({ user, path, name: fileName, force: true });
+ const driveFile = await this.driveService.addFile({ user, path, name: fileName, force: true, ext: 'csv' });
this.logger.succ(`Exported to: ${driveFile.id}`);
} finally {
diff --git a/packages/backend/src/queue/processors/ImportAntennasProcessorService.ts b/packages/backend/src/queue/processors/ImportAntennasProcessorService.ts
new file mode 100644
index 0000000000..d06131b8c8
--- /dev/null
+++ b/packages/backend/src/queue/processors/ImportAntennasProcessorService.ts
@@ -0,0 +1,96 @@
+import { Injectable, Inject } from '@nestjs/common';
+import Ajv from 'ajv';
+import { IdService } from '@/core/IdService.js';
+import { GlobalEventService } from '@/core/GlobalEventService.js';
+import Logger from '@/logger.js';
+import type { AntennasRepository } from '@/models/index.js';
+import { DI } from '@/di-symbols.js';
+import { bindThis } from '@/decorators.js';
+import { QueueLoggerService } from '../QueueLoggerService.js';
+import { DBAntennaImportJobData } from '../types.js';
+import type Bull from 'bull';
+
+const validate = new Ajv().compile({
+ type: 'object',
+ properties: {
+ name: { type: 'string', minLength: 1, maxLength: 100 },
+ src: { type: 'string', enum: ['home', 'all', 'users', 'list'] },
+ userListAccts: {
+ type: 'array',
+ items: {
+ type: 'string',
+ },
+ nullable: true,
+ },
+ keywords: { type: 'array', items: {
+ type: 'array', items: {
+ type: 'string',
+ },
+ } },
+ excludeKeywords: { type: 'array', items: {
+ type: 'array', items: {
+ type: 'string',
+ },
+ } },
+ users: { type: 'array', items: {
+ type: 'string',
+ } },
+ caseSensitive: { type: 'boolean' },
+ withReplies: { type: 'boolean' },
+ withFile: { type: 'boolean' },
+ notify: { type: 'boolean' },
+ },
+ required: ['name', 'src', 'keywords', 'excludeKeywords', 'users', 'caseSensitive', 'withReplies', 'withFile', 'notify'],
+});
+
+@Injectable()
+export class ImportAntennasProcessorService {
+ private logger: Logger;
+
+ constructor (
+ @Inject(DI.antennasRepository)
+ private antennasRepository: AntennasRepository,
+
+ private queueLoggerService: QueueLoggerService,
+ private idService: IdService,
+ private globalEventService: GlobalEventService,
+ ) {
+ this.logger = this.queueLoggerService.logger.createSubLogger('import-antennas');
+ }
+
+ @bindThis
+ public async process(job: Bull.Job<DBAntennaImportJobData>, done: () => void): Promise<void> {
+ const now = new Date();
+ try {
+ for (const antenna of job.data.antenna) {
+ if (antenna.keywords.length === 0 || antenna.keywords[0].every(x => x === '')) continue;
+ if (!validate(antenna)) {
+ this.logger.warn('Validation Failed');
+ continue;
+ }
+ const result = await this.antennasRepository.insert({
+ id: this.idService.genId(),
+ createdAt: now,
+ lastUsedAt: now,
+ userId: job.data.user.id,
+ name: antenna.name,
+ src: antenna.src === 'list' && antenna.userListAccts ? 'users' : antenna.src,
+ userListId: null,
+ keywords: antenna.keywords,
+ excludeKeywords: antenna.excludeKeywords,
+ users: (antenna.src === 'list' && antenna.userListAccts !== null ? antenna.userListAccts : antenna.users).filter(Boolean),
+ caseSensitive: antenna.caseSensitive,
+ withReplies: antenna.withReplies,
+ withFile: antenna.withFile,
+ notify: antenna.notify,
+ }).then(x => this.antennasRepository.findOneByOrFail(x.identifiers[0]));
+ this.logger.succ('Antenna created: ' + result.id);
+ this.globalEventService.publishInternalEvent('antennaCreated', result);
+ }
+ } catch (err: any) {
+ this.logger.error(err);
+ } finally {
+ done();
+ }
+ }
+}
diff --git a/packages/backend/src/queue/processors/InboxProcessorService.ts b/packages/backend/src/queue/processors/InboxProcessorService.ts
index ed7f38d013..ab8b1e9e22 100644
--- a/packages/backend/src/queue/processors/InboxProcessorService.ts
+++ b/packages/backend/src/queue/processors/InboxProcessorService.ts
@@ -84,9 +84,9 @@ export class InboxProcessorService {
// HTTP-Signature keyIdを元にDBから取得
let authUser: {
- user: RemoteUser;
- key: UserPublickey | null;
- } | null = await this.apDbResolverService.getAuthUserFromKeyId(signature.keyId);
+ user: RemoteUser;
+ key: UserPublickey | null;
+ } | null = await this.apDbResolverService.getAuthUserFromKeyId(signature.keyId);
// keyIdでわからなければ、activity.actorを元にDBから取得 || activity.actorを元にリモートから取得
if (authUser == null) {
@@ -174,13 +174,10 @@ export class InboxProcessorService {
// Update stats
this.federatedInstanceService.fetch(authUser.user.host).then(i => {
- this.instancesRepository.update(i.id, {
+ this.federatedInstanceService.update(i.id, {
latestRequestReceivedAt: new Date(),
isNotResponding: false,
});
- this.federatedInstanceService.updateCachePartial(host, {
- isNotResponding: false,
- });
this.fetchInstanceMetadataService.fetchInstanceMetadata(i);
diff --git a/packages/backend/src/queue/processors/RelationshipProcessorService.ts b/packages/backend/src/queue/processors/RelationshipProcessorService.ts
index a5006dcf03..ff454df455 100644
--- a/packages/backend/src/queue/processors/RelationshipProcessorService.ts
+++ b/packages/backend/src/queue/processors/RelationshipProcessorService.ts
@@ -10,6 +10,7 @@ import { QueueLoggerService } from '../QueueLoggerService.js';
import { RelationshipJobData } from '../types.js';
import type { UsersRepository } from '@/models/index.js';
import { DI } from '@/di-symbols.js';
+import { LocalUser, RemoteUser } from '@/models/entities/User.js';
@Injectable()
export class RelationshipProcessorService {
@@ -39,7 +40,7 @@ export class RelationshipProcessorService {
const [follower, followee] = await Promise.all([
this.usersRepository.findOneByOrFail({ id: job.data.from.id }),
this.usersRepository.findOneByOrFail({ id: job.data.to.id }),
- ]);
+ ]) as [LocalUser | RemoteUser, LocalUser | RemoteUser];
await this.userFollowingService.unfollow(follower, followee, job.data.silent);
return 'ok';
}
diff --git a/packages/backend/src/queue/types.ts b/packages/backend/src/queue/types.ts
index 23c973d449..776dd3aa12 100644
--- a/packages/backend/src/queue/types.ts
+++ b/packages/backend/src/queue/types.ts
@@ -1,3 +1,4 @@
+import type { Antenna } from '@/server/api/endpoints/i/import-antennas.js';
import type { DriveFile } from '@/models/entities/DriveFile.js';
import type { Note } from '@/models/entities/Note.js';
import type { User } from '@/models/entities/User.js';
@@ -33,12 +34,14 @@ export type DbJobData<T extends keyof DbJobMap> = DbJobMap[T];
export type DbJobMap = {
deleteDriveFiles: DbJobDataWithUser;
exportCustomEmojis: DbJobDataWithUser;
+ exportAntennas: DBExportAntennasData;
exportNotes: DbJobDataWithUser;
exportFavorites: DbJobDataWithUser;
exportFollowing: DbExportFollowingData;
exportMuting: DbJobDataWithUser;
exportBlocking: DbJobDataWithUser;
exportUserLists: DbJobDataWithUser;
+ importAntennas: DBAntennaImportJobData;
importFollowing: DbUserImportJobData;
importFollowingToDb: DbUserImportToDbJobData;
importMuting: DbUserImportJobData;
@@ -59,6 +62,10 @@ export type DbExportFollowingData = {
excludeInactive: boolean;
};
+export type DBExportAntennasData = {
+ user: ThinUser
+}
+
export type DbUserDeleteJobData = {
user: ThinUser;
soft?: boolean;
@@ -69,6 +76,11 @@ export type DbUserImportJobData = {
fileId: DriveFile['id'];
};
+export type DBAntennaImportJobData = {
+ user: ThinUser,
+ antenna: Antenna
+}
+
export type DbUserImportToDbJobData = {
user: ThinUser;
target: string;
diff --git a/packages/backend/src/server/ActivityPubServerService.ts b/packages/backend/src/server/ActivityPubServerService.ts
index e13e9265ab..e675d9cf1b 100644
--- a/packages/backend/src/server/ActivityPubServerService.ts
+++ b/packages/backend/src/server/ActivityPubServerService.ts
@@ -11,7 +11,7 @@ import * as url from '@/misc/prelude/url.js';
import type { Config } from '@/config.js';
import { ApRendererService } from '@/core/activitypub/ApRendererService.js';
import { QueueService } from '@/core/QueueService.js';
-import type { LocalUser, User } from '@/models/entities/User.js';
+import type { LocalUser, RemoteUser, User } from '@/models/entities/User.js';
import { UserKeypairService } from '@/core/UserKeypairService.js';
import type { Following } from '@/models/entities/Following.js';
import { countIf } from '@/misc/prelude/array.js';
@@ -630,7 +630,7 @@ export class ActivityPubServerService {
id: request.params.followee,
host: Not(IsNull()),
}),
- ]);
+ ]) as [LocalUser | RemoteUser | null, LocalUser | RemoteUser | null];
if (follower == null || followee == null) {
reply.code(404);
@@ -665,7 +665,7 @@ export class ActivityPubServerService {
id: followRequest.followeeId,
host: Not(IsNull()),
}),
- ]);
+ ]) as [LocalUser | RemoteUser | null, LocalUser | RemoteUser | null];
if (follower == null || followee == null) {
reply.code(404);
diff --git a/packages/backend/src/server/FileServerService.ts b/packages/backend/src/server/FileServerService.ts
index 794fa76d9e..98329ddffa 100644
--- a/packages/backend/src/server/FileServerService.ts
+++ b/packages/backend/src/server/FileServerService.ts
@@ -297,7 +297,8 @@ export class FileServerService {
} else if ('badge' in request.query) {
const mask = (await sharpBmp(file.path, file.mime))
.resize(96, 96, {
- fit: 'inside',
+ fit: 'contain',
+ position: 'centre',
withoutEnlargement: false,
})
.greyscale()
@@ -453,7 +454,8 @@ export class FileServerService {
fileRole: 'original',
file,
filename: file.name,
- mime: file.type,
+ // 古いファイルは修正前のmimeを持っているのでできるだけ修正してあげる
+ mime: this.fileInfoService.fixMime(file.type),
ext: null,
path,
};
diff --git a/packages/backend/src/server/WellKnownServerService.ts b/packages/backend/src/server/WellKnownServerService.ts
index e722563036..9bf8deb221 100644
--- a/packages/backend/src/server/WellKnownServerService.ts
+++ b/packages/backend/src/server/WellKnownServerService.ts
@@ -8,6 +8,7 @@ import { escapeAttribute, escapeValue } from '@/misc/prelude/xml.js';
import type { User } from '@/models/entities/User.js';
import * as Acct from '@/misc/acct.js';
import { NodeinfoServerService } from './NodeinfoServerService.js';
+import { UserEntityService } from '@/core/entities/UserEntityService.js';
import type { FindOptionsWhere } from 'typeorm';
import { bindThis } from '@/decorators.js';
import type { FastifyInstance, FastifyPluginOptions } from 'fastify';
@@ -23,6 +24,7 @@ export class WellKnownServerService {
private usersRepository: UsersRepository,
private nodeinfoServerService: NodeinfoServerService,
+ private userEntityService: UserEntityService,
) {
//this.createServer = this.createServer.bind(this);
}
@@ -130,7 +132,7 @@ fastify.get('/.well-known/change-password', async (request, reply) => {
const self = {
rel: 'self',
type: 'application/activity+json',
- href: `${this.config.url}/users/${user.id}`,
+ href: this.userEntityService.genLocalUserUri(user.id),
};
const profilePage = {
rel: 'http://webfinger.net/rel/profile-page',
diff --git a/packages/backend/src/server/api/ApiCallService.ts b/packages/backend/src/server/api/ApiCallService.ts
index bf5cb20918..e3483c82c6 100644
--- a/packages/backend/src/server/api/ApiCallService.ts
+++ b/packages/backend/src/server/api/ApiCallService.ts
@@ -261,6 +261,17 @@ export class ApiCallService implements OnApplicationShutdown {
}
}
+ if (ep.meta.prohibitMoved) {
+ if (user?.movedToUri) {
+ throw new ApiError({
+ message: 'You have moved your account.',
+ code: 'YOUR_ACCOUNT_MOVED',
+ id: '56f20ec9-fd06-4fa5-841b-edd6d7d4fa31',
+ httpStatusCode: 403,
+ });
+ }
+ }
+
if ((ep.meta.requireModerator || ep.meta.requireAdmin) && !user!.isRoot) {
const myRoles = await this.roleService.getUserRoles(user!.id);
if (ep.meta.requireModerator && !myRoles.some(r => r.isModerator || r.isAdministrator)) {
diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts
index 689f90287e..ee1aae5b6c 100644
--- a/packages/backend/src/server/api/EndpointsModule.ts
+++ b/packages/backend/src/server/api/EndpointsModule.ts
@@ -29,6 +29,7 @@ import * as ep___admin_emoji_list from './endpoints/admin/emoji/list.js';
import * as ep___admin_emoji_removeAliasesBulk from './endpoints/admin/emoji/remove-aliases-bulk.js';
import * as ep___admin_emoji_setAliasesBulk from './endpoints/admin/emoji/set-aliases-bulk.js';
import * as ep___admin_emoji_setCategoryBulk from './endpoints/admin/emoji/set-category-bulk.js';
+import * as ep___admin_emoji_setLicenseBulk from './endpoints/admin/emoji/set-license-bulk.js';
import * as ep___admin_emoji_update from './endpoints/admin/emoji/update.js';
import * as ep___admin_federation_deleteAllFiles from './endpoints/admin/federation/delete-all-files.js';
import * as ep___admin_federation_refreshRemoteInstanceMetadata from './endpoints/admin/federation/refresh-remote-instance-metadata.js';
@@ -193,6 +194,7 @@ import * as ep___i_exportMute from './endpoints/i/export-mute.js';
import * as ep___i_exportNotes from './endpoints/i/export-notes.js';
import * as ep___i_exportFavorites from './endpoints/i/export-favorites.js';
import * as ep___i_exportUserLists from './endpoints/i/export-user-lists.js';
+import * as ep___i_exportAntennas from './endpoints/i/export-antennas.js';
import * as ep___i_favorites from './endpoints/i/favorites.js';
import * as ep___i_gallery_likes from './endpoints/i/gallery/likes.js';
import * as ep___i_gallery_posts from './endpoints/i/gallery/posts.js';
@@ -201,6 +203,7 @@ import * as ep___i_importBlocking from './endpoints/i/import-blocking.js';
import * as ep___i_importFollowing from './endpoints/i/import-following.js';
import * as ep___i_importMuting from './endpoints/i/import-muting.js';
import * as ep___i_importUserLists from './endpoints/i/import-user-lists.js';
+import * as ep___i_importAntennas from './endpoints/i/import-antennas.js';
import * as ep___i_notifications from './endpoints/i/notifications.js';
import * as ep___i_pageLikes from './endpoints/i/page-likes.js';
import * as ep___i_pages from './endpoints/i/pages.js';
@@ -222,7 +225,6 @@ import * as ep___i_unpin from './endpoints/i/unpin.js';
import * as ep___i_updateEmail from './endpoints/i/update-email.js';
import * as ep___i_update from './endpoints/i/update.js';
import * as ep___i_move from './endpoints/i/move.js';
-import * as ep___i_knownAs from './endpoints/i/known-as.js';
import * as ep___i_webhooks_create from './endpoints/i/webhooks/create.js';
import * as ep___i_webhooks_show from './endpoints/i/webhooks/show.js';
import * as ep___i_webhooks_list from './endpoints/i/webhooks/list.js';
@@ -330,6 +332,7 @@ import * as ep___users_search from './endpoints/users/search.js';
import * as ep___users_show from './endpoints/users/show.js';
import * as ep___users_stats from './endpoints/users/stats.js';
import * as ep___users_achievements from './endpoints/users/achievements.js';
+import * as ep___users_updateMemo from './endpoints/users/update-memo.js';
import * as ep___fetchRss from './endpoints/fetch-rss.js';
import * as ep___retention from './endpoints/retention.js';
import { GetterService } from './GetterService.js';
@@ -364,6 +367,7 @@ const $admin_emoji_list: Provider = { provide: 'ep:admin/emoji/list', useClass:
const $admin_emoji_removeAliasesBulk: Provider = { provide: 'ep:admin/emoji/remove-aliases-bulk', useClass: ep___admin_emoji_removeAliasesBulk.default };
const $admin_emoji_setAliasesBulk: Provider = { provide: 'ep:admin/emoji/set-aliases-bulk', useClass: ep___admin_emoji_setAliasesBulk.default };
const $admin_emoji_setCategoryBulk: Provider = { provide: 'ep:admin/emoji/set-category-bulk', useClass: ep___admin_emoji_setCategoryBulk.default };
+const $admin_emoji_setLicenseBulk: Provider = { provide: 'ep:admin/emoji/set-license-bulk', useClass: ep___admin_emoji_setLicenseBulk.default };
const $admin_emoji_update: Provider = { provide: 'ep:admin/emoji/update', useClass: ep___admin_emoji_update.default };
const $admin_federation_deleteAllFiles: Provider = { provide: 'ep:admin/federation/delete-all-files', useClass: ep___admin_federation_deleteAllFiles.default };
const $admin_federation_refreshRemoteInstanceMetadata: Provider = { provide: 'ep:admin/federation/refresh-remote-instance-metadata', useClass: ep___admin_federation_refreshRemoteInstanceMetadata.default };
@@ -528,6 +532,7 @@ const $i_exportMute: Provider = { provide: 'ep:i/export-mute', useClass: ep___i_
const $i_exportNotes: Provider = { provide: 'ep:i/export-notes', useClass: ep___i_exportNotes.default };
const $i_exportFavorites: Provider = { provide: 'ep:i/export-favorites', useClass: ep___i_exportFavorites.default };
const $i_exportUserLists: Provider = { provide: 'ep:i/export-user-lists', useClass: ep___i_exportUserLists.default };
+const $i_exportAntennas: Provider = { provide: 'ep:i/export-antennas', useClass: ep___i_exportAntennas.default };
const $i_favorites: Provider = { provide: 'ep:i/favorites', useClass: ep___i_favorites.default };
const $i_gallery_likes: Provider = { provide: 'ep:i/gallery/likes', useClass: ep___i_gallery_likes.default };
const $i_gallery_posts: Provider = { provide: 'ep:i/gallery/posts', useClass: ep___i_gallery_posts.default };
@@ -536,6 +541,7 @@ const $i_importBlocking: Provider = { provide: 'ep:i/import-blocking', useClass:
const $i_importFollowing: Provider = { provide: 'ep:i/import-following', useClass: ep___i_importFollowing.default };
const $i_importMuting: Provider = { provide: 'ep:i/import-muting', useClass: ep___i_importMuting.default };
const $i_importUserLists: Provider = { provide: 'ep:i/import-user-lists', useClass: ep___i_importUserLists.default };
+const $i_importAntennas: Provider = { provide: 'ep:i/import-antennas', useClass: ep___i_importAntennas.default };
const $i_notifications: Provider = { provide: 'ep:i/notifications', useClass: ep___i_notifications.default };
const $i_pageLikes: Provider = { provide: 'ep:i/page-likes', useClass: ep___i_pageLikes.default };
const $i_pages: Provider = { provide: 'ep:i/pages', useClass: ep___i_pages.default };
@@ -557,7 +563,6 @@ const $i_unpin: Provider = { provide: 'ep:i/unpin', useClass: ep___i_unpin.defau
const $i_updateEmail: Provider = { provide: 'ep:i/update-email', useClass: ep___i_updateEmail.default };
const $i_update: Provider = { provide: 'ep:i/update', useClass: ep___i_update.default };
const $i_move: Provider = { provide: 'ep:i/move', useClass: ep___i_move.default };
-const $i_knownAs: Provider = { provide: 'ep:i/known-as', useClass: ep___i_knownAs.default };
const $i_webhooks_create: Provider = { provide: 'ep:i/webhooks/create', useClass: ep___i_webhooks_create.default };
const $i_webhooks_list: Provider = { provide: 'ep:i/webhooks/list', useClass: ep___i_webhooks_list.default };
const $i_webhooks_show: Provider = { provide: 'ep:i/webhooks/show', useClass: ep___i_webhooks_show.default };
@@ -665,6 +670,7 @@ const $users_search: Provider = { provide: 'ep:users/search', useClass: ep___use
const $users_show: Provider = { provide: 'ep:users/show', useClass: ep___users_show.default };
const $users_stats: Provider = { provide: 'ep:users/stats', useClass: ep___users_stats.default };
const $users_achievements: Provider = { provide: 'ep:users/achievements', useClass: ep___users_achievements.default };
+const $users_updateMemo: Provider = { provide: 'ep:users/update-memo', useClass: ep___users_updateMemo.default };
const $fetchRss: Provider = { provide: 'ep:fetch-rss', useClass: ep___fetchRss.default };
const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention.default };
@@ -703,6 +709,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$admin_emoji_removeAliasesBulk,
$admin_emoji_setAliasesBulk,
$admin_emoji_setCategoryBulk,
+ $admin_emoji_setLicenseBulk,
$admin_emoji_update,
$admin_federation_deleteAllFiles,
$admin_federation_refreshRemoteInstanceMetadata,
@@ -867,6 +874,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$i_exportNotes,
$i_exportFavorites,
$i_exportUserLists,
+ $i_exportAntennas,
$i_favorites,
$i_gallery_likes,
$i_gallery_posts,
@@ -875,6 +883,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$i_importFollowing,
$i_importMuting,
$i_importUserLists,
+ $i_importAntennas,
$i_notifications,
$i_pageLikes,
$i_pages,
@@ -896,7 +905,6 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$i_updateEmail,
$i_update,
$i_move,
- $i_knownAs,
$i_webhooks_create,
$i_webhooks_list,
$i_webhooks_show,
@@ -1004,6 +1012,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$users_show,
$users_stats,
$users_achievements,
+ $users_updateMemo,
$fetchRss,
$retention,
],
@@ -1036,6 +1045,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$admin_emoji_removeAliasesBulk,
$admin_emoji_setAliasesBulk,
$admin_emoji_setCategoryBulk,
+ $admin_emoji_setLicenseBulk,
$admin_emoji_update,
$admin_federation_deleteAllFiles,
$admin_federation_refreshRemoteInstanceMetadata,
@@ -1200,6 +1210,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$i_exportNotes,
$i_exportFavorites,
$i_exportUserLists,
+ $i_exportAntennas,
$i_favorites,
$i_gallery_likes,
$i_gallery_posts,
@@ -1208,6 +1219,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$i_importFollowing,
$i_importMuting,
$i_importUserLists,
+ $i_importAntennas,
$i_notifications,
$i_pageLikes,
$i_pages,
@@ -1229,7 +1241,6 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$i_updateEmail,
$i_update,
$i_move,
- $i_knownAs,
$i_webhooks_create,
$i_webhooks_list,
$i_webhooks_show,
@@ -1335,6 +1346,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$users_show,
$users_stats,
$users_achievements,
+ $users_updateMemo,
$fetchRss,
$retention,
],
diff --git a/packages/backend/src/server/api/RateLimiterService.ts b/packages/backend/src/server/api/RateLimiterService.ts
index 1f8915ecca..fe2db1d66a 100644
--- a/packages/backend/src/server/api/RateLimiterService.ts
+++ b/packages/backend/src/server/api/RateLimiterService.ts
@@ -1,6 +1,6 @@
import { Inject, Injectable } from '@nestjs/common';
import Limiter from 'ratelimiter';
-import Redis from 'ioredis';
+import * as Redis from 'ioredis';
import { DI } from '@/di-symbols.js';
import type Logger from '@/logger.js';
import { LoggerService } from '@/core/LoggerService.js';
diff --git a/packages/backend/src/server/api/SignupApiService.ts b/packages/backend/src/server/api/SignupApiService.ts
index fbabf47aff..b2bd7d82e7 100644
--- a/packages/backend/src/server/api/SignupApiService.ts
+++ b/packages/backend/src/server/api/SignupApiService.ts
@@ -1,6 +1,7 @@
import { Inject, Injectable } from '@nestjs/common';
import rndstr from 'rndstr';
import bcrypt from 'bcryptjs';
+import { IsNull } from 'typeorm';
import { DI } from '@/di-symbols.js';
import type { RegistrationTicketsRepository, UsedUsernamesRepository, UserPendingsRepository, UserProfilesRepository, UsersRepository } from '@/models/index.js';
import type { Config } from '@/config.js';
@@ -15,7 +16,6 @@ import { FastifyReplyError } from '@/misc/fastify-reply-error.js';
import { bindThis } from '@/decorators.js';
import { SigninService } from './SigninService.js';
import type { FastifyRequest, FastifyReply } from 'fastify';
-import { IsNull } from 'typeorm';
@Injectable()
export class SignupApiService {
@@ -137,6 +137,11 @@ export class SignupApiService {
throw new FastifyReplyError(400, 'USED_USERNAME');
}
+ const isPreserved = instance.preservedUsernames.map(x => x.toLowerCase()).includes(username.toLowerCase());
+ if (isPreserved) {
+ throw new FastifyReplyError(400, 'DENIED_USERNAME');
+ }
+
const code = rndstr('a-z0-9', 16);
// Generate hash of password
diff --git a/packages/backend/src/server/api/StreamingApiServerService.ts b/packages/backend/src/server/api/StreamingApiServerService.ts
index 769a4490d6..258e8de034 100644
--- a/packages/backend/src/server/api/StreamingApiServerService.ts
+++ b/packages/backend/src/server/api/StreamingApiServerService.ts
@@ -1,6 +1,6 @@
import { EventEmitter } from 'events';
import { Inject, Injectable } from '@nestjs/common';
-import Redis from 'ioredis';
+import * as Redis from 'ioredis';
import * as websocket from 'websocket';
import { DI } from '@/di-symbols.js';
import type { UsersRepository, BlockingsRepository, ChannelFollowingsRepository, FollowingsRepository, MutingsRepository, UserProfilesRepository, RenoteMutingsRepository } from '@/models/index.js';
diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts
index d0fe6a57c1..09bd7cbff4 100644
--- a/packages/backend/src/server/api/endpoints.ts
+++ b/packages/backend/src/server/api/endpoints.ts
@@ -29,6 +29,7 @@ import * as ep___admin_emoji_list from './endpoints/admin/emoji/list.js';
import * as ep___admin_emoji_removeAliasesBulk from './endpoints/admin/emoji/remove-aliases-bulk.js';
import * as ep___admin_emoji_setAliasesBulk from './endpoints/admin/emoji/set-aliases-bulk.js';
import * as ep___admin_emoji_setCategoryBulk from './endpoints/admin/emoji/set-category-bulk.js';
+import * as ep___admin_emoji_setLicenseBulk from './endpoints/admin/emoji/set-license-bulk.js';
import * as ep___admin_emoji_update from './endpoints/admin/emoji/update.js';
import * as ep___admin_federation_deleteAllFiles from './endpoints/admin/federation/delete-all-files.js';
import * as ep___admin_federation_refreshRemoteInstanceMetadata from './endpoints/admin/federation/refresh-remote-instance-metadata.js';
@@ -193,6 +194,7 @@ import * as ep___i_exportMute from './endpoints/i/export-mute.js';
import * as ep___i_exportNotes from './endpoints/i/export-notes.js';
import * as ep___i_exportFavorites from './endpoints/i/export-favorites.js';
import * as ep___i_exportUserLists from './endpoints/i/export-user-lists.js';
+import * as ep___i_exportAntennas from './endpoints/i/export-antennas.js';
import * as ep___i_favorites from './endpoints/i/favorites.js';
import * as ep___i_gallery_likes from './endpoints/i/gallery/likes.js';
import * as ep___i_gallery_posts from './endpoints/i/gallery/posts.js';
@@ -201,6 +203,7 @@ import * as ep___i_importBlocking from './endpoints/i/import-blocking.js';
import * as ep___i_importFollowing from './endpoints/i/import-following.js';
import * as ep___i_importMuting from './endpoints/i/import-muting.js';
import * as ep___i_importUserLists from './endpoints/i/import-user-lists.js';
+import * as ep___i_importAntennas from './endpoints/i/import-antennas.js';
import * as ep___i_notifications from './endpoints/i/notifications.js';
import * as ep___i_pageLikes from './endpoints/i/page-likes.js';
import * as ep___i_pages from './endpoints/i/pages.js';
@@ -222,7 +225,6 @@ import * as ep___i_unpin from './endpoints/i/unpin.js';
import * as ep___i_updateEmail from './endpoints/i/update-email.js';
import * as ep___i_update from './endpoints/i/update.js';
import * as ep___i_move from './endpoints/i/move.js';
-import * as ep___i_knownAs from './endpoints/i/known-as.js';
import * as ep___i_webhooks_create from './endpoints/i/webhooks/create.js';
import * as ep___i_webhooks_show from './endpoints/i/webhooks/show.js';
import * as ep___i_webhooks_list from './endpoints/i/webhooks/list.js';
@@ -330,6 +332,7 @@ import * as ep___users_search from './endpoints/users/search.js';
import * as ep___users_show from './endpoints/users/show.js';
import * as ep___users_stats from './endpoints/users/stats.js';
import * as ep___users_achievements from './endpoints/users/achievements.js';
+import * as ep___users_updateMemo from './endpoints/users/update-memo.js';
import * as ep___fetchRss from './endpoints/fetch-rss.js';
import * as ep___retention from './endpoints/retention.js';
@@ -362,6 +365,7 @@ const eps = [
['admin/emoji/remove-aliases-bulk', ep___admin_emoji_removeAliasesBulk],
['admin/emoji/set-aliases-bulk', ep___admin_emoji_setAliasesBulk],
['admin/emoji/set-category-bulk', ep___admin_emoji_setCategoryBulk],
+ ['admin/emoji/set-license-bulk', ep___admin_emoji_setLicenseBulk],
['admin/emoji/update', ep___admin_emoji_update],
['admin/federation/delete-all-files', ep___admin_federation_deleteAllFiles],
['admin/federation/refresh-remote-instance-metadata', ep___admin_federation_refreshRemoteInstanceMetadata],
@@ -526,6 +530,7 @@ const eps = [
['i/export-notes', ep___i_exportNotes],
['i/export-favorites', ep___i_exportFavorites],
['i/export-user-lists', ep___i_exportUserLists],
+ ['i/export-antennas', ep___i_exportAntennas],
['i/favorites', ep___i_favorites],
['i/gallery/likes', ep___i_gallery_likes],
['i/gallery/posts', ep___i_gallery_posts],
@@ -534,6 +539,7 @@ const eps = [
['i/import-following', ep___i_importFollowing],
['i/import-muting', ep___i_importMuting],
['i/import-user-lists', ep___i_importUserLists],
+ ['i/import-antennas', ep___i_importAntennas],
['i/notifications', ep___i_notifications],
['i/page-likes', ep___i_pageLikes],
['i/pages', ep___i_pages],
@@ -554,8 +560,7 @@ const eps = [
['i/unpin', ep___i_unpin],
['i/update-email', ep___i_updateEmail],
['i/update', ep___i_update],
- //['i/move', ep___i_move],
- //['i/known-as', ep___i_knownAs],
+ ['i/move', ep___i_move],
['i/webhooks/create', ep___i_webhooks_create],
['i/webhooks/list', ep___i_webhooks_list],
['i/webhooks/show', ep___i_webhooks_show],
@@ -663,6 +668,7 @@ const eps = [
['users/show', ep___users_show],
['users/stats', ep___users_stats],
['users/achievements', ep___users_achievements],
+ ['users/update-memo', ep___users_updateMemo],
['fetch-rss', ep___fetchRss],
['retention', ep___retention],
];
@@ -701,6 +707,12 @@ export interface IEndpointMeta {
readonly requireRolePolicy?: keyof RolePolicies;
/**
+ * 引っ越し済みのユーザーによるリクエストを禁止するか
+ * 省略した場合は false として解釈されます。
+ */
+ readonly prohibitMoved?: boolean;
+
+ /**
* エンドポイントのリミテーションに関するやつ
* 省略した場合はリミテーションは無いものとして解釈されます。
*/
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 bac8ae16e5..8a3541dffe 100644
--- a/packages/backend/src/server/api/endpoints/admin/accounts/create.ts
+++ b/packages/backend/src/server/api/endpoints/admin/accounts/create.ts
@@ -52,6 +52,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
const { account, secret } = await this.signupService.signup({
username: ps.username,
password: ps.password,
+ ignorePreservedUsernames: true,
});
const res = await this.userEntityService.pack(account, account, {
diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/list.ts b/packages/backend/src/server/api/endpoints/admin/emoji/list.ts
index 814668294f..4aa4ad82b4 100644
--- a/packages/backend/src/server/api/endpoints/admin/emoji/list.ts
+++ b/packages/backend/src/server/api/endpoints/admin/emoji/list.ts
@@ -87,12 +87,18 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
//const emojis = await q.take(ps.limit).getMany();
emojis = await q.getMany();
+ const queryarry = ps.query.match(/\:([a-z0-9_]*)\:/g);
- emojis = emojis.filter(emoji =>
- emoji.name.includes(ps.query!) ||
- emoji.aliases.some(a => a.includes(ps.query!)) ||
- emoji.category?.includes(ps.query!));
-
+ if (queryarry) {
+ emojis = emojis.filter(emoji =>
+ queryarry.includes(`:${emoji.name}:`)
+ );
+ } else {
+ emojis = emojis.filter(emoji =>
+ emoji.name.includes(ps.query!) ||
+ emoji.aliases.some(a => a.includes(ps.query!)) ||
+ emoji.category?.includes(ps.query!));
+ }
emojis.splice(ps.limit + 1);
} else {
emojis = await q.take(ps.limit).getMany();
diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/set-license-bulk.ts b/packages/backend/src/server/api/endpoints/admin/emoji/set-license-bulk.ts
new file mode 100644
index 0000000000..b90b9757be
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/admin/emoji/set-license-bulk.ts
@@ -0,0 +1,37 @@
+import { Inject, Injectable } from '@nestjs/common';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { CustomEmojiService } from '@/core/CustomEmojiService.js';
+
+export const meta = {
+ tags: ['admin'],
+
+ requireCredential: true,
+ requireRolePolicy: 'canManageCustomEmojis',
+} as const;
+
+export const paramDef = {
+ type: 'object',
+ properties: {
+ ids: { type: 'array', items: {
+ type: 'string', format: 'misskey:id',
+ } },
+ license: {
+ type: 'string',
+ nullable: true,
+ description: 'Use `null` to reset the license.',
+ },
+ },
+ required: ['ids'],
+} as const;
+
+// eslint-disable-next-line import/no-default-export
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> {
+ constructor(
+ private customEmojiService: CustomEmojiService,
+ ) {
+ super(meta, paramDef, async (ps, me) => {
+ await this.customEmojiService.setLicenseBulk(ps.ids, ps.license ?? null);
+ });
+ }
+}
diff --git a/packages/backend/src/server/api/endpoints/admin/federation/remove-all-following.ts b/packages/backend/src/server/api/endpoints/admin/federation/remove-all-following.ts
index 54ce095488..83f729953a 100644
--- a/packages/backend/src/server/api/endpoints/admin/federation/remove-all-following.ts
+++ b/packages/backend/src/server/api/endpoints/admin/federation/remove-all-following.ts
@@ -39,9 +39,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
const pairs = await Promise.all(followings.map(f => Promise.all([
this.usersRepository.findOneByOrFail({ id: f.followerId }),
this.usersRepository.findOneByOrFail({ id: f.followeeId }),
- ])));
+ ]).then(([from, to]) => [{ id: from.id }, { id: to.id }])));
- this.queueService.createUnfollowJob(pairs.map(p => ({ to: p[0], from: p[1], silent: true })));
+ this.queueService.createUnfollowJob(pairs.map(p => ({ from: p[0], to: p[1], silent: true })));
});
}
}
diff --git a/packages/backend/src/server/api/endpoints/admin/federation/update-instance.ts b/packages/backend/src/server/api/endpoints/admin/federation/update-instance.ts
index 0a529ecb08..4fd74e591d 100644
--- a/packages/backend/src/server/api/endpoints/admin/federation/update-instance.ts
+++ b/packages/backend/src/server/api/endpoints/admin/federation/update-instance.ts
@@ -3,6 +3,7 @@ import { Endpoint } from '@/server/api/endpoint-base.js';
import type { InstancesRepository } from '@/models/index.js';
import { UtilityService } from '@/core/UtilityService.js';
import { DI } from '@/di-symbols.js';
+import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
export const meta = {
tags: ['admin'],
@@ -28,6 +29,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
private instancesRepository: InstancesRepository,
private utilityService: UtilityService,
+ private federatedInstanceService: FederatedInstanceService,
) {
super(meta, paramDef, async (ps, me) => {
const instance = await this.instancesRepository.findOneBy({ host: this.utilityService.toPuny(ps.host) });
@@ -36,7 +38,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
throw new Error('instance not found');
}
- this.instancesRepository.update({ host: this.utilityService.toPuny(ps.host) }, {
+ this.federatedInstanceService.update(instance.id, {
isSuspended: ps.isSuspended,
});
});
diff --git a/packages/backend/src/server/api/endpoints/admin/meta.ts b/packages/backend/src/server/api/endpoints/admin/meta.ts
index fc318a621a..87a2d22ac2 100644
--- a/packages/backend/src/server/api/endpoints/admin/meta.ts
+++ b/packages/backend/src/server/api/endpoints/admin/meta.ts
@@ -118,6 +118,14 @@ export const meta = {
optional: false, nullable: false,
},
},
+ preservedUsernames: {
+ type: 'array',
+ optional: false, nullable: false,
+ items: {
+ type: 'string',
+ optional: false, nullable: false,
+ },
+ },
hcaptchaSecretKey: {
type: 'string',
optional: true, nullable: true,
@@ -311,6 +319,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
hiddenTags: instance.hiddenTags,
blockedHosts: instance.blockedHosts,
sensitiveWords: instance.sensitiveWords,
+ preservedUsernames: instance.preservedUsernames,
hcaptchaSecretKey: instance.hcaptchaSecretKey,
recaptchaSecretKey: instance.recaptchaSecretKey,
turnstileSecretKey: instance.turnstileSecretKey,
diff --git a/packages/backend/src/server/api/endpoints/admin/roles/create.ts b/packages/backend/src/server/api/endpoints/admin/roles/create.ts
index 1359894634..916172f54a 100644
--- a/packages/backend/src/server/api/endpoints/admin/roles/create.ts
+++ b/packages/backend/src/server/api/endpoints/admin/roles/create.ts
@@ -25,6 +25,7 @@ export const paramDef = {
isPublic: { type: 'boolean' },
isModerator: { type: 'boolean' },
isAdministrator: { type: 'boolean' },
+ isExplorable: { type: 'boolean', default: false }, // optional for backward compatibility
asBadge: { type: 'boolean' },
canEditMembersByModerator: { type: 'boolean' },
displayOrder: { type: 'number' },
@@ -76,12 +77,13 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
isPublic: ps.isPublic,
isAdministrator: ps.isAdministrator,
isModerator: ps.isModerator,
+ isExplorable: ps.isExplorable,
asBadge: ps.asBadge,
canEditMembersByModerator: ps.canEditMembersByModerator,
displayOrder: ps.displayOrder,
policies: ps.policies,
}).then(x => this.rolesRepository.findOneByOrFail(x.identifiers[0]));
-
+
this.globalEventService.publishInternalEvent('roleCreated', created);
return await this.roleEntityService.pack(created, me);
diff --git a/packages/backend/src/server/api/endpoints/admin/roles/update.ts b/packages/backend/src/server/api/endpoints/admin/roles/update.ts
index 37b68c4c41..467f157a61 100644
--- a/packages/backend/src/server/api/endpoints/admin/roles/update.ts
+++ b/packages/backend/src/server/api/endpoints/admin/roles/update.ts
@@ -33,6 +33,7 @@ export const paramDef = {
isPublic: { type: 'boolean' },
isModerator: { type: 'boolean' },
isAdministrator: { type: 'boolean' },
+ isExplorable: { type: 'boolean' },
asBadge: { type: 'boolean' },
canEditMembersByModerator: { type: 'boolean' },
displayOrder: { type: 'number' },
@@ -85,6 +86,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
isPublic: ps.isPublic,
isModerator: ps.isModerator,
isAdministrator: ps.isAdministrator,
+ isExplorable: ps.isExplorable,
asBadge: ps.asBadge,
canEditMembersByModerator: ps.canEditMembersByModerator,
displayOrder: ps.displayOrder,
diff --git a/packages/backend/src/server/api/endpoints/admin/server-info.ts b/packages/backend/src/server/api/endpoints/admin/server-info.ts
index 9c576dffe9..4ef4fdc665 100644
--- a/packages/backend/src/server/api/endpoints/admin/server-info.ts
+++ b/packages/backend/src/server/api/endpoints/admin/server-info.ts
@@ -2,7 +2,7 @@ import * as os from 'node:os';
import si from 'systeminformation';
import { Inject, Injectable } from '@nestjs/common';
import { DataSource } from 'typeorm';
-import Redis from 'ioredis';
+import * as Redis from 'ioredis';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { DI } from '@/di-symbols.js';
diff --git a/packages/backend/src/server/api/endpoints/admin/show-user.ts b/packages/backend/src/server/api/endpoints/admin/show-user.ts
index 9d19efbbcf..42229c8f23 100644
--- a/packages/backend/src/server/api/endpoints/admin/show-user.ts
+++ b/packages/backend/src/server/api/endpoints/admin/show-user.ts
@@ -80,7 +80,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
isSilenced: isSilenced,
isSuspended: user.isSuspended,
lastActiveDate: user.lastActiveDate,
- moderationNote: profile.moderationNote,
+ moderationNote: profile.moderationNote ?? '',
signins,
policies: await this.roleService.getUserPolicies(user.id),
roles: await this.roleEntityService.packMany(roles, me),
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 11de29bf83..0e94f56cfd 100644
--- a/packages/backend/src/server/api/endpoints/admin/update-meta.ts
+++ b/packages/backend/src/server/api/endpoints/admin/update-meta.ts
@@ -94,6 +94,8 @@ export const paramDef = {
enableActiveEmailValidation: { type: 'boolean' },
enableChartsForRemoteUser: { type: 'boolean' },
enableChartsForFederatedInstances: { type: 'boolean' },
+ serverRules: { type: 'array', items: { type: 'string' } },
+ preservedUsernames: { type: 'array', items: { type: 'string' } },
},
required: [],
} as const;
@@ -387,6 +389,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
set.enableChartsForFederatedInstances = ps.enableChartsForFederatedInstances;
}
+ if (ps.serverRules !== undefined) {
+ set.serverRules = ps.serverRules;
+ }
+
+ if (ps.preservedUsernames !== undefined) {
+ set.preservedUsernames = ps.preservedUsernames;
+ }
+
await this.metaService.update(set);
this.moderationLogService.insertModerationLog(me, 'updateMeta');
});
diff --git a/packages/backend/src/server/api/endpoints/antennas/create.ts b/packages/backend/src/server/api/endpoints/antennas/create.ts
index b7ce3363a9..5754a9f12a 100644
--- a/packages/backend/src/server/api/endpoints/antennas/create.ts
+++ b/packages/backend/src/server/api/endpoints/antennas/create.ts
@@ -13,6 +13,8 @@ export const meta = {
requireCredential: true,
+ prohibitMoved: true,
+
kind: 'write:account',
errors: {
diff --git a/packages/backend/src/server/api/endpoints/antennas/notes.ts b/packages/backend/src/server/api/endpoints/antennas/notes.ts
index 88623ce26a..dca0f443b7 100644
--- a/packages/backend/src/server/api/endpoints/antennas/notes.ts
+++ b/packages/backend/src/server/api/endpoints/antennas/notes.ts
@@ -1,5 +1,5 @@
import { Inject, Injectable } from '@nestjs/common';
-import Redis from 'ioredis';
+import * as Redis from 'ioredis';
import { Endpoint } from '@/server/api/endpoint-base.js';
import type { NotesRepository, AntennasRepository } from '@/models/index.js';
import { QueryService } from '@/core/QueryService.js';
diff --git a/packages/backend/src/server/api/endpoints/antennas/update.ts b/packages/backend/src/server/api/endpoints/antennas/update.ts
index 3f85442131..5f980bdbeb 100644
--- a/packages/backend/src/server/api/endpoints/antennas/update.ts
+++ b/packages/backend/src/server/api/endpoints/antennas/update.ts
@@ -11,6 +11,8 @@ export const meta = {
requireCredential: true,
+ prohibitMoved: true,
+
kind: 'write:account',
errors: {
@@ -71,7 +73,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
@Inject(DI.userListsRepository)
private userListsRepository: UserListsRepository,
-
+
private antennaEntityService: AntennaEntityService,
private globalEventService: GlobalEventService,
) {
diff --git a/packages/backend/src/server/api/endpoints/channels/create.ts b/packages/backend/src/server/api/endpoints/channels/create.ts
index dff8a9d10d..69e2f2504c 100644
--- a/packages/backend/src/server/api/endpoints/channels/create.ts
+++ b/packages/backend/src/server/api/endpoints/channels/create.ts
@@ -13,6 +13,8 @@ export const meta = {
requireCredential: true,
+ prohibitMoved: true,
+
kind: 'write:channels',
limit: {
@@ -41,6 +43,7 @@ export const paramDef = {
name: { type: 'string', minLength: 1, maxLength: 128 },
description: { type: 'string', nullable: true, minLength: 1, maxLength: 2048 },
bannerId: { type: 'string', format: 'misskey:id', nullable: true },
+ color: { type: 'string', minLength: 1, maxLength: 16 },
},
required: ['name'],
} as const;
@@ -78,6 +81,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
name: ps.name,
description: ps.description ?? null,
bannerId: banner ? banner.id : null,
+ ...(ps.color !== undefined ? { color: ps.color } : {}),
} as Channel).then(x => this.channelsRepository.findOneByOrFail(x.identifiers[0]));
return await this.channelEntityService.pack(channel, me);
diff --git a/packages/backend/src/server/api/endpoints/channels/favorite.ts b/packages/backend/src/server/api/endpoints/channels/favorite.ts
index f52b45ccf3..c8544273a1 100644
--- a/packages/backend/src/server/api/endpoints/channels/favorite.ts
+++ b/packages/backend/src/server/api/endpoints/channels/favorite.ts
@@ -10,6 +10,8 @@ export const meta = {
requireCredential: true,
+ prohibitMoved: true,
+
kind: 'write:channels',
errors: {
diff --git a/packages/backend/src/server/api/endpoints/channels/featured.ts b/packages/backend/src/server/api/endpoints/channels/featured.ts
index d25faae38d..1a8d1164c7 100644
--- a/packages/backend/src/server/api/endpoints/channels/featured.ts
+++ b/packages/backend/src/server/api/endpoints/channels/featured.ts
@@ -38,6 +38,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
super(meta, paramDef, async (ps, me) => {
const query = this.channelsRepository.createQueryBuilder('channel')
.where('channel.lastNotedAt IS NOT NULL')
+ .andWhere('channel.isArchived = FALSE')
.orderBy('channel.lastNotedAt', 'DESC');
const channels = await query.take(10).getMany();
diff --git a/packages/backend/src/server/api/endpoints/channels/follow.ts b/packages/backend/src/server/api/endpoints/channels/follow.ts
index 8ab59991c7..f3ca66cfd2 100644
--- a/packages/backend/src/server/api/endpoints/channels/follow.ts
+++ b/packages/backend/src/server/api/endpoints/channels/follow.ts
@@ -11,6 +11,8 @@ export const meta = {
requireCredential: true,
+ prohibitMoved: true,
+
kind: 'write:channels',
errors: {
diff --git a/packages/backend/src/server/api/endpoints/channels/owned.ts b/packages/backend/src/server/api/endpoints/channels/owned.ts
index 59df0616be..8fae972cb1 100644
--- a/packages/backend/src/server/api/endpoints/channels/owned.ts
+++ b/packages/backend/src/server/api/endpoints/channels/owned.ts
@@ -44,7 +44,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
private queryService: QueryService,
) {
super(meta, paramDef, async (ps, me) => {
- const query = this.queryService.makePaginationQuery(this.channelsRepository.createQueryBuilder(), ps.sinceId, ps.untilId)
+ const query = this.queryService.makePaginationQuery(this.channelsRepository.createQueryBuilder('channel'), ps.sinceId, ps.untilId)
+ .andWhere('channel.isArchived = FALSE')
.andWhere({ userId: me.id });
const channels = await query
diff --git a/packages/backend/src/server/api/endpoints/channels/search.ts b/packages/backend/src/server/api/endpoints/channels/search.ts
index a954ba224c..a3b40b0bbd 100644
--- a/packages/backend/src/server/api/endpoints/channels/search.ts
+++ b/packages/backend/src/server/api/endpoints/channels/search.ts
@@ -46,15 +46,18 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
private queryService: QueryService,
) {
super(meta, paramDef, async (ps, me) => {
- const query = this.queryService.makePaginationQuery(this.channelsRepository.createQueryBuilder('channel'), ps.sinceId, ps.untilId);
+ const query = this.queryService.makePaginationQuery(this.channelsRepository.createQueryBuilder('channel'), ps.sinceId, ps.untilId)
+ .andWhere('channel.isArchived = FALSE');
- if (ps.type === 'nameAndDescription') {
- query.andWhere(new Brackets(qb => { qb
- .where('channel.name ILIKE :q', { q: `%${ sqlLikeEscape(ps.query) }%` })
- .orWhere('channel.description ILIKE :q', { q: `%${ sqlLikeEscape(ps.query) }%` });
- }));
- } else {
- query.andWhere('channel.name ILIKE :q', { q: `%${ sqlLikeEscape(ps.query) }%` });
+ if (ps.query !== '') {
+ if (ps.type === 'nameAndDescription') {
+ query.andWhere(new Brackets(qb => { qb
+ .where('channel.name ILIKE :q', { q: `%${ sqlLikeEscape(ps.query) }%` })
+ .orWhere('channel.description ILIKE :q', { q: `%${ sqlLikeEscape(ps.query) }%` });
+ }));
+ } else {
+ query.andWhere('channel.name ILIKE :q', { q: `%${ sqlLikeEscape(ps.query) }%` });
+ }
}
const channels = await query
diff --git a/packages/backend/src/server/api/endpoints/channels/timeline.ts b/packages/backend/src/server/api/endpoints/channels/timeline.ts
index 2491d14235..c881074bab 100644
--- a/packages/backend/src/server/api/endpoints/channels/timeline.ts
+++ b/packages/backend/src/server/api/endpoints/channels/timeline.ts
@@ -1,5 +1,5 @@
import { Inject, Injectable } from '@nestjs/common';
-import Redis from 'ioredis';
+import * as Redis from 'ioredis';
import { Endpoint } from '@/server/api/endpoint-base.js';
import type { ChannelsRepository, Note, NotesRepository } from '@/models/index.js';
import { QueryService } from '@/core/QueryService.js';
diff --git a/packages/backend/src/server/api/endpoints/channels/unfavorite.ts b/packages/backend/src/server/api/endpoints/channels/unfavorite.ts
index 0c3f6c4855..67fb1ea03e 100644
--- a/packages/backend/src/server/api/endpoints/channels/unfavorite.ts
+++ b/packages/backend/src/server/api/endpoints/channels/unfavorite.ts
@@ -9,6 +9,8 @@ export const meta = {
requireCredential: true,
+ prohibitMoved: true,
+
kind: 'write:channels',
errors: {
diff --git a/packages/backend/src/server/api/endpoints/channels/unfollow.ts b/packages/backend/src/server/api/endpoints/channels/unfollow.ts
index 855ba47f8c..f46ff9f286 100644
--- a/packages/backend/src/server/api/endpoints/channels/unfollow.ts
+++ b/packages/backend/src/server/api/endpoints/channels/unfollow.ts
@@ -10,6 +10,8 @@ export const meta = {
requireCredential: true,
+ prohibitMoved: true,
+
kind: 'write:channels',
errors: {
diff --git a/packages/backend/src/server/api/endpoints/channels/update.ts b/packages/backend/src/server/api/endpoints/channels/update.ts
index 084b3f919e..30d7f8b244 100644
--- a/packages/backend/src/server/api/endpoints/channels/update.ts
+++ b/packages/backend/src/server/api/endpoints/channels/update.ts
@@ -47,12 +47,14 @@ export const paramDef = {
name: { type: 'string', minLength: 1, maxLength: 128 },
description: { type: 'string', nullable: true, minLength: 1, maxLength: 2048 },
bannerId: { type: 'string', format: 'misskey:id', nullable: true },
+ isArchived: { type: 'boolean', nullable: true },
pinnedNoteIds: {
type: 'array',
items: {
type: 'string', format: 'misskey:id',
},
},
+ color: { type: 'string', minLength: 1, maxLength: 16 },
},
required: ['channelId'],
} as const;
@@ -104,6 +106,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
...(ps.name !== undefined ? { name: ps.name } : {}),
...(ps.description !== undefined ? { description: ps.description } : {}),
...(ps.pinnedNoteIds !== undefined ? { pinnedNoteIds: ps.pinnedNoteIds } : {}),
+ ...(ps.color !== undefined ? { color: ps.color } : {}),
+ ...(typeof ps.isArchived === 'boolean' ? { isArchived: ps.isArchived } : {}),
...(banner ? { bannerId: banner.id } : {}),
});
diff --git a/packages/backend/src/server/api/endpoints/clips/add-note.ts b/packages/backend/src/server/api/endpoints/clips/add-note.ts
index b9d8dce47a..c3561e2a71 100644
--- a/packages/backend/src/server/api/endpoints/clips/add-note.ts
+++ b/packages/backend/src/server/api/endpoints/clips/add-note.ts
@@ -13,6 +13,8 @@ export const meta = {
requireCredential: true,
+ prohibitMoved: true,
+
kind: 'write:account',
limit: {
diff --git a/packages/backend/src/server/api/endpoints/clips/create.ts b/packages/backend/src/server/api/endpoints/clips/create.ts
index a770dc986d..5395a5c373 100644
--- a/packages/backend/src/server/api/endpoints/clips/create.ts
+++ b/packages/backend/src/server/api/endpoints/clips/create.ts
@@ -12,6 +12,8 @@ export const meta = {
requireCredential: true,
+ prohibitMoved: true,
+
kind: 'write:account',
res: {
@@ -57,7 +59,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
if (currentCount > (await this.roleService.getUserPolicies(me.id)).clipLimit) {
throw new ApiError(meta.errors.tooManyClips);
}
-
+
const clip = await this.clipsRepository.insert({
id: this.idService.genId(),
createdAt: new Date(),
diff --git a/packages/backend/src/server/api/endpoints/clips/favorite.ts b/packages/backend/src/server/api/endpoints/clips/favorite.ts
index 6addf743a2..f08caaf8d7 100644
--- a/packages/backend/src/server/api/endpoints/clips/favorite.ts
+++ b/packages/backend/src/server/api/endpoints/clips/favorite.ts
@@ -10,6 +10,8 @@ export const meta = {
requireCredential: true,
+ prohibitMoved: true,
+
kind: 'write:clip-favorite',
errors: {
diff --git a/packages/backend/src/server/api/endpoints/clips/remove-note.ts b/packages/backend/src/server/api/endpoints/clips/remove-note.ts
index 5d88870ed2..50c5d758bd 100644
--- a/packages/backend/src/server/api/endpoints/clips/remove-note.ts
+++ b/packages/backend/src/server/api/endpoints/clips/remove-note.ts
@@ -10,6 +10,8 @@ export const meta = {
requireCredential: true,
+ prohibitMoved: true,
+
kind: 'write:account',
errors: {
diff --git a/packages/backend/src/server/api/endpoints/clips/unfavorite.ts b/packages/backend/src/server/api/endpoints/clips/unfavorite.ts
index 244843d50f..3da252a226 100644
--- a/packages/backend/src/server/api/endpoints/clips/unfavorite.ts
+++ b/packages/backend/src/server/api/endpoints/clips/unfavorite.ts
@@ -9,6 +9,8 @@ export const meta = {
requireCredential: true,
+ prohibitMoved: true,
+
kind: 'write:clip-favorite',
errors: {
diff --git a/packages/backend/src/server/api/endpoints/clips/update.ts b/packages/backend/src/server/api/endpoints/clips/update.ts
index a103c3f7d3..70f1959353 100644
--- a/packages/backend/src/server/api/endpoints/clips/update.ts
+++ b/packages/backend/src/server/api/endpoints/clips/update.ts
@@ -10,6 +10,8 @@ export const meta = {
requireCredential: true,
+ prohibitMoved: true,
+
kind: 'write:account',
errors: {
diff --git a/packages/backend/src/server/api/endpoints/drive/files/create.ts b/packages/backend/src/server/api/endpoints/drive/files/create.ts
index b3bdef41d3..a1c1f9325e 100644
--- a/packages/backend/src/server/api/endpoints/drive/files/create.ts
+++ b/packages/backend/src/server/api/endpoints/drive/files/create.ts
@@ -15,6 +15,8 @@ export const meta = {
requireCredential: true,
+ prohibitMoved: true,
+
limit: {
duration: ms('1hour'),
max: 120,
diff --git a/packages/backend/src/server/api/endpoints/drive/files/update.ts b/packages/backend/src/server/api/endpoints/drive/files/update.ts
index 3141e0fc01..3ecbba22b5 100644
--- a/packages/backend/src/server/api/endpoints/drive/files/update.ts
+++ b/packages/backend/src/server/api/endpoints/drive/files/update.ts
@@ -40,8 +40,13 @@ export const meta = {
code: 'NO_SUCH_FOLDER',
id: 'ea8fb7a5-af77-4a08-b608-c0218176cd73',
},
+
+ restrictedByRole: {
+ message: 'This feature is restricted by your role.',
+ code: 'RESTRICTED_BY_ROLE',
+ id: '7f59dccb-f465-75ab-5cf4-3ce44e3282f7',
+ },
},
-
res: {
type: 'object',
optional: false, nullable: false,
@@ -77,7 +82,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
) {
super(meta, paramDef, async (ps, me) => {
const file = await this.driveFilesRepository.findOneBy({ id: ps.fileId });
-
+ const alwaysMarkNsfw = (await this.roleService.getUserPolicies(me.id)).alwaysMarkNsfw;
if (file == null) {
throw new ApiError(meta.errors.noSuchFile);
}
@@ -93,6 +98,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
if (ps.comment !== undefined) file.comment = ps.comment;
+ if (ps.isSensitive !== undefined && ps.isSensitive !== file.isSensitive && alwaysMarkNsfw && !ps.isSensitive) {
+ throw new ApiError(meta.errors.restrictedByRole);
+ }
+
if (ps.isSensitive !== undefined) file.isSensitive = ps.isSensitive;
if (ps.folderId !== undefined) {
diff --git a/packages/backend/src/server/api/endpoints/drive/files/upload-from-url.ts b/packages/backend/src/server/api/endpoints/drive/files/upload-from-url.ts
index cfef793831..c835587c4a 100644
--- a/packages/backend/src/server/api/endpoints/drive/files/upload-from-url.ts
+++ b/packages/backend/src/server/api/endpoints/drive/files/upload-from-url.ts
@@ -19,6 +19,8 @@ export const meta = {
requireCredential: true,
+ prohibitMoved: true,
+
kind: 'write:drive',
} as const;
diff --git a/packages/backend/src/server/api/endpoints/flash/create.ts b/packages/backend/src/server/api/endpoints/flash/create.ts
index f21d9d5c33..3172bdbfda 100644
--- a/packages/backend/src/server/api/endpoints/flash/create.ts
+++ b/packages/backend/src/server/api/endpoints/flash/create.ts
@@ -11,6 +11,8 @@ export const meta = {
requireCredential: true,
+ prohibitMoved: true,
+
kind: 'write:flash',
limit: {
diff --git a/packages/backend/src/server/api/endpoints/flash/like.ts b/packages/backend/src/server/api/endpoints/flash/like.ts
index 5581b8ec60..23de2f3970 100644
--- a/packages/backend/src/server/api/endpoints/flash/like.ts
+++ b/packages/backend/src/server/api/endpoints/flash/like.ts
@@ -10,6 +10,8 @@ export const meta = {
requireCredential: true,
+ prohibitMoved: true,
+
kind: 'write:flash-likes',
errors: {
diff --git a/packages/backend/src/server/api/endpoints/flash/unlike.ts b/packages/backend/src/server/api/endpoints/flash/unlike.ts
index b994f5d347..696512b06c 100644
--- a/packages/backend/src/server/api/endpoints/flash/unlike.ts
+++ b/packages/backend/src/server/api/endpoints/flash/unlike.ts
@@ -9,6 +9,8 @@ export const meta = {
requireCredential: true,
+ prohibitMoved: true,
+
kind: 'write:flash-likes',
errors: {
diff --git a/packages/backend/src/server/api/endpoints/flash/update.ts b/packages/backend/src/server/api/endpoints/flash/update.ts
index cd4e413a40..78dfd4a06a 100644
--- a/packages/backend/src/server/api/endpoints/flash/update.ts
+++ b/packages/backend/src/server/api/endpoints/flash/update.ts
@@ -10,6 +10,8 @@ export const meta = {
requireCredential: true,
+ prohibitMoved: true,
+
kind: 'write:flash',
limit: {
diff --git a/packages/backend/src/server/api/endpoints/following/create.ts b/packages/backend/src/server/api/endpoints/following/create.ts
index 411c39110a..4ad16de911 100644
--- a/packages/backend/src/server/api/endpoints/following/create.ts
+++ b/packages/backend/src/server/api/endpoints/following/create.ts
@@ -19,6 +19,8 @@ export const meta = {
requireCredential: true,
+ prohibitMoved: true,
+
kind: 'write:following',
errors: {
diff --git a/packages/backend/src/server/api/endpoints/gallery/posts/create.ts b/packages/backend/src/server/api/endpoints/gallery/posts/create.ts
index cb8b6a2e3e..ca6bfa7e0f 100644
--- a/packages/backend/src/server/api/endpoints/gallery/posts/create.ts
+++ b/packages/backend/src/server/api/endpoints/gallery/posts/create.ts
@@ -13,6 +13,8 @@ export const meta = {
requireCredential: true,
+ prohibitMoved: true,
+
kind: 'write:gallery',
limit: {
diff --git a/packages/backend/src/server/api/endpoints/gallery/posts/like.ts b/packages/backend/src/server/api/endpoints/gallery/posts/like.ts
index 519e56ed6a..6ac5fa8606 100644
--- a/packages/backend/src/server/api/endpoints/gallery/posts/like.ts
+++ b/packages/backend/src/server/api/endpoints/gallery/posts/like.ts
@@ -10,6 +10,8 @@ export const meta = {
requireCredential: true,
+ prohibitMoved: true,
+
kind: 'write:gallery-likes',
errors: {
diff --git a/packages/backend/src/server/api/endpoints/gallery/posts/unlike.ts b/packages/backend/src/server/api/endpoints/gallery/posts/unlike.ts
index cfbedcc4d9..513089217d 100644
--- a/packages/backend/src/server/api/endpoints/gallery/posts/unlike.ts
+++ b/packages/backend/src/server/api/endpoints/gallery/posts/unlike.ts
@@ -9,6 +9,8 @@ export const meta = {
requireCredential: true,
+ prohibitMoved: true,
+
kind: 'write:gallery-likes',
errors: {
diff --git a/packages/backend/src/server/api/endpoints/gallery/posts/update.ts b/packages/backend/src/server/api/endpoints/gallery/posts/update.ts
index f14d644a3a..a2a10d8400 100644
--- a/packages/backend/src/server/api/endpoints/gallery/posts/update.ts
+++ b/packages/backend/src/server/api/endpoints/gallery/posts/update.ts
@@ -11,6 +11,8 @@ export const meta = {
requireCredential: true,
+ prohibitMoved: true,
+
kind: 'write:gallery',
limit: {
diff --git a/packages/backend/src/server/api/endpoints/hashtags/users.ts b/packages/backend/src/server/api/endpoints/hashtags/users.ts
index c3f2ea9ea7..dd3549020e 100644
--- a/packages/backend/src/server/api/endpoints/hashtags/users.ts
+++ b/packages/backend/src/server/api/endpoints/hashtags/users.ts
@@ -44,7 +44,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
) {
super(meta, paramDef, async (ps, me) => {
const query = this.usersRepository.createQueryBuilder('user')
- .where(':tag = ANY(user.tags)', { tag: normalizeForSearch(ps.tag) });
+ .where(':tag = ANY(user.tags)', { tag: normalizeForSearch(ps.tag) })
+ .andWhere('user.isSuspended = FALSE');
const recent = new Date(Date.now() - (1000 * 60 * 60 * 24 * 5));
diff --git a/packages/backend/src/server/api/endpoints/i/claim-achievement.ts b/packages/backend/src/server/api/endpoints/i/claim-achievement.ts
index 102dae4fb7..4eef496385 100644
--- a/packages/backend/src/server/api/endpoints/i/claim-achievement.ts
+++ b/packages/backend/src/server/api/endpoints/i/claim-achievement.ts
@@ -4,6 +4,7 @@ import { AchievementService, ACHIEVEMENT_TYPES } from '@/core/AchievementService
export const meta = {
requireCredential: true,
+ prohibitMoved: true,
} as const;
export const paramDef = {
diff --git a/packages/backend/src/server/api/endpoints/i/export-antennas.ts b/packages/backend/src/server/api/endpoints/i/export-antennas.ts
new file mode 100644
index 0000000000..4182c1b247
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/i/export-antennas.ts
@@ -0,0 +1,30 @@
+import { Injectable } from '@nestjs/common';
+import ms from 'ms';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { QueueService } from '@/core/QueueService.js';
+
+export const meta = {
+ secure: true,
+ requireCredential: true,
+ limit: {
+ duration: ms('1hour'),
+ max: 1,
+ },
+} as const;
+
+export const paramDef = {
+ type: 'object',
+ properties: {},
+ required: [],
+} as const;
+
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
+ constructor (
+ private queueService: QueueService,
+ ) {
+ super(meta, paramDef, async (ps, me) => {
+ this.queueService.createExportAntennasJob(me);
+ });
+ }
+}
diff --git a/packages/backend/src/server/api/endpoints/i/import-antennas.ts b/packages/backend/src/server/api/endpoints/i/import-antennas.ts
new file mode 100644
index 0000000000..efb5ce4223
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/i/import-antennas.ts
@@ -0,0 +1,84 @@
+import { Inject, Injectable } from '@nestjs/common';
+import ms from 'ms';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { QueueService } from '@/core/QueueService.js';
+import type { AntennasRepository, DriveFilesRepository, UsersRepository, Antenna as _Antenna } from '@/models/index.js';
+import { DI } from '@/di-symbols.js';
+import { RoleService } from '@/core/RoleService.js';
+import { DownloadService } from '@/core/DownloadService.js';
+import { ApiError } from '../../error.js';
+
+export const meta = {
+ secure: true,
+ requireCredential: true,
+ prohibitMoved: true,
+
+ limit: {
+ duration: ms('1hour'),
+ max: 1,
+ },
+ errors: {
+ noSuchFile: {
+ message: 'No such file.',
+ code: 'NO_SUCH_FILE',
+ id: '3b71d086-c3fa-431c-b01d-ded65a777172',
+ },
+ noSuchUser: {
+ message: 'No such user.',
+ code: 'NO_SUCH_USER',
+ id: 'e842c379-8ac7-4cf7-b07a-4d4de7e4671c',
+ },
+ emptyFile: {
+ message: 'That file is empty.',
+ code: 'EMPTY_FILE',
+ id: '7f60115d-8d93-4b0f-bd0e-3815dcbb389f',
+ },
+ tooManyAntennas: {
+ message: 'You cannot create antenna any more.',
+ code: 'TOO_MANY_ANTENNAS',
+ id: '600917d4-a4cb-4cc5-8ba8-7ac8ea3c7779',
+ },
+ },
+} as const;
+
+export const paramDef = {
+ type: 'object',
+ properties: {
+ fileId: { type: 'string', format: 'misskey:id' },
+ },
+ required: ['fileId'],
+} as const;
+
+@Injectable() // eslint-disable-next-line import/no-default-export
+export default class extends Endpoint<typeof meta, typeof paramDef> {
+ constructor (
+ @Inject(DI.driveFilesRepository)
+ private driveFilesRepository: DriveFilesRepository,
+
+ @Inject(DI.antennasRepository)
+ private antennasRepository: AntennasRepository,
+
+ @Inject(DI.usersRepository)
+ private usersRepository: UsersRepository,
+
+ private roleService: RoleService,
+ private queueService: QueueService,
+ private downloadService: DownloadService,
+ ) {
+ super(meta, paramDef, async (ps, me) => {
+ const users = await this.usersRepository.findOneBy({ id: me.id });
+ if (users === null) throw new ApiError(meta.errors.noSuchUser);
+ const file = await this.driveFilesRepository.findOneBy({ id: ps.fileId });
+ if (file === null) throw new ApiError(meta.errors.noSuchFile);
+ if (file.size === 0) throw new ApiError(meta.errors.emptyFile);
+ const antennas: (_Antenna & { userListAccts: string[] | null })[] = JSON.parse(await this.downloadService.downloadTextFile(file.url));
+ const currentAntennasCount = await this.antennasRepository.countBy({ userId: me.id });
+ if (currentAntennasCount + antennas.length > (await this.roleService.getUserPolicies(me.id)).antennaLimit) {
+ throw new ApiError(meta.errors.tooManyAntennas);
+ }
+ this.queueService.createImportAntennasJob(me, antennas);
+ });
+ }
+}
+
+export type Antenna = (_Antenna & { userListAccts: string[] | null })[];
diff --git a/packages/backend/src/server/api/endpoints/i/import-blocking.ts b/packages/backend/src/server/api/endpoints/i/import-blocking.ts
index 8c1c158ab1..811971591a 100644
--- a/packages/backend/src/server/api/endpoints/i/import-blocking.ts
+++ b/packages/backend/src/server/api/endpoints/i/import-blocking.ts
@@ -2,6 +2,7 @@ import { Inject, Injectable } from '@nestjs/common';
import ms from 'ms';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { QueueService } from '@/core/QueueService.js';
+import { AccountMoveService } from '@/core/AccountMoveService.js';
import type { DriveFilesRepository } from '@/models/index.js';
import { DI } from '@/di-symbols.js';
import { ApiError } from '../../error.js';
@@ -9,6 +10,7 @@ import { ApiError } from '../../error.js';
export const meta = {
secure: true,
requireCredential: true,
+ prohibitMoved: true,
limit: {
duration: ms('1hour'),
@@ -58,15 +60,22 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
private driveFilesRepository: DriveFilesRepository,
private queueService: QueueService,
+ private accountMoveService: AccountMoveService,
) {
super(meta, paramDef, async (ps, me) => {
const file = await this.driveFilesRepository.findOneBy({ id: ps.fileId });
if (file == null) throw new ApiError(meta.errors.noSuchFile);
//if (!file.type.endsWith('/csv')) throw new ApiError(meta.errors.unexpectedFileType);
- if (file.size > 50000) throw new ApiError(meta.errors.tooBigFile);
if (file.size === 0) throw new ApiError(meta.errors.emptyFile);
+ const checkMoving = await this.accountMoveService.validateAlsoKnownAs(
+ me,
+ (old, src) => !!src.movedAt && src.movedAt.getTime() + 1000 * 60 * 60 * 2 > (new Date()).getTime(),
+ true
+ );
+ if (checkMoving ? file.size > 32 * 1024 * 1024 : file.size > 64 * 1024) throw new ApiError(meta.errors.tooBigFile);
+
this.queueService.createImportBlockingJob(me, file.id);
});
}
diff --git a/packages/backend/src/server/api/endpoints/i/import-following.ts b/packages/backend/src/server/api/endpoints/i/import-following.ts
index 383bdc02b5..8af278c883 100644
--- a/packages/backend/src/server/api/endpoints/i/import-following.ts
+++ b/packages/backend/src/server/api/endpoints/i/import-following.ts
@@ -2,6 +2,7 @@ import { Inject, Injectable } from '@nestjs/common';
import ms from 'ms';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { QueueService } from '@/core/QueueService.js';
+import { AccountMoveService } from '@/core/AccountMoveService.js';
import type { DriveFilesRepository } from '@/models/index.js';
import { DI } from '@/di-symbols.js';
import { ApiError } from '../../error.js';
@@ -9,6 +10,7 @@ import { ApiError } from '../../error.js';
export const meta = {
secure: true,
requireCredential: true,
+ prohibitMoved: true,
limit: {
duration: ms('1hour'),
max: 1,
@@ -57,15 +59,22 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
private driveFilesRepository: DriveFilesRepository,
private queueService: QueueService,
+ private accountMoveService: AccountMoveService,
) {
super(meta, paramDef, async (ps, me) => {
const file = await this.driveFilesRepository.findOneBy({ id: ps.fileId });
if (file == null) throw new ApiError(meta.errors.noSuchFile);
//if (!file.type.endsWith('/csv')) throw new ApiError(meta.errors.unexpectedFileType);
- if (file.size > 50000) throw new ApiError(meta.errors.tooBigFile);
if (file.size === 0) throw new ApiError(meta.errors.emptyFile);
+ const checkMoving = await this.accountMoveService.validateAlsoKnownAs(
+ me,
+ (old, src) => !!src.movedAt && src.movedAt.getTime() + 1000 * 60 * 60 * 2 > (new Date()).getTime(),
+ true
+ );
+ if (checkMoving ? file.size > 32 * 1024 * 1024 : file.size > 64 * 1024) throw new ApiError(meta.errors.tooBigFile);
+
this.queueService.createImportFollowingJob(me, file.id);
});
}
diff --git a/packages/backend/src/server/api/endpoints/i/import-muting.ts b/packages/backend/src/server/api/endpoints/i/import-muting.ts
index 345ad916cb..eb0f9ba474 100644
--- a/packages/backend/src/server/api/endpoints/i/import-muting.ts
+++ b/packages/backend/src/server/api/endpoints/i/import-muting.ts
@@ -2,6 +2,7 @@ import { Inject, Injectable } from '@nestjs/common';
import ms from 'ms';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { QueueService } from '@/core/QueueService.js';
+import { AccountMoveService } from '@/core/AccountMoveService.js';
import type { DriveFilesRepository } from '@/models/index.js';
import { DI } from '@/di-symbols.js';
import { ApiError } from '../../error.js';
@@ -9,6 +10,7 @@ import { ApiError } from '../../error.js';
export const meta = {
secure: true,
requireCredential: true,
+ prohibitMoved: true,
limit: {
duration: ms('1hour'),
@@ -58,15 +60,22 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
private driveFilesRepository: DriveFilesRepository,
private queueService: QueueService,
+ private accountMoveService: AccountMoveService,
) {
super(meta, paramDef, async (ps, me) => {
const file = await this.driveFilesRepository.findOneBy({ id: ps.fileId });
if (file == null) throw new ApiError(meta.errors.noSuchFile);
//if (!file.type.endsWith('/csv')) throw new ApiError(meta.errors.unexpectedFileType);
- if (file.size > 50000) throw new ApiError(meta.errors.tooBigFile);
if (file.size === 0) throw new ApiError(meta.errors.emptyFile);
+ const checkMoving = await this.accountMoveService.validateAlsoKnownAs(
+ me,
+ (old, src) => !!src.movedAt && src.movedAt.getTime() + 1000 * 60 * 60 * 2 > (new Date()).getTime(),
+ true
+ );
+ if (checkMoving ? file.size > 32 * 1024 * 1024 : file.size > 64 * 1024) throw new ApiError(meta.errors.tooBigFile);
+
this.queueService.createImportMutingJob(me, file.id);
});
}
diff --git a/packages/backend/src/server/api/endpoints/i/import-user-lists.ts b/packages/backend/src/server/api/endpoints/i/import-user-lists.ts
index 875af7ec23..4568e93901 100644
--- a/packages/backend/src/server/api/endpoints/i/import-user-lists.ts
+++ b/packages/backend/src/server/api/endpoints/i/import-user-lists.ts
@@ -2,6 +2,7 @@ import { Inject, Injectable } from '@nestjs/common';
import ms from 'ms';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { QueueService } from '@/core/QueueService.js';
+import { AccountMoveService } from '@/core/AccountMoveService.js';
import type { DriveFilesRepository } from '@/models/index.js';
import { DI } from '@/di-symbols.js';
import { ApiError } from '../../error.js';
@@ -9,6 +10,7 @@ import { ApiError } from '../../error.js';
export const meta = {
secure: true,
requireCredential: true,
+ prohibitMoved: true,
limit: {
duration: ms('1hour'),
max: 1,
@@ -57,15 +59,22 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
private driveFilesRepository: DriveFilesRepository,
private queueService: QueueService,
+ private accountMoveService: AccountMoveService,
) {
super(meta, paramDef, async (ps, me) => {
const file = await this.driveFilesRepository.findOneBy({ id: ps.fileId });
if (file == null) throw new ApiError(meta.errors.noSuchFile);
//if (!file.type.endsWith('/csv')) throw new ApiError(meta.errors.unexpectedFileType);
- if (file.size > 30000) throw new ApiError(meta.errors.tooBigFile);
if (file.size === 0) throw new ApiError(meta.errors.emptyFile);
+ const checkMoving = await this.accountMoveService.validateAlsoKnownAs(
+ me,
+ (old, src) => !!src.movedAt && src.movedAt.getTime() + 1000 * 60 * 60 * 2 > (new Date()).getTime(),
+ true
+ );
+ if (checkMoving ? file.size > 32 * 1024 * 1024 : file.size > 64 * 1024) throw new ApiError(meta.errors.tooBigFile);
+
this.queueService.createImportUserListsJob(me, file.id);
});
}
diff --git a/packages/backend/src/server/api/endpoints/i/known-as.ts b/packages/backend/src/server/api/endpoints/i/known-as.ts
deleted file mode 100644
index 964704d82b..0000000000
--- a/packages/backend/src/server/api/endpoints/i/known-as.ts
+++ /dev/null
@@ -1,92 +0,0 @@
-import { Injectable } from '@nestjs/common';
-import ms from 'ms';
-
-import { User } from '@/models/entities/User.js';
-import { Endpoint } from '@/server/api/endpoint-base.js';
-import { ApiError } from '@/server/api/error.js';
-
-import { AccountMoveService } from '@/core/AccountMoveService.js';
-import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js';
-import { UserEntityService } from '@/core/entities/UserEntityService.js';
-import { ApiLoggerService } from '@/server/api/ApiLoggerService.js';
-
-export const meta = {
- tags: ['users'],
-
- secure: true,
- requireCredential: true,
-
- limit: {
- duration: ms('1day'),
- max: 30,
- },
-
- errors: {
- noSuchUser: {
- message: 'No such user.',
- code: 'NO_SUCH_USER',
- id: 'fcd2eef9-a9b2-4c4f-8624-038099e90aa5',
- },
- notRemote: {
- message: 'User is not remote. You can only migrate from other instances.',
- code: 'NOT_REMOTE',
- id: '4362f8dc-731f-4ad8-a694-be2a88922a24',
- },
- uriNull: {
- message: 'User ActivityPup URI is null.',
- code: 'URI_NULL',
- id: 'bf326f31-d430-4f97-9933-5d61e4d48a23',
- },
- },
-} as const;
-
-export const paramDef = {
- type: 'object',
- properties: {
- alsoKnownAs: { type: 'string' },
- },
- required: ['alsoKnownAs'],
-} as const;
-
-@Injectable()
-export default class extends Endpoint<typeof meta, typeof paramDef> {
- constructor(
- private userEntityService: UserEntityService,
- private remoteUserResolveService: RemoteUserResolveService,
- private apiLoggerService: ApiLoggerService,
- private accountMoveService: AccountMoveService,
- ) {
- super(meta, paramDef, async (ps, me) => {
- // Check parameter
- if (!ps.alsoKnownAs) throw new ApiError(meta.errors.noSuchUser);
-
- let unfiltered = ps.alsoKnownAs;
- const updates = {} as Partial<User>;
-
- if (!unfiltered) {
- updates.alsoKnownAs = null;
- } else {
- // Parse user's input into the old account
- if (unfiltered.startsWith('acct:')) unfiltered = unfiltered.substring(5);
- if (unfiltered.startsWith('@')) unfiltered = unfiltered.substring(1);
- if (!unfiltered.includes('@')) throw new ApiError(meta.errors.notRemote);
-
- const userAddress = unfiltered.split('@');
- // Retrieve the old account
- const knownAs = await this.remoteUserResolveService.resolveUser(userAddress[0], userAddress[1]).catch((e) => {
- this.apiLoggerService.logger.warn(`failed to resolve remote user: ${e}`);
- throw new ApiError(meta.errors.noSuchUser);
- });
-
- const toUrl: string | null = knownAs.uri;
- if (!toUrl) throw new ApiError(meta.errors.uriNull);
- // Only allow moving from a remote account
- if (this.userEntityService.isLocalUser(knownAs)) throw new ApiError(meta.errors.notRemote);
-
- updates.alsoKnownAs = updates.alsoKnownAs?.concat([toUrl]) ?? [toUrl];
- }
-
- return await this.accountMoveService.createAlias(me, updates);
- });
- }
-}
diff --git a/packages/backend/src/server/api/endpoints/i/move.ts b/packages/backend/src/server/api/endpoints/i/move.ts
index ac76e1f620..261dd527c0 100644
--- a/packages/backend/src/server/api/endpoints/i/move.ts
+++ b/packages/backend/src/server/api/endpoints/i/move.ts
@@ -7,40 +7,35 @@ import { DI } from '@/di-symbols.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { ApiError } from '@/server/api/error.js';
+import { LocalUser, RemoteUser } from '@/models/entities/User.js';
+
import { AccountMoveService } from '@/core/AccountMoveService.js';
import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js';
-import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { ApiLoggerService } from '@/server/api/ApiLoggerService.js';
import { GetterService } from '@/server/api/GetterService.js';
import { ApPersonService } from '@/core/activitypub/models/ApPersonService.js';
+import { UserEntityService } from '@/core/entities/UserEntityService.js';
+
+import * as Acct from '@/misc/acct.js';
export const meta = {
tags: ['users'],
secure: true,
requireCredential: true,
+ prohibitMoved: true,
limit: {
duration: ms('1day'),
max: 5,
},
errors: {
- noSuchMoveTarget: {
- message: 'No such move target.',
- code: 'NO_SUCH_MOVE_TARGET',
- id: 'b5c90186-4ab0-49c8-9bba-a1f76c202ba4',
- },
- remoteAccountForbids: {
+ destinationAccountForbids: {
message:
- 'Remote account doesn\'t have proper \'Known As\' alias. Did you remember to set it?',
- code: 'REMOTE_ACCOUNT_FORBIDS',
+ 'Destination account doesn\'t have proper \'Known As\' alias, or has already moved.',
+ code: 'DESTINATION_ACCOUNT_FORBIDS',
id: 'b5c90186-4ab0-49c8-9bba-a1f766282ba4',
},
- notRemote: {
- message: 'User is not remote. You can only migrate to other instances.',
- code: 'NOT_REMOTE',
- id: '4362f8dc-731f-4ad8-a694-be2a88922a24',
- },
rootForbidden: {
message: 'The root can\'t migrate.',
code: 'NOT_ROOT_FORBIDDEN',
@@ -84,57 +79,52 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
@Inject(DI.config)
private config: Config,
- private userEntityService: UserEntityService,
private remoteUserResolveService: RemoteUserResolveService,
private apiLoggerService: ApiLoggerService,
private accountMoveService: AccountMoveService,
private getterService: GetterService,
private apPersonService: ApPersonService,
+ private userEntityService: UserEntityService,
) {
super(meta, paramDef, async (ps, me) => {
// check parameter
- if (!ps.moveToAccount) throw new ApiError(meta.errors.noSuchMoveTarget);
+ if (!ps.moveToAccount) throw new ApiError(meta.errors.noSuchUser);
// abort if user is the root
if (me.isRoot) throw new ApiError(meta.errors.rootForbidden);
// abort if user has already moved
if (me.movedToUri) throw new ApiError(meta.errors.alreadyMoved);
- let unfiltered = ps.moveToAccount;
- if (!unfiltered) throw new ApiError(meta.errors.noSuchMoveTarget);
-
// parse user's input into the destination account
- if (unfiltered.startsWith('acct:')) unfiltered = unfiltered.substring(5);
- if (unfiltered.startsWith('@')) unfiltered = unfiltered.substring(1);
- if (!unfiltered.includes('@')) throw new ApiError(meta.errors.notRemote);
-
- const userAddress = unfiltered.split('@');
+ const { username, host } = Acct.parse(ps.moveToAccount);
// retrieve the destination account
- let moveTo = await this.remoteUserResolveService.resolveUser(userAddress[0], userAddress[1]).catch((e) => {
+ let moveTo = await this.remoteUserResolveService.resolveUser(username, host).catch((e) => {
this.apiLoggerService.logger.warn(`failed to resolve remote user: ${e}`);
- throw new ApiError(meta.errors.noSuchMoveTarget);
+ throw new ApiError(meta.errors.noSuchUser);
});
- const remoteMoveTo = await this.getterService.getRemoteUser(moveTo.id);
- if (!remoteMoveTo.uri) throw new ApiError(meta.errors.uriNull);
+ const destination = await this.getterService.getUser(moveTo.id) as LocalUser | RemoteUser;
+ const newUri = this.userEntityService.getUserUri(destination);
// update local db
- await this.apPersonService.updatePerson(remoteMoveTo.uri);
+ await this.apPersonService.updatePerson(newUri);
// retrieve updated user
- moveTo = await this.apPersonService.resolvePerson(remoteMoveTo.uri);
- // only allow moving to a remote account
- if (this.userEntityService.isLocalUser(moveTo)) throw new ApiError(meta.errors.notRemote);
+ moveTo = await this.apPersonService.resolvePerson(newUri);
- let allowed = false;
-
- const fromUrl = `${this.config.url}/users/${me.id}`;
// make sure that the user has indicated the old account as an alias
- moveTo.alsoKnownAs?.forEach((elem) => {
- if (fromUrl.includes(elem)) allowed = true;
- });
+ const fromUrl = this.userEntityService.genLocalUserUri(me.id);
+ let allowed = false;
+ if (moveTo.alsoKnownAs) {
+ for (const knownAs of moveTo.alsoKnownAs) {
+ if (knownAs.includes(fromUrl)) {
+ allowed = true;
+ break;
+ }
+ }
+ }
// abort if unintended
- if (!(allowed && moveTo.uri && fromUrl)) throw new ApiError(meta.errors.remoteAccountForbids);
+ if (!allowed || moveTo.movedToUri) throw new ApiError(meta.errors.destinationAccountForbids);
- return await this.accountMoveService.moveToRemote(me, moveTo);
+ return await this.accountMoveService.moveFromLocal(me, moveTo);
});
}
}
diff --git a/packages/backend/src/server/api/endpoints/i/notifications.ts b/packages/backend/src/server/api/endpoints/i/notifications.ts
index ba0487f223..e141be764a 100644
--- a/packages/backend/src/server/api/endpoints/i/notifications.ts
+++ b/packages/backend/src/server/api/endpoints/i/notifications.ts
@@ -1,5 +1,5 @@
import { Brackets, In } from 'typeorm';
-import Redis from 'ioredis';
+import * as Redis from 'ioredis';
import { Inject, Injectable } from '@nestjs/common';
import type { UsersRepository, FollowingsRepository, MutingsRepository, UserProfilesRepository, NotesRepository } from '@/models/index.js';
import { obsoleteNotificationTypes, notificationTypes } from '@/types.js';
diff --git a/packages/backend/src/server/api/endpoints/i/pin.ts b/packages/backend/src/server/api/endpoints/i/pin.ts
index d4af00027e..2293500945 100644
--- a/packages/backend/src/server/api/endpoints/i/pin.ts
+++ b/packages/backend/src/server/api/endpoints/i/pin.ts
@@ -8,6 +8,7 @@ export const meta = {
tags: ['account', 'notes'],
requireCredential: true,
+ prohibitMoved: true,
kind: 'write:account',
diff --git a/packages/backend/src/server/api/endpoints/i/update.ts b/packages/backend/src/server/api/endpoints/i/update.ts
index be1c72b207..6c66300bb7 100644
--- a/packages/backend/src/server/api/endpoints/i/update.ts
+++ b/packages/backend/src/server/api/endpoints/i/update.ts
@@ -3,6 +3,7 @@ import * as mfm from 'mfm-js';
import { Inject, Injectable } from '@nestjs/common';
import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mfm.js';
import { extractHashtags } from '@/misc/extract-hashtags.js';
+import * as Acct from '@/misc/acct.js';
import type { UsersRepository, DriveFilesRepository, UserProfilesRepository, PagesRepository } from '@/models/index.js';
import type { User } from '@/models/entities/User.js';
import { birthdaySchema, descriptionSchema, locationSchema, nameSchema } from '@/models/entities/User.js';
@@ -19,7 +20,10 @@ import { HashtagService } from '@/core/HashtagService.js';
import { DI } from '@/di-symbols.js';
import { RoleService } from '@/core/RoleService.js';
import { CacheService } from '@/core/CacheService.js';
+import { AccountMoveService } from '@/core/AccountMoveService.js';
+import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js';
import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js';
+import { ApiLoggerService } from '../../ApiLoggerService.js';
import { ApiError } from '../../error.js';
export const meta = {
@@ -71,6 +75,30 @@ export const meta = {
code: 'TOO_MANY_MUTED_WORDS',
id: '010665b1-a211-42d2-bc64-8f6609d79785',
},
+
+ noSuchUser: {
+ message: 'No such user.',
+ code: 'NO_SUCH_USER',
+ id: 'fcd2eef9-a9b2-4c4f-8624-038099e90aa5',
+ },
+
+ uriNull: {
+ message: 'User ActivityPup URI is null.',
+ code: 'URI_NULL',
+ id: 'bf326f31-d430-4f97-9933-5d61e4d48a23',
+ },
+
+ forbiddenToSetYourself: {
+ message: 'You can\'t set yourself as your own alias.',
+ code: 'FORBIDDEN_TO_SET_YOURSELF',
+ id: '25c90186-4ab0-49c8-9bba-a1fa6c202ba4',
+ },
+
+ restrictedByRole: {
+ message: 'This feature is restricted by your role.',
+ code: 'RESTRICTED_BY_ROLE',
+ id: '8feff0ba-5ab5-585b-31f4-4df816663fad',
+ }
},
res: {
@@ -129,6 +157,12 @@ export const paramDef = {
emailNotificationTypes: { type: 'array', items: {
type: 'string',
} },
+ alsoKnownAs: {
+ type: 'array',
+ maxItems: 10,
+ uniqueItems: true,
+ items: { type: 'string' },
+ },
},
} as const;
@@ -153,6 +187,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
private globalEventService: GlobalEventService,
private userFollowingService: UserFollowingService,
private accountUpdateService: AccountUpdateService,
+ private accountMoveService: AccountMoveService,
+ private remoteUserResolveService: RemoteUserResolveService,
+ private apiLoggerService: ApiLoggerService,
private hashtagService: HashtagService,
private roleService: RoleService,
private cacheService: CacheService,
@@ -208,7 +245,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
if (typeof ps.isCat === 'boolean') updates.isCat = ps.isCat;
if (typeof ps.injectFeaturedNote === 'boolean') profileUpdates.injectFeaturedNote = ps.injectFeaturedNote;
if (typeof ps.receiveAnnouncementEmail === 'boolean') profileUpdates.receiveAnnouncementEmail = ps.receiveAnnouncementEmail;
- if (typeof ps.alwaysMarkNsfw === 'boolean') profileUpdates.alwaysMarkNsfw = ps.alwaysMarkNsfw;
+ if (typeof ps.alwaysMarkNsfw === 'boolean') {
+ if ((await roleService.getUserPolicies(user.id)).alwaysMarkNsfw) throw new ApiError(meta.errors.restrictedByRole);
+ profileUpdates.alwaysMarkNsfw = ps.alwaysMarkNsfw;
+ }
if (typeof ps.autoSensitive === 'boolean') profileUpdates.autoSensitive = ps.autoSensitive;
if (ps.emailNotificationTypes !== undefined) profileUpdates.emailNotificationTypes = ps.emailNotificationTypes;
@@ -221,6 +261,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
updates.avatarId = avatar.id;
updates.avatarUrl = this.driveFileEntityService.getPublicUrl(avatar, 'avatar');
updates.avatarBlurhash = avatar.blurhash;
+ } else if (ps.avatarId === null) {
+ updates.avatarId = null;
+ updates.avatarUrl = null;
+ updates.avatarBlurhash = null;
}
if (ps.bannerId) {
@@ -232,6 +276,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
updates.bannerId = banner.id;
updates.bannerUrl = this.driveFileEntityService.getPublicUrl(banner);
updates.bannerBlurhash = banner.blurhash;
+ } else if (ps.bannerId === null) {
+ updates.bannerId = null;
+ updates.bannerUrl = null;
+ updates.bannerBlurhash = null;
}
if (ps.pinnedPageId) {
@@ -252,6 +300,38 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
});
}
+ if (ps.alsoKnownAs) {
+ if (_user.movedToUri) {
+ throw new ApiError({
+ message: 'You have moved your account.',
+ code: 'YOUR_ACCOUNT_MOVED',
+ id: '56f20ec9-fd06-4fa5-841b-edd6d7d4fa31',
+ httpStatusCode: 403,
+ });
+ }
+
+ // Parse user's input into the old account
+ const newAlsoKnownAs = new Set<string>();
+ for (const line of ps.alsoKnownAs) {
+ if (!line) throw new ApiError(meta.errors.noSuchUser);
+ const { username, host } = Acct.parse(line);
+
+ // Retrieve the old account
+ const knownAs = await this.remoteUserResolveService.resolveUser(username, host).catch((e) => {
+ this.apiLoggerService.logger.warn(`failed to resolve dstination user: ${e}`);
+ throw new ApiError(meta.errors.noSuchUser);
+ });
+ if (knownAs.id === _user.id) throw new ApiError(meta.errors.forbiddenToSetYourself);
+
+ const toUrl = this.userEntityService.getUserUri(knownAs);
+ if (!toUrl) throw new ApiError(meta.errors.uriNull);
+
+ newAlsoKnownAs.add(toUrl);
+ }
+
+ updates.alsoKnownAs = newAlsoKnownAs.size > 0 ? Array.from(newAlsoKnownAs) : null;
+ }
+
//#region emojis/tags
let emojis = [] as string[];
@@ -279,6 +359,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
//#endregion
if (Object.keys(updates).length > 0) await this.usersRepository.update(user.id, updates);
+ if (Object.keys(updates).includes('alsoKnownAs')) {
+ this.cacheService.uriPersonCache.set(this.userEntityService.genLocalUserUri(user.id), { ...user, ...updates });
+ }
if (Object.keys(profileUpdates).length > 0) await this.userProfilesRepository.update(user.id, profileUpdates);
const iObj = await this.userEntityService.pack<true, true>(user.id, user, {
diff --git a/packages/backend/src/server/api/endpoints/meta.ts b/packages/backend/src/server/api/endpoints/meta.ts
index 37974ce2a3..584ea07c3b 100644
--- a/packages/backend/src/server/api/endpoints/meta.ts
+++ b/packages/backend/src/server/api/endpoints/meta.ts
@@ -201,10 +201,6 @@ export const meta = {
type: 'boolean',
optional: false, nullable: false,
},
- elasticsearch: {
- type: 'boolean',
- optional: false, nullable: false,
- },
hcaptcha: {
type: 'boolean',
optional: false, nullable: false,
@@ -310,6 +306,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
translatorAvailable: instance.deeplAuthKey != null,
+ serverRules: instance.serverRules,
+
policies: { ...DEFAULT_POLICIES, ...instance.policies },
mediaProxy: this.config.mediaProxy,
@@ -329,7 +327,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
response.features = {
registration: !instance.disableRegistration,
emailRequiredForSignup: instance.emailRequiredForSignup,
- elasticsearch: this.config.elasticsearch ? true : false,
hcaptcha: instance.enableHcaptcha,
recaptcha: instance.enableRecaptcha,
turnstile: instance.enableTurnstile,
diff --git a/packages/backend/src/server/api/endpoints/mute/create.ts b/packages/backend/src/server/api/endpoints/mute/create.ts
index 6e24e1024d..ee358d5c6c 100644
--- a/packages/backend/src/server/api/endpoints/mute/create.ts
+++ b/packages/backend/src/server/api/endpoints/mute/create.ts
@@ -11,6 +11,7 @@ export const meta = {
tags: ['account'],
requireCredential: true,
+ prohibitMoved: true,
kind: 'write:mutes',
diff --git a/packages/backend/src/server/api/endpoints/notes/create.ts b/packages/backend/src/server/api/endpoints/notes/create.ts
index 69fafcb9c7..3f7f2cdece 100644
--- a/packages/backend/src/server/api/endpoints/notes/create.ts
+++ b/packages/backend/src/server/api/endpoints/notes/create.ts
@@ -18,6 +18,8 @@ export const meta = {
requireCredential: true,
+ prohibitMoved: true,
+
limit: {
duration: ms('1hour'),
max: 300,
@@ -260,7 +262,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
let channel: Channel | null = null;
if (ps.channelId != null) {
- channel = await this.channelsRepository.findOneBy({ id: ps.channelId });
+ channel = await this.channelsRepository.findOneBy({ id: ps.channelId, isArchived: false });
if (channel == null) {
throw new ApiError(meta.errors.noSuchChannel);
diff --git a/packages/backend/src/server/api/endpoints/notes/favorites/create.ts b/packages/backend/src/server/api/endpoints/notes/favorites/create.ts
index 0ce80a1a63..611ea19560 100644
--- a/packages/backend/src/server/api/endpoints/notes/favorites/create.ts
+++ b/packages/backend/src/server/api/endpoints/notes/favorites/create.ts
@@ -12,6 +12,7 @@ export const meta = {
tags: ['notes', 'favorites'],
requireCredential: true,
+ prohibitMoved: true,
kind: 'write:favorites',
diff --git a/packages/backend/src/server/api/endpoints/notes/polls/vote.ts b/packages/backend/src/server/api/endpoints/notes/polls/vote.ts
index 2a44dc537e..3a33b037f8 100644
--- a/packages/backend/src/server/api/endpoints/notes/polls/vote.ts
+++ b/packages/backend/src/server/api/endpoints/notes/polls/vote.ts
@@ -17,6 +17,8 @@ export const meta = {
requireCredential: true,
+ prohibitMoved: true,
+
kind: 'write:votes',
errors: {
diff --git a/packages/backend/src/server/api/endpoints/notes/reactions/create.ts b/packages/backend/src/server/api/endpoints/notes/reactions/create.ts
index 04e374d1ae..97cb026779 100644
--- a/packages/backend/src/server/api/endpoints/notes/reactions/create.ts
+++ b/packages/backend/src/server/api/endpoints/notes/reactions/create.ts
@@ -9,6 +9,8 @@ export const meta = {
requireCredential: true,
+ prohibitMoved: true,
+
kind: 'write:reactions',
errors: {
diff --git a/packages/backend/src/server/api/endpoints/notes/search.ts b/packages/backend/src/server/api/endpoints/notes/search.ts
index fb5abd917f..f6385400c3 100644
--- a/packages/backend/src/server/api/endpoints/notes/search.ts
+++ b/packages/backend/src/server/api/endpoints/notes/search.ts
@@ -1,11 +1,10 @@
import { Inject, Injectable } from '@nestjs/common';
import type { NotesRepository } from '@/models/index.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
-import { QueryService } from '@/core/QueryService.js';
+import { SearchService } from '@/core/SearchService.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import type { Config } from '@/config.js';
import { DI } from '@/di-symbols.js';
-import { sqlLikeEscape } from '@/misc/sql-like-escape.js';
import { RoleService } from '@/core/RoleService.js';
import { ApiError } from '../../error.js';
@@ -43,8 +42,7 @@ export const paramDef = {
offset: { type: 'integer', default: 0 },
host: {
type: 'string',
- nullable: true,
- description: 'The local host is represented with `null`.',
+ description: 'The local host is represented with `.`.',
},
userId: { type: 'string', format: 'misskey:id', nullable: true, default: null },
channelId: { type: 'string', format: 'misskey:id', nullable: true, default: null },
@@ -61,11 +59,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
@Inject(DI.config)
private config: Config,
- @Inject(DI.notesRepository)
- private notesRepository: NotesRepository,
-
private noteEntityService: NoteEntityService,
- private queryService: QueryService,
+ private searchService: SearchService,
private roleService: RoleService,
) {
super(meta, paramDef, async (ps, me) => {
@@ -74,27 +69,15 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
throw new ApiError(meta.errors.unavailable);
}
- const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId);
-
- if (ps.userId) {
- query.andWhere('note.userId = :userId', { userId: ps.userId });
- } else if (ps.channelId) {
- query.andWhere('note.channelId = :channelId', { channelId: ps.channelId });
- }
-
- query
- .andWhere('note.text ILIKE :q', { q: `%${ sqlLikeEscape(ps.query) }%` })
- .innerJoinAndSelect('note.user', 'user')
- .leftJoinAndSelect('note.reply', 'reply')
- .leftJoinAndSelect('note.renote', 'renote')
- .leftJoinAndSelect('reply.user', 'replyUser')
- .leftJoinAndSelect('renote.user', 'renoteUser');
-
- this.queryService.generateVisibilityQuery(query, me);
- if (me) this.queryService.generateMutedUserQuery(query, me);
- if (me) this.queryService.generateBlockedUserQuery(query, me);
-
- const notes = await query.take(ps.limit).getMany();
+ const notes = await this.searchService.searchNote(ps.query, me, {
+ userId: ps.userId,
+ channelId: ps.channelId,
+ host: ps.host,
+ }, {
+ untilId: ps.untilId,
+ sinceId: ps.sinceId,
+ limit: ps.limit,
+ });
return await this.noteEntityService.packMany(notes, me);
});
diff --git a/packages/backend/src/server/api/endpoints/pages/create.ts b/packages/backend/src/server/api/endpoints/pages/create.ts
index 4015bf1f29..e08ab399f8 100644
--- a/packages/backend/src/server/api/endpoints/pages/create.ts
+++ b/packages/backend/src/server/api/endpoints/pages/create.ts
@@ -13,6 +13,8 @@ export const meta = {
requireCredential: true,
+ prohibitMoved: true,
+
kind: 'write:pages',
limit: {
diff --git a/packages/backend/src/server/api/endpoints/pages/like.ts b/packages/backend/src/server/api/endpoints/pages/like.ts
index d27990f7e1..543c126d9c 100644
--- a/packages/backend/src/server/api/endpoints/pages/like.ts
+++ b/packages/backend/src/server/api/endpoints/pages/like.ts
@@ -10,6 +10,8 @@ export const meta = {
requireCredential: true,
+ prohibitMoved: true,
+
kind: 'write:page-likes',
errors: {
diff --git a/packages/backend/src/server/api/endpoints/pages/unlike.ts b/packages/backend/src/server/api/endpoints/pages/unlike.ts
index e397e2a23b..f0c0198460 100644
--- a/packages/backend/src/server/api/endpoints/pages/unlike.ts
+++ b/packages/backend/src/server/api/endpoints/pages/unlike.ts
@@ -9,6 +9,8 @@ export const meta = {
requireCredential: true,
+ prohibitMoved: true,
+
kind: 'write:page-likes',
errors: {
diff --git a/packages/backend/src/server/api/endpoints/pages/update.ts b/packages/backend/src/server/api/endpoints/pages/update.ts
index 35b402ec56..751274067e 100644
--- a/packages/backend/src/server/api/endpoints/pages/update.ts
+++ b/packages/backend/src/server/api/endpoints/pages/update.ts
@@ -11,6 +11,8 @@ export const meta = {
requireCredential: true,
+ prohibitMoved: true,
+
kind: 'write:pages',
limit: {
diff --git a/packages/backend/src/server/api/endpoints/renote-mute/create.ts b/packages/backend/src/server/api/endpoints/renote-mute/create.ts
index b285269617..beb5850d78 100644
--- a/packages/backend/src/server/api/endpoints/renote-mute/create.ts
+++ b/packages/backend/src/server/api/endpoints/renote-mute/create.ts
@@ -13,6 +13,7 @@ export const meta = {
tags: ['account'],
requireCredential: true,
+ prohibitMoved: true,
kind: 'write:mutes',
diff --git a/packages/backend/src/server/api/endpoints/reset-db.ts b/packages/backend/src/server/api/endpoints/reset-db.ts
index 655dd7cd83..4ced6d3ff1 100644
--- a/packages/backend/src/server/api/endpoints/reset-db.ts
+++ b/packages/backend/src/server/api/endpoints/reset-db.ts
@@ -1,6 +1,6 @@
import { Inject, Injectable } from '@nestjs/common';
import { DataSource } from 'typeorm';
-import Redis from 'ioredis';
+import * as Redis from 'ioredis';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { DI } from '@/di-symbols.js';
import { resetDb } from '@/misc/reset-db.js';
diff --git a/packages/backend/src/server/api/endpoints/roles/notes.ts b/packages/backend/src/server/api/endpoints/roles/notes.ts
index b45d4af1fe..6202c740f1 100644
--- a/packages/backend/src/server/api/endpoints/roles/notes.ts
+++ b/packages/backend/src/server/api/endpoints/roles/notes.ts
@@ -1,5 +1,5 @@
import { Inject, Injectable } from '@nestjs/common';
-import Redis from 'ioredis';
+import * as Redis from 'ioredis';
import { Endpoint } from '@/server/api/endpoint-base.js';
import type { NotesRepository, RolesRepository } from '@/models/index.js';
import { QueryService } from '@/core/QueryService.js';
@@ -65,12 +65,15 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
super(meta, paramDef, async (ps, me) => {
const role = await this.rolesRepository.findOneBy({
id: ps.roleId,
+ isPublic: true,
});
if (role == null) {
throw new ApiError(meta.errors.noSuchRole);
}
-
+ if (!role.isExplorable) {
+ return [];
+ }
const limit = ps.limit + (ps.untilId ? 1 : 0) + (ps.sinceId ? 1 : 0); // untilIdに指定したものも含まれるため+1
const noteIdsRes = await this.redisClient.xrevrange(
`roleTimeline:${role.id}`,
diff --git a/packages/backend/src/server/api/endpoints/username/available.ts b/packages/backend/src/server/api/endpoints/username/available.ts
index c80b6efdcd..6293c5cb50 100644
--- a/packages/backend/src/server/api/endpoints/username/available.ts
+++ b/packages/backend/src/server/api/endpoints/username/available.ts
@@ -4,6 +4,7 @@ import type { UsedUsernamesRepository, UsersRepository } from '@/models/index.js
import { Endpoint } from '@/server/api/endpoint-base.js';
import { localUsernameSchema } from '@/models/entities/User.js';
import { DI } from '@/di-symbols.js';
+import { MetaService } from '@/core/MetaService.js';
export const meta = {
tags: ['users'],
@@ -39,9 +40,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
@Inject(DI.usedUsernamesRepository)
private usedUsernamesRepository: UsedUsernamesRepository,
+
+ private metaService: MetaService,
) {
super(meta, paramDef, async (ps, me) => {
- // Get exist
const exist = await this.usersRepository.countBy({
host: IsNull(),
usernameLower: ps.username.toLowerCase(),
@@ -49,8 +51,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
const exist2 = await this.usedUsernamesRepository.countBy({ username: ps.username.toLowerCase() });
+ const meta = await this.metaService.fetch();
+ const isPreserved = meta.preservedUsernames.map(x => x.toLowerCase()).includes(ps.username.toLowerCase());
+
return {
- available: exist === 0 && exist2 === 0,
+ available: exist === 0 && exist2 === 0 && !isPreserved,
};
});
}
diff --git a/packages/backend/src/server/api/endpoints/users.ts b/packages/backend/src/server/api/endpoints/users.ts
index 8becb68a34..28cd9f6ce5 100644
--- a/packages/backend/src/server/api/endpoints/users.ts
+++ b/packages/backend/src/server/api/endpoints/users.ts
@@ -50,8 +50,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
private queryService: QueryService,
) {
super(meta, paramDef, async (ps, me) => {
- const query = this.usersRepository.createQueryBuilder('user');
- query.where('user.isExplorable = TRUE');
+ const query = this.usersRepository.createQueryBuilder('user')
+ .where('user.isExplorable = TRUE')
+ .andWhere('user.isSuspended = FALSE');
switch (ps.state) {
case 'alive': query.andWhere('user.updatedAt > :date', { date: new Date(Date.now() - 1000 * 60 * 60 * 24 * 5) }); break;
diff --git a/packages/backend/src/server/api/endpoints/users/lists/create.ts b/packages/backend/src/server/api/endpoints/users/lists/create.ts
index a840c1a04e..7510889526 100644
--- a/packages/backend/src/server/api/endpoints/users/lists/create.ts
+++ b/packages/backend/src/server/api/endpoints/users/lists/create.ts
@@ -13,6 +13,8 @@ export const meta = {
requireCredential: true,
+ prohibitMoved: true,
+
kind: 'write:account',
description: 'Create a new list of users.',
@@ -58,7 +60,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
if (currentCount > (await this.roleService.getUserPolicies(me.id)).userListLimit) {
throw new ApiError(meta.errors.tooManyUserLists);
}
-
+
const userList = await this.userListsRepository.insert({
id: this.idService.genId(),
createdAt: new Date(),
diff --git a/packages/backend/src/server/api/endpoints/users/lists/pull.ts b/packages/backend/src/server/api/endpoints/users/lists/pull.ts
index d2dd5731ee..d50b70efc2 100644
--- a/packages/backend/src/server/api/endpoints/users/lists/pull.ts
+++ b/packages/backend/src/server/api/endpoints/users/lists/pull.ts
@@ -12,6 +12,8 @@ export const meta = {
requireCredential: true,
+ prohibitMoved: true,
+
kind: 'write:account',
description: 'Remove a user from a list.',
diff --git a/packages/backend/src/server/api/endpoints/users/lists/push.ts b/packages/backend/src/server/api/endpoints/users/lists/push.ts
index 1c1fdc23f1..925037e484 100644
--- a/packages/backend/src/server/api/endpoints/users/lists/push.ts
+++ b/packages/backend/src/server/api/endpoints/users/lists/push.ts
@@ -12,6 +12,8 @@ export const meta = {
requireCredential: true,
+ prohibitMoved: true,
+
kind: 'write:account',
description: 'Add a user to an existing list.',
diff --git a/packages/backend/src/server/api/endpoints/users/update-memo.ts b/packages/backend/src/server/api/endpoints/users/update-memo.ts
new file mode 100644
index 0000000000..ca7756ef75
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/users/update-memo.ts
@@ -0,0 +1,85 @@
+import { Inject, Injectable } from '@nestjs/common';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { IdService } from '@/core/IdService.js';
+import type { UserMemoRepository } from '@/models/index.js';
+import { DI } from '@/di-symbols.js';
+import { GetterService } from '@/server/api/GetterService.js';
+import { ApiError } from '../../error.js';
+
+export const meta = {
+ tags: ['account'],
+
+ requireCredential: true,
+
+ kind: 'write:account',
+
+ errors: {
+ noSuchUser: {
+ message: 'No such user.',
+ code: 'NO_SUCH_USER',
+ id: '6fef56f3-e765-4957-88e5-c6f65329b8a5',
+ },
+ },
+} as const;
+
+export const paramDef = {
+ type: 'object',
+ properties: {
+ userId: { type: 'string', format: 'misskey:id' },
+ memo: {
+ type: 'string',
+ nullable: true,
+ description: 'A personal memo for the target user. If null or empty, delete the memo.',
+ },
+ },
+ required: ['userId', 'memo'],
+} as const;
+
+// eslint-disable-next-line import/no-default-export
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> {
+ constructor(
+ @Inject(DI.userMemosRepository)
+ private userMemosRepository: UserMemoRepository,
+ private getterService: GetterService,
+ private idService: IdService,
+ ) {
+ super(meta, paramDef, async (ps, me) => {
+ // Get target
+ const target = await this.getterService.getUser(ps.userId).catch(err => {
+ if (err.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser);
+ throw err;
+ });
+
+ // 引数がnullか空文字であれば、パーソナルメモを削除する
+ if (ps.memo === '' || ps.memo == null) {
+ await this.userMemosRepository.delete({
+ userId: me.id,
+ targetUserId: target.id,
+ });
+ return;
+ }
+
+ // 以前に作成されたパーソナルメモがあるかどうか確認
+ const previousMemo = await this.userMemosRepository.findOneBy({
+ userId: me.id,
+ targetUserId: target.id,
+ });
+
+ if (!previousMemo) {
+ await this.userMemosRepository.insert({
+ id: this.idService.genId(),
+ userId: me.id,
+ targetUserId: target.id,
+ memo: ps.memo,
+ });
+ } else {
+ await this.userMemosRepository.update(previousMemo.id, {
+ userId: me.id,
+ targetUserId: target.id,
+ memo: ps.memo,
+ });
+ }
+ });
+ }
+}
diff --git a/packages/backend/src/server/web/UrlPreviewService.ts b/packages/backend/src/server/web/UrlPreviewService.ts
index b3e193cd34..e61e92c623 100644
--- a/packages/backend/src/server/web/UrlPreviewService.ts
+++ b/packages/backend/src/server/web/UrlPreviewService.ts
@@ -70,10 +70,10 @@ export class UrlPreviewService {
await summaly(url, {
followRedirects: false,
lang: lang ?? 'ja-JP',
- agent: {
+ agent: this.config.proxy ? {
http: this.httpRequestService.httpAgent,
https: this.httpRequestService.httpsAgent,
- },
+ } : undefined,
});
this.logger.succ(`Got preview of ${url}: ${summary.title}`);
diff --git a/packages/backend/src/server/web/views/base.pug b/packages/backend/src/server/web/views/base.pug
index d772ac3184..cb5d05a403 100644
--- a/packages/backend/src/server/web/views/base.pug
+++ b/packages/backend/src/server/web/views/base.pug
@@ -36,7 +36,7 @@ html
link(rel='prefetch' href='https://xn--931a.moe/assets/not-found.jpg')
link(rel='prefetch' href='https://xn--931a.moe/assets/error.jpg')
//- https://github.com/misskey-dev/misskey/issues/9842
- link(rel='stylesheet' href='/assets/tabler-icons/tabler-icons.min.css?v2.12.0')
+ link(rel='stylesheet' href='/assets/tabler-icons/tabler-icons.min.css?v2.17.0')
link(rel='modulepreload' href=`/vite/${clientEntry.file}`)
if !config.clientManifestExists
diff --git a/packages/backend/test/e2e/endpoints.ts b/packages/backend/test/e2e/endpoints.ts
index afb72c84d4..f885209b7f 100644
--- a/packages/backend/test/e2e/endpoints.ts
+++ b/packages/backend/test/e2e/endpoints.ts
@@ -4,8 +4,9 @@ import * as assert from 'assert';
// node-fetch only supports it's own Blob yet
// https://github.com/node-fetch/node-fetch/pull/1664
import { Blob } from 'node-fetch';
-import { startServer, signup, post, api, uploadFile, simpleGet } from '../utils.js';
+import { startServer, signup, post, api, uploadFile, simpleGet, initTestDb } from '../utils.js';
import type { INestApplicationContext } from '@nestjs/common';
+import { User } from '@/models/index.js';
describe('Endpoints', () => {
let app: INestApplicationContext;
@@ -289,6 +290,16 @@ describe('Endpoints', () => {
}, bob);
assert.strictEqual(res.status, 200);
+
+ const connection = await initTestDb(true);
+ const Users = connection.getRepository(User);
+ const newBob = await Users.findOneByOrFail({ id: bob.id });
+ assert.strictEqual(newBob.followersCount, 0);
+ assert.strictEqual(newBob.followingCount, 1);
+ const newAlice = await Users.findOneByOrFail({ id: alice.id });
+ assert.strictEqual(newAlice.followersCount, 1);
+ assert.strictEqual(newAlice.followingCount, 0);
+ connection.destroy();
});
test('既にフォローしている場合は怒る', async () => {
@@ -341,6 +352,16 @@ describe('Endpoints', () => {
}, bob);
assert.strictEqual(res.status, 200);
+
+ const connection = await initTestDb(true);
+ const Users = connection.getRepository(User);
+ const newBob = await Users.findOneByOrFail({ id: bob.id });
+ assert.strictEqual(newBob.followersCount, 0);
+ assert.strictEqual(newBob.followingCount, 0);
+ const newAlice = await Users.findOneByOrFail({ id: alice.id });
+ assert.strictEqual(newAlice.followersCount, 0);
+ assert.strictEqual(newAlice.followingCount, 0);
+ connection.destroy();
});
test('フォローしていない場合は怒る', async () => {
@@ -382,6 +403,100 @@ describe('Endpoints', () => {
});
});
+ describe('channels/search', () => {
+ test('空白検索で一覧を取得できる', async () => {
+ await api('/channels/create', {
+ name: 'aaa',
+ description: 'bbb',
+ }, bob);
+ await api('/channels/create', {
+ name: 'ccc1',
+ description: 'ddd1',
+ }, bob);
+ await api('/channels/create', {
+ name: 'ccc2',
+ description: 'ddd2',
+ }, bob);
+
+ const res = await api('/channels/search', {
+ query: '',
+ }, bob);
+
+ assert.strictEqual(res.status, 200);
+ assert.strictEqual(typeof res.body === 'object' && Array.isArray(res.body), true);
+ assert.strictEqual(res.body.length, 3);
+ });
+ test('名前のみの検索で名前を検索できる', async () => {
+ const res = await api('/channels/search', {
+ query: 'aaa',
+ type: 'nameOnly',
+ }, bob);
+
+ assert.strictEqual(res.status, 200);
+ assert.strictEqual(typeof res.body === 'object' && Array.isArray(res.body), true);
+ assert.strictEqual(res.body.length, 1);
+ assert.strictEqual(res.body[0].name, 'aaa');
+ });
+ test('名前のみの検索で名前を複数検索できる', async () => {
+ const res = await api('/channels/search', {
+ query: 'ccc',
+ type: 'nameOnly',
+ }, bob);
+
+ assert.strictEqual(res.status, 200);
+ assert.strictEqual(typeof res.body === 'object' && Array.isArray(res.body), true);
+ assert.strictEqual(res.body.length, 2);
+ });
+ test('名前のみの検索で説明は検索できない', async () => {
+ const res = await api('/channels/search', {
+ query: 'bbb',
+ type: 'nameOnly',
+ }, bob);
+
+ assert.strictEqual(res.status, 200);
+ assert.strictEqual(typeof res.body === 'object' && Array.isArray(res.body), true);
+ assert.strictEqual(res.body.length, 0);
+ });
+ test('名前と説明の検索で名前を検索できる', async () => {
+ const res = await api('/channels/search', {
+ query: 'ccc1',
+ }, bob);
+
+ assert.strictEqual(res.status, 200);
+ assert.strictEqual(typeof res.body === 'object' && Array.isArray(res.body), true);
+ assert.strictEqual(res.body.length, 1);
+ assert.strictEqual(res.body[0].name, 'ccc1');
+ });
+ test('名前と説明での検索で説明を検索できる', async () => {
+ const res = await api('/channels/search', {
+ query: 'ddd1',
+ }, bob);
+
+ assert.strictEqual(res.status, 200);
+ assert.strictEqual(typeof res.body === 'object' && Array.isArray(res.body), true);
+ assert.strictEqual(res.body.length, 1);
+ assert.strictEqual(res.body[0].name, 'ccc1');
+ });
+ test('名前と説明の検索で名前を複数検索できる', async () => {
+ const res = await api('/channels/search', {
+ query: 'ccc',
+ }, bob);
+
+ assert.strictEqual(res.status, 200);
+ assert.strictEqual(typeof res.body === 'object' && Array.isArray(res.body), true);
+ assert.strictEqual(res.body.length, 2);
+ });
+ test('名前と説明での検索で説明を複数検索できる', async () => {
+ const res = await api('/channels/search', {
+ query: 'ddd',
+ }, bob);
+
+ assert.strictEqual(res.status, 200);
+ assert.strictEqual(typeof res.body === 'object' && Array.isArray(res.body), true);
+ assert.strictEqual(res.body.length, 2);
+ });
+ });
+
describe('drive', () => {
test('ドライブ情報を取得できる', async () => {
await uploadFile(alice, {
@@ -849,4 +964,85 @@ describe('Endpoints', () => {
assert.strictEqual(res.body.error.code, 'URL_PREVIEW_FAILED');
});
});
+
+ describe('パーソナルメモ機能のテスト', () => {
+ test('他者に関するメモを更新できる', async () => {
+ const memo = '10月まで低浮上とのこと。';
+
+ const res1 = await api('/users/update-memo', {
+ memo,
+ userId: bob.id,
+ }, alice);
+
+ const res2 = await api('/users/show', {
+ userId: bob.id,
+ }, alice);
+ assert.strictEqual(res1.status, 204);
+ assert.strictEqual(res2.body?.memo, memo);
+ });
+
+ test('自分に関するメモを更新できる', async () => {
+ const memo = 'チケットを月末までに買う。';
+
+ const res1 = await api('/users/update-memo', {
+ memo,
+ userId: alice.id,
+ }, alice);
+
+ const res2 = await api('/users/show', {
+ userId: alice.id,
+ }, alice);
+ assert.strictEqual(res1.status, 204);
+ assert.strictEqual(res2.body?.memo, memo);
+ });
+
+ test('メモを削除できる', async () => {
+ const memo = '10月まで低浮上とのこと。';
+
+ await api('/users/update-memo', {
+ memo,
+ userId: bob.id,
+ }, alice);
+
+ await api('/users/update-memo', {
+ memo: '',
+ userId: bob.id,
+ }, alice);
+
+ const res = await api('/users/show', {
+ userId: bob.id,
+ }, alice);
+
+ // memoには常に文字列かnullが入っている(5cac151)
+ assert.strictEqual(res.body.memo, null);
+ });
+
+ test('メモは個人ごとに独立して保存される', async () => {
+ const memoAliceToBob = '10月まで低浮上とのこと。';
+ const memoCarolToBob = '例の件について今度問いただす。';
+
+ await Promise.all([
+ api('/users/update-memo', {
+ memo: memoAliceToBob,
+ userId: bob.id,
+ }, alice),
+ api('/users/update-memo', {
+ memo: memoCarolToBob,
+ userId: bob.id,
+ }, carol),
+ ]);
+
+ const [resAlice, resCarol] = await Promise.all([
+ api('/users/show', {
+ userId: bob.id,
+ }, alice),
+ api('/users/show', {
+ userId: bob.id,
+ }, carol),
+ ]);
+
+ assert.strictEqual(resAlice.body.memo, memoAliceToBob);
+ assert.strictEqual(resCarol.body.memo, memoCarolToBob);
+ });
+ });
});
diff --git a/packages/backend/test/e2e/move.ts b/packages/backend/test/e2e/move.ts
new file mode 100644
index 0000000000..7d6c646090
--- /dev/null
+++ b/packages/backend/test/e2e/move.ts
@@ -0,0 +1,456 @@
+process.env.NODE_ENV = 'test';
+
+import * as assert from 'assert';
+import rndstr from 'rndstr';
+import { loadConfig } from '@/config.js';
+import { User, UsersRepository } from '@/models/index.js';
+import { jobQueue } from '@/boot/common.js';
+import { uploadFile, signup, startServer, initTestDb, api, sleep, successfulApiCall } from '../utils.js';
+import type { INestApplicationContext } from '@nestjs/common';
+
+describe('Account Move', () => {
+ let app: INestApplicationContext;
+ let jq: INestApplicationContext;
+ let url: URL;
+
+ let root: any;
+ let alice: any;
+ let bob: any;
+ let carol: any;
+ let dave: any;
+ let eve: any;
+ let frank: any;
+
+ let Users: UsersRepository;
+
+ beforeAll(async () => {
+ app = await startServer();
+ jq = await jobQueue();
+ const config = loadConfig();
+ url = new URL(config.url);
+ const connection = await initTestDb(false);
+ root = await signup({ username: 'root' });
+ alice = await signup({ username: 'alice' });
+ bob = await signup({ username: 'bob' });
+ carol = await signup({ username: 'carol' });
+ dave = await signup({ username: 'dave' });
+ eve = await signup({ username: 'eve' });
+ frank = await signup({ username: 'frank' });
+ Users = connection.getRepository(User);
+ }, 1000 * 60 * 2);
+
+ afterAll(async () => {
+ await Promise.all([app.close(), jq.close()]);
+ });
+
+ describe('Create Alias', () => {
+ afterEach(async () => {
+ await Users.update(bob.id, { alsoKnownAs: null });
+ }, 1000 * 10);
+
+ test('Able to create an alias', async () => {
+ const res = await api('/i/update', {
+ alsoKnownAs: [`@alice@${url.hostname}`],
+ }, bob);
+
+ const newBob = await Users.findOneByOrFail({ id: bob.id });
+ assert.strictEqual(newBob.alsoKnownAs?.length, 1);
+ assert.strictEqual(newBob.alsoKnownAs[0], `${url.origin}/users/${alice.id}`);
+ assert.strictEqual(res.body.alsoKnownAs?.length, 1);
+ assert.strictEqual(res.body.alsoKnownAs[0], alice.id);
+ });
+
+ test('Able to create a local alias without hostname', async () => {
+ await api('/i/update', {
+ alsoKnownAs: ['@alice'],
+ }, bob);
+
+ const newBob = await Users.findOneByOrFail({ id: bob.id });
+ assert.strictEqual(newBob.alsoKnownAs?.length, 1);
+ assert.strictEqual(newBob.alsoKnownAs[0], `${url.origin}/users/${alice.id}`);
+ });
+
+ test('Able to create a local alias without @', async () => {
+ await api('/i/update', {
+ alsoKnownAs: ['alice'],
+ }, bob);
+
+ const newBob = await Users.findOneByOrFail({ id: bob.id });
+ assert.strictEqual(newBob.alsoKnownAs?.length, 1);
+ assert.strictEqual(newBob.alsoKnownAs[0], `${url.origin}/users/${alice.id}`);
+ });
+
+ test('Able to set remote user (but may fail)', async () => {
+ const res = await api('/i/update', {
+ alsoKnownAs: ['@syuilo@example.com'],
+ }, bob);
+
+ assert.strictEqual(res.status, 400);
+ assert.strictEqual(res.body.error.code, 'NO_SUCH_USER');
+ assert.strictEqual(res.body.error.id, 'fcd2eef9-a9b2-4c4f-8624-038099e90aa5');
+ });
+
+ test('Unable to add duplicated aliases to alsoKnownAs', async () => {
+ const res = await api('/i/update', {
+ alsoKnownAs: [`@alice@${url.hostname}`, `@alice@${url.hostname}`],
+ }, bob);
+
+ assert.strictEqual(res.status, 400);
+ assert.strictEqual(res.body.error.code, 'INVALID_PARAM');
+ assert.strictEqual(res.body.error.id, '3d81ceae-475f-4600-b2a8-2bc116157532');
+ });
+
+ test('Unable to add itself', async () => {
+ const res = await api('/i/update', {
+ alsoKnownAs: [`@bob@${url.hostname}`],
+ }, bob);
+
+ assert.strictEqual(res.status, 400);
+ assert.strictEqual(res.body.error.code, 'FORBIDDEN_TO_SET_YOURSELF');
+ assert.strictEqual(res.body.error.id, '25c90186-4ab0-49c8-9bba-a1fa6c202ba4');
+ });
+
+ test('Unable to add a nonexisting local account to alsoKnownAs', async () => {
+ const res1 = await api('/i/update', {
+ alsoKnownAs: [`@nonexist@${url.hostname}`],
+ }, bob);
+
+ assert.strictEqual(res1.status, 400);
+ assert.strictEqual(res1.body.error.code, 'NO_SUCH_USER');
+ assert.strictEqual(res1.body.error.id, 'fcd2eef9-a9b2-4c4f-8624-038099e90aa5');
+
+ const res2 = await api('/i/update', {
+ alsoKnownAs: ['@alice', 'nonexist'],
+ }, bob);
+
+ assert.strictEqual(res2.status, 400);
+ assert.strictEqual(res2.body.error.code, 'NO_SUCH_USER');
+ assert.strictEqual(res2.body.error.id, 'fcd2eef9-a9b2-4c4f-8624-038099e90aa5');
+ });
+
+ test('Able to add two existing local account to alsoKnownAs', async () => {
+ await api('/i/update', {
+ alsoKnownAs: [`@alice@${url.hostname}`, `@carol@${url.hostname}`],
+ }, bob);
+
+ const newBob = await Users.findOneByOrFail({ id: bob.id });
+ assert.strictEqual(newBob.alsoKnownAs?.length, 2);
+ assert.strictEqual(newBob.alsoKnownAs[0], `${url.origin}/users/${alice.id}`);
+ assert.strictEqual(newBob.alsoKnownAs[1], `${url.origin}/users/${carol.id}`);
+ });
+
+ test('Able to properly overwrite alsoKnownAs', async () => {
+ await api('/i/update', {
+ alsoKnownAs: [`@alice@${url.hostname}`],
+ }, bob);
+ await api('/i/update', {
+ alsoKnownAs: [`@carol@${url.hostname}`, `@dave@${url.hostname}`],
+ }, bob);
+
+ const newBob = await Users.findOneByOrFail({ id: bob.id });
+ assert.strictEqual(newBob.alsoKnownAs?.length, 2);
+ assert.strictEqual(newBob.alsoKnownAs[0], `${url.origin}/users/${carol.id}`);
+ assert.strictEqual(newBob.alsoKnownAs[1], `${url.origin}/users/${dave.id}`);
+ });
+ });
+
+ describe('Local to Local', () => {
+ let antennaId = '';
+
+ beforeAll(async () => {
+ await api('/i/update', {
+ alsoKnownAs: [`@alice@${url.hostname}`],
+ }, root);
+ const listRoot = await api('/users/lists/create', {
+ name: rndstr('0-9a-z', 8),
+ }, root);
+ await api('/users/lists/push', {
+ listId: listRoot.body.id,
+ userId: alice.id,
+ }, root);
+
+ await api('/following/create', {
+ userId: root.id,
+ }, alice);
+ await api('/following/create', {
+ userId: eve.id,
+ }, alice);
+ const antenna = await api('/antennas/create', {
+ name: rndstr('0-9a-z', 8),
+ src: 'home',
+ keywords: [rndstr('0-9a-z', 8)],
+ excludeKeywords: [],
+ users: [],
+ caseSensitive: false,
+ withReplies: false,
+ withFile: false,
+ notify: false,
+ }, alice);
+ antennaId = antenna.body.id;
+
+ await api('/i/update', {
+ alsoKnownAs: [`@alice@${url.hostname}`],
+ }, bob);
+
+ await api('/following/create', {
+ userId: alice.id,
+ }, carol);
+
+ await api('/mute/create', {
+ userId: alice.id,
+ }, dave);
+ await api('/blocking/create', {
+ userId: alice.id,
+ }, dave);
+ await api('/following/create', {
+ userId: eve.id,
+ }, dave);
+
+ await api('/following/create', {
+ userId: dave.id,
+ }, eve);
+ const listEve = await api('/users/lists/create', {
+ name: rndstr('0-9a-z', 8),
+ }, eve);
+ await api('/users/lists/push', {
+ listId: listEve.body.id,
+ userId: bob.id,
+ }, eve);
+
+ await api('/i/update', {
+ isLocked: true,
+ }, frank);
+ await api('/following/create', {
+ userId: frank.id,
+ }, alice);
+ await api('/following/requests/accept', {
+ userId: alice.id,
+ }, frank);
+ }, 1000 * 10);
+
+ test('Prohibit the root account from moving', async () => {
+ const res = await api('/i/move', {
+ moveToAccount: `@bob@${url.hostname}`,
+ }, root);
+
+ assert.strictEqual(res.status, 400);
+ assert.strictEqual(res.body.error.code, 'NOT_ROOT_FORBIDDEN');
+ assert.strictEqual(res.body.error.id, '4362e8dc-731f-4ad8-a694-be2a88922a24');
+ });
+
+ test('Unable to move to a nonexisting local account', async () => {
+ const res = await api('/i/move', {
+ moveToAccount: `@nonexist@${url.hostname}`,
+ }, alice);
+
+ assert.strictEqual(res.status, 400);
+ assert.strictEqual(res.body.error.code, 'NO_SUCH_USER');
+ assert.strictEqual(res.body.error.id, 'fcd2eef9-a9b2-4c4f-8624-038099e90aa5');
+ });
+
+ test('Unable to move if alsoKnownAs is invalid', async () => {
+ const res = await api('/i/move', {
+ moveToAccount: `@carol@${url.hostname}`,
+ }, alice);
+
+ assert.strictEqual(res.status, 400);
+ assert.strictEqual(res.body.error.code, 'DESTINATION_ACCOUNT_FORBIDS');
+ assert.strictEqual(res.body.error.id, 'b5c90186-4ab0-49c8-9bba-a1f766282ba4');
+ });
+
+ test('Relationships have been properly migrated', async () => {
+ const move = await api('/i/move', {
+ moveToAccount: `@bob@${url.hostname}`,
+ }, alice);
+
+ assert.strictEqual(move.status, 200);
+
+ await sleep(1000 * 3); // wait for jobs to finish
+
+ // Unfollow delayed?
+ const aliceFollowings = await api('/users/following', {
+ userId: alice.id,
+ }, alice);
+ assert.strictEqual(aliceFollowings.status, 200);
+ assert.strictEqual(aliceFollowings.body.length, 3);
+
+ const carolFollowings = await api('/users/following', {
+ userId: carol.id,
+ }, carol);
+ assert.strictEqual(carolFollowings.status, 200);
+ assert.strictEqual(carolFollowings.body.length, 2);
+ assert.strictEqual(carolFollowings.body[0].followeeId, bob.id);
+ assert.strictEqual(carolFollowings.body[1].followeeId, alice.id);
+
+ const blockings = await api('/blocking/list', {}, dave);
+ assert.strictEqual(blockings.status, 200);
+ assert.strictEqual(blockings.body.length, 2);
+ assert.strictEqual(blockings.body[0].blockeeId, bob.id);
+ assert.strictEqual(blockings.body[1].blockeeId, alice.id);
+
+ const mutings = await api('/mute/list', {}, dave);
+ assert.strictEqual(mutings.status, 200);
+ assert.strictEqual(mutings.body.length, 2);
+ assert.strictEqual(mutings.body[0].muteeId, bob.id);
+ assert.strictEqual(mutings.body[1].muteeId, alice.id);
+
+ const rootLists = await api('/users/lists/list', {}, root);
+ assert.strictEqual(rootLists.status, 200);
+ assert.strictEqual(rootLists.body[0].userIds.length, 2);
+ assert.ok(rootLists.body[0].userIds.find((id: string) => id === bob.id));
+ assert.ok(rootLists.body[0].userIds.find((id: string) => id === alice.id));
+
+ const eveLists = await api('/users/lists/list', {}, eve);
+ assert.strictEqual(eveLists.status, 200);
+ assert.strictEqual(eveLists.body[0].userIds.length, 1);
+ assert.ok(eveLists.body[0].userIds.find((id: string) => id === bob.id));
+ });
+
+ test('A locked account automatically accept the follow request if it had already accepted the old account.', async () => {
+ await successfulApiCall({
+ endpoint: '/following/create',
+ parameters: {
+ userId: frank.id,
+ },
+ user: bob,
+ });
+ const followers = await api('/users/followers', {
+ userId: frank.id,
+ }, frank);
+
+ assert.strictEqual(followers.status, 200);
+ assert.strictEqual(followers.body.length, 2);
+ assert.strictEqual(followers.body[0].followerId, bob.id);
+ });
+
+ test('Unfollowed after 10 sec (24 hours in production).', async () => {
+ await sleep(1000 * 8);
+
+ const following = await api('/users/following', {
+ userId: alice.id,
+ }, alice);
+
+ assert.strictEqual(following.status, 200);
+ assert.strictEqual(following.body.length, 0);
+ });
+
+ test('Unable to move if the destination account has already moved.', async () => {
+ const res = await api('/i/move', {
+ moveToAccount: `@alice@${url.hostname}`,
+ }, bob);
+
+ assert.strictEqual(res.status, 400);
+ assert.strictEqual(res.body.error.code, 'DESTINATION_ACCOUNT_FORBIDS');
+ assert.strictEqual(res.body.error.id, 'b5c90186-4ab0-49c8-9bba-a1f766282ba4');
+ });
+
+ test('Follow and follower counts are properly adjusted', async () => {
+ await api('/following/create', {
+ userId: alice.id,
+ }, eve);
+ const newAlice = await Users.findOneByOrFail({ id: alice.id });
+ const newCarol = await Users.findOneByOrFail({ id: carol.id });
+ let newEve = await Users.findOneByOrFail({ id: eve.id });
+ assert.strictEqual(newAlice.movedToUri, `${url.origin}/users/${bob.id}`);
+ assert.strictEqual(newAlice.followingCount, 0);
+ assert.strictEqual(newAlice.followersCount, 0);
+ assert.strictEqual(newCarol.followingCount, 1);
+ assert.strictEqual(newEve.followingCount, 1);
+ assert.strictEqual(newEve.followersCount, 1);
+
+ await api('/following/delete', {
+ userId: alice.id,
+ }, eve);
+ newEve = await Users.findOneByOrFail({ id: eve.id });
+ assert.strictEqual(newEve.followingCount, 1);
+ assert.strictEqual(newEve.followersCount, 1);
+ });
+
+ test.each([
+ '/antennas/create',
+ '/channels/create',
+ '/channels/favorite',
+ '/channels/follow',
+ '/channels/unfavorite',
+ '/channels/unfollow',
+ '/clips/add-note',
+ '/clips/create',
+ '/clips/favorite',
+ '/clips/remove-note',
+ '/clips/unfavorite',
+ '/clips/update',
+ '/drive/files/upload-from-url',
+ '/flash/create',
+ '/flash/like',
+ '/flash/unlike',
+ '/flash/update',
+ '/following/create',
+ '/gallery/posts/create',
+ '/gallery/posts/like',
+ '/gallery/posts/unlike',
+ '/gallery/posts/update',
+ '/i/claim-achievement',
+ '/i/move',
+ '/i/import-blocking',
+ '/i/import-following',
+ '/i/import-muting',
+ '/i/import-user-lists',
+ '/i/pin',
+ '/mute/create',
+ '/notes/create',
+ '/notes/favorites/create',
+ '/notes/polls/vote',
+ '/notes/reactions/create',
+ '/pages/create',
+ '/pages/like',
+ '/pages/unlike',
+ '/pages/update',
+ '/renote-mute/create',
+ '/users/lists/create',
+ '/users/lists/pull',
+ '/users/lists/push',
+ ])('Prohibit access after moving: %s', async (endpoint) => {
+ const res = await api(endpoint, {}, alice);
+ assert.strictEqual(res.status, 403);
+ assert.strictEqual(res.body.error.code, 'YOUR_ACCOUNT_MOVED');
+ assert.strictEqual(res.body.error.id, '56f20ec9-fd06-4fa5-841b-edd6d7d4fa31');
+ });
+
+ test('Prohibit access after moving: /antennas/update', async () => {
+ const res = await api('/antennas/update', {
+ antennaId,
+ name: rndstr('0-9a-z', 8),
+ src: 'users',
+ keywords: [rndstr('0-9a-z', 8)],
+ excludeKeywords: [],
+ users: [eve.id],
+ caseSensitive: false,
+ withReplies: false,
+ withFile: false,
+ notify: false,
+ }, alice);
+
+ assert.strictEqual(res.status, 403);
+ assert.strictEqual(res.body.error.code, 'YOUR_ACCOUNT_MOVED');
+ assert.strictEqual(res.body.error.id, '56f20ec9-fd06-4fa5-841b-edd6d7d4fa31');
+ });
+
+ test('Prohibit access after moving: /drive/files/create', async () => {
+ const res = await uploadFile(alice);
+
+ assert.strictEqual(res.status, 403);
+ assert.strictEqual(res.body.error.code, 'YOUR_ACCOUNT_MOVED');
+ assert.strictEqual(res.body.error.id, '56f20ec9-fd06-4fa5-841b-edd6d7d4fa31');
+ });
+
+ test('Prohibit updating alsoKnownAs after moving', async () => {
+ const res = await api('/i/update', {
+ alsoKnownAs: [`@eve@${url.hostname}`],
+ }, alice);
+
+ assert.strictEqual(res.status, 403);
+ assert.strictEqual(res.body.error.code, 'YOUR_ACCOUNT_MOVED');
+ assert.strictEqual(res.body.error.id, '56f20ec9-fd06-4fa5-841b-edd6d7d4fa31');
+ });
+ });
+});
diff --git a/packages/backend/test/e2e/note.ts b/packages/backend/test/e2e/note.ts
index e87045a8cf..9c851a5dd6 100644
--- a/packages/backend/test/e2e/note.ts
+++ b/packages/backend/test/e2e/note.ts
@@ -352,6 +352,72 @@ describe('Note', () => {
assert.strictEqual(myNote.renote.reply.files.length, 1);
assert.strictEqual(myNote.renote.reply.files[0].id, file.body.id);
});
+
+ test('NSFWが強制されている場合変更できない', async () => {
+ const file = await uploadFile(alice);
+
+ const res = await api('admin/roles/create', {
+ name: 'test',
+ description: '',
+ color: null,
+ iconUrl: null,
+ displayOrder: 0,
+ target: 'manual',
+ condFormula: {},
+ isAdministrator: false,
+ isModerator: false,
+ isPublic: false,
+ isExplorable: false,
+ asBadge: false,
+ canEditMembersByModerator: false,
+ policies: {
+ alwaysMarkNsfw: {
+ useDefault: false,
+ priority: 0,
+ value: true,
+ },
+ },
+ }, alice);
+
+ assert.strictEqual(res.status, 200);
+
+ const assign = await api('admin/roles/assign', {
+ userId: alice.id,
+ roleId: res.body.id,
+ }, alice);
+
+ assert.strictEqual(assign.status, 204);
+ assert.strictEqual(file.body.isSensitive, false);
+
+ const nsfwfile = await uploadFile(alice);
+
+ assert.strictEqual(nsfwfile.status, 200);
+ assert.strictEqual(nsfwfile.body.isSensitive, true);
+
+ const liftnsfw = await api('drive/files/update', {
+ fileId: nsfwfile.body.id,
+ isSensitive: false,
+ }, alice);
+
+ assert.strictEqual(liftnsfw.status, 400);
+ assert.strictEqual(liftnsfw.body.error.code, 'RESTRICTED_BY_ROLE');
+
+ const oldaddnsfw = await api('drive/files/update', {
+ fileId: file.body.id,
+ isSensitive: true,
+ }, alice);
+
+ assert.strictEqual(oldaddnsfw.status, 200);
+
+ await api('admin/roles/unassign', {
+ userId: alice.id,
+ roleId: res.body.id,
+ });
+
+ await api('admin/roles/delete', {
+ roleId: res.body.id,
+ }, alice);
+ });
});
describe('notes/create', () => {
diff --git a/packages/backend/test/e2e/users.ts b/packages/backend/test/e2e/users.ts
index bc3455e346..51537dda16 100644
--- a/packages/backend/test/e2e/users.ts
+++ b/packages/backend/test/e2e/users.ts
@@ -31,7 +31,7 @@ describe('ユーザー', () => {
}, {});
};
- // FIXME: 足りないキーがたくさんある
+ // BUG misskey-jsとjson-schemaと実際に返ってくるデータが全部違う
type UserLite = misskey.entities.UserLite & {
badgeRoles: any[],
};
@@ -51,10 +51,11 @@ describe('ユーザー', () => {
type User = MeDetailed & { token: string };
- const show = async (id: string, me = alice): Promise<MeDetailed | UserDetailedNotMe> => {
+ const show = async (id: string, me = root): Promise<MeDetailed | UserDetailedNotMe> => {
return successfulApiCall({ endpoint: 'users/show', parameters: { userId: id }, user: me }) as any;
};
+ // UserLiteのキーが過不足なく入っている?
const userLite = (user: User): Partial<UserLite> => {
return stripUndefined({
id: user.id,
@@ -76,12 +77,13 @@ describe('ユーザー', () => {
});
};
+ // UserDetailedNotMeのキーが過不足なく入っている?
const userDetailedNotMe = (user: User): Partial<UserDetailedNotMe> => {
return stripUndefined({
...userLite(user),
url: user.url,
uri: user.uri,
- movedToUri: user.movedToUri,
+ movedTo: user.movedTo,
alsoKnownAs: user.alsoKnownAs,
createdAt: user.createdAt,
updatedAt: user.updatedAt,
@@ -109,9 +111,11 @@ describe('ユーザー', () => {
usePasswordLessLogin: user.usePasswordLessLogin,
securityKeys: user.securityKeys,
roles: user.roles,
+ memo: user.memo,
});
};
+ // Relations関連のキーが過不足なく入っている?
const userDetailedNotMeWithRelations = (user: User): Partial<UserDetailedNotMe> => {
return stripUndefined({
...userDetailedNotMe(user),
@@ -126,6 +130,7 @@ describe('ユーザー', () => {
});
};
+ // MeDetailedのキーが過不足なく入っている?
const meDetailed = (user: User, security = false): Partial<MeDetailed> => {
return stripUndefined({
...userDetailedNotMe(user),
@@ -216,8 +221,8 @@ describe('ユーザー', () => {
}, 1000 * 60 * 2);
beforeAll(async () => {
- root = await signup({ username: 'alice' });
- alice = root;
+ root = await signup({ username: 'root' });
+ alice = await signup({ username: 'alice' });
aliceNote = await post(alice, { text: 'test' }) as any;
alicePage = await page(alice);
aliceList = (await api('users/list/create', { name: 'aliceList' }, alice)).body;
@@ -343,7 +348,7 @@ describe('ユーザー', () => {
// UserDetailedNotMeOnly
assert.strictEqual(response.url, null);
assert.strictEqual(response.uri, null);
- assert.strictEqual(response.movedToUri, null);
+ assert.strictEqual(response.movedTo, null);
assert.strictEqual(response.alsoKnownAs, null);
assert.strictEqual(response.createdAt, new Date(response.createdAt).toISOString());
assert.strictEqual(response.updatedAt, null);
@@ -371,6 +376,7 @@ describe('ユーザー', () => {
assert.strictEqual(response.usePasswordLessLogin, false);
assert.strictEqual(response.securityKeys, false);
assert.deepStrictEqual(response.roles, []);
+ assert.strictEqual(response.memo, null);
// MeDetailedOnly
assert.strictEqual(response.avatarId, null);
@@ -410,7 +416,7 @@ describe('ユーザー', () => {
//#endregion
//#region 自分の情報(i)
- test('を読み取ることができる。(自分)', async () => {
+ test('を読み取ることができること(自分)、キーが過不足なく入っていること。', async () => {
const response = await successfulApiCall({
endpoint: 'i',
parameters: {},
@@ -502,7 +508,6 @@ describe('ユーザー', () => {
};
assert.deepStrictEqual(response, expected, inspect(parameters));
- if (1) return; // BUG 521eb95 以降アバターのリセットができない。
const parameters2 = { avatarId: null };
const response2 = await successfulApiCall({ endpoint: 'i/update', parameters: parameters2, user: alice });
const expected2 = {
@@ -528,7 +533,6 @@ describe('ユーザー', () => {
};
assert.deepStrictEqual(response, expected, inspect(parameters));
- if (1) return; // BUG 521eb95 以降バナーのリセットができない。
const parameters2 = { bannerId: null };
const response2 = await successfulApiCall({ endpoint: 'i/update', parameters: parameters2, user: alice });
const expected2 = {
@@ -555,6 +559,21 @@ describe('ユーザー', () => {
});
//#endregion
+ //#region メモの更新(users/update-memo)
+
+ test.each([
+ { label: '最大長', memo: 'x'.repeat(2048) },
+ { label: '空文字', memo: '', expects: null },
+ { label: 'null', memo: null },
+ ])('を書き換えることができる(メモを$labelに)', async ({ memo, expects }) => {
+ const expected = { ...await show(bob.id, alice), memo: expects === undefined ? memo : expects };
+ const parameters = { userId: bob.id, memo };
+ await successfulApiCall({ endpoint: 'users/update-memo', parameters, user: alice });
+ const response = await show(bob.id, alice);
+ assert.deepStrictEqual(response, expected);
+ });
+
+ //#endregion
//#region ユーザー(users)
test.each([
@@ -569,7 +588,7 @@ describe('ユーザー', () => {
const response = await successfulApiCall({ endpoint: 'users', parameters, user: alice });
// 結果の並びを事前にアサートするのは困難なので返ってきたidに対応するユーザーが返っており、ソート順が正しいことだけを検証する
- const users = await Promise.all(response.map(u => show(u.id)));
+ const users = await Promise.all(response.map(u => show(u.id, alice)));
const expected = users.sort((x, y) => {
const index = (selector(x) < selector(y)) ? -1 : (selector(x) > selector(y)) ? 1 : 0;
return index * (parameters.sort?.startsWith('+') ? -1 : 1);
@@ -583,13 +602,13 @@ describe('ユーザー', () => {
{ label: 'ブロックしてきているユーザーが含まれる', user: (): User => userBlockingAlice, excluded: true },
{ label: '承認制ユーザーが含まれる', user: (): User => userLocking },
{ label: 'サイレンスユーザーが含まれる', user: (): User => userSilenced },
- { label: 'サスペンドユーザーが含まれる', user: (): User => userSuspended },
+ { label: 'サスペンドユーザーが含まれない', user: (): User => userSuspended, excluded: true },
{ label: '削除済ユーザーが含まれる', user: (): User => userDeletedBySelf },
{ label: '削除済(byAdmin)ユーザーが含まれる', user: (): User => userDeletedByAdmin },
] as const)('をリスト形式で取得することができ、結果に$label', async ({ user, excluded }) => {
const parameters = { limit: 100 };
const response = await successfulApiCall({ endpoint: 'users', parameters, user: alice });
- const expected = (excluded ?? false) ? [] : [await show(user().id)];
+ const expected = (excluded ?? false) ? [] : [await show(user().id, alice)];
assert.deepStrictEqual(response.filter((u) => u.id === user().id), expected);
});
test.todo('をリスト形式で取得することができる(リモート, hostname指定)');
@@ -616,7 +635,7 @@ describe('ユーザー', () => {
{ label: 'Moderatorになっている', user: (): User => userModerator, me: (): User => userModerator, selector: (user: User): unknown => user.isModerator },
{ label: '自分以外から見たときはModeratorか判定できない', user: (): User => userModerator, selector: (user: User): unknown => user.isModerator, expected: (): undefined => undefined },
{ label: 'サイレンスになっている', user: (): User => userSilenced, selector: (user: User): unknown => user.isSilenced },
- { label: 'サスペンドになっている', user: (): User => userSuspended, selector: (user: User): unknown => user.isSuspended },
+ //{ label: 'サスペンドになっている', user: (): User => userSuspended, selector: (user: User): unknown => user.isSuspended },
{ label: '削除済みになっている', user: (): User => userDeletedBySelf, me: (): User => userDeletedBySelf, selector: (user: User): unknown => user.isDeleted },
{ label: '自分以外から見たときは削除済みか判定できない', user: (): User => userDeletedBySelf, selector: (user: User): unknown => user.isDeleted, expected: (): undefined => undefined },
{ label: '削除済み(byAdmin)になっている', user: (): User => userDeletedByAdmin, me: (): User => userDeletedByAdmin, selector: (user: User): unknown => user.isDeleted },
@@ -698,13 +717,13 @@ describe('ユーザー', () => {
test('を検索することができる', async () => {
const parameters = { query: 'carol', limit: 10 };
const response = await successfulApiCall({ endpoint: 'users/search', parameters, user: alice });
- const expected = [await show(carol.id)];
+ const expected = [await show(carol.id, alice)];
assert.deepStrictEqual(response, expected);
});
test('を検索することができる(UserLite)', async () => {
const parameters = { query: 'carol', detail: false, limit: 10 };
const response = await successfulApiCall({ endpoint: 'users/search', parameters, user: alice });
- const expected = [userLite(await show(carol.id))];
+ const expected = [userLite(await show(carol.id, alice))];
assert.deepStrictEqual(response, expected);
});
test.each([
@@ -720,7 +739,7 @@ describe('ユーザー', () => {
] as const)('を検索することができ、結果に$labelが含まれる', async ({ user, excluded }) => {
const parameters = { query: user().username, limit: 1 };
const response = await successfulApiCall({ endpoint: 'users/search', parameters, user: alice });
- const expected = (excluded ?? false) ? [] : [await show(user().id)];
+ const expected = (excluded ?? false) ? [] : [await show(user().id, alice)];
assert.deepStrictEqual(response, expected);
});
test.todo('を検索することができる(リモート)');
@@ -741,7 +760,7 @@ describe('ユーザー', () => {
{ label: 'ローカル', parameters: { host: '.', limit: 1 }, user: (): User[] => [userFollowedByAlice] },
])('をID&ホスト指定で検索できる($label)', async ({ parameters, user }) => {
const response = await successfulApiCall({ endpoint: 'users/search-by-username-and-host', parameters, user: alice });
- const expected = await Promise.all(user().map(u => show(u.id)));
+ const expected = await Promise.all(user().map(u => show(u.id, alice)));
assert.deepStrictEqual(response, expected);
});
test.each([
@@ -757,7 +776,7 @@ describe('ユーザー', () => {
] as const)('をID&ホスト指定で検索でき、結果に$label', async ({ user, excluded }) => {
const parameters = { username: user().username };
const response = await successfulApiCall({ endpoint: 'users/search-by-username-and-host', parameters, user: alice });
- const expected = (excluded ?? false) ? [] : [await show(user().id)];
+ const expected = (excluded ?? false) ? [] : [await show(user().id, alice)];
assert.deepStrictEqual(response, expected);
});
test.todo('をID&ホスト指定で検索できる(リモート)');
@@ -769,7 +788,7 @@ describe('ユーザー', () => {
const parameters = { userId: alice.id, limit: 5 };
const response = await successfulApiCall({ endpoint: 'users/get-frequently-replied-users', parameters, user: alice });
const expected = await Promise.all(usersReplying.slice(0, parameters.limit).map(async (s, i) => ({
- user: await show(s.id),
+ user: await show(s.id, alice),
weight: (usersReplying.length - i) / usersReplying.length,
})));
assert.deepStrictEqual(response, expected);
@@ -781,7 +800,7 @@ describe('ユーザー', () => {
{ label: 'ブロックしてきているユーザーが含まれない', user: (): User => userBlockingAlice, excluded: true },
{ label: '承認制ユーザーが含まれる', user: (): User => userLocking },
{ label: 'サイレンスユーザーが含まれる', user: (): User => userSilenced },
- { label: 'サスペンドユーザーが含まれない', user: (): User => userSuspended },
+ //{ label: 'サスペンドユーザーが含まれない', user: (): User => userSuspended, excluded: true },
{ label: '削除済ユーザーが含まれる', user: (): User => userDeletedBySelf },
{ label: '削除済(byAdmin)ユーザーが含まれる', user: (): User => userDeletedByAdmin },
] as const)('がよくリプライをするユーザーのリストを取得でき、結果に$label', async ({ user, excluded }) => {
@@ -789,7 +808,7 @@ describe('ユーザー', () => {
await post(alice, { text: `@${user().username} test`, replyId: replyTo.id });
const parameters = { userId: alice.id, limit: 100 };
const response = await successfulApiCall({ endpoint: 'users/get-frequently-replied-users', parameters, user: alice });
- const expected = (excluded ?? false) ? [] : [await show(user().id)];
+ const expected = (excluded ?? false) ? [] : [await show(user().id, alice)];
assert.deepStrictEqual(response.map(s => s.user).filter((u) => u.id === user().id), expected);
});
@@ -808,7 +827,7 @@ describe('ユーザー', () => {
await successfulApiCall({ endpoint: 'i/update', parameters: { description: `#${hashtag}` }, user: alice });
const parameters = { tag: hashtag, limit: 5, ...sort };
const response = await successfulApiCall({ endpoint: 'hashtags/users', parameters, user: alice });
- const users = await Promise.all(response.map(u => show(u.id)));
+ const users = await Promise.all(response.map(u => show(u.id, alice)));
const expected = users.sort((x, y) => {
const index = (selector(x) < selector(y)) ? -1 : (selector(x) > selector(y)) ? 1 : 0;
return index * (parameters.sort.startsWith('+') ? -1 : 1);
@@ -822,10 +841,10 @@ describe('ユーザー', () => {
{ label: 'ブロックしてきているユーザーが含まれる', user: (): User => userBlockingAlice },
{ label: '承認制ユーザーが含まれる', user: (): User => userLocking },
{ label: 'サイレンスユーザーが含まれる', user: (): User => userSilenced },
- { label: 'サスペンドユーザーが含まれる', user: (): User => userSuspended },
+ { label: 'サスペンドユーザーが含まれない', user: (): User => userSuspended, excluded: true },
{ label: '削除済ユーザーが含まれる', user: (): User => userDeletedBySelf },
{ label: '削除済(byAdmin)ユーザーが含まれる', user: (): User => userDeletedByAdmin },
- ] as const)('をハッシュタグ指定で取得することができ、結果に$label', async ({ user }) => {
+ ] as const)('をハッシュタグ指定で取得することができ、結果に$label', async ({ user, excluded }) => {
const hashtag = `user_test${user().username}`;
if (user() !== userSuspended) {
// サスペンドユーザーはupdateできない。
@@ -833,7 +852,7 @@ describe('ユーザー', () => {
}
const parameters = { tag: hashtag, limit: 100, sort: '-follower' } as const;
const response = await successfulApiCall({ endpoint: 'hashtags/users', parameters, user: alice });
- const expected = [await show(user().id)];
+ const expected = (excluded ?? false) ? [] : [await show(user().id, alice)];
assert.deepStrictEqual(response, expected);
});
test.todo('をハッシュタグ指定で取得することができる(リモート)');
@@ -856,7 +875,7 @@ describe('ユーザー', () => {
await successfulApiCall({ endpoint: 'admin/update-meta', parameters: { pinnedUsers: [bob.username, `@${carol.username}`] }, user: root });
const parameters = {} as const;
const response = await successfulApiCall({ endpoint: 'pinned-users', parameters, user: alice });
- const expected = await Promise.all([bob, carol].map(u => show(u.id)));
+ const expected = await Promise.all([bob, carol].map(u => show(u.id, alice)));
assert.deepStrictEqual(response, expected);
});
diff --git a/packages/backend/test/resources/kick_gaba7.aac b/packages/backend/test/resources/kick_gaba7.aac
new file mode 100644
index 0000000000..4644542f96
--- /dev/null
+++ b/packages/backend/test/resources/kick_gaba7.aac
Binary files differ
diff --git a/packages/backend/test/resources/kick_gaba7.flac b/packages/backend/test/resources/kick_gaba7.flac
new file mode 100644
index 0000000000..7512812018
--- /dev/null
+++ b/packages/backend/test/resources/kick_gaba7.flac
Binary files differ
diff --git a/packages/backend/test/resources/kick_gaba7.mp3 b/packages/backend/test/resources/kick_gaba7.mp3
new file mode 100644
index 0000000000..6ba317deb1
--- /dev/null
+++ b/packages/backend/test/resources/kick_gaba7.mp3
Binary files differ
diff --git a/packages/backend/test/resources/kick_gaba7.wav b/packages/backend/test/resources/kick_gaba7.wav
new file mode 100644
index 0000000000..2cd280148e
--- /dev/null
+++ b/packages/backend/test/resources/kick_gaba7.wav
Binary files differ
diff --git a/packages/backend/test/resources/kick_gaba7.webm b/packages/backend/test/resources/kick_gaba7.webm
new file mode 100644
index 0000000000..82c5349cd4
--- /dev/null
+++ b/packages/backend/test/resources/kick_gaba7.webm
Binary files differ
diff --git a/packages/backend/test/unit/FileInfoService.ts b/packages/backend/test/unit/FileInfoService.ts
index d05833560d..f378184c74 100644
--- a/packages/backend/test/unit/FileInfoService.ts
+++ b/packages/backend/test/unit/FileInfoService.ts
@@ -7,10 +7,10 @@ import { ModuleMocker } from 'jest-mock';
import { Test } from '@nestjs/testing';
import { GlobalModule } from '@/GlobalModule.js';
import { FileInfoService } from '@/core/FileInfoService.js';
-import { DI } from '@/di-symbols.js';
+//import { DI } from '@/di-symbols.js';
import { AiService } from '@/core/AiService.js';
import type { TestingModule } from '@nestjs/testing';
-import type { jest } from '@jest/globals';
+import { describe, beforeAll, afterAll, test } from '@jest/globals';
import type { MockFunctionMetadata } from 'jest-mock';
const _filename = fileURLToPath(import.meta.url);
@@ -74,164 +74,271 @@ describe('FileInfoService', () => {
});
});
- test('Generic JPEG', async () => {
- const path = `${resources}/Lenna.jpg`;
- const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any;
- delete info.warnings;
- delete info.blurhash;
- delete info.sensitive;
- delete info.porn;
- assert.deepStrictEqual(info, {
- size: 25360,
- md5: '091b3f259662aa31e2ffef4519951168',
- type: {
- mime: 'image/jpeg',
- ext: 'jpg',
- },
- width: 512,
- height: 512,
- orientation: undefined,
+ describe('IMAGE', () => {
+ test('Generic JPEG', async () => {
+ const path = `${resources}/Lenna.jpg`;
+ const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any;
+ delete info.warnings;
+ delete info.blurhash;
+ delete info.sensitive;
+ delete info.porn;
+ assert.deepStrictEqual(info, {
+ size: 25360,
+ md5: '091b3f259662aa31e2ffef4519951168',
+ type: {
+ mime: 'image/jpeg',
+ ext: 'jpg',
+ },
+ width: 512,
+ height: 512,
+ orientation: undefined,
+ });
});
- });
-
- test('Generic APNG', async () => {
- const path = `${resources}/anime.png`;
- const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any;
- delete info.warnings;
- delete info.blurhash;
- delete info.sensitive;
- delete info.porn;
- assert.deepStrictEqual(info, {
- size: 1868,
- md5: '08189c607bea3b952704676bb3c979e0',
- type: {
- mime: 'image/apng',
- ext: 'apng',
- },
- width: 256,
- height: 256,
- orientation: undefined,
+
+ test('Generic APNG', async () => {
+ const path = `${resources}/anime.png`;
+ const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any;
+ delete info.warnings;
+ delete info.blurhash;
+ delete info.sensitive;
+ delete info.porn;
+ assert.deepStrictEqual(info, {
+ size: 1868,
+ md5: '08189c607bea3b952704676bb3c979e0',
+ type: {
+ mime: 'image/apng',
+ ext: 'apng',
+ },
+ width: 256,
+ height: 256,
+ orientation: undefined,
+ });
});
- });
-
- test('Generic AGIF', async () => {
- const path = `${resources}/anime.gif`;
- const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any;
- delete info.warnings;
- delete info.blurhash;
- delete info.sensitive;
- delete info.porn;
- assert.deepStrictEqual(info, {
- size: 2248,
- md5: '32c47a11555675d9267aee1a86571e7e',
- type: {
- mime: 'image/gif',
- ext: 'gif',
- },
- width: 256,
- height: 256,
- orientation: undefined,
+
+ test('Generic AGIF', async () => {
+ const path = `${resources}/anime.gif`;
+ const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any;
+ delete info.warnings;
+ delete info.blurhash;
+ delete info.sensitive;
+ delete info.porn;
+ assert.deepStrictEqual(info, {
+ size: 2248,
+ md5: '32c47a11555675d9267aee1a86571e7e',
+ type: {
+ mime: 'image/gif',
+ ext: 'gif',
+ },
+ width: 256,
+ height: 256,
+ orientation: undefined,
+ });
});
- });
-
- test('PNG with alpha', async () => {
- const path = `${resources}/with-alpha.png`;
- const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any;
- delete info.warnings;
- delete info.blurhash;
- delete info.sensitive;
- delete info.porn;
- assert.deepStrictEqual(info, {
- size: 3772,
- md5: 'f73535c3e1e27508885b69b10cf6e991',
- type: {
- mime: 'image/png',
- ext: 'png',
- },
- width: 256,
- height: 256,
- orientation: undefined,
+
+ test('PNG with alpha', async () => {
+ const path = `${resources}/with-alpha.png`;
+ const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any;
+ delete info.warnings;
+ delete info.blurhash;
+ delete info.sensitive;
+ delete info.porn;
+ assert.deepStrictEqual(info, {
+ size: 3772,
+ md5: 'f73535c3e1e27508885b69b10cf6e991',
+ type: {
+ mime: 'image/png',
+ ext: 'png',
+ },
+ width: 256,
+ height: 256,
+ orientation: undefined,
+ });
});
- });
-
- test('Generic SVG', async () => {
- const path = `${resources}/image.svg`;
- const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any;
- delete info.warnings;
- delete info.blurhash;
- delete info.sensitive;
- delete info.porn;
- assert.deepStrictEqual(info, {
- size: 505,
- md5: 'b6f52b4b021e7b92cdd04509c7267965',
- type: {
- mime: 'image/svg+xml',
- ext: 'svg',
- },
- width: 256,
- height: 256,
- orientation: undefined,
+
+ test('Generic SVG', async () => {
+ const path = `${resources}/image.svg`;
+ const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any;
+ delete info.warnings;
+ delete info.blurhash;
+ delete info.sensitive;
+ delete info.porn;
+ assert.deepStrictEqual(info, {
+ size: 505,
+ md5: 'b6f52b4b021e7b92cdd04509c7267965',
+ type: {
+ mime: 'image/svg+xml',
+ ext: 'svg',
+ },
+ width: 256,
+ height: 256,
+ orientation: undefined,
+ });
});
- });
-
- test('SVG with XML definition', async () => {
- // https://github.com/misskey-dev/misskey/issues/4413
- const path = `${resources}/with-xml-def.svg`;
- const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any;
- delete info.warnings;
- delete info.blurhash;
- delete info.sensitive;
- delete info.porn;
- assert.deepStrictEqual(info, {
- size: 544,
- md5: '4b7a346cde9ccbeb267e812567e33397',
- type: {
- mime: 'image/svg+xml',
- ext: 'svg',
- },
- width: 256,
- height: 256,
- orientation: undefined,
+
+ test('SVG with XML definition', async () => {
+ // https://github.com/misskey-dev/misskey/issues/4413
+ const path = `${resources}/with-xml-def.svg`;
+ const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any;
+ delete info.warnings;
+ delete info.blurhash;
+ delete info.sensitive;
+ delete info.porn;
+ assert.deepStrictEqual(info, {
+ size: 544,
+ md5: '4b7a346cde9ccbeb267e812567e33397',
+ type: {
+ mime: 'image/svg+xml',
+ ext: 'svg',
+ },
+ width: 256,
+ height: 256,
+ orientation: undefined,
+ });
});
- });
-
- test('Dimension limit', async () => {
- const path = `${resources}/25000x25000.png`;
- const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any;
- delete info.warnings;
- delete info.blurhash;
- delete info.sensitive;
- delete info.porn;
- assert.deepStrictEqual(info, {
- size: 75933,
- md5: '268c5dde99e17cf8fe09f1ab3f97df56',
- type: {
- mime: 'application/octet-stream', // do not treat as image
- ext: null,
- },
- width: 25000,
- height: 25000,
- orientation: undefined,
+
+ test('Dimension limit', async () => {
+ const path = `${resources}/25000x25000.png`;
+ const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any;
+ delete info.warnings;
+ delete info.blurhash;
+ delete info.sensitive;
+ delete info.porn;
+ assert.deepStrictEqual(info, {
+ size: 75933,
+ md5: '268c5dde99e17cf8fe09f1ab3f97df56',
+ type: {
+ mime: 'application/octet-stream', // do not treat as image
+ ext: null,
+ },
+ width: 25000,
+ height: 25000,
+ orientation: undefined,
+ });
+ });
+
+ test('Rotate JPEG', async () => {
+ const path = `${resources}/rotate.jpg`;
+ const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any;
+ delete info.warnings;
+ delete info.blurhash;
+ delete info.sensitive;
+ delete info.porn;
+ assert.deepStrictEqual(info, {
+ size: 12624,
+ md5: '68d5b2d8d1d1acbbce99203e3ec3857e',
+ type: {
+ mime: 'image/jpeg',
+ ext: 'jpg',
+ },
+ width: 512,
+ height: 256,
+ orientation: 8,
+ });
});
});
- test('Rotate JPEG', async () => {
- const path = `${resources}/rotate.jpg`;
- const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any;
- delete info.warnings;
- delete info.blurhash;
- delete info.sensitive;
- delete info.porn;
- assert.deepStrictEqual(info, {
- size: 12624,
- md5: '68d5b2d8d1d1acbbce99203e3ec3857e',
- type: {
- mime: 'image/jpeg',
- ext: 'jpg',
- },
- width: 512,
- height: 256,
- orientation: 8,
+ describe('AUDIO', () => {
+ test('MP3', async () => {
+ const path = `${resources}/kick_gaba7.mp3`;
+ const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any;
+ delete info.warnings;
+ delete info.blurhash;
+ delete info.sensitive;
+ delete info.porn;
+ delete info.width;
+ delete info.height;
+ delete info.orientation;
+ assert.deepStrictEqual(info, {
+ size: 19853,
+ md5: '4f557df8548bc3cecc794c652f690446',
+ type: {
+ mime: 'audio/mpeg',
+ ext: 'mp3',
+ },
+ });
+ });
+
+ test('WAV', async () => {
+ const path = `${resources}/kick_gaba7.wav`;
+ const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any;
+ delete info.warnings;
+ delete info.blurhash;
+ delete info.sensitive;
+ delete info.porn;
+ delete info.width;
+ delete info.height;
+ delete info.orientation;
+ assert.deepStrictEqual(info, {
+ size: 87630,
+ md5: '8bc9bb4fe5e77bb1871448209be635c1',
+ type: {
+ mime: 'audio/wav',
+ ext: 'wav',
+ },
+ });
+ });
+
+ test('AAC', async () => {
+ const path = `${resources}/kick_gaba7.aac`;
+ const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any;
+ delete info.warnings;
+ delete info.blurhash;
+ delete info.sensitive;
+ delete info.porn;
+ delete info.width;
+ delete info.height;
+ delete info.orientation;
+ assert.deepStrictEqual(info, {
+ size: 7291,
+ md5: '2789323f05e3392b648066f50be6a2a6',
+ type: {
+ mime: 'audio/aac',
+ ext: 'aac',
+ },
+ });
+ });
+
+ test('FLAC', async () => {
+ const path = `${resources}/kick_gaba7.flac`;
+ const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any;
+ delete info.warnings;
+ delete info.blurhash;
+ delete info.sensitive;
+ delete info.porn;
+ delete info.width;
+ delete info.height;
+ delete info.orientation;
+ assert.deepStrictEqual(info, {
+ size: 108793,
+ md5: 'bc0f3adfe0e1ca99ae6c7528c46b3173',
+ type: {
+ mime: 'audio/flac',
+ ext: 'flac',
+ },
+ });
+ });
+
+ /*
+ * video/webmとして検出されてしまう
+ test('WEBM AUDIO', async () => {
+ const path = `${resources}/kick_gaba7.webm`;
+ const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any;
+ delete info.warnings;
+ delete info.blurhash;
+ delete info.sensitive;
+ delete info.porn;
+ delete info.width;
+ delete info.height;
+ delete info.orientation;
+ assert.deepStrictEqual(info, {
+ size: 8879,
+ md5: '3350083dec312419cfdc06c16413aca7',
+ type: {
+ mime: 'audio/webm',
+ ext: 'webm',
+ },
+ });
});
+ */
});
});
diff --git a/packages/backend/test/unit/RelayService.ts b/packages/backend/test/unit/RelayService.ts
index 529e923b2c..c2280142a6 100644
--- a/packages/backend/test/unit/RelayService.ts
+++ b/packages/backend/test/unit/RelayService.ts
@@ -7,6 +7,7 @@ import { GlobalModule } from '@/GlobalModule.js';
import { RelayService } from '@/core/RelayService.js';
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/index.js';
@@ -21,6 +22,7 @@ describe('RelayService', () => {
let relayService: RelayService;
let queueService: jest.Mocked<QueueService>;
let relaysRepository: RelaysRepository;
+ let userEntityService: UserEntityService;
beforeAll(async () => {
app = await Test.createTestingModule({
@@ -32,6 +34,7 @@ describe('RelayService', () => {
CreateSystemUserService,
ApRendererService,
RelayService,
+ UserEntityService,
],
})
.useMocker((token) => {
@@ -51,6 +54,7 @@ describe('RelayService', () => {
relayService = app.get<RelayService>(RelayService);
queueService = app.get<QueueService>(QueueService) as jest.Mocked<QueueService>;
relaysRepository = app.get<RelaysRepository>(DI.relaysRepository);
+ userEntityService = app.get<UserEntityService>(UserEntityService);
});
afterAll(async () => {
@@ -63,7 +67,7 @@ describe('RelayService', () => {
expect(result.inbox).toBe('https://example.com');
expect(result.status).toBe('requesting');
expect(queueService.deliver).toHaveBeenCalled();
- expect(queueService.deliver.mock.lastCall![1].type).toBe('Follow');
+ expect(queueService.deliver.mock.lastCall![1]?.type).toBe('Follow');
expect(queueService.deliver.mock.lastCall![2]).toBe('https://example.com');
//expect(queueService.deliver.mock.lastCall![0].username).toBe('relay.actor');
});
@@ -80,8 +84,8 @@ describe('RelayService', () => {
await relayService.removeRelay('https://example.com');
expect(queueService.deliver).toHaveBeenCalled();
- expect(queueService.deliver.mock.lastCall![1].type).toBe('Undo');
- expect(queueService.deliver.mock.lastCall![1].object.type).toBe('Follow');
+ expect(queueService.deliver.mock.lastCall![1]?.type).toBe('Undo');
+ expect(queueService.deliver.mock.lastCall![1]?.object.type).toBe('Follow');
expect(queueService.deliver.mock.lastCall![2]).toBe('https://example.com');
//expect(queueService.deliver.mock.lastCall![0].username).toBe('relay.actor');
diff --git a/packages/backend/test/unit/misc/check-word-mute.ts b/packages/backend/test/unit/misc/check-word-mute.ts
new file mode 100644
index 0000000000..7ab838bdee
--- /dev/null
+++ b/packages/backend/test/unit/misc/check-word-mute.ts
@@ -0,0 +1,49 @@
+import { checkWordMute } from '@/misc/check-word-mute.js';
+
+describe(checkWordMute, () => {
+ describe('Slacc boost mode', () => {
+ it('should return false if mutedWords is empty', async () => {
+ expect(await checkWordMute({ userId: '1', text: 'foo' }, null, [])).toBe(false);
+ });
+ it('should return true if mutedWords is not empty and text contains muted word', async () => {
+ expect(await checkWordMute({ userId: '1', text: 'foo' }, null, [['foo']])).toBe(true);
+ });
+ it('should return false if mutedWords is not empty and text does not contain muted word', async () => {
+ expect(await checkWordMute({ userId: '1', text: 'foo' }, null, [['bar']])).toBe(false);
+ });
+ it('should return false when the note is written by me even if mutedWords is not empty and text contains muted word', async () => {
+ expect(await checkWordMute({ userId: '1', text: 'foo' }, { id: '1' }, [['foo']])).toBe(false);
+ });
+ it('should return true if mutedWords is not empty and text contains muted word in CW', async () => {
+ expect(await checkWordMute({ userId: '1', text: 'foo', cw: 'bar' }, null, [['bar']])).toBe(true);
+ });
+ it('should return true if mutedWords is not empty and text contains muted word in both CW and text', async () => {
+ expect(await checkWordMute({ userId: '1', text: 'foo', cw: 'bar' }, null, [['foo'], ['bar']])).toBe(true);
+ });
+ it('should return true if mutedWords is not empty and text does not contain muted word in both CW and text', async () => {
+ expect(await checkWordMute({ userId: '1', text: 'foo', cw: 'bar' }, null, [['foo'], ['baz']])).toBe(true);
+ });
+ });
+ describe('normal mode', () => {
+ it('should return false if text does not contain muted words', async () => {
+ expect(await checkWordMute({ userId: '1', text: 'foo' }, null, [['foo', 'bar']])).toBe(false);
+ });
+ it('should return true if text contains muted words', async () => {
+ expect(await checkWordMute({ userId: '1', text: 'foobar' }, null, [['foo', 'bar']])).toBe(true);
+ });
+ it('should return false when the note is written by me even if text contains muted words', async () => {
+ expect(await checkWordMute({ userId: '1', text: 'foo bar' }, { id: '1' }, [['foo', 'bar']])).toBe(false);
+ });
+ });
+ describe('RegExp mode', () => {
+ it('should return false if text does not contain muted words', async () => {
+ expect(await checkWordMute({ userId: '1', text: 'foo' }, null, ['/bar/'])).toBe(false);
+ });
+ it('should return true if text contains muted words', async () => {
+ expect(await checkWordMute({ userId: '1', text: 'foobar' }, null, ['/bar/'])).toBe(true);
+ });
+ it('should return false when the note is written by me even if text contains muted words', async () => {
+ expect(await checkWordMute({ userId: '1', text: 'foo bar' }, { id: '1' }, ['/bar/'])).toBe(false);
+ });
+ });
+});
diff --git a/packages/frontend/.storybook/changes.ts b/packages/frontend/.storybook/changes.ts
index f0827331f7..755bec6869 100644
--- a/packages/frontend/.storybook/changes.ts
+++ b/packages/frontend/.storybook/changes.ts
@@ -38,6 +38,7 @@ fs.readFile(
path.resolve(__dirname, '../../..', arg)
)
)
+ .map((path) => path.replace(/(?:(?<=\.stories)\.(?:impl|meta)|\.msw)(?=\.ts$)/g, ''))
.map((path) => (path.startsWith('.') ? path : `./${path}`))
);
if (
diff --git a/packages/frontend/.storybook/generate.tsx b/packages/frontend/.storybook/generate.tsx
index dd40bac2cc..7c51d4c00c 100644
--- a/packages/frontend/.storybook/generate.tsx
+++ b/packages/frontend/.storybook/generate.tsx
@@ -118,7 +118,7 @@ function toStories(component: string): string {
.replace(/[-.]|^(?=\d)/g, '_')
.replace(/(?<=^[^A-Z_]*$)/, '_')}
/> as estree.Identifier;
- const parameters = (
+ const parameters =
<object-expression
properties={[
<property
@@ -137,9 +137,8 @@ function toStories(component: string): string {
]
: []),
]}
- />
- ) as estree.ObjectExpression;
- const program = (
+ /> as estree.ObjectExpression;
+ const program =
<program
body={[
<import-declaration
@@ -379,11 +378,11 @@ function toStories(component: string): string {
declaration={(<identifier name='meta' />) as estree.Identifier}
/> as estree.ExportDefaultDeclaration,
]}
- />
- ) as estree.Program;
+ /> as estree.Program;
return format(
'/* eslint-disable @typescript-eslint/explicit-function-return-type */\n' +
'/* eslint-disable import/no-default-export */\n' +
+ '/* eslint-disable import/no-duplicates */\n' +
generate(program, { generator }) +
(hasImplStories ? readFileSync(`${implStories}.ts`, 'utf-8') : ''),
{
@@ -397,7 +396,11 @@ function toStories(component: string): string {
// glob('src/{components,pages,ui,widgets}/**/*.vue')
Promise.all([
glob('src/components/global/*.vue'),
+ glob('src/components/Mk{A,B}*.vue'),
glob('src/components/MkGalleryPostPreview.vue'),
+ glob('src/components/MkSignupServerRules.vue'),
+ glob('src/components/MkUserSetupDialog.vue'),
+ glob('src/components/MkUserSetupDialog.*.vue'),
glob('src/pages/user/home.vue'),
])
.then((globs) => globs.flat())
diff --git a/packages/frontend/.storybook/main.ts b/packages/frontend/.storybook/main.ts
index 45db48fa1d..1d0ce5ab63 100644
--- a/packages/frontend/.storybook/main.ts
+++ b/packages/frontend/.storybook/main.ts
@@ -1,6 +1,6 @@
import { resolve } from 'node:path';
import type { StorybookConfig } from '@storybook/vue3-vite';
-import { mergeConfig } from 'vite';
+import { type Plugin, mergeConfig } from 'vite';
import turbosnap from 'vite-plugin-turbosnap';
const config = {
stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'],
@@ -22,6 +22,10 @@ const config = {
disableTelemetry: true,
},
async viteFinal(config) {
+ const replacePluginForIsChromatic = config.plugins?.findIndex((plugin) => plugin && (plugin as Partial<Plugin>)?.name === 'replace') ?? -1;
+ if (~replacePluginForIsChromatic) {
+ config.plugins?.splice(replacePluginForIsChromatic, 1);
+ }
return mergeConfig(config, {
plugins: [
turbosnap({
diff --git a/packages/frontend/.storybook/mocks.ts b/packages/frontend/.storybook/mocks.ts
index 41c3c5c4d9..4091e39686 100644
--- a/packages/frontend/.storybook/mocks.ts
+++ b/packages/frontend/.storybook/mocks.ts
@@ -8,6 +8,16 @@ export const onUnhandledRequest = ((req, print) => {
}) satisfies SharedOptions['onUnhandledRequest'];
export const commonHandlers = [
+ rest.get('/fluent-emoji/:codepoints.png', async (req, res, ctx) => {
+ const { codepoints } = req.params;
+ const value = await fetch(`https://raw.githubusercontent.com/misskey-dev/emojis/main/dist/${codepoints}.png`).then((response) => response.blob());
+ return res(ctx.set('Content-Type', 'image/png'), ctx.body(value));
+ }),
+ rest.get('/fluent-emojis/:codepoints.png', async (req, res, ctx) => {
+ const { codepoints } = req.params;
+ const value = await fetch(`https://raw.githubusercontent.com/misskey-dev/emojis/main/dist/${codepoints}.png`).then((response) => response.blob());
+ return res(ctx.set('Content-Type', 'image/png'), ctx.body(value));
+ }),
rest.get('/twemoji/:codepoints.svg', async (req, res, ctx) => {
const { codepoints } = req.params;
const value = await fetch(`https://unpkg.com/@discordapp/twemoji@14.1.2/dist/svg/${codepoints}.svg`).then((response) => response.blob());
diff --git a/packages/frontend/.storybook/preview-head.html b/packages/frontend/.storybook/preview-head.html
index 64e537b931..ab694f64fb 100644
--- a/packages/frontend/.storybook/preview-head.html
+++ b/packages/frontend/.storybook/preview-head.html
@@ -1,3 +1,5 @@
+<link rel="preload" href="https://github.com/misskey-dev/misskey/blob/master/packages/frontend/assets/about-icon.png?raw=true" as="image" type="image/png" crossorigin="anonymous">
+<link rel="preload" href="https://github.com/misskey-dev/misskey/blob/master/packages/frontend/assets/fedi.jpg?raw=true" as="image" type="image/jpeg" crossorigin="anonymous">
<link rel="stylesheet" href="https://unpkg.com/@tabler/icons-webfont@2.12.0/tabler-icons.min.css">
<link rel="stylesheet" href="https://unpkg.com/@fontsource/m-plus-rounded-1c/index.css">
<style>
diff --git a/packages/frontend/.storybook/preview.ts b/packages/frontend/.storybook/preview.ts
index b2974276ab..e887acaa2e 100644
--- a/packages/frontend/.storybook/preview.ts
+++ b/packages/frontend/.storybook/preview.ts
@@ -3,6 +3,7 @@ import { FORCE_REMOUNT } from '@storybook/core-events';
import { type Preview, setup } from '@storybook/vue3';
import isChromatic from 'chromatic/isChromatic';
import { initialize, mswDecorator } from 'msw-storybook-addon';
+import { userDetailed } from './fakes';
import locale from './locale';
import { commonHandlers, onUnhandledRequest } from './mocks';
import themes from './themes';
@@ -10,6 +11,7 @@ import '../src/style.scss';
const appInitialized = Symbol();
+let lastStory = null;
let moduleInitialized = false;
let unobserve = () => {};
let misskeyOS = null;
@@ -19,7 +21,7 @@ function loadTheme(applyTheme: typeof import('../src/scripts/theme')['applyTheme
const theme = themes[document.documentElement.dataset.misskeyTheme];
if (theme) {
applyTheme(themes[document.documentElement.dataset.misskeyTheme]);
- } else if (isChromatic()) {
+ } else {
applyTheme(themes['l-light']);
}
const observer = new MutationObserver((entries) => {
@@ -42,10 +44,19 @@ function loadTheme(applyTheme: typeof import('../src/scripts/theme')['applyTheme
unobserve = () => observer.disconnect();
}
+function initLocalStorage() {
+ localStorage.clear();
+ localStorage.setItem('account', JSON.stringify({
+ ...userDetailed(),
+ policies: {},
+ }));
+ localStorage.setItem('locale', JSON.stringify(locale));
+}
+
initialize({
onUnhandledRequest,
});
-localStorage.setItem("locale", JSON.stringify(locale));
+initLocalStorage();
queueMicrotask(() => {
Promise.all([
import('../src/components'),
@@ -76,6 +87,27 @@ queueMicrotask(() => {
const preview = {
decorators: [
(Story, context) => {
+ if (lastStory === context.id) {
+ lastStory = null;
+ } else {
+ lastStory = context.id;
+ const channel = addons.getChannel();
+ const resetIndexedDBPromise = globalThis.indexedDB?.databases
+ ? indexedDB.databases().then((r) => {
+ for (var i = 0; i < r.length; i++) {
+ indexedDB.deleteDatabase(r[i].name!);
+ }
+ }).catch(() => {})
+ : Promise.resolve();
+ const resetDefaultStorePromise = import('../src/store').then(({ defaultStore }) => {
+ // @ts-expect-error
+ defaultStore.init();
+ }).catch(() => {});
+ Promise.all([resetIndexedDBPromise, resetDefaultStorePromise]).then(() => {
+ initLocalStorage();
+ channel.emit(FORCE_REMOUNT, { storyId: context.id });
+ });
+ }
const story = Story();
if (!moduleInitialized) {
const channel = addons.getChannel();
diff --git a/packages/frontend/.vscode/storybook.code-snippets b/packages/frontend/.vscode/storybook.code-snippets
new file mode 100644
index 0000000000..785d0a1608
--- /dev/null
+++ b/packages/frontend/.vscode/storybook.code-snippets
@@ -0,0 +1,84 @@
+{
+ "Storybook Story Impl File": {
+ "scope": "typescript",
+ "prefix": "storyimpl",
+ "body": [
+ "/* eslint-disable @typescript-eslint/explicit-function-return-type */",
+ "import { StoryObj } from '@storybook/vue3';",
+ "import $1 from './$1.vue';",
+ "export const Default = {",
+ "\trender(args) {",
+ "\t\treturn {",
+ "\t\t\tcomponents: {",
+ "\t\t\t\t$1,",
+ "\t\t\t},",
+ "\t\t\tsetup() {",
+ "\t\t\t\treturn {",
+ "\t\t\t\t\targs,",
+ "\t\t\t\t};",
+ "\t\t\t},",
+ "\t\t\tcomputed: {",
+ "\t\t\t\tprops() {",
+ "\t\t\t\t\treturn {",
+ "\t\t\t\t\t\t...this.args,",
+ "\t\t\t\t\t};",
+ "\t\t\t\t},",
+ "\t\t\t},",
+ "\t\t\ttemplate: '<$1 v-bind=\"props\" />',",
+ "\t\t};",
+ "\t},",
+ "\targs: {",
+ "\t\t$2",
+ "\t},",
+ "\tparameters: {",
+ "\t\tlayout: 'centered',",
+ "\t},",
+ "} satisfies StoryObj<typeof $1>;",
+ ""
+ ]
+ },
+ "Storybook Story Impl File (w/ events)": {
+ "scope": "typescript",
+ "prefix": "storyimplevent",
+ "body": [
+ "/* eslint-disable @typescript-eslint/explicit-function-return-type */",
+ "import { action } from '@storybook/addon-actions';",
+ "import { StoryObj } from '@storybook/vue3';",
+ "import $1 from './$1.vue';",
+ "export const Default = {",
+ "\trender(args) {",
+ "\t\treturn {",
+ "\t\t\tcomponents: {",
+ "\t\t\t\t$1,",
+ "\t\t\t},",
+ "\t\t\tsetup() {",
+ "\t\t\t\treturn {",
+ "\t\t\t\t\targs,",
+ "\t\t\t\t};",
+ "\t\t\t},",
+ "\t\t\tcomputed: {",
+ "\t\t\t\tprops() {",
+ "\t\t\t\t\treturn {",
+ "\t\t\t\t\t\t...this.args,",
+ "\t\t\t\t\t};",
+ "\t\t\t\t},",
+ "\t\t\t\tevents() {",
+ "\t\t\t\t\treturn {",
+ "\t\t\t\t\t\t$3",
+ "\t\t\t\t\t};",
+ "\t\t\t\t},",
+ "\t\t\t},",
+ "\t\t\ttemplate: '<$1 v-bind=\"props\" v-on=\"events\" />',",
+ "\t\t};",
+ "\t},",
+ "\targs: {",
+ "\t\t$2",
+ "\t},",
+ "\tparameters: {",
+ "\t\tlayout: 'centered',",
+ "\t},",
+ "} satisfies StoryObj<typeof $1>;",
+ ""
+ ]
+ }
+}
diff --git a/packages/frontend/package.json b/packages/frontend/package.json
index 79fb626a9a..7646e152f9 100644
--- a/packages/frontend/package.json
+++ b/packages/frontend/package.json
@@ -15,28 +15,31 @@
},
"dependencies": {
"@discordapp/twemoji": "14.1.2",
- "@rollup/plugin-alias": "4.0.3",
+ "@rollup/plugin-alias": "5.0.0",
"@rollup/plugin-json": "6.0.0",
+ "@rollup/plugin-replace": "^5.0.2",
"@rollup/pluginutils": "5.0.2",
- "@syuilo/aiscript": "0.13.1",
- "@tabler/icons-webfont": "2.12.0",
- "@vitejs/plugin-vue": "4.1.0",
+ "@syuilo/aiscript": "0.13.2",
+ "@tabler/icons-webfont": "2.17.0",
+ "@vitejs/plugin-vue": "4.2.1",
+ "@vue-macros/reactivity-transform": "^0.3.5",
"@vue/compiler-sfc": "3.2.47",
"autosize": "5.0.2",
"blurhash": "2.0.5",
"broadcast-channel": "4.20.2",
"browser-image-resizer": "github:misskey-dev/browser-image-resizer#v2.2.1-misskey.3",
"canvas-confetti": "1.6.0",
- "chart.js": "4.2.1",
+ "chart.js": "4.3.0",
"chartjs-adapter-date-fns": "3.0.0",
"chartjs-chart-matrix": "2.0.1",
"chartjs-plugin-gradient": "0.6.1",
"chartjs-plugin-zoom": "2.0.1",
+ "chromatic": "6.17.3",
"compare-versions": "5.0.1",
"cropperjs": "2.0.0-beta.2",
- "date-fns": "2.29.3",
+ "date-fns": "2.30.0",
"escape-regexp": "0.0.1",
- "eventemitter3": "5.0.0",
+ "eventemitter3": "5.0.1",
"gsap": "3.11.5",
"idb-keyval": "6.2.0",
"insert-text-at-cursor": "0.3.0",
@@ -50,10 +53,10 @@
"punycode": "2.3.0",
"querystring": "0.2.1",
"rndstr": "1.0.0",
- "rollup": "3.20.2",
+ "rollup": "3.21.3",
"s-age": "1.1.2",
"sanitize-html": "2.10.0",
- "sass": "1.60.0",
+ "sass": "1.62.1",
"seedrandom": "3.0.5",
"strict-event-emitter-types": "2.0.0",
"syuilo-password-strength": "0.0.1",
@@ -61,45 +64,46 @@
"three": "0.151.3",
"throttle-debounce": "5.0.0",
"tinycolor2": "1.6.0",
- "tsc-alias": "1.8.5",
+ "tsc-alias": "1.8.6",
"tsconfig-paths": "4.2.0",
"twemoji-parser": "14.0.0",
- "typescript": "5.0.3",
+ "typescript": "5.0.4",
"uuid": "9.0.0",
"vanilla-tilt": "1.8.0",
- "vite": "4.2.1",
+ "vite": "4.3.4",
"vue": "3.2.47",
"vue-plyr": "7.0.0",
"vue-prism-editor": "2.0.0-alpha.2",
"vuedraggable": "next"
},
"devDependencies": {
- "@storybook/addon-essentials": "7.0.2",
- "@storybook/addon-interactions": "7.0.2",
- "@storybook/addon-links": "7.0.2",
- "@storybook/addon-storysource": "7.0.2",
- "@storybook/addons": "7.0.2",
- "@storybook/blocks": "7.0.2",
- "@storybook/core-events": "7.0.2",
+ "@storybook/addon-actions": "7.0.7",
+ "@storybook/addon-essentials": "7.0.7",
+ "@storybook/addon-interactions": "7.0.7",
+ "@storybook/addon-links": "7.0.7",
+ "@storybook/addon-storysource": "7.0.7",
+ "@storybook/addons": "7.0.7",
+ "@storybook/blocks": "7.0.7",
+ "@storybook/core-events": "7.0.7",
"@storybook/jest": "0.1.0",
- "@storybook/manager-api": "7.0.2",
- "@storybook/preview-api": "7.0.2",
- "@storybook/react": "7.0.2",
- "@storybook/react-vite": "7.0.2",
- "@storybook/testing-library": "0.0.14-next.1",
- "@storybook/theming": "7.0.2",
- "@storybook/types": "7.0.2",
- "@storybook/vue3": "7.0.2",
- "@storybook/vue3-vite": "7.0.2",
+ "@storybook/manager-api": "7.0.7",
+ "@storybook/preview-api": "7.0.7",
+ "@storybook/react": "7.0.7",
+ "@storybook/react-vite": "7.0.7",
+ "@storybook/testing-library": "0.1.0",
+ "@storybook/theming": "7.0.7",
+ "@storybook/types": "7.0.7",
+ "@storybook/vue3": "7.0.7",
+ "@storybook/vue3-vite": "7.0.7",
"@testing-library/jest-dom": "5.16.5",
"@testing-library/vue": "7.0.0",
"@types/escape-regexp": "0.0.1",
- "@types/estree": "1.0.0",
+ "@types/estree": "1.0.1",
"@types/gulp": "4.0.10",
"@types/gulp-rename": "2.0.1",
"@types/matter-js": "0.18.2",
- "@types/micromatch": "3.1.1",
- "@types/node": "18.15.11",
+ "@types/micromatch": "4.0.2",
+ "@types/node": "18.16.3",
"@types/punycode": "2.1.0",
"@types/sanitize-html": "2.9.0",
"@types/seedrandom": "3.0.5",
@@ -109,34 +113,33 @@
"@types/uuid": "9.0.1",
"@types/websocket": "1.0.5",
"@types/ws": "8.5.4",
- "@typescript-eslint/eslint-plugin": "5.57.1",
- "@typescript-eslint/parser": "5.57.1",
- "@vitest/coverage-c8": "^0.29.8",
+ "@typescript-eslint/eslint-plugin": "5.59.2",
+ "@typescript-eslint/parser": "5.59.2",
+ "@vitest/coverage-c8": "0.30.1",
"@vue/runtime-core": "3.2.47",
"astring": "1.8.4",
"chokidar-cli": "3.0.0",
- "chromatic": "6.17.3",
"cross-env": "7.0.3",
- "cypress": "12.9.0",
- "eslint": "8.37.0",
+ "cypress": "12.11.0",
+ "eslint": "8.39.0",
"eslint-plugin-import": "2.27.5",
- "eslint-plugin-vue": "9.10.0",
+ "eslint-plugin-vue": "9.11.0",
"fast-glob": "3.2.12",
- "happy-dom": "8.9.0",
+ "happy-dom": "9.10.2",
"micromatch": "3.1.10",
"msw": "1.2.1",
"msw-storybook-addon": "1.8.0",
- "prettier": "2.8.7",
+ "prettier": "2.8.8",
"react": "18.2.0",
"react-dom": "18.2.0",
"start-server-and-test": "2.0.0",
- "storybook": "7.0.2",
+ "storybook": "7.0.7",
"storybook-addon-misskey-theme": "github:misskey-dev/storybook-addon-misskey-theme",
"summaly": "github:misskey-dev/summaly",
- "vite-plugin-turbosnap": "^1.0.1",
- "vitest": "0.29.8",
+ "vite-plugin-turbosnap": "1.0.2",
+ "vitest": "0.30.1",
"vitest-fetch-mock": "0.2.2",
"vue-eslint-parser": "9.1.1",
- "vue-tsc": "1.2.0"
+ "vue-tsc": "1.6.3"
}
}
diff --git a/packages/frontend/src/components/MkAbuseReport.stories.impl.ts b/packages/frontend/src/components/MkAbuseReport.stories.impl.ts
new file mode 100644
index 0000000000..7d27adeb04
--- /dev/null
+++ b/packages/frontend/src/components/MkAbuseReport.stories.impl.ts
@@ -0,0 +1,49 @@
+/* eslint-disable @typescript-eslint/explicit-function-return-type */
+import { action } from '@storybook/addon-actions';
+import { StoryObj } from '@storybook/vue3';
+import { rest } from 'msw';
+import { abuseUserReport } from '../../.storybook/fakes';
+import { commonHandlers } from '../../.storybook/mocks';
+import MkAbuseReport from './MkAbuseReport.vue';
+export const Default = {
+ render(args) {
+ return {
+ components: {
+ MkAbuseReport,
+ },
+ setup() {
+ return {
+ args,
+ };
+ },
+ computed: {
+ props() {
+ return {
+ ...this.args,
+ };
+ },
+ events() {
+ return {
+ resolved: action('resolved'),
+ };
+ },
+ },
+ template: '<MkAbuseReport v-bind="props" v-on="events" />',
+ };
+ },
+ args: {
+ report: abuseUserReport(),
+ },
+ parameters: {
+ layout: 'fullscreen',
+ msw: {
+ handlers: [
+ ...commonHandlers,
+ rest.post('/api/admin/resolve-abuse-user-report', async (req, res, ctx) => {
+ action('POST /api/admin/resolve-abuse-user-report')(await req.json());
+ return res(ctx.json({}));
+ }),
+ ],
+ },
+ },
+} satisfies StoryObj<typeof MkAbuseReport>;
diff --git a/packages/frontend/src/components/MkAbuseReportWindow.stories.impl.ts b/packages/frontend/src/components/MkAbuseReportWindow.stories.impl.ts
new file mode 100644
index 0000000000..d0877ffd3b
--- /dev/null
+++ b/packages/frontend/src/components/MkAbuseReportWindow.stories.impl.ts
@@ -0,0 +1,49 @@
+/* eslint-disable @typescript-eslint/explicit-function-return-type */
+import { action } from '@storybook/addon-actions';
+import { StoryObj } from '@storybook/vue3';
+import { rest } from 'msw';
+import { userDetailed } from '../../.storybook/fakes';
+import { commonHandlers } from '../../.storybook/mocks';
+import MkAbuseReportWindow from './MkAbuseReportWindow.vue';
+export const Default = {
+ render(args) {
+ return {
+ components: {
+ MkAbuseReportWindow,
+ },
+ setup() {
+ return {
+ args,
+ };
+ },
+ computed: {
+ props() {
+ return {
+ ...this.args,
+ };
+ },
+ events() {
+ return {
+ 'closed': action('closed'),
+ };
+ },
+ },
+ template: '<MkAbuseReportWindow v-bind="props" v-on="events" />',
+ };
+ },
+ args: {
+ user: userDetailed(),
+ },
+ parameters: {
+ layout: 'centered',
+ msw: {
+ handlers: [
+ ...commonHandlers,
+ rest.post('/api/users/report-abuse', async (req, res, ctx) => {
+ action('POST /api/users/report-abuse')(await req.json());
+ return res(ctx.json({}));
+ }),
+ ],
+ },
+ },
+} satisfies StoryObj<typeof MkAbuseReportWindow>;
diff --git a/packages/frontend/src/components/MkAccountMoved.stories.impl.ts b/packages/frontend/src/components/MkAccountMoved.stories.impl.ts
new file mode 100644
index 0000000000..bed9d94311
--- /dev/null
+++ b/packages/frontend/src/components/MkAccountMoved.stories.impl.ts
@@ -0,0 +1,33 @@
+/* eslint-disable @typescript-eslint/explicit-function-return-type */
+import { StoryObj } from '@storybook/vue3';
+import { userDetailed } from '../../.storybook/fakes';
+import MkAccountMoved from './MkAccountMoved.vue';
+export const Default = {
+ render(args) {
+ return {
+ components: {
+ MkAccountMoved,
+ },
+ setup() {
+ return {
+ args,
+ };
+ },
+ computed: {
+ props() {
+ return {
+ ...this.args,
+ };
+ },
+ },
+ template: '<MkAccountMoved v-bind="props" />',
+ };
+ },
+ args: {
+ username: userDetailed().username,
+ host: userDetailed().host,
+ },
+ parameters: {
+ layout: 'centered',
+ },
+} satisfies StoryObj<typeof MkAccountMoved>;
diff --git a/packages/frontend/src/components/MkAccountMoved.vue b/packages/frontend/src/components/MkAccountMoved.vue
index fd472de6c1..b02bfdc2b8 100644
--- a/packages/frontend/src/components/MkAccountMoved.vue
+++ b/packages/frontend/src/components/MkAccountMoved.vue
@@ -1,8 +1,8 @@
<template>
-<div :class="$style.root">
+<div v-if="user" :class="$style.root">
<i class="ti ti-plane-departure" style="margin-right: 8px;"></i>
{{ i18n.ts.accountMoved }}
- <MkMention :class="$style.link" :username="acct" :host="host ?? localHost"/>
+ <MkMention :class="$style.link" :username="user.username" :host="user.host ?? localHost"/>
</div>
</template>
@@ -10,11 +10,17 @@
import MkMention from './MkMention.vue';
import { i18n } from '@/i18n';
import { host as localHost } from '@/config';
+import { ref } from 'vue';
+import { UserLite } from 'misskey-js/built/entities';
+import { api } from '@/os';
-defineProps<{
- acct: string;
- host: string;
+const user = ref<UserLite>();
+
+const props = defineProps<{
+ movedTo: string; // user id
}>();
+
+api('users/show', { userId: props.movedTo }).then(u => user.value = u);
</script>
<style lang="scss" module>
diff --git a/packages/frontend/src/components/MkAchievements.stories.impl.ts b/packages/frontend/src/components/MkAchievements.stories.impl.ts
new file mode 100644
index 0000000000..477152a47b
--- /dev/null
+++ b/packages/frontend/src/components/MkAchievements.stories.impl.ts
@@ -0,0 +1,56 @@
+/* eslint-disable @typescript-eslint/explicit-function-return-type */
+import { StoryObj } from '@storybook/vue3';
+import { rest } from 'msw';
+import { userDetailed } from '../../.storybook/fakes';
+import { commonHandlers } from '../../.storybook/mocks';
+import MkAchievements from './MkAchievements.vue';
+import { ACHIEVEMENT_TYPES } from '@/scripts/achievements';
+export const Empty = {
+ render(args) {
+ return {
+ components: {
+ MkAchievements,
+ },
+ setup() {
+ return {
+ args,
+ };
+ },
+ computed: {
+ props() {
+ return {
+ ...this.args,
+ };
+ },
+ },
+ template: '<MkAchievements v-bind="props" />',
+ };
+ },
+ args: {
+ user: userDetailed(),
+ },
+ parameters: {
+ layout: 'fullscreen',
+ msw: {
+ handlers: [
+ ...commonHandlers,
+ rest.post('/api/users/achievements', (req, res, ctx) => {
+ return res(ctx.json([]));
+ }),
+ ],
+ },
+ },
+} satisfies StoryObj<typeof MkAchievements>;
+export const All = {
+ ...Empty,
+ parameters: {
+ msw: {
+ handlers: [
+ ...commonHandlers,
+ rest.post('/api/users/achievements', (req, res, ctx) => {
+ return res(ctx.json(ACHIEVEMENT_TYPES.map((name) => ({ name, unlockedAt: 0 }))));
+ }),
+ ],
+ },
+ },
+} satisfies StoryObj<typeof MkAchievements>;
diff --git a/packages/frontend/src/components/MkAnalogClock.stories.impl.ts b/packages/frontend/src/components/MkAnalogClock.stories.impl.ts
index 05190aa268..e7fbb47284 100644
--- a/packages/frontend/src/components/MkAnalogClock.stories.impl.ts
+++ b/packages/frontend/src/components/MkAnalogClock.stories.impl.ts
@@ -1,6 +1,7 @@
/* eslint-disable @typescript-eslint/explicit-function-return-type */
import { StoryObj } from '@storybook/vue3';
import MkAnalogClock from './MkAnalogClock.vue';
+import isChromatic from 'chromatic';
export const Default = {
render(args) {
return {
@@ -22,6 +23,14 @@ export const Default = {
template: '<MkAnalogClock v-bind="props" />',
};
},
+ args: {
+ now: isChromatic() ? () => new Date('2023-01-01T10:10:30') : undefined,
+ },
+ decorators: [
+ () => ({
+ template: '<div style="container-type:inline-size;height:100%"><div style="height:100cqmin;margin:auto;width:100cqmin"><story/></div></div>',
+ }),
+ ],
parameters: {
layout: 'fullscreen',
},
diff --git a/packages/frontend/src/components/MkAnalogClock.vue b/packages/frontend/src/components/MkAnalogClock.vue
index 1218202616..f12020f810 100644
--- a/packages/frontend/src/components/MkAnalogClock.vue
+++ b/packages/frontend/src/components/MkAnalogClock.vue
@@ -99,6 +99,7 @@ const props = withDefaults(defineProps<{
graduations?: 'none' | 'dots' | 'numbers';
fadeGraduations?: boolean;
sAnimation?: 'none' | 'elastic' | 'easeOut';
+ now?: () => Date;
}>(), {
numbers: false,
thickness: 0.1,
@@ -107,6 +108,7 @@ const props = withDefaults(defineProps<{
graduations: 'dots',
fadeGraduations: true,
sAnimation: 'elastic',
+ now: () => new Date(),
});
const graduationsMajor = computed(() => {
@@ -145,11 +147,17 @@ let disableSAnimate = $ref(false);
let sOneRound = false;
function tick() {
- const now = new Date();
- now.setMinutes(now.getMinutes() + (new Date().getTimezoneOffset() + props.offset));
+ const now = props.now();
+ now.setMinutes(now.getMinutes() + now.getTimezoneOffset() + props.offset);
+ const previousS = s;
+ const previousM = m;
+ const previousH = h;
s = now.getSeconds();
m = now.getMinutes();
h = now.getHours();
+ if (previousS === s && previousM === m && previousH === h) {
+ return;
+ }
hAngle = Math.PI * (h % (props.twentyfour ? 24 : 12) + (m + s / 60) / 60) / (props.twentyfour ? 12 : 6);
mAngle = Math.PI * (m + s / 60) / 30;
if (sOneRound) { // 秒針が一周した際のアニメーションをよしなに処理する(これが無いと秒が59->0になったときに期待したアニメーションにならない)
diff --git a/packages/frontend/src/components/MkAsUi.stories.impl.ts b/packages/frontend/src/components/MkAsUi.stories.impl.ts
new file mode 100644
index 0000000000..b67c0e679d
--- /dev/null
+++ b/packages/frontend/src/components/MkAsUi.stories.impl.ts
@@ -0,0 +1,2 @@
+import MkAsUi from './MkAsUi.vue';
+void MkAsUi;
diff --git a/packages/frontend/src/components/MkAutocomplete.stories.impl.ts b/packages/frontend/src/components/MkAutocomplete.stories.impl.ts
new file mode 100644
index 0000000000..075904d6a3
--- /dev/null
+++ b/packages/frontend/src/components/MkAutocomplete.stories.impl.ts
@@ -0,0 +1,176 @@
+/* eslint-disable @typescript-eslint/explicit-function-return-type */
+import { action } from '@storybook/addon-actions';
+import { expect } from '@storybook/jest';
+import { userEvent, waitFor, within } from '@storybook/testing-library';
+import { StoryObj } from '@storybook/vue3';
+import { rest } from 'msw';
+import { userDetailed } from '../../.storybook/fakes';
+import { commonHandlers } from '../../.storybook/mocks';
+import MkAutocomplete from './MkAutocomplete.vue';
+import MkInput from './MkInput.vue';
+import { tick } from '@/scripts/test-utils';
+const common = {
+ render(args) {
+ return {
+ components: {
+ MkAutocomplete,
+ },
+ setup() {
+ return {
+ args,
+ };
+ },
+ computed: {
+ props() {
+ return {
+ ...this.args,
+ };
+ },
+ events() {
+ return {
+ open: action('open'),
+ closed: action('closed'),
+ };
+ },
+ },
+ template: '<MkAutocomplete v-bind="props" v-on="events" :textarea="textarea" />',
+ };
+ },
+ args: {
+ close: action('close'),
+ x: 0,
+ y: 0,
+ },
+ decorators: [
+ (_, context) => ({
+ components: {
+ MkInput,
+ },
+ data() {
+ return {
+ q: context.args.q,
+ textarea: null,
+ };
+ },
+ methods: {
+ inputMounted() {
+ this.textarea = this.$refs.input.$refs.inputEl;
+ },
+ },
+ template: '<MkInput v-model="q" ref="input" @vue:mounted="inputMounted"/><story v-if="textarea" :q="q" :textarea="textarea"/>',
+ }),
+ ],
+ parameters: {
+ controls: {
+ exclude: ['textarea'],
+ },
+ layout: 'centered',
+ chromatic: {
+ // FIXME: flaky
+ disableSnapshot: true,
+ },
+ },
+} satisfies StoryObj<typeof MkAutocomplete>;
+export const User = {
+ ...common,
+ args: {
+ ...common.args,
+ type: 'user',
+ },
+ async play({ canvasElement }) {
+ const canvas = within(canvasElement);
+ const input = canvas.getByRole('combobox');
+ await waitFor(() => userEvent.hover(input));
+ await waitFor(() => userEvent.click(input));
+ await waitFor(() => userEvent.type(input, 'm'));
+ await waitFor(async () => {
+ await userEvent.type(input, ' ', { delay: 256 });
+ await tick();
+ return await expect(canvas.getByRole('list')).toBeInTheDocument();
+ }, { timeout: 16384 });
+ },
+ parameters: {
+ ...common.parameters,
+ msw: {
+ handlers: [
+ ...commonHandlers,
+ rest.post('/api/users/search-by-username-and-host', (req, res, ctx) => {
+ return res(ctx.json([
+ userDetailed('44', 'mizuki', 'misskey-hub.net', 'Mizuki'),
+ userDetailed('49', 'momoko', 'misskey-hub.net', 'Momoko'),
+ ]));
+ }),
+ ],
+ },
+ },
+};
+export const Hashtag = {
+ ...common,
+ args: {
+ ...common.args,
+ type: 'hashtag',
+ },
+ async play({ canvasElement }) {
+ const canvas = within(canvasElement);
+ const input = canvas.getByRole('combobox');
+ await waitFor(() => userEvent.hover(input));
+ await waitFor(() => userEvent.click(input));
+ await waitFor(() => userEvent.type(input, '気象'));
+ await waitFor(async () => {
+ await userEvent.type(input, ' ', { delay: 256 });
+ await tick();
+ return await expect(canvas.getByRole('list')).toBeInTheDocument();
+ }, { interval: 256, timeout: 16384 });
+ },
+ parameters: {
+ ...common.parameters,
+ msw: {
+ handlers: [
+ ...commonHandlers,
+ rest.post('/api/hashtags/search', (req, res, ctx) => {
+ return res(ctx.json([
+ '気象警報注意報',
+ '気象警報',
+ '気象情報',
+ ]));
+ }),
+ ],
+ },
+ },
+};
+export const Emoji = {
+ ...common,
+ args: {
+ ...common.args,
+ type: 'emoji',
+ },
+ async play({ canvasElement }) {
+ const canvas = within(canvasElement);
+ const input = canvas.getByRole('combobox');
+ await waitFor(() => userEvent.hover(input));
+ await waitFor(() => userEvent.click(input));
+ await waitFor(() => userEvent.type(input, 'smile'));
+ await waitFor(async () => {
+ await userEvent.type(input, ' ', { delay: 256 });
+ await tick();
+ return await expect(canvas.getByRole('list')).toBeInTheDocument();
+ }, { interval: 256, timeout: 16384 });
+ },
+} satisfies StoryObj<typeof MkAutocomplete>;
+export const MfmTag = {
+ ...common,
+ args: {
+ ...common.args,
+ type: 'mfmTag',
+ },
+ async play({ canvasElement }) {
+ const canvas = within(canvasElement);
+ const input = canvas.getByRole('combobox');
+ await waitFor(() => userEvent.hover(input));
+ await waitFor(() => userEvent.click(input));
+ await waitFor(async () => {
+ await tick();
+ return await expect(canvas.getByRole('list')).toBeInTheDocument();
+ }, { interval: 256, timeout: 16384 });
+ },
+} satisfies StoryObj<typeof MkAutocomplete>;
diff --git a/packages/frontend/src/components/MkAvatars.stories.impl.ts b/packages/frontend/src/components/MkAvatars.stories.impl.ts
new file mode 100644
index 0000000000..14052c7343
--- /dev/null
+++ b/packages/frontend/src/components/MkAvatars.stories.impl.ts
@@ -0,0 +1,46 @@
+/* eslint-disable @typescript-eslint/explicit-function-return-type */
+import { StoryObj } from '@storybook/vue3';
+import { rest } from 'msw';
+import { userDetailed } from '../../.storybook/fakes';
+import { commonHandlers } from '../../.storybook/mocks';
+import MkAvatars from './MkAvatars.vue';
+export const Default = {
+ render(args) {
+ return {
+ components: {
+ MkAvatars,
+ },
+ setup() {
+ return {
+ args,
+ };
+ },
+ computed: {
+ props() {
+ return {
+ ...this.args,
+ };
+ },
+ },
+ template: '<MkAvatars v-bind="props" />',
+ };
+ },
+ args: {
+ userIds: ['17', '20', '18'],
+ },
+ parameters: {
+ layout: 'centered',
+ msw: {
+ handlers: [
+ ...commonHandlers,
+ rest.post('/api/users/show', (req, res, ctx) => {
+ return res(ctx.json([
+ userDetailed('17'),
+ userDetailed('20'),
+ userDetailed('18'),
+ ]));
+ }),
+ ],
+ },
+ },
+} satisfies StoryObj<typeof MkAvatars>;
diff --git a/packages/frontend/src/components/MkButton.stories.impl.ts b/packages/frontend/src/components/MkButton.stories.impl.ts
index e1c1c54d10..982a8b3be1 100644
--- a/packages/frontend/src/components/MkButton.stories.impl.ts
+++ b/packages/frontend/src/components/MkButton.stories.impl.ts
@@ -1,6 +1,6 @@
/* eslint-disable @typescript-eslint/explicit-function-return-type */
/* eslint-disable import/no-default-export */
-/* eslint-disable import/no-duplicates */
+import { action } from '@storybook/addon-actions';
import { StoryObj } from '@storybook/vue3';
import MkButton from './MkButton.vue';
export const Default = {
@@ -20,11 +20,60 @@ export const Default = {
...this.args,
};
},
+ events() {
+ return {
+ click: action('click'),
+ };
+ },
},
- template: '<MkButton v-bind="props">Text</MkButton>',
+ template: '<MkButton v-bind="props" v-on="events">Text</MkButton>',
};
},
+ args: {
+ },
parameters: {
layout: 'centered',
},
} satisfies StoryObj<typeof MkButton>;
+export const Primary = {
+ ...Default,
+ args: {
+ ...Default.args,
+ primary: true,
+ },
+} satisfies StoryObj<typeof MkButton>;
+export const Gradate = {
+ ...Default,
+ args: {
+ ...Default.args,
+ gradate: true,
+ },
+} satisfies StoryObj<typeof MkButton>;
+export const Rounded = {
+ ...Default,
+ args: {
+ ...Default.args,
+ rounded: true,
+ },
+} satisfies StoryObj<typeof MkButton>;
+export const Danger = {
+ ...Default,
+ args: {
+ ...Default.args,
+ danger: true,
+ },
+} satisfies StoryObj<typeof MkButton>;
+export const Small = {
+ ...Default,
+ args: {
+ ...Default.args,
+ small: true,
+ },
+} satisfies StoryObj<typeof MkButton>;
+export const Large = {
+ ...Default,
+ args: {
+ ...Default.args,
+ large: true,
+ },
+} satisfies StoryObj<typeof MkButton>;
diff --git a/packages/frontend/src/components/MkColorInput.vue b/packages/frontend/src/components/MkColorInput.vue
new file mode 100644
index 0000000000..2471aa958d
--- /dev/null
+++ b/packages/frontend/src/components/MkColorInput.vue
@@ -0,0 +1,110 @@
+<template>
+<div>
+ <div :class="$style.label"><slot name="label"></slot></div>
+ <div :class="[$style.input, { disabled }]">
+ <input
+ ref="inputEl"
+ v-model="v"
+ v-adaptive-border
+ :class="$style.inputCore"
+ type="color"
+ :disabled="disabled"
+ :required="required"
+ :readonly="readonly"
+ @input="onInput"
+ >
+ </div>
+ <div :class="$style.caption"><slot name="caption"></slot></div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { onMounted, nextTick, ref, shallowRef, watch, computed, toRefs } from 'vue';
+import { i18n } from '@/i18n';
+
+const props = defineProps<{
+ modelValue: string | null;
+ required?: boolean;
+ readonly?: boolean;
+ disabled?: boolean;
+}>();
+
+const emit = defineEmits<{
+ (ev: 'update:modelValue', value: string): void;
+}>();
+
+const { modelValue } = toRefs(props);
+const v = ref(modelValue.value);
+const inputEl = shallowRef<HTMLElement>();
+
+const onInput = (ev: KeyboardEvent) => {
+ emit('update:modelValue', v.value);
+};
+</script>
+
+<style lang="scss" module>
+.label {
+ font-size: 0.85em;
+ padding: 0 0 8px 0;
+ user-select: none;
+
+ &:empty {
+ display: none;
+ }
+}
+
+.caption {
+ font-size: 0.85em;
+ padding: 8px 0 0 0;
+ color: var(--fgTransparentWeak);
+
+ &:empty {
+ display: none;
+ }
+}
+
+.input {
+ position: relative;
+
+ &.focused {
+ > .inputCore {
+ border-color: var(--accent) !important;
+ //box-shadow: 0 0 0 4px var(--focus);
+ }
+ }
+
+ &.disabled {
+ opacity: 0.7;
+
+ &,
+ > .inputCore {
+ cursor: not-allowed !important;
+ }
+ }
+}
+
+.inputCore {
+ appearance: none;
+ -webkit-appearance: none;
+ display: block;
+ height: 42px;
+ width: 100%;
+ margin: 0;
+ padding: 0 12px;
+ font: inherit;
+ font-weight: normal;
+ font-size: 1em;
+ color: var(--fg);
+ background: var(--panel);
+ border: solid 1px var(--panel);
+ border-radius: 6px;
+ outline: none;
+ box-shadow: none;
+ box-sizing: border-box;
+ transition: border-color 0.1s ease-out;
+
+ &:hover {
+ border-color: var(--inputBorderHover) !important;
+ }
+}
+</style>
diff --git a/packages/frontend/src/components/MkContainer.vue b/packages/frontend/src/components/MkContainer.vue
index a6372b7b6f..d03331a6eb 100644
--- a/packages/frontend/src/components/MkContainer.vue
+++ b/packages/frontend/src/components/MkContainer.vue
@@ -1,6 +1,6 @@
<template>
-<div class="_panel" :class="[$style.root, { [$style.naked]: naked, [$style.thin]: thin, [$style.hideHeader]: !showHeader, [$style.scrollable]: scrollable, [$style.closed]: !showBody }]">
- <header v-if="showHeader" ref="header" :class="$style.header">
+<div ref="rootEl" class="_panel" :class="[$style.root, { [$style.naked]: naked, [$style.thin]: thin, [$style.hideHeader]: !showHeader, [$style.scrollable]: scrollable, [$style.closed]: !showBody }]">
+ <header v-if="showHeader" ref="headerEl" :class="$style.header">
<div :class="$style.title">
<span :class="$style.titleIcon"><slot name="icon"></slot></span>
<slot name="header"></slot>
@@ -23,7 +23,7 @@
@leave="leave"
@after-leave="afterLeave"
>
- <div v-show="showBody" ref="content" :class="[$style.content, { [$style.omitted]: omitted }]">
+ <div v-show="showBody" ref="contentEl" :class="[$style.content, { [$style.omitted]: omitted }]">
<slot></slot>
<button v-if="omitted" :class="$style.fade" class="_button" @click="() => { ignoreOmit = true; omitted = false; }">
<span :class="$style.fadeLabel">{{ i18n.ts.showMore }}</span>
@@ -33,109 +33,80 @@
</div>
</template>
-<script lang="ts">
-import { defineComponent } from 'vue';
+<script lang="ts" setup>
+import { onMounted, ref, shallowRef, watch } from 'vue';
import { defaultStore } from '@/store';
import { i18n } from '@/i18n';
-export default defineComponent({
- props: {
- showHeader: {
- type: Boolean,
- required: false,
- default: true,
- },
- thin: {
- type: Boolean,
- required: false,
- default: false,
- },
- naked: {
- type: Boolean,
- required: false,
- default: false,
- },
- foldable: {
- type: Boolean,
- required: false,
- default: false,
- },
- expanded: {
- type: Boolean,
- required: false,
- default: true,
- },
- scrollable: {
- type: Boolean,
- required: false,
- default: false,
- },
- maxHeight: {
- type: Number,
- required: false,
- default: null,
- },
- },
- data() {
- return {
- showBody: this.expanded,
- omitted: null,
- ignoreOmit: false,
- defaultStore,
- i18n,
- };
- },
- mounted() {
- this.$watch('showBody', showBody => {
- const headerHeight = this.showHeader ? this.$refs.header.offsetHeight : 0;
- this.$el.style.minHeight = `${headerHeight}px`;
- if (showBody) {
- this.$el.style.flexBasis = 'auto';
- } else {
- this.$el.style.flexBasis = `${headerHeight}px`;
- }
- }, {
- immediate: true,
- });
+const props = withDefaults(defineProps<{
+ showHeader?: boolean;
+ thin?: boolean;
+ naked?: boolean;
+ foldable?: boolean;
+ scrollable?: boolean;
+ expanded?: boolean;
+ maxHeight?: number | null;
+}>(), {
+ expanded: true,
+ showHeader: true,
+ maxHeight: null,
+});
- this.$el.style.setProperty('--maxHeight', this.maxHeight + 'px');
+const rootEl = shallowRef<HTMLElement>();
+const contentEl = shallowRef<HTMLElement>();
+const headerEl = shallowRef<HTMLElement>();
+const showBody = ref(props.expanded);
+const ignoreOmit = ref(false);
+const omitted = ref(false);
- const calcOmit = () => {
- if (this.omitted || this.ignoreOmit || this.maxHeight == null) return;
- const height = this.$refs.content.offsetHeight;
- this.omitted = height > this.maxHeight;
- };
+function enter(el) {
+ const elementHeight = el.getBoundingClientRect().height;
+ el.style.height = 0;
+ el.offsetHeight; // reflow
+ el.style.height = Math.min(elementHeight, props.maxHeight ?? Infinity) + 'px';
+}
- calcOmit();
- new ResizeObserver((entries, observer) => {
- calcOmit();
- }).observe(this.$refs.content);
- },
- methods: {
- toggleContent(show: boolean) {
- if (!this.foldable) return;
- this.showBody = show;
- },
+function afterEnter(el) {
+ el.style.height = null;
+}
+
+function leave(el) {
+ const elementHeight = el.getBoundingClientRect().height;
+ el.style.height = elementHeight + 'px';
+ el.offsetHeight; // reflow
+ el.style.height = 0;
+}
- enter(el) {
- const elementHeight = el.getBoundingClientRect().height;
- el.style.height = 0;
- el.offsetHeight; // reflow
- el.style.height = elementHeight + 'px';
- },
- afterEnter(el) {
- el.style.height = null;
- },
- leave(el) {
- const elementHeight = el.getBoundingClientRect().height;
- el.style.height = elementHeight + 'px';
- el.offsetHeight; // reflow
- el.style.height = 0;
- },
- afterLeave(el) {
- el.style.height = null;
- },
- },
+function afterLeave(el) {
+ el.style.height = null;
+}
+
+const calcOmit = () => {
+ if (omitted.value || ignoreOmit.value || props.maxHeight == null) return;
+ const height = contentEl.value.offsetHeight;
+ omitted.value = height > props.maxHeight;
+};
+
+onMounted(() => {
+ watch(showBody, v => {
+ const headerHeight = props.showHeader ? headerEl.value.offsetHeight : 0;
+ rootEl.value.style.minHeight = `${headerHeight}px`;
+ if (v) {
+ rootEl.value.style.flexBasis = 'auto';
+ } else {
+ rootEl.value.style.flexBasis = `${headerHeight}px`;
+ }
+ }, {
+ immediate: true,
+ });
+
+ rootEl.value.style.setProperty('--maxHeight', props.maxHeight + 'px');
+
+ calcOmit();
+
+ new ResizeObserver((entries, observer) => {
+ calcOmit();
+ }).observe(contentEl.value);
});
</script>
diff --git a/packages/frontend/src/components/MkDialog.vue b/packages/frontend/src/components/MkDialog.vue
index 93c1f89199..9f5404ce15 100644
--- a/packages/frontend/src/components/MkDialog.vue
+++ b/packages/frontend/src/components/MkDialog.vue
@@ -9,7 +9,7 @@
<i v-else-if="type === 'error'" :class="$style.iconInner" class="ti ti-circle-x"></i>
<i v-else-if="type === 'warning'" :class="$style.iconInner" class="ti ti-alert-triangle"></i>
<i v-else-if="type === 'info'" :class="$style.iconInner" class="ti ti-info-circle"></i>
- <i v-else-if="type === 'question'" :class="$style.iconInner" class="ti ti-question-circle"></i>
+ <i v-else-if="type === 'question'" :class="$style.iconInner" class="ti ti-help-circle"></i>
<MkLoading v-else-if="type === 'waiting'" :class="$style.iconInner" :em="true"/>
</div>
<header v-if="title" :class="$style.title"><Mfm :text="title"/></header>
@@ -32,8 +32,8 @@
</template>
</MkSelect>
<div v-if="(showOkButton || showCancelButton) && !actions" :class="$style.buttons">
- <MkButton v-if="showOkButton" inline primary rounded :autofocus="!input && !select" :disabled="okButtonDisabled" @click="ok">{{ okText ?? ((showCancelButton || input || select) ? i18n.ts.ok : i18n.ts.gotIt) }}</MkButton>
- <MkButton v-if="showCancelButton || input || select" inline rounded @click="cancel">{{ cancelText ?? i18n.ts.cancel }}</MkButton>
+ <MkButton v-if="showOkButton" data-cy-modal-dialog-ok inline primary rounded :autofocus="!input && !select" :disabled="okButtonDisabled" @click="ok">{{ okText ?? ((showCancelButton || input || select) ? i18n.ts.ok : i18n.ts.gotIt) }}</MkButton>
+ <MkButton v-if="showCancelButton || input || select" data-cy-modal-dialog-cancel inline rounded @click="cancel">{{ cancelText ?? i18n.ts.cancel }}</MkButton>
</div>
<div v-if="actions" :class="$style.buttons">
<MkButton v-for="action in actions" :key="action.text" inline rounded :primary="action.primary" :danger="action.danger" @click="() => { action.callback(); modal?.close(); }">{{ action.text }}</MkButton>
@@ -183,7 +183,7 @@ onBeforeUnmount(() => {
box-sizing: border-box;
text-align: center;
background: var(--panel);
- border-radius: var(--radius);
+ border-radius: 16px;
}
.icon {
diff --git a/packages/frontend/src/components/MkFolder.vue b/packages/frontend/src/components/MkFolder.vue
index 58cc0de5c8..10eee6aab1 100644
--- a/packages/frontend/src/components/MkFolder.vue
+++ b/packages/frontend/src/components/MkFolder.vue
@@ -1,8 +1,8 @@
<template>
-<div ref="rootEl" :class="$style.root">
+<div ref="rootEl" :class="$style.root" role="group" :aria-expanded="opened">
<MkStickyContainer>
<template #header>
- <div :class="[$style.header, { [$style.opened]: opened }]" class="_button" @click="toggle">
+ <div :class="[$style.header, { [$style.opened]: opened }]" class="_button" role="button" data-cy-folder-header @click="toggle">
<div :class="$style.headerIcon"><slot name="icon"></slot></div>
<div :class="$style.headerText">
<div :class="$style.headerTextMain">
@@ -20,7 +20,7 @@
</div>
</template>
- <div v-if="openedAtLeastOnce" :class="[$style.body, { [$style.bgSame]: bgSame }]" :style="{ maxHeight: maxHeight ? `${maxHeight}px` : null, overflow: maxHeight ? `auto` : null }">
+ <div v-if="openedAtLeastOnce" :class="[$style.body, { [$style.bgSame]: bgSame }]" :style="{ maxHeight: maxHeight ? `${maxHeight}px` : null, overflow: maxHeight ? `auto` : null }" :aria-hidden="!opened">
<Transition
:enter-active-class="defaultStore.state.animation ? $style.transition_toggle_enterActive : ''"
:leave-active-class="defaultStore.state.animation ? $style.transition_toggle_leaveActive : ''"
@@ -65,7 +65,7 @@ const getBgColor = (el: HTMLElement) => {
}
};
-let rootEl = $ref<HTMLElement>();
+let rootEl = $shallowRef<HTMLElement>();
let bgSame = $ref(false);
let opened = $ref(props.defaultOpen);
let openedAtLeastOnce = $ref(props.defaultOpen);
@@ -196,7 +196,7 @@ onMounted(() => {
.headerRight {
margin-left: auto;
- opacity: 0.7;
+ color: var(--fgTransparentWeak);
white-space: nowrap;
}
diff --git a/packages/frontend/src/components/MkFollowButton.vue b/packages/frontend/src/components/MkFollowButton.vue
index de8db54bfa..beee21c647 100644
--- a/packages/frontend/src/components/MkFollowButton.vue
+++ b/packages/frontend/src/components/MkFollowButton.vue
@@ -178,7 +178,7 @@ onBeforeUnmount(() => {
}
&.active {
- color: #fff;
+ color: var(--fgOnAccent);
background: var(--accent);
&:hover {
diff --git a/packages/frontend/src/components/MkGalleryPostPreview.stories.impl.ts b/packages/frontend/src/components/MkGalleryPostPreview.stories.impl.ts
index e46a708192..57b3e75513 100644
--- a/packages/frontend/src/components/MkGalleryPostPreview.stories.impl.ts
+++ b/packages/frontend/src/components/MkGalleryPostPreview.stories.impl.ts
@@ -28,9 +28,11 @@ export const Default = {
async play({ canvasElement }) {
const canvas = within(canvasElement);
const links = canvas.getAllByRole('link');
- await expect(links).toHaveLength(2);
- await expect(links[0]).toHaveAttribute('href', `/gallery/${galleryPost().id}`);
- await expect(links[1]).toHaveAttribute('href', `/@${galleryPost().user.username}@${galleryPost().user.host}`);
+ expect(links).toHaveLength(2);
+ expect(links[0]).toHaveAttribute('href', `/gallery/${galleryPost().id}`);
+ expect(links[1]).toHaveAttribute('href', `/@${galleryPost().user.username}@${galleryPost().user.host}`);
+ const images = canvas.getAllByRole<HTMLImageElement>('img');
+ await waitFor(() => expect(Promise.all(images.map((image) => image.decode()))).resolves.toBeDefined());
},
args: {
post: galleryPost(),
diff --git a/packages/frontend/src/components/MkGalleryPostPreview.vue b/packages/frontend/src/components/MkGalleryPostPreview.vue
index 944f5ad97b..4f8f7b945a 100644
--- a/packages/frontend/src/components/MkGalleryPostPreview.vue
+++ b/packages/frontend/src/components/MkGalleryPostPreview.vue
@@ -1,9 +1,21 @@
<template>
<MkA :to="`/gallery/${post.id}`" class="ttasepnz _panel" tabindex="-1" @pointerenter="enterHover" @pointerleave="leaveHover">
<div class="thumbnail">
- <ImgWithBlurhash class="img" :hash="post.files[0].blurhash"/>
<Transition>
- <ImgWithBlurhash v-if="show" class="img layered" :src="post.files[0].thumbnailUrl" :hash="post.files[0].blurhash"/>
+ <ImgWithBlurhash
+ class="img layered"
+ :transition="safe ? null : {
+ enterActiveClass: $style.transition_toggle_enterActive,
+ leaveActiveClass: $style.transition_toggle_leaveActive,
+ enterFromClass: $style.transition_toggle_enterFrom,
+ leaveToClass: $style.transition_toggle_leaveTo,
+ enterToClass: $style.transition_toggle_enterTo,
+ leaveFromClass: $style.transition_toggle_leaveFrom,
+ }"
+ :src="post.files[0].thumbnailUrl"
+ :hash="post.files[0].blurhash"
+ :force-blurhash="!show"
+ />
</Transition>
</div>
<article>
@@ -28,7 +40,8 @@ const props = defineProps<{
}>();
const hover = ref(false);
-const show = computed(() => defaultStore.state.nsfw === 'ignore' || defaultStore.state.nsfw === 'respect' && !props.post.isSensitive || hover.value);
+const safe = computed(() => defaultStore.state.nsfw === 'ignore' || defaultStore.state.nsfw === 'respect' && !props.post.isSensitive);
+const show = computed(() => safe.value || hover.value);
function enterHover(): void {
hover.value = true;
@@ -39,6 +52,27 @@ function leaveHover(): void {
}
</script>
+<style lang="scss" module>
+.transition_toggle_enterActive,
+.transition_toggle_leaveActive {
+ transition: opacity 0.5s;
+ position: absolute;
+ top: 0;
+ left: 0;
+}
+
+.transition_toggle_enterFrom,
+.transition_toggle_leaveTo {
+ opacity: 0;
+}
+
+.transition_toggle_enterTo,
+.transition_toggle_leaveFrom {
+ transition: none;
+ opacity: 1;
+}
+</style>
+
<style lang="scss" scoped>
.ttasepnz {
display: block;
@@ -66,7 +100,7 @@ function leaveHover(): void {
width: 100%;
height: 100%;
position: absolute;
- transition: all 0.5s ease;
+ transition: transform 0.5s ease;
> .img {
width: 100%;
@@ -76,16 +110,6 @@ function leaveHover(): void {
&.layered {
position: absolute;
top: 0;
-
- &.v-enter-active,
- &.v-leave-active {
- transition: opacity 0.5s ease;
- }
-
- &.v-enter-from,
- &.v-leave-to {
- opacity: 0;
- }
}
}
}
diff --git a/packages/frontend/src/components/MkImgWithBlurhash.vue b/packages/frontend/src/components/MkImgWithBlurhash.vue
index 944c76d7dc..6406a35060 100644
--- a/packages/frontend/src/components/MkImgWithBlurhash.vue
+++ b/packages/frontend/src/components/MkImgWithBlurhash.vue
@@ -1,44 +1,90 @@
<template>
-<div :class="[$style.root, { [$style.cover]: cover }]" :title="title">
- <canvas v-if="!loaded" ref="canvas" :class="$style.canvas" :width="size" :height="size" :title="title"/>
- <img v-if="src" :class="$style.img" :src="src" :title="title" :alt="alt" @load="onLoad"/>
+<div :class="[$style.root, { [$style.cover]: cover }]" :title="title ?? ''">
+ <img v-if="!loaded && src && !forceBlurhash" :class="$style.loader" :src="src" @load="onLoad"/>
+ <Transition
+ mode="in-out"
+ :enter-active-class="defaultStore.state.animation && (props.transition?.enterActiveClass ?? $style['transition_toggle_enterActive']) || undefined"
+ :leave-active-class="defaultStore.state.animation && (props.transition?.leaveActiveClass ?? $style['transition_toggle_leaveActive']) || undefined"
+ :enter-from-class="defaultStore.state.animation && props.transition?.enterFromClass || undefined"
+ :leave-to-class="defaultStore.state.animation && props.transition?.leaveToClass || undefined"
+ :enter-to-class="defaultStore.state.animation && (props.transition?.enterToClass ?? $style['transition_toggle_enterTo']) || undefined"
+ :leave-from-class="defaultStore.state.animation && (props.transition?.leaveFromClass ?? $style['transition_toggle_leaveFrom']) || undefined"
+ >
+ <canvas v-if="!loaded || forceBlurhash" ref="canvas" :class="$style.canvas" :width="width" :height="height" :title="title ?? undefined"/>
+ <img v-else :class="$style.img" :src="src ?? undefined" :title="title ?? undefined" :alt="alt ?? undefined"/>
+ </Transition>
</div>
</template>
<script lang="ts" setup>
-import { onMounted } from 'vue';
+import { onMounted, shallowRef, useCssModule, watch } from 'vue';
import { decode } from 'blurhash';
+import { defaultStore } from '@/store';
+
+const $style = useCssModule();
const props = withDefaults(defineProps<{
+ transition?: {
+ enterActiveClass?: string;
+ leaveActiveClass?: string;
+ enterFromClass?: string;
+ leaveToClass?: string;
+ enterToClass?: string;
+ leaveFromClass?: string;
+ } | null;
src?: string | null;
hash?: string;
- alt?: string;
+ alt?: string | null;
title?: string | null;
- size?: number;
+ height?: number;
+ width?: number;
cover?: boolean;
+ forceBlurhash?: boolean;
}>(), {
+ transition: null,
src: null,
alt: '',
title: null,
- size: 64,
+ height: 64,
+ width: 64,
cover: true,
+ forceBlurhash: false,
});
-const canvas = $shallowRef<HTMLCanvasElement>();
+const canvas = shallowRef<HTMLCanvasElement>();
let loaded = $ref(false);
+let width = $ref(props.width);
+let height = $ref(props.height);
+
+function onLoad() {
+ loaded = true;
+}
+
+watch([() => props.width, () => props.height], () => {
+ const ratio = props.width / props.height;
+ if (ratio > 1) {
+ width = Math.round(64 * ratio);
+ height = 64;
+ } else {
+ width = 64;
+ height = Math.round(64 / ratio);
+ }
+}, {
+ immediate: true,
+});
function draw() {
- if (props.hash == null) return;
- const pixels = decode(props.hash, props.size, props.size);
- const ctx = canvas.getContext('2d');
- const imageData = ctx!.createImageData(props.size, props.size);
+ if (props.hash == null || !canvas.value) return;
+ const pixels = decode(props.hash, width, height);
+ const ctx = canvas.value.getContext('2d');
+ const imageData = ctx!.createImageData(width, height);
imageData.data.set(pixels);
ctx!.putImageData(imageData, 0, 0);
}
-function onLoad() {
- loaded = true;
-}
+watch([() => props.hash, canvas], () => {
+ draw();
+});
onMounted(() => {
draw();
@@ -46,12 +92,33 @@ onMounted(() => {
</script>
<style lang="scss" module>
+.transition_toggle_enterActive,
+.transition_toggle_leaveActive {
+ position: absolute;
+ top: 0;
+ left: 0;
+}
+
+.transition_toggle_enterTo,
+.transition_toggle_leaveFrom {
+ opacity: 0;
+}
+
+.loader {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 0;
+ height: 0;
+}
+
.root {
position: relative;
width: 100%;
height: 100%;
&.cover {
+ > .canvas,
> .img {
object-fit: cover;
}
@@ -66,8 +133,7 @@ onMounted(() => {
}
.canvas {
- position: absolute;
- object-fit: cover;
+ object-fit: contain;
}
.img {
diff --git a/packages/frontend/src/components/MkInfo.vue b/packages/frontend/src/components/MkInfo.vue
index dc7344d707..cda428a77c 100644
--- a/packages/frontend/src/components/MkInfo.vue
+++ b/packages/frontend/src/components/MkInfo.vue
@@ -21,6 +21,7 @@ const props = defineProps<{
background: var(--infoBg);
color: var(--infoFg);
border-radius: var(--radius);
+ white-space: pre-wrap;
&.warn {
background: var(--infoWarnBg);
diff --git a/packages/frontend/src/components/MkInput.vue b/packages/frontend/src/components/MkInput.vue
index 3e3d7354c1..e48032d599 100644
--- a/packages/frontend/src/components/MkInput.vue
+++ b/packages/frontend/src/components/MkInput.vue
@@ -1,12 +1,13 @@
<template>
-<div class="matxzzsk">
- <div class="label" @click="focus"><slot name="label"></slot></div>
- <div class="input" :class="{ inline, disabled, focused }">
- <div ref="prefixEl" class="prefix"><slot name="prefix"></slot></div>
+<div>
+ <div :class="$style.label" @click="focus"><slot name="label"></slot></div>
+ <div :class="[$style.input, { [$style.inline]: inline, [$style.disabled]: disabled, [$style.focused]: focused }]">
+ <div ref="prefixEl" :class="$style.prefix"><slot name="prefix"></slot></div>
<input
ref="inputEl"
v-model="v"
v-adaptive-border
+ :class="$style.inputCore"
:type="type"
:disabled="disabled"
:required="required"
@@ -25,11 +26,11 @@
<datalist v-if="datalist" :id="id">
<option v-for="data in datalist" :key="data" :value="data"/>
</datalist>
- <div ref="suffixEl" class="suffix"><slot name="suffix"></slot></div>
+ <div ref="suffixEl" :class="$style.suffix"><slot name="suffix"></slot></div>
</div>
- <div class="caption"><slot name="caption"></slot></div>
+ <div :class="$style.caption"><slot name="caption"></slot></div>
- <MkButton v-if="manualSave && changed" primary class="save" @click="updated"><i class="ti ti-check"></i> {{ i18n.ts.save }}</MkButton>
+ <MkButton v-if="manualSave && changed" primary :class="$style.save" @click="updated"><i class="ti ti-check"></i> {{ i18n.ts.save }}</MkButton>
</div>
</template>
@@ -151,115 +152,110 @@ onMounted(() => {
});
</script>
-<style lang="scss" scoped>
-.matxzzsk {
- > .label {
- font-size: 0.85em;
- padding: 0 0 8px 0;
- user-select: none;
+<style lang="scss" module>
+.label {
+ font-size: 0.85em;
+ padding: 0 0 8px 0;
+ user-select: none;
- &:empty {
- display: none;
- }
+ &:empty {
+ display: none;
}
+}
- > .caption {
- font-size: 0.85em;
- padding: 8px 0 0 0;
- color: var(--fgTransparentWeak);
+.caption {
+ font-size: 0.85em;
+ padding: 8px 0 0 0;
+ color: var(--fgTransparentWeak);
- &:empty {
- display: none;
- }
+ &:empty {
+ display: none;
}
+}
- > .input {
- position: relative;
-
- > input {
- appearance: none;
- -webkit-appearance: none;
- display: block;
- height: v-bind("height + 'px'");
- width: 100%;
- margin: 0;
- padding: 0 12px;
- font: inherit;
- font-weight: normal;
- font-size: 1em;
- color: var(--fg);
- background: var(--panel);
- border: solid 1px var(--panel);
- border-radius: 6px;
- outline: none;
- box-shadow: none;
- box-sizing: border-box;
- transition: border-color 0.1s ease-out;
-
- &:hover {
- border-color: var(--inputBorderHover) !important;
- }
- }
-
- > .prefix,
- > .suffix {
- display: flex;
- align-items: center;
- position: absolute;
- z-index: 1;
- top: 0;
- padding: 0 12px;
- font-size: 1em;
- height: v-bind("height + 'px'");
- pointer-events: none;
+.input {
+ position: relative;
- &:empty {
- display: none;
- }
+ &.inline {
+ display: inline-block;
+ margin: 0;
+ }
- > * {
- display: inline-block;
- min-width: 16px;
- max-width: 150px;
- overflow: hidden;
- white-space: nowrap;
- text-overflow: ellipsis;
- }
+ &.focused {
+ > .inputCore {
+ border-color: var(--accent) !important;
+ //box-shadow: 0 0 0 4px var(--focus);
}
+ }
- > .prefix {
- left: 0;
- padding-right: 6px;
- }
+ &.disabled {
+ opacity: 0.7;
- > .suffix {
- right: 0;
- padding-left: 6px;
+ &,
+ > .inputCore {
+ cursor: not-allowed !important;
}
+ }
+}
- &.inline {
- display: inline-block;
- margin: 0;
- }
+.inputCore {
+ appearance: none;
+ -webkit-appearance: none;
+ display: block;
+ height: v-bind("height + 'px'");
+ width: 100%;
+ margin: 0;
+ padding: 0 12px;
+ font: inherit;
+ font-weight: normal;
+ font-size: 1em;
+ color: var(--fg);
+ background: var(--panel);
+ border: solid 1px var(--panel);
+ border-radius: 6px;
+ outline: none;
+ box-shadow: none;
+ box-sizing: border-box;
+ transition: border-color 0.1s ease-out;
- &.focused {
- > input {
- border-color: var(--accent) !important;
- //box-shadow: 0 0 0 4px var(--focus);
- }
- }
+ &:hover {
+ border-color: var(--inputBorderHover) !important;
+ }
+}
- &.disabled {
- opacity: 0.7;
+.prefix,
+.suffix {
+ display: flex;
+ align-items: center;
+ position: absolute;
+ z-index: 1;
+ top: 0;
+ padding: 0 12px;
+ font-size: 1em;
+ height: v-bind("height + 'px'");
+ min-width: 16px;
+ max-width: 150px;
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ box-sizing: border-box;
+ pointer-events: none;
- &, * {
- cursor: not-allowed !important;
- }
- }
+ &:empty {
+ display: none;
}
+}
- > .save {
- margin: 8px 0 0 0;
- }
+.prefix {
+ left: 0;
+ padding-right: 6px;
+}
+
+.suffix {
+ right: 0;
+ padding-left: 6px;
+}
+.save {
+ margin: 8px 0 0 0;
}
</style>
diff --git a/packages/frontend/src/components/MkMediaImage.vue b/packages/frontend/src/components/MkMediaImage.vue
index a4065dcd07..42dc9e79ff 100644
--- a/packages/frontend/src/components/MkMediaImage.vue
+++ b/packages/frontend/src/components/MkMediaImage.vue
@@ -1,9 +1,10 @@
<template>
<div v-if="hide" :class="$style.hidden" @click="hide = false">
- <ImgWithBlurhash style="filter: brightness(0.5);" :hash="image.blurhash" :title="image.comment" :alt="image.comment"/>
+ <ImgWithBlurhash style="filter: brightness(0.5);" :hash="image.blurhash" :title="image.comment" :alt="image.comment" :width="image.properties.width" :height="image.properties.height" :force-blurhash="defaultStore.state.enableDataSaverMode"/>
<div :class="$style.hiddenText">
<div :class="$style.hiddenTextWrapper">
- <b style="display: block;"><i class="ti ti-alert-triangle"></i> {{ i18n.ts.sensitive }}</b>
+ <b v-if="image.isSensitive" style="display: block;"><i class="ti ti-alert-triangle"></i> {{ i18n.ts.sensitive }}{{ defaultStore.state.enableDataSaverMode ? ` (${i18n.ts.image}${image.size ? ' ' + bytes(image.size) : ''})` : '' }}</b>
+ <b v-else style="display: block;"><i class="ti ti-photo"></i> {{ defaultStore.state.enableDataSaverMode && image.size ? bytes(image.size) : i18n.ts.image }}</b>
<span style="display: block;">{{ i18n.ts.clickToShow }}</span>
</div>
</div>
@@ -14,13 +15,15 @@
:href="image.url"
:title="image.name"
>
- <ImgWithBlurhash :hash="image.blurhash" :src="url" :alt="image.comment || image.name" :title="image.comment || image.name" :cover="false"/>
+ <ImgWithBlurhash :hash="image.blurhash" :src="url" :alt="image.comment || image.name" :title="image.comment || image.name" :width="image.properties.width" :height="image.properties.height" :cover="false"/>
</a>
<div :class="$style.indicators">
<div v-if="['image/gif', 'image/apng'].includes(image.type)" :class="$style.indicator">GIF</div>
<div v-if="image.comment" :class="$style.indicator">ALT</div>
+ <div v-if="image.isSensitive" :class="$style.indicator" style="color: var(--warn);">NSFW</div>
</div>
<button v-tooltip="i18n.ts.hide" :class="$style.hide" class="_button" @click="hide = true"><i class="ti ti-eye-off"></i></button>
+ <button :class="$style.menu" class="_button" @click.stop="showMenu"><i class="ti ti-dots"></i></button>
</div>
</template>
@@ -28,9 +31,12 @@
import { watch } from 'vue';
import * as misskey from 'misskey-js';
import { getStaticImageUrl } from '@/scripts/media-proxy';
+import bytes from '@/filters/bytes';
import ImgWithBlurhash from '@/components/MkImgWithBlurhash.vue';
import { defaultStore } from '@/store';
import { i18n } from '@/i18n';
+import * as os from '@/os';
+import { iAmModerator } from '@/account';
const props = defineProps<{
image: misskey.entities.DriveFile;
@@ -38,21 +44,33 @@ const props = defineProps<{
}>();
let hide = $ref(true);
-let darkMode = $ref(defaultStore.state.darkMode);
+let darkMode: boolean = $ref(defaultStore.state.darkMode);
-const url = (props.raw || defaultStore.state.loadRawImages)
+const url = $computed(() => (props.raw || defaultStore.state.loadRawImages)
? props.image.url
: defaultStore.state.disableShowingAnimatedImages
? getStaticImageUrl(props.image.url)
- : props.image.thumbnailUrl;
+ : props.image.thumbnailUrl,
+);
// Plugin:register_note_view_interruptor を使って書き換えられる可能性があるためwatchする
watch(() => props.image, () => {
- hide = (defaultStore.state.nsfw === 'force') ? true : props.image.isSensitive && (defaultStore.state.nsfw !== 'ignore');
+ hide = (defaultStore.state.nsfw === 'force' || defaultStore.state.enableDataSaverMode) ? true : (props.image.isSensitive && defaultStore.state.nsfw !== 'ignore');
}, {
deep: true,
immediate: true,
});
+
+function showMenu(ev: MouseEvent) {
+ os.popupMenu([...(iAmModerator ? [{
+ text: i18n.ts.markAsSensitive,
+ icon: 'ti ti-eye-off',
+ action: () => {
+ os.apiWithDialog('drive/files/update', { fileId: props.image.id, isSensitive: true });
+ },
+ }] : [])], ev.currentTarget ?? ev.target);
+}
+
</script>
<style lang="scss" module>
@@ -102,6 +120,21 @@ watch(() => props.image, () => {
right: 12px;
}
+.menu {
+ display: block;
+ position: absolute;
+ border-radius: 6px;
+ background-color: rgba(0, 0, 0, 0.3);
+ -webkit-backdrop-filter: var(--blur, blur(15px));
+ backdrop-filter: var(--blur, blur(15px));
+ color: #fff;
+ font-size: 0.8em;
+ padding: 6px 8px;
+ text-align: center;
+ bottom: 12px;
+ right: 12px;
+}
+
.imageContainer {
display: block;
cursor: zoom-in;
@@ -132,6 +165,7 @@ watch(() => props.image, () => {
color: var(--accentLighten);
display: inline-block;
font-weight: bold;
- padding: 0 6px;
+ font-size: 12px;
+ padding: 2px 6px;
}
</style>
diff --git a/packages/frontend/src/components/MkMediaList.vue b/packages/frontend/src/components/MkMediaList.vue
index d36cc2d26b..e456ff3eec 100644
--- a/packages/frontend/src/components/MkMediaList.vue
+++ b/packages/frontend/src/components/MkMediaList.vue
@@ -2,10 +2,16 @@
<div>
<XBanner v-for="media in mediaList.filter(media => !previewable(media))" :key="media.id" :media="media"/>
<div v-if="mediaList.filter(media => previewable(media)).length > 0" :class="$style.container">
- <div ref="gallery" :class="[$style.medias, count <= 4 ? $style['n' + count] : $style.nMany]">
+ <div
+ ref="gallery"
+ :class="[
+ $style.medias,
+ count <= 4 ? $style['n' + count] : $style.nMany,
+ ]"
+ >
<template v-for="media in mediaList.filter(media => previewable(media))">
- <XVideo v-if="media.type.startsWith('video')" :key="media.id" :class="$style.media" :video="media"/>
- <XImage v-else-if="media.type.startsWith('image')" :key="media.id" :class="$style.media" class="image" :data-id="media.id" :image="media" :raw="raw"/>
+ <XVideo v-if="media.type.startsWith('video')" :key="`video:${media.id}`" :class="$style.media" :video="media"/>
+ <XImage v-else-if="media.type.startsWith('image')" :key="`image:${media.id}`" :class="$style.media" class="image" :data-id="media.id" :image="media" :raw="raw"/>
</template>
</div>
</div>
@@ -13,7 +19,7 @@
</template>
<script lang="ts" setup>
-import { onMounted, ref, useCssModule } from 'vue';
+import { onMounted, ref, useCssModule, watch } from 'vue';
import * as misskey from 'misskey-js';
import PhotoSwipeLightbox from 'photoswipe/lightbox';
import PhotoSwipe from 'photoswipe';
@@ -23,6 +29,7 @@ import XImage from '@/components/MkMediaImage.vue';
import XVideo from '@/components/MkMediaVideo.vue';
import * as os from '@/os';
import { FILE_TYPE_BROWSERSAFE } from '@/const';
+import { defaultStore } from '@/store';
const props = defineProps<{
mediaList: misskey.entities.DriveFile[];
@@ -31,7 +38,7 @@ const props = defineProps<{
const $style = useCssModule();
-const gallery = ref(null);
+const gallery = ref<HTMLDivElement>();
const pswpZIndex = os.claimZIndex('middle');
document.documentElement.style.setProperty('--mk-pswp-root-z-index', pswpZIndex.toString());
const count = $computed(() => props.mediaList.filter(media => previewable(media)).length);
diff --git a/packages/frontend/src/components/MkMediaVideo.vue b/packages/frontend/src/components/MkMediaVideo.vue
index e02a7af09e..a4b76300e6 100644
--- a/packages/frontend/src/components/MkMediaVideo.vue
+++ b/packages/frontend/src/components/MkMediaVideo.vue
@@ -1,7 +1,9 @@
<template>
<div v-if="hide" class="icozogqfvdetwohsdglrbswgrejoxbdj" @click="hide = false">
+ <!-- 【注意】dataSaverMode が有効になっている際には、hide が false になるまでサムネイルや動画を読み込まないようにすること -->
<div>
- <b><i class="ti ti-alert-triangle"></i> {{ i18n.ts.sensitive }}</b>
+ <b v-if="video.isSensitive"><i class="ti ti-alert-triangle"></i> {{ i18n.ts.sensitive }}{{ defaultStore.state.enableDataSaverMode ? ` (${i18n.ts.video}${video.size ? ' ' + bytes(video.size) : ''})` : '' }}</b>
+ <b v-else><i class="ti ti-movie"></i> {{ defaultStore.state.enableDataSaverMode && video.size ? bytes(video.size) : i18n.ts.video }}</b>
<span>{{ i18n.ts.clickToShow }}</span>
</div>
</div>
@@ -25,6 +27,7 @@
<script lang="ts" setup>
import { ref } from 'vue';
import * as misskey from 'misskey-js';
+import bytes from '@/filters/bytes';
import VuePlyr from 'vue-plyr';
import { defaultStore } from '@/store';
import 'vue-plyr/dist/vue-plyr.css';
@@ -34,7 +37,7 @@ const props = defineProps<{
video: misskey.entities.DriveFile;
}>();
-const hide = ref((defaultStore.state.nsfw === 'force') ? true : props.video.isSensitive && (defaultStore.state.nsfw !== 'ignore'));
+const hide = ref((defaultStore.state.nsfw === 'force' || defaultStore.state.enableDataSaverMode) ? true : (props.video.isSensitive && defaultStore.state.nsfw !== 'ignore'));
</script>
<style lang="scss" scoped>
diff --git a/packages/frontend/src/components/MkModal.vue b/packages/frontend/src/components/MkModal.vue
index 852c72f6ff..99df9e8150 100644
--- a/packages/frontend/src/components/MkModal.vue
+++ b/packages/frontend/src/components/MkModal.vue
@@ -404,16 +404,10 @@ defineExpose({
right: 0;
margin: auto;
padding: 32px;
- // TODO: mask-imageはiOSだとやたら重い。なんとかしたい
- -webkit-mask-image: linear-gradient(0deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 32px, rgba(0,0,0,1) calc(100% - 32px), rgba(0,0,0,0) 100%);
- mask-image: linear-gradient(0deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 32px, rgba(0,0,0,1) calc(100% - 32px), rgba(0,0,0,0) 100%);
- overflow: auto;
display: flex;
@media (max-width: 500px) {
padding: 16px;
- -webkit-mask-image: linear-gradient(0deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 16px, rgba(0,0,0,1) calc(100% - 16px), rgba(0,0,0,0) 100%);
- mask-image: linear-gradient(0deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 16px, rgba(0,0,0,1) calc(100% - 16px), rgba(0,0,0,0) 100%);
}
}
}
diff --git a/packages/frontend/src/components/MkModalWindow.vue b/packages/frontend/src/components/MkModalWindow.vue
index dd115246ff..1c942cfd0d 100644
--- a/packages/frontend/src/components/MkModalWindow.vue
+++ b/packages/frontend/src/components/MkModalWindow.vue
@@ -1,12 +1,12 @@
<template>
<MkModal ref="modal" :prefer-type="'dialog'" @click="onBgClick" @closed="$emit('closed')">
- <div ref="rootEl" class="ebkgoccj" :style="{ width: `${width}px`, height: scroll ? (height ? `${height}px` : null) : (height ? `min(${height}px, 100%)` : '100%') }" @keydown="onKeydown">
+ <div ref="rootEl" class="ebkgoccj" :style="{ width: `${width}px`, height: height ? `${height}px` : null }" @keydown="onKeydown">
<div ref="headerEl" class="header">
<button v-if="withOkButton" class="_button" @click="$emit('close')"><i class="ti ti-x"></i></button>
<span class="title">
<slot name="header"></slot>
</span>
- <button v-if="!withOkButton" class="_button" @click="$emit('close')"><i class="ti ti-x"></i></button>
+ <button v-if="!withOkButton" class="_button" data-cy-modal-window-close @click="$emit('close')"><i class="ti ti-x"></i></button>
<button v-if="withOkButton" class="_button" :disabled="okButtonDisabled" @click="$emit('ok')"><i class="ti ti-check"></i></button>
</div>
<div class="body">
@@ -25,13 +25,11 @@ const props = withDefaults(defineProps<{
okButtonDisabled: boolean;
width: number;
height: number | null;
- scroll: boolean;
}>(), {
withOkButton: false,
okButtonDisabled: false,
width: 400,
height: null,
- scroll: true,
});
const emit = defineEmits<{
@@ -86,11 +84,11 @@ defineExpose({
<style lang="scss" scoped>
.ebkgoccj {
margin: auto;
+ max-height: 100%;
overflow: hidden;
display: flex;
flex-direction: column;
contain: content;
- container-type: inline-size;
border-radius: var(--radius);
--root-margin: 24px;
@@ -143,6 +141,7 @@ defineExpose({
flex: 1;
overflow: auto;
background: var(--panel);
+ container-type: size;
}
}
</style>
diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue
index 36ec778a14..d95f8de311 100644
--- a/packages/frontend/src/components/MkNote.vue
+++ b/packages/frontend/src/components/MkNote.vue
@@ -12,6 +12,7 @@
<!--<div v-if="appearNote._prId_" class="tip"><i class="ti ti-speakerphone"></i> {{ i18n.ts.promotion }}<button class="_textButton hide" @click="readPromo()">{{ i18n.ts.hideThisNote }} <i class="ti ti-x"></i></button></div>-->
<!--<div v-if="appearNote._featuredId_" class="tip"><i class="ti ti-bolt"></i> {{ i18n.ts.featured }}</div>-->
<div v-if="isRenote" :class="$style.renote">
+ <div v-if="note.channel" :class="$style.colorBar" :style="{ background: note.channel.color }"></div>
<MkAvatar :class="$style.renoteAvatar" :user="note.user" link preview/>
<i class="ti ti-repeat" style="margin-right: 4px;"></i>
<I18n :src="i18n.ts.renotedBy" tag="span" :class="$style.renoteText">
@@ -40,6 +41,7 @@
<Mfm :text="getNoteSummary(appearNote)" :plain="true" :nowrap="true" :author="appearNote.user" :class="$style.collapsedRenoteTargetText" @click="renoteCollapsed = false"/>
</div>
<article v-else :class="$style.article" @contextmenu.stop="onContextmenu">
+ <div v-if="appearNote.channel" :class="$style.colorBar" :style="{ background: appearNote.channel.color }"></div>
<MkAvatar :class="$style.avatar" :user="appearNote.user" link preview/>
<div :class="$style.main">
<MkNoteHeader :class="$style.header" :note="appearNote" :mini="true"/>
@@ -162,6 +164,7 @@ import { claimAchievement } from '@/scripts/achievements';
import { getNoteSummary } from '@/scripts/get-note-summary';
import { MenuItem } from '@/types/menu';
import MkRippleEffect from '@/components/MkRippleEffect.vue';
+import { showMovedDialog } from '@/scripts/show-moved-dialog';
const props = defineProps<{
note: misskey.entities.Note;
@@ -255,6 +258,7 @@ useTooltip(renoteButton, async (showing) => {
function renote(viaKeyboard = false) {
pleaseLogin();
+ showMovedDialog();
let items = [] as MenuItem[];
@@ -335,6 +339,7 @@ function reply(viaKeyboard = false): void {
function react(viaKeyboard = false): void {
pleaseLogin();
+ showMovedDialog();
if (appearNote.reactionAcceptance === 'likeOnly') {
os.api('notes/reactions/create', {
noteId: appearNote.id,
@@ -401,6 +406,7 @@ async function clip() {
function showRenoteMenu(viaKeyboard = false): void {
if (!isMyRenote) return;
+ pleaseLogin();
os.popupMenu([{
text: i18n.ts.unrenote,
icon: 'ti ti-trash',
@@ -484,6 +490,11 @@ function showReactions(): void {
}
}
+ .footer {
+ position: relative;
+ z-index: 1;
+ }
+
&:hover > .article > .main > .footer > .footerButton {
opacity: 1;
}
@@ -537,6 +548,7 @@ function showReactions(): void {
}
.renote {
+ position: relative;
display: flex;
align-items: center;
padding: 16px 32px 8px 32px;
@@ -547,6 +559,10 @@ function showReactions(): void {
& + .article {
padding-top: 8px;
}
+
+ > .colorBar {
+ height: calc(100% - 6px);
+ }
}
.renoteAvatar {
@@ -618,6 +634,16 @@ function showReactions(): void {
padding: 28px 32px;
}
+.colorBar {
+ position: absolute;
+ top: 8px;
+ left: 8px;
+ width: 5px;
+ height: calc(100% - 16px);
+ border-radius: 999px;
+ pointer-events: none;
+}
+
.avatar {
flex-shrink: 0;
display: block !important;
@@ -669,6 +695,7 @@ function showReactions(): void {
position: absolute;
bottom: 0;
left: 0;
+ z-index: 2;
width: 100%;
height: 64px;
background: linear-gradient(0deg, var(--panel), var(--X15));
@@ -833,6 +860,13 @@ function showReactions(): void {
}
}
}
+
+ .colorBar {
+ top: 6px;
+ left: 6px;
+ width: 4px;
+ height: calc(100% - 12px);
+ }
}
@container (max-width: 300px) {
diff --git a/packages/frontend/src/components/MkNoteDetailed.vue b/packages/frontend/src/components/MkNoteDetailed.vue
index b9ab366850..0d6d329d98 100644
--- a/packages/frontend/src/components/MkNoteDetailed.vue
+++ b/packages/frontend/src/components/MkNoteDetailed.vue
@@ -166,6 +166,7 @@ import { useTooltip } from '@/scripts/use-tooltip';
import { claimAchievement } from '@/scripts/achievements';
import { MenuItem } from '@/types/menu';
import MkRippleEffect from '@/components/MkRippleEffect.vue';
+import { showMovedDialog } from '@/scripts/show-moved-dialog';
const props = defineProps<{
note: misskey.entities.Note;
@@ -248,6 +249,7 @@ useTooltip(renoteButton, async (showing) => {
function renote(viaKeyboard = false) {
pleaseLogin();
+ showMovedDialog();
let items = [] as MenuItem[];
@@ -318,6 +320,7 @@ function renote(viaKeyboard = false) {
function reply(viaKeyboard = false): void {
pleaseLogin();
+ showMovedDialog();
os.post({
reply: appearNote,
animation: !viaKeyboard,
@@ -328,6 +331,7 @@ function reply(viaKeyboard = false): void {
function react(viaKeyboard = false): void {
pleaseLogin();
+ showMovedDialog();
if (appearNote.reactionAcceptance === 'likeOnly') {
os.api('notes/reactions/create', {
noteId: appearNote.id,
@@ -394,6 +398,7 @@ async function clip() {
function showRenoteMenu(viaKeyboard = false): void {
if (!isMyRenote) return;
+ pleaseLogin();
os.popupMenu([{
text: i18n.ts.unrenote,
icon: 'ti ti-trash',
diff --git a/packages/frontend/src/components/MkNumberDiff.vue b/packages/frontend/src/components/MkNumberDiff.vue
index e7d4a5472a..303417dae8 100644
--- a/packages/frontend/src/components/MkNumberDiff.vue
+++ b/packages/frontend/src/components/MkNumberDiff.vue
@@ -1,47 +1,32 @@
<template>
-<span class="ceaaebcd" :class="{ isPlus, isMinus, isZero }">
+<span class="ceaaebcd" :class="{ [$style.isPlus]: isPlus, [$style.isMinus]: isMinus, [$style.isZero]: isZero }">
<slot name="before"></slot>{{ isPlus ? '+' : '' }}{{ number(value) }}<slot name="after"></slot>
</span>
</template>
-<script lang="ts">
-import { computed, defineComponent } from 'vue';
+<script lang="ts" setup>
+import { computed } from 'vue';
import number from '@/filters/number';
-export default defineComponent({
- props: {
- value: {
- type: Number,
- required: true,
- },
- },
+const props = defineProps<{
+ value: number;
+}>();
- setup(props) {
- const isPlus = computed(() => props.value > 0);
- const isMinus = computed(() => props.value < 0);
- const isZero = computed(() => props.value === 0);
- return {
- isPlus,
- isMinus,
- isZero,
- number,
- };
- },
-});
+const isPlus = computed(() => props.value > 0);
+const isMinus = computed(() => props.value < 0);
+const isZero = computed(() => props.value === 0);
</script>
-<style lang="scss" scoped>
-.ceaaebcd {
- &.isPlus {
- color: var(--success);
- }
+<style lang="scss" module>
+.isPlus {
+ color: var(--success);
+}
- &.isMinus {
- color: var(--error);
- }
+.isMinus {
+ color: var(--error);
+}
- &.isZero {
- opacity: 0.5;
- }
+.isZero {
+ opacity: 0.5;
}
</style>
diff --git a/packages/frontend/src/components/MkOmit.vue b/packages/frontend/src/components/MkOmit.vue
index 0f148022bf..e2d68d12c3 100644
--- a/packages/frontend/src/components/MkOmit.vue
+++ b/packages/frontend/src/components/MkOmit.vue
@@ -17,7 +17,7 @@ const props = withDefaults(defineProps<{
maxHeight: 200,
});
-let content = $ref<HTMLElement>();
+let content = $shallowRef<HTMLElement>();
let omitted = $ref(false);
let ignoreOmit = $ref(false);
diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue
index 42a3748d9a..c65cb7d6e5 100644
--- a/packages/frontend/src/components/MkPostForm.vue
+++ b/packages/frontend/src/components/MkPostForm.vue
@@ -247,6 +247,10 @@ watch($$(text), () => {
checkMissingMention();
}, { immediate: true });
+watch($$(visibility), () => {
+ checkMissingMention();
+}, { immediate: true });
+
watch($$(visibleUsers), () => {
checkMissingMention();
}, {
@@ -900,27 +904,28 @@ defineExpose({
}
.headerLeft {
- display: grid;
- grid-template-columns: repeat(2, minmax(36px, 50px));
- grid-template-rows: minmax(40px, 100%);
+ display: flex;
+ flex: 0 1 100px;
}
.cancel {
padding: 0;
font-size: 1em;
height: 100%;
+ flex: 0 1 50px;
}
.account {
height: 100%;
display: inline-flex;
vertical-align: bottom;
+ flex: 0 1 50px;
}
.avatar {
width: 28px;
height: 28px;
- margin: auto 0;
+ margin: auto;
}
.headerRight {
diff --git a/packages/frontend/src/components/MkRadio.vue b/packages/frontend/src/components/MkRadio.vue
index fcf454c77a..5db2f5ee6d 100644
--- a/packages/frontend/src/components/MkRadio.vue
+++ b/packages/frontend/src/components/MkRadio.vue
@@ -24,7 +24,7 @@ import { } from 'vue';
const props = defineProps<{
modelValue: any;
value: any;
- disabled: boolean;
+ disabled?: boolean;
}>();
const emit = defineEmits<{
diff --git a/packages/frontend/src/components/MkRadios.vue b/packages/frontend/src/components/MkRadios.vue
index 8590ccf9ae..e2240fb4e1 100644
--- a/packages/frontend/src/components/MkRadios.vue
+++ b/packages/frontend/src/components/MkRadios.vue
@@ -1,5 +1,5 @@
<script lang="ts">
-import { defineComponent, h } from 'vue';
+import { VNode, defineComponent, h } from 'vue';
import MkRadio from './MkRadio.vue';
export default defineComponent({
@@ -22,31 +22,33 @@ export default defineComponent({
},
},
render() {
+ console.log(this.$slots, this.$slots.label && this.$slots.label());
+ if (!this.$slots.default) return null;
let options = this.$slots.default();
const label = this.$slots.label && this.$slots.label();
const caption = this.$slots.caption && this.$slots.caption();
// なぜかFragmentになることがあるため
- if (options.length === 1 && options[0].props == null) options = options[0].children;
+ if (options.length === 1 && options[0].props == null) options = options[0].children as VNode[];
return h('div', {
class: 'novjtcto',
}, [
...(label ? [h('div', {
class: 'label',
- }, [label])] : []),
+ }, label)] : []),
h('div', {
class: 'body',
}, options.map(option => h(MkRadio, {
key: option.key,
- value: option.props.value,
+ value: option.props?.value,
modelValue: this.value,
'onUpdate:modelValue': value => this.value = value,
- }, option.children)),
+ }, () => option.children)),
),
...(caption ? [h('div', {
class: 'caption',
- }, [caption])] : []),
+ }, caption)] : []),
]);
},
});
diff --git a/packages/frontend/src/components/MkRange.vue b/packages/frontend/src/components/MkRange.vue
index a1ee6367a0..eaa134df25 100644
--- a/packages/frontend/src/components/MkRange.vue
+++ b/packages/frontend/src/components/MkRange.vue
@@ -17,7 +17,7 @@
</template>
<script lang="ts" setup>
-import { computed, defineAsyncComponent, onMounted, onUnmounted, ref, watch } from 'vue';
+import { computed, defineAsyncComponent, onMounted, onUnmounted, ref, watch, shallowRef } from 'vue';
import * as os from '@/os';
const props = withDefaults(defineProps<{
@@ -39,8 +39,8 @@ const emit = defineEmits<{
(ev: 'update:modelValue', value: number): void;
}>();
-const containerEl = ref<HTMLElement>();
-const thumbEl = ref<HTMLElement>();
+const containerEl = shallowRef<HTMLElement>();
+const thumbEl = shallowRef<HTMLElement>();
const rawValue = ref((props.modelValue - props.min) / (props.max - props.min));
const steppedRawValue = computed(() => {
diff --git a/packages/frontend/src/components/MkReactedUsersDialog.vue b/packages/frontend/src/components/MkReactedUsersDialog.vue
index 1506e24ce8..0c0cc36692 100644
--- a/packages/frontend/src/components/MkReactedUsersDialog.vue
+++ b/packages/frontend/src/components/MkReactedUsersDialog.vue
@@ -6,7 +6,7 @@
@close="dialog.close()"
@closed="emit('closed')"
>
- <template #header>{{ i18n.ts.reactions }}</template>
+ <template #header>{{ i18n.ts.reactionsList }}</template>
<MkSpacer :margin-min="20" :margin-max="28">
<div v-if="note" class="_gaps">
@@ -21,7 +21,7 @@
<span style="margin-left: 4px;">{{ note.reactions[reaction] }}</span>
</button>
</div>
- <MkA v-for="user in users" :key="user.id" :to="userPage(user)">
+ <MkA v-for="user in users" :key="user.id" :to="userPage(user)" @click="dialog.close()">
<MkUserCardMini :user="user" :with-chart="false"/>
</MkA>
</template>
diff --git a/packages/frontend/src/components/MkReactionsViewer.details.vue b/packages/frontend/src/components/MkReactionsViewer.details.vue
index b4210be911..f5e611c62a 100644
--- a/packages/frontend/src/components/MkReactionsViewer.details.vue
+++ b/packages/frontend/src/components/MkReactionsViewer.details.vue
@@ -10,7 +10,7 @@
<MkAvatar :class="$style.avatar" :user="u"/>
<MkUserName :user="u" :nowrap="true"/>
</div>
- <div v-if="users.length > 10">+{{ count - 10 }}</div>
+ <div v-if="users.length > 10" :class="$style.more">+{{ count - 10 }}</div>
</div>
</div>
</MkTooltip>
@@ -50,7 +50,9 @@ function getReactionName(reaction: string): string {
.reaction {
max-width: 100px;
+ padding-right: 10px;
text-align: center;
+ border-right: solid 0.5px var(--divider);
}
.reactionIcon {
@@ -66,25 +68,20 @@ function getReactionName(reaction: string): string {
}
.users {
+ contain: content;
flex: 1;
min-width: 0;
+ margin: -4px 14px 0 10px;
font-size: 0.95em;
- border-left: solid 0.5px var(--divider);
- padding-left: 10px;
- margin-left: 10px;
- margin-right: 14px;
text-align: left;
}
.user {
line-height: 24px;
+ padding-top: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
-
- &:not(:last-child) {
- margin-bottom: 3px;
- }
}
.avatar {
@@ -92,4 +89,8 @@ function getReactionName(reaction: string): string {
height: 24px;
margin-right: 3px;
}
+
+.more {
+ padding-top: 4px;
+}
</style>
diff --git a/packages/frontend/src/components/MkRenotedUsersDialog.vue b/packages/frontend/src/components/MkRenotedUsersDialog.vue
new file mode 100644
index 0000000000..56025535f1
--- /dev/null
+++ b/packages/frontend/src/components/MkRenotedUsersDialog.vue
@@ -0,0 +1,65 @@
+<template>
+<MkModalWindow
+ ref="dialog"
+ :width="400"
+ :height="450"
+ @close="dialog.close()"
+ @closed="emit('closed')"
+>
+ <template #header>{{ i18n.ts.renotesList }}</template>
+
+ <MkSpacer :margin-min="20" :margin-max="28">
+ <div v-if="renotes" class="_gaps">
+ <div v-if="renotes.length === 0" class="_fullinfo">
+ <img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/>
+ <div>{{ i18n.ts.nothing }}</div>
+ </div>
+ <template v-else>
+ <MkA v-for="user in users" :key="user.id" :to="userPage(user)" @click="dialog.close()">
+ <MkUserCardMini :user="user" :with-chart="false"/>
+ </MkA>
+ </template>
+ </div>
+ <div v-else>
+ <MkLoading/>
+ </div>
+ </MkSpacer>
+</MkModalWindow>
+</template>
+
+<script lang="ts" setup>
+import { onMounted } from 'vue';
+import * as misskey from 'misskey-js';
+import MkModalWindow from '@/components/MkModalWindow.vue';
+import MkUserCardMini from '@/components/MkUserCardMini.vue';
+import { userPage } from '@/filters/user';
+import { i18n } from '@/i18n';
+import * as os from '@/os';
+
+const emit = defineEmits<{
+ (ev: 'closed'): void,
+}>();
+
+const props = defineProps<{
+ noteId: misskey.entities.Note['id'];
+}>();
+
+const dialog = $shallowRef<InstanceType<typeof MkModalWindow>>();
+
+let note = $ref<misskey.entities.Note>();
+let renotes = $ref();
+let users = $ref();
+
+onMounted(async () => {
+ const res = await os.api('notes/renotes', {
+ noteId: props.noteId,
+ limit: 30,
+ });
+
+ renotes = res;
+ users = res.map(x => x.user);
+});
+</script>
+
+<style lang="scss" module>
+</style>
diff --git a/packages/frontend/src/components/MkRetentionHeatmap.vue b/packages/frontend/src/components/MkRetentionHeatmap.vue
index 85c009f746..f33f68cab7 100644
--- a/packages/frontend/src/components/MkRetentionHeatmap.vue
+++ b/packages/frontend/src/components/MkRetentionHeatmap.vue
@@ -44,7 +44,13 @@ async function renderChart() {
const data = [];
for (const record of raw) {
- let i = 0;
+ data.push({
+ x: 0,
+ y: record.createdAt,
+ v: record.users,
+ });
+
+ let i = 1;
for (const date of Object.keys(record.data).sort((a, b) => new Date(a).getTime() - new Date(b).getTime())) {
data.push({
x: i,
@@ -61,8 +67,14 @@ async function renderChart() {
const color = defaultStore.state.darkMode ? '#b4e900' : '#86b300';
- // 視覚上の分かりやすさのため上から最も大きい3つの値の平均を最大値とする
- const max = raw.map(x => x.users).slice().sort((a, b) => b - a).slice(0, 3).reduce((a, b) => a + b, 0) / 3;
+ const getYYYYMMDD = (date: Date) => {
+ const y = date.getFullYear().toString().padStart(2, '0');
+ const m = (date.getMonth() + 1).toString().padStart(2, '0');
+ const d = date.getDate().toString().padStart(2, '0');
+ return `${y}/${m}/${d}`;
+ };
+
+ const max = (createdAt: string) => raw.find(x => x.createdAt === createdAt)!.users;
const marginEachCell = 12;
@@ -78,7 +90,7 @@ async function renderChart() {
borderRadius: 3,
backgroundColor(c) {
const value = c.dataset.data[c.dataIndex].v;
- const a = value / max;
+ const a = value / max(c.dataset.data[c.dataIndex].y);
return alpha(color, a);
},
fill: true,
@@ -115,7 +127,7 @@ async function renderChart() {
maxRotation: 0,
autoSkipPadding: 0,
autoSkip: false,
- callback: (value, index, values) => value + 1,
+ callback: (value, index, values) => value,
},
},
y: {
@@ -150,11 +162,11 @@ async function renderChart() {
callbacks: {
title(context) {
const v = context[0].dataset.data[context[0].dataIndex];
- return v.d;
+ return getYYYYMMDD(new Date(new Date(v.y).getTime() + (v.x * 86400000)));
},
label(context) {
const v = context.dataset.data[context.dataIndex];
- return ['Active: ' + v.v];
+ return [`Active: ${v.v} (${Math.round((v.v / max(v.y)) * 100)}%)`];
},
},
//mode: 'index',
diff --git a/packages/frontend/src/components/MkSample.vue b/packages/frontend/src/components/MkSample.vue
index 7a3bc20888..922b862b47 100644
--- a/packages/frontend/src/components/MkSample.vue
+++ b/packages/frontend/src/components/MkSample.vue
@@ -87,7 +87,7 @@ export default defineComponent({
},
async openDrive() {
- os.selectDriveFile();
+ os.selectDriveFile(false);
},
async selectUser() {
diff --git a/packages/frontend/src/components/MkSignup.vue b/packages/frontend/src/components/MkSignup.vue
deleted file mode 100644
index 30279148f8..0000000000
--- a/packages/frontend/src/components/MkSignup.vue
+++ /dev/null
@@ -1,263 +0,0 @@
-<template>
-<form class="qlvuhzng _gaps_m" autocomplete="new-password" @submit.prevent="onSubmit">
- <MkInput v-if="instance.disableRegistration" v-model="invitationCode" type="text" :spellcheck="false" required>
- <template #label>{{ i18n.ts.invitationCode }}</template>
- <template #prefix><i class="ti ti-key"></i></template>
- </MkInput>
- <MkInput v-model="username" type="text" pattern="^[a-zA-Z0-9_]{1,20}$" :spellcheck="false" required data-cy-signup-username @update:model-value="onChangeUsername">
- <template #label>{{ i18n.ts.username }} <div v-tooltip:dialog="i18n.ts.usernameInfo" class="_button _help"><i class="ti ti-question-circle"></i></div></template>
- <template #prefix>@</template>
- <template #suffix>@{{ host }}</template>
- <template #caption>
- <div><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.cannotBeChangedLater }}</div>
- <span v-if="usernameState === 'wait'" style="color:#999"><MkLoading :em="true"/> {{ i18n.ts.checking }}</span>
- <span v-else-if="usernameState === 'ok'" style="color: var(--success)"><i class="ti ti-check ti-fw"></i> {{ i18n.ts.available }}</span>
- <span v-else-if="usernameState === 'unavailable'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.unavailable }}</span>
- <span v-else-if="usernameState === 'error'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.error }}</span>
- <span v-else-if="usernameState === 'invalid-format'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.usernameInvalidFormat }}</span>
- <span v-else-if="usernameState === 'min-range'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.tooShort }}</span>
- <span v-else-if="usernameState === 'max-range'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.tooLong }}</span>
- </template>
- </MkInput>
- <MkInput v-if="instance.emailRequiredForSignup" v-model="email" :debounce="true" type="email" :spellcheck="false" required data-cy-signup-email @update:model-value="onChangeEmail">
- <template #label>{{ i18n.ts.emailAddress }} <div v-tooltip:dialog="i18n.ts._signup.emailAddressInfo" class="_button _help"><i class="ti ti-question-circle"></i></div></template>
- <template #prefix><i class="ti ti-mail"></i></template>
- <template #caption>
- <span v-if="emailState === 'wait'" style="color:#999"><MkLoading :em="true"/> {{ i18n.ts.checking }}</span>
- <span v-else-if="emailState === 'ok'" style="color: var(--success)"><i class="ti ti-check ti-fw"></i> {{ i18n.ts.available }}</span>
- <span v-else-if="emailState === 'unavailable:used'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts._emailUnavailable.used }}</span>
- <span v-else-if="emailState === 'unavailable:format'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts._emailUnavailable.format }}</span>
- <span v-else-if="emailState === 'unavailable:disposable'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts._emailUnavailable.disposable }}</span>
- <span v-else-if="emailState === 'unavailable:mx'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts._emailUnavailable.mx }}</span>
- <span v-else-if="emailState === 'unavailable:smtp'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts._emailUnavailable.smtp }}</span>
- <span v-else-if="emailState === 'unavailable'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.unavailable }}</span>
- <span v-else-if="emailState === 'error'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.error }}</span>
- </template>
- </MkInput>
- <MkInput v-model="password" type="password" autocomplete="new-password" required data-cy-signup-password @update:model-value="onChangePassword">
- <template #label>{{ i18n.ts.password }}</template>
- <template #prefix><i class="ti ti-lock"></i></template>
- <template #caption>
- <span v-if="passwordStrength == 'low'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.weakPassword }}</span>
- <span v-if="passwordStrength == 'medium'" style="color: var(--warn)"><i class="ti ti-check ti-fw"></i> {{ i18n.ts.normalPassword }}</span>
- <span v-if="passwordStrength == 'high'" style="color: var(--success)"><i class="ti ti-check ti-fw"></i> {{ i18n.ts.strongPassword }}</span>
- </template>
- </MkInput>
- <MkInput v-model="retypedPassword" type="password" autocomplete="new-password" required data-cy-signup-password-retype @update:model-value="onChangePasswordRetype">
- <template #label>{{ i18n.ts.password }} ({{ i18n.ts.retype }})</template>
- <template #prefix><i class="ti ti-lock"></i></template>
- <template #caption>
- <span v-if="passwordRetypeState == 'match'" style="color: var(--success)"><i class="ti ti-check ti-fw"></i> {{ i18n.ts.passwordMatched }}</span>
- <span v-if="passwordRetypeState == 'not-match'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.passwordNotMatched }}</span>
- </template>
- </MkInput>
- <MkSwitch v-model="ToSAgreement" class="tou">
- <template #label>{{ i18n.ts.agreeBelow }}</template>
- </MkSwitch>
- <ul style="margin: 0; padding-left: 2em;">
- <li v-if="instance.tosUrl"><a :href="instance.tosUrl" class="_link" target="_blank">{{ i18n.ts.tos }}</a></li>
- <li><a href="https://misskey-hub.net/docs/notes.html" class="_link" target="_blank">{{ i18n.ts.basicNotesBeforeCreateAccount }}</a></li>
- </ul>
- <MkCaptcha v-if="instance.enableHcaptcha" ref="hcaptcha" v-model="hCaptchaResponse" class="captcha" provider="hcaptcha" :sitekey="instance.hcaptchaSiteKey"/>
- <MkCaptcha v-if="instance.enableRecaptcha" ref="recaptcha" v-model="reCaptchaResponse" class="captcha" provider="recaptcha" :sitekey="instance.recaptchaSiteKey"/>
- <MkCaptcha v-if="instance.enableTurnstile" ref="turnstile" v-model="turnstileResponse" class="captcha" provider="turnstile" :sitekey="instance.turnstileSiteKey"/>
- <MkButton type="submit" :disabled="shouldDisableSubmitting" gradate data-cy-signup-submit>{{ i18n.ts.start }}</MkButton>
-</form>
-</template>
-
-<script lang="ts" setup>
-import { } from 'vue';
-import getPasswordStrength from 'syuilo-password-strength';
-import { toUnicode } from 'punycode/';
-import MkButton from './MkButton.vue';
-import MkInput from './MkInput.vue';
-import MkSwitch from './MkSwitch.vue';
-import MkCaptcha, { type Captcha } from '@/components/MkCaptcha.vue';
-import * as config from '@/config';
-import * as os from '@/os';
-import { login } from '@/account';
-import { instance } from '@/instance';
-import { i18n } from '@/i18n';
-
-const props = withDefaults(defineProps<{
- autoSet?: boolean;
-}>(), {
- autoSet: false,
-});
-
-const emit = defineEmits<{
- (ev: 'signup', user: Record<string, any>): void;
- (ev: 'signupEmailPending'): void;
-}>();
-
-const host = toUnicode(config.host);
-
-let hcaptcha = $ref<Captcha | undefined>();
-let recaptcha = $ref<Captcha | undefined>();
-let turnstile = $ref<Captcha | undefined>();
-
-let username: string = $ref('');
-let password: string = $ref('');
-let retypedPassword: string = $ref('');
-let invitationCode: string = $ref('');
-let email = $ref('');
-let usernameState: null | 'wait' | 'ok' | 'unavailable' | 'error' | 'invalid-format' | 'min-range' | 'max-range' = $ref(null);
-let emailState: null | 'wait' | 'ok' | 'unavailable:used' | 'unavailable:format' | 'unavailable:disposable' | 'unavailable:mx' | 'unavailable:smtp' | 'unavailable' | 'error' = $ref(null);
-let passwordStrength: '' | 'low' | 'medium' | 'high' = $ref('');
-let passwordRetypeState: null | 'match' | 'not-match' = $ref(null);
-let submitting: boolean = $ref(false);
-let ToSAgreement: boolean = $ref(false);
-let hCaptchaResponse = $ref(null);
-let reCaptchaResponse = $ref(null);
-let turnstileResponse = $ref(null);
-let usernameAbortController: null | AbortController = $ref(null);
-let emailAbortController: null | AbortController = $ref(null);
-
-const shouldDisableSubmitting = $computed((): boolean => {
- return submitting ||
- instance.tosUrl && !ToSAgreement ||
- instance.enableHcaptcha && !hCaptchaResponse ||
- instance.enableRecaptcha && !reCaptchaResponse ||
- instance.enableTurnstile && !turnstileResponse ||
- instance.emailRequiredForSignup && emailState !== 'ok' ||
- usernameState !== 'ok' ||
- passwordRetypeState !== 'match';
-});
-
-function onChangeUsername(): void {
- if (username === '') {
- usernameState = null;
- return;
- }
-
- {
- const err =
- !username.match(/^[a-zA-Z0-9_]+$/) ? 'invalid-format' :
- username.length < 1 ? 'min-range' :
- username.length > 20 ? 'max-range' :
- null;
-
- if (err) {
- usernameState = err;
- return;
- }
- }
-
- if (usernameAbortController != null) {
- usernameAbortController.abort();
- }
- usernameState = 'wait';
- usernameAbortController = new AbortController();
-
- os.api('username/available', {
- username,
- }, undefined, usernameAbortController.signal).then(result => {
- usernameState = result.available ? 'ok' : 'unavailable';
- }).catch((err) => {
- if (err.name !== 'AbortError') {
- usernameState = 'error';
- }
- });
-}
-
-function onChangeEmail(): void {
- if (email === '') {
- emailState = null;
- return;
- }
-
- if (emailAbortController != null) {
- emailAbortController.abort();
- }
- emailState = 'wait';
- emailAbortController = new AbortController();
-
- os.api('email-address/available', {
- emailAddress: email,
- }, undefined, emailAbortController.signal).then(result => {
- emailState = result.available ? 'ok' :
- result.reason === 'used' ? 'unavailable:used' :
- result.reason === 'format' ? 'unavailable:format' :
- result.reason === 'disposable' ? 'unavailable:disposable' :
- result.reason === 'mx' ? 'unavailable:mx' :
- result.reason === 'smtp' ? 'unavailable:smtp' :
- 'unavailable';
- }).catch((err) => {
- if (err.name !== 'AbortError') {
- emailState = 'error';
- }
- });
-}
-
-function onChangePassword(): void {
- if (password === '') {
- passwordStrength = '';
- return;
- }
-
- const strength = getPasswordStrength(password);
- passwordStrength = strength > 0.7 ? 'high' : strength > 0.3 ? 'medium' : 'low';
-}
-
-function onChangePasswordRetype(): void {
- if (retypedPassword === '') {
- passwordRetypeState = null;
- return;
- }
-
- passwordRetypeState = password === retypedPassword ? 'match' : 'not-match';
-}
-
-async function onSubmit(): Promise<void> {
- if (submitting) return;
- submitting = true;
-
- try {
- await os.api('signup', {
- username,
- password,
- emailAddress: email,
- invitationCode,
- 'hcaptcha-response': hCaptchaResponse,
- 'g-recaptcha-response': reCaptchaResponse,
- 'turnstile-response': turnstileResponse,
- });
- if (instance.emailRequiredForSignup) {
- os.alert({
- type: 'success',
- title: i18n.ts._signup.almostThere,
- text: i18n.t('_signup.emailSent', { email }),
- });
- emit('signupEmailPending');
- } else {
- const res = await os.api('signin', {
- username,
- password,
- });
- emit('signup', res);
-
- if (props.autoSet) {
- return login(res.i);
- }
- }
- } catch {
- submitting = false;
- hcaptcha?.reset?.();
- recaptcha?.reset?.();
- turnstile?.reset?.();
-
- os.alert({
- type: 'error',
- text: i18n.ts.somethingHappened,
- });
- }
-}
-</script>
-
-<style lang="scss" scoped>
-.qlvuhzng {
- .captcha {
- margin: 16px 0;
- }
-}
-</style>
diff --git a/packages/frontend/src/components/MkSignupDialog.form.vue b/packages/frontend/src/components/MkSignupDialog.form.vue
new file mode 100644
index 0000000000..0e8bdb321e
--- /dev/null
+++ b/packages/frontend/src/components/MkSignupDialog.form.vue
@@ -0,0 +1,272 @@
+<template>
+<div>
+ <div :class="$style.banner">
+ <i class="ti ti-user-edit"></i>
+ </div>
+ <MkSpacer :margin-min="20" :margin-max="32">
+ <form class="_gaps_m" autocomplete="new-password" @submit.prevent="onSubmit">
+ <MkInput v-if="instance.disableRegistration" v-model="invitationCode" type="text" :spellcheck="false" required>
+ <template #label>{{ i18n.ts.invitationCode }}</template>
+ <template #prefix><i class="ti ti-key"></i></template>
+ </MkInput>
+ <MkInput v-model="username" type="text" pattern="^[a-zA-Z0-9_]{1,20}$" :spellcheck="false" required data-cy-signup-username @update:model-value="onChangeUsername">
+ <template #label>{{ i18n.ts.username }} <div v-tooltip:dialog="i18n.ts.usernameInfo" class="_button _help"><i class="ti ti-help-circle"></i></div></template>
+ <template #prefix>@</template>
+ <template #suffix>@{{ host }}</template>
+ <template #caption>
+ <div><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.cannotBeChangedLater }}</div>
+ <span v-if="usernameState === 'wait'" style="color:#999"><MkLoading :em="true"/> {{ i18n.ts.checking }}</span>
+ <span v-else-if="usernameState === 'ok'" style="color: var(--success)"><i class="ti ti-check ti-fw"></i> {{ i18n.ts.available }}</span>
+ <span v-else-if="usernameState === 'unavailable'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.unavailable }}</span>
+ <span v-else-if="usernameState === 'error'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.error }}</span>
+ <span v-else-if="usernameState === 'invalid-format'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.usernameInvalidFormat }}</span>
+ <span v-else-if="usernameState === 'min-range'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.tooShort }}</span>
+ <span v-else-if="usernameState === 'max-range'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.tooLong }}</span>
+ </template>
+ </MkInput>
+ <MkInput v-if="instance.emailRequiredForSignup" v-model="email" :debounce="true" type="email" :spellcheck="false" required data-cy-signup-email @update:model-value="onChangeEmail">
+ <template #label>{{ i18n.ts.emailAddress }} <div v-tooltip:dialog="i18n.ts._signup.emailAddressInfo" class="_button _help"><i class="ti ti-help-circle"></i></div></template>
+ <template #prefix><i class="ti ti-mail"></i></template>
+ <template #caption>
+ <span v-if="emailState === 'wait'" style="color:#999"><MkLoading :em="true"/> {{ i18n.ts.checking }}</span>
+ <span v-else-if="emailState === 'ok'" style="color: var(--success)"><i class="ti ti-check ti-fw"></i> {{ i18n.ts.available }}</span>
+ <span v-else-if="emailState === 'unavailable:used'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts._emailUnavailable.used }}</span>
+ <span v-else-if="emailState === 'unavailable:format'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts._emailUnavailable.format }}</span>
+ <span v-else-if="emailState === 'unavailable:disposable'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts._emailUnavailable.disposable }}</span>
+ <span v-else-if="emailState === 'unavailable:mx'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts._emailUnavailable.mx }}</span>
+ <span v-else-if="emailState === 'unavailable:smtp'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts._emailUnavailable.smtp }}</span>
+ <span v-else-if="emailState === 'unavailable'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.unavailable }}</span>
+ <span v-else-if="emailState === 'error'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.error }}</span>
+ </template>
+ </MkInput>
+ <MkInput v-model="password" type="password" autocomplete="new-password" required data-cy-signup-password @update:model-value="onChangePassword">
+ <template #label>{{ i18n.ts.password }}</template>
+ <template #prefix><i class="ti ti-lock"></i></template>
+ <template #caption>
+ <span v-if="passwordStrength == 'low'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.weakPassword }}</span>
+ <span v-if="passwordStrength == 'medium'" style="color: var(--warn)"><i class="ti ti-check ti-fw"></i> {{ i18n.ts.normalPassword }}</span>
+ <span v-if="passwordStrength == 'high'" style="color: var(--success)"><i class="ti ti-check ti-fw"></i> {{ i18n.ts.strongPassword }}</span>
+ </template>
+ </MkInput>
+ <MkInput v-model="retypedPassword" type="password" autocomplete="new-password" required data-cy-signup-password-retype @update:model-value="onChangePasswordRetype">
+ <template #label>{{ i18n.ts.password }} ({{ i18n.ts.retype }})</template>
+ <template #prefix><i class="ti ti-lock"></i></template>
+ <template #caption>
+ <span v-if="passwordRetypeState == 'match'" style="color: var(--success)"><i class="ti ti-check ti-fw"></i> {{ i18n.ts.passwordMatched }}</span>
+ <span v-if="passwordRetypeState == 'not-match'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.passwordNotMatched }}</span>
+ </template>
+ </MkInput>
+ <MkCaptcha v-if="instance.enableHcaptcha" ref="hcaptcha" v-model="hCaptchaResponse" :class="$style.captcha" provider="hcaptcha" :sitekey="instance.hcaptchaSiteKey"/>
+ <MkCaptcha v-if="instance.enableRecaptcha" ref="recaptcha" v-model="reCaptchaResponse" :class="$style.captcha" provider="recaptcha" :sitekey="instance.recaptchaSiteKey"/>
+ <MkCaptcha v-if="instance.enableTurnstile" ref="turnstile" v-model="turnstileResponse" :class="$style.captcha" provider="turnstile" :sitekey="instance.turnstileSiteKey"/>
+ <MkButton type="submit" :disabled="shouldDisableSubmitting" large gradate rounded data-cy-signup-submit style="margin: 0 auto;">
+ <template v-if="submitting">
+ <MkLoading :em="true" :colored="false"/>
+ </template>
+ <template v-else>{{ i18n.ts.start }}</template>
+ </MkButton>
+ </form>
+ </MkSpacer>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+import getPasswordStrength from 'syuilo-password-strength';
+import { toUnicode } from 'punycode/';
+import MkButton from './MkButton.vue';
+import MkInput from './MkInput.vue';
+import MkSwitch from './MkSwitch.vue';
+import MkCaptcha, { type Captcha } from '@/components/MkCaptcha.vue';
+import * as config from '@/config';
+import * as os from '@/os';
+import { login } from '@/account';
+import { instance } from '@/instance';
+import { i18n } from '@/i18n';
+
+const props = withDefaults(defineProps<{
+ autoSet?: boolean;
+}>(), {
+ autoSet: false,
+});
+
+const emit = defineEmits<{
+ (ev: 'signup', user: Record<string, any>): void;
+ (ev: 'signupEmailPending'): void;
+}>();
+
+const host = toUnicode(config.host);
+
+let hcaptcha = $ref<Captcha | undefined>();
+let recaptcha = $ref<Captcha | undefined>();
+let turnstile = $ref<Captcha | undefined>();
+
+let username: string = $ref('');
+let password: string = $ref('');
+let retypedPassword: string = $ref('');
+let invitationCode: string = $ref('');
+let email = $ref('');
+let usernameState: null | 'wait' | 'ok' | 'unavailable' | 'error' | 'invalid-format' | 'min-range' | 'max-range' = $ref(null);
+let emailState: null | 'wait' | 'ok' | 'unavailable:used' | 'unavailable:format' | 'unavailable:disposable' | 'unavailable:mx' | 'unavailable:smtp' | 'unavailable' | 'error' = $ref(null);
+let passwordStrength: '' | 'low' | 'medium' | 'high' = $ref('');
+let passwordRetypeState: null | 'match' | 'not-match' = $ref(null);
+let submitting: boolean = $ref(false);
+let hCaptchaResponse = $ref(null);
+let reCaptchaResponse = $ref(null);
+let turnstileResponse = $ref(null);
+let usernameAbortController: null | AbortController = $ref(null);
+let emailAbortController: null | AbortController = $ref(null);
+
+const shouldDisableSubmitting = $computed((): boolean => {
+ return submitting ||
+ instance.enableHcaptcha && !hCaptchaResponse ||
+ instance.enableRecaptcha && !reCaptchaResponse ||
+ instance.enableTurnstile && !turnstileResponse ||
+ instance.emailRequiredForSignup && emailState !== 'ok' ||
+ usernameState !== 'ok' ||
+ passwordRetypeState !== 'match';
+});
+
+function onChangeUsername(): void {
+ if (username === '') {
+ usernameState = null;
+ return;
+ }
+
+ {
+ const err =
+ !username.match(/^[a-zA-Z0-9_]+$/) ? 'invalid-format' :
+ username.length < 1 ? 'min-range' :
+ username.length > 20 ? 'max-range' :
+ null;
+
+ if (err) {
+ usernameState = err;
+ return;
+ }
+ }
+
+ if (usernameAbortController != null) {
+ usernameAbortController.abort();
+ }
+ usernameState = 'wait';
+ usernameAbortController = new AbortController();
+
+ os.api('username/available', {
+ username,
+ }, undefined, usernameAbortController.signal).then(result => {
+ usernameState = result.available ? 'ok' : 'unavailable';
+ }).catch((err) => {
+ if (err.name !== 'AbortError') {
+ usernameState = 'error';
+ }
+ });
+}
+
+function onChangeEmail(): void {
+ if (email === '') {
+ emailState = null;
+ return;
+ }
+
+ if (emailAbortController != null) {
+ emailAbortController.abort();
+ }
+ emailState = 'wait';
+ emailAbortController = new AbortController();
+
+ os.api('email-address/available', {
+ emailAddress: email,
+ }, undefined, emailAbortController.signal).then(result => {
+ emailState = result.available ? 'ok' :
+ result.reason === 'used' ? 'unavailable:used' :
+ result.reason === 'format' ? 'unavailable:format' :
+ result.reason === 'disposable' ? 'unavailable:disposable' :
+ result.reason === 'mx' ? 'unavailable:mx' :
+ result.reason === 'smtp' ? 'unavailable:smtp' :
+ 'unavailable';
+ }).catch((err) => {
+ if (err.name !== 'AbortError') {
+ emailState = 'error';
+ }
+ });
+}
+
+function onChangePassword(): void {
+ if (password === '') {
+ passwordStrength = '';
+ return;
+ }
+
+ const strength = getPasswordStrength(password);
+ passwordStrength = strength > 0.7 ? 'high' : strength > 0.3 ? 'medium' : 'low';
+}
+
+function onChangePasswordRetype(): void {
+ if (retypedPassword === '') {
+ passwordRetypeState = null;
+ return;
+ }
+
+ passwordRetypeState = password === retypedPassword ? 'match' : 'not-match';
+}
+
+async function onSubmit(): Promise<void> {
+ if (submitting) return;
+ submitting = true;
+
+ try {
+ await os.api('signup', {
+ username,
+ password,
+ emailAddress: email,
+ invitationCode,
+ 'hcaptcha-response': hCaptchaResponse,
+ 'g-recaptcha-response': reCaptchaResponse,
+ 'turnstile-response': turnstileResponse,
+ });
+ if (instance.emailRequiredForSignup) {
+ os.alert({
+ type: 'success',
+ title: i18n.ts._signup.almostThere,
+ text: i18n.t('_signup.emailSent', { email }),
+ });
+ emit('signupEmailPending');
+ } else {
+ const res = await os.api('signin', {
+ username,
+ password,
+ });
+ emit('signup', res);
+
+ if (props.autoSet) {
+ return login(res.i);
+ }
+ }
+ } catch {
+ submitting = false;
+ hcaptcha?.reset?.();
+ recaptcha?.reset?.();
+ turnstile?.reset?.();
+
+ os.alert({
+ type: 'error',
+ text: i18n.ts.somethingHappened,
+ });
+ }
+}
+</script>
+
+<style lang="scss" module>
+.banner {
+ padding: 16px;
+ text-align: center;
+ font-size: 26px;
+ background-color: var(--accentedBg);
+ color: var(--accent);
+}
+
+.captcha {
+ margin: 16px 0;
+}
+</style>
diff --git a/packages/frontend/src/components/MkSignupDialog.rules.stories.impl.ts b/packages/frontend/src/components/MkSignupDialog.rules.stories.impl.ts
new file mode 100644
index 0000000000..2d95455730
--- /dev/null
+++ b/packages/frontend/src/components/MkSignupDialog.rules.stories.impl.ts
@@ -0,0 +1,94 @@
+/* eslint-disable @typescript-eslint/explicit-function-return-type */
+import { expect } from '@storybook/jest';
+import { userEvent, waitFor, within } from '@storybook/testing-library';
+import { StoryObj } from '@storybook/vue3';
+import { onBeforeUnmount } from 'vue';
+import MkSignupServerRules from './MkSignupDialog.rules.vue';
+import { i18n } from '@/i18n';
+import { instance } from '@/instance';
+export const Empty = {
+ render(args) {
+ return {
+ components: {
+ MkSignupServerRules,
+ },
+ setup() {
+ return {
+ args,
+ };
+ },
+ computed: {
+ props() {
+ return {
+ ...this.args,
+ };
+ },
+ },
+ template: '<MkSignupServerRules v-bind="props" />',
+ };
+ },
+ async play({ canvasElement }) {
+ const canvas = within(canvasElement);
+ const groups = await canvas.findAllByRole('group');
+ const buttons = await canvas.findAllByRole('button');
+ for (const group of groups) {
+ if (group.ariaExpanded === 'true') {
+ continue;
+ }
+ const button = await within(group).findByRole('button');
+ userEvent.click(button);
+ await waitFor(() => expect(group).toHaveAttribute('aria-expanded', 'true'));
+ }
+ const labels = await canvas.findAllByText(i18n.ts.agree);
+ for (const label of labels) {
+ expect(buttons.at(-1)).toBeDisabled();
+ await waitFor(() => userEvent.click(label));
+ }
+ expect(buttons.at(-1)).toBeEnabled();
+ },
+ args: {
+ serverRules: [],
+ tosUrl: null,
+ },
+ decorators: [
+ (_, context) => ({
+ setup() {
+ instance.serverRules = context.args.serverRules;
+ instance.tosUrl = context.args.tosUrl;
+ onBeforeUnmount(() => {
+ // FIXME: 呼び出されない
+ instance.serverRules = [];
+ instance.tosUrl = null;
+ });
+ },
+ template: '<story/>',
+ }),
+ ],
+ parameters: {
+ layout: 'centered',
+ },
+} satisfies StoryObj<typeof MkSignupServerRules>;
+export const ServerRulesOnly = {
+ ...Empty,
+ args: {
+ ...Empty.args,
+ serverRules: [
+ 'ルール',
+ ],
+ },
+} satisfies StoryObj<typeof MkSignupServerRules>;
+export const TOSOnly = {
+ ...Empty,
+ args: {
+ ...Empty.args,
+ tosUrl: 'https://example.com/tos',
+ },
+} satisfies StoryObj<typeof MkSignupServerRules>;
+export const ServerRulesAndTOS = {
+ ...Empty,
+ args: {
+ ...Empty.args,
+ serverRules: ServerRulesOnly.args.serverRules,
+ tosUrl: TOSOnly.args.tosUrl,
+ },
+} satisfies StoryObj<typeof MkSignupServerRules>;
diff --git a/packages/frontend/src/components/MkSignupDialog.rules.vue b/packages/frontend/src/components/MkSignupDialog.rules.vue
new file mode 100644
index 0000000000..6da81c3bcb
--- /dev/null
+++ b/packages/frontend/src/components/MkSignupDialog.rules.vue
@@ -0,0 +1,124 @@
+<template>
+<div>
+ <div :class="$style.banner">
+ <i class="ti ti-checklist"></i>
+ </div>
+ <MkSpacer :margin-min="20" :margin-max="28">
+ <div class="_gaps_m">
+ <div v-if="instance.disableRegistration">
+ <MkInfo warn>{{ i18n.ts.invitationRequiredToRegister }}</MkInfo>
+ </div>
+
+ <div style="text-align: center;">{{ i18n.ts.pleaseConfirmBelowBeforeSignup }}</div>
+
+ <MkFolder v-if="availableServerRules" :default-open="true">
+ <template #label>{{ i18n.ts.serverRules }}</template>
+ <template #suffix><i v-if="agreeServerRules" class="ti ti-check" style="color: var(--success)"></i></template>
+
+ <ol class="_gaps_s" :class="$style.rules">
+ <li v-for="item in instance.serverRules" :class="$style.rule"><div :class="$style.ruleText" v-html="item"></div></li>
+ </ol>
+
+ <MkSwitch v-model="agreeServerRules" style="margin-top: 16px;">{{ i18n.ts.agree }}</MkSwitch>
+ </MkFolder>
+
+ <MkFolder v-if="availableTos" :default-open="true">
+ <template #label>{{ i18n.ts.termsOfService }}</template>
+ <template #suffix><i v-if="agreeTos" class="ti ti-check" style="color: var(--success)"></i></template>
+
+ <a :href="instance.tosUrl" class="_link" target="_blank">{{ i18n.ts.termsOfService }} <i class="ti ti-external-link"></i></a>
+
+ <MkSwitch v-model="agreeTos" style="margin-top: 16px;">{{ i18n.ts.agree }}</MkSwitch>
+ </MkFolder>
+
+ <MkFolder :default-open="true">
+ <template #label>{{ i18n.ts.basicNotesBeforeCreateAccount }}</template>
+ <template #suffix><i v-if="agreeNote" class="ti ti-check" style="color: var(--success)"></i></template>
+
+ <a href="https://misskey-hub.net/docs/notes.html" class="_link" target="_blank">{{ i18n.ts.basicNotesBeforeCreateAccount }} <i class="ti ti-external-link"></i></a>
+
+ <MkSwitch v-model="agreeNote" style="margin-top: 16px;" data-cy-signup-rules-notes-agree>{{ i18n.ts.agree }}</MkSwitch>
+ </MkFolder>
+
+ <div v-if="!agreed" style="text-align: center;">{{ i18n.ts.pleaseAgreeAllToContinue }}</div>
+
+ <div class="_buttonsCenter">
+ <MkButton inline rounded @click="emit('cancel')">{{ i18n.ts.cancel }}</MkButton>
+ <MkButton inline primary rounded gradate :disabled="!agreed" data-cy-signup-rules-continue @click="emit('done')">{{ i18n.ts.continue }} <i class="ti ti-arrow-right"></i></MkButton>
+ </div>
+ </div>
+ </MkSpacer>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { computed, ref } from 'vue';
+import { instance } from '@/instance';
+import { i18n } from '@/i18n';
+import MkButton from '@/components/MkButton.vue';
+import MkFolder from '@/components/MkFolder.vue';
+import MkSwitch from '@/components/MkSwitch.vue';
+import MkInfo from '@/components/MkInfo.vue';
+
+const availableServerRules = instance.serverRules.length > 0;
+const availableTos = instance.tosUrl != null;
+
+const agreeServerRules = ref(false);
+const agreeTos = ref(false);
+const agreeNote = ref(false);
+
+const agreed = computed(() => {
+ return (!availableServerRules || agreeServerRules.value) && (!availableTos || agreeTos.value) && agreeNote.value;
+});
+
+const emit = defineEmits<{
+ (ev: 'cancel'): void;
+ (ev: 'done'): void;
+}>();
+</script>
+
+<style lang="scss" module>
+.banner {
+ padding: 16px;
+ text-align: center;
+ font-size: 26px;
+ background-color: var(--accentedBg);
+ color: var(--accent);
+}
+
+.rules {
+ counter-reset: item;
+ list-style: none;
+ padding: 0;
+ margin: 0;
+}
+
+.rule {
+ display: flex;
+ gap: 8px;
+ word-break: break-word;
+
+ &::before {
+ flex-shrink: 0;
+ display: flex;
+ position: sticky;
+ top: calc(var(--stickyTop, 0px) + 8px);
+ counter-increment: item;
+ content: counter(item);
+ width: 32px;
+ height: 32px;
+ line-height: 32px;
+ background-color: var(--accentedBg);
+ color: var(--accent);
+ font-size: 13px;
+ font-weight: bold;
+ align-items: center;
+ justify-content: center;
+ border-radius: 999px;
+ }
+}
+
+.ruleText {
+ padding-top: 6px;
+}
+</style>
diff --git a/packages/frontend/src/components/MkSignupDialog.vue b/packages/frontend/src/components/MkSignupDialog.vue
index 790c1e94df..17f8b86425 100644
--- a/packages/frontend/src/components/MkSignupDialog.vue
+++ b/packages/frontend/src/components/MkSignupDialog.vue
@@ -1,24 +1,40 @@
<template>
<MkModalWindow
ref="dialog"
- :width="366"
- :height="500"
+ :width="500"
+ :height="600"
@close="dialog.close()"
@closed="$emit('closed')"
>
<template #header>{{ i18n.ts.signup }}</template>
- <MkSpacer :margin-min="20" :margin-max="28">
- <XSignup :auto-set="autoSet" @signup="onSignup" @signup-email-pending="onSignupEmailPending"/>
- </MkSpacer>
+ <div style="overflow-x: clip;">
+ <Transition
+ mode="out-in"
+ :enter-active-class="$style.transition_x_enterActive"
+ :leave-active-class="$style.transition_x_leaveActive"
+ :enter-from-class="$style.transition_x_enterFrom"
+ :leave-to-class="$style.transition_x_leaveTo"
+ >
+ <template v-if="!isAcceptedServerRule">
+ <XServerRules @done="isAcceptedServerRule = true" @cancel="dialog.close()"/>
+ </template>
+ <template v-else>
+ <XSignup :auto-set="autoSet" @signup="onSignup" @signup-email-pending="onSignupEmailPending"/>
+ </template>
+ </Transition>
+ </div>
</MkModalWindow>
</template>
<script lang="ts" setup>
import { } from 'vue';
-import XSignup from '@/components/MkSignup.vue';
+import { $ref } from 'vue/macros';
+import XSignup from '@/components/MkSignupDialog.form.vue';
+import XServerRules from '@/components/MkSignupDialog.rules.vue';
import MkModalWindow from '@/components/MkModalWindow.vue';
import { i18n } from '@/i18n';
+import { instance } from '@/instance';
const props = withDefaults(defineProps<{
autoSet?: boolean;
@@ -33,6 +49,8 @@ const emit = defineEmits<{
const dialog = $shallowRef<InstanceType<typeof MkModalWindow>>();
+const isAcceptedServerRule = $ref(false);
+
function onSignup(res) {
emit('done', res);
dialog.close();
@@ -42,3 +60,18 @@ function onSignupEmailPending() {
dialog.close();
}
</script>
+
+<style lang="scss" module>
+.transition_x_enterActive,
+.transition_x_leaveActive {
+ transition: opacity 0.3s cubic-bezier(0,0,.35,1), transform 0.3s cubic-bezier(0,0,.35,1);
+}
+.transition_x_enterFrom {
+ opacity: 0;
+ transform: translateX(50px);
+}
+.transition_x_leaveTo {
+ opacity: 0;
+ transform: translateX(-50px);
+}
+</style>
diff --git a/packages/frontend/src/components/MkSwitch.vue b/packages/frontend/src/components/MkSwitch.vue
index 8bb8637dda..d9f6716f92 100644
--- a/packages/frontend/src/components/MkSwitch.vue
+++ b/packages/frontend/src/components/MkSwitch.vue
@@ -9,7 +9,7 @@
:disabled="disabled"
@keydown.enter="toggle"
>
- <span ref="button" v-tooltip="checked ? i18n.ts.itsOn : i18n.ts.itsOff" class="button" @click.prevent="toggle">
+ <span ref="button" v-tooltip="checked ? i18n.ts.itsOn : i18n.ts.itsOff" class="button" data-cy-switch-toggle @click.prevent="toggle">
<div class="knob"></div>
</span>
<span class="label">
diff --git a/packages/frontend/src/components/MkUserInfo.vue b/packages/frontend/src/components/MkUserInfo.vue
index 5086c1b319..6349ada65a 100644
--- a/packages/frontend/src/components/MkUserInfo.vue
+++ b/packages/frontend/src/components/MkUserInfo.vue
@@ -1,30 +1,30 @@
<template>
-<div class="_panel vjnjpkug">
- <div class="banner" :style="user.bannerUrl ? `background-image: url(${user.bannerUrl})` : ''"></div>
- <MkAvatar class="avatar" :user="user" indicator/>
- <div class="title">
- <MkA class="name" :to="userPage(user)"><MkUserName :user="user" :nowrap="false"/></MkA>
- <p class="username"><MkAcct :user="user"/></p>
+<div class="_panel" :class="$style.root">
+ <div :class="$style.banner" :style="user.bannerUrl ? `background-image: url(${user.bannerUrl})` : ''"></div>
+ <MkAvatar :class="$style.avatar" :user="user" indicator/>
+ <div :class="$style.title">
+ <MkA :class="$style.name" :to="userPage(user)"><MkUserName :user="user" :nowrap="false"/></MkA>
+ <p :class="$style.username"><MkAcct :user="user"/></p>
</div>
- <span v-if="$i && $i.id !== user.id && user.isFollowed" class="followed">{{ i18n.ts.followsYou }}</span>
- <div class="description">
+ <span v-if="$i && $i.id !== user.id && user.isFollowed" :class="$style.followed">{{ i18n.ts.followsYou }}</span>
+ <div :class="$style.description">
<div v-if="user.description" class="mfm">
<Mfm :text="user.description" :author="user" :i="$i"/>
</div>
<span v-else style="opacity: 0.7;">{{ i18n.ts.noAccountDescription }}</span>
</div>
- <div class="status">
- <div>
- <p>{{ i18n.ts.notes }}</p><span>{{ user.notesCount }}</span>
+ <div :class="$style.status">
+ <div :class="$style.statusItem">
+ <p :class="$style.statusItemLabel">{{ i18n.ts.notes }}</p><span :class="$style.statusItemValue">{{ user.notesCount }}</span>
</div>
- <div>
- <p>{{ i18n.ts.following }}</p><span>{{ user.followingCount }}</span>
+ <div :class="$style.statusItem">
+ <p :class="$style.statusItemLabel">{{ i18n.ts.following }}</p><span :class="$style.statusItemValue">{{ user.followingCount }}</span>
</div>
- <div>
- <p>{{ i18n.ts.followers }}</p><span>{{ user.followersCount }}</span>
+ <div :class="$style.statusItem">
+ <p :class="$style.statusItemLabel">{{ i18n.ts.followers }}</p><span :class="$style.statusItemValue">{{ user.followersCount }}</span>
</div>
</div>
- <MkFollowButton v-if="$i && user.id != $i.id" class="koudoku-button" :user="user" mini/>
+ <MkFollowButton v-if="$i && user.id != $i.id" :class="$style.follow" :user="user" mini/>
</div>
</template>
@@ -40,99 +40,99 @@ defineProps<{
}>();
</script>
-<style lang="scss" scoped>
-.vjnjpkug {
+<style lang="scss" module>
+.root {
position: relative;
+}
- > .banner {
- height: 84px;
- background-color: rgba(0, 0, 0, 0.1);
- background-size: cover;
- background-position: center;
- }
+.banner {
+ height: 84px;
+ background-color: rgba(0, 0, 0, 0.1);
+ background-size: cover;
+ background-position: center;
+}
- > .avatar {
- display: block;
- position: absolute;
- top: 62px;
- left: 13px;
- z-index: 2;
- width: 58px;
- height: 58px;
- border: solid 4px var(--panel);
- }
+.avatar {
+ display: block;
+ position: absolute;
+ top: 62px;
+ left: 13px;
+ z-index: 2;
+ width: 58px;
+ height: 58px;
+ border: solid 4px var(--panel);
+}
- > .title {
- display: block;
- padding: 10px 0 10px 88px;
+.title {
+ display: block;
+ padding: 10px 0 10px 88px;
+}
- > .name {
- display: inline-block;
- margin: 0;
- font-weight: bold;
- line-height: 16px;
- word-break: break-all;
- }
+.name {
+ display: inline-block;
+ margin: 0;
+ font-weight: bold;
+ line-height: 16px;
+ word-break: break-all;
+}
- > .username {
- display: block;
- margin: 0;
- line-height: 16px;
- font-size: 0.8em;
- color: var(--fg);
- opacity: 0.7;
- }
- }
-
- > .followed {
- position: absolute;
- top: 12px;
- left: 12px;
- padding: 4px 8px;
- color: #fff;
- background: rgba(0, 0, 0, 0.7);
- font-size: 0.7em;
- border-radius: 6px;
- }
-
- > .description {
- padding: 16px;
- font-size: 0.8em;
- border-top: solid 0.5px var(--divider);
+.username {
+ display: block;
+ margin: 0;
+ line-height: 16px;
+ font-size: 0.8em;
+ color: var(--fg);
+ opacity: 0.7;
+}
- > .mfm {
- display: -webkit-box;
- -webkit-line-clamp: 3;
- -webkit-box-orient: vertical;
- overflow: hidden;
- }
- }
+.followed {
+ position: absolute;
+ top: 12px;
+ left: 12px;
+ padding: 4px 8px;
+ color: #fff;
+ background: rgba(0, 0, 0, 0.7);
+ font-size: 0.7em;
+ border-radius: 6px;
+}
- > .status {
- padding: 10px 16px;
- border-top: solid 0.5px var(--divider);
+.description {
+ padding: 16px;
+ font-size: 0.8em;
+ border-top: solid 0.5px var(--divider);
+}
- > div {
- display: inline-block;
- width: 33%;
+.mfm {
+ display: -webkit-box;
+ -webkit-line-clamp: 3;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
+
+.status {
+ padding: 10px 16px;
+ border-top: solid 0.5px var(--divider);
+}
- > p {
- margin: 0;
- font-size: 0.7em;
- color: var(--fg);
- }
+.statusItem {
+ display: inline-block;
+ width: 33%;
+}
- > span {
- font-size: 1em;
- color: var(--accent);
- }
- }
- }
+.statusItemLabel {
+ margin: 0;
+ font-size: 0.7em;
+ color: var(--fg);
+}
+
+.statusItemValue {
+ font-size: 1em;
+ color: var(--accent);
+}
- > .koudoku-button {
- position: absolute;
- top: 8px;
- right: 8px;
- }
+.follow {
+ position: absolute;
+ top: 8px;
+ right: 8px;
}
</style>
diff --git a/packages/frontend/src/components/MkUserList.vue b/packages/frontend/src/components/MkUserList.vue
index 51eb426e97..3571ca84d9 100644
--- a/packages/frontend/src/components/MkUserList.vue
+++ b/packages/frontend/src/components/MkUserList.vue
@@ -8,7 +8,7 @@
</template>
<template #default="{ items }">
- <div class="efvhhmdq">
+ <div :class="$style.root">
<MkUserInfo v-for="item in items" :key="item.id" class="user" :user="extractor(item)"/>
</div>
</template>
@@ -29,8 +29,8 @@ const props = withDefaults(defineProps<{
});
</script>
-<style lang="scss" scoped>
-.efvhhmdq {
+<style lang="scss" module>
+.root {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
grid-gap: var(--margin);
diff --git a/packages/frontend/src/components/MkUserSetupDialog.Follow.stories.impl.ts b/packages/frontend/src/components/MkUserSetupDialog.Follow.stories.impl.ts
new file mode 100644
index 0000000000..7d5a65f41a
--- /dev/null
+++ b/packages/frontend/src/components/MkUserSetupDialog.Follow.stories.impl.ts
@@ -0,0 +1,51 @@
+/* eslint-disable @typescript-eslint/explicit-function-return-type */
+import { StoryObj } from '@storybook/vue3';
+import { rest } from 'msw';
+import { commonHandlers } from '../../.storybook/mocks';
+import { userDetailed } from '../../.storybook/fakes';
+import MkUserSetupDialog_Follow from './MkUserSetupDialog.Follow.vue';
+export const Default = {
+ render(args) {
+ return {
+ components: {
+ MkUserSetupDialog_Follow,
+ },
+ setup() {
+ return {
+ args,
+ };
+ },
+ computed: {
+ props() {
+ return {
+ ...this.args,
+ };
+ },
+ },
+ template: '<MkUserSetupDialog_Follow v-bind="props" />',
+ };
+ },
+ args: {
+
+ },
+ parameters: {
+ layout: 'centered',
+ msw: {
+ handlers: [
+ ...commonHandlers,
+ rest.post('/api/users', (req, res, ctx) => {
+ return res(ctx.json([
+ userDetailed('44'),
+ userDetailed('49'),
+ ]));
+ }),
+ rest.post('/api/pinned-users', (req, res, ctx) => {
+ return res(ctx.json([
+ userDetailed('44'),
+ userDetailed('49'),
+ ]));
+ }),
+ ],
+ },
+ },
+} satisfies StoryObj<typeof MkUserSetupDialog_Follow>;
diff --git a/packages/frontend/src/components/MkUserSetupDialog.Follow.vue b/packages/frontend/src/components/MkUserSetupDialog.Follow.vue
new file mode 100644
index 0000000000..b89e3e4c9d
--- /dev/null
+++ b/packages/frontend/src/components/MkUserSetupDialog.Follow.vue
@@ -0,0 +1,63 @@
+<template>
+<div class="_gaps">
+ <div style="text-align: center;">{{ i18n.ts._initialAccountSetting.followUsers }}</div>
+
+ <MkFolder :default-open="true">
+ <template #label>{{ i18n.ts.recommended }}</template>
+
+ <MkPagination :pagination="pinnedUsers">
+ <template #default="{ items }">
+ <div :class="$style.users">
+ <XUser v-for="item in items" :key="item.id" :user="item"/>
+ </div>
+ </template>
+ </MkPagination>
+ </MkFolder>
+
+ <MkFolder :default-open="true">
+ <template #label>{{ i18n.ts.popularUsers }}</template>
+
+ <MkPagination :pagination="popularUsers">
+ <template #default="{ items }">
+ <div :class="$style.users">
+ <XUser v-for="item in items" :key="item.id" :user="item"/>
+ </div>
+ </template>
+ </MkPagination>
+ </MkFolder>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { computed, ref, watch } from 'vue';
+import { instance } from '@/instance';
+import { i18n } from '@/i18n';
+import MkButton from '@/components/MkButton.vue';
+import MkFolder from '@/components/MkFolder.vue';
+import XUser from '@/components/MkUserSetupDialog.User.vue';
+import MkInfo from '@/components/MkInfo.vue';
+import * as os from '@/os';
+import { $i } from '@/account';
+import MkPagination from '@/components/MkPagination.vue';
+
+const emit = defineEmits<{
+ (ev: 'done'): void;
+}>();
+
+const pinnedUsers = { endpoint: 'pinned-users', noPaging: true };
+
+const popularUsers = { endpoint: 'users', limit: 10, noPaging: true, params: {
+ state: 'alive',
+ origin: 'local',
+ sort: '+follower',
+} };
+</script>
+
+<style lang="scss" module>
+.users {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
+ grid-gap: var(--margin);
+ justify-content: center;
+}
+</style>
diff --git a/packages/frontend/src/components/MkUserSetupDialog.Profile.stories.impl.ts b/packages/frontend/src/components/MkUserSetupDialog.Profile.stories.impl.ts
new file mode 100644
index 0000000000..f4930aa26b
--- /dev/null
+++ b/packages/frontend/src/components/MkUserSetupDialog.Profile.stories.impl.ts
@@ -0,0 +1,31 @@
+/* eslint-disable @typescript-eslint/explicit-function-return-type */
+import { StoryObj } from '@storybook/vue3';
+import MkUserSetupDialog_Profile from './MkUserSetupDialog.Profile.vue';
+export const Default = {
+ render(args) {
+ return {
+ components: {
+ MkUserSetupDialog_Profile,
+ },
+ setup() {
+ return {
+ args,
+ };
+ },
+ computed: {
+ props() {
+ return {
+ ...this.args,
+ };
+ },
+ },
+ template: '<MkUserSetupDialog_Profile v-bind="props" />',
+ };
+ },
+ args: {
+
+ },
+ parameters: {
+ layout: 'centered',
+ },
+} satisfies StoryObj<typeof MkUserSetupDialog_Profile>;
diff --git a/packages/frontend/src/components/MkUserSetupDialog.Profile.vue b/packages/frontend/src/components/MkUserSetupDialog.Profile.vue
new file mode 100644
index 0000000000..adb8d43349
--- /dev/null
+++ b/packages/frontend/src/components/MkUserSetupDialog.Profile.vue
@@ -0,0 +1,101 @@
+<template>
+<div class="_gaps">
+ <MkInfo>{{ i18n.ts._initialAccountSetting.theseSettingsCanEditLater }}</MkInfo>
+
+ <FormSlot>
+ <template #label>{{ i18n.ts.avatar }}</template>
+ <div v-adaptive-bg :class="$style.avatarSection" class="_panel">
+ <MkAvatar :class="$style.avatar" :user="$i" @click="setAvatar"/>
+ <div style="margin-top: 16px;">
+ <MkButton primary rounded inline @click="setAvatar">{{ i18n.ts._profile.changeAvatar }}</MkButton>
+ </div>
+ </div>
+ </FormSlot>
+
+ <MkInput v-model="name" :max="30" manual-save data-cy-user-setup-user-name>
+ <template #label>{{ i18n.ts._profile.name }}</template>
+ </MkInput>
+
+ <MkTextarea v-model="description" :max="500" tall manual-save data-cy-user-setup-user-description>
+ <template #label>{{ i18n.ts._profile.description }}</template>
+ </MkTextarea>
+
+ <MkInfo>{{ i18n.ts._initialAccountSetting.youCanEditMoreSettingsInSettingsPageLater }}</MkInfo>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { computed, ref, watch } from 'vue';
+import { instance } from '@/instance';
+import { i18n } from '@/i18n';
+import MkButton from '@/components/MkButton.vue';
+import MkInput from '@/components/MkInput.vue';
+import MkTextarea from '@/components/MkTextarea.vue';
+import FormSlot from '@/components/form/slot.vue';
+import MkInfo from '@/components/MkInfo.vue';
+import { chooseFileFromPc } from '@/scripts/select-file';
+import * as os from '@/os';
+import { $i } from '@/account';
+
+const emit = defineEmits<{
+ (ev: 'done'): void;
+}>();
+
+const name = ref('');
+const description = ref('');
+
+watch(name, () => {
+ os.apiWithDialog('i/update', {
+ // 空文字列をnullにしたいので??は使うな
+ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
+ name: name.value || null,
+ });
+});
+
+watch(description, () => {
+ os.apiWithDialog('i/update', {
+ // 空文字列をnullにしたいので??は使うな
+ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
+ description: description.value || null,
+ });
+});
+
+function setAvatar(ev) {
+ chooseFileFromPc(false).then(async (files) => {
+ const file = files[0];
+
+ let originalOrCropped = file;
+
+ const { canceled } = await os.confirm({
+ type: 'question',
+ text: i18n.t('cropImageAsk'),
+ okText: i18n.ts.cropYes,
+ cancelText: i18n.ts.cropNo,
+ });
+
+ if (!canceled) {
+ originalOrCropped = await os.cropImage(file, {
+ aspectRatio: 1,
+ });
+ }
+
+ const i = await os.apiWithDialog('i/update', {
+ avatarId: originalOrCropped.id,
+ });
+ $i.avatarId = i.avatarId;
+ $i.avatarUrl = i.avatarUrl;
+ });
+}
+</script>
+
+<style lang="scss" module>
+.avatarSection {
+ text-align: center;
+ padding: 20px;
+}
+
+.avatar {
+ width: 100px;
+ height: 100px;
+}
+</style>
diff --git a/packages/frontend/src/components/MkUserSetupDialog.User.stories.impl.ts b/packages/frontend/src/components/MkUserSetupDialog.User.stories.impl.ts
new file mode 100644
index 0000000000..7413f4884b
--- /dev/null
+++ b/packages/frontend/src/components/MkUserSetupDialog.User.stories.impl.ts
@@ -0,0 +1,32 @@
+/* eslint-disable @typescript-eslint/explicit-function-return-type */
+import { StoryObj } from '@storybook/vue3';
+import { userDetailed } from '../../.storybook/fakes';
+import MkUserSetupDialog_User from './MkUserSetupDialog.User.vue';
+export const Default = {
+ render(args) {
+ return {
+ components: {
+ MkUserSetupDialog_User,
+ },
+ setup() {
+ return {
+ args,
+ };
+ },
+ computed: {
+ props() {
+ return {
+ ...this.args,
+ };
+ },
+ },
+ template: '<MkUserSetupDialog_User v-bind="props" />',
+ };
+ },
+ args: {
+ user: userDetailed(),
+ },
+ parameters: {
+ layout: 'centered',
+ },
+} satisfies StoryObj<typeof MkUserSetupDialog_User>;
diff --git a/packages/frontend/src/components/MkUserSetupDialog.User.vue b/packages/frontend/src/components/MkUserSetupDialog.User.vue
new file mode 100644
index 0000000000..d66f34f165
--- /dev/null
+++ b/packages/frontend/src/components/MkUserSetupDialog.User.vue
@@ -0,0 +1,101 @@
+<template>
+<div v-adaptive-bg class="_panel" style="position: relative;">
+ <div :class="$style.banner" :style="user.bannerUrl ? `background-image: url(${user.bannerUrl})` : ''"></div>
+ <MkAvatar :class="$style.avatar" :user="user" indicator/>
+ <div :class="$style.title">
+ <div :class="$style.name"><MkUserName :user="user" :nowrap="false"/></div>
+ <p :class="$style.username"><MkAcct :user="user"/></p>
+ </div>
+ <div :class="$style.description">
+ <div v-if="user.description" :class="$style.mfm">
+ <Mfm :text="user.description" :author="user" :i="$i"/>
+ </div>
+ <span v-else style="opacity: 0.7;">{{ i18n.ts.noAccountDescription }}</span>
+ </div>
+ <div :class="$style.footer">
+ <MkButton v-if="!isFollowing" primary gradate rounded full @click="follow"><i class="ti ti-plus"></i> {{ i18n.ts.follow }}</MkButton>
+ <div v-else style="opacity: 0.7; text-align: center;">{{ i18n.ts.youFollowing }} <i class="ti ti-check"></i></div>
+ </div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import * as misskey from 'misskey-js';
+import { ref } from 'vue';
+import MkButton from '@/components/MkButton.vue';
+import { i18n } from '@/i18n';
+import { $i } from '@/account';
+import * as os from '@/os';
+
+const props = defineProps<{
+ user: misskey.entities.UserDetailed;
+}>();
+
+const isFollowing = ref(false);
+
+async function follow() {
+ isFollowing.value = true;
+ os.api('following/create', {
+ userId: props.user.id,
+ });
+}
+</script>
+
+<style lang="scss" module>
+.banner {
+ height: 60px;
+ background-color: rgba(0, 0, 0, 0.1);
+ background-size: cover;
+ background-position: center;
+}
+
+.avatar {
+ display: block;
+ position: absolute;
+ top: 30px;
+ left: 13px;
+ z-index: 2;
+ width: 58px;
+ height: 58px;
+ border: solid 4px var(--panel);
+}
+
+.title {
+ display: block;
+ padding: 10px 0 10px 88px;
+}
+
+.name {
+ display: inline-block;
+ margin: 0;
+ font-weight: bold;
+ line-height: 16px;
+ word-break: break-all;
+}
+
+.username {
+ display: block;
+ margin: 0;
+ line-height: 16px;
+ font-size: 0.8em;
+ color: var(--fg);
+ opacity: 0.7;
+}
+
+.description {
+ padding: 0 16px 16px 88px;
+ font-size: 0.9em;
+}
+
+.mfm {
+ display: -webkit-box;
+ -webkit-line-clamp: 5;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
+
+.footer {
+ border-top: solid 0.5px var(--divider);
+ padding: 16px;
+}
+</style>
diff --git a/packages/frontend/src/components/MkUserSetupDialog.stories.impl.ts b/packages/frontend/src/components/MkUserSetupDialog.stories.impl.ts
new file mode 100644
index 0000000000..55790602d5
--- /dev/null
+++ b/packages/frontend/src/components/MkUserSetupDialog.stories.impl.ts
@@ -0,0 +1,51 @@
+/* eslint-disable @typescript-eslint/explicit-function-return-type */
+import { StoryObj } from '@storybook/vue3';
+import { rest } from 'msw';
+import { commonHandlers } from '../../.storybook/mocks';
+import { userDetailed } from '../../.storybook/fakes';
+import MkUserSetupDialog from './MkUserSetupDialog.vue';
+export const Default = {
+ render(args) {
+ return {
+ components: {
+ MkUserSetupDialog,
+ },
+ setup() {
+ return {
+ args,
+ };
+ },
+ computed: {
+ props() {
+ return {
+ ...this.args,
+ };
+ },
+ },
+ template: '<MkUserSetupDialog v-bind="props" />',
+ };
+ },
+ args: {
+
+ },
+ parameters: {
+ layout: 'centered',
+ msw: {
+ handlers: [
+ ...commonHandlers,
+ rest.post('/api/users', (req, res, ctx) => {
+ return res(ctx.json([
+ userDetailed('44'),
+ userDetailed('49'),
+ ]));
+ }),
+ rest.post('/api/pinned-users', (req, res, ctx) => {
+ return res(ctx.json([
+ userDetailed('44'),
+ userDetailed('49'),
+ ]));
+ }),
+ ],
+ },
+ },
+} satisfies StoryObj<typeof MkUserSetupDialog>;
diff --git a/packages/frontend/src/components/MkUserSetupDialog.vue b/packages/frontend/src/components/MkUserSetupDialog.vue
new file mode 100644
index 0000000000..096b88c309
--- /dev/null
+++ b/packages/frontend/src/components/MkUserSetupDialog.vue
@@ -0,0 +1,145 @@
+<template>
+<MkModalWindow
+ ref="dialog"
+ :width="500"
+ :height="550"
+ data-cy-user-setup
+ @close="close(true)"
+ @closed="emit('closed')"
+>
+ <template #header>{{ i18n.ts.initialAccountSetting }}</template>
+
+ <div style="overflow-x: clip;">
+ <Transition
+ mode="out-in"
+ :enter-active-class="$style.transition_x_enterActive"
+ :leave-active-class="$style.transition_x_leaveActive"
+ :enter-from-class="$style.transition_x_enterFrom"
+ :leave-to-class="$style.transition_x_leaveTo"
+ >
+ <template v-if="page === 0">
+ <div :class="$style.centerPage">
+ <MkSpacer :margin-min="20" :margin-max="28">
+ <div class="_gaps" style="text-align: center;">
+ <i class="ti ti-confetti" style="display: block; margin: auto; font-size: 3em; color: var(--accent);"></i>
+ <div style="font-size: 120%;">{{ i18n.ts._initialAccountSetting.accountCreated }}</div>
+ <div>{{ i18n.ts._initialAccountSetting.letsStartAccountSetup }}</div>
+ <MkButton primary rounded gradate style="margin: 16px auto 0 auto;" data-cy-user-setup-continue @click="page++">{{ i18n.ts._initialAccountSetting.profileSetting }} <i class="ti ti-arrow-right"></i></MkButton>
+ </div>
+ </MkSpacer>
+ </div>
+ </template>
+ <template v-else-if="page === 1">
+ <div style="height: 100cqh; overflow: auto;">
+ <MkSpacer :margin-min="20" :margin-max="28">
+ <XProfile/>
+ <MkButton primary rounded gradate style="margin: 16px auto 0 auto;" data-cy-user-setup-continue @click="page++">{{ i18n.ts.continue }} <i class="ti ti-arrow-right"></i></MkButton>
+ </MkSpacer>
+ </div>
+ </template>
+ <template v-else-if="page === 2">
+ <div style="height: 100cqh; overflow: auto;">
+ <MkSpacer :margin-min="20" :margin-max="28">
+ <XFollow/>
+ <MkButton primary rounded gradate style="margin: 16px auto 0 auto;" data-cy-user-setup-continue @click="page++">{{ i18n.ts.continue }} <i class="ti ti-arrow-right"></i></MkButton>
+ </MkSpacer>
+ </div>
+ </template>
+ <template v-else-if="page === 3">
+ <div :class="$style.centerPage">
+ <MkSpacer :margin-min="20" :margin-max="28">
+ <div class="_gaps" style="text-align: center;">
+ <i class="ti ti-bell-ringing-2" style="display: block; margin: auto; font-size: 3em; color: var(--accent);"></i>
+ <div style="font-size: 120%;">{{ i18n.ts.pushNotification }}</div>
+ <div style="padding: 0 16px;">{{ i18n.t('_initialAccountSetting.pushNotificationDescription', { name: instance.name ?? host }) }}</div>
+ <MkPushNotificationAllowButton primary show-only-to-register style="margin: 0 auto;"/>
+ <MkButton primary rounded gradate style="margin: 16px auto 0 auto;" data-cy-user-setup-continue @click="page++">{{ i18n.ts.continue }} <i class="ti ti-arrow-right"></i></MkButton>
+ </div>
+ </MkSpacer>
+ </div>
+ </template>
+ <template v-else-if="page === 4">
+ <div :class="$style.centerPage">
+ <MkSpacer :margin-min="20" :margin-max="28">
+ <div class="_gaps" style="text-align: center;">
+ <i class="ti ti-check" style="display: block; margin: auto; font-size: 3em; color: var(--accent);"></i>
+ <div style="font-size: 120%;">{{ i18n.ts._initialAccountSetting.initialAccountSettingCompleted }}</div>
+ <I18n :src="i18n.ts._initialAccountSetting.ifYouNeedLearnMore" tag="div" style="padding: 0 16px;">
+ <template #name>{{ instance.name ?? host }}</template>
+ <template #link>
+ <a href="https://misskey-hub.net/help.html" target="_blank" class="_link">{{ i18n.ts.help }}</a>
+ </template>
+ </I18n>
+ <div>{{ i18n.t('_initialAccountSetting.haveFun', { name: instance.name ?? host }) }}</div>
+ <MkButton primary rounded gradate style="margin: 16px auto 0 auto;" data-cy-user-setup-continue @click="close(false)">{{ i18n.ts.close }}</MkButton>
+ </div>
+ </MkSpacer>
+ </div>
+ </template>
+ </Transition>
+ </div>
+</MkModalWindow>
+</template>
+
+<script lang="ts" setup>
+import { ref, shallowRef, watch } from 'vue';
+import MkModalWindow from '@/components/MkModalWindow.vue';
+import MkButton from '@/components/MkButton.vue';
+import XProfile from '@/components/MkUserSetupDialog.Profile.vue';
+import XFollow from '@/components/MkUserSetupDialog.Follow.vue';
+import { i18n } from '@/i18n';
+import { instance } from '@/instance';
+import { host } from '@/config';
+import MkPushNotificationAllowButton from '@/components/MkPushNotificationAllowButton.vue';
+import { defaultStore } from '@/store';
+import * as os from '@/os';
+
+const emit = defineEmits<{
+ (ev: 'closed'): void;
+}>();
+
+const dialog = shallowRef<InstanceType<typeof MkModalWindow>>();
+
+const page = ref(defaultStore.state.accountSetupWizard);
+
+watch(page, () => {
+ defaultStore.set('accountSetupWizard', page.value);
+});
+
+async function close(skip: boolean) {
+ if (skip) {
+ const { canceled } = await os.confirm({
+ type: 'warning',
+ text: i18n.ts._initialAccountSetting.skipAreYouSure,
+ });
+ if (canceled) return;
+ }
+
+ dialog.value.close();
+ defaultStore.set('accountSetupWizard', -1);
+}
+</script>
+
+<style lang="scss" module>
+.transition_x_enterActive,
+.transition_x_leaveActive {
+ transition: opacity 0.3s cubic-bezier(0,0,.35,1), transform 0.3s cubic-bezier(0,0,.35,1);
+}
+.transition_x_enterFrom {
+ opacity: 0;
+ transform: translateX(50px);
+}
+.transition_x_leaveTo {
+ opacity: 0;
+ transform: translateX(-50px);
+}
+
+.centerPage {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ height: 100cqh;
+ padding-bottom: 30px;
+ box-sizing: border-box;
+}
+</style>
diff --git a/packages/frontend/src/components/MkVisitorDashboard.ActiveUsersChart.vue b/packages/frontend/src/components/MkVisitorDashboard.ActiveUsersChart.vue
new file mode 100644
index 0000000000..fb705786cf
--- /dev/null
+++ b/packages/frontend/src/components/MkVisitorDashboard.ActiveUsersChart.vue
@@ -0,0 +1,157 @@
+<template>
+<div>
+ <MkLoading v-if="fetching"/>
+ <div v-show="!fetching" :class="$style.root">
+ <canvas ref="chartEl"></canvas>
+ </div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { onMounted } from 'vue';
+import { Chart } from 'chart.js';
+import gradient from 'chartjs-plugin-gradient';
+import tinycolor from 'tinycolor2';
+import * as os from '@/os';
+import { defaultStore } from '@/store';
+import { useChartTooltip } from '@/scripts/use-chart-tooltip';
+import { chartVLine } from '@/scripts/chart-vline';
+import { initChart } from '@/scripts/init-chart';
+
+initChart();
+
+const chartEl = $shallowRef<HTMLCanvasElement>(null);
+const now = new Date();
+let chartInstance: Chart = null;
+const chartLimit = 30;
+let fetching = $ref(true);
+
+const { handler: externalTooltipHandler } = useChartTooltip();
+
+async function renderChart() {
+ if (chartInstance) {
+ chartInstance.destroy();
+ }
+
+ const getDate = (ago: number) => {
+ const y = now.getFullYear();
+ const m = now.getMonth();
+ const d = now.getDate();
+
+ return new Date(y, m, d - ago);
+ };
+
+ const format = (arr) => {
+ return arr.map((v, i) => ({
+ x: getDate(i).getTime(),
+ y: v,
+ }));
+ };
+
+ const raw = await os.api('charts/active-users', { limit: chartLimit, span: 'day' });
+
+ const vLineColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)';
+
+ const computedStyle = getComputedStyle(document.documentElement);
+ const accent = tinycolor(computedStyle.getPropertyValue('--accent')).toHexString();
+
+ const colorRead = accent;
+ const colorWrite = '#2ecc71';
+
+ const max = Math.max(...raw.read);
+
+ chartInstance = new Chart(chartEl, {
+ type: 'bar',
+ data: {
+ datasets: [{
+ parsing: false,
+ label: 'Read',
+ data: format(raw.read).slice().reverse(),
+ pointRadius: 0,
+ borderWidth: 0,
+ borderJoinStyle: 'round',
+ borderRadius: 4,
+ backgroundColor: colorRead,
+ barPercentage: 0.5,
+ categoryPercentage: 1,
+ fill: true,
+ }],
+ },
+ options: {
+ aspectRatio: 2.5,
+ layout: {
+ padding: {
+ left: 0,
+ right: 8,
+ top: 0,
+ bottom: 0,
+ },
+ },
+ scales: {
+ x: {
+ type: 'time',
+ offset: true,
+ time: {
+ stepSize: 1,
+ unit: 'day',
+ displayFormats: {
+ day: 'M/d',
+ month: 'Y/M',
+ },
+ },
+ grid: {
+ display: false,
+ },
+ ticks: {
+ display: true,
+ maxRotation: 0,
+ autoSkipPadding: 8,
+ },
+ },
+ y: {
+ position: 'left',
+ suggestedMax: 10,
+ grid: {
+ display: true,
+ },
+ ticks: {
+ display: true,
+ //mirror: true,
+ },
+ },
+ },
+ interaction: {
+ intersect: false,
+ mode: 'index',
+ },
+ plugins: {
+ legend: {
+ display: false,
+ },
+ tooltip: {
+ enabled: false,
+ mode: 'index',
+ animation: {
+ duration: 0,
+ },
+ external: externalTooltipHandler,
+ },
+ gradient,
+ },
+ },
+ plugins: [chartVLine(vLineColor)],
+ });
+
+ fetching = false;
+}
+
+onMounted(async () => {
+ renderChart();
+});
+</script>
+
+<style lang="scss" module>
+.root {
+ padding: 20px;
+}
+</style>
diff --git a/packages/frontend/src/components/MkVisitorDashboard.vue b/packages/frontend/src/components/MkVisitorDashboard.vue
new file mode 100644
index 0000000000..6226768127
--- /dev/null
+++ b/packages/frontend/src/components/MkVisitorDashboard.vue
@@ -0,0 +1,227 @@
+<template>
+<div v-if="meta" :class="$style.root">
+ <div :class="[$style.main, $style.panel]">
+ <img :src="instance.iconUrl || instance.faviconUrl || '/favicon.ico'" alt="" :class="$style.mainIcon"/>
+ <button class="_button _acrylic" :class="$style.mainMenu" @click="showMenu"><i class="ti ti-dots"></i></button>
+ <div :class="$style.mainFg">
+ <h1 :class="$style.mainTitle">
+ <!-- 背景色によってはロゴが見えなくなるのでとりあえず無効に -->
+ <!-- <img class="logo" v-if="meta.logoImageUrl" :src="meta.logoImageUrl"><span v-else class="text">{{ instanceName }}</span> -->
+ <span>{{ instanceName }}</span>
+ </h1>
+ <div :class="$style.mainAbout">
+ <!-- eslint-disable-next-line vue/no-v-html -->
+ <div v-html="meta.description || i18n.ts.headlineMisskey"></div>
+ </div>
+ <div v-if="instance.disableRegistration" :class="$style.mainWarn">
+ <MkInfo warn>{{ i18n.ts.invitationRequiredToRegister }}</MkInfo>
+ </div>
+ <div class="_gaps_s" :class="$style.mainActions">
+ <MkButton :class="$style.mainAction" full rounded gradate data-cy-signup style="margin-right: 12px;" @click="signup()">{{ i18n.ts.joinThisServer }}</MkButton>
+ <MkButton :class="$style.mainAction" full rounded @click="exploreOtherServers()">{{ i18n.ts.exploreOtherServers }}</MkButton>
+ <MkButton :class="$style.mainAction" full rounded data-cy-signin @click="signin()">{{ i18n.ts.login }}</MkButton>
+ </div>
+ </div>
+ </div>
+ <div v-if="stats" :class="$style.stats">
+ <div :class="[$style.statsItem, $style.panel]">
+ <div :class="$style.statsItemLabel">{{ i18n.ts.users }}</div>
+ <div :class="$style.statsItemCount"><MkNumber :value="stats.originalUsersCount"/></div>
+ </div>
+ <div :class="[$style.statsItem, $style.panel]">
+ <div :class="$style.statsItemLabel">{{ i18n.ts.notes }}</div>
+ <div :class="$style.statsItemCount"><MkNumber :value="stats.originalNotesCount"/></div>
+ </div>
+ </div>
+ <div v-if="instance.policies.ltlAvailable" :class="[$style.tl, $style.panel]">
+ <div :class="$style.tlHeader">{{ i18n.ts.letsLookAtTimeline }}</div>
+ <div :class="$style.tlBody">
+ <MkTimeline src="local"/>
+ </div>
+ </div>
+ <div :class="[$style.activeUsersChart, $style.panel]">
+ <XActiveUsersChart/>
+ </div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+import { Instance } from 'misskey-js/built/entities';
+import XTimeline from './welcome.timeline.vue';
+import XSigninDialog from '@/components/MkSigninDialog.vue';
+import XSignupDialog from '@/components/MkSignupDialog.vue';
+import MkButton from '@/components/MkButton.vue';
+import MkTimeline from '@/components/MkTimeline.vue';
+import MkInfo from '@/components/MkInfo.vue';
+import { instanceName } from '@/config';
+import * as os from '@/os';
+import { i18n } from '@/i18n';
+import { instance } from '@/instance';
+import number from '@/filters/number';
+import MkNumber from '@/components/MkNumber.vue';
+import XActiveUsersChart from '@/components/MkVisitorDashboard.ActiveUsersChart.vue';
+
+let meta = $ref<Instance>();
+let stats = $ref(null);
+
+os.api('meta', { detail: true }).then(_meta => {
+ meta = _meta;
+});
+
+os.api('stats', {
+}).then((res) => {
+ stats = res;
+});
+
+function signin() {
+ os.popup(XSigninDialog, {
+ autoSet: true,
+ }, {}, 'closed');
+}
+
+function signup() {
+ os.popup(XSignupDialog, {
+ autoSet: true,
+ }, {}, 'closed');
+}
+
+function showMenu(ev) {
+ os.popupMenu([{
+ text: i18n.ts.instanceInfo,
+ icon: 'ti ti-info-circle',
+ action: () => {
+ os.pageWindow('/about');
+ },
+ }, {
+ text: i18n.ts.aboutMisskey,
+ icon: 'ti ti-info-circle',
+ action: () => {
+ os.pageWindow('/about-misskey');
+ },
+ }, null, {
+ text: i18n.ts.help,
+ icon: 'ti ti-help-circle',
+ action: () => {
+ window.open('https://misskey-hub.net/help.md', '_blank');
+ },
+ }], ev.currentTarget ?? ev.target);
+}
+
+function exploreOtherServers() {
+ // TODO: 言語をよしなに
+ window.open('https://join.misskey.page/ja-JP/instances', '_blank');
+}
+</script>
+
+<style lang="scss" module>
+.root {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ padding: 32px 0 0 0;
+}
+
+.panel {
+ position: relative;
+ background: var(--panel);
+ border-radius: var(--radius);
+ box-shadow: 0 12px 32px rgb(0 0 0 / 25%);
+}
+
+.main {
+ text-align: center;
+}
+
+.mainIcon {
+ width: 85px;
+ margin-top: -47px;
+ vertical-align: bottom;
+ filter: drop-shadow(0 2px 5px rgba(0, 0, 0, 0.5));
+}
+
+.mainMenu {
+ position: absolute;
+ top: 16px;
+ right: 16px;
+ width: 32px;
+ height: 32px;
+ border-radius: 8px;
+ font-size: 18px;
+}
+
+.mainFg {
+ position: relative;
+ z-index: 1;
+}
+
+.mainTitle {
+ display: block;
+ margin: 0;
+ padding: 16px 32px 24px 32px;
+ font-size: 1.4em;
+}
+
+.mainLogo {
+ vertical-align: bottom;
+ max-height: 120px;
+ max-width: min(100%, 300px);
+}
+
+.mainAbout {
+ padding: 0 32px;
+}
+
+.mainWarn {
+ padding: 32px 32px 0 32px;
+}
+
+.mainActions {
+ padding: 32px;
+}
+
+.mainAction {
+ line-height: 28px;
+}
+
+.stats {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ grid-gap: 16px;
+}
+
+.statsItem {
+ overflow: clip;
+ padding: 16px 20px;
+}
+
+.statsItemLabel {
+ color: var(--fgTransparentWeak);
+ font-size: 0.9em;
+}
+
+.statsItemCount {
+ font-weight: bold;
+ font-size: 1.2em;
+ color: var(--accent);
+}
+
+.tl {
+ overflow: clip;
+}
+
+.tlHeader {
+ padding: 12px 16px;
+ border-bottom: solid 1px var(--divider);
+}
+
+.tlBody {
+ height: 350px;
+ overflow: auto;
+}
+
+.activeUsersChart {
+
+}
+</style>
diff --git a/packages/frontend/src/components/MkWidgets.vue b/packages/frontend/src/components/MkWidgets.vue
index d074fdd150..33e594acd8 100644
--- a/packages/frontend/src/components/MkWidgets.vue
+++ b/packages/frontend/src/components/MkWidgets.vue
@@ -2,11 +2,11 @@
<div :class="$style.root">
<template v-if="edit">
<header :class="$style['edit-header']">
- <MkSelect v-model="widgetAdderSelected" style="margin-bottom: var(--margin)" class="mk-widget-select">
+ <MkSelect v-model="widgetAdderSelected" style="margin-bottom: var(--margin)" data-cy-widget-select>
<template #label>{{ i18n.ts.selectWidget }}</template>
<option v-for="widget in widgetDefs" :key="widget" :value="widget">{{ i18n.t(`_widgets.${widget}`) }}</option>
</MkSelect>
- <MkButton inline primary class="mk-widget-add" @click="addWidget"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton>
+ <MkButton inline primary data-cy-widget-add @click="addWidget"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton>
<MkButton inline @click="$emit('exit')">{{ i18n.ts.close }}</MkButton>
</header>
<Sortable
diff --git a/packages/frontend/src/components/MkWindow.vue b/packages/frontend/src/components/MkWindow.vue
index 687abed632..b662479b2a 100644
--- a/packages/frontend/src/components/MkWindow.vue
+++ b/packages/frontend/src/components/MkWindow.vue
@@ -29,7 +29,7 @@
<button v-if="closeButton" v-tooltip="i18n.ts.close" class="_button" :class="$style.headerButton" @click="close()"><i class="ti ti-x"></i></button>
</span>
</div>
- <div v-container :class="$style.content">
+ <div :class="$style.content">
<slot></slot>
</div>
</div>
@@ -541,7 +541,7 @@ defineExpose({
flex: 1;
overflow: auto;
background: var(--panel);
- container-type: inline-size;
+ container-type: size;
}
$handleSize: 8px;
diff --git a/packages/frontend/src/components/global/MkAcct.stories.impl.ts b/packages/frontend/src/components/global/MkAcct.stories.impl.ts
index d5e3fc3568..9d5fd3947d 100644
--- a/packages/frontend/src/components/global/MkAcct.stories.impl.ts
+++ b/packages/frontend/src/components/global/MkAcct.stories.impl.ts
@@ -41,3 +41,35 @@ export const Detail = {
detail: true,
},
} satisfies StoryObj<typeof MkAcct>;
+export const Long = {
+ ...Default,
+ args: {
+ ...Default.args,
+ user: {
+ ...userDetailed(),
+ username: 'the_quick_brown_fox_jumped_over_the_lazy_dog',
+ host: 'misskey.example',
+ },
+ },
+ decorators: [
+ () => ({
+ template: '<div style="width: 360px;"><story/></div>',
+ }),
+ ],
+} satisfies StoryObj<typeof MkAcct>;
+export const VeryLong = {
+ ...Default,
+ args: {
+ ...Default.args,
+ user: {
+ ...userDetailed(),
+ username: '2c7cc62a697ea3a7826521f3fd34f0cb273693cbe5e9310f35449f43622a5cdc',
+ host: 'the.quick.brown.fox.jumped.over.the.lazy.dog.very.long.hostname.nostr.example',
+ },
+ },
+ decorators: [
+ () => ({
+ template: '<div style="width: 360px;"><story/></div>',
+ }),
+ ],
+} satisfies StoryObj<typeof MkAcct>;
diff --git a/packages/frontend/src/components/global/MkAcct.vue b/packages/frontend/src/components/global/MkAcct.vue
index 2b9f892fc6..59358aef70 100644
--- a/packages/frontend/src/components/global/MkAcct.vue
+++ b/packages/frontend/src/components/global/MkAcct.vue
@@ -1,5 +1,9 @@
<template>
-<span>
+<MkCondensedLine v-if="defaultStore.state.enableCondensedLineForAcct" :min-scale="2 / 3">
+ <span>@{{ user.username }}</span>
+ <span v-if="user.host || detail || defaultStore.state.showFullAcct" style="opacity: 0.5;">@{{ user.host || host }}</span>
+</MkCondensedLine>
+<span v-else>
<span>@{{ user.username }}</span>
<span v-if="user.host || detail || defaultStore.state.showFullAcct" style="opacity: 0.5;">@{{ user.host || host }}</span>
</span>
@@ -8,6 +12,7 @@
<script lang="ts" setup>
import * as misskey from 'misskey-js';
import { toUnicode } from 'punycode/';
+import MkCondensedLine from './MkCondensedLine.vue';
import { host as hostRaw } from '@/config';
import { defaultStore } from '@/store';
diff --git a/packages/frontend/src/components/global/MkAvatar.vue b/packages/frontend/src/components/global/MkAvatar.vue
index 8497b8443b..ad36dcabe4 100644
--- a/packages/frontend/src/components/global/MkAvatar.vue
+++ b/packages/frontend/src/components/global/MkAvatar.vue
@@ -222,7 +222,7 @@ watch(() => props.user.avatarBlurhash, () => {
transform: rotate(37.5deg) skew(30deg);
&, &::after {
- border-radius: 0 75% 75%;
+ border-radius: 25% 75% 75%;
}
> .layer {
@@ -251,7 +251,7 @@ watch(() => props.user.avatarBlurhash, () => {
transform: rotate(-37.5deg) skew(-30deg);
&, &::after {
- border-radius: 75% 0 75% 75%;
+ border-radius: 75% 25% 75% 75%;
}
> .layer {
diff --git a/packages/frontend/src/components/global/MkCondensedLine.stories.impl.ts b/packages/frontend/src/components/global/MkCondensedLine.stories.impl.ts
new file mode 100644
index 0000000000..ce985bc59f
--- /dev/null
+++ b/packages/frontend/src/components/global/MkCondensedLine.stories.impl.ts
@@ -0,0 +1,39 @@
+/* eslint-disable @typescript-eslint/explicit-function-return-type */
+import { StoryObj } from '@storybook/vue3';
+import MkCondensedLine from './MkCondensedLine.vue';
+export const Default = {
+ render(args) {
+ return {
+ components: {
+ MkCondensedLine,
+ },
+ setup() {
+ return {
+ args,
+ };
+ },
+ computed: {
+ props() {
+ return {
+ ...this.args,
+ };
+ },
+ },
+ template: '<MkCondensedLine>{{ props.text }}</MkCondensedLine>',
+ };
+ },
+ args: {
+ text: 'This is a condensed line.',
+ },
+ parameters: {
+ layout: 'centered',
+ },
+} satisfies StoryObj<typeof MkCondensedLine>;
+export const ContainerIs100px = {
+ ...Default,
+ decorators: [
+ () => ({
+ template: '<div style="width: 100px;"><story/></div>',
+ }),
+ ],
+} satisfies StoryObj<typeof MkCondensedLine>;
diff --git a/packages/frontend/src/components/global/MkCondensedLine.vue b/packages/frontend/src/components/global/MkCondensedLine.vue
new file mode 100644
index 0000000000..1d46ff1ec9
--- /dev/null
+++ b/packages/frontend/src/components/global/MkCondensedLine.vue
@@ -0,0 +1,65 @@
+<template>
+<span :class="$style.container">
+ <span ref="content" :class="$style.content">
+ <slot/>
+ </span>
+</span>
+</template>
+
+<script lang="ts">
+interface Props {
+ readonly minScale?: number;
+}
+
+const contentSymbol = Symbol();
+const observer = new ResizeObserver((entries) => {
+ for (const entry of entries) {
+ const content = (entry.target[contentSymbol] ? entry.target : entry.target.firstElementChild) as HTMLSpanElement;
+ const props: Required<Props> = content[contentSymbol];
+ const container = content.parentElement as HTMLSpanElement;
+ const contentWidth = content.getBoundingClientRect().width;
+ const containerWidth = container.getBoundingClientRect().width;
+ container.style.transform = `scaleX(${Math.max(props.minScale, Math.min(1, containerWidth / contentWidth))})`;
+ }
+});
+</script>
+
+<script setup lang="ts">
+import { ref, watch } from 'vue';
+
+const props = withDefaults(defineProps<Props>(), {
+ minScale: 0,
+});
+
+const content = ref<HTMLSpanElement>();
+
+watch(content, (value, oldValue) => {
+ if (oldValue) {
+ delete oldValue[contentSymbol];
+ observer.unobserve(oldValue);
+ if (oldValue.parentElement) {
+ observer.unobserve(oldValue.parentElement);
+ }
+ }
+ if (value) {
+ value[contentSymbol] = props;
+ observer.observe(value);
+ if (value.parentElement) {
+ observer.observe(value.parentElement);
+ }
+ }
+});
+</script>
+
+<style module lang="scss">
+.container {
+ display: inline-block;
+ max-width: 100%;
+ transform-origin: 0;
+}
+
+.content {
+ display: inline-block;
+ white-space: nowrap;
+}
+</style>
diff --git a/packages/frontend/src/components/global/MkError.stories.impl.ts b/packages/frontend/src/components/global/MkError.stories.impl.ts
index 60ac5c91ad..8252a4d76e 100644
--- a/packages/frontend/src/components/global/MkError.stories.impl.ts
+++ b/packages/frontend/src/components/global/MkError.stories.impl.ts
@@ -1,4 +1,5 @@
/* eslint-disable @typescript-eslint/explicit-function-return-type */
+import { action } from '@storybook/addon-actions';
import { expect } from '@storybook/jest';
import { waitFor } from '@storybook/testing-library';
import { StoryObj } from '@storybook/vue3';
@@ -20,14 +21,21 @@ export const Default = {
...this.args,
};
},
+ events() {
+ return {
+ retry: action('retry'),
+ };
+ },
},
- template: '<MkError v-bind="props" />',
+ template: '<MkError v-bind="props" v-on="events" />',
};
},
async play({ canvasElement }) {
await expect(canvasElement.firstElementChild).not.toBeNull();
await waitFor(async () => expect(canvasElement.firstElementChild?.classList).not.toContain('_transition_zoom-enter-active'));
},
+ args: {
+ },
parameters: {
layout: 'centered',
},
diff --git a/packages/frontend/src/components/global/MkPageHeader.vue b/packages/frontend/src/components/global/MkPageHeader.vue
index 710edd797a..b91d378b17 100644
--- a/packages/frontend/src/components/global/MkPageHeader.vue
+++ b/packages/frontend/src/components/global/MkPageHeader.vue
@@ -156,7 +156,7 @@ onUnmounted(() => {
}
&.thin {
- --height: 42px;
+ --height: 40px;
> .buttons {
> .button {
diff --git a/packages/frontend/src/components/global/MkTime.vue b/packages/frontend/src/components/global/MkTime.vue
index 99169512db..261cc0ee18 100644
--- a/packages/frontend/src/components/global/MkTime.vue
+++ b/packages/frontend/src/components/global/MkTime.vue
@@ -8,6 +8,7 @@
</template>
<script lang="ts" setup>
+import isChromatic from 'chromatic/isChromatic';
import { onUnmounted } from 'vue';
import { i18n } from '@/i18n';
import { dateTimeFormat } from '@/scripts/intl-const';
@@ -17,7 +18,7 @@ const props = withDefaults(defineProps<{
origin?: Date | null;
mode?: 'relative' | 'absolute' | 'detail';
}>(), {
- origin: null,
+ origin: isChromatic() ? new Date('2023-04-01T00:00:00Z') : null,
mode: 'relative',
});
diff --git a/packages/frontend/src/components/index.ts b/packages/frontend/src/components/index.ts
index 63e8fc225c..4ef8111da9 100644
--- a/packages/frontend/src/components/index.ts
+++ b/packages/frontend/src/components/index.ts
@@ -5,6 +5,7 @@ import MkA from './global/MkA.vue';
import MkAcct from './global/MkAcct.vue';
import MkAvatar from './global/MkAvatar.vue';
import MkEmoji from './global/MkEmoji.vue';
+import MkCondensedLine from './global/MkCondensedLine.vue';
import MkCustomEmoji from './global/MkCustomEmoji.vue';
import MkUserName from './global/MkUserName.vue';
import MkEllipsis from './global/MkEllipsis.vue';
@@ -33,6 +34,7 @@ export const components = {
MkAcct: MkAcct,
MkAvatar: MkAvatar,
MkEmoji: MkEmoji,
+ MkCondensedLine: MkCondensedLine,
MkCustomEmoji: MkCustomEmoji,
MkUserName: MkUserName,
MkEllipsis: MkEllipsis,
@@ -55,6 +57,7 @@ declare module '@vue/runtime-core' {
MkAcct: typeof MkAcct;
MkAvatar: typeof MkAvatar;
MkEmoji: typeof MkEmoji;
+ MkCondensedLine: typeof MkCondensedLine;
MkCustomEmoji: typeof MkCustomEmoji;
MkUserName: typeof MkUserName;
MkEllipsis: typeof MkEllipsis;
diff --git a/packages/frontend/src/config.ts b/packages/frontend/src/config.ts
index 073b21a0ae..24f60910d1 100644
--- a/packages/frontend/src/config.ts
+++ b/packages/frontend/src/config.ts
@@ -1,21 +1,22 @@
-import { miLocalStorage } from "./local-storage";
+import { miLocalStorage } from './local-storage';
const address = new URL(location.href);
-const siteName = (document.querySelector('meta[property="og:site_name"]') as HTMLMetaElement)?.content;
+const siteName = document.querySelector<HTMLMetaElement>('meta[property="og:site_name"]')?.content;
export const host = address.host;
export const hostname = address.hostname;
export const url = address.origin;
export const apiUrl = url + '/api';
export const wsUrl = url.replace('http://', 'ws://').replace('https://', 'wss://') + '/streaming';
-export const lang = miLocalStorage.getItem('lang');
+export const lang = miLocalStorage.getItem('lang') ?? 'en-US';
export const langs = _LANGS_;
-export let locale = JSON.parse(miLocalStorage.getItem('locale'));
+const preParseLocale = miLocalStorage.getItem('locale');
+export let locale = preParseLocale ? JSON.parse(preParseLocale) : null;
export const version = _VERSION_;
export const instanceName = siteName === 'Misskey' ? host : siteName;
export const ui = miLocalStorage.getItem('ui');
export const debug = miLocalStorage.getItem('debug') === 'true';
-export function updateLocale(newLocale) {
+export function updateLocale(newLocale): void {
locale = newLocale;
}
diff --git a/packages/frontend/src/const.ts b/packages/frontend/src/const.ts
index 1d1b8fcea4..aaa3d10302 100644
--- a/packages/frontend/src/const.ts
+++ b/packages/frontend/src/const.ts
@@ -35,6 +35,11 @@ export const FILE_TYPE_BROWSERSAFE = [
'audio/webm',
'audio/aac',
+
+ // see https://github.com/misskey-dev/misskey/pull/10686
+ 'audio/flac',
+ 'audio/wav',
+ // backward compatibility
'audio/x-flac',
'audio/vnd.wave',
];
@@ -56,6 +61,7 @@ export const ROLE_POLICIES = [
'canSearchNotes',
'canHideAds',
'driveCapacityMb',
+ 'alwaysMarkNsfw',
'pinLimit',
'antennaLimit',
'wordMuteLimit',
diff --git a/packages/frontend/src/directives/container.ts b/packages/frontend/src/directives/container.ts
deleted file mode 100644
index a8a93eb9be..0000000000
--- a/packages/frontend/src/directives/container.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-import { Directive } from 'vue';
-
-const map = new WeakMap<HTMLElement, ResizeObserver>();
-
-export default {
- mounted(el: HTMLElement, binding, vn) {
- const ro = new ResizeObserver((entries, observer) => {
- el.style.setProperty('--containerHeight', el.offsetHeight + 'px');
- });
- ro.observe(el);
- map.set(el, ro);
- },
-
- unmounted(el, binding, vn) {
- const ro = map.get(el);
- if (ro) {
- ro.disconnect();
- map.delete(el);
- }
- },
-} as Directive;
diff --git a/packages/frontend/src/directives/index.ts b/packages/frontend/src/directives/index.ts
index 064ee4f64b..7847d661d4 100644
--- a/packages/frontend/src/directives/index.ts
+++ b/packages/frontend/src/directives/index.ts
@@ -11,7 +11,6 @@ import clickAnime from './click-anime';
import panel from './panel';
import adaptiveBorder from './adaptive-border';
import adaptiveBg from './adaptive-bg';
-import container from './container';
export default function(app: App) {
for (const [key, value] of Object.entries(directives)) {
@@ -32,5 +31,4 @@ export const directives = {
'panel': panel,
'adaptive-border': adaptiveBorder,
'adaptive-bg': adaptiveBg,
- 'container': container,
};
diff --git a/packages/frontend/src/init.ts b/packages/frontend/src/init.ts
index 5b3e7ec932..49e7bb4008 100644
--- a/packages/frontend/src/init.ts
+++ b/packages/frontend/src/init.ts
@@ -6,18 +6,6 @@ import 'vite/modulepreload-polyfill';
import '@/style.scss';
-//#region account indexedDB migration
-import { set } from '@/scripts/idb-proxy';
-
-{
- const accounts = miLocalStorage.getItem('accounts');
- if (accounts) {
- set('accounts', JSON.parse(accounts));
- miLocalStorage.removeItem('accounts');
- }
-}
-//#endregion
-
import { computed, createApp, watch, markRaw, version as vueVersion, defineAsyncComponent } from 'vue';
import { compareVersions } from 'compare-versions';
import JSON5 from 'json5';
@@ -42,11 +30,11 @@ import { reloadChannel } from '@/scripts/unison-reload';
import { reactionPicker } from '@/scripts/reaction-picker';
import { getUrlWithoutLoginId } from '@/scripts/login-id';
import { getAccountFromId } from '@/scripts/get-account-from-id';
-import { deckStore } from './ui/deck/deck-store';
-import { miLocalStorage } from './local-storage';
-import { claimAchievement, claimedAchievements } from './scripts/achievements';
-import { fetchCustomEmojis } from './custom-emojis';
-import { mainRouter } from './router';
+import { deckStore } from '@/ui/deck/deck-store';
+import { miLocalStorage } from '@/local-storage';
+import { claimAchievement, claimedAchievements } from '@/scripts/achievements';
+import { fetchCustomEmojis } from '@/custom-emojis';
+import { mainRouter } from '@/router';
console.info(`Misskey v${version}`);
@@ -55,7 +43,9 @@ if (_DEV_) {
console.info(`vue ${vueVersion}`);
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
(window as any).$i = $i;
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
(window as any).$store = defaultStore;
window.addEventListener('error', event => {
@@ -184,7 +174,7 @@ fetchInstanceMetaPromise.then(() => {
try {
await fetchCustomEmojis();
-} catch (err) {}
+} catch (err) { /* empty */ }
const app = createApp(
new URLSearchParams(window.location.search).has('zen') ? defineAsyncComponent(() => import('@/ui/zen.vue')) :
@@ -212,20 +202,20 @@ await deckStore.ready;
// https://github.com/misskey-dev/misskey/pull/8575#issuecomment-1114239210
// なぜかinit.tsの内容が2回実行されることがあるため、mountするdivを1つに制限する
-const rootEl = (() => {
+const rootEl = ((): HTMLElement => {
const MISSKEY_MOUNT_DIV_ID = 'misskey_app';
- const currentEl = document.getElementById(MISSKEY_MOUNT_DIV_ID);
+ const currentRoot = document.getElementById(MISSKEY_MOUNT_DIV_ID);
- if (currentEl) {
+ if (currentRoot) {
console.warn('multiple import detected');
- return currentEl;
+ return currentRoot;
}
- const rootEl = document.createElement('div');
- rootEl.id = MISSKEY_MOUNT_DIV_ID;
- document.body.appendChild(rootEl);
- return rootEl;
+ const root = document.createElement('div');
+ root.id = MISSKEY_MOUNT_DIV_ID;
+ document.body.appendChild(root);
+ return root;
})();
app.mount(rootEl);
@@ -256,8 +246,7 @@ if (lastVersion !== version) {
popup(defineAsyncComponent(() => import('@/components/MkUpdated.vue')), {}, {}, 'closed');
}
}
- } catch (err) {
- }
+ } catch (err) { /* empty */ }
}
await defaultStore.ready;
@@ -354,6 +343,16 @@ if ($i) {
// only add post shortcuts if logged in
hotkeys['p|n'] = post;
+ if (defaultStore.state.accountSetupWizard !== -1) {
+ // このウィザードが実装される前に登録したユーザーには表示させないため
+ // TODO: そのうち消す
+ if (Date.now() - new Date($i.createdAt).getTime() < 1000 * 60 * 60 * 24) {
+ popup(defineAsyncComponent(() => import('@/components/MkUserSetupDialog.vue')), {}, {}, 'closed');
+ } else {
+ defaultStore.set('accountSetupWizard', -1);
+ }
+ }
+
if ($i.isDeleted) {
alert({
type: 'warning',
@@ -442,6 +441,10 @@ if ($i) {
claimAchievement('client30min');
}, 1000 * 60 * 30);
+ window.setTimeout(() => {
+ claimAchievement('client60min');
+ }, 1000 * 60 * 60);
+
const lastUsed = miLocalStorage.getItem('lastUsed');
if (lastUsed) {
const lastUsedDate = parseInt(lastUsed, 10);
@@ -456,7 +459,7 @@ if ($i) {
const latestDonationInfoShownAt = miLocalStorage.getItem('latestDonationInfoShownAt');
const neverShowDonationInfo = miLocalStorage.getItem('neverShowDonationInfo');
- if (neverShowDonationInfo !== 'true' && (new Date($i.createdAt).getTime() < (Date.now() - (1000 * 60 * 60 * 24 * 3)))) {
+ if (neverShowDonationInfo !== 'true' && (new Date($i.createdAt).getTime() < (Date.now() - (1000 * 60 * 60 * 24 * 3))) && !location.pathname.startsWith('/miauth')) {
if (latestDonationInfoShownAt == null || (new Date(latestDonationInfoShownAt).getTime() < (Date.now() - (1000 * 60 * 60 * 24 * 30)))) {
popup(defineAsyncComponent(() => import('@/components/MkDonation.vue')), {}, {}, 'closed');
}
diff --git a/packages/frontend/src/local-storage.ts b/packages/frontend/src/local-storage.ts
index 9a288f264c..441a35747a 100644
--- a/packages/frontend/src/local-storage.ts
+++ b/packages/frontend/src/local-storage.ts
@@ -24,6 +24,7 @@ type Keys =
'customCss' |
'message_drafts' |
'scratchpad' |
+ 'debug' |
`miux:${string}` |
`ui:folder:${string}` |
`themes:${string}` |
@@ -32,7 +33,7 @@ type Keys =
'emojis' // DEPRECATED, stored in indexeddb (13.9.0~);
export const miLocalStorage = {
- getItem: (key: Keys) => window.localStorage.getItem(key),
- setItem: (key: Keys, value: string) => window.localStorage.setItem(key, value),
- removeItem: (key: Keys) => window.localStorage.removeItem(key),
+ getItem: (key: Keys): string | null => window.localStorage.getItem(key),
+ setItem: (key: Keys, value: string): void => window.localStorage.setItem(key, value),
+ removeItem: (key: Keys): void => window.localStorage.removeItem(key),
};
diff --git a/packages/frontend/src/os.ts b/packages/frontend/src/os.ts
index 962f9cdd98..c4f9d47d7d 100644
--- a/packages/frontend/src/os.ts
+++ b/packages/frontend/src/os.ts
@@ -18,6 +18,8 @@ import MkPopupMenu from '@/components/MkPopupMenu.vue';
import MkContextMenu from '@/components/MkContextMenu.vue';
import { MenuItem } from '@/types/menu';
import copyToClipboard from './scripts/copy-to-clipboard';
+import { showMovedDialog } from './scripts/show-moved-dialog';
+import { DriveFile } from 'misskey-js/built/entities';
export const openingWindowsCount = ref(0);
@@ -55,6 +57,12 @@ export const apiWithDialog = ((
} else if (err.code === 'RATE_LIMIT_EXCEEDED') {
title = i18n.ts.cannotPerformTemporary;
text = i18n.ts.cannotPerformTemporaryDescription;
+ } else if (err.code === 'INVALID_PARAM') {
+ title = i18n.ts.invalidParamError;
+ text = i18n.ts.invalidParamErrorDescription;
+ } else if (err.code === 'ROLE_PERMISSION_DENIED') {
+ title = i18n.ts.permissionDeniedError;
+ text = i18n.ts.permissionDeniedErrorDescription;
} else if (err.code.startsWith('TOO_MANY')) {
title = i18n.ts.youCannotCreateAnymore;
text = `${i18n.ts.error}: ${err.id}`;
@@ -413,7 +421,7 @@ export async function selectUser(opts: { includeSelf?: boolean } = {}) {
});
}
-export async function selectDriveFile(multiple: boolean) {
+export async function selectDriveFile(multiple: boolean): Promise<DriveFile[]> {
return new Promise((resolve, reject) => {
popup(defineAsyncComponent(() => import('@/components/MkDriveSelectDialog.vue')), {
type: 'file',
@@ -421,7 +429,7 @@ export async function selectDriveFile(multiple: boolean) {
}, {
done: files => {
if (files) {
- resolve(multiple ? files : files[0]);
+ resolve(files);
}
},
}, 'closed');
@@ -572,6 +580,8 @@ export function contextMenu(items: MenuItem[] | Ref<MenuItem[]>, ev: MouseEvent)
}
export function post(props: Record<string, any> = {}): Promise<void> {
+ showMovedDialog();
+
return new Promise((resolve, reject) => {
// NOTE: MkPostFormDialogをdynamic importするとiOSでテキストエリアに自動フォーカスできない
// NOTE: ただ、dynamic importしない場合、MkPostFormDialogインスタンスが使いまわされ、
diff --git a/packages/frontend/src/pages/about-misskey.vue b/packages/frontend/src/pages/about-misskey.vue
index bca4d17784..e592c629ce 100644
--- a/packages/frontend/src/pages/about-misskey.vue
+++ b/packages/frontend/src/pages/about-misskey.vue
@@ -132,6 +132,18 @@ const patronsWithIcon = [{
}, {
name: 'mollinaca',
icon: 'https://misskey-hub.net/patrons/ceb36b8f66e549bdadb3b90d5da62314.jpg',
+}, {
+ name: '坂本龍',
+ icon: 'https://misskey-hub.net/patrons/a631cf8b490145cf8dbbe4e7508cfbc2.jpg',
+}, {
+ name: 'takke',
+ icon: 'https://misskey-hub.net/patrons/6c3327e626c046f2914fbcd9f7557935.jpg',
+}, {
+ name: 'ぺんぎん',
+ icon: 'https://misskey-hub.net/patrons/6a652e0534ff4cb1836e7ce4968d76a7.jpg',
+}, {
+ name: 'かみらえっと',
+ icon: 'https://misskey-hub.net/patrons/be1326bda7d940a482f3758ffd9ffaf6.jpg',
}];
const patrons = [
@@ -219,6 +231,13 @@ const patrons = [
'巣黒るい@リスケモ男の娘VTuber!',
'ふぇいぽむ',
'依古田イコ',
+ '戸塚こだま',
+ 'すー。',
+ '秋雨/Slime-hatena.jp',
+ 'けそ',
+ 'ずも',
+ 'binvinyl',
+ '渡志郎',
];
let thereIsTreasure = $ref($i && !claimedAchievements.includes('foundTreasure'));
diff --git a/packages/frontend/src/pages/about.emojis.vue b/packages/frontend/src/pages/about.emojis.vue
index d461430234..2d82fcf277 100644
--- a/packages/frontend/src/pages/about.emojis.vue
+++ b/packages/frontend/src/pages/about.emojis.vue
@@ -53,7 +53,15 @@ function search() {
}
if (selectedTags.size === 0) {
- searchEmojis = customEmojis.value.filter(emoji => emoji.name.includes(q) || emoji.aliases.includes(q));
+ const queryarry = q.match(/\:([a-z0-9_]*)\:/g);
+
+ if (queryarry) {
+ searchEmojis = customEmojis.value.filter(emoji =>
+ queryarry.includes(`:${emoji.name}:`)
+ );
+ } else {
+ searchEmojis = customEmojis.value.filter(emoji => emoji.name.includes(q) || emoji.aliases.includes(q));
+ }
} else {
searchEmojis = customEmojis.value.filter(emoji => (emoji.name.includes(q) || emoji.aliases.includes(q)) && [...selectedTags].every(t => emoji.aliases.includes(t)));
}
diff --git a/packages/frontend/src/pages/about.vue b/packages/frontend/src/pages/about.vue
index d54d93eaee..8e29990426 100644
--- a/packages/frontend/src/pages/about.vue
+++ b/packages/frontend/src/pages/about.vue
@@ -3,10 +3,10 @@
<template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
<MkSpacer v-if="tab === 'overview'" :content-max="600" :margin-min="20">
<div class="_gaps_m">
- <div class="fwhjspax" :style="{ backgroundImage: `url(${ instance.bannerUrl })` }">
- <div class="content">
- <img :src="instance.iconUrl ?? instance.faviconUrl ?? '/favicon.ico'" alt="" class="icon"/>
- <div class="name">
+ <div :class="$style.banner" :style="{ backgroundImage: `url(${ instance.bannerUrl })` }">
+ <div style="overflow: clip;">
+ <img :src="instance.iconUrl ?? instance.faviconUrl ?? '/favicon.ico'" alt="" :class="$style.bannerIcon"/>
+ <div :class="$style.bannerName">
<b>{{ instance.name ?? host }}</b>
</div>
</div>
@@ -41,7 +41,14 @@
<template #value>{{ instance.maintainerEmail }}</template>
</MkKeyValue>
</FormSplit>
- <FormLink v-if="instance.tosUrl" :to="instance.tosUrl" external>{{ i18n.ts.tos }}</FormLink>
+ <MkFolder v-if="instance.serverRules.length > 0">
+ <template #label>{{ i18n.ts.serverRules }}</template>
+
+ <ol class="_gaps_s" :class="$style.rules">
+ <li v-for="item in instance.serverRules" :class="$style.rule"><div :class="$style.ruleText" v-html="item"></div></li>
+ </ol>
+ </MkFolder>
+ <FormLink v-if="instance.tosUrl" :to="instance.tosUrl" external>{{ i18n.ts.termsOfService }}</FormLink>
</div>
</FormSection>
@@ -94,6 +101,7 @@ import FormLink from '@/components/form/link.vue';
import FormSection from '@/components/form/section.vue';
import FormSuspense from '@/components/form/suspense.vue';
import FormSplit from '@/components/form/split.vue';
+import MkFolder from '@/components/MkFolder.vue';
import MkKeyValue from '@/components/MkKeyValue.vue';
import MkInstanceStats from '@/components/MkInstanceStats.vue';
import * as os from '@/os';
@@ -148,31 +156,63 @@ definePageMetadata(computed(() => ({
})));
</script>
-<style lang="scss" scoped>
-.fwhjspax {
+<style lang="scss" module>
+.banner {
text-align: center;
border-radius: 10px;
overflow: clip;
background-size: cover;
background-position: center center;
+}
- > .content {
- overflow: hidden;
+.bannerIcon {
+ display: block;
+ margin: 16px auto 0 auto;
+ height: 64px;
+ border-radius: 8px;
+}
- > .icon {
- display: block;
- margin: 16px auto 0 auto;
- height: 64px;
- border-radius: 8px;
- }
+.bannerName {
+ display: block;
+ padding: 16px;
+ color: #fff;
+ text-shadow: 0 0 8px #000;
+ background: linear-gradient(transparent, rgba(0, 0, 0, 0.7));
+}
- > .name {
- display: block;
- padding: 16px;
- color: #fff;
- text-shadow: 0 0 8px #000;
- background: linear-gradient(transparent, rgba(0, 0, 0, 0.7));
- }
+.rules {
+ counter-reset: item;
+ list-style: none;
+ padding: 0;
+ margin: 0;
+}
+
+.rule {
+ display: flex;
+ gap: 8px;
+ word-break: break-word;
+
+ &::before {
+ flex-shrink: 0;
+ display: flex;
+ position: sticky;
+ top: calc(var(--stickyTop, 0px) + 8px);
+ counter-increment: item;
+ content: counter(item);
+ width: 32px;
+ height: 32px;
+ line-height: 32px;
+ background-color: var(--accentedBg);
+ color: var(--accent);
+ font-size: 13px;
+ font-weight: bold;
+ align-items: center;
+ justify-content: center;
+ border-radius: 999px;
}
}
+
+.ruleText {
+ padding-top: 6px;
+}
</style>
diff --git a/packages/frontend/src/pages/admin/email-settings.vue b/packages/frontend/src/pages/admin/email-settings.vue
index b742132af6..d51bf6230a 100644
--- a/packages/frontend/src/pages/admin/email-settings.vue
+++ b/packages/frontend/src/pages/admin/email-settings.vue
@@ -96,7 +96,9 @@ async function testEmail() {
const { canceled, result: destination } = await os.inputText({
title: i18n.ts.destination,
type: 'email',
- placeholder: instance.maintainerEmail,
+ default: instance.maintainerEmail ?? '',
+ placeholder: 'test@example.com',
+ minLength: 1,
});
if (canceled) return;
os.apiWithDialog('admin/send-email', {
diff --git a/packages/frontend/src/pages/admin/moderation.vue b/packages/frontend/src/pages/admin/moderation.vue
index ebe1a8ade0..ffd3b6e233 100644
--- a/packages/frontend/src/pages/admin/moderation.vue
+++ b/packages/frontend/src/pages/admin/moderation.vue
@@ -5,14 +5,30 @@
<MkSpacer :content-max="700" :margin-min="16" :margin-max="32">
<FormSuspense :p="init">
<div class="_gaps_m">
- <FormSection first>
- <div class="_gaps_m">
- <MkTextarea v-model="sensitiveWords">
- <template #label>{{ i18n.ts.sensitiveWords }}</template>
- <template #caption>{{ i18n.ts.sensitiveWordsDescription }}</template>
- </MkTextarea>
- </div>
- </FormSection>
+ <MkSwitch v-model="enableRegistration">
+ <template #label>{{ i18n.ts.enableRegistration }}</template>
+ </MkSwitch>
+
+ <MkSwitch v-model="emailRequiredForSignup">
+ <template #label>{{ i18n.ts.emailRequiredForSignup }}</template>
+ </MkSwitch>
+
+ <FormLink to="/admin/server-rules">{{ i18n.ts.serverRules }}</FormLink>
+
+ <MkInput v-model="tosUrl">
+ <template #prefix><i class="ti ti-link"></i></template>
+ <template #label>{{ i18n.ts.tosUrl }}</template>
+ </MkInput>
+
+ <MkTextarea v-model="preservedUsernames">
+ <template #label>{{ i18n.ts.preservedUsernames }}</template>
+ <template #caption>{{ i18n.ts.preservedUsernamesDescription }}</template>
+ </MkTextarea>
+
+ <MkTextarea v-model="sensitiveWords">
+ <template #label>{{ i18n.ts.sensitiveWords }}</template>
+ <template #caption>{{ i18n.ts.sensitiveWordsDescription }}</template>
+ </MkTextarea>
</div>
</FormSuspense>
</MkSpacer>
@@ -41,17 +57,30 @@ import { fetchInstance } from '@/instance';
import { i18n } from '@/i18n';
import { definePageMetadata } from '@/scripts/page-metadata';
import MkButton from '@/components/MkButton.vue';
+import FormLink from '@/components/form/link.vue';
+let enableRegistration: boolean = $ref(false);
+let emailRequiredForSignup: boolean = $ref(false);
let sensitiveWords: string = $ref('');
+let preservedUsernames: string = $ref('');
+let tosUrl: string | null = $ref(null);
async function init() {
const meta = await os.api('admin/meta');
+ enableRegistration = !meta.disableRegistration;
+ emailRequiredForSignup = meta.emailRequiredForSignup;
sensitiveWords = meta.sensitiveWords.join('\n');
+ preservedUsernames = meta.preservedUsernames.join('\n');
+ tosUrl = meta.tosUrl;
}
function save() {
os.apiWithDialog('admin/update-meta', {
+ disableRegistration: !enableRegistration,
+ emailRequiredForSignup,
+ tosUrl,
sensitiveWords: sensitiveWords.split('\n'),
+ preservedUsernames: preservedUsernames.split('\n'),
}).then(() => {
fetchInstance();
});
diff --git a/packages/frontend/src/pages/admin/roles.edit.vue b/packages/frontend/src/pages/admin/roles.edit.vue
index b1aa03f1f7..c211ef2f05 100644
--- a/packages/frontend/src/pages/admin/roles.edit.vue
+++ b/packages/frontend/src/pages/admin/roles.edit.vue
@@ -54,6 +54,7 @@ if (props.id) {
target: 'manual',
condFormula: { id: uuid(), type: 'isRemote' },
isPublic: false,
+ isExplorable: false,
asBadge: false,
canEditMembersByModerator: false,
displayOrder: 0,
diff --git a/packages/frontend/src/pages/admin/roles.editor.vue b/packages/frontend/src/pages/admin/roles.editor.vue
index 873ff02feb..49942c87ce 100644
--- a/packages/frontend/src/pages/admin/roles.editor.vue
+++ b/packages/frontend/src/pages/admin/roles.editor.vue
@@ -8,10 +8,9 @@
<template #label>{{ i18n.ts._role.description }}</template>
</MkTextarea>
- <MkInput v-model="role.color">
+ <MkColorInput v-model="role.color">
<template #label>{{ i18n.ts.color }}</template>
- <template #caption>#RRGGBB</template>
- </MkInput>
+ </MkColorInput>
<MkInput v-model="role.iconUrl">
<template #label>{{ i18n.ts._role.iconUrl }}</template>
@@ -59,6 +58,11 @@
<template #caption>{{ i18n.ts._role.descriptionOfAsBadge }}</template>
</MkSwitch>
+ <MkSwitch v-model="role.isExplorable" :readonly="readonly">
+ <template #label>{{ i18n.ts._role.isExplorable }}</template>
+ <template #caption>{{ i18n.ts._role.descriptionOfIsExplorable }}</template>
+ </MkSwitch>
+
<FormSlot>
<template #label><i class="ti ti-license"></i> {{ i18n.ts._role.policies }}</template>
<div class="_gaps_s">
@@ -206,7 +210,7 @@
</MkRange>
</div>
</MkFolder>
-
+
<MkFolder v-if="matchQuery([i18n.ts._role._options.driveCapacity, 'driveCapacityMb'])">
<template #label>{{ i18n.ts._role._options.driveCapacity }}</template>
<template #suffix>
@@ -227,6 +231,26 @@
</div>
</MkFolder>
+ <MkFolder v-if="matchQuery([i18n.ts._role._options.alwaysMarkNsfw, 'alwaysMarkNsfw'])">
+ <template #label>{{ i18n.ts._role._options.alwaysMarkNsfw }}</template>
+ <template #suffix>
+ <span v-if="role.policies.alwaysMarkNsfw.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
+ <span v-else>{{ role.policies.alwaysMarkNsfw.value ? i18n.ts.yes : i18n.ts.no }}</span>
+ <span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.alwaysMarkNsfw)"></i></span>
+ </template>
+ <div class="_gaps">
+ <MkSwitch v-model="role.policies.alwaysMarkNsfw.useDefault" :readonly="readonly">
+ <template #label>{{ i18n.ts._role.useBaseValue }}</template>
+ </MkSwitch>
+ <MkSwitch v-model="role.policies.alwaysMarkNsfw.value" :disabled="role.policies.alwaysMarkNsfw.useDefault" :readonly="readonly">
+ <template #label>{{ i18n.ts.enable }}</template>
+ </MkSwitch>
+ <MkRange v-model="role.policies.alwaysMarkNsfw.priority" :min="0" :max="2" :step="1" easing :text-converter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''">
+ <template #label>{{ i18n.ts._role.priority }}</template>
+ </MkRange>
+ </div>
+ </MkFolder>
+
<MkFolder v-if="matchQuery([i18n.ts._role._options.pinMax, 'pinLimit'])">
<template #label>{{ i18n.ts._role._options.pinMax }}</template>
<template #suffix>
@@ -409,6 +433,7 @@ import { watch } from 'vue';
import { throttle } from 'throttle-debounce';
import RolesEditorFormula from './RolesEditorFormula.vue';
import MkInput from '@/components/MkInput.vue';
+import MkColorInput from '@/components/MkColorInput.vue';
import MkSelect from '@/components/MkSelect.vue';
import MkTextarea from '@/components/MkTextarea.vue';
import MkFolder from '@/components/MkFolder.vue';
@@ -475,6 +500,7 @@ const save = throttle(100, () => {
isAdministrator: role.isAdministrator,
isModerator: role.isModerator,
isPublic: role.isPublic,
+ isExplorable: role.isExplorable,
asBadge: role.asBadge,
canEditMembersByModerator: role.canEditMembersByModerator,
policies: role.policies,
diff --git a/packages/frontend/src/pages/admin/roles.vue b/packages/frontend/src/pages/admin/roles.vue
index a1e467edbd..e8dbe1c5f0 100644
--- a/packages/frontend/src/pages/admin/roles.vue
+++ b/packages/frontend/src/pages/admin/roles.vue
@@ -75,6 +75,14 @@
</MkInput>
</MkFolder>
+ <MkFolder v-if="matchQuery([i18n.ts._role._options.alwaysMarkNsfw, 'alwaysMarkNsfw'])">
+ <template #label>{{ i18n.ts._role._options.alwaysMarkNsfw }}</template>
+ <template #suffix>{{ policies.alwaysMarkNsfw ? i18n.ts.yes : i18n.ts.no }}</template>
+ <MkSwitch v-model="policies.alwaysMarkNsfw">
+ <template #label>{{ i18n.ts.enable }}</template>
+ </MkSwitch>
+ </MkFolder>
+
<MkFolder v-if="matchQuery([i18n.ts._role._options.pinMax, 'pinLimit'])">
<template #label>{{ i18n.ts._role._options.pinMax }}</template>
<template #suffix>{{ policies.pinLimit }}</template>
diff --git a/packages/frontend/src/pages/admin/server-rules.vue b/packages/frontend/src/pages/admin/server-rules.vue
new file mode 100644
index 0000000000..85781c0bd0
--- /dev/null
+++ b/packages/frontend/src/pages/admin/server-rules.vue
@@ -0,0 +1,128 @@
+<template>
+<div>
+ <MkStickyContainer>
+ <template #header><XHeader :tabs="headerTabs"/></template>
+ <MkSpacer :content-max="700" :margin-min="16" :margin-max="32">
+ <div class="_gaps_m">
+ <div>{{ i18n.ts._serverRules.description }}</div>
+ <Sortable
+ v-model="serverRules"
+ class="_gaps_m"
+ :item-key="(_, i) => i"
+ :animation="150"
+ :handle="'.' + $style.itemHandle"
+ @start="e => e.item.classList.add('active')"
+ @end="e => e.item.classList.remove('active')"
+ >
+ <template #item="{element,index}">
+ <div :class="$style.item">
+ <div :class="$style.itemHeader">
+ <div :class="$style.itemNumber" v-text="String(index + 1)"/>
+ <span :class="$style.itemHandle"><i class="ti ti-menu"/></span>
+ <button class="_button" :class="$style.itemRemove" @click="remove(index)"><i class="ti ti-x"></i></button>
+ </div>
+ <MkInput v-model="serverRules[index]"/>
+ </div>
+ </template>
+ </Sortable>
+ <div :class="$style.commands">
+ <MkButton rounded @click="serverRules.push('')"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton>
+ <MkButton primary rounded :class="$style.buttonSave" @click="save"><i class="ti ti-check"></i> {{ i18n.ts.save }}</MkButton>
+ </div>
+ </div>
+ </MkSpacer>
+ </MkStickyContainer>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { defineAsyncComponent } from 'vue';
+import XHeader from './_header_.vue';
+import * as os from '@/os';
+import { fetchInstance, instance } from '@/instance';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+import MkButton from '@/components/MkButton.vue';
+import MkInput from '@/components/MkInput.vue';
+
+const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default));
+
+let serverRules: string[] = $ref(instance.serverRules);
+
+const save = async () => {
+ await os.apiWithDialog('admin/update-meta', {
+ serverRules,
+ });
+ fetchInstance();
+};
+
+const remove = (index: number): void => {
+ serverRules.splice(index, 1);
+};
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+ title: i18n.ts.serverRules,
+ icon: 'ti ti-checkbox',
+});
+</script>
+
+<style lang="scss" module>
+.item {
+ display: block;
+ color: var(--navFg);
+}
+
+.itemHeader {
+ display: flex;
+ margin-bottom: 8px;
+ align-items: center;
+}
+
+.itemHandle {
+ display: flex;
+ width: 40px;
+ height: 40px;
+ align-items: center;
+ justify-content: center;
+ cursor: move;
+}
+
+.itemNumber {
+ display: flex;
+ background-color: var(--accentedBg);
+ color: var(--accent);
+ font-size: 14px;
+ font-weight: bold;
+ width: 28px;
+ height: 28px;
+ align-items: center;
+ justify-content: center;
+ border-radius: 999px;
+ margin-right: 8px;
+}
+
+.itemEdit {
+ width: 100%;
+ max-width: 100%;
+ min-width: 100%;
+}
+
+.itemRemove {
+ width: 40px;
+ height: 40px;
+ color: var(--error);
+ margin-left: auto;
+ border-radius: 6px;
+
+ &:hover {
+ background: var(--X5);
+ }
+}
+
+.commands {
+ display: flex;
+ gap: 16px;
+}
+</style>
diff --git a/packages/frontend/src/pages/admin/settings.vue b/packages/frontend/src/pages/admin/settings.vue
index 65e64930d5..7ec3c381f3 100644
--- a/packages/frontend/src/pages/admin/settings.vue
+++ b/packages/frontend/src/pages/admin/settings.vue
@@ -13,11 +13,6 @@
<template #label>{{ i18n.ts.instanceDescription }}</template>
</MkTextarea>
- <MkInput v-model="tosUrl">
- <template #prefix><i class="ti ti-link"></i></template>
- <template #label>{{ i18n.ts.tosUrl }}</template>
- </MkInput>
-
<FormSplit :min-width="300">
<MkInput v-model="maintainerName">
<template #label>{{ i18n.ts.maintainerName }}</template>
@@ -36,14 +31,6 @@
<FormSection>
<div class="_gaps_s">
- <MkSwitch v-model="enableRegistration">
- <template #label>{{ i18n.ts.enableRegistration }}</template>
- </MkSwitch>
-
- <MkSwitch v-model="emailRequiredForSignup">
- <template #label>{{ i18n.ts.emailRequiredForSignup }}</template>
- </MkSwitch>
-
<MkSwitch v-model="enableChartsForRemoteUser">
<template #label>{{ i18n.ts.enableChartsForRemoteUser }}</template>
</MkSwitch>
@@ -73,11 +60,9 @@
<template #label>{{ i18n.ts.backgroundImageUrl }}</template>
</MkInput>
- <MkInput v-model="themeColor">
- <template #prefix><i class="ti ti-palette"></i></template>
+ <MkColorInput v-model="themeColor">
<template #label>{{ i18n.ts.themeColor }}</template>
- <template #caption>#RRGGBB</template>
- </MkInput>
+ </MkColorInput>
<MkTextarea v-model="defaultLightTheme">
<template #label>{{ i18n.ts.instanceDefaultLightTheme }}</template>
@@ -166,10 +151,10 @@ import { fetchInstance } from '@/instance';
import { i18n } from '@/i18n';
import { definePageMetadata } from '@/scripts/page-metadata';
import MkButton from '@/components/MkButton.vue';
+import MkColorInput from '@/components/MkColorInput.vue';
let name: string | null = $ref(null);
let description: string | null = $ref(null);
-let tosUrl: string | null = $ref(null);
let maintainerName: string | null = $ref(null);
let maintainerEmail: string | null = $ref(null);
let iconUrl: string | null = $ref(null);
@@ -180,8 +165,6 @@ let defaultLightTheme: any = $ref(null);
let defaultDarkTheme: any = $ref(null);
let pinnedUsers: string = $ref('');
let cacheRemoteFiles: boolean = $ref(false);
-let enableRegistration: boolean = $ref(false);
-let emailRequiredForSignup: boolean = $ref(false);
let enableServiceWorker: boolean = $ref(false);
let enableChartsForRemoteUser: boolean = $ref(false);
let enableChartsForFederatedInstances: boolean = $ref(false);
@@ -194,7 +177,6 @@ async function init() {
const meta = await os.api('admin/meta');
name = meta.name;
description = meta.description;
- tosUrl = meta.tosUrl;
iconUrl = meta.iconUrl;
bannerUrl = meta.bannerUrl;
backgroundImageUrl = meta.backgroundImageUrl;
@@ -205,8 +187,6 @@ async function init() {
maintainerEmail = meta.maintainerEmail;
pinnedUsers = meta.pinnedUsers.join('\n');
cacheRemoteFiles = meta.cacheRemoteFiles;
- enableRegistration = !meta.disableRegistration;
- emailRequiredForSignup = meta.emailRequiredForSignup;
enableServiceWorker = meta.enableServiceWorker;
enableChartsForRemoteUser = meta.enableChartsForRemoteUser;
enableChartsForFederatedInstances = meta.enableChartsForFederatedInstances;
@@ -220,7 +200,6 @@ function save() {
os.apiWithDialog('admin/update-meta', {
name,
description,
- tosUrl,
iconUrl,
bannerUrl,
backgroundImageUrl,
@@ -231,8 +210,6 @@ function save() {
maintainerEmail,
pinnedUsers: pinnedUsers.split('\n'),
cacheRemoteFiles,
- disableRegistration: !enableRegistration,
- emailRequiredForSignup,
enableServiceWorker,
enableChartsForRemoteUser,
enableChartsForFederatedInstances,
diff --git a/packages/frontend/src/pages/channel-editor.vue b/packages/frontend/src/pages/channel-editor.vue
index 9cb440d2bb..a74ab40473 100644
--- a/packages/frontend/src/pages/channel-editor.vue
+++ b/packages/frontend/src/pages/channel-editor.vue
@@ -11,6 +11,10 @@
<template #label>{{ i18n.ts.description }}</template>
</MkTextarea>
+ <MkColorInput v-model="color">
+ <template #label>{{ i18n.ts.color }}</template>
+ </MkColorInput>
+
<div>
<MkButton v-if="bannerId == null" @click="setBannerImage"><i class="ti ti-plus"></i> {{ i18n.ts._channel.setBanner }}</MkButton>
<div v-else-if="bannerUrl">
@@ -42,8 +46,9 @@
</div>
</MkFolder>
- <div>
+ <div class="_buttons">
<MkButton primary @click="save()"><i class="ti ti-device-floppy"></i> {{ channelId ? i18n.ts.save : i18n.ts.create }}</MkButton>
+ <MkButton v-if="channelId" danger @click="archive()"><i class="ti ti-trash"></i> {{ i18n.ts.archive }}</MkButton>
</div>
</div>
</MkSpacer>
@@ -55,6 +60,7 @@ import { computed, ref, watch, defineAsyncComponent } from 'vue';
import MkTextarea from '@/components/MkTextarea.vue';
import MkButton from '@/components/MkButton.vue';
import MkInput from '@/components/MkInput.vue';
+import MkColorInput from '@/components/MkColorInput.vue';
import { selectFile } from '@/scripts/select-file';
import * as os from '@/os';
import { useRouter } from '@/router';
@@ -75,6 +81,7 @@ let name = $ref(null);
let description = $ref(null);
let bannerUrl = $ref<string | null>(null);
let bannerId = $ref<string | null>(null);
+let color = $ref('#000');
const pinnedNotes = ref([]);
watch(() => bannerId, async () => {
@@ -101,6 +108,7 @@ async function fetchChannel() {
pinnedNotes.value = channel.pinnedNoteIds.map(id => ({
id,
}));
+ color = channel.color;
}
fetchChannel();
@@ -128,6 +136,7 @@ function save() {
description: description,
bannerId: bannerId,
pinnedNoteIds: pinnedNotes.value.map(x => x.id),
+ color: color,
};
if (props.channelId) {
@@ -143,6 +152,23 @@ function save() {
}
}
+async function archive() {
+ const { canceled } = await os.confirm({
+ type: 'warning',
+ title: i18n.t('channelArchiveConfirmTitle', { name: name }),
+ text: i18n.ts.channelArchiveConfirmDescription,
+ });
+
+ if (canceled) return;
+
+ os.api('channels/update', {
+ channelId: props.channelId,
+ isArchived: true,
+ }).then(() => {
+ os.success();
+ });
+}
+
function setBannerImage(evt) {
selectFile(evt.currentTarget ?? evt.target, null).then(file => {
bannerId = file.id;
diff --git a/packages/frontend/src/pages/channel.vue b/packages/frontend/src/pages/channel.vue
index 437c1fae31..af1b4d2056 100644
--- a/packages/frontend/src/pages/channel.vue
+++ b/packages/frontend/src/pages/channel.vue
@@ -28,6 +28,8 @@
</MkFoldableSection>
</div>
<div v-if="channel && tab === 'timeline'" class="_gaps">
+ <MkInfo v-if="channel.isArchived" warn>{{ i18n.ts.thisChannelArchived }}</MkInfo>
+
<!-- スマホ・タブレットの場合、キーボードが表示されると投稿が見づらくなるので、デスクトップ場合のみ自動でフォーカスを当てる -->
<MkPostForm v-if="$i && defaultStore.reactiveState.showFixedPostFormInChannel.value" :channel="channel" class="post-form _panel" fixed :autofocus="deviceKind === 'desktop'"/>
@@ -36,6 +38,17 @@
<div v-else-if="tab === 'featured'">
<MkNotes :pagination="featuredPagination"/>
</div>
+ <div v-else-if="tab === 'search'">
+ <div class="_gaps">
+ <div>
+ <MkInput v-model="searchQuery">
+ <template #prefix><i class="ti ti-search"></i></template>
+ </MkInput>
+ <MkButton primary rounded style="margin-top: 8px;" @click="search()">{{ i18n.ts.search }}</MkButton>
+ </div>
+ <MkNotes v-if="searchPagination" :key="searchQuery" :pagination="searchPagination"/>
+ </div>
+ </div>
</MkSpacer>
<template #footer>
<div :class="$style.footer">
@@ -63,8 +76,10 @@ import { deviceKind } from '@/scripts/device-kind';
import MkNotes from '@/components/MkNotes.vue';
import { url } from '@/config';
import MkButton from '@/components/MkButton.vue';
+import MkInput from '@/components/MkInput.vue';
import { defaultStore } from '@/store';
import MkNote from '@/components/MkNote.vue';
+import MkInfo from '@/components/MkInfo.vue';
import MkFoldableSection from '@/components/MkFoldableSection.vue';
const router = useRouter();
@@ -76,6 +91,8 @@ const props = defineProps<{
let tab = $ref('timeline');
let channel = $ref(null);
let favorited = $ref(false);
+let searchQuery = $ref('');
+let searchPagination = $ref();
const featuredPagination = $computed(() => ({
endpoint: 'notes/featured' as const,
limit: 10,
@@ -123,6 +140,21 @@ async function unfavorite() {
});
}
+async function search() {
+ const query = searchQuery.toString().trim();
+
+ if (query == null) return;
+
+ searchPagination = {
+ endpoint: 'notes/search',
+ limit: 10,
+ params: {
+ query: searchQuery,
+ channelId: channel.id,
+ },
+ };
+}
+
const headerActions = $computed(() => {
if (channel && channel.userId) {
const share = {
@@ -160,6 +192,10 @@ const headerTabs = $computed(() => [{
key: 'featured',
title: i18n.ts.featured,
icon: 'ti ti-bolt',
+}, {
+ key: 'search',
+ title: i18n.ts.search,
+ icon: 'ti ti-search',
}]);
definePageMetadata(computed(() => channel ? {
@@ -170,7 +206,7 @@ definePageMetadata(computed(() => channel ? {
<style lang="scss" module>
.main {
- min-height: calc(var(--containerHeight) - (var(--stickyTop, 0px) + var(--stickyBottom, 0px)));
+ min-height: calc(100cqh - (var(--stickyTop, 0px) + var(--stickyBottom, 0px)));
}
.footer {
diff --git a/packages/frontend/src/pages/channels.vue b/packages/frontend/src/pages/channels.vue
index 70e7705d1d..e670cdd864 100644
--- a/packages/frontend/src/pages/channels.vue
+++ b/packages/frontend/src/pages/channels.vue
@@ -96,7 +96,7 @@ const ownedPagination = {
async function search() {
const query = searchQuery.toString().trim();
- if (query == null || query === '') return;
+ if (query == null) return;
const type = searchType.toString().trim();
diff --git a/packages/frontend/src/pages/custom-emojis-manager.vue b/packages/frontend/src/pages/custom-emojis-manager.vue
index 59cb3262b7..3f13f0787d 100644
--- a/packages/frontend/src/pages/custom-emojis-manager.vue
+++ b/packages/frontend/src/pages/custom-emojis-manager.vue
@@ -15,9 +15,10 @@
<div v-if="selectMode" class="_buttons">
<MkButton inline @click="selectAll">Select all</MkButton>
<MkButton inline @click="setCategoryBulk">Set category</MkButton>
+ <MkButton inline @click="setTagBulk">Set tag</MkButton>
<MkButton inline @click="addTagBulk">Add tag</MkButton>
<MkButton inline @click="removeTagBulk">Remove tag</MkButton>
- <MkButton inline @click="setTagBulk">Set tag</MkButton>
+ <MkButton inline @click="setLisenceBulk">Set Lisence</MkButton>
<MkButton inline danger @click="delBulk">Delete</MkButton>
</div>
<MkPagination ref="emojisPaginationComponent" :pagination="pagination">
@@ -221,6 +222,18 @@ const setCategoryBulk = async () => {
emojisPaginationComponent.value.reload();
};
+const setLisenceBulk = async () => {
+ const { canceled, result } = await os.inputText({
+ title: 'License',
+ });
+ if (canceled) return;
+ await os.apiWithDialog('admin/emoji/set-license-bulk', {
+ ids: selectedEmojis.value,
+ license: result,
+ });
+ emojisPaginationComponent.value.reload();
+};
+
const addTagBulk = async () => {
const { canceled, result } = await os.inputText({
title: 'Tag',
diff --git a/packages/frontend/src/pages/flash/flash-edit.vue b/packages/frontend/src/pages/flash/flash-edit.vue
index 35edcc7cda..816825e5b6 100644
--- a/packages/frontend/src/pages/flash/flash-edit.vue
+++ b/packages/frontend/src/pages/flash/flash-edit.vue
@@ -33,7 +33,7 @@ import MkTextarea from '@/components/MkTextarea.vue';
import MkInput from '@/components/MkInput.vue';
import { useRouter } from '@/router';
-const PRESET_DEFAULT = `/// @ 0.13.1
+const PRESET_DEFAULT = `/// @ 0.13.2
var name = ""
@@ -51,7 +51,7 @@ Ui:render([
])
`;
-const PRESET_OMIKUJI = `/// @ 0.13.1
+const PRESET_OMIKUJI = `/// @ 0.13.2
// ユーザーごとに日替わりのおみくじのプリセット
// 選択肢
@@ -94,7 +94,7 @@ Ui:render([
])
`;
-const PRESET_SHUFFLE = `/// @ 0.13.1
+const PRESET_SHUFFLE = `/// @ 0.13.2
// 巻き戻し可能な文字シャッフルのプリセット
let string = "ペペロンチーノ"
@@ -173,7 +173,7 @@ var cursor = 0
do()
`;
-const PRESET_QUIZ = `/// @ 0.13.1
+const PRESET_QUIZ = `/// @ 0.13.2
let title = '地理クイズ'
let qas = [{
@@ -286,7 +286,7 @@ qaEls.push(Ui:C:container({
Ui:render(qaEls)
`;
-const PRESET_TIMELINE = `/// @ 0.13.1
+const PRESET_TIMELINE = `/// @ 0.13.2
// APIリクエストを行いローカルタイムラインを表示するプリセット
@fetch() {
@@ -305,6 +305,11 @@ const PRESET_TIMELINE = `/// @ 0.13.1
// それぞれのノートごとにUI要素作成
let noteEls = []
each (let note, notes) {
+ // 表示名を設定していないアカウントはidを表示
+ let userName = if Core:type(note.user.name) == "str" note.user.name else note.user.username
+ // リノートもしくはメディア・投票のみで本文が無いノートに代替表示文を設定
+ let noteText = if Core:type(note.text) == "str" note.text else "(リノートもしくはメディア・投票のみのノート)"
+
let el = Ui:C:container({
bgColor: "#444"
fgColor: "#fff"
@@ -312,11 +317,11 @@ const PRESET_TIMELINE = `/// @ 0.13.1
rounded: true
children: [
Ui:C:mfm({
- text: note.user.name
+ text: userName
bold: true
})
Ui:C:mfm({
- text: note.text
+ text: noteText
})
]
})
diff --git a/packages/frontend/src/pages/gallery/edit.vue b/packages/frontend/src/pages/gallery/edit.vue
index 1fae7686e5..cafcee0c33 100644
--- a/packages/frontend/src/pages/gallery/edit.vue
+++ b/packages/frontend/src/pages/gallery/edit.vue
@@ -2,7 +2,7 @@
<MkStickyContainer>
<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
<MkSpacer :content-max="800" :margin-min="16" :margin-max="32">
- <FormSuspense :p="init">
+ <FormSuspense :p="init" class="_gaps">
<MkInput v-model="title">
<template #label>{{ i18n.ts.title }}</template>
</MkInput>
@@ -11,7 +11,7 @@
<template #label>{{ i18n.ts.description }}</template>
</MkTextarea>
- <div class="">
+ <div class="_gaps_s">
<div v-for="file in files" :key="file.id" class="wqugxsfx" :style="{ backgroundImage: file ? `url(${ file.thumbnailUrl })` : null }">
<div class="name">{{ file.name }}</div>
<button v-tooltip="i18n.ts.remove" class="remove _button" @click="remove(file)"><i class="ti ti-x"></i></button>
@@ -21,10 +21,12 @@
<MkSwitch v-model="isSensitive">{{ i18n.ts.markAsSensitive }}</MkSwitch>
- <MkButton v-if="postId" primary @click="save"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton>
- <MkButton v-else primary @click="save"><i class="ti ti-device-floppy"></i> {{ i18n.ts.publish }}</MkButton>
+ <div class="_buttons">
+ <MkButton v-if="postId" primary @click="save"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton>
+ <MkButton v-else primary @click="save"><i class="ti ti-device-floppy"></i> {{ i18n.ts.publish }}</MkButton>
- <MkButton v-if="postId" danger @click="del"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton>
+ <MkButton v-if="postId" danger @click="del"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton>
+ </div>
</FormSuspense>
</MkSpacer>
</MkStickyContainer>
diff --git a/packages/frontend/src/pages/my-lists/list.vue b/packages/frontend/src/pages/my-lists/list.vue
index 768a48746c..86201e8e0c 100644
--- a/packages/frontend/src/pages/my-lists/list.vue
+++ b/packages/frontend/src/pages/my-lists/list.vue
@@ -131,7 +131,7 @@ definePageMetadata(computed(() => list ? {
<style lang="scss" module>
.main {
- min-height: calc(var(--containerHeight) - (var(--stickyTop, 0px) + var(--stickyBottom, 0px)));
+ min-height: calc(100cqh - (var(--stickyTop, 0px) + var(--stickyBottom, 0px)));
}
.userItem {
diff --git a/packages/frontend/src/pages/page-editor/els/page-editor.el.image.vue b/packages/frontend/src/pages/page-editor/els/page-editor.el.image.vue
index ffeb8ba285..e97a4b07f1 100644
--- a/packages/frontend/src/pages/page-editor/els/page-editor.el.image.vue
+++ b/packages/frontend/src/pages/page-editor/els/page-editor.el.image.vue
@@ -33,8 +33,8 @@ const emit = defineEmits<{
let file: any = $ref(null);
async function choose() {
- os.selectDriveFile(false).then((fileResponse: any) => {
- file = fileResponse;
+ os.selectDriveFile(false).then((fileResponse) => {
+ file = fileResponse[0];
emit('update:modelValue', {
...props.modelValue,
fileId: fileResponse.id,
diff --git a/packages/frontend/src/pages/role.vue b/packages/frontend/src/pages/role.vue
index f2645394a2..fe39c594ba 100644
--- a/packages/frontend/src/pages/role.vue
+++ b/packages/frontend/src/pages/role.vue
@@ -1,8 +1,16 @@
<template>
<MkStickyContainer>
<template #header><MkPageHeader v-model:tab="tab" :tabs="headerTabs"/></template>
-
- <MkSpacer v-if="tab === 'users'" :content-max="1200">
+ <MKSpacer v-if="!(typeof error === 'undefined')" :content-max="1200">
+ <div :class="$style.root">
+ <img :class="$style.img" src="https://xn--931a.moe/assets/error.jpg" class="_ghost"/>
+ <p :class="$style.text">
+ <i class="ti ti-alert-triangle"></i>
+ {{ error }}
+ </p>
+ </div>
+ </MKSpacer>
+ <MkSpacer v-else-if="tab === 'users'" :content-max="1200">
<div class="_gaps_s">
<div v-if="role">{{ role.description }}</div>
<MkUserList :pagination="users" :extractor="(item) => item.user"/>
@@ -13,7 +21,6 @@
</MkSpacer>
</MkStickyContainer>
</template>
-
<script lang="ts" setup>
import { computed, watch } from 'vue';
import * as os from '@/os';
@@ -21,6 +28,7 @@ import MkUserList from '@/components/MkUserList.vue';
import { definePageMetadata } from '@/scripts/page-metadata';
import { i18n } from '@/i18n';
import MkTimeline from '@/components/MkTimeline.vue';
+import { instanceName } from '@/config';
const props = withDefaults(defineProps<{
role: string;
@@ -31,12 +39,21 @@ const props = withDefaults(defineProps<{
let tab = $ref(props.initialTab);
let role = $ref();
+let error = $ref();
watch(() => props.role, () => {
os.api('roles/show', {
roleId: props.role,
}).then(res => {
role = res;
+ document.title = `${role?.name} | ${instanceName}`;
+ }).catch((err) => {
+ if (err.code === 'NO_SUCH_ROLE') {
+ error = i18n.ts.noRole;
+ } else {
+ error = i18n.ts.somethingHappened;
+ }
+ document.title = `${error} | ${instanceName}`;
});
}, { immediate: true });
@@ -63,4 +80,23 @@ definePageMetadata(computed(() => ({
icon: 'ti ti-badge',
})));
</script>
+<style lang="scss" module>
+.root {
+ padding: 32px;
+ text-align: center;
+ align-items: center;
+}
+
+.text {
+ margin: 0 0 8px 0;
+}
+
+.img {
+ vertical-align: bottom;
+ width: 128px;
+ height: 128px;
+ margin-bottom: 16px;
+ border-radius: 16px;
+}
+</style>
diff --git a/packages/frontend/src/pages/settings/account-info.vue b/packages/frontend/src/pages/settings/account-stats.vue
index 584808b0b4..a0f1541b40 100644
--- a/packages/frontend/src/pages/settings/account-info.vue
+++ b/packages/frontend/src/pages/settings/account-stats.vue
@@ -1,18 +1,6 @@
<template>
<div class="_gaps_m">
- <MkKeyValue>
- <template #key>ID</template>
- <template #value><span class="_monospace">{{ $i.id }}</span></template>
- </MkKeyValue>
-
- <FormSection>
- <MkKeyValue>
- <template #key>{{ i18n.ts.registeredDate }}</template>
- <template #value><MkTime :time="$i.createdAt" mode="detail"/></template>
- </MkKeyValue>
- </FormSection>
-
- <FormSection v-if="stats">
+ <FormSection v-if="stats" first>
<template #label>{{ i18n.ts.statistics }}</template>
<MkKeyValue oneline style="margin: 1em 0;">
<template #key>{{ i18n.ts.notesCount }}</template>
diff --git a/packages/frontend/src/pages/settings/delete-account.vue b/packages/frontend/src/pages/settings/delete-account.vue
deleted file mode 100644
index c6e79165c5..0000000000
--- a/packages/frontend/src/pages/settings/delete-account.vue
+++ /dev/null
@@ -1,52 +0,0 @@
-<template>
-<div class="_gaps_m">
- <FormInfo warn>{{ i18n.ts._accountDelete.mayTakeTime }}</FormInfo>
- <FormInfo>{{ i18n.ts._accountDelete.sendEmail }}</FormInfo>
- <MkButton v-if="!$i.isDeleted" danger @click="deleteAccount">{{ i18n.ts._accountDelete.requestAccountDelete }}</MkButton>
- <MkButton v-else disabled>{{ i18n.ts._accountDelete.inProgress }}</MkButton>
-</div>
-</template>
-
-<script lang="ts" setup>
-import FormInfo from '@/components/MkInfo.vue';
-import MkButton from '@/components/MkButton.vue';
-import * as os from '@/os';
-import { signout, $i } from '@/account';
-import { i18n } from '@/i18n';
-import { definePageMetadata } from '@/scripts/page-metadata';
-
-async function deleteAccount() {
- {
- const { canceled } = await os.confirm({
- type: 'warning',
- text: i18n.ts.deleteAccountConfirm,
- });
- if (canceled) return;
- }
-
- const { canceled, result: password } = await os.inputText({
- title: i18n.ts.password,
- type: 'password',
- });
- if (canceled) return;
-
- await os.apiWithDialog('i/delete-account', {
- password: password,
- });
-
- await os.alert({
- title: i18n.ts._accountDelete.started,
- });
-
- await signout();
-}
-
-const headerActions = $computed(() => []);
-
-const headerTabs = $computed(() => []);
-
-definePageMetadata({
- title: i18n.ts._accountDelete.accountDelete,
- icon: 'ti ti-alert-triangle',
-});
-</script>
diff --git a/packages/frontend/src/pages/settings/drive.vue b/packages/frontend/src/pages/settings/drive.vue
index d3fb422e01..73c2b2e604 100644
--- a/packages/frontend/src/pages/settings/drive.vue
+++ b/packages/frontend/src/pages/settings/drive.vue
@@ -119,6 +119,13 @@ function saveProfile() {
os.api('i/update', {
alwaysMarkNsfw: !!alwaysMarkNsfw,
autoSensitive: !!autoSensitive,
+ }).catch(err => {
+ os.alert({
+ type: 'error',
+ title: i18n.ts.error,
+ text: err.message,
+ });
+ alwaysMarkNsfw = true;
});
}
diff --git a/packages/frontend/src/pages/settings/general.vue b/packages/frontend/src/pages/settings/general.vue
index 904fd3f952..ba0f3274fc 100644
--- a/packages/frontend/src/pages/settings/general.vue
+++ b/packages/frontend/src/pages/settings/general.vue
@@ -20,24 +20,71 @@
<option value="desktop"><i class="ti ti-device-desktop"/> {{ i18n.ts.desktop }}</option>
</MkRadios>
- <MkSwitch v-model="showFixedPostForm">{{ i18n.ts.showFixedPostForm }}</MkSwitch>
- <MkSwitch v-model="showFixedPostFormInChannel">{{ i18n.ts.showFixedPostFormInChannel }}</MkSwitch>
+ <FormSection>
+ <div class="_gaps_s">
+ <MkSwitch v-model="showFixedPostForm">{{ i18n.ts.showFixedPostForm }}</MkSwitch>
+ <MkSwitch v-model="showFixedPostFormInChannel">{{ i18n.ts.showFixedPostFormInChannel }}</MkSwitch>
+ </div>
+ </FormSection>
<FormSection>
- <template #label>{{ i18n.ts.behavior }}</template>
+ <template #label>{{ i18n.ts.displayOfNote }}</template>
<div class="_gaps_m">
<div class="_gaps_s">
- <MkSwitch v-model="imageNewTab">{{ i18n.ts.openImageInNewTab }}</MkSwitch>
- <MkSwitch v-model="enableInfiniteScroll">{{ i18n.ts.enableInfiniteScroll }}</MkSwitch>
+ <MkSwitch v-model="showNoteActionsOnlyHover">{{ i18n.ts.showNoteActionsOnlyHover }}</MkSwitch>
+ <MkSwitch v-model="showClipButtonInNoteFooter">{{ i18n.ts.showClipButtonInNoteFooter }}</MkSwitch>
+ <MkSwitch v-model="largeNoteReactions">{{ i18n.ts.largeNoteReactions }}</MkSwitch>
+ <MkSwitch v-model="collapseRenotes">{{ i18n.ts.collapseRenotes }}</MkSwitch>
+ <MkSwitch v-model="advancedMfm">{{ i18n.ts.enableAdvancedMfm }}</MkSwitch>
+ <MkSwitch v-if="advancedMfm" v-model="animatedMfm">{{ i18n.ts.enableAnimatedMfm }}</MkSwitch>
+ <MkSwitch v-model="showGapBetweenNotesInTimeline">{{ i18n.ts.showGapBetweenNotesInTimeline }}</MkSwitch>
+ <MkSwitch v-model="loadRawImages">{{ i18n.ts.loadRawImages }}</MkSwitch>
<MkSwitch v-model="useReactionPickerForContextMenu">{{ i18n.ts.useReactionPickerForContextMenu }}</MkSwitch>
</div>
- <MkSelect v-model="serverDisconnectedBehavior">
- <template #label>{{ i18n.ts.whenServerDisconnected }}</template>
- <option value="reload">{{ i18n.ts._serverDisconnectedBehavior.reload }}</option>
- <option value="dialog">{{ i18n.ts._serverDisconnectedBehavior.dialog }}</option>
- <option value="quiet">{{ i18n.ts._serverDisconnectedBehavior.quiet }}</option>
+
+ <MkSelect v-model="instanceTicker">
+ <template #label>{{ i18n.ts.instanceTicker }}</template>
+ <option value="none">{{ i18n.ts._instanceTicker.none }}</option>
+ <option value="remote">{{ i18n.ts._instanceTicker.remote }}</option>
+ <option value="always">{{ i18n.ts._instanceTicker.always }}</option>
</MkSelect>
+
+ <MkSelect v-model="nsfw">
+ <template #label>{{ i18n.ts.nsfw }}</template>
+ <option value="respect">{{ i18n.ts._nsfw.respect }}</option>
+ <option value="ignore">{{ i18n.ts._nsfw.ignore }}</option>
+ <option value="force">{{ i18n.ts._nsfw.force }}</option>
+ </MkSelect>
+ <!--
+ <MkRadios v-model="mediaListWithOneImageAppearance">
+ <template #label>{{ i18n.ts.mediaListWithOneImageAppearance }}</template>
+ <option value="expand">{{ i18n.ts.default }}</option>
+ <option value="16_9">{{ i18n.t('limitTo', { x: '16:9' }) }}</option>
+ <option value="1_1">{{ i18n.t('limitTo', { x: '1:1' }) }}</option>
+ <option value="2_3">{{ i18n.t('limitTo', { x: '2:3' }) }}</option>
+ </MkRadios>
+ -->
+ </div>
+ </FormSection>
+
+ <FormSection>
+ <template #label>{{ i18n.ts.notificationDisplay }}</template>
+
+ <div class="_gaps_m">
+ <MkRadios v-model="notificationPosition">
+ <template #label>{{ i18n.ts.position }}</template>
+ <option value="leftTop"><i class="ti ti-align-box-left-top"></i> {{ i18n.ts.leftTop }}</option>
+ <option value="rightTop"><i class="ti ti-align-box-right-top"></i> {{ i18n.ts.rightTop }}</option>
+ <option value="leftBottom"><i class="ti ti-align-box-left-bottom"></i> {{ i18n.ts.leftBottom }}</option>
+ <option value="rightBottom"><i class="ti ti-align-box-right-bottom"></i> {{ i18n.ts.rightBottom }}</option>
+ </MkRadios>
+
+ <MkRadios v-model="notificationStackAxis">
+ <template #label>{{ i18n.ts.stackAxis }}</template>
+ <option value="vertical"><i class="ti ti-carousel-vertical"></i> {{ i18n.ts.vertical }}</option>
+ <option value="horizontal"><i class="ti ti-carousel-horizontal"></i> {{ i18n.ts.horizontal }}</option>
+ </MkRadios>
</div>
</FormSection>
@@ -46,22 +93,15 @@
<div class="_gaps_m">
<div class="_gaps_s">
- <MkSwitch v-model="showNoteActionsOnlyHover">{{ i18n.ts.showNoteActionsOnlyHover }}</MkSwitch>
- <MkSwitch v-model="showClipButtonInNoteFooter">{{ i18n.ts.showClipButtonInNoteFooter }}</MkSwitch>
- <MkSwitch v-model="largeNoteReactions">{{ i18n.ts.largeNoteReactions }}</MkSwitch>
- <MkSwitch v-model="collapseRenotes">{{ i18n.ts.collapseRenotes }}</MkSwitch>
- <MkSwitch v-model="advancedMfm">{{ i18n.ts.enableAdvancedMfm }}</MkSwitch>
- <MkSwitch v-if="advancedMfm" v-model="animatedMfm">{{ i18n.ts.enableAnimatedMfm }}</MkSwitch>
<MkSwitch v-model="reduceAnimation">{{ i18n.ts.reduceUiAnimation }}</MkSwitch>
<MkSwitch v-model="useBlurEffect">{{ i18n.ts.useBlurEffect }}</MkSwitch>
<MkSwitch v-model="useBlurEffectForModal">{{ i18n.ts.useBlurEffectForModal }}</MkSwitch>
- <MkSwitch v-model="showGapBetweenNotesInTimeline">{{ i18n.ts.showGapBetweenNotesInTimeline }}</MkSwitch>
- <MkSwitch v-model="loadRawImages">{{ i18n.ts.loadRawImages }}</MkSwitch>
<MkSwitch v-model="disableShowingAnimatedImages">{{ i18n.ts.disableShowingAnimatedImages }}</MkSwitch>
<MkSwitch v-model="squareAvatars">{{ i18n.ts.squareAvatars }}</MkSwitch>
<MkSwitch v-model="useSystemFont">{{ i18n.ts.useSystemFont }}</MkSwitch>
<MkSwitch v-model="disableDrawer">{{ i18n.ts.disableDrawer }}</MkSwitch>
<MkSwitch v-model="forceShowAds">{{ i18n.ts.forceShowAds }}</MkSwitch>
+ <MkSwitch v-model="enableDataSaverMode">{{ i18n.ts.dataSaver }}</MkSwitch>
</div>
<div>
<MkRadios v-model="emojiStyle">
@@ -84,27 +124,29 @@
</FormSection>
<FormSection>
- <MkSwitch v-model="aiChanMode">{{ i18n.ts.aiChanMode }}</MkSwitch>
- </FormSection>
-
- <MkSelect v-model="instanceTicker">
- <template #label>{{ i18n.ts.instanceTicker }}</template>
- <option value="none">{{ i18n.ts._instanceTicker.none }}</option>
- <option value="remote">{{ i18n.ts._instanceTicker.remote }}</option>
- <option value="always">{{ i18n.ts._instanceTicker.always }}</option>
- </MkSelect>
+ <template #label>{{ i18n.ts.behavior }}</template>
- <MkSelect v-model="nsfw">
- <template #label>{{ i18n.ts.nsfw }}</template>
- <option value="respect">{{ i18n.ts._nsfw.respect }}</option>
- <option value="ignore">{{ i18n.ts._nsfw.ignore }}</option>
- <option value="force">{{ i18n.ts._nsfw.force }}</option>
- </MkSelect>
+ <div class="_gaps_m">
+ <div class="_gaps_s">
+ <MkSwitch v-model="imageNewTab">{{ i18n.ts.openImageInNewTab }}</MkSwitch>
+ <MkSwitch v-model="enableInfiniteScroll">{{ i18n.ts.enableInfiniteScroll }}</MkSwitch>
+ </div>
+ <MkSelect v-model="serverDisconnectedBehavior">
+ <template #label>{{ i18n.ts.whenServerDisconnected }}</template>
+ <option value="reload">{{ i18n.ts._serverDisconnectedBehavior.reload }}</option>
+ <option value="dialog">{{ i18n.ts._serverDisconnectedBehavior.dialog }}</option>
+ <option value="quiet">{{ i18n.ts._serverDisconnectedBehavior.quiet }}</option>
+ </MkSelect>
+ <MkRange v-model="numberOfPageCache" :min="1" :max="10" :step="1" easing>
+ <template #label>{{ i18n.ts.numberOfPageCache }}</template>
+ <template #caption>{{ i18n.ts.numberOfPageCacheDescription }}</template>
+ </MkRange>
+ </div>
+ </FormSection>
- <MkRange v-model="numberOfPageCache" :min="1" :max="10" :step="1" easing>
- <template #label>{{ i18n.ts.numberOfPageCache }}</template>
- <template #caption>{{ i18n.ts.numberOfPageCacheDescription }}</template>
- </MkRange>
+ <FormSection>
+ <MkSwitch v-model="aiChanMode">{{ i18n.ts.aiChanMode }}</MkSwitch>
+ </FormSection>
<FormLink to="/settings/deck">{{ i18n.ts.deck }}</FormLink>
@@ -160,6 +202,7 @@ const disableDrawer = computed(defaultStore.makeGetterSetter('disableDrawer'));
const disableShowingAnimatedImages = computed(defaultStore.makeGetterSetter('disableShowingAnimatedImages'));
const forceShowAds = computed(defaultStore.makeGetterSetter('forceShowAds'));
const loadRawImages = computed(defaultStore.makeGetterSetter('loadRawImages'));
+const enableDataSaverMode = computed(defaultStore.makeGetterSetter('enableDataSaverMode'));
const imageNewTab = computed(defaultStore.makeGetterSetter('imageNewTab'));
const nsfw = computed(defaultStore.makeGetterSetter('nsfw'));
const showFixedPostForm = computed(defaultStore.makeGetterSetter('showFixedPostForm'));
@@ -170,6 +213,9 @@ const enableInfiniteScroll = computed(defaultStore.makeGetterSetter('enableInfin
const useReactionPickerForContextMenu = computed(defaultStore.makeGetterSetter('useReactionPickerForContextMenu'));
const squareAvatars = computed(defaultStore.makeGetterSetter('squareAvatars'));
const aiChanMode = computed(defaultStore.makeGetterSetter('aiChanMode'));
+const mediaListWithOneImageAppearance = computed(defaultStore.makeGetterSetter('mediaListWithOneImageAppearance'));
+const notificationPosition = computed(defaultStore.makeGetterSetter('notificationPosition'));
+const notificationStackAxis = computed(defaultStore.makeGetterSetter('notificationStackAxis'));
watch(lang, () => {
miLocalStorage.setItem('lang', lang.value as string);
diff --git a/packages/frontend/src/pages/settings/import-export.vue b/packages/frontend/src/pages/settings/import-export.vue
index a8274f5601..89b4104020 100644
--- a/packages/frontend/src/pages/settings/import-export.vue
+++ b/packages/frontend/src/pages/settings/import-export.vue
@@ -32,7 +32,7 @@
<MkButton primary :class="$style.button" inline @click="exportFollowing()"><i class="ti ti-download"></i> {{ i18n.ts.export }}</MkButton>
</div>
</MkFolder>
- <MkFolder>
+ <MkFolder v-if="$i && !$i.movedTo">
<template #label>{{ i18n.ts.import }}</template>
<template #icon><i class="ti ti-upload"></i></template>
<MkButton primary :class="$style.button" inline @click="importFollowing($event)"><i class="ti ti-upload"></i> {{ i18n.ts.import }}</MkButton>
@@ -47,7 +47,7 @@
<template #icon><i class="ti ti-download"></i></template>
<MkButton primary :class="$style.button" inline @click="exportUserLists()"><i class="ti ti-download"></i> {{ i18n.ts.export }}</MkButton>
</MkFolder>
- <MkFolder>
+ <MkFolder v-if="$i && !$i.movedTo">
<template #label>{{ i18n.ts.import }}</template>
<template #icon><i class="ti ti-upload"></i></template>
<MkButton primary :class="$style.button" inline @click="importUserLists($event)"><i class="ti ti-upload"></i> {{ i18n.ts.import }}</MkButton>
@@ -62,7 +62,7 @@
<template #icon><i class="ti ti-download"></i></template>
<MkButton primary :class="$style.button" inline @click="exportMuting()"><i class="ti ti-download"></i> {{ i18n.ts.export }}</MkButton>
</MkFolder>
- <MkFolder>
+ <MkFolder v-if="$i && !$i.movedTo">
<template #label>{{ i18n.ts.import }}</template>
<template #icon><i class="ti ti-upload"></i></template>
<MkButton primary :class="$style.button" inline @click="importMuting($event)"><i class="ti ti-upload"></i> {{ i18n.ts.import }}</MkButton>
@@ -77,13 +77,28 @@
<template #icon><i class="ti ti-download"></i></template>
<MkButton primary :class="$style.button" inline @click="exportBlocking()"><i class="ti ti-download"></i> {{ i18n.ts.export }}</MkButton>
</MkFolder>
- <MkFolder>
+ <MkFolder v-if="$i && !$i.movedTo">
<template #label>{{ i18n.ts.import }}</template>
<template #icon><i class="ti ti-upload"></i></template>
<MkButton primary :class="$style.button" inline @click="importBlocking($event)"><i class="ti ti-upload"></i> {{ i18n.ts.import }}</MkButton>
</MkFolder>
</div>
</FormSection>
+ <FormSection>
+ <template #label><i class="ti ti-antenna"></i> {{ i18n.ts.antennas }}</template>
+ <div class="_gaps_s">
+ <MkFolder>
+ <template #label>{{ i18n.ts.export }}</template>
+ <template #icon><i class="ti ti-download"></i></template>
+ <MkButton primary :class="$style.button" inline @click="exportAntennas()"><i class="ti ti-download"></i> {{ i18n.ts.export }}</MkButton>
+ </MkFolder>
+ <MkFolder v-if="$i && !$i.movedTo">
+ <template #label>{{ i18n.ts.import }}</template>
+ <template #icon><i class="ti ti-upload"></i></template>
+ <MkButton primary :class="$style.button" inline @click="importAntennas($event)"><i class="ti ti-upload"></i> {{ i18n.ts.import }}</MkButton>
+ </MkFolder>
+ </div>
+ </FormSection>
</div>
</template>
@@ -97,6 +112,7 @@ import * as os from '@/os';
import { selectFile } from '@/scripts/select-file';
import { i18n } from '@/i18n';
import { definePageMetadata } from '@/scripts/page-metadata';
+import { $i } from '@/account';
const excludeMutingUsers = ref(false);
const excludeInactiveUsers = ref(false);
@@ -150,6 +166,10 @@ const exportMuting = () => {
os.api('i/export-mute', {}).then(onExportSuccess).catch(onError);
};
+const exportAntennas = () => {
+ os.api('i/export-antennas', {}).then(onExportSuccess).catch(onError);
+};
+
const importFollowing = async (ev) => {
const file = await selectFile(ev.currentTarget ?? ev.target);
os.api('i/import-following', { fileId: file.id }).then(onImportSuccess).catch(onError);
@@ -170,6 +190,11 @@ const importBlocking = async (ev) => {
os.api('i/import-blocking', { fileId: file.id }).then(onImportSuccess).catch(onError);
};
+const importAntennas = async (ev) => {
+ const file = await selectFile(ev.currentTarget ?? ev.target);
+ os.api('i/import-antennas', { fileId: file.id }).then(onImportSuccess).catch(onError);
+};
+
const headerActions = $computed(() => []);
const headerTabs = $computed(() => []);
diff --git a/packages/frontend/src/pages/settings/index.vue b/packages/frontend/src/pages/settings/index.vue
index 17af7417fd..34a962ef4c 100644
--- a/packages/frontend/src/pages/settings/index.vue
+++ b/packages/frontend/src/pages/settings/index.vue
@@ -164,12 +164,12 @@ const menuDef = computed(() => [{
text: i18n.ts.importAndExport,
to: '/settings/import-export',
active: currentPage?.route.name === 'import-export',
- }, /*{
+ }, {
icon: 'ti ti-plane',
- text: i18n.ts.accountMigration,
+ text: `${i18n.ts.accountMigration} (${i18n.ts.experimental})`,
to: '/settings/migration',
active: currentPage?.route.name === 'migration',
- },*/ {
+ }, {
icon: 'ti ti-dots',
text: i18n.ts.other,
to: '/settings/other',
diff --git a/packages/frontend/src/pages/settings/migration.vue b/packages/frontend/src/pages/settings/migration.vue
index 2ef8af7481..541992875e 100644
--- a/packages/frontend/src/pages/settings/migration.vue
+++ b/packages/frontend/src/pages/settings/migration.vue
@@ -1,63 +1,121 @@
<template>
<div class="_gaps_m">
- <FormSection first>
+ <FormInfo warn>
+ {{ i18n.ts.thisIsExperimentalFeature }}
+ </FormInfo>
+ <MkFolder :default-open="true">
+ <template #icon><i class="ti ti-plane-arrival"></i></template>
+ <template #label>{{ i18n.ts._accountMigration.moveFrom }}</template>
+ <template #caption>{{ i18n.ts._accountMigration.moveFromSub }}</template>
+
+ <div class="_gaps_m">
+ <FormInfo>
+ {{ i18n.ts._accountMigration.moveFromDescription }}
+ </FormInfo>
+ <div>
+ <MkButton :disabled="accountAliases.length >= 10" inline style="margin-right: 8px;" @click="add"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton>
+ <MkButton inline primary @click="save"><i class="ti ti-check"></i> {{ i18n.ts.save }}</MkButton>
+ </div>
+ <div class="_gaps">
+ <MkInput v-for="(_, i) in accountAliases" v-model="accountAliases[i]">
+ <template #prefix><i class="ti ti-plane-arrival"></i></template>
+ <template #label>{{ i18n.t('_accountMigration.moveFromLabel', { n: i + 1 }) }}</template>
+ </MkInput>
+ </div>
+ </div>
+ </MkFolder>
+
+ <MkFolder :default-open="!!$i?.movedTo">
+ <template #icon><i class="ti ti-plane-departure"></i></template>
<template #label>{{ i18n.ts._accountMigration.moveTo }}</template>
- <MkInput v-model="moveToAccount" manual-save>
- <template #prefix><i class="ti ti-plane-departure"></i></template>
- <template #label>{{ i18n.ts._accountMigration.moveToLabel }}</template>
- </MkInput>
- </FormSection>
- <FormInfo warn>{{ i18n.ts._accountMigration.moveAccountDescription }}</FormInfo>
- <FormSection>
- <template #label>{{ i18n.ts._accountMigration.moveFrom }}</template>
- <MkInput v-model="accountAlias" manual-save>
- <template #prefix><i class="ti ti-plane-arrival"></i></template>
- <template #label>{{ i18n.ts._accountMigration.moveFromLabel }}</template>
- </MkInput>
- </FormSection>
- <FormInfo warn>{{ i18n.ts._accountMigration.moveFromDescription }}</FormInfo>
+ <div class="_gaps_m">
+ <FormInfo>{{ i18n.ts._accountMigration.moveAccountDescription }}</FormInfo>
+
+ <template v-if="$i && !$i.movedTo">
+ <FormInfo>{{ i18n.ts._accountMigration.moveAccountHowTo }}</FormInfo>
+ <FormInfo warn>{{ i18n.ts._accountMigration.moveCannotBeUndone }}</FormInfo>
+
+ <MkInput v-model="moveToAccount">
+ <template #prefix><i class="ti ti-plane-departure"></i></template>
+ <template #label>{{ i18n.ts._accountMigration.moveToLabel }}</template>
+ </MkInput>
+ <MkButton inline danger :disabled="!moveToAccount" @click="move">
+ <i class="ti ti-check"></i> {{ i18n.ts._accountMigration.startMigration }}
+ </MkButton>
+ </template>
+ <template v-else-if="$i">
+ <FormInfo>{{ i18n.ts._accountMigration.postMigrationNote }}</FormInfo>
+ <FormInfo warn>{{ i18n.ts._accountMigration.movedAndCannotBeUndone }}</FormInfo>
+ <div>{{ i18n.ts._accountMigration.movedTo }}</div>
+ <MkUserInfo v-if="movedTo" :user="movedTo" class="_panel _shadow" />
+ </template>
+ </div>
+ </MkFolder>
</div>
</template>
<script lang="ts" setup>
-import { ref, watch } from 'vue';
-import FormSection from '@/components/form/section.vue';
+import { ref } from 'vue';
import FormInfo from '@/components/MkInfo.vue';
import MkInput from '@/components/MkInput.vue';
+import MkButton from '@/components/MkButton.vue';
+import MkFolder from '@/components/MkFolder.vue';
+import MkUserInfo from '@/components/MkUserInfo.vue';
import * as os from '@/os';
import { i18n } from '@/i18n';
import { definePageMetadata } from '@/scripts/page-metadata';
+import { $i } from '@/account';
+import { toString } from 'misskey-js/built/acct';
+import { UserDetailed } from 'misskey-js/built/entities';
+import { unisonReload } from '@/scripts/unison-reload';
const moveToAccount = ref('');
-const accountAlias = ref('');
+const movedTo = ref<UserDetailed>();
+const accountAliases = ref(['']);
+
+async function init() {
+ if ($i?.movedTo) {
+ movedTo.value = await os.api('users/show', { userId: $i.movedTo });
+ } else {
+ moveToAccount.value = '';
+ }
+
+ if ($i?.alsoKnownAs && $i.alsoKnownAs.length > 0) {
+ const alsoKnownAs = await os.api('users/show', { userIds: $i.alsoKnownAs });
+ accountAliases.value = (alsoKnownAs && alsoKnownAs.length > 0) ? alsoKnownAs.map(user => `@${toString(user)}`) : [''];
+ } else {
+ accountAliases.value = [''];
+ }
+}
async function move(): Promise<void> {
const account = moveToAccount.value;
const confirm = await os.confirm({
type: 'warning',
- text: i18n.t('migrationConfirm', { account: account.toString() }),
+ text: i18n.t('_accountMigration.migrationConfirm', { account }),
});
if (confirm.canceled) return;
- os.apiWithDialog('i/move', {
+ await os.apiWithDialog('i/move', {
moveToAccount: account,
});
+ unisonReload();
+}
+
+function add(): void {
+ accountAliases.value.push('');
}
async function save(): Promise<void> {
- const account = accountAlias.value;
- os.apiWithDialog('i/known-as', {
- alsoKnownAs: account,
+ const alsoKnownAs = accountAliases.value.map(alias => alias.trim()).filter(alias => alias !== '');
+ const i = await os.apiWithDialog('i/update', {
+ alsoKnownAs,
});
+ $i.alsoKnownAs = i.alsoKnownAs;
+ init();
}
-watch(accountAlias, async () => {
- await save();
-});
-
-watch(moveToAccount, async () => {
- await move();
-});
+init();
definePageMetadata({
title: i18n.ts.accountMigration,
diff --git a/packages/frontend/src/pages/settings/other.vue b/packages/frontend/src/pages/settings/other.vue
index 892ea61e75..776305d723 100644
--- a/packages/frontend/src/pages/settings/other.vue
+++ b/packages/frontend/src/pages/settings/other.vue
@@ -1,32 +1,85 @@
<template>
<div class="_gaps_m">
+ <!--
<MkSwitch v-model="$i.injectFeaturedNote" @update:model-value="onChangeInjectFeaturedNote">
- {{ i18n.ts.showFeaturedNotesInTimeline }}
+ <template #label>{{ i18n.ts.showFeaturedNotesInTimeline }}</template>
</MkSwitch>
+ -->
<!--
<MkSwitch v-model="reportError">{{ i18n.ts.sendErrorReports }}<template #caption>{{ i18n.ts.sendErrorReportsDescription }}</template></MkSwitch>
-->
- <FormLink to="/settings/account-info">{{ i18n.ts.accountInfo }}</FormLink>
+ <FormSection first>
+ <div class="_gaps_s">
+ <MkFolder>
+ <template #icon><i class="ti ti-info-circle"></i></template>
+ <template #label>{{ i18n.ts.accountInfo }}</template>
+
+ <div class="_gaps_m">
+ <MkKeyValue>
+ <template #key>ID</template>
+ <template #value><span class="_monospace">{{ $i.id }}</span></template>
+ </MkKeyValue>
+
+ <MkKeyValue>
+ <template #key>{{ i18n.ts.registeredDate }}</template>
+ <template #value><MkTime :time="$i.createdAt" mode="detail"/></template>
+ </MkKeyValue>
+
+ <FormLink to="/settings/account-stats"><template #icon><i class="ti ti-info-circle"></i></template>{{ i18n.ts.statistics }}</FormLink>
+ </div>
+ </MkFolder>
+
+ <MkFolder>
+ <template #icon><i class="ti ti-alert-triangle"></i></template>
+ <template #label>{{ i18n.ts.closeAccount }}</template>
- <FormLink to="/registry"><template #icon><i class="ti ti-adjustments"></i></template>{{ i18n.ts.registry }}</FormLink>
+ <div class="_gaps_m">
+ <FormInfo warn>{{ i18n.ts._accountDelete.mayTakeTime }}</FormInfo>
+ <FormInfo>{{ i18n.ts._accountDelete.sendEmail }}</FormInfo>
+ <MkButton v-if="!$i.isDeleted" danger @click="deleteAccount">{{ i18n.ts._accountDelete.requestAccountDelete }}</MkButton>
+ <MkButton v-else disabled>{{ i18n.ts._accountDelete.inProgress }}</MkButton>
+ </div>
+ </MkFolder>
- <FormLink to="/settings/delete-account"><template #icon><i class="ti ti-alert-triangle"></i></template>{{ i18n.ts.closeAccount }}</FormLink>
+ <MkFolder>
+ <template #icon><i class="ti ti-flask"></i></template>
+ <template #label>{{ i18n.ts.experimentalFeatures }}</template>
+
+ <div class="_gaps_m">
+ <MkSwitch v-model="enableCondensedLineForAcct">
+ <template #label>Enable condensed line for acct</template>
+ </MkSwitch>
+ </div>
+ </MkFolder>
+ </div>
+ </FormSection>
+
+ <FormSection>
+ <FormLink to="/registry"><template #icon><i class="ti ti-adjustments"></i></template>{{ i18n.ts.registry }}</FormLink>
+ </FormSection>
</div>
</template>
<script lang="ts" setup>
-import { computed } from 'vue';
+import { computed, watch } from 'vue';
import MkSwitch from '@/components/MkSwitch.vue';
import FormLink from '@/components/form/link.vue';
+import MkFolder from '@/components/MkFolder.vue';
+import FormInfo from '@/components/MkInfo.vue';
+import MkKeyValue from '@/components/MkKeyValue.vue';
+import MkButton from '@/components/MkButton.vue';
import * as os from '@/os';
import { defaultStore } from '@/store';
-import { $i } from '@/account';
+import { signout, $i } from '@/account';
import { i18n } from '@/i18n';
import { definePageMetadata } from '@/scripts/page-metadata';
+import { unisonReload } from '@/scripts/unison-reload';
+import FormSection from '@/components/form/section.vue';
const reportError = computed(defaultStore.makeGetterSetter('reportError'));
+const enableCondensedLineForAcct = computed(defaultStore.makeGetterSetter('enableCondensedLineForAcct'));
function onChangeInjectFeaturedNote(v) {
os.api('i/update', {
@@ -36,6 +89,48 @@ function onChangeInjectFeaturedNote(v) {
});
}
+async function deleteAccount() {
+ {
+ const { canceled } = await os.confirm({
+ type: 'warning',
+ text: i18n.ts.deleteAccountConfirm,
+ });
+ if (canceled) return;
+ }
+
+ const { canceled, result: password } = await os.inputText({
+ title: i18n.ts.password,
+ type: 'password',
+ });
+ if (canceled) return;
+
+ await os.apiWithDialog('i/delete-account', {
+ password: password,
+ });
+
+ await os.alert({
+ title: i18n.ts._accountDelete.started,
+ });
+
+ await signout();
+}
+
+async function reloadAsk() {
+ const { canceled } = await os.confirm({
+ type: 'info',
+ text: i18n.ts.reloadToApplySetting,
+ });
+ if (canceled) return;
+
+ unisonReload();
+}
+
+watch([
+ enableCondensedLineForAcct,
+], async () => {
+ await reloadAsk();
+});
+
const headerActions = $computed(() => []);
const headerTabs = $computed(() => []);
diff --git a/packages/frontend/src/pages/settings/preferences-backups.vue b/packages/frontend/src/pages/settings/preferences-backups.vue
index 092b9a9cc8..6613ce4c1d 100644
--- a/packages/frontend/src/pages/settings/preferences-backups.vue
+++ b/packages/frontend/src/pages/settings/preferences-backups.vue
@@ -88,6 +88,7 @@ const defaultStoreSaveKeys: (keyof typeof defaultStore['state'])[] = [
'squareAvatars',
'numberOfPageCache',
'aiChanMode',
+ 'mediaListWithOneImageAppearance',
];
const coldDeviceStorageSaveKeys: (keyof typeof ColdDeviceStorage.default)[] = [
'lightTheme',
diff --git a/packages/frontend/src/pages/settings/profile.vue b/packages/frontend/src/pages/settings/profile.vue
index a5f6c11f89..6ffd682610 100644
--- a/packages/frontend/src/pages/settings/profile.vue
+++ b/packages/frontend/src/pages/settings/profile.vue
@@ -37,19 +37,40 @@
<template #icon><i class="ti ti-list"></i></template>
<template #label>{{ i18n.ts._profile.metadataEdit }}</template>
- <div class="_gaps_m">
- <FormSplit v-for="(record, i) in fields" :min-width="250">
- <MkInput v-model="record.name" small>
- <template #label>{{ i18n.ts._profile.metadataLabel }} #{{ i + 1 }}</template>
- </MkInput>
- <MkInput v-model="record.value" small>
- <template #label>{{ i18n.ts._profile.metadataContent }} #{{ i + 1 }}</template>
- </MkInput>
- </FormSplit>
- <div>
+ <div :class="$style.metadataRoot">
+ <div :class="$style.metadataMargin">
<MkButton :disabled="fields.length >= 16" inline style="margin-right: 8px;" @click="addField"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton>
+ <MkButton v-if="!fieldEditMode" :disabled="fields.length <= 1" inline danger style="margin-right: 8px;" @click="fieldEditMode = !fieldEditMode"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton>
+ <MkButton v-else inline style="margin-right: 8px;" @click="fieldEditMode = !fieldEditMode"><i class="ti ti-arrows-sort"></i> {{ i18n.ts.rearrange }}</MkButton>
<MkButton inline primary @click="saveFields"><i class="ti ti-check"></i> {{ i18n.ts.save }}</MkButton>
</div>
+
+ <Sortable
+ v-model="fields"
+ class="_gaps_s"
+ item-key="id"
+ :animation="150"
+ :handle="'.' + $style.dragItemHandle"
+ @start="e => e.item.classList.add('active')"
+ @end="e => e.item.classList.remove('active')"
+ >
+ <template #item="{element, index}">
+ <div :class="$style.fieldDragItem">
+ <button v-if="!fieldEditMode" class="_button" :class="$style.dragItemHandle" tabindex="-1"><i class="ti ti-menu"></i></button>
+ <button v-if="fieldEditMode" :disabled="fields.length <= 1" class="_button" :class="$style.dragItemRemove" @click="deleteField(index)"><i class="ti ti-x"></i></button>
+ <div :class="$style.dragItemForm">
+ <FormSplit :min-width="200">
+ <MkInput v-model="element.name" small>
+ <template #label>{{ i18n.ts._profile.metadataLabel }}</template>
+ </MkInput>
+ <MkInput v-model="element.value" small>
+ <template #label>{{ i18n.ts._profile.metadataContent }}</template>
+ </MkInput>
+ </FormSplit>
+ </div>
+ </div>
+ </template>
+ </Sortable>
</div>
</MkFolder>
<template #caption>{{ i18n.ts._profile.metadataDescription }}</template>
@@ -76,7 +97,7 @@
</template>
<script lang="ts" setup>
-import { computed, reactive, watch } from 'vue';
+import { computed, reactive, ref, watch, defineAsyncComponent, onMounted, onUnmounted } from 'vue';
import MkButton from '@/components/MkButton.vue';
import MkInput from '@/components/MkInput.vue';
import MkTextarea from '@/components/MkTextarea.vue';
@@ -94,6 +115,8 @@ import { definePageMetadata } from '@/scripts/page-metadata';
import { claimAchievement } from '@/scripts/achievements';
import { defaultStore } from '@/store';
+const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default));
+
const reactionAcceptance = computed(defaultStore.makeGetterSetter('reactionAcceptance'));
const profile = reactive({
@@ -113,22 +136,28 @@ watch(() => profile, () => {
deep: true,
});
-const fields = reactive($i.fields.map(field => ({ name: field.name, value: field.value })));
+const fields = ref($i?.fields.map(field => ({ id: Math.random().toString(), name: field.name, value: field.value })) ?? []);
+const fieldEditMode = ref(false);
function addField() {
- fields.push({
+ fields.value.push({
+ id: Math.random().toString(),
name: '',
value: '',
});
}
-while (fields.length < 4) {
+while (fields.value.length < 4) {
addField();
}
+function deleteField(index: number) {
+ fields.value.splice(index, 1);
+}
+
function saveFields() {
os.apiWithDialog('i/update', {
- fields: fields.filter(field => field.name !== '' && field.value !== ''),
+ fields: fields.value.filter(field => field.name !== '' && field.value !== '').map(field => ({ name: field.name, value: field.value })),
});
}
@@ -248,3 +277,60 @@ definePageMetadata({
}
}
</style>
+<style lang="scss" module>
+.metadataRoot {
+ container-type: inline-size;
+}
+
+.metadataMargin {
+ margin-bottom: 1.5em;
+}
+
+.fieldDragItem {
+ display: flex;
+ padding-bottom: .75em;
+ align-items: flex-end;
+ border-bottom: solid 0.5px var(--divider);
+
+ &:last-child {
+ border-bottom: 0;
+ }
+
+ /* (drag button) 32px + (drag button margin) 8px + (input width) 200px * 2 + (input gap) 12px = 452px */
+ @container (max-width: 452px) {
+ align-items: center;
+ }
+}
+
+.dragItemHandle {
+ cursor: grab;
+ width: 32px;
+ height: 32px;
+ margin: 0 8px 0 0;
+ opacity: 0.5;
+ flex-shrink: 0;
+
+ &:active {
+ cursor: grabbing;
+ }
+}
+
+.dragItemRemove {
+ @extend .dragItemHandle;
+
+ color: #ff2a2a;
+ opacity: 1;
+ cursor: pointer;
+
+ &:hover, &:focus {
+ opacity: .7;
+ }
+ &:active {
+ cursor: pointer;
+ }
+}
+
+.dragItemForm {
+ flex-grow: 1;
+}
+</style>
diff --git a/packages/frontend/src/pages/timeline.tutorial.vue b/packages/frontend/src/pages/timeline.tutorial.vue
index 0d0c932a5c..32228d28f4 100644
--- a/packages/frontend/src/pages/timeline.tutorial.vue
+++ b/packages/frontend/src/pages/timeline.tutorial.vue
@@ -1,7 +1,7 @@
<template>
<div :class="$style.container">
<div :class="$style.title">
- <div :class="$style.titleText"><i class="ti ti-info-circle"></i> {{ i18n.ts._tutorial.title }}</div>
+ <div :class="$style.titleText"><i class="ti ti-info-circle"></i> {{ i18n.ts._timelineTutorial.title }}</div>
<div :class="$style.step">
<button class="_button" :class="$style.stepArrow" :disabled="tutorial === 0" @click="tutorial--">
<i class="ti ti-chevron-left"></i>
@@ -12,66 +12,30 @@
</button>
</div>
</div>
+
<div v-if="tutorial === 0" :class="$style.body">
- <div>{{ i18n.ts._tutorial.step1_1 }}</div>
- <div>{{ i18n.ts._tutorial.step1_2 }}</div>
- <div>{{ i18n.ts._tutorial.step1_3 }}</div>
+ <div>{{ i18n.t('_timelineTutorial.step1_1', { name: instance.name ?? host }) }}</div>
+ <div>{{ i18n.t('_timelineTutorial.step1_2', { name: instance.name ?? host }) }}</div>
</div>
<div v-else-if="tutorial === 1" :class="$style.body">
- <div>{{ i18n.ts._tutorial.step2_1 }}</div>
- <div>{{ i18n.ts._tutorial.step2_2 }}</div>
- <MkA class="_link" to="/settings/profile">{{ i18n.ts.editProfile }}</MkA>
+ <div>{{ i18n.ts._timelineTutorial.step2_1 }}</div>
+ <div>{{ i18n.t('_timelineTutorial.step2_2', { name: instance.name ?? host }) }}</div>
</div>
<div v-else-if="tutorial === 2" :class="$style.body">
- <div>{{ i18n.ts._tutorial.step3_1 }}</div>
- <div>{{ i18n.ts._tutorial.step3_2 }}</div>
- <div>{{ i18n.ts._tutorial.step3_3 }}</div>
- <small :class="$style.small">{{ i18n.ts._tutorial.step3_4 }}</small>
+ <div>{{ i18n.ts._timelineTutorial.step3_1 }}</div>
+ <div>{{ i18n.ts._timelineTutorial.step3_2 }}</div>
</div>
<div v-else-if="tutorial === 3" :class="$style.body">
- <div>{{ i18n.ts._tutorial.step4_1 }}</div>
- <div>{{ i18n.ts._tutorial.step4_2 }}</div>
- </div>
- <div v-else-if="tutorial === 4" :class="$style.body">
- <div>{{ i18n.ts._tutorial.step5_1 }}</div>
- <I18n :src="i18n.ts._tutorial.step5_2" tag="div">
- <template #featured>
- <MkA class="_link" to="/explore">{{ i18n.ts.featured }}</MkA>
- </template>
- <template #explore>
- <MkA class="_link" to="/explore#users">{{ i18n.ts.explore }}</MkA>
- </template>
- </I18n>
- <div>{{ i18n.ts._tutorial.step5_3 }}</div>
- <small :class="$style.small">{{ i18n.ts._tutorial.step5_4 }}</small>
- </div>
- <div v-else-if="tutorial === 5" :class="$style.body">
- <div>{{ i18n.ts._tutorial.step6_1 }}</div>
- <div>{{ i18n.ts._tutorial.step6_2 }}</div>
- <div>{{ i18n.ts._tutorial.step6_3 }}</div>
- </div>
- <div v-else-if="tutorial === 6" :class="$style.body">
- <div>{{ i18n.ts._tutorial.step7_1 }}</div>
- <I18n :src="i18n.ts._tutorial.step7_2" tag="div">
- <template #help>
- <a href="https://misskey-hub.net/help.html" target="_blank" class="_link">{{ i18n.ts.help }}</a>
- </template>
- </I18n>
- <div>{{ i18n.ts._tutorial.step7_3 }}</div>
- </div>
- <div v-else-if="tutorial === 7" :class="$style.body">
- <div>{{ i18n.ts._tutorial.step8_1 }}</div>
- <div>{{ i18n.ts._tutorial.step8_2 }}</div>
- <small :class="$style.small">{{ i18n.ts._tutorial.step8_3 }}</small>
+ <div>{{ i18n.ts._timelineTutorial.step4_1 }}</div>
+ <div>{{ i18n.ts._timelineTutorial.step4_2 }}</div>
</div>
<div :class="$style.footer">
<template v-if="tutorial === tutorialsNumber - 1">
- <MkPushNotificationAllowButton :class="$style.footerItem" primary show-only-to-register @click="tutorial = -1"/>
- <MkButton :class="$style.footerItem" :primary="false" @click="tutorial = -1">{{ i18n.ts.noThankYou }}</MkButton>
+ <MkButton :class="$style.footerItem" primary rounded gradate @click="tutorial = -1">{{ i18n.ts.done }} <i class="ti ti-check"></i></MkButton>
</template>
<template v-else>
- <MkButton :class="$style.footerItem" primary @click="tutorial++"><i class="ti ti-check"></i> {{ i18n.ts.next }}</MkButton>
+ <MkButton :class="$style.footerItem" primary rounded gradate @click="tutorial++">{{ i18n.ts.next }} <i class="ti ti-arrow-right"></i></MkButton>
</template>
</div>
</div>
@@ -80,15 +44,16 @@
<script lang="ts" setup>
import { computed } from 'vue';
import MkButton from '@/components/MkButton.vue';
-import MkPushNotificationAllowButton from '@/components/MkPushNotificationAllowButton.vue';
import { defaultStore } from '@/store';
import { i18n } from '@/i18n';
+import { instance } from '@/instance';
+import { host } from '@/config';
-const tutorialsNumber = 8;
+const tutorialsNumber = 4;
const tutorial = computed({
- get() { return defaultStore.reactiveState.tutorial.value || 0; },
- set(value) { defaultStore.set('tutorial', value); },
+ get() { return defaultStore.reactiveState.timelineTutorial.value || 0; },
+ set(value) { defaultStore.set('timelineTutorial', value); },
});
</script>
diff --git a/packages/frontend/src/pages/timeline.vue b/packages/frontend/src/pages/timeline.vue
index 9f13f7a1dd..1bf4cdc99a 100644
--- a/packages/frontend/src/pages/timeline.vue
+++ b/packages/frontend/src/pages/timeline.vue
@@ -3,7 +3,7 @@
<template #header><MkPageHeader v-model:tab="src" :actions="headerActions" :tabs="$i ? headerTabs : headerTabsWhenNotLogin" :display-my-avatar="true"/></template>
<MkSpacer :content-max="800">
<div ref="rootEl" v-hotkey.global="keymap">
- <XTutorial v-if="$i && defaultStore.reactiveState.tutorial.value != -1" class="_panel" style="margin-bottom: var(--margin);"/>
+ <XTutorial v-if="$i && defaultStore.reactiveState.timelineTutorial.value != -1" class="_panel" style="margin-bottom: var(--margin);"/>
<MkPostForm v-if="defaultStore.reactiveState.showFixedPostForm.value" :class="$style.postForm" class="post-form _panel" fixed style="margin-bottom: var(--margin);"/>
<div v-if="queue > 0" :class="$style.new"><button class="_buttonPrimary" @click="top()">{{ i18n.ts.newNoteRecived }}</button></div>
diff --git a/packages/frontend/src/pages/user/home.vue b/packages/frontend/src/pages/user/home.vue
index 8c3478d8f2..5bc1578268 100644
--- a/packages/frontend/src/pages/user/home.vue
+++ b/packages/frontend/src/pages/user/home.vue
@@ -7,7 +7,7 @@
<!-- <div class="punished" v-if="user.isSilenced"><i class="ti ti-alert-triangle" style="margin-right: 8px;"></i> {{ i18n.ts.userSilenced }}</div> -->
<div class="profile _gaps">
- <MkAccountMoved v-if="user.movedToUri" :host="user.movedToUri.host" :acct="user.movedToUri.username"/>
+ <MkAccountMoved v-if="user.movedTo" :moved-to="user.movedTo"/>
<MkRemoteCaution v-if="user.host != null" :href="user.url ?? user.uri!" class="warn"/>
<div :key="user.id" class="main _panel">
@@ -21,6 +21,9 @@
<span v-if="user.isAdmin" :title="i18n.ts.isAdmin" style="color: var(--badge);"><i class="ti ti-shield"></i></span>
<span v-if="user.isLocked" :title="i18n.ts.isLocked"><i class="ti ti-lock"></i></span>
<span v-if="user.isBot" :title="i18n.ts.isBot"><i class="ti ti-robot"></i></span>
+ <button v-if="!isEditingMemo && !memoDraft" class="_button add-note-button" @click="showMemoTextarea">
+ <i class="ti ti-edit"/> {{ i18n.ts.addMemo }}
+ </button>
</div>
</div>
<span v-if="$i && $i.id != user.id && user.isFollowed" class="followed">{{ i18n.ts.followsYou }}</span>
@@ -45,6 +48,22 @@
{{ role.name }}
</span>
</div>
+ <div v-if="iAmModerator" class="moderationNote">
+ <MkTextarea v-model="moderationNote" manual-save>
+ <template #label>Moderation note</template>
+ </MkTextarea>
+ </div>
+ <div v-if="isEditingMemo || memoDraft" class="memo" :class="{'no-memo': !memoDraft}">
+ <div class="heading" v-text="i18n.ts.memo"/>
+ <textarea
+ ref="memoTextareaEl"
+ v-model="memoDraft"
+ rows="1"
+ @focus="isEditingMemo = true"
+ @blur="updateMemo"
+ @input="adjustMemoTextarea"
+ />
+ </div>
<div class="description">
<MkOmit>
<Mfm v-if="user.description" :text="user.description" :is-note="false" :author="user" :i="$i"/>
@@ -113,13 +132,14 @@
</template>
<script lang="ts" setup>
-import { defineAsyncComponent, computed, onMounted, onUnmounted } from 'vue';
+import { defineAsyncComponent, computed, onMounted, onUnmounted, nextTick, watch } from 'vue';
import calcAge from 's-age';
import * as misskey from 'misskey-js';
import MkNote from '@/components/MkNote.vue';
import MkFollowButton from '@/components/MkFollowButton.vue';
import MkAccountMoved from '@/components/MkAccountMoved.vue';
import MkRemoteCaution from '@/components/MkRemoteCaution.vue';
+import MkTextarea from '@/components/MkTextarea.vue';
import MkOmit from '@/components/MkOmit.vue';
import MkInfo from '@/components/MkInfo.vue';
import { getScrollPosition } from '@/scripts/scroll';
@@ -129,10 +149,11 @@ import { userPage } from '@/filters/user';
import * as os from '@/os';
import { useRouter } from '@/router';
import { i18n } from '@/i18n';
-import { $i } from '@/account';
+import { $i, iAmModerator } from '@/account';
import { dateString } from '@/filters/date';
import { confetti } from '@/scripts/confetti';
import MkNotes from '@/components/MkNotes.vue';
+import { api } from '@/os';
const XPhotos = defineAsyncComponent(() => import('./index.photos.vue'));
const XActivity = defineAsyncComponent(() => import('./index.activity.vue'));
@@ -151,6 +172,14 @@ let parallaxAnimationId = $ref<null | number>(null);
let narrow = $ref<null | boolean>(null);
let rootEl = $ref<null | HTMLElement>(null);
let bannerEl = $ref<null | HTMLElement>(null);
+let memoTextareaEl = $ref<null | HTMLElement>(null);
+let memoDraft = $ref(props.user.memo);
+let isEditingMemo = $ref(false);
+let moderationNote = $ref(props.user.moderationNote);
+
+watch($$(moderationNote), async () => {
+ await os.api('admin/update-user-note', { userId: props.user.id, text: moderationNote });
+});
const pagination = {
endpoint: 'users/notes' as const,
@@ -193,6 +222,31 @@ function parallax() {
banner.style.backgroundPosition = `center calc(50% - ${pos}px)`;
}
+function showMemoTextarea() {
+ isEditingMemo = true;
+ nextTick(() => {
+ memoTextareaEl?.focus();
+ });
+}
+
+function adjustMemoTextarea() {
+ if (!memoTextareaEl) return;
+ memoTextareaEl.style.height = '0px';
+ memoTextareaEl.style.height = `${memoTextareaEl.scrollHeight}px`;
+}
+
+async function updateMemo() {
+ await api('users/update-memo', {
+ memo: memoDraft,
+ userId: props.user.id,
+ });
+ isEditingMemo = false;
+}
+
+watch([props.user], () => {
+ memoDraft = props.user.memo;
+});
+
onMounted(() => {
window.requestAnimationFrame(parallaxLoop);
narrow = rootEl!.clientWidth < 1000;
@@ -208,6 +262,9 @@ onMounted(() => {
});
}
}
+ nextTick(() => {
+ adjustMemoTextarea();
+ });
});
onUnmounted(() => {
@@ -323,6 +380,16 @@ onUnmounted(() => {
font-weight: bold;
}
}
+
+ > .add-note-button {
+ background: rgba(0, 0, 0, 0.2);
+ color: #fff;
+ -webkit-backdrop-filter: var(--blur, blur(8px));
+ backdrop-filter: var(--blur, blur(8px));
+ border-radius: 24px;
+ padding: 4px 8px;
+ font-size: 80%;
+ }
}
}
}
@@ -369,6 +436,43 @@ onUnmounted(() => {
}
}
+ > .moderationNote {
+ margin: 12px 24px 0 154px;
+ }
+
+ > .memo {
+ margin: 12px 24px 0 154px;
+ background: transparent;
+ color: var(--fg);
+ border: 1px solid var(--divider);
+ border-radius: 8px;
+ padding: 8px;
+ line-height: 0;
+
+ > .heading {
+ text-align: left;
+ color: var(--fgTransparent);
+ line-height: 1.5;
+ font-size: 85%;
+ }
+
+ textarea {
+ margin: 0;
+ padding: 0;
+ resize: none;
+ border: none;
+ outline: none;
+ width: 100%;
+ height: auto;
+ min-height: 0;
+ line-height: 1.5;
+ color: var(--fg);
+ overflow: hidden;
+ background: transparent;
+ font-family: inherit;
+ }
+ }
+
> .description {
padding: 24px 24px 24px 154px;
font-size: 0.95em;
@@ -504,6 +608,14 @@ onUnmounted(() => {
justify-content: center;
}
+ > .moderationNote {
+ margin: 16px 16px 0 16px;
+ }
+
+ > .memo {
+ margin: 16px 16px 0 16px;
+ }
+
> .description {
padding: 16px;
text-align: center;
diff --git a/packages/frontend/src/pages/welcome.entrance.a.vue b/packages/frontend/src/pages/welcome.entrance.a.vue
index 4d8d76db18..929152bd5a 100644
--- a/packages/frontend/src/pages/welcome.entrance.a.vue
+++ b/packages/frontend/src/pages/welcome.entrance.a.vue
@@ -13,35 +13,7 @@
<MkEmoji :normal="true" :no-style="true" emoji="🍮"/>
</div>
<div class="contents">
- <div class="main">
- <img :src="instance.iconUrl || instance.faviconUrl || '/favicon.ico'" alt="" class="icon"/>
- <button class="_button _acrylic menu" @click="showMenu"><i class="ti ti-dots"></i></button>
- <div class="fg">
- <h1>
- <!-- 背景色によってはロゴが見えなくなるのでとりあえず無効に -->
- <!-- <img class="logo" v-if="meta.logoImageUrl" :src="meta.logoImageUrl"><span v-else class="text">{{ instanceName }}</span> -->
- <span class="text">{{ instanceName }}</span>
- </h1>
- <div class="about">
- <!-- eslint-disable-next-line vue/no-v-html -->
- <div class="desc" v-html="meta.description || i18n.ts.headlineMisskey"></div>
- </div>
- <div v-if="instance.disableRegistration" class="warn">
- <MkInfo warn>{{ i18n.ts.invitationRequiredToRegister }}</MkInfo>
- </div>
- <div class="action _gaps_s">
- <MkButton full rounded gradate data-cy-signup style="margin-right: 12px;" @click="signup()">{{ i18n.ts.joinThisServer }}</MkButton>
- <MkButton full rounded @click="exploreOtherServers()">{{ i18n.ts.exploreOtherServers }}</MkButton>
- <MkButton full rounded data-cy-signin @click="signin()">{{ i18n.ts.login }}</MkButton>
- </div>
- </div>
- </div>
- <div v-if="instance.policies.ltlAvailable" class="tl">
- <div class="title">{{ i18n.ts.letsLookAtTimeline }}</div>
- <div class="body">
- <MkTimeline src="local"/>
- </div>
- </div>
+ <MkVisitorDashboard/>
</div>
<div v-if="instances && instances.length > 0" class="federation">
<MarqueeText :duration="40">
@@ -60,16 +32,15 @@ import { } from 'vue';
import { Instance } from 'misskey-js/built/entities';
import XTimeline from './welcome.timeline.vue';
import MarqueeText from '@/components/MkMarquee.vue';
-import XSigninDialog from '@/components/MkSigninDialog.vue';
-import XSignupDialog from '@/components/MkSignupDialog.vue';
-import MkButton from '@/components/MkButton.vue';
import MkFeaturedPhotos from '@/components/MkFeaturedPhotos.vue';
-import MkTimeline from '@/components/MkTimeline.vue';
import MkInfo from '@/components/MkInfo.vue';
import { instanceName } from '@/config';
import * as os from '@/os';
import { i18n } from '@/i18n';
import { instance } from '@/instance';
+import number from '@/filters/number';
+import MkNumber from '@/components/MkNumber.vue';
+import MkVisitorDashboard from '@/components/MkVisitorDashboard.vue';
let meta = $ref<Instance>();
let instances = $ref<any[]>();
@@ -84,45 +55,6 @@ os.apiGet('federation/instances', {
}).then(_instances => {
instances = _instances;
});
-
-function signin() {
- os.popup(XSigninDialog, {
- autoSet: true,
- }, {}, 'closed');
-}
-
-function signup() {
- os.popup(XSignupDialog, {
- autoSet: true,
- }, {}, 'closed');
-}
-
-function showMenu(ev) {
- os.popupMenu([{
- text: i18n.ts.instanceInfo,
- icon: 'ti ti-info-circle',
- action: () => {
- os.pageWindow('/about');
- },
- }, {
- text: i18n.ts.aboutMisskey,
- icon: 'ti ti-info-circle',
- action: () => {
- os.pageWindow('/about-misskey');
- },
- }, null, {
- text: i18n.ts.help,
- icon: 'ti ti-question-circle',
- action: () => {
- window.open('https://misskey-hub.net/help.md', '_blank');
- },
- }], ev.currentTarget ?? ev.target);
-}
-
-function exploreOtherServers() {
- // TODO: 言語をよしなに
- window.open('https://join.misskey.page/ja-JP/instances', '_blank');
-}
</script>
<style lang="scss" scoped>
@@ -202,89 +134,11 @@ function exploreOtherServers() {
position: relative;
width: min(430px, calc(100% - 32px));
margin-left: 128px;
- padding: 150px 0 100px 0;
+ padding: 100px 0 100px 0;
@media (max-width: 1200px) {
margin: auto;
}
-
- > .main {
- position: relative;
- background: var(--panel);
- border-radius: var(--radius);
- box-shadow: 0 12px 32px rgb(0 0 0 / 25%);
- text-align: center;
-
- > .icon {
- width: 85px;
- margin-top: -47px;
- border-radius: 100%;
- vertical-align: bottom;
- }
-
- > .menu {
- position: absolute;
- top: 16px;
- right: 16px;
- width: 32px;
- height: 32px;
- border-radius: 8px;
- font-size: 18px;
- }
-
- > .fg {
- position: relative;
- z-index: 1;
-
- > h1 {
- display: block;
- margin: 0;
- padding: 16px 32px 24px 32px;
- font-size: 1.4em;
-
- > .logo {
- vertical-align: bottom;
- max-height: 120px;
- max-width: min(100%, 300px);
- }
- }
-
- > .about {
- padding: 0 32px;
- }
-
- > .warn {
- padding: 32px 32px 0 32px;
- }
-
- > .action {
- padding: 32px;
-
- > * {
- line-height: 28px;
- }
- }
- }
- }
-
- > .tl {
- position: relative;
- background: var(--panel);
- border-radius: var(--radius);
- overflow: clip;
- box-shadow: 0 12px 32px rgb(0 0 0 / 25%);
- margin-top: 16px;
-
- > .title {
- padding: 12px 16px;
- border-bottom: solid 1px var(--divider);
- }
-
- > .body {
- height: 350px;
- overflow: auto;
- }
- }
}
> .federation {
diff --git a/packages/frontend/src/pages/welcome.entrance.b.vue b/packages/frontend/src/pages/welcome.entrance.b.vue
deleted file mode 100644
index 03bf174710..0000000000
--- a/packages/frontend/src/pages/welcome.entrance.b.vue
+++ /dev/null
@@ -1,239 +0,0 @@
-<template>
-<div v-if="meta" class="rsqzvsbo">
- <div class="top">
- <MkFeaturedPhotos class="bg"/>
- <XTimeline class="tl"/>
- <div class="shape"></div>
- <div class="main">
- <h1>
- <img v-if="meta.logoImageUrl" class="logo" :src="meta.logoImageUrl"><span v-else class="text">{{ instanceName }}</span>
- </h1>
- <div class="about">
- <!-- eslint-disable-next-line vue/no-v-html -->
- <div class="desc" v-html="meta.description || i18n.ts.headlineMisskey"></div>
- </div>
- <div class="action">
- <MkButton class="signup" inline gradate @click="signup()">{{ i18n.ts.signup }}</MkButton>
- <MkButton class="signin" inline @click="signin()">{{ i18n.ts.login }}</MkButton>
- </div>
- <div v-if="onlineUsersCount && stats" class="status">
- <div>
- <I18n :src="i18n.ts.nUsers" text-tag="span" class="users">
- <template #n><b>{{ number(stats.originalUsersCount) }}</b></template>
- </I18n>
- <I18n :src="i18n.ts.nNotes" text-tag="span" class="notes">
- <template #n><b>{{ number(stats.originalNotesCount) }}</b></template>
- </I18n>
- </div>
- <I18n :src="i18n.ts.onlineUsersCount" text-tag="span" class="online">
- <template #n><b>{{ onlineUsersCount }}</b></template>
- </I18n>
- </div>
- </div>
- <img src="/client-assets/misskey.svg" class="misskey"/>
- </div>
-</div>
-</template>
-
-<script lang="ts">
-import { defineComponent } from 'vue';
-import { toUnicode } from 'punycode/';
-import XTimeline from './welcome.timeline.vue';
-import XSigninDialog from '@/components/MkSigninDialog.vue';
-import XSignupDialog from '@/components/MkSignupDialog.vue';
-import MkButton from '@/components/MkButton.vue';
-import MkNote from '@/components/MkNote.vue';
-import MkFeaturedPhotos from '@/components/MkFeaturedPhotos.vue';
-import { host, instanceName } from '@/config';
-import * as os from '@/os';
-import number from '@/filters/number';
-import { i18n } from '@/i18n';
-
-export default defineComponent({
- components: {
- MkButton,
- MkNote,
- XTimeline,
- MkFeaturedPhotos,
- },
-
- data() {
- return {
- host: toUnicode(host),
- instanceName,
- meta: null,
- stats: null,
- tags: [],
- onlineUsersCount: null,
- i18n,
- };
- },
-
- created() {
- os.api('meta', { detail: true }).then(meta => {
- this.meta = meta;
- });
-
- os.api('stats').then(stats => {
- this.stats = stats;
- });
-
- os.api('get-online-users-count').then(res => {
- this.onlineUsersCount = res.count;
- });
-
- os.api('hashtags/list', {
- sort: '+mentionedLocalUsers',
- limit: 8,
- }).then(tags => {
- this.tags = tags;
- });
- },
-
- methods: {
- signin() {
- os.popup(XSigninDialog, {
- autoSet: true,
- }, {}, 'closed');
- },
-
- signup() {
- os.popup(XSignupDialog, {
- autoSet: true,
- }, {}, 'closed');
- },
-
- showMenu(ev) {
- os.popupMenu([{
- text: i18n.t('aboutX', { x: instanceName }),
- icon: 'ti ti-info-circle',
- action: () => {
- os.pageWindow('/about');
- },
- }, {
- text: i18n.ts.aboutMisskey,
- icon: 'ti ti-info-circle',
- action: () => {
- os.pageWindow('/about-misskey');
- },
- }, null, {
- text: i18n.ts.help,
- icon: 'ti ti-question-circle',
- action: () => {
- window.open('https://misskey-hub.net/help.md', '_blank');
- },
- }], ev.currentTarget ?? ev.target);
- },
-
- number,
- },
-});
-</script>
-
-<style lang="scss" scoped>
-.rsqzvsbo {
- > .top {
- min-height: 100vh;
- box-sizing: border-box;
-
- > .bg {
- position: absolute;
- top: 0;
- left: 0;
- width: 100%;
- height: 100%;
- }
-
- > .tl {
- position: absolute;
- top: 0;
- bottom: 0;
- right: 64px;
- margin: auto;
- width: 500px;
- height: calc(100% - 128px);
- overflow: hidden;
- -webkit-mask-image: linear-gradient(0deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 128px, rgba(0,0,0,1) calc(100% - 128px), rgba(0,0,0,0) 100%);
- mask-image: linear-gradient(0deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 128px, rgba(0,0,0,1) calc(100% - 128px), rgba(0,0,0,0) 100%);
- }
-
- > .shape {
- position: absolute;
- top: 0;
- left: 0;
- width: 100%;
- height: 100%;
- background: var(--accent);
- clip-path: polygon(0% 0%, 40% 0%, 22% 100%, 0% 100%);
- }
-
- > .misskey {
- position: absolute;
- bottom: 64px;
- left: 64px;
- width: 160px;
- }
-
- > .main {
- position: relative;
- width: min(450px, 100%);
- padding: 64px;
- color: #fff;
- font-size: 1.1em;
-
- @media (max-width: 1200px) {
- margin: auto;
- }
-
- > h1 {
- display: block;
- margin: 0 0 32px 0;
- padding: 0;
-
- > .logo {
- vertical-align: bottom;
- max-height: 100px;
- }
- }
-
- > .about {
- padding: 0;
- }
-
- > .action {
- margin: 32px 0;
-
- > * {
- line-height: 32px;
- }
-
- > .signup {
- background: var(--panel);
- color: var(--fg);
- }
-
- > .signin {
- background: var(--accent);
- color: inherit;
- }
- }
-
- > .status {
- margin: 32px 0;
- border-top: solid 1px rgba(255, 255, 255, 0.5);
- font-size: 90%;
-
- > div {
- padding: 16px 0;
-
- > span:not(:last-child) {
- padding-right: 1em;
- margin-right: 1em;
- border-right: solid 1px rgba(255, 255, 255, 0.5);
- }
- }
- }
- }
- }
-}
-</style>
diff --git a/packages/frontend/src/pages/welcome.entrance.c.vue b/packages/frontend/src/pages/welcome.entrance.c.vue
deleted file mode 100644
index eca4e5764d..0000000000
--- a/packages/frontend/src/pages/welcome.entrance.c.vue
+++ /dev/null
@@ -1,308 +0,0 @@
-<template>
-<div v-if="meta" class="rsqzvsbo">
- <div class="top">
- <MkFeaturedPhotos class="bg"/>
- <div class="fade"></div>
- <div class="emojis">
- <MkEmoji :normal="true" :no-style="true" emoji="👍"/>
- <MkEmoji :normal="true" :no-style="true" emoji="❤"/>
- <MkEmoji :normal="true" :no-style="true" emoji="😆"/>
- <MkEmoji :normal="true" :no-style="true" emoji="🎉"/>
- <MkEmoji :normal="true" :no-style="true" emoji="🍮"/>
- </div>
- <div class="main">
- <img src="/client-assets/misskey.svg" class="misskey"/>
- <div class="form _panel">
- <div class="bg">
- <div class="fade"></div>
- </div>
- <div class="fg">
- <h1>
- <img v-if="meta.logoImageUrl" class="logo" :src="meta.logoImageUrl"><span v-else class="text">{{ instanceName }}</span>
- </h1>
- <div class="about">
- <!-- eslint-disable-next-line vue/no-v-html -->
- <div class="desc" v-html="meta.description || i18n.ts.headlineMisskey"></div>
- </div>
- <div class="action">
- <MkButton inline gradate @click="signup()">{{ i18n.ts.signup }}</MkButton>
- <MkButton inline @click="signin()">{{ i18n.ts.login }}</MkButton>
- </div>
- <div v-if="onlineUsersCount && stats" class="status">
- <div>
- <I18n :src="i18n.ts.nUsers" text-tag="span" class="users">
- <template #n><b>{{ number(stats.originalUsersCount) }}</b></template>
- </I18n>
- <I18n :src="i18n.ts.nNotes" text-tag="span" class="notes">
- <template #n><b>{{ number(stats.originalNotesCount) }}</b></template>
- </I18n>
- </div>
- <I18n :src="i18n.ts.onlineUsersCount" text-tag="span" class="online">
- <template #n><b>{{ onlineUsersCount }}</b></template>
- </I18n>
- </div>
- <button class="_button _acrylic menu" @click="showMenu"><i class="ti ti-dots"></i></button>
- </div>
- </div>
- <nav class="nav">
- <MkA to="/announcements">{{ i18n.ts.announcements }}</MkA>
- <MkA to="/explore">{{ i18n.ts.explore }}</MkA>
- <MkA to="/channels">{{ i18n.ts.channel }}</MkA>
- <MkA to="/featured">{{ i18n.ts.featured }}</MkA>
- </nav>
- </div>
- </div>
-</div>
-</template>
-
-<script lang="ts">
-import { defineComponent } from 'vue';
-import { toUnicode } from 'punycode/';
-import XTimeline from './welcome.timeline.vue';
-import XSigninDialog from '@/components/MkSigninDialog.vue';
-import XSignupDialog from '@/components/MkSignupDialog.vue';
-import MkButton from '@/components/MkButton.vue';
-import MkNote from '@/components/MkNote.vue';
-import MkFeaturedPhotos from '@/components/MkFeaturedPhotos.vue';
-import { host, instanceName } from '@/config';
-import * as os from '@/os';
-import number from '@/filters/number';
-import { i18n } from '@/i18n';
-
-export default defineComponent({
- components: {
- MkButton,
- MkNote,
- MkFeaturedPhotos,
- XTimeline,
- },
-
- data() {
- return {
- host: toUnicode(host),
- instanceName,
- meta: null,
- stats: null,
- tags: [],
- onlineUsersCount: null,
- i18n,
- };
- },
-
- created() {
- os.api('meta', { detail: true }).then(meta => {
- this.meta = meta;
- });
-
- os.api('stats').then(stats => {
- this.stats = stats;
- });
-
- os.api('get-online-users-count').then(res => {
- this.onlineUsersCount = res.count;
- });
-
- os.api('hashtags/list', {
- sort: '+mentionedLocalUsers',
- limit: 8,
- }).then(tags => {
- this.tags = tags;
- });
- },
-
- methods: {
- signin() {
- os.popup(XSigninDialog, {
- autoSet: true,
- }, {}, 'closed');
- },
-
- signup() {
- os.popup(XSignupDialog, {
- autoSet: true,
- }, {}, 'closed');
- },
-
- showMenu(ev) {
- os.popupMenu([{
- text: i18n.t('aboutX', { x: instanceName }),
- icon: 'ti ti-info-circle',
- action: () => {
- os.pageWindow('/about');
- },
- }, {
- text: i18n.ts.aboutMisskey,
- icon: 'ti ti-info-circle',
- action: () => {
- os.pageWindow('/about-misskey');
- },
- }, null, {
- text: i18n.ts.help,
- icon: 'ti ti-question-circle',
- action: () => {
- window.open('https://misskey-hub.net/help.md', '_blank');
- },
- }], ev.currentTarget ?? ev.target);
- },
-
- number,
- },
-});
-</script>
-
-<style lang="scss" scoped>
-.rsqzvsbo {
- > .top {
- display: flex;
- text-align: center;
- min-height: 100vh;
- box-sizing: border-box;
- padding: 16px;
-
- > .bg {
- position: absolute;
- top: 0;
- left: 0;
- width: 100%;
- height: 100%;
- }
-
- > .fade {
- position: absolute;
- top: 0;
- left: 0;
- width: 100%;
- height: 100%;
- background: rgba(0, 0, 0, 0.25);
- }
-
- > .emojis {
- position: absolute;
- bottom: 32px;
- left: 35px;
-
- > * {
- margin-right: 8px;
- }
-
- @media (max-width: 1200px) {
- display: none;
- }
- }
-
- > .main {
- position: relative;
- width: min(460px, 100%);
- margin: auto;
-
- > .misskey {
- width: 150px;
- margin-bottom: 16px;
-
- @media (max-width: 450px) {
- width: 130px;
- }
- }
-
- > .form {
- position: relative;
- box-shadow: 0 12px 32px rgb(0 0 0 / 25%);
-
- > .bg {
- position: absolute;
- top: 0;
- left: 0;
- width: 100%;
- height: 128px;
- background-position: center;
- background-size: cover;
- opacity: 0.75;
-
- > .fade {
- position: absolute;
- bottom: 0;
- left: 0;
- width: 100%;
- height: 128px;
- background: linear-gradient(0deg, var(--panel), var(--X15));
- }
- }
-
- > .fg {
- position: relative;
- z-index: 1;
-
- > h1 {
- display: block;
- margin: 0;
- padding: 32px 32px 24px 32px;
-
- > .logo {
- vertical-align: bottom;
- max-height: 120px;
- }
- }
-
- > .about {
- padding: 0 32px;
- }
-
- > .action {
- padding: 32px;
-
- > * {
- line-height: 28px;
- }
- }
-
- > .status {
- border-top: solid 0.5px var(--divider);
- padding: 32px;
- font-size: 90%;
-
- > div {
- > span:not(:last-child) {
- padding-right: 1em;
- margin-right: 1em;
- border-right: solid 0.5px var(--divider);
- }
- }
-
- > .online {
- ::v-deep(b) {
- color: #41b781;
- }
-
- ::v-deep(span) {
- opacity: 0.7;
- }
- }
- }
-
- > .menu {
- position: absolute;
- top: 16px;
- right: 16px;
- width: 32px;
- height: 32px;
- border-radius: 8px;
- }
- }
- }
-
- > .nav {
- position: relative;
- z-index: 2;
- margin-top: 20px;
- color: #fff;
- text-shadow: 0 0 8px black;
- font-size: 0.9em;
-
- > *:not(:last-child) {
- margin-right: 1.5em;
- }
- }
- }
- }
-}
-</style>
diff --git a/packages/frontend/src/pages/welcome.setup.vue b/packages/frontend/src/pages/welcome.setup.vue
index 212d156a83..7728d97a65 100644
--- a/packages/frontend/src/pages/welcome.setup.vue
+++ b/packages/frontend/src/pages/welcome.setup.vue
@@ -1,8 +1,11 @@
<template>
-<form class="mk-setup" @submit.prevent="submit()">
- <h1>Welcome to Misskey!</h1>
- <div class="_gaps_m">
- <p>{{ i18n.ts.intro }}</p>
+<form :class="$style.root" class="_panel" @submit.prevent="submit()">
+ <div :class="$style.title">
+ <div>Welcome to Misskey!</div>
+ <div :class="$style.version">v{{ version }}</div>
+ </div>
+ <div class="_gaps_m" style="padding: 32px;">
+ <div>{{ i18n.ts.intro }}</div>
<MkInput v-model="username" pattern="^[a-zA-Z0-9_]{1,20}$" :spellcheck="false" required data-cy-admin-username>
<template #label>{{ i18n.ts.username }}</template>
<template #prefix>@</template>
@@ -12,8 +15,8 @@
<template #label>{{ i18n.ts.password }}</template>
<template #prefix><i class="ti ti-lock"></i></template>
</MkInput>
- <div class="bottom">
- <MkButton gradate type="submit" :disabled="submitting" data-cy-admin-ok>
+ <div>
+ <MkButton gradate large rounded type="submit" :disabled="submitting" data-cy-admin-ok style="margin: 0 auto;">
{{ submitting ? i18n.ts.processing : i18n.ts.done }}<MkEllipsis v-if="submitting"/>
</MkButton>
</div>
@@ -25,7 +28,7 @@
import { } from 'vue';
import MkButton from '@/components/MkButton.vue';
import MkInput from '@/components/MkInput.vue';
-import { host } from '@/config';
+import { host, version } from '@/config';
import * as os from '@/os';
import { login } from '@/account';
import { i18n } from '@/i18n';
@@ -54,36 +57,28 @@ function submit() {
}
</script>
-<style lang="scss" scoped>
-.mk-setup {
+<style lang="scss" module>
+.root {
border-radius: var(--radius);
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1);
overflow: hidden;
max-width: 500px;
margin: 32px auto;
+}
- > h1 {
- margin: 0;
- font-size: 1.5em;
- text-align: center;
- padding: 32px;
- background: var(--accent);
- color: #fff;
- }
-
- > div {
- padding: 32px;
- background: var(--panel);
-
- > p {
- margin-top: 0;
- }
+.title {
+ margin: 0;
+ font-size: 1.5em;
+ text-align: center;
+ padding: 32px;
+ background: var(--accentedBg);
+ color: var(--accent);
+ font-weight: bold;
+}
- > .bottom {
- > * {
- margin: 0 auto;
- }
- }
- }
+.version {
+ font-size: 70%;
+ font-weight: normal;
+ opacity: 0.7;
}
</style>
diff --git a/packages/frontend/src/pages/welcome.timeline.vue b/packages/frontend/src/pages/welcome.timeline.vue
index 6a507ee1ed..6ec6e3f863 100644
--- a/packages/frontend/src/pages/welcome.timeline.vue
+++ b/packages/frontend/src/pages/welcome.timeline.vue
@@ -33,7 +33,7 @@ import { $i } from '@/account';
let notes = $ref<Note[]>([]);
let isScrolling = $ref(false);
-let scrollEl = $ref<HTMLElement>();
+let scrollEl = $shallowRef<HTMLElement>();
os.apiGet('notes/featured').then(_notes => {
notes = _notes;
diff --git a/packages/frontend/src/router.ts b/packages/frontend/src/router.ts
index 0769ec2614..e46c1eeb77 100644
--- a/packages/frontend/src/router.ts
+++ b/packages/frontend/src/router.ts
@@ -164,7 +164,7 @@ export const routes = [{
}, {
path: '/migration',
name: 'migration',
- component: page(() => import('./pages/settings/migration.vue'))
+ component: page(() => import('./pages/settings/migration.vue')),
}, {
path: '/custom-css',
name: 'general',
@@ -174,13 +174,9 @@ export const routes = [{
name: 'profile',
component: page(() => import('./pages/settings/accounts.vue')),
}, {
- path: '/account-info',
+ path: '/account-stats',
name: 'other',
- component: page(() => import('./pages/settings/account-info.vue')),
- }, {
- path: '/delete-account',
- name: 'other',
- component: page(() => import('./pages/settings/delete-account.vue')),
+ component: page(() => import('./pages/settings/account-stats.vue')),
}, {
path: '/other',
name: 'other',
@@ -428,6 +424,10 @@ export const routes = [{
name: 'other-settings',
component: page(() => import('./pages/admin/other-settings.vue')),
}, {
+ path: '/server-rules',
+ name: 'server-rules',
+ component: page(() => import('./pages/admin/server-rules.vue')),
+ }, {
path: '/',
component: page(() => import('./pages/_empty_.vue')),
}],
diff --git a/packages/frontend/src/scripts/achievements.ts b/packages/frontend/src/scripts/achievements.ts
index 25e8b71a12..fbca005769 100644
--- a/packages/frontend/src/scripts/achievements.ts
+++ b/packages/frontend/src/scripts/achievements.ts
@@ -60,6 +60,7 @@ export const ACHIEVEMENT_TYPES = [
'iLoveMisskey',
'foundTreasure',
'client30min',
+ 'client60min',
'noteDeletedWithin1min',
'postedAtLateNight',
'postedAt0min0sec',
@@ -343,6 +344,11 @@ export const ACHIEVEMENT_BADGES = {
bg: 'linear-gradient(0deg, rgb(220 223 225), rgb(172 192 207))',
frame: 'bronze',
},
+ 'client60min': {
+ img: '/fluent-emoji/1f552.png',
+ bg: 'linear-gradient(0deg, rgb(220 223 225), rgb(172 192 207))',
+ frame: 'silver',
+ },
'noteDeletedWithin1min': {
img: '/fluent-emoji/1f5d1.png',
bg: 'linear-gradient(0deg, rgb(220 223 225), rgb(172 192 207))',
@@ -458,6 +464,7 @@ const claimingQueue = new Set<string>();
export async function claimAchievement(type: typeof ACHIEVEMENT_TYPES[number]) {
if ($i == null) return;
+ if ($i.movedTo) return;
if (claimedAchievements.includes(type)) return;
claimingQueue.add(type);
claimedAchievements.push(type);
diff --git a/packages/frontend/src/scripts/aiscript/api.ts b/packages/frontend/src/scripts/aiscript/api.ts
index 1b47eaa420..b6b7445b67 100644
--- a/packages/frontend/src/scripts/aiscript/api.ts
+++ b/packages/frontend/src/scripts/aiscript/api.ts
@@ -17,6 +17,7 @@ export function createAiScriptEnv(opts) {
title: title.value,
text: text.value,
});
+ return values.NULL;
}),
'Mk:confirm': values.FN_NATIVE(async ([title, text, type]) => {
const confirm = await os.confirm({
diff --git a/packages/frontend/src/scripts/get-drive-file-menu.ts b/packages/frontend/src/scripts/get-drive-file-menu.ts
index 52e610e437..ed01b49054 100644
--- a/packages/frontend/src/scripts/get-drive-file-menu.ts
+++ b/packages/frontend/src/scripts/get-drive-file-menu.ts
@@ -36,6 +36,12 @@ function toggleSensitive(file: Misskey.entities.DriveFile) {
os.api('drive/files/update', {
fileId: file.id,
isSensitive: !file.isSensitive,
+ }).catch(err => {
+ os.alert({
+ type: 'error',
+ title: i18n.ts.error,
+ text: err.message,
+ });
});
}
@@ -74,6 +80,12 @@ export function getDriveFileMenu(file: Misskey.entities.DriveFile) {
icon: 'ti ti-text-caption',
action: () => describe(file),
}, null, {
+ text: i18n.ts.createNoteFromTheFile,
+ icon: 'ti ti-pencil',
+ action: () => os.post({
+ initialFiles: [file],
+ }),
+ }, {
text: i18n.ts.copyUrl,
icon: 'ti ti-link',
action: () => copyUrl(file),
diff --git a/packages/frontend/src/scripts/get-note-menu.ts b/packages/frontend/src/scripts/get-note-menu.ts
index d91f0b0eb6..c8a6100253 100644
--- a/packages/frontend/src/scripts/get-note-menu.ts
+++ b/packages/frontend/src/scripts/get-note-menu.ts
@@ -211,6 +211,12 @@ export function getNoteMenu(props: {
}, {}, 'closed');
}
+ function showRenotes(): void {
+ os.popup(defineAsyncComponent(() => import('@/components/MkRenotedUsersDialog.vue')), {
+ noteId: appearNote.id,
+ }, {}, 'closed');
+ }
+
async function translate(): Promise<void> {
if (props.translation.value != null) return;
props.translating.value = true;
@@ -241,8 +247,12 @@ export function getNoteMenu(props: {
text: i18n.ts.details,
action: openDetail,
}, {
- icon: 'ti ti-users',
- text: i18n.ts.reactions,
+ icon: 'ti ti-repeat',
+ text: i18n.ts.renotesList,
+ action: showRenotes,
+ }, {
+ icon: 'ti ti-icons',
+ text: i18n.ts.reactionsList,
action: showReactions,
}, {
icon: 'ti ti-copy',
diff --git a/packages/frontend/src/scripts/get-user-menu.ts b/packages/frontend/src/scripts/get-user-menu.ts
index fe941c77b2..6ff9fb63f1 100644
--- a/packages/frontend/src/scripts/get-user-menu.ts
+++ b/packages/frontend/src/scripts/get-user-menu.ts
@@ -98,6 +98,27 @@ export function getUserMenu(user: misskey.entities.UserDetailed, router: Router
});
}
+ async function editMemo(): Promise<void> {
+ const userDetailed = await os.api('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,
+ });
+ }
+
let menu = [{
icon: 'ti ti-at',
text: i18n.ts.copyUsername,
@@ -123,6 +144,12 @@ export function getUserMenu(user: misskey.entities.UserDetailed, router: Router
os.post({ specified: user, initialText: `@${user.username} ` });
},
}, null, {
+ icon: 'ti ti-pencil',
+ text: i18n.ts.editMemo,
+ action: () => {
+ editMemo();
+ },
+ }, {
type: 'parent',
icon: 'ti ti-list',
text: i18n.ts.addToList,
diff --git a/packages/frontend/src/scripts/please-login.ts b/packages/frontend/src/scripts/please-login.ts
index b8fb853cc1..c101a127f3 100644
--- a/packages/frontend/src/scripts/please-login.ts
+++ b/packages/frontend/src/scripts/please-login.ts
@@ -17,5 +17,5 @@ export function pleaseLogin(path?: string) {
},
}, 'closed');
- if (!path) throw new Error('signin required');
+ throw new Error('signin required');
}
diff --git a/packages/frontend/src/scripts/select-file.ts b/packages/frontend/src/scripts/select-file.ts
index ec5f8f65e9..fe9f0a2447 100644
--- a/packages/frontend/src/scripts/select-file.ts
+++ b/packages/frontend/src/scripts/select-file.ts
@@ -6,70 +6,76 @@ import { i18n } from '@/i18n';
import { defaultStore } from '@/store';
import { uploadFile } from '@/scripts/upload';
-function select(src: any, label: string | null, multiple: boolean): Promise<DriveFile | DriveFile[]> {
+export function chooseFileFromPc(multiple: boolean, keepOriginal = false): Promise<DriveFile[]> {
return new Promise((res, rej) => {
- const keepOriginal = ref(defaultStore.state.keepOriginalUploading);
-
- const chooseFileFromPc = () => {
- const input = document.createElement('input');
- input.type = 'file';
- input.multiple = multiple;
- input.onchange = () => {
- const promises = Array.from(input.files).map(file => uploadFile(file, defaultStore.state.uploadFolder, undefined, keepOriginal.value));
+ const input = document.createElement('input');
+ input.type = 'file';
+ input.multiple = multiple;
+ input.onchange = () => {
+ const promises = Array.from(input.files).map(file => uploadFile(file, defaultStore.state.uploadFolder, undefined, keepOriginal));
- Promise.all(promises).then(driveFiles => {
- res(multiple ? driveFiles : driveFiles[0]);
- }).catch(err => {
- // アップロードのエラーは uploadFile 内でハンドリングされているためアラートダイアログを出したりはしてはいけない
- });
+ Promise.all(promises).then(driveFiles => {
+ res(driveFiles);
+ }).catch(err => {
+ // アップロードのエラーは uploadFile 内でハンドリングされているためアラートダイアログを出したりはしてはいけない
+ });
- // 一応廃棄
- (window as any).__misskey_input_ref__ = null;
- };
+ // 一応廃棄
+ (window as any).__misskey_input_ref__ = null;
+ };
- // https://qiita.com/fukasawah/items/b9dc732d95d99551013d
- // iOS Safari で正常に動かす為のおまじない
- (window as any).__misskey_input_ref__ = input;
+ // https://qiita.com/fukasawah/items/b9dc732d95d99551013d
+ // iOS Safari で正常に動かす為のおまじない
+ (window as any).__misskey_input_ref__ = input;
- input.click();
- };
+ input.click();
+ });
+}
- const chooseFileFromDrive = () => {
- os.selectDriveFile(multiple).then(files => {
- res(files);
- });
- };
+export function chooseFileFromDrive(multiple: boolean): Promise<DriveFile[]> {
+ return new Promise((res, rej) => {
+ os.selectDriveFile(multiple).then(files => {
+ res(files);
+ });
+ });
+}
- const chooseFileFromUrl = () => {
- os.inputText({
- title: i18n.ts.uploadFromUrl,
- type: 'url',
- placeholder: i18n.ts.uploadFromUrlDescription,
- }).then(({ canceled, result: url }) => {
- if (canceled) return;
+export function chooseFileFromUrl(): Promise<DriveFile> {
+ 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 marker = Math.random().toString(); // TODO: UUIDとか使う
- const connection = stream.useChannel('main');
- connection.on('urlUploadFinished', urlResponse => {
- if (urlResponse.marker === marker) {
- res(multiple ? [urlResponse.file] : urlResponse.file);
- connection.dispose();
- }
- });
+ const connection = stream.useChannel('main');
+ connection.on('urlUploadFinished', urlResponse => {
+ if (urlResponse.marker === marker) {
+ res(urlResponse.file);
+ connection.dispose();
+ }
+ });
- os.api('drive/files/upload-from-url', {
- url: url,
- folderId: defaultStore.state.uploadFolder,
- marker,
- });
+ os.api('drive/files/upload-from-url', {
+ url: url,
+ folderId: defaultStore.state.uploadFolder,
+ marker,
+ });
- os.alert({
- title: i18n.ts.uploadFromUrlRequested,
- text: i18n.ts.uploadFromUrlMayTakeTime,
- });
+ os.alert({
+ title: i18n.ts.uploadFromUrlRequested,
+ text: i18n.ts.uploadFromUrlMayTakeTime,
});
- };
+ });
+ });
+}
+
+function select(src: any, label: string | null, multiple: boolean): Promise<DriveFile[]> {
+ return new Promise((res, rej) => {
+ const keepOriginal = ref(defaultStore.state.keepOriginalUploading);
os.popupMenu([label ? {
text: label,
@@ -81,23 +87,23 @@ function select(src: any, label: string | null, multiple: boolean): Promise<Driv
}, {
text: i18n.ts.upload,
icon: 'ti ti-upload',
- action: chooseFileFromPc,
+ action: () => chooseFileFromPc(multiple, keepOriginal.value).then(files => res(files)),
}, {
text: i18n.ts.fromDrive,
icon: 'ti ti-cloud',
- action: chooseFileFromDrive,
+ action: () => chooseFileFromDrive(multiple).then(files => res(files)),
}, {
text: i18n.ts.fromUrl,
icon: 'ti ti-link',
- action: chooseFileFromUrl,
+ action: () => chooseFileFromUrl().then(file => res([file])),
}], src);
});
}
export function selectFile(src: any, label: string | null = null): Promise<DriveFile> {
- return select(src, label, false) as Promise<DriveFile>;
+ return select(src, label, false).then(files => files[0]);
}
export function selectFiles(src: any, label: string | null = null): Promise<DriveFile[]> {
- return select(src, label, true) as Promise<DriveFile[]>;
+ 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
new file mode 100644
index 0000000000..acb26c36e2
--- /dev/null
+++ b/packages/frontend/src/scripts/show-moved-dialog.ts
@@ -0,0 +1,16 @@
+import * as os from '@/os';
+import { $i } from '@/account';
+import { i18n } from '@/i18n';
+
+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/upload.ts b/packages/frontend/src/scripts/upload.ts
index 9a39652ef5..2dd11c9fa2 100644
--- a/packages/frontend/src/scripts/upload.ts
+++ b/packages/frontend/src/scripts/upload.ts
@@ -83,7 +83,13 @@ export function uploadFile(
// TODO: 消すのではなくて(ネットワーク的なエラーなら)再送できるようにしたい
uploads.value = uploads.value.filter(x => x.id !== id);
- if (ev.target?.response) {
+ 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({
diff --git a/packages/frontend/src/store.ts b/packages/frontend/src/store.ts
index a935093240..245bcbefe1 100644
--- a/packages/frontend/src/store.ts
+++ b/packages/frontend/src/store.ts
@@ -38,7 +38,11 @@ export const pageViewInterruptors: PageViewInterruptor[] = [];
// TODO: それぞれいちいちwhereとかdefaultというキーを付けなきゃいけないの冗長なのでなんとかする(ただ型定義が面倒になりそう)
// あと、現行の定義の仕方なら「whereが何であるかに関わらずキー名の重複不可」という制約を付けられるメリットもあるからそのメリットを引き継ぐ方法も考えないといけない
export const defaultStore = markRaw(new Storage('base', {
- tutorial: {
+ accountSetupWizard: {
+ where: 'account',
+ default: 0,
+ },
+ timelineTutorial: {
where: 'account',
default: 0,
},
@@ -164,7 +168,7 @@ export const defaultStore = markRaw(new Storage('base', {
},
animation: {
where: 'device',
- default: !matchMedia('(prefers-reduced-motion)').matches,
+ default: !window.matchMedia('(prefers-reduced-motion)').matches,
},
animatedMfm: {
where: 'device',
@@ -182,9 +186,13 @@ export const defaultStore = markRaw(new Storage('base', {
where: 'device',
default: false,
},
+ enableDataSaverMode: {
+ where: 'device',
+ default: false,
+ },
disableShowingAnimatedImages: {
where: 'device',
- default: matchMedia('(prefers-reduced-motion)').matches,
+ default: window.matchMedia('(prefers-reduced-motion)').matches,
},
emojiStyle: {
where: 'device',
@@ -306,6 +314,22 @@ export const defaultStore = markRaw(new Storage('base', {
where: 'device',
default: false,
},
+ mediaListWithOneImageAppearance: {
+ where: 'device',
+ default: 'expand' as 'expand' | '16_9' | '1_1' | '2_3',
+ },
+ notificationPosition: {
+ where: 'device',
+ default: 'rightBottom' as 'leftTop' | 'leftBottom' | 'rightTop' | 'rightBottom',
+ },
+ notificationStackAxis: {
+ where: 'device',
+ default: 'horizontal' as 'vertical' | 'horizontal',
+ },
+ enableCondensedLineForAcct: {
+ where: 'device',
+ default: true,
+ },
}));
// TODO: 他のタブと永続化されたstateを同期
diff --git a/packages/frontend/src/themes/d-botanical.json5 b/packages/frontend/src/themes/d-botanical.json5
index c03b95e2d7..33cf7aa817 100644
--- a/packages/frontend/src/themes/d-botanical.json5
+++ b/packages/frontend/src/themes/d-botanical.json5
@@ -13,8 +13,7 @@
fgHighlighted: '#fff',
divider: 'rgba(255, 255, 255, 0.14)',
panel: 'rgb(47, 47, 44)',
- panelHeaderBg: '@panel',
- panelHeaderDivider: '@divider',
+ panelHeaderDivider: 'rgba(0, 0, 0, 0)',
header: ':alpha<0.7<@panel',
navBg: '#363636',
renote: '@accent',
diff --git a/packages/frontend/src/themes/d-dark.json5 b/packages/frontend/src/themes/d-dark.json5
index d24ce4df69..63144e88ea 100644
--- a/packages/frontend/src/themes/d-dark.json5
+++ b/packages/frontend/src/themes/d-dark.json5
@@ -13,8 +13,7 @@
fgHighlighted: '#fff',
divider: 'rgba(255, 255, 255, 0.14)',
panel: '#2d2d2d',
- panelHeaderBg: '@panel',
- panelHeaderDivider: '@divider',
+ panelHeaderDivider: 'rgba(0, 0, 0, 0)',
header: ':alpha<0.7<@panel',
navBg: '#363636',
renote: '@accent',
diff --git a/packages/frontend/src/themes/d-future.json5 b/packages/frontend/src/themes/d-future.json5
index b6fa1ab0c1..0962a12411 100644
--- a/packages/frontend/src/themes/d-future.json5
+++ b/packages/frontend/src/themes/d-future.json5
@@ -14,8 +14,7 @@
fgOnAccent: '#000',
divider: 'rgba(255, 255, 255, 0.1)',
panel: '#18181c',
- panelHeaderBg: '@panel',
- panelHeaderDivider: '@divider',
+ panelHeaderDivider: 'rgba(0, 0, 0, 0)',
renote: '@accent',
mention: '#f2c97d',
mentionMe: '@accent',
diff --git a/packages/frontend/src/themes/d-green-lime.json5 b/packages/frontend/src/themes/d-green-lime.json5
index a6983b9ac2..9522f534a4 100644
--- a/packages/frontend/src/themes/d-green-lime.json5
+++ b/packages/frontend/src/themes/d-green-lime.json5
@@ -14,8 +14,7 @@
fgOnAccent: '#192320',
divider: '#e7fffb24',
panel: '#192320',
- panelHeaderBg: '@panel',
- panelHeaderDivider: '@divider',
+ panelHeaderDivider: 'rgba(0, 0, 0, 0)',
popup: '#293330',
renote: '@accent',
mentionMe: '#ffaa00',
diff --git a/packages/frontend/src/themes/d-green-orange.json5 b/packages/frontend/src/themes/d-green-orange.json5
index 62adc39e29..e542782c66 100644
--- a/packages/frontend/src/themes/d-green-orange.json5
+++ b/packages/frontend/src/themes/d-green-orange.json5
@@ -14,8 +14,7 @@
fgOnAccent: '#192320',
divider: '#e7fffb24',
panel: '#192320',
- panelHeaderBg: '@panel',
- panelHeaderDivider: '@divider',
+ panelHeaderDivider: 'rgba(0, 0, 0, 0)',
popup: '#293330',
renote: '@accent',
mentionMe: '#b4e900',
diff --git a/packages/frontend/src/ui/_common_/common.ts b/packages/frontend/src/ui/_common_/common.ts
index eae4f0091c..53042a4ce7 100644
--- a/packages/frontend/src/ui/_common_/common.ts
+++ b/packages/frontend/src/ui/_common_/common.ts
@@ -76,7 +76,7 @@ export function openInstanceMenu(ev: MouseEvent) {
} : undefined],
}, null, {
text: i18n.ts.help,
- icon: 'ti ti-question-circle',
+ icon: 'ti ti-help-circle',
action: () => {
window.open('https://misskey-hub.net/help.html', '_blank');
},
diff --git a/packages/frontend/src/ui/_common_/common.vue b/packages/frontend/src/ui/_common_/common.vue
index 5a32c076a4..71a4285e9d 100644
--- a/packages/frontend/src/ui/_common_/common.vue
+++ b/packages/frontend/src/ui/_common_/common.vue
@@ -10,14 +10,16 @@
<XUpload v-if="uploads.length > 0"/>
<TransitionGroup
- tag="div" :class="$style.notifications"
+ tag="div" :class="[$style.notifications, $style[`notificationsPosition-${defaultStore.state.notificationPosition}`], $style[`notificationsStackAxis-${defaultStore.state.notificationStackAxis}`]]"
:move-class="defaultStore.state.animation ? $style.transition_notification_move : ''"
:enter-active-class="defaultStore.state.animation ? $style.transition_notification_enterActive : ''"
:leave-active-class="defaultStore.state.animation ? $style.transition_notification_leaveActive : ''"
:enter-from-class="defaultStore.state.animation ? $style.transition_notification_enterFrom : ''"
:leave-to-class="defaultStore.state.animation ? $style.transition_notification_leaveTo : ''"
>
- <XNotification v-for="notification in notifications" :key="notification.id" :notification="notification" :class="$style.notification"/>
+ <div v-for="notification in notifications" :key="notification.id" :class="$style.notification">
+ <XNotification :notification="notification"/>
+ </div>
</TransitionGroup>
<XStreamIndicator/>
@@ -30,7 +32,7 @@
</template>
<script lang="ts" setup>
-import { defineAsyncComponent } from 'vue';
+import { defineAsyncComponent, ref } from 'vue';
import * as misskey from 'misskey-js';
import { swInject } from './sw-inject';
import XNotification from './notification.vue';
@@ -85,7 +87,10 @@ if ($i) {
.transition_notification_leaveActive {
transition: opacity 0.3s, transform 0.3s !important;
}
-.transition_notification_enterFrom,
+.transition_notification_enterFrom {
+ opacity: 0;
+ transform: translateX(250px);
+}
.transition_notification_leaveTo {
opacity: 0;
transform: translateX(-250px);
@@ -94,35 +99,90 @@ if ($i) {
.notifications {
position: fixed;
z-index: 3900000;
- left: 0;
- width: 250px;
- top: 32px;
- padding: 0 32px;
+ padding: 0 var(--margin);
pointer-events: none;
- container-type: inline-size;
-}
+ display: flex;
-.notification {
- & + .notification {
- margin-top: 8px;
+ &.notificationsPosition-leftTop {
+ top: var(--margin);
+ left: 0;
+ }
+
+ &.notificationsPosition-rightTop {
+ top: var(--margin);
+ right: 0;
+ }
+
+ &.notificationsPosition-leftBottom {
+ bottom: calc(var(--minBottomSpacing) + var(--margin));
+ left: 0;
}
-}
-@media (max-width: 500px) {
- .notifications {
- top: initial;
+ &.notificationsPosition-rightBottom {
bottom: calc(var(--minBottomSpacing) + var(--margin));
- padding: 0 var(--margin);
- display: flex;
- flex-direction: column-reverse;
+ right: 0;
}
- .notification {
- & + .notification {
- margin-top: 0;
- margin-bottom: 8px;
+ &.notificationsStackAxis-vertical {
+ width: 250px;
+
+ &.notificationsPosition-leftTop,
+ &.notificationsPosition-rightTop {
+ flex-direction: column;
+
+ .notification {
+ & + .notification {
+ margin-top: 8px;
+ }
+ }
+ }
+
+ &.notificationsPosition-leftBottom,
+ &.notificationsPosition-rightBottom {
+ flex-direction: column-reverse;
+
+ .notification {
+ & + .notification {
+ margin-bottom: 8px;
+ }
+ }
}
}
+
+ &.notificationsStackAxis-horizontal {
+ width: 100%;
+
+ &.notificationsPosition-leftTop,
+ &.notificationsPosition-leftBottom {
+ flex-direction: row;
+
+ .notification {
+ & + .notification {
+ margin-left: 8px;
+ }
+ }
+ }
+
+ &.notificationsPosition-rightTop,
+ &.notificationsPosition-rightBottom {
+ flex-direction: row-reverse;
+
+ .notification {
+ & + .notification {
+ margin-right: 8px;
+ }
+ }
+ }
+
+ .notification {
+ width: 250px;
+ flex-shrink: 0;
+ }
+ }
+}
+
+.notification {
+ container-type: inline-size;
}
</style>
diff --git a/packages/frontend/src/ui/deck/channel-column.vue b/packages/frontend/src/ui/deck/channel-column.vue
index ff0cba33ac..9605d1b22e 100644
--- a/packages/frontend/src/ui/deck/channel-column.vue
+++ b/packages/frontend/src/ui/deck/channel-column.vue
@@ -40,7 +40,7 @@ if (props.column.channelId == null) {
}
async function setChannel() {
- const channels = await os.api('channels/followed', {
+ const channels = await os.api('channels/my-favorites', {
limit: 100,
});
const { canceled, result: channel } = await os.select({
diff --git a/packages/frontend/src/ui/deck/column.vue b/packages/frontend/src/ui/deck/column.vue
index e895847bd9..402bbe0352 100644
--- a/packages/frontend/src/ui/deck/column.vue
+++ b/packages/frontend/src/ui/deck/column.vue
@@ -22,7 +22,7 @@
<span :class="$style.title"><slot name="header"></slot></span>
<button v-tooltip="i18n.ts.settings" :class="$style.menu" class="_button" @click.stop="showSettingsMenu"><i class="ti ti-dots"></i></button>
</header>
- <div v-show="active" ref="body" v-container :class="$style.body">
+ <div v-show="active" ref="body" :class="$style.body">
<slot></slot>
</div>
</section>
@@ -243,7 +243,7 @@ function onDrop(ev) {
<style lang="scss" module>
.root {
--root-margin: 10px;
- --deckColumnHeaderHeight: 40px;
+ --deckColumnHeaderHeight: 38px;
height: 100%;
overflow: clip;
@@ -318,10 +318,7 @@ function onDrop(ev) {
background: var(--panelHeaderBg);
box-shadow: 0 1px 0 0 var(--panelHeaderDivider);
cursor: pointer;
-
- &, * {
- user-select: none;
- }
+ user-select: none;
}
.title {
@@ -365,7 +362,7 @@ function onDrop(ev) {
overflow-x: clip;
-webkit-overflow-scrolling: touch;
box-sizing: border-box;
- container-type: inline-size;
+ container-type: size;
background-color: var(--bg);
}
</style>
diff --git a/packages/frontend/src/ui/universal.vue b/packages/frontend/src/ui/universal.vue
index 2462967515..27d0c26ac4 100644
--- a/packages/frontend/src/ui/universal.vue
+++ b/packages/frontend/src/ui/universal.vue
@@ -2,7 +2,7 @@
<div :class="[$style.root, { [$style.withWallpaper]: wallpaper }]">
<XSidebar v-if="!isMobile" :class="$style.sidebar"/>
- <MkStickyContainer v-container :class="$style.contents">
+ <MkStickyContainer :class="$style.contents">
<template #header><XStatusBars :class="$style.statusbars"/></template>
<main style="min-width: 0;" :style="{ background: pageMetadata?.value?.bg }" @contextmenu.stop="onContextmenu">
<div :class="$style.content" style="container-type: inline-size;">
diff --git a/packages/frontend/src/ui/universal.widgets.vue b/packages/frontend/src/ui/universal.widgets.vue
index d11649c603..3e0c38bb83 100644
--- a/packages/frontend/src/ui/universal.widgets.vue
+++ b/packages/frontend/src/ui/universal.widgets.vue
@@ -3,7 +3,7 @@
<XWidgets :class="$style.widgets" :edit="editMode" :widgets="widgets" @add-widget="addWidget" @remove-widget="removeWidget" @update-widget="updateWidget" @update-widgets="updateWidgets" @exit="editMode = false"/>
<button v-if="editMode" class="_textButton" style="font-size: 0.9em;" @click="editMode = false"><i class="ti ti-check"></i> {{ i18n.ts.editWidgetsExit }}</button>
- <button v-else class="_textButton mk-widget-edit" :class="$style.edit" style="font-size: 0.9em;" @click="editMode = true"><i class="ti ti-pencil"></i> {{ i18n.ts.editWidgets }}</button>
+ <button v-else class="_textButton" data-cy-widget-edit :class="$style.edit" style="font-size: 0.9em;" @click="editMode = true"><i class="ti ti-pencil"></i> {{ i18n.ts.editWidgets }}</button>
</div>
</template>
diff --git a/packages/frontend/src/ui/visitor.vue b/packages/frontend/src/ui/visitor.vue
index 6c96440ebd..623abbda39 100644
--- a/packages/frontend/src/ui/visitor.vue
+++ b/packages/frontend/src/ui/visitor.vue
@@ -1,19 +1,286 @@
<template>
-<DesignB/>
+<div class="mk-app">
+ <a v-if="root" href="https://github.com/misskey-dev/misskey" target="_blank" class="github-corner" aria-label="View source on GitHub"><svg width="80" height="80" viewBox="0 0 250 250" style="fill:var(--panel); color:var(--fg); position: fixed; z-index: 10; top: 0; border: 0; right: 0;" aria-hidden="true"><path d="M0,0 L115,115 L130,115 L142,142 L250,250 L250,0 Z"></path><path d="M128.3,109.0 C113.8,99.7 119.0,89.6 119.0,89.6 C122.0,82.7 120.5,78.6 120.5,78.6 C119.2,72.0 123.4,76.3 123.4,76.3 C127.3,80.9 125.5,87.3 125.5,87.3 C122.9,97.6 130.6,101.9 134.4,103.2" fill="currentColor" style="transform-origin: 130px 106px;" class="octo-arm"></path><path d="M115.0,115.0 C114.9,115.1 118.7,116.5 119.8,115.4 L133.7,101.6 C136.9,99.2 139.9,98.4 142.2,98.6 C133.8,88.0 127.5,74.4 143.8,58.0 C148.5,53.4 154.0,51.2 159.7,51.0 C160.3,49.4 163.2,43.6 171.4,40.1 C171.4,40.1 176.1,42.5 178.8,56.2 C183.1,58.6 187.2,61.8 190.9,65.4 C194.5,69.0 197.7,73.2 200.1,77.6 C213.8,80.2 216.3,84.9 216.3,84.9 C212.7,93.1 206.9,96.0 205.4,96.6 C205.1,102.4 203.0,107.8 198.3,112.5 C181.9,128.9 168.3,122.5 157.7,114.1 C157.9,116.9 156.7,120.9 152.7,124.9 L141.0,136.5 C139.8,137.7 141.6,141.9 141.8,141.8 Z" fill="currentColor" class="octo-body"></path></svg></a>
+
+ <div v-if="!narrow && !root" class="side">
+ <div class="banner" :style="{ backgroundImage: instance.backgroundImageUrl ? `url(${ instance.backgroundImageUrl })` : 'none' }"></div>
+ <div class="dashboard">
+ <MkVisitorDashboard/>
+ </div>
+ </div>
+
+ <div class="main">
+ <div v-if="!root" class="header">
+ <div v-if="narrow === false" class="wide">
+ <MkA to="/" class="link" active-class="active"><i class="ti ti-home icon"></i> {{ i18n.ts.home }}</MkA>
+ <MkA v-if="isTimelineAvailable" to="/timeline" class="link" active-class="active"><i class="ti ti-message icon"></i> {{ i18n.ts.timeline }}</MkA>
+ <MkA to="/explore" class="link" active-class="active"><i class="ti ti-hash icon"></i> {{ i18n.ts.explore }}</MkA>
+ <MkA to="/channels" class="link" active-class="active"><i class="ti ti-device-tv icon"></i> {{ i18n.ts.channel }}</MkA>
+ </div>
+ <div v-else-if="narrow === true" class="narrow">
+ <button class="menu _button" @click="showMenu = true">
+ <i class="ti ti-menu-2 icon"></i>
+ </button>
+ </div>
+ </div>
+ <div class="contents">
+ <main v-if="!root" style="container-type: inline-size;">
+ <RouterView/>
+ </main>
+ <main v-else>
+ <RouterView/>
+ </main>
+ </div>
+ </div>
+
+ <Transition :name="'tray-back'">
+ <div
+ v-if="showMenu"
+ class="menu-back _modalBg"
+ @click="showMenu = false"
+ @touchstart.passive="showMenu = false"
+ ></div>
+ </Transition>
+
+ <Transition :name="'tray'">
+ <div v-if="showMenu" class="menu">
+ <MkA to="/" class="link" active-class="active"><i class="ti ti-home icon"></i>{{ i18n.ts.home }}</MkA>
+ <MkA v-if="isTimelineAvailable" to="/timeline" class="link" active-class="active"><i class="ti ti-message icon"></i>{{ i18n.ts.timeline }}</MkA>
+ <MkA to="/explore" class="link" active-class="active"><i class="ti ti-hash icon"></i>{{ i18n.ts.explore }}</MkA>
+ <MkA to="/announcements" class="link" active-class="active"><i class="ti ti-speakerphone icon"></i>{{ i18n.ts.announcements }}</MkA>
+ <MkA to="/channels" class="link" active-class="active"><i class="ti ti-device-tv icon"></i>{{ i18n.ts.channel }}</MkA>
+ <div class="divider"></div>
+ <MkA to="/pages" class="link" active-class="active"><i class="ti ti-news icon"></i>{{ i18n.ts.pages }}</MkA>
+ <MkA to="/play" class="link" active-class="active"><i class="ti ti-player-play icon"></i>Play</MkA>
+ <MkA to="/gallery" class="link" active-class="active"><i class="ti ti-icons icon"></i>{{ i18n.ts.gallery }}</MkA>
+ <div class="action">
+ <button class="_buttonPrimary" @click="signup()">{{ i18n.ts.signup }}</button>
+ <button class="_button" @click="signin()">{{ i18n.ts.login }}</button>
+ </div>
+ </div>
+ </Transition>
+</div>
<XCommon/>
</template>
-<script lang="ts">
-import { defineComponent } from 'vue';
-//import DesignA from './visitor/a.vue';
-import DesignB from './visitor/b.vue';
+<script lang="ts" setup>
+import { ComputedRef, onMounted, provide } from 'vue';
import XCommon from './_common_/common.vue';
+import { host, instanceName } from '@/config';
+import * as os from '@/os';
+import { instance } from '@/instance';
+import XSigninDialog from '@/components/MkSigninDialog.vue';
+import XSignupDialog from '@/components/MkSignupDialog.vue';
+import { ColdDeviceStorage, defaultStore } from '@/store';
+import { mainRouter } from '@/router';
+import { PageMetadata, provideMetadataReceiver } from '@/scripts/page-metadata';
+import { i18n } from '@/i18n';
+import MkVisitorDashboard from '@/components/MkVisitorDashboard.vue';
+
+const DESKTOP_THRESHOLD = 1100;
+
+let pageMetadata = $ref<null | ComputedRef<PageMetadata>>();
+
+provide('router', mainRouter);
+provideMetadataReceiver((info) => {
+ pageMetadata = info;
+ if (pageMetadata.value) {
+ document.title = `${pageMetadata.value.title} | ${instanceName}`;
+ }
+});
+
+const announcements = {
+ endpoint: 'announcements',
+ limit: 10,
+};
+
+const isTimelineAvailable = $ref(instance.policies?.ltlAvailable || instance.policies?.gtlAvailable);
+
+let showMenu = $ref(false);
+let isDesktop = $ref(window.innerWidth >= DESKTOP_THRESHOLD);
+let narrow = $ref(window.innerWidth < 1280);
+let meta = $ref();
-export default defineComponent({
- components: {
- XCommon,
- //DesignA,
- DesignB,
- },
+const keymap = $computed(() => {
+ return {
+ 'd': () => {
+ if (ColdDeviceStorage.get('syncDeviceDarkMode')) return;
+ defaultStore.set('darkMode', !defaultStore.state.darkMode);
+ },
+ 's': () => {
+ mainRouter.push('/search');
+ },
+ };
+});
+
+const root = $computed(() => mainRouter.currentRoute.value.name === 'index');
+
+os.api('meta', { detail: true }).then(res => {
+ meta = res;
+});
+
+function signin() {
+ os.popup(XSigninDialog, {
+ autoSet: true,
+ }, {}, 'closed');
+}
+
+function signup() {
+ os.popup(XSignupDialog, {
+ autoSet: true,
+ }, {}, 'closed');
+}
+
+onMounted(() => {
+ if (!isDesktop) {
+ window.addEventListener('resize', () => {
+ if (window.innerWidth >= DESKTOP_THRESHOLD) isDesktop = true;
+ }, { passive: true });
+ }
+});
+
+defineExpose({
+ showMenu: $$(showMenu),
});
</script>
+
+<style>
+.github-corner:hover .octo-arm{animation:octocat-wave 560ms ease-in-out}@keyframes octocat-wave{0%,100%{transform:rotate(0)}20%,60%{transform:rotate(-25deg)}40%,80%{transform:rotate(10deg)}}@media (max-width:500px){.github-corner:hover .octo-arm{animation:none}.github-corner .octo-arm{animation:octocat-wave 560ms ease-in-out}}
+</style>
+
+<style lang="scss" scoped>
+.tray-enter-active,
+.tray-leave-active {
+ opacity: 1;
+ transform: translateX(0);
+ transition: transform 300ms cubic-bezier(0.23, 1, 0.32, 1), opacity 300ms cubic-bezier(0.23, 1, 0.32, 1);
+}
+.tray-enter-from,
+.tray-leave-active {
+ opacity: 0;
+ transform: translateX(-240px);
+}
+
+.tray-back-enter-active,
+.tray-back-leave-active {
+ opacity: 1;
+ transition: opacity 300ms cubic-bezier(0.23, 1, 0.32, 1);
+}
+.tray-back-enter-from,
+.tray-back-leave-active {
+ opacity: 0;
+}
+
+.mk-app {
+ display: flex;
+ min-height: 100vh;
+
+ > .side {
+ position: sticky;
+ top: 0;
+ left: 0;
+ width: 500px;
+ height: 100vh;
+ background: var(--accent);
+
+ > .banner {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ aspect-ratio: 1.5;
+ background-position: center;
+ background-size: cover;
+ -webkit-mask-image: linear-gradient(rgba(0, 0, 0, 1.0), transparent);
+ mask-image: linear-gradient(rgba(0, 0, 0, 1.0), transparent);
+ }
+
+ > .dashboard {
+ position: relative;
+ padding: 32px;
+ box-sizing: border-box;
+ max-height: 100%;
+ overflow: auto;
+ }
+ }
+
+ > .main {
+ flex: 1;
+ min-width: 0;
+
+ > .header {
+ background: var(--panel);
+
+ > .wide {
+ line-height: 50px;
+ padding: 0 16px;
+
+ > .link {
+ padding: 0 16px;
+ }
+ }
+
+ > .narrow {
+ > .menu {
+ padding: 16px;
+ }
+ }
+ }
+ }
+
+ > .menu-back {
+ position: fixed;
+ z-index: 1001;
+ top: 0;
+ left: 0;
+ width: 100vw;
+ height: 100vh;
+ }
+
+ > .menu {
+ position: fixed;
+ z-index: 1001;
+ top: 0;
+ left: 0;
+ width: 240px;
+ height: 100vh;
+ background: var(--panel);
+
+ > .link {
+ display: block;
+ padding: 16px;
+
+ > .icon {
+ margin-right: 1em;
+ }
+ }
+
+ > .divider {
+ margin: 8px auto;
+ width: calc(100% - 32px);
+ border-top: solid 0.5px var(--divider);
+ }
+
+ > .action {
+ padding: 16px;
+
+ > button {
+ display: block;
+ width: 100%;
+ padding: 10px;
+ box-sizing: border-box;
+ text-align: center;
+ border-radius: 999px;
+
+ &._button {
+ background: var(--panel);
+ }
+
+ &:first-child {
+ margin-bottom: 16px;
+ }
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/frontend/src/ui/visitor/a.vue b/packages/frontend/src/ui/visitor/a.vue
deleted file mode 100644
index 4761036075..0000000000
--- a/packages/frontend/src/ui/visitor/a.vue
+++ /dev/null
@@ -1,263 +0,0 @@
-<template>
-<div class="mk-app">
- <div v-if="mainRouter.currentRoute?.name === 'index'" class="banner" :style="{ backgroundImage: `url(${ instance.bannerUrl })` }">
- <div>
- <h1 v-if="meta"><img v-if="meta.logoImageUrl" class="logo" :src="meta.logoImageUrl"><span v-else class="text">{{ instanceName }}</span></h1>
- <div v-if="meta" class="about">
- <!-- eslint-disable-next-line vue/no-v-html -->
- <div class="desc" v-html="meta.description || i18n.ts.introMisskey"></div>
- </div>
- <div class="action">
- <button class="_button primary" @click="signup()">{{ i18n.ts.signup }}</button>
- <button class="_button" @click="signin()">{{ i18n.ts.login }}</button>
- </div>
- </div>
- </div>
- <div v-else class="banner-mini" :style="{ backgroundImage: `url(${ instance.bannerUrl })` }">
- <div>
- <h1 v-if="meta"><img v-if="meta.logoImageUrl" class="logo" :src="meta.logoImageUrl"><span v-else class="text">{{ instanceName }}</span></h1>
- </div>
- </div>
-
- <div class="main">
- <div ref="contents" class="contents" :class="{ wallpaper }">
- <header v-show="mainRouter.currentRoute?.name !== 'index'" ref="header" class="header">
- <XHeader :info="pageInfo"/>
- </header>
- <main ref="main" style="container-type: inline-size;">
- <RouterView/>
- </main>
- <div class="powered-by">
- <b><MkA to="/">{{ host }}</MkA></b>
- <small>Powered by <a href="https://github.com/misskey-dev/misskey" target="_blank">Misskey</a></small>
- </div>
- </div>
- </div>
-</div>
-</template>
-
-<script lang="ts">
-import { defineComponent } from 'vue';
-import XHeader from './header.vue';
-import { host, instanceName } from '@/config';
-import * as os from '@/os';
-import MkButton from '@/components/MkButton.vue';
-import { defaultStore, ColdDeviceStorage } from '@/store';
-import { mainRouter } from '@/router';
-import { instance } from '@/instance';
-import { i18n } from '@/i18n';
-
-const DESKTOP_THRESHOLD = 1100;
-
-export default defineComponent({
- components: {
- XHeader,
- MkButton,
- },
-
- data() {
- return {
- host,
- instanceName,
- pageInfo: null,
- meta: null,
- narrow: window.innerWidth < 1280,
- announcements: {
- endpoint: 'announcements',
- limit: 10,
- },
- mainRouter,
- isDesktop: window.innerWidth >= DESKTOP_THRESHOLD,
- defaultStore,
- instance,
- i18n,
- };
- },
-
- computed: {
- keymap(): any {
- return {
- 'd': () => {
- if (ColdDeviceStorage.get('syncDeviceDarkMode')) return;
- this.defaultStore.set('darkMode', !this.defaultStore.state.darkMode);
- },
- 's': () => {
- mainRouter.push('/search');
- },
- 'h|/': this.help,
- };
- },
- },
-
- created() {
- document.documentElement.style.overflowY = 'scroll';
-
- os.api('meta', { detail: true }).then(meta => {
- this.meta = meta;
- });
- },
-
- mounted() {
- if (!this.isDesktop) {
- window.addEventListener('resize', () => {
- if (window.innerWidth >= DESKTOP_THRESHOLD) this.isDesktop = true;
- }, { passive: true });
- }
- },
-
- methods: {
- // @ThatOneCalculator: Are these methods even used?
- // I can't find references to them anywhere else in the code...
-
- // setParallax(el) {
- // new simpleParallax(el);
- // },
-
- changePage(page) {
- if (page == null) return;
- // eslint-disable-next-line no-undef
- if (page[symbols.PAGE_INFO]) {
- // eslint-disable-next-line no-undef
- this.pageInfo = page[symbols.PAGE_INFO];
- }
- },
-
- top() {
- window.scroll({ top: 0, behavior: 'smooth' });
- },
-
- help() {
- window.open('https://misskey-hub.net/docs/keyboard-shortcut.md', '_blank');
- },
- },
-});
-</script>
-
-<style lang="scss" scoped>
-.mk-app {
- min-height: 100vh;
-
- > .banner {
- position: relative;
- width: 100%;
- text-align: center;
- background-position: center;
- background-size: cover;
-
- > div {
- height: 100%;
- background: rgba(0, 0, 0, 0.3);
-
- * {
- color: #fff;
- }
-
- > h1 {
- margin: 0;
- padding: 96px 32px 0 32px;
- text-shadow: 0 0 8px black;
-
- > .logo {
- vertical-align: bottom;
- max-height: 150px;
- }
- }
-
- > .about {
- padding: 32px;
- max-width: 580px;
- margin: 0 auto;
- box-sizing: border-box;
- text-shadow: 0 0 8px black;
- }
-
- > .action {
- padding-bottom: 64px;
-
- > button {
- display: inline-block;
- padding: 10px 20px;
- box-sizing: border-box;
- text-align: center;
- border-radius: 999px;
- background: var(--panel);
- color: var(--fg);
-
- &.primary {
- background: var(--accent);
- color: #fff;
- }
-
- &:first-child {
- margin-right: 16px;
- }
- }
- }
- }
- }
-
- > .banner-mini {
- position: relative;
- width: 100%;
- text-align: center;
- background-position: center;
- background-size: cover;
-
- > div {
- position: relative;
- z-index: 1;
- height: 100%;
- background: rgba(0, 0, 0, 0.3);
-
- * {
- color: #fff !important;
- }
-
- > header {
-
- }
-
- > h1 {
- margin: 0;
- padding: 32px;
- text-shadow: 0 0 8px black;
-
- > .logo {
- vertical-align: bottom;
- max-height: 100px;
- }
- }
- }
- }
-
- > .main {
- > .contents {
- position: relative;
- z-index: 1;
-
- > .header {
- position: sticky;
- top: 0;
- left: 0;
- z-index: 1000;
- }
-
- > .powered-by {
- padding: 28px;
- font-size: 14px;
- text-align: center;
- border-top: 1px solid var(--divider);
-
- > small {
- display: block;
- margin-top: 8px;
- opacity: 0.5;
- }
- }
- }
- }
-}
-</style>
-
-<style lang="scss">
-</style>
diff --git a/packages/frontend/src/ui/visitor/b.vue b/packages/frontend/src/ui/visitor/b.vue
deleted file mode 100644
index 5287a670c5..0000000000
--- a/packages/frontend/src/ui/visitor/b.vue
+++ /dev/null
@@ -1,266 +0,0 @@
-<template>
-<div class="mk-app">
- <a v-if="root" href="https://github.com/misskey-dev/misskey" target="_blank" class="github-corner" aria-label="View source on GitHub"><svg width="80" height="80" viewBox="0 0 250 250" style="fill:var(--panel); color:var(--fg); position: fixed; z-index: 10; top: 0; border: 0; right: 0;" aria-hidden="true"><path d="M0,0 L115,115 L130,115 L142,142 L250,250 L250,0 Z"></path><path d="M128.3,109.0 C113.8,99.7 119.0,89.6 119.0,89.6 C122.0,82.7 120.5,78.6 120.5,78.6 C119.2,72.0 123.4,76.3 123.4,76.3 C127.3,80.9 125.5,87.3 125.5,87.3 C122.9,97.6 130.6,101.9 134.4,103.2" fill="currentColor" style="transform-origin: 130px 106px;" class="octo-arm"></path><path d="M115.0,115.0 C114.9,115.1 118.7,116.5 119.8,115.4 L133.7,101.6 C136.9,99.2 139.9,98.4 142.2,98.6 C133.8,88.0 127.5,74.4 143.8,58.0 C148.5,53.4 154.0,51.2 159.7,51.0 C160.3,49.4 163.2,43.6 171.4,40.1 C171.4,40.1 176.1,42.5 178.8,56.2 C183.1,58.6 187.2,61.8 190.9,65.4 C194.5,69.0 197.7,73.2 200.1,77.6 C213.8,80.2 216.3,84.9 216.3,84.9 C212.7,93.1 206.9,96.0 205.4,96.6 C205.1,102.4 203.0,107.8 198.3,112.5 C181.9,128.9 168.3,122.5 157.7,114.1 C157.9,116.9 156.7,120.9 152.7,124.9 L141.0,136.5 C139.8,137.7 141.6,141.9 141.8,141.8 Z" fill="currentColor" class="octo-body"></path></svg></a>
-
- <div v-if="!narrow && !root" class="side">
- <XKanban class="kanban" full/>
- </div>
-
- <div class="main">
- <XKanban v-if="narrow && !root" class="banner" :powered-by="root"/>
-
- <div class="contents">
- <XHeader v-if="!root" class="header"/>
- <main v-if="!root" style="container-type: inline-size;">
- <RouterView/>
- </main>
- <main v-else>
- <RouterView/>
- </main>
- <div v-if="!root" class="powered-by">
- <b><MkA to="/">{{ host }}</MkA></b>
- <small>Powered by <a href="https://github.com/misskey-dev/misskey" target="_blank">Misskey</a></small>
- </div>
- </div>
- </div>
-
- <Transition :name="'tray-back'">
- <div
- v-if="showMenu"
- class="menu-back _modalBg"
- @click="showMenu = false"
- @touchstart.passive="showMenu = false"
- ></div>
- </Transition>
-
- <Transition :name="'tray'">
- <div v-if="showMenu" class="menu">
- <MkA to="/" class="link" active-class="active"><i class="ti ti-home icon"></i>{{ i18n.ts.home }}</MkA>
- <MkA v-if="isTimelineAvailable" to="/timeline" class="link" active-class="active"><i class="ti ti-message icon"></i>{{ i18n.ts.timeline }}</MkA>
- <MkA to="/explore" class="link" active-class="active"><i class="ti ti-hash icon"></i>{{ i18n.ts.explore }}</MkA>
- <MkA to="/announcements" class="link" active-class="active"><i class="ti ti-speakerphone icon"></i>{{ i18n.ts.announcements }}</MkA>
- <MkA to="/channels" class="link" active-class="active"><i class="ti ti-device-tv icon"></i>{{ i18n.ts.channel }}</MkA>
- <div class="divider"></div>
- <MkA to="/pages" class="link" active-class="active"><i class="ti ti-news icon"></i>{{ i18n.ts.pages }}</MkA>
- <MkA to="/play" class="link" active-class="active"><i class="ti ti-player-play icon"></i>Play</MkA>
- <MkA to="/gallery" class="link" active-class="active"><i class="ti ti-icons icon"></i>{{ i18n.ts.gallery }}</MkA>
- <div class="action">
- <button class="_buttonPrimary" @click="signup()">{{ i18n.ts.signup }}</button>
- <button class="_button" @click="signin()">{{ i18n.ts.login }}</button>
- </div>
- </div>
- </Transition>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { ComputedRef, onMounted, provide } from 'vue';
-import XHeader from './header.vue';
-import XKanban from './kanban.vue';
-import { host, instanceName } from '@/config';
-import * as os from '@/os';
-import { instance } from '@/instance';
-import XSigninDialog from '@/components/MkSigninDialog.vue';
-import XSignupDialog from '@/components/MkSignupDialog.vue';
-import { ColdDeviceStorage, defaultStore } from '@/store';
-import { mainRouter } from '@/router';
-import { PageMetadata, provideMetadataReceiver } from '@/scripts/page-metadata';
-import { i18n } from '@/i18n';
-
-const DESKTOP_THRESHOLD = 1100;
-
-let pageMetadata = $ref<null | ComputedRef<PageMetadata>>();
-
-provide('router', mainRouter);
-provideMetadataReceiver((info) => {
- pageMetadata = info;
- if (pageMetadata.value) {
- document.title = `${pageMetadata.value.title} | ${instanceName}`;
- }
-});
-
-const announcements = {
- endpoint: 'announcements',
- limit: 10,
-};
-
-const isTimelineAvailable = $ref(instance.policies?.ltlAvailable || instance.policies?.gtlAvailable);
-
-let showMenu = $ref(false);
-let isDesktop = $ref(window.innerWidth >= DESKTOP_THRESHOLD);
-let narrow = $ref(window.innerWidth < 1280);
-let meta = $ref();
-
-const keymap = $computed(() => {
- return {
- 'd': () => {
- if (ColdDeviceStorage.get('syncDeviceDarkMode')) return;
- defaultStore.set('darkMode', !defaultStore.state.darkMode);
- },
- 's': () => {
- mainRouter.push('/search');
- },
- };
-});
-
-const root = $computed(() => mainRouter.currentRoute.value.name === 'index');
-
-os.api('meta', { detail: true }).then(res => {
- meta = res;
-});
-
-function signin() {
- os.popup(XSigninDialog, {
- autoSet: true,
- }, {}, 'closed');
-}
-
-function signup() {
- os.popup(XSignupDialog, {
- autoSet: true,
- }, {}, 'closed');
-}
-
-onMounted(() => {
- if (!isDesktop) {
- window.addEventListener('resize', () => {
- if (window.innerWidth >= DESKTOP_THRESHOLD) isDesktop = true;
- }, { passive: true });
- }
-});
-
-defineExpose({
- showMenu: $$(showMenu),
-});
-</script>
-
-<style>
-.github-corner:hover .octo-arm{animation:octocat-wave 560ms ease-in-out}@keyframes octocat-wave{0%,100%{transform:rotate(0)}20%,60%{transform:rotate(-25deg)}40%,80%{transform:rotate(10deg)}}@media (max-width:500px){.github-corner:hover .octo-arm{animation:none}.github-corner .octo-arm{animation:octocat-wave 560ms ease-in-out}}
-</style>
-
-<style lang="scss" scoped>
-.tray-enter-active,
-.tray-leave-active {
- opacity: 1;
- transform: translateX(0);
- transition: transform 300ms cubic-bezier(0.23, 1, 0.32, 1), opacity 300ms cubic-bezier(0.23, 1, 0.32, 1);
-}
-.tray-enter-from,
-.tray-leave-active {
- opacity: 0;
- transform: translateX(-240px);
-}
-
-.tray-back-enter-active,
-.tray-back-leave-active {
- opacity: 1;
- transition: opacity 300ms cubic-bezier(0.23, 1, 0.32, 1);
-}
-.tray-back-enter-from,
-.tray-back-leave-active {
- opacity: 0;
-}
-
-.mk-app {
- display: flex;
- min-height: 100vh;
- background-position: center;
- background-size: cover;
- background-attachment: fixed;
-
- > .side {
- width: 500px;
- height: 100vh;
-
- > .kanban {
- position: fixed;
- top: 0;
- left: 0;
- width: 500px;
- height: 100vh;
- overflow: auto;
- }
- }
-
- > .main {
- flex: 1;
- min-width: 0;
-
- > .banner {
- }
-
- > .contents {
- position: relative;
- z-index: 1;
-
- > .powered-by {
- padding: 28px;
- font-size: 14px;
- text-align: center;
- border-top: 1px solid var(--divider);
-
- > small {
- display: block;
- margin-top: 8px;
- opacity: 0.5;
- }
- }
- }
- }
-
- > .menu-back {
- position: fixed;
- z-index: 1001;
- top: 0;
- left: 0;
- width: 100vw;
- height: 100vh;
- }
-
- > .menu {
- position: fixed;
- z-index: 1001;
- top: 0;
- left: 0;
- width: 240px;
- height: 100vh;
- background: var(--panel);
-
- > .link {
- display: block;
- padding: 16px;
-
- > .icon {
- margin-right: 1em;
- }
- }
-
- > .divider {
- margin: 8px auto;
- width: calc(100% - 32px);
- border-top: solid 0.5px var(--divider);
- }
-
- > .action {
- padding: 16px;
-
- > button {
- display: block;
- width: 100%;
- padding: 10px;
- box-sizing: border-box;
- text-align: center;
- border-radius: 999px;
-
- &._button {
- background: var(--panel);
- }
-
- &:first-child {
- margin-bottom: 16px;
- }
- }
- }
- }
-}
-</style>
diff --git a/packages/frontend/src/ui/visitor/header.vue b/packages/frontend/src/ui/visitor/header.vue
deleted file mode 100644
index 7de81f6431..0000000000
--- a/packages/frontend/src/ui/visitor/header.vue
+++ /dev/null
@@ -1,211 +0,0 @@
-<template>
-<div class="sqxihjet">
- <div v-if="narrow === false" class="wide">
- <div class="content">
- <MkA to="/" class="link" active-class="active"><i class="ti ti-home icon"></i>{{ i18n.ts.home }}</MkA>
- <MkA v-if="isTimelineAvailable" to="/timeline" class="link" active-class="active"><i class="ti ti-message icon"></i>{{ i18n.ts.timeline }}</MkA>
- <MkA to="/explore" class="link" active-class="active"><i class="ti ti-hash icon"></i>{{ i18n.ts.explore }}</MkA>
- <MkA to="/channels" class="link" active-class="active"><i class="ti ti-device-tv icon"></i>{{ i18n.ts.channel }}</MkA>
- <div class="right">
- <button class="_button search" @click="search()"><i class="ti ti-search icon"></i><span>{{ i18n.ts.search }}</span></button>
- <button class="_buttonPrimary signup" @click="signup()">{{ i18n.ts.signup }}</button>
- <button class="_button login" @click="signin()">{{ i18n.ts.login }}</button>
- </div>
- </div>
- </div>
- <div v-else-if="narrow === true" class="narrow">
- <button class="menu _button" @click="$parent.showMenu = true">
- <i class="ti ti-menu-2 icon"></i>
- </button>
- </div>
-</div>
-</template>
-
-<script lang="ts">
-import { defineComponent } from 'vue';
-import XSigninDialog from '@/components/MkSigninDialog.vue';
-import XSignupDialog from '@/components/MkSignupDialog.vue';
-import * as os from '@/os';
-import { instance } from '@/instance';
-import { mainRouter } from '@/router';
-import { i18n } from '@/i18n';
-
-export default defineComponent({
- data() {
- return {
- narrow: null,
- showMenu: false,
- isTimelineAvailable: instance.policies.ltlAvailable || instance.policies.gtlAvailable,
- i18n,
- };
- },
-
- mounted() {
- this.narrow = this.$el.clientWidth < 1300;
- },
-
- methods: {
- signin() {
- os.popup(XSigninDialog, {
- autoSet: true,
- }, {}, 'closed');
- },
-
- signup() {
- os.popup(XSignupDialog, {
- autoSet: true,
- }, {}, 'closed');
- },
-
- search() {
- mainRouter.push('/search');
- },
- },
-});
-</script>
-
-<style lang="scss" scoped>
-.sqxihjet {
- $height: 50px;
- position: sticky;
- width: 50px;
- top: 0;
- left: 0;
- z-index: 1000;
- line-height: $height;
- -webkit-backdrop-filter: var(--blur, blur(32px));
- backdrop-filter: var(--blur, blur(32px));
- background-color: var(--X16);
-
- > .wide {
- > .content {
- max-width: 1400px;
- margin: 0 auto;
- display: flex;
- align-items: center;
-
- > .link {
- $line: 3px;
- display: inline-block;
- padding: 0 16px;
- line-height: $height - ($line * 2);
- border-top: solid $line transparent;
- border-bottom: solid $line transparent;
-
- > .icon {
- margin-right: 0.5em;
- }
-
- &.page {
- border-bottom-color: var(--accent);
- }
- }
-
- > .page {
- > .title {
- display: inline-block;
- vertical-align: bottom;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- position: relative;
-
- > .icon + .text {
- margin-left: 8px;
- }
-
- > .avatar {
- $size: 32px;
- display: inline-block;
- width: $size;
- height: $size;
- vertical-align: middle;
- margin-right: 8px;
- pointer-events: none;
- }
-
- &._button {
- &:hover {
- color: var(--fgHighlighted);
- }
- }
-
- &.selected {
- box-shadow: 0 -2px 0 0 var(--accent) inset;
- color: var(--fgHighlighted);
- }
- }
-
- > .action {
- padding: 0 0 0 16px;
- }
- }
-
- > .right {
- margin-left: auto;
-
- > .search {
- background: var(--bg);
- border-radius: 999px;
- width: 230px;
- line-height: $height - 20px;
- margin-right: 16px;
- text-align: left;
-
- > * {
- opacity: 0.7;
- }
-
- > .icon {
- padding: 0 16px;
- }
- }
-
- > .signup {
- border-radius: 999px;
- padding: 0 24px;
- line-height: $height - 20px;
- }
-
- > .login {
- padding: 0 16px;
- }
- }
- }
- }
-
- > .narrow {
- display: flex;
-
- > .menu,
- > .action {
- width: $height;
- height: $height;
- font-size: 20px;
- }
-
- > .title {
- flex: 1;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- position: relative;
- text-align: center;
-
- > .icon + .text {
- margin-left: 8px;
- }
-
- > .avatar {
- $size: 32px;
- display: inline-block;
- width: $size;
- height: $size;
- vertical-align: middle;
- margin-right: 8px;
- pointer-events: none;
- }
- }
- }
-}
-</style>
diff --git a/packages/frontend/src/ui/visitor/kanban.vue b/packages/frontend/src/ui/visitor/kanban.vue
deleted file mode 100644
index ce7fcfe944..0000000000
--- a/packages/frontend/src/ui/visitor/kanban.vue
+++ /dev/null
@@ -1,261 +0,0 @@
-<!-- eslint-disable vue/no-v-html -->
-<template>
-<div class="rwqkcmrc" :style="{ backgroundImage: transparent ? 'none' : `url(${ instance.backgroundImageUrl })` }">
- <div class="back" :class="{ transparent }"></div>
- <div class="contents">
- <div class="wrapper">
- <h1 v-if="meta" :class="{ full }">
- <MkA to="/" class="link"><img v-if="meta.logoImageUrl" class="logo" :src="meta.logoImageUrl" alt="logo"><span v-else class="text">{{ instanceName }}</span></MkA>
- </h1>
- <template v-if="full">
- <div v-if="meta" class="about">
- <div class="desc" v-html="meta.description || i18n.ts.introMisskey"></div>
- </div>
- <div class="action">
- <button class="_buttonPrimary" @click="signup()">{{ i18n.ts.signup }}</button>
- <button class="_button" @click="signin()">{{ i18n.ts.login }}</button>
- </div>
- <div class="announcements panel">
- <header>{{ i18n.ts.announcements }}</header>
- <MkPagination v-slot="{items}" :pagination="announcements" class="list">
- <section v-for="announcement in items" :key="announcement.id" class="item">
- <div class="title">{{ announcement.title }}</div>
- <div class="content">
- <Mfm :text="announcement.text"/>
- <img v-if="announcement.imageUrl" :src="announcement.imageUrl" alt="announcement image"/>
- </div>
- </section>
- </MkPagination>
- </div>
- <div v-if="poweredBy" class="powered-by">
- <b><MkA to="/">{{ host }}</MkA></b>
- <small>Powered by <a href="https://github.com/misskey-dev/misskey" target="_blank">Misskey</a></small>
- </div>
- </template>
- </div>
- </div>
-</div>
-</template>
-
-<script lang="ts">
-import { defineComponent } from 'vue';
-import { host, instanceName } from '@/config';
-import * as os from '@/os';
-import MkPagination from '@/components/MkPagination.vue';
-import XSigninDialog from '@/components/MkSigninDialog.vue';
-import XSignupDialog from '@/components/MkSignupDialog.vue';
-import MkButton from '@/components/MkButton.vue';
-import { instance } from '@/instance';
-import { i18n } from '@/i18n';
-
-export default defineComponent({
- components: {
- MkPagination,
- MkButton,
- },
-
- props: {
- full: {
- type: Boolean,
- required: false,
- default: false,
- },
- transparent: {
- type: Boolean,
- required: false,
- default: false,
- },
- poweredBy: {
- type: Boolean,
- required: false,
- default: false,
- },
- },
-
- data() {
- return {
- host,
- instanceName,
- pageInfo: null,
- meta: null,
- narrow: window.innerWidth < 1280,
- announcements: {
- endpoint: 'announcements',
- limit: 10,
- },
- instance,
- i18n,
- };
- },
-
- created() {
- os.api('meta', { detail: true }).then(meta => {
- this.meta = meta;
- });
- },
-
- methods: {
- signin() {
- os.popup(XSigninDialog, {
- autoSet: true,
- }, {}, 'closed');
- },
-
- signup() {
- os.popup(XSignupDialog, {
- autoSet: true,
- }, {}, 'closed');
- },
- },
-});
-</script>
-
-<style lang="scss" scoped>
-.rwqkcmrc {
- position: relative;
- text-align: center;
- background-position: center;
- background-size: cover;
- // TODO: パララックスにしたい
-
- > .back {
- position: absolute;
- top: 0;
- left: 0;
- width: 100%;
- height: 100%;
- background: rgba(0, 0, 0, 0.3);
-
- &.transparent {
- -webkit-backdrop-filter: var(--blur, blur(12px));
- backdrop-filter: var(--blur, blur(12px));
- }
- }
-
- > .contents {
- position: relative;
- z-index: 1;
- height: inherit;
- overflow: auto;
-
- > .wrapper {
- max-width: 380px;
- padding: 0 16px;
- box-sizing: border-box;
- margin: 0 auto;
-
- > .panel {
- -webkit-backdrop-filter: var(--blur, blur(8px));
- backdrop-filter: var(--blur, blur(8px));
- background: rgba(0, 0, 0, 0.5);
- border-radius: var(--radius);
-
- &, * {
- color: #fff !important;
- }
- }
-
- > h1 {
- display: block;
- margin: 0;
- padding: 32px 0 32px 0;
- color: #fff;
-
- &.full {
- padding: 64px 0 0 0;
-
- > .link {
- > ::v-deep(.logo) {
- max-height: 130px;
- }
- }
- }
-
- > .link {
- display: block;
-
- > ::v-deep(.logo) {
- vertical-align: bottom;
- max-height: 100px;
- }
- }
- }
-
- > .about {
- display: block;
- margin: 24px 0;
- text-align: center;
- box-sizing: border-box;
- text-shadow: 0 0 8px black;
- color: #fff;
- }
-
- > .action {
- > button {
- display: block;
- width: 100%;
- padding: 10px;
- box-sizing: border-box;
- text-align: center;
- border-radius: 999px;
-
- &._button {
- background: var(--panel);
- }
-
- &:first-child {
- margin-bottom: 16px;
- }
- }
- }
-
- > .announcements {
- margin: 32px 0;
- text-align: left;
-
- > header {
- padding: 12px 16px;
- border-bottom: solid 1px rgba(255, 255, 255, 0.5);
- }
-
- > .list {
- max-height: 300px;
- overflow: auto;
-
- > .item {
- padding: 12px 16px;
-
- & + .item {
- border-top: solid 1px rgba(255, 255, 255, 0.5);
- }
-
- > .title {
- font-weight: bold;
- }
-
- > .content {
- > img {
- max-width: 100%;
- }
- }
- }
- }
- }
-
- > .powered-by {
- padding: 28px;
- font-size: 14px;
- text-align: center;
- border-top: 1px solid rgba(255, 255, 255, 0.5);
- color: #fff;
-
- > small {
- display: block;
- margin-top: 8px;
- opacity: 0.5;
- }
- }
- }
- }
-}
-</style>
diff --git a/packages/frontend/tsconfig.json b/packages/frontend/tsconfig.json
index 4d582daa3c..514b304246 100644
--- a/packages/frontend/tsconfig.json
+++ b/packages/frontend/tsconfig.json
@@ -27,10 +27,12 @@
},
"typeRoots": [
"node_modules/@types",
+ "node_modules/@vue-macros",
"@types",
],
"types": [
"vite/client",
+ "reactivity-transform/macros-global"
],
"lib": [
"esnext",
diff --git a/packages/frontend/vite.config.ts b/packages/frontend/vite.config.ts
index 425f3aa45d..fad0dd0177 100644
--- a/packages/frontend/vite.config.ts
+++ b/packages/frontend/vite.config.ts
@@ -1,6 +1,9 @@
import path from 'path';
+import pluginReplace from '@rollup/plugin-replace';
import pluginVue from '@vitejs/plugin-vue';
import { type UserConfig, defineConfig } from 'vite';
+// @ts-expect-error https://github.com/sxzz/unplugin-vue-macros/issues/257#issuecomment-1410752890
+import ReactivityTransform from '@vue-macros/reactivity-transform/vite';
import locales from '../../locales';
import meta from '../../package.json';
@@ -41,11 +44,26 @@ export function getConfig(): UserConfig {
return {
base: '/vite/',
+ server: {
+ port: 5173,
+ },
+
plugins: [
pluginVue({
reactivityTransform: true,
}),
+ ReactivityTransform(),
pluginJson5(),
+ ...process.env.NODE_ENV === 'production'
+ ? [
+ pluginReplace({
+ preventAssignment: true,
+ values: {
+ 'isChromatic()': JSON.stringify(false),
+ },
+ }),
+ ]
+ : [],
],
resolve: {
diff --git a/packages/misskey-js/etc/misskey-js.api.md b/packages/misskey-js/etc/misskey-js.api.md
index 67d12000b8..19e5b75443 100644
--- a/packages/misskey-js/etc/misskey-js.api.md
+++ b/packages/misskey-js/etc/misskey-js.api.md
@@ -1357,10 +1357,6 @@ export type Endpoints = {
req: TODO;
res: TODO;
};
- 'i/known-as': {
- req: TODO;
- res: TODO;
- };
'i/notifications': {
req: {
limit?: number;
@@ -1511,6 +1507,7 @@ export type Endpoints = {
mutedWords?: string[][];
mutingNotificationTypes?: Notification_2['type'][];
emailNotificationTypes?: string[];
+ alsoKnownAs?: string[];
};
res: MeDetailed;
};
@@ -2348,6 +2345,7 @@ type LiteInstanceMetadata = {
imageUrl: string;
}[];
translatorAvailable: boolean;
+ serverRules: string[];
};
// @public (undocumented)
@@ -2633,6 +2631,7 @@ type User = UserLite | UserDetailed;
// @public (undocumented)
type UserDetailed = UserLite & {
+ alsoKnownAs: string[];
bannerBlurhash: string | null;
bannerColor: string | null;
bannerUrl: string | null;
@@ -2663,6 +2662,7 @@ type UserDetailed = UserLite & {
lang: string | null;
lastFetchedAt?: DateString;
location: string | null;
+ movedTo: string;
notesCount: number;
pinnedNoteIds: ID[];
pinnedNotes: Note[];
@@ -2696,8 +2696,6 @@ type UserLite = {
onlineStatus: 'online' | 'active' | 'offline' | 'unknown';
avatarUrl: string;
avatarBlurhash: string;
- alsoKnownAs: string[];
- movedToUri: any;
emojis: {
name: string;
url: string;
diff --git a/packages/misskey-js/package.json b/packages/misskey-js/package.json
index 76e0452444..2e9bc76059 100644
--- a/packages/misskey-js/package.json
+++ b/packages/misskey-js/package.json
@@ -20,27 +20,27 @@
"url": "git+https://github.com/misskey-dev/misskey.js.git"
},
"devDependencies": {
- "@microsoft/api-extractor": "7.34.4",
- "@swc/jest": "0.2.24",
- "@types/jest": "29.5.0",
- "@types/node": "18.15.11",
- "@typescript-eslint/eslint-plugin": "5.57.1",
- "@typescript-eslint/parser": "5.57.1",
- "eslint": "8.37.0",
+ "@microsoft/api-extractor": "7.34.7",
+ "@swc/jest": "0.2.26",
+ "@types/jest": "29.5.1",
+ "@types/node": "18.16.3",
+ "@typescript-eslint/eslint-plugin": "5.59.2",
+ "@typescript-eslint/parser": "5.59.2",
+ "eslint": "8.39.0",
"jest": "29.5.0",
"jest-fetch-mock": "3.0.3",
"jest-websocket-mock": "2.4.0",
"mock-socket": "9.2.1",
"tsd": "0.28.1",
- "typescript": "5.0.3"
+ "typescript": "5.0.4"
},
"files": [
"built"
],
"dependencies": {
"@swc/cli": "0.1.62",
- "@swc/core": "1.3.46",
- "eventemitter3": "5.0.0",
+ "@swc/core": "1.3.56",
+ "eventemitter3": "5.0.1",
"reconnecting-websocket": "4.4.0"
}
}
diff --git a/packages/misskey-js/src/api.types.ts b/packages/misskey-js/src/api.types.ts
index aed9f5bf84..cc88c4b1a4 100644
--- a/packages/misskey-js/src/api.types.ts
+++ b/packages/misskey-js/src/api.types.ts
@@ -363,7 +363,6 @@ export type Endpoints = {
'i/import-following': { req: TODO; res: TODO; };
'i/import-user-lists': { req: TODO; res: TODO; };
'i/move': { req: TODO; res: TODO; };
- 'i/known-as': { req: TODO; res: TODO; };
'i/notifications': { req: {
limit?: number;
sinceId?: Notification['id'];
@@ -421,6 +420,7 @@ export type Endpoints = {
mutedWords?: string[][];
mutingNotificationTypes?: Notification['type'][];
emailNotificationTypes?: string[];
+ alsoKnownAs?: string[];
}; res: MeDetailed; };
'i/user-group-invites': { req: TODO; res: TODO; };
'i/2fa/done': { req: TODO; res: TODO; };
diff --git a/packages/misskey-js/src/entities.ts b/packages/misskey-js/src/entities.ts
index 0c90e44494..04065c51c9 100644
--- a/packages/misskey-js/src/entities.ts
+++ b/packages/misskey-js/src/entities.ts
@@ -14,8 +14,6 @@ export type UserLite = {
onlineStatus: 'online' | 'active' | 'offline' | 'unknown';
avatarUrl: string;
avatarBlurhash: string;
- alsoKnownAs: string[];
- movedToUri: any;
emojis: {
name: string;
url: string;
@@ -31,6 +29,7 @@ export type UserLite = {
};
export type UserDetailed = UserLite & {
+ alsoKnownAs: string[];
bannerBlurhash: string | null;
bannerColor: string | null;
bannerUrl: string | null;
@@ -58,6 +57,7 @@ export type UserDetailed = UserLite & {
lang: string | null;
lastFetchedAt?: DateString;
location: string | null;
+ movedTo: string;
notesCount: number;
pinnedNoteIds: ID[];
pinnedNotes: Note[];
@@ -315,6 +315,7 @@ export type LiteInstanceMetadata = {
imageUrl: string;
}[];
translatorAvailable: boolean;
+ serverRules: string[];
};
export type DetailedInstanceMetadata = LiteInstanceMetadata & {
diff --git a/packages/sw/package.json b/packages/sw/package.json
index d7a4a2a4b6..75bb40ccb1 100644
--- a/packages/sw/package.json
+++ b/packages/sw/package.json
@@ -9,15 +9,15 @@
"lint": "pnpm typecheck && pnpm eslint"
},
"dependencies": {
- "esbuild": "0.17.15",
+ "esbuild": "0.17.18",
"idb-keyval": "6.2.0",
"misskey-js": "workspace:*"
},
"devDependencies": {
- "@typescript-eslint/parser": "5.57.1",
+ "@typescript-eslint/parser": "5.59.2",
"@typescript/lib-webworker": "npm:@types/serviceworker@0.0.67",
- "eslint": "8.37.0",
+ "eslint": "8.39.0",
"eslint-plugin-import": "2.27.5",
- "typescript": "5.0.3"
+ "typescript": "5.0.4"
}
}
diff --git a/packages/sw/src/sw.ts b/packages/sw/src/sw.ts
index 9e0d9f0d1e..2783f2bfb5 100644
--- a/packages/sw/src/sw.ts
+++ b/packages/sw/src/sw.ts
@@ -56,7 +56,7 @@ globalThis.addEventListener('push', ev => {
return createNotification(data);
case 'readAllNotifications':
await globalThis.registration.getNotifications()
- .then(notifications => notifications.forEach(n => n.close()));
+ .then(notifications => notifications.forEach(n => n.tag !== 'read_notification' && n.close()));
break;
}
@@ -133,7 +133,7 @@ globalThis.addEventListener('notificationclick', (ev: ServiceWorkerGlobalScopeEv
switch (action) {
case 'markAllAsRead':
await globalThis.registration.getNotifications()
- .then(notifications => notifications.forEach(n => n.close()));
+ .then(notifications => notifications.forEach(n => n.tag !== 'read_notification' && n.close()));
await get('accounts').then(accounts => {
return Promise.all(accounts.map(async account => {
await swos.sendMarkAllAsRead(account.id);