summaryrefslogtreecommitdiff
path: root/packages
diff options
context:
space:
mode:
Diffstat (limited to 'packages')
-rw-r--r--packages/backend/migration/1683847157541-UserList.js13
-rw-r--r--packages/backend/migration/1683869758873-UserListFavorites.js19
-rw-r--r--packages/backend/migration/1684206886988-remove-showTimelineReplies.js11
-rw-r--r--packages/backend/migration/1684386446061-emoji-improve.js15
-rw-r--r--packages/backend/package.json91
-rw-r--r--packages/backend/src/GlobalModule.ts16
-rw-r--r--packages/backend/src/config.ts6
-rw-r--r--packages/backend/src/core/AntennaService.ts15
-rw-r--r--packages/backend/src/core/CacheService.ts7
-rw-r--r--packages/backend/src/core/CaptchaService.ts20
-rw-r--r--packages/backend/src/core/CustomEmojiService.ts22
-rw-r--r--packages/backend/src/core/FetchInstanceMetadataService.ts6
-rw-r--r--packages/backend/src/core/MetaService.ts7
-rw-r--r--packages/backend/src/core/MfmService.ts2
-rw-r--r--packages/backend/src/core/NoteCreateService.ts10
-rw-r--r--packages/backend/src/core/NoteReadService.ts8
-rw-r--r--packages/backend/src/core/NotificationService.ts8
-rw-r--r--packages/backend/src/core/QueryService.ts4
-rw-r--r--packages/backend/src/core/QueueModule.ts59
-rw-r--r--packages/backend/src/core/QueueService.ts65
-rw-r--r--packages/backend/src/core/ReactionService.ts63
-rw-r--r--packages/backend/src/core/RoleService.ts15
-rw-r--r--packages/backend/src/core/WebfingerService.ts7
-rw-r--r--packages/backend/src/core/WebhookService.ts7
-rw-r--r--packages/backend/src/core/activitypub/ApRendererService.ts6
-rw-r--r--packages/backend/src/core/activitypub/LdSignatureService.ts4
-rw-r--r--packages/backend/src/core/activitypub/models/ApNoteService.ts6
-rw-r--r--packages/backend/src/core/activitypub/models/ApPersonService.ts7
-rw-r--r--packages/backend/src/core/chart/ChartManagementService.ts8
-rw-r--r--packages/backend/src/core/entities/EmojiEntityService.ts5
-rw-r--r--packages/backend/src/core/entities/UserEntityService.ts1
-rw-r--r--packages/backend/src/core/entities/UserListEntityService.ts1
-rw-r--r--packages/backend/src/daemons/JanitorService.ts7
-rw-r--r--packages/backend/src/daemons/QueueStatsService.ts25
-rw-r--r--packages/backend/src/daemons/ServerStatsService.ts7
-rw-r--r--packages/backend/src/di-symbols.ts1
-rw-r--r--packages/backend/src/misc/id/aid.ts2
-rw-r--r--packages/backend/src/misc/prelude/time.ts17
-rw-r--r--packages/backend/src/models/RepositoryModule.ts10
-rw-r--r--packages/backend/src/models/entities/Emoji.ts16
-rw-r--r--packages/backend/src/models/entities/Note.ts2
-rw-r--r--packages/backend/src/models/entities/User.ts6
-rw-r--r--packages/backend/src/models/entities/UserList.ts6
-rw-r--r--packages/backend/src/models/entities/UserListFavorite.ts33
-rw-r--r--packages/backend/src/models/index.ts3
-rw-r--r--packages/backend/src/models/json-schema/emoji.ts30
-rw-r--r--packages/backend/src/models/json-schema/user-list.ts5
-rw-r--r--packages/backend/src/postgres.ts2
-rw-r--r--packages/backend/src/queue/QueueProcessorService.ts356
-rw-r--r--packages/backend/src/queue/const.ts26
-rw-r--r--packages/backend/src/queue/get-job-info.ts15
-rw-r--r--packages/backend/src/queue/processors/AggregateRetentionProcessorService.ts6
-rw-r--r--packages/backend/src/queue/processors/CheckExpiredMutingsProcessorService.ts5
-rw-r--r--packages/backend/src/queue/processors/CleanChartsProcessorService.ts5
-rw-r--r--packages/backend/src/queue/processors/CleanProcessorService.ts5
-rw-r--r--packages/backend/src/queue/processors/CleanRemoteFilesProcessorService.ts11
-rw-r--r--packages/backend/src/queue/processors/DeleteAccountProcessorService.ts4
-rw-r--r--packages/backend/src/queue/processors/DeleteDriveFilesProcessorService.ts12
-rw-r--r--packages/backend/src/queue/processors/DeleteFileProcessorService.ts4
-rw-r--r--packages/backend/src/queue/processors/DeliverProcessorService.ts10
-rw-r--r--packages/backend/src/queue/processors/EndedPollNotificationProcessorService.ts7
-rw-r--r--packages/backend/src/queue/processors/ExportAntennasProcessorService.ts6
-rw-r--r--packages/backend/src/queue/processors/ExportBlockingProcessorService.ts13
-rw-r--r--packages/backend/src/queue/processors/ExportCustomEmojisProcessorService.ts39
-rw-r--r--packages/backend/src/queue/processors/ExportFavoritesProcessorService.ts11
-rw-r--r--packages/backend/src/queue/processors/ExportFollowingProcessorService.ts9
-rw-r--r--packages/backend/src/queue/processors/ExportMutingProcessorService.ts13
-rw-r--r--packages/backend/src/queue/processors/ExportNotesProcessorService.ts11
-rw-r--r--packages/backend/src/queue/processors/ExportUserListsProcessorService.ts9
-rw-r--r--packages/backend/src/queue/processors/ImportAntennasProcessorService.ts6
-rw-r--r--packages/backend/src/queue/processors/ImportBlockingProcessorService.ts13
-rw-r--r--packages/backend/src/queue/processors/ImportCustomEmojisProcessorService.ts9
-rw-r--r--packages/backend/src/queue/processors/ImportFollowingProcessorService.ts13
-rw-r--r--packages/backend/src/queue/processors/ImportMutingProcessorService.ts11
-rw-r--r--packages/backend/src/queue/processors/ImportUserListsProcessorService.ts7
-rw-r--r--packages/backend/src/queue/processors/InboxProcessorService.ts38
-rw-r--r--packages/backend/src/queue/processors/RelationshipProcessorService.ts2
-rw-r--r--packages/backend/src/queue/processors/ResyncChartsProcessorService.ts5
-rw-r--r--packages/backend/src/queue/processors/TickChartsProcessorService.ts5
-rw-r--r--packages/backend/src/queue/processors/WebhookDeliverProcessorService.ts6
-rw-r--r--packages/backend/src/server/ActivityPubServerService.ts2
-rw-r--r--packages/backend/src/server/ServerService.ts11
-rw-r--r--packages/backend/src/server/api/ApiCallService.ts7
-rw-r--r--packages/backend/src/server/api/AuthenticateService.ts2
-rw-r--r--packages/backend/src/server/api/EndpointsModule.ts12
-rw-r--r--packages/backend/src/server/api/StreamingApiServerService.ts136
-rw-r--r--packages/backend/src/server/api/endpoints.ts6
-rw-r--r--packages/backend/src/server/api/endpoints/admin/announcements/update.ts5
-rw-r--r--packages/backend/src/server/api/endpoints/admin/emoji/add.ts31
-rw-r--r--packages/backend/src/server/api/endpoints/admin/emoji/update.ts27
-rw-r--r--packages/backend/src/server/api/endpoints/admin/relays/add.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/antennas/notes.ts1
-rw-r--r--packages/backend/src/server/api/endpoints/auth/accept.ts3
-rw-r--r--packages/backend/src/server/api/endpoints/i/2fa/key-done.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/i/apps.ts5
-rw-r--r--packages/backend/src/server/api/endpoints/i/notifications.ts6
-rw-r--r--packages/backend/src/server/api/endpoints/i/update.ts4
-rw-r--r--packages/backend/src/server/api/endpoints/meta.ts6
-rw-r--r--packages/backend/src/server/api/endpoints/notes/create.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/notes/global-timeline.ts9
-rw-r--r--packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts9
-rw-r--r--packages/backend/src/server/api/endpoints/notes/local-timeline.ts9
-rw-r--r--packages/backend/src/server/api/endpoints/notes/search-by-tag.ts4
-rw-r--r--packages/backend/src/server/api/endpoints/notes/timeline.ts9
-rw-r--r--packages/backend/src/server/api/endpoints/reset-db.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/roles/notes.ts1
-rw-r--r--packages/backend/src/server/api/endpoints/users/lists/create-from-public.ts148
-rw-r--r--packages/backend/src/server/api/endpoints/users/lists/favorite.ts70
-rw-r--r--packages/backend/src/server/api/endpoints/users/lists/list.ts45
-rw-r--r--packages/backend/src/server/api/endpoints/users/lists/show.ts35
-rw-r--r--packages/backend/src/server/api/endpoints/users/lists/unfavorite.ts63
-rw-r--r--packages/backend/src/server/api/endpoints/users/lists/update.ts5
-rw-r--r--packages/backend/src/server/api/stream/channels/global-timeline.ts5
-rw-r--r--packages/backend/src/server/api/stream/channels/home-timeline.ts5
-rw-r--r--packages/backend/src/server/api/stream/channels/hybrid-timeline.ts5
-rw-r--r--packages/backend/src/server/api/stream/channels/local-timeline.ts5
-rw-r--r--packages/backend/src/server/api/stream/channels/role-timeline.ts11
-rw-r--r--packages/backend/src/server/api/stream/index.ts23
-rw-r--r--packages/backend/src/server/web/boot.js76
-rw-r--r--packages/backend/src/server/web/views/base.pug4
-rw-r--r--packages/backend/src/server/web/views/channel.pug1
-rw-r--r--packages/backend/src/server/web/views/clip.pug1
-rw-r--r--packages/backend/src/server/web/views/flash.pug1
-rw-r--r--packages/backend/src/server/web/views/gallery-post.pug1
-rw-r--r--packages/backend/src/server/web/views/note.pug16
-rw-r--r--packages/backend/src/server/web/views/page.pug1
-rw-r--r--packages/backend/src/server/web/views/user.pug1
-rw-r--r--packages/backend/test/e2e/2fa.ts2
-rw-r--r--packages/backend/test/e2e/antennas.ts653
-rw-r--r--packages/backend/test/e2e/users.ts5
-rw-r--r--packages/backend/test/misc/mock-resolver.ts6
-rw-r--r--packages/backend/test/unit/ReactionService.ts42
-rw-r--r--packages/backend/test/utils.ts99
-rw-r--r--packages/frontend/.eslintrc.js5
-rw-r--r--packages/frontend/.storybook/generate.tsx1
-rw-r--r--packages/frontend/.storybook/preview-head.html2
-rw-r--r--packages/frontend/lib/rollup-plugin-unwind-css-module-class-name.test.ts597
-rw-r--r--packages/frontend/lib/rollup-plugin-unwind-css-module-class-name.ts275
-rw-r--r--packages/frontend/package.json95
-rw-r--r--packages/frontend/src/_boot_.ts14
-rw-r--r--packages/frontend/src/account.ts82
-rw-r--r--packages/frontend/src/boot/common.ts262
-rw-r--r--packages/frontend/src/boot/main-boot.ts254
-rw-r--r--packages/frontend/src/boot/sub-boot.ts8
-rw-r--r--packages/frontend/src/components/MkAbuseReportWindow.vue10
-rw-r--r--packages/frontend/src/components/MkAccountMoved.vue4
-rw-r--r--packages/frontend/src/components/MkAchievements.vue9
-rw-r--r--packages/frontend/src/components/MkAnalogClock.stories.impl.ts2
-rw-r--r--packages/frontend/src/components/MkAnalogClock.vue34
-rw-r--r--packages/frontend/src/components/MkAnimBg.vue243
-rw-r--r--packages/frontend/src/components/MkAsUi.vue12
-rw-r--r--packages/frontend/src/components/MkAutocomplete.vue16
-rw-r--r--packages/frontend/src/components/MkAvatars.vue2
-rw-r--r--packages/frontend/src/components/MkButton.vue19
-rw-r--r--packages/frontend/src/components/MkChannelFollowButton.vue22
-rw-r--r--packages/frontend/src/components/MkChannelList.vue3
-rw-r--r--packages/frontend/src/components/MkChart.vue34
-rw-r--r--packages/frontend/src/components/MkChartTooltip.vue2
-rw-r--r--packages/frontend/src/components/MkClickerGame.vue6
-rw-r--r--packages/frontend/src/components/MkContainer.vue36
-rw-r--r--packages/frontend/src/components/MkContextMenu.vue8
-rw-r--r--packages/frontend/src/components/MkCropperDialog.vue2
-rw-r--r--packages/frontend/src/components/MkDateSeparatedList.vue2
-rw-r--r--packages/frontend/src/components/MkDialog.vue12
-rw-r--r--packages/frontend/src/components/MkDigitalClock.stories.impl.ts32
-rw-r--r--packages/frontend/src/components/MkDigitalClock.vue21
-rw-r--r--packages/frontend/src/components/MkDrive.file.vue178
-rw-r--r--packages/frontend/src/components/MkDrive.folder.vue84
-rw-r--r--packages/frontend/src/components/MkDrive.navFolder.vue18
-rw-r--r--packages/frontend/src/components/MkDrive.vue286
-rw-r--r--packages/frontend/src/components/MkDriveFileThumbnail.vue52
-rw-r--r--packages/frontend/src/components/MkDriveSelectDialog.vue6
-rw-r--r--packages/frontend/src/components/MkDriveWindow.vue8
-rw-r--r--packages/frontend/src/components/MkEmojiPicker.vue51
-rw-r--r--packages/frontend/src/components/MkEmojiPickerDialog.vue30
-rw-r--r--packages/frontend/src/components/MkEmojiPickerWindow.vue11
-rw-r--r--packages/frontend/src/components/MkFileCaptionEditWindow.vue6
-rw-r--r--packages/frontend/src/components/MkFoldableSection.vue195
-rw-r--r--packages/frontend/src/components/MkFolder.vue22
-rw-r--r--packages/frontend/src/components/MkFollowButton.vue33
-rw-r--r--packages/frontend/src/components/MkForgotPassword.vue55
-rw-r--r--packages/frontend/src/components/MkFormDialog.vue90
-rw-r--r--packages/frontend/src/components/MkGalleryPostPreview.stories.impl.ts4
-rw-r--r--packages/frontend/src/components/MkGalleryPostPreview.vue17
-rw-r--r--packages/frontend/src/components/MkImageViewer.vue78
-rw-r--r--packages/frontend/src/components/MkImgWithBlurhash.vue204
-rw-r--r--packages/frontend/src/components/MkKeyValue.vue26
-rw-r--r--packages/frontend/src/components/MkLaunchPad.vue2
-rw-r--r--packages/frontend/src/components/MkMediaBanner.vue95
-rw-r--r--packages/frontend/src/components/MkMediaImage.vue99
-rw-r--r--packages/frontend/src/components/MkMediaList.vue76
-rw-r--r--packages/frontend/src/components/MkMediaVideo.vue111
-rw-r--r--packages/frontend/src/components/MkMention.vue2
-rw-r--r--packages/frontend/src/components/MkMenu.child.vue2
-rw-r--r--packages/frontend/src/components/MkMenu.vue4
-rw-r--r--packages/frontend/src/components/MkModal.vue62
-rw-r--r--packages/frontend/src/components/MkModalPageWindow.vue182
-rw-r--r--packages/frontend/src/components/MkModalWindow.vue2
-rw-r--r--packages/frontend/src/components/MkNote.vue25
-rw-r--r--packages/frontend/src/components/MkNoteDetailed.vue616
-rw-r--r--packages/frontend/src/components/MkNotePreview.vue2
-rw-r--r--packages/frontend/src/components/MkNoteSimple.vue2
-rw-r--r--packages/frontend/src/components/MkNotes.vue2
-rw-r--r--packages/frontend/src/components/MkNotification.vue23
-rw-r--r--packages/frontend/src/components/MkNotificationSettingWindow.vue6
-rw-r--r--packages/frontend/src/components/MkNotifications.vue10
-rw-r--r--packages/frontend/src/components/MkObjectView.value.vue64
-rw-r--r--packages/frontend/src/components/MkObjectView.vue8
-rw-r--r--packages/frontend/src/components/MkOmit.vue24
-rw-r--r--packages/frontend/src/components/MkPageWindow.vue16
-rw-r--r--packages/frontend/src/components/MkPagination.vue8
-rw-r--r--packages/frontend/src/components/MkPoll.vue114
-rw-r--r--packages/frontend/src/components/MkPollEditor.vue2
-rw-r--r--packages/frontend/src/components/MkPopupMenu.vue4
-rw-r--r--packages/frontend/src/components/MkPostForm.vue14
-rw-r--r--packages/frontend/src/components/MkPostFormAttaches.vue105
-rw-r--r--packages/frontend/src/components/MkPostFormDialog.vue4
-rw-r--r--packages/frontend/src/components/MkPushNotificationAllowButton.vue38
-rw-r--r--packages/frontend/src/components/MkRadios.vue36
-rw-r--r--packages/frontend/src/components/MkReactedUsersDialog.vue4
-rw-r--r--packages/frontend/src/components/MkReactionIcon.vue4
-rw-r--r--packages/frontend/src/components/MkReactionTooltip.vue4
-rw-r--r--packages/frontend/src/components/MkReactionsViewer.details.vue4
-rw-r--r--packages/frontend/src/components/MkReactionsViewer.reaction.vue19
-rw-r--r--packages/frontend/src/components/MkReactionsViewer.vue12
-rw-r--r--packages/frontend/src/components/MkRenotedUsersDialog.vue4
-rw-r--r--packages/frontend/src/components/MkRetentionLineChart.vue4
-rw-r--r--packages/frontend/src/components/MkRippleEffect.vue16
-rw-r--r--packages/frontend/src/components/MkRolePreview.vue13
-rw-r--r--packages/frontend/src/components/MkSample.vue118
-rw-r--r--packages/frontend/src/components/MkSignin.vue34
-rw-r--r--packages/frontend/src/components/MkSigninDialog.vue4
-rw-r--r--packages/frontend/src/components/MkSignupDialog.form.vue10
-rw-r--r--packages/frontend/src/components/MkSignupDialog.rules.vue8
-rw-r--r--packages/frontend/src/components/MkSignupDialog.vue10
-rw-r--r--packages/frontend/src/components/MkSubNoteContent.vue10
-rw-r--r--packages/frontend/src/components/MkSuperMenu.vue21
-rw-r--r--packages/frontend/src/components/MkTab.vue12
-rw-r--r--packages/frontend/src/components/MkTagCloud.vue24
-rw-r--r--packages/frontend/src/components/MkTextarea.vue342
-rw-r--r--packages/frontend/src/components/MkTimeline.vue42
-rw-r--r--packages/frontend/src/components/MkToast.vue10
-rw-r--r--packages/frontend/src/components/MkTokenGenerateWindow.vue8
-rw-r--r--packages/frontend/src/components/MkTooltip.vue19
-rw-r--r--packages/frontend/src/components/MkUpdated.vue2
-rw-r--r--packages/frontend/src/components/MkUrlPreview.vue41
-rw-r--r--packages/frontend/src/components/MkUrlPreviewPopup.vue8
-rw-r--r--packages/frontend/src/components/MkUserInfo.vue4
-rw-r--r--packages/frontend/src/components/MkUserOnlineIndicator.vue10
-rw-r--r--packages/frontend/src/components/MkUserPopup.vue19
-rw-r--r--packages/frontend/src/components/MkUserSelectDialog.vue14
-rw-r--r--packages/frontend/src/components/MkUserSetupDialog.Follow.vue4
-rw-r--r--packages/frontend/src/components/MkUserSetupDialog.Privacy.vue4
-rw-r--r--packages/frontend/src/components/MkUserSetupDialog.Profile.vue8
-rw-r--r--packages/frontend/src/components/MkUserSetupDialog.vue76
-rw-r--r--packages/frontend/src/components/MkUsersTooltip.vue14
-rw-r--r--packages/frontend/src/components/MkVisibilityPicker.vue2
-rw-r--r--packages/frontend/src/components/MkVisitorDashboard.vue6
-rw-r--r--packages/frontend/src/components/MkWaitingDialog.vue2
-rw-r--r--packages/frontend/src/components/MkWidgets.vue38
-rw-r--r--packages/frontend/src/components/MkWindow.vue10
-rw-r--r--packages/frontend/src/components/MkYouTubePlayer.vue2
-rw-r--r--packages/frontend/src/components/form/link.vue116
-rw-r--r--packages/frontend/src/components/form/slot.vue38
-rw-r--r--packages/frontend/src/components/form/suspense.vue132
-rw-r--r--packages/frontend/src/components/global/MkA.vue12
-rw-r--r--packages/frontend/src/components/global/MkAcct.vue2
-rw-r--r--packages/frontend/src/components/global/MkAd.vue12
-rw-r--r--packages/frontend/src/components/global/MkAvatar.vue3
-rw-r--r--packages/frontend/src/components/global/MkCondensedLine.vue9
-rw-r--r--packages/frontend/src/components/global/MkCustomEmoji.vue4
-rw-r--r--packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.stories.impl.ts2
-rw-r--r--packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.ts367
-rw-r--r--packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.vue171
-rw-r--r--packages/frontend/src/components/global/MkPageHeader.tabs.vue4
-rw-r--r--packages/frontend/src/components/global/MkPageHeader.vue4
-rw-r--r--packages/frontend/src/components/global/MkStickyContainer.vue9
-rw-r--r--packages/frontend/src/components/global/MkTime.vue1
-rw-r--r--packages/frontend/src/components/global/MkUrl.vue2
-rw-r--r--packages/frontend/src/components/global/MkUserName.vue2
-rw-r--r--packages/frontend/src/components/global/i18n.ts58
-rw-r--r--packages/frontend/src/components/index.ts2
-rw-r--r--packages/frontend/src/components/mfm.ts390
-rw-r--r--packages/frontend/src/components/page/block.type.ts29
-rw-r--r--packages/frontend/src/components/page/page.block.vue55
-rw-r--r--packages/frontend/src/components/page/page.button.vue66
-rw-r--r--packages/frontend/src/components/page/page.canvas.vue48
-rw-r--r--packages/frontend/src/components/page/page.counter.vue51
-rw-r--r--packages/frontend/src/components/page/page.if.vue31
-rw-r--r--packages/frontend/src/components/page/page.image.vue12
-rw-r--r--packages/frontend/src/components/page/page.note.vue48
-rw-r--r--packages/frontend/src/components/page/page.number-input.vue54
-rw-r--r--packages/frontend/src/components/page/page.post.vue111
-rw-r--r--packages/frontend/src/components/page/page.radio-button.vue44
-rw-r--r--packages/frontend/src/components/page/page.section.vue82
-rw-r--r--packages/frontend/src/components/page/page.switch.vue54
-rw-r--r--packages/frontend/src/components/page/page.text-input.vue54
-rw-r--r--packages/frontend/src/components/page/page.text.vue74
-rw-r--r--packages/frontend/src/components/page/page.textarea-input.vue45
-rw-r--r--packages/frontend/src/components/page/page.textarea.vue39
-rw-r--r--packages/frontend/src/components/page/page.vue59
-rw-r--r--packages/frontend/src/custom-emojis.ts23
-rw-r--r--packages/frontend/src/directives/tooltip.ts16
-rw-r--r--packages/frontend/src/emojilist.json3565
-rw-r--r--packages/frontend/src/i18n.ts3
-rw-r--r--packages/frontend/src/init.ts527
-rw-r--r--packages/frontend/src/local-storage.ts2
-rw-r--r--packages/frontend/src/os.ts6
-rw-r--r--packages/frontend/src/pages/_error_.vue54
-rw-r--r--packages/frontend/src/pages/about-misskey.vue21
-rw-r--r--packages/frontend/src/pages/about.emojis.vue52
-rw-r--r--packages/frontend/src/pages/about.federation.vue25
-rw-r--r--packages/frontend/src/pages/about.vue8
-rw-r--r--packages/frontend/src/pages/achievements.vue2
-rw-r--r--packages/frontend/src/pages/admin-file.vue4
-rw-r--r--packages/frontend/src/pages/admin/RolesEditorFormula.vue16
-rw-r--r--packages/frontend/src/pages/admin/abuses.vue10
-rw-r--r--packages/frontend/src/pages/admin/ads.vue18
-rw-r--r--packages/frontend/src/pages/admin/announcements.vue10
-rw-r--r--packages/frontend/src/pages/admin/database.vue2
-rw-r--r--packages/frontend/src/pages/admin/email-settings.vue8
-rw-r--r--packages/frontend/src/pages/admin/federation.vue27
-rw-r--r--packages/frontend/src/pages/admin/files.vue50
-rw-r--r--packages/frontend/src/pages/admin/index.vue2
-rw-r--r--packages/frontend/src/pages/admin/instance-block.vue2
-rw-r--r--packages/frontend/src/pages/admin/moderation.vue4
-rw-r--r--packages/frontend/src/pages/admin/object-storage.vue6
-rw-r--r--packages/frontend/src/pages/admin/other-settings.vue25
-rw-r--r--packages/frontend/src/pages/admin/overview.instances.vue24
-rw-r--r--packages/frontend/src/pages/admin/overview.pie.vue4
-rw-r--r--packages/frontend/src/pages/admin/overview.queue.chart.vue4
-rw-r--r--packages/frontend/src/pages/admin/overview.queue.vue4
-rw-r--r--packages/frontend/src/pages/admin/overview.vue12
-rw-r--r--packages/frontend/src/pages/admin/proxy-account.vue2
-rw-r--r--packages/frontend/src/pages/admin/queue.chart.chart.vue4
-rw-r--r--packages/frontend/src/pages/admin/queue.chart.vue87
-rw-r--r--packages/frontend/src/pages/admin/queue.vue2
-rw-r--r--packages/frontend/src/pages/admin/relays.vue32
-rw-r--r--packages/frontend/src/pages/admin/roles.edit.vue4
-rw-r--r--packages/frontend/src/pages/admin/roles.editor.vue40
-rw-r--r--packages/frontend/src/pages/admin/roles.role.vue6
-rw-r--r--packages/frontend/src/pages/admin/roles.vue8
-rw-r--r--packages/frontend/src/pages/admin/security.vue8
-rw-r--r--packages/frontend/src/pages/admin/server-rules.vue6
-rw-r--r--packages/frontend/src/pages/admin/settings.vue24
-rw-r--r--packages/frontend/src/pages/admin/users.vue6
-rw-r--r--packages/frontend/src/pages/ads.vue2
-rw-r--r--packages/frontend/src/pages/announcements.vue2
-rw-r--r--packages/frontend/src/pages/antenna-timeline.vue70
-rw-r--r--packages/frontend/src/pages/api-console.vue4
-rw-r--r--packages/frontend/src/pages/auth.vue2
-rw-r--r--packages/frontend/src/pages/channel-editor.vue6
-rw-r--r--packages/frontend/src/pages/channel.vue18
-rw-r--r--packages/frontend/src/pages/channels.vue4
-rw-r--r--packages/frontend/src/pages/clicker.vue2
-rw-r--r--packages/frontend/src/pages/clip.vue49
-rw-r--r--packages/frontend/src/pages/custom-emojis-manager.vue19
-rw-r--r--packages/frontend/src/pages/emoji-edit-dialog.vue231
-rw-r--r--packages/frontend/src/pages/emojis.emoji.vue54
-rw-r--r--packages/frontend/src/pages/explore.featured.vue2
-rw-r--r--packages/frontend/src/pages/explore.roles.vue4
-rw-r--r--packages/frontend/src/pages/explore.users.vue10
-rw-r--r--packages/frontend/src/pages/favorites.vue4
-rw-r--r--packages/frontend/src/pages/flash/flash-edit.vue16
-rw-r--r--packages/frontend/src/pages/flash/flash-index.vue34
-rw-r--r--packages/frontend/src/pages/flash/flash.vue8
-rw-r--r--packages/frontend/src/pages/follow-requests.vue2
-rw-r--r--packages/frontend/src/pages/follow.vue2
-rw-r--r--packages/frontend/src/pages/gallery/edit.vue2
-rw-r--r--packages/frontend/src/pages/gallery/index.vue22
-rw-r--r--packages/frontend/src/pages/gallery/post.vue2
-rw-r--r--packages/frontend/src/pages/instance-info.vue6
-rw-r--r--packages/frontend/src/pages/list.vue148
-rw-r--r--packages/frontend/src/pages/miauth.vue2
-rw-r--r--packages/frontend/src/pages/my-antennas/create.vue6
-rw-r--r--packages/frontend/src/pages/my-antennas/edit.vue4
-rw-r--r--packages/frontend/src/pages/my-antennas/editor.vue18
-rw-r--r--packages/frontend/src/pages/my-antennas/index.vue26
-rw-r--r--packages/frontend/src/pages/my-clips/index.vue2
-rw-r--r--packages/frontend/src/pages/my-lists/index.vue49
-rw-r--r--packages/frontend/src/pages/my-lists/list.vue90
-rw-r--r--packages/frontend/src/pages/not-found.vue2
-rw-r--r--packages/frontend/src/pages/note.vue72
-rw-r--r--packages/frontend/src/pages/notifications.vue4
-rw-r--r--packages/frontend/src/pages/page-editor/els/page-editor.el.image.vue12
-rw-r--r--packages/frontend/src/pages/page-editor/els/page-editor.el.text.vue38
-rw-r--r--packages/frontend/src/pages/page-editor/page-editor.blocks.vue74
-rw-r--r--packages/frontend/src/pages/page-editor/page-editor.container.vue84
-rw-r--r--packages/frontend/src/pages/page-editor/page-editor.vue2
-rw-r--r--packages/frontend/src/pages/page.vue6
-rw-r--r--packages/frontend/src/pages/pages.vue38
-rw-r--r--packages/frontend/src/pages/preview.vue27
-rw-r--r--packages/frontend/src/pages/registry.keys.vue5
-rw-r--r--packages/frontend/src/pages/registry.value.vue5
-rw-r--r--packages/frontend/src/pages/registry.vue5
-rw-r--r--packages/frontend/src/pages/reset-password.vue6
-rw-r--r--packages/frontend/src/pages/role.vue8
-rw-r--r--packages/frontend/src/pages/scratchpad.vue4
-rw-r--r--packages/frontend/src/pages/search.user.vue2
-rw-r--r--packages/frontend/src/pages/search.vue4
-rw-r--r--packages/frontend/src/pages/settings/2fa.qrdialog.vue4
-rw-r--r--packages/frontend/src/pages/settings/2fa.vue2
-rw-r--r--packages/frontend/src/pages/settings/accounts.vue2
-rw-r--r--packages/frontend/src/pages/settings/apps.vue46
-rw-r--r--packages/frontend/src/pages/settings/custom-css.vue2
-rw-r--r--packages/frontend/src/pages/settings/drive.vue36
-rw-r--r--packages/frontend/src/pages/settings/email.vue4
-rw-r--r--packages/frontend/src/pages/settings/general.vue55
-rw-r--r--packages/frontend/src/pages/settings/index.vue2
-rw-r--r--packages/frontend/src/pages/settings/migration.vue10
-rw-r--r--packages/frontend/src/pages/settings/navbar.vue4
-rw-r--r--packages/frontend/src/pages/settings/notifications.vue2
-rw-r--r--packages/frontend/src/pages/settings/other.vue12
-rw-r--r--packages/frontend/src/pages/settings/plugin.vue6
-rw-r--r--packages/frontend/src/pages/settings/preferences-backups.vue8
-rw-r--r--packages/frontend/src/pages/settings/privacy.vue20
-rw-r--r--packages/frontend/src/pages/settings/profile.vue67
-rw-r--r--packages/frontend/src/pages/settings/reaction.vue28
-rw-r--r--packages/frontend/src/pages/settings/roles.vue2
-rw-r--r--packages/frontend/src/pages/settings/security.vue2
-rw-r--r--packages/frontend/src/pages/settings/sounds.sound.vue2
-rw-r--r--packages/frontend/src/pages/settings/sounds.vue2
-rw-r--r--packages/frontend/src/pages/settings/statusbar.statusbar.vue10
-rw-r--r--packages/frontend/src/pages/settings/statusbar.vue2
-rw-r--r--packages/frontend/src/pages/settings/theme.manage.vue6
-rw-r--r--packages/frontend/src/pages/share.vue29
-rw-r--r--packages/frontend/src/pages/signup-complete.vue83
-rw-r--r--packages/frontend/src/pages/tag.vue41
-rw-r--r--packages/frontend/src/pages/theme-editor.vue12
-rw-r--r--packages/frontend/src/pages/timeline.vue18
-rw-r--r--packages/frontend/src/pages/user-info.vue10
-rw-r--r--packages/frontend/src/pages/user-list-timeline.vue70
-rw-r--r--packages/frontend/src/pages/user-tag.vue2
-rw-r--r--packages/frontend/src/pages/user/achievements.vue4
-rw-r--r--packages/frontend/src/pages/user/activity.vue6
-rw-r--r--packages/frontend/src/pages/user/clips.vue32
-rw-r--r--packages/frontend/src/pages/user/follow-list.vue18
-rw-r--r--packages/frontend/src/pages/user/followers.vue5
-rw-r--r--packages/frontend/src/pages/user/following.vue5
-rw-r--r--packages/frontend/src/pages/user/gallery.vue8
-rw-r--r--packages/frontend/src/pages/user/home.vue10
-rw-r--r--packages/frontend/src/pages/user/index.activity.vue2
-rw-r--r--packages/frontend/src/pages/user/index.timeline.vue4
-rw-r--r--packages/frontend/src/pages/user/index.vue43
-rw-r--r--packages/frontend/src/pages/user/lists.vue51
-rw-r--r--packages/frontend/src/pages/user/pages.vue6
-rw-r--r--packages/frontend/src/pages/user/reactions.vue52
-rw-r--r--packages/frontend/src/pages/welcome.entrance.a.vue10
-rw-r--r--packages/frontend/src/pages/welcome.setup.vue65
-rw-r--r--packages/frontend/src/pages/welcome.timeline.vue6
-rw-r--r--packages/frontend/src/pizzax.ts8
-rw-r--r--packages/frontend/src/router.ts7
-rw-r--r--packages/frontend/src/scripts/emojilist.ts18
-rw-r--r--packages/frontend/src/scripts/get-drive-file-menu.ts2
-rw-r--r--packages/frontend/src/scripts/get-note-menu.ts12
-rw-r--r--packages/frontend/src/scripts/get-user-menu.ts12
-rw-r--r--packages/frontend/src/scripts/hpml/block.ts109
-rw-r--r--packages/frontend/src/scripts/hpml/evaluator.ts171
-rw-r--r--packages/frontend/src/scripts/hpml/expr.ts79
-rw-r--r--packages/frontend/src/scripts/hpml/index.ts100
-rw-r--r--packages/frontend/src/scripts/hpml/lib.ts245
-rw-r--r--packages/frontend/src/scripts/hpml/type-checker.ts182
-rw-r--r--packages/frontend/src/scripts/idle-render.ts38
-rw-r--r--packages/frontend/src/scripts/select-file.ts4
-rw-r--r--packages/frontend/src/scripts/theme.ts6
-rw-r--r--packages/frontend/src/scripts/time.ts17
-rw-r--r--packages/frontend/src/scripts/use-note-capture.ts4
-rw-r--r--packages/frontend/src/scripts/worker-multi-dispatch.ts75
-rw-r--r--packages/frontend/src/store.ts16
-rw-r--r--packages/frontend/src/stream.ts14
-rw-r--r--packages/frontend/src/style.scss144
-rw-r--r--packages/frontend/src/themes/_dark.json53
-rw-r--r--packages/frontend/src/themes/_light.json53
-rw-r--r--packages/frontend/src/themes/d-astro.json51
-rw-r--r--packages/frontend/src/themes/d-botanical.json51
-rw-r--r--packages/frontend/src/themes/d-cherry.json51
-rw-r--r--packages/frontend/src/themes/d-dark.json51
-rw-r--r--packages/frontend/src/themes/d-future.json51
-rw-r--r--packages/frontend/src/themes/d-green-lime.json51
-rw-r--r--packages/frontend/src/themes/d-green-orange.json51
-rw-r--r--packages/frontend/src/themes/d-ice.json51
-rw-r--r--packages/frontend/src/themes/d-persimmon.json51
-rw-r--r--packages/frontend/src/themes/d-u0.json53
-rw-r--r--packages/frontend/src/themes/l-apricot.json51
-rw-r--r--packages/frontend/src/themes/l-botanical.json51
-rw-r--r--packages/frontend/src/themes/l-cherry.json51
-rw-r--r--packages/frontend/src/themes/l-coffee.json51
-rw-r--r--packages/frontend/src/themes/l-light.json51
-rw-r--r--packages/frontend/src/themes/l-rainy.json51
-rw-r--r--packages/frontend/src/themes/l-sushi.json51
-rw-r--r--packages/frontend/src/themes/l-u0.json51
-rw-r--r--packages/frontend/src/themes/l-vivid.json51
-rw-r--r--packages/frontend/src/ui/_common_/common.vue54
-rw-r--r--packages/frontend/src/ui/_common_/navbar-for-mobile.vue402
-rw-r--r--packages/frontend/src/ui/_common_/navbar.vue691
-rw-r--r--packages/frontend/src/ui/_common_/statusbar-federation.vue71
-rw-r--r--packages/frontend/src/ui/_common_/statusbar-rss.vue57
-rw-r--r--packages/frontend/src/ui/_common_/statusbar-user-list.vue87
-rw-r--r--packages/frontend/src/ui/_common_/statusbars.vue121
-rw-r--r--packages/frontend/src/ui/_common_/stream-indicator.vue13
-rw-r--r--packages/frontend/src/ui/classic.header.vue8
-rw-r--r--packages/frontend/src/ui/classic.sidebar.vue8
-rw-r--r--packages/frontend/src/ui/classic.vue6
-rw-r--r--packages/frontend/src/ui/deck.vue115
-rw-r--r--packages/frontend/src/ui/deck/antenna-column.vue9
-rw-r--r--packages/frontend/src/ui/deck/channel-column.vue11
-rw-r--r--packages/frontend/src/ui/deck/column-core.vue38
-rw-r--r--packages/frontend/src/ui/deck/column.vue113
-rw-r--r--packages/frontend/src/ui/deck/direct-column.vue6
-rw-r--r--packages/frontend/src/ui/deck/list-column.vue9
-rw-r--r--packages/frontend/src/ui/deck/main-column.vue6
-rw-r--r--packages/frontend/src/ui/deck/mentions-column.vue6
-rw-r--r--packages/frontend/src/ui/deck/notifications-column.vue8
-rw-r--r--packages/frontend/src/ui/deck/role-timeline-column.vue11
-rw-r--r--packages/frontend/src/ui/deck/tl-column.vue9
-rw-r--r--packages/frontend/src/ui/deck/widgets-column.vue8
-rw-r--r--packages/frontend/src/ui/minimum.vue34
-rw-r--r--packages/frontend/src/ui/universal.vue93
-rw-r--r--packages/frontend/src/ui/universal.widgets.vue28
-rw-r--r--packages/frontend/src/ui/visitor.vue24
-rw-r--r--packages/frontend/src/ui/zen.vue48
-rw-r--r--packages/frontend/src/unicode-emoji-indexes/en-US.json1784
-rw-r--r--packages/frontend/src/widgets/WidgetActivity.calendar.vue18
-rw-r--r--packages/frontend/src/widgets/WidgetActivity.chart.vue18
-rw-r--r--packages/frontend/src/widgets/WidgetActivity.vue11
-rw-r--r--packages/frontend/src/widgets/WidgetAichan.vue17
-rw-r--r--packages/frontend/src/widgets/WidgetAiscript.vue11
-rw-r--r--packages/frontend/src/widgets/WidgetAiscriptApp.vue13
-rw-r--r--packages/frontend/src/widgets/WidgetButton.vue14
-rw-r--r--packages/frontend/src/widgets/WidgetCalendar.vue9
-rw-r--r--packages/frontend/src/widgets/WidgetClicker.vue13
-rw-r--r--packages/frontend/src/widgets/WidgetClock.vue93
-rw-r--r--packages/frontend/src/widgets/WidgetDigitalClock.vue11
-rw-r--r--packages/frontend/src/widgets/WidgetFederation.vue11
-rw-r--r--packages/frontend/src/widgets/WidgetInstanceCloud.vue15
-rw-r--r--packages/frontend/src/widgets/WidgetInstanceInfo.vue9
-rw-r--r--packages/frontend/src/widgets/WidgetJobQueue.vue13
-rw-r--r--packages/frontend/src/widgets/WidgetMemo.vue11
-rw-r--r--packages/frontend/src/widgets/WidgetNotifications.vue15
-rw-r--r--packages/frontend/src/widgets/WidgetOnlineUsers.vue35
-rw-r--r--packages/frontend/src/widgets/WidgetPhotos.vue15
-rw-r--r--packages/frontend/src/widgets/WidgetPostForm.vue9
-rw-r--r--packages/frontend/src/widgets/WidgetProfile.vue9
-rw-r--r--packages/frontend/src/widgets/WidgetRss.vue11
-rw-r--r--packages/frontend/src/widgets/WidgetRssTicker.vue13
-rw-r--r--packages/frontend/src/widgets/WidgetSlideshow.vue9
-rw-r--r--packages/frontend/src/widgets/WidgetTimeline.vue11
-rw-r--r--packages/frontend/src/widgets/WidgetTrends.vue11
-rw-r--r--packages/frontend/src/widgets/WidgetUnixClock.vue9
-rw-r--r--packages/frontend/src/widgets/WidgetUserList.vue11
-rw-r--r--packages/frontend/src/widgets/server-metric/index.vue6
-rw-r--r--packages/frontend/src/widgets/server-metric/pie.vue27
-rw-r--r--packages/frontend/src/workers/draw-blurhash.ts15
-rw-r--r--packages/frontend/src/workers/test-webgl2.ts7
-rw-r--r--packages/frontend/src/workers/tsconfig.json5
-rw-r--r--packages/frontend/vite.config.ts15
-rw-r--r--packages/shared/.eslintrc.js2
557 files changed, 13577 insertions, 10822 deletions
diff --git a/packages/backend/migration/1683847157541-UserList.js b/packages/backend/migration/1683847157541-UserList.js
new file mode 100644
index 0000000000..b50a50eed8
--- /dev/null
+++ b/packages/backend/migration/1683847157541-UserList.js
@@ -0,0 +1,13 @@
+export class UserList1683847157541 {
+ name = 'UserList1683847157541'
+
+ async up(queryRunner) {
+ await queryRunner.query(`ALTER TABLE "user_list" ADD "isPublic" boolean NOT NULL DEFAULT false`);
+ await queryRunner.query(`CREATE INDEX "IDX_48a00f08598662b9ca540521eb" ON "user_list" ("isPublic") `);
+ }
+
+ async down(queryRunner) {
+ await queryRunner.query(`DROP INDEX "public"."IDX_48a00f08598662b9ca540521eb"`);
+ await queryRunner.query(`ALTER TABLE "user_list" DROP COLUMN "isPublic"`);
+ }
+}
diff --git a/packages/backend/migration/1683869758873-UserListFavorites.js b/packages/backend/migration/1683869758873-UserListFavorites.js
new file mode 100644
index 0000000000..ac9c4c42b9
--- /dev/null
+++ b/packages/backend/migration/1683869758873-UserListFavorites.js
@@ -0,0 +1,19 @@
+export class UserListFavorites1683869758873 {
+ name = 'UserListFavorites1683869758873'
+
+ async up(queryRunner) {
+ await queryRunner.query(`CREATE TABLE "user_list_favorite" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "userId" character varying(32) NOT NULL, "userListId" character varying(32) NOT NULL, CONSTRAINT "PK_c0974b21e18502a4c8178e09fe6" PRIMARY KEY ("id"))`);
+ await queryRunner.query(`CREATE INDEX "IDX_016f613dc4feb807e03e3e7da9" ON "user_list_favorite" ("userId") `);
+ await queryRunner.query(`CREATE UNIQUE INDEX "IDX_d6765a8c2a4c17c33f9d7f948b" ON "user_list_favorite" ("userId", "userListId") `);
+ await queryRunner.query(`ALTER TABLE "user_list_favorite" ADD CONSTRAINT "FK_016f613dc4feb807e03e3e7da92" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
+ await queryRunner.query(`ALTER TABLE "user_list_favorite" ADD CONSTRAINT "FK_4d52b20bfe32c8552e7a61e80d2" FOREIGN KEY ("userListId") REFERENCES "user_list"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
+ }
+
+ async down(queryRunner) {
+ await queryRunner.query(`ALTER TABLE "user_list_favorite" DROP CONSTRAINT "FK_4d52b20bfe32c8552e7a61e80d2"`);
+ await queryRunner.query(`ALTER TABLE "user_list_favorite" DROP CONSTRAINT "FK_016f613dc4feb807e03e3e7da92"`);
+ await queryRunner.query(`DROP INDEX "public"."IDX_d6765a8c2a4c17c33f9d7f948b"`);
+ await queryRunner.query(`DROP INDEX "public"."IDX_016f613dc4feb807e03e3e7da9"`);
+ await queryRunner.query(`DROP TABLE "user_list_favorite"`);
+ }
+}
diff --git a/packages/backend/migration/1684206886988-remove-showTimelineReplies.js b/packages/backend/migration/1684206886988-remove-showTimelineReplies.js
new file mode 100644
index 0000000000..690653bd7c
--- /dev/null
+++ b/packages/backend/migration/1684206886988-remove-showTimelineReplies.js
@@ -0,0 +1,11 @@
+export class RemoveShowTimelineReplies1684206886988 {
+ name = 'RemoveShowTimelineReplies1684206886988'
+
+ async up(queryRunner) {
+ await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "showTimelineReplies"`);
+ }
+
+ async down(queryRunner) {
+ await queryRunner.query(`ALTER TABLE "user" ADD "showTimelineReplies" boolean NOT NULL DEFAULT false`);
+ }
+}
diff --git a/packages/backend/migration/1684386446061-emoji-improve.js b/packages/backend/migration/1684386446061-emoji-improve.js
new file mode 100644
index 0000000000..40b0a2bc5e
--- /dev/null
+++ b/packages/backend/migration/1684386446061-emoji-improve.js
@@ -0,0 +1,15 @@
+export class EmojiImprove1684386446061 {
+ name = 'EmojiImprove1684386446061'
+
+ async up(queryRunner) {
+ await queryRunner.query(`ALTER TABLE "emoji" ADD "localOnly" boolean NOT NULL DEFAULT false`);
+ await queryRunner.query(`ALTER TABLE "emoji" ADD "isSensitive" boolean NOT NULL DEFAULT false`);
+ await queryRunner.query(`ALTER TABLE "emoji" ADD "roleIdsThatCanBeUsedThisEmojiAsReaction" character varying(128) array NOT NULL DEFAULT '{}'`);
+ }
+
+ async down(queryRunner) {
+ await queryRunner.query(`ALTER TABLE "emoji" DROP COLUMN "roleIdsThatCanBeUsedThisEmojiAsReaction"`);
+ await queryRunner.query(`ALTER TABLE "emoji" DROP COLUMN "isSensitive"`);
+ await queryRunner.query(`ALTER TABLE "emoji" DROP COLUMN "localOnly"`);
+ }
+}
diff --git a/packages/backend/package.json b/packages/backend/package.json
index 4bab4a7341..56ecbc2eaf 100644
--- a/packages/backend/package.json
+++ b/packages/backend/package.json
@@ -35,49 +35,52 @@
"@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"
+ "bufferutil": "^4.0.7",
+ "slacc-android-arm-eabi": "0.0.9",
+ "slacc-android-arm64": "0.0.9",
+ "slacc-darwin-arm64": "0.0.9",
+ "slacc-darwin-universal": "0.0.9",
+ "slacc-darwin-x64": "0.0.9",
+ "slacc-freebsd-x64": "0.0.9",
+ "slacc-linux-arm-gnueabihf": "0.0.9",
+ "slacc-linux-arm64-gnu": "0.0.9",
+ "slacc-linux-arm64-musl": "0.0.9",
+ "slacc-linux-x64-gnu": "0.0.9",
+ "slacc-win32-arm64-msvc": "0.0.9",
+ "slacc-win32-x64-msvc": "0.0.9",
+ "utf-8-validate": "^6.0.3"
},
"dependencies": {
"@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",
+ "@bull-board/api": "5.2.0",
+ "@bull-board/fastify": "5.2.0",
+ "@bull-board/ui": "5.2.0",
"@discordapp/twemoji": "14.1.2",
"@fastify/accepts": "4.1.0",
"@fastify/cookie": "8.3.0",
- "@fastify/cors": "8.2.1",
+ "@fastify/cors": "8.3.0",
"@fastify/http-proxy": "9.1.0",
"@fastify/multipart": "7.6.0",
- "@fastify/static": "6.10.1",
+ "@fastify/static": "6.10.2",
"@fastify/view": "7.4.1",
- "@nestjs/common": "9.4.0",
- "@nestjs/core": "9.4.0",
- "@nestjs/testing": "9.4.0",
+ "@nestjs/common": "9.4.2",
+ "@nestjs/core": "9.4.2",
+ "@nestjs/testing": "9.4.2",
"@peertube/http-signature": "1.7.0",
- "@sinonjs/fake-timers": "10.0.2",
+ "@sinonjs/fake-timers": "10.2.0",
"@swc/cli": "0.1.62",
- "@swc/core": "1.3.56",
+ "@swc/core": "1.3.61",
"accepts": "1.3.8",
"ajv": "8.12.0",
"archiver": "5.3.1",
"autwh": "0.1.0",
"bcryptjs": "2.4.3",
"blurhash": "2.0.5",
- "bull": "4.10.4",
+ "bullmq": "3.15.0",
"cacheable-lookup": "6.1.0",
- "cbor": "8.1.0",
+ "cbor": "9.0.0",
"chalk": "5.2.0",
"chalk-template": "0.4.0",
"chokidar": "3.5.3",
@@ -93,30 +96,30 @@
"fluent-ffmpeg": "2.1.2",
"form-data": "4.0.0",
"got": "12.6.0",
- "happy-dom": "9.16.0",
+ "happy-dom": "9.20.3",
"hpagent": "1.2.0",
"ioredis": "5.3.2",
"ip-cidr": "3.1.0",
"is-svg": "4.3.2",
"js-yaml": "4.1.0",
- "jsdom": "21.1.1",
+ "jsdom": "22.1.0",
"json5": "2.2.3",
- "jsonld": "8.1.1",
- "meilisearch": "0.32.3",
+ "jsonld": "8.2.0",
"jsrsasign": "10.8.6",
+ "meilisearch": "0.32.5",
"mfm-js": "0.23.3",
"mime-types": "2.1.35",
"misskey-js": "workspace:*",
"ms": "3.0.0-canary.1",
"nested-property": "4.0.0",
"node-fetch": "3.3.1",
- "nodemailer": "6.9.2",
+ "nodemailer": "6.9.3",
"nsfwjs": "2.4.2",
"oauth": "0.10.0",
"os-utils": "0.0.14",
"otpauth": "9.1.2",
"parse5": "7.1.2",
- "pg": "8.10.0",
+ "pg": "8.11.0",
"private-ip": "3.0.0",
"probe-image-size": "7.2.3",
"promise-limit": "2.7.0",
@@ -126,7 +129,7 @@
"qrcode": "1.5.3",
"random-seed": "0.3.0",
"ratelimiter": "3.4.1",
- "re2": "1.18.0",
+ "re2": "1.19.0",
"redis-lock": "0.1.4",
"reflect-metadata": "0.1.13",
"rename": "1.0.4",
@@ -136,27 +139,26 @@
"s-age": "1.1.2",
"sanitize-html": "2.10.0",
"seedrandom": "3.0.5",
- "semver": "7.5.0",
+ "semver": "7.5.1",
"sharp": "0.32.1",
"sharp-read-bmp": "github:misskey-dev/sharp-read-bmp",
- "slacc": "0.0.7",
+ "slacc": "0.0.9",
"strict-event-emitter-types": "2.0.0",
"stringz": "2.1.0",
"summaly": "github:misskey-dev/summaly",
- "systeminformation": "5.17.12",
+ "systeminformation": "5.17.16",
"tinycolor2": "1.6.0",
"tmp": "0.2.1",
"tsc-alias": "1.8.6",
"tsconfig-paths": "4.2.0",
"twemoji-parser": "14.0.0",
"typeorm": "0.3.16",
- "typescript": "5.0.4",
+ "typescript": "5.1.3",
"ulid": "2.3.0",
- "unzipper": "0.10.11",
+ "unzipper": "0.10.14",
"uuid": "9.0.0",
"vary": "1.1.2",
"web-push": "3.6.1",
- "websocket": "1.0.34",
"ws": "8.13.0",
"xev": "3.0.2"
},
@@ -166,23 +168,22 @@
"@types/accepts": "1.3.5",
"@types/archiver": "5.3.2",
"@types/bcryptjs": "2.4.2",
- "@types/bull": "4.10.0",
"@types/cbor": "6.0.0",
"@types/color-convert": "2.0.0",
"@types/content-disposition": "0.5.5",
"@types/escape-regexp": "0.0.1",
"@types/fluent-ffmpeg": "2.1.21",
- "@types/jest": "29.5.1",
+ "@types/jest": "29.5.2",
"@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": "20.1.3",
+ "@types/node": "20.2.5",
"@types/node-fetch": "3.0.3",
- "@types/nodemailer": "6.4.7",
+ "@types/nodemailer": "6.4.8",
"@types/oauth": "0.9.1",
- "@types/pg": "8.6.6",
+ "@types/pg": "8.10.1",
"@types/pug": "2.0.6",
"@types/punycode": "2.1.0",
"@types/qrcode": "1.5.0",
@@ -196,17 +197,17 @@
"@types/sinonjs__fake-timers": "8.1.2",
"@types/tinycolor2": "1.4.3",
"@types/tmp": "0.2.3",
- "@types/unzipper": "0.10.5",
+ "@types/unzipper": "0.10.6",
"@types/uuid": "9.0.1",
"@types/vary": "1.1.0",
"@types/web-push": "3.3.2",
"@types/websocket": "1.0.5",
"@types/ws": "8.5.4",
- "@typescript-eslint/eslint-plugin": "5.59.5",
- "@typescript-eslint/parser": "5.59.5",
+ "@typescript-eslint/eslint-plugin": "5.59.8",
+ "@typescript-eslint/parser": "5.59.8",
"aws-sdk-client-mock": "2.1.1",
"cross-env": "7.0.3",
- "eslint": "8.40.0",
+ "eslint": "8.41.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 5fb4e8ef3c..406e3192bb 100644
--- a/packages/backend/src/GlobalModule.ts
+++ b/packages/backend/src/GlobalModule.ts
@@ -4,7 +4,7 @@ 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 { Config, loadConfig } from './config.js';
import { createPostgresDataSource } from './postgres.js';
import { RepositoryModule } from './models/RepositoryModule.js';
import type { Provider, OnApplicationShutdown } from '@nestjs/common';
@@ -25,7 +25,7 @@ const $db: Provider = {
const $meilisearch: Provider = {
provide: DI.meilisearch,
- useFactory: (config) => {
+ useFactory: (config: Config) => {
if (config.meilisearch) {
return new MeiliSearch({
host: `${config.meilisearch.ssl ? 'https' : 'http' }://${config.meilisearch.host}:${config.meilisearch.port}`,
@@ -40,7 +40,7 @@ const $meilisearch: Provider = {
const $redis: Provider = {
provide: DI.redis,
- useFactory: (config) => {
+ useFactory: (config: Config) => {
return new Redis.Redis({
port: config.redis.port,
host: config.redis.host,
@@ -55,7 +55,7 @@ const $redis: Provider = {
const $redisForPub: Provider = {
provide: DI.redisForPub,
- useFactory: (config) => {
+ useFactory: (config: Config) => {
const redis = new Redis.Redis({
port: config.redisForPubsub.port,
host: config.redisForPubsub.host,
@@ -71,7 +71,7 @@ const $redisForPub: Provider = {
const $redisForSub: Provider = {
provide: DI.redisForSub,
- useFactory: (config) => {
+ useFactory: (config: Config) => {
const redis = new Redis.Redis({
port: config.redisForPubsub.port,
host: config.redisForPubsub.host,
@@ -100,7 +100,7 @@ export class GlobalModule implements OnApplicationShutdown {
@Inject(DI.redisForSub) private redisForSub: Redis.Redis,
) {}
- async onApplicationShutdown(signal: string): Promise<void> {
+ public async dispose(): Promise<void> {
if (process.env.NODE_ENV === 'test') {
// XXX:
// Shutting down the existing connections causes errors on Jest as
@@ -116,4 +116,8 @@ export class GlobalModule implements OnApplicationShutdown {
this.redisForSub.disconnect(),
]);
}
+
+ async onApplicationShutdown(signal: string): Promise<void> {
+ await this.dispose();
+ }
}
diff --git a/packages/backend/src/config.ts b/packages/backend/src/config.ts
index c6e1075389..9d1945e4d4 100644
--- a/packages/backend/src/config.ts
+++ b/packages/backend/src/config.ts
@@ -144,7 +144,7 @@ export function loadConfig() {
const clientManifestExists = fs.existsSync(_dirname + '/../../../built/_vite_/manifest.json');
const clientManifest = clientManifestExists ?
JSON.parse(fs.readFileSync(`${_dirname}/../../../built/_vite_/manifest.json`, 'utf-8'))
- : { 'src/init.ts': { file: 'src/init.ts' } };
+ : { 'src/_boot_.ts': { file: 'src/_boot_.ts' } };
const config = yaml.load(fs.readFileSync(path, 'utf-8')) as Source;
const mixin = {} as Mixin;
@@ -165,7 +165,7 @@ export function loadConfig() {
mixin.authUrl = `${mixin.scheme}://${mixin.host}/auth`;
mixin.driveUrl = `${mixin.scheme}://${mixin.host}/files`;
mixin.userAgent = `Misskey/${meta.version} (${config.url})`;
- mixin.clientEntry = clientManifest['src/init.ts'];
+ mixin.clientEntry = clientManifest['src/_boot_.ts'];
mixin.clientManifestExists = clientManifestExists;
const externalMediaProxy = config.mediaProxy ?
@@ -190,6 +190,6 @@ function tryCreateUrl(url: string) {
try {
return new URL(url);
} catch (e) {
- throw `url="${url}" is not a valid URL.`;
+ throw new Error(`url="${url}" is not a valid URL.`);
}
}
diff --git a/packages/backend/src/core/AntennaService.ts b/packages/backend/src/core/AntennaService.ts
index 2d4226a32d..d8df371916 100644
--- a/packages/backend/src/core/AntennaService.ts
+++ b/packages/backend/src/core/AntennaService.ts
@@ -56,11 +56,6 @@ export class AntennaService implements OnApplicationShutdown {
}
@bindThis
- public onApplicationShutdown(signal?: string | undefined) {
- this.redisForSub.off('message', this.onRedisMessage);
- }
-
- @bindThis
private async onRedisMessage(_: string, data: string): Promise<void> {
const obj = JSON.parse(data);
@@ -196,4 +191,14 @@ export class AntennaService implements OnApplicationShutdown {
return this.antennas;
}
+
+ @bindThis
+ public dispose(): void {
+ this.redisForSub.off('message', this.onRedisMessage);
+ }
+
+ @bindThis
+ public onApplicationShutdown(signal?: string | undefined): void {
+ this.dispose();
+ }
}
diff --git a/packages/backend/src/core/CacheService.ts b/packages/backend/src/core/CacheService.ts
index cf1e81ffc8..de33e4c243 100644
--- a/packages/backend/src/core/CacheService.ts
+++ b/packages/backend/src/core/CacheService.ts
@@ -166,7 +166,12 @@ export class CacheService implements OnApplicationShutdown {
}
@bindThis
- public onApplicationShutdown(signal?: string | undefined) {
+ public dispose(): void {
this.redisForSub.off('message', this.onMessage);
}
+
+ @bindThis
+ public onApplicationShutdown(signal?: string | undefined): void {
+ this.dispose();
+ }
}
diff --git a/packages/backend/src/core/CaptchaService.ts b/packages/backend/src/core/CaptchaService.ts
index 7aaa1b833f..1a52a229c5 100644
--- a/packages/backend/src/core/CaptchaService.ts
+++ b/packages/backend/src/core/CaptchaService.ts
@@ -30,7 +30,7 @@ export class CaptchaService {
}, { throwErrorWhenResponseNotOk: false });
if (!res.ok) {
- throw `${res.status}`;
+ throw new Error(`${res.status}`);
}
return await res.json() as CaptchaResponse;
@@ -39,48 +39,48 @@ export class CaptchaService {
@bindThis
public async verifyRecaptcha(secret: string, response: string | null | undefined): Promise<void> {
if (response == null) {
- throw 'recaptcha-failed: no response provided';
+ throw new Error('recaptcha-failed: no response provided');
}
const result = await this.getCaptchaResponse('https://www.recaptcha.net/recaptcha/api/siteverify', secret, response).catch(err => {
- throw `recaptcha-request-failed: ${err}`;
+ throw new Error(`recaptcha-request-failed: ${err}`);
});
if (result.success !== true) {
const errorCodes = result['error-codes'] ? result['error-codes'].join(', ') : '';
- throw `recaptcha-failed: ${errorCodes}`;
+ throw new Error(`recaptcha-failed: ${errorCodes}`);
}
}
@bindThis
public async verifyHcaptcha(secret: string, response: string | null | undefined): Promise<void> {
if (response == null) {
- throw 'hcaptcha-failed: no response provided';
+ throw new Error('hcaptcha-failed: no response provided');
}
const result = await this.getCaptchaResponse('https://hcaptcha.com/siteverify', secret, response).catch(err => {
- throw `hcaptcha-request-failed: ${err}`;
+ throw new Error(`hcaptcha-request-failed: ${err}`);
});
if (result.success !== true) {
const errorCodes = result['error-codes'] ? result['error-codes'].join(', ') : '';
- throw `hcaptcha-failed: ${errorCodes}`;
+ throw new Error(`hcaptcha-failed: ${errorCodes}`);
}
}
@bindThis
public async verifyTurnstile(secret: string, response: string | null | undefined): Promise<void> {
if (response == null) {
- throw 'turnstile-failed: no response provided';
+ throw new Error('turnstile-failed: no response provided');
}
const result = await this.getCaptchaResponse('https://challenges.cloudflare.com/turnstile/v0/siteverify', secret, response).catch(err => {
- throw `turnstile-request-failed: ${err}`;
+ throw new Error(`turnstile-request-failed: ${err}`);
});
if (result.success !== true) {
const errorCodes = result['error-codes'] ? result['error-codes'].join(', ') : '';
- throw `turnstile-failed: ${errorCodes}`;
+ throw new Error(`turnstile-failed: ${errorCodes}`);
}
}
}
diff --git a/packages/backend/src/core/CustomEmojiService.ts b/packages/backend/src/core/CustomEmojiService.ts
index 93557ce617..3499df38b7 100644
--- a/packages/backend/src/core/CustomEmojiService.ts
+++ b/packages/backend/src/core/CustomEmojiService.ts
@@ -7,7 +7,7 @@ import { EmojiEntityService } from '@/core/entities/EmojiEntityService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import type { DriveFile } from '@/models/entities/DriveFile.js';
import type { Emoji } from '@/models/entities/Emoji.js';
-import type { EmojisRepository } from '@/models/index.js';
+import type { EmojisRepository, Role } from '@/models/index.js';
import { bindThis } from '@/decorators.js';
import { MemoryKVCache, RedisSingleCache } from '@/misc/cache.js';
import { UtilityService } from '@/core/UtilityService.js';
@@ -15,6 +15,8 @@ import type { Config } from '@/config.js';
import { query } from '@/misc/prelude/url.js';
import type { Serialized } from '@/server/api/stream/types.js';
+const parseEmojiStrRegexp = /^(\w+)(?:@([\w.-]+))?$/;
+
@Injectable()
export class CustomEmojiService {
private cache: MemoryKVCache<Emoji | null>;
@@ -63,6 +65,9 @@ export class CustomEmojiService {
aliases: string[];
host: string | null;
license: string | null;
+ isSensitive: boolean;
+ localOnly: boolean;
+ roleIdsThatCanBeUsedThisEmojiAsReaction: Role['id'][];
}): Promise<Emoji> {
const emoji = await this.emojisRepository.insert({
id: this.idService.genId(),
@@ -75,6 +80,9 @@ export class CustomEmojiService {
publicUrl: data.driveFile.webpublicUrl ?? data.driveFile.url,
type: data.driveFile.webpublicType ?? data.driveFile.type,
license: data.license,
+ isSensitive: data.isSensitive,
+ localOnly: data.localOnly,
+ roleIdsThatCanBeUsedThisEmojiAsReaction: data.roleIdsThatCanBeUsedThisEmojiAsReaction,
}).then(x => this.emojisRepository.findOneByOrFail(x.identifiers[0]));
if (data.host == null) {
@@ -90,10 +98,14 @@ export class CustomEmojiService {
@bindThis
public async update(id: Emoji['id'], data: {
+ driveFile?: DriveFile;
name?: string;
category?: string | null;
aliases?: string[];
license?: string | null;
+ isSensitive?: boolean;
+ localOnly?: boolean;
+ roleIdsThatCanBeUsedThisEmojiAsReaction?: Role['id'][];
}): Promise<void> {
const emoji = await this.emojisRepository.findOneByOrFail({ id: id });
const sameNameEmoji = await this.emojisRepository.findOneBy({ name: data.name, host: IsNull() });
@@ -105,6 +117,12 @@ export class CustomEmojiService {
category: data.category,
aliases: data.aliases,
license: data.license,
+ isSensitive: data.isSensitive,
+ localOnly: data.localOnly,
+ originalUrl: data.driveFile != null ? data.driveFile.url : undefined,
+ publicUrl: data.driveFile != null ? (data.driveFile.webpublicUrl ?? data.driveFile.url) : undefined,
+ type: data.driveFile != null ? (data.driveFile.webpublicType ?? data.driveFile.type) : undefined,
+ roleIdsThatCanBeUsedThisEmojiAsReaction: data.roleIdsThatCanBeUsedThisEmojiAsReaction ?? undefined,
});
this.localEmojisCache.refresh();
@@ -259,7 +277,7 @@ export class CustomEmojiService {
@bindThis
public parseEmojiStr(emojiName: string, noteUserHost: string | null) {
- const match = emojiName.match(/^(\w+)(?:@([\w.-]+))?$/);
+ const match = emojiName.match(parseEmojiStrRegexp);
if (!match) return { name: null, host: null };
const name = match[1];
diff --git a/packages/backend/src/core/FetchInstanceMetadataService.ts b/packages/backend/src/core/FetchInstanceMetadataService.ts
index 8103d5afe9..9de633350b 100644
--- a/packages/backend/src/core/FetchInstanceMetadataService.ts
+++ b/packages/backend/src/core/FetchInstanceMetadataService.ts
@@ -116,14 +116,14 @@ export class FetchInstanceMetadataService {
const wellknown = await this.httpRequestService.getJson('https://' + instance.host + '/.well-known/nodeinfo')
.catch(err => {
if (err.statusCode === 404) {
- throw 'No nodeinfo provided';
+ throw new Error('No nodeinfo provided');
} else {
throw err.statusCode ?? err.message;
}
}) as Record<string, unknown>;
if (wellknown.links == null || !Array.isArray(wellknown.links)) {
- throw 'No wellknown links';
+ throw new Error('No wellknown links');
}
const links = wellknown.links as any[];
@@ -134,7 +134,7 @@ export class FetchInstanceMetadataService {
const link = lnik2_1 ?? lnik2_0 ?? lnik1_0;
if (link == null) {
- throw 'No nodeinfo link provided';
+ throw new Error('No nodeinfo link provided');
}
const info = await this.httpRequestService.getJson(link.href)
diff --git a/packages/backend/src/core/MetaService.ts b/packages/backend/src/core/MetaService.ts
index 0b861be8d0..5acc9ad9ad 100644
--- a/packages/backend/src/core/MetaService.ts
+++ b/packages/backend/src/core/MetaService.ts
@@ -120,8 +120,13 @@ export class MetaService implements OnApplicationShutdown {
}
@bindThis
- public onApplicationShutdown(signal?: string | undefined) {
+ public dispose(): void {
clearInterval(this.intervalId);
this.redisForSub.off('message', this.onMessage);
}
+
+ @bindThis
+ public onApplicationShutdown(signal?: string | undefined): void {
+ this.dispose();
+ }
}
diff --git a/packages/backend/src/core/MfmService.ts b/packages/backend/src/core/MfmService.ts
index 9b2d5dc0ff..dffee16e08 100644
--- a/packages/backend/src/core/MfmService.ts
+++ b/packages/backend/src/core/MfmService.ts
@@ -83,7 +83,7 @@ export class MfmService {
if (hashtagNames && href && hashtagNames.map(x => x.toLowerCase()).includes(txt.toLowerCase())) {
text += txt;
// メンション
- } else if (txt.startsWith('@') && !(rel && rel.value.match(/^me /))) {
+ } else if (txt.startsWith('@') && !(rel && rel.value.startsWith('me '))) {
const part = txt.split('@');
if (part.length === 2 && href) {
diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts
index 977c9052c0..1c8491bf57 100644
--- a/packages/backend/src/core/NoteCreateService.ts
+++ b/packages/backend/src/core/NoteCreateService.ts
@@ -510,7 +510,7 @@ export class NoteCreateService implements OnApplicationShutdown {
if (data.poll && data.poll.expiresAt) {
const delay = data.poll.expiresAt.getTime() - Date.now();
- this.queueService.endedPollNotificationQueue.add({
+ this.queueService.endedPollNotificationQueue.add(note.id, {
noteId: note.id,
}, {
delay,
@@ -790,7 +790,13 @@ export class NoteCreateService implements OnApplicationShutdown {
return mentionedUsers;
}
- onApplicationShutdown(signal?: string | undefined) {
+ @bindThis
+ public dispose(): void {
this.#shutdownController.abort();
}
+
+ @bindThis
+ public onApplicationShutdown(signal?: string | undefined): void {
+ this.dispose();
+ }
}
diff --git a/packages/backend/src/core/NoteReadService.ts b/packages/backend/src/core/NoteReadService.ts
index 1129bd159c..e57e57d310 100644
--- a/packages/backend/src/core/NoteReadService.ts
+++ b/packages/backend/src/core/NoteReadService.ts
@@ -122,7 +122,13 @@ export class NoteReadService implements OnApplicationShutdown {
}
}
- onApplicationShutdown(signal?: string | undefined): void {
+ @bindThis
+ public dispose(): void {
this.#shutdownController.abort();
}
+
+ @bindThis
+ public onApplicationShutdown(signal?: string | undefined): void {
+ this.dispose();
+ }
}
diff --git a/packages/backend/src/core/NotificationService.ts b/packages/backend/src/core/NotificationService.ts
index a245908c98..ed47165f7b 100644
--- a/packages/backend/src/core/NotificationService.ts
+++ b/packages/backend/src/core/NotificationService.ts
@@ -152,7 +152,13 @@ export class NotificationService implements OnApplicationShutdown {
*/
}
- onApplicationShutdown(signal?: string | undefined): void {
+ @bindThis
+ public dispose(): void {
this.#shutdownController.abort();
}
+
+ @bindThis
+ public onApplicationShutdown(signal?: string | undefined): void {
+ this.dispose();
+ }
}
diff --git a/packages/backend/src/core/QueryService.ts b/packages/backend/src/core/QueryService.ts
index 0cee2076bf..bf50a1cded 100644
--- a/packages/backend/src/core/QueryService.ts
+++ b/packages/backend/src/core/QueryService.ts
@@ -208,7 +208,7 @@ export class QueryService {
}
@bindThis
- public generateRepliesQuery(q: SelectQueryBuilder<any>, me?: Pick<User, 'id' | 'showTimelineReplies'> | null): void {
+ public generateRepliesQuery(q: SelectQueryBuilder<any>, withReplies: boolean, me?: Pick<User, 'id'> | null): void {
if (me == null) {
q.andWhere(new Brackets(qb => { qb
.where('note.replyId IS NULL') // 返信ではない
@@ -217,7 +217,7 @@ export class QueryService {
.andWhere('note.replyUserId = note.userId');
}));
}));
- } else if (!me.showTimelineReplies) {
+ } else if (!withReplies) {
q.andWhere(new Brackets(qb => { qb
.where('note.replyId IS NULL') // 返信ではない
.orWhere('note.replyUserId = :meId', { meId: me.id }) // 返信だけど自分のノートへの返信
diff --git a/packages/backend/src/core/QueueModule.ts b/packages/backend/src/core/QueueModule.ts
index 1d73947776..3384ca4577 100644
--- a/packages/backend/src/core/QueueModule.ts
+++ b/packages/backend/src/core/QueueModule.ts
@@ -1,42 +1,11 @@
import { setTimeout } from 'node:timers/promises';
import { Inject, Module, OnApplicationShutdown } from '@nestjs/common';
-import Bull from 'bull';
+import * as Bull from 'bullmq';
import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
+import { QUEUE, baseQueueOptions } from '@/queue/const.js';
import type { Provider } from '@nestjs/common';
-import type { DeliverJobData, InboxJobData, DbJobData, ObjectStorageJobData, EndedPollNotificationJobData, WebhookDeliverJobData, RelationshipJobData, DbJobMap } from '../queue/types.js';
-
-function q<T>(config: Config, name: string, limitPerSec = -1) {
- return new Bull<T>(name, {
- redis: {
- port: config.redisForJobQueue.port,
- host: config.redisForJobQueue.host,
- family: config.redisForJobQueue.family == null ? 0 : config.redisForJobQueue.family,
- password: config.redisForJobQueue.pass,
- db: config.redisForJobQueue.db ?? 0,
- },
- prefix: config.redisForJobQueue.prefix ? `${config.redisForJobQueue.prefix}:queue` : 'queue',
- limiter: limitPerSec > 0 ? {
- max: limitPerSec,
- duration: 1000,
- } : undefined,
- settings: {
- backoffStrategies: {
- apBackoff,
- },
- },
- });
-}
-
-// ref. https://github.com/misskey-dev/misskey/pull/7635#issue-971097019
-function apBackoff(attemptsMade: number, err: Error) {
- const baseDelay = 60 * 1000; // 1min
- const maxBackoff = 8 * 60 * 60 * 1000; // 8hours
- let backoff = (Math.pow(2, attemptsMade) - 1) * baseDelay;
- backoff = Math.min(backoff, maxBackoff);
- backoff += Math.round(backoff * Math.random() * 0.2);
- return backoff;
-}
+import type { DeliverJobData, InboxJobData, EndedPollNotificationJobData, WebhookDeliverJobData, RelationshipJobData } from '../queue/types.js';
export type SystemQueue = Bull.Queue<Record<string, unknown>>;
export type EndedPollNotificationQueue = Bull.Queue<EndedPollNotificationJobData>;
@@ -49,49 +18,49 @@ export type WebhookDeliverQueue = Bull.Queue<WebhookDeliverJobData>;
const $system: Provider = {
provide: 'queue:system',
- useFactory: (config: Config) => q(config, 'system'),
+ useFactory: (config: Config) => new Bull.Queue(QUEUE.SYSTEM, baseQueueOptions(config, QUEUE.SYSTEM)),
inject: [DI.config],
};
const $endedPollNotification: Provider = {
provide: 'queue:endedPollNotification',
- useFactory: (config: Config) => q(config, 'endedPollNotification'),
+ useFactory: (config: Config) => new Bull.Queue(QUEUE.ENDED_POLL_NOTIFICATION, baseQueueOptions(config, QUEUE.ENDED_POLL_NOTIFICATION)),
inject: [DI.config],
};
const $deliver: Provider = {
provide: 'queue:deliver',
- useFactory: (config: Config) => q(config, 'deliver', config.deliverJobPerSec ?? 128),
+ useFactory: (config: Config) => new Bull.Queue(QUEUE.DELIVER, baseQueueOptions(config, QUEUE.DELIVER)),
inject: [DI.config],
};
const $inbox: Provider = {
provide: 'queue:inbox',
- useFactory: (config: Config) => q(config, 'inbox', config.inboxJobPerSec ?? 16),
+ useFactory: (config: Config) => new Bull.Queue(QUEUE.INBOX, baseQueueOptions(config, QUEUE.INBOX)),
inject: [DI.config],
};
const $db: Provider = {
provide: 'queue:db',
- useFactory: (config: Config) => q(config, 'db'),
+ useFactory: (config: Config) => new Bull.Queue(QUEUE.DB, baseQueueOptions(config, QUEUE.DB)),
inject: [DI.config],
};
const $relationship: Provider = {
provide: 'queue:relationship',
- useFactory: (config: Config) => q(config, 'relationship', config.relashionshipJobPerSec ?? 64),
+ useFactory: (config: Config) => new Bull.Queue(QUEUE.RELATIONSHIP, baseQueueOptions(config, QUEUE.RELATIONSHIP)),
inject: [DI.config],
};
const $objectStorage: Provider = {
provide: 'queue:objectStorage',
- useFactory: (config: Config) => q(config, 'objectStorage'),
+ useFactory: (config: Config) => new Bull.Queue(QUEUE.OBJECT_STORAGE, baseQueueOptions(config, QUEUE.OBJECT_STORAGE)),
inject: [DI.config],
};
const $webhookDeliver: Provider = {
provide: 'queue:webhookDeliver',
- useFactory: (config: Config) => q(config, 'webhookDeliver', 64),
+ useFactory: (config: Config) => new Bull.Queue(QUEUE.WEBHOOK_DELIVER, baseQueueOptions(config, QUEUE.WEBHOOK_DELIVER)),
inject: [DI.config],
};
@@ -131,7 +100,7 @@ export class QueueModule implements OnApplicationShutdown {
@Inject('queue:webhookDeliver') public webhookDeliverQueue: WebhookDeliverQueue,
) {}
- async onApplicationShutdown(signal: string): Promise<void> {
+ public async dispose(): Promise<void> {
if (process.env.NODE_ENV === 'test') {
// XXX:
// Shutting down the existing connections causes errors on Jest as
@@ -151,4 +120,8 @@ export class QueueModule implements OnApplicationShutdown {
this.webhookDeliverQueue.close(),
]);
}
+
+ async onApplicationShutdown(signal: string): Promise<void> {
+ await this.dispose();
+ }
}
diff --git a/packages/backend/src/core/QueueService.ts b/packages/backend/src/core/QueueService.ts
index b4ffffecc0..2ae8a2b754 100644
--- a/packages/backend/src/core/QueueService.ts
+++ b/packages/backend/src/core/QueueService.ts
@@ -1,6 +1,5 @@
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';
@@ -11,6 +10,7 @@ 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 type * as Bull from 'bullmq';
@Injectable()
export class QueueService {
@@ -26,7 +26,43 @@ export class QueueService {
@Inject('queue:relationship') public relationshipQueue: RelationshipQueue,
@Inject('queue:objectStorage') public objectStorageQueue: ObjectStorageQueue,
@Inject('queue:webhookDeliver') public webhookDeliverQueue: WebhookDeliverQueue,
- ) {}
+ ) {
+ this.systemQueue.add('tickCharts', {
+ }, {
+ repeat: { pattern: '55 * * * *' },
+ removeOnComplete: true,
+ });
+
+ this.systemQueue.add('resyncCharts', {
+ }, {
+ repeat: { pattern: '0 0 * * *' },
+ removeOnComplete: true,
+ });
+
+ this.systemQueue.add('cleanCharts', {
+ }, {
+ repeat: { pattern: '0 0 * * *' },
+ removeOnComplete: true,
+ });
+
+ this.systemQueue.add('aggregateRetention', {
+ }, {
+ repeat: { pattern: '0 0 * * *' },
+ removeOnComplete: true,
+ });
+
+ this.systemQueue.add('clean', {
+ }, {
+ repeat: { pattern: '0 0 * * *' },
+ removeOnComplete: true,
+ });
+
+ this.systemQueue.add('checkExpiredMutings', {
+ }, {
+ repeat: { pattern: '*/5 * * * *' },
+ removeOnComplete: true,
+ });
+ }
@bindThis
public deliver(user: ThinUser, content: IActivity | null, to: string | null, isSharedInbox: boolean) {
@@ -42,11 +78,10 @@ export class QueueService {
isSharedInbox,
};
- return this.deliverQueue.add(data, {
+ return this.deliverQueue.add(to, data, {
attempts: this.config.deliverJobMaxAttempts ?? 12,
- timeout: 1 * 60 * 1000, // 1min
backoff: {
- type: 'apBackoff',
+ type: 'custom',
},
removeOnComplete: true,
removeOnFail: true,
@@ -60,11 +95,10 @@ export class QueueService {
signature,
};
- return this.inboxQueue.add(data, {
+ return this.inboxQueue.add('', data, {
attempts: this.config.inboxJobMaxAttempts ?? 8,
- timeout: 5 * 60 * 1000, // 5min
backoff: {
- type: 'apBackoff',
+ type: 'custom',
},
removeOnComplete: true,
removeOnFail: true,
@@ -212,7 +246,7 @@ export class QueueService {
private generateToDbJobData<T extends 'importFollowingToDb' | 'importBlockingToDb', D extends DbJobData<T>>(name: T, data: D): {
name: string,
data: D,
- opts: Bull.JobOptions,
+ opts: Bull.JobsOptions,
} {
return {
name,
@@ -299,10 +333,10 @@ export class QueueService {
}
@bindThis
- private generateRelationshipJobData(name: 'follow' | 'unfollow' | 'block' | 'unblock', data: RelationshipJobData, opts: Bull.JobOptions = {}): {
+ private generateRelationshipJobData(name: 'follow' | 'unfollow' | 'block' | 'unblock', data: RelationshipJobData, opts: Bull.JobsOptions = {}): {
name: string,
data: RelationshipJobData,
- opts: Bull.JobOptions,
+ opts: Bull.JobsOptions,
} {
return {
name,
@@ -351,11 +385,10 @@ export class QueueService {
eventId: uuid(),
};
- return this.webhookDeliverQueue.add(data, {
+ return this.webhookDeliverQueue.add(webhook.id, data, {
attempts: 4,
- timeout: 1 * 60 * 1000, // 1min
backoff: {
- type: 'apBackoff',
+ type: 'custom',
},
removeOnComplete: true,
removeOnFail: true,
@@ -367,11 +400,11 @@ export class QueueService {
this.deliverQueue.once('cleaned', (jobs, status) => {
//deliverLogger.succ(`Cleaned ${jobs.length} ${status} jobs`);
});
- this.deliverQueue.clean(0, 'delayed');
+ this.deliverQueue.clean(0, Infinity, 'delayed');
this.inboxQueue.once('cleaned', (jobs, status) => {
//inboxLogger.succ(`Cleaned ${jobs.length} ${status} jobs`);
});
- this.inboxQueue.clean(0, 'delayed');
+ this.inboxQueue.clean(0, Infinity, 'delayed');
}
}
diff --git a/packages/backend/src/core/ReactionService.ts b/packages/backend/src/core/ReactionService.ts
index a274b19e4b..4b01b6af7e 100644
--- a/packages/backend/src/core/ReactionService.ts
+++ b/packages/backend/src/core/ReactionService.ts
@@ -20,6 +20,7 @@ import { bindThis } from '@/decorators.js';
import { UtilityService } from '@/core/UtilityService.js';
import { UserBlockingService } from '@/core/UserBlockingService.js';
import { CustomEmojiService } from '@/core/CustomEmojiService.js';
+import { RoleService } from '@/core/RoleService.js';
const FALLBACK = '❤';
@@ -54,6 +55,9 @@ type DecodedReaction = {
host?: string | null;
};
+const isCustomEmojiRegexp = /^:([\w+-]+)(?:@\.)?:$/;
+const decodeCustomEmojiRegexp = /^:([\w+-]+)(?:@([\w.-]+))?:$/;
+
@Injectable()
export class ReactionService {
constructor(
@@ -72,6 +76,7 @@ export class ReactionService {
private utilityService: UtilityService,
private metaService: MetaService,
private customEmojiService: CustomEmojiService,
+ private roleService: RoleService,
private userEntityService: UserEntityService,
private noteEntityService: NoteEntityService,
private userBlockingService: UserBlockingService,
@@ -85,7 +90,7 @@ export class ReactionService {
}
@bindThis
- public async create(user: { id: User['id']; host: User['host']; isBot: User['isBot'] }, note: Note, reaction?: string | null) {
+ public async create(user: { id: User['id']; host: User['host']; isBot: User['isBot'] }, note: Note, _reaction?: string | null) {
// Check blocking
if (note.userId !== user.id) {
const blocked = await this.userBlockingService.checkBlocked(note.userId, user.id);
@@ -99,10 +104,41 @@ export class ReactionService {
throw new IdentifiableError('68e9d2d1-48bf-42c2-b90a-b20e09fd3d48', 'Note not accessible for you.');
}
- if (note.reactionAcceptance === 'likeOnly' || ((note.reactionAcceptance === 'likeOnlyForRemote') && (user.host != null))) {
+ let reaction = _reaction ?? FALLBACK;
+
+ if (note.reactionAcceptance === 'likeOnly' || ((note.reactionAcceptance === 'likeOnlyForRemote' || note.reactionAcceptance === 'nonSensitiveOnlyForLocalLikeOnlyForRemote') && (user.host != null))) {
reaction = '❤️';
- } else {
- reaction = await this.toDbReaction(reaction, user.host);
+ } else if (_reaction) {
+ const custom = reaction.match(isCustomEmojiRegexp);
+ if (custom) {
+ const reacterHost = this.utilityService.toPunyNullable(user.host);
+
+ const name = custom[1];
+ const emoji = reacterHost == null
+ ? (await this.customEmojiService.localEmojisCache.fetch()).get(name)
+ : await this.emojisRepository.findOneBy({
+ host: reacterHost,
+ name,
+ });
+
+ if (emoji) {
+ if (emoji.roleIdsThatCanBeUsedThisEmojiAsReaction.length === 0 || (await this.roleService.getUserRoles(user.id)).some(r => emoji.roleIdsThatCanBeUsedThisEmojiAsReaction.includes(r.id))) {
+ reaction = reacterHost ? `:${name}@${reacterHost}:` : `:${name}:`;
+
+ // センシティブ
+ if ((note.reactionAcceptance === 'nonSensitiveOnly') && emoji.isSensitive) {
+ reaction = FALLBACK;
+ }
+ } else {
+ // リアクションとして使う権限がない
+ reaction = FALLBACK;
+ }
+ } else {
+ reaction = FALLBACK;
+ }
+ } else {
+ reaction = this.normalize(reaction ?? null);
+ }
}
const record: NoteReaction = {
@@ -288,11 +324,9 @@ export class ReactionService {
}
@bindThis
- public async toDbReaction(reaction?: string | null, reacterHost?: string | null): Promise<string> {
+ public normalize(reaction: string | null): string {
if (reaction == null) return FALLBACK;
- reacterHost = this.utilityService.toPunyNullable(reacterHost);
-
// 文字列タイプのリアクションを絵文字に変換
if (Object.keys(legacies).includes(reaction)) return legacies[reaction];
@@ -306,25 +340,12 @@ export class ReactionService {
return unicode.match('\u200d') ? unicode : unicode.replace(/\ufe0f/g, '');
}
- const custom = reaction.match(/^:([\w+-]+)(?:@\.)?:$/);
- if (custom) {
- const name = custom[1];
- const emoji = reacterHost == null
- ? (await this.customEmojiService.localEmojisCache.fetch()).get(name)
- : await this.emojisRepository.findOneBy({
- host: reacterHost,
- name,
- });
-
- if (emoji) return reacterHost ? `:${name}@${reacterHost}:` : `:${name}:`;
- }
-
return FALLBACK;
}
@bindThis
public decodeReaction(str: string): DecodedReaction {
- const custom = str.match(/^:([\w+-]+)(?:@([\w.-]+))?:$/);
+ const custom = str.match(decodeCustomEmojiRegexp);
if (custom) {
const name = custom[1];
diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts
index 68087ccc3b..40ae106662 100644
--- a/packages/backend/src/core/RoleService.ts
+++ b/packages/backend/src/core/RoleService.ts
@@ -307,6 +307,14 @@ export class RoleService implements OnApplicationShutdown {
}
@bindThis
+ public async isExplorable(role: { id: Role['id']} | null): Promise<boolean> {
+ if (role == null) return false;
+ const check = await this.rolesRepository.findOneBy({ id: role.id });
+ if (check == null) return false;
+ return check.isExplorable;
+ }
+
+ @bindThis
public async getModeratorIds(includeAdmins = true): Promise<User['id'][]> {
const roles = await this.rolesCache.fetch(() => this.rolesRepository.findBy({}));
const moderatorRoles = includeAdmins ? roles.filter(r => r.isModerator || r.isAdministrator) : roles.filter(r => r.isModerator);
@@ -425,7 +433,12 @@ export class RoleService implements OnApplicationShutdown {
}
@bindThis
- public onApplicationShutdown(signal?: string | undefined) {
+ public dispose(): void {
this.redisForSub.off('message', this.onMessage);
}
+
+ @bindThis
+ public onApplicationShutdown(signal?: string | undefined): void {
+ this.dispose();
+ }
}
diff --git a/packages/backend/src/core/WebfingerService.ts b/packages/backend/src/core/WebfingerService.ts
index 3ee7990643..f58a6a10fc 100644
--- a/packages/backend/src/core/WebfingerService.ts
+++ b/packages/backend/src/core/WebfingerService.ts
@@ -16,6 +16,9 @@ type IWebFinger = {
subject: string;
};
+const urlRegex = /^https?:\/\//;
+const mRegex = /^([^@]+)@(.*)/;
+
@Injectable()
export class WebfingerService {
constructor(
@@ -35,12 +38,12 @@ export class WebfingerService {
@bindThis
private genUrl(query: string): string {
- if (query.match(/^https?:\/\//)) {
+ if (query.match(urlRegex)) {
const u = new URL(query);
return `${u.protocol}//${u.hostname}/.well-known/webfinger?` + urlQuery({ resource: query });
}
- const m = query.match(/^([^@]+)@(.*)/);
+ const m = query.match(mRegex);
if (m) {
const hostname = m[2];
const useHttp = process.env.MISSKEY_WEBFINGER_USE_HTTP && process.env.MISSKEY_WEBFINGER_USE_HTTP.toLowerCase() === 'true';
diff --git a/packages/backend/src/core/WebhookService.ts b/packages/backend/src/core/WebhookService.ts
index 57baade777..467755a072 100644
--- a/packages/backend/src/core/WebhookService.ts
+++ b/packages/backend/src/core/WebhookService.ts
@@ -81,7 +81,12 @@ export class WebhookService implements OnApplicationShutdown {
}
@bindThis
- public onApplicationShutdown(signal?: string | undefined) {
+ public dispose(): void {
this.redisForSub.off('message', this.onMessage);
}
+
+ @bindThis
+ public onApplicationShutdown(signal?: string | undefined): void {
+ this.dispose();
+ }
}
diff --git a/packages/backend/src/core/activitypub/ApRendererService.ts b/packages/backend/src/core/activitypub/ApRendererService.ts
index 60e19bfca5..d8b95ca4d1 100644
--- a/packages/backend/src/core/activitypub/ApRendererService.ts
+++ b/packages/backend/src/core/activitypub/ApRendererService.ts
@@ -277,7 +277,7 @@ export class ApRendererService {
const name = reaction.replaceAll(':', '');
const emoji = (await this.customEmojiService.localEmojisCache.fetch()).get(name);
- if (emoji) object.tag = [this.renderEmoji(emoji)];
+ if (emoji && !emoji.localOnly) object.tag = [this.renderEmoji(emoji)];
}
return object;
@@ -400,7 +400,7 @@ export class ApRendererService {
}));
const emojis = await this.getEmojis(note.emojis);
- const apemojis = emojis.map(emoji => this.renderEmoji(emoji));
+ const apemojis = emojis.filter(emoji => !emoji.localOnly).map(emoji => this.renderEmoji(emoji));
const tag = [
...hashtagTags,
@@ -479,7 +479,7 @@ export class ApRendererService {
}
const emojis = await this.getEmojis(user.emojis);
- const apemojis = emojis.map(emoji => this.renderEmoji(emoji));
+ const apemojis = emojis.filter(emoji => !emoji.localOnly).map(emoji => this.renderEmoji(emoji));
const hashtagTags = (user.tags ?? []).map(tag => this.renderHashtag(tag));
diff --git a/packages/backend/src/core/activitypub/LdSignatureService.ts b/packages/backend/src/core/activitypub/LdSignatureService.ts
index 2dc1a410ac..20fe2a0a77 100644
--- a/packages/backend/src/core/activitypub/LdSignatureService.ts
+++ b/packages/backend/src/core/activitypub/LdSignatureService.ts
@@ -94,7 +94,7 @@ class LdSignature {
@bindThis
private getLoader() {
return async (url: string): Promise<any> => {
- if (!url.match('^https?\:\/\/')) throw `Invalid URL ${url}`;
+ if (!url.match('^https?\:\/\/')) throw new Error(`Invalid URL ${url}`);
if (this.preLoad) {
if (url in CONTEXTS) {
@@ -126,7 +126,7 @@ class LdSignature {
timeout: this.loderTimeout,
}, { throwErrorWhenResponseNotOk: false }).then(res => {
if (!res.ok) {
- throw `${res.status} ${res.statusText}`;
+ throw new Error(`${res.status} ${res.statusText}`);
} else {
return res.json();
}
diff --git a/packages/backend/src/core/activitypub/models/ApNoteService.ts b/packages/backend/src/core/activitypub/models/ApNoteService.ts
index 87a9db405f..76757f530a 100644
--- a/packages/backend/src/core/activitypub/models/ApNoteService.ts
+++ b/packages/backend/src/core/activitypub/models/ApNoteService.ts
@@ -18,6 +18,7 @@ import { PollService } from '@/core/PollService.js';
import { StatusError } from '@/misc/status-error.js';
import { UtilityService } from '@/core/UtilityService.js';
import { bindThis } from '@/decorators.js';
+import { checkHttps } from '@/misc/check-https.js';
import { getOneApId, getApId, getOneApHrefNullable, validPost, isEmoji, getApType } from '../type.js';
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
import { ApLoggerService } from '../ApLoggerService.js';
@@ -32,7 +33,6 @@ 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 {
@@ -230,7 +230,7 @@ export class ApNoteService {
quote = results.filter((x): x is { status: 'ok', res: Note | null } => x.status === 'ok').map(x => x.res).find(x => x);
if (!quote) {
if (results.some(x => x.status === 'temperror')) {
- throw 'quote resolve failed';
+ throw new Error('quote resolve failed');
}
}
}
@@ -311,7 +311,7 @@ export class ApNoteService {
// ブロックしてたら中断
const meta = await this.metaService.fetch();
- if (this.utilityService.isBlockedHost(meta.blockedHosts, this.utilityService.extractDbHost(uri))) throw { statusCode: 451 };
+ if (this.utilityService.isBlockedHost(meta.blockedHosts, this.utilityService.extractDbHost(uri))) throw new StatusError('blocked host', 451);
const unlock = await this.appLockService.getApLock(uri);
diff --git a/packages/backend/src/core/activitypub/models/ApPersonService.ts b/packages/backend/src/core/activitypub/models/ApPersonService.ts
index eea1d1b848..f52ebed107 100644
--- a/packages/backend/src/core/activitypub/models/ApPersonService.ts
+++ b/packages/backend/src/core/activitypub/models/ApPersonService.ts
@@ -32,6 +32,8 @@ import type { UserEntityService } from '@/core/entities/UserEntityService.js';
import { bindThis } from '@/decorators.js';
import { MetaService } from '@/core/MetaService.js';
import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js';
+import type { AccountMoveService } from '@/core/AccountMoveService.js';
+import { checkHttps } from '@/misc/check-https.js';
import { getApId, getApType, getOneApHrefNullable, isActor, isCollection, isCollectionOrOrderedCollection, isPropertyValue } from '../type.js';
import { extractApHashtags } from './tag.js';
import type { OnModuleInit } from '@nestjs/common';
@@ -42,8 +44,6 @@ 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;
@@ -306,7 +306,6 @@ export class ApPersonService implements OnModuleInit {
tags,
isBot,
isCat: (person as any).isCat === true,
- showTimelineReplies: false,
})) as RemoteUser;
await transactionalEntityManager.save(new UserProfile({
@@ -696,7 +695,7 @@ export class ApPersonService implements OnModuleInit {
if (!dst.alsoKnownAs || dst.alsoKnownAs.length === 0) {
return 'skip: dst.alsoKnownAs is empty';
}
- if (!dst.alsoKnownAs?.includes(src.uri)) {
+ if (!dst.alsoKnownAs.includes(src.uri)) {
return 'skip: alsoKnownAs does not include from.uri';
}
diff --git a/packages/backend/src/core/chart/ChartManagementService.ts b/packages/backend/src/core/chart/ChartManagementService.ts
index 03e3612658..b0e9e534df 100644
--- a/packages/backend/src/core/chart/ChartManagementService.ts
+++ b/packages/backend/src/core/chart/ChartManagementService.ts
@@ -60,7 +60,8 @@ export class ChartManagementService implements OnApplicationShutdown {
}, 1000 * 60 * 20);
}
- async onApplicationShutdown(signal: string): Promise<void> {
+ @bindThis
+ public async dispose(): Promise<void> {
clearInterval(this.saveIntervalId);
if (process.env.NODE_ENV !== 'test') {
await Promise.all(
@@ -68,4 +69,9 @@ export class ChartManagementService implements OnApplicationShutdown {
);
}
}
+
+ @bindThis
+ async onApplicationShutdown(signal: string): Promise<void> {
+ await this.dispose();
+ }
}
diff --git a/packages/backend/src/core/entities/EmojiEntityService.ts b/packages/backend/src/core/entities/EmojiEntityService.ts
index 3bad048bc0..4a18cd1b3b 100644
--- a/packages/backend/src/core/entities/EmojiEntityService.ts
+++ b/packages/backend/src/core/entities/EmojiEntityService.ts
@@ -26,6 +26,8 @@ export class EmojiEntityService {
category: emoji.category,
// || emoji.originalUrl してるのは後方互換性のため(publicUrlはstringなので??はだめ)
url: emoji.publicUrl || emoji.originalUrl,
+ isSensitive: emoji.isSensitive ? true : undefined,
+ roleIdsThatCanBeUsedThisEmojiAsReaction: emoji.roleIdsThatCanBeUsedThisEmojiAsReaction.length > 0 ? emoji.roleIdsThatCanBeUsedThisEmojiAsReaction : undefined,
};
}
@@ -51,6 +53,9 @@ export class EmojiEntityService {
// || emoji.originalUrl してるのは後方互換性のため(publicUrlはstringなので??はだめ)
url: emoji.publicUrl || emoji.originalUrl,
license: emoji.license,
+ isSensitive: emoji.isSensitive,
+ localOnly: emoji.localOnly,
+ roleIdsThatCanBeUsedThisEmojiAsReaction: emoji.roleIdsThatCanBeUsedThisEmojiAsReaction,
};
}
diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts
index 7f61e1d6f3..bfd506ea86 100644
--- a/packages/backend/src/core/entities/UserEntityService.ts
+++ b/packages/backend/src/core/entities/UserEntityService.ts
@@ -466,7 +466,6 @@ export class UserEntityService implements OnModuleInit {
mutedInstances: profile!.mutedInstances,
mutingNotificationTypes: profile!.mutingNotificationTypes,
emailNotificationTypes: profile!.emailNotificationTypes,
- showTimelineReplies: user.showTimelineReplies ?? falsy,
achievements: profile!.achievements,
loggedInDays: profile!.loggedInDates.length,
policies: this.roleService.getUserPolicies(user.id),
diff --git a/packages/backend/src/core/entities/UserListEntityService.ts b/packages/backend/src/core/entities/UserListEntityService.ts
index 2461cb2c12..8628819278 100644
--- a/packages/backend/src/core/entities/UserListEntityService.ts
+++ b/packages/backend/src/core/entities/UserListEntityService.ts
@@ -35,6 +35,7 @@ export class UserListEntityService {
createdAt: userList.createdAt.toISOString(),
name: userList.name,
userIds: users.map(x => x.userId),
+ isPublic: userList.isPublic,
};
}
}
diff --git a/packages/backend/src/daemons/JanitorService.ts b/packages/backend/src/daemons/JanitorService.ts
index 8cdfb703f1..f826d50625 100644
--- a/packages/backend/src/daemons/JanitorService.ts
+++ b/packages/backend/src/daemons/JanitorService.ts
@@ -34,7 +34,12 @@ export class JanitorService implements OnApplicationShutdown {
}
@bindThis
- public onApplicationShutdown(signal?: string | undefined) {
+ public dispose(): void {
clearInterval(this.intervalId);
}
+
+ @bindThis
+ public onApplicationShutdown(signal?: string | undefined): void {
+ this.dispose();
+ }
}
diff --git a/packages/backend/src/daemons/QueueStatsService.ts b/packages/backend/src/daemons/QueueStatsService.ts
index b717434e09..53a0d14cd7 100644
--- a/packages/backend/src/daemons/QueueStatsService.ts
+++ b/packages/backend/src/daemons/QueueStatsService.ts
@@ -1,7 +1,11 @@
-import { Injectable } from '@nestjs/common';
+import { Inject, Injectable } from '@nestjs/common';
import Xev from 'xev';
+import * as Bull from 'bullmq';
import { QueueService } from '@/core/QueueService.js';
import { bindThis } from '@/decorators.js';
+import { DI } from '@/di-symbols.js';
+import type { Config } from '@/config.js';
+import { QUEUE, baseQueueOptions } from '@/queue/const.js';
import type { OnApplicationShutdown } from '@nestjs/common';
const ev = new Xev();
@@ -13,6 +17,9 @@ export class QueueStatsService implements OnApplicationShutdown {
private intervalId: NodeJS.Timer;
constructor(
+ @Inject(DI.config)
+ private config: Config,
+
private queueService: QueueService,
) {
}
@@ -31,11 +38,14 @@ export class QueueStatsService implements OnApplicationShutdown {
let activeDeliverJobs = 0;
let activeInboxJobs = 0;
- this.queueService.deliverQueue.on('global:active', () => {
+ const deliverQueueEvents = new Bull.QueueEvents(QUEUE.DELIVER, baseQueueOptions(this.config, QUEUE.DELIVER));
+ const inboxQueueEvents = new Bull.QueueEvents(QUEUE.INBOX, baseQueueOptions(this.config, QUEUE.INBOX));
+
+ deliverQueueEvents.on('active', () => {
activeDeliverJobs++;
});
- this.queueService.inboxQueue.on('global:active', () => {
+ inboxQueueEvents.on('active', () => {
activeInboxJobs++;
});
@@ -71,9 +81,14 @@ export class QueueStatsService implements OnApplicationShutdown {
this.intervalId = setInterval(tick, interval);
}
-
+
@bindThis
- public onApplicationShutdown(signal?: string | undefined) {
+ public dispose(): void {
clearInterval(this.intervalId);
}
+
+ @bindThis
+ public onApplicationShutdown(signal?: string | undefined): void {
+ this.dispose();
+ }
}
diff --git a/packages/backend/src/daemons/ServerStatsService.ts b/packages/backend/src/daemons/ServerStatsService.ts
index bb190cf60f..6cd71c0e2a 100644
--- a/packages/backend/src/daemons/ServerStatsService.ts
+++ b/packages/backend/src/daemons/ServerStatsService.ts
@@ -63,9 +63,14 @@ export class ServerStatsService implements OnApplicationShutdown {
}
@bindThis
- public onApplicationShutdown(signal?: string | undefined) {
+ public dispose(): void {
clearInterval(this.intervalId);
}
+
+ @bindThis
+ public onApplicationShutdown(signal?: string | undefined): void {
+ this.dispose();
+ }
}
// CPU STAT
diff --git a/packages/backend/src/di-symbols.ts b/packages/backend/src/di-symbols.ts
index c06c7a7159..4a073f102f 100644
--- a/packages/backend/src/di-symbols.ts
+++ b/packages/backend/src/di-symbols.ts
@@ -25,6 +25,7 @@ export const DI = {
userSecurityKeysRepository: Symbol('userSecurityKeysRepository'),
userPublickeysRepository: Symbol('userPublickeysRepository'),
userListsRepository: Symbol('userListsRepository'),
+ userListFavoritesRepository: Symbol('userListFavoritesRepository'),
userListJoiningsRepository: Symbol('userListJoiningsRepository'),
userNotePiningsRepository: Symbol('userNotePiningsRepository'),
userIpsRepository: Symbol('userIpsRepository'),
diff --git a/packages/backend/src/misc/id/aid.ts b/packages/backend/src/misc/id/aid.ts
index 9e206ee98f..f0cbc9900d 100644
--- a/packages/backend/src/misc/id/aid.ts
+++ b/packages/backend/src/misc/id/aid.ts
@@ -21,7 +21,7 @@ function getNoise(): string {
export function genAid(date: Date): string {
const t = date.getTime();
- if (isNaN(t)) throw 'Failed to create AID: Invalid Date';
+ if (isNaN(t)) throw new Error('Failed to create AID: Invalid Date');
counter++;
return getTime(t) + getNoise();
}
diff --git a/packages/backend/src/misc/prelude/time.ts b/packages/backend/src/misc/prelude/time.ts
index 34e8b6b17c..b21978b186 100644
--- a/packages/backend/src/misc/prelude/time.ts
+++ b/packages/backend/src/misc/prelude/time.ts
@@ -5,15 +5,16 @@ const dateTimeIntervals = {
};
export function dateUTC(time: number[]): Date {
- const d = time.length === 2 ? Date.UTC(time[0], time[1])
- : time.length === 3 ? Date.UTC(time[0], time[1], time[2])
- : time.length === 4 ? Date.UTC(time[0], time[1], time[2], time[3])
- : time.length === 5 ? Date.UTC(time[0], time[1], time[2], time[3], time[4])
- : time.length === 6 ? Date.UTC(time[0], time[1], time[2], time[3], time[4], time[5])
- : time.length === 7 ? Date.UTC(time[0], time[1], time[2], time[3], time[4], time[5], time[6])
- : null;
+ const d =
+ time.length === 2 ? Date.UTC(time[0], time[1])
+ : time.length === 3 ? Date.UTC(time[0], time[1], time[2])
+ : time.length === 4 ? Date.UTC(time[0], time[1], time[2], time[3])
+ : time.length === 5 ? Date.UTC(time[0], time[1], time[2], time[3], time[4])
+ : time.length === 6 ? Date.UTC(time[0], time[1], time[2], time[3], time[4], time[5])
+ : time.length === 7 ? Date.UTC(time[0], time[1], time[2], time[3], time[4], time[5], time[6])
+ : null;
- if (!d) throw 'wrong number of arguments';
+ if (!d) throw new Error('wrong number of arguments');
return new Date(d);
}
diff --git a/packages/backend/src/models/RepositoryModule.ts b/packages/backend/src/models/RepositoryModule.ts
index 588c98b58d..4231acc046 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, UserMemo } 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, UserListFavorite } from './index.js';
import type { DataSource } from 'typeorm';
import type { Provider } from '@nestjs/common';
@@ -112,6 +112,12 @@ const $userListsRepository: Provider = {
inject: [DI.db],
};
+const $userListFavoritesRepository: Provider = {
+ provide: DI.userListFavoritesRepository,
+ useFactory: (db: DataSource) => db.getRepository(UserListFavorite),
+ inject: [DI.db],
+};
+
const $userListJoiningsRepository: Provider = {
provide: DI.userListJoiningsRepository,
useFactory: (db: DataSource) => db.getRepository(UserListJoining),
@@ -416,6 +422,7 @@ const $userMemosRepository: Provider = {
$userSecurityKeysRepository,
$userPublickeysRepository,
$userListsRepository,
+ $userListFavoritesRepository,
$userListJoiningsRepository,
$userNotePiningsRepository,
$userIpsRepository,
@@ -483,6 +490,7 @@ const $userMemosRepository: Provider = {
$userSecurityKeysRepository,
$userPublickeysRepository,
$userListsRepository,
+ $userListFavoritesRepository,
$userListJoiningsRepository,
$userNotePiningsRepository,
$userIpsRepository,
diff --git a/packages/backend/src/models/entities/Emoji.ts b/packages/backend/src/models/entities/Emoji.ts
index dbb437d439..8fd3e65f5e 100644
--- a/packages/backend/src/models/entities/Emoji.ts
+++ b/packages/backend/src/models/entities/Emoji.ts
@@ -60,4 +60,20 @@ export class Emoji {
length: 1024, nullable: true,
})
public license: string | null;
+
+ @Column('boolean', {
+ default: false,
+ })
+ public localOnly: boolean;
+
+ @Column('boolean', {
+ default: false,
+ })
+ public isSensitive: boolean;
+
+ // TODO: 定期ジョブで存在しなくなったロールIDを除去するようにする
+ @Column('varchar', {
+ array: true, length: 128, default: '{}',
+ })
+ public roleIdsThatCanBeUsedThisEmojiAsReaction: string[];
}
diff --git a/packages/backend/src/models/entities/Note.ts b/packages/backend/src/models/entities/Note.ts
index df508b4dca..4f49a05950 100644
--- a/packages/backend/src/models/entities/Note.ts
+++ b/packages/backend/src/models/entities/Note.ts
@@ -90,7 +90,7 @@ export class Note {
@Column('varchar', {
length: 64, nullable: true,
})
- public reactionAcceptance: 'likeOnly' | 'likeOnlyForRemote' | null;
+ public reactionAcceptance: 'likeOnly' | 'likeOnlyForRemote' | 'nonSensitiveOnly' | 'nonSensitiveOnlyForLocalLikeOnlyForRemote' | null;
@Column('smallint', {
default: 0,
diff --git a/packages/backend/src/models/entities/User.ts b/packages/backend/src/models/entities/User.ts
index 8e10f999b6..6669890cf6 100644
--- a/packages/backend/src/models/entities/User.ts
+++ b/packages/backend/src/models/entities/User.ts
@@ -232,12 +232,6 @@ export class User {
})
public followersUri: string | null;
- @Column('boolean', {
- default: false,
- comment: 'Whether to show users replying to other users in the timeline.',
- })
- public showTimelineReplies: boolean;
-
@Index({ unique: true })
@Column('char', {
length: 16, nullable: true, unique: true,
diff --git a/packages/backend/src/models/entities/UserList.ts b/packages/backend/src/models/entities/UserList.ts
index b8a4b54d4c..94f3dc3cb3 100644
--- a/packages/backend/src/models/entities/UserList.ts
+++ b/packages/backend/src/models/entities/UserList.ts
@@ -19,6 +19,12 @@ export class UserList {
})
public userId: User['id'];
+ @Index()
+ @Column('boolean', {
+ default: false,
+ })
+ public isPublic: boolean;
+
@ManyToOne(type => User, {
onDelete: 'CASCADE',
})
diff --git a/packages/backend/src/models/entities/UserListFavorite.ts b/packages/backend/src/models/entities/UserListFavorite.ts
new file mode 100644
index 0000000000..e57abb460a
--- /dev/null
+++ b/packages/backend/src/models/entities/UserListFavorite.ts
@@ -0,0 +1,33 @@
+import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm';
+import { id } from '../id.js';
+import { User } from './User.js';
+import { UserList } from './UserList.js';
+
+@Entity()
+@Index(['userId', 'userListId'], { unique: true })
+export class UserListFavorite {
+ @PrimaryColumn(id())
+ public id: string;
+
+ @Column('timestamp with time zone')
+ public createdAt: Date;
+
+ @Index()
+ @Column(id())
+ public userId: User['id'];
+
+ @ManyToOne(type => User, {
+ onDelete: 'CASCADE',
+ })
+ @JoinColumn()
+ public user: User | null;
+
+ @Column(id())
+ public userListId: UserList['id'];
+
+ @ManyToOne(type => UserList, {
+ onDelete: 'CASCADE',
+ })
+ @JoinColumn()
+ public userList: UserList | null;
+}
diff --git a/packages/backend/src/models/index.ts b/packages/backend/src/models/index.ts
index b8ba28db9b..4b230ab742 100644
--- a/packages/backend/src/models/index.ts
+++ b/packages/backend/src/models/index.ts
@@ -49,6 +49,7 @@ import { User } from '@/models/entities/User.js';
import { UserIp } from '@/models/entities/UserIp.js';
import { UserKeypair } from '@/models/entities/UserKeypair.js';
import { UserList } from '@/models/entities/UserList.js';
+import { UserListFavorite } from './entities/UserListFavorite.js';
import { UserListJoining } from '@/models/entities/UserListJoining.js';
import { UserNotePining } from '@/models/entities/UserNotePining.js';
import { UserPending } from '@/models/entities/UserPending.js';
@@ -117,6 +118,7 @@ export {
UserIp,
UserKeypair,
UserList,
+ UserListFavorite,
UserListJoining,
UserNotePining,
UserPending,
@@ -184,6 +186,7 @@ export type UsersRepository = Repository<User>;
export type UserIpsRepository = Repository<UserIp>;
export type UserKeypairsRepository = Repository<UserKeypair>;
export type UserListsRepository = Repository<UserList>;
+export type UserListFavoritesRepository = Repository<UserListFavorite>;
export type UserListJoiningsRepository = Repository<UserListJoining>;
export type UserNotePiningsRepository = Repository<UserNotePining>;
export type UserPendingsRepository = Repository<UserPending>;
diff --git a/packages/backend/src/models/json-schema/emoji.ts b/packages/backend/src/models/json-schema/emoji.ts
index db4fd62cf6..63f56e77cb 100644
--- a/packages/backend/src/models/json-schema/emoji.ts
+++ b/packages/backend/src/models/json-schema/emoji.ts
@@ -22,6 +22,19 @@ export const packedEmojiSimpleSchema = {
type: 'string',
optional: false, nullable: false,
},
+ isSensitive: {
+ type: 'boolean',
+ optional: true, nullable: false,
+ },
+ roleIdsThatCanBeUsedThisEmojiAsReaction: {
+ type: 'array',
+ optional: true, nullable: false,
+ items: {
+ type: 'string',
+ optional: false, nullable: false,
+ format: 'id',
+ },
+ },
},
} as const;
@@ -63,5 +76,22 @@ export const packedEmojiDetailedSchema = {
type: 'string',
optional: false, nullable: true,
},
+ isSensitive: {
+ type: 'boolean',
+ optional: false, nullable: false,
+ },
+ localOnly: {
+ type: 'boolean',
+ optional: false, nullable: false,
+ },
+ roleIdsThatCanBeUsedThisEmojiAsReaction: {
+ type: 'array',
+ optional: false, nullable: false,
+ items: {
+ type: 'string',
+ optional: false, nullable: false,
+ format: 'id',
+ },
+ },
},
} as const;
diff --git a/packages/backend/src/models/json-schema/user-list.ts b/packages/backend/src/models/json-schema/user-list.ts
index 3ba5dc4a8a..1e620516e4 100644
--- a/packages/backend/src/models/json-schema/user-list.ts
+++ b/packages/backend/src/models/json-schema/user-list.ts
@@ -25,5 +25,10 @@ export const packedUserListSchema = {
format: 'id',
},
},
+ isPublic: {
+ type: 'boolean',
+ nullable: false,
+ optional: false,
+ },
},
} as const;
diff --git a/packages/backend/src/postgres.ts b/packages/backend/src/postgres.ts
index f3d404e6c9..488979c409 100644
--- a/packages/backend/src/postgres.ts
+++ b/packages/backend/src/postgres.ts
@@ -57,6 +57,7 @@ import { User } from '@/models/entities/User.js';
import { UserIp } from '@/models/entities/UserIp.js';
import { UserKeypair } from '@/models/entities/UserKeypair.js';
import { UserList } from '@/models/entities/UserList.js';
+import { UserListFavorite } from '@/models/entities/UserListFavorite.js';
import { UserListJoining } from '@/models/entities/UserListJoining.js';
import { UserNotePining } from '@/models/entities/UserNotePining.js';
import { UserPending } from '@/models/entities/UserPending.js';
@@ -132,6 +133,7 @@ export const entities = [
UserKeypair,
UserPublickey,
UserList,
+ UserListFavorite,
UserListJoining,
UserNotePining,
UserSecurityKey,
diff --git a/packages/backend/src/queue/QueueProcessorService.ts b/packages/backend/src/queue/QueueProcessorService.ts
index dc025f9889..42f9c1af7d 100644
--- a/packages/backend/src/queue/QueueProcessorService.ts
+++ b/packages/backend/src/queue/QueueProcessorService.ts
@@ -1,10 +1,9 @@
-import { Inject, Injectable } from '@nestjs/common';
+import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
+import * as Bull from 'bullmq';
import type { Config } from '@/config.js';
import { DI } from '@/di-symbols.js';
import type Logger from '@/logger.js';
-import { QueueService } from '@/core/QueueService.js';
import { bindThis } from '@/decorators.js';
-import { getJobInfo } from './get-job-info.js';
import { WebhookDeliverProcessorService } from './processors/WebhookDeliverProcessorService.js';
import { EndedPollNotificationProcessorService } from './processors/EndedPollNotificationProcessorService.js';
import { DeliverProcessorService } from './processors/DeliverProcessorService.js';
@@ -35,17 +34,51 @@ import { CheckExpiredMutingsProcessorService } from './processors/CheckExpiredMu
import { CleanProcessorService } from './processors/CleanProcessorService.js';
import { AggregateRetentionProcessorService } from './processors/AggregateRetentionProcessorService.js';
import { QueueLoggerService } from './QueueLoggerService.js';
+import { QUEUE, baseQueueOptions } from './const.js';
+
+// ref. https://github.com/misskey-dev/misskey/pull/7635#issue-971097019
+function httpRelatedBackoff(attemptsMade: number) {
+ const baseDelay = 60 * 1000; // 1min
+ const maxBackoff = 8 * 60 * 60 * 1000; // 8hours
+ let backoff = (Math.pow(2, attemptsMade) - 1) * baseDelay;
+ backoff = Math.min(backoff, maxBackoff);
+ backoff += Math.round(backoff * Math.random() * 0.2);
+ return backoff;
+}
+
+function getJobInfo(job: Bull.Job | undefined, increment = false): string {
+ if (job == null) return '-';
+
+ const age = Date.now() - job.timestamp;
+
+ const formated = age > 60000 ? `${Math.floor(age / 1000 / 60)}m`
+ : age > 10000 ? `${Math.floor(age / 1000)}s`
+ : `${age}ms`;
+
+ // onActiveとかonCompletedのattemptsMadeがなぜか0始まりなのでインクリメントする
+ const currentAttempts = job.attemptsMade + (increment ? 1 : 0);
+ const maxAttempts = job.opts ? job.opts.attempts : 0;
+
+ return `id=${job.id} attempts=${currentAttempts}/${maxAttempts} age=${formated}`;
+}
@Injectable()
-export class QueueProcessorService {
+export class QueueProcessorService implements OnApplicationShutdown {
private logger: Logger;
+ private systemQueueWorker: Bull.Worker;
+ private dbQueueWorker: Bull.Worker;
+ private deliverQueueWorker: Bull.Worker;
+ private inboxQueueWorker: Bull.Worker;
+ private webhookDeliverQueueWorker: Bull.Worker;
+ private relationshipQueueWorker: Bull.Worker;
+ private objectStorageQueueWorker: Bull.Worker;
+ private endedPollNotificationQueueWorker: Bull.Worker;
constructor(
@Inject(DI.config)
private config: Config,
private queueLoggerService: QueueLoggerService,
- private queueService: QueueService,
private webhookDeliverProcessorService: WebhookDeliverProcessorService,
private endedPollNotificationProcessorService: EndedPollNotificationProcessorService,
private deliverProcessorService: DeliverProcessorService,
@@ -77,10 +110,7 @@ export class QueueProcessorService {
private cleanProcessorService: CleanProcessorService,
) {
this.logger = this.queueLoggerService.logger;
- }
- @bindThis
- public start() {
function renderError(e: Error): any {
if (e) { // 何故かeがundefinedで来ることがある
return {
@@ -97,146 +127,232 @@ export class QueueProcessorService {
}
}
+ //#region system
+ this.systemQueueWorker = new Bull.Worker(QUEUE.SYSTEM, (job) => {
+ switch (job.name) {
+ case 'tickCharts': return this.tickChartsProcessorService.process();
+ case 'resyncCharts': return this.resyncChartsProcessorService.process();
+ case 'cleanCharts': return this.cleanChartsProcessorService.process();
+ case 'aggregateRetention': return this.aggregateRetentionProcessorService.process();
+ case 'checkExpiredMutings': return this.checkExpiredMutingsProcessorService.process();
+ case 'clean': return this.cleanProcessorService.process();
+ default: throw new Error(`unrecognized job type ${job.name} for system`);
+ }
+ }, {
+ ...baseQueueOptions(this.config, QUEUE.SYSTEM),
+ autorun: false,
+ });
+
const systemLogger = this.logger.createSubLogger('system');
- const deliverLogger = this.logger.createSubLogger('deliver');
- const webhookLogger = this.logger.createSubLogger('webhook');
- const inboxLogger = this.logger.createSubLogger('inbox');
- const dbLogger = this.logger.createSubLogger('db');
- const relationshipLogger = this.logger.createSubLogger('relationship');
- const objectStorageLogger = this.logger.createSubLogger('objectStorage');
- this.queueService.systemQueue
- .on('waiting', (jobId) => systemLogger.debug(`waiting id=${jobId}`))
+ this.systemQueueWorker
.on('active', (job) => systemLogger.debug(`active id=${job.id}`))
.on('completed', (job, result) => systemLogger.debug(`completed(${result}) id=${job.id}`))
- .on('failed', (job, err) => systemLogger.warn(`failed(${err}) id=${job.id}`, { job, e: renderError(err) }))
- .on('error', (job: any, err: Error) => systemLogger.error(`error ${err}`, { job, e: renderError(err) }))
- .on('stalled', (job) => systemLogger.warn(`stalled id=${job.id}`));
+ .on('failed', (job, err) => systemLogger.warn(`failed(${err}) id=${job ? job.id : '-'}`, { job, e: renderError(err) }))
+ .on('error', (err: Error) => systemLogger.error(`error ${err}`, { e: renderError(err) }))
+ .on('stalled', (jobId) => systemLogger.warn(`stalled id=${jobId}`));
+ //#endregion
+
+ //#region db
+ this.dbQueueWorker = new Bull.Worker(QUEUE.DB, (job) => {
+ switch (job.name) {
+ case 'deleteDriveFiles': return this.deleteDriveFilesProcessorService.process(job);
+ case 'exportCustomEmojis': return this.exportCustomEmojisProcessorService.process(job);
+ case 'exportNotes': return this.exportNotesProcessorService.process(job);
+ case 'exportFavorites': return this.exportFavoritesProcessorService.process(job);
+ case 'exportFollowing': return this.exportFollowingProcessorService.process(job);
+ case 'exportMuting': return this.exportMutingProcessorService.process(job);
+ case 'exportBlocking': return this.exportBlockingProcessorService.process(job);
+ case 'exportUserLists': return this.exportUserListsProcessorService.process(job);
+ case 'exportAntennas': return this.exportAntennasProcessorService.process(job);
+ case 'importFollowing': return this.importFollowingProcessorService.process(job);
+ case 'importFollowingToDb': return this.importFollowingProcessorService.processDb(job);
+ case 'importMuting': return this.importMutingProcessorService.process(job);
+ case 'importBlocking': return this.importBlockingProcessorService.process(job);
+ case 'importBlockingToDb': return this.importBlockingProcessorService.processDb(job);
+ case 'importUserLists': return this.importUserListsProcessorService.process(job);
+ case 'importCustomEmojis': return this.importCustomEmojisProcessorService.process(job);
+ case 'importAntennas': return this.importAntennasProcessorService.process(job);
+ case 'deleteAccount': return this.deleteAccountProcessorService.process(job);
+ default: throw new Error(`unrecognized job type ${job.name} for db`);
+ }
+ }, {
+ ...baseQueueOptions(this.config, QUEUE.DB),
+ autorun: false,
+ });
+
+ const dbLogger = this.logger.createSubLogger('db');
+
+ this.dbQueueWorker
+ .on('active', (job) => dbLogger.debug(`active id=${job.id}`))
+ .on('completed', (job, result) => dbLogger.debug(`completed(${result}) id=${job.id}`))
+ .on('failed', (job, err) => dbLogger.warn(`failed(${err}) id=${job ? job.id : '-'}`, { job, e: renderError(err) }))
+ .on('error', (err: Error) => dbLogger.error(`error ${err}`, { e: renderError(err) }))
+ .on('stalled', (jobId) => dbLogger.warn(`stalled id=${jobId}`));
+ //#endregion
- this.queueService.deliverQueue
- .on('waiting', (jobId) => deliverLogger.debug(`waiting id=${jobId}`))
+ //#region deliver
+ this.deliverQueueWorker = new Bull.Worker(QUEUE.DELIVER, (job) => this.deliverProcessorService.process(job), {
+ ...baseQueueOptions(this.config, QUEUE.DELIVER),
+ autorun: false,
+ concurrency: this.config.deliverJobConcurrency ?? 128,
+ limiter: {
+ max: this.config.deliverJobPerSec ?? 128,
+ duration: 1000,
+ },
+ settings: {
+ backoffStrategy: httpRelatedBackoff,
+ },
+ });
+
+ const deliverLogger = this.logger.createSubLogger('deliver');
+
+ this.deliverQueueWorker
.on('active', (job) => deliverLogger.debug(`active ${getJobInfo(job, true)} to=${job.data.to}`))
.on('completed', (job, result) => deliverLogger.debug(`completed(${result}) ${getJobInfo(job, true)} to=${job.data.to}`))
- .on('failed', (job, err) => deliverLogger.warn(`failed(${err}) ${getJobInfo(job)} to=${job.data.to}`))
- .on('error', (job: any, err: Error) => deliverLogger.error(`error ${err}`, { job, e: renderError(err) }))
- .on('stalled', (job) => deliverLogger.warn(`stalled ${getJobInfo(job)} to=${job.data.to}`));
+ .on('failed', (job, err) => deliverLogger.warn(`failed(${err}) ${getJobInfo(job)} to=${job ? job.data.to : '-'}`))
+ .on('error', (err: Error) => deliverLogger.error(`error ${err}`, { e: renderError(err) }))
+ .on('stalled', (jobId) => deliverLogger.warn(`stalled id=${jobId}`));
+ //#endregion
+
+ //#region inbox
+ this.inboxQueueWorker = new Bull.Worker(QUEUE.INBOX, (job) => this.inboxProcessorService.process(job), {
+ ...baseQueueOptions(this.config, QUEUE.INBOX),
+ autorun: false,
+ concurrency: this.config.inboxJobConcurrency ?? 16,
+ limiter: {
+ max: this.config.inboxJobPerSec ?? 16,
+ duration: 1000,
+ },
+ settings: {
+ backoffStrategy: httpRelatedBackoff,
+ },
+ });
- this.queueService.inboxQueue
- .on('waiting', (jobId) => inboxLogger.debug(`waiting id=${jobId}`))
+ const inboxLogger = this.logger.createSubLogger('inbox');
+
+ this.inboxQueueWorker
.on('active', (job) => inboxLogger.debug(`active ${getJobInfo(job, true)}`))
.on('completed', (job, result) => inboxLogger.debug(`completed(${result}) ${getJobInfo(job, true)}`))
- .on('failed', (job, err) => inboxLogger.warn(`failed(${err}) ${getJobInfo(job)} activity=${job.data.activity ? job.data.activity.id : 'none'}`, { job, e: renderError(err) }))
- .on('error', (job: any, err: Error) => inboxLogger.error(`error ${err}`, { job, e: renderError(err) }))
- .on('stalled', (job) => inboxLogger.warn(`stalled ${getJobInfo(job)} activity=${job.data.activity ? job.data.activity.id : 'none'}`));
+ .on('failed', (job, err) => inboxLogger.warn(`failed(${err}) ${getJobInfo(job)} activity=${job ? (job.data.activity ? job.data.activity.id : 'none') : '-'}`, { job, e: renderError(err) }))
+ .on('error', (err: Error) => inboxLogger.error(`error ${err}`, { e: renderError(err) }))
+ .on('stalled', (jobId) => inboxLogger.warn(`stalled id=${jobId}`));
+ //#endregion
- this.queueService.dbQueue
- .on('waiting', (jobId) => dbLogger.debug(`waiting id=${jobId}`))
- .on('active', (job) => dbLogger.debug(`active id=${job.id}`))
- .on('completed', (job, result) => dbLogger.debug(`completed(${result}) id=${job.id}`))
- .on('failed', (job, err) => dbLogger.warn(`failed(${err}) id=${job.id}`, { job, e: renderError(err) }))
- .on('error', (job: any, err: Error) => dbLogger.error(`error ${err}`, { job, e: renderError(err) }))
- .on('stalled', (job) => dbLogger.warn(`stalled id=${job.id}`));
-
- this.queueService.relationshipQueue
- .on('waiting', (jobId) => relationshipLogger.debug(`waiting id=${jobId}`))
- .on('active', (job) => relationshipLogger.debug(`active id=${job.id}`))
- .on('completed', (job, result) => relationshipLogger.debug(`completed(${result}) id=${job.id}`))
- .on('failed', (job, err) => relationshipLogger.warn(`failed(${err}) id=${job.id}`, { job, e: renderError(err) }))
- .on('error', (job: any, err: Error) => relationshipLogger.error(`error ${err}`, { job, e: renderError(err) }))
- .on('stalled', (job) => relationshipLogger.warn(`stalled id=${job.id}`));
+ //#region webhook deliver
+ this.webhookDeliverQueueWorker = new Bull.Worker(QUEUE.WEBHOOK_DELIVER, (job) => this.webhookDeliverProcessorService.process(job), {
+ ...baseQueueOptions(this.config, QUEUE.WEBHOOK_DELIVER),
+ autorun: false,
+ concurrency: 64,
+ limiter: {
+ max: 64,
+ duration: 1000,
+ },
+ settings: {
+ backoffStrategy: httpRelatedBackoff,
+ },
+ });
- this.queueService.objectStorageQueue
- .on('waiting', (jobId) => objectStorageLogger.debug(`waiting id=${jobId}`))
- .on('active', (job) => objectStorageLogger.debug(`active id=${job.id}`))
- .on('completed', (job, result) => objectStorageLogger.debug(`completed(${result}) id=${job.id}`))
- .on('failed', (job, err) => objectStorageLogger.warn(`failed(${err}) id=${job.id}`, { job, e: renderError(err) }))
- .on('error', (job: any, err: Error) => objectStorageLogger.error(`error ${err}`, { job, e: renderError(err) }))
- .on('stalled', (job) => objectStorageLogger.warn(`stalled id=${job.id}`));
+ const webhookLogger = this.logger.createSubLogger('webhook');
- this.queueService.webhookDeliverQueue
- .on('waiting', (jobId) => webhookLogger.debug(`waiting id=${jobId}`))
+ this.webhookDeliverQueueWorker
.on('active', (job) => webhookLogger.debug(`active ${getJobInfo(job, true)} to=${job.data.to}`))
.on('completed', (job, result) => webhookLogger.debug(`completed(${result}) ${getJobInfo(job, true)} to=${job.data.to}`))
- .on('failed', (job, err) => webhookLogger.warn(`failed(${err}) ${getJobInfo(job)} to=${job.data.to}`))
- .on('error', (job: any, err: Error) => webhookLogger.error(`error ${err}`, { job, e: renderError(err) }))
- .on('stalled', (job) => webhookLogger.warn(`stalled ${getJobInfo(job)} to=${job.data.to}`));
+ .on('failed', (job, err) => webhookLogger.warn(`failed(${err}) ${getJobInfo(job)} to=${job ? job.data.to : '-'}`))
+ .on('error', (err: Error) => webhookLogger.error(`error ${err}`, { e: renderError(err) }))
+ .on('stalled', (jobId) => webhookLogger.warn(`stalled id=${jobId}`));
+ //#endregion
- this.queueService.systemQueue.add('tickCharts', {
+ //#region relationship
+ this.relationshipQueueWorker = new Bull.Worker(QUEUE.RELATIONSHIP, (job) => {
+ switch (job.name) {
+ case 'follow': return this.relationshipProcessorService.processFollow(job);
+ case 'unfollow': return this.relationshipProcessorService.processUnfollow(job);
+ case 'block': return this.relationshipProcessorService.processBlock(job);
+ case 'unblock': return this.relationshipProcessorService.processUnblock(job);
+ default: throw new Error(`unrecognized job type ${job.name} for relationship`);
+ }
}, {
- repeat: { cron: '55 * * * *' },
- removeOnComplete: true,
+ ...baseQueueOptions(this.config, QUEUE.RELATIONSHIP),
+ autorun: false,
+ concurrency: this.config.relashionshipJobConcurrency ?? 16,
+ limiter: {
+ max: this.config.relashionshipJobPerSec ?? 64,
+ duration: 1000,
+ },
});
- this.queueService.systemQueue.add('resyncCharts', {
- }, {
- repeat: { cron: '0 0 * * *' },
- removeOnComplete: true,
- });
+ const relationshipLogger = this.logger.createSubLogger('relationship');
+
+ this.relationshipQueueWorker
+ .on('active', (job) => relationshipLogger.debug(`active id=${job.id}`))
+ .on('completed', (job, result) => relationshipLogger.debug(`completed(${result}) id=${job.id}`))
+ .on('failed', (job, err) => relationshipLogger.warn(`failed(${err}) id=${job ? job.id : '-'}`, { job, e: renderError(err) }))
+ .on('error', (err: Error) => relationshipLogger.error(`error ${err}`, { e: renderError(err) }))
+ .on('stalled', (jobId) => relationshipLogger.warn(`stalled id=${jobId}`));
+ //#endregion
- this.queueService.systemQueue.add('cleanCharts', {
+ //#region object storage
+ this.objectStorageQueueWorker = new Bull.Worker(QUEUE.OBJECT_STORAGE, (job) => {
+ switch (job.name) {
+ case 'deleteFile': return this.deleteFileProcessorService.process(job);
+ case 'cleanRemoteFiles': return this.cleanRemoteFilesProcessorService.process(job);
+ default: throw new Error(`unrecognized job type ${job.name} for objectStorage`);
+ }
}, {
- repeat: { cron: '0 0 * * *' },
- removeOnComplete: true,
+ ...baseQueueOptions(this.config, QUEUE.OBJECT_STORAGE),
+ autorun: false,
+ concurrency: 16,
});
- this.queueService.systemQueue.add('aggregateRetention', {
- }, {
- repeat: { cron: '0 0 * * *' },
- removeOnComplete: true,
- });
+ const objectStorageLogger = this.logger.createSubLogger('objectStorage');
- this.queueService.systemQueue.add('clean', {
- }, {
- repeat: { cron: '0 0 * * *' },
- removeOnComplete: true,
- });
+ this.objectStorageQueueWorker
+ .on('active', (job) => objectStorageLogger.debug(`active id=${job.id}`))
+ .on('completed', (job, result) => objectStorageLogger.debug(`completed(${result}) id=${job.id}`))
+ .on('failed', (job, err) => objectStorageLogger.warn(`failed(${err}) id=${job ? job.id : '-'}`, { job, e: renderError(err) }))
+ .on('error', (err: Error) => objectStorageLogger.error(`error ${err}`, { e: renderError(err) }))
+ .on('stalled', (jobId) => objectStorageLogger.warn(`stalled id=${jobId}`));
+ //#endregion
- this.queueService.systemQueue.add('checkExpiredMutings', {
- }, {
- repeat: { cron: '*/5 * * * *' },
- removeOnComplete: true,
+ //#region ended poll notification
+ this.endedPollNotificationQueueWorker = new Bull.Worker(QUEUE.ENDED_POLL_NOTIFICATION, (job) => this.endedPollNotificationProcessorService.process(job), {
+ ...baseQueueOptions(this.config, QUEUE.ENDED_POLL_NOTIFICATION),
+ autorun: false,
});
+ //#endregion
+ }
- this.queueService.deliverQueue.process(this.config.deliverJobConcurrency ?? 128, (job) => this.deliverProcessorService.process(job));
- this.queueService.inboxQueue.process(this.config.inboxJobConcurrency ?? 16, (job) => this.inboxProcessorService.process(job));
- this.queueService.endedPollNotificationQueue.process((job, done) => this.endedPollNotificationProcessorService.process(job, done));
- this.queueService.webhookDeliverQueue.process(64, (job) => this.webhookDeliverProcessorService.process(job));
-
- this.queueService.dbQueue.process('deleteDriveFiles', (job, done) => this.deleteDriveFilesProcessorService.process(job, done));
- this.queueService.dbQueue.process('exportCustomEmojis', (job, done) => this.exportCustomEmojisProcessorService.process(job, done));
- this.queueService.dbQueue.process('exportNotes', (job, done) => this.exportNotesProcessorService.process(job, done));
- this.queueService.dbQueue.process('exportFavorites', (job, done) => this.exportFavoritesProcessorService.process(job, done));
- this.queueService.dbQueue.process('exportFollowing', (job, done) => this.exportFollowingProcessorService.process(job, done));
- this.queueService.dbQueue.process('exportMuting', (job, done) => this.exportMutingProcessorService.process(job, done));
- this.queueService.dbQueue.process('exportBlocking', (job, done) => this.exportBlockingProcessorService.process(job, done));
- this.queueService.dbQueue.process('exportUserLists', (job, done) => this.exportUserListsProcessorService.process(job, done));
- this.queueService.dbQueue.process('exportAntennas', (job, done) => this.exportAntennasProcessorService.process(job, done));
- this.queueService.dbQueue.process('importFollowing', (job, done) => this.importFollowingProcessorService.process(job, done));
- this.queueService.dbQueue.process('importFollowingToDb', (job) => this.importFollowingProcessorService.processDb(job));
- this.queueService.dbQueue.process('importMuting', (job, done) => this.importMutingProcessorService.process(job, done));
- this.queueService.dbQueue.process('importBlocking', (job, done) => this.importBlockingProcessorService.process(job, done));
- this.queueService.dbQueue.process('importBlockingToDb', (job) => this.importBlockingProcessorService.processDb(job));
- this.queueService.dbQueue.process('importUserLists', (job, done) => this.importUserListsProcessorService.process(job, done));
- this.queueService.dbQueue.process('importCustomEmojis', (job, done) => this.importCustomEmojisProcessorService.process(job, done));
- this.queueService.dbQueue.process('importAntennas', (job, done) => this.importAntennasProcessorService.process(job, done));
- this.queueService.dbQueue.process('deleteAccount', (job) => this.deleteAccountProcessorService.process(job));
+ @bindThis
+ public async start(): Promise<void> {
+ await Promise.all([
+ this.systemQueueWorker.run(),
+ this.dbQueueWorker.run(),
+ this.deliverQueueWorker.run(),
+ this.inboxQueueWorker.run(),
+ this.webhookDeliverQueueWorker.run(),
+ this.relationshipQueueWorker.run(),
+ this.objectStorageQueueWorker.run(),
+ this.endedPollNotificationQueueWorker.run(),
+ ]);
+ }
- this.queueService.objectStorageQueue.process('deleteFile', 16, (job) => this.deleteFileProcessorService.process(job));
- this.queueService.objectStorageQueue.process('cleanRemoteFiles', 16, (job, done) => this.cleanRemoteFilesProcessorService.process(job, done));
-
- {
- const maxJobs = this.config.relashionshipJobConcurrency ?? 16;
- this.queueService.relationshipQueue.process('follow', maxJobs, (job) => this.relationshipProcessorService.processFollow(job));
- this.queueService.relationshipQueue.process('unfollow', maxJobs, (job) => this.relationshipProcessorService.processUnfollow(job));
- this.queueService.relationshipQueue.process('block', maxJobs, (job) => this.relationshipProcessorService.processBlock(job));
- this.queueService.relationshipQueue.process('unblock', maxJobs, (job) => this.relationshipProcessorService.processUnblock(job));
- }
+ @bindThis
+ public async stop(): Promise<void> {
+ await Promise.all([
+ this.systemQueueWorker.close(),
+ this.dbQueueWorker.close(),
+ this.deliverQueueWorker.close(),
+ this.inboxQueueWorker.close(),
+ this.webhookDeliverQueueWorker.close(),
+ this.relationshipQueueWorker.close(),
+ this.objectStorageQueueWorker.close(),
+ this.endedPollNotificationQueueWorker.close(),
+ ]);
+ }
- this.queueService.systemQueue.process('tickCharts', (job, done) => this.tickChartsProcessorService.process(job, done));
- this.queueService.systemQueue.process('resyncCharts', (job, done) => this.resyncChartsProcessorService.process(job, done));
- this.queueService.systemQueue.process('cleanCharts', (job, done) => this.cleanChartsProcessorService.process(job, done));
- this.queueService.systemQueue.process('aggregateRetention', (job, done) => this.aggregateRetentionProcessorService.process(job, done));
- this.queueService.systemQueue.process('checkExpiredMutings', (job, done) => this.checkExpiredMutingsProcessorService.process(job, done));
- this.queueService.systemQueue.process('clean', (job, done) => this.cleanProcessorService.process(job, done));
+ @bindThis
+ public async onApplicationShutdown(signal?: string | undefined): Promise<void> {
+ await this.stop();
}
}
diff --git a/packages/backend/src/queue/const.ts b/packages/backend/src/queue/const.ts
new file mode 100644
index 0000000000..d240fe70e0
--- /dev/null
+++ b/packages/backend/src/queue/const.ts
@@ -0,0 +1,26 @@
+import { Config } from '@/config.js';
+import type * as Bull from 'bullmq';
+
+export const QUEUE = {
+ DELIVER: 'deliver',
+ INBOX: 'inbox',
+ SYSTEM: 'system',
+ ENDED_POLL_NOTIFICATION: 'endedPollNotification',
+ DB: 'db',
+ RELATIONSHIP: 'relationship',
+ OBJECT_STORAGE: 'objectStorage',
+ WEBHOOK_DELIVER: 'webhookDeliver',
+};
+
+export function baseQueueOptions(config: Config, queueName: typeof QUEUE[keyof typeof QUEUE]): Bull.QueueOptions {
+ return {
+ connection: {
+ port: config.redisForJobQueue.port,
+ host: config.redisForJobQueue.host,
+ family: config.redisForJobQueue.family == null ? 0 : config.redisForJobQueue.family,
+ password: config.redisForJobQueue.pass,
+ db: config.redisForJobQueue.db ?? 0,
+ },
+ prefix: config.redisForJobQueue.prefix ? `${config.redisForJobQueue.prefix}:queue:${queueName}` : `queue:${queueName}`,
+ };
+}
diff --git a/packages/backend/src/queue/get-job-info.ts b/packages/backend/src/queue/get-job-info.ts
deleted file mode 100644
index d33e349c36..0000000000
--- a/packages/backend/src/queue/get-job-info.ts
+++ /dev/null
@@ -1,15 +0,0 @@
-import Bull from 'bull';
-
-export function getJobInfo(job: Bull.Job, increment = false) {
- const age = Date.now() - job.timestamp;
-
- const formated = age > 60000 ? `${Math.floor(age / 1000 / 60)}m`
- : age > 10000 ? `${Math.floor(age / 1000)}s`
- : `${age}ms`;
-
- // onActiveとかonCompletedのattemptsMadeがなぜか0始まりなのでインクリメントする
- const currentAttempts = job.attemptsMade + (increment ? 1 : 0);
- const maxAttempts = job.opts ? job.opts.attempts : 0;
-
- return `id=${job.id} attempts=${currentAttempts}/${maxAttempts} age=${formated}`;
-}
diff --git a/packages/backend/src/queue/processors/AggregateRetentionProcessorService.ts b/packages/backend/src/queue/processors/AggregateRetentionProcessorService.ts
index e2720b4fe0..600ce0828f 100644
--- a/packages/backend/src/queue/processors/AggregateRetentionProcessorService.ts
+++ b/packages/backend/src/queue/processors/AggregateRetentionProcessorService.ts
@@ -9,7 +9,7 @@ import { deepClone } from '@/misc/clone.js';
import { IdService } from '@/core/IdService.js';
import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js';
import { QueueLoggerService } from '../QueueLoggerService.js';
-import type Bull from 'bull';
+import type * as Bull from 'bullmq';
@Injectable()
export class AggregateRetentionProcessorService {
@@ -32,7 +32,7 @@ export class AggregateRetentionProcessorService {
}
@bindThis
- public async process(job: Bull.Job<Record<string, unknown>>, done: () => void): Promise<void> {
+ public async process(): Promise<void> {
this.logger.info('Aggregating retention...');
const now = new Date();
@@ -62,7 +62,6 @@ export class AggregateRetentionProcessorService {
} catch (err) {
if (isDuplicateKeyValueError(err)) {
this.logger.succ('Skip because it has already been processed by another worker.');
- done();
return;
}
throw err;
@@ -88,6 +87,5 @@ export class AggregateRetentionProcessorService {
}
this.logger.succ('Retention aggregated.');
- done();
}
}
diff --git a/packages/backend/src/queue/processors/CheckExpiredMutingsProcessorService.ts b/packages/backend/src/queue/processors/CheckExpiredMutingsProcessorService.ts
index 2476d71a5e..c4ee212bab 100644
--- a/packages/backend/src/queue/processors/CheckExpiredMutingsProcessorService.ts
+++ b/packages/backend/src/queue/processors/CheckExpiredMutingsProcessorService.ts
@@ -7,7 +7,7 @@ import type Logger from '@/logger.js';
import { bindThis } from '@/decorators.js';
import { UserMutingService } from '@/core/UserMutingService.js';
import { QueueLoggerService } from '../QueueLoggerService.js';
-import type Bull from 'bull';
+import type * as Bull from 'bullmq';
@Injectable()
export class CheckExpiredMutingsProcessorService {
@@ -27,7 +27,7 @@ export class CheckExpiredMutingsProcessorService {
}
@bindThis
- public async process(job: Bull.Job<Record<string, unknown>>, done: () => void): Promise<void> {
+ public async process(): Promise<void> {
this.logger.info('Checking expired mutings...');
const expired = await this.mutingsRepository.createQueryBuilder('muting')
@@ -41,6 +41,5 @@ export class CheckExpiredMutingsProcessorService {
}
this.logger.succ('All expired mutings checked.');
- done();
}
}
diff --git a/packages/backend/src/queue/processors/CleanChartsProcessorService.ts b/packages/backend/src/queue/processors/CleanChartsProcessorService.ts
index b458167042..22d7c1b4fb 100644
--- a/packages/backend/src/queue/processors/CleanChartsProcessorService.ts
+++ b/packages/backend/src/queue/processors/CleanChartsProcessorService.ts
@@ -16,7 +16,7 @@ import PerUserDriveChart from '@/core/chart/charts/per-user-drive.js';
import ApRequestChart from '@/core/chart/charts/ap-request.js';
import { bindThis } from '@/decorators.js';
import { QueueLoggerService } from '../QueueLoggerService.js';
-import type Bull from 'bull';
+import type * as Bull from 'bullmq';
@Injectable()
export class CleanChartsProcessorService {
@@ -45,7 +45,7 @@ export class CleanChartsProcessorService {
}
@bindThis
- public async process(job: Bull.Job<Record<string, unknown>>, done: () => void): Promise<void> {
+ public async process(): Promise<void> {
this.logger.info('Clean charts...');
await Promise.all([
@@ -64,6 +64,5 @@ export class CleanChartsProcessorService {
]);
this.logger.succ('All charts successfully cleaned.');
- done();
}
}
diff --git a/packages/backend/src/queue/processors/CleanProcessorService.ts b/packages/backend/src/queue/processors/CleanProcessorService.ts
index 1936e8df23..cefa6da5e9 100644
--- a/packages/backend/src/queue/processors/CleanProcessorService.ts
+++ b/packages/backend/src/queue/processors/CleanProcessorService.ts
@@ -7,7 +7,7 @@ import type Logger from '@/logger.js';
import { bindThis } from '@/decorators.js';
import { IdService } from '@/core/IdService.js';
import { QueueLoggerService } from '../QueueLoggerService.js';
-import type Bull from 'bull';
+import type * as Bull from 'bullmq';
@Injectable()
export class CleanProcessorService {
@@ -36,7 +36,7 @@ export class CleanProcessorService {
}
@bindThis
- public async process(job: Bull.Job<Record<string, unknown>>, done: () => void): Promise<void> {
+ public async process(): Promise<void> {
this.logger.info('Cleaning...');
this.userIpsRepository.delete({
@@ -72,6 +72,5 @@ export class CleanProcessorService {
}
this.logger.succ('Cleaned.');
- done();
}
}
diff --git a/packages/backend/src/queue/processors/CleanRemoteFilesProcessorService.ts b/packages/backend/src/queue/processors/CleanRemoteFilesProcessorService.ts
index 5a33c27188..c54bf59ae4 100644
--- a/packages/backend/src/queue/processors/CleanRemoteFilesProcessorService.ts
+++ b/packages/backend/src/queue/processors/CleanRemoteFilesProcessorService.ts
@@ -5,9 +5,9 @@ import type { DriveFilesRepository } from '@/models/index.js';
import type { Config } from '@/config.js';
import type Logger from '@/logger.js';
import { DriveService } from '@/core/DriveService.js';
-import { QueueLoggerService } from '../QueueLoggerService.js';
-import type Bull from 'bull';
import { bindThis } from '@/decorators.js';
+import { QueueLoggerService } from '../QueueLoggerService.js';
+import type * as Bull from 'bullmq';
@Injectable()
export class CleanRemoteFilesProcessorService {
@@ -27,7 +27,7 @@ export class CleanRemoteFilesProcessorService {
}
@bindThis
- public async process(job: Bull.Job<Record<string, unknown>>, done: () => void): Promise<void> {
+ public async process(job: Bull.Job<Record<string, unknown>>): Promise<void> {
this.logger.info('Deleting cached remote files...');
let deletedCount = 0;
@@ -47,7 +47,7 @@ export class CleanRemoteFilesProcessorService {
});
if (files.length === 0) {
- job.progress(100);
+ job.updateProgress(100);
break;
}
@@ -62,10 +62,9 @@ export class CleanRemoteFilesProcessorService {
isLink: false,
});
- job.progress(deletedCount / total);
+ job.updateProgress(deletedCount / total);
}
this.logger.succ('All cached remote files has been deleted.');
- done();
}
}
diff --git a/packages/backend/src/queue/processors/DeleteAccountProcessorService.ts b/packages/backend/src/queue/processors/DeleteAccountProcessorService.ts
index e36a78de6a..39dd801af0 100644
--- a/packages/backend/src/queue/processors/DeleteAccountProcessorService.ts
+++ b/packages/backend/src/queue/processors/DeleteAccountProcessorService.ts
@@ -8,10 +8,10 @@ import { DriveService } from '@/core/DriveService.js';
import type { DriveFile } from '@/models/entities/DriveFile.js';
import type { Note } from '@/models/entities/Note.js';
import { EmailService } from '@/core/EmailService.js';
+import { bindThis } from '@/decorators.js';
import { QueueLoggerService } from '../QueueLoggerService.js';
-import type Bull from 'bull';
+import type * as Bull from 'bullmq';
import type { DbUserDeleteJobData } from '../types.js';
-import { bindThis } from '@/decorators.js';
@Injectable()
export class DeleteAccountProcessorService {
diff --git a/packages/backend/src/queue/processors/DeleteDriveFilesProcessorService.ts b/packages/backend/src/queue/processors/DeleteDriveFilesProcessorService.ts
index 604497cf54..6772c5dc76 100644
--- a/packages/backend/src/queue/processors/DeleteDriveFilesProcessorService.ts
+++ b/packages/backend/src/queue/processors/DeleteDriveFilesProcessorService.ts
@@ -5,10 +5,10 @@ import type { UsersRepository, DriveFilesRepository } from '@/models/index.js';
import type { Config } from '@/config.js';
import type Logger from '@/logger.js';
import { DriveService } from '@/core/DriveService.js';
+import { bindThis } from '@/decorators.js';
import { QueueLoggerService } from '../QueueLoggerService.js';
-import type Bull from 'bull';
+import type * as Bull from 'bullmq';
import type { DbJobDataWithUser } from '../types.js';
-import { bindThis } from '@/decorators.js';
@Injectable()
export class DeleteDriveFilesProcessorService {
@@ -31,12 +31,11 @@ export class DeleteDriveFilesProcessorService {
}
@bindThis
- public async process(job: Bull.Job<DbJobDataWithUser>, done: () => void): Promise<void> {
+ public async process(job: Bull.Job<DbJobDataWithUser>): Promise<void> {
this.logger.info(`Deleting drive files of ${job.data.user.id} ...`);
const user = await this.usersRepository.findOneBy({ id: job.data.user.id });
if (user == null) {
- done();
return;
}
@@ -56,7 +55,7 @@ export class DeleteDriveFilesProcessorService {
});
if (files.length === 0) {
- job.progress(100);
+ job.updateProgress(100);
break;
}
@@ -71,10 +70,9 @@ export class DeleteDriveFilesProcessorService {
userId: user.id,
});
- job.progress(deletedCount / total);
+ job.updateProgress(deletedCount / total);
}
this.logger.succ(`All drive files (${deletedCount}) of ${user.id} has been deleted.`);
- done();
}
}
diff --git a/packages/backend/src/queue/processors/DeleteFileProcessorService.ts b/packages/backend/src/queue/processors/DeleteFileProcessorService.ts
index 2fb2f56f8d..edf87bd921 100644
--- a/packages/backend/src/queue/processors/DeleteFileProcessorService.ts
+++ b/packages/backend/src/queue/processors/DeleteFileProcessorService.ts
@@ -3,10 +3,10 @@ import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
import type Logger from '@/logger.js';
import { DriveService } from '@/core/DriveService.js';
+import { bindThis } from '@/decorators.js';
import { QueueLoggerService } from '../QueueLoggerService.js';
-import type Bull from 'bull';
+import type * as Bull from 'bullmq';
import type { ObjectStorageFileJobData } from '../types.js';
-import { bindThis } from '@/decorators.js';
@Injectable()
export class DeleteFileProcessorService {
diff --git a/packages/backend/src/queue/processors/DeliverProcessorService.ts b/packages/backend/src/queue/processors/DeliverProcessorService.ts
index f293bd4d7e..406e9df850 100644
--- a/packages/backend/src/queue/processors/DeliverProcessorService.ts
+++ b/packages/backend/src/queue/processors/DeliverProcessorService.ts
@@ -1,4 +1,5 @@
import { Inject, Injectable } from '@nestjs/common';
+import * as Bull from 'bullmq';
import { DI } from '@/di-symbols.js';
import type { DriveFilesRepository, InstancesRepository } from '@/models/index.js';
import type { Config } from '@/config.js';
@@ -16,7 +17,6 @@ import { StatusError } from '@/misc/status-error.js';
import { UtilityService } from '@/core/UtilityService.js';
import { bindThis } from '@/decorators.js';
import { QueueLoggerService } from '../QueueLoggerService.js';
-import type Bull from 'bull';
import type { DeliverJobData } from '../types.js';
@Injectable()
@@ -121,15 +121,13 @@ export class DeliverProcessorService {
isSuspended: true,
});
});
- return `${host} is gone`;
+ throw new Bull.UnrecoverableError(`${host} is gone`);
}
- // HTTPステータスコード4xxはクライアントエラーであり、それはつまり
- // 何回再送しても成功することはないということなのでエラーにはしないでおく
- return `${res.statusCode} ${res.statusMessage}`;
+ throw new Bull.UnrecoverableError(`${res.statusCode} ${res.statusMessage}`);
}
// 5xx etc.
- throw `${res.statusCode} ${res.statusMessage}`;
+ throw new Error(`${res.statusCode} ${res.statusMessage}`);
} else {
// DNS error, socket error, timeout ...
throw res;
diff --git a/packages/backend/src/queue/processors/EndedPollNotificationProcessorService.ts b/packages/backend/src/queue/processors/EndedPollNotificationProcessorService.ts
index 501ed4090a..21501592f2 100644
--- a/packages/backend/src/queue/processors/EndedPollNotificationProcessorService.ts
+++ b/packages/backend/src/queue/processors/EndedPollNotificationProcessorService.ts
@@ -6,7 +6,7 @@ import type Logger from '@/logger.js';
import { NotificationService } from '@/core/NotificationService.js';
import { bindThis } from '@/decorators.js';
import { QueueLoggerService } from '../QueueLoggerService.js';
-import type Bull from 'bull';
+import type * as Bull from 'bullmq';
import type { EndedPollNotificationJobData } from '../types.js';
@Injectable()
@@ -30,10 +30,9 @@ export class EndedPollNotificationProcessorService {
}
@bindThis
- public async process(job: Bull.Job<EndedPollNotificationJobData>, done: () => void): Promise<void> {
+ public async process(job: Bull.Job<EndedPollNotificationJobData>): Promise<void> {
const note = await this.notesRepository.findOneBy({ id: job.data.noteId });
if (note == null || !note.hasPoll) {
- done();
return;
}
@@ -51,7 +50,5 @@ export class EndedPollNotificationProcessorService {
noteId: note.id,
});
}
-
- done();
}
}
diff --git a/packages/backend/src/queue/processors/ExportAntennasProcessorService.ts b/packages/backend/src/queue/processors/ExportAntennasProcessorService.ts
index 894903e79b..ac52325c8d 100644
--- a/packages/backend/src/queue/processors/ExportAntennasProcessorService.ts
+++ b/packages/backend/src/queue/processors/ExportAntennasProcessorService.ts
@@ -12,7 +12,7 @@ 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';
+import type * as Bull from 'bullmq';
@Injectable()
export class ExportAntennasProcessorService {
@@ -39,10 +39,9 @@ export class ExportAntennasProcessorService {
}
@bindThis
- public async process(job: Bull.Job<DBExportAntennasData>, done: () => void): Promise<void> {
+ public async process(job: Bull.Job<DBExportAntennasData>): Promise<void> {
const user = await this.usersRepository.findOneBy({ id: job.data.user.id });
if (user == null) {
- done();
return;
}
const [path, cleanup] = await createTemp();
@@ -96,7 +95,6 @@ export class ExportAntennasProcessorService {
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 c7b54070d6..eb758e162d 100644
--- a/packages/backend/src/queue/processors/ExportBlockingProcessorService.ts
+++ b/packages/backend/src/queue/processors/ExportBlockingProcessorService.ts
@@ -9,10 +9,10 @@ import type Logger from '@/logger.js';
import { DriveService } from '@/core/DriveService.js';
import { createTemp } from '@/misc/create-temp.js';
import { UtilityService } from '@/core/UtilityService.js';
+import { bindThis } from '@/decorators.js';
import { QueueLoggerService } from '../QueueLoggerService.js';
-import type Bull from 'bull';
+import type * as Bull from 'bullmq';
import type { DbJobDataWithUser } from '../types.js';
-import { bindThis } from '@/decorators.js';
@Injectable()
export class ExportBlockingProcessorService {
@@ -36,12 +36,11 @@ export class ExportBlockingProcessorService {
}
@bindThis
- public async process(job: Bull.Job<DbJobDataWithUser>, done: () => void): Promise<void> {
+ public async process(job: Bull.Job<DbJobDataWithUser>): Promise<void> {
this.logger.info(`Exporting blocking of ${job.data.user.id} ...`);
const user = await this.usersRepository.findOneBy({ id: job.data.user.id });
if (user == null) {
- done();
return;
}
@@ -69,7 +68,7 @@ export class ExportBlockingProcessorService {
});
if (blockings.length === 0) {
- job.progress(100);
+ job.updateProgress(100);
break;
}
@@ -99,7 +98,7 @@ export class ExportBlockingProcessorService {
blockerId: user.id,
});
- job.progress(exportedCount / total);
+ job.updateProgress(exportedCount / total);
}
stream.end();
@@ -112,7 +111,5 @@ export class ExportBlockingProcessorService {
} finally {
cleanup();
}
-
- done();
}
}
diff --git a/packages/backend/src/queue/processors/ExportCustomEmojisProcessorService.ts b/packages/backend/src/queue/processors/ExportCustomEmojisProcessorService.ts
index b50f373ef8..3203d9f3e5 100644
--- a/packages/backend/src/queue/processors/ExportCustomEmojisProcessorService.ts
+++ b/packages/backend/src/queue/processors/ExportCustomEmojisProcessorService.ts
@@ -13,7 +13,7 @@ import { createTemp, createTempDir } from '@/misc/create-temp.js';
import { DownloadService } from '@/core/DownloadService.js';
import { bindThis } from '@/decorators.js';
import { QueueLoggerService } from '../QueueLoggerService.js';
-import type Bull from 'bull';
+import type * as Bull from 'bullmq';
@Injectable()
export class ExportCustomEmojisProcessorService {
@@ -37,12 +37,11 @@ export class ExportCustomEmojisProcessorService {
}
@bindThis
- public async process(job: Bull.Job, done: () => void): Promise<void> {
+ public async process(job: Bull.Job): Promise<void> {
this.logger.info('Exporting custom emojis ...');
const user = await this.usersRepository.findOneBy({ id: job.data.user.id });
if (user == null) {
- done();
return;
}
@@ -117,24 +116,26 @@ export class ExportCustomEmojisProcessorService {
metaStream.end();
// Create archive
- const [archivePath, archiveCleanup] = await createTemp();
- const archiveStream = fs.createWriteStream(archivePath);
- const archive = archiver('zip', {
- zlib: { level: 0 },
- });
- archiveStream.on('close', async () => {
- this.logger.succ(`Exported to: ${archivePath}`);
+ await new Promise<void>(async (resolve) => {
+ const [archivePath, archiveCleanup] = await createTemp();
+ const archiveStream = fs.createWriteStream(archivePath);
+ const archive = archiver('zip', {
+ zlib: { level: 0 },
+ });
+ archiveStream.on('close', async () => {
+ this.logger.succ(`Exported to: ${archivePath}`);
- const fileName = 'custom-emojis-' + dateFormat(new Date(), 'yyyy-MM-dd-HH-mm-ss') + '.zip';
- const driveFile = await this.driveService.addFile({ user, path: archivePath, name: fileName, force: true });
+ const fileName = 'custom-emojis-' + dateFormat(new Date(), 'yyyy-MM-dd-HH-mm-ss') + '.zip';
+ const driveFile = await this.driveService.addFile({ user, path: archivePath, name: fileName, force: true });
- this.logger.succ(`Exported to: ${driveFile.id}`);
- cleanup();
- archiveCleanup();
- done();
+ this.logger.succ(`Exported to: ${driveFile.id}`);
+ cleanup();
+ archiveCleanup();
+ resolve();
+ });
+ archive.pipe(archiveStream);
+ archive.directory(path, false);
+ archive.finalize();
});
- archive.pipe(archiveStream);
- archive.directory(path, false);
- archive.finalize();
}
}
diff --git a/packages/backend/src/queue/processors/ExportFavoritesProcessorService.ts b/packages/backend/src/queue/processors/ExportFavoritesProcessorService.ts
index f2f2383a88..76c38a6b86 100644
--- a/packages/backend/src/queue/processors/ExportFavoritesProcessorService.ts
+++ b/packages/backend/src/queue/processors/ExportFavoritesProcessorService.ts
@@ -12,7 +12,7 @@ import type { Poll } from '@/models/entities/Poll.js';
import type { Note } from '@/models/entities/Note.js';
import { bindThis } from '@/decorators.js';
import { QueueLoggerService } from '../QueueLoggerService.js';
-import type Bull from 'bull';
+import type * as Bull from 'bullmq';
import type { DbJobDataWithUser } from '../types.js';
@Injectable()
@@ -42,12 +42,11 @@ export class ExportFavoritesProcessorService {
}
@bindThis
- public async process(job: Bull.Job<DbJobDataWithUser>, done: () => void): Promise<void> {
+ public async process(job: Bull.Job<DbJobDataWithUser>): Promise<void> {
this.logger.info(`Exporting favorites of ${job.data.user.id} ...`);
const user = await this.usersRepository.findOneBy({ id: job.data.user.id });
if (user == null) {
- done();
return;
}
@@ -91,7 +90,7 @@ export class ExportFavoritesProcessorService {
}) as (NoteFavorite & { note: Note & { user: User } })[];
if (favorites.length === 0) {
- job.progress(100);
+ job.updateProgress(100);
break;
}
@@ -112,7 +111,7 @@ export class ExportFavoritesProcessorService {
userId: user.id,
});
- job.progress(exportedFavoritesCount / total);
+ job.updateProgress(exportedFavoritesCount / total);
}
await write(']');
@@ -127,8 +126,6 @@ export class ExportFavoritesProcessorService {
} finally {
cleanup();
}
-
- done();
}
}
diff --git a/packages/backend/src/queue/processors/ExportFollowingProcessorService.ts b/packages/backend/src/queue/processors/ExportFollowingProcessorService.ts
index fa9c1ac1ea..8726cb1402 100644
--- a/packages/backend/src/queue/processors/ExportFollowingProcessorService.ts
+++ b/packages/backend/src/queue/processors/ExportFollowingProcessorService.ts
@@ -10,10 +10,10 @@ import { DriveService } from '@/core/DriveService.js';
import { createTemp } from '@/misc/create-temp.js';
import type { Following } from '@/models/entities/Following.js';
import { UtilityService } from '@/core/UtilityService.js';
+import { bindThis } from '@/decorators.js';
import { QueueLoggerService } from '../QueueLoggerService.js';
-import type Bull from 'bull';
+import type * as Bull from 'bullmq';
import type { DbExportFollowingData } from '../types.js';
-import { bindThis } from '@/decorators.js';
@Injectable()
export class ExportFollowingProcessorService {
@@ -40,12 +40,11 @@ export class ExportFollowingProcessorService {
}
@bindThis
- public async process(job: Bull.Job<DbExportFollowingData>, done: () => void): Promise<void> {
+ public async process(job: Bull.Job<DbExportFollowingData>): Promise<void> {
this.logger.info(`Exporting following of ${job.data.user.id} ...`);
const user = await this.usersRepository.findOneBy({ id: job.data.user.id });
if (user == null) {
- done();
return;
}
@@ -116,7 +115,5 @@ export class ExportFollowingProcessorService {
} finally {
cleanup();
}
-
- done();
}
}
diff --git a/packages/backend/src/queue/processors/ExportMutingProcessorService.ts b/packages/backend/src/queue/processors/ExportMutingProcessorService.ts
index b14bf5f5b1..0f11a9e843 100644
--- a/packages/backend/src/queue/processors/ExportMutingProcessorService.ts
+++ b/packages/backend/src/queue/processors/ExportMutingProcessorService.ts
@@ -9,10 +9,10 @@ import type Logger from '@/logger.js';
import { DriveService } from '@/core/DriveService.js';
import { createTemp } from '@/misc/create-temp.js';
import { UtilityService } from '@/core/UtilityService.js';
+import { bindThis } from '@/decorators.js';
import { QueueLoggerService } from '../QueueLoggerService.js';
-import type Bull from 'bull';
+import type * as Bull from 'bullmq';
import type { DbJobDataWithUser } from '../types.js';
-import { bindThis } from '@/decorators.js';
@Injectable()
export class ExportMutingProcessorService {
@@ -39,12 +39,11 @@ export class ExportMutingProcessorService {
}
@bindThis
- public async process(job: Bull.Job<DbJobDataWithUser>, done: () => void): Promise<void> {
+ public async process(job: Bull.Job<DbJobDataWithUser>): Promise<void> {
this.logger.info(`Exporting muting of ${job.data.user.id} ...`);
const user = await this.usersRepository.findOneBy({ id: job.data.user.id });
if (user == null) {
- done();
return;
}
@@ -73,7 +72,7 @@ export class ExportMutingProcessorService {
});
if (mutes.length === 0) {
- job.progress(100);
+ job.updateProgress(100);
break;
}
@@ -103,7 +102,7 @@ export class ExportMutingProcessorService {
muterId: user.id,
});
- job.progress(exportedCount / total);
+ job.updateProgress(exportedCount / total);
}
stream.end();
@@ -116,7 +115,5 @@ export class ExportMutingProcessorService {
} finally {
cleanup();
}
-
- done();
}
}
diff --git a/packages/backend/src/queue/processors/ExportNotesProcessorService.ts b/packages/backend/src/queue/processors/ExportNotesProcessorService.ts
index e4f12ad101..24fb331883 100644
--- a/packages/backend/src/queue/processors/ExportNotesProcessorService.ts
+++ b/packages/backend/src/queue/processors/ExportNotesProcessorService.ts
@@ -12,7 +12,7 @@ import type { Poll } from '@/models/entities/Poll.js';
import type { Note } from '@/models/entities/Note.js';
import { bindThis } from '@/decorators.js';
import { QueueLoggerService } from '../QueueLoggerService.js';
-import type Bull from 'bull';
+import type * as Bull from 'bullmq';
import type { DbJobDataWithUser } from '../types.js';
@Injectable()
@@ -39,12 +39,11 @@ export class ExportNotesProcessorService {
}
@bindThis
- public async process(job: Bull.Job<DbJobDataWithUser>, done: () => void): Promise<void> {
+ public async process(job: Bull.Job<DbJobDataWithUser>): Promise<void> {
this.logger.info(`Exporting notes of ${job.data.user.id} ...`);
const user = await this.usersRepository.findOneBy({ id: job.data.user.id });
if (user == null) {
- done();
return;
}
@@ -87,7 +86,7 @@ export class ExportNotesProcessorService {
}) as Note[];
if (notes.length === 0) {
- job.progress(100);
+ job.updateProgress(100);
break;
}
@@ -108,7 +107,7 @@ export class ExportNotesProcessorService {
userId: user.id,
});
- job.progress(exportedNotesCount / total);
+ job.updateProgress(exportedNotesCount / total);
}
await write(']');
@@ -123,8 +122,6 @@ export class ExportNotesProcessorService {
} finally {
cleanup();
}
-
- done();
}
}
diff --git a/packages/backend/src/queue/processors/ExportUserListsProcessorService.ts b/packages/backend/src/queue/processors/ExportUserListsProcessorService.ts
index 54bde44044..ec63358053 100644
--- a/packages/backend/src/queue/processors/ExportUserListsProcessorService.ts
+++ b/packages/backend/src/queue/processors/ExportUserListsProcessorService.ts
@@ -9,10 +9,10 @@ import type Logger from '@/logger.js';
import { DriveService } from '@/core/DriveService.js';
import { createTemp } from '@/misc/create-temp.js';
import { UtilityService } from '@/core/UtilityService.js';
+import { bindThis } from '@/decorators.js';
import { QueueLoggerService } from '../QueueLoggerService.js';
-import type Bull from 'bull';
+import type * as Bull from 'bullmq';
import type { DbJobDataWithUser } from '../types.js';
-import { bindThis } from '@/decorators.js';
@Injectable()
export class ExportUserListsProcessorService {
@@ -39,12 +39,11 @@ export class ExportUserListsProcessorService {
}
@bindThis
- public async process(job: Bull.Job<DbJobDataWithUser>, done: () => void): Promise<void> {
+ public async process(job: Bull.Job<DbJobDataWithUser>): Promise<void> {
this.logger.info(`Exporting user lists of ${job.data.user.id} ...`);
const user = await this.usersRepository.findOneBy({ id: job.data.user.id });
if (user == null) {
- done();
return;
}
@@ -92,7 +91,5 @@ export class ExportUserListsProcessorService {
} finally {
cleanup();
}
-
- done();
}
}
diff --git a/packages/backend/src/queue/processors/ImportAntennasProcessorService.ts b/packages/backend/src/queue/processors/ImportAntennasProcessorService.ts
index d06131b8c8..575cad69d5 100644
--- a/packages/backend/src/queue/processors/ImportAntennasProcessorService.ts
+++ b/packages/backend/src/queue/processors/ImportAntennasProcessorService.ts
@@ -8,7 +8,7 @@ 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';
+import type * as Bull from 'bullmq';
const validate = new Ajv().compile({
type: 'object',
@@ -59,7 +59,7 @@ export class ImportAntennasProcessorService {
}
@bindThis
- public async process(job: Bull.Job<DBAntennaImportJobData>, done: () => void): Promise<void> {
+ public async process(job: Bull.Job<DBAntennaImportJobData>): Promise<void> {
const now = new Date();
try {
for (const antenna of job.data.antenna) {
@@ -89,8 +89,6 @@ export class ImportAntennasProcessorService {
}
} catch (err: any) {
this.logger.error(err);
- } finally {
- done();
}
}
}
diff --git a/packages/backend/src/queue/processors/ImportBlockingProcessorService.ts b/packages/backend/src/queue/processors/ImportBlockingProcessorService.ts
index 3f075b02d2..2f1a9e5b03 100644
--- a/packages/backend/src/queue/processors/ImportBlockingProcessorService.ts
+++ b/packages/backend/src/queue/processors/ImportBlockingProcessorService.ts
@@ -7,11 +7,11 @@ import * as Acct from '@/misc/acct.js';
import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js';
import { DownloadService } from '@/core/DownloadService.js';
import { UtilityService } from '@/core/UtilityService.js';
-import { QueueLoggerService } from '../QueueLoggerService.js';
-import type Bull from 'bull';
-import type { DbUserImportJobData, DbUserImportToDbJobData } from '../types.js';
import { bindThis } from '@/decorators.js';
import { QueueService } from '@/core/QueueService.js';
+import { QueueLoggerService } from '../QueueLoggerService.js';
+import type * as Bull from 'bullmq';
+import type { DbUserImportJobData, DbUserImportToDbJobData } from '../types.js';
@Injectable()
export class ImportBlockingProcessorService {
@@ -34,12 +34,11 @@ export class ImportBlockingProcessorService {
}
@bindThis
- public async process(job: Bull.Job<DbUserImportJobData>, done: () => void): Promise<void> {
+ public async process(job: Bull.Job<DbUserImportJobData>): Promise<void> {
this.logger.info(`Importing blocking of ${job.data.user.id} ...`);
const user = await this.usersRepository.findOneBy({ id: job.data.user.id });
if (user == null) {
- done();
return;
}
@@ -47,7 +46,6 @@ export class ImportBlockingProcessorService {
id: job.data.fileId,
});
if (file == null) {
- done();
return;
}
@@ -56,7 +54,6 @@ export class ImportBlockingProcessorService {
this.queueService.createImportBlockingToDbJob({ id: user.id }, targets);
this.logger.succ('Import jobs created');
- done();
}
@bindThis
@@ -85,7 +82,7 @@ export class ImportBlockingProcessorService {
}
if (target == null) {
- throw `Unable to resolve user: @${username}@${host}`;
+ throw new Error(`Unable to resolve user: @${username}@${host}`);
}
// skip myself
diff --git a/packages/backend/src/queue/processors/ImportCustomEmojisProcessorService.ts b/packages/backend/src/queue/processors/ImportCustomEmojisProcessorService.ts
index cf78d8330c..d862567871 100644
--- a/packages/backend/src/queue/processors/ImportCustomEmojisProcessorService.ts
+++ b/packages/backend/src/queue/processors/ImportCustomEmojisProcessorService.ts
@@ -12,7 +12,7 @@ import { DriveService } from '@/core/DriveService.js';
import { DownloadService } from '@/core/DownloadService.js';
import { bindThis } from '@/decorators.js';
import { QueueLoggerService } from '../QueueLoggerService.js';
-import type Bull from 'bull';
+import type * as Bull from 'bullmq';
import type { DbUserImportJobData } from '../types.js';
// TODO: 名前衝突時の動作を選べるようにする
@@ -45,14 +45,13 @@ export class ImportCustomEmojisProcessorService {
}
@bindThis
- public async process(job: Bull.Job<DbUserImportJobData>, done: () => void): Promise<void> {
+ public async process(job: Bull.Job<DbUserImportJobData>): Promise<void> {
this.logger.info('Importing custom emojis ...');
const file = await this.driveFilesRepository.findOneBy({
id: job.data.fileId,
});
if (file == null) {
- done();
return;
}
@@ -107,13 +106,15 @@ export class ImportCustomEmojisProcessorService {
aliases: emojiInfo.aliases,
driveFile,
license: emojiInfo.license,
+ isSensitive: emojiInfo.isSensitive,
+ localOnly: emojiInfo.localOnly,
+ roleIdsThatCanBeUsedThisEmojiAsReaction: [],
});
}
cleanup();
this.logger.succ('Imported');
- done();
});
unzipStream.pipe(extractor);
this.logger.succ(`Unzipping to ${outputPath}`);
diff --git a/packages/backend/src/queue/processors/ImportFollowingProcessorService.ts b/packages/backend/src/queue/processors/ImportFollowingProcessorService.ts
index aa5cf12c50..15bee9672e 100644
--- a/packages/backend/src/queue/processors/ImportFollowingProcessorService.ts
+++ b/packages/backend/src/queue/processors/ImportFollowingProcessorService.ts
@@ -7,11 +7,11 @@ import * as Acct from '@/misc/acct.js';
import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js';
import { DownloadService } from '@/core/DownloadService.js';
import { UtilityService } from '@/core/UtilityService.js';
-import { QueueLoggerService } from '../QueueLoggerService.js';
-import type Bull from 'bull';
-import type { DbUserImportJobData, DbUserImportToDbJobData } from '../types.js';
import { bindThis } from '@/decorators.js';
import { QueueService } from '@/core/QueueService.js';
+import { QueueLoggerService } from '../QueueLoggerService.js';
+import type * as Bull from 'bullmq';
+import type { DbUserImportJobData, DbUserImportToDbJobData } from '../types.js';
@Injectable()
export class ImportFollowingProcessorService {
@@ -34,12 +34,11 @@ export class ImportFollowingProcessorService {
}
@bindThis
- public async process(job: Bull.Job<DbUserImportJobData>, done: () => void): Promise<void> {
+ public async process(job: Bull.Job<DbUserImportJobData>): Promise<void> {
this.logger.info(`Importing following of ${job.data.user.id} ...`);
const user = await this.usersRepository.findOneBy({ id: job.data.user.id });
if (user == null) {
- done();
return;
}
@@ -47,7 +46,6 @@ export class ImportFollowingProcessorService {
id: job.data.fileId,
});
if (file == null) {
- done();
return;
}
@@ -56,7 +54,6 @@ export class ImportFollowingProcessorService {
this.queueService.createImportFollowingToDbJob({ id: user.id }, targets);
this.logger.succ('Import jobs created');
- done();
}
@bindThis
@@ -85,7 +82,7 @@ export class ImportFollowingProcessorService {
}
if (target == null) {
- throw `Unable to resolve user: @${username}@${host}`;
+ throw new Error(`Unable to resolve user: @${username}@${host}`);
}
// skip myself
diff --git a/packages/backend/src/queue/processors/ImportMutingProcessorService.ts b/packages/backend/src/queue/processors/ImportMutingProcessorService.ts
index 379994ee79..723935cd31 100644
--- a/packages/backend/src/queue/processors/ImportMutingProcessorService.ts
+++ b/packages/backend/src/queue/processors/ImportMutingProcessorService.ts
@@ -9,10 +9,10 @@ import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js';
import { DownloadService } from '@/core/DownloadService.js';
import { UserMutingService } from '@/core/UserMutingService.js';
import { UtilityService } from '@/core/UtilityService.js';
+import { bindThis } from '@/decorators.js';
import { QueueLoggerService } from '../QueueLoggerService.js';
-import type Bull from 'bull';
+import type * as Bull from 'bullmq';
import type { DbUserImportJobData } from '../types.js';
-import { bindThis } from '@/decorators.js';
@Injectable()
export class ImportMutingProcessorService {
@@ -38,12 +38,11 @@ export class ImportMutingProcessorService {
}
@bindThis
- public async process(job: Bull.Job<DbUserImportJobData>, done: () => void): Promise<void> {
+ public async process(job: Bull.Job<DbUserImportJobData>): Promise<void> {
this.logger.info(`Importing muting of ${job.data.user.id} ...`);
const user = await this.usersRepository.findOneBy({ id: job.data.user.id });
if (user == null) {
- done();
return;
}
@@ -51,7 +50,6 @@ export class ImportMutingProcessorService {
id: job.data.fileId,
});
if (file == null) {
- done();
return;
}
@@ -83,7 +81,7 @@ export class ImportMutingProcessorService {
}
if (target == null) {
- throw `cannot resolve user: @${username}@${host}`;
+ throw new Error(`cannot resolve user: @${username}@${host}`);
}
// skip myself
@@ -98,6 +96,5 @@ export class ImportMutingProcessorService {
}
this.logger.succ('Imported');
- done();
}
}
diff --git a/packages/backend/src/queue/processors/ImportUserListsProcessorService.ts b/packages/backend/src/queue/processors/ImportUserListsProcessorService.ts
index c423863410..824ee8157a 100644
--- a/packages/backend/src/queue/processors/ImportUserListsProcessorService.ts
+++ b/packages/backend/src/queue/processors/ImportUserListsProcessorService.ts
@@ -12,7 +12,7 @@ import { IdService } from '@/core/IdService.js';
import { UtilityService } from '@/core/UtilityService.js';
import { bindThis } from '@/decorators.js';
import { QueueLoggerService } from '../QueueLoggerService.js';
-import type Bull from 'bull';
+import type * as Bull from 'bullmq';
import type { DbUserImportJobData } from '../types.js';
@Injectable()
@@ -46,12 +46,11 @@ export class ImportUserListsProcessorService {
}
@bindThis
- public async process(job: Bull.Job<DbUserImportJobData>, done: () => void): Promise<void> {
+ public async process(job: Bull.Job<DbUserImportJobData>): Promise<void> {
this.logger.info(`Importing user lists of ${job.data.user.id} ...`);
const user = await this.usersRepository.findOneBy({ id: job.data.user.id });
if (user == null) {
- done();
return;
}
@@ -59,7 +58,6 @@ export class ImportUserListsProcessorService {
id: job.data.fileId,
});
if (file == null) {
- done();
return;
}
@@ -109,6 +107,5 @@ export class ImportUserListsProcessorService {
}
this.logger.succ('Imported');
- done();
}
}
diff --git a/packages/backend/src/queue/processors/InboxProcessorService.ts b/packages/backend/src/queue/processors/InboxProcessorService.ts
index ab8b1e9e22..ce1d7aaa1b 100644
--- a/packages/backend/src/queue/processors/InboxProcessorService.ts
+++ b/packages/backend/src/queue/processors/InboxProcessorService.ts
@@ -1,8 +1,8 @@
import { URL } from 'node:url';
import { Inject, Injectable } from '@nestjs/common';
import httpSignature from '@peertube/http-signature';
+import * as Bull from 'bullmq';
import { DI } from '@/di-symbols.js';
-import type { InstancesRepository, DriveFilesRepository } from '@/models/index.js';
import type { Config } from '@/config.js';
import type Logger from '@/logger.js';
import { MetaService } from '@/core/MetaService.js';
@@ -23,10 +23,8 @@ import { LdSignatureService } from '@/core/activitypub/LdSignatureService.js';
import { ApInboxService } from '@/core/activitypub/ApInboxService.js';
import { bindThis } from '@/decorators.js';
import { QueueLoggerService } from '../QueueLoggerService.js';
-import type Bull from 'bull';
import type { InboxJobData } from '../types.js';
-// ユーザーのinboxにアクティビティが届いた時の処理
@Injectable()
export class InboxProcessorService {
private logger: Logger;
@@ -35,12 +33,6 @@ export class InboxProcessorService {
@Inject(DI.config)
private config: Config,
- @Inject(DI.instancesRepository)
- private instancesRepository: InstancesRepository,
-
- @Inject(DI.driveFilesRepository)
- private driveFilesRepository: DriveFilesRepository,
-
private utilityService: UtilityService,
private metaService: MetaService,
private apInboxService: ApInboxService,
@@ -93,24 +85,24 @@ export class InboxProcessorService {
try {
authUser = await this.apDbResolverService.getAuthUserFromApId(getApId(activity.actor));
} catch (err) {
- // 対象が4xxならスキップ
+ // 対象が4xxならスキップ
if (err instanceof StatusError) {
if (err.isClientError) {
- return `skip: Ignored deleted actors on both ends ${activity.actor} - ${err.statusCode}`;
+ throw new Bull.UnrecoverableError(`skip: Ignored deleted actors on both ends ${activity.actor} - ${err.statusCode}`);
}
- throw `Error in actor ${activity.actor} - ${err.statusCode ?? err}`;
+ throw new Error(`Error in actor ${activity.actor} - ${err.statusCode ?? err}`);
}
}
}
// それでもわからなければ終了
if (authUser == null) {
- return 'skip: failed to resolve user';
+ throw new Bull.UnrecoverableError('skip: failed to resolve user');
}
// publicKey がなくても終了
if (authUser.key == null) {
- return 'skip: failed to resolve user publicKey';
+ throw new Bull.UnrecoverableError('skip: failed to resolve user publicKey');
}
// HTTP-Signatureの検証
@@ -118,10 +110,10 @@ export class InboxProcessorService {
// また、signatureのsignerは、activity.actorと一致する必要がある
if (!httpSignatureValidated || authUser.user.uri !== activity.actor) {
- // 一致しなくても、でもLD-Signatureがありそうならそっちも見る
+ // 一致しなくても、でもLD-Signatureがありそうならそっちも見る
if (activity.signature) {
if (activity.signature.type !== 'RsaSignature2017') {
- return `skip: unsupported LD-signature type ${activity.signature.type}`;
+ throw new Bull.UnrecoverableError(`skip: unsupported LD-signature type ${activity.signature.type}`);
}
// activity.signature.creator: https://example.oom/users/user#main-key
@@ -134,32 +126,32 @@ export class InboxProcessorService {
// keyIdからLD-Signatureのユーザーを取得
authUser = await this.apDbResolverService.getAuthUserFromKeyId(activity.signature.creator);
if (authUser == null) {
- return 'skip: LD-Signatureのユーザーが取得できませんでした';
+ throw new Bull.UnrecoverableError('skip: LD-Signatureのユーザーが取得できませんでした');
}
if (authUser.key == null) {
- return 'skip: LD-SignatureのユーザーはpublicKeyを持っていませんでした';
+ throw new Bull.UnrecoverableError('skip: LD-SignatureのユーザーはpublicKeyを持っていませんでした');
}
// LD-Signature検証
const ldSignature = this.ldSignatureService.use();
const verified = await ldSignature.verifyRsaSignature2017(activity, authUser.key.keyPem).catch(() => false);
if (!verified) {
- return 'skip: LD-Signatureの検証に失敗しました';
+ throw new Bull.UnrecoverableError('skip: LD-Signatureの検証に失敗しました');
}
// もう一度actorチェック
if (authUser.user.uri !== activity.actor) {
- return `skip: LD-Signature user(${authUser.user.uri}) !== activity.actor(${activity.actor})`;
+ throw new Bull.UnrecoverableError(`skip: LD-Signature user(${authUser.user.uri}) !== activity.actor(${activity.actor})`);
}
// ブロックしてたら中断
const ldHost = this.utilityService.extractDbHost(authUser.user.uri);
if (this.utilityService.isBlockedHost(meta.blockedHosts, ldHost)) {
- return `Blocked request: ${ldHost}`;
+ throw new Bull.UnrecoverableError(`Blocked request: ${ldHost}`);
}
} else {
- return `skip: http-signature verification failed and no LD-Signature. keyId=${signature.keyId}`;
+ throw new Bull.UnrecoverableError(`skip: http-signature verification failed and no LD-Signature. keyId=${signature.keyId}`);
}
}
@@ -168,7 +160,7 @@ export class InboxProcessorService {
const signerHost = this.utilityService.extractDbHost(authUser.user.uri!);
const activityIdHost = this.utilityService.extractDbHost(activity.id);
if (signerHost !== activityIdHost) {
- return `skip: signerHost(${signerHost}) !== activity.id host(${activityIdHost}`;
+ throw new Bull.UnrecoverableError(`skip: signerHost(${signerHost}) !== activity.id host(${activityIdHost}`);
}
}
diff --git a/packages/backend/src/queue/processors/RelationshipProcessorService.ts b/packages/backend/src/queue/processors/RelationshipProcessorService.ts
index ff454df455..722260d948 100644
--- a/packages/backend/src/queue/processors/RelationshipProcessorService.ts
+++ b/packages/backend/src/queue/processors/RelationshipProcessorService.ts
@@ -1,5 +1,5 @@
import { Inject, Injectable } from '@nestjs/common';
-import type Bull from 'bull';
+import type * as Bull from 'bullmq';
import { UserFollowingService } from '@/core/UserFollowingService.js';
import { UserBlockingService } from '@/core/UserBlockingService.js';
diff --git a/packages/backend/src/queue/processors/ResyncChartsProcessorService.ts b/packages/backend/src/queue/processors/ResyncChartsProcessorService.ts
index e5840f3da8..eab8e1e68d 100644
--- a/packages/backend/src/queue/processors/ResyncChartsProcessorService.ts
+++ b/packages/backend/src/queue/processors/ResyncChartsProcessorService.ts
@@ -15,7 +15,7 @@ import PerUserDriveChart from '@/core/chart/charts/per-user-drive.js';
import ApRequestChart from '@/core/chart/charts/ap-request.js';
import { bindThis } from '@/decorators.js';
import { QueueLoggerService } from '../QueueLoggerService.js';
-import type Bull from 'bull';
+import type * as Bull from 'bullmq';
@Injectable()
export class ResyncChartsProcessorService {
@@ -43,7 +43,7 @@ export class ResyncChartsProcessorService {
}
@bindThis
- public async process(job: Bull.Job<Record<string, unknown>>, done: () => void): Promise<void> {
+ public async process(): Promise<void> {
this.logger.info('Resync charts...');
// TODO: ユーザーごとのチャートも更新する
@@ -55,6 +55,5 @@ export class ResyncChartsProcessorService {
]);
this.logger.succ('All charts successfully resynced.');
- done();
}
}
diff --git a/packages/backend/src/queue/processors/TickChartsProcessorService.ts b/packages/backend/src/queue/processors/TickChartsProcessorService.ts
index 7ff84c15a5..f1696bf567 100644
--- a/packages/backend/src/queue/processors/TickChartsProcessorService.ts
+++ b/packages/backend/src/queue/processors/TickChartsProcessorService.ts
@@ -16,7 +16,7 @@ import PerUserDriveChart from '@/core/chart/charts/per-user-drive.js';
import ApRequestChart from '@/core/chart/charts/ap-request.js';
import { bindThis } from '@/decorators.js';
import { QueueLoggerService } from '../QueueLoggerService.js';
-import type Bull from 'bull';
+import type * as Bull from 'bullmq';
@Injectable()
export class TickChartsProcessorService {
@@ -45,7 +45,7 @@ export class TickChartsProcessorService {
}
@bindThis
- public async process(job: Bull.Job<Record<string, unknown>>, done: () => void): Promise<void> {
+ public async process(): Promise<void> {
this.logger.info('Tick charts...');
await Promise.all([
@@ -64,6 +64,5 @@ export class TickChartsProcessorService {
]);
this.logger.succ('All charts successfully ticked.');
- done();
}
}
diff --git a/packages/backend/src/queue/processors/WebhookDeliverProcessorService.ts b/packages/backend/src/queue/processors/WebhookDeliverProcessorService.ts
index 84a5c21c49..8b40c16749 100644
--- a/packages/backend/src/queue/processors/WebhookDeliverProcessorService.ts
+++ b/packages/backend/src/queue/processors/WebhookDeliverProcessorService.ts
@@ -1,4 +1,5 @@
import { Inject, Injectable } from '@nestjs/common';
+import * as Bull from 'bullmq';
import { DI } from '@/di-symbols.js';
import type { WebhooksRepository } from '@/models/index.js';
import type { Config } from '@/config.js';
@@ -7,7 +8,6 @@ import { HttpRequestService } from '@/core/HttpRequestService.js';
import { StatusError } from '@/misc/status-error.js';
import { bindThis } from '@/decorators.js';
import { QueueLoggerService } from '../QueueLoggerService.js';
-import type Bull from 'bull';
import type { WebhookDeliverJobData } from '../types.js';
@Injectable()
@@ -66,11 +66,11 @@ export class WebhookDeliverProcessorService {
if (res instanceof StatusError) {
// 4xx
if (res.isClientError) {
- return `${res.statusCode} ${res.statusMessage}`;
+ throw new Bull.UnrecoverableError(`${res.statusCode} ${res.statusMessage}`);
}
// 5xx etc.
- throw `${res.statusCode} ${res.statusMessage}`;
+ throw new Error(`${res.statusCode} ${res.statusMessage}`);
} else {
// DNS error, socket error, timeout ...
throw res;
diff --git a/packages/backend/src/server/ActivityPubServerService.ts b/packages/backend/src/server/ActivityPubServerService.ts
index e675d9cf1b..455acd1e47 100644
--- a/packages/backend/src/server/ActivityPubServerService.ts
+++ b/packages/backend/src/server/ActivityPubServerService.ts
@@ -585,7 +585,7 @@ export class ActivityPubServerService {
name: request.params.emoji,
});
- if (emoji == null) {
+ if (emoji == null || emoji.localOnly) {
reply.code(404);
return;
}
diff --git a/packages/backend/src/server/ServerService.ts b/packages/backend/src/server/ServerService.ts
index 9257fee13e..c3d45e4ad6 100644
--- a/packages/backend/src/server/ServerService.ts
+++ b/packages/backend/src/server/ServerService.ts
@@ -194,7 +194,7 @@ export class ServerService implements OnApplicationShutdown {
fastify.register(this.clientServerService.createServer);
- this.streamingApiServerService.attachStreamingApi(fastify.server);
+ this.streamingApiServerService.attach(fastify.server);
fastify.server.on('error', err => {
switch ((err as any).code) {
@@ -222,7 +222,14 @@ export class ServerService implements OnApplicationShutdown {
await fastify.ready();
}
- async onApplicationShutdown(signal: string): Promise<void> {
+ @bindThis
+ public async dispose(): Promise<void> {
+ await this.streamingApiServerService.detach();
await this.#fastify.close();
}
+
+ @bindThis
+ async onApplicationShutdown(signal: string): Promise<void> {
+ await this.dispose();
+ }
}
diff --git a/packages/backend/src/server/api/ApiCallService.ts b/packages/backend/src/server/api/ApiCallService.ts
index e3483c82c6..dad1a4132a 100644
--- a/packages/backend/src/server/api/ApiCallService.ts
+++ b/packages/backend/src/server/api/ApiCallService.ts
@@ -359,7 +359,12 @@ export class ApiCallService implements OnApplicationShutdown {
}
@bindThis
- public onApplicationShutdown(signal?: string | undefined) {
+ public dispose(): void {
clearInterval(this.userIpHistoriesClearIntervalId);
}
+
+ @bindThis
+ public onApplicationShutdown(signal?: string | undefined): void {
+ this.dispose();
+ }
}
diff --git a/packages/backend/src/server/api/AuthenticateService.ts b/packages/backend/src/server/api/AuthenticateService.ts
index 6548c475b2..e23591d876 100644
--- a/packages/backend/src/server/api/AuthenticateService.ts
+++ b/packages/backend/src/server/api/AuthenticateService.ts
@@ -36,7 +36,7 @@ export class AuthenticateService {
}
@bindThis
- public async authenticate(token: string | null | undefined): Promise<[LocalUser | null | undefined, AccessToken | null | undefined]> {
+ public async authenticate(token: string | null | undefined): Promise<[LocalUser | null, AccessToken | null]> {
if (token == null) {
return [null, null];
}
diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts
index ee1aae5b6c..1e32e9988d 100644
--- a/packages/backend/src/server/api/EndpointsModule.ts
+++ b/packages/backend/src/server/api/EndpointsModule.ts
@@ -321,6 +321,9 @@ import * as ep___users_lists_pull from './endpoints/users/lists/pull.js';
import * as ep___users_lists_push from './endpoints/users/lists/push.js';
import * as ep___users_lists_show from './endpoints/users/lists/show.js';
import * as ep___users_lists_update from './endpoints/users/lists/update.js';
+import * as ep___users_lists_favorite from './endpoints/users/lists/favorite.js';
+import * as ep___users_lists_unfavorite from './endpoints/users/lists/unfavorite.js';
+import * as ep___users_lists_create_from_public from './endpoints/users/lists/create-from-public.js';
import * as ep___users_notes from './endpoints/users/notes.js';
import * as ep___users_pages from './endpoints/users/pages.js';
import * as ep___users_reactions from './endpoints/users/reactions.js';
@@ -659,6 +662,9 @@ const $users_lists_pull: Provider = { provide: 'ep:users/lists/pull', useClass:
const $users_lists_push: Provider = { provide: 'ep:users/lists/push', useClass: ep___users_lists_push.default };
const $users_lists_show: Provider = { provide: 'ep:users/lists/show', useClass: ep___users_lists_show.default };
const $users_lists_update: Provider = { provide: 'ep:users/lists/update', useClass: ep___users_lists_update.default };
+const $users_lists_favorite: Provider = { provide: 'ep:users/lists/favorite', useClass: ep___users_lists_favorite.default };
+const $users_lists_unfavorite: Provider = { provide: 'ep:users/lists/unfavorite', useClass: ep___users_lists_unfavorite.default };
+const $users_lists_create_from_public: Provider = { provide: 'ep:users/lists/create-from-public', useClass: ep___users_lists_create_from_public.default };
const $users_notes: Provider = { provide: 'ep:users/notes', useClass: ep___users_notes.default };
const $users_pages: Provider = { provide: 'ep:users/pages', useClass: ep___users_pages.default };
const $users_reactions: Provider = { provide: 'ep:users/reactions', useClass: ep___users_reactions.default };
@@ -1001,6 +1007,9 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$users_lists_push,
$users_lists_show,
$users_lists_update,
+ $users_lists_favorite,
+ $users_lists_unfavorite,
+ $users_lists_create_from_public,
$users_notes,
$users_pages,
$users_reactions,
@@ -1335,6 +1344,9 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$users_lists_push,
$users_lists_show,
$users_lists_update,
+ $users_lists_favorite,
+ $users_lists_unfavorite,
+ $users_lists_create_from_public,
$users_notes,
$users_pages,
$users_reactions,
diff --git a/packages/backend/src/server/api/StreamingApiServerService.ts b/packages/backend/src/server/api/StreamingApiServerService.ts
index 258e8de034..893dfe956e 100644
--- a/packages/backend/src/server/api/StreamingApiServerService.ts
+++ b/packages/backend/src/server/api/StreamingApiServerService.ts
@@ -1,23 +1,27 @@
import { EventEmitter } from 'events';
import { Inject, Injectable } from '@nestjs/common';
import * as Redis from 'ioredis';
-import * as websocket from 'websocket';
+import * as WebSocket from 'ws';
import { DI } from '@/di-symbols.js';
-import type { UsersRepository, BlockingsRepository, ChannelFollowingsRepository, FollowingsRepository, MutingsRepository, UserProfilesRepository, RenoteMutingsRepository } from '@/models/index.js';
+import type { UsersRepository, AccessToken } from '@/models/index.js';
import type { Config } from '@/config.js';
import { NoteReadService } from '@/core/NoteReadService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import { NotificationService } from '@/core/NotificationService.js';
import { bindThis } from '@/decorators.js';
import { CacheService } from '@/core/CacheService.js';
-import { AuthenticateService } from './AuthenticateService.js';
+import { LocalUser } from '@/models/entities/User';
+import { AuthenticateService, AuthenticationError } from './AuthenticateService.js';
import MainStreamConnection from './stream/index.js';
import { ChannelsService } from './stream/ChannelsService.js';
-import type { ParsedUrlQuery } from 'querystring';
import type * as http from 'node:http';
@Injectable()
export class StreamingApiServerService {
+ #wss: WebSocket.WebSocketServer;
+ #connections = new Map<WebSocket.WebSocket, number>();
+ #cleanConnectionsIntervalId: NodeJS.Timeout | null = null;
+
constructor(
@Inject(DI.config)
private config: Config,
@@ -28,24 +32,6 @@ export class StreamingApiServerService {
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
- @Inject(DI.followingsRepository)
- private followingsRepository: FollowingsRepository,
-
- @Inject(DI.mutingsRepository)
- private mutingsRepository: MutingsRepository,
-
- @Inject(DI.renoteMutingsRepository)
- private renoteMutingsRepository: RenoteMutingsRepository,
-
- @Inject(DI.blockingsRepository)
- private blockingsRepository: BlockingsRepository,
-
- @Inject(DI.channelFollowingsRepository)
- private channelFollowingsRepository: ChannelFollowingsRepository,
-
- @Inject(DI.userProfilesRepository)
- private userProfilesRepository: UserProfilesRepository,
-
private cacheService: CacheService,
private noteReadService: NoteReadService,
private authenticateService: AuthenticateService,
@@ -55,25 +41,65 @@ export class StreamingApiServerService {
}
@bindThis
- public attachStreamingApi(server: http.Server) {
- // Init websocket server
- const ws = new websocket.server({
- httpServer: server,
+ public attach(server: http.Server): void {
+ this.#wss = new WebSocket.WebSocketServer({
+ noServer: true,
});
- ws.on('request', async (request) => {
- const q = request.resourceURL.query as ParsedUrlQuery;
+ server.on('upgrade', async (request, socket, head) => {
+ if (request.url == null) {
+ socket.write('HTTP/1.1 400 Bad Request\r\n\r\n');
+ socket.destroy();
+ return;
+ }
+
+ const q = new URL(request.url, `http://${request.headers.host}`).searchParams;
+
+ let user: LocalUser | null = null;
+ let app: AccessToken | null = null;
- // TODO: トークンが間違ってるなどしてauthenticateに失敗したら
- // コネクション切断するなりエラーメッセージ返すなりする
- // (現状はエラーがキャッチされておらずサーバーのログに流れて邪魔なので)
- const [user, miapp] = await this.authenticateService.authenticate(q.i as string);
+ try {
+ [user, app] = await this.authenticateService.authenticate(q.get('i'));
+ } catch (e) {
+ if (e instanceof AuthenticationError) {
+ socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
+ } else {
+ socket.write('HTTP/1.1 500 Internal Server Error\r\n\r\n');
+ }
+ socket.destroy();
+ return;
+ }
if (user?.isSuspended) {
- request.reject(400);
+ socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
+ socket.destroy();
return;
}
+ const stream = new MainStreamConnection(
+ this.channelsService,
+ this.noteReadService,
+ this.notificationService,
+ this.cacheService,
+ user, app,
+ );
+
+ await stream.init();
+
+ this.#wss.handleUpgrade(request, socket, head, (ws) => {
+ this.#wss.emit('connection', ws, request, {
+ stream, user, app,
+ });
+ });
+ });
+
+ this.#wss.on('connection', async (connection: WebSocket.WebSocket, request: http.IncomingMessage, ctx: {
+ stream: MainStreamConnection,
+ user: LocalUser | null;
+ app: AccessToken | null
+ }) => {
+ const { stream, user, app } = ctx;
+
const ev = new EventEmitter();
async function onRedisMessage(_: string, data: string): Promise<void> {
@@ -83,21 +109,11 @@ export class StreamingApiServerService {
this.redisForSub.on('message', onRedisMessage);
- const main = new MainStreamConnection(
- this.channelsService,
- this.noteReadService,
- this.notificationService,
- this.cacheService,
- ev, user, miapp,
- );
+ await stream.listen(ev, connection);
- await main.init();
+ this.#connections.set(connection, Date.now());
- const connection = request.accept();
-
- main.init2(connection);
-
- const intervalId = user ? setInterval(() => {
+ const userUpdateIntervalId = user ? setInterval(() => {
this.usersRepository.update(user.id, {
lastActiveDate: new Date(),
});
@@ -110,16 +126,38 @@ export class StreamingApiServerService {
connection.once('close', () => {
ev.removeAllListeners();
- main.dispose();
+ stream.dispose();
this.redisForSub.off('message', onRedisMessage);
- if (intervalId) clearInterval(intervalId);
+ if (userUpdateIntervalId) clearInterval(userUpdateIntervalId);
});
connection.on('message', async (data) => {
- if (data.type === 'utf8' && data.utf8Data === 'ping') {
+ this.#connections.set(connection, Date.now());
+ if (data.toString() === 'ping') {
connection.send('pong');
}
});
});
+
+ this.#cleanConnectionsIntervalId = setInterval(() => {
+ const now = Date.now();
+ for (const [connection, lastActive] of this.#connections.entries()) {
+ if (now - lastActive > 1000 * 60 * 5) {
+ connection.terminate();
+ this.#connections.delete(connection);
+ }
+ }
+ }, 1000 * 60 * 5);
+ }
+
+ @bindThis
+ public detach(): Promise<void> {
+ if (this.#cleanConnectionsIntervalId) {
+ clearInterval(this.#cleanConnectionsIntervalId);
+ this.#cleanConnectionsIntervalId = null;
+ }
+ return new Promise((resolve) => {
+ this.#wss.close(() => resolve());
+ });
}
}
diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts
index 09bd7cbff4..7e678a6404 100644
--- a/packages/backend/src/server/api/endpoints.ts
+++ b/packages/backend/src/server/api/endpoints.ts
@@ -320,6 +320,9 @@ import * as ep___users_lists_list from './endpoints/users/lists/list.js';
import * as ep___users_lists_pull from './endpoints/users/lists/pull.js';
import * as ep___users_lists_push from './endpoints/users/lists/push.js';
import * as ep___users_lists_show from './endpoints/users/lists/show.js';
+import * as ep___users_lists_favorite from './endpoints/users/lists/favorite.js';
+import * as ep___users_lists_unfavorite from './endpoints/users/lists/unfavorite.js';
+import * as ep___users_lists_create_from_public from './endpoints/users/lists/create-from-public.js';
import * as ep___users_lists_update from './endpoints/users/lists/update.js';
import * as ep___users_notes from './endpoints/users/notes.js';
import * as ep___users_pages from './endpoints/users/pages.js';
@@ -656,7 +659,10 @@ const eps = [
['users/lists/pull', ep___users_lists_pull],
['users/lists/push', ep___users_lists_push],
['users/lists/show', ep___users_lists_show],
+ ['users/lists/favorite', ep___users_lists_favorite],
+ ['users/lists/unfavorite', ep___users_lists_unfavorite],
['users/lists/update', ep___users_lists_update],
+ ['users/lists/create-from-public', ep___users_lists_create_from_public],
['users/notes', ep___users_notes],
['users/pages', ep___users_pages],
['users/reactions', ep___users_reactions],
diff --git a/packages/backend/src/server/api/endpoints/admin/announcements/update.ts b/packages/backend/src/server/api/endpoints/admin/announcements/update.ts
index 2393c2441c..12db1f78fb 100644
--- a/packages/backend/src/server/api/endpoints/admin/announcements/update.ts
+++ b/packages/backend/src/server/api/endpoints/admin/announcements/update.ts
@@ -25,7 +25,7 @@ export const paramDef = {
id: { type: 'string', format: 'misskey:id' },
title: { type: 'string', minLength: 1 },
text: { type: 'string', minLength: 1 },
- imageUrl: { type: 'string', nullable: true, minLength: 1 },
+ imageUrl: { type: 'string', nullable: true, minLength: 0 },
},
required: ['id', 'title', 'text', 'imageUrl'],
} as const;
@@ -46,7 +46,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
updatedAt: new Date(),
title: ps.title,
text: ps.text,
- imageUrl: ps.imageUrl,
+ /* eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- 空の文字列の場合、nullを渡すようにするため */
+ imageUrl: ps.imageUrl || null,
});
});
}
diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/add.ts b/packages/backend/src/server/api/endpoints/admin/emoji/add.ts
index 2fb3e489e7..509224e9c3 100644
--- a/packages/backend/src/server/api/endpoints/admin/emoji/add.ts
+++ b/packages/backend/src/server/api/endpoints/admin/emoji/add.ts
@@ -25,9 +25,24 @@ export const meta = {
export const paramDef = {
type: 'object',
properties: {
+ name: { type: 'string', pattern: '^[a-zA-Z0-9_]+$' },
fileId: { type: 'string', format: 'misskey:id' },
+ category: {
+ type: 'string',
+ nullable: true,
+ description: 'Use `null` to reset the category.',
+ },
+ aliases: { type: 'array', items: {
+ type: 'string',
+ } },
+ license: { type: 'string', nullable: true },
+ isSensitive: { type: 'boolean' },
+ localOnly: { type: 'boolean' },
+ roleIdsThatCanBeUsedThisEmojiAsReaction: { type: 'array', items: {
+ type: 'string',
+ } },
},
- required: ['fileId'],
+ required: ['name', 'fileId'],
} as const;
// TODO: ロジックをサービスに切り出す
@@ -45,18 +60,18 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
) {
super(meta, paramDef, async (ps, me) => {
const driveFile = await this.driveFilesRepository.findOneBy({ id: ps.fileId });
-
if (driveFile == null) throw new ApiError(meta.errors.noSuchFile);
- const name = driveFile.name.split('.')[0].match(/^[a-z0-9_]+$/) ? driveFile.name.split('.')[0] : `_${rndstr('a-z0-9', 8)}_`;
-
const emoji = await this.customEmojiService.add({
driveFile,
- name,
- category: null,
- aliases: [],
+ name: ps.name,
+ category: ps.category ?? null,
+ aliases: ps.aliases ?? [],
host: null,
- license: null,
+ license: ps.license ?? null,
+ isSensitive: ps.isSensitive ?? false,
+ localOnly: ps.localOnly ?? false,
+ roleIdsThatCanBeUsedThisEmojiAsReaction: ps.roleIdsThatCanBeUsedThisEmojiAsReaction ?? [],
});
this.moderationLogService.insertModerationLog(me, 'addEmoji', {
diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/update.ts b/packages/backend/src/server/api/endpoints/admin/emoji/update.ts
index f63348b60b..fb22bdc477 100644
--- a/packages/backend/src/server/api/endpoints/admin/emoji/update.ts
+++ b/packages/backend/src/server/api/endpoints/admin/emoji/update.ts
@@ -1,6 +1,8 @@
import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { CustomEmojiService } from '@/core/CustomEmojiService.js';
+import type { DriveFilesRepository } from '@/models/index.js';
+import { DI } from '@/di-symbols.js';
import { ApiError } from '../../../error.js';
export const meta = {
@@ -15,6 +17,11 @@ export const meta = {
code: 'NO_SUCH_EMOJI',
id: '684dec9d-a8c2-4364-9aa8-456c49cb1dc8',
},
+ noSuchFile: {
+ message: 'No such file.',
+ code: 'NO_SUCH_FILE',
+ id: '14fb9fd9-0731-4e2f-aeb9-f09e4740333d',
+ },
sameNameEmojiExists: {
message: 'Emoji that have same name already exists.',
code: 'SAME_NAME_EMOJI_EXISTS',
@@ -28,6 +35,7 @@ export const paramDef = {
properties: {
id: { type: 'string', format: 'misskey:id' },
name: { type: 'string', pattern: '^[a-zA-Z0-9_]+$' },
+ fileId: { type: 'string', format: 'misskey:id' },
category: {
type: 'string',
nullable: true,
@@ -37,6 +45,11 @@ export const paramDef = {
type: 'string',
} },
license: { type: 'string', nullable: true },
+ isSensitive: { type: 'boolean' },
+ localOnly: { type: 'boolean' },
+ roleIdsThatCanBeUsedThisEmojiAsReaction: { type: 'array', items: {
+ type: 'string',
+ } },
},
required: ['id', 'name', 'aliases'],
} as const;
@@ -45,14 +58,28 @@ export const paramDef = {
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> {
constructor(
+ @Inject(DI.driveFilesRepository)
+ private driveFilesRepository: DriveFilesRepository,
+
private customEmojiService: CustomEmojiService,
) {
super(meta, paramDef, async (ps, me) => {
+ let driveFile;
+
+ if (ps.fileId) {
+ driveFile = await this.driveFilesRepository.findOneBy({ id: ps.fileId });
+ if (driveFile == null) throw new ApiError(meta.errors.noSuchFile);
+ }
+
await this.customEmojiService.update(ps.id, {
+ driveFile,
name: ps.name,
category: ps.category ?? null,
aliases: ps.aliases,
license: ps.license ?? null,
+ isSensitive: ps.isSensitive,
+ localOnly: ps.localOnly,
+ roleIdsThatCanBeUsedThisEmojiAsReaction: ps.roleIdsThatCanBeUsedThisEmojiAsReaction,
});
});
}
diff --git a/packages/backend/src/server/api/endpoints/admin/relays/add.ts b/packages/backend/src/server/api/endpoints/admin/relays/add.ts
index f12738bd3a..f2d4aa8996 100644
--- a/packages/backend/src/server/api/endpoints/admin/relays/add.ts
+++ b/packages/backend/src/server/api/endpoints/admin/relays/add.ts
@@ -62,7 +62,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
) {
super(meta, paramDef, async (ps, me) => {
try {
- if (new URL(ps.inbox).protocol !== 'https:') throw 'https only';
+ if (new URL(ps.inbox).protocol !== 'https:') throw new Error('https only');
} catch {
throw new ApiError(meta.errors.invalidUrl);
}
diff --git a/packages/backend/src/server/api/endpoints/antennas/notes.ts b/packages/backend/src/server/api/endpoints/antennas/notes.ts
index dca0f443b7..e756a9b510 100644
--- a/packages/backend/src/server/api/endpoints/antennas/notes.ts
+++ b/packages/backend/src/server/api/endpoints/antennas/notes.ts
@@ -113,6 +113,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
}
this.antennasRepository.update(antenna.id, {
+ isActive: true,
lastUsedAt: new Date(),
});
diff --git a/packages/backend/src/server/api/endpoints/auth/accept.ts b/packages/backend/src/server/api/endpoints/auth/accept.ts
index cb2e661bfb..05842460cf 100644
--- a/packages/backend/src/server/api/endpoints/auth/accept.ts
+++ b/packages/backend/src/server/api/endpoints/auth/accept.ts
@@ -55,7 +55,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
throw new ApiError(meta.errors.noSuchSession);
}
- // Generate access token
const accessToken = secureRndstr(32, true);
// Fetch exist access token
@@ -65,7 +64,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
});
if (exist == null) {
- // Lookup app
const app = await this.appsRepository.findOneByOrFail({ id: session.appId });
// Generate Hash
@@ -75,7 +73,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
const now = new Date();
- // Insert access token doc
await this.accessTokensRepository.insert({
id: this.idService.genId(),
createdAt: now,
diff --git a/packages/backend/src/server/api/endpoints/i/2fa/key-done.ts b/packages/backend/src/server/api/endpoints/i/2fa/key-done.ts
index ad33398da6..e8985a9cd8 100644
--- a/packages/backend/src/server/api/endpoints/i/2fa/key-done.ts
+++ b/packages/backend/src/server/api/endpoints/i/2fa/key-done.ts
@@ -1,6 +1,6 @@
import { promisify } from 'node:util';
import bcrypt from 'bcryptjs';
-import * as cbor from 'cbor';
+import cbor from 'cbor';
import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
diff --git a/packages/backend/src/server/api/endpoints/i/apps.ts b/packages/backend/src/server/api/endpoints/i/apps.ts
index 3361e5a4d3..48fb03a8af 100644
--- a/packages/backend/src/server/api/endpoints/i/apps.ts
+++ b/packages/backend/src/server/api/endpoints/i/apps.ts
@@ -26,7 +26,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
) {
super(meta, paramDef, async (ps, me) => {
const query = this.accessTokensRepository.createQueryBuilder('token')
- .where('token.userId = :userId', { userId: me.id });
+ .where('token.userId = :userId', { userId: me.id })
+ .leftJoinAndSelect('token.app', 'app');
switch (ps.sort) {
case '+createdAt': query.orderBy('token.createdAt', 'DESC'); break;
@@ -40,7 +41,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
return await Promise.all(tokens.map(token => ({
id: token.id,
- name: token.name,
+ name: token.name ?? token.app?.name,
createdAt: token.createdAt,
lastUsedAt: token.lastUsedAt,
permission: token.permission,
diff --git a/packages/backend/src/server/api/endpoints/i/notifications.ts b/packages/backend/src/server/api/endpoints/i/notifications.ts
index e141be764a..f5662f4a0e 100644
--- a/packages/backend/src/server/api/endpoints/i/notifications.ts
+++ b/packages/backend/src/server/api/endpoints/i/notifications.ts
@@ -91,18 +91,18 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
const includeTypes = ps.includeTypes && ps.includeTypes.filter(type => !(obsoleteNotificationTypes).includes(type as any)) as typeof notificationTypes[number][];
const excludeTypes = ps.excludeTypes && ps.excludeTypes.filter(type => !(obsoleteNotificationTypes).includes(type as any)) as typeof notificationTypes[number][];
- const limit = ps.limit + (ps.untilId ? 1 : 0); // untilIdに指定したものも含まれるため+1
+ const limit = ps.limit + (ps.untilId ? 1 : 0) + (ps.sinceId ? 1 : 0); // untilIdに指定したものも含まれるため+1
const notificationsRes = await this.redisClient.xrevrange(
`notificationTimeline:${me.id}`,
ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : '+',
- '-',
+ ps.sinceId ? this.idService.parse(ps.sinceId).date.getTime() : '-',
'COUNT', limit);
if (notificationsRes.length === 0) {
return [];
}
- let notifications = notificationsRes.map(x => JSON.parse(x[1][1])).filter(x => x.id !== ps.untilId) as Notification[];
+ let notifications = notificationsRes.map(x => JSON.parse(x[1][1])).filter(x => x.id !== ps.untilId && x !== ps.sinceId) as Notification[];
if (includeTypes && includeTypes.length > 0) {
notifications = notifications.filter(notification => includeTypes.includes(notification.type));
diff --git a/packages/backend/src/server/api/endpoints/i/update.ts b/packages/backend/src/server/api/endpoints/i/update.ts
index 74be00a8b8..8f5e6177c2 100644
--- a/packages/backend/src/server/api/endpoints/i/update.ts
+++ b/packages/backend/src/server/api/endpoints/i/update.ts
@@ -141,13 +141,12 @@ export const paramDef = {
preventAiLearning: { type: 'boolean' },
isBot: { type: 'boolean' },
isCat: { type: 'boolean' },
- showTimelineReplies: { type: 'boolean' },
injectFeaturedNote: { type: 'boolean' },
receiveAnnouncementEmail: { type: 'boolean' },
alwaysMarkNsfw: { type: 'boolean' },
autoSensitive: { type: 'boolean' },
ffVisibility: { type: 'string', enum: ['public', 'followers', 'private'] },
- pinnedPageId: { type: 'string', format: 'misskey:id' },
+ pinnedPageId: { type: 'string', format: 'misskey:id', nullable: true },
mutedWords: { type: 'array' },
mutedInstances: { type: 'array', items: {
type: 'string',
@@ -239,7 +238,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
if (typeof ps.hideOnlineStatus === 'boolean') updates.hideOnlineStatus = ps.hideOnlineStatus;
if (typeof ps.publicReactions === 'boolean') profileUpdates.publicReactions = ps.publicReactions;
if (typeof ps.isBot === 'boolean') updates.isBot = ps.isBot;
- if (typeof ps.showTimelineReplies === 'boolean') updates.showTimelineReplies = ps.showTimelineReplies;
if (typeof ps.carefulBot === 'boolean') profileUpdates.carefulBot = ps.carefulBot;
if (typeof ps.autoAcceptFollowed === 'boolean') profileUpdates.autoAcceptFollowed = ps.autoAcceptFollowed;
if (typeof ps.noCrawle === 'boolean') profileUpdates.noCrawle = ps.noCrawle;
diff --git a/packages/backend/src/server/api/endpoints/meta.ts b/packages/backend/src/server/api/endpoints/meta.ts
index 584ea07c3b..53d724a9dd 100644
--- a/packages/backend/src/server/api/endpoints/meta.ts
+++ b/packages/backend/src/server/api/endpoints/meta.ts
@@ -1,5 +1,6 @@
import { IsNull, LessThanOrEqual, MoreThan } from 'typeorm';
import { Inject, Injectable } from '@nestjs/common';
+import * as JSON5 from 'json5';
import type { AdsRepository, UsersRepository } from '@/models/index.js';
import { MAX_NOTE_TEXT_LENGTH } from '@/const.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
@@ -292,8 +293,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
backgroundImageUrl: instance.backgroundImageUrl,
logoImageUrl: instance.logoImageUrl,
maxNoteTextLength: MAX_NOTE_TEXT_LENGTH,
- defaultLightTheme: instance.defaultLightTheme,
- defaultDarkTheme: instance.defaultDarkTheme,
+ // クライアントの手間を減らすためあらかじめJSONに変換しておく
+ defaultLightTheme: instance.defaultLightTheme ? JSON.stringify(JSON5.parse(instance.defaultLightTheme)) : null,
+ defaultDarkTheme: instance.defaultDarkTheme ? JSON.stringify(JSON5.parse(instance.defaultDarkTheme)) : null,
ads: ads.map(ad => ({
id: ad.id,
url: ad.url,
diff --git a/packages/backend/src/server/api/endpoints/notes/create.ts b/packages/backend/src/server/api/endpoints/notes/create.ts
index 3f7f2cdece..96be5ed844 100644
--- a/packages/backend/src/server/api/endpoints/notes/create.ts
+++ b/packages/backend/src/server/api/endpoints/notes/create.ts
@@ -99,7 +99,7 @@ export const paramDef = {
} },
cw: { type: 'string', nullable: true, maxLength: 100 },
localOnly: { type: 'boolean', default: false },
- reactionAcceptance: { type: 'string', nullable: true, enum: [null, 'likeOnly', 'likeOnlyForRemote'], default: null },
+ reactionAcceptance: { type: 'string', nullable: true, enum: [null, 'likeOnly', 'likeOnlyForRemote', 'nonSensitiveOnly', 'nonSensitiveOnlyForLocalLikeOnlyForRemote'], default: null },
noExtractMentions: { type: 'boolean', default: false },
noExtractHashtags: { type: 'boolean', default: false },
noExtractEmojis: { type: 'boolean', default: false },
diff --git a/packages/backend/src/server/api/endpoints/notes/global-timeline.ts b/packages/backend/src/server/api/endpoints/notes/global-timeline.ts
index c11c1eac40..88c1ca7f58 100644
--- a/packages/backend/src/server/api/endpoints/notes/global-timeline.ts
+++ b/packages/backend/src/server/api/endpoints/notes/global-timeline.ts
@@ -34,11 +34,8 @@ export const meta = {
export const paramDef = {
type: 'object',
properties: {
- withFiles: {
- type: 'boolean',
- default: false,
- description: 'Only show notes that have attached files.',
- },
+ withFiles: { type: 'boolean', default: false },
+ withReplies: { type: 'boolean', default: false },
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
sinceId: { type: 'string', format: 'misskey:id' },
untilId: { type: 'string', format: 'misskey:id' },
@@ -78,7 +75,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
.leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('renote.user', 'renoteUser');
- this.queryService.generateRepliesQuery(query, me);
+ this.queryService.generateRepliesQuery(query, ps.withReplies, me);
if (me) {
this.queryService.generateMutedUserQuery(query, me);
this.queryService.generateMutedNoteQuery(query, me);
diff --git a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts
index 89abd91c7e..7a3581e6e4 100644
--- a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts
+++ b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts
@@ -46,11 +46,8 @@ export const paramDef = {
includeMyRenotes: { type: 'boolean', default: true },
includeRenotedMyNotes: { type: 'boolean', default: true },
includeLocalRenotes: { type: 'boolean', default: true },
- withFiles: {
- type: 'boolean',
- default: false,
- description: 'Only show notes that have attached files.',
- },
+ withFiles: { type: 'boolean', default: false },
+ withReplies: { type: 'boolean', default: false },
},
required: [],
} as const;
@@ -98,7 +95,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
.setParameters(followingQuery.getParameters());
this.queryService.generateChannelQuery(query, me);
- this.queryService.generateRepliesQuery(query, me);
+ this.queryService.generateRepliesQuery(query, ps.withReplies, me);
this.queryService.generateVisibilityQuery(query, me);
this.queryService.generateMutedUserQuery(query, me);
this.queryService.generateMutedNoteQuery(query, me);
diff --git a/packages/backend/src/server/api/endpoints/notes/local-timeline.ts b/packages/backend/src/server/api/endpoints/notes/local-timeline.ts
index afdafc7c55..2ee549232c 100644
--- a/packages/backend/src/server/api/endpoints/notes/local-timeline.ts
+++ b/packages/backend/src/server/api/endpoints/notes/local-timeline.ts
@@ -36,11 +36,8 @@ export const meta = {
export const paramDef = {
type: 'object',
properties: {
- withFiles: {
- type: 'boolean',
- default: false,
- description: 'Only show notes that have attached files.',
- },
+ withFiles: { type: 'boolean', default: false },
+ withReplies: { type: 'boolean', default: false },
fileType: { type: 'array', items: {
type: 'string',
} },
@@ -86,7 +83,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
.leftJoinAndSelect('renote.user', 'renoteUser');
this.queryService.generateChannelQuery(query, me);
- this.queryService.generateRepliesQuery(query, me);
+ this.queryService.generateRepliesQuery(query, ps.withReplies, me);
this.queryService.generateVisibilityQuery(query, me);
if (me) this.queryService.generateMutedUserQuery(query, me);
if (me) this.queryService.generateMutedNoteQuery(query, me);
diff --git a/packages/backend/src/server/api/endpoints/notes/search-by-tag.ts b/packages/backend/src/server/api/endpoints/notes/search-by-tag.ts
index 2956bf1cbd..742df0ca95 100644
--- a/packages/backend/src/server/api/endpoints/notes/search-by-tag.ts
+++ b/packages/backend/src/server/api/endpoints/notes/search-by-tag.ts
@@ -82,14 +82,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
try {
if (ps.tag) {
- if (!safeForSql(normalizeForSearch(ps.tag))) throw 'Injection';
+ if (!safeForSql(normalizeForSearch(ps.tag))) throw new Error('Injection');
query.andWhere(`'{"${normalizeForSearch(ps.tag)}"}' <@ note.tags`);
} else {
query.andWhere(new Brackets(qb => {
for (const tags of ps.query!) {
qb.orWhere(new Brackets(qb => {
for (const tag of tags) {
- if (!safeForSql(normalizeForSearch(tag))) throw 'Injection';
+ if (!safeForSql(normalizeForSearch(tag))) throw new Error('Injection');
qb.andWhere(`'{"${normalizeForSearch(tag)}"}' <@ note.tags`);
}
}));
diff --git a/packages/backend/src/server/api/endpoints/notes/timeline.ts b/packages/backend/src/server/api/endpoints/notes/timeline.ts
index c6ee1e5c2b..e1f286439b 100644
--- a/packages/backend/src/server/api/endpoints/notes/timeline.ts
+++ b/packages/backend/src/server/api/endpoints/notes/timeline.ts
@@ -35,11 +35,8 @@ export const paramDef = {
includeMyRenotes: { type: 'boolean', default: true },
includeRenotedMyNotes: { type: 'boolean', default: true },
includeLocalRenotes: { type: 'boolean', default: true },
- withFiles: {
- type: 'boolean',
- default: false,
- description: 'Only show notes that have attached files.',
- },
+ withFiles: { type: 'boolean', default: false },
+ withReplies: { type: 'boolean', default: false },
},
required: [],
} as const;
@@ -84,7 +81,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
}
this.queryService.generateChannelQuery(query, me);
- this.queryService.generateRepliesQuery(query, me);
+ this.queryService.generateRepliesQuery(query, ps.withReplies, me);
this.queryService.generateVisibilityQuery(query, me);
this.queryService.generateMutedUserQuery(query, me);
this.queryService.generateMutedNoteQuery(query, me);
diff --git a/packages/backend/src/server/api/endpoints/reset-db.ts b/packages/backend/src/server/api/endpoints/reset-db.ts
index 4ced6d3ff1..1d4825f812 100644
--- a/packages/backend/src/server/api/endpoints/reset-db.ts
+++ b/packages/backend/src/server/api/endpoints/reset-db.ts
@@ -34,7 +34,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
private redisClient: Redis.Redis,
) {
super(meta, paramDef, async (ps, me) => {
- if (process.env.NODE_ENV !== 'test') throw 'NODE_ENV is not a test';
+ if (process.env.NODE_ENV !== 'test') throw new Error('NODE_ENV is not a test');
await redisClient.flushdb();
await resetDb(this.db);
diff --git a/packages/backend/src/server/api/endpoints/roles/notes.ts b/packages/backend/src/server/api/endpoints/roles/notes.ts
index 6202c740f1..42e36cb04a 100644
--- a/packages/backend/src/server/api/endpoints/roles/notes.ts
+++ b/packages/backend/src/server/api/endpoints/roles/notes.ts
@@ -93,6 +93,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
const query = this.notesRepository.createQueryBuilder('note')
.where('note.id IN (:...noteIds)', { noteIds: noteIds })
+ .andWhere('(note.visibility = \'public\')')
.innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote')
diff --git a/packages/backend/src/server/api/endpoints/users/lists/create-from-public.ts b/packages/backend/src/server/api/endpoints/users/lists/create-from-public.ts
new file mode 100644
index 0000000000..8591e4ab96
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/users/lists/create-from-public.ts
@@ -0,0 +1,148 @@
+import { Inject, Injectable } from '@nestjs/common';
+import type { UserListsRepository, UserListJoiningsRepository, BlockingsRepository } from '@/models/index.js';
+import { IdService } from '@/core/IdService.js';
+import type { UserList } from '@/models/entities/UserList.js';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { GetterService } from '@/server/api/GetterService.js';
+import { UserListEntityService } from '@/core/entities/UserListEntityService.js';
+import { DI } from '@/di-symbols.js';
+import { ApiError } from '@/server/api/error.js';
+import { RoleService } from '@/core/RoleService.js';
+import { UserListService } from '@/core/UserListService.js';
+
+export const meta = {
+ requireCredential: true,
+ prohibitMoved: true,
+ res: {
+ type: 'object',
+ optional: false, nullable: false,
+ ref: 'UserList',
+ },
+
+ errors: {
+ tooManyUserLists: {
+ message: 'You cannot create user list any more.',
+ code: 'TOO_MANY_USERLISTS',
+ id: 'e9c105b2-c595-47de-97fb-7f7c2c33e92f',
+ },
+ noSuchList: {
+ message: 'No such list.',
+ code: 'NO_SUCH_LIST',
+ id: '9292f798-6175-4f7d-93f4-b6742279667d',
+ },
+ noSuchUser: {
+ message: 'No such user.',
+ code: 'NO_SUCH_USER',
+ id: '13c457db-a8cb-4d88-b70a-211ceeeabb5f',
+ },
+
+ alreadyAdded: {
+ message: 'That user has already been added to that list.',
+ code: 'ALREADY_ADDED',
+ id: 'c3ad6fdb-692b-47ee-a455-7bd12c7af615',
+ },
+
+ youHaveBeenBlocked: {
+ message: 'You cannot push this user because you have been blocked by this user.',
+ code: 'YOU_HAVE_BEEN_BLOCKED',
+ id: 'a2497f2a-2389-439c-8626-5298540530f4',
+ },
+
+ tooManyUsers: {
+ message: 'You can not push users any more.',
+ code: 'TOO_MANY_USERS',
+ id: '1845ea77-38d1-426e-8e4e-8b83b24f5bd7',
+ },
+ },
+} as const;
+
+export const paramDef = {
+ type: 'object',
+ properties: {
+ name: { type: 'string', minLength: 1, maxLength: 100 },
+ listId: { type: 'string', format: 'misskey:id' },
+ },
+ required: ['name', 'listId'],
+} as const;
+
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> {
+ constructor(
+ @Inject(DI.userListsRepository)
+ private userListsRepository: UserListsRepository,
+
+ @Inject(DI.userListJoiningsRepository)
+ private userListJoiningsRepository: UserListJoiningsRepository,
+
+ @Inject(DI.blockingsRepository)
+ private blockingsRepository: BlockingsRepository,
+
+ private userListService: UserListService,
+ private userListEntityService: UserListEntityService,
+ private idService: IdService,
+ private getterService: GetterService,
+ private roleService: RoleService,
+ ) {
+ super(meta, paramDef, async (ps, me) => {
+ const list = await this.userListsRepository.findOneBy({
+ id: ps.listId,
+ isPublic: true,
+ });
+ if (list === null) throw new ApiError(meta.errors.noSuchList);
+ const currentCount = await this.userListsRepository.countBy({
+ userId: me.id,
+ });
+ 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(),
+ userId: me.id,
+ name: ps.name,
+ } as UserList).then(x => this.userListsRepository.findOneByOrFail(x.identifiers[0]));
+
+ const users = (await this.userListJoiningsRepository.findBy({
+ userListId: ps.listId,
+ })).map(x => x.userId);
+
+ for (const user of users) {
+ const currentUser = await this.getterService.getUser(user).catch(err => {
+ if (err.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser);
+ throw err;
+ });
+
+ if (currentUser.id !== me.id) {
+ const block = await this.blockingsRepository.findOneBy({
+ blockerId: currentUser.id,
+ blockeeId: me.id,
+ });
+ if (block) {
+ throw new ApiError(meta.errors.youHaveBeenBlocked);
+ }
+ }
+
+ const exist = await this.userListJoiningsRepository.findOneBy({
+ userListId: userList.id,
+ userId: currentUser.id,
+ });
+
+ if (exist) {
+ throw new ApiError(meta.errors.alreadyAdded);
+ }
+
+ try {
+ await this.userListService.push(currentUser, userList, me);
+ } catch (err) {
+ if (err instanceof UserListService.TooManyUsersError) {
+ throw new ApiError(meta.errors.tooManyUsers);
+ }
+ throw err;
+ }
+ }
+ return await this.userListEntityService.pack(userList);
+ });
+ }
+}
+
diff --git a/packages/backend/src/server/api/endpoints/users/lists/favorite.ts b/packages/backend/src/server/api/endpoints/users/lists/favorite.ts
new file mode 100644
index 0000000000..263852fde1
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/users/lists/favorite.ts
@@ -0,0 +1,70 @@
+import { Inject, Injectable } from '@nestjs/common';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import type { UserListFavoritesRepository, UserListsRepository } from '@/models/index.js';
+import { IdService } from '@/core/IdService.js';
+import { ApiError } from '@/server/api/error.js';
+import { DI } from '@/di-symbols.js';
+
+export const meta = {
+ requireCredential: true,
+ errors: {
+ noSuchList: {
+ message: 'No such user list.',
+ code: 'NO_SUCH_USER_LIST',
+ id: '7dbaf3cf-7b42-4b8f-b431-b3919e580dbe',
+ },
+
+ alreadyFavorited: {
+ message: 'The list has already been favorited.',
+ code: 'ALREADY_FAVORITED',
+ id: '6425bba0-985b-461e-af1b-518070e72081',
+ },
+ },
+} as const;
+
+export const paramDef = {
+ type: 'object',
+ properties: {
+ listId: { type: 'string', format: 'misskey:id' },
+ },
+ required: ['listId'],
+} as const;
+
+@Injectable() // eslint-disable-next-line import/no-default-export
+export default class extends Endpoint<typeof meta, typeof paramDef> {
+ constructor (
+ @Inject(DI.userListsRepository)
+ private userListsRepository: UserListsRepository,
+
+ @Inject(DI.userListFavoritesRepository)
+ private userListFavoritesRepository: UserListFavoritesRepository,
+ private idService: IdService,
+ ) {
+ super(meta, paramDef, async (ps, me) => {
+ const userList = await this.userListsRepository.findOneBy({
+ id: ps.listId,
+ isPublic: true,
+ });
+
+ if (userList === null) {
+ throw new ApiError(meta.errors.noSuchList);
+ }
+
+ const exist = await this.userListFavoritesRepository.findOneBy({
+ userId: me.id,
+ userListId: ps.listId,
+ });
+
+ if (exist !== null) {
+ throw new ApiError(meta.errors.alreadyFavorited);
+ }
+
+ await this.userListFavoritesRepository.insert({
+ id: this.idService.genId(),
+ createdAt: new Date(),
+ userId: me.id,
+ userListId: ps.listId,
+ });
+ });
+ }
+}
diff --git a/packages/backend/src/server/api/endpoints/users/lists/list.ts b/packages/backend/src/server/api/endpoints/users/lists/list.ts
index 2104c4377d..eab29944b2 100644
--- a/packages/backend/src/server/api/endpoints/users/lists/list.ts
+++ b/packages/backend/src/server/api/endpoints/users/lists/list.ts
@@ -1,13 +1,14 @@
import { Inject, Injectable } from '@nestjs/common';
-import type { UserListsRepository } from '@/models/index.js';
+import type { UserListsRepository, UsersRepository } from '@/models/index.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { UserListEntityService } from '@/core/entities/UserListEntityService.js';
+import { ApiError } from '@/server/api/error.js';
import { DI } from '@/di-symbols.js';
export const meta = {
tags: ['lists', 'account'],
- requireCredential: true,
+ requireCredential: false,
kind: 'read:account',
@@ -22,26 +23,58 @@ export const meta = {
ref: 'UserList',
},
},
+ errors: {
+ noSuchUser: {
+ message: 'No such user.',
+ code: 'NO_SUCH_USER',
+ id: 'a8af4a82-0980-4cc4-a6af-8b0ffd54465e',
+ },
+ remoteUser: {
+ message: 'Not allowed to load the remote user\'s list',
+ code: 'REMOTE_USER_NOT_ALLOWED',
+ id: '53858f1b-3315-4a01-81b7-db9b48d4b79a',
+ },
+ invalidParam: {
+ message: 'Invalid param.',
+ code: 'INVALID_PARAM',
+ id: 'ab36de0e-29e9-48cb-9732-d82f1281620d',
+ },
+ },
} as const;
export const paramDef = {
type: 'object',
- properties: {},
+ properties: {
+ userId: { type: 'string', format: 'misskey:id' },
+ },
required: [],
} as const;
-// eslint-disable-next-line import/no-default-export
-@Injectable()
+@Injectable() // eslint-disable-next-line import/no-default-export
export default class extends Endpoint<typeof meta, typeof paramDef> {
constructor(
+ @Inject(DI.usersRepository)
+ private usersRepository: UsersRepository,
+
@Inject(DI.userListsRepository)
private userListsRepository: UserListsRepository,
private userListEntityService: UserListEntityService,
) {
super(meta, paramDef, async (ps, me) => {
- const userLists = await this.userListsRepository.findBy({
+ if (typeof ps.userId !== 'undefined') {
+ const user = await this.usersRepository.findOneBy({ id: ps.userId });
+ if (user === null) throw new ApiError(meta.errors.noSuchUser);
+ if (user.host !== null) throw new ApiError(meta.errors.remoteUser);
+ } else if (me === null) {
+ throw new ApiError(meta.errors.invalidParam);
+ }
+
+ const userLists = await this.userListsRepository.findBy(typeof ps.userId === 'undefined' && me !== null ? {
userId: me.id,
+ } : {
+ userId: ps.userId,
+ isPublic: true,
});
return await Promise.all(userLists.map(x => this.userListEntityService.pack(x)));
diff --git a/packages/backend/src/server/api/endpoints/users/lists/show.ts b/packages/backend/src/server/api/endpoints/users/lists/show.ts
index 77f9cba808..8077841c8c 100644
--- a/packages/backend/src/server/api/endpoints/users/lists/show.ts
+++ b/packages/backend/src/server/api/endpoints/users/lists/show.ts
@@ -1,5 +1,5 @@
import { Inject, Injectable } from '@nestjs/common';
-import type { UserListsRepository } from '@/models/index.js';
+import type { UserListsRepository, UserListFavoritesRepository } from '@/models/index.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { UserListEntityService } from '@/core/entities/UserListEntityService.js';
import { DI } from '@/di-symbols.js';
@@ -8,7 +8,7 @@ import { ApiError } from '../../../error.js';
export const meta = {
tags: ['lists', 'account'],
- requireCredential: true,
+ requireCredential: false,
kind: 'read:account',
@@ -33,31 +33,54 @@ export const paramDef = {
type: 'object',
properties: {
listId: { type: 'string', format: 'misskey:id' },
+ forPublic: { type: 'boolean', default: false },
},
required: ['listId'],
} as const;
-// eslint-disable-next-line import/no-default-export
-@Injectable()
+@Injectable() // eslint-disable-next-line import/no-default-export
export default class extends Endpoint<typeof meta, typeof paramDef> {
constructor(
@Inject(DI.userListsRepository)
private userListsRepository: UserListsRepository,
+ @Inject(DI.userListFavoritesRepository)
+ private userListFavoritesRepository: UserListFavoritesRepository,
+
private userListEntityService: UserListEntityService,
) {
super(meta, paramDef, async (ps, me) => {
+ const additionalProperties: Partial<{ likedCount: number, isLiked: boolean }> = {};
// Fetch the list
- const userList = await this.userListsRepository.findOneBy({
+ const userList = await this.userListsRepository.findOneBy(!ps.forPublic && me !== null ? {
id: ps.listId,
userId: me.id,
+ } : {
+ id: ps.listId,
+ isPublic: true,
});
if (userList == null) {
throw new ApiError(meta.errors.noSuchList);
}
- return await this.userListEntityService.pack(userList);
+ if (ps.forPublic && userList.isPublic) {
+ additionalProperties.likedCount = await this.userListFavoritesRepository.countBy({
+ userListId: ps.listId,
+ });
+ if (me !== null) {
+ additionalProperties.isLiked = (await this.userListFavoritesRepository.findOneBy({
+ userId: me.id,
+ userListId: ps.listId,
+ }) !== null);
+ } else {
+ additionalProperties.isLiked = false;
+ }
+ }
+ return {
+ ...await this.userListEntityService.pack(userList),
+ ...additionalProperties,
+ };
});
}
}
diff --git a/packages/backend/src/server/api/endpoints/users/lists/unfavorite.ts b/packages/backend/src/server/api/endpoints/users/lists/unfavorite.ts
new file mode 100644
index 0000000000..be8e317816
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/users/lists/unfavorite.ts
@@ -0,0 +1,63 @@
+import { Inject, Injectable } from '@nestjs/common';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import type { UserListFavoritesRepository, UserListsRepository } from '@/models/index.js';
+import { ApiError } from '@/server/api/error.js';
+import { DI } from '@/di-symbols.js';
+
+export const meta = {
+ requireCredential: true,
+ errors: {
+ noSuchList: {
+ message: 'No such user list.',
+ code: 'NO_SUCH_USER_LIST',
+ id: 'baedb33e-76b8-4b0c-86a8-9375c0a7b94b',
+ },
+
+ notFavorited: {
+ message: 'You have not favorited the list.',
+ code: 'ALREADY_FAVORITED',
+ id: '835c4b27-463d-4cfa-969b-a9058678d465',
+ },
+ },
+} as const;
+
+export const paramDef = {
+ type: 'object',
+ properties: {
+ listId: { type: 'string', format: 'misskey:id' },
+ },
+ required: ['listId'],
+} as const;
+
+@Injectable() // eslint-disable-next-line import/no-default-export
+export default class extends Endpoint<typeof meta, typeof paramDef> {
+ constructor (
+ @Inject(DI.userListsRepository)
+ private userListsRepository: UserListsRepository,
+
+ @Inject(DI.userListFavoritesRepository)
+ private userListFavoritesRepository: UserListFavoritesRepository,
+ ) {
+ super(meta, paramDef, async (ps, me) => {
+ const userList = await this.userListsRepository.findOneBy({
+ id: ps.listId,
+ isPublic: true,
+ });
+
+ if (userList === null) {
+ throw new ApiError(meta.errors.noSuchList);
+ }
+
+ const exist = await this.userListFavoritesRepository.findOneBy({
+ userListId: ps.listId,
+ userId: me.id,
+ });
+
+ if (exist === null) {
+ throw new ApiError(meta.errors.notFavorited);
+ }
+
+ await this.userListFavoritesRepository.delete({ id: exist.id });
+ });
+ }
+}
diff --git a/packages/backend/src/server/api/endpoints/users/lists/update.ts b/packages/backend/src/server/api/endpoints/users/lists/update.ts
index 6453d7d980..b0a95a2f28 100644
--- a/packages/backend/src/server/api/endpoints/users/lists/update.ts
+++ b/packages/backend/src/server/api/endpoints/users/lists/update.ts
@@ -34,8 +34,9 @@ export const paramDef = {
properties: {
listId: { type: 'string', format: 'misskey:id' },
name: { type: 'string', minLength: 1, maxLength: 100 },
+ isPublic: { type: 'boolean' },
},
- required: ['listId', 'name'],
+ required: ['listId'],
} as const;
// eslint-disable-next-line import/no-default-export
@@ -48,7 +49,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
private userListEntityService: UserListEntityService,
) {
super(meta, paramDef, async (ps, me) => {
- // Fetch the list
const userList = await this.userListsRepository.findOneBy({
id: ps.listId,
userId: me.id,
@@ -60,6 +60,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
await this.userListsRepository.update(userList.id, {
name: ps.name,
+ isPublic: ps.isPublic,
});
return await this.userListEntityService.pack(userList.id);
diff --git a/packages/backend/src/server/api/stream/channels/global-timeline.ts b/packages/backend/src/server/api/stream/channels/global-timeline.ts
index 5454836fe1..d3339072c1 100644
--- a/packages/backend/src/server/api/stream/channels/global-timeline.ts
+++ b/packages/backend/src/server/api/stream/channels/global-timeline.ts
@@ -13,6 +13,7 @@ class GlobalTimelineChannel extends Channel {
public readonly chName = 'globalTimeline';
public static shouldShare = true;
public static requireCredential = false;
+ private withReplies: boolean;
constructor(
private metaService: MetaService,
@@ -31,6 +32,8 @@ class GlobalTimelineChannel extends Channel {
const policies = await this.roleService.getUserPolicies(this.user ? this.user.id : null);
if (!policies.gtlAvailable) return;
+ this.withReplies = params.withReplies as boolean;
+
// Subscribe events
this.subscriber.on('notesStream', this.onNote);
}
@@ -54,7 +57,7 @@ class GlobalTimelineChannel extends Channel {
}
// 関係ない返信は除外
- if (note.reply && !this.user!.showTimelineReplies) {
+ if (note.reply && !this.withReplies) {
const reply = note.reply;
// 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合
if (reply.userId !== this.user!.id && note.userId !== this.user!.id && reply.userId !== note.userId) return;
diff --git a/packages/backend/src/server/api/stream/channels/home-timeline.ts b/packages/backend/src/server/api/stream/channels/home-timeline.ts
index ee874ad81e..1755aa94cf 100644
--- a/packages/backend/src/server/api/stream/channels/home-timeline.ts
+++ b/packages/backend/src/server/api/stream/channels/home-timeline.ts
@@ -11,6 +11,7 @@ class HomeTimelineChannel extends Channel {
public readonly chName = 'homeTimeline';
public static shouldShare = true;
public static requireCredential = true;
+ private withReplies: boolean;
constructor(
private noteEntityService: NoteEntityService,
@@ -24,6 +25,8 @@ class HomeTimelineChannel extends Channel {
@bindThis
public async init(params: any) {
+ this.withReplies = params.withReplies as boolean;
+
this.subscriber.on('notesStream', this.onNote);
}
@@ -63,7 +66,7 @@ class HomeTimelineChannel extends Channel {
}
// 関係ない返信は除外
- if (note.reply && !this.user!.showTimelineReplies) {
+ if (note.reply && !this.withReplies) {
const reply = note.reply;
// 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合
if (reply.userId !== this.user!.id && note.userId !== this.user!.id && reply.userId !== note.userId) return;
diff --git a/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts b/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts
index 4f7b4e78b6..5a33e13cf5 100644
--- a/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts
+++ b/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts
@@ -13,6 +13,7 @@ class HybridTimelineChannel extends Channel {
public readonly chName = 'hybridTimeline';
public static shouldShare = true;
public static requireCredential = true;
+ private withReplies: boolean;
constructor(
private metaService: MetaService,
@@ -31,6 +32,8 @@ class HybridTimelineChannel extends Channel {
const policies = await this.roleService.getUserPolicies(this.user ? this.user.id : null);
if (!policies.ltlAvailable) return;
+ this.withReplies = params.withReplies as boolean;
+
// Subscribe events
this.subscriber.on('notesStream', this.onNote);
}
@@ -75,7 +78,7 @@ class HybridTimelineChannel extends Channel {
if (isInstanceMuted(note, new Set<string>(this.userProfile!.mutedInstances ?? []))) return;
// 関係ない返信は除外
- if (note.reply && !this.user!.showTimelineReplies) {
+ if (note.reply && !this.withReplies) {
const reply = note.reply;
// 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合
if (reply.userId !== this.user!.id && note.userId !== this.user!.id && reply.userId !== note.userId) return;
diff --git a/packages/backend/src/server/api/stream/channels/local-timeline.ts b/packages/backend/src/server/api/stream/channels/local-timeline.ts
index 09b0005ac1..9ca4db8ced 100644
--- a/packages/backend/src/server/api/stream/channels/local-timeline.ts
+++ b/packages/backend/src/server/api/stream/channels/local-timeline.ts
@@ -12,6 +12,7 @@ class LocalTimelineChannel extends Channel {
public readonly chName = 'localTimeline';
public static shouldShare = true;
public static requireCredential = false;
+ private withReplies: boolean;
constructor(
private metaService: MetaService,
@@ -30,6 +31,8 @@ class LocalTimelineChannel extends Channel {
const policies = await this.roleService.getUserPolicies(this.user ? this.user.id : null);
if (!policies.ltlAvailable) return;
+ this.withReplies = params.withReplies as boolean;
+
// Subscribe events
this.subscriber.on('notesStream', this.onNote);
}
@@ -54,7 +57,7 @@ class LocalTimelineChannel extends Channel {
}
// 関係ない返信は除外
- if (note.reply && this.user && !this.user.showTimelineReplies) {
+ if (note.reply && this.user && !this.withReplies) {
const reply = note.reply;
// 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合
if (reply.userId !== this.user.id && note.userId !== this.user.id && reply.userId !== note.userId) return;
diff --git a/packages/backend/src/server/api/stream/channels/role-timeline.ts b/packages/backend/src/server/api/stream/channels/role-timeline.ts
index 9d106c8b2f..ab9c1aa0b5 100644
--- a/packages/backend/src/server/api/stream/channels/role-timeline.ts
+++ b/packages/backend/src/server/api/stream/channels/role-timeline.ts
@@ -5,15 +5,17 @@ import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { bindThis } from '@/decorators.js';
import Channel from '../channel.js';
import { StreamMessages } from '../types.js';
+import { RoleService } from '@/core/RoleService.js';
class RoleTimelineChannel extends Channel {
public readonly chName = 'roleTimeline';
public static shouldShare = false;
public static requireCredential = false;
private roleId: string;
-
+
constructor(
private noteEntityService: NoteEntityService,
+ private roleservice: RoleService,
id: string,
connection: Channel['connection'],
@@ -34,6 +36,11 @@ class RoleTimelineChannel extends Channel {
if (data.type === 'note') {
const note = data.body;
+ if (!(await this.roleservice.isExplorable({ id: this.roleId }))) {
+ return;
+ }
+ if (note.visibility !== 'public') return;
+
// 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する
if (isUserRelated(note, this.userIdsWhoMeMuting)) return;
// 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する
@@ -61,6 +68,7 @@ export class RoleTimelineChannelService {
constructor(
private noteEntityService: NoteEntityService,
+ private roleservice: RoleService,
) {
}
@@ -68,6 +76,7 @@ export class RoleTimelineChannelService {
public create(id: string, connection: Channel['connection']): RoleTimelineChannel {
return new RoleTimelineChannel(
this.noteEntityService,
+ this.roleservice,
id,
connection,
);
diff --git a/packages/backend/src/server/api/stream/index.ts b/packages/backend/src/server/api/stream/index.ts
index a6f9145952..8b1c2c09c9 100644
--- a/packages/backend/src/server/api/stream/index.ts
+++ b/packages/backend/src/server/api/stream/index.ts
@@ -1,3 +1,4 @@
+import * as WebSocket from 'ws';
import type { User } from '@/models/entities/User.js';
import type { AccessToken } from '@/models/entities/AccessToken.js';
import type { Packed } from '@/misc/json-schema.js';
@@ -7,7 +8,6 @@ import { bindThis } from '@/decorators.js';
import { CacheService } from '@/core/CacheService.js';
import { UserProfile } from '@/models/index.js';
import type { ChannelsService } from './ChannelsService.js';
-import type * as websocket from 'websocket';
import type { EventEmitter } from 'events';
import type Channel from './channel.js';
import type { StreamEventEmitter, StreamMessages } from './types.js';
@@ -18,7 +18,7 @@ import type { StreamEventEmitter, StreamMessages } from './types.js';
export default class Connection {
public user?: User;
public token?: AccessToken;
- private wsConnection: websocket.connection;
+ private wsConnection: WebSocket.WebSocket;
public subscriber: StreamEventEmitter;
private channels: Channel[] = [];
private subscribingNotes: any = {};
@@ -37,11 +37,9 @@ export default class Connection {
private notificationService: NotificationService,
private cacheService: CacheService,
- subscriber: EventEmitter,
user: User | null | undefined,
token: AccessToken | null | undefined,
) {
- this.subscriber = subscriber;
if (user) this.user = user;
if (token) this.token = token;
}
@@ -70,12 +68,16 @@ export default class Connection {
if (this.user != null) {
await this.fetch();
- this.fetchIntervalId = setInterval(this.fetch, 1000 * 10);
+ if (!this.fetchIntervalId) {
+ this.fetchIntervalId = setInterval(this.fetch, 1000 * 10);
+ }
}
}
@bindThis
- public async init2(wsConnection: websocket.connection) {
+ public async listen(subscriber: EventEmitter, wsConnection: WebSocket.WebSocket) {
+ this.subscriber = subscriber;
+
this.wsConnection = wsConnection;
this.wsConnection.on('message', this.onWsConnectionMessage);
@@ -88,14 +90,11 @@ export default class Connection {
* クライアントからメッセージ受信時
*/
@bindThis
- private async onWsConnectionMessage(data: websocket.Message) {
- if (data.type !== 'utf8') return;
- if (data.utf8Data == null) return;
-
+ private async onWsConnectionMessage(data: WebSocket.RawData) {
let obj: Record<string, any>;
try {
- obj = JSON.parse(data.utf8Data);
+ obj = JSON.parse(data.toString());
} catch (e) {
return;
}
@@ -246,7 +245,7 @@ export default class Connection {
const ch: Channel = channelService.create(id, this);
this.channels.push(ch);
- ch.init(params);
+ ch.init(params ?? {});
if (pong) {
this.sendMessageToWs('connected', {
diff --git a/packages/backend/src/server/web/boot.js b/packages/backend/src/server/web/boot.js
index fd7f54da54..38ae8ad2e5 100644
--- a/packages/backend/src/server/web/boot.js
+++ b/packages/backend/src/server/web/boot.js
@@ -116,9 +116,9 @@
}
}
}
- const colorSchema = localStorage.getItem('colorSchema');
- if (colorSchema) {
- document.documentElement.style.setProperty('color-schema', colorSchema);
+ const colorScheme = localStorage.getItem('colorScheme');
+ if (colorScheme) {
+ document.documentElement.style.setProperty('color-scheme', colorScheme);
}
//#endregion
@@ -160,37 +160,41 @@
<path d="M12 9v2m0 4v.01"></path>
<path d="M5 19h14a2 2 0 0 0 1.84 -2.75l-7.1 -12.25a2 2 0 0 0 -3.5 0l-7.1 12.25a2 2 0 0 0 1.75 2.75"></path>
</svg>
- <h1>An error has occurred!</h1>
- <button class="button-big" onclick="location.reload();">
- <span class="button-label-big">Refresh</span>
+ <h1>Failed to load<br>読み込みに失敗しました</h1>
+ <button class="button-big" onclick="location.reload(true);">
+ <span class="button-label-big">Reload / リロード</span>
</button>
- <p class="dont-worry">Don't worry, it's (probably) not your fault.</p>
- <p>If the problem persists after refreshing, please contact your instance's administrator.<br>You may also try the following options:</p>
- <p>Update your os and browser.</p>
- <p>Disable an adblocker.</p>
- <a href="/flush">
- <button class="button-small">
- <span class="button-label-small">Clear preferences and cache</span>
- </button>
- </a>
- <br>
- <a href="/cli">
- <button class="button-small">
- <span class="button-label-small">Start the simple client</span>
- </button>
- </a>
- <br>
- <a href="/bios">
- <button class="button-small">
- <span class="button-label-small">Start the repair tool</span>
- </button>
- </a>
+ <p><b>The following actions may solve the problem. / 以下を行うと解決する可能性があります。</b></p>
+ <p>Clear the browser cache / ブラウザのキャッシュをクリアする</p>
+ <p>Update your os and browser / ブラウザおよびOSを最新バージョンに更新する</p>
+ <p>Disable an adblocker / アドブロッカーを無効にする</p>
+ <details style="color: #86b300;">
+ <summary>Other options / その他のオプション</summary>
+ <a href="/flush">
+ <button class="button-small">
+ <span class="button-label-small">Clear preferences and cache</span>
+ </button>
+ </a>
+ <br>
+ <a href="/cli">
+ <button class="button-small">
+ <span class="button-label-small">Start the simple client</span>
+ </button>
+ </a>
+ <br>
+ <a href="/bios">
+ <button class="button-small">
+ <span class="button-label-small">Start the repair tool</span>
+ </button>
+ </a>
+ </details>
<br>
<div id="errors"></div>
`;
errorsElement = document.getElementById('errors');
}
const detailsElement = document.createElement('details');
+ detailsElement.id = 'errorInfo';
detailsElement.innerHTML = `
<br>
<summary>
@@ -247,7 +251,7 @@
.button-label-big {
color: #222;
font-weight: bold;
- font-size: 20px;
+ font-size: 1.2em;
padding: 12px;
}
@@ -267,11 +271,6 @@
font-size: 16px;
}
- .dont-worry,
- #msg {
- font-size: 18px;
- }
-
.icon-warning {
color: #dec340;
height: 4rem;
@@ -279,14 +278,15 @@
}
h1 {
- font-size: 32px;
+ font-size: 1.5em;
+ margin: 1em;
}
code {
font-family: Fira, FiraCode, monospace;
}
- details {
+ #errorInfo {
background: #333;
margin-bottom: 2rem;
padding: 0.5rem 1rem;
@@ -296,16 +296,16 @@
margin: auto;
}
- summary {
+ #errorInfo summary {
cursor: pointer;
}
- summary > * {
+ #errorInfo summary > * {
display: inline;
}
@media screen and (max-width: 500px) {
- details {
+ #errorInfo {
width: 50%;
}
`)
diff --git a/packages/backend/src/server/web/views/base.pug b/packages/backend/src/server/web/views/base.pug
index cb5d05a403..69b3f68e05 100644
--- a/packages/backend/src/server/web/views/base.pug
+++ b/packages/backend/src/server/web/views/base.pug
@@ -25,7 +25,6 @@ html
meta(name='referrer' content='origin')
meta(name='theme-color' content= themeColor || '#86b300')
meta(name='theme-color-orig' content= themeColor || '#86b300')
- meta(property='twitter:card' content='summary')
meta(property='og:site_name' content= instanceName || 'Misskey')
meta(name='viewport' content='width=device-width, initial-scale=1')
link(rel='icon' href= icon || '/favicon.ico')
@@ -36,7 +35,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.17.0')
+ link(rel='stylesheet' href='/assets/tabler-icons/tabler-icons.min.css?v2.21.0')
link(rel='modulepreload' href=`/vite/${clientEntry.file}`)
if !config.clientManifestExists
@@ -59,6 +58,7 @@ html
meta(property='og:title' content= title || 'Misskey')
meta(property='og:description' content= desc || '✨🌎✨ A interplanetary communication platform ✨🚀✨')
meta(property='og:image' content= img)
+ meta(property='twitter:card' content='summary')
style
include ../style.css
diff --git a/packages/backend/src/server/web/views/channel.pug b/packages/backend/src/server/web/views/channel.pug
index 486f0ecc47..c514025e0b 100644
--- a/packages/backend/src/server/web/views/channel.pug
+++ b/packages/backend/src/server/web/views/channel.pug
@@ -16,3 +16,4 @@ block og
meta(property='og:description' content= channel.description)
meta(property='og:url' content= url)
meta(property='og:image' content= channel.bannerUrl)
+ meta(property='twitter:card' content='summary')
diff --git a/packages/backend/src/server/web/views/clip.pug b/packages/backend/src/server/web/views/clip.pug
index 74dc62f1e7..5a0018803a 100644
--- a/packages/backend/src/server/web/views/clip.pug
+++ b/packages/backend/src/server/web/views/clip.pug
@@ -17,6 +17,7 @@ block og
meta(property='og:description' content= clip.description)
meta(property='og:url' content= url)
meta(property='og:image' content= avatarUrl)
+ meta(property='twitter:card' content='summary')
block meta
if profile.noCrawle
diff --git a/packages/backend/src/server/web/views/flash.pug b/packages/backend/src/server/web/views/flash.pug
index 5594fcdfbf..1549aa7906 100644
--- a/packages/backend/src/server/web/views/flash.pug
+++ b/packages/backend/src/server/web/views/flash.pug
@@ -17,6 +17,7 @@ block og
meta(property='og:description' content= flash.summary)
meta(property='og:url' content= url)
meta(property='og:image' content= avatarUrl)
+ meta(property='twitter:card' content='summary')
block meta
if profile.noCrawle
diff --git a/packages/backend/src/server/web/views/gallery-post.pug b/packages/backend/src/server/web/views/gallery-post.pug
index 10f2d269bc..a458d7f8c7 100644
--- a/packages/backend/src/server/web/views/gallery-post.pug
+++ b/packages/backend/src/server/web/views/gallery-post.pug
@@ -17,6 +17,7 @@ block og
meta(property='og:description' content= post.description)
meta(property='og:url' content= url)
meta(property='og:image' content= post.files[0].thumbnailUrl)
+ meta(property='twitter:card' content='summary_large_image')
block meta
if user.host || profile.noCrawle
diff --git a/packages/backend/src/server/web/views/note.pug b/packages/backend/src/server/web/views/note.pug
index badfcccd61..874c48c602 100644
--- a/packages/backend/src/server/web/views/note.pug
+++ b/packages/backend/src/server/web/views/note.pug
@@ -5,6 +5,8 @@ block vars
- const title = user.name ? `${user.name} (@${user.username})` : `@${user.username}`;
- const url = `${config.url}/notes/${note.id}`;
- const isRenote = note.renote && note.text == null && note.fileIds.length == 0 && note.poll == null;
+ - const image = (note.files || []).find(file => file.type.startsWith('image/') && !file.type.isSensitive)
+ - const video = (note.files || []).find(file => file.type.startsWith('video/') && !file.type.isSensitive)
block title
= `${title} | ${instanceName}`
@@ -17,7 +19,19 @@ block og
meta(property='og:title' content= title)
meta(property='og:description' content= summary)
meta(property='og:url' content= url)
- meta(property='og:image' content= avatarUrl)
+ if video
+ meta(property='og:video:url' content= video.url)
+ meta(property='og:video:secure_url' content= video.url)
+ meta(property='og:video:type' content= video.type)
+ // FIXME: add width and height
+ // FIXME: add embed player for Twitter
+ if image
+ meta(property='twitter:card' content='summary_large_image')
+ meta(property='og:image' content= image.url)
+ else
+ meta(property='twitter:card' content='summary')
+ meta(property='og:image' content= avatarUrl)
+
block meta
if user.host || isRenote || profile.noCrawle
diff --git a/packages/backend/src/server/web/views/page.pug b/packages/backend/src/server/web/views/page.pug
index ddffc361c8..08bb08ffe7 100644
--- a/packages/backend/src/server/web/views/page.pug
+++ b/packages/backend/src/server/web/views/page.pug
@@ -17,6 +17,7 @@ block og
meta(property='og:description' content= page.summary)
meta(property='og:url' content= url)
meta(property='og:image' content= page.eyeCatchingImage ? page.eyeCatchingImage.thumbnailUrl : avatarUrl)
+ meta(property='twitter:card' content= page.eyeCatchingImage ? 'summary_large_image' : 'summary')
block meta
if profile.noCrawle
diff --git a/packages/backend/src/server/web/views/user.pug b/packages/backend/src/server/web/views/user.pug
index f4c83aa89d..83d57349a6 100644
--- a/packages/backend/src/server/web/views/user.pug
+++ b/packages/backend/src/server/web/views/user.pug
@@ -16,6 +16,7 @@ block og
meta(property='og:description' content= profile.description)
meta(property='og:url' content= url)
meta(property='og:image' content= avatarUrl)
+ meta(property='twitter:card' content='summary')
block meta
if user.host || profile.noCrawle
diff --git a/packages/backend/test/e2e/2fa.ts b/packages/backend/test/e2e/2fa.ts
index 0addb430c9..5da997f28b 100644
--- a/packages/backend/test/e2e/2fa.ts
+++ b/packages/backend/test/e2e/2fa.ts
@@ -2,7 +2,7 @@ process.env.NODE_ENV = 'test';
import * as assert from 'assert';
import * as crypto from 'node:crypto';
-import * as cbor from 'cbor';
+import cbor from 'cbor';
import * as OTPAuth from 'otpauth';
import { loadConfig } from '../../src/config.js';
import { signup, api, post, react, startServer, waitFire } from '../utils.js';
diff --git a/packages/backend/test/e2e/antennas.ts b/packages/backend/test/e2e/antennas.ts
new file mode 100644
index 0000000000..dd3b09f85a
--- /dev/null
+++ b/packages/backend/test/e2e/antennas.ts
@@ -0,0 +1,653 @@
+process.env.NODE_ENV = 'test';
+
+import * as assert from 'assert';
+import { inspect } from 'node:util';
+import { DEFAULT_POLICIES } from '@/core/RoleService.js';
+import type { Packed } from '@/misc/json-schema.js';
+import {
+ signup,
+ post,
+ userList,
+ page,
+ role,
+ startServer,
+ api,
+ successfulApiCall,
+ failedApiCall,
+ uploadFile,
+ testPaginationConsistency,
+} from '../utils.js';
+import type * as misskey from 'misskey-js';
+import type { INestApplicationContext } from '@nestjs/common';
+
+const compareBy = <T extends { id: string }>(selector: (s: T) => string = (s: T): string => s.id) => (a: T, b: T): number => {
+ return selector(a).localeCompare(selector(b));
+};
+
+describe('アンテナ', () => {
+ // エンティティとしてのアンテナを主眼においたテストを記述する
+ // (Antennaを返すエンドポイント、Antennaエンティティを書き換えるエンドポイント、Antennaからノートを取得するエンドポイントをテストする)
+
+ // BUG misskey-jsとjson-schemaが一致していない。
+ // - srcのenumにgroupが残っている
+ // - userGroupIdが残っている, isActiveがない
+ type Antenna = misskey.entities.Antenna | Packed<'Antenna'>;
+ type User = misskey.entities.MeDetailed & { token: string };
+ type Note = misskey.entities.Note;
+
+ // アンテナを作成できる最小のパラメタ
+ const defaultParam = {
+ caseSensitive: false,
+ excludeKeywords: [['']],
+ keywords: [['keyword']],
+ name: 'test',
+ notify: false,
+ src: 'all' as const,
+ userListId: null,
+ users: [''],
+ withFile: false,
+ withReplies: false,
+ };
+
+ let app: INestApplicationContext;
+
+ let root: User;
+ let alice: User;
+ let bob: User;
+ let carol: User;
+
+ let alicePost: Note;
+ let aliceList: misskey.entities.UserList;
+ let bobFile: misskey.entities.DriveFile;
+ let bobList: misskey.entities.UserList;
+
+ let userNotExplorable: User;
+ let userLocking: User;
+ let userSilenced: User;
+ let userSuspended: User;
+ let userDeletedBySelf: User;
+ let userDeletedByAdmin: User;
+ let userFollowingAlice: User;
+ let userFollowedByAlice: User;
+ let userBlockingAlice: User;
+ let userBlockedByAlice: User;
+ let userMutingAlice: User;
+ let userMutedByAlice: User;
+
+ beforeAll(async () => {
+ app = await startServer();
+ }, 1000 * 60 * 2);
+
+ beforeAll(async () => {
+ root = await signup({ username: 'root' });
+ alice = await signup({ username: 'alice' });
+ alicePost = await post(alice, { text: 'test' });
+ aliceList = await userList(alice, {});
+ bob = await signup({ username: 'bob' });
+ aliceList = await userList(alice, {});
+ bobFile = (await uploadFile(bob)).body;
+ bobList = await userList(bob);
+ carol = await signup({ username: 'carol' });
+ await api('users/lists/push', { listId: aliceList.id, userId: bob.id }, alice);
+ await api('users/lists/push', { listId: aliceList.id, userId: carol.id }, alice);
+
+ userNotExplorable = await signup({ username: 'userNotExplorable' });
+ await post(userNotExplorable, { text: 'test' });
+ await api('i/update', { isExplorable: false }, userNotExplorable);
+ userLocking = await signup({ username: 'userLocking' });
+ await post(userLocking, { text: 'test' });
+ await api('i/update', { isLocked: true }, userLocking);
+ userSilenced = await signup({ username: 'userSilenced' });
+ await post(userSilenced, { text: 'test' });
+ const roleSilenced = await role(root, {}, { canPublicNote: { priority: 0, useDefault: false, value: false } });
+ await api('admin/roles/assign', { userId: userSilenced.id, roleId: roleSilenced.id }, root);
+ userSuspended = await signup({ username: 'userSuspended' });
+ await post(userSuspended, { text: 'test' });
+ await successfulApiCall({ endpoint: 'i/update', parameters: { description: '#user_testuserSuspended' }, user: userSuspended });
+ await api('admin/suspend-user', { userId: userSuspended.id }, root);
+ userDeletedBySelf = await signup({ username: 'userDeletedBySelf', password: 'userDeletedBySelf' });
+ await post(userDeletedBySelf, { text: 'test' });
+ await api('i/delete-account', { password: 'userDeletedBySelf' }, userDeletedBySelf);
+ userDeletedByAdmin = await signup({ username: 'userDeletedByAdmin' });
+ await post(userDeletedByAdmin, { text: 'test' });
+ await api('admin/delete-account', { userId: userDeletedByAdmin.id }, root);
+ userFollowedByAlice = await signup({ username: 'userFollowedByAlice' });
+ await post(userFollowedByAlice, { text: 'test' });
+ await api('following/create', { userId: userFollowedByAlice.id }, alice);
+ userFollowingAlice = await signup({ username: 'userFollowingAlice' });
+ await post(userFollowingAlice, { text: 'test' });
+ await api('following/create', { userId: alice.id }, userFollowingAlice);
+ userBlockingAlice = await signup({ username: 'userBlockingAlice' });
+ await post(userBlockingAlice, { text: 'test' });
+ await api('blocking/create', { userId: alice.id }, userBlockingAlice);
+ userBlockedByAlice = await signup({ username: 'userBlockedByAlice' });
+ await post(userBlockedByAlice, { text: 'test' });
+ await api('blocking/create', { userId: userBlockedByAlice.id }, alice);
+ userMutingAlice = await signup({ username: 'userMutingAlice' });
+ await post(userMutingAlice, { text: 'test' });
+ await api('mute/create', { userId: alice.id }, userMutingAlice);
+ userMutedByAlice = await signup({ username: 'userMutedByAlice' });
+ await post(userMutedByAlice, { text: 'test' });
+ await api('mute/create', { userId: userMutedByAlice.id }, alice);
+ }, 1000 * 60 * 10);
+
+ afterAll(async () => {
+ await app.close();
+ });
+
+ beforeEach(async () => {
+ // テスト間で影響し合わないように毎回全部消す。
+ for (const user of [alice, bob]) {
+ const list = await api('/antennas/list', {}, user);
+ for (const antenna of list.body) {
+ await api('/antennas/delete', { antennaId: antenna.id }, user);
+ }
+ }
+ });
+
+ //#region 作成(antennas/create)
+
+ test('が作成できること、キーが過不足なく入っていること。', async () => {
+ const response = await successfulApiCall({
+ endpoint: 'antennas/create',
+ parameters: { ...defaultParam },
+ user: alice,
+ });
+ assert.match(response.id, /[0-9a-z]{10}/);
+ const expected = {
+ id: response.id,
+ caseSensitive: false,
+ createdAt: new Date(response.createdAt).toISOString(),
+ excludeKeywords: [['']],
+ hasUnreadNote: false,
+ isActive: true,
+ keywords: [['keyword']],
+ name: 'test',
+ notify: false,
+ src: 'all',
+ userListId: null,
+ users: [''],
+ withFile: false,
+ withReplies: false,
+ } as Antenna;
+ assert.deepStrictEqual(response, expected);
+ });
+
+ test('が上限いっぱいまで作成できること', async () => {
+ // antennaLimit + 1まで作れるのがキモ
+ const response = await Promise.all([...Array(DEFAULT_POLICIES.antennaLimit + 1)].map(() => successfulApiCall({
+ endpoint: 'antennas/create',
+ parameters: { ...defaultParam },
+ user: alice,
+ })));
+
+ const expected = await successfulApiCall({ endpoint: 'antennas/list', parameters: {}, user: alice });
+ assert.deepStrictEqual(
+ response.sort(compareBy(s => s.id)),
+ expected.sort(compareBy(s => s.id)));
+
+ failedApiCall({
+ endpoint: 'antennas/create',
+ parameters: { ...defaultParam },
+ user: alice,
+ }, {
+ status: 400,
+ code: 'TOO_MANY_ANTENNAS',
+ id: 'faf47050-e8b5-438c-913c-db2b1576fde4',
+ });
+ });
+
+ test('を作成するとき他人のリストを指定したらエラーになる', async () => {
+ failedApiCall({
+ endpoint: 'antennas/create',
+ parameters: { ...defaultParam, src: 'list', userListId: bobList.id },
+ user: alice,
+ }, {
+ status: 400,
+ code: 'NO_SUCH_USER_LIST',
+ id: '95063e93-a283-4b8b-9aa5-bcdb8df69a7f',
+ });
+ });
+
+ const antennaParamPattern = [
+ { parameters: (): object => ({ name: 'x'.repeat(100) }) },
+ { parameters: (): object => ({ name: 'x' }) },
+ { parameters: (): object => ({ src: 'home' }) },
+ { parameters: (): object => ({ src: 'all' }) },
+ { parameters: (): object => ({ src: 'users' }) },
+ { parameters: (): object => ({ src: 'list' }) },
+ { parameters: (): object => ({ userListId: null }) },
+ { parameters: (): object => ({ src: 'list', userListId: aliceList.id }) },
+ { parameters: (): object => ({ keywords: [['x']] }) },
+ { parameters: (): object => ({ keywords: [['a', 'b', 'c'], ['x'], ['y'], ['z']] }) },
+ { parameters: (): object => ({ excludeKeywords: [['a', 'b', 'c'], ['x'], ['y'], ['z']] }) },
+ { parameters: (): object => ({ users: [alice.username] }) },
+ { parameters: (): object => ({ users: [alice.username, bob.username, carol.username] }) },
+ { parameters: (): object => ({ caseSensitive: false }) },
+ { parameters: (): object => ({ caseSensitive: true }) },
+ { parameters: (): object => ({ withReplies: false }) },
+ { parameters: (): object => ({ withReplies: true }) },
+ { parameters: (): object => ({ withFile: false }) },
+ { parameters: (): object => ({ withFile: true }) },
+ { parameters: (): object => ({ notify: false }) },
+ { parameters: (): object => ({ notify: true }) },
+ ];
+ test.each(antennaParamPattern)('を作成できること($#)', async ({ parameters }) => {
+ const response = await successfulApiCall({
+ endpoint: 'antennas/create',
+ parameters: { ...defaultParam, ...parameters() },
+ user: alice,
+ });
+ const expected = { ...response, ...parameters() };
+ assert.deepStrictEqual(response, expected);
+ });
+
+ //#endregion
+ //#region 更新(antennas/update)
+
+ test.each(antennaParamPattern)('を変更できること($#)', async ({ parameters }) => {
+ const antenna = await successfulApiCall({ endpoint: 'antennas/create', parameters: defaultParam, user: alice });
+ const response = await successfulApiCall({
+ endpoint: 'antennas/update',
+ parameters: { antennaId: antenna.id, ...defaultParam, ...parameters() },
+ user: alice,
+ });
+ const expected = { ...response, ...parameters() };
+ assert.deepStrictEqual(response, expected);
+ });
+ test.todo('は他人のものは変更できない');
+
+ test('を変更するとき他人のリストを指定したらエラーになる', async () => {
+ const antenna = await successfulApiCall({ endpoint: 'antennas/create', parameters: defaultParam, user: alice });
+ failedApiCall({
+ endpoint: 'antennas/update',
+ parameters: { antennaId: antenna.id, ...defaultParam, src: 'list', userListId: bobList.id },
+ user: alice,
+ }, {
+ status: 400,
+ code: 'NO_SUCH_USER_LIST',
+ id: '1c6b35c9-943e-48c2-81e4-2844989407f7',
+ });
+ });
+
+ //#endregion
+ //#region 表示(antennas/show)
+
+ test('をID指定で表示できること。', async () => {
+ const antenna = await successfulApiCall({ endpoint: 'antennas/create', parameters: defaultParam, user: alice });
+ const response = await successfulApiCall({
+ endpoint: 'antennas/show',
+ parameters: { antennaId: antenna.id },
+ user: alice,
+ });
+ const expected = { ...antenna };
+ assert.deepStrictEqual(response, expected);
+ });
+ test.todo('は他人のものをID指定で表示できない');
+
+ //#endregion
+ //#region 一覧(antennas/list)
+
+ test('をリスト形式で取得できること。', async () => {
+ const antenna = await successfulApiCall({ endpoint: 'antennas/create', parameters: defaultParam, user: alice });
+ await successfulApiCall({ endpoint: 'antennas/create', parameters: defaultParam, user: bob });
+ const response = await successfulApiCall({
+ endpoint: 'antennas/list',
+ parameters: {},
+ user: alice,
+ });
+ const expected = [{ ...antenna }];
+ assert.deepStrictEqual(response, expected);
+ });
+
+ //#endregion
+ //#region 削除(antennas/delete)
+
+ test('を削除できること。', async () => {
+ const antenna = await successfulApiCall({ endpoint: 'antennas/create', parameters: defaultParam, user: alice });
+ const response = await successfulApiCall({
+ endpoint: 'antennas/delete',
+ parameters: { antennaId: antenna.id },
+ user: alice,
+ });
+ assert.deepStrictEqual(response, null);
+ const list = await successfulApiCall({ endpoint: 'antennas/list', parameters: {}, user: alice });
+ assert.deepStrictEqual(list, []);
+ });
+ test.todo('は他人のものを削除できない');
+
+ //#endregion
+
+ describe('のノート', () => {
+ //#region アンテナのノート取得(antennas/notes)
+
+ test('を取得できること。', async () => {
+ const keyword = 'キーワード';
+ await post(bob, { text: `test ${keyword} beforehand` });
+ const antenna = await successfulApiCall({
+ endpoint: 'antennas/create',
+ parameters: { ...defaultParam, keywords: [[keyword]] },
+ user: alice,
+ });
+ const note = await post(bob, { text: `test ${keyword}` });
+ const response = await successfulApiCall({
+ endpoint: 'antennas/notes',
+ parameters: { antennaId: antenna.id },
+ user: alice,
+ });
+ const expected = [note];
+ assert.deepStrictEqual(response, expected);
+ });
+
+ const keyword = 'キーワード';
+ test.each([
+ {
+ label: '全体から',
+ parameters: (): object => ({ src: 'all' }),
+ posts: [
+ { note: (): Promise<Note> => post(alice, { text: `${keyword}` }), included: true },
+ { note: (): Promise<Note> => post(userFollowedByAlice, { text: `${keyword}` }), included: true },
+ { note: (): Promise<Note> => post(bob, { text: `test ${keyword}` }), included: true },
+ { note: (): Promise<Note> => post(carol, { text: `test ${keyword}` }), included: true },
+ ],
+ },
+ {
+ // BUG e4144a1 以降home指定は壊れている(allと同じ)
+ label: 'ホーム指定はallと同じ',
+ parameters: (): object => ({ src: 'home' }),
+ posts: [
+ { note: (): Promise<Note> => post(alice, { text: `${keyword}` }), included: true },
+ { note: (): Promise<Note> => post(userFollowedByAlice, { text: `${keyword}` }), included: true },
+ { note: (): Promise<Note> => post(bob, { text: `test ${keyword}` }), included: true },
+ { note: (): Promise<Note> => post(carol, { text: `test ${keyword}` }), included: true },
+ ],
+ },
+ {
+ // https://github.com/misskey-dev/misskey/issues/9025
+ label: 'ただし、フォロワー限定投稿とDM投稿を含まない。フォロワーであっても。',
+ parameters: (): object => ({}),
+ posts: [
+ { note: (): Promise<Note> => post(userFollowedByAlice, { text: `${keyword}`, visibility: 'public' }), included: true },
+ { note: (): Promise<Note> => post(userFollowedByAlice, { text: `${keyword}`, visibility: 'home' }), included: true },
+ { note: (): Promise<Note> => post(userFollowedByAlice, { text: `${keyword}`, visibility: 'followers' }) },
+ { note: (): Promise<Note> => post(userFollowedByAlice, { text: `${keyword}`, visibility: 'specified', visibleUserIds: [alice.id] }) },
+ ],
+ },
+ {
+ label: 'ブロックしているユーザーのノートは含む',
+ parameters: (): object => ({}),
+ posts: [
+ { note: (): Promise<Note> => post(userBlockedByAlice, { text: `${keyword}` }), included: true },
+ ],
+ },
+ {
+ label: 'ブロックされているユーザーのノートは含まない',
+ parameters: (): object => ({}),
+ posts: [
+ { note: (): Promise<Note> => post(userBlockingAlice, { text: `${keyword}` }) },
+ ],
+ },
+ {
+ label: 'ミュートしているユーザーのノートは含まない',
+ parameters: (): object => ({}),
+ posts: [
+ { note: (): Promise<Note> => post(userMutedByAlice, { text: `${keyword}` }) },
+ ],
+ },
+ {
+ label: 'ミュートされているユーザーのノートは含む',
+ parameters: (): object => ({}),
+ posts: [
+ { note: (): Promise<Note> => post(userMutingAlice, { text: `${keyword}` }), included: true },
+ ],
+ },
+ {
+ label: '「見つけやすくする」がOFFのユーザーのノートも含まれる',
+ parameters: (): object => ({}),
+ posts: [
+ { note: (): Promise<Note> => post(userNotExplorable, { text: `${keyword}` }), included: true },
+ ],
+ },
+ {
+ label: '鍵付きユーザーのノートも含まれる',
+ parameters: (): object => ({}),
+ posts: [
+ { note: (): Promise<Note> => post(userLocking, { text: `${keyword}` }), included: true },
+ ],
+ },
+ {
+ label: 'サイレンスのノートも含まれる',
+ parameters: (): object => ({}),
+ posts: [
+ { note: (): Promise<Note> => post(userSilenced, { text: `${keyword}` }), included: true },
+ ],
+ },
+ {
+ label: '削除ユーザーのノートも含まれる',
+ parameters: (): object => ({}),
+ posts: [
+ { note: (): Promise<Note> => post(userDeletedBySelf, { text: `${keyword}` }), included: true },
+ { note: (): Promise<Note> => post(userDeletedByAdmin, { text: `${keyword}` }), included: true },
+ ],
+ },
+ {
+ label: 'ユーザー指定で',
+ parameters: (): object => ({ src: 'users', users: [`@${bob.username}`, `@${carol.username}`] }),
+ posts: [
+ { note: (): Promise<Note> => post(alice, { text: `test ${keyword}` }) },
+ { note: (): Promise<Note> => post(bob, { text: `test ${keyword}` }), included: true },
+ { note: (): Promise<Note> => post(carol, { text: `test ${keyword}` }), included: true },
+ ],
+ },
+ {
+ label: 'リスト指定で',
+ parameters: (): object => ({ src: 'list', userListId: aliceList.id }),
+ posts: [
+ { note: (): Promise<Note> => post(alice, { text: `test ${keyword}` }) },
+ { note: (): Promise<Note> => post(bob, { text: `test ${keyword}` }), included: true },
+ { note: (): Promise<Note> => post(carol, { text: `test ${keyword}` }), included: true },
+ ],
+ },
+ {
+ label: 'CWにもマッチする',
+ parameters: (): object => ({ keywords: [[keyword]] }),
+ posts: [
+ { note: (): Promise<Note> => post(bob, { text: 'test', cw: `cw ${keyword}` }), included: true },
+ ],
+ },
+ {
+ label: 'キーワード1つ',
+ parameters: (): object => ({ keywords: [[keyword]] }),
+ posts: [
+ { note: (): Promise<Note> => post(alice, { text: 'test' }) },
+ { note: (): Promise<Note> => post(bob, { text: `test ${keyword}` }), included: true },
+ { note: (): Promise<Note> => post(carol, { text: 'test' }) },
+ ],
+ },
+ {
+ label: 'キーワード3つ(AND)',
+ parameters: (): object => ({ keywords: [['A', 'B', 'C']] }),
+ posts: [
+ { note: (): Promise<Note> => post(bob, { text: 'test A' }) },
+ { note: (): Promise<Note> => post(bob, { text: 'test A B' }) },
+ { note: (): Promise<Note> => post(bob, { text: 'test B C' }) },
+ { note: (): Promise<Note> => post(bob, { text: 'test A B C' }), included: true },
+ { note: (): Promise<Note> => post(bob, { text: 'test C B A A B C' }), included: true },
+ ],
+ },
+ {
+ label: 'キーワード3つ(OR)',
+ parameters: (): object => ({ keywords: [['A'], ['B'], ['C']] }),
+ posts: [
+ { note: (): Promise<Note> => post(bob, { text: 'test' }) },
+ { note: (): Promise<Note> => post(bob, { text: 'test A' }), included: true },
+ { note: (): Promise<Note> => post(bob, { text: 'test A B' }), included: true },
+ { note: (): Promise<Note> => post(bob, { text: 'test B C' }), included: true },
+ { note: (): Promise<Note> => post(bob, { text: 'test B C A' }), included: true },
+ { note: (): Promise<Note> => post(bob, { text: 'test C B' }), included: true },
+ { note: (): Promise<Note> => post(bob, { text: 'test C' }), included: true },
+ ],
+ },
+ {
+ label: '除外ワード3つ(AND)',
+ parameters: (): object => ({ excludeKeywords: [['A', 'B', 'C']] }),
+ posts: [
+ { note: (): Promise<Note> => post(bob, { text: `test ${keyword}` }), included: true },
+ { note: (): Promise<Note> => post(bob, { text: `test ${keyword} A` }), included: true },
+ { note: (): Promise<Note> => post(bob, { text: `test ${keyword} A B` }), included: true },
+ { note: (): Promise<Note> => post(bob, { text: `test ${keyword} B C` }), included: true },
+ { note: (): Promise<Note> => post(bob, { text: `test ${keyword} B C A` }) },
+ { note: (): Promise<Note> => post(bob, { text: `test ${keyword} C B` }), included: true },
+ { note: (): Promise<Note> => post(bob, { text: `test ${keyword} C` }), included: true },
+ ],
+ },
+ {
+ label: '除外ワード3つ(OR)',
+ parameters: (): object => ({ excludeKeywords: [['A'], ['B'], ['C']] }),
+ posts: [
+ { note: (): Promise<Note> => post(bob, { text: `test ${keyword}` }), included: true },
+ { note: (): Promise<Note> => post(bob, { text: `test ${keyword} A` }) },
+ { note: (): Promise<Note> => post(bob, { text: `test ${keyword} A B` }) },
+ { note: (): Promise<Note> => post(bob, { text: `test ${keyword} B C` }) },
+ { note: (): Promise<Note> => post(bob, { text: `test ${keyword} B C A` }) },
+ { note: (): Promise<Note> => post(bob, { text: `test ${keyword} C B` }) },
+ { note: (): Promise<Note> => post(bob, { text: `test ${keyword} C` }) },
+ ],
+ },
+ {
+ label: 'キーワード1つ(大文字小文字区別する)',
+ parameters: (): object => ({ keywords: [['KEYWORD']], caseSensitive: true }),
+ posts: [
+ { note: (): Promise<Note> => post(bob, { text: 'keyword' }) },
+ { note: (): Promise<Note> => post(bob, { text: 'kEyWoRd' }) },
+ { note: (): Promise<Note> => post(bob, { text: 'KEYWORD' }), included: true },
+ ],
+ },
+ {
+ label: 'キーワード1つ(大文字小文字区別しない)',
+ parameters: (): object => ({ keywords: [['KEYWORD']], caseSensitive: false }),
+ posts: [
+ { note: (): Promise<Note> => post(bob, { text: 'keyword' }), included: true },
+ { note: (): Promise<Note> => post(bob, { text: 'kEyWoRd' }), included: true },
+ { note: (): Promise<Note> => post(bob, { text: 'KEYWORD' }), included: true },
+ ],
+ },
+ {
+ label: '除外ワード1つ(大文字小文字区別する)',
+ parameters: (): object => ({ excludeKeywords: [['KEYWORD']], caseSensitive: true }),
+ posts: [
+ { note: (): Promise<Note> => post(bob, { text: `${keyword}` }), included: true },
+ { note: (): Promise<Note> => post(bob, { text: `${keyword} keyword` }), included: true },
+ { note: (): Promise<Note> => post(bob, { text: `${keyword} kEyWoRd` }), included: true },
+ { note: (): Promise<Note> => post(bob, { text: `${keyword} KEYWORD` }) },
+ ],
+ },
+ {
+ label: '除外ワード1つ(大文字小文字区別しない)',
+ parameters: (): object => ({ excludeKeywords: [['KEYWORD']], caseSensitive: false }),
+ posts: [
+ { note: (): Promise<Note> => post(bob, { text: `${keyword}` }), included: true },
+ { note: (): Promise<Note> => post(bob, { text: `${keyword} keyword` }) },
+ { note: (): Promise<Note> => post(bob, { text: `${keyword} kEyWoRd` }) },
+ { note: (): Promise<Note> => post(bob, { text: `${keyword} KEYWORD` }) },
+ ],
+ },
+ {
+ label: '添付ファイルを問わない',
+ parameters: (): object => ({ withFile: false }),
+ posts: [
+ { note: (): Promise<Note> => post(bob, { text: `${keyword}`, fileIds: [bobFile.id] }), included: true },
+ { note: (): Promise<Note> => post(bob, { text: `${keyword}` }), included: true },
+ ],
+ },
+ {
+ label: '添付ファイル付きのみ',
+ parameters: (): object => ({ withFile: true }),
+ posts: [
+ { note: (): Promise<Note> => post(bob, { text: `${keyword}`, fileIds: [bobFile.id] }), included: true },
+ { note: (): Promise<Note> => post(bob, { text: `${keyword}` }) },
+ ],
+ },
+ {
+ label: 'リプライ以外',
+ parameters: (): object => ({ withReplies: false }),
+ posts: [
+ { note: (): Promise<Note> => post(bob, { text: `${keyword}`, replyId: alicePost.id }) },
+ { note: (): Promise<Note> => post(bob, { text: `${keyword}` }), included: true },
+ ],
+ },
+ {
+ label: 'リプライも含む',
+ parameters: (): object => ({ withReplies: true }),
+ posts: [
+ { note: (): Promise<Note> => post(bob, { text: `${keyword}`, replyId: alicePost.id }), included: true },
+ { note: (): Promise<Note> => post(bob, { text: `${keyword}` }), included: true },
+ ],
+ },
+ ])('が取得できること($label)', async ({ parameters, posts }) => {
+ const antenna = await successfulApiCall({
+ endpoint: 'antennas/create',
+ parameters: { ...defaultParam, keywords: [[keyword]], ...parameters() },
+ user: alice,
+ });
+
+ const notes = await posts.reduce(async (prev, current) => {
+ // includedに関わらずnote()は評価して投稿する。
+ const p = await prev;
+ const n = await current.note();
+ if (current.included) return p.concat(n);
+ return p;
+ }, Promise.resolve([] as Note[]));
+
+ // alice視点でNoteを取り直す
+ const expected = await Promise.all(notes.reverse().map(s => successfulApiCall({
+ endpoint: 'notes/show',
+ parameters: { noteId: s.id },
+ user: alice,
+ })));
+
+ const response = await successfulApiCall({
+ endpoint: 'antennas/notes',
+ parameters: { antennaId: antenna.id },
+ user: alice,
+ });
+ assert.deepStrictEqual(
+ response.map(({ userId, id, text }) => ({ userId, id, text })),
+ expected.map(({ userId, id, text }) => ({ userId, id, text })));
+ assert.deepStrictEqual(response, expected);
+ });
+
+ test.skip('が取得でき、日付指定のPaginationに一貫性があること', async () => { });
+ test.each([
+ { label: 'ID指定', offsetBy: 'id' },
+
+ // BUG sinceDate, untilDateはsinceIdや他のエンドポイントとは異なり、その時刻に一致するレコードを含んでしまう。
+ // { label: '日付指定', offsetBy: 'createdAt' },
+ ] as const)('が取得でき、$labelのPaginationに一貫性があること', async ({ offsetBy }) => {
+ const antenna = await successfulApiCall({
+ endpoint: 'antennas/create',
+ parameters: { ...defaultParam, keywords: [[keyword]] },
+ user: alice,
+ });
+ const notes = await [...Array(30)].reduce(async (prev, current, index) => {
+ const p = await prev;
+ const n = await post(alice, { text: `${keyword} (${index})` });
+ return [n].concat(p);
+ }, Promise.resolve([] as Note[]));
+
+ // antennas/notesは降順のみで、昇順をサポートしない。
+ await testPaginationConsistency(notes, async (paginationParam) => {
+ return successfulApiCall({
+ endpoint: 'antennas/notes',
+ parameters: { antennaId: antenna.id, ...paginationParam },
+ user: alice,
+ }) as any as Note[];
+ }, offsetBy, 'desc');
+ });
+
+ // BUG 7日過ぎると作り直すしかない。 https://github.com/misskey-dev/misskey/issues/10476
+ test.todo('を取得したときActiveに戻る');
+
+ //#endregion
+ });
+});
diff --git a/packages/backend/test/e2e/users.ts b/packages/backend/test/e2e/users.ts
index a7f8210c8e..02684c93b8 100644
--- a/packages/backend/test/e2e/users.ts
+++ b/packages/backend/test/e2e/users.ts
@@ -43,7 +43,6 @@ describe('ユーザー', () => {
type MeDetailed = UserDetailedNotMe &
misskey.entities.MeDetailed & {
- showTimelineReplies: boolean,
achievements: object[],
loggedInDays: number,
policies: object,
@@ -160,7 +159,6 @@ describe('ユーザー', () => {
mutedInstances: user.mutedInstances,
mutingNotificationTypes: user.mutingNotificationTypes,
emailNotificationTypes: user.emailNotificationTypes,
- showTimelineReplies: user.showTimelineReplies,
achievements: user.achievements,
loggedInDays: user.loggedInDays,
policies: user.policies,
@@ -406,7 +404,6 @@ describe('ユーザー', () => {
assert.deepStrictEqual(response.mutedInstances, []);
assert.deepStrictEqual(response.mutingNotificationTypes, []);
assert.deepStrictEqual(response.emailNotificationTypes, ['follow', 'receiveFollowRequest']);
- assert.strictEqual(response.showTimelineReplies, false);
assert.deepStrictEqual(response.achievements, []);
assert.deepStrictEqual(response.loggedInDays, 0);
assert.deepStrictEqual(response.policies, DEFAULT_POLICIES);
@@ -470,8 +467,6 @@ describe('ユーザー', () => {
{ parameters: (): object => ({ isBot: false }) },
{ parameters: (): object => ({ isCat: true }) },
{ parameters: (): object => ({ isCat: false }) },
- { parameters: (): object => ({ showTimelineReplies: true }) },
- { parameters: (): object => ({ showTimelineReplies: false }) },
{ parameters: (): object => ({ injectFeaturedNote: true }) },
{ parameters: (): object => ({ injectFeaturedNote: false }) },
{ parameters: (): object => ({ receiveAnnouncementEmail: true }) },
diff --git a/packages/backend/test/misc/mock-resolver.ts b/packages/backend/test/misc/mock-resolver.ts
index 6b31e68616..a7bcd859ae 100644
--- a/packages/backend/test/misc/mock-resolver.ts
+++ b/packages/backend/test/misc/mock-resolver.ts
@@ -52,11 +52,7 @@ export class MockResolver extends Resolver {
const r = this._rs.get(value);
if (!r) {
- throw {
- name: 'StatusError',
- statusCode: 404,
- message: 'Not registed for mock',
- };
+ throw new Error('Not registed for mock');
}
const object = JSON.parse(r.content);
diff --git a/packages/backend/test/unit/ReactionService.ts b/packages/backend/test/unit/ReactionService.ts
index 38db081ac0..aa68f4117d 100644
--- a/packages/backend/test/unit/ReactionService.ts
+++ b/packages/backend/test/unit/ReactionService.ts
@@ -15,78 +15,74 @@ describe('ReactionService', () => {
reactionService = app.get<ReactionService>(ReactionService);
});
- describe('toDbReaction', () => {
+ describe('normalize', () => {
test('絵文字リアクションはそのまま', async () => {
- assert.strictEqual(await reactionService.toDbReaction('👍'), '👍');
- assert.strictEqual(await reactionService.toDbReaction('🍅'), '🍅');
+ assert.strictEqual(await reactionService.normalize('👍'), '👍');
+ assert.strictEqual(await reactionService.normalize('🍅'), '🍅');
});
test('既存のリアクションは絵文字化する pudding', async () => {
- assert.strictEqual(await reactionService.toDbReaction('pudding'), '🍮');
+ assert.strictEqual(await reactionService.normalize('pudding'), '🍮');
});
test('既存のリアクションは絵文字化する like', async () => {
- assert.strictEqual(await reactionService.toDbReaction('like'), '👍');
+ assert.strictEqual(await reactionService.normalize('like'), '👍');
});
test('既存のリアクションは絵文字化する love', async () => {
- assert.strictEqual(await reactionService.toDbReaction('love'), '❤');
+ assert.strictEqual(await reactionService.normalize('love'), '❤');
});
test('既存のリアクションは絵文字化する laugh', async () => {
- assert.strictEqual(await reactionService.toDbReaction('laugh'), '😆');
+ assert.strictEqual(await reactionService.normalize('laugh'), '😆');
});
test('既存のリアクションは絵文字化する hmm', async () => {
- assert.strictEqual(await reactionService.toDbReaction('hmm'), '🤔');
+ assert.strictEqual(await reactionService.normalize('hmm'), '🤔');
});
test('既存のリアクションは絵文字化する surprise', async () => {
- assert.strictEqual(await reactionService.toDbReaction('surprise'), '😮');
+ assert.strictEqual(await reactionService.normalize('surprise'), '😮');
});
test('既存のリアクションは絵文字化する congrats', async () => {
- assert.strictEqual(await reactionService.toDbReaction('congrats'), '🎉');
+ assert.strictEqual(await reactionService.normalize('congrats'), '🎉');
});
test('既存のリアクションは絵文字化する angry', async () => {
- assert.strictEqual(await reactionService.toDbReaction('angry'), '💢');
+ assert.strictEqual(await reactionService.normalize('angry'), '💢');
});
test('既存のリアクションは絵文字化する confused', async () => {
- assert.strictEqual(await reactionService.toDbReaction('confused'), '😥');
+ assert.strictEqual(await reactionService.normalize('confused'), '😥');
});
test('既存のリアクションは絵文字化する rip', async () => {
- assert.strictEqual(await reactionService.toDbReaction('rip'), '😇');
+ assert.strictEqual(await reactionService.normalize('rip'), '😇');
});
test('既存のリアクションは絵文字化する star', async () => {
- assert.strictEqual(await reactionService.toDbReaction('star'), '⭐');
+ assert.strictEqual(await reactionService.normalize('star'), '⭐');
});
test('異体字セレクタ除去', async () => {
- assert.strictEqual(await reactionService.toDbReaction('㊗️'), '㊗');
+ assert.strictEqual(await reactionService.normalize('㊗️'), '㊗');
});
test('異体字セレクタ除去 必要なし', async () => {
- assert.strictEqual(await reactionService.toDbReaction('㊗'), '㊗');
- });
-
- test('fallback - undefined', async () => {
- assert.strictEqual(await reactionService.toDbReaction(undefined), '❤');
+ assert.strictEqual(await reactionService.normalize('㊗'), '㊗');
});
test('fallback - null', async () => {
- assert.strictEqual(await reactionService.toDbReaction(null), '❤');
+ assert.strictEqual(await reactionService.normalize(null), '❤');
});
test('fallback - empty', async () => {
- assert.strictEqual(await reactionService.toDbReaction(''), '❤');
+ assert.strictEqual(await reactionService.normalize(''), '❤');
});
test('fallback - unknown', async () => {
- assert.strictEqual(await reactionService.toDbReaction('unknown'), '❤');
+ assert.strictEqual(await reactionService.normalize('unknown'), '❤');
});
});
});
diff --git a/packages/backend/test/utils.ts b/packages/backend/test/utils.ts
index 809ed2c66c..22f7d81e4e 100644
--- a/packages/backend/test/utils.ts
+++ b/packages/backend/test/utils.ts
@@ -124,6 +124,13 @@ export const react = async (user: any, note: any, reaction: string): Promise<any
}, user);
};
+export const userList = async (user: any, userList: any = {}): Promise<any> => {
+ const res = await api('users/lists/create', {
+ name: 'test',
+ }, user);
+ return res.body;
+};
+
export const page = async (user: any, page: any = {}): Promise<any> => {
const res = await api('pages/create', {
alignCenter: false,
@@ -380,8 +387,98 @@ export const simpleGet = async (path: string, accept = '*/*', cookie: any = unde
};
};
+/**
+ * あるAPIエンドポイントのPaginationが複数の条件で一貫した挙動であることをテストします。
+ * (sinceId, untilId, sinceDate, untilDate, offset, limit)
+ * @param expected 期待値となるEntityの並び(例:Note[])昇順降順が一致している必要がある
+ * @param fetchEntities Entity[]を返却するテスト対象のAPIを呼び出す関数
+ * @param offsetBy 何をキーとしてPaginationするか。
+ * @param ordering 昇順・降順
+ */
+export async function testPaginationConsistency<Entity extends { id: string, createdAt?: string }>(
+ expected: Entity[],
+ fetchEntities: (paginationParam: {
+ limit?: number,
+ offset?: number,
+ sinceId?: string,
+ untilId?: string,
+ sinceDate?: number,
+ untilDate?: number,
+ }) => Promise<Entity[]>,
+ offsetBy: 'offset' | 'id' | 'createdAt' = 'id',
+ ordering: 'desc' | 'asc' = 'desc'): Promise<void> {
+ const rangeToParam = (p: { limit?: number, until?: Entity, since?: Entity }): object => {
+ if (offsetBy === 'id') {
+ return { limit: p.limit, sinceId: p.since?.id, untilId: p.until?.id };
+ } else {
+ const sinceDate = p.since?.createdAt !== undefined ? new Date(p.since.createdAt).getTime() : undefined;
+ const untilDate = p.until?.createdAt !== undefined ? new Date(p.until.createdAt).getTime() : undefined;
+ return { limit: p.limit, sinceDate, untilDate };
+ }
+ };
+
+ for (const limit of [1, 5, 10, 100, undefined]) {
+ // 1. sinceId/DateとuntilId/Dateで両端を指定して取得した結果が期待通りになっていること
+ if (ordering === 'desc') {
+ const end = expected[expected.length - 1];
+ let last = await fetchEntities(rangeToParam({ limit, since: end }));
+ const actual: Entity[] = [];
+ while (last.length !== 0) {
+ actual.push(...last);
+ last = await fetchEntities(rangeToParam({ limit, until: last[last.length - 1], since: end }));
+ }
+ actual.push(end);
+ assert.deepStrictEqual(
+ actual.map(({ id, createdAt }) => id + ':' + createdAt),
+ expected.map(({ id, createdAt }) => id + ':' + createdAt));
+ }
+
+ // 2. sinceId/Date指定+limitで取得してつなぎ合わせた結果が期待通りになっていること
+ if (ordering === 'asc') {
+ // 昇順にしたときの先頭(一番古いもの)をもってくる(expected[1]を基準に降順にして0番目)
+ let last = await fetchEntities({ limit: 1, untilId: expected[1].id });
+ const actual: Entity[] = [];
+ while (last.length !== 0) {
+ actual.push(...last);
+ last = await fetchEntities(rangeToParam({ limit, since: last[last.length - 1] }));
+ }
+ assert.deepStrictEqual(
+ actual.map(({ id, createdAt }) => id + ':' + createdAt),
+ expected.map(({ id, createdAt }) => id + ':' + createdAt));
+ }
+
+ // 3. untilId指定+limitで取得してつなぎ合わせた結果が期待通りになっていること
+ if (ordering === 'desc') {
+ let last = await fetchEntities({ limit });
+ const actual: Entity[] = [];
+ while (last.length !== 0) {
+ actual.push(...last);
+ last = await fetchEntities(rangeToParam({ limit, until: last[last.length - 1] }));
+ }
+ assert.deepStrictEqual(
+ actual.map(({ id, createdAt }) => id + ':' + createdAt),
+ expected.map(({ id, createdAt }) => id + ':' + createdAt));
+ }
+
+ // 4. offset指定+limitで取得してつなぎ合わせた結果が期待通りになっていること
+ if (offsetBy === 'offset') {
+ let last = await fetchEntities({ limit, offset: 0 });
+ let offset = limit ?? 10;
+ const actual: Entity[] = [];
+ while (last.length !== 0) {
+ actual.push(...last);
+ last = await fetchEntities({ limit, offset });
+ offset += limit ?? 10;
+ }
+ assert.deepStrictEqual(
+ actual.map(({ id, createdAt }) => id + ':' + createdAt),
+ expected.map(({ id, createdAt }) => id + ':' + createdAt));
+ }
+ }
+}
+
export async function initTestDb(justBorrow = false, initEntities?: any[]) {
- if (process.env.NODE_ENV !== 'test') throw 'NODE_ENV is not a test';
+ if (process.env.NODE_ENV !== 'test') throw new Error('NODE_ENV is not a test');
const db = new DataSource({
type: 'postgres',
diff --git a/packages/frontend/.eslintrc.js b/packages/frontend/.eslintrc.js
index e8e0e57d2a..24c3ad4b83 100644
--- a/packages/frontend/.eslintrc.js
+++ b/packages/frontend/.eslintrc.js
@@ -56,14 +56,15 @@ module.exports = {
'vue/require-v-for-key': 'warn',
'vue/no-unused-components': 'warn',
'vue/no-unused-vars': 'warn',
+ 'vue/no-dupe-keys': 'warn',
'vue/valid-v-for': 'warn',
'vue/return-in-computed-property': 'warn',
'vue/no-setup-props-destructure': 'warn',
'vue/max-attributes-per-line': 'off',
'vue/html-self-closing': 'off',
'vue/singleline-html-element-content-newline': 'off',
- // (vue/vue3-recommended disabled the autofix for Vue 2 compatibility)
- 'vue/v-on-event-hyphenation': ['warn', 'always', { autofix: true }],
+ 'vue/v-on-event-hyphenation': ['error', 'never', { autofix: true }],
+ 'vue/attribute-hyphenation': ['error', 'never'],
},
globals: {
// Node.js
diff --git a/packages/frontend/.storybook/generate.tsx b/packages/frontend/.storybook/generate.tsx
index 7c51d4c00c..f442422109 100644
--- a/packages/frontend/.storybook/generate.tsx
+++ b/packages/frontend/.storybook/generate.tsx
@@ -397,6 +397,7 @@ function toStories(component: string): string {
Promise.all([
glob('src/components/global/*.vue'),
glob('src/components/Mk{A,B}*.vue'),
+ glob('src/components/MkDigitalClock.vue'),
glob('src/components/MkGalleryPostPreview.vue'),
glob('src/components/MkSignupServerRules.vue'),
glob('src/components/MkUserSetupDialog.vue'),
diff --git a/packages/frontend/.storybook/preview-head.html b/packages/frontend/.storybook/preview-head.html
index ab694f64fb..f6a9a4875d 100644
--- a/packages/frontend/.storybook/preview-head.html
+++ b/packages/frontend/.storybook/preview-head.html
@@ -1,6 +1,6 @@
<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/@tabler/icons-webfont@2.21.0/tabler-icons.min.css">
<link rel="stylesheet" href="https://unpkg.com/@fontsource/m-plus-rounded-1c/index.css">
<style>
html {
diff --git a/packages/frontend/lib/rollup-plugin-unwind-css-module-class-name.test.ts b/packages/frontend/lib/rollup-plugin-unwind-css-module-class-name.test.ts
new file mode 100644
index 0000000000..3929bf0608
--- /dev/null
+++ b/packages/frontend/lib/rollup-plugin-unwind-css-module-class-name.test.ts
@@ -0,0 +1,597 @@
+import { parse } from 'acorn';
+import { generate } from 'astring';
+import { describe, expect, it } from 'vitest';
+import { normalizeClass, unwindCssModuleClassName } from './rollup-plugin-unwind-css-module-class-name';
+import type * as estree from 'estree';
+
+function parseExpression(code: string): estree.Expression {
+ const program = parse(code, { ecmaVersion: 'latest', sourceType: 'module' }) as unknown as estree.Program;
+ const statement = program.body[0] as estree.ExpressionStatement;
+ return statement.expression;
+}
+
+describe(normalizeClass.name, () => {
+ it('should normalize string', () => {
+ expect(normalizeClass(parseExpression('"a b c"'))).toBe('a b c');
+ });
+ it('should trim redundant spaces', () => {
+ expect(normalizeClass(parseExpression('" a b c "'))).toBe('a b c');
+ });
+ it('should ignore undefined', () => {
+ expect(normalizeClass(parseExpression('undefined'))).toBe('');
+ });
+ it('should ignore non string literals', () => {
+ expect(normalizeClass(parseExpression('0'))).toBe('');
+ expect(normalizeClass(parseExpression('true'))).toBe('');
+ expect(normalizeClass(parseExpression('null'))).toBe('');
+ expect(normalizeClass(parseExpression('/I.D/'))).toBe('');
+ });
+ it('should not normalize identifiers', () => {
+ expect(normalizeClass(parseExpression('EScape'))).toBeNull();
+ });
+ it('should normalize recursively array', () => {
+ expect(normalizeClass(parseExpression('["from", ...["Utopia"]]'))).toBe('from Utopia');
+ expect(normalizeClass(parseExpression('["from", ...[Utopia]]'))).toBeNull();
+ });
+ it('should normalize recursively template literal', () => {
+ expect(normalizeClass(parseExpression('`name ${"shiho"} code ${33}`'))).toBe('name shiho code');
+ expect(normalizeClass(parseExpression('`name ${shiho.name} code ${33}`'))).toBeNull();
+ });
+ it('should normalize recursively binary expression', () => {
+ expect(normalizeClass(parseExpression('"mirage" + "mirror"'))).toBe('miragemirror');
+ expect(normalizeClass(parseExpression('"mirage" + mirror'))).toBeNull();
+ });
+ it('should normalize recursively object expression', () => {
+ expect(normalizeClass(parseExpression('({ a: true, b: "c" })'))).toBe('a b');
+ expect(normalizeClass(parseExpression('({ a: false, b: "c" })'))).toBe('b');
+ expect(normalizeClass(parseExpression('({ a: true, b: c })'))).toBeNull();
+ expect(normalizeClass(parseExpression('({ a: true, b: "c", ...({ d: true }) })'))).toBe('a b d');
+ expect(normalizeClass(parseExpression('({ a: true, [b]: "c" })'))).toBeNull();
+ expect(normalizeClass(parseExpression('({ a: true, b: false, c: !false, d: !!0 })'))).toBe('a c');
+ });
+});
+
+it('Composition API (standard)', () => {
+ const ast = parse(`
+import { c as api, d as defaultStore, i as i18n, aD as notePage, bN as ImgWithBlurhash, bY as getStaticImageUrl, _ as _export_sfc } from './app-!~{001}~.js';
+import { M as MkContainer } from './MkContainer-!~{03M}~.js';
+import { b as defineComponent, a as ref, e as onMounted, z as resolveComponent, g as openBlock, h as createBlock, i as withCtx, K as createTextVNode, E as toDisplayString, u as unref, l as createBaseVNode, q as normalizeClass, B as createCommentVNode, k as createElementBlock, F as Fragment, C as renderList, A as createVNode } from './vue-!~{002}~.js';
+import './photoswipe-!~{003}~.js';
+
+const _hoisted_1 = /* @__PURE__ */ createBaseVNode("i", { class: "ti ti-photo" }, null, -1);
+const _sfc_main = /* @__PURE__ */ defineComponent({
+ __name: "index.photos",
+ props: {
+ user: {}
+ },
+ setup(__props) {
+ const props = __props;
+ let fetching = ref(true);
+ let images = ref([]);
+ function thumbnail(image) {
+ return defaultStore.state.disableShowingAnimatedImages ? getStaticImageUrl(image.url) : image.thumbnailUrl;
+ }
+ onMounted(() => {
+ const image = [
+ "image/jpeg",
+ "image/webp",
+ "image/avif",
+ "image/png",
+ "image/gif",
+ "image/apng",
+ "image/vnd.mozilla.apng"
+ ];
+ api("users/notes", {
+ userId: props.user.id,
+ fileType: image,
+ excludeNsfw: defaultStore.state.nsfw !== "ignore",
+ limit: 10
+ }).then((notes) => {
+ for (const note of notes) {
+ for (const file of note.files) {
+ images.value.push({
+ note,
+ file
+ });
+ }
+ }
+ fetching.value = false;
+ });
+ });
+ return (_ctx, _cache) => {
+ const _component_MkLoading = resolveComponent("MkLoading");
+ const _component_MkA = resolveComponent("MkA");
+ return openBlock(), createBlock(MkContainer, {
+ "max-height": 300,
+ foldable: true
+ }, {
+ icon: withCtx(() => [
+ _hoisted_1
+ ]),
+ header: withCtx(() => [
+ createTextVNode(toDisplayString(unref(i18n).ts.images), 1)
+ ]),
+ default: withCtx(() => [
+ createBaseVNode("div", {
+ class: normalizeClass(_ctx.$style.root)
+ }, [
+ unref(fetching) ? (openBlock(), createBlock(_component_MkLoading, { key: 0 })) : createCommentVNode("", true),
+ !unref(fetching) && unref(images).length > 0 ? (openBlock(), createElementBlock("div", {
+ key: 1,
+ class: normalizeClass(_ctx.$style.stream)
+ }, [
+ (openBlock(true), createElementBlock(Fragment, null, renderList(unref(images), (image) => {
+ return openBlock(), createBlock(_component_MkA, {
+ key: image.note.id + image.file.id,
+ class: normalizeClass(_ctx.$style.img),
+ to: unref(notePage)(image.note)
+ }, {
+ default: withCtx(() => [
+ createVNode(ImgWithBlurhash, {
+ hash: image.file.blurhash,
+ src: thumbnail(image.file),
+ title: image.file.name
+ }, null, 8, ["hash", "src", "title"])
+ ]),
+ _: 2
+ }, 1032, ["class", "to"]);
+ }), 128))
+ ], 2)) : createCommentVNode("", true),
+ !unref(fetching) && unref(images).length == 0 ? (openBlock(), createElementBlock("p", {
+ key: 2,
+ class: normalizeClass(_ctx.$style.empty)
+ }, toDisplayString(unref(i18n).ts.nothing), 3)) : createCommentVNode("", true)
+ ], 2)
+ ]),
+ _: 1
+ });
+ };
+ }
+});
+
+const root = "xenMW";
+const stream = "xaZzf";
+const img = "xtA8t";
+const empty = "xhYKj";
+const style0 = {
+ root: root,
+ stream: stream,
+ img: img,
+ empty: empty
+};
+
+const cssModules = {
+ "$style": style0
+};
+const index_photos = /* @__PURE__ */ _export_sfc(_sfc_main, [["__cssModules", cssModules]]);
+
+export { index_photos as default };
+`.slice(1), { ecmaVersion: 'latest', sourceType: 'module' });
+ unwindCssModuleClassName(ast);
+ expect(generate(ast)).toBe(`
+import {c as api, d as defaultStore, i as i18n, aD as notePage, bN as ImgWithBlurhash, bY as getStaticImageUrl, _ as _export_sfc} from './app-!~{001}~.js';
+import {M as MkContainer} from './MkContainer-!~{03M}~.js';
+import {b as defineComponent, a as ref, e as onMounted, z as resolveComponent, g as openBlock, h as createBlock, i as withCtx, K as createTextVNode, E as toDisplayString, u as unref, l as createBaseVNode, q as normalizeClass, B as createCommentVNode, k as createElementBlock, F as Fragment, C as renderList, A as createVNode} from './vue-!~{002}~.js';
+import './photoswipe-!~{003}~.js';
+const _hoisted_1 = createBaseVNode("i", {
+ class: "ti ti-photo"
+}, null, -1);
+const _sfc_main = defineComponent({
+ __name: "index.photos",
+ props: {
+ user: {}
+ },
+ setup(__props) {
+ const props = __props;
+ let fetching = ref(true);
+ let images = ref([]);
+ function thumbnail(image) {
+ return defaultStore.state.disableShowingAnimatedImages ? getStaticImageUrl(image.url) : image.thumbnailUrl;
+ }
+ onMounted(() => {
+ const image = ["image/jpeg", "image/webp", "image/avif", "image/png", "image/gif", "image/apng", "image/vnd.mozilla.apng"];
+ api("users/notes", {
+ userId: props.user.id,
+ fileType: image,
+ excludeNsfw: defaultStore.state.nsfw !== "ignore",
+ limit: 10
+ }).then(notes => {
+ for (const note of notes) {
+ for (const file of note.files) {
+ images.value.push({
+ note,
+ file
+ });
+ }
+ }
+ fetching.value = false;
+ });
+ });
+ return (_ctx, _cache) => {
+ const _component_MkLoading = resolveComponent("MkLoading");
+ const _component_MkA = resolveComponent("MkA");
+ return (openBlock(), createBlock(MkContainer, {
+ "max-height": 300,
+ foldable: true
+ }, {
+ icon: withCtx(() => [_hoisted_1]),
+ header: withCtx(() => [createTextVNode(toDisplayString(unref(i18n).ts.images), 1)]),
+ default: withCtx(() => [createBaseVNode("div", {
+ class: "xenMW"
+ }, [unref(fetching) ? (openBlock(), createBlock(_component_MkLoading, {
+ key: 0
+ })) : createCommentVNode("", true), !unref(fetching) && unref(images).length > 0 ? (openBlock(), createElementBlock("div", {
+ key: 1,
+ class: "xaZzf"
+ }, [(openBlock(true), createElementBlock(Fragment, null, renderList(unref(images), image => {
+ return (openBlock(), createBlock(_component_MkA, {
+ key: image.note.id + image.file.id,
+ class: "xtA8t",
+ to: unref(notePage)(image.note)
+ }, {
+ default: withCtx(() => [createVNode(ImgWithBlurhash, {
+ hash: image.file.blurhash,
+ src: thumbnail(image.file),
+ title: image.file.name
+ }, null, 8, ["hash", "src", "title"])]),
+ _: 2
+ }, 1032, ["class", "to"]));
+ }), 128))], 2)) : createCommentVNode("", true), !unref(fetching) && unref(images).length == 0 ? (openBlock(), createElementBlock("p", {
+ key: 2,
+ class: "xhYKj"
+ }, toDisplayString(unref(i18n).ts.nothing), 3)) : createCommentVNode("", true)], 2)]),
+ _: 1
+ }));
+ };
+ }
+});
+const root = "xenMW";
+const stream = "xaZzf";
+const img = "xtA8t";
+const empty = "xhYKj";
+const style0 = {
+ root: root,
+ stream: stream,
+ img: img,
+ empty: empty
+};
+const cssModules = {
+ "$style": style0
+};
+const index_photos = _sfc_main;
+export {index_photos as default};
+`.slice(1));
+});
+
+it('Composition API (with `useCssModule()`)', () => {
+ const ast = parse(`
+import { a7 as getCurrentInstance, b as defineComponent, G as useCssModule, a1 as h, H as TransitionGroup } from './!~{002}~.js';
+import { d as defaultStore, aK as toast, b5 as MkAd, i as i18n, _ as _export_sfc } from './app-!~{001}~.js';
+
+function isDebuggerEnabled(id) {
+ try {
+ return localStorage.getItem(\`DEBUG_\${id}\`) !== null;
+ } catch {
+ return false;
+ }
+}
+function stackTraceInstances() {
+ let instance = getCurrentInstance();
+ const stack = [];
+ while (instance) {
+ stack.push(instance);
+ instance = instance.parent;
+ }
+ return stack;
+}
+
+const _sfc_main = defineComponent({
+ props: {
+ items: {
+ type: Array,
+ required: true
+ },
+ direction: {
+ type: String,
+ required: false,
+ default: "down"
+ },
+ reversed: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ noGap: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ ad: {
+ type: Boolean,
+ required: false,
+ default: false
+ }
+ },
+ setup(props, { slots, expose }) {
+ const $style = useCssModule();
+ function getDateText(time) {
+ const date = new Date(time).getDate();
+ const month = new Date(time).getMonth() + 1;
+ return i18n.t("monthAndDay", {
+ month: month.toString(),
+ day: date.toString()
+ });
+ }
+ if (props.items.length === 0)
+ return;
+ const renderChildrenImpl = () => props.items.map((item, i) => {
+ if (!slots || !slots.default)
+ return;
+ const el = slots.default({
+ item
+ })[0];
+ if (el.key == null && item.id)
+ el.key = item.id;
+ if (i !== props.items.length - 1 && new Date(item.createdAt).getDate() !== new Date(props.items[i + 1].createdAt).getDate()) {
+ const separator = h("div", {
+ class: $style["separator"],
+ key: item.id + ":separator"
+ }, h("p", {
+ class: $style["date"]
+ }, [
+ h("span", {
+ class: $style["date-1"]
+ }, [
+ h("i", {
+ class: \`ti ti-chevron-up \${$style["date-1-icon"]}\`
+ }),
+ getDateText(item.createdAt)
+ ]),
+ h("span", {
+ class: $style["date-2"]
+ }, [
+ getDateText(props.items[i + 1].createdAt),
+ h("i", {
+ class: \`ti ti-chevron-down \${$style["date-2-icon"]}\`
+ })
+ ])
+ ]));
+ return [el, separator];
+ } else {
+ if (props.ad && item._shouldInsertAd_) {
+ return [h(MkAd, {
+ key: item.id + ":ad",
+ prefer: ["horizontal", "horizontal-big"]
+ }), el];
+ } else {
+ return el;
+ }
+ }
+ });
+ const renderChildren = () => {
+ const children = renderChildrenImpl();
+ if (isDebuggerEnabled(6864)) {
+ const nodes = children.flatMap((node) => node ?? []);
+ const keys = new Set(nodes.map((node) => node.key));
+ if (keys.size !== nodes.length) {
+ const id = crypto.randomUUID();
+ const instances = stackTraceInstances();
+ toast(instances.reduce((a, c) => \`\${a} at \${c.type.name}\`, \`[DEBUG_6864 (\${id})]: \${nodes.length - keys.size} duplicated keys found\`));
+ console.warn({ id, debugId: 6864, stack: instances });
+ }
+ }
+ return children;
+ };
+ function onBeforeLeave(el) {
+ el.style.top = \`\${el.offsetTop}px\`;
+ el.style.left = \`\${el.offsetLeft}px\`;
+ }
+ function onLeaveCanceled(el) {
+ el.style.top = "";
+ el.style.left = "";
+ }
+ return () => h(
+ defaultStore.state.animation ? TransitionGroup : "div",
+ {
+ class: {
+ [$style["date-separated-list"]]: true,
+ [$style["date-separated-list-nogap"]]: props.noGap,
+ [$style["reversed"]]: props.reversed,
+ [$style["direction-down"]]: props.direction === "down",
+ [$style["direction-up"]]: props.direction === "up"
+ },
+ ...defaultStore.state.animation ? {
+ name: "list",
+ tag: "div",
+ onBeforeLeave,
+ onLeaveCanceled
+ } : {}
+ },
+ { default: renderChildren }
+ );
+ }
+});
+
+const reversed = "xxiZh";
+const separator = "xxeDx";
+const date = "xxawD";
+const style0 = {
+ "date-separated-list": "xfKPa",
+ "date-separated-list-nogap": "xf9zr",
+ "direction-up": "x7AeO",
+ "direction-down": "xBIqc",
+ reversed: reversed,
+ separator: separator,
+ date: date,
+ "date-1": "xwtmh",
+ "date-1-icon": "xsNPa",
+ "date-2": "x1xvw",
+ "date-2-icon": "x9ZiG"
+};
+
+const cssModules = {
+ "$style": style0
+};
+const MkDateSeparatedList = /* @__PURE__ */ _export_sfc(_sfc_main, [["__cssModules", cssModules]]);
+
+export { MkDateSeparatedList as M };
+`.slice(1), { ecmaVersion: 'latest', sourceType: 'module' });
+ unwindCssModuleClassName(ast);
+ expect(generate(ast)).toBe(`
+import {a7 as getCurrentInstance, b as defineComponent, G as useCssModule, a1 as h, H as TransitionGroup} from './!~{002}~.js';
+import {d as defaultStore, aK as toast, b5 as MkAd, i as i18n, _ as _export_sfc} from './app-!~{001}~.js';
+function isDebuggerEnabled(id) {
+ try {
+ return localStorage.getItem(\`DEBUG_\${id}\`) !== null;
+ } catch {
+ return false;
+ }
+}
+function stackTraceInstances() {
+ let instance = getCurrentInstance();
+ const stack = [];
+ while (instance) {
+ stack.push(instance);
+ instance = instance.parent;
+ }
+ return stack;
+}
+const _sfc_main = defineComponent({
+ props: {
+ items: {
+ type: Array,
+ required: true
+ },
+ direction: {
+ type: String,
+ required: false,
+ default: "down"
+ },
+ reversed: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ noGap: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ ad: {
+ type: Boolean,
+ required: false,
+ default: false
+ }
+ },
+ setup(props, {slots, expose}) {
+ const $style = useCssModule();
+ function getDateText(time) {
+ const date = new Date(time).getDate();
+ const month = new Date(time).getMonth() + 1;
+ return i18n.t("monthAndDay", {
+ month: month.toString(),
+ day: date.toString()
+ });
+ }
+ if (props.items.length === 0) return;
+ const renderChildrenImpl = () => props.items.map((item, i) => {
+ if (!slots || !slots.default) return;
+ const el = slots.default({
+ item
+ })[0];
+ if (el.key == null && item.id) el.key = item.id;
+ if (i !== props.items.length - 1 && new Date(item.createdAt).getDate() !== new Date(props.items[i + 1].createdAt).getDate()) {
+ const separator = h("div", {
+ class: $style["separator"],
+ key: item.id + ":separator"
+ }, h("p", {
+ class: $style["date"]
+ }, [h("span", {
+ class: $style["date-1"]
+ }, [h("i", {
+ class: \`ti ti-chevron-up \${$style["date-1-icon"]}\`
+ }), getDateText(item.createdAt)]), h("span", {
+ class: $style["date-2"]
+ }, [getDateText(props.items[i + 1].createdAt), h("i", {
+ class: \`ti ti-chevron-down \${$style["date-2-icon"]}\`
+ })])]));
+ return [el, separator];
+ } else {
+ if (props.ad && item._shouldInsertAd_) {
+ return [h(MkAd, {
+ key: item.id + ":ad",
+ prefer: ["horizontal", "horizontal-big"]
+ }), el];
+ } else {
+ return el;
+ }
+ }
+ });
+ const renderChildren = () => {
+ const children = renderChildrenImpl();
+ if (isDebuggerEnabled(6864)) {
+ const nodes = children.flatMap(node => node ?? []);
+ const keys = new Set(nodes.map(node => node.key));
+ if (keys.size !== nodes.length) {
+ const id = crypto.randomUUID();
+ const instances = stackTraceInstances();
+ toast(instances.reduce((a, c) => \`\${a} at \${c.type.name}\`, \`[DEBUG_6864 (\${id})]: \${nodes.length - keys.size} duplicated keys found\`));
+ console.warn({
+ id,
+ debugId: 6864,
+ stack: instances
+ });
+ }
+ }
+ return children;
+ };
+ function onBeforeLeave(el) {
+ el.style.top = \`\${el.offsetTop}px\`;
+ el.style.left = \`\${el.offsetLeft}px\`;
+ }
+ function onLeaveCanceled(el) {
+ el.style.top = "";
+ el.style.left = "";
+ }
+ return () => h(defaultStore.state.animation ? TransitionGroup : "div", {
+ class: {
+ [$style["date-separated-list"]]: true,
+ [$style["date-separated-list-nogap"]]: props.noGap,
+ [$style["reversed"]]: props.reversed,
+ [$style["direction-down"]]: props.direction === "down",
+ [$style["direction-up"]]: props.direction === "up"
+ },
+ ...defaultStore.state.animation ? {
+ name: "list",
+ tag: "div",
+ onBeforeLeave,
+ onLeaveCanceled
+ } : {}
+ }, {
+ default: renderChildren
+ });
+ }
+});
+const reversed = "xxiZh";
+const separator = "xxeDx";
+const date = "xxawD";
+const style0 = {
+ "date-separated-list": "xfKPa",
+ "date-separated-list-nogap": "xf9zr",
+ "direction-up": "x7AeO",
+ "direction-down": "xBIqc",
+ reversed: reversed,
+ separator: separator,
+ date: date,
+ "date-1": "xwtmh",
+ "date-1-icon": "xsNPa",
+ "date-2": "x1xvw",
+ "date-2-icon": "x9ZiG"
+};
+const cssModules = {
+ "$style": style0
+};
+const MkDateSeparatedList = _export_sfc(_sfc_main, [["__cssModules", cssModules]]);
+export {MkDateSeparatedList as M};
+`.slice(1));
+});
diff --git a/packages/frontend/lib/rollup-plugin-unwind-css-module-class-name.ts b/packages/frontend/lib/rollup-plugin-unwind-css-module-class-name.ts
new file mode 100644
index 0000000000..a18f0d9049
--- /dev/null
+++ b/packages/frontend/lib/rollup-plugin-unwind-css-module-class-name.ts
@@ -0,0 +1,275 @@
+import { generate } from 'astring';
+import * as estree from 'estree';
+import { walk } from '../node_modules/estree-walker/src/index.js';
+import type * as estreeWalker from 'estree-walker';
+import type { Plugin } from 'vite';
+
+function isFalsyIdentifier(identifier: estree.Identifier): boolean {
+ return identifier.name === 'undefined' || identifier.name === 'NaN';
+}
+
+function normalizeClassWalker(tree: estree.Node): string | null {
+ if (tree.type === 'Identifier') return isFalsyIdentifier(tree) ? '' : null;
+ if (tree.type === 'Literal') return typeof tree.value === 'string' ? tree.value : '';
+ if (tree.type === 'BinaryExpression') {
+ if (tree.operator !== '+') return null;
+ const left = normalizeClassWalker(tree.left);
+ const right = normalizeClassWalker(tree.right);
+ if (left === null || right === null) return null;
+ return `${left}${right}`;
+ }
+ if (tree.type === 'TemplateLiteral') {
+ if (tree.expressions.some((x) => x.type !== 'Literal' && (x.type !== 'Identifier' || !isFalsyIdentifier(x)))) return null;
+ return tree.quasis.reduce((a, c, i) => {
+ const v = i === tree.quasis.length - 1 ? '' : (tree.expressions[i] as Partial<estree.Literal>).value;
+ return a + c.value.raw + (typeof v === 'string' ? v : '');
+ }, '');
+ }
+ if (tree.type === 'ArrayExpression') {
+ const values = tree.elements.map((treeNode) => {
+ if (treeNode === null) return '';
+ if (treeNode.type === 'SpreadElement') return normalizeClassWalker(treeNode.argument);
+ return normalizeClassWalker(treeNode);
+ });
+ if (values.some((x) => x === null)) return null;
+ return values.join(' ');
+ }
+ if (tree.type === 'ObjectExpression') {
+ const values = tree.properties.map((treeNode) => {
+ if (treeNode.type === 'SpreadElement') return normalizeClassWalker(treeNode.argument);
+ let x = treeNode.value;
+ let inveted = false;
+ while (x.type === 'UnaryExpression' && x.operator === '!') {
+ x = x.argument;
+ inveted = !inveted;
+ }
+ if (x.type === 'Literal') {
+ if (inveted === !x.value) {
+ return treeNode.key.type === 'Identifier' ? treeNode.computed ? null : treeNode.key.name : treeNode.key.type === 'Literal' ? treeNode.key.value : '';
+ } else {
+ return '';
+ }
+ }
+ if (x.type === 'Identifier') {
+ if (inveted !== isFalsyIdentifier(x)) {
+ return '';
+ } else {
+ return null;
+ }
+ }
+ return null;
+ });
+ if (values.some((x) => x === null)) return null;
+ return values.join(' ');
+ }
+ console.error(`Unexpected node type: ${tree.type}`);
+ return null;
+}
+
+export function normalizeClass(tree: estree.Node): string | null {
+ const walked = normalizeClassWalker(tree);
+ return walked && walked.replace(/^\s+|\s+(?=\s)|\s+$/g, '');
+}
+
+export function unwindCssModuleClassName(ast: estree.Node): void {
+ (walk as typeof estreeWalker.walk)(ast, {
+ enter(node, parent): void {
+ if (parent?.type !== 'Program') return;
+ if (node.type !== 'VariableDeclaration') return;
+ if (node.declarations.length !== 1) return;
+ if (node.declarations[0].id.type !== 'Identifier') return;
+ const name = node.declarations[0].id.name;
+ if (node.declarations[0].init?.type !== 'CallExpression') return;
+ if (node.declarations[0].init.callee.type !== 'Identifier') return;
+ if (node.declarations[0].init.callee.name !== '_export_sfc') return;
+ if (node.declarations[0].init.arguments.length !== 2) return;
+ if (node.declarations[0].init.arguments[0].type !== 'Identifier') return;
+ const ident = node.declarations[0].init.arguments[0].name;
+ if (!ident.startsWith('_sfc_main')) return;
+ if (node.declarations[0].init.arguments[1].type !== 'ArrayExpression') return;
+ if (node.declarations[0].init.arguments[1].elements.length === 0) return;
+ const __cssModulesIndex = node.declarations[0].init.arguments[1].elements.findIndex((x) => {
+ if (x?.type !== 'ArrayExpression') return false;
+ if (x.elements.length !== 2) return false;
+ if (x.elements[0]?.type !== 'Literal') return false;
+ if (x.elements[0].value !== '__cssModules') return false;
+ if (x.elements[1]?.type !== 'Identifier') return false;
+ return true;
+ });
+ if (!~__cssModulesIndex) return;
+ const cssModuleForestName = ((node.declarations[0].init.arguments[1].elements[__cssModulesIndex] as estree.ArrayExpression).elements[1] as estree.Identifier).name;
+ const cssModuleForestNode = parent.body.find((x) => {
+ if (x.type !== 'VariableDeclaration') return false;
+ if (x.declarations.length !== 1) return false;
+ if (x.declarations[0].id.type !== 'Identifier') return false;
+ if (x.declarations[0].id.name !== cssModuleForestName) return false;
+ if (x.declarations[0].init?.type !== 'ObjectExpression') return false;
+ return true;
+ }) as unknown as estree.VariableDeclaration;
+ const moduleForest = new Map((cssModuleForestNode.declarations[0].init as estree.ObjectExpression).properties.flatMap((property) => {
+ if (property.type !== 'Property') return [];
+ if (property.key.type !== 'Literal') return [];
+ if (property.value.type !== 'Identifier') return [];
+ return [[property.key.value as string, property.value.name as string]];
+ }));
+ const sfcMain = parent.body.find((x) => {
+ if (x.type !== 'VariableDeclaration') return false;
+ if (x.declarations.length !== 1) return false;
+ if (x.declarations[0].id.type !== 'Identifier') return false;
+ if (x.declarations[0].id.name !== ident) return false;
+ return true;
+ }) as unknown as estree.VariableDeclaration;
+ if (sfcMain.declarations[0].init?.type !== 'CallExpression') return;
+ if (sfcMain.declarations[0].init.callee.type !== 'Identifier') return;
+ if (sfcMain.declarations[0].init.callee.name !== 'defineComponent') return;
+ if (sfcMain.declarations[0].init.arguments.length !== 1) return;
+ if (sfcMain.declarations[0].init.arguments[0].type !== 'ObjectExpression') return;
+ const setup = sfcMain.declarations[0].init.arguments[0].properties.find((x) => {
+ if (x.type !== 'Property') return false;
+ if (x.key.type !== 'Identifier') return false;
+ if (x.key.name !== 'setup') return false;
+ return true;
+ }) as unknown as estree.Property;
+ if (setup.value.type !== 'FunctionExpression') return;
+ const render = setup.value.body.body.find((x) => {
+ if (x.type !== 'ReturnStatement') return false;
+ return true;
+ }) as unknown as estree.ReturnStatement;
+ if (render.argument?.type !== 'ArrowFunctionExpression') return;
+ if (render.argument.params.length !== 2) return;
+ const ctx = render.argument.params[0];
+ if (ctx.type !== 'Identifier') return;
+ if (ctx.name !== '_ctx') return;
+ if (render.argument.body.type !== 'BlockStatement') return;
+ for (const [key, value] of moduleForest) {
+ const cssModuleTreeNode = parent.body.find((x) => {
+ if (x.type !== 'VariableDeclaration') return false;
+ if (x.declarations.length !== 1) return false;
+ if (x.declarations[0].id.type !== 'Identifier') return false;
+ if (x.declarations[0].id.name !== value) return false;
+ return true;
+ }) as unknown as estree.VariableDeclaration;
+ if (cssModuleTreeNode.declarations[0].init?.type !== 'ObjectExpression') return;
+ const moduleTree = new Map(cssModuleTreeNode.declarations[0].init.properties.flatMap((property) => {
+ if (property.type !== 'Property') return [];
+ const actualKey = property.key.type === 'Identifier' ? property.key.name : property.key.type === 'Literal' ? property.key.value : null;
+ if (typeof actualKey !== 'string') return [];
+ if (property.value.type === 'Literal') return [[actualKey, property.value.value as string]];
+ if (property.value.type !== 'Identifier') return [];
+ const labelledValue = property.value.name;
+ const actualValue = parent.body.find((x) => {
+ if (x.type !== 'VariableDeclaration') return false;
+ if (x.declarations.length !== 1) return false;
+ if (x.declarations[0].id.type !== 'Identifier') return false;
+ if (x.declarations[0].id.name !== labelledValue) return false;
+ return true;
+ }) as unknown as estree.VariableDeclaration;
+ if (actualValue.declarations[0].init?.type !== 'Literal') return [];
+ return [[actualKey, actualValue.declarations[0].init.value as string]];
+ }));
+ (walk as typeof estreeWalker.walk)(render.argument.body, {
+ enter(childNode) {
+ if (childNode.type !== 'MemberExpression') return;
+ if (childNode.object.type !== 'MemberExpression') return;
+ if (childNode.object.object.type !== 'Identifier') return;
+ if (childNode.object.object.name !== ctx.name) return;
+ if (childNode.object.property.type !== 'Identifier') return;
+ if (childNode.object.property.name !== key) return;
+ if (childNode.property.type !== 'Identifier') return;
+ const actualValue = moduleTree.get(childNode.property.name);
+ if (actualValue === undefined) return;
+ this.replace({
+ type: 'Literal',
+ value: actualValue,
+ });
+ },
+ });
+ (walk as typeof estreeWalker.walk)(render.argument.body, {
+ enter(childNode) {
+ if (childNode.type !== 'MemberExpression') return;
+ if (childNode.object.type !== 'MemberExpression') return;
+ if (childNode.object.object.type !== 'Identifier') return;
+ if (childNode.object.object.name !== ctx.name) return;
+ if (childNode.object.property.type !== 'Identifier') return;
+ if (childNode.object.property.name !== key) return;
+ if (childNode.property.type !== 'Identifier') return;
+ console.error(`Undefined style detected: ${key}.${childNode.property.name} (in ${name})`);
+ this.replace({
+ type: 'Identifier',
+ name: 'undefined',
+ });
+ },
+ });
+ (walk as typeof estreeWalker.walk)(render.argument.body, {
+ enter(childNode) {
+ if (childNode.type !== 'CallExpression') return;
+ if (childNode.callee.type !== 'Identifier') return;
+ if (childNode.callee.name !== 'normalizeClass') return;
+ if (childNode.arguments.length !== 1) return;
+ const normalized = normalizeClass(childNode.arguments[0]);
+ if (normalized === null) return;
+ this.replace({
+ type: 'Literal',
+ value: normalized,
+ });
+ },
+ });
+ }
+ if (node.declarations[0].init.arguments[1].elements.length === 1) {
+ this.replace({
+ type: 'VariableDeclaration',
+ declarations: [{
+ type: 'VariableDeclarator',
+ id: {
+ type: 'Identifier',
+ name: node.declarations[0].id.name,
+ },
+ init: {
+ type: 'Identifier',
+ name: ident,
+ },
+ }],
+ kind: 'const',
+ });
+ } else {
+ this.replace({
+ type: 'VariableDeclaration',
+ declarations: [{
+ type: 'VariableDeclarator',
+ id: {
+ type: 'Identifier',
+ name: node.declarations[0].id.name,
+ },
+ init: {
+ type: 'CallExpression',
+ callee: {
+ type: 'Identifier',
+ name: '_export_sfc',
+ },
+ arguments: [{
+ type: 'Identifier',
+ name: ident,
+ }, {
+ type: 'ArrayExpression',
+ elements: node.declarations[0].init.arguments[1].elements.slice(0, __cssModulesIndex).concat(node.declarations[0].init.arguments[1].elements.slice(__cssModulesIndex + 1)),
+ }],
+ },
+ }],
+ kind: 'const',
+ });
+ }
+ },
+ });
+}
+
+// eslint-disable-next-line import/no-default-export
+export default function pluginUnwindCssModuleClassName(): Plugin {
+ return {
+ name: 'UnwindCssModuleClassName',
+ renderChunk(code): { code: string } {
+ const ast = this.parse(code) as unknown as estree.Node;
+ unwindCssModuleClassName(ast);
+ return { code: generate(ast) };
+ },
+ };
+}
diff --git a/packages/frontend/package.json b/packages/frontend/package.json
index 5b4004d8e3..506d187901 100644
--- a/packages/frontend/package.json
+++ b/packages/frontend/package.json
@@ -19,26 +19,28 @@
"@rollup/plugin-json": "6.0.0",
"@rollup/plugin-replace": "5.0.2",
"@rollup/pluginutils": "5.0.2",
- "@syuilo/aiscript": "0.13.2",
- "@tabler/icons-webfont": "2.17.0",
- "@vitejs/plugin-vue": "4.2.2",
- "@vue-macros/reactivity-transform": "0.3.6",
- "@vue/compiler-sfc": "3.3.1",
- "autosize": "5.0.2",
- "blurhash": "2.0.5",
- "broadcast-channel": "4.20.2",
+ "@syuilo/aiscript": "0.13.3",
+ "@tabler/icons-webfont": "2.21.0",
+ "@vitejs/plugin-vue": "4.2.3",
+ "@vue-macros/reactivity-transform": "0.3.9",
+ "@vue/compiler-sfc": "3.3.4",
+ "astring": "1.8.6",
+ "autosize": "6.0.1",
+ "broadcast-channel": "5.1.0",
"browser-image-resizer": "github:misskey-dev/browser-image-resizer#v2.2.1-misskey.3",
+ "buraha": "github:misskey-dev/buraha",
"canvas-confetti": "1.6.0",
"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.4",
+ "chromatic": "6.18.0",
"compare-versions": "5.0.3",
"cropperjs": "2.0.0-beta.2",
"date-fns": "2.30.0",
"escape-regexp": "0.0.1",
+ "estree-walker": "^3.0.3",
"eventemitter3": "5.0.1",
"gsap": "3.11.5",
"idb-keyval": "6.2.1",
@@ -53,7 +55,7 @@
"punycode": "2.3.0",
"querystring": "0.2.1",
"rndstr": "1.0.0",
- "rollup": "3.21.6",
+ "rollup": "3.23.0",
"s-age": "1.1.2",
"sanitize-html": "2.10.0",
"sass": "1.62.1",
@@ -61,71 +63,70 @@
"strict-event-emitter-types": "2.0.0",
"syuilo-password-strength": "0.0.1",
"textarea-caret": "3.1.0",
- "three": "0.151.3",
+ "three": "0.153.0",
"throttle-debounce": "5.0.0",
"tinycolor2": "1.6.0",
"tsc-alias": "1.8.6",
"tsconfig-paths": "4.2.0",
"twemoji-parser": "14.0.0",
- "typescript": "5.0.4",
+ "typescript": "5.1.3",
"uuid": "9.0.0",
"vanilla-tilt": "1.8.0",
- "vite": "4.3.5",
- "vue": "3.3.1",
- "vue-plyr": "7.0.0",
+ "vite": "4.3.9",
+ "vue": "3.3.4",
"vue-prism-editor": "2.0.0-alpha.2",
"vuedraggable": "next"
},
"devDependencies": {
- "@storybook/addon-actions": "7.0.10",
- "@storybook/addon-essentials": "7.0.10",
- "@storybook/addon-interactions": "7.0.10",
- "@storybook/addon-links": "7.0.10",
- "@storybook/addon-storysource": "7.0.10",
- "@storybook/addons": "7.0.10",
- "@storybook/blocks": "7.0.10",
- "@storybook/core-events": "7.0.10",
+ "@storybook/addon-actions": "7.0.18",
+ "@storybook/addon-essentials": "7.0.18",
+ "@storybook/addon-interactions": "7.0.18",
+ "@storybook/addon-links": "7.0.18",
+ "@storybook/addon-storysource": "7.0.18",
+ "@storybook/addons": "7.0.18",
+ "@storybook/blocks": "7.0.18",
+ "@storybook/core-events": "7.0.18",
"@storybook/jest": "0.1.0",
- "@storybook/manager-api": "7.0.10",
- "@storybook/preview-api": "7.0.10",
- "@storybook/react": "7.0.10",
- "@storybook/react-vite": "7.0.10",
+ "@storybook/manager-api": "7.0.18",
+ "@storybook/preview-api": "7.0.18",
+ "@storybook/react": "7.0.18",
+ "@storybook/react-vite": "7.0.18",
"@storybook/testing-library": "0.1.0",
- "@storybook/theming": "7.0.10",
- "@storybook/types": "7.0.10",
- "@storybook/vue3": "7.0.10",
- "@storybook/vue3-vite": "7.0.10",
+ "@storybook/theming": "7.0.18",
+ "@storybook/types": "7.0.18",
+ "@storybook/vue3": "7.0.18",
+ "@storybook/vue3-vite": "7.0.18",
"@testing-library/jest-dom": "5.16.5",
"@testing-library/vue": "7.0.0",
"@types/escape-regexp": "0.0.1",
"@types/estree": "1.0.1",
"@types/gulp": "4.0.10",
"@types/gulp-rename": "2.0.2",
- "@types/matter-js": "0.18.3",
+ "@types/matter-js": "0.18.5",
"@types/micromatch": "4.0.2",
- "@types/node": "20.1.3",
+ "@types/node": "20.2.5",
"@types/punycode": "2.1.0",
"@types/sanitize-html": "2.9.0",
"@types/seedrandom": "3.0.5",
- "@types/testing-library__jest-dom": "^5.14.5",
+ "@types/testing-library__jest-dom": "^5.14.6",
"@types/throttle-debounce": "5.0.0",
"@types/tinycolor2": "1.4.3",
"@types/uuid": "9.0.1",
"@types/websocket": "1.0.5",
"@types/ws": "8.5.4",
- "@typescript-eslint/eslint-plugin": "5.59.5",
- "@typescript-eslint/parser": "5.59.5",
- "@vitest/coverage-c8": "0.31.0",
- "@vue/runtime-core": "3.3.1",
- "astring": "1.8.4",
+ "@typescript-eslint/eslint-plugin": "5.59.8",
+ "@typescript-eslint/parser": "5.59.8",
+ "@vitest/coverage-c8": "0.31.4",
+ "@vue/runtime-core": "3.3.4",
+ "acorn": "^8.8.2",
"chokidar-cli": "3.0.0",
"cross-env": "7.0.3",
- "cypress": "12.12.0",
- "eslint": "8.40.0",
+ "cypress": "12.13.0",
+ "eslint": "8.41.0",
"eslint-plugin-import": "2.27.5",
- "eslint-plugin-vue": "9.12.0",
+ "eslint-plugin-vue": "9.14.1",
"fast-glob": "3.2.12",
- "happy-dom": "9.16.0",
+ "happy-dom": "9.20.3",
"micromatch": "3.1.10",
"msw": "1.2.1",
"msw-storybook-addon": "1.8.0",
@@ -133,13 +134,13 @@
"react": "18.2.0",
"react-dom": "18.2.0",
"start-server-and-test": "2.0.0",
- "storybook": "7.0.10",
+ "storybook": "7.0.18",
"storybook-addon-misskey-theme": "github:misskey-dev/storybook-addon-misskey-theme",
"summaly": "github:misskey-dev/summaly",
"vite-plugin-turbosnap": "1.0.2",
- "vitest": "0.31.0",
+ "vitest": "0.31.4",
"vitest-fetch-mock": "0.2.2",
- "vue-eslint-parser": "9.2.1",
- "vue-tsc": "1.6.4"
+ "vue-eslint-parser": "9.3.0",
+ "vue-tsc": "1.6.5"
}
}
diff --git a/packages/frontend/src/_boot_.ts b/packages/frontend/src/_boot_.ts
new file mode 100644
index 0000000000..921c161765
--- /dev/null
+++ b/packages/frontend/src/_boot_.ts
@@ -0,0 +1,14 @@
+// https://vitejs.dev/config/build-options.html#build-modulepreload
+import 'vite/modulepreload-polyfill';
+
+import '@/style.scss';
+import { mainBoot } from './boot/main-boot';
+import { subBoot } from './boot/sub-boot';
+
+const subBootPaths = ['/share', '/auth', '/miauth', '/signup-complete'];
+
+if (subBootPaths.some(i => location.pathname === i || location.pathname.startsWith(i + '/'))) {
+ subBoot();
+} else {
+ mainBoot();
+}
diff --git a/packages/frontend/src/account.ts b/packages/frontend/src/account.ts
index 9b104391d7..4770f616ac 100644
--- a/packages/frontend/src/account.ts
+++ b/packages/frontend/src/account.ts
@@ -3,11 +3,11 @@ import * as misskey from 'misskey-js';
import { showSuspendedDialog } from './scripts/show-suspended-dialog';
import { i18n } from './i18n';
import { miLocalStorage } from './local-storage';
+import { MenuButton } from './types/menu';
import { del, get, set } from '@/scripts/idb-proxy';
import { apiUrl } from '@/config';
import { waiting, api, popup, popupMenu, success, alert } from '@/os';
import { unisonReload, reloadChannel } from '@/scripts/unison-reload';
-import { MenuButton } from './types/menu';
// TODO: 他のタブと永続化されたstateを同期
@@ -101,57 +101,57 @@ function fetchAccount(token: string, id?: string, forceShowDialog?: boolean): Pr
'Content-Type': 'application/json',
},
})
- .then(res => new Promise<Account | { error: Record<string, any> }>((done2, fail2) => {
- if (res.status >= 500 && res.status < 600) {
+ .then(res => new Promise<Account | { error: Record<string, any> }>((done2, fail2) => {
+ if (res.status >= 500 && res.status < 600) {
// サーバーエラー(5xx)の場合をrejectとする
// (認証エラーなど4xxはresolve)
- return fail2(res);
- }
- res.json().then(done2, fail2);
- }))
- .then(async res => {
- if (res.error) {
- if (res.error.id === 'a8c724b3-6e9c-4b46-b1a8-bc3ed6258370') {
+ return fail2(res);
+ }
+ res.json().then(done2, fail2);
+ }))
+ .then(async res => {
+ if (res.error) {
+ if (res.error.id === 'a8c724b3-6e9c-4b46-b1a8-bc3ed6258370') {
// SUSPENDED
- if (forceShowDialog || $i && (token === $i.token || id === $i.id)) {
- await showSuspendedDialog();
- }
- } else if (res.error.id === 'e5b3b9f0-2b8f-4b9f-9c1f-8c5c1b2e1b1a') {
+ if (forceShowDialog || $i && (token === $i.token || id === $i.id)) {
+ await showSuspendedDialog();
+ }
+ } else if (res.error.id === 'e5b3b9f0-2b8f-4b9f-9c1f-8c5c1b2e1b1a') {
// USER_IS_DELETED
// アカウントが削除されている
- if (forceShowDialog || $i && (token === $i.token || id === $i.id)) {
- await alert({
- type: 'error',
- title: i18n.ts.accountDeleted,
- text: i18n.ts.accountDeletedDescription,
- });
- }
- } else if (res.error.id === 'b0a7f5f8-dc2f-4171-b91f-de88ad238e14') {
+ if (forceShowDialog || $i && (token === $i.token || id === $i.id)) {
+ await alert({
+ type: 'error',
+ title: i18n.ts.accountDeleted,
+ text: i18n.ts.accountDeletedDescription,
+ });
+ }
+ } else if (res.error.id === 'b0a7f5f8-dc2f-4171-b91f-de88ad238e14') {
// AUTHENTICATION_FAILED
// トークンが無効化されていたりアカウントが削除されたりしている
- if (forceShowDialog || $i && (token === $i.token || id === $i.id)) {
+ if (forceShowDialog || $i && (token === $i.token || id === $i.id)) {
+ await alert({
+ type: 'error',
+ title: i18n.ts.tokenRevoked,
+ text: i18n.ts.tokenRevokedDescription,
+ });
+ }
+ } else {
await alert({
type: 'error',
- title: i18n.ts.tokenRevoked,
- text: i18n.ts.tokenRevokedDescription,
+ title: i18n.ts.failedToFetchAccountInformation,
+ text: JSON.stringify(res.error),
});
}
+
+ // rejectかつ理由がtrueの場合、削除対象であることを示す
+ fail(true);
} else {
- await alert({
- type: 'error',
- title: i18n.ts.failedToFetchAccountInformation,
- text: JSON.stringify(res.error),
- });
+ (res as Account).token = token;
+ done(res as Account);
}
-
- // rejectかつ理由がtrueの場合、削除対象であることを示す
- fail(true);
- } else {
- (res as Account).token = token;
- done(res as Account);
- }
- })
- .catch(fail);
+ })
+ .catch(fail);
});
}
@@ -305,3 +305,7 @@ export async function openAccountMenu(opts: {
});
}
}
+
+if (_DEV_) {
+ (window as any).$i = $i;
+}
diff --git a/packages/frontend/src/boot/common.ts b/packages/frontend/src/boot/common.ts
new file mode 100644
index 0000000000..e1b12fe7d6
--- /dev/null
+++ b/packages/frontend/src/boot/common.ts
@@ -0,0 +1,262 @@
+import { computed, createApp, watch, markRaw, version as vueVersion, defineAsyncComponent, App } from 'vue';
+import { compareVersions } from 'compare-versions';
+import widgets from '@/widgets';
+import directives from '@/directives';
+import components from '@/components';
+import { version, ui, lang, updateLocale } from '@/config';
+import { applyTheme } from '@/scripts/theme';
+import { isDeviceDarkmode } from '@/scripts/is-device-darkmode';
+import { i18n, updateI18n } from '@/i18n';
+import { confirm, alert, post, popup, toast } from '@/os';
+import { $i, refreshAccount, login, updateAccount, signout } from '@/account';
+import { defaultStore, ColdDeviceStorage } from '@/store';
+import { fetchInstance, instance } from '@/instance';
+import { deviceKind } from '@/scripts/device-kind';
+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 { fetchCustomEmojis } from '@/custom-emojis';
+import { mainRouter } from '@/router';
+
+export async function common(createVue: () => App<Element>) {
+ console.info(`Misskey v${version}`);
+
+ if (_DEV_) {
+ console.warn('Development mode!!!');
+
+ console.info(`vue ${vueVersion}`);
+
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (window as any).$i = $i;
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (window as any).$store = defaultStore;
+
+ window.addEventListener('error', event => {
+ console.error(event);
+ /*
+ alert({
+ type: 'error',
+ title: 'DEV: Unhandled error',
+ text: event.message
+ });
+ */
+ });
+
+ window.addEventListener('unhandledrejection', event => {
+ console.error(event);
+ /*
+ alert({
+ type: 'error',
+ title: 'DEV: Unhandled promise rejection',
+ text: event.reason
+ });
+ */
+ });
+ }
+
+ const splash = document.getElementById('splash');
+ // 念のためnullチェック(HTMLが古い場合があるため(そのうち消す))
+ if (splash) splash.addEventListener('transitionend', () => {
+ splash.remove();
+ });
+
+ let isClientUpdated = false;
+
+ //#region クライアントが更新されたかチェック
+ const lastVersion = miLocalStorage.getItem('lastVersion');
+ if (lastVersion !== version) {
+ miLocalStorage.setItem('lastVersion', version);
+
+ // テーマリビルドするため
+ miLocalStorage.removeItem('theme');
+
+ try { // 変なバージョン文字列来るとcompareVersionsでエラーになるため
+ if (lastVersion != null && compareVersions(version, lastVersion) === 1) {
+ isClientUpdated = true;
+ }
+ } catch (err) { /* empty */ }
+ }
+ //#endregion
+
+ //#region Detect language & fetch translations
+ const localeVersion = miLocalStorage.getItem('localeVersion');
+ const localeOutdated = (localeVersion == null || localeVersion !== version);
+ if (localeOutdated) {
+ const res = await window.fetch(`/assets/locales/${lang}.${version}.json`);
+ if (res.status === 200) {
+ const newLocale = await res.text();
+ const parsedNewLocale = JSON.parse(newLocale);
+ miLocalStorage.setItem('locale', newLocale);
+ miLocalStorage.setItem('localeVersion', version);
+ updateLocale(parsedNewLocale);
+ updateI18n(parsedNewLocale);
+ }
+ }
+ //#endregion
+
+ // タッチデバイスでCSSの:hoverを機能させる
+ document.addEventListener('touchend', () => {}, { passive: true });
+
+ // 一斉リロード
+ reloadChannel.addEventListener('message', path => {
+ if (path !== null) location.href = path;
+ else location.reload();
+ });
+
+ // If mobile, insert the viewport meta tag
+ if (['smartphone', 'tablet'].includes(deviceKind)) {
+ const viewport = document.getElementsByName('viewport').item(0);
+ viewport.setAttribute('content',
+ `${viewport.getAttribute('content')}, minimum-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover`);
+ }
+
+ //#region Set lang attr
+ const html = document.documentElement;
+ html.setAttribute('lang', lang);
+ //#endregion
+
+ await defaultStore.ready;
+ await deckStore.ready;
+
+ const fetchInstanceMetaPromise = fetchInstance();
+
+ fetchInstanceMetaPromise.then(() => {
+ miLocalStorage.setItem('v', instance.version);
+ });
+
+ //#region loginId
+ const params = new URLSearchParams(location.search);
+ const loginId = params.get('loginId');
+
+ if (loginId) {
+ const target = getUrlWithoutLoginId(location.href);
+
+ if (!$i || $i.id !== loginId) {
+ const account = await getAccountFromId(loginId);
+ if (account) {
+ await login(account.token, target);
+ }
+ }
+
+ history.replaceState({ misskey: 'loginId' }, '', target);
+ }
+ //#endregion
+
+ // NOTE: この処理は必ずクライアント更新チェック処理より後に来ること(テーマ再構築のため)
+ watch(defaultStore.reactiveState.darkMode, (darkMode) => {
+ applyTheme(darkMode ? ColdDeviceStorage.get('darkTheme') : ColdDeviceStorage.get('lightTheme'));
+ }, { immediate: miLocalStorage.getItem('theme') == null });
+
+ const darkTheme = computed(ColdDeviceStorage.makeGetterSetter('darkTheme'));
+ const lightTheme = computed(ColdDeviceStorage.makeGetterSetter('lightTheme'));
+
+ watch(darkTheme, (theme) => {
+ if (defaultStore.state.darkMode) {
+ applyTheme(theme);
+ }
+ });
+
+ watch(lightTheme, (theme) => {
+ if (!defaultStore.state.darkMode) {
+ applyTheme(theme);
+ }
+ });
+
+ //#region Sync dark mode
+ if (ColdDeviceStorage.get('syncDeviceDarkMode')) {
+ defaultStore.set('darkMode', isDeviceDarkmode());
+ }
+
+ window.matchMedia('(prefers-color-scheme: dark)').addListener(mql => {
+ if (ColdDeviceStorage.get('syncDeviceDarkMode')) {
+ defaultStore.set('darkMode', mql.matches);
+ }
+ });
+ //#endregion
+
+ fetchInstanceMetaPromise.then(() => {
+ if (defaultStore.state.themeInitial) {
+ if (instance.defaultLightTheme != null) ColdDeviceStorage.set('lightTheme', JSON.parse(instance.defaultLightTheme));
+ if (instance.defaultDarkTheme != null) ColdDeviceStorage.set('darkTheme', JSON.parse(instance.defaultDarkTheme));
+ defaultStore.set('themeInitial', false);
+ }
+ });
+
+ watch(defaultStore.reactiveState.useBlurEffectForModal, v => {
+ document.documentElement.style.setProperty('--modalBgFilter', v ? 'blur(4px)' : 'none');
+ }, { immediate: true });
+
+ watch(defaultStore.reactiveState.useBlurEffect, v => {
+ if (v) {
+ document.documentElement.style.removeProperty('--blur');
+ } else {
+ document.documentElement.style.setProperty('--blur', 'none');
+ }
+ }, { immediate: true });
+
+ //#region Fetch user
+ if ($i && $i.token) {
+ if (_DEV_) {
+ console.log('account cache found. refreshing...');
+ }
+
+ refreshAccount();
+ }
+ //#endregion
+
+ try {
+ await fetchCustomEmojis();
+ } catch (err) { /* empty */ }
+
+ const app = createVue();
+
+ if (_DEV_) {
+ app.config.performance = true;
+ }
+
+ widgets(app);
+ directives(app);
+ components(app);
+
+ // https://github.com/misskey-dev/misskey/pull/8575#issuecomment-1114239210
+ // なぜか2回実行されることがあるため、mountするdivを1つに制限する
+ const rootEl = ((): HTMLElement => {
+ const MISSKEY_MOUNT_DIV_ID = 'misskey_app';
+
+ const currentRoot = document.getElementById(MISSKEY_MOUNT_DIV_ID);
+
+ if (currentRoot) {
+ console.warn('multiple import detected');
+ return currentRoot;
+ }
+
+ const root = document.createElement('div');
+ root.id = MISSKEY_MOUNT_DIV_ID;
+ document.body.appendChild(root);
+ return root;
+ })();
+
+ app.mount(rootEl);
+
+ // boot.jsのやつを解除
+ window.onerror = null;
+ window.onunhandledrejection = null;
+
+ removeSplash();
+
+ return {
+ isClientUpdated,
+ app,
+ };
+}
+
+function removeSplash() {
+ const splash = document.getElementById('splash');
+ if (splash) {
+ splash.style.opacity = '0';
+ splash.style.pointerEvents = 'none';
+ }
+}
diff --git a/packages/frontend/src/boot/main-boot.ts b/packages/frontend/src/boot/main-boot.ts
new file mode 100644
index 0000000000..76e8c50724
--- /dev/null
+++ b/packages/frontend/src/boot/main-boot.ts
@@ -0,0 +1,254 @@
+import { computed, createApp, watch, markRaw, version as vueVersion, defineAsyncComponent } from 'vue';
+import { common } from './common';
+import { version, ui, lang, updateLocale } from '@/config';
+import { i18n, updateI18n } from '@/i18n';
+import { confirm, alert, post, popup, toast } from '@/os';
+import { useStream } from '@/stream';
+import * as sound from '@/scripts/sound';
+import { $i, refreshAccount, login, updateAccount, signout } from '@/account';
+import { defaultStore, ColdDeviceStorage } from '@/store';
+import { makeHotkey } from '@/scripts/hotkey';
+import { reactionPicker } from '@/scripts/reaction-picker';
+import { miLocalStorage } from '@/local-storage';
+import { claimAchievement, claimedAchievements } from '@/scripts/achievements';
+import { mainRouter } from '@/router';
+import { initializeSw } from '@/scripts/initialize-sw';
+
+export async function mainBoot() {
+ const { isClientUpdated } = await common(() => createApp(
+ new URLSearchParams(window.location.search).has('zen') || (ui === 'deck' && location.pathname !== '/') ? defineAsyncComponent(() => import('@/ui/zen.vue')) :
+ !$i ? defineAsyncComponent(() => import('@/ui/visitor.vue')) :
+ ui === 'deck' ? defineAsyncComponent(() => import('@/ui/deck.vue')) :
+ ui === 'classic' ? defineAsyncComponent(() => import('@/ui/classic.vue')) :
+ defineAsyncComponent(() => import('@/ui/universal.vue')),
+ ));
+
+ reactionPicker.init();
+
+ if (isClientUpdated && $i) {
+ popup(defineAsyncComponent(() => import('@/components/MkUpdated.vue')), {}, {}, 'closed');
+ }
+
+ const stream = useStream();
+
+ let reloadDialogShowing = false;
+ stream.on('_disconnected_', async () => {
+ if (defaultStore.state.serverDisconnectedBehavior === 'reload') {
+ location.reload();
+ } else if (defaultStore.state.serverDisconnectedBehavior === 'dialog') {
+ if (reloadDialogShowing) return;
+ reloadDialogShowing = true;
+ const { canceled } = await confirm({
+ type: 'warning',
+ title: i18n.ts.disconnectedFromServer,
+ text: i18n.ts.reloadConfirm,
+ });
+ reloadDialogShowing = false;
+ if (!canceled) {
+ location.reload();
+ }
+ }
+ });
+
+ for (const plugin of ColdDeviceStorage.get('plugins').filter(p => p.active)) {
+ import('../plugin').then(async ({ install }) => {
+ // Workaround for https://bugs.webkit.org/show_bug.cgi?id=242740
+ await new Promise(r => setTimeout(r, 0));
+ install(plugin);
+ });
+ }
+
+ const hotkeys = {
+ 'd': (): void => {
+ defaultStore.set('darkMode', !defaultStore.state.darkMode);
+ },
+ 's': (): void => {
+ mainRouter.push('/search');
+ },
+ };
+
+ if ($i) {
+ // only add post shortcuts if logged in
+ hotkeys['p|n'] = post;
+
+ defaultStore.loaded.then(() => {
+ if (defaultStore.state.accountSetupWizard !== -1) {
+ popup(defineAsyncComponent(() => import('@/components/MkUserSetupDialog.vue')), {}, {}, 'closed');
+ }
+ });
+
+ if ($i.isDeleted) {
+ alert({
+ type: 'warning',
+ text: i18n.ts.accountDeletionInProgress,
+ });
+ }
+
+ const now = new Date();
+ const m = now.getMonth() + 1;
+ const d = now.getDate();
+
+ if ($i.birthday) {
+ const bm = parseInt($i.birthday.split('-')[1]);
+ const bd = parseInt($i.birthday.split('-')[2]);
+ if (m === bm && d === bd) {
+ claimAchievement('loggedInOnBirthday');
+ }
+ }
+
+ if (m === 1 && d === 1) {
+ claimAchievement('loggedInOnNewYearsDay');
+ }
+
+ if ($i.loggedInDays >= 3) claimAchievement('login3');
+ if ($i.loggedInDays >= 7) claimAchievement('login7');
+ if ($i.loggedInDays >= 15) claimAchievement('login15');
+ if ($i.loggedInDays >= 30) claimAchievement('login30');
+ if ($i.loggedInDays >= 60) claimAchievement('login60');
+ if ($i.loggedInDays >= 100) claimAchievement('login100');
+ if ($i.loggedInDays >= 200) claimAchievement('login200');
+ if ($i.loggedInDays >= 300) claimAchievement('login300');
+ if ($i.loggedInDays >= 400) claimAchievement('login400');
+ if ($i.loggedInDays >= 500) claimAchievement('login500');
+ if ($i.loggedInDays >= 600) claimAchievement('login600');
+ if ($i.loggedInDays >= 700) claimAchievement('login700');
+ if ($i.loggedInDays >= 800) claimAchievement('login800');
+ if ($i.loggedInDays >= 900) claimAchievement('login900');
+ if ($i.loggedInDays >= 1000) claimAchievement('login1000');
+
+ if ($i.notesCount > 0) claimAchievement('notes1');
+ if ($i.notesCount >= 10) claimAchievement('notes10');
+ if ($i.notesCount >= 100) claimAchievement('notes100');
+ if ($i.notesCount >= 500) claimAchievement('notes500');
+ if ($i.notesCount >= 1000) claimAchievement('notes1000');
+ if ($i.notesCount >= 5000) claimAchievement('notes5000');
+ if ($i.notesCount >= 10000) claimAchievement('notes10000');
+ if ($i.notesCount >= 20000) claimAchievement('notes20000');
+ if ($i.notesCount >= 30000) claimAchievement('notes30000');
+ if ($i.notesCount >= 40000) claimAchievement('notes40000');
+ if ($i.notesCount >= 50000) claimAchievement('notes50000');
+ if ($i.notesCount >= 60000) claimAchievement('notes60000');
+ if ($i.notesCount >= 70000) claimAchievement('notes70000');
+ if ($i.notesCount >= 80000) claimAchievement('notes80000');
+ if ($i.notesCount >= 90000) claimAchievement('notes90000');
+ if ($i.notesCount >= 100000) claimAchievement('notes100000');
+
+ if ($i.followersCount > 0) claimAchievement('followers1');
+ if ($i.followersCount >= 10) claimAchievement('followers10');
+ if ($i.followersCount >= 50) claimAchievement('followers50');
+ if ($i.followersCount >= 100) claimAchievement('followers100');
+ if ($i.followersCount >= 300) claimAchievement('followers300');
+ if ($i.followersCount >= 500) claimAchievement('followers500');
+ if ($i.followersCount >= 1000) claimAchievement('followers1000');
+
+ if (Date.now() - new Date($i.createdAt).getTime() > 1000 * 60 * 60 * 24 * 365) {
+ claimAchievement('passedSinceAccountCreated1');
+ }
+ if (Date.now() - new Date($i.createdAt).getTime() > 1000 * 60 * 60 * 24 * 365 * 2) {
+ claimAchievement('passedSinceAccountCreated2');
+ }
+ if (Date.now() - new Date($i.createdAt).getTime() > 1000 * 60 * 60 * 24 * 365 * 3) {
+ claimAchievement('passedSinceAccountCreated3');
+ }
+
+ if (claimedAchievements.length >= 30) {
+ claimAchievement('collectAchievements30');
+ }
+
+ window.setInterval(() => {
+ if (Math.floor(Math.random() * 20000) === 0) {
+ claimAchievement('justPlainLucky');
+ }
+ }, 1000 * 10);
+
+ window.setTimeout(() => {
+ claimAchievement('client30min');
+ }, 1000 * 60 * 30);
+
+ window.setTimeout(() => {
+ claimAchievement('client60min');
+ }, 1000 * 60 * 60);
+
+ const lastUsed = miLocalStorage.getItem('lastUsed');
+ if (lastUsed) {
+ const lastUsedDate = parseInt(lastUsed, 10);
+ // 二時間以上前なら
+ if (Date.now() - lastUsedDate > 1000 * 60 * 60 * 2) {
+ toast(i18n.t('welcomeBackWithName', {
+ name: $i.name || $i.username,
+ }));
+ }
+ }
+ miLocalStorage.setItem('lastUsed', Date.now().toString());
+
+ 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))) && !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');
+ }
+ }
+
+ if ('Notification' in window) {
+ // 許可を得ていなかったらリクエスト
+ if (Notification.permission === 'default') {
+ Notification.requestPermission();
+ }
+ }
+
+ const main = markRaw(stream.useChannel('main', null, 'System'));
+
+ // 自分の情報が更新されたとき
+ main.on('meUpdated', i => {
+ updateAccount(i);
+ });
+
+ main.on('readAllNotifications', () => {
+ updateAccount({ hasUnreadNotification: false });
+ });
+
+ main.on('unreadNotification', () => {
+ updateAccount({ hasUnreadNotification: true });
+ });
+
+ main.on('unreadMention', () => {
+ updateAccount({ hasUnreadMentions: true });
+ });
+
+ main.on('readAllUnreadMentions', () => {
+ updateAccount({ hasUnreadMentions: false });
+ });
+
+ main.on('unreadSpecifiedNote', () => {
+ updateAccount({ hasUnreadSpecifiedNotes: true });
+ });
+
+ main.on('readAllUnreadSpecifiedNotes', () => {
+ updateAccount({ hasUnreadSpecifiedNotes: false });
+ });
+
+ main.on('readAllAntennas', () => {
+ updateAccount({ hasUnreadAntenna: false });
+ });
+
+ main.on('unreadAntenna', () => {
+ updateAccount({ hasUnreadAntenna: true });
+ sound.play('antenna');
+ });
+
+ main.on('readAllAnnouncements', () => {
+ updateAccount({ hasUnreadAnnouncement: false });
+ });
+
+ // トークンが再生成されたとき
+ // このままではMisskeyが利用できないので強制的にサインアウトさせる
+ main.on('myTokenRegenerated', () => {
+ signout();
+ });
+ }
+
+ // shortcut
+ document.addEventListener('keydown', makeHotkey(hotkeys));
+
+ initializeSw();
+}
diff --git a/packages/frontend/src/boot/sub-boot.ts b/packages/frontend/src/boot/sub-boot.ts
new file mode 100644
index 0000000000..c2664f6c1d
--- /dev/null
+++ b/packages/frontend/src/boot/sub-boot.ts
@@ -0,0 +1,8 @@
+import { computed, createApp, watch, markRaw, version as vueVersion, defineAsyncComponent } from 'vue';
+import { common } from './common';
+
+export async function subBoot() {
+ const { isClientUpdated } = await common(() => createApp(
+ defineAsyncComponent(() => import('@/ui/minimum.vue')),
+ ));
+}
diff --git a/packages/frontend/src/components/MkAbuseReportWindow.vue b/packages/frontend/src/components/MkAbuseReportWindow.vue
index 9f2bf99338..48236782d9 100644
--- a/packages/frontend/src/components/MkAbuseReportWindow.vue
+++ b/packages/frontend/src/components/MkAbuseReportWindow.vue
@@ -1,5 +1,5 @@
<template>
-<MkWindow ref="uiWindow" :initial-width="400" :initial-height="500" :can-resize="true" @closed="emit('closed')">
+<MkWindow ref="uiWindow" :initialWidth="400" :initialHeight="500" :canResize="true" @closed="emit('closed')">
<template #header>
<i class="ti ti-exclamation-circle" style="margin-right: 0.5em;"></i>
<I18n :src="i18n.ts.reportAbuseOf" tag="span">
@@ -8,8 +8,8 @@
</template>
</I18n>
</template>
- <MkSpacer :margin-min="20" :margin-max="28">
- <div class="dpvffvvy _gaps_m">
+ <MkSpacer :marginMin="20" :marginMax="28">
+ <div class="_gaps_m" :class="$style.root">
<div class="">
<MkTextarea v-model="comment">
<template #label>{{ i18n.ts.details }}</template>
@@ -60,8 +60,8 @@ function send() {
}
</script>
-<style lang="scss" scoped>
-.dpvffvvy {
+<style lang="scss" module>
+.root {
--root-margin: 16px;
}
</style>
diff --git a/packages/frontend/src/components/MkAccountMoved.vue b/packages/frontend/src/components/MkAccountMoved.vue
index b02bfdc2b8..bc07b9ba5f 100644
--- a/packages/frontend/src/components/MkAccountMoved.vue
+++ b/packages/frontend/src/components/MkAccountMoved.vue
@@ -7,11 +7,11 @@
</template>
<script lang="ts" setup>
+import { ref } from 'vue';
+import { UserLite } from 'misskey-js/built/entities';
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';
const user = ref<UserLite>();
diff --git a/packages/frontend/src/components/MkAchievements.vue b/packages/frontend/src/components/MkAchievements.vue
index d30037dcf9..3fdb261dac 100644
--- a/packages/frontend/src/components/MkAchievements.vue
+++ b/packages/frontend/src/components/MkAchievements.vue
@@ -3,7 +3,14 @@
<div v-if="achievements" :class="$style.root">
<div v-for="achievement in achievements" :key="achievement" :class="$style.achievement" class="_panel">
<div :class="$style.icon">
- <div :class="[$style.iconFrame, $style['iconFrame_' + ACHIEVEMENT_BADGES[achievement.name].frame]]">
+ <div
+ :class="[$style.iconFrame, {
+ [$style.iconFrame_bronze]: ACHIEVEMENT_BADGES[achievement.name].frame === 'bronze',
+ [$style.iconFrame_silver]: ACHIEVEMENT_BADGES[achievement.name].frame === 'silver',
+ [$style.iconFrame_gold]: ACHIEVEMENT_BADGES[achievement.name].frame === 'gold',
+ [$style.iconFrame_platinum]: ACHIEVEMENT_BADGES[achievement.name].frame === 'platinum',
+ }]"
+ >
<div :class="[$style.iconInner]" :style="{ background: ACHIEVEMENT_BADGES[achievement.name].bg }">
<img :class="$style.iconImg" :src="ACHIEVEMENT_BADGES[achievement.name].img">
</div>
diff --git a/packages/frontend/src/components/MkAnalogClock.stories.impl.ts b/packages/frontend/src/components/MkAnalogClock.stories.impl.ts
index e7fbb47284..0aebdccf4f 100644
--- a/packages/frontend/src/components/MkAnalogClock.stories.impl.ts
+++ b/packages/frontend/src/components/MkAnalogClock.stories.impl.ts
@@ -1,7 +1,7 @@
/* eslint-disable @typescript-eslint/explicit-function-return-type */
import { StoryObj } from '@storybook/vue3';
+import isChromatic from 'chromatic/isChromatic';
import MkAnalogClock from './MkAnalogClock.vue';
-import isChromatic from 'chromatic';
export const Default = {
render(args) {
return {
diff --git a/packages/frontend/src/components/MkAnalogClock.vue b/packages/frontend/src/components/MkAnalogClock.vue
index f12020f810..05caffe7d0 100644
--- a/packages/frontend/src/components/MkAnalogClock.vue
+++ b/packages/frontend/src/components/MkAnalogClock.vue
@@ -39,6 +39,7 @@
-->
<line
+ ref="sLine"
:class="[$style.s, { [$style.animate]: !disableSAnimate && sAnimation !== 'none', [$style.elastic]: sAnimation === 'elastic', [$style.easeOut]: sAnimation === 'easeOut' }]"
:x1="5 - (0 * (sHandLengthRatio * handsTailLength))"
:y1="5 + (1 * (sHandLengthRatio * handsTailLength))"
@@ -73,9 +74,10 @@
</template>
<script lang="ts" setup>
-import { computed, onMounted, onBeforeUnmount } from 'vue';
+import { computed, onMounted, onBeforeUnmount, ref } from 'vue';
import tinycolor from 'tinycolor2';
import { globalEvents } from '@/events.js';
+import { defaultIdlingRenderScheduler } from '@/scripts/idle-render.js';
// https://stackoverflow.com/questions/1878907/how-can-i-find-the-difference-between-two-angles
const angleDiff = (a: number, b: number) => {
@@ -145,6 +147,7 @@ let mAngle = $ref<number>(0);
let sAngle = $ref<number>(0);
let disableSAnimate = $ref(false);
let sOneRound = false;
+const sLine = ref<SVGPathElement>();
function tick() {
const now = props.now();
@@ -160,17 +163,21 @@ function tick() {
}
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になったときに期待したアニメーションにならない)
+ if (sOneRound && sLine.value) { // 秒針が一周した際のアニメーションをよしなに処理する(これが無いと秒が59->0になったときに期待したアニメーションにならない)
sAngle = Math.PI * 60 / 30;
- window.setTimeout(() => {
+ defaultIdlingRenderScheduler.delete(tick);
+ sLine.value.addEventListener('transitionend', () => {
disableSAnimate = true;
- window.setTimeout(() => {
+ requestAnimationFrame(() => {
sAngle = 0;
- window.setTimeout(() => {
+ requestAnimationFrame(() => {
disableSAnimate = false;
- }, 100);
- }, 100);
- }, 700);
+ if (enabled) {
+ defaultIdlingRenderScheduler.add(tick);
+ }
+ });
+ });
+ }, { once: true });
} else {
sAngle = Math.PI * s / 30;
}
@@ -194,20 +201,13 @@ function calcColors() {
calcColors();
onMounted(() => {
- const update = () => {
- if (enabled) {
- tick();
- window.setTimeout(update, 1000);
- }
- };
- update();
-
+ defaultIdlingRenderScheduler.add(tick);
globalEvents.on('themeChanged', calcColors);
});
onBeforeUnmount(() => {
enabled = false;
-
+ defaultIdlingRenderScheduler.delete(tick);
globalEvents.off('themeChanged', calcColors);
});
</script>
diff --git a/packages/frontend/src/components/MkAnimBg.vue b/packages/frontend/src/components/MkAnimBg.vue
new file mode 100644
index 0000000000..575ea7c5e3
--- /dev/null
+++ b/packages/frontend/src/components/MkAnimBg.vue
@@ -0,0 +1,243 @@
+<template>
+<canvas ref="canvasEl" style="width: 100%; height: 100%; pointer-events: none;"></canvas>
+</template>
+
+<script lang="ts" setup>
+import { onMounted, onUnmounted, shallowRef } from 'vue';
+import isChromatic from 'chromatic/isChromatic';
+
+const canvasEl = shallowRef<HTMLCanvasElement>();
+
+const props = withDefaults(defineProps<{
+ scale?: number;
+ focus?: number;
+}>(), {
+ scale: 1.0,
+ focus: 1.0,
+});
+
+function loadShader(gl, type, source) {
+ const shader = gl.createShader(type);
+
+ gl.shaderSource(shader, source);
+ gl.compileShader(shader);
+
+ if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
+ alert(
+ `falied to compile shader: ${gl.getShaderInfoLog(shader)}`,
+ );
+ gl.deleteShader(shader);
+ return null;
+ }
+
+ return shader;
+}
+
+function initShaderProgram(gl, vsSource, fsSource) {
+ const vertexShader = loadShader(gl, gl.VERTEX_SHADER, vsSource);
+ const fragmentShader = loadShader(gl, gl.FRAGMENT_SHADER, fsSource);
+
+ const shaderProgram = gl.createProgram();
+ gl.attachShader(shaderProgram, vertexShader);
+ gl.attachShader(shaderProgram, fragmentShader);
+ gl.linkProgram(shaderProgram);
+
+ if (!gl.getProgramParameter(shaderProgram, gl.LINK_STATUS)) {
+ alert(
+ `failed to init shader: ${gl.getProgramInfoLog(
+ shaderProgram,
+ )}`,
+ );
+ return null;
+ }
+
+ return shaderProgram;
+}
+
+let handle: ReturnType<typeof window['requestAnimationFrame']> | null = null;
+
+onMounted(() => {
+ const canvas = canvasEl.value!;
+ canvas.width = canvas.offsetWidth;
+ canvas.height = canvas.offsetHeight;
+
+ const gl = canvas.getContext('webgl', { premultipliedAlpha: true });
+ if (gl == null) return;
+
+ gl.clearColor(0.0, 0.0, 0.0, 0.0);
+ gl.clear(gl.COLOR_BUFFER_BIT);
+
+ const positionBuffer = gl.createBuffer();
+ gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
+
+ const shaderProgram = initShaderProgram(gl, `
+ attribute vec2 vertex;
+
+ uniform vec2 u_scale;
+
+ varying vec2 v_pos;
+
+ void main() {
+ gl_Position = vec4(vertex, 0.0, 1.0);
+ v_pos = vertex / u_scale;
+ }
+ `, `
+ precision mediump float;
+
+ vec3 mod289(vec3 x) {
+ return x - floor(x * (1.0 / 289.0)) * 289.0;
+ }
+
+ vec2 mod289(vec2 x) {
+ return x - floor(x * (1.0 / 289.0)) * 289.0;
+ }
+
+ vec3 permute(vec3 x) {
+ return mod289(((x*34.0)+1.0)*x);
+ }
+
+ float snoise(vec2 v) {
+ const vec4 C = vec4(0.211324865405187,
+ 0.366025403784439,
+ -0.577350269189626,
+ 0.024390243902439);
+
+ vec2 i = floor(v + dot(v, C.yy) );
+ vec2 x0 = v - i + dot(i, C.xx);
+
+ vec2 i1;
+ i1 = (x0.x > x0.y) ? vec2(1.0, 0.0) : vec2(0.0, 1.0);
+ vec4 x12 = x0.xyxy + C.xxzz;
+ x12.xy -= i1;
+
+ i = mod289(i);
+ vec3 p = permute( permute( i.y + vec3(0.0, i1.y, 1.0 ))
+ + i.x + vec3(0.0, i1.x, 1.0 ));
+
+ vec3 m = max(0.5 - vec3(dot(x0,x0), dot(x12.xy,x12.xy), dot(x12.zw,x12.zw)), 0.0);
+ m = m*m ;
+ m = m*m ;
+
+ vec3 x = 2.0 * fract(p * C.www) - 1.0;
+ vec3 h = abs(x) - 0.5;
+ vec3 ox = floor(x + 0.5);
+ vec3 a0 = x - ox;
+
+ m *= 1.79284291400159 - 0.85373472095314 * ( a0*a0 + h*h );
+
+ vec3 g;
+ g.x = a0.x * x0.x + h.x * x0.y;
+ g.yz = a0.yz * x12.xz + h.yz * x12.yw;
+ return 130.0 * dot(m, g);
+ }
+
+ uniform float u_time;
+ uniform vec2 u_resolution;
+ uniform float u_spread;
+ uniform float u_speed;
+ uniform float u_warp;
+ uniform float u_focus;
+ uniform float u_itensity;
+
+ varying vec2 v_pos;
+
+ float circle( in vec2 _pos, in vec2 _origin, in float _radius ) {
+ float SPREAD = 0.7 * u_spread;
+ float SPEED = 0.00055 * u_speed;
+ float WARP = 1.5 * u_warp;
+ float FOCUS = 1.15 * u_focus;
+
+ vec2 dist = _pos - _origin;
+
+ float distortion = snoise( vec2(
+ _pos.x * 1.587 * WARP + u_time * SPEED * 0.5,
+ _pos.y * 1.192 * WARP + u_time * SPEED * 0.3
+ ) ) * 0.5 + 0.5;
+
+ float feather = 0.01 + SPREAD * pow( distortion, FOCUS );
+
+ return 1.0 - smoothstep(
+ _radius - ( _radius * feather ),
+ _radius + ( _radius * feather ),
+ dot( dist, dist ) * 4.0
+ );
+ }
+
+ void main() {
+ vec3 green = vec3( 1.0 ) - vec3( 153.0 / 255.0, 211.0 / 255.0, 221.0 / 255.0 );
+ vec3 purple = vec3( 1.0 ) - vec3( 195.0 / 255.0, 165.0 / 255.0, 242.0 / 255.0 );
+ vec3 orange = vec3( 1.0 ) - vec3( 255.0 / 255.0, 156.0 / 255.0, 136.0 / 255.0 );
+
+ float ratio = u_resolution.x / u_resolution.y;
+
+ vec2 uv = vec2( v_pos.x, v_pos.y / ratio ) * 0.5 + 0.5;
+
+ vec3 color = vec3( 0.0 );
+
+ float greenMix = snoise( v_pos * 1.31 + u_time * 0.8 * 0.00017 ) * 0.5 + 0.5;
+ float purpleMix = snoise( v_pos * 1.26 + u_time * 0.8 * -0.0001 ) * 0.5 + 0.5;
+ float orangeMix = snoise( v_pos * 1.34 + u_time * 0.8 * 0.00015 ) * 0.5 + 0.5;
+
+ float alphaOne = 0.35 + 0.65 * pow( snoise( vec2( u_time * 0.00012, uv.x ) ) * 0.5 + 0.5, 1.2 );
+ float alphaTwo = 0.35 + 0.65 * pow( snoise( vec2( ( u_time + 1561.0 ) * 0.00014, uv.x ) ) * 0.5 + 0.5, 1.2 );
+ float alphaThree = 0.35 + 0.65 * pow( snoise( vec2( ( u_time + 3917.0 ) * 0.00013, uv.x ) ) * 0.5 + 0.5, 1.2 );
+
+ color += vec3( circle( uv, vec2( 0.22 + sin( u_time * 0.000201 ) * 0.06, 0.80 + cos( u_time * 0.000151 ) * 0.06 ), 0.15 ) ) * alphaOne * ( purple * purpleMix + orange * orangeMix );
+ color += vec3( circle( uv, vec2( 0.90 + cos( u_time * 0.000166 ) * 0.06, 0.42 + sin( u_time * 0.000138 ) * 0.06 ), 0.18 ) ) * alphaTwo * ( green * greenMix + purple * purpleMix );
+ color += vec3( circle( uv, vec2( 0.19 + sin( u_time * 0.000112 ) * 0.06, 0.25 + sin( u_time * 0.000192 ) * 0.06 ), 0.09 ) ) * alphaThree * ( orange * orangeMix );
+
+ color *= u_itensity + 1.0 * pow( snoise( vec2( v_pos.y + u_time * 0.00013, v_pos.x + u_time * -0.00009 ) ) * 0.5 + 0.5, 2.0 );
+
+ vec3 inverted = vec3( 1.0 ) - color;
+ gl_FragColor = vec4( color, max(max(color.x, color.y), color.z) );
+ }
+ `);
+
+ gl.useProgram(shaderProgram);
+ const u_resolution = gl.getUniformLocation(shaderProgram, 'u_resolution');
+ const u_time = gl.getUniformLocation(shaderProgram, 'u_time');
+ const u_spread = gl.getUniformLocation(shaderProgram, 'u_spread');
+ const u_speed = gl.getUniformLocation(shaderProgram, 'u_speed');
+ const u_warp = gl.getUniformLocation(shaderProgram, 'u_warp');
+ const u_focus = gl.getUniformLocation(shaderProgram, 'u_focus');
+ const u_itensity = gl.getUniformLocation(shaderProgram, 'u_itensity');
+ const u_scale = gl.getUniformLocation(shaderProgram, 'u_scale');
+ gl.uniform2fv(u_resolution, [canvas.width, canvas.height]);
+ gl.uniform1f(u_spread, 1.0);
+ gl.uniform1f(u_speed, 1.0);
+ gl.uniform1f(u_warp, 1.0);
+ gl.uniform1f(u_focus, props.focus);
+ gl.uniform1f(u_itensity, 0.5);
+ gl.uniform2fv(u_scale, [props.scale, props.scale]);
+
+ const vertex = gl.getAttribLocation(shaderProgram, 'vertex');
+ gl.enableVertexAttribArray(vertex);
+ gl.vertexAttribPointer(vertex, 2, gl.FLOAT, false, 0, 0);
+
+ const vertices = [1.0, 1.0, -1.0, 1.0, 1.0, -1.0, -1.0, -1.0];
+ gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(vertices), gl.DYNAMIC_DRAW);
+
+ if (isChromatic()) {
+ gl!.uniform1f(u_time, 0);
+ gl!.drawArrays(gl!.TRIANGLE_STRIP, 0, 4);
+ } else {
+ function render(timeStamp) {
+ gl!.uniform1f(u_time, timeStamp);
+ gl!.drawArrays(gl!.TRIANGLE_STRIP, 0, 4);
+
+ handle = window.requestAnimationFrame(render);
+ }
+
+ handle = window.requestAnimationFrame(render);
+ }
+});
+
+onUnmounted(() => {
+ if (handle) {
+ window.cancelAnimationFrame(handle);
+ }
+});
+</script>
+
+<style lang="scss" module>
+</style>
diff --git a/packages/frontend/src/components/MkAsUi.vue b/packages/frontend/src/components/MkAsUi.vue
index 6ade5316c6..8bfcfa6aa6 100644
--- a/packages/frontend/src/components/MkAsUi.vue
+++ b/packages/frontend/src/components/MkAsUi.vue
@@ -11,29 +11,29 @@
<div v-else-if="c.type === 'buttons'" class="_buttons" :style="{ justifyContent: align }">
<MkButton v-for="button in c.buttons" :primary="button.primary" :rounded="button.rounded" :disabled="button.disabled" inline :small="size === 'small'" @click="button.onClick">{{ button.text }}</MkButton>
</div>
- <MkSwitch v-else-if="c.type === 'switch'" :model-value="valueForSwitch" @update:model-value="onSwitchUpdate">
+ <MkSwitch v-else-if="c.type === 'switch'" :modelValue="valueForSwitch" @update:modelValue="onSwitchUpdate">
<template v-if="c.label" #label>{{ c.label }}</template>
<template v-if="c.caption" #caption>{{ c.caption }}</template>
</MkSwitch>
- <MkTextarea v-else-if="c.type === 'textarea'" :model-value="c.default" @update:model-value="c.onInput">
+ <MkTextarea v-else-if="c.type === 'textarea'" :modelValue="c.default" @update:modelValue="c.onInput">
<template v-if="c.label" #label>{{ c.label }}</template>
<template v-if="c.caption" #caption>{{ c.caption }}</template>
</MkTextarea>
- <MkInput v-else-if="c.type === 'textInput'" :small="size === 'small'" :model-value="c.default" @update:model-value="c.onInput">
+ <MkInput v-else-if="c.type === 'textInput'" :small="size === 'small'" :modelValue="c.default" @update:modelValue="c.onInput">
<template v-if="c.label" #label>{{ c.label }}</template>
<template v-if="c.caption" #caption>{{ c.caption }}</template>
</MkInput>
- <MkInput v-else-if="c.type === 'numberInput'" :small="size === 'small'" :model-value="c.default" type="number" @update:model-value="c.onInput">
+ <MkInput v-else-if="c.type === 'numberInput'" :small="size === 'small'" :modelValue="c.default" type="number" @update:modelValue="c.onInput">
<template v-if="c.label" #label>{{ c.label }}</template>
<template v-if="c.caption" #caption>{{ c.caption }}</template>
</MkInput>
- <MkSelect v-else-if="c.type === 'select'" :small="size === 'small'" :model-value="c.default" @update:model-value="c.onChange">
+ <MkSelect v-else-if="c.type === 'select'" :small="size === 'small'" :modelValue="c.default" @update:modelValue="c.onChange">
<template v-if="c.label" #label>{{ c.label }}</template>
<template v-if="c.caption" #caption>{{ c.caption }}</template>
<option v-for="item in c.items" :key="item.value" :value="item.value">{{ item.text }}</option>
</MkSelect>
<MkButton v-else-if="c.type === 'postFormButton'" :primary="c.primary" :rounded="c.rounded" :small="size === 'small'" inline @click="openPostForm">{{ c.text }}</MkButton>
- <MkFolder v-else-if="c.type === 'folder'" :default-open="c.opened">
+ <MkFolder v-else-if="c.type === 'folder'" :defaultOpen="c.opened">
<template #label>{{ c.title }}</template>
<template v-for="child in c.children" :key="child">
<MkAsUi v-if="!g(child).hidden" :component="g(child)" :components="props.components" :size="size"/>
diff --git a/packages/frontend/src/components/MkAutocomplete.vue b/packages/frontend/src/components/MkAutocomplete.vue
index 663c57623d..fd892d8174 100644
--- a/packages/frontend/src/components/MkAutocomplete.vue
+++ b/packages/frontend/src/components/MkAutocomplete.vue
@@ -10,7 +10,7 @@
</li>
<li tabindex="-1" :class="$style.item" @click="chooseUser()" @keydown="onKeydown">{{ i18n.ts.selectUser }}</li>
</ol>
- <ol v-else-if="hashtags.length > 0" ref="suggests" :class="[$style.list, $style.hashtags]">
+ <ol v-else-if="hashtags.length > 0" ref="suggests" :class="$style.list">
<li v-for="hashtag in hashtags" tabindex="-1" :class="$style.item" @click="complete(type, hashtag)" @keydown="onKeydown">
<span class="name">{{ hashtag }}</span>
</li>
@@ -42,7 +42,7 @@ import { acct } from '@/filters/user';
import * as os from '@/os';
import { MFM_TAGS } from '@/scripts/mfm-tags';
import { defaultStore } from '@/store';
-import { emojilist } from '@/scripts/emojilist';
+import { emojilist, getEmojiName } from '@/scripts/emojilist';
import { i18n } from '@/i18n';
import { miLocalStorage } from '@/local-storage';
import { customEmojis } from '@/custom-emojis';
@@ -71,14 +71,14 @@ const emojiDb = computed(() => {
url: char2path(x.char),
}));
- for (const x of lib) {
- if (x.keywords) {
- for (const k of x.keywords) {
+ for (const index of Object.values(defaultStore.state.additionalUnicodeEmojiIndexes)) {
+ for (const [emoji, keywords] of Object.entries(index)) {
+ for (const k of keywords) {
unicodeEmojiDB.push({
- emoji: x.char,
+ emoji: emoji,
name: k,
- aliasOf: x.name,
- url: char2path(x.char),
+ aliasOf: getEmojiName(emoji)!,
+ url: char2path(emoji),
});
}
}
diff --git a/packages/frontend/src/components/MkAvatars.vue b/packages/frontend/src/components/MkAvatars.vue
index 995a72e511..630620fc08 100644
--- a/packages/frontend/src/components/MkAvatars.vue
+++ b/packages/frontend/src/components/MkAvatars.vue
@@ -1,7 +1,7 @@
<template>
<div>
<div v-for="user in users" :key="user.id" style="display:inline-block;width:32px;height:32px;margin-right:8px;">
- <MkAvatar :user="user" style="width:32px;height:32px;" indicator link preview/>
+ <MkAvatar :user="user" style="width:32px; height:32px;" indicator link preview/>
</div>
</div>
</template>
diff --git a/packages/frontend/src/components/MkButton.vue b/packages/frontend/src/components/MkButton.vue
index 0ddee34f0a..16e44ec618 100644
--- a/packages/frontend/src/components/MkButton.vue
+++ b/packages/frontend/src/components/MkButton.vue
@@ -2,23 +2,23 @@
<button
v-if="!link"
ref="el" class="_button"
- :class="[$style.root, { [$style.inline]: inline, [$style.primary]: primary, [$style.gradate]: gradate, [$style.danger]: danger, [$style.rounded]: rounded, [$style.full]: full, [$style.small]: small, [$style.large]: large, [$style.asLike]: asLike }]"
+ :class="[$style.root, { [$style.inline]: inline, [$style.primary]: primary, [$style.gradate]: gradate, [$style.danger]: danger, [$style.rounded]: rounded, [$style.full]: full, [$style.small]: small, [$style.large]: large, [$style.transparent]: transparent, [$style.asLike]: asLike }]"
:type="type"
@click="emit('click', $event)"
@mousedown="onMousedown"
>
- <div ref="ripples" :class="$style.ripples"></div>
+ <div ref="ripples" :class="$style.ripples" :data-children-class="$style.ripple"></div>
<div :class="$style.content">
<slot></slot>
</div>
</button>
<MkA
v-else class="_button"
- :class="[$style.root, { [$style.inline]: inline, [$style.primary]: primary, [$style.gradate]: gradate, [$style.danger]: danger, [$style.rounded]: rounded, [$style.full]: full, [$style.small]: small, [$style.large]: large, [$style.asLike]: asLike }]"
+ :class="[$style.root, { [$style.inline]: inline, [$style.primary]: primary, [$style.gradate]: gradate, [$style.danger]: danger, [$style.rounded]: rounded, [$style.full]: full, [$style.small]: small, [$style.large]: large, [$style.transparent]: transparent, [$style.asLike]: asLike }]"
:to="to"
@mousedown="onMousedown"
>
- <div ref="ripples" :class="$style.ripples"></div>
+ <div ref="ripples" :class="$style.ripples" :data-children-class="$style.ripple"></div>
<div :class="$style.content">
<slot></slot>
</div>
@@ -26,9 +26,7 @@
</template>
<script lang="ts" setup>
-import { nextTick, onMounted, useCssModule } from 'vue';
-
-const $style = useCssModule();
+import { nextTick, onMounted } from 'vue';
const props = defineProps<{
type?: 'button' | 'submit' | 'reset';
@@ -44,6 +42,7 @@ const props = defineProps<{
full?: boolean;
small?: boolean;
large?: boolean;
+ transparent?: boolean;
asLike?: boolean;
}>();
@@ -80,7 +79,7 @@ function onMousedown(evt: MouseEvent): void {
const rect = target.getBoundingClientRect();
const ripple = document.createElement('div');
- ripple.classList.add($style.ripple);
+ ripple.classList.add(ripples!.dataset.childrenClass!);
ripple.style.top = (evt.clientY - rect.top - 1).toString() + 'px';
ripple.style.left = (evt.clientX - rect.left - 1).toString() + 'px';
@@ -194,6 +193,10 @@ function onMousedown(evt: MouseEvent): void {
}
}
+ &.transparent {
+ background: transparent;
+ }
+
&.gradate {
font-weight: bold;
color: var(--fgOnAccent) !important;
diff --git a/packages/frontend/src/components/MkChannelFollowButton.vue b/packages/frontend/src/components/MkChannelFollowButton.vue
index 9e275d6172..7b7bef4787 100644
--- a/packages/frontend/src/components/MkChannelFollowButton.vue
+++ b/packages/frontend/src/components/MkChannelFollowButton.vue
@@ -1,20 +1,20 @@
<template>
<button
- class="hdcaacmi _button"
- :class="{ wait, active: isFollowing, full }"
+ class="_button"
+ :class="[$style.root, { [$style.wait]: wait, [$style.active]: isFollowing, [$style.full]: full }]"
:disabled="wait"
@click="onClick"
>
<template v-if="!wait">
<template v-if="isFollowing">
- <span v-if="full">{{ i18n.ts.unfollow }}</span><i class="ti ti-minus"></i>
+ <span v-if="full" :class="$style.text">{{ i18n.ts.unfollow }}</span><i class="ti ti-minus"></i>
</template>
<template v-else>
- <span v-if="full">{{ i18n.ts.follow }}</span><i class="ti ti-plus"></i>
+ <span v-if="full" :class="$style.text">{{ i18n.ts.follow }}</span><i class="ti ti-plus"></i>
</template>
</template>
<template v-else>
- <span v-if="full">{{ i18n.ts.processing }}</span><MkLoading :em="true"/>
+ <span v-if="full" :class="$style.text">{{ i18n.ts.processing }}</span><MkLoading :em="true"/>
</template>
</button>
</template>
@@ -57,8 +57,8 @@ async function onClick() {
}
</script>
-<style lang="scss" scoped>
-.hdcaacmi {
+<style lang="scss" module>
+.root {
position: relative;
display: inline-block;
font-weight: bold;
@@ -103,7 +103,7 @@ async function onClick() {
}
&.active {
- color: #fff;
+ color: var(--fgOnAccent);
background: var(--accent);
&:hover {
@@ -121,9 +121,9 @@ async function onClick() {
cursor: wait !important;
opacity: 0.7;
}
+}
- > span {
- margin-right: 6px;
- }
+.text {
+ margin-right: 6px;
}
</style>
diff --git a/packages/frontend/src/components/MkChannelList.vue b/packages/frontend/src/components/MkChannelList.vue
index 408eab7399..4050520eb9 100644
--- a/packages/frontend/src/components/MkChannelList.vue
+++ b/packages/frontend/src/components/MkChannelList.vue
@@ -26,6 +26,3 @@ const props = withDefaults(defineProps<{
extractor: (item) => item,
});
</script>
-
-<style lang="scss" scoped>
-</style>
diff --git a/packages/frontend/src/components/MkChart.vue b/packages/frontend/src/components/MkChart.vue
index 06d5b9949a..00ff98774b 100644
--- a/packages/frontend/src/components/MkChart.vue
+++ b/packages/frontend/src/components/MkChart.vue
@@ -1,8 +1,8 @@
<template>
-<div class="cbbedffa">
+<div :class="$style.root">
<canvas ref="chartEl"></canvas>
<MkChartLegend ref="legendEl" style="margin-top: 8px;"/>
- <div v-if="fetching" class="fetching">
+ <div v-if="fetching" :class="$style.fetching">
<MkLoading/>
</div>
</div>
@@ -817,22 +817,22 @@ onMounted(() => {
/* eslint-enable id-denylist */
</script>
-<style lang="scss" scoped>
-.cbbedffa {
+<style lang="scss" module>
+.root {
position: relative;
+}
- > .fetching {
- position: absolute;
- top: 0;
- left: 0;
- width: 100%;
- height: 100%;
- -webkit-backdrop-filter: var(--blur, blur(12px));
- backdrop-filter: var(--blur, blur(12px));
- display: flex;
- justify-content: center;
- align-items: center;
- cursor: wait;
- }
+.fetching {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ -webkit-backdrop-filter: var(--blur, blur(12px));
+ backdrop-filter: var(--blur, blur(12px));
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ cursor: wait;
}
</style>
diff --git a/packages/frontend/src/components/MkChartTooltip.vue b/packages/frontend/src/components/MkChartTooltip.vue
index 7cfe535edd..fe5b78754d 100644
--- a/packages/frontend/src/components/MkChartTooltip.vue
+++ b/packages/frontend/src/components/MkChartTooltip.vue
@@ -1,5 +1,5 @@
<template>
-<MkTooltip ref="tooltip" :showing="showing" :x="x" :y="y" :max-width="340" :direction="'top'" :inner-margin="16" @closed="emit('closed')">
+<MkTooltip ref="tooltip" :showing="showing" :x="x" :y="y" :maxWidth="340" :direction="'top'" :innerMargin="16" @closed="emit('closed')">
<div v-if="title || series">
<div v-if="title" :class="$style.title">{{ title }}</div>
<template v-if="series">
diff --git a/packages/frontend/src/components/MkClickerGame.vue b/packages/frontend/src/components/MkClickerGame.vue
index da6439fd2c..a6ab5aded4 100644
--- a/packages/frontend/src/components/MkClickerGame.vue
+++ b/packages/frontend/src/components/MkClickerGame.vue
@@ -3,7 +3,7 @@
<div v-if="game.ready" :class="$style.game">
<div :class="$style.cps" class="">{{ number(cps) }}cps</div>
<div :class="$style.count" class=""><i class="ti ti-cookie" style="font-size: 70%;"></i> {{ number(cookies) }}</div>
- <button v-click-anime class="_button" :class="$style.button" @click="onClick">
+ <button v-click-anime class="_button" @click="onClick">
<img src="/client-assets/cookie.png" :class="$style.img">
</button>
</div>
@@ -84,10 +84,6 @@ onUnmounted(() => {
margin-bottom: 6px;
}
-.button {
-
-}
-
.img {
max-width: 90px;
}
diff --git a/packages/frontend/src/components/MkContainer.vue b/packages/frontend/src/components/MkContainer.vue
index d03331a6eb..af1c57b349 100644
--- a/packages/frontend/src/components/MkContainer.vue
+++ b/packages/frontend/src/components/MkContainer.vue
@@ -1,12 +1,12 @@
<template>
-<div ref="rootEl" class="_panel" :class="[$style.root, { [$style.naked]: naked, [$style.thin]: thin, [$style.hideHeader]: !showHeader, [$style.scrollable]: scrollable, [$style.closed]: !showBody }]">
+<div ref="rootEl" class="_panel" :class="[$style.root, { [$style.naked]: naked, [$style.thin]: thin, [$style.scrollable]: scrollable }]">
<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>
</div>
<div :class="$style.headerSub">
- <slot name="func" :button-style-class="$style.headerButton"></slot>
+ <slot name="func" :buttonStyleClass="$style.headerButton"></slot>
<button v-if="foldable" :class="$style.headerButton" class="_button" @click="() => showBody = !showBody">
<template v-if="showBody"><i class="ti ti-chevron-up"></i></template>
<template v-else><i class="ti ti-chevron-down"></i></template>
@@ -14,14 +14,14 @@
</div>
</header>
<Transition
- :enter-active-class="defaultStore.state.animation ? $style.transition_toggle_enterActive : ''"
- :leave-active-class="defaultStore.state.animation ? $style.transition_toggle_leaveActive : ''"
- :enter-from-class="defaultStore.state.animation ? $style.transition_toggle_enterFrom : ''"
- :leave-to-class="defaultStore.state.animation ? $style.transition_toggle_leaveTo : ''"
+ :enterActiveClass="defaultStore.state.animation ? $style.transition_toggle_enterActive : ''"
+ :leaveActiveClass="defaultStore.state.animation ? $style.transition_toggle_leaveActive : ''"
+ :enterFromClass="defaultStore.state.animation ? $style.transition_toggle_enterFrom : ''"
+ :leaveToClass="defaultStore.state.animation ? $style.transition_toggle_leaveTo : ''"
@enter="enter"
- @after-enter="afterEnter"
+ @afterEnter="afterEnter"
@leave="leave"
- @after-leave="afterLeave"
+ @afterLeave="afterLeave"
>
<div v-show="showBody" ref="contentEl" :class="[$style.content, { [$style.omitted]: omitted }]">
<slot></slot>
@@ -34,7 +34,7 @@
</template>
<script lang="ts" setup>
-import { onMounted, ref, shallowRef, watch } from 'vue';
+import { onMounted, onUnmounted, ref, shallowRef, watch } from 'vue';
import { defaultStore } from '@/store';
import { i18n } from '@/i18n';
@@ -83,13 +83,19 @@ function afterLeave(el) {
const calcOmit = () => {
if (omitted.value || ignoreOmit.value || props.maxHeight == null) return;
+ if (!contentEl.value) return;
const height = contentEl.value.offsetHeight;
omitted.value = height > props.maxHeight;
};
+const omitObserver = new ResizeObserver((entries, observer) => {
+ calcOmit();
+});
+
onMounted(() => {
watch(showBody, v => {
- const headerHeight = props.showHeader ? headerEl.value.offsetHeight : 0;
+ if (!rootEl.value) return;
+ const headerHeight = props.showHeader ? headerEl.value?.offsetHeight ?? 0 : 0;
rootEl.value.style.minHeight = `${headerHeight}px`;
if (v) {
rootEl.value.style.flexBasis = 'auto';
@@ -100,13 +106,15 @@ onMounted(() => {
immediate: true,
});
- rootEl.value.style.setProperty('--maxHeight', props.maxHeight + 'px');
+ if (rootEl.value) rootEl.value.style.setProperty('--maxHeight', props.maxHeight + 'px');
calcOmit();
- new ResizeObserver((entries, observer) => {
- calcOmit();
- }).observe(contentEl.value);
+ if (contentEl.value) omitObserver.observe(contentEl.value);
+});
+
+onUnmounted(() => {
+ omitObserver.disconnect();
});
</script>
diff --git a/packages/frontend/src/components/MkContextMenu.vue b/packages/frontend/src/components/MkContextMenu.vue
index b81c806b0c..fb11834f4d 100644
--- a/packages/frontend/src/components/MkContextMenu.vue
+++ b/packages/frontend/src/components/MkContextMenu.vue
@@ -1,10 +1,10 @@
<template>
<Transition
appear
- :enter-active-class="defaultStore.state.animation ? $style.transition_fade_enterActive : ''"
- :leave-active-class="defaultStore.state.animation ? $style.transition_fade_leaveActive : ''"
- :enter-from-class="defaultStore.state.animation ? $style.transition_fade_enterFrom : ''"
- :leave-to-class="defaultStore.state.animation ? $style.transition_fade_leaveTo : ''"
+ :enterActiveClass="defaultStore.state.animation ? $style.transition_fade_enterActive : ''"
+ :leaveActiveClass="defaultStore.state.animation ? $style.transition_fade_leaveActive : ''"
+ :enterFromClass="defaultStore.state.animation ? $style.transition_fade_enterFrom : ''"
+ :leaveToClass="defaultStore.state.animation ? $style.transition_fade_leaveTo : ''"
>
<div ref="rootEl" :class="$style.root" :style="{ zIndex }" @contextmenu.prevent.stop="() => {}">
<MkMenu :items="items" :align="'left'" @close="$emit('closed')"/>
diff --git a/packages/frontend/src/components/MkCropperDialog.vue b/packages/frontend/src/components/MkCropperDialog.vue
index 043a614e46..82363499b7 100644
--- a/packages/frontend/src/components/MkCropperDialog.vue
+++ b/packages/frontend/src/components/MkCropperDialog.vue
@@ -4,7 +4,7 @@
:width="800"
:height="500"
:scroll="false"
- :with-ok-button="true"
+ :withOkButton="true"
@close="cancel()"
@ok="ok()"
@closed="$emit('closed')"
diff --git a/packages/frontend/src/components/MkDateSeparatedList.vue b/packages/frontend/src/components/MkDateSeparatedList.vue
index d6303f9675..6942a0e6c3 100644
--- a/packages/frontend/src/components/MkDateSeparatedList.vue
+++ b/packages/frontend/src/components/MkDateSeparatedList.vue
@@ -36,7 +36,7 @@ export default defineComponent({
},
setup(props, { slots, expose }) {
- const $style = useCssModule();
+ const $style = useCssModule(); // カスタムレンダラなので使っても大丈夫
function getDateText(time: string) {
const date = new Date(time).getDate();
const month = new Date(time).getMonth() + 1;
diff --git a/packages/frontend/src/components/MkDialog.vue b/packages/frontend/src/components/MkDialog.vue
index 9f5404ce15..4d5df0bba4 100644
--- a/packages/frontend/src/components/MkDialog.vue
+++ b/packages/frontend/src/components/MkDialog.vue
@@ -1,10 +1,18 @@
<template>
-<MkModal ref="modal" :prefer-type="'dialog'" :z-priority="'high'" @click="done(true)" @closed="emit('closed')">
+<MkModal ref="modal" :preferType="'dialog'" :zPriority="'high'" @click="done(true)" @closed="emit('closed')">
<div :class="$style.root">
<div v-if="icon" :class="$style.icon">
<i :class="icon"></i>
</div>
- <div v-else-if="!input && !select" :class="[$style.icon, $style['type_' + type]]">
+ <div
+ v-else-if="!input && !select"
+ :class="[$style.icon, {
+ [$style.type_success]: type === 'success',
+ [$style.type_error]: type === 'error',
+ [$style.type_warning]: type === 'warning',
+ [$style.type_info]: type === 'info',
+ }]"
+ >
<i v-if="type === 'success'" :class="$style.iconInner" class="ti ti-check"></i>
<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>
diff --git a/packages/frontend/src/components/MkDigitalClock.stories.impl.ts b/packages/frontend/src/components/MkDigitalClock.stories.impl.ts
new file mode 100644
index 0000000000..344f6de47c
--- /dev/null
+++ b/packages/frontend/src/components/MkDigitalClock.stories.impl.ts
@@ -0,0 +1,32 @@
+/* eslint-disable @typescript-eslint/explicit-function-return-type */
+import { StoryObj } from '@storybook/vue3';
+import isChromatic from 'chromatic/isChromatic';
+import MkDigitalClock from './MkDigitalClock.vue';
+export const Default = {
+ render(args) {
+ return {
+ components: {
+ MkDigitalClock,
+ },
+ setup() {
+ return {
+ args,
+ };
+ },
+ computed: {
+ props() {
+ return {
+ ...this.args,
+ };
+ },
+ },
+ template: '<MkDigitalClock v-bind="props" />',
+ };
+ },
+ args: {
+ now: isChromatic() ? () => new Date('2023-01-01T10:10:30') : undefined,
+ },
+ parameters: {
+ layout: 'centered',
+ },
+} satisfies StoryObj<typeof MkDigitalClock>;
diff --git a/packages/frontend/src/components/MkDigitalClock.vue b/packages/frontend/src/components/MkDigitalClock.vue
index 278dc8a5e7..aea20f2489 100644
--- a/packages/frontend/src/components/MkDigitalClock.vue
+++ b/packages/frontend/src/components/MkDigitalClock.vue
@@ -11,19 +11,21 @@
</template>
<script lang="ts" setup>
-import { onUnmounted, ref, watch } from 'vue';
+import { onMounted, onUnmounted, ref, watch } from 'vue';
+import { defaultIdlingRenderScheduler } from '@/scripts/idle-render.js';
const props = withDefaults(defineProps<{
showS?: boolean;
showMs?: boolean;
offset?: number;
+ now?: () => Date;
}>(), {
showS: true,
showMs: false,
offset: 0 - new Date().getTimezoneOffset(),
+ now: () => new Date(),
});
-let intervalId;
const hh = ref('');
const mm = ref('');
const ss = ref('');
@@ -39,9 +41,9 @@ watch(showColon, (v) => {
}
});
-const tick = () => {
- const now = new Date();
- now.setMinutes(now.getMinutes() + (new Date().getTimezoneOffset() + props.offset));
+const tick = (): void => {
+ const now = props.now();
+ now.setMinutes(now.getMinutes() + now.getTimezoneOffset() + props.offset);
hh.value = now.getHours().toString().padStart(2, '0');
mm.value = now.getMinutes().toString().padStart(2, '0');
ss.value = now.getSeconds().toString().padStart(2, '0');
@@ -52,13 +54,12 @@ const tick = () => {
tick();
-watch(() => props.showMs, () => {
- if (intervalId) window.clearInterval(intervalId);
- intervalId = window.setInterval(tick, props.showMs ? 10 : 1000);
-}, { immediate: true });
+onMounted(() => {
+ defaultIdlingRenderScheduler.add(tick);
+});
onUnmounted(() => {
- window.clearInterval(intervalId);
+ defaultIdlingRenderScheduler.delete(tick);
});
</script>
diff --git a/packages/frontend/src/components/MkDrive.file.vue b/packages/frontend/src/components/MkDrive.file.vue
index ab408b5008..f0641161be 100644
--- a/packages/frontend/src/components/MkDrive.file.vue
+++ b/packages/frontend/src/components/MkDrive.file.vue
@@ -1,7 +1,6 @@
<template>
<div
- class="ncvczrfv"
- :class="{ isSelected }"
+ :class="[$style.root, { [$style.isSelected]: isSelected }]"
draggable="true"
:title="title"
@click="onClick"
@@ -9,25 +8,27 @@
@dragstart="onDragstart"
@dragend="onDragend"
>
- <div v-if="$i?.avatarId == file.id" class="label">
- <img src="/client-assets/label.svg"/>
- <p>{{ i18n.ts.avatar }}</p>
- </div>
- <div v-if="$i?.bannerId == file.id" class="label">
- <img src="/client-assets/label.svg"/>
- <p>{{ i18n.ts.banner }}</p>
- </div>
- <div v-if="file.isSensitive" class="label red">
- <img src="/client-assets/label-red.svg"/>
- <p>{{ i18n.ts.nsfw }}</p>
- </div>
+ <div style="pointer-events: none;">
+ <div v-if="$i?.avatarId == file.id" :class="[$style.label]">
+ <img :class="$style.labelImg" src="/client-assets/label.svg"/>
+ <p :class="$style.labelText">{{ i18n.ts.avatar }}</p>
+ </div>
+ <div v-if="$i?.bannerId == file.id" :class="[$style.label]">
+ <img :class="$style.labelImg" src="/client-assets/label.svg"/>
+ <p :class="$style.labelText">{{ i18n.ts.banner }}</p>
+ </div>
+ <div v-if="file.isSensitive" :class="[$style.label, $style.red]">
+ <img :class="$style.labelImg" src="/client-assets/label-red.svg"/>
+ <p :class="$style.labelText">{{ i18n.ts.nsfw }}</p>
+ </div>
- <MkDriveFileThumbnail class="thumbnail" :file="file" fit="contain"/>
+ <MkDriveFileThumbnail :class="$style.thumbnail" :file="file" fit="contain"/>
- <p class="name">
- <span>{{ file.name.lastIndexOf('.') != -1 ? file.name.substr(0, file.name.lastIndexOf('.')) : file.name }}</span>
- <span v-if="file.name.lastIndexOf('.') != -1" class="ext">{{ file.name.substr(file.name.lastIndexOf('.')) }}</span>
- </p>
+ <p :class="$style.name">
+ <span>{{ file.name.lastIndexOf('.') != -1 ? file.name.substr(0, file.name.lastIndexOf('.')) : file.name }}</span>
+ <span v-if="file.name.lastIndexOf('.') != -1" style="opacity: 0.5;">{{ file.name.substr(file.name.lastIndexOf('.')) }}</span>
+ </p>
+ </div>
</div>
</template>
@@ -88,20 +89,13 @@ function onDragend() {
}
</script>
-<style lang="scss" scoped>
-.ncvczrfv {
+<style lang="scss" module>
+.root {
position: relative;
padding: 8px 0 0 0;
min-height: 180px;
border-radius: 8px;
-
- &, * {
- cursor: pointer;
- }
-
- > * {
- pointer-events: none;
- }
+ cursor: pointer;
&:hover {
background: rgba(#000, 0.05);
@@ -165,82 +159,78 @@ function onDragend() {
color: #fff;
}
}
+}
+
+.label {
+ position: absolute;
+ top: 0;
+ left: 0;
+ pointer-events: none;
- > .label {
+ &:before,
+ &:after {
+ content: "";
+ display: block;
position: absolute;
+ z-index: 1;
+ background: #0c7ac9;
+ }
+
+ &:before {
top: 0;
+ left: 57px;
+ width: 28px;
+ height: 8px;
+ }
+
+ &:after {
+ top: 57px;
left: 0;
- pointer-events: none;
+ width: 8px;
+ height: 28px;
+ }
+ &.red {
&:before,
&:after {
- content: "";
- display: block;
- position: absolute;
- z-index: 1;
- background: #0c7ac9;
- }
-
- &:before {
- top: 0;
- left: 57px;
- width: 28px;
- height: 8px;
- }
-
- &:after {
- top: 57px;
- left: 0;
- width: 8px;
- height: 28px;
- }
-
- &.red {
- &:before,
- &:after {
- background: #c12113;
- }
- }
-
- > img {
- position: absolute;
- z-index: 2;
- top: 0;
- left: 0;
- }
-
- > p {
- position: absolute;
- z-index: 3;
- top: 19px;
- left: -28px;
- width: 120px;
- margin: 0;
- text-align: center;
- line-height: 28px;
- color: #fff;
- transform: rotate(-45deg);
+ background: #c12113;
}
}
+}
- > .thumbnail {
- width: 110px;
- height: 110px;
- margin: auto;
- }
+.labelImg {
+ position: absolute;
+ z-index: 2;
+ top: 0;
+ left: 0;
+}
- > .name {
- display: block;
- margin: 4px 0 0 0;
- font-size: 0.8em;
- text-align: center;
- word-break: break-all;
- color: var(--fg);
- overflow: hidden;
+.labelText {
+ position: absolute;
+ z-index: 3;
+ top: 19px;
+ left: -28px;
+ width: 120px;
+ margin: 0;
+ text-align: center;
+ line-height: 28px;
+ color: #fff;
+ transform: rotate(-45deg);
+}
- > .ext {
- opacity: 0.5;
- }
- }
+.thumbnail {
+ width: 110px;
+ height: 110px;
+ margin: auto;
+}
+
+.name {
+ display: block;
+ margin: 4px 0 0 0;
+ font-size: 0.8em;
+ text-align: center;
+ word-break: break-all;
+ color: var(--fg);
+ overflow: hidden;
}
</style>
diff --git a/packages/frontend/src/components/MkDrive.folder.vue b/packages/frontend/src/components/MkDrive.folder.vue
index 156013b9aa..1969342402 100644
--- a/packages/frontend/src/components/MkDrive.folder.vue
+++ b/packages/frontend/src/components/MkDrive.folder.vue
@@ -1,7 +1,6 @@
<template>
<div
- class="rghtznwe"
- :class="{ draghover }"
+ :class="[$style.root, { [$style.draghover]: draghover }]"
draggable="true"
:title="title"
@click="onClick"
@@ -15,15 +14,15 @@
@dragstart="onDragstart"
@dragend="onDragend"
>
- <p class="name">
- <template v-if="hover"><i class="ti ti-folder ti-fw"></i></template>
- <template v-if="!hover"><i class="ti ti-folder ti-fw"></i></template>
+ <p :class="$style.name">
+ <template v-if="hover"><i :class="$style.icon" class="ti ti-folder ti-fw"></i></template>
+ <template v-if="!hover"><i :class="$style.icon" class="ti ti-folder ti-fw"></i></template>
{{ folder.name }}
</p>
- <p v-if="defaultStore.state.uploadFolder == folder.id" class="upload">
+ <p v-if="defaultStore.state.uploadFolder == folder.id" :class="$style.upload">
{{ i18n.ts.uploadFolder }}
</p>
- <button v-if="selectMode" class="checkbox _button" :class="{ checked: isSelected }" @click.prevent.stop="checkboxClicked"></button>
+ <button v-if="selectMode" class="_button" :class="[$style.checkbox, { [$style.checked]: isSelected }]" @click.prevent.stop="checkboxClicked"></button>
</div>
</template>
@@ -267,35 +266,14 @@ function onContextmenu(ev: MouseEvent) {
}
</script>
-<style lang="scss" scoped>
-.rghtznwe {
+<style lang="scss" module>
+.root {
position: relative;
padding: 8px;
height: 64px;
background: var(--driveFolderBg);
border-radius: 4px;
-
- &, * {
- cursor: pointer;
- }
-
- *:not(.checkbox) {
- pointer-events: none;
- }
-
- > .checkbox {
- position: absolute;
- bottom: 8px;
- right: 8px;
- width: 16px;
- height: 16px;
- background: #fff;
- border: solid 1px #000;
-
- &.checked {
- background: var(--accent);
- }
- }
+ cursor: pointer;
&.draghover {
&:after {
@@ -310,24 +288,38 @@ function onContextmenu(ev: MouseEvent) {
border-radius: 4px;
}
}
+}
- > .name {
- margin: 0;
- font-size: 0.9em;
- color: var(--desktopDriveFolderFg);
+.checkbox {
+ position: absolute;
+ bottom: 8px;
+ right: 8px;
+ width: 16px;
+ height: 16px;
+ background: #fff;
+ border: solid 1px #000;
- > i {
- margin-right: 4px;
- margin-left: 2px;
- text-align: left;
- }
+ &.checked {
+ background: var(--accent);
}
+}
- > .upload {
- margin: 4px 4px;
- font-size: 0.8em;
- text-align: right;
- color: var(--desktopDriveFolderFg);
- }
+.name {
+ margin: 0;
+ font-size: 0.9em;
+ color: var(--desktopDriveFolderFg);
+}
+
+.icon {
+ margin-right: 4px;
+ margin-left: 2px;
+ text-align: left;
+}
+
+.upload {
+ margin: 4px 4px;
+ font-size: 0.8em;
+ text-align: right;
+ color: var(--desktopDriveFolderFg);
}
</style>
diff --git a/packages/frontend/src/components/MkDrive.navFolder.vue b/packages/frontend/src/components/MkDrive.navFolder.vue
index dbbfef5f05..3349603d3b 100644
--- a/packages/frontend/src/components/MkDrive.navFolder.vue
+++ b/packages/frontend/src/components/MkDrive.navFolder.vue
@@ -1,13 +1,13 @@
<template>
-<div class="drylbebk"
- :class="{ draghover }"
+<div
+ :class="[$style.root, { [$style.draghover]: draghover }]"
@click="onClick"
@dragover.prevent.stop="onDragover"
@dragenter="onDragenter"
@dragleave="onDragleave"
@drop.stop="onDrop"
>
- <i v-if="folder == null" class="ti ti-cloud"></i>
+ <i v-if="folder == null" class="ti ti-cloud" style="margin-right: 4px;"></i>
<span>{{ folder == null ? i18n.ts.drive : folder.name }}</span>
</div>
</template>
@@ -130,18 +130,10 @@ function onDrop(ev: DragEvent) {
}
</script>
-<style lang="scss" scoped>
-.drylbebk {
- > * {
- pointer-events: none;
- }
-
+<style lang="scss" module>
+.root {
&.draghover {
background: #eee;
}
-
- > i {
- margin-right: 4px;
- }
}
</style>
diff --git a/packages/frontend/src/components/MkDrive.vue b/packages/frontend/src/components/MkDrive.vue
index bfec57d6a0..52aef450d9 100644
--- a/packages/frontend/src/components/MkDrive.vue
+++ b/packages/frontend/src/components/MkDrive.vue
@@ -1,89 +1,90 @@
<template>
-<div class="yfudmmck">
- <nav>
- <div class="path" @contextmenu.prevent.stop="() => {}">
+<div :class="$style.root">
+ <nav :class="$style.nav">
+ <div :class="$style.navPath" @contextmenu.prevent.stop="() => {}">
<XNavFolder
- :class="{ current: folder == null }"
- :parent-folder="folder"
+ :class="[$style.navPathItem, { [$style.navCurrent]: folder == null }]"
+ :parentFolder="folder"
@move="move"
@upload="upload"
- @remove-file="removeFile"
- @remove-folder="removeFolder"
+ @removeFile="removeFile"
+ @removeFolder="removeFolder"
/>
<template v-for="f in hierarchyFolders">
- <span class="separator"><i class="ti ti-chevron-right"></i></span>
+ <span :class="[$style.navPathItem, $style.navSeparator]"><i class="ti ti-chevron-right"></i></span>
<XNavFolder
:folder="f"
- :parent-folder="folder"
+ :parentFolder="folder"
+ :class="[$style.navPathItem]"
@move="move"
@upload="upload"
- @remove-file="removeFile"
- @remove-folder="removeFolder"
+ @removeFile="removeFile"
+ @removeFolder="removeFolder"
/>
</template>
- <span v-if="folder != null" class="separator"><i class="ti ti-chevron-right"></i></span>
- <span v-if="folder != null" class="folder current">{{ folder.name }}</span>
+ <span v-if="folder != null" :class="[$style.navPathItem, $style.navSeparator]"><i class="ti ti-chevron-right"></i></span>
+ <span v-if="folder != null" :class="[$style.navPathItem, $style.navCurrent]">{{ folder.name }}</span>
</div>
- <button class="menu _button" @click="showMenu"><i class="ti ti-dots"></i></button>
+ <button class="_button" :class="$style.navMenu" @click="showMenu"><i class="ti ti-dots"></i></button>
</nav>
<div
- ref="main" class="main"
- :class="{ uploading: uploadings.length > 0, fetching }"
+ ref="main"
+ :class="[$style.main, { [$style.uploading]: uploadings.length > 0, [$style.fetching]: fetching }]"
@dragover.prevent.stop="onDragover"
@dragenter="onDragenter"
@dragleave="onDragleave"
@drop.prevent.stop="onDrop"
@contextmenu.stop="onContextmenu"
>
- <div ref="contents" class="contents">
- <div v-show="folders.length > 0" ref="foldersContainer" class="folders">
+ <div ref="contents">
+ <div v-show="folders.length > 0" ref="foldersContainer" :class="$style.folders">
<XFolder
v-for="(f, i) in folders"
:key="f.id"
v-anim="i"
- class="folder"
+ :class="$style.folder"
:folder="f"
- :select-mode="select === 'folder'"
- :is-selected="selectedFolders.some(x => x.id === f.id)"
+ :selectMode="select === 'folder'"
+ :isSelected="selectedFolders.some(x => x.id === f.id)"
@chosen="chooseFolder"
@move="move"
@upload="upload"
- @remove-file="removeFile"
- @remove-folder="removeFolder"
+ @removeFile="removeFile"
+ @removeFolder="removeFolder"
@dragstart="isDragSource = true"
@dragend="isDragSource = false"
/>
<!-- SEE: https://stackoverflow.com/questions/18744164/flex-box-align-last-row-to-grid -->
- <div v-for="(n, i) in 16" :key="i" class="padding"></div>
+ <div v-for="(n, i) in 16" :key="i" :class="$style.padding"></div>
<MkButton v-if="moreFolders" ref="moreFolders">{{ i18n.ts.loadMore }}</MkButton>
</div>
- <div v-show="files.length > 0" ref="filesContainer" class="files">
+ <div v-show="files.length > 0" ref="filesContainer" :class="$style.files">
<XFile
v-for="(file, i) in files"
:key="file.id"
v-anim="i"
- class="file"
+ :class="$style.file"
:file="file"
- :select-mode="select === 'file'"
- :is-selected="selectedFiles.some(x => x.id === file.id)"
+ :selectMode="select === 'file'"
+ :isSelected="selectedFiles.some(x => x.id === file.id)"
@chosen="chooseFile"
@dragstart="isDragSource = true"
@dragend="isDragSource = false"
/>
<!-- SEE: https://stackoverflow.com/questions/18744164/flex-box-align-last-row-to-grid -->
- <div v-for="(n, i) in 16" :key="i" class="padding"></div>
+ <div v-for="(n, i) in 16" :key="i" :class="$style.padding"></div>
<MkButton v-show="moreFiles" ref="loadMoreFiles" @click="fetchMoreFiles">{{ i18n.ts.loadMore }}</MkButton>
</div>
- <div v-if="files.length == 0 && folders.length == 0 && !fetching" class="empty">
- <p v-if="draghover">{{ i18n.t('empty-draghover') }}</p>
- <p v-if="!draghover && folder == null"><strong>{{ i18n.ts.emptyDrive }}</strong><br/>{{ i18n.t('empty-drive-description') }}</p>
- <p v-if="!draghover && folder != null">{{ i18n.ts.emptyFolder }}</p>
+ <div v-if="files.length == 0 && folders.length == 0 && !fetching" :class="$style.empty">
+ <div v-if="draghover">{{ i18n.t('empty-draghover') }}</div>
+ <div v-if="!draghover && folder == null"><strong>{{ i18n.ts.emptyDrive }}</strong><br/>{{ i18n.t('empty-drive-description') }}</div>
+ <div v-if="!draghover && folder != null">{{ i18n.ts.emptyFolder }}</div>
</div>
</div>
<MkLoading v-if="fetching"/>
</div>
- <div v-if="draghover" class="dropzone"></div>
- <input ref="fileInput" type="file" accept="*/*" multiple tabindex="-1" @change="onChangeFileInput"/>
+ <div v-if="draghover" :class="$style.dropzone"></div>
+ <input ref="fileInput" style="display: none;" type="file" accept="*/*" multiple tabindex="-1" @change="onChangeFileInput"/>
</div>
</template>
@@ -95,7 +96,7 @@ import XNavFolder from '@/components/MkDrive.navFolder.vue';
import XFolder from '@/components/MkDrive.folder.vue';
import XFile from '@/components/MkDrive.file.vue';
import * as os from '@/os';
-import { stream } from '@/stream';
+import { useStream } from '@/stream';
import { defaultStore } from '@/store';
import { i18n } from '@/i18n';
import { uploadFile, uploads } from '@/scripts/upload';
@@ -131,7 +132,7 @@ const hierarchyFolders = ref<Misskey.entities.DriveFolder[]>([]);
const selectedFiles = ref<Misskey.entities.DriveFile[]>([]);
const selectedFolders = ref<Misskey.entities.DriveFolder[]>([]);
const uploadings = uploads;
-const connection = stream.useChannel('drive');
+const connection = useStream().useChannel('drive');
const keepOriginal = ref<boolean>(defaultStore.state.keepOriginalUploading); // 外部渡しが多いので$refは使わないほうがよい
// ドロップされようとしているか
@@ -658,147 +659,116 @@ onBeforeUnmount(() => {
});
</script>
-<style lang="scss" scoped>
-.yfudmmck {
+<style lang="scss" module>
+.root {
display: flex;
flex-direction: column;
height: 100%;
+}
- > nav {
- display: flex;
- z-index: 2;
- width: 100%;
- padding: 0 8px;
- box-sizing: border-box;
- overflow: auto;
- font-size: 0.9em;
- box-shadow: 0 1px 0 var(--divider);
-
- &, * {
- user-select: none;
- }
-
- > .path {
- display: inline-block;
- vertical-align: bottom;
- line-height: 42px;
- white-space: nowrap;
-
- > * {
- display: inline-block;
- margin: 0;
- padding: 0 8px;
- line-height: 42px;
- cursor: pointer;
-
- * {
- pointer-events: none;
- }
-
- &:hover {
- text-decoration: underline;
- }
-
- &.current {
- font-weight: bold;
- cursor: default;
-
- &:hover {
- text-decoration: none;
- }
- }
+.nav {
+ display: flex;
+ z-index: 2;
+ width: 100%;
+ padding: 0 8px;
+ box-sizing: border-box;
+ overflow: auto;
+ font-size: 0.9em;
+ box-shadow: 0 1px 0 var(--divider);
+ user-select: none;
+}
- &.separator {
- margin: 0;
- padding: 0;
- opacity: 0.5;
- cursor: default;
+.navPath {
+ display: inline-block;
+ vertical-align: bottom;
+ line-height: 42px;
+ white-space: nowrap;
+}
- > i {
- margin: 0;
- }
- }
- }
- }
+.navPathItem {
+ display: inline-block;
+ margin: 0;
+ padding: 0 8px;
+ line-height: 42px;
+ cursor: pointer;
- > .menu {
- margin-left: auto;
- padding: 0 12px;
- }
+ &:hover {
+ text-decoration: underline;
}
- > .main {
- flex: 1;
- overflow: auto;
- padding: var(--margin);
+ &.navCurrent {
+ font-weight: bold;
+ cursor: default;
- &, * {
- user-select: none;
+ &:hover {
+ text-decoration: none;
}
+ }
- &.fetching {
- cursor: wait !important;
-
- * {
- pointer-events: none;
- }
-
- > .contents {
- opacity: 0.5;
- }
- }
+ &.navSeparator {
+ margin: 0;
+ padding: 0;
+ opacity: 0.5;
+ cursor: default;
+ }
+}
- &.uploading {
- height: calc(100% - 38px - 100px);
- }
+.navMenu {
+ margin-left: auto;
+ padding: 0 12px;
+}
- > .contents {
+.main {
+ flex: 1;
+ overflow: auto;
+ padding: var(--margin);
+ user-select: none;
- > .folders,
- > .files {
- display: flex;
- flex-wrap: wrap;
+ &.fetching {
+ cursor: wait !important;
+ opacity: 0.5;
+ pointer-events: none;
+ }
- > .folder,
- > .file {
- flex-grow: 1;
- width: 128px;
- margin: 4px;
- box-sizing: border-box;
- }
+ &.uploading {
+ height: calc(100% - 38px - 100px);
+ }
+}
- > .padding {
- flex-grow: 1;
- pointer-events: none;
- width: 128px + 8px;
- }
- }
+.folders,
+.files {
+ display: flex;
+ flex-wrap: wrap;
+}
- > .empty {
- padding: 16px;
- text-align: center;
- pointer-events: none;
- opacity: 0.5;
+.folder,
+.file {
+ flex-grow: 1;
+ width: 128px;
+ margin: 4px;
+ box-sizing: border-box;
+}
- > p {
- margin: 0;
- }
- }
- }
- }
+.padding {
+ flex-grow: 1;
+ pointer-events: none;
+ width: 128px + 8px;
+}
- > .dropzone {
- position: absolute;
- left: 0;
- top: 38px;
- width: 100%;
- height: calc(100% - 38px);
- border: dashed 2px var(--focus);
- pointer-events: none;
- }
+.empty {
+ padding: 16px;
+ text-align: center;
+ pointer-events: none;
+ opacity: 0.5;
+}
- > input {
- display: none;
- }
+.dropzone {
+ position: absolute;
+ left: 0;
+ top: 38px;
+ width: 100%;
+ height: calc(100% - 38px);
+ border: dashed 2px var(--focus);
+ pointer-events: none;
}
</style>
diff --git a/packages/frontend/src/components/MkDriveFileThumbnail.vue b/packages/frontend/src/components/MkDriveFileThumbnail.vue
index 33379ed5ca..490aed6e04 100644
--- a/packages/frontend/src/components/MkDriveFileThumbnail.vue
+++ b/packages/frontend/src/components/MkDriveFileThumbnail.vue
@@ -1,16 +1,16 @@
<template>
-<div ref="thumbnail" class="zdjebgpv">
+<div ref="thumbnail" :class="$style.root">
<ImgWithBlurhash v-if="isThumbnailAvailable" :hash="file.blurhash" :src="file.thumbnailUrl" :alt="file.name" :title="file.name" :cover="fit !== 'contain'"/>
- <i v-else-if="is === 'image'" class="ti ti-photo icon"></i>
- <i v-else-if="is === 'video'" class="ti ti-video icon"></i>
- <i v-else-if="is === 'audio' || is === 'midi'" class="ti ti-file-music icon"></i>
- <i v-else-if="is === 'csv'" class="ti ti-file-text icon"></i>
- <i v-else-if="is === 'pdf'" class="ti ti-file-text icon"></i>
- <i v-else-if="is === 'textfile'" class="ti ti-file-text icon"></i>
- <i v-else-if="is === 'archive'" class="ti ti-file-zip icon"></i>
- <i v-else class="ti ti-file icon"></i>
+ <i v-else-if="is === 'image'" class="ti ti-photo" :class="$style.icon"></i>
+ <i v-else-if="is === 'video'" class="ti ti-video" :class="$style.icon"></i>
+ <i v-else-if="is === 'audio' || is === 'midi'" class="ti ti-file-music" :class="$style.icon"></i>
+ <i v-else-if="is === 'csv'" class="ti ti-file-text" :class="$style.icon"></i>
+ <i v-else-if="is === 'pdf'" class="ti ti-file-text" :class="$style.icon"></i>
+ <i v-else-if="is === 'textfile'" class="ti ti-file-text" :class="$style.icon"></i>
+ <i v-else-if="is === 'archive'" class="ti ti-file-zip" :class="$style.icon"></i>
+ <i v-else class="ti ti-file" :class="$style.icon"></i>
- <i v-if="isThumbnailAvailable && is === 'video'" class="ti ti-video icon-sub"></i>
+ <i v-if="isThumbnailAvailable && is === 'video'" class="ti ti-video" :class="$style.iconSub"></i>
</div>
</template>
@@ -53,28 +53,28 @@ const isThumbnailAvailable = computed(() => {
});
</script>
-<style lang="scss" scoped>
-.zdjebgpv {
+<style lang="scss" module>
+.root {
position: relative;
display: flex;
background: var(--panel);
border-radius: 8px;
overflow: clip;
+}
- > .icon-sub {
- position: absolute;
- width: 30%;
- height: auto;
- margin: 0;
- right: 4%;
- bottom: 4%;
- }
+.iconSub {
+ position: absolute;
+ width: 30%;
+ height: auto;
+ margin: 0;
+ right: 4%;
+ bottom: 4%;
+}
- > .icon {
- pointer-events: none;
- margin: auto;
- font-size: 32px;
- color: #777;
- }
+.icon {
+ pointer-events: none;
+ margin: auto;
+ font-size: 32px;
+ color: #777;
}
</style>
diff --git a/packages/frontend/src/components/MkDriveSelectDialog.vue b/packages/frontend/src/components/MkDriveSelectDialog.vue
index 8d2b19c013..da873cb90b 100644
--- a/packages/frontend/src/components/MkDriveSelectDialog.vue
+++ b/packages/frontend/src/components/MkDriveSelectDialog.vue
@@ -3,8 +3,8 @@
ref="dialog"
:width="800"
:height="500"
- :with-ok-button="true"
- :ok-button-disabled="(type === 'file') && (selected.length === 0)"
+ :withOkButton="true"
+ :okButtonDisabled="(type === 'file') && (selected.length === 0)"
@click="cancel()"
@close="cancel()"
@ok="ok()"
@@ -14,7 +14,7 @@
{{ multiple ? ((type === 'file') ? i18n.ts.selectFiles : i18n.ts.selectFolders) : ((type === 'file') ? i18n.ts.selectFile : i18n.ts.selectFolder) }}
<span v-if="selected.length > 0" style="margin-left: 8px; opacity: 0.5;">({{ number(selected.length) }})</span>
</template>
- <XDrive :multiple="multiple" :select="type" @change-selection="onChangeSelection" @selected="ok()"/>
+ <XDrive :multiple="multiple" :select="type" @changeSelection="onChangeSelection" @selected="ok()"/>
</MkModalWindow>
</template>
diff --git a/packages/frontend/src/components/MkDriveWindow.vue b/packages/frontend/src/components/MkDriveWindow.vue
index 8b2abc15a3..64ccbec9c3 100644
--- a/packages/frontend/src/components/MkDriveWindow.vue
+++ b/packages/frontend/src/components/MkDriveWindow.vue
@@ -1,15 +1,15 @@
<template>
<MkWindow
ref="window"
- :initial-width="800"
- :initial-height="500"
- :can-resize="true"
+ :initialWidth="800"
+ :initialHeight="500"
+ :canResize="true"
@closed="emit('closed')"
>
<template #header>
{{ i18n.ts.drive }}
</template>
- <XDrive :initial-folder="initialFolder"/>
+ <XDrive :initialFolder="initialFolder"/>
</MkWindow>
</template>
diff --git a/packages/frontend/src/components/MkEmojiPicker.vue b/packages/frontend/src/components/MkEmojiPicker.vue
index 9eaf16374b..cf856fd31f 100644
--- a/packages/frontend/src/components/MkEmojiPicker.vue
+++ b/packages/frontend/src/components/MkEmojiPicker.vue
@@ -1,7 +1,8 @@
<template>
<div class="omfetrab" :class="['s' + size, 'w' + width, 'h' + height, { asDrawer, asWindow }]" :style="{ maxHeight: maxHeight ? maxHeight + 'px' : undefined }">
<input ref="searchEl" :value="q" class="search" data-prevent-emoji-insert :class="{ filled: q != null && q != '' }" :placeholder="i18n.ts.search" type="search" @input="input()" @paste.stop="paste" @keydown.stop.prevent.enter="onEnter">
- <div ref="emojisEl" class="emojis">
+ <!-- FirefoxのTabフォーカスが想定外の挙動となるためtabindex="-1"を追加 https://github.com/misskey-dev/misskey/issues/10744 -->
+ <div ref="emojisEl" class="emojis" tabindex="-1">
<section class="result">
<div v-if="searchResultCustom.length > 0" class="body">
<button
@@ -69,8 +70,8 @@
<XSection
v-for="category in customEmojiCategories"
:key="`custom:${category}`"
- :initial-shown="false"
- :emojis="computed(() => customEmojis.filter(e => category === null ? (e.category === 'null' || !e.category) : e.category === category).map(e => `:${e.name}:`))"
+ :initialShown="false"
+ :emojis="computed(() => customEmojis.filter(e => category === null ? (e.category === 'null' || !e.category) : e.category === category).filter(filterAvailable).map(e => `:${e.name}:`))"
@chosen="chosen"
>
{{ category || i18n.ts.other }}
@@ -101,7 +102,8 @@ import { isTouchUsing } from '@/scripts/touch';
import { deviceKind } from '@/scripts/device-kind';
import { i18n } from '@/i18n';
import { defaultStore } from '@/store';
-import { customEmojiCategories, customEmojis } from '@/custom-emojis';
+import { customEmojiCategories, customEmojis, customEmojisMap } from '@/custom-emojis';
+import { $i } from '@/account';
const props = withDefaults(defineProps<{
showPinned?: boolean;
@@ -222,7 +224,6 @@ watch(q, () => {
if (newQ.includes(' ')) { // AND検索
const keywords = newQ.split(' ');
- // 名前にキーワードが含まれている
for (const emoji of emojis) {
if (keywords.every(keyword => emoji.name.includes(keyword))) {
matches.add(emoji);
@@ -231,11 +232,12 @@ watch(q, () => {
}
if (matches.size >= max) return matches;
- // 名前またはエイリアスにキーワードが含まれている
- for (const emoji of emojis) {
- if (keywords.every(keyword => emoji.name.includes(keyword) || emoji.keywords.some(alias => alias.includes(keyword)))) {
- matches.add(emoji);
- if (matches.size >= max) break;
+ for (const index of Object.values(defaultStore.state.additionalUnicodeEmojiIndexes)) {
+ for (const emoji of emojis) {
+ if (keywords.every(keyword => index[emoji.char].some(k => k.includes(keyword)))) {
+ matches.add(emoji);
+ if (matches.size >= max) break;
+ }
}
}
} else {
@@ -247,13 +249,14 @@ watch(q, () => {
}
if (matches.size >= max) return matches;
- for (const emoji of emojis) {
- if (emoji.keywords.some(keyword => keyword.startsWith(newQ))) {
- matches.add(emoji);
- if (matches.size >= max) break;
+ for (const index of Object.values(defaultStore.state.additionalUnicodeEmojiIndexes)) {
+ for (const emoji of emojis) {
+ if (index[emoji.char].some(k => k.startsWith(newQ))) {
+ matches.add(emoji);
+ if (matches.size >= max) break;
+ }
}
}
- if (matches.size >= max) return matches;
for (const emoji of emojis) {
if (emoji.name.includes(newQ)) {
@@ -263,10 +266,12 @@ watch(q, () => {
}
if (matches.size >= max) return matches;
- for (const emoji of emojis) {
- if (emoji.keywords.some(keyword => keyword.includes(newQ))) {
- matches.add(emoji);
- if (matches.size >= max) break;
+ for (const index of Object.values(defaultStore.state.additionalUnicodeEmojiIndexes)) {
+ for (const emoji of emojis) {
+ if (index[emoji.char].some(k => k.includes(newQ))) {
+ matches.add(emoji);
+ if (matches.size >= max) break;
+ }
}
}
}
@@ -274,10 +279,14 @@ watch(q, () => {
return matches;
};
- searchResultCustom.value = Array.from(searchCustom());
+ searchResultCustom.value = Array.from(searchCustom()).filter(filterAvailable);
searchResultUnicode.value = Array.from(searchUnicode());
});
+function filterAvailable(emoji: Misskey.entities.CustomEmoji): boolean {
+ return (emoji.roleIdsThatCanBeUsedThisEmojiAsReaction == null || emoji.roleIdsThatCanBeUsedThisEmojiAsReaction.length === 0) || ($i && $i.roles.some(r => emoji.roleIdsThatCanBeUsedThisEmojiAsReaction.includes(r.id)));
+}
+
function focus() {
if (!['smartphone', 'tablet'].includes(deviceKind) && !isTouchUsing) {
searchEl.value?.focus({
@@ -347,7 +356,7 @@ function done(query?: string): boolean | void {
if (query == null || typeof query !== 'string') return;
const q2 = query.replace(/:/g, '');
- const exactMatchCustom = customEmojis.value.find(emoji => emoji.name === q2);
+ const exactMatchCustom = customEmojisMap.get(q2);
if (exactMatchCustom) {
chosen(exactMatchCustom);
return true;
diff --git a/packages/frontend/src/components/MkEmojiPickerDialog.vue b/packages/frontend/src/components/MkEmojiPickerDialog.vue
index c568d4ed5c..cfb65e3b63 100644
--- a/packages/frontend/src/components/MkEmojiPickerDialog.vue
+++ b/packages/frontend/src/components/MkEmojiPickerDialog.vue
@@ -2,10 +2,10 @@
<MkModal
ref="modal"
v-slot="{ type, maxHeight }"
- :z-priority="'middle'"
- :prefer-type="asReactionPicker && defaultStore.state.reactionPickerUseDrawerForMobile === false ? 'popup' : 'auto'"
- :transparent-bg="true"
- :manual-showing="manualShowing"
+ :zPriority="'middle'"
+ :preferType="asReactionPicker && defaultStore.state.reactionPickerUseDrawerForMobile === false ? 'popup' : 'auto'"
+ :transparentBg="true"
+ :manualShowing="manualShowing"
:src="src"
@click="modal?.close()"
@opening="opening"
@@ -14,11 +14,11 @@
>
<MkEmojiPicker
ref="picker"
- class="ryghynhb _popup _shadow"
- :class="{ drawer: type === 'drawer' }"
- :show-pinned="showPinned"
- :as-reaction-picker="asReactionPicker"
- :as-drawer="type === 'drawer'"
+ class="_popup _shadow"
+ :class="{ [$style.drawer]: type === 'drawer' }"
+ :showPinned="showPinned"
+ :asReactionPicker="asReactionPicker"
+ :asDrawer="type === 'drawer'"
:max-height="maxHeight"
@chosen="chosen"
/>
@@ -67,12 +67,10 @@ function opening() {
}
</script>
-<style lang="scss" scoped>
-.ryghynhb {
- &.drawer {
- border-radius: 24px;
- border-bottom-right-radius: 0;
- border-bottom-left-radius: 0;
- }
+<style lang="scss" module>
+.drawer {
+ border-radius: 24px;
+ border-bottom-right-radius: 0;
+ border-bottom-left-radius: 0;
}
</style>
diff --git a/packages/frontend/src/components/MkEmojiPickerWindow.vue b/packages/frontend/src/components/MkEmojiPickerWindow.vue
index 84970410e9..9fecfd6082 100644
--- a/packages/frontend/src/components/MkEmojiPickerWindow.vue
+++ b/packages/frontend/src/components/MkEmojiPickerWindow.vue
@@ -1,13 +1,14 @@
<template>
-<MkWindow ref="window"
- :initial-width="300"
- :initial-height="290"
- :can-resize="true"
+<MkWindow
+ ref="window"
+ :initialWidth="300"
+ :initialHeight="290"
+ :canResize="true"
:mini="true"
:front="true"
@closed="emit('closed')"
>
- <MkEmojiPicker :show-pinned="showPinned" :as-reaction-picker="asReactionPicker" as-window :class="$style.picker" @chosen="chosen"/>
+ <MkEmojiPicker :showPinned="showPinned" :asReactionPicker="asReactionPicker" asWindow :class="$style.picker" @chosen="chosen"/>
</MkWindow>
</template>
diff --git a/packages/frontend/src/components/MkFileCaptionEditWindow.vue b/packages/frontend/src/components/MkFileCaptionEditWindow.vue
index 95eef45df0..61b87bda78 100644
--- a/packages/frontend/src/components/MkFileCaptionEditWindow.vue
+++ b/packages/frontend/src/components/MkFileCaptionEditWindow.vue
@@ -3,14 +3,14 @@
ref="dialog"
:width="400"
:height="450"
- :with-ok-button="true"
- :ok-button-disabled="false"
+ :withOkButton="true"
+ :okButtonDisabled="false"
@ok="ok()"
@close="dialog.close()"
@closed="emit('closed')"
>
<template #header>{{ i18n.ts.describeFile }}</template>
- <MkSpacer :margin-min="20" :margin-max="28">
+ <MkSpacer :marginMin="20" :marginMax="28">
<MkDriveFileThumbnail :file="file" fit="contain" style="height: 100px; margin-bottom: 16px;"/>
<MkTextarea v-model="caption" autofocus :placeholder="i18n.ts.inputNewDescription">
<template #label>{{ i18n.ts.caption }}</template>
diff --git a/packages/frontend/src/components/MkFoldableSection.vue b/packages/frontend/src/components/MkFoldableSection.vue
index 475e01c8d4..5dd07fc7da 100644
--- a/packages/frontend/src/components/MkFoldableSection.vue
+++ b/packages/frontend/src/components/MkFoldableSection.vue
@@ -1,9 +1,9 @@
<template>
-<div class="ssazuxis">
- <header class="_button" :style="{ background: bg }" @click="showBody = !showBody">
- <div class="title"><div><slot name="header"></slot></div></div>
- <div class="divider"></div>
- <button class="_button">
+<div ref="el" :class="$style.root">
+ <header :class="$style.header" class="_button" :style="{ background: bg }" @click="showBody = !showBody">
+ <div :class="$style.title"><div><slot name="header"></slot></div></div>
+ <div :class="$style.divider"></div>
+ <button class="_button" :class="$style.button">
<template v-if="showBody"><i class="ti ti-chevron-up"></i></template>
<template v-else><i class="ti ti-chevron-down"></i></template>
</button>
@@ -11,9 +11,9 @@
<Transition
:name="defaultStore.state.animation ? 'folder-toggle' : ''"
@enter="enter"
- @after-enter="afterEnter"
+ @afterEnter="afterEnter"
@leave="leave"
- @after-leave="afterLeave"
+ @afterLeave="afterLeave"
>
<div v-show="showBody">
<slot></slot>
@@ -22,84 +22,71 @@
</div>
</template>
-<script lang="ts">
-import { defineComponent } from 'vue';
+<script lang="ts" setup>
+import { onMounted, ref, shallowRef, watch } from 'vue';
import tinycolor from 'tinycolor2';
import { miLocalStorage } from '@/local-storage';
import { defaultStore } from '@/store';
const miLocalStoragePrefix = 'ui:folder:' as const;
-export default defineComponent({
- props: {
- expanded: {
- type: Boolean,
- required: false,
- default: true,
- },
- persistKey: {
- type: String,
- required: false,
- default: null,
- },
- },
- data() {
- return {
- defaultStore,
- bg: null,
- showBody: (this.persistKey && miLocalStorage.getItem(`${miLocalStoragePrefix}${this.persistKey}`)) ? (miLocalStorage.getItem(`${miLocalStoragePrefix}${this.persistKey}`) === 't') : this.expanded,
- };
- },
- watch: {
- showBody() {
- if (this.persistKey) {
- miLocalStorage.setItem(`${miLocalStoragePrefix}${this.persistKey}`, this.showBody ? 't' : 'f');
- }
- },
- },
- mounted() {
- function getParentBg(el: Element | null): string {
- if (el == null || el.tagName === 'BODY') return 'var(--bg)';
- const bg = el.style.background || el.style.backgroundColor;
- if (bg) {
- return bg;
- } else {
- return getParentBg(el.parentElement);
- }
- }
- const rawBg = getParentBg(this.$el);
- const bg = tinycolor(rawBg.startsWith('var(') ? getComputedStyle(document.documentElement).getPropertyValue(rawBg.slice(4, -1)) : rawBg);
- bg.setAlpha(0.85);
- this.bg = bg.toRgbString();
- },
- methods: {
- toggleContent(show: boolean) {
- this.showBody = show;
- },
+const props = withDefaults(defineProps<{
+ expanded?: boolean;
+ persistKey?: string;
+}>(), {
+ expanded: true,
+});
+
+const el = shallowRef<HTMLDivElement>();
+const bg = ref<string | null>(null);
+const showBody = ref((props.persistKey && miLocalStorage.getItem(`${miLocalStoragePrefix}${props.persistKey}`)) ? (miLocalStorage.getItem(`${miLocalStoragePrefix}${props.persistKey}`) === 't') : props.expanded);
+
+watch(showBody, () => {
+ if (props.persistKey) {
+ miLocalStorage.setItem(`${miLocalStoragePrefix}${props.persistKey}`, showBody.value ? 't' : 'f');
+ }
+});
+
+function enter(el: Element) {
+ const elementHeight = el.getBoundingClientRect().height;
+ el.style.height = 0;
+ el.offsetHeight; // reflow
+ el.style.height = elementHeight + 'px';
+}
+
+function afterEnter(el: Element) {
+ el.style.height = null;
+}
+
+function leave(el: Element) {
+ const elementHeight = el.getBoundingClientRect().height;
+ el.style.height = elementHeight + 'px';
+ el.offsetHeight; // reflow
+ el.style.height = 0;
+}
+
+function afterLeave(el: Element) {
+ el.style.height = null;
+}
- 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;
- },
- },
+onMounted(() => {
+ function getParentBg(el: HTMLElement | null): string {
+ if (el == null || el.tagName === 'BODY') return 'var(--bg)';
+ const bg = el.style.background || el.style.backgroundColor;
+ if (bg) {
+ return bg;
+ } else {
+ return getParentBg(el.parentElement);
+ }
+ }
+ const rawBg = getParentBg(el.value);
+ const _bg = tinycolor(rawBg.startsWith('var(') ? getComputedStyle(document.documentElement).getPropertyValue(rawBg.slice(4, -1)) : rawBg);
+ _bg.setAlpha(0.85);
+ bg.value = _bg.toRgbString();
});
</script>
-<style lang="scss" scoped>
+<style lang="scss" module>
.folder-toggle-enter-active, .folder-toggle-leave-active {
overflow-y: clip;
transition: opacity 0.5s, height 0.5s !important;
@@ -111,45 +98,41 @@ export default defineComponent({
opacity: 0;
}
-.ssazuxis {
+.root {
position: relative;
+}
- > header {
- display: flex;
- position: relative;
- z-index: 10;
- position: sticky;
- top: var(--stickyTop, 0px);
- -webkit-backdrop-filter: var(--blur, blur(8px));
- backdrop-filter: var(--blur, blur(20px));
+.header {
+ display: flex;
+ position: relative;
+ z-index: 10;
+ position: sticky;
+ top: var(--stickyTop, 0px);
+ -webkit-backdrop-filter: var(--blur, blur(8px));
+ backdrop-filter: var(--blur, blur(20px));
+}
- > .title {
- display: grid;
- place-content: center;
- margin: 0;
- padding: 12px 16px 12px 0;
- }
+.title {
+ display: grid;
+ place-content: center;
+ margin: 0;
+ padding: 12px 16px 12px 0;
+}
- > .divider {
- flex: 1;
- margin: auto;
- height: 1px;
- background: var(--divider);
- }
+.divider {
+ flex: 1;
+ margin: auto;
+ height: 1px;
+ background: var(--divider);
+}
- > button {
- padding: 12px 0 12px 16px;
- }
- }
+.button {
+ padding: 12px 0 12px 16px;
}
@container (max-width: 500px) {
- .ssazuxis {
- > header {
- > .title {
- padding: 8px 10px 8px 0;
- }
- }
+ .title {
+ padding: 8px 10px 8px 0;
}
}
</style>
diff --git a/packages/frontend/src/components/MkFolder.vue b/packages/frontend/src/components/MkFolder.vue
index 10eee6aab1..70f0cc5cda 100644
--- a/packages/frontend/src/components/MkFolder.vue
+++ b/packages/frontend/src/components/MkFolder.vue
@@ -5,8 +5,8 @@
<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">
- <slot name="label"></slot>
+ <div>
+ <MkCondensedLine :minScale="2 / 3"><slot name="label"></slot></MkCondensedLine>
</div>
<div :class="$style.headerTextSub">
<slot name="caption"></slot>
@@ -22,18 +22,18 @@
<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 : ''"
- :enter-from-class="defaultStore.state.animation ? $style.transition_toggle_enterFrom : ''"
- :leave-to-class="defaultStore.state.animation ? $style.transition_toggle_leaveTo : ''"
+ :enterActiveClass="defaultStore.state.animation ? $style.transition_toggle_enterActive : ''"
+ :leaveActiveClass="defaultStore.state.animation ? $style.transition_toggle_leaveActive : ''"
+ :enterFromClass="defaultStore.state.animation ? $style.transition_toggle_enterFrom : ''"
+ :leaveToClass="defaultStore.state.animation ? $style.transition_toggle_leaveTo : ''"
@enter="enter"
- @after-enter="afterEnter"
+ @afterEnter="afterEnter"
@leave="leave"
- @after-leave="afterLeave"
+ @afterLeave="afterLeave"
>
<KeepAlive>
<div v-show="opened">
- <MkSpacer :margin-min="14" :margin-max="22">
+ <MkSpacer :marginMin="14" :marginMax="22">
<slot></slot>
</MkSpacer>
</div>
@@ -185,10 +185,6 @@ onMounted(() => {
padding-right: 12px;
}
-.headerTextMain {
-
-}
-
.headerTextSub {
color: var(--fgTransparentWeak);
font-size: .85em;
diff --git a/packages/frontend/src/components/MkFollowButton.vue b/packages/frontend/src/components/MkFollowButton.vue
index beee21c647..b732fbb2b9 100644
--- a/packages/frontend/src/components/MkFollowButton.vue
+++ b/packages/frontend/src/components/MkFollowButton.vue
@@ -1,30 +1,30 @@
<template>
<button
- class="kpoogebi _button"
- :class="{ wait, active: isFollowing || hasPendingFollowRequestFromYou, full, large }"
+ class="_button"
+ :class="[$style.root, { [$style.wait]: wait, [$style.active]: isFollowing || hasPendingFollowRequestFromYou, [$style.full]: full, [$style.large]: large }]"
:disabled="wait"
@click="onClick"
>
<template v-if="!wait">
<template v-if="hasPendingFollowRequestFromYou && user.isLocked">
- <span v-if="full">{{ i18n.ts.followRequestPending }}</span><i class="ti ti-hourglass-empty"></i>
+ <span v-if="full" :class="$style.text">{{ i18n.ts.followRequestPending }}</span><i class="ti ti-hourglass-empty"></i>
</template>
<template v-else-if="hasPendingFollowRequestFromYou && !user.isLocked">
<!-- つまりリモートフォローの場合。 -->
- <span v-if="full">{{ i18n.ts.processing }}</span><MkLoading :em="true" :colored="false"/>
+ <span v-if="full" :class="$style.text">{{ i18n.ts.processing }}</span><MkLoading :em="true" :colored="false"/>
</template>
<template v-else-if="isFollowing">
- <span v-if="full">{{ i18n.ts.unfollow }}</span><i class="ti ti-minus"></i>
+ <span v-if="full" :class="$style.text">{{ i18n.ts.unfollow }}</span><i class="ti ti-minus"></i>
</template>
<template v-else-if="!isFollowing && user.isLocked">
- <span v-if="full">{{ i18n.ts.followRequest }}</span><i class="ti ti-plus"></i>
+ <span v-if="full" :class="$style.text">{{ i18n.ts.followRequest }}</span><i class="ti ti-plus"></i>
</template>
<template v-else-if="!isFollowing && !user.isLocked">
- <span v-if="full">{{ i18n.ts.follow }}</span><i class="ti ti-plus"></i>
+ <span v-if="full" :class="$style.text">{{ i18n.ts.follow }}</span><i class="ti ti-plus"></i>
</template>
</template>
<template v-else>
- <span v-if="full">{{ i18n.ts.processing }}</span><MkLoading :em="true" :colored="false"/>
+ <span v-if="full" :class="$style.text">{{ i18n.ts.processing }}</span><MkLoading :em="true" :colored="false"/>
</template>
</button>
</template>
@@ -33,7 +33,7 @@
import { onBeforeUnmount, onMounted } from 'vue';
import * as Misskey from 'misskey-js';
import * as os from '@/os';
-import { stream } from '@/stream';
+import { useStream } from '@/stream';
import { i18n } from '@/i18n';
import { claimAchievement } from '@/scripts/achievements';
import { $i } from '@/account';
@@ -50,7 +50,7 @@ const props = withDefaults(defineProps<{
let isFollowing = $ref(props.user.isFollowing);
let hasPendingFollowRequestFromYou = $ref(props.user.hasPendingFollowRequestFromYou);
let wait = $ref(false);
-const connection = stream.useChannel('main');
+const connection = useStream().useChannel('main');
if (props.user.isFollowing == null) {
os.api('users/show', {
@@ -126,13 +126,12 @@ onBeforeUnmount(() => {
});
</script>
-<style lang="scss" scoped>
-.kpoogebi {
+<style lang="scss" module>
+.root {
position: relative;
display: inline-block;
font-weight: bold;
- color: var(--accent);
- background: transparent;
+ color: var(--fgOnWhite);
border: solid 1px var(--accent);
padding: 0;
height: 31px;
@@ -196,9 +195,9 @@ onBeforeUnmount(() => {
cursor: wait !important;
opacity: 0.7;
}
+}
- > span {
- margin-right: 6px;
- }
+.text {
+ margin-right: 6px;
}
</style>
diff --git a/packages/frontend/src/components/MkForgotPassword.vue b/packages/frontend/src/components/MkForgotPassword.vue
index 0befa7e3ae..1264c42331 100644
--- a/packages/frontend/src/components/MkForgotPassword.vue
+++ b/packages/frontend/src/components/MkForgotPassword.vue
@@ -8,27 +8,28 @@
>
<template #header>{{ i18n.ts.forgotPassword }}</template>
- <form v-if="instance.enableEmail" class="bafeceda" @submit.prevent="onSubmit">
- <div class="main _gaps_m">
- <MkInput v-model="username" type="text" pattern="^[a-zA-Z0-9_]+$" :spellcheck="false" autofocus required>
- <template #label>{{ i18n.ts.username }}</template>
- <template #prefix>@</template>
- </MkInput>
+ <MkSpacer :marginMin="20" :marginMax="28">
+ <form v-if="instance.enableEmail" @submit.prevent="onSubmit">
+ <div class="_gaps_m">
+ <MkInput v-model="username" type="text" pattern="^[a-zA-Z0-9_]+$" :spellcheck="false" autofocus required>
+ <template #label>{{ i18n.ts.username }}</template>
+ <template #prefix>@</template>
+ </MkInput>
- <MkInput v-model="email" type="email" :spellcheck="false" required>
- <template #label>{{ i18n.ts.emailAddress }}</template>
- <template #caption>{{ i18n.ts._forgotPassword.enterEmail }}</template>
- </MkInput>
+ <MkInput v-model="email" type="email" :spellcheck="false" required>
+ <template #label>{{ i18n.ts.emailAddress }}</template>
+ <template #caption>{{ i18n.ts._forgotPassword.enterEmail }}</template>
+ </MkInput>
- <MkButton type="submit" :disabled="processing" primary style="margin: 0 auto;">{{ i18n.ts.send }}</MkButton>
- </div>
- <div class="sub">
- <MkA to="/about" class="_link">{{ i18n.ts._forgotPassword.ifNoEmail }}</MkA>
+ <MkButton type="submit" rounded :disabled="processing" primary style="margin: 0 auto;">{{ i18n.ts.send }}</MkButton>
+
+ <MkInfo>{{ i18n.ts._forgotPassword.ifNoEmail }}</MkInfo>
+ </div>
+ </form>
+ <div v-else>
+ {{ i18n.ts._forgotPassword.contactAdmin }}
</div>
- </form>
- <div v-else class="bafecedb">
- {{ i18n.ts._forgotPassword.contactAdmin }}
- </div>
+ </MkSpacer>
</MkModalWindow>
</template>
@@ -37,6 +38,7 @@ import { } from 'vue';
import MkModalWindow from '@/components/MkModalWindow.vue';
import MkButton from '@/components/MkButton.vue';
import MkInput from '@/components/MkInput.vue';
+import MkInfo from '@/components/MkInfo.vue';
import * as os from '@/os';
import { instance } from '@/instance';
import { i18n } from '@/i18n';
@@ -62,20 +64,3 @@ async function onSubmit() {
dialog.close();
}
</script>
-
-<style lang="scss" scoped>
-.bafeceda {
- > .main {
- padding: 24px;
- }
-
- > .sub {
- border-top: solid 0.5px var(--divider);
- padding: 24px;
- }
-}
-
-.bafecedb {
- padding: 24px;
-}
-</style>
diff --git a/packages/frontend/src/components/MkFormDialog.vue b/packages/frontend/src/components/MkFormDialog.vue
index 979df2e7c1..6d2b391e6d 100644
--- a/packages/frontend/src/components/MkFormDialog.vue
+++ b/packages/frontend/src/components/MkFormDialog.vue
@@ -2,9 +2,9 @@
<MkModalWindow
ref="dialog"
:width="450"
- :can-close="false"
- :with-ok-button="true"
- :ok-button-disabled="false"
+ :canClose="false"
+ :withOkButton="true"
+ :okButtonDisabled="false"
@click="cancel()"
@ok="ok()"
@close="cancel()"
@@ -14,7 +14,7 @@
{{ title }}
</template>
- <MkSpacer :margin-min="20" :margin-max="32">
+ <MkSpacer :marginMin="20" :marginMax="32">
<div class="_gaps_m">
<template v-for="item in Object.keys(form).filter(item => !form[item].hidden)">
<MkInput v-if="form[item].type === 'number'" v-model="values[item]" type="number" :step="form[item].step || 1">
@@ -41,7 +41,7 @@
<template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ i18n.ts.optional }})</span></template>
<option v-for="item in form[item].options" :key="item.value" :value="item.value">{{ item.label }}</option>
</MkRadios>
- <MkRange v-else-if="form[item].type === 'range'" v-model="values[item]" :min="form[item].min" :max="form[item].max" :step="form[item].step" :text-converter="form[item].textConverter">
+ <MkRange v-else-if="form[item].type === 'range'" v-model="values[item]" :min="form[item].min" :max="form[item].max" :step="form[item].step" :textConverter="form[item].textConverter">
<template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ i18n.ts.optional }})</span></template>
<template v-if="form[item].description" #caption>{{ form[item].description }}</template>
</MkRange>
@@ -54,8 +54,8 @@
</MkModalWindow>
</template>
-<script lang="ts">
-import { defineComponent } from 'vue';
+<script lang="ts" setup>
+import { reactive, shallowRef } from 'vue';
import MkInput from './MkInput.vue';
import MkTextarea from './MkTextarea.vue';
import MkSwitch from './MkSwitch.vue';
@@ -66,58 +66,36 @@ import MkRadios from './MkRadios.vue';
import MkModalWindow from '@/components/MkModalWindow.vue';
import { i18n } from '@/i18n';
-export default defineComponent({
- components: {
- MkModalWindow,
- MkInput,
- MkTextarea,
- MkSwitch,
- MkSelect,
- MkRange,
- MkButton,
- MkRadios,
- },
+const props = defineProps<{
+ title: string;
+ form: any;
+}>();
- props: {
- title: {
- type: String,
- required: true,
- },
- form: {
- type: Object,
- required: true,
- },
- },
+const emit = defineEmits<{
+ (ev: 'done', v: {
+ canceled?: boolean;
+ result?: any;
+ }): void;
+}>();
- emits: ['done'],
+const dialog = shallowRef<InstanceType<typeof MkModalWindow>>();
+const values = reactive({});
- data() {
- return {
- values: {},
- i18n,
- };
- },
+for (const item in props.form) {
+ values[item] = props.form[item].default ?? null;
+}
- created() {
- for (const item in this.form) {
- this.values[item] = this.form[item].default ?? null;
- }
- },
+function ok() {
+ emit('done', {
+ result: values,
+ });
+ dialog.value.close();
+}
- methods: {
- ok() {
- this.$emit('done', {
- result: this.values,
- });
- this.$refs.dialog.close();
- },
-
- cancel() {
- this.$emit('done', {
- canceled: true,
- });
- this.$refs.dialog.close();
- },
- },
-});
+function cancel() {
+ emit('done', {
+ canceled: true,
+ });
+ dialog.value.close();
+}
</script>
diff --git a/packages/frontend/src/components/MkGalleryPostPreview.stories.impl.ts b/packages/frontend/src/components/MkGalleryPostPreview.stories.impl.ts
index 57b3e75513..72ac0a58f9 100644
--- a/packages/frontend/src/components/MkGalleryPostPreview.stories.impl.ts
+++ b/packages/frontend/src/components/MkGalleryPostPreview.stories.impl.ts
@@ -44,6 +44,10 @@ export const Default = {
],
parameters: {
layout: 'centered',
+ chromatic: {
+ // FIXME: flaky
+ disableSnapshot: true,
+ },
},
} satisfies StoryObj<typeof MkGalleryPostPreview>;
export const Hover = {
diff --git a/packages/frontend/src/components/MkGalleryPostPreview.vue b/packages/frontend/src/components/MkGalleryPostPreview.vue
index 4f8f7b945a..3a39ad963b 100644
--- a/packages/frontend/src/components/MkGalleryPostPreview.vue
+++ b/packages/frontend/src/components/MkGalleryPostPreview.vue
@@ -5,16 +5,13 @@
<ImgWithBlurhash
class="img layered"
:transition="safe ? null : {
- enterActiveClass: $style.transition_toggle_enterActive,
+ duration: 500,
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"
+ :forceBlurhash="!show"
/>
</Transition>
</div>
@@ -53,24 +50,16 @@ function leaveHover(): void {
</script>
<style lang="scss" module>
-.transition_toggle_enterActive,
.transition_toggle_leaveActive {
- transition: opacity 0.5s;
+ transition: opacity .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>
diff --git a/packages/frontend/src/components/MkImageViewer.vue b/packages/frontend/src/components/MkImageViewer.vue
deleted file mode 100644
index a90e27e502..0000000000
--- a/packages/frontend/src/components/MkImageViewer.vue
+++ /dev/null
@@ -1,78 +0,0 @@
-<template>
-<MkModal ref="modal" :z-priority="'middle'" @click="modal.close()" @closed="emit('closed')">
- <div class="xubzgfga">
- <header>{{ image.name }}</header>
- <img :src="image.url" :alt="image.comment" :title="image.comment" @click="modal.close()"/>
- <footer>
- <span>{{ image.type }}</span>
- <span>{{ bytes(image.size) }}</span>
- <span v-if="image.properties && image.properties.width">{{ number(image.properties.width) }}px × {{ number(image.properties.height) }}px</span>
- </footer>
- </div>
-</MkModal>
-</template>
-
-<script lang="ts" setup>
-import { } from 'vue';
-import * as misskey from 'misskey-js';
-import bytes from '@/filters/bytes';
-import number from '@/filters/number';
-import MkModal from '@/components/MkModal.vue';
-
-const props = withDefaults(defineProps<{
- image: misskey.entities.DriveFile;
-}>(), {
-});
-
-const emit = defineEmits<{
- (ev: 'closed'): void;
-}>();
-
-const modal = $shallowRef<InstanceType<typeof MkModal>>();
-</script>
-
-<style lang="scss" scoped>
-.xubzgfga {
- margin: auto;
- display: flex;
- flex-direction: column;
- height: 100%;
-
- > header,
- > footer {
- align-self: center;
- display: inline-block;
- padding: 6px 9px;
- font-size: 90%;
- background: rgba(0, 0, 0, 0.5);
- border-radius: 6px;
- color: #fff;
- }
-
- > header {
- margin-bottom: 8px;
- opacity: 0.9;
- }
-
- > img {
- display: block;
- flex: 1;
- min-height: 0;
- object-fit: contain;
- width: 100%;
- cursor: zoom-out;
- image-orientation: from-image;
- }
-
- > footer {
- margin-top: 8px;
- opacity: 0.8;
-
- > span + span {
- margin-left: 0.5em;
- padding-left: 0.5em;
- border-left: solid 1px rgba(255, 255, 255, 0.5);
- }
- }
-}
-</style>
diff --git a/packages/frontend/src/components/MkImgWithBlurhash.vue b/packages/frontend/src/components/MkImgWithBlurhash.vue
index 6406a35060..672a28f6d0 100644
--- a/packages/frontend/src/components/MkImgWithBlurhash.vue
+++ b/packages/frontend/src/components/MkImgWithBlurhash.vue
@@ -1,30 +1,60 @@
<template>
-<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"
+<div ref="root" :class="['chromatic-ignore', $style.root, { [$style.cover]: cover }]" :title="title ?? ''">
+ <TransitionGroup
+ :duration="defaultStore.state.animation && props.transition?.duration || undefined"
+ :enterActiveClass="defaultStore.state.animation && props.transition?.enterActiveClass || undefined"
+ :leaveActiveClass="defaultStore.state.animation && (props.transition?.leaveActiveClass ?? $style.transition_leaveActive) || undefined"
+ :enterFromClass="defaultStore.state.animation && props.transition?.enterFromClass || undefined"
+ :leaveToClass="defaultStore.state.animation && props.transition?.leaveToClass || undefined"
+ :enterToClass="defaultStore.state.animation && props.transition?.enterToClass || undefined"
+ :leaveFromClass="defaultStore.state.animation && props.transition?.leaveFromClass || undefined"
>
- <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>
+ <canvas v-show="hide" key="canvas" ref="canvas" :class="$style.canvas" :width="canvasWidth" :height="canvasHeight" :title="title ?? undefined"/>
+ <img v-show="!hide" key="img" ref="img" :height="imgHeight" :width="imgWidth" :class="$style.img" :src="src ?? undefined" :title="title ?? undefined" :alt="alt ?? undefined" loading="eager" decoding="async"/>
+ </TransitionGroup>
</div>
</template>
+<script lang="ts">
+import { $ref } from 'vue/macros';
+import DrawBlurhash from '@/workers/draw-blurhash?worker';
+import TestWebGL2 from '@/workers/test-webgl2?worker';
+import { WorkerMultiDispatch } from '@/scripts/worker-multi-dispatch';
+import { extractAvgColorFromBlurhash } from '@/scripts/extract-avg-color-from-blurhash';
+
+const workerPromise = new Promise<WorkerMultiDispatch | null>(resolve => {
+ // テスト環境で Web Worker インスタンスは作成できない
+ if (import.meta.env.MODE === 'test') {
+ resolve(null);
+ return;
+ }
+ const testWorker = new TestWebGL2();
+ testWorker.addEventListener('message', event => {
+ if (event.data.result) {
+ const workers = new WorkerMultiDispatch(
+ () => new DrawBlurhash(),
+ Math.min(navigator.hardwareConcurrency - 1, 4),
+ );
+ resolve(workers);
+ if (_DEV_) console.log('WebGL2 in worker is supported!');
+ } else {
+ resolve(null);
+ if (_DEV_) console.log('WebGL2 in worker is not supported...');
+ }
+ testWorker.terminate();
+ });
+});
+</script>
+
<script lang="ts" setup>
-import { onMounted, shallowRef, useCssModule, watch } from 'vue';
-import { decode } from 'blurhash';
+import { computed, nextTick, onMounted, onUnmounted, shallowRef, watch } from 'vue';
+import { v4 as uuid } from 'uuid';
+import { render } from 'buraha';
import { defaultStore } from '@/store';
-const $style = useCssModule();
-
const props = withDefaults(defineProps<{
transition?: {
+ duration?: number | { enter: number; leave: number; };
enterActiveClass?: string;
leaveActiveClass?: string;
enterFromClass?: string;
@@ -51,67 +81,141 @@ const props = withDefaults(defineProps<{
forceBlurhash: false,
});
+const viewId = uuid();
const canvas = shallowRef<HTMLCanvasElement>();
+const root = shallowRef<HTMLDivElement>();
+const img = shallowRef<HTMLImageElement>();
let loaded = $ref(false);
-let width = $ref(props.width);
-let height = $ref(props.height);
+let canvasWidth = $ref(64);
+let canvasHeight = $ref(64);
+let imgWidth = $ref(props.width);
+let imgHeight = $ref(props.height);
+let bitmapTmp = $ref<CanvasImageSource | undefined>();
+const hide = computed(() => !loaded || props.forceBlurhash);
-function onLoad() {
- loaded = true;
+function waitForDecode() {
+ if (props.src != null && props.src !== '') {
+ nextTick()
+ .then(() => img.value?.decode())
+ .then(() => {
+ loaded = true;
+ }, error => {
+ console.error('Error occured during decoding image', img.value, error);
+ throw Error(error);
+ });
+ } else {
+ loaded = false;
+ }
}
-watch([() => props.width, () => props.height], () => {
+watch([() => props.width, () => props.height, root], () => {
const ratio = props.width / props.height;
if (ratio > 1) {
- width = Math.round(64 * ratio);
- height = 64;
+ canvasWidth = Math.round(64 * ratio);
+ canvasHeight = 64;
} else {
- width = 64;
- height = Math.round(64 / ratio);
+ canvasWidth = 64;
+ canvasHeight = Math.round(64 / ratio);
}
+
+ const clientWidth = root.value?.clientWidth ?? 300;
+ imgWidth = clientWidth;
+ imgHeight = Math.round(clientWidth / ratio);
}, {
immediate: true,
});
-function draw() {
- if (props.hash == null || !canvas.value) return;
- const pixels = decode(props.hash, width, height);
+function drawImage(bitmap: CanvasImageSource) {
+ // canvasがない(mountedされていない)場合はTmpに保存しておく
+ if (!canvas.value) {
+ bitmapTmp = bitmap;
+ return;
+ }
+
+ // canvasがあれば描画する
+ bitmapTmp = undefined;
+ const ctx = canvas.value.getContext('2d');
+ if (!ctx) return;
+ ctx.drawImage(bitmap, 0, 0, canvasWidth, canvasHeight);
+}
+
+async function draw() {
+ if (!canvas.value || props.hash == null) return;
+
const ctx = canvas.value.getContext('2d');
- const imageData = ctx!.createImageData(width, height);
- imageData.data.set(pixels);
- ctx!.putImageData(imageData, 0, 0);
+ if (!ctx) return;
+
+ // avgColorでお茶をにごす
+ ctx.beginPath();
+ ctx.fillStyle = extractAvgColorFromBlurhash(props.hash) ?? '#888';
+ ctx.fillRect(0, 0, canvasWidth, canvasHeight);
+
+ const workers = await workerPromise;
+ if (workers) {
+ workers.postMessage(
+ {
+ id: viewId,
+ hash: props.hash,
+ width: canvasWidth,
+ height: canvasHeight,
+ },
+ undefined,
+ );
+ } else {
+ try {
+ const work = document.createElement('canvas');
+ work.width = canvasWidth;
+ work.height = canvasHeight;
+ render(props.hash, work);
+ ctx.drawImage(work, 0, 0, canvasWidth, canvasHeight);
+ } catch (error) {
+ console.error('Error occured during drawing blurhash', error);
+ }
+ }
}
-watch([() => props.hash, canvas], () => {
+function workerOnMessage(event: MessageEvent) {
+ if (event.data.id !== viewId) return;
+ drawImage(event.data.bitmap as ImageBitmap);
+}
+
+workerPromise.then(worker => {
+ if (worker) {
+ worker.addListener(workerOnMessage);
+ }
+
draw();
});
-onMounted(() => {
+watch(() => props.src, () => {
+ waitForDecode();
+});
+
+watch(() => props.hash, () => {
draw();
});
-</script>
-<style lang="scss" module>
-.transition_toggle_enterActive,
-.transition_toggle_leaveActive {
- position: absolute;
- top: 0;
- left: 0;
-}
+onMounted(() => {
+ // drawImageがmountedより先に呼ばれている場合はここで描画する
+ if (bitmapTmp) {
+ drawImage(bitmapTmp);
+ }
+ waitForDecode();
+});
-.transition_toggle_enterTo,
-.transition_toggle_leaveFrom {
- opacity: 0;
-}
+onUnmounted(() => {
+ workerPromise.then(worker => {
+ worker?.removeListener(workerOnMessage);
+ });
+});
+</script>
-.loader {
+<style lang="scss" module>
+.transition_leaveActive {
position: absolute;
top: 0;
left: 0;
- width: 0;
- height: 0;
}
-
.root {
position: relative;
width: 100%;
diff --git a/packages/frontend/src/components/MkKeyValue.vue b/packages/frontend/src/components/MkKeyValue.vue
index ff69c79641..4b6a775635 100644
--- a/packages/frontend/src/components/MkKeyValue.vue
+++ b/packages/frontend/src/components/MkKeyValue.vue
@@ -1,9 +1,9 @@
<template>
-<div class="alqyeyti" :class="{ oneline }">
- <div class="key">
+<div :class="[$style.root, { [$style.oneline]: oneline }]">
+ <div :class="$style.key">
<slot name="key"></slot>
</div>
- <div class="value">
+ <div :class="$style.value">
<slot name="value"></slot>
<button v-if="copy" v-tooltip="i18n.ts.copy" class="_textButton" style="margin-left: 0.5em;" @click="copy_"><i class="ti ti-copy"></i></button>
</div>
@@ -30,24 +30,18 @@ const copy_ = () => {
};
</script>
-<style lang="scss" scoped>
-.alqyeyti {
- > .key {
- font-size: 0.85em;
- padding: 0 0 0.25em 0;
- opacity: 0.75;
- }
-
+<style lang="scss" module>
+.root {
&.oneline {
display: flex;
- > .key {
+ .key {
width: 30%;
font-size: 1em;
padding: 0 8px 0 0;
}
- > .value {
+ .value {
width: 70%;
white-space: nowrap;
overflow: hidden;
@@ -55,4 +49,10 @@ const copy_ = () => {
}
}
}
+
+.key {
+ font-size: 0.85em;
+ padding: 0 0 0.25em 0;
+ opacity: 0.75;
+}
</style>
diff --git a/packages/frontend/src/components/MkLaunchPad.vue b/packages/frontend/src/components/MkLaunchPad.vue
index 80e5cc8270..9262778612 100644
--- a/packages/frontend/src/components/MkLaunchPad.vue
+++ b/packages/frontend/src/components/MkLaunchPad.vue
@@ -1,5 +1,5 @@
<template>
-<MkModal ref="modal" v-slot="{ type, maxHeight }" :prefer-type="preferedModalType" :anchor="anchor" :transparent-bg="true" :src="src" @click="modal.close()" @closed="emit('closed')">
+<MkModal ref="modal" v-slot="{ type, maxHeight }" :preferType="preferedModalType" :anchor="anchor" :transparentBg="true" :src="src" @click="modal.close()" @closed="emit('closed')">
<div class="szkkfdyq _popup _shadow" :class="{ asDrawer: type === 'drawer' }" :style="{ maxHeight: maxHeight ? maxHeight + 'px' : '' }">
<div class="main">
<template v-for="item in items">
diff --git a/packages/frontend/src/components/MkMediaBanner.vue b/packages/frontend/src/components/MkMediaBanner.vue
index 5ca4c50518..5902d6fd25 100644
--- a/packages/frontend/src/components/MkMediaBanner.vue
+++ b/packages/frontend/src/components/MkMediaBanner.vue
@@ -1,27 +1,27 @@
<template>
-<div class="mk-media-banner">
- <div v-if="media.isSensitive && hide" class="sensitive" @click="hide = false">
- <span class="icon"><i class="ti ti-alert-triangle"></i></span>
+<div :class="$style.root">
+ <div v-if="media.isSensitive && hide" :class="$style.sensitive" @click="hide = false">
+ <span style="font-size: 1.6em;"><i class="ti ti-alert-triangle"></i></span>
<b>{{ i18n.ts.sensitive }}</b>
<span>{{ i18n.ts.clickToShow }}</span>
</div>
- <div v-else-if="media.type.startsWith('audio') && media.type !== 'audio/midi'" class="audio">
- <VuePlyr :options="{ volume: 0.5 }">
- <audio controls preload="metadata">
- <source
- :src="media.url"
- :type="media.type"
- />
- </audio>
- </VuePlyr>
+ <div v-else-if="media.type.startsWith('audio') && media.type !== 'audio/midi'" :class="$style.audio">
+ <audio
+ ref="audioEl"
+ :src="media.url"
+ :title="media.name"
+ controls
+ preload="metadata"
+ @volumechange="volumechange"
+ />
</div>
<a
- v-else class="download"
+ v-else :class="$style.download"
:href="media.url"
:title="media.name"
:download="media.name"
>
- <span class="icon"><i class="ti ti-download"></i></span>
+ <span style="font-size: 1.6em;"><i class="ti ti-download"></i></span>
<b>{{ media.name }}</b>
</a>
</div>
@@ -30,9 +30,7 @@
<script lang="ts" setup>
import { onMounted } from 'vue';
import * as misskey from 'misskey-js';
-import VuePlyr from 'vue-plyr';
import { soundConfigStore } from '@/scripts/sound';
-import 'vue-plyr/dist/vue-plyr.css';
import { i18n } from '@/i18n';
const props = withDefaults(defineProps<{
@@ -52,55 +50,34 @@ onMounted(() => {
});
</script>
-<style lang="scss" scoped>
-.mk-media-banner {
+<style lang="scss" module>
+.root {
width: 100%;
border-radius: 4px;
margin-top: 4px;
- // overflow: clip;
-
- --plyr-color-main: var(--accent);
- --plyr-audio-controls-background: var(--bg);
- --plyr-audio-controls-color: var(--accentLighten);
-
- > .download,
- > .sensitive {
- display: flex;
- align-items: center;
- font-size: 12px;
- padding: 8px 12px;
- white-space: nowrap;
-
- > * {
- display: block;
- }
-
- > b {
- overflow: hidden;
- text-overflow: ellipsis;
- }
-
- > *:not(:last-child) {
- margin-right: .2em;
- }
+ overflow: clip;
+}
- > .icon {
- font-size: 1.6em;
- }
- }
+.download,
+.sensitive {
+ display: flex;
+ align-items: center;
+ font-size: 12px;
+ padding: 8px 12px;
+ white-space: nowrap;
+}
- > .download {
- background: var(--noteAttachedFile);
- }
+.download {
+ background: var(--noteAttachedFile);
+}
- > .sensitive {
- background: #111;
- color: #fff;
- }
+.sensitive {
+ background: #111;
+ color: #fff;
+}
- > .audio {
- border-radius: 8px;
- // overflow: clip;
- }
+.audio {
+ border-radius: 8px;
+ overflow: clip;
}
</style>
diff --git a/packages/frontend/src/components/MkMediaImage.vue b/packages/frontend/src/components/MkMediaImage.vue
index 42dc9e79ff..b29871c363 100644
--- a/packages/frontend/src/components/MkMediaImage.vue
+++ b/packages/frontend/src/components/MkMediaImage.vue
@@ -1,29 +1,39 @@
<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" :width="image.properties.width" :height="image.properties.height" :force-blurhash="defaultStore.state.enableDataSaverMode"/>
- <div :class="$style.hiddenText">
- <div :class="$style.hiddenTextWrapper">
- <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>
-</div>
-<div v-else :class="$style.visible" :style="darkMode ? '--c: rgb(255 255 255 / 2%);' : '--c: rgb(0 0 0 / 2%);'">
+<div :class="hide ? $style.hidden : $style.visible" :style="darkMode ? '--c: rgb(255 255 255 / 2%);' : '--c: rgb(0 0 0 / 2%);'" @click="onclick">
<a
:class="$style.imageContainer"
:href="image.url"
:title="image.name"
>
- <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"/>
+ <ImgWithBlurhash
+ :hash="image.blurhash"
+ :src="(defaultStore.state.enableDataSaverMode && hide) ? null : url"
+ :forceBlurhash="hide"
+ :cover="hide"
+ :alt="image.comment || image.name"
+ :title="image.comment || image.name"
+ :width="image.properties.width"
+ :height="image.properties.height"
+ :style="hide ? 'filter: brightness(0.5);' : null"
+ />
</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>
+ <template v-if="hide">
+ <div :class="$style.hiddenText">
+ <div :class="$style.hiddenTextWrapper">
+ <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>
+ </template>
+ <template v-else>
+ <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 :class="$style.menu" class="_button" @click.stop="showMenu"><i class="ti ti-dots" style="vertical-align: middle;"></i></button>
+ </template>
</div>
</template>
@@ -53,6 +63,12 @@ const url = $computed(() => (props.raw || defaultStore.state.loadRawImages)
: props.image.thumbnailUrl,
);
+function onclick() {
+ if (hide) {
+ hide = false;
+ }
+}
+
// Plugin:register_note_view_interruptor を使って書き換えられる可能性があるためwatchする
watch(() => props.image, () => {
hide = (defaultStore.state.nsfw === 'force' || defaultStore.state.enableDataSaverMode) ? true : (props.image.isSensitive && defaultStore.state.nsfw !== 'ignore');
@@ -62,10 +78,17 @@ watch(() => props.image, () => {
});
function showMenu(ev: MouseEvent) {
- os.popupMenu([...(iAmModerator ? [{
- text: i18n.ts.markAsSensitive,
+ os.popupMenu([{
+ text: i18n.ts.hide,
icon: 'ti ti-eye-off',
action: () => {
+ hide = true;
+ },
+ }, ...(iAmModerator ? [{
+ text: i18n.ts.markAsSensitive,
+ icon: 'ti ti-eye-exclamation',
+ danger: true,
+ action: () => {
os.apiWithDialog('drive/files/update', { fileId: props.image.id, isSensitive: true });
},
}] : [])], ev.currentTarget ?? ev.target);
@@ -105,34 +128,20 @@ function showMenu(ev: MouseEvent) {
background-size: 16px 16px;
}
-.hide {
- display: block;
- position: absolute;
- border-radius: 6px;
- background-color: var(--accentedBg);
- -webkit-backdrop-filter: var(--blur, blur(15px));
- backdrop-filter: var(--blur, blur(15px));
- color: var(--accent);
- font-size: 0.8em;
- padding: 6px 8px;
- text-align: center;
- top: 12px;
- right: 12px;
-}
-
.menu {
display: block;
position: absolute;
- border-radius: 6px;
+ border-radius: 999px;
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;
+ width: 32px;
+ height: 32px;
text-align: center;
- bottom: 12px;
- right: 12px;
+ bottom: 10px;
+ right: 10px;
}
.imageContainer {
@@ -149,12 +158,10 @@ function showMenu(ev: MouseEvent) {
.indicators {
display: inline-flex;
position: absolute;
- top: 12px;
- left: 12px;
- text-align: center;
+ top: 10px;
+ left: 10px;
pointer-events: none;
opacity: .5;
- font-size: 14px;
gap: 6px;
}
@@ -165,7 +172,7 @@ function showMenu(ev: MouseEvent) {
color: var(--accentLighten);
display: inline-block;
font-weight: bold;
- font-size: 12px;
- padding: 2px 6px;
+ font-size: 0.8em;
+ padding: 2px 5px;
}
</style>
diff --git a/packages/frontend/src/components/MkMediaList.vue b/packages/frontend/src/components/MkMediaList.vue
index e456ff3eec..a0a2450054 100644
--- a/packages/frontend/src/components/MkMediaList.vue
+++ b/packages/frontend/src/components/MkMediaList.vue
@@ -6,7 +6,11 @@
ref="gallery"
:class="[
$style.medias,
- count <= 4 ? $style['n' + count] : $style.nMany,
+ count === 1 ? [$style.n1, {
+ [$style.n116_9]: defaultStore.reactiveState.mediaListWithOneImageAppearance.value === '16_9',
+ [$style.n11_1]: defaultStore.reactiveState.mediaListWithOneImageAppearance.value === '1_1',
+ [$style.n12_3]: defaultStore.reactiveState.mediaListWithOneImageAppearance.value === '2_3',
+ }] : count === 2 ? $style.n2 : count === 3 ? $style.n3 : count === 4 ? $style.n4 : $style.nMany,
]"
>
<template v-for="media in mediaList.filter(media => previewable(media))">
@@ -19,7 +23,7 @@
</template>
<script lang="ts" setup>
-import { onMounted, ref, useCssModule, watch } from 'vue';
+import { onMounted, watch, shallowRef } from 'vue';
import * as misskey from 'misskey-js';
import PhotoSwipeLightbox from 'photoswipe/lightbox';
import PhotoSwipe from 'photoswipe';
@@ -36,13 +40,42 @@ const props = defineProps<{
raw?: boolean;
}>();
-const $style = useCssModule();
-
-const gallery = ref<HTMLDivElement>();
+const gallery = shallowRef<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);
+function calcAspectRatio() {
+ if (!gallery.value) return;
+
+ let img = props.mediaList[0];
+
+ if (props.mediaList.length !== 1 || !(img.properties.width && img.properties.height)) {
+ gallery.value.style.aspectRatio = '';
+ return;
+ }
+
+ // アスペクト比上限設定では、横長の場合は高さを縮小させる
+ const ratioMax = (ratio: number) => `${Math.max(ratio, img.properties.width / img.properties.height).toString()} / 1`;
+
+ switch (defaultStore.state.mediaListWithOneImageAppearance) {
+ case '16_9':
+ gallery.value.style.aspectRatio = ratioMax(16 / 9);
+ break;
+ case '1_1':
+ gallery.value.style.aspectRatio = ratioMax(1);
+ break;
+ case '2_3':
+ gallery.value.style.aspectRatio = ratioMax(2 / 3);
+ break;
+ default:
+ gallery.value.style.aspectRatio = '';
+ break;
+ }
+}
+
+watch([defaultStore.reactiveState.mediaListWithOneImageAppearance, gallery], () => calcAspectRatio());
+
onMounted(() => {
const lightbox = new PhotoSwipeLightbox({
dataSource: props.mediaList
@@ -64,7 +97,7 @@ onMounted(() => {
return item;
}),
gallery: gallery.value,
- mainClass: $style.pswp,
+ mainClass: 'pswp',
children: '.image',
thumbSelector: '.image',
loop: false,
@@ -162,12 +195,37 @@ const previewable = (file: misskey.entities.DriveFile): boolean => {
display: grid;
grid-gap: 8px;
- // for webkit
height: 100%;
+ width: 100%;
&.n1 {
- aspect-ratio: 16/9;
grid-template-rows: 1fr;
+
+ // default (expand)
+ min-height: 64px;
+ max-height: clamp(
+ 64px,
+ 50cqh,
+ min(360px, 50vh)
+ );
+
+ &.n116_9 {
+ min-height: none;
+ max-height: none;
+ aspect-ratio: 16 / 9; // fallback
+ }
+
+ &.n11_1{
+ min-height: none;
+ max-height: none;
+ aspect-ratio: 1 / 1; // fallback
+ }
+
+ &.n12_3 {
+ min-height: none;
+ max-height: none;
+ aspect-ratio: 2 / 3; // fallback
+ }
}
&.n2 {
@@ -211,7 +269,7 @@ const previewable = (file: misskey.entities.DriveFile): boolean => {
border-radius: 8px;
}
-.pswp {
+:global(.pswp) {
--pswp-root-z-index: var(--mk-pswp-root-z-index, 2000700) !important;
--pswp-bg: var(--modalBg) !important;
}
diff --git a/packages/frontend/src/components/MkMediaVideo.vue b/packages/frontend/src/components/MkMediaVideo.vue
index a4b76300e6..40bae90b5e 100644
--- a/packages/frontend/src/components/MkMediaVideo.vue
+++ b/packages/frontend/src/components/MkMediaVideo.vue
@@ -1,26 +1,28 @@
<template>
-<div v-if="hide" class="icozogqfvdetwohsdglrbswgrejoxbdj" @click="hide = false">
+<div v-if="hide" :class="$style.hidden" @click="hide = false">
<!-- 【注意】dataSaverMode が有効になっている際には、hide が false になるまでサムネイルや動画を読み込まないようにすること -->
- <div>
- <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>
+ <div :class="$style.sensitive">
+ <b v-if="video.isSensitive" style="display: block;"><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 style="display: block;"><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>
-<div v-else class="kkjnbbplepmiyuadieoenjgutgcmtsvu">
- <VuePlyr :options="{ volume: 0.5 }">
- <video
- controls
- :data-poster="video.thumbnailUrl"
+<div v-else :class="$style.visible">
+ <video
+ :class="$style.video"
+ :poster="video.thumbnailUrl"
+ :title="video.comment"
+ :alt="video.comment"
+ preload="none"
+ controls
+ @contextmenu.stop
+ >
+ <source
+ :src="video.url"
+ :type="video.type"
>
- <source
- size="720"
- :src="video.url"
- :type="video.type"
- />
- </video>
- </VuePlyr>
- <i class="ti ti-eye-off" @click="hide = true"></i>
+ </video>
+ <i class="ti ti-eye-off" :class="$style.hide" @click="hide = true"></i>
</div>
</template>
@@ -28,9 +30,7 @@
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';
import { i18n } from '@/i18n';
const props = defineProps<{
@@ -40,56 +40,49 @@ const props = defineProps<{
const hide = ref((defaultStore.state.nsfw === 'force' || defaultStore.state.enableDataSaverMode) ? true : (props.video.isSensitive && defaultStore.state.nsfw !== 'ignore'));
</script>
-<style lang="scss" scoped>
-.kkjnbbplepmiyuadieoenjgutgcmtsvu {
+<style lang="scss" module>
+.visible {
position: relative;
+}
- --plyr-color-main: var(--accent);
-
- > i {
- display: block;
- position: absolute;
- border-radius: 6px;
- background-color: var(--fg);
- color: var(--accentLighten);
- font-size: 14px;
- opacity: .5;
- padding: 3px 6px;
- text-align: center;
- cursor: pointer;
- top: 12px;
- right: 12px;
- }
-
- > video {
- display: flex;
- justify-content: center;
- align-items: center;
+.hide {
+ display: block;
+ position: absolute;
+ border-radius: 6px;
+ background-color: var(--fg);
+ color: var(--accentLighten);
+ font-size: 14px;
+ opacity: .5;
+ padding: 3px 6px;
+ text-align: center;
+ cursor: pointer;
+ top: 12px;
+ right: 12px;
+}
- font-size: 3.5em;
- overflow: hidden;
- background-position: center;
- background-size: cover;
- width: 100%;
- height: 100%;
- }
+.video {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ font-size: 3.5em;
+ overflow: hidden;
+ background-position: center;
+ background-size: cover;
+ width: 100%;
+ height: 100%;
}
-.icozogqfvdetwohsdglrbswgrejoxbdj {
+.hidden {
display: flex;
justify-content: center;
align-items: center;
background: #111;
color: #fff;
+}
- > div {
- display: table-cell;
- text-align: center;
- font-size: 12px;
-
- > b {
- display: block;
- }
- }
+.sensitive {
+ display: table-cell;
+ text-align: center;
+ font-size: 12px;
}
</style>
diff --git a/packages/frontend/src/components/MkMention.vue b/packages/frontend/src/components/MkMention.vue
index 481c3710ca..bb256c394b 100644
--- a/packages/frontend/src/components/MkMention.vue
+++ b/packages/frontend/src/components/MkMention.vue
@@ -2,7 +2,7 @@
<MkA v-user-preview="canonical" :class="[$style.root, { [$style.isMe]: isMe }]" :to="url" :style="{ background: bgCss }">
<img :class="$style.icon" :src="`/avatar/@${username}@${host}`" alt="">
<span>
- <span :class="$style.username">@{{ username }}</span>
+ <span>@{{ username }}</span>
<span v-if="(host != localHost) || defaultStore.state.showFullAcct" :class="$style.host">@{{ toUnicode(host) }}</span>
</span>
</MkA>
diff --git a/packages/frontend/src/components/MkMenu.child.vue b/packages/frontend/src/components/MkMenu.child.vue
index e0935efbe7..4fedfe7014 100644
--- a/packages/frontend/src/components/MkMenu.child.vue
+++ b/packages/frontend/src/components/MkMenu.child.vue
@@ -1,6 +1,6 @@
<template>
<div ref="el" :class="$style.root">
- <MkMenu :items="items" :align="align" :width="width" :as-drawer="false" @close="onChildClosed"/>
+ <MkMenu :items="items" :align="align" :width="width" :asDrawer="false" @close="onChildClosed"/>
</div>
</template>
diff --git a/packages/frontend/src/components/MkMenu.vue b/packages/frontend/src/components/MkMenu.vue
index e513a65a32..7dd6a8c88f 100644
--- a/packages/frontend/src/components/MkMenu.vue
+++ b/packages/frontend/src/components/MkMenu.vue
@@ -49,8 +49,8 @@
<span>{{ i18n.ts.none }}</span>
</span>
</div>
- <div v-if="childMenu" :class="$style.child">
- <XChild ref="child" :items="childMenu" :target-element="childTarget" :root-element="itemsEl" showing @actioned="childActioned"/>
+ <div v-if="childMenu">
+ <XChild ref="child" :items="childMenu" :targetElement="childTarget" :rootElement="itemsEl" showing @actioned="childActioned"/>
</div>
</div>
</template>
diff --git a/packages/frontend/src/components/MkModal.vue b/packages/frontend/src/components/MkModal.vue
index 99df9e8150..bb5c6c7aab 100644
--- a/packages/frontend/src/components/MkModal.vue
+++ b/packages/frontend/src/components/MkModal.vue
@@ -1,11 +1,31 @@
<template>
<Transition
:name="transitionName"
- :enter-active-class="$style['transition_' + transitionName + '_enterActive']"
- :leave-active-class="$style['transition_' + transitionName + '_leaveActive']"
- :enter-from-class="$style['transition_' + transitionName + '_enterFrom']"
- :leave-to-class="$style['transition_' + transitionName + '_leaveTo']"
- :duration="transitionDuration" appear @after-leave="emit('closed')" @enter="emit('opening')" @after-enter="onOpened"
+ :enterActiveClass="normalizeClass({
+ [$style.transition_modalDrawer_enterActive]: transitionName === 'modal-drawer',
+ [$style.transition_modalPopup_enterActive]: transitionName === 'modal-popup',
+ [$style.transition_modal_enterActive]: transitionName === 'modal',
+ [$style.transition_send_enterActive]: transitionName === 'send',
+ })"
+ :leaveActiveClass="normalizeClass({
+ [$style.transition_modalDrawer_leaveActive]: transitionName === 'modal-drawer',
+ [$style.transition_modalPopup_leaveActive]: transitionName === 'modal-popup',
+ [$style.transition_modal_leaveActive]: transitionName === 'modal',
+ [$style.transition_send_leaveActive]: transitionName === 'send',
+ })"
+ :enterFromClass="normalizeClass({
+ [$style.transition_modalDrawer_enterFrom]: transitionName === 'modal-drawer',
+ [$style.transition_modalPopup_enterFrom]: transitionName === 'modal-popup',
+ [$style.transition_modal_enterFrom]: transitionName === 'modal',
+ [$style.transition_send_enterFrom]: transitionName === 'send',
+ })"
+ :leaveToClass="normalizeClass({
+ [$style.transition_modalDrawer_leaveTo]: transitionName === 'modal-drawer',
+ [$style.transition_modalPopup_leaveTo]: transitionName === 'modal-popup',
+ [$style.transition_modal_leaveTo]: transitionName === 'modal',
+ [$style.transition_send_leaveTo]: transitionName === 'send',
+ })"
+ :duration="transitionDuration" appear @afterLeave="emit('closed')" @enter="emit('opening')" @afterEnter="onOpened"
>
<div v-show="manualShowing != null ? manualShowing : showing" v-hotkey.global="keymap" :class="[$style.root, { [$style.drawer]: type === 'drawer', [$style.dialog]: type === 'dialog', [$style.popup]: type === 'popup' }]" :style="{ zIndex, pointerEvents: (manualShowing != null ? manualShowing : showing) ? 'auto' : 'none', '--transformOrigin': transformOrigin }">
<div data-cy-bg :data-cy-transparent="isEnableBgTransparent" class="_modalBg" :class="[$style.bg, { [$style.bgTransparent]: isEnableBgTransparent }]" :style="{ zIndex }" @click="onBgClick" @mousedown="onBgClick" @contextmenu.prevent.stop="() => {}"></div>
@@ -17,7 +37,7 @@
</template>
<script lang="ts" setup>
-import { nextTick, onMounted, watch, provide } from 'vue';
+import { nextTick, normalizeClass, onMounted, onUnmounted, provide, watch } from 'vue';
import * as os from '@/os';
import { isTouchUsing } from '@/scripts/touch';
import { defaultStore } from '@/store';
@@ -38,7 +58,7 @@ type ModalTypes = 'popup' | 'dialog' | 'drawer';
const props = withDefaults(defineProps<{
manualShowing?: boolean | null;
anchor?: { x: string; y: string; };
- src?: HTMLElement;
+ src?: HTMLElement | null;
preferType?: ModalTypes | 'auto';
zPriority?: 'low' | 'middle' | 'high';
noOverlap?: boolean;
@@ -264,6 +284,10 @@ const onOpened = () => {
}, { passive: true });
};
+const alignObserver = new ResizeObserver((entries, observer) => {
+ align();
+});
+
onMounted(() => {
watch(() => props.src, async () => {
if (props.src) {
@@ -278,12 +302,14 @@ onMounted(() => {
}, { immediate: true });
nextTick(() => {
- new ResizeObserver((entries, observer) => {
- align();
- }).observe(content!);
+ alignObserver.observe(content!);
});
});
+onUnmounted(() => {
+ alignObserver.disconnect();
+});
+
defineExpose({
close,
});
@@ -339,8 +365,8 @@ defineExpose({
}
}
-.transition_modal-popup_enterActive,
-.transition_modal-popup_leaveActive {
+.transition_modalPopup_enterActive,
+.transition_modalPopup_leaveActive {
> .bg {
transition: opacity 0.1s !important;
}
@@ -350,8 +376,8 @@ defineExpose({
transition: opacity 0.1s cubic-bezier(0, 0, 0.2, 1), transform 0.1s cubic-bezier(0, 0, 0.2, 1) !important;
}
}
-.transition_modal-popup_enterFrom,
-.transition_modal-popup_leaveTo {
+.transition_modalPopup_enterFrom,
+.transition_modalPopup_leaveTo {
> .bg {
opacity: 0;
}
@@ -364,7 +390,7 @@ defineExpose({
}
}
-.transition_modal-drawer_enterActive {
+.transition_modalDrawer_enterActive {
> .bg {
transition: opacity 0.2s !important;
}
@@ -373,7 +399,7 @@ defineExpose({
transition: transform 0.2s cubic-bezier(0,.5,0,1) !important;
}
}
-.transition_modal-drawer_leaveActive {
+.transition_modalDrawer_leaveActive {
> .bg {
transition: opacity 0.2s !important;
}
@@ -382,8 +408,8 @@ defineExpose({
transition: transform 0.2s cubic-bezier(0,.5,0,1) !important;
}
}
-.transition_modal-drawer_enterFrom,
-.transition_modal-drawer_leaveTo {
+.transition_modalDrawer_enterFrom,
+.transition_modalDrawer_leaveTo {
> .bg {
opacity: 0;
}
diff --git a/packages/frontend/src/components/MkModalPageWindow.vue b/packages/frontend/src/components/MkModalPageWindow.vue
deleted file mode 100644
index b38865f525..0000000000
--- a/packages/frontend/src/components/MkModalPageWindow.vue
+++ /dev/null
@@ -1,182 +0,0 @@
-<template>
-<MkModal ref="modal" @click="$emit('click')" @closed="$emit('closed')">
- <div ref="rootEl" class="hrmcaedk" :style="{ width: `${width}px`, height: (height ? `min(${height}px, 100%)` : '100%') }">
- <div class="header" @contextmenu="onContextmenu">
- <button v-if="history.length > 0" v-tooltip="i18n.ts.goBack" class="_button" @click="back()"><i class="ti ti-arrow-left"></i></button>
- <span v-else style="display: inline-block; width: 20px"></span>
- <span v-if="pageMetadata?.value" class="title">
- <i v-if="pageMetadata?.value.icon" class="icon" :class="pageMetadata?.value.icon"></i>
- <span>{{ pageMetadata?.value.title }}</span>
- </span>
- <button class="_button" @click="$refs.modal.close()"><i class="ti ti-x"></i></button>
- </div>
- <div class="body" style="container-type: inline-size;">
- <MkStickyContainer>
- <template #header><MkPageHeader v-if="pageMetadata?.value && !pageMetadata?.value.hideHeader" :info="pageMetadata?.value"/></template>
- <RouterView :router="router"/>
- </MkStickyContainer>
- </div>
- </div>
-</MkModal>
-</template>
-
-<script lang="ts" setup>
-import { ComputedRef, provide } from 'vue';
-import MkModal from '@/components/MkModal.vue';
-import { popout as _popout } from '@/scripts/popout';
-import copyToClipboard from '@/scripts/copy-to-clipboard';
-import { url } from '@/config';
-import * as os from '@/os';
-import { mainRouter, routes } from '@/router';
-import { i18n } from '@/i18n';
-import { PageMetadata, provideMetadataReceiver } from '@/scripts/page-metadata';
-import { Router } from '@/nirax';
-
-const props = defineProps<{
- initialPath: string;
-}>();
-
-defineEmits<{
- (ev: 'closed'): void;
- (ev: 'click'): void;
-}>();
-
-const router = new Router(routes, props.initialPath);
-
-router.addListener('push', ctx => {
-
-});
-
-let pageMetadata = $ref<null | ComputedRef<PageMetadata>>();
-let rootEl = $ref();
-let modal = $shallowRef<InstanceType<typeof MkModal>>();
-let path = $ref(props.initialPath);
-let width = $ref(860);
-let height = $ref(660);
-const history = [];
-
-provide('router', router);
-provideMetadataReceiver((info) => {
- pageMetadata = info;
-});
-provide('shouldOmitHeaderTitle', true);
-provide('shouldHeaderThin', true);
-
-const pageUrl = $computed(() => url + path);
-const contextmenu = $computed(() => {
- return [{
- type: 'label',
- text: path,
- }, {
- icon: 'ti ti-player-eject',
- text: i18n.ts.showInPage,
- action: expand,
- }, {
- icon: 'ti ti-window-maximize',
- text: i18n.ts.popout,
- action: popout,
- }, null, {
- icon: 'ti ti-external-link',
- text: i18n.ts.openInNewTab,
- action: () => {
- window.open(pageUrl, '_blank');
- modal.close();
- },
- }, {
- icon: 'ti ti-link',
- text: i18n.ts.copyLink,
- action: () => {
- copyToClipboard(pageUrl);
- },
- }];
-});
-
-function navigate(path, record = true) {
- if (record) history.push(router.getCurrentPath());
- router.push(path);
-}
-
-function back() {
- navigate(history.pop(), false);
-}
-
-function expand() {
- mainRouter.push(path);
- modal.close();
-}
-
-function popout() {
- _popout(path, rootEl);
- modal.close();
-}
-
-function onContextmenu(ev: MouseEvent) {
- os.contextMenu(contextmenu, ev);
-}
-</script>
-
-<style lang="scss" scoped>
-.hrmcaedk {
- margin: auto;
- overflow: hidden;
- display: flex;
- flex-direction: column;
- contain: content;
- border-radius: var(--radius);
-
- --root-margin: 24px;
-
- @media (max-width: 500px) {
- --root-margin: 16px;
- }
-
- > .header {
- $height: 52px;
- $height-narrow: 42px;
- display: flex;
- flex-shrink: 0;
- height: $height;
- line-height: $height;
- font-weight: bold;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- background: var(--windowHeader);
- -webkit-backdrop-filter: var(--blur, blur(15px));
- backdrop-filter: var(--blur, blur(15px));
-
- > button {
- height: $height;
- width: $height;
-
- &:hover {
- color: var(--fgHighlighted);
- }
- }
-
- @media (max-width: 500px) {
- height: $height-narrow;
- line-height: $height-narrow;
- padding-left: 16px;
-
- > button {
- height: $height-narrow;
- width: $height-narrow;
- }
- }
-
- > .title {
- flex: 1;
-
- > .icon {
- margin-right: 0.5em;
- }
- }
- }
-
- > .body {
- overflow: auto;
- background: var(--bg);
- }
-}
-</style>
diff --git a/packages/frontend/src/components/MkModalWindow.vue b/packages/frontend/src/components/MkModalWindow.vue
index 63c55b904a..08569b4d6e 100644
--- a/packages/frontend/src/components/MkModalWindow.vue
+++ b/packages/frontend/src/components/MkModalWindow.vue
@@ -1,5 +1,5 @@
<template>
-<MkModal ref="modal" :prefer-type="'dialog'" @click="onBgClick" @closed="$emit('closed')">
+<MkModal ref="modal" :preferType="'dialog'" @click="onBgClick" @closed="$emit('closed')">
<div ref="rootEl" :class="$style.root" :style="{ width: `${width}px`, height: `min(${height}px, 100%)` }" @keydown="onKeydown">
<div ref="headerEl" :class="$style.header">
<button v-if="withOkButton" :class="$style.headerButton" class="_button" @click="$emit('close')"><i class="ti ti-x"></i></button>
diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue
index d95f8de311..7c9ddadbf8 100644
--- a/packages/frontend/src/components/MkNote.vue
+++ b/packages/frontend/src/components/MkNote.vue
@@ -44,8 +44,8 @@
<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"/>
- <MkInstanceTicker v-if="showTicker" :class="$style.ticker" :instance="appearNote.user.instance"/>
+ <MkNoteHeader :note="appearNote" :mini="true"/>
+ <MkInstanceTicker v-if="showTicker" :instance="appearNote.user.instance"/>
<div style="container-type: inline-size;">
<p v-if="appearNote.cw != null" :class="$style.cw">
<Mfm v-if="appearNote.cw != ''" style="margin-right: 8px;" :text="appearNote.cw" :author="appearNote.user" :i="$i"/>
@@ -55,17 +55,17 @@
<div :class="$style.text">
<span v-if="appearNote.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span>
<MkA v-if="appearNote.replyId" :class="$style.replyIcon" :to="`/notes/${appearNote.replyId}`"><i class="ti ti-arrow-back-up"></i></MkA>
- <Mfm v-if="appearNote.text" :text="appearNote.text" :author="appearNote.user" :i="$i" :emoji-urls="appearNote.emojis"/>
+ <Mfm v-if="appearNote.text" :text="appearNote.text" :author="appearNote.user" :i="$i" :emojiUrls="appearNote.emojis"/>
<div v-if="translating || translation" :class="$style.translation">
<MkLoading v-if="translating" mini/>
- <div v-else :class="$style.translated">
+ <div v-else>
<b>{{ i18n.t('translatedFrom', { x: translation.sourceLang }) }}: </b>
- <Mfm :text="translation.text" :author="appearNote.user" :i="$i" :emoji-urls="appearNote.emojis"/>
+ <Mfm :text="translation.text" :author="appearNote.user" :i="$i" :emojiUrls="appearNote.emojis"/>
</div>
</div>
</div>
- <div v-if="appearNote.files.length > 0" :class="$style.files">
- <MkMediaList :media-list="appearNote.files"/>
+ <div v-if="appearNote.files.length > 0">
+ <MkMediaList :mediaList="appearNote.files"/>
</div>
<MkPoll v-if="appearNote.poll" :note="appearNote" :class="$style.poll"/>
<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="false" :class="$style.urlPreview"/>
@@ -79,7 +79,7 @@
</div>
<MkA v-if="appearNote.channel && !inChannel" :class="$style.channel" :to="`/channels/${appearNote.channel.id}`"><i class="ti ti-device-tv"></i> {{ appearNote.channel.name }}</MkA>
</div>
- <MkReactionsViewer :note="appearNote" :max-number="16">
+ <MkReactionsViewer :note="appearNote" :maxNumber="16">
<template #more>
<button class="_button" :class="$style.reactionDetailsButton" @click="showReactions">
{{ i18n.ts.more }}
@@ -205,8 +205,11 @@ const isMyRenote = $i && ($i.id === note.userId);
const showContent = ref(false);
const urls = appearNote.text ? extractUrlFromMfm(mfm.parse(appearNote.text)) : null;
const isLong = (appearNote.cw == null && appearNote.text != null && (
+ (appearNote.text.includes('$[x2')) ||
(appearNote.text.includes('$[x3')) ||
(appearNote.text.includes('$[x4')) ||
+ (appearNote.text.includes('$[scale')) ||
+ (appearNote.text.includes('$[position')) ||
(appearNote.text.split('\n').length > 9) ||
(appearNote.text.length > 500) ||
(appearNote.files.length >= 5) ||
@@ -274,7 +277,7 @@ function renote(viaKeyboard = false) {
const y = rect.top + (el.offsetHeight / 2);
os.popup(MkRippleEffect, { x, y }, {}, 'end');
}
-
+
os.api('notes/create', {
renoteId: appearNote.id,
channelId: appearNote.channelId,
@@ -305,7 +308,7 @@ function renote(viaKeyboard = false) {
const y = rect.top + (el.offsetHeight / 2);
os.popup(MkRippleEffect, { x, y }, {}, 'end');
}
-
+
os.api('notes/create', {
renoteId: appearNote.id,
}).then(() => {
@@ -379,6 +382,8 @@ function undoReact(note): void {
function onContextmenu(ev: MouseEvent): void {
const isLink = (el: HTMLElement) => {
if (el.tagName === 'A') return true;
+ // 再生速度の選択などのために、Audio要素のコンテキストメニューはブラウザデフォルトとする。
+ if (el.tagName === 'AUDIO') return true;
if (el.parentElement) {
return isLink(el.parentElement);
}
diff --git a/packages/frontend/src/components/MkNoteDetailed.vue b/packages/frontend/src/components/MkNoteDetailed.vue
index 0d6d329d98..a65039277b 100644
--- a/packages/frontend/src/components/MkNoteDetailed.vue
+++ b/packages/frontend/src/components/MkNoteDetailed.vue
@@ -4,25 +4,25 @@
v-show="!isDeleted"
ref="el"
v-hotkey="keymap"
- class="lxwezrsl"
- :tabindex="!isDeleted ? '-1' : null"
- :class="{ renote: isRenote }"
+ :class="$style.root"
>
- <MkNoteSub v-for="note in conversation" :key="note.id" class="reply-to-more" :note="note"/>
- <MkNoteSub v-if="appearNote.reply" :note="appearNote.reply" class="reply-to"/>
- <div v-if="isRenote" class="renote">
- <MkAvatar class="avatar" :user="note.user" link preview/>
- <i class="ti ti-repeat"></i>
- <I18n :src="i18n.ts.renotedBy" tag="span">
- <template #user>
- <MkA v-user-preview="note.userId" class="name" :to="userPage(note.user)">
- <MkUserName :user="note.user"/>
- </MkA>
- </template>
- </I18n>
- <div class="info">
- <button ref="renoteTime" class="_button time" @click="showRenoteMenu()">
- <i v-if="isMyRenote" class="ti ti-dots dropdownIcon"></i>
+ <MkNoteSub v-for="note in conversation" :key="note.id" :class="$style.replyToMore" :note="note"/>
+ <MkNoteSub v-if="appearNote.reply" :note="appearNote.reply" :class="$style.replyTo"/>
+ <div v-if="isRenote" :class="$style.renote">
+ <MkAvatar :class="$style.renoteAvatar" :user="note.user" link preview/>
+ <i class="ti ti-repeat" style="margin-right: 4px;"></i>
+ <span :class="$style.renoteText">
+ <I18n :src="i18n.ts.renotedBy" tag="span">
+ <template #user>
+ <MkA v-user-preview="note.userId" :class="$style.renoteName" :to="userPage(note.user)">
+ <MkUserName :user="note.user"/>
+ </MkA>
+ </template>
+ </I18n>
+ </span>
+ <div :class="$style.renoteInfo">
+ <button ref="renoteTime" class="_button" :class="$style.renoteTime" @click="showRenoteMenu()">
+ <i v-if="isMyRenote" class="ti ti-dots" style="margin-right: 4px;"></i>
<MkTime :time="note.createdAt"/>
</button>
<span v-if="note.visibility !== 'public'" style="margin-left: 0.5em;" :title="i18n.ts._visibility[note.visibility]">
@@ -33,16 +33,16 @@
<span v-if="note.localOnly" style="margin-left: 0.5em;" :title="i18n.ts._visibility['disableFederation']"><i class="ti ti-rocket-off"></i></span>
</div>
</div>
- <article class="article" @contextmenu.stop="onContextmenu">
- <header class="header">
- <MkAvatar class="avatar" :user="appearNote.user" indicator link preview/>
- <div class="body">
- <div class="top">
- <MkA v-user-preview="appearNote.user.id" class="name" :to="userPage(appearNote.user)">
+ <article :class="$style.note" @contextmenu.stop="onContextmenu">
+ <header :class="$style.noteHeader">
+ <MkAvatar :class="$style.noteHeaderAvatar" :user="appearNote.user" indicator link preview/>
+ <div :class="$style.noteHeaderBody">
+ <div>
+ <MkA v-user-preview="appearNote.user.id" :class="$style.noteHeaderName" :to="userPage(appearNote.user)">
<MkUserName :nowrap="false" :user="appearNote.user"/>
</MkA>
- <span v-if="appearNote.user.isBot" class="is-bot">bot</span>
- <div class="info">
+ <span v-if="appearNote.user.isBot" :class="$style.isBot">bot</span>
+ <div :class="$style.noteHeaderInfo">
<span v-if="appearNote.visibility !== 'public'" style="margin-left: 0.5em;" :title="i18n.ts._visibility[appearNote.visibility]">
<i v-if="appearNote.visibility === 'home'" class="ti ti-home"></i>
<i v-else-if="appearNote.visibility === 'followers'" class="ti ti-lock"></i>
@@ -51,84 +51,81 @@
<span v-if="appearNote.localOnly" style="margin-left: 0.5em;" :title="i18n.ts._visibility['disableFederation']"><i class="ti ti-rocket-off"></i></span>
</div>
</div>
- <div class="username"><MkAcct :user="appearNote.user"/></div>
- <MkInstanceTicker v-if="showTicker" class="ticker" :instance="appearNote.user.instance"/>
+ <div :class="$style.noteHeaderUsername"><MkAcct :user="appearNote.user"/></div>
+ <MkInstanceTicker v-if="showTicker" :instance="appearNote.user.instance"/>
</div>
</header>
- <div class="main">
- <div class="body">
- <p v-if="appearNote.cw != null" class="cw">
- <Mfm v-if="appearNote.cw != ''" class="text" :text="appearNote.cw" :author="appearNote.user" :i="$i"/>
- <MkCwButton v-model="showContent" :note="appearNote"/>
- </p>
- <div v-show="appearNote.cw == null || showContent" class="content">
- <div class="text">
- <span v-if="appearNote.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span>
- <MkA v-if="appearNote.replyId" class="reply" :to="`/notes/${appearNote.replyId}`"><i class="ti ti-arrow-back-up"></i></MkA>
- <Mfm v-if="appearNote.text" :text="appearNote.text" :author="appearNote.user" :i="$i" :emoji-urls="appearNote.emojis"/>
- <a v-if="appearNote.renote != null" class="rp">RN:</a>
- <div v-if="translating || translation" class="translation">
- <MkLoading v-if="translating" mini/>
- <div v-else class="translated">
- <b>{{ i18n.t('translatedFrom', { x: translation.sourceLang }) }}: </b>
- <Mfm :text="translation.text" :author="appearNote.user" :i="$i" :emoji-urls="appearNote.emojis"/>
- </div>
- </div>
+ <div :class="$style.noteContent">
+ <p v-if="appearNote.cw != null" :class="$style.cw">
+ <Mfm v-if="appearNote.cw != ''" style="margin-right: 8px;" :text="appearNote.cw" :author="appearNote.user" :i="$i"/>
+ <MkCwButton v-model="showContent" :note="appearNote"/>
+ </p>
+ <div v-show="appearNote.cw == null || showContent">
+ <span v-if="appearNote.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span>
+ <MkA v-if="appearNote.replyId" :class="$style.noteReplyTarget" :to="`/notes/${appearNote.replyId}`"><i class="ti ti-arrow-back-up"></i></MkA>
+ <Mfm v-if="appearNote.text" :text="appearNote.text" :author="appearNote.user" :i="$i" :emojiUrls="appearNote.emojis"/>
+ <a v-if="appearNote.renote != null" :class="$style.rn">RN:</a>
+ <div v-if="translating || translation" :class="$style.translation">
+ <MkLoading v-if="translating" mini/>
+ <div v-else>
+ <b>{{ i18n.t('translatedFrom', { x: translation.sourceLang }) }}: </b>
+ <Mfm :text="translation.text" :author="appearNote.user" :i="$i" :emojiUrls="appearNote.emojis"/>
</div>
- <div v-if="appearNote.files.length > 0" class="files">
- <MkMediaList :media-list="appearNote.files"/>
- </div>
- <MkPoll v-if="appearNote.poll" ref="pollViewer" :note="appearNote" class="poll"/>
- <MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="true" class="url-preview"/>
- <div v-if="appearNote.renote" class="renote"><MkNoteSimple :note="appearNote.renote" class="note"/></div>
</div>
- <MkA v-if="appearNote.channel && !inChannel" class="channel" :to="`/channels/${appearNote.channel.id}`"><i class="ti ti-device-tv"></i> {{ appearNote.channel.name }}</MkA>
- </div>
- <footer class="footer">
- <div class="info">
- <MkA class="created-at" :to="notePage(appearNote)">
- <MkTime :time="appearNote.createdAt" mode="detail"/>
- </MkA>
+ <div v-if="appearNote.files.length > 0">
+ <MkMediaList :mediaList="appearNote.files"/>
</div>
- <MkReactionsViewer ref="reactionsViewer" :note="appearNote"/>
- <button class="button _button" @click="reply()">
- <i class="ti ti-arrow-back-up"></i>
- <p v-if="appearNote.repliesCount > 0" class="count">{{ appearNote.repliesCount }}</p>
- </button>
- <button
- v-if="canRenote"
- ref="renoteButton"
- class="button _button"
- @mousedown="renote()"
- >
- <i class="ti ti-repeat"></i>
- <p v-if="appearNote.renoteCount > 0" class="count">{{ appearNote.renoteCount }}</p>
- </button>
- <button v-else class="button _button" disabled>
- <i class="ti ti-ban"></i>
- </button>
- <button v-if="appearNote.myReaction == null" ref="reactButton" class="button _button" @mousedown="react()">
- <i v-if="appearNote.reactionAcceptance === 'likeOnly'" class="ti ti-heart"></i>
- <i v-else class="ti ti-plus"></i>
- </button>
- <button v-if="appearNote.myReaction != null" ref="reactButton" class="button _button reacted" @click="undoReact(appearNote)">
- <i class="ti ti-minus"></i>
- </button>
- <button v-if="defaultStore.state.showClipButtonInNoteFooter" ref="clipButton" class="button _button" @mousedown="clip()">
- <i class="ti ti-paperclip"></i>
- </button>
- <button ref="menuButton" class="button _button" @mousedown="menu()">
- <i class="ti ti-dots"></i>
- </button>
- </footer>
+ <MkPoll v-if="appearNote.poll" ref="pollViewer" :note="appearNote" :class="$style.poll"/>
+ <MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="true" style="margin-top: 6px;"/>
+ <div v-if="appearNote.renote" :class="$style.quote"><MkNoteSimple :note="appearNote.renote" :class="$style.quoteNote"/></div>
+ </div>
+ <MkA v-if="appearNote.channel && !inChannel" :class="$style.channel" :to="`/channels/${appearNote.channel.id}`"><i class="ti ti-device-tv"></i> {{ appearNote.channel.name }}</MkA>
</div>
+ <footer>
+ <div :class="$style.noteFooterInfo">
+ <MkA :to="notePage(appearNote)">
+ <MkTime :time="appearNote.createdAt" mode="detail"/>
+ </MkA>
+ </div>
+ <MkReactionsViewer ref="reactionsViewer" :note="appearNote"/>
+ <button class="_button" :class="$style.noteFooterButton" @click="reply()">
+ <i class="ti ti-arrow-back-up"></i>
+ <p v-if="appearNote.repliesCount > 0" :class="$style.noteFooterButtonCount">{{ appearNote.repliesCount }}</p>
+ </button>
+ <button
+ v-if="canRenote"
+ ref="renoteButton"
+ class="_button"
+ :class="$style.noteFooterButton"
+ @mousedown="renote()"
+ >
+ <i class="ti ti-repeat"></i>
+ <p v-if="appearNote.renoteCount > 0" :class="$style.noteFooterButtonCount">{{ appearNote.renoteCount }}</p>
+ </button>
+ <button v-else class="_button" :class="$style.noteFooterButton" disabled>
+ <i class="ti ti-ban"></i>
+ </button>
+ <button v-if="appearNote.myReaction == null" ref="reactButton" :class="$style.noteFooterButton" class="_button" @mousedown="react()">
+ <i v-if="appearNote.reactionAcceptance === 'likeOnly'" class="ti ti-heart"></i>
+ <i v-else class="ti ti-plus"></i>
+ </button>
+ <button v-if="appearNote.myReaction != null" ref="reactButton" class="_button" :class="[$style.noteFooterButton, $style.reacted]" @click="undoReact(appearNote)">
+ <i class="ti ti-minus"></i>
+ </button>
+ <button v-if="defaultStore.state.showClipButtonInNoteFooter" ref="clipButton" class="_button" :class="$style.noteFooterButton" @mousedown="clip()">
+ <i class="ti ti-paperclip"></i>
+ </button>
+ <button ref="menuButton" class="_button" :class="$style.noteFooterButton" @mousedown="menu()">
+ <i class="ti ti-dots"></i>
+ </button>
+ </footer>
</article>
- <MkNoteSub v-for="note in replies" :key="note.id" :note="note" class="reply" :detail="true"/>
+ <MkNoteSub v-for="note in replies" :key="note.id" :note="note" :class="$style.reply" :detail="true"/>
</div>
-<div v-else class="_panel muted" @click="muted = false">
+<div v-else class="_panel" :class="$style.muted" @click="muted = false">
<I18n :src="i18n.ts.userSaysSomething" tag="small">
<template #name>
- <MkA v-user-preview="appearNote.userId" class="name" :to="userPage(appearNote.user)">
+ <MkA v-user-preview="appearNote.userId" :to="userPage(appearNote.user)">
<MkUserName :user="appearNote.user"/>
</MkA>
</template>
@@ -438,318 +435,249 @@ if (appearNote.replyId) {
}
</script>
-<style lang="scss" scoped>
-.lxwezrsl {
+<style lang="scss" module>
+.root {
position: relative;
transition: box-shadow 0.1s ease;
overflow: clip;
contain: content;
+}
- &:focus-visible {
- outline: none;
-
- &:after {
- content: "";
- pointer-events: none;
- display: block;
- position: absolute;
- z-index: 10;
- top: 0;
- left: 0;
- right: 0;
- bottom: 0;
- margin: auto;
- width: calc(100% - 8px);
- height: calc(100% - 8px);
- border: dashed 1px var(--focus);
- border-radius: var(--radius);
- box-sizing: border-box;
- }
- }
-
- &:hover > .article > .main > .footer > .button {
- opacity: 1;
- }
-
- > .reply-to {
- opacity: 0.7;
- padding-bottom: 0;
- }
+.replyTo {
+ opacity: 0.7;
+ padding-bottom: 0;
+}
- > .reply-to-more {
- opacity: 0.7;
- }
+.replyToMore {
+ opacity: 0.7;
+}
- > .renote {
- display: flex;
- align-items: center;
- padding: 16px 32px 8px 32px;
- line-height: 28px;
- white-space: pre;
- color: var(--renote);
+.renote {
+ display: flex;
+ align-items: center;
+ padding: 16px 32px 8px 32px;
+ line-height: 28px;
+ white-space: pre;
+ color: var(--renote);
+}
- > .avatar {
- flex-shrink: 0;
- display: inline-block;
- width: 28px;
- height: 28px;
- margin: 0 8px 0 0;
- border-radius: 6px;
- }
+.renoteAvatar {
+ flex-shrink: 0;
+ display: inline-block;
+ width: 28px;
+ height: 28px;
+ margin: 0 8px 0 0;
+ border-radius: 6px;
+}
- > i {
- margin-right: 4px;
- }
+.renoteText {
+ overflow: hidden;
+ flex-shrink: 1;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
- > span {
- overflow: hidden;
- flex-shrink: 1;
- text-overflow: ellipsis;
- white-space: nowrap;
+.renoteName {
+ font-weight: bold;
+}
- > .name {
- font-weight: bold;
- }
- }
+.renoteInfo {
+ margin-left: auto;
+ font-size: 0.9em;
+}
- > .info {
- margin-left: auto;
- font-size: 0.9em;
+.renoteTime {
+ flex-shrink: 0;
+ color: inherit;
+}
- > .time {
- flex-shrink: 0;
- color: inherit;
+.renote + .note {
+ padding-top: 8px;
+}
- > .dropdownIcon {
- margin-right: 4px;
- }
- }
- }
- }
+.note {
+ padding: 32px;
+ font-size: 1.2em;
- > .renote + .article {
- padding-top: 8px;
+ &:hover > .main > .footer > .button {
+ opacity: 1;
}
+}
- > .article {
- padding: 32px;
- font-size: 1.2em;
-
- > .header {
- display: flex;
- position: relative;
- margin-bottom: 16px;
- align-items: center;
-
- > .avatar {
- display: block;
- flex-shrink: 0;
- width: 58px;
- height: 58px;
- }
-
- > .body {
- flex: 1;
- display: flex;
- flex-direction: column;
- justify-content: center;
- padding-left: 16px;
- font-size: 0.95em;
-
- > .top {
- > .name {
- font-weight: bold;
- line-height: 1.3;
- }
+.noteHeader {
+ display: flex;
+ position: relative;
+ margin-bottom: 16px;
+ align-items: center;
+}
- > .is-bot {
- display: inline-block;
- margin: 0 0.5em;
- padding: 4px 6px;
- font-size: 80%;
- line-height: 1;
- border: solid 0.5px var(--divider);
- border-radius: 4px;
- }
+.noteHeaderAvatar {
+ display: block;
+ flex-shrink: 0;
+ width: 58px;
+ height: 58px;
+}
- > .info {
- float: right;
- }
- }
+.noteHeaderBody {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ padding-left: 16px;
+ font-size: 0.95em;
+}
- > .username {
- margin-bottom: 2px;
- line-height: 1.3;
- word-wrap: anywhere;
- }
- }
- }
+.noteHeaderName {
+ font-weight: bold;
+ line-height: 1.3;
+}
- > .main {
- > .body {
- container-type: inline-size;
+.isBot {
+ display: inline-block;
+ margin: 0 0.5em;
+ padding: 4px 6px;
+ font-size: 80%;
+ line-height: 1;
+ border: solid 0.5px var(--divider);
+ border-radius: 4px;
+}
- > .cw {
- cursor: default;
- display: block;
- margin: 0;
- padding: 0;
- overflow-wrap: break-word;
+.noteHeaderInfo {
+ float: right;
+}
- > .text {
- margin-right: 8px;
- }
- }
+.noteHeaderUsername {
+ margin-bottom: 2px;
+ line-height: 1.3;
+ word-wrap: anywhere;
+}
- > .content {
- > .text {
- overflow-wrap: break-word;
+.noteContent {
+ container-type: inline-size;
+ overflow-wrap: break-word;
+}
- > .reply {
- color: var(--accent);
- margin-right: 0.5em;
- }
+.cw {
+ cursor: default;
+ display: block;
+ margin: 0;
+ padding: 0;
+ overflow-wrap: break-word;
+}
- > .rp {
- margin-left: 4px;
- font-style: oblique;
- color: var(--renote);
- }
+.noteReplyTarget {
+ color: var(--accent);
+ margin-right: 0.5em;
+}
- > .translation {
- border: solid 0.5px var(--divider);
- border-radius: var(--radius);
- padding: 12px;
- margin-top: 8px;
- }
- }
+.rn {
+ margin-left: 4px;
+ font-style: oblique;
+ color: var(--renote);
+}
- > .url-preview {
- margin-top: 8px;
- }
+.translation {
+ border: solid 0.5px var(--divider);
+ border-radius: var(--radius);
+ padding: 12px;
+ margin-top: 8px;
+}
- > .poll {
- font-size: 80%;
- }
+.poll {
+ font-size: 80%;
+}
- > .renote {
- padding: 8px 0;
+.quote {
+ padding: 8px 0;
+}
- > .note {
- padding: 16px;
- border: dashed 1px var(--renote);
- border-radius: 8px;
- }
- }
- }
+.quoteNote {
+ padding: 16px;
+ border: dashed 1px var(--renote);
+ border-radius: 8px;
+}
- > .channel {
- opacity: 0.7;
- font-size: 80%;
- }
- }
+.channel {
+ opacity: 0.7;
+ font-size: 80%;
+}
- > .footer {
- > .info {
- margin: 16px 0;
- opacity: 0.7;
- font-size: 0.9em;
- }
+.noteFooterInfo {
+ margin: 16px 0;
+ opacity: 0.7;
+ font-size: 0.9em;
+}
- > .button {
- margin: 0;
- padding: 8px;
- opacity: 0.7;
+.noteFooterButton {
+ margin: 0;
+ padding: 8px;
+ opacity: 0.7;
- &:not(:last-child) {
- margin-right: 28px;
- }
+ &:not(:last-child) {
+ margin-right: 28px;
+ }
- &:hover {
- color: var(--fgHighlighted);
- }
+ &:hover {
+ color: var(--fgHighlighted);
+ }
+}
- > .count {
- display: inline;
- margin: 0 0 0 8px;
- opacity: 0.7;
- }
+.noteFooterButtonCount {
+ display: inline;
+ margin: 0 0 0 8px;
+ opacity: 0.7;
- &.reacted {
- color: var(--accent);
- }
- }
- }
- }
+ &.reacted {
+ color: var(--accent);
}
+}
- > .reply {
- border-top: solid 0.5px var(--divider);
- }
+.reply {
+ border-top: solid 0.5px var(--divider);
}
@container (max-width: 500px) {
- .lxwezrsl {
+ .root {
font-size: 0.9em;
}
}
@container (max-width: 450px) {
- .lxwezrsl {
- > .renote {
- padding: 8px 16px 0 16px;
- }
+ .renote {
+ padding: 8px 16px 0 16px;
+ }
- > .article {
- padding: 16px;
+ .note {
+ padding: 16px;
+ }
- > .header {
- > .avatar {
- width: 50px;
- height: 50px;
- }
- }
- }
+ .noteHeaderAvatar {
+ width: 50px;
+ height: 50px;
}
}
@container (max-width: 350px) {
- .lxwezrsl {
- > .article {
- > .main {
- > .footer {
- > .button {
- &:not(:last-child) {
- margin-right: 18px;
- }
- }
- }
- }
+ .noteFooterButton {
+ &:not(:last-child) {
+ margin-right: 18px;
}
}
}
@container (max-width: 300px) {
- .lxwezrsl {
+ .root {
font-size: 0.825em;
+ }
- > .article {
- > .header {
- > .avatar {
- width: 50px;
- height: 50px;
- }
- }
+ .noteHeaderAvatar {
+ width: 50px;
+ height: 50px;
+ }
- > .main {
- > .footer {
- > .button {
- &:not(:last-child) {
- margin-right: 12px;
- }
- }
- }
- }
+ .noteFooterButton {
+ &:not(:last-child) {
+ margin-right: 12px;
}
}
}
diff --git a/packages/frontend/src/components/MkNotePreview.vue b/packages/frontend/src/components/MkNotePreview.vue
index 6b55c27869..6786f8b256 100644
--- a/packages/frontend/src/components/MkNotePreview.vue
+++ b/packages/frontend/src/components/MkNotePreview.vue
@@ -6,7 +6,7 @@
<MkUserName :user="$i" :nowrap="true"/>
</div>
<div>
- <div :class="$style.content">
+ <div>
<Mfm :text="text.trim()" :author="$i" :i="$i"/>
</div>
</div>
diff --git a/packages/frontend/src/components/MkNoteSimple.vue b/packages/frontend/src/components/MkNoteSimple.vue
index bd27a43b61..21be1454a7 100644
--- a/packages/frontend/src/components/MkNoteSimple.vue
+++ b/packages/frontend/src/components/MkNoteSimple.vue
@@ -5,7 +5,7 @@
<MkNoteHeader :class="$style.header" :note="note" :mini="true"/>
<div>
<p v-if="note.cw != null" :class="$style.cw">
- <Mfm v-if="note.cw != ''" style="margin-right: 8px;" :text="note.cw" :author="note.user" :i="$i" :emoji-urls="note.emojis"/>
+ <Mfm v-if="note.cw != ''" style="margin-right: 8px;" :text="note.cw" :author="note.user" :i="$i" :emojiUrls="note.emojis"/>
<MkCwButton v-model="showContent" :note="note"/>
</p>
<div v-show="note.cw == null || showContent">
diff --git a/packages/frontend/src/components/MkNotes.vue b/packages/frontend/src/components/MkNotes.vue
index a4e949c898..9cc2b7a967 100644
--- a/packages/frontend/src/components/MkNotes.vue
+++ b/packages/frontend/src/components/MkNotes.vue
@@ -15,7 +15,7 @@
:items="notes"
:direction="pagination.reversed ? 'up' : 'down'"
:reversed="pagination.reversed"
- :no-gap="noGap"
+ :noGap="noGap"
:ad="true"
:class="$style.notes"
>
diff --git a/packages/frontend/src/components/MkNotification.vue b/packages/frontend/src/components/MkNotification.vue
index efae687e66..d25332b10f 100644
--- a/packages/frontend/src/components/MkNotification.vue
+++ b/packages/frontend/src/components/MkNotification.vue
@@ -5,7 +5,19 @@
<MkAvatar v-else-if="notification.type === 'achievementEarned'" :class="$style.icon" :user="$i" link preview/>
<MkAvatar v-else-if="notification.user" :class="$style.icon" :user="notification.user" link preview/>
<img v-else-if="notification.icon" :class="$style.icon" :src="notification.icon" alt=""/>
- <div :class="[$style.subIcon, $style['t_' + notification.type]]">
+ <div
+ :class="[$style.subIcon, {
+ [$style.t_follow]: notification.type === 'follow',
+ [$style.t_followRequestAccepted]: notification.type === 'followRequestAccepted',
+ [$style.t_receiveFollowRequest]: notification.type === 'receiveFollowRequest',
+ [$style.t_renote]: notification.type === 'renote',
+ [$style.t_reply]: notification.type === 'reply',
+ [$style.t_mention]: notification.type === 'mention',
+ [$style.t_quote]: notification.type === 'quote',
+ [$style.t_pollEnded]: notification.type === 'pollEnded',
+ [$style.t_achievementEarned]: notification.type === 'achievementEarned',
+ }]"
+ >
<i v-if="notification.type === 'follow'" class="ti ti-plus"></i>
<i v-else-if="notification.type === 'receiveFollowRequest'" class="ti ti-clock"></i>
<i v-else-if="notification.type === 'followRequestAccepted'" class="ti ti-check"></i>
@@ -20,8 +32,8 @@
v-else-if="notification.type === 'reaction'"
ref="reactionRef"
:reaction="notification.reaction ? notification.reaction.replace(/^:(\w+):$/, ':$1@.:') : notification.reaction"
- :custom-emojis="notification.note.emojis"
- :no-style="true"
+ :customEmojis="notification.note.emojis"
+ :noStyle="true"
style="width: 100%; height: 100%;"
/>
</div>
@@ -34,7 +46,7 @@
<span v-else>{{ notification.header }}</span>
<MkTime v-if="withTime" :time="notification.createdAt" :class="$style.headerTime"/>
</header>
- <div :class="$style.content">
+ <div>
<MkA v-if="notification.type === 'reaction'" :class="$style.text" :to="notePage(notification.note)" :title="getNoteSummary(notification.note)">
<i class="ti ti-quote" :class="$style.quote"></i>
<Mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="true" :author="notification.note.user"/>
@@ -243,9 +255,6 @@ useTooltip(reactionRef, (showing) => {
font-size: 0.9em;
}
-.content {
-}
-
.text {
display: flex;
width: 100%;
diff --git a/packages/frontend/src/components/MkNotificationSettingWindow.vue b/packages/frontend/src/components/MkNotificationSettingWindow.vue
index f6d0e5681d..598d3a0551 100644
--- a/packages/frontend/src/components/MkNotificationSettingWindow.vue
+++ b/packages/frontend/src/components/MkNotificationSettingWindow.vue
@@ -3,15 +3,15 @@
ref="dialog"
:width="400"
:height="450"
- :with-ok-button="true"
- :ok-button-disabled="false"
+ :withOkButton="true"
+ :okButtonDisabled="false"
@ok="ok()"
@close="dialog?.close()"
@closed="emit('closed')"
>
<template #header>{{ i18n.ts.notificationSetting }}</template>
- <MkSpacer :margin-min="20" :margin-max="28">
+ <MkSpacer :marginMin="20" :marginMax="28">
<div class="_gaps_m">
<template v-if="showGlobalToggle">
<MkSwitch v-model="useGlobalSetting">
diff --git a/packages/frontend/src/components/MkNotifications.vue b/packages/frontend/src/components/MkNotifications.vue
index 1aea95fe0e..70224bffa1 100644
--- a/packages/frontend/src/components/MkNotifications.vue
+++ b/packages/frontend/src/components/MkNotifications.vue
@@ -8,9 +8,9 @@
</template>
<template #default="{ items: notifications }">
- <MkDateSeparatedList v-slot="{ item: notification }" :class="$style.list" :items="notifications" :no-gap="true">
+ <MkDateSeparatedList v-slot="{ item: notification }" :class="$style.list" :items="notifications" :noGap="true">
<MkNote v-if="['reply', 'quote', 'mention'].includes(notification.type)" :key="notification.id" :note="notification.note"/>
- <XNotification v-else :key="notification.id" :notification="notification" :with-time="true" :full="true" class="_panel notification"/>
+ <XNotification v-else :key="notification.id" :notification="notification" :withTime="true" :full="true" class="_panel notification"/>
</MkDateSeparatedList>
</template>
</MkPagination>
@@ -22,7 +22,7 @@ import MkPagination, { Paging } from '@/components/MkPagination.vue';
import XNotification from '@/components/MkNotification.vue';
import MkDateSeparatedList from '@/components/MkDateSeparatedList.vue';
import MkNote from '@/components/MkNote.vue';
-import { stream } from '@/stream';
+import { useStream } from '@/stream';
import { $i } from '@/account';
import { i18n } from '@/i18n';
import { notificationTypes } from '@/const';
@@ -45,7 +45,7 @@ const pagination: Paging = {
const onNotification = (notification) => {
const isMuted = props.includeTypes ? !props.includeTypes.includes(notification.type) : $i.mutingNotificationTypes.includes(notification.type);
if (isMuted || document.visibilityState === 'visible') {
- stream.send('readNotification');
+ useStream().send('readNotification');
}
if (!isMuted) {
@@ -56,7 +56,7 @@ const onNotification = (notification) => {
let connection;
onMounted(() => {
- connection = stream.useChannel('main');
+ connection = useStream().useChannel('main');
connection.on('notification', onNotification);
});
diff --git a/packages/frontend/src/components/MkObjectView.value.vue b/packages/frontend/src/components/MkObjectView.value.vue
index e7fc73bce3..d48e7886eb 100644
--- a/packages/frontend/src/components/MkObjectView.value.vue
+++ b/packages/frontend/src/components/MkObjectView.value.vue
@@ -28,54 +28,38 @@
</div>
</template>
-<script lang="ts">
-import { defineComponent, reactive } from 'vue';
+<script lang="ts" setup>
+import { reactive } from 'vue';
import number from '@/filters/number';
+import XValue from '@/components/MkObjectView.value.vue';
-export default defineComponent({
- name: 'XValue',
+const props = defineProps<{
+ value: any;
+}>();
- props: {
- value: {
- required: true,
- },
- },
+const collapsed = reactive({});
- setup(props) {
- const collapsed = reactive({});
-
- if (isObject(props.value)) {
- for (const key in props.value) {
- collapsed[key] = collapsable(props.value[key]);
- }
- }
-
- function isObject(v): boolean {
- return typeof v === 'object' && !Array.isArray(v) && v !== null;
- }
+if (isObject(props.value)) {
+ for (const key in props.value) {
+ collapsed[key] = collapsable(props.value[key]);
+ }
+}
- function isArray(v): boolean {
- return Array.isArray(v);
- }
+function isObject(v): boolean {
+ return typeof v === 'object' && !Array.isArray(v) && v !== null;
+}
- function isEmpty(v): boolean {
- return (isArray(v) && v.length === 0) || (isObject(v) && Object.keys(v).length === 0);
- }
+function isArray(v): boolean {
+ return Array.isArray(v);
+}
- function collapsable(v): boolean {
- return (isObject(v) || isArray(v)) && !isEmpty(v);
- }
+function isEmpty(v): boolean {
+ return (isArray(v) && v.length === 0) || (isObject(v) && Object.keys(v).length === 0);
+}
- return {
- number,
- collapsed,
- isObject,
- isArray,
- isEmpty,
- collapsable,
- };
- },
-});
+function collapsable(v): boolean {
+ return (isObject(v) || isArray(v)) && !isEmpty(v);
+}
</script>
<style lang="scss" scoped>
diff --git a/packages/frontend/src/components/MkObjectView.vue b/packages/frontend/src/components/MkObjectView.vue
index 55578a37f6..8b1ed74142 100644
--- a/packages/frontend/src/components/MkObjectView.vue
+++ b/packages/frontend/src/components/MkObjectView.vue
@@ -1,5 +1,5 @@
<template>
-<div class="zhyxdalp">
+<div>
<XValue :value="value" :collapsed="false"/>
</div>
</template>
@@ -12,9 +12,3 @@ const props = defineProps<{
value: Record<string, unknown>;
}>();
</script>
-
-<style lang="scss" scoped>
-.zhyxdalp {
-
-}
-</style>
diff --git a/packages/frontend/src/components/MkOmit.vue b/packages/frontend/src/components/MkOmit.vue
index e2d68d12c3..668f9ff5af 100644
--- a/packages/frontend/src/components/MkOmit.vue
+++ b/packages/frontend/src/components/MkOmit.vue
@@ -8,7 +8,7 @@
</template>
<script lang="ts" setup>
-import { onMounted } from 'vue';
+import { onMounted, onUnmounted } from 'vue';
import { i18n } from '@/i18n';
const props = withDefaults(defineProps<{
@@ -21,16 +21,22 @@ let content = $shallowRef<HTMLElement>();
let omitted = $ref(false);
let ignoreOmit = $ref(false);
-onMounted(() => {
- const calcOmit = () => {
- if (omitted || ignoreOmit) return;
- omitted = content.offsetHeight > props.maxHeight;
- };
+const calcOmit = () => {
+ if (omitted || ignoreOmit) return;
+ omitted = content.offsetHeight > props.maxHeight;
+};
+const omitObserver = new ResizeObserver((entries, observer) => {
calcOmit();
- new ResizeObserver((entries, observer) => {
- calcOmit();
- }).observe(content);
+});
+
+onMounted(() => {
+ calcOmit();
+ omitObserver.observe(content);
+});
+
+onUnmounted(() => {
+ omitObserver.disconnect();
});
</script>
diff --git a/packages/frontend/src/components/MkPageWindow.vue b/packages/frontend/src/components/MkPageWindow.vue
index 02ce58451d..709b5a52df 100644
--- a/packages/frontend/src/components/MkPageWindow.vue
+++ b/packages/frontend/src/components/MkPageWindow.vue
@@ -1,23 +1,23 @@
<template>
<MkWindow
ref="windowEl"
- :initial-width="500"
- :initial-height="500"
- :can-resize="true"
- :close-button="true"
- :buttons-left="buttonsLeft"
- :buttons-right="buttonsRight"
+ :initialWidth="500"
+ :initialHeight="500"
+ :canResize="true"
+ :closeButton="true"
+ :buttonsLeft="buttonsLeft"
+ :buttonsRight="buttonsRight"
:contextmenu="contextmenu"
@closed="$emit('closed')"
>
<template #header>
<template v-if="pageMetadata?.value">
- <i v-if="pageMetadata.value.icon" class="icon" :class="pageMetadata.value.icon" style="margin-right: 0.5em;"></i>
+ <i v-if="pageMetadata.value.icon" :class="pageMetadata.value.icon" style="margin-right: 0.5em;"></i>
<span>{{ pageMetadata.value.title }}</span>
</template>
</template>
- <div :class="$style.root" :style="{ background: pageMetadata?.value?.bg }" style="container-type: inline-size;">
+ <div :class="$style.root" style="container-type: inline-size;">
<RouterView :key="reloadCount" :router="router"/>
</div>
</MkWindow>
diff --git a/packages/frontend/src/components/MkPagination.vue b/packages/frontend/src/components/MkPagination.vue
index cd8af560e4..740094b113 100644
--- a/packages/frontend/src/components/MkPagination.vue
+++ b/packages/frontend/src/components/MkPagination.vue
@@ -1,9 +1,9 @@
<template>
<Transition
- :enter-active-class="defaultStore.state.animation ? $style.transition_fade_enterActive : ''"
- :leave-active-class="defaultStore.state.animation ? $style.transition_fade_leaveActive : ''"
- :enter-from-class="defaultStore.state.animation ? $style.transition_fade_enterFrom : ''"
- :leave-to-class="defaultStore.state.animation ? $style.transition_fade_leaveTo : ''"
+ :enterActiveClass="defaultStore.state.animation ? $style.transition_fade_enterActive : ''"
+ :leaveActiveClass="defaultStore.state.animation ? $style.transition_fade_leaveActive : ''"
+ :enterFromClass="defaultStore.state.animation ? $style.transition_fade_enterFrom : ''"
+ :leaveToClass="defaultStore.state.animation ? $style.transition_fade_leaveTo : ''"
mode="out-in"
>
<MkLoading v-if="fetching"/>
diff --git a/packages/frontend/src/components/MkPoll.vue b/packages/frontend/src/components/MkPoll.vue
index 0810061ff9..464e340116 100644
--- a/packages/frontend/src/components/MkPoll.vue
+++ b/packages/frontend/src/components/MkPoll.vue
@@ -1,19 +1,19 @@
<template>
-<div class="tivcixzd" :class="{ done: closed || isVoted }">
- <ul>
- <li v-for="(choice, i) in note.poll.choices" :key="i" :class="{ voted: choice.voted }" @click="vote(i)">
- <div class="backdrop" :style="{ 'width': `${showResult ? (choice.votes / total * 100) : 0}%` }"></div>
- <span>
- <template v-if="choice.isVoted"><i class="ti ti-check"></i></template>
+<div :class="{ [$style.done]: closed || isVoted }">
+ <ul :class="$style.choices">
+ <li v-for="(choice, i) in note.poll.choices" :key="i" :class="$style.choice" @click="vote(i)">
+ <div :class="$style.bg" :style="{ 'width': `${showResult ? (choice.votes / total * 100) : 0}%` }"></div>
+ <span :class="$style.fg">
+ <template v-if="choice.isVoted"><i class="ti ti-check" style="margin-right: 4px; color: var(--accent);"></i></template>
<Mfm :text="choice.text" :plain="true"/>
- <span v-if="showResult" class="votes">({{ i18n.t('_poll.votesCount', { n: choice.votes }) }})</span>
+ <span v-if="showResult" style="margin-left: 4px; opacity: 0.7;">({{ i18n.t('_poll.votesCount', { n: choice.votes }) }})</span>
</span>
</li>
</ul>
- <p v-if="!readOnly">
+ <p v-if="!readOnly" :class="$style.info">
<span>{{ i18n.t('_poll.totalVotes', { n: total }) }}</span>
<span> · </span>
- <a v-if="!closed && !isVoted" @click="showResult = !showResult">{{ showResult ? i18n.ts._poll.vote : i18n.ts._poll.showResult }}</a>
+ <a v-if="!closed && !isVoted" style="color: inherit;" @click="showResult = !showResult">{{ showResult ? i18n.ts._poll.vote : i18n.ts._poll.showResult }}</a>
<span v-if="isVoted">{{ i18n.ts._poll.voted }}</span>
<span v-else-if="closed">{{ i18n.ts._poll.closed }}</span>
<span v-if="remaining > 0"> · {{ timer }}</span>
@@ -86,67 +86,51 @@ const vote = async (id) => {
};
</script>
-<style lang="scss" scoped>
-.tivcixzd {
- > ul {
- display: block;
- margin: 0;
- padding: 0;
- list-style: none;
-
- > li {
- display: block;
- position: relative;
- margin: 4px 0;
- padding: 4px;
- //border: solid 0.5px var(--divider);
- background: var(--accentedBg);
- border-radius: 4px;
- overflow: clip;
- cursor: pointer;
-
- > .backdrop {
- position: absolute;
- top: 0;
- left: 0;
- height: 100%;
- background: var(--accent);
- background: linear-gradient(90deg,var(--buttonGradateA),var(--buttonGradateB));
- transition: width 1s ease;
- }
-
- > span {
- position: relative;
- display: inline-block;
- padding: 3px 5px;
- background: var(--panel);
- border-radius: 3px;
+<style lang="scss" module>
+.choices {
+ display: block;
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
- > i {
- margin-right: 4px;
- color: var(--accent);
- }
+.choice {
+ display: block;
+ position: relative;
+ margin: 4px 0;
+ padding: 4px;
+ //border: solid 0.5px var(--divider);
+ background: var(--accentedBg);
+ border-radius: 4px;
+ overflow: clip;
+ cursor: pointer;
+}
- > .votes {
- margin-left: 4px;
- opacity: 0.7;
- }
- }
- }
- }
+.bg {
+ position: absolute;
+ top: 0;
+ left: 0;
+ height: 100%;
+ background: var(--accent);
+ background: linear-gradient(90deg,var(--buttonGradateA),var(--buttonGradateB));
+ transition: width 1s ease;
+}
- > p {
- color: var(--fg);
+.fg {
+ position: relative;
+ display: inline-block;
+ padding: 3px 5px;
+ background: var(--panel);
+ border-radius: 3px;
+}
- a {
- color: inherit;
- }
- }
+.info {
+ color: var(--fg);
+}
- &.done {
- > ul > li {
- cursor: default;
- }
+.done {
+ .choice {
+ cursor: default;
}
}
</style>
diff --git a/packages/frontend/src/components/MkPollEditor.vue b/packages/frontend/src/components/MkPollEditor.vue
index 471ec39169..2da9339944 100644
--- a/packages/frontend/src/components/MkPollEditor.vue
+++ b/packages/frontend/src/components/MkPollEditor.vue
@@ -5,7 +5,7 @@
</p>
<ul>
<li v-for="(choice, i) in choices" :key="i">
- <MkInput class="input" small :model-value="choice" :placeholder="i18n.t('_poll.choiceN', { n: i + 1 })" @update:model-value="onInput(i, $event)">
+ <MkInput class="input" small :modelValue="choice" :placeholder="i18n.t('_poll.choiceN', { n: i + 1 })" @update:modelValue="onInput(i, $event)">
</MkInput>
<button class="_button" @click="remove(i)">
<i class="ti ti-x"></i>
diff --git a/packages/frontend/src/components/MkPopupMenu.vue b/packages/frontend/src/components/MkPopupMenu.vue
index 93b9eb401d..30af365669 100644
--- a/packages/frontend/src/components/MkPopupMenu.vue
+++ b/packages/frontend/src/components/MkPopupMenu.vue
@@ -1,6 +1,6 @@
<template>
-<MkModal ref="modal" v-slot="{ type, maxHeight }" :z-priority="'high'" :src="src" :transparent-bg="true" @click="modal.close()" @close="emit('closing')" @closed="emit('closed')">
- <MkMenu :items="items" :align="align" :width="width" :max-height="maxHeight" :as-drawer="type === 'drawer'" :class="{ [$style.drawer]: type === 'drawer' }" @close="modal.close()"/>
+<MkModal ref="modal" v-slot="{ type, maxHeight }" :zPriority="'high'" :src="src" :transparentBg="true" @click="modal.close()" @close="emit('closing')" @closed="emit('closed')">
+ <MkMenu :items="items" :align="align" :width="width" :max-height="maxHeight" :asDrawer="type === 'drawer'" :class="{ [$style.drawer]: type === 'drawer' }" @close="modal.close()"/>
</MkModal>
</template>
diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue
index c65cb7d6e5..5c65569683 100644
--- a/packages/frontend/src/components/MkPostForm.vue
+++ b/packages/frontend/src/components/MkPostForm.vue
@@ -22,21 +22,21 @@
<span v-if="visibility === 'specified'"><i class="ti ti-mail"></i></span>
<span :class="$style.headerRightButtonText">{{ i18n.ts._visibility[visibility] }}</span>
</button>
- <button v-else :class="['_button', $style.headerRightItem, $style.visibility]" disabled>
+ <button v-else class="_button" :class="[$style.headerRightItem, $style.visibility]" disabled>
<span><i class="ti ti-device-tv"></i></span>
<span :class="$style.headerRightButtonText">{{ channel.name }}</span>
</button>
</template>
- <button v-click-anime v-tooltip="i18n.ts._visibility.disableFederation" :class="['_button', $style.headerRightItem, $style.localOnly, { [$style.danger]: localOnly }]" :disabled="channel != null || visibility === 'specified'" @click="toggleLocalOnly">
+ <button v-click-anime v-tooltip="i18n.ts._visibility.disableFederation" class="_button" :class="[$style.headerRightItem, { [$style.danger]: localOnly }]" :disabled="channel != null || visibility === 'specified'" @click="toggleLocalOnly">
<span v-if="!localOnly"><i class="ti ti-rocket"></i></span>
<span v-else><i class="ti ti-rocket-off"></i></span>
</button>
- <button v-click-anime v-tooltip="i18n.ts.reactionAcceptance" :class="['_button', $style.headerRightItem, $style.reactionAcceptance, { [$style.danger]: reactionAcceptance }]" @click="toggleReactionAcceptance">
+ <button v-click-anime v-tooltip="i18n.ts.reactionAcceptance" class="_button" :class="[$style.headerRightItem, { [$style.danger]: reactionAcceptance === 'likeOnly' }]" @click="toggleReactionAcceptance">
<span v-if="reactionAcceptance === 'likeOnly'"><i class="ti ti-heart"></i></span>
<span v-else-if="reactionAcceptance === 'likeOnlyForRemote'"><i class="ti ti-heart-plus"></i></span>
<span v-else><i class="ti ti-icons"></i></span>
</button>
- <button v-click-anime class="_button" :class="[$style.submit, { [$style.submitPosting]: posting }]" :disabled="!canPost" data-cy-open-post-form-submit @click="post">
+ <button v-click-anime class="_button" :class="$style.submit" :disabled="!canPost" data-cy-open-post-form-submit @click="post">
<div :class="$style.submitInner">
<template v-if="posted"></template>
<template v-else-if="posting"><MkEllipsis/></template>
@@ -66,7 +66,7 @@
<div v-if="maxTextLength - textLength < 100" :class="['_acrylic', $style.textCount, { [$style.textOver]: textLength > maxTextLength }]">{{ maxTextLength - textLength }}</div>
</div>
<input v-show="withHashtags" ref="hashtagsInputEl" v-model="hashtags" :class="$style.hashtags" :placeholder="i18n.ts.hashtags" list="hashtags">
- <XPostFormAttaches v-model="files" :class="$style.attaches" @detach="detachFile" @change-sensitive="updateFileSensitive" @change-name="updateFileName"/>
+ <XPostFormAttaches v-model="files" @detach="detachFile" @changeSensitive="updateFileSensitive" @changeName="updateFileName"/>
<MkPollEditor v-if="poll" v-model="poll" @destroyed="poll = null"/>
<MkNotePreview v-if="showPreview" :class="$style.preview" :text="text"/>
<div v-if="showingOptions" style="padding: 8px 16px;">
@@ -484,8 +484,10 @@ async function toggleReactionAcceptance() {
title: i18n.ts.reactionAcceptance,
items: [
{ value: null, text: i18n.ts.all },
- { value: 'likeOnly' as const, text: i18n.ts.likeOnly },
{ value: 'likeOnlyForRemote' as const, text: i18n.ts.likeOnlyForRemote },
+ { value: 'nonSensitiveOnly' as const, text: i18n.ts.nonSensitiveOnly },
+ { value: 'nonSensitiveOnlyForLocalLikeOnlyForRemote' as const, text: i18n.ts.nonSensitiveOnlyForLocalLikeOnlyForRemote },
+ { value: 'likeOnly' as const, text: i18n.ts.likeOnly },
],
default: reactionAcceptance,
});
diff --git a/packages/frontend/src/components/MkPostFormAttaches.vue b/packages/frontend/src/components/MkPostFormAttaches.vue
index 760c6e5d08..18fa142ebc 100644
--- a/packages/frontend/src/components/MkPostFormAttaches.vue
+++ b/packages/frontend/src/components/MkPostFormAttaches.vue
@@ -1,16 +1,16 @@
<template>
-<div v-show="props.modelValue.length != 0" class="skeikyzd">
- <Sortable :model-value="props.modelValue" class="files" item-key="id" :animation="150" :delay="100" :delay-on-touch-only="true" @update:model-value="v => emit('update:modelValue', v)">
+<div v-show="props.modelValue.length != 0" :class="$style.root">
+ <Sortable :modelValue="props.modelValue" :class="$style.files" itemKey="id" :animation="150" :delay="100" :delayOnTouchOnly="true" @update:modelValue="v => emit('update:modelValue', v)">
<template #item="{element}">
- <div class="file" @click="showFileMenu(element, $event)" @contextmenu.prevent="showFileMenu(element, $event)">
- <MkDriveFileThumbnail :data-id="element.id" class="thumbnail" :file="element" fit="cover"/>
- <div v-if="element.isSensitive" class="sensitive">
- <i class="ti ti-alert-triangle icon"></i>
+ <div :class="$style.file" @click="showFileMenu(element, $event)" @contextmenu.prevent="showFileMenu(element, $event)">
+ <MkDriveFileThumbnail :data-id="element.id" :class="$style.thumbnail" :file="element" fit="cover"/>
+ <div v-if="element.isSensitive" :class="$style.sensitive">
+ <i class="ti ti-alert-triangle" style="margin: auto;"></i>
</div>
</div>
</template>
</Sortable>
- <p class="remain">{{ 16 - props.modelValue.length }}/16</p>
+ <p :class="$style.remain">{{ 16 - props.modelValue.length }}/16</p>
</div>
</template>
@@ -93,7 +93,7 @@ function showFileMenu(file, ev: MouseEvent) {
action: () => { rename(file); },
}, {
text: file.isSensitive ? i18n.ts.unmarkAsSensitive : i18n.ts.markAsSensitive,
- icon: file.isSensitive ? 'ti ti-eye-off' : 'ti ti-eye',
+ icon: file.isSensitive ? 'ti ti-eye-exclamation' : 'ti ti-eye',
action: () => { toggleSensitive(file); },
}, {
text: i18n.ts.describeFile,
@@ -108,60 +108,53 @@ function showFileMenu(file, ev: MouseEvent) {
}
</script>
-<style lang="scss" scoped>
-.skeikyzd {
+<style lang="scss" module>
+.root {
padding: 8px 16px;
position: relative;
+}
- > .files {
- display: flex;
- flex-wrap: wrap;
-
- > .file {
- position: relative;
- width: 64px;
- height: 64px;
- margin-right: 4px;
- border-radius: 4px;
- overflow: hidden;
- cursor: move;
-
- &:hover > .remove {
- display: block;
- }
+.files {
+ display: flex;
+ flex-wrap: wrap;
+}
- > .thumbnail {
- width: 100%;
- height: 100%;
- z-index: 1;
- color: var(--fg);
- }
+.file {
+ position: relative;
+ width: 64px;
+ height: 64px;
+ margin-right: 4px;
+ border-radius: 4px;
+ overflow: hidden;
+ cursor: move;
+}
- > .sensitive {
- display: flex;
- position: absolute;
- width: 64px;
- height: 64px;
- top: 0;
- left: 0;
- z-index: 2;
- background: rgba(17, 17, 17, .7);
- color: #fff;
+.thumbnail {
+ width: 100%;
+ height: 100%;
+ z-index: 1;
+ color: var(--fg);
+}
- > .icon {
- margin: auto;
- }
- }
- }
- }
+.sensitive {
+ display: flex;
+ position: absolute;
+ width: 64px;
+ height: 64px;
+ top: 0;
+ left: 0;
+ z-index: 2;
+ background: rgba(17, 17, 17, .7);
+ color: #fff;
+}
- > .remain {
- display: block;
- position: absolute;
- top: 8px;
- right: 8px;
- margin: 0;
- padding: 0;
- }
+.remain {
+ display: block;
+ position: absolute;
+ top: 8px;
+ right: 8px;
+ margin: 0;
+ padding: 0;
+ font-size: 90%;
}
</style>
diff --git a/packages/frontend/src/components/MkPostFormDialog.vue b/packages/frontend/src/components/MkPostFormDialog.vue
index 6326c498d7..98af92c6f8 100644
--- a/packages/frontend/src/components/MkPostFormDialog.vue
+++ b/packages/frontend/src/components/MkPostFormDialog.vue
@@ -1,6 +1,6 @@
<template>
-<MkModal ref="modal" :prefer-type="'dialog'" @click="modal.close()" @closed="onModalClosed()">
- <MkPostForm ref="form" style="margin: 0 auto auto auto;" v-bind="props" autofocus freeze-after-posted @posted="onPosted" @cancel="modal.close()" @esc="modal.close()"/>
+<MkModal ref="modal" :preferType="'dialog'" @click="modal.close()" @closed="onModalClosed()">
+ <MkPostForm ref="form" style="margin: 0 auto auto auto;" v-bind="props" autofocus freezeAfterPosted @posted="onPosted" @cancel="modal.close()" @esc="modal.close()"/>
</MkModal>
</template>
diff --git a/packages/frontend/src/components/MkPushNotificationAllowButton.vue b/packages/frontend/src/components/MkPushNotificationAllowButton.vue
index b98c814f24..448084d9ba 100644
--- a/packages/frontend/src/components/MkPushNotificationAllowButton.vue
+++ b/packages/frontend/src/components/MkPushNotificationAllowButton.vue
@@ -72,28 +72,28 @@ function subscribe() {
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(instance.swPublickey),
})
- .then(async subscription => {
- pushSubscription = subscription;
+ .then(async subscription => {
+ pushSubscription = subscription;
- // Register
- pushRegistrationInServer = await api('sw/register', {
- endpoint: subscription.endpoint,
- auth: encode(subscription.getKey('auth')),
- publickey: encode(subscription.getKey('p256dh')),
- });
- }, async err => { // When subscribe failed
+ // Register
+ pushRegistrationInServer = await api('sw/register', {
+ endpoint: subscription.endpoint,
+ auth: encode(subscription.getKey('auth')),
+ publickey: encode(subscription.getKey('p256dh')),
+ });
+ }, async err => { // When subscribe failed
// 通知が許可されていなかったとき
- if (err?.name === 'NotAllowedError') {
- console.info('User denied the notification permission request.');
- return;
- }
+ if (err?.name === 'NotAllowedError') {
+ console.info('User denied the notification permission request.');
+ return;
+ }
- // 違うapplicationServerKey (または gcm_sender_id)のサブスクリプションが
- // 既に存在していることが原因でエラーになった可能性があるので、
- // そのサブスクリプションを解除しておく
- // (これは実行されなさそうだけど、おまじない的に古い実装から残してある)
- await unsubscribe();
- }), null, null);
+ // 違うapplicationServerKey (または gcm_sender_id)のサブスクリプションが
+ // 既に存在していることが原因でエラーになった可能性があるので、
+ // そのサブスクリプションを解除しておく
+ // (これは実行されなさそうだけど、おまじない的に古い実装から残してある)
+ await unsubscribe();
+ }), null, null);
}
async function unsubscribe() {
diff --git a/packages/frontend/src/components/MkRadios.vue b/packages/frontend/src/components/MkRadios.vue
index e2240fb4e1..84be10078a 100644
--- a/packages/frontend/src/components/MkRadios.vue
+++ b/packages/frontend/src/components/MkRadios.vue
@@ -1,37 +1,27 @@
<script lang="ts">
-import { VNode, defineComponent, h } from 'vue';
+import { VNode, defineComponent, h, ref, watch } from 'vue';
import MkRadio from './MkRadio.vue';
export default defineComponent({
- components: {
- MkRadio,
- },
props: {
modelValue: {
required: false,
},
},
- data() {
- return {
- value: this.modelValue,
- };
- },
- watch: {
- value() {
- this.$emit('update:modelValue', this.value);
- },
- },
- 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();
+ setup(props, context) {
+ const value = ref(props.modelValue);
+ watch(value, () => {
+ context.emit('update:modelValue', value.value);
+ });
+ if (!context.slots.default) return null;
+ let options = context.slots.default();
+ const label = context.slots.label && context.slots.label();
+ const caption = context.slots.caption && context.slots.caption();
// なぜかFragmentになることがあるため
if (options.length === 1 && options[0].props == null) options = options[0].children as VNode[];
- return h('div', {
+ return () => h('div', {
class: 'novjtcto',
}, [
...(label ? [h('div', {
@@ -42,8 +32,8 @@ export default defineComponent({
}, options.map(option => h(MkRadio, {
key: option.key,
value: option.props?.value,
- modelValue: this.value,
- 'onUpdate:modelValue': value => this.value = value,
+ modelValue: value.value,
+ 'onUpdate:modelValue': _v => value.value = _v,
}, () => option.children)),
),
...(caption ? [h('div', {
diff --git a/packages/frontend/src/components/MkReactedUsersDialog.vue b/packages/frontend/src/components/MkReactedUsersDialog.vue
index 0c0cc36692..cd2a359d5c 100644
--- a/packages/frontend/src/components/MkReactedUsersDialog.vue
+++ b/packages/frontend/src/components/MkReactedUsersDialog.vue
@@ -8,7 +8,7 @@
>
<template #header>{{ i18n.ts.reactionsList }}</template>
- <MkSpacer :margin-min="20" :margin-max="28">
+ <MkSpacer :marginMin="20" :marginMax="28">
<div v-if="note" class="_gaps">
<div v-if="reactions.length === 0" class="_fullinfo">
<img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/>
@@ -22,7 +22,7 @@
</button>
</div>
<MkA v-for="user in users" :key="user.id" :to="userPage(user)" @click="dialog.close()">
- <MkUserCardMini :user="user" :with-chart="false"/>
+ <MkUserCardMini :user="user" :withChart="false"/>
</MkA>
</template>
</div>
diff --git a/packages/frontend/src/components/MkReactionIcon.vue b/packages/frontend/src/components/MkReactionIcon.vue
index 29b3f9b85b..dfb06f63c4 100644
--- a/packages/frontend/src/components/MkReactionIcon.vue
+++ b/packages/frontend/src/components/MkReactionIcon.vue
@@ -1,6 +1,6 @@
<template>
-<MkCustomEmoji v-if="reaction[0] === ':'" :name="reaction" :normal="true" :no-style="noStyle" :url="emojiUrl"/>
-<MkEmoji v-else :emoji="reaction" :normal="true" :no-style="noStyle"/>
+<MkCustomEmoji v-if="reaction[0] === ':'" :name="reaction" :normal="true" :noStyle="noStyle" :url="emojiUrl"/>
+<MkEmoji v-else :emoji="reaction" :normal="true" :noStyle="noStyle"/>
</template>
<script lang="ts" setup>
diff --git a/packages/frontend/src/components/MkReactionTooltip.vue b/packages/frontend/src/components/MkReactionTooltip.vue
index 4d67dc3da2..34afa72232 100644
--- a/packages/frontend/src/components/MkReactionTooltip.vue
+++ b/packages/frontend/src/components/MkReactionTooltip.vue
@@ -1,7 +1,7 @@
<template>
-<MkTooltip ref="tooltip" :showing="showing" :target-element="targetElement" :max-width="340" @closed="emit('closed')">
+<MkTooltip ref="tooltip" :showing="showing" :targetElement="targetElement" :maxWidth="340" @closed="emit('closed')">
<div :class="$style.root">
- <MkReactionIcon :reaction="reaction" :class="$style.icon" :no-style="true"/>
+ <MkReactionIcon :reaction="reaction" :class="$style.icon" :noStyle="true"/>
<div :class="$style.name">{{ reaction.replace('@.', '') }}</div>
</div>
</MkTooltip>
diff --git a/packages/frontend/src/components/MkReactionsViewer.details.vue b/packages/frontend/src/components/MkReactionsViewer.details.vue
index f5e611c62a..99960f5d25 100644
--- a/packages/frontend/src/components/MkReactionsViewer.details.vue
+++ b/packages/frontend/src/components/MkReactionsViewer.details.vue
@@ -1,8 +1,8 @@
<template>
-<MkTooltip ref="tooltip" :showing="showing" :target-element="targetElement" :max-width="340" @closed="emit('closed')">
+<MkTooltip ref="tooltip" :showing="showing" :targetElement="targetElement" :maxWidth="340" @closed="emit('closed')">
<div :class="$style.root">
<div :class="$style.reaction">
- <MkReactionIcon :reaction="reaction" :class="$style.reactionIcon" :no-style="true"/>
+ <MkReactionIcon :reaction="reaction" :class="$style.reactionIcon" :noStyle="true"/>
<div :class="$style.reactionName">{{ getReactionName(reaction) }}</div>
</div>
<div :class="$style.users">
diff --git a/packages/frontend/src/components/MkReactionsViewer.reaction.vue b/packages/frontend/src/components/MkReactionsViewer.reaction.vue
index 9480af5102..aabebb3abf 100644
--- a/packages/frontend/src/components/MkReactionsViewer.reaction.vue
+++ b/packages/frontend/src/components/MkReactionsViewer.reaction.vue
@@ -6,7 +6,7 @@
:class="[$style.root, { [$style.reacted]: note.myReaction == reaction, [$style.canToggle]: canToggle, [$style.large]: defaultStore.state.largeNoteReactions }]"
@click="toggleReaction()"
>
- <MkReactionIcon :class="$style.icon" :reaction="reaction" :emoji-url="note.reactionEmojis[reaction.substr(1, reaction.length - 2)]"/>
+ <MkReactionIcon :class="$style.icon" :reaction="reaction" :emojiUrl="note.reactionEmojis[reaction.substr(1, reaction.length - 2)]"/>
<span :class="$style.count">{{ count }}</span>
</button>
</template>
@@ -22,6 +22,7 @@ import { $i } from '@/account';
import MkReactionEffect from '@/components/MkReactionEffect.vue';
import { claimAchievement } from '@/scripts/achievements';
import { defaultStore } from '@/store';
+import { i18n } from '@/i18n';
const props = defineProps<{
reaction: string;
@@ -34,11 +35,19 @@ const buttonEl = shallowRef<HTMLElement>();
const canToggle = computed(() => !props.reaction.match(/@\w/) && $i);
-const toggleReaction = () => {
+async function toggleReaction() {
if (!canToggle.value) return;
+ // TODO: その絵文字を使う権限があるかどうか確認
+
const oldReaction = props.note.myReaction;
if (oldReaction) {
+ const confirm = await os.confirm({
+ type: 'warning',
+ text: oldReaction !== props.reaction ? i18n.ts.changeReactionConfirm : i18n.ts.cancelReactionConfirm,
+ });
+ if (confirm.canceled) return;
+
os.api('notes/reactions/delete', {
noteId: props.note.id,
}).then(() => {
@@ -58,9 +67,9 @@ const toggleReaction = () => {
claimAchievement('reactWithoutRead');
}
}
-};
+}
-const anime = () => {
+function anime() {
if (document.hidden) return;
if (!defaultStore.state.animation) return;
@@ -68,7 +77,7 @@ const anime = () => {
const x = rect.left + 16;
const y = rect.top + (buttonEl.value.offsetHeight / 2);
os.popup(MkReactionEffect, { reaction: props.reaction, x, y }, {}, 'end');
-};
+}
watch(() => props.count, (newCount, oldCount) => {
if (oldCount < newCount) anime();
diff --git a/packages/frontend/src/components/MkReactionsViewer.vue b/packages/frontend/src/components/MkReactionsViewer.vue
index 3219c8a92c..ce146463ec 100644
--- a/packages/frontend/src/components/MkReactionsViewer.vue
+++ b/packages/frontend/src/components/MkReactionsViewer.vue
@@ -1,13 +1,13 @@
<template>
<TransitionGroup
- :enter-active-class="defaultStore.state.animation ? $style.transition_x_enterActive : ''"
- :leave-active-class="defaultStore.state.animation ? $style.transition_x_leaveActive : ''"
- :enter-from-class="defaultStore.state.animation ? $style.transition_x_enterFrom : ''"
- :leave-to-class="defaultStore.state.animation ? $style.transition_x_leaveTo : ''"
- :move-class="defaultStore.state.animation ? $style.transition_x_move : ''"
+ :enterActiveClass="defaultStore.state.animation ? $style.transition_x_enterActive : ''"
+ :leaveActiveClass="defaultStore.state.animation ? $style.transition_x_leaveActive : ''"
+ :enterFromClass="defaultStore.state.animation ? $style.transition_x_enterFrom : ''"
+ :leaveToClass="defaultStore.state.animation ? $style.transition_x_leaveTo : ''"
+ :moveClass="defaultStore.state.animation ? $style.transition_x_move : ''"
tag="div" :class="$style.root"
>
- <XReaction v-for="[reaction, count] in reactions" :key="reaction" :reaction="reaction" :count="count" :is-initial="initialReactions.has(reaction)" :note="note"/>
+ <XReaction v-for="[reaction, count] in reactions" :key="reaction" :reaction="reaction" :count="count" :isInitial="initialReactions.has(reaction)" :note="note"/>
<slot v-if="hasMoreReactions" name="more"/>
</TransitionGroup>
</template>
diff --git a/packages/frontend/src/components/MkRenotedUsersDialog.vue b/packages/frontend/src/components/MkRenotedUsersDialog.vue
index 56025535f1..814a68d4da 100644
--- a/packages/frontend/src/components/MkRenotedUsersDialog.vue
+++ b/packages/frontend/src/components/MkRenotedUsersDialog.vue
@@ -8,7 +8,7 @@
>
<template #header>{{ i18n.ts.renotesList }}</template>
- <MkSpacer :margin-min="20" :margin-max="28">
+ <MkSpacer :marginMin="20" :marginMax="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"/>
@@ -16,7 +16,7 @@
</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"/>
+ <MkUserCardMini :user="user" :withChart="false"/>
</MkA>
</template>
</div>
diff --git a/packages/frontend/src/components/MkRetentionLineChart.vue b/packages/frontend/src/components/MkRetentionLineChart.vue
index 8bd0279806..9f56189f3e 100644
--- a/packages/frontend/src/components/MkRetentionLineChart.vue
+++ b/packages/frontend/src/components/MkRetentionLineChart.vue
@@ -124,7 +124,3 @@ onMounted(async () => {
});
});
</script>
-
-<style lang="scss" scoped>
-
-</style>
diff --git a/packages/frontend/src/components/MkRippleEffect.vue b/packages/frontend/src/components/MkRippleEffect.vue
index 9d93211d5f..60c3a47385 100644
--- a/packages/frontend/src/components/MkRippleEffect.vue
+++ b/packages/frontend/src/components/MkRippleEffect.vue
@@ -1,7 +1,7 @@
<template>
-<div class="vswabwbm" :style="{ zIndex, top: `${y - 64}px`, left: `${x - 64}px` }">
+<div :class="$style.root" :style="{ zIndex, top: `${y - 64}px`, left: `${x - 64}px` }">
<svg width="128" height="128" viewBox="0 0 128 128" xmlns="http://www.w3.org/2000/svg">
- <circle fill="none" cx="64" cy="64">
+ <circle fill="none" cx="64" cy="64" style="stroke: var(--accent);">
<animate
attributeName="r"
begin="0s" dur="0.5s"
@@ -22,7 +22,7 @@
/>
</circle>
<g fill="none" fill-rule="evenodd">
- <circle v-for="(particle, i) in particles" :key="i" :fill="particle.color">
+ <circle v-for="(particle, i) in particles" :key="i" :fill="particle.color" style="stroke: var(--accent);">
<animate
attributeName="r"
begin="0s" dur="0.8s"
@@ -100,17 +100,11 @@ onMounted(() => {
});
</script>
-<style lang="scss" scoped>
-.vswabwbm {
+<style lang="scss" module>
+.root {
pointer-events: none;
position: fixed;
width: 128px;
height: 128px;
-
- > svg {
- > circle {
- stroke: var(--accent);
- }
- }
}
</style>
diff --git a/packages/frontend/src/components/MkRolePreview.vue b/packages/frontend/src/components/MkRolePreview.vue
index 2f5866f340..9fbe1ec993 100644
--- a/packages/frontend/src/components/MkRolePreview.vue
+++ b/packages/frontend/src/components/MkRolePreview.vue
@@ -12,8 +12,10 @@
</template>
</span>
<span :class="$style.name">{{ role.name }}</span>
- <span v-if="role.target === 'manual'" :class="$style.users">{{ role.usersCount }} users</span>
- <span v-else-if="role.target === 'conditional'" :class="$style.users">({{ i18n.ts._role.conditional }})</span>
+ <template v-if="detailed">
+ <span v-if="role.target === 'manual'" :class="$style.users">{{ role.usersCount }} users</span>
+ <span v-else-if="role.target === 'conditional'" :class="$style.users">({{ i18n.ts._role.conditional }})</span>
+ </template>
</div>
<div :class="$style.description">{{ role.description }}</div>
</MkA>
@@ -23,10 +25,13 @@
import { } from 'vue';
import { i18n } from '@/i18n';
-const props = defineProps<{
+const props = withDefaults(defineProps<{
role: any;
forModeration: boolean;
-}>();
+ detailed: boolean;
+}>(), {
+ detailed: true,
+});
</script>
<style lang="scss" module>
diff --git a/packages/frontend/src/components/MkSample.vue b/packages/frontend/src/components/MkSample.vue
deleted file mode 100644
index 922b862b47..0000000000
--- a/packages/frontend/src/components/MkSample.vue
+++ /dev/null
@@ -1,118 +0,0 @@
-<template>
-<div class="">
- <div class="">
- <MkInput v-model="text">
- <template #label>Text</template>
- </MkInput>
- <MkSwitch v-model="flag">
- <span>Switch is now {{ flag ? 'on' : 'off' }}</span>
- </MkSwitch>
- <div style="margin: 32px 0;">
- <MkRadio v-model="radio" value="misskey">Misskey</MkRadio>
- <MkRadio v-model="radio" value="mastodon">Mastodon</MkRadio>
- <MkRadio v-model="radio" value="pleroma">Pleroma</MkRadio>
- </div>
- <MkButton inline>This is</MkButton>
- <MkButton inline primary>the button</MkButton>
- </div>
- <div class="" style="pointer-events: none;">
- <Mfm :text="mfm"/>
- </div>
- <div class="">
- <MkButton inline primary @click="openMenu">Open menu</MkButton>
- <MkButton inline primary @click="openDialog">Open dialog</MkButton>
- <MkButton inline primary @click="openForm">Open form</MkButton>
- <MkButton inline primary @click="openDrive">Open drive</MkButton>
- </div>
-</div>
-</template>
-
-<script lang="ts">
-import { defineComponent } from 'vue';
-import MkButton from '@/components/MkButton.vue';
-import MkInput from '@/components/MkInput.vue';
-import MkSwitch from '@/components/MkSwitch.vue';
-import MkTextarea from '@/components/MkTextarea.vue';
-import MkRadio from '@/components/MkRadio.vue';
-import * as os from '@/os';
-import * as config from '@/config';
-import { $i } from '@/account';
-
-export default defineComponent({
- components: {
- MkButton,
- MkInput,
- MkSwitch,
- MkTextarea,
- MkRadio,
- },
-
- data() {
- return {
- text: '',
- flag: true,
- radio: 'misskey',
- $i,
- mfm: `Hello world! This is an @example mention. BTW you are @${this.$i ? this.$i.username : 'guest'}.\nAlso, here is ${config.url} and [example link](${config.url}). for more details, see https://example.com.\nAs you know #misskey is open-source software.`,
- };
- },
-
- methods: {
- async openDialog() {
- os.alert({
- type: 'warning',
- title: 'Oh my Aichan',
- text: 'Lorem ipsum dolor sit amet, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.',
- });
- },
-
- async openForm() {
- os.form('Example form', {
- foo: {
- type: 'boolean',
- default: true,
- label: 'This is a boolean property',
- },
- bar: {
- type: 'number',
- default: 300,
- label: 'This is a number property',
- },
- baz: {
- type: 'string',
- default: 'Misskey makes you happy.',
- label: 'This is a string property',
- },
- });
- },
-
- async openDrive() {
- os.selectDriveFile(false);
- },
-
- async selectUser() {
- os.selectUser();
- },
-
- async openMenu(ev) {
- os.popupMenu([{
- type: 'label',
- text: 'Fruits',
- }, {
- text: 'Create some apples',
- action: () => {},
- }, {
- text: 'Read some oranges',
- action: () => {},
- }, {
- text: 'Update some melons',
- action: () => {},
- }, null, {
- text: 'Delete some bananas',
- danger: true,
- action: () => {},
- }], ev.currentTarget ?? ev.target);
- },
- },
-});
-</script>
diff --git a/packages/frontend/src/components/MkSignin.vue b/packages/frontend/src/components/MkSignin.vue
index ffc5e82b56..b1a509b9e6 100644
--- a/packages/frontend/src/components/MkSignin.vue
+++ b/packages/frontend/src/components/MkSignin.vue
@@ -1,16 +1,16 @@
<template>
-<form class="eppvobhk" :class="{ signing, totpLogin }" @submit.prevent="onSubmit">
- <div class="auth _gaps_m">
- <div v-show="withAvatar" class="avatar" :style="{ backgroundImage: user ? `url('${ user.avatarUrl }')` : null, marginBottom: message ? '1.5em' : null }"></div>
+<form :class="{ signing, totpLogin }" @submit.prevent="onSubmit">
+ <div class="_gaps_m">
+ <div v-show="withAvatar" :class="$style.avatar" :style="{ backgroundImage: user ? `url('${ user.avatarUrl }')` : null, marginBottom: message ? '1.5em' : null }"></div>
<MkInfo v-if="message">
{{ message }}
</MkInfo>
<div v-if="!totpLogin" class="normal-signin _gaps_m">
- <MkInput v-model="username" :placeholder="i18n.ts.username" type="text" pattern="^[a-zA-Z0-9_]+$" :spellcheck="false" autocomplete="username" autofocus required data-cy-signin-username @update:model-value="onUsernameChange">
+ <MkInput v-model="username" :placeholder="i18n.ts.username" type="text" pattern="^[a-zA-Z0-9_]+$" :spellcheck="false" autocomplete="username" autofocus required data-cy-signin-username @update:modelValue="onUsernameChange">
<template #prefix>@</template>
<template #suffix>@{{ host }}</template>
</MkInput>
- <MkInput v-if="!user || user && !user.usePasswordLessLogin" v-model="password" :placeholder="i18n.ts.password" type="password" autocomplete="current-password" :with-password-toggle="true" required data-cy-signin-password>
+ <MkInput v-if="!user || user && !user.usePasswordLessLogin" v-model="password" :placeholder="i18n.ts.password" type="password" autocomplete="current-password" :withPasswordToggle="true" required data-cy-signin-password>
<template #prefix><i class="ti ti-lock"></i></template>
<template #caption><button class="_textButton" type="button" @click="resetPassword">{{ i18n.ts.forgotPassword }}</button></template>
</MkInput>
@@ -28,7 +28,7 @@
</div>
<div class="twofa-group totp-group">
<p style="margin-bottom:0;">{{ i18n.ts.twoStepAuthentication }}</p>
- <MkInput v-if="user && user.usePasswordLessLogin" v-model="password" type="password" autocomplete="current-password" :with-password-toggle="true" required>
+ <MkInput v-if="user && user.usePasswordLessLogin" v-model="password" type="password" autocomplete="current-password" :withPasswordToggle="true" required>
<template #label>{{ i18n.ts.password }}</template>
<template #prefix><i class="ti ti-lock"></i></template>
</MkInput>
@@ -236,18 +236,14 @@ function resetPassword() {
}
</script>
-<style lang="scss" scoped>
-.eppvobhk {
- > .auth {
- > .avatar {
- margin: 0 auto 0 auto;
- width: 64px;
- height: 64px;
- background: #ddd;
- background-position: center;
- background-size: cover;
- border-radius: 100%;
- }
- }
+<style lang="scss" module>
+.avatar {
+ margin: 0 auto 0 auto;
+ width: 64px;
+ height: 64px;
+ background: #ddd;
+ background-position: center;
+ background-size: cover;
+ border-radius: 100%;
}
</style>
diff --git a/packages/frontend/src/components/MkSigninDialog.vue b/packages/frontend/src/components/MkSigninDialog.vue
index 08e41d6ae5..eb5876e584 100644
--- a/packages/frontend/src/components/MkSigninDialog.vue
+++ b/packages/frontend/src/components/MkSigninDialog.vue
@@ -8,8 +8,8 @@
>
<template #header>{{ i18n.ts.login }}</template>
- <MkSpacer :margin-min="20" :margin-max="28">
- <MkSignin :auto-set="autoSet" :message="message" @login="onLogin"/>
+ <MkSpacer :marginMin="20" :marginMax="28">
+ <MkSignin :autoSet="autoSet" :message="message" @login="onLogin"/>
</MkSpacer>
</MkModalWindow>
</template>
diff --git a/packages/frontend/src/components/MkSignupDialog.form.vue b/packages/frontend/src/components/MkSignupDialog.form.vue
index 0e8bdb321e..472269abaf 100644
--- a/packages/frontend/src/components/MkSignupDialog.form.vue
+++ b/packages/frontend/src/components/MkSignupDialog.form.vue
@@ -3,13 +3,13 @@
<div :class="$style.banner">
<i class="ti ti-user-edit"></i>
</div>
- <MkSpacer :margin-min="20" :margin-max="32">
+ <MkSpacer :marginMin="20" :marginMax="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">
+ <MkInput v-model="username" type="text" pattern="^[a-zA-Z0-9_]{1,20}$" :spellcheck="false" autocomplete="username" required data-cy-signup-username @update:modelValue="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>
@@ -24,7 +24,7 @@
<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">
+ <MkInput v-if="instance.emailRequiredForSignup" v-model="email" :debounce="true" type="email" :spellcheck="false" required data-cy-signup-email @update:modelValue="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>
@@ -39,7 +39,7 @@
<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">
+ <MkInput v-model="password" type="password" autocomplete="new-password" required data-cy-signup-password @update:modelValue="onChangePassword">
<template #label>{{ i18n.ts.password }}</template>
<template #prefix><i class="ti ti-lock"></i></template>
<template #caption>
@@ -48,7 +48,7 @@
<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">
+ <MkInput v-model="retypedPassword" type="password" autocomplete="new-password" required data-cy-signup-password-retype @update:modelValue="onChangePasswordRetype">
<template #label>{{ i18n.ts.password }} ({{ i18n.ts.retype }})</template>
<template #prefix><i class="ti ti-lock"></i></template>
<template #caption>
diff --git a/packages/frontend/src/components/MkSignupDialog.rules.vue b/packages/frontend/src/components/MkSignupDialog.rules.vue
index 6da81c3bcb..b6ffba6cc7 100644
--- a/packages/frontend/src/components/MkSignupDialog.rules.vue
+++ b/packages/frontend/src/components/MkSignupDialog.rules.vue
@@ -3,7 +3,7 @@
<div :class="$style.banner">
<i class="ti ti-checklist"></i>
</div>
- <MkSpacer :margin-min="20" :margin-max="28">
+ <MkSpacer :marginMin="20" :marginMax="28">
<div class="_gaps_m">
<div v-if="instance.disableRegistration">
<MkInfo warn>{{ i18n.ts.invitationRequiredToRegister }}</MkInfo>
@@ -11,7 +11,7 @@
<div style="text-align: center;">{{ i18n.ts.pleaseConfirmBelowBeforeSignup }}</div>
- <MkFolder v-if="availableServerRules" :default-open="true">
+ <MkFolder v-if="availableServerRules" :defaultOpen="true">
<template #label>{{ i18n.ts.serverRules }}</template>
<template #suffix><i v-if="agreeServerRules" class="ti ti-check" style="color: var(--success)"></i></template>
@@ -22,7 +22,7 @@
<MkSwitch v-model="agreeServerRules" style="margin-top: 16px;">{{ i18n.ts.agree }}</MkSwitch>
</MkFolder>
- <MkFolder v-if="availableTos" :default-open="true">
+ <MkFolder v-if="availableTos" :defaultOpen="true">
<template #label>{{ i18n.ts.termsOfService }}</template>
<template #suffix><i v-if="agreeTos" class="ti ti-check" style="color: var(--success)"></i></template>
@@ -31,7 +31,7 @@
<MkSwitch v-model="agreeTos" style="margin-top: 16px;">{{ i18n.ts.agree }}</MkSwitch>
</MkFolder>
- <MkFolder :default-open="true">
+ <MkFolder :defaultOpen="true">
<template #label>{{ i18n.ts.basicNotesBeforeCreateAccount }}</template>
<template #suffix><i v-if="agreeNote" class="ti ti-check" style="color: var(--success)"></i></template>
diff --git a/packages/frontend/src/components/MkSignupDialog.vue b/packages/frontend/src/components/MkSignupDialog.vue
index 17f8b86425..d8d002fdb6 100644
--- a/packages/frontend/src/components/MkSignupDialog.vue
+++ b/packages/frontend/src/components/MkSignupDialog.vue
@@ -11,16 +11,16 @@
<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"
+ :enterActiveClass="$style.transition_x_enterActive"
+ :leaveActiveClass="$style.transition_x_leaveActive"
+ :enterFromClass="$style.transition_x_enterFrom"
+ :leaveToClass="$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"/>
+ <XSignup :autoSet="autoSet" @signup="onSignup" @signupEmailPending="onSignupEmailPending"/>
</template>
</Transition>
</div>
diff --git a/packages/frontend/src/components/MkSubNoteContent.vue b/packages/frontend/src/components/MkSubNoteContent.vue
index 1ac7107aa7..3a050889c8 100644
--- a/packages/frontend/src/components/MkSubNoteContent.vue
+++ b/packages/frontend/src/components/MkSubNoteContent.vue
@@ -1,15 +1,15 @@
<template>
<div :class="[$style.root, { [$style.collapsed]: collapsed }]">
- <div :class="$style.body">
+ <div>
<span v-if="note.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span>
<span v-if="note.deletedAt" style="opacity: 0.5">({{ i18n.ts.deleted }})</span>
<MkA v-if="note.replyId" :class="$style.reply" :to="`/notes/${note.replyId}`"><i class="ti ti-arrow-back-up"></i></MkA>
- <Mfm v-if="note.text" :text="note.text" :author="note.user" :i="$i" :emoji-urls="note.emojis"/>
+ <Mfm v-if="note.text" :text="note.text" :author="note.user" :i="$i" :emojiUrls="note.emojis"/>
<MkA v-if="note.renoteId" :class="$style.rp" :to="`/notes/${note.renoteId}`">RN: ...</MkA>
</div>
<details v-if="note.files.length > 0">
<summary>({{ i18n.t('withNFiles', { n: note.files.length }) }})</summary>
- <MkMediaList :media-list="note.files"/>
+ <MkMediaList :mediaList="note.files"/>
</details>
<details v-if="note.poll">
<summary>{{ i18n.ts.poll }}</summary>
@@ -76,10 +76,6 @@ const collapsed = $ref(
}
}
-.body {
-
-}
-
.reply {
margin-right: 6px;
color: var(--accent);
diff --git a/packages/frontend/src/components/MkSuperMenu.vue b/packages/frontend/src/components/MkSuperMenu.vue
index 2a8e43c570..72b70416d9 100644
--- a/packages/frontend/src/components/MkSuperMenu.vue
+++ b/packages/frontend/src/components/MkSuperMenu.vue
@@ -23,22 +23,13 @@
</div>
</template>
-<script lang="ts">
-import { defineComponent } from 'vue';
+<script lang="ts" setup>
+import { } from 'vue';
-export default defineComponent({
- props: {
- def: {
- type: Array,
- required: true,
- },
- grid: {
- type: Boolean,
- required: false,
- default: false,
- },
- },
-});
+defineProps<{
+ def: any[];
+ grid?: boolean;
+}>();
</script>
<style lang="scss" scoped>
diff --git a/packages/frontend/src/components/MkTab.vue b/packages/frontend/src/components/MkTab.vue
index 6f819bbbd7..7274f9b310 100644
--- a/packages/frontend/src/components/MkTab.vue
+++ b/packages/frontend/src/components/MkTab.vue
@@ -7,17 +7,17 @@ export default defineComponent({
required: true,
},
},
- render() {
- const options = this.$slots.default();
+ setup(props, { emit, slots }) {
+ const options = slots.default();
- return h('div', {
+ return () => h('div', {
class: 'pxhvhrfw',
}, options.map(option => withDirectives(h('button', {
- class: ['_button', { active: this.modelValue === option.props.value }],
+ class: ['_button', { active: props.modelValue === option.props.value }],
key: option.key,
- disabled: this.modelValue === option.props.value,
+ disabled: props.modelValue === option.props.value,
onClick: () => {
- this.$emit('update:modelValue', option.props.value);
+ emit('update:modelValue', option.props.value);
},
}, option.children), [
[resolveDirective('click-anime')],
diff --git a/packages/frontend/src/components/MkTagCloud.vue b/packages/frontend/src/components/MkTagCloud.vue
index 4e8d5bab7f..6e4e054aad 100644
--- a/packages/frontend/src/components/MkTagCloud.vue
+++ b/packages/frontend/src/components/MkTagCloud.vue
@@ -1,7 +1,7 @@
<template>
-<div ref="rootEl" class="meijqfqm">
- <canvas :id="idForCanvas" ref="canvasEl" class="canvas" :width="width" height="300" @contextmenu.prevent="() => {}"></canvas>
- <div :id="idForTags" ref="tagsEl" class="tags">
+<div ref="rootEl" :class="$style.root">
+ <canvas :id="idForCanvas" ref="canvasEl" style="display: block;" :width="width" height="300" @contextmenu.prevent="() => {}"></canvas>
+ <div :id="idForTags" ref="tagsEl" :class="$style.tags">
<ul>
<slot></slot>
</ul>
@@ -70,21 +70,17 @@ defineExpose({
});
</script>
-<style lang="scss" scoped>
-.meijqfqm {
+<style lang="scss" module>
+.root {
position: relative;
overflow: clip;
display: grid;
place-items: center;
+}
- > .canvas {
- display: block;
- }
-
- > .tags {
- position: absolute;
- top: 999px;
- left: 999px;
- }
+.tags {
+ position: absolute;
+ top: 999px;
+ left: 999px;
}
</style>
diff --git a/packages/frontend/src/components/MkTextarea.vue b/packages/frontend/src/components/MkTextarea.vue
index 82b631edda..83b2ed2444 100644
--- a/packages/frontend/src/components/MkTextarea.vue
+++ b/packages/frontend/src/components/MkTextarea.vue
@@ -1,12 +1,12 @@
<template>
-<div class="adhpbeos">
- <div class="label" @click="focus"><slot name="label"></slot></div>
- <div class="input" :class="{ disabled, focused, tall, pre }">
+<div>
+ <div :class="$style.label" @click="focus"><slot name="label"></slot></div>
+ <div :class="{ [$style.disabled]: disabled, [$style.focused]: focused, [$style.tall]: tall, [$style.pre]: pre }" style="position: relative;">
<textarea
ref="inputEl"
v-model="v"
v-adaptive-border
- :class="{ code, _monospace: code }"
+ :class="[$style.textarea, { _monospace: code }]"
:disabled="disabled"
:required="required"
:readonly="readonly"
@@ -20,243 +20,173 @@
@input="onInput"
></textarea>
</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-device-floppy"></i> {{ i18n.ts.save }}</MkButton>
+ <MkButton v-if="manualSave && changed" primary :class="$style.save" @click="updated"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton>
</div>
</template>
-<script lang="ts">
-import { defineComponent, onMounted, nextTick, ref, watch, computed, toRefs } from 'vue';
+<script lang="ts" setup>
+import { onMounted, nextTick, ref, watch, computed, toRefs, shallowRef } from 'vue';
import { debounce } from 'throttle-debounce';
import MkButton from '@/components/MkButton.vue';
import { i18n } from '@/i18n';
-export default defineComponent({
- components: {
- MkButton,
- },
+const props = defineProps<{
+ modelValue: string | null;
+ required?: boolean;
+ readonly?: boolean;
+ disabled?: boolean;
+ pattern?: string;
+ placeholder?: string;
+ autofocus?: boolean;
+ autocomplete?: string;
+ spellcheck?: boolean;
+ debounce?: boolean;
+ manualSave?: boolean;
+ code?: boolean;
+ tall?: boolean;
+ pre?: boolean;
+}>();
- props: {
- modelValue: {
- required: true,
- },
- type: {
- type: String,
- required: false,
- },
- required: {
- type: Boolean,
- required: false,
- },
- readonly: {
- type: Boolean,
- required: false,
- },
- disabled: {
- type: Boolean,
- required: false,
- },
- pattern: {
- type: String,
- required: false,
- },
- placeholder: {
- type: String,
- required: false,
- },
- autofocus: {
- type: Boolean,
- required: false,
- default: false,
- },
- autocomplete: {
- required: false,
- },
- spellcheck: {
- required: false,
- },
- code: {
- type: Boolean,
- required: false,
- },
- tall: {
- type: Boolean,
- required: false,
- default: false,
- },
- pre: {
- type: Boolean,
- required: false,
- default: false,
- },
- debounce: {
- type: Boolean,
- required: false,
- default: false,
- },
- manualSave: {
- type: Boolean,
- required: false,
- default: false,
- },
- },
+const emit = defineEmits<{
+ (ev: 'change', _ev: KeyboardEvent): void;
+ (ev: 'keydown', _ev: KeyboardEvent): void;
+ (ev: 'enter'): void;
+ (ev: 'update:modelValue', value: string): void;
+}>();
- emits: ['change', 'keydown', 'enter', 'update:modelValue'],
+const { modelValue, autofocus } = toRefs(props);
+const v = ref<string>(modelValue.value ?? '');
+const focused = ref(false);
+const changed = ref(false);
+const invalid = ref(false);
+const filled = computed(() => v.value !== '' && v.value != null);
+const inputEl = shallowRef<HTMLTextAreaElement>();
- setup(props, context) {
- const { modelValue, autofocus } = toRefs(props);
- const v = ref(modelValue.value);
- const focused = ref(false);
- const changed = ref(false);
- const invalid = ref(false);
- const filled = computed(() => v.value !== '' && v.value != null);
- const inputEl = ref(null);
+const focus = () => inputEl.value.focus();
+const onInput = (ev) => {
+ changed.value = true;
+ emit('change', ev);
+};
+const onKeydown = (ev: KeyboardEvent) => {
+ if (ev.isComposing || ev.key === 'Process' || ev.keyCode === 229) return;
- const focus = () => inputEl.value.focus();
- const onInput = (ev) => {
- changed.value = true;
- context.emit('change', ev);
- };
- const onKeydown = (ev: KeyboardEvent) => {
- if (ev.isComposing || ev.key === 'Process' || ev.keyCode === 229) return;
+ emit('keydown', ev);
- context.emit('keydown', ev);
-
- if (ev.code === 'Enter') {
- context.emit('enter');
- }
- };
-
- const updated = () => {
- changed.value = false;
- context.emit('update:modelValue', v.value);
- };
+ if (ev.code === 'Enter') {
+ emit('enter');
+ }
+};
- const debouncedUpdated = debounce(1000, updated);
+const updated = () => {
+ changed.value = false;
+ emit('update:modelValue', v.value ?? '');
+};
- watch(modelValue, newValue => {
- v.value = newValue;
- });
+const debouncedUpdated = debounce(1000, updated);
- watch(v, newValue => {
- if (!props.manualSave) {
- if (props.debounce) {
- debouncedUpdated();
- } else {
- updated();
- }
- }
+watch(modelValue, newValue => {
+ v.value = newValue;
+});
- invalid.value = inputEl.value.validity.badInput;
- });
+watch(v, newValue => {
+ if (!props.manualSave) {
+ if (props.debounce) {
+ debouncedUpdated();
+ } else {
+ updated();
+ }
+ }
- onMounted(() => {
- nextTick(() => {
- if (autofocus.value) {
- focus();
- }
- });
- });
+ invalid.value = inputEl.value.validity.badInput;
+});
- return {
- v,
- focused,
- invalid,
- changed,
- filled,
- inputEl,
- focus,
- onInput,
- onKeydown,
- updated,
- i18n,
- };
- },
+onMounted(() => {
+ nextTick(() => {
+ if (autofocus.value) {
+ focus();
+ }
+ });
});
</script>
-<style lang="scss" scoped>
-.adhpbeos {
- > .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;
-
- > textarea {
- appearance: none;
- -webkit-appearance: none;
- display: block;
- width: 100%;
- min-width: 100%;
- max-width: 100%;
- min-height: 130px;
- margin: 0;
- padding: 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;
- }
- }
+.textarea {
+ appearance: none;
+ -webkit-appearance: none;
+ display: block;
+ width: 100%;
+ min-width: 100%;
+ max-width: 100%;
+ min-height: 130px;
+ margin: 0;
+ padding: 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 {
- > textarea {
- border-color: var(--accent) !important;
- }
- }
+ &:hover {
+ border-color: var(--inputBorderHover) !important;
+ }
+}
- &.disabled {
- opacity: 0.7;
+.focused {
+ > .textarea {
+ border-color: var(--accent) !important;
+ }
+}
- &, * {
- cursor: not-allowed !important;
- }
- }
+.disabled {
+ opacity: 0.7;
+ cursor: not-allowed !important;
- &.tall {
- > textarea {
- min-height: 200px;
- }
- }
+ > .textarea {
+ cursor: not-allowed !important;
+ }
+}
- &.pre {
- > textarea {
- white-space: pre;
- }
- }
+.tall {
+ > .textarea {
+ min-height: 200px;
}
+}
- > .save {
- margin: 8px 0 0 0;
+.pre {
+ > .textarea {
+ white-space: pre;
}
}
+
+.save {
+ margin: 8px 0 0 0;
+}
</style>
diff --git a/packages/frontend/src/components/MkTimeline.vue b/packages/frontend/src/components/MkTimeline.vue
index fb0a3a4b67..2595ebc45d 100644
--- a/packages/frontend/src/components/MkTimeline.vue
+++ b/packages/frontend/src/components/MkTimeline.vue
@@ -1,11 +1,11 @@
<template>
-<MkNotes ref="tlComponent" :no-gap="!defaultStore.state.showGapBetweenNotesInTimeline" :pagination="pagination" @queue="emit('queue', $event)"/>
+<MkNotes ref="tlComponent" :noGap="!defaultStore.state.showGapBetweenNotesInTimeline" :pagination="pagination" @queue="emit('queue', $event)"/>
</template>
<script lang="ts" setup>
import { computed, provide, onUnmounted } from 'vue';
import MkNotes from '@/components/MkNotes.vue';
-import { stream } from '@/stream';
+import { useStream } from '@/stream';
import * as sound from '@/scripts/sound';
import { $i } from '@/account';
import { defaultStore } from '@/store';
@@ -46,17 +46,13 @@ const onUserRemoved = () => {
tlComponent.pagingComponent?.reload();
};
-const onChangeFollowing = () => {
- if (!tlComponent.pagingComponent?.backed) {
- tlComponent.pagingComponent?.reload();
- }
-};
-
let endpoint;
let query;
let connection;
let connection2;
+const stream = useStream();
+
if (props.src === 'antenna') {
endpoint = 'antennas/notes';
query = {
@@ -68,23 +64,41 @@ if (props.src === 'antenna') {
connection.on('note', prepend);
} else if (props.src === 'home') {
endpoint = 'notes/timeline';
- connection = stream.useChannel('homeTimeline');
+ query = {
+ withReplies: defaultStore.state.showTimelineReplies,
+ };
+ connection = stream.useChannel('homeTimeline', {
+ withReplies: defaultStore.state.showTimelineReplies,
+ });
connection.on('note', prepend);
connection2 = stream.useChannel('main');
- connection2.on('follow', onChangeFollowing);
- connection2.on('unfollow', onChangeFollowing);
} else if (props.src === 'local') {
endpoint = 'notes/local-timeline';
- connection = stream.useChannel('localTimeline');
+ query = {
+ withReplies: defaultStore.state.showTimelineReplies,
+ };
+ connection = stream.useChannel('localTimeline', {
+ withReplies: defaultStore.state.showTimelineReplies,
+ });
connection.on('note', prepend);
} else if (props.src === 'social') {
endpoint = 'notes/hybrid-timeline';
- connection = stream.useChannel('hybridTimeline');
+ query = {
+ withReplies: defaultStore.state.showTimelineReplies,
+ };
+ connection = stream.useChannel('hybridTimeline', {
+ withReplies: defaultStore.state.showTimelineReplies,
+ });
connection.on('note', prepend);
} else if (props.src === 'global') {
endpoint = 'notes/global-timeline';
- connection = stream.useChannel('globalTimeline');
+ query = {
+ withReplies: defaultStore.state.showTimelineReplies,
+ };
+ connection = stream.useChannel('globalTimeline', {
+ withReplies: defaultStore.state.showTimelineReplies,
+ });
connection.on('note', prepend);
} else if (props.src === 'mentions') {
endpoint = 'notes/mentions';
diff --git a/packages/frontend/src/components/MkToast.vue b/packages/frontend/src/components/MkToast.vue
index ad53c7f289..e135f56472 100644
--- a/packages/frontend/src/components/MkToast.vue
+++ b/packages/frontend/src/components/MkToast.vue
@@ -1,11 +1,11 @@
<template>
<div>
<Transition
- :enter-active-class="defaultStore.state.animation ? $style.transition_toast_enterActive : ''"
- :leave-active-class="defaultStore.state.animation ? $style.transition_toast_leaveActive : ''"
- :enter-from-class="defaultStore.state.animation ? $style.transition_toast_enterFrom : ''"
- :leave-to-class="defaultStore.state.animation ? $style.transition_toast_leaveTo : ''"
- appear @after-leave="emit('closed')"
+ :enterActiveClass="defaultStore.state.animation ? $style.transition_toast_enterActive : ''"
+ :leaveActiveClass="defaultStore.state.animation ? $style.transition_toast_leaveActive : ''"
+ :enterFromClass="defaultStore.state.animation ? $style.transition_toast_enterFrom : ''"
+ :leaveToClass="defaultStore.state.animation ? $style.transition_toast_leaveTo : ''"
+ appear @afterLeave="emit('closed')"
>
<div v-if="showing" class="_acrylic" :class="$style.root" :style="{ zIndex }">
<div style="padding: 16px 24px;">
diff --git a/packages/frontend/src/components/MkTokenGenerateWindow.vue b/packages/frontend/src/components/MkTokenGenerateWindow.vue
index 56be044405..3ddd81aaee 100644
--- a/packages/frontend/src/components/MkTokenGenerateWindow.vue
+++ b/packages/frontend/src/components/MkTokenGenerateWindow.vue
@@ -3,16 +3,16 @@
ref="dialog"
:width="400"
:height="450"
- :with-ok-button="true"
- :ok-button-disabled="false"
- :can-close="false"
+ :withOkButton="true"
+ :okButtonDisabled="false"
+ :canClose="false"
@close="dialog.close()"
@closed="$emit('closed')"
@ok="ok()"
>
<template #header>{{ title || i18n.ts.generateAccessToken }}</template>
- <MkSpacer :margin-min="20" :margin-max="28">
+ <MkSpacer :marginMin="20" :marginMax="28">
<div class="_gaps_m">
<div v-if="information">
<MkInfo warn>{{ information }}</MkInfo>
diff --git a/packages/frontend/src/components/MkTooltip.vue b/packages/frontend/src/components/MkTooltip.vue
index 2d34b090ed..91c9b70a5a 100644
--- a/packages/frontend/src/components/MkTooltip.vue
+++ b/packages/frontend/src/components/MkTooltip.vue
@@ -1,10 +1,10 @@
<template>
<Transition
- :enter-active-class="defaultStore.state.animation ? $style.transition_tooltip_enterActive : ''"
- :leave-active-class="defaultStore.state.animation ? $style.transition_tooltip_leaveActive : ''"
- :enter-from-class="defaultStore.state.animation ? $style.transition_tooltip_enterFrom : ''"
- :leave-to-class="defaultStore.state.animation ? $style.transition_tooltip_leaveTo : ''"
- appear @after-leave="emit('closed')"
+ :enterActiveClass="defaultStore.state.animation ? $style.transition_tooltip_enterActive : ''"
+ :leaveActiveClass="defaultStore.state.animation ? $style.transition_tooltip_leaveActive : ''"
+ :enterFromClass="defaultStore.state.animation ? $style.transition_tooltip_enterFrom : ''"
+ :leaveToClass="defaultStore.state.animation ? $style.transition_tooltip_leaveTo : ''"
+ appear @afterLeave="emit('closed')"
>
<div v-show="showing" ref="el" :class="$style.root" class="_acrylic _shadow" :style="{ zIndex, maxWidth: maxWidth + 'px' }">
<slot>
@@ -41,6 +41,9 @@ const emit = defineEmits<{
(ev: 'closed'): void;
}>();
+// タイミングによっては最初から showing = false な場合があり、その場合に closed 扱いにしないと永久にDOMに残ることになる
+if (!props.showing) emit('closed');
+
const el = shallowRef<HTMLElement>();
const zIndex = os.claimZIndex('high');
@@ -66,10 +69,8 @@ onMounted(() => {
setPosition();
const loop = () => {
- loopHandler = window.requestAnimationFrame(() => {
- setPosition();
- loop();
- });
+ setPosition();
+ loopHandler = window.requestAnimationFrame(loop);
};
loop();
diff --git a/packages/frontend/src/components/MkUpdated.vue b/packages/frontend/src/components/MkUpdated.vue
index eed7fa71f6..3a0b2abb4e 100644
--- a/packages/frontend/src/components/MkUpdated.vue
+++ b/packages/frontend/src/components/MkUpdated.vue
@@ -1,5 +1,5 @@
<template>
-<MkModal ref="modal" :z-priority="'middle'" @click="$refs.modal.close()" @closed="$emit('closed')">
+<MkModal ref="modal" :zPriority="'middle'" @click="$refs.modal.close()" @closed="$emit('closed')">
<div :class="$style.root">
<div :class="$style.title"><MkSparkle>{{ i18n.ts.misskeyUpdated }}</MkSparkle></div>
<div :class="$style.version">✨{{ version }}🚀</div>
diff --git a/packages/frontend/src/components/MkUrlPreview.vue b/packages/frontend/src/components/MkUrlPreview.vue
index 9c5622b1c5..fcad5b8064 100644
--- a/packages/frontend/src/components/MkUrlPreview.vue
+++ b/packages/frontend/src/components/MkUrlPreview.vue
@@ -22,7 +22,7 @@
</div>
</template>
<template v-else-if="tweetId && tweetExpanded">
- <div ref="twitter" :class="$style.twitter">
+ <div ref="twitter">
<iframe ref="tweet" scrolling="no" frameborder="no" :style="{ position: 'relative', width: '100%', height: `${tweetHeight}px` }" :src="`https://platform.twitter.com/embed/index.html?embedId=${embedId}&amp;hideCard=false&amp;hideThread=false&amp;lang=en&amp;theme=${defaultStore.state.darkMode ? 'dark' : 'light'}&amp;id=${tweetId}`"></iframe>
</div>
<div :class="$style.action">
@@ -31,7 +31,7 @@
</MkButton>
</div>
</template>
-<div v-else :class="$style.urlPreview">
+<div v-else>
<component :is="self ? 'MkA' : 'a'" :class="[$style.link, { [$style.compact]: compact }]" :[attr]="self ? url.substr(local.length) : url" rel="nofollow noopener" :target="target" :title="url">
<div v-if="thumbnail" :class="$style.thumbnail" :style="`background-image: url('${thumbnail}')`">
</div>
@@ -41,14 +41,14 @@
<h1 v-else-if="fetching" :class="$style.title"><MkEllipsis/></h1>
<h1 v-else :class="$style.title" :title="title ?? undefined">{{ title }}</h1>
</header>
- <p v-if="unknownUrl" :class="$style.text">{{ i18n.ts.cannotLoad }}</p>
+ <p v-if="unknownUrl" :class="$style.text">{{ i18n.ts.failedToPreviewUrl }}</p>
<p v-else-if="fetching" :class="$style.text"><MkEllipsis/></p>
<p v-else-if="description" :class="$style.text" :title="description">{{ description.length > 85 ? description.slice(0, 85) + '…' : description }}</p>
<footer :class="$style.footer">
<img v-if="icon" :class="$style.siteIcon" :src="icon"/>
- <p v-if="unknownUrl" :class="$style.siteName">?</p>
+ <p v-if="unknownUrl" :class="$style.siteName">{{ requestUrl.host }}</p>
<p v-else-if="fetching" :class="$style.siteName"><MkEllipsis/></p>
- <p v-else :class="$style.siteName" :title="sitename ?? undefined">{{ sitename }}</p>
+ <p v-else :class="$style.siteName" :title="sitename ?? requestUrl.host">{{ sitename ?? requestUrl.host }}</p>
</footer>
</article>
</component>
@@ -128,17 +128,33 @@ if (requestUrl.hostname === 'music.youtube.com' && requestUrl.pathname.match('^/
requestUrl.hash = '';
-window.fetch(`/url?url=${encodeURIComponent(requestUrl.href)}&lang=${versatileLang}`).then(res => {
- res.json().then((info: SummalyResult) => {
+window.fetch(`/url?url=${encodeURIComponent(requestUrl.href)}&lang=${versatileLang}`)
+ .then(res => {
+ if (!res.ok) {
+ fetching = false;
+ unknownUrl = true;
+ return;
+ }
+
+ return res.json();
+ })
+ .then((info: SummalyResult) => {
+ if (info.url == null) {
+ fetching = false;
+ unknownUrl = true;
+ return;
+ }
+
+ fetching = false;
+ unknownUrl = false;
+
title = info.title;
description = info.description;
thumbnail = info.thumbnail;
icon = info.icon;
sitename = info.sitename;
- fetching = false;
player = info.player;
});
-});
function adjustTweetHeight(message: any) {
if (message.origin !== 'https://platform.twitter.com') return;
@@ -194,13 +210,6 @@ onUnmounted(() => {
width: 100%;
}
-.twitter {
-
-}
-
-.urlPreview {
-}
-
.link {
position: relative;
display: block;
diff --git a/packages/frontend/src/components/MkUrlPreviewPopup.vue b/packages/frontend/src/components/MkUrlPreviewPopup.vue
index e244be3e96..36a9e2f73f 100644
--- a/packages/frontend/src/components/MkUrlPreviewPopup.vue
+++ b/packages/frontend/src/components/MkUrlPreviewPopup.vue
@@ -1,6 +1,6 @@
<template>
-<div class="fgmtyycl" :style="{ zIndex, top: top + 'px', left: left + 'px' }">
- <Transition :name="defaultStore.state.animation ? '_transition_zoom' : ''" @after-leave="emit('closed')">
+<div :class="$style.root" :style="{ zIndex, top: top + 'px', left: left + 'px' }">
+ <Transition :name="defaultStore.state.animation ? '_transition_zoom' : ''" @afterLeave="emit('closed')">
<MkUrlPreview v-if="showing" class="_popup _shadow" :url="url"/>
</Transition>
</div>
@@ -36,8 +36,8 @@ onMounted(() => {
});
</script>
-<style lang="scss" scoped>
-.fgmtyycl {
+<style lang="scss" module>
+.root {
position: absolute;
width: 500px;
max-width: calc(90vw - 12px);
diff --git a/packages/frontend/src/components/MkUserInfo.vue b/packages/frontend/src/components/MkUserInfo.vue
index f560ebcd8a..172b517511 100644
--- a/packages/frontend/src/components/MkUserInfo.vue
+++ b/packages/frontend/src/components/MkUserInfo.vue
@@ -8,7 +8,7 @@
</div>
<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">
+ <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>
@@ -105,7 +105,7 @@ defineProps<{
.mfm {
display: -webkit-box;
-webkit-line-clamp: 3;
- -webkit-box-orient: vertical;
+ -webkit-box-orient: vertical;
overflow: hidden;
}
diff --git a/packages/frontend/src/components/MkUserOnlineIndicator.vue b/packages/frontend/src/components/MkUserOnlineIndicator.vue
index 251ab5d79a..a2c2b53b08 100644
--- a/packages/frontend/src/components/MkUserOnlineIndicator.vue
+++ b/packages/frontend/src/components/MkUserOnlineIndicator.vue
@@ -1,5 +1,13 @@
<template>
-<div v-tooltip="text" :class="[$style.root, $style['status_' + user.onlineStatus]]"></div>
+<div
+ v-tooltip="text"
+ :class="[$style.root, {
+ [$style.status_online]: user.onlineStatus === 'online',
+ [$style.status_active]: user.onlineStatus === 'active',
+ [$style.status_offline]: user.onlineStatus === 'offline',
+ [$style.status_unknown]: user.onlineStatus === 'unknown',
+ }]"
+></div>
</template>
<script lang="ts" setup>
diff --git a/packages/frontend/src/components/MkUserPopup.vue b/packages/frontend/src/components/MkUserPopup.vue
index 8ca0355448..c3b777a12e 100644
--- a/packages/frontend/src/components/MkUserPopup.vue
+++ b/packages/frontend/src/components/MkUserPopup.vue
@@ -1,10 +1,10 @@
<template>
<Transition
- :enter-active-class="defaultStore.state.animation ? $style.transition_popup_enterActive : ''"
- :leave-active-class="defaultStore.state.animation ? $style.transition_popup_leaveActive : ''"
- :enter-from-class="defaultStore.state.animation ? $style.transition_popup_enterFrom : ''"
- :leave-to-class="defaultStore.state.animation ? $style.transition_popup_leaveTo : ''"
- appear @after-leave="emit('closed')"
+ :enterActiveClass="defaultStore.state.animation ? $style.transition_popup_enterActive : ''"
+ :leaveActiveClass="defaultStore.state.animation ? $style.transition_popup_leaveActive : ''"
+ :enterFromClass="defaultStore.state.animation ? $style.transition_popup_enterFrom : ''"
+ :leaveToClass="defaultStore.state.animation ? $style.transition_popup_leaveTo : ''"
+ appear @afterLeave="emit('closed')"
>
<div v-if="showing" :class="$style.root" class="_popup _shadow" :style="{ zIndex, top: top + 'px', left: left + 'px' }" @mouseover="() => { emit('mouseover'); }" @mouseleave="() => { emit('mouseleave'); }">
<div v-if="user != null">
@@ -22,7 +22,7 @@
<div :class="$style.username"><MkAcct :user="user"/></div>
</div>
<div :class="$style.description">
- <Mfm v-if="user.description" :text="user.description" :author="user" :i="$i"/>
+ <Mfm v-if="user.description" :class="$style.mfm" :text="user.description" :author="user" :i="$i"/>
<div v-else style="opacity: 0.7;">{{ i18n.ts.noAccountDescription }}</div>
</div>
<div :class="$style.status">
@@ -192,6 +192,13 @@ onMounted(() => {
border-bottom: solid 1px var(--divider);
}
+.mfm {
+ display: -webkit-box;
+ -webkit-line-clamp: 5;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
+
.status {
padding: 16px 26px 16px 26px;
}
diff --git a/packages/frontend/src/components/MkUserSelectDialog.vue b/packages/frontend/src/components/MkUserSelectDialog.vue
index dc78bbf42d..792ff7afd7 100644
--- a/packages/frontend/src/components/MkUserSelectDialog.vue
+++ b/packages/frontend/src/components/MkUserSelectDialog.vue
@@ -1,22 +1,22 @@
<template>
<MkModalWindow
ref="dialogEl"
- :with-ok-button="true"
- :ok-button-disabled="selected == null"
+ :withOkButton="true"
+ :okButtonDisabled="selected == null"
@click="cancel()"
@close="cancel()"
@ok="ok()"
@closed="$emit('closed')"
>
<template #header>{{ i18n.ts.selectUser }}</template>
- <div :class="$style.root">
+ <div>
<div :class="$style.form">
- <FormSplit :min-width="170">
- <MkInput v-model="username" :autofocus="true" @update:model-value="search">
+ <FormSplit :minWidth="170">
+ <MkInput v-model="username" :autofocus="true" @update:modelValue="search">
<template #label>{{ i18n.ts.username }}</template>
<template #prefix>@</template>
</MkInput>
- <MkInput v-model="host" :datalist="[hostname]" @update:model-value="search">
+ <MkInput v-model="host" :datalist="[hostname]" @update:modelValue="search">
<template #label>{{ i18n.ts.host }}</template>
<template #prefix>@</template>
</MkInput>
@@ -126,8 +126,6 @@ onMounted(() => {
</script>
<style lang="scss" module>
-.root {
-}
.form {
padding: 0 var(--root-margin);
diff --git a/packages/frontend/src/components/MkUserSetupDialog.Follow.vue b/packages/frontend/src/components/MkUserSetupDialog.Follow.vue
index a2a195cb09..789f88a8fe 100644
--- a/packages/frontend/src/components/MkUserSetupDialog.Follow.vue
+++ b/packages/frontend/src/components/MkUserSetupDialog.Follow.vue
@@ -2,7 +2,7 @@
<div class="_gaps">
<div style="text-align: center;">{{ i18n.ts._initialAccountSetting.followUsers }}</div>
- <MkFolder :default-open="true">
+ <MkFolder :defaultOpen="true">
<template #label>{{ i18n.ts.recommended }}</template>
<MkPagination :pagination="pinnedUsers">
@@ -14,7 +14,7 @@
</MkPagination>
</MkFolder>
- <MkFolder :default-open="true">
+ <MkFolder :defaultOpen="true">
<template #label>{{ i18n.ts.popularUsers }}</template>
<MkPagination :pagination="popularUsers">
diff --git a/packages/frontend/src/components/MkUserSetupDialog.Privacy.vue b/packages/frontend/src/components/MkUserSetupDialog.Privacy.vue
index e9f4f68df8..5cea67ccf5 100644
--- a/packages/frontend/src/components/MkUserSetupDialog.Privacy.vue
+++ b/packages/frontend/src/components/MkUserSetupDialog.Privacy.vue
@@ -4,6 +4,7 @@
<MkFolder>
<template #label>{{ i18n.ts.makeFollowManuallyApprove }}</template>
+ <template #icon><i class="ti ti-lock"></i></template>
<template #suffix>{{ isLocked ? i18n.ts.on : i18n.ts.off }}</template>
<MkSwitch v-model="isLocked">{{ i18n.ts.makeFollowManuallyApprove }}<template #caption>{{ i18n.ts.lockedAccountInfo }}</template></MkSwitch>
@@ -11,6 +12,7 @@
<MkFolder>
<template #label>{{ i18n.ts.hideOnlineStatus }}</template>
+ <template #icon><i class="ti ti-eye-off"></i></template>
<template #suffix>{{ hideOnlineStatus ? i18n.ts.on : i18n.ts.off }}</template>
<MkSwitch v-model="hideOnlineStatus">{{ i18n.ts.hideOnlineStatus }}<template #caption>{{ i18n.ts.hideOnlineStatusDescription }}</template></MkSwitch>
@@ -18,6 +20,7 @@
<MkFolder>
<template #label>{{ i18n.ts.noCrawle }}</template>
+ <template #icon><i class="ti ti-world-x"></i></template>
<template #suffix>{{ noCrawle ? i18n.ts.on : i18n.ts.off }}</template>
<MkSwitch v-model="noCrawle">{{ i18n.ts.noCrawle }}<template #caption>{{ i18n.ts.noCrawleDescription }}</template></MkSwitch>
@@ -25,6 +28,7 @@
<MkFolder>
<template #label>{{ i18n.ts.preventAiLearning }}</template>
+ <template #icon><i class="ti ti-photo-shield"></i></template>
<template #suffix>{{ preventAiLearning ? i18n.ts.on : i18n.ts.off }}</template>
<MkSwitch v-model="preventAiLearning">{{ i18n.ts.preventAiLearning }}<template #caption>{{ i18n.ts.preventAiLearningDescription }}</template></MkSwitch>
diff --git a/packages/frontend/src/components/MkUserSetupDialog.Profile.vue b/packages/frontend/src/components/MkUserSetupDialog.Profile.vue
index f26ea11214..3107209b97 100644
--- a/packages/frontend/src/components/MkUserSetupDialog.Profile.vue
+++ b/packages/frontend/src/components/MkUserSetupDialog.Profile.vue
@@ -12,11 +12,11 @@
</div>
</FormSlot>
- <MkInput v-model="name" :max="30" manual-save data-cy-user-setup-user-name>
+ <MkInput v-model="name" :max="30" manualSave 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>
+ <MkTextarea v-model="description" :max="500" tall manualSave data-cy-user-setup-user-description>
<template #label>{{ i18n.ts._profile.description }}</template>
</MkTextarea>
@@ -37,8 +37,8 @@ import { chooseFileFromPc } from '@/scripts/select-file';
import * as os from '@/os';
import { $i } from '@/account';
-const name = ref('');
-const description = ref('');
+const name = ref($i.name ?? '');
+const description = ref($i.description ?? '');
watch(name, () => {
os.apiWithDialog('i/update', {
diff --git a/packages/frontend/src/components/MkUserSetupDialog.vue b/packages/frontend/src/components/MkUserSetupDialog.vue
index 4e80a5c0fb..566441213e 100644
--- a/packages/frontend/src/components/MkUserSetupDialog.vue
+++ b/packages/frontend/src/components/MkUserSetupDialog.vue
@@ -7,10 +7,10 @@
@close="close(true)"
@closed="emit('closed')"
>
- <template v-if="page === 1" #header>{{ i18n.ts._initialAccountSetting.profileSetting }}</template>
- <template v-else-if="page === 2" #header>{{ i18n.ts._initialAccountSetting.privacySetting }}</template>
- <template v-else-if="page === 3" #header>{{ i18n.ts.follow }}</template>
- <template v-else-if="page === 4" #header>{{ i18n.ts.pushNotification }}</template>
+ <template v-if="page === 1" #header><i class="ti ti-user-edit"></i> {{ i18n.ts._initialAccountSetting.profileSetting }}</template>
+ <template v-else-if="page === 2" #header><i class="ti ti-lock"></i> {{ i18n.ts._initialAccountSetting.privacySetting }}</template>
+ <template v-else-if="page === 3" #header><i class="ti ti-user-plus"></i> {{ i18n.ts.follow }}</template>
+ <template v-else-if="page === 4" #header><i class="ti ti-bell-plus"></i> {{ i18n.ts.pushNotification }}</template>
<template v-else-if="page === 5" #header>{{ i18n.ts.done }}</template>
<template v-else #header>{{ i18n.ts.initialAccountSetting }}</template>
@@ -20,65 +20,80 @@
</div>
<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"
+ :enterActiveClass="$style.transition_x_enterActive"
+ :leaveActiveClass="$style.transition_x_leaveActive"
+ :enterFromClass="$style.transition_x_enterFrom"
+ :leaveToClass="$style.transition_x_leaveTo"
>
<template v-if="page === 0">
<div :class="$style.centerPage">
- <MkSpacer :margin-min="20" :margin-max="28">
+ <MkAnimBg style="position: absolute; top: 0;" :scale="1.5"/>
+ <MkSpacer :marginMin="20" :marginMax="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>
+ <MkButton style="margin: 0 auto;" transparent rounded @click="later(true)">{{ i18n.ts.later }}</MkButton>
</div>
</MkSpacer>
</div>
</template>
<template v-else-if="page === 1">
<div style="height: 100cqh; overflow: auto;">
- <MkSpacer :margin-min="20" :margin-max="28">
+ <MkSpacer :marginMin="20" :marginMax="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>
+ <div class="_buttonsCenter" style="margin-top: 16px;">
+ <MkButton rounded data-cy-user-setup-back @click="page--"><i class="ti ti-arrow-left"></i> {{ i18n.ts.goBack }}</MkButton>
+ <MkButton primary rounded gradate 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 === 2">
<div style="height: 100cqh; overflow: auto;">
- <MkSpacer :margin-min="20" :margin-max="28">
+ <MkSpacer :marginMin="20" :marginMax="28">
<XPrivacy/>
- <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 class="_buttonsCenter" style="margin-top: 16px;">
+ <MkButton rounded data-cy-user-setup-back @click="page--"><i class="ti ti-arrow-left"></i> {{ i18n.ts.goBack }}</MkButton>
+ <MkButton primary rounded gradate 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 === 3">
<div style="height: 100cqh; overflow: auto;">
- <MkSpacer :margin-min="20" :margin-max="28">
+ <MkSpacer :marginMin="20" :marginMax="28">
<XFollow/>
</MkSpacer>
<div :class="$style.pageFooter">
- <MkButton primary rounded gradate style="margin: 0 auto;" data-cy-user-setup-continue @click="page++">{{ i18n.ts.continue }} <i class="ti ti-arrow-right"></i></MkButton>
+ <div class="_buttonsCenter">
+ <MkButton rounded data-cy-user-setup-back @click="page--"><i class="ti ti-arrow-left"></i> {{ i18n.ts.goBack }}</MkButton>
+ <MkButton primary rounded gradate style="" data-cy-user-setup-continue @click="page++">{{ i18n.ts.continue }} <i class="ti ti-arrow-right"></i></MkButton>
+ </div>
</div>
</div>
</template>
<template v-else-if="page === 4">
<div :class="$style.centerPage">
- <MkSpacer :margin-min="20" :margin-max="28">
+ <MkSpacer :marginMin="20" :marginMax="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>
+ <MkPushNotificationAllowButton primary showOnlyToRegister style="margin: 0 auto;"/>
+ <div class="_buttonsCenter" style="margin-top: 16px;">
+ <MkButton rounded data-cy-user-setup-back @click="page--"><i class="ti ti-arrow-left"></i> {{ i18n.ts.goBack }}</MkButton>
+ <MkButton primary rounded gradate data-cy-user-setup-continue @click="page++">{{ i18n.ts.continue }} <i class="ti ti-arrow-right"></i></MkButton>
+ </div>
</div>
</MkSpacer>
</div>
</template>
<template v-else-if="page === 5">
<div :class="$style.centerPage">
- <MkSpacer :margin-min="20" :margin-max="28">
+ <MkAnimBg style="position: absolute; top: 0;" :scale="1.5"/>
+ <MkSpacer :marginMin="20" :marginMax="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>
@@ -89,7 +104,10 @@
</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 class="_buttonsCenter" style="margin-top: 16px;">
+ <MkButton rounded data-cy-user-setup-back @click="page--"><i class="ti ti-arrow-left"></i> {{ i18n.ts.goBack }}</MkButton>
+ <MkButton primary rounded gradate data-cy-user-setup-continue @click="close(false)">{{ i18n.ts.close }}</MkButton>
+ </div>
</div>
</MkSpacer>
</div>
@@ -106,6 +124,7 @@ import MkButton from '@/components/MkButton.vue';
import XProfile from '@/components/MkUserSetupDialog.Profile.vue';
import XFollow from '@/components/MkUserSetupDialog.Follow.vue';
import XPrivacy from '@/components/MkUserSetupDialog.Privacy.vue';
+import MkAnimBg from '@/components/MkAnimBg.vue';
import { i18n } from '@/i18n';
import { instance } from '@/instance';
import { host } from '@/config';
@@ -137,6 +156,19 @@ async function close(skip: boolean) {
dialog.value.close();
defaultStore.set('accountSetupWizard', -1);
}
+
+async function later(later: boolean) {
+ if (later) {
+ const { canceled } = await os.confirm({
+ type: 'warning',
+ text: i18n.ts._initialAccountSetting.laterAreYouSure,
+ });
+ if (canceled) return;
+ }
+
+ dialog.value.close();
+ defaultStore.set('accountSetupWizard', 0);
+}
</script>
<style lang="scss" module>
@@ -183,7 +215,7 @@ async function close(skip: boolean) {
left: 0;
padding: 12px;
border-top: solid 0.5px var(--divider);
- -webkit-backdrop-filter: var(--blur, blur(15px));
- backdrop-filter: var(--blur, blur(15px));
+ -webkit-backdrop-filter: blur(15px);
+ backdrop-filter: blur(15px);
}
</style>
diff --git a/packages/frontend/src/components/MkUsersTooltip.vue b/packages/frontend/src/components/MkUsersTooltip.vue
index d0f95fceda..0b80c2edc7 100644
--- a/packages/frontend/src/components/MkUsersTooltip.vue
+++ b/packages/frontend/src/components/MkUsersTooltip.vue
@@ -1,11 +1,11 @@
<template>
-<MkTooltip ref="tooltip" :showing="showing" :target-element="targetElement" :max-width="250" @closed="emit('closed')">
+<MkTooltip ref="tooltip" :showing="showing" :targetElement="targetElement" :maxWidth="250" @closed="emit('closed')">
<div :class="$style.root">
<div v-for="u in users" :key="u.id" :class="$style.user">
<MkAvatar :class="$style.avatar" :user="u"/>
- <MkUserName :class="$style.name" :user="u" :nowrap="true"/>
+ <MkUserName :user="u" :nowrap="true"/>
</div>
- <div v-if="users.length < count" :class="$style.omitted">+{{ count - users.length }}</div>
+ <div v-if="users.length < count">+{{ count - users.length }}</div>
</div>
</MkTooltip>
</template>
@@ -43,14 +43,6 @@ const emit = defineEmits<{
}
}
-.name {
-
-}
-
-.omitted {
-
-}
-
.avatar {
width: 24px;
height: 24px;
diff --git a/packages/frontend/src/components/MkVisibilityPicker.vue b/packages/frontend/src/components/MkVisibilityPicker.vue
index c181d84bc0..c8dbe90944 100644
--- a/packages/frontend/src/components/MkVisibilityPicker.vue
+++ b/packages/frontend/src/components/MkVisibilityPicker.vue
@@ -1,5 +1,5 @@
<template>
-<MkModal ref="modal" v-slot="{ type }" :z-priority="'high'" :src="src" @click="modal.close()" @closed="emit('closed')">
+<MkModal ref="modal" v-slot="{ type }" :zPriority="'high'" :src="src" @click="modal.close()" @closed="emit('closed')">
<div class="_popup" :class="{ [$style.root]: true, [$style.asDrawer]: type === 'drawer' }">
<div :class="[$style.label, $style.item]">
{{ i18n.ts.visibility }}
diff --git a/packages/frontend/src/components/MkVisitorDashboard.vue b/packages/frontend/src/components/MkVisitorDashboard.vue
index 6226768127..9566cc651f 100644
--- a/packages/frontend/src/components/MkVisitorDashboard.vue
+++ b/packages/frontend/src/components/MkVisitorDashboard.vue
@@ -39,7 +39,7 @@
<MkTimeline src="local"/>
</div>
</div>
- <div :class="[$style.activeUsersChart, $style.panel]">
+ <div :class="$style.panel">
<XActiveUsersChart/>
</div>
</div>
@@ -220,8 +220,4 @@ function exploreOtherServers() {
height: 350px;
overflow: auto;
}
-
-.activeUsersChart {
-
-}
</style>
diff --git a/packages/frontend/src/components/MkWaitingDialog.vue b/packages/frontend/src/components/MkWaitingDialog.vue
index da98da29d0..1b6ab1f13a 100644
--- a/packages/frontend/src/components/MkWaitingDialog.vue
+++ b/packages/frontend/src/components/MkWaitingDialog.vue
@@ -1,5 +1,5 @@
<template>
-<MkModal ref="modal" :prefer-type="'dialog'" :z-priority="'high'" @click="success ? done() : () => {}" @closed="emit('closed')">
+<MkModal ref="modal" :preferType="'dialog'" :zPriority="'high'" @click="success ? done() : () => {}" @closed="emit('closed')">
<div :class="[$style.root, { [$style.iconOnly]: (text == null) || success }]">
<i v-if="success" :class="[$style.icon, $style.success]" class="ti ti-check"></i>
<MkLoading v-else :class="[$style.icon, $style.waiting]" :em="true"/>
diff --git a/packages/frontend/src/components/MkWidgets.vue b/packages/frontend/src/components/MkWidgets.vue
index ad1c02a488..30547c7444 100644
--- a/packages/frontend/src/components/MkWidgets.vue
+++ b/packages/frontend/src/components/MkWidgets.vue
@@ -1,7 +1,7 @@
<template>
<div :class="$style.root">
<template v-if="edit">
- <header :class="$style['edit-header']">
+ <header :class="$style.editHeader">
<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>
@@ -10,26 +10,26 @@
<MkButton inline @click="$emit('exit')">{{ i18n.ts.close }}</MkButton>
</header>
<Sortable
- :model-value="props.widgets"
- item-key="id"
+ :modelValue="props.widgets"
+ itemKey="id"
handle=".handle"
:animation="150"
:group="{ name: 'SortableMkWidgets' }"
- :class="$style['edit-editing']"
- @update:model-value="v => emit('updateWidgets', v)"
+ :class="$style.editEditing"
+ @update:modelValue="v => emit('updateWidgets', v)"
>
<template #item="{element}">
- <div :class="[$style.widget, $style['customize-container']]" data-cy-customize-container>
- <button :class="$style['customize-container-config']" class="_button" @click.prevent.stop="configWidget(element.id)"><i class="ti ti-settings"></i></button>
- <button :class="$style['customize-container-remove']" data-cy-customize-container-remove class="_button" @click.prevent.stop="removeWidget(element)"><i class="ti ti-x"></i></button>
+ <div :class="[$style.widget, $style.customizeContainer]" data-cy-customize-container>
+ <button :class="$style.customizeContainerConfig" class="_button" @click.prevent.stop="configWidget(element.id)"><i class="ti ti-settings"></i></button>
+ <button :class="$style.customizeContainerRemove" data-cy-customize-container-remove class="_button" @click.prevent.stop="removeWidget(element)"><i class="ti ti-x"></i></button>
<div class="handle">
- <component :is="`widget-${element.name}`" :ref="el => widgetRefs[element.id] = el" class="widget" :class="$style['customize-container-handle-widget']" :widget="element" @update-props="updateWidget(element.id, $event)"/>
+ <component :is="`widget-${element.name}`" :ref="el => widgetRefs[element.id] = el" class="widget" :class="$style.customizeContainerHandleWidget" :widget="element" @updateProps="updateWidget(element.id, $event)"/>
</div>
</div>
</template>
</Sortable>
</template>
- <component :is="`widget-${widget.name}`" v-for="widget in widgets" v-else :key="widget.id" :ref="el => widgetRefs[widget.id] = el" :class="$style.widget" :widget="widget" @update-props="updateWidget(widget.id, $event)" @contextmenu.stop="onContextmenu(widget, $event)"/>
+ <component :is="`widget-${widget.name}`" v-for="widget in widgets" v-else :key="widget.id" :ref="el => widgetRefs[widget.id] = el" :class="$style.widget" :widget="widget" @updateProps="updateWidget(widget.id, $event)" @contextmenu.stop="onContextmenu(widget, $event)"/>
</div>
</template>
@@ -130,7 +130,7 @@ function onContextmenu(widget: Widget, ev: MouseEvent) {
}
.edit {
- &-header {
+ &Header {
margin: 16px 0;
> * {
@@ -139,17 +139,17 @@ function onContextmenu(widget: Widget, ev: MouseEvent) {
}
}
- &-editing {
+ &Editing {
min-height: 100px;
}
}
-.customize-container {
+.customizeContainer {
position: relative;
cursor: move;
- &-config,
- &-remove {
+ &Config,
+ &Remove {
position: absolute;
z-index: 10000;
top: 8px;
@@ -160,17 +160,17 @@ function onContextmenu(widget: Widget, ev: MouseEvent) {
border-radius: 4px;
}
- &-config {
+ &Config {
right: 8px + 8px + 32px;
}
- &-remove {
+ &Remove {
right: 8px;
}
- &-handle {
+ &Handle {
- &-widget {
+ &Widget {
pointer-events: none;
}
}
diff --git a/packages/frontend/src/components/MkWindow.vue b/packages/frontend/src/components/MkWindow.vue
index b662479b2a..dafabf2ba8 100644
--- a/packages/frontend/src/components/MkWindow.vue
+++ b/packages/frontend/src/components/MkWindow.vue
@@ -1,11 +1,11 @@
<template>
<Transition
- :enter-active-class="defaultStore.state.animation ? $style.transition_window_enterActive : ''"
- :leave-active-class="defaultStore.state.animation ? $style.transition_window_leaveActive : ''"
- :enter-from-class="defaultStore.state.animation ? $style.transition_window_enterFrom : ''"
- :leave-to-class="defaultStore.state.animation ? $style.transition_window_leaveTo : ''"
+ :enterActiveClass="defaultStore.state.animation ? $style.transition_window_enterActive : ''"
+ :leaveActiveClass="defaultStore.state.animation ? $style.transition_window_leaveActive : ''"
+ :enterFromClass="defaultStore.state.animation ? $style.transition_window_enterFrom : ''"
+ :leaveToClass="defaultStore.state.animation ? $style.transition_window_leaveTo : ''"
appear
- @after-leave="$emit('closed')"
+ @afterLeave="$emit('closed')"
>
<div v-if="showing" ref="rootEl" :class="[$style.root, { [$style.maximized]: maximized }]">
<div :class="$style.body" class="_shadow" @mousedown="onBodyMousedown" @keydown="onKeydown">
diff --git a/packages/frontend/src/components/MkYouTubePlayer.vue b/packages/frontend/src/components/MkYouTubePlayer.vue
index 4d765fe2f7..0edfd98efc 100644
--- a/packages/frontend/src/components/MkYouTubePlayer.vue
+++ b/packages/frontend/src/components/MkYouTubePlayer.vue
@@ -1,5 +1,5 @@
<template>
-<MkWindow :initial-width="640" :initial-height="402" :can-resize="true" :close-button="true">
+<MkWindow :initialWidth="640" :initialHeight="402" :canResize="true" :closeButton="true">
<template #header>
<i class="icon ti ti-brand-youtube" style="margin-right: 0.5em;"></i>
<span>{{ title ?? 'YouTube' }}</span>
diff --git a/packages/frontend/src/components/form/link.vue b/packages/frontend/src/components/form/link.vue
index a1775c0bdb..22b5edc3c9 100644
--- a/packages/frontend/src/components/form/link.vue
+++ b/packages/frontend/src/components/form/link.vue
@@ -1,19 +1,19 @@
<template>
-<div class="ffcbddfc" :class="{ inline }">
- <a v-if="external" class="main _button" :href="to" target="_blank">
- <span class="icon"><slot name="icon"></slot></span>
- <span class="text"><slot></slot></span>
- <span class="right">
- <span class="text"><slot name="suffix"></slot></span>
- <i class="ti ti-external-link icon"></i>
+<div :class="[$style.root, { [$style.inline]: inline }]">
+ <a v-if="external" :class="$style.main" class="_button" :href="to" target="_blank">
+ <span :class="$style.icon"><slot name="icon"></slot></span>
+ <span :class="$style.text"><slot></slot></span>
+ <span :class="$style.suffix">
+ <span :class="$style.suffixText"><slot name="suffix"></slot></span>
+ <i class="ti ti-external-link"></i>
</span>
</a>
- <MkA v-else class="main _button" :class="{ active }" :to="to" :behavior="behavior">
- <span class="icon"><slot name="icon"></slot></span>
- <span class="text"><slot></slot></span>
- <span class="right">
- <span class="text"><slot name="suffix"></slot></span>
- <i class="ti ti-chevron-right icon"></i>
+ <MkA v-else :class="[$style.main, { [$style.active]: active }]" class="_button" :to="to" :behavior="behavior">
+ <span :class="$style.icon"><slot name="icon"></slot></span>
+ <span :class="$style.text"><slot></slot></span>
+ <span :class="$style.suffix">
+ <span :class="$style.suffixText"><slot name="suffix"></slot></span>
+ <i class="ti ti-chevron-right"></i>
</span>
</MkA>
</div>
@@ -26,70 +26,70 @@ const props = defineProps<{
to: string;
active?: boolean;
external?: boolean;
- behavior?: null | 'window' | 'browser' | 'modalWindow';
+ behavior?: null | 'window' | 'browser';
inline?: boolean;
}>();
</script>
-<style lang="scss" scoped>
-.ffcbddfc {
+<style lang="scss" module>
+.root {
display: block;
&.inline {
display: inline-block;
}
+}
- > .main {
- display: flex;
- align-items: center;
- width: 100%;
- box-sizing: border-box;
- padding: 10px 14px;
- background: var(--buttonBg);
- border-radius: 6px;
- font-size: 0.9em;
+.main {
+ display: flex;
+ align-items: center;
+ width: 100%;
+ box-sizing: border-box;
+ padding: 10px 14px;
+ background: var(--buttonBg);
+ border-radius: 6px;
+ font-size: 0.9em;
- &:hover {
- text-decoration: none;
- background: var(--buttonHoverBg);
- }
+ &:hover {
+ text-decoration: none;
+ background: var(--buttonHoverBg);
+ }
- &.active {
- color: var(--accent);
- background: var(--buttonHoverBg);
- }
+ &.active {
+ color: var(--accent);
+ background: var(--buttonHoverBg);
+ }
+}
- > .icon {
- margin-right: 0.75em;
- flex-shrink: 0;
- text-align: center;
- color: var(--fgTransparentWeak);
+.icon {
+ margin-right: 0.75em;
+ flex-shrink: 0;
+ text-align: center;
+ color: var(--fgTransparentWeak);
- &:empty {
- display: none;
+ &:empty {
+ display: none;
- & + .text {
- padding-left: 4px;
- }
- }
+ & + .text {
+ padding-left: 4px;
}
+ }
+}
- > .text {
- flex-shrink: 1;
- white-space: normal;
- padding-right: 12px;
- text-align: center;
- }
+.text {
+ flex-shrink: 1;
+ white-space: normal;
+ padding-right: 12px;
+ text-align: center;
+}
- > .right {
- margin-left: auto;
- opacity: 0.7;
- white-space: nowrap;
+.suffix {
+ margin-left: auto;
+ opacity: 0.7;
+ white-space: nowrap;
- > .text:not(:empty) {
- margin-right: 0.75em;
- }
- }
+ > .suffixText:not(:empty) {
+ margin-right: 0.75em;
}
}
</style>
diff --git a/packages/frontend/src/components/form/slot.vue b/packages/frontend/src/components/form/slot.vue
index 79ce8fe51f..809d80620f 100644
--- a/packages/frontend/src/components/form/slot.vue
+++ b/packages/frontend/src/components/form/slot.vue
@@ -1,10 +1,10 @@
<template>
-<div class="adhpbeou">
- <div class="label" @click="focus"><slot name="label"></slot></div>
- <div class="content">
+<div>
+ <div :class="$style.label" @click="focus"><slot name="label"></slot></div>
+ <div>
<slot></slot>
</div>
- <div class="caption"><slot name="caption"></slot></div>
+ <div :class="$style.caption"><slot name="caption"></slot></div>
</div>
</template>
@@ -16,26 +16,24 @@ function focus() {
}
</script>
-<style lang="scss" scoped>
-.adhpbeou {
- > .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;
}
}
</style>
diff --git a/packages/frontend/src/components/form/suspense.vue b/packages/frontend/src/components/form/suspense.vue
index 3a44c3da3d..b3d8c22b27 100644
--- a/packages/frontend/src/components/form/suspense.vue
+++ b/packages/frontend/src/components/form/suspense.vue
@@ -1,102 +1,66 @@
<template>
-<Transition :name="defaultStore.state.animation ? 'fade' : ''" mode="out-in">
- <div v-if="pending">
- <MkLoading/>
+<div v-if="pending">
+ <MkLoading/>
+</div>
+<div v-else-if="resolved">
+ <slot :result="result"></slot>
+</div>
+<div v-else>
+ <div :class="$style.error">
+ <div><i class="ti ti-alert-triangle"></i> {{ i18n.ts.somethingHappened }}</div>
+ <MkButton inline style="margin-top: 16px;" @click="retry"><i class="ti ti-reload"></i> {{ i18n.ts.retry }}</MkButton>
</div>
- <div v-else-if="resolved">
- <slot :result="result"></slot>
- </div>
- <div v-else>
- <div class="wszdbhzo">
- <div><i class="ti ti-alert-triangle"></i> {{ i18n.ts.somethingHappened }}</div>
- <MkButton inline class="retry" @click="retry"><i class="ti ti-reload"></i> {{ i18n.ts.retry }}</MkButton>
- </div>
- </div>
-</Transition>
+</div>
</template>
-<script lang="ts">
-import { defineComponent, PropType, ref, watch } from 'vue';
+<script lang="ts" setup>
+import { ref, watch } from 'vue';
import MkButton from '@/components/MkButton.vue';
import { defaultStore } from '@/store';
import { i18n } from '@/i18n';
-export default defineComponent({
- components: {
- MkButton,
- },
-
- props: {
- p: {
- type: Function as PropType<() => Promise<any>>,
- required: true,
- },
- },
+const props = defineProps<{
+ p: () => Promise<any>;
+}>();
- setup(props, context) {
- const pending = ref(true);
- const resolved = ref(false);
- const rejected = ref(false);
- const result = ref(null);
+const pending = ref(true);
+const resolved = ref(false);
+const rejected = ref(false);
+const result = ref(null);
- const process = () => {
- if (props.p == null) {
- return;
- }
- const promise = props.p();
- pending.value = true;
- resolved.value = false;
- rejected.value = false;
- promise.then((_result) => {
- pending.value = false;
- resolved.value = true;
- result.value = _result;
- });
- promise.catch(() => {
- pending.value = false;
- rejected.value = true;
- });
- };
-
- watch(() => props.p, () => {
- process();
- }, {
- immediate: true,
- });
-
- const retry = () => {
- process();
- };
+const process = () => {
+ if (props.p == null) {
+ return;
+ }
+ const promise = props.p();
+ pending.value = true;
+ resolved.value = false;
+ rejected.value = false;
+ promise.then((_result) => {
+ pending.value = false;
+ resolved.value = true;
+ result.value = _result;
+ });
+ promise.catch(() => {
+ pending.value = false;
+ rejected.value = true;
+ });
+};
- return {
- pending,
- resolved,
- rejected,
- result,
- retry,
- defaultStore,
- i18n,
- };
- },
+watch(() => props.p, () => {
+ process();
+}, {
+ immediate: true,
});
-</script>
-<style lang="scss" scoped>
-.fade-enter-active,
-.fade-leave-active {
- transition: opacity 0.125s ease;
-}
-.fade-enter-from,
-.fade-leave-to {
- opacity: 0;
-}
+const retry = () => {
+ process();
+};
+</script>
-.wszdbhzo {
+<style lang="scss" module>
+.error {
padding: 16px;
text-align: center;
-
- > .retry {
- margin-top: 16px;
- }
}
</style>
diff --git a/packages/frontend/src/components/global/MkA.vue b/packages/frontend/src/components/global/MkA.vue
index 40d134dffb..4e608c6efe 100644
--- a/packages/frontend/src/components/global/MkA.vue
+++ b/packages/frontend/src/components/global/MkA.vue
@@ -15,7 +15,7 @@ import { useRouter } from '@/router';
const props = withDefaults(defineProps<{
to: string;
activeClass?: null | string;
- behavior?: null | 'window' | 'browser' | 'modalWindow';
+ behavior?: null | 'window' | 'browser';
}>(), {
activeClass: null,
behavior: null,
@@ -70,14 +70,6 @@ function openWindow() {
os.pageWindow(props.to);
}
-function modalWindow() {
- os.modalPageWindow(props.to);
-}
-
-function popout() {
- popout_(props.to);
-}
-
function nav(ev: MouseEvent) {
if (props.behavior === 'browser') {
location.href = props.to;
@@ -87,8 +79,6 @@ function nav(ev: MouseEvent) {
if (props.behavior) {
if (props.behavior === 'window') {
return openWindow();
- } else if (props.behavior === 'modalWindow') {
- return modalWindow();
}
}
diff --git a/packages/frontend/src/components/global/MkAcct.vue b/packages/frontend/src/components/global/MkAcct.vue
index 59358aef70..f93659f5ed 100644
--- a/packages/frontend/src/components/global/MkAcct.vue
+++ b/packages/frontend/src/components/global/MkAcct.vue
@@ -1,5 +1,5 @@
<template>
-<MkCondensedLine v-if="defaultStore.state.enableCondensedLineForAcct" :min-scale="2 / 3">
+<MkCondensedLine v-if="defaultStore.state.enableCondensedLineForAcct" :minScale="2 / 3">
<span>@{{ user.username }}</span>
<span v-if="user.host || detail || defaultStore.state.showFullAcct" style="opacity: 0.5;">@{{ user.host || host }}</span>
</MkCondensedLine>
diff --git a/packages/frontend/src/components/global/MkAd.vue b/packages/frontend/src/components/global/MkAd.vue
index aa975600f0..8b25ab1b6a 100644
--- a/packages/frontend/src/components/global/MkAd.vue
+++ b/packages/frontend/src/components/global/MkAd.vue
@@ -1,6 +1,14 @@
<template>
<div v-if="chosen && !shouldHide" :class="$style.root">
- <div v-if="!showMenu" :class="[$style.main, $style['form_' + chosen.place]]">
+ <div
+ v-if="!showMenu"
+ :class="[$style.main, {
+ [$style.form_square]: chosen.place === 'square',
+ [$style.form_horizontal]: chosen.place === 'horizontal',
+ [$style.form_horizontalBig]: chosen.place === 'horizontal-big',
+ [$style.form_vertical]: chosen.place === 'vertical',
+ }]"
+ >
<a :href="chosen.url" target="_blank" :class="$style.link">
<img :src="chosen.imageUrl" :class="$style.img">
<button class="_button" :class="$style.i" @click.prevent.stop="toggleMenu"><i :class="$style.iIcon" class="ti ti-info-circle"></i></button>
@@ -122,7 +130,7 @@ function reduceFrequency(): void {
}
}
- &.form_horizontal-big {
+ &.form_horizontalBig {
padding: 8px;
> .link,
diff --git a/packages/frontend/src/components/global/MkAvatar.vue b/packages/frontend/src/components/global/MkAvatar.vue
index 42abdcbdcc..422b35c9dd 100644
--- a/packages/frontend/src/components/global/MkAvatar.vue
+++ b/packages/frontend/src/components/global/MkAvatar.vue
@@ -1,6 +1,6 @@
<template>
<component :is="link ? MkA : 'span'" v-user-preview="preview ? user.id : undefined" v-bind="bound" class="_noSelect" :class="[$style.root, { [$style.animation]: animation, [$style.cat]: user.isCat, [$style.square]: squareAvatars }]" :style="{ color }" :title="acct(user)" @click="onClick">
- <img :class="$style.inner" :src="url" decoding="async"/>
+ <MkImgWithBlurhash :class="$style.inner" :src="url" :hash="user?.avatarBlurhash" :cover="true"/>
<MkUserOnlineIndicator v-if="indicator" :class="$style.indicator" :user="user"/>
<div v-if="user.isCat" :class="[$style.ears]">
<div :class="$style.earLeft">
@@ -24,6 +24,7 @@
<script lang="ts" setup>
import { watch } from 'vue';
import * as misskey from 'misskey-js';
+import MkImgWithBlurhash from '../MkImgWithBlurhash.vue';
import MkA from './MkA.vue';
import { getStaticImageUrl } from '@/scripts/media-proxy';
import { extractAvgColorFromBlurhash } from '@/scripts/extract-avg-color-from-blurhash';
diff --git a/packages/frontend/src/components/global/MkCondensedLine.vue b/packages/frontend/src/components/global/MkCondensedLine.vue
index 1d46ff1ec9..4b2e8e4750 100644
--- a/packages/frontend/src/components/global/MkCondensedLine.vue
+++ b/packages/frontend/src/components/global/MkCondensedLine.vue
@@ -13,13 +13,20 @@ interface Props {
const contentSymbol = Symbol();
const observer = new ResizeObserver((entries) => {
+ const results: {
+ container: HTMLSpanElement;
+ transform: string;
+ }[] = [];
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))})`;
+ results.push({ container, transform: `scaleX(${Math.max(props.minScale, Math.min(1, containerWidth / contentWidth))})` });
+ }
+ for (const result of results) {
+ result.container.style.transform = result.transform;
}
});
</script>
diff --git a/packages/frontend/src/components/global/MkCustomEmoji.vue b/packages/frontend/src/components/global/MkCustomEmoji.vue
index 0cb31ffcba..e8a7f17cc6 100644
--- a/packages/frontend/src/components/global/MkCustomEmoji.vue
+++ b/packages/frontend/src/components/global/MkCustomEmoji.vue
@@ -7,7 +7,7 @@
import { computed } from 'vue';
import { getProxiedImageUrl, getStaticImageUrl } from '@/scripts/media-proxy';
import { defaultStore } from '@/store';
-import { customEmojis } from '@/custom-emojis';
+import { customEmojisMap } from '@/custom-emojis';
const props = defineProps<{
name: string;
@@ -26,7 +26,7 @@ const rawUrl = computed(() => {
return props.url;
}
if (isLocal.value) {
- return customEmojis.value.find(x => x.name === customEmojiName.value)?.url ?? null;
+ return customEmojisMap.get(customEmojiName.value)?.url ?? null;
}
return props.host ? `/emoji/${customEmojiName.value}@${props.host}.webp` : `/emoji/${customEmojiName.value}.webp`;
});
diff --git a/packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.stories.impl.ts b/packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.stories.impl.ts
index f6811b6747..685b3b8b8e 100644
--- a/packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.stories.impl.ts
+++ b/packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.stories.impl.ts
@@ -1,8 +1,8 @@
/* eslint-disable @typescript-eslint/explicit-function-return-type */
import { StoryObj } from '@storybook/vue3';
-import MkMisskeyFlavoredMarkdown from './MkMisskeyFlavoredMarkdown.vue';
import { within } from '@storybook/testing-library';
import { expect } from '@storybook/jest';
+import MkMisskeyFlavoredMarkdown from './MkMisskeyFlavoredMarkdown.ts';
export const Default = {
render(args) {
return {
diff --git a/packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.ts b/packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.ts
new file mode 100644
index 0000000000..2a50a34390
--- /dev/null
+++ b/packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.ts
@@ -0,0 +1,367 @@
+import { VNode, h } from 'vue';
+import * as mfm from 'mfm-js';
+import * as Misskey from 'misskey-js';
+import MkUrl from '@/components/global/MkUrl.vue';
+import MkLink from '@/components/MkLink.vue';
+import MkMention from '@/components/MkMention.vue';
+import MkEmoji from '@/components/global/MkEmoji.vue';
+import MkCustomEmoji from '@/components/global/MkCustomEmoji.vue';
+import MkCode from '@/components/MkCode.vue';
+import MkGoogle from '@/components/MkGoogle.vue';
+import MkSparkle from '@/components/MkSparkle.vue';
+import MkA from '@/components/global/MkA.vue';
+import { host } from '@/config';
+import { defaultStore } from '@/store';
+
+const QUOTE_STYLE = `
+display: block;
+margin: 8px;
+padding: 6px 0 6px 12px;
+color: var(--fg);
+border-left: solid 3px var(--fg);
+opacity: 0.7;
+`.split('\n').join(' ');
+
+export default function(props: {
+ text: string;
+ plain?: boolean;
+ nowrap?: boolean;
+ author?: Misskey.entities.UserLite;
+ i?: Misskey.entities.UserLite;
+ isNote?: boolean;
+ emojiUrls?: string[];
+ rootScale?: number;
+}) {
+ const isNote = props.isNote !== undefined ? props.isNote : true;
+
+ if (props.text == null || props.text === '') return;
+
+ const ast = (props.plain ? mfm.parseSimple : mfm.parse)(props.text);
+
+ const validTime = (t: string | null | undefined) => {
+ if (t == null) return null;
+ return t.match(/^[0-9.]+s$/) ? t : null;
+ };
+
+ const useAnim = defaultStore.state.advancedMfm && defaultStore.state.animatedMfm;
+
+ /**
+ * Gen Vue Elements from MFM AST
+ * @param ast MFM AST
+ * @param scale How times large the text is
+ */
+ const genEl = (ast: mfm.MfmNode[], scale: number) => ast.map((token): VNode | string | (VNode | string)[] => {
+ switch (token.type) {
+ case 'text': {
+ const text = token.props.text.replace(/(\r\n|\n|\r)/g, '\n');
+
+ if (!props.plain) {
+ const res: (VNode | string)[] = [];
+ for (const t of text.split('\n')) {
+ res.push(h('br'));
+ res.push(t);
+ }
+ res.shift();
+ return res;
+ } else {
+ return [text.replace(/\n/g, ' ')];
+ }
+ }
+
+ case 'bold': {
+ return [h('b', genEl(token.children, scale))];
+ }
+
+ case 'strike': {
+ return [h('del', genEl(token.children, scale))];
+ }
+
+ case 'italic': {
+ return h('i', {
+ style: 'font-style: oblique;',
+ }, genEl(token.children, scale));
+ }
+
+ case 'fn': {
+ // TODO: CSSを文字列で組み立てていくと token.props.args.~~~ 経由でCSSインジェクションできるのでよしなにやる
+ let style;
+ switch (token.props.name) {
+ case 'tada': {
+ const speed = validTime(token.props.args.speed) ?? '1s';
+ style = 'font-size: 150%;' + (useAnim ? `animation: tada ${speed} linear infinite both;` : '');
+ break;
+ }
+ case 'jelly': {
+ const speed = validTime(token.props.args.speed) ?? '1s';
+ style = (useAnim ? `animation: mfm-rubberBand ${speed} linear infinite both;` : '');
+ break;
+ }
+ case 'twitch': {
+ const speed = validTime(token.props.args.speed) ?? '0.5s';
+ style = useAnim ? `animation: mfm-twitch ${speed} ease infinite;` : '';
+ break;
+ }
+ case 'shake': {
+ const speed = validTime(token.props.args.speed) ?? '0.5s';
+ style = useAnim ? `animation: mfm-shake ${speed} ease infinite;` : '';
+ break;
+ }
+ case 'spin': {
+ const direction =
+ token.props.args.left ? 'reverse' :
+ token.props.args.alternate ? 'alternate' :
+ 'normal';
+ const anime =
+ token.props.args.x ? 'mfm-spinX' :
+ token.props.args.y ? 'mfm-spinY' :
+ 'mfm-spin';
+ const speed = validTime(token.props.args.speed) ?? '1.5s';
+ style = useAnim ? `animation: ${anime} ${speed} linear infinite; animation-direction: ${direction};` : '';
+ break;
+ }
+ case 'jump': {
+ const speed = validTime(token.props.args.speed) ?? '0.75s';
+ style = useAnim ? `animation: mfm-jump ${speed} linear infinite;` : '';
+ break;
+ }
+ case 'bounce': {
+ const speed = validTime(token.props.args.speed) ?? '0.75s';
+ style = useAnim ? `animation: mfm-bounce ${speed} linear infinite; transform-origin: center bottom;` : '';
+ break;
+ }
+ case 'flip': {
+ const transform =
+ (token.props.args.h && token.props.args.v) ? 'scale(-1, -1)' :
+ token.props.args.v ? 'scaleY(-1)' :
+ 'scaleX(-1)';
+ style = `transform: ${transform};`;
+ break;
+ }
+ case 'x2': {
+ return h('span', {
+ class: defaultStore.state.advancedMfm ? 'mfm-x2' : '',
+ }, genEl(token.children, scale * 2));
+ }
+ case 'x3': {
+ return h('span', {
+ class: defaultStore.state.advancedMfm ? 'mfm-x3' : '',
+ }, genEl(token.children, scale * 3));
+ }
+ case 'x4': {
+ return h('span', {
+ class: defaultStore.state.advancedMfm ? 'mfm-x4' : '',
+ }, genEl(token.children, scale * 4));
+ }
+ case 'font': {
+ const family =
+ token.props.args.serif ? 'serif' :
+ token.props.args.monospace ? 'monospace' :
+ token.props.args.cursive ? 'cursive' :
+ token.props.args.fantasy ? 'fantasy' :
+ token.props.args.emoji ? 'emoji' :
+ token.props.args.math ? 'math' :
+ null;
+ if (family) style = `font-family: ${family};`;
+ break;
+ }
+ case 'blur': {
+ return h('span', {
+ class: '_mfm_blur_',
+ }, genEl(token.children, scale));
+ }
+ case 'rainbow': {
+ const speed = validTime(token.props.args.speed) ?? '1s';
+ style = useAnim ? `animation: mfm-rainbow ${speed} linear infinite;` : '';
+ break;
+ }
+ case 'sparkle': {
+ if (!useAnim) {
+ return genEl(token.children, scale);
+ }
+ return h(MkSparkle, {}, genEl(token.children, scale));
+ }
+ case 'rotate': {
+ const degrees = parseFloat(token.props.args.deg ?? '90');
+ style = `transform: rotate(${degrees}deg); transform-origin: center center;`;
+ break;
+ }
+ case 'position': {
+ if (!defaultStore.state.advancedMfm) break;
+ const x = parseFloat(token.props.args.x ?? '0');
+ const y = parseFloat(token.props.args.y ?? '0');
+ style = `transform: translateX(${x}em) translateY(${y}em);`;
+ break;
+ }
+ case 'scale': {
+ if (!defaultStore.state.advancedMfm) {
+ style = '';
+ break;
+ }
+ const x = Math.min(parseFloat(token.props.args.x ?? '1'), 5);
+ const y = Math.min(parseFloat(token.props.args.y ?? '1'), 5);
+ style = `transform: scale(${x}, ${y});`;
+ scale = scale * Math.max(x, y);
+ break;
+ }
+ case 'fg': {
+ let color = token.props.args.color;
+ if (!/^[0-9a-f]{3,6}$/i.test(color)) color = 'f00';
+ style = `color: #${color};`;
+ break;
+ }
+ case 'bg': {
+ let color = token.props.args.color;
+ if (!/^[0-9a-f]{3,6}$/i.test(color)) color = 'f00';
+ style = `background-color: #${color};`;
+ break;
+ }
+ }
+ if (style == null) {
+ return h('span', {}, ['$[', token.props.name, ' ', ...genEl(token.children, scale), ']']);
+ } else {
+ return h('span', {
+ style: 'display: inline-block; ' + style,
+ }, genEl(token.children, scale));
+ }
+ }
+
+ case 'small': {
+ return [h('small', {
+ style: 'opacity: 0.7;',
+ }, genEl(token.children, scale))];
+ }
+
+ case 'center': {
+ return [h('div', {
+ style: 'text-align:center;',
+ }, genEl(token.children, scale))];
+ }
+
+ case 'url': {
+ return [h(MkUrl, {
+ key: Math.random(),
+ url: token.props.url,
+ rel: 'nofollow noopener',
+ })];
+ }
+
+ case 'link': {
+ return [h(MkLink, {
+ key: Math.random(),
+ url: token.props.url,
+ rel: 'nofollow noopener',
+ }, genEl(token.children, scale))];
+ }
+
+ case 'mention': {
+ return [h(MkMention, {
+ key: Math.random(),
+ host: (token.props.host == null && props.author && props.author.host != null ? props.author.host : token.props.host) || host,
+ username: token.props.username,
+ })];
+ }
+
+ case 'hashtag': {
+ return [h(MkA, {
+ key: Math.random(),
+ to: isNote ? `/tags/${encodeURIComponent(token.props.hashtag)}` : `/user-tags/${encodeURIComponent(token.props.hashtag)}`,
+ style: 'color:var(--hashtag);',
+ }, `#${token.props.hashtag}`)];
+ }
+
+ case 'blockCode': {
+ return [h(MkCode, {
+ key: Math.random(),
+ code: token.props.code,
+ lang: token.props.lang,
+ })];
+ }
+
+ case 'inlineCode': {
+ return [h(MkCode, {
+ key: Math.random(),
+ code: token.props.code,
+ inline: true,
+ })];
+ }
+
+ case 'quote': {
+ if (!props.nowrap) {
+ return [h('div', {
+ style: QUOTE_STYLE,
+ }, genEl(token.children, scale))];
+ } else {
+ return [h('span', {
+ style: QUOTE_STYLE,
+ }, genEl(token.children, scale))];
+ }
+ }
+
+ case 'emojiCode': {
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+ if (props.author?.host == null) {
+ return [h(MkCustomEmoji, {
+ key: Math.random(),
+ name: token.props.name,
+ normal: props.plain,
+ host: null,
+ useOriginalSize: scale >= 2.5,
+ })];
+ } else {
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+ if (props.emojiUrls && (props.emojiUrls[token.props.name] == null)) {
+ return [h('span', `:${token.props.name}:`)];
+ } else {
+ return [h(MkCustomEmoji, {
+ key: Math.random(),
+ name: token.props.name,
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+ url: props.emojiUrls ? props.emojiUrls[token.props.name] : null,
+ normal: props.plain,
+ host: props.author.host,
+ useOriginalSize: scale >= 2.5,
+ })];
+ }
+ }
+ }
+
+ case 'unicodeEmoji': {
+ return [h(MkEmoji, {
+ key: Math.random(),
+ emoji: token.props.emoji,
+ })];
+ }
+
+ case 'mathInline': {
+ return [h('code', token.props.formula)];
+ }
+
+ case 'mathBlock': {
+ return [h('code', token.props.formula)];
+ }
+
+ case 'search': {
+ return [h(MkGoogle, {
+ key: Math.random(),
+ q: token.props.query,
+ })];
+ }
+
+ case 'plain': {
+ return [h('span', genEl(token.children, scale))];
+ }
+
+ default: {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ console.error('unrecognized ast type:', (token as any).type);
+
+ return [];
+ }
+ }
+ }).flat(Infinity) as (VNode | string)[];
+
+ return h('span', {
+ // https://codeday.me/jp/qa/20190424/690106.html
+ style: props.nowrap ? 'white-space: pre; word-wrap: normal; overflow: hidden; text-overflow: ellipsis;' : 'white-space: pre-wrap;',
+ }, genEl(ast, props.rootScale ?? 1));
+}
diff --git a/packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.vue b/packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.vue
deleted file mode 100644
index 28a0d1c986..0000000000
--- a/packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.vue
+++ /dev/null
@@ -1,171 +0,0 @@
-<template>
-<MfmCore :text="text" :plain="plain" :nowrap="nowrap" :author="author" :is-note="isNote" :class="[$style.root, { [$style.nowrap]: nowrap }]"/>
-</template>
-
-<script lang="ts" setup>
-import { } from 'vue';
-import MfmCore from '@/components/mfm';
-
-const props = withDefaults(defineProps<{
- text: string;
- plain?: boolean;
- nowrap?: boolean;
- author?: any;
- isNote?: boolean;
-}>(), {
- plain: false,
- nowrap: false,
- author: null,
- isNote: true,
-});
-</script>
-
-<style lang="scss">
-._mfm_blur_ {
- filter: blur(6px);
- transition: filter 0.3s;
-
- &:hover {
- filter: blur(0px);
- }
-}
-
-.mfm-x2 {
- --mfm-zoom-size: 200%;
-}
-
-.mfm-x3 {
- --mfm-zoom-size: 400%;
-}
-
-.mfm-x4 {
- --mfm-zoom-size: 600%;
-}
-
-.mfm-x2, .mfm-x3, .mfm-x4 {
- font-size: var(--mfm-zoom-size);
-
- .mfm-x2, .mfm-x3, .mfm-x4 {
- /* only half effective */
- font-size: calc(var(--mfm-zoom-size) / 2 + 50%);
-
- .mfm-x2, .mfm-x3, .mfm-x4 {
- /* disabled */
- font-size: 100%;
- }
- }
-}
-
-@keyframes mfm-spin {
- 0% { transform: rotate(0deg); }
- 100% { transform: rotate(360deg); }
-}
-
-@keyframes mfm-spinX {
- 0% { transform: perspective(128px) rotateX(0deg); }
- 100% { transform: perspective(128px) rotateX(360deg); }
-}
-
-@keyframes mfm-spinY {
- 0% { transform: perspective(128px) rotateY(0deg); }
- 100% { transform: perspective(128px) rotateY(360deg); }
-}
-
-@keyframes mfm-jump {
- 0% { transform: translateY(0); }
- 25% { transform: translateY(-16px); }
- 50% { transform: translateY(0); }
- 75% { transform: translateY(-8px); }
- 100% { transform: translateY(0); }
-}
-
-@keyframes mfm-bounce {
- 0% { transform: translateY(0) scale(1, 1); }
- 25% { transform: translateY(-16px) scale(1, 1); }
- 50% { transform: translateY(0) scale(1, 1); }
- 75% { transform: translateY(0) scale(1.5, 0.75); }
- 100% { transform: translateY(0) scale(1, 1); }
-}
-
-// const val = () => `translate(${Math.floor(Math.random() * 20) - 10}px, ${Math.floor(Math.random() * 20) - 10}px)`;
-// let css = '';
-// for (let i = 0; i <= 100; i += 5) { css += `${i}% { transform: ${val()} }\n`; }
-@keyframes mfm-twitch {
- 0% { transform: translate(7px, -2px) }
- 5% { transform: translate(-3px, 1px) }
- 10% { transform: translate(-7px, -1px) }
- 15% { transform: translate(0px, -1px) }
- 20% { transform: translate(-8px, 6px) }
- 25% { transform: translate(-4px, -3px) }
- 30% { transform: translate(-4px, -6px) }
- 35% { transform: translate(-8px, -8px) }
- 40% { transform: translate(4px, 6px) }
- 45% { transform: translate(-3px, 1px) }
- 50% { transform: translate(2px, -10px) }
- 55% { transform: translate(-7px, 0px) }
- 60% { transform: translate(-2px, 4px) }
- 65% { transform: translate(3px, -8px) }
- 70% { transform: translate(6px, 7px) }
- 75% { transform: translate(-7px, -2px) }
- 80% { transform: translate(-7px, -8px) }
- 85% { transform: translate(9px, 3px) }
- 90% { transform: translate(-3px, -2px) }
- 95% { transform: translate(-10px, 2px) }
- 100% { transform: translate(-2px, -6px) }
-}
-
-// const val = () => `translate(${Math.floor(Math.random() * 6) - 3}px, ${Math.floor(Math.random() * 6) - 3}px) rotate(${Math.floor(Math.random() * 24) - 12}deg)`;
-// let css = '';
-// for (let i = 0; i <= 100; i += 5) { css += `${i}% { transform: ${val()} }\n`; }
-@keyframes mfm-shake {
- 0% { transform: translate(-3px, -1px) rotate(-8deg) }
- 5% { transform: translate(0px, -1px) rotate(-10deg) }
- 10% { transform: translate(1px, -3px) rotate(0deg) }
- 15% { transform: translate(1px, 1px) rotate(11deg) }
- 20% { transform: translate(-2px, 1px) rotate(1deg) }
- 25% { transform: translate(-1px, -2px) rotate(-2deg) }
- 30% { transform: translate(-1px, 2px) rotate(-3deg) }
- 35% { transform: translate(2px, 1px) rotate(6deg) }
- 40% { transform: translate(-2px, -3px) rotate(-9deg) }
- 45% { transform: translate(0px, -1px) rotate(-12deg) }
- 50% { transform: translate(1px, 2px) rotate(10deg) }
- 55% { transform: translate(0px, -3px) rotate(8deg) }
- 60% { transform: translate(1px, -1px) rotate(8deg) }
- 65% { transform: translate(0px, -1px) rotate(-7deg) }
- 70% { transform: translate(-1px, -3px) rotate(6deg) }
- 75% { transform: translate(0px, -2px) rotate(4deg) }
- 80% { transform: translate(-2px, -1px) rotate(3deg) }
- 85% { transform: translate(1px, -3px) rotate(-10deg) }
- 90% { transform: translate(1px, 0px) rotate(3deg) }
- 95% { transform: translate(-2px, 0px) rotate(-3deg) }
- 100% { transform: translate(2px, 1px) rotate(2deg) }
-}
-
-@keyframes mfm-rubberBand {
- from { transform: scale3d(1, 1, 1); }
- 30% { transform: scale3d(1.25, 0.75, 1); }
- 40% { transform: scale3d(0.75, 1.25, 1); }
- 50% { transform: scale3d(1.15, 0.85, 1); }
- 65% { transform: scale3d(0.95, 1.05, 1); }
- 75% { transform: scale3d(1.05, 0.95, 1); }
- to { transform: scale3d(1, 1, 1); }
-}
-
-@keyframes mfm-rainbow {
- 0% { filter: hue-rotate(0deg) contrast(150%) saturate(150%); }
- 100% { filter: hue-rotate(360deg) contrast(150%) saturate(150%); }
-}
-</style>
-
-<style lang="scss" module>
-.root {
- white-space: pre-wrap;
-
- &.nowrap {
- white-space: pre;
- word-wrap: normal; // https://codeday.me/jp/qa/20190424/690106.html
- overflow: hidden;
- text-overflow: ellipsis;
- }
-}
-</style>
diff --git a/packages/frontend/src/components/global/MkPageHeader.tabs.vue b/packages/frontend/src/components/global/MkPageHeader.tabs.vue
index 9e1da64e61..d71343baf9 100644
--- a/packages/frontend/src/components/global/MkPageHeader.tabs.vue
+++ b/packages/frontend/src/components/global/MkPageHeader.tabs.vue
@@ -15,8 +15,8 @@
{{ t.title }}
</div>
<Transition
- v-else mode="in-out" @enter="enter" @after-enter="afterEnter" @leave="leave"
- @after-leave="afterLeave"
+ v-else mode="in-out" @enter="enter" @afterEnter="afterEnter" @leave="leave"
+ @afterLeave="afterLeave"
>
<div v-show="t.key === tab" :class="[$style.tabTitle, $style.animate]">{{ t.title }}</div>
</Transition>
diff --git a/packages/frontend/src/components/global/MkPageHeader.vue b/packages/frontend/src/components/global/MkPageHeader.vue
index b91d378b17..0a21d39bca 100644
--- a/packages/frontend/src/components/global/MkPageHeader.vue
+++ b/packages/frontend/src/components/global/MkPageHeader.vue
@@ -21,7 +21,7 @@
</div>
</div>
</div>
- <XTabs v-if="!narrow || hideTitle" :class="$style.tabs" :tab="tab" :tabs="tabs" :root-el="el" @update:tab="key => emit('update:tab', key)" @tab-click="onTabClick"/>
+ <XTabs v-if="!narrow || hideTitle" :class="$style.tabs" :tab="tab" :tabs="tabs" :rootEl="el" @update:tab="key => emit('update:tab', key)" @tabClick="onTabClick"/>
</template>
<div v-if="(!thin_ && narrow && !hideTitle) || (actions && actions.length > 0)" :class="$style.buttonsRight">
<template v-for="action in actions">
@@ -30,7 +30,7 @@
</div>
</div>
<div v-if="(narrow && !hideTitle) && hasTabs" :class="[$style.lower, { [$style.slim]: narrow, [$style.thin]: thin_ }]">
- <XTabs :class="$style.tabs" :tab="tab" :tabs="tabs" :root-el="el" @update:tab="key => emit('update:tab', key)" @tab-click="onTabClick"/>
+ <XTabs :class="$style.tabs" :tab="tab" :tabs="tabs" :rootEl="el" @update:tab="key => emit('update:tab', key)" @tabClick="onTabClick"/>
</div>
</div>
</template>
diff --git a/packages/frontend/src/components/global/MkStickyContainer.vue b/packages/frontend/src/components/global/MkStickyContainer.vue
index 44c02088da..e5dba54b4e 100644
--- a/packages/frontend/src/components/global/MkStickyContainer.vue
+++ b/packages/frontend/src/components/global/MkStickyContainer.vue
@@ -14,6 +14,7 @@
<script lang="ts" setup>
import { onMounted, onUnmounted, provide, inject, Ref, ref, watch } from 'vue';
+import { $$ } from 'vue/macros';
import { CURRENT_STICKY_BOTTOM, CURRENT_STICKY_TOP } from '@/const';
const rootEl = $shallowRef<HTMLElement>();
@@ -83,8 +84,8 @@ onMounted(() => {
onUnmounted(() => {
observer.disconnect();
});
-</script>
-
-<style lang="scss" module>
-</style>
+defineExpose({
+ rootEl: $$(rootEl),
+});
+</script>
diff --git a/packages/frontend/src/components/global/MkTime.vue b/packages/frontend/src/components/global/MkTime.vue
index 261cc0ee18..dfc3c89798 100644
--- a/packages/frontend/src/components/global/MkTime.vue
+++ b/packages/frontend/src/components/global/MkTime.vue
@@ -58,7 +58,6 @@ function tick() {
if (props.mode === 'relative' || props.mode === 'detail') {
tick();
-
onUnmounted(() => {
window.clearTimeout(tickId);
});
diff --git a/packages/frontend/src/components/global/MkUrl.vue b/packages/frontend/src/components/global/MkUrl.vue
index 2a92780306..c1efd9a06b 100644
--- a/packages/frontend/src/components/global/MkUrl.vue
+++ b/packages/frontend/src/components/global/MkUrl.vue
@@ -6,7 +6,7 @@
<template v-if="!self">
<span :class="$style.schema">{{ schema }}//</span>
<span :class="$style.hostname">{{ hostname }}</span>
- <span v-if="port != ''" :class="$style.port">:{{ port }}</span>
+ <span v-if="port != ''">:{{ port }}</span>
</template>
<template v-if="pathname === '/' && self">
<span :class="$style.self">{{ hostname }}</span>
diff --git a/packages/frontend/src/components/global/MkUserName.vue b/packages/frontend/src/components/global/MkUserName.vue
index 4186a4a4fb..c9e85c5460 100644
--- a/packages/frontend/src/components/global/MkUserName.vue
+++ b/packages/frontend/src/components/global/MkUserName.vue
@@ -1,5 +1,5 @@
<template>
-<Mfm :text="user.name ?? user.username" :author="user" :plain="true" :nowrap="nowrap" :emoji-urls="user.emojis"/>
+<Mfm :text="user.name ?? user.username" :author="user" :plain="true" :nowrap="nowrap" :emojiUrls="user.emojis"/>
</template>
<script lang="ts" setup>
diff --git a/packages/frontend/src/components/global/i18n.ts b/packages/frontend/src/components/global/i18n.ts
index 1fd293ba10..2708b759aa 100644
--- a/packages/frontend/src/components/global/i18n.ts
+++ b/packages/frontend/src/components/global/i18n.ts
@@ -1,42 +1,24 @@
-import { h, defineComponent } from 'vue';
+import { h } from 'vue';
-export default defineComponent({
- props: {
- src: {
- type: String,
- required: true,
- },
- tag: {
- type: String,
- required: false,
- default: 'span',
- },
- textTag: {
- type: String,
- required: false,
- default: null,
- },
- },
- render() {
- let str = this.src;
- const parsed = [] as (string | { arg: string; })[];
- while (true) {
- const nextBracketOpen = str.indexOf('{');
- const nextBracketClose = str.indexOf('}');
+export default function(props: { src: string; tag?: string; textTag?: string; }, { slots }) {
+ let str = props.src;
+ const parsed = [] as (string | { arg: string; })[];
+ while (true) {
+ const nextBracketOpen = str.indexOf('{');
+ const nextBracketClose = str.indexOf('}');
- if (nextBracketOpen === -1) {
- parsed.push(str);
- break;
- } else {
- if (nextBracketOpen > 0) parsed.push(str.substr(0, nextBracketOpen));
- parsed.push({
- arg: str.substring(nextBracketOpen + 1, nextBracketClose),
- });
- }
-
- str = str.substr(nextBracketClose + 1);
+ if (nextBracketOpen === -1) {
+ parsed.push(str);
+ break;
+ } else {
+ if (nextBracketOpen > 0) parsed.push(str.substr(0, nextBracketOpen));
+ parsed.push({
+ arg: str.substring(nextBracketOpen + 1, nextBracketClose),
+ });
}
- return h(this.tag, parsed.map(x => typeof x === 'string' ? (this.textTag ? h(this.textTag, x) : x) : this.$slots[x.arg]()));
- },
-});
+ str = str.substr(nextBracketClose + 1);
+ }
+
+ return h(props.tag ?? 'span', parsed.map(x => typeof x === 'string' ? (props.textTag ? h(props.textTag, x) : x) : slots[x.arg]()));
+}
diff --git a/packages/frontend/src/components/index.ts b/packages/frontend/src/components/index.ts
index 4ef8111da9..ee2a2bc7bd 100644
--- a/packages/frontend/src/components/index.ts
+++ b/packages/frontend/src/components/index.ts
@@ -1,6 +1,6 @@
import { App } from 'vue';
-import Mfm from './global/MkMisskeyFlavoredMarkdown.vue';
+import Mfm from './global/MkMisskeyFlavoredMarkdown.ts';
import MkA from './global/MkA.vue';
import MkAcct from './global/MkAcct.vue';
import MkAvatar from './global/MkAvatar.vue';
diff --git a/packages/frontend/src/components/mfm.ts b/packages/frontend/src/components/mfm.ts
deleted file mode 100644
index c3c07b5834..0000000000
--- a/packages/frontend/src/components/mfm.ts
+++ /dev/null
@@ -1,390 +0,0 @@
-import { VNode, defineComponent, h } from 'vue';
-import * as mfm from 'mfm-js';
-import MkUrl from '@/components/global/MkUrl.vue';
-import MkLink from '@/components/MkLink.vue';
-import MkMention from '@/components/MkMention.vue';
-import MkEmoji from '@/components/global/MkEmoji.vue';
-import MkCustomEmoji from '@/components/global/MkCustomEmoji.vue';
-import MkCode from '@/components/MkCode.vue';
-import MkGoogle from '@/components/MkGoogle.vue';
-import MkSparkle from '@/components/MkSparkle.vue';
-import MkA from '@/components/global/MkA.vue';
-import { host } from '@/config';
-import { defaultStore } from '@/store';
-
-const QUOTE_STYLE = `
-display: block;
-margin: 8px;
-padding: 6px 0 6px 12px;
-color: var(--fg);
-border-left: solid 3px var(--fg);
-opacity: 0.7;
-`.split('\n').join(' ');
-
-export default defineComponent({
- props: {
- text: {
- type: String,
- required: true,
- },
- plain: {
- type: Boolean,
- default: false,
- },
- nowrap: {
- type: Boolean,
- default: false,
- },
- author: {
- type: Object,
- default: null,
- },
- i: {
- type: Object,
- default: null,
- },
- isNote: {
- type: Boolean,
- default: true,
- },
- emojiUrls: {
- type: Object,
- default: null,
- },
- rootScale: {
- type: Number,
- default: 1,
- }
- },
-
- render() {
- if (this.text == null || this.text === '') return;
-
- const ast = (this.plain ? mfm.parseSimple : mfm.parse)(this.text);
-
- const validTime = (t: string | null | undefined) => {
- if (t == null) return null;
- return t.match(/^[0-9.]+s$/) ? t : null;
- };
-
- const useAnim = defaultStore.state.advancedMfm && defaultStore.state.animatedMfm;
-
- /**
- * Gen Vue Elements from MFM AST
- * @param ast MFM AST
- * @param scale How times large the text is
- */
- const genEl = (ast: mfm.MfmNode[], scale: number) => ast.map((token): VNode | string | (VNode | string)[] => {
- switch (token.type) {
- case 'text': {
- const text = token.props.text.replace(/(\r\n|\n|\r)/g, '\n');
-
- if (!this.plain) {
- const res: (VNode | string)[] = [];
- for (const t of text.split('\n')) {
- res.push(h('br'));
- res.push(t);
- }
- res.shift();
- return res;
- } else {
- return [text.replace(/\n/g, ' ')];
- }
- }
-
- case 'bold': {
- return [h('b', genEl(token.children, scale))];
- }
-
- case 'strike': {
- return [h('del', genEl(token.children, scale))];
- }
-
- case 'italic': {
- return h('i', {
- style: 'font-style: oblique;',
- }, genEl(token.children, scale));
- }
-
- case 'fn': {
- // TODO: CSSを文字列で組み立てていくと token.props.args.~~~ 経由でCSSインジェクションできるのでよしなにやる
- let style;
- switch (token.props.name) {
- case 'tada': {
- const speed = validTime(token.props.args.speed) ?? '1s';
- style = 'font-size: 150%;' + (useAnim ? `animation: tada ${speed} linear infinite both;` : '');
- break;
- }
- case 'jelly': {
- const speed = validTime(token.props.args.speed) ?? '1s';
- style = (useAnim ? `animation: mfm-rubberBand ${speed} linear infinite both;` : '');
- break;
- }
- case 'twitch': {
- const speed = validTime(token.props.args.speed) ?? '0.5s';
- style = useAnim ? `animation: mfm-twitch ${speed} ease infinite;` : '';
- break;
- }
- case 'shake': {
- const speed = validTime(token.props.args.speed) ?? '0.5s';
- style = useAnim ? `animation: mfm-shake ${speed} ease infinite;` : '';
- break;
- }
- case 'spin': {
- const direction =
- token.props.args.left ? 'reverse' :
- token.props.args.alternate ? 'alternate' :
- 'normal';
- const anime =
- token.props.args.x ? 'mfm-spinX' :
- token.props.args.y ? 'mfm-spinY' :
- 'mfm-spin';
- const speed = validTime(token.props.args.speed) ?? '1.5s';
- style = useAnim ? `animation: ${anime} ${speed} linear infinite; animation-direction: ${direction};` : '';
- break;
- }
- case 'jump': {
- const speed = validTime(token.props.args.speed) ?? '0.75s';
- style = useAnim ? `animation: mfm-jump ${speed} linear infinite;` : '';
- break;
- }
- case 'bounce': {
- const speed = validTime(token.props.args.speed) ?? '0.75s';
- style = useAnim ? `animation: mfm-bounce ${speed} linear infinite; transform-origin: center bottom;` : '';
- break;
- }
- case 'flip': {
- const transform =
- (token.props.args.h && token.props.args.v) ? 'scale(-1, -1)' :
- token.props.args.v ? 'scaleY(-1)' :
- 'scaleX(-1)';
- style = `transform: ${transform};`;
- break;
- }
- case 'x2': {
- return h('span', {
- class: defaultStore.state.advancedMfm ? 'mfm-x2' : '',
- }, genEl(token.children, scale * 2));
- }
- case 'x3': {
- return h('span', {
- class: defaultStore.state.advancedMfm ? 'mfm-x3' : '',
- }, genEl(token.children, scale * 3));
- }
- case 'x4': {
- return h('span', {
- class: defaultStore.state.advancedMfm ? 'mfm-x4' : '',
- }, genEl(token.children, scale * 4));
- }
- case 'font': {
- const family =
- token.props.args.serif ? 'serif' :
- token.props.args.monospace ? 'monospace' :
- token.props.args.cursive ? 'cursive' :
- token.props.args.fantasy ? 'fantasy' :
- token.props.args.emoji ? 'emoji' :
- token.props.args.math ? 'math' :
- null;
- if (family) style = `font-family: ${family};`;
- break;
- }
- case 'blur': {
- return h('span', {
- class: '_mfm_blur_',
- }, genEl(token.children, scale));
- }
- case 'rainbow': {
- const speed = validTime(token.props.args.speed) ?? '1s';
- style = useAnim ? `animation: mfm-rainbow ${speed} linear infinite;` : '';
- break;
- }
- case 'sparkle': {
- if (!useAnim) {
- return genEl(token.children, scale);
- }
- return h(MkSparkle, {}, genEl(token.children, scale));
- }
- case 'rotate': {
- const degrees = parseFloat(token.props.args.deg ?? '90');
- style = `transform: rotate(${degrees}deg); transform-origin: center center;`;
- break;
- }
- case 'position': {
- if (!defaultStore.state.advancedMfm) break;
- const x = parseFloat(token.props.args.x ?? '0');
- const y = parseFloat(token.props.args.y ?? '0');
- style = `transform: translateX(${x}em) translateY(${y}em);`;
- break;
- }
- case 'scale': {
- if (!defaultStore.state.advancedMfm) {
- style = '';
- break;
- }
- const x = Math.min(parseFloat(token.props.args.x ?? '1'), 5);
- const y = Math.min(parseFloat(token.props.args.y ?? '1'), 5);
- style = `transform: scale(${x}, ${y});`;
- scale = scale * Math.max(x, y);
- break;
- }
- case 'fg': {
- let color = token.props.args.color;
- if (!/^[0-9a-f]{3,6}$/i.test(color)) color = 'f00';
- style = `color: #${color};`;
- break;
- }
- case 'bg': {
- let color = token.props.args.color;
- if (!/^[0-9a-f]{3,6}$/i.test(color)) color = 'f00';
- style = `background-color: #${color};`;
- break;
- }
- }
- if (style == null) {
- return h('span', {}, ['$[', token.props.name, ' ', ...genEl(token.children, scale), ']']);
- } else {
- return h('span', {
- style: 'display: inline-block; ' + style,
- }, genEl(token.children, scale));
- }
- }
-
- case 'small': {
- return [h('small', {
- style: 'opacity: 0.7;',
- }, genEl(token.children, scale))];
- }
-
- case 'center': {
- return [h('div', {
- style: 'text-align:center;',
- }, genEl(token.children, scale))];
- }
-
- case 'url': {
- return [h(MkUrl, {
- key: Math.random(),
- url: token.props.url,
- rel: 'nofollow noopener',
- })];
- }
-
- case 'link': {
- return [h(MkLink, {
- key: Math.random(),
- url: token.props.url,
- rel: 'nofollow noopener',
- }, genEl(token.children, scale))];
- }
-
- case 'mention': {
- return [h(MkMention, {
- key: Math.random(),
- host: (token.props.host == null && this.author && this.author.host != null ? this.author.host : token.props.host) || host,
- username: token.props.username,
- })];
- }
-
- case 'hashtag': {
- return [h(MkA, {
- key: Math.random(),
- to: this.isNote ? `/tags/${encodeURIComponent(token.props.hashtag)}` : `/user-tags/${encodeURIComponent(token.props.hashtag)}`,
- style: 'color:var(--hashtag);',
- }, `#${token.props.hashtag}`)];
- }
-
- case 'blockCode': {
- return [h(MkCode, {
- key: Math.random(),
- code: token.props.code,
- lang: token.props.lang,
- })];
- }
-
- case 'inlineCode': {
- return [h(MkCode, {
- key: Math.random(),
- code: token.props.code,
- inline: true,
- })];
- }
-
- case 'quote': {
- if (!this.nowrap) {
- return [h('div', {
- style: QUOTE_STYLE,
- }, genEl(token.children, scale))];
- } else {
- return [h('span', {
- style: QUOTE_STYLE,
- }, genEl(token.children, scale))];
- }
- }
-
- case 'emojiCode': {
- // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
- if (this.author?.host == null) {
- return [h(MkCustomEmoji, {
- key: Math.random(),
- name: token.props.name,
- normal: this.plain,
- host: null,
- useOriginalSize: scale >= 2.5,
- })];
- } else {
- // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
- if (this.emojiUrls && (this.emojiUrls[token.props.name] == null)) {
- return [h('span', `:${token.props.name}:`)];
- } else {
- return [h(MkCustomEmoji, {
- key: Math.random(),
- name: token.props.name,
- // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
- url: this.emojiUrls ? this.emojiUrls[token.props.name] : null,
- normal: this.plain,
- host: this.author.host,
- useOriginalSize: scale >= 2.5,
- })];
- }
- }
- }
-
- case 'unicodeEmoji': {
- return [h(MkEmoji, {
- key: Math.random(),
- emoji: token.props.emoji,
- })];
- }
-
- case 'mathInline': {
- return [h('code', token.props.formula)];
- }
-
- case 'mathBlock': {
- return [h('code', token.props.formula)];
- }
-
- case 'search': {
- return [h(MkGoogle, {
- key: Math.random(),
- q: token.props.query,
- })];
- }
-
- case 'plain': {
- return [h('span', genEl(token.children, scale))];
- }
-
- default: {
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- console.error('unrecognized ast type:', (token as any).type);
-
- return [];
- }
- }
- }).flat(Infinity) as (VNode | string)[];
-
- // Parse ast to DOM
- return h('span', genEl(ast, this.rootScale));
- },
-});
diff --git a/packages/frontend/src/components/page/block.type.ts b/packages/frontend/src/components/page/block.type.ts
new file mode 100644
index 0000000000..71249a8aff
--- /dev/null
+++ b/packages/frontend/src/components/page/block.type.ts
@@ -0,0 +1,29 @@
+export type BlockBase = {
+ id: string;
+ type: string;
+};
+
+export type TextBlock = BlockBase & {
+ type: 'text';
+ text: string;
+};
+
+export type SectionBlock = BlockBase & {
+ type: 'section';
+ title: string;
+ children: Block[];
+};
+
+export type ImageBlock = BlockBase & {
+ type: 'image';
+ fileId: string | null;
+};
+
+export type NoteBlock = BlockBase & {
+ type: 'note';
+ detailed: boolean;
+ note: string | null;
+};
+
+export type Block =
+ TextBlock | SectionBlock | ImageBlock | NoteBlock;
diff --git a/packages/frontend/src/components/page/page.block.vue b/packages/frontend/src/components/page/page.block.vue
index f3e7764604..2bf3d12daa 100644
--- a/packages/frontend/src/components/page/page.block.vue
+++ b/packages/frontend/src/components/page/page.block.vue
@@ -1,44 +1,29 @@
<template>
-<component :is="'x-' + block.type" :key="block.id" :block="block" :hpml="hpml" :h="h"/>
+<component :is="getComponent(block.type)" :key="block.id" :page="page" :block="block" :h="h"/>
</template>
-<script lang="ts">
-import { defineComponent, PropType } from 'vue';
+<script lang="ts" setup>
+import { } from 'vue';
+import * as Misskey from 'misskey-js';
import XText from './page.text.vue';
import XSection from './page.section.vue';
import XImage from './page.image.vue';
-import XButton from './page.button.vue';
-import XNumberInput from './page.number-input.vue';
-import XTextInput from './page.text-input.vue';
-import XTextareaInput from './page.textarea-input.vue';
-import XSwitch from './page.switch.vue';
-import XIf from './page.if.vue';
-import XTextarea from './page.textarea.vue';
-import XPost from './page.post.vue';
-import XCounter from './page.counter.vue';
-import XRadioButton from './page.radio-button.vue';
-import XCanvas from './page.canvas.vue';
import XNote from './page.note.vue';
-import { Hpml } from '@/scripts/hpml/evaluator';
-import { Block } from '@/scripts/hpml/block';
+import { Block } from './block.type';
-export default defineComponent({
- components: {
- XText, XSection, XImage, XButton, XNumberInput, XTextInput, XTextareaInput, XTextarea, XPost, XSwitch, XIf, XCounter, XRadioButton, XCanvas, XNote,
- },
- props: {
- block: {
- type: Object as PropType<Block>,
- required: true,
- },
- hpml: {
- type: Object as PropType<Hpml>,
- required: true,
- },
- h: {
- type: Number,
- required: true,
- },
- },
-});
+function getComponent(type: string) {
+ switch (type) {
+ case 'text': return XText;
+ case 'section': return XSection;
+ case 'image': return XImage;
+ case 'note': return XNote;
+ default: return null;
+ }
+}
+
+defineProps<{
+ block: Block,
+ h: number,
+ page: Misskey.entities.Page,
+}>();
</script>
diff --git a/packages/frontend/src/components/page/page.button.vue b/packages/frontend/src/components/page/page.button.vue
deleted file mode 100644
index 83931021d8..0000000000
--- a/packages/frontend/src/components/page/page.button.vue
+++ /dev/null
@@ -1,66 +0,0 @@
-<template>
-<div>
- <MkButton class="kudkigyw" :primary="block.primary" @click="click()">{{ hpml.interpolate(block.text) }}</MkButton>
-</div>
-</template>
-
-<script lang="ts">
-import { defineComponent, PropType, unref } from 'vue';
-import MkButton from '../MkButton.vue';
-import * as os from '@/os';
-import { ButtonBlock } from '@/scripts/hpml/block';
-import { Hpml } from '@/scripts/hpml/evaluator';
-
-export default defineComponent({
- components: {
- MkButton,
- },
- props: {
- block: {
- type: Object as PropType<ButtonBlock>,
- required: true,
- },
- hpml: {
- type: Object as PropType<Hpml>,
- required: true,
- },
- },
- methods: {
- click() {
- if (this.block.action === 'dialog') {
- this.hpml.eval();
- os.alert({
- text: this.hpml.interpolate(this.block.content),
- });
- } else if (this.block.action === 'resetRandom') {
- this.hpml.updateRandomSeed(Math.random());
- this.hpml.eval();
- } else if (this.block.action === 'pushEvent') {
- os.api('page-push', {
- pageId: this.hpml.page.id,
- event: this.block.event,
- ...(this.block.var ? {
- var: unref(this.hpml.vars)[this.block.var],
- } : {}),
- });
-
- os.alert({
- type: 'success',
- text: this.hpml.interpolate(this.block.message),
- });
- } else if (this.block.action === 'callAiScript') {
- this.hpml.callAiScript(this.block.fn);
- }
- },
- },
-});
-</script>
-
-<style lang="scss" scoped>
-.kudkigyw {
- display: inline-block;
- min-width: 200px;
- max-width: 450px;
- margin: 8px 0;
-}
-</style>
diff --git a/packages/frontend/src/components/page/page.canvas.vue b/packages/frontend/src/components/page/page.canvas.vue
deleted file mode 100644
index 82ff36ec36..0000000000
--- a/packages/frontend/src/components/page/page.canvas.vue
+++ /dev/null
@@ -1,48 +0,0 @@
-<template>
-<div class="ysrxegms">
- <canvas ref="canvas" :width="block.width" :height="block.height"/>
-</div>
-</template>
-
-<script lang="ts">
-import { defineComponent, onMounted, PropType, Ref, ref } from 'vue';
-import { CanvasBlock } from '@/scripts/hpml/block';
-import { Hpml } from '@/scripts/hpml/evaluator';
-
-export default defineComponent({
- props: {
- block: {
- type: Object as PropType<CanvasBlock>,
- required: true,
- },
- hpml: {
- type: Object as PropType<Hpml>,
- required: true,
- },
- },
- setup(props, ctx) {
- const canvas: Ref<any> = ref(null);
-
- onMounted(() => {
- props.hpml.registerCanvas(props.block.name, canvas.value);
- });
-
- return {
- canvas,
- };
- },
-});
-</script>
-
-<style lang="scss" scoped>
-.ysrxegms {
- display: inline-block;
- vertical-align: bottom;
- overflow: auto;
- max-width: 100%;
-
- > canvas {
- display: block;
- }
-}
-</style>
diff --git a/packages/frontend/src/components/page/page.counter.vue b/packages/frontend/src/components/page/page.counter.vue
deleted file mode 100644
index 63fde6a120..0000000000
--- a/packages/frontend/src/components/page/page.counter.vue
+++ /dev/null
@@ -1,51 +0,0 @@
-<template>
-<div>
- <MkButton class="llumlmnx" @click="click()">{{ hpml.interpolate(block.text) }}</MkButton>
-</div>
-</template>
-
-<script lang="ts">
-import { computed, defineComponent, PropType } from 'vue';
-import MkButton from '../MkButton.vue';
-import { CounterVarBlock } from '@/scripts/hpml/block';
-import { Hpml } from '@/scripts/hpml/evaluator';
-
-export default defineComponent({
- components: {
- MkButton,
- },
- props: {
- block: {
- type: Object as PropType<CounterVarBlock>,
- required: true,
- },
- hpml: {
- type: Object as PropType<Hpml>,
- required: true,
- },
- },
- setup(props, ctx) {
- const value = computed(() => {
- return props.hpml.vars.value[props.block.name];
- });
-
- function click() {
- props.hpml.updatePageVar(props.block.name, value.value + (props.block.inc || 1));
- props.hpml.eval();
- }
-
- return {
- click,
- };
- },
-});
-</script>
-
-<style lang="scss" scoped>
-.llumlmnx {
- display: inline-block;
- min-width: 300px;
- max-width: 450px;
- margin: 8px 0;
-}
-</style>
diff --git a/packages/frontend/src/components/page/page.if.vue b/packages/frontend/src/components/page/page.if.vue
deleted file mode 100644
index 372a15f0c6..0000000000
--- a/packages/frontend/src/components/page/page.if.vue
+++ /dev/null
@@ -1,31 +0,0 @@
-<template>
-<div v-show="hpml.vars.value[block.var]">
- <XBlock v-for="child in block.children" :key="child.id" :block="child" :hpml="hpml" :h="h"/>
-</div>
-</template>
-
-<script lang="ts">
-import { IfBlock } from '@/scripts/hpml/block';
-import { Hpml } from '@/scripts/hpml/evaluator';
-import { defineComponent, defineAsyncComponent, PropType } from 'vue';
-
-export default defineComponent({
- components: {
- XBlock: defineAsyncComponent(() => import('./page.block.vue')),
- },
- props: {
- block: {
- type: Object as PropType<IfBlock>,
- required: true,
- },
- hpml: {
- type: Object as PropType<Hpml>,
- required: true,
- },
- h: {
- type: Number,
- required: true,
- },
- },
-});
-</script>
diff --git a/packages/frontend/src/components/page/page.image.vue b/packages/frontend/src/components/page/page.image.vue
index 6ea81d257f..2edcfb8b1a 100644
--- a/packages/frontend/src/components/page/page.image.vue
+++ b/packages/frontend/src/components/page/page.image.vue
@@ -5,15 +5,15 @@
</template>
<script lang="ts" setup>
-import { PropType } from 'vue';
+import { } from 'vue';
+import * as Misskey from 'misskey-js';
+import { ImageBlock } from './block.type';
import ImgWithBlurhash from '@/components/MkImgWithBlurhash.vue';
-import { ImageBlock } from '@/scripts/hpml/block';
-import { Hpml } from '@/scripts/hpml/evaluator';
const props = defineProps<{
- block: PropType<ImageBlock>,
- hpml: PropType<Hpml>,
+ block: ImageBlock,
+ page: Misskey.entities.Page,
}>();
-const image = props.hpml.page.attachedFiles.find(x => x.id === props.block.fileId);
+const image = props.page.attachedFiles.find(x => x.id === props.block.fileId);
</script>
diff --git a/packages/frontend/src/components/page/page.note.vue b/packages/frontend/src/components/page/page.note.vue
index 8c65dabf08..7133a7f5a1 100644
--- a/packages/frontend/src/components/page/page.note.vue
+++ b/packages/frontend/src/components/page/page.note.vue
@@ -1,47 +1,29 @@
<template>
-<div class="voxdxuby">
+<div style="margin: 1em 0;">
<MkNote v-if="note && !block.detailed" :key="note.id + ':normal'" v-model:note="note"/>
<MkNoteDetailed v-if="note && block.detailed" :key="note.id + ':detail'" v-model:note="note"/>
</div>
</template>
-<script lang="ts">
-import { defineComponent, onMounted, PropType, Ref, ref } from 'vue';
+<script lang="ts" setup>
+import { onMounted, Ref, ref } from 'vue';
+import * as Misskey from 'misskey-js';
+import { NoteBlock } from './block.type';
import MkNote from '@/components/MkNote.vue';
import MkNoteDetailed from '@/components/MkNoteDetailed.vue';
import * as os from '@/os';
-import { NoteBlock } from '@/scripts/hpml/block';
-export default defineComponent({
- components: {
- MkNote,
- MkNoteDetailed,
- },
- props: {
- block: {
- type: Object as PropType<NoteBlock>,
- required: true,
- },
- },
- setup(props, ctx) {
- const note: Ref<Record<string, any> | null> = ref(null);
+const props = defineProps<{
+ block: NoteBlock,
+ page: Misskey.entities.Page,
+}>();
- onMounted(() => {
- os.api('notes/show', { noteId: props.block.note })
- .then(result => {
- note.value = result;
- });
- });
+const note: Ref<Misskey.entities.Note | null> = ref(null);
- return {
- note,
- };
- },
+onMounted(() => {
+ os.api('notes/show', { noteId: props.block.note })
+ .then(result => {
+ note.value = result;
+ });
});
</script>
-
-<style lang="scss" scoped>
-.voxdxuby {
- margin: 1em 0;
-}
-</style>
diff --git a/packages/frontend/src/components/page/page.number-input.vue b/packages/frontend/src/components/page/page.number-input.vue
deleted file mode 100644
index 72c1b6deb0..0000000000
--- a/packages/frontend/src/components/page/page.number-input.vue
+++ /dev/null
@@ -1,54 +0,0 @@
-<template>
-<div>
- <MkInput class="kudkigyw" :model-value="value" type="number" @update:model-value="updateValue($event)">
- <template #label>{{ hpml.interpolate(block.text) }}</template>
- </MkInput>
-</div>
-</template>
-
-<script lang="ts">
-import { computed, defineComponent, PropType } from 'vue';
-import MkInput from '../MkInput.vue';
-import { Hpml } from '@/scripts/hpml/evaluator';
-import { NumberInputVarBlock } from '@/scripts/hpml/block';
-
-export default defineComponent({
- components: {
- MkInput,
- },
- props: {
- block: {
- type: Object as PropType<NumberInputVarBlock>,
- required: true,
- },
- hpml: {
- type: Object as PropType<Hpml>,
- required: true,
- },
- },
- setup(props, ctx) {
- const value = computed(() => {
- return props.hpml.vars.value[props.block.name];
- });
-
- function updateValue(newValue) {
- props.hpml.updatePageVar(props.block.name, newValue);
- props.hpml.eval();
- }
-
- return {
- value,
- updateValue,
- };
- },
-});
-</script>
-
-<style lang="scss" scoped>
-.kudkigyw {
- display: inline-block;
- min-width: 300px;
- max-width: 450px;
- margin: 8px 0;
-}
-</style>
diff --git a/packages/frontend/src/components/page/page.post.vue b/packages/frontend/src/components/page/page.post.vue
deleted file mode 100644
index 55da610cb6..0000000000
--- a/packages/frontend/src/components/page/page.post.vue
+++ /dev/null
@@ -1,111 +0,0 @@
-<template>
-<div class="ngbfujlo">
- <MkTextarea :model-value="text" readonly style="margin: 0;"></MkTextarea>
- <MkButton class="button" primary :disabled="posting || posted" @click="post()">
- <i v-if="posted" class="ti ti-check"></i>
- <i v-else class="ti ti-send"></i>
- </MkButton>
-</div>
-</template>
-
-<script lang="ts">
-import { defineComponent, PropType } from 'vue';
-import MkTextarea from '../MkTextarea.vue';
-import MkButton from '../MkButton.vue';
-import { apiUrl } from '@/config';
-import * as os from '@/os';
-import { PostBlock } from '@/scripts/hpml/block';
-import { Hpml } from '@/scripts/hpml/evaluator';
-import { defaultStore } from '@/store';
-import { $i } from '@/account';
-
-export default defineComponent({
- components: {
- MkTextarea,
- MkButton,
- },
- props: {
- block: {
- type: Object as PropType<PostBlock>,
- required: true,
- },
- hpml: {
- type: Object as PropType<Hpml>,
- required: true,
- },
- },
- data() {
- return {
- text: this.hpml.interpolate(this.block.text),
- posted: false,
- posting: false,
- };
- },
- watch: {
- 'hpml.vars': {
- handler() {
- this.text = this.hpml.interpolate(this.block.text);
- },
- deep: true,
- },
- },
- methods: {
- upload() {
- const promise = new Promise((ok) => {
- const canvas = this.hpml.canvases[this.block.canvasId];
- canvas.toBlob(blob => {
- const formData = new FormData();
- formData.append('file', blob);
- formData.append('i', $i.token);
- if (defaultStore.state.uploadFolder) {
- formData.append('folderId', defaultStore.state.uploadFolder);
- }
-
- window.fetch(apiUrl + '/drive/files/create', {
- method: 'POST',
- body: formData,
- })
- .then(response => response.json())
- .then(f => {
- ok(f);
- });
- });
- });
- os.promiseDialog(promise);
- return promise;
- },
- async post() {
- this.posting = true;
- const file = this.block.attachCanvasImage ? await this.upload() : null;
- os.apiWithDialog('notes/create', {
- text: this.text === '' ? null : this.text,
- fileIds: file ? [file.id] : undefined,
- }).then(() => {
- this.posted = true;
- });
- },
- },
-});
-</script>
-
-<style lang="scss" scoped>
-.ngbfujlo {
- position: relative;
- padding: 32px;
- border-radius: 6px;
- box-shadow: 0 2px 8px var(--shadow);
- z-index: 1;
-
- > .button {
- margin-top: 32px;
- }
-
- @media (max-width: 600px) {
- padding: 16px;
-
- > .button {
- margin-top: 16px;
- }
- }
-}
-</style>
diff --git a/packages/frontend/src/components/page/page.radio-button.vue b/packages/frontend/src/components/page/page.radio-button.vue
deleted file mode 100644
index ce8f252e44..0000000000
--- a/packages/frontend/src/components/page/page.radio-button.vue
+++ /dev/null
@@ -1,44 +0,0 @@
-<template>
-<div>
- <div>{{ hpml.interpolate(block.title) }}</div>
- <MkRadio v-for="item in block.values" :key="item" :modelValue="value" :value="item" @update:model-value="updateValue($event)">{{ item }}</MkRadio>
-</div>
-</template>
-
-<script lang="ts">
-import { computed, defineComponent, PropType } from 'vue';
-import MkRadio from '../MkRadio.vue';
-import { Hpml } from '@/scripts/hpml/evaluator';
-import { RadioButtonVarBlock } from '@/scripts/hpml/block';
-
-export default defineComponent({
- components: {
- MkRadio,
- },
- props: {
- block: {
- type: Object as PropType<RadioButtonVarBlock>,
- required: true,
- },
- hpml: {
- type: Object as PropType<Hpml>,
- required: true,
- },
- },
- setup(props, ctx) {
- const value = computed(() => {
- return props.hpml.vars.value[props.block.name];
- });
-
- function updateValue(newValue: string) {
- props.hpml.updatePageVar(props.block.name, newValue);
- props.hpml.eval();
- }
-
- return {
- value,
- updateValue,
- };
- },
-});
-</script>
diff --git a/packages/frontend/src/components/page/page.section.vue b/packages/frontend/src/components/page/page.section.vue
index 50181b3905..83a16ae0a5 100644
--- a/packages/frontend/src/components/page/page.section.vue
+++ b/packages/frontend/src/components/page/page.section.vue
@@ -1,59 +1,49 @@
<template>
-<section class="sdgxphyu">
- <component :is="'h' + h">{{ block.title }}</component>
+<section>
+ <component
+ :is="'h' + h"
+ :class="{
+ 'h2': h === 2,
+ 'h3': h === 3,
+ 'h4': h === 4,
+ }"
+ >
+ {{ block.title }}
+ </component>
- <div class="children">
- <XBlock v-for="child in block.children" :key="child.id" :block="child" :hpml="hpml" :h="h + 1"/>
+ <div class="_gaps">
+ <XBlock v-for="child in block.children" :key="child.id" :page="page" :block="child" :h="h + 1"/>
</div>
</section>
</template>
-<script lang="ts">
-import { defineComponent, defineAsyncComponent, PropType } from 'vue';
-import { SectionBlock } from '@/scripts/hpml/block';
-import { Hpml } from '@/scripts/hpml/evaluator';
+<script lang="ts" setup>
+import { defineAsyncComponent } from 'vue';
+import * as Misskey from 'misskey-js';
+import { SectionBlock } from './block.type';
-export default defineComponent({
- components: {
- XBlock: defineAsyncComponent(() => import('./page.block.vue')),
- },
- props: {
- block: {
- type: Object as PropType<SectionBlock>,
- required: true,
- },
- hpml: {
- type: Object as PropType<Hpml>,
- required: true,
- },
- h: {
- required: true,
- },
- },
-});
-</script>
-
-<style lang="scss" scoped>
-.sdgxphyu {
- margin: 1.5em 0;
+const XBlock = defineAsyncComponent(() => import('./page.block.vue'));
- > h2 {
- font-size: 1.35em;
- margin: 0 0 0.5em 0;
- }
+defineProps<{
+ block: SectionBlock,
+ h: number,
+ page: Misskey.entities.Page,
+}>();
+</script>
- > h3 {
- font-size: 1em;
- margin: 0 0 0.5em 0;
- }
+<style lang="scss" module>
+.h2 {
+ font-size: 1.35em;
+ margin: 0 0 0.5em 0;
+}
- > h4 {
- font-size: 1em;
- margin: 0 0 0.5em 0;
- }
+.h3 {
+ font-size: 1em;
+ margin: 0 0 0.5em 0;
+}
- > .children {
- //padding 16px
- }
+.h4 {
+ font-size: 1em;
+ margin: 0 0 0.5em 0;
}
</style>
diff --git a/packages/frontend/src/components/page/page.switch.vue b/packages/frontend/src/components/page/page.switch.vue
deleted file mode 100644
index b5f3464512..0000000000
--- a/packages/frontend/src/components/page/page.switch.vue
+++ /dev/null
@@ -1,54 +0,0 @@
-<template>
-<div class="hkcxmtwj">
- <MkSwitch :model-value="value" @update:model-value="updateValue($event)">{{ hpml.interpolate(block.text) }}</MkSwitch>
-</div>
-</template>
-
-<script lang="ts">
-import { computed, defineComponent, PropType } from 'vue';
-import MkSwitch from '../MkSwitch.vue';
-import { Hpml } from '@/scripts/hpml/evaluator';
-import { SwitchVarBlock } from '@/scripts/hpml/block';
-
-export default defineComponent({
- components: {
- MkSwitch,
- },
- props: {
- block: {
- type: Object as PropType<SwitchVarBlock>,
- required: true,
- },
- hpml: {
- type: Object as PropType<Hpml>,
- required: true,
- },
- },
- setup(props, ctx) {
- const value = computed(() => {
- return props.hpml.vars.value[props.block.name];
- });
-
- function updateValue(newValue: boolean) {
- props.hpml.updatePageVar(props.block.name, newValue);
- props.hpml.eval();
- }
-
- return {
- value,
- updateValue,
- };
- },
-});
-</script>
-
-<style lang="scss" scoped>
-.hkcxmtwj {
- display: inline-block;
- margin: 16px auto;
-
- & + .hkcxmtwj {
- margin-left: 16px;
- }
-}
-</style>
diff --git a/packages/frontend/src/components/page/page.text-input.vue b/packages/frontend/src/components/page/page.text-input.vue
deleted file mode 100644
index d020a99de8..0000000000
--- a/packages/frontend/src/components/page/page.text-input.vue
+++ /dev/null
@@ -1,54 +0,0 @@
-<template>
-<div>
- <MkInput class="kudkigyw" :model-value="value" type="text" @update:model-value="updateValue($event)">
- <template #label>{{ hpml.interpolate(block.text) }}</template>
- </MkInput>
-</div>
-</template>
-
-<script lang="ts">
-import { computed, defineComponent, PropType } from 'vue';
-import MkInput from '../MkInput.vue';
-import { Hpml } from '@/scripts/hpml/evaluator';
-import { TextInputVarBlock } from '@/scripts/hpml/block';
-
-export default defineComponent({
- components: {
- MkInput,
- },
- props: {
- block: {
- type: Object as PropType<TextInputVarBlock>,
- required: true,
- },
- hpml: {
- type: Object as PropType<Hpml>,
- required: true,
- },
- },
- setup(props, ctx) {
- const value = computed(() => {
- return props.hpml.vars.value[props.block.name];
- });
-
- function updateValue(newValue) {
- props.hpml.updatePageVar(props.block.name, newValue);
- props.hpml.eval();
- }
-
- return {
- value,
- updateValue,
- };
- },
-});
-</script>
-
-<style lang="scss" scoped>
-.kudkigyw {
- display: inline-block;
- min-width: 300px;
- max-width: 450px;
- margin: 8px 0;
-}
-</style>
diff --git a/packages/frontend/src/components/page/page.text.vue b/packages/frontend/src/components/page/page.text.vue
index e0e4959efa..48ce4b0e1e 100644
--- a/packages/frontend/src/components/page/page.text.vue
+++ b/packages/frontend/src/components/page/page.text.vue
@@ -1,70 +1,24 @@
<template>
-<div class="mrdgzndn">
- <Mfm :key="text" :text="text" :is-note="false" :i="$i"/>
- <MkUrlPreview v-for="url in urls" :key="url" :url="url" class="url"/>
+<div class="_gaps">
+ <Mfm :text="block.text" :isNote="false" :i="$i"/>
+ <MkUrlPreview v-for="url in urls" :key="url" :url="url"/>
</div>
</template>
-<script lang="ts">
-import { defineAsyncComponent, defineComponent, PropType } from 'vue';
+<script lang="ts" setup>
+import { defineAsyncComponent } from 'vue';
import * as mfm from 'mfm-js';
-import { TextBlock } from '@/scripts/hpml/block';
-import { Hpml } from '@/scripts/hpml/evaluator';
+import * as Misskey from 'misskey-js';
+import { TextBlock } from './block.type';
import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm';
import { $i } from '@/account';
-export default defineComponent({
- components: {
- MkUrlPreview: defineAsyncComponent(() => import('@/components/MkUrlPreview.vue')),
- },
- props: {
- block: {
- type: Object as PropType<TextBlock>,
- required: true,
- },
- hpml: {
- type: Object as PropType<Hpml>,
- required: true,
- },
- },
- data() {
- return {
- text: this.hpml.interpolate(this.block.text),
- $i,
- };
- },
- computed: {
- urls(): string[] {
- if (this.text) {
- return extractUrlFromMfm(mfm.parse(this.text));
- } else {
- return [];
- }
- },
- },
- watch: {
- 'hpml.vars': {
- handler() {
- this.text = this.hpml.interpolate(this.block.text);
- },
- deep: true,
- },
- },
-});
-</script>
-
-<style lang="scss" scoped>
-.mrdgzndn {
- &:not(:first-child) {
- margin-top: 0.5em;
- }
+const MkUrlPreview = defineAsyncComponent(() => import('@/components/MkUrlPreview.vue'));
- &:not(:last-child) {
- margin-bottom: 0.5em;
- }
+const props = defineProps<{
+ block: TextBlock,
+ page: Misskey.entities.Page,
+}>();
- > .url {
- margin: 0.5em 0;
- }
-}
-</style>
+const urls = props.block.text ? extractUrlFromMfm(mfm.parse(props.block.text)) : [];
+</script>
diff --git a/packages/frontend/src/components/page/page.textarea-input.vue b/packages/frontend/src/components/page/page.textarea-input.vue
deleted file mode 100644
index db3a96dd1b..0000000000
--- a/packages/frontend/src/components/page/page.textarea-input.vue
+++ /dev/null
@@ -1,45 +0,0 @@
-<template>
-<div>
- <MkTextarea :model-value="value" @update:model-value="updateValue($event)">
- <template #label>{{ hpml.interpolate(block.text) }}</template>
- </MkTextarea>
-</div>
-</template>
-
-<script lang="ts">
-import { computed, defineComponent, PropType } from 'vue';
-import MkTextarea from '../MkTextarea.vue';
-import { Hpml } from '@/scripts/hpml/evaluator';
-import { TextInputVarBlock } from '@/scripts/hpml/block';
-
-export default defineComponent({
- components: {
- MkTextarea,
- },
- props: {
- block: {
- type: Object as PropType<TextInputVarBlock>,
- required: true,
- },
- hpml: {
- type: Object as PropType<Hpml>,
- required: true,
- },
- },
- setup(props, ctx) {
- const value = computed(() => {
- return props.hpml.vars.value[props.block.name];
- });
-
- function updateValue(newValue) {
- props.hpml.updatePageVar(props.block.name, newValue);
- props.hpml.eval();
- }
-
- return {
- value,
- updateValue,
- };
- },
-});
-</script>
diff --git a/packages/frontend/src/components/page/page.textarea.vue b/packages/frontend/src/components/page/page.textarea.vue
deleted file mode 100644
index 9b82412e8a..0000000000
--- a/packages/frontend/src/components/page/page.textarea.vue
+++ /dev/null
@@ -1,39 +0,0 @@
-<template>
-<MkTextarea :model-value="text" readonly></MkTextarea>
-</template>
-
-<script lang="ts">
-import { TextBlock } from '@/scripts/hpml/block';
-import { Hpml } from '@/scripts/hpml/evaluator';
-import { defineComponent, PropType } from 'vue';
-import MkTextarea from '../MkTextarea.vue';
-
-export default defineComponent({
- components: {
- MkTextarea,
- },
- props: {
- block: {
- type: Object as PropType<TextBlock>,
- required: true,
- },
- hpml: {
- type: Object as PropType<Hpml>,
- required: true,
- },
- },
- data() {
- return {
- text: this.hpml.interpolate(this.block.text),
- };
- },
- watch: {
- 'hpml.vars': {
- handler() {
- this.text = this.hpml.interpolate(this.block.text);
- },
- deep: true,
- },
- },
-});
-</script>
diff --git a/packages/frontend/src/components/page/page.vue b/packages/frontend/src/components/page/page.vue
index 5f1f62581e..c2c2693224 100644
--- a/packages/frontend/src/components/page/page.vue
+++ b/packages/frontend/src/components/page/page.vue
@@ -1,56 +1,25 @@
<template>
-<div v-if="hpml" class="iroscrza" :class="{ center: page.alignCenter, serif: page.font === 'serif' }">
- <XBlock v-for="child in page.content" :key="child.id" :block="child" :hpml="hpml" :h="2"/>
+<div :class="{ [$style.center]: page.alignCenter, [$style.serif]: page.font === 'serif' }">
+ <XBlock v-for="child in page.content" :key="child.id" :page="page" :block="child" :h="2"/>
</div>
</template>
-<script lang="ts">
-import { defineComponent, onMounted, nextTick, PropType } from 'vue';
+<script lang="ts" setup>
+import { onMounted, nextTick } from 'vue';
+import * as Misskey from 'misskey-js';
import XBlock from './page.block.vue';
-import { Hpml } from '@/scripts/hpml/evaluator';
-import { url } from '@/config';
-import { $i } from '@/account';
-export default defineComponent({
- components: {
- XBlock,
- },
- props: {
- page: {
- type: Object as PropType<Record<string, any>>,
- required: true,
- },
- },
- setup(props, ctx) {
- const hpml = new Hpml(props.page, {
- randomSeed: Math.random(),
- visitor: $i,
- url: url,
- });
-
- onMounted(() => {
- nextTick(() => {
- hpml.eval();
- });
- });
-
- return {
- hpml,
- };
- },
-});
+defineProps<{
+ page: Misskey.entities.Page,
+}>();
</script>
-<style lang="scss" scoped>
-.iroscrza {
- &.serif {
- > div {
- font-family: serif;
- }
- }
+<style lang="scss" module>
+.serif {
+ font-family: serif;
+}
- &.center {
- text-align: center;
- }
+.center {
+ text-align: center;
}
</style>
diff --git a/packages/frontend/src/custom-emojis.ts b/packages/frontend/src/custom-emojis.ts
index a89a420d77..9b738b2fd4 100644
--- a/packages/frontend/src/custom-emojis.ts
+++ b/packages/frontend/src/custom-emojis.ts
@@ -1,8 +1,7 @@
-import { shallowRef, computed, markRaw } from 'vue';
+import { shallowRef, computed, markRaw, watch } from 'vue';
import * as Misskey from 'misskey-js';
import { api, apiGet } from './os';
-import { miLocalStorage } from './local-storage';
-import { stream } from '@/stream';
+import { useStream } from '@/stream';
import { get, set } from '@/scripts/idb-proxy';
const storageCache = await get('emojis');
@@ -17,6 +16,17 @@ export const customEmojiCategories = computed<[ ...string[], null ]>(() => {
return markRaw([...Array.from(categories), null]);
});
+export const customEmojisMap = new Map<string, Misskey.entities.CustomEmoji>();
+watch(customEmojis, emojis => {
+ customEmojisMap.clear();
+ for (const emoji of emojis) {
+ customEmojisMap.set(emoji.name, emoji);
+ }
+}, { immediate: true });
+
+// TODO: ここら辺副作用なのでいい感じにする
+const stream = useStream();
+
stream.on('emojiAdded', emojiData => {
customEmojis.value = [emojiData.emoji, ...customEmojis.value];
set('emojis', customEmojis.value);
@@ -34,10 +44,9 @@ stream.on('emojiDeleted', emojiData => {
export async function fetchCustomEmojis(force = false) {
const now = Date.now();
- const needsMigration = miLocalStorage.getItem('emojis') != null;
let res;
- if (force || needsMigration) {
+ if (force) {
res = await api('emojis', {});
} else {
const lastFetchedAt = await get('lastEmojisFetchedAt');
@@ -48,10 +57,6 @@ export async function fetchCustomEmojis(force = false) {
customEmojis.value = res.emojis;
set('emojis', res.emojis);
set('lastEmojisFetchedAt', now);
- if (needsMigration) {
- miLocalStorage.removeItem('emojis');
- miLocalStorage.removeItem('lastEmojisFetchedAt');
- }
}
let cachedTags;
diff --git a/packages/frontend/src/directives/tooltip.ts b/packages/frontend/src/directives/tooltip.ts
index 5d13497b5f..373141fa35 100644
--- a/packages/frontend/src/directives/tooltip.ts
+++ b/packages/frontend/src/directives/tooltip.ts
@@ -5,7 +5,7 @@ import { defineAsyncComponent, Directive, ref } from 'vue';
import { isTouchUsing } from '@/scripts/touch';
import { popup, alert } from '@/os';
-const start = isTouchUsing ? 'touchstart' : 'mouseover';
+const start = isTouchUsing ? 'touchstart' : 'mouseenter';
const end = isTouchUsing ? 'touchend' : 'mouseleave';
export default {
@@ -63,16 +63,24 @@ export default {
ev.preventDefault();
});
- el.addEventListener(start, () => {
+ el.addEventListener(start, (ev) => {
window.clearTimeout(self.showTimer);
window.clearTimeout(self.hideTimer);
- self.showTimer = window.setTimeout(self.show, delay);
+ if (delay === 0) {
+ self.show();
+ } else {
+ self.showTimer = window.setTimeout(self.show, delay);
+ }
}, { passive: true });
el.addEventListener(end, () => {
window.clearTimeout(self.showTimer);
window.clearTimeout(self.hideTimer);
- self.hideTimer = window.setTimeout(self.close, delay);
+ if (delay === 0) {
+ self.close();
+ } else {
+ self.hideTimer = window.setTimeout(self.close, delay);
+ }
}, { passive: true });
el.addEventListener('click', () => {
diff --git a/packages/frontend/src/emojilist.json b/packages/frontend/src/emojilist.json
index 402e82e33b..fde06a4aa0 100644
--- a/packages/frontend/src/emojilist.json
+++ b/packages/frontend/src/emojilist.json
@@ -1,1785 +1,1784 @@
[
- { "category": "face", "char": "😀", "name": "grinning", "keywords": ["face", "smile", "happy", "joy", ": D", "grin"] },
- { "category": "face", "char": "😬", "name": "grimacing", "keywords": ["face", "grimace", "teeth"] },
- { "category": "face", "char": "😁", "name": "grin", "keywords": ["face", "happy", "smile", "joy", "kawaii"] },
- { "category": "face", "char": "😂", "name": "joy", "keywords": ["face", "cry", "tears", "weep", "happy", "happytears", "haha"] },
- { "category": "face", "char": "🤣", "name": "rofl", "keywords": ["face", "rolling", "floor", "laughing", "lol", "haha"] },
- { "category": "face", "char": "🥳", "name": "partying", "keywords": ["face", "celebration", "woohoo"] },
- { "category": "face", "char": "😃", "name": "smiley", "keywords": ["face", "happy", "joy", "haha", ": D", ": )", "smile", "funny"] },
- { "category": "face", "char": "😄", "name": "smile", "keywords": ["face", "happy", "joy", "funny", "haha", "laugh", "like", ": D", ": )"] },
- { "category": "face", "char": "😅", "name": "sweat_smile", "keywords": ["face", "hot", "happy", "laugh", "sweat", "smile", "relief"] },
- { "category": "face", "char": "🥲", "name": "smiling_face_with_tear", "keywords": ["face"] },
- { "category": "face", "char": "😆", "name": "laughing", "keywords": ["happy", "joy", "lol", "satisfied", "haha", "face", "glad", "XD", "laugh"] },
- { "category": "face", "char": "😇", "name": "innocent", "keywords": ["face", "angel", "heaven", "halo"] },
- { "category": "face", "char": "😉", "name": "wink", "keywords": ["face", "happy", "mischievous", "secret", ";)", "smile", "eye"] },
- { "category": "face", "char": "😊", "name": "blush", "keywords": ["face", "smile", "happy", "flushed", "crush", "embarrassed", "shy", "joy"] },
- { "category": "face", "char": "🙂", "name": "slightly_smiling_face", "keywords": ["face", "smile"] },
- { "category": "face", "char": "🙃", "name": "upside_down_face", "keywords": ["face", "flipped", "silly", "smile"] },
- { "category": "face", "char": "☺️", "name": "relaxed", "keywords": ["face", "blush", "massage", "happiness"] },
- { "category": "face", "char": "😋", "name": "yum", "keywords": ["happy", "joy", "tongue", "smile", "face", "silly", "yummy", "nom", "delicious", "savouring"] },
- { "category": "face", "char": "😌", "name": "relieved", "keywords": ["face", "relaxed", "phew", "massage", "happiness"] },
- { "category": "face", "char": "😍", "name": "heart_eyes", "keywords": ["face", "love", "like", "affection", "valentines", "infatuation", "crush", "heart"] },
- { "category": "face", "char": "🥰", "name": "smiling_face_with_three_hearts", "keywords": ["face", "love", "like", "affection", "valentines", "infatuation", "crush", "hearts", "adore"] },
- { "category": "face", "char": "😘", "name": "kissing_heart", "keywords": ["face", "love", "like", "affection", "valentines", "infatuation", "kiss"] },
- { "category": "face", "char": "😗", "name": "kissing", "keywords": ["love", "like", "face", "3", "valentines", "infatuation", "kiss"] },
- { "category": "face", "char": "😙", "name": "kissing_smiling_eyes", "keywords": ["face", "affection", "valentines", "infatuation", "kiss"] },
- { "category": "face", "char": "😚", "name": "kissing_closed_eyes", "keywords": ["face", "love", "like", "affection", "valentines", "infatuation", "kiss"] },
- { "category": "face", "char": "😜", "name": "stuck_out_tongue_winking_eye", "keywords": ["face", "prank", "childish", "playful", "mischievous", "smile", "wink", "tongue"] },
- { "category": "face", "char": "🤪", "name": "zany", "keywords": ["face", "goofy", "crazy"] },
- { "category": "face", "char": "🤨", "name": "raised_eyebrow", "keywords": ["face", "distrust", "scepticism", "disapproval", "disbelief", "surprise"] },
- { "category": "face", "char": "🧐", "name": "monocle", "keywords": ["face", "stuffy", "wealthy"] },
- { "category": "face", "char": "😝", "name": "stuck_out_tongue_closed_eyes", "keywords": ["face", "prank", "playful", "mischievous", "smile", "tongue"] },
- { "category": "face", "char": "😛", "name": "stuck_out_tongue", "keywords": ["face", "prank", "childish", "playful", "mischievous", "smile", "tongue"] },
- { "category": "face", "char": "🤑", "name": "money_mouth_face", "keywords": ["face", "rich", "dollar", "money"] },
- { "category": "face", "char": "🤓", "name": "nerd_face", "keywords": ["face", "nerdy", "geek", "dork"] },
- { "category": "face", "char": "🥸", "name": "disguised_face", "keywords": ["face", "nose", "glasses", "incognito"] },
- { "category": "face", "char": "😎", "name": "sunglasses", "keywords": ["face", "cool", "smile", "summer", "beach", "sunglass"] },
- { "category": "face", "char": "🤩", "name": "star_struck", "keywords": ["face", "smile", "starry", "eyes", "grinning"] },
- { "category": "face", "char": "🤡", "name": "clown_face", "keywords": ["face"] },
- { "category": "face", "char": "🤠", "name": "cowboy_hat_face", "keywords": ["face", "cowgirl", "hat"] },
- { "category": "face", "char": "🤗", "name": "hugs", "keywords": ["face", "smile", "hug"] },
- { "category": "face", "char": "😏", "name": "smirk", "keywords": ["face", "smile", "mean", "prank", "smug", "sarcasm"] },
- { "category": "face", "char": "😶", "name": "no_mouth", "keywords": ["face", "hellokitty"] },
- { "category": "face", "char": "😐", "name": "neutral_face", "keywords": ["indifference", "meh", ": |", "neutral"] },
- { "category": "face", "char": "😑", "name": "expressionless", "keywords": ["face", "indifferent", "-_-", "meh", "deadpan"] },
- { "category": "face", "char": "😒", "name": "unamused", "keywords": ["indifference", "bored", "straight face", "serious", "sarcasm", "unimpressed", "skeptical", "dubious", "side_eye"] },
- { "category": "face", "char": "🙄", "name": "roll_eyes", "keywords": ["face", "eyeroll", "frustrated"] },
- { "category": "face", "char": "🤔", "name": "thinking", "keywords": ["face", "hmmm", "think", "consider"] },
- { "category": "face", "char": "🤥", "name": "lying_face", "keywords": ["face", "lie", "pinocchio"] },
- { "category": "face", "char": "🤭", "name": "hand_over_mouth", "keywords": ["face", "whoops", "shock", "surprise"] },
- { "category": "face", "char": "🤫", "name": "shushing", "keywords": ["face", "quiet", "shhh"] },
- { "category": "face", "char": "🤬", "name": "symbols_over_mouth", "keywords": ["face", "swearing", "cursing", "cussing", "profanity", "expletive"] },
- { "category": "face", "char": "🤯", "name": "exploding_head", "keywords": ["face", "shocked", "mind", "blown"] },
- { "category": "face", "char": "😳", "name": "flushed", "keywords": ["face", "blush", "shy", "flattered"] },
- { "category": "face", "char": "😞", "name": "disappointed", "keywords": ["face", "sad", "upset", "depressed", ": ("] },
- { "category": "face", "char": "😟", "name": "worried", "keywords": ["face", "concern", "nervous", ": ("] },
- { "category": "face", "char": "😠", "name": "angry", "keywords": ["mad", "face", "annoyed", "frustrated"] },
- { "category": "face", "char": "😡", "name": "rage", "keywords": ["angry", "mad", "hate", "despise"] },
- { "category": "face", "char": "😔", "name": "pensive", "keywords": ["face", "sad", "depressed", "upset"] },
- { "category": "face", "char": "😕", "name": "confused", "keywords": ["face", "indifference", "huh", "weird", "hmmm", ": /"] },
- { "category": "face", "char": "🙁", "name": "slightly_frowning_face", "keywords": ["face", "frowning", "disappointed", "sad", "upset"] },
- { "category": "face", "char": "☹", "name": "frowning_face", "keywords": ["face", "sad", "upset", "frown"] },
- { "category": "face", "char": "😣", "name": "persevere", "keywords": ["face", "sick", "no", "upset", "oops"] },
- { "category": "face", "char": "😖", "name": "confounded", "keywords": ["face", "confused", "sick", "unwell", "oops", ": S"] },
- { "category": "face", "char": "😫", "name": "tired_face", "keywords": ["sick", "whine", "upset", "frustrated"] },
- { "category": "face", "char": "😩", "name": "weary", "keywords": ["face", "tired", "sleepy", "sad", "frustrated", "upset"] },
- { "category": "face", "char": "🥺", "name": "pleading", "keywords": ["face", "begging", "mercy"] },
- { "category": "face", "char": "😤", "name": "triumph", "keywords": ["face", "gas", "phew", "proud", "pride"] },
- { "category": "face", "char": "😮", "name": "open_mouth", "keywords": ["face", "surprise", "impressed", "wow", "whoa", ": O"] },
- { "category": "face", "char": "😱", "name": "scream", "keywords": ["face", "munch", "scared", "omg"] },
- { "category": "face", "char": "😨", "name": "fearful", "keywords": ["face", "scared", "terrified", "nervous", "oops", "huh"] },
- { "category": "face", "char": "😰", "name": "cold_sweat", "keywords": ["face", "nervous", "sweat"] },
- { "category": "face", "char": "😯", "name": "hushed", "keywords": ["face", "woo", "shh"] },
- { "category": "face", "char": "😦", "name": "frowning", "keywords": ["face", "aw", "what"] },
- { "category": "face", "char": "😧", "name": "anguished", "keywords": ["face", "stunned", "nervous"] },
- { "category": "face", "char": "😢", "name": "cry", "keywords": ["face", "tears", "sad", "depressed", "upset", ": '("] },
- { "category": "face", "char": "😥", "name": "disappointed_relieved", "keywords": ["face", "phew", "sweat", "nervous"] },
- { "category": "face", "char": "🤤", "name": "drooling_face", "keywords": ["face"] },
- { "category": "face", "char": "😪", "name": "sleepy", "keywords": ["face", "tired", "rest", "nap"] },
- { "category": "face", "char": "😓", "name": "sweat", "keywords": ["face", "hot", "sad", "tired", "exercise"] },
- { "category": "face", "char": "🥵", "name": "hot", "keywords": ["face", "feverish", "heat", "red", "sweating"] },
- { "category": "face", "char": "🥶", "name": "cold", "keywords": ["face", "blue", "freezing", "frozen", "frostbite", "icicles"] },
- { "category": "face", "char": "😭", "name": "sob", "keywords": ["face", "cry", "tears", "sad", "upset", "depressed"] },
- { "category": "face", "char": "😵", "name": "dizzy_face", "keywords": ["spent", "unconscious", "xox", "dizzy"] },
- { "category": "face", "char": "😲", "name": "astonished", "keywords": ["face", "xox", "surprised", "poisoned"] },
- { "category": "face", "char": "🤐", "name": "zipper_mouth_face", "keywords": ["face", "sealed", "zipper", "secret"] },
- { "category": "face", "char": "🤢", "name": "nauseated_face", "keywords": ["face", "vomit", "gross", "green", "sick", "throw up", "ill"] },
- { "category": "face", "char": "🤧", "name": "sneezing_face", "keywords": ["face", "gesundheit", "sneeze", "sick", "allergy"] },
- { "category": "face", "char": "🤮", "name": "vomiting", "keywords": ["face", "sick"] },
- { "category": "face", "char": "😷", "name": "mask", "keywords": ["face", "sick", "ill", "disease"] },
- { "category": "face", "char": "🤒", "name": "face_with_thermometer", "keywords": ["sick", "temperature", "thermometer", "cold", "fever"] },
- { "category": "face", "char": "🤕", "name": "face_with_head_bandage", "keywords": ["injured", "clumsy", "bandage", "hurt"] },
- { "category": "face", "char": "🥴", "name": "woozy", "keywords": ["face", "dizzy", "intoxicated", "tipsy", "wavy"] },
- { "category": "face", "char": "🥱", "name": "yawning", "keywords": ["face", "tired", "yawning"] },
- { "category": "face", "char": "😴", "name": "sleeping", "keywords": ["face", "tired", "sleepy", "night", "zzz"] },
- { "category": "face", "char": "💤", "name": "zzz", "keywords": ["sleepy", "tired", "dream"] },
- { "category": "face", "char": "\uD83D\uDE36\u200D\uD83C\uDF2B\uFE0F", "name": "face_in_clouds", "keywords": [] },
- { "category": "face", "char": "\uD83D\uDE2E\u200D\uD83D\uDCA8", "name": "face_exhaling", "keywords": [] },
- { "category": "face", "char": "\uD83D\uDE35\u200D\uD83D\uDCAB", "name": "face_with_spiral_eyes", "keywords": [] },
- { "category": "face", "char": "\uD83E\uDEE0", "name": "melting_face", "keywords": ["disappear", "dissolve", "liquid", "melt", "toketa"] },
- { "category": "face", "char": "\uD83E\uDEE2", "name": "face_with_open_eyes_and_hand_over_mouth", "keywords": ["amazement", "awe", "disbelief", "embarrass", "scared", "surprise", "ohoho"] },
- { "category": "face", "char": "\uD83E\uDEE3", "name": "face_with_peeking_eye", "keywords": ["captivated", "peep", "stare", "chunibyo"] },
- { "category": "face", "char": "\uD83E\uDEE1", "name": "saluting_face", "keywords": ["ok", "salute", "sunny", "troops", "yes", "raja"] },
- { "category": "face", "char": "\uD83E\uDEE5", "name": "dotted_line_face", "keywords": ["depressed", "disappear", "hide", "introvert", "invisible", "tensen"] },
- { "category": "face", "char": "\uD83E\uDEE4", "name": "face_with_diagonal_mouth", "keywords": ["disappointed", "meh", "skeptical", "unsure"] },
- { "category": "face", "char": "\uD83E\uDD79", "name": "face_holding_back_tears", "keywords": ["angry", "cry", "proud", "resist", "sad"] },
- { "category": "face", "char": "💩", "name": "poop", "keywords": ["hankey", "shitface", "fail", "turd", "shit"] },
- { "category": "face", "char": "😈", "name": "smiling_imp", "keywords": ["devil", "horns"] },
- { "category": "face", "char": "👿", "name": "imp", "keywords": ["devil", "angry", "horns"] },
- { "category": "face", "char": "👹", "name": "japanese_ogre", "keywords": ["monster", "red", "mask", "halloween", "scary", "creepy", "devil", "demon", "japanese", "ogre"] },
- { "category": "face", "char": "👺", "name": "japanese_goblin", "keywords": ["red", "evil", "mask", "monster", "scary", "creepy", "japanese", "goblin"] },
- { "category": "face", "char": "💀", "name": "skull", "keywords": ["dead", "skeleton", "creepy", "death"] },
- { "category": "face", "char": "👻", "name": "ghost", "keywords": ["halloween", "spooky", "scary"] },
- { "category": "face", "char": "👽", "name": "alien", "keywords": ["UFO", "paul", "weird", "outer_space"] },
- { "category": "face", "char": "🤖", "name": "robot", "keywords": ["computer", "machine", "bot"] },
- { "category": "face", "char": "😺", "name": "smiley_cat", "keywords": ["animal", "cats", "happy", "smile"] },
- { "category": "face", "char": "😸", "name": "smile_cat", "keywords": ["animal", "cats", "smile"] },
- { "category": "face", "char": "😹", "name": "joy_cat", "keywords": ["animal", "cats", "haha", "happy", "tears"] },
- { "category": "face", "char": "😻", "name": "heart_eyes_cat", "keywords": ["animal", "love", "like", "affection", "cats", "valentines", "heart"] },
- { "category": "face", "char": "😼", "name": "smirk_cat", "keywords": ["animal", "cats", "smirk"] },
- { "category": "face", "char": "😽", "name": "kissing_cat", "keywords": ["animal", "cats", "kiss"] },
- { "category": "face", "char": "🙀", "name": "scream_cat", "keywords": ["animal", "cats", "munch", "scared", "scream"] },
- { "category": "face", "char": "😿", "name": "crying_cat_face", "keywords": ["animal", "tears", "weep", "sad", "cats", "upset", "cry"] },
- { "category": "face", "char": "😾", "name": "pouting_cat", "keywords": ["animal", "cats"] },
- { "category": "people", "char": "🤲", "name": "palms_up", "keywords": ["hands", "gesture", "cupped", "prayer"] },
- { "category": "people", "char": "🙌", "name": "raised_hands", "keywords": ["gesture", "hooray", "yea", "celebration", "hands"] },
- { "category": "people", "char": "👏", "name": "clap", "keywords": ["hands", "praise", "applause", "congrats", "yay"] },
- { "category": "people", "char": "👋", "name": "wave", "keywords": ["hands", "gesture", "goodbye", "solong", "farewell", "hello", "hi", "palm"] },
- { "category": "people", "char": "🤙", "name": "call_me_hand", "keywords": ["hands", "gesture"] },
- { "category": "people", "char": "👍", "name": "+1", "keywords": ["thumbsup", "yes", "awesome", "good", "agree", "accept", "cool", "hand", "like"] },
- { "category": "people", "char": "👎", "name": "-1", "keywords": ["thumbsdown", "no", "dislike", "hand"] },
- { "category": "people", "char": "👊", "name": "facepunch", "keywords": ["angry", "violence", "fist", "hit", "attack", "hand"] },
- { "category": "people", "char": "✊", "name": "fist", "keywords": ["fingers", "hand", "grasp"] },
- { "category": "people", "char": "🤛", "name": "fist_left", "keywords": ["hand", "fistbump"] },
- { "category": "people", "char": "🤜", "name": "fist_right", "keywords": ["hand", "fistbump"] },
- { "category": "people", "char": "✌", "name": "v", "keywords": ["fingers", "ohyeah", "hand", "peace", "victory", "two"] },
- { "category": "people", "char": "👌", "name": "ok_hand", "keywords": ["fingers", "limbs", "perfect", "ok", "okay"] },
- { "category": "people", "char": "✋", "name": "raised_hand", "keywords": ["fingers", "stop", "highfive", "palm", "ban"] },
- { "category": "people", "char": "🤚", "name": "raised_back_of_hand", "keywords": ["fingers", "raised", "backhand"] },
- { "category": "people", "char": "👐", "name": "open_hands", "keywords": ["fingers", "butterfly", "hands", "open"] },
- { "category": "people", "char": "💪", "name": "muscle", "keywords": ["arm", "flex", "hand", "summer", "strong", "biceps"] },
- { "category": "people", "char": "🦾", "name": "mechanical_arm", "keywords": ["flex", "hand", "strong", "biceps"] },
- { "category": "people", "char": "🙏", "name": "pray", "keywords": ["please", "hope", "wish", "namaste", "highfive"] },
- { "category": "people", "char": "🦶", "name": "foot", "keywords": ["kick", "stomp"] },
- { "category": "people", "char": "🦵", "name": "leg", "keywords": ["kick", "limb"] },
- { "category": "people", "char": "🦿", "name": "mechanical_leg", "keywords": ["kick", "limb"] },
- { "category": "people", "char": "🤝", "name": "handshake", "keywords": ["agreement", "shake"] },
- { "category": "people", "char": "☝", "name": "point_up", "keywords": ["hand", "fingers", "direction", "up"] },
- { "category": "people", "char": "👆", "name": "point_up_2", "keywords": ["fingers", "hand", "direction", "up"] },
- { "category": "people", "char": "👇", "name": "point_down", "keywords": ["fingers", "hand", "direction", "down"] },
- { "category": "people", "char": "👈", "name": "point_left", "keywords": ["direction", "fingers", "hand", "left"] },
- { "category": "people", "char": "👉", "name": "point_right", "keywords": ["fingers", "hand", "direction", "right"] },
- { "category": "people", "char": "🖕", "name": "fu", "keywords": ["hand", "fingers", "rude", "middle", "flipping"] },
- { "category": "people", "char": "🖐", "name": "raised_hand_with_fingers_splayed", "keywords": ["hand", "fingers", "palm"] },
- { "category": "people", "char": "🤟", "name": "love_you", "keywords": ["hand", "fingers", "gesture"] },
- { "category": "people", "char": "🤘", "name": "metal", "keywords": ["hand", "fingers", "evil_eye", "sign_of_horns", "rock_on"] },
- { "category": "people", "char": "🤞", "name": "crossed_fingers", "keywords": ["good", "lucky"] },
- { "category": "people", "char": "🖖", "name": "vulcan_salute", "keywords": ["hand", "fingers", "spock", "star trek"] },
- { "category": "people", "char": "✍", "name": "writing_hand", "keywords": ["lower_left_ballpoint_pen", "stationery", "write", "compose"] },
- { "category": "people", "char": "\uD83E\uDEF0", "name": "hand_with_index_finger_and_thumb_crossed", "keywords": [] },
- { "category": "people", "char": "\uD83E\uDEF1", "name": "rightwards_hand", "keywords": [] },
- { "category": "people", "char": "\uD83E\uDEF2", "name": "leftwards_hand", "keywords": [] },
- { "category": "people", "char": "\uD83E\uDEF3", "name": "palm_down_hand", "keywords": [] },
- { "category": "people", "char": "\uD83E\uDEF4", "name": "palm_up_hand", "keywords": [] },
- { "category": "people", "char": "\uD83E\uDEF5", "name": "index_pointing_at_the_viewer", "keywords": [] },
- { "category": "people", "char": "\uD83E\uDEF6", "name": "heart_hands", "keywords": ["moemoekyun"] },
- { "category": "people", "char": "🤏", "name": "pinching_hand", "keywords": ["hand", "fingers"] },
- { "category": "people", "char": "🤌", "name": "pinched_fingers", "keywords": ["hand", "fingers"] },
- { "category": "people", "char": "🤳", "name": "selfie", "keywords": ["camera", "phone"] },
- { "category": "people", "char": "💅", "name": "nail_care", "keywords": ["beauty", "manicure", "finger", "fashion", "nail"] },
- { "category": "people", "char": "👄", "name": "lips", "keywords": ["mouth", "kiss"] },
- { "category": "people", "char": "\uD83E\uDEE6", "name": "biting_lip", "keywords": [] },
- { "category": "people", "char": "🦷", "name": "tooth", "keywords": ["teeth", "dentist"] },
- { "category": "people", "char": "👅", "name": "tongue", "keywords": ["mouth", "playful"] },
- { "category": "people", "char": "👂", "name": "ear", "keywords": ["face", "hear", "sound", "listen"] },
- { "category": "people", "char": "🦻", "name": "ear_with_hearing_aid", "keywords": ["face", "hear", "sound", "listen"] },
- { "category": "people", "char": "👃", "name": "nose", "keywords": ["smell", "sniff"] },
- { "category": "people", "char": "👁", "name": "eye", "keywords": ["face", "look", "see", "watch", "stare"] },
- { "category": "people", "char": "👀", "name": "eyes", "keywords": ["look", "watch", "stalk", "peek", "see"] },
- { "category": "people", "char": "🧠", "name": "brain", "keywords": ["smart", "intelligent"] },
- { "category": "people", "char": "🫀", "name": "anatomical_heart", "keywords": [] },
- { "category": "people", "char": "🫁", "name": "lungs", "keywords": [] },
- { "category": "people", "char": "👤", "name": "bust_in_silhouette", "keywords": ["user", "person", "human"] },
- { "category": "people", "char": "👥", "name": "busts_in_silhouette", "keywords": ["user", "person", "human", "group", "team"] },
- { "category": "people", "char": "🗣", "name": "speaking_head", "keywords": ["user", "person", "human", "sing", "say", "talk"] },
- { "category": "people", "char": "👶", "name": "baby", "keywords": ["child", "boy", "girl", "toddler"] },
- { "category": "people", "char": "🧒", "name": "child", "keywords": ["gender-neutral", "young"] },
- { "category": "people", "char": "👦", "name": "boy", "keywords": ["man", "male", "guy", "teenager"] },
- { "category": "people", "char": "👧", "name": "girl", "keywords": ["female", "woman", "teenager"] },
- { "category": "people", "char": "🧑", "name": "adult", "keywords": ["gender-neutral", "person"] },
- { "category": "people", "char": "👨", "name": "man", "keywords": ["mustache", "father", "dad", "guy", "classy", "sir", "moustache"] },
- { "category": "people", "char": "👩", "name": "woman", "keywords": ["female", "girls", "lady"] },
- { "category": "people", "char": "🧑‍🦱", "name": "curly_hair", "keywords": ["curly", "afro", "braids", "ringlets"] },
- { "category": "people", "char": "👩‍🦱", "name": "curly_hair_woman", "keywords": ["woman", "female", "girl", "curly", "afro", "braids", "ringlets"] },
- { "category": "people", "char": "👨‍🦱", "name": "curly_hair_man", "keywords": ["man", "male", "boy", "guy", "curly", "afro", "braids", "ringlets"] },
- { "category": "people", "char": "🧑‍🦰", "name": "red_hair", "keywords": ["redhead"] },
- { "category": "people", "char": "👩‍🦰", "name": "red_hair_woman", "keywords": ["woman", "female", "girl", "ginger", "redhead"] },
- { "category": "people", "char": "👨‍🦰", "name": "red_hair_man", "keywords": ["man", "male", "boy", "guy", "ginger", "redhead"] },
- { "category": "people", "char": "👱‍♀️", "name": "blonde_woman", "keywords": ["woman", "female", "girl", "blonde", "person"] },
- { "category": "people", "char": "👱", "name": "blonde_man", "keywords": ["man", "male", "boy", "blonde", "guy", "person"] },
- { "category": "people", "char": "🧑‍🦳", "name": "white_hair", "keywords": ["gray", "old", "white"] },
- { "category": "people", "char": "👩‍🦳", "name": "white_hair_woman", "keywords": ["woman", "female", "girl", "gray", "old", "white"] },
- { "category": "people", "char": "👨‍🦳", "name": "white_hair_man", "keywords": ["man", "male", "boy", "guy", "gray", "old", "white"] },
- { "category": "people", "char": "🧑‍🦲", "name": "bald", "keywords": ["bald", "chemotherapy", "hairless", "shaven"] },
- { "category": "people", "char": "👩‍🦲", "name": "bald_woman", "keywords": ["woman", "female", "girl", "bald", "chemotherapy", "hairless", "shaven"] },
- { "category": "people", "char": "👨‍🦲", "name": "bald_man", "keywords": ["man", "male", "boy", "guy", "bald", "chemotherapy", "hairless", "shaven"] },
- { "category": "people", "char": "🧔", "name": "bearded_person", "keywords": ["person", "bewhiskered"] },
- { "category": "people", "char": "🧓", "name": "older_adult", "keywords": ["human", "elder", "senior", "gender-neutral"] },
- { "category": "people", "char": "👴", "name": "older_man", "keywords": ["human", "male", "men", "old", "elder", "senior"] },
- { "category": "people", "char": "👵", "name": "older_woman", "keywords": ["human", "female", "women", "lady", "old", "elder", "senior"] },
- { "category": "people", "char": "👲", "name": "man_with_gua_pi_mao", "keywords": ["male", "boy", "chinese"] },
- { "category": "people", "char": "🧕", "name": "woman_with_headscarf", "keywords": ["female", "hijab", "mantilla", "tichel"] },
- { "category": "people", "char": "👳‍♀️", "name": "woman_with_turban", "keywords": ["female", "indian", "hinduism", "arabs", "woman"] },
- { "category": "people", "char": "👳", "name": "man_with_turban", "keywords": ["male", "indian", "hinduism", "arabs"] },
- { "category": "people", "char": "👮‍♀️", "name": "policewoman", "keywords": ["woman", "police", "law", "legal", "enforcement", "arrest", "911", "female"] },
- { "category": "people", "char": "👮", "name": "policeman", "keywords": ["man", "police", "law", "legal", "enforcement", "arrest", "911"] },
- { "category": "people", "char": "👷‍♀️", "name": "construction_worker_woman", "keywords": ["female", "human", "wip", "build", "construction", "worker", "labor", "woman"] },
- { "category": "people", "char": "👷", "name": "construction_worker_man", "keywords": ["male", "human", "wip", "guy", "build", "construction", "worker", "labor"] },
- { "category": "people", "char": "💂‍♀️", "name": "guardswoman", "keywords": ["uk", "gb", "british", "female", "royal", "woman"] },
- { "category": "people", "char": "💂", "name": "guardsman", "keywords": ["uk", "gb", "british", "male", "guy", "royal"] },
- { "category": "people", "char": "🕵️‍♀️", "name": "female_detective", "keywords": ["human", "spy", "detective", "female", "woman"] },
- { "category": "people", "char": "🕵", "name": "male_detective", "keywords": ["human", "spy", "detective"] },
- { "category": "people", "char": "🧑‍⚕️", "name": "health_worker", "keywords": ["doctor", "nurse", "therapist", "healthcare", "human"] },
- { "category": "people", "char": "👩‍⚕️", "name": "woman_health_worker", "keywords": ["doctor", "nurse", "therapist", "healthcare", "woman", "human"] },
- { "category": "people", "char": "👨‍⚕️", "name": "man_health_worker", "keywords": ["doctor", "nurse", "therapist", "healthcare", "man", "human"] },
- { "category": "people", "char": "🧑‍🌾", "name": "farmer", "keywords": ["rancher", "gardener", "human"] },
- { "category": "people", "char": "👩‍🌾", "name": "woman_farmer", "keywords": ["rancher", "gardener", "woman", "human"] },
- { "category": "people", "char": "👨‍🌾", "name": "man_farmer", "keywords": ["rancher", "gardener", "man", "human"] },
- { "category": "people", "char": "🧑‍🍳", "name": "cook", "keywords": ["chef", "human"] },
- { "category": "people", "char": "👩‍🍳", "name": "woman_cook", "keywords": ["chef", "woman", "human"] },
- { "category": "people", "char": "👨‍🍳", "name": "man_cook", "keywords": ["chef", "man", "human"] },
- { "category": "people", "char": "🧑‍🎓", "name": "student", "keywords": ["graduate", "human"] },
- { "category": "people", "char": "👩‍🎓", "name": "woman_student", "keywords": ["graduate", "woman", "human"] },
- { "category": "people", "char": "👨‍🎓", "name": "man_student", "keywords": ["graduate", "man", "human"] },
- { "category": "people", "char": "🧑‍🎤", "name": "singer", "keywords": ["rockstar", "entertainer", "human"] },
- { "category": "people", "char": "👩‍🎤", "name": "woman_singer", "keywords": ["rockstar", "entertainer", "woman", "human"] },
- { "category": "people", "char": "👨‍🎤", "name": "man_singer", "keywords": ["rockstar", "entertainer", "man", "human"] },
- { "category": "people", "char": "🧑‍🏫", "name": "teacher", "keywords": ["instructor", "professor", "human"] },
- { "category": "people", "char": "👩‍🏫", "name": "woman_teacher", "keywords": ["instructor", "professor", "woman", "human"] },
- { "category": "people", "char": "👨‍🏫", "name": "man_teacher", "keywords": ["instructor", "professor", "man", "human"] },
- { "category": "people", "char": "🧑‍🏭", "name": "factory_worker", "keywords": ["assembly", "industrial", "human"] },
- { "category": "people", "char": "👩‍🏭", "name": "woman_factory_worker", "keywords": ["assembly", "industrial", "woman", "human"] },
- { "category": "people", "char": "👨‍🏭", "name": "man_factory_worker", "keywords": ["assembly", "industrial", "man", "human"] },
- { "category": "people", "char": "🧑‍💻", "name": "technologist", "keywords": ["coder", "developer", "engineer", "programmer", "software", "human", "laptop", "computer"] },
- { "category": "people", "char": "👩‍💻", "name": "woman_technologist", "keywords": ["coder", "developer", "engineer", "programmer", "software", "woman", "human", "laptop", "computer"] },
- { "category": "people", "char": "👨‍💻", "name": "man_technologist", "keywords": ["coder", "developer", "engineer", "programmer", "software", "man", "human", "laptop", "computer"] },
- { "category": "people", "char": "🧑‍💼", "name": "office_worker", "keywords": ["business", "manager", "human"] },
- { "category": "people", "char": "👩‍💼", "name": "woman_office_worker", "keywords": ["business", "manager", "woman", "human"] },
- { "category": "people", "char": "👨‍💼", "name": "man_office_worker", "keywords": ["business", "manager", "man", "human"] },
- { "category": "people", "char": "🧑‍🔧", "name": "mechanic", "keywords": ["plumber", "human", "wrench"] },
- { "category": "people", "char": "👩‍🔧", "name": "woman_mechanic", "keywords": ["plumber", "woman", "human", "wrench"] },
- { "category": "people", "char": "👨‍🔧", "name": "man_mechanic", "keywords": ["plumber", "man", "human", "wrench"] },
- { "category": "people", "char": "🧑‍🔬", "name": "scientist", "keywords": ["biologist", "chemist", "engineer", "physicist", "human"] },
- { "category": "people", "char": "👩‍🔬", "name": "woman_scientist", "keywords": ["biologist", "chemist", "engineer", "physicist", "woman", "human"] },
- { "category": "people", "char": "👨‍🔬", "name": "man_scientist", "keywords": ["biologist", "chemist", "engineer", "physicist", "man", "human"] },
- { "category": "people", "char": "🧑‍🎨", "name": "artist", "keywords": ["painter", "human"] },
- { "category": "people", "char": "👩‍🎨", "name": "woman_artist", "keywords": ["painter", "woman", "human"] },
- { "category": "people", "char": "👨‍🎨", "name": "man_artist", "keywords": ["painter", "man", "human"] },
- { "category": "people", "char": "🧑‍🚒", "name": "firefighter", "keywords": ["fireman", "human"] },
- { "category": "people", "char": "👩‍🚒", "name": "woman_firefighter", "keywords": ["fireman", "woman", "human"] },
- { "category": "people", "char": "👨‍🚒", "name": "man_firefighter", "keywords": ["fireman", "man", "human"] },
- { "category": "people", "char": "🧑‍✈️", "name": "pilot", "keywords": ["aviator", "plane", "human"] },
- { "category": "people", "char": "👩‍✈️", "name": "woman_pilot", "keywords": ["aviator", "plane", "woman", "human"] },
- { "category": "people", "char": "👨‍✈️", "name": "man_pilot", "keywords": ["aviator", "plane", "man", "human"] },
- { "category": "people", "char": "🧑‍🚀", "name": "astronaut", "keywords": ["space", "rocket", "human"] },
- { "category": "people", "char": "👩‍🚀", "name": "woman_astronaut", "keywords": ["space", "rocket", "woman", "human"] },
- { "category": "people", "char": "👨‍🚀", "name": "man_astronaut", "keywords": ["space", "rocket", "man", "human"] },
- { "category": "people", "char": "🧑‍⚖️", "name": "judge", "keywords": ["justice", "court", "human"] },
- { "category": "people", "char": "👩‍⚖️", "name": "woman_judge", "keywords": ["justice", "court", "woman", "human"] },
- { "category": "people", "char": "👨‍⚖️", "name": "man_judge", "keywords": ["justice", "court", "man", "human"] },
- { "category": "people", "char": "🦸‍♀️", "name": "woman_superhero", "keywords": ["woman", "female", "good", "heroine", "superpowers"] },
- { "category": "people", "char": "🦸‍♂️", "name": "man_superhero", "keywords": ["man", "male", "good", "hero", "superpowers"] },
- { "category": "people", "char": "🦹‍♀️", "name": "woman_supervillain", "keywords": ["woman", "female", "evil", "bad", "criminal", "heroine", "superpowers"] },
- { "category": "people", "char": "🦹‍♂️", "name": "man_supervillain", "keywords": ["man", "male", "evil", "bad", "criminal", "hero", "superpowers"] },
- { "category": "people", "char": "🤶", "name": "mrs_claus", "keywords": ["woman", "female", "xmas", "mother christmas"] },
- { "category": "people", "char": "\uD83E\uDDD1\u200D\uD83C\uDF84", "name": "mx_claus", "keywords": ["xmas", "christmas"] },
- { "category": "people", "char": "🎅", "name": "santa", "keywords": ["festival", "man", "male", "xmas", "father christmas"] },
- { "category": "people", "char": "🥷", "name": "ninja", "keywords": [] },
- { "category": "people", "char": "🧙‍♀️", "name": "sorceress", "keywords": ["woman", "female", "mage", "witch"] },
- { "category": "people", "char": "🧙‍♂️", "name": "wizard", "keywords": ["man", "male", "mage", "sorcerer"] },
- { "category": "people", "char": "🧝‍♀️", "name": "woman_elf", "keywords": ["woman", "female"] },
- { "category": "people", "char": "🧝‍♂️", "name": "man_elf", "keywords": ["man", "male"] },
- { "category": "people", "char": "🧛‍♀️", "name": "woman_vampire", "keywords": ["woman", "female"] },
- { "category": "people", "char": "🧛‍♂️", "name": "man_vampire", "keywords": ["man", "male", "dracula"] },
- { "category": "people", "char": "🧟‍♀️", "name": "woman_zombie", "keywords": ["woman", "female", "undead", "walking dead"] },
- { "category": "people", "char": "🧟‍♂️", "name": "man_zombie", "keywords": ["man", "male", "dracula", "undead", "walking dead"] },
- { "category": "people", "char": "🧞‍♀️", "name": "woman_genie", "keywords": ["woman", "female"] },
- { "category": "people", "char": "🧞‍♂️", "name": "man_genie", "keywords": ["man", "male"] },
- { "category": "people", "char": "🧜‍♀️", "name": "mermaid", "keywords": ["woman", "female", "merwoman", "ariel"] },
- { "category": "people", "char": "🧜‍♂️", "name": "merman", "keywords": ["man", "male", "triton"] },
- { "category": "people", "char": "🧚‍♀️", "name": "woman_fairy", "keywords": ["woman", "female"] },
- { "category": "people", "char": "🧚‍♂️", "name": "man_fairy", "keywords": ["man", "male"] },
- { "category": "people", "char": "👼", "name": "angel", "keywords": ["heaven", "wings", "halo"] },
- { "category": "people", "char": "\uD83E\uDDCC", "name": "troll", "keywords": [] },
- { "category": "people", "char": "🤰", "name": "pregnant_woman", "keywords": ["baby"] },
- { "category": "people", "char": "\uD83E\uDEC3", "name": "pregnant_man", "keywords": [] },
- { "category": "people", "char": "\uD83E\uDEC4", "name": "pregnant_person", "keywords": [] },
- { "category": "people", "char": "\uD83E\uDEC5", "name": "person_with_crown", "keywords": [] },
- { "category": "people", "char": "🤱", "name": "breastfeeding", "keywords": ["nursing", "baby"] },
- { "category": "people", "char": "\uD83D\uDC69\u200D\uD83C\uDF7C", "name": "woman_feeding_baby", "keywords": [] },
- { "category": "people", "char": "\uD83D\uDC68\u200D\uD83C\uDF7C", "name": "man_feeding_baby", "keywords": [] },
- { "category": "people", "char": "\uD83E\uDDD1\u200D\uD83C\uDF7C", "name": "person_feeding_baby", "keywords": [] },
- { "category": "people", "char": "👸", "name": "princess", "keywords": ["girl", "woman", "female", "blond", "crown", "royal", "queen"] },
- { "category": "people", "char": "🤴", "name": "prince", "keywords": ["boy", "man", "male", "crown", "royal", "king"] },
- { "category": "people", "char": "👰", "name": "person_with_veil", "keywords": ["couple", "marriage", "wedding", "woman", "bride"] },
- { "category": "people", "char": "👰", "name": "bride_with_veil", "keywords": ["couple", "marriage", "wedding", "woman", "bride"] },
- { "category": "people", "char": "🤵", "name": "person_in_tuxedo", "keywords": ["couple", "marriage", "wedding", "groom"] },
- { "category": "people", "char": "🤵", "name": "man_in_tuxedo", "keywords": ["couple", "marriage", "wedding", "groom"] },
- { "category": "people", "char": "🏃‍♀️", "name": "running_woman", "keywords": ["woman", "walking", "exercise", "race", "running", "female"] },
- { "category": "people", "char": "🏃", "name": "running_man", "keywords": ["man", "walking", "exercise", "race", "running"] },
- { "category": "people", "char": "🚶‍♀️", "name": "walking_woman", "keywords": ["human", "feet", "steps", "woman", "female"] },
- { "category": "people", "char": "🚶", "name": "walking_man", "keywords": ["human", "feet", "steps"] },
- { "category": "people", "char": "💃", "name": "dancer", "keywords": ["female", "girl", "woman", "fun"] },
- { "category": "people", "char": "🕺", "name": "man_dancing", "keywords": ["male", "boy", "fun", "dancer"] },
- { "category": "people", "char": "👯", "name": "dancing_women", "keywords": ["female", "bunny", "women", "girls"] },
- { "category": "people", "char": "👯‍♂️", "name": "dancing_men", "keywords": ["male", "bunny", "men", "boys"] },
- { "category": "people", "char": "👫", "name": "couple", "keywords": ["pair", "people", "human", "love", "date", "dating", "like", "affection", "valentines", "marriage"] },
- { "category": "people", "char": "\uD83E\uDDD1\u200D\uD83E\uDD1D\u200D\uD83E\uDDD1", "name": "people_holding_hands", "keywords": ["pair", "couple", "love", "like", "bromance", "friendship", "people", "human"] },
- { "category": "people", "char": "👬", "name": "two_men_holding_hands", "keywords": ["pair", "couple", "love", "like", "bromance", "friendship", "people", "man", "human"] },
- { "category": "people", "char": "👭", "name": "two_women_holding_hands", "keywords": ["pair", "couple", "love", "like", "bromance", "friendship", "people", "female", "human"] },
- { "category": "people", "char": "🫂", "name": "people_hugging", "keywords": [] },
- { "category": "people", "char": "🙇‍♀️", "name": "bowing_woman", "keywords": ["woman", "female", "girl"] },
- { "category": "people", "char": "🙇", "name": "bowing_man", "keywords": ["man", "male", "boy"] },
- { "category": "people", "char": "🤦‍♂️", "name": "man_facepalming", "keywords": ["man", "male", "boy", "disbelief"] },
- { "category": "people", "char": "🤦‍♀️", "name": "woman_facepalming", "keywords": ["woman", "female", "girl", "disbelief"] },
- { "category": "people", "char": "🤷", "name": "woman_shrugging", "keywords": ["woman", "female", "girl", "confused", "indifferent", "doubt"] },
- { "category": "people", "char": "🤷‍♂️", "name": "man_shrugging", "keywords": ["man", "male", "boy", "confused", "indifferent", "doubt"] },
- { "category": "people", "char": "💁", "name": "tipping_hand_woman", "keywords": ["female", "girl", "woman", "human", "information"] },
- { "category": "people", "char": "💁‍♂️", "name": "tipping_hand_man", "keywords": ["male", "boy", "man", "human", "information"] },
- { "category": "people", "char": "🙅", "name": "no_good_woman", "keywords": ["female", "girl", "woman", "nope"] },
- { "category": "people", "char": "🙅‍♂️", "name": "no_good_man", "keywords": ["male", "boy", "man", "nope"] },
- { "category": "people", "char": "🙆", "name": "ok_woman", "keywords": ["women", "girl", "female", "pink", "human", "woman"] },
- { "category": "people", "char": "🙆‍♂️", "name": "ok_man", "keywords": ["men", "boy", "male", "blue", "human", "man"] },
- { "category": "people", "char": "🙋", "name": "raising_hand_woman", "keywords": ["female", "girl", "woman"] },
- { "category": "people", "char": "🙋‍♂️", "name": "raising_hand_man", "keywords": ["male", "boy", "man"] },
- { "category": "people", "char": "🙎", "name": "pouting_woman", "keywords": ["female", "girl", "woman"] },
- { "category": "people", "char": "🙎‍♂️", "name": "pouting_man", "keywords": ["male", "boy", "man"] },
- { "category": "people", "char": "🙍", "name": "frowning_woman", "keywords": ["female", "girl", "woman", "sad", "depressed", "discouraged", "unhappy"] },
- { "category": "people", "char": "🙍‍♂️", "name": "frowning_man", "keywords": ["male", "boy", "man", "sad", "depressed", "discouraged", "unhappy"] },
- { "category": "people", "char": "💇", "name": "haircut_woman", "keywords": ["female", "girl", "woman"] },
- { "category": "people", "char": "💇‍♂️", "name": "haircut_man", "keywords": ["male", "boy", "man"] },
- { "category": "people", "char": "💆", "name": "massage_woman", "keywords": ["female", "girl", "woman", "head"] },
- { "category": "people", "char": "💆‍♂️", "name": "massage_man", "keywords": ["male", "boy", "man", "head"] },
- { "category": "people", "char": "🧖‍♀️", "name": "woman_in_steamy_room", "keywords": ["female", "woman", "spa", "steamroom", "sauna"] },
- { "category": "people", "char": "🧖‍♂️", "name": "man_in_steamy_room", "keywords": ["male", "man", "spa", "steamroom", "sauna"] },
- { "category": "people", "char": "🧏‍♀️", "name": "woman_deaf", "keywords": ["woman", "female"] },
- { "category": "people", "char": "🧏‍♂️", "name": "man_deaf", "keywords": ["man", "male"] },
- { "category": "people", "char": "🧍‍♀️", "name": "woman_standing", "keywords": ["woman", "female"] },
- { "category": "people", "char": "🧍‍♂️", "name": "man_standing", "keywords": ["man", "male"] },
- { "category": "people", "char": "🧎‍♀️", "name": "woman_kneeling", "keywords": ["woman", "female"] },
- { "category": "people", "char": "🧎‍♂️", "name": "man_kneeling", "keywords": ["man", "male"] },
- { "category": "people", "char": "🧑‍🦯", "name": "person_with_probing_cane", "keywords": ["accessibility", "blind"] },
- { "category": "people", "char": "👩‍🦯", "name": "woman_with_probing_cane", "keywords": ["woman", "female", "accessibility", "blind"] },
- { "category": "people", "char": "👨‍🦯", "name": "man_with_probing_cane", "keywords": ["man", "male", "accessibility", "blind"] },
- { "category": "people", "char": "🧑‍🦼", "name": "person_in_motorized_wheelchair", "keywords": ["accessibility"] },
- { "category": "people", "char": "👩‍🦼", "name": "woman_in_motorized_wheelchair", "keywords": ["woman", "female", "accessibility"] },
- { "category": "people", "char": "👨‍🦼", "name": "man_in_motorized_wheelchair", "keywords": ["man", "male", "accessibility"] },
- { "category": "people", "char": "🧑‍🦽", "name": "person_in_manual_wheelchair", "keywords": ["accessibility"] },
- { "category": "people", "char": "👩‍🦽", "name": "woman_in_manual_wheelchair", "keywords": ["woman", "female", "accessibility"] },
- { "category": "people", "char": "👨‍🦽", "name": "man_in_manual_wheelchair", "keywords": ["man", "male", "accessibility"] },
- { "category": "people", "char": "💑", "name": "couple_with_heart_woman_man", "keywords": ["pair", "love", "like", "affection", "human", "dating", "valentines", "marriage"] },
- { "category": "people", "char": "👩‍❤️‍👩", "name": "couple_with_heart_woman_woman", "keywords": ["pair", "love", "like", "affection", "human", "dating", "valentines", "marriage"] },
- { "category": "people", "char": "👨‍❤️‍👨", "name": "couple_with_heart_man_man", "keywords": ["pair", "love", "like", "affection", "human", "dating", "valentines", "marriage"] },
- { "category": "people", "char": "💏", "name": "couplekiss_man_woman", "keywords": ["pair", "valentines", "love", "like", "dating", "marriage"] },
- { "category": "people", "char": "👩‍❤️‍💋‍👩", "name": "couplekiss_woman_woman", "keywords": ["pair", "valentines", "love", "like", "dating", "marriage"] },
- { "category": "people", "char": "👨‍❤️‍💋‍👨", "name": "couplekiss_man_man", "keywords": ["pair", "valentines", "love", "like", "dating", "marriage"] },
- { "category": "people", "char": "👪", "name": "family_man_woman_boy", "keywords": ["home", "parents", "child", "mom", "dad", "father", "mother", "people", "human"] },
- { "category": "people", "char": "👨‍👩‍👧", "name": "family_man_woman_girl", "keywords": ["home", "parents", "people", "human", "child"] },
- { "category": "people", "char": "👨‍👩‍👧‍👦", "name": "family_man_woman_girl_boy", "keywords": ["home", "parents", "people", "human", "children"] },
- { "category": "people", "char": "👨‍👩‍👦‍👦", "name": "family_man_woman_boy_boy", "keywords": ["home", "parents", "people", "human", "children"] },
- { "category": "people", "char": "👨‍👩‍👧‍👧", "name": "family_man_woman_girl_girl", "keywords": ["home", "parents", "people", "human", "children"] },
- { "category": "people", "char": "👩‍👩‍👦", "name": "family_woman_woman_boy", "keywords": ["home", "parents", "people", "human", "children"] },
- { "category": "people", "char": "👩‍👩‍👧", "name": "family_woman_woman_girl", "keywords": ["home", "parents", "people", "human", "children"] },
- { "category": "people", "char": "👩‍👩‍👧‍👦", "name": "family_woman_woman_girl_boy", "keywords": ["home", "parents", "people", "human", "children"] },
- { "category": "people", "char": "👩‍👩‍👦‍👦", "name": "family_woman_woman_boy_boy", "keywords": ["home", "parents", "people", "human", "children"] },
- { "category": "people", "char": "👩‍👩‍👧‍👧", "name": "family_woman_woman_girl_girl", "keywords": ["home", "parents", "people", "human", "children"] },
- { "category": "people", "char": "👨‍👨‍👦", "name": "family_man_man_boy", "keywords": ["home", "parents", "people", "human", "children"] },
- { "category": "people", "char": "👨‍👨‍👧", "name": "family_man_man_girl", "keywords": ["home", "parents", "people", "human", "children"] },
- { "category": "people", "char": "👨‍👨‍👧‍👦", "name": "family_man_man_girl_boy", "keywords": ["home", "parents", "people", "human", "children"] },
- { "category": "people", "char": "👨‍👨‍👦‍👦", "name": "family_man_man_boy_boy", "keywords": ["home", "parents", "people", "human", "children"] },
- { "category": "people", "char": "👨‍👨‍👧‍👧", "name": "family_man_man_girl_girl", "keywords": ["home", "parents", "people", "human", "children"] },
- { "category": "people", "char": "👩‍👦", "name": "family_woman_boy", "keywords": ["home", "parent", "people", "human", "child"] },
- { "category": "people", "char": "👩‍👧", "name": "family_woman_girl", "keywords": ["home", "parent", "people", "human", "child"] },
- { "category": "people", "char": "👩‍👧‍👦", "name": "family_woman_girl_boy", "keywords": ["home", "parent", "people", "human", "children"] },
- { "category": "people", "char": "👩‍👦‍👦", "name": "family_woman_boy_boy", "keywords": ["home", "parent", "people", "human", "children"] },
- { "category": "people", "char": "👩‍👧‍👧", "name": "family_woman_girl_girl", "keywords": ["home", "parent", "people", "human", "children"] },
- { "category": "people", "char": "👨‍👦", "name": "family_man_boy", "keywords": ["home", "parent", "people", "human", "child"] },
- { "category": "people", "char": "👨‍👧", "name": "family_man_girl", "keywords": ["home", "parent", "people", "human", "child"] },
- { "category": "people", "char": "👨‍👧‍👦", "name": "family_man_girl_boy", "keywords": ["home", "parent", "people", "human", "children"] },
- { "category": "people", "char": "👨‍👦‍👦", "name": "family_man_boy_boy", "keywords": ["home", "parent", "people", "human", "children"] },
- { "category": "people", "char": "👨‍👧‍👧", "name": "family_man_girl_girl", "keywords": ["home", "parent", "people", "human", "children"] },
- { "category": "people", "char": "🧶", "name": "yarn", "keywords": ["ball", "crochet", "knit"] },
- { "category": "people", "char": "🧵", "name": "thread", "keywords": ["needle", "sewing", "spool", "string"] },
- { "category": "people", "char": "🧥", "name": "coat", "keywords": ["jacket"] },
- { "category": "people", "char": "🥼", "name": "labcoat", "keywords": ["doctor", "experiment", "scientist", "chemist"] },
- { "category": "people", "char": "👚", "name": "womans_clothes", "keywords": ["fashion", "shopping_bags", "female"] },
- { "category": "people", "char": "👕", "name": "tshirt", "keywords": ["fashion", "cloth", "casual", "shirt", "tee"] },
- { "category": "people", "char": "👖", "name": "jeans", "keywords": ["fashion", "shopping"] },
- { "category": "people", "char": "👔", "name": "necktie", "keywords": ["shirt", "suitup", "formal", "fashion", "cloth", "business"] },
- { "category": "people", "char": "👗", "name": "dress", "keywords": ["clothes", "fashion", "shopping"] },
- { "category": "people", "char": "👙", "name": "bikini", "keywords": ["swimming", "female", "woman", "girl", "fashion", "beach", "summer"] },
- { "category": "people", "char": "🩱", "name": "one_piece_swimsuit", "keywords": ["swimming", "female", "woman", "girl", "fashion", "beach", "summer"] },
- { "category": "people", "char": "👘", "name": "kimono", "keywords": ["dress", "fashion", "women", "female", "japanese"] },
- { "category": "people", "char": "🥻", "name": "sari", "keywords": ["dress", "fashion", "women", "female"] },
- { "category": "people", "char": "🩲", "name": "briefs", "keywords": ["dress", "fashion"] },
- { "category": "people", "char": "🩳", "name": "shorts", "keywords": ["dress", "fashion"] },
- { "category": "people", "char": "💄", "name": "lipstick", "keywords": ["female", "girl", "fashion", "woman"] },
- { "category": "people", "char": "💋", "name": "kiss", "keywords": ["face", "lips", "love", "like", "affection", "valentines"] },
- { "category": "people", "char": "👣", "name": "footprints", "keywords": ["feet", "tracking", "walking", "beach"] },
- { "category": "people", "char": "🥿", "name": "flat_shoe", "keywords": ["ballet", "slip-on", "slipper"] },
- { "category": "people", "char": "👠", "name": "high_heel", "keywords": ["fashion", "shoes", "female", "pumps", "stiletto"] },
- { "category": "people", "char": "👡", "name": "sandal", "keywords": ["shoes", "fashion", "flip flops"] },
- { "category": "people", "char": "👢", "name": "boot", "keywords": ["shoes", "fashion"] },
- { "category": "people", "char": "👞", "name": "mans_shoe", "keywords": ["fashion", "male"] },
- { "category": "people", "char": "👟", "name": "athletic_shoe", "keywords": ["shoes", "sports", "sneakers"] },
- { "category": "people", "char": "🩴", "name": "thong_sandal", "keywords": [] },
- { "category": "people", "char": "🩰", "name": "ballet_shoes", "keywords": ["shoes", "sports"] },
- { "category": "people", "char": "🧦", "name": "socks", "keywords": ["stockings", "clothes"] },
- { "category": "people", "char": "🧤", "name": "gloves", "keywords": ["hands", "winter", "clothes"] },
- { "category": "people", "char": "🧣", "name": "scarf", "keywords": ["neck", "winter", "clothes"] },
- { "category": "people", "char": "👒", "name": "womans_hat", "keywords": ["fashion", "accessories", "female", "lady", "spring"] },
- { "category": "people", "char": "🎩", "name": "tophat", "keywords": ["magic", "gentleman", "classy", "circus"] },
- { "category": "people", "char": "🧢", "name": "billed_hat", "keywords": ["cap", "baseball"] },
- { "category": "people", "char": "⛑", "name": "rescue_worker_helmet", "keywords": ["construction", "build"] },
- { "category": "people", "char": "🪖", "name": "military_helmet", "keywords": [] },
- { "category": "people", "char": "🎓", "name": "mortar_board", "keywords": ["school", "college", "degree", "university", "graduation", "cap", "hat", "legal", "learn", "education"] },
- { "category": "people", "char": "👑", "name": "crown", "keywords": ["king", "kod", "leader", "royalty", "lord"] },
- { "category": "people", "char": "🎒", "name": "school_satchel", "keywords": ["student", "education", "bag", "backpack"] },
- { "category": "people", "char": "🧳", "name": "luggage", "keywords": ["packing", "travel"] },
- { "category": "people", "char": "👝", "name": "pouch", "keywords": ["bag", "accessories", "shopping"] },
- { "category": "people", "char": "👛", "name": "purse", "keywords": ["fashion", "accessories", "money", "sales", "shopping"] },
- { "category": "people", "char": "👜", "name": "handbag", "keywords": ["fashion", "accessory", "accessories", "shopping"] },
- { "category": "people", "char": "💼", "name": "briefcase", "keywords": ["business", "documents", "work", "law", "legal", "job", "career"] },
- { "category": "people", "char": "👓", "name": "eyeglasses", "keywords": ["fashion", "accessories", "eyesight", "nerdy", "dork", "geek"] },
- { "category": "people", "char": "🕶", "name": "dark_sunglasses", "keywords": ["face", "cool", "accessories"] },
- { "category": "people", "char": "🥽", "name": "goggles", "keywords": ["eyes", "protection", "safety"] },
- { "category": "people", "char": "💍", "name": "ring", "keywords": ["wedding", "propose", "marriage", "valentines", "diamond", "fashion", "jewelry", "gem", "engagement"] },
- { "category": "people", "char": "🌂", "name": "closed_umbrella", "keywords": ["weather", "rain", "drizzle"] },
- { "category": "animals_and_nature", "char": "🐶", "name": "dog", "keywords": ["animal", "friend", "nature", "woof", "puppy", "pet", "faithful"] },
- { "category": "animals_and_nature", "char": "🐱", "name": "cat", "keywords": ["animal", "meow", "nature", "pet", "kitten"] },
- { "category": "animals_and_nature", "char": "🐈‍⬛", "name": "black_cat", "keywords": ["animal", "meow", "nature", "pet", "kitten"] },
- { "category": "animals_and_nature", "char": "🐭", "name": "mouse", "keywords": ["animal", "nature", "cheese_wedge", "rodent"] },
- { "category": "animals_and_nature", "char": "🐹", "name": "hamster", "keywords": ["animal", "nature"] },
- { "category": "animals_and_nature", "char": "🐰", "name": "rabbit", "keywords": ["animal", "nature", "pet", "spring", "magic", "bunny"] },
- { "category": "animals_and_nature", "char": "🦊", "name": "fox_face", "keywords": ["animal", "nature", "face"] },
- { "category": "animals_and_nature", "char": "🐻", "name": "bear", "keywords": ["animal", "nature", "wild"] },
- { "category": "animals_and_nature", "char": "🐼", "name": "panda_face", "keywords": ["animal", "nature", "panda"] },
- { "category": "animals_and_nature", "char": "🐨", "name": "koala", "keywords": ["animal", "nature"] },
- { "category": "animals_and_nature", "char": "🐯", "name": "tiger", "keywords": ["animal", "cat", "danger", "wild", "nature", "roar"] },
- { "category": "animals_and_nature", "char": "🦁", "name": "lion", "keywords": ["animal", "nature"] },
- { "category": "animals_and_nature", "char": "🐮", "name": "cow", "keywords": ["beef", "ox", "animal", "nature", "moo", "milk"] },
- { "category": "animals_and_nature", "char": "🐷", "name": "pig", "keywords": ["animal", "oink", "nature"] },
- { "category": "animals_and_nature", "char": "🐽", "name": "pig_nose", "keywords": ["animal", "oink"] },
- { "category": "animals_and_nature", "char": "🐸", "name": "frog", "keywords": ["animal", "nature", "croak", "toad"] },
- { "category": "animals_and_nature", "char": "🦑", "name": "squid", "keywords": ["animal", "nature", "ocean", "sea"] },
- { "category": "animals_and_nature", "char": "🐙", "name": "octopus", "keywords": ["animal", "creature", "ocean", "sea", "nature", "beach"] },
- { "category": "animals_and_nature", "char": "🦐", "name": "shrimp", "keywords": ["animal", "ocean", "nature", "seafood"] },
- { "category": "animals_and_nature", "char": "🐵", "name": "monkey_face", "keywords": ["animal", "nature", "circus"] },
- { "category": "animals_and_nature", "char": "🦍", "name": "gorilla", "keywords": ["animal", "nature", "circus"] },
- { "category": "animals_and_nature", "char": "🙈", "name": "see_no_evil", "keywords": ["monkey", "animal", "nature", "haha"] },
- { "category": "animals_and_nature", "char": "🙉", "name": "hear_no_evil", "keywords": ["animal", "monkey", "nature"] },
- { "category": "animals_and_nature", "char": "🙊", "name": "speak_no_evil", "keywords": ["monkey", "animal", "nature", "omg"] },
- { "category": "animals_and_nature", "char": "🐒", "name": "monkey", "keywords": ["animal", "nature", "banana", "circus"] },
- { "category": "animals_and_nature", "char": "🐔", "name": "chicken", "keywords": ["animal", "cluck", "nature", "bird"] },
- { "category": "animals_and_nature", "char": "🐧", "name": "penguin", "keywords": ["animal", "nature"] },
- { "category": "animals_and_nature", "char": "🐦", "name": "bird", "keywords": ["animal", "nature", "fly", "tweet", "spring"] },
- { "category": "animals_and_nature", "char": "🐤", "name": "baby_chick", "keywords": ["animal", "chicken", "bird"] },
- { "category": "animals_and_nature", "char": "🐣", "name": "hatching_chick", "keywords": ["animal", "chicken", "egg", "born", "baby", "bird"] },
- { "category": "animals_and_nature", "char": "🐥", "name": "hatched_chick", "keywords": ["animal", "chicken", "baby", "bird"] },
- { "category": "animals_and_nature", "char": "🦆", "name": "duck", "keywords": ["animal", "nature", "bird", "mallard"] },
- { "category": "animals_and_nature", "char": "🦅", "name": "eagle", "keywords": ["animal", "nature", "bird"] },
- { "category": "animals_and_nature", "char": "🦉", "name": "owl", "keywords": ["animal", "nature", "bird", "hoot"] },
- { "category": "animals_and_nature", "char": "🦇", "name": "bat", "keywords": ["animal", "nature", "blind", "vampire"] },
- { "category": "animals_and_nature", "char": "🐺", "name": "wolf", "keywords": ["animal", "nature", "wild"] },
- { "category": "animals_and_nature", "char": "🐗", "name": "boar", "keywords": ["animal", "nature"] },
- { "category": "animals_and_nature", "char": "🐴", "name": "horse", "keywords": ["animal", "brown", "nature"] },
- { "category": "animals_and_nature", "char": "🦄", "name": "unicorn", "keywords": ["animal", "nature", "mystical"] },
- { "category": "animals_and_nature", "char": "🐝", "name": "honeybee", "keywords": ["animal", "insect", "nature", "bug", "spring", "honey"] },
- { "category": "animals_and_nature", "char": "🐛", "name": "bug", "keywords": ["animal", "insect", "nature", "worm"] },
- { "category": "animals_and_nature", "char": "🦋", "name": "butterfly", "keywords": ["animal", "insect", "nature", "caterpillar"] },
- { "category": "animals_and_nature", "char": "🐌", "name": "snail", "keywords": ["slow", "animal", "shell"] },
- { "category": "animals_and_nature", "char": "🐞", "name": "lady_beetle", "keywords": ["animal", "insect", "nature", "ladybug"] },
- { "category": "animals_and_nature", "char": "🐜", "name": "ant", "keywords": ["animal", "insect", "nature", "bug"] },
- { "category": "animals_and_nature", "char": "🦗", "name": "grasshopper", "keywords": ["animal", "cricket", "chirp"] },
- { "category": "animals_and_nature", "char": "🕷", "name": "spider", "keywords": ["animal", "arachnid"] },
- { "category": "animals_and_nature", "char": "🪲", "name": "beetle", "keywords": ["animal"] },
- { "category": "animals_and_nature", "char": "🪳", "name": "cockroach", "keywords": ["animal"] },
- { "category": "animals_and_nature", "char": "🪰", "name": "fly", "keywords": ["animal"] },
- { "category": "animals_and_nature", "char": "🪱", "name": "worm", "keywords": ["animal"] },
- { "category": "animals_and_nature", "char": "🦂", "name": "scorpion", "keywords": ["animal", "arachnid"] },
- { "category": "animals_and_nature", "char": "🦀", "name": "crab", "keywords": ["animal", "crustacean"] },
- { "category": "animals_and_nature", "char": "🐍", "name": "snake", "keywords": ["animal", "evil", "nature", "hiss", "python"] },
- { "category": "animals_and_nature", "char": "🦎", "name": "lizard", "keywords": ["animal", "nature", "reptile"] },
- { "category": "animals_and_nature", "char": "🦖", "name": "t-rex", "keywords": ["animal", "nature", "dinosaur", "tyrannosaurus", "extinct"] },
- { "category": "animals_and_nature", "char": "🦕", "name": "sauropod", "keywords": ["animal", "nature", "dinosaur", "brachiosaurus", "brontosaurus", "diplodocus", "extinct"] },
- { "category": "animals_and_nature", "char": "🐢", "name": "turtle", "keywords": ["animal", "slow", "nature", "tortoise"] },
- { "category": "animals_and_nature", "char": "🐠", "name": "tropical_fish", "keywords": ["animal", "swim", "ocean", "beach", "nemo"] },
- { "category": "animals_and_nature", "char": "🐟", "name": "fish", "keywords": ["animal", "food", "nature"] },
- { "category": "animals_and_nature", "char": "🐡", "name": "blowfish", "keywords": ["animal", "nature", "food", "sea", "ocean"] },
- { "category": "animals_and_nature", "char": "🐬", "name": "dolphin", "keywords": ["animal", "nature", "fish", "sea", "ocean", "flipper", "fins", "beach"] },
- { "category": "animals_and_nature", "char": "🦈", "name": "shark", "keywords": ["animal", "nature", "fish", "sea", "ocean", "jaws", "fins", "beach"] },
- { "category": "animals_and_nature", "char": "🐳", "name": "whale", "keywords": ["animal", "nature", "sea", "ocean"] },
- { "category": "animals_and_nature", "char": "🐋", "name": "whale2", "keywords": ["animal", "nature", "sea", "ocean"] },
- { "category": "animals_and_nature", "char": "🐊", "name": "crocodile", "keywords": ["animal", "nature", "reptile", "lizard", "alligator"] },
- { "category": "animals_and_nature", "char": "🐆", "name": "leopard", "keywords": ["animal", "nature"] },
- { "category": "animals_and_nature", "char": "🦓", "name": "zebra", "keywords": ["animal", "nature", "stripes", "safari"] },
- { "category": "animals_and_nature", "char": "🐅", "name": "tiger2", "keywords": ["animal", "nature", "roar"] },
- { "category": "animals_and_nature", "char": "🐃", "name": "water_buffalo", "keywords": ["animal", "nature", "ox", "cow"] },
- { "category": "animals_and_nature", "char": "🐂", "name": "ox", "keywords": ["animal", "cow", "beef"] },
- { "category": "animals_and_nature", "char": "🐄", "name": "cow2", "keywords": ["beef", "ox", "animal", "nature", "moo", "milk"] },
- { "category": "animals_and_nature", "char": "🦌", "name": "deer", "keywords": ["animal", "nature", "horns", "venison"] },
- { "category": "animals_and_nature", "char": "🐪", "name": "dromedary_camel", "keywords": ["animal", "hot", "desert", "hump"] },
- { "category": "animals_and_nature", "char": "🐫", "name": "camel", "keywords": ["animal", "nature", "hot", "desert", "hump"] },
- { "category": "animals_and_nature", "char": "🦒", "name": "giraffe", "keywords": ["animal", "nature", "spots", "safari"] },
- { "category": "animals_and_nature", "char": "🐘", "name": "elephant", "keywords": ["animal", "nature", "nose", "th", "circus"] },
- { "category": "animals_and_nature", "char": "🦏", "name": "rhinoceros", "keywords": ["animal", "nature", "horn"] },
- { "category": "animals_and_nature", "char": "🐐", "name": "goat", "keywords": ["animal", "nature"] },
- { "category": "animals_and_nature", "char": "🐏", "name": "ram", "keywords": ["animal", "sheep", "nature"] },
- { "category": "animals_and_nature", "char": "🐑", "name": "sheep", "keywords": ["animal", "nature", "wool", "shipit"] },
- { "category": "animals_and_nature", "char": "🐎", "name": "racehorse", "keywords": ["animal", "gamble", "luck"] },
- { "category": "animals_and_nature", "char": "🐖", "name": "pig2", "keywords": ["animal", "nature"] },
- { "category": "animals_and_nature", "char": "🐀", "name": "rat", "keywords": ["animal", "mouse", "rodent"] },
- { "category": "animals_and_nature", "char": "🐁", "name": "mouse2", "keywords": ["animal", "nature", "rodent"] },
- { "category": "animals_and_nature", "char": "🐓", "name": "rooster", "keywords": ["animal", "nature", "chicken"] },
- { "category": "animals_and_nature", "char": "🦃", "name": "turkey", "keywords": ["animal", "bird"] },
- { "category": "animals_and_nature", "char": "🕊", "name": "dove", "keywords": ["animal", "bird"] },
- { "category": "animals_and_nature", "char": "🐕", "name": "dog2", "keywords": ["animal", "nature", "friend", "doge", "pet", "faithful"] },
- { "category": "animals_and_nature", "char": "🐩", "name": "poodle", "keywords": ["dog", "animal", "101", "nature", "pet"] },
- { "category": "animals_and_nature", "char": "🐈", "name": "cat2", "keywords": ["animal", "meow", "pet", "cats"] },
- { "category": "animals_and_nature", "char": "🐇", "name": "rabbit2", "keywords": ["animal", "nature", "pet", "magic", "spring"] },
- { "category": "animals_and_nature", "char": "🐿", "name": "chipmunk", "keywords": ["animal", "nature", "rodent", "squirrel"] },
- { "category": "animals_and_nature", "char": "🦔", "name": "hedgehog", "keywords": ["animal", "nature", "spiny"] },
- { "category": "animals_and_nature", "char": "🦝", "name": "raccoon", "keywords": ["animal", "nature"] },
- { "category": "animals_and_nature", "char": "🦙", "name": "llama", "keywords": ["animal", "nature", "alpaca"] },
- { "category": "animals_and_nature", "char": "🦛", "name": "hippopotamus", "keywords": ["animal", "nature"] },
- { "category": "animals_and_nature", "char": "🦘", "name": "kangaroo", "keywords": ["animal", "nature", "australia", "joey", "hop", "marsupial"] },
- { "category": "animals_and_nature", "char": "🦡", "name": "badger", "keywords": ["animal", "nature", "honey"] },
- { "category": "animals_and_nature", "char": "🦢", "name": "swan", "keywords": ["animal", "nature", "bird"] },
- { "category": "animals_and_nature", "char": "🦚", "name": "peacock", "keywords": ["animal", "nature", "peahen", "bird"] },
- { "category": "animals_and_nature", "char": "🦜", "name": "parrot", "keywords": ["animal", "nature", "bird", "pirate", "talk"] },
- { "category": "animals_and_nature", "char": "🦞", "name": "lobster", "keywords": ["animal", "nature", "bisque", "claws", "seafood"] },
- { "category": "animals_and_nature", "char": "🦠", "name": "microbe", "keywords": ["amoeba", "bacteria", "germs"] },
- { "category": "animals_and_nature", "char": "🦟", "name": "mosquito", "keywords": ["animal", "nature", "insect", "malaria"] },
- { "category": "animals_and_nature", "char": "🦬", "name": "bison", "keywords": ["animal", "nature"] },
- { "category": "animals_and_nature", "char": "🦣", "name": "mammoth", "keywords": ["animal", "nature"] },
- { "category": "animals_and_nature", "char": "🦫", "name": "beaver", "keywords": ["animal", "nature"] },
- { "category": "animals_and_nature", "char": "🐻‍❄️", "name": "polar_bear", "keywords": ["animal", "nature"] },
- { "category": "animals_and_nature", "char": "🦤", "name": "dodo", "keywords": ["animal", "nature"] },
- { "category": "animals_and_nature", "char": "🪶", "name": "feather", "keywords": ["animal", "nature"] },
- { "category": "animals_and_nature", "char": "🦭", "name": "seal", "keywords": ["animal", "nature"] },
- { "category": "animals_and_nature", "char": "🐾", "name": "paw_prints", "keywords": ["animal", "tracking", "footprints", "dog", "cat", "pet", "feet"] },
- { "category": "animals_and_nature", "char": "🐉", "name": "dragon", "keywords": ["animal", "myth", "nature", "chinese", "green"] },
- { "category": "animals_and_nature", "char": "🐲", "name": "dragon_face", "keywords": ["animal", "myth", "nature", "chinese", "green"] },
- { "category": "animals_and_nature", "char": "🦧", "name": "orangutan", "keywords": ["animal", "nature"] },
- { "category": "animals_and_nature", "char": "🦮", "name": "guide_dog", "keywords": ["animal", "nature"] },
- { "category": "animals_and_nature", "char": "🐕‍🦺", "name": "service_dog", "keywords": ["animal", "nature"] },
- { "category": "animals_and_nature", "char": "🦥", "name": "sloth", "keywords": ["animal", "nature"] },
- { "category": "animals_and_nature", "char": "🦦", "name": "otter", "keywords": ["animal", "nature"] },
- { "category": "animals_and_nature", "char": "🦨", "name": "skunk", "keywords": ["animal", "nature"] },
- { "category": "animals_and_nature", "char": "🦩", "name": "flamingo", "keywords": ["animal", "nature"] },
- { "category": "animals_and_nature", "char": "🌵", "name": "cactus", "keywords": ["vegetable", "plant", "nature"] },
- { "category": "animals_and_nature", "char": "🎄", "name": "christmas_tree", "keywords": ["festival", "vacation", "december", "xmas", "celebration"] },
- { "category": "animals_and_nature", "char": "🌲", "name": "evergreen_tree", "keywords": ["plant", "nature"] },
- { "category": "animals_and_nature", "char": "🌳", "name": "deciduous_tree", "keywords": ["plant", "nature"] },
- { "category": "animals_and_nature", "char": "🌴", "name": "palm_tree", "keywords": ["plant", "vegetable", "nature", "summer", "beach", "mojito", "tropical"] },
- { "category": "animals_and_nature", "char": "🌱", "name": "seedling", "keywords": ["plant", "nature", "grass", "lawn", "spring"] },
- { "category": "animals_and_nature", "char": "🌿", "name": "herb", "keywords": ["vegetable", "plant", "medicine", "weed", "grass", "lawn"] },
- { "category": "animals_and_nature", "char": "☘", "name": "shamrock", "keywords": ["vegetable", "plant", "nature", "irish", "clover"] },
- { "category": "animals_and_nature", "char": "🍀", "name": "four_leaf_clover", "keywords": ["vegetable", "plant", "nature", "lucky", "irish"] },
- { "category": "animals_and_nature", "char": "🎍", "name": "bamboo", "keywords": ["plant", "nature", "vegetable", "panda", "pine_decoration"] },
- { "category": "animals_and_nature", "char": "🎋", "name": "tanabata_tree", "keywords": ["plant", "nature", "branch", "summer"] },
- { "category": "animals_and_nature", "char": "🍃", "name": "leaves", "keywords": ["nature", "plant", "tree", "vegetable", "grass", "lawn", "spring"] },
- { "category": "animals_and_nature", "char": "🍂", "name": "fallen_leaf", "keywords": ["nature", "plant", "vegetable", "leaves"] },
- { "category": "animals_and_nature", "char": "🍁", "name": "maple_leaf", "keywords": ["nature", "plant", "vegetable", "ca", "fall"] },
- { "category": "animals_and_nature", "char": "🌾", "name": "ear_of_rice", "keywords": ["nature", "plant"] },
- { "category": "animals_and_nature", "char": "🌺", "name": "hibiscus", "keywords": ["plant", "vegetable", "flowers", "beach"] },
- { "category": "animals_and_nature", "char": "🌻", "name": "sunflower", "keywords": ["nature", "plant", "fall"] },
- { "category": "animals_and_nature", "char": "🌹", "name": "rose", "keywords": ["flowers", "valentines", "love", "spring"] },
- { "category": "animals_and_nature", "char": "🥀", "name": "wilted_flower", "keywords": ["plant", "nature", "flower"] },
- { "category": "animals_and_nature", "char": "🌷", "name": "tulip", "keywords": ["flowers", "plant", "nature", "summer", "spring"] },
- { "category": "animals_and_nature", "char": "🌼", "name": "blossom", "keywords": ["nature", "flowers", "yellow"] },
- { "category": "animals_and_nature", "char": "🌸", "name": "cherry_blossom", "keywords": ["nature", "plant", "spring", "flower"] },
- { "category": "animals_and_nature", "char": "💐", "name": "bouquet", "keywords": ["flowers", "nature", "spring"] },
- { "category": "animals_and_nature", "char": "🍄", "name": "mushroom", "keywords": ["plant", "vegetable"] },
- { "category": "animals_and_nature", "char": "🪴", "name": "potted_plant", "keywords": ["plant"] },
- { "category": "animals_and_nature", "char": "🌰", "name": "chestnut", "keywords": ["food", "squirrel"] },
- { "category": "animals_and_nature", "char": "🎃", "name": "jack_o_lantern", "keywords": ["halloween", "light", "pumpkin", "creepy", "fall"] },
- { "category": "animals_and_nature", "char": "🐚", "name": "shell", "keywords": ["nature", "sea", "beach"] },
- { "category": "animals_and_nature", "char": "🕸", "name": "spider_web", "keywords": ["animal", "insect", "arachnid", "silk"] },
- { "category": "animals_and_nature", "char": "🌎", "name": "earth_americas", "keywords": ["globe", "world", "USA", "international"] },
- { "category": "animals_and_nature", "char": "🌍", "name": "earth_africa", "keywords": ["globe", "world", "international"] },
- { "category": "animals_and_nature", "char": "🌏", "name": "earth_asia", "keywords": ["globe", "world", "east", "international"] },
- { "category": "animals_and_nature", "char": "🪐", "name": "ringed_planet", "keywords": ["saturn"] },
- { "category": "animals_and_nature", "char": "🌕", "name": "full_moon", "keywords": ["nature", "yellow", "twilight", "planet", "space", "night", "evening", "sleep"] },
- { "category": "animals_and_nature", "char": "🌖", "name": "waning_gibbous_moon", "keywords": ["nature", "twilight", "planet", "space", "night", "evening", "sleep", "waxing_gibbous_moon"] },
- { "category": "animals_and_nature", "char": "🌗", "name": "last_quarter_moon", "keywords": ["nature", "twilight", "planet", "space", "night", "evening", "sleep"] },
- { "category": "animals_and_nature", "char": "🌘", "name": "waning_crescent_moon", "keywords": ["nature", "twilight", "planet", "space", "night", "evening", "sleep"] },
- { "category": "animals_and_nature", "char": "🌑", "name": "new_moon", "keywords": ["nature", "twilight", "planet", "space", "night", "evening", "sleep"] },
- { "category": "animals_and_nature", "char": "🌒", "name": "waxing_crescent_moon", "keywords": ["nature", "twilight", "planet", "space", "night", "evening", "sleep"] },
- { "category": "animals_and_nature", "char": "🌓", "name": "first_quarter_moon", "keywords": ["nature", "twilight", "planet", "space", "night", "evening", "sleep"] },
- { "category": "animals_and_nature", "char": "🌔", "name": "waxing_gibbous_moon", "keywords": ["nature", "night", "sky", "gray", "twilight", "planet", "space", "evening", "sleep"] },
- { "category": "animals_and_nature", "char": "🌚", "name": "new_moon_with_face", "keywords": ["nature", "twilight", "planet", "space", "night", "evening", "sleep"] },
- { "category": "animals_and_nature", "char": "🌝", "name": "full_moon_with_face", "keywords": ["nature", "twilight", "planet", "space", "night", "evening", "sleep"] },
- { "category": "animals_and_nature", "char": "🌛", "name": "first_quarter_moon_with_face", "keywords": ["nature", "twilight", "planet", "space", "night", "evening", "sleep"] },
- { "category": "animals_and_nature", "char": "🌜", "name": "last_quarter_moon_with_face", "keywords": ["nature", "twilight", "planet", "space", "night", "evening", "sleep"] },
- { "category": "animals_and_nature", "char": "🌞", "name": "sun_with_face", "keywords": ["nature", "morning", "sky"] },
- { "category": "animals_and_nature", "char": "🌙", "name": "crescent_moon", "keywords": ["night", "sleep", "sky", "evening", "magic"] },
- { "category": "animals_and_nature", "char": "⭐", "name": "star", "keywords": ["night", "yellow"] },
- { "category": "animals_and_nature", "char": "🌟", "name": "star2", "keywords": ["night", "sparkle", "awesome", "good", "magic"] },
- { "category": "animals_and_nature", "char": "💫", "name": "dizzy", "keywords": ["star", "sparkle", "shoot", "magic"] },
- { "category": "animals_and_nature", "char": "✨", "name": "sparkles", "keywords": ["stars", "shine", "shiny", "cool", "awesome", "good", "magic"] },
- { "category": "animals_and_nature", "char": "☄", "name": "comet", "keywords": ["space"] },
- { "category": "animals_and_nature", "char": "☀️", "name": "sunny", "keywords": ["weather", "nature", "brightness", "summer", "beach", "spring"] },
- { "category": "animals_and_nature", "char": "🌤", "name": "sun_behind_small_cloud", "keywords": ["weather"] },
- { "category": "animals_and_nature", "char": "⛅", "name": "partly_sunny", "keywords": ["weather", "nature", "cloudy", "morning", "fall", "spring"] },
- { "category": "animals_and_nature", "char": "🌥", "name": "sun_behind_large_cloud", "keywords": ["weather"] },
- { "category": "animals_and_nature", "char": "🌦", "name": "sun_behind_rain_cloud", "keywords": ["weather"] },
- { "category": "animals_and_nature", "char": "☁️", "name": "cloud", "keywords": ["weather", "sky"] },
- { "category": "animals_and_nature", "char": "🌧", "name": "cloud_with_rain", "keywords": ["weather"] },
- { "category": "animals_and_nature", "char": "⛈", "name": "cloud_with_lightning_and_rain", "keywords": ["weather", "lightning"] },
- { "category": "animals_and_nature", "char": "🌩", "name": "cloud_with_lightning", "keywords": ["weather", "thunder"] },
- { "category": "animals_and_nature", "char": "⚡", "name": "zap", "keywords": ["thunder", "weather", "lightning bolt", "fast"] },
- { "category": "animals_and_nature", "char": "🔥", "name": "fire", "keywords": ["hot", "cook", "flame"] },
- { "category": "animals_and_nature", "char": "💥", "name": "boom", "keywords": ["bomb", "explode", "explosion", "collision", "blown"] },
- { "category": "animals_and_nature", "char": "❄️", "name": "snowflake", "keywords": ["winter", "season", "cold", "weather", "christmas", "xmas"] },
- { "category": "animals_and_nature", "char": "🌨", "name": "cloud_with_snow", "keywords": ["weather"] },
- { "category": "animals_and_nature", "char": "⛄", "name": "snowman", "keywords": ["winter", "season", "cold", "weather", "christmas", "xmas", "frozen", "without_snow"] },
- { "category": "animals_and_nature", "char": "☃", "name": "snowman_with_snow", "keywords": ["winter", "season", "cold", "weather", "christmas", "xmas", "frozen"] },
- { "category": "animals_and_nature", "char": "🌬", "name": "wind_face", "keywords": ["gust", "air"] },
- { "category": "animals_and_nature", "char": "💨", "name": "dash", "keywords": ["wind", "air", "fast", "shoo", "fart", "smoke", "puff"] },
- { "category": "animals_and_nature", "char": "🌪", "name": "tornado", "keywords": ["weather", "cyclone", "twister"] },
- { "category": "animals_and_nature", "char": "🌫", "name": "fog", "keywords": ["weather"] },
- { "category": "animals_and_nature", "char": "☂", "name": "open_umbrella", "keywords": ["weather", "spring"] },
- { "category": "animals_and_nature", "char": "☔", "name": "umbrella", "keywords": ["rainy", "weather", "spring"] },
- { "category": "animals_and_nature", "char": "💧", "name": "droplet", "keywords": ["water", "drip", "faucet", "spring"] },
- { "category": "animals_and_nature", "char": "💦", "name": "sweat_drops", "keywords": ["water", "drip", "oops"] },
- { "category": "animals_and_nature", "char": "🌊", "name": "ocean", "keywords": ["sea", "water", "wave", "nature", "tsunami", "disaster"] },
- { "category": "animals_and_nature", "char": "\uD83E\uDEB7", "name": "lotus", "keywords": [] },
- { "category": "animals_and_nature", "char": "\uD83E\uDEB8", "name": "coral", "keywords": [] },
- { "category": "animals_and_nature", "char": "\uD83E\uDEB9", "name": "empty_nest", "keywords": [] },
- { "category": "animals_and_nature", "char": "\uD83E\uDEBA", "name": "nest_with_eggs", "keywords": [] },
- { "category": "food_and_drink", "char": "🍏", "name": "green_apple", "keywords": ["fruit", "nature"] },
- { "category": "food_and_drink", "char": "🍎", "name": "apple", "keywords": ["fruit", "mac", "school"] },
- { "category": "food_and_drink", "char": "🍐", "name": "pear", "keywords": ["fruit", "nature", "food"] },
- { "category": "food_and_drink", "char": "🍊", "name": "tangerine", "keywords": ["food", "fruit", "nature", "orange"] },
- { "category": "food_and_drink", "char": "🍋", "name": "lemon", "keywords": ["fruit", "nature"] },
- { "category": "food_and_drink", "char": "🍌", "name": "banana", "keywords": ["fruit", "food", "monkey"] },
- { "category": "food_and_drink", "char": "🍉", "name": "watermelon", "keywords": ["fruit", "food", "picnic", "summer"] },
- { "category": "food_and_drink", "char": "🍇", "name": "grapes", "keywords": ["fruit", "food", "wine"] },
- { "category": "food_and_drink", "char": "🍓", "name": "strawberry", "keywords": ["fruit", "food", "nature"] },
- { "category": "food_and_drink", "char": "🍈", "name": "melon", "keywords": ["fruit", "nature", "food"] },
- { "category": "food_and_drink", "char": "🍒", "name": "cherries", "keywords": ["food", "fruit"] },
- { "category": "food_and_drink", "char": "🍑", "name": "peach", "keywords": ["fruit", "nature", "food"] },
- { "category": "food_and_drink", "char": "🍍", "name": "pineapple", "keywords": ["fruit", "nature", "food"] },
- { "category": "food_and_drink", "char": "🥥", "name": "coconut", "keywords": ["fruit", "nature", "food", "palm"] },
- { "category": "food_and_drink", "char": "🥝", "name": "kiwi_fruit", "keywords": ["fruit", "food"] },
- { "category": "food_and_drink", "char": "🥭", "name": "mango", "keywords": ["fruit", "food", "tropical"] },
- { "category": "food_and_drink", "char": "🥑", "name": "avocado", "keywords": ["fruit", "food"] },
- { "category": "food_and_drink", "char": "🥦", "name": "broccoli", "keywords": ["fruit", "food", "vegetable"] },
- { "category": "food_and_drink", "char": "🍅", "name": "tomato", "keywords": ["fruit", "vegetable", "nature", "food"] },
- { "category": "food_and_drink", "char": "🍆", "name": "eggplant", "keywords": ["vegetable", "nature", "food", "aubergine"] },
- { "category": "food_and_drink", "char": "🥒", "name": "cucumber", "keywords": ["fruit", "food", "pickle"] },
- { "category": "food_and_drink", "char": "🫐", "name": "blueberries", "keywords": ["fruit", "food"] },
- { "category": "food_and_drink", "char": "🫒", "name": "olive", "keywords": ["fruit", "food"] },
- { "category": "food_and_drink", "char": "🫑", "name": "bell_pepper", "keywords": ["fruit", "food"] },
- { "category": "food_and_drink", "char": "🥕", "name": "carrot", "keywords": ["vegetable", "food", "orange"] },
- { "category": "food_and_drink", "char": "🌶", "name": "hot_pepper", "keywords": ["food", "spicy", "chilli", "chili"] },
- { "category": "food_and_drink", "char": "🥔", "name": "potato", "keywords": ["food", "tuber", "vegatable", "starch"] },
- { "category": "food_and_drink", "char": "🌽", "name": "corn", "keywords": ["food", "vegetable", "plant"] },
- { "category": "food_and_drink", "char": "🥬", "name": "leafy_greens", "keywords": ["food", "vegetable", "plant", "bok choy", "cabbage", "kale", "lettuce"] },
- { "category": "food_and_drink", "char": "🍠", "name": "sweet_potato", "keywords": ["food", "nature"] },
- { "category": "food_and_drink", "char": "🥜", "name": "peanuts", "keywords": ["food", "nut"] },
- { "category": "food_and_drink", "char": "🧄", "name": "garlic", "keywords": ["food"] },
- { "category": "food_and_drink", "char": "🧅", "name": "onion", "keywords": ["food"] },
- { "category": "food_and_drink", "char": "🍯", "name": "honey_pot", "keywords": ["bees", "sweet", "kitchen"] },
- { "category": "food_and_drink", "char": "🥐", "name": "croissant", "keywords": ["food", "bread", "french"] },
- { "category": "food_and_drink", "char": "🍞", "name": "bread", "keywords": ["food", "wheat", "breakfast", "toast"] },
- { "category": "food_and_drink", "char": "🥖", "name": "baguette_bread", "keywords": ["food", "bread", "french"] },
- { "category": "food_and_drink", "char": "🥯", "name": "bagel", "keywords": ["food", "bread", "bakery", "schmear"] },
- { "category": "food_and_drink", "char": "🥨", "name": "pretzel", "keywords": ["food", "bread", "twisted"] },
- { "category": "food_and_drink", "char": "🧀", "name": "cheese", "keywords": ["food", "chadder"] },
- { "category": "food_and_drink", "char": "🥚", "name": "egg", "keywords": ["food", "chicken", "breakfast"] },
- { "category": "food_and_drink", "char": "🥓", "name": "bacon", "keywords": ["food", "breakfast", "pork", "pig", "meat"] },
- { "category": "food_and_drink", "char": "🥩", "name": "steak", "keywords": ["food", "cow", "meat", "cut", "chop", "lambchop", "porkchop"] },
- { "category": "food_and_drink", "char": "🥞", "name": "pancakes", "keywords": ["food", "breakfast", "flapjacks", "hotcakes"] },
- { "category": "food_and_drink", "char": "🍗", "name": "poultry_leg", "keywords": ["food", "meat", "drumstick", "bird", "chicken", "turkey"] },
- { "category": "food_and_drink", "char": "🍖", "name": "meat_on_bone", "keywords": ["good", "food", "drumstick"] },
- { "category": "food_and_drink", "char": "🦴", "name": "bone", "keywords": ["skeleton"] },
- { "category": "food_and_drink", "char": "🍤", "name": "fried_shrimp", "keywords": ["food", "animal", "appetizer", "summer"] },
- { "category": "food_and_drink", "char": "🍳", "name": "fried_egg", "keywords": ["food", "breakfast", "kitchen", "egg"] },
- { "category": "food_and_drink", "char": "🍔", "name": "hamburger", "keywords": ["meat", "fast food", "beef", "cheeseburger", "mcdonalds", "burger king"] },
- { "category": "food_and_drink", "char": "🍟", "name": "fries", "keywords": ["chips", "snack", "fast food"] },
- { "category": "food_and_drink", "char": "🥙", "name": "stuffed_flatbread", "keywords": ["food", "flatbread", "stuffed", "gyro"] },
- { "category": "food_and_drink", "char": "🌭", "name": "hotdog", "keywords": ["food", "frankfurter"] },
- { "category": "food_and_drink", "char": "🍕", "name": "pizza", "keywords": ["food", "party"] },
- { "category": "food_and_drink", "char": "🥪", "name": "sandwich", "keywords": ["food", "lunch", "bread"] },
- { "category": "food_and_drink", "char": "🥫", "name": "canned_food", "keywords": ["food", "soup"] },
- { "category": "food_and_drink", "char": "🍝", "name": "spaghetti", "keywords": ["food", "italian", "noodle"] },
- { "category": "food_and_drink", "char": "🌮", "name": "taco", "keywords": ["food", "mexican"] },
- { "category": "food_and_drink", "char": "🌯", "name": "burrito", "keywords": ["food", "mexican"] },
- { "category": "food_and_drink", "char": "🥗", "name": "green_salad", "keywords": ["food", "healthy", "lettuce"] },
- { "category": "food_and_drink", "char": "🥘", "name": "shallow_pan_of_food", "keywords": ["food", "cooking", "casserole", "paella"] },
- { "category": "food_and_drink", "char": "🍜", "name": "ramen", "keywords": ["food", "japanese", "noodle", "chopsticks"] },
- { "category": "food_and_drink", "char": "🍲", "name": "stew", "keywords": ["food", "meat", "soup"] },
- { "category": "food_and_drink", "char": "🍥", "name": "fish_cake", "keywords": ["food", "japan", "sea", "beach", "narutomaki", "pink", "swirl", "kamaboko", "surimi", "ramen"] },
- { "category": "food_and_drink", "char": "🥠", "name": "fortune_cookie", "keywords": ["food", "prophecy"] },
- { "category": "food_and_drink", "char": "🍣", "name": "sushi", "keywords": ["food", "fish", "japanese", "rice"] },
- { "category": "food_and_drink", "char": "🍱", "name": "bento", "keywords": ["food", "japanese", "box"] },
- { "category": "food_and_drink", "char": "🍛", "name": "curry", "keywords": ["food", "spicy", "hot", "indian"] },
- { "category": "food_and_drink", "char": "🍙", "name": "rice_ball", "keywords": ["food", "japanese"] },
- { "category": "food_and_drink", "char": "🍚", "name": "rice", "keywords": ["food", "china", "asian"] },
- { "category": "food_and_drink", "char": "🍘", "name": "rice_cracker", "keywords": ["food", "japanese"] },
- { "category": "food_and_drink", "char": "🍢", "name": "oden", "keywords": ["food", "japanese"] },
- { "category": "food_and_drink", "char": "🍡", "name": "dango", "keywords": ["food", "dessert", "sweet", "japanese", "barbecue", "meat"] },
- { "category": "food_and_drink", "char": "🍧", "name": "shaved_ice", "keywords": ["hot", "dessert", "summer"] },
- { "category": "food_and_drink", "char": "🍨", "name": "ice_cream", "keywords": ["food", "hot", "dessert"] },
- { "category": "food_and_drink", "char": "🍦", "name": "icecream", "keywords": ["food", "hot", "dessert", "summer"] },
- { "category": "food_and_drink", "char": "🥧", "name": "pie", "keywords": ["food", "dessert", "pastry"] },
- { "category": "food_and_drink", "char": "🍰", "name": "cake", "keywords": ["food", "dessert"] },
- { "category": "food_and_drink", "char": "🧁", "name": "cupcake", "keywords": ["food", "dessert", "bakery", "sweet"] },
- { "category": "food_and_drink", "char": "🥮", "name": "moon_cake", "keywords": ["food", "autumn"] },
- { "category": "food_and_drink", "char": "🎂", "name": "birthday", "keywords": ["food", "dessert", "cake"] },
- { "category": "food_and_drink", "char": "🍮", "name": "custard", "keywords": ["dessert", "food"] },
- { "category": "food_and_drink", "char": "🍬", "name": "candy", "keywords": ["snack", "dessert", "sweet", "lolly"] },
- { "category": "food_and_drink", "char": "🍭", "name": "lollipop", "keywords": ["food", "snack", "candy", "sweet"] },
- { "category": "food_and_drink", "char": "🍫", "name": "chocolate_bar", "keywords": ["food", "snack", "dessert", "sweet"] },
- { "category": "food_and_drink", "char": "🍿", "name": "popcorn", "keywords": ["food", "movie theater", "films", "snack"] },
- { "category": "food_and_drink", "char": "🥟", "name": "dumpling", "keywords": ["food", "empanada", "pierogi", "potsticker"] },
- { "category": "food_and_drink", "char": "🍩", "name": "doughnut", "keywords": ["food", "dessert", "snack", "sweet", "donut"] },
- { "category": "food_and_drink", "char": "🍪", "name": "cookie", "keywords": ["food", "snack", "oreo", "chocolate", "sweet", "dessert"] },
- { "category": "food_and_drink", "char": "🧇", "name": "waffle", "keywords": ["food"] },
- { "category": "food_and_drink", "char": "🧆", "name": "falafel", "keywords": ["food"] },
- { "category": "food_and_drink", "char": "🧈", "name": "butter", "keywords": ["food"] },
- { "category": "food_and_drink", "char": "🦪", "name": "oyster", "keywords": ["food"] },
- { "category": "food_and_drink", "char": "🫓", "name": "flatbread", "keywords": ["food"] },
- { "category": "food_and_drink", "char": "🫔", "name": "tamale", "keywords": ["food"] },
- { "category": "food_and_drink", "char": "🫕", "name": "fondue", "keywords": ["food"] },
- { "category": "food_and_drink", "char": "🥛", "name": "milk_glass", "keywords": ["beverage", "drink", "cow"] },
- { "category": "food_and_drink", "char": "🍺", "name": "beer", "keywords": ["relax", "beverage", "drink", "drunk", "party", "pub", "summer", "alcohol", "booze"] },
- { "category": "food_and_drink", "char": "🍻", "name": "beers", "keywords": ["relax", "beverage", "drink", "drunk", "party", "pub", "summer", "alcohol", "booze"] },
- { "category": "food_and_drink", "char": "🥂", "name": "clinking_glasses", "keywords": ["beverage", "drink", "party", "alcohol", "celebrate", "cheers", "wine", "champagne", "toast"] },
- { "category": "food_and_drink", "char": "🍷", "name": "wine_glass", "keywords": ["drink", "beverage", "drunk", "alcohol", "booze"] },
- { "category": "food_and_drink", "char": "🥃", "name": "tumbler_glass", "keywords": ["drink", "beverage", "drunk", "alcohol", "liquor", "booze", "bourbon", "scotch", "whisky", "glass", "shot"] },
- { "category": "food_and_drink", "char": "🍸", "name": "cocktail", "keywords": ["drink", "drunk", "alcohol", "beverage", "booze", "mojito"] },
- { "category": "food_and_drink", "char": "🍹", "name": "tropical_drink", "keywords": ["beverage", "cocktail", "summer", "beach", "alcohol", "booze", "mojito"] },
- { "category": "food_and_drink", "char": "🍾", "name": "champagne", "keywords": ["drink", "wine", "bottle", "celebration"] },
- { "category": "food_and_drink", "char": "🍶", "name": "sake", "keywords": ["wine", "drink", "drunk", "beverage", "japanese", "alcohol", "booze"] },
- { "category": "food_and_drink", "char": "🍵", "name": "tea", "keywords": ["drink", "bowl", "breakfast", "green", "british"] },
- { "category": "food_and_drink", "char": "🥤", "name": "cup_with_straw", "keywords": ["drink", "soda"] },
- { "category": "food_and_drink", "char": "☕", "name": "coffee", "keywords": ["beverage", "caffeine", "latte", "espresso"] },
- { "category": "food_and_drink", "char": "🫖", "name": "teapot", "keywords": [] },
- { "category": "food_and_drink", "char": "🧋", "name": "bubble_tea", "keywords": ["tapioca"] },
- { "category": "food_and_drink", "char": "🍼", "name": "baby_bottle", "keywords": ["food", "container", "milk"] },
- { "category": "food_and_drink", "char": "🧃", "name": "beverage_box", "keywords": ["food", "drink"] },
- { "category": "food_and_drink", "char": "🧉", "name": "mate", "keywords": ["food", "drink"] },
- { "category": "food_and_drink", "char": "🧊", "name": "ice_cube", "keywords": ["food"] },
- { "category": "food_and_drink", "char": "🧂", "name": "salt", "keywords": ["condiment", "shaker"] },
- { "category": "food_and_drink", "char": "🥄", "name": "spoon", "keywords": ["cutlery", "kitchen", "tableware"] },
- { "category": "food_and_drink", "char": "🍴", "name": "fork_and_knife", "keywords": ["cutlery", "kitchen"] },
- { "category": "food_and_drink", "char": "🍽", "name": "plate_with_cutlery", "keywords": ["food", "eat", "meal", "lunch", "dinner", "restaurant"] },
- { "category": "food_and_drink", "char": "🥣", "name": "bowl_with_spoon", "keywords": ["food", "breakfast", "cereal", "oatmeal", "porridge"] },
- { "category": "food_and_drink", "char": "🥡", "name": "takeout_box", "keywords": ["food", "leftovers"] },
- { "category": "food_and_drink", "char": "🥢", "name": "chopsticks", "keywords": ["food"] },
- { "category": "food_and_drink", "char": "\uD83E\uDED7", "name": "pouring_liquid", "keywords": [] },
- { "category": "food_and_drink", "char": "\uD83E\uDED8", "name": "beans", "keywords": [] },
- { "category": "food_and_drink", "char": "\uD83E\uDED9", "name": "jar", "keywords": [] },
- { "category": "activity", "char": "⚽", "name": "soccer", "keywords": ["sports", "football"] },
- { "category": "activity", "char": "🏀", "name": "basketball", "keywords": ["sports", "balls", "NBA"] },
- { "category": "activity", "char": "🏈", "name": "football", "keywords": ["sports", "balls", "NFL"] },
- { "category": "activity", "char": "⚾", "name": "baseball", "keywords": ["sports", "balls"] },
- { "category": "activity", "char": "🥎", "name": "softball", "keywords": ["sports", "balls"] },
- { "category": "activity", "char": "🎾", "name": "tennis", "keywords": ["sports", "balls", "green"] },
- { "category": "activity", "char": "🏐", "name": "volleyball", "keywords": ["sports", "balls"] },
- { "category": "activity", "char": "🏉", "name": "rugby_football", "keywords": ["sports", "team"] },
- { "category": "activity", "char": "🥏", "name": "flying_disc", "keywords": ["sports", "frisbee", "ultimate"] },
- { "category": "activity", "char": "🎱", "name": "8ball", "keywords": ["pool", "hobby", "game", "luck", "magic"] },
- { "category": "activity", "char": "⛳", "name": "golf", "keywords": ["sports", "business", "flag", "hole", "summer"] },
- { "category": "activity", "char": "🏌️‍♀️", "name": "golfing_woman", "keywords": ["sports", "business", "woman", "female"] },
- { "category": "activity", "char": "🏌", "name": "golfing_man", "keywords": ["sports", "business"] },
- { "category": "activity", "char": "🏓", "name": "ping_pong", "keywords": ["sports", "pingpong"] },
- { "category": "activity", "char": "🏸", "name": "badminton", "keywords": ["sports"] },
- { "category": "activity", "char": "🥅", "name": "goal_net", "keywords": ["sports"] },
- { "category": "activity", "char": "🏒", "name": "ice_hockey", "keywords": ["sports"] },
- { "category": "activity", "char": "🏑", "name": "field_hockey", "keywords": ["sports"] },
- { "category": "activity", "char": "🥍", "name": "lacrosse", "keywords": ["sports", "ball", "stick"] },
- { "category": "activity", "char": "🏏", "name": "cricket", "keywords": ["sports"] },
- { "category": "activity", "char": "🎿", "name": "ski", "keywords": ["sports", "winter", "cold", "snow"] },
- { "category": "activity", "char": "⛷", "name": "skier", "keywords": ["sports", "winter", "snow"] },
- { "category": "activity", "char": "🏂", "name": "snowboarder", "keywords": ["sports", "winter"] },
- { "category": "activity", "char": "🤺", "name": "person_fencing", "keywords": ["sports", "fencing", "sword"] },
- { "category": "activity", "char": "🤼‍♀️", "name": "women_wrestling", "keywords": ["sports", "wrestlers"] },
- { "category": "activity", "char": "🤼‍♂️", "name": "men_wrestling", "keywords": ["sports", "wrestlers"] },
- { "category": "activity", "char": "🤸‍♀️", "name": "woman_cartwheeling", "keywords": ["gymnastics"] },
- { "category": "activity", "char": "🤸‍♂️", "name": "man_cartwheeling", "keywords": ["gymnastics"] },
- { "category": "activity", "char": "🤾‍♀️", "name": "woman_playing_handball", "keywords": ["sports"] },
- { "category": "activity", "char": "🤾‍♂️", "name": "man_playing_handball", "keywords": ["sports"] },
- { "category": "activity", "char": "⛸", "name": "ice_skate", "keywords": ["sports"] },
- { "category": "activity", "char": "🥌", "name": "curling_stone", "keywords": ["sports"] },
- { "category": "activity", "char": "🛹", "name": "skateboard", "keywords": ["board"] },
- { "category": "activity", "char": "🛷", "name": "sled", "keywords": ["sleigh", "luge", "toboggan"] },
- { "category": "activity", "char": "🏹", "name": "bow_and_arrow", "keywords": ["sports"] },
- { "category": "activity", "char": "🎣", "name": "fishing_pole_and_fish", "keywords": ["food", "hobby", "summer"] },
- { "category": "activity", "char": "🥊", "name": "boxing_glove", "keywords": ["sports", "fighting"] },
- { "category": "activity", "char": "🥋", "name": "martial_arts_uniform", "keywords": ["judo", "karate", "taekwondo"] },
- { "category": "activity", "char": "🚣‍♀️", "name": "rowing_woman", "keywords": ["sports", "hobby", "water", "ship", "woman", "female"] },
- { "category": "activity", "char": "🚣", "name": "rowing_man", "keywords": ["sports", "hobby", "water", "ship"] },
- { "category": "activity", "char": "🧗‍♀️", "name": "climbing_woman", "keywords": ["sports", "hobby", "woman", "female", "rock"] },
- { "category": "activity", "char": "🧗‍♂️", "name": "climbing_man", "keywords": ["sports", "hobby", "man", "male", "rock"] },
- { "category": "activity", "char": "🏊‍♀️", "name": "swimming_woman", "keywords": ["sports", "exercise", "human", "athlete", "water", "summer", "woman", "female"] },
- { "category": "activity", "char": "🏊", "name": "swimming_man", "keywords": ["sports", "exercise", "human", "athlete", "water", "summer"] },
- { "category": "activity", "char": "🤽‍♀️", "name": "woman_playing_water_polo", "keywords": ["sports", "pool"] },
- { "category": "activity", "char": "🤽‍♂️", "name": "man_playing_water_polo", "keywords": ["sports", "pool"] },
- { "category": "activity", "char": "🧘‍♀️", "name": "woman_in_lotus_position", "keywords": ["woman", "female", "meditation", "yoga", "serenity", "zen", "mindfulness"] },
- { "category": "activity", "char": "🧘‍♂️", "name": "man_in_lotus_position", "keywords": ["man", "male", "meditation", "yoga", "serenity", "zen", "mindfulness"] },
- { "category": "activity", "char": "🏄‍♀️", "name": "surfing_woman", "keywords": ["sports", "ocean", "sea", "summer", "beach", "woman", "female"] },
- { "category": "activity", "char": "🏄", "name": "surfing_man", "keywords": ["sports", "ocean", "sea", "summer", "beach"] },
- { "category": "activity", "char": "🛀", "name": "bath", "keywords": ["clean", "shower", "bathroom"] },
- { "category": "activity", "char": "⛹️‍♀️", "name": "basketball_woman", "keywords": ["sports", "human", "woman", "female"] },
- { "category": "activity", "char": "⛹", "name": "basketball_man", "keywords": ["sports", "human"] },
- { "category": "activity", "char": "🏋️‍♀️", "name": "weight_lifting_woman", "keywords": ["sports", "training", "exercise", "woman", "female"] },
- { "category": "activity", "char": "🏋", "name": "weight_lifting_man", "keywords": ["sports", "training", "exercise"] },
- { "category": "activity", "char": "🚴‍♀️", "name": "biking_woman", "keywords": ["sports", "bike", "exercise", "hipster", "woman", "female"] },
- { "category": "activity", "char": "🚴", "name": "biking_man", "keywords": ["sports", "bike", "exercise", "hipster"] },
- { "category": "activity", "char": "🚵‍♀️", "name": "mountain_biking_woman", "keywords": ["transportation", "sports", "human", "race", "bike", "woman", "female"] },
- { "category": "activity", "char": "🚵", "name": "mountain_biking_man", "keywords": ["transportation", "sports", "human", "race", "bike"] },
- { "category": "activity", "char": "🏇", "name": "horse_racing", "keywords": ["animal", "betting", "competition", "gambling", "luck"] },
- { "category": "activity", "char": "🤿", "name": "diving_mask", "keywords": ["sports"] },
- { "category": "activity", "char": "🪀", "name": "yo_yo", "keywords": ["sports"] },
- { "category": "activity", "char": "🪁", "name": "kite", "keywords": ["sports"] },
- { "category": "activity", "char": "🦺", "name": "safety_vest", "keywords": ["sports"] },
- { "category": "activity", "char": "🪡", "name": "sewing_needle", "keywords": [] },
- { "category": "activity", "char": "🪢", "name": "knot", "keywords": [] },
- { "category": "activity", "char": "🕴", "name": "business_suit_levitating", "keywords": ["suit", "business", "levitate", "hover", "jump"] },
- { "category": "activity", "char": "🏆", "name": "trophy", "keywords": ["win", "award", "contest", "place", "ftw", "ceremony"] },
- { "category": "activity", "char": "🎽", "name": "running_shirt_with_sash", "keywords": ["play", "pageant"] },
- { "category": "activity", "char": "🏅", "name": "medal_sports", "keywords": ["award", "winning"] },
- { "category": "activity", "char": "🎖", "name": "medal_military", "keywords": ["award", "winning", "army"] },
- { "category": "activity", "char": "🥇", "name": "1st_place_medal", "keywords": ["award", "winning", "first"] },
- { "category": "activity", "char": "🥈", "name": "2nd_place_medal", "keywords": ["award", "second"] },
- { "category": "activity", "char": "🥉", "name": "3rd_place_medal", "keywords": ["award", "third"] },
- { "category": "activity", "char": "🎗", "name": "reminder_ribbon", "keywords": ["sports", "cause", "support", "awareness"] },
- { "category": "activity", "char": "🏵", "name": "rosette", "keywords": ["flower", "decoration", "military"] },
- { "category": "activity", "char": "🎫", "name": "ticket", "keywords": ["event", "concert", "pass"] },
- { "category": "activity", "char": "🎟", "name": "tickets", "keywords": ["sports", "concert", "entrance"] },
- { "category": "activity", "char": "🎭", "name": "performing_arts", "keywords": ["acting", "theater", "drama"] },
- { "category": "activity", "char": "🎨", "name": "art", "keywords": ["design", "paint", "draw", "colors"] },
- { "category": "activity", "char": "🎪", "name": "circus_tent", "keywords": ["festival", "carnival", "party"] },
- { "category": "activity", "char": "🤹‍♀️", "name": "woman_juggling", "keywords": ["juggle", "balance", "skill", "multitask"] },
- { "category": "activity", "char": "🤹‍♂️", "name": "man_juggling", "keywords": ["juggle", "balance", "skill", "multitask"] },
- { "category": "activity", "char": "🎤", "name": "microphone", "keywords": ["sound", "music", "PA", "sing", "talkshow"] },
- { "category": "activity", "char": "🎧", "name": "headphones", "keywords": ["music", "score", "gadgets"] },
- { "category": "activity", "char": "🎼", "name": "musical_score", "keywords": ["treble", "clef", "compose"] },
- { "category": "activity", "char": "🎹", "name": "musical_keyboard", "keywords": ["piano", "instrument", "compose"] },
- { "category": "activity", "char": "🥁", "name": "drum", "keywords": ["music", "instrument", "drumsticks", "snare"] },
- { "category": "activity", "char": "🎷", "name": "saxophone", "keywords": ["music", "instrument", "jazz", "blues"] },
- { "category": "activity", "char": "🎺", "name": "trumpet", "keywords": ["music", "brass"] },
- { "category": "activity", "char": "🎸", "name": "guitar", "keywords": ["music", "instrument"] },
- { "category": "activity", "char": "🎻", "name": "violin", "keywords": ["music", "instrument", "orchestra", "symphony"] },
- { "category": "activity", "char": "🪕", "name": "banjo", "keywords": ["music", "instrument"] },
- { "category": "activity", "char": "🪗", "name": "accordion", "keywords": ["music", "instrument"] },
- { "category": "activity", "char": "🪘", "name": "long_drum", "keywords": ["music", "instrument"] },
- { "category": "activity", "char": "🎬", "name": "clapper", "keywords": ["movie", "film", "record"] },
- { "category": "activity", "char": "🎮", "name": "video_game", "keywords": ["play", "console", "PS4", "controller"] },
- { "category": "activity", "char": "👾", "name": "space_invader", "keywords": ["game", "arcade", "play"] },
- { "category": "activity", "char": "🎯", "name": "dart", "keywords": ["game", "play", "bar", "target", "bullseye"] },
- { "category": "activity", "char": "🎲", "name": "game_die", "keywords": ["dice", "random", "tabletop", "play", "luck"] },
- { "category": "activity", "char": "♟️", "name": "chess_pawn", "keywords": ["expendable"] },
- { "category": "activity", "char": "🎰", "name": "slot_machine", "keywords": ["bet", "gamble", "vegas", "fruit machine", "luck", "casino"] },
- { "category": "activity", "char": "🧩", "name": "jigsaw", "keywords": ["interlocking", "puzzle", "piece"] },
- { "category": "activity", "char": "🎳", "name": "bowling", "keywords": ["sports", "fun", "play"] },
- { "category": "activity", "char": "🪄", "name": "magic_wand", "keywords": [] },
- { "category": "activity", "char": "🪅", "name": "pinata", "keywords": [] },
- { "category": "activity", "char": "🪆", "name": "nesting_dolls", "keywords": [] },
- { "category": "activity", "char": "\uD83E\uDEAC", "name": "hamsa", "keywords": [] },
- { "category": "activity", "char": "\uD83E\uDEA9", "name": "mirror_ball", "keywords": [] },
- { "category": "travel_and_places", "char": "🚗", "name": "red_car", "keywords": ["red", "transportation", "vehicle"] },
- { "category": "travel_and_places", "char": "🚕", "name": "taxi", "keywords": ["uber", "vehicle", "cars", "transportation"] },
- { "category": "travel_and_places", "char": "🚙", "name": "blue_car", "keywords": ["transportation", "vehicle"] },
- { "category": "travel_and_places", "char": "🚌", "name": "bus", "keywords": ["car", "vehicle", "transportation"] },
- { "category": "travel_and_places", "char": "🚎", "name": "trolleybus", "keywords": ["bart", "transportation", "vehicle"] },
- { "category": "travel_and_places", "char": "🏎", "name": "racing_car", "keywords": ["sports", "race", "fast", "formula", "f1"] },
- { "category": "travel_and_places", "char": "🚓", "name": "police_car", "keywords": ["vehicle", "cars", "transportation", "law", "legal", "enforcement"] },
- { "category": "travel_and_places", "char": "🚑", "name": "ambulance", "keywords": ["health", "911", "hospital"] },
- { "category": "travel_and_places", "char": "🚒", "name": "fire_engine", "keywords": ["transportation", "cars", "vehicle"] },
- { "category": "travel_and_places", "char": "🚐", "name": "minibus", "keywords": ["vehicle", "car", "transportation"] },
- { "category": "travel_and_places", "char": "🚚", "name": "truck", "keywords": ["cars", "transportation"] },
- { "category": "travel_and_places", "char": "🚛", "name": "articulated_lorry", "keywords": ["vehicle", "cars", "transportation", "express"] },
- { "category": "travel_and_places", "char": "🚜", "name": "tractor", "keywords": ["vehicle", "car", "farming", "agriculture"] },
- { "category": "travel_and_places", "char": "🛴", "name": "kick_scooter", "keywords": ["vehicle", "kick", "razor"] },
- { "category": "travel_and_places", "char": "🏍", "name": "motorcycle", "keywords": ["race", "sports", "fast"] },
- { "category": "travel_and_places", "char": "🚲", "name": "bike", "keywords": ["sports", "bicycle", "exercise", "hipster"] },
- { "category": "travel_and_places", "char": "🛵", "name": "motor_scooter", "keywords": ["vehicle", "vespa", "sasha"] },
- { "category": "travel_and_places", "char": "🦽", "name": "manual_wheelchair", "keywords": ["vehicle"] },
- { "category": "travel_and_places", "char": "🦼", "name": "motorized_wheelchair", "keywords": ["vehicle"] },
- { "category": "travel_and_places", "char": "🛺", "name": "auto_rickshaw", "keywords": ["vehicle"] },
- { "category": "travel_and_places", "char": "🪂", "name": "parachute", "keywords": ["vehicle"] },
- { "category": "travel_and_places", "char": "🚨", "name": "rotating_light", "keywords": ["police", "ambulance", "911", "emergency", "alert", "error", "pinged", "law", "legal"] },
- { "category": "travel_and_places", "char": "🚔", "name": "oncoming_police_car", "keywords": ["vehicle", "law", "legal", "enforcement", "911"] },
- { "category": "travel_and_places", "char": "🚍", "name": "oncoming_bus", "keywords": ["vehicle", "transportation"] },
- { "category": "travel_and_places", "char": "🚘", "name": "oncoming_automobile", "keywords": ["car", "vehicle", "transportation"] },
- { "category": "travel_and_places", "char": "🚖", "name": "oncoming_taxi", "keywords": ["vehicle", "cars", "uber"] },
- { "category": "travel_and_places", "char": "🚡", "name": "aerial_tramway", "keywords": ["transportation", "vehicle", "ski"] },
- { "category": "travel_and_places", "char": "🚠", "name": "mountain_cableway", "keywords": ["transportation", "vehicle", "ski"] },
- { "category": "travel_and_places", "char": "🚟", "name": "suspension_railway", "keywords": ["vehicle", "transportation"] },
- { "category": "travel_and_places", "char": "🚃", "name": "railway_car", "keywords": ["transportation", "vehicle", "train"] },
- { "category": "travel_and_places", "char": "🚋", "name": "train", "keywords": ["transportation", "vehicle", "carriage", "public", "travel"] },
- { "category": "travel_and_places", "char": "🚝", "name": "monorail", "keywords": ["transportation", "vehicle"] },
- { "category": "travel_and_places", "char": "🚄", "name": "bullettrain_side", "keywords": ["transportation", "vehicle"] },
- { "category": "travel_and_places", "char": "🚅", "name": "bullettrain_front", "keywords": ["transportation", "vehicle", "speed", "fast", "public", "travel"] },
- { "category": "travel_and_places", "char": "🚈", "name": "light_rail", "keywords": ["transportation", "vehicle"] },
- { "category": "travel_and_places", "char": "🚞", "name": "mountain_railway", "keywords": ["transportation", "vehicle"] },
- { "category": "travel_and_places", "char": "🚂", "name": "steam_locomotive", "keywords": ["transportation", "vehicle", "train"] },
- { "category": "travel_and_places", "char": "🚆", "name": "train2", "keywords": ["transportation", "vehicle"] },
- { "category": "travel_and_places", "char": "🚇", "name": "metro", "keywords": ["transportation", "blue-square", "mrt", "underground", "tube"] },
- { "category": "travel_and_places", "char": "🚊", "name": "tram", "keywords": ["transportation", "vehicle"] },
- { "category": "travel_and_places", "char": "🚉", "name": "station", "keywords": ["transportation", "vehicle", "public"] },
- { "category": "travel_and_places", "char": "🛸", "name": "flying_saucer", "keywords": ["transportation", "vehicle", "ufo"] },
- { "category": "travel_and_places", "char": "🚁", "name": "helicopter", "keywords": ["transportation", "vehicle", "fly"] },
- { "category": "travel_and_places", "char": "🛩", "name": "small_airplane", "keywords": ["flight", "transportation", "fly", "vehicle"] },
- { "category": "travel_and_places", "char": "✈️", "name": "airplane", "keywords": ["vehicle", "transportation", "flight", "fly"] },
- { "category": "travel_and_places", "char": "🛫", "name": "flight_departure", "keywords": ["airport", "flight", "landing"] },
- { "category": "travel_and_places", "char": "🛬", "name": "flight_arrival", "keywords": ["airport", "flight", "boarding"] },
- { "category": "travel_and_places", "char": "⛵", "name": "sailboat", "keywords": ["ship", "summer", "transportation", "water", "sailing"] },
- { "category": "travel_and_places", "char": "🛥", "name": "motor_boat", "keywords": ["ship"] },
- { "category": "travel_and_places", "char": "🚤", "name": "speedboat", "keywords": ["ship", "transportation", "vehicle", "summer"] },
- { "category": "travel_and_places", "char": "⛴", "name": "ferry", "keywords": ["boat", "ship", "yacht"] },
- { "category": "travel_and_places", "char": "🛳", "name": "passenger_ship", "keywords": ["yacht", "cruise", "ferry"] },
- { "category": "travel_and_places", "char": "🚀", "name": "rocket", "keywords": ["launch", "ship", "staffmode", "NASA", "outer space", "outer_space", "fly"] },
- { "category": "travel_and_places", "char": "🛰", "name": "artificial_satellite", "keywords": ["communication", "gps", "orbit", "spaceflight", "NASA", "ISS"] },
- { "category": "travel_and_places", "char": "🛻", "name": "pickup_truck", "keywords": ["car"] },
- { "category": "travel_and_places", "char": "🛼", "name": "roller_skate", "keywords": [] },
- { "category": "travel_and_places", "char": "💺", "name": "seat", "keywords": ["sit", "airplane", "transport", "bus", "flight", "fly"] },
- { "category": "travel_and_places", "char": "🛶", "name": "canoe", "keywords": ["boat", "paddle", "water", "ship"] },
- { "category": "travel_and_places", "char": "⚓", "name": "anchor", "keywords": ["ship", "ferry", "sea", "boat"] },
- { "category": "travel_and_places", "char": "🚧", "name": "construction", "keywords": ["wip", "progress", "caution", "warning"] },
- { "category": "travel_and_places", "char": "⛽", "name": "fuelpump", "keywords": ["gas station", "petroleum"] },
- { "category": "travel_and_places", "char": "🚏", "name": "busstop", "keywords": ["transportation", "wait"] },
- { "category": "travel_and_places", "char": "🚦", "name": "vertical_traffic_light", "keywords": ["transportation", "driving"] },
- { "category": "travel_and_places", "char": "🚥", "name": "traffic_light", "keywords": ["transportation", "signal"] },
- { "category": "travel_and_places", "char": "🏁", "name": "checkered_flag", "keywords": ["contest", "finishline", "race", "gokart"] },
- { "category": "travel_and_places", "char": "🚢", "name": "ship", "keywords": ["transportation", "titanic", "deploy"] },
- { "category": "travel_and_places", "char": "🎡", "name": "ferris_wheel", "keywords": ["photo", "carnival", "londoneye"] },
- { "category": "travel_and_places", "char": "🎢", "name": "roller_coaster", "keywords": ["carnival", "playground", "photo", "fun"] },
- { "category": "travel_and_places", "char": "🎠", "name": "carousel_horse", "keywords": ["photo", "carnival"] },
- { "category": "travel_and_places", "char": "🏗", "name": "building_construction", "keywords": ["wip", "working", "progress"] },
- { "category": "travel_and_places", "char": "🌁", "name": "foggy", "keywords": ["photo", "mountain"] },
- { "category": "travel_and_places", "char": "🏭", "name": "factory", "keywords": ["building", "industry", "pollution", "smoke"] },
- { "category": "travel_and_places", "char": "⛲", "name": "fountain", "keywords": ["photo", "summer", "water", "fresh"] },
- { "category": "travel_and_places", "char": "🎑", "name": "rice_scene", "keywords": ["photo", "japan", "asia", "tsukimi"] },
- { "category": "travel_and_places", "char": "⛰", "name": "mountain", "keywords": ["photo", "nature", "environment"] },
- { "category": "travel_and_places", "char": "🏔", "name": "mountain_snow", "keywords": ["photo", "nature", "environment", "winter", "cold"] },
- { "category": "travel_and_places", "char": "🗻", "name": "mount_fuji", "keywords": ["photo", "mountain", "nature", "japanese"] },
- { "category": "travel_and_places", "char": "🌋", "name": "volcano", "keywords": ["photo", "nature", "disaster"] },
- { "category": "travel_and_places", "char": "🗾", "name": "japan", "keywords": ["nation", "country", "japanese", "asia"] },
- { "category": "travel_and_places", "char": "🏕", "name": "camping", "keywords": ["photo", "outdoors", "tent"] },
- { "category": "travel_and_places", "char": "⛺", "name": "tent", "keywords": ["photo", "camping", "outdoors"] },
- { "category": "travel_and_places", "char": "🏞", "name": "national_park", "keywords": ["photo", "environment", "nature"] },
- { "category": "travel_and_places", "char": "🛣", "name": "motorway", "keywords": ["road", "cupertino", "interstate", "highway"] },
- { "category": "travel_and_places", "char": "🛤", "name": "railway_track", "keywords": ["train", "transportation"] },
- { "category": "travel_and_places", "char": "🌅", "name": "sunrise", "keywords": ["morning", "view", "vacation", "photo"] },
- { "category": "travel_and_places", "char": "🌄", "name": "sunrise_over_mountains", "keywords": ["view", "vacation", "photo"] },
- { "category": "travel_and_places", "char": "🏜", "name": "desert", "keywords": ["photo", "warm", "saharah"] },
- { "category": "travel_and_places", "char": "🏖", "name": "beach_umbrella", "keywords": ["weather", "summer", "sunny", "sand", "mojito"] },
- { "category": "travel_and_places", "char": "🏝", "name": "desert_island", "keywords": ["photo", "tropical", "mojito"] },
- { "category": "travel_and_places", "char": "🌇", "name": "city_sunrise", "keywords": ["photo", "good morning", "dawn"] },
- { "category": "travel_and_places", "char": "🌆", "name": "city_sunset", "keywords": ["photo", "evening", "sky", "buildings"] },
- { "category": "travel_and_places", "char": "🏙", "name": "cityscape", "keywords": ["photo", "night life", "urban"] },
- { "category": "travel_and_places", "char": "🌃", "name": "night_with_stars", "keywords": ["evening", "city", "downtown"] },
- { "category": "travel_and_places", "char": "🌉", "name": "bridge_at_night", "keywords": ["photo", "sanfrancisco"] },
- { "category": "travel_and_places", "char": "🌌", "name": "milky_way", "keywords": ["photo", "space", "stars"] },
- { "category": "travel_and_places", "char": "🌠", "name": "stars", "keywords": ["night", "photo"] },
- { "category": "travel_and_places", "char": "🎇", "name": "sparkler", "keywords": ["stars", "night", "shine"] },
- { "category": "travel_and_places", "char": "🎆", "name": "fireworks", "keywords": ["photo", "festival", "carnival", "congratulations"] },
- { "category": "travel_and_places", "char": "🌈", "name": "rainbow", "keywords": ["nature", "happy", "unicorn_face", "photo", "sky", "spring"] },
- { "category": "travel_and_places", "char": "🏘", "name": "houses", "keywords": ["buildings", "photo"] },
- { "category": "travel_and_places", "char": "🏰", "name": "european_castle", "keywords": ["building", "royalty", "history"] },
- { "category": "travel_and_places", "char": "🏯", "name": "japanese_castle", "keywords": ["photo", "building"] },
- { "category": "travel_and_places", "char": "🗼", "name": "tokyo_tower", "keywords": ["photo", "japanese"] },
- { "category": "travel_and_places", "char": "", "name": "shibuya_109", "keywords": ["photo", "japanese"] },
- { "category": "travel_and_places", "char": "🏟", "name": "stadium", "keywords": ["photo", "place", "sports", "concert", "venue"] },
- { "category": "travel_and_places", "char": "🗽", "name": "statue_of_liberty", "keywords": ["american", "newyork"] },
- { "category": "travel_and_places", "char": "🏠", "name": "house", "keywords": ["building", "home"] },
- { "category": "travel_and_places", "char": "🏡", "name": "house_with_garden", "keywords": ["home", "plant", "nature"] },
- { "category": "travel_and_places", "char": "🏚", "name": "derelict_house", "keywords": ["abandon", "evict", "broken", "building"] },
- { "category": "travel_and_places", "char": "🏢", "name": "office", "keywords": ["building", "bureau", "work"] },
- { "category": "travel_and_places", "char": "🏬", "name": "department_store", "keywords": ["building", "shopping", "mall"] },
- { "category": "travel_and_places", "char": "🏣", "name": "post_office", "keywords": ["building", "envelope", "communication"] },
- { "category": "travel_and_places", "char": "🏤", "name": "european_post_office", "keywords": ["building", "email"] },
- { "category": "travel_and_places", "char": "🏥", "name": "hospital", "keywords": ["building", "health", "surgery", "doctor"] },
- { "category": "travel_and_places", "char": "🏦", "name": "bank", "keywords": ["building", "money", "sales", "cash", "business", "enterprise"] },
- { "category": "travel_and_places", "char": "🏨", "name": "hotel", "keywords": ["building", "accomodation", "checkin"] },
- { "category": "travel_and_places", "char": "🏪", "name": "convenience_store", "keywords": ["building", "shopping", "groceries"] },
- { "category": "travel_and_places", "char": "🏫", "name": "school", "keywords": ["building", "student", "education", "learn", "teach"] },
- { "category": "travel_and_places", "char": "🏩", "name": "love_hotel", "keywords": ["like", "affection", "dating"] },
- { "category": "travel_and_places", "char": "💒", "name": "wedding", "keywords": ["love", "like", "affection", "couple", "marriage", "bride", "groom"] },
- { "category": "travel_and_places", "char": "🏛", "name": "classical_building", "keywords": ["art", "culture", "history"] },
- { "category": "travel_and_places", "char": "⛪", "name": "church", "keywords": ["building", "religion", "christ"] },
- { "category": "travel_and_places", "char": "🕌", "name": "mosque", "keywords": ["islam", "worship", "minaret"] },
- { "category": "travel_and_places", "char": "🕍", "name": "synagogue", "keywords": ["judaism", "worship", "temple", "jewish"] },
- { "category": "travel_and_places", "char": "🕋", "name": "kaaba", "keywords": ["mecca", "mosque", "islam"] },
- { "category": "travel_and_places", "char": "⛩", "name": "shinto_shrine", "keywords": ["temple", "japan", "kyoto"] },
- { "category": "travel_and_places", "char": "🛕", "name": "hindu_temple", "keywords": ["temple"] },
- { "category": "travel_and_places", "char": "🪨", "name": "rock", "keywords": [] },
- { "category": "travel_and_places", "char": "🪵", "name": "wood", "keywords": [] },
- { "category": "travel_and_places", "char": "🛖", "name": "hut", "keywords": [] },
- { "category": "travel_and_places", "char": "\uD83D\uDEDD", "name": "playground_slide", "keywords": [] },
- { "category": "travel_and_places", "char": "\uD83D\uDEDE", "name": "wheel", "keywords": [] },
- { "category": "travel_and_places", "char": "\uD83D\uDEDF", "name": "ring_buoy", "keywords": [] },
- { "category": "objects", "char": "⌚", "name": "watch", "keywords": ["time", "accessories"] },
- { "category": "objects", "char": "📱", "name": "iphone", "keywords": ["technology", "apple", "gadgets", "dial"] },
- { "category": "objects", "char": "📲", "name": "calling", "keywords": ["iphone", "incoming"] },
- { "category": "objects", "char": "💻", "name": "computer", "keywords": ["technology", "laptop", "screen", "display", "monitor"] },
- { "category": "objects", "char": "⌨", "name": "keyboard", "keywords": ["technology", "computer", "type", "input", "text"] },
- { "category": "objects", "char": "🖥", "name": "desktop_computer", "keywords": ["technology", "computing", "screen"] },
- { "category": "objects", "char": "🖨", "name": "printer", "keywords": ["paper", "ink"] },
- { "category": "objects", "char": "🖱", "name": "computer_mouse", "keywords": ["click"] },
- { "category": "objects", "char": "🖲", "name": "trackball", "keywords": ["technology", "trackpad"] },
- { "category": "objects", "char": "🕹", "name": "joystick", "keywords": ["game", "play"] },
- { "category": "objects", "char": "🗜", "name": "clamp", "keywords": ["tool"] },
- { "category": "objects", "char": "💽", "name": "minidisc", "keywords": ["technology", "record", "data", "disk", "90s"] },
- { "category": "objects", "char": "💾", "name": "floppy_disk", "keywords": ["oldschool", "technology", "save", "90s", "80s"] },
- { "category": "objects", "char": "💿", "name": "cd", "keywords": ["technology", "dvd", "disk", "disc", "90s"] },
- { "category": "objects", "char": "📀", "name": "dvd", "keywords": ["cd", "disk", "disc"] },
- { "category": "objects", "char": "📼", "name": "vhs", "keywords": ["record", "video", "oldschool", "90s", "80s"] },
- { "category": "objects", "char": "📷", "name": "camera", "keywords": ["gadgets", "photography"] },
- { "category": "objects", "char": "📸", "name": "camera_flash", "keywords": ["photography", "gadgets"] },
- { "category": "objects", "char": "📹", "name": "video_camera", "keywords": ["film", "record"] },
- { "category": "objects", "char": "🎥", "name": "movie_camera", "keywords": ["film", "record"] },
- { "category": "objects", "char": "📽", "name": "film_projector", "keywords": ["video", "tape", "record", "movie"] },
- { "category": "objects", "char": "🎞", "name": "film_strip", "keywords": ["movie"] },
- { "category": "objects", "char": "📞", "name": "telephone_receiver", "keywords": ["technology", "communication", "dial"] },
- { "category": "objects", "char": "☎️", "name": "phone", "keywords": ["technology", "communication", "dial", "telephone"] },
- { "category": "objects", "char": "📟", "name": "pager", "keywords": ["bbcall", "oldschool", "90s"] },
- { "category": "objects", "char": "📠", "name": "fax", "keywords": ["communication", "technology"] },
- { "category": "objects", "char": "📺", "name": "tv", "keywords": ["technology", "program", "oldschool", "show", "television"] },
- { "category": "objects", "char": "📻", "name": "radio", "keywords": ["communication", "music", "podcast", "program"] },
- { "category": "objects", "char": "🎙", "name": "studio_microphone", "keywords": ["sing", "recording", "artist", "talkshow"] },
- { "category": "objects", "char": "🎚", "name": "level_slider", "keywords": ["scale"] },
- { "category": "objects", "char": "🎛", "name": "control_knobs", "keywords": ["dial"] },
- { "category": "objects", "char": "🧭", "name": "compass", "keywords": ["magnetic", "navigation", "orienteering"] },
- { "category": "objects", "char": "⏱", "name": "stopwatch", "keywords": ["time", "deadline"] },
- { "category": "objects", "char": "⏲", "name": "timer_clock", "keywords": ["alarm"] },
- { "category": "objects", "char": "⏰", "name": "alarm_clock", "keywords": ["time", "wake"] },
- { "category": "objects", "char": "🕰", "name": "mantelpiece_clock", "keywords": ["time"] },
- { "category": "objects", "char": "⏳", "name": "hourglass_flowing_sand", "keywords": ["oldschool", "time", "countdown"] },
- { "category": "objects", "char": "⌛", "name": "hourglass", "keywords": ["time", "clock", "oldschool", "limit", "exam", "quiz", "test"] },
- { "category": "objects", "char": "📡", "name": "satellite", "keywords": ["communication", "future", "radio", "space"] },
- { "category": "objects", "char": "🔋", "name": "battery", "keywords": ["power", "energy", "sustain"] },
- { "category": "objects", "char": "\uD83E\uDEAB", "name": "battery", "keywords": [] },
- { "category": "objects", "char": "🔌", "name": "electric_plug", "keywords": ["charger", "power"] },
- { "category": "objects", "char": "💡", "name": "bulb", "keywords": ["light", "electricity", "idea"] },
- { "category": "objects", "char": "🔦", "name": "flashlight", "keywords": ["dark", "camping", "sight", "night"] },
- { "category": "objects", "char": "🕯", "name": "candle", "keywords": ["fire", "wax"] },
- { "category": "objects", "char": "🧯", "name": "fire_extinguisher", "keywords": ["quench"] },
- { "category": "objects", "char": "🗑", "name": "wastebasket", "keywords": ["bin", "trash", "rubbish", "garbage", "toss"] },
- { "category": "objects", "char": "🛢", "name": "oil_drum", "keywords": ["barrell"] },
- { "category": "objects", "char": "💸", "name": "money_with_wings", "keywords": ["dollar", "bills", "payment", "sale"] },
- { "category": "objects", "char": "💵", "name": "dollar", "keywords": ["money", "sales", "bill", "currency"] },
- { "category": "objects", "char": "💴", "name": "yen", "keywords": ["money", "sales", "japanese", "dollar", "currency"] },
- { "category": "objects", "char": "💶", "name": "euro", "keywords": ["money", "sales", "dollar", "currency"] },
- { "category": "objects", "char": "💷", "name": "pound", "keywords": ["british", "sterling", "money", "sales", "bills", "uk", "england", "currency"] },
- { "category": "objects", "char": "💰", "name": "moneybag", "keywords": ["dollar", "payment", "coins", "sale"] },
- { "category": "objects", "char": "🪙", "name": "coin", "keywords": ["dollar", "payment", "coins", "sale"] },
- { "category": "objects", "char": "💳", "name": "credit_card", "keywords": ["money", "sales", "dollar", "bill", "payment", "shopping"] },
- { "category": "objects", "char": "\uD83E\uDEAB", "name": "identification_card", "keywords": [] },
- { "category": "objects", "char": "💎", "name": "gem", "keywords": ["blue", "ruby", "diamond", "jewelry"] },
- { "category": "objects", "char": "⚖", "name": "balance_scale", "keywords": ["law", "fairness", "weight"] },
- { "category": "objects", "char": "🧰", "name": "toolbox", "keywords": ["tools", "diy", "fix", "maintainer", "mechanic"] },
- { "category": "objects", "char": "🔧", "name": "wrench", "keywords": ["tools", "diy", "ikea", "fix", "maintainer"] },
- { "category": "objects", "char": "🔨", "name": "hammer", "keywords": ["tools", "build", "create"] },
- { "category": "objects", "char": "⚒", "name": "hammer_and_pick", "keywords": ["tools", "build", "create"] },
- { "category": "objects", "char": "🛠", "name": "hammer_and_wrench", "keywords": ["tools", "build", "create"] },
- { "category": "objects", "char": "⛏", "name": "pick", "keywords": ["tools", "dig"] },
- { "category": "objects", "char": "🪓", "name": "axe", "keywords": ["tools"] },
- { "category": "objects", "char": "🦯", "name": "probing_cane", "keywords": ["tools"] },
- { "category": "objects", "char": "🔩", "name": "nut_and_bolt", "keywords": ["handy", "tools", "fix"] },
- { "category": "objects", "char": "⚙", "name": "gear", "keywords": ["cog"] },
- { "category": "objects", "char": "🪃", "name": "boomerang", "keywords": ["tool"] },
- { "category": "objects", "char": "🪚", "name": "carpentry_saw", "keywords": ["tool"] },
- { "category": "objects", "char": "🪛", "name": "screwdriver", "keywords": ["tool"] },
- { "category": "objects", "char": "🪝", "name": "hook", "keywords": ["tool"] },
- { "category": "objects", "char": "🪜", "name": "ladder", "keywords": ["tool"] },
- { "category": "objects", "char": "🧱", "name": "brick", "keywords": ["bricks"] },
- { "category": "objects", "char": "⛓", "name": "chains", "keywords": ["lock", "arrest"] },
- { "category": "objects", "char": "🧲", "name": "magnet", "keywords": ["attraction", "magnetic"] },
- { "category": "objects", "char": "🔫", "name": "gun", "keywords": ["violence", "weapon", "pistol", "revolver"] },
- { "category": "objects", "char": "💣", "name": "bomb", "keywords": ["boom", "explode", "explosion", "terrorism"] },
- { "category": "objects", "char": "🧨", "name": "firecracker", "keywords": ["dynamite", "boom", "explode", "explosion", "explosive"] },
- { "category": "objects", "char": "🔪", "name": "hocho", "keywords": ["knife", "blade", "cutlery", "kitchen", "weapon"] },
- { "category": "objects", "char": "🗡", "name": "dagger", "keywords": ["weapon"] },
- { "category": "objects", "char": "⚔", "name": "crossed_swords", "keywords": ["weapon"] },
- { "category": "objects", "char": "🛡", "name": "shield", "keywords": ["protection", "security"] },
- { "category": "objects", "char": "🚬", "name": "smoking", "keywords": ["kills", "tobacco", "cigarette", "joint", "smoke"] },
- { "category": "objects", "char": "☠", "name": "skull_and_crossbones", "keywords": ["poison", "danger", "deadly", "scary", "death", "pirate", "evil"] },
- { "category": "objects", "char": "⚰", "name": "coffin", "keywords": ["vampire", "dead", "die", "death", "rip", "graveyard", "cemetery", "casket", "funeral", "box"] },
- { "category": "objects", "char": "⚱", "name": "funeral_urn", "keywords": ["dead", "die", "death", "rip", "ashes"] },
- { "category": "objects", "char": "🏺", "name": "amphora", "keywords": ["vase", "jar"] },
- { "category": "objects", "char": "🔮", "name": "crystal_ball", "keywords": ["disco", "party", "magic", "circus", "fortune_teller"] },
- { "category": "objects", "char": "📿", "name": "prayer_beads", "keywords": ["dhikr", "religious"] },
- { "category": "objects", "char": "🧿", "name": "nazar_amulet", "keywords": ["bead", "charm"] },
- { "category": "objects", "char": "💈", "name": "barber", "keywords": ["hair", "salon", "style"] },
- { "category": "objects", "char": "⚗", "name": "alembic", "keywords": ["distilling", "science", "experiment", "chemistry"] },
- { "category": "objects", "char": "🔭", "name": "telescope", "keywords": ["stars", "space", "zoom", "science", "astronomy"] },
- { "category": "objects", "char": "🔬", "name": "microscope", "keywords": ["laboratory", "experiment", "zoomin", "science", "study"] },
- { "category": "objects", "char": "🕳", "name": "hole", "keywords": ["embarrassing"] },
- { "category": "objects", "char": "💊", "name": "pill", "keywords": ["health", "medicine", "doctor", "pharmacy", "drug"] },
- { "category": "objects", "char": "💉", "name": "syringe", "keywords": ["health", "hospital", "drugs", "blood", "medicine", "needle", "doctor", "nurse"] },
- { "category": "objects", "char": "🩸", "name": "drop_of_blood", "keywords": ["health", "hospital", "medicine", "needle", "doctor", "nurse"] },
- { "category": "objects", "char": "🩹", "name": "adhesive_bandage", "keywords": ["health", "hospital", "medicine", "needle", "doctor", "nurse"] },
- { "category": "objects", "char": "🩺", "name": "stethoscope", "keywords": ["health", "hospital", "medicine", "needle", "doctor", "nurse"] },
- { "category": "objects", "char": "🪒", "name": "razor", "keywords": ["health"] },
- { "category": "objects", "char": "\uD83E\uDE7B", "name": "xray", "keywords": [] },
- { "category": "objects", "char": "\uD83E\uDE7C", "name": "crutch", "keywords": [] },
- { "category": "objects", "char": "🧬", "name": "dna", "keywords": ["biologist", "genetics", "life"] },
- { "category": "objects", "char": "🧫", "name": "petri_dish", "keywords": ["bacteria", "biology", "culture", "lab"] },
- { "category": "objects", "char": "🧪", "name": "test_tube", "keywords": ["chemistry", "experiment", "lab", "science"] },
- { "category": "objects", "char": "🌡", "name": "thermometer", "keywords": ["weather", "temperature", "hot", "cold"] },
- { "category": "objects", "char": "🧹", "name": "broom", "keywords": ["cleaning", "sweeping", "witch"] },
- { "category": "objects", "char": "🧺", "name": "basket", "keywords": ["laundry"] },
- { "category": "objects", "char": "🧻", "name": "toilet_paper", "keywords": ["roll"] },
- { "category": "objects", "char": "🏷", "name": "label", "keywords": ["sale", "tag"] },
- { "category": "objects", "char": "🔖", "name": "bookmark", "keywords": ["favorite", "label", "save"] },
- { "category": "objects", "char": "🚽", "name": "toilet", "keywords": ["restroom", "wc", "washroom", "bathroom", "potty"] },
- { "category": "objects", "char": "🚿", "name": "shower", "keywords": ["clean", "water", "bathroom"] },
- { "category": "objects", "char": "🛁", "name": "bathtub", "keywords": ["clean", "shower", "bathroom"] },
- { "category": "objects", "char": "🧼", "name": "soap", "keywords": ["bar", "bathing", "cleaning", "lather"] },
- { "category": "objects", "char": "🧽", "name": "sponge", "keywords": ["absorbing", "cleaning", "porous"] },
- { "category": "objects", "char": "🧴", "name": "lotion_bottle", "keywords": ["moisturizer", "sunscreen"] },
- { "category": "objects", "char": "🔑", "name": "key", "keywords": ["lock", "door", "password"] },
- { "category": "objects", "char": "🗝", "name": "old_key", "keywords": ["lock", "door", "password"] },
- { "category": "objects", "char": "🛋", "name": "couch_and_lamp", "keywords": ["read", "chill"] },
- { "category": "objects", "char": "🪔", "name": "diya_Lamp", "keywords": ["light", "oil"] },
- { "category": "objects", "char": "🛌", "name": "sleeping_bed", "keywords": ["bed", "rest"] },
- { "category": "objects", "char": "🛏", "name": "bed", "keywords": ["sleep", "rest"] },
- { "category": "objects", "char": "🚪", "name": "door", "keywords": ["house", "entry", "exit"] },
- { "category": "objects", "char": "🪑", "name": "chair", "keywords": ["house", "desk"] },
- { "category": "objects", "char": "🛎", "name": "bellhop_bell", "keywords": ["service"] },
- { "category": "objects", "char": "🧸", "name": "teddy_bear", "keywords": ["plush", "stuffed"] },
- { "category": "objects", "char": "🖼", "name": "framed_picture", "keywords": ["photography"] },
- { "category": "objects", "char": "🗺", "name": "world_map", "keywords": ["location", "direction"] },
- { "category": "objects", "char": "🛗", "name": "elevator", "keywords": ["household"] },
- { "category": "objects", "char": "🪞", "name": "mirror", "keywords": ["household"] },
- { "category": "objects", "char": "🪟", "name": "window", "keywords": ["household"] },
- { "category": "objects", "char": "🪠", "name": "plunger", "keywords": ["household"] },
- { "category": "objects", "char": "🪤", "name": "mouse_trap", "keywords": ["household"] },
- { "category": "objects", "char": "🪣", "name": "bucket", "keywords": ["household"] },
- { "category": "objects", "char": "🪥", "name": "toothbrush", "keywords": ["household"] },
- { "category": "objects", "char": "\uD83E\uDEE7", "name": "bubbles", "keywords": [] },
- { "category": "objects", "char": "⛱", "name": "parasol_on_ground", "keywords": ["weather", "summer"] },
- { "category": "objects", "char": "🗿", "name": "moyai", "keywords": ["rock", "easter island", "moai"] },
- { "category": "objects", "char": "🛍", "name": "shopping", "keywords": ["mall", "buy", "purchase"] },
- { "category": "objects", "char": "🛒", "name": "shopping_cart", "keywords": ["trolley"] },
- { "category": "objects", "char": "🎈", "name": "balloon", "keywords": ["party", "celebration", "birthday", "circus"] },
- { "category": "objects", "char": "🎏", "name": "flags", "keywords": ["fish", "japanese", "koinobori", "carp", "banner"] },
- { "category": "objects", "char": "🎀", "name": "ribbon", "keywords": ["decoration", "pink", "girl", "bowtie"] },
- { "category": "objects", "char": "🎁", "name": "gift", "keywords": ["present", "birthday", "christmas", "xmas"] },
- { "category": "objects", "char": "🎊", "name": "confetti_ball", "keywords": ["festival", "party", "birthday", "circus"] },
- { "category": "objects", "char": "🎉", "name": "tada", "keywords": ["party", "congratulations", "birthday", "magic", "circus", "celebration"] },
- { "category": "objects", "char": "🎎", "name": "dolls", "keywords": ["japanese", "toy", "kimono"] },
- { "category": "objects", "char": "🎐", "name": "wind_chime", "keywords": ["nature", "ding", "spring", "bell"] },
- { "category": "objects", "char": "🎌", "name": "crossed_flags", "keywords": ["japanese", "nation", "country", "border"] },
- { "category": "objects", "char": "🏮", "name": "izakaya_lantern", "keywords": ["light", "paper", "halloween", "spooky"] },
- { "category": "objects", "char": "🧧", "name": "red_envelope", "keywords": ["gift"] },
- { "category": "objects", "char": "✉️", "name": "email", "keywords": ["letter", "postal", "inbox", "communication"] },
- { "category": "objects", "char": "📩", "name": "envelope_with_arrow", "keywords": ["email", "communication"] },
- { "category": "objects", "char": "📨", "name": "incoming_envelope", "keywords": ["email", "inbox"] },
- { "category": "objects", "char": "📧", "name": "e-mail", "keywords": ["communication", "inbox"] },
- { "category": "objects", "char": "💌", "name": "love_letter", "keywords": ["email", "like", "affection", "envelope", "valentines"] },
- { "category": "objects", "char": "📮", "name": "postbox", "keywords": ["email", "letter", "envelope"] },
- { "category": "objects", "char": "📪", "name": "mailbox_closed", "keywords": ["email", "communication", "inbox"] },
- { "category": "objects", "char": "📫", "name": "mailbox", "keywords": ["email", "inbox", "communication"] },
- { "category": "objects", "char": "📬", "name": "mailbox_with_mail", "keywords": ["email", "inbox", "communication"] },
- { "category": "objects", "char": "📭", "name": "mailbox_with_no_mail", "keywords": ["email", "inbox"] },
- { "category": "objects", "char": "📦", "name": "package", "keywords": ["mail", "gift", "cardboard", "box", "moving"] },
- { "category": "objects", "char": "📯", "name": "postal_horn", "keywords": ["instrument", "music"] },
- { "category": "objects", "char": "📥", "name": "inbox_tray", "keywords": ["email", "documents"] },
- { "category": "objects", "char": "📤", "name": "outbox_tray", "keywords": ["inbox", "email"] },
- { "category": "objects", "char": "📜", "name": "scroll", "keywords": ["documents", "ancient", "history", "paper"] },
- { "category": "objects", "char": "📃", "name": "page_with_curl", "keywords": ["documents", "office", "paper"] },
- { "category": "objects", "char": "📑", "name": "bookmark_tabs", "keywords": ["favorite", "save", "order", "tidy"] },
- { "category": "objects", "char": "🧾", "name": "receipt", "keywords": ["accounting", "expenses"] },
- { "category": "objects", "char": "📊", "name": "bar_chart", "keywords": ["graph", "presentation", "stats"] },
- { "category": "objects", "char": "📈", "name": "chart_with_upwards_trend", "keywords": ["graph", "presentation", "stats", "recovery", "business", "economics", "money", "sales", "good", "success"] },
- { "category": "objects", "char": "📉", "name": "chart_with_downwards_trend", "keywords": ["graph", "presentation", "stats", "recession", "business", "economics", "money", "sales", "bad", "failure"] },
- { "category": "objects", "char": "📄", "name": "page_facing_up", "keywords": ["documents", "office", "paper", "information"] },
- { "category": "objects", "char": "📅", "name": "date", "keywords": ["calendar", "schedule"] },
- { "category": "objects", "char": "📆", "name": "calendar", "keywords": ["schedule", "date", "planning"] },
- { "category": "objects", "char": "🗓", "name": "spiral_calendar", "keywords": ["date", "schedule", "planning"] },
- { "category": "objects", "char": "📇", "name": "card_index", "keywords": ["business", "stationery"] },
- { "category": "objects", "char": "🗃", "name": "card_file_box", "keywords": ["business", "stationery"] },
- { "category": "objects", "char": "🗳", "name": "ballot_box", "keywords": ["election", "vote"] },
- { "category": "objects", "char": "🗄", "name": "file_cabinet", "keywords": ["filing", "organizing"] },
- { "category": "objects", "char": "📋", "name": "clipboard", "keywords": ["stationery", "documents"] },
- { "category": "objects", "char": "🗒", "name": "spiral_notepad", "keywords": ["memo", "stationery"] },
- { "category": "objects", "char": "📁", "name": "file_folder", "keywords": ["documents", "business", "office"] },
- { "category": "objects", "char": "📂", "name": "open_file_folder", "keywords": ["documents", "load"] },
- { "category": "objects", "char": "🗂", "name": "card_index_dividers", "keywords": ["organizing", "business", "stationery"] },
- { "category": "objects", "char": "🗞", "name": "newspaper_roll", "keywords": ["press", "headline"] },
- { "category": "objects", "char": "📰", "name": "newspaper", "keywords": ["press", "headline"] },
- { "category": "objects", "char": "📓", "name": "notebook", "keywords": ["stationery", "record", "notes", "paper", "study"] },
- { "category": "objects", "char": "📕", "name": "closed_book", "keywords": ["read", "library", "knowledge", "textbook", "learn"] },
- { "category": "objects", "char": "📗", "name": "green_book", "keywords": ["read", "library", "knowledge", "study"] },
- { "category": "objects", "char": "📘", "name": "blue_book", "keywords": ["read", "library", "knowledge", "learn", "study"] },
- { "category": "objects", "char": "📙", "name": "orange_book", "keywords": ["read", "library", "knowledge", "textbook", "study"] },
- { "category": "objects", "char": "📔", "name": "notebook_with_decorative_cover", "keywords": ["classroom", "notes", "record", "paper", "study"] },
- { "category": "objects", "char": "📒", "name": "ledger", "keywords": ["notes", "paper"] },
- { "category": "objects", "char": "📚", "name": "books", "keywords": ["literature", "library", "study"] },
- { "category": "objects", "char": "📖", "name": "open_book", "keywords": ["book", "read", "library", "knowledge", "literature", "learn", "study"] },
- { "category": "objects", "char": "🧷", "name": "safety_pin", "keywords": ["diaper"] },
- { "category": "objects", "char": "🔗", "name": "link", "keywords": ["rings", "url"] },
- { "category": "objects", "char": "📎", "name": "paperclip", "keywords": ["documents", "stationery"] },
- { "category": "objects", "char": "🖇", "name": "paperclips", "keywords": ["documents", "stationery"] },
- { "category": "objects", "char": "✂️", "name": "scissors", "keywords": ["stationery", "cut"] },
- { "category": "objects", "char": "📐", "name": "triangular_ruler", "keywords": ["stationery", "math", "architect", "sketch"] },
- { "category": "objects", "char": "📏", "name": "straight_ruler", "keywords": ["stationery", "calculate", "length", "math", "school", "drawing", "architect", "sketch"] },
- { "category": "objects", "char": "🧮", "name": "abacus", "keywords": ["calculation"] },
- { "category": "objects", "char": "📌", "name": "pushpin", "keywords": ["stationery", "mark", "here"] },
- { "category": "objects", "char": "📍", "name": "round_pushpin", "keywords": ["stationery", "location", "map", "here"] },
- { "category": "objects", "char": "🚩", "name": "triangular_flag_on_post", "keywords": ["mark", "milestone", "place"] },
- { "category": "objects", "char": "🏳", "name": "white_flag", "keywords": ["losing", "loser", "lost", "surrender", "give up", "fail"] },
- { "category": "objects", "char": "🏴", "name": "black_flag", "keywords": ["pirate"] },
- { "category": "objects", "char": "🏳️‍🌈", "name": "rainbow_flag", "keywords": ["flag", "rainbow", "pride", "gay", "lgbt", "glbt", "queer", "homosexual", "lesbian", "bisexual", "transgender"] },
- { "category": "objects", "char": "🏳️‍⚧️", "name": "transgender_flag", "keywords": ["flag", "transgender"] },
- { "category": "objects", "char": "🔐", "name": "closed_lock_with_key", "keywords": ["security", "privacy"] },
- { "category": "objects", "char": "🔒", "name": "lock", "keywords": ["security", "password", "padlock"] },
- { "category": "objects", "char": "🔓", "name": "unlock", "keywords": ["privacy", "security"] },
- { "category": "objects", "char": "🔏", "name": "lock_with_ink_pen", "keywords": ["security", "secret"] },
- { "category": "objects", "char": "🖊", "name": "pen", "keywords": ["stationery", "writing", "write"] },
- { "category": "objects", "char": "🖋", "name": "fountain_pen", "keywords": ["stationery", "writing", "write"] },
- { "category": "objects", "char": "✒️", "name": "black_nib", "keywords": ["pen", "stationery", "writing", "write"] },
- { "category": "objects", "char": "📝", "name": "memo", "keywords": ["write", "documents", "stationery", "pencil", "paper", "writing", "legal", "exam", "quiz", "test", "study", "compose"] },
- { "category": "objects", "char": "✏️", "name": "pencil2", "keywords": ["stationery", "write", "paper", "writing", "school", "study"] },
- { "category": "objects", "char": "🖍", "name": "crayon", "keywords": ["drawing", "creativity"] },
- { "category": "objects", "char": "🖌", "name": "paintbrush", "keywords": ["drawing", "creativity", "art"] },
- { "category": "objects", "char": "🔍", "name": "mag", "keywords": ["search", "zoom", "find", "detective"] },
- { "category": "objects", "char": "🔎", "name": "mag_right", "keywords": ["search", "zoom", "find", "detective"] },
- { "category": "objects", "char": "🪦", "name": "headstone", "keywords": [] },
- { "category": "objects", "char": "🪧", "name": "placard", "keywords": [] },
- { "category": "symbols", "char": "💯", "name": "100", "keywords": ["score", "perfect", "numbers", "century", "exam", "quiz", "test", "pass", "hundred"] },
- { "category": "symbols", "char": "🔢", "name": "1234", "keywords": ["numbers", "blue-square"] },
- { "category": "symbols", "char": "❤️", "name": "heart", "keywords": ["love", "like", "affection", "valentines"] },
- { "category": "symbols", "char": "🧡", "name": "orange_heart", "keywords": ["love", "like", "affection", "valentines"] },
- { "category": "symbols", "char": "💛", "name": "yellow_heart", "keywords": ["love", "like", "affection", "valentines"] },
- { "category": "symbols", "char": "💚", "name": "green_heart", "keywords": ["love", "like", "affection", "valentines"] },
- { "category": "symbols", "char": "💙", "name": "blue_heart", "keywords": ["love", "like", "affection", "valentines"] },
- { "category": "symbols", "char": "💜", "name": "purple_heart", "keywords": ["love", "like", "affection", "valentines"] },
- { "category": "symbols", "char": "🤎", "name": "brown_heart", "keywords": ["love", "like", "affection", "valentines"] },
- { "category": "symbols", "char": "🖤", "name": "black_heart", "keywords": ["love", "like", "affection", "valentines"] },
- { "category": "symbols", "char": "🤍", "name": "white_heart", "keywords": ["love", "like", "affection", "valentines"] },
- { "category": "symbols", "char": "💔", "name": "broken_heart", "keywords": ["sad", "sorry", "break", "heart", "heartbreak"] },
- { "category": "symbols", "char": "❣", "name": "heavy_heart_exclamation", "keywords": ["decoration", "love"] },
- { "category": "symbols", "char": "💕", "name": "two_hearts", "keywords": ["love", "like", "affection", "valentines", "heart"] },
- { "category": "symbols", "char": "💞", "name": "revolving_hearts", "keywords": ["love", "like", "affection", "valentines"] },
- { "category": "symbols", "char": "💓", "name": "heartbeat", "keywords": ["love", "like", "affection", "valentines", "pink", "heart"] },
- { "category": "symbols", "char": "💗", "name": "heartpulse", "keywords": ["like", "love", "affection", "valentines", "pink"] },
- { "category": "symbols", "char": "💖", "name": "sparkling_heart", "keywords": ["love", "like", "affection", "valentines"] },
- { "category": "symbols", "char": "💘", "name": "cupid", "keywords": ["love", "like", "heart", "affection", "valentines"] },
- { "category": "symbols", "char": "💝", "name": "gift_heart", "keywords": ["love", "valentines"] },
- { "category": "symbols", "char": "💟", "name": "heart_decoration", "keywords": ["purple-square", "love", "like"] },
- { "category": "symbols", "char": "\u2764\uFE0F\u200D\uD83D\uDD25", "name": "heart_on_fire", "keywords": [] },
- { "category": "symbols", "char": "\u2764\uFE0F\u200D\uD83E\uDE79", "name": "mending_heart", "keywords": [] },
- { "category": "symbols", "char": "☮", "name": "peace_symbol", "keywords": ["hippie"] },
- { "category": "symbols", "char": "✝", "name": "latin_cross", "keywords": ["christianity"] },
- { "category": "symbols", "char": "☪", "name": "star_and_crescent", "keywords": ["islam"] },
- { "category": "symbols", "char": "🕉", "name": "om", "keywords": ["hinduism", "buddhism", "sikhism", "jainism"] },
- { "category": "symbols", "char": "☸", "name": "wheel_of_dharma", "keywords": ["hinduism", "buddhism", "sikhism", "jainism"] },
- { "category": "symbols", "char": "✡", "name": "star_of_david", "keywords": ["judaism"] },
- { "category": "symbols", "char": "🔯", "name": "six_pointed_star", "keywords": ["purple-square", "religion", "jewish", "hexagram"] },
- { "category": "symbols", "char": "🕎", "name": "menorah", "keywords": ["hanukkah", "candles", "jewish"] },
- { "category": "symbols", "char": "☯", "name": "yin_yang", "keywords": ["balance"] },
- { "category": "symbols", "char": "☦", "name": "orthodox_cross", "keywords": ["suppedaneum", "religion"] },
- { "category": "symbols", "char": "🛐", "name": "place_of_worship", "keywords": ["religion", "church", "temple", "prayer"] },
- { "category": "symbols", "char": "⛎", "name": "ophiuchus", "keywords": ["sign", "purple-square", "constellation", "astrology"] },
- { "category": "symbols", "char": "♈", "name": "aries", "keywords": ["sign", "purple-square", "zodiac", "astrology"] },
- { "category": "symbols", "char": "♉", "name": "taurus", "keywords": ["purple-square", "sign", "zodiac", "astrology"] },
- { "category": "symbols", "char": "♊", "name": "gemini", "keywords": ["sign", "zodiac", "purple-square", "astrology"] },
- { "category": "symbols", "char": "♋", "name": "cancer", "keywords": ["sign", "zodiac", "purple-square", "astrology"] },
- { "category": "symbols", "char": "♌", "name": "leo", "keywords": ["sign", "purple-square", "zodiac", "astrology"] },
- { "category": "symbols", "char": "♍", "name": "virgo", "keywords": ["sign", "zodiac", "purple-square", "astrology"] },
- { "category": "symbols", "char": "♎", "name": "libra", "keywords": ["sign", "purple-square", "zodiac", "astrology"] },
- { "category": "symbols", "char": "♏", "name": "scorpius", "keywords": ["sign", "zodiac", "purple-square", "astrology", "scorpio"] },
- { "category": "symbols", "char": "♐", "name": "sagittarius", "keywords": ["sign", "zodiac", "purple-square", "astrology"] },
- { "category": "symbols", "char": "♑", "name": "capricorn", "keywords": ["sign", "zodiac", "purple-square", "astrology"] },
- { "category": "symbols", "char": "♒", "name": "aquarius", "keywords": ["sign", "purple-square", "zodiac", "astrology"] },
- { "category": "symbols", "char": "♓", "name": "pisces", "keywords": ["purple-square", "sign", "zodiac", "astrology"] },
- { "category": "symbols", "char": "🆔", "name": "id", "keywords": ["purple-square", "words"] },
- { "category": "symbols", "char": "⚛", "name": "atom_symbol", "keywords": ["science", "physics", "chemistry"] },
- { "category": "symbols", "char": "⚧️", "name": "transgender_symbol", "keywords": ["purple-square", "woman", "female", "toilet", "loo", "restroom", "gender"] },
- { "category": "symbols", "char": "🈳", "name": "u7a7a", "keywords": ["kanji", "japanese", "chinese", "empty", "sky", "blue-square", "aki"] },
- { "category": "symbols", "char": "🈹", "name": "u5272", "keywords": ["cut", "divide", "chinese", "kanji", "pink-square", "waribiki"] },
- { "category": "symbols", "char": "☢", "name": "radioactive", "keywords": ["nuclear", "danger"] },
- { "category": "symbols", "char": "☣", "name": "biohazard", "keywords": ["danger"] },
- { "category": "symbols", "char": "📴", "name": "mobile_phone_off", "keywords": ["mute", "orange-square", "silence", "quiet"] },
- { "category": "symbols", "char": "📳", "name": "vibration_mode", "keywords": ["orange-square", "phone"] },
- { "category": "symbols", "char": "🈶", "name": "u6709", "keywords": ["orange-square", "chinese", "have", "kanji", "ari"] },
- { "category": "symbols", "char": "🈚", "name": "u7121", "keywords": ["nothing", "chinese", "kanji", "japanese", "orange-square", "nashi"] },
- { "category": "symbols", "char": "🈸", "name": "u7533", "keywords": ["chinese", "japanese", "kanji", "orange-square", "moushikomi"] },
- { "category": "symbols", "char": "🈺", "name": "u55b6", "keywords": ["japanese", "opening hours", "orange-square", "eigyo"] },
- { "category": "symbols", "char": "🈷️", "name": "u6708", "keywords": ["chinese", "month", "moon", "japanese", "orange-square", "kanji", "tsuki", "tsukigime", "getsugaku"] },
- { "category": "symbols", "char": "✴️", "name": "eight_pointed_black_star", "keywords": ["orange-square", "shape", "polygon"] },
- { "category": "symbols", "char": "🆚", "name": "vs", "keywords": ["words", "orange-square"] },
- { "category": "symbols", "char": "🉑", "name": "accept", "keywords": ["ok", "good", "chinese", "kanji", "agree", "yes", "orange-circle"] },
- { "category": "symbols", "char": "💮", "name": "white_flower", "keywords": ["japanese", "spring"] },
- { "category": "symbols", "char": "🉐", "name": "ideograph_advantage", "keywords": ["chinese", "kanji", "obtain", "get", "circle"] },
- { "category": "symbols", "char": "㊙️", "name": "secret", "keywords": ["privacy", "chinese", "sshh", "kanji", "red-circle"] },
- { "category": "symbols", "char": "㊗️", "name": "congratulations", "keywords": ["chinese", "kanji", "japanese", "red-circle"] },
- { "category": "symbols", "char": "🈴", "name": "u5408", "keywords": ["japanese", "chinese", "join", "kanji", "red-square", "goukaku", "pass"] },
- { "category": "symbols", "char": "🈵", "name": "u6e80", "keywords": ["full", "chinese", "japanese", "red-square", "kanji", "man"] },
- { "category": "symbols", "char": "🈲", "name": "u7981", "keywords": ["kanji", "japanese", "chinese", "forbidden", "limit", "restricted", "red-square", "kinshi"] },
- { "category": "symbols", "char": "🅰️", "name": "a", "keywords": ["red-square", "alphabet", "letter"] },
- { "category": "symbols", "char": "🅱️", "name": "b", "keywords": ["red-square", "alphabet", "letter"] },
- { "category": "symbols", "char": "🆎", "name": "ab", "keywords": ["red-square", "alphabet"] },
- { "category": "symbols", "char": "🆑", "name": "cl", "keywords": ["alphabet", "words", "red-square"] },
- { "category": "symbols", "char": "🅾️", "name": "o2", "keywords": ["alphabet", "red-square", "letter"] },
- { "category": "symbols", "char": "🆘", "name": "sos", "keywords": ["help", "red-square", "words", "emergency", "911"] },
- { "category": "symbols", "char": "⛔", "name": "no_entry", "keywords": ["limit", "security", "privacy", "bad", "denied", "stop", "circle"] },
- { "category": "symbols", "char": "📛", "name": "name_badge", "keywords": ["fire", "forbid"] },
- { "category": "symbols", "char": "🚫", "name": "no_entry_sign", "keywords": ["forbid", "stop", "limit", "denied", "disallow", "circle"] },
- { "category": "symbols", "char": "❌", "name": "x", "keywords": ["no", "delete", "remove", "cancel", "red"] },
- { "category": "symbols", "char": "⭕", "name": "o", "keywords": ["circle", "round"] },
- { "category": "symbols", "char": "🛑", "name": "stop_sign", "keywords": ["stop"] },
- { "category": "symbols", "char": "💢", "name": "anger", "keywords": ["angry", "mad"] },
- { "category": "symbols", "char": "♨️", "name": "hotsprings", "keywords": ["bath", "warm", "relax"] },
- { "category": "symbols", "char": "🚷", "name": "no_pedestrians", "keywords": ["rules", "crossing", "walking", "circle"] },
- { "category": "symbols", "char": "🚯", "name": "do_not_litter", "keywords": ["trash", "bin", "garbage", "circle"] },
- { "category": "symbols", "char": "🚳", "name": "no_bicycles", "keywords": ["cyclist", "prohibited", "circle"] },
- { "category": "symbols", "char": "🚱", "name": "non-potable_water", "keywords": ["drink", "faucet", "tap", "circle"] },
- { "category": "symbols", "char": "🔞", "name": "underage", "keywords": ["18", "drink", "pub", "night", "minor", "circle"] },
- { "category": "symbols", "char": "📵", "name": "no_mobile_phones", "keywords": ["iphone", "mute", "circle"] },
- { "category": "symbols", "char": "❗", "name": "exclamation", "keywords": ["heavy_exclamation_mark", "danger", "surprise", "punctuation", "wow", "warning"] },
- { "category": "symbols", "char": "❕", "name": "grey_exclamation", "keywords": ["surprise", "punctuation", "gray", "wow", "warning"] },
- { "category": "symbols", "char": "❓", "name": "question", "keywords": ["doubt", "confused"] },
- { "category": "symbols", "char": "❔", "name": "grey_question", "keywords": ["doubts", "gray", "huh", "confused"] },
- { "category": "symbols", "char": "‼️", "name": "bangbang", "keywords": ["exclamation", "surprise"] },
- { "category": "symbols", "char": "⁉️", "name": "interrobang", "keywords": ["wat", "punctuation", "surprise"] },
- { "category": "symbols", "char": "🔅", "name": "low_brightness", "keywords": ["sun", "afternoon", "warm", "summer"] },
- { "category": "symbols", "char": "🔆", "name": "high_brightness", "keywords": ["sun", "light"] },
- { "category": "symbols", "char": "🔱", "name": "trident", "keywords": ["weapon", "spear"] },
- { "category": "symbols", "char": "⚜", "name": "fleur_de_lis", "keywords": ["decorative", "scout"] },
- { "category": "symbols", "char": "〽️", "name": "part_alternation_mark", "keywords": ["graph", "presentation", "stats", "business", "economics", "bad"] },
- { "category": "symbols", "char": "⚠️", "name": "warning", "keywords": ["exclamation", "wip", "alert", "error", "problem", "issue"] },
- { "category": "symbols", "char": "🚸", "name": "children_crossing", "keywords": ["school", "warning", "danger", "sign", "driving", "yellow-diamond"] },
- { "category": "symbols", "char": "🔰", "name": "beginner", "keywords": ["badge", "shield"] },
- { "category": "symbols", "char": "♻️", "name": "recycle", "keywords": ["arrow", "environment", "garbage", "trash"] },
- { "category": "symbols", "char": "🈯", "name": "u6307", "keywords": ["chinese", "point", "green-square", "kanji", "reserved", "shiteiseki"] },
- { "category": "symbols", "char": "💹", "name": "chart", "keywords": ["green-square", "graph", "presentation", "stats"] },
- { "category": "symbols", "char": "❇️", "name": "sparkle", "keywords": ["stars", "green-square", "awesome", "good", "fireworks"] },
- { "category": "symbols", "char": "✳️", "name": "eight_spoked_asterisk", "keywords": ["star", "sparkle", "green-square"] },
- { "category": "symbols", "char": "❎", "name": "negative_squared_cross_mark", "keywords": ["x", "green-square", "no", "deny"] },
- { "category": "symbols", "char": "✅", "name": "white_check_mark", "keywords": ["green-square", "ok", "agree", "vote", "election", "answer", "tick"] },
- { "category": "symbols", "char": "💠", "name": "diamond_shape_with_a_dot_inside", "keywords": ["jewel", "blue", "gem", "crystal", "fancy"] },
- { "category": "symbols", "char": "🌀", "name": "cyclone", "keywords": ["weather", "swirl", "blue", "cloud", "vortex", "spiral", "whirlpool", "spin", "tornado", "hurricane", "typhoon"] },
- { "category": "symbols", "char": "➿", "name": "loop", "keywords": ["tape", "cassette"] },
- { "category": "symbols", "char": "🌐", "name": "globe_with_meridians", "keywords": ["earth", "international", "world", "internet", "interweb", "i18n"] },
- { "category": "symbols", "char": "Ⓜ️", "name": "m", "keywords": ["alphabet", "blue-circle", "letter"] },
- { "category": "symbols", "char": "🏧", "name": "atm", "keywords": ["money", "sales", "cash", "blue-square", "payment", "bank"] },
- { "category": "symbols", "char": "🈂️", "name": "sa", "keywords": ["japanese", "blue-square", "katakana"] },
- { "category": "symbols", "char": "🛂", "name": "passport_control", "keywords": ["custom", "blue-square"] },
- { "category": "symbols", "char": "🛃", "name": "customs", "keywords": ["passport", "border", "blue-square"] },
- { "category": "symbols", "char": "🛄", "name": "baggage_claim", "keywords": ["blue-square", "airport", "transport"] },
- { "category": "symbols", "char": "🛅", "name": "left_luggage", "keywords": ["blue-square", "travel"] },
- { "category": "symbols", "char": "♿", "name": "wheelchair", "keywords": ["blue-square", "disabled", "a11y", "accessibility"] },
- { "category": "symbols", "char": "🚭", "name": "no_smoking", "keywords": ["cigarette", "blue-square", "smell", "smoke"] },
- { "category": "symbols", "char": "🚾", "name": "wc", "keywords": ["toilet", "restroom", "blue-square"] },
- { "category": "symbols", "char": "🅿️", "name": "parking", "keywords": ["cars", "blue-square", "alphabet", "letter"] },
- { "category": "symbols", "char": "🚰", "name": "potable_water", "keywords": ["blue-square", "liquid", "restroom", "cleaning", "faucet"] },
- { "category": "symbols", "char": "🚹", "name": "mens", "keywords": ["toilet", "restroom", "wc", "blue-square", "gender", "male"] },
- { "category": "symbols", "char": "🚺", "name": "womens", "keywords": ["purple-square", "woman", "female", "toilet", "loo", "restroom", "gender"] },
- { "category": "symbols", "char": "🚼", "name": "baby_symbol", "keywords": ["orange-square", "child"] },
- { "category": "symbols", "char": "🚻", "name": "restroom", "keywords": ["blue-square", "toilet", "refresh", "wc", "gender"] },
- { "category": "symbols", "char": "🚮", "name": "put_litter_in_its_place", "keywords": ["blue-square", "sign", "human", "info"] },
- { "category": "symbols", "char": "🎦", "name": "cinema", "keywords": ["blue-square", "record", "film", "movie", "curtain", "stage", "theater"] },
- { "category": "symbols", "char": "📶", "name": "signal_strength", "keywords": ["blue-square", "reception", "phone", "internet", "connection", "wifi", "bluetooth", "bars"] },
- { "category": "symbols", "char": "🈁", "name": "koko", "keywords": ["blue-square", "here", "katakana", "japanese", "destination"] },
- { "category": "symbols", "char": "🆖", "name": "ng", "keywords": ["blue-square", "words", "shape", "icon"] },
- { "category": "symbols", "char": "🆗", "name": "ok", "keywords": ["good", "agree", "yes", "blue-square"] },
- { "category": "symbols", "char": "🆙", "name": "up", "keywords": ["blue-square", "above", "high"] },
- { "category": "symbols", "char": "🆒", "name": "cool", "keywords": ["words", "blue-square"] },
- { "category": "symbols", "char": "🆕", "name": "new", "keywords": ["blue-square", "words", "start"] },
- { "category": "symbols", "char": "🆓", "name": "free", "keywords": ["blue-square", "words"] },
- { "category": "symbols", "char": "0️⃣", "name": "zero", "keywords": ["0", "numbers", "blue-square", "null"] },
- { "category": "symbols", "char": "1️⃣", "name": "one", "keywords": ["blue-square", "numbers", "1"] },
- { "category": "symbols", "char": "2️⃣", "name": "two", "keywords": ["numbers", "2", "prime", "blue-square"] },
- { "category": "symbols", "char": "3️⃣", "name": "three", "keywords": ["3", "numbers", "prime", "blue-square"] },
- { "category": "symbols", "char": "4️⃣", "name": "four", "keywords": ["4", "numbers", "blue-square"] },
- { "category": "symbols", "char": "5️⃣", "name": "five", "keywords": ["5", "numbers", "blue-square", "prime"] },
- { "category": "symbols", "char": "6️⃣", "name": "six", "keywords": ["6", "numbers", "blue-square"] },
- { "category": "symbols", "char": "7️⃣", "name": "seven", "keywords": ["7", "numbers", "blue-square", "prime"] },
- { "category": "symbols", "char": "8️⃣", "name": "eight", "keywords": ["8", "blue-square", "numbers"] },
- { "category": "symbols", "char": "9️⃣", "name": "nine", "keywords": ["blue-square", "numbers", "9"] },
- { "category": "symbols", "char": "🔟", "name": "keycap_ten", "keywords": ["numbers", "10", "blue-square"] },
- { "category": "symbols", "char": "*⃣", "name": "asterisk", "keywords": ["star", "keycap"] },
- { "category": "symbols", "char": "⏏️", "name": "eject_button", "keywords": ["blue-square"] },
- { "category": "symbols", "char": "▶️", "name": "arrow_forward", "keywords": ["blue-square", "right", "direction", "play"] },
- { "category": "symbols", "char": "⏸", "name": "pause_button", "keywords": ["pause", "blue-square"] },
- { "category": "symbols", "char": "⏭", "name": "next_track_button", "keywords": ["forward", "next", "blue-square"] },
- { "category": "symbols", "char": "⏹", "name": "stop_button", "keywords": ["blue-square"] },
- { "category": "symbols", "char": "⏺", "name": "record_button", "keywords": ["blue-square"] },
- { "category": "symbols", "char": "⏯", "name": "play_or_pause_button", "keywords": ["blue-square", "play", "pause"] },
- { "category": "symbols", "char": "⏮", "name": "previous_track_button", "keywords": ["backward"] },
- { "category": "symbols", "char": "⏩", "name": "fast_forward", "keywords": ["blue-square", "play", "speed", "continue"] },
- { "category": "symbols", "char": "⏪", "name": "rewind", "keywords": ["play", "blue-square"] },
- { "category": "symbols", "char": "🔀", "name": "twisted_rightwards_arrows", "keywords": ["blue-square", "shuffle", "music", "random"] },
- { "category": "symbols", "char": "🔁", "name": "repeat", "keywords": ["loop", "record"] },
- { "category": "symbols", "char": "🔂", "name": "repeat_one", "keywords": ["blue-square", "loop"] },
- { "category": "symbols", "char": "◀️", "name": "arrow_backward", "keywords": ["blue-square", "left", "direction"] },
- { "category": "symbols", "char": "🔼", "name": "arrow_up_small", "keywords": ["blue-square", "triangle", "direction", "point", "forward", "top"] },
- { "category": "symbols", "char": "🔽", "name": "arrow_down_small", "keywords": ["blue-square", "direction", "bottom"] },
- { "category": "symbols", "char": "⏫", "name": "arrow_double_up", "keywords": ["blue-square", "direction", "top"] },
- { "category": "symbols", "char": "⏬", "name": "arrow_double_down", "keywords": ["blue-square", "direction", "bottom"] },
- { "category": "symbols", "char": "➡️", "name": "arrow_right", "keywords": ["blue-square", "next"] },
- { "category": "symbols", "char": "⬅️", "name": "arrow_left", "keywords": ["blue-square", "previous", "back"] },
- { "category": "symbols", "char": "⬆️", "name": "arrow_up", "keywords": ["blue-square", "continue", "top", "direction"] },
- { "category": "symbols", "char": "⬇️", "name": "arrow_down", "keywords": ["blue-square", "direction", "bottom"] },
- { "category": "symbols", "char": "↗️", "name": "arrow_upper_right", "keywords": ["blue-square", "point", "direction", "diagonal", "northeast"] },
- { "category": "symbols", "char": "↘️", "name": "arrow_lower_right", "keywords": ["blue-square", "direction", "diagonal", "southeast"] },
- { "category": "symbols", "char": "↙️", "name": "arrow_lower_left", "keywords": ["blue-square", "direction", "diagonal", "southwest"] },
- { "category": "symbols", "char": "↖️", "name": "arrow_upper_left", "keywords": ["blue-square", "point", "direction", "diagonal", "northwest"] },
- { "category": "symbols", "char": "↕️", "name": "arrow_up_down", "keywords": ["blue-square", "direction", "way", "vertical"] },
- { "category": "symbols", "char": "↔️", "name": "left_right_arrow", "keywords": ["shape", "direction", "horizontal", "sideways"] },
- { "category": "symbols", "char": "🔄", "name": "arrows_counterclockwise", "keywords": ["blue-square", "sync", "cycle"] },
- { "category": "symbols", "char": "↪️", "name": "arrow_right_hook", "keywords": ["blue-square", "return", "rotate", "direction"] },
- { "category": "symbols", "char": "↩️", "name": "leftwards_arrow_with_hook", "keywords": ["back", "return", "blue-square", "undo", "enter"] },
- { "category": "symbols", "char": "⤴️", "name": "arrow_heading_up", "keywords": ["blue-square", "direction", "top"] },
- { "category": "symbols", "char": "⤵️", "name": "arrow_heading_down", "keywords": ["blue-square", "direction", "bottom"] },
- { "category": "symbols", "char": "#️⃣", "name": "hash", "keywords": ["symbol", "blue-square", "twitter"] },
- { "category": "symbols", "char": "ℹ️", "name": "information_source", "keywords": ["blue-square", "alphabet", "letter"] },
- { "category": "symbols", "char": "🔤", "name": "abc", "keywords": ["blue-square", "alphabet"] },
- { "category": "symbols", "char": "🔡", "name": "abcd", "keywords": ["blue-square", "alphabet"] },
- { "category": "symbols", "char": "🔠", "name": "capital_abcd", "keywords": ["alphabet", "words", "blue-square"] },
- { "category": "symbols", "char": "🔣", "name": "symbols", "keywords": ["blue-square", "music", "note", "ampersand", "percent", "glyphs", "characters"] },
- { "category": "symbols", "char": "🎵", "name": "musical_note", "keywords": ["score", "tone", "sound"] },
- { "category": "symbols", "char": "🎶", "name": "notes", "keywords": ["music", "score"] },
- { "category": "symbols", "char": "〰️", "name": "wavy_dash", "keywords": ["draw", "line", "moustache", "mustache", "squiggle", "scribble"] },
- { "category": "symbols", "char": "➰", "name": "curly_loop", "keywords": ["scribble", "draw", "shape", "squiggle"] },
- { "category": "symbols", "char": "✔️", "name": "heavy_check_mark", "keywords": ["ok", "nike", "answer", "yes", "tick"] },
- { "category": "symbols", "char": "🔃", "name": "arrows_clockwise", "keywords": ["sync", "cycle", "round", "repeat"] },
- { "category": "symbols", "char": "➕", "name": "heavy_plus_sign", "keywords": ["math", "calculation", "addition", "more", "increase"] },
- { "category": "symbols", "char": "➖", "name": "heavy_minus_sign", "keywords": ["math", "calculation", "subtract", "less"] },
- { "category": "symbols", "char": "➗", "name": "heavy_division_sign", "keywords": ["divide", "math", "calculation"] },
- { "category": "symbols", "char": "✖️", "name": "heavy_multiplication_x", "keywords": ["math", "calculation"] },
- { "category": "symbols", "char": "\uD83D\uDFF0", "name": "heavy_equals_sign", "keywords": [] },
- { "category": "symbols", "char": "♾", "name": "infinity", "keywords": ["forever"] },
- { "category": "symbols", "char": "💲", "name": "heavy_dollar_sign", "keywords": ["money", "sales", "payment", "currency", "buck"] },
- { "category": "symbols", "char": "💱", "name": "currency_exchange", "keywords": ["money", "sales", "dollar", "travel"] },
- { "category": "symbols", "char": "©️", "name": "copyright", "keywords": ["ip", "license", "circle", "law", "legal"] },
- { "category": "symbols", "char": "®️", "name": "registered", "keywords": ["alphabet", "circle"] },
- { "category": "symbols", "char": "™️", "name": "tm", "keywords": ["trademark", "brand", "law", "legal"] },
- { "category": "symbols", "char": "🔚", "name": "end", "keywords": ["words", "arrow"] },
- { "category": "symbols", "char": "🔙", "name": "back", "keywords": ["arrow", "words", "return"] },
- { "category": "symbols", "char": "🔛", "name": "on", "keywords": ["arrow", "words"] },
- { "category": "symbols", "char": "🔝", "name": "top", "keywords": ["words", "blue-square"] },
- { "category": "symbols", "char": "🔜", "name": "soon", "keywords": ["arrow", "words"] },
- { "category": "symbols", "char": "☑️", "name": "ballot_box_with_check", "keywords": ["ok", "agree", "confirm", "black-square", "vote", "election", "yes", "tick"] },
- { "category": "symbols", "char": "🔘", "name": "radio_button", "keywords": ["input", "old", "music", "circle"] },
- { "category": "symbols", "char": "⚫", "name": "black_circle", "keywords": ["shape", "button", "round"] },
- { "category": "symbols", "char": "⚪", "name": "white_circle", "keywords": ["shape", "round"] },
- { "category": "symbols", "char": "🔴", "name": "red_circle", "keywords": ["shape", "error", "danger"] },
- { "category": "symbols", "char": "🟠", "name": "orange_circle", "keywords": ["shape"] },
- { "category": "symbols", "char": "🟡", "name": "yellow_circle", "keywords": ["shape"] },
- { "category": "symbols", "char": "🟢", "name": "green_circle", "keywords": ["shape"] },
- { "category": "symbols", "char": "🔵", "name": "large_blue_circle", "keywords": ["shape", "icon", "button"] },
- { "category": "symbols", "char": "🟣", "name": "purple_circle", "keywords": ["shape"] },
- { "category": "symbols", "char": "🟤", "name": "brown_circle", "keywords": ["shape"] },
- { "category": "symbols", "char": "🔸", "name": "small_orange_diamond", "keywords": ["shape", "jewel", "gem"] },
- { "category": "symbols", "char": "🔹", "name": "small_blue_diamond", "keywords": ["shape", "jewel", "gem"] },
- { "category": "symbols", "char": "🔶", "name": "large_orange_diamond", "keywords": ["shape", "jewel", "gem"] },
- { "category": "symbols", "char": "🔷", "name": "large_blue_diamond", "keywords": ["shape", "jewel", "gem"] },
- { "category": "symbols", "char": "🔺", "name": "small_red_triangle", "keywords": ["shape", "direction", "up", "top"] },
- { "category": "symbols", "char": "▪️", "name": "black_small_square", "keywords": ["shape", "icon"] },
- { "category": "symbols", "char": "▫️", "name": "white_small_square", "keywords": ["shape", "icon"] },
- { "category": "symbols", "char": "⬛", "name": "black_large_square", "keywords": ["shape", "icon", "button"] },
- { "category": "symbols", "char": "⬜", "name": "white_large_square", "keywords": ["shape", "icon", "stone", "button"] },
- { "category": "symbols", "char": "🟥", "name": "red_square", "keywords": ["shape"] },
- { "category": "symbols", "char": "🟧", "name": "orange_square", "keywords": ["shape"] },
- { "category": "symbols", "char": "🟨", "name": "yellow_square", "keywords": ["shape"] },
- { "category": "symbols", "char": "🟩", "name": "green_square", "keywords": ["shape"] },
- { "category": "symbols", "char": "🟦", "name": "blue_square", "keywords": ["shape"] },
- { "category": "symbols", "char": "🟪", "name": "purple_square", "keywords": ["shape"] },
- { "category": "symbols", "char": "🟫", "name": "brown_square", "keywords": ["shape"] },
- { "category": "symbols", "char": "🔻", "name": "small_red_triangle_down", "keywords": ["shape", "direction", "bottom"] },
- { "category": "symbols", "char": "◼️", "name": "black_medium_square", "keywords": ["shape", "button", "icon"] },
- { "category": "symbols", "char": "◻️", "name": "white_medium_square", "keywords": ["shape", "stone", "icon"] },
- { "category": "symbols", "char": "◾", "name": "black_medium_small_square", "keywords": ["icon", "shape", "button"] },
- { "category": "symbols", "char": "◽", "name": "white_medium_small_square", "keywords": ["shape", "stone", "icon", "button"] },
- { "category": "symbols", "char": "🔲", "name": "black_square_button", "keywords": ["shape", "input", "frame"] },
- { "category": "symbols", "char": "🔳", "name": "white_square_button", "keywords": ["shape", "input"] },
- { "category": "symbols", "char": "🔈", "name": "speaker", "keywords": ["sound", "volume", "silence", "broadcast"] },
- { "category": "symbols", "char": "🔉", "name": "sound", "keywords": ["volume", "speaker", "broadcast"] },
- { "category": "symbols", "char": "🔊", "name": "loud_sound", "keywords": ["volume", "noise", "noisy", "speaker", "broadcast"] },
- { "category": "symbols", "char": "🔇", "name": "mute", "keywords": ["sound", "volume", "silence", "quiet"] },
- { "category": "symbols", "char": "📣", "name": "mega", "keywords": ["sound", "speaker", "volume"] },
- { "category": "symbols", "char": "📢", "name": "loudspeaker", "keywords": ["volume", "sound"] },
- { "category": "symbols", "char": "🔔", "name": "bell", "keywords": ["sound", "notification", "christmas", "xmas", "chime"] },
- { "category": "symbols", "char": "🔕", "name": "no_bell", "keywords": ["sound", "volume", "mute", "quiet", "silent"] },
- { "category": "symbols", "char": "🃏", "name": "black_joker", "keywords": ["poker", "cards", "game", "play", "magic"] },
- { "category": "symbols", "char": "🀄", "name": "mahjong", "keywords": ["game", "play", "chinese", "kanji"] },
- { "category": "symbols", "char": "♠️", "name": "spades", "keywords": ["poker", "cards", "suits", "magic"] },
- { "category": "symbols", "char": "♣️", "name": "clubs", "keywords": ["poker", "cards", "magic", "suits"] },
- { "category": "symbols", "char": "♥️", "name": "hearts", "keywords": ["poker", "cards", "magic", "suits"] },
- { "category": "symbols", "char": "♦️", "name": "diamonds", "keywords": ["poker", "cards", "magic", "suits"] },
- { "category": "symbols", "char": "🎴", "name": "flower_playing_cards", "keywords": ["game", "sunset", "red"] },
- { "category": "symbols", "char": "💭", "name": "thought_balloon", "keywords": ["bubble", "cloud", "speech", "thinking", "dream"] },
- { "category": "symbols", "char": "🗯", "name": "right_anger_bubble", "keywords": ["caption", "speech", "thinking", "mad"] },
- { "category": "symbols", "char": "💬", "name": "speech_balloon", "keywords": ["bubble", "words", "message", "talk", "chatting"] },
- { "category": "symbols", "char": "🗨", "name": "left_speech_bubble", "keywords": ["words", "message", "talk", "chatting"] },
- { "category": "symbols", "char": "🕐", "name": "clock1", "keywords": ["time", "late", "early", "schedule"] },
- { "category": "symbols", "char": "🕑", "name": "clock2", "keywords": ["time", "late", "early", "schedule"] },
- { "category": "symbols", "char": "🕒", "name": "clock3", "keywords": ["time", "late", "early", "schedule"] },
- { "category": "symbols", "char": "🕓", "name": "clock4", "keywords": ["time", "late", "early", "schedule"] },
- { "category": "symbols", "char": "🕔", "name": "clock5", "keywords": ["time", "late", "early", "schedule"] },
- { "category": "symbols", "char": "🕕", "name": "clock6", "keywords": ["time", "late", "early", "schedule", "dawn", "dusk"] },
- { "category": "symbols", "char": "🕖", "name": "clock7", "keywords": ["time", "late", "early", "schedule"] },
- { "category": "symbols", "char": "🕗", "name": "clock8", "keywords": ["time", "late", "early", "schedule"] },
- { "category": "symbols", "char": "🕘", "name": "clock9", "keywords": ["time", "late", "early", "schedule"] },
- { "category": "symbols", "char": "🕙", "name": "clock10", "keywords": ["time", "late", "early", "schedule"] },
- { "category": "symbols", "char": "🕚", "name": "clock11", "keywords": ["time", "late", "early", "schedule"] },
- { "category": "symbols", "char": "🕛", "name": "clock12", "keywords": ["time", "noon", "midnight", "midday", "late", "early", "schedule"] },
- { "category": "symbols", "char": "🕜", "name": "clock130", "keywords": ["time", "late", "early", "schedule"] },
- { "category": "symbols", "char": "🕝", "name": "clock230", "keywords": ["time", "late", "early", "schedule"] },
- { "category": "symbols", "char": "🕞", "name": "clock330", "keywords": ["time", "late", "early", "schedule"] },
- { "category": "symbols", "char": "🕟", "name": "clock430", "keywords": ["time", "late", "early", "schedule"] },
- { "category": "symbols", "char": "🕠", "name": "clock530", "keywords": ["time", "late", "early", "schedule"] },
- { "category": "symbols", "char": "🕡", "name": "clock630", "keywords": ["time", "late", "early", "schedule"] },
- { "category": "symbols", "char": "🕢", "name": "clock730", "keywords": ["time", "late", "early", "schedule"] },
- { "category": "symbols", "char": "🕣", "name": "clock830", "keywords": ["time", "late", "early", "schedule"] },
- { "category": "symbols", "char": "🕤", "name": "clock930", "keywords": ["time", "late", "early", "schedule"] },
- { "category": "symbols", "char": "🕥", "name": "clock1030", "keywords": ["time", "late", "early", "schedule"] },
- { "category": "symbols", "char": "🕦", "name": "clock1130", "keywords": ["time", "late", "early", "schedule"] },
- { "category": "symbols", "char": "🕧", "name": "clock1230", "keywords": ["time", "late", "early", "schedule"] },
- { "category": "flags", "char": "🇦🇫", "name": "afghanistan", "keywords": ["af", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇦🇽", "name": "aland_islands", "keywords": ["Åland", "islands", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇦🇱", "name": "albania", "keywords": ["al", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇩🇿", "name": "algeria", "keywords": ["dz", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇦🇸", "name": "american_samoa", "keywords": ["american", "ws", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇦🇩", "name": "andorra", "keywords": ["ad", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇦🇴", "name": "angola", "keywords": ["ao", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇦🇮", "name": "anguilla", "keywords": ["ai", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇦🇶", "name": "antarctica", "keywords": ["aq", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇦🇬", "name": "antigua_barbuda", "keywords": ["antigua", "barbuda", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇦🇷", "name": "argentina", "keywords": ["ar", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇦🇲", "name": "armenia", "keywords": ["am", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇦🇼", "name": "aruba", "keywords": ["aw", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇦🇨", "name": "ascension_island", "keywords": ["flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇦🇺", "name": "australia", "keywords": ["au", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇦🇹", "name": "austria", "keywords": ["at", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇦🇿", "name": "azerbaijan", "keywords": ["az", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇧🇸", "name": "bahamas", "keywords": ["bs", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇧🇭", "name": "bahrain", "keywords": ["bh", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇧🇩", "name": "bangladesh", "keywords": ["bd", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇧🇧", "name": "barbados", "keywords": ["bb", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇧🇾", "name": "belarus", "keywords": ["by", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇧🇪", "name": "belgium", "keywords": ["be", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇧🇿", "name": "belize", "keywords": ["bz", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇧🇯", "name": "benin", "keywords": ["bj", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇧🇲", "name": "bermuda", "keywords": ["bm", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇧🇹", "name": "bhutan", "keywords": ["bt", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇧🇴", "name": "bolivia", "keywords": ["bo", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇧🇶", "name": "caribbean_netherlands", "keywords": ["bonaire", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇧🇦", "name": "bosnia_herzegovina", "keywords": ["bosnia", "herzegovina", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇧🇼", "name": "botswana", "keywords": ["bw", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇧🇷", "name": "brazil", "keywords": ["br", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇮🇴", "name": "british_indian_ocean_territory", "keywords": ["british", "indian", "ocean", "territory", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇻🇬", "name": "british_virgin_islands", "keywords": ["british", "virgin", "islands", "bvi", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇧🇳", "name": "brunei", "keywords": ["bn", "darussalam", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇧🇬", "name": "bulgaria", "keywords": ["bg", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇧🇫", "name": "burkina_faso", "keywords": ["burkina", "faso", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇧🇮", "name": "burundi", "keywords": ["bi", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇨🇻", "name": "cape_verde", "keywords": ["cabo", "verde", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇰🇭", "name": "cambodia", "keywords": ["kh", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇨🇲", "name": "cameroon", "keywords": ["cm", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇨🇦", "name": "canada", "keywords": ["ca", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇮🇨", "name": "canary_islands", "keywords": ["canary", "islands", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇰🇾", "name": "cayman_islands", "keywords": ["cayman", "islands", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇨🇫", "name": "central_african_republic", "keywords": ["central", "african", "republic", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇹🇩", "name": "chad", "keywords": ["td", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇨🇱", "name": "chile", "keywords": ["flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇨🇳", "name": "cn", "keywords": ["china", "chinese", "prc", "flag", "country", "nation", "banner"] },
- { "category": "flags", "char": "🇨🇽", "name": "christmas_island", "keywords": ["christmas", "island", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇨🇨", "name": "cocos_islands", "keywords": ["cocos", "keeling", "islands", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇨🇴", "name": "colombia", "keywords": ["co", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇰🇲", "name": "comoros", "keywords": ["km", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇨🇬", "name": "congo_brazzaville", "keywords": ["congo", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇨🇩", "name": "congo_kinshasa", "keywords": ["congo", "democratic", "republic", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇨🇰", "name": "cook_islands", "keywords": ["cook", "islands", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇨🇷", "name": "costa_rica", "keywords": ["costa", "rica", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇭🇷", "name": "croatia", "keywords": ["hr", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇨🇺", "name": "cuba", "keywords": ["cu", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇨🇼", "name": "curacao", "keywords": ["curaçao", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇨🇾", "name": "cyprus", "keywords": ["cy", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇨🇿", "name": "czech_republic", "keywords": ["cz", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇩🇰", "name": "denmark", "keywords": ["dk", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇩🇯", "name": "djibouti", "keywords": ["dj", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇩🇲", "name": "dominica", "keywords": ["dm", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇩🇴", "name": "dominican_republic", "keywords": ["dominican", "republic", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇪🇨", "name": "ecuador", "keywords": ["ec", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇪🇬", "name": "egypt", "keywords": ["eg", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇸🇻", "name": "el_salvador", "keywords": ["el", "salvador", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇬🇶", "name": "equatorial_guinea", "keywords": ["equatorial", "gn", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇪🇷", "name": "eritrea", "keywords": ["er", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇪🇪", "name": "estonia", "keywords": ["ee", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇪🇹", "name": "ethiopia", "keywords": ["et", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇪🇺", "name": "eu", "keywords": ["european", "union", "flag", "banner"] },
- { "category": "flags", "char": "🇫🇰", "name": "falkland_islands", "keywords": ["falkland", "islands", "malvinas", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇫🇴", "name": "faroe_islands", "keywords": ["faroe", "islands", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇫🇯", "name": "fiji", "keywords": ["fj", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇫🇮", "name": "finland", "keywords": ["fi", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇫🇷", "name": "fr", "keywords": ["banner", "flag", "nation", "france", "french", "country"] },
- { "category": "flags", "char": "🇬🇫", "name": "french_guiana", "keywords": ["french", "guiana", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇵🇫", "name": "french_polynesia", "keywords": ["french", "polynesia", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇹🇫", "name": "french_southern_territories", "keywords": ["french", "southern", "territories", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇬🇦", "name": "gabon", "keywords": ["ga", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇬🇲", "name": "gambia", "keywords": ["gm", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇬🇪", "name": "georgia", "keywords": ["ge", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇩🇪", "name": "de", "keywords": ["german", "nation", "flag", "country", "banner"] },
- { "category": "flags", "char": "🇬🇭", "name": "ghana", "keywords": ["gh", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇬🇮", "name": "gibraltar", "keywords": ["gi", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇬🇷", "name": "greece", "keywords": ["gr", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇬🇱", "name": "greenland", "keywords": ["gl", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇬🇩", "name": "grenada", "keywords": ["gd", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇬🇵", "name": "guadeloupe", "keywords": ["gp", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇬🇺", "name": "guam", "keywords": ["gu", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇬🇹", "name": "guatemala", "keywords": ["gt", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇬🇬", "name": "guernsey", "keywords": ["gg", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇬🇳", "name": "guinea", "keywords": ["gn", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇬🇼", "name": "guinea_bissau", "keywords": ["gw", "bissau", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇬🇾", "name": "guyana", "keywords": ["gy", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇭🇹", "name": "haiti", "keywords": ["ht", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇭🇳", "name": "honduras", "keywords": ["hn", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇭🇰", "name": "hong_kong", "keywords": ["hong", "kong", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇭🇺", "name": "hungary", "keywords": ["hu", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇮🇸", "name": "iceland", "keywords": ["is", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇮🇳", "name": "india", "keywords": ["in", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇮🇩", "name": "indonesia", "keywords": ["flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇮🇷", "name": "iran", "keywords": ["iran, ", "islamic", "republic", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇮🇶", "name": "iraq", "keywords": ["iq", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇮🇪", "name": "ireland", "keywords": ["ie", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇮🇲", "name": "isle_of_man", "keywords": ["isle", "man", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇮🇱", "name": "israel", "keywords": ["il", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇮🇹", "name": "it", "keywords": ["italy", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇨🇮", "name": "cote_divoire", "keywords": ["ivory", "coast", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇯🇲", "name": "jamaica", "keywords": ["jm", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇯🇵", "name": "jp", "keywords": ["japanese", "nation", "flag", "country", "banner"] },
- { "category": "flags", "char": "🇯🇪", "name": "jersey", "keywords": ["je", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇯🇴", "name": "jordan", "keywords": ["jo", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇰🇿", "name": "kazakhstan", "keywords": ["kz", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇰🇪", "name": "kenya", "keywords": ["ke", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇰🇮", "name": "kiribati", "keywords": ["ki", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇽🇰", "name": "kosovo", "keywords": ["xk", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇰🇼", "name": "kuwait", "keywords": ["kw", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇰🇬", "name": "kyrgyzstan", "keywords": ["kg", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇱🇦", "name": "laos", "keywords": ["lao", "democratic", "republic", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇱🇻", "name": "latvia", "keywords": ["lv", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇱🇧", "name": "lebanon", "keywords": ["lb", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇱🇸", "name": "lesotho", "keywords": ["ls", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇱🇷", "name": "liberia", "keywords": ["lr", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇱🇾", "name": "libya", "keywords": ["ly", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇱🇮", "name": "liechtenstein", "keywords": ["li", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇱🇹", "name": "lithuania", "keywords": ["lt", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇱🇺", "name": "luxembourg", "keywords": ["lu", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇲🇴", "name": "macau", "keywords": ["macao", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇲🇰", "name": "macedonia", "keywords": ["macedonia, ", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇲🇬", "name": "madagascar", "keywords": ["mg", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇲🇼", "name": "malawi", "keywords": ["mw", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇲🇾", "name": "malaysia", "keywords": ["my", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇲🇻", "name": "maldives", "keywords": ["mv", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇲🇱", "name": "mali", "keywords": ["ml", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇲🇹", "name": "malta", "keywords": ["mt", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇲🇭", "name": "marshall_islands", "keywords": ["marshall", "islands", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇲🇶", "name": "martinique", "keywords": ["mq", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇲🇷", "name": "mauritania", "keywords": ["mr", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇲🇺", "name": "mauritius", "keywords": ["mu", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇾🇹", "name": "mayotte", "keywords": ["yt", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇲🇽", "name": "mexico", "keywords": ["mx", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇫🇲", "name": "micronesia", "keywords": ["micronesia, ", "federated", "states", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇲🇩", "name": "moldova", "keywords": ["moldova, ", "republic", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇲🇨", "name": "monaco", "keywords": ["mc", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇲🇳", "name": "mongolia", "keywords": ["mn", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇲🇪", "name": "montenegro", "keywords": ["me", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇲🇸", "name": "montserrat", "keywords": ["ms", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇲🇦", "name": "morocco", "keywords": ["ma", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇲🇿", "name": "mozambique", "keywords": ["mz", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇲🇲", "name": "myanmar", "keywords": ["mm", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇳🇦", "name": "namibia", "keywords": ["na", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇳🇷", "name": "nauru", "keywords": ["nr", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇳🇵", "name": "nepal", "keywords": ["np", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇳🇱", "name": "netherlands", "keywords": ["nl", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇳🇨", "name": "new_caledonia", "keywords": ["new", "caledonia", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇳🇿", "name": "new_zealand", "keywords": ["new", "zealand", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇳🇮", "name": "nicaragua", "keywords": ["ni", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇳🇪", "name": "niger", "keywords": ["ne", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇳🇬", "name": "nigeria", "keywords": ["flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇳🇺", "name": "niue", "keywords": ["nu", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇳🇫", "name": "norfolk_island", "keywords": ["norfolk", "island", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇲🇵", "name": "northern_mariana_islands", "keywords": ["northern", "mariana", "islands", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇰🇵", "name": "north_korea", "keywords": ["north", "korea", "nation", "flag", "country", "banner"] },
- { "category": "flags", "char": "🇳🇴", "name": "norway", "keywords": ["no", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇴🇲", "name": "oman", "keywords": ["om_symbol", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇵🇰", "name": "pakistan", "keywords": ["pk", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇵🇼", "name": "palau", "keywords": ["pw", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇵🇸", "name": "palestinian_territories", "keywords": ["palestine", "palestinian", "territories", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇵🇦", "name": "panama", "keywords": ["pa", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇵🇬", "name": "papua_new_guinea", "keywords": ["papua", "new", "guinea", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇵🇾", "name": "paraguay", "keywords": ["py", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇵🇪", "name": "peru", "keywords": ["pe", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇵🇭", "name": "philippines", "keywords": ["ph", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇵🇳", "name": "pitcairn_islands", "keywords": ["pitcairn", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇵🇱", "name": "poland", "keywords": ["pl", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇵🇹", "name": "portugal", "keywords": ["pt", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇵🇷", "name": "puerto_rico", "keywords": ["puerto", "rico", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇶🇦", "name": "qatar", "keywords": ["qa", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇷🇪", "name": "reunion", "keywords": ["réunion", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇷🇴", "name": "romania", "keywords": ["ro", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇷🇺", "name": "ru", "keywords": ["russian", "federation", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇷🇼", "name": "rwanda", "keywords": ["rw", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇧🇱", "name": "st_barthelemy", "keywords": ["saint", "barthélemy", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇸🇭", "name": "st_helena", "keywords": ["saint", "helena", "ascension", "tristan", "cunha", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇰🇳", "name": "st_kitts_nevis", "keywords": ["saint", "kitts", "nevis", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇱🇨", "name": "st_lucia", "keywords": ["saint", "lucia", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇵🇲", "name": "st_pierre_miquelon", "keywords": ["saint", "pierre", "miquelon", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇻🇨", "name": "st_vincent_grenadines", "keywords": ["saint", "vincent", "grenadines", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇼🇸", "name": "samoa", "keywords": ["ws", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇸🇲", "name": "san_marino", "keywords": ["san", "marino", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇸🇹", "name": "sao_tome_principe", "keywords": ["sao", "tome", "principe", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇸🇦", "name": "saudi_arabia", "keywords": ["flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇸🇳", "name": "senegal", "keywords": ["sn", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇷🇸", "name": "serbia", "keywords": ["rs", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇸🇨", "name": "seychelles", "keywords": ["sc", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇸🇱", "name": "sierra_leone", "keywords": ["sierra", "leone", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇸🇬", "name": "singapore", "keywords": ["sg", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇸🇽", "name": "sint_maarten", "keywords": ["sint", "maarten", "dutch", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇸🇰", "name": "slovakia", "keywords": ["sk", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇸🇮", "name": "slovenia", "keywords": ["si", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇸🇧", "name": "solomon_islands", "keywords": ["solomon", "islands", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇸🇴", "name": "somalia", "keywords": ["so", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇿🇦", "name": "south_africa", "keywords": ["south", "africa", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇬🇸", "name": "south_georgia_south_sandwich_islands", "keywords": ["south", "georgia", "sandwich", "islands", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇰🇷", "name": "kr", "keywords": ["south", "korea", "nation", "flag", "country", "banner"] },
- { "category": "flags", "char": "🇸🇸", "name": "south_sudan", "keywords": ["south", "sd", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇪🇸", "name": "es", "keywords": ["spain", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇱🇰", "name": "sri_lanka", "keywords": ["sri", "lanka", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇸🇩", "name": "sudan", "keywords": ["sd", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇸🇷", "name": "suriname", "keywords": ["sr", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇸🇿", "name": "swaziland", "keywords": ["sz", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇸🇪", "name": "sweden", "keywords": ["se", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇨🇭", "name": "switzerland", "keywords": ["ch", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇸🇾", "name": "syria", "keywords": ["syrian", "arab", "republic", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇹🇼", "name": "taiwan", "keywords": ["tw", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇹🇯", "name": "tajikistan", "keywords": ["tj", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇹🇿", "name": "tanzania", "keywords": ["tanzania, ", "united", "republic", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇹🇭", "name": "thailand", "keywords": ["th", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇹🇱", "name": "timor_leste", "keywords": ["timor", "leste", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇹🇬", "name": "togo", "keywords": ["tg", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇹🇰", "name": "tokelau", "keywords": ["tk", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇹🇴", "name": "tonga", "keywords": ["to", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇹🇹", "name": "trinidad_tobago", "keywords": ["trinidad", "tobago", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇹🇦", "name": "tristan_da_cunha", "keywords": ["flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇹🇳", "name": "tunisia", "keywords": ["tn", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇹🇷", "name": "tr", "keywords": ["turkey", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇹🇲", "name": "turkmenistan", "keywords": ["flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇹🇨", "name": "turks_caicos_islands", "keywords": ["turks", "caicos", "islands", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇹🇻", "name": "tuvalu", "keywords": ["flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇺🇬", "name": "uganda", "keywords": ["ug", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇺🇦", "name": "ukraine", "keywords": ["ua", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇦🇪", "name": "united_arab_emirates", "keywords": ["united", "arab", "emirates", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇬🇧", "name": "uk", "keywords": ["united", "kingdom", "great", "britain", "northern", "ireland", "flag", "nation", "country", "banner", "british", "UK", "english", "england", "union jack"] },
- { "category": "flags", "char": "🏴󠁧󠁢󠁥󠁮󠁧󠁿", "name": "england", "keywords": ["flag", "english"] },
- { "category": "flags", "char": "🏴󠁧󠁢󠁳󠁣󠁴󠁿", "name": "scotland", "keywords": ["flag", "scottish"] },
- { "category": "flags", "char": "🏴󠁧󠁢󠁷󠁬󠁳󠁿", "name": "wales", "keywords": ["flag", "welsh"] },
- { "category": "flags", "char": "🇺🇸", "name": "us", "keywords": ["united", "states", "america", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇻🇮", "name": "us_virgin_islands", "keywords": ["virgin", "islands", "us", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇺🇾", "name": "uruguay", "keywords": ["uy", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇺🇿", "name": "uzbekistan", "keywords": ["uz", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇻🇺", "name": "vanuatu", "keywords": ["vu", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇻🇦", "name": "vatican_city", "keywords": ["vatican", "city", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇻🇪", "name": "venezuela", "keywords": ["ve", "bolivarian", "republic", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇻🇳", "name": "vietnam", "keywords": ["viet", "nam", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇼🇫", "name": "wallis_futuna", "keywords": ["wallis", "futuna", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇪🇭", "name": "western_sahara", "keywords": ["western", "sahara", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇾🇪", "name": "yemen", "keywords": ["ye", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇿🇲", "name": "zambia", "keywords": ["zm", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇿🇼", "name": "zimbabwe", "keywords": ["zw", "flag", "nation", "country", "banner"] },
- { "category": "flags", "char": "🇺🇳", "name": "united_nations", "keywords": ["un", "flag", "banner"] },
- { "category": "flags", "char": "🏴‍☠️", "name": "pirate_flag", "keywords": ["skull", "crossbones", "flag", "banner"] }
+ ["😀", "grinning", 0],
+ ["😬", "grimacing", 0],
+ ["😁", "grin", 0],
+ ["😂", "joy", 0],
+ ["🤣", "rofl", 0],
+ ["🥳", "partying", 0],
+ ["😃", "smiley", 0],
+ ["😄", "smile", 0],
+ ["😅", "sweat_smile", 0],
+ ["🥲", "smiling_face_with_tear", 0],
+ ["😆", "laughing", 0],
+ ["😇", "innocent", 0],
+ ["😉", "wink", 0],
+ ["😊", "blush", 0],
+ ["🙂", "slightly_smiling_face", 0],
+ ["🙃", "upside_down_face", 0],
+ ["☺️", "relaxed", 0],
+ ["😋", "yum", 0],
+ ["😌", "relieved", 0],
+ ["😍", "heart_eyes", 0],
+ ["🥰", "smiling_face_with_three_hearts", 0],
+ ["😘", "kissing_heart", 0],
+ ["😗", "kissing", 0],
+ ["😙", "kissing_smiling_eyes", 0],
+ ["😚", "kissing_closed_eyes", 0],
+ ["😜", "stuck_out_tongue_winking_eye", 0],
+ ["🤪", "zany", 0],
+ ["🤨", "raised_eyebrow", 0],
+ ["🧐", "monocle", 0],
+ ["😝", "stuck_out_tongue_closed_eyes", 0],
+ ["😛", "stuck_out_tongue", 0],
+ ["🤑", "money_mouth_face", 0],
+ ["🤓", "nerd_face", 0],
+ ["🥸", "disguised_face", 0],
+ ["😎", "sunglasses", 0],
+ ["🤩", "star_struck", 0],
+ ["🤡", "clown_face", 0],
+ ["🤠", "cowboy_hat_face", 0],
+ ["🤗", "hugs", 0],
+ ["😏", "smirk", 0],
+ ["😶", "no_mouth", 0],
+ ["😐", "neutral_face", 0],
+ ["😑", "expressionless", 0],
+ ["😒", "unamused", 0],
+ ["🙄", "roll_eyes", 0],
+ ["🤔", "thinking", 0],
+ ["🤥", "lying_face", 0],
+ ["🤭", "hand_over_mouth", 0],
+ ["🤫", "shushing", 0],
+ ["🤬", "symbols_over_mouth", 0],
+ ["🤯", "exploding_head", 0],
+ ["😳", "flushed", 0],
+ ["😞", "disappointed", 0],
+ ["😟", "worried", 0],
+ ["😠", "angry", 0],
+ ["😡", "rage", 0],
+ ["😔", "pensive", 0],
+ ["😕", "confused", 0],
+ ["🙁", "slightly_frowning_face", 0],
+ ["☹", "frowning_face", 0],
+ ["😣", "persevere", 0],
+ ["😖", "confounded", 0],
+ ["😫", "tired_face", 0],
+ ["😩", "weary", 0],
+ ["🥺", "pleading", 0],
+ ["😤", "triumph", 0],
+ ["😮", "open_mouth", 0],
+ ["😱", "scream", 0],
+ ["😨", "fearful", 0],
+ ["😰", "cold_sweat", 0],
+ ["😯", "hushed", 0],
+ ["😦", "frowning", 0],
+ ["😧", "anguished", 0],
+ ["😢", "cry", 0],
+ ["😥", "disappointed_relieved", 0],
+ ["🤤", "drooling_face", 0],
+ ["😪", "sleepy", 0],
+ ["😓", "sweat", 0],
+ ["🥵", "hot", 0],
+ ["🥶", "cold", 0],
+ ["😭", "sob", 0],
+ ["😵", "dizzy_face", 0],
+ ["😲", "astonished", 0],
+ ["🤐", "zipper_mouth_face", 0],
+ ["🤢", "nauseated_face", 0],
+ ["🤧", "sneezing_face", 0],
+ ["🤮", "vomiting", 0],
+ ["😷", "mask", 0],
+ ["🤒", "face_with_thermometer", 0],
+ ["🤕", "face_with_head_bandage", 0],
+ ["🥴", "woozy", 0],
+ ["🥱", "yawning", 0],
+ ["😴", "sleeping", 0],
+ ["💤", "zzz", 0],
+ ["😶‍🌫️", "face_in_clouds", 0],
+ ["😮‍💨", "face_exhaling", 0],
+ ["😵‍💫", "face_with_spiral_eyes", 0],
+ ["🫠", "melting_face", 0],
+ ["🫢", "face_with_open_eyes_and_hand_over_mouth", 0],
+ ["🫣", "face_with_peeking_eye", 0],
+ ["🫡", "saluting_face", 0],
+ ["🫥", "dotted_line_face", 0],
+ ["🫤", "face_with_diagonal_mouth", 0],
+ ["🥹", "face_holding_back_tears", 0],
+ ["💩", "poop", 0],
+ ["😈", "smiling_imp", 0],
+ ["👿", "imp", 0],
+ ["👹", "japanese_ogre", 0],
+ ["👺", "japanese_goblin", 0],
+ ["💀", "skull", 0],
+ ["👻", "ghost", 0],
+ ["👽", "alien", 0],
+ ["🤖", "robot", 0],
+ ["😺", "smiley_cat", 0],
+ ["😸", "smile_cat", 0],
+ ["😹", "joy_cat", 0],
+ ["😻", "heart_eyes_cat", 0],
+ ["😼", "smirk_cat", 0],
+ ["😽", "kissing_cat", 0],
+ ["🙀", "scream_cat", 0],
+ ["😿", "crying_cat_face", 0],
+ ["😾", "pouting_cat", 0],
+ ["🤲", "palms_up", 1],
+ ["🙌", "raised_hands", 1],
+ ["👏", "clap", 1],
+ ["👋", "wave", 1],
+ ["🤙", "call_me_hand", 1],
+ ["👍", "+1", 1],
+ ["👎", "-1", 1],
+ ["👊", "facepunch", 1],
+ ["✊", "fist", 1],
+ ["🤛", "fist_left", 1],
+ ["🤜", "fist_right", 1],
+ ["✌", "v", 1],
+ ["👌", "ok_hand", 1],
+ ["✋", "raised_hand", 1],
+ ["🤚", "raised_back_of_hand", 1],
+ ["👐", "open_hands", 1],
+ ["💪", "muscle", 1],
+ ["🦾", "mechanical_arm", 1],
+ ["🙏", "pray", 1],
+ ["🦶", "foot", 1],
+ ["🦵", "leg", 1],
+ ["🦿", "mechanical_leg", 1],
+ ["🤝", "handshake", 1],
+ ["☝", "point_up", 1],
+ ["👆", "point_up_2", 1],
+ ["👇", "point_down", 1],
+ ["👈", "point_left", 1],
+ ["👉", "point_right", 1],
+ ["🖕", "fu", 1],
+ ["🖐", "raised_hand_with_fingers_splayed", 1],
+ ["🤟", "love_you", 1],
+ ["🤘", "metal", 1],
+ ["🤞", "crossed_fingers", 1],
+ ["🖖", "vulcan_salute", 1],
+ ["✍", "writing_hand", 1],
+ ["🫰", "hand_with_index_finger_and_thumb_crossed", 1],
+ ["🫱", "rightwards_hand", 1],
+ ["🫲", "leftwards_hand", 1],
+ ["🫳", "palm_down_hand", 1],
+ ["🫴", "palm_up_hand", 1],
+ ["🫵", "index_pointing_at_the_viewer", 1],
+ ["🫶", "heart_hands", 1],
+ ["🤏", "pinching_hand", 1],
+ ["🤌", "pinched_fingers", 1],
+ ["🤳", "selfie", 1],
+ ["💅", "nail_care", 1],
+ ["👄", "lips", 1],
+ ["🫦", "biting_lip", 1],
+ ["🦷", "tooth", 1],
+ ["👅", "tongue", 1],
+ ["👂", "ear", 1],
+ ["🦻", "ear_with_hearing_aid", 1],
+ ["👃", "nose", 1],
+ ["👁", "eye", 1],
+ ["👀", "eyes", 1],
+ ["🧠", "brain", 1],
+ ["🫀", "anatomical_heart", 1],
+ ["🫁", "lungs", 1],
+ ["👤", "bust_in_silhouette", 1],
+ ["👥", "busts_in_silhouette", 1],
+ ["🗣", "speaking_head", 1],
+ ["👶", "baby", 1],
+ ["🧒", "child", 1],
+ ["👦", "boy", 1],
+ ["👧", "girl", 1],
+ ["🧑", "adult", 1],
+ ["👨", "man", 1],
+ ["👩", "woman", 1],
+ ["🧑‍🦱", "curly_hair", 1],
+ ["👩‍🦱", "curly_hair_woman", 1],
+ ["👨‍🦱", "curly_hair_man", 1],
+ ["🧑‍🦰", "red_hair", 1],
+ ["👩‍🦰", "red_hair_woman", 1],
+ ["👨‍🦰", "red_hair_man", 1],
+ ["👱‍♀️", "blonde_woman", 1],
+ ["👱", "blonde_man", 1],
+ ["🧑‍🦳", "white_hair", 1],
+ ["👩‍🦳", "white_hair_woman", 1],
+ ["👨‍🦳", "white_hair_man", 1],
+ ["🧑‍🦲", "bald", 1],
+ ["👩‍🦲", "bald_woman", 1],
+ ["👨‍🦲", "bald_man", 1],
+ ["🧔", "bearded_person", 1],
+ ["🧓", "older_adult", 1],
+ ["👴", "older_man", 1],
+ ["👵", "older_woman", 1],
+ ["👲", "man_with_gua_pi_mao", 1],
+ ["🧕", "woman_with_headscarf", 1],
+ ["👳‍♀️", "woman_with_turban", 1],
+ ["👳", "man_with_turban", 1],
+ ["👮‍♀️", "policewoman", 1],
+ ["👮", "policeman", 1],
+ ["👷‍♀️", "construction_worker_woman", 1],
+ ["👷", "construction_worker_man", 1],
+ ["💂‍♀️", "guardswoman", 1],
+ ["💂", "guardsman", 1],
+ ["🕵️‍♀️", "female_detective", 1],
+ ["🕵", "male_detective", 1],
+ ["🧑‍⚕️", "health_worker", 1],
+ ["👩‍⚕️", "woman_health_worker", 1],
+ ["👨‍⚕️", "man_health_worker", 1],
+ ["🧑‍🌾", "farmer", 1],
+ ["👩‍🌾", "woman_farmer", 1],
+ ["👨‍🌾", "man_farmer", 1],
+ ["🧑‍🍳", "cook", 1],
+ ["👩‍🍳", "woman_cook", 1],
+ ["👨‍🍳", "man_cook", 1],
+ ["🧑‍🎓", "student", 1],
+ ["👩‍🎓", "woman_student", 1],
+ ["👨‍🎓", "man_student", 1],
+ ["🧑‍🎤", "singer", 1],
+ ["👩‍🎤", "woman_singer", 1],
+ ["👨‍🎤", "man_singer", 1],
+ ["🧑‍🏫", "teacher", 1],
+ ["👩‍🏫", "woman_teacher", 1],
+ ["👨‍🏫", "man_teacher", 1],
+ ["🧑‍🏭", "factory_worker", 1],
+ ["👩‍🏭", "woman_factory_worker", 1],
+ ["👨‍🏭", "man_factory_worker", 1],
+ ["🧑‍💻", "technologist", 1],
+ ["👩‍💻", "woman_technologist", 1],
+ ["👨‍💻", "man_technologist", 1],
+ ["🧑‍💼", "office_worker", 1],
+ ["👩‍💼", "woman_office_worker", 1],
+ ["👨‍💼", "man_office_worker", 1],
+ ["🧑‍🔧", "mechanic", 1],
+ ["👩‍🔧", "woman_mechanic", 1],
+ ["👨‍🔧", "man_mechanic", 1],
+ ["🧑‍🔬", "scientist", 1],
+ ["👩‍🔬", "woman_scientist", 1],
+ ["👨‍🔬", "man_scientist", 1],
+ ["🧑‍🎨", "artist", 1],
+ ["👩‍🎨", "woman_artist", 1],
+ ["👨‍🎨", "man_artist", 1],
+ ["🧑‍🚒", "firefighter", 1],
+ ["👩‍🚒", "woman_firefighter", 1],
+ ["👨‍🚒", "man_firefighter", 1],
+ ["🧑‍✈️", "pilot", 1],
+ ["👩‍✈️", "woman_pilot", 1],
+ ["👨‍✈️", "man_pilot", 1],
+ ["🧑‍🚀", "astronaut", 1],
+ ["👩‍🚀", "woman_astronaut", 1],
+ ["👨‍🚀", "man_astronaut", 1],
+ ["🧑‍⚖️", "judge", 1],
+ ["👩‍⚖️", "woman_judge", 1],
+ ["👨‍⚖️", "man_judge", 1],
+ ["🦸‍♀️", "woman_superhero", 1],
+ ["🦸‍♂️", "man_superhero", 1],
+ ["🦹‍♀️", "woman_supervillain", 1],
+ ["🦹‍♂️", "man_supervillain", 1],
+ ["🤶", "mrs_claus", 1],
+ ["🧑‍🎄", "mx_claus", 1],
+ ["🎅", "santa", 1],
+ ["🥷", "ninja", 1],
+ ["🧙‍♀️", "sorceress", 1],
+ ["🧙‍♂️", "wizard", 1],
+ ["🧝‍♀️", "woman_elf", 1],
+ ["🧝‍♂️", "man_elf", 1],
+ ["🧛‍♀️", "woman_vampire", 1],
+ ["🧛‍♂️", "man_vampire", 1],
+ ["🧟‍♀️", "woman_zombie", 1],
+ ["🧟‍♂️", "man_zombie", 1],
+ ["🧞‍♀️", "woman_genie", 1],
+ ["🧞‍♂️", "man_genie", 1],
+ ["🧜‍♀️", "mermaid", 1],
+ ["🧜‍♂️", "merman", 1],
+ ["🧚‍♀️", "woman_fairy", 1],
+ ["🧚‍♂️", "man_fairy", 1],
+ ["👼", "angel", 1],
+ ["🧌", "troll", 1],
+ ["🤰", "pregnant_woman", 1],
+ ["🫃", "pregnant_man", 1],
+ ["🫄", "pregnant_person", 1],
+ ["🫅", "person_with_crown", 1],
+ ["🤱", "breastfeeding", 1],
+ ["👩‍🍼", "woman_feeding_baby", 1],
+ ["👨‍🍼", "man_feeding_baby", 1],
+ ["🧑‍🍼", "person_feeding_baby", 1],
+ ["👸", "princess", 1],
+ ["🤴", "prince", 1],
+ ["👰", "person_with_veil", 1],
+ ["👰", "bride_with_veil", 1],
+ ["🤵", "person_in_tuxedo", 1],
+ ["🤵", "man_in_tuxedo", 1],
+ ["🏃‍♀️", "running_woman", 1],
+ ["🏃", "running_man", 1],
+ ["🚶‍♀️", "walking_woman", 1],
+ ["🚶", "walking_man", 1],
+ ["💃", "dancer", 1],
+ ["🕺", "man_dancing", 1],
+ ["👯", "dancing_women", 1],
+ ["👯‍♂️", "dancing_men", 1],
+ ["👫", "couple", 1],
+ ["🧑‍🤝‍🧑", "people_holding_hands", 1],
+ ["👬", "two_men_holding_hands", 1],
+ ["👭", "two_women_holding_hands", 1],
+ ["🫂", "people_hugging", 1],
+ ["🙇‍♀️", "bowing_woman", 1],
+ ["🙇", "bowing_man", 1],
+ ["🤦‍♂️", "man_facepalming", 1],
+ ["🤦‍♀️", "woman_facepalming", 1],
+ ["🤷", "woman_shrugging", 1],
+ ["🤷‍♂️", "man_shrugging", 1],
+ ["💁", "tipping_hand_woman", 1],
+ ["💁‍♂️", "tipping_hand_man", 1],
+ ["🙅", "no_good_woman", 1],
+ ["🙅‍♂️", "no_good_man", 1],
+ ["🙆", "ok_woman", 1],
+ ["🙆‍♂️", "ok_man", 1],
+ ["🙋", "raising_hand_woman", 1],
+ ["🙋‍♂️", "raising_hand_man", 1],
+ ["🙎", "pouting_woman", 1],
+ ["🙎‍♂️", "pouting_man", 1],
+ ["🙍", "frowning_woman", 1],
+ ["🙍‍♂️", "frowning_man", 1],
+ ["💇", "haircut_woman", 1],
+ ["💇‍♂️", "haircut_man", 1],
+ ["💆", "massage_woman", 1],
+ ["💆‍♂️", "massage_man", 1],
+ ["🧖‍♀️", "woman_in_steamy_room", 1],
+ ["🧖‍♂️", "man_in_steamy_room", 1],
+ ["🧏‍♀️", "woman_deaf", 1],
+ ["🧏‍♂️", "man_deaf", 1],
+ ["🧍‍♀️", "woman_standing", 1],
+ ["🧍‍♂️", "man_standing", 1],
+ ["🧎‍♀️", "woman_kneeling", 1],
+ ["🧎‍♂️", "man_kneeling", 1],
+ ["🧑‍🦯", "person_with_probing_cane", 1],
+ ["👩‍🦯", "woman_with_probing_cane", 1],
+ ["👨‍🦯", "man_with_probing_cane", 1],
+ ["🧑‍🦼", "person_in_motorized_wheelchair", 1],
+ ["👩‍🦼", "woman_in_motorized_wheelchair", 1],
+ ["👨‍🦼", "man_in_motorized_wheelchair", 1],
+ ["🧑‍🦽", "person_in_manual_wheelchair", 1],
+ ["👩‍🦽", "woman_in_manual_wheelchair", 1],
+ ["👨‍🦽", "man_in_manual_wheelchair", 1],
+ ["💑", "couple_with_heart_woman_man", 1],
+ ["👩‍❤️‍👩", "couple_with_heart_woman_woman", 1],
+ ["👨‍❤️‍👨", "couple_with_heart_man_man", 1],
+ ["💏", "couplekiss_man_woman", 1],
+ ["👩‍❤️‍💋‍👩", "couplekiss_woman_woman", 1],
+ ["👨‍❤️‍💋‍👨", "couplekiss_man_man", 1],
+ ["👪", "family_man_woman_boy", 1],
+ ["👨‍👩‍👧", "family_man_woman_girl", 1],
+ ["👨‍👩‍👧‍👦", "family_man_woman_girl_boy", 1],
+ ["👨‍👩‍👦‍👦", "family_man_woman_boy_boy", 1],
+ ["👨‍👩‍👧‍👧", "family_man_woman_girl_girl", 1],
+ ["👩‍👩‍👦", "family_woman_woman_boy", 1],
+ ["👩‍👩‍👧", "family_woman_woman_girl", 1],
+ ["👩‍👩‍👧‍👦", "family_woman_woman_girl_boy", 1],
+ ["👩‍👩‍👦‍👦", "family_woman_woman_boy_boy", 1],
+ ["👩‍👩‍👧‍👧", "family_woman_woman_girl_girl", 1],
+ ["👨‍👨‍👦", "family_man_man_boy", 1],
+ ["👨‍👨‍👧", "family_man_man_girl", 1],
+ ["👨‍👨‍👧‍👦", "family_man_man_girl_boy", 1],
+ ["👨‍👨‍👦‍👦", "family_man_man_boy_boy", 1],
+ ["👨‍👨‍👧‍👧", "family_man_man_girl_girl", 1],
+ ["👩‍👦", "family_woman_boy", 1],
+ ["👩‍👧", "family_woman_girl", 1],
+ ["👩‍👧‍👦", "family_woman_girl_boy", 1],
+ ["👩‍👦‍👦", "family_woman_boy_boy", 1],
+ ["👩‍👧‍👧", "family_woman_girl_girl", 1],
+ ["👨‍👦", "family_man_boy", 1],
+ ["👨‍👧", "family_man_girl", 1],
+ ["👨‍👧‍👦", "family_man_girl_boy", 1],
+ ["👨‍👦‍👦", "family_man_boy_boy", 1],
+ ["👨‍👧‍👧", "family_man_girl_girl", 1],
+ ["🧶", "yarn", 1],
+ ["🧵", "thread", 1],
+ ["🧥", "coat", 1],
+ ["🥼", "labcoat", 1],
+ ["👚", "womans_clothes", 1],
+ ["👕", "tshirt", 1],
+ ["👖", "jeans", 1],
+ ["👔", "necktie", 1],
+ ["👗", "dress", 1],
+ ["👙", "bikini", 1],
+ ["🩱", "one_piece_swimsuit", 1],
+ ["👘", "kimono", 1],
+ ["🥻", "sari", 1],
+ ["🩲", "briefs", 1],
+ ["🩳", "shorts", 1],
+ ["💄", "lipstick", 1],
+ ["💋", "kiss", 1],
+ ["👣", "footprints", 1],
+ ["🥿", "flat_shoe", 1],
+ ["👠", "high_heel", 1],
+ ["👡", "sandal", 1],
+ ["👢", "boot", 1],
+ ["👞", "mans_shoe", 1],
+ ["👟", "athletic_shoe", 1],
+ ["🩴", "thong_sandal", 1],
+ ["🩰", "ballet_shoes", 1],
+ ["🧦", "socks", 1],
+ ["🧤", "gloves", 1],
+ ["🧣", "scarf", 1],
+ ["👒", "womans_hat", 1],
+ ["🎩", "tophat", 1],
+ ["🧢", "billed_hat", 1],
+ ["⛑", "rescue_worker_helmet", 1],
+ ["🪖", "military_helmet", 1],
+ ["🎓", "mortar_board", 1],
+ ["👑", "crown", 1],
+ ["🎒", "school_satchel", 1],
+ ["🧳", "luggage", 1],
+ ["👝", "pouch", 1],
+ ["👛", "purse", 1],
+ ["👜", "handbag", 1],
+ ["💼", "briefcase", 1],
+ ["👓", "eyeglasses", 1],
+ ["🕶", "dark_sunglasses", 1],
+ ["🥽", "goggles", 1],
+ ["💍", "ring", 1],
+ ["🌂", "closed_umbrella", 1],
+ ["🐶", "dog", 2],
+ ["🐱", "cat", 2],
+ ["🐈‍⬛", "black_cat", 2],
+ ["🐭", "mouse", 2],
+ ["🐹", "hamster", 2],
+ ["🐰", "rabbit", 2],
+ ["🦊", "fox_face", 2],
+ ["🐻", "bear", 2],
+ ["🐼", "panda_face", 2],
+ ["🐨", "koala", 2],
+ ["🐯", "tiger", 2],
+ ["🦁", "lion", 2],
+ ["🐮", "cow", 2],
+ ["🐷", "pig", 2],
+ ["🐽", "pig_nose", 2],
+ ["🐸", "frog", 2],
+ ["🦑", "squid", 2],
+ ["🐙", "octopus", 2],
+ ["🦐", "shrimp", 2],
+ ["🐵", "monkey_face", 2],
+ ["🦍", "gorilla", 2],
+ ["🙈", "see_no_evil", 2],
+ ["🙉", "hear_no_evil", 2],
+ ["🙊", "speak_no_evil", 2],
+ ["🐒", "monkey", 2],
+ ["🐔", "chicken", 2],
+ ["🐧", "penguin", 2],
+ ["🐦", "bird", 2],
+ ["🐤", "baby_chick", 2],
+ ["🐣", "hatching_chick", 2],
+ ["🐥", "hatched_chick", 2],
+ ["🦆", "duck", 2],
+ ["🦅", "eagle", 2],
+ ["🦉", "owl", 2],
+ ["🦇", "bat", 2],
+ ["🐺", "wolf", 2],
+ ["🐗", "boar", 2],
+ ["🐴", "horse", 2],
+ ["🦄", "unicorn", 2],
+ ["🐝", "honeybee", 2],
+ ["🐛", "bug", 2],
+ ["🦋", "butterfly", 2],
+ ["🐌", "snail", 2],
+ ["🐞", "lady_beetle", 2],
+ ["🐜", "ant", 2],
+ ["🦗", "grasshopper", 2],
+ ["🕷", "spider", 2],
+ ["🪲", "beetle", 2],
+ ["🪳", "cockroach", 2],
+ ["🪰", "fly", 2],
+ ["🪱", "worm", 2],
+ ["🦂", "scorpion", 2],
+ ["🦀", "crab", 2],
+ ["🐍", "snake", 2],
+ ["🦎", "lizard", 2],
+ ["🦖", "t-rex", 2],
+ ["🦕", "sauropod", 2],
+ ["🐢", "turtle", 2],
+ ["🐠", "tropical_fish", 2],
+ ["🐟", "fish", 2],
+ ["🐡", "blowfish", 2],
+ ["🐬", "dolphin", 2],
+ ["🦈", "shark", 2],
+ ["🐳", "whale", 2],
+ ["🐋", "whale2", 2],
+ ["🐊", "crocodile", 2],
+ ["🐆", "leopard", 2],
+ ["🦓", "zebra", 2],
+ ["🐅", "tiger2", 2],
+ ["🐃", "water_buffalo", 2],
+ ["🐂", "ox", 2],
+ ["🐄", "cow2", 2],
+ ["🦌", "deer", 2],
+ ["🐪", "dromedary_camel", 2],
+ ["🐫", "camel", 2],
+ ["🦒", "giraffe", 2],
+ ["🐘", "elephant", 2],
+ ["🦏", "rhinoceros", 2],
+ ["🐐", "goat", 2],
+ ["🐏", "ram", 2],
+ ["🐑", "sheep", 2],
+ ["🐎", "racehorse", 2],
+ ["🐖", "pig2", 2],
+ ["🐀", "rat", 2],
+ ["🐁", "mouse2", 2],
+ ["🐓", "rooster", 2],
+ ["🦃", "turkey", 2],
+ ["🕊", "dove", 2],
+ ["🐕", "dog2", 2],
+ ["🐩", "poodle", 2],
+ ["🐈", "cat2", 2],
+ ["🐇", "rabbit2", 2],
+ ["🐿", "chipmunk", 2],
+ ["🦔", "hedgehog", 2],
+ ["🦝", "raccoon", 2],
+ ["🦙", "llama", 2],
+ ["🦛", "hippopotamus", 2],
+ ["🦘", "kangaroo", 2],
+ ["🦡", "badger", 2],
+ ["🦢", "swan", 2],
+ ["🦚", "peacock", 2],
+ ["🦜", "parrot", 2],
+ ["🦞", "lobster", 2],
+ ["🦠", "microbe", 2],
+ ["🦟", "mosquito", 2],
+ ["🦬", "bison", 2],
+ ["🦣", "mammoth", 2],
+ ["🦫", "beaver", 2],
+ ["🐻‍❄️", "polar_bear", 2],
+ ["🦤", "dodo", 2],
+ ["🪶", "feather", 2],
+ ["🦭", "seal", 2],
+ ["🐾", "paw_prints", 2],
+ ["🐉", "dragon", 2],
+ ["🐲", "dragon_face", 2],
+ ["🦧", "orangutan", 2],
+ ["🦮", "guide_dog", 2],
+ ["🐕‍🦺", "service_dog", 2],
+ ["🦥", "sloth", 2],
+ ["🦦", "otter", 2],
+ ["🦨", "skunk", 2],
+ ["🦩", "flamingo", 2],
+ ["🌵", "cactus", 2],
+ ["🎄", "christmas_tree", 2],
+ ["🌲", "evergreen_tree", 2],
+ ["🌳", "deciduous_tree", 2],
+ ["🌴", "palm_tree", 2],
+ ["🌱", "seedling", 2],
+ ["🌿", "herb", 2],
+ ["☘", "shamrock", 2],
+ ["🍀", "four_leaf_clover", 2],
+ ["🎍", "bamboo", 2],
+ ["🎋", "tanabata_tree", 2],
+ ["🍃", "leaves", 2],
+ ["🍂", "fallen_leaf", 2],
+ ["🍁", "maple_leaf", 2],
+ ["🌾", "ear_of_rice", 2],
+ ["🌺", "hibiscus", 2],
+ ["🌻", "sunflower", 2],
+ ["🌹", "rose", 2],
+ ["🥀", "wilted_flower", 2],
+ ["🌷", "tulip", 2],
+ ["🌼", "blossom", 2],
+ ["🌸", "cherry_blossom", 2],
+ ["💐", "bouquet", 2],
+ ["🍄", "mushroom", 2],
+ ["🪴", "potted_plant", 2],
+ ["🌰", "chestnut", 2],
+ ["🎃", "jack_o_lantern", 2],
+ ["🐚", "shell", 2],
+ ["🕸", "spider_web", 2],
+ ["🌎", "earth_americas", 2],
+ ["🌍", "earth_africa", 2],
+ ["🌏", "earth_asia", 2],
+ ["🪐", "ringed_planet", 2],
+ ["🌕", "full_moon", 2],
+ ["🌖", "waning_gibbous_moon", 2],
+ ["🌗", "last_quarter_moon", 2],
+ ["🌘", "waning_crescent_moon", 2],
+ ["🌑", "new_moon", 2],
+ ["🌒", "waxing_crescent_moon", 2],
+ ["🌓", "first_quarter_moon", 2],
+ ["🌔", "waxing_gibbous_moon", 2],
+ ["🌚", "new_moon_with_face", 2],
+ ["🌝", "full_moon_with_face", 2],
+ ["🌛", "first_quarter_moon_with_face", 2],
+ ["🌜", "last_quarter_moon_with_face", 2],
+ ["🌞", "sun_with_face", 2],
+ ["🌙", "crescent_moon", 2],
+ ["⭐", "star", 2],
+ ["🌟", "star2", 2],
+ ["💫", "dizzy", 2],
+ ["✨", "sparkles", 2],
+ ["☄", "comet", 2],
+ ["☀️", "sunny", 2],
+ ["🌤", "sun_behind_small_cloud", 2],
+ ["⛅", "partly_sunny", 2],
+ ["🌥", "sun_behind_large_cloud", 2],
+ ["🌦", "sun_behind_rain_cloud", 2],
+ ["☁️", "cloud", 2],
+ ["🌧", "cloud_with_rain", 2],
+ ["⛈", "cloud_with_lightning_and_rain", 2],
+ ["🌩", "cloud_with_lightning", 2],
+ ["⚡", "zap", 2],
+ ["🔥", "fire", 2],
+ ["💥", "boom", 2],
+ ["❄️", "snowflake", 2],
+ ["🌨", "cloud_with_snow", 2],
+ ["⛄", "snowman", 2],
+ ["☃", "snowman_with_snow", 2],
+ ["🌬", "wind_face", 2],
+ ["💨", "dash", 2],
+ ["🌪", "tornado", 2],
+ ["🌫", "fog", 2],
+ ["☂", "open_umbrella", 2],
+ ["☔", "umbrella", 2],
+ ["💧", "droplet", 2],
+ ["💦", "sweat_drops", 2],
+ ["🌊", "ocean", 2],
+ ["🪷", "lotus", 2],
+ ["🪸", "coral", 2],
+ ["🪹", "empty_nest", 2],
+ ["🪺", "nest_with_eggs", 2],
+ ["🍏", "green_apple", 3],
+ ["🍎", "apple", 3],
+ ["🍐", "pear", 3],
+ ["🍊", "tangerine", 3],
+ ["🍋", "lemon", 3],
+ ["🍌", "banana", 3],
+ ["🍉", "watermelon", 3],
+ ["🍇", "grapes", 3],
+ ["🍓", "strawberry", 3],
+ ["🍈", "melon", 3],
+ ["🍒", "cherries", 3],
+ ["🍑", "peach", 3],
+ ["🍍", "pineapple", 3],
+ ["🥥", "coconut", 3],
+ ["🥝", "kiwi_fruit", 3],
+ ["🥭", "mango", 3],
+ ["🥑", "avocado", 3],
+ ["🥦", "broccoli", 3],
+ ["🍅", "tomato", 3],
+ ["🍆", "eggplant", 3],
+ ["🥒", "cucumber", 3],
+ ["🫐", "blueberries", 3],
+ ["🫒", "olive", 3],
+ ["🫑", "bell_pepper", 3],
+ ["🥕", "carrot", 3],
+ ["🌶", "hot_pepper", 3],
+ ["🥔", "potato", 3],
+ ["🌽", "corn", 3],
+ ["🥬", "leafy_greens", 3],
+ ["🍠", "sweet_potato", 3],
+ ["🥜", "peanuts", 3],
+ ["🧄", "garlic", 3],
+ ["🧅", "onion", 3],
+ ["🍯", "honey_pot", 3],
+ ["🥐", "croissant", 3],
+ ["🍞", "bread", 3],
+ ["🥖", "baguette_bread", 3],
+ ["🥯", "bagel", 3],
+ ["🥨", "pretzel", 3],
+ ["🧀", "cheese", 3],
+ ["🥚", "egg", 3],
+ ["🥓", "bacon", 3],
+ ["🥩", "steak", 3],
+ ["🥞", "pancakes", 3],
+ ["🍗", "poultry_leg", 3],
+ ["🍖", "meat_on_bone", 3],
+ ["🦴", "bone", 3],
+ ["🍤", "fried_shrimp", 3],
+ ["🍳", "fried_egg", 3],
+ ["🍔", "hamburger", 3],
+ ["🍟", "fries", 3],
+ ["🥙", "stuffed_flatbread", 3],
+ ["🌭", "hotdog", 3],
+ ["🍕", "pizza", 3],
+ ["🥪", "sandwich", 3],
+ ["🥫", "canned_food", 3],
+ ["🍝", "spaghetti", 3],
+ ["🌮", "taco", 3],
+ ["🌯", "burrito", 3],
+ ["🥗", "green_salad", 3],
+ ["🥘", "shallow_pan_of_food", 3],
+ ["🍜", "ramen", 3],
+ ["🍲", "stew", 3],
+ ["🍥", "fish_cake", 3],
+ ["🥠", "fortune_cookie", 3],
+ ["🍣", "sushi", 3],
+ ["🍱", "bento", 3],
+ ["🍛", "curry", 3],
+ ["🍙", "rice_ball", 3],
+ ["🍚", "rice", 3],
+ ["🍘", "rice_cracker", 3],
+ ["🍢", "oden", 3],
+ ["🍡", "dango", 3],
+ ["🍧", "shaved_ice", 3],
+ ["🍨", "ice_cream", 3],
+ ["🍦", "icecream", 3],
+ ["🥧", "pie", 3],
+ ["🍰", "cake", 3],
+ ["🧁", "cupcake", 3],
+ ["🥮", "moon_cake", 3],
+ ["🎂", "birthday", 3],
+ ["🍮", "custard", 3],
+ ["🍬", "candy", 3],
+ ["🍭", "lollipop", 3],
+ ["🍫", "chocolate_bar", 3],
+ ["🍿", "popcorn", 3],
+ ["🥟", "dumpling", 3],
+ ["🍩", "doughnut", 3],
+ ["🍪", "cookie", 3],
+ ["🧇", "waffle", 3],
+ ["🧆", "falafel", 3],
+ ["🧈", "butter", 3],
+ ["🦪", "oyster", 3],
+ ["🫓", "flatbread", 3],
+ ["🫔", "tamale", 3],
+ ["🫕", "fondue", 3],
+ ["🥛", "milk_glass", 3],
+ ["🍺", "beer", 3],
+ ["🍻", "beers", 3],
+ ["🥂", "clinking_glasses", 3],
+ ["🍷", "wine_glass", 3],
+ ["🥃", "tumbler_glass", 3],
+ ["🍸", "cocktail", 3],
+ ["🍹", "tropical_drink", 3],
+ ["🍾", "champagne", 3],
+ ["🍶", "sake", 3],
+ ["🍵", "tea", 3],
+ ["🥤", "cup_with_straw", 3],
+ ["☕", "coffee", 3],
+ ["🫖", "teapot", 3],
+ ["🧋", "bubble_tea", 3],
+ ["🍼", "baby_bottle", 3],
+ ["🧃", "beverage_box", 3],
+ ["🧉", "mate", 3],
+ ["🧊", "ice_cube", 3],
+ ["🧂", "salt", 3],
+ ["🥄", "spoon", 3],
+ ["🍴", "fork_and_knife", 3],
+ ["🍽", "plate_with_cutlery", 3],
+ ["🥣", "bowl_with_spoon", 3],
+ ["🥡", "takeout_box", 3],
+ ["🥢", "chopsticks", 3],
+ ["🫗", "pouring_liquid", 3],
+ ["🫘", "beans", 3],
+ ["🫙", "jar", 3],
+ ["⚽", "soccer", 4],
+ ["🏀", "basketball", 4],
+ ["🏈", "football", 4],
+ ["⚾", "baseball", 4],
+ ["🥎", "softball", 4],
+ ["🎾", "tennis", 4],
+ ["🏐", "volleyball", 4],
+ ["🏉", "rugby_football", 4],
+ ["🥏", "flying_disc", 4],
+ ["🎱", "8ball", 4],
+ ["⛳", "golf", 4],
+ ["🏌️‍♀️", "golfing_woman", 4],
+ ["🏌", "golfing_man", 4],
+ ["🏓", "ping_pong", 4],
+ ["🏸", "badminton", 4],
+ ["🥅", "goal_net", 4],
+ ["🏒", "ice_hockey", 4],
+ ["🏑", "field_hockey", 4],
+ ["🥍", "lacrosse", 4],
+ ["🏏", "cricket", 4],
+ ["🎿", "ski", 4],
+ ["⛷", "skier", 4],
+ ["🏂", "snowboarder", 4],
+ ["🤺", "person_fencing", 4],
+ ["🤼‍♀️", "women_wrestling", 4],
+ ["🤼‍♂️", "men_wrestling", 4],
+ ["🤸‍♀️", "woman_cartwheeling", 4],
+ ["🤸‍♂️", "man_cartwheeling", 4],
+ ["🤾‍♀️", "woman_playing_handball", 4],
+ ["🤾‍♂️", "man_playing_handball", 4],
+ ["⛸", "ice_skate", 4],
+ ["🥌", "curling_stone", 4],
+ ["🛹", "skateboard", 4],
+ ["🛷", "sled", 4],
+ ["🏹", "bow_and_arrow", 4],
+ ["🎣", "fishing_pole_and_fish", 4],
+ ["🥊", "boxing_glove", 4],
+ ["🥋", "martial_arts_uniform", 4],
+ ["🚣‍♀️", "rowing_woman", 4],
+ ["🚣", "rowing_man", 4],
+ ["🧗‍♀️", "climbing_woman", 4],
+ ["🧗‍♂️", "climbing_man", 4],
+ ["🏊‍♀️", "swimming_woman", 4],
+ ["🏊", "swimming_man", 4],
+ ["🤽‍♀️", "woman_playing_water_polo", 4],
+ ["🤽‍♂️", "man_playing_water_polo", 4],
+ ["🧘‍♀️", "woman_in_lotus_position", 4],
+ ["🧘‍♂️", "man_in_lotus_position", 4],
+ ["🏄‍♀️", "surfing_woman", 4],
+ ["🏄", "surfing_man", 4],
+ ["🛀", "bath", 4],
+ ["⛹️‍♀️", "basketball_woman", 4],
+ ["⛹", "basketball_man", 4],
+ ["🏋️‍♀️", "weight_lifting_woman", 4],
+ ["🏋", "weight_lifting_man", 4],
+ ["🚴‍♀️", "biking_woman", 4],
+ ["🚴", "biking_man", 4],
+ ["🚵‍♀️", "mountain_biking_woman", 4],
+ ["🚵", "mountain_biking_man", 4],
+ ["🏇", "horse_racing", 4],
+ ["🤿", "diving_mask", 4],
+ ["🪀", "yo_yo", 4],
+ ["🪁", "kite", 4],
+ ["🦺", "safety_vest", 4],
+ ["🪡", "sewing_needle", 4],
+ ["🪢", "knot", 4],
+ ["🕴", "business_suit_levitating", 4],
+ ["🏆", "trophy", 4],
+ ["🎽", "running_shirt_with_sash", 4],
+ ["🏅", "medal_sports", 4],
+ ["🎖", "medal_military", 4],
+ ["🥇", "1st_place_medal", 4],
+ ["🥈", "2nd_place_medal", 4],
+ ["🥉", "3rd_place_medal", 4],
+ ["🎗", "reminder_ribbon", 4],
+ ["🏵", "rosette", 4],
+ ["🎫", "ticket", 4],
+ ["🎟", "tickets", 4],
+ ["🎭", "performing_arts", 4],
+ ["🎨", "art", 4],
+ ["🎪", "circus_tent", 4],
+ ["🤹‍♀️", "woman_juggling", 4],
+ ["🤹‍♂️", "man_juggling", 4],
+ ["🎤", "microphone", 4],
+ ["🎧", "headphones", 4],
+ ["🎼", "musical_score", 4],
+ ["🎹", "musical_keyboard", 4],
+ ["🥁", "drum", 4],
+ ["🎷", "saxophone", 4],
+ ["🎺", "trumpet", 4],
+ ["🎸", "guitar", 4],
+ ["🎻", "violin", 4],
+ ["🪕", "banjo", 4],
+ ["🪗", "accordion", 4],
+ ["🪘", "long_drum", 4],
+ ["🎬", "clapper", 4],
+ ["🎮", "video_game", 4],
+ ["👾", "space_invader", 4],
+ ["🎯", "dart", 4],
+ ["🎲", "game_die", 4],
+ ["♟️", "chess_pawn", 4],
+ ["🎰", "slot_machine", 4],
+ ["🧩", "jigsaw", 4],
+ ["🎳", "bowling", 4],
+ ["🪄", "magic_wand", 4],
+ ["🪅", "pinata", 4],
+ ["🪆", "nesting_dolls", 4],
+ ["🪬", "hamsa", 4],
+ ["🪩", "mirror_ball", 4],
+ ["🚗", "red_car", 5],
+ ["🚕", "taxi", 5],
+ ["🚙", "blue_car", 5],
+ ["🚌", "bus", 5],
+ ["🚎", "trolleybus", 5],
+ ["🏎", "racing_car", 5],
+ ["🚓", "police_car", 5],
+ ["🚑", "ambulance", 5],
+ ["🚒", "fire_engine", 5],
+ ["🚐", "minibus", 5],
+ ["🚚", "truck", 5],
+ ["🚛", "articulated_lorry", 5],
+ ["🚜", "tractor", 5],
+ ["🛴", "kick_scooter", 5],
+ ["🏍", "motorcycle", 5],
+ ["🚲", "bike", 5],
+ ["🛵", "motor_scooter", 5],
+ ["🦽", "manual_wheelchair", 5],
+ ["🦼", "motorized_wheelchair", 5],
+ ["🛺", "auto_rickshaw", 5],
+ ["🪂", "parachute", 5],
+ ["🚨", "rotating_light", 5],
+ ["🚔", "oncoming_police_car", 5],
+ ["🚍", "oncoming_bus", 5],
+ ["🚘", "oncoming_automobile", 5],
+ ["🚖", "oncoming_taxi", 5],
+ ["🚡", "aerial_tramway", 5],
+ ["🚠", "mountain_cableway", 5],
+ ["🚟", "suspension_railway", 5],
+ ["🚃", "railway_car", 5],
+ ["🚋", "train", 5],
+ ["🚝", "monorail", 5],
+ ["🚄", "bullettrain_side", 5],
+ ["🚅", "bullettrain_front", 5],
+ ["🚈", "light_rail", 5],
+ ["🚞", "mountain_railway", 5],
+ ["🚂", "steam_locomotive", 5],
+ ["🚆", "train2", 5],
+ ["🚇", "metro", 5],
+ ["🚊", "tram", 5],
+ ["🚉", "station", 5],
+ ["🛸", "flying_saucer", 5],
+ ["🚁", "helicopter", 5],
+ ["🛩", "small_airplane", 5],
+ ["✈️", "airplane", 5],
+ ["🛫", "flight_departure", 5],
+ ["🛬", "flight_arrival", 5],
+ ["⛵", "sailboat", 5],
+ ["🛥", "motor_boat", 5],
+ ["🚤", "speedboat", 5],
+ ["⛴", "ferry", 5],
+ ["🛳", "passenger_ship", 5],
+ ["🚀", "rocket", 5],
+ ["🛰", "artificial_satellite", 5],
+ ["🛻", "pickup_truck", 5],
+ ["🛼", "roller_skate", 5],
+ ["💺", "seat", 5],
+ ["🛶", "canoe", 5],
+ ["⚓", "anchor", 5],
+ ["🚧", "construction", 5],
+ ["⛽", "fuelpump", 5],
+ ["🚏", "busstop", 5],
+ ["🚦", "vertical_traffic_light", 5],
+ ["🚥", "traffic_light", 5],
+ ["🏁", "checkered_flag", 5],
+ ["🚢", "ship", 5],
+ ["🎡", "ferris_wheel", 5],
+ ["🎢", "roller_coaster", 5],
+ ["🎠", "carousel_horse", 5],
+ ["🏗", "building_construction", 5],
+ ["🌁", "foggy", 5],
+ ["🏭", "factory", 5],
+ ["⛲", "fountain", 5],
+ ["🎑", "rice_scene", 5],
+ ["⛰", "mountain", 5],
+ ["🏔", "mountain_snow", 5],
+ ["🗻", "mount_fuji", 5],
+ ["🌋", "volcano", 5],
+ ["🗾", "japan", 5],
+ ["🏕", "camping", 5],
+ ["⛺", "tent", 5],
+ ["🏞", "national_park", 5],
+ ["🛣", "motorway", 5],
+ ["🛤", "railway_track", 5],
+ ["🌅", "sunrise", 5],
+ ["🌄", "sunrise_over_mountains", 5],
+ ["🏜", "desert", 5],
+ ["🏖", "beach_umbrella", 5],
+ ["🏝", "desert_island", 5],
+ ["🌇", "city_sunrise", 5],
+ ["🌆", "city_sunset", 5],
+ ["🏙", "cityscape", 5],
+ ["🌃", "night_with_stars", 5],
+ ["🌉", "bridge_at_night", 5],
+ ["🌌", "milky_way", 5],
+ ["🌠", "stars", 5],
+ ["🎇", "sparkler", 5],
+ ["🎆", "fireworks", 5],
+ ["🌈", "rainbow", 5],
+ ["🏘", "houses", 5],
+ ["🏰", "european_castle", 5],
+ ["🏯", "japanese_castle", 5],
+ ["🗼", "tokyo_tower", 5],
+ ["", "shibuya_109", 5],
+ ["🏟", "stadium", 5],
+ ["🗽", "statue_of_liberty", 5],
+ ["🏠", "house", 5],
+ ["🏡", "house_with_garden", 5],
+ ["🏚", "derelict_house", 5],
+ ["🏢", "office", 5],
+ ["🏬", "department_store", 5],
+ ["🏣", "post_office", 5],
+ ["🏤", "european_post_office", 5],
+ ["🏥", "hospital", 5],
+ ["🏦", "bank", 5],
+ ["🏨", "hotel", 5],
+ ["🏪", "convenience_store", 5],
+ ["🏫", "school", 5],
+ ["🏩", "love_hotel", 5],
+ ["💒", "wedding", 5],
+ ["🏛", "classical_building", 5],
+ ["⛪", "church", 5],
+ ["🕌", "mosque", 5],
+ ["🕍", "synagogue", 5],
+ ["🕋", "kaaba", 5],
+ ["⛩", "shinto_shrine", 5],
+ ["🛕", "hindu_temple", 5],
+ ["🪨", "rock", 5],
+ ["🪵", "wood", 5],
+ ["🛖", "hut", 5],
+ ["🛝", "playground_slide", 5],
+ ["🛞", "wheel", 5],
+ ["🛟", "ring_buoy", 5],
+ ["⌚", "watch", 6],
+ ["📱", "iphone", 6],
+ ["📲", "calling", 6],
+ ["💻", "computer", 6],
+ ["⌨", "keyboard", 6],
+ ["🖥", "desktop_computer", 6],
+ ["🖨", "printer", 6],
+ ["🖱", "computer_mouse", 6],
+ ["🖲", "trackball", 6],
+ ["🕹", "joystick", 6],
+ ["🗜", "clamp", 6],
+ ["💽", "minidisc", 6],
+ ["💾", "floppy_disk", 6],
+ ["💿", "cd", 6],
+ ["📀", "dvd", 6],
+ ["📼", "vhs", 6],
+ ["📷", "camera", 6],
+ ["📸", "camera_flash", 6],
+ ["📹", "video_camera", 6],
+ ["🎥", "movie_camera", 6],
+ ["📽", "film_projector", 6],
+ ["🎞", "film_strip", 6],
+ ["📞", "telephone_receiver", 6],
+ ["☎️", "phone", 6],
+ ["📟", "pager", 6],
+ ["📠", "fax", 6],
+ ["📺", "tv", 6],
+ ["📻", "radio", 6],
+ ["🎙", "studio_microphone", 6],
+ ["🎚", "level_slider", 6],
+ ["🎛", "control_knobs", 6],
+ ["🧭", "compass", 6],
+ ["⏱", "stopwatch", 6],
+ ["⏲", "timer_clock", 6],
+ ["⏰", "alarm_clock", 6],
+ ["🕰", "mantelpiece_clock", 6],
+ ["⏳", "hourglass_flowing_sand", 6],
+ ["⌛", "hourglass", 6],
+ ["📡", "satellite", 6],
+ ["🔋", "battery", 6],
+ ["🪫", "battery", 6],
+ ["🔌", "electric_plug", 6],
+ ["💡", "bulb", 6],
+ ["🔦", "flashlight", 6],
+ ["🕯", "candle", 6],
+ ["🧯", "fire_extinguisher", 6],
+ ["🗑", "wastebasket", 6],
+ ["🛢", "oil_drum", 6],
+ ["💸", "money_with_wings", 6],
+ ["💵", "dollar", 6],
+ ["💴", "yen", 6],
+ ["💶", "euro", 6],
+ ["💷", "pound", 6],
+ ["💰", "moneybag", 6],
+ ["🪙", "coin", 6],
+ ["💳", "credit_card", 6],
+ ["🪫", "identification_card", 6],
+ ["💎", "gem", 6],
+ ["⚖", "balance_scale", 6],
+ ["🧰", "toolbox", 6],
+ ["🔧", "wrench", 6],
+ ["🔨", "hammer", 6],
+ ["⚒", "hammer_and_pick", 6],
+ ["🛠", "hammer_and_wrench", 6],
+ ["⛏", "pick", 6],
+ ["🪓", "axe", 6],
+ ["🦯", "probing_cane", 6],
+ ["🔩", "nut_and_bolt", 6],
+ ["⚙", "gear", 6],
+ ["🪃", "boomerang", 6],
+ ["🪚", "carpentry_saw", 6],
+ ["🪛", "screwdriver", 6],
+ ["🪝", "hook", 6],
+ ["🪜", "ladder", 6],
+ ["🧱", "brick", 6],
+ ["⛓", "chains", 6],
+ ["🧲", "magnet", 6],
+ ["🔫", "gun", 6],
+ ["💣", "bomb", 6],
+ ["🧨", "firecracker", 6],
+ ["🔪", "hocho", 6],
+ ["🗡", "dagger", 6],
+ ["⚔", "crossed_swords", 6],
+ ["🛡", "shield", 6],
+ ["🚬", "smoking", 6],
+ ["☠", "skull_and_crossbones", 6],
+ ["⚰", "coffin", 6],
+ ["⚱", "funeral_urn", 6],
+ ["🏺", "amphora", 6],
+ ["🔮", "crystal_ball", 6],
+ ["📿", "prayer_beads", 6],
+ ["🧿", "nazar_amulet", 6],
+ ["💈", "barber", 6],
+ ["⚗", "alembic", 6],
+ ["🔭", "telescope", 6],
+ ["🔬", "microscope", 6],
+ ["🕳", "hole", 6],
+ ["💊", "pill", 6],
+ ["💉", "syringe", 6],
+ ["🩸", "drop_of_blood", 6],
+ ["🩹", "adhesive_bandage", 6],
+ ["🩺", "stethoscope", 6],
+ ["🪒", "razor", 6],
+ ["🩻", "xray", 6],
+ ["🩼", "crutch", 6],
+ ["🧬", "dna", 6],
+ ["🧫", "petri_dish", 6],
+ ["🧪", "test_tube", 6],
+ ["🌡", "thermometer", 6],
+ ["🧹", "broom", 6],
+ ["🧺", "basket", 6],
+ ["🧻", "toilet_paper", 6],
+ ["🏷", "label", 6],
+ ["🔖", "bookmark", 6],
+ ["🚽", "toilet", 6],
+ ["🚿", "shower", 6],
+ ["🛁", "bathtub", 6],
+ ["🧼", "soap", 6],
+ ["🧽", "sponge", 6],
+ ["🧴", "lotion_bottle", 6],
+ ["🔑", "key", 6],
+ ["🗝", "old_key", 6],
+ ["🛋", "couch_and_lamp", 6],
+ ["🪔", "diya_Lamp", 6],
+ ["🛌", "sleeping_bed", 6],
+ ["🛏", "bed", 6],
+ ["🚪", "door", 6],
+ ["🪑", "chair", 6],
+ ["🛎", "bellhop_bell", 6],
+ ["🧸", "teddy_bear", 6],
+ ["🖼", "framed_picture", 6],
+ ["🗺", "world_map", 6],
+ ["🛗", "elevator", 6],
+ ["🪞", "mirror", 6],
+ ["🪟", "window", 6],
+ ["🪠", "plunger", 6],
+ ["🪤", "mouse_trap", 6],
+ ["🪣", "bucket", 6],
+ ["🪥", "toothbrush", 6],
+ ["🫧", "bubbles", 6],
+ ["⛱", "parasol_on_ground", 6],
+ ["🗿", "moyai", 6],
+ ["🛍", "shopping", 6],
+ ["🛒", "shopping_cart", 6],
+ ["🎈", "balloon", 6],
+ ["🎏", "flags", 6],
+ ["🎀", "ribbon", 6],
+ ["🎁", "gift", 6],
+ ["🎊", "confetti_ball", 6],
+ ["🎉", "tada", 6],
+ ["🎎", "dolls", 6],
+ ["🎐", "wind_chime", 6],
+ ["🎌", "crossed_flags", 6],
+ ["🏮", "izakaya_lantern", 6],
+ ["🧧", "red_envelope", 6],
+ ["✉️", "email", 6],
+ ["📩", "envelope_with_arrow", 6],
+ ["📨", "incoming_envelope", 6],
+ ["📧", "e-mail", 6],
+ ["💌", "love_letter", 6],
+ ["📮", "postbox", 6],
+ ["📪", "mailbox_closed", 6],
+ ["📫", "mailbox", 6],
+ ["📬", "mailbox_with_mail", 6],
+ ["📭", "mailbox_with_no_mail", 6],
+ ["📦", "package", 6],
+ ["📯", "postal_horn", 6],
+ ["📥", "inbox_tray", 6],
+ ["📤", "outbox_tray", 6],
+ ["📜", "scroll", 6],
+ ["📃", "page_with_curl", 6],
+ ["📑", "bookmark_tabs", 6],
+ ["🧾", "receipt", 6],
+ ["📊", "bar_chart", 6],
+ ["📈", "chart_with_upwards_trend", 6],
+ ["📉", "chart_with_downwards_trend", 6],
+ ["📄", "page_facing_up", 6],
+ ["📅", "date", 6],
+ ["📆", "calendar", 6],
+ ["🗓", "spiral_calendar", 6],
+ ["📇", "card_index", 6],
+ ["🗃", "card_file_box", 6],
+ ["🗳", "ballot_box", 6],
+ ["🗄", "file_cabinet", 6],
+ ["📋", "clipboard", 6],
+ ["🗒", "spiral_notepad", 6],
+ ["📁", "file_folder", 6],
+ ["📂", "open_file_folder", 6],
+ ["🗂", "card_index_dividers", 6],
+ ["🗞", "newspaper_roll", 6],
+ ["📰", "newspaper", 6],
+ ["📓", "notebook", 6],
+ ["📕", "closed_book", 6],
+ ["📗", "green_book", 6],
+ ["📘", "blue_book", 6],
+ ["📙", "orange_book", 6],
+ ["📔", "notebook_with_decorative_cover", 6],
+ ["📒", "ledger", 6],
+ ["📚", "books", 6],
+ ["📖", "open_book", 6],
+ ["🧷", "safety_pin", 6],
+ ["🔗", "link", 6],
+ ["📎", "paperclip", 6],
+ ["🖇", "paperclips", 6],
+ ["✂️", "scissors", 6],
+ ["📐", "triangular_ruler", 6],
+ ["📏", "straight_ruler", 6],
+ ["🧮", "abacus", 6],
+ ["📌", "pushpin", 6],
+ ["📍", "round_pushpin", 6],
+ ["🚩", "triangular_flag_on_post", 6],
+ ["🏳", "white_flag", 6],
+ ["🏴", "black_flag", 6],
+ ["🏳️‍🌈", "rainbow_flag", 6],
+ ["🏳️‍⚧️", "transgender_flag", 6],
+ ["🔐", "closed_lock_with_key", 6],
+ ["🔒", "lock", 6],
+ ["🔓", "unlock", 6],
+ ["🔏", "lock_with_ink_pen", 6],
+ ["🖊", "pen", 6],
+ ["🖋", "fountain_pen", 6],
+ ["✒️", "black_nib", 6],
+ ["📝", "memo", 6],
+ ["✏️", "pencil2", 6],
+ ["🖍", "crayon", 6],
+ ["🖌", "paintbrush", 6],
+ ["🔍", "mag", 6],
+ ["🔎", "mag_right", 6],
+ ["🪦", "headstone", 6],
+ ["🪧", "placard", 6],
+ ["💯", "100", 7],
+ ["🔢", "1234", 7],
+ ["❤️", "heart", 7],
+ ["🧡", "orange_heart", 7],
+ ["💛", "yellow_heart", 7],
+ ["💚", "green_heart", 7],
+ ["💙", "blue_heart", 7],
+ ["💜", "purple_heart", 7],
+ ["🤎", "brown_heart", 7],
+ ["🖤", "black_heart", 7],
+ ["🤍", "white_heart", 7],
+ ["💔", "broken_heart", 7],
+ ["❣", "heavy_heart_exclamation", 7],
+ ["💕", "two_hearts", 7],
+ ["💞", "revolving_hearts", 7],
+ ["💓", "heartbeat", 7],
+ ["💗", "heartpulse", 7],
+ ["💖", "sparkling_heart", 7],
+ ["💘", "cupid", 7],
+ ["💝", "gift_heart", 7],
+ ["💟", "heart_decoration", 7],
+ ["❤️‍🔥", "heart_on_fire", 7],
+ ["❤️‍🩹", "mending_heart", 7],
+ ["☮", "peace_symbol", 7],
+ ["✝", "latin_cross", 7],
+ ["☪", "star_and_crescent", 7],
+ ["🕉", "om", 7],
+ ["☸", "wheel_of_dharma", 7],
+ ["✡", "star_of_david", 7],
+ ["🔯", "six_pointed_star", 7],
+ ["🕎", "menorah", 7],
+ ["☯", "yin_yang", 7],
+ ["☦", "orthodox_cross", 7],
+ ["🛐", "place_of_worship", 7],
+ ["⛎", "ophiuchus", 7],
+ ["♈", "aries", 7],
+ ["♉", "taurus", 7],
+ ["♊", "gemini", 7],
+ ["♋", "cancer", 7],
+ ["♌", "leo", 7],
+ ["♍", "virgo", 7],
+ ["♎", "libra", 7],
+ ["♏", "scorpius", 7],
+ ["♐", "sagittarius", 7],
+ ["♑", "capricorn", 7],
+ ["♒", "aquarius", 7],
+ ["♓", "pisces", 7],
+ ["🆔", "id", 7],
+ ["⚛", "atom_symbol", 7],
+ ["⚧️", "transgender_symbol", 7],
+ ["🈳", "u7a7a", 7],
+ ["🈹", "u5272", 7],
+ ["☢", "radioactive", 7],
+ ["☣", "biohazard", 7],
+ ["📴", "mobile_phone_off", 7],
+ ["📳", "vibration_mode", 7],
+ ["🈶", "u6709", 7],
+ ["🈚", "u7121", 7],
+ ["🈸", "u7533", 7],
+ ["🈺", "u55b6", 7],
+ ["🈷️", "u6708", 7],
+ ["✴️", "eight_pointed_black_star", 7],
+ ["🆚", "vs", 7],
+ ["🉑", "accept", 7],
+ ["💮", "white_flower", 7],
+ ["🉐", "ideograph_advantage", 7],
+ ["㊙️", "secret", 7],
+ ["㊗️", "congratulations", 7],
+ ["🈴", "u5408", 7],
+ ["🈵", "u6e80", 7],
+ ["🈲", "u7981", 7],
+ ["🅰️", "a", 7],
+ ["🅱️", "b", 7],
+ ["🆎", "ab", 7],
+ ["🆑", "cl", 7],
+ ["🅾️", "o2", 7],
+ ["🆘", "sos", 7],
+ ["⛔", "no_entry", 7],
+ ["📛", "name_badge", 7],
+ ["🚫", "no_entry_sign", 7],
+ ["❌", "x", 7],
+ ["⭕", "o", 7],
+ ["🛑", "stop_sign", 7],
+ ["💢", "anger", 7],
+ ["♨️", "hotsprings", 7],
+ ["🚷", "no_pedestrians", 7],
+ ["🚯", "do_not_litter", 7],
+ ["🚳", "no_bicycles", 7],
+ ["🚱", "non-potable_water", 7],
+ ["🔞", "underage", 7],
+ ["📵", "no_mobile_phones", 7],
+ ["❗", "exclamation", 7],
+ ["❕", "grey_exclamation", 7],
+ ["❓", "question", 7],
+ ["❔", "grey_question", 7],
+ ["‼️", "bangbang", 7],
+ ["⁉️", "interrobang", 7],
+ ["🔅", "low_brightness", 7],
+ ["🔆", "high_brightness", 7],
+ ["🔱", "trident", 7],
+ ["⚜", "fleur_de_lis", 7],
+ ["〽️", "part_alternation_mark", 7],
+ ["⚠️", "warning", 7],
+ ["🚸", "children_crossing", 7],
+ ["🔰", "beginner", 7],
+ ["♻️", "recycle", 7],
+ ["🈯", "u6307", 7],
+ ["💹", "chart", 7],
+ ["❇️", "sparkle", 7],
+ ["✳️", "eight_spoked_asterisk", 7],
+ ["❎", "negative_squared_cross_mark", 7],
+ ["✅", "white_check_mark", 7],
+ ["💠", "diamond_shape_with_a_dot_inside", 7],
+ ["🌀", "cyclone", 7],
+ ["➿", "loop", 7],
+ ["🌐", "globe_with_meridians", 7],
+ ["Ⓜ️", "m", 7],
+ ["🏧", "atm", 7],
+ ["🈂️", "sa", 7],
+ ["🛂", "passport_control", 7],
+ ["🛃", "customs", 7],
+ ["🛄", "baggage_claim", 7],
+ ["🛅", "left_luggage", 7],
+ ["♿", "wheelchair", 7],
+ ["🚭", "no_smoking", 7],
+ ["🚾", "wc", 7],
+ ["🅿️", "parking", 7],
+ ["🚰", "potable_water", 7],
+ ["🚹", "mens", 7],
+ ["🚺", "womens", 7],
+ ["🚼", "baby_symbol", 7],
+ ["🚻", "restroom", 7],
+ ["🚮", "put_litter_in_its_place", 7],
+ ["🎦", "cinema", 7],
+ ["📶", "signal_strength", 7],
+ ["🈁", "koko", 7],
+ ["🆖", "ng", 7],
+ ["🆗", "ok", 7],
+ ["🆙", "up", 7],
+ ["🆒", "cool", 7],
+ ["🆕", "new", 7],
+ ["🆓", "free", 7],
+ ["0️⃣", "zero", 7],
+ ["1️⃣", "one", 7],
+ ["2️⃣", "two", 7],
+ ["3️⃣", "three", 7],
+ ["4️⃣", "four", 7],
+ ["5️⃣", "five", 7],
+ ["6️⃣", "six", 7],
+ ["7️⃣", "seven", 7],
+ ["8️⃣", "eight", 7],
+ ["9️⃣", "nine", 7],
+ ["🔟", "keycap_ten", 7],
+ ["*⃣", "asterisk", 7],
+ ["⏏️", "eject_button", 7],
+ ["▶️", "arrow_forward", 7],
+ ["⏸", "pause_button", 7],
+ ["⏭", "next_track_button", 7],
+ ["⏹", "stop_button", 7],
+ ["⏺", "record_button", 7],
+ ["⏯", "play_or_pause_button", 7],
+ ["⏮", "previous_track_button", 7],
+ ["⏩", "fast_forward", 7],
+ ["⏪", "rewind", 7],
+ ["🔀", "twisted_rightwards_arrows", 7],
+ ["🔁", "repeat", 7],
+ ["🔂", "repeat_one", 7],
+ ["◀️", "arrow_backward", 7],
+ ["🔼", "arrow_up_small", 7],
+ ["🔽", "arrow_down_small", 7],
+ ["⏫", "arrow_double_up", 7],
+ ["⏬", "arrow_double_down", 7],
+ ["➡️", "arrow_right", 7],
+ ["⬅️", "arrow_left", 7],
+ ["⬆️", "arrow_up", 7],
+ ["⬇️", "arrow_down", 7],
+ ["↗️", "arrow_upper_right", 7],
+ ["↘️", "arrow_lower_right", 7],
+ ["↙️", "arrow_lower_left", 7],
+ ["↖️", "arrow_upper_left", 7],
+ ["↕️", "arrow_up_down", 7],
+ ["↔️", "left_right_arrow", 7],
+ ["🔄", "arrows_counterclockwise", 7],
+ ["↪️", "arrow_right_hook", 7],
+ ["↩️", "leftwards_arrow_with_hook", 7],
+ ["⤴️", "arrow_heading_up", 7],
+ ["⤵️", "arrow_heading_down", 7],
+ ["#️⃣", "hash", 7],
+ ["ℹ️", "information_source", 7],
+ ["🔤", "abc", 7],
+ ["🔡", "abcd", 7],
+ ["🔠", "capital_abcd", 7],
+ ["🔣", "symbols", 7],
+ ["🎵", "musical_note", 7],
+ ["🎶", "notes", 7],
+ ["〰️", "wavy_dash", 7],
+ ["➰", "curly_loop", 7],
+ ["✔️", "heavy_check_mark", 7],
+ ["🔃", "arrows_clockwise", 7],
+ ["➕", "heavy_plus_sign", 7],
+ ["➖", "heavy_minus_sign", 7],
+ ["➗", "heavy_division_sign", 7],
+ ["✖️", "heavy_multiplication_x", 7],
+ ["🟰", "heavy_equals_sign", 7],
+ ["♾", "infinity", 7],
+ ["💲", "heavy_dollar_sign", 7],
+ ["💱", "currency_exchange", 7],
+ ["©️", "copyright", 7],
+ ["®️", "registered", 7],
+ ["™️", "tm", 7],
+ ["🔚", "end", 7],
+ ["🔙", "back", 7],
+ ["🔛", "on", 7],
+ ["🔝", "top", 7],
+ ["🔜", "soon", 7],
+ ["☑️", "ballot_box_with_check", 7],
+ ["🔘", "radio_button", 7],
+ ["⚫", "black_circle", 7],
+ ["⚪", "white_circle", 7],
+ ["🔴", "red_circle", 7],
+ ["🟠", "orange_circle", 7],
+ ["🟡", "yellow_circle", 7],
+ ["🟢", "green_circle", 7],
+ ["🔵", "large_blue_circle", 7],
+ ["🟣", "purple_circle", 7],
+ ["🟤", "brown_circle", 7],
+ ["🔸", "small_orange_diamond", 7],
+ ["🔹", "small_blue_diamond", 7],
+ ["🔶", "large_orange_diamond", 7],
+ ["🔷", "large_blue_diamond", 7],
+ ["🔺", "small_red_triangle", 7],
+ ["▪️", "black_small_square", 7],
+ ["▫️", "white_small_square", 7],
+ ["⬛", "black_large_square", 7],
+ ["⬜", "white_large_square", 7],
+ ["🟥", "red_square", 7],
+ ["🟧", "orange_square", 7],
+ ["🟨", "yellow_square", 7],
+ ["🟩", "green_square", 7],
+ ["🟦", "blue_square", 7],
+ ["🟪", "purple_square", 7],
+ ["🟫", "brown_square", 7],
+ ["🔻", "small_red_triangle_down", 7],
+ ["◼️", "black_medium_square", 7],
+ ["◻️", "white_medium_square", 7],
+ ["◾", "black_medium_small_square", 7],
+ ["◽", "white_medium_small_square", 7],
+ ["🔲", "black_square_button", 7],
+ ["🔳", "white_square_button", 7],
+ ["🔈", "speaker", 7],
+ ["🔉", "sound", 7],
+ ["🔊", "loud_sound", 7],
+ ["🔇", "mute", 7],
+ ["📣", "mega", 7],
+ ["📢", "loudspeaker", 7],
+ ["🔔", "bell", 7],
+ ["🔕", "no_bell", 7],
+ ["🃏", "black_joker", 7],
+ ["🀄", "mahjong", 7],
+ ["♠️", "spades", 7],
+ ["♣️", "clubs", 7],
+ ["♥️", "hearts", 7],
+ ["♦️", "diamonds", 7],
+ ["🎴", "flower_playing_cards", 7],
+ ["💭", "thought_balloon", 7],
+ ["🗯", "right_anger_bubble", 7],
+ ["💬", "speech_balloon", 7],
+ ["🗨", "left_speech_bubble", 7],
+ ["🕐", "clock1", 7],
+ ["🕑", "clock2", 7],
+ ["🕒", "clock3", 7],
+ ["🕓", "clock4", 7],
+ ["🕔", "clock5", 7],
+ ["🕕", "clock6", 7],
+ ["🕖", "clock7", 7],
+ ["🕗", "clock8", 7],
+ ["🕘", "clock9", 7],
+ ["🕙", "clock10", 7],
+ ["🕚", "clock11", 7],
+ ["🕛", "clock12", 7],
+ ["🕜", "clock130", 7],
+ ["🕝", "clock230", 7],
+ ["🕞", "clock330", 7],
+ ["🕟", "clock430", 7],
+ ["🕠", "clock530", 7],
+ ["🕡", "clock630", 7],
+ ["🕢", "clock730", 7],
+ ["🕣", "clock830", 7],
+ ["🕤", "clock930", 7],
+ ["🕥", "clock1030", 7],
+ ["🕦", "clock1130", 7],
+ ["🕧", "clock1230", 7],
+ ["🇦🇫", "afghanistan", 8],
+ ["🇦🇽", "aland_islands", 8],
+ ["🇦🇱", "albania", 8],
+ ["🇩🇿", "algeria", 8],
+ ["🇦🇸", "american_samoa", 8],
+ ["🇦🇩", "andorra", 8],
+ ["🇦🇴", "angola", 8],
+ ["🇦🇮", "anguilla", 8],
+ ["🇦🇶", "antarctica", 8],
+ ["🇦🇬", "antigua_barbuda", 8],
+ ["🇦🇷", "argentina", 8],
+ ["🇦🇲", "armenia", 8],
+ ["🇦🇼", "aruba", 8],
+ ["🇦🇨", "ascension_island", 8],
+ ["🇦🇺", "australia", 8],
+ ["🇦🇹", "austria", 8],
+ ["🇦🇿", "azerbaijan", 8],
+ ["🇧🇸", "bahamas", 8],
+ ["🇧🇭", "bahrain", 8],
+ ["🇧🇩", "bangladesh", 8],
+ ["🇧🇧", "barbados", 8],
+ ["🇧🇾", "belarus", 8],
+ ["🇧🇪", "belgium", 8],
+ ["🇧🇿", "belize", 8],
+ ["🇧🇯", "benin", 8],
+ ["🇧🇲", "bermuda", 8],
+ ["🇧🇹", "bhutan", 8],
+ ["🇧🇴", "bolivia", 8],
+ ["🇧🇶", "caribbean_netherlands", 8],
+ ["🇧🇦", "bosnia_herzegovina", 8],
+ ["🇧🇼", "botswana", 8],
+ ["🇧🇷", "brazil", 8],
+ ["🇮🇴", "british_indian_ocean_territory", 8],
+ ["🇻🇬", "british_virgin_islands", 8],
+ ["🇧🇳", "brunei", 8],
+ ["🇧🇬", "bulgaria", 8],
+ ["🇧🇫", "burkina_faso", 8],
+ ["🇧🇮", "burundi", 8],
+ ["🇨🇻", "cape_verde", 8],
+ ["🇰🇭", "cambodia", 8],
+ ["🇨🇲", "cameroon", 8],
+ ["🇨🇦", "canada", 8],
+ ["🇮🇨", "canary_islands", 8],
+ ["🇰🇾", "cayman_islands", 8],
+ ["🇨🇫", "central_african_republic", 8],
+ ["🇹🇩", "chad", 8],
+ ["🇨🇱", "chile", 8],
+ ["🇨🇳", "cn", 8],
+ ["🇨🇽", "christmas_island", 8],
+ ["🇨🇨", "cocos_islands", 8],
+ ["🇨🇴", "colombia", 8],
+ ["🇰🇲", "comoros", 8],
+ ["🇨🇬", "congo_brazzaville", 8],
+ ["🇨🇩", "congo_kinshasa", 8],
+ ["🇨🇰", "cook_islands", 8],
+ ["🇨🇷", "costa_rica", 8],
+ ["🇭🇷", "croatia", 8],
+ ["🇨🇺", "cuba", 8],
+ ["🇨🇼", "curacao", 8],
+ ["🇨🇾", "cyprus", 8],
+ ["🇨🇿", "czech_republic", 8],
+ ["🇩🇰", "denmark", 8],
+ ["🇩🇯", "djibouti", 8],
+ ["🇩🇲", "dominica", 8],
+ ["🇩🇴", "dominican_republic", 8],
+ ["🇪🇨", "ecuador", 8],
+ ["🇪🇬", "egypt", 8],
+ ["🇸🇻", "el_salvador", 8],
+ ["🇬🇶", "equatorial_guinea", 8],
+ ["🇪🇷", "eritrea", 8],
+ ["🇪🇪", "estonia", 8],
+ ["🇪🇹", "ethiopia", 8],
+ ["🇪🇺", "eu", 8],
+ ["🇫🇰", "falkland_islands", 8],
+ ["🇫🇴", "faroe_islands", 8],
+ ["🇫🇯", "fiji", 8],
+ ["🇫🇮", "finland", 8],
+ ["🇫🇷", "fr", 8],
+ ["🇬🇫", "french_guiana", 8],
+ ["🇵🇫", "french_polynesia", 8],
+ ["🇹🇫", "french_southern_territories", 8],
+ ["🇬🇦", "gabon", 8],
+ ["🇬🇲", "gambia", 8],
+ ["🇬🇪", "georgia", 8],
+ ["🇩🇪", "de", 8],
+ ["🇬🇭", "ghana", 8],
+ ["🇬🇮", "gibraltar", 8],
+ ["🇬🇷", "greece", 8],
+ ["🇬🇱", "greenland", 8],
+ ["🇬🇩", "grenada", 8],
+ ["🇬🇵", "guadeloupe", 8],
+ ["🇬🇺", "guam", 8],
+ ["🇬🇹", "guatemala", 8],
+ ["🇬🇬", "guernsey", 8],
+ ["🇬🇳", "guinea", 8],
+ ["🇬🇼", "guinea_bissau", 8],
+ ["🇬🇾", "guyana", 8],
+ ["🇭🇹", "haiti", 8],
+ ["🇭🇳", "honduras", 8],
+ ["🇭🇰", "hong_kong", 8],
+ ["🇭🇺", "hungary", 8],
+ ["🇮🇸", "iceland", 8],
+ ["🇮🇳", "india", 8],
+ ["🇮🇩", "indonesia", 8],
+ ["🇮🇷", "iran", 8],
+ ["🇮🇶", "iraq", 8],
+ ["🇮🇪", "ireland", 8],
+ ["🇮🇲", "isle_of_man", 8],
+ ["🇮🇱", "israel", 8],
+ ["🇮🇹", "it", 8],
+ ["🇨🇮", "cote_divoire", 8],
+ ["🇯🇲", "jamaica", 8],
+ ["🇯🇵", "jp", 8],
+ ["🇯🇪", "jersey", 8],
+ ["🇯🇴", "jordan", 8],
+ ["🇰🇿", "kazakhstan", 8],
+ ["🇰🇪", "kenya", 8],
+ ["🇰🇮", "kiribati", 8],
+ ["🇽🇰", "kosovo", 8],
+ ["🇰🇼", "kuwait", 8],
+ ["🇰🇬", "kyrgyzstan", 8],
+ ["🇱🇦", "laos", 8],
+ ["🇱🇻", "latvia", 8],
+ ["🇱🇧", "lebanon", 8],
+ ["🇱🇸", "lesotho", 8],
+ ["🇱🇷", "liberia", 8],
+ ["🇱🇾", "libya", 8],
+ ["🇱🇮", "liechtenstein", 8],
+ ["🇱🇹", "lithuania", 8],
+ ["🇱🇺", "luxembourg", 8],
+ ["🇲🇴", "macau", 8],
+ ["🇲🇰", "macedonia", 8],
+ ["🇲🇬", "madagascar", 8],
+ ["🇲🇼", "malawi", 8],
+ ["🇲🇾", "malaysia", 8],
+ ["🇲🇻", "maldives", 8],
+ ["🇲🇱", "mali", 8],
+ ["🇲🇹", "malta", 8],
+ ["🇲🇭", "marshall_islands", 8],
+ ["🇲🇶", "martinique", 8],
+ ["🇲🇷", "mauritania", 8],
+ ["🇲🇺", "mauritius", 8],
+ ["🇾🇹", "mayotte", 8],
+ ["🇲🇽", "mexico", 8],
+ ["🇫🇲", "micronesia", 8],
+ ["🇲🇩", "moldova", 8],
+ ["🇲🇨", "monaco", 8],
+ ["🇲🇳", "mongolia", 8],
+ ["🇲🇪", "montenegro", 8],
+ ["🇲🇸", "montserrat", 8],
+ ["🇲🇦", "morocco", 8],
+ ["🇲🇿", "mozambique", 8],
+ ["🇲🇲", "myanmar", 8],
+ ["🇳🇦", "namibia", 8],
+ ["🇳🇷", "nauru", 8],
+ ["🇳🇵", "nepal", 8],
+ ["🇳🇱", "netherlands", 8],
+ ["🇳🇨", "new_caledonia", 8],
+ ["🇳🇿", "new_zealand", 8],
+ ["🇳🇮", "nicaragua", 8],
+ ["🇳🇪", "niger", 8],
+ ["🇳🇬", "nigeria", 8],
+ ["🇳🇺", "niue", 8],
+ ["🇳🇫", "norfolk_island", 8],
+ ["🇲🇵", "northern_mariana_islands", 8],
+ ["🇰🇵", "north_korea", 8],
+ ["🇳🇴", "norway", 8],
+ ["🇴🇲", "oman", 8],
+ ["🇵🇰", "pakistan", 8],
+ ["🇵🇼", "palau", 8],
+ ["🇵🇸", "palestinian_territories", 8],
+ ["🇵🇦", "panama", 8],
+ ["🇵🇬", "papua_new_guinea", 8],
+ ["🇵🇾", "paraguay", 8],
+ ["🇵🇪", "peru", 8],
+ ["🇵🇭", "philippines", 8],
+ ["🇵🇳", "pitcairn_islands", 8],
+ ["🇵🇱", "poland", 8],
+ ["🇵🇹", "portugal", 8],
+ ["🇵🇷", "puerto_rico", 8],
+ ["🇶🇦", "qatar", 8],
+ ["🇷🇪", "reunion", 8],
+ ["🇷🇴", "romania", 8],
+ ["🇷🇺", "ru", 8],
+ ["🇷🇼", "rwanda", 8],
+ ["🇧🇱", "st_barthelemy", 8],
+ ["🇸🇭", "st_helena", 8],
+ ["🇰🇳", "st_kitts_nevis", 8],
+ ["🇱🇨", "st_lucia", 8],
+ ["🇵🇲", "st_pierre_miquelon", 8],
+ ["🇻🇨", "st_vincent_grenadines", 8],
+ ["🇼🇸", "samoa", 8],
+ ["🇸🇲", "san_marino", 8],
+ ["🇸🇹", "sao_tome_principe", 8],
+ ["🇸🇦", "saudi_arabia", 8],
+ ["🇸🇳", "senegal", 8],
+ ["🇷🇸", "serbia", 8],
+ ["🇸🇨", "seychelles", 8],
+ ["🇸🇱", "sierra_leone", 8],
+ ["🇸🇬", "singapore", 8],
+ ["🇸🇽", "sint_maarten", 8],
+ ["🇸🇰", "slovakia", 8],
+ ["🇸🇮", "slovenia", 8],
+ ["🇸🇧", "solomon_islands", 8],
+ ["🇸🇴", "somalia", 8],
+ ["🇿🇦", "south_africa", 8],
+ ["🇬🇸", "south_georgia_south_sandwich_islands", 8],
+ ["🇰🇷", "kr", 8],
+ ["🇸🇸", "south_sudan", 8],
+ ["🇪🇸", "es", 8],
+ ["🇱🇰", "sri_lanka", 8],
+ ["🇸🇩", "sudan", 8],
+ ["🇸🇷", "suriname", 8],
+ ["🇸🇿", "swaziland", 8],
+ ["🇸🇪", "sweden", 8],
+ ["🇨🇭", "switzerland", 8],
+ ["🇸🇾", "syria", 8],
+ ["🇹🇼", "taiwan", 8],
+ ["🇹🇯", "tajikistan", 8],
+ ["🇹🇿", "tanzania", 8],
+ ["🇹🇭", "thailand", 8],
+ ["🇹🇱", "timor_leste", 8],
+ ["🇹🇬", "togo", 8],
+ ["🇹🇰", "tokelau", 8],
+ ["🇹🇴", "tonga", 8],
+ ["🇹🇹", "trinidad_tobago", 8],
+ ["🇹🇦", "tristan_da_cunha", 8],
+ ["🇹🇳", "tunisia", 8],
+ ["🇹🇷", "tr", 8],
+ ["🇹🇲", "turkmenistan", 8],
+ ["🇹🇨", "turks_caicos_islands", 8],
+ ["🇹🇻", "tuvalu", 8],
+ ["🇺🇬", "uganda", 8],
+ ["🇺🇦", "ukraine", 8],
+ ["🇦🇪", "united_arab_emirates", 8],
+ ["🇬🇧", "uk", 8],
+ ["🏴󠁧󠁢󠁥󠁮󠁧󠁿", "england", 8],
+ ["🏴󠁧󠁢󠁳󠁣󠁴󠁿", "scotland", 8],
+ ["🏴󠁧󠁢󠁷󠁬󠁳󠁿", "wales", 8],
+ ["🇺🇸", "us", 8],
+ ["🇻🇮", "us_virgin_islands", 8],
+ ["🇺🇾", "uruguay", 8],
+ ["🇺🇿", "uzbekistan", 8],
+ ["🇻🇺", "vanuatu", 8],
+ ["🇻🇦", "vatican_city", 8],
+ ["🇻🇪", "venezuela", 8],
+ ["🇻🇳", "vietnam", 8],
+ ["🇼🇫", "wallis_futuna", 8],
+ ["🇪🇭", "western_sahara", 8],
+ ["🇾🇪", "yemen", 8],
+ ["🇿🇲", "zambia", 8],
+ ["🇿🇼", "zimbabwe", 8],
+ ["🇺🇳", "united_nations", 8],
+ ["🏴‍☠️", "pirate_flag", 8]
]
-
diff --git a/packages/frontend/src/i18n.ts b/packages/frontend/src/i18n.ts
index 220c6210c0..30771ec1b3 100644
--- a/packages/frontend/src/i18n.ts
+++ b/packages/frontend/src/i18n.ts
@@ -1,8 +1,9 @@
import { markRaw } from 'vue';
+import type { Locale } from '../../../locales';
import { locale } from '@/config';
import { I18n } from '@/scripts/i18n';
-export const i18n = markRaw(new I18n(locale));
+export const i18n = markRaw(new I18n<Locale>(locale));
export function updateI18n(newLocale) {
i18n.ts = newLocale;
diff --git a/packages/frontend/src/init.ts b/packages/frontend/src/init.ts
deleted file mode 100644
index 49e7bb4008..0000000000
--- a/packages/frontend/src/init.ts
+++ /dev/null
@@ -1,527 +0,0 @@
-/**
- * Client entry point
- */
-// https://vitejs.dev/config/build-options.html#build-modulepreload
-import 'vite/modulepreload-polyfill';
-
-import '@/style.scss';
-
-import { computed, createApp, watch, markRaw, version as vueVersion, defineAsyncComponent } from 'vue';
-import { compareVersions } from 'compare-versions';
-import JSON5 from 'json5';
-
-import widgets from '@/widgets';
-import directives from '@/directives';
-import components from '@/components';
-import { version, ui, lang, updateLocale } from '@/config';
-import { applyTheme } from '@/scripts/theme';
-import { isDeviceDarkmode } from '@/scripts/is-device-darkmode';
-import { i18n, updateI18n } from '@/i18n';
-import { confirm, alert, post, popup, toast } from '@/os';
-import { stream } from '@/stream';
-import * as sound from '@/scripts/sound';
-import { $i, refreshAccount, login, updateAccount, signout } from '@/account';
-import { defaultStore, ColdDeviceStorage } from '@/store';
-import { fetchInstance, instance } from '@/instance';
-import { makeHotkey } from '@/scripts/hotkey';
-import { deviceKind } from '@/scripts/device-kind';
-import { initializeSw } from '@/scripts/initialize-sw';
-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';
-
-console.info(`Misskey v${version}`);
-
-if (_DEV_) {
- console.warn('Development mode!!!');
-
- console.info(`vue ${vueVersion}`);
-
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- (window as any).$i = $i;
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- (window as any).$store = defaultStore;
-
- window.addEventListener('error', event => {
- console.error(event);
- /*
- alert({
- type: 'error',
- title: 'DEV: Unhandled error',
- text: event.message
- });
- */
- });
-
- window.addEventListener('unhandledrejection', event => {
- console.error(event);
- /*
- alert({
- type: 'error',
- title: 'DEV: Unhandled promise rejection',
- text: event.reason
- });
- */
- });
-}
-
-//#region Detect language & fetch translations
-const localeVersion = miLocalStorage.getItem('localeVersion');
-const localeOutdated = (localeVersion == null || localeVersion !== version);
-if (localeOutdated) {
- const res = await window.fetch(`/assets/locales/${lang}.${version}.json`);
- if (res.status === 200) {
- const newLocale = await res.text();
- const parsedNewLocale = JSON.parse(newLocale);
- miLocalStorage.setItem('locale', newLocale);
- miLocalStorage.setItem('localeVersion', version);
- updateLocale(parsedNewLocale);
- updateI18n(parsedNewLocale);
- }
-}
-//#endregion
-
-// タッチデバイスでCSSの:hoverを機能させる
-document.addEventListener('touchend', () => {}, { passive: true });
-
-// 一斉リロード
-reloadChannel.addEventListener('message', path => {
- if (path !== null) location.href = path;
- else location.reload();
-});
-
-// If mobile, insert the viewport meta tag
-if (['smartphone', 'tablet'].includes(deviceKind)) {
- const viewport = document.getElementsByName('viewport').item(0);
- viewport.setAttribute('content',
- `${viewport.getAttribute('content')}, minimum-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover`);
-}
-
-//#region Set lang attr
-const html = document.documentElement;
-html.setAttribute('lang', lang);
-//#endregion
-
-//#region loginId
-const params = new URLSearchParams(location.search);
-const loginId = params.get('loginId');
-
-if (loginId) {
- const target = getUrlWithoutLoginId(location.href);
-
- if (!$i || $i.id !== loginId) {
- const account = await getAccountFromId(loginId);
- if (account) {
- await login(account.token, target);
- }
- }
-
- history.replaceState({ misskey: 'loginId' }, '', target);
-}
-
-//#endregion
-
-//#region Fetch user
-if ($i && $i.token) {
- if (_DEV_) {
- console.log('account cache found. refreshing...');
- }
-
- refreshAccount();
-} else {
- if (_DEV_) {
- console.log('no account cache found.');
- }
-
- // 連携ログインの場合用にCookieを参照する
- const i = (document.cookie.match(/igi=(\w+)/) ?? [null, null])[1];
-
- if (i != null && i !== 'null') {
- if (_DEV_) {
- console.log('signing...');
- }
-
- try {
- document.body.innerHTML = '<div>Please wait...</div>';
- await login(i);
- } catch (err) {
- // Render the error screen
- // TODO: ちゃんとしたコンポーネントをレンダリングする(v10とかのトラブルシューティングゲーム付きのやつみたいな)
- document.body.innerHTML = '<div id="err">Oops!</div>';
- }
- } else {
- if (_DEV_) {
- console.log('not signed in');
- }
- }
-}
-//#endregion
-
-const fetchInstanceMetaPromise = fetchInstance();
-
-fetchInstanceMetaPromise.then(() => {
- miLocalStorage.setItem('v', instance.version);
-
- // Init service worker
- initializeSw();
-});
-
-try {
- await fetchCustomEmojis();
-} catch (err) { /* empty */ }
-
-const app = createApp(
- new URLSearchParams(window.location.search).has('zen') ? defineAsyncComponent(() => import('@/ui/zen.vue')) :
- !$i ? defineAsyncComponent(() => import('@/ui/visitor.vue')) :
- ui === 'deck' ? defineAsyncComponent(() => import('@/ui/deck.vue')) :
- ui === 'classic' ? defineAsyncComponent(() => import('@/ui/classic.vue')) :
- defineAsyncComponent(() => import('@/ui/universal.vue')),
-);
-
-if (_DEV_) {
- app.config.performance = true;
-}
-
-widgets(app);
-directives(app);
-components(app);
-
-const splash = document.getElementById('splash');
-// 念のためnullチェック(HTMLが古い場合があるため(そのうち消す))
-if (splash) splash.addEventListener('transitionend', () => {
- splash.remove();
-});
-
-await deckStore.ready;
-
-// https://github.com/misskey-dev/misskey/pull/8575#issuecomment-1114239210
-// なぜかinit.tsの内容が2回実行されることがあるため、mountするdivを1つに制限する
-const rootEl = ((): HTMLElement => {
- const MISSKEY_MOUNT_DIV_ID = 'misskey_app';
-
- const currentRoot = document.getElementById(MISSKEY_MOUNT_DIV_ID);
-
- if (currentRoot) {
- console.warn('multiple import detected');
- return currentRoot;
- }
-
- const root = document.createElement('div');
- root.id = MISSKEY_MOUNT_DIV_ID;
- document.body.appendChild(root);
- return root;
-})();
-
-app.mount(rootEl);
-
-// boot.jsのやつを解除
-window.onerror = null;
-window.onunhandledrejection = null;
-
-reactionPicker.init();
-
-if (splash) {
- splash.style.opacity = '0';
- splash.style.pointerEvents = 'none';
-}
-
-// クライアントが更新されたか?
-const lastVersion = miLocalStorage.getItem('lastVersion');
-if (lastVersion !== version) {
- miLocalStorage.setItem('lastVersion', version);
-
- // テーマリビルドするため
- miLocalStorage.removeItem('theme');
-
- try { // 変なバージョン文字列来るとcompareVersionsでエラーになるため
- if (lastVersion != null && compareVersions(version, lastVersion) === 1) {
- // ログインしてる場合だけ
- if ($i) {
- popup(defineAsyncComponent(() => import('@/components/MkUpdated.vue')), {}, {}, 'closed');
- }
- }
- } catch (err) { /* empty */ }
-}
-
-await defaultStore.ready;
-
-// NOTE: この処理は必ず↑のクライアント更新時処理より後に来ること(テーマ再構築のため)
-watch(defaultStore.reactiveState.darkMode, (darkMode) => {
- applyTheme(darkMode ? ColdDeviceStorage.get('darkTheme') : ColdDeviceStorage.get('lightTheme'));
-}, { immediate: miLocalStorage.getItem('theme') == null });
-
-const darkTheme = computed(ColdDeviceStorage.makeGetterSetter('darkTheme'));
-const lightTheme = computed(ColdDeviceStorage.makeGetterSetter('lightTheme'));
-
-watch(darkTheme, (theme) => {
- if (defaultStore.state.darkMode) {
- applyTheme(theme);
- }
-});
-
-watch(lightTheme, (theme) => {
- if (!defaultStore.state.darkMode) {
- applyTheme(theme);
- }
-});
-
-//#region Sync dark mode
-if (ColdDeviceStorage.get('syncDeviceDarkMode')) {
- defaultStore.set('darkMode', isDeviceDarkmode());
-}
-
-window.matchMedia('(prefers-color-scheme: dark)').addListener(mql => {
- if (ColdDeviceStorage.get('syncDeviceDarkMode')) {
- defaultStore.set('darkMode', mql.matches);
- }
-});
-//#endregion
-
-fetchInstanceMetaPromise.then(() => {
- if (defaultStore.state.themeInitial) {
- if (instance.defaultLightTheme != null) ColdDeviceStorage.set('lightTheme', JSON5.parse(instance.defaultLightTheme));
- if (instance.defaultDarkTheme != null) ColdDeviceStorage.set('darkTheme', JSON5.parse(instance.defaultDarkTheme));
- defaultStore.set('themeInitial', false);
- }
-});
-
-watch(defaultStore.reactiveState.useBlurEffectForModal, v => {
- document.documentElement.style.setProperty('--modalBgFilter', v ? 'blur(4px)' : 'none');
-}, { immediate: true });
-
-watch(defaultStore.reactiveState.useBlurEffect, v => {
- if (v) {
- document.documentElement.style.removeProperty('--blur');
- } else {
- document.documentElement.style.setProperty('--blur', 'none');
- }
-}, { immediate: true });
-
-let reloadDialogShowing = false;
-stream.on('_disconnected_', async () => {
- if (defaultStore.state.serverDisconnectedBehavior === 'reload') {
- location.reload();
- } else if (defaultStore.state.serverDisconnectedBehavior === 'dialog') {
- if (reloadDialogShowing) return;
- reloadDialogShowing = true;
- const { canceled } = await confirm({
- type: 'warning',
- title: i18n.ts.disconnectedFromServer,
- text: i18n.ts.reloadConfirm,
- });
- reloadDialogShowing = false;
- if (!canceled) {
- location.reload();
- }
- }
-});
-
-for (const plugin of ColdDeviceStorage.get('plugins').filter(p => p.active)) {
- import('./plugin').then(async ({ install }) => {
- // Workaround for https://bugs.webkit.org/show_bug.cgi?id=242740
- await new Promise(r => setTimeout(r, 0));
- install(plugin);
- });
-}
-
-const hotkeys = {
- 'd': (): void => {
- defaultStore.set('darkMode', !defaultStore.state.darkMode);
- },
- 's': (): void => {
- mainRouter.push('/search');
- },
-};
-
-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',
- text: i18n.ts.accountDeletionInProgress,
- });
- }
-
- const now = new Date();
- const m = now.getMonth() + 1;
- const d = now.getDate();
-
- if ($i.birthday) {
- const bm = parseInt($i.birthday.split('-')[1]);
- const bd = parseInt($i.birthday.split('-')[2]);
- if (m === bm && d === bd) {
- claimAchievement('loggedInOnBirthday');
- }
- }
-
- if (m === 1 && d === 1) {
- claimAchievement('loggedInOnNewYearsDay');
- }
-
- if ($i.loggedInDays >= 3) claimAchievement('login3');
- if ($i.loggedInDays >= 7) claimAchievement('login7');
- if ($i.loggedInDays >= 15) claimAchievement('login15');
- if ($i.loggedInDays >= 30) claimAchievement('login30');
- if ($i.loggedInDays >= 60) claimAchievement('login60');
- if ($i.loggedInDays >= 100) claimAchievement('login100');
- if ($i.loggedInDays >= 200) claimAchievement('login200');
- if ($i.loggedInDays >= 300) claimAchievement('login300');
- if ($i.loggedInDays >= 400) claimAchievement('login400');
- if ($i.loggedInDays >= 500) claimAchievement('login500');
- if ($i.loggedInDays >= 600) claimAchievement('login600');
- if ($i.loggedInDays >= 700) claimAchievement('login700');
- if ($i.loggedInDays >= 800) claimAchievement('login800');
- if ($i.loggedInDays >= 900) claimAchievement('login900');
- if ($i.loggedInDays >= 1000) claimAchievement('login1000');
-
- if ($i.notesCount > 0) claimAchievement('notes1');
- if ($i.notesCount >= 10) claimAchievement('notes10');
- if ($i.notesCount >= 100) claimAchievement('notes100');
- if ($i.notesCount >= 500) claimAchievement('notes500');
- if ($i.notesCount >= 1000) claimAchievement('notes1000');
- if ($i.notesCount >= 5000) claimAchievement('notes5000');
- if ($i.notesCount >= 10000) claimAchievement('notes10000');
- if ($i.notesCount >= 20000) claimAchievement('notes20000');
- if ($i.notesCount >= 30000) claimAchievement('notes30000');
- if ($i.notesCount >= 40000) claimAchievement('notes40000');
- if ($i.notesCount >= 50000) claimAchievement('notes50000');
- if ($i.notesCount >= 60000) claimAchievement('notes60000');
- if ($i.notesCount >= 70000) claimAchievement('notes70000');
- if ($i.notesCount >= 80000) claimAchievement('notes80000');
- if ($i.notesCount >= 90000) claimAchievement('notes90000');
- if ($i.notesCount >= 100000) claimAchievement('notes100000');
-
- if ($i.followersCount > 0) claimAchievement('followers1');
- if ($i.followersCount >= 10) claimAchievement('followers10');
- if ($i.followersCount >= 50) claimAchievement('followers50');
- if ($i.followersCount >= 100) claimAchievement('followers100');
- if ($i.followersCount >= 300) claimAchievement('followers300');
- if ($i.followersCount >= 500) claimAchievement('followers500');
- if ($i.followersCount >= 1000) claimAchievement('followers1000');
-
- if (Date.now() - new Date($i.createdAt).getTime() > 1000 * 60 * 60 * 24 * 365) {
- claimAchievement('passedSinceAccountCreated1');
- }
- if (Date.now() - new Date($i.createdAt).getTime() > 1000 * 60 * 60 * 24 * 365 * 2) {
- claimAchievement('passedSinceAccountCreated2');
- }
- if (Date.now() - new Date($i.createdAt).getTime() > 1000 * 60 * 60 * 24 * 365 * 3) {
- claimAchievement('passedSinceAccountCreated3');
- }
-
- if (claimedAchievements.length >= 30) {
- claimAchievement('collectAchievements30');
- }
-
- window.setInterval(() => {
- if (Math.floor(Math.random() * 20000) === 0) {
- claimAchievement('justPlainLucky');
- }
- }, 1000 * 10);
-
- window.setTimeout(() => {
- claimAchievement('client30min');
- }, 1000 * 60 * 30);
-
- window.setTimeout(() => {
- claimAchievement('client60min');
- }, 1000 * 60 * 60);
-
- const lastUsed = miLocalStorage.getItem('lastUsed');
- if (lastUsed) {
- const lastUsedDate = parseInt(lastUsed, 10);
- // 二時間以上前なら
- if (Date.now() - lastUsedDate > 1000 * 60 * 60 * 2) {
- toast(i18n.t('welcomeBackWithName', {
- name: $i.name || $i.username,
- }));
- }
- }
- miLocalStorage.setItem('lastUsed', Date.now().toString());
-
- 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))) && !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');
- }
- }
-
- if ('Notification' in window) {
- // 許可を得ていなかったらリクエスト
- if (Notification.permission === 'default') {
- Notification.requestPermission();
- }
- }
-
- const main = markRaw(stream.useChannel('main', null, 'System'));
-
- // 自分の情報が更新されたとき
- main.on('meUpdated', i => {
- updateAccount(i);
- });
-
- main.on('readAllNotifications', () => {
- updateAccount({ hasUnreadNotification: false });
- });
-
- main.on('unreadNotification', () => {
- updateAccount({ hasUnreadNotification: true });
- });
-
- main.on('unreadMention', () => {
- updateAccount({ hasUnreadMentions: true });
- });
-
- main.on('readAllUnreadMentions', () => {
- updateAccount({ hasUnreadMentions: false });
- });
-
- main.on('unreadSpecifiedNote', () => {
- updateAccount({ hasUnreadSpecifiedNotes: true });
- });
-
- main.on('readAllUnreadSpecifiedNotes', () => {
- updateAccount({ hasUnreadSpecifiedNotes: false });
- });
-
- main.on('readAllAntennas', () => {
- updateAccount({ hasUnreadAntenna: false });
- });
-
- main.on('unreadAntenna', () => {
- updateAccount({ hasUnreadAntenna: true });
- sound.play('antenna');
- });
-
- main.on('readAllAnnouncements', () => {
- updateAccount({ hasUnreadAnnouncement: false });
- });
-
- // トークンが再生成されたとき
- // このままではMisskeyが利用できないので強制的にサインアウトさせる
- main.on('myTokenRegenerated', () => {
- signout();
- });
-}
-
-// shortcut
-document.addEventListener('keydown', makeHotkey(hotkeys));
diff --git a/packages/frontend/src/local-storage.ts b/packages/frontend/src/local-storage.ts
index 441a35747a..ca4f21f79b 100644
--- a/packages/frontend/src/local-storage.ts
+++ b/packages/frontend/src/local-storage.ts
@@ -13,7 +13,7 @@ type Keys =
'hashtags' |
'wallpaper' |
'theme' |
- 'colorSchema' |
+ 'colorScheme' |
'useSystemFont' |
'fontSize' |
'ui' |
diff --git a/packages/frontend/src/os.ts b/packages/frontend/src/os.ts
index c4f9d47d7d..c44d348046 100644
--- a/packages/frontend/src/os.ts
+++ b/packages/frontend/src/os.ts
@@ -172,12 +172,6 @@ export function pageWindow(path: string) {
}, {}, 'closed');
}
-export function modalPageWindow(path: string) {
- popup(defineAsyncComponent(() => import('@/components/MkModalPageWindow.vue')), {
- initialPath: path,
- }, {}, 'closed');
-}
-
export function toast(message: string) {
popup(MkToast, {
message,
diff --git a/packages/frontend/src/pages/_error_.vue b/packages/frontend/src/pages/_error_.vue
index f53fec7d94..f27d2df336 100644
--- a/packages/frontend/src/pages/_error_.vue
+++ b/packages/frontend/src/pages/_error_.vue
@@ -1,18 +1,20 @@
<template>
<MkLoading v-if="!loaded"/>
<Transition :name="defaultStore.state.animation ? '_transition_zoom' : ''" appear>
- <div v-show="loaded" class="mjndxjch">
- <img src="https://xn--931a.moe/assets/error.jpg" class="_ghost"/>
- <p><b><i class="ti ti-alert-triangle"></i> {{ i18n.ts.pageLoadError }}</b></p>
- <p v-if="meta && (version === meta.version)">{{ i18n.ts.pageLoadErrorDescription }}</p>
- <p v-else-if="serverIsDead">{{ i18n.ts.serverIsDead }}</p>
- <template v-else>
- <p>{{ i18n.ts.newVersionOfClientAvailable }}</p>
- <p>{{ i18n.ts.youShouldUpgradeClient }}</p>
- <MkButton class="button primary" @click="reload">{{ i18n.ts.reload }}</MkButton>
- </template>
- <p><MkA to="/docs/general/troubleshooting" class="_link">{{ i18n.ts.troubleshooting }}</MkA></p>
- <p v-if="error" class="error">ERROR: {{ error }}</p>
+ <div v-show="loaded" :class="$style.root">
+ <img src="https://xn--931a.moe/assets/error.jpg" class="_ghost" :class="$style.img"/>
+ <div class="_gaps">
+ <p><b><i class="ti ti-alert-triangle"></i> {{ i18n.ts.pageLoadError }}</b></p>
+ <p v-if="meta && (version === meta.version)">{{ i18n.ts.pageLoadErrorDescription }}</p>
+ <p v-else-if="serverIsDead">{{ i18n.ts.serverIsDead }}</p>
+ <template v-else>
+ <p>{{ i18n.ts.newVersionOfClientAvailable }}</p>
+ <p>{{ i18n.ts.youShouldUpgradeClient }}</p>
+ <MkButton style="margin: 8px auto;" @click="reload">{{ i18n.ts.reload }}</MkButton>
+ </template>
+ <p><MkA to="/docs/general/troubleshooting" class="_link">{{ i18n.ts.troubleshooting }}</MkA></p>
+ <p v-if="error" style="opacity: 0.7;">ERROR: {{ error }}</p>
+ </div>
</div>
</Transition>
</template>
@@ -64,28 +66,16 @@ definePageMetadata({
});
</script>
-<style lang="scss" scoped>
-.mjndxjch {
+<style lang="scss" module>
+.root {
padding: 32px;
text-align: center;
+}
- > p {
- margin: 0 0 12px 0;
- }
-
- > .button {
- margin: 8px auto;
- }
-
- > img {
- vertical-align: bottom;
- height: 128px;
- margin-bottom: 24px;
- border-radius: 16px;
- }
-
- > .error {
- opacity: 0.7;
- }
+.img {
+ vertical-align: bottom;
+ height: 128px;
+ margin-bottom: 24px;
+ border-radius: 16px;
}
</style>
diff --git a/packages/frontend/src/pages/about-misskey.vue b/packages/frontend/src/pages/about-misskey.vue
index 9e0594db3c..0017145fa1 100644
--- a/packages/frontend/src/pages/about-misskey.vue
+++ b/packages/frontend/src/pages/about-misskey.vue
@@ -2,7 +2,7 @@
<MkStickyContainer>
<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
<div style="overflow: clip;">
- <MkSpacer :content-max="600" :margin-min="20">
+ <MkSpacer :contentMax="600" :marginMin="20">
<div class="_gaps_m znqjceqz">
<div v-panel class="about">
<div ref="containerEl" class="container" :class="{ playing: easterEggEngine != null }">
@@ -10,8 +10,8 @@
<div class="misskey">Misskey</div>
<div class="version">v{{ version }}</div>
<span v-for="emoji in easterEggEmojis" :key="emoji.id" class="emoji" :data-physics-x="emoji.left" :data-physics-y="emoji.top" :class="{ _physics_circle_: !emoji.emoji.startsWith(':') }">
- <MkCustomEmoji v-if="emoji.emoji[0] === ':'" class="emoji" :name="emoji.emoji" :normal="true" :no-style="true"/>
- <MkEmoji v-else class="emoji" :emoji="emoji.emoji" :normal="true" :no-style="true"/>
+ <MkCustomEmoji v-if="emoji.emoji[0] === ':'" class="emoji" :name="emoji.emoji" :normal="true" :noStyle="true"/>
+ <MkEmoji v-else class="emoji" :emoji="emoji.emoji" :normal="true" :noStyle="true"/>
</span>
</div>
<button v-if="thereIsTreasure" class="_button treasure" @click="getTreasure"><img src="/fluent-emoji/1f3c6.png" class="treasureImg"></button>
@@ -86,8 +86,13 @@
</FormSection>
<FormSection>
<template #label>Special thanks</template>
- <div style="text-align: center;">
- <a style="display: inline-block;" class="dcadvirth" title="DC Advirth" href="https://www.dotchain.ltd/advirth" target="_blank"><img width="200" src="https://misskey-hub.net/sponsors/dcadvirth.png" alt="DC Advirth"></a>
+ <div class="_gaps" style="text-align: center;">
+ <div>
+ <a style="display: inline-block;" class="masknetwork" title="Mask Network" href="https://mask.io/" target="_blank"><img width="200" src="https://misskey-hub.net/sponsors/masknetwork.png" alt="Mask Network"></a>
+ </div>
+ <div>
+ <a style="display: inline-block;" class="dcadvirth" title="DC Advirth" href="https://www.dotchain.ltd/advirth" target="_blank"><img width="200" src="https://misskey-hub.net/sponsors/dcadvirth.png" alt="DC Advirth"></a>
+ </div>
</div>
</FormSection>
</div>
@@ -144,6 +149,12 @@ const patronsWithIcon = [{
}, {
name: 'かみらえっと',
icon: 'https://misskey-hub.net/patrons/be1326bda7d940a482f3758ffd9ffaf6.jpg',
+}, {
+ name: 'へてて',
+ icon: 'https://misskey-hub.net/patrons/0431eacd7c6843d09de8ea9984307e86.jpg',
+}, {
+ name: 'spinlock',
+ icon: 'https://misskey-hub.net/patrons/6a1cebc819d540a78bf20e9e3115baa8.jpg',
}];
const patrons = [
diff --git a/packages/frontend/src/pages/about.emojis.vue b/packages/frontend/src/pages/about.emojis.vue
index 2d82fcf277..3744bed10f 100644
--- a/packages/frontend/src/pages/about.emojis.vue
+++ b/packages/frontend/src/pages/about.emojis.vue
@@ -1,5 +1,5 @@
<template>
-<div class="driuhtrh _gaps">
+<div class="_gaps">
<MkButton v-if="$i && ($i.isModerator || $i.policies.canManageCustomEmojis)" primary link to="/custom-emojis-manager">{{ i18n.ts.manageCustomEmojis }}</MkButton>
<div class="query">
@@ -14,17 +14,17 @@
-->
</div>
- <MkFoldableSection v-if="searchEmojis" class="emojis">
+ <MkFoldableSection v-if="searchEmojis">
<template #header>{{ i18n.ts.searchResult }}</template>
- <div class="zuvgdzyt">
- <XEmoji v-for="emoji in searchEmojis" :key="emoji.name" class="emoji" :emoji="emoji"/>
+ <div :class="$style.emojis">
+ <XEmoji v-for="emoji in searchEmojis" :key="emoji.name" :emoji="emoji"/>
</div>
</MkFoldableSection>
- <MkFoldableSection v-for="category in customEmojiCategories" v-once :key="category" class="emojis">
+ <MkFoldableSection v-for="category in customEmojiCategories" v-once :key="category">
<template #header>{{ category || i18n.ts.other }}</template>
- <div class="zuvgdzyt">
- <XEmoji v-for="emoji in customEmojis.filter(e => e.category === category)" :key="emoji.name" class="emoji" :emoji="emoji"/>
+ <div :class="$style.emojis">
+ <XEmoji v-for="emoji in customEmojis.filter(e => e.category === category)" :key="emoji.name" :emoji="emoji"/>
</div>
</MkFoldableSection>
</div>
@@ -57,7 +57,7 @@ function search() {
if (queryarry) {
searchEmojis = customEmojis.value.filter(emoji =>
- queryarry.includes(`:${emoji.name}:`)
+ queryarry.includes(`:${emoji.name}:`),
);
} else {
searchEmojis = customEmojis.value.filter(emoji => emoji.name.includes(q) || emoji.aliases.includes(q));
@@ -84,36 +84,10 @@ watch($$(selectedTags), () => {
}, { deep: true });
</script>
-<style lang="scss" scoped>
-.driuhtrh {
- background: var(--bg);
-
- > .query {
- background: var(--bg);
-
- > .tags {
- > .tag {
- display: inline-block;
- margin: 8px 8px 0 0;
- padding: 4px 8px;
- font-size: 0.9em;
- background: var(--accentedBg);
- border-radius: 5px;
-
- &.active {
- background: var(--accent);
- color: var(--fgOnAccent);
- }
- }
- }
- }
-
- > .emojis {
- .zuvgdzyt {
- display: grid;
- grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
- grid-gap: 12px;
- }
- }
+<style lang="scss" module>
+.emojis {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
+ grid-gap: 12px;
}
</style>
diff --git a/packages/frontend/src/pages/about.federation.vue b/packages/frontend/src/pages/about.federation.vue
index 8fe613a9a8..a8c6c05d8b 100644
--- a/packages/frontend/src/pages/about.federation.vue
+++ b/packages/frontend/src/pages/about.federation.vue
@@ -1,6 +1,6 @@
<template>
-<div class="taeiyria">
- <div class="query">
+<div>
+ <div>
<MkInput v-model="host" :debounce="true" class="">
<template #prefix><i class="ti ti-search"></i></template>
<template #label>{{ i18n.ts.host }}</template>
@@ -35,8 +35,8 @@
</div>
<MkPagination v-slot="{items}" ref="instances" :key="host + state" :pagination="pagination">
- <div class="dqokceoi">
- <MkA v-for="instance in items" :key="instance.id" v-tooltip.mfm="`Status: ${getStatus(instance)}`" class="instance" :to="`/instance-info/${instance.host}`">
+ <div :class="$style.items">
+ <MkA v-for="instance in items" :key="instance.id" v-tooltip.mfm="`Status: ${getStatus(instance)}`" :class="$style.item" :to="`/instance-info/${instance.host}`">
<MkInstanceCardMini :instance="instance"/>
</MkA>
</div>
@@ -82,21 +82,14 @@ function getStatus(instance) {
}
</script>
-<style lang="scss" scoped>
-.taeiyria {
- > .query {
- background: var(--bg);
- margin-bottom: 16px;
- }
-}
-
-.dqokceoi {
+<style lang="scss" module>
+.items {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(270px, 1fr));
grid-gap: 12px;
+}
- > .instance:hover {
- text-decoration: none;
- }
+.item:hover {
+ text-decoration: none;
}
</style>
diff --git a/packages/frontend/src/pages/about.vue b/packages/frontend/src/pages/about.vue
index 8e29990426..693d369b89 100644
--- a/packages/frontend/src/pages/about.vue
+++ b/packages/frontend/src/pages/about.vue
@@ -1,7 +1,7 @@
<template>
<MkStickyContainer>
<template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
- <MkSpacer v-if="tab === 'overview'" :content-max="600" :margin-min="20">
+ <MkSpacer v-if="tab === 'overview'" :contentMax="600" :marginMin="20">
<div class="_gaps_m">
<div :class="$style.banner" :style="{ backgroundImage: `url(${ instance.bannerUrl })` }">
<div style="overflow: clip;">
@@ -80,13 +80,13 @@
</FormSection>
</div>
</MkSpacer>
- <MkSpacer v-else-if="tab === 'emojis'" :content-max="1000" :margin-min="20">
+ <MkSpacer v-else-if="tab === 'emojis'" :contentMax="1000" :marginMin="20">
<XEmojis/>
</MkSpacer>
- <MkSpacer v-else-if="tab === 'federation'" :content-max="1000" :margin-min="20">
+ <MkSpacer v-else-if="tab === 'federation'" :contentMax="1000" :marginMin="20">
<XFederation/>
</MkSpacer>
- <MkSpacer v-else-if="tab === 'charts'" :content-max="1000" :margin-min="20">
+ <MkSpacer v-else-if="tab === 'charts'" :contentMax="1000" :marginMin="20">
<MkInstanceStats/>
</MkSpacer>
</MkStickyContainer>
diff --git a/packages/frontend/src/pages/achievements.vue b/packages/frontend/src/pages/achievements.vue
index 1eef7a53fe..dc47d8dde0 100644
--- a/packages/frontend/src/pages/achievements.vue
+++ b/packages/frontend/src/pages/achievements.vue
@@ -1,7 +1,7 @@
<template>
<MkStickyContainer>
<template #header><MkPageHeader/></template>
- <MkSpacer :content-max="1200">
+ <MkSpacer :contentMax="1200">
<MkAchievements :user="$i"/>
</MkSpacer>
</MkStickyContainer>
diff --git a/packages/frontend/src/pages/admin-file.vue b/packages/frontend/src/pages/admin-file.vue
index 1d309a7377..24c863ba62 100644
--- a/packages/frontend/src/pages/admin-file.vue
+++ b/packages/frontend/src/pages/admin-file.vue
@@ -1,7 +1,7 @@
<template>
<MkStickyContainer>
<template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
- <MkSpacer v-if="file" :content-max="600" :margin-min="16" :margin-max="32">
+ <MkSpacer v-if="file" :contentMax="600" :marginMin="16" :marginMax="32">
<div v-if="tab === 'overview'" class="cxqhhsmd _gaps_m">
<a class="thumbnail" :href="file.url" target="_blank">
<MkDriveFileThumbnail class="thumbnail" :file="file" fit="contain"/>
@@ -32,7 +32,7 @@
<MkUserCardMini :user="file.user"/>
</MkA>
<div>
- <MkSwitch v-model="isSensitive" @update:model-value="toggleIsSensitive">NSFW</MkSwitch>
+ <MkSwitch v-model="isSensitive" @update:modelValue="toggleIsSensitive">NSFW</MkSwitch>
</div>
<div>
diff --git a/packages/frontend/src/pages/admin/RolesEditorFormula.vue b/packages/frontend/src/pages/admin/RolesEditorFormula.vue
index 343d2c4c5c..9530b27bad 100644
--- a/packages/frontend/src/pages/admin/RolesEditorFormula.vue
+++ b/packages/frontend/src/pages/admin/RolesEditorFormula.vue
@@ -1,5 +1,5 @@
<template>
-<div :class="$style.root" class="_gaps">
+<div class="_gaps">
<div :class="$style.header">
<MkSelect v-model="type" :class="$style.typeSelect">
<option value="isLocal">{{ i18n.ts._role._condition.isLocal }}</option>
@@ -24,12 +24,12 @@
</button>
</div>
- <div v-if="type === 'and' || type === 'or'" :class="$style.values" class="_gaps">
- <Sortable v-model="v.values" tag="div" class="_gaps" item-key="id" handle=".drag-handle" :group="{ name: 'roleFormula' }" :animation="150" :swap-threshold="0.5">
+ <div v-if="type === 'and' || type === 'or'" class="_gaps">
+ <Sortable v-model="v.values" tag="div" class="_gaps" itemKey="id" handle=".drag-handle" :group="{ name: 'roleFormula' }" :animation="150" :swapThreshold="0.5">
<template #item="{element}">
<div :class="$style.item">
<!-- divが無いとエラーになる https://github.com/SortableJS/vue.draggable.next/issues/189 -->
- <RolesEditorFormula :model-value="element" draggable @update:model-value="updated => valuesItemUpdated(updated)" @remove="removeItem(element)"/>
+ <RolesEditorFormula :modelValue="element" draggable @update:modelValue="updated => valuesItemUpdated(updated)" @remove="removeItem(element)"/>
</div>
</template>
</Sortable>
@@ -118,10 +118,6 @@ function removeSelf() {
</script>
<style lang="scss" module>
-.root {
-
-}
-
.header {
display: flex;
}
@@ -148,8 +144,4 @@ function removeSelf() {
border-color: var(--accent);
}
}
-
-.values {
-
-}
</style>
diff --git a/packages/frontend/src/pages/admin/abuses.vue b/packages/frontend/src/pages/admin/abuses.vue
index 9e8af43024..3bc5ee9723 100644
--- a/packages/frontend/src/pages/admin/abuses.vue
+++ b/packages/frontend/src/pages/admin/abuses.vue
@@ -1,8 +1,8 @@
<template>
<MkStickyContainer>
<template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
- <MkSpacer :content-max="900">
- <div class="lcixvhis">
+ <MkSpacer :contentMax="900">
+ <div>
<div class="reports">
<div class="">
<div class="inputs" style="display: flex;">
@@ -87,9 +87,3 @@ definePageMetadata({
icon: 'ti ti-exclamation-circle',
});
</script>
-
-<style lang="scss" scoped>
-.lcixvhis {
- margin: var(--margin);
-}
-</style>
diff --git a/packages/frontend/src/pages/admin/ads.vue b/packages/frontend/src/pages/admin/ads.vue
index 803e8cb7b0..2c9e18b0bf 100644
--- a/packages/frontend/src/pages/admin/ads.vue
+++ b/packages/frontend/src/pages/admin/ads.vue
@@ -1,9 +1,9 @@
<template>
<MkStickyContainer>
<template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
- <MkSpacer :content-max="900">
- <div class="uqshojas">
- <div v-for="ad in ads" class="_panel _gaps_m ad">
+ <MkSpacer :contentMax="900">
+ <div>
+ <div v-for="ad in ads" class="_panel _gaps_m" :class="$style.ad">
<MkAd v-if="ad.url" :specify="ad"/>
<MkInput v-model="ad.url" type="url">
<template #label>URL</template>
@@ -196,14 +196,12 @@ definePageMetadata({
});
</script>
-<style lang="scss" scoped>
-.uqshojas {
- > .ad {
- padding: 32px;
+<style lang="scss" module>
+.ad {
+ padding: 32px;
- &:not(:last-child) {
- margin-bottom: var(--margin);
- }
+ &:not(:last-child) {
+ margin-bottom: var(--margin);
}
}
</style>
diff --git a/packages/frontend/src/pages/admin/announcements.vue b/packages/frontend/src/pages/admin/announcements.vue
index b76e4b9114..3cb32c1d9d 100644
--- a/packages/frontend/src/pages/admin/announcements.vue
+++ b/packages/frontend/src/pages/admin/announcements.vue
@@ -1,8 +1,8 @@
<template>
<MkStickyContainer>
<template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
- <MkSpacer :content-max="900">
- <div class="ztgjmzrw _gaps_m">
+ <MkSpacer :contentMax="900">
+ <div class="_gaps_m">
<section v-for="announcement in announcements" class="">
<div class="_panel _gaps_m" style="padding: 24px;">
<MkInput v-model="announcement.title">
@@ -113,9 +113,3 @@ definePageMetadata({
icon: 'ti ti-speakerphone',
});
</script>
-
-<style lang="scss" scoped>
-.ztgjmzrw {
- margin: var(--margin);
-}
-</style>
diff --git a/packages/frontend/src/pages/admin/database.vue b/packages/frontend/src/pages/admin/database.vue
index 5a0d3d5e51..131e586afd 100644
--- a/packages/frontend/src/pages/admin/database.vue
+++ b/packages/frontend/src/pages/admin/database.vue
@@ -1,7 +1,7 @@
<template>
<MkStickyContainer>
<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
- <MkSpacer :content-max="800" :margin-min="16" :margin-max="32">
+ <MkSpacer :contentMax="800" :marginMin="16" :marginMax="32">
<FormSuspense v-slot="{ result: database }" :p="databasePromiseFactory">
<MkKeyValue v-for="table in database" :key="table[0]" oneline style="margin: 1em 0;">
<template #key>{{ table[0] }}</template>
diff --git a/packages/frontend/src/pages/admin/email-settings.vue b/packages/frontend/src/pages/admin/email-settings.vue
index d51bf6230a..4f5bb379ad 100644
--- a/packages/frontend/src/pages/admin/email-settings.vue
+++ b/packages/frontend/src/pages/admin/email-settings.vue
@@ -1,7 +1,7 @@
<template>
<MkStickyContainer>
<template #header><XHeader :tabs="headerTabs"/></template>
- <MkSpacer :content-max="700" :margin-min="16" :margin-max="32">
+ <MkSpacer :contentMax="700" :marginMin="16" :marginMax="32">
<FormSuspense :p="init">
<div class="_gaps_m">
<MkSwitch v-model="enableEmail">
@@ -18,7 +18,7 @@
<template #label>{{ i18n.ts.smtpConfig }}</template>
<div class="_gaps_m">
- <FormSplit :min-width="280">
+ <FormSplit :minWidth="280">
<MkInput v-model="smtpHost">
<template #label>{{ i18n.ts.smtpHost }}</template>
</MkInput>
@@ -26,7 +26,7 @@
<template #label>{{ i18n.ts.smtpPort }}</template>
</MkInput>
</FormSplit>
- <FormSplit :min-width="280">
+ <FormSplit :minWidth="280">
<MkInput v-model="smtpUser">
<template #label>{{ i18n.ts.smtpUser }}</template>
</MkInput>
@@ -47,7 +47,7 @@
</MkSpacer>
<template #footer>
<div :class="$style.footer">
- <MkSpacer :content-max="700" :margin-min="16" :margin-max="16">
+ <MkSpacer :contentMax="700" :marginMin="16" :marginMax="16">
<div class="_buttons">
<MkButton primary rounded @click="save"><i class="ti ti-check"></i> {{ i18n.ts.save }}</MkButton>
<MkButton rounded @click="testEmail"><i class="ti ti-send"></i> {{ i18n.ts.testEmail }}</MkButton>
diff --git a/packages/frontend/src/pages/admin/federation.vue b/packages/frontend/src/pages/admin/federation.vue
index 6af1610431..a8d6dcf3de 100644
--- a/packages/frontend/src/pages/admin/federation.vue
+++ b/packages/frontend/src/pages/admin/federation.vue
@@ -2,9 +2,9 @@
<div>
<MkStickyContainer>
<template #header><XHeader :actions="headerActions"/></template>
- <MkSpacer :content-max="900">
- <div class="taeiyrib">
- <div class="query">
+ <MkSpacer :contentMax="900">
+ <div class="_gaps">
+ <div>
<MkInput v-model="host" :debounce="true" class="">
<template #prefix><i class="ti ti-search"></i></template>
<template #label>{{ i18n.ts.host }}</template>
@@ -39,8 +39,8 @@
</div>
<MkPagination v-slot="{items}" ref="instances" :key="host + state" :pagination="pagination">
- <div class="dqokceoj">
- <MkA v-for="instance in items" :key="instance.id" v-tooltip.mfm="`Status: ${getStatus(instance)}`" class="instance" :to="`/instance-info/${instance.host}`">
+ <div :class="$style.instances">
+ <MkA v-for="instance in items" :key="instance.id" v-tooltip.mfm="`Status: ${getStatus(instance)}`" :class="$style.instance" :to="`/instance-info/${instance.host}`">
<MkInstanceCardMini :instance="instance"/>
</MkA>
</div>
@@ -100,21 +100,14 @@ definePageMetadata(computed(() => ({
})));
</script>
-<style lang="scss" scoped>
-.taeiyrib {
- > .query {
- background: var(--bg);
- margin-bottom: 16px;
- }
-}
-
-.dqokceoj {
+<style lang="scss" module>
+.instances {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(270px, 1fr));
grid-gap: 12px;
+}
- > .instance:hover {
- text-decoration: none;
- }
+.instance:hover {
+ text-decoration: none;
}
</style>
diff --git a/packages/frontend/src/pages/admin/files.vue b/packages/frontend/src/pages/admin/files.vue
index c189437246..b204a1a64a 100644
--- a/packages/frontend/src/pages/admin/files.vue
+++ b/packages/frontend/src/pages/admin/files.vue
@@ -2,30 +2,28 @@
<div>
<MkStickyContainer>
<template #header><XHeader :actions="headerActions"/></template>
- <MkSpacer :content-max="900">
- <div class="xrmjdkdw">
- <div>
- <div class="inputs" style="display: flex; gap: var(--margin); flex-wrap: wrap;">
- <MkSelect v-model="origin" style="margin: 0; flex: 1;">
- <template #label>{{ i18n.ts.instance }}</template>
- <option value="combined">{{ i18n.ts.all }}</option>
- <option value="local">{{ i18n.ts.local }}</option>
- <option value="remote">{{ i18n.ts.remote }}</option>
- </MkSelect>
- <MkInput v-model="searchHost" :debounce="true" type="search" style="margin: 0; flex: 1;" :disabled="pagination.params.origin === 'local'">
- <template #label>{{ i18n.ts.host }}</template>
- </MkInput>
- </div>
- <div class="inputs" style="display: flex; gap: var(--margin); flex-wrap: wrap; padding-top: 1.2em;">
- <MkInput v-model="userId" :debounce="true" type="search" style="margin: 0; flex: 1;">
- <template #label>User ID</template>
- </MkInput>
- <MkInput v-model="type" :debounce="true" type="search" style="margin: 0; flex: 1;">
- <template #label>MIME type</template>
- </MkInput>
- </div>
- <MkFileListForAdmin :pagination="pagination" :view-mode="viewMode"/>
+ <MkSpacer :contentMax="900">
+ <div class="_gaps">
+ <div class="inputs" style="display: flex; gap: var(--margin); flex-wrap: wrap;">
+ <MkSelect v-model="origin" style="margin: 0; flex: 1;">
+ <template #label>{{ i18n.ts.instance }}</template>
+ <option value="combined">{{ i18n.ts.all }}</option>
+ <option value="local">{{ i18n.ts.local }}</option>
+ <option value="remote">{{ i18n.ts.remote }}</option>
+ </MkSelect>
+ <MkInput v-model="searchHost" :debounce="true" type="search" style="margin: 0; flex: 1;" :disabled="pagination.params.origin === 'local'">
+ <template #label>{{ i18n.ts.host }}</template>
+ </MkInput>
</div>
+ <div class="inputs" style="display: flex; gap: var(--margin); flex-wrap: wrap;">
+ <MkInput v-model="userId" :debounce="true" type="search" style="margin: 0; flex: 1;">
+ <template #label>User ID</template>
+ </MkInput>
+ <MkInput v-model="type" :debounce="true" type="search" style="margin: 0; flex: 1;">
+ <template #label>MIME type</template>
+ </MkInput>
+ </div>
+ <MkFileListForAdmin :pagination="pagination" :viewMode="viewMode"/>
</div>
</MkSpacer>
</MkStickyContainer>
@@ -109,9 +107,3 @@ definePageMetadata(computed(() => ({
icon: 'ti ti-cloud',
})));
</script>
-
-<style lang="scss" scoped>
-.xrmjdkdw {
- margin: var(--margin);
-}
-</style>
diff --git a/packages/frontend/src/pages/admin/index.vue b/packages/frontend/src/pages/admin/index.vue
index 963393d7e5..5cbbcaa44c 100644
--- a/packages/frontend/src/pages/admin/index.vue
+++ b/packages/frontend/src/pages/admin/index.vue
@@ -1,7 +1,7 @@
<template>
<div ref="el" class="hiyeyicy" :class="{ wide: !narrow }">
<div v-if="!narrow || currentPage?.route.name == null" class="nav">
- <MkSpacer :content-max="700" :margin-min="16">
+ <MkSpacer :contentMax="700" :marginMin="16">
<div class="lxpfedzu">
<div class="banner">
<img :src="instance.iconUrl || '/favicon.ico'" alt="" class="icon"/>
diff --git a/packages/frontend/src/pages/admin/instance-block.vue b/packages/frontend/src/pages/admin/instance-block.vue
index 7a4937093e..e5f3816c82 100644
--- a/packages/frontend/src/pages/admin/instance-block.vue
+++ b/packages/frontend/src/pages/admin/instance-block.vue
@@ -1,7 +1,7 @@
<template>
<MkStickyContainer>
<template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
- <MkSpacer :content-max="700" :margin-min="16" :margin-max="32">
+ <MkSpacer :contentMax="700" :marginMin="16" :marginMax="32">
<FormSuspense :p="init">
<MkTextarea v-model="blockedHosts">
<span>{{ i18n.ts.blockedInstances }}</span>
diff --git a/packages/frontend/src/pages/admin/moderation.vue b/packages/frontend/src/pages/admin/moderation.vue
index bf788e3609..e36c9ac91d 100644
--- a/packages/frontend/src/pages/admin/moderation.vue
+++ b/packages/frontend/src/pages/admin/moderation.vue
@@ -2,7 +2,7 @@
<div>
<MkStickyContainer>
<template #header><XHeader :tabs="headerTabs"/></template>
- <MkSpacer :content-max="700" :margin-min="16" :margin-max="32">
+ <MkSpacer :contentMax="700" :marginMin="16" :marginMax="32">
<FormSuspense :p="init">
<div class="_gaps_m">
<MkSwitch v-model="enableRegistration">
@@ -34,7 +34,7 @@
</MkSpacer>
<template #footer>
<div :class="$style.footer">
- <MkSpacer :content-max="700" :margin-min="16" :margin-max="16">
+ <MkSpacer :contentMax="700" :marginMin="16" :marginMax="16">
<MkButton primary rounded @click="save"><i class="ti ti-check"></i> {{ i18n.ts.save }}</MkButton>
</MkSpacer>
</div>
diff --git a/packages/frontend/src/pages/admin/object-storage.vue b/packages/frontend/src/pages/admin/object-storage.vue
index 704b27c174..e569aad1b8 100644
--- a/packages/frontend/src/pages/admin/object-storage.vue
+++ b/packages/frontend/src/pages/admin/object-storage.vue
@@ -1,7 +1,7 @@
<template>
<MkStickyContainer>
<template #header><XHeader :tabs="headerTabs"/></template>
- <MkSpacer :content-max="700" :margin-min="16" :margin-max="32">
+ <MkSpacer :contentMax="700" :marginMin="16" :marginMax="32">
<FormSuspense :p="init">
<div class="_gaps_m">
<MkSwitch v-model="useObjectStorage">{{ i18n.ts.useObjectStorage }}</MkSwitch>
@@ -33,7 +33,7 @@
<template #caption>{{ i18n.ts.objectStorageRegionDesc }}</template>
</MkInput>
- <FormSplit :min-width="280">
+ <FormSplit :minWidth="280">
<MkInput v-model="objectStorageAccessKey">
<template #prefix><i class="ti ti-key"></i></template>
<template #label>Access key</template>
@@ -69,7 +69,7 @@
</MkSpacer>
<template #footer>
<div :class="$style.footer">
- <MkSpacer :content-max="700" :margin-min="16" :margin-max="16">
+ <MkSpacer :contentMax="700" :marginMin="16" :marginMax="16">
<MkButton primary rounded @click="save"><i class="ti ti-check"></i> {{ i18n.ts.save }}</MkButton>
</MkSpacer>
</div>
diff --git a/packages/frontend/src/pages/admin/other-settings.vue b/packages/frontend/src/pages/admin/other-settings.vue
index 62dff6ce7f..15d720a070 100644
--- a/packages/frontend/src/pages/admin/other-settings.vue
+++ b/packages/frontend/src/pages/admin/other-settings.vue
@@ -1,9 +1,17 @@
<template>
<MkStickyContainer>
<template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
- <MkSpacer :content-max="700" :margin-min="16" :margin-max="32">
+ <MkSpacer :contentMax="700" :marginMin="16" :marginMax="32">
<FormSuspense :p="init">
- none
+ <div class="_gaps_s">
+ <MkSwitch v-model="enableChartsForRemoteUser">
+ <template #label>{{ i18n.ts.enableChartsForRemoteUser }}</template>
+ </MkSwitch>
+
+ <MkSwitch v-model="enableChartsForFederatedInstances">
+ <template #label>{{ i18n.ts.enableChartsForFederatedInstances }}</template>
+ </MkSwitch>
+ </div>
</FormSuspense>
</MkSpacer>
</MkStickyContainer>
@@ -17,13 +25,22 @@ import * as os from '@/os';
import { fetchInstance } from '@/instance';
import { i18n } from '@/i18n';
import { definePageMetadata } from '@/scripts/page-metadata';
+import MkSwitch from '@/components/MkSwitch.vue';
+
+let enableChartsForRemoteUser: boolean = $ref(false);
+let enableChartsForFederatedInstances: boolean = $ref(false);
async function init() {
- await os.api('admin/meta');
+ const meta = await os.api('admin/meta');
+ enableChartsForRemoteUser = meta.enableChartsForRemoteUser;
+ enableChartsForFederatedInstances = meta.enableChartsForFederatedInstances;
}
function save() {
- os.apiWithDialog('admin/update-meta').then(() => {
+ os.apiWithDialog('admin/update-meta', {
+ enableChartsForRemoteUser,
+ enableChartsForFederatedInstances,
+ }).then(() => {
fetchInstance();
});
}
diff --git a/packages/frontend/src/pages/admin/overview.instances.vue b/packages/frontend/src/pages/admin/overview.instances.vue
index 6c2ffd4742..d349b32322 100644
--- a/packages/frontend/src/pages/admin/overview.instances.vue
+++ b/packages/frontend/src/pages/admin/overview.instances.vue
@@ -1,9 +1,9 @@
<template>
-<div class="wbrkwale">
+<div>
<Transition :name="defaultStore.state.animation ? '_transition_zoom' : ''" mode="out-in">
<MkLoading v-if="fetching"/>
- <div v-else class="instances">
- <MkA v-for="(instance, i) in instances" :key="instance.id" v-tooltip.mfm.noDelay="`${instance.name}\n${instance.host}\n${instance.softwareName} ${instance.softwareVersion}`" :to="`/instance-info/${instance.host}`" class="instance">
+ <div v-else :class="$style.instances">
+ <MkA v-for="(instance, i) in instances" :key="instance.id" v-tooltip.mfm.noDelay="`${instance.name}\n${instance.host}\n${instance.softwareName} ${instance.softwareVersion}`" :to="`/instance-info/${instance.host}`" :class="$style.instance">
<MkInstanceCardMini :instance="instance"/>
</MkA>
</div>
@@ -36,16 +36,14 @@ useInterval(fetch, 1000 * 60, {
});
</script>
-<style lang="scss" scoped>
-.wbrkwale {
- > .instances {
- display: grid;
- grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
- grid-gap: 12px;
+<style lang="scss" module>
+.instances {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
+ grid-gap: 12px;
+}
- > .instance:hover {
- text-decoration: none;
- }
- }
+.instance:hover {
+ text-decoration: none;
}
</style>
diff --git a/packages/frontend/src/pages/admin/overview.pie.vue b/packages/frontend/src/pages/admin/overview.pie.vue
index 08a29bf550..af7bc70551 100644
--- a/packages/frontend/src/pages/admin/overview.pie.vue
+++ b/packages/frontend/src/pages/admin/overview.pie.vue
@@ -67,7 +67,3 @@ onMounted(() => {
});
});
</script>
-
-<style lang="scss" scoped>
-
-</style>
diff --git a/packages/frontend/src/pages/admin/overview.queue.chart.vue b/packages/frontend/src/pages/admin/overview.queue.chart.vue
index 6a11e8b768..a3c8659ce5 100644
--- a/packages/frontend/src/pages/admin/overview.queue.chart.vue
+++ b/packages/frontend/src/pages/admin/overview.queue.chart.vue
@@ -132,7 +132,3 @@ defineExpose({
pushData,
});
</script>
-
-<style lang="scss" scoped>
-
-</style>
diff --git a/packages/frontend/src/pages/admin/overview.queue.vue b/packages/frontend/src/pages/admin/overview.queue.vue
index 1f56a2826a..69ca89e226 100644
--- a/packages/frontend/src/pages/admin/overview.queue.vue
+++ b/packages/frontend/src/pages/admin/overview.queue.vue
@@ -33,9 +33,9 @@
import { markRaw, onMounted, onUnmounted, ref } from 'vue';
import XChart from './overview.queue.chart.vue';
import number from '@/filters/number';
-import { stream } from '@/stream';
+import { useStream } from '@/stream';
-const connection = markRaw(stream.useChannel('queueStats'));
+const connection = markRaw(useStream().useChannel('queueStats'));
const activeSincePrevTick = ref(0);
const active = ref(0);
diff --git a/packages/frontend/src/pages/admin/overview.vue b/packages/frontend/src/pages/admin/overview.vue
index 5c96c07bfb..e8295c81b5 100644
--- a/packages/frontend/src/pages/admin/overview.vue
+++ b/packages/frontend/src/pages/admin/overview.vue
@@ -1,6 +1,6 @@
<template>
-<MkSpacer :content-max="1000">
- <div ref="rootEl" class="edbbcaef">
+<MkSpacer :contentMax="1000">
+ <div ref="rootEl" :class="$style.root">
<MkFoldableSection class="item">
<template #header>Stats</template>
<XStats/>
@@ -72,7 +72,7 @@ import XRetention from './overview.retention.vue';
import XModerators from './overview.moderators.vue';
import XHeatmap from './overview.heatmap.vue';
import * as os from '@/os';
-import { stream } from '@/stream';
+import { useStream } from '@/stream';
import { i18n } from '@/i18n';
import { definePageMetadata } from '@/scripts/page-metadata';
import MkFoldableSection from '@/components/MkFoldableSection.vue';
@@ -87,7 +87,7 @@ let federationSubActive = $ref<number | null>(null);
let federationSubActiveDiff = $ref<number | null>(null);
let newUsers = $ref(null);
let activeInstances = $shallowRef(null);
-const queueStatsConnection = markRaw(stream.useChannel('queueStats'));
+const queueStatsConnection = markRaw(useStream().useChannel('queueStats'));
const now = new Date();
const filesPagination = {
endpoint: 'admin/drive/files' as const,
@@ -176,8 +176,8 @@ definePageMetadata({
});
</script>
-<style lang="scss" scoped>
-.edbbcaef {
+<style lang="scss" module>
+.root {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(400px, 1fr));
grid-gap: 16px;
diff --git a/packages/frontend/src/pages/admin/proxy-account.vue b/packages/frontend/src/pages/admin/proxy-account.vue
index 6ad566187a..c81f50a0d2 100644
--- a/packages/frontend/src/pages/admin/proxy-account.vue
+++ b/packages/frontend/src/pages/admin/proxy-account.vue
@@ -1,7 +1,7 @@
<template>
<MkStickyContainer>
<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
- <MkSpacer :content-max="700" :margin-min="16" :margin-max="32">
+ <MkSpacer :contentMax="700" :marginMin="16" :marginMax="32">
<FormSuspense :p="init">
<MkInfo>{{ i18n.ts.proxyAccountDescription }}</MkInfo>
<MkKeyValue>
diff --git a/packages/frontend/src/pages/admin/queue.chart.chart.vue b/packages/frontend/src/pages/admin/queue.chart.chart.vue
index 1a1f6a9db4..9bc0eee212 100644
--- a/packages/frontend/src/pages/admin/queue.chart.chart.vue
+++ b/packages/frontend/src/pages/admin/queue.chart.chart.vue
@@ -132,7 +132,3 @@ defineExpose({
pushData,
});
</script>
-
-<style lang="scss" scoped>
-
-</style>
diff --git a/packages/frontend/src/pages/admin/queue.chart.vue b/packages/frontend/src/pages/admin/queue.chart.vue
index 100d1ea545..8e6856fddd 100644
--- a/packages/frontend/src/pages/admin/queue.chart.vue
+++ b/packages/frontend/src/pages/admin/queue.chart.vue
@@ -1,35 +1,35 @@
<template>
-<div class="pumxzjhg _gaps">
+<div class="_gaps">
<div :class="$style.status">
- <div class="item _panel"><div class="label">Process</div>{{ number(activeSincePrevTick) }}</div>
- <div class="item _panel"><div class="label">Active</div>{{ number(active) }}</div>
- <div class="item _panel"><div class="label">Waiting</div>{{ number(waiting) }}</div>
- <div class="item _panel"><div class="label">Delayed</div>{{ number(delayed) }}</div>
+ <div :class="$style.statusItem" class="_panel"><div :class="$style.statusLabel">Process</div>{{ number(activeSincePrevTick) }}</div>
+ <div :class="$style.statusItem" class="_panel"><div :class="$style.statusLabel">Active</div>{{ number(active) }}</div>
+ <div :class="$style.statusItem" class="_panel"><div :class="$style.statusLabel">Waiting</div>{{ number(waiting) }}</div>
+ <div :class="$style.statusItem" class="_panel"><div :class="$style.statusLabel">Delayed</div>{{ number(delayed) }}</div>
</div>
- <div class="charts">
- <div class="chart">
- <div class="title">Process</div>
+ <div :class="$style.charts">
+ <div :class="$style.chart">
+ <div :class="$style.chartTitle">Process</div>
<XChart ref="chartProcess" type="process"/>
</div>
- <div class="chart">
- <div class="title">Active</div>
+ <div :class="$style.chart">
+ <div :class="$style.chartTitle">Active</div>
<XChart ref="chartActive" type="active"/>
</div>
- <div class="chart">
- <div class="title">Delayed</div>
+ <div :class="$style.chart">
+ <div :class="$style.chartTitle">Delayed</div>
<XChart ref="chartDelayed" type="delayed"/>
</div>
- <div class="chart">
- <div class="title">Waiting</div>
+ <div :class="$style.chart">
+ <div :class="$style.chartTitle">Waiting</div>
<XChart ref="chartWaiting" type="waiting"/>
</div>
</div>
- <MkFolder :default-open="true" :max-height="250">
+ <MkFolder :defaultOpen="true" :max-height="250">
<template #icon><i class="ti ti-alert-triangle"></i></template>
<template #label>Errored instances</template>
<template #suffix>({{ number(jobs.reduce((a, b) => a + b[1], 0)) }} jobs)</template>
-
- <div :class="$style.jobs">
+
+ <div>
<div v-if="jobs.length > 0">
<div v-for="job in jobs" :key="job[0]">
<MkA :to="`/instance-info/${job[0]}`" behavior="window">{{ job[0] }}</MkA>
@@ -47,11 +47,11 @@ import { markRaw, onMounted, onUnmounted, ref } from 'vue';
import XChart from './queue.chart.chart.vue';
import number from '@/filters/number';
import * as os from '@/os';
-import { stream } from '@/stream';
+import { useStream } from '@/stream';
import { i18n } from '@/i18n';
import MkFolder from '@/components/MkFolder.vue';
-const connection = markRaw(stream.useChannel('queueStats'));
+const connection = markRaw(useStream().useChannel('queueStats'));
const activeSincePrevTick = ref(0);
const active = ref(0);
@@ -118,45 +118,36 @@ onUnmounted(() => {
});
</script>
-<style lang="scss" scoped>
-.pumxzjhg {
- > .charts {
- display: grid;
- grid-template-columns: 1fr 1fr;
- gap: 10px;
+<style lang="scss" module>
+.charts {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 10px;
+}
- > .chart {
- min-width: 0;
- padding: 16px;
- background: var(--panel);
- border-radius: var(--radius);
+.chart {
+ min-width: 0;
+ padding: 16px;
+ background: var(--panel);
+ border-radius: var(--radius);
+}
- > .title {
- margin-bottom: 8px;
- }
- }
- }
+.chartTitle {
+ margin-bottom: 8px;
}
-</style>
-<style lang="scss" module>
.status {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
grid-gap: 10px;
+}
- &:global {
- > .item {
- padding: 12px 16px;
-
- > .label {
- font-size: 80%;
- opacity: 0.6;
- }
- }
- }
+.statusItem {
+ padding: 12px 16px;
}
-.jobs {
+.statusLabel {
+ font-size: 80%;
+ opacity: 0.6;
}
</style>
diff --git a/packages/frontend/src/pages/admin/queue.vue b/packages/frontend/src/pages/admin/queue.vue
index 509d329eb1..1282a4b49f 100644
--- a/packages/frontend/src/pages/admin/queue.vue
+++ b/packages/frontend/src/pages/admin/queue.vue
@@ -1,7 +1,7 @@
<template>
<MkStickyContainer>
<template #header><XHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
- <MkSpacer :content-max="800">
+ <MkSpacer :contentMax="800">
<XQueue v-if="tab === 'deliver'" domain="deliver"/>
<XQueue v-else-if="tab === 'inbox'" domain="inbox"/>
<br>
diff --git a/packages/frontend/src/pages/admin/relays.vue b/packages/frontend/src/pages/admin/relays.vue
index 7ebcdfc583..119439c958 100644
--- a/packages/frontend/src/pages/admin/relays.vue
+++ b/packages/frontend/src/pages/admin/relays.vue
@@ -1,14 +1,14 @@
<template>
<MkStickyContainer>
<template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
- <MkSpacer :content-max="800">
+ <MkSpacer :contentMax="800">
<div class="_gaps">
<div v-for="relay in relays" :key="relay.inbox" class="relaycxt _panel" style="padding: 16px;">
<div>{{ relay.inbox }}</div>
- <div class="status">
- <i v-if="relay.status === 'accepted'" class="ti ti-check icon accepted"></i>
- <i v-else-if="relay.status === 'rejected'" class="ti ti-ban icon rejected"></i>
- <i v-else class="ti ti-clock icon requesting"></i>
+ <div style="margin: 8px 0;">
+ <i v-if="relay.status === 'accepted'" class="ti ti-check" :class="$style.icon" style="color: var(--success);"></i>
+ <i v-else-if="relay.status === 'rejected'" class="ti ti-ban" :class="$style.icon" style="color: var(--error);"></i>
+ <i v-else class="ti ti-clock" :class="$style.icon"></i>
<span>{{ i18n.t(`_relayStatus.${relay.status}`) }}</span>
</div>
<MkButton class="button" inline danger @click="remove(relay.inbox)"><i class="ti ti-trash"></i> {{ i18n.ts.remove }}</MkButton>
@@ -83,23 +83,9 @@ definePageMetadata({
});
</script>
-<style lang="scss" scoped>
-.relaycxt {
- > .status {
- margin: 8px 0;
-
- > .icon {
- width: 1em;
- margin-right: 0.75em;
-
- &.accepted {
- color: var(--success);
- }
-
- &.rejected {
- color: var(--error);
- }
- }
- }
+<style lang="scss" module>
+.icon {
+ width: 1em;
+ margin-right: 0.75em;
}
</style>
diff --git a/packages/frontend/src/pages/admin/roles.edit.vue b/packages/frontend/src/pages/admin/roles.edit.vue
index c211ef2f05..c7a34ac77b 100644
--- a/packages/frontend/src/pages/admin/roles.edit.vue
+++ b/packages/frontend/src/pages/admin/roles.edit.vue
@@ -2,12 +2,12 @@
<div>
<MkStickyContainer>
<template #header><XHeader :tabs="headerTabs"/></template>
- <MkSpacer :content-max="600" :margin-min="16" :margin-max="32">
+ <MkSpacer :contentMax="600" :marginMin="16" :marginMax="32">
<XEditor v-if="data" v-model="data"/>
</MkSpacer>
<template #footer>
<div :class="$style.footer">
- <MkSpacer :content-max="600" :margin-min="16" :margin-max="16">
+ <MkSpacer :contentMax="600" :marginMin="16" :marginMax="16">
<MkButton primary rounded @click="save"><i class="ti ti-check"></i> {{ i18n.ts.save }}</MkButton>
</MkSpacer>
</div>
diff --git a/packages/frontend/src/pages/admin/roles.editor.vue b/packages/frontend/src/pages/admin/roles.editor.vue
index 49942c87ce..a1fa9d2932 100644
--- a/packages/frontend/src/pages/admin/roles.editor.vue
+++ b/packages/frontend/src/pages/admin/roles.editor.vue
@@ -36,7 +36,7 @@
<option value="conditional">{{ i18n.ts._role.conditional }}</option>
</MkSelect>
- <MkFolder v-if="role.target === 'conditional'" default-open>
+ <MkFolder v-if="role.target === 'conditional'" defaultOpen>
<template #label>{{ i18n.ts._role.condition }}</template>
<div class="_gaps">
<RolesEditorFormula v-model="role.condFormula"/>
@@ -81,11 +81,11 @@
<MkSwitch v-model="role.policies.rateLimitFactor.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts._role.useBaseValue }}</template>
</MkSwitch>
- <MkRange :model-value="role.policies.rateLimitFactor.value * 100" :min="0" :max="400" :step="10" :text-converter="(v) => `${v}%`" @update:model-value="v => role.policies.rateLimitFactor.value = (v / 100)">
+ <MkRange :modelValue="role.policies.rateLimitFactor.value * 100" :min="0" :max="400" :step="10" :textConverter="(v) => `${v}%`" @update:modelValue="v => role.policies.rateLimitFactor.value = (v / 100)">
<template #label>{{ i18n.ts._role._options.rateLimitFactor }}</template>
<template #caption>{{ i18n.ts._role._options.descriptionOfRateLimitFactor }}</template>
</MkRange>
- <MkRange v-model="role.policies.rateLimitFactor.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 : ''">
+ <MkRange v-model="role.policies.rateLimitFactor.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''">
<template #label>{{ i18n.ts._role.priority }}</template>
</MkRange>
</div>
@@ -105,7 +105,7 @@
<MkSwitch v-model="role.policies.gtlAvailable.value" :disabled="role.policies.gtlAvailable.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts.enable }}</template>
</MkSwitch>
- <MkRange v-model="role.policies.gtlAvailable.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 : ''">
+ <MkRange v-model="role.policies.gtlAvailable.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''">
<template #label>{{ i18n.ts._role.priority }}</template>
</MkRange>
</div>
@@ -125,7 +125,7 @@
<MkSwitch v-model="role.policies.ltlAvailable.value" :disabled="role.policies.ltlAvailable.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts.enable }}</template>
</MkSwitch>
- <MkRange v-model="role.policies.ltlAvailable.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 : ''">
+ <MkRange v-model="role.policies.ltlAvailable.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''">
<template #label>{{ i18n.ts._role.priority }}</template>
</MkRange>
</div>
@@ -145,7 +145,7 @@
<MkSwitch v-model="role.policies.canPublicNote.value" :disabled="role.policies.canPublicNote.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts.enable }}</template>
</MkSwitch>
- <MkRange v-model="role.policies.canPublicNote.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 : ''">
+ <MkRange v-model="role.policies.canPublicNote.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''">
<template #label>{{ i18n.ts._role.priority }}</template>
</MkRange>
</div>
@@ -165,7 +165,7 @@
<MkSwitch v-model="role.policies.canInvite.value" :disabled="role.policies.canInvite.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts.enable }}</template>
</MkSwitch>
- <MkRange v-model="role.policies.canInvite.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 : ''">
+ <MkRange v-model="role.policies.canInvite.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''">
<template #label>{{ i18n.ts._role.priority }}</template>
</MkRange>
</div>
@@ -185,7 +185,7 @@
<MkSwitch v-model="role.policies.canManageCustomEmojis.value" :disabled="role.policies.canManageCustomEmojis.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts.enable }}</template>
</MkSwitch>
- <MkRange v-model="role.policies.canManageCustomEmojis.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 : ''">
+ <MkRange v-model="role.policies.canManageCustomEmojis.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''">
<template #label>{{ i18n.ts._role.priority }}</template>
</MkRange>
</div>
@@ -205,7 +205,7 @@
<MkSwitch v-model="role.policies.canSearchNotes.value" :disabled="role.policies.canSearchNotes.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts.enable }}</template>
</MkSwitch>
- <MkRange v-model="role.policies.canSearchNotes.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 : ''">
+ <MkRange v-model="role.policies.canSearchNotes.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''">
<template #label>{{ i18n.ts._role.priority }}</template>
</MkRange>
</div>
@@ -225,7 +225,7 @@
<MkInput v-model="role.policies.driveCapacityMb.value" :disabled="role.policies.driveCapacityMb.useDefault" type="number" :readonly="readonly">
<template #suffix>MB</template>
</MkInput>
- <MkRange v-model="role.policies.driveCapacityMb.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 : ''">
+ <MkRange v-model="role.policies.driveCapacityMb.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''">
<template #label>{{ i18n.ts._role.priority }}</template>
</MkRange>
</div>
@@ -245,7 +245,7 @@
<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 : ''">
+ <MkRange v-model="role.policies.alwaysMarkNsfw.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''">
<template #label>{{ i18n.ts._role.priority }}</template>
</MkRange>
</div>
@@ -264,7 +264,7 @@
</MkSwitch>
<MkInput v-model="role.policies.pinLimit.value" :disabled="role.policies.pinLimit.useDefault" type="number" :readonly="readonly">
</MkInput>
- <MkRange v-model="role.policies.pinLimit.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 : ''">
+ <MkRange v-model="role.policies.pinLimit.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''">
<template #label>{{ i18n.ts._role.priority }}</template>
</MkRange>
</div>
@@ -283,7 +283,7 @@
</MkSwitch>
<MkInput v-model="role.policies.antennaLimit.value" :disabled="role.policies.antennaLimit.useDefault" type="number" :readonly="readonly">
</MkInput>
- <MkRange v-model="role.policies.antennaLimit.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 : ''">
+ <MkRange v-model="role.policies.antennaLimit.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''">
<template #label>{{ i18n.ts._role.priority }}</template>
</MkRange>
</div>
@@ -303,7 +303,7 @@
<MkInput v-model="role.policies.wordMuteLimit.value" :disabled="role.policies.wordMuteLimit.useDefault" type="number" :readonly="readonly">
<template #suffix>chars</template>
</MkInput>
- <MkRange v-model="role.policies.wordMuteLimit.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 : ''">
+ <MkRange v-model="role.policies.wordMuteLimit.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''">
<template #label>{{ i18n.ts._role.priority }}</template>
</MkRange>
</div>
@@ -322,7 +322,7 @@
</MkSwitch>
<MkInput v-model="role.policies.webhookLimit.value" :disabled="role.policies.webhookLimit.useDefault" type="number" :readonly="readonly">
</MkInput>
- <MkRange v-model="role.policies.webhookLimit.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 : ''">
+ <MkRange v-model="role.policies.webhookLimit.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''">
<template #label>{{ i18n.ts._role.priority }}</template>
</MkRange>
</div>
@@ -341,7 +341,7 @@
</MkSwitch>
<MkInput v-model="role.policies.clipLimit.value" :disabled="role.policies.clipLimit.useDefault" type="number" :readonly="readonly">
</MkInput>
- <MkRange v-model="role.policies.clipLimit.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 : ''">
+ <MkRange v-model="role.policies.clipLimit.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''">
<template #label>{{ i18n.ts._role.priority }}</template>
</MkRange>
</div>
@@ -360,7 +360,7 @@
</MkSwitch>
<MkInput v-model="role.policies.noteEachClipsLimit.value" :disabled="role.policies.noteEachClipsLimit.useDefault" type="number" :readonly="readonly">
</MkInput>
- <MkRange v-model="role.policies.noteEachClipsLimit.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 : ''">
+ <MkRange v-model="role.policies.noteEachClipsLimit.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''">
<template #label>{{ i18n.ts._role.priority }}</template>
</MkRange>
</div>
@@ -379,7 +379,7 @@
</MkSwitch>
<MkInput v-model="role.policies.userListLimit.value" :disabled="role.policies.userListLimit.useDefault" type="number" :readonly="readonly">
</MkInput>
- <MkRange v-model="role.policies.userListLimit.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 : ''">
+ <MkRange v-model="role.policies.userListLimit.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''">
<template #label>{{ i18n.ts._role.priority }}</template>
</MkRange>
</div>
@@ -398,7 +398,7 @@
</MkSwitch>
<MkInput v-model="role.policies.userEachUserListsLimit.value" :disabled="role.policies.userEachUserListsLimit.useDefault" type="number" :readonly="readonly">
</MkInput>
- <MkRange v-model="role.policies.userEachUserListsLimit.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 : ''">
+ <MkRange v-model="role.policies.userEachUserListsLimit.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''">
<template #label>{{ i18n.ts._role.priority }}</template>
</MkRange>
</div>
@@ -418,7 +418,7 @@
<MkSwitch v-model="role.policies.canHideAds.value" :disabled="role.policies.canHideAds.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts.enable }}</template>
</MkSwitch>
- <MkRange v-model="role.policies.canHideAds.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 : ''">
+ <MkRange v-model="role.policies.canHideAds.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''">
<template #label>{{ i18n.ts._role.priority }}</template>
</MkRange>
</div>
diff --git a/packages/frontend/src/pages/admin/roles.role.vue b/packages/frontend/src/pages/admin/roles.role.vue
index 6eac902577..4ed6abf200 100644
--- a/packages/frontend/src/pages/admin/roles.role.vue
+++ b/packages/frontend/src/pages/admin/roles.role.vue
@@ -2,7 +2,7 @@
<div>
<MkStickyContainer>
<template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
- <MkSpacer :content-max="700">
+ <MkSpacer :contentMax="700">
<div class="_gaps">
<div class="_buttons">
<MkButton primary rounded @click="edit"><i class="ti ti-pencil"></i> {{ i18n.ts.edit }}</MkButton>
@@ -11,9 +11,9 @@
<MkFolder>
<template #icon><i class="ti ti-info-circle"></i></template>
<template #label>{{ i18n.ts.info }}</template>
- <XEditor :model-value="role" readonly/>
+ <XEditor :modelValue="role" readonly/>
</MkFolder>
- <MkFolder v-if="role.target === 'manual'" default-open>
+ <MkFolder v-if="role.target === 'manual'" defaultOpen>
<template #icon><i class="ti ti-users"></i></template>
<template #label>{{ i18n.ts.users }}</template>
<template #suffix>{{ role.usersCount }}</template>
diff --git a/packages/frontend/src/pages/admin/roles.vue b/packages/frontend/src/pages/admin/roles.vue
index e8dbe1c5f0..6634d9cba9 100644
--- a/packages/frontend/src/pages/admin/roles.vue
+++ b/packages/frontend/src/pages/admin/roles.vue
@@ -2,7 +2,7 @@
<div>
<MkStickyContainer>
<template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
- <MkSpacer :content-max="700">
+ <MkSpacer :contentMax="700">
<div class="_gaps">
<MkFolder>
<template #label>{{ i18n.ts._role.baseRole }}</template>
@@ -14,7 +14,7 @@
<MkFolder v-if="matchQuery([i18n.ts._role._options.rateLimitFactor, 'rateLimitFactor'])">
<template #label>{{ i18n.ts._role._options.rateLimitFactor }}</template>
<template #suffix>{{ Math.floor(policies.rateLimitFactor * 100) }}%</template>
- <MkRange :model-value="policies.rateLimitFactor * 100" :min="30" :max="300" :step="10" :text-converter="(v) => `${v}%`" @update:model-value="v => policies.rateLimitFactor = (v / 100)">
+ <MkRange :modelValue="policies.rateLimitFactor * 100" :min="30" :max="300" :step="10" :textConverter="(v) => `${v}%`" @update:modelValue="v => policies.rateLimitFactor = (v / 100)">
<template #caption>{{ i18n.ts._role._options.descriptionOfRateLimitFactor }}</template>
</MkRange>
</MkFolder>
@@ -156,13 +156,13 @@
<MkFoldableSection>
<template #header>Manual roles</template>
<div class="_gaps_s">
- <MkRolePreview v-for="role in roles.filter(x => x.target === 'manual')" :key="role.id" :role="role" :for-moderation="true"/>
+ <MkRolePreview v-for="role in roles.filter(x => x.target === 'manual')" :key="role.id" :role="role" :forModeration="true"/>
</div>
</MkFoldableSection>
<MkFoldableSection>
<template #header>Conditional roles</template>
<div class="_gaps_s">
- <MkRolePreview v-for="role in roles.filter(x => x.target === 'conditional')" :key="role.id" :role="role" :for-moderation="true"/>
+ <MkRolePreview v-for="role in roles.filter(x => x.target === 'conditional')" :key="role.id" :role="role" :forModeration="true"/>
</div>
</MkFoldableSection>
</div>
diff --git a/packages/frontend/src/pages/admin/security.vue b/packages/frontend/src/pages/admin/security.vue
index cd8ef9e68b..efb9f81f25 100644
--- a/packages/frontend/src/pages/admin/security.vue
+++ b/packages/frontend/src/pages/admin/security.vue
@@ -1,7 +1,7 @@
<template>
<MkStickyContainer>
<template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
- <MkSpacer :content-max="700" :margin-min="16" :margin-max="32">
+ <MkSpacer :contentMax="700" :marginMin="16" :marginMax="32">
<FormSuspense :p="init">
<div class="_gaps_m">
<MkFolder>
@@ -33,7 +33,7 @@
<option value="remote">{{ i18n.ts.remoteOnly }}</option>
</MkRadios>
- <MkRange v-model="sensitiveMediaDetectionSensitivity" :min="0" :max="4" :step="1" :text-converter="(v) => `${v + 1}`">
+ <MkRange v-model="sensitiveMediaDetectionSensitivity" :min="0" :max="4" :step="1" :textConverter="(v) => `${v + 1}`">
<template #label>{{ i18n.ts._sensitiveMediaDetection.sensitivity }}</template>
<template #caption>{{ i18n.ts._sensitiveMediaDetection.sensitivityDescription }}</template>
</MkRange>
@@ -65,7 +65,7 @@
<div class="_gaps_m">
<span>{{ i18n.ts.activeEmailValidationDescription }}</span>
- <MkSwitch v-model="enableActiveEmailValidation" @update:model-value="save">
+ <MkSwitch v-model="enableActiveEmailValidation" @update:modelValue="save">
<template #label>Enable</template>
</MkSwitch>
</div>
@@ -77,7 +77,7 @@
<template v-else #suffix>Disabled</template>
<div class="_gaps_m">
- <MkSwitch v-model="enableIpLogging" @update:model-value="save">
+ <MkSwitch v-model="enableIpLogging" @update:modelValue="save">
<template #label>Enable</template>
</MkSwitch>
</div>
diff --git a/packages/frontend/src/pages/admin/server-rules.vue b/packages/frontend/src/pages/admin/server-rules.vue
index 85781c0bd0..fdba4f464e 100644
--- a/packages/frontend/src/pages/admin/server-rules.vue
+++ b/packages/frontend/src/pages/admin/server-rules.vue
@@ -2,13 +2,13 @@
<div>
<MkStickyContainer>
<template #header><XHeader :tabs="headerTabs"/></template>
- <MkSpacer :content-max="700" :margin-min="16" :margin-max="32">
+ <MkSpacer :contentMax="700" :marginMin="16" :marginMax="32">
<div class="_gaps_m">
<div>{{ i18n.ts._serverRules.description }}</div>
<Sortable
v-model="serverRules"
class="_gaps_m"
- :item-key="(_, i) => i"
+ :itemKey="(_, i) => i"
:animation="150"
:handle="'.' + $style.itemHandle"
@start="e => e.item.classList.add('active')"
@@ -27,7 +27,7 @@
</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>
+ <MkButton primary rounded @click="save"><i class="ti ti-check"></i> {{ i18n.ts.save }}</MkButton>
</div>
</div>
</MkSpacer>
diff --git a/packages/frontend/src/pages/admin/settings.vue b/packages/frontend/src/pages/admin/settings.vue
index 7ec3c381f3..39d5ae8607 100644
--- a/packages/frontend/src/pages/admin/settings.vue
+++ b/packages/frontend/src/pages/admin/settings.vue
@@ -2,7 +2,7 @@
<div>
<MkStickyContainer>
<template #header><XHeader :tabs="headerTabs"/></template>
- <MkSpacer :content-max="700" :margin-min="16" :margin-max="32">
+ <MkSpacer :contentMax="700" :marginMin="16" :marginMax="32">
<FormSuspense :p="init">
<div class="_gaps_m">
<MkInput v-model="name">
@@ -13,7 +13,7 @@
<template #label>{{ i18n.ts.instanceDescription }}</template>
</MkTextarea>
- <FormSplit :min-width="300">
+ <FormSplit :minWidth="300">
<MkInput v-model="maintainerName">
<template #label>{{ i18n.ts.maintainerName }}</template>
</MkInput>
@@ -30,18 +30,6 @@
</MkTextarea>
<FormSection>
- <div class="_gaps_s">
- <MkSwitch v-model="enableChartsForRemoteUser">
- <template #label>{{ i18n.ts.enableChartsForRemoteUser }}</template>
- </MkSwitch>
-
- <MkSwitch v-model="enableChartsForFederatedInstances">
- <template #label>{{ i18n.ts.enableChartsForFederatedInstances }}</template>
- </MkSwitch>
- </div>
- </FormSection>
-
- <FormSection>
<template #label>{{ i18n.ts.theme }}</template>
<div class="_gaps_m">
@@ -128,7 +116,7 @@
</MkSpacer>
<template #footer>
<div :class="$style.footer">
- <MkSpacer :content-max="700" :margin-min="16" :margin-max="16">
+ <MkSpacer :contentMax="700" :marginMin="16" :marginMax="16">
<MkButton primary rounded @click="save"><i class="ti ti-check"></i> {{ i18n.ts.save }}</MkButton>
</MkSpacer>
</div>
@@ -166,8 +154,6 @@ let defaultDarkTheme: any = $ref(null);
let pinnedUsers: string = $ref('');
let cacheRemoteFiles: boolean = $ref(false);
let enableServiceWorker: boolean = $ref(false);
-let enableChartsForRemoteUser: boolean = $ref(false);
-let enableChartsForFederatedInstances: boolean = $ref(false);
let swPublicKey: any = $ref(null);
let swPrivateKey: any = $ref(null);
let deeplAuthKey: string = $ref('');
@@ -188,8 +174,6 @@ async function init() {
pinnedUsers = meta.pinnedUsers.join('\n');
cacheRemoteFiles = meta.cacheRemoteFiles;
enableServiceWorker = meta.enableServiceWorker;
- enableChartsForRemoteUser = meta.enableChartsForRemoteUser;
- enableChartsForFederatedInstances = meta.enableChartsForFederatedInstances;
swPublicKey = meta.swPublickey;
swPrivateKey = meta.swPrivateKey;
deeplAuthKey = meta.deeplAuthKey;
@@ -211,8 +195,6 @@ function save() {
pinnedUsers: pinnedUsers.split('\n'),
cacheRemoteFiles,
enableServiceWorker,
- enableChartsForRemoteUser,
- enableChartsForFederatedInstances,
swPublicKey,
swPrivateKey,
deeplAuthKey,
diff --git a/packages/frontend/src/pages/admin/users.vue b/packages/frontend/src/pages/admin/users.vue
index 819ced826d..1af661a475 100644
--- a/packages/frontend/src/pages/admin/users.vue
+++ b/packages/frontend/src/pages/admin/users.vue
@@ -2,7 +2,7 @@
<div>
<MkStickyContainer>
<template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
- <MkSpacer :content-max="900">
+ <MkSpacer :contentMax="900">
<div class="_gaps">
<div :class="$style.inputs">
<MkSelect v-model="sort" style="flex: 1;">
@@ -28,11 +28,11 @@
</MkSelect>
</div>
<div :class="$style.inputs">
- <MkInput v-model="searchUsername" style="flex: 1;" type="text" :spellcheck="false" @update:model-value="$refs.users.reload()">
+ <MkInput v-model="searchUsername" style="flex: 1;" type="text" :spellcheck="false" @update:modelValue="$refs.users.reload()">
<template #prefix>@</template>
<template #label>{{ i18n.ts.username }}</template>
</MkInput>
- <MkInput v-model="searchHost" style="flex: 1;" type="text" :spellcheck="false" :disabled="pagination.params.origin === 'local'" @update:model-value="$refs.users.reload()">
+ <MkInput v-model="searchHost" style="flex: 1;" type="text" :spellcheck="false" :disabled="pagination.params.origin === 'local'" @update:modelValue="$refs.users.reload()">
<template #prefix>@</template>
<template #label>{{ i18n.ts.host }}</template>
</MkInput>
diff --git a/packages/frontend/src/pages/ads.vue b/packages/frontend/src/pages/ads.vue
index 728ef3c0b1..4cf2e4b2e5 100644
--- a/packages/frontend/src/pages/ads.vue
+++ b/packages/frontend/src/pages/ads.vue
@@ -2,7 +2,7 @@
<MkStickyContainer>
<template #header><MkPageHeader/></template>
- <MkSpacer :content-max="500">
+ <MkSpacer :contentMax="500">
<div class="_gaps">
<MkAd v-for="ad in instance.ads" :key="ad.id" :specify="ad"/>
</div>
diff --git a/packages/frontend/src/pages/announcements.vue b/packages/frontend/src/pages/announcements.vue
index 16a0ee8373..3dfb9e5554 100644
--- a/packages/frontend/src/pages/announcements.vue
+++ b/packages/frontend/src/pages/announcements.vue
@@ -1,7 +1,7 @@
<template>
<MkStickyContainer>
<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
- <MkSpacer :content-max="800">
+ <MkSpacer :contentMax="800">
<MkPagination v-slot="{items}" :pagination="pagination" class="ruryvtyk _gaps_m">
<section v-for="(announcement, i) in items" :key="announcement.id" class="announcement _panel">
<div class="header"><span v-if="$i && !announcement.isRead">🆕 </span>{{ announcement.title }}</div>
diff --git a/packages/frontend/src/pages/antenna-timeline.vue b/packages/frontend/src/pages/antenna-timeline.vue
index 62e8178af1..a22714791f 100644
--- a/packages/frontend/src/pages/antenna-timeline.vue
+++ b/packages/frontend/src/pages/antenna-timeline.vue
@@ -1,19 +1,20 @@
<template>
<MkStickyContainer>
<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
- <div ref="rootEl" v-hotkey.global="keymap" class="tqmomfks">
- <div v-if="queue > 0" class="new"><button class="_buttonPrimary" @click="top()">{{ i18n.ts.newNoteRecived }}</button></div>
- <div class="tl">
- <MkTimeline
- ref="tlEl" :key="antennaId"
- class="tl"
- src="antenna"
- :antenna="antennaId"
- :sound="true"
- @queue="queueUpdated"
- />
+ <MkSpacer :contentMax="800">
+ <div ref="rootEl" v-hotkey.global="keymap">
+ <div v-if="queue > 0" :class="$style.new"><button class="_buttonPrimary" :class="$style.newButton" @click="top()">{{ i18n.ts.newNoteRecived }}</button></div>
+ <div :class="$style.tl">
+ <MkTimeline
+ ref="tlEl" :key="antennaId"
+ src="antenna"
+ :antenna="antennaId"
+ :sound="true"
+ @queue="queueUpdated"
+ />
+ </div>
</div>
- </div>
+ </MkSpacer>
</MkStickyContainer>
</template>
@@ -89,36 +90,29 @@ definePageMetadata(computed(() => antenna ? {
} : null));
</script>
-<style lang="scss" scoped>
-.tqmomfks {
- padding: var(--margin);
+<style lang="scss" module>
+.new {
+ position: sticky;
+ top: calc(var(--stickyTop, 0px) + 16px);
+ z-index: 1000;
+ width: 100%;
+ margin: calc(-0.675em - 8px) 0;
- > .new {
- position: sticky;
- top: calc(var(--stickyTop, 0px) + 16px);
- z-index: 1000;
- width: 100%;
- margin: calc(-0.675em - 8px - var(--margin)) 0 calc(-0.675em - 8px);
-
- > button {
- display: block;
- margin: var(--margin) auto 0 auto;
- padding: 8px 16px;
- border-radius: 32px;
- }
+ &:first-child {
+ margin-top: calc(-0.675em - 8px - var(--margin));
}
+}
- > .tl {
- background: var(--bg);
- border-radius: var(--radius);
- overflow: clip;
- }
+.newButton {
+ display: block;
+ margin: var(--margin) auto 0 auto;
+ padding: 8px 16px;
+ border-radius: 32px;
}
-@container (min-width: 800px) {
- .tqmomfks {
- max-width: 800px;
- margin: 0 auto;
- }
+.tl {
+ background: var(--bg);
+ border-radius: var(--radius);
+ overflow: clip;
}
</style>
diff --git a/packages/frontend/src/pages/api-console.vue b/packages/frontend/src/pages/api-console.vue
index 7d2828e91d..3a3cb3d7d3 100644
--- a/packages/frontend/src/pages/api-console.vue
+++ b/packages/frontend/src/pages/api-console.vue
@@ -1,10 +1,10 @@
<template>
<MkStickyContainer>
<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
- <MkSpacer :content-max="700">
+ <MkSpacer :contentMax="700">
<div class="_gaps_m">
<div class="_gaps_m">
- <MkInput v-model="endpoint" :datalist="endpoints" @update:model-value="onEndpointChange()">
+ <MkInput v-model="endpoint" :datalist="endpoints" @update:modelValue="onEndpointChange()">
<template #label>Endpoint</template>
</MkInput>
<MkTextarea v-model="body" code>
diff --git a/packages/frontend/src/pages/auth.vue b/packages/frontend/src/pages/auth.vue
index 2f40e7ded6..54e76805bf 100644
--- a/packages/frontend/src/pages/auth.vue
+++ b/packages/frontend/src/pages/auth.vue
@@ -1,7 +1,7 @@
<template>
<MkStickyContainer>
<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
- <MkSpacer :content-max="500">
+ <MkSpacer :contentMax="500">
<div v-if="state == 'fetch-session-error'">
<p>{{ i18n.ts.somethingHappened }}</p>
</div>
diff --git a/packages/frontend/src/pages/channel-editor.vue b/packages/frontend/src/pages/channel-editor.vue
index a74ab40473..0a358a141b 100644
--- a/packages/frontend/src/pages/channel-editor.vue
+++ b/packages/frontend/src/pages/channel-editor.vue
@@ -1,7 +1,7 @@
<template>
<MkStickyContainer>
<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
- <MkSpacer :content-max="700">
+ <MkSpacer :contentMax="700">
<div v-if="channelId == null || channel != null" class="_gaps_m">
<MkInput v-model="name">
<template #label>{{ i18n.ts.name }}</template>
@@ -23,7 +23,7 @@
</div>
</div>
- <MkFolder :default-open="true">
+ <MkFolder :defaultOpen="true">
<template #label>{{ i18n.ts.pinnedNotes }}</template>
<div class="_gaps">
@@ -31,7 +31,7 @@
<Sortable
v-model="pinnedNotes"
- item-key="id"
+ itemKey="id"
:handle="'.' + $style.pinnedNoteHandle"
:animation="150"
>
diff --git a/packages/frontend/src/pages/channel.vue b/packages/frontend/src/pages/channel.vue
index 9aa564a7da..bcc0fc6860 100644
--- a/packages/frontend/src/pages/channel.vue
+++ b/packages/frontend/src/pages/channel.vue
@@ -1,10 +1,12 @@
<template>
<MkStickyContainer>
<template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
- <MkSpacer :content-max="700" :class="$style.main">
+ <MkSpacer :contentMax="700" :class="$style.main">
<div v-if="channel && tab === 'overview'" class="_gaps">
<div class="_panel" :class="$style.bannerContainer">
<XChannelFollowButton :channel="channel" :full="true" :class="$style.subscribe"/>
+ <MkButton v-if="favorited" v-tooltip="i18n.ts.unfavorite" asLike class="button" rounded primary :class="$style.favorite" @click="unfavorite()"><i class="ti ti-star"></i></MkButton>
+ <MkButton v-else v-tooltip="i18n.ts.favorite" asLike class="button" rounded :class="$style.favorite" @click="favorite()"><i class="ti ti-star"></i></MkButton>
<div :style="{ backgroundImage: channel.bannerUrl ? `url(${channel.bannerUrl})` : null }" :class="$style.banner">
<div :class="$style.bannerStatus">
<div><i class="ti ti-users ti-fw"></i><I18n :src="i18n.ts._channel.usersCount" tag="span" style="margin-left: 4px;"><template #n><b>{{ channel.usersCount }}</b></template></I18n></div>
@@ -13,13 +15,10 @@
<div :class="$style.bannerFade"></div>
</div>
<div v-if="channel.description" :class="$style.description">
- <Mfm :text="channel.description" :is-note="false" :i="$i"/>
+ <Mfm :text="channel.description" :isNote="false" :i="$i"/>
</div>
</div>
- <MkButton v-if="favorited" v-tooltip="i18n.ts.unfavorite" as-like class="button" rounded primary @click="unfavorite()"><i class="ti ti-star"></i></MkButton>
- <MkButton v-else v-tooltip="i18n.ts.favorite" as-like class="button" rounded @click="favorite()"><i class="ti ti-star"></i></MkButton>
-
<MkFoldableSection>
<template #header><i class="ti ti-pin ti-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.pinnedNotes }}</template>
<div v-if="channel.pinnedNotes.length > 0" class="_gaps">
@@ -52,7 +51,7 @@
</MkSpacer>
<template #footer>
<div :class="$style.footer">
- <MkSpacer :content-max="700" :margin-min="16" :margin-max="16">
+ <MkSpacer :contentMax="700" :marginMin="16" :marginMax="16">
<div class="_buttonsCenter">
<MkButton inline rounded primary gradate @click="openPostForm()"><i class="ti ti-pencil"></i> {{ i18n.ts.postToTheChannel }}</MkButton>
</div>
@@ -229,6 +228,13 @@ definePageMetadata(computed(() => channel ? {
left: 16px;
}
+.favorite {
+ position: absolute;
+ z-index: 1;
+ top: 16px;
+ right: 16px;
+}
+
.banner {
position: relative;
height: 200px;
diff --git a/packages/frontend/src/pages/channels.vue b/packages/frontend/src/pages/channels.vue
index e670cdd864..0c4ccc1bcd 100644
--- a/packages/frontend/src/pages/channels.vue
+++ b/packages/frontend/src/pages/channels.vue
@@ -1,13 +1,13 @@
<template>
<MkStickyContainer>
<template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
- <MkSpacer :content-max="700">
+ <MkSpacer :contentMax="700">
<div v-if="tab === 'search'">
<div class="_gaps">
<MkInput v-model="searchQuery" :large="true" :autofocus="true" type="search">
<template #prefix><i class="ti ti-search"></i></template>
</MkInput>
- <MkRadios v-model="searchType" @update:model-value="search()">
+ <MkRadios v-model="searchType" @update:modelValue="search()">
<option value="nameAndDescription">{{ i18n.ts._channel.nameAndDescription }}</option>
<option value="nameOnly">{{ i18n.ts._channel.nameOnly }}</option>
</MkRadios>
diff --git a/packages/frontend/src/pages/clicker.vue b/packages/frontend/src/pages/clicker.vue
index 24eae32e13..69ecc9e772 100644
--- a/packages/frontend/src/pages/clicker.vue
+++ b/packages/frontend/src/pages/clicker.vue
@@ -1,7 +1,7 @@
<template>
<MkStickyContainer>
<template #header><MkPageHeader/></template>
- <MkSpacer :content-max="800">
+ <MkSpacer :contentMax="800">
<MkClickerGame/>
</MkSpacer>
</MkStickyContainer>
diff --git a/packages/frontend/src/pages/clip.vue b/packages/frontend/src/pages/clip.vue
index e3ac3f4c9b..d5313099da 100644
--- a/packages/frontend/src/pages/clip.vue
+++ b/packages/frontend/src/pages/clip.vue
@@ -1,16 +1,16 @@
<template>
<MkStickyContainer>
<template #header><MkPageHeader :actions="headerActions"/></template>
- <MkSpacer :content-max="800">
- <div v-if="clip">
- <div class="okzinsic _panel">
- <div v-if="clip.description" class="description">
- <Mfm :text="clip.description" :is-note="false" :i="$i"/>
+ <MkSpacer :contentMax="800">
+ <div v-if="clip" class="_gaps">
+ <div class="_panel">
+ <div v-if="clip.description" :class="$style.description">
+ <Mfm :text="clip.description" :isNote="false" :i="$i"/>
</div>
- <MkButton v-if="favorited" v-tooltip="i18n.ts.unfavorite" as-like class="button" rounded primary @click="unfavorite()"><i class="ti ti-heart"></i><span v-if="clip.favoritedCount > 0" style="margin-left: 6px;">{{ clip.favoritedCount }}</span></MkButton>
- <MkButton v-else v-tooltip="i18n.ts.favorite" as-like class="button" rounded @click="favorite()"><i class="ti ti-heart"></i><span v-if="clip.favoritedCount > 0" style="margin-left: 6px;">{{ clip.favoritedCount }}</span></MkButton>
- <div class="user">
- <MkAvatar :user="clip.user" class="avatar" indicator link preview/> <MkUserName :user="clip.user" :nowrap="false"/>
+ <MkButton v-if="favorited" v-tooltip="i18n.ts.unfavorite" asLike rounded primary @click="unfavorite()"><i class="ti ti-heart"></i><span v-if="clip.favoritedCount > 0" style="margin-left: 6px;">{{ clip.favoritedCount }}</span></MkButton>
+ <MkButton v-else v-tooltip="i18n.ts.favorite" asLike rounded @click="favorite()"><i class="ti ti-heart"></i><span v-if="clip.favoritedCount > 0" style="margin-left: 6px;">{{ clip.favoritedCount }}</span></MkButton>
+ <div :class="$style.user">
+ <MkAvatar :user="clip.user" :class="$style.avatar" indicator link preview/> <MkUserName :user="clip.user" :nowrap="false"/>
</div>
</div>
@@ -147,25 +147,20 @@ definePageMetadata(computed(() => clip ? {
} : null));
</script>
-<style lang="scss" scoped>
-.okzinsic {
- position: relative;
- margin-bottom: var(--margin);
-
- > .description {
- padding: 16px;
- }
+<style lang="scss" module>
+.description {
+ padding: 16px;
+}
- > .user {
- $height: 32px;
- padding: 16px;
- border-top: solid 0.5px var(--divider);
- line-height: $height;
+.user {
+ --height: 32px;
+ padding: 16px;
+ border-top: solid 0.5px var(--divider);
+ line-height: var(--height);
+}
- > .avatar {
- width: $height;
- height: $height;
- }
- }
+.avatar {
+ width: var(--height);
+ height: var(--height);
}
</style>
diff --git a/packages/frontend/src/pages/custom-emojis-manager.vue b/packages/frontend/src/pages/custom-emojis-manager.vue
index 3f13f0787d..3da6a0d9cb 100644
--- a/packages/frontend/src/pages/custom-emojis-manager.vue
+++ b/packages/frontend/src/pages/custom-emojis-manager.vue
@@ -2,7 +2,7 @@
<div>
<MkStickyContainer>
<template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
- <MkSpacer :content-max="900">
+ <MkSpacer :contentMax="900">
<div class="ogwlenmc">
<div v-if="tab === 'local'" class="local">
<MkInput v-model="query" :debounce="true" type="search">
@@ -123,15 +123,14 @@ const toggleSelect = (emoji) => {
};
const add = async (ev: MouseEvent) => {
- const files = await selectFiles(ev.currentTarget ?? ev.target, null);
-
- const promise = Promise.all(files.map(file => os.api('admin/emoji/add', {
- fileId: file.id,
- })));
- promise.then(() => {
- emojisPaginationComponent.value.reload();
- });
- os.promiseDialog(promise);
+ os.popup(defineAsyncComponent(() => import('./emoji-edit-dialog.vue')), {
+ }, {
+ done: result => {
+ if (result.created) {
+ emojisPaginationComponent.value.prepend(result.created);
+ }
+ },
+ }, 'closed');
};
const edit = (emoji) => {
diff --git a/packages/frontend/src/pages/emoji-edit-dialog.vue b/packages/frontend/src/pages/emoji-edit-dialog.vue
index 84bc153b71..3208c92738 100644
--- a/packages/frontend/src/pages/emoji-edit-dialog.vue
+++ b/packages/frontend/src/pages/emoji-edit-dialog.vue
@@ -1,84 +1,171 @@
<template>
<MkModalWindow
ref="dialog"
- :width="370"
- :with-ok-button="true"
- @close="$refs.dialog.close()"
+ :width="400"
+ @close="dialog.close()"
@closed="$emit('closed')"
- @ok="ok()"
>
- <template #header>:{{ emoji.name }}:</template>
+ <template v-if="emoji" #header>:{{ emoji.name }}:</template>
+ <template v-else #header>New emoji</template>
- <MkSpacer :margin-min="20" :margin-max="28">
- <div class="yigymqpb _gaps_m">
- <img :src="`/emoji/${emoji.name}.webp`" class="img"/>
- <MkInput v-model="name">
- <template #label>{{ i18n.ts.name }}</template>
- </MkInput>
- <MkInput v-model="category" :datalist="customEmojiCategories">
- <template #label>{{ i18n.ts.category }}</template>
- </MkInput>
- <MkInput v-model="aliases">
- <template #label>{{ i18n.ts.tags }}</template>
- <template #caption>{{ i18n.ts.setMultipleBySeparatingWithSpace }}</template>
- </MkInput>
- <MkInput v-model="license">
- <template #label>{{ i18n.ts.license }}</template>
- </MkInput>
- <MkButton danger @click="del()"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton>
+ <div>
+ <MkSpacer :marginMin="20" :marginMax="28">
+ <div class="_gaps_m">
+ <div v-if="imgUrl != null" :class="$style.imgs">
+ <div style="background: #000;" :class="$style.imgContainer">
+ <img :src="imgUrl" :class="$style.img"/>
+ </div>
+ <div style="background: #222;" :class="$style.imgContainer">
+ <img :src="imgUrl" :class="$style.img"/>
+ </div>
+ <div style="background: #ddd;" :class="$style.imgContainer">
+ <img :src="imgUrl" :class="$style.img"/>
+ </div>
+ <div style="background: #fff;" :class="$style.imgContainer">
+ <img :src="imgUrl" :class="$style.img"/>
+ </div>
+ </div>
+ <MkButton rounded style="margin: 0 auto;" @click="changeImage">{{ i18n.ts.selectFile }}</MkButton>
+ <MkInput v-model="name">
+ <template #label>{{ i18n.ts.name }}</template>
+ </MkInput>
+ <MkInput v-model="category" :datalist="customEmojiCategories">
+ <template #label>{{ i18n.ts.category }}</template>
+ </MkInput>
+ <MkInput v-model="aliases">
+ <template #label>{{ i18n.ts.tags }}</template>
+ <template #caption>{{ i18n.ts.setMultipleBySeparatingWithSpace }}</template>
+ </MkInput>
+ <MkInput v-model="license">
+ <template #label>{{ i18n.ts.license }}</template>
+ </MkInput>
+ <MkFolder>
+ <template #label>{{ i18n.ts.rolesThatCanBeUsedThisEmojiAsReaction }}</template>
+ <template #suffix>{{ rolesThatCanBeUsedThisEmojiAsReaction.length === 0 ? i18n.ts.all : rolesThatCanBeUsedThisEmojiAsReaction.length }}</template>
+
+ <div class="_gaps">
+ <MkButton rounded @click="addRole"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton>
+
+ <div v-for="role in rolesThatCanBeUsedThisEmojiAsReaction" :key="role.id" :class="$style.roleItem">
+ <MkRolePreview :class="$style.role" :role="role" :forModeration="true" :detailed="false" style="pointer-events: none;"/>
+ <button v-if="role.target === 'manual'" class="_button" :class="$style.roleUnassign" @click="removeRole(role, $event)"><i class="ti ti-x"></i></button>
+ <button v-else class="_button" :class="$style.roleUnassign" disabled><i class="ti ti-ban"></i></button>
+ </div>
+
+ <MkInfo>{{ i18n.ts.rolesThatCanBeUsedThisEmojiAsReactionEmptyDescription }}</MkInfo>
+ <MkInfo warn>{{ i18n.ts.rolesThatCanBeUsedThisEmojiAsReactionPublicRoleWarn }}</MkInfo>
+ </div>
+ </MkFolder>
+ <MkSwitch v-model="isSensitive">isSensitive</MkSwitch>
+ <MkSwitch v-model="localOnly">{{ i18n.ts.localOnly }}</MkSwitch>
+ <MkButton danger @click="del()"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton>
+ </div>
+ </MkSpacer>
+ <div :class="$style.footer">
+ <MkButton primary rounded style="margin: 0 auto;" @click="done"><i class="ti ti-check"></i> {{ props.emoji ? i18n.ts.update : i18n.ts.create }}</MkButton>
</div>
- </MkSpacer>
+ </div>
</MkModalWindow>
</template>
<script lang="ts" setup>
-import { } from 'vue';
+import { computed, watch } from 'vue';
import MkModalWindow from '@/components/MkModalWindow.vue';
import MkButton from '@/components/MkButton.vue';
import MkInput from '@/components/MkInput.vue';
+import MkInfo from '@/components/MkInfo.vue';
+import MkFolder from '@/components/MkFolder.vue';
import * as os from '@/os';
import { i18n } from '@/i18n';
import { customEmojiCategories } from '@/custom-emojis';
+import MkSwitch from '@/components/MkSwitch.vue';
+import { selectFile, selectFiles } from '@/scripts/select-file';
+import MkRolePreview from '@/components/MkRolePreview.vue';
const props = defineProps<{
- emoji: any,
+ emoji?: any,
}>();
let dialog = $ref(null);
-let name: string = $ref(props.emoji.name);
-let category: string = $ref(props.emoji.category);
-let aliases: string = $ref(props.emoji.aliases.join(' '));
-let license: string = $ref(props.emoji.license ?? '');
+let name: string = $ref(props.emoji ? props.emoji.name : '');
+let category: string = $ref(props.emoji ? props.emoji.category : '');
+let aliases: string = $ref(props.emoji ? props.emoji.aliases.join(' ') : '');
+let license: string = $ref(props.emoji ? (props.emoji.license ?? '') : '');
+let isSensitive = $ref(props.emoji ? props.emoji.isSensitive : false);
+let localOnly = $ref(props.emoji ? props.emoji.localOnly : false);
+let roleIdsThatCanBeUsedThisEmojiAsReaction = $ref(props.emoji ? props.emoji.roleIdsThatCanBeUsedThisEmojiAsReaction : []);
+let rolesThatCanBeUsedThisEmojiAsReaction = $ref([]);
+let file = $ref();
+
+watch($$(roleIdsThatCanBeUsedThisEmojiAsReaction), async () => {
+ rolesThatCanBeUsedThisEmojiAsReaction = (await Promise.all(roleIdsThatCanBeUsedThisEmojiAsReaction.map((id) => os.api('admin/roles/show', { roleId: id }).catch(() => null)))).filter(x => x != null);
+}, { immediate: true });
+
+const imgUrl = computed(() => file ? file.url : props.emoji ? `/emoji/${props.emoji.name}.webp` : null);
const emit = defineEmits<{
- (ev: 'done', v: { deleted?: boolean, updated?: any }): void,
+ (ev: 'done', v: { deleted?: boolean; updated?: any; created?: any }): void,
(ev: 'closed'): void
}>();
-function ok() {
- update();
+async function changeImage(ev) {
+ file = await selectFile(ev.currentTarget ?? ev.target, null);
}
-async function update() {
- await os.apiWithDialog('admin/emoji/update', {
- id: props.emoji.id,
+async function addRole() {
+ const roles = await os.api('admin/roles/list');
+ const currentRoleIds = rolesThatCanBeUsedThisEmojiAsReaction.map(x => x.id);
+
+ const { canceled, result: role } = await os.select({
+ items: roles.filter(r => r.isPublic).filter(r => !currentRoleIds.includes(r.id)).map(r => ({ text: r.name, value: r })),
+ });
+ if (canceled) return;
+
+ rolesThatCanBeUsedThisEmojiAsReaction.push(role);
+}
+
+async function removeRole(role, ev) {
+ rolesThatCanBeUsedThisEmojiAsReaction = rolesThatCanBeUsedThisEmojiAsReaction.filter(x => x.id !== role.id);
+}
+
+async function done() {
+ const params = {
name,
- category,
- aliases: aliases.split(' '),
+ category: category === '' ? null : category,
+ aliases: aliases.split(' ').filter(x => x !== ''),
license: license === '' ? null : license,
- });
+ isSensitive,
+ localOnly,
+ roleIdsThatCanBeUsedThisEmojiAsReaction: rolesThatCanBeUsedThisEmojiAsReaction.map(x => x.id),
+ };
+
+ if (file) {
+ params.fileId = file.id;
+ }
- emit('done', {
- updated: {
+ if (props.emoji) {
+ await os.apiWithDialog('admin/emoji/update', {
id: props.emoji.id,
- name,
- category,
- aliases: aliases.split(' '),
- license: license === '' ? null : license,
- },
- });
+ ...params,
+ });
+
+ emit('done', {
+ updated: {
+ id: props.emoji.id,
+ ...params,
+ },
+ });
+
+ dialog.close();
+ } else {
+ const created = await os.apiWithDialog('admin/emoji/add', params);
+
+ emit('done', {
+ created: created,
+ });
- dialog.close();
+ dialog.close();
+ }
}
async function del() {
@@ -99,12 +186,48 @@ async function del() {
}
</script>
-<style lang="scss" scoped>
-.yigymqpb {
- > .img {
- display: block;
- height: 64px;
- margin: 0 auto;
- }
+<style lang="scss" module>
+.imgs {
+ display: flex;
+ gap: 8px;
+ flex-wrap: wrap;
+ justify-content: center;
+}
+
+.imgContainer {
+ padding: 8px;
+ border-radius: 6px;
+}
+
+.img {
+ display: block;
+ height: 64px;
+ width: 64px;
+ object-fit: contain;
+}
+
+.roleItem {
+ display: flex;
+}
+
+.role {
+ flex: 1;
+}
+
+.roleUnassign {
+ width: 32px;
+ height: 32px;
+ margin-left: 8px;
+ align-self: center;
+}
+
+.footer {
+ position: sticky;
+ bottom: 0;
+ left: 0;
+ padding: 12px;
+ border-top: solid 0.5px var(--divider);
+ -webkit-backdrop-filter: var(--blur, blur(15px));
+ backdrop-filter: var(--blur, blur(15px));
}
</style>
diff --git a/packages/frontend/src/pages/emojis.emoji.vue b/packages/frontend/src/pages/emojis.emoji.vue
index bdd21b29ee..e9fab6a313 100644
--- a/packages/frontend/src/pages/emojis.emoji.vue
+++ b/packages/frontend/src/pages/emojis.emoji.vue
@@ -1,9 +1,9 @@
<template>
-<button class="zuvgdzyu _button" @click="menu">
- <img :src="emoji.url" class="img" loading="lazy"/>
- <div class="body">
- <div class="name _monospace">{{ emoji.name }}</div>
- <div class="info">{{ emoji.aliases.join(' ') }}</div>
+<button class="_button" :class="$style.root" @click="menu">
+ <img :src="emoji.url" :class="$style.img" loading="lazy"/>
+ <div :class="$style.body">
+ <div :class="$style.name" class="_monospace">{{ emoji.name }}</div>
+ <div :class="$style.info">{{ emoji.aliases.join(' ') }}</div>
</div>
</button>
</template>
@@ -49,8 +49,8 @@ function menu(ev) {
}
</script>
-<style lang="scss" scoped>
-.zuvgdzyu {
+<style lang="scss" module>
+.root {
display: flex;
align-items: center;
padding: 12px;
@@ -61,29 +61,29 @@ function menu(ev) {
&:hover {
border-color: var(--accent);
}
+}
- > .img {
- width: 42px;
- height: 42px;
- object-fit: contain;
- }
+.img {
+ width: 42px;
+ height: 42px;
+ object-fit: contain;
+}
- > .body {
- padding: 0 0 0 8px;
- white-space: nowrap;
- overflow: hidden;
+.body {
+ padding: 0 0 0 8px;
+ white-space: nowrap;
+ overflow: hidden;
+}
- > .name {
- text-overflow: ellipsis;
- overflow: hidden;
- }
+.name {
+ text-overflow: ellipsis;
+ overflow: hidden;
+}
- > .info {
- opacity: 0.5;
- font-size: 0.9em;
- text-overflow: ellipsis;
- overflow: hidden;
- }
- }
+.info {
+ opacity: 0.5;
+ font-size: 0.9em;
+ text-overflow: ellipsis;
+ overflow: hidden;
}
</style>
diff --git a/packages/frontend/src/pages/explore.featured.vue b/packages/frontend/src/pages/explore.featured.vue
index a972ae04ec..5c71313738 100644
--- a/packages/frontend/src/pages/explore.featured.vue
+++ b/packages/frontend/src/pages/explore.featured.vue
@@ -1,5 +1,5 @@
<template>
-<MkSpacer :content-max="800">
+<MkSpacer :contentMax="800">
<MkTab v-model="tab" style="margin-bottom: var(--margin);">
<option value="notes">{{ i18n.ts.notes }}</option>
<option value="polls">{{ i18n.ts.poll }}</option>
diff --git a/packages/frontend/src/pages/explore.roles.vue b/packages/frontend/src/pages/explore.roles.vue
index 6ac469f7ba..c855d79f45 100644
--- a/packages/frontend/src/pages/explore.roles.vue
+++ b/packages/frontend/src/pages/explore.roles.vue
@@ -1,7 +1,7 @@
<template>
-<MkSpacer :content-max="700">
+<MkSpacer :contentMax="700">
<div class="_gaps_s">
- <MkRolePreview v-for="role in roles" :key="role.id" :role="role" :for-moderation="false"/>
+ <MkRolePreview v-for="role in roles" :key="role.id" :role="role" :forModeration="false"/>
</div>
</MkSpacer>
</template>
diff --git a/packages/frontend/src/pages/explore.users.vue b/packages/frontend/src/pages/explore.users.vue
index f9c833dd29..785dbaa343 100644
--- a/packages/frontend/src/pages/explore.users.vue
+++ b/packages/frontend/src/pages/explore.users.vue
@@ -1,24 +1,24 @@
<template>
-<MkSpacer :content-max="1200">
+<MkSpacer :contentMax="1200">
<MkTab v-model="origin" style="margin-bottom: var(--margin);">
<option value="local">{{ i18n.ts.local }}</option>
<option value="remote">{{ i18n.ts.remote }}</option>
</MkTab>
<div v-if="origin === 'local'">
<template v-if="tag == null">
- <MkFoldableSection class="_margin" persist-key="explore-pinned-users">
+ <MkFoldableSection class="_margin" persistKey="explore-pinned-users">
<template #header><i class="ti ti-bookmark ti-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.pinnedUsers }}</template>
<MkUserList :pagination="pinnedUsers"/>
</MkFoldableSection>
- <MkFoldableSection class="_margin" persist-key="explore-popular-users">
+ <MkFoldableSection class="_margin" persistKey="explore-popular-users">
<template #header><i class="ti ti-chart-line ti-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.popularUsers }}</template>
<MkUserList :pagination="popularUsers"/>
</MkFoldableSection>
- <MkFoldableSection class="_margin" persist-key="explore-recently-updated-users">
+ <MkFoldableSection class="_margin" persistKey="explore-recently-updated-users">
<template #header><i class="ti ti-message ti-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.recentlyUpdatedUsers }}</template>
<MkUserList :pagination="recentlyUpdatedUsers"/>
</MkFoldableSection>
- <MkFoldableSection class="_margin" persist-key="explore-recently-registered-users">
+ <MkFoldableSection class="_margin" persistKey="explore-recently-registered-users">
<template #header><i class="ti ti-plus ti-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.recentlyRegisteredUsers }}</template>
<MkUserList :pagination="recentlyRegisteredUsers"/>
</MkFoldableSection>
diff --git a/packages/frontend/src/pages/favorites.vue b/packages/frontend/src/pages/favorites.vue
index 0dc9b9dc8f..460bf65d1e 100644
--- a/packages/frontend/src/pages/favorites.vue
+++ b/packages/frontend/src/pages/favorites.vue
@@ -1,7 +1,7 @@
<template>
<MkStickyContainer>
<template #header><MkPageHeader/></template>
- <MkSpacer :content-max="800">
+ <MkSpacer :contentMax="800">
<MkPagination :pagination="pagination">
<template #empty>
<div class="_fullinfo">
@@ -11,7 +11,7 @@
</template>
<template #default="{ items }">
- <MkDateSeparatedList v-slot="{ item }" :items="items" :direction="'down'" :no-gap="false" :ad="false">
+ <MkDateSeparatedList v-slot="{ item }" :items="items" :direction="'down'" :noGap="false" :ad="false">
<MkNote :key="item.id" :note="item.note" :class="$style.note"/>
</MkDateSeparatedList>
</template>
diff --git a/packages/frontend/src/pages/flash/flash-edit.vue b/packages/frontend/src/pages/flash/flash-edit.vue
index 816825e5b6..6a16cd1c4a 100644
--- a/packages/frontend/src/pages/flash/flash-edit.vue
+++ b/packages/frontend/src/pages/flash/flash-edit.vue
@@ -1,7 +1,7 @@
<template>
<MkStickyContainer>
<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
- <MkSpacer :content-max="700">
+ <MkSpacer :contentMax="700">
<div class="_gaps">
<MkInput v-model="title">
<template #label>{{ i18n.ts._play.title }}</template>
@@ -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.2
+const PRESET_DEFAULT = `/// @ 0.13.3
var name = ""
@@ -51,7 +51,7 @@ Ui:render([
])
`;
-const PRESET_OMIKUJI = `/// @ 0.13.2
+const PRESET_OMIKUJI = `/// @ 0.13.3
// ユーザーごとに日替わりのおみくじのプリセット
// 選択肢
@@ -94,7 +94,7 @@ Ui:render([
])
`;
-const PRESET_SHUFFLE = `/// @ 0.13.2
+const PRESET_SHUFFLE = `/// @ 0.13.3
// 巻き戻し可能な文字シャッフルのプリセット
let string = "ペペロンチーノ"
@@ -173,7 +173,7 @@ var cursor = 0
do()
`;
-const PRESET_QUIZ = `/// @ 0.13.2
+const PRESET_QUIZ = `/// @ 0.13.3
let title = '地理クイズ'
let qas = [{
@@ -286,7 +286,7 @@ qaEls.push(Ui:C:container({
Ui:render(qaEls)
`;
-const PRESET_TIMELINE = `/// @ 0.13.2
+const PRESET_TIMELINE = `/// @ 0.13.3
// APIリクエストを行いローカルタイムラインを表示するプリセット
@fetch() {
@@ -442,7 +442,3 @@ definePageMetadata(computed(() => flash ? {
title: i18n.ts._play.new,
}));
</script>
-
-<style lang="scss" scoped>
-
-</style>
diff --git a/packages/frontend/src/pages/flash/flash-index.vue b/packages/frontend/src/pages/flash/flash-index.vue
index f1dca5f240..1f933c2346 100644
--- a/packages/frontend/src/pages/flash/flash-index.vue
+++ b/packages/frontend/src/pages/flash/flash-index.vue
@@ -1,30 +1,30 @@
<template>
<MkStickyContainer>
<template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
- <MkSpacer :content-max="700">
- <div v-if="tab === 'featured'" class="">
+ <MkSpacer :contentMax="700">
+ <div v-if="tab === 'featured'">
<MkPagination v-slot="{items}" :pagination="featuredFlashsPagination">
<div class="_gaps_s">
- <MkFlashPreview v-for="flash in items" :key="flash.id" class="" :flash="flash"/>
+ <MkFlashPreview v-for="flash in items" :key="flash.id" :flash="flash"/>
</div>
</MkPagination>
</div>
- <div v-else-if="tab === 'my'" class="my">
+ <div v-else-if="tab === 'my'">
<div class="_gaps">
- <MkButton class="new" gradate rounded style="margin: 0 auto;" @click="create()"><i class="ti ti-plus"></i></MkButton>
+ <MkButton gradate rounded style="margin: 0 auto;" @click="create()"><i class="ti ti-plus"></i></MkButton>
<MkPagination v-slot="{items}" :pagination="myFlashsPagination">
<div class="_gaps_s">
- <MkFlashPreview v-for="flash in items" :key="flash.id" class="" :flash="flash"/>
+ <MkFlashPreview v-for="flash in items" :key="flash.id" :flash="flash"/>
</div>
</MkPagination>
</div>
</div>
- <div v-else-if="tab === 'liked'" class="">
+ <div v-else-if="tab === 'liked'">
<MkPagination v-slot="{items}" :pagination="likedFlashsPagination">
<div class="_gaps_s">
- <MkFlashPreview v-for="like in items" :key="like.flash.id" class="" :flash="like.flash"/>
+ <MkFlashPreview v-for="like in items" :key="like.flash.id" :flash="like.flash"/>
</div>
</MkPagination>
</div>
@@ -87,21 +87,3 @@ definePageMetadata(computed(() => ({
icon: 'ti ti-player-play',
})));
</script>
-
-<style lang="scss" scoped>
-.rknalgpo {
- &.my .ckltabjg:first-child {
- margin-top: 16px;
- }
-
- .ckltabjg:not(:last-child) {
- margin-bottom: 8px;
- }
-
- @media (min-width: 500px) {
- .ckltabjg:not(:last-child) {
- margin-bottom: 16px;
- }
- }
-}
-</style>
diff --git a/packages/frontend/src/pages/flash/flash.vue b/packages/frontend/src/pages/flash/flash.vue
index 961ef4b751..2e1532b9f3 100644
--- a/packages/frontend/src/pages/flash/flash.vue
+++ b/packages/frontend/src/pages/flash/flash.vue
@@ -1,7 +1,7 @@
<template>
<MkStickyContainer>
<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
- <MkSpacer :content-max="700">
+ <MkSpacer :contentMax="700">
<Transition :name="defaultStore.state.animation ? 'fade' : ''" mode="out-in">
<div v-if="flash" :key="flash.id">
<Transition :name="defaultStore.state.animation ? 'zoom' : ''" mode="out-in">
@@ -10,8 +10,8 @@
<MkAsUi v-if="root" :component="root" :components="components"/>
</div>
<div class="actions _panel">
- <MkButton v-if="flash.isLiked" v-tooltip="i18n.ts.unlike" as-like class="button" rounded primary @click="unlike()"><i class="ti ti-heart"></i><span v-if="flash.likedCount > 0" style="margin-left: 6px;">{{ flash.likedCount }}</span></MkButton>
- <MkButton v-else v-tooltip="i18n.ts.like" as-like class="button" rounded @click="like()"><i class="ti ti-heart"></i><span v-if="flash.likedCount > 0" style="margin-left: 6px;">{{ flash.likedCount }}</span></MkButton>
+ <MkButton v-if="flash.isLiked" v-tooltip="i18n.ts.unlike" asLike class="button" rounded primary @click="unlike()"><i class="ti ti-heart"></i><span v-if="flash.likedCount > 0" style="margin-left: 6px;">{{ flash.likedCount }}</span></MkButton>
+ <MkButton v-else v-tooltip="i18n.ts.like" asLike class="button" rounded @click="like()"><i class="ti ti-heart"></i><span v-if="flash.likedCount > 0" style="margin-left: 6px;">{{ flash.likedCount }}</span></MkButton>
<MkButton v-tooltip="i18n.ts.shareWithNote" class="button" rounded @click="shareWithNote"><i class="ti ti-repeat ti-fw"></i></MkButton>
<MkButton v-tooltip="i18n.ts.share" class="button" rounded @click="share"><i class="ti ti-share ti-fw"></i></MkButton>
</div>
@@ -27,7 +27,7 @@
</div>
</div>
</Transition>
- <MkFolder :default-open="false" :max-height="280" class="_margin">
+ <MkFolder :defaultOpen="false" :max-height="280" class="_margin">
<template #icon><i class="ti ti-code"></i></template>
<template #label>{{ i18n.ts._play.viewSource }}</template>
diff --git a/packages/frontend/src/pages/follow-requests.vue b/packages/frontend/src/pages/follow-requests.vue
index a51d1c78a4..1452942a1e 100644
--- a/packages/frontend/src/pages/follow-requests.vue
+++ b/packages/frontend/src/pages/follow-requests.vue
@@ -1,7 +1,7 @@
<template>
<MkStickyContainer>
<template #header><MkPageHeader/></template>
- <MkSpacer :content-max="800">
+ <MkSpacer :contentMax="800">
<MkPagination ref="paginationComponent" :pagination="pagination">
<template #empty>
<div class="_fullinfo">
diff --git a/packages/frontend/src/pages/follow.vue b/packages/frontend/src/pages/follow.vue
index 828246d678..d14b663364 100644
--- a/packages/frontend/src/pages/follow.vue
+++ b/packages/frontend/src/pages/follow.vue
@@ -1,5 +1,5 @@
<template>
-<div class="mk-follow-page">
+<div>
</div>
</template>
diff --git a/packages/frontend/src/pages/gallery/edit.vue b/packages/frontend/src/pages/gallery/edit.vue
index cafcee0c33..f381636a78 100644
--- a/packages/frontend/src/pages/gallery/edit.vue
+++ b/packages/frontend/src/pages/gallery/edit.vue
@@ -1,7 +1,7 @@
<template>
<MkStickyContainer>
<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
- <MkSpacer :content-max="800" :margin-min="16" :margin-max="32">
+ <MkSpacer :contentMax="800" :marginMin="16" :marginMax="32">
<FormSuspense :p="init" class="_gaps">
<MkInput v-model="title">
<template #label>{{ i18n.ts.title }}</template>
diff --git a/packages/frontend/src/pages/gallery/index.vue b/packages/frontend/src/pages/gallery/index.vue
index fc9cc7ae9e..3c9c21a2ff 100644
--- a/packages/frontend/src/pages/gallery/index.vue
+++ b/packages/frontend/src/pages/gallery/index.vue
@@ -1,21 +1,21 @@
<template>
<MkStickyContainer>
<template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
- <MkSpacer :content-max="1400">
+ <MkSpacer :contentMax="1400">
<div class="_root">
<div v-if="tab === 'explore'">
<MkFoldableSection class="_margin">
<template #header><i class="ti ti-clock"></i>{{ i18n.ts.recentPosts }}</template>
- <MkPagination v-slot="{items}" :pagination="recentPostsPagination" :disable-auto-load="true">
- <div class="vfpdbgtk">
+ <MkPagination v-slot="{items}" :pagination="recentPostsPagination" :disableAutoLoad="true">
+ <div :class="$style.items">
<MkGalleryPostPreview v-for="post in items" :key="post.id" :post="post" class="post"/>
</div>
</MkPagination>
</MkFoldableSection>
<MkFoldableSection class="_margin">
<template #header><i class="ti ti-comet"></i>{{ i18n.ts.popularPosts }}</template>
- <MkPagination v-slot="{items}" :pagination="popularPostsPagination" :disable-auto-load="true">
- <div class="vfpdbgtk">
+ <MkPagination v-slot="{items}" :pagination="popularPostsPagination" :disableAutoLoad="true">
+ <div :class="$style.items">
<MkGalleryPostPreview v-for="post in items" :key="post.id" :post="post" class="post"/>
</div>
</MkPagination>
@@ -23,7 +23,7 @@
</div>
<div v-else-if="tab === 'liked'">
<MkPagination v-slot="{items}" :pagination="likedPostsPagination">
- <div class="vfpdbgtk">
+ <div :class="$style.items">
<MkGalleryPostPreview v-for="like in items" :key="like.id" :post="like.post" class="post"/>
</div>
</MkPagination>
@@ -31,7 +31,7 @@
<div v-else-if="tab === 'my'">
<MkA to="/gallery/new" class="_link" style="margin: 16px;"><i class="ti ti-plus"></i> {{ i18n.ts.postToGallery }}</MkA>
<MkPagination v-slot="{items}" :pagination="myPostsPagination">
- <div class="vfpdbgtk">
+ <div :class="$style.items">
<MkGalleryPostPreview v-for="post in items" :key="post.id" :post="post" class="post"/>
</div>
</MkPagination>
@@ -119,15 +119,11 @@ definePageMetadata({
});
</script>
-<style lang="scss" scoped>
-.vfpdbgtk {
+<style lang="scss" module>
+.items {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
grid-gap: 12px;
margin: 0 var(--margin);
-
- > .post {
-
- }
}
</style>
diff --git a/packages/frontend/src/pages/gallery/post.vue b/packages/frontend/src/pages/gallery/post.vue
index e0f3c105e1..dfa6c0bac0 100644
--- a/packages/frontend/src/pages/gallery/post.vue
+++ b/packages/frontend/src/pages/gallery/post.vue
@@ -1,7 +1,7 @@
<template>
<MkStickyContainer>
<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
- <MkSpacer :content-max="1000" :margin-min="16" :margin-max="32">
+ <MkSpacer :contentMax="1000" :marginMin="16" :marginMax="32">
<div class="_root">
<Transition :name="defaultStore.state.animation ? 'fade' : ''" mode="out-in">
<div v-if="post" class="rkxwuolj">
diff --git a/packages/frontend/src/pages/instance-info.vue b/packages/frontend/src/pages/instance-info.vue
index ba5fda137a..83997b2555 100644
--- a/packages/frontend/src/pages/instance-info.vue
+++ b/packages/frontend/src/pages/instance-info.vue
@@ -1,7 +1,7 @@
<template>
<MkStickyContainer>
<template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
- <MkSpacer v-if="instance" :content-max="600" :margin-min="16" :margin-max="32">
+ <MkSpacer v-if="instance" :contentMax="600" :marginMin="16" :marginMax="32">
<div v-if="tab === 'overview'" class="_gaps_m">
<div class="fnfelxur">
<img :src="faviconUrl" alt="" class="icon"/>
@@ -29,8 +29,8 @@
<FormSection v-if="iAmModerator">
<template #label>Moderation</template>
<div class="_gaps_s">
- <MkSwitch v-model="suspended" @update:model-value="toggleSuspend">{{ i18n.ts.stopActivityDelivery }}</MkSwitch>
- <MkSwitch v-model="isBlocked" @update:model-value="toggleBlock">{{ i18n.ts.blockThisInstance }}</MkSwitch>
+ <MkSwitch v-model="suspended" @update:modelValue="toggleSuspend">{{ i18n.ts.stopActivityDelivery }}</MkSwitch>
+ <MkSwitch v-model="isBlocked" @update:modelValue="toggleBlock">{{ i18n.ts.blockThisInstance }}</MkSwitch>
<MkButton @click="refreshMetadata"><i class="ti ti-refresh"></i> Refresh metadata</MkButton>
</div>
</FormSection>
diff --git a/packages/frontend/src/pages/list.vue b/packages/frontend/src/pages/list.vue
new file mode 100644
index 0000000000..f92c06d1c5
--- /dev/null
+++ b/packages/frontend/src/pages/list.vue
@@ -0,0 +1,148 @@
+<template>
+<MkStickyContainer>
+ <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+ <MKSpacer v-if="!(typeof error === 'undefined')" :contentMax="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>
+ {{ i18n.ts.nothing }}
+ </p>
+ </div>
+ </MKSpacer>
+ <MkSpacer v-else-if="list" :contentMax="700" :class="$style.main">
+ <div v-if="list" class="members _margin">
+ <div :class="$style.member_text">{{ i18n.ts.members }}</div>
+ <div class="_gaps_s">
+ <div v-for="user in users" :key="user.id" :class="$style.userItem">
+ <MkA :class="$style.userItemBody" :to="`${userPage(user)}`">
+ <MkUserCardMini :user="user"/>
+ </MkA>
+ </div>
+ </div>
+ </div>
+ <MkButton v-if="list.isLiked" v-tooltip="i18n.ts.unlike" inline :class="$style.button" asLike primary @click="unlike()"><i class="ti ti-heart-off"></i><span v-if="list.likedCount > 0" class="count">{{ list.likedCount }}</span></MkButton>
+ <MkButton v-if="!list.isLiked" v-tooltip="i18n.ts.like" inline :class="$style.button" asLike @click="like()"><i class="ti ti-heart"></i><span v-if="1 > 0" class="count">{{ list.likedCount }}</span></MkButton>
+ <MkButton inline @click="create()"><i class="ti ti-download" :class="$style.import"></i>{{ i18n.ts.import }}</MkButton>
+ </MkSpacer>
+</MkStickyContainer>
+</template>
+
+<script lang="ts" setup>
+import { watch, computed } from 'vue';
+import * as os from '@/os';
+import { userPage } from '@/filters/user';
+import { i18n } from '@/i18n';
+import MkUserCardMini from '@/components/MkUserCardMini.vue';
+import MkButton from '@/components/MkButton.vue';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+const props = defineProps<{
+ listId: string;
+}>();
+
+let list = $ref(null);
+let error = $ref();
+let users = $ref([]);
+
+function fetchList(): void {
+ os.api('users/lists/show', {
+ listId: props.listId,
+ forPublic: true,
+ }).then(_list => {
+ list = _list;
+ os.api('users/show', {
+ userIds: list.userIds,
+ }).then(_users => {
+ users = _users;
+ });
+ }).catch(err => {
+ error = err;
+ });
+}
+
+function like() {
+ os.apiWithDialog('users/lists/favorite', {
+ listId: list.id,
+ }).then(() => {
+ list.isLiked = true;
+ list.likedCount++;
+ });
+}
+
+function unlike() {
+ os.apiWithDialog('users/lists/unfavorite', {
+ listId: list.id,
+ }).then(() => {
+ list.isLiked = false;
+ list.likedCount--;
+ });
+}
+
+async function create() {
+ const { canceled, result: name } = await os.inputText({
+ title: i18n.ts.enterListName,
+ });
+ if (canceled) return;
+ await os.apiWithDialog('users/lists/create-from-public', { name: name, listId: list.id });
+}
+
+watch(() => props.listId, fetchList, { immediate: true });
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata(computed(() => list ? {
+ title: list.name,
+ icon: 'ti ti-list',
+} : null));
+</script>
+<style lang="scss" module>
+.main {
+ min-height: calc(100cqh - (var(--stickyTop, 0px) + var(--stickyBottom, 0px)));
+}
+
+.userItem {
+ display: flex;
+}
+
+.userItemBody {
+ flex: 1;
+ min-width: 0;
+ margin-right: 8px;
+
+ &:hover {
+ text-decoration: none;
+ }
+}
+.member_text {
+ margin: 5px;
+}
+
+.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;
+}
+
+.button {
+ margin-right: 10px;
+}
+
+.import {
+ margin-right: 4px;
+}
+</style>
diff --git a/packages/frontend/src/pages/miauth.vue b/packages/frontend/src/pages/miauth.vue
index 8e0624f555..553946cd9e 100644
--- a/packages/frontend/src/pages/miauth.vue
+++ b/packages/frontend/src/pages/miauth.vue
@@ -1,7 +1,7 @@
<template>
<MkStickyContainer>
<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
- <MkSpacer :content-max="800">
+ <MkSpacer :contentMax="800">
<div v-if="$i">
<div v-if="state == 'waiting'">
<MkLoading/>
diff --git a/packages/frontend/src/pages/my-antennas/create.vue b/packages/frontend/src/pages/my-antennas/create.vue
index c35af3e22a..355d18fdb5 100644
--- a/packages/frontend/src/pages/my-antennas/create.vue
+++ b/packages/frontend/src/pages/my-antennas/create.vue
@@ -1,5 +1,5 @@
<template>
-<div class="geegznzt">
+<div>
<XAntenna :antenna="draft" @created="onAntennaCreated"/>
</div>
</template>
@@ -38,7 +38,3 @@ definePageMetadata({
icon: 'ti ti-antenna',
});
</script>
-
-<style lang="scss" scoped>
-
-</style>
diff --git a/packages/frontend/src/pages/my-antennas/edit.vue b/packages/frontend/src/pages/my-antennas/edit.vue
index 913fbde8e9..da9b2de48f 100644
--- a/packages/frontend/src/pages/my-antennas/edit.vue
+++ b/packages/frontend/src/pages/my-antennas/edit.vue
@@ -36,7 +36,3 @@ definePageMetadata({
icon: 'ti ti-antenna',
});
</script>
-
-<style lang="scss" scoped>
-
-</style>
diff --git a/packages/frontend/src/pages/my-antennas/editor.vue b/packages/frontend/src/pages/my-antennas/editor.vue
index 26b7bcc71b..ed92208c42 100644
--- a/packages/frontend/src/pages/my-antennas/editor.vue
+++ b/packages/frontend/src/pages/my-antennas/editor.vue
@@ -1,6 +1,6 @@
<template>
-<MkSpacer :content-max="700">
- <div class="shaynizk">
+<MkSpacer :contentMax="700">
+ <div>
<div class="_gaps_m">
<MkInput v-model="name">
<template #label>{{ i18n.ts.name }}</template>
@@ -33,7 +33,7 @@
<MkSwitch v-model="withFile">{{ i18n.ts.withFileAntenna }}</MkSwitch>
<MkSwitch v-model="notify">{{ i18n.ts.notifyAntenna }}</MkSwitch>
</div>
- <div class="actions">
+ <div :class="$style.actions">
<MkButton inline primary @click="saveAntenna()"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton>
<MkButton v-if="antenna.id != null" inline danger @click="deleteAntenna()"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton>
</div>
@@ -128,12 +128,10 @@ function addUser() {
}
</script>
-<style lang="scss" scoped>
-.shaynizk {
- > .actions {
- margin-top: 16px;
- padding: 24px 0;
- border-top: solid 0.5px var(--divider);
- }
+<style lang="scss" module>
+.actions {
+ margin-top: 16px;
+ padding: 24px 0;
+ border-top: solid 0.5px var(--divider);
}
</style>
diff --git a/packages/frontend/src/pages/my-antennas/index.vue b/packages/frontend/src/pages/my-antennas/index.vue
index f1764b1aad..2ca026b9a1 100644
--- a/packages/frontend/src/pages/my-antennas/index.vue
+++ b/packages/frontend/src/pages/my-antennas/index.vue
@@ -1,18 +1,20 @@
-<template><MkStickyContainer>
+<template>
+<MkStickyContainer>
<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
- <MkSpacer :content-max="700">
- <div class="ieepwinx">
- <MkButton :link="true" to="/my/antennas/create" primary class="add"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton>
+ <MkSpacer :contentMax="700">
+ <div class="ieepwinx">
+ <MkButton :link="true" to="/my/antennas/create" primary class="add"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton>
- <div class="">
- <MkPagination v-slot="{items}" ref="list" :pagination="pagination">
- <MkA v-for="antenna in items" :key="antenna.id" class="ljoevbzj" :to="`/my/antennas/${antenna.id}`">
- <div class="name">{{ antenna.name }}</div>
- </MkA>
- </MkPagination>
+ <div class="">
+ <MkPagination v-slot="{items}" ref="list" :pagination="pagination">
+ <MkA v-for="antenna in items" :key="antenna.id" class="ljoevbzj" :to="`/my/antennas/${antenna.id}`">
+ <div class="name">{{ antenna.name }}</div>
+ </MkA>
+ </MkPagination>
+ </div>
</div>
- </div>
-</MkSpacer></MkStickyContainer>
+ </MkSpacer>
+</MkStickyContainer>
</template>
<script lang="ts" setup>
diff --git a/packages/frontend/src/pages/my-clips/index.vue b/packages/frontend/src/pages/my-clips/index.vue
index ccffa7b563..a769f8ee97 100644
--- a/packages/frontend/src/pages/my-clips/index.vue
+++ b/packages/frontend/src/pages/my-clips/index.vue
@@ -1,7 +1,7 @@
<template>
<MkStickyContainer>
<template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
- <MkSpacer :content-max="700">
+ <MkSpacer :contentMax="700">
<div v-if="tab === 'my'" class="_gaps">
<MkButton primary rounded class="add" @click="create"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton>
diff --git a/packages/frontend/src/pages/my-lists/index.vue b/packages/frontend/src/pages/my-lists/index.vue
index 47437f3e57..cee241c489 100644
--- a/packages/frontend/src/pages/my-lists/index.vue
+++ b/packages/frontend/src/pages/my-lists/index.vue
@@ -1,15 +1,17 @@
<template>
<MkStickyContainer>
<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
- <MkSpacer :content-max="700">
- <div class="qkcjvfiv">
- <MkButton primary class="add" @click="create"><i class="ti ti-plus"></i> {{ i18n.ts.createList }}</MkButton>
+ <MkSpacer :contentMax="700">
+ <div class="_gaps">
+ <MkButton primary rounded style="margin: 0 auto;" @click="create"><i class="ti ti-plus"></i> {{ i18n.ts.createList }}</MkButton>
- <MkPagination v-slot="{items}" ref="pagingComponent" :pagination="pagination" class="lists">
- <MkA v-for="list in items" :key="list.id" class="list _panel" :to="`/my/lists/${ list.id }`">
- <div class="name">{{ list.name }}</div>
- <MkAvatars :user-ids="list.userIds"/>
- </MkA>
+ <MkPagination v-slot="{items}" ref="pagingComponent" :pagination="pagination">
+ <div class="_gaps">
+ <MkA v-for="list in items" :key="list.id" class="_panel" :class="$style.list" :to="`/my/lists/${ list.id }`">
+ <div style="margin-bottom: 4px;">{{ list.name }}</div>
+ <MkAvatars :userIds="list.userIds"/>
+ </MkA>
+ </div>
</MkPagination>
</div>
</MkSpacer>
@@ -58,28 +60,17 @@ definePageMetadata({
});
</script>
-<style lang="scss" scoped>
-.qkcjvfiv {
- > .add {
- margin: 0 auto var(--margin) auto;
- }
-
- > .lists {
- > .list {
- display: block;
- padding: 16px;
- border: solid 1px var(--divider);
- border-radius: 6px;
-
- &:hover {
- border: solid 1px var(--accent);
- text-decoration: none;
- }
+<style lang="scss" module>
+.list {
+ display: block;
+ padding: 16px;
+ border: solid 1px var(--divider);
+ border-radius: 6px;
+ margin-bottom: 8px;
- > .name {
- margin-bottom: 4px;
- }
- }
+ &:hover {
+ border: solid 1px var(--accent);
+ text-decoration: none;
}
}
</style>
diff --git a/packages/frontend/src/pages/my-lists/list.vue b/packages/frontend/src/pages/my-lists/list.vue
index 86201e8e0c..dd431e8dc0 100644
--- a/packages/frontend/src/pages/my-lists/list.vue
+++ b/packages/frontend/src/pages/my-lists/list.vue
@@ -1,35 +1,43 @@
<template>
<MkStickyContainer>
<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
- <MkSpacer :content-max="700" :class="$style.main">
- <div v-if="list" class="members _margin">
- <div class="">{{ i18n.ts.members }}</div>
- <div class="_gaps_s">
- <div v-for="user in users" :key="user.id" :class="$style.userItem">
- <MkA :class="$style.userItemBody" :to="`${userPage(user)}`">
- <MkUserCardMini :user="user"/>
- </MkA>
- <button class="_button" :class="$style.remove" @click="removeUser(user, $event)"><i class="ti ti-x"></i></button>
+ <MkSpacer :contentMax="700" :class="$style.main">
+ <div v-if="list" class="_gaps">
+ <MkFolder>
+ <template #label>{{ i18n.ts.settings }}</template>
+
+ <div class="_gaps">
+ <MkInput v-model="name">
+ <template #label>{{ i18n.ts.name }}</template>
+ </MkInput>
+ <MkSwitch v-model="isPublic">{{ i18n.ts.public }}</MkSwitch>
+ <div class="_buttons">
+ <MkButton rounded primary @click="updateSettings">{{ i18n.ts.save }}</MkButton>
+ <MkButton rounded danger @click="deleteList()">{{ i18n.ts.delete }}</MkButton>
+ </div>
</div>
- </div>
- </div>
- </MkSpacer>
- <template #footer>
- <div :class="$style.footer">
- <MkSpacer :content-max="700" :margin-min="16" :margin-max="16">
- <div class="_buttons">
- <MkButton inline rounded primary @click="addUser()">{{ i18n.ts.addUser }}</MkButton>
- <MkButton inline rounded @click="renameList()">{{ i18n.ts.rename }}</MkButton>
- <MkButton inline rounded danger @click="deleteList()">{{ i18n.ts.delete }}</MkButton>
+ </MkFolder>
+
+ <MkFolder defaultOpen>
+ <template #label>{{ i18n.ts.members }}</template>
+
+ <div class="_gaps_s">
+ <MkButton rounded primary style="margin: 0 auto;" @click="addUser()">{{ i18n.ts.addUser }}</MkButton>
+ <div v-for="user in users" :key="user.id" :class="$style.userItem">
+ <MkA :class="$style.userItemBody" :to="`${userPage(user)}`">
+ <MkUserCardMini :user="user"/>
+ </MkA>
+ <button class="_button" :class="$style.remove" @click="removeUser(user, $event)"><i class="ti ti-x"></i></button>
+ </div>
</div>
- </MkSpacer>
+ </MkFolder>
</div>
- </template>
+ </MkSpacer>
</MkStickyContainer>
</template>
<script lang="ts" setup>
-import { computed, watch } from 'vue';
+import { computed, ref, watch } from 'vue';
import MkButton from '@/components/MkButton.vue';
import * as os from '@/os';
import { mainRouter } from '@/router';
@@ -37,6 +45,9 @@ import { definePageMetadata } from '@/scripts/page-metadata';
import { i18n } from '@/i18n';
import { userPage } from '@/filters/user';
import MkUserCardMini from '@/components/MkUserCardMini.vue';
+import MkSwitch from '@/components/MkSwitch.vue';
+import MkFolder from '@/components/MkFolder.vue';
+import MkInput from '@/components/MkInput.vue';
import { userListsCache } from '@/cache';
const props = defineProps<{
@@ -45,12 +56,17 @@ const props = defineProps<{
let list = $ref(null);
let users = $ref([]);
+const isPublic = ref(false);
+const name = ref('');
function fetchList() {
os.api('users/lists/show', {
listId: props.listId,
}).then(_list => {
list = _list;
+ name.value = list.name;
+ isPublic.value = list.isPublic;
+
os.api('users/show', {
userIds: list.userIds,
}).then(_users => {
@@ -86,23 +102,6 @@ async function removeUser(user, ev) {
}], ev.currentTarget ?? ev.target);
}
-async function renameList() {
- const { canceled, result: name } = await os.inputText({
- title: i18n.ts.enterListName,
- default: list.name,
- });
- if (canceled) return;
-
- await os.api('users/lists/update', {
- listId: list.id,
- name: name,
- });
-
- userListsCache.delete();
-
- list.name = name;
-}
-
async function deleteList() {
const { canceled } = await os.confirm({
type: 'warning',
@@ -117,6 +116,19 @@ async function deleteList() {
mainRouter.push('/my/lists');
}
+async function updateSettings() {
+ await os.apiWithDialog('users/lists/update', {
+ listId: list.id,
+ name: name.value,
+ isPublic: isPublic.value,
+ });
+
+ userListsCache.delete();
+
+ list.name = name.value;
+ list.isPublic = isPublic.value;
+}
+
watch(() => props.listId, fetchList, { immediate: true });
const headerActions = $computed(() => []);
diff --git a/packages/frontend/src/pages/not-found.vue b/packages/frontend/src/pages/not-found.vue
index e58e44ef79..2c9d949017 100644
--- a/packages/frontend/src/pages/not-found.vue
+++ b/packages/frontend/src/pages/not-found.vue
@@ -1,5 +1,5 @@
<template>
-<div class="ipledcug">
+<div>
<div class="_fullinfo">
<img src="https://xn--931a.moe/assets/not-found.jpg" class="_ghost"/>
<div>{{ i18n.ts.notFoundDescription }}</div>
diff --git a/packages/frontend/src/pages/note.vue b/packages/frontend/src/pages/note.vue
index d9baa1096a..c519cefbaf 100644
--- a/packages/frontend/src/pages/note.vue
+++ b/packages/frontend/src/pages/note.vue
@@ -1,33 +1,33 @@
<template>
<MkStickyContainer>
<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
- <MkSpacer :content-max="800">
- <div class="fcuexfpr">
+ <MkSpacer :contentMax="800">
+ <div>
<Transition :name="defaultStore.state.animation ? 'fade' : ''" mode="out-in">
- <div v-if="note" class="note">
+ <div v-if="note">
<div v-if="showNext" class="_margin">
- <MkNotes class="" :pagination="nextPagination" :no-gap="true"/>
+ <MkNotes class="" :pagination="nextPagination" :noGap="true"/>
</div>
- <div class="main _margin">
- <MkButton v-if="!showNext && hasNext" class="load next" @click="showNext = true"><i class="ti ti-chevron-up"></i></MkButton>
- <div class="note _margin _gaps_s">
+ <div class="_margin">
+ <MkButton v-if="!showNext && hasNext" :class="$style.loadNext" @click="showNext = true"><i class="ti ti-chevron-up"></i></MkButton>
+ <div class="_margin _gaps_s">
<MkRemoteCaution v-if="note.user.host != null" :href="note.url ?? note.uri"/>
- <MkNoteDetailed :key="note.id" v-model:note="note" class="note"/>
+ <MkNoteDetailed :key="note.id" v-model:note="note" :class="$style.note"/>
</div>
- <div v-if="clips && clips.length > 0" class="clips _margin">
- <div class="title">{{ i18n.ts.clip }}</div>
+ <div v-if="clips && clips.length > 0" class="_margin">
+ <div style="font-weight: bold; padding: 12px;">{{ i18n.ts.clip }}</div>
<div class="_gaps">
<MkA v-for="item in clips" :key="item.id" :to="`/clips/${item.id}`">
<MkClipPreview :clip="item"/>
</MkA>
</div>
</div>
- <MkButton v-if="!showPrev && hasPrev" class="load prev" @click="showPrev = true"><i class="ti ti-chevron-down"></i></MkButton>
+ <MkButton v-if="!showPrev && hasPrev" :class="$style.loadPrev" @click="showPrev = true"><i class="ti ti-chevron-down"></i></MkButton>
</div>
<div v-if="showPrev" class="_margin">
- <MkNotes class="" :pagination="prevPagination" :no-gap="true"/>
+ <MkNotes class="" :pagination="prevPagination" :noGap="true"/>
</div>
</div>
<MkError v-else-if="error" @retry="fetchNote()"/>
@@ -137,7 +137,7 @@ definePageMetadata(computed(() => note ? {
} : null));
</script>
-<style lang="scss" scoped>
+<style lang="scss" module>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.125s ease;
@@ -147,39 +147,23 @@ definePageMetadata(computed(() => note ? {
opacity: 0;
}
-.fcuexfpr {
- background: var(--bg);
-
- > .note {
- > .main {
- > .load {
- min-width: 0;
- margin: 0 auto;
- border-radius: 999px;
-
- &.next {
- margin-bottom: var(--margin);
- }
+.loadNext,
+.loadPrev {
+ min-width: 0;
+ margin: 0 auto;
+ border-radius: 999px;
+}
- &.prev {
- margin-top: var(--margin);
- }
- }
+.loadNext {
+ margin-bottom: var(--margin);
+}
- > .note {
- > .note {
- border-radius: var(--radius);
- background: var(--panel);
- }
- }
+.loadPrev {
+ margin-top: var(--margin);
+}
- > .clips {
- > .title {
- font-weight: bold;
- padding: 12px;
- }
- }
- }
- }
+.note {
+ border-radius: var(--radius);
+ background: var(--panel);
}
</style>
diff --git a/packages/frontend/src/pages/notifications.vue b/packages/frontend/src/pages/notifications.vue
index 1789606cd8..8196f91868 100644
--- a/packages/frontend/src/pages/notifications.vue
+++ b/packages/frontend/src/pages/notifications.vue
@@ -1,9 +1,9 @@
<template>
<MkStickyContainer>
<template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
- <MkSpacer :content-max="800">
+ <MkSpacer :contentMax="800">
<div v-if="tab === 'all'">
- <XNotifications class="notifications" :include-types="includeTypes"/>
+ <XNotifications class="notifications" :includeTypes="includeTypes"/>
</div>
<div v-else-if="tab === 'mentions'">
<MkNotes :pagination="mentionsPagination"/>
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 1b292e8f3c..eca3feda62 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
@@ -8,8 +8,8 @@
</button>
</template>
- <section class="oyyftmcf">
- <MkDriveFileThumbnail v-if="file" class="preview" :file="file" fit="contain" @click="choose()"/>
+ <section>
+ <MkDriveFileThumbnail v-if="file" style="height: 150px;" :file="file" fit="contain" @click="choose()"/>
</section>
</XContainer>
</template>
@@ -54,11 +54,3 @@ onMounted(async () => {
}
});
</script>
-
-<style lang="scss" scoped>
-.oyyftmcf {
- > .preview {
- height: 150px;
- }
-}
-</style>
diff --git a/packages/frontend/src/pages/page-editor/els/page-editor.el.text.vue b/packages/frontend/src/pages/page-editor/els/page-editor.el.text.vue
index bf21ae3c67..3b15c17747 100644
--- a/packages/frontend/src/pages/page-editor/els/page-editor.el.text.vue
+++ b/packages/frontend/src/pages/page-editor/els/page-editor.el.text.vue
@@ -3,8 +3,8 @@
<XContainer :draggable="true" @remove="() => $emit('remove')">
<template #header><i class="ti ti-align-left"></i> {{ i18n.ts._pages.blocks.text }}</template>
- <section class="vckmsadr">
- <textarea v-model="text"></textarea>
+ <section>
+ <textarea v-model="text" :class="$style.textarea"></textarea>
</section>
</XContainer>
</template>
@@ -33,23 +33,21 @@ watch($$(text), () => {
});
</script>
-<style lang="scss" scoped>
-.vckmsadr {
- > textarea {
- display: block;
- -webkit-appearance: none;
- -moz-appearance: none;
- appearance: none;
- width: 100%;
- min-width: 100%;
- min-height: 150px;
- border: none;
- box-shadow: none;
- padding: 16px;
- background: transparent;
- color: var(--fg);
- font-size: 14px;
- box-sizing: border-box;
- }
+<style lang="scss" module>
+.textarea {
+ display: block;
+ -webkit-appearance: none;
+ -moz-appearance: none;
+ appearance: none;
+ width: 100%;
+ min-width: 100%;
+ min-height: 150px;
+ border: none;
+ box-shadow: none;
+ padding: 16px;
+ background: transparent;
+ color: var(--fg);
+ font-size: 14px;
+ box-sizing: border-box;
}
</style>
diff --git a/packages/frontend/src/pages/page-editor/page-editor.blocks.vue b/packages/frontend/src/pages/page-editor/page-editor.blocks.vue
index 97bdcfe80f..fc945b3d63 100644
--- a/packages/frontend/src/pages/page-editor/page-editor.blocks.vue
+++ b/packages/frontend/src/pages/page-editor/page-editor.blocks.vue
@@ -1,57 +1,59 @@
<template>
-<Sortable :model-value="modelValue" tag="div" item-key="id" handle=".drag-handle" :group="{ name: 'blocks' }" :animation="150" :swap-threshold="0.5" @update:model-value="v => $emit('update:modelValue', v)">
+<Sortable :modelValue="modelValue" tag="div" itemKey="id" handle=".drag-handle" :group="{ name: 'blocks' }" :animation="150" :swapThreshold="0.5" @update:modelValue="v => $emit('update:modelValue', v)">
<template #item="{element}">
<div :class="$style.item">
<!-- divが無いとエラーになる https://github.com/SortableJS/vue.draggable.next/issues/189 -->
- <component :is="'x-' + element.type" :model-value="element" @update:model-value="updateItem" @remove="() => removeItem(element)"/>
+ <component :is="getComponent(element.type)" :modelValue="element" @update:modelValue="updateItem" @remove="() => removeItem(element)"/>
</div>
</template>
</Sortable>
</template>
-<script lang="ts">
-import { defineComponent, defineAsyncComponent } from 'vue';
+<script lang="ts" setup>
+import { defineAsyncComponent } from 'vue';
import XSection from './els/page-editor.el.section.vue';
import XText from './els/page-editor.el.text.vue';
import XImage from './els/page-editor.el.image.vue';
import XNote from './els/page-editor.el.note.vue';
-export default defineComponent({
- components: {
- Sortable: defineAsyncComponent(() => import('vuedraggable').then(x => x.default)),
- XSection, XText, XImage, XNote,
- },
+function getComponent(type: string) {
+ switch (type) {
+ case 'section': return XSection;
+ case 'text': return XText;
+ case 'image': return XImage;
+ case 'note': return XNote;
+ default: return null;
+ }
+}
- props: {
- modelValue: {
- type: Array,
- required: true,
- },
- },
+const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default));
- emits: ['update:modelValue'],
+const props = defineProps<{
+ modelValue: any[];
+}>();
- methods: {
- updateItem(v) {
- const i = this.modelValue.findIndex(x => x.id === v.id);
- const newValue = [
- ...this.modelValue.slice(0, i),
- v,
- ...this.modelValue.slice(i + 1),
- ];
- this.$emit('update:modelValue', newValue);
- },
+const emit = defineEmits<{
+ (ev: 'update:modelValue', value: any[]): void;
+}>();
- removeItem(el) {
- const i = this.modelValue.findIndex(x => x.id === el.id);
- const newValue = [
- ...this.modelValue.slice(0, i),
- ...this.modelValue.slice(i + 1),
- ];
- this.$emit('update:modelValue', newValue);
- },
- },
-});
+function updateItem(v) {
+ const i = props.modelValue.findIndex(x => x.id === v.id);
+ const newValue = [
+ ...props.modelValue.slice(0, i),
+ v,
+ ...props.modelValue.slice(i + 1),
+ ];
+ emit('update:modelValue', newValue);
+}
+
+function removeItem(el) {
+ const i = props.modelValue.findIndex(x => x.id === el.id);
+ const newValue = [
+ ...props.modelValue.slice(0, i),
+ ...props.modelValue.slice(i + 1),
+ ];
+ emit('update:modelValue', newValue);
+}
</script>
<style lang="scss" module>
diff --git a/packages/frontend/src/pages/page-editor/page-editor.container.vue b/packages/frontend/src/pages/page-editor/page-editor.container.vue
index dd733403af..0842b4fd26 100644
--- a/packages/frontend/src/pages/page-editor/page-editor.container.vue
+++ b/packages/frontend/src/pages/page-editor/page-editor.container.vue
@@ -1,5 +1,5 @@
<template>
-<div class="cpjygsrt" :class="{ error: error != null, warn: warn != null }">
+<div class="cpjygsrt">
<header>
<div class="title"><slot name="header"></slot></div>
<div class="buttons">
@@ -16,58 +16,40 @@
</button>
</div>
</header>
- <p v-show="showBody" v-if="error != null" class="error">{{ i18n.t('_pages.script.typeError', { slot: error.arg + 1, expect: i18n.t(`script.types.${error.expect}`), actual: i18n.t(`script.types.${error.actual}`) }) }}</p>
- <p v-show="showBody" v-if="warn != null" class="warn">{{ i18n.t('_pages.script.thereIsEmptySlot', { slot: warn.slot + 1 }) }}</p>
<div v-show="showBody" class="body">
<slot></slot>
</div>
</div>
</template>
-<script lang="ts">
-import { defineComponent } from 'vue';
+<script lang="ts" setup>
+import { ref } from 'vue';
import { i18n } from '@/i18n';
-export default defineComponent({
- props: {
- expanded: {
- type: Boolean,
- default: true,
- },
- removable: {
- type: Boolean,
- default: true,
- },
- draggable: {
- type: Boolean,
- default: false,
- },
- error: {
- required: false,
- default: null,
- },
- warn: {
- required: false,
- default: null,
- },
- },
- emits: ['toggle', 'remove'],
- data() {
- return {
- showBody: this.expanded,
- i18n,
- };
- },
- methods: {
- toggleContent(show: boolean) {
- this.showBody = show;
- this.$emit('toggle', show);
- },
- remove() {
- this.$emit('remove');
- },
- },
+const props = withDefaults(defineProps<{
+ expanded?: boolean;
+ removable?: boolean;
+ draggable?: boolean;
+}>(), {
+ expanded: true,
+ removable: true,
});
+
+const emit = defineEmits<{
+ (ev: 'toggle', show: boolean): void;
+ (ev: 'remove'): void;
+}>();
+
+const showBody = ref(props.expanded);
+
+function toggleContent(show: boolean) {
+ showBody.value = show;
+ emit('toggle', show);
+}
+
+function remove() {
+ emit('remove');
+}
</script>
<style lang="scss" scoped>
@@ -128,20 +110,6 @@ export default defineComponent({
}
}
- > .warn {
- color: #b19e49;
- margin: 0;
- padding: 16px 16px 0 16px;
- font-size: 14px;
- }
-
- > .error {
- color: #f00;
- margin: 0;
- padding: 16px 16px 0 16px;
- font-size: 14px;
- }
-
> .body {
::v-deep(.juejbjww), ::v-deep(.eiipwacr) {
&:not(.inline):first-child {
diff --git a/packages/frontend/src/pages/page-editor/page-editor.vue b/packages/frontend/src/pages/page-editor/page-editor.vue
index bcf30e23a7..bd54699dc4 100644
--- a/packages/frontend/src/pages/page-editor/page-editor.vue
+++ b/packages/frontend/src/pages/page-editor/page-editor.vue
@@ -1,7 +1,7 @@
<template>
<MkStickyContainer>
<template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
- <MkSpacer :content-max="700">
+ <MkSpacer :contentMax="700">
<div class="jqqmcavi">
<MkButton v-if="pageId" class="button" inline link :to="`/@${ author.username }/pages/${ currentName }`"><i class="ti ti-external-link"></i> {{ i18n.ts._pages.viewPage }}</MkButton>
<MkButton v-if="!readonly" inline primary class="button" @click="save"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton>
diff --git a/packages/frontend/src/pages/page.vue b/packages/frontend/src/pages/page.vue
index 5a0f58c8df..27a4cd0595 100644
--- a/packages/frontend/src/pages/page.vue
+++ b/packages/frontend/src/pages/page.vue
@@ -1,7 +1,7 @@
<template>
<MkStickyContainer>
<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
- <MkSpacer :content-max="700">
+ <MkSpacer :contentMax="700">
<Transition :name="defaultStore.state.animation ? 'fade' : ''" mode="out-in">
<div v-if="page" :key="page.id" class="xcukqgmh">
<div class="main">
@@ -18,8 +18,8 @@
</div>
<div class="actions">
<div class="like">
- <MkButton v-if="page.isLiked" v-tooltip="i18n.ts._pages.unlike" class="button" as-like primary @click="unlike()"><i class="ti ti-heart-off"></i><span v-if="page.likedCount > 0" class="count">{{ page.likedCount }}</span></MkButton>
- <MkButton v-else v-tooltip="i18n.ts._pages.like" class="button" as-like @click="like()"><i class="ti ti-heart"></i><span v-if="page.likedCount > 0" class="count">{{ page.likedCount }}</span></MkButton>
+ <MkButton v-if="page.isLiked" v-tooltip="i18n.ts._pages.unlike" class="button" asLike primary @click="unlike()"><i class="ti ti-heart-off"></i><span v-if="page.likedCount > 0" class="count">{{ page.likedCount }}</span></MkButton>
+ <MkButton v-else v-tooltip="i18n.ts._pages.like" class="button" asLike @click="like()"><i class="ti ti-heart"></i><span v-if="page.likedCount > 0" class="count">{{ page.likedCount }}</span></MkButton>
</div>
<div class="other">
<button v-tooltip="i18n.ts.shareWithNote" v-click-anime class="_button" @click="shareWithNote"><i class="ti ti-repeat ti-fw"></i></button>
diff --git a/packages/frontend/src/pages/pages.vue b/packages/frontend/src/pages/pages.vue
index 0427332ab2..4f67bda11f 100644
--- a/packages/frontend/src/pages/pages.vue
+++ b/packages/frontend/src/pages/pages.vue
@@ -1,23 +1,29 @@
<template>
<MkStickyContainer>
<template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
- <MkSpacer :content-max="700">
- <div v-if="tab === 'featured'" class="rknalgpo">
+ <MkSpacer :contentMax="700">
+ <div v-if="tab === 'featured'">
<MkPagination v-slot="{items}" :pagination="featuredPagesPagination">
- <MkPagePreview v-for="page in items" :key="page.id" class="ckltabjg" :page="page"/>
+ <div class="_gaps">
+ <MkPagePreview v-for="page in items" :key="page.id" :page="page"/>
+ </div>
</MkPagination>
</div>
- <div v-else-if="tab === 'my'" class="rknalgpo my">
+ <div v-else-if="tab === 'my'" class="_gaps">
<MkButton class="new" @click="create()"><i class="ti ti-plus"></i></MkButton>
<MkPagination v-slot="{items}" :pagination="myPagesPagination">
- <MkPagePreview v-for="page in items" :key="page.id" class="ckltabjg" :page="page"/>
+ <div class="_gaps">
+ <MkPagePreview v-for="page in items" :key="page.id" :page="page"/>
+ </div>
</MkPagination>
</div>
- <div v-else-if="tab === 'liked'" class="rknalgpo">
+ <div v-else-if="tab === 'liked'">
<MkPagination v-slot="{items}" :pagination="likedPagesPagination">
- <MkPagePreview v-for="like in items" :key="like.page.id" class="ckltabjg" :page="like.page"/>
+ <div class="_gaps">
+ <MkPagePreview v-for="like in items" :key="like.page.id" :page="like.page"/>
+ </div>
</MkPagination>
</div>
</MkSpacer>
@@ -79,21 +85,3 @@ definePageMetadata(computed(() => ({
icon: 'ti ti-note',
})));
</script>
-
-<style lang="scss" scoped>
-.rknalgpo {
- &.my .ckltabjg:first-child {
- margin-top: 16px;
- }
-
- .ckltabjg:not(:last-child) {
- margin-bottom: 8px;
- }
-
- @media (min-width: 500px) {
- .ckltabjg:not(:last-child) {
- margin-bottom: 16px;
- }
- }
-}
-</style>
diff --git a/packages/frontend/src/pages/preview.vue b/packages/frontend/src/pages/preview.vue
deleted file mode 100644
index 354f686e46..0000000000
--- a/packages/frontend/src/pages/preview.vue
+++ /dev/null
@@ -1,27 +0,0 @@
-<template>
-<div class="graojtoi">
- <MkSample/>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { computed } from 'vue';
-import MkSample from '@/components/MkSample.vue';
-import { i18n } from '@/i18n';
-import { definePageMetadata } from '@/scripts/page-metadata';
-
-const headerActions = $computed(() => []);
-
-const headerTabs = $computed(() => []);
-
-definePageMetadata(computed(() => ({
- title: i18n.ts.preview,
- icon: 'ti ti-eye',
-})));
-</script>
-
-<style lang="scss" scoped>
-.graojtoi {
- padding: var(--margin);
-}
-</style>
diff --git a/packages/frontend/src/pages/registry.keys.vue b/packages/frontend/src/pages/registry.keys.vue
index c687b89eab..b1d41fe2c7 100644
--- a/packages/frontend/src/pages/registry.keys.vue
+++ b/packages/frontend/src/pages/registry.keys.vue
@@ -1,7 +1,7 @@
<template>
<MkStickyContainer>
<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
- <MkSpacer :content-max="600" :margin-min="16">
+ <MkSpacer :contentMax="600" :marginMin="16">
<div class="_gaps_m">
<FormSplit>
<MkKeyValue>
@@ -93,6 +93,3 @@ definePageMetadata({
icon: 'ti ti-adjustments',
});
</script>
-
-<style lang="scss" scoped>
-</style>
diff --git a/packages/frontend/src/pages/registry.value.vue b/packages/frontend/src/pages/registry.value.vue
index 00e2ca5e03..513a2f8feb 100644
--- a/packages/frontend/src/pages/registry.value.vue
+++ b/packages/frontend/src/pages/registry.value.vue
@@ -1,7 +1,7 @@
<template>
<MkStickyContainer>
<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
- <MkSpacer :content-max="600" :margin-min="16">
+ <MkSpacer :contentMax="600" :marginMin="16">
<div class="_gaps_m">
<FormInfo warn>{{ i18n.ts.editTheseSettingsMayBreakAccount }}</FormInfo>
@@ -118,6 +118,3 @@ definePageMetadata({
icon: 'ti ti-adjustments',
});
</script>
-
-<style lang="scss" scoped>
-</style>
diff --git a/packages/frontend/src/pages/registry.vue b/packages/frontend/src/pages/registry.vue
index 5a029cb0c7..6bfb9bce58 100644
--- a/packages/frontend/src/pages/registry.vue
+++ b/packages/frontend/src/pages/registry.vue
@@ -1,7 +1,7 @@
<template>
<MkStickyContainer>
<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
- <MkSpacer :content-max="600" :margin-min="16">
+ <MkSpacer :contentMax="600" :marginMin="16">
<MkButton primary @click="createKey">{{ i18n.ts._registry.createKey }}</MkButton>
<FormSection v-if="scopes">
@@ -68,6 +68,3 @@ definePageMetadata({
icon: 'ti ti-adjustments',
});
</script>
-
-<style lang="scss" scoped>
-</style>
diff --git a/packages/frontend/src/pages/reset-password.vue b/packages/frontend/src/pages/reset-password.vue
index 38c88cc650..9d57307314 100644
--- a/packages/frontend/src/pages/reset-password.vue
+++ b/packages/frontend/src/pages/reset-password.vue
@@ -1,7 +1,7 @@
<template>
<MkStickyContainer>
<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
- <MkSpacer v-if="token" :content-max="700" :margin-min="16" :margin-max="32">
+ <MkSpacer v-if="token" :contentMax="700" :marginMin="16" :marginMax="32">
<div class="_gaps_m">
<MkInput v-model="password" type="password">
<template #prefix><i class="ti ti-lock"></i></template>
@@ -53,7 +53,3 @@ definePageMetadata({
icon: 'ti ti-lock',
});
</script>
-
-<style lang="scss" scoped>
-
-</style>
diff --git a/packages/frontend/src/pages/role.vue b/packages/frontend/src/pages/role.vue
index fe39c594ba..e85ab0917a 100644
--- a/packages/frontend/src/pages/role.vue
+++ b/packages/frontend/src/pages/role.vue
@@ -1,7 +1,7 @@
<template>
<MkStickyContainer>
<template #header><MkPageHeader v-model:tab="tab" :tabs="headerTabs"/></template>
- <MKSpacer v-if="!(typeof error === 'undefined')" :content-max="1200">
+ <MKSpacer v-if="!(typeof error === 'undefined')" :contentMax="1200">
<div :class="$style.root">
<img :class="$style.img" src="https://xn--931a.moe/assets/error.jpg" class="_ghost"/>
<p :class="$style.text">
@@ -10,17 +10,18 @@
</p>
</div>
</MKSpacer>
- <MkSpacer v-else-if="tab === 'users'" :content-max="1200">
+ <MkSpacer v-else-if="tab === 'users'" :contentMax="1200">
<div class="_gaps_s">
<div v-if="role">{{ role.description }}</div>
<MkUserList :pagination="users" :extractor="(item) => item.user"/>
</div>
</MkSpacer>
- <MkSpacer v-else-if="tab === 'timeline'" :content-max="700">
+ <MkSpacer v-else-if="tab === 'timeline'" :contentMax="700">
<MkTimeline ref="timeline" src="role" :role="props.role"/>
</MkSpacer>
</MkStickyContainer>
</template>
+
<script lang="ts" setup>
import { computed, watch } from 'vue';
import * as os from '@/os';
@@ -80,6 +81,7 @@ definePageMetadata(computed(() => ({
icon: 'ti ti-badge',
})));
</script>
+
<style lang="scss" module>
.root {
padding: 32px;
diff --git a/packages/frontend/src/pages/scratchpad.vue b/packages/frontend/src/pages/scratchpad.vue
index fb78546cb1..22eb00dad4 100644
--- a/packages/frontend/src/pages/scratchpad.vue
+++ b/packages/frontend/src/pages/scratchpad.vue
@@ -1,8 +1,8 @@
<template>
-<MkSpacer :content-max="800">
+<MkSpacer :contentMax="800">
<div :class="$style.root">
<div :class="$style.editor" class="_panel">
- <PrismEditor v-model="code" class="_code code" :highlight="highlighter" :line-numbers="false"/>
+ <PrismEditor v-model="code" class="_code code" :highlight="highlighter" :lineNumbers="false"/>
<MkButton style="position: absolute; top: 8px; right: 8px;" primary @click="run()"><i class="ti ti-player-play"></i></MkButton>
</div>
diff --git a/packages/frontend/src/pages/search.user.vue b/packages/frontend/src/pages/search.user.vue
index 23a8978fd1..bd1389ffef 100644
--- a/packages/frontend/src/pages/search.user.vue
+++ b/packages/frontend/src/pages/search.user.vue
@@ -4,7 +4,7 @@
<MkInput v-model="searchQuery" :large="true" :autofocus="true" type="search">
<template #prefix><i class="ti ti-search"></i></template>
</MkInput>
- <MkRadios v-model="searchOrigin" @update:model-value="search()">
+ <MkRadios v-model="searchOrigin" @update:modelValue="search()">
<option value="combined">{{ i18n.ts.all }}</option>
<option value="local">{{ i18n.ts.local }}</option>
<option value="remote">{{ i18n.ts.remote }}</option>
diff --git a/packages/frontend/src/pages/search.vue b/packages/frontend/src/pages/search.vue
index 9f3d8da560..dcaf42e648 100644
--- a/packages/frontend/src/pages/search.vue
+++ b/packages/frontend/src/pages/search.vue
@@ -2,7 +2,7 @@
<MkStickyContainer>
<template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
- <MkSpacer v-if="tab === 'note'" :content-max="800">
+ <MkSpacer v-if="tab === 'note'" :contentMax="800">
<div v-if="notesSearchAvailable">
<XNote/>
</div>
@@ -11,7 +11,7 @@
</div>
</MkSpacer>
- <MkSpacer v-else-if="tab === 'user'" :content-max="800">
+ <MkSpacer v-else-if="tab === 'user'" :contentMax="800">
<XUser/>
</MkSpacer>
</MkStickyContainer>
diff --git a/packages/frontend/src/pages/settings/2fa.qrdialog.vue b/packages/frontend/src/pages/settings/2fa.qrdialog.vue
index 1d836db5f5..6a798b5626 100644
--- a/packages/frontend/src/pages/settings/2fa.qrdialog.vue
+++ b/packages/frontend/src/pages/settings/2fa.qrdialog.vue
@@ -1,8 +1,8 @@
<template>
<MkModal
ref="dialogEl"
- :prefer-type="'dialog'"
- :z-priority="'low'"
+ :preferType="'dialog'"
+ :zPriority="'low'"
@click="cancel"
@close="cancel"
@closed="emit('closed')"
diff --git a/packages/frontend/src/pages/settings/2fa.vue b/packages/frontend/src/pages/settings/2fa.vue
index 891934d706..aff7765ed5 100644
--- a/packages/frontend/src/pages/settings/2fa.vue
+++ b/packages/frontend/src/pages/settings/2fa.vue
@@ -51,7 +51,7 @@
</div>
</MkFolder>
- <MkSwitch :disabled="!$i.twoFactorEnabled || $i.securityKeysList.length === 0" :model-value="usePasswordLessLogin" @update:model-value="v => updatePasswordLessLogin(v)">
+ <MkSwitch :disabled="!$i.twoFactorEnabled || $i.securityKeysList.length === 0" :modelValue="usePasswordLessLogin" @update:modelValue="v => updatePasswordLessLogin(v)">
<template #label>{{ i18n.ts.passwordLessLogin }}</template>
<template #caption>{{ i18n.ts.passwordLessLoginDescription }}</template>
</MkSwitch>
diff --git a/packages/frontend/src/pages/settings/accounts.vue b/packages/frontend/src/pages/settings/accounts.vue
index a58e74fe69..78479be973 100644
--- a/packages/frontend/src/pages/settings/accounts.vue
+++ b/packages/frontend/src/pages/settings/accounts.vue
@@ -15,13 +15,13 @@
<script lang="ts" setup>
import { defineAsyncComponent, ref } from 'vue';
+import type * as Misskey from 'misskey-js';
import FormSuspense from '@/components/form/suspense.vue';
import MkButton from '@/components/MkButton.vue';
import * as os from '@/os';
import { getAccounts, addAccount as addAccounts, removeAccount as _removeAccount, login, $i } from '@/account';
import { i18n } from '@/i18n';
import { definePageMetadata } from '@/scripts/page-metadata';
-import type * as Misskey from 'misskey-js';
import MkUserCardMini from '@/components/MkUserCardMini.vue';
const storedAccounts = ref<any>(null);
diff --git a/packages/frontend/src/pages/settings/apps.vue b/packages/frontend/src/pages/settings/apps.vue
index 599d6329e2..fbb78200d4 100644
--- a/packages/frontend/src/pages/settings/apps.vue
+++ b/packages/frontend/src/pages/settings/apps.vue
@@ -9,11 +9,11 @@
</template>
<template #default="{items}">
<div class="_gaps">
- <div v-for="token in items" :key="token.id" class="_panel bfomjevm">
- <img v-if="token.iconUrl" class="icon" :src="token.iconUrl" alt=""/>
- <div class="body">
- <div class="name">{{ token.name }}</div>
- <div class="description">{{ token.description }}</div>
+ <div v-for="token in items" :key="token.id" class="_panel" :class="$style.app">
+ <img v-if="token.iconUrl" :class="$style.appIcon" :src="token.iconUrl" alt=""/>
+ <div :class="$style.appBody">
+ <div :class="$style.appName">{{ token.name }}</div>
+ <div>{{ token.description }}</div>
<MkKeyValue oneline>
<template #key>{{ i18n.ts.installedDate }}</template>
<template #value><MkTime :time="token.createdAt"/></template>
@@ -28,7 +28,7 @@
<li v-for="p in token.permission" :key="p">{{ i18n.t(`_permissions.${p}`) }}</li>
</ul>
</details>
- <div class="actions">
+ <div>
<MkButton inline danger @click="revoke(token)"><i class="ti ti-trash"></i></MkButton>
</div>
</div>
@@ -75,27 +75,27 @@ definePageMetadata({
});
</script>
-<style lang="scss" scoped>
-.bfomjevm {
+<style lang="scss" module>
+.app {
display: flex;
padding: 16px;
+}
- > .icon {
- display: block;
- flex-shrink: 0;
- margin: 0 12px 0 0;
- width: 50px;
- height: 50px;
- border-radius: 8px;
- }
+.appIcon {
+ display: block;
+ flex-shrink: 0;
+ margin: 0 12px 0 0;
+ width: 50px;
+ height: 50px;
+ border-radius: 8px;
+}
- > .body {
- width: calc(100% - 62px);
- position: relative;
+.appBody {
+ width: calc(100% - 62px);
+ position: relative;
+}
- > .name {
- font-weight: bold;
- }
- }
+.appName {
+ font-weight: bold;
}
</style>
diff --git a/packages/frontend/src/pages/settings/custom-css.vue b/packages/frontend/src/pages/settings/custom-css.vue
index 456c3742c5..970d5689b4 100644
--- a/packages/frontend/src/pages/settings/custom-css.vue
+++ b/packages/frontend/src/pages/settings/custom-css.vue
@@ -2,7 +2,7 @@
<div class="_gaps_m">
<FormInfo warn>{{ i18n.ts.customCssWarn }}</FormInfo>
- <MkTextarea v-model="localCustomCss" manual-save tall class="_monospace" style="tab-size: 2;">
+ <MkTextarea v-model="localCustomCss" manualSave tall class="_monospace" style="tab-size: 2;">
<template #label>CSS</template>
</MkTextarea>
</div>
diff --git a/packages/frontend/src/pages/settings/drive.vue b/packages/frontend/src/pages/settings/drive.vue
index 73c2b2e604..8d7b30dc6e 100644
--- a/packages/frontend/src/pages/settings/drive.vue
+++ b/packages/frontend/src/pages/settings/drive.vue
@@ -4,8 +4,8 @@
<template #label>{{ i18n.ts.usageAmount }}</template>
<div class="_gaps_m">
- <div class="uawsfosz">
- <div class="meter"><div :style="meterStyle"></div></div>
+ <div>
+ <div :class="$style.meter"><div :class="$style.meterValue" :style="meterStyle"></div></div>
</div>
<FormSplit>
<MkKeyValue>
@@ -22,7 +22,7 @@
<FormSection>
<template #label>{{ i18n.ts.statistics }}</template>
- <MkChart src="per-user-drive" :args="{ user: $i }" span="day" :limit="7 * 5" :bar="true" :stacked="true" :detailed="false" :aspect-ratio="6"/>
+ <MkChart src="per-user-drive" :args="{ user: $i }" span="day" :limit="7 * 5" :bar="true" :stacked="true" :detailed="false" :aspectRatio="6"/>
</FormSection>
<FormSection>
@@ -39,10 +39,10 @@
<template #label>{{ i18n.ts.keepOriginalUploading }}</template>
<template #caption>{{ i18n.ts.keepOriginalUploadingDescription }}</template>
</MkSwitch>
- <MkSwitch v-model="alwaysMarkNsfw" @update:model-value="saveProfile()">
+ <MkSwitch v-model="alwaysMarkNsfw" @update:modelValue="saveProfile()">
<template #label>{{ i18n.ts.alwaysMarkSensitive }}</template>
</MkSwitch>
- <MkSwitch v-model="autoSensitive" @update:model-value="saveProfile()">
+ <MkSwitch v-model="autoSensitive" @update:modelValue="saveProfile()">
<template #label>{{ i18n.ts.enableAutoSensitive }}<span class="_beta">{{ i18n.ts.beta }}</span></template>
<template #caption>{{ i18n.ts.enableAutoSensitiveDescription }}</template>
</MkSwitch>
@@ -139,22 +139,16 @@ definePageMetadata({
});
</script>
-<style lang="scss" scoped>
-
-@use "sass:math";
-
-.uawsfosz {
-
- > .meter {
- $size: 12px;
- background: rgba(0, 0, 0, 0.1);
- border-radius: math.div($size, 2);
- overflow: hidden;
+<style lang="scss" module>
+.meter {
+ height: 10px;
+ background: rgba(0, 0, 0, 0.1);
+ border-radius: 999px;
+ overflow: clip;
+}
- > div {
- height: $size;
- border-radius: math.div($size, 2);
- }
- }
+.meterValue {
+ height: 100%;
+ border-radius: 999px;
}
</style>
diff --git a/packages/frontend/src/pages/settings/email.vue b/packages/frontend/src/pages/settings/email.vue
index b1e6f223b6..d015cec154 100644
--- a/packages/frontend/src/pages/settings/email.vue
+++ b/packages/frontend/src/pages/settings/email.vue
@@ -2,7 +2,7 @@
<div v-if="instance.enableEmail" class="_gaps_m">
<FormSection first>
<template #label>{{ i18n.ts.emailAddress }}</template>
- <MkInput v-model="emailAddress" type="email" manual-save>
+ <MkInput v-model="emailAddress" type="email" manualSave>
<template #prefix><i class="ti ti-mail"></i></template>
<template v-if="$i.email && !$i.emailVerified" #caption>{{ i18n.ts.verificationEmailSent }}</template>
<template v-else-if="emailAddress === $i.email && $i.emailVerified" #caption><i class="ti ti-check" style="color: var(--success);"></i> {{ i18n.ts.emailVerified }}</template>
@@ -10,7 +10,7 @@
</FormSection>
<FormSection>
- <MkSwitch :model-value="$i.receiveAnnouncementEmail" @update:model-value="onChangeReceiveAnnouncementEmail">
+ <MkSwitch :modelValue="$i.receiveAnnouncementEmail" @update:modelValue="onChangeReceiveAnnouncementEmail">
{{ i18n.ts.receiveAnnouncementFromInstance }}
</MkSwitch>
</FormSection>
diff --git a/packages/frontend/src/pages/settings/general.vue b/packages/frontend/src/pages/settings/general.vue
index ba0f3274fc..20b36f0fcb 100644
--- a/packages/frontend/src/pages/settings/general.vue
+++ b/packages/frontend/src/pages/settings/general.vue
@@ -24,6 +24,7 @@
<div class="_gaps_s">
<MkSwitch v-model="showFixedPostForm">{{ i18n.ts.showFixedPostForm }}</MkSwitch>
<MkSwitch v-model="showFixedPostFormInChannel">{{ i18n.ts.showFixedPostFormInChannel }}</MkSwitch>
+ <MkSwitch v-model="showTimelineReplies">{{ i18n.ts.flagShowTimelineReplies }}<template #caption>{{ i18n.ts.flagShowTimelineRepliesDescription }} {{ i18n.ts.reflectMayTakeTime }}</template></MkSwitch>
</div>
</FormSection>
@@ -56,7 +57,7 @@
<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>
@@ -64,7 +65,6 @@
<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>
@@ -145,12 +145,20 @@
</FormSection>
<FormSection>
- <MkSwitch v-model="aiChanMode">{{ i18n.ts.aiChanMode }}</MkSwitch>
- </FormSection>
+ <template #label>{{ i18n.ts.other }}</template>
- <FormLink to="/settings/deck">{{ i18n.ts.deck }}</FormLink>
-
- <FormLink to="/settings/custom-css"><template #icon><i class="ti ti-code"></i></template>{{ i18n.ts.customCss }}</FormLink>
+ <div class="_gaps">
+ <MkFolder>
+ <template #label>{{ i18n.ts.additionalEmojiDictionary }}</template>
+ <div v-for="lang in emojiIndexLangs" class="_buttons">
+ <MkButton @click="downloadEmojiIndex(lang)"><i class="ti ti-download"></i> {{ lang }}{{ defaultStore.reactiveState.additionalUnicodeEmojiIndexes.value[lang] ? ` (${ i18n.ts.installed })` : '' }}</MkButton>
+ <MkButton v-if="defaultStore.reactiveState.additionalUnicodeEmojiIndexes.value[lang]" danger @click="removeEmojiIndex(lang)"><i class="ti ti-trash"></i> {{ i18n.ts.remove }}</MkButton>
+ </div>
+ </MkFolder>
+ <FormLink to="/settings/deck">{{ i18n.ts.deck }}</FormLink>
+ <FormLink to="/settings/custom-css"><template #icon><i class="ti ti-code"></i></template>{{ i18n.ts.customCss }}</FormLink>
+ </div>
+ </FormSection>
</div>
</template>
@@ -160,6 +168,8 @@ import MkSwitch from '@/components/MkSwitch.vue';
import MkSelect from '@/components/MkSelect.vue';
import MkRadios from '@/components/MkRadios.vue';
import MkRange from '@/components/MkRange.vue';
+import MkFolder from '@/components/MkFolder.vue';
+import MkButton from '@/components/MkButton.vue';
import FormSection from '@/components/form/section.vue';
import FormLink from '@/components/form/link.vue';
import MkLink from '@/components/MkLink.vue';
@@ -212,10 +222,10 @@ const instanceTicker = computed(defaultStore.makeGetterSetter('instanceTicker'))
const enableInfiniteScroll = computed(defaultStore.makeGetterSetter('enableInfiniteScroll'));
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'));
+const showTimelineReplies = computed(defaultStore.makeGetterSetter('showTimelineReplies'));
watch(lang, () => {
miLocalStorage.setItem('lang', lang.value as string);
@@ -244,7 +254,6 @@ watch([
useSystemFont,
enableInfiniteScroll,
squareAvatars,
- aiChanMode,
showNoteActionsOnlyHover,
showGapBetweenNotesInTimeline,
instanceTicker,
@@ -253,6 +262,34 @@ watch([
await reloadAsk();
});
+const emojiIndexLangs = ['en-US'];
+
+function downloadEmojiIndex(lang: string) {
+ async function main() {
+ const currentIndexes = defaultStore.state.additionalUnicodeEmojiIndexes;
+ function download() {
+ switch (lang) {
+ case 'en-US': return import('../../unicode-emoji-indexes/en-US.json').then(x => x.default);
+ default: throw new Error('unrecognized lang: ' + lang);
+ }
+ }
+ currentIndexes[lang] = await download();
+ await defaultStore.set('additionalUnicodeEmojiIndexes', currentIndexes);
+ }
+
+ os.promiseDialog(main());
+}
+
+function removeEmojiIndex(lang: string) {
+ async function main() {
+ const currentIndexes = defaultStore.state.additionalUnicodeEmojiIndexes;
+ delete currentIndexes[lang];
+ await defaultStore.set('additionalUnicodeEmojiIndexes', currentIndexes);
+ }
+
+ os.promiseDialog(main());
+}
+
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 34a962ef4c..b4f056d8a6 100644
--- a/packages/frontend/src/pages/settings/index.vue
+++ b/packages/frontend/src/pages/settings/index.vue
@@ -1,7 +1,7 @@
<template>
<MkStickyContainer>
<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
- <MkSpacer :content-max="900" :margin-min="20" :margin-max="32">
+ <MkSpacer :contentMax="900" :marginMin="20" :marginMax="32">
<div ref="el" class="vvcocwet" :class="{ wide: !narrow }">
<div class="body">
<div v-if="!narrow || currentPage?.route.name == null" class="nav">
diff --git a/packages/frontend/src/pages/settings/migration.vue b/packages/frontend/src/pages/settings/migration.vue
index 541992875e..102bc68523 100644
--- a/packages/frontend/src/pages/settings/migration.vue
+++ b/packages/frontend/src/pages/settings/migration.vue
@@ -3,7 +3,7 @@
<FormInfo warn>
{{ i18n.ts.thisIsExperimentalFeature }}
</FormInfo>
- <MkFolder :default-open="true">
+ <MkFolder :defaultOpen="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>
@@ -25,7 +25,7 @@
</div>
</MkFolder>
- <MkFolder :default-open="!!$i?.movedTo">
+ <MkFolder :defaultOpen="!!$i?.movedTo">
<template #icon><i class="ti ti-plane-departure"></i></template>
<template #label>{{ i18n.ts._accountMigration.moveTo }}</template>
@@ -48,7 +48,7 @@
<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" />
+ <MkUserInfo v-if="movedTo" :user="movedTo" class="_panel _shadow"/>
</template>
</div>
</MkFolder>
@@ -57,6 +57,8 @@
<script lang="ts" setup>
import { ref } from 'vue';
+import { toString } from 'misskey-js/built/acct';
+import { UserDetailed } from 'misskey-js/built/entities';
import FormInfo from '@/components/MkInfo.vue';
import MkInput from '@/components/MkInput.vue';
import MkButton from '@/components/MkButton.vue';
@@ -66,8 +68,6 @@ 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('');
diff --git a/packages/frontend/src/pages/settings/navbar.vue b/packages/frontend/src/pages/settings/navbar.vue
index b3b33b8026..8780bfbc1e 100644
--- a/packages/frontend/src/pages/settings/navbar.vue
+++ b/packages/frontend/src/pages/settings/navbar.vue
@@ -2,10 +2,10 @@
<div class="_gaps_m">
<FormSlot>
<template #label>{{ i18n.ts.navbar }}</template>
- <MkContainer :show-header="false">
+ <MkContainer :showHeader="false">
<Sortable
v-model="items"
- item-key="id"
+ itemKey="id"
:animation="150"
:handle="'.' + $style.itemHandle"
@start="e => e.item.classList.add('active')"
diff --git a/packages/frontend/src/pages/settings/notifications.vue b/packages/frontend/src/pages/settings/notifications.vue
index 2cf2f6d7f6..2552db4198 100644
--- a/packages/frontend/src/pages/settings/notifications.vue
+++ b/packages/frontend/src/pages/settings/notifications.vue
@@ -12,7 +12,7 @@
<div class="_gaps_m">
<MkPushNotificationAllowButton ref="allowButton"/>
- <MkSwitch :disabled="!pushRegistrationInServer" :model-value="sendReadMessage" @update:model-value="onChangeSendReadMessage">
+ <MkSwitch :disabled="!pushRegistrationInServer" :modelValue="sendReadMessage" @update:modelValue="onChangeSendReadMessage">
<template #label>{{ i18n.ts.sendPushNotificationReadMessage }}</template>
<template #caption>
<I18n :src="i18n.ts.sendPushNotificationReadMessageCaption">
diff --git a/packages/frontend/src/pages/settings/other.vue b/packages/frontend/src/pages/settings/other.vue
index 776305d723..0b73780a8b 100644
--- a/packages/frontend/src/pages/settings/other.vue
+++ b/packages/frontend/src/pages/settings/other.vue
@@ -53,6 +53,17 @@
</MkSwitch>
</div>
</MkFolder>
+
+ <MkFolder>
+ <template #icon><i class="ti ti-code"></i></template>
+ <template #label>{{ i18n.ts.developer }}</template>
+
+ <div class="_gaps_m">
+ <MkSwitch v-model="devMode">
+ <template #label>{{ i18n.ts.devMode }}</template>
+ </MkSwitch>
+ </div>
+ </MkFolder>
</div>
</FormSection>
@@ -80,6 +91,7 @@ import FormSection from '@/components/form/section.vue';
const reportError = computed(defaultStore.makeGetterSetter('reportError'));
const enableCondensedLineForAcct = computed(defaultStore.makeGetterSetter('enableCondensedLineForAcct'));
+const devMode = computed(defaultStore.makeGetterSetter('devMode'));
function onChangeInjectFeaturedNote(v) {
os.api('i/update', {
diff --git a/packages/frontend/src/pages/settings/plugin.vue b/packages/frontend/src/pages/settings/plugin.vue
index 8b57dceefb..75fae014f8 100644
--- a/packages/frontend/src/pages/settings/plugin.vue
+++ b/packages/frontend/src/pages/settings/plugin.vue
@@ -8,7 +8,7 @@
<div v-for="plugin in plugins" :key="plugin.id" class="_panel _gaps_s" style="padding: 20px;">
<span style="display: flex;"><b>{{ plugin.name }}</b><span style="margin-left: auto;">v{{ plugin.version }}</span></span>
- <MkSwitch :model-value="plugin.active" @update:model-value="changeActive(plugin, $event)">{{ i18n.ts.makeActive }}</MkSwitch>
+ <MkSwitch :modelValue="plugin.active" @update:modelValue="changeActive(plugin, $event)">{{ i18n.ts.makeActive }}</MkSwitch>
<MkKeyValue>
<template #key>{{ i18n.ts.author }}</template>
@@ -94,7 +94,3 @@ definePageMetadata({
icon: 'ti ti-plug',
});
</script>
-
-<style lang="scss" scoped>
-
-</style>
diff --git a/packages/frontend/src/pages/settings/preferences-backups.vue b/packages/frontend/src/pages/settings/preferences-backups.vue
index 6613ce4c1d..e34901cd11 100644
--- a/packages/frontend/src/pages/settings/preferences-backups.vue
+++ b/packages/frontend/src/pages/settings/preferences-backups.vue
@@ -32,7 +32,7 @@
</template>
<script lang="ts" setup>
-import { computed, onMounted, onUnmounted, useCssModule } from 'vue';
+import { computed, onMounted, onUnmounted } from 'vue';
import { v4 as uuid } from 'uuid';
import FormSection from '@/components/form/section.vue';
import MkButton from '@/components/MkButton.vue';
@@ -40,7 +40,7 @@ import MkInfo from '@/components/MkInfo.vue';
import * as os from '@/os';
import { ColdDeviceStorage, defaultStore } from '@/store';
import { unisonReload } from '@/scripts/unison-reload';
-import { stream } from '@/stream';
+import { useStream } from '@/stream';
import { $i } from '@/account';
import { i18n } from '@/i18n';
import { version, host } from '@/config';
@@ -48,8 +48,6 @@ import { definePageMetadata } from '@/scripts/page-metadata';
import { miLocalStorage } from '@/local-storage';
const { t, ts } = i18n;
-useCssModule();
-
const defaultStoreSaveKeys: (keyof typeof defaultStore['state'])[] = [
'menu',
'visibility',
@@ -125,7 +123,7 @@ type Profile = {
};
};
-const connection = $i && stream.useChannel('main');
+const connection = $i && useStream().useChannel('main');
let profiles = $ref<Record<string, Profile> | null>(null);
diff --git a/packages/frontend/src/pages/settings/privacy.vue b/packages/frontend/src/pages/settings/privacy.vue
index a1af0ba80b..7fd4d6d34e 100644
--- a/packages/frontend/src/pages/settings/privacy.vue
+++ b/packages/frontend/src/pages/settings/privacy.vue
@@ -1,14 +1,14 @@
<template>
<div class="_gaps_m">
- <MkSwitch v-model="isLocked" @update:model-value="save()">{{ i18n.ts.makeFollowManuallyApprove }}<template #caption>{{ i18n.ts.lockedAccountInfo }}</template></MkSwitch>
- <MkSwitch v-if="isLocked" v-model="autoAcceptFollowed" @update:model-value="save()">{{ i18n.ts.autoAcceptFollowed }}</MkSwitch>
+ <MkSwitch v-model="isLocked" @update:modelValue="save()">{{ i18n.ts.makeFollowManuallyApprove }}<template #caption>{{ i18n.ts.lockedAccountInfo }}</template></MkSwitch>
+ <MkSwitch v-if="isLocked" v-model="autoAcceptFollowed" @update:modelValue="save()">{{ i18n.ts.autoAcceptFollowed }}</MkSwitch>
- <MkSwitch v-model="publicReactions" @update:model-value="save()">
+ <MkSwitch v-model="publicReactions" @update:modelValue="save()">
{{ i18n.ts.makeReactionsPublic }}
<template #caption>{{ i18n.ts.makeReactionsPublicDescription }}</template>
</MkSwitch>
- <MkSelect v-model="ffVisibility" @update:model-value="save()">
+ <MkSelect v-model="ffVisibility" @update:modelValue="save()">
<template #label>{{ i18n.ts.ffVisibility }}</template>
<option value="public">{{ i18n.ts._ffVisibility.public }}</option>
<option value="followers">{{ i18n.ts._ffVisibility.followers }}</option>
@@ -16,26 +16,26 @@
<template #caption>{{ i18n.ts.ffVisibilityDescription }}</template>
</MkSelect>
- <MkSwitch v-model="hideOnlineStatus" @update:model-value="save()">
+ <MkSwitch v-model="hideOnlineStatus" @update:modelValue="save()">
{{ i18n.ts.hideOnlineStatus }}
<template #caption>{{ i18n.ts.hideOnlineStatusDescription }}</template>
</MkSwitch>
- <MkSwitch v-model="noCrawle" @update:model-value="save()">
+ <MkSwitch v-model="noCrawle" @update:modelValue="save()">
{{ i18n.ts.noCrawle }}
<template #caption>{{ i18n.ts.noCrawleDescription }}</template>
</MkSwitch>
- <MkSwitch v-model="preventAiLearning" @update:model-value="save()">
+ <MkSwitch v-model="preventAiLearning" @update:modelValue="save()">
{{ i18n.ts.preventAiLearning }}<span class="_beta">{{ i18n.ts.beta }}</span>
<template #caption>{{ i18n.ts.preventAiLearningDescription }}</template>
</MkSwitch>
- <MkSwitch v-model="isExplorable" @update:model-value="save()">
+ <MkSwitch v-model="isExplorable" @update:modelValue="save()">
{{ i18n.ts.makeExplorable }}
<template #caption>{{ i18n.ts.makeExplorableDescription }}</template>
</MkSwitch>
<FormSection>
<div class="_gaps_m">
- <MkSwitch v-model="rememberNoteVisibility" @update:model-value="save()">{{ i18n.ts.rememberNoteVisibility }}</MkSwitch>
+ <MkSwitch v-model="rememberNoteVisibility" @update:modelValue="save()">{{ i18n.ts.rememberNoteVisibility }}</MkSwitch>
<MkFolder v-if="!rememberNoteVisibility">
<template #label>{{ i18n.ts.defaultNoteVisibility }}</template>
<template v-if="defaultNoteVisibility === 'public'" #suffix>{{ i18n.ts._visibility.public }}</template>
@@ -56,7 +56,7 @@
</div>
</FormSection>
- <MkSwitch v-model="keepCw" @update:model-value="save()">{{ i18n.ts.keepCw }}</MkSwitch>
+ <MkSwitch v-model="keepCw" @update:modelValue="save()">{{ i18n.ts.keepCw }}</MkSwitch>
</div>
</template>
diff --git a/packages/frontend/src/pages/settings/profile.vue b/packages/frontend/src/pages/settings/profile.vue
index 6ffd682610..58217d0475 100644
--- a/packages/frontend/src/pages/settings/profile.vue
+++ b/packages/frontend/src/pages/settings/profile.vue
@@ -1,28 +1,28 @@
<template>
<div class="_gaps_m">
- <div class="llvierxe" :style="{ backgroundImage: $i.bannerUrl ? `url(${ $i.bannerUrl })` : null }">
- <div class="avatar">
- <MkAvatar class="avatar" :user="$i" @click="changeAvatar"/>
- <MkButton primary rounded class="avatarEdit" @click="changeAvatar">{{ i18n.ts._profile.changeAvatar }}</MkButton>
+ <div :class="$style.avatarAndBanner" :style="{ backgroundImage: $i.bannerUrl ? `url(${ $i.bannerUrl })` : null }">
+ <div :class="$style.avatarContainer">
+ <MkAvatar :class="$style.avatar" :user="$i" @click="changeAvatar"/>
+ <MkButton primary rounded @click="changeAvatar">{{ i18n.ts._profile.changeAvatar }}</MkButton>
</div>
- <MkButton primary rounded class="bannerEdit" @click="changeBanner">{{ i18n.ts._profile.changeBanner }}</MkButton>
+ <MkButton primary rounded :class="$style.bannerEdit" @click="changeBanner">{{ i18n.ts._profile.changeBanner }}</MkButton>
</div>
- <MkInput v-model="profile.name" :max="30" manual-save>
+ <MkInput v-model="profile.name" :max="30" manualSave>
<template #label>{{ i18n.ts._profile.name }}</template>
</MkInput>
- <MkTextarea v-model="profile.description" :max="500" tall manual-save>
+ <MkTextarea v-model="profile.description" :max="500" tall manualSave>
<template #label>{{ i18n.ts._profile.description }}</template>
<template #caption>{{ i18n.ts._profile.youCanIncludeHashtags }}</template>
</MkTextarea>
- <MkInput v-model="profile.location" manual-save>
+ <MkInput v-model="profile.location" manualSave>
<template #label>{{ i18n.ts.location }}</template>
<template #prefix><i class="ti ti-map-pin"></i></template>
</MkInput>
- <MkInput v-model="profile.birthday" type="date" manual-save>
+ <MkInput v-model="profile.birthday" type="date" manualSave>
<template #label>{{ i18n.ts.birthday }}</template>
<template #prefix><i class="ti ti-cake"></i></template>
</MkInput>
@@ -48,7 +48,7 @@
<Sortable
v-model="fields"
class="_gaps_s"
- item-key="id"
+ itemKey="id"
:animation="150"
:handle="'.' + $style.dragItemHandle"
@start="e => e.item.classList.add('active')"
@@ -59,7 +59,7 @@
<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">
+ <FormSplit :minWidth="200">
<MkInput v-model="element.name" small>
<template #label>{{ i18n.ts._profile.metadataLabel }}</template>
</MkInput>
@@ -88,11 +88,11 @@
<MkSelect v-model="reactionAcceptance">
<template #label>{{ i18n.ts.reactionAcceptance }}</template>
<option :value="null">{{ i18n.ts.all }}</option>
- <option value="likeOnly">{{ i18n.ts.likeOnly }}</option>
<option value="likeOnlyForRemote">{{ i18n.ts.likeOnlyForRemote }}</option>
+ <option value="nonSensitiveOnly">{{ i18n.ts.nonSensitiveOnly }}</option>
+ <option value="nonSensitiveOnlyForLocalLikeOnlyForRemote">{{ i18n.ts.nonSensitiveOnlyForLocalLikeOnlyForRemote }}</option>
+ <option value="likeOnly">{{ i18n.ts.likeOnly }}</option>
</MkSelect>
-
- <MkSwitch v-model="profile.showTimelineReplies">{{ i18n.ts.flagShowTimelineReplies }}<template #caption>{{ i18n.ts.flagShowTimelineRepliesDescription }} {{ i18n.ts.reflectMayTakeTime }}</template></MkSwitch>
</div>
</template>
@@ -248,36 +248,35 @@ definePageMetadata({
});
</script>
-<style lang="scss" scoped>
-.llvierxe {
+<style lang="scss" module>
+.avatarAndBanner {
position: relative;
background-size: cover;
background-position: center;
border: solid 1px var(--divider);
border-radius: 10px;
overflow: clip;
+}
- > .avatar {
- display: inline-block;
- text-align: center;
- padding: 16px;
+.avatarContainer {
+ display: inline-block;
+ text-align: center;
+ padding: 16px;
+}
- > .avatar {
- display: inline-block;
- width: 72px;
- height: 72px;
- margin: 0 auto 16px auto;
- }
- }
+.avatar {
+ display: inline-block;
+ width: 72px;
+ height: 72px;
+ margin: 0 auto 16px auto;
+}
- > .bannerEdit {
- position: absolute;
- top: 16px;
- right: 16px;
- }
+.bannerEdit {
+ position: absolute;
+ top: 16px;
+ right: 16px;
}
-</style>
-<style lang="scss" module>
+
.metadataRoot {
container-type: inline-size;
}
diff --git a/packages/frontend/src/pages/settings/reaction.vue b/packages/frontend/src/pages/settings/reaction.vue
index ed913731d3..cb483e34b5 100644
--- a/packages/frontend/src/pages/settings/reaction.vue
+++ b/packages/frontend/src/pages/settings/reaction.vue
@@ -3,15 +3,15 @@
<FromSlot>
<template #label>{{ i18n.ts.reactionSettingDescription }}</template>
<div v-panel style="border-radius: 6px;">
- <Sortable v-model="reactions" class="zoaiodol" :item-key="item => item" :animation="150" :delay="100" :delay-on-touch-only="true">
+ <Sortable v-model="reactions" :class="$style.reactions" :itemKey="item => item" :animation="150" :delay="100" :delayOnTouchOnly="true">
<template #item="{element}">
- <button class="_button item" @click="remove(element, $event)">
+ <button class="_button" :class="$style.reactionsItem" @click="remove(element, $event)">
<MkCustomEmoji v-if="element[0] === ':'" :name="element" :normal="true"/>
<MkEmoji v-else :emoji="element" :normal="true"/>
</button>
</template>
<template #footer>
- <button class="_button add" @click="chooseEmoji"><i class="ti ti-plus"></i></button>
+ <button class="_button" :class="$style.reactionsAdd" @click="chooseEmoji"><i class="ti ti-plus"></i></button>
</template>
</Sortable>
</div>
@@ -135,20 +135,20 @@ definePageMetadata({
});
</script>
-<style lang="scss" scoped>
-.zoaiodol {
+<style lang="scss" module>
+.reactions {
padding: 12px;
font-size: 1.1em;
+}
- > .item {
- display: inline-block;
- padding: 8px;
- cursor: move;
- }
+.reactionsItem {
+ display: inline-block;
+ padding: 8px;
+ cursor: move;
+}
- > .add {
- display: inline-block;
- padding: 8px;
- }
+.reactionsAdd {
+ display: inline-block;
+ padding: 8px;
}
</style>
diff --git a/packages/frontend/src/pages/settings/roles.vue b/packages/frontend/src/pages/settings/roles.vue
index ba510dced3..05753c9b60 100644
--- a/packages/frontend/src/pages/settings/roles.vue
+++ b/packages/frontend/src/pages/settings/roles.vue
@@ -3,7 +3,7 @@
<FormSection first>
<template #label>{{ i18n.ts.rolesAssignedToMe }}</template>
<div class="_gaps_s">
- <MkRolePreview v-for="role in $i.roles" :key="role.id" :role="role" :for-moderation="false"/>
+ <MkRolePreview v-for="role in $i.roles" :key="role.id" :role="role" :forModeration="false"/>
</div>
</FormSection>
<FormSection>
diff --git a/packages/frontend/src/pages/settings/security.vue b/packages/frontend/src/pages/settings/security.vue
index 0cc2df09c5..2da84763a3 100644
--- a/packages/frontend/src/pages/settings/security.vue
+++ b/packages/frontend/src/pages/settings/security.vue
@@ -9,7 +9,7 @@
<FormSection>
<template #label>{{ i18n.ts.signinHistory }}</template>
- <MkPagination :pagination="pagination" disable-auto-load>
+ <MkPagination :pagination="pagination" disableAutoLoad>
<template #default="{items}">
<div>
<div v-for="item in items" :key="item.id" v-panel class="timnmucd">
diff --git a/packages/frontend/src/pages/settings/sounds.sound.vue b/packages/frontend/src/pages/settings/sounds.sound.vue
index aa9f528006..c1a333548d 100644
--- a/packages/frontend/src/pages/settings/sounds.sound.vue
+++ b/packages/frontend/src/pages/settings/sounds.sound.vue
@@ -4,7 +4,7 @@
<template #label>{{ i18n.ts.sound }}</template>
<option v-for="x in soundsTypes" :key="x" :value="x">{{ x == null ? i18n.ts.none : x }}</option>
</MkSelect>
- <MkRange v-model="volume" :min="0" :max="1" :step="0.05" :text-converter="(v) => `${Math.floor(v * 100)}%`">
+ <MkRange v-model="volume" :min="0" :max="1" :step="0.05" :textConverter="(v) => `${Math.floor(v * 100)}%`">
<template #label>{{ i18n.ts.volume }}</template>
</MkRange>
diff --git a/packages/frontend/src/pages/settings/sounds.vue b/packages/frontend/src/pages/settings/sounds.vue
index 724fe43478..c2bf3f8cd5 100644
--- a/packages/frontend/src/pages/settings/sounds.vue
+++ b/packages/frontend/src/pages/settings/sounds.vue
@@ -1,6 +1,6 @@
<template>
<div class="_gaps_m">
- <MkRange v-model="masterVolume" :min="0" :max="1" :step="0.05" :text-converter="(v) => `${Math.floor(v * 100)}%`">
+ <MkRange v-model="masterVolume" :min="0" :max="1" :step="0.05" :textConverter="(v) => `${Math.floor(v * 100)}%`">
<template #label>{{ i18n.ts.masterVolume }}</template>
</MkRange>
diff --git a/packages/frontend/src/pages/settings/statusbar.statusbar.vue b/packages/frontend/src/pages/settings/statusbar.statusbar.vue
index 81ff873e9e..c73ff7c075 100644
--- a/packages/frontend/src/pages/settings/statusbar.statusbar.vue
+++ b/packages/frontend/src/pages/settings/statusbar.statusbar.vue
@@ -7,7 +7,7 @@
<option value="userList">User list timeline</option>
</MkSelect>
- <MkInput v-model="statusbar.name" manual-save>
+ <MkInput v-model="statusbar.name" manualSave>
<template #label>{{ i18n.ts.label }}</template>
</MkInput>
@@ -25,13 +25,13 @@
</MkRadios>
<template v-if="statusbar.type === 'rss'">
- <MkInput v-model="statusbar.props.url" manual-save type="url">
+ <MkInput v-model="statusbar.props.url" manualSave type="url">
<template #label>URL</template>
</MkInput>
<MkSwitch v-model="statusbar.props.shuffle">
<template #label>{{ i18n.ts.shuffle }}</template>
</MkSwitch>
- <MkInput v-model="statusbar.props.refreshIntervalSec" manual-save type="number">
+ <MkInput v-model="statusbar.props.refreshIntervalSec" manualSave type="number">
<template #label>{{ i18n.ts.refreshInterval }}</template>
</MkInput>
<MkRange v-model="statusbar.props.marqueeDuration" :min="5" :max="150" :step="1">
@@ -43,7 +43,7 @@
</MkSwitch>
</template>
<template v-else-if="statusbar.type === 'federation'">
- <MkInput v-model="statusbar.props.refreshIntervalSec" manual-save type="number">
+ <MkInput v-model="statusbar.props.refreshIntervalSec" manualSave type="number">
<template #label>{{ i18n.ts.refreshInterval }}</template>
</MkInput>
<MkRange v-model="statusbar.props.marqueeDuration" :min="5" :max="150" :step="1">
@@ -62,7 +62,7 @@
<template #label>{{ i18n.ts.userList }}</template>
<option v-for="list in userLists" :value="list.id">{{ list.name }}</option>
</MkSelect>
- <MkInput v-model="statusbar.props.refreshIntervalSec" manual-save type="number">
+ <MkInput v-model="statusbar.props.refreshIntervalSec" manualSave type="number">
<template #label>{{ i18n.ts.refreshInterval }}</template>
</MkInput>
<MkRange v-model="statusbar.props.marqueeDuration" :min="5" :max="150" :step="1">
diff --git a/packages/frontend/src/pages/settings/statusbar.vue b/packages/frontend/src/pages/settings/statusbar.vue
index f5a090a63b..bfb69936e1 100644
--- a/packages/frontend/src/pages/settings/statusbar.vue
+++ b/packages/frontend/src/pages/settings/statusbar.vue
@@ -3,7 +3,7 @@
<MkFolder v-for="x in statusbars" :key="x.id">
<template #label>{{ x.type ?? i18n.ts.notSet }}</template>
<template #suffix>{{ x.name }}</template>
- <XStatusbar :_id="x.id" :user-lists="userLists"/>
+ <XStatusbar :_id="x.id" :userLists="userLists"/>
</MkFolder>
<MkButton primary @click="add">{{ i18n.ts.add }}</MkButton>
</div>
diff --git a/packages/frontend/src/pages/settings/theme.manage.vue b/packages/frontend/src/pages/settings/theme.manage.vue
index d1821a00d4..0255435112 100644
--- a/packages/frontend/src/pages/settings/theme.manage.vue
+++ b/packages/frontend/src/pages/settings/theme.manage.vue
@@ -10,13 +10,13 @@
</optgroup>
</MkSelect>
<template v-if="selectedTheme">
- <MkInput readonly :model-value="selectedTheme.author">
+ <MkInput readonly :modelValue="selectedTheme.author">
<template #label>{{ i18n.ts.author }}</template>
</MkInput>
- <MkTextarea v-if="selectedTheme.desc" readonly :model-value="selectedTheme.desc">
+ <MkTextarea v-if="selectedTheme.desc" readonly :modelValue="selectedTheme.desc">
<template #label>{{ i18n.ts._theme.description }}</template>
</MkTextarea>
- <MkTextarea readonly tall :model-value="selectedThemeCode">
+ <MkTextarea readonly tall :modelValue="selectedThemeCode">
<template #label>{{ i18n.ts._theme.code }}</template>
<template #caption><button class="_textButton" @click="copyThemeCode()">{{ i18n.ts.copy }}</button></template>
</MkTextarea>
diff --git a/packages/frontend/src/pages/share.vue b/packages/frontend/src/pages/share.vue
index 78e0710162..e0ac899230 100644
--- a/packages/frontend/src/pages/share.vue
+++ b/packages/frontend/src/pages/share.vue
@@ -1,22 +1,25 @@
<template>
<MkStickyContainer>
<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
- <MkSpacer :content-max="800">
+ <MkSpacer :contentMax="800">
<MkPostForm
v-if="state === 'writing'"
fixed
:instant="true"
- :initial-text="initialText"
- :initial-visibility="visibility"
- :initial-files="files"
- :initial-local-only="localOnly"
+ :initialText="initialText"
+ :initialVisibility="visibility"
+ :initialFiles="files"
+ :initialLocalOnly="localOnly"
:reply="reply"
:renote="renote"
- :initial-visible-users="visibleUsers"
+ :initialVisibleUsers="visibleUsers"
class="_panel"
@posted="state = 'posted'"
/>
- <MkButton v-else-if="state === 'posted'" primary class="close" @click="close()">{{ i18n.ts.close }}</MkButton>
+ <div v-else-if="state === 'posted'" class="_buttonsCenter">
+ <MkButton primary @click="close">{{ i18n.ts.close }}</MkButton>
+ <MkButton @click="goToMisskey">{{ i18n.ts.goToMisskey }}</MkButton>
+ </div>
</MkSpacer>
</MkStickyContainer>
</template>
@@ -148,10 +151,14 @@ function close(): void {
// 閉じなければ100ms後タイムラインに
window.setTimeout(() => {
- mainRouter.push('/');
+ location.href = '/';
}, 100);
}
+function goToMisskey(): void {
+ location.href = '/';
+}
+
const headerActions = $computed(() => []);
const headerTabs = $computed(() => []);
@@ -161,9 +168,3 @@ definePageMetadata({
icon: 'ti ti-share',
});
</script>
-
-<style lang="scss" scoped>
-.close {
- margin: 16px auto;
-}
-</style>
diff --git a/packages/frontend/src/pages/signup-complete.vue b/packages/frontend/src/pages/signup-complete.vue
index 5459532310..61d7eb24fd 100644
--- a/packages/frontend/src/pages/signup-complete.vue
+++ b/packages/frontend/src/pages/signup-complete.vue
@@ -1,41 +1,80 @@
<template>
<div>
- {{ i18n.ts.processing }}
+ <MkAnimBg style="position: fixed; top: 0;"/>
+ <div :class="$style.formContainer">
+ <form :class="$style.form" class="_panel" @submit.prevent="submit()">
+ <div :class="$style.banner">
+ <i class="ti ti-user-check"></i>
+ </div>
+ <div class="_gaps_m" style="padding: 32px;">
+ <div>{{ i18n.t('clickToFinishEmailVerification', { ok: i18n.ts.gotIt }) }}</div>
+ <div>
+ <MkButton gradate large rounded type="submit" :disabled="submitting" data-cy-admin-ok style="margin: 0 auto;">
+ {{ submitting ? i18n.ts.processing : i18n.ts.gotIt }}<MkEllipsis v-if="submitting"/>
+ </MkButton>
+ </div>
+ </div>
+ </form>
+ </div>
</div>
</template>
<script lang="ts" setup>
-import { onMounted } from 'vue';
-import * as os from '@/os';
+import { } from 'vue';
+import MkButton from '@/components/MkButton.vue';
+import MkAnimBg from '@/components/MkAnimBg.vue';
import { login } from '@/account';
import { i18n } from '@/i18n';
-import { definePageMetadata } from '@/scripts/page-metadata';
+import * as os from '@/os';
+
+let submitting = $ref(false);
const props = defineProps<{
code: string;
}>();
-onMounted(async () => {
- await os.alert({
- type: 'info',
- text: i18n.t('clickToFinishEmailVerification', { ok: i18n.ts.gotIt }),
- });
- const res = await os.apiWithDialog('signup-pending', {
- code: props.code,
- });
- login(res.i, '/');
-});
-
-const headerActions = $computed(() => []);
+function submit() {
+ if (submitting) return;
+ submitting = true;
-const headerTabs = $computed(() => []);
+ os.api('signup-pending', {
+ code: props.code,
+ }).then(res => {
+ return login(res.i, '/');
+ }).catch(() => {
+ submitting = false;
-definePageMetadata({
- title: i18n.ts.signup,
- icon: 'ti ti-user',
-});
+ os.alert({
+ type: 'error',
+ text: i18n.ts.somethingHappened,
+ });
+ });
+}
</script>
-<style lang="scss" scoped>
+<style lang="scss" module>
+.formContainer {
+ min-height: 100svh;
+ padding: 32px 32px 64px 32px;
+ box-sizing: border-box;
+display: grid;
+place-content: center;
+}
+
+.form {
+ position: relative;
+ z-index: 10;
+ border-radius: var(--radius);
+ box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1);
+ overflow: clip;
+ max-width: 500px;
+}
+.banner {
+ padding: 16px;
+ text-align: center;
+ font-size: 26px;
+ background-color: var(--accentedBg);
+ color: var(--accent);
+}
</style>
diff --git a/packages/frontend/src/pages/tag.vue b/packages/frontend/src/pages/tag.vue
index 511052c424..104e738866 100644
--- a/packages/frontend/src/pages/tag.vue
+++ b/packages/frontend/src/pages/tag.vue
@@ -1,16 +1,28 @@
<template>
<MkStickyContainer>
<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
- <MkSpacer :content-max="800">
- <MkNotes class="" :pagination="pagination"/>
+ <MkSpacer :contentMax="800">
+ <MkNotes ref="notes" class="" :pagination="pagination"/>
</MkSpacer>
+ <template v-if="$i" #footer>
+ <div :class="$style.footer">
+ <MkSpacer :contentMax="800" :marginMin="16" :marginMax="16">
+ <MkButton rounded primary :class="$style.button" @click="post()"><i class="ti ti-pencil"></i>{{ i18n.ts.postToHashtag }}</MkButton>
+ </MkSpacer>
+ </div>
+ </template>
</MkStickyContainer>
</template>
<script lang="ts" setup>
-import { computed } from 'vue';
+import { computed, ref } from 'vue';
import MkNotes from '@/components/MkNotes.vue';
+import MkButton from '@/components/MkButton.vue';
import { definePageMetadata } from '@/scripts/page-metadata';
+import { i18n } from '@/i18n';
+import { $i } from '@/account';
+import { defaultStore } from '@/store';
+import * as os from '@/os';
const props = defineProps<{
tag: string;
@@ -23,6 +35,16 @@ const pagination = {
tag: props.tag,
})),
};
+const notes = ref<InstanceType<typeof MkNotes>>();
+
+async function post() {
+ defaultStore.set('postFormHashtags', props.tag);
+ defaultStore.set('postFormWithHashtags', true);
+ await os.post();
+ defaultStore.set('postFormHashtags', '');
+ defaultStore.set('postFormWithHashtags', false);
+ notes.value?.pagingComponent?.reload();
+}
const headerActions = $computed(() => []);
@@ -33,3 +55,16 @@ definePageMetadata(computed(() => ({
icon: 'ti ti-hash',
})));
</script>
+
+<style lang="scss" module>
+.footer {
+ -webkit-backdrop-filter: var(--blur, blur(15px));
+ backdrop-filter: var(--blur, blur(15px));
+ border-top: solid 0.5px var(--divider);
+ display: flex;
+}
+
+.button {
+ margin: 0 auto var(--margin) auto;
+}
+</style>
diff --git a/packages/frontend/src/pages/theme-editor.vue b/packages/frontend/src/pages/theme-editor.vue
index 56fdfdf782..f942b5005b 100644
--- a/packages/frontend/src/pages/theme-editor.vue
+++ b/packages/frontend/src/pages/theme-editor.vue
@@ -1,9 +1,9 @@
<template>
<MkStickyContainer>
<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
- <MkSpacer :content-max="800" :margin-min="16" :margin-max="32">
+ <MkSpacer :contentMax="800" :marginMin="16" :marginMax="32">
<div class="cwepdizn _gaps_m">
- <MkFolder :default-open="true">
+ <MkFolder :defaultOpen="true">
<template #label>{{ i18n.ts.backgroundColor }}</template>
<div class="cwepdizn-colors">
<div class="row">
@@ -19,7 +19,7 @@
</div>
</MkFolder>
- <MkFolder :default-open="true">
+ <MkFolder :defaultOpen="true">
<template #label>{{ i18n.ts.accentColor }}</template>
<div class="cwepdizn-colors">
<div class="row">
@@ -30,7 +30,7 @@
</div>
</MkFolder>
- <MkFolder :default-open="true">
+ <MkFolder :defaultOpen="true">
<template #label>{{ i18n.ts.textColor }}</template>
<div class="cwepdizn-colors">
<div class="row">
@@ -41,7 +41,7 @@
</div>
</MkFolder>
- <MkFolder :default-open="false">
+ <MkFolder :defaultOpen="false">
<template #icon><i class="ti ti-code"></i></template>
<template #label>{{ i18n.ts.editCode }}</template>
@@ -53,7 +53,7 @@
</div>
</MkFolder>
- <MkFolder :default-open="false">
+ <MkFolder :defaultOpen="false">
<template #label>{{ i18n.ts.addDescription }}</template>
<div class="_gaps_m">
diff --git a/packages/frontend/src/pages/timeline.vue b/packages/frontend/src/pages/timeline.vue
index 1bf4cdc99a..a441c6f728 100644
--- a/packages/frontend/src/pages/timeline.vue
+++ b/packages/frontend/src/pages/timeline.vue
@@ -1,12 +1,12 @@
<template>
<MkStickyContainer>
- <template #header><MkPageHeader v-model:tab="src" :actions="headerActions" :tabs="$i ? headerTabs : headerTabsWhenNotLogin" :display-my-avatar="true"/></template>
- <MkSpacer :content-max="800">
+ <template #header><MkPageHeader v-model:tab="src" :actions="headerActions" :tabs="$i ? headerTabs : headerTabsWhenNotLogin" :displayMyAvatar="true"/></template>
+ <MkSpacer :contentMax="800">
<div ref="rootEl" v-hotkey.global="keymap">
<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>
+ <div v-if="queue > 0" :class="$style.new"><button class="_buttonPrimary" :class="$style.newButton" @click="top()">{{ i18n.ts.newNoteRecived }}</button></div>
<div :class="$style.tl">
<MkTimeline
ref="tlComponent"
@@ -187,13 +187,13 @@ definePageMetadata(computed(() => ({
&:first-child {
margin-top: calc(-0.675em - 8px - var(--margin));
}
+}
- > button {
- display: block;
- margin: var(--margin) auto 0 auto;
- padding: 8px 16px;
- border-radius: 32px;
- }
+.newButton {
+ display: block;
+ margin: var(--margin) auto 0 auto;
+ padding: 8px 16px;
+ border-radius: 32px;
}
.postForm {
diff --git a/packages/frontend/src/pages/user-info.vue b/packages/frontend/src/pages/user-info.vue
index 94718d1533..56e8737e1c 100644
--- a/packages/frontend/src/pages/user-info.vue
+++ b/packages/frontend/src/pages/user-info.vue
@@ -1,7 +1,7 @@
<template>
<MkStickyContainer>
<template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
- <MkSpacer :content-max="600" :margin-min="16" :margin-max="32">
+ <MkSpacer :contentMax="600" :marginMin="16" :marginMax="32">
<FormSuspense :p="init">
<div v-if="tab === 'overview'" class="_gaps_m">
<div class="aeakzknw">
@@ -88,7 +88,7 @@
</div>
<div v-else-if="tab === 'moderation'" class="_gaps_m">
- <MkSwitch v-model="suspended" @update:model-value="toggleSuspend">{{ i18n.ts.suspend }}</MkSwitch>
+ <MkSwitch v-model="suspended" @update:modelValue="toggleSuspend">{{ i18n.ts.suspend }}</MkSwitch>
<div>
<MkButton v-if="user.host == null && iAmModerator" inline style="margin-right: 8px;" @click="resetPassword"><i class="ti ti-key"></i> {{ i18n.ts.resetPassword }}</MkButton>
@@ -112,7 +112,7 @@
<MkButton v-if="user.host == null && iAmModerator" primary rounded @click="assignRole"><i class="ti ti-plus"></i> {{ i18n.ts.assign }}</MkButton>
<div v-for="role in info.roles" :key="role.id" :class="$style.roleItem">
- <MkRolePreview :class="$style.role" :role="role" :for-moderation="true"/>
+ <MkRolePreview :class="$style.role" :role="role" :forModeration="true"/>
<button v-if="role.target === 'manual'" class="_button" :class="$style.roleUnassign" @click="unassignRole(role, $event)"><i class="ti ti-x"></i></button>
<button v-else class="_button" :class="$style.roleUnassign" disabled><i class="ti ti-ban"></i></button>
</div>
@@ -135,10 +135,10 @@
<MkFolder>
<template #icon><i class="ti ti-cloud"></i></template>
<template #label>{{ i18n.ts.files }}</template>
- <MkFileListForAdmin :pagination="filesPagination" view-mode="grid"/>
+ <MkFileListForAdmin :pagination="filesPagination" viewMode="grid"/>
</MkFolder>
- <MkTextarea v-model="moderationNote" manual-save>
+ <MkTextarea v-model="moderationNote" manualSave>
<template #label>Moderation note</template>
</MkTextarea>
</div>
diff --git a/packages/frontend/src/pages/user-list-timeline.vue b/packages/frontend/src/pages/user-list-timeline.vue
index acf7ea9b2c..f66670e1f6 100644
--- a/packages/frontend/src/pages/user-list-timeline.vue
+++ b/packages/frontend/src/pages/user-list-timeline.vue
@@ -1,19 +1,20 @@
<template>
<MkStickyContainer>
<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
- <div ref="rootEl" class="eqqrhokj">
- <div v-if="queue > 0" class="new"><button class="_buttonPrimary" @click="top()">{{ i18n.ts.newNoteRecived }}</button></div>
- <div class="tl">
- <MkTimeline
- ref="tlEl" :key="listId"
- class="tl"
- src="list"
- :list="listId"
- :sound="true"
- @queue="queueUpdated"
- />
+ <MkSpacer :contentMax="800">
+ <div ref="rootEl">
+ <div v-if="queue > 0" :class="$style.new"><button class="_buttonPrimary" :class="$style.newButton" @click="top()">{{ i18n.ts.newNoteRecived }}</button></div>
+ <div :class="$style.tl">
+ <MkTimeline
+ ref="tlEl" :key="listId"
+ src="list"
+ :list="listId"
+ :sound="true"
+ @queue="queueUpdated"
+ />
+ </div>
</div>
- </div>
+ </MkSpacer>
</MkStickyContainer>
</template>
@@ -82,36 +83,29 @@ definePageMetadata(computed(() => list ? {
} : null));
</script>
-<style lang="scss" scoped>
-.eqqrhokj {
- padding: var(--margin);
+<style lang="scss" module>
+.new {
+ position: sticky;
+ top: calc(var(--stickyTop, 0px) + 16px);
+ z-index: 1000;
+ width: 100%;
+ margin: calc(-0.675em - 8px) 0;
- > .new {
- position: sticky;
- top: calc(var(--stickyTop, 0px) + 16px);
- z-index: 1000;
- width: 100%;
- margin: calc(-0.675em - 8px - var(--margin)) 0 calc(-0.675em - 8px);
-
- > button {
- display: block;
- margin: var(--margin) auto 0 auto;
- padding: 8px 16px;
- border-radius: 32px;
- }
+ &:first-child {
+ margin-top: calc(-0.675em - 8px - var(--margin));
}
+}
- > .tl {
- background: var(--bg);
- border-radius: var(--radius);
- overflow: clip;
- }
+.newButton {
+ display: block;
+ margin: var(--margin) auto 0 auto;
+ padding: 8px 16px;
+ border-radius: 32px;
}
-@container (min-width: 800px) {
- .eqqrhokj {
- max-width: 800px;
- margin: 0 auto;
- }
+.tl {
+ background: var(--bg);
+ border-radius: var(--radius);
+ overflow: clip;
}
</style>
diff --git a/packages/frontend/src/pages/user-tag.vue b/packages/frontend/src/pages/user-tag.vue
index fac7593e9c..01ef1126cb 100644
--- a/packages/frontend/src/pages/user-tag.vue
+++ b/packages/frontend/src/pages/user-tag.vue
@@ -2,7 +2,7 @@
<MkStickyContainer>
<template #header><MkPageHeader/></template>
- <MkSpacer :content-max="1200">
+ <MkSpacer :contentMax="1200">
<div class="_gaps_s">
<MkUserList :pagination="tagUsers"/>
</div>
diff --git a/packages/frontend/src/pages/user/achievements.vue b/packages/frontend/src/pages/user/achievements.vue
index 1b3a6e24b3..7d5993c265 100644
--- a/packages/frontend/src/pages/user/achievements.vue
+++ b/packages/frontend/src/pages/user/achievements.vue
@@ -1,6 +1,6 @@
<template>
-<MkSpacer :content-max="1200">
- <MkAchievements :user="user" :with-locked="false" :with-description="$i != null && (props.user.id === $i.id)"/>
+<MkSpacer :contentMax="1200">
+ <MkAchievements :user="user" :withLocked="false" :withDescription="$i != null && (props.user.id === $i.id)"/>
</MkSpacer>
</template>
diff --git a/packages/frontend/src/pages/user/activity.vue b/packages/frontend/src/pages/user/activity.vue
index cd538ad61f..655371ac1d 100644
--- a/packages/frontend/src/pages/user/activity.vue
+++ b/packages/frontend/src/pages/user/activity.vue
@@ -1,5 +1,5 @@
<template>
-<MkSpacer :content-max="700">
+<MkSpacer :contentMax="700">
<div class="_gaps">
<MkFoldableSection class="item">
<template #header><i class="ti ti-activity"></i> Heatmap</template>
@@ -34,7 +34,3 @@ const props = defineProps<{
}>();
</script>
-
-<style lang="scss" scoped>
-
-</style>
diff --git a/packages/frontend/src/pages/user/clips.vue b/packages/frontend/src/pages/user/clips.vue
index 95f8cbc296..08b7b9a71f 100644
--- a/packages/frontend/src/pages/user/clips.vue
+++ b/packages/frontend/src/pages/user/clips.vue
@@ -1,10 +1,10 @@
<template>
-<MkSpacer :content-max="700">
- <div class="pages-user-clips">
- <MkPagination v-slot="{items}" ref="list" :pagination="pagination" class="list">
- <MkA v-for="item in items" :key="item.id" :to="`/clips/${item.id}`" class="item _panel _margin">
+<MkSpacer :contentMax="700">
+ <div>
+ <MkPagination v-slot="{items}" ref="list" :pagination="pagination">
+ <MkA v-for="item in items" :key="item.id" :to="`/clips/${item.id}`" :class="$style.item" class="_panel _margin">
<b>{{ item.name }}</b>
- <div v-if="item.description" class="description">{{ item.description }}</div>
+ <div v-if="item.description" :class="$style.description">{{ item.description }}</div>
</MkA>
</MkPagination>
</div>
@@ -29,19 +29,15 @@ const pagination = {
};
</script>
-<style lang="scss" scoped>
-.pages-user-clips {
- > .list {
- > .item {
- display: block;
- padding: 16px;
+<style lang="scss" module>
+.item {
+ display: block;
+ padding: 16px;
+}
- > .description {
- margin-top: 8px;
- padding-top: 8px;
- border-top: solid 0.5px var(--divider);
- }
- }
- }
+.description {
+ margin-top: 8px;
+ padding-top: 8px;
+ border-top: solid 0.5px var(--divider);
}
</style>
diff --git a/packages/frontend/src/pages/user/follow-list.vue b/packages/frontend/src/pages/user/follow-list.vue
index d42acd838f..4e76ddfe79 100644
--- a/packages/frontend/src/pages/user/follow-list.vue
+++ b/packages/frontend/src/pages/user/follow-list.vue
@@ -1,8 +1,8 @@
<template>
<div>
- <MkPagination v-slot="{items}" ref="list" :pagination="type === 'following' ? followingPagination : followersPagination" class="mk-following-or-followers">
- <div class="users">
- <MkUserInfo v-for="user in items.map(x => type === 'following' ? x.followee : x.follower)" :key="user.id" class="user" :user="user"/>
+ <MkPagination v-slot="{items}" ref="list" :pagination="type === 'following' ? followingPagination : followersPagination">
+ <div :class="$style.users">
+ <MkUserInfo v-for="user in items.map(x => type === 'following' ? x.followee : x.follower)" :key="user.id" :user="user"/>
</div>
</MkPagination>
</div>
@@ -36,12 +36,10 @@ const followersPagination = {
};
</script>
-<style lang="scss" scoped>
-.mk-following-or-followers {
- > .users {
- display: grid;
- grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
- grid-gap: var(--margin);
- }
+<style lang="scss" module>
+.users {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
+ grid-gap: var(--margin);
}
</style>
diff --git a/packages/frontend/src/pages/user/followers.vue b/packages/frontend/src/pages/user/followers.vue
index 20573e67e9..b330f78637 100644
--- a/packages/frontend/src/pages/user/followers.vue
+++ b/packages/frontend/src/pages/user/followers.vue
@@ -1,7 +1,7 @@
<template>
<MkStickyContainer>
<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
- <MkSpacer :content-max="1000">
+ <MkSpacer :contentMax="1000">
<Transition name="fade" mode="out-in">
<div v-if="user">
<XFollowList :user="user" type="followers"/>
@@ -56,6 +56,3 @@ definePageMetadata(computed(() => user ? {
avatar: user,
} : null));
</script>
-
-<style lang="scss" scoped>
-</style>
diff --git a/packages/frontend/src/pages/user/following.vue b/packages/frontend/src/pages/user/following.vue
index 3825f138cf..9544cf76ca 100644
--- a/packages/frontend/src/pages/user/following.vue
+++ b/packages/frontend/src/pages/user/following.vue
@@ -1,7 +1,7 @@
<template>
<MkStickyContainer>
<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
- <MkSpacer :content-max="1000">
+ <MkSpacer :contentMax="1000">
<Transition name="fade" mode="out-in">
<div v-if="user">
<XFollowList :user="user" type="following"/>
@@ -56,6 +56,3 @@ definePageMetadata(computed(() => user ? {
avatar: user,
} : null));
</script>
-
-<style lang="scss" scoped>
-</style>
diff --git a/packages/frontend/src/pages/user/gallery.vue b/packages/frontend/src/pages/user/gallery.vue
index b80e83fb11..b4bbab16fd 100644
--- a/packages/frontend/src/pages/user/gallery.vue
+++ b/packages/frontend/src/pages/user/gallery.vue
@@ -1,7 +1,7 @@
<template>
-<MkSpacer :content-max="700">
+<MkSpacer :contentMax="700">
<MkPagination v-slot="{items}" :pagination="pagination">
- <div class="jrnovfpt">
+ <div :class="$style.root">
<MkGalleryPostPreview v-for="post in items" :key="post.id" :post="post" class="post"/>
</div>
</MkPagination>
@@ -28,8 +28,8 @@ const pagination = {
};
</script>
-<style lang="scss" scoped>
-.jrnovfpt {
+<style lang="scss" module>
+.root {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
grid-gap: 12px;
diff --git a/packages/frontend/src/pages/user/home.vue b/packages/frontend/src/pages/user/home.vue
index 9c133346d5..2e69eb367b 100644
--- a/packages/frontend/src/pages/user/home.vue
+++ b/packages/frontend/src/pages/user/home.vue
@@ -1,5 +1,5 @@
<template>
-<MkSpacer :content-max="narrow ? 800 : 1100">
+<MkSpacer :contentMax="narrow ? 800 : 1100">
<div ref="rootEl" class="ftskorzw" :class="{ wide: !narrow }" style="container-type: inline-size;">
<div class="main _gaps">
<!-- TODO -->
@@ -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.movedTo" :moved-to="user.movedTo"/>
+ <MkAccountMoved v-if="user.movedTo" :movedTo="user.movedTo"/>
<MkRemoteCaution v-if="user.host != null" :href="user.url ?? user.uri!" class="warn"/>
<div :key="user.id" class="main _panel">
@@ -49,7 +49,7 @@
</span>
</div>
<div v-if="iAmModerator" class="moderationNote">
- <MkTextarea v-if="editModerationNote || (moderationNote != null && moderationNote !== '')" v-model="moderationNote" manual-save>
+ <MkTextarea v-if="editModerationNote || (moderationNote != null && moderationNote !== '')" v-model="moderationNote" manualSave>
<template #label>Moderation note</template>
</MkTextarea>
<div v-else>
@@ -69,7 +69,7 @@
</div>
<div class="description">
<MkOmit>
- <Mfm v-if="user.description" :text="user.description" :is-note="false" :author="user" :i="$i"/>
+ <Mfm v-if="user.description" :text="user.description" :isNote="false" :author="user" :i="$i"/>
<p v-else class="empty">{{ i18n.ts.noAccountDescription }}</p>
</MkOmit>
</div>
@@ -123,7 +123,7 @@
<XPhotos :key="user.id" :user="user"/>
<XActivity :key="user.id" :user="user"/>
</template>
- <MkNotes v-if="!disableNotes" :class="$style.tl" :no-gap="true" :pagination="pagination"/>
+ <MkNotes v-if="!disableNotes" :class="$style.tl" :noGap="true" :pagination="pagination"/>
</div>
</div>
<div v-if="!narrow" class="sub _gaps" style="container-type: inline-size;">
diff --git a/packages/frontend/src/pages/user/index.activity.vue b/packages/frontend/src/pages/user/index.activity.vue
index 2d9ee85bc4..64d36307e9 100644
--- a/packages/frontend/src/pages/user/index.activity.vue
+++ b/packages/frontend/src/pages/user/index.activity.vue
@@ -9,7 +9,7 @@
</template>
<div style="padding: 8px;">
- <MkChart :src="chartSrc" :args="{ user, withoutAll: true }" span="day" :limit="limit" :bar="true" :stacked="true" :detailed="false" :aspect-ratio="5"/>
+ <MkChart :src="chartSrc" :args="{ user, withoutAll: true }" span="day" :limit="limit" :bar="true" :stacked="true" :detailed="false" :aspectRatio="5"/>
</div>
</MkContainer>
</template>
diff --git a/packages/frontend/src/pages/user/index.timeline.vue b/packages/frontend/src/pages/user/index.timeline.vue
index d8fc253910..91c580ce96 100644
--- a/packages/frontend/src/pages/user/index.timeline.vue
+++ b/packages/frontend/src/pages/user/index.timeline.vue
@@ -1,5 +1,5 @@
<template>
-<MkSpacer :content-max="800" style="padding-top: 0">
+<MkSpacer :contentMax="800" style="padding-top: 0">
<MkStickyContainer>
<template #header>
<MkTab v-model="include" :class="$style.tab">
@@ -8,7 +8,7 @@
<option value="files">{{ i18n.ts.withFiles }}</option>
</MkTab>
</template>
- <MkNotes :no-gap="true" :pagination="pagination" :class="$style.tl"/>
+ <MkNotes :noGap="true" :pagination="pagination" :class="$style.tl"/>
</MkStickyContainer>
</MkSpacer>
</template>
diff --git a/packages/frontend/src/pages/user/index.vue b/packages/frontend/src/pages/user/index.vue
index 03a226cc09..6aba815e9d 100644
--- a/packages/frontend/src/pages/user/index.vue
+++ b/packages/frontend/src/pages/user/index.vue
@@ -2,20 +2,19 @@
<MkStickyContainer>
<template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
<div>
- <Transition name="fade" mode="out-in">
- <div v-if="user">
- <XHome v-if="tab === 'home'" :user="user"/>
- <XTimeline v-else-if="tab === 'notes'" :user="user" />
- <XActivity v-else-if="tab === 'activity'" :user="user"/>
- <XAchievements v-else-if="tab === 'achievements'" :user="user"/>
- <XReactions v-else-if="tab === 'reactions'" :user="user"/>
- <XClips v-else-if="tab === 'clips'" :user="user"/>
- <XPages v-else-if="tab === 'pages'" :user="user"/>
- <XGallery v-else-if="tab === 'gallery'" :user="user"/>
- </div>
- <MkError v-else-if="error" @retry="fetchUser()"/>
- <MkLoading v-else/>
- </Transition>
+ <div v-if="user">
+ <XHome v-if="tab === 'home'" :user="user"/>
+ <XTimeline v-else-if="tab === 'notes'" :user="user"/>
+ <XActivity v-else-if="tab === 'activity'" :user="user"/>
+ <XAchievements v-else-if="tab === 'achievements'" :user="user"/>
+ <XReactions v-else-if="tab === 'reactions'" :user="user"/>
+ <XClips v-else-if="tab === 'clips'" :user="user"/>
+ <XLists v-else-if="tab === 'lists'" :user="user"/>
+ <XPages v-else-if="tab === 'pages'" :user="user"/>
+ <XGallery v-else-if="tab === 'gallery'" :user="user"/>
+ </div>
+ <MkError v-else-if="error" @retry="fetchUser()"/>
+ <MkLoading v-else/>
</div>
</MkStickyContainer>
</template>
@@ -36,6 +35,7 @@ const XActivity = defineAsyncComponent(() => import('./activity.vue'));
const XAchievements = defineAsyncComponent(() => import('./achievements.vue'));
const XReactions = defineAsyncComponent(() => import('./reactions.vue'));
const XClips = defineAsyncComponent(() => import('./clips.vue'));
+const XLists = defineAsyncComponent(() => import('./lists.vue'));
const XPages = defineAsyncComponent(() => import('./pages.vue'));
const XGallery = defineAsyncComponent(() => import('./gallery.vue'));
@@ -91,6 +91,10 @@ const headerTabs = $computed(() => user ? [{
title: i18n.ts.clips,
icon: 'ti ti-paperclip',
}, {
+ key: 'lists',
+ title: i18n.ts.lists,
+ icon: 'ti ti-list',
+}, {
key: 'pages',
title: i18n.ts.pages,
icon: 'ti ti-news',
@@ -112,14 +116,3 @@ definePageMetadata(computed(() => user ? {
},
} : null));
</script>
-
-<style lang="scss" scoped>
-.fade-enter-active,
-.fade-leave-active {
- transition: opacity 0.125s ease;
-}
-.fade-enter-from,
-.fade-leave-to {
- opacity: 0;
-}
-</style>
diff --git a/packages/frontend/src/pages/user/lists.vue b/packages/frontend/src/pages/user/lists.vue
new file mode 100644
index 0000000000..78f03d2b38
--- /dev/null
+++ b/packages/frontend/src/pages/user/lists.vue
@@ -0,0 +1,51 @@
+<template>
+<MkStickyContainer>
+ <MkSpacer :contentMax="700">
+ <div>
+ <MkPagination v-slot="{items}" ref="pagingComponent" :pagination="pagination" class="lists">
+ <MkA v-for="list in items" :key="list.id" class="_panel" :class="$style.list" :to="`/list/${ list.id }`">
+ <div>{{ list.name }}</div>
+ <MkAvatars :userIds="list.userIds"/>
+ </MkA>
+ </MkPagination>
+ </div>
+ </MkSpacer>
+</MkStickyContainer>
+</template>
+
+<script lang="ts" setup>
+import {} from 'vue';
+import * as misskey from 'misskey-js';
+import MkPagination from '@/components/MkPagination.vue';
+import MkStickyContainer from '@/components/global/MkStickyContainer.vue';
+import MkSpacer from '@/components/global/MkSpacer.vue';
+import MkAvatars from '@/components/MkAvatars.vue';
+
+const props = defineProps<{
+ user: misskey.entities.UserDetailed;
+}>();
+
+const pagination = {
+ endpoint: 'users/lists/list' as const,
+ noPaging: true,
+ limit: 10,
+ params: {
+ userId: props.user.id,
+ },
+};
+</script>
+
+<style lang="scss" module>
+.list {
+ display: block;
+ padding: 16px;
+ border: solid 1px var(--divider);
+ border-radius: 6px;
+ margin-bottom: 8px;
+
+ &:hover {
+ border: solid 1px var(--accent);
+ text-decoration: none;
+ }
+}
+</style>
diff --git a/packages/frontend/src/pages/user/pages.vue b/packages/frontend/src/pages/user/pages.vue
index 7ea1d75f43..a2975c7079 100644
--- a/packages/frontend/src/pages/user/pages.vue
+++ b/packages/frontend/src/pages/user/pages.vue
@@ -1,5 +1,5 @@
<template>
-<MkSpacer :content-max="700">
+<MkSpacer :contentMax="700">
<MkPagination v-slot="{items}" ref="list" :pagination="pagination">
<MkPagePreview v-for="page in items" :key="page.id" :page="page" class="_margin"/>
</MkPagination>
@@ -24,7 +24,3 @@ const pagination = {
})),
};
</script>
-
-<style lang="scss" scoped>
-
-</style>
diff --git a/packages/frontend/src/pages/user/reactions.vue b/packages/frontend/src/pages/user/reactions.vue
index 24129ec024..2281603394 100644
--- a/packages/frontend/src/pages/user/reactions.vue
+++ b/packages/frontend/src/pages/user/reactions.vue
@@ -1,11 +1,11 @@
<template>
-<MkSpacer :content-max="700">
+<MkSpacer :contentMax="700">
<MkPagination v-slot="{items}" ref="list" :pagination="pagination">
- <div v-for="item in items" :key="item.id" :to="`/clips/${item.id}`" class="item _panel _margin afdcfbfb">
- <div class="header">
- <MkAvatar class="avatar" :user="user"/>
- <MkReactionIcon class="reaction" :reaction="item.type" :no-style="true"/>
- <MkTime :time="item.createdAt" class="createdAt"/>
+ <div v-for="item in items" :key="item.id" :to="`/clips/${item.id}`" class="_panel _margin">
+ <div :class="$style.header">
+ <MkAvatar :class="$style.avatar" :user="user"/>
+ <MkReactionIcon :class="$style.reaction" :reaction="item.type" :noStyle="true"/>
+ <MkTime :time="item.createdAt" :class="$style.createdAt"/>
</div>
<MkNote :key="item.id" :note="item.note"/>
</div>
@@ -33,29 +33,27 @@ const pagination = {
};
</script>
-<style lang="scss" scoped>
-.afdcfbfb {
- > .header {
- display: flex;
- align-items: center;
- padding: 8px 16px;
- margin-bottom: 8px;
- border-bottom: solid 2px var(--divider);
+<style lang="scss" module>
+.header {
+ display: flex;
+ align-items: center;
+ padding: 8px 16px;
+ margin-bottom: 8px;
+ border-bottom: solid 2px var(--divider);
+}
- > .avatar {
- width: 24px;
- height: 24px;
- margin-right: 8px;
- }
+.avatar {
+ width: 24px;
+ height: 24px;
+ margin-right: 8px;
+}
- > .reaction {
- width: 32px;
- height: 32px;
- }
+.reaction {
+ width: 32px;
+ height: 32px;
+}
- > .createdAt {
- margin-left: auto;
- }
- }
+.createdAt {
+ margin-left: auto;
}
</style>
diff --git a/packages/frontend/src/pages/welcome.entrance.a.vue b/packages/frontend/src/pages/welcome.entrance.a.vue
index 929152bd5a..f082b4b3c7 100644
--- a/packages/frontend/src/pages/welcome.entrance.a.vue
+++ b/packages/frontend/src/pages/welcome.entrance.a.vue
@@ -6,11 +6,11 @@
<div class="shape2"></div>
<img src="/client-assets/misskey.svg" class="misskey"/>
<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="🍮"/>
+ <MkEmoji :normal="true" :noStyle="true" emoji="👍"/>
+ <MkEmoji :normal="true" :noStyle="true" emoji="❤"/>
+ <MkEmoji :normal="true" :noStyle="true" emoji="😆"/>
+ <MkEmoji :normal="true" :noStyle="true" emoji="🎉"/>
+ <MkEmoji :normal="true" :noStyle="true" emoji="🍮"/>
</div>
<div class="contents">
<MkVisitorDashboard/>
diff --git a/packages/frontend/src/pages/welcome.setup.vue b/packages/frontend/src/pages/welcome.setup.vue
index 7728d97a65..2081cb9c93 100644
--- a/packages/frontend/src/pages/welcome.setup.vue
+++ b/packages/frontend/src/pages/welcome.setup.vue
@@ -1,27 +1,32 @@
<template>
-<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>
+ <MkAnimBg style="position: fixed; top: 0;"/>
+ <div :class="$style.formContainer">
+ <form :class="$style.form" 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>
+ <template #suffix>@{{ host }}</template>
+ </MkInput>
+ <MkInput v-model="password" type="password" data-cy-admin-password>
+ <template #label>{{ i18n.ts.password }}</template>
+ <template #prefix><i class="ti ti-lock"></i></template>
+ </MkInput>
+ <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>
+ </div>
+ </form>
</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>
- <template #suffix>@{{ host }}</template>
- </MkInput>
- <MkInput v-model="password" type="password" data-cy-admin-password>
- <template #label>{{ i18n.ts.password }}</template>
- <template #prefix><i class="ti ti-lock"></i></template>
- </MkInput>
- <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>
- </div>
-</form>
+</div>
</template>
<script lang="ts" setup>
@@ -32,6 +37,7 @@ import { host, version } from '@/config';
import * as os from '@/os';
import { login } from '@/account';
import { i18n } from '@/i18n';
+import MkAnimBg from '@/components/MkAnimBg.vue';
let username = $ref('');
let password = $ref('');
@@ -58,12 +64,21 @@ function submit() {
</script>
<style lang="scss" module>
-.root {
+.formContainer {
+ min-height: 100svh;
+ padding: 32px 32px 64px 32px;
+ box-sizing: border-box;
+display: grid;
+place-content: center;
+}
+
+.form {
+ position: relative;
+ z-index: 10;
border-radius: var(--radius);
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1);
- overflow: hidden;
+ overflow: clip;
max-width: 500px;
- margin: 32px auto;
}
.title {
diff --git a/packages/frontend/src/pages/welcome.timeline.vue b/packages/frontend/src/pages/welcome.timeline.vue
index 6ec6e3f863..a93d103d4f 100644
--- a/packages/frontend/src/pages/welcome.timeline.vue
+++ b/packages/frontend/src/pages/welcome.timeline.vue
@@ -3,16 +3,16 @@
<div ref="scrollEl" :class="[$style.scrollbox, { [$style.scroll]: isScrolling }]">
<div v-for="note in notes" :key="note.id" :class="$style.note">
<div class="_panel" :class="$style.content">
- <div :class="$style.body">
+ <div>
<MkA v-if="note.replyId" class="reply" :to="`/notes/${note.replyId}`"><i class="ti ti-arrow-back-up"></i></MkA>
<Mfm v-if="note.text" :text="note.text" :author="note.user" :i="$i"/>
<MkA v-if="note.renoteId" class="rp" :to="`/notes/${note.renoteId}`">RN: ...</MkA>
</div>
<div v-if="note.files.length > 0" :class="$style.richcontent">
- <MkMediaList :media-list="note.files"/>
+ <MkMediaList :mediaList="note.files"/>
</div>
<div v-if="note.poll">
- <MkPoll :note="note" :read-only="true"/>
+ <MkPoll :note="note" :readOnly="true"/>
</div>
</div>
<MkReactionsViewer ref="reactionsViewer" :note="note"/>
diff --git a/packages/frontend/src/pizzax.ts b/packages/frontend/src/pizzax.ts
index 2616a8a1d5..d97bd4be62 100644
--- a/packages/frontend/src/pizzax.ts
+++ b/packages/frontend/src/pizzax.ts
@@ -6,7 +6,7 @@ import { $i } from './account';
import { api } from './os';
import { get, set } from './scripts/idb-proxy';
import { defaultStore } from './store';
-import { stream } from './stream';
+import { useStream } from './stream';
import { deepClone } from './scripts/clone';
type StateDef = Record<string, {
@@ -26,8 +26,6 @@ type PizzaxChannelMessage<T extends StateDef> = {
userId?: string;
};
-const connection = $i && stream.useChannel('main');
-
export class Storage<T extends StateDef> {
public readonly ready: Promise<void>;
public readonly loaded: Promise<void>;
@@ -105,8 +103,10 @@ export class Storage<T extends StateDef> {
});
if ($i) {
+ const connection = useStream().useChannel('main');
+
// streamingのuser storage updateイベントを監視して更新
- connection?.on('registryUpdated', ({ scope, key, value }: { scope?: string[], key: keyof T, value: T[typeof key]['default'] }) => {
+ connection.on('registryUpdated', ({ scope, key, value }: { scope?: string[], key: keyof T, value: T[typeof key]['default'] }) => {
if (!scope || scope.length !== 2 || scope[0] !== 'client' || scope[1] !== this.key || this.state[key] === value) return;
this.reactiveState[key].value = this.state[key] = value;
diff --git a/packages/frontend/src/router.ts b/packages/frontend/src/router.ts
index e46c1eeb77..6b11137d79 100644
--- a/packages/frontend/src/router.ts
+++ b/packages/frontend/src/router.ts
@@ -31,6 +31,10 @@ export const routes = [{
path: '/notes/:noteId',
component: page(() => import('./pages/note.vue')),
}, {
+ name: 'list',
+ path: '/list/:listId',
+ component: page(() => import('./pages/list.vue')),
+}, {
path: '/clips/:clipId',
component: page(() => import('./pages/clip.vue')),
}, {
@@ -243,9 +247,6 @@ export const routes = [{
path: '/scratchpad',
component: page(() => import('./pages/scratchpad.vue')),
}, {
- path: '/preview',
- component: page(() => import('./pages/preview.vue')),
-}, {
path: '/auth/:token',
component: page(() => import('./pages/auth.vue')),
}, {
diff --git a/packages/frontend/src/scripts/emojilist.ts b/packages/frontend/src/scripts/emojilist.ts
index 2e853b58b5..79661b7ce9 100644
--- a/packages/frontend/src/scripts/emojilist.ts
+++ b/packages/frontend/src/scripts/emojilist.ts
@@ -2,7 +2,6 @@ export const unicodeEmojiCategories = ['face', 'people', 'animals_and_nature', '
export type UnicodeEmojiDef = {
name: string;
- keywords: string[];
char: string;
category: typeof unicodeEmojiCategories[number];
}
@@ -10,11 +9,16 @@ export type UnicodeEmojiDef = {
// initial converted from https://github.com/muan/emojilib/commit/242fe68be86ed6536843b83f7e32f376468b38fb
import _emojilist from '../emojilist.json';
-export const emojilist = _emojilist as UnicodeEmojiDef[];
+export const emojilist: UnicodeEmojiDef[] = _emojilist.map(x => ({
+ name: x[1] as string,
+ char: x[0] as string,
+ category: unicodeEmojiCategories[x[2]],
+}));
const _indexByChar = new Map<string, number>();
const _charGroupByCategory = new Map<string, string[]>();
-emojilist.forEach((emo, i) => {
+for (let i = 0; i < emojilist.length; i++) {
+ const emo = emojilist[i];
_indexByChar.set(emo.char, i);
if (_charGroupByCategory.has(emo.category)) {
@@ -22,14 +26,14 @@ emojilist.forEach((emo, i) => {
} else {
_charGroupByCategory.set(emo.category, [emo.char]);
}
-});
+}
export const emojiCharByCategory = _charGroupByCategory;
-export function getEmojiName(char: string): string | undefined {
+export function getEmojiName(char: string): string | null {
const idx = _indexByChar.get(char);
- if (typeof idx === 'undefined') {
- return undefined;
+ if (idx == null) {
+ return null;
} else {
return emojilist[idx].name;
}
diff --git a/packages/frontend/src/scripts/get-drive-file-menu.ts b/packages/frontend/src/scripts/get-drive-file-menu.ts
index ed01b49054..060c8a1a11 100644
--- a/packages/frontend/src/scripts/get-drive-file-menu.ts
+++ b/packages/frontend/src/scripts/get-drive-file-menu.ts
@@ -73,7 +73,7 @@ export function getDriveFileMenu(file: Misskey.entities.DriveFile) {
action: () => rename(file),
}, {
text: file.isSensitive ? i18n.ts.unmarkAsSensitive : i18n.ts.markAsSensitive,
- icon: file.isSensitive ? 'ti ti-eye' : 'ti ti-eye-off',
+ icon: file.isSensitive ? 'ti ti-eye' : 'ti ti-eye-exclamation',
action: () => toggleSensitive(file),
}, {
text: i18n.ts.describeFile,
diff --git a/packages/frontend/src/scripts/get-note-menu.ts b/packages/frontend/src/scripts/get-note-menu.ts
index c8a6100253..960f26ca67 100644
--- a/packages/frontend/src/scripts/get-note-menu.ts
+++ b/packages/frontend/src/scripts/get-note-menu.ts
@@ -7,7 +7,7 @@ import { instance } from '@/instance';
import * as os from '@/os';
import copyToClipboard from '@/scripts/copy-to-clipboard';
import { url } from '@/config';
-import { noteActions } from '@/store';
+import { defaultStore, noteActions } from '@/store';
import { miLocalStorage } from '@/local-storage';
import { getUserMenu } from '@/scripts/get-user-menu';
import { clipsCache } from '@/cache';
@@ -396,5 +396,15 @@ export function getNoteMenu(props: {
}))]);
}
+ if (defaultStore.state.devMode) {
+ menu = menu.concat([null, {
+ icon: 'ti ti-id',
+ text: i18n.ts.copyNoteId,
+ action: () => {
+ copyToClipboard(appearNote.id);
+ },
+ }]);
+ }
+
return menu;
}
diff --git a/packages/frontend/src/scripts/get-user-menu.ts b/packages/frontend/src/scripts/get-user-menu.ts
index 6ff9fb63f1..b055d26473 100644
--- a/packages/frontend/src/scripts/get-user-menu.ts
+++ b/packages/frontend/src/scripts/get-user-menu.ts
@@ -4,7 +4,7 @@ import { i18n } from '@/i18n';
import copyToClipboard from '@/scripts/copy-to-clipboard';
import { host } from '@/config';
import * as os from '@/os';
-import { userActions } from '@/store';
+import { defaultStore, userActions } from '@/store';
import { $i, iAmModerator } from '@/account';
import { mainRouter } from '@/router';
import { Router } from '@/nirax';
@@ -240,6 +240,16 @@ export function getUserMenu(user: misskey.entities.UserDetailed, router: Router
}]);
}
+ if (defaultStore.state.devMode) {
+ menu = menu.concat([null, {
+ icon: 'ti ti-id',
+ text: i18n.ts.copyUserId,
+ action: () => {
+ copyToClipboard(user.id);
+ },
+ }]);
+ }
+
if ($i && meId === user.id) {
menu = menu.concat([null, {
icon: 'ti ti-pencil',
diff --git a/packages/frontend/src/scripts/hpml/block.ts b/packages/frontend/src/scripts/hpml/block.ts
deleted file mode 100644
index 804c5c1124..0000000000
--- a/packages/frontend/src/scripts/hpml/block.ts
+++ /dev/null
@@ -1,109 +0,0 @@
-// blocks
-
-export type BlockBase = {
- id: string;
- type: string;
-};
-
-export type TextBlock = BlockBase & {
- type: 'text';
- text: string;
-};
-
-export type SectionBlock = BlockBase & {
- type: 'section';
- title: string;
- children: (Block | VarBlock)[];
-};
-
-export type ImageBlock = BlockBase & {
- type: 'image';
- fileId: string | null;
-};
-
-export type ButtonBlock = BlockBase & {
- type: 'button';
- text: any;
- primary: boolean;
- action: string;
- content: string;
- event: string;
- message: string;
- var: string;
- fn: string;
-};
-
-export type IfBlock = BlockBase & {
- type: 'if';
- var: string;
- children: Block[];
-};
-
-export type TextareaBlock = BlockBase & {
- type: 'textarea';
- text: string;
-};
-
-export type PostBlock = BlockBase & {
- type: 'post';
- text: string;
- attachCanvasImage: boolean;
- canvasId: string;
-};
-
-export type CanvasBlock = BlockBase & {
- type: 'canvas';
- name: string; // canvas id
- width: number;
- height: number;
-};
-
-export type NoteBlock = BlockBase & {
- type: 'note';
- detailed: boolean;
- note: string | null;
-};
-
-export type Block =
- TextBlock | SectionBlock | ImageBlock | ButtonBlock | IfBlock | TextareaBlock | PostBlock | CanvasBlock | NoteBlock | VarBlock;
-
-// variable blocks
-
-export type VarBlockBase = BlockBase & {
- name: string;
-};
-
-export type NumberInputVarBlock = VarBlockBase & {
- type: 'numberInput';
- text: string;
-};
-
-export type TextInputVarBlock = VarBlockBase & {
- type: 'textInput';
- text: string;
-};
-
-export type SwitchVarBlock = VarBlockBase & {
- type: 'switch';
- text: string;
-};
-
-export type RadioButtonVarBlock = VarBlockBase & {
- type: 'radioButton';
- title: string;
- values: string[];
-};
-
-export type CounterVarBlock = VarBlockBase & {
- type: 'counter';
- text: string;
- inc: number;
-};
-
-export type VarBlock =
- NumberInputVarBlock | TextInputVarBlock | SwitchVarBlock | RadioButtonVarBlock | CounterVarBlock;
-
-const varBlock = ['numberInput', 'textInput', 'switch', 'radioButton', 'counter'];
-export function isVarBlock(block: Block): block is VarBlock {
- return varBlock.includes(block.type);
-}
diff --git a/packages/frontend/src/scripts/hpml/evaluator.ts b/packages/frontend/src/scripts/hpml/evaluator.ts
deleted file mode 100644
index 9adfba7f27..0000000000
--- a/packages/frontend/src/scripts/hpml/evaluator.ts
+++ /dev/null
@@ -1,171 +0,0 @@
-import { ref, Ref, unref } from 'vue';
-import { collectPageVars } from '../collect-page-vars';
-import { initHpmlLib } from './lib';
-import { Expr, isLiteralValue, Variable } from './expr';
-import { PageVar, envVarsDef, Fn, HpmlScope, HpmlError } from '.';
-import { version } from '@/config';
-
-/**
- * Hpml evaluator
- */
-export class Hpml {
- private variables: Variable[];
- private pageVars: PageVar[];
- private envVars: Record<keyof typeof envVarsDef, any>;
- public pageVarUpdatedCallback?: values.VFn;
- public canvases: Record<string, HTMLCanvasElement> = {};
- public vars: Ref<Record<string, any>> = ref({});
- public page: Record<string, any>;
-
- private opts: {
- randomSeed: string; visitor?: any; url?: string;
- };
-
- constructor(page: Hpml['page'], opts: Hpml['opts']) {
- this.page = page;
- this.variables = this.page.variables;
- this.pageVars = collectPageVars(this.page.content);
- this.opts = opts;
-
- const date = new Date();
-
- this.envVars = {
- AI: 'kawaii',
- VERSION: version,
- URL: this.page ? `${opts.url}/@${this.page.user.username}/pages/${this.page.name}` : '',
- LOGIN: opts.visitor != null,
- NAME: opts.visitor ? opts.visitor.name || opts.visitor.username : '',
- USERNAME: opts.visitor ? opts.visitor.username : '',
- USERID: opts.visitor ? opts.visitor.id : '',
- NOTES_COUNT: opts.visitor ? opts.visitor.notesCount : 0,
- FOLLOWERS_COUNT: opts.visitor ? opts.visitor.followersCount : 0,
- FOLLOWING_COUNT: opts.visitor ? opts.visitor.followingCount : 0,
- IS_CAT: opts.visitor ? opts.visitor.isCat : false,
- SEED: opts.randomSeed ? opts.randomSeed : '',
- YMD: `${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()}`,
- AISCRIPT_DISABLED: true,
- NULL: null,
- };
-
- this.eval();
- }
-
- public eval() {
- try {
- this.vars.value = this.evaluateVars();
- } catch (err) {
- //this.onError(e);
- }
- }
-
- public interpolate(str: string) {
- if (str == null) return null;
- return str.replace(/{(.+?)}/g, match => {
- const v = unref(this.vars)[match.slice(1, -1).trim()];
- return v == null ? 'NULL' : v.toString();
- });
- }
-
- public registerCanvas(id: string, canvas: any) {
- this.canvases[id] = canvas;
- }
-
- public updatePageVar(name: string, value: any) {
- const pageVar = this.pageVars.find(v => v.name === name);
- if (pageVar !== undefined) {
- pageVar.value = value;
- } else {
- throw new HpmlError(`No such page var '${name}'`);
- }
- }
-
- public updateRandomSeed(seed: string) {
- this.opts.randomSeed = seed;
- this.envVars.SEED = seed;
- }
-
- private _interpolateScope(str: string, scope: HpmlScope) {
- return str.replace(/{(.+?)}/g, match => {
- const v = scope.getState(match.slice(1, -1).trim());
- return v == null ? 'NULL' : v.toString();
- });
- }
-
- public evaluateVars(): Record<string, any> {
- const values: Record<string, any> = {};
-
- for (const [k, v] of Object.entries(this.envVars)) {
- values[k] = v;
- }
-
- for (const v of this.pageVars) {
- values[v.name] = v.value;
- }
-
- for (const v of this.variables) {
- values[v.name] = this.evaluate(v, new HpmlScope([values]));
- }
-
- return values;
- }
-
- private evaluate(expr: Expr, scope: HpmlScope): any {
- if (isLiteralValue(expr)) {
- if (expr.type === null) {
- return null;
- }
-
- if (expr.type === 'number') {
- return parseInt((expr.value as any), 10);
- }
-
- if (expr.type === 'text' || expr.type === 'multiLineText') {
- return this._interpolateScope(expr.value || '', scope);
- }
-
- if (expr.type === 'textList') {
- return this._interpolateScope(expr.value || '', scope).trim().split('\n');
- }
-
- if (expr.type === 'ref') {
- return scope.getState(expr.value);
- }
-
- // Define user function
- if (expr.type === 'fn') {
- return {
- slots: expr.value.slots.map(x => x.name),
- exec: (slotArg: Record<string, any>) => {
- return this.evaluate(expr.value.expression, scope.createChildScope(slotArg, expr.id));
- },
- } as Fn;
- }
- return;
- }
-
- // Call user function
- if (expr.type.startsWith('fn:')) {
- const fnName = expr.type.split(':')[1];
- const fn = scope.getState(fnName);
- const args = {} as Record<string, any>;
- for (let i = 0; i < fn.slots.length; i++) {
- const name = fn.slots[i];
- args[name] = this.evaluate(expr.args[i], scope);
- }
- return fn.exec(args);
- }
-
- if (expr.args === undefined) return null;
-
- const funcs = initHpmlLib(expr, scope, this.opts.randomSeed, this.opts.visitor);
-
- // Call function
- const fnName = expr.type;
- const fn = (funcs as any)[fnName];
- if (fn == null) {
- throw new HpmlError(`No such function '${fnName}'`);
- } else {
- return fn(...expr.args.map(x => this.evaluate(x, scope)));
- }
- }
-}
diff --git a/packages/frontend/src/scripts/hpml/expr.ts b/packages/frontend/src/scripts/hpml/expr.ts
deleted file mode 100644
index 18c7c2a14b..0000000000
--- a/packages/frontend/src/scripts/hpml/expr.ts
+++ /dev/null
@@ -1,79 +0,0 @@
-import { literalDefs, Type } from '.';
-
-export type ExprBase = {
- id: string;
-};
-
-// value
-
-export type EmptyValue = ExprBase & {
- type: null;
- value: null;
-};
-
-export type TextValue = ExprBase & {
- type: 'text';
- value: string;
-};
-
-export type MultiLineTextValue = ExprBase & {
- type: 'multiLineText';
- value: string;
-};
-
-export type TextListValue = ExprBase & {
- type: 'textList';
- value: string;
-};
-
-export type NumberValue = ExprBase & {
- type: 'number';
- value: number;
-};
-
-export type RefValue = ExprBase & {
- type: 'ref';
- value: string; // value is variable name
-};
-
-export type AiScriptRefValue = ExprBase & {
- type: 'aiScriptVar';
- value: string; // value is variable name
-};
-
-export type UserFnValue = ExprBase & {
- type: 'fn';
- value: UserFnInnerValue;
-};
-type UserFnInnerValue = {
- slots: {
- name: string;
- type: Type;
- }[];
- expression: Expr;
-};
-
-export type Value =
- EmptyValue | TextValue | MultiLineTextValue | TextListValue | NumberValue | RefValue | AiScriptRefValue | UserFnValue;
-
-export function isLiteralValue(expr: Expr): expr is Value {
- if (expr.type == null) return true;
- if (literalDefs[expr.type]) return true;
- return false;
-}
-
-// call function
-
-export type CallFn = ExprBase & { // "fn:hoge" or string
- type: string;
- args: Expr[];
- value: null;
-};
-
-// variable
-export type Variable = (Value | CallFn) & {
- name: string;
-};
-
-// expression
-export type Expr = Variable | Value | CallFn;
diff --git a/packages/frontend/src/scripts/hpml/index.ts b/packages/frontend/src/scripts/hpml/index.ts
deleted file mode 100644
index 994f286b9f..0000000000
--- a/packages/frontend/src/scripts/hpml/index.ts
+++ /dev/null
@@ -1,100 +0,0 @@
-/**
- * Hpml
- */
-
-import { Hpml } from './evaluator';
-import { funcDefs } from './lib';
-
-export type Fn = {
- slots: string[];
- exec: (args: Record<string, any>) => ReturnType<Hpml['evaluate']>;
-};
-
-export type Type = 'string' | 'number' | 'boolean' | 'stringArray' | null;
-
-export const literalDefs: Record<string, { out: any; category: string; icon: any; }> = {
- text: { out: 'string', category: 'value', icon: 'ti ti-quote' },
- multiLineText: { out: 'string', category: 'value', icon: 'ti ti-align-left' },
- textList: { out: 'stringArray', category: 'value', icon: 'ti ti-list' },
- number: { out: 'number', category: 'value', icon: 'ti ti-list-numbers' },
- ref: { out: null, category: 'value', icon: 'ti ti-wand' },
- aiScriptVar: { out: null, category: 'value', icon: 'ti ti-wand' },
- fn: { out: 'function', category: 'value', icon: 'ti ti-math-function' },
-};
-
-export const blockDefs = [
- ...Object.entries(literalDefs).map(([k, v]) => ({
- type: k, out: v.out, category: v.category, icon: v.icon,
- })),
- ...Object.entries(funcDefs).map(([k, v]) => ({
- type: k, out: v.out, category: v.category, icon: v.icon,
- })),
-];
-
-export type PageVar = { name: string; value: any; type: Type; };
-
-export const envVarsDef: Record<string, Type> = {
- AI: 'string',
- URL: 'string',
- VERSION: 'string',
- LOGIN: 'boolean',
- NAME: 'string',
- USERNAME: 'string',
- USERID: 'string',
- NOTES_COUNT: 'number',
- FOLLOWERS_COUNT: 'number',
- FOLLOWING_COUNT: 'number',
- IS_CAT: 'boolean',
- SEED: null,
- YMD: 'string',
- AISCRIPT_DISABLED: 'boolean',
- NULL: null,
-};
-
-export class HpmlScope {
- private layerdStates: Record<string, any>[];
- public name: string;
-
- constructor(layerdStates: HpmlScope['layerdStates'], name?: HpmlScope['name']) {
- this.layerdStates = layerdStates;
- this.name = name ?? 'anonymous';
- }
-
- public createChildScope(states: Record<string, any>, name?: HpmlScope['name']): HpmlScope {
- const layer = [states, ...this.layerdStates];
- return new HpmlScope(layer, name);
- }
-
- /**
- * 指定した名前の変数の値を取得します
- * @param name 変数名
- */
- public getState(name: string): any {
- for (const later of this.layerdStates) {
- const state = later[name];
- if (state !== undefined) {
- return state;
- }
- }
-
- throw new HpmlError(
- `No such variable '${name}' in scope '${this.name}'`, {
- scope: this.layerdStates,
- });
- }
-}
-
-export class HpmlError extends Error {
- public info?: any;
-
- constructor(message: string, info?: any) {
- super(message);
-
- this.info = info;
-
- // Maintains proper stack trace for where our error was thrown (only available on V8)
- if (Error.captureStackTrace) {
- Error.captureStackTrace(this, HpmlError);
- }
- }
-}
diff --git a/packages/frontend/src/scripts/hpml/lib.ts b/packages/frontend/src/scripts/hpml/lib.ts
deleted file mode 100644
index 88db82dd27..0000000000
--- a/packages/frontend/src/scripts/hpml/lib.ts
+++ /dev/null
@@ -1,245 +0,0 @@
-import seedrandom from 'seedrandom';
-import { Hpml } from './evaluator';
-import { Expr } from './expr';
-import { Fn, HpmlScope } from '.';
-
-/* TODO: https://www.chartjs.org/docs/latest/configuration/canvas-background.html#color
-// https://stackoverflow.com/questions/38493564/chart-area-background-color-chartjs
-Chart.pluginService.register({
- beforeDraw: (chart, easing) => {
- if (chart.config.options.chartArea && chart.config.options.chartArea.backgroundColor) {
- const ctx = chart.chart.ctx;
- ctx.save();
- ctx.fillStyle = chart.config.options.chartArea.backgroundColor;
- ctx.fillRect(0, 0, chart.chart.width, chart.chart.height);
- ctx.restore();
- }
- }
-});
-*/
-
-export function initAiLib(hpml: Hpml) {
- return {
- 'MkPages:updated': values.FN_NATIVE(([callback]) => {
- hpml.pageVarUpdatedCallback = (callback as values.VFn);
- }),
- 'MkPages:get_canvas': values.FN_NATIVE(([id]) => {
- utils.assertString(id);
- const canvas = hpml.canvases[id.value];
- const ctx = canvas.getContext('2d');
- return values.OBJ(new Map([
- ['clear_rect', values.FN_NATIVE(([x, y, width, height]) => { ctx.clearRect(x.value, y.value, width.value, height.value); })],
- ['fill_rect', values.FN_NATIVE(([x, y, width, height]) => { ctx.fillRect(x.value, y.value, width.value, height.value); })],
- ['stroke_rect', values.FN_NATIVE(([x, y, width, height]) => { ctx.strokeRect(x.value, y.value, width.value, height.value); })],
- ['fill_text', values.FN_NATIVE(([text, x, y, width]) => { ctx.fillText(text.value, x.value, y.value, width ? width.value : undefined); })],
- ['stroke_text', values.FN_NATIVE(([text, x, y, width]) => { ctx.strokeText(text.value, x.value, y.value, width ? width.value : undefined); })],
- ['set_line_width', values.FN_NATIVE(([width]) => { ctx.lineWidth = width.value; })],
- ['set_font', values.FN_NATIVE(([font]) => { ctx.font = font.value; })],
- ['set_fill_style', values.FN_NATIVE(([style]) => { ctx.fillStyle = style.value; })],
- ['set_stroke_style', values.FN_NATIVE(([style]) => { ctx.strokeStyle = style.value; })],
- ['begin_path', values.FN_NATIVE(() => { ctx.beginPath(); })],
- ['close_path', values.FN_NATIVE(() => { ctx.closePath(); })],
- ['move_to', values.FN_NATIVE(([x, y]) => { ctx.moveTo(x.value, y.value); })],
- ['line_to', values.FN_NATIVE(([x, y]) => { ctx.lineTo(x.value, y.value); })],
- ['arc', values.FN_NATIVE(([x, y, radius, startAngle, endAngle]) => { ctx.arc(x.value, y.value, radius.value, startAngle.value, endAngle.value); })],
- ['rect', values.FN_NATIVE(([x, y, width, height]) => { ctx.rect(x.value, y.value, width.value, height.value); })],
- ['fill', values.FN_NATIVE(() => { ctx.fill(); })],
- ['stroke', values.FN_NATIVE(() => { ctx.stroke(); })],
- ]));
- }),
- 'MkPages:chart': values.FN_NATIVE(([id, opts]) => {
- /* TODO
- utils.assertString(id);
- utils.assertObject(opts);
- const canvas = hpml.canvases[id.value];
- const color = getComputedStyle(document.documentElement).getPropertyValue('--accent');
- Chart.defaults.color = '#555';
- const chart = new Chart(canvas, {
- type: opts.value.get('type').value,
- data: {
- labels: opts.value.get('labels').value.map(x => x.value),
- datasets: opts.value.get('datasets').value.map(x => ({
- label: x.value.has('label') ? x.value.get('label').value : '',
- data: x.value.get('data').value.map(x => x.value),
- pointRadius: 0,
- lineTension: 0,
- borderWidth: 2,
- borderColor: x.value.has('color') ? x.value.get('color') : color,
- backgroundColor: tinycolor(x.value.has('color') ? x.value.get('color') : color).setAlpha(0.1).toRgbString(),
- }))
- },
- options: {
- responsive: false,
- devicePixelRatio: 1.5,
- title: {
- display: opts.value.has('title'),
- text: opts.value.has('title') ? opts.value.get('title').value : '',
- fontSize: 14,
- },
- layout: {
- padding: {
- left: 32,
- right: 32,
- top: opts.value.has('title') ? 16 : 32,
- bottom: 16
- }
- },
- legend: {
- display: opts.value.get('datasets').value.filter(x => x.value.has('label') && x.value.get('label').value).length === 0 ? false : true,
- position: 'bottom',
- labels: {
- boxWidth: 16,
- }
- },
- tooltips: {
- enabled: false,
- },
- chartArea: {
- backgroundColor: '#fff'
- },
- ...(opts.value.get('type').value === 'radar' ? {
- scale: {
- ticks: {
- display: opts.value.has('show_tick_label') ? opts.value.get('show_tick_label').value : false,
- min: opts.value.has('min') ? opts.value.get('min').value : undefined,
- max: opts.value.has('max') ? opts.value.get('max').value : undefined,
- maxTicksLimit: 8,
- },
- pointLabels: {
- fontSize: 12
- }
- }
- } : {
- scales: {
- yAxes: [{
- ticks: {
- display: opts.value.has('show_tick_label') ? opts.value.get('show_tick_label').value : true,
- min: opts.value.has('min') ? opts.value.get('min').value : undefined,
- max: opts.value.has('max') ? opts.value.get('max').value : undefined,
- }
- }]
- }
- })
- }
- });
- */
- }),
- };
-}
-
-export const funcDefs: Record<string, { in: any[]; out: any; category: string; icon: any; }> = {
- if: { in: ['boolean', 0, 0], out: 0, category: 'flow', icon: 'ti ti-share' },
- for: { in: ['number', 'function'], out: null, category: 'flow', icon: 'ti ti-recycle' },
- not: { in: ['boolean'], out: 'boolean', category: 'logical', icon: 'ti ti-flag' },
- or: { in: ['boolean', 'boolean'], out: 'boolean', category: 'logical', icon: 'ti ti-flag' },
- and: { in: ['boolean', 'boolean'], out: 'boolean', category: 'logical', icon: 'ti ti-flag' },
- add: { in: ['number', 'number'], out: 'number', category: 'operation', icon: 'ti ti-plus' },
- subtract: { in: ['number', 'number'], out: 'number', category: 'operation', icon: 'ti ti-minus' },
- multiply: { in: ['number', 'number'], out: 'number', category: 'operation', icon: 'ti ti-x' },
- divide: { in: ['number', 'number'], out: 'number', category: 'operation', icon: 'ti ti-divide' },
- mod: { in: ['number', 'number'], out: 'number', category: 'operation', icon: 'ti ti-divide' },
- round: { in: ['number'], out: 'number', category: 'operation', icon: 'ti ti-calculator' },
- eq: { in: [0, 0], out: 'boolean', category: 'comparison', icon: 'ti ti-equal' },
- notEq: { in: [0, 0], out: 'boolean', category: 'comparison', icon: 'ti ti-equal-not' },
- gt: { in: ['number', 'number'], out: 'boolean', category: 'comparison', icon: 'ti ti-math-greater' },
- lt: { in: ['number', 'number'], out: 'boolean', category: 'comparison', icon: 'ti ti-math-lower' },
- gtEq: { in: ['number', 'number'], out: 'boolean', category: 'comparison', icon: 'ti ti-math-equal-greater' },
- ltEq: { in: ['number', 'number'], out: 'boolean', category: 'comparison', icon: 'ti ti-math-equal-lower' },
- strLen: { in: ['string'], out: 'number', category: 'text', icon: 'ti ti-quote' },
- strPick: { in: ['string', 'number'], out: 'string', category: 'text', icon: 'ti ti-quote' },
- strReplace: { in: ['string', 'string', 'string'], out: 'string', category: 'text', icon: 'ti ti-quote' },
- strReverse: { in: ['string'], out: 'string', category: 'text', icon: 'ti ti-quote' },
- join: { in: ['stringArray', 'string'], out: 'string', category: 'text', icon: 'ti ti-quote' },
- stringToNumber: { in: ['string'], out: 'number', category: 'convert', icon: 'ti ti-arrows-right-left' },
- numberToString: { in: ['number'], out: 'string', category: 'convert', icon: 'ti ti-arrows-right-left' },
- splitStrByLine: { in: ['string'], out: 'stringArray', category: 'convert', icon: 'ti ti-arrows-right-left' },
- pick: { in: [null, 'number'], out: null, category: 'list', icon: 'ti ti-indent-increase' },
- listLen: { in: [null], out: 'number', category: 'list', icon: 'ti ti-indent-increase' },
- rannum: { in: ['number', 'number'], out: 'number', category: 'random', icon: 'ti ti-dice' },
- dailyRannum: { in: ['number', 'number'], out: 'number', category: 'random', icon: 'ti ti-dice' },
- seedRannum: { in: [null, 'number', 'number'], out: 'number', category: 'random', icon: 'ti ti-dice' },
- random: { in: ['number'], out: 'boolean', category: 'random', icon: 'ti ti-dice' },
- dailyRandom: { in: ['number'], out: 'boolean', category: 'random', icon: 'ti ti-dice' },
- seedRandom: { in: [null, 'number'], out: 'boolean', category: 'random', icon: 'ti ti-dice' },
- randomPick: { in: [0], out: 0, category: 'random', icon: 'ti ti-dice' },
- dailyRandomPick: { in: [0], out: 0, category: 'random', icon: 'ti ti-dice' },
- seedRandomPick: { in: [null, 0], out: 0, category: 'random', icon: 'ti ti-dice' },
- DRPWPM: { in: ['stringArray'], out: 'string', category: 'random', icon: 'ti ti-dice' }, // dailyRandomPickWithProbabilityMapping
-};
-
-export function initHpmlLib(expr: Expr, scope: HpmlScope, randomSeed: string, visitor?: any) {
- const date = new Date();
- const day = `${visitor ? visitor.id : ''} ${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()}`;
-
- // SHOULD be fine to ignore since it's intended + function shape isn't defined
- // eslint-disable-next-line @typescript-eslint/ban-types
- const funcs: Record<string, Function> = {
- not: (a: boolean) => !a,
- or: (a: boolean, b: boolean) => a || b,
- and: (a: boolean, b: boolean) => a && b,
- eq: (a: any, b: any) => a === b,
- notEq: (a: any, b: any) => a !== b,
- gt: (a: number, b: number) => a > b,
- lt: (a: number, b: number) => a < b,
- gtEq: (a: number, b: number) => a >= b,
- ltEq: (a: number, b: number) => a <= b,
- if: (bool: boolean, a: any, b: any) => bool ? a : b,
- for: (times: number, fn: Fn) => {
- const result: any[] = [];
- for (let i = 0; i < times; i++) {
- result.push(fn.exec({
- [fn.slots[0]]: i + 1,
- }));
- }
- return result;
- },
- add: (a: number, b: number) => a + b,
- subtract: (a: number, b: number) => a - b,
- multiply: (a: number, b: number) => a * b,
- divide: (a: number, b: number) => a / b,
- mod: (a: number, b: number) => a % b,
- round: (a: number) => Math.round(a),
- strLen: (a: string) => a.length,
- strPick: (a: string, b: number) => a[b - 1],
- strReplace: (a: string, b: string, c: string) => a.split(b).join(c),
- strReverse: (a: string) => a.split('').reverse().join(''),
- join: (texts: string[], separator: string) => texts.join(separator || ''),
- stringToNumber: (a: string) => parseInt(a),
- numberToString: (a: number) => a.toString(),
- splitStrByLine: (a: string) => a.split('\n'),
- pick: (list: any[], i: number) => list[i - 1],
- listLen: (list: any[]) => list.length,
- random: (probability: number) => Math.floor(seedrandom(`${randomSeed}:${expr.id}`)() * 100) < probability,
- rannum: (min: number, max: number) => min + Math.floor(seedrandom(`${randomSeed}:${expr.id}`)() * (max - min + 1)),
- randomPick: (list: any[]) => list[Math.floor(seedrandom(`${randomSeed}:${expr.id}`)() * list.length)],
- dailyRandom: (probability: number) => Math.floor(seedrandom(`${day}:${expr.id}`)() * 100) < probability,
- dailyRannum: (min: number, max: number) => min + Math.floor(seedrandom(`${day}:${expr.id}`)() * (max - min + 1)),
- dailyRandomPick: (list: any[]) => list[Math.floor(seedrandom(`${day}:${expr.id}`)() * list.length)],
- seedRandom: (seed: any, probability: number) => Math.floor(seedrandom(seed)() * 100) < probability,
- seedRannum: (seed: any, min: number, max: number) => min + Math.floor(seedrandom(seed)() * (max - min + 1)),
- seedRandomPick: (seed: any, list: any[]) => list[Math.floor(seedrandom(seed)() * list.length)],
- DRPWPM: (list: string[]) => {
- const xs: any[] = [];
- let totalFactor = 0;
- for (const x of list) {
- const parts = x.split(' ');
- const factor = parseInt(parts.pop()!, 10);
- const text = parts.join(' ');
- totalFactor += factor;
- xs.push({ factor, text });
- }
- const r = seedrandom(`${day}:${expr.id}`)() * totalFactor;
- let stackedFactor = 0;
- for (const x of xs) {
- if (r >= stackedFactor && r <= stackedFactor + x.factor) {
- return x.text;
- } else {
- stackedFactor += x.factor;
- }
- }
- return xs[0].text;
- },
- };
-
- return funcs;
-}
diff --git a/packages/frontend/src/scripts/hpml/type-checker.ts b/packages/frontend/src/scripts/hpml/type-checker.ts
deleted file mode 100644
index ea8133f297..0000000000
--- a/packages/frontend/src/scripts/hpml/type-checker.ts
+++ /dev/null
@@ -1,182 +0,0 @@
-import { isLiteralValue } from './expr';
-import { funcDefs } from './lib';
-import { envVarsDef } from '.';
-import type { Type, PageVar } from '.';
-import type { Expr, Variable } from './expr';
-
-type TypeError = {
- arg: number;
- expect: Type;
- actual: Type;
-};
-
-/**
- * Hpml type checker
- */
-export class HpmlTypeChecker {
- public variables: Variable[];
- public pageVars: PageVar[];
-
- constructor(variables: HpmlTypeChecker['variables'] = [], pageVars: HpmlTypeChecker['pageVars'] = []) {
- this.variables = variables;
- this.pageVars = pageVars;
- }
-
- public typeCheck(v: Expr): TypeError | null {
- if (isLiteralValue(v)) return null;
-
- const def = funcDefs[v.type || ''];
- if (def == null) {
- throw new Error('Unknown type: ' + v.type);
- }
-
- const generic: Type[] = [];
-
- for (let i = 0; i < def.in.length; i++) {
- const arg = def.in[i];
- const type = this.infer(v.args[i]);
- if (type === null) continue;
-
- if (typeof arg === 'number') {
- if (generic[arg] === undefined) {
- generic[arg] = type;
- } else if (type !== generic[arg]) {
- return {
- arg: i,
- expect: generic[arg],
- actual: type,
- };
- }
- } else if (type !== arg) {
- return {
- arg: i,
- expect: arg,
- actual: type,
- };
- }
- }
-
- return null;
- }
-
- public getExpectedType(v: Expr, slot: number): Type {
- const def = funcDefs[v.type ?? ''];
- if (def == null) {
- throw new Error('Unknown type: ' + v.type);
- }
-
- const generic: Type[] = [];
-
- for (let i = 0; i < def.in.length; i++) {
- const arg = def.in[i];
- const type = this.infer(v.args[i]);
- if (type === null) continue;
-
- if (typeof arg === 'number') {
- if (generic[arg] === undefined) {
- generic[arg] = type;
- }
- }
- }
-
- if (typeof def.in[slot] === 'number') {
- return generic[def.in[slot]] ?? null;
- } else {
- return def.in[slot];
- }
- }
-
- public infer(v: Expr): Type {
- if (v.type === null) return null;
- if (v.type === 'text') return 'string';
- if (v.type === 'multiLineText') return 'string';
- if (v.type === 'textList') return 'stringArray';
- if (v.type === 'number') return 'number';
- if (v.type === 'ref') {
- const variable = this.variables.find(va => va.name === v.value);
- if (variable) {
- return this.infer(variable);
- }
-
- const pageVar = this.pageVars.find(va => va.name === v.value);
- if (pageVar) {
- return pageVar.type;
- }
-
- const envVar = envVarsDef[v.value ?? ''];
- if (envVar !== undefined) {
- return envVar;
- }
-
- return null;
- }
- if (v.type === 'aiScriptVar') return null;
- if (v.type === 'fn') return null; // todo
- if (v.type.startsWith('fn:')) return null; // todo
-
- const generic: Type[] = [];
-
- const def = funcDefs[v.type];
-
- for (let i = 0; i < def.in.length; i++) {
- const arg = def.in[i];
- if (typeof arg === 'number') {
- const type = this.infer(v.args[i]);
-
- if (generic[arg] === undefined) {
- generic[arg] = type;
- } else {
- if (type !== generic[arg]) {
- generic[arg] = null;
- }
- }
- }
- }
-
- if (typeof def.out === 'number') {
- return generic[def.out];
- } else {
- return def.out;
- }
- }
-
- public getVarByName(name: string): Variable {
- const v = this.variables.find(x => x.name === name);
- if (v !== undefined) {
- return v;
- } else {
- throw new Error(`No such variable '${name}'`);
- }
- }
-
- public getVarsByType(type: Type): Variable[] {
- if (type == null) return this.variables;
- return this.variables.filter(x => (this.infer(x) === null) || (this.infer(x) === type));
- }
-
- public getEnvVarsByType(type: Type): string[] {
- if (type == null) return Object.keys(envVarsDef);
- return Object.entries(envVarsDef).filter(([k, v]) => v === null || type === v).map(([k, v]) => k);
- }
-
- public getPageVarsByType(type: Type): string[] {
- if (type == null) return this.pageVars.map(v => v.name);
- return this.pageVars.filter(v => type === v.type).map(v => v.name);
- }
-
- public isUsedName(name: string) {
- if (this.variables.some(v => v.name === name)) {
- return true;
- }
-
- if (this.pageVars.some(v => v.name === name)) {
- return true;
- }
-
- if (envVarsDef[name]) {
- return true;
- }
-
- return false;
- }
-}
diff --git a/packages/frontend/src/scripts/idle-render.ts b/packages/frontend/src/scripts/idle-render.ts
new file mode 100644
index 0000000000..ccce8b02bf
--- /dev/null
+++ b/packages/frontend/src/scripts/idle-render.ts
@@ -0,0 +1,38 @@
+class IdlingRenderScheduler {
+ #renderers: Set<FrameRequestCallback>;
+ #rafId: number;
+ #ricId: number;
+
+ constructor() {
+ this.#renderers = new Set();
+ this.#rafId = 0;
+ this.#ricId = requestIdleCallback((deadline) => this.#schedule(deadline));
+ }
+
+ #schedule(deadline: IdleDeadline): void {
+ if (deadline.timeRemaining()) {
+ this.#rafId = requestAnimationFrame((time) => {
+ for (const renderer of this.#renderers) {
+ renderer(time);
+ }
+ });
+ }
+ this.#ricId = requestIdleCallback((arg) => this.#schedule(arg));
+ }
+
+ add(renderer: FrameRequestCallback): void {
+ this.#renderers.add(renderer);
+ }
+
+ delete(renderer: FrameRequestCallback): void {
+ this.#renderers.delete(renderer);
+ }
+
+ dispose(): void {
+ this.#renderers.clear();
+ cancelAnimationFrame(this.#rafId);
+ cancelIdleCallback(this.#ricId);
+ }
+}
+
+export const defaultIdlingRenderScheduler = new IdlingRenderScheduler();
diff --git a/packages/frontend/src/scripts/select-file.ts b/packages/frontend/src/scripts/select-file.ts
index fe9f0a2447..44a58d6c7d 100644
--- a/packages/frontend/src/scripts/select-file.ts
+++ b/packages/frontend/src/scripts/select-file.ts
@@ -1,7 +1,7 @@
import { ref } from 'vue';
import { DriveFile } from 'misskey-js/built/entities';
import * as os from '@/os';
-import { stream } from '@/stream';
+import { useStream } from '@/stream';
import { i18n } from '@/i18n';
import { defaultStore } from '@/store';
import { uploadFile } from '@/scripts/upload';
@@ -51,7 +51,7 @@ export function chooseFileFromUrl(): Promise<DriveFile> {
const marker = Math.random().toString(); // TODO: UUIDとか使う
- const connection = stream.useChannel('main');
+ const connection = useStream().useChannel('main');
connection.on('urlUploadFinished', urlResponse => {
if (urlResponse.marker === marker) {
res(urlResponse.file);
diff --git a/packages/frontend/src/scripts/theme.ts b/packages/frontend/src/scripts/theme.ts
index 28284c7bcf..f2e8253565 100644
--- a/packages/frontend/src/scripts/theme.ts
+++ b/packages/frontend/src/scripts/theme.ts
@@ -60,7 +60,7 @@ export function applyTheme(theme: Theme, persist = true) {
document.documentElement.classList.remove('_themeChanging_');
}, 1000);
- const colorSchema = theme.base === 'dark' ? 'dark' : 'light';
+ const colorScheme = theme.base === 'dark' ? 'dark' : 'light';
// Deep copy
const _theme = deepClone(theme);
@@ -83,11 +83,11 @@ export function applyTheme(theme: Theme, persist = true) {
document.documentElement.style.setProperty(`--${k}`, v.toString());
}
- document.documentElement.style.setProperty('color-schema', colorSchema);
+ document.documentElement.style.setProperty('color-scheme', colorScheme);
if (persist) {
miLocalStorage.setItem('theme', JSON.stringify(props));
- miLocalStorage.setItem('colorSchema', colorSchema);
+ miLocalStorage.setItem('colorScheme', colorScheme);
}
// 色計算など再度行えるようにクライアント全体に通知
diff --git a/packages/frontend/src/scripts/time.ts b/packages/frontend/src/scripts/time.ts
index 34e8b6b17c..b21978b186 100644
--- a/packages/frontend/src/scripts/time.ts
+++ b/packages/frontend/src/scripts/time.ts
@@ -5,15 +5,16 @@ const dateTimeIntervals = {
};
export function dateUTC(time: number[]): Date {
- const d = time.length === 2 ? Date.UTC(time[0], time[1])
- : time.length === 3 ? Date.UTC(time[0], time[1], time[2])
- : time.length === 4 ? Date.UTC(time[0], time[1], time[2], time[3])
- : time.length === 5 ? Date.UTC(time[0], time[1], time[2], time[3], time[4])
- : time.length === 6 ? Date.UTC(time[0], time[1], time[2], time[3], time[4], time[5])
- : time.length === 7 ? Date.UTC(time[0], time[1], time[2], time[3], time[4], time[5], time[6])
- : null;
+ const d =
+ time.length === 2 ? Date.UTC(time[0], time[1])
+ : time.length === 3 ? Date.UTC(time[0], time[1], time[2])
+ : time.length === 4 ? Date.UTC(time[0], time[1], time[2], time[3])
+ : time.length === 5 ? Date.UTC(time[0], time[1], time[2], time[3], time[4])
+ : time.length === 6 ? Date.UTC(time[0], time[1], time[2], time[3], time[4], time[5])
+ : time.length === 7 ? Date.UTC(time[0], time[1], time[2], time[3], time[4], time[5], time[6])
+ : null;
- if (!d) throw 'wrong number of arguments';
+ if (!d) throw new Error('wrong number of arguments');
return new Date(d);
}
diff --git a/packages/frontend/src/scripts/use-note-capture.ts b/packages/frontend/src/scripts/use-note-capture.ts
index ffe33cccc0..22a01e066a 100644
--- a/packages/frontend/src/scripts/use-note-capture.ts
+++ b/packages/frontend/src/scripts/use-note-capture.ts
@@ -1,6 +1,6 @@
import { onUnmounted, Ref } from 'vue';
import * as misskey from 'misskey-js';
-import { stream } from '@/stream';
+import { useStream } from '@/stream';
import { $i } from '@/account';
export function useNoteCapture(props: {
@@ -9,7 +9,7 @@ export function useNoteCapture(props: {
isDeletedRef: Ref<boolean>;
}) {
const note = props.note;
- const connection = $i ? stream : null;
+ const connection = $i ? useStream() : null;
function onStreamNoteUpdated(noteData): void {
const { type, id, body } = noteData;
diff --git a/packages/frontend/src/scripts/worker-multi-dispatch.ts b/packages/frontend/src/scripts/worker-multi-dispatch.ts
new file mode 100644
index 0000000000..1847a8ccff
--- /dev/null
+++ b/packages/frontend/src/scripts/worker-multi-dispatch.ts
@@ -0,0 +1,75 @@
+function defaultUseWorkerNumber(prev: number, totalWorkers: number) {
+ return prev + 1;
+}
+
+export class WorkerMultiDispatch<POST = any, RETURN = any> {
+ private symbol = Symbol('WorkerMultiDispatch');
+ private workers: Worker[] = [];
+ private terminated = false;
+ private prevWorkerNumber = 0;
+ private getUseWorkerNumber = defaultUseWorkerNumber;
+ private finalizationRegistry: FinalizationRegistry<symbol>;
+
+ constructor(workerConstructor: () => Worker, concurrency: number, getUseWorkerNumber = defaultUseWorkerNumber) {
+ this.getUseWorkerNumber = getUseWorkerNumber;
+ for (let i = 0; i < concurrency; i++) {
+ this.workers.push(workerConstructor());
+ }
+
+ this.finalizationRegistry = new FinalizationRegistry(() => {
+ this.terminate();
+ });
+ this.finalizationRegistry.register(this, this.symbol);
+
+ if (_DEV_) console.log('WorkerMultiDispatch: Created', this);
+ }
+
+ public postMessage(message: POST, options?: Transferable[] | StructuredSerializeOptions, useWorkerNumber: typeof defaultUseWorkerNumber = this.getUseWorkerNumber) {
+ let workerNumber = useWorkerNumber(this.prevWorkerNumber, this.workers.length);
+ workerNumber = Math.abs(Math.round(workerNumber)) % this.workers.length;
+ if (_DEV_) console.log('WorkerMultiDispatch: Posting message to worker', workerNumber, useWorkerNumber);
+ this.prevWorkerNumber = workerNumber;
+
+ // 不毛だがunionをoverloadに突っ込めない
+ // https://stackoverflow.com/questions/66507585/overload-signatures-union-types-and-no-overload-matches-this-call-error
+ // https://github.com/microsoft/TypeScript/issues/14107
+ if (Array.isArray(options)) {
+ this.workers[workerNumber].postMessage(message, options);
+ } else {
+ this.workers[workerNumber].postMessage(message, options);
+ }
+ return workerNumber;
+ }
+
+ public addListener(callback: (this: Worker, ev: MessageEvent<RETURN>) => any, options?: boolean | AddEventListenerOptions) {
+ this.workers.forEach(worker => {
+ worker.addEventListener('message', callback, options);
+ });
+ }
+
+ public removeListener(callback: (this: Worker, ev: MessageEvent<RETURN>) => any, options?: boolean | AddEventListenerOptions) {
+ this.workers.forEach(worker => {
+ worker.removeEventListener('message', callback, options);
+ });
+ }
+
+ public terminate() {
+ this.terminated = true;
+ if (_DEV_) console.log('WorkerMultiDispatch: Terminating', this);
+ this.workers.forEach(worker => {
+ worker.terminate();
+ });
+ this.workers = [];
+ this.finalizationRegistry.unregister(this);
+ }
+
+ public isTerminated() {
+ return this.terminated;
+ }
+ public getWorkers() {
+ return this.workers;
+ }
+ public getSymbol() {
+ return this.symbol;
+ }
+}
diff --git a/packages/frontend/src/store.ts b/packages/frontend/src/store.ts
index 245bcbefe1..6ba05c36ab 100644
--- a/packages/frontend/src/store.ts
+++ b/packages/frontend/src/store.ts
@@ -92,7 +92,7 @@ export const defaultStore = markRaw(new Storage('base', {
},
reactionAcceptance: {
where: 'account',
- default: null as 'likeOnly' | 'likeOnlyForRemote' | null,
+ default: 'nonSensitiveOnly' as 'likeOnly' | 'likeOnlyForRemote' | 'nonSensitiveOnly' | 'nonSensitiveOnlyForLocalLikeOnlyForRemote' | null,
},
mutedWords: {
where: 'account',
@@ -102,6 +102,10 @@ export const defaultStore = markRaw(new Storage('base', {
where: 'account',
default: [] as string[],
},
+ showTimelineReplies: {
+ where: 'account',
+ default: false,
+ },
menu: {
where: 'deviceAccount',
@@ -314,6 +318,10 @@ export const defaultStore = markRaw(new Storage('base', {
where: 'device',
default: false,
},
+ devMode: {
+ where: 'device',
+ default: false,
+ },
mediaListWithOneImageAppearance: {
where: 'device',
default: 'expand' as 'expand' | '16_9' | '1_1' | '2_3',
@@ -328,7 +336,11 @@ export const defaultStore = markRaw(new Storage('base', {
},
enableCondensedLineForAcct: {
where: 'device',
- default: true,
+ default: false,
+ },
+ additionalUnicodeEmojiIndexes: {
+ where: 'device',
+ default: {} as Record<string, Record<string, string[]>>,
},
}));
diff --git a/packages/frontend/src/stream.ts b/packages/frontend/src/stream.ts
index dea3459b86..9cae58a26a 100644
--- a/packages/frontend/src/stream.ts
+++ b/packages/frontend/src/stream.ts
@@ -3,6 +3,14 @@ import { markRaw } from 'vue';
import { $i } from '@/account';
import { url } from '@/config';
-export const stream = markRaw(new Misskey.Stream(url, $i ? {
- token: $i.token,
-} : null));
+let stream: Misskey.Stream | null = null;
+
+export function useStream(): Misskey.Stream {
+ if (stream) return stream;
+
+ stream = markRaw(new Misskey.Stream(url, $i ? {
+ token: $i.token,
+ } : null));
+
+ return stream;
+}
diff --git a/packages/frontend/src/style.scss b/packages/frontend/src/style.scss
index 20254d335e..b376e4c42d 100644
--- a/packages/frontend/src/style.scss
+++ b/packages/frontend/src/style.scss
@@ -22,11 +22,7 @@
}
html {
- touch-action: manipulation;
background-color: var(--bg);
- background-attachment: fixed;
- background-size: cover;
- background-position: center;
color: var(--fg);
accent-color: var(--accent);
overflow: auto;
@@ -38,7 +34,7 @@ html {
tab-size: 2;
&, * {
- scrollbar-color: var(--scrollbarHandle) inherit;
+ scrollbar-color: var(--scrollbarHandle) transparent;
scrollbar-width: thin;
&::-webkit-scrollbar {
@@ -87,6 +83,7 @@ html._themeChanging_ {
}
html, body {
+ touch-action: manipulation;
margin: 0;
padding: 0;
scroll-behavior: smooth;
@@ -483,3 +480,140 @@ hr {
transform: scaleX(1.00) scaleY(1.00) ;
}
}
+
+// MFM -----------------------------
+
+._mfm_blur_ {
+ filter: blur(6px);
+ transition: filter 0.3s;
+
+ &:hover {
+ filter: blur(0px);
+ }
+}
+
+.mfm-x2 {
+ --mfm-zoom-size: 200%;
+}
+
+.mfm-x3 {
+ --mfm-zoom-size: 400%;
+}
+
+.mfm-x4 {
+ --mfm-zoom-size: 600%;
+}
+
+.mfm-x2, .mfm-x3, .mfm-x4 {
+ font-size: var(--mfm-zoom-size);
+
+ .mfm-x2, .mfm-x3, .mfm-x4 {
+ /* only half effective */
+ font-size: calc(var(--mfm-zoom-size) / 2 + 50%);
+
+ .mfm-x2, .mfm-x3, .mfm-x4 {
+ /* disabled */
+ font-size: 100%;
+ }
+ }
+}
+
+@keyframes mfm-spin {
+ 0% { transform: rotate(0deg); }
+ 100% { transform: rotate(360deg); }
+}
+
+@keyframes mfm-spinX {
+ 0% { transform: perspective(128px) rotateX(0deg); }
+ 100% { transform: perspective(128px) rotateX(360deg); }
+}
+
+@keyframes mfm-spinY {
+ 0% { transform: perspective(128px) rotateY(0deg); }
+ 100% { transform: perspective(128px) rotateY(360deg); }
+}
+
+@keyframes mfm-jump {
+ 0% { transform: translateY(0); }
+ 25% { transform: translateY(-16px); }
+ 50% { transform: translateY(0); }
+ 75% { transform: translateY(-8px); }
+ 100% { transform: translateY(0); }
+}
+
+@keyframes mfm-bounce {
+ 0% { transform: translateY(0) scale(1, 1); }
+ 25% { transform: translateY(-16px) scale(1, 1); }
+ 50% { transform: translateY(0) scale(1, 1); }
+ 75% { transform: translateY(0) scale(1.5, 0.75); }
+ 100% { transform: translateY(0) scale(1, 1); }
+}
+
+// const val = () => `translate(${Math.floor(Math.random() * 20) - 10}px, ${Math.floor(Math.random() * 20) - 10}px)`;
+// let css = '';
+// for (let i = 0; i <= 100; i += 5) { css += `${i}% { transform: ${val()} }\n`; }
+@keyframes mfm-twitch {
+ 0% { transform: translate(7px, -2px) }
+ 5% { transform: translate(-3px, 1px) }
+ 10% { transform: translate(-7px, -1px) }
+ 15% { transform: translate(0px, -1px) }
+ 20% { transform: translate(-8px, 6px) }
+ 25% { transform: translate(-4px, -3px) }
+ 30% { transform: translate(-4px, -6px) }
+ 35% { transform: translate(-8px, -8px) }
+ 40% { transform: translate(4px, 6px) }
+ 45% { transform: translate(-3px, 1px) }
+ 50% { transform: translate(2px, -10px) }
+ 55% { transform: translate(-7px, 0px) }
+ 60% { transform: translate(-2px, 4px) }
+ 65% { transform: translate(3px, -8px) }
+ 70% { transform: translate(6px, 7px) }
+ 75% { transform: translate(-7px, -2px) }
+ 80% { transform: translate(-7px, -8px) }
+ 85% { transform: translate(9px, 3px) }
+ 90% { transform: translate(-3px, -2px) }
+ 95% { transform: translate(-10px, 2px) }
+ 100% { transform: translate(-2px, -6px) }
+}
+
+// const val = () => `translate(${Math.floor(Math.random() * 6) - 3}px, ${Math.floor(Math.random() * 6) - 3}px) rotate(${Math.floor(Math.random() * 24) - 12}deg)`;
+// let css = '';
+// for (let i = 0; i <= 100; i += 5) { css += `${i}% { transform: ${val()} }\n`; }
+@keyframes mfm-shake {
+ 0% { transform: translate(-3px, -1px) rotate(-8deg) }
+ 5% { transform: translate(0px, -1px) rotate(-10deg) }
+ 10% { transform: translate(1px, -3px) rotate(0deg) }
+ 15% { transform: translate(1px, 1px) rotate(11deg) }
+ 20% { transform: translate(-2px, 1px) rotate(1deg) }
+ 25% { transform: translate(-1px, -2px) rotate(-2deg) }
+ 30% { transform: translate(-1px, 2px) rotate(-3deg) }
+ 35% { transform: translate(2px, 1px) rotate(6deg) }
+ 40% { transform: translate(-2px, -3px) rotate(-9deg) }
+ 45% { transform: translate(0px, -1px) rotate(-12deg) }
+ 50% { transform: translate(1px, 2px) rotate(10deg) }
+ 55% { transform: translate(0px, -3px) rotate(8deg) }
+ 60% { transform: translate(1px, -1px) rotate(8deg) }
+ 65% { transform: translate(0px, -1px) rotate(-7deg) }
+ 70% { transform: translate(-1px, -3px) rotate(6deg) }
+ 75% { transform: translate(0px, -2px) rotate(4deg) }
+ 80% { transform: translate(-2px, -1px) rotate(3deg) }
+ 85% { transform: translate(1px, -3px) rotate(-10deg) }
+ 90% { transform: translate(1px, 0px) rotate(3deg) }
+ 95% { transform: translate(-2px, 0px) rotate(-3deg) }
+ 100% { transform: translate(2px, 1px) rotate(2deg) }
+}
+
+@keyframes mfm-rubberBand {
+ from { transform: scale3d(1, 1, 1); }
+ 30% { transform: scale3d(1.25, 0.75, 1); }
+ 40% { transform: scale3d(0.75, 1.25, 1); }
+ 50% { transform: scale3d(1.15, 0.85, 1); }
+ 65% { transform: scale3d(0.95, 1.05, 1); }
+ 75% { transform: scale3d(1.05, 0.95, 1); }
+ to { transform: scale3d(1, 1, 1); }
+}
+
+@keyframes mfm-rainbow {
+ 0% { filter: hue-rotate(0deg) contrast(150%) saturate(150%); }
+ 100% { filter: hue-rotate(360deg) contrast(150%) saturate(150%); }
+}
diff --git a/packages/frontend/src/themes/_dark.json5 b/packages/frontend/src/themes/_dark.json5
index a23d25e866..5ef6adb085 100644
--- a/packages/frontend/src/themes/_dark.json5
+++ b/packages/frontend/src/themes/_dark.json5
@@ -21,6 +21,7 @@
fgTransparent: ':alpha<0.5<@fg',
fgHighlighted: ':lighten<3<@fg',
fgOnAccent: '#fff',
+ fgOnWhite: '#333',
divider: 'rgba(255, 255, 255, 0.1)',
indicator: '@accent',
panel: ':lighten<3<@bg',
@@ -77,7 +78,7 @@
codeString: '#ffb675',
codeNumber: '#cfff9e',
codeBoolean: '#c59eff',
- deckDivider: '#000',
+ deckBg: '#000',
htmlThemeColor: '@bg',
X2: ':darken<2<@panel',
X3: 'rgba(255, 255, 255, 0.05)',
diff --git a/packages/frontend/src/themes/_light.json5 b/packages/frontend/src/themes/_light.json5
index 713756221a..32f3c74909 100644
--- a/packages/frontend/src/themes/_light.json5
+++ b/packages/frontend/src/themes/_light.json5
@@ -21,6 +21,7 @@
fgTransparent: ':alpha<0.5<@fg',
fgHighlighted: ':darken<3<@fg',
fgOnAccent: '#fff',
+ fgOnWhite: '#333',
divider: 'rgba(0, 0, 0, 0.1)',
indicator: '@accent',
panel: ':lighten<3<@bg',
@@ -77,7 +78,7 @@
codeString: '#b98710',
codeNumber: '#0fbbbb',
codeBoolean: '#62b70c',
- deckDivider: ':darken<3<@bg',
+ deckBg: ':darken<3<@bg',
htmlThemeColor: '@bg',
X2: ':darken<2<@panel',
X3: 'rgba(0, 0, 0, 0.05)',
diff --git a/packages/frontend/src/themes/d-astro.json5 b/packages/frontend/src/themes/d-astro.json5
index c6a927ec3a..09a9ead1a2 100644
--- a/packages/frontend/src/themes/d-astro.json5
+++ b/packages/frontend/src/themes/d-astro.json5
@@ -53,6 +53,7 @@
panelHeaderBg: ':lighten<3<@panel',
panelHeaderFg: '@fg',
htmlThemeColor: '@bg',
+ fgOnWhite: '@accent',
panelHighlight: ':lighten<3<@panel',
listItemHoverBg: 'rgba(255, 255, 255, 0.03)',
scrollbarHandle: 'rgba(255, 255, 255, 0.2)',
diff --git a/packages/frontend/src/themes/d-botanical.json5 b/packages/frontend/src/themes/d-botanical.json5
index 33cf7aa817..62208d2378 100644
--- a/packages/frontend/src/themes/d-botanical.json5
+++ b/packages/frontend/src/themes/d-botanical.json5
@@ -11,6 +11,7 @@
bg: 'rgb(37, 38, 36)',
fg: 'rgb(216, 212, 199)',
fgHighlighted: '#fff',
+ fgOnWhite: '@accent',
divider: 'rgba(255, 255, 255, 0.14)',
panel: 'rgb(47, 47, 44)',
panelHeaderDivider: 'rgba(0, 0, 0, 0)',
diff --git a/packages/frontend/src/themes/d-cherry.json5 b/packages/frontend/src/themes/d-cherry.json5
index a7e1ad1c80..f9638124c2 100644
--- a/packages/frontend/src/themes/d-cherry.json5
+++ b/packages/frontend/src/themes/d-cherry.json5
@@ -10,6 +10,7 @@
accent: 'rgb(255, 89, 117)',
bg: 'rgb(28, 28, 37)',
fg: 'rgb(236, 239, 244)',
+ fgOnWhite: '@accent',
panel: 'rgb(35, 35, 47)',
renote: '@accent',
link: '@accent',
diff --git a/packages/frontend/src/themes/d-dark.json5 b/packages/frontend/src/themes/d-dark.json5
index 63144e88ea..ae4f7d53f5 100644
--- a/packages/frontend/src/themes/d-dark.json5
+++ b/packages/frontend/src/themes/d-dark.json5
@@ -11,6 +11,7 @@
bg: '#232323',
fg: 'rgb(199, 209, 216)',
fgHighlighted: '#fff',
+ fgOnWhite: '@accent',
divider: 'rgba(255, 255, 255, 0.14)',
panel: '#2d2d2d',
panelHeaderDivider: 'rgba(0, 0, 0, 0)',
diff --git a/packages/frontend/src/themes/d-future.json5 b/packages/frontend/src/themes/d-future.json5
index 0962a12411..f2c1f3eb86 100644
--- a/packages/frontend/src/themes/d-future.json5
+++ b/packages/frontend/src/themes/d-future.json5
@@ -12,6 +12,7 @@
fg: '#D5D5D6',
fgHighlighted: '#fff',
fgOnAccent: '#000',
+ fgOnWhite: '@accent',
divider: 'rgba(255, 255, 255, 0.1)',
panel: '#18181c',
panelHeaderDivider: 'rgba(0, 0, 0, 0)',
diff --git a/packages/frontend/src/themes/d-green-lime.json5 b/packages/frontend/src/themes/d-green-lime.json5
index 9522f534a4..ca4e688fdb 100644
--- a/packages/frontend/src/themes/d-green-lime.json5
+++ b/packages/frontend/src/themes/d-green-lime.json5
@@ -12,6 +12,7 @@
fg: '#dee7e4',
fgHighlighted: '#fff',
fgOnAccent: '#192320',
+ fgOnWhite: '@accent',
divider: '#e7fffb24',
panel: '#192320',
panelHeaderDivider: 'rgba(0, 0, 0, 0)',
diff --git a/packages/frontend/src/themes/d-green-orange.json5 b/packages/frontend/src/themes/d-green-orange.json5
index e542782c66..c2539816e2 100644
--- a/packages/frontend/src/themes/d-green-orange.json5
+++ b/packages/frontend/src/themes/d-green-orange.json5
@@ -12,6 +12,7 @@
fg: '#dee7e4',
fgHighlighted: '#fff',
fgOnAccent: '#192320',
+ fgOnWhite: '@accent',
divider: '#e7fffb24',
panel: '#192320',
panelHeaderDivider: 'rgba(0, 0, 0, 0)',
diff --git a/packages/frontend/src/themes/d-ice.json5 b/packages/frontend/src/themes/d-ice.json5
index 179b060dcf..b4abc0cacb 100644
--- a/packages/frontend/src/themes/d-ice.json5
+++ b/packages/frontend/src/themes/d-ice.json5
@@ -8,6 +8,7 @@
props: {
accent: '#47BFE8',
+ fgOnWhite: '@accent',
bg: '#212526',
},
}
diff --git a/packages/frontend/src/themes/d-persimmon.json5 b/packages/frontend/src/themes/d-persimmon.json5
index e36265ff10..0ab6523dd7 100644
--- a/packages/frontend/src/themes/d-persimmon.json5
+++ b/packages/frontend/src/themes/d-persimmon.json5
@@ -11,6 +11,7 @@
bg: 'rgb(31, 33, 31)',
fg: '#cdd8c7',
fgHighlighted: '#fff',
+ fgOnWhite: '@accent',
divider: 'rgba(255, 255, 255, 0.14)',
panel: 'rgb(41, 43, 41)',
infoFg: '@fg',
diff --git a/packages/frontend/src/themes/d-u0.json5 b/packages/frontend/src/themes/d-u0.json5
index b270f809ac..ed776746a8 100644
--- a/packages/frontend/src/themes/d-u0.json5
+++ b/packages/frontend/src/themes/d-u0.json5
@@ -55,6 +55,7 @@
codeNumber: '#cfff9e',
codeString: '#ffb675',
fgOnAccent: '#fff',
+ fgOnWhite: '@accent',
infoWarnBg: '#42321c',
infoWarnFg: '#ffbd3e',
navHoverFg: ':lighten<17<@fg',
@@ -83,6 +84,6 @@
fgTransparentWeak: ':alpha<0.75<@fg',
panelHeaderDivider: 'rgba(0, 0, 0, 0)',
scrollbarHandleHover: 'rgba(255, 255, 255, 0.4)',
- deckDivider: '#142022',
+ deckBg: '#142022',
},
}
diff --git a/packages/frontend/src/themes/l-apricot.json5 b/packages/frontend/src/themes/l-apricot.json5
index 1ed5525575..fe1f9f8927 100644
--- a/packages/frontend/src/themes/l-apricot.json5
+++ b/packages/frontend/src/themes/l-apricot.json5
@@ -10,6 +10,7 @@
accent: 'rgb(234, 154, 82)',
bg: '#e6e5e2',
fg: 'rgb(149, 143, 139)',
+ fgOnWhite: '@accent',
panel: '#EEECE8',
renote: '@accent',
link: '@accent',
diff --git a/packages/frontend/src/themes/l-botanical.json5 b/packages/frontend/src/themes/l-botanical.json5
index 2ea9a7d6c9..5c98927896 100644
--- a/packages/frontend/src/themes/l-botanical.json5
+++ b/packages/frontend/src/themes/l-botanical.json5
@@ -11,6 +11,7 @@
bg: 'e2deda',
fg: '#3d3d3d',
fgHighlighted: '#6bc9a0',
+ fgOnWhite: '@accent',
divider: '#cfcfcf',
panel: '@X14',
panelHeaderBg: '@panel',
diff --git a/packages/frontend/src/themes/l-cherry.json5 b/packages/frontend/src/themes/l-cherry.json5
index 5ad240241e..1189a28fe6 100644
--- a/packages/frontend/src/themes/l-cherry.json5
+++ b/packages/frontend/src/themes/l-cherry.json5
@@ -10,6 +10,7 @@
accent: 'rgb(219, 96, 114)',
bg: 'rgb(254, 248, 249)',
fg: 'rgb(152, 13, 26)',
+ fgOnWhite: '@accent',
panel: 'rgb(255, 255, 255)',
renote: '@accent',
link: 'rgb(156, 187, 5)',
diff --git a/packages/frontend/src/themes/l-coffee.json5 b/packages/frontend/src/themes/l-coffee.json5
index fbcd4fa9ef..b64cc73583 100644
--- a/packages/frontend/src/themes/l-coffee.json5
+++ b/packages/frontend/src/themes/l-coffee.json5
@@ -10,6 +10,7 @@
accent: '#9f8989',
bg: '#f5f3f3',
fg: '#7f6666',
+ fgOnWhite: '@accent',
panel: '#fff',
divider: 'rgba(87, 68, 68, 0.1)',
renote: 'rgb(160, 172, 125)',
diff --git a/packages/frontend/src/themes/l-light.json5 b/packages/frontend/src/themes/l-light.json5
index 248355c945..63c2e6d278 100644
--- a/packages/frontend/src/themes/l-light.json5
+++ b/packages/frontend/src/themes/l-light.json5
@@ -10,6 +10,7 @@
props: {
bg: '#f9f9f9',
fg: '#676767',
+ fgOnWhite: '@accent',
divider: '#e8e8e8',
header: ':alpha<0.7<@panel',
navBg: '#fff',
diff --git a/packages/frontend/src/themes/l-rainy.json5 b/packages/frontend/src/themes/l-rainy.json5
index 283dd74c6c..e7d1d5af00 100644
--- a/packages/frontend/src/themes/l-rainy.json5
+++ b/packages/frontend/src/themes/l-rainy.json5
@@ -10,6 +10,7 @@
accent: '#5db0da',
bg: 'rgb(246 248 249)',
fg: '#636b71',
+ fgOnWhite: '@accent',
panel: '#fff',
divider: 'rgb(230 233 234)',
panelHeaderDivider: '@divider',
diff --git a/packages/frontend/src/themes/l-sushi.json5 b/packages/frontend/src/themes/l-sushi.json5
index 5846927d65..e787d63734 100644
--- a/packages/frontend/src/themes/l-sushi.json5
+++ b/packages/frontend/src/themes/l-sushi.json5
@@ -10,6 +10,7 @@
accent: '#e36749',
bg: '#f0eee9',
fg: '#5f5f5f',
+ fgOnWhite: '@accent',
renote: '@accent',
link: '@accent',
mention: '@accent',
diff --git a/packages/frontend/src/themes/l-u0.json5 b/packages/frontend/src/themes/l-u0.json5
index 03b114ba39..b77b15e3f0 100644
--- a/packages/frontend/src/themes/l-u0.json5
+++ b/packages/frontend/src/themes/l-u0.json5
@@ -55,6 +55,7 @@
codeNumber: '#cfff9e',
codeString: '#ffb675',
fgOnAccent: '#fff',
+ fgOnWhite: '@accent',
infoWarnBg: '#42321c',
infoWarnFg: '#ffbd3e',
navHoverFg: ':lighten<17<@fg',
diff --git a/packages/frontend/src/themes/l-vivid.json5 b/packages/frontend/src/themes/l-vivid.json5
index b3c08f38ae..822ef948dd 100644
--- a/packages/frontend/src/themes/l-vivid.json5
+++ b/packages/frontend/src/themes/l-vivid.json5
@@ -52,6 +52,7 @@
driveFolderBg: ':alpha<0.3<@accent',
fgHighlighted: ':darken<3<@fg',
fgTransparent: ':alpha<0.5<@fg',
+ fgOnWhite: '@accent',
panelHeaderBg: ':lighten<3<@panel',
panelHeaderFg: '@fg',
htmlThemeColor: '@bg',
diff --git a/packages/frontend/src/ui/_common_/common.vue b/packages/frontend/src/ui/_common_/common.vue
index 71a4285e9d..3b970eefbe 100644
--- a/packages/frontend/src/ui/_common_/common.vue
+++ b/packages/frontend/src/ui/_common_/common.vue
@@ -10,12 +10,20 @@
<XUpload v-if="uploads.length > 0"/>
<TransitionGroup
- 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 : ''"
+ tag="div"
+ :class="[$style.notifications, {
+ [$style.notificationsPosition_leftTop]: defaultStore.state.notificationPosition === 'leftTop',
+ [$style.notificationsPosition_leftBottom]: defaultStore.state.notificationPosition === 'leftBottom',
+ [$style.notificationsPosition_rightTop]: defaultStore.state.notificationPosition === 'rightTop',
+ [$style.notificationsPosition_rightBottom]: defaultStore.state.notificationPosition === 'rightBottom',
+ [$style.notificationsStackAxis_vertical]: defaultStore.state.notificationStackAxis === 'vertical',
+ [$style.notificationsStackAxis_horizontal]: defaultStore.state.notificationStackAxis === 'horizontal',
+ }]"
+ :moveClass="defaultStore.state.animation ? $style.transition_notification_move : ''"
+ :enterActiveClass="defaultStore.state.animation ? $style.transition_notification_enterActive : ''"
+ :leaveActiveClass="defaultStore.state.animation ? $style.transition_notification_leaveActive : ''"
+ :enterFromClass="defaultStore.state.animation ? $style.transition_notification_enterFrom : ''"
+ :leaveToClass="defaultStore.state.animation ? $style.transition_notification_leaveTo : ''"
>
<div v-for="notification in notifications" :key="notification.id" :class="$style.notification">
<XNotification :notification="notification"/>
@@ -40,7 +48,7 @@ import { popups, pendingApiRequestsCount } from '@/os';
import { uploads } from '@/scripts/upload';
import * as sound from '@/scripts/sound';
import { $i } from '@/account';
-import { stream } from '@/stream';
+import { useStream } from '@/stream';
import { i18n } from '@/i18n';
import { defaultStore } from '@/store';
@@ -55,7 +63,7 @@ function onNotification(notification) {
if ($i.mutingNotificationTypes.includes(notification.type)) return;
if (document.visibilityState === 'visible') {
- stream.send('readNotification');
+ useStream().send('readNotification');
notifications.unshift(notification);
window.setTimeout(() => {
@@ -71,7 +79,7 @@ function onNotification(notification) {
}
if ($i) {
- const connection = stream.useChannel('main', null, 'UI');
+ const connection = useStream().useChannel('main', null, 'UI');
connection.on('notification', onNotification);
//#region Listen message from SW
@@ -103,31 +111,31 @@ if ($i) {
pointer-events: none;
display: flex;
- &.notificationsPosition-leftTop {
+ &.notificationsPosition_leftTop {
top: var(--margin);
left: 0;
}
- &.notificationsPosition-rightTop {
+ &.notificationsPosition_rightTop {
top: var(--margin);
right: 0;
}
- &.notificationsPosition-leftBottom {
+ &.notificationsPosition_leftBottom {
bottom: calc(var(--minBottomSpacing) + var(--margin));
left: 0;
}
- &.notificationsPosition-rightBottom {
+ &.notificationsPosition_rightBottom {
bottom: calc(var(--minBottomSpacing) + var(--margin));
right: 0;
}
- &.notificationsStackAxis-vertical {
+ &.notificationsStackAxis_vertical {
width: 250px;
- &.notificationsPosition-leftTop,
- &.notificationsPosition-rightTop {
+ &.notificationsPosition_leftTop,
+ &.notificationsPosition_rightTop {
flex-direction: column;
.notification {
@@ -137,8 +145,8 @@ if ($i) {
}
}
- &.notificationsPosition-leftBottom,
- &.notificationsPosition-rightBottom {
+ &.notificationsPosition_leftBottom,
+ &.notificationsPosition_rightBottom {
flex-direction: column-reverse;
.notification {
@@ -149,11 +157,11 @@ if ($i) {
}
}
- &.notificationsStackAxis-horizontal {
+ &.notificationsStackAxis_horizontal {
width: 100%;
- &.notificationsPosition-leftTop,
- &.notificationsPosition-leftBottom {
+ &.notificationsPosition_leftTop,
+ &.notificationsPosition_leftBottom {
flex-direction: row;
.notification {
@@ -163,8 +171,8 @@ if ($i) {
}
}
- &.notificationsPosition-rightTop,
- &.notificationsPosition-rightBottom {
+ &.notificationsPosition_rightTop,
+ &.notificationsPosition_rightBottom {
flex-direction: row-reverse;
.notification {
diff --git a/packages/frontend/src/ui/_common_/navbar-for-mobile.vue b/packages/frontend/src/ui/_common_/navbar-for-mobile.vue
index 7a94a0c3ee..365486a0ae 100644
--- a/packages/frontend/src/ui/_common_/navbar-for-mobile.vue
+++ b/packages/frontend/src/ui/_common_/navbar-for-mobile.vue
@@ -1,43 +1,41 @@
<template>
-<div class="kmwsukvl">
- <div class="body">
- <div class="top">
- <div class="banner" :style="{ backgroundImage: `url(${ instance.bannerUrl })` }"></div>
- <button v-click-anime class="item _button instance" @click="openInstanceMenu">
- <img :src="instance.iconUrl || instance.faviconUrl || '/favicon.ico'" alt="" class="icon"/>
- </button>
- </div>
- <div class="middle">
- <MkA v-click-anime class="item index" active-class="active" to="/" exact>
- <i class="icon ti ti-home ti-fw"></i><span class="text">{{ i18n.ts.timeline }}</span>
- </MkA>
- <template v-for="item in menu">
- <div v-if="item === '-'" class="divider"></div>
- <component :is="navbarItemDef[item].to ? 'MkA' : 'button'" v-else-if="navbarItemDef[item] && (navbarItemDef[item].show !== false)" v-click-anime class="item _button" :class="[item, { active: navbarItemDef[item].active }]" active-class="active" :to="navbarItemDef[item].to" v-on="navbarItemDef[item].action ? { click: navbarItemDef[item].action } : {}">
- <i class="icon ti-fw" :class="navbarItemDef[item].icon"></i><span class="text">{{ navbarItemDef[item].title }}</span>
- <span v-if="navbarItemDef[item].indicated" class="indicator"><i class="icon _indicatorCircle"></i></span>
- </component>
- </template>
- <div class="divider"></div>
- <MkA v-if="$i.isAdmin || $i.isModerator" v-click-anime class="item" active-class="active" to="/admin">
- <i class="icon ti ti-dashboard ti-fw"></i><span class="text">{{ i18n.ts.controlPanel }}</span>
- </MkA>
- <button v-click-anime class="item _button" @click="more">
- <i class="icon ti ti-grid-dots ti-fw"></i><span class="text">{{ i18n.ts.more }}</span>
- <span v-if="otherMenuItemIndicated" class="indicator"><i class="icon _indicatorCircle"></i></span>
- </button>
- <MkA v-click-anime class="item" active-class="active" to="/settings">
- <i class="icon ti ti-settings ti-fw"></i><span class="text">{{ i18n.ts.settings }}</span>
- </MkA>
- </div>
- <div class="bottom">
- <button class="item _button post" data-cy-open-post-form @click="os.post">
- <i class="icon ti ti-pencil ti-fw"></i><span class="text">{{ i18n.ts.note }}</span>
- </button>
- <button v-click-anime class="item _button account" @click="openAccountMenu">
- <MkAvatar :user="$i" class="avatar"/><MkAcct class="text _nowrap" :user="$i"/>
- </button>
- </div>
+<div :class="$style.root">
+ <div :class="$style.top">
+ <div :class="$style.banner" :style="{ backgroundImage: `url(${ instance.bannerUrl })` }"></div>
+ <button class="_button" :class="$style.instance" @click="openInstanceMenu">
+ <img :src="instance.iconUrl || instance.faviconUrl || '/favicon.ico'" alt="" :class="$style.instanceIcon"/>
+ </button>
+ </div>
+ <div :class="$style.middle">
+ <MkA :class="$style.item" :activeClass="$style.active" to="/" exact>
+ <i :class="$style.itemIcon" class="ti ti-home ti-fw"></i><span :class="$style.itemText">{{ i18n.ts.timeline }}</span>
+ </MkA>
+ <template v-for="item in menu">
+ <div v-if="item === '-'" :class="$style.divider"></div>
+ <component :is="navbarItemDef[item].to ? 'MkA' : 'button'" v-else-if="navbarItemDef[item] && (navbarItemDef[item].show !== false)" class="_button" :class="[$style.item, { [$style.active]: navbarItemDef[item].active }]" :activeClass="$style.active" :to="navbarItemDef[item].to" v-on="navbarItemDef[item].action ? { click: navbarItemDef[item].action } : {}">
+ <i class="ti-fw" :class="[$style.itemIcon, navbarItemDef[item].icon]"></i><span :class="$style.itemText">{{ navbarItemDef[item].title }}</span>
+ <span v-if="navbarItemDef[item].indicated" :class="$style.itemIndicator"><i class="_indicatorCircle"></i></span>
+ </component>
+ </template>
+ <div :class="$style.divider"></div>
+ <MkA v-if="$i.isAdmin || $i.isModerator" :class="$style.item" :activeClass="$style.active" to="/admin">
+ <i :class="$style.itemIcon" class="ti ti-dashboard ti-fw"></i><span :class="$style.itemText">{{ i18n.ts.controlPanel }}</span>
+ </MkA>
+ <button :class="$style.item" class="_button" @click="more">
+ <i :class="$style.itemIcon" class="ti ti-grid-dots ti-fw"></i><span :class="$style.itemText">{{ i18n.ts.more }}</span>
+ <span v-if="otherMenuItemIndicated" :class="$style.itemIndicator"><i class="_indicatorCircle"></i></span>
+ </button>
+ <MkA :class="$style.item" :activeClass="$style.active" to="/settings">
+ <i :class="$style.itemIcon" class="ti ti-settings ti-fw"></i><span :class="$style.itemText">{{ i18n.ts.settings }}</span>
+ </MkA>
+ </div>
+ <div :class="$style.bottom">
+ <button class="_button" :class="$style.post" data-cy-open-post-form @click="os.post">
+ <i :class="$style.postIcon" class="ti ti-pencil ti-fw"></i><span style="position: relative;">{{ i18n.ts.note }}</span>
+ </button>
+ <button class="_button" :class="$style.account" @click="openAccountMenu">
+ <MkAvatar :user="$i" :class="$style.avatar"/><MkAcct :class="$style.acct" class="_nowrap" :user="$i"/>
+ </button>
</div>
</div>
</template>
@@ -73,192 +71,186 @@ function more() {
}
</script>
-<style lang="scss" scoped>
-.kmwsukvl {
- > .body {
- display: flex;
- flex-direction: column;
-
- > .top {
- position: sticky;
- top: 0;
- z-index: 1;
- padding: 20px 0;
- background: var(--X14);
- -webkit-backdrop-filter: var(--blur, blur(8px));
- backdrop-filter: var(--blur, blur(8px));
+<style lang="scss" module>
+.root {
+ display: flex;
+ flex-direction: column;
+}
- > .banner {
- position: absolute;
- top: 0;
- left: 0;
- width: 100%;
- height: 100%;
- background-size: cover;
- background-position: center center;
- -webkit-mask-image: linear-gradient(0deg, rgba(0,0,0,0) 15%, rgba(0,0,0,0.75) 100%);
- mask-image: linear-gradient(0deg, rgba(0,0,0,0) 15%, rgba(0,0,0,0.75) 100%);
- }
+.top {
+ position: sticky;
+ top: 0;
+ z-index: 1;
+ padding: 20px 0;
+ background: var(--X14);
+ -webkit-backdrop-filter: var(--blur, blur(8px));
+ backdrop-filter: var(--blur, blur(8px));
+}
- > .instance {
- position: relative;
- display: block;
- text-align: center;
- width: 100%;
+.banner {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background-size: cover;
+ background-position: center center;
+ -webkit-mask-image: linear-gradient(0deg, rgba(0,0,0,0) 15%, rgba(0,0,0,0.75) 100%);
+ mask-image: linear-gradient(0deg, rgba(0,0,0,0) 15%, rgba(0,0,0,0.75) 100%);
+}
- > .icon {
- display: inline-block;
- width: 38px;
- aspect-ratio: 1;
- }
- }
- }
+.instance {
+ position: relative;
+ display: block;
+ text-align: center;
+ width: 100%;
+}
- > .bottom {
- position: sticky;
- bottom: 0;
- padding: 20px 0;
- background: var(--X14);
- -webkit-backdrop-filter: var(--blur, blur(8px));
- backdrop-filter: var(--blur, blur(8px));
+.instanceIcon {
+ display: inline-block;
+ width: 38px;
+ aspect-ratio: 1;
+}
- > .post {
- position: relative;
- display: block;
- width: 100%;
- height: 40px;
- color: var(--fgOnAccent);
- font-weight: bold;
- text-align: left;
+.bottom {
+ position: sticky;
+ bottom: 0;
+ padding: 20px 0;
+ background: var(--X14);
+ -webkit-backdrop-filter: var(--blur, blur(8px));
+ backdrop-filter: var(--blur, blur(8px));
+}
- &:before {
- content: "";
- display: block;
- width: calc(100% - 38px);
- height: 100%;
- margin: auto;
- position: absolute;
- top: 0;
- left: 0;
- right: 0;
- bottom: 0;
- border-radius: 999px;
- background: linear-gradient(90deg, var(--buttonGradateA), var(--buttonGradateB));
- }
+.post {
+ position: relative;
+ display: block;
+ width: 100%;
+ height: 40px;
+ color: var(--fgOnAccent);
+ font-weight: bold;
+ text-align: left;
- &:hover, &.active {
- &:before {
- background: var(--accentLighten);
- }
- }
+ &:before {
+ content: "";
+ display: block;
+ width: calc(100% - 38px);
+ height: 100%;
+ margin: auto;
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ border-radius: 999px;
+ background: linear-gradient(90deg, var(--buttonGradateA), var(--buttonGradateB));
+ }
- > .icon {
- position: relative;
- margin-left: 30px;
- margin-right: 8px;
- width: 32px;
- }
+ &:hover, &.active {
+ &:before {
+ background: var(--accentLighten);
+ }
+ }
+}
- > .text {
- position: relative;
- }
- }
+.postIcon {
+ position: relative;
+ margin-left: 30px;
+ margin-right: 8px;
+ width: 32px;
+}
- > .account {
- position: relative;
- display: flex;
- align-items: center;
- padding-left: 30px;
- width: 100%;
- text-align: left;
- box-sizing: border-box;
- margin-top: 16px;
+.account {
+ position: relative;
+ display: flex;
+ align-items: center;
+ padding-left: 30px;
+ width: 100%;
+ text-align: left;
+ box-sizing: border-box;
+ margin-top: 16px;
+}
- > .avatar {
- display: block;
- flex-shrink: 0;
- position: relative;
- width: 32px;
- aspect-ratio: 1;
- margin-right: 8px;
- }
+.avatar {
+ display: block;
+ flex-shrink: 0;
+ position: relative;
+ width: 32px;
+ aspect-ratio: 1;
+ margin-right: 8px;
+}
- > .text {
- display: block;
- flex-shrink: 1;
- padding-right: 8px;
- }
- }
- }
+.acct {
+ display: block;
+ flex-shrink: 1;
+ padding-right: 8px;
+}
- > .middle {
- flex: 1;
+.middle {
+ flex: 1;
+}
- > .divider {
- margin: 16px 16px;
- border-top: solid 0.5px var(--divider);
- }
+.divider {
+ margin: 16px 16px;
+ border-top: solid 0.5px var(--divider);
+}
- > .item {
- position: relative;
- display: block;
- padding-left: 24px;
- line-height: 2.85rem;
- text-overflow: ellipsis;
- overflow: hidden;
- white-space: nowrap;
- width: 100%;
- text-align: left;
- box-sizing: border-box;
- color: var(--navFg);
+.item {
+ position: relative;
+ display: block;
+ padding-left: 24px;
+ line-height: 2.85rem;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ white-space: nowrap;
+ width: 100%;
+ text-align: left;
+ box-sizing: border-box;
+ color: var(--navFg);
- > .icon {
- position: relative;
- width: 32px;
- margin-right: 8px;
- }
+ &:hover {
+ text-decoration: none;
+ color: var(--navHoverFg);
+ }
- > .indicator {
- position: absolute;
- top: 0;
- left: 20px;
- color: var(--navIndicator);
- font-size: 8px;
- animation: blink 1s infinite;
- }
+ &.active {
+ color: var(--navActive);
+ }
- > .text {
- position: relative;
- font-size: 0.9em;
- }
+ &:hover, &.active {
+ &:before {
+ content: "";
+ display: block;
+ width: calc(100% - 24px);
+ height: 100%;
+ margin: auto;
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ border-radius: 999px;
+ background: var(--accentedBg);
+ }
+ }
+}
- &:hover {
- text-decoration: none;
- color: var(--navHoverFg);
- }
+.itemIcon {
+ position: relative;
+ width: 32px;
+ margin-right: 8px;
+}
- &.active {
- color: var(--navActive);
- }
+.itemIndicator {
+ position: absolute;
+ top: 0;
+ left: 20px;
+ color: var(--navIndicator);
+ font-size: 8px;
+ animation: blink 1s infinite;
+}
- &:hover, &.active {
- &:before {
- content: "";
- display: block;
- width: calc(100% - 24px);
- height: 100%;
- margin: auto;
- position: absolute;
- top: 0;
- left: 0;
- right: 0;
- bottom: 0;
- border-radius: 999px;
- background: var(--accentedBg);
- }
- }
- }
- }
- }
+.itemText {
+ position: relative;
+ font-size: 0.9em;
}
</style>
diff --git a/packages/frontend/src/ui/_common_/navbar.vue b/packages/frontend/src/ui/_common_/navbar.vue
index 3b4b161422..a184f1d2f0 100644
--- a/packages/frontend/src/ui/_common_/navbar.vue
+++ b/packages/frontend/src/ui/_common_/navbar.vue
@@ -1,51 +1,50 @@
<template>
-<div class="mvcprjjd" :class="{ iconOnly }">
- <div class="body">
- <div class="top">
- <div class="banner" :style="{ backgroundImage: `url(${ instance.bannerUrl })` }"></div>
- <button v-click-anime v-tooltip.noDelay.right="instance.name ?? i18n.ts.instance" class="item _button instance" @click="openInstanceMenu">
- <img :src="instance.iconUrl || instance.faviconUrl || '/favicon.ico'" alt="" class="icon"/>
+<div :class="[$style.root, { [$style.iconOnly]: iconOnly }]">
+ <div :class="$style.body">
+ <div :class="$style.top">
+ <div :class="$style.banner" :style="{ backgroundImage: `url(${ instance.bannerUrl })` }"></div>
+ <button v-tooltip.noDelay.right="instance.name ?? i18n.ts.instance" class="_button" :class="$style.instance" @click="openInstanceMenu">
+ <img :src="instance.iconUrl || instance.faviconUrl || '/favicon.ico'" alt="" :class="$style.instanceIcon"/>
</button>
</div>
- <div class="middle">
- <MkA v-click-anime v-tooltip.noDelay.right="i18n.ts.timeline" class="item index" active-class="active" to="/" exact>
- <i class="icon ti ti-home ti-fw"></i><span class="text">{{ i18n.ts.timeline }}</span>
+ <div :class="$style.middle">
+ <MkA v-tooltip.noDelay.right="i18n.ts.timeline" :class="$style.item" :activeClass="$style.active" to="/" exact>
+ <i :class="$style.itemIcon" class="ti ti-home ti-fw"></i><span :class="$style.itemText">{{ i18n.ts.timeline }}</span>
</MkA>
<template v-for="item in menu">
- <div v-if="item === '-'" class="divider"></div>
+ <div v-if="item === '-'" :class="$style.divider"></div>
<component
:is="navbarItemDef[item].to ? 'MkA' : 'button'"
v-else-if="navbarItemDef[item] && (navbarItemDef[item].show !== false)"
- v-click-anime
v-tooltip.noDelay.right="navbarItemDef[item].title"
- class="item _button"
- :class="[item, { active: navbarItemDef[item].active }]"
- active-class="active"
+ class="_button"
+ :class="[$style.item, { [$style.active]: navbarItemDef[item].active }]"
+ :activeClass="$style.active"
:to="navbarItemDef[item].to"
v-on="navbarItemDef[item].action ? { click: navbarItemDef[item].action } : {}"
>
- <i class="icon ti-fw" :class="navbarItemDef[item].icon"></i><span class="text">{{ navbarItemDef[item].title }}</span>
- <span v-if="navbarItemDef[item].indicated" class="indicator"><i class="icon _indicatorCircle"></i></span>
+ <i class="ti-fw" :class="[$style.itemIcon, navbarItemDef[item].icon]"></i><span :class="$style.itemText">{{ navbarItemDef[item].title }}</span>
+ <span v-if="navbarItemDef[item].indicated" :class="$style.itemIndicator"><i class="_indicatorCircle"></i></span>
</component>
</template>
- <div class="divider"></div>
- <MkA v-if="$i.isAdmin || $i.isModerator" v-click-anime v-tooltip.noDelay.right="i18n.ts.controlPanel" class="item" active-class="active" to="/admin">
- <i class="icon ti ti-dashboard ti-fw"></i><span class="text">{{ i18n.ts.controlPanel }}</span>
+ <div :class="$style.divider"></div>
+ <MkA v-if="$i.isAdmin || $i.isModerator" v-tooltip.noDelay.right="i18n.ts.controlPanel" :class="$style.item" :activeClass="$style.active" to="/admin">
+ <i :class="$style.itemIcon" class="ti ti-dashboard ti-fw"></i><span :class="$style.itemText">{{ i18n.ts.controlPanel }}</span>
</MkA>
- <button v-click-anime class="item _button" @click="more">
- <i class="icon ti ti-grid-dots ti-fw"></i><span class="text">{{ i18n.ts.more }}</span>
- <span v-if="otherMenuItemIndicated" class="indicator"><i class="icon _indicatorCircle"></i></span>
+ <button class="_button" :class="$style.item" @click="more">
+ <i :class="$style.itemIcon" class="ti ti-grid-dots ti-fw"></i><span :class="$style.itemText">{{ i18n.ts.more }}</span>
+ <span v-if="otherMenuItemIndicated" :class="$style.itemIndicator"><i class="_indicatorCircle"></i></span>
</button>
- <MkA v-click-anime v-tooltip.noDelay.right="i18n.ts.settings" class="item" active-class="active" to="/settings">
- <i class="icon ti ti-settings ti-fw"></i><span class="text">{{ i18n.ts.settings }}</span>
+ <MkA v-tooltip.noDelay.right="i18n.ts.settings" :class="$style.item" :activeClass="$style.active" to="/settings">
+ <i :class="$style.itemIcon" class="ti ti-settings ti-fw"></i><span :class="$style.itemText">{{ i18n.ts.settings }}</span>
</MkA>
</div>
- <div class="bottom">
- <button v-tooltip.noDelay.right="i18n.ts.note" class="item _button post" data-cy-open-post-form @click="os.post">
- <i class="icon ti ti-pencil ti-fw"></i><span class="text">{{ i18n.ts.note }}</span>
+ <div :class="$style.bottom">
+ <button v-tooltip.noDelay.right="i18n.ts.note" class="_button" :class="[$style.post]" data-cy-open-post-form @click="os.post">
+ <i class="ti ti-pencil ti-fw" :class="$style.postIcon"></i><span :class="$style.postText">{{ i18n.ts.note }}</span>
</button>
- <button v-click-anime v-tooltip.noDelay.right="`${i18n.ts.account}: @${$i.username}`" class="item _button account" @click="openAccountMenu">
- <MkAvatar :user="$i" class="avatar"/><MkAcct class="text _nowrap" :user="$i"/>
+ <button v-tooltip.noDelay.right="`${i18n.ts.account}: @${$i.username}`" class="_button" :class="[$style.account]" @click="openAccountMenu">
+ <MkAvatar :user="$i" :class="$style.avatar"/><MkAcct class="_nowrap" :class="$style.acct" :user="$i"/>
</button>
</div>
</div>
@@ -99,374 +98,376 @@ function more(ev: MouseEvent) {
}
</script>
-<style lang="scss" scoped>
-.mvcprjjd {
- $nav-width: 250px;
- $nav-icon-only-width: 80px;
+<style lang="scss" module>
+.root {
+ --nav-width: 250px;
+ --nav-icon-only-width: 72px;
- flex: 0 0 $nav-width;
- width: $nav-width;
+ flex: 0 0 var(--nav-width);
+ width: var(--nav-width);
box-sizing: border-box;
+}
- > .body {
- position: fixed;
- top: 0;
- left: 0;
- z-index: 1001;
- width: $nav-icon-only-width;
- height: 100dvh;
- box-sizing: border-box;
- overflow: auto;
- overflow-x: clip;
- background: var(--navBg);
- contain: strict;
- display: flex;
- flex-direction: column;
- }
-
- &:not(.iconOnly) {
- > .body {
- width: $nav-width;
-
- > .top {
- position: sticky;
- top: 0;
- z-index: 1;
- padding: 20px 0;
- background: var(--X14);
- -webkit-backdrop-filter: var(--blur, blur(8px));
- backdrop-filter: var(--blur, blur(8px));
-
- > .banner {
- position: absolute;
- top: 0;
- left: 0;
- width: 100%;
- height: 100%;
- background-size: cover;
- background-position: center center;
- -webkit-mask-image: linear-gradient(0deg, rgba(0,0,0,0) 15%, rgba(0,0,0,0.75) 100%);
- mask-image: linear-gradient(0deg, rgba(0,0,0,0) 15%, rgba(0,0,0,0.75) 100%);
- }
+.body {
+ position: fixed;
+ top: 0;
+ left: 0;
+ z-index: 1001;
+ width: var(--nav-icon-only-width);
+ height: 100dvh;
+ box-sizing: border-box;
+ overflow: auto;
+ overflow-x: clip;
+ overscroll-behavior: contain;
+ background: var(--navBg);
+ contain: strict;
+ display: flex;
+ flex-direction: column;
+}
- > .instance {
- position: relative;
- display: block;
- text-align: center;
- width: 100%;
+.root:not(.iconOnly) {
+ .body {
+ width: var(--nav-width);
+ }
- > .icon {
- display: inline-block;
- width: 38px;
- aspect-ratio: 1;
- }
- }
- }
+ .top {
+ position: sticky;
+ top: 0;
+ z-index: 1;
+ padding: 20px 0;
+ background: var(--X14);
+ -webkit-backdrop-filter: var(--blur, blur(8px));
+ backdrop-filter: var(--blur, blur(8px));
+ }
- > .bottom {
- position: sticky;
- bottom: 0;
- padding: 20px 0;
- background: var(--X14);
- -webkit-backdrop-filter: var(--blur, blur(8px));
- backdrop-filter: var(--blur, blur(8px));
+ .banner {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background-size: cover;
+ background-position: center center;
+ -webkit-mask-image: linear-gradient(0deg, rgba(0,0,0,0) 15%, rgba(0,0,0,0.75) 100%);
+ mask-image: linear-gradient(0deg, rgba(0,0,0,0) 15%, rgba(0,0,0,0.75) 100%);
+ }
- > .post {
- position: relative;
- display: block;
- width: 100%;
- height: 40px;
- color: var(--fgOnAccent);
- font-weight: bold;
- text-align: left;
+ .instance {
+ position: relative;
+ display: block;
+ text-align: center;
+ width: 100%;
+ }
- &:before {
- content: "";
- display: block;
- width: calc(100% - 38px);
- height: 100%;
- margin: auto;
- position: absolute;
- top: 0;
- left: 0;
- right: 0;
- bottom: 0;
- border-radius: 999px;
- background: linear-gradient(90deg, var(--buttonGradateA), var(--buttonGradateB));
- }
+ .instanceIcon {
+ display: inline-block;
+ width: 38px;
+ aspect-ratio: 1;
+ }
- &:hover, &.active {
- &:before {
- background: var(--accentLighten);
- }
- }
+ .bottom {
+ position: sticky;
+ bottom: 0;
+ padding: 20px 0;
+ background: var(--X14);
+ -webkit-backdrop-filter: var(--blur, blur(8px));
+ backdrop-filter: var(--blur, blur(8px));
+ }
- > .icon {
- position: relative;
- margin-left: 30px;
- margin-right: 8px;
- width: 32px;
- }
+ .post {
+ position: relative;
+ display: block;
+ width: 100%;
+ height: 40px;
+ color: var(--fgOnAccent);
+ font-weight: bold;
+ text-align: left;
- > .text {
- position: relative;
- }
- }
+ &:before {
+ content: "";
+ display: block;
+ width: calc(100% - 38px);
+ height: 100%;
+ margin: auto;
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ border-radius: 999px;
+ background: linear-gradient(90deg, var(--buttonGradateA), var(--buttonGradateB));
+ }
- > .account {
- position: relative;
- display: flex;
- align-items: center;
- padding-left: 30px;
- width: 100%;
- text-align: left;
- box-sizing: border-box;
- margin-top: 16px;
+ &:hover, &.active {
+ &:before {
+ background: var(--accentLighten);
+ }
+ }
+ }
- > .avatar {
- display: block;
- flex-shrink: 0;
- position: relative;
- width: 32px;
- aspect-ratio: 1;
- margin-right: 8px;
- }
+ .postIcon {
+ position: relative;
+ margin-left: 30px;
+ margin-right: 8px;
+ width: 32px;
+ }
- > .text {
- display: block;
- flex-shrink: 1;
- padding-right: 8px;
- }
- }
- }
+ .postText {
+ position: relative;
+ }
- > .middle {
- flex: 1;
+ .account {
+ position: relative;
+ display: flex;
+ align-items: center;
+ padding-left: 30px;
+ width: 100%;
+ text-align: left;
+ box-sizing: border-box;
+ margin-top: 16px;
+ }
- > .divider {
- margin: 16px 16px;
- border-top: solid 0.5px var(--divider);
- }
+ .avatar {
+ display: block;
+ flex-shrink: 0;
+ position: relative;
+ width: 32px;
+ aspect-ratio: 1;
+ margin-right: 8px;
+ }
- > .item {
- position: relative;
- display: block;
- padding-left: 30px;
- line-height: 2.85rem;
- text-overflow: ellipsis;
- overflow: hidden;
- white-space: nowrap;
- width: 100%;
- text-align: left;
- box-sizing: border-box;
- color: var(--navFg);
+ .acct {
+ display: block;
+ flex-shrink: 1;
+ padding-right: 8px;
+ }
- > .icon {
- position: relative;
- width: 32px;
- margin-right: 8px;
- }
+ .middle {
+ flex: 1;
+ }
- > .indicator {
- position: absolute;
- top: 0;
- left: 20px;
- color: var(--navIndicator);
- font-size: 8px;
- animation: blink 1s infinite;
- }
+ .divider {
+ margin: 16px 16px;
+ border-top: solid 0.5px var(--divider);
+ }
- > .text {
- position: relative;
- font-size: 0.9em;
- }
+ .item {
+ position: relative;
+ display: block;
+ padding-left: 30px;
+ line-height: 2.85rem;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ white-space: nowrap;
+ width: 100%;
+ text-align: left;
+ box-sizing: border-box;
+ color: var(--navFg);
- &:hover {
- text-decoration: none;
- color: var(--navHoverFg);
- }
+ &:hover {
+ text-decoration: none;
+ color: var(--navHoverFg);
+ }
- &.active {
- color: var(--navActive);
- }
+ &.active {
+ color: var(--navActive);
+ }
- &:hover, &.active {
- color: var(--accent);
+ &:hover, &.active {
+ color: var(--accent);
- &:before {
- content: "";
- display: block;
- width: calc(100% - 34px);
- height: 100%;
- margin: auto;
- position: absolute;
- top: 0;
- left: 0;
- right: 0;
- bottom: 0;
- border-radius: 999px;
- background: var(--accentedBg);
- }
- }
- }
+ &:before {
+ content: "";
+ display: block;
+ width: calc(100% - 34px);
+ height: 100%;
+ margin: auto;
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ border-radius: 999px;
+ background: var(--accentedBg);
}
}
}
- &.iconOnly {
- flex: 0 0 $nav-icon-only-width;
- width: $nav-icon-only-width;
+ .itemIcon {
+ position: relative;
+ width: 32px;
+ margin-right: 8px;
+ }
- > .body {
- width: $nav-icon-only-width;
+ .itemIndicator {
+ position: absolute;
+ top: 0;
+ left: 20px;
+ color: var(--navIndicator);
+ font-size: 8px;
+ animation: blink 1s infinite;
+ }
- > .top {
- position: sticky;
- top: 0;
- z-index: 1;
- padding: 20px 0;
- background: var(--X14);
- -webkit-backdrop-filter: var(--blur, blur(8px));
- backdrop-filter: var(--blur, blur(8px));
+ .itemText {
+ position: relative;
+ font-size: 0.9em;
+ }
+}
- > .instance {
- display: block;
- text-align: center;
- width: 100%;
+.root.iconOnly {
+ flex: 0 0 var(--nav-icon-only-width);
+ width: var(--nav-icon-only-width);
- > .icon {
- display: inline-block;
- width: 30px;
- aspect-ratio: 1;
- }
- }
- }
+ .body {
+ width: var(--nav-icon-only-width);
+ }
- > .bottom {
- position: sticky;
- bottom: 0;
- padding: 20px 0;
- background: var(--X14);
- -webkit-backdrop-filter: var(--blur, blur(8px));
- backdrop-filter: var(--blur, blur(8px));
+ .top {
+ position: sticky;
+ top: 0;
+ z-index: 1;
+ padding: 20px 0;
+ background: var(--X14);
+ -webkit-backdrop-filter: var(--blur, blur(8px));
+ backdrop-filter: var(--blur, blur(8px));
+ }
- > .post {
- display: block;
- position: relative;
- width: 100%;
- height: 52px;
- margin-bottom: 16px;
- text-align: center;
+ .instance {
+ display: block;
+ text-align: center;
+ width: 100%;
+ }
- &:before {
- content: "";
- display: block;
- position: absolute;
- top: 0;
- left: 0;
- right: 0;
- bottom: 0;
- margin: auto;
- width: 52px;
- aspect-ratio: 1/1;
- border-radius: 100%;
- background: linear-gradient(90deg, var(--buttonGradateA), var(--buttonGradateB));
- }
+ .instanceIcon {
+ display: inline-block;
+ width: 30px;
+ aspect-ratio: 1;
+ }
- &:hover, &.active {
- &:before {
- background: var(--accentLighten);
- }
- }
+ .bottom {
+ position: sticky;
+ bottom: 0;
+ padding: 20px 0;
+ background: var(--X14);
+ -webkit-backdrop-filter: var(--blur, blur(8px));
+ backdrop-filter: var(--blur, blur(8px));
+ }
- > .icon {
- position: relative;
- color: var(--fgOnAccent);
- }
+ .post {
+ display: block;
+ position: relative;
+ width: 100%;
+ height: 52px;
+ margin-bottom: 16px;
+ text-align: center;
- > .text {
- display: none;
- }
- }
+ &:before {
+ content: "";
+ display: block;
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ margin: auto;
+ width: 52px;
+ aspect-ratio: 1/1;
+ border-radius: 100%;
+ background: linear-gradient(90deg, var(--buttonGradateA), var(--buttonGradateB));
+ }
- > .account {
- display: block;
- text-align: center;
- width: 100%;
+ &:hover, &.active {
+ &:before {
+ background: var(--accentLighten);
+ }
+ }
+ }
- > .avatar {
- display: inline-block;
- width: 38px;
- aspect-ratio: 1;
- }
+ .postIcon {
+ position: relative;
+ color: var(--fgOnAccent);
+ }
- > .text {
- display: none;
- }
- }
- }
+ .postText {
+ display: none;
+ }
- > .middle {
- flex: 1;
+ .account {
+ display: block;
+ text-align: center;
+ width: 100%;
+ }
- > .divider {
- margin: 8px auto;
- width: calc(100% - 32px);
- border-top: solid 0.5px var(--divider);
- }
+ .avatar {
+ display: inline-block;
+ width: 38px;
+ aspect-ratio: 1;
+ }
- > .item {
- display: block;
- position: relative;
- padding: 18px 0;
- width: 100%;
- text-align: center;
+ .acct {
+ display: none;
+ }
- > .icon {
- display: block;
- margin: 0 auto;
- opacity: 0.7;
- }
+ .middle {
+ flex: 1;
+ }
- > .text {
- display: none;
- }
+ .divider {
+ margin: 8px auto;
+ width: calc(100% - 32px);
+ border-top: solid 0.5px var(--divider);
+ }
- > .indicator {
- position: absolute;
- top: 6px;
- left: 24px;
- color: var(--navIndicator);
- font-size: 8px;
- animation: blink 1s infinite;
- }
+ .item {
+ display: block;
+ position: relative;
+ padding: 18px 0;
+ width: 100%;
+ text-align: center;
- &:hover, &.active {
- text-decoration: none;
- color: var(--accent);
+ &:hover, &.active {
+ text-decoration: none;
+ color: var(--accent);
- &:before {
- content: "";
- display: block;
- height: 100%;
- aspect-ratio: 1;
- margin: auto;
- position: absolute;
- top: 0;
- left: 0;
- right: 0;
- bottom: 0;
- border-radius: 999px;
- background: var(--accentedBg);
- }
+ &:before {
+ content: "";
+ display: block;
+ height: 100%;
+ aspect-ratio: 1;
+ margin: auto;
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ border-radius: 999px;
+ background: var(--accentedBg);
+ }
- > .icon, > .text {
- opacity: 1;
- }
- }
- }
+ > .icon,
+ > .text {
+ opacity: 1;
}
}
}
+
+ .itemIcon {
+ display: block;
+ margin: 0 auto;
+ opacity: 0.7;
+ }
+
+ .itemText {
+ display: none;
+ }
+
+ .itemIndicator {
+ position: absolute;
+ top: 6px;
+ left: 24px;
+ color: var(--navIndicator);
+ font-size: 8px;
+ animation: blink 1s infinite;
+ }
}
</style>
diff --git a/packages/frontend/src/ui/_common_/statusbar-federation.vue b/packages/frontend/src/ui/_common_/statusbar-federation.vue
index fe95460ba4..6f2e4bc9a7 100644
--- a/packages/frontend/src/ui/_common_/statusbar-federation.vue
+++ b/packages/frontend/src/ui/_common_/statusbar-federation.vue
@@ -1,14 +1,20 @@
<template>
-<span v-if="!fetching" class="nmidsaqw">
+<span v-if="!fetching" :class="$style.root">
<template v-if="display === 'marquee'">
- <Transition name="change" mode="default">
+ <Transition
+ :enterActiveClass="$style.transition_change_enterActive"
+ :leaveActiveClass="$style.transition_change_leaveActive"
+ :enterFromClass="$style.transition_change_enterFrom"
+ :leaveToClass="$style.transition_change_leaveTo"
+ mode="default"
+ >
<MarqueeText :key="key" :duration="marqueeDuration" :reverse="marqueeReverse">
- <span v-for="instance in instances" :key="instance.id" class="item" :class="{ colored }" :style="{ background: colored ? instance.themeColor : null }">
- <img class="icon" :src="getInstanceIcon(instance)" alt=""/>
- <MkA :to="`/instance-info/${instance.host}`" class="host _monospace">
+ <span v-for="instance in instances" :key="instance.id" :class="[$style.item, { [$style.colored]: colored }]" :style="{ background: colored ? instance.themeColor : null }">
+ <img :class="$style.icon" :src="getInstanceIcon(instance)" alt=""/>
+ <MkA :to="`/instance-info/${instance.host}`" :class="$style.host" class="_monospace">
{{ instance.host }}
</MkA>
- <span class="divider"></span>
+ <span></span>
</span>
</MarqueeText>
</Transition>
@@ -61,46 +67,47 @@ function getInstanceIcon(instance): string {
}
</script>
-<style lang="scss" scoped>
-.change-enter-active, .change-leave-active {
+<style lang="scss" module>
+.transition_change_enterActive,
+.transition_change_leaveActive {
position: absolute;
top: 0;
transition: all 1s ease;
}
-.change-enter-from {
- opacity: 0;
+.transition_change_enterFrom {
+ opacity: 0;
transform: translateY(-100%);
}
-.change-leave-to {
- opacity: 0;
+.transition_change_leaveTo {
+ opacity: 0;
transform: translateY(100%);
}
-.nmidsaqw {
+.root {
display: inline-block;
position: relative;
+}
- ::v-deep(.item) {
- display: inline-block;
- vertical-align: bottom;
- margin-right: 5em;
+.item {
+ display: inline-block;
+ vertical-align: bottom;
+ margin-right: 5em;
- > .icon {
- display: inline-block;
- height: var(--height);
- aspect-ratio: 1;
- vertical-align: bottom;
- margin-right: 1em;
- }
+ &.colored {
+ padding-right: 1em;
+ color: #fff;
+ }
+}
- > .host {
- vertical-align: bottom;
- }
+.icon {
+ display: inline-block;
+ height: var(--height);
+ aspect-ratio: 1;
+ vertical-align: bottom;
+ margin-right: 1em;
+}
- &.colored {
- padding-right: 1em;
- color: #fff;
- }
- }
+.host {
+ vertical-align: bottom;
}
</style>
diff --git a/packages/frontend/src/ui/_common_/statusbar-rss.vue b/packages/frontend/src/ui/_common_/statusbar-rss.vue
index 44b6b278ea..82473b609f 100644
--- a/packages/frontend/src/ui/_common_/statusbar-rss.vue
+++ b/packages/frontend/src/ui/_common_/statusbar-rss.vue
@@ -1,10 +1,16 @@
<template>
-<span v-if="!fetching" class="xbhtxfms">
+<span v-if="!fetching" :class="$style.root">
<template v-if="display === 'marquee'">
- <Transition name="change" mode="default">
+ <Transition
+ :enterActiveClass="$style.transition_change_enterActive"
+ :leaveActiveClass="$style.transition_change_leaveActive"
+ :enterFromClass="$style.transition_change_enterFrom"
+ :leaveToClass="$style.transition_change_leaveTo"
+ mode="default"
+ >
<MarqueeText :key="key" :duration="marqueeDuration" :reverse="marqueeReverse">
- <span v-for="item in items" class="item">
- <a class="link" :href="item.link" rel="nofollow noopener" target="_blank" :title="item.title">{{ item.title }}</a><span class="divider"></span>
+ <span v-for="item in items" :class="$style.item">
+ <a :href="item.link" rel="nofollow noopener" target="_blank" :title="item.title">{{ item.title }}</a><span :class="$style.divider"></span>
</span>
</MarqueeText>
</Transition>
@@ -54,39 +60,40 @@ useInterval(tick, Math.max(5000, props.refreshIntervalSec * 1000), {
});
</script>
-<style lang="scss" scoped>
-.change-enter-active, .change-leave-active {
+<style lang="scss" module>
+.transition_change_enterActive,
+.transition_change_leaveActive {
position: absolute;
top: 0;
transition: all 1s ease;
}
-.change-enter-from {
- opacity: 0;
+.transition_change_enterFrom {
+ opacity: 0;
transform: translateY(-100%);
}
-.change-leave-to {
- opacity: 0;
+.transition_change_leaveTo {
+ opacity: 0;
transform: translateY(100%);
}
-.xbhtxfms {
+.root {
display: inline-block;
position: relative;
+}
- ::v-deep(.item) {
- display: inline-flex;
- align-items: center;
- vertical-align: bottom;
- margin: 0;
+.item {
+ display: inline-flex;
+ align-items: center;
+ vertical-align: bottom;
+ margin: 0;
+}
- > .divider {
- display: inline-block;
- width: 0.5px;
- height: var(--height);
- margin: 0 3em;
- background: currentColor;
- opacity: 0.3;
- }
- }
+.divider {
+ display: inline-block;
+ width: 0.5px;
+ height: var(--height);
+ margin: 0 3em;
+ background: currentColor;
+ opacity: 0.3;
}
</style>
diff --git a/packages/frontend/src/ui/_common_/statusbar-user-list.vue b/packages/frontend/src/ui/_common_/statusbar-user-list.vue
index 16df69d968..9ac744943d 100644
--- a/packages/frontend/src/ui/_common_/statusbar-user-list.vue
+++ b/packages/frontend/src/ui/_common_/statusbar-user-list.vue
@@ -1,14 +1,20 @@
<template>
-<span v-if="!fetching" class="osdsvwzy">
+<span v-if="!fetching" :class="$style.root">
<template v-if="display === 'marquee'">
- <Transition name="change" mode="default">
+ <Transition
+ :enterActiveClass="$style.transition_change_enterActive"
+ :leaveActiveClass="$style.transition_change_leaveActive"
+ :enterFromClass="$style.transition_change_enterFrom"
+ :leaveToClass="$style.transition_change_leaveTo"
+ mode="default"
+ >
<MarqueeText :key="key" :duration="marqueeDuration" :reverse="marqueeReverse">
- <span v-for="note in notes" :key="note.id" class="item">
- <img class="avatar" :src="note.user.avatarUrl" decoding="async"/>
- <MkA class="text" :to="notePage(note)">
- <Mfm class="text" :text="getNoteSummary(note)" :plain="true" :nowrap="true"/>
+ <span v-for="note in notes" :key="note.id" :class="$style.item">
+ <img :class="$style.avatar" :src="note.user.avatarUrl" decoding="async"/>
+ <MkA :class="$style.text" :to="notePage(note)">
+ <Mfm :text="getNoteSummary(note)" :plain="true" :nowrap="true"/>
</MkA>
- <span class="divider"></span>
+ <span :class="$style.divider"></span>
</span>
</MarqueeText>
</Transition>
@@ -60,54 +66,53 @@ useInterval(tick, Math.max(5000, props.refreshIntervalSec * 1000), {
});
</script>
-<style lang="scss" scoped>
-.change-enter-active, .change-leave-active {
+<style lang="scss" module>
+.transition_change_enterActive,
+.transition_change_leaveActive {
position: absolute;
top: 0;
transition: all 1s ease;
}
-.change-enter-from {
- opacity: 0;
+.transition_change_enterFrom {
+ opacity: 0;
transform: translateY(-100%);
}
-.change-leave-to {
- opacity: 0;
+.transition_change_leaveTo {
+ opacity: 0;
transform: translateY(100%);
}
-.osdsvwzy {
+.root {
display: inline-block;
position: relative;
+}
- ::v-deep(.item) {
- display: inline-flex;
- align-items: center;
- vertical-align: bottom;
- margin: 0;
+.item {
+ display: inline-flex;
+ align-items: center;
+ vertical-align: bottom;
+ margin: 0;
+}
- > .avatar {
- display: inline-block;
- height: var(--height);
- aspect-ratio: 1;
- vertical-align: bottom;
- margin-right: 8px;
- }
+.avatar {
+ display: inline-block;
+ height: var(--height);
+ aspect-ratio: 1;
+ vertical-align: bottom;
+ margin-right: 8px;
+}
- > .text {
- > .text {
- display: inline-block;
- vertical-align: bottom;
- }
- }
+.text {
+ display: inline-block;
+ vertical-align: bottom;
+}
- > .divider {
- display: inline-block;
- width: 0.5px;
- height: 16px;
- margin: 0 3em;
- background: currentColor;
- opacity: 0;
- }
- }
+.divider {
+ display: inline-block;
+ width: 0.5px;
+ height: 16px;
+ margin: 0 3em;
+ background: currentColor;
+ opacity: 0;
}
</style>
diff --git a/packages/frontend/src/ui/_common_/statusbars.vue b/packages/frontend/src/ui/_common_/statusbars.vue
index f84695c15f..3533972cdf 100644
--- a/packages/frontend/src/ui/_common_/statusbars.vue
+++ b/packages/frontend/src/ui/_common_/statusbars.vue
@@ -1,18 +1,17 @@
<template>
-<div class="dlrsnxqu">
+<div :class="$style.root">
<div
- v-for="x in defaultStore.reactiveState.statusbars.value" :key="x.id" class="item" :class="[{ black: x.black }, {
- verySmall: x.size === 'verySmall',
- small: x.size === 'small',
- medium: x.size === 'medium',
- large: x.size === 'large',
- veryLarge: x.size === 'veryLarge',
+ v-for="x in defaultStore.reactiveState.statusbars.value" :key="x.id" :class="[$style.item, { [$style.black]: x.black,
+ [$style.verySmall]: x.size === 'verySmall',
+ [$style.small]: x.size === 'small',
+ [$style.large]: x.size === 'large',
+ [$style.veryLarge]: x.size === 'veryLarge',
}]"
>
- <span class="name">{{ x.name }}</span>
- <XRss v-if="x.type === 'rss'" class="body" :refresh-interval-sec="x.props.refreshIntervalSec" :marquee-duration="x.props.marqueeDuration" :marquee-reverse="x.props.marqueeReverse" :display="x.props.display" :url="x.props.url" :shuffle="x.props.shuffle"/>
- <XFederation v-else-if="x.type === 'federation'" class="body" :refresh-interval-sec="x.props.refreshIntervalSec" :marquee-duration="x.props.marqueeDuration" :marquee-reverse="x.props.marqueeReverse" :display="x.props.display" :colored="x.props.colored"/>
- <XUserList v-else-if="x.type === 'userList'" class="body" :refresh-interval-sec="x.props.refreshIntervalSec" :marquee-duration="x.props.marqueeDuration" :marquee-reverse="x.props.marqueeReverse" :display="x.props.display" :user-list-id="x.props.userListId"/>
+ <span :class="$style.name">{{ x.name }}</span>
+ <XRss v-if="x.type === 'rss'" :class="$style.body" :refreshIntervalSec="x.props.refreshIntervalSec" :marqueeDuration="x.props.marqueeDuration" :marqueeReverse="x.props.marqueeReverse" :display="x.props.display" :url="x.props.url" :shuffle="x.props.shuffle"/>
+ <XFederation v-else-if="x.type === 'federation'" :class="$style.body" :refreshIntervalSec="x.props.refreshIntervalSec" :marqueeDuration="x.props.marqueeDuration" :marqueeReverse="x.props.marqueeReverse" :display="x.props.display" :colored="x.props.colored"/>
+ <XUserList v-else-if="x.type === 'userList'" :class="$style.body" :refreshIntervalSec="x.props.refreshIntervalSec" :marqueeDuration="x.props.marqueeDuration" :marqueeReverse="x.props.marqueeReverse" :display="x.props.display" :userListId="x.props.userListId"/>
</div>
</div>
</template>
@@ -25,67 +24,67 @@ const XFederation = defineAsyncComponent(() => import('./statusbar-federation.vu
const XUserList = defineAsyncComponent(() => import('./statusbar-user-list.vue'));
</script>
-<style lang="scss" scoped>
-.dlrsnxqu {
+<style lang="scss" module>
+.root {
font-size: 15px;
background: var(--panel);
+}
- > .item {
- --height: 24px;
- --nameMargin: 10px;
- font-size: 0.85em;
-
- &.verySmall {
- --nameMargin: 7px;
- --height: 16px;
- font-size: 0.75em;
- }
+.item {
+ --height: 24px;
+ --nameMargin: 10px;
+ font-size: 0.85em;
- &.small {
- --nameMargin: 8px;
- --height: 20px;
- font-size: 0.8em;
- }
+ &.verySmall {
+ --nameMargin: 7px;
+ --height: 16px;
+ font-size: 0.75em;
+ }
- &.large {
- --nameMargin: 12px;
- --height: 26px;
- font-size: 0.875em;
- }
+ &.small {
+ --nameMargin: 8px;
+ --height: 20px;
+ font-size: 0.8em;
+ }
- &.veryLarge {
- --nameMargin: 14px;
- --height: 30px;
- font-size: 0.9em;
- }
+ &.large {
+ --nameMargin: 12px;
+ --height: 26px;
+ font-size: 0.875em;
+ }
- display: flex;
- vertical-align: bottom;
- width: 100%;
- line-height: var(--height);
- height: var(--height);
- overflow: clip;
- contain: strict;
+ &.veryLarge {
+ --nameMargin: 14px;
+ --height: 30px;
+ font-size: 0.9em;
+ }
- > .name {
- padding: 0 var(--nameMargin);
- font-weight: bold;
- color: var(--accent);
+ display: flex;
+ vertical-align: bottom;
+ width: 100%;
+ line-height: var(--height);
+ height: var(--height);
+ overflow: clip;
+ contain: strict;
- &:empty {
- display: none;
- }
- }
+ &.black {
+ background: #000;
+ color: #fff;
+ }
+}
- > .body {
- min-width: 0;
- flex: 1;
- }
+.name {
+ padding: 0 var(--nameMargin);
+ font-weight: bold;
+ color: var(--accent);
- &.black {
- background: #000;
- color: #fff;
- }
+ &:empty {
+ display: none;
}
}
+
+.body {
+ min-width: 0;
+ flex: 1;
+}
</style>
diff --git a/packages/frontend/src/ui/_common_/stream-indicator.vue b/packages/frontend/src/ui/_common_/stream-indicator.vue
index 2a856e2a45..74c475fc7d 100644
--- a/packages/frontend/src/ui/_common_/stream-indicator.vue
+++ b/packages/frontend/src/ui/_common_/stream-indicator.vue
@@ -2,15 +2,15 @@
<div v-if="hasDisconnected && defaultStore.state.serverDisconnectedBehavior === 'quiet'" :class="$style.root" class="_panel _shadow" @click="resetDisconnected">
<div><i class="ti ti-alert-triangle"></i> {{ i18n.ts.disconnectedFromServer }}</div>
<div :class="$style.command" class="_buttons">
- <MkButton :class="$style.commandButton" small primary @click="reload">{{ i18n.ts.reload }}</MkButton>
- <MkButton :class="$style.commandButton" small>{{ i18n.ts.doNothing }}</MkButton>
+ <MkButton small primary @click="reload">{{ i18n.ts.reload }}</MkButton>
+ <MkButton small>{{ i18n.ts.doNothing }}</MkButton>
</div>
</div>
</template>
<script lang="ts" setup>
import { onUnmounted } from 'vue';
-import { stream } from '@/stream';
+import { useStream } from '@/stream';
import { i18n } from '@/i18n';
import MkButton from '@/components/MkButton.vue';
import * as os from '@/os';
@@ -32,10 +32,10 @@ function reload() {
location.reload();
}
-stream.on('_disconnected_', onDisconnected);
+useStream().on('_disconnected_', onDisconnected);
onUnmounted(() => {
- stream.off('_disconnected_', onDisconnected);
+ useStream().off('_disconnected_', onDisconnected);
});
</script>
@@ -54,7 +54,4 @@ onUnmounted(() => {
.command {
margin-top: 8px;
}
-
-.commandButton {
-}
</style>
diff --git a/packages/frontend/src/ui/classic.header.vue b/packages/frontend/src/ui/classic.header.vue
index daea775552..747d4edcb4 100644
--- a/packages/frontend/src/ui/classic.header.vue
+++ b/packages/frontend/src/ui/classic.header.vue
@@ -5,18 +5,18 @@
<button v-click-anime class="item _button instance" @click="openInstanceMenu">
<img :src="instance.iconUrl ?? instance.faviconUrl ?? '/favicon.ico'" class="_ghost"/>
</button>
- <MkA v-click-anime v-tooltip="i18n.ts.timeline" class="item index" active-class="active" to="/" exact>
+ <MkA v-click-anime v-tooltip="i18n.ts.timeline" class="item index" activeClass="active" to="/" exact>
<i class="ti ti-home ti-fw"></i>
</MkA>
<template v-for="item in menu">
<div v-if="item === '-'" class="divider"></div>
- <component :is="navbarItemDef[item].to ? 'MkA' : 'button'" v-else-if="navbarItemDef[item] && (navbarItemDef[item].show !== false)" v-click-anime v-tooltip="navbarItemDef[item].title" class="item _button" :class="item" active-class="active" :to="navbarItemDef[item].to" v-on="navbarItemDef[item].action ? { click: navbarItemDef[item].action } : {}">
+ <component :is="navbarItemDef[item].to ? 'MkA' : 'button'" v-else-if="navbarItemDef[item] && (navbarItemDef[item].show !== false)" v-click-anime v-tooltip="navbarItemDef[item].title" class="item _button" :class="item" activeClass="active" :to="navbarItemDef[item].to" v-on="navbarItemDef[item].action ? { click: navbarItemDef[item].action } : {}">
<i class="ti-fw" :class="navbarItemDef[item].icon"></i>
<span v-if="navbarItemDef[item].indicated" class="indicator"><i class="_indicatorCircle"></i></span>
</component>
</template>
<div class="divider"></div>
- <MkA v-if="$i.isAdmin || $i.isModerator" v-click-anime v-tooltip="i18n.ts.controlPanel" class="item" active-class="active" to="/admin" :behavior="settingsWindowed ? 'modalWindow' : null">
+ <MkA v-if="$i.isAdmin || $i.isModerator" v-click-anime v-tooltip="i18n.ts.controlPanel" class="item" activeClass="active" to="/admin" :behavior="settingsWindowed ? 'window' : null">
<i class="ti ti-dashboard ti-fw"></i>
</MkA>
<button v-click-anime class="item _button" @click="more">
@@ -25,7 +25,7 @@
</button>
</div>
<div class="right">
- <MkA v-click-anime v-tooltip="i18n.ts.settings" class="item" active-class="active" to="/settings" :behavior="settingsWindowed ? 'modalWindow' : null">
+ <MkA v-click-anime v-tooltip="i18n.ts.settings" class="item" activeClass="active" to="/settings" :behavior="settingsWindowed ? 'window' : null">
<i class="ti ti-settings ti-fw"></i>
</MkA>
<button v-click-anime class="item _button account" @click="openAccountMenu">
diff --git a/packages/frontend/src/ui/classic.sidebar.vue b/packages/frontend/src/ui/classic.sidebar.vue
index 73db14c65e..cb264fc3ba 100644
--- a/packages/frontend/src/ui/classic.sidebar.vue
+++ b/packages/frontend/src/ui/classic.sidebar.vue
@@ -9,25 +9,25 @@
</MkButton>
</div>
<div class="divider"></div>
- <MkA v-click-anime class="item index" active-class="active" to="/" exact>
+ <MkA v-click-anime class="item index" activeClass="active" to="/" exact>
<i class="ti ti-home ti-fw"></i><span class="text">{{ i18n.ts.timeline }}</span>
</MkA>
<template v-for="item in menu">
<div v-if="item === '-'" class="divider"></div>
- <component :is="navbarItemDef[item].to ? 'MkA' : 'button'" v-else-if="navbarItemDef[item] && (navbarItemDef[item].show !== false)" v-click-anime class="item _button" :class="item" active-class="active" :to="navbarItemDef[item].to" v-on="navbarItemDef[item].action ? { click: navbarItemDef[item].action } : {}">
+ <component :is="navbarItemDef[item].to ? 'MkA' : 'button'" v-else-if="navbarItemDef[item] && (navbarItemDef[item].show !== false)" v-click-anime class="item _button" :class="item" activeClass="active" :to="navbarItemDef[item].to" v-on="navbarItemDef[item].action ? { click: navbarItemDef[item].action } : {}">
<i class="ti-fw" :class="navbarItemDef[item].icon"></i><span class="text">{{ navbarItemDef[item].title }}</span>
<span v-if="navbarItemDef[item].indicated" class="indicator"><i class="_indicatorCircle"></i></span>
</component>
</template>
<div class="divider"></div>
- <MkA v-if="$i.isAdmin || $i.isModerator" v-click-anime class="item" active-class="active" to="/admin" :behavior="settingsWindowed ? 'modalWindow' : null">
+ <MkA v-if="$i.isAdmin || $i.isModerator" v-click-anime class="item" activeClass="active" to="/admin" :behavior="settingsWindowed ? 'window' : null">
<i class="ti ti-dashboard ti-fw"></i><span class="text">{{ i18n.ts.controlPanel }}</span>
</MkA>
<button v-click-anime class="item _button" @click="more">
<i class="ti ti-dots ti-fw"></i><span class="text">{{ i18n.ts.more }}</span>
<span v-if="otherNavItemIndicated" class="indicator"><i class="_indicatorCircle"></i></span>
</button>
- <MkA v-click-anime class="item" active-class="active" to="/settings" :behavior="settingsWindowed ? 'modalWindow' : null">
+ <MkA v-click-anime class="item" activeClass="active" to="/settings" :behavior="settingsWindowed ? 'window' : null">
<i class="ti ti-settings ti-fw"></i><span class="text">{{ i18n.ts.settings }}</span>
</MkA>
<div class="divider"></div>
diff --git a/packages/frontend/src/ui/classic.vue b/packages/frontend/src/ui/classic.vue
index 792c1ccc5e..d50f2b0454 100644
--- a/packages/frontend/src/ui/classic.vue
+++ b/packages/frontend/src/ui/classic.vue
@@ -7,17 +7,17 @@
<XSidebar/>
</div>
<div v-else ref="widgetsLeft" class="widgets left">
- <XWidgets place="left" :margin-top="'var(--margin)'" @mounted="attachSticky(widgetsLeft)"/>
+ <XWidgets place="left" :marginTop="'var(--margin)'" @mounted="attachSticky(widgetsLeft)"/>
</div>
- <main class="main" :style="{ background: pageMetadata?.value?.bg }" @contextmenu.stop="onContextmenu">
+ <main class="main" @contextmenu.stop="onContextmenu">
<div class="content" style="container-type: inline-size;">
<RouterView/>
</div>
</main>
<div v-if="isDesktop" ref="widgetsRight" class="widgets right">
- <XWidgets :place="showMenuOnTop ? 'right' : null" :margin-top="showMenuOnTop ? '0' : 'var(--margin)'" @mounted="attachSticky(widgetsRight)"/>
+ <XWidgets :place="showMenuOnTop ? 'right' : null" :marginTop="showMenuOnTop ? '0' : 'var(--margin)'" @mounted="attachSticky(widgetsRight)"/>
</div>
</div>
diff --git a/packages/frontend/src/ui/deck.vue b/packages/frontend/src/ui/deck.vue
index 33e752513b..c828731773 100644
--- a/packages/frontend/src/ui/deck.vue
+++ b/packages/frontend/src/ui/deck.vue
@@ -4,27 +4,23 @@
<div :class="$style.main">
<XStatusBars/>
- <div ref="columnsEl" :class="[$style.columns, deckStore.reactiveState.columnAlign.value, { [$style.snapScroll]: snapScroll }]" @contextmenu.self.prevent="onContextmenu">
- <template v-for="ids in layout">
- <!-- sectionを利用しているのは、deck.vue側でcolumnに対してfirst-of-typeを効かせるため -->
- <section
- v-if="ids.length > 1"
- :class="$style.folder"
- :style="columns.filter(c => ids.includes(c.id)).some(c => c.flexible) ? { flex: 1, minWidth: '350px' } : { width: Math.max(...columns.filter(c => ids.includes(c.id)).map(c => c.width)) + 'px' }"
- >
- <DeckColumnCore v-for="id in ids" :ref="id" :key="id" :column="columns.find(c => c.id === id)" :is-stacked="true" @parent-focus="moveFocus(id, $event)"/>
- </section>
- <DeckColumnCore
- v-else
- :ref="ids[0]"
- :key="ids[0]"
+ <div ref="columnsEl" :class="[$style.sections, { [$style.center]: deckStore.reactiveState.columnAlign.value === 'center', [$style.snapScroll]: snapScroll }]" @contextmenu.self.prevent="onContextmenu">
+ <!-- sectionを利用しているのは、deck.vue側でcolumnに対してfirst-of-typeを効かせるため -->
+ <section
+ v-for="ids in layout"
+ :class="$style.section"
+ :style="columns.filter(c => ids.includes(c.id)).some(c => c.flexible) ? { flex: 1, minWidth: '350px' } : { width: Math.max(...columns.filter(c => ids.includes(c.id)).map(c => c.width)) + 'px' }"
+ >
+ <component
+ :is="columnComponents[columns.find(c => c.id === id)!.type] ?? XTlColumn"
+ v-for="id in ids"
+ :ref="id"
+ :key="id"
:class="$style.column"
- :column="columns.find(c => c.id === ids[0])"
- :is-stacked="false"
- :style="columns.find(c => c.id === ids[0])!.flexible ? { flex: 1, minWidth: '350px' } : { width: columns.find(c => c.id === ids[0])!.width + 'px' }"
- @parent-focus="moveFocus(ids[0], $event)"
+ :column="columns.find(c => c.id === id)"
+ :isStacked="ids.length > 1"
/>
- </template>
+ </section>
<div v-if="layout.length === 0" class="_panel" :class="$style.onboarding">
<div>{{ i18n.ts._deck.introduction }}</div>
<MkButton primary style="margin: 1em auto;" @click="addColumn">{{ i18n.ts._deck.addColumn }}</MkButton>
@@ -53,10 +49,10 @@
</div>
<Transition
- :enter-active-class="defaultStore.state.animation ? $style.transition_menuDrawerBg_enterActive : ''"
- :leave-active-class="defaultStore.state.animation ? $style.transition_menuDrawerBg_leaveActive : ''"
- :enter-from-class="defaultStore.state.animation ? $style.transition_menuDrawerBg_enterFrom : ''"
- :leave-to-class="defaultStore.state.animation ? $style.transition_menuDrawerBg_leaveTo : ''"
+ :enterActiveClass="defaultStore.state.animation ? $style.transition_menuDrawerBg_enterActive : ''"
+ :leaveActiveClass="defaultStore.state.animation ? $style.transition_menuDrawerBg_leaveActive : ''"
+ :enterFromClass="defaultStore.state.animation ? $style.transition_menuDrawerBg_enterFrom : ''"
+ :leaveToClass="defaultStore.state.animation ? $style.transition_menuDrawerBg_leaveTo : ''"
>
<div
v-if="drawerMenuShowing"
@@ -68,10 +64,10 @@
</Transition>
<Transition
- :enter-active-class="defaultStore.state.animation ? $style.transition_menuDrawer_enterActive : ''"
- :leave-active-class="defaultStore.state.animation ? $style.transition_menuDrawer_leaveActive : ''"
- :enter-from-class="defaultStore.state.animation ? $style.transition_menuDrawer_enterFrom : ''"
- :leave-to-class="defaultStore.state.animation ? $style.transition_menuDrawer_leaveTo : ''"
+ :enterActiveClass="defaultStore.state.animation ? $style.transition_menuDrawer_enterActive : ''"
+ :leaveActiveClass="defaultStore.state.animation ? $style.transition_menuDrawer_leaveActive : ''"
+ :enterFromClass="defaultStore.state.animation ? $style.transition_menuDrawer_enterFrom : ''"
+ :leaveToClass="defaultStore.state.animation ? $style.transition_menuDrawer_leaveTo : ''"
>
<div v-if="drawerMenuShowing" :class="$style.menu">
<XDrawerMenu/>
@@ -87,7 +83,6 @@ import { computed, defineAsyncComponent, ref, watch } from 'vue';
import { v4 as uuid } from 'uuid';
import XCommon from './_common_/common.vue';
import { deckStore, addColumn as addColumnToStore, loadDeck, getProfiles, deleteProfile as deleteProfile_ } from './deck/deck-store';
-import DeckColumnCore from '@/ui/deck/column-core.vue';
import XSidebar from '@/ui/_common_/navbar.vue';
import XDrawerMenu from '@/ui/_common_/navbar-for-mobile.vue';
import MkButton from '@/components/MkButton.vue';
@@ -100,8 +95,31 @@ import { mainRouter } from '@/router';
import { unisonReload } from '@/scripts/unison-reload';
import { deviceKind } from '@/scripts/device-kind';
import { defaultStore } from '@/store';
+import XMainColumn from '@/ui/deck/main-column.vue';
+import XTlColumn from '@/ui/deck/tl-column.vue';
+import XAntennaColumn from '@/ui/deck/antenna-column.vue';
+import XListColumn from '@/ui/deck/list-column.vue';
+import XChannelColumn from '@/ui/deck/channel-column.vue';
+import XNotificationsColumn from '@/ui/deck/notifications-column.vue';
+import XWidgetsColumn from '@/ui/deck/widgets-column.vue';
+import XMentionsColumn from '@/ui/deck/mentions-column.vue';
+import XDirectColumn from '@/ui/deck/direct-column.vue';
+import XRoleTimelineColumn from '@/ui/deck/role-timeline-column.vue';
const XStatusBars = defineAsyncComponent(() => import('@/ui/_common_/statusbars.vue'));
+const columnComponents = {
+ main: XMainColumn,
+ widgets: XWidgetsColumn,
+ notifications: XNotificationsColumn,
+ tl: XTlColumn,
+ list: XListColumn,
+ channel: XChannelColumn,
+ antenna: XAntennaColumn,
+ mentions: XMentionsColumn,
+ direct: XDirectColumn,
+ roleTimeline: XRoleTimelineColumn,
+};
+
mainRouter.navHook = (path, flag): boolean => {
if (flag === 'forcePage') return false;
const noMainColumn = !deckStore.state.columns.some(x => x.type === 'main');
@@ -187,11 +205,8 @@ window.addEventListener('wheel', (ev) => {
columnsEl.scrollLeft += ev.deltaY;
}
});
-loadDeck();
-function moveFocus(id: string, direction: 'up' | 'down' | 'left' | 'right') {
- // TODO??
-}
+loadDeck();
function changeProfile(ev: MouseEvent) {
const items = ref([{
@@ -267,7 +282,7 @@ async function deleteProfile() {
--margin: var(--marginHalf);
- --deckDividerThickness: 5px;
+ --columnGap: 6px;
display: flex;
height: 100dvh;
@@ -286,19 +301,21 @@ async function deleteProfile() {
flex-direction: column;
}
-.columns {
+.sections {
flex: 1;
display: flex;
overflow-x: auto;
overflow-y: clip;
+ overscroll-behavior: contain;
+ background: var(--deckBg);
&.center {
- > .column:first-of-type {
- margin-left: auto;
+ > .section:first-of-type {
+ margin-left: auto !important;
}
- > .column:last-of-type {
- margin-right: auto;
+ > .section:last-of-type {
+ margin-right: auto !important;
}
}
@@ -307,23 +324,17 @@ async function deleteProfile() {
}
}
-.column {
- scroll-snap-align: start;
- flex-shrink: 0;
- border-right: solid var(--deckDividerThickness) var(--deckDivider);
-
- &:first-of-type {
- border-left: solid var(--deckDividerThickness) var(--deckDivider);
- }
-}
-
-.folder {
- composes: column;
+.section {
display: flex;
flex-direction: column;
+ scroll-snap-align: start;
+ flex-shrink: 0;
+ padding-top: var(--columnGap);
+ padding-bottom: var(--columnGap);
+ padding-left: var(--columnGap);
- > *:not(:last-of-type) {
- border-bottom: solid var(--deckDividerThickness) var(--deckDivider);
+ > .column:not(:last-of-type) {
+ margin-bottom: var(--columnGap);
}
}
diff --git a/packages/frontend/src/ui/deck/antenna-column.vue b/packages/frontend/src/ui/deck/antenna-column.vue
index 76a8b6e760..d21a9cc580 100644
--- a/packages/frontend/src/ui/deck/antenna-column.vue
+++ b/packages/frontend/src/ui/deck/antenna-column.vue
@@ -1,10 +1,10 @@
<template>
-<XColumn :menu="menu" :column="column" :is-stacked="isStacked" @parent-focus="$event => emit('parent-focus', $event)">
+<XColumn :menu="menu" :column="column" :isStacked="isStacked">
<template #header>
<i class="ti ti-antenna"></i><span style="margin-left: 8px;">{{ column.name }}</span>
</template>
- <MkTimeline v-if="column.antennaId" ref="timeline" src="antenna" :antenna="column.antennaId" @after="() => emit('loaded')"/>
+ <MkTimeline v-if="column.antennaId" ref="timeline" src="antenna" :antenna="column.antennaId"/>
</XColumn>
</template>
@@ -21,11 +21,6 @@ const props = defineProps<{
isStacked: boolean;
}>();
-const emit = defineEmits<{
- (ev: 'loaded'): void;
- (ev: 'parent-focus', direction: 'up' | 'down' | 'left' | 'right'): void;
-}>();
-
let timeline = $shallowRef<InstanceType<typeof MkTimeline>>();
onMounted(() => {
diff --git a/packages/frontend/src/ui/deck/channel-column.vue b/packages/frontend/src/ui/deck/channel-column.vue
index 9605d1b22e..8b05ecc0bb 100644
--- a/packages/frontend/src/ui/deck/channel-column.vue
+++ b/packages/frontend/src/ui/deck/channel-column.vue
@@ -1,5 +1,5 @@
<template>
-<XColumn :menu="menu" :column="column" :is-stacked="isStacked" @parent-focus="$event => emit('parent-focus', $event)">
+<XColumn :menu="menu" :column="column" :isStacked="isStacked">
<template #header>
<i class="ti ti-device-tv"></i><span style="margin-left: 8px;">{{ column.name }}</span>
</template>
@@ -8,30 +8,25 @@
<div style="padding: 8px; text-align: center;">
<MkButton primary gradate rounded inline @click="post"><i class="ti ti-pencil"></i></MkButton>
</div>
- <MkTimeline ref="timeline" src="channel" :channel="column.channelId" @after="() => emit('loaded')"/>
+ <MkTimeline ref="timeline" src="channel" :channel="column.channelId"/>
</template>
</XColumn>
</template>
<script lang="ts" setup>
+import * as misskey from 'misskey-js';
import XColumn from './column.vue';
import { updateColumn, Column } from './deck-store';
import MkTimeline from '@/components/MkTimeline.vue';
import MkButton from '@/components/MkButton.vue';
import * as os from '@/os';
import { i18n } from '@/i18n';
-import * as misskey from 'misskey-js';
const props = defineProps<{
column: Column;
isStacked: boolean;
}>();
-const emit = defineEmits<{
- (ev: 'loaded'): void;
- (ev: 'parent-focus', direction: 'up' | 'down' | 'left' | 'right'): void;
-}>();
-
let timeline = $shallowRef<InstanceType<typeof MkTimeline>>();
let channel = $shallowRef<misskey.entities.Channel>();
diff --git a/packages/frontend/src/ui/deck/column-core.vue b/packages/frontend/src/ui/deck/column-core.vue
deleted file mode 100644
index 8e7addf359..0000000000
--- a/packages/frontend/src/ui/deck/column-core.vue
+++ /dev/null
@@ -1,38 +0,0 @@
-<template>
-<!-- TODO: リファクタの余地がありそう -->
-<div v-if="!column">たぶん見えちゃいけないやつ</div>
-<XMainColumn v-else-if="column.type === 'main'" :column="column" :is-stacked="isStacked" @parent-focus="emit('parent-focus', $event)"/>
-<XWidgetsColumn v-else-if="column.type === 'widgets'" :column="column" :is-stacked="isStacked" @parent-focus="emit('parent-focus', $event)"/>
-<XNotificationsColumn v-else-if="column.type === 'notifications'" :column="column" :is-stacked="isStacked" @parent-focus="emit('parent-focus', $event)"/>
-<XTlColumn v-else-if="column.type === 'tl'" :column="column" :is-stacked="isStacked" @parent-focus="emit('parent-focus', $event)"/>
-<XListColumn v-else-if="column.type === 'list'" :column="column" :is-stacked="isStacked" @parent-focus="emit('parent-focus', $event)"/>
-<XChannelColumn v-else-if="column.type === 'channel'" :column="column" :is-stacked="isStacked" @parent-focus="emit('parent-focus', $event)"/>
-<XAntennaColumn v-else-if="column.type === 'antenna'" :column="column" :is-stacked="isStacked" @parent-focus="emit('parent-focus', $event)"/>
-<XMentionsColumn v-else-if="column.type === 'mentions'" :column="column" :is-stacked="isStacked" @parent-focus="emit('parent-focus', $event)"/>
-<XDirectColumn v-else-if="column.type === 'direct'" :column="column" :is-stacked="isStacked" @parent-focus="emit('parent-focus', $event)"/>
-<XRoleTimelineColumn v-else-if="column.type === 'roleTimeline'" :column="column" :is-stacked="isStacked" @parent-focus="emit('parent-focus', $event)"/>
-</template>
-
-<script lang="ts" setup>
-import { } from 'vue';
-import XMainColumn from './main-column.vue';
-import XTlColumn from './tl-column.vue';
-import XAntennaColumn from './antenna-column.vue';
-import XListColumn from './list-column.vue';
-import XChannelColumn from './channel-column.vue';
-import XNotificationsColumn from './notifications-column.vue';
-import XWidgetsColumn from './widgets-column.vue';
-import XMentionsColumn from './mentions-column.vue';
-import XDirectColumn from './direct-column.vue';
-import XRoleTimelineColumn from './role-timeline-column.vue';
-import { Column } from './deck-store';
-
-defineProps<{
- column?: Column;
- isStacked: boolean;
-}>();
-
-const emit = defineEmits<{
- (ev: 'parent-focus', direction: 'up' | 'down' | 'left' | 'right'): void;
-}>();
-</script>
diff --git a/packages/frontend/src/ui/deck/column.vue b/packages/frontend/src/ui/deck/column.vue
index 402bbe0352..c376eb2b47 100644
--- a/packages/frontend/src/ui/deck/column.vue
+++ b/packages/frontend/src/ui/deck/column.vue
@@ -1,8 +1,6 @@
<template>
-<!-- sectionを利用しているのは、deck.vue側でcolumnに対してfirst-of-typeを効かせるため -->
-<section
- v-hotkey="keymap"
- :class="[$style.root, { [$style.paged]: isMainColumn, [$style.naked]: naked, [$style.active]: active, [$style.isStacked]: isStacked, [$style.draghover]: draghover, [$style.dragging]: dragging, [$style.dropready]: dropready }]"
+<div
+ :class="[$style.root, { [$style.paged]: isMainColumn, [$style.naked]: naked, [$style.active]: active, [$style.draghover]: draghover, [$style.dragging]: dragging, [$style.dropready]: dropready }]"
@dragover.prevent.stop="onDragover"
@dragleave="onDragleave"
@drop.prevent.stop="onDrop"
@@ -15,17 +13,26 @@
@dragend="onDragend"
@contextmenu.prevent.stop="onContextmenu"
>
+ <svg viewBox="0 0 256 128" :class="$style.tabShape">
+ <g transform="matrix(6.2431,0,0,6.2431,-677.417,-29.3839)">
+ <path d="M149.512,4.707L108.507,4.707C116.252,4.719 118.758,14.958 118.758,14.958C118.758,14.958 121.381,25.283 129.009,25.209L149.512,25.209L149.512,4.707Z" style="fill:var(--deckBg);"/>
+ </g>
+ </svg>
+ <div :class="$style.color"></div>
<button v-if="isStacked && !isMainColumn" :class="$style.toggleActive" class="_button" @click="toggleActive">
<template v-if="active"><i class="ti ti-chevron-up"></i></template>
<template v-else><i class="ti ti-chevron-down"></i></template>
</button>
<span :class="$style.title"><slot name="header"></slot></span>
+ <svg viewBox="0 0 16 16" version="1.1" :class="$style.grabber">
+ <path fill="currentColor" d="M10 13a1 1 0 1 1 0-2 1 1 0 0 1 0 2Zm0-4a1 1 0 1 1 0-2 1 1 0 0 1 0 2Zm-4 4a1 1 0 1 1 0-2 1 1 0 0 1 0 2Zm5-9a1 1 0 1 1-2 0 1 1 0 0 1 2 0ZM7 8a1 1 0 1 1-2 0 1 1 0 0 1 2 0ZM6 5a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z"></path>
+ </svg>
<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" :class="$style.body">
+ <div v-if="active" ref="body" :class="$style.body">
<slot></slot>
</div>
-</section>
+</div>
</template>
<script lang="ts" setup>
@@ -49,12 +56,7 @@ const props = withDefaults(defineProps<{
naked: false,
});
-const emit = defineEmits<{
- (ev: 'parent-focus', direction: 'up' | 'down' | 'left' | 'right'): void;
- (ev: 'change-active-state', v: boolean): void;
-}>();
-
-let body = $shallowRef<HTMLDivElement>();
+let body = $shallowRef<HTMLDivElement | null>();
let dragging = $ref(false);
watch($$(dragging), v => os.deckGlobalEvents.emit(v ? 'column.dragStart' : 'column.dragEnd'));
@@ -64,14 +66,6 @@ let dropready = $ref(false);
const isMainColumn = $computed(() => props.column.type === 'main');
const active = $computed(() => props.column.active !== false);
-watch($$(active), v => emit('change-active-state', v));
-
-const keymap = $computed(() => ({
- 'shift+up': () => emit('parent-focus', 'up'),
- 'shift+down': () => emit('parent-focus', 'down'),
- 'shift+left': () => emit('parent-focus', 'left'),
- 'shift+right': () => emit('parent-focus', 'right'),
-}));
onMounted(() => {
os.deckGlobalEvents.on('column.dragStart', onOtherDragStart);
@@ -190,10 +184,12 @@ function onContextmenu(ev: MouseEvent) {
}
function goTop() {
- body.scrollTo({
- top: 0,
- behavior: 'smooth',
- });
+ if (body) {
+ body.scrollTo({
+ top: 0,
+ behavior: 'smooth',
+ });
+ }
}
function onDragstart(ev) {
@@ -248,6 +244,7 @@ function onDrop(ev) {
height: 100%;
overflow: clip;
contain: strict;
+ border-radius: 10px;
&.draghover {
&:after {
@@ -287,6 +284,7 @@ function onDrop(ev) {
&:not(.active) {
flex-basis: var(--deckColumnHeaderHeight);
min-height: var(--deckColumnHeaderHeight);
+ border-bottom-right-radius: 0;
}
&.naked {
@@ -299,10 +297,28 @@ function onDrop(ev) {
box-shadow: none;
color: var(--fg);
}
+
+ > .body {
+ background: transparent !important;
+
+ &::-webkit-scrollbar-track {
+ background: transparent;
+ }
+ scrollbar-color: var(--scrollbarHandle) transparent;
+ }
}
&.paged {
background: var(--bg) !important;
+
+ > .body {
+ background: var(--bg) !important;
+
+ &::-webkit-scrollbar-track {
+ background: inherit;
+ }
+ scrollbar-color: var(--scrollbarHandle) transparent;
+ }
}
}
@@ -312,7 +328,7 @@ function onDrop(ev) {
z-index: 2;
line-height: var(--deckColumnHeaderHeight);
height: var(--deckColumnHeaderHeight);
- padding: 0 16px;
+ padding: 0 16px 0 30px;
font-size: 0.9em;
color: var(--panelHeaderFg);
background: var(--panelHeaderBg);
@@ -321,6 +337,24 @@ function onDrop(ev) {
user-select: none;
}
+.color {
+ position: absolute;
+ top: 12px;
+ left: 12px;
+ width: 3px;
+ height: calc(100% - 24px);
+ background: var(--accent);
+ border-radius: 999px;
+}
+
+.tabShape {
+ position: absolute;
+ top: 0;
+ right: -8px;
+ width: auto;
+ height: calc(100% - 6px);
+}
+
.title {
display: inline-block;
align-items: center;
@@ -335,34 +369,39 @@ function onDrop(ev) {
z-index: 1;
width: var(--deckColumnHeaderHeight);
line-height: var(--deckColumnHeaderHeight);
- color: var(--faceTextButton);
-
- &:hover {
- color: var(--faceTextButtonHover);
- }
-
- &:active {
- color: var(--faceTextButtonActive);
- }
}
.toggleActive {
margin-left: -16px;
}
-.menu {
+.grabber {
margin-left: auto;
+ margin-right: 10px;
+ padding: 8px 8px;
+ box-sizing: border-box;
+ height: var(--deckColumnHeaderHeight);
+ cursor: move;
+ user-select: none;
+ opacity: 0.5;
+}
+
+.menu {
margin-right: -16px;
}
.body {
height: calc(100% - var(--deckColumnHeaderHeight));
overflow-y: auto;
- overflow-x: hidden; // Safari does not supports clip
overflow-x: clip;
- -webkit-overflow-scrolling: touch;
+ overscroll-behavior-y: contain;
box-sizing: border-box;
container-type: size;
background-color: var(--bg);
+
+ &::-webkit-scrollbar-track {
+ background: var(--panel);
+ }
+ scrollbar-color: var(--scrollbarHandle) var(--panel);
}
</style>
diff --git a/packages/frontend/src/ui/deck/direct-column.vue b/packages/frontend/src/ui/deck/direct-column.vue
index 15b76c4d92..dc3f58e6a4 100644
--- a/packages/frontend/src/ui/deck/direct-column.vue
+++ b/packages/frontend/src/ui/deck/direct-column.vue
@@ -1,5 +1,5 @@
<template>
-<XColumn :column="column" :is-stacked="isStacked" @parent-focus="$event => emit('parent-focus', $event)">
+<XColumn :column="column" :isStacked="isStacked">
<template #header><i class="ti ti-mail" style="margin-right: 8px;"></i>{{ column.name }}</template>
<MkNotes :pagination="pagination"/>
@@ -17,10 +17,6 @@ defineProps<{
isStacked: boolean;
}>();
-const emit = defineEmits<{
- (ev: 'parent-focus', direction: 'up' | 'down' | 'left' | 'right'): void;
-}>();
-
const pagination = {
endpoint: 'notes/mentions' as const,
limit: 10,
diff --git a/packages/frontend/src/ui/deck/list-column.vue b/packages/frontend/src/ui/deck/list-column.vue
index 352c1d246a..f36dc6151c 100644
--- a/packages/frontend/src/ui/deck/list-column.vue
+++ b/packages/frontend/src/ui/deck/list-column.vue
@@ -1,10 +1,10 @@
<template>
-<XColumn :menu="menu" :column="column" :is-stacked="isStacked" @parent-focus="$event => emit('parent-focus', $event)">
+<XColumn :menu="menu" :column="column" :isStacked="isStacked">
<template #header>
<i class="ti ti-list"></i><span style="margin-left: 8px;">{{ column.name }}</span>
</template>
- <MkTimeline v-if="column.listId" ref="timeline" src="list" :list="column.listId" @after="() => emit('loaded')"/>
+ <MkTimeline v-if="column.listId" ref="timeline" src="list" :list="column.listId"/>
</XColumn>
</template>
@@ -21,11 +21,6 @@ const props = defineProps<{
isStacked: boolean;
}>();
-const emit = defineEmits<{
- (ev: 'loaded'): void;
- (ev: 'parent-focus', direction: 'up' | 'down' | 'left' | 'right'): void;
-}>();
-
let timeline = $shallowRef<InstanceType<typeof MkTimeline>>();
if (props.column.listId == null) {
diff --git a/packages/frontend/src/ui/deck/main-column.vue b/packages/frontend/src/ui/deck/main-column.vue
index f3826a8d31..169fac70a2 100644
--- a/packages/frontend/src/ui/deck/main-column.vue
+++ b/packages/frontend/src/ui/deck/main-column.vue
@@ -1,5 +1,5 @@
<template>
-<XColumn v-if="deckStore.state.alwaysShowMainColumn || mainRouter.currentRoute.value.name !== 'index'" :column="column" :is-stacked="isStacked" @parent-focus="$event => emit('parent-focus', $event)">
+<XColumn v-if="deckStore.state.alwaysShowMainColumn || mainRouter.currentRoute.value.name !== 'index'" :column="column" :isStacked="isStacked">
<template #header>
<template v-if="pageMetadata?.value">
<i :class="pageMetadata?.value.icon"></i>
@@ -25,10 +25,6 @@ defineProps<{
isStacked: boolean;
}>();
-const emit = defineEmits<{
- (ev: 'parent-focus', direction: 'up' | 'down' | 'left' | 'right'): void;
-}>();
-
let pageMetadata = $ref<null | ComputedRef<PageMetadata>>();
provide('router', mainRouter);
diff --git a/packages/frontend/src/ui/deck/mentions-column.vue b/packages/frontend/src/ui/deck/mentions-column.vue
index 852d7a8f7e..98cf898749 100644
--- a/packages/frontend/src/ui/deck/mentions-column.vue
+++ b/packages/frontend/src/ui/deck/mentions-column.vue
@@ -1,5 +1,5 @@
<template>
-<XColumn :column="column" :is-stacked="isStacked" @parent-focus="$event => emit('parent-focus', $event)">
+<XColumn :column="column" :isStacked="isStacked">
<template #header><i class="ti ti-at" style="margin-right: 8px;"></i>{{ column.name }}</template>
<MkNotes :pagination="pagination"/>
@@ -17,10 +17,6 @@ defineProps<{
isStacked: boolean;
}>();
-const emit = defineEmits<{
- (ev: 'parent-focus', direction: 'up' | 'down' | 'left' | 'right'): void;
-}>();
-
const pagination = {
endpoint: 'notes/mentions' as const,
limit: 10,
diff --git a/packages/frontend/src/ui/deck/notifications-column.vue b/packages/frontend/src/ui/deck/notifications-column.vue
index 9d133035fe..8cf6ec1f65 100644
--- a/packages/frontend/src/ui/deck/notifications-column.vue
+++ b/packages/frontend/src/ui/deck/notifications-column.vue
@@ -1,8 +1,8 @@
<template>
-<XColumn :column="column" :is-stacked="isStacked" :menu="menu" @parent-focus="$event => emit('parent-focus', $event)">
+<XColumn :column="column" :isStacked="isStacked" :menu="menu">
<template #header><i class="ti ti-bell" style="margin-right: 8px;"></i>{{ column.name }}</template>
- <XNotifications :include-types="column.includingTypes"/>
+ <XNotifications :includeTypes="column.includingTypes"/>
</XColumn>
</template>
@@ -19,10 +19,6 @@ const props = defineProps<{
isStacked: boolean;
}>();
-const emit = defineEmits<{
- (ev: 'parent-focus', direction: 'up' | 'down' | 'left' | 'right'): void;
-}>();
-
function func() {
os.popup(defineAsyncComponent(() => import('@/components/MkNotificationSettingWindow.vue')), {
includingTypes: props.column.includingTypes,
diff --git a/packages/frontend/src/ui/deck/role-timeline-column.vue b/packages/frontend/src/ui/deck/role-timeline-column.vue
index 5783b3f071..a0b7f1c675 100644
--- a/packages/frontend/src/ui/deck/role-timeline-column.vue
+++ b/packages/frontend/src/ui/deck/role-timeline-column.vue
@@ -1,10 +1,10 @@
<template>
-<XColumn :menu="menu" :column="column" :is-stacked="isStacked" @parent-focus="$event => emit('parent-focus', $event)">
+<XColumn :menu="menu" :column="column" :isStacked="isStacked">
<template #header>
<i class="ti ti-badge"></i><span style="margin-left: 8px;">{{ column.name }}</span>
</template>
- <MkTimeline v-if="column.roleId" ref="timeline" src="role" :role="column.roleId" @after="() => emit('loaded')"/>
+ <MkTimeline v-if="column.roleId" ref="timeline" src="role" :role="column.roleId"/>
</XColumn>
</template>
@@ -21,11 +21,6 @@ const props = defineProps<{
isStacked: boolean;
}>();
-const emit = defineEmits<{
- (ev: 'loaded'): void;
- (ev: 'parent-focus', direction: 'up' | 'down' | 'left' | 'right'): void;
-}>();
-
let timeline = $shallowRef<InstanceType<typeof MkTimeline>>();
onMounted(() => {
@@ -35,7 +30,7 @@ onMounted(() => {
});
async function setRole() {
- const roles = await os.api('roles/list');
+ const roles = (await os.api('roles/list')).filter(x => x.isExplorable);
const { canceled, result: role } = await os.select({
title: i18n.ts.role,
items: roles.map(x => ({
diff --git a/packages/frontend/src/ui/deck/tl-column.vue b/packages/frontend/src/ui/deck/tl-column.vue
index c23943d4db..4844ad11ff 100644
--- a/packages/frontend/src/ui/deck/tl-column.vue
+++ b/packages/frontend/src/ui/deck/tl-column.vue
@@ -1,5 +1,5 @@
<template>
-<XColumn :menu="menu" :column="column" :is-stacked="isStacked" @parent-focus="$event => emit('parent-focus', $event)">
+<XColumn :menu="menu" :column="column" :isStacked="isStacked">
<template #header>
<i v-if="column.tl === 'home'" class="ti ti-home"></i>
<i v-else-if="column.tl === 'local'" class="ti ti-planet"></i>
@@ -15,7 +15,7 @@
</p>
<p :class="$style.disabledDescription">{{ i18n.ts._disabledTimeline.description }}</p>
</div>
- <MkTimeline v-else-if="column.tl" ref="timeline" :key="column.tl" :src="column.tl" @after="() => emit('loaded')"/>
+ <MkTimeline v-else-if="column.tl" ref="timeline" :key="column.tl" :src="column.tl"/>
</XColumn>
</template>
@@ -34,11 +34,6 @@ const props = defineProps<{
isStacked: boolean;
}>();
-const emit = defineEmits<{
- (ev: 'loaded'): void;
- (ev: 'parent-focus', direction: 'up' | 'down' | 'left' | 'right'): void;
-}>();
-
let disabled = $ref(false);
const isLocalTimelineAvailable = (($i == null && instance.policies.ltlAvailable) || ($i != null && $i.policies.ltlAvailable));
diff --git a/packages/frontend/src/ui/deck/widgets-column.vue b/packages/frontend/src/ui/deck/widgets-column.vue
index 3b5b727991..da14e54f74 100644
--- a/packages/frontend/src/ui/deck/widgets-column.vue
+++ b/packages/frontend/src/ui/deck/widgets-column.vue
@@ -1,10 +1,10 @@
<template>
-<XColumn :menu="menu" :naked="true" :column="column" :is-stacked="isStacked" @parent-focus="$event => emit('parent-focus', $event)">
+<XColumn :menu="menu" :naked="true" :column="column" :isStacked="isStacked">
<template #header><i class="ti ti-apps" style="margin-right: 8px;"></i>{{ column.name }}</template>
<div :class="$style.root">
<div v-if="!(column.widgets && column.widgets.length > 0) && !edit" :class="$style.intro">{{ i18n.ts._deck.widgetsIntroduction }}</div>
- <XWidgets :edit="edit" :widgets="column.widgets ?? []" @add-widget="addWidget" @remove-widget="removeWidget" @update-widget="updateWidget" @update-widgets="updateWidgets" @exit="edit = false"/>
+ <XWidgets :edit="edit" :widgets="column.widgets ?? []" @addWidget="addWidget" @removeWidget="removeWidget" @updateWidget="updateWidget" @updateWidgets="updateWidgets" @exit="edit = false"/>
</div>
</XColumn>
</template>
@@ -21,10 +21,6 @@ const props = defineProps<{
isStacked: boolean;
}>();
-const emit = defineEmits<{
- (ev: 'parent-focus', direction: 'up' | 'down' | 'left' | 'right'): void;
-}>();
-
let edit = $ref(false);
function addWidget(widget) {
diff --git a/packages/frontend/src/ui/minimum.vue b/packages/frontend/src/ui/minimum.vue
new file mode 100644
index 0000000000..e656f00bb2
--- /dev/null
+++ b/packages/frontend/src/ui/minimum.vue
@@ -0,0 +1,34 @@
+<template>
+<div :class="$style.root" style="container-type: inline-size;">
+ <RouterView/>
+
+ <XCommon/>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { provide, ComputedRef } from 'vue';
+import XCommon from './_common_/common.vue';
+import { mainRouter } from '@/router';
+import { PageMetadata, provideMetadataReceiver } from '@/scripts/page-metadata';
+import { instanceName } from '@/config';
+
+let pageMetadata = $ref<null | ComputedRef<PageMetadata>>();
+
+provide('router', mainRouter);
+provideMetadataReceiver((info) => {
+ pageMetadata = info;
+ if (pageMetadata.value) {
+ document.title = `${pageMetadata.value.title} | ${instanceName}`;
+ }
+});
+
+document.documentElement.style.overflowY = 'scroll';
+</script>
+
+<style lang="scss" module>
+.root {
+ min-height: 100dvh;
+ box-sizing: border-box;
+}
+</style>
diff --git a/packages/frontend/src/ui/universal.vue b/packages/frontend/src/ui/universal.vue
index 27d0c26ac4..c0da59a57d 100644
--- a/packages/frontend/src/ui/universal.vue
+++ b/packages/frontend/src/ui/universal.vue
@@ -1,19 +1,15 @@
<template>
-<div :class="[$style.root, { [$style.withWallpaper]: wallpaper }]">
+<div :class="$style.root">
<XSidebar v-if="!isMobile" :class="$style.sidebar"/>
- <MkStickyContainer :class="$style.contents">
+ <MkStickyContainer ref="contents" :class="$style.contents" style="container-type: inline-size;" @contextmenu.stop="onContextmenu">
<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;">
- <RouterView/>
- </div>
- <div :class="$style.spacer"></div>
- </main>
+ <RouterView/>
+ <div :class="$style.spacer"></div>
</MkStickyContainer>
- <div v-if="isDesktop" ref="widgetsEl" :class="$style.widgets">
- <XWidgets :margin-top="'var(--margin)'" @mounted="attachSticky"/>
+ <div v-if="isDesktop" :class="$style.widgets">
+ <XWidgets/>
</div>
<button v-if="!isDesktop && !isMobile" :class="$style.widgetButton" class="_button" @click="widgetsShowing = true"><i class="ti ti-apps"></i></button>
@@ -27,10 +23,10 @@
</div>
<Transition
- :enter-active-class="defaultStore.state.animation ? $style.transition_menuDrawerBg_enterActive : ''"
- :leave-active-class="defaultStore.state.animation ? $style.transition_menuDrawerBg_leaveActive : ''"
- :enter-from-class="defaultStore.state.animation ? $style.transition_menuDrawerBg_enterFrom : ''"
- :leave-to-class="defaultStore.state.animation ? $style.transition_menuDrawerBg_leaveTo : ''"
+ :enterActiveClass="defaultStore.state.animation ? $style.transition_menuDrawerBg_enterActive : ''"
+ :leaveActiveClass="defaultStore.state.animation ? $style.transition_menuDrawerBg_leaveActive : ''"
+ :enterFromClass="defaultStore.state.animation ? $style.transition_menuDrawerBg_enterFrom : ''"
+ :leaveToClass="defaultStore.state.animation ? $style.transition_menuDrawerBg_leaveTo : ''"
>
<div
v-if="drawerMenuShowing"
@@ -42,10 +38,10 @@
</Transition>
<Transition
- :enter-active-class="defaultStore.state.animation ? $style.transition_menuDrawer_enterActive : ''"
- :leave-active-class="defaultStore.state.animation ? $style.transition_menuDrawer_leaveActive : ''"
- :enter-from-class="defaultStore.state.animation ? $style.transition_menuDrawer_enterFrom : ''"
- :leave-to-class="defaultStore.state.animation ? $style.transition_menuDrawer_leaveTo : ''"
+ :enterActiveClass="defaultStore.state.animation ? $style.transition_menuDrawer_enterActive : ''"
+ :leaveActiveClass="defaultStore.state.animation ? $style.transition_menuDrawer_leaveActive : ''"
+ :enterFromClass="defaultStore.state.animation ? $style.transition_menuDrawer_enterFrom : ''"
+ :leaveToClass="defaultStore.state.animation ? $style.transition_menuDrawer_leaveTo : ''"
>
<div v-if="drawerMenuShowing" :class="$style.menuDrawer">
<XDrawerMenu/>
@@ -53,10 +49,10 @@
</Transition>
<Transition
- :enter-active-class="defaultStore.state.animation ? $style.transition_widgetsDrawerBg_enterActive : ''"
- :leave-active-class="defaultStore.state.animation ? $style.transition_widgetsDrawerBg_leaveActive : ''"
- :enter-from-class="defaultStore.state.animation ? $style.transition_widgetsDrawerBg_enterFrom : ''"
- :leave-to-class="defaultStore.state.animation ? $style.transition_widgetsDrawerBg_leaveTo : ''"
+ :enterActiveClass="defaultStore.state.animation ? $style.transition_widgetsDrawerBg_enterActive : ''"
+ :leaveActiveClass="defaultStore.state.animation ? $style.transition_widgetsDrawerBg_leaveActive : ''"
+ :enterFromClass="defaultStore.state.animation ? $style.transition_widgetsDrawerBg_enterFrom : ''"
+ :leaveToClass="defaultStore.state.animation ? $style.transition_widgetsDrawerBg_leaveTo : ''"
>
<div
v-if="widgetsShowing"
@@ -68,10 +64,10 @@
</Transition>
<Transition
- :enter-active-class="defaultStore.state.animation ? $style.transition_widgetsDrawer_enterActive : ''"
- :leave-active-class="defaultStore.state.animation ? $style.transition_widgetsDrawer_leaveActive : ''"
- :enter-from-class="defaultStore.state.animation ? $style.transition_widgetsDrawer_enterFrom : ''"
- :leave-to-class="defaultStore.state.animation ? $style.transition_widgetsDrawer_leaveTo : ''"
+ :enterActiveClass="defaultStore.state.animation ? $style.transition_widgetsDrawer_enterActive : ''"
+ :leaveActiveClass="defaultStore.state.animation ? $style.transition_widgetsDrawer_leaveActive : ''"
+ :enterFromClass="defaultStore.state.animation ? $style.transition_widgetsDrawer_enterFrom : ''"
+ :leaveToClass="defaultStore.state.animation ? $style.transition_widgetsDrawer_leaveTo : ''"
>
<div v-if="widgetsShowing" :class="$style.widgetsDrawer">
<button class="_button" :class="$style.widgetsCloseButton" @click="widgetsShowing = false"><i class="ti ti-x"></i></button>
@@ -84,10 +80,10 @@
</template>
<script lang="ts" setup>
-import { defineAsyncComponent, provide, onMounted, computed, ref, ComputedRef, watch, inject, Ref } from 'vue';
+import { defineAsyncComponent, provide, onMounted, computed, ref, ComputedRef, watch, shallowRef, Ref } from 'vue';
import XCommon from './_common_/common.vue';
+import type MkStickyContainer from '@/components/global/MkStickyContainer.vue';
import { instanceName } from '@/config';
-import { StickySidebar } from '@/scripts/sticky-sidebar';
import XDrawerMenu from '@/ui/_common_/navbar-for-mobile.vue';
import * as os from '@/os';
import { defaultStore } from '@/store';
@@ -99,6 +95,7 @@ import { PageMetadata, provideMetadataReceiver } from '@/scripts/page-metadata';
import { deviceKind } from '@/scripts/device-kind';
import { miLocalStorage } from '@/local-storage';
import { CURRENT_STICKY_BOTTOM } from '@/const';
+
const XWidgets = defineAsyncComponent(() => import('./universal.widgets.vue'));
const XSidebar = defineAsyncComponent(() => import('@/ui/_common_/navbar.vue'));
const XStatusBars = defineAsyncComponent(() => import('@/ui/_common_/statusbars.vue'));
@@ -114,9 +111,9 @@ window.addEventListener('resize', () => {
});
let pageMetadata = $ref<null | ComputedRef<PageMetadata>>();
-const widgetsEl = $shallowRef<HTMLElement>();
const widgetsShowing = $ref(false);
const navFooter = $shallowRef<HTMLElement>();
+const contents = shallowRef<InstanceType<typeof MkStickyContainer>>();
provide('router', mainRouter);
provideMetadataReceiver((info) => {
@@ -140,8 +137,6 @@ mainRouter.on('change', () => {
drawerMenuShowing.value = false;
});
-document.documentElement.style.overflowY = 'scroll';
-
if (window.innerWidth > 1024) {
const tempUI = miLocalStorage.getItem('ui_temp');
if (tempUI) {
@@ -197,19 +192,13 @@ const onContextmenu = (ev) => {
}], ev);
};
-const attachSticky = (el) => {
- const sticky = new StickySidebar(widgetsEl);
- window.addEventListener('scroll', () => {
- sticky.calc(window.scrollY);
- }, { passive: true });
-};
-
function top() {
- window.scroll({ top: 0, behavior: 'smooth' });
+ contents.value.rootEl.scrollTo({
+ top: 0,
+ behavior: 'smooth',
+ });
}
-const wallpaper = miLocalStorage.getItem('wallpaper') != null;
-
let navFooterHeight = $ref(0);
provide<Ref<number>>(CURRENT_STICKY_BOTTOM, $$(navFooterHeight));
@@ -275,28 +264,33 @@ $widgets-hide-threshold: 1090px;
}
.root {
- min-height: 100dvh;
+ height: 100dvh;
+ overflow: clip;
+ contain: strict;
box-sizing: border-box;
display: flex;
}
-.withWallpaper {
- background: var(--wallpaperOverlay);
- //backdrop-filter: var(--blur, blur(4px));
-}
-
.sidebar {
border-right: solid 0.5px var(--divider);
}
.contents {
- width: 100%;
+ flex: 1;
+ height: 100%;
min-width: 0;
+ overflow: auto;
+ overflow-y: scroll;
+ overscroll-behavior: contain;
background: var(--bg);
}
.widgets {
- padding: 0 var(--margin) calc(var(--margin) + env(safe-area-inset-bottom, 0px));
+ width: 350px;
+ height: 100%;
+ box-sizing: border-box;
+ overflow: auto;
+ padding: var(--margin) var(--margin) calc(var(--margin) + env(safe-area-inset-bottom, 0px));
border-left: solid 0.5px var(--divider);
background: var(--bg);
@@ -328,6 +322,7 @@ $widgets-hide-threshold: 1090px;
top: 0;
right: 0;
z-index: 1001;
+ width: 310px;
height: 100dvh;
padding: var(--margin) var(--margin) calc(var(--margin) + env(safe-area-inset-bottom, 0px)) !important;
box-sizing: border-box;
diff --git a/packages/frontend/src/ui/universal.widgets.vue b/packages/frontend/src/ui/universal.widgets.vue
index 3e0c38bb83..ec5e8bb03f 100644
--- a/packages/frontend/src/ui/universal.widgets.vue
+++ b/packages/frontend/src/ui/universal.widgets.vue
@@ -1,6 +1,6 @@
<template>
-<div :class="$style.root" :style="{ paddingTop: marginTop }">
- <XWidgets :class="$style.widgets" :edit="editMode" :widgets="widgets" @add-widget="addWidget" @remove-widget="removeWidget" @update-widget="updateWidget" @update-widgets="updateWidgets" @exit="editMode = false"/>
+<div>
+ <XWidgets :edit="editMode" :widgets="widgets" @addWidget="addWidget" @removeWidget="removeWidget" @updateWidget="updateWidget" @updateWidgets="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" 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>
@@ -11,7 +11,7 @@
let editMode = $ref(false);
</script>
<script lang="ts" setup>
-import { onMounted } from 'vue';
+import { } from 'vue';
import XWidgets from '@/components/MkWidgets.vue';
import { i18n } from '@/i18n';
import { defaultStore } from '@/store';
@@ -21,28 +21,16 @@ const props = withDefaults(defineProps<{
// left = place: leftだけを表示
// right = rightとnullを表示
place?: 'left' | null | 'right';
- marginTop?: string;
}>(), {
place: null,
- marginTop: '0',
});
-const emit = defineEmits<{
- (ev: 'mounted', el?: Element): void;
-}>();
-
-let rootEl = $shallowRef<HTMLDivElement>();
-
const widgets = $computed(() => {
if (props.place === null) return defaultStore.reactiveState.widgets.value;
if (props.place === 'left') return defaultStore.reactiveState.widgets.value.filter(w => w.place === 'left');
return defaultStore.reactiveState.widgets.value.filter(w => w.place !== 'left');
});
-onMounted(() => {
- emit('mounted', rootEl);
-});
-
function addWidget(widget) {
defaultStore.set('widgets', [{
...widget,
@@ -82,16 +70,6 @@ function updateWidgets(thisWidgets) {
</script>
<style lang="scss" module>
-.root {
- position: sticky;
- height: min-content;
- box-sizing: border-box;
-}
-
-.widgets {
- width: 300px;
-}
-
.edit {
width: 100%;
}
diff --git a/packages/frontend/src/ui/visitor.vue b/packages/frontend/src/ui/visitor.vue
index 623abbda39..d6de145edb 100644
--- a/packages/frontend/src/ui/visitor.vue
+++ b/packages/frontend/src/ui/visitor.vue
@@ -12,10 +12,10 @@
<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>
+ <MkA to="/" class="link" activeClass="active"><i class="ti ti-home icon"></i> {{ i18n.ts.home }}</MkA>
+ <MkA v-if="isTimelineAvailable" to="/timeline" class="link" activeClass="active"><i class="ti ti-message icon"></i> {{ i18n.ts.timeline }}</MkA>
+ <MkA to="/explore" class="link" activeClass="active"><i class="ti ti-hash icon"></i> {{ i18n.ts.explore }}</MkA>
+ <MkA to="/channels" class="link" activeClass="active"><i class="ti ti-device-tv icon"></i> {{ i18n.ts.channel }}</MkA>
</div>
<div v-else-if="narrow === true" class="narrow">
<button class="menu _button" @click="showMenu = true">
@@ -44,15 +44,15 @@
<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>
+ <MkA to="/" class="link" activeClass="active"><i class="ti ti-home icon"></i>{{ i18n.ts.home }}</MkA>
+ <MkA v-if="isTimelineAvailable" to="/timeline" class="link" activeClass="active"><i class="ti ti-message icon"></i>{{ i18n.ts.timeline }}</MkA>
+ <MkA to="/explore" class="link" activeClass="active"><i class="ti ti-hash icon"></i>{{ i18n.ts.explore }}</MkA>
+ <MkA to="/announcements" class="link" activeClass="active"><i class="ti ti-speakerphone icon"></i>{{ i18n.ts.announcements }}</MkA>
+ <MkA to="/channels" class="link" activeClass="active"><i class="ti ti-device-tv icon"></i>{{ i18n.ts.channel }}</MkA>
<div class="divider"></div>
- <MkA to="/pages" class="link" 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>
+ <MkA to="/pages" class="link" activeClass="active"><i class="ti ti-news icon"></i>{{ i18n.ts.pages }}</MkA>
+ <MkA to="/play" class="link" activeClass="active"><i class="ti ti-player-play icon"></i>Play</MkA>
+ <MkA to="/gallery" class="link" activeClass="active"><i class="ti ti-icons icon"></i>{{ i18n.ts.gallery }}</MkA>
<div class="action">
<button class="_buttonPrimary" @click="signup()">{{ i18n.ts.signup }}</button>
<button class="_button" @click="signin()">{{ i18n.ts.login }}</button>
diff --git a/packages/frontend/src/ui/zen.vue b/packages/frontend/src/ui/zen.vue
index 628390e3f7..d516a5df75 100644
--- a/packages/frontend/src/ui/zen.vue
+++ b/packages/frontend/src/ui/zen.vue
@@ -1,9 +1,17 @@
<template>
-<div class="mk-app" style="container-type: inline-size;">
+<div :class="showBottom ? $style.rootWithBottom : $style.root" style="container-type: inline-size;">
<RouterView/>
<XCommon/>
</div>
+
+<!--
+ デッキUIが設定されている場合はデッキUIに戻れるようにする (ただし?zenが明示された場合は表示しない)
+ See https://github.com/misskey-dev/misskey/issues/10905
+-->
+<div v-if="showBottom" :class="$style.bottom">
+ <button v-tooltip="i18n.ts.goToMisskey" :class="['_button', '_shadow', $style.button]" @click="goToMisskey"><i class="ti ti-home"></i></button>
+</div>
</template>
<script lang="ts" setup>
@@ -11,10 +19,13 @@ import { provide, ComputedRef } from 'vue';
import XCommon from './_common_/common.vue';
import { mainRouter } from '@/router';
import { PageMetadata, provideMetadataReceiver } from '@/scripts/page-metadata';
-import { instanceName } from '@/config';
+import { instanceName, ui } from '@/config';
+import { i18n } from '@/i18n';
let pageMetadata = $ref<null | ComputedRef<PageMetadata>>();
+const showBottom = !(new URLSearchParams(location.search)).has('zen') && ui === 'deck';
+
provide('router', mainRouter);
provideMetadataReceiver((info) => {
pageMetadata = info;
@@ -23,12 +34,41 @@ provideMetadataReceiver((info) => {
}
});
+function goToMisskey() {
+ window.location.href = '/';
+}
+
document.documentElement.style.overflowY = 'scroll';
</script>
-<style lang="scss" scoped>
-.mk-app {
+<style lang="scss" module>
+.root {
min-height: 100dvh;
box-sizing: border-box;
}
+
+.rootWithBottom {
+ min-height: calc(100dvh - (60px + (var(--margin) * 2) + env(safe-area-inset-bottom, 0px)));
+ box-sizing: border-box;
+}
+
+.bottom {
+ height: calc(60px + (var(--margin) * 2) + env(safe-area-inset-bottom, 0px));
+ width: 100%;
+ margin-top: auto;
+}
+
+.button {
+ position: fixed !important;
+ padding: 0;
+ aspect-ratio: 1;
+ width: 100%;
+ max-width: 60px;
+ margin: auto;
+ border-radius: 100%;
+ background: var(--panel);
+ color: var(--fg);
+ right: var(--margin);
+ bottom: calc(var(--margin) + env(safe-area-inset-bottom, 0px));
+}
</style>
diff --git a/packages/frontend/src/unicode-emoji-indexes/en-US.json b/packages/frontend/src/unicode-emoji-indexes/en-US.json
new file mode 100644
index 0000000000..c5544418db
--- /dev/null
+++ b/packages/frontend/src/unicode-emoji-indexes/en-US.json
@@ -0,0 +1,1784 @@
+{
+ "😀": ["face", "smile", "happy", "joy", ": D", "grin"],
+ "😬": ["face", "grimace", "teeth"],
+ "😁": ["face", "happy", "smile", "joy", "kawaii"],
+ "😂": ["face", "cry", "tears", "weep", "happy", "happytears", "haha"],
+ "🤣": ["face", "rolling", "floor", "laughing", "lol", "haha"],
+ "🥳": ["face", "celebration", "woohoo"],
+ "😃": ["face", "happy", "joy", "haha", ": D", ": )", "smile", "funny"],
+ "😄": ["face", "happy", "joy", "funny", "haha", "laugh", "like", ": D", ": )"],
+ "😅": ["face", "hot", "happy", "laugh", "sweat", "smile", "relief"],
+ "🥲": ["face"],
+ "😆": ["happy", "joy", "lol", "satisfied", "haha", "face", "glad", "XD", "laugh"],
+ "😇": ["face", "angel", "heaven", "halo"],
+ "😉": ["face", "happy", "mischievous", "secret", ";)", "smile", "eye"],
+ "😊": ["face", "smile", "happy", "flushed", "crush", "embarrassed", "shy", "joy"],
+ "🙂": ["face", "smile"],
+ "🙃": ["face", "flipped", "silly", "smile"],
+ "☺️": ["face", "blush", "massage", "happiness"],
+ "😋": ["happy", "joy", "tongue", "smile", "face", "silly", "yummy", "nom", "delicious", "savouring"],
+ "😌": ["face", "relaxed", "phew", "massage", "happiness"],
+ "😍": ["face", "love", "like", "affection", "valentines", "infatuation", "crush", "heart"],
+ "🥰": ["face", "love", "like", "affection", "valentines", "infatuation", "crush", "hearts", "adore"],
+ "😘": ["face", "love", "like", "affection", "valentines", "infatuation", "kiss"],
+ "😗": ["love", "like", "face", "3", "valentines", "infatuation", "kiss"],
+ "😙": ["face", "affection", "valentines", "infatuation", "kiss"],
+ "😚": ["face", "love", "like", "affection", "valentines", "infatuation", "kiss"],
+ "😜": ["face", "prank", "childish", "playful", "mischievous", "smile", "wink", "tongue"],
+ "🤪": ["face", "goofy", "crazy"],
+ "🤨": ["face", "distrust", "scepticism", "disapproval", "disbelief", "surprise"],
+ "🧐": ["face", "stuffy", "wealthy"],
+ "😝": ["face", "prank", "playful", "mischievous", "smile", "tongue"],
+ "😛": ["face", "prank", "childish", "playful", "mischievous", "smile", "tongue"],
+ "🤑": ["face", "rich", "dollar", "money"],
+ "🤓": ["face", "nerdy", "geek", "dork"],
+ "🥸": ["face", "nose", "glasses", "incognito"],
+ "😎": ["face", "cool", "smile", "summer", "beach", "sunglass"],
+ "🤩": ["face", "smile", "starry", "eyes", "grinning"],
+ "🤡": ["face"],
+ "🤠": ["face", "cowgirl", "hat"],
+ "🤗": ["face", "smile", "hug"],
+ "😏": ["face", "smile", "mean", "prank", "smug", "sarcasm"],
+ "😶": ["face", "hellokitty"],
+ "😐": ["indifference", "meh", ": |", "neutral"],
+ "😑": ["face", "indifferent", "-_-", "meh", "deadpan"],
+ "😒": ["indifference", "bored", "straight face", "serious", "sarcasm", "unimpressed", "skeptical", "dubious", "side_eye"],
+ "🙄": ["face", "eyeroll", "frustrated"],
+ "🤔": ["face", "hmmm", "think", "consider"],
+ "🤥": ["face", "lie", "pinocchio"],
+ "🤭": ["face", "whoops", "shock", "surprise"],
+ "🤫": ["face", "quiet", "shhh"],
+ "🤬": ["face", "swearing", "cursing", "cussing", "profanity", "expletive"],
+ "🤯": ["face", "shocked", "mind", "blown"],
+ "😳": ["face", "blush", "shy", "flattered"],
+ "😞": ["face", "sad", "upset", "depressed", ": ("],
+ "😟": ["face", "concern", "nervous", ": ("],
+ "😠": ["mad", "face", "annoyed", "frustrated"],
+ "😡": ["angry", "mad", "hate", "despise"],
+ "😔": ["face", "sad", "depressed", "upset"],
+ "😕": ["face", "indifference", "huh", "weird", "hmmm", ": /"],
+ "🙁": ["face", "frowning", "disappointed", "sad", "upset"],
+ "☹": ["face", "sad", "upset", "frown"],
+ "😣": ["face", "sick", "no", "upset", "oops"],
+ "😖": ["face", "confused", "sick", "unwell", "oops", ": S"],
+ "😫": ["sick", "whine", "upset", "frustrated"],
+ "😩": ["face", "tired", "sleepy", "sad", "frustrated", "upset"],
+ "🥺": ["face", "begging", "mercy"],
+ "😤": ["face", "gas", "phew", "proud", "pride"],
+ "😮": ["face", "surprise", "impressed", "wow", "whoa", ": O"],
+ "😱": ["face", "munch", "scared", "omg"],
+ "😨": ["face", "scared", "terrified", "nervous", "oops", "huh"],
+ "😰": ["face", "nervous", "sweat"],
+ "😯": ["face", "woo", "shh"],
+ "😦": ["face", "aw", "what"],
+ "😧": ["face", "stunned", "nervous"],
+ "😢": ["face", "tears", "sad", "depressed", "upset", ": '("],
+ "😥": ["face", "phew", "sweat", "nervous"],
+ "🤤": ["face"],
+ "😪": ["face", "tired", "rest", "nap"],
+ "😓": ["face", "hot", "sad", "tired", "exercise"],
+ "🥵": ["face", "feverish", "heat", "red", "sweating"],
+ "🥶": ["face", "blue", "freezing", "frozen", "frostbite", "icicles"],
+ "😭": ["face", "cry", "tears", "sad", "upset", "depressed"],
+ "😵": ["spent", "unconscious", "xox", "dizzy"],
+ "😲": ["face", "xox", "surprised", "poisoned"],
+ "🤐": ["face", "sealed", "zipper", "secret"],
+ "🤢": ["face", "vomit", "gross", "green", "sick", "throw up", "ill"],
+ "🤧": ["face", "gesundheit", "sneeze", "sick", "allergy"],
+ "🤮": ["face", "sick"],
+ "😷": ["face", "sick", "ill", "disease"],
+ "🤒": ["sick", "temperature", "thermometer", "cold", "fever"],
+ "🤕": ["injured", "clumsy", "bandage", "hurt"],
+ "🥴": ["face", "dizzy", "intoxicated", "tipsy", "wavy"],
+ "🥱": ["face", "tired", "yawning"],
+ "😴": ["face", "tired", "sleepy", "night", "zzz"],
+ "💤": ["sleepy", "tired", "dream"],
+ "😶‍🌫️": [],
+ "😮‍💨": [],
+ "😵‍💫": [],
+ "🫠": ["disappear", "dissolve", "liquid", "melt", "toketa"],
+ "🫢": ["amazement", "awe", "disbelief", "embarrass", "scared", "surprise", "ohoho"],
+ "🫣": ["captivated", "peep", "stare", "chunibyo"],
+ "🫡": ["ok", "salute", "sunny", "troops", "yes", "raja"],
+ "🫥": ["depressed", "disappear", "hide", "introvert", "invisible", "tensen"],
+ "🫤": ["disappointed", "meh", "skeptical", "unsure"],
+ "🥹": ["angry", "cry", "proud", "resist", "sad"],
+ "💩": ["hankey", "shitface", "fail", "turd", "shit"],
+ "😈": ["devil", "horns"],
+ "👿": ["devil", "angry", "horns"],
+ "👹": ["monster", "red", "mask", "halloween", "scary", "creepy", "devil", "demon", "japanese", "ogre"],
+ "👺": ["red", "evil", "mask", "monster", "scary", "creepy", "japanese", "goblin"],
+ "💀": ["dead", "skeleton", "creepy", "death"],
+ "👻": ["halloween", "spooky", "scary"],
+ "👽": ["UFO", "paul", "weird", "outer_space"],
+ "🤖": ["computer", "machine", "bot"],
+ "😺": ["animal", "cats", "happy", "smile"],
+ "😸": ["animal", "cats", "smile"],
+ "😹": ["animal", "cats", "haha", "happy", "tears"],
+ "😻": ["animal", "love", "like", "affection", "cats", "valentines", "heart"],
+ "😼": ["animal", "cats", "smirk"],
+ "😽": ["animal", "cats", "kiss"],
+ "🙀": ["animal", "cats", "munch", "scared", "scream"],
+ "😿": ["animal", "tears", "weep", "sad", "cats", "upset", "cry"],
+ "😾": ["animal", "cats"],
+ "🤲": ["hands", "gesture", "cupped", "prayer"],
+ "🙌": ["gesture", "hooray", "yea", "celebration", "hands"],
+ "👏": ["hands", "praise", "applause", "congrats", "yay"],
+ "👋": ["hands", "gesture", "goodbye", "solong", "farewell", "hello", "hi", "palm"],
+ "🤙": ["hands", "gesture"],
+ "👍": ["thumbsup", "yes", "awesome", "good", "agree", "accept", "cool", "hand", "like"],
+ "👎": ["thumbsdown", "no", "dislike", "hand"],
+ "👊": ["angry", "violence", "fist", "hit", "attack", "hand"],
+ "✊": ["fingers", "hand", "grasp"],
+ "🤛": ["hand", "fistbump"],
+ "🤜": ["hand", "fistbump"],
+ "✌": ["fingers", "ohyeah", "hand", "peace", "victory", "two"],
+ "👌": ["fingers", "limbs", "perfect", "ok", "okay"],
+ "✋": ["fingers", "stop", "highfive", "palm", "ban"],
+ "🤚": ["fingers", "raised", "backhand"],
+ "👐": ["fingers", "butterfly", "hands", "open"],
+ "💪": ["arm", "flex", "hand", "summer", "strong", "biceps"],
+ "🦾": ["flex", "hand", "strong", "biceps"],
+ "🙏": ["please", "hope", "wish", "namaste", "highfive"],
+ "🦶": ["kick", "stomp"],
+ "🦵": ["kick", "limb"],
+ "🦿": ["kick", "limb"],
+ "🤝": ["agreement", "shake"],
+ "☝": ["hand", "fingers", "direction", "up"],
+ "👆": ["fingers", "hand", "direction", "up"],
+ "👇": ["fingers", "hand", "direction", "down"],
+ "👈": ["direction", "fingers", "hand", "left"],
+ "👉": ["fingers", "hand", "direction", "right"],
+ "🖕": ["hand", "fingers", "rude", "middle", "flipping"],
+ "🖐": ["hand", "fingers", "palm"],
+ "🤟": ["hand", "fingers", "gesture"],
+ "🤘": ["hand", "fingers", "evil_eye", "sign_of_horns", "rock_on"],
+ "🤞": ["good", "lucky"],
+ "🖖": ["hand", "fingers", "spock", "star trek"],
+ "✍": ["lower_left_ballpoint_pen", "stationery", "write", "compose"],
+ "🫰": [],
+ "🫱": [],
+ "🫲": [],
+ "🫳": [],
+ "🫴": [],
+ "🫵": [],
+ "🫶": ["moemoekyun"],
+ "🤏": ["hand", "fingers"],
+ "🤌": ["hand", "fingers"],
+ "🤳": ["camera", "phone"],
+ "💅": ["beauty", "manicure", "finger", "fashion", "nail"],
+ "👄": ["mouth", "kiss"],
+ "🫦": [],
+ "🦷": ["teeth", "dentist"],
+ "👅": ["mouth", "playful"],
+ "👂": ["face", "hear", "sound", "listen"],
+ "🦻": ["face", "hear", "sound", "listen"],
+ "👃": ["smell", "sniff"],
+ "👁": ["face", "look", "see", "watch", "stare"],
+ "👀": ["look", "watch", "stalk", "peek", "see"],
+ "🧠": ["smart", "intelligent"],
+ "🫀": [],
+ "🫁": [],
+ "👤": ["user", "person", "human"],
+ "👥": ["user", "person", "human", "group", "team"],
+ "🗣": ["user", "person", "human", "sing", "say", "talk"],
+ "👶": ["child", "boy", "girl", "toddler"],
+ "🧒": ["gender-neutral", "young"],
+ "👦": ["man", "male", "guy", "teenager"],
+ "👧": ["female", "woman", "teenager"],
+ "🧑": ["gender-neutral", "person"],
+ "👨": ["mustache", "father", "dad", "guy", "classy", "sir", "moustache"],
+ "👩": ["female", "girls", "lady"],
+ "🧑‍🦱": ["curly", "afro", "braids", "ringlets"],
+ "👩‍🦱": ["woman", "female", "girl", "curly", "afro", "braids", "ringlets"],
+ "👨‍🦱": ["man", "male", "boy", "guy", "curly", "afro", "braids", "ringlets"],
+ "🧑‍🦰": ["redhead"],
+ "👩‍🦰": ["woman", "female", "girl", "ginger", "redhead"],
+ "👨‍🦰": ["man", "male", "boy", "guy", "ginger", "redhead"],
+ "👱‍♀️": ["woman", "female", "girl", "blonde", "person"],
+ "👱": ["man", "male", "boy", "blonde", "guy", "person"],
+ "🧑‍🦳": ["gray", "old", "white"],
+ "👩‍🦳": ["woman", "female", "girl", "gray", "old", "white"],
+ "👨‍🦳": ["man", "male", "boy", "guy", "gray", "old", "white"],
+ "🧑‍🦲": ["bald", "chemotherapy", "hairless", "shaven"],
+ "👩‍🦲": ["woman", "female", "girl", "bald", "chemotherapy", "hairless", "shaven"],
+ "👨‍🦲": ["man", "male", "boy", "guy", "bald", "chemotherapy", "hairless", "shaven"],
+ "🧔": ["person", "bewhiskered"],
+ "🧓": ["human", "elder", "senior", "gender-neutral"],
+ "👴": ["human", "male", "men", "old", "elder", "senior"],
+ "👵": ["human", "female", "women", "lady", "old", "elder", "senior"],
+ "👲": ["male", "boy", "chinese"],
+ "🧕": ["female", "hijab", "mantilla", "tichel"],
+ "👳‍♀️": ["female", "indian", "hinduism", "arabs", "woman"],
+ "👳": ["male", "indian", "hinduism", "arabs"],
+ "👮‍♀️": ["woman", "police", "law", "legal", "enforcement", "arrest", "911", "female"],
+ "👮": ["man", "police", "law", "legal", "enforcement", "arrest", "911"],
+ "👷‍♀️": ["female", "human", "wip", "build", "construction", "worker", "labor", "woman"],
+ "👷": ["male", "human", "wip", "guy", "build", "construction", "worker", "labor"],
+ "💂‍♀️": ["uk", "gb", "british", "female", "royal", "woman"],
+ "💂": ["uk", "gb", "british", "male", "guy", "royal"],
+ "🕵️‍♀️": ["human", "spy", "detective", "female", "woman"],
+ "🕵": ["human", "spy", "detective"],
+ "🧑‍⚕️": ["doctor", "nurse", "therapist", "healthcare", "human"],
+ "👩‍⚕️": ["doctor", "nurse", "therapist", "healthcare", "woman", "human"],
+ "👨‍⚕️": ["doctor", "nurse", "therapist", "healthcare", "man", "human"],
+ "🧑‍🌾": ["rancher", "gardener", "human"],
+ "👩‍🌾": ["rancher", "gardener", "woman", "human"],
+ "👨‍🌾": ["rancher", "gardener", "man", "human"],
+ "🧑‍🍳": ["chef", "human"],
+ "👩‍🍳": ["chef", "woman", "human"],
+ "👨‍🍳": ["chef", "man", "human"],
+ "🧑‍🎓": ["graduate", "human"],
+ "👩‍🎓": ["graduate", "woman", "human"],
+ "👨‍🎓": ["graduate", "man", "human"],
+ "🧑‍🎤": ["rockstar", "entertainer", "human"],
+ "👩‍🎤": ["rockstar", "entertainer", "woman", "human"],
+ "👨‍🎤": ["rockstar", "entertainer", "man", "human"],
+ "🧑‍🏫": ["instructor", "professor", "human"],
+ "👩‍🏫": ["instructor", "professor", "woman", "human"],
+ "👨‍🏫": ["instructor", "professor", "man", "human"],
+ "🧑‍🏭": ["assembly", "industrial", "human"],
+ "👩‍🏭": ["assembly", "industrial", "woman", "human"],
+ "👨‍🏭": ["assembly", "industrial", "man", "human"],
+ "🧑‍💻": ["coder", "developer", "engineer", "programmer", "software", "human", "laptop", "computer"],
+ "👩‍💻": ["coder", "developer", "engineer", "programmer", "software", "woman", "human", "laptop", "computer"],
+ "👨‍💻": ["coder", "developer", "engineer", "programmer", "software", "man", "human", "laptop", "computer"],
+ "🧑‍💼": ["business", "manager", "human"],
+ "👩‍💼": ["business", "manager", "woman", "human"],
+ "👨‍💼": ["business", "manager", "man", "human"],
+ "🧑‍🔧": ["plumber", "human", "wrench"],
+ "👩‍🔧": ["plumber", "woman", "human", "wrench"],
+ "👨‍🔧": ["plumber", "man", "human", "wrench"],
+ "🧑‍🔬": ["biologist", "chemist", "engineer", "physicist", "human"],
+ "👩‍🔬": ["biologist", "chemist", "engineer", "physicist", "woman", "human"],
+ "👨‍🔬": ["biologist", "chemist", "engineer", "physicist", "man", "human"],
+ "🧑‍🎨": ["painter", "human"],
+ "👩‍🎨": ["painter", "woman", "human"],
+ "👨‍🎨": ["painter", "man", "human"],
+ "🧑‍🚒": ["fireman", "human"],
+ "👩‍🚒": ["fireman", "woman", "human"],
+ "👨‍🚒": ["fireman", "man", "human"],
+ "🧑‍✈️": ["aviator", "plane", "human"],
+ "👩‍✈️": ["aviator", "plane", "woman", "human"],
+ "👨‍✈️": ["aviator", "plane", "man", "human"],
+ "🧑‍🚀": ["space", "rocket", "human"],
+ "👩‍🚀": ["space", "rocket", "woman", "human"],
+ "👨‍🚀": ["space", "rocket", "man", "human"],
+ "🧑‍⚖️": ["justice", "court", "human"],
+ "👩‍⚖️": ["justice", "court", "woman", "human"],
+ "👨‍⚖️": ["justice", "court", "man", "human"],
+ "🦸‍♀️": ["woman", "female", "good", "heroine", "superpowers"],
+ "🦸‍♂️": ["man", "male", "good", "hero", "superpowers"],
+ "🦹‍♀️": ["woman", "female", "evil", "bad", "criminal", "heroine", "superpowers"],
+ "🦹‍♂️": ["man", "male", "evil", "bad", "criminal", "hero", "superpowers"],
+ "🤶": ["woman", "female", "xmas", "mother christmas"],
+ "🧑‍🎄": ["xmas", "christmas"],
+ "🎅": ["festival", "man", "male", "xmas", "father christmas"],
+ "🥷": [],
+ "🧙‍♀️": ["woman", "female", "mage", "witch"],
+ "🧙‍♂️": ["man", "male", "mage", "sorcerer"],
+ "🧝‍♀️": ["woman", "female"],
+ "🧝‍♂️": ["man", "male"],
+ "🧛‍♀️": ["woman", "female"],
+ "🧛‍♂️": ["man", "male", "dracula"],
+ "🧟‍♀️": ["woman", "female", "undead", "walking dead"],
+ "🧟‍♂️": ["man", "male", "dracula", "undead", "walking dead"],
+ "🧞‍♀️": ["woman", "female"],
+ "🧞‍♂️": ["man", "male"],
+ "🧜‍♀️": ["woman", "female", "merwoman", "ariel"],
+ "🧜‍♂️": ["man", "male", "triton"],
+ "🧚‍♀️": ["woman", "female"],
+ "🧚‍♂️": ["man", "male"],
+ "👼": ["heaven", "wings", "halo"],
+ "🧌": [],
+ "🤰": ["baby"],
+ "🫃": [],
+ "🫄": [],
+ "🫅": [],
+ "🤱": ["nursing", "baby"],
+ "👩‍🍼": [],
+ "👨‍🍼": [],
+ "🧑‍🍼": [],
+ "👸": ["girl", "woman", "female", "blond", "crown", "royal", "queen"],
+ "🤴": ["boy", "man", "male", "crown", "royal", "king"],
+ "👰": ["couple", "marriage", "wedding", "woman", "bride"],
+ "👰": ["couple", "marriage", "wedding", "woman", "bride"],
+ "🤵": ["couple", "marriage", "wedding", "groom"],
+ "🤵": ["couple", "marriage", "wedding", "groom"],
+ "🏃‍♀️": ["woman", "walking", "exercise", "race", "running", "female"],
+ "🏃": ["man", "walking", "exercise", "race", "running"],
+ "🚶‍♀️": ["human", "feet", "steps", "woman", "female"],
+ "🚶": ["human", "feet", "steps"],
+ "💃": ["female", "girl", "woman", "fun"],
+ "🕺": ["male", "boy", "fun", "dancer"],
+ "👯": ["female", "bunny", "women", "girls"],
+ "👯‍♂️": ["male", "bunny", "men", "boys"],
+ "👫": ["pair", "people", "human", "love", "date", "dating", "like", "affection", "valentines", "marriage"],
+ "🧑‍🤝‍🧑": ["pair", "couple", "love", "like", "bromance", "friendship", "people", "human"],
+ "👬": ["pair", "couple", "love", "like", "bromance", "friendship", "people", "man", "human"],
+ "👭": ["pair", "couple", "love", "like", "bromance", "friendship", "people", "female", "human"],
+ "🫂": [],
+ "🙇‍♀️": ["woman", "female", "girl"],
+ "🙇": ["man", "male", "boy"],
+ "🤦‍♂️": ["man", "male", "boy", "disbelief"],
+ "🤦‍♀️": ["woman", "female", "girl", "disbelief"],
+ "🤷": ["woman", "female", "girl", "confused", "indifferent", "doubt"],
+ "🤷‍♂️": ["man", "male", "boy", "confused", "indifferent", "doubt"],
+ "💁": ["female", "girl", "woman", "human", "information"],
+ "💁‍♂️": ["male", "boy", "man", "human", "information"],
+ "🙅": ["female", "girl", "woman", "nope"],
+ "🙅‍♂️": ["male", "boy", "man", "nope"],
+ "🙆": ["women", "girl", "female", "pink", "human", "woman"],
+ "🙆‍♂️": ["men", "boy", "male", "blue", "human", "man"],
+ "🙋": ["female", "girl", "woman"],
+ "🙋‍♂️": ["male", "boy", "man"],
+ "🙎": ["female", "girl", "woman"],
+ "🙎‍♂️": ["male", "boy", "man"],
+ "🙍": ["female", "girl", "woman", "sad", "depressed", "discouraged", "unhappy"],
+ "🙍‍♂️": ["male", "boy", "man", "sad", "depressed", "discouraged", "unhappy"],
+ "💇": ["female", "girl", "woman"],
+ "💇‍♂️": ["male", "boy", "man"],
+ "💆": ["female", "girl", "woman", "head"],
+ "💆‍♂️": ["male", "boy", "man", "head"],
+ "🧖‍♀️": ["female", "woman", "spa", "steamroom", "sauna"],
+ "🧖‍♂️": ["male", "man", "spa", "steamroom", "sauna"],
+ "🧏‍♀️": ["woman", "female"],
+ "🧏‍♂️": ["man", "male"],
+ "🧍‍♀️": ["woman", "female"],
+ "🧍‍♂️": ["man", "male"],
+ "🧎‍♀️": ["woman", "female"],
+ "🧎‍♂️": ["man", "male"],
+ "🧑‍🦯": ["accessibility", "blind"],
+ "👩‍🦯": ["woman", "female", "accessibility", "blind"],
+ "👨‍🦯": ["man", "male", "accessibility", "blind"],
+ "🧑‍🦼": ["accessibility"],
+ "👩‍🦼": ["woman", "female", "accessibility"],
+ "👨‍🦼": ["man", "male", "accessibility"],
+ "🧑‍🦽": ["accessibility"],
+ "👩‍🦽": ["woman", "female", "accessibility"],
+ "👨‍🦽": ["man", "male", "accessibility"],
+ "💑": ["pair", "love", "like", "affection", "human", "dating", "valentines", "marriage"],
+ "👩‍❤️‍👩": ["pair", "love", "like", "affection", "human", "dating", "valentines", "marriage"],
+ "👨‍❤️‍👨": ["pair", "love", "like", "affection", "human", "dating", "valentines", "marriage"],
+ "💏": ["pair", "valentines", "love", "like", "dating", "marriage"],
+ "👩‍❤️‍💋‍👩": ["pair", "valentines", "love", "like", "dating", "marriage"],
+ "👨‍❤️‍💋‍👨": ["pair", "valentines", "love", "like", "dating", "marriage"],
+ "👪": ["home", "parents", "child", "mom", "dad", "father", "mother", "people", "human"],
+ "👨‍👩‍👧": ["home", "parents", "people", "human", "child"],
+ "👨‍👩‍👧‍👦": ["home", "parents", "people", "human", "children"],
+ "👨‍👩‍👦‍👦": ["home", "parents", "people", "human", "children"],
+ "👨‍👩‍👧‍👧": ["home", "parents", "people", "human", "children"],
+ "👩‍👩‍👦": ["home", "parents", "people", "human", "children"],
+ "👩‍👩‍👧": ["home", "parents", "people", "human", "children"],
+ "👩‍👩‍👧‍👦": ["home", "parents", "people", "human", "children"],
+ "👩‍👩‍👦‍👦": ["home", "parents", "people", "human", "children"],
+ "👩‍👩‍👧‍👧": ["home", "parents", "people", "human", "children"],
+ "👨‍👨‍👦": ["home", "parents", "people", "human", "children"],
+ "👨‍👨‍👧": ["home", "parents", "people", "human", "children"],
+ "👨‍👨‍👧‍👦": ["home", "parents", "people", "human", "children"],
+ "👨‍👨‍👦‍👦": ["home", "parents", "people", "human", "children"],
+ "👨‍👨‍👧‍👧": ["home", "parents", "people", "human", "children"],
+ "👩‍👦": ["home", "parent", "people", "human", "child"],
+ "👩‍👧": ["home", "parent", "people", "human", "child"],
+ "👩‍👧‍👦": ["home", "parent", "people", "human", "children"],
+ "👩‍👦‍👦": ["home", "parent", "people", "human", "children"],
+ "👩‍👧‍👧": ["home", "parent", "people", "human", "children"],
+ "👨‍👦": ["home", "parent", "people", "human", "child"],
+ "👨‍👧": ["home", "parent", "people", "human", "child"],
+ "👨‍👧‍👦": ["home", "parent", "people", "human", "children"],
+ "👨‍👦‍👦": ["home", "parent", "people", "human", "children"],
+ "👨‍👧‍👧": ["home", "parent", "people", "human", "children"],
+ "🧶": ["ball", "crochet", "knit"],
+ "🧵": ["needle", "sewing", "spool", "string"],
+ "🧥": ["jacket"],
+ "🥼": ["doctor", "experiment", "scientist", "chemist"],
+ "👚": ["fashion", "shopping_bags", "female"],
+ "👕": ["fashion", "cloth", "casual", "shirt", "tee"],
+ "👖": ["fashion", "shopping"],
+ "👔": ["shirt", "suitup", "formal", "fashion", "cloth", "business"],
+ "👗": ["clothes", "fashion", "shopping"],
+ "👙": ["swimming", "female", "woman", "girl", "fashion", "beach", "summer"],
+ "🩱": ["swimming", "female", "woman", "girl", "fashion", "beach", "summer"],
+ "👘": ["dress", "fashion", "women", "female", "japanese"],
+ "🥻": ["dress", "fashion", "women", "female"],
+ "🩲": ["dress", "fashion"],
+ "🩳": ["dress", "fashion"],
+ "💄": ["female", "girl", "fashion", "woman"],
+ "💋": ["face", "lips", "love", "like", "affection", "valentines"],
+ "👣": ["feet", "tracking", "walking", "beach"],
+ "🥿": ["ballet", "slip-on", "slipper"],
+ "👠": ["fashion", "shoes", "female", "pumps", "stiletto"],
+ "👡": ["shoes", "fashion", "flip flops"],
+ "👢": ["shoes", "fashion"],
+ "👞": ["fashion", "male"],
+ "👟": ["shoes", "sports", "sneakers"],
+ "🩴": [],
+ "🩰": ["shoes", "sports"],
+ "🧦": ["stockings", "clothes"],
+ "🧤": ["hands", "winter", "clothes"],
+ "🧣": ["neck", "winter", "clothes"],
+ "👒": ["fashion", "accessories", "female", "lady", "spring"],
+ "🎩": ["magic", "gentleman", "classy", "circus"],
+ "🧢": ["cap", "baseball"],
+ "⛑": ["construction", "build"],
+ "🪖": [],
+ "🎓": ["school", "college", "degree", "university", "graduation", "cap", "hat", "legal", "learn", "education"],
+ "👑": ["king", "kod", "leader", "royalty", "lord"],
+ "🎒": ["student", "education", "bag", "backpack"],
+ "🧳": ["packing", "travel"],
+ "👝": ["bag", "accessories", "shopping"],
+ "👛": ["fashion", "accessories", "money", "sales", "shopping"],
+ "👜": ["fashion", "accessory", "accessories", "shopping"],
+ "💼": ["business", "documents", "work", "law", "legal", "job", "career"],
+ "👓": ["fashion", "accessories", "eyesight", "nerdy", "dork", "geek"],
+ "🕶": ["face", "cool", "accessories"],
+ "🥽": ["eyes", "protection", "safety"],
+ "💍": ["wedding", "propose", "marriage", "valentines", "diamond", "fashion", "jewelry", "gem", "engagement"],
+ "🌂": ["weather", "rain", "drizzle"],
+ "🐶": ["animal", "friend", "nature", "woof", "puppy", "pet", "faithful"],
+ "🐱": ["animal", "meow", "nature", "pet", "kitten"],
+ "🐈‍⬛": ["animal", "meow", "nature", "pet", "kitten"],
+ "🐭": ["animal", "nature", "cheese_wedge", "rodent"],
+ "🐹": ["animal", "nature"],
+ "🐰": ["animal", "nature", "pet", "spring", "magic", "bunny"],
+ "🦊": ["animal", "nature", "face"],
+ "🐻": ["animal", "nature", "wild"],
+ "🐼": ["animal", "nature", "panda"],
+ "🐨": ["animal", "nature"],
+ "🐯": ["animal", "cat", "danger", "wild", "nature", "roar"],
+ "🦁": ["animal", "nature"],
+ "🐮": ["beef", "ox", "animal", "nature", "moo", "milk"],
+ "🐷": ["animal", "oink", "nature"],
+ "🐽": ["animal", "oink"],
+ "🐸": ["animal", "nature", "croak", "toad"],
+ "🦑": ["animal", "nature", "ocean", "sea"],
+ "🐙": ["animal", "creature", "ocean", "sea", "nature", "beach"],
+ "🦐": ["animal", "ocean", "nature", "seafood"],
+ "🐵": ["animal", "nature", "circus"],
+ "🦍": ["animal", "nature", "circus"],
+ "🙈": ["monkey", "animal", "nature", "haha"],
+ "🙉": ["animal", "monkey", "nature"],
+ "🙊": ["monkey", "animal", "nature", "omg"],
+ "🐒": ["animal", "nature", "banana", "circus"],
+ "🐔": ["animal", "cluck", "nature", "bird"],
+ "🐧": ["animal", "nature"],
+ "🐦": ["animal", "nature", "fly", "tweet", "spring"],
+ "🐤": ["animal", "chicken", "bird"],
+ "🐣": ["animal", "chicken", "egg", "born", "baby", "bird"],
+ "🐥": ["animal", "chicken", "baby", "bird"],
+ "🦆": ["animal", "nature", "bird", "mallard"],
+ "🦅": ["animal", "nature", "bird"],
+ "🦉": ["animal", "nature", "bird", "hoot"],
+ "🦇": ["animal", "nature", "blind", "vampire"],
+ "🐺": ["animal", "nature", "wild"],
+ "🐗": ["animal", "nature"],
+ "🐴": ["animal", "brown", "nature"],
+ "🦄": ["animal", "nature", "mystical"],
+ "🐝": ["animal", "insect", "nature", "bug", "spring", "honey"],
+ "🐛": ["animal", "insect", "nature", "worm"],
+ "🦋": ["animal", "insect", "nature", "caterpillar"],
+ "🐌": ["slow", "animal", "shell"],
+ "🐞": ["animal", "insect", "nature", "ladybug"],
+ "🐜": ["animal", "insect", "nature", "bug"],
+ "🦗": ["animal", "cricket", "chirp"],
+ "🕷": ["animal", "arachnid"],
+ "🪲": ["animal"],
+ "🪳": ["animal"],
+ "🪰": ["animal"],
+ "🪱": ["animal"],
+ "🦂": ["animal", "arachnid"],
+ "🦀": ["animal", "crustacean"],
+ "🐍": ["animal", "evil", "nature", "hiss", "python"],
+ "🦎": ["animal", "nature", "reptile"],
+ "🦖": ["animal", "nature", "dinosaur", "tyrannosaurus", "extinct"],
+ "🦕": ["animal", "nature", "dinosaur", "brachiosaurus", "brontosaurus", "diplodocus", "extinct"],
+ "🐢": ["animal", "slow", "nature", "tortoise"],
+ "🐠": ["animal", "swim", "ocean", "beach", "nemo"],
+ "🐟": ["animal", "food", "nature"],
+ "🐡": ["animal", "nature", "food", "sea", "ocean"],
+ "🐬": ["animal", "nature", "fish", "sea", "ocean", "flipper", "fins", "beach"],
+ "🦈": ["animal", "nature", "fish", "sea", "ocean", "jaws", "fins", "beach"],
+ "🐳": ["animal", "nature", "sea", "ocean"],
+ "🐋": ["animal", "nature", "sea", "ocean"],
+ "🐊": ["animal", "nature", "reptile", "lizard", "alligator"],
+ "🐆": ["animal", "nature"],
+ "🦓": ["animal", "nature", "stripes", "safari"],
+ "🐅": ["animal", "nature", "roar"],
+ "🐃": ["animal", "nature", "ox", "cow"],
+ "🐂": ["animal", "cow", "beef"],
+ "🐄": ["beef", "ox", "animal", "nature", "moo", "milk"],
+ "🦌": ["animal", "nature", "horns", "venison"],
+ "🐪": ["animal", "hot", "desert", "hump"],
+ "🐫": ["animal", "nature", "hot", "desert", "hump"],
+ "🦒": ["animal", "nature", "spots", "safari"],
+ "🐘": ["animal", "nature", "nose", "th", "circus"],
+ "🦏": ["animal", "nature", "horn"],
+ "🐐": ["animal", "nature"],
+ "🐏": ["animal", "sheep", "nature"],
+ "🐑": ["animal", "nature", "wool", "shipit"],
+ "🐎": ["animal", "gamble", "luck"],
+ "🐖": ["animal", "nature"],
+ "🐀": ["animal", "mouse", "rodent"],
+ "🐁": ["animal", "nature", "rodent"],
+ "🐓": ["animal", "nature", "chicken"],
+ "🦃": ["animal", "bird"],
+ "🕊": ["animal", "bird"],
+ "🐕": ["animal", "nature", "friend", "doge", "pet", "faithful"],
+ "🐩": ["dog", "animal", "101", "nature", "pet"],
+ "🐈": ["animal", "meow", "pet", "cats"],
+ "🐇": ["animal", "nature", "pet", "magic", "spring"],
+ "🐿": ["animal", "nature", "rodent", "squirrel"],
+ "🦔": ["animal", "nature", "spiny"],
+ "🦝": ["animal", "nature"],
+ "🦙": ["animal", "nature", "alpaca"],
+ "🦛": ["animal", "nature"],
+ "🦘": ["animal", "nature", "australia", "joey", "hop", "marsupial"],
+ "🦡": ["animal", "nature", "honey"],
+ "🦢": ["animal", "nature", "bird"],
+ "🦚": ["animal", "nature", "peahen", "bird"],
+ "🦜": ["animal", "nature", "bird", "pirate", "talk"],
+ "🦞": ["animal", "nature", "bisque", "claws", "seafood"],
+ "🦠": ["amoeba", "bacteria", "germs"],
+ "🦟": ["animal", "nature", "insect", "malaria"],
+ "🦬": ["animal", "nature"],
+ "🦣": ["animal", "nature"],
+ "🦫": ["animal", "nature"],
+ "🐻‍❄️": ["animal", "nature"],
+ "🦤": ["animal", "nature"],
+ "🪶": ["animal", "nature"],
+ "🦭": ["animal", "nature"],
+ "🐾": ["animal", "tracking", "footprints", "dog", "cat", "pet", "feet"],
+ "🐉": ["animal", "myth", "nature", "chinese", "green"],
+ "🐲": ["animal", "myth", "nature", "chinese", "green"],
+ "🦧": ["animal", "nature"],
+ "🦮": ["animal", "nature"],
+ "🐕‍🦺": ["animal", "nature"],
+ "🦥": ["animal", "nature"],
+ "🦦": ["animal", "nature"],
+ "🦨": ["animal", "nature"],
+ "🦩": ["animal", "nature"],
+ "🌵": ["vegetable", "plant", "nature"],
+ "🎄": ["festival", "vacation", "december", "xmas", "celebration"],
+ "🌲": ["plant", "nature"],
+ "🌳": ["plant", "nature"],
+ "🌴": ["plant", "vegetable", "nature", "summer", "beach", "mojito", "tropical"],
+ "🌱": ["plant", "nature", "grass", "lawn", "spring"],
+ "🌿": ["vegetable", "plant", "medicine", "weed", "grass", "lawn"],
+ "☘": ["vegetable", "plant", "nature", "irish", "clover"],
+ "🍀": ["vegetable", "plant", "nature", "lucky", "irish"],
+ "🎍": ["plant", "nature", "vegetable", "panda", "pine_decoration"],
+ "🎋": ["plant", "nature", "branch", "summer"],
+ "🍃": ["nature", "plant", "tree", "vegetable", "grass", "lawn", "spring"],
+ "🍂": ["nature", "plant", "vegetable", "leaves"],
+ "🍁": ["nature", "plant", "vegetable", "ca", "fall"],
+ "🌾": ["nature", "plant"],
+ "🌺": ["plant", "vegetable", "flowers", "beach"],
+ "🌻": ["nature", "plant", "fall"],
+ "🌹": ["flowers", "valentines", "love", "spring"],
+ "🥀": ["plant", "nature", "flower"],
+ "🌷": ["flowers", "plant", "nature", "summer", "spring"],
+ "🌼": ["nature", "flowers", "yellow"],
+ "🌸": ["nature", "plant", "spring", "flower"],
+ "💐": ["flowers", "nature", "spring"],
+ "🍄": ["plant", "vegetable"],
+ "🪴": ["plant"],
+ "🌰": ["food", "squirrel"],
+ "🎃": ["halloween", "light", "pumpkin", "creepy", "fall"],
+ "🐚": ["nature", "sea", "beach"],
+ "🕸": ["animal", "insect", "arachnid", "silk"],
+ "🌎": ["globe", "world", "USA", "international"],
+ "🌍": ["globe", "world", "international"],
+ "🌏": ["globe", "world", "east", "international"],
+ "🪐": ["saturn"],
+ "🌕": ["nature", "yellow", "twilight", "planet", "space", "night", "evening", "sleep"],
+ "🌖": ["nature", "twilight", "planet", "space", "night", "evening", "sleep", "waxing_gibbous_moon"],
+ "🌗": ["nature", "twilight", "planet", "space", "night", "evening", "sleep"],
+ "🌘": ["nature", "twilight", "planet", "space", "night", "evening", "sleep"],
+ "🌑": ["nature", "twilight", "planet", "space", "night", "evening", "sleep"],
+ "🌒": ["nature", "twilight", "planet", "space", "night", "evening", "sleep"],
+ "🌓": ["nature", "twilight", "planet", "space", "night", "evening", "sleep"],
+ "🌔": ["nature", "night", "sky", "gray", "twilight", "planet", "space", "evening", "sleep"],
+ "🌚": ["nature", "twilight", "planet", "space", "night", "evening", "sleep"],
+ "🌝": ["nature", "twilight", "planet", "space", "night", "evening", "sleep"],
+ "🌛": ["nature", "twilight", "planet", "space", "night", "evening", "sleep"],
+ "🌜": ["nature", "twilight", "planet", "space", "night", "evening", "sleep"],
+ "🌞": ["nature", "morning", "sky"],
+ "🌙": ["night", "sleep", "sky", "evening", "magic"],
+ "⭐": ["night", "yellow"],
+ "🌟": ["night", "sparkle", "awesome", "good", "magic"],
+ "💫": ["star", "sparkle", "shoot", "magic"],
+ "✨": ["stars", "shine", "shiny", "cool", "awesome", "good", "magic"],
+ "☄": ["space"],
+ "☀️": ["weather", "nature", "brightness", "summer", "beach", "spring"],
+ "🌤": ["weather"],
+ "⛅": ["weather", "nature", "cloudy", "morning", "fall", "spring"],
+ "🌥": ["weather"],
+ "🌦": ["weather"],
+ "☁️": ["weather", "sky"],
+ "🌧": ["weather"],
+ "⛈": ["weather", "lightning"],
+ "🌩": ["weather", "thunder"],
+ "⚡": ["thunder", "weather", "lightning bolt", "fast"],
+ "🔥": ["hot", "cook", "flame"],
+ "💥": ["bomb", "explode", "explosion", "collision", "blown"],
+ "❄️": ["winter", "season", "cold", "weather", "christmas", "xmas"],
+ "🌨": ["weather"],
+ "⛄": ["winter", "season", "cold", "weather", "christmas", "xmas", "frozen", "without_snow"],
+ "☃": ["winter", "season", "cold", "weather", "christmas", "xmas", "frozen"],
+ "🌬": ["gust", "air"],
+ "💨": ["wind", "air", "fast", "shoo", "fart", "smoke", "puff"],
+ "🌪": ["weather", "cyclone", "twister"],
+ "🌫": ["weather"],
+ "☂": ["weather", "spring"],
+ "☔": ["rainy", "weather", "spring"],
+ "💧": ["water", "drip", "faucet", "spring"],
+ "💦": ["water", "drip", "oops"],
+ "🌊": ["sea", "water", "wave", "nature", "tsunami", "disaster"],
+ "🪷": [],
+ "🪸": [],
+ "🪹": [],
+ "🪺": [],
+ "🍏": ["fruit", "nature"],
+ "🍎": ["fruit", "mac", "school"],
+ "🍐": ["fruit", "nature", "food"],
+ "🍊": ["food", "fruit", "nature", "orange"],
+ "🍋": ["fruit", "nature"],
+ "🍌": ["fruit", "food", "monkey"],
+ "🍉": ["fruit", "food", "picnic", "summer"],
+ "🍇": ["fruit", "food", "wine"],
+ "🍓": ["fruit", "food", "nature"],
+ "🍈": ["fruit", "nature", "food"],
+ "🍒": ["food", "fruit"],
+ "🍑": ["fruit", "nature", "food"],
+ "🍍": ["fruit", "nature", "food"],
+ "🥥": ["fruit", "nature", "food", "palm"],
+ "🥝": ["fruit", "food"],
+ "🥭": ["fruit", "food", "tropical"],
+ "🥑": ["fruit", "food"],
+ "🥦": ["fruit", "food", "vegetable"],
+ "🍅": ["fruit", "vegetable", "nature", "food"],
+ "🍆": ["vegetable", "nature", "food", "aubergine"],
+ "🥒": ["fruit", "food", "pickle"],
+ "🫐": ["fruit", "food"],
+ "🫒": ["fruit", "food"],
+ "🫑": ["fruit", "food"],
+ "🥕": ["vegetable", "food", "orange"],
+ "🌶": ["food", "spicy", "chilli", "chili"],
+ "🥔": ["food", "tuber", "vegatable", "starch"],
+ "🌽": ["food", "vegetable", "plant"],
+ "🥬": ["food", "vegetable", "plant", "bok choy", "cabbage", "kale", "lettuce"],
+ "🍠": ["food", "nature"],
+ "🥜": ["food", "nut"],
+ "🧄": ["food"],
+ "🧅": ["food"],
+ "🍯": ["bees", "sweet", "kitchen"],
+ "🥐": ["food", "bread", "french"],
+ "🍞": ["food", "wheat", "breakfast", "toast"],
+ "🥖": ["food", "bread", "french"],
+ "🥯": ["food", "bread", "bakery", "schmear"],
+ "🥨": ["food", "bread", "twisted"],
+ "🧀": ["food", "chadder"],
+ "🥚": ["food", "chicken", "breakfast"],
+ "🥓": ["food", "breakfast", "pork", "pig", "meat"],
+ "🥩": ["food", "cow", "meat", "cut", "chop", "lambchop", "porkchop"],
+ "🥞": ["food", "breakfast", "flapjacks", "hotcakes"],
+ "🍗": ["food", "meat", "drumstick", "bird", "chicken", "turkey"],
+ "🍖": ["good", "food", "drumstick"],
+ "🦴": ["skeleton"],
+ "🍤": ["food", "animal", "appetizer", "summer"],
+ "🍳": ["food", "breakfast", "kitchen", "egg"],
+ "🍔": ["meat", "fast food", "beef", "cheeseburger", "mcdonalds", "burger king"],
+ "🍟": ["chips", "snack", "fast food"],
+ "🥙": ["food", "flatbread", "stuffed", "gyro"],
+ "🌭": ["food", "frankfurter"],
+ "🍕": ["food", "party"],
+ "🥪": ["food", "lunch", "bread"],
+ "🥫": ["food", "soup"],
+ "🍝": ["food", "italian", "noodle"],
+ "🌮": ["food", "mexican"],
+ "🌯": ["food", "mexican"],
+ "🥗": ["food", "healthy", "lettuce"],
+ "🥘": ["food", "cooking", "casserole", "paella"],
+ "🍜": ["food", "japanese", "noodle", "chopsticks"],
+ "🍲": ["food", "meat", "soup"],
+ "🍥": ["food", "japan", "sea", "beach", "narutomaki", "pink", "swirl", "kamaboko", "surimi", "ramen"],
+ "🥠": ["food", "prophecy"],
+ "🍣": ["food", "fish", "japanese", "rice"],
+ "🍱": ["food", "japanese", "box"],
+ "🍛": ["food", "spicy", "hot", "indian"],
+ "🍙": ["food", "japanese"],
+ "🍚": ["food", "china", "asian"],
+ "🍘": ["food", "japanese"],
+ "🍢": ["food", "japanese"],
+ "🍡": ["food", "dessert", "sweet", "japanese", "barbecue", "meat"],
+ "🍧": ["hot", "dessert", "summer"],
+ "🍨": ["food", "hot", "dessert"],
+ "🍦": ["food", "hot", "dessert", "summer"],
+ "🥧": ["food", "dessert", "pastry"],
+ "🍰": ["food", "dessert"],
+ "🧁": ["food", "dessert", "bakery", "sweet"],
+ "🥮": ["food", "autumn"],
+ "🎂": ["food", "dessert", "cake"],
+ "🍮": ["dessert", "food"],
+ "🍬": ["snack", "dessert", "sweet", "lolly"],
+ "🍭": ["food", "snack", "candy", "sweet"],
+ "🍫": ["food", "snack", "dessert", "sweet"],
+ "🍿": ["food", "movie theater", "films", "snack"],
+ "🥟": ["food", "empanada", "pierogi", "potsticker"],
+ "🍩": ["food", "dessert", "snack", "sweet", "donut"],
+ "🍪": ["food", "snack", "oreo", "chocolate", "sweet", "dessert"],
+ "🧇": ["food"],
+ "🧆": ["food"],
+ "🧈": ["food"],
+ "🦪": ["food"],
+ "🫓": ["food"],
+ "🫔": ["food"],
+ "🫕": ["food"],
+ "🥛": ["beverage", "drink", "cow"],
+ "🍺": ["relax", "beverage", "drink", "drunk", "party", "pub", "summer", "alcohol", "booze"],
+ "🍻": ["relax", "beverage", "drink", "drunk", "party", "pub", "summer", "alcohol", "booze"],
+ "🥂": ["beverage", "drink", "party", "alcohol", "celebrate", "cheers", "wine", "champagne", "toast"],
+ "🍷": ["drink", "beverage", "drunk", "alcohol", "booze"],
+ "🥃": ["drink", "beverage", "drunk", "alcohol", "liquor", "booze", "bourbon", "scotch", "whisky", "glass", "shot"],
+ "🍸": ["drink", "drunk", "alcohol", "beverage", "booze", "mojito"],
+ "🍹": ["beverage", "cocktail", "summer", "beach", "alcohol", "booze", "mojito"],
+ "🍾": ["drink", "wine", "bottle", "celebration"],
+ "🍶": ["wine", "drink", "drunk", "beverage", "japanese", "alcohol", "booze"],
+ "🍵": ["drink", "bowl", "breakfast", "green", "british"],
+ "🥤": ["drink", "soda"],
+ "☕": ["beverage", "caffeine", "latte", "espresso"],
+ "🫖": [],
+ "🧋": ["tapioca"],
+ "🍼": ["food", "container", "milk"],
+ "🧃": ["food", "drink"],
+ "🧉": ["food", "drink"],
+ "🧊": ["food"],
+ "🧂": ["condiment", "shaker"],
+ "🥄": ["cutlery", "kitchen", "tableware"],
+ "🍴": ["cutlery", "kitchen"],
+ "🍽": ["food", "eat", "meal", "lunch", "dinner", "restaurant"],
+ "🥣": ["food", "breakfast", "cereal", "oatmeal", "porridge"],
+ "🥡": ["food", "leftovers"],
+ "🥢": ["food"],
+ "🫗": [],
+ "🫘": [],
+ "🫙": [],
+ "⚽": ["sports", "football"],
+ "🏀": ["sports", "balls", "NBA"],
+ "🏈": ["sports", "balls", "NFL"],
+ "⚾": ["sports", "balls"],
+ "🥎": ["sports", "balls"],
+ "🎾": ["sports", "balls", "green"],
+ "🏐": ["sports", "balls"],
+ "🏉": ["sports", "team"],
+ "🥏": ["sports", "frisbee", "ultimate"],
+ "🎱": ["pool", "hobby", "game", "luck", "magic"],
+ "⛳": ["sports", "business", "flag", "hole", "summer"],
+ "🏌️‍♀️": ["sports", "business", "woman", "female"],
+ "🏌": ["sports", "business"],
+ "🏓": ["sports", "pingpong"],
+ "🏸": ["sports"],
+ "🥅": ["sports"],
+ "🏒": ["sports"],
+ "🏑": ["sports"],
+ "🥍": ["sports", "ball", "stick"],
+ "🏏": ["sports"],
+ "🎿": ["sports", "winter", "cold", "snow"],
+ "⛷": ["sports", "winter", "snow"],
+ "🏂": ["sports", "winter"],
+ "🤺": ["sports", "fencing", "sword"],
+ "🤼‍♀️": ["sports", "wrestlers"],
+ "🤼‍♂️": ["sports", "wrestlers"],
+ "🤸‍♀️": ["gymnastics"],
+ "🤸‍♂️": ["gymnastics"],
+ "🤾‍♀️": ["sports"],
+ "🤾‍♂️": ["sports"],
+ "⛸": ["sports"],
+ "🥌": ["sports"],
+ "🛹": ["board"],
+ "🛷": ["sleigh", "luge", "toboggan"],
+ "🏹": ["sports"],
+ "🎣": ["food", "hobby", "summer"],
+ "🥊": ["sports", "fighting"],
+ "🥋": ["judo", "karate", "taekwondo"],
+ "🚣‍♀️": ["sports", "hobby", "water", "ship", "woman", "female"],
+ "🚣": ["sports", "hobby", "water", "ship"],
+ "🧗‍♀️": ["sports", "hobby", "woman", "female", "rock"],
+ "🧗‍♂️": ["sports", "hobby", "man", "male", "rock"],
+ "🏊‍♀️": ["sports", "exercise", "human", "athlete", "water", "summer", "woman", "female"],
+ "🏊": ["sports", "exercise", "human", "athlete", "water", "summer"],
+ "🤽‍♀️": ["sports", "pool"],
+ "🤽‍♂️": ["sports", "pool"],
+ "🧘‍♀️": ["woman", "female", "meditation", "yoga", "serenity", "zen", "mindfulness"],
+ "🧘‍♂️": ["man", "male", "meditation", "yoga", "serenity", "zen", "mindfulness"],
+ "🏄‍♀️": ["sports", "ocean", "sea", "summer", "beach", "woman", "female"],
+ "🏄": ["sports", "ocean", "sea", "summer", "beach"],
+ "🛀": ["clean", "shower", "bathroom"],
+ "⛹️‍♀️": ["sports", "human", "woman", "female"],
+ "⛹": ["sports", "human"],
+ "🏋️‍♀️": ["sports", "training", "exercise", "woman", "female"],
+ "🏋": ["sports", "training", "exercise"],
+ "🚴‍♀️": ["sports", "bike", "exercise", "hipster", "woman", "female"],
+ "🚴": ["sports", "bike", "exercise", "hipster"],
+ "🚵‍♀️": ["transportation", "sports", "human", "race", "bike", "woman", "female"],
+ "🚵": ["transportation", "sports", "human", "race", "bike"],
+ "🏇": ["animal", "betting", "competition", "gambling", "luck"],
+ "🤿": ["sports"],
+ "🪀": ["sports"],
+ "🪁": ["sports"],
+ "🦺": ["sports"],
+ "🪡": [],
+ "🪢": [],
+ "🕴": ["suit", "business", "levitate", "hover", "jump"],
+ "🏆": ["win", "award", "contest", "place", "ftw", "ceremony"],
+ "🎽": ["play", "pageant"],
+ "🏅": ["award", "winning"],
+ "🎖": ["award", "winning", "army"],
+ "🥇": ["award", "winning", "first"],
+ "🥈": ["award", "second"],
+ "🥉": ["award", "third"],
+ "🎗": ["sports", "cause", "support", "awareness"],
+ "🏵": ["flower", "decoration", "military"],
+ "🎫": ["event", "concert", "pass"],
+ "🎟": ["sports", "concert", "entrance"],
+ "🎭": ["acting", "theater", "drama"],
+ "🎨": ["design", "paint", "draw", "colors"],
+ "🎪": ["festival", "carnival", "party"],
+ "🤹‍♀️": ["juggle", "balance", "skill", "multitask"],
+ "🤹‍♂️": ["juggle", "balance", "skill", "multitask"],
+ "🎤": ["sound", "music", "PA", "sing", "talkshow"],
+ "🎧": ["music", "score", "gadgets"],
+ "🎼": ["treble", "clef", "compose"],
+ "🎹": ["piano", "instrument", "compose"],
+ "🥁": ["music", "instrument", "drumsticks", "snare"],
+ "🎷": ["music", "instrument", "jazz", "blues"],
+ "🎺": ["music", "brass"],
+ "🎸": ["music", "instrument"],
+ "🎻": ["music", "instrument", "orchestra", "symphony"],
+ "🪕": ["music", "instrument"],
+ "🪗": ["music", "instrument"],
+ "🪘": ["music", "instrument"],
+ "🎬": ["movie", "film", "record"],
+ "🎮": ["play", "console", "PS4", "controller"],
+ "👾": ["game", "arcade", "play"],
+ "🎯": ["game", "play", "bar", "target", "bullseye"],
+ "🎲": ["dice", "random", "tabletop", "play", "luck"],
+ "♟️": ["expendable"],
+ "🎰": ["bet", "gamble", "vegas", "fruit machine", "luck", "casino"],
+ "🧩": ["interlocking", "puzzle", "piece"],
+ "🎳": ["sports", "fun", "play"],
+ "🪄": [],
+ "🪅": [],
+ "🪆": [],
+ "🪬": [],
+ "🪩": [],
+ "🚗": ["red", "transportation", "vehicle"],
+ "🚕": ["uber", "vehicle", "cars", "transportation"],
+ "🚙": ["transportation", "vehicle"],
+ "🚌": ["car", "vehicle", "transportation"],
+ "🚎": ["bart", "transportation", "vehicle"],
+ "🏎": ["sports", "race", "fast", "formula", "f1"],
+ "🚓": ["vehicle", "cars", "transportation", "law", "legal", "enforcement"],
+ "🚑": ["health", "911", "hospital"],
+ "🚒": ["transportation", "cars", "vehicle"],
+ "🚐": ["vehicle", "car", "transportation"],
+ "🚚": ["cars", "transportation"],
+ "🚛": ["vehicle", "cars", "transportation", "express"],
+ "🚜": ["vehicle", "car", "farming", "agriculture"],
+ "🛴": ["vehicle", "kick", "razor"],
+ "🏍": ["race", "sports", "fast"],
+ "🚲": ["sports", "bicycle", "exercise", "hipster"],
+ "🛵": ["vehicle", "vespa", "sasha"],
+ "🦽": ["vehicle"],
+ "🦼": ["vehicle"],
+ "🛺": ["vehicle"],
+ "🪂": ["vehicle"],
+ "🚨": ["police", "ambulance", "911", "emergency", "alert", "error", "pinged", "law", "legal"],
+ "🚔": ["vehicle", "law", "legal", "enforcement", "911"],
+ "🚍": ["vehicle", "transportation"],
+ "🚘": ["car", "vehicle", "transportation"],
+ "🚖": ["vehicle", "cars", "uber"],
+ "🚡": ["transportation", "vehicle", "ski"],
+ "🚠": ["transportation", "vehicle", "ski"],
+ "🚟": ["vehicle", "transportation"],
+ "🚃": ["transportation", "vehicle", "train"],
+ "🚋": ["transportation", "vehicle", "carriage", "public", "travel"],
+ "🚝": ["transportation", "vehicle"],
+ "🚄": ["transportation", "vehicle"],
+ "🚅": ["transportation", "vehicle", "speed", "fast", "public", "travel"],
+ "🚈": ["transportation", "vehicle"],
+ "🚞": ["transportation", "vehicle"],
+ "🚂": ["transportation", "vehicle", "train"],
+ "🚆": ["transportation", "vehicle"],
+ "🚇": ["transportation", "blue-square", "mrt", "underground", "tube"],
+ "🚊": ["transportation", "vehicle"],
+ "🚉": ["transportation", "vehicle", "public"],
+ "🛸": ["transportation", "vehicle", "ufo"],
+ "🚁": ["transportation", "vehicle", "fly"],
+ "🛩": ["flight", "transportation", "fly", "vehicle"],
+ "✈️": ["vehicle", "transportation", "flight", "fly"],
+ "🛫": ["airport", "flight", "landing"],
+ "🛬": ["airport", "flight", "boarding"],
+ "⛵": ["ship", "summer", "transportation", "water", "sailing"],
+ "🛥": ["ship"],
+ "🚤": ["ship", "transportation", "vehicle", "summer"],
+ "⛴": ["boat", "ship", "yacht"],
+ "🛳": ["yacht", "cruise", "ferry"],
+ "🚀": ["launch", "ship", "staffmode", "NASA", "outer space", "outer_space", "fly"],
+ "🛰": ["communication", "gps", "orbit", "spaceflight", "NASA", "ISS"],
+ "🛻": ["car"],
+ "🛼": [],
+ "💺": ["sit", "airplane", "transport", "bus", "flight", "fly"],
+ "🛶": ["boat", "paddle", "water", "ship"],
+ "⚓": ["ship", "ferry", "sea", "boat"],
+ "🚧": ["wip", "progress", "caution", "warning"],
+ "⛽": ["gas station", "petroleum"],
+ "🚏": ["transportation", "wait"],
+ "🚦": ["transportation", "driving"],
+ "🚥": ["transportation", "signal"],
+ "🏁": ["contest", "finishline", "race", "gokart"],
+ "🚢": ["transportation", "titanic", "deploy"],
+ "🎡": ["photo", "carnival", "londoneye"],
+ "🎢": ["carnival", "playground", "photo", "fun"],
+ "🎠": ["photo", "carnival"],
+ "🏗": ["wip", "working", "progress"],
+ "🌁": ["photo", "mountain"],
+ "🏭": ["building", "industry", "pollution", "smoke"],
+ "⛲": ["photo", "summer", "water", "fresh"],
+ "🎑": ["photo", "japan", "asia", "tsukimi"],
+ "⛰": ["photo", "nature", "environment"],
+ "🏔": ["photo", "nature", "environment", "winter", "cold"],
+ "🗻": ["photo", "mountain", "nature", "japanese"],
+ "🌋": ["photo", "nature", "disaster"],
+ "🗾": ["nation", "country", "japanese", "asia"],
+ "🏕": ["photo", "outdoors", "tent"],
+ "⛺": ["photo", "camping", "outdoors"],
+ "🏞": ["photo", "environment", "nature"],
+ "🛣": ["road", "cupertino", "interstate", "highway"],
+ "🛤": ["train", "transportation"],
+ "🌅": ["morning", "view", "vacation", "photo"],
+ "🌄": ["view", "vacation", "photo"],
+ "🏜": ["photo", "warm", "saharah"],
+ "🏖": ["weather", "summer", "sunny", "sand", "mojito"],
+ "🏝": ["photo", "tropical", "mojito"],
+ "🌇": ["photo", "good morning", "dawn"],
+ "🌆": ["photo", "evening", "sky", "buildings"],
+ "🏙": ["photo", "night life", "urban"],
+ "🌃": ["evening", "city", "downtown"],
+ "🌉": ["photo", "sanfrancisco"],
+ "🌌": ["photo", "space", "stars"],
+ "🌠": ["night", "photo"],
+ "🎇": ["stars", "night", "shine"],
+ "🎆": ["photo", "festival", "carnival", "congratulations"],
+ "🌈": ["nature", "happy", "unicorn_face", "photo", "sky", "spring"],
+ "🏘": ["buildings", "photo"],
+ "🏰": ["building", "royalty", "history"],
+ "🏯": ["photo", "building"],
+ "🗼": ["photo", "japanese"],
+ "": ["photo", "japanese"],
+ "🏟": ["photo", "place", "sports", "concert", "venue"],
+ "🗽": ["american", "newyork"],
+ "🏠": ["building", "home"],
+ "🏡": ["home", "plant", "nature"],
+ "🏚": ["abandon", "evict", "broken", "building"],
+ "🏢": ["building", "bureau", "work"],
+ "🏬": ["building", "shopping", "mall"],
+ "🏣": ["building", "envelope", "communication"],
+ "🏤": ["building", "email"],
+ "🏥": ["building", "health", "surgery", "doctor"],
+ "🏦": ["building", "money", "sales", "cash", "business", "enterprise"],
+ "🏨": ["building", "accomodation", "checkin"],
+ "🏪": ["building", "shopping", "groceries"],
+ "🏫": ["building", "student", "education", "learn", "teach"],
+ "🏩": ["like", "affection", "dating"],
+ "💒": ["love", "like", "affection", "couple", "marriage", "bride", "groom"],
+ "🏛": ["art", "culture", "history"],
+ "⛪": ["building", "religion", "christ"],
+ "🕌": ["islam", "worship", "minaret"],
+ "🕍": ["judaism", "worship", "temple", "jewish"],
+ "🕋": ["mecca", "mosque", "islam"],
+ "⛩": ["temple", "japan", "kyoto"],
+ "🛕": ["temple"],
+ "🪨": [],
+ "🪵": [],
+ "🛖": [],
+ "🛝": [],
+ "🛞": [],
+ "🛟": [],
+ "⌚": ["time", "accessories"],
+ "📱": ["technology", "apple", "gadgets", "dial"],
+ "📲": ["iphone", "incoming"],
+ "💻": ["technology", "laptop", "screen", "display", "monitor"],
+ "⌨": ["technology", "computer", "type", "input", "text"],
+ "🖥": ["technology", "computing", "screen"],
+ "🖨": ["paper", "ink"],
+ "🖱": ["click"],
+ "🖲": ["technology", "trackpad"],
+ "🕹": ["game", "play"],
+ "🗜": ["tool"],
+ "💽": ["technology", "record", "data", "disk", "90s"],
+ "💾": ["oldschool", "technology", "save", "90s", "80s"],
+ "💿": ["technology", "dvd", "disk", "disc", "90s"],
+ "📀": ["cd", "disk", "disc"],
+ "📼": ["record", "video", "oldschool", "90s", "80s"],
+ "📷": ["gadgets", "photography"],
+ "📸": ["photography", "gadgets"],
+ "📹": ["film", "record"],
+ "🎥": ["film", "record"],
+ "📽": ["video", "tape", "record", "movie"],
+ "🎞": ["movie"],
+ "📞": ["technology", "communication", "dial"],
+ "☎️": ["technology", "communication", "dial", "telephone"],
+ "📟": ["bbcall", "oldschool", "90s"],
+ "📠": ["communication", "technology"],
+ "📺": ["technology", "program", "oldschool", "show", "television"],
+ "📻": ["communication", "music", "podcast", "program"],
+ "🎙": ["sing", "recording", "artist", "talkshow"],
+ "🎚": ["scale"],
+ "🎛": ["dial"],
+ "🧭": ["magnetic", "navigation", "orienteering"],
+ "⏱": ["time", "deadline"],
+ "⏲": ["alarm"],
+ "⏰": ["time", "wake"],
+ "🕰": ["time"],
+ "⏳": ["oldschool", "time", "countdown"],
+ "⌛": ["time", "clock", "oldschool", "limit", "exam", "quiz", "test"],
+ "📡": ["communication", "future", "radio", "space"],
+ "🔋": ["power", "energy", "sustain"],
+ "🪫": [],
+ "🔌": ["charger", "power"],
+ "💡": ["light", "electricity", "idea"],
+ "🔦": ["dark", "camping", "sight", "night"],
+ "🕯": ["fire", "wax"],
+ "🧯": ["quench"],
+ "🗑": ["bin", "trash", "rubbish", "garbage", "toss"],
+ "🛢": ["barrell"],
+ "💸": ["dollar", "bills", "payment", "sale"],
+ "💵": ["money", "sales", "bill", "currency"],
+ "💴": ["money", "sales", "japanese", "dollar", "currency"],
+ "💶": ["money", "sales", "dollar", "currency"],
+ "💷": ["british", "sterling", "money", "sales", "bills", "uk", "england", "currency"],
+ "💰": ["dollar", "payment", "coins", "sale"],
+ "🪙": ["dollar", "payment", "coins", "sale"],
+ "💳": ["money", "sales", "dollar", "bill", "payment", "shopping"],
+ "🪫": [],
+ "💎": ["blue", "ruby", "diamond", "jewelry"],
+ "⚖": ["law", "fairness", "weight"],
+ "🧰": ["tools", "diy", "fix", "maintainer", "mechanic"],
+ "🔧": ["tools", "diy", "ikea", "fix", "maintainer"],
+ "🔨": ["tools", "build", "create"],
+ "⚒": ["tools", "build", "create"],
+ "🛠": ["tools", "build", "create"],
+ "⛏": ["tools", "dig"],
+ "🪓": ["tools"],
+ "🦯": ["tools"],
+ "🔩": ["handy", "tools", "fix"],
+ "⚙": ["cog"],
+ "🪃": ["tool"],
+ "🪚": ["tool"],
+ "🪛": ["tool"],
+ "🪝": ["tool"],
+ "🪜": ["tool"],
+ "🧱": ["bricks"],
+ "⛓": ["lock", "arrest"],
+ "🧲": ["attraction", "magnetic"],
+ "🔫": ["violence", "weapon", "pistol", "revolver"],
+ "💣": ["boom", "explode", "explosion", "terrorism"],
+ "🧨": ["dynamite", "boom", "explode", "explosion", "explosive"],
+ "🔪": ["knife", "blade", "cutlery", "kitchen", "weapon"],
+ "🗡": ["weapon"],
+ "⚔": ["weapon"],
+ "🛡": ["protection", "security"],
+ "🚬": ["kills", "tobacco", "cigarette", "joint", "smoke"],
+ "☠": ["poison", "danger", "deadly", "scary", "death", "pirate", "evil"],
+ "⚰": ["vampire", "dead", "die", "death", "rip", "graveyard", "cemetery", "casket", "funeral", "box"],
+ "⚱": ["dead", "die", "death", "rip", "ashes"],
+ "🏺": ["vase", "jar"],
+ "🔮": ["disco", "party", "magic", "circus", "fortune_teller"],
+ "📿": ["dhikr", "religious"],
+ "🧿": ["bead", "charm"],
+ "💈": ["hair", "salon", "style"],
+ "⚗": ["distilling", "science", "experiment", "chemistry"],
+ "🔭": ["stars", "space", "zoom", "science", "astronomy"],
+ "🔬": ["laboratory", "experiment", "zoomin", "science", "study"],
+ "🕳": ["embarrassing"],
+ "💊": ["health", "medicine", "doctor", "pharmacy", "drug"],
+ "💉": ["health", "hospital", "drugs", "blood", "medicine", "needle", "doctor", "nurse"],
+ "🩸": ["health", "hospital", "medicine", "needle", "doctor", "nurse"],
+ "🩹": ["health", "hospital", "medicine", "needle", "doctor", "nurse"],
+ "🩺": ["health", "hospital", "medicine", "needle", "doctor", "nurse"],
+ "🪒": ["health"],
+ "🩻": [],
+ "🩼": [],
+ "🧬": ["biologist", "genetics", "life"],
+ "🧫": ["bacteria", "biology", "culture", "lab"],
+ "🧪": ["chemistry", "experiment", "lab", "science"],
+ "🌡": ["weather", "temperature", "hot", "cold"],
+ "🧹": ["cleaning", "sweeping", "witch"],
+ "🧺": ["laundry"],
+ "🧻": ["roll"],
+ "🏷": ["sale", "tag"],
+ "🔖": ["favorite", "label", "save"],
+ "🚽": ["restroom", "wc", "washroom", "bathroom", "potty"],
+ "🚿": ["clean", "water", "bathroom"],
+ "🛁": ["clean", "shower", "bathroom"],
+ "🧼": ["bar", "bathing", "cleaning", "lather"],
+ "🧽": ["absorbing", "cleaning", "porous"],
+ "🧴": ["moisturizer", "sunscreen"],
+ "🔑": ["lock", "door", "password"],
+ "🗝": ["lock", "door", "password"],
+ "🛋": ["read", "chill"],
+ "🪔": ["light", "oil"],
+ "🛌": ["bed", "rest"],
+ "🛏": ["sleep", "rest"],
+ "🚪": ["house", "entry", "exit"],
+ "🪑": ["house", "desk"],
+ "🛎": ["service"],
+ "🧸": ["plush", "stuffed"],
+ "🖼": ["photography"],
+ "🗺": ["location", "direction"],
+ "🛗": ["household"],
+ "🪞": ["household"],
+ "🪟": ["household"],
+ "🪠": ["household"],
+ "🪤": ["household"],
+ "🪣": ["household"],
+ "🪥": ["household"],
+ "🫧": [],
+ "⛱": ["weather", "summer"],
+ "🗿": ["rock", "easter island", "moai"],
+ "🛍": ["mall", "buy", "purchase"],
+ "🛒": ["trolley"],
+ "🎈": ["party", "celebration", "birthday", "circus"],
+ "🎏": ["fish", "japanese", "koinobori", "carp", "banner"],
+ "🎀": ["decoration", "pink", "girl", "bowtie"],
+ "🎁": ["present", "birthday", "christmas", "xmas"],
+ "🎊": ["festival", "party", "birthday", "circus"],
+ "🎉": ["party", "congratulations", "birthday", "magic", "circus", "celebration"],
+ "🎎": ["japanese", "toy", "kimono"],
+ "🎐": ["nature", "ding", "spring", "bell"],
+ "🎌": ["japanese", "nation", "country", "border"],
+ "🏮": ["light", "paper", "halloween", "spooky"],
+ "🧧": ["gift"],
+ "✉️": ["letter", "postal", "inbox", "communication"],
+ "📩": ["email", "communication"],
+ "📨": ["email", "inbox"],
+ "📧": ["communication", "inbox"],
+ "💌": ["email", "like", "affection", "envelope", "valentines"],
+ "📮": ["email", "letter", "envelope"],
+ "📪": ["email", "communication", "inbox"],
+ "📫": ["email", "inbox", "communication"],
+ "📬": ["email", "inbox", "communication"],
+ "📭": ["email", "inbox"],
+ "📦": ["mail", "gift", "cardboard", "box", "moving"],
+ "📯": ["instrument", "music"],
+ "📥": ["email", "documents"],
+ "📤": ["inbox", "email"],
+ "📜": ["documents", "ancient", "history", "paper"],
+ "📃": ["documents", "office", "paper"],
+ "📑": ["favorite", "save", "order", "tidy"],
+ "🧾": ["accounting", "expenses"],
+ "📊": ["graph", "presentation", "stats"],
+ "📈": ["graph", "presentation", "stats", "recovery", "business", "economics", "money", "sales", "good", "success"],
+ "📉": ["graph", "presentation", "stats", "recession", "business", "economics", "money", "sales", "bad", "failure"],
+ "📄": ["documents", "office", "paper", "information"],
+ "📅": ["calendar", "schedule"],
+ "📆": ["schedule", "date", "planning"],
+ "🗓": ["date", "schedule", "planning"],
+ "📇": ["business", "stationery"],
+ "🗃": ["business", "stationery"],
+ "🗳": ["election", "vote"],
+ "🗄": ["filing", "organizing"],
+ "📋": ["stationery", "documents"],
+ "🗒": ["memo", "stationery"],
+ "📁": ["documents", "business", "office"],
+ "📂": ["documents", "load"],
+ "🗂": ["organizing", "business", "stationery"],
+ "🗞": ["press", "headline"],
+ "📰": ["press", "headline"],
+ "📓": ["stationery", "record", "notes", "paper", "study"],
+ "📕": ["read", "library", "knowledge", "textbook", "learn"],
+ "📗": ["read", "library", "knowledge", "study"],
+ "📘": ["read", "library", "knowledge", "learn", "study"],
+ "📙": ["read", "library", "knowledge", "textbook", "study"],
+ "📔": ["classroom", "notes", "record", "paper", "study"],
+ "📒": ["notes", "paper"],
+ "📚": ["literature", "library", "study"],
+ "📖": ["book", "read", "library", "knowledge", "literature", "learn", "study"],
+ "🧷": ["diaper"],
+ "🔗": ["rings", "url"],
+ "📎": ["documents", "stationery"],
+ "🖇": ["documents", "stationery"],
+ "✂️": ["stationery", "cut"],
+ "📐": ["stationery", "math", "architect", "sketch"],
+ "📏": ["stationery", "calculate", "length", "math", "school", "drawing", "architect", "sketch"],
+ "🧮": ["calculation"],
+ "📌": ["stationery", "mark", "here"],
+ "📍": ["stationery", "location", "map", "here"],
+ "🚩": ["mark", "milestone", "place"],
+ "🏳": ["losing", "loser", "lost", "surrender", "give up", "fail"],
+ "🏴": ["pirate"],
+ "🏳️‍🌈": ["flag", "rainbow", "pride", "gay", "lgbt", "glbt", "queer", "homosexual", "lesbian", "bisexual", "transgender"],
+ "🏳️‍⚧️": ["flag", "transgender"],
+ "🔐": ["security", "privacy"],
+ "🔒": ["security", "password", "padlock"],
+ "🔓": ["privacy", "security"],
+ "🔏": ["security", "secret"],
+ "🖊": ["stationery", "writing", "write"],
+ "🖋": ["stationery", "writing", "write"],
+ "✒️": ["pen", "stationery", "writing", "write"],
+ "📝": ["write", "documents", "stationery", "pencil", "paper", "writing", "legal", "exam", "quiz", "test", "study", "compose"],
+ "✏️": ["stationery", "write", "paper", "writing", "school", "study"],
+ "🖍": ["drawing", "creativity"],
+ "🖌": ["drawing", "creativity", "art"],
+ "🔍": ["search", "zoom", "find", "detective"],
+ "🔎": ["search", "zoom", "find", "detective"],
+ "🪦": [],
+ "🪧": [],
+ "💯": ["score", "perfect", "numbers", "century", "exam", "quiz", "test", "pass", "hundred"],
+ "🔢": ["numbers", "blue-square"],
+ "❤️": ["love", "like", "affection", "valentines"],
+ "🧡": ["love", "like", "affection", "valentines"],
+ "💛": ["love", "like", "affection", "valentines"],
+ "💚": ["love", "like", "affection", "valentines"],
+ "💙": ["love", "like", "affection", "valentines"],
+ "💜": ["love", "like", "affection", "valentines"],
+ "🤎": ["love", "like", "affection", "valentines"],
+ "🖤": ["love", "like", "affection", "valentines"],
+ "🤍": ["love", "like", "affection", "valentines"],
+ "💔": ["sad", "sorry", "break", "heart", "heartbreak"],
+ "❣": ["decoration", "love"],
+ "💕": ["love", "like", "affection", "valentines", "heart"],
+ "💞": ["love", "like", "affection", "valentines"],
+ "💓": ["love", "like", "affection", "valentines", "pink", "heart"],
+ "💗": ["like", "love", "affection", "valentines", "pink"],
+ "💖": ["love", "like", "affection", "valentines"],
+ "💘": ["love", "like", "heart", "affection", "valentines"],
+ "💝": ["love", "valentines"],
+ "💟": ["purple-square", "love", "like"],
+ "❤️‍🔥": [],
+ "❤️‍🩹": [],
+ "☮": ["hippie"],
+ "✝": ["christianity"],
+ "☪": ["islam"],
+ "🕉": ["hinduism", "buddhism", "sikhism", "jainism"],
+ "☸": ["hinduism", "buddhism", "sikhism", "jainism"],
+ "✡": ["judaism"],
+ "🔯": ["purple-square", "religion", "jewish", "hexagram"],
+ "🕎": ["hanukkah", "candles", "jewish"],
+ "☯": ["balance"],
+ "☦": ["suppedaneum", "religion"],
+ "🛐": ["religion", "church", "temple", "prayer"],
+ "⛎": ["sign", "purple-square", "constellation", "astrology"],
+ "♈": ["sign", "purple-square", "zodiac", "astrology"],
+ "♉": ["purple-square", "sign", "zodiac", "astrology"],
+ "♊": ["sign", "zodiac", "purple-square", "astrology"],
+ "♋": ["sign", "zodiac", "purple-square", "astrology"],
+ "♌": ["sign", "purple-square", "zodiac", "astrology"],
+ "♍": ["sign", "zodiac", "purple-square", "astrology"],
+ "♎": ["sign", "purple-square", "zodiac", "astrology"],
+ "♏": ["sign", "zodiac", "purple-square", "astrology", "scorpio"],
+ "♐": ["sign", "zodiac", "purple-square", "astrology"],
+ "♑": ["sign", "zodiac", "purple-square", "astrology"],
+ "♒": ["sign", "purple-square", "zodiac", "astrology"],
+ "♓": ["purple-square", "sign", "zodiac", "astrology"],
+ "🆔": ["purple-square", "words"],
+ "⚛": ["science", "physics", "chemistry"],
+ "⚧️": ["purple-square", "woman", "female", "toilet", "loo", "restroom", "gender"],
+ "🈳": ["kanji", "japanese", "chinese", "empty", "sky", "blue-square", "aki"],
+ "🈹": ["cut", "divide", "chinese", "kanji", "pink-square", "waribiki"],
+ "☢": ["nuclear", "danger"],
+ "☣": ["danger"],
+ "📴": ["mute", "orange-square", "silence", "quiet"],
+ "📳": ["orange-square", "phone"],
+ "🈶": ["orange-square", "chinese", "have", "kanji", "ari"],
+ "🈚": ["nothing", "chinese", "kanji", "japanese", "orange-square", "nashi"],
+ "🈸": ["chinese", "japanese", "kanji", "orange-square", "moushikomi"],
+ "🈺": ["japanese", "opening hours", "orange-square", "eigyo"],
+ "🈷️": ["chinese", "month", "moon", "japanese", "orange-square", "kanji", "tsuki", "tsukigime", "getsugaku"],
+ "✴️": ["orange-square", "shape", "polygon"],
+ "🆚": ["words", "orange-square"],
+ "🉑": ["ok", "good", "chinese", "kanji", "agree", "yes", "orange-circle"],
+ "💮": ["japanese", "spring"],
+ "🉐": ["chinese", "kanji", "obtain", "get", "circle"],
+ "㊙️": ["privacy", "chinese", "sshh", "kanji", "red-circle"],
+ "㊗️": ["chinese", "kanji", "japanese", "red-circle"],
+ "🈴": ["japanese", "chinese", "join", "kanji", "red-square", "goukaku", "pass"],
+ "🈵": ["full", "chinese", "japanese", "red-square", "kanji", "man"],
+ "🈲": ["kanji", "japanese", "chinese", "forbidden", "limit", "restricted", "red-square", "kinshi"],
+ "🅰️": ["red-square", "alphabet", "letter"],
+ "🅱️": ["red-square", "alphabet", "letter"],
+ "🆎": ["red-square", "alphabet"],
+ "🆑": ["alphabet", "words", "red-square"],
+ "🅾️": ["alphabet", "red-square", "letter"],
+ "🆘": ["help", "red-square", "words", "emergency", "911"],
+ "⛔": ["limit", "security", "privacy", "bad", "denied", "stop", "circle"],
+ "📛": ["fire", "forbid"],
+ "🚫": ["forbid", "stop", "limit", "denied", "disallow", "circle"],
+ "❌": ["no", "delete", "remove", "cancel", "red"],
+ "⭕": ["circle", "round"],
+ "🛑": ["stop"],
+ "💢": ["angry", "mad"],
+ "♨️": ["bath", "warm", "relax"],
+ "🚷": ["rules", "crossing", "walking", "circle"],
+ "🚯": ["trash", "bin", "garbage", "circle"],
+ "🚳": ["cyclist", "prohibited", "circle"],
+ "🚱": ["drink", "faucet", "tap", "circle"],
+ "🔞": ["18", "drink", "pub", "night", "minor", "circle"],
+ "📵": ["iphone", "mute", "circle"],
+ "❗": ["heavy_exclamation_mark", "danger", "surprise", "punctuation", "wow", "warning"],
+ "❕": ["surprise", "punctuation", "gray", "wow", "warning"],
+ "❓": ["doubt", "confused"],
+ "❔": ["doubts", "gray", "huh", "confused"],
+ "‼️": ["exclamation", "surprise"],
+ "⁉️": ["wat", "punctuation", "surprise"],
+ "🔅": ["sun", "afternoon", "warm", "summer"],
+ "🔆": ["sun", "light"],
+ "🔱": ["weapon", "spear"],
+ "⚜": ["decorative", "scout"],
+ "〽️": ["graph", "presentation", "stats", "business", "economics", "bad"],
+ "⚠️": ["exclamation", "wip", "alert", "error", "problem", "issue"],
+ "🚸": ["school", "warning", "danger", "sign", "driving", "yellow-diamond"],
+ "🔰": ["badge", "shield"],
+ "♻️": ["arrow", "environment", "garbage", "trash"],
+ "🈯": ["chinese", "point", "green-square", "kanji", "reserved", "shiteiseki"],
+ "💹": ["green-square", "graph", "presentation", "stats"],
+ "❇️": ["stars", "green-square", "awesome", "good", "fireworks"],
+ "✳️": ["star", "sparkle", "green-square"],
+ "❎": ["x", "green-square", "no", "deny"],
+ "✅": ["green-square", "ok", "agree", "vote", "election", "answer", "tick"],
+ "💠": ["jewel", "blue", "gem", "crystal", "fancy"],
+ "🌀": ["weather", "swirl", "blue", "cloud", "vortex", "spiral", "whirlpool", "spin", "tornado", "hurricane", "typhoon"],
+ "➿": ["tape", "cassette"],
+ "🌐": ["earth", "international", "world", "internet", "interweb", "i18n"],
+ "Ⓜ️": ["alphabet", "blue-circle", "letter"],
+ "🏧": ["money", "sales", "cash", "blue-square", "payment", "bank"],
+ "🈂️": ["japanese", "blue-square", "katakana"],
+ "🛂": ["custom", "blue-square"],
+ "🛃": ["passport", "border", "blue-square"],
+ "🛄": ["blue-square", "airport", "transport"],
+ "🛅": ["blue-square", "travel"],
+ "♿": ["blue-square", "disabled", "a11y", "accessibility"],
+ "🚭": ["cigarette", "blue-square", "smell", "smoke"],
+ "🚾": ["toilet", "restroom", "blue-square"],
+ "🅿️": ["cars", "blue-square", "alphabet", "letter"],
+ "🚰": ["blue-square", "liquid", "restroom", "cleaning", "faucet"],
+ "🚹": ["toilet", "restroom", "wc", "blue-square", "gender", "male"],
+ "🚺": ["purple-square", "woman", "female", "toilet", "loo", "restroom", "gender"],
+ "🚼": ["orange-square", "child"],
+ "🚻": ["blue-square", "toilet", "refresh", "wc", "gender"],
+ "🚮": ["blue-square", "sign", "human", "info"],
+ "🎦": ["blue-square", "record", "film", "movie", "curtain", "stage", "theater"],
+ "📶": ["blue-square", "reception", "phone", "internet", "connection", "wifi", "bluetooth", "bars"],
+ "🈁": ["blue-square", "here", "katakana", "japanese", "destination"],
+ "🆖": ["blue-square", "words", "shape", "icon"],
+ "🆗": ["good", "agree", "yes", "blue-square"],
+ "🆙": ["blue-square", "above", "high"],
+ "🆒": ["words", "blue-square"],
+ "🆕": ["blue-square", "words", "start"],
+ "🆓": ["blue-square", "words"],
+ "0️⃣": ["0", "numbers", "blue-square", "null"],
+ "1️⃣": ["blue-square", "numbers", "1"],
+ "2️⃣": ["numbers", "2", "prime", "blue-square"],
+ "3️⃣": ["3", "numbers", "prime", "blue-square"],
+ "4️⃣": ["4", "numbers", "blue-square"],
+ "5️⃣": ["5", "numbers", "blue-square", "prime"],
+ "6️⃣": ["6", "numbers", "blue-square"],
+ "7️⃣": ["7", "numbers", "blue-square", "prime"],
+ "8️⃣": ["8", "blue-square", "numbers"],
+ "9️⃣": ["blue-square", "numbers", "9"],
+ "🔟": ["numbers", "10", "blue-square"],
+ "*⃣": ["star", "keycap"],
+ "⏏️": ["blue-square"],
+ "▶️": ["blue-square", "right", "direction", "play"],
+ "⏸": ["pause", "blue-square"],
+ "⏭": ["forward", "next", "blue-square"],
+ "⏹": ["blue-square"],
+ "⏺": ["blue-square"],
+ "⏯": ["blue-square", "play", "pause"],
+ "⏮": ["backward"],
+ "⏩": ["blue-square", "play", "speed", "continue"],
+ "⏪": ["play", "blue-square"],
+ "🔀": ["blue-square", "shuffle", "music", "random"],
+ "🔁": ["loop", "record"],
+ "🔂": ["blue-square", "loop"],
+ "◀️": ["blue-square", "left", "direction"],
+ "🔼": ["blue-square", "triangle", "direction", "point", "forward", "top"],
+ "🔽": ["blue-square", "direction", "bottom"],
+ "⏫": ["blue-square", "direction", "top"],
+ "⏬": ["blue-square", "direction", "bottom"],
+ "➡️": ["blue-square", "next"],
+ "⬅️": ["blue-square", "previous", "back"],
+ "⬆️": ["blue-square", "continue", "top", "direction"],
+ "⬇️": ["blue-square", "direction", "bottom"],
+ "↗️": ["blue-square", "point", "direction", "diagonal", "northeast"],
+ "↘️": ["blue-square", "direction", "diagonal", "southeast"],
+ "↙️": ["blue-square", "direction", "diagonal", "southwest"],
+ "↖️": ["blue-square", "point", "direction", "diagonal", "northwest"],
+ "↕️": ["blue-square", "direction", "way", "vertical"],
+ "↔️": ["shape", "direction", "horizontal", "sideways"],
+ "🔄": ["blue-square", "sync", "cycle"],
+ "↪️": ["blue-square", "return", "rotate", "direction"],
+ "↩️": ["back", "return", "blue-square", "undo", "enter"],
+ "⤴️": ["blue-square", "direction", "top"],
+ "⤵️": ["blue-square", "direction", "bottom"],
+ "#️⃣": ["symbol", "blue-square", "twitter"],
+ "ℹ️": ["blue-square", "alphabet", "letter"],
+ "🔤": ["blue-square", "alphabet"],
+ "🔡": ["blue-square", "alphabet"],
+ "🔠": ["alphabet", "words", "blue-square"],
+ "🔣": ["blue-square", "music", "note", "ampersand", "percent", "glyphs", "characters"],
+ "🎵": ["score", "tone", "sound"],
+ "🎶": ["music", "score"],
+ "〰️": ["draw", "line", "moustache", "mustache", "squiggle", "scribble"],
+ "➰": ["scribble", "draw", "shape", "squiggle"],
+ "✔️": ["ok", "nike", "answer", "yes", "tick"],
+ "🔃": ["sync", "cycle", "round", "repeat"],
+ "➕": ["math", "calculation", "addition", "more", "increase"],
+ "➖": ["math", "calculation", "subtract", "less"],
+ "➗": ["divide", "math", "calculation"],
+ "✖️": ["math", "calculation"],
+ "🟰": [],
+ "♾": ["forever"],
+ "💲": ["money", "sales", "payment", "currency", "buck"],
+ "💱": ["money", "sales", "dollar", "travel"],
+ "©️": ["ip", "license", "circle", "law", "legal"],
+ "®️": ["alphabet", "circle"],
+ "™️": ["trademark", "brand", "law", "legal"],
+ "🔚": ["words", "arrow"],
+ "🔙": ["arrow", "words", "return"],
+ "🔛": ["arrow", "words"],
+ "🔝": ["words", "blue-square"],
+ "🔜": ["arrow", "words"],
+ "☑️": ["ok", "agree", "confirm", "black-square", "vote", "election", "yes", "tick"],
+ "🔘": ["input", "old", "music", "circle"],
+ "⚫": ["shape", "button", "round"],
+ "⚪": ["shape", "round"],
+ "🔴": ["shape", "error", "danger"],
+ "🟠": ["shape"],
+ "🟡": ["shape"],
+ "🟢": ["shape"],
+ "🔵": ["shape", "icon", "button"],
+ "🟣": ["shape"],
+ "🟤": ["shape"],
+ "🔸": ["shape", "jewel", "gem"],
+ "🔹": ["shape", "jewel", "gem"],
+ "🔶": ["shape", "jewel", "gem"],
+ "🔷": ["shape", "jewel", "gem"],
+ "🔺": ["shape", "direction", "up", "top"],
+ "▪️": ["shape", "icon"],
+ "▫️": ["shape", "icon"],
+ "⬛": ["shape", "icon", "button"],
+ "⬜": ["shape", "icon", "stone", "button"],
+ "🟥": ["shape"],
+ "🟧": ["shape"],
+ "🟨": ["shape"],
+ "🟩": ["shape"],
+ "🟦": ["shape"],
+ "🟪": ["shape"],
+ "🟫": ["shape"],
+ "🔻": ["shape", "direction", "bottom"],
+ "◼️": ["shape", "button", "icon"],
+ "◻️": ["shape", "stone", "icon"],
+ "◾": ["icon", "shape", "button"],
+ "◽": ["shape", "stone", "icon", "button"],
+ "🔲": ["shape", "input", "frame"],
+ "🔳": ["shape", "input"],
+ "🔈": ["sound", "volume", "silence", "broadcast"],
+ "🔉": ["volume", "speaker", "broadcast"],
+ "🔊": ["volume", "noise", "noisy", "speaker", "broadcast"],
+ "🔇": ["sound", "volume", "silence", "quiet"],
+ "📣": ["sound", "speaker", "volume"],
+ "📢": ["volume", "sound"],
+ "🔔": ["sound", "notification", "christmas", "xmas", "chime"],
+ "🔕": ["sound", "volume", "mute", "quiet", "silent"],
+ "🃏": ["poker", "cards", "game", "play", "magic"],
+ "🀄": ["game", "play", "chinese", "kanji"],
+ "♠️": ["poker", "cards", "suits", "magic"],
+ "♣️": ["poker", "cards", "magic", "suits"],
+ "♥️": ["poker", "cards", "magic", "suits"],
+ "♦️": ["poker", "cards", "magic", "suits"],
+ "🎴": ["game", "sunset", "red"],
+ "💭": ["bubble", "cloud", "speech", "thinking", "dream"],
+ "🗯": ["caption", "speech", "thinking", "mad"],
+ "💬": ["bubble", "words", "message", "talk", "chatting"],
+ "🗨": ["words", "message", "talk", "chatting"],
+ "🕐": ["time", "late", "early", "schedule"],
+ "🕑": ["time", "late", "early", "schedule"],
+ "🕒": ["time", "late", "early", "schedule"],
+ "🕓": ["time", "late", "early", "schedule"],
+ "🕔": ["time", "late", "early", "schedule"],
+ "🕕": ["time", "late", "early", "schedule", "dawn", "dusk"],
+ "🕖": ["time", "late", "early", "schedule"],
+ "🕗": ["time", "late", "early", "schedule"],
+ "🕘": ["time", "late", "early", "schedule"],
+ "🕙": ["time", "late", "early", "schedule"],
+ "🕚": ["time", "late", "early", "schedule"],
+ "🕛": ["time", "noon", "midnight", "midday", "late", "early", "schedule"],
+ "🕜": ["time", "late", "early", "schedule"],
+ "🕝": ["time", "late", "early", "schedule"],
+ "🕞": ["time", "late", "early", "schedule"],
+ "🕟": ["time", "late", "early", "schedule"],
+ "🕠": ["time", "late", "early", "schedule"],
+ "🕡": ["time", "late", "early", "schedule"],
+ "🕢": ["time", "late", "early", "schedule"],
+ "🕣": ["time", "late", "early", "schedule"],
+ "🕤": ["time", "late", "early", "schedule"],
+ "🕥": ["time", "late", "early", "schedule"],
+ "🕦": ["time", "late", "early", "schedule"],
+ "🕧": ["time", "late", "early", "schedule"],
+ "🇦🇫": ["af", "flag", "nation", "country", "banner"],
+ "🇦🇽": ["Åland", "islands", "flag", "nation", "country", "banner"],
+ "🇦🇱": ["al", "flag", "nation", "country", "banner"],
+ "🇩🇿": ["dz", "flag", "nation", "country", "banner"],
+ "🇦🇸": ["american", "ws", "flag", "nation", "country", "banner"],
+ "🇦🇩": ["ad", "flag", "nation", "country", "banner"],
+ "🇦🇴": ["ao", "flag", "nation", "country", "banner"],
+ "🇦🇮": ["ai", "flag", "nation", "country", "banner"],
+ "🇦🇶": ["aq", "flag", "nation", "country", "banner"],
+ "🇦🇬": ["antigua", "barbuda", "flag", "nation", "country", "banner"],
+ "🇦🇷": ["ar", "flag", "nation", "country", "banner"],
+ "🇦🇲": ["am", "flag", "nation", "country", "banner"],
+ "🇦🇼": ["aw", "flag", "nation", "country", "banner"],
+ "🇦🇨": ["flag", "nation", "country", "banner"],
+ "🇦🇺": ["au", "flag", "nation", "country", "banner"],
+ "🇦🇹": ["at", "flag", "nation", "country", "banner"],
+ "🇦🇿": ["az", "flag", "nation", "country", "banner"],
+ "🇧🇸": ["bs", "flag", "nation", "country", "banner"],
+ "🇧🇭": ["bh", "flag", "nation", "country", "banner"],
+ "🇧🇩": ["bd", "flag", "nation", "country", "banner"],
+ "🇧🇧": ["bb", "flag", "nation", "country", "banner"],
+ "🇧🇾": ["by", "flag", "nation", "country", "banner"],
+ "🇧🇪": ["be", "flag", "nation", "country", "banner"],
+ "🇧🇿": ["bz", "flag", "nation", "country", "banner"],
+ "🇧🇯": ["bj", "flag", "nation", "country", "banner"],
+ "🇧🇲": ["bm", "flag", "nation", "country", "banner"],
+ "🇧🇹": ["bt", "flag", "nation", "country", "banner"],
+ "🇧🇴": ["bo", "flag", "nation", "country", "banner"],
+ "🇧🇶": ["bonaire", "flag", "nation", "country", "banner"],
+ "🇧🇦": ["bosnia", "herzegovina", "flag", "nation", "country", "banner"],
+ "🇧🇼": ["bw", "flag", "nation", "country", "banner"],
+ "🇧🇷": ["br", "flag", "nation", "country", "banner"],
+ "🇮🇴": ["british", "indian", "ocean", "territory", "flag", "nation", "country", "banner"],
+ "🇻🇬": ["british", "virgin", "islands", "bvi", "flag", "nation", "country", "banner"],
+ "🇧🇳": ["bn", "darussalam", "flag", "nation", "country", "banner"],
+ "🇧🇬": ["bg", "flag", "nation", "country", "banner"],
+ "🇧🇫": ["burkina", "faso", "flag", "nation", "country", "banner"],
+ "🇧🇮": ["bi", "flag", "nation", "country", "banner"],
+ "🇨🇻": ["cabo", "verde", "flag", "nation", "country", "banner"],
+ "🇰🇭": ["kh", "flag", "nation", "country", "banner"],
+ "🇨🇲": ["cm", "flag", "nation", "country", "banner"],
+ "🇨🇦": ["ca", "flag", "nation", "country", "banner"],
+ "🇮🇨": ["canary", "islands", "flag", "nation", "country", "banner"],
+ "🇰🇾": ["cayman", "islands", "flag", "nation", "country", "banner"],
+ "🇨🇫": ["central", "african", "republic", "flag", "nation", "country", "banner"],
+ "🇹🇩": ["td", "flag", "nation", "country", "banner"],
+ "🇨🇱": ["flag", "nation", "country", "banner"],
+ "🇨🇳": ["china", "chinese", "prc", "flag", "country", "nation", "banner"],
+ "🇨🇽": ["christmas", "island", "flag", "nation", "country", "banner"],
+ "🇨🇨": ["cocos", "keeling", "islands", "flag", "nation", "country", "banner"],
+ "🇨🇴": ["co", "flag", "nation", "country", "banner"],
+ "🇰🇲": ["km", "flag", "nation", "country", "banner"],
+ "🇨🇬": ["congo", "flag", "nation", "country", "banner"],
+ "🇨🇩": ["congo", "democratic", "republic", "flag", "nation", "country", "banner"],
+ "🇨🇰": ["cook", "islands", "flag", "nation", "country", "banner"],
+ "🇨🇷": ["costa", "rica", "flag", "nation", "country", "banner"],
+ "🇭🇷": ["hr", "flag", "nation", "country", "banner"],
+ "🇨🇺": ["cu", "flag", "nation", "country", "banner"],
+ "🇨🇼": ["curaçao", "flag", "nation", "country", "banner"],
+ "🇨🇾": ["cy", "flag", "nation", "country", "banner"],
+ "🇨🇿": ["cz", "flag", "nation", "country", "banner"],
+ "🇩🇰": ["dk", "flag", "nation", "country", "banner"],
+ "🇩🇯": ["dj", "flag", "nation", "country", "banner"],
+ "🇩🇲": ["dm", "flag", "nation", "country", "banner"],
+ "🇩🇴": ["dominican", "republic", "flag", "nation", "country", "banner"],
+ "🇪🇨": ["ec", "flag", "nation", "country", "banner"],
+ "🇪🇬": ["eg", "flag", "nation", "country", "banner"],
+ "🇸🇻": ["el", "salvador", "flag", "nation", "country", "banner"],
+ "🇬🇶": ["equatorial", "gn", "flag", "nation", "country", "banner"],
+ "🇪🇷": ["er", "flag", "nation", "country", "banner"],
+ "🇪🇪": ["ee", "flag", "nation", "country", "banner"],
+ "🇪🇹": ["et", "flag", "nation", "country", "banner"],
+ "🇪🇺": ["european", "union", "flag", "banner"],
+ "🇫🇰": ["falkland", "islands", "malvinas", "flag", "nation", "country", "banner"],
+ "🇫🇴": ["faroe", "islands", "flag", "nation", "country", "banner"],
+ "🇫🇯": ["fj", "flag", "nation", "country", "banner"],
+ "🇫🇮": ["fi", "flag", "nation", "country", "banner"],
+ "🇫🇷": ["banner", "flag", "nation", "france", "french", "country"],
+ "🇬🇫": ["french", "guiana", "flag", "nation", "country", "banner"],
+ "🇵🇫": ["french", "polynesia", "flag", "nation", "country", "banner"],
+ "🇹🇫": ["french", "southern", "territories", "flag", "nation", "country", "banner"],
+ "🇬🇦": ["ga", "flag", "nation", "country", "banner"],
+ "🇬🇲": ["gm", "flag", "nation", "country", "banner"],
+ "🇬🇪": ["ge", "flag", "nation", "country", "banner"],
+ "🇩🇪": ["german", "nation", "flag", "country", "banner"],
+ "🇬🇭": ["gh", "flag", "nation", "country", "banner"],
+ "🇬🇮": ["gi", "flag", "nation", "country", "banner"],
+ "🇬🇷": ["gr", "flag", "nation", "country", "banner"],
+ "🇬🇱": ["gl", "flag", "nation", "country", "banner"],
+ "🇬🇩": ["gd", "flag", "nation", "country", "banner"],
+ "🇬🇵": ["gp", "flag", "nation", "country", "banner"],
+ "🇬🇺": ["gu", "flag", "nation", "country", "banner"],
+ "🇬🇹": ["gt", "flag", "nation", "country", "banner"],
+ "🇬🇬": ["gg", "flag", "nation", "country", "banner"],
+ "🇬🇳": ["gn", "flag", "nation", "country", "banner"],
+ "🇬🇼": ["gw", "bissau", "flag", "nation", "country", "banner"],
+ "🇬🇾": ["gy", "flag", "nation", "country", "banner"],
+ "🇭🇹": ["ht", "flag", "nation", "country", "banner"],
+ "🇭🇳": ["hn", "flag", "nation", "country", "banner"],
+ "🇭🇰": ["hong", "kong", "flag", "nation", "country", "banner"],
+ "🇭🇺": ["hu", "flag", "nation", "country", "banner"],
+ "🇮🇸": ["is", "flag", "nation", "country", "banner"],
+ "🇮🇳": ["in", "flag", "nation", "country", "banner"],
+ "🇮🇩": ["flag", "nation", "country", "banner"],
+ "🇮🇷": ["iran, ", "islamic", "republic", "flag", "nation", "country", "banner"],
+ "🇮🇶": ["iq", "flag", "nation", "country", "banner"],
+ "🇮🇪": ["ie", "flag", "nation", "country", "banner"],
+ "🇮🇲": ["isle", "man", "flag", "nation", "country", "banner"],
+ "🇮🇱": ["il", "flag", "nation", "country", "banner"],
+ "🇮🇹": ["italy", "flag", "nation", "country", "banner"],
+ "🇨🇮": ["ivory", "coast", "flag", "nation", "country", "banner"],
+ "🇯🇲": ["jm", "flag", "nation", "country", "banner"],
+ "🇯🇵": ["japanese", "nation", "flag", "country", "banner"],
+ "🇯🇪": ["je", "flag", "nation", "country", "banner"],
+ "🇯🇴": ["jo", "flag", "nation", "country", "banner"],
+ "🇰🇿": ["kz", "flag", "nation", "country", "banner"],
+ "🇰🇪": ["ke", "flag", "nation", "country", "banner"],
+ "🇰🇮": ["ki", "flag", "nation", "country", "banner"],
+ "🇽🇰": ["xk", "flag", "nation", "country", "banner"],
+ "🇰🇼": ["kw", "flag", "nation", "country", "banner"],
+ "🇰🇬": ["kg", "flag", "nation", "country", "banner"],
+ "🇱🇦": ["lao", "democratic", "republic", "flag", "nation", "country", "banner"],
+ "🇱🇻": ["lv", "flag", "nation", "country", "banner"],
+ "🇱🇧": ["lb", "flag", "nation", "country", "banner"],
+ "🇱🇸": ["ls", "flag", "nation", "country", "banner"],
+ "🇱🇷": ["lr", "flag", "nation", "country", "banner"],
+ "🇱🇾": ["ly", "flag", "nation", "country", "banner"],
+ "🇱🇮": ["li", "flag", "nation", "country", "banner"],
+ "🇱🇹": ["lt", "flag", "nation", "country", "banner"],
+ "🇱🇺": ["lu", "flag", "nation", "country", "banner"],
+ "🇲🇴": ["macao", "flag", "nation", "country", "banner"],
+ "🇲🇰": ["macedonia, ", "flag", "nation", "country", "banner"],
+ "🇲🇬": ["mg", "flag", "nation", "country", "banner"],
+ "🇲🇼": ["mw", "flag", "nation", "country", "banner"],
+ "🇲🇾": ["my", "flag", "nation", "country", "banner"],
+ "🇲🇻": ["mv", "flag", "nation", "country", "banner"],
+ "🇲🇱": ["ml", "flag", "nation", "country", "banner"],
+ "🇲🇹": ["mt", "flag", "nation", "country", "banner"],
+ "🇲🇭": ["marshall", "islands", "flag", "nation", "country", "banner"],
+ "🇲🇶": ["mq", "flag", "nation", "country", "banner"],
+ "🇲🇷": ["mr", "flag", "nation", "country", "banner"],
+ "🇲🇺": ["mu", "flag", "nation", "country", "banner"],
+ "🇾🇹": ["yt", "flag", "nation", "country", "banner"],
+ "🇲🇽": ["mx", "flag", "nation", "country", "banner"],
+ "🇫🇲": ["micronesia, ", "federated", "states", "flag", "nation", "country", "banner"],
+ "🇲🇩": ["moldova, ", "republic", "flag", "nation", "country", "banner"],
+ "🇲🇨": ["mc", "flag", "nation", "country", "banner"],
+ "🇲🇳": ["mn", "flag", "nation", "country", "banner"],
+ "🇲🇪": ["me", "flag", "nation", "country", "banner"],
+ "🇲🇸": ["ms", "flag", "nation", "country", "banner"],
+ "🇲🇦": ["ma", "flag", "nation", "country", "banner"],
+ "🇲🇿": ["mz", "flag", "nation", "country", "banner"],
+ "🇲🇲": ["mm", "flag", "nation", "country", "banner"],
+ "🇳🇦": ["na", "flag", "nation", "country", "banner"],
+ "🇳🇷": ["nr", "flag", "nation", "country", "banner"],
+ "🇳🇵": ["np", "flag", "nation", "country", "banner"],
+ "🇳🇱": ["nl", "flag", "nation", "country", "banner"],
+ "🇳🇨": ["new", "caledonia", "flag", "nation", "country", "banner"],
+ "🇳🇿": ["new", "zealand", "flag", "nation", "country", "banner"],
+ "🇳🇮": ["ni", "flag", "nation", "country", "banner"],
+ "🇳🇪": ["ne", "flag", "nation", "country", "banner"],
+ "🇳🇬": ["flag", "nation", "country", "banner"],
+ "🇳🇺": ["nu", "flag", "nation", "country", "banner"],
+ "🇳🇫": ["norfolk", "island", "flag", "nation", "country", "banner"],
+ "🇲🇵": ["northern", "mariana", "islands", "flag", "nation", "country", "banner"],
+ "🇰🇵": ["north", "korea", "nation", "flag", "country", "banner"],
+ "🇳🇴": ["no", "flag", "nation", "country", "banner"],
+ "🇴🇲": ["om_symbol", "flag", "nation", "country", "banner"],
+ "🇵🇰": ["pk", "flag", "nation", "country", "banner"],
+ "🇵🇼": ["pw", "flag", "nation", "country", "banner"],
+ "🇵🇸": ["palestine", "palestinian", "territories", "flag", "nation", "country", "banner"],
+ "🇵🇦": ["pa", "flag", "nation", "country", "banner"],
+ "🇵🇬": ["papua", "new", "guinea", "flag", "nation", "country", "banner"],
+ "🇵🇾": ["py", "flag", "nation", "country", "banner"],
+ "🇵🇪": ["pe", "flag", "nation", "country", "banner"],
+ "🇵🇭": ["ph", "flag", "nation", "country", "banner"],
+ "🇵🇳": ["pitcairn", "flag", "nation", "country", "banner"],
+ "🇵🇱": ["pl", "flag", "nation", "country", "banner"],
+ "🇵🇹": ["pt", "flag", "nation", "country", "banner"],
+ "🇵🇷": ["puerto", "rico", "flag", "nation", "country", "banner"],
+ "🇶🇦": ["qa", "flag", "nation", "country", "banner"],
+ "🇷🇪": ["réunion", "flag", "nation", "country", "banner"],
+ "🇷🇴": ["ro", "flag", "nation", "country", "banner"],
+ "🇷🇺": ["russian", "federation", "flag", "nation", "country", "banner"],
+ "🇷🇼": ["rw", "flag", "nation", "country", "banner"],
+ "🇧🇱": ["saint", "barthélemy", "flag", "nation", "country", "banner"],
+ "🇸🇭": ["saint", "helena", "ascension", "tristan", "cunha", "flag", "nation", "country", "banner"],
+ "🇰🇳": ["saint", "kitts", "nevis", "flag", "nation", "country", "banner"],
+ "🇱🇨": ["saint", "lucia", "flag", "nation", "country", "banner"],
+ "🇵🇲": ["saint", "pierre", "miquelon", "flag", "nation", "country", "banner"],
+ "🇻🇨": ["saint", "vincent", "grenadines", "flag", "nation", "country", "banner"],
+ "🇼🇸": ["ws", "flag", "nation", "country", "banner"],
+ "🇸🇲": ["san", "marino", "flag", "nation", "country", "banner"],
+ "🇸🇹": ["sao", "tome", "principe", "flag", "nation", "country", "banner"],
+ "🇸🇦": ["flag", "nation", "country", "banner"],
+ "🇸🇳": ["sn", "flag", "nation", "country", "banner"],
+ "🇷🇸": ["rs", "flag", "nation", "country", "banner"],
+ "🇸🇨": ["sc", "flag", "nation", "country", "banner"],
+ "🇸🇱": ["sierra", "leone", "flag", "nation", "country", "banner"],
+ "🇸🇬": ["sg", "flag", "nation", "country", "banner"],
+ "🇸🇽": ["sint", "maarten", "dutch", "flag", "nation", "country", "banner"],
+ "🇸🇰": ["sk", "flag", "nation", "country", "banner"],
+ "🇸🇮": ["si", "flag", "nation", "country", "banner"],
+ "🇸🇧": ["solomon", "islands", "flag", "nation", "country", "banner"],
+ "🇸🇴": ["so", "flag", "nation", "country", "banner"],
+ "🇿🇦": ["south", "africa", "flag", "nation", "country", "banner"],
+ "🇬🇸": ["south", "georgia", "sandwich", "islands", "flag", "nation", "country", "banner"],
+ "🇰🇷": ["south", "korea", "nation", "flag", "country", "banner"],
+ "🇸🇸": ["south", "sd", "flag", "nation", "country", "banner"],
+ "🇪🇸": ["spain", "flag", "nation", "country", "banner"],
+ "🇱🇰": ["sri", "lanka", "flag", "nation", "country", "banner"],
+ "🇸🇩": ["sd", "flag", "nation", "country", "banner"],
+ "🇸🇷": ["sr", "flag", "nation", "country", "banner"],
+ "🇸🇿": ["sz", "flag", "nation", "country", "banner"],
+ "🇸🇪": ["se", "flag", "nation", "country", "banner"],
+ "🇨🇭": ["ch", "flag", "nation", "country", "banner"],
+ "🇸🇾": ["syrian", "arab", "republic", "flag", "nation", "country", "banner"],
+ "🇹🇼": ["tw", "flag", "nation", "country", "banner"],
+ "🇹🇯": ["tj", "flag", "nation", "country", "banner"],
+ "🇹🇿": ["tanzania, ", "united", "republic", "flag", "nation", "country", "banner"],
+ "🇹🇭": ["th", "flag", "nation", "country", "banner"],
+ "🇹🇱": ["timor", "leste", "flag", "nation", "country", "banner"],
+ "🇹🇬": ["tg", "flag", "nation", "country", "banner"],
+ "🇹🇰": ["tk", "flag", "nation", "country", "banner"],
+ "🇹🇴": ["to", "flag", "nation", "country", "banner"],
+ "🇹🇹": ["trinidad", "tobago", "flag", "nation", "country", "banner"],
+ "🇹🇦": ["flag", "nation", "country", "banner"],
+ "🇹🇳": ["tn", "flag", "nation", "country", "banner"],
+ "🇹🇷": ["turkey", "flag", "nation", "country", "banner"],
+ "🇹🇲": ["flag", "nation", "country", "banner"],
+ "🇹🇨": ["turks", "caicos", "islands", "flag", "nation", "country", "banner"],
+ "🇹🇻": ["flag", "nation", "country", "banner"],
+ "🇺🇬": ["ug", "flag", "nation", "country", "banner"],
+ "🇺🇦": ["ua", "flag", "nation", "country", "banner"],
+ "🇦🇪": ["united", "arab", "emirates", "flag", "nation", "country", "banner"],
+ "🇬🇧": ["united", "kingdom", "great", "britain", "northern", "ireland", "flag", "nation", "country", "banner", "british", "UK", "english", "england", "union jack"],
+ "🏴󠁧󠁢󠁥󠁮󠁧󠁿": ["flag", "english"],
+ "🏴󠁧󠁢󠁳󠁣󠁴󠁿": ["flag", "scottish"],
+ "🏴󠁧󠁢󠁷󠁬󠁳󠁿": ["flag", "welsh"],
+ "🇺🇸": ["united", "states", "america", "flag", "nation", "country", "banner"],
+ "🇻🇮": ["virgin", "islands", "us", "flag", "nation", "country", "banner"],
+ "🇺🇾": ["uy", "flag", "nation", "country", "banner"],
+ "🇺🇿": ["uz", "flag", "nation", "country", "banner"],
+ "🇻🇺": ["vu", "flag", "nation", "country", "banner"],
+ "🇻🇦": ["vatican", "city", "flag", "nation", "country", "banner"],
+ "🇻🇪": ["ve", "bolivarian", "republic", "flag", "nation", "country", "banner"],
+ "🇻🇳": ["viet", "nam", "flag", "nation", "country", "banner"],
+ "🇼🇫": ["wallis", "futuna", "flag", "nation", "country", "banner"],
+ "🇪🇭": ["western", "sahara", "flag", "nation", "country", "banner"],
+ "🇾🇪": ["ye", "flag", "nation", "country", "banner"],
+ "🇿🇲": ["zm", "flag", "nation", "country", "banner"],
+ "🇿🇼": ["zw", "flag", "nation", "country", "banner"],
+ "🇺🇳": ["un", "flag", "banner"],
+ "🏴‍☠️": ["skull", "crossbones", "flag", "banner"]
+}
diff --git a/packages/frontend/src/widgets/WidgetActivity.calendar.vue b/packages/frontend/src/widgets/WidgetActivity.calendar.vue
index 84f6af1c13..110f1d32eb 100644
--- a/packages/frontend/src/widgets/WidgetActivity.calendar.vue
+++ b/packages/frontend/src/widgets/WidgetActivity.calendar.vue
@@ -1,25 +1,31 @@
<template>
<svg viewBox="0 0 21 7">
- <rect v-for="record in activity" class="day"
+ <rect
+ v-for="record in activity" class="day"
width="1" height="1"
:x="record.x" :y="record.date.weekday"
rx="1" ry="1"
- fill="transparent">
+ fill="transparent"
+ >
<title>{{ record.date.year }}/{{ record.date.month + 1 }}/{{ record.date.day }}</title>
</rect>
- <rect v-for="record in activity" class="day"
+ <rect
+ v-for="record in activity" class="day"
:width="record.v" :height="record.v"
:x="record.x + ((1 - record.v) / 2)" :y="record.date.weekday + ((1 - record.v) / 2)"
rx="1" ry="1"
:fill="record.color"
- style="pointer-events: none;"/>
- <rect class="today"
+ style="pointer-events: none;"
+ />
+ <rect
+ class="today"
width="1" height="1"
:x="activity[0].x" :y="activity[0].date.weekday"
rx="1" ry="1"
fill="none"
stroke-width="0.1"
- stroke="#f73520"/>
+ stroke="#f73520"
+ />
</svg>
</template>
diff --git a/packages/frontend/src/widgets/WidgetActivity.chart.vue b/packages/frontend/src/widgets/WidgetActivity.chart.vue
index b61e419f94..cc4df65dd2 100644
--- a/packages/frontend/src/widgets/WidgetActivity.chart.vue
+++ b/packages/frontend/src/widgets/WidgetActivity.chart.vue
@@ -1,26 +1,30 @@
<template>
-<svg :viewBox="`0 0 ${ viewBoxX } ${ viewBoxY }`" @mousedown.prevent="onMousedown">
+<svg :viewBox="`0 0 ${ viewBoxX } ${ viewBoxY }`" :class="$style.root" @mousedown.prevent="onMousedown">
<polyline
:points="pointsNote"
fill="none"
stroke-width="1"
- stroke="#41ddde"/>
+ stroke="#41ddde"
+ />
<polyline
:points="pointsReply"
fill="none"
stroke-width="1"
- stroke="#f7796c"/>
+ stroke="#f7796c"
+ />
<polyline
:points="pointsRenote"
fill="none"
stroke-width="1"
- stroke="#a1de41"/>
+ stroke="#a1de41"
+ />
<polyline
:points="pointsTotal"
fill="none"
stroke-width="1"
stroke="#555"
- stroke-dasharray="2 2"/>
+ stroke-dasharray="2 2"
+ />
</svg>
</template>
@@ -81,8 +85,8 @@ function render() {
}
</script>
-<style lang="scss" scoped>
-svg {
+<style lang="scss" module>
+.root {
display: block;
padding: 16px;
width: 100%;
diff --git a/packages/frontend/src/widgets/WidgetActivity.vue b/packages/frontend/src/widgets/WidgetActivity.vue
index e7f8819abd..892b24f69d 100644
--- a/packages/frontend/src/widgets/WidgetActivity.vue
+++ b/packages/frontend/src/widgets/WidgetActivity.vue
@@ -1,5 +1,5 @@
<template>
-<MkContainer :show-header="widgetProps.showHeader" :naked="widgetProps.transparent" data-cy-mkw-activity class="mkw-activity">
+<MkContainer :showHeader="widgetProps.showHeader" :naked="widgetProps.transparent" data-cy-mkw-activity class="mkw-activity">
<template #icon><i class="ti ti-chart-line"></i></template>
<template #header>{{ i18n.ts._widgets.activity }}</template>
<template #func="{ buttonStyleClass }"><button class="_button" :class="buttonStyleClass" @click="toggleView()"><i class="ti ti-selector"></i></button></template>
@@ -16,7 +16,7 @@
<script lang="ts" setup>
import { ref } from 'vue';
-import { useWidgetPropsManager, Widget, WidgetComponentExpose } from './widget';
+import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
import XCalendar from './WidgetActivity.calendar.vue';
import XChart from './WidgetActivity.chart.vue';
import { GetFormResultType } from '@/scripts/form';
@@ -45,11 +45,8 @@ const widgetPropsDef = {
type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
-// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
-//const props = defineProps<WidgetComponentProps<WidgetProps>>();
-//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
-const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
-const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>();
+const props = defineProps<WidgetComponentProps<WidgetProps>>();
+const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
const { widgetProps, configure, save } = useWidgetPropsManager(name,
widgetPropsDef,
diff --git a/packages/frontend/src/widgets/WidgetAichan.vue b/packages/frontend/src/widgets/WidgetAichan.vue
index 37326ee981..797dd9c09f 100644
--- a/packages/frontend/src/widgets/WidgetAichan.vue
+++ b/packages/frontend/src/widgets/WidgetAichan.vue
@@ -1,12 +1,12 @@
<template>
-<MkContainer :naked="widgetProps.transparent" :show-header="false" data-cy-mkw-aichan class="mkw-aichan">
- <iframe ref="live2d" class="dedjhjmo" src="https://misskey-dev.github.io/mascot-web/?scale=1.5&y=1.1&eyeY=100" @click="touched"></iframe>
+<MkContainer :naked="widgetProps.transparent" :showHeader="false" data-cy-mkw-aichan class="mkw-aichan">
+ <iframe ref="live2d" :class="$style.root" src="https://misskey-dev.github.io/mascot-web/?scale=1.5&y=1.1&eyeY=100" @click="touched"></iframe>
</MkContainer>
</template>
<script lang="ts" setup>
import { onMounted, onUnmounted, shallowRef } from 'vue';
-import { useWidgetPropsManager, Widget, WidgetComponentExpose } from './widget';
+import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
import { GetFormResultType } from '@/scripts/form';
const name = 'ai';
@@ -20,11 +20,8 @@ const widgetPropsDef = {
type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
-// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
-//const props = defineProps<WidgetComponentProps<WidgetProps>>();
-//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
-const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
-const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>();
+const props = defineProps<WidgetComponentProps<WidgetProps>>();
+const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
const { widgetProps, configure } = useWidgetPropsManager(name,
widgetPropsDef,
@@ -64,8 +61,8 @@ defineExpose<WidgetComponentExpose>({
});
</script>
-<style lang="scss" scoped>
-.dedjhjmo {
+<style lang="scss" module>
+.root {
width: 100%;
height: 350px;
border: none;
diff --git a/packages/frontend/src/widgets/WidgetAiscript.vue b/packages/frontend/src/widgets/WidgetAiscript.vue
index 947dbe5e77..d6c94cd56a 100644
--- a/packages/frontend/src/widgets/WidgetAiscript.vue
+++ b/packages/frontend/src/widgets/WidgetAiscript.vue
@@ -1,5 +1,5 @@
<template>
-<MkContainer :show-header="widgetProps.showHeader" data-cy-mkw-aiscript class="mkw-aiscript">
+<MkContainer :showHeader="widgetProps.showHeader" data-cy-mkw-aiscript class="mkw-aiscript">
<template #icon><i class="ti ti-terminal-2"></i></template>
<template #header>{{ i18n.ts._widgets.aiscript }}</template>
@@ -16,7 +16,7 @@
<script lang="ts" setup>
import { ref } from 'vue';
import { Interpreter, Parser, utils } from '@syuilo/aiscript';
-import { useWidgetPropsManager, Widget, WidgetComponentExpose } from './widget';
+import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
import { GetFormResultType } from '@/scripts/form';
import * as os from '@/os';
import MkContainer from '@/components/MkContainer.vue';
@@ -41,11 +41,8 @@ const widgetPropsDef = {
type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
-// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
-//const props = defineProps<WidgetComponentProps<WidgetProps>>();
-//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
-const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
-const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>();
+const props = defineProps<WidgetComponentProps<WidgetProps>>();
+const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
const { widgetProps, configure } = useWidgetPropsManager(name,
widgetPropsDef,
diff --git a/packages/frontend/src/widgets/WidgetAiscriptApp.vue b/packages/frontend/src/widgets/WidgetAiscriptApp.vue
index 455a6e6ea5..3b67972e40 100644
--- a/packages/frontend/src/widgets/WidgetAiscriptApp.vue
+++ b/packages/frontend/src/widgets/WidgetAiscriptApp.vue
@@ -1,5 +1,5 @@
<template>
-<MkContainer :show-header="widgetProps.showHeader" class="mkw-aiscriptApp">
+<MkContainer :showHeader="widgetProps.showHeader" class="mkw-aiscriptApp">
<template #header>App</template>
<div :class="$style.root">
<MkAsUi v-if="root" :component="root" :components="components" size="small"/>
@@ -10,7 +10,7 @@
<script lang="ts" setup>
import { onMounted, Ref, ref, watch } from 'vue';
import { Interpreter, Parser } from '@syuilo/aiscript';
-import { useWidgetPropsManager, Widget, WidgetComponentExpose } from './widget';
+import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
import { GetFormResultType } from '@/scripts/form';
import * as os from '@/os';
import { createAiScriptEnv } from '@/scripts/aiscript/api';
@@ -35,12 +35,9 @@ const widgetPropsDef = {
type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
-// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
-//const props = defineProps<WidgetComponentProps<WidgetProps>>();
-//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
-const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
-const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>();
-
+const props = defineProps<WidgetComponentProps<WidgetProps>>();
+const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
+
const { widgetProps, configure } = useWidgetPropsManager(name,
widgetPropsDef,
props,
diff --git a/packages/frontend/src/widgets/WidgetButton.vue b/packages/frontend/src/widgets/WidgetButton.vue
index 9eee9680db..bcb380f849 100644
--- a/packages/frontend/src/widgets/WidgetButton.vue
+++ b/packages/frontend/src/widgets/WidgetButton.vue
@@ -8,7 +8,7 @@
<script lang="ts" setup>
import { Interpreter, Parser } from '@syuilo/aiscript';
-import { useWidgetPropsManager, Widget, WidgetComponentExpose } from './widget';
+import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
import { GetFormResultType } from '@/scripts/form';
import * as os from '@/os';
import { createAiScriptEnv } from '@/scripts/aiscript/api';
@@ -35,11 +35,8 @@ const widgetPropsDef = {
type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
-// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
-//const props = defineProps<WidgetComponentProps<WidgetProps>>();
-//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
-const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
-const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>();
+const props = defineProps<WidgetComponentProps<WidgetProps>>();
+const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
const { widgetProps, configure } = useWidgetPropsManager(name,
widgetPropsDef,
@@ -101,8 +98,3 @@ defineExpose<WidgetComponentExpose>({
id: props.widget ? props.widget.id : null,
});
</script>
-
-<style lang="scss" scoped>
-.mkw-button {
-}
-</style>
diff --git a/packages/frontend/src/widgets/WidgetCalendar.vue b/packages/frontend/src/widgets/WidgetCalendar.vue
index 58d0732263..447525837c 100644
--- a/packages/frontend/src/widgets/WidgetCalendar.vue
+++ b/packages/frontend/src/widgets/WidgetCalendar.vue
@@ -34,7 +34,7 @@
<script lang="ts" setup>
import { ref } from 'vue';
-import { useWidgetPropsManager, Widget, WidgetComponentExpose } from './widget';
+import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
import { GetFormResultType } from '@/scripts/form';
import { i18n } from '@/i18n';
import { useInterval } from '@/scripts/use-interval';
@@ -50,11 +50,8 @@ const widgetPropsDef = {
type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
-// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
-//const props = defineProps<WidgetComponentProps<WidgetProps>>();
-//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
-const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
-const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>();
+const props = defineProps<WidgetComponentProps<WidgetProps>>();
+const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
const { widgetProps, configure } = useWidgetPropsManager(name,
widgetPropsDef,
diff --git a/packages/frontend/src/widgets/WidgetClicker.vue b/packages/frontend/src/widgets/WidgetClicker.vue
index 981788a3c5..b7be2e8c83 100644
--- a/packages/frontend/src/widgets/WidgetClicker.vue
+++ b/packages/frontend/src/widgets/WidgetClicker.vue
@@ -1,5 +1,5 @@
<template>
-<MkContainer :show-header="widgetProps.showHeader" class="mkw-clicker">
+<MkContainer :showHeader="widgetProps.showHeader" class="mkw-clicker">
<template #icon><i class="ti ti-cookie"></i></template>
<template #header>Clicker</template>
<MkClickerGame/>
@@ -7,7 +7,7 @@
</template>
<script lang="ts" setup>
-import { useWidgetPropsManager, Widget, WidgetComponentExpose } from './widget';
+import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
import { GetFormResultType } from '@/scripts/form';
import MkContainer from '@/components/MkContainer.vue';
import MkClickerGame from '@/components/MkClickerGame.vue';
@@ -23,12 +23,9 @@ const widgetPropsDef = {
type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
-// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
-//const props = defineProps<WidgetComponentProps<WidgetProps>>();
-//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
-const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
-const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>();
-
+const props = defineProps<WidgetComponentProps<WidgetProps>>();
+const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
+
const { widgetProps, configure } = useWidgetPropsManager(name,
widgetPropsDef,
props,
diff --git a/packages/frontend/src/widgets/WidgetClock.vue b/packages/frontend/src/widgets/WidgetClock.vue
index ebd73cb9f5..aee5026db4 100644
--- a/packages/frontend/src/widgets/WidgetClock.vue
+++ b/packages/frontend/src/widgets/WidgetClock.vue
@@ -1,25 +1,31 @@
<template>
-<MkContainer :naked="widgetProps.transparent" :show-header="false" data-cy-mkw-clock class="mkw-clock">
- <div class="vubelbmv" :class="widgetProps.size">
- <div v-if="widgetProps.label === 'tz' || widgetProps.label === 'timeAndTz'" class="_monospace label a abbrev">{{ tzAbbrev }}</div>
+<MkContainer :naked="widgetProps.transparent" :showHeader="false" data-cy-mkw-clock>
+ <div
+ :class="[$style.root, {
+ [$style.small]: widgetProps.size === 'small',
+ [$style.medium]: widgetProps.size === 'medium',
+ [$style.large]: widgetProps.size === 'large',
+ }]"
+ >
+ <div v-if="widgetProps.label === 'tz' || widgetProps.label === 'timeAndTz'" class="_monospace" :class="[$style.label, $style.a]">{{ tzAbbrev }}</div>
<MkAnalogClock
- class="clock"
+ :class="$style.clock"
:thickness="widgetProps.thickness"
:offset="tzOffset"
:graduations="widgetProps.graduations"
- :fade-graduations="widgetProps.fadeGraduations"
+ :fadeGraduations="widgetProps.fadeGraduations"
:twentyfour="widgetProps.twentyFour"
- :s-animation="widgetProps.sAnimation"
+ :sAnimation="widgetProps.sAnimation"
/>
- <MkDigitalClock v-if="widgetProps.label === 'time' || widgetProps.label === 'timeAndTz'" class="_monospace label c time" :show-s="false" :offset="tzOffset"/>
- <div v-if="widgetProps.label === 'tz' || widgetProps.label === 'timeAndTz'" class="_monospace label d offset">{{ tzOffsetLabel }}</div>
+ <MkDigitalClock v-if="widgetProps.label === 'time' || widgetProps.label === 'timeAndTz'" :class="[$style.label, $style.c]" class="_monospace" :showS="false" :offset="tzOffset"/>
+ <div v-if="widgetProps.label === 'tz' || widgetProps.label === 'timeAndTz'" class="_monospace" :class="[$style.label, $style.d]">{{ tzOffsetLabel }}</div>
</div>
</MkContainer>
</template>
<script lang="ts" setup>
import { } from 'vue';
-import { useWidgetPropsManager, Widget, WidgetComponentExpose } from './widget';
+import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
import { GetFormResultType } from '@/scripts/form';
import MkContainer from '@/components/MkContainer.vue';
import MkAnalogClock from '@/components/MkAnalogClock.vue';
@@ -114,11 +120,8 @@ const widgetPropsDef = {
type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
-// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
-//const props = defineProps<WidgetComponentProps<WidgetProps>>();
-//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
-const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
-const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>();
+const props = defineProps<WidgetComponentProps<WidgetProps>>();
+const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
const { widgetProps, configure } = useWidgetPropsManager(name,
widgetPropsDef,
@@ -143,39 +146,10 @@ defineExpose<WidgetComponentExpose>({
});
</script>
-<style lang="scss" scoped>
-.vubelbmv {
+<style lang="scss" module>
+.root {
position: relative;
- > .label {
- position: absolute;
- opacity: 0.7;
-
- &.a {
- top: 14px;
- left: 14px;
- }
-
- &.b {
- top: 14px;
- right: 14px;
- }
-
- &.c {
- bottom: 14px;
- left: 14px;
- }
-
- &.d {
- bottom: 14px;
- right: 14px;
- }
- }
-
- > .clock {
- margin: auto;
- }
-
&.small {
padding: 12px;
@@ -200,4 +174,33 @@ defineExpose<WidgetComponentExpose>({
}
}
}
+
+.label {
+ position: absolute;
+ opacity: 0.7;
+
+ &.a {
+ top: 14px;
+ left: 14px;
+ }
+
+ &.b {
+ top: 14px;
+ right: 14px;
+ }
+
+ &.c {
+ bottom: 14px;
+ left: 14px;
+ }
+
+ &.d {
+ bottom: 14px;
+ right: 14px;
+ }
+}
+
+.clock {
+ margin: auto;
+}
</style>
diff --git a/packages/frontend/src/widgets/WidgetDigitalClock.vue b/packages/frontend/src/widgets/WidgetDigitalClock.vue
index cdd9c3a401..6148177d9a 100644
--- a/packages/frontend/src/widgets/WidgetDigitalClock.vue
+++ b/packages/frontend/src/widgets/WidgetDigitalClock.vue
@@ -2,14 +2,14 @@
<div data-cy-mkw-digitalClock class="_monospace" :class="[$style.root, { _panel: !widgetProps.transparent }]" :style="{ fontSize: `${widgetProps.fontSize}em` }">
<div v-if="widgetProps.showLabel" :class="$style.label">{{ tzAbbrev }}</div>
<div>
- <MkDigitalClock :show-ms="widgetProps.showMs" :offset="tzOffset"/>
+ <MkDigitalClock :showMs="widgetProps.showMs" :offset="tzOffset"/>
</div>
<div v-if="widgetProps.showLabel" :class="$style.label">{{ tzOffsetLabel }}</div>
</div>
</template>
<script lang="ts" setup>
-import { useWidgetPropsManager, Widget, WidgetComponentExpose } from './widget';
+import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
import { GetFormResultType } from '@/scripts/form';
import { timezones } from '@/scripts/timezones';
import MkDigitalClock from '@/components/MkDigitalClock.vue';
@@ -49,11 +49,8 @@ const widgetPropsDef = {
type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
-// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
-//const props = defineProps<WidgetComponentProps<WidgetProps>>();
-//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
-const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
-const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>();
+const props = defineProps<WidgetComponentProps<WidgetProps>>();
+const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
const { widgetProps, configure } = useWidgetPropsManager(name,
widgetPropsDef,
diff --git a/packages/frontend/src/widgets/WidgetFederation.vue b/packages/frontend/src/widgets/WidgetFederation.vue
index 2033b074e0..951c4aaa6d 100644
--- a/packages/frontend/src/widgets/WidgetFederation.vue
+++ b/packages/frontend/src/widgets/WidgetFederation.vue
@@ -1,5 +1,5 @@
<template>
-<MkContainer :show-header="widgetProps.showHeader" :foldable="foldable" :scrollable="scrollable" data-cy-mkw-federation class="mkw-federation">
+<MkContainer :showHeader="widgetProps.showHeader" :foldable="foldable" :scrollable="scrollable" data-cy-mkw-federation class="mkw-federation">
<template #icon><i class="ti ti-whirl"></i></template>
<template #header>{{ i18n.ts._widgets.federation }}</template>
@@ -21,7 +21,7 @@
<script lang="ts" setup>
import { ref } from 'vue';
-import { useWidgetPropsManager, Widget, WidgetComponentExpose } from './widget';
+import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
import { GetFormResultType } from '@/scripts/form';
import MkContainer from '@/components/MkContainer.vue';
import MkMiniChart from '@/components/MkMiniChart.vue';
@@ -42,11 +42,8 @@ const widgetPropsDef = {
type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
-// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
-//const props = defineProps<WidgetComponentProps<WidgetProps> & { foldable?: boolean; scrollable?: boolean; }>();
-//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
-const props = defineProps<{ widget?: Widget<WidgetProps>; foldable?: boolean; scrollable?: boolean; }>();
-const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>();
+const props = defineProps<WidgetComponentProps<WidgetProps>>();
+const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
const { widgetProps, configure } = useWidgetPropsManager(name,
widgetPropsDef,
diff --git a/packages/frontend/src/widgets/WidgetInstanceCloud.vue b/packages/frontend/src/widgets/WidgetInstanceCloud.vue
index b157807655..f8b811e6ba 100644
--- a/packages/frontend/src/widgets/WidgetInstanceCloud.vue
+++ b/packages/frontend/src/widgets/WidgetInstanceCloud.vue
@@ -1,5 +1,5 @@
<template>
-<MkContainer :naked="widgetProps.transparent" :show-header="false" class="mkw-instance-cloud">
+<MkContainer :naked="widgetProps.transparent" :showHeader="false" class="mkw-instance-cloud">
<div class="">
<MkTagCloud v-if="activeInstances">
<li v-for="instance in activeInstances" :key="instance.id">
@@ -14,7 +14,7 @@
<script lang="ts" setup>
import { } from 'vue';
-import { useWidgetPropsManager, Widget, WidgetComponentExpose } from './widget';
+import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
import { GetFormResultType } from '@/scripts/form';
import MkContainer from '@/components/MkContainer.vue';
import MkTagCloud from '@/components/MkTagCloud.vue';
@@ -33,11 +33,8 @@ const widgetPropsDef = {
type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
-// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
-//const props = defineProps<WidgetComponentProps<WidgetProps>>();
-//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
-const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
-const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>();
+const props = defineProps<WidgetComponentProps<WidgetProps>>();
+const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
const { widgetProps, configure } = useWidgetPropsManager(name,
widgetPropsDef,
@@ -75,7 +72,3 @@ defineExpose<WidgetComponentExpose>({
id: props.widget ? props.widget.id : null,
});
</script>
-
-<style lang="scss" scoped>
-
-</style>
diff --git a/packages/frontend/src/widgets/WidgetInstanceInfo.vue b/packages/frontend/src/widgets/WidgetInstanceInfo.vue
index d702fd2cb0..c77b98f8f4 100644
--- a/packages/frontend/src/widgets/WidgetInstanceInfo.vue
+++ b/packages/frontend/src/widgets/WidgetInstanceInfo.vue
@@ -15,7 +15,7 @@
</template>
<script lang="ts" setup>
-import { useWidgetPropsManager, Widget, WidgetComponentExpose } from './widget';
+import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
import { GetFormResultType } from '@/scripts/form';
import { host } from '@/config';
import { instance } from '@/instance';
@@ -27,11 +27,8 @@ const widgetPropsDef = {
type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
-// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
-//const props = defineProps<WidgetComponentProps<WidgetProps>>();
-//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
-const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
-const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>();
+const props = defineProps<WidgetComponentProps<WidgetProps>>();
+const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
const { widgetProps, configure } = useWidgetPropsManager(name,
widgetPropsDef,
diff --git a/packages/frontend/src/widgets/WidgetJobQueue.vue b/packages/frontend/src/widgets/WidgetJobQueue.vue
index 84043cf13f..3c8ffdb55a 100644
--- a/packages/frontend/src/widgets/WidgetJobQueue.vue
+++ b/packages/frontend/src/widgets/WidgetJobQueue.vue
@@ -47,9 +47,9 @@
<script lang="ts" setup>
import { onUnmounted, reactive } from 'vue';
-import { useWidgetPropsManager, Widget, WidgetComponentExpose } from './widget';
+import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
import { GetFormResultType } from '@/scripts/form';
-import { stream } from '@/stream';
+import { useStream } from '@/stream';
import number from '@/filters/number';
import * as sound from '@/scripts/sound';
import { deepClone } from '@/scripts/clone';
@@ -69,11 +69,8 @@ const widgetPropsDef = {
type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
-// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
-//const props = defineProps<WidgetComponentProps<WidgetProps>>();
-//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
-const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
-const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>();
+const props = defineProps<WidgetComponentProps<WidgetProps>>();
+const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
const { widgetProps, configure } = useWidgetPropsManager(name,
widgetPropsDef,
@@ -81,7 +78,7 @@ const { widgetProps, configure } = useWidgetPropsManager(name,
emit,
);
-const connection = stream.useChannel('queueStats');
+const connection = useStream().useChannel('queueStats');
const current = reactive({
inbox: {
activeSincePrevTick: 0,
diff --git a/packages/frontend/src/widgets/WidgetMemo.vue b/packages/frontend/src/widgets/WidgetMemo.vue
index 959cf776ad..78d27a31b9 100644
--- a/packages/frontend/src/widgets/WidgetMemo.vue
+++ b/packages/frontend/src/widgets/WidgetMemo.vue
@@ -1,5 +1,5 @@
<template>
-<MkContainer :show-header="widgetProps.showHeader" data-cy-mkw-memo class="mkw-memo">
+<MkContainer :showHeader="widgetProps.showHeader" data-cy-mkw-memo class="mkw-memo">
<template #icon><i class="ti ti-note"></i></template>
<template #header>{{ i18n.ts._widgets.memo }}</template>
@@ -12,7 +12,7 @@
<script lang="ts" setup>
import { ref, watch } from 'vue';
-import { useWidgetPropsManager, Widget, WidgetComponentExpose } from './widget';
+import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
import { GetFormResultType } from '@/scripts/form';
import MkContainer from '@/components/MkContainer.vue';
import { defaultStore } from '@/store';
@@ -33,11 +33,8 @@ const widgetPropsDef = {
type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
-// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
-//const props = defineProps<WidgetComponentProps<WidgetProps>>();
-//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
-const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
-const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>();
+const props = defineProps<WidgetComponentProps<WidgetProps>>();
+const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
const { widgetProps, configure } = useWidgetPropsManager(name,
widgetPropsDef,
diff --git a/packages/frontend/src/widgets/WidgetNotifications.vue b/packages/frontend/src/widgets/WidgetNotifications.vue
index 661f68b278..a24aa9b2e9 100644
--- a/packages/frontend/src/widgets/WidgetNotifications.vue
+++ b/packages/frontend/src/widgets/WidgetNotifications.vue
@@ -1,18 +1,18 @@
<template>
-<MkContainer :style="`height: ${widgetProps.height}px;`" :show-header="widgetProps.showHeader" :scrollable="true" data-cy-mkw-notifications class="mkw-notifications">
+<MkContainer :style="`height: ${widgetProps.height}px;`" :showHeader="widgetProps.showHeader" :scrollable="true" data-cy-mkw-notifications class="mkw-notifications">
<template #icon><i class="ti ti-bell"></i></template>
<template #header>{{ i18n.ts.notifications }}</template>
<template #func="{ buttonStyleClass }"><button class="_button" :class="buttonStyleClass" @click="configureNotification()"><i class="ti ti-settings"></i></button></template>
<div>
- <XNotifications :include-types="widgetProps.includingTypes"/>
+ <XNotifications :includeTypes="widgetProps.includingTypes"/>
</div>
</MkContainer>
</template>
<script lang="ts" setup>
import { defineAsyncComponent } from 'vue';
-import { useWidgetPropsManager, Widget, WidgetComponentExpose } from './widget';
+import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
import { GetFormResultType } from '@/scripts/form';
import MkContainer from '@/components/MkContainer.vue';
import XNotifications from '@/components/MkNotifications.vue';
@@ -39,12 +39,9 @@ const widgetPropsDef = {
type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
-// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
-//const props = defineProps<WidgetComponentProps<WidgetProps>>();
-//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
-const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
-const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>();
-
+const props = defineProps<WidgetComponentProps<WidgetProps>>();
+const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
+
const { widgetProps, configure, save } = useWidgetPropsManager(name,
widgetPropsDef,
props,
diff --git a/packages/frontend/src/widgets/WidgetOnlineUsers.vue b/packages/frontend/src/widgets/WidgetOnlineUsers.vue
index 44e073545d..c920c3ca53 100644
--- a/packages/frontend/src/widgets/WidgetOnlineUsers.vue
+++ b/packages/frontend/src/widgets/WidgetOnlineUsers.vue
@@ -1,14 +1,16 @@
<template>
-<div data-cy-mkw-onlineUsers class="mkw-onlineUsers" :class="{ _panel: !widgetProps.transparent, pad: !widgetProps.transparent }">
- <I18n v-if="onlineUsersCount" :src="i18n.ts.onlineUsersCount" text-tag="span" class="text">
- <template #n><b>{{ number(onlineUsersCount) }}</b></template>
- </I18n>
+<div data-cy-mkw-onlineUsers :class="[$style.root, { _panel: !widgetProps.transparent, [$style.pad]: !widgetProps.transparent }]">
+ <span :class="$style.text">
+ <I18n v-if="onlineUsersCount" :src="i18n.ts.onlineUsersCount" textTag="span">
+ <template #n><b style="color: #41b781;">{{ number(onlineUsersCount) }}</b></template>
+ </I18n>
+ </span>
</div>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
-import { useWidgetPropsManager, Widget, WidgetComponentExpose } from './widget';
+import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
import { GetFormResultType } from '@/scripts/form';
import * as os from '@/os';
import { useInterval } from '@/scripts/use-interval';
@@ -26,11 +28,8 @@ const widgetPropsDef = {
type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
-// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
-//const props = defineProps<WidgetComponentProps<WidgetProps>>();
-//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
-const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
-const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>();
+const props = defineProps<WidgetComponentProps<WidgetProps>>();
+const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
const { widgetProps, configure } = useWidgetPropsManager(name,
widgetPropsDef,
@@ -58,22 +57,16 @@ defineExpose<WidgetComponentExpose>({
});
</script>
-<style lang="scss" scoped>
-.mkw-onlineUsers {
+<style lang="scss" module>
+.root {
text-align: center;
&.pad {
padding: 16px 0;
}
+}
- > .text {
- ::v-deep(b) {
- color: #41b781;
- }
-
- ::v-deep(span) {
- opacity: 0.7;
- }
- }
+.text {
+ color: var(--fgTransparentWeak);
}
</style>
diff --git a/packages/frontend/src/widgets/WidgetPhotos.vue b/packages/frontend/src/widgets/WidgetPhotos.vue
index 716bbb4274..5c6a8cbf83 100644
--- a/packages/frontend/src/widgets/WidgetPhotos.vue
+++ b/packages/frontend/src/widgets/WidgetPhotos.vue
@@ -1,5 +1,5 @@
<template>
-<MkContainer :show-header="widgetProps.showHeader" :naked="widgetProps.transparent" :class="$style.root" :data-transparent="widgetProps.transparent ? true : null" data-cy-mkw-photos class="mkw-photos">
+<MkContainer :showHeader="widgetProps.showHeader" :naked="widgetProps.transparent" :class="$style.root" :data-transparent="widgetProps.transparent ? true : null" data-cy-mkw-photos class="mkw-photos">
<template #icon><i class="ti ti-camera"></i></template>
<template #header>{{ i18n.ts._widgets.photos }}</template>
@@ -18,9 +18,9 @@
<script lang="ts" setup>
import { onUnmounted, ref } from 'vue';
-import { useWidgetPropsManager, Widget, WidgetComponentExpose } from './widget';
+import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
import { GetFormResultType } from '@/scripts/form';
-import { stream } from '@/stream';
+import { useStream } from '@/stream';
import { getStaticImageUrl } from '@/scripts/media-proxy';
import * as os from '@/os';
import MkContainer from '@/components/MkContainer.vue';
@@ -42,11 +42,8 @@ const widgetPropsDef = {
type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
-// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
-//const props = defineProps<WidgetComponentProps<WidgetProps>>();
-//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
-const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
-const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>();
+const props = defineProps<WidgetComponentProps<WidgetProps>>();
+const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
const { widgetProps, configure } = useWidgetPropsManager(name,
widgetPropsDef,
@@ -54,7 +51,7 @@ const { widgetProps, configure } = useWidgetPropsManager(name,
emit,
);
-const connection = stream.useChannel('main');
+const connection = useStream().useChannel('main');
const images = ref([]);
const fetching = ref(true);
diff --git a/packages/frontend/src/widgets/WidgetPostForm.vue b/packages/frontend/src/widgets/WidgetPostForm.vue
index 7a96b00217..bc63f02821 100644
--- a/packages/frontend/src/widgets/WidgetPostForm.vue
+++ b/packages/frontend/src/widgets/WidgetPostForm.vue
@@ -4,7 +4,7 @@
<script lang="ts" setup>
import { } from 'vue';
-import { useWidgetPropsManager, Widget, WidgetComponentExpose } from './widget';
+import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
import { GetFormResultType } from '@/scripts/form';
import MkPostForm from '@/components/MkPostForm.vue';
@@ -15,11 +15,8 @@ const widgetPropsDef = {
type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
-// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
-//const props = defineProps<WidgetComponentProps<WidgetProps>>();
-//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
-const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
-const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>();
+const props = defineProps<WidgetComponentProps<WidgetProps>>();
+const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
const { widgetProps, configure } = useWidgetPropsManager(name,
widgetPropsDef,
diff --git a/packages/frontend/src/widgets/WidgetProfile.vue b/packages/frontend/src/widgets/WidgetProfile.vue
index 819663a366..72e229ef8f 100644
--- a/packages/frontend/src/widgets/WidgetProfile.vue
+++ b/packages/frontend/src/widgets/WidgetProfile.vue
@@ -17,7 +17,7 @@
</template>
<script lang="ts" setup>
-import { useWidgetPropsManager, Widget, WidgetComponentExpose } from './widget';
+import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
import { GetFormResultType } from '@/scripts/form';
import { $i } from '@/account';
import { userPage } from '@/filters/user';
@@ -29,11 +29,8 @@ const widgetPropsDef = {
type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
-// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
-//const props = defineProps<WidgetComponentProps<WidgetProps>>();
-//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
-const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
-const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>();
+const props = defineProps<WidgetComponentProps<WidgetProps>>();
+const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
const { widgetProps, configure } = useWidgetPropsManager(name,
widgetPropsDef,
diff --git a/packages/frontend/src/widgets/WidgetRss.vue b/packages/frontend/src/widgets/WidgetRss.vue
index 18fa2e2c22..1be882c66d 100644
--- a/packages/frontend/src/widgets/WidgetRss.vue
+++ b/packages/frontend/src/widgets/WidgetRss.vue
@@ -1,5 +1,5 @@
<template>
-<MkContainer :show-header="widgetProps.showHeader" data-cy-mkw-rss class="mkw-rss">
+<MkContainer :showHeader="widgetProps.showHeader" data-cy-mkw-rss class="mkw-rss">
<template #icon><i class="ti ti-rss"></i></template>
<template #header>RSS</template>
<template #func="{ buttonStyleClass }"><button class="_button" :class="buttonStyleClass" @click="configure"><i class="ti ti-settings"></i></button></template>
@@ -19,7 +19,7 @@
<script lang="ts" setup>
import { ref, watch, computed } from 'vue';
-import { useWidgetPropsManager, Widget, WidgetComponentExpose } from './widget';
+import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
import { GetFormResultType } from '@/scripts/form';
import MkContainer from '@/components/MkContainer.vue';
import { url as base } from '@/config';
@@ -49,11 +49,8 @@ const widgetPropsDef = {
type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
-// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
-//const props = defineProps<WidgetComponentProps<WidgetProps>>();
-//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
-const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
-const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>();
+const props = defineProps<WidgetComponentProps<WidgetProps>>();
+const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
const { widgetProps, configure } = useWidgetPropsManager(name,
widgetPropsDef,
diff --git a/packages/frontend/src/widgets/WidgetRssTicker.vue b/packages/frontend/src/widgets/WidgetRssTicker.vue
index b0408f0d7f..6b346c0598 100644
--- a/packages/frontend/src/widgets/WidgetRssTicker.vue
+++ b/packages/frontend/src/widgets/WidgetRssTicker.vue
@@ -1,5 +1,5 @@
<template>
-<MkContainer :naked="widgetProps.transparent" :show-header="widgetProps.showHeader" class="mkw-rss-ticker">
+<MkContainer :naked="widgetProps.transparent" :showHeader="widgetProps.showHeader" class="mkw-rss-ticker">
<template #icon><i class="ti ti-rss"></i></template>
<template #header>RSS</template>
<template #func="{ buttonStyleClass }"><button class="_button" :class="buttonStyleClass" @click="configure"><i class="ti ti-settings"></i></button></template>
@@ -12,7 +12,7 @@
<Transition :name="$style.change" mode="default" appear>
<MarqueeText :key="key" :duration="widgetProps.duration" :reverse="widgetProps.reverse">
<span v-for="item in items" :key="item.link" :class="$style.item">
- <a :class="$style.link" :href="item.link" rel="nofollow noopener" target="_blank" :title="item.title">{{ item.title }}</a><span :class="$style.divider"></span>
+ <a :href="item.link" rel="nofollow noopener" target="_blank" :title="item.title">{{ item.title }}</a><span :class="$style.divider"></span>
</span>
</MarqueeText>
</Transition>
@@ -23,7 +23,7 @@
<script lang="ts" setup>
import { ref, watch, computed } from 'vue';
-import { useWidgetPropsManager, Widget, WidgetComponentExpose } from './widget';
+import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
import MarqueeText from '@/components/MkMarquee.vue';
import { GetFormResultType } from '@/scripts/form';
import MkContainer from '@/components/MkContainer.vue';
@@ -73,11 +73,8 @@ const widgetPropsDef = {
type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
-// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
-//const props = defineProps<WidgetComponentProps<WidgetProps>>();
-//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
-const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
-const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>();
+const props = defineProps<WidgetComponentProps<WidgetProps>>();
+const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
const { widgetProps, configure } = useWidgetPropsManager(name,
widgetPropsDef,
diff --git a/packages/frontend/src/widgets/WidgetSlideshow.vue b/packages/frontend/src/widgets/WidgetSlideshow.vue
index 915e7aaaf4..d4ede57926 100644
--- a/packages/frontend/src/widgets/WidgetSlideshow.vue
+++ b/packages/frontend/src/widgets/WidgetSlideshow.vue
@@ -13,7 +13,7 @@
<script lang="ts" setup>
import { onMounted, ref, shallowRef } from 'vue';
-import { useWidgetPropsManager, Widget, WidgetComponentExpose } from './widget';
+import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
import { GetFormResultType } from '@/scripts/form';
import * as os from '@/os';
import { useInterval } from '@/scripts/use-interval';
@@ -35,11 +35,8 @@ const widgetPropsDef = {
type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
-// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
-//const props = defineProps<WidgetComponentProps<WidgetProps>>();
-//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
-const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
-const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>();
+const props = defineProps<WidgetComponentProps<WidgetProps>>();
+const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
const { widgetProps, configure, save } = useWidgetPropsManager(name,
widgetPropsDef,
diff --git a/packages/frontend/src/widgets/WidgetTimeline.vue b/packages/frontend/src/widgets/WidgetTimeline.vue
index 71ee75f6cb..3d497c2e23 100644
--- a/packages/frontend/src/widgets/WidgetTimeline.vue
+++ b/packages/frontend/src/widgets/WidgetTimeline.vue
@@ -1,5 +1,5 @@
<template>
-<MkContainer :show-header="widgetProps.showHeader" :style="`height: ${widgetProps.height}px;`" :scrollable="true" data-cy-mkw-timeline class="mkw-timeline">
+<MkContainer :showHeader="widgetProps.showHeader" :style="`height: ${widgetProps.height}px;`" :scrollable="true" data-cy-mkw-timeline class="mkw-timeline">
<template #icon>
<i v-if="widgetProps.src === 'home'" class="ti ti-home"></i>
<i v-else-if="widgetProps.src === 'local'" class="ti ti-planet"></i>
@@ -30,7 +30,7 @@
<script lang="ts" setup>
import { ref } from 'vue';
-import { useWidgetPropsManager, Widget, WidgetComponentExpose } from './widget';
+import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
import { GetFormResultType } from '@/scripts/form';
import * as os from '@/os';
import MkContainer from '@/components/MkContainer.vue';
@@ -71,11 +71,8 @@ const widgetPropsDef = {
type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
-// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
-//const props = defineProps<WidgetComponentProps<WidgetProps>>();
-//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
-const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
-const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>();
+const props = defineProps<WidgetComponentProps<WidgetProps>>();
+const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
const { widgetProps, configure, save } = useWidgetPropsManager(name,
widgetPropsDef,
diff --git a/packages/frontend/src/widgets/WidgetTrends.vue b/packages/frontend/src/widgets/WidgetTrends.vue
index 01450a7ab5..36f908d5ea 100644
--- a/packages/frontend/src/widgets/WidgetTrends.vue
+++ b/packages/frontend/src/widgets/WidgetTrends.vue
@@ -1,5 +1,5 @@
<template>
-<MkContainer :show-header="widgetProps.showHeader" data-cy-mkw-trends class="mkw-trends">
+<MkContainer :showHeader="widgetProps.showHeader" data-cy-mkw-trends class="mkw-trends">
<template #icon><i class="ti ti-hash"></i></template>
<template #header>{{ i18n.ts._widgets.trends }}</template>
@@ -20,7 +20,7 @@
<script lang="ts" setup>
import { ref } from 'vue';
-import { useWidgetPropsManager, Widget, WidgetComponentExpose } from './widget';
+import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
import { GetFormResultType } from '@/scripts/form';
import MkContainer from '@/components/MkContainer.vue';
import MkMiniChart from '@/components/MkMiniChart.vue';
@@ -40,11 +40,8 @@ const widgetPropsDef = {
type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
-// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
-//const props = defineProps<WidgetComponentProps<WidgetProps>>();
-//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
-const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
-const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>();
+const props = defineProps<WidgetComponentProps<WidgetProps>>();
+const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
const { widgetProps, configure } = useWidgetPropsManager(name,
widgetPropsDef,
diff --git a/packages/frontend/src/widgets/WidgetUnixClock.vue b/packages/frontend/src/widgets/WidgetUnixClock.vue
index 22162d2b2c..f1af71adda 100644
--- a/packages/frontend/src/widgets/WidgetUnixClock.vue
+++ b/packages/frontend/src/widgets/WidgetUnixClock.vue
@@ -12,7 +12,7 @@
<script lang="ts" setup>
import { onUnmounted, ref, watch } from 'vue';
-import { useWidgetPropsManager, Widget, WidgetComponentExpose } from './widget';
+import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
import { GetFormResultType } from '@/scripts/form';
const name = 'unixClock';
@@ -39,11 +39,8 @@ const widgetPropsDef = {
type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
-// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
-//const props = defineProps<WidgetComponentProps<WidgetProps>>();
-//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
-const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
-const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>();
+const props = defineProps<WidgetComponentProps<WidgetProps>>();
+const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
const { widgetProps, configure } = useWidgetPropsManager(name,
widgetPropsDef,
diff --git a/packages/frontend/src/widgets/WidgetUserList.vue b/packages/frontend/src/widgets/WidgetUserList.vue
index b8811d2fed..4380fdb62f 100644
--- a/packages/frontend/src/widgets/WidgetUserList.vue
+++ b/packages/frontend/src/widgets/WidgetUserList.vue
@@ -1,5 +1,5 @@
<template>
-<MkContainer :show-header="widgetProps.showHeader" class="mkw-userList">
+<MkContainer :showHeader="widgetProps.showHeader" class="mkw-userList">
<template #icon><i class="ti ti-users"></i></template>
<template #header>{{ list ? list.name : i18n.ts._widgets.userList }}</template>
<template #func="{ buttonStyleClass }"><button class="_button" :class="buttonStyleClass" @click="configure()"><i class="ti ti-settings"></i></button></template>
@@ -19,7 +19,7 @@
</template>
<script lang="ts" setup>
-import { useWidgetPropsManager, Widget, WidgetComponentExpose } from './widget';
+import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
import { GetFormResultType } from '@/scripts/form';
import MkContainer from '@/components/MkContainer.vue';
import * as os from '@/os';
@@ -43,11 +43,8 @@ const widgetPropsDef = {
type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
-// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
-//const props = defineProps<WidgetComponentProps<WidgetProps>>();
-//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
-const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
-const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>();
+const props = defineProps<WidgetComponentProps<WidgetProps>>();
+const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
const { widgetProps, configure, save } = useWidgetPropsManager(name,
widgetPropsDef,
diff --git a/packages/frontend/src/widgets/server-metric/index.vue b/packages/frontend/src/widgets/server-metric/index.vue
index 357d0ab78b..e019ff540b 100644
--- a/packages/frontend/src/widgets/server-metric/index.vue
+++ b/packages/frontend/src/widgets/server-metric/index.vue
@@ -1,5 +1,5 @@
<template>
-<MkContainer :show-header="widgetProps.showHeader" :naked="widgetProps.transparent">
+<MkContainer :showHeader="widgetProps.showHeader" :naked="widgetProps.transparent">
<template #icon><i class="ti ti-server"></i></template>
<template #header>{{ i18n.ts._widgets.serverMetric }}</template>
<template #func="{ buttonStyleClass }"><button class="_button" :class="buttonStyleClass" @click="toggleView()"><i class="ti ti-selector"></i></button></template>
@@ -25,7 +25,7 @@ import XDisk from './disk.vue';
import MkContainer from '@/components/MkContainer.vue';
import { GetFormResultType } from '@/scripts/form';
import * as os from '@/os';
-import { stream } from '@/stream';
+import { useStream } from '@/stream';
import { i18n } from '@/i18n';
const name = 'serverMetric';
@@ -75,7 +75,7 @@ const toggleView = () => {
save();
};
-const connection = stream.useChannel('serverStats');
+const connection = useStream().useChannel('serverStats');
onUnmounted(() => {
connection.dispose();
});
diff --git a/packages/frontend/src/widgets/server-metric/pie.vue b/packages/frontend/src/widgets/server-metric/pie.vue
index 868dbc0484..398815a6ae 100644
--- a/packages/frontend/src/widgets/server-metric/pie.vue
+++ b/packages/frontend/src/widgets/server-metric/pie.vue
@@ -1,11 +1,12 @@
<template>
-<svg class="hsalcinq" viewBox="0 0 1 1" preserveAspectRatio="none">
+<svg :class="$style.root" viewBox="0 0 1 1" preserveAspectRatio="none">
<circle
:r="r"
cx="50%" cy="50%"
fill="none"
stroke-width="0.1"
stroke="rgba(0, 0, 0, 0.05)"
+ :class="$style.circle"
/>
<circle
:r="r"
@@ -16,7 +17,7 @@
stroke-width="0.1"
:stroke="color"
/>
- <text x="50%" y="50%" dy="0.05" text-anchor="middle">{{ (value * 100).toFixed(0) }}%</text>
+ <text x="50%" y="50%" dy="0.05" text-anchor="middle" :class="$style.text">{{ (value * 100).toFixed(0) }}%</text>
</svg>
</template>
@@ -33,20 +34,20 @@ const color = $computed(() => `hsl(${180 - (props.value * 180)}, 80%, 70%)`);
const strokeDashoffset = $computed(() => (1 - props.value) * (Math.PI * (r * 2)));
</script>
-<style lang="scss" scoped>
-.hsalcinq {
+<style lang="scss" module>
+.root {
display: block;
height: 100%;
+}
- > circle {
- transform-origin: center;
- transform: rotate(-90deg);
- transition: stroke-dashoffset 0.5s ease;
- }
+.circle {
+ transform-origin: center;
+ transform: rotate(-90deg);
+ transition: stroke-dashoffset 0.5s ease;
+}
- > text {
- font-size: 0.15px;
- fill: currentColor;
- }
+.text {
+ font-size: 0.15px;
+ fill: currentColor;
}
</style>
diff --git a/packages/frontend/src/workers/draw-blurhash.ts b/packages/frontend/src/workers/draw-blurhash.ts
new file mode 100644
index 0000000000..5f2168a44a
--- /dev/null
+++ b/packages/frontend/src/workers/draw-blurhash.ts
@@ -0,0 +1,15 @@
+import { render } from 'buraha';
+
+onmessage = (event) => {
+ // console.log(event.data);
+ if (!('id' in event.data && typeof event.data.id === 'string')) {
+ return;
+ }
+ if (!('hash' in event.data && typeof event.data.hash === 'string')) {
+ return;
+ }
+ const work = new OffscreenCanvas(event.data.width ?? 64, event.data.height ?? 64);
+ render(event.data.hash, work);
+ const bitmap = work.transferToImageBitmap();
+ postMessage({ id: event.data.id, bitmap });
+};
diff --git a/packages/frontend/src/workers/test-webgl2.ts b/packages/frontend/src/workers/test-webgl2.ts
new file mode 100644
index 0000000000..4769524d9c
--- /dev/null
+++ b/packages/frontend/src/workers/test-webgl2.ts
@@ -0,0 +1,7 @@
+const canvas = new OffscreenCanvas(1, 1);
+const gl = canvas.getContext('webgl2');
+if (gl) {
+ postMessage({ result: true });
+} else {
+ postMessage({ result: false });
+}
diff --git a/packages/frontend/src/workers/tsconfig.json b/packages/frontend/src/workers/tsconfig.json
new file mode 100644
index 0000000000..8ee8930465
--- /dev/null
+++ b/packages/frontend/src/workers/tsconfig.json
@@ -0,0 +1,5 @@
+{
+ "compilerOptions": {
+ "lib": ["esnext", "webworker"],
+ }
+}
diff --git a/packages/frontend/vite.config.ts b/packages/frontend/vite.config.ts
index fad0dd0177..531dd0b488 100644
--- a/packages/frontend/vite.config.ts
+++ b/packages/frontend/vite.config.ts
@@ -6,7 +6,9 @@ import { type UserConfig, defineConfig } from 'vite';
import ReactivityTransform from '@vue-macros/reactivity-transform/vite';
import locales from '../../locales';
+import generateDTS from '../../locales/generateDTS';
import meta from '../../package.json';
+import pluginUnwindCssModuleClassName from './lib/rollup-plugin-unwind-css-module-class-name';
import pluginJson5 from './vite.json5';
const extensions = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.json', '.json5', '.svg', '.sass', '.scss', '.css', '.vue'];
@@ -53,6 +55,7 @@ export function getConfig(): UserConfig {
reactivityTransform: true,
}),
ReactivityTransform(),
+ pluginUnwindCssModuleClassName(),
pluginJson5(),
...process.env.NODE_ENV === 'production'
? [
@@ -64,6 +67,10 @@ export function getConfig(): UserConfig {
}),
]
: [],
+ {
+ name: 'locale:generateDTS',
+ buildStart: generateDTS,
+ },
],
resolve: {
@@ -117,13 +124,15 @@ export function getConfig(): UserConfig {
manifest: 'manifest.json',
rollupOptions: {
input: {
- app: './src/init.ts',
+ app: './src/_boot_.ts',
},
output: {
manualChunks: {
vue: ['vue'],
photoswipe: ['photoswipe', 'photoswipe/lightbox', 'photoswipe/style.css'],
},
+ chunkFileNames: process.env.NODE_ENV === 'production' ? '[hash:8].js' : '[name]-[hash:8].js',
+ assetFileNames: process.env.NODE_ENV === 'production' ? '[hash:8][extname]' : '[name]-[hash:8][extname]',
},
},
cssCodeSplit: true,
@@ -139,6 +148,10 @@ export function getConfig(): UserConfig {
},
},
+ worker: {
+ format: 'es',
+ },
+
test: {
environment: 'happy-dom',
deps: {
diff --git a/packages/shared/.eslintrc.js b/packages/shared/.eslintrc.js
index 7c979a93dc..a53ad17894 100644
--- a/packages/shared/.eslintrc.js
+++ b/packages/shared/.eslintrc.js
@@ -49,7 +49,7 @@ module.exports = {
'no-multi-spaces': ['error'],
'no-var': ['error'],
'prefer-arrow-callback': ['error'],
- 'no-throw-literal': ['warn'],
+ 'no-throw-literal': ['error'],
'no-param-reassign': ['warn'],
'no-constant-condition': ['warn'],
'no-empty-pattern': ['warn'],