The response has been limited to 50k tokens of the smallest files in the repo. You can remove this limitation by removing the max tokens filter.
├── .dockerignore
├── .env.example
├── .env.mock
├── .gitattributes
├── .github
    ├── FUNDING.yml
    ├── ISSUE_TEMPLATE
    │   ├── bug_report.md
    │   ├── config.yml
    │   └── feature_request.md
    ├── renovate.json5
    └── workflows
    │   ├── ci.yml
    │   ├── docker.yml
    │   ├── release.yml
    │   └── semantic-pull-request.yml
├── .gitignore
├── .npmrc
├── .nvmrc
├── .stackblitz
    └── codeflow.json
├── .stackblitzrc
├── .vscode
    ├── extensions.json
    └── settings.json
├── CODE_OF_CONDUCT.md
├── CONTRIBUTING.md
├── Dockerfile
├── LICENSE
├── README.md
├── app
    ├── app.vue
    ├── components
    │   ├── account
    │   │   ├── AccountAvatar.vue
    │   │   ├── AccountBigAvatar.vue
    │   │   ├── AccountBigCard.vue
    │   │   ├── AccountBigCardSkeleton.vue
    │   │   ├── AccountBotIndicator.vue
    │   │   ├── AccountCard.vue
    │   │   ├── AccountDisplayName.vue
    │   │   ├── AccountFollowButton.vue
    │   │   ├── AccountFollowRequestButton.vue
    │   │   ├── AccountHandle.vue
    │   │   ├── AccountHeader.vue
    │   │   ├── AccountHoverCard.vue
    │   │   ├── AccountHoverWrapper.vue
    │   │   ├── AccountInfo.vue
    │   │   ├── AccountInlineInfo.vue
    │   │   ├── AccountLockIndicator.vue
    │   │   ├── AccountMoreButton.vue
    │   │   ├── AccountMoved.vue
    │   │   ├── AccountPaginator.vue
    │   │   ├── AccountPostsFollowers.vue
    │   │   ├── AccountRolesIndicator.vue
    │   │   ├── AccountTabs.vue
    │   │   └── TagHoverWrapper.vue
    │   ├── aria
    │   │   ├── AriaAnnouncer.vue
    │   │   ├── AriaLog.vue
    │   │   └── AriaStatus.vue
    │   ├── command
    │   │   ├── CommandItem.vue
    │   │   ├── CommandKey.vue
    │   │   └── CommandPanel.vue
    │   ├── common
    │   │   ├── AnimateNumber.vue
    │   │   ├── CommonAlert.vue
    │   │   ├── CommonBlurhash.vue
    │   │   ├── CommonCheckbox.vue
    │   │   ├── CommonCropImage.vue
    │   │   ├── CommonErrorMessage.vue
    │   │   ├── CommonInputImage.vue
    │   │   ├── CommonMask.vue
    │   │   ├── CommonNotFound.vue
    │   │   ├── CommonPaginator.vue
    │   │   ├── CommonPreviewPrompt.vue
    │   │   ├── CommonRadio.vue
    │   │   ├── CommonRouteTabs.vue
    │   │   ├── CommonScrollIntoView.vue
    │   │   ├── CommonTabs.vue
    │   │   ├── CommonTooltip.vue
    │   │   ├── CommonTrending.vue
    │   │   ├── CommonTrendingCharts.vue
    │   │   ├── LocalizedNumber.vue
    │   │   ├── OfflineChecker.vue
    │   │   └── dropdown
    │   │   │   ├── Dropdown.vue
    │   │   │   └── DropdownItem.vue
    │   ├── content
    │   │   ├── ContentCode.vue
    │   │   ├── ContentMentionGroup.vue
    │   │   └── ContentRich.setup.ts
    │   ├── conversation
    │   │   ├── ConversationCard.vue
    │   │   └── ConversationPaginator.vue
    │   ├── emoji
    │   │   └── Emoji.vue
    │   ├── help
    │   │   └── HelpPreview.vue
    │   ├── list
    │   │   ├── Account.vue
    │   │   ├── AccountSearchResult.vue
    │   │   ├── ListEntry.vue
    │   │   └── Lists.vue
    │   ├── magickeys
    │   │   └── MagickeysKeyboardShortcuts.vue
    │   ├── main
    │   │   └── MainContent.vue
    │   ├── modal
    │   │   ├── DurationPicker.vue
    │   │   ├── ModalConfirm.vue
    │   │   ├── ModalContainer.vue
    │   │   ├── ModalDialog.vue
    │   │   ├── ModalError.vue
    │   │   ├── ModalMediaPreview.vue
    │   │   └── ModalMediaPreviewCarousel.vue
    │   ├── nav
    │   │   ├── NavBottom.vue
    │   │   ├── NavBottomMoreMenu.vue
    │   │   ├── NavFooter.vue
    │   │   ├── NavLogo.vue
    │   │   ├── NavSide.vue
    │   │   ├── NavSideItem.vue
    │   │   ├── NavTitle.vue
    │   │   ├── NavUser.vue
    │   │   ├── NavUserSkeleton.vue
    │   │   └── button
    │   │   │   ├── Bookmark.vue
    │   │   │   ├── Compose.vue
    │   │   │   ├── Explore.vue
    │   │   │   ├── Favorite.vue
    │   │   │   ├── Federated.vue
    │   │   │   ├── Hashtag.vue
    │   │   │   ├── Home.vue
    │   │   │   ├── List.vue
    │   │   │   ├── Local.vue
    │   │   │   ├── Mention.vue
    │   │   │   ├── MoreMenu.vue
    │   │   │   ├── Notification.vue
    │   │   │   └── Search.vue
    │   ├── notification
    │   │   ├── NotificationCard.vue
    │   │   ├── NotificationEnablePushNotification.client.vue
    │   │   ├── NotificationGroupedFollow.vue
    │   │   ├── NotificationGroupedLikes.vue
    │   │   ├── NotificationPaginator.vue
    │   │   ├── NotificationPreferences.client.vue
    │   │   └── NotificationSubscribePushNotificationError.vue
    │   ├── publish
    │   │   ├── PublishAttachment.vue
    │   │   ├── PublishCharacterCounter.vue
    │   │   ├── PublishEditorTools.vue
    │   │   ├── PublishEmojiPicker.client.vue
    │   │   ├── PublishLanguagePicker.vue
    │   │   ├── PublishThreadTools.vue
    │   │   ├── PublishVisibilityPicker.vue
    │   │   ├── PublishWidget.vue
    │   │   ├── PublishWidgetFull.client.vue
    │   │   └── PublishWidgetList.vue
    │   ├── pwa
    │   │   ├── PwaBadge.client.vue
    │   │   ├── PwaInstallPrompt.client.vue
    │   │   └── PwaPrompt.client.vue
    │   ├── report
    │   │   └── ReportModal.vue
    │   ├── search
    │   │   ├── SearchAccountInfo.vue
    │   │   ├── SearchEmojiInfo.vue
    │   │   ├── SearchHashtagInfo.vue
    │   │   ├── SearchResult.vue
    │   │   ├── SearchResultSkeleton.vue
    │   │   └── SearchWidget.vue
    │   ├── settings
    │   │   ├── SettingsBottomNav.vue
    │   │   ├── SettingsColorMode.vue
    │   │   ├── SettingsFontSize.vue
    │   │   ├── SettingsItem.vue
    │   │   ├── SettingsLanguage.vue
    │   │   ├── SettingsProfileMetadata.vue
    │   │   ├── SettingsSponsorsList.vue
    │   │   ├── SettingsThemeColors.vue
    │   │   ├── SettingsToggleItem.vue
    │   │   └── SettingsTranslations.vue
    │   ├── status
    │   │   ├── StatusAccountDetails.vue
    │   │   ├── StatusActionButton.vue
    │   │   ├── StatusActions.vue
    │   │   ├── StatusActionsMore.vue
    │   │   ├── StatusAttachment.vue
    │   │   ├── StatusBody.vue
    │   │   ├── StatusCard.vue
    │   │   ├── StatusCardSkeleton.vue
    │   │   ├── StatusContent.vue
    │   │   ├── StatusDetails.vue
    │   │   ├── StatusEmbeddedMedia.vue
    │   │   ├── StatusFavouritedBoostedBy.vue
    │   │   ├── StatusLink.vue
    │   │   ├── StatusMedia.vue
    │   │   ├── StatusNotFound.vue
    │   │   ├── StatusPoll.vue
    │   │   ├── StatusPreviewCard.vue
    │   │   ├── StatusPreviewCardInfo.vue
    │   │   ├── StatusPreviewCardMoreFromAuthor.vue
    │   │   ├── StatusPreviewCardNormal.vue
    │   │   ├── StatusPreviewCardSkeleton.vue
    │   │   ├── StatusPreviewGitHub.vue
    │   │   ├── StatusPreviewStackBlitz.vue
    │   │   ├── StatusReplyingTo.vue
    │   │   ├── StatusSpoiler.vue
    │   │   ├── StatusTranslation.vue
    │   │   ├── StatusVisibilityIndicator.vue
    │   │   └── edit
    │   │   │   ├── StatusEditHistory.vue
    │   │   │   ├── StatusEditHistorySkeleton.vue
    │   │   │   ├── StatusEditIndicator.vue
    │   │   │   └── StatusEditPreview.vue
    │   ├── tag
    │   │   ├── TagActionButton.vue
    │   │   ├── TagCard.vue
    │   │   ├── TagCardPaginator.vue
    │   │   └── TagCardSkeleton.vue
    │   ├── timeline
    │   │   ├── TimelineBlocks.vue
    │   │   ├── TimelineBookmarks.vue
    │   │   ├── TimelineConversations.vue
    │   │   ├── TimelineDomainBlocks.vue
    │   │   ├── TimelineFavourites.vue
    │   │   ├── TimelineHome.vue
    │   │   ├── TimelineMutes.vue
    │   │   ├── TimelineNotifications.vue
    │   │   ├── TimelinePaginator.vue
    │   │   ├── TimelinePinned.vue
    │   │   ├── TimelinePublic.vue
    │   │   ├── TimelinePublicLocal.vue
    │   │   └── TimelineSkeleton.vue
    │   ├── tiptap
    │   │   ├── TiptapCodeBlock.vue
    │   │   ├── TiptapEmojiList.vue
    │   │   ├── TiptapHashtagList.vue
    │   │   └── TiptapMentionList.vue
    │   └── user
    │   │   ├── UserDropdown.vue
    │   │   ├── UserPicker.vue
    │   │   ├── UserSignIn.vue
    │   │   ├── UserSignInEntry.vue
    │   │   └── UserSwitcher.vue
    ├── composables
    │   ├── about.ts
    │   ├── aria.ts
    │   ├── cache.ts
    │   ├── command.ts
    │   ├── content-parse.ts
    │   ├── content-render.ts
    │   ├── dialog.ts
    │   ├── emojis.ts
    │   ├── i18n.ts
    │   ├── idb
    │   │   └── index.ts
    │   ├── injections.ts
    │   ├── langugage.ts
    │   ├── magickeys.ts
    │   ├── mask.ts
    │   ├── masto
    │   │   ├── account.ts
    │   │   ├── icons.ts
    │   │   ├── masto.ts
    │   │   ├── notification.ts
    │   │   ├── publish.ts
    │   │   ├── relationship.ts
    │   │   ├── routes.ts
    │   │   ├── search.ts
    │   │   ├── status.ts
    │   │   ├── statusDrafts.ts
    │   │   └── translate.ts
    │   ├── misc.ts
    │   ├── notification.ts
    │   ├── paginator.ts
    │   ├── push-notifications
    │   │   ├── createPushSubscription.ts
    │   │   ├── types.ts
    │   │   └── usePushManager.ts
    │   ├── screen.ts
    │   ├── settings
    │   │   ├── definition.ts
    │   │   ├── index.ts
    │   │   ├── metadata.ts
    │   │   └── storage.ts
    │   ├── setups.ts
    │   ├── shiki.ts
    │   ├── sign-in.ts
    │   ├── thread.ts
    │   ├── timeline.ts
    │   ├── tiptap.ts
    │   ├── tiptap
    │   │   ├── custom-emoji.ts
    │   │   ├── emoji.ts
    │   │   ├── shiki-parser.ts
    │   │   ├── shiki.ts
    │   │   └── suggestion.ts
    │   ├── users.ts
    │   ├── vue.ts
    │   └── web-share-target.ts
    ├── constants
    │   ├── index.ts
    │   ├── options.ts
    │   ├── symbols.ts
    │   └── themes.json
    ├── error.vue
    ├── layouts
    │   ├── default.vue
    │   └── none.vue
    ├── middleware
    │   ├── 1.permalink.global.ts
    │   ├── 2.single-instance.global.ts
    │   └── auth.ts
    ├── pages
    │   ├── [...permalink].vue
    │   ├── [[server]]
    │   │   ├── @[account]
    │   │   │   ├── [status].vue
    │   │   │   ├── index.vue
    │   │   │   └── index
    │   │   │   │   ├── followers.vue
    │   │   │   │   ├── following.vue
    │   │   │   │   ├── index.vue
    │   │   │   │   ├── media.vue
    │   │   │   │   └── with_replies.vue
    │   │   ├── explore.vue
    │   │   ├── explore
    │   │   │   ├── index.vue
    │   │   │   ├── links.vue
    │   │   │   ├── tags.vue
    │   │   │   └── users.vue
    │   │   ├── index.vue
    │   │   ├── list
    │   │   │   └── [list]
    │   │   │   │   ├── index.vue
    │   │   │   │   └── index
    │   │   │   │       ├── accounts.vue
    │   │   │   │       └── index.vue
    │   │   ├── lists.vue
    │   │   ├── lists
    │   │   │   └── index.vue
    │   │   ├── public
    │   │   │   ├── index.vue
    │   │   │   └── local.vue
    │   │   ├── search.vue
    │   │   ├── status
    │   │   │   └── [status].vue
    │   │   └── tags
    │   │   │   └── [tag].vue
    │   ├── blocks.vue
    │   ├── bookmarks.vue
    │   ├── compose.vue
    │   ├── conversations.vue
    │   ├── domain_blocks.vue
    │   ├── favourites.vue
    │   ├── hashtags.vue
    │   ├── hashtags
    │   │   └── index.vue
    │   ├── home.vue
    │   ├── index.vue
    │   ├── intent
    │   │   └── post.vue
    │   ├── mutes.vue
    │   ├── notifications.vue
    │   ├── notifications
    │   │   ├── [filter].vue
    │   │   └── index.vue
    │   ├── pinned.vue
    │   ├── settings.vue
    │   ├── settings
    │   │   ├── about
    │   │   │   └── index.vue
    │   │   ├── index.vue
    │   │   ├── interface
    │   │   │   └── index.vue
    │   │   ├── language
    │   │   │   └── index.vue
    │   │   ├── notifications
    │   │   │   ├── index.vue
    │   │   │   ├── notifications.vue
    │   │   │   └── push-notifications.vue
    │   │   ├── preferences
    │   │   │   └── index.vue
    │   │   ├── profile
    │   │   │   ├── appearance.vue
    │   │   │   ├── featured-tags.vue
    │   │   │   └── index.vue
    │   │   └── users
    │   │   │   └── index.vue
    │   └── share-target.vue
    ├── plugins
    │   ├── 0.setup-users.ts
    │   ├── 1.scroll-to-top.ts
    │   ├── color-mode.ts
    │   ├── floating-vue.ts
    │   ├── hydration.client.ts
    │   ├── magic-keys.client.ts
    │   ├── page-lifecycle.client.ts
    │   ├── path.ts
    │   ├── setup-global-effects.client.ts
    │   ├── setup-head-script.server.ts
    │   ├── setup-i18n.ts
    │   └── social.server.ts
    ├── styles
    │   ├── default-theme.css
    │   ├── dropdown.css
    │   ├── global.css
    │   ├── scrollbars.css
    │   ├── tiptap.css
    │   └── vars.css
    └── utils
    │   ├── elk-idb.ts
    │   ├── i18n.ts
    │   └── language.ts
├── config
    ├── emojis.ts
    ├── env.ts
    ├── i18n.config.ts
    ├── i18n.ts
    └── pwa.ts
├── docker-compose.yaml
├── docs
    ├── .env.example
    ├── .gitignore
    ├── README.md
    ├── app.config.ts
    ├── app.vue
    ├── components
    │   └── global
    │   │   ├── ClipboardIcon.vue
    │   │   ├── IconMastodon.vue
    │   │   ├── Logo.vue
    │   │   ├── ToggleIcon.vue
    │   │   └── TranslationState.vue
    ├── content
    │   ├── 0.index.md
    │   ├── 1.guide
    │   │   ├── 1.index.md
    │   │   ├── 2.features.md
    │   │   ├── 3.contributing.md
    │   │   └── 4.sponsoring.md
    │   ├── 2.deployment
    │   │   └── 1.netlify.md
    │   ├── 80.pwa.md
    │   └── 99.privacy.md
    ├── netlify.toml
    ├── nuxt.config.ts
    ├── package.json
    ├── public
    │   ├── apple-touch-icon.png
    │   ├── elk-screenshot.png
    │   ├── favicon.ico
    │   ├── fonts
    │   │   └── DM-sans-v11.ttf
    │   ├── images
    │   │   ├── nuxtlabs.svg
    │   │   ├── selfhosting-guide
    │   │   │   ├── cf-api-token-settings.png
    │   │   │   └── github-fork.png
    │   │   └── stackblitz.svg
    │   ├── logo.svg
    │   ├── pwa-192x192.png
    │   ├── pwa-512x512.png
    │   ├── screenshot.png
    │   └── site.webmanifest
    ├── tokens.config.ts
    ├── tsconfig.json
    └── types.ts
├── elk.svg
├── emoji-mart-traslation.d.ts
├── eslint.config.js
├── https-dev-config
    ├── local-https-server.mjs
    ├── localhost.crt
    └── localhost.key
├── images
    ├── nuxtlabs.svg
    └── stackblitz.svg
├── locales
    ├── ar-EG.json
    ├── ar.json
    ├── ca-ES.json
    ├── ca-valencia.json
    ├── ca.json
    ├── ckb.json
    ├── cs-CZ.json
    ├── cy.json
    ├── de-DE.json
    ├── el-GR.json
    ├── en-CA.json
    ├── en-GB.json
    ├── en-US.json
    ├── en.json
    ├── es-419.json
    ├── es-ES.json
    ├── es.json
    ├── eu-ES.json
    ├── fa-IR.json
    ├── fi.json
    ├── fr-FR.json
    ├── gl-ES.json
    ├── hu-HU.json
    ├── id-ID.json
    ├── it-IT.json
    ├── ja-JP.json
    ├── ko-KR.json
    ├── nl-NL.json
    ├── pl-PL.json
    ├── pt-BR.json
    ├── pt-PT.json
    ├── pt.json
    ├── ru-RU.json
    ├── th-TH.json
    ├── tl-PH.json
    ├── tr-TR.json
    ├── uk-UA.json
    ├── vi-VN.json
    ├── zh-CN.json
    └── zh-TW.json
├── mocks
    ├── class.ts
    ├── prosemirror.ts
    ├── semver.ts
    └── tiptap.ts
├── modules
    ├── build-env.ts
    ├── emoji-mart-translation.ts
    ├── purge-comments.ts
    ├── pwa
    │   ├── config.ts
    │   ├── i18n.ts
    │   ├── index.ts
    │   ├── runtime
    │   │   ├── pwa-plugin.client.ts
    │   │   └── types.d.ts
    │   └── types.ts
    └── tauri
    │   ├── index.ts
    │   └── runtime
    │       ├── build-info.ts
    │       ├── logging.client.ts
    │       ├── nitro.client.ts
    │       ├── storage-config.ts
    │       └── storage.ts
├── netlify.toml
├── nuxt.config.ts
├── package.json
├── page-lifecycle.d.ts
├── patches
    ├── .gitkeep
    └── pinceau.patch
├── pnpm-lock.yaml
├── pnpm-workspace.yaml
├── public-dev
    ├── apple-touch-icon.png
    ├── favicon.ico
    ├── logo.svg
    ├── maskable-icon.png
    ├── pwa-192x192.png
    ├── pwa-512x512.png
    └── pwa-64x64.png
├── public-staging
    ├── apple-touch-icon.png
    ├── favicon.ico
    ├── logo.svg
    ├── maskable-icon.png
    ├── pwa-192x192.png
    ├── pwa-512x512.png
    ├── pwa-64x64.png
    └── robots.txt
├── public
    ├── _redirects
    ├── apple-touch-icon.png
    ├── avatars
    │   ├── antfu-100x100.png
    │   ├── antfu-60x60.png
    │   ├── danielroe-100x100.png
    │   ├── danielroe-60x60.png
    │   ├── patak-dev-100x100.png
    │   ├── patak-dev-60x60.png
    │   ├── shuuji3-100x100.png
    │   ├── shuuji3-60x60.png
    │   ├── sxzz-100x100.png
    │   ├── sxzz-60x60.png
    │   ├── userquin-100x100.png
    │   └── userquin-60x60.png
    ├── elk-og.png
    ├── favicon.ico
    ├── fonts
    │   ├── DM-mono-v10.ttf
    │   ├── DM-sans-v11.ttf
    │   ├── DM-serif-display-v10.ttf
    │   └── homemade-apple-v18.ttf
    ├── logo.svg
    ├── maskable-icon.png
    ├── pwa-192x192.png
    ├── pwa-512x512.png
    ├── pwa-64x64.png
    ├── robots.txt
    ├── screenshots
    │   ├── dark-1.webp
    │   └── light-1.webp
    ├── shortcuts
    │   ├── compose-96x96.png
    │   ├── compose.png
    │   ├── home-96x96.png
    │   ├── home.png
    │   ├── local-96x96.png
    │   ├── local.png
    │   ├── notifications-96x96.png
    │   ├── notifications.png
    │   ├── settings-96x96.png
    │   └── settings.png
    └── sw.js
├── scripts
    ├── avatars.ts
    ├── cleanup-translations.ts
    ├── generate-pwa-icons.ts
    ├── generate-themes.ts
    ├── prepare-translation-status.ts
    ├── prepare.ts
    └── release.ts
├── server
    ├── api
    │   ├── [server]
    │   │   ├── clear.ts
    │   │   ├── login.ts
    │   │   └── oauth
    │   │   │   └── [origin].ts
    │   └── list-servers.ts
    ├── cache-driver.ts
    └── utils
    │   └── shared.ts
├── service-worker
    ├── elk-sw.ts
    ├── notification.ts
    ├── share-target.ts
    ├── tsconfig.json
    ├── types.ts
    └── web-push-notifications.ts
├── shared
    └── types
    │   ├── index.ts
    │   ├── translation-status.ts
    │   └── utils.ts
├── shims.d.ts
├── tests
    ├── nuxt
    │   ├── __snapshots__
    │   │   ├── content-rich.test.ts.snap
    │   │   └── html-parse.test.ts.snap
    │   ├── content-rich.test.ts
    │   ├── html-parse.test.ts
    │   └── html-to-text.test.ts
    ├── setup.ts
    └── unit
    │   ├── language.test.ts
    │   ├── permalinks.test.ts
    │   └── reorder-timeline.test.ts
├── tsconfig.json
├── unocss.config.ts
├── vitest.config.ts
└── vue-compiler-options.d.ts


/.dockerignore:
--------------------------------------------------------------------------------
 1 | # Modified from .gitignore
 2 | node_modules
 3 | *.log
 4 | dist
 5 | .output
 6 | .nuxt
 7 | #.env # Not ignoring this file because it can contain build-related settings.
 8 | .DS_Store
 9 | .idea/
10 | .vite-inspect
11 | .netlify/
12 | .eslintcache
13 | 
14 | public/emojis
15 | 
16 | *~
17 | *swp
18 | *swo
19 | 


--------------------------------------------------------------------------------
/.env.example:
--------------------------------------------------------------------------------
 1 | NUXT_PUBLIC_TRANSLATE_API=
 2 | NUXT_PUBLIC_DEFAULT_SERVER=
 3 | NUXT_PUBLIC_SINGLE_INSTANCE=
 4 | NUXT_PUBLIC_PRIVACY_POLICY_URL=
 5 | 
 6 | # Production only
 7 | NUXT_CLOUDFLARE_ACCOUNT_ID=
 8 | NUXT_CLOUDFLARE_NAMESPACE_ID=
 9 | NUXT_CLOUDFLARE_API_TOKEN=
10 | 
11 | # 'cloudflare' | 'vercel' | 'fs'
12 | NUXT_STORAGE_DRIVER=
13 | NUXT_STORAGE_FS_BASE=
14 | 
15 | NUXT_ADMIN_KEY=
16 | 
17 | NUXT_PUBLIC_DISABLE_VERSION_CHECK=
18 | 
19 | NUXT_GITHUB_CLIENT_ID=
20 | NUXT_GITHUB_CLIENT_SECRET=
21 | NUXT_GITHUB_INVITE_TOKEN=
22 | 


--------------------------------------------------------------------------------
/.gitattributes:
--------------------------------------------------------------------------------
1 | * text=auto eol=lf
2 | 


--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | github: [elk-zone]
2 | open_collective: elk
3 | 


--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: 🐞 Bug report
3 | about: Report an issue
4 | labels: ['s: pending triage', 'c: bug']
5 | ---
6 | 


--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/config.yml:
--------------------------------------------------------------------------------
1 | blank_issues_enabled: false
2 | contact_links:
3 |   - name: Discord Chat
4 |     url: https://chat.elk.zone
5 |     about: Ask questions and discuss with other users in real time.
6 |   - name: Questions & Discussions
7 |     url: https://github.com/elk-zone/elk/discussions
8 |     about: Use GitHub discussions for message-board style questions and discussions.
9 | 


--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: 🚀 New feature proposal
3 | about: Propose a new feature
4 | labels: 's: pending triage'
5 | ---
6 | 


--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
 1 | name: ci
 2 | 
 3 | permissions: {}
 4 | 
 5 | on:
 6 |   push:
 7 |     branches:
 8 |       - main
 9 |   pull_request:
10 |     branches:
11 |       - main
12 |   workflow_dispatch: {}
13 |   merge_group: {}
14 | 
15 | jobs:
16 |   ci:
17 |     runs-on: ubuntu-latest
18 | 
19 |     steps:
20 |       - uses: actions/checkout@v4
21 |       # workaround for npm registry key change
22 |       # ref. `pnpm@10.1.0` / `pnpm@9.15.4` cannot be installed due to key id mismatch · Issue #612 · nodejs/corepack
23 |       # - https://github.com/nodejs/corepack/issues/612#issuecomment-2629496091
24 |       - run: npm i -g corepack@latest && corepack enable
25 |       - uses: actions/setup-node@v4.4.0
26 |         with:
27 |           node-version-file: .nvmrc
28 | 
29 |       - name: 📦 Install dependencies
30 |         run: pnpm install --frozen-lockfile
31 | 
32 |       - name: 🚧 Set up project
33 |         run: pnpm nuxi prepare
34 | 
35 |       - name: 🧪 Test project
36 |         run: pnpm test:ci
37 |         timeout-minutes: 10
38 | 
39 |       - name: 📝 Lint
40 |         run: pnpm lint
41 | 
42 |       - name: 💪 Type check
43 |         run: pnpm test:typecheck
44 | 


--------------------------------------------------------------------------------
/.github/workflows/docker.yml:
--------------------------------------------------------------------------------
 1 | name: build & push docker container
 2 | on:
 3 |   push:
 4 |     branches:
 5 |       - main
 6 |     tags:
 7 |       - '*'
 8 |   pull_request:
 9 |     branches:
10 |       - main
11 | jobs:
12 |   docker:
13 |     runs-on: ubuntu-latest
14 |     permissions:
15 |       contents: read
16 |       packages: write
17 |     steps:
18 |       - name: Checkout
19 |         uses: actions/checkout@v4
20 |       - name: Docker meta
21 |         id: metal
22 |         uses: docker/metadata-action@v5
23 |         with:
24 |           images: |
25 |             ghcr.io/${{ github.repository }}
26 |       - name: Set up QEMU
27 |         uses: docker/setup-qemu-action@v3
28 |       - name: Set up Docker Buildx
29 |         uses: docker/setup-buildx-action@v3
30 |       - name: Login to GitHub Container Registry
31 |         if: github.event_name != 'pull_request'
32 |         uses: docker/login-action@v3
33 |         with:
34 |           registry: ghcr.io
35 |           username: ${{ github.actor }}
36 |           password: ${{ github.token }}
37 |       - name: Build and push
38 |         uses: docker/build-push-action@v6
39 |         with:
40 |           context: .
41 |           platforms: linux/amd64,linux/arm64
42 |           push: ${{ github.event_name != 'pull_request' }}
43 |           tags: ${{ steps.metal.outputs.tags }}
44 |           labels: ${{ steps.metal.outputs.labels }}
45 |           cache-from: type=gha
46 |           cache-to: type=gha,mode=max
47 | 


--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
 1 | name: Release
 2 | 
 3 | permissions:
 4 |   contents: write
 5 | 
 6 | on:
 7 |   push:
 8 |     tags:
 9 |       - 'v*'
10 | 
11 | jobs:
12 |   release:
13 |     runs-on: ubuntu-latest
14 |     steps:
15 |       - uses: actions/checkout@v4
16 |         with:
17 |           fetch-depth: 0
18 | 
19 |       - name: Set node
20 |         uses: actions/setup-node@v4
21 |         with:
22 |           node-version-file: .nvmrc
23 | 
24 |       - run: npx changelogithub
25 |         env:
26 |           GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
27 | 


--------------------------------------------------------------------------------
/.github/workflows/semantic-pull-request.yml:
--------------------------------------------------------------------------------
 1 | name: Semantic Pull Request
 2 | 
 3 | on:
 4 |   pull_request_target:
 5 |     types:
 6 |       - opened
 7 |       - edited
 8 |       - synchronize
 9 | 
10 | permissions: {}
11 | 
12 | jobs:
13 |   main:
14 |     permissions:
15 |       pull-requests: read # to analyze PRs (amannn/action-semantic-pull-request)
16 |       statuses: write # to mark status of analyzed PR (amannn/action-semantic-pull-request)
17 | 
18 |     runs-on: ubuntu-latest
19 |     name: Semantic Pull Request
20 |     steps:
21 |       - name: Validate PR title
22 |         uses: amannn/action-semantic-pull-request@v5.5.3
23 |         env:
24 |           GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
25 | 


--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
 1 | node_modules
 2 | *.log
 3 | dist
 4 | .output
 5 | .pnpm-store
 6 | .nuxt
 7 | .env
 8 | .DS_Store
 9 | .idea/
10 | .vite-inspect
11 | .netlify/
12 | .eslintcache
13 | elk-translation-status.json
14 | 
15 | public/emojis
16 | 
17 | *~
18 | *swp
19 | *swo
20 | 


--------------------------------------------------------------------------------
/.npmrc:
--------------------------------------------------------------------------------
1 | shamefully-hoist=true
2 | shell-emulator=true
3 | ignore-workspace-root-check=true
4 | package-manager-strict=false
5 | 


--------------------------------------------------------------------------------
/.nvmrc:
--------------------------------------------------------------------------------
1 | 22


--------------------------------------------------------------------------------
/.stackblitz/codeflow.json:
--------------------------------------------------------------------------------
1 | {
2 |   "bot": {
3 |     "issues": {
4 |       "trigger": "all-issues"
5 |     }
6 |   }
7 | }
8 | 


--------------------------------------------------------------------------------
/.stackblitzrc:
--------------------------------------------------------------------------------
1 | {
2 |   "installDependencies": true,
3 |   "startCommand": "npm run dev:mocked"
4 | }
5 | 


--------------------------------------------------------------------------------
/.vscode/extensions.json:
--------------------------------------------------------------------------------
 1 | {
 2 |   "recommendations": [
 3 |     "antfu.iconify",
 4 |     "antfu.unocss",
 5 |     "antfu.goto-alias",
 6 |     "csstools.postcss",
 7 |     "dbaeumer.vscode-eslint",
 8 |     "vue.volar",
 9 |     "lokalise.i18n-ally"
10 |   ]
11 | }
12 | 


--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
 1 | MIT License
 2 | 
 3 | Copyright (c) 2022-present, Elk contributors
 4 | 
 5 | Permission is hereby granted, free of charge, to any person obtaining a copy
 6 | of this software and associated documentation files (the "Software"), to deal
 7 | in the Software without restriction, including without limitation the rights
 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 | 
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 | 
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 | 


--------------------------------------------------------------------------------
/app/app.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | setupPageHeader()
 3 | provideGlobalCommands()
 4 | 
 5 | const route = useRoute()
 6 | 
 7 | if (import.meta.server && !route.path.startsWith('/settings')) {
 8 |   const url = useRequestURL()
 9 | 
10 |   useHead({
11 |     meta: [
12 |       { property: 'og:url', content: `${url.origin}${route.path}` },
13 |     ],
14 |   })
15 | }
16 | 
17 | // We want to trigger rerendering the page when account changes
18 | const key = computed(() => `${currentUser.value?.server ?? currentServer.value}:${currentUser.value?.account.id || ''}`)
19 | </script>
20 | 
21 | <template>
22 |   <NuxtLoadingIndicator color="repeating-linear-gradient(to right,var(--c-primary) 0%,var(--c-primary-active) 100%)" />
23 |   <NuxtLayout :key="key">
24 |     <NuxtPage />
25 |   </NuxtLayout>
26 |   <AriaAnnouncer />
27 | 
28 |   <!-- Avatar Mask -->
29 |   <svg absolute op0 width="0" height="0">
30 |     <defs>
31 |       <clipPath id="avatar-mask" clipPathUnits="objectBoundingBox">
32 |         <path d="M 0,0.5 C 0,0 0,0 0.5,0 S 1,0 1,0.5 1,1 0.5,1 0,1 0,0.5" />
33 |       </clipPath>
34 |     </defs>
35 |   </svg>
36 | </template>
37 | 


--------------------------------------------------------------------------------
/app/components/account/AccountAvatar.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | import type { mastodon } from 'masto'
 3 | 
 4 | const { account } = defineProps<{
 5 |   account: mastodon.v1.Account
 6 |   square?: boolean
 7 | }>()
 8 | 
 9 | const loaded = ref(false)
10 | const error = ref(false)
11 | 
12 | const preferredMotion = usePreferredReducedMotion()
13 | const accountAvatarSrc = computed(() => {
14 |   return preferredMotion.value === 'reduce' ? (account?.avatarStatic ?? account.avatar) : account.avatar
15 | })
16 | </script>
17 | 
18 | <template>
19 |   <img
20 |     :key="account.avatar"
21 |     width="400"
22 |     height="400"
23 |     select-none
24 |     :src="(error || !loaded) ? 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7' : accountAvatarSrc"
25 |     :alt="$t('account.avatar_description', [account.username])"
26 |     loading="lazy"
27 |     class="account-avatar object-cover"
28 |     :class="(loaded ? 'bg-base' : 'bg-gray:10') + (square ? ' ' : ' rounded-full')"
29 |     :style="{ 'clip-path': square ? `url(#avatar-mask)` : 'none' }"
30 |     v-bind="$attrs"
31 |     @load="loaded = true"
32 |     @error="error = true"
33 |   >
34 | </template>
35 | 


--------------------------------------------------------------------------------
/app/components/account/AccountBigAvatar.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | import type { mastodon } from 'masto'
 3 | 
 4 | // Avatar with a background base achieving a 3px border to be used in status cards
 5 | // The border is used for Avatar on Avatar for reblogs and connecting replies
 6 | 
 7 | defineProps<{
 8 |   account: mastodon.v1.Account
 9 |   square?: boolean
10 | }>()
11 | </script>
12 | 
13 | <template>
14 |   <div :key="account.avatar" v-bind="$attrs" :style="{ 'clip-path': square ? `url(#avatar-mask)` : 'none' }" :class="{ 'rounded-full': !square }" bg-base w-54px h-54px flex items-center justify-center>
15 |     <AccountAvatar :account="account" w-48px h-48px :square="square" />
16 |   </div>
17 | </template>
18 | 


--------------------------------------------------------------------------------
/app/components/account/AccountBigCardSkeleton.vue:
--------------------------------------------------------------------------------
 1 | <template>
 2 |   <div>
 3 |     <!-- Banner -->
 4 |     <div px2 pt2>
 5 |       <div rounded of-hidden aspect="3.19" class="flex skeleton-loading-bg" />
 6 |       <div px-4 pb-4 flex="~ col gap-2">
 7 |         <!-- User info -->
 8 |         <div flex sm:flex-row flex-col flex-gap-2>
 9 |           <div flex items-center justify-between>
10 |             <div w-17 h-17 rounded-full border-4 border-bg-base z-2 mt--2 ms--1 of-hidden bg-base>
11 |               <div class="flex skeleton-loading-bg" w-full h-full />
12 |             </div>
13 |             <div block sm:hidden class="skeleton-loading-bg" h-8 w-30 rounded-full />
14 |           </div>
15 |           <div sm:mt-2 flex="~ col 1 gap-2">
16 |             <div flex class="skeleton-loading-bg" h-5 w-20 rounded />
17 |             <div flex class="skeleton-loading-bg" h-4 w-40 rounded />
18 |           </div>
19 |         </div>
20 |         <!-- Note -->
21 |         <div flex class="skeleton-loading-bg" h-4 my3 w="3/5" rounded />
22 |         <!-- Follow info -->
23 |         <div flex justify-between items-center>
24 |           <div flex class="skeleton-loading-bg" h-4 w="sm:1/2 full" rounded />
25 |           <div sm:flex hidden class="skeleton-loading-bg" h-8 w-30 rounded-full />
26 |         </div>
27 |       </div>
28 |     </div>
29 |   </div>
30 | </template>
31 | 


--------------------------------------------------------------------------------
/app/components/account/AccountBotIndicator.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | defineProps<{
 3 |   showLabel?: boolean
 4 | }>()
 5 | </script>
 6 | 
 7 | <template>
 8 |   <div
 9 |     flex="~ gap1" items-center
10 |     :class="{ 'border border-base rounded-md px-1': showLabel }"
11 |     text-secondary-light
12 |   >
13 |     <slot name="prepend" />
14 |     <CommonTooltip :content="$t('account.bot')" :disabled="showLabel">
15 |       <div i-mdi:robot-outline />
16 |     </CommonTooltip>
17 |     <div v-if="showLabel">
18 |       {{ $t('account.bot') }}
19 |     </div>
20 |   </div>
21 | </template>
22 | 


--------------------------------------------------------------------------------
/app/components/account/AccountCard.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | import type { mastodon } from 'masto'
 3 | 
 4 | const { account } = defineProps<{
 5 |   account: mastodon.v1.Account
 6 |   hoverCard?: boolean
 7 |   relationshipContext?: 'followedBy' | 'following'
 8 | }>()
 9 | 
10 | cacheAccount(account)
11 | </script>
12 | 
13 | <template>
14 |   <div flex justify-between hover:bg-active transition-100>
15 |     <AccountInfo
16 |       :account="account" hover p1 as="router-link"
17 |       :hover-card="hoverCard"
18 |       shrink
19 |       overflow-hidden
20 |       :to="getAccountRoute(account)"
21 |     />
22 |     <slot>
23 |       <div h-full p1 shrink-0>
24 |         <AccountFollowButton :account="account" :context="relationshipContext" />
25 |       </div>
26 |     </slot>
27 |   </div>
28 | </template>
29 | 


--------------------------------------------------------------------------------
/app/components/account/AccountDisplayName.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | import type { mastodon } from 'masto'
 3 | 
 4 | const { hideEmojis = false } = defineProps<{
 5 |   account: mastodon.v1.Account
 6 |   hideEmojis?: boolean
 7 | }>()
 8 | </script>
 9 | 
10 | <template>
11 |   <ContentRich
12 |     :content="getDisplayName(account, { rich: true })"
13 |     :emojis="account.emojis"
14 |     :hide-emojis="hideEmojis"
15 |     :markdown="false"
16 |   />
17 | </template>
18 | 


--------------------------------------------------------------------------------
/app/components/account/AccountHandle.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | import type { mastodon } from 'masto'
 3 | 
 4 | const { account } = defineProps<{
 5 |   account: mastodon.v1.Account
 6 | }>()
 7 | 
 8 | const serverName = computed(() => getServerName(account))
 9 | </script>
10 | 
11 | <template>
12 |   <p line-clamp-1 whitespace-pre-wrap break-all text-secondary-light leading-tight dir="ltr">
13 |     <!-- fix: #274 only line-clamp-1 can be used here, using text-ellipsis is not valid -->
14 |     <span text-secondary>{{ getShortHandle(account) }}</span>
15 |     <span v-if="serverName" text-secondary-light>@{{ serverName }}</span>
16 |   </p>
17 | </template>
18 | 


--------------------------------------------------------------------------------
/app/components/account/AccountHoverCard.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | import type { mastodon } from 'masto'
 3 | 
 4 | const { account } = defineProps<{
 5 |   account: mastodon.v1.Account
 6 | }>()
 7 | 
 8 | const relationship = useRelationship(account)
 9 | </script>
10 | 
11 | <template>
12 |   <div v-show="relationship" flex="~ col gap2" rounded min-w-90 max-w-120 z-100 overflow-hidden p-4>
13 |     <div flex="~ gap2" items-center>
14 |       <NuxtLink :to="getAccountRoute(account)" flex-auto rounded-full hover:bg-active transition-100 pe5 me-a>
15 |         <AccountInfo :account="account" :hover-card="false" />
16 |       </NuxtLink>
17 |       <AccountFollowButton text-sm :account="account" :relationship="relationship" />
18 |     </div>
19 |     <div v-if="account.note" max-h-100 overflow-y-auto>
20 |       <ContentRich text-4 text-secondary :content="account.note" :emojis="account.emojis" />
21 |     </div>
22 |     <AccountPostsFollowers text-sm :account="account" :is-hover-card="true" />
23 |   </div>
24 | </template>
25 | 


--------------------------------------------------------------------------------
/app/components/account/AccountInlineInfo.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | import type { mastodon } from 'masto'
 3 | 
 4 | const { link = true, avatar = true } = defineProps<{
 5 |   account: mastodon.v1.Account
 6 |   link?: boolean
 7 |   avatar?: boolean
 8 | }>()
 9 | 
10 | const userSettings = useUserSettings()
11 | </script>
12 | 
13 | <script lang="ts">
14 | export default {
15 |   inheritAttrs: false,
16 | }
17 | </script>
18 | 
19 | <template>
20 |   <AccountHoverWrapper :account="account">
21 |     <NuxtLink
22 |       :to="link ? getAccountRoute(account) : undefined"
23 |       :class="link ? 'text-link-rounded -ml-1.5rem pl-1.5rem rtl-(ml0 pl-0.5rem -mr-1.5rem pr-1.5rem)' : ''"
24 |       v-bind="$attrs"
25 |       min-w-0 flex gap-2 items-center
26 |     >
27 |       <AccountAvatar v-if="avatar" :account="account" w-5 h-5 />
28 |       <AccountDisplayName :account="account" :hide-emojis="getPreferences(userSettings, 'hideUsernameEmojis')" line-clamp-1 ws-pre-wrap break-all />
29 |     </NuxtLink>
30 |   </AccountHoverWrapper>
31 | </template>
32 | 


--------------------------------------------------------------------------------
/app/components/account/AccountLockIndicator.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | defineProps<{
 3 |   showLabel?: boolean
 4 | }>()
 5 | 
 6 | const { t } = useI18n()
 7 | </script>
 8 | 
 9 | <template>
10 |   <div
11 |     flex="~ gap1" items-center
12 |     :class="{ 'border border-base rounded-md px-1': showLabel }"
13 |     text-secondary-light
14 |   >
15 |     <slot name="prepend" />
16 |     <CommonTooltip content="Lock" :disabled="showLabel">
17 |       <div i-ri:lock-line />
18 |     </CommonTooltip>
19 |     <div v-if="showLabel">
20 |       {{ t('account.lock') }}
21 |     </div>
22 |   </div>
23 | </template>
24 | 


--------------------------------------------------------------------------------
/app/components/account/AccountMoved.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | import type { mastodon } from 'masto'
 3 | 
 4 | defineProps<{
 5 |   account: mastodon.v1.Account
 6 | }>()
 7 | </script>
 8 | 
 9 | <template>
10 |   <div flex="~ col gap-2" p4>
11 |     <div flex="~ gap-1" justify-center>
12 |       <AccountInlineInfo :account="account" :link="false" />
13 |       {{ $t('account.moved_title') }}
14 |     </div>
15 | 
16 |     <div flex>
17 |       <NuxtLink :to="getAccountRoute(account.moved!)">
18 |         <AccountInfo :account="account.moved!" />
19 |       </NuxtLink>
20 |       <div flex-auto />
21 |       <div flex items-center>
22 |         <NuxtLink :to="getAccountRoute(account.moved as any)" btn-solid inline-block h-fit>
23 |           {{ $t('account.go_to_profile') }}
24 |         </NuxtLink>
25 |       </div>
26 |     </div>
27 |   </div>
28 | </template>
29 | 


--------------------------------------------------------------------------------
/app/components/account/AccountRolesIndicator.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | import type { mastodon } from 'masto'
 3 | 
 4 | defineProps<{
 5 |   account: mastodon.v1.Account
 6 |   limit?: number
 7 | }>()
 8 | </script>
 9 | 
10 | <template>
11 |   <div
12 |     flex="~ gap1" items-center
13 |     class="border border-base rounded-md px-1"
14 |     text-secondary-light
15 |   >
16 |     <slot name="prepend" />
17 |     <div v-for="role in account.roles?.slice(0, limit)" :key="role.id" flex>
18 |       <div :style="`color: ${role.color}; border-color: ${role.color}`">
19 |         {{ role.name }}
20 |       </div>
21 |     </div>
22 |   </div>
23 |   <div
24 |     v-if="limit && account.roles?.length > limit"
25 |     flex="~ gap1" items-center
26 |     class="border border-base rounded-md px-1"
27 |     text-secondary-light
28 |   >
29 |     +{{ account.roles?.length - limit }}
30 |   </div>
31 | </template>
32 | 


--------------------------------------------------------------------------------
/app/components/account/AccountTabs.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | import type { CommonRouteTabOption } from '#shared/types'
 3 | 
 4 | const { t } = useI18n()
 5 | const route = useRoute()
 6 | 
 7 | const server = computed(() => route.params.server as string)
 8 | const account = computed(() => route.params.account as string)
 9 | 
10 | const tabs = computed<CommonRouteTabOption[]>(() => [
11 |   {
12 |     name: 'account-index',
13 |     to: {
14 |       name: 'account-index',
15 |       params: { server: server.value, account: account.value },
16 |     },
17 |     display: t('tab.posts'),
18 |     icon: 'i-ri:file-list-2-line',
19 |   },
20 |   {
21 |     name: 'account-replies',
22 |     to: {
23 |       name: 'account-replies',
24 |       params: { server: server.value, account: account.value },
25 |     },
26 |     display: t('tab.posts_with_replies'),
27 |     icon: 'i-ri:chat-1-line',
28 |   },
29 |   {
30 |     name: 'account-media',
31 |     to: {
32 |       name: 'account-media',
33 |       params: { server: server.value, account: account.value },
34 |     },
35 |     display: t('tab.media'),
36 |     icon: 'i-ri:camera-2-line',
37 |   },
38 | ])
39 | </script>
40 | 
41 | <template>
42 |   <CommonRouteTabs force replace :options="tabs" prevent-scroll-top command border="base b" />
43 | </template>
44 | 


--------------------------------------------------------------------------------
/app/components/account/TagHoverWrapper.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | import type { mastodon } from 'masto'
 3 | 
 4 | defineOptions({
 5 |   inheritAttrs: false,
 6 | })
 7 | 
 8 | const { tagName } = defineProps<{
 9 |   tagName?: string
10 |   disabled?: boolean
11 | }>()
12 | 
13 | const tag = ref<mastodon.v1.Tag>()
14 | const tagHover = ref()
15 | const hovered = useElementHover(tagHover)
16 | 
17 | watch(hovered, (newHovered) => {
18 |   if (newHovered && tagName) {
19 |     fetchTag(tagName).then((t) => {
20 |       tag.value = t
21 |     })
22 |   }
23 | })
24 | 
25 | const userSettings = useUserSettings()
26 | </script>
27 | 
28 | <template>
29 |   <span ref="tagHover">
30 |     <VMenu
31 |       v-if="!disabled && !getPreferences(userSettings, 'hideTagHoverCard')"
32 |       placement="bottom-start"
33 |       :delay="{ show: 500, hide: 100 }"
34 |       v-bind="$attrs"
35 |       :close-on-content-click="false"
36 |       no-auto-focus
37 |     >
38 |       <slot />
39 |       <template #popper>
40 |         <TagCardSkeleton v-if="!tag" />
41 |         <TagCard v-else :tag="tag" />
42 |       </template>
43 |     </VMenu>
44 |     <slot v-else />
45 |   </span>
46 | </template>
47 | 


--------------------------------------------------------------------------------
/app/components/aria/AriaLog.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | import type { AriaLive } from '~/composables/aria'
 3 | 
 4 | const {
 5 |   ariaLive = 'polite',
 6 |   heading = 'h2',
 7 |   messageKey = (message: any) => message,
 8 | } = defineProps<{
 9 |   ariaLive?: AriaLive
10 |   heading?: 'h2' | 'h3' | 'h4' | 'h5' | 'h6'
11 |   title: string
12 |   messageKey?: (message: any) => any
13 | }>()
14 | 
15 | const { announceLogs, appendLogs, clearLogs, logs } = useAriaLog()
16 | 
17 | defineExpose({
18 |   announceLogs,
19 |   appendLogs,
20 |   clearLogs,
21 | })
22 | </script>
23 | 
24 | <template>
25 |   <slot />
26 |   <div sr-only role="log" :aria-live="ariaLive">
27 |     <component :is="heading">
28 |       {{ title }}
29 |     </component>
30 |     <ul>
31 |       <li v-for="log in logs" :key="messageKey(log)">
32 |         <slot name="log" :log="log">
33 |           {{ log }}
34 |         </slot>
35 |       </li>
36 |     </ul>
37 |   </div>
38 | </template>
39 | 


--------------------------------------------------------------------------------
/app/components/aria/AriaStatus.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | import type { AriaLive } from '~/composables/aria'
 3 | 
 4 | const { ariaLive = 'polite' } = defineProps<{
 5 |   ariaLive?: AriaLive
 6 | }>()
 7 | 
 8 | const { announceStatus, clearStatus, status } = useAriaStatus()
 9 | 
10 | defineExpose({
11 |   announceStatus,
12 |   clearStatus,
13 | })
14 | </script>
15 | 
16 | <template>
17 |   <slot />
18 |   <p sr-only role="status" :aria-live="ariaLive">
19 |     <slot name="status" :status="status">
20 |       {{ status }}
21 |     </slot>
22 |   </p>
23 | </template>
24 | 


--------------------------------------------------------------------------------
/app/components/command/CommandKey.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | const { name } = defineProps<{
 3 |   name: string
 4 | }>()
 5 | 
 6 | const isMac = useIsMac()
 7 | 
 8 | const keys = computed(() => name.toLowerCase().split('+'))
 9 | </script>
10 | 
11 | <template>
12 |   <div class="flex items-center px-1">
13 |     <template v-for="(key, index) in keys" :key="key">
14 |       <div v-if="index > 0" class="inline-block px-.5">
15 |         +
16 |       </div>
17 |       <div
18 |         class="p-1 grid place-items-center rounded-lg shadow-sm"
19 |         text="xs secondary"
20 |         border="1 base"
21 |       >
22 |         <div v-if="key === 'enter'" i-material-symbols:keyboard-return-rounded />
23 |         <div v-else-if="key === 'meta' && isMac" i-material-symbols:keyboard-command-key />
24 |         <div v-else-if="key === 'meta' && !isMac" i-material-symbols:window-sharp />
25 |         <div v-else-if="key === 'alt' && isMac" i-material-symbols:keyboard-option-key-rounded />
26 |         <div v-else-if="key === 'arrowup'" i-ri:arrow-up-line />
27 |         <div v-else-if="key === 'arrowdown'" i-ri:arrow-down-line />
28 |         <div v-else-if="key === 'arrowleft'" i-ri:arrow-left-line />
29 |         <div v-else-if="key === 'arrowright'" i-ri:arrow-right-line />
30 |         <template v-else-if="key === 'escape'">
31 |           ESC
32 |         </template>
33 |         <div v-else :class="{ 'px-.5': key.length === 1 }">
34 |           {{ key[0].toUpperCase() + key.slice(1) }}
35 |         </div>
36 |       </div>
37 |     </template>
38 |   </div>
39 | </template>
40 | 


--------------------------------------------------------------------------------
/app/components/common/AnimateNumber.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | defineProps<{
 3 |   increased?: boolean
 4 | }>()
 5 | </script>
 6 | 
 7 | <template>
 8 |   <div of-hidden h="1.25rem">
 9 |     <div flex="~ col" transition-transform duration-300 :class="increased ? 'translate-y--1/2' : 'translate-y-0'">
10 |       <slot />
11 |       <slot name="next" />
12 |     </div>
13 |   </div>
14 | </template>
15 | 


--------------------------------------------------------------------------------
/app/components/common/CommonAlert.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | const emit = defineEmits<{
 3 |   (event: 'close'): void
 4 | }>()
 5 | const visible = defineModel<boolean>()
 6 | 
 7 | function close() {
 8 |   emit('close')
 9 |   visible.value = false
10 | }
11 | </script>
12 | 
13 | <template>
14 |   <div
15 |     flex="~ gap-2" justify-between items-center
16 |     border="b base" text-sm text-secondary px4 py2 sm:py4
17 |   >
18 |     <div>
19 |       <slot />
20 |     </div>
21 |     <button text-xl hover:text-primary bg-hover-overflow w="1.2em" h="1.2em" @click="close()">
22 |       <div i-ri:close-line />
23 |     </button>
24 |   </div>
25 | </template>
26 | 


--------------------------------------------------------------------------------
/app/components/common/CommonBlurhash.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | defineOptions({
 3 |   inheritAttrs: false,
 4 | })
 5 | 
 6 | const { blurhash = '', shouldLoadImage = true } = defineProps<{
 7 |   blurhash?: string
 8 |   src: string
 9 |   srcset?: string
10 |   shouldLoadImage?: boolean
11 | }>()
12 | </script>
13 | 
14 | <template>
15 |   <UnLazyImage v-bind="$attrs" :blurhash="blurhash" :src="src" :src-set="srcset" :lazy-load="shouldLoadImage" auto-sizes />
16 | </template>
17 | 


--------------------------------------------------------------------------------
/app/components/common/CommonCheckbox.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | defineProps<{
 3 |   label?: string
 4 |   hover?: boolean
 5 |   iconChecked?: string
 6 |   iconUnchecked?: string
 7 |   checkedIconColor?: string
 8 |   prependCheckbox?: boolean
 9 | }>()
10 | const modelValue = defineModel<boolean | null>()
11 | </script>
12 | 
13 | <template>
14 |   <label
15 |     class="common-checkbox flex items-center cursor-pointer py-1 text-md w-full gap-y-1"
16 |     :class="hover ? 'hover:bg-active ms--2 px-4 py-2' : null"
17 |     v-bind="$attrs"
18 |     @click.prevent="modelValue = !modelValue"
19 |   >
20 |     <span v-if="label && !prependCheckbox" flex-1 ms-2 pointer-events-none>{{ label }}</span>
21 |     <span
22 |       :class="[
23 |         modelValue ? (iconChecked ?? 'i-ri:checkbox-line') : (iconUnchecked ?? 'i-ri:checkbox-blank-line'),
24 |         modelValue && checkedIconColor,
25 |       ]"
26 |       text-lg
27 |       aria-hidden="true"
28 |     />
29 |     <input
30 |       v-model="modelValue"
31 |       type="checkbox"
32 |       sr-only
33 |     >
34 |     <span v-if="label && prependCheckbox" flex-1 ms-2 pointer-events-none>{{ label }}</span>
35 |   </label>
36 | </template>
37 | 
38 | <style>
39 | .common-checkbox:focus-within {
40 |   outline: none;
41 |   border-bottom: 1px solid var(--c-text-base);
42 | }
43 | </style>
44 | 


--------------------------------------------------------------------------------
/app/components/common/CommonErrorMessage.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | defineProps<{ describedBy: string }>()
 3 | </script>
 4 | 
 5 | <template>
 6 |   <div
 7 |     role="alert"
 8 |     aria-live="polite"
 9 |     :aria-describedby="describedBy"
10 |     flex="~ col"
11 |     gap-1 text-sm
12 |     pt-1 ps-2 pe-1 pb-2
13 |     text-red-600 dark:text-red-400
14 |     border="~ base rounded red-600 dark:red-400"
15 |     v-bind="$attrs"
16 |   >
17 |     <slot />
18 |   </div>
19 | </template>
20 | 


--------------------------------------------------------------------------------
/app/components/common/CommonMask.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | const {
 3 |   zIndex = 100,
 4 |   background = 'transparent',
 5 | } = defineProps<{
 6 |   zIndex?: number
 7 |   background?: string
 8 | }>()
 9 | </script>
10 | 
11 | <template>
12 |   <div fixed top-0 bottom-0 left-0 right-0 :style="{ background, zIndex }" />
13 | </template>
14 | 


--------------------------------------------------------------------------------
/app/components/common/CommonNotFound.vue:
--------------------------------------------------------------------------------
1 | <template>
2 |   <div flex="~ col" items-center>
3 |     <div i-ri:forbid-line text-10 mt10 mb2 />
4 |     <div text-lg>
5 |       <slot>{{ $t('common.not_found') }}</slot>
6 |     </div>
7 |   </div>
8 | </template>
9 | 


--------------------------------------------------------------------------------
/app/components/common/CommonPreviewPrompt.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | const build = useBuildInfo()
 3 | </script>
 4 | 
 5 | <template>
 6 |   <div
 7 |     m-2 p5 bg-rose:10 relative
 8 |     rounded-lg of-hidden
 9 |     flex="~ col gap-3"
10 |   >
11 |     <h2 font-bold text-rose>
12 |       {{ $t('help.build_preview.title') }}
13 |     </h2>
14 |     <p>
15 |       <i18n-t keypath="help.build_preview.desc1">
16 |         <NuxtLink :href="`https://github.com/elk-zone/elk/commit/${build.commit}`" target="_blank" text-rose hover:underline>
17 |           <code>{{ build.shortCommit }}</code>
18 |         </NuxtLink>
19 |       </i18n-t>
20 |     </p>
21 |     <p>{{ $t('help.build_preview.desc2') }}</p>
22 |     <p font-bold>
23 |       {{ $t('help.build_preview.desc3') }}
24 |     </p>
25 |     <div i-ri-git-pull-request-line absolute text-10em bottom--10 inset-ie--10 text-rose op10 class="-z-1" />
26 |   </div>
27 | </template>
28 | 


--------------------------------------------------------------------------------
/app/components/common/CommonRadio.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | defineProps<{
 3 |   label: string
 4 |   value: any
 5 |   hover?: boolean
 6 | }>()
 7 | const modelValue = defineModel()
 8 | </script>
 9 | 
10 | <template>
11 |   <label
12 |     class="common-radio flex items-center cursor-pointer py-1 text-md w-full gap-y-1"
13 |     :class="hover ? 'hover:bg-active ms--2 px-4 py-2' : null"
14 |     @click.prevent="modelValue = value"
15 |   >
16 |     <span flex-1 ms-2 pointer-events-none>{{ label }}</span>
17 |     <span
18 |       :class="modelValue === value ? 'i-ri:radio-button-line' : 'i-ri:checkbox-blank-circle-line'"
19 |       aria-hidden="true"
20 |     />
21 |     <input
22 |       v-model="modelValue"
23 |       type="radio"
24 |       :value="value"
25 |       sr-only
26 |     >
27 |   </label>
28 | </template>
29 | 
30 | <style>
31 | .common-radio:focus-within {
32 |   outline: none;
33 |   border-bottom: 1px solid var(--c-text-base);
34 | }
35 | </style>
36 | 


--------------------------------------------------------------------------------
/app/components/common/CommonScrollIntoView.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | const { as = 'div', active } = defineProps<{
 3 |   as: any
 4 |   active: boolean
 5 | }>()
 6 | 
 7 | const el = ref()
 8 | 
 9 | watch(() => active, (active) => {
10 |   const _el = unrefElement(el)
11 | 
12 |   if (active && _el)
13 |     _el.scrollIntoView({ block: 'nearest', inline: 'start' })
14 | })
15 | </script>
16 | 
17 | <template>
18 |   <component :is="as" ref="el">
19 |     <slot />
20 |   </component>
21 | </template>
22 | 


--------------------------------------------------------------------------------
/app/components/common/CommonTooltip.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | import type { Popper as VTooltipType } from 'floating-vue'
 3 | 
 4 | export interface Props extends Partial<typeof VTooltipType> {
 5 |   content?: string
 6 | }
 7 | 
 8 | defineProps<Props>()
 9 | </script>
10 | 
11 | <template>
12 |   <VTooltip
13 |     v-if="isHydrated"
14 |     v-bind="$attrs"
15 |     auto-hide
16 |     no-auto-focus
17 |   >
18 |     <slot />
19 |     <template #popper>
20 |       <div text-3>
21 |         <slot name="popper">
22 |           {{ content }}
23 |         </slot>
24 |       </div>
25 |     </template>
26 |   </VTooltip>
27 | </template>
28 | 


--------------------------------------------------------------------------------
/app/components/common/CommonTrending.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | import type { mastodon } from 'masto'
 3 | 
 4 | const {
 5 |   history,
 6 |   maxDay = 2,
 7 | } = defineProps<{
 8 |   history: mastodon.v1.TagHistory[]
 9 |   maxDay?: number
10 | }>()
11 | 
12 | const ongoingHot = computed(() => history.slice(0, maxDay))
13 | 
14 | const people = computed(() =>
15 |   ongoingHot.value.reduce((total: number, item) => total + (Number(item.accounts) || 0), 0),
16 | )
17 | </script>
18 | 
19 | <template>
20 |   <p>
21 |     {{ $t('command.n_people_in_the_past_n_days', [people, maxDay]) }}
22 |   </p>
23 | </template>
24 | 


--------------------------------------------------------------------------------
/app/components/common/CommonTrendingCharts.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | import type { mastodon } from 'masto'
 3 | import sparkline from '@fnando/sparkline'
 4 | 
 5 | const {
 6 |   history,
 7 |   width = 60,
 8 |   height = 40,
 9 | } = defineProps<{
10 |   history?: mastodon.v1.TagHistory[]
11 |   width?: number
12 |   height?: number
13 | }>()
14 | 
15 | const historyNum = computed(() => {
16 |   if (!history)
17 |     return [1, 1, 1, 1, 1, 1, 1]
18 |   return [...history].reverse().map(item => Number(item.accounts) || 0)
19 | })
20 | 
21 | const sparklineEl = ref<SVGSVGElement>()
22 | const sparklineFn = typeof sparkline !== 'function' ? (sparkline as any).default : sparkline
23 | 
24 | watch([historyNum, sparklineEl], ([historyNum, sparklineEl]) => {
25 |   if (!sparklineEl)
26 |     return
27 |   sparklineFn(sparklineEl, historyNum)
28 | })
29 | </script>
30 | 
31 | <template>
32 |   <svg ref="sparklineEl" class="sparkline" :width="width" :height="height" stroke-width="3" />
33 | </template>
34 | 


--------------------------------------------------------------------------------
/app/components/common/LocalizedNumber.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | defineOptions({
 3 |   inheritAttrs: false,
 4 | })
 5 | 
 6 | const { count } = defineProps<{
 7 |   count: number
 8 |   keypath: string
 9 | }>()
10 | 
11 | const { formatHumanReadableNumber, formatNumber, forSR } = useHumanReadableNumber()
12 | 
13 | const useSR = computed(() => forSR(count))
14 | const rawNumber = computed(() => formatNumber(count))
15 | const humanReadableNumber = computed(() => formatHumanReadableNumber(count))
16 | </script>
17 | 
18 | <template>
19 |   <i18n-t :keypath="keypath" :plural="count" tag="span" class="flex gap-x-1">
20 |     <CommonTooltip v-if="useSR" :content="rawNumber" placement="bottom">
21 |       <span aria-hidden="true" v-bind="$attrs">{{ humanReadableNumber }}</span>
22 |       <span sr-only>{{ rawNumber }}</span>
23 |     </CommonTooltip>
24 |     <span v-else v-bind="$attrs">{{ humanReadableNumber }}</span>
25 |   </i18n-t>
26 | </template>
27 | 


--------------------------------------------------------------------------------
/app/components/common/OfflineChecker.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | const online = useOnline()
 3 | </script>
 4 | 
 5 | <template>
 6 |   <div
 7 |     v-if="!online"
 8 |     w-full min-h-30px px4 py3 text-primary bg-base
 9 |     border="t base" flex="~ gap-2 center"
10 |   >
11 |     <div i-ri:wifi-off-line />
12 |     {{ $t('common.offline_desc') }}
13 |   </div>
14 | </template>
15 | 


--------------------------------------------------------------------------------
/app/components/common/dropdown/Dropdown.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | import { InjectionKeyDropdownContext } from '~/constants/symbols'
 3 | 
 4 | defineProps<{
 5 |   placement?: string
 6 |   autoBoundaryMaxSize?: boolean
 7 | }>()
 8 | 
 9 | const dropdown = ref<any>()
10 | const colorMode = useColorMode()
11 | 
12 | function hide() {
13 |   return dropdown.value.hide()
14 | }
15 | provide(InjectionKeyDropdownContext, {
16 |   hide,
17 | })
18 | 
19 | defineExpose({
20 |   hide,
21 | })
22 | </script>
23 | 
24 | <template>
25 |   <VDropdown v-bind="$attrs" ref="dropdown" :class="colorMode.value" :placement="placement || 'auto'" :auto-boundary-max-size="autoBoundaryMaxSize">
26 |     <slot />
27 |     <template #popper="scope">
28 |       <slot name="popper" v-bind="scope" />
29 |     </template>
30 |   </VDropdown>
31 | </template>
32 | 


--------------------------------------------------------------------------------
/app/components/content/ContentCode.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | const { code, lang } = defineProps<{
 3 |   code: string
 4 |   lang?: string
 5 | }>()
 6 | 
 7 | const raw = computed(() => decodeURIComponent(code).replace(/&#39;/g, '\''))
 8 | 
 9 | const langMap: Record<string, string> = {
10 |   js: 'javascript',
11 |   ts: 'typescript',
12 |   vue: 'html',
13 | }
14 | 
15 | const highlighted = computed(() => {
16 |   return lang ? highlightCode(raw.value, (langMap[lang] || lang) as any) : raw
17 | })
18 | </script>
19 | 
20 | <template>
21 |   <pre v-if="lang" class="code-block" v-html="highlighted" />
22 |   <pre v-else class="code-block">{{ raw }}</pre>
23 | </template>
24 | 


--------------------------------------------------------------------------------
/app/components/content/ContentMentionGroup.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | defineProps<{
 3 |   replying?: boolean
 4 | }>()
 5 | </script>
 6 | 
 7 | <template>
 8 |   <p flex="~ gap-1 wrap" items-center text-sm :class="{ 'zen-none': !replying }">
 9 |     <span i-ri-arrow-right-line ml--1 text-secondary-light /><slot />
10 |   </p>
11 | </template>
12 | 


--------------------------------------------------------------------------------
/app/components/content/ContentRich.setup.ts:
--------------------------------------------------------------------------------
 1 | import type { mastodon } from 'masto'
 2 | 
 3 | defineOptions({
 4 |   name: 'ContentRich',
 5 | })
 6 | 
 7 | const {
 8 |   content,
 9 |   emojis,
10 |   hideEmojis = false,
11 |   markdown = true,
12 | } = defineProps<{
13 |   content: string
14 |   emojis?: mastodon.v1.CustomEmoji[]
15 |   hideEmojis?: boolean
16 |   markdown?: boolean
17 | }>()
18 | 
19 | const emojisObject = useEmojisFallback(() => emojis)
20 | 
21 | export default () => h(
22 |   'span',
23 |   { class: 'content-rich', dir: 'auto' },
24 |   contentToVNode(content, {
25 |     emojis: emojisObject.value,
26 |     hideEmojis,
27 |     markdown,
28 |   }),
29 | )
30 | 


--------------------------------------------------------------------------------
/app/components/conversation/ConversationCard.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | import type { mastodon } from 'masto'
 3 | 
 4 | const { conversation } = defineProps<{
 5 |   conversation: mastodon.v1.Conversation
 6 | }>()
 7 | 
 8 | const withAccounts = computed(() =>
 9 |   conversation.accounts.filter(account => account.id !== conversation.lastStatus?.account.id),
10 | )
11 | </script>
12 | 
13 | <template>
14 |   <article v-if="conversation.lastStatus" flex flex-col gap-2>
15 |     <StatusCard v-if="conversation.lastStatus" :status="conversation.lastStatus" :actions="false">
16 |       <template #meta>
17 |         <div flex gap-2 text-sm text-secondary font-bold>
18 |           <p me-1>
19 |             {{ $t('conversation.with') }}
20 |           </p>
21 |           <AccountAvatar v-for="account in withAccounts" :key="account.id" h-5 w-5 :account="account" />
22 |         </div>
23 |       </template>
24 |     </StatusCard>
25 |   </article>
26 | </template>
27 | 


--------------------------------------------------------------------------------
/app/components/conversation/ConversationPaginator.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | import type { mastodon } from 'masto'
 3 | 
 4 | defineProps<{
 5 |   paginator: mastodon.Paginator<mastodon.v1.Conversation[], mastodon.DefaultPaginationParams>
 6 | }>()
 7 | 
 8 | function preprocess(items: mastodon.v1.Conversation[]): mastodon.v1.Conversation[] {
 9 |   const isAuthored = (conversation: mastodon.v1.Conversation) => conversation.lastStatus ? conversation.lastStatus.account.id === currentUser.value?.account.id : false
10 |   return items.filter(item => isAuthored(item) || !item.lastStatus?.filtered?.find(
11 |     filter => filter.filter.filterAction === 'hide' && filter.filter.context.includes('thread'),
12 |   ))
13 | }
14 | </script>
15 | 
16 | <template>
17 |   <CommonPaginator :paginator="paginator" :preprocess="preprocess">
18 |     <template #default="{ item }">
19 |       <ConversationCard
20 |         :conversation="item"
21 |         border="b base" py-1
22 |       />
23 |     </template>
24 |   </CommonPaginator>
25 | </template>
26 | 


--------------------------------------------------------------------------------
/app/components/emoji/Emoji.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | const { alt, dataEmojiId } = defineProps<{
 3 |   as: string
 4 |   alt?: string
 5 |   dataEmojiId?: string
 6 | }>()
 7 | 
 8 | const title = ref<string | undefined>()
 9 | 
10 | if (alt) {
11 |   if (alt.startsWith(':')) {
12 |     title.value = alt.replace(/:/g, '')
13 |   }
14 |   else {
15 |     import('node-emoji').then(({ find }) => {
16 |       title.value = find(alt)?.key.replace(/_/g, ' ')
17 |     })
18 |   }
19 | }
20 | 
21 | // if it has a data-emoji-id, use that as the title instead
22 | if (dataEmojiId)
23 |   title.value = dataEmojiId
24 | </script>
25 | 
26 | <template>
27 |   <component :is="as" v-bind="$attrs" :alt="alt" :data-emoji-id="dataEmojiId" :title="title">
28 |     <slot />
29 |   </component>
30 | </template>
31 | 


--------------------------------------------------------------------------------
/app/components/list/AccountSearchResult.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | import type { SearchResult } from '~/composables/masto/search'
 3 | 
 4 | defineProps<{
 5 |   result: SearchResult
 6 |   active: boolean
 7 | }>()
 8 | </script>
 9 | 
10 | <template>
11 |   <CommonScrollIntoView
12 |     as="div"
13 |     :active="active"
14 |     py2 block px2
15 |     :aria-selected="active"
16 |     :class="{ 'bg-active': active }"
17 |   >
18 |     <AccountInfo
19 |       v-if="result.type === 'account'"
20 |       :account="result.data"
21 |     />
22 |   </CommonScrollIntoView>
23 | </template>
24 | 


--------------------------------------------------------------------------------
/app/components/modal/DurationPicker.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | const model = defineModel<number>()
 3 | const isValid = defineModel<boolean>('isValid')
 4 | 
 5 | const days = ref<number | ''>(0)
 6 | const hours = ref<number | ''>(1)
 7 | const minutes = ref<number | ''>(0)
 8 | 
 9 | watchEffect(() => {
10 |   if (days.value === '' || hours.value === '' || minutes.value === '') {
11 |     isValid.value = false
12 |     return
13 |   }
14 | 
15 |   const duration
16 |       = days.value * 24 * 60 * 60
17 |         + hours.value * 60 * 60
18 |         + minutes.value * 60
19 | 
20 |   if (duration <= 0) {
21 |     isValid.value = false
22 |     return
23 |   }
24 | 
25 |   isValid.value = true
26 |   model.value = duration
27 | })
28 | </script>
29 | 
30 | <template>
31 |   <div flex flex-grow-0 gap-2>
32 |     <label flex items-center gap-2>
33 |       <input v-model="days" type="number" min="0" max="1999" input-base :class="!isValid ? 'input-error' : null">
34 |       {{ $t('confirm.mute_account.days', days === '' ? 0 : days) }}
35 |     </label>
36 |     <label flex items-center gap-2>
37 |       <input v-model="hours" type="number" min="0" max="24" input-base :class="!isValid ? 'input-error' : null">
38 |       {{ $t('confirm.mute_account.hours', hours === '' ? 0 : hours) }}
39 |     </label>
40 |     <label flex items-center gap-2>
41 |       <input v-model="minutes" type="number" min="0" max="59" step="5" input-base :class="!isValid ? 'input-error' : null">
42 |       {{ $t('confirm.mute_account.minute', minutes === '' ? 0 : minutes) }}
43 |     </label>
44 |   </div>
45 | </template>
46 | 


--------------------------------------------------------------------------------
/app/components/modal/ModalError.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | import type { ErrorDialogData } from '#shared/types'
 3 | 
 4 | defineProps<ErrorDialogData>()
 5 | </script>
 6 | 
 7 | <template>
 8 |   <div flex="~ col" gap-6>
 9 |     <div font-bold text-lg text-center>
10 |       {{ title }}
11 |     </div>
12 |     <div
13 |       flex="~ col"
14 |       gap-1 text-sm
15 |       pt-1 ps-2 pe-1 pb-2
16 |       text-red-600 dark:text-red-400
17 |       border="~ base rounded red-600 dark:red-400"
18 |     >
19 |       <ol ps-2 sm:ps-1>
20 |         <li v-for="(message, i) in messages" :key="i" flex="~ col sm:row" gap-y-1 sm:gap-x-2>
21 |           {{ message }}
22 |         </li>
23 |       </ol>
24 |     </div>
25 |     <div flex justify-end gap-2>
26 |       <button btn-text @click="closeErrorDialog()">
27 |         {{ close }}
28 |       </button>
29 |     </div>
30 |   </div>
31 | </template>
32 | 


--------------------------------------------------------------------------------
/app/components/nav/NavUser.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | const { busy, oauth, singleInstanceServer } = useSignIn()
 3 | </script>
 4 | 
 5 | <template>
 6 |   <VDropdown v-if="isHydrated && currentUser" sm:hidden>
 7 |     <div style="-webkit-touch-callout: none;">
 8 |       <AccountAvatar
 9 |         :account="currentUser.account"
10 |         h-8
11 |         w-8
12 |         :draggable="false"
13 |         square
14 |       />
15 |     </div>
16 | 
17 |     <template #popper="{ hide }">
18 |       <UserSwitcher @click="hide()" />
19 |     </template>
20 |   </VDropdown>
21 |   <template v-else>
22 |     <button
23 |       v-if="singleInstanceServer"
24 |       flex="~ row"
25 |       gap-x-1 items-center justify-center btn-solid text-sm px-2 py-1 xl:hidden
26 |       :disabled="busy"
27 |       @click="oauth()"
28 |     >
29 |       <span v-if="busy" aria-hidden="true" block animate animate-spin preserve-3d class="rtl-flip">
30 |         <span block i-ri:loader-2-fill aria-hidden="true" />
31 |       </span>
32 |       <span v-else aria-hidden="true" block i-ri:login-circle-line class="rtl-flip" />
33 |       <i18n-t keypath="action.sign_in_to">
34 |         <strong>{{ currentServer }}</strong>
35 |       </i18n-t>
36 |     </button>
37 |     <button
38 |       v-else
39 |       flex="~ row"
40 |       gap-x-1 items-center justify-center btn-solid text-sm px-2 py-1 xl:hidden
41 |       @click="openSigninDialog()"
42 |     >
43 |       <span aria-hidden="true" block i-ri:login-circle-line class="rtl-flip" />
44 |       {{ $t('action.sign_in') }}
45 |     </button>
46 |   </template>
47 | </template>
48 | 


--------------------------------------------------------------------------------
/app/components/nav/NavUserSkeleton.vue:
--------------------------------------------------------------------------------
1 | <template>
2 |   <div bg-base h-8 w-8 rounded-full />
3 | </template>
4 | 


--------------------------------------------------------------------------------
/app/components/nav/button/Bookmark.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | defineProps<{
 3 |   activeClass: string
 4 | }>()
 5 | </script>
 6 | 
 7 | <template>
 8 |   <NuxtLink to="/bookmarks" :aria-label="$t('nav.bookmarks')" :active-class="activeClass" flex flex-row items-center place-content-center h-full flex-1 class="coarse-pointer:select-none" @click="$scrollToTop">
 9 |     <div i-ri:bookmark-line />
10 |   </NuxtLink>
11 | </template>
12 | 


--------------------------------------------------------------------------------
/app/components/nav/button/Compose.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | defineProps<{
 3 |   activeClass: string
 4 | }>()
 5 | </script>
 6 | 
 7 | <template>
 8 |   <NuxtLink to="/compose" :aria-label="$t('nav.favourites')" :active-class="activeClass" flex flex-row items-center place-content-center h-full flex-1 class="coarse-pointer:select-none" @click="$scrollToTop">
 9 |     <div i-ri:quill-pen-line />
10 |   </NuxtLink>
11 | </template>
12 | 


--------------------------------------------------------------------------------
/app/components/nav/button/Explore.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | import { STORAGE_KEY_LAST_ACCESSED_EXPLORE_ROUTE } from '~/constants'
 3 | 
 4 | defineProps<{
 5 |   activeClass: string
 6 | }>()
 7 | 
 8 | const lastAccessedExploreRoute = useLocalStorage(STORAGE_KEY_LAST_ACCESSED_EXPLORE_ROUTE, '')
 9 | </script>
10 | 
11 | <template>
12 |   <NuxtLink :to="`/${currentServer}/explore/${lastAccessedExploreRoute}`" :aria-label="$t('nav.explore')" :active-class="activeClass" flex flex-row items-center place-content-center h-full flex-1 class="coarse-pointer:select-none" @click="$scrollToTop">
13 |     <div i-ri:compass-3-line />
14 |   </NuxtLink>
15 | </template>
16 | 


--------------------------------------------------------------------------------
/app/components/nav/button/Favorite.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | defineProps<{
 3 |   activeClass: string
 4 | }>()
 5 | </script>
 6 | 
 7 | <template>
 8 |   <NuxtLink to="/favourites" :aria-label="$t('nav.favourites')" :active-class="activeClass" flex flex-row items-center place-content-center h-full flex-1 class="coarse-pointer:select-none" @click="$scrollToTop">
 9 |     <div i-ri:heart-line />
10 |   </NuxtLink>
11 | </template>
12 | 


--------------------------------------------------------------------------------
/app/components/nav/button/Federated.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | defineProps<{
 3 |   activeClass: string
 4 | }>()
 5 | </script>
 6 | 
 7 | <template>
 8 |   <NuxtLink :to="`/${currentServer}/public`" :aria-label="$t('nav.federated')" :active-class="activeClass" flex flex-row items-center place-content-center h-full flex-1 class="coarse-pointer:select-none" @click="$scrollToTop">
 9 |     <div i-ri:earth-line />
10 |   </NuxtLink>
11 | </template>
12 | 


--------------------------------------------------------------------------------
/app/components/nav/button/Hashtag.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | defineProps<{
 3 |   activeClass: string
 4 | }>()
 5 | </script>
 6 | 
 7 | <template>
 8 |   <NuxtLink to="/hashtags" :aria-label="$t('nav.hashtags')" :active-class="activeClass" flex flex-row items-center place-content-center h-full flex-1 class="coarse-pointer:select-none" @click="$scrollToTop">
 9 |     <div i-ri:hashtag />
10 |   </NuxtLink>
11 | </template>
12 | 


--------------------------------------------------------------------------------
/app/components/nav/button/Home.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | defineProps<{
 3 |   activeClass: string
 4 | }>()
 5 | </script>
 6 | 
 7 | <template>
 8 |   <NuxtLink to="/home" :aria-label="$t('nav.home')" :active-class="activeClass" flex flex-row items-center place-content-center h-full flex-1 class="coarse-pointer:select-none" @click="$scrollToTop">
 9 |     <div i-ri:home-5-line />
10 |   </NuxtLink>
11 | </template>
12 | 


--------------------------------------------------------------------------------
/app/components/nav/button/List.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | defineProps<{
 3 |   activeClass: string
 4 | }>()
 5 | </script>
 6 | 
 7 | <template>
 8 |   <NuxtLink
 9 |     to="/lists"
10 |     :aria-label="$t('nav.lists')"
11 |     :active-class="activeClass"
12 |     flex flex-row items-center place-content-center h-full flex-1
13 |     class="coarse-pointer:select-none" @click="$scrollToTop"
14 |   >
15 |     <div i-ri:list-check />
16 |   </NuxtLink>
17 | </template>
18 | 


--------------------------------------------------------------------------------
/app/components/nav/button/Local.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | defineProps<{
 3 |   activeClass: string
 4 | }>()
 5 | </script>
 6 | 
 7 | <template>
 8 |   <NuxtLink group :to="`/${currentServer}/public/local`" :aria-label="$t('nav.local')" :active-class="activeClass" flex flex-row items-center place-content-center h-full flex-1 class="coarse-pointer:select-none" @click="$scrollToTop">
 9 |     <div i-ri:group-2-line />
10 |   </NuxtLink>
11 | </template>
12 | 


--------------------------------------------------------------------------------
/app/components/nav/button/Mention.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | defineProps<{
 3 |   activeClass: string
 4 | }>()
 5 | </script>
 6 | 
 7 | <template>
 8 |   <NuxtLink
 9 |     to="/conversations" :aria-label="$t('nav.conversations')"
10 |     :active-class="activeClass" flex flex-row items-center place-content-center h-full
11 |     flex-1 class="coarse-pointer:select-none" @click="$scrollToTop"
12 |   >
13 |     <div i-ri:at-line />
14 |   </NuxtLink>
15 | </template>
16 | 


--------------------------------------------------------------------------------
/app/components/nav/button/MoreMenu.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | const model = defineModel<boolean>()
 3 | </script>
 4 | 
 5 | <template>
 6 |   <NavBottomMoreMenu
 7 |     v-slot="{ toggleVisible, show }" v-model="model!" flex flex-row items-center
 8 |     place-content-center h-full flex-1 cursor-pointer
 9 |   >
10 |     <button
11 |       flex items-center place-content-center h-full flex-1 class="select-none"
12 |       :class="show ? '!text-primary' : ''"
13 |       :aria-label="$t('nav.more_menu')"
14 |       @click="toggleVisible"
15 |     >
16 |       <span :class="show ? 'i-ri:close-fill' : 'i-ri:more-fill'" />
17 |     </button>
18 |   </NavBottomMoreMenu>
19 | </template>
20 | 


--------------------------------------------------------------------------------
/app/components/nav/button/Notification.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | import { STORAGE_KEY_LAST_ACCESSED_NOTIFICATION_ROUTE } from '~/constants'
 3 | 
 4 | defineProps<{
 5 |   activeClass: string
 6 | }>()
 7 | 
 8 | const { notifications } = useNotifications()
 9 | const lastAccessedNotificationRoute = useLocalStorage(STORAGE_KEY_LAST_ACCESSED_NOTIFICATION_ROUTE, '')
10 | </script>
11 | 
12 | <template>
13 |   <NuxtLink :to="`/notifications/${lastAccessedNotificationRoute}`" :aria-label="$t('nav.notifications')" :active-class="activeClass" flex flex-row items-center place-content-center h-full flex-1 class="coarse-pointer:select-none" @click="$scrollToTop">
14 |     <div flex relative>
15 |       <div class="i-ri:notification-4-line" text-xl />
16 |       <div v-if="notifications" class="top-[-0.3rem] right-[-0.3rem]" absolute font-bold rounded-full h-4 w-4 text-xs bg-primary text-inverted flex items-center justify-center>
17 |         {{ notifications < 10 ? notifications : '•' }}
18 |       </div>
19 |     </div>
20 |   </NuxtLink>
21 | </template>
22 | 


--------------------------------------------------------------------------------
/app/components/nav/button/Search.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | defineProps<{
 3 |   activeClass: string
 4 | }>()
 5 | </script>
 6 | 
 7 | <template>
 8 |   <NuxtLink to="/search" :aria-label="$t('nav.search')" :active-class="activeClass" flex flex-row items-center place-content-center h-full flex-1 class="coarse-pointer:select-none" @click="$scrollToTop">
 9 |     <div i-ri:search-line />
10 |   </NuxtLink>
11 | </template>
12 | 


--------------------------------------------------------------------------------
/app/components/publish/PublishCharacterCounter.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | defineProps<{
 3 |   max: number
 4 |   length: number
 5 | }>()
 6 | </script>
 7 | 
 8 | <template>
 9 |   <div dir="ltr" pointer-events-none pe-1 pt-2 text-sm tabular-nums text-secondary flex gap="0.5" :class="{ 'text-rose-500': length > max }">
10 |     {{ length ?? 0 }}<span text-secondary-light>/</span><span text-secondary-light>{{ max }}</span>
11 |   </div>
12 | </template>
13 | 


--------------------------------------------------------------------------------
/app/components/publish/PublishVisibilityPicker.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | defineProps<{
 3 |   editing?: boolean
 4 | }>()
 5 | 
 6 | const modelValue = defineModel<string>({
 7 |   required: true,
 8 | })
 9 | 
10 | const currentVisibility = computed(() =>
11 |   statusVisibilities.find(v => v.value === modelValue.value) || statusVisibilities[0],
12 | )
13 | 
14 | function chooseVisibility(visibility: string) {
15 |   modelValue.value = visibility
16 | }
17 | </script>
18 | 
19 | <template>
20 |   <CommonTooltip placement="top" :content="editing ? $t(`visibility.${currentVisibility.value}`) : $t('tooltip.change_content_visibility')">
21 |     <CommonDropdown placement="bottom">
22 |       <slot :visibility="currentVisibility" />
23 |       <template #popper>
24 |         <CommonDropdownItem
25 |           v-for="visibility in statusVisibilities"
26 |           :key="visibility.value"
27 |           :icon="visibility.icon"
28 |           :text="$t(`visibility.${visibility.value}`)"
29 |           :description="$t(`visibility.${visibility.value}_desc`)"
30 |           :checked="visibility.value === modelValue"
31 |           @click="chooseVisibility(visibility.value)"
32 |         />
33 |       </template>
34 |     </CommonDropdown>
35 |   </CommonTooltip>
36 | </template>
37 | 


--------------------------------------------------------------------------------
/app/components/publish/PublishWidgetList.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | import type { DraftItem } from '#shared/types'
 3 | import type { mastodon } from 'masto'
 4 | 
 5 | const {
 6 |   draftKey,
 7 |   initial = getDefaultDraftItem,
 8 |   expanded = false,
 9 | } = defineProps<{
10 |   draftKey: string
11 |   initial?: () => DraftItem
12 |   placeholder?: string
13 |   inReplyToId?: string
14 |   inReplyToVisibility?: mastodon.v1.StatusVisibility
15 |   expanded?: boolean
16 |   dialogLabelledBy?: string
17 | }>()
18 | 
19 | const threadComposer = useThreadComposer(draftKey, initial)
20 | const threadItems = computed(() => threadComposer.threadItems.value)
21 | 
22 | onDeactivated(() => {
23 |   clearEmptyDrafts()
24 | })
25 | 
26 | function isFirstItem(index: number) {
27 |   return index === 0
28 | }
29 | </script>
30 | 
31 | <template>
32 |   <template v-if="isHydrated && currentUser">
33 |     <PublishWidget
34 |       v-for="(_, index) in threadItems" :key="`${draftKey}-${index}`"
35 |       v-bind="$attrs"
36 |       :thread-composer="threadComposer"
37 |       :draft-key="draftKey"
38 |       :draft-item-index="index"
39 |       :expanded="isFirstItem(index) ? expanded : true"
40 |       :placeholder="placeholder"
41 |       :dialog-labelled-by="dialogLabelledBy"
42 |       :in-reply-to-id="isFirstItem(index) ? inReplyToId : undefined"
43 |       :in-reply-to-visibility="inReplyToVisibility"
44 |     />
45 |   </template>
46 | </template>
47 | 


--------------------------------------------------------------------------------
/app/components/pwa/PwaBadge.client.vue:
--------------------------------------------------------------------------------
 1 | <template>
 2 |   <button
 3 |     v-if="useNuxtApp().$pwa?.needRefresh"
 4 |     bg="primary-fade" relative rounded
 5 |     flex="~ gap-1 center" px3 py1 text-primary
 6 |     @click="useNuxtApp().$pwa?.updateServiceWorker()"
 7 |   >
 8 |     <div i-ri-download-cloud-2-line />
 9 |     <h2 flex="~ gap-2" items-center>
10 |       {{ $t('pwa.update_available_short') }}
11 |     </h2>
12 |   </button>
13 | </template>
14 | 


--------------------------------------------------------------------------------
/app/components/pwa/PwaInstallPrompt.client.vue:
--------------------------------------------------------------------------------
 1 | <template>
 2 |   <div
 3 |     v-if="useNuxtApp().$pwa?.showInstallPrompt && !useNuxtApp().$pwa?.needRefresh"
 4 |     m-2 p5 bg="primary-fade" relative
 5 |     rounded-lg of-hidden
 6 |     flex="~ col gap-3"
 7 |     v-bind="$attrs"
 8 |   >
 9 |     <h2 flex="~ gap-2" items-center>
10 |       {{ $t('pwa.install_title') }}
11 |     </h2>
12 |     <div flex="~ gap-1">
13 |       <button type="button" btn-solid px-4 py-1 text-center text-sm @click="useNuxtApp().$pwa?.install()">
14 |         {{ $t('pwa.install') }}
15 |       </button>
16 |       <button type="button" btn-text filter-saturate-0 px-4 py-1 text-center text-sm @click="useNuxtApp().$pwa?.cancelInstall()">
17 |         {{ $t('pwa.dismiss') }}
18 |       </button>
19 |     </div>
20 |     <div i-material-symbols:install-desktop-rounded absolute text-6em bottom--2 inset-ie--2 text-primary dark:text-white op10 class="-z-1 rtl-flip" />
21 |   </div>
22 | </template>
23 | 


--------------------------------------------------------------------------------
/app/components/pwa/PwaPrompt.client.vue:
--------------------------------------------------------------------------------
 1 | <template>
 2 |   <div
 3 |     v-if="useNuxtApp().$pwa?.needRefresh"
 4 |     m-2 p5 bg="primary-fade" relative
 5 |     rounded-lg of-hidden
 6 |     flex="~ col gap-3"
 7 |   >
 8 |     <h2 flex="~ gap-2" items-center>
 9 |       {{ $t('pwa.title') }}
10 |     </h2>
11 |     <div flex="~ gap-1">
12 |       <button type="button" btn-solid px-4 py-1 text-center text-sm @click="useNuxtApp().$pwa?.updateServiceWorker()">
13 |         {{ $t('pwa.update') }}
14 |       </button>
15 |       <button type="button" btn-text filter-saturate-0 px-4 py-1 text-center text-sm @click="useNuxtApp().$pwa?.close()">
16 |         {{ $t('pwa.dismiss') }}
17 |       </button>
18 |     </div>
19 |     <div i-ri-arrow-down-circle-line absolute text-8em bottom--10 inset-ie--10 text-primary dark:text-white op10 class="-z-1" />
20 |   </div>
21 | </template>
22 | 


--------------------------------------------------------------------------------
/app/components/search/SearchAccountInfo.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | import type { mastodon } from 'masto'
 3 | 
 4 | defineProps<{
 5 |   account: mastodon.v1.Account
 6 | }>()
 7 | </script>
 8 | 
 9 | <!-- TODO: reuse AccountInfo.vue -->
10 | 
11 | <template>
12 |   <div flex gap-2 items-center>
13 |     <AccountAvatar w-10 h-10 :account="account" shrink-0 />
14 |     <div flex="~ col gap1" shrink h-full overflow-hidden leading-none>
15 |       <div flex="~" gap-2>
16 |         <AccountDisplayName :account="account" line-clamp-1 ws-pre-wrap break-all text-base />
17 |         <AccountLockIndicator v-if="account.locked" text-xs />
18 |         <AccountBotIndicator v-if="account.bot" text-xs />
19 |       </div>
20 |       <AccountHandle text-sm :account="account" text-secondary-light />
21 |     </div>
22 |   </div>
23 | </template>
24 | 


--------------------------------------------------------------------------------
/app/components/search/SearchEmojiInfo.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | export interface SearchEmoji {
 3 |   title: string
 4 |   src: string
 5 | }
 6 | 
 7 | defineProps<{
 8 |   emoji: SearchEmoji
 9 | }>()
10 | </script>
11 | 
12 | <template>
13 |   <div flex="~ gap3" items-center text-base>
14 |     <img
15 |       width="20"
16 |       height="20"
17 |       :src="emoji.src"
18 |       loading="lazy"
19 |     >
20 |     <span shrink overflow-hidden leading-none text-base><span text-secondary>:</span>{{ emoji.title }}<span text-secondary>:</span></span>
21 |   </div>
22 | </template>
23 | 


--------------------------------------------------------------------------------
/app/components/search/SearchHashtagInfo.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | import type { mastodon } from 'masto'
 3 | 
 4 | const { hashtag } = defineProps<{
 5 |   hashtag: mastodon.v1.Tag
 6 | }>()
 7 | 
 8 | const totalTrend = computed(() =>
 9 |   hashtag.history?.reduce((total: number, item) => total + (Number(item.accounts) || 0), 0),
10 | )
11 | </script>
12 | 
13 | <template>
14 |   <div flex flex-row items-center gap2 relative>
15 |     <div w-10 h-10 flex-none rounded-full bg-active flex place-items-center place-content-center>
16 |       <div i-ri:hashtag text-secondary text-lg />
17 |     </div>
18 |     <div flex flex-col>
19 |       <span>
20 |         {{ hashtag.name }}
21 |       </span>
22 |       <CommonTrending v-if="hashtag.history" :history="hashtag.history" text-xs text-secondary truncate />
23 |     </div>
24 |     <div v-if="totalTrend && hashtag.history" absolute left-15 right-0 top-0 bottom-4 op35 flex place-items-center place-content-center ml-auto>
25 |       <CommonTrendingCharts
26 |         :history="hashtag.history" :width="150" :height="20"
27 |         text-xs text-secondary h-full w-full
28 |       />
29 |     </div>
30 |   </div>
31 | </template>
32 | 


--------------------------------------------------------------------------------
/app/components/search/SearchResult.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | import type { SearchResult } from '~/composables/masto/search'
 3 | 
 4 | defineProps<{
 5 |   result: SearchResult
 6 |   active: boolean
 7 | }>()
 8 | 
 9 | function onActivate() {
10 |   (document.activeElement as HTMLElement).blur()
11 | }
12 | </script>
13 | 
14 | <template>
15 |   <CommonScrollIntoView
16 |     as="RouterLink"
17 |     hover:bg-active
18 |     :active="active"
19 |     :to="result.to" py2 block px2
20 |     :aria-selected="active"
21 |     :class="{ 'bg-active': active }"
22 |     @click="() => onActivate()"
23 |   >
24 |     <SearchHashtagInfo v-if="result.type === 'hashtag'" :hashtag="result.data" />
25 |     <SearchAccountInfo v-else-if="result.type === 'account'" :account="result.data" />
26 |     <StatusCard v-else-if="result.type === 'status'" :status="result.data" :actions="false" :show-reply-to="false" />
27 |     <!-- <div v-else-if="result.type === 'action'" text-center>
28 |       {{ result.action!.label }}
29 |     </div> -->
30 |   </CommonScrollIntoView>
31 | </template>
32 | 


--------------------------------------------------------------------------------
/app/components/search/SearchResultSkeleton.vue:
--------------------------------------------------------------------------------
 1 | <template>
 2 |   <div flex flex-col gap-2 px-4 py-3>
 3 |     <div flex gap-4>
 4 |       <div>
 5 |         <div w-12 h-12 rounded-full class="skeleton-loading-bg" />
 6 |       </div>
 7 |       <div flex="~ col 1 gap-2" pb2 min-w-0>
 8 |         <div flex class="skeleton-loading-bg" h-5 w-20 rounded />
 9 |         <div flex class="skeleton-loading-bg" h-4 w-full rounded />
10 |       </div>
11 |     </div>
12 |   </div>
13 | </template>
14 | 


--------------------------------------------------------------------------------
/app/components/settings/SettingsColorMode.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | import type { ColorMode } from '~/composables/settings'
 3 | 
 4 | const colorMode = useColorMode()
 5 | 
 6 | function setColorMode(mode: ColorMode) {
 7 |   colorMode.preference = mode
 8 | }
 9 | 
10 | const modes = [
11 |   {
12 |     icon: 'i-ri-moon-line',
13 |     label: 'settings.interface.dark_mode',
14 |     mode: 'dark',
15 |   },
16 |   {
17 |     icon: 'i-ri-sun-line',
18 |     label: 'settings.interface.light_mode',
19 |     mode: 'light',
20 |   },
21 |   {
22 |     icon: 'i-ri-computer-line',
23 |     label: 'settings.interface.system_mode',
24 |     mode: 'system',
25 |   },
26 | ] as const
27 | </script>
28 | 
29 | <template>
30 |   <section space-y-2>
31 |     <h2 id="interface-cm" font-medium>
32 |       {{ $t('settings.interface.color_mode') }}
33 |     </h2>
34 |     <div flex="~ gap4 wrap" w-full role="group" aria-labelledby="interface-cm">
35 |       <button
36 |         v-for="{ icon, label, mode } in modes"
37 |         :key="mode"
38 |         type="button"
39 |         btn-text flex-1 flex="~ gap-1 center" p4 border="~ base rounded" bg-base ws-nowrap
40 |         :aria-pressed="colorMode.preference === mode ? 'true' : 'false'"
41 |         :class="colorMode.preference === mode ? 'pointer-events-none' : 'filter-saturate-0'"
42 |         @click="setColorMode(mode)"
43 |       >
44 |         <span :class="`${icon}`" />
45 |         {{ $t(label) }}
46 |       </button>
47 |     </div>
48 |   </section>
49 | </template>
50 | 


--------------------------------------------------------------------------------
/app/components/settings/SettingsLanguage.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | import type { LocaleObject } from '@nuxtjs/i18n'
 3 | import type { ComputedRef } from 'vue'
 4 | 
 5 | const userSettings = useUserSettings()
 6 | 
 7 | const { locales } = useI18n() as { locales: ComputedRef<LocaleObject[]> }
 8 | </script>
 9 | 
10 | <template>
11 |   <select v-model="userSettings.language">
12 |     <option v-for="item in locales" :key="item.code" :value="item.code" :selected="userSettings.language === item.code">
13 |       {{ item.name }}
14 |     </option>
15 |   </select>
16 | </template>
17 | 


--------------------------------------------------------------------------------
/app/components/status/StatusAccountDetails.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | import type { mastodon } from 'masto'
 3 | 
 4 | const { link = true } = defineProps<{
 5 |   account: mastodon.v1.Account
 6 |   link?: boolean
 7 | }>()
 8 | 
 9 | const userSettings = useUserSettings()
10 | </script>
11 | 
12 | <template>
13 |   <NuxtLink
14 |     :to="link ? getAccountRoute(account) : undefined"
15 |     flex="~ col" min-w-0 md:flex="~ row gap-2" md:items-center
16 |     text-link-rounded
17 |   >
18 |     <AccountDisplayName :account="account" :hide-emojis="getPreferences(userSettings, 'hideUsernameEmojis')" font-bold line-clamp-1 ws-pre-wrap break-all />
19 |     <AccountHandle :account="account" class="zen-none" />
20 |   </NuxtLink>
21 | </template>
22 | 


--------------------------------------------------------------------------------
/app/components/status/StatusCardSkeleton.vue:
--------------------------------------------------------------------------------
 1 | <template>
 2 |   <div flex flex-col gap-2 px-4 py-3>
 3 |     <div flex gap-4>
 4 |       <div>
 5 |         <div w-12 h-12 rounded-full class="skeleton-loading-bg" />
 6 |       </div>
 7 |       <div flex="~ col 1 gap-2" pb2 min-w-0>
 8 |         <div flex class="skeleton-loading-bg" h-5 w-20 rounded />
 9 |         <div flex class="skeleton-loading-bg" h-4 w-full rounded />
10 |         <div flex class="skeleton-loading-bg" h-4 w="4/5" rounded />
11 |         <div flex class="skeleton-loading-bg" h-4 w="2/5" rounded />
12 |       </div>
13 |     </div>
14 |   </div>
15 | </template>
16 | 


--------------------------------------------------------------------------------
/app/components/status/StatusLink.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | import type { mastodon } from 'masto'
 3 | 
 4 | const { status } = defineProps<{
 5 |   status: mastodon.v1.Status
 6 |   hover?: boolean
 7 | }>()
 8 | 
 9 | const el = ref<HTMLElement>()
10 | const router = useRouter()
11 | const statusRoute = computed(() => getStatusRoute(status))
12 | 
13 | function onclick(evt: MouseEvent | KeyboardEvent) {
14 |   const path = evt.composedPath() as HTMLElement[]
15 |   const el = path.find(el => ['A', 'BUTTON', 'IMG', 'VIDEO'].includes(el.tagName?.toUpperCase()))
16 |   const text = window.getSelection()?.toString()
17 |   const isCustomEmoji = el?.parentElement?.classList.contains('custom-emoji')
18 |   if ((!el && !text) || isCustomEmoji)
19 |     go(evt)
20 | }
21 | 
22 | function go(evt: MouseEvent | KeyboardEvent) {
23 |   if (evt.metaKey || evt.ctrlKey) {
24 |     window.open(statusRoute.value.href)
25 |   }
26 |   else {
27 |     cacheStatus(status)
28 |     router.push(statusRoute.value)
29 |   }
30 | }
31 | </script>
32 | 
33 | <template>
34 |   <div
35 |     :id="`status-${status.id}`"
36 |     ref="el"
37 |     relative flex="~ col gap1"
38 |     p="b-2 is-3 ie-4"
39 |     :class="{ 'hover:bg-active': hover }"
40 |     tabindex="0"
41 |     focus:outline-none focus-visible:ring="2 primary inset"
42 |     aria-roledescription="status-card"
43 |     :lang="status.language ?? undefined"
44 |     @click="onclick"
45 |     @keydown.enter="onclick"
46 |   >
47 |     <slot />
48 |   </div>
49 | </template>
50 | 


--------------------------------------------------------------------------------
/app/components/status/StatusMedia.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | import type { mastodon } from 'masto'
 3 | 
 4 | const { status, isPreview = false } = defineProps<{
 5 |   status: mastodon.v1.Status | mastodon.v1.StatusEdit
 6 |   fullSize?: boolean
 7 |   isPreview?: boolean
 8 | }>()
 9 | 
10 | const gridColumnNumber = computed(() => {
11 |   const num = status.mediaAttachments.length
12 |   if (num <= 1)
13 |     return 1
14 |   else if (num <= 4)
15 |     return 2
16 |   else
17 |     return 3
18 | })
19 | </script>
20 | 
21 | <template>
22 |   <div class="status-media-container">
23 |     <template v-for="attachment of status.mediaAttachments" :key="attachment.id">
24 |       <StatusAttachment
25 |         :attachment="attachment"
26 |         :attachments="status.mediaAttachments"
27 |         :full-size="fullSize"
28 |         w-full
29 |         h-full
30 |         :is-preview="isPreview"
31 |       />
32 |     </template>
33 |   </div>
34 | </template>
35 | 
36 | <style lang="postcss">
37 | .status-media-container {
38 |   --grid-cols: v-bind(gridColumnNumber);
39 |   display: grid;
40 |   grid-template-columns: repeat(var(--grid-cols, 1), 1fr);
41 |   --at-apply: gap-2;
42 |   position: relative;
43 |   width: 100%;
44 |   overflow: hidden;
45 | }
46 | </style>
47 | 


--------------------------------------------------------------------------------
/app/components/status/StatusNotFound.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | const { account, status } = defineProps<{
 3 |   account: string
 4 |   status: string
 5 | }>()
 6 | 
 7 | const originalUrl = computed(() => {
 8 |   const [handle, _server] = account.split('@')
 9 |   const server = _server || currentUser.value?.server
10 |   if (!server)
11 |     return null
12 | 
13 |   return `https://${server}/@${handle}/${status}`
14 | })
15 | </script>
16 | 
17 | <template>
18 |   <CommonNotFound>
19 |     <div flex="~ col center gap2">
20 |       <div>{{ $t('error.status_not_found') }}</div>
21 | 
22 |       <NuxtLink v-if="originalUrl" :to="originalUrl" external target="_blank">
23 |         <button btn-solid flex="~ center gap-2" text-sm px2 py1>
24 |           <div i-ri:arrow-right-up-line />
25 |           {{ $t('status.try_original_site') }}
26 |         </button>
27 |       </NuxtLink>
28 |     </div>
29 |   </CommonNotFound>
30 | </template>
31 | 


--------------------------------------------------------------------------------
/app/components/status/StatusPreviewCard.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | import type { mastodon } from 'masto'
 3 | 
 4 | const { card } = defineProps<{
 5 |   card: mastodon.v1.PreviewCard
 6 |   /** For the preview image, only the small image mode is displayed */
 7 |   smallPictureOnly?: boolean
 8 |   /** When it is root card in the list, not appear as a child card */
 9 |   root?: boolean
10 | }>()
11 | 
12 | const providerName = card.providerName
13 | 
14 | const gitHubCards = usePreferences('experimentalGitHubCards')
15 | </script>
16 | 
17 | <template>
18 |   <LazyStatusPreviewGitHub v-if="gitHubCards && providerName === 'GitHub'" :card="card" />
19 |   <LazyStatusPreviewStackBlitz v-else-if="gitHubCards && providerName === 'StackBlitz'" :card="card" :small-picture-only="smallPictureOnly" :root="root" />
20 |   <StatusPreviewCardNormal v-else :card="card" :small-picture-only="smallPictureOnly" :root="root" />
21 | </template>
22 | 


--------------------------------------------------------------------------------
/app/components/status/StatusPreviewCardInfo.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | import type { mastodon } from 'masto'
 3 | 
 4 | defineProps<{
 5 |   card: mastodon.v1.PreviewCard
 6 |   /** When it is root card in the list, not appear as a child card */
 7 |   root?: boolean
 8 |   /** For the preview image, only the small image mode is displayed */
 9 |   provider?: string
10 | }>()
11 | </script>
12 | 
13 | <template>
14 |   <div
15 |     max-h-2xl
16 |     flex flex-col
17 |     my-auto
18 |     :class="[
19 |       root ? 'flex-gap-1' : 'justify-center sm:justify-start',
20 |     ]"
21 |   >
22 |     <p text-secondary break-all line-clamp-1>
23 |       {{ provider }}
24 |     </p>
25 |     <strong
26 |       v-if="card.title" font-normal sm:font-medium line-clamp-1
27 |       break-all
28 |     >{{ card.title }}</strong>
29 |     <p
30 |       v-if="card.description"
31 |       line-clamp-1 break-all sm:break-words text-secondary :class="[root ? 'sm:line-clamp-2' : '']"
32 |     >
33 |       {{ card.description }}
34 |     </p>
35 |   </div>
36 | </template>
37 | 


--------------------------------------------------------------------------------
/app/components/status/StatusPreviewCardMoreFromAuthor.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | import type { mastodon } from 'masto'
 3 | 
 4 | defineProps<{
 5 |   account: mastodon.v1.Account
 6 | }>()
 7 | </script>
 8 | 
 9 | <template>
10 |   <div
11 |     max-h-2xl
12 |     flex gap-2
13 |     my-auto
14 |     p-4 py-2
15 |     light:bg-gray-3 dark:bg-gray-8
16 |   >
17 |     <span z-0>More from</span>
18 |     <AccountInlineInfo :account="account" hover:bg-inherit ps-0 ms-0 />
19 |   </div>
20 | </template>
21 | 


--------------------------------------------------------------------------------
/app/components/status/StatusPreviewCardSkeleton.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | defineProps<{
 3 |   /** For the preview image, only the small image mode is displayed */
 4 |   square?: boolean
 5 |   /** When it is root card in the list, not appear as a child card */
 6 |   root?: boolean
 7 | }>()
 8 | </script>
 9 | 
10 | <template>
11 |   <div
12 |     of-hidden
13 |     :class="{
14 |       'flex': square,
15 |       'p-4': root,
16 |       'rounded-lg border border-base': !root,
17 |     }"
18 |   >
19 |     <div
20 |       flex flex-col
21 |       display-block of-hidden
22 |       border="base"
23 |       :class="{
24 |         'sm:(min-w-32 w-32 h-32) min-w-22 w-22 h-22 border-r': square,
25 |         'w-full aspect-[1.91] border-b': !square,
26 |         'rounded-lg': root,
27 |       }"
28 |     >
29 |       <div w-full h-full class="skeleton-loading-bg" />
30 |     </div>
31 |     <div
32 |       px3 max-h-2xl
33 |       flex-1 flex flex-col flex-gap-2 sm:flex-gap-3
34 |       :class="[
35 |         root ? 'py2.5 sm:py3' : 'py3  justify-center sm:justify-start',
36 |       ]"
37 |     >
38 |       <div flex class="skeleton-loading-bg" h-4 w-30 rounded :class="root ? '' : 'hidden sm:block'" />
39 |       <div flex class="skeleton-loading-bg" h-5 w="4/5" rounded />
40 |       <div flex="~ col gap-2">
41 |         <div flex class="skeleton-loading-bg" h-4 w-full rounded />
42 |         <div sm:flex hidden class="skeleton-loading-bg" h-4 w="2/5" rounded />
43 |       </div>
44 |     </div>
45 |   </div>
46 | </template>
47 | 


--------------------------------------------------------------------------------
/app/components/status/StatusTranslation.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | import type { mastodon } from 'masto'
 3 | 
 4 | const { status } = defineProps<{
 5 |   status: mastodon.v1.Status
 6 | }>()
 7 | 
 8 | const {
 9 |   toggle: _toggleTranslation,
10 |   translation,
11 |   enabled: isTranslationEnabled,
12 | } = await useTranslation(status, getLanguageCode())
13 | const preferenceHideTranslation = usePreferences('hideTranslation')
14 | 
15 | const showButton = computed(() =>
16 |   !preferenceHideTranslation.value
17 |   && isTranslationEnabled
18 |   && status.content.trim().length,
19 | )
20 | 
21 | const translating = ref(false)
22 | async function toggleTranslation() {
23 |   translating.value = true
24 |   try {
25 |     await _toggleTranslation()
26 |   }
27 |   finally {
28 |     translating.value = false
29 |   }
30 | }
31 | </script>
32 | 
33 | <template>
34 |   <div v-if="showButton">
35 |     <button
36 |       p-0 flex="~ center" gap-2 text-sm
37 |       :disabled="translating" disabled-bg-transparent btn-text class="disabled-text-$c-text-btn-disabled-deeper" @click="toggleTranslation"
38 |     >
39 |       <span v-if="translating" block animate-spin preserve-3d>
40 |         <span block i-ri:loader-2-fill />
41 |       </span>
42 |       <div v-else i-ri:translate />
43 |       {{ translation.visible ? $t('menu.show_untranslated') : $t('menu.translate_post') }}
44 |     </button>
45 |   </div>
46 | </template>
47 | 
48 | <style scoped></style>
49 | 


--------------------------------------------------------------------------------
/app/components/status/StatusVisibilityIndicator.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | import type { mastodon } from 'masto'
 3 | 
 4 | const { status } = defineProps<{
 5 |   status: mastodon.v1.Status
 6 | }>()
 7 | 
 8 | const visibility = computed(() => statusVisibilities.find(v => v.value === status.visibility)!)
 9 | </script>
10 | 
11 | <template>
12 |   <CommonTooltip :content="$t(`visibility.${visibility.value}`)" placement="bottom">
13 |     <div :class="visibility.icon" :aria-label="$t(`visibility.${visibility.value}`)" />
14 |   </CommonTooltip>
15 | </template>
16 | 


--------------------------------------------------------------------------------
/app/components/status/edit/StatusEditHistorySkeleton.vue:
--------------------------------------------------------------------------------
1 | <template>
2 |   <div class="skeleton-loading-bg" h-5 w-full rounded my2 />
3 | </template>
4 | 


--------------------------------------------------------------------------------
/app/components/status/edit/StatusEditIndicator.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | import type { mastodon } from 'masto'
 3 | 
 4 | const { status } = defineProps<{
 5 |   status: mastodon.v1.Status
 6 |   inline: boolean
 7 | }>()
 8 | 
 9 | const editedAt = computed(() => status.editedAt)
10 | const formatted = useFormattedDateTime(editedAt)
11 | </script>
12 | 
13 | <template>
14 |   <template v-if="editedAt">
15 |     <CommonTooltip v-if="inline" :content="$t('status.edited', [formatted])">
16 |       &#160;
17 |       <time
18 |         :title="editedAt"
19 |         :datetime="editedAt"
20 |         font-bold underline decoration-dashed
21 |         text-secondary
22 |       >&#160;*&#160;</time>
23 |     </CommonTooltip>
24 | 
25 |     <CommonDropdown v-else>
26 |       <slot />
27 | 
28 |       <template #popper>
29 |         <div text-sm p2>
30 |           <div text-center mb1>
31 |             {{ $t('status.edited', [formatted]) }}
32 |           </div>
33 |           <StatusEditHistory :status="status" />
34 |         </div>
35 |       </template>
36 |     </CommonDropdown>
37 |   </template>
38 | </template>
39 | 


--------------------------------------------------------------------------------
/app/components/status/edit/StatusEditPreview.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | import type { mastodon } from 'masto'
 3 | 
 4 | defineProps<{
 5 |   edit: mastodon.v1.StatusEdit
 6 | }>()
 7 | </script>
 8 | 
 9 | <template>
10 |   <div px3 py-4 flex="~ col">
11 |     <div text-center flex="~ row gap-1 wrap">
12 |       <AccountInlineInfo :account="edit.account" />
13 |       <span>
14 |         {{ $t('status_history.edited', [useFormattedDateTime(edit.createdAt).value]) }}
15 |       </span>
16 |     </div>
17 | 
18 |     <div h1px bg="gray/20" my2 />
19 | 
20 |     <StatusSpoiler :enabled="edit.sensitive">
21 |       <template #spoiler>
22 |         {{ edit.spoilerText }}
23 |       </template>
24 |       <StatusBody :status="edit" />
25 |       <StatusMedia v-if="edit.mediaAttachments.length" :status="edit" />
26 |     </StatusSpoiler>
27 |   </div>
28 | </template>
29 | 


--------------------------------------------------------------------------------
/app/components/tag/TagCardPaginator.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | import type { mastodon } from 'masto'
 3 | 
 4 | defineProps<{
 5 |   paginator: mastodon.Paginator<mastodon.v1.Tag[], mastodon.DefaultPaginationParams>
 6 | }>()
 7 | </script>
 8 | 
 9 | <template>
10 |   <CommonPaginator :paginator="paginator" key-prop="name">
11 |     <template #default="{ item }">
12 |       <TagCard :tag="item" border="b base" />
13 |     </template>
14 |     <template #loading>
15 |       <TagCardSkeleton border="b base" />
16 |       <TagCardSkeleton border="b base" />
17 |       <TagCardSkeleton border="b base" op50 />
18 |       <TagCardSkeleton border="b base" op50 />
19 |       <TagCardSkeleton border="b base" op25 />
20 |     </template>
21 |   </CommonPaginator>
22 | </template>
23 | 


--------------------------------------------------------------------------------
/app/components/tag/TagCardSkeleton.vue:
--------------------------------------------------------------------------------
 1 | <template>
 2 |   <div p4 flex justify-between gap-4>
 3 |     <div flex="~ col 1 gap-2">
 4 |       <div flex class="skeleton-loading-bg" h-5 w-30 rounded />
 5 |       <div flex class="skeleton-loading-bg" h-4 w-45 rounded />
 6 |     </div>
 7 |     <div flex items-center>
 8 |       <div flex class="skeleton-loading-bg" h-9 w-15 rounded />
 9 |     </div>
10 |   </div>
11 | </template>
12 | 


--------------------------------------------------------------------------------
/app/components/timeline/TimelineBlocks.vue:
--------------------------------------------------------------------------------
1 | <script setup lang="ts">
2 | const paginator = useMastoClient().v1.blocks.list()
3 | </script>
4 | 
5 | <template>
6 |   <AccountPaginator :paginator="paginator" />
7 | </template>
8 | 


--------------------------------------------------------------------------------
/app/components/timeline/TimelineBookmarks.vue:
--------------------------------------------------------------------------------
1 | <script setup lang="ts">
2 | const paginator = useMastoClient().v1.bookmarks.list()
3 | </script>
4 | 
5 | <template>
6 |   <TimelinePaginator end-message="common.no_bookmarks" :paginator="paginator" />
7 | </template>
8 | 


--------------------------------------------------------------------------------
/app/components/timeline/TimelineConversations.vue:
--------------------------------------------------------------------------------
1 | <script setup lang="ts">
2 | const paginator = useMastoClient().v1.conversations.list()
3 | </script>
4 | 
5 | <template>
6 |   <ConversationPaginator :paginator="paginator" />
7 | </template>
8 | 


--------------------------------------------------------------------------------
/app/components/timeline/TimelineDomainBlocks.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | const { client } = useMasto()
 3 | const paginator = client.value.v1.domainBlocks.list()
 4 | 
 5 | async function unblock(domain: string) {
 6 |   await client.value.v1.domainBlocks.remove({ domain })
 7 | }
 8 | </script>
 9 | 
10 | <template>
11 |   <CommonPaginator :paginator="paginator">
12 |     <template #default="{ item }">
13 |       <CommonDropdownItem class="!cursor-auto">
14 |         {{ item }}
15 |         <template #actions>
16 |           <div i-ri:lock-unlock-line text-primary cursor-pointer @click="unblock(item)" />
17 |         </template>
18 |       </CommonDropdownItem>
19 |     </template>
20 |   </CommonPaginator>
21 | </template>
22 | 


--------------------------------------------------------------------------------
/app/components/timeline/TimelineFavourites.vue:
--------------------------------------------------------------------------------
1 | <script setup lang="ts">
2 | const paginator = useMastoClient().v1.favourites.list()
3 | </script>
4 | 
5 | <template>
6 |   <TimelinePaginator end-message="common.no_favourites" :paginator="paginator" />
7 | </template>
8 | 


--------------------------------------------------------------------------------
/app/components/timeline/TimelineHome.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | import type { mastodon } from 'masto'
 3 | 
 4 | const { isSupported, effectiveType } = useNetwork()
 5 | const isSlow = computed(() => isSupported.value && effectiveType.value && ['slow-2g', '2g', '3g'].includes(effectiveType.value))
 6 | const limit = computed(() => isSlow.value ? 10 : 30)
 7 | 
 8 | const paginator = useMastoClient().v1.timelines.home.list({ limit: limit.value })
 9 | const stream = useStreaming(client => client.user.subscribe())
10 | function reorderAndFilter(items: mastodon.v1.Status[]) {
11 |   return reorderedTimeline(items, 'home')
12 | }
13 | 
14 | let followedTags: mastodon.v1.Tag[] | undefined
15 | if (currentUser.value !== undefined) {
16 |   followedTags = (await useMasto().client.value.v1.followedTags.list({ limit: 0 }))
17 | }
18 | </script>
19 | 
20 | <template>
21 |   <div>
22 |     <PublishWidgetList draft-key="home" />
23 |     <div h="1px" w-auto bg-border mb-3 />
24 |     <TimelinePaginator :followed-tags="followedTags" v-bind="{ paginator, stream }" :preprocess="reorderAndFilter" context="home" />
25 |   </div>
26 | </template>
27 | 


--------------------------------------------------------------------------------
/app/components/timeline/TimelineMutes.vue:
--------------------------------------------------------------------------------
1 | <script setup lang="ts">
2 | const paginator = useMastoClient().v1.mutes.list()
3 | </script>
4 | 
5 | <template>
6 |   <AccountPaginator :paginator="paginator" />
7 | </template>
8 | 


--------------------------------------------------------------------------------
/app/components/timeline/TimelineNotifications.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | import type { mastodon } from 'masto'
 3 | import { STORAGE_KEY_LAST_ACCESSED_NOTIFICATION_ROUTE } from '~/constants'
 4 | 
 5 | const { filter } = defineProps<{
 6 |   filter?: mastodon.v1.NotificationType
 7 | }>()
 8 | 
 9 | const route = useRoute()
10 | const lastAccessedNotificationRoute = useLocalStorage(STORAGE_KEY_LAST_ACCESSED_NOTIFICATION_ROUTE, '')
11 | 
12 | const options = { limit: 30, types: filter ? [filter] : [] }
13 | 
14 | // Default limit is 20 notifications, and servers are normally caped to 30
15 | const paginator = useMastoClient().v1.notifications.list(options)
16 | const stream = useStreaming(client => client.user.notification.subscribe())
17 | 
18 | lastAccessedNotificationRoute.value = route.path.replace(/\/notifications\/?/, '')
19 | 
20 | const { clearNotifications } = useNotifications()
21 | onActivated(() => {
22 |   clearNotifications()
23 |   lastAccessedNotificationRoute.value = route.path.replace(/\/notifications\/?/, '')
24 | })
25 | </script>
26 | 
27 | <template>
28 |   <NotificationPaginator v-bind="{ paginator, stream }" />
29 | </template>
30 | 


--------------------------------------------------------------------------------
/app/components/timeline/TimelinePinned.vue:
--------------------------------------------------------------------------------
1 | <script setup lang="ts">
2 | const paginator = useMastoClient().v1.accounts.$select(currentUser.value!.account.id).statuses.list({ pinned: true })
3 | </script>
4 | 
5 | <template>
6 |   <TimelinePaginator :paginator="paginator" />
7 | </template>
8 | 


--------------------------------------------------------------------------------
/app/components/timeline/TimelinePublic.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | import type { mastodon } from 'masto'
 3 | 
 4 | const paginator = useMastoClient().v1.timelines.public.list({ limit: 30 })
 5 | const stream = useStreaming(client => client.public.subscribe())
 6 | function reorderAndFilter(items: mastodon.v1.Status[]) {
 7 |   return reorderedTimeline(items, 'public')
 8 | }
 9 | 
10 | let followedTags: mastodon.v1.Tag[] | undefined
11 | if (currentUser.value !== undefined) {
12 |   followedTags = (await useMasto().client.value.v1.followedTags.list({ limit: 0 }))
13 | }
14 | </script>
15 | 
16 | <template>
17 |   <div>
18 |     <TimelinePaginator :followed-tags="followedTags" v-bind="{ paginator, stream }" :preprocess="reorderAndFilter" context="public" />
19 |   </div>
20 | </template>
21 | 


--------------------------------------------------------------------------------
/app/components/timeline/TimelinePublicLocal.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | import type { mastodon } from 'masto'
 3 | 
 4 | const paginator = useMastoClient().v1.timelines.public.list({ limit: 30, local: true })
 5 | const stream = useStreaming(client => client.public.local.subscribe())
 6 | function reorderAndFilter(items: mastodon.v1.Status[]) {
 7 |   return reorderedTimeline(items, 'public')
 8 | }
 9 | 
10 | let followedTags: mastodon.v1.Tag[] | undefined
11 | if (currentUser.value !== undefined) {
12 |   followedTags = (await useMasto().client.value.v1.followedTags.list({ limit: 0 }))
13 | }
14 | </script>
15 | 
16 | <template>
17 |   <div>
18 |     <TimelinePaginator :followed-tags="followedTags" v-bind="{ paginator, stream }" :preprocess="reorderAndFilter" context="public" />
19 |   </div>
20 | </template>
21 | 


--------------------------------------------------------------------------------
/app/components/timeline/TimelineSkeleton.vue:
--------------------------------------------------------------------------------
1 | <template>
2 |   <div>
3 |     <StatusCardSkeleton border="b base" op50 />
4 |     <StatusCardSkeleton border="b base" op35 />
5 |     <StatusCardSkeleton border="b base" op25 />
6 |     <StatusCardSkeleton border="b base" op10 />
7 |   </div>
8 | </template>
9 | 


--------------------------------------------------------------------------------
/app/components/tiptap/TiptapCodeBlock.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | import { NodeViewContent, nodeViewProps, NodeViewWrapper } from '@tiptap/vue-3'
 3 | 
 4 | const { node, updateAttributes } = defineProps(nodeViewProps)
 5 | 
 6 | const languages = [
 7 |   'c',
 8 |   'cpp',
 9 |   'csharp',
10 |   'css',
11 |   'dart',
12 |   'go',
13 |   'html',
14 |   'java',
15 |   'javascript',
16 |   'jsx',
17 |   'kotlin',
18 |   'python',
19 |   'rust',
20 |   'svelte',
21 |   'swift',
22 |   'tsx',
23 |   'typescript',
24 |   'vue',
25 | ]
26 | 
27 | const selectedLanguage = computed({
28 |   get() {
29 |     return node.attrs.language
30 |   },
31 |   set(language) {
32 |     updateAttributes({ language })
33 |   },
34 | })
35 | </script>
36 | 
37 | <template>
38 |   <NodeViewWrapper>
39 |     <div relative my2>
40 |       <select
41 |         v-model="selectedLanguage"
42 |         contenteditable="false"
43 |         absolute top-1 right-1 rounded px2 op0 hover:op100 focus:op100 transition
44 |         outline-none border="~ base"
45 |       >
46 |         <option :value="null">
47 |           plain
48 |         </option>
49 |         <option v-for="(language, index) in languages" :key="index" :value="language">
50 |           {{ language }}
51 |         </option>
52 |       </select>
53 |       <pre class="code-block"><code><NodeViewContent /></code></pre>
54 |     </div>
55 |   </NodeViewWrapper>
56 | </template>
57 | 


--------------------------------------------------------------------------------
/app/components/user/UserDropdown.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | const mask = useMask()
 3 | </script>
 4 | 
 5 | <template>
 6 |   <VDropdown :distance="0" placement="top-start" strategy="fixed" @apply-show="mask.show()" @apply-hide="mask.hide()">
 7 |     <button btn-action-icon :aria-label="$t('action.switch_account')">
 8 |       <div :class="{ 'hidden xl:block': currentUser }" i-ri:more-2-line />
 9 |       <AccountAvatar v-if="currentUser" xl:hidden :account="currentUser.account" w-9 h-9 square />
10 |     </button>
11 |     <template #popper="{ hide }">
12 |       <UserSwitcher @click="hide" />
13 |     </template>
14 |   </VDropdown>
15 | </template>
16 | 


--------------------------------------------------------------------------------
/app/components/user/UserPicker.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | import type { UserLogin } from '#shared/types'
 3 | 
 4 | const all = useUsers()
 5 | const router = useRouter()
 6 | 
 7 | function clickUser(user: UserLogin) {
 8 |   if (user.account.acct === currentUser.value?.account.acct)
 9 |     router.push(getAccountRoute(user.account))
10 |   else
11 |     switchUser(user)
12 | }
13 | </script>
14 | 
15 | <template>
16 |   <div flex justify-start items-end px-2 gap-5>
17 |     <div flex="~ wrap-reverse" gap-5>
18 |       <template v-for="user of all" :key="user.id">
19 |         <CommonTooltip :distance="8" :delay="{ show: 300, hide: 100 }">
20 |           <button
21 |             flex rounded
22 |             cursor-pointer
23 |             :aria-label="$t('action.switch_account')"
24 |             :class="user.account.acct === currentUser?.account.acct ? '' : 'op25 grayscale'"
25 |             hover="filter-none op100"
26 |             @click="clickUser(user)"
27 |           >
28 |             <AccountAvatar w-13 h-13 :account="user.account" square />
29 |           </button>
30 | 
31 |           <template #popper>
32 |             <div text-center>
33 |               <span text-4>
34 |                 <AccountDisplayName :account="user.account" />
35 |               </span>
36 |               <AccountHandle :account="user.account" />
37 |             </div>
38 |           </template>
39 |         </CommonTooltip>
40 |       </template>
41 |     </div>
42 |     <div flex items-center justify-center w-13 h-13>
43 |       <UserDropdown />
44 |     </div>
45 |   </div>
46 | </template>
47 | 


--------------------------------------------------------------------------------
/app/components/user/UserSignInEntry.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | const { busy, oauth, singleInstanceServer } = useSignIn()
 3 | </script>
 4 | 
 5 | <template>
 6 |   <div p8 lg:flex="~ col gap2" hidden>
 7 |     <p v-if="isHydrated" text-sm>
 8 |       <i18n-t keypath="user.sign_in_notice_title">
 9 |         <strong>{{ currentServer }}</strong>
10 |       </i18n-t>
11 |     </p>
12 |     <p text-sm text-secondary>
13 |       {{ $t(singleInstanceServer ? 'user.single_instance_sign_in_desc' : 'user.sign_in_desc') }}
14 |     </p>
15 |     <button
16 |       v-if="singleInstanceServer"
17 |       flex="~ row" gap-x-2 items-center justify-center btn-solid text-center rounded-3
18 |       :disabled="busy"
19 |       @click="oauth()"
20 |     >
21 |       <span v-if="busy" aria-hidden="true" block animate animate-spin preserve-3d class="rtl-flip">
22 |         <span block i-ri:loader-2-fill aria-hidden="true" />
23 |       </span>
24 |       <span v-else aria-hidden="true" block i-ri:login-circle-line class="rtl-flip" />
25 |       {{ $t('action.sign_in') }}
26 |     </button>
27 |     <button v-else btn-solid rounded-3 text-center mt-2 select-none @click="openSigninDialog()">
28 |       {{ $t('action.sign_in') }}
29 |     </button>
30 |   </div>
31 | </template>
32 | 


--------------------------------------------------------------------------------
/app/composables/aria.ts:
--------------------------------------------------------------------------------
 1 | export type AriaLive = 'off' | 'polite' | 'assertive'
 2 | export type AriaAnnounceType = 'announce' | 'mute' | 'unmute'
 3 | 
 4 | const ariaAnnouncer = useEventBus<AriaAnnounceType, string | undefined>(Symbol('aria-announcer'))
 5 | 
 6 | export function useAriaAnnouncer() {
 7 |   const announce = (message: string) => {
 8 |     ariaAnnouncer.emit('announce', message)
 9 |   }
10 | 
11 |   const mute = () => {
12 |     ariaAnnouncer.emit('mute')
13 |   }
14 | 
15 |   const unmute = () => {
16 |     ariaAnnouncer.emit('unmute')
17 |   }
18 | 
19 |   return { announce, ariaAnnouncer, mute, unmute }
20 | }
21 | 
22 | export function useAriaLog() {
23 |   const logs = ref<any[]>([])
24 | 
25 |   const announceLogs = (messages: any[]) => {
26 |     logs.value = messages
27 |   }
28 | 
29 |   const appendLogs = (messages: any[]) => {
30 |     logs.value = logs.value.concat(messages)
31 |   }
32 | 
33 |   const clearLogs = () => {
34 |     logs.value = []
35 |   }
36 | 
37 |   return {
38 |     announceLogs,
39 |     appendLogs,
40 |     clearLogs,
41 |     logs,
42 |   }
43 | }
44 | 
45 | export function useAriaStatus() {
46 |   const status = ref<any>('')
47 | 
48 |   const announceStatus = (message: any) => {
49 |     status.value = message
50 |   }
51 | 
52 |   const clearStatus = () => {
53 |     status.value = ''
54 |   }
55 | 
56 |   return {
57 |     announceStatus,
58 |     clearStatus,
59 |     status,
60 |   }
61 | }
62 | 


--------------------------------------------------------------------------------
/app/composables/injections.ts:
--------------------------------------------------------------------------------
1 | import { InjectionKeyDropdownContext } from '~/constants/symbols'
2 | 
3 | export function useDropdownContext() {
4 |   return inject(InjectionKeyDropdownContext, undefined)
5 | }
6 | 


--------------------------------------------------------------------------------
/app/composables/langugage.ts:
--------------------------------------------------------------------------------
 1 | import ISO6391 from 'iso-639-1'
 2 | 
 3 | export const languagesNameList: {
 4 |   code: string
 5 |   nativeName: string
 6 |   name: string
 7 | }[] = ISO6391.getAllCodes().map(code => ({
 8 |   code,
 9 |   nativeName: ISO6391.getNativeName(code),
10 |   name: ISO6391.getName(code),
11 | }))
12 | 


--------------------------------------------------------------------------------
/app/composables/magickeys.ts:
--------------------------------------------------------------------------------
 1 | import type { ComputedRef } from 'vue'
 2 | 
 3 | // TODO: consider to allow combinations similar to useMagicKeys using proxy?
 4 | //       e.g. `const magicSequence = useMagicSequence()`
 5 | //         `magicSequence['Shift+Ctrl+A']`
 6 | //         `const { Ctrl_A_B } = useMagicSequence()`
 7 | 
 8 | /**
 9 |  * source: inspired by https://github.com/vueuse/vueuse/issues/427#issuecomment-815619446
10 |  * @param keys ordered list of keys making up the sequence
11 |  */
12 | export function useMagicSequence(keys: string[]): ComputedRef<boolean> {
13 |   const magicKeys = useMagicKeys()
14 | 
15 |   const success = ref(false)
16 |   const i = ref(0)
17 |   let down = false
18 | 
19 |   watch(
20 |     () => magicKeys.current,
21 |     () => {
22 |       if (magicKeys[keys[i.value]].value && !down) {
23 |         down = true
24 |         i.value += 1
25 |       }
26 |       else if (i.value > 0 && !magicKeys[keys[i.value - 1]].value && down) {
27 |         down = false
28 |       }
29 |       else {
30 |         i.value = 0
31 |         down = false
32 |         success.value = false
33 |       }
34 |       if (i.value >= keys.length && !down) {
35 |         i.value = 0
36 |         down = false
37 |         success.value = true
38 |       }
39 |     },
40 |     {
41 |       deep: true,
42 |     },
43 |   )
44 | 
45 |   return computed(() => success.value)
46 | }
47 | 


--------------------------------------------------------------------------------
/app/composables/mask.ts:
--------------------------------------------------------------------------------
 1 | import { h, render } from 'vue'
 2 | import CommonMask from '~/components/common/CommonMask.vue'
 3 | 
 4 | export interface UseMaskOptions {
 5 |   getContainer?: () => HTMLElement
 6 |   background?: string
 7 |   zIndex?: number
 8 | }
 9 | 
10 | export function useMask(options: UseMaskOptions = {}) {
11 |   const {
12 |     background = 'transparent',
13 |     getContainer = () => document.body,
14 |     zIndex = 100,
15 |   } = options
16 |   const wrapperEl = (import.meta.server ? null : document.createElement('div')) as HTMLDivElement
17 | 
18 |   function show() {
19 |     const container = getContainer()
20 |     container?.appendChild(wrapperEl)
21 |     const MaskComp = h(CommonMask, { background, zIndex })
22 |     render(MaskComp, wrapperEl)
23 |   }
24 | 
25 |   function hide() {
26 |     render(null, wrapperEl)
27 |     wrapperEl.parentNode?.removeChild(wrapperEl)
28 |   }
29 | 
30 |   return {
31 |     show,
32 |     hide,
33 |   }
34 | }
35 | 


--------------------------------------------------------------------------------
/app/composables/misc.ts:
--------------------------------------------------------------------------------
 1 | import type { mastodon } from 'masto'
 2 | 
 3 | export const UserLinkRE = /^(?:https:\/)?\/([^/]+)\/@([^/]+)$/
 4 | export const TagLinkRE = /^https?:\/\/([^/]+)\/tags\/([^/]+)\/?$/
 5 | export const HTMLTagRE = /<[^>]+>/g
 6 | 
 7 | export function getDataUrlFromArr(arr: Uint8ClampedArray, w: number, h: number) {
 8 |   if (typeof w === 'undefined' || typeof h === 'undefined')
 9 |     w = h = Math.sqrt(arr.length / 4)
10 | 
11 |   const canvas = document.createElement('canvas')
12 |   const ctx = canvas.getContext('2d')!
13 | 
14 |   canvas.width = w
15 |   canvas.height = h
16 | 
17 |   const imgData = ctx.createImageData(w, h)
18 |   imgData.data.set(arr)
19 |   ctx.putImageData(imgData, 0, 0)
20 | 
21 |   return canvas.toDataURL()
22 | }
23 | 
24 | export function emojisArrayToObject(emojis: mastodon.v1.CustomEmoji[]) {
25 |   return Object.fromEntries(emojis.map(i => [i.shortcode, i]))
26 | }
27 | 
28 | export function noop() {}
29 | 
30 | export function useIsMac() {
31 |   const headers = useRequestHeaders(['user-agent'])
32 |   return computed(() => headers['user-agent']?.includes('Macintosh')
33 |     ?? navigator?.userAgent?.includes('Mac') ?? false)
34 | }
35 | 
36 | export function isEmptyObject(object: object) {
37 |   return Object.keys(object).length === 0
38 | }
39 | 
40 | export function removeHTMLTags(str: string) {
41 |   return str.replaceAll(HTMLTagRE, '')
42 | }
43 | 


--------------------------------------------------------------------------------
/app/composables/notification.ts:
--------------------------------------------------------------------------------
 1 | import type { mastodon } from 'masto'
 2 | import { NOTIFICATION_FILTER_TYPES } from '~/constants'
 3 | 
 4 | /**
 5 |  * Typeguard to check if an object is a valid notification filter
 6 |  * @param obj the object to be checked
 7 |  * @returns boolean and assigns type to object if true
 8 |  */
 9 | export function isNotificationFilter(obj: unknown): obj is mastodon.v1.NotificationType {
10 |   return !!obj && NOTIFICATION_FILTER_TYPES.includes(obj as unknown as mastodon.v1.NotificationType)
11 | }
12 | 
13 | /**
14 |  * Typeguard to check if an object is a valid notification
15 |  * @param obj the object to be checked
16 |  * @returns boolean and assigns type to object if true
17 |  */
18 | export function isNotification(obj: unknown): obj is mastodon.v1.NotificationType {
19 |   return !!obj && ['mention', ...NOTIFICATION_FILTER_TYPES].includes(obj as unknown as mastodon.v1.NotificationType)
20 | }
21 | 


--------------------------------------------------------------------------------
/app/composables/push-notifications/types.ts:
--------------------------------------------------------------------------------
 1 | import type { UserLogin } from '#shared/types'
 2 | 
 3 | import type { mastodon } from 'masto'
 4 | 
 5 | export type SubscriptionResult = 'subscribed' | 'notification-denied' | 'not-supported' | 'invalid-vapid-key' | 'no-user'
 6 | export interface PushManagerSubscriptionInfo {
 7 |   registration: ServiceWorkerRegistration
 8 |   subscription: PushSubscription | null
 9 | }
10 | 
11 | export interface RequiredUserLogin extends Required<Omit<UserLogin, 'account' | 'pushSubscription'>> {
12 |   pushSubscription?: mastodon.v1.WebPushSubscription
13 | }
14 | 
15 | export interface CreatePushNotification {
16 |   alerts?: Partial<mastodon.v1.WebPushSubscriptionAlerts> | null
17 |   policy?: mastodon.v1.WebPushSubscriptionPolicy
18 | }
19 | 
20 | export type PushNotificationRequest = Record<string, boolean>
21 | export type PushNotificationPolicy = Record<string, mastodon.v1.WebPushSubscriptionPolicy>
22 | 
23 | export interface CustomEmojisInfo {
24 |   lastUpdate: number
25 |   emojis: mastodon.v1.CustomEmoji[]
26 | }
27 | 
28 | export type PushSubscriptionErrorCode = 'too_many_registrations' | 'vapid_not_supported' | 'invalid_vapid_key'
29 | 
30 | export class PushSubscriptionError extends Error {
31 |   code: PushSubscriptionErrorCode
32 |   constructor(code: PushSubscriptionErrorCode, message?: string) {
33 |     super(message)
34 |     this.code = code
35 |   }
36 | }
37 | 


--------------------------------------------------------------------------------
/app/composables/screen.ts:
--------------------------------------------------------------------------------
1 | import { breakpointsTailwind } from '@vueuse/core'
2 | 
3 | export const breakpoints = useBreakpoints(breakpointsTailwind)
4 | 
5 | export const isSmallScreen = breakpoints.smallerOrEqual('sm')
6 | export const isMediumOrLargeScreen = breakpoints.between('sm', 'xl')
7 | export const isExtraLargeScreen = breakpoints.smallerOrEqual('xl')
8 | 


--------------------------------------------------------------------------------
/app/composables/settings/index.ts:
--------------------------------------------------------------------------------
1 | export * from './definition'
2 | export * from './storage'
3 | 


--------------------------------------------------------------------------------
/app/composables/settings/metadata.ts:
--------------------------------------------------------------------------------
 1 | import type { Node } from 'ultrahtml'
 2 | import { decode } from 'tiny-decode'
 3 | import { parse, TEXT_NODE } from 'ultrahtml'
 4 | 
 5 | export const maxAccountFieldCount = computed(() => isGlitchEdition.value ? 16 : 4)
 6 | 
 7 | export function convertMetadata(metadata: string) {
 8 |   try {
 9 |     const tree = parse(metadata)
10 |     return (tree.children as Node[]).map(n => convertToText(n)).join('').trim()
11 |   }
12 |   catch (err) {
13 |     console.error(err)
14 |     return ''
15 |   }
16 | }
17 | 
18 | function convertToText(input: Node): string {
19 |   let text = ''
20 | 
21 |   if (input.type === TEXT_NODE)
22 |     return decode(input.value)
23 | 
24 |   if ('children' in input)
25 |     text = (input.children as Node[]).map(n => convertToText(n)).join('')
26 | 
27 |   return text
28 | }
29 | 


--------------------------------------------------------------------------------
/app/composables/tiptap/shiki-parser.ts:
--------------------------------------------------------------------------------
 1 | import type { Parser } from 'prosemirror-highlight/shiki'
 2 | import type { BuiltinLanguage } from 'shiki'
 3 | import { createParser } from 'prosemirror-highlight/shiki'
 4 | 
 5 | let parser: Parser | undefined
 6 | 
 7 | export const shikiParser: Parser = (options) => {
 8 |   const lang = options.language ?? 'text'
 9 | 
10 |   // Register the language if it's not yet registered
11 |   const { highlighter, promise } = useHighlighter(lang as BuiltinLanguage)
12 | 
13 |   // If the highlighter or the language is not available, return a promise that
14 |   // will resolve when it's ready. When the promise resolves, the editor will
15 |   // re-parse the code block.
16 |   if (!highlighter)
17 |     return promise ?? []
18 | 
19 |   if (!parser)
20 |     parser = createParser(highlighter)
21 | 
22 |   return parser(options)
23 | }
24 | 


--------------------------------------------------------------------------------
/app/composables/tiptap/shiki.ts:
--------------------------------------------------------------------------------
 1 | import CodeBlock from '@tiptap/extension-code-block'
 2 | import { VueNodeViewRenderer } from '@tiptap/vue-3'
 3 | 
 4 | import { createHighlightPlugin } from 'prosemirror-highlight'
 5 | import TiptapCodeBlock from '~/components/tiptap/TiptapCodeBlock.vue'
 6 | import { shikiParser } from './shiki-parser'
 7 | 
 8 | export const TiptapPluginCodeBlockShiki = CodeBlock.extend({
 9 |   addOptions() {
10 |     return {
11 |       ...this.parent?.(),
12 |       defaultLanguage: null,
13 |     }
14 |   },
15 | 
16 |   addProseMirrorPlugins() {
17 |     return [
18 |       createHighlightPlugin({ parser: shikiParser, nodeTypes: ['codeBlock'] }),
19 |     ]
20 |   },
21 | 
22 |   addNodeView() {
23 |     return VueNodeViewRenderer(TiptapCodeBlock)
24 |   },
25 | })
26 | 


--------------------------------------------------------------------------------
/app/composables/web-share-target.ts:
--------------------------------------------------------------------------------
 1 | export function useWebShareTarget(listener?: (message: MessageEvent) => void) {
 2 |   if (import.meta.server)
 3 |     return
 4 | 
 5 |   onBeforeMount(() => {
 6 |     // PWA must be installed to use share target
 7 |     if (useNuxtApp().$pwa?.isInstalled && 'serviceWorker' in navigator) {
 8 |       if (listener)
 9 |         navigator.serviceWorker.addEventListener('message', listener)
10 | 
11 |       navigator.serviceWorker.getRegistration()
12 |         .then((registration) => {
13 |           if (registration && registration.active) {
14 |             // we need to signal the service worker that we are ready to receive data
15 |             registration.active.postMessage({ action: 'ready-to-receive' })
16 |           }
17 |         })
18 |         .catch(err => console.error('Could not get registration', err))
19 | 
20 |       if (listener)
21 |         onBeforeUnmount(() => navigator.serviceWorker.removeEventListener('message', listener))
22 |     }
23 |   })
24 | }
25 | 


--------------------------------------------------------------------------------
/app/constants/options.ts:
--------------------------------------------------------------------------------
1 | export const oldFontSizeMap = {
2 |   xs: '13px',
3 |   sm: '14px',
4 |   md: '15px',
5 |   lg: '16px',
6 |   xl: '17px',
7 | }
8 | 


--------------------------------------------------------------------------------
/app/constants/symbols.ts:
--------------------------------------------------------------------------------
1 | import type { InjectionKey } from 'vue'
2 | 
3 | export const InjectionKeyDropdownContext: InjectionKey<{
4 |   hide: () => void
5 | }> = Symbol('dropdown-context')
6 | 


--------------------------------------------------------------------------------
/app/layouts/none.vue:
--------------------------------------------------------------------------------
1 | <template>
2 |   <slot />
3 | </template>
4 | 


--------------------------------------------------------------------------------
/app/middleware/2.single-instance.global.ts:
--------------------------------------------------------------------------------
 1 | export default defineNuxtRouteMiddleware(async (to) => {
 2 |   if (import.meta.server || !useRuntimeConfig().public.singleInstance)
 3 |     return
 4 | 
 5 |   if (to.params.server) {
 6 |     const newTo = { ...to }
 7 |     delete newTo.params.server
 8 |     return newTo
 9 |   }
10 | })
11 | 


--------------------------------------------------------------------------------
/app/middleware/auth.ts:
--------------------------------------------------------------------------------
 1 | import type { RouteLocationNormalized } from 'vue-router'
 2 | 
 3 | export default defineNuxtRouteMiddleware((to) => {
 4 |   if (import.meta.server)
 5 |     return
 6 | 
 7 |   if (to.path === '/signin/callback')
 8 |     return
 9 | 
10 |   if (isHydrated.value)
11 |     return handleAuth(to)
12 | 
13 |   onHydrated(() => handleAuth(to))
14 | })
15 | 
16 | function handleAuth(to: RouteLocationNormalized) {
17 |   if (to.path === '/') {
18 |     // Installed PWA shortcut to notifications
19 |     if (to.query['notifications-pwa-shortcut'] !== undefined) {
20 |       if (currentUser.value)
21 |         return navigateTo('/notifications')
22 |       else
23 |         return navigateTo(`/${currentServer.value}/public/local`)
24 |     }
25 | 
26 |     // Installed PWA shortcut to local
27 |     if (to.query['local-pwa-shortcut'] !== undefined)
28 |       return navigateTo(`/${currentServer.value}/public/local`)
29 |   }
30 | 
31 |   if (!currentUser.value) {
32 |     if (to.path === '/home' && to.query['share-target'] !== undefined)
33 |       return navigateTo('/share-target')
34 |     else
35 |       return navigateTo(`/${currentServer.value}/public/local`)
36 |   }
37 | 
38 |   if (to.path === '/')
39 |     return navigateTo('/home')
40 | }
41 | 


--------------------------------------------------------------------------------
/app/pages/[...permalink].vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | import { hasProtocol, parseURL } from 'ufo'
 3 | 
 4 | definePageMeta({
 5 |   middleware: async (to) => {
 6 |     const permalink = Array.isArray(to.params.permalink)
 7 |       ? to.params.permalink.join('/')
 8 |       : to.params.permalink
 9 | 
10 |     if (hasProtocol(permalink)) {
11 |       const { host, pathname } = parseURL(permalink)
12 | 
13 |       if (host)
14 |         return `/${host}${pathname}`
15 |     }
16 | 
17 |     // We've reached a page that doesn't exist
18 |     return false
19 |   },
20 | })
21 | </script>
22 | 
23 | <template>
24 |   <div />
25 | </template>
26 | 


--------------------------------------------------------------------------------
/app/pages/[[server]]/@[account]/index/followers.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | const { t } = useI18n()
 3 | const params = useRoute().params
 4 | const handle = computed(() => params.account as string)
 5 | 
 6 | definePageMeta({ name: 'account-followers' })
 7 | 
 8 | const account = await fetchAccountByHandle(handle.value)
 9 | const paginator = account ? useMastoClient().v1.accounts.$select(account.id).followers.list() : null
10 | 
11 | const isSelf = useSelfAccount(account)
12 | 
13 | if (account) {
14 |   useHydratedHead({
15 |     title: () => `${t('account.followers')} | ${getDisplayName(account)} (@${account.acct})`,
16 |   })
17 | }
18 | </script>
19 | 
20 | <template>
21 |   <template v-if="paginator">
22 |     <AccountPaginator :paginator="paginator" :relationship-context="isSelf ? 'followedBy' : undefined" context="followers" :account="account" />
23 |   </template>
24 | </template>
25 | 


--------------------------------------------------------------------------------
/app/pages/[[server]]/@[account]/index/following.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | const { t } = useI18n()
 3 | const params = useRoute().params
 4 | const handle = computed(() => params.account as string)
 5 | 
 6 | definePageMeta({ name: 'account-following' })
 7 | 
 8 | const account = await fetchAccountByHandle(handle.value)
 9 | const paginator = account ? useMastoClient().v1.accounts.$select(account.id).following.list() : null
10 | 
11 | const isSelf = useSelfAccount(account)
12 | 
13 | if (account) {
14 |   useHydratedHead({
15 |     title: () => `${t('account.following')} | ${getDisplayName(account)} (@${account.acct})`,
16 |   })
17 | }
18 | </script>
19 | 
20 | <template>
21 |   <template v-if="paginator">
22 |     <AccountPaginator :paginator="paginator" :relationship-context="isSelf ? 'following' : undefined" context="following" :account="account" />
23 |   </template>
24 | </template>
25 | 


--------------------------------------------------------------------------------
/app/pages/[[server]]/@[account]/index/media.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | definePageMeta({ name: 'account-media' })
 3 | 
 4 | const { t } = useI18n()
 5 | const params = useRoute().params
 6 | const handle = computed(() => params.account as string)
 7 | 
 8 | const account = await fetchAccountByHandle(handle.value)
 9 | 
10 | const paginator = useMastoClient().v1.accounts.$select(account.id).statuses.list({ onlyMedia: true, excludeReplies: false })
11 | 
12 | if (account) {
13 |   useHydratedHead({
14 |     title: () => `${t('tab.media')} | ${getDisplayName(account)} (@${account.acct})`,
15 |   })
16 | }
17 | </script>
18 | 
19 | <template>
20 |   <div>
21 |     <AccountTabs />
22 |     <TimelinePaginator :paginator="paginator" :preprocess="reorderedTimeline" context="account" :account="account" />
23 |   </div>
24 | </template>
25 | 


--------------------------------------------------------------------------------
/app/pages/[[server]]/@[account]/index/with_replies.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | definePageMeta({ name: 'account-replies' })
 3 | 
 4 | const { t } = useI18n()
 5 | const params = useRoute().params
 6 | const handle = computed(() => params.account as string)
 7 | 
 8 | const account = await fetchAccountByHandle(handle.value)
 9 | 
10 | const paginator = useMastoClient().v1.accounts.$select(account.id).statuses.list({ excludeReplies: false })
11 | 
12 | if (account) {
13 |   useHydratedHead({
14 |     title: () => `${t('tab.posts_with_replies')} | ${getDisplayName(account)} (@${account.acct})`,
15 |   })
16 | }
17 | </script>
18 | 
19 | <template>
20 |   <div>
21 |     <AccountTabs />
22 |     <TimelinePaginator :paginator="paginator" :preprocess="reorderedTimeline" context="account" :account="account" />
23 |   </div>
24 | </template>
25 | 


--------------------------------------------------------------------------------
/app/pages/[[server]]/explore/index.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | import { STORAGE_KEY_HIDE_EXPLORE_POSTS_TIPS, STORAGE_KEY_LAST_ACCESSED_EXPLORE_ROUTE } from '~/constants'
 3 | 
 4 | const { t } = useI18n()
 5 | const route = useRoute()
 6 | 
 7 | const paginator = useMastoClient().v1.trends.statuses.list()
 8 | 
 9 | const hideNewsTips = useLocalStorage(STORAGE_KEY_HIDE_EXPLORE_POSTS_TIPS, false)
10 | 
11 | useHydratedHead({
12 |   title: () => `${t('tab.posts')} | ${t('nav.explore')}`,
13 | })
14 | 
15 | const lastAccessedExploreRoute = useLocalStorage(STORAGE_KEY_LAST_ACCESSED_EXPLORE_ROUTE, '')
16 | lastAccessedExploreRoute.value = route.path.replace(/(.*\/explore\/?)/, '')
17 | 
18 | onActivated(() => {
19 |   lastAccessedExploreRoute.value = route.path.replace(/(.*\/explore\/?)/, '')
20 | })
21 | </script>
22 | 
23 | <template>
24 |   <CommonAlert v-if="isHydrated && !hideNewsTips" @close="hideNewsTips = true">
25 |     <p>{{ $t('tooltip.explore_posts_intro') }}</p>
26 |   </CommonAlert>
27 |   <!-- TODO: Tabs for trending statuses, tags, and links -->
28 |   <TimelinePaginator v-if="isHydrated" :paginator="paginator" context="public" />
29 | </template>
30 | 


--------------------------------------------------------------------------------
/app/pages/[[server]]/explore/links.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | import { STORAGE_KEY_HIDE_EXPLORE_NEWS_TIPS, STORAGE_KEY_LAST_ACCESSED_EXPLORE_ROUTE } from '~/constants'
 3 | 
 4 | const { t } = useI18n()
 5 | const route = useRoute()
 6 | 
 7 | const paginator = useMastoClient().v1.trends.links.list()
 8 | 
 9 | const hideNewsTips = useLocalStorage(STORAGE_KEY_HIDE_EXPLORE_NEWS_TIPS, false)
10 | 
11 | useHydratedHead({
12 |   title: () => `${t('tab.news')} | ${t('nav.explore')}`,
13 | })
14 | 
15 | const lastAccessedExploreRoute = useLocalStorage(STORAGE_KEY_LAST_ACCESSED_EXPLORE_ROUTE, '')
16 | lastAccessedExploreRoute.value = route.path.replace(/(.*\/explore\/?)/, '')
17 | 
18 | onActivated(() => {
19 |   lastAccessedExploreRoute.value = route.path.replace(/(.*\/explore\/?)/, '')
20 | })
21 | </script>
22 | 
23 | <template>
24 |   <CommonAlert v-if="isHydrated && !hideNewsTips" @close="hideNewsTips = true">
25 |     <p>{{ $t('tooltip.explore_links_intro') }}</p>
26 |   </CommonAlert>
27 | 
28 |   <CommonPaginator v-bind="{ paginator }">
29 |     <template #default="{ item }">
30 |       <StatusPreviewCard :card="item" border="!b base" rounded="!none" p="!4" small-picture-only root />
31 |     </template>
32 |     <template #loading>
33 |       <StatusPreviewCardSkeleton square root border="b base" />
34 |       <StatusPreviewCardSkeleton square root border="b base" op50 />
35 |       <StatusPreviewCardSkeleton square root border="b base" op25 />
36 |     </template>
37 |   </CommonPaginator>
38 | </template>
39 | 


--------------------------------------------------------------------------------
/app/pages/[[server]]/explore/tags.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | import { STORAGE_KEY_HIDE_EXPLORE_TAGS_TIPS, STORAGE_KEY_LAST_ACCESSED_EXPLORE_ROUTE } from '~/constants'
 3 | 
 4 | const { t } = useI18n()
 5 | const route = useRoute()
 6 | const { client } = useMasto()
 7 | 
 8 | const paginator = client.value.v1.trends.tags.list({
 9 |   limit: 20,
10 | })
11 | 
12 | const hideTagsTips = useLocalStorage(STORAGE_KEY_HIDE_EXPLORE_TAGS_TIPS, false)
13 | 
14 | useHydratedHead({
15 |   title: () => `${t('tab.hashtags')} | ${t('nav.explore')}`,
16 | })
17 | 
18 | const lastAccessedExploreRoute = useLocalStorage(STORAGE_KEY_LAST_ACCESSED_EXPLORE_ROUTE, '')
19 | lastAccessedExploreRoute.value = route.path.replace(/(.*\/explore\/?)/, '')
20 | 
21 | onActivated(() => {
22 |   lastAccessedExploreRoute.value = route.path.replace(/(.*\/explore\/?)/, '')
23 | })
24 | </script>
25 | 
26 | <template>
27 |   <CommonAlert v-if="!hideTagsTips" @close="hideTagsTips = true">
28 |     <p>{{ $t('tooltip.explore_tags_intro') }}</p>
29 |   </CommonAlert>
30 | 
31 |   <TagCardPaginator v-bind="{ paginator }" />
32 | </template>
33 | 


--------------------------------------------------------------------------------
/app/pages/[[server]]/explore/users.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | import { STORAGE_KEY_LAST_ACCESSED_EXPLORE_ROUTE } from '~/constants'
 3 | 
 4 | const { t } = useI18n()
 5 | const route = useRoute()
 6 | 
 7 | // limit: 20 is the default configuration of the official client
 8 | const paginator = useMastoClient().v2.suggestions.list({ limit: 20 })
 9 | 
10 | useHydratedHead({
11 |   title: () => `${t('tab.for_you')} | ${t('nav.explore')}`,
12 | })
13 | 
14 | const lastAccessedExploreRoute = useLocalStorage(STORAGE_KEY_LAST_ACCESSED_EXPLORE_ROUTE, '')
15 | lastAccessedExploreRoute.value = route.path.replace(/(.*\/explore\/?)/, '')
16 | 
17 | onActivated(() => {
18 |   lastAccessedExploreRoute.value = route.path.replace(/(.*\/explore\/?)/, '')
19 | })
20 | </script>
21 | 
22 | <template>
23 |   <CommonPaginator :paginator="paginator" key-prop="account">
24 |     <template #default="{ item }">
25 |       <AccountBigCard
26 |         :account="item.account"
27 |         as="router-link"
28 |         :to="getAccountRoute(item.account)"
29 |         border="b base"
30 |       />
31 |     </template>
32 |     <template #loading>
33 |       <AccountBigCardSkeleton border="b base" />
34 |       <AccountBigCardSkeleton border="b base" op50 />
35 |       <AccountBigCardSkeleton border="b base" op25 />
36 |     </template>
37 |   </CommonPaginator>
38 | </template>
39 | 


--------------------------------------------------------------------------------
/app/pages/[[server]]/index.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | const instance = instanceStorage.value[currentServer.value]
 3 | try {
 4 |   clearError({ redirect: currentUser.value ? '/home' : `/${currentServer.value}/public/local` })
 5 | }
 6 | catch (err) {
 7 |   console.error(err)
 8 | }
 9 | </script>
10 | 
11 | <template>
12 |   <MainContent text-base grid gap-3 m3>
13 |     <img rounded-3 :src="instance.thumbnail.url">
14 |   </MainContent>
15 | </template>
16 | 


--------------------------------------------------------------------------------
/app/pages/[[server]]/list/[list]/index/index.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | definePageMeta({
 3 |   name: 'list',
 4 | })
 5 | 
 6 | const params = useRoute().params
 7 | const listId = computed(() => params.list as string)
 8 | 
 9 | const client = useMastoClient()
10 | 
11 | const paginator = client.v1.timelines.list.$select(listId.value).list()
12 | const stream = useStreaming(client => client.list.subscribe({ list: listId.value }))
13 | </script>
14 | 
15 | <template>
16 |   <TimelinePaginator v-bind="{ paginator, stream }" :preprocess="reorderedTimeline" context="home" />
17 | </template>
18 | 


--------------------------------------------------------------------------------
/app/pages/[[server]]/lists.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | const { t } = useI18n()
 3 | 
 4 | useHydratedHead({
 5 |   title: () => t('nav.lists'),
 6 | })
 7 | </script>
 8 | 
 9 | <template>
10 |   <MainContent>
11 |     <template #title>
12 |       <NuxtLink to="/lists" timeline-title-style flex items-center gap-2 @click="$scrollToTop">
13 |         <div i-ri:list-check />
14 |         <span text-lg font-bold>{{ t('nav.lists') }}</span>
15 |       </NuxtLink>
16 |     </template>
17 |     <NuxtPage v-if="isHydrated" />
18 |   </MainContent>
19 | </template>
20 | 


--------------------------------------------------------------------------------
/app/pages/[[server]]/public/index.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | const { t } = useI18n()
 3 | 
 4 | useHydratedHead({
 5 |   title: () => t('title.federated_timeline'),
 6 | })
 7 | </script>
 8 | 
 9 | <template>
10 |   <MainContent>
11 |     <template #title>
12 |       <NuxtLink to="/public" timeline-title-style flex items-center gap-2 @click="$scrollToTop">
13 |         <div i-ri:earth-line />
14 |         <span>{{ $t('title.federated_timeline') }}</span>
15 |       </NuxtLink>
16 |     </template>
17 | 
18 |     <TimelinePublic v-if="isHydrated" />
19 |   </MainContent>
20 | </template>
21 | 


--------------------------------------------------------------------------------
/app/pages/[[server]]/public/local.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | const { t } = useI18n()
 3 | 
 4 | useHydratedHead({
 5 |   title: () => t('title.local_timeline'),
 6 | })
 7 | </script>
 8 | 
 9 | <template>
10 |   <MainContent>
11 |     <template #title>
12 |       <NuxtLink to="/public/local" timeline-title-style flex items-center gap-2 @click="$scrollToTop">
13 |         <div i-ri:group-2-line />
14 |         <span>{{ t('title.local_timeline') }}</span>
15 |       </NuxtLink>
16 |     </template>
17 | 
18 |     <TimelinePublicLocal v-if="isHydrated" />
19 |   </MainContent>
20 | </template>
21 | 


--------------------------------------------------------------------------------
/app/pages/[[server]]/search.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | const keys = useMagicKeys()
 3 | const { t } = useI18n()
 4 | 
 5 | useHydratedHead({
 6 |   title: () => t('nav.search'),
 7 | })
 8 | 
 9 | const search = ref<{ input?: HTMLInputElement }>()
10 | 
11 | watchEffect(() => {
12 |   if (search.value?.input)
13 |     search.value?.input?.focus()
14 | })
15 | onActivated(() => search.value?.input?.focus())
16 | onDeactivated(() => search.value?.input?.blur())
17 | 
18 | watch(keys['/'], (v) => {
19 |   // focus on input when '/' is up to avoid '/' being typed
20 |   if (!v)
21 |     search.value?.input?.focus()
22 | })
23 | </script>
24 | 
25 | <template>
26 |   <MainContent>
27 |     <template #title>
28 |       <NuxtLink to="/search" timeline-title-style flex items-center gap-2 @click="$scrollToTop">
29 |         <div i-ri:search-line class="rtl-flip" />
30 |         <span>{{ $t('nav.search') }}</span>
31 |       </NuxtLink>
32 |     </template>
33 | 
34 |     <div px2 mt3>
35 |       <SearchWidget v-if="isHydrated" ref="search" m-1 />
36 |     </div>
37 |   </MainContent>
38 | </template>
39 | 


--------------------------------------------------------------------------------
/app/pages/[[server]]/status/[status].vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | definePageMeta({
 3 |   name: 'status-by-id',
 4 |   middleware: async (to) => {
 5 |     const params = to.params
 6 |     const id = params.status as string
 7 |     const status = await fetchStatus(id)
 8 |     return getStatusRoute(status)
 9 |   },
10 | })
11 | </script>
12 | 
13 | <template>
14 |   <div />
15 | </template>
16 | 


--------------------------------------------------------------------------------
/app/pages/blocks.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | definePageMeta({
 3 |   middleware: 'auth',
 4 | })
 5 | 
 6 | const { t } = useI18n()
 7 | 
 8 | useHydratedHead({
 9 |   title: () => t('nav.blocked_users'),
10 | })
11 | </script>
12 | 
13 | <template>
14 |   <MainContent back>
15 |     <template #title>
16 |       <span timeline-title-style>{{ $t('nav.blocked_users') }}</span>
17 |     </template>
18 | 
19 |     <TimelineBlocks v-if="isHydrated" />
20 |   </MainContent>
21 | </template>
22 | 


--------------------------------------------------------------------------------
/app/pages/bookmarks.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | definePageMeta({
 3 |   middleware: 'auth',
 4 | })
 5 | 
 6 | const { t } = useI18n()
 7 | 
 8 | useHydratedHead({
 9 |   title: () => t('nav.bookmarks'),
10 | })
11 | </script>
12 | 
13 | <template>
14 |   <MainContent>
15 |     <template #title>
16 |       <NuxtLink to="/bookmarks" timeline-title-style flex items-center gap-2 @click="$scrollToTop">
17 |         <div i-ri:bookmark-line />
18 |         <span>{{ t('nav.bookmarks') }}</span>
19 |       </NuxtLink>
20 |     </template>
21 | 
22 |     <TimelineBookmarks v-if="isHydrated" />
23 |   </MainContent>
24 | </template>
25 | 


--------------------------------------------------------------------------------
/app/pages/compose.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | definePageMeta({
 3 |   middleware: 'auth',
 4 | })
 5 | 
 6 | const { t } = useI18n()
 7 | 
 8 | useHydratedHead({
 9 |   title: () => t('nav.compose'),
10 | })
11 | </script>
12 | 
13 | <template>
14 |   <MainContent>
15 |     <template #title>
16 |       <NuxtLink to="/compose" timeline-title-style flex items-center gap-2 @click="$scrollToTop">
17 |         <div i-ri:quill-pen-line />
18 |         <span>{{ $t('nav.compose') }}</span>
19 |       </NuxtLink>
20 |     </template>
21 |     <PublishWidgetFull />
22 |   </MainContent>
23 | </template>
24 | 


--------------------------------------------------------------------------------
/app/pages/conversations.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | definePageMeta({
 3 |   middleware: 'auth',
 4 | })
 5 | 
 6 | const { t } = useI18n()
 7 | 
 8 | useHydratedHead({
 9 |   title: () => t('nav.conversations'),
10 | })
11 | </script>
12 | 
13 | <template>
14 |   <MainContent>
15 |     <template #title>
16 |       <NuxtLink to="/conversations" timeline-title-style flex items-center gap-2 @click="$scrollToTop">
17 |         <div i-ri:at-line />
18 |         <span>{{ t('nav.conversations') }}</span>
19 |       </NuxtLink>
20 |     </template>
21 | 
22 |     <TimelineConversations v-if="isHydrated" />
23 |   </MainContent>
24 | </template>
25 | 


--------------------------------------------------------------------------------
/app/pages/domain_blocks.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | definePageMeta({
 3 |   middleware: 'auth',
 4 | })
 5 | 
 6 | const { t } = useI18n()
 7 | 
 8 | useHydratedHead({
 9 |   title: () => t('nav.blocked_domains'),
10 | })
11 | </script>
12 | 
13 | <template>
14 |   <MainContent back>
15 |     <template #title>
16 |       <span timeline-title-style>{{ $t('nav.blocked_domains') }}</span>
17 |     </template>
18 | 
19 |     <TimelineDomainBlocks v-if="isHydrated" />
20 |   </MainContent>
21 | </template>
22 | 


--------------------------------------------------------------------------------
/app/pages/favourites.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | definePageMeta({
 3 |   middleware: 'auth',
 4 | })
 5 | 
 6 | const { t } = useI18n()
 7 | const useStarFavoriteIcon = usePreferences('useStarFavoriteIcon')
 8 | 
 9 | useHydratedHead({
10 |   title: () => t('nav.favourites'),
11 | })
12 | </script>
13 | 
14 | <template>
15 |   <MainContent>
16 |     <template #title>
17 |       <NuxtLink to="/favourites" timeline-title-style flex items-center gap-2 @click="$scrollToTop">
18 |         <div :class="useStarFavoriteIcon ? 'i-ri:star-line' : 'i-ri:heart-3-line'" />
19 |         <span>{{ t('nav.favourites') }}</span>
20 |       </NuxtLink>
21 |     </template>
22 | 
23 |     <TimelineFavourites v-if="isHydrated" />
24 |   </MainContent>
25 | </template>
26 | 


--------------------------------------------------------------------------------
/app/pages/hashtags.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | const { t } = useI18n()
 3 | 
 4 | useHydratedHead({
 5 |   title: () => t('nav.hashtags'),
 6 | })
 7 | </script>
 8 | 
 9 | <template>
10 |   <MainContent>
11 |     <template #title>
12 |       <NuxtLink to="/hashtags" timeline-title-style flex items-center gap-2 @click="$scrollToTop">
13 |         <div class="i-ri:hashtag" />
14 |         <span>{{ t('nav.hashtags') }}</span>
15 |       </NuxtLink>
16 |     </template>
17 | 
18 |     <NuxtPage v-if="isHydrated && currentUser" />
19 |   </MainContent>
20 | </template>
21 | 


--------------------------------------------------------------------------------
/app/pages/hashtags/index.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | definePageMeta({
 3 |   middleware: 'auth',
 4 | })
 5 | 
 6 | const { t } = useI18n()
 7 | 
 8 | const { client } = useMasto()
 9 | const paginator = client.value.v1.followedTags.list({
10 |   limit: 20,
11 | })
12 | 
13 | useHydratedHead({
14 |   title: () => t('nav.hashtags'),
15 | })
16 | </script>
17 | 
18 | <template>
19 |   <TagCardPaginator v-bind="{ paginator }" />
20 | </template>
21 | 


--------------------------------------------------------------------------------
/app/pages/home.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | definePageMeta({
 3 |   middleware: 'auth',
 4 |   alias: ['/signin/callback'],
 5 | })
 6 | 
 7 | const route = useRoute()
 8 | const router = useRouter()
 9 | if (import.meta.client && route.path === '/signin/callback')
10 |   router.push('/home')
11 | 
12 | const { t } = useI18n()
13 | useHydratedHead({
14 |   title: () => t('nav.home'),
15 | })
16 | </script>
17 | 
18 | <template>
19 |   <MainContent>
20 |     <template #title>
21 |       <NuxtLink to="/home" timeline-title-style flex items-center gap-2 @click="$scrollToTop">
22 |         <div i-ri:home-5-line />
23 |         <span>{{ $t('nav.home') }}</span>
24 |       </NuxtLink>
25 |     </template>
26 | 
27 |     <TimelineHome v-if="isHydrated" />
28 |   </MainContent>
29 | </template>
30 | 


--------------------------------------------------------------------------------
/app/pages/index.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | definePageMeta({
 3 |   middleware: 'auth',
 4 | })
 5 | </script>
 6 | 
 7 | <template>
 8 |   <div />
 9 | </template>
10 | 


--------------------------------------------------------------------------------
/app/pages/intent/post.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | import type { mastodon } from 'masto'
 3 | 
 4 | const router = useRouter()
 5 | const route = useRoute()
 6 | 
 7 | onMounted(async () => {
 8 |   // TODO: login check
 9 |   await openPublishDialog('intent', getDefaultDraftItem({
10 |     status: route.query.text as string,
11 |     sensitive: route.query.sensitive === 'true' || route.query.sensitive === null,
12 |     spoilerText: route.query.spoiler_text as string,
13 |     visibility: route.query.visibility as mastodon.v1.StatusVisibility,
14 |     language: route.query.language as string,
15 |   }), true)
16 |   // TODO: need a better idea 👀
17 |   await router.replace('/home')
18 | })
19 | </script>
20 | 
21 | <template>
22 |   <div>
23 |     <slot />
24 |   </div>
25 | </template>
26 | 


--------------------------------------------------------------------------------
/app/pages/mutes.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | definePageMeta({
 3 |   middleware: 'auth',
 4 | })
 5 | 
 6 | const { t } = useI18n()
 7 | 
 8 | useHydratedHead({
 9 |   title: () => t('nav.muted_users'),
10 | })
11 | </script>
12 | 
13 | <template>
14 |   <MainContent back>
15 |     <template #title>
16 |       <span timeline-title-style>{{ $t('nav.muted_users') }}</span>
17 |     </template>
18 | 
19 |     <TimelineMutes v-if="isHydrated" />
20 |   </MainContent>
21 | </template>
22 | 


--------------------------------------------------------------------------------
/app/pages/notifications/[filter].vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | import type { mastodon } from 'masto'
 3 | 
 4 | const route = useRoute()
 5 | const { t } = useI18n()
 6 | 
 7 | const filter = computed<mastodon.v1.NotificationType | undefined>(() => {
 8 |   if (!isHydrated.value)
 9 |     return undefined
10 | 
11 |   const rawFilter = route.params?.filter
12 |   const actualFilter = Array.isArray(rawFilter) ? rawFilter[0] : rawFilter
13 |   if (isNotification(actualFilter))
14 |     return actualFilter
15 | 
16 |   return undefined
17 | })
18 | 
19 | useHydratedHead({
20 |   title: () => `${t(`tab.notifications_${filter.value ?? 'all'}`)} | ${t('nav.notifications')}`,
21 | })
22 | </script>
23 | 
24 | <template>
25 |   <TimelineNotifications v-if="isHydrated" :filter="filter" />
26 | </template>
27 | 


--------------------------------------------------------------------------------
/app/pages/notifications/index.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | const { t } = useI18n()
 3 | 
 4 | useHydratedHead({
 5 |   title: () => `${t('tab.notifications_all')} | ${t('nav.notifications')}`,
 6 | })
 7 | </script>
 8 | 
 9 | <template>
10 |   <TimelineNotifications v-if="isHydrated" />
11 | </template>
12 | 


--------------------------------------------------------------------------------
/app/pages/pinned.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | definePageMeta({
 3 |   middleware: 'auth',
 4 | })
 5 | 
 6 | const { t } = useI18n()
 7 | 
 8 | useHydratedHead({
 9 |   title: () => t('account.pinned'),
10 | })
11 | </script>
12 | 
13 | <template>
14 |   <MainContent>
15 |     <template #title>
16 |       <NuxtLink to="/pinned" timeline-title-style flex items-center gap-2 @click="$scrollToTop">
17 |         <div i-ri:pushpin-line />
18 |         <span>{{ t('account.pinned') }}</span>
19 |       </NuxtLink>
20 |     </template>
21 | 
22 |     <TimelinePinned v-if="isHydrated && currentUser" />
23 |   </MainContent>
24 | </template>
25 | 


--------------------------------------------------------------------------------
/app/pages/settings/index.vue:
--------------------------------------------------------------------------------
1 | <template>
2 |   <div min-h-screen flex justify-center items-center>
3 |     <div text-center flex="~ col gap-2" items-center>
4 |       <div i-ri:settings-3-line text-5xl />
5 |       <span text-xl>{{ $t('settings.select_a_settings') }}</span>
6 |     </div>
7 |   </div>
8 | </template>
9 | 


--------------------------------------------------------------------------------
/app/pages/settings/interface/index.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | const { t } = useI18n()
 3 | 
 4 | useHydratedHead({
 5 |   title: () => `${t('settings.interface.label')} | ${t('nav.settings')}`,
 6 | })
 7 | </script>
 8 | 
 9 | <template>
10 |   <MainContent back-on-small-screen>
11 |     <template #title>
12 |       <div text-lg font-bold flex items-center gap-2 @click="$scrollToTop">
13 |         <span>{{ $t('settings.interface.label') }}</span>
14 |       </div>
15 |     </template>
16 |     <div px-6 pt-3 pb-6 flex="~ col gap6">
17 |       <SettingsFontSize />
18 |       <SettingsColorMode />
19 |       <SettingsThemeColors />
20 |       <SettingsBottomNav />
21 |     </div>
22 |   </MainContent>
23 | </template>
24 | 


--------------------------------------------------------------------------------
/app/pages/settings/notifications/index.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | definePageMeta({
 3 |   middleware: 'auth',
 4 | })
 5 | 
 6 | const { t } = useI18n()
 7 | const pwaEnabled = useAppConfig().pwaEnabled
 8 | 
 9 | useHydratedHead({
10 |   title: () => `${t('settings.notifications.label')} | ${t('nav.settings')}`,
11 | })
12 | </script>
13 | 
14 | <template>
15 |   <MainContent back-on-small-screen>
16 |     <template #title>
17 |       <div text-lg font-bold flex items-center gap-2 @click="$scrollToTop">
18 |         <span>{{ $t('settings.notifications.label') }}</span>
19 |       </div>
20 |     </template>
21 | 
22 |     <SettingsItem
23 |       command
24 |       :text="$t('settings.notifications.notifications.label')"
25 |       to="/settings/notifications/notifications"
26 |     />
27 |     <SettingsItem
28 |       command
29 |       :disabled="!pwaEnabled"
30 |       :text="$t('settings.notifications.push_notifications.label')"
31 |       :description="$t('settings.notifications.push_notifications.description')"
32 |       to="/settings/notifications/push-notifications"
33 |     />
34 |   </MainContent>
35 | </template>
36 | 


--------------------------------------------------------------------------------
/app/pages/settings/notifications/notifications.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | definePageMeta({
 3 |   middleware: 'auth',
 4 | })
 5 | 
 6 | const { t } = useI18n()
 7 | 
 8 | useHydratedHead({
 9 |   title: () => `${t('settings.notifications.notifications.label')} | ${t('settings.notifications.label')} | ${t('nav.settings')}`,
10 | })
11 | </script>
12 | 
13 | <template>
14 |   <MainContent back>
15 |     <template #title>
16 |       <div text-lg font-bold flex items-center gap-2 @click="$scrollToTop">
17 |         <div i-ri:test-tube-line />
18 |         <span>{{ $t('settings.notifications.notifications.label') }}</span>
19 |       </div>
20 |     </template>
21 |     <div text-center mt-10>
22 |       <h1 text-4xl>
23 |         <span sr-only>{{ $t('settings.notifications.under_construction') }}</span>
24 |         🚧
25 |       </h1>
26 |       <h3 text-xl>
27 |         {{ $t('settings.notifications.notifications.label') }}
28 |       </h3>
29 |     </div>
30 |   </MainContent>
31 | </template>
32 | 


--------------------------------------------------------------------------------
/app/pages/settings/notifications/push-notifications.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | definePageMeta({
 3 |   middleware: ['auth', () => {
 4 |     if (!useAppConfig().pwaEnabled)
 5 |       return navigateTo('/settings/notifications')
 6 |   }],
 7 | })
 8 | 
 9 | const { t } = useI18n()
10 | 
11 | useHydratedHead({
12 |   title: () => `${t('settings.notifications.push_notifications.label')} | ${t('settings.notifications.label')} | ${t('nav.settings')}`,
13 | })
14 | </script>
15 | 
16 | <template>
17 |   <MainContent back>
18 |     <template #title>
19 |       <div text-lg font-bold flex items-center gap-2 @click="$scrollToTop">
20 |         <span>{{ $t('settings.notifications.push_notifications.label') }}</span>
21 |       </div>
22 |     </template>
23 |     <NotificationPreferences show />
24 |   </MainContent>
25 | </template>
26 | 


--------------------------------------------------------------------------------
/app/pages/settings/profile/featured-tags.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | definePageMeta({
 3 |   middleware: 'auth',
 4 | })
 5 | 
 6 | const { t } = useI18n()
 7 | 
 8 | useHydratedHead({
 9 |   title: () => `${t('settings.profile.featured_tags.label')} | ${t('nav.settings')}`,
10 | })
11 | </script>
12 | 
13 | <template>
14 |   <MainContent back>
15 |     <template #title>
16 |       <div text-lg font-bold flex items-center gap-2 @click="$scrollToTop">
17 |         <div i-ri:test-tube-line />
18 |         <span>{{ $t('settings.profile.featured_tags.label') }}</span>
19 |       </div>
20 |     </template>
21 |     <div text-center mt-10>
22 |       <h1 text-4xl>
23 |         <span sr-only>{{ $t('settings.profile.featured_tags.under_construction') }}</span>
24 |         🚧
25 |       </h1>
26 |       <h3 text-xl>
27 |         {{ $t('settings.profile.featured_tags.label') }}
28 |       </h3>
29 |     </div>
30 |   </MainContent>
31 | </template>
32 | 


--------------------------------------------------------------------------------
/app/pages/settings/profile/index.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | definePageMeta({
 3 |   middleware: 'auth',
 4 | })
 5 | 
 6 | const { t } = useI18n()
 7 | 
 8 | useHydratedHead({
 9 |   title: () => `${t('settings.profile.label')} | ${t('nav.settings')}`,
10 | })
11 | </script>
12 | 
13 | <template>
14 |   <MainContent back-on-small-screen>
15 |     <template #title>
16 |       <div text-lg font-bold flex items-center gap-2 @click="$scrollToTop">
17 |         <span>{{ $t('settings.profile.label') }}</span>
18 |       </div>
19 |     </template>
20 | 
21 |     <SettingsItem
22 |       command large
23 |       icon="i-ri:user-settings-line"
24 |       :text="$t('settings.profile.appearance.label')"
25 |       :description="$t('settings.profile.appearance.description')"
26 |       to="/settings/profile/appearance"
27 |     />
28 |     <SettingsItem
29 |       command large
30 |       icon="i-ri:hashtag"
31 |       :text="$t('settings.profile.featured_tags.label')"
32 |       :description="$t('settings.profile.featured_tags.description')"
33 |       to="/settings/profile/featured-tags"
34 |     />
35 |     <SettingsItem
36 |       v-if="isHydrated && currentUser"
37 |       command large
38 |       icon="i-ri:settings-line"
39 |       :text="$t('settings.account_settings.label')"
40 |       :description="$t('settings.account_settings.description')"
41 |       :to="`https://${currentUser!.server}/auth/edit`"
42 |       external target="_blank"
43 |     />
44 |   </MainContent>
45 | </template>
46 | 


--------------------------------------------------------------------------------
/app/pages/share-target.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | definePageMeta({
 3 |   middleware: () => {
 4 |     if (!useAppConfig().pwaEnabled)
 5 |       return navigateTo('/')
 6 |   },
 7 | })
 8 | 
 9 | useWebShareTarget()
10 | 
11 | const pwaIsInstalled = import.meta.client && !!useNuxtApp().$pwa?.isInstalled
12 | </script>
13 | 
14 | <template>
15 |   <MainContent>
16 |     <template #title>
17 |       <NuxtLink to="/share-target" flex items-center gap-2>
18 |         <div i-ri:share-line />
19 |         <span>{{ $t('share_target.title') }}</span>
20 |       </NuxtLink>
21 |     </template>
22 |     <slot>
23 |       <div flex="~ col" px5 py2 gap-y-4>
24 |         <div
25 |           v-if="!pwaIsInstalled || !currentUser"
26 |           role="alert"
27 |           gap-1
28 |           p-2
29 |           text-red-600 dark:text-red-400
30 |           border="~ base rounded red-600 dark:red-400"
31 |         >
32 |           {{ $t('share_target.hint') }}
33 |         </div>
34 |         <div>{{ $t('share_target.description') }}</div>
35 |       </div>
36 |     </slot>
37 |   </MainContent>
38 | </template>
39 | 


--------------------------------------------------------------------------------
/app/plugins/1.scroll-to-top.ts:
--------------------------------------------------------------------------------
 1 | export default defineNuxtPlugin(() => {
 2 |   return {
 3 |     provide: {
 4 |       scrollToTop: () => {
 5 |         window.scrollTo({ top: 0, left: 0, behavior: 'smooth' })
 6 |       },
 7 |     },
 8 |   }
 9 | })
10 | 


--------------------------------------------------------------------------------
/app/plugins/color-mode.ts:
--------------------------------------------------------------------------------
 1 | import { THEME_COLORS } from '~/constants'
 2 | 
 3 | export default defineNuxtPlugin(() => {
 4 |   const colorMode = useColorMode()
 5 |   useHead({
 6 |     meta: [{
 7 |       id: 'theme-color',
 8 |       name: 'theme-color',
 9 |       content: () => colorMode.value === 'dark' ? THEME_COLORS.themeDark : THEME_COLORS.themeLight,
10 |     }],
11 |   })
12 | })
13 | 


--------------------------------------------------------------------------------
/app/plugins/floating-vue.ts:
--------------------------------------------------------------------------------
1 | import { defineNuxtPlugin } from '#imports'
2 | import FloatingVue from 'floating-vue'
3 | 
4 | export default defineNuxtPlugin((nuxtApp) => {
5 |   nuxtApp.vueApp.use(FloatingVue)
6 | })
7 | 


--------------------------------------------------------------------------------
/app/plugins/hydration.client.ts:
--------------------------------------------------------------------------------
1 | export default defineNuxtPlugin((nuxtApp) => {
2 |   nuxtApp.hooks.hookOnce('app:suspense:resolve', () => {
3 |     isHydrated.value = true
4 |   })
5 | })
6 | 


--------------------------------------------------------------------------------
/app/plugins/page-lifecycle.client.ts:
--------------------------------------------------------------------------------
 1 | import lifecycle from 'page-lifecycle/dist/lifecycle.mjs'
 2 | import { ELK_PAGE_LIFECYCLE_FROZEN } from '~/constants'
 3 | import { closeDatabases } from '~/utils/elk-idb'
 4 | 
 5 | export default defineNuxtPlugin(() => {
 6 |   const state = ref(lifecycle.state)
 7 |   const frozenListeners: (() => void)[] = []
 8 |   const frozenState = useLocalStorage(ELK_PAGE_LIFECYCLE_FROZEN, false)
 9 | 
10 |   lifecycle.addEventListener('statechange', (evt) => {
11 |     if (evt.newState === 'hidden' && evt.oldState === 'frozen') {
12 |       frozenState.value = false
13 |       nextTick().then(() => window.location.reload())
14 |       return
15 |     }
16 | 
17 |     if (evt.newState === 'frozen') {
18 |       frozenState.value = true
19 |       frozenListeners.forEach(listener => listener())
20 |     }
21 |     else {
22 |       state.value = evt.newState
23 |     }
24 |   })
25 | 
26 |   const addFrozenListener = (listener: () => void) => {
27 |     frozenListeners.push(listener)
28 |   }
29 | 
30 |   addFrozenListener(() => {
31 |     if (useAppConfig().pwaEnabled && navigator.serviceWorker.controller)
32 |       navigator.serviceWorker.controller.postMessage(ELK_PAGE_LIFECYCLE_FROZEN)
33 | 
34 |     closeDatabases()
35 |   })
36 | 
37 |   return {
38 |     provide: {
39 |       pageLifecycle: reactive({
40 |         state,
41 |         addFrozenListener,
42 |       }),
43 |     },
44 |   }
45 | })
46 | 


--------------------------------------------------------------------------------
/app/plugins/path.ts:
--------------------------------------------------------------------------------
1 | export default defineNuxtPlugin({
2 |   order: -40,
3 |   setup: (nuxtApp) => {
4 |     delete nuxtApp.payload.path
5 |   },
6 | })
7 | 


--------------------------------------------------------------------------------
/app/plugins/setup-global-effects.client.ts:
--------------------------------------------------------------------------------
 1 | import type { OldFontSize } from '~/composables/settings'
 2 | import { DEFAULT_FONT_SIZE } from '~/constants'
 3 | import { oldFontSizeMap } from '~/constants/options'
 4 | 
 5 | export default defineNuxtPlugin(() => {
 6 |   const userSettings = useUserSettings()
 7 |   const html = document.documentElement
 8 |   watchEffect(() => {
 9 |     const { fontSize } = userSettings.value
10 |     html.style.setProperty('--font-size', fontSize ? (oldFontSizeMap[fontSize as OldFontSize] ?? fontSize) : DEFAULT_FONT_SIZE)
11 |   })
12 |   watchEffect(() => {
13 |     html.classList.toggle('zen', getPreferences(userSettings.value, 'zenMode'))
14 |   })
15 |   watchEffect(() => {
16 |     Object.entries(userSettings.value.themeColors || {}).forEach(([k, v]) => html.style.setProperty(k, v))
17 |   })
18 | })
19 | 


--------------------------------------------------------------------------------
/app/plugins/setup-head-script.server.ts:
--------------------------------------------------------------------------------
 1 | import { STORAGE_KEY_CURRENT_USER_HANDLE, STORAGE_KEY_SETTINGS } from '~/constants'
 2 | import { oldFontSizeMap } from '~/constants/options'
 3 | 
 4 | /**
 5 |  * Injecting scripts before renders
 6 |  */
 7 | export default defineNuxtPlugin(() => {
 8 |   useHead({
 9 |     script: [
10 |       {
11 |         innerHTML: `
12 | ;(function() {
13 |   const handle = localStorage.getItem('${STORAGE_KEY_CURRENT_USER_HANDLE}') || '[anonymous]'
14 |   const allSettings = JSON.parse(localStorage.getItem('${STORAGE_KEY_SETTINGS}') || '{}')
15 |   const settings = allSettings[handle]
16 |   if (!settings) { return }
17 | 
18 |   const html = document.documentElement
19 |   ${import.meta.dev ? 'console.log({ settings })' : ''}
20 | 
21 |   if (settings.fontSize) {
22 |     const oldFontSizeMap = ${JSON.stringify(oldFontSizeMap)}
23 |     html.style.setProperty('--font-size', oldFontSizeMap[settings.fontSize] || settings.fontSize)
24 |   }
25 |   if (settings.language) {
26 |     html.setAttribute('lang', settings.language)
27 |   }
28 |   if (settings.preferences.zenMode) {
29 |     html.classList.add('zen')
30 |   }
31 |   if (settings.themeColors) {
32 |     Object.entries(settings.themeColors).map(i => html.style.setProperty(i[0], i[1]))
33 |   }
34 | })()`.trim().replace(/\s*\n\s*/g, ';'),
35 |       },
36 |     ],
37 |   })
38 | })
39 | 


--------------------------------------------------------------------------------
/app/plugins/setup-i18n.ts:
--------------------------------------------------------------------------------
 1 | import type { Locale } from '#i18n'
 2 | 
 3 | export default defineNuxtPlugin(async (nuxt) => {
 4 |   const t = nuxt.vueApp.config.globalProperties.$t
 5 |   const d = nuxt.vueApp.config.globalProperties.$d
 6 |   const n = nuxt.vueApp.config.globalProperties.$n
 7 | 
 8 |   nuxt.vueApp.config.globalProperties.$t = wrapI18n(t)
 9 |   nuxt.vueApp.config.globalProperties.$d = wrapI18n(d)
10 |   nuxt.vueApp.config.globalProperties.$n = wrapI18n(n)
11 | 
12 |   if (import.meta.client) {
13 |     const i18n = useNuxtApp().$i18n
14 |     const { setLocale, locales } = i18n
15 |     const userSettings = useUserSettings()
16 |     const lang = computed(() => userSettings.value.language as Locale)
17 | 
18 |     const supportLanguages = unref(locales).map(locale => locale.code)
19 |     if (!supportLanguages.includes(lang.value))
20 |       userSettings.value.language = getDefaultLanguage(supportLanguages)
21 | 
22 |     if (lang.value !== i18n.locale)
23 |       await setLocale(userSettings.value.language as Locale)
24 | 
25 |     watch([lang, isHydrated], () => {
26 |       if (isHydrated.value && lang.value !== i18n.locale)
27 |         setLocale(lang.value)
28 |     }, { immediate: true })
29 |   }
30 | })
31 | 


--------------------------------------------------------------------------------
/app/plugins/social.server.ts:
--------------------------------------------------------------------------------
 1 | import { sendRedirect } from 'h3'
 2 | 
 3 | const BOT_RE = /bot\b|index|spider|facebookexternalhit|crawl|wget|slurp|mediapartners-google|whatsapp/i
 4 | 
 5 | export default defineNuxtPlugin(async (nuxtApp) => {
 6 |   const route = useRoute()
 7 |   if (!('server' in route.params))
 8 |     return
 9 | 
10 |   const userAgent = useRequestHeaders()['user-agent']
11 |   if (!userAgent)
12 |     return
13 | 
14 |   const isOpenGraphCrawler = BOT_RE.test(userAgent)
15 |   if (isOpenGraphCrawler) {
16 |     // Redirect bots to the original instance to respect their social sharing settings
17 |     await sendRedirect(nuxtApp.ssrContext!.event, `https:/${route.path}`, 301)
18 |   }
19 | })
20 | 


--------------------------------------------------------------------------------
/app/styles/default-theme.css:
--------------------------------------------------------------------------------
 1 | :root {
 2 |   --theme-color-name: #cc7d24;
 3 |   --c-primary: rgb(var(--rgb-primary));
 4 |   --c-primary-active: #b16605;
 5 |   --c-primary-light: #cc7d2480;
 6 |   --c-primary-fade: #c7781f1a;
 7 |   --rgb-primary: 204, 125, 36;
 8 |   --c-dark-primary: rgb(var(--rgb-dark-primary));
 9 |   --c-dark-primary-active: #b66b0d;
10 |   --c-dark-primary-light: #d1822980;
11 |   --c-dark-primary-fade: #cc7d241a;
12 |   --rgb-dark-primary: 204, 125, 36;
13 | }
14 | 


--------------------------------------------------------------------------------
/app/styles/dropdown.css:
--------------------------------------------------------------------------------
 1 | .v-popper--theme-dropdown .v-popper__inner {
 2 |   --at-apply: bg-base text-base rounded border border-base shadow;
 3 |   box-shadow: 0 6px 30px #0000001a;
 4 | }
 5 | .v-popper--theme-dropdown .v-popper__arrow-inner {
 6 |   visibility: visible;
 7 |   --at-apply: border-$c-bg-base;
 8 | }
 9 | .v-popper--theme-dropdown .v-popper__arrow-outer {
10 |   --at-apply: border-base;
11 | }
12 | .v-popper--theme-tooltip .v-popper__inner {
13 |   --at-apply: bg-base text-base rounded border border-base shadow-sm;
14 |   padding: 7px 12px 6px;
15 | }
16 | .v-popper--theme-tooltip .v-popper__arrow-inner {
17 |   visibility: visible;
18 |   --at-apply: border-bg-base;
19 | }
20 | .v-popper--theme-tooltip .v-popper__arrow-outer {
21 |   --at-apply: border-base;
22 | }
23 | 


--------------------------------------------------------------------------------
/app/styles/scrollbars.css:
--------------------------------------------------------------------------------
 1 | * {
 2 |   scrollbar-color: #8885 var(--c-border);
 3 | }
 4 | 
 5 | ::-webkit-scrollbar {
 6 |   width: 10px;
 7 | }
 8 | 
 9 | ::-webkit-scrollbar:horizontal {
10 |   height: 10px;
11 | }
12 | 
13 | ::-webkit-scrollbar-track {
14 |   background: var(--c-border);
15 |   border-radius: 1px;
16 | }
17 | 
18 | ::-webkit-scrollbar-thumb {
19 |   background: #8885;
20 |   border-radius: 1px;
21 | }
22 | 
23 | ::-webkit-scrollbar-thumb:hover {
24 |   background: #8886;
25 | }


--------------------------------------------------------------------------------
/app/styles/tiptap.css:
--------------------------------------------------------------------------------
 1 | .ProseMirror {
 2 |   p.is-editor-empty:first-child::before {
 3 |     content: attr(data-placeholder);
 4 |     float: left;
 5 |     pointer-events: none;
 6 |     height: 0;
 7 |     opacity: 0.4;
 8 |   }
 9 |   span[data-type='mention'],
10 |   span[data-type='hashtag'] {
11 |     --at-apply: text-primary;
12 |   }
13 | }
14 | 


--------------------------------------------------------------------------------
/app/styles/vars.css:
--------------------------------------------------------------------------------
 1 | :root, :root::selection {
 2 |   --c-border: #eee;
 3 |   --c-border-dark: #dccfcf;
 4 |   --c-border-code: #ddd;
 5 |   --c-danger: #FF3C1B;
 6 |   --c-danger-active: #B50900;
 7 | 
 8 |   --rgb-bg-base: 250, 250, 250;
 9 | 
10 |   --c-bg-base: rgb(var(--rgb-bg-base));
11 | 
12 |   --c-bg-active: #f2f2f2;
13 |   --c-bg-card: #00000006;
14 |   --c-bg-code: #00000006;
15 |   --c-bg-selection: #8885;
16 |   --c-bg-dm: #f1e8e6;
17 | 
18 |   --c-text-base: #232323;
19 |   --c-text-code: #63470c;
20 |   --c-text-secondary: #686868;
21 |   --c-text-secondary-light: #919191;
22 | 
23 |   --c-bg-btn-disabled: #a1a1a1;
24 |   --c-text-btn-disabled: #fff;
25 |   --c-text-btn-disabled-deeper: #a1a1a1;
26 | 
27 |   --c-success: #67C23A;
28 |   --c-warning: #E6A23C;
29 |   --c-error: #F56C6C;
30 | }
31 | 
32 | .dark {
33 |   --c-primary: var(--c-dark-primary);
34 |   --c-primary-active: var(--c-dark-primary-active);
35 |   --c-primary-light: var(--c-dark-primary-light);
36 |   --c-primary-fade: var(--c-dark-primary-fade);
37 |   --c-danger: #FF2810;
38 |   --c-danger-active: #E02F00;
39 | 
40 |   --c-border: #222;
41 |   --c-border-code: #333;
42 |   --c-border-dark: #545251;
43 | 
44 |   --rgb-bg-base: 17, 17, 17;
45 | 
46 |   --c-bg-active: #191919;
47 |   --c-bg-card: #ffffff06;
48 |   --c-bg-code: #ffffff06;
49 |   --c-bg-dm: #0a2f35;
50 | 
51 |   --c-text-base: #f3f3f3;
52 |   --c-text-code: #ecd88e;
53 |   --c-text-secondary: #888;
54 |   --c-text-secondary-light: #686868;
55 | 
56 |   --c-bg-btn-disabled: #2a2a2a;
57 |   --c-text-btn-disabled: #919191;
58 | }
59 | 


--------------------------------------------------------------------------------
/app/utils/elk-idb.ts:
--------------------------------------------------------------------------------
 1 | import type { UseStore } from 'idb-keyval'
 2 | import {
 3 |   del as delIdb,
 4 |   get as getIdb,
 5 |   promisifyRequest,
 6 |   set as setIdb,
 7 |   update as updateIdb,
 8 | 
 9 | } from 'idb-keyval'
10 | 
11 | const databases: IDBOpenDBRequest[] = []
12 | 
13 | function createStore(): UseStore {
14 |   const storeName = 'keyval'
15 |   const request = indexedDB.open('keyval-store')
16 |   databases.push(request)
17 |   request.onupgradeneeded = () => request.result.createObjectStore(storeName)
18 |   const dbp = promisifyRequest(request)
19 |   return (txMode, callback) => dbp.then(db => callback(db.transaction(storeName, txMode).objectStore(storeName)))
20 | }
21 | 
22 | let defaultGetStoreFunc: UseStore | undefined
23 | function defaultGetStore() {
24 |   if (!defaultGetStoreFunc)
25 |     defaultGetStoreFunc = createStore()
26 | 
27 |   return defaultGetStoreFunc
28 | }
29 | 
30 | export function get<T = any>(key: IDBValidKey) {
31 |   return getIdb<T>(key, defaultGetStore())
32 | }
33 | 
34 | export function set(key: IDBValidKey, value: any) {
35 |   return setIdb(key, value, defaultGetStore())
36 | }
37 | 
38 | export function update<T = any>(key: IDBValidKey, updater: (oldValue: T | undefined) => T) {
39 |   return updateIdb(key, updater, defaultGetStore())
40 | }
41 | 
42 | export function del(key: IDBValidKey) {
43 |   return delIdb(key, defaultGetStore())
44 | }
45 | 
46 | export function closeDatabases() {
47 |   databases.forEach((db) => {
48 |     if (db.result)
49 |       db.result.close()
50 |   })
51 |   defaultGetStoreFunc = undefined
52 | }
53 | 


--------------------------------------------------------------------------------
/app/utils/i18n.ts:
--------------------------------------------------------------------------------
 1 | import { useI18n as useOriginalI18n } from 'vue-i18n'
 2 | 
 3 | export function useI18n() {
 4 |   const {
 5 |     t,
 6 |     d,
 7 |     n,
 8 |     ...rest
 9 |   } = useOriginalI18n()
10 | 
11 |   return {
12 |     ...rest,
13 |     t: wrapI18n(t),
14 |     d: wrapI18n(d),
15 |     n: wrapI18n(n),
16 |   } satisfies ReturnType<typeof useOriginalI18n>
17 | }
18 | 
19 | export function wrapI18n<T extends (...args: any[]) => any>(t: T): T {
20 |   return <T>((...args: any[]) => {
21 |     return isHydrated.value ? t(...args) : ''
22 |   })
23 | }
24 | 


--------------------------------------------------------------------------------
/app/utils/language.ts:
--------------------------------------------------------------------------------
 1 | export function matchLanguages(languages: string[], acceptLanguages: readonly string[]): string | null {
 2 |   {
 3 |     // const lang = acceptLanguages.map(userLang => languages.find(lang => lang.startsWith(userLang))).filter(v => !!v)[0]
 4 |     // TODO: Support es-419, remove this code if we include spanish country variants
 5 |     const lang = acceptLanguages.map(userLang => languages.find((currentLang) => {
 6 |       if (currentLang === userLang)
 7 |         return currentLang
 8 | 
 9 |       // Edge browser: case for ca-valencia
10 |       if (currentLang === 'ca-valencia' && userLang === 'ca-Es-VALENCIA')
11 |         return currentLang
12 | 
13 |       if (userLang.startsWith('es-') && userLang !== 'es-ES' && currentLang === 'es-419')
14 |         return currentLang
15 | 
16 |       return currentLang.startsWith(userLang) ? currentLang : undefined
17 |     })).filter(v => !!v)?.[0]
18 |     if (lang)
19 |       return lang
20 |   }
21 | 
22 |   const lang = acceptLanguages.map((userLang) => {
23 |     userLang = userLang.split('-')[0]!
24 |     return languages.find(lang => lang.startsWith(userLang))
25 |   }).filter(v => !!v)[0]
26 |   if (lang)
27 |     return lang
28 | 
29 |   return null
30 | }
31 | 


--------------------------------------------------------------------------------
/config/emojis.ts:
--------------------------------------------------------------------------------
 1 | import type { EmojiRegexMatch } from '@iconify/utils/lib/emoji/replace/find'
 2 | // @unimport-disabled
 3 | import { emojiFilename, emojiPrefix, emojiRegEx } from '@iconify-emoji/twemoji'
 4 | import { getEmojiMatchesInText } from '@iconify/utils/lib/emoji/replace/find'
 5 | 
 6 | // Re-export everything from package
 7 | export * from '@iconify-emoji/twemoji'
 8 | 
 9 | // Package name
10 | export const iconifyEmojiPackage = '@iconify-emoji/twemoji'
11 | 
12 | export function getEmojiAttributes(input: EmojiRegexMatch | string) {
13 |   const match = typeof input === 'string'
14 |     ? getEmojiMatchesInText(emojiRegEx, input)?.[0]
15 |     : input
16 |   const file = emojiFilename(match)
17 |   const className = `iconify-emoji iconify-emoji--${emojiPrefix}${file.padding ? ' iconify-emoji-padded' : ''}`
18 |   return {
19 |     class: className,
20 |     src: `/emojis/${emojiPrefix}/${file.filename}`,
21 |     alt: match.match,
22 |   }
23 | }
24 | 


--------------------------------------------------------------------------------
/config/i18n.config.ts:
--------------------------------------------------------------------------------
 1 | import {
 2 |   currentLocales,
 3 |   datetimeFormats,
 4 |   numberFormats,
 5 |   pluralRules,
 6 | } from './i18n'
 7 | 
 8 | export default defineI18nConfig(() => {
 9 |   return {
10 |     legacy: false,
11 |     availableLocales: currentLocales.map(l => l.code),
12 |     fallbackLocale: 'en-US',
13 |     fallbackWarn: true,
14 |     missingWarn: true,
15 |     datetimeFormats,
16 |     numberFormats,
17 |     pluralRules,
18 |   }
19 | })
20 | 


--------------------------------------------------------------------------------
/config/pwa.ts:
--------------------------------------------------------------------------------
 1 | import type { VitePWANuxtOptions } from '../modules/pwa/types'
 2 | import { isCI, isDevelopment } from 'std-env'
 3 | 
 4 | export const pwa: VitePWANuxtOptions = {
 5 |   mode: isCI ? 'production' : 'development',
 6 |   // disable PWA only when in preview mode
 7 |   disable: /* temporarily test in CI isPreview || */ (isDevelopment && process.env.VITE_DEV_PWA !== 'true'),
 8 |   scope: '/',
 9 |   srcDir: '../service-worker',
10 |   filename: 'elk-sw.ts',
11 |   strategies: 'injectManifest',
12 |   injectRegister: false,
13 |   includeManifestIcons: false,
14 |   manifest: false,
15 |   injectManifest: {
16 |     globPatterns: ['**/*.{js,json,css,html,txt,svg,png,ico,webp,woff,woff2,ttf,eot,otf,wasm}'],
17 |     globIgnores: ['emojis/**', 'manifest**.webmanifest'],
18 |   },
19 |   devOptions: {
20 |     enabled: process.env.VITE_DEV_PWA === 'true',
21 |     type: 'module',
22 |   },
23 | }
24 | 


--------------------------------------------------------------------------------
/docker-compose.yaml:
--------------------------------------------------------------------------------
 1 | services:
 2 |   elk:
 3 |     build:
 4 |       context: .
 5 |       dockerfile: Dockerfile
 6 |     volumes:
 7 |       # make sure this directory has the same ownership as the elk user from the Dockerfile
 8 |       # otherwise Elk will not be able to store configs for accounts
 9 |       # e.q. mkdir ./elk-storage; sudo chown 911:911 ./elk-storage
10 |       - './elk-storage:/elk/data'
11 |     ports:
12 |       - 5314:5314
13 | 


--------------------------------------------------------------------------------
/docs/.env.example:
--------------------------------------------------------------------------------
1 | # Create one with no scope selected on https://github.com/settings/tokens/new
2 | # This token is used for fetching the repository releases.
3 | GITHUB_TOKEN=


--------------------------------------------------------------------------------
/docs/.gitignore:
--------------------------------------------------------------------------------
 1 | node_modules
 2 | *.iml
 3 | .idea
 4 | *.log*
 5 | .nuxt
 6 | .vscode
 7 | .DS_Store
 8 | coverage
 9 | dist
10 | sw.*
11 | .env
12 | .output
13 | translation-status.json
14 | 


--------------------------------------------------------------------------------
/docs/app.config.ts:
--------------------------------------------------------------------------------
 1 | export default defineAppConfig({
 2 |   docus: {
 3 |     title: 'Elk',
 4 |     description: 'A nimble Mastodon web client.',
 5 |     image: 'https://docs.elk.zone/elk-screenshot.png',
 6 |     socials: {
 7 |       // twitter: 'elk_zone',
 8 |       github: 'elk-zone/elk',
 9 |       mastodon: {
10 |         label: 'Mastodon',
11 |         icon: 'IconMastodon',
12 |         href: 'https://elk.zone/@elk@webtoo.ls',
13 |       },
14 |     },
15 |     aside: {
16 |       level: 0,
17 |       exclude: [],
18 |     },
19 |     header: {
20 |       logo: true,
21 |       showLinkIcon: true,
22 |       exclude: [],
23 |     },
24 |     footer: {
25 |       iconLinks: [
26 |         {
27 |           href: 'https://nuxt.com',
28 |           icon: 'IconNuxtLabs',
29 |         },
30 |         {
31 |           href: 'https://m.webtoo.ls/@elk',
32 |           icon: 'IconMastodon',
33 |         },
34 |       ],
35 |     },
36 |   },
37 | })
38 | 


--------------------------------------------------------------------------------
/docs/app.vue:
--------------------------------------------------------------------------------
1 | <template>
2 |   <AppLayout>
3 |     <NuxtPage />
4 |   </AppLayout>
5 | </template>
6 | 


--------------------------------------------------------------------------------
/docs/components/global/ClipboardIcon.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | defineProps<{
 3 |   copy?: boolean
 4 | }>()
 5 | </script>
 6 | 
 7 | <template>
 8 |   <svg v-if="copy" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
 9 |     <path fill="currentColor" d="M19 3h-4.18C14.4 1.84 13.3 1 12 1c-1.3 0-2.4.84-2.82 2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2m-7 0a1 1 0 0 1 1 1a1 1 0 0 1-1 1a1 1 0 0 1-1-1a1 1 0 0 1 1-1M7 7h10V5h2v14H5V5h2v2Z" />
10 |   </svg>
11 |   <svg v-else xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
12 |     <path fill="currentColor" d="M19 3h-4.18C14.4 1.84 13.3 1 12 1c-1.3 0-2.4.84-2.82 2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2m-7 0a1 1 0 0 1 1 1a1 1 0 0 1-1 1a1 1 0 0 1-1-1a1 1 0 0 1 1-1M7 7h10V5h2v14H5V5h2v2m.5 6.5L9 12l2 2l4.5-4.5L17 11l-6 6l-3.5-3.5Z" />
13 |   </svg>
14 | </template>
15 | 


--------------------------------------------------------------------------------
/docs/components/global/IconMastodon.vue:
--------------------------------------------------------------------------------
1 | <script setup lang="ts"></script>
2 | 
3 | <template>
4 |   <svg width="1em" height="1em" viewBox="0 0 24 24"><path fill="currentColor" d="M21.258 13.99c-.274 1.41-2.456 2.955-4.962 3.254c-1.306.156-2.593.3-3.965.236c-2.243-.103-4.014-.535-4.014-.535c0 .218.014.426.04.62c.292 2.215 2.196 2.347 4 2.41c1.82.062 3.44-.45 3.44-.45l.076 1.646s-1.274.684-3.542.81c-1.25.068-2.803-.032-4.612-.51c-3.923-1.039-4.598-5.22-4.701-9.464c-.031-1.26-.012-2.447-.012-3.44c0-4.34 2.843-5.611 2.843-5.611c1.433-.658 3.892-.935 6.45-.956h.062c2.557.02 5.018.298 6.451.956c0 0 2.843 1.272 2.843 5.61c0 0 .036 3.201-.397 5.424zm-2.956-5.087c0-1.074-.273-1.927-.822-2.558c-.567-.631-1.308-.955-2.229-.955c-1.065 0-1.871.41-2.405 1.228l-.518.87l-.519-.87C11.276 5.8 10.47 5.39 9.405 5.39c-.921 0-1.663.324-2.229.955c-.549.631-.822 1.484-.822 2.558v5.253h2.081V9.057c0-1.075.452-1.62 1.357-1.62c1 0 1.501.647 1.501 1.927v2.79h2.07v-2.79c0-1.28.5-1.927 1.5-1.927c.905 0 1.358.545 1.358 1.62v5.1h2.08V8.902z" /></svg>
5 | </template>
6 | 


--------------------------------------------------------------------------------
/docs/components/global/Logo.vue:
--------------------------------------------------------------------------------
 1 | <template>
 2 |   <div class="logo">
 3 |     <img alt="Elk" src="/logo.svg">
 4 |     Elk
 5 |   </div>
 6 | </template>
 7 | 
 8 | <style lang="ts" scoped>
 9 | css({
10 |   '.logo': {
11 |     display: 'flex',
12 |     flexDirection: 'row',
13 |     alignItems: 'center',
14 |     gap: '0.5rem',
15 |     fontSize: '1.5rem',
16 |   },
17 |   'img': {
18 |     flexShrink: 0,
19 |     aspectRatio: '1/1',
20 |     height: '2.5rem',
21 |   },
22 | })
23 | </style>
24 | 


--------------------------------------------------------------------------------
/docs/components/global/ToggleIcon.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 | defineProps<{
 3 |   up?: boolean
 4 | }>()
 5 | </script>
 6 | 
 7 | <template>
 8 |   <svg v-if="up" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24">
 9 |     <path fill="currentColor" d="m12 10.828l-4.95 4.95l-1.414-1.414L12 8l6.364 6.364l-1.414 1.414z" />
10 |   </svg>
11 |   <svg v-else xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24">
12 |     <path fill="currentColor" d="m12 13.172l4.95-4.95l1.414 1.414L12 16L5.636 9.636L7.05 8.222z" />
13 |   </svg>
14 | </template>
15 | 


--------------------------------------------------------------------------------
/docs/content/0.index.md:
--------------------------------------------------------------------------------
 1 | ---
 2 | title: Elk
 3 | navigation: false
 4 | layout: page
 5 | ---
 6 | 
 7 | ::block-hero
 8 | ---
 9 | cta:
10 |   - Read more
11 |   - /guide
12 | secondary:
13 |   - Try it out →
14 |   - https://elk.zone
15 | ---
16 | 
17 | #title
18 | Elk
19 | 
20 | #description
21 | An in-progress, nimble Mastodon web client
22 | 
23 | #support
24 | ![Screenshot of Elk](/screenshot.png)
25 | 
26 | #extra
27 | ::list
28 | - markdown support
29 | - code blocks
30 | - reordering and connecting posts in timelines
31 | - multi account
32 | - GitHub HTML cards
33 | - and more...
34 | ::
35 | 
36 | ::
37 | 


--------------------------------------------------------------------------------
/docs/content/1.guide/4.sponsoring.md:
--------------------------------------------------------------------------------
 1 | # Sponsoring
 2 | 
 3 | If you're enjoying the app, consider sponsoring our team:
 4 | 
 5 | - [Anthony Fu](https://github.com/sponsors/antfu)
 6 | - [Daniel Roe](https://github.com/sponsors/danielroe)
 7 | - [三咲智子 Kevin Deng](https://github.com/sponsors/sxzz)
 8 | - [Patak](https://github.com/sponsors/patak-dev)
 9 | 
10 | We would also appreciate sponsoring other contributors to the Elk project. If someone helps you solve an issue or implement a feature you wanted, supporting them would help make this project and OS more sustainable.
11 | 


--------------------------------------------------------------------------------
/docs/netlify.toml:
--------------------------------------------------------------------------------
 1 | [build]
 2 | publish = "dist"
 3 | command = "pnpm generate"
 4 | 
 5 | # Allow previewing docs
 6 | [[redirects]]
 7 | from = "/docs/*"
 8 | to = "/:splat"
 9 | status = 200
10 | force = true
11 | 


--------------------------------------------------------------------------------
/docs/nuxt.config.ts:
--------------------------------------------------------------------------------
 1 | export default defineNuxtConfig({
 2 |   extends: '@nuxt-themes/docus',
 3 | 
 4 |   vite: {
 5 |     optimizeDeps: {
 6 |       include: ['scule'],
 7 |     },
 8 |   },
 9 | 
10 |   compatibilityDate: '2024-11-07',
11 | })
12 | 


--------------------------------------------------------------------------------
/docs/package.json:
--------------------------------------------------------------------------------
 1 | {
 2 |   "name": "elk-docs",
 3 |   "version": "0.1.0",
 4 |   "private": true,
 5 |   "scripts": {
 6 |     "dev": "nuxi dev",
 7 |     "build": "nuxi build",
 8 |     "generate": "nuxi generate",
 9 |     "preview": "nuxi preview"
10 |   },
11 |   "dependencies": {
12 |     "theme-colors": "^0.1.0"
13 |   },
14 |   "devDependencies": {
15 |     "@nuxt-themes/docus": "^1.15.1",
16 |     "nuxt": "^3.17.6"
17 |   }
18 | }
19 | 


--------------------------------------------------------------------------------
/docs/public/apple-touch-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/elk-zone/elk/190be77043f03363433fdd55a29b76e5ff81cbd3/docs/public/apple-touch-icon.png


--------------------------------------------------------------------------------
/docs/public/elk-screenshot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/elk-zone/elk/190be77043f03363433fdd55a29b76e5ff81cbd3/docs/public/elk-screenshot.png


--------------------------------------------------------------------------------
/docs/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/elk-zone/elk/190be77043f03363433fdd55a29b76e5ff81cbd3/docs/public/favicon.ico


--------------------------------------------------------------------------------
/docs/public/fonts/DM-sans-v11.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/elk-zone/elk/190be77043f03363433fdd55a29b76e5ff81cbd3/docs/public/fonts/DM-sans-v11.ttf


--------------------------------------------------------------------------------
/docs/public/images/selfhosting-guide/cf-api-token-settings.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/elk-zone/elk/190be77043f03363433fdd55a29b76e5ff81cbd3/docs/public/images/selfhosting-guide/cf-api-token-settings.png


--------------------------------------------------------------------------------
/docs/public/images/selfhosting-guide/github-fork.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/elk-zone/elk/190be77043f03363433fdd55a29b76e5ff81cbd3/docs/public/images/selfhosting-guide/github-fork.png


--------------------------------------------------------------------------------
/docs/public/pwa-192x192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/elk-zone/elk/190be77043f03363433fdd55a29b76e5ff81cbd3/docs/public/pwa-192x192.png


--------------------------------------------------------------------------------
/docs/public/pwa-512x512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/elk-zone/elk/190be77043f03363433fdd55a29b76e5ff81cbd3/docs/public/pwa-512x512.png


--------------------------------------------------------------------------------
/docs/public/screenshot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/elk-zone/elk/190be77043f03363433fdd55a29b76e5ff81cbd3/docs/public/screenshot.png


--------------------------------------------------------------------------------
/docs/public/site.webmanifest:
--------------------------------------------------------------------------------
 1 | {
 2 |   "name": "",
 3 |   "short_name": "",
 4 |   "icons": [
 5 |     {
 6 |       "src": "/pwa-192x192.png",
 7 |       "sizes": "192x192",
 8 |       "type": "image/png"
 9 |     },
10 |     {
11 |       "src": "/pwa-512x512.png",
12 |       "sizes": "512x512",
13 |       "type": "image/png"
14 |     }
15 |   ],
16 |   "theme_color": "#ffffff",
17 |   "background_color": "#ffffff",
18 |   "display": "standalone"
19 | }
20 | 


--------------------------------------------------------------------------------
/docs/tokens.config.ts:
--------------------------------------------------------------------------------
 1 | import { defineTheme } from 'pinceau'
 2 | import { getColors } from 'theme-colors'
 3 | 
 4 | const light = getColors('#995e1b')
 5 | const primary = Object
 6 |   .entries(getColors('#d98018'))
 7 |   .reduce((acc, [key, value]) => {
 8 |     acc[key] = {
 9 |       initial: light[key]!,
10 |       dark: value,
11 |     }
12 |     return acc
13 |   }, {} as Record<string | number, { initial: string, dark: string }>)
14 | 
15 | export default defineTheme({
16 |   color: { primary },
17 | })
18 | 


--------------------------------------------------------------------------------
/docs/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 |   "extends": "./.nuxt/tsconfig.json"
3 | }
4 | 


--------------------------------------------------------------------------------
/docs/types.ts:
--------------------------------------------------------------------------------
 1 | export interface LocaleEntry {
 2 |   title: string
 3 |   file: string
 4 |   useFile: string
 5 |   translated: string[]
 6 |   missing: string[]
 7 |   outdated: string[]
 8 |   total: number
 9 |   isSource?: boolean
10 | }
11 | 
12 | export type TranslationStatus = Record<string, LocaleEntry>
13 | 


--------------------------------------------------------------------------------
/emoji-mart-traslation.d.ts:
--------------------------------------------------------------------------------
1 | declare module 'virtual:emoji-mart-lang-importer' {
2 |   export default function (lang: string): Promise<any>
3 | }
4 | 


--------------------------------------------------------------------------------
/eslint.config.js:
--------------------------------------------------------------------------------
 1 | // @ts-check
 2 | import antfu from '@antfu/eslint-config'
 3 | 
 4 | export default await antfu(
 5 |   {
 6 |     unocss: false,
 7 |     vue: {
 8 |       overrides: {
 9 |         'vue/no-restricted-syntax': ['error', {
10 |           selector: 'VElement[name=\'a\']',
11 |           message: 'Use NuxtLink instead.',
12 |         }],
13 |       },
14 |     },
15 |     ignores: [
16 |       'public/**',
17 |       'public-dev/**',
18 |       'public-staging/**',
19 |       'https-dev-config/**',
20 |       'elk-translation-status.json',
21 |       'docs/translation-status.json',
22 |     ],
23 |   },
24 |   {
25 |     rules: {
26 |       // TODO: migrate all process reference to `import.meta.env` and remove this rule
27 |       'node/prefer-global/process': 'off',
28 |     },
29 |   },
30 |   // Sort local files
31 |   {
32 |     files: ['locales/**.json'],
33 |     rules: {
34 |       'jsonc/sort-keys': 'error',
35 |     },
36 |   },
37 | )
38 | 


--------------------------------------------------------------------------------
/https-dev-config/local-https-server.mjs:
--------------------------------------------------------------------------------
 1 | import { readFileSync } from 'node:fs'
 2 | import { fileURLToPath } from 'node:url'
 3 | 
 4 | process.env.NITRO_SSL_CERT = readFileSync(fileURLToPath(new URL('./localhost.crt', import.meta.url)), 'utf8')
 5 | process.env.NITRO_SSL_KEY = readFileSync(fileURLToPath(new URL('./localhost.key', import.meta.url)), 'utf8')
 6 | 
 7 | async function run() {
 8 |   await import('../.output/server/index.mjs')
 9 | }
10 | 
11 | run()
12 | 


--------------------------------------------------------------------------------
/locales/ar-EG.json:
--------------------------------------------------------------------------------
1 | {}
2 | 


--------------------------------------------------------------------------------
/locales/ca-ES.json:
--------------------------------------------------------------------------------
1 | {}
2 | 


--------------------------------------------------------------------------------
/locales/en-US.json:
--------------------------------------------------------------------------------
1 | {}
2 | 


--------------------------------------------------------------------------------
/locales/es-ES.json:
--------------------------------------------------------------------------------
1 | {}
2 | 


--------------------------------------------------------------------------------
/locales/pt-PT.json:
--------------------------------------------------------------------------------
1 | {}
2 | 


--------------------------------------------------------------------------------
/mocks/class.ts:
--------------------------------------------------------------------------------
1 | export default class SomeClass {
2 | 
3 | }
4 | 


--------------------------------------------------------------------------------
/mocks/prosemirror.ts:
--------------------------------------------------------------------------------
1 | import proxy from 'mocked-exports/proxy'
2 | 
3 | export const Plugin = proxy
4 | export const PluginKey = proxy
5 | export const createParser = proxy
6 | export const createHighlightPlugin = proxy
7 | 
8 | export { proxy as default }
9 | 


--------------------------------------------------------------------------------
/mocks/semver.ts:
--------------------------------------------------------------------------------
1 | import proxy from 'mocked-exports/proxy'
2 | 
3 | export const lt = proxy
4 | export const gt = proxy
5 | export const gte = proxy
6 | export const satisfies = proxy
7 | export class SemVer {}
8 | 


--------------------------------------------------------------------------------
/mocks/tiptap.ts:
--------------------------------------------------------------------------------
 1 | import proxy from 'mocked-exports/proxy'
 2 | 
 3 | export const Extension = proxy
 4 | export const useEditor = proxy
 5 | export const EditorContent = proxy
 6 | export const NodeViewContent = proxy
 7 | export const NodeViewWrapper = proxy
 8 | export const nodeViewProps = proxy
 9 | export const Node = proxy
10 | export const mergeAttributes = proxy
11 | export const nodeInputRule = proxy
12 | export const nodePasteRule = proxy
13 | export const VueNodeViewRenderer = proxy
14 | export const findChildren = proxy
15 | export const VueRenderer = proxy
16 | export const callOrReturn = proxy
17 | export const InputRule = proxy
18 | 
19 | export { proxy as default }
20 | 


--------------------------------------------------------------------------------
/modules/build-env.ts:
--------------------------------------------------------------------------------
 1 | import type { BuildInfo } from '#shared/types'
 2 | import { createResolver, defineNuxtModule } from '@nuxt/kit'
 3 | import { isCI } from 'std-env'
 4 | import { getEnv, version } from '../config/env'
 5 | 
 6 | const { resolve } = createResolver(import.meta.url)
 7 | 
 8 | export default defineNuxtModule({
 9 |   meta: {
10 |     name: 'elk:build-env',
11 |   },
12 |   async setup(_options, nuxt) {
13 |     const { env, commit, shortCommit, branch } = await getEnv()
14 |     const buildInfo: BuildInfo = {
15 |       version,
16 |       time: +Date.now(),
17 |       commit,
18 |       shortCommit,
19 |       branch,
20 |       env,
21 |     }
22 | 
23 |     nuxt.options.appConfig = nuxt.options.appConfig || {}
24 |     nuxt.options.appConfig.env = env
25 |     nuxt.options.appConfig.buildInfo = buildInfo
26 | 
27 |     nuxt.options.nitro.virtual = nuxt.options.nitro.virtual || {}
28 |     nuxt.options.nitro.virtual['#build-info'] = `export const env = ${JSON.stringify(env)}`
29 | 
30 |     nuxt.options.nitro.publicAssets = nuxt.options.nitro.publicAssets || []
31 |     if (env === 'dev')
32 |       nuxt.options.nitro.publicAssets.unshift({ dir: resolve('../public-dev') })
33 |     else if (env === 'canary' || env === 'preview' || !isCI)
34 |       nuxt.options.nitro.publicAssets.unshift({ dir: resolve('../public-staging') })
35 |   },
36 | })
37 | 


--------------------------------------------------------------------------------
/modules/purge-comments.ts:
--------------------------------------------------------------------------------
 1 | import { addVitePlugin, defineNuxtModule } from '@nuxt/kit'
 2 | import MagicString from 'magic-string'
 3 | 
 4 | export default defineNuxtModule({
 5 |   meta: {
 6 |     name: 'purge-comments',
 7 |   },
 8 |   setup() {
 9 |     addVitePlugin({
10 |       name: 'purge-comments',
11 |       enforce: 'pre',
12 |       transform: (code, id) => {
13 |         if (!id.endsWith('.vue') || !code.includes('<!--'))
14 |           return
15 | 
16 |         const s = new MagicString(code)
17 |         s.replace(/<!--.*?-->/gs, '')
18 | 
19 |         if (s.hasChanged()) {
20 |           return {
21 |             code: s.toString(),
22 |             map: s.generateMap({ source: id, includeContent: true }),
23 |           }
24 |         }
25 |       },
26 |     })
27 |   },
28 | })
29 | 


--------------------------------------------------------------------------------
/modules/pwa/runtime/types.d.ts:
--------------------------------------------------------------------------------
 1 | import type { Ref } from 'vue'
 2 | import type { UnwrapNestedRefs } from 'vue'
 3 | 
 4 | export interface PwaInjection {
 5 |   isInstalled: boolean
 6 |   showInstallPrompt: Ref<boolean>
 7 |   cancelInstall: () => void
 8 |   install: () => Promise<void>
 9 |   swActivated: Ref<boolean>
10 |   registrationError: Ref<boolean>
11 |   needRefresh: Ref<boolean>
12 |   updateServiceWorker: (reloadPage?: boolean | undefined) => Promise<void>
13 |   close: () => Promise<void>
14 | }
15 | 
16 | declare module '#app' {
17 |   interface NuxtApp {
18 |     $pwa?: UnwrapNestedRefs<PwaInjection>
19 |   }
20 | }
21 | 
22 | declare module 'vue' {
23 |   interface ComponentCustomProperties {
24 |     $pwa?: UnwrapNestedRefs<PwaInjection>
25 |   }
26 | }
27 | 
28 | export {}
29 | 


--------------------------------------------------------------------------------
/modules/pwa/types.ts:
--------------------------------------------------------------------------------
 1 | import type { VitePWAOptions } from 'vite-plugin-pwa'
 2 | 
 3 | export interface VitePWANuxtOptions extends Partial<VitePWAOptions> {}
 4 | 
 5 | declare module '@nuxt/schema' {
 6 |   interface NuxtConfig {
 7 |     pwa?: { [K in keyof VitePWANuxtOptions]?: Partial<VitePWANuxtOptions[K]> }
 8 |   }
 9 |   interface NuxtOptions {
10 |     pwa: VitePWANuxtOptions
11 |   }
12 | }
13 | 


--------------------------------------------------------------------------------
/modules/tauri/runtime/build-info.ts:
--------------------------------------------------------------------------------
1 | export const env = useAppConfig().env
2 | 


--------------------------------------------------------------------------------
/modules/tauri/runtime/logging.client.ts:
--------------------------------------------------------------------------------
 1 | import * as log from 'tauri-plugin-log-api'
 2 | 
 3 | // When running inside Tauri, catch all logs from 3rd party packages and direct them to the unified logging stream
 4 | export default defineNuxtPlugin(() => {
 5 |   // eslint-disable-next-line no-global-assign
 6 |   console = {
 7 |     ...console,
 8 |     trace: log.trace,
 9 |     debug: log.debug,
10 |     log: log.info,
11 |     warn: log.warn,
12 |     error: log.error,
13 |   }
14 | 
15 |   window.addEventListener('unhandledrejection', err =>
16 |     log.error(err.reason))
17 |   window.addEventListener('error', err => log.error(err.error), true)
18 | })
19 | 


--------------------------------------------------------------------------------
/modules/tauri/runtime/storage-config.ts:
--------------------------------------------------------------------------------
1 | export const driver = undefined
2 | export const fsBase = ''
3 | 


--------------------------------------------------------------------------------
/modules/tauri/runtime/storage.ts:
--------------------------------------------------------------------------------
 1 | import { Store } from 'tauri-plugin-store-api'
 2 | import { createStorage } from 'unstorage'
 3 | 
 4 | const store = new Store('.servers.dat')
 5 | const storage = createStorage()
 6 | storage.mount('servers', {
 7 |   getKeys() {
 8 |     return store.keys()
 9 |   },
10 |   async removeItem(key: string) {
11 |     await store.delete(key)
12 |   },
13 |   clear() {
14 |     return store.clear()
15 |   },
16 |   hasItem(key: string) {
17 |     return store.has(key)
18 |   },
19 |   setItem(key: string, value: any) {
20 |     return store.set(key, value)
21 |   },
22 |   getItem(key: string) {
23 |     return store.get(key)
24 |   },
25 | })
26 | 
27 | export function useStorage() {
28 |   return storage
29 | }
30 | 


--------------------------------------------------------------------------------
/netlify.toml:
--------------------------------------------------------------------------------
 1 | [build]
 2 | publish = "dist"
 3 | command = "pnpm run build"
 4 | 
 5 | [build.environment]
 6 | NODE_OPTIONS = '--max-old-space-size=4096'
 7 | 
 8 | # Redirect to Discord server
 9 | [[redirects]]
10 | from = "https://chat.elk.zone"
11 | to = "https://discord.gg/vAZSDU9J"
12 | status = 301
13 | force = true
14 | 
15 | # Redirect to Discord server
16 | [[redirects]]
17 | from = "https://code.elk.zone"
18 | to = "https://github.com/elk-zone/elk"
19 | status = 301
20 | force = true
21 | 


--------------------------------------------------------------------------------
/page-lifecycle.d.ts:
--------------------------------------------------------------------------------
 1 | declare module 'page-lifecycle/dist/lifecycle.mjs' {
 2 |      type PageLifecycleState = 'active' | 'passive' | 'hidden' | 'frozen' | 'terminated'
 3 | 
 4 |      interface PageLifecycleEvent extends Event {
 5 |        newState: PageLifecycleState
 6 |        oldState: PageLifecycleState
 7 |      }
 8 |      interface PageLifecycle extends EventTarget {
 9 |        get state(): PageLifecycleState
10 |        get pageWasDiscarded(): boolean
11 |        addUnsavedChanges: (id: symbol | any) => void
12 |        removeUnsavedChanges: (id: symbol | any) => void
13 |        addEventListener: (type: string, listener: (evt: PageLifecycleEvent) => void) => void
14 |      }
15 |      const lifecycle: PageLifecycle
16 |      export default lifecycle
17 | }
18 | 


--------------------------------------------------------------------------------
/patches/.gitkeep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/elk-zone/elk/190be77043f03363433fdd55a29b76e5ff81cbd3/patches/.gitkeep


--------------------------------------------------------------------------------
/patches/pinceau.patch:
--------------------------------------------------------------------------------
 1 | diff --git a/dist/index.d.ts b/dist/index.d.ts
 2 | index 612f1c7908c2e973870be08c6fba1515e6e2b9ca..445a3b0574c5388b624d537fc17cbf4a08973ded 100644
 3 | --- a/dist/index.d.ts
 4 | +++ b/dist/index.d.ts
 5 | @@ -115,7 +115,7 @@ interface ModuleHooks {
 6 |  interface ModuleOptions extends PinceauOptions {
 7 |  }
 8 |  
 9 | -declare module '@vue/runtime-core' {
10 | +declare module 'vue' {
11 |      interface ComponentCustomProperties {
12 |          $dt: DtFunction;
13 |          $pinceau: ComputedRef<string>;
14 | 


--------------------------------------------------------------------------------
/pnpm-workspace.yaml:
--------------------------------------------------------------------------------
1 | packages:
2 |   - docs
3 | 


--------------------------------------------------------------------------------
/public-dev/apple-touch-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/elk-zone/elk/190be77043f03363433fdd55a29b76e5ff81cbd3/public-dev/apple-touch-icon.png


--------------------------------------------------------------------------------
/public-dev/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/elk-zone/elk/190be77043f03363433fdd55a29b76e5ff81cbd3/public-dev/favicon.ico


--------------------------------------------------------------------------------
/public-dev/maskable-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/elk-zone/elk/190be77043f03363433fdd55a29b76e5ff81cbd3/public-dev/maskable-icon.png


--------------------------------------------------------------------------------
/public-dev/pwa-192x192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/elk-zone/elk/190be77043f03363433fdd55a29b76e5ff81cbd3/public-dev/pwa-192x192.png


--------------------------------------------------------------------------------
/public-dev/pwa-512x512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/elk-zone/elk/190be77043f03363433fdd55a29b76e5ff81cbd3/public-dev/pwa-512x512.png


--------------------------------------------------------------------------------
/public-dev/pwa-64x64.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/elk-zone/elk/190be77043f03363433fdd55a29b76e5ff81cbd3/public-dev/pwa-64x64.png


--------------------------------------------------------------------------------
/public-staging/apple-touch-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/elk-zone/elk/190be77043f03363433fdd55a29b76e5ff81cbd3/public-staging/apple-touch-icon.png


--------------------------------------------------------------------------------
/public-staging/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/elk-zone/elk/190be77043f03363433fdd55a29b76e5ff81cbd3/public-staging/favicon.ico


--------------------------------------------------------------------------------
/public-staging/maskable-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/elk-zone/elk/190be77043f03363433fdd55a29b76e5ff81cbd3/public-staging/maskable-icon.png


--------------------------------------------------------------------------------
/public-staging/pwa-192x192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/elk-zone/elk/190be77043f03363433fdd55a29b76e5ff81cbd3/public-staging/pwa-192x192.png


--------------------------------------------------------------------------------
/public-staging/pwa-512x512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/elk-zone/elk/190be77043f03363433fdd55a29b76e5ff81cbd3/public-staging/pwa-512x512.png


--------------------------------------------------------------------------------
/public-staging/pwa-64x64.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/elk-zone/elk/190be77043f03363433fdd55a29b76e5ff81cbd3/public-staging/pwa-64x64.png


--------------------------------------------------------------------------------
/public-staging/robots.txt:
--------------------------------------------------------------------------------
1 | User-agent: *
2 | Disallow: /
3 | 


--------------------------------------------------------------------------------
/public/_redirects:
--------------------------------------------------------------------------------
1 | /docs/* https://docs.elk.zone/:splat 200
2 | /settings/* /index.html 200
3 | 


--------------------------------------------------------------------------------
/public/apple-touch-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/elk-zone/elk/190be77043f03363433fdd55a29b76e5ff81cbd3/public/apple-touch-icon.png


--------------------------------------------------------------------------------
/public/avatars/antfu-100x100.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/elk-zone/elk/190be77043f03363433fdd55a29b76e5ff81cbd3/public/avatars/antfu-100x100.png


--------------------------------------------------------------------------------
/public/avatars/antfu-60x60.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/elk-zone/elk/190be77043f03363433fdd55a29b76e5ff81cbd3/public/avatars/antfu-60x60.png


--------------------------------------------------------------------------------
/public/avatars/danielroe-100x100.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/elk-zone/elk/190be77043f03363433fdd55a29b76e5ff81cbd3/public/avatars/danielroe-100x100.png


--------------------------------------------------------------------------------
/public/avatars/danielroe-60x60.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/elk-zone/elk/190be77043f03363433fdd55a29b76e5ff81cbd3/public/avatars/danielroe-60x60.png


--------------------------------------------------------------------------------
/public/avatars/patak-dev-100x100.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/elk-zone/elk/190be77043f03363433fdd55a29b76e5ff81cbd3/public/avatars/patak-dev-100x100.png


--------------------------------------------------------------------------------
/public/avatars/patak-dev-60x60.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/elk-zone/elk/190be77043f03363433fdd55a29b76e5ff81cbd3/public/avatars/patak-dev-60x60.png


--------------------------------------------------------------------------------
/public/avatars/shuuji3-100x100.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/elk-zone/elk/190be77043f03363433fdd55a29b76e5ff81cbd3/public/avatars/shuuji3-100x100.png


--------------------------------------------------------------------------------
/public/avatars/shuuji3-60x60.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/elk-zone/elk/190be77043f03363433fdd55a29b76e5ff81cbd3/public/avatars/shuuji3-60x60.png


--------------------------------------------------------------------------------
/public/avatars/sxzz-100x100.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/elk-zone/elk/190be77043f03363433fdd55a29b76e5ff81cbd3/public/avatars/sxzz-100x100.png


--------------------------------------------------------------------------------
/public/avatars/sxzz-60x60.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/elk-zone/elk/190be77043f03363433fdd55a29b76e5ff81cbd3/public/avatars/sxzz-60x60.png


--------------------------------------------------------------------------------
/public/avatars/userquin-100x100.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/elk-zone/elk/190be77043f03363433fdd55a29b76e5ff81cbd3/public/avatars/userquin-100x100.png


--------------------------------------------------------------------------------
/public/avatars/userquin-60x60.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/elk-zone/elk/190be77043f03363433fdd55a29b76e5ff81cbd3/public/avatars/userquin-60x60.png


--------------------------------------------------------------------------------
/public/elk-og.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/elk-zone/elk/190be77043f03363433fdd55a29b76e5ff81cbd3/public/elk-og.png


--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/elk-zone/elk/190be77043f03363433fdd55a29b76e5ff81cbd3/public/favicon.ico


--------------------------------------------------------------------------------
/public/fonts/DM-mono-v10.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/elk-zone/elk/190be77043f03363433fdd55a29b76e5ff81cbd3/public/fonts/DM-mono-v10.ttf


--------------------------------------------------------------------------------
/public/fonts/DM-sans-v11.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/elk-zone/elk/190be77043f03363433fdd55a29b76e5ff81cbd3/public/fonts/DM-sans-v11.ttf


--------------------------------------------------------------------------------
/public/fonts/DM-serif-display-v10.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/elk-zone/elk/190be77043f03363433fdd55a29b76e5ff81cbd3/public/fonts/DM-serif-display-v10.ttf


--------------------------------------------------------------------------------
/public/fonts/homemade-apple-v18.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/elk-zone/elk/190be77043f03363433fdd55a29b76e5ff81cbd3/public/fonts/homemade-apple-v18.ttf


--------------------------------------------------------------------------------
/public/maskable-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/elk-zone/elk/190be77043f03363433fdd55a29b76e5ff81cbd3/public/maskable-icon.png


--------------------------------------------------------------------------------
/public/pwa-192x192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/elk-zone/elk/190be77043f03363433fdd55a29b76e5ff81cbd3/public/pwa-192x192.png


--------------------------------------------------------------------------------
/public/pwa-512x512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/elk-zone/elk/190be77043f03363433fdd55a29b76e5ff81cbd3/public/pwa-512x512.png


--------------------------------------------------------------------------------
/public/pwa-64x64.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/elk-zone/elk/190be77043f03363433fdd55a29b76e5ff81cbd3/public/pwa-64x64.png


--------------------------------------------------------------------------------
/public/robots.txt:
--------------------------------------------------------------------------------
 1 | User-agent: *
 2 | Allow: /
 3 | 
 4 | # Disallow authenticated pages
 5 | 
 6 | Disallow: /intent
 7 | Disallow: /settings
 8 | Disallow: /blocks
 9 | Disallow: /bookmarks
10 | Disallow: /compose
11 | Disallow: /conversations
12 | Disallow: /domain_blocks
13 | Disallow: /favourites
14 | Disallow: /home
15 | Disallow: /mutes
16 | Disallow: /notifications
17 | Disallow: /pinned
18 | Disallow: /search
19 | Disallow: /settings
20 | Disallow: /share-target
21 | 
22 | # Wait 1 second between successive requests.
23 | Crawl-delay: 1
24 | 


--------------------------------------------------------------------------------
/public/screenshots/dark-1.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/elk-zone/elk/190be77043f03363433fdd55a29b76e5ff81cbd3/public/screenshots/dark-1.webp


--------------------------------------------------------------------------------
/public/screenshots/light-1.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/elk-zone/elk/190be77043f03363433fdd55a29b76e5ff81cbd3/public/screenshots/light-1.webp


--------------------------------------------------------------------------------
/public/shortcuts/compose-96x96.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/elk-zone/elk/190be77043f03363433fdd55a29b76e5ff81cbd3/public/shortcuts/compose-96x96.png


--------------------------------------------------------------------------------
/public/shortcuts/compose.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/elk-zone/elk/190be77043f03363433fdd55a29b76e5ff81cbd3/public/shortcuts/compose.png


--------------------------------------------------------------------------------
/public/shortcuts/home-96x96.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/elk-zone/elk/190be77043f03363433fdd55a29b76e5ff81cbd3/public/shortcuts/home-96x96.png


--------------------------------------------------------------------------------
/public/shortcuts/home.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/elk-zone/elk/190be77043f03363433fdd55a29b76e5ff81cbd3/public/shortcuts/home.png


--------------------------------------------------------------------------------
/public/shortcuts/local-96x96.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/elk-zone/elk/190be77043f03363433fdd55a29b76e5ff81cbd3/public/shortcuts/local-96x96.png


--------------------------------------------------------------------------------
/public/shortcuts/local.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/elk-zone/elk/190be77043f03363433fdd55a29b76e5ff81cbd3/public/shortcuts/local.png


--------------------------------------------------------------------------------
/public/shortcuts/notifications-96x96.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/elk-zone/elk/190be77043f03363433fdd55a29b76e5ff81cbd3/public/shortcuts/notifications-96x96.png


--------------------------------------------------------------------------------
/public/shortcuts/notifications.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/elk-zone/elk/190be77043f03363433fdd55a29b76e5ff81cbd3/public/shortcuts/notifications.png


--------------------------------------------------------------------------------
/public/shortcuts/settings-96x96.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/elk-zone/elk/190be77043f03363433fdd55a29b76e5ff81cbd3/public/shortcuts/settings-96x96.png


--------------------------------------------------------------------------------
/public/shortcuts/settings.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/elk-zone/elk/190be77043f03363433fdd55a29b76e5ff81cbd3/public/shortcuts/settings.png


--------------------------------------------------------------------------------
/public/sw.js:
--------------------------------------------------------------------------------
 1 | // DON'T REMOVE THIS FILE: IT IS THE OLD sw.js
 2 | self.addEventListener('install', (e) => {
 3 |     self.skipWaiting();
 4 | });
 5 | self.addEventListener('activate', (e) => {
 6 |     self.registration.unregister()
 7 |         .then(() => self.clients.matchAll())
 8 |         .then((clients) => {
 9 |             clients.forEach((client) => {
10 |                 if (client instanceof WindowClient)
11 |                     client.navigate(client.url);
12 |             });
13 |             return Promise.resolve();
14 |         })
15 |         .then(() => {
16 |             self.caches.keys().then((cacheNames) => {
17 |                 Promise.all(
18 |                     cacheNames.map((cacheName) => {
19 |                         return self.caches.delete(cacheName);
20 |                     })
21 |                 );
22 |             })
23 |         });
24 | });
25 | 


--------------------------------------------------------------------------------
/scripts/avatars.ts:
--------------------------------------------------------------------------------
 1 | import { writeFile } from 'node:fs/promises'
 2 | import fs from 'fs-extra'
 3 | import { ofetch } from 'ofetch'
 4 | import { join, resolve } from 'pathe'
 5 | import { elkTeamMembers } from '../app/composables/about'
 6 | 
 7 | const avatarsDir = resolve('./public/avatars/')
 8 | 
 9 | const sizes = [60, 100]
10 | 
11 | async function download(url: string, fileName: string) {
12 |   console.log('downloading', fileName)
13 |   try {
14 |     const image = await ofetch(url, { responseType: 'arrayBuffer' })
15 |     await writeFile(fileName, new Uint8Array(image))
16 |   }
17 |   catch (err) {
18 |     console.error(err)
19 |   }
20 | }
21 | 
22 | async function fetchAvatars() {
23 |   await fs.ensureDir(avatarsDir)
24 | 
25 |   await Promise.all(elkTeamMembers.reduce((acc, { github }) => {
26 |     acc.push(...sizes.map(s => download(`https://github.com/${github}.png?size=${s}`, join(avatarsDir, `${github}-${s}x${s}.png`))))
27 |     return acc
28 |   }, [] as Promise<void>[]))
29 | }
30 | 
31 | fetchAvatars()
32 | 


--------------------------------------------------------------------------------
/scripts/generate-themes.ts:
--------------------------------------------------------------------------------
 1 | import type { ThemeColors } from '~/composables/settings'
 2 | import chroma from 'chroma-js'
 3 | 
 4 | // #cc7d24 -> hcl(67.14,62.19,59.56)
 5 | export const themesColor = Array.from(
 6 |   { length: 9 },
 7 |   (_, i) => chroma.hcl((67.14 + i * 40) % 360, 62.19, 59.56).hex(),
 8 | )
 9 | 
10 | export function getThemeColors(primary: string): ThemeColors {
11 |   const c = chroma(primary)
12 |   const dc = c.brighten(0.1)
13 | 
14 |   return {
15 |     '--theme-color-name': primary,
16 | 
17 |     '--c-primary': 'rgb(var(--rgb-primary))',
18 |     '--c-primary-active': c.darken(0.5).hex(),
19 |     '--c-primary-light': c.alpha(0.5).hex(),
20 |     '--c-primary-fade': c.darken(0.1).alpha(0.1).hex(),
21 |     '--rgb-primary': c.rgb().join(', '),
22 | 
23 |     '--c-dark-primary': 'rgb(var(--rgb-dark-primary))',
24 |     '--c-dark-primary-active': dc.darken(0.5).hex(),
25 |     '--c-dark-primary-light': dc.alpha(0.5).hex(),
26 |     '--c-dark-primary-fade': dc.darken(0.1).alpha(0.1).hex(),
27 |     '--rgb-dark-primary': c.rgb().join(', '),
28 |   }
29 | }
30 | 
31 | export const colorsMap = themesColor.map(color => [color, getThemeColors(color)])
32 | 


--------------------------------------------------------------------------------
/scripts/prepare.ts:
--------------------------------------------------------------------------------
 1 | import process from 'node:process'
 2 | import fs from 'fs-extra'
 3 | import { emojiPrefix, iconifyEmojiPackage } from '../config/emojis'
 4 | import { colorsMap } from './generate-themes'
 5 | 
 6 | const dereference = process.platform === 'win32' ? true : undefined
 7 | 
 8 | await fs.copy(`node_modules/${iconifyEmojiPackage}/icons`, `public/emojis/${emojiPrefix}`, { overwrite: true, dereference })
 9 | 
10 | await fs.writeJSON('app/constants/themes.json', colorsMap, { spaces: 2, EOL: '\n' })
11 | await fs.writeFile('app/styles/default-theme.css', `:root {\n${Object.entries(colorsMap[0][1]).map(([k, v]) => `  ${k}: ${v};`).join('\n')}\n}\n`, { encoding: 'utf-8' })
12 | 


--------------------------------------------------------------------------------
/scripts/release.ts:
--------------------------------------------------------------------------------
 1 | import Git from 'simple-git'
 2 | 
 3 | const git = Git()
 4 | 
 5 | const hash = await git.revparse(['main'])
 6 | 
 7 | console.log('Checkout release branch')
 8 | await git.checkout('release')
 9 | 
10 | console.log(`Reset to main branch (${hash})`)
11 | await git.reset(['--hard', hash])
12 | 
13 | console.log('Push to release branch')
14 | await git.push(['--force'])
15 | 
16 | console.log('Checkout main branch')
17 | await git.checkout('main')
18 | 


--------------------------------------------------------------------------------
/server/api/[server]/clear.ts:
--------------------------------------------------------------------------------
 1 | export default defineEventHandler(async (event) => {
 2 |   const { server } = getRouterParams(event)
 3 |   const { key } = getQuery(event)
 4 | 
 5 |   if (key !== String(useRuntimeConfig().adminKey))
 6 |     return { status: false, error: 'incorrect key' }
 7 | 
 8 |   await deleteApp(server)
 9 | 
10 |   return { status: true }
11 | })
12 | 


--------------------------------------------------------------------------------
/server/api/[server]/login.ts:
--------------------------------------------------------------------------------
 1 | import { stringifyQuery } from 'ufo'
 2 | 
 3 | export default defineEventHandler(async (event) => {
 4 |   let { server } = getRouterParams(event)
 5 |   const { origin, force_login, lang } = await readBody(event)
 6 |   server = server.toLocaleLowerCase().trim()
 7 |   const app = await getApp(origin, server)
 8 | 
 9 |   if (!app) {
10 |     throw createError({
11 |       statusCode: 400,
12 |       statusMessage: `App not registered for server: ${server}`,
13 |     })
14 |   }
15 | 
16 |   const query = stringifyQuery({
17 |     client_id: app.client_id,
18 |     force_login: force_login === true ? 'true' : 'false',
19 |     scope: 'read write follow push',
20 |     response_type: 'code',
21 |     lang,
22 |     redirect_uri: getRedirectURI(origin, server),
23 |   })
24 | 
25 |   return `https://${server}/oauth/authorize?${query}`
26 | })
27 | 


--------------------------------------------------------------------------------
/server/api/[server]/oauth/[origin].ts:
--------------------------------------------------------------------------------
 1 | import { stringifyQuery } from 'ufo'
 2 | 
 3 | import { defaultUserAgent } from '~~/server/utils/shared'
 4 | 
 5 | export default defineEventHandler(async (event) => {
 6 |   let { server, origin } = getRouterParams(event)
 7 |   server = server.toLocaleLowerCase().trim()
 8 |   origin = decodeURIComponent(origin)
 9 |   const app = await getApp(origin, server)
10 | 
11 |   if (!app) {
12 |     throw createError({
13 |       statusCode: 400,
14 |       statusMessage: `App not registered for server: ${server}`,
15 |     })
16 |   }
17 | 
18 |   const { code } = getQuery(event)
19 |   if (!code) {
20 |     throw createError({
21 |       statusCode: 422,
22 |       statusMessage: 'Missing authentication code.',
23 |     })
24 |   }
25 | 
26 |   try {
27 |     const result: any = await $fetch(`https://${server}/oauth/token`, {
28 |       method: 'POST',
29 |       headers: {
30 |         'user-agent': defaultUserAgent,
31 |       },
32 |       body: {
33 |         client_id: app.client_id,
34 |         client_secret: app.client_secret,
35 |         redirect_uri: getRedirectURI(origin, server),
36 |         grant_type: 'authorization_code',
37 |         code,
38 |         scope: 'read write follow push',
39 |       },
40 |       retry: 3,
41 |     })
42 | 
43 |     const url = `/signin/callback?${stringifyQuery({ server, token: result.access_token, vapid_key: app.vapid_key })}`
44 |     await sendRedirect(event, url, 302)
45 |   }
46 |   catch {
47 |     throw createError({
48 |       statusCode: 400,
49 |       statusMessage: 'Could not complete log in.',
50 |     })
51 |   }
52 | })
53 | 


--------------------------------------------------------------------------------
/server/api/list-servers.ts:
--------------------------------------------------------------------------------
1 | let servers: string[]
2 | 
3 | export default defineEventHandler(async () => {
4 |   if (!servers)
5 |     servers = await listServers()
6 |   return servers
7 | })
8 | 


--------------------------------------------------------------------------------
/server/cache-driver.ts:
--------------------------------------------------------------------------------
 1 | import type { Driver } from 'unstorage'
 2 | import { defineDriver } from 'unstorage'
 3 | import memory from 'unstorage/drivers/memory'
 4 | 
 5 | export interface CacheDriverOptions {
 6 |   driver: Driver
 7 | }
 8 | 
 9 | export default defineDriver((driver: Driver = memory()) => {
10 |   const memoryDriver = memory()
11 |   return {
12 |     ...driver,
13 |     async hasItem(key: string) {
14 |       if (await memoryDriver.hasItem(key, {}))
15 |         return true
16 | 
17 |       return driver.hasItem(key, {})
18 |     },
19 |     async setItem(key: string, value: any, opts: any = {}) {
20 |       await Promise.all([
21 |         memoryDriver.setItem?.(key, value, {}),
22 |         driver.setItem?.(key, value, opts),
23 |       ])
24 |     },
25 |     async getItem(key: string) {
26 |       let value = await memoryDriver.getItem(key)
27 | 
28 |       if (value !== null)
29 |         return value
30 | 
31 |       value = await driver.getItem(key)
32 |       memoryDriver.setItem?.(key, value as string, {})
33 | 
34 |       return value
35 |     },
36 |   }
37 | })
38 | 


--------------------------------------------------------------------------------
/service-worker/tsconfig.json:
--------------------------------------------------------------------------------
 1 | {
 2 |   "extends": "../tsconfig.json",
 3 |   "compilerOptions": {
 4 |     "lib": ["ESNext", "WebWorker", "DOM.Iterable"],
 5 |     "types": ["vite/client"]
 6 |   },
 7 |   "include": ["./"],
 8 |   "exclude": []
 9 | }
10 | 


--------------------------------------------------------------------------------
/shared/types/translation-status.ts:
--------------------------------------------------------------------------------
1 | export interface ElkTranslationStatus {
2 |   total: number
3 |   locales: Record<string, {
4 |     percentage: string
5 |     total: number
6 |   }>
7 | }
8 | 


--------------------------------------------------------------------------------
/shared/types/utils.ts:
--------------------------------------------------------------------------------
1 | export type Mutable<T> = {
2 |   -readonly[P in keyof T]: T[P]
3 | }
4 | 
5 | export type Overwrite<T, O> = Omit<T, keyof O> & O
6 | export type MarkNonNullable<T, K extends keyof T> = Overwrite<T, {
7 |   [P in K]-?: NonNullable<T[P]>
8 | }>
9 | 


--------------------------------------------------------------------------------
/shims.d.ts:
--------------------------------------------------------------------------------
 1 | declare global {
 2 |   namespace NodeJS {
 3 |     interface Process {
 4 |       test?: boolean
 5 |     }
 6 |   }
 7 | }
 8 | 
 9 | export {}
10 | 


--------------------------------------------------------------------------------
/tests/nuxt/html-to-text.test.ts:
--------------------------------------------------------------------------------
 1 | import { describe, expect, it } from 'vitest'
 2 | 
 3 | describe('html-to-text', () => {
 4 |   it('inline code', () => {
 5 |     expect(htmlToText('<p>text <code>code</code> inline</p>'))
 6 |       .toMatchInlineSnapshot('"text `code` inline"')
 7 |   })
 8 | 
 9 |   it('code block', () => {
10 |     expect(htmlToText('<p>text </p><pre><code class="language-js">code</code></pre>'))
11 |       .toMatchInlineSnapshot(`
12 |       "text 
13 |       \`\`\`js
14 |       code
15 |       \`\`\`"
16 |     `)
17 |   })
18 | 
19 |   it('bold & italic', () => {
20 |     expect(htmlToText('<p>text <b>bold</b> <em>italic</em></p>'))
21 |       .toMatchInlineSnapshot('"text **bold** *italic*"')
22 |   })
23 | })
24 | 


--------------------------------------------------------------------------------
/tests/setup.ts:
--------------------------------------------------------------------------------
1 | // We have TypeError: AbortSignal.timeout is not a function when running tests against masto.js v6
2 | if (!AbortSignal.timeout) {
3 |   AbortSignal.timeout = (ms) => {
4 |     const controller = new AbortController()
5 |     setTimeout(() => controller.abort(new DOMException('TimeoutError')), ms)
6 |     return controller.signal
7 |   }
8 | }
9 | 


--------------------------------------------------------------------------------
/tests/unit/language.test.ts:
--------------------------------------------------------------------------------
 1 | import { describe, expect, it } from 'vitest'
 2 | import { matchLanguages } from '../../app/utils/language'
 3 | 
 4 | describe('language', () => {
 5 |   it('match language', () => {
 6 |     expect(matchLanguages(['zh-CN', 'zh-TW'], ['zh'])).toMatchInlineSnapshot('"zh-CN"')
 7 |     expect(matchLanguages(['zh-CN', 'zh-TW'], ['en'])).toMatchInlineSnapshot('null')
 8 | 
 9 |     expect(matchLanguages(['zh-CN', 'zh-TW', 'en-US'], ['zh', 'en'])).toMatchInlineSnapshot('"zh-CN"')
10 |     expect(matchLanguages(['zh-CN', 'zh-TW', 'en-US'], ['en', 'zh-CN'])).toMatchInlineSnapshot('"en-US"')
11 |     expect(matchLanguages(['zh-CN', 'zh-TW', 'en-US'], ['zh-TW', 'en'])).toMatchInlineSnapshot('"zh-TW"')
12 | 
13 |     expect(matchLanguages(['zh-TW', 'en-US'], ['zh-CN', 'en-GB'])).toMatchInlineSnapshot('"zh-TW"')
14 |     expect(matchLanguages(['zh-TW', 'en-GB'], ['ja-JP', 'zh-CN'])).toMatchInlineSnapshot('"zh-TW"')
15 | 
16 |     expect(matchLanguages(['zh-TW'], ['zh-tw'])).toMatchInlineSnapshot('"zh-TW"')
17 |   })
18 | })
19 | 


--------------------------------------------------------------------------------
/tests/unit/permalinks.test.ts:
--------------------------------------------------------------------------------
 1 | import { describe, expect, it } from 'vitest'
 2 | import { HANDLED_MASTO_URLS } from '~/constants'
 3 | 
 4 | const validPermalinks = [
 5 |   'https://m1as-social34.to.social/@elk',
 6 |   'https://m1as-social34.to.social/@elk22/123',
 7 |   'https://m1as-social34.to.social/@elk22/objects/123',
 8 |   'webtoo.ls/@elk',
 9 | ]
10 | 
11 | const invalidPermalinks = [
12 |   'https://webtoo.ls',
13 |   'https://webtoo.ls/elk/123',
14 | ]
15 | 
16 | describe('permalinks', () => {
17 |   it.each(validPermalinks)('should recognise %s', (url) => {
18 |     expect(HANDLED_MASTO_URLS.test(url)).toBe(true)
19 |   })
20 |   it.each(invalidPermalinks)('should not recognise %s', (url) => {
21 |     expect(HANDLED_MASTO_URLS.test(url)).toBe(false)
22 |   })
23 | })
24 | 


--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 |   "extends": "./.nuxt/tsconfig.json"
3 | }
4 | 


--------------------------------------------------------------------------------
/vitest.config.ts:
--------------------------------------------------------------------------------
 1 | import { defineVitestConfig } from '@nuxt/test-utils/config'
 2 | import { isCI } from 'std-env'
 3 | 
 4 | export default defineVitestConfig({
 5 |   define: {
 6 |     'process.test': 'true',
 7 |   },
 8 |   test: {
 9 |     reporters: isCI ? ['default', 'hanging-process'] : ['default'],
10 |     setupFiles: [
11 |       './tests/setup.ts',
12 |     ],
13 |     environmentOptions: {
14 |       nuxt: {
15 |         mock: {
16 |           indexedDb: true,
17 |           intersectionObserver: true,
18 |         },
19 |       },
20 |     },
21 |   },
22 | })
23 | 


--------------------------------------------------------------------------------
/vue-compiler-options.d.ts:
--------------------------------------------------------------------------------
1 | declare module 'pkg-types' {
2 |   interface TSConfig {
3 |     // TODO: augment in nuxt
4 |     vueCompilerOptions: any
5 |   }
6 | }
7 | 
8 | export {}
9 | 


--------------------------------------------------------------------------------