├── .editorconfig ├── .forgejo └── workflows │ └── publish.yml ├── .gitignore ├── .mailmap ├── .npmrc ├── .prettierignore ├── .prettierrc ├── .vscode ├── extensions.json └── settings.json ├── LICENSE ├── README.md ├── app ├── .npmrc ├── api │ ├── api-env.ts │ ├── classes │ │ └── multiagent.ts │ ├── did.ts │ ├── display.ts │ ├── globals │ │ ├── agent.ts │ │ ├── defaults.ts │ │ └── platform.ts │ ├── models │ │ ├── notifications.ts │ │ ├── threads.ts │ │ └── timeline.ts │ ├── moderation │ │ ├── entities │ │ │ ├── basic-profile.ts │ │ │ ├── post.ts │ │ │ ├── profile.ts │ │ │ └── quote.ts │ │ ├── index.ts │ │ ├── service.ts │ │ └── utils.ts │ ├── mutations │ │ ├── block-profile.ts │ │ ├── follow-profile.ts │ │ ├── like-feed.ts │ │ ├── like-post.ts │ │ ├── mute-profile.ts │ │ ├── mute-thread.ts │ │ ├── repost-post.ts │ │ ├── subscribe-list-block.ts │ │ ├── subscribe-list-mute.ts │ │ ├── update-notifications-seen.ts │ │ ├── upload-blob.ts │ │ └── upsert-profile.ts │ ├── queries │ │ ├── _did.ts │ │ ├── get-feed-info.ts │ │ ├── get-labeler-info.ts │ │ ├── get-labeler-popular.ts │ │ ├── get-likes.ts │ │ ├── get-link-meta.ts │ │ ├── get-list-info.ts │ │ ├── get-list-members.ts │ │ ├── get-list-memberships.ts │ │ ├── get-notifications.ts │ │ ├── get-post-reposts.ts │ │ ├── get-post-thread.ts │ │ ├── get-post.ts │ │ ├── get-profile-feeds.ts │ │ ├── get-profile-followers.ts │ │ ├── get-profile-follows.ts │ │ ├── get-profile-known-followers.ts │ │ ├── get-profile-lists.ts │ │ ├── get-profile.ts │ │ ├── get-resolved-handle.ts │ │ ├── get-suggested-follows.ts │ │ ├── get-timeline.ts │ │ ├── get-translation.ts │ │ ├── search-profiles-typeahead.ts │ │ └── search-profiles.ts │ ├── richtext │ │ ├── composer.ts │ │ ├── graphemer.ts │ │ ├── intl.ts │ │ ├── renderer.ts │ │ ├── segmentize.ts │ │ ├── types.ts │ │ ├── unicode-segmenter.ts │ │ ├── unicode.ts │ │ └── utils.ts │ ├── stores │ │ ├── convo.ts │ │ ├── feeds.ts │ │ ├── lists.ts │ │ ├── posts.ts │ │ └── profiles.ts │ ├── types.ts │ ├── updaters │ │ ├── delete-post.ts │ │ └── timeline-filter.ts │ └── utils │ │ ├── batch-fetch.ts │ │ ├── links.ts │ │ ├── misc.ts │ │ ├── post.ts │ │ ├── query.ts │ │ └── toggle-mutation.ts ├── com │ ├── assets │ │ ├── banner.jpg │ │ ├── default-feed-avatar.svg │ │ ├── default-labeler-avatar.svg │ │ ├── default-list-avatar.svg │ │ └── default-user-avatar.svg │ ├── components │ │ ├── BlobImage.tsx │ │ ├── CircularProgress.tsx │ │ ├── Flyout.tsx │ │ ├── Keyed.ts │ │ ├── Link.tsx │ │ ├── List.tsx │ │ ├── Modal.tsx │ │ ├── ProfileFollowButton.tsx │ │ ├── RichTextRenderer.tsx │ │ ├── Tab.tsx │ │ ├── TabbedPanel.tsx │ │ ├── TimeAgo.tsx │ │ ├── VirtualContainer.tsx │ │ ├── dialogs │ │ │ ├── BlockConfirmDialog.desktop.tsx │ │ │ ├── BlockConfirmDialog.tsx │ │ │ ├── ConfirmDialog.tsx │ │ │ ├── DeletePostConfirmDialog.desktop.tsx │ │ │ ├── DeletePostConfirmDialog.tsx │ │ │ ├── DialogOverlay.tsx │ │ │ ├── HandleHistoryDialog.tsx │ │ │ ├── ImageViewerDialog.tsx │ │ │ ├── LabelDetailsDialog.tsx │ │ │ ├── LabelsOnMeDialog.tsx │ │ │ ├── LinkWarningDialog.tsx │ │ │ ├── MuteConfirmDialog.desktop.tsx │ │ │ ├── MuteConfirmDialog.tsx │ │ │ ├── ReportDialog.tsx │ │ │ ├── SilenceConfirmDialog.tsx │ │ │ └── lists │ │ │ │ ├── AddListDialog.tsx │ │ │ │ ├── AddProfileInListDialog.desktop.tsx │ │ │ │ ├── AddProfileInListDialog.tsx │ │ │ │ ├── CloneListMembersDialog.desktop.tsx │ │ │ │ ├── CloneListMembersDialog.tsx │ │ │ │ ├── PruneListOrphanDialog.tsx │ │ │ │ └── SubscribeListDialog.tsx │ │ ├── embeds │ │ │ ├── Embed.tsx │ │ │ ├── EmbedFeed.tsx │ │ │ ├── EmbedImage.tsx │ │ │ ├── EmbedLink.tsx │ │ │ ├── EmbedList.tsx │ │ │ ├── EmbedQuote.tsx │ │ │ ├── EmbedRecordBlocked.tsx │ │ │ ├── EmbedRecordNotFound.tsx │ │ │ ├── EmbedVideo.tsx │ │ │ ├── images │ │ │ │ └── ImageAltAction.tsx │ │ │ └── players │ │ │ │ └── VideoPlayer.tsx │ │ ├── emojis │ │ │ ├── EmojiFlyout.tsx │ │ │ ├── EmojiPicker.tsx │ │ │ └── utils │ │ │ │ ├── database.ts │ │ │ │ └── support.ts │ │ ├── inputs │ │ │ ├── AddPhotoButton.tsx │ │ │ ├── Checkbox.tsx │ │ │ ├── FileInput.tsx │ │ │ ├── FilterBar.tsx │ │ │ ├── Radio.tsx │ │ │ ├── SearchInput.tsx │ │ │ └── SelectInput.tsx │ │ ├── items │ │ │ ├── FeedItem.tsx │ │ │ ├── GalleryItem.tsx │ │ │ ├── ListItem.tsx │ │ │ ├── Notification.tsx │ │ │ ├── Post.tsx │ │ │ ├── PostTreeItem.tsx │ │ │ ├── ProfileItem.tsx │ │ │ └── posts │ │ │ │ ├── PostOverflowAction.tsx │ │ │ │ ├── PostTag.tsx │ │ │ │ ├── ReplyAction.tsx │ │ │ │ └── RepostAction.tsx │ │ ├── lists │ │ │ ├── ListMembersList.tsx │ │ │ ├── ProfileList.tsx │ │ │ ├── TimelineGalleryList.tsx │ │ │ └── TimelineList.tsx │ │ ├── moderation │ │ │ ├── ContentWarning.tsx │ │ │ ├── LabelsOnMe.tsx │ │ │ └── ModerationAlerts.tsx │ │ ├── richtext │ │ │ └── RichtextComposer.tsx │ │ └── views │ │ │ ├── GenericErrorView.tsx │ │ │ ├── PermalinkPost.tsx │ │ │ ├── ProfileHeader.tsx │ │ │ ├── TakingActionNotice.tsx │ │ │ ├── posts │ │ │ └── PostTranslation.tsx │ │ │ ├── profiles │ │ │ ├── ProfileHandleAction.tsx │ │ │ └── ProfileOverflowAction.tsx │ │ │ └── threads │ │ │ ├── FlattenedThread.tsx │ │ │ └── NestedThread.tsx │ ├── globals │ │ ├── modals.tsx │ │ └── shared.ts │ ├── icons │ │ ├── README.md │ │ ├── _icon.tsx │ │ ├── baseline-accessibility.tsx │ │ ├── baseline-account-check.tsx │ │ ├── baseline-account-circle.tsx │ │ ├── baseline-account-switch.tsx │ │ ├── baseline-add-box.tsx │ │ ├── baseline-add-photo-alternate.tsx │ │ ├── baseline-add.tsx │ │ ├── baseline-alternate-email.tsx │ │ ├── baseline-arrow-drop-down.tsx │ │ ├── baseline-arrow-left.tsx │ │ ├── baseline-av-timer.tsx │ │ ├── baseline-back-hand.tsx │ │ ├── baseline-block.tsx │ │ ├── baseline-brightness-medium.tsx │ │ ├── baseline-cell-tower.tsx │ │ ├── baseline-chat-bubble.tsx │ │ ├── baseline-check-all.tsx │ │ ├── baseline-check-box-outline-blank.tsx │ │ ├── baseline-check-box.tsx │ │ ├── baseline-check.tsx │ │ ├── baseline-checkbox-multiple-blank.tsx │ │ ├── baseline-chevron-right.tsx │ │ ├── baseline-close.tsx │ │ ├── baseline-color-lens.tsx │ │ ├── baseline-confirmation-number.tsx │ │ ├── baseline-content-copy.tsx │ │ ├── baseline-copy-all.tsx │ │ ├── baseline-delete.tsx │ │ ├── baseline-download.tsx │ │ ├── baseline-drag-handle.tsx │ │ ├── baseline-drag-indicator.tsx │ │ ├── baseline-edit.tsx │ │ ├── baseline-emoji-emotions.tsx │ │ ├── baseline-error.tsx │ │ ├── baseline-explore.tsx │ │ ├── baseline-favorite.tsx │ │ ├── baseline-feather.tsx │ │ ├── baseline-file.tsx │ │ ├── baseline-filter-alt.tsx │ │ ├── baseline-format-letter-matches.tsx │ │ ├── baseline-format-quote.tsx │ │ ├── baseline-g-translate.tsx │ │ ├── baseline-globe.tsx │ │ ├── baseline-group-off.tsx │ │ ├── baseline-history.tsx │ │ ├── baseline-home.tsx │ │ ├── baseline-image.tsx │ │ ├── baseline-info.tsx │ │ ├── baseline-language.tsx │ │ ├── baseline-launch.tsx │ │ ├── baseline-link.tsx │ │ ├── baseline-list.tsx │ │ ├── baseline-lock.tsx │ │ ├── baseline-menu.tsx │ │ ├── baseline-more-horiz.tsx │ │ ├── baseline-notifications.tsx │ │ ├── baseline-password.tsx │ │ ├── baseline-people.tsx │ │ ├── baseline-person-add.tsx │ │ ├── baseline-person-off.tsx │ │ ├── baseline-person.tsx │ │ ├── baseline-play.tsx │ │ ├── baseline-playlist-add-check.tsx │ │ ├── baseline-playlist-add.tsx │ │ ├── baseline-pound.tsx │ │ ├── baseline-public.tsx │ │ ├── baseline-push-pin.tsx │ │ ├── baseline-refresh.tsx │ │ ├── baseline-repeat-off.tsx │ │ ├── baseline-repeat.tsx │ │ ├── baseline-reply.tsx │ │ ├── baseline-report-problem.tsx │ │ ├── baseline-report.tsx │ │ ├── baseline-search.tsx │ │ ├── baseline-settings.tsx │ │ ├── baseline-share.tsx │ │ ├── baseline-shield.tsx │ │ ├── baseline-swap-vert.tsx │ │ ├── baseline-sync-alt.tsx │ │ ├── baseline-system-update-alt.tsx │ │ ├── baseline-table-column-right-add.tsx │ │ ├── baseline-table-large-add.tsx │ │ ├── baseline-translate.tsx │ │ ├── baseline-unfold-more.tsx │ │ ├── baseline-upload.tsx │ │ ├── baseline-videocam.tsx │ │ ├── baseline-visibility-off.tsx │ │ ├── baseline-visibility.tsx │ │ ├── baseline-volume-off.tsx │ │ ├── baseline-volume-up.tsx │ │ ├── outline-add-box.tsx │ │ ├── outline-add-photo-alternate.tsx │ │ ├── outline-back-hand.tsx │ │ ├── outline-chat-bubble.tsx │ │ ├── outline-cleaning-services.tsx │ │ ├── outline-delete.tsx │ │ ├── outline-door-open.tsx │ │ ├── outline-edit.tsx │ │ ├── outline-emoji-emotions.tsx │ │ ├── outline-error.tsx │ │ ├── outline-explore.tsx │ │ ├── outline-favorite.tsx │ │ ├── outline-filter-alt.tsx │ │ ├── outline-filter-none.tsx │ │ ├── outline-gif-box.tsx │ │ ├── outline-home.tsx │ │ ├── outline-image.tsx │ │ ├── outline-info.tsx │ │ ├── outline-list-box.tsx │ │ ├── outline-mail-add.tsx │ │ ├── outline-mail.tsx │ │ ├── outline-notifications.tsx │ │ ├── outline-people.tsx │ │ ├── outline-person-off.tsx │ │ ├── outline-person.tsx │ │ ├── outline-report-problem.tsx │ │ ├── outline-send.tsx │ │ ├── outline-settings.tsx │ │ ├── outline-shield.tsx │ │ ├── outline-visibility-off.tsx │ │ ├── outline-visibility.tsx │ │ ├── outline-volume-off.tsx │ │ └── outline-volume-up.tsx │ ├── lib │ │ ├── meta.tsx │ │ └── snippet.tsx │ ├── primitives │ │ ├── boxed-icon-button.ts │ │ ├── button.ts │ │ ├── dialog.ts │ │ ├── icon-button.ts │ │ ├── input.ts │ │ ├── interactive.ts │ │ ├── list-box.ts │ │ ├── menu.ts │ │ ├── select.ts │ │ └── textarea.ts │ └── styles │ │ └── theme.css ├── desktop │ ├── .env │ ├── components │ │ ├── composer │ │ │ ├── ComposerContext.tsx │ │ │ ├── ComposerContextProvider.tsx │ │ │ ├── ComposerPane.tsx │ │ │ ├── DummyPost.tsx │ │ │ ├── TagInput.tsx │ │ │ ├── actions │ │ │ │ ├── ContentWarningAction.tsx │ │ │ │ ├── PostLanguageAction.tsx │ │ │ │ └── ThreadgateAction.tsx │ │ │ ├── dialogs │ │ │ │ ├── CustomPostLanguageDialog.tsx │ │ │ │ ├── ImageAltDialog.tsx │ │ │ │ ├── ImageAltReminderDialog.tsx │ │ │ │ ├── ViewDraftsDialog.tsx │ │ │ │ └── drafts │ │ │ │ │ ├── ApplyDraftDialog.tsx │ │ │ │ │ ├── DeleteDraftDialog.tsx │ │ │ │ │ ├── RenameDraftDialog.tsx │ │ │ │ │ └── SaveDraftDialog.tsx │ │ │ └── utils │ │ │ │ ├── cid.ts │ │ │ │ ├── draft-db.ts │ │ │ │ ├── idb.ts │ │ │ │ └── state.ts │ │ ├── dialogs │ │ │ └── DateTimeDialog.tsx │ │ ├── flyouts │ │ │ ├── SearchFlyout.tsx │ │ │ ├── SelectAction.tsx │ │ │ ├── SwitchAccountAction.tsx │ │ │ └── SwitchDeckAction.tsx │ │ ├── messages │ │ │ ├── MessagesContext.tsx │ │ │ ├── MessagesContextProvider.tsx │ │ │ ├── MessagesPane.tsx │ │ │ ├── components │ │ │ │ ├── ChannelHeader.tsx │ │ │ │ ├── ChannelItem.tsx │ │ │ │ ├── ChannelMessages.tsx │ │ │ │ ├── ChannelOverflowAction.tsx │ │ │ │ ├── ChatAccountAction.tsx │ │ │ │ ├── Composition.tsx │ │ │ │ ├── CompositionBlocked.tsx │ │ │ │ ├── CompositionDisabled.tsx │ │ │ │ ├── FirehoseStatus.tsx │ │ │ │ ├── MessageDivider.tsx │ │ │ │ ├── MessageItem.tsx │ │ │ │ ├── MessageOverflowAction.tsx │ │ │ │ └── NoopLinkingProvider.tsx │ │ │ ├── const.ts │ │ │ ├── contexts │ │ │ │ ├── channel.tsx │ │ │ │ ├── chat.tsx │ │ │ │ ├── router-view.tsx │ │ │ │ └── router.tsx │ │ │ ├── utils │ │ │ │ ├── chat.ts │ │ │ │ └── intl.ts │ │ │ └── views │ │ │ │ ├── ChannelListingView.tsx │ │ │ │ ├── ChannelView.tsx │ │ │ │ ├── NewChannelView.tsx │ │ │ │ ├── ResolveChannelView.tsx │ │ │ │ └── SettingsView.tsx │ │ ├── panes │ │ │ ├── Pane.tsx │ │ │ ├── PaneAside.tsx │ │ │ ├── PaneBody.tsx │ │ │ ├── PaneContext.tsx │ │ │ ├── PaneContextProvider.tsx │ │ │ ├── PaneDialog.tsx │ │ │ ├── PaneDialogHeader.tsx │ │ │ ├── PaneFallback.tsx │ │ │ ├── PaneLinkingProvider.tsx │ │ │ ├── PaneRouter.tsx │ │ │ ├── dialogs │ │ │ │ ├── FeedLikedByPaneDialog.tsx │ │ │ │ ├── FeedPaneDialog.tsx │ │ │ │ ├── HashtagPaneDialog.tsx │ │ │ │ ├── ListMembersPaneDialog.tsx │ │ │ │ ├── ListPaneDialog.tsx │ │ │ │ ├── ListSettingsPaneDialog.tsx │ │ │ │ ├── PostLikedByPaneDialog.tsx │ │ │ │ ├── PostQuotedByPaneDialog.tsx │ │ │ │ ├── PostRepostedByPaneDialog.tsx │ │ │ │ ├── ProfileFeedsPaneDialog.tsx │ │ │ │ ├── ProfileFollowersPaneDialog.tsx │ │ │ │ ├── ProfileFollowsPaneDialog.tsx │ │ │ │ ├── ProfileKnownFollowersPaneDialog.tsx │ │ │ │ ├── ProfileListsPaneDialog.tsx │ │ │ │ ├── ProfilePaneDialog.tsx │ │ │ │ ├── ProfileSearchPaneDialog.tsx │ │ │ │ ├── ProfileSettingsPaneDialog.tsx │ │ │ │ └── ThreadPaneDialog.tsx │ │ │ ├── partials │ │ │ │ ├── FeedHeader.tsx │ │ │ │ ├── ListHeader.tsx │ │ │ │ ├── ThreadView.tsx │ │ │ │ └── actions │ │ │ │ │ ├── FeedOverflowAction.tsx │ │ │ │ │ └── ListOverflowAction.tsx │ │ │ ├── settings │ │ │ │ ├── CustomFeedPaneSettings.tsx │ │ │ │ ├── CustomListPaneSettings.tsx │ │ │ │ ├── GenericPaneSettings.tsx │ │ │ │ ├── HomePaneSettings.tsx │ │ │ │ ├── NotificationsPaneSettings.tsx │ │ │ │ ├── ProfilePaneTabSettings.tsx │ │ │ │ └── SearchPaneSettings.tsx │ │ │ └── views │ │ │ │ ├── CustomFeedPane.tsx │ │ │ │ ├── CustomListPane.tsx │ │ │ │ ├── HomePane.tsx │ │ │ │ ├── NotificationsPane.tsx │ │ │ │ ├── ProfilePane.tsx │ │ │ │ ├── SearchPane.tsx │ │ │ │ └── ThreadPane.tsx │ │ ├── settings │ │ │ ├── AddAccountDialog.tsx │ │ │ ├── AddDeckDialog.tsx │ │ │ ├── AddPaneDialog.tsx │ │ │ ├── ChooseServiceDialog.tsx │ │ │ ├── EditDeckDialog.tsx │ │ │ ├── SettingsDialog.tsx │ │ │ ├── pane-creators │ │ │ │ ├── CustomFeedPaneCreator.tsx │ │ │ │ ├── CustomListPaneCreator.tsx │ │ │ │ ├── ProfilePaneCreator.tsx │ │ │ │ └── types.ts │ │ │ └── settings-views │ │ │ │ ├── AboutView.tsx │ │ │ │ ├── AccountsView.tsx │ │ │ │ ├── ContentView.tsx │ │ │ │ ├── ImportExportSettingsView.tsx │ │ │ │ ├── InterfaceView.tsx │ │ │ │ ├── KeywordFiltersView.tsx │ │ │ │ ├── ModerationView.tsx │ │ │ │ ├── SettingsRouterView.tsx │ │ │ │ ├── _components.tsx │ │ │ │ ├── _router.tsx │ │ │ │ ├── account │ │ │ │ └── AccountConfigView.tsx │ │ │ │ ├── content-filters │ │ │ │ ├── HiddenRepostersView.tsx │ │ │ │ ├── LabelerConfigView.tsx │ │ │ │ ├── LabelerPopularView.tsx │ │ │ │ ├── TemporaryMutesView.tsx │ │ │ │ └── components │ │ │ │ │ └── LabelItem.tsx │ │ │ │ ├── import-export │ │ │ │ └── ImportSettingsView.tsx │ │ │ │ ├── keyword-filters │ │ │ │ └── KeywordFilterFormView.tsx │ │ │ │ └── languages │ │ │ │ ├── AdditionalLanguageView.tsx │ │ │ │ └── ExcludedTranslationView.tsx │ │ └── views │ │ │ └── Onboarding.tsx │ ├── globals │ │ ├── events.ts │ │ ├── panes.tsx │ │ ├── query.ts │ │ └── settings.ts │ ├── index.html │ ├── lib │ │ ├── messages │ │ │ ├── channel-listing.ts │ │ │ ├── channel.ts │ │ │ ├── firehose.ts │ │ │ └── lru.ts │ │ ├── scheduled │ │ │ └── index.ts │ │ └── settings │ │ │ ├── backup.ts │ │ │ └── onboarding.ts │ ├── main.tsx │ ├── public │ │ ├── _routes.json │ │ └── favicon.png │ ├── styles │ │ └── tailwind.css │ ├── utils │ │ └── dnd.ts │ └── views │ │ ├── DecksView.tsx │ │ ├── EmptyView.tsx │ │ └── Layout.tsx ├── env.d.ts ├── package.json ├── postcss.config.js ├── scripts │ ├── publish-prod.sh │ └── update-labeler-list.js ├── tailwind.config.js ├── tsconfig.json ├── tsconfig.node.json ├── utils │ ├── dequal.ts │ ├── hooks.ts │ ├── image.ts │ ├── image │ │ └── exif-remover.ts │ ├── immer.ts │ ├── input.ts │ ├── interaction.ts │ ├── intersection-observer.ts │ ├── intl │ │ ├── bytes.ts │ │ ├── display-names.ts │ │ ├── languages.ts │ │ ├── number.ts │ │ └── time.ts │ ├── media-query.ts │ ├── misc.ts │ ├── refs.ts │ ├── service-worker.ts │ ├── sets.ts │ ├── signals.ts │ ├── stack.ts │ └── storage.ts └── vite.config.ts ├── mise.toml ├── package.json ├── packages ├── emoji-db │ ├── README.md │ ├── jsconfig.json │ ├── lib │ │ ├── Database.js │ │ ├── constants.js │ │ ├── data.js │ │ ├── idb-interface.js │ │ ├── idb-lifecycle.js │ │ ├── idb-util.js │ │ ├── migrations.js │ │ └── utils │ │ │ ├── ajax.js │ │ │ ├── assertEmojiData.js │ │ │ ├── cleanEmoji.js │ │ │ ├── extractTokens.js │ │ │ ├── findCommonMembers.js │ │ │ ├── jsonChecksum.js │ │ │ ├── minBy.js │ │ │ ├── normalizeTokens.js │ │ │ ├── requiredKeys.js │ │ │ ├── transformEmojiData.js │ │ │ ├── trie.js │ │ │ ├── uniqBy.js │ │ │ └── uniqEmoji.js │ ├── package.json │ └── types │ │ └── database.d.ts ├── solid-freeze │ ├── .gitignore │ ├── README.md │ ├── index.html │ ├── jsconfig.json │ ├── lib │ │ └── index.tsx │ ├── package.json │ ├── src │ │ └── main.jsx │ ├── tsconfig.json │ └── vite.config.ts ├── solid-page-router │ ├── README.md │ ├── jsconfig.json │ ├── lib │ │ ├── index.tsx │ │ └── routing.ts │ ├── package.json │ ├── tsconfig.json │ └── vite.config.ts ├── solid-query │ ├── .gitignore │ ├── README.md │ ├── jsconfig.json │ ├── lib │ │ ├── QueryClientProvider.tsx │ │ ├── createBaseQuery.ts │ │ ├── createInfiniteQuery.ts │ │ ├── createMutation.ts │ │ ├── createQueries.ts │ │ ├── createQuery.ts │ │ ├── index.ts │ │ ├── notifyManager.ts │ │ ├── types.ts │ │ ├── useIsFetching.ts │ │ ├── useIsMutating.ts │ │ ├── useMutationState.ts │ │ └── utils.ts │ ├── package.json │ ├── tsconfig.json │ └── vite.config.ts └── validity │ ├── README.md │ ├── lib │ ├── index.ts │ ├── methods │ │ ├── chain.ts │ │ ├── pipe.ts │ │ └── union.ts │ ├── parse.ts │ ├── requirements │ │ ├── endsWith.ts │ │ ├── length.ts │ │ ├── regex.ts │ │ └── startsWith.ts │ ├── schemas │ │ ├── array.ts │ │ ├── bigint.ts │ │ ├── boolean.ts │ │ ├── literal.ts │ │ ├── never.ts │ │ ├── null.ts │ │ ├── nullable.ts │ │ ├── number.ts │ │ ├── object.ts │ │ ├── optional.ts │ │ ├── record.ts │ │ ├── string.ts │ │ ├── undefined.ts │ │ └── unknown.ts │ ├── types.ts │ └── utils.ts │ ├── package.json │ └── tsconfig.json ├── patches ├── @tanstack__query-core@5.17.19.patch ├── solid-js@1.9.4.patch ├── solid-textarea-autosize@0.0.5.patch ├── vite-plugin-pwa@0.17.4.patch └── workbox-precaching@7.3.0.patch ├── pnpm-lock.yaml └── pnpm-workspace.yaml /.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | charset = utf-8 3 | end_of_line = lf 4 | insert_final_newline = true 5 | indent_style = tab 6 | trim_trailing_whitespace = true 7 | 8 | [*.yaml] 9 | indent_style = space 10 | 11 | [*.md] 12 | indent_style = space 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | 4 | .npm-*.log 5 | .pnpm-*.log 6 | .yarn-*.log 7 | npm-*.log 8 | pnpm-*.log 9 | yarn-*.log 10 | 11 | *.local 12 | 13 | tsconfig.tsbuildinfo 14 | -------------------------------------------------------------------------------- /.mailmap: -------------------------------------------------------------------------------- 1 | Mary 2 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | publish-branch=trunk 2 | auto-install-peers=false 3 | public-hoist-pattern[]=workbox-window 4 | 5 | @jsr:registry=https://npm.jsr.io 6 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | pnpm-lock.yaml 2 | /packages/solid-query/types/ 3 | /packages/solid-freeze/types/ 4 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "all", 3 | "useTabs": true, 4 | "tabWidth": 2, 5 | "printWidth": 110, 6 | "semi": true, 7 | "singleQuote": true, 8 | "bracketSpacing": true, 9 | "plugins": ["prettier-plugin-tailwindcss", "@trivago/prettier-plugin-sort-imports"], 10 | "tailwindConfig": "./app/tailwind-base.config.js", 11 | "tailwindFunctions": ["tw"], 12 | "importOrder": [ 13 | "^node:", 14 | "", 15 | "^@(atcute|mary)/", 16 | "^~/api/", 17 | "^~/com/globals/", 18 | "^~/desktop/globals/", 19 | "^~/utils/", 20 | "^~/com/lib/", 21 | "^~/desktop/lib/", 22 | "^~/desktop/utils/", 23 | "^~/com/primitives/", 24 | "^~/com/components/", 25 | "^~/desktop/components/", 26 | "^~/desktop/views/", 27 | "^~/com/icons/", 28 | "^~/com/assets/", 29 | "^~/com/styles/", 30 | "^~/desktop/styles", 31 | "^~/com/", 32 | "^~/desktop/", 33 | "^~", 34 | "^\\.{2}(?:/(?:.*)(? { 18 | const accu: ModerationCause[] = []; 19 | const did = view.did; 20 | 21 | decideLabelModeration(accu, TargetProfile, view.labels, did, opts); 22 | decideMutedPermanentModeration(accu, !!view.viewer?.muted); 23 | decideMutedTemporaryModeration(accu, did, opts); 24 | 25 | return accu; 26 | }; 27 | -------------------------------------------------------------------------------- /app/api/moderation/entities/post.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type ModerationCause, 3 | type ModerationOptions, 4 | PreferenceWarn, 5 | TargetContent, 6 | decideLabelModeration, 7 | decideMutedKeywordModeration, 8 | } from '..'; 9 | import type { SignalizedPost } from '../../stores/posts'; 10 | import { unwrapPostEmbedText } from '../../utils/post'; 11 | 12 | import { moderateProfile } from './profile'; 13 | 14 | export const moderatePost = (post: SignalizedPost, opts: ModerationOptions) => { 15 | const author = post.author; 16 | 17 | const labels = post.labels.value; 18 | const isFollowing = author.viewer.following.value; 19 | 20 | const accu: ModerationCause[] = []; 21 | const did = author.did; 22 | 23 | const record = post.record.peek(); 24 | const text = record.text + unwrapPostEmbedText(record.embed); 25 | 26 | decideLabelModeration(accu, TargetContent, labels, did, opts); 27 | decideMutedKeywordModeration(accu, text, !!isFollowing, PreferenceWarn, opts); 28 | 29 | return accu.concat(moderateProfile(author, opts)); 30 | }; 31 | -------------------------------------------------------------------------------- /app/api/moderation/entities/profile.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type ModerationCause, 3 | type ModerationOptions, 4 | TargetAccount, 5 | TargetProfile, 6 | decideLabelModeration, 7 | decideMutedPermanentModeration, 8 | decideMutedTemporaryModeration, 9 | } from '..'; 10 | import type { SignalizedProfile } from '../../stores/profiles'; 11 | 12 | export const moderateProfile = (profile: SignalizedProfile, opts: ModerationOptions) => { 13 | const viewer = profile.viewer; 14 | 15 | const labels = profile.labels.value; 16 | const isMuted = viewer.muted.value; 17 | 18 | const accu: ModerationCause[] = []; 19 | const did = profile.did; 20 | 21 | const profileLabels = labels.filter((label) => label.uri.endsWith('/app.bsky.actor.profile/self')); 22 | const accountLabels = labels.filter((label) => !label.uri.endsWith('/app.bsky.actor.profile/self')); 23 | 24 | decideLabelModeration(accu, TargetProfile, profileLabels, did, opts); 25 | decideLabelModeration(accu, TargetAccount, accountLabels, did, opts); 26 | decideMutedPermanentModeration(accu, isMuted); 27 | decideMutedTemporaryModeration(accu, did, opts); 28 | 29 | return accu; 30 | }; 31 | -------------------------------------------------------------------------------- /app/api/moderation/entities/quote.ts: -------------------------------------------------------------------------------- 1 | import type { AppBskyEmbedRecord, AppBskyFeedPost } from '@atcute/client/lexicons'; 2 | 3 | import { 4 | type ModerationCause, 5 | type ModerationOptions, 6 | PreferenceWarn, 7 | TargetContent, 8 | decideLabelModeration, 9 | decideMutedKeywordModeration, 10 | } from '..'; 11 | import { unwrapPostEmbedText } from '../../utils/post'; 12 | 13 | import { decideBasicProfile } from './basic-profile'; 14 | 15 | export const decideQuote = (quote: AppBskyEmbedRecord.ViewRecord, opts: ModerationOptions) => { 16 | const accu: ModerationCause[] = decideBasicProfile(quote.author, opts); 17 | 18 | const author = quote.author; 19 | 20 | const record = quote.value as AppBskyFeedPost.Record; 21 | const text = record.text + unwrapPostEmbedText(quote.embeds?.[0]); 22 | 23 | decideLabelModeration(accu, TargetContent, quote.labels, author.did, opts); 24 | decideMutedKeywordModeration(accu, text, !!author.viewer?.following, PreferenceWarn, opts); 25 | 26 | return accu; 27 | }; 28 | -------------------------------------------------------------------------------- /app/api/moderation/utils.ts: -------------------------------------------------------------------------------- 1 | import type { SignalizedProfile } from '../stores/profiles'; 2 | 3 | import { ContextProfileList, type ModerationOptions, getModerationUI } from '.'; 4 | import { moderateProfile } from './entities/profile'; 5 | 6 | export const moderateProfileList = ( 7 | profiles: SignalizedProfile[], 8 | opts: ModerationOptions | undefined, 9 | ): SignalizedProfile[] => { 10 | if (opts) { 11 | return profiles.filter((profile) => { 12 | const causes = moderateProfile(profile, opts); 13 | const ui = getModerationUI(causes, ContextProfileList); 14 | 15 | return ui.f.length === 0; 16 | }); 17 | } 18 | 19 | return profiles; 20 | }; 21 | -------------------------------------------------------------------------------- /app/api/mutations/update-notifications-seen.ts: -------------------------------------------------------------------------------- 1 | import type { At } from '@atcute/client/lexicons'; 2 | 3 | import { multiagent } from '../globals/agent'; 4 | 5 | export const updateNotificationsSeen = async (uid: At.DID, date = new Date()) => { 6 | const agent = await multiagent.connect(uid); 7 | 8 | await agent.rpc.call('app.bsky.notification.updateSeen', { 9 | data: { seenAt: date.toISOString() }, 10 | }); 11 | }; 12 | -------------------------------------------------------------------------------- /app/api/mutations/upload-blob.ts: -------------------------------------------------------------------------------- 1 | import type { At } from '@atcute/client/lexicons'; 2 | 3 | import { multiagent } from '../globals/agent'; 4 | 5 | interface BlobMetadata { 6 | u: At.DID; 7 | b: At.Blob; 8 | } 9 | 10 | const cache = new WeakMap(); 11 | 12 | export const uploadBlob = async (uid: At.DID, blob: Blob): Promise> => { 13 | let meta = cache.get(blob); 14 | 15 | if (!meta || meta.u !== uid) { 16 | const agent = await multiagent.connect(uid); 17 | 18 | const response = await agent.rpc.call('com.atproto.repo.uploadBlob', { data: blob }); 19 | 20 | cache.set(blob, (meta = { u: uid, b: response.data.blob })); 21 | } 22 | 23 | return meta.b as At.Blob; 24 | }; 25 | 26 | export const getUploadedBlob = ( 27 | uid: At.DID, 28 | blob: Blob, 29 | ): At.Blob | undefined => { 30 | const meta = cache.get(blob); 31 | return meta && meta.u === uid ? (meta.b as At.Blob) : undefined; 32 | }; 33 | -------------------------------------------------------------------------------- /app/api/mutations/upsert-profile.ts: -------------------------------------------------------------------------------- 1 | import { XRPCError } from '@atcute/client'; 2 | import type { AppBskyActorProfile, At } from '@atcute/client/lexicons'; 3 | 4 | import { multiagent } from '../globals/agent'; 5 | 6 | export const upsertProfile = async ( 7 | uid: At.DID, 8 | updater: (existing: AppBskyActorProfile.Record | undefined) => AppBskyActorProfile.Record, 9 | ) => { 10 | const agent = await multiagent.connect(uid); 11 | 12 | const existing = await agent.rpc 13 | .get('com.atproto.repo.getRecord', { 14 | params: { 15 | repo: uid, 16 | collection: 'app.bsky.actor.profile', 17 | rkey: 'self', 18 | }, 19 | }) 20 | .catch((err) => { 21 | if (err instanceof XRPCError) { 22 | if (err.kind === 'InvalidRequest') { 23 | return undefined; 24 | } 25 | } 26 | 27 | return Promise.reject(err); 28 | }); 29 | 30 | const updated = updater(existing?.data.value as AppBskyActorProfile.Record | undefined); 31 | 32 | await agent.rpc.call('com.atproto.repo.putRecord', { 33 | data: { 34 | repo: uid, 35 | collection: 'app.bsky.actor.profile', 36 | rkey: 'self', 37 | record: updated, 38 | swapRecord: existing?.data.cid, 39 | }, 40 | }); 41 | }; 42 | -------------------------------------------------------------------------------- /app/api/queries/_did.ts: -------------------------------------------------------------------------------- 1 | import type { XRPC } from '@atcute/client'; 2 | import type { At } from '@atcute/client/lexicons'; 3 | 4 | import { isDid } from '../utils/misc'; 5 | 6 | const _getDid = async (rpc: XRPC, actor: string, signal?: AbortSignal) => { 7 | let did: At.DID; 8 | if (isDid(actor)) { 9 | did = actor; 10 | } else { 11 | const response = await rpc.get('com.atproto.identity.resolveHandle', { 12 | signal: signal, 13 | params: { handle: actor }, 14 | }); 15 | 16 | did = response.data.did; 17 | } 18 | 19 | return did; 20 | }; 21 | 22 | export default _getDid; 23 | -------------------------------------------------------------------------------- /app/api/queries/get-labeler-info.ts: -------------------------------------------------------------------------------- 1 | import { XRPCError } from '@atcute/client'; 2 | import type { AppBskyLabelerDefs, At } from '@atcute/client/lexicons'; 3 | import type { QueryFunctionContext as QC } from '@mary/solid-query'; 4 | 5 | import { publicAppView } from '../globals/agent'; 6 | import { interpretServiceDefinition } from '../moderation/service'; 7 | 8 | export const getLabelerInfoKey = (did: At.DID) => { 9 | return ['/getLabelerInfo', did] as const; 10 | }; 11 | export const getLabelerInfo = async (ctx: QC>) => { 12 | const [, did] = ctx.queryKey; 13 | 14 | const response = await publicAppView.get('app.bsky.labeler.getServices', { 15 | signal: ctx.signal, 16 | params: { 17 | dids: [did], 18 | detailed: true, 19 | }, 20 | }); 21 | 22 | const result = response.data.views[0] as AppBskyLabelerDefs.LabelerViewDetailed; 23 | 24 | if (!result) { 25 | throw new XRPCError(400, { kind: 'NotFound', description: `Service not found: ${did}` }); 26 | } 27 | 28 | return interpretServiceDefinition(result); 29 | }; 30 | -------------------------------------------------------------------------------- /app/api/queries/get-likes.ts: -------------------------------------------------------------------------------- 1 | import type { At } from '@atcute/client/lexicons'; 2 | import type { QueryFunctionContext as QC } from '@mary/solid-query'; 3 | 4 | import { multiagent } from '../globals/agent'; 5 | import { moderateProfileList } from '../moderation/utils'; 6 | import { mergeProfile } from '../stores/profiles'; 7 | 8 | export const getLikesKey = (uid: At.DID, uri: string, limit = 25) => { 9 | return ['getLikes', uid, uri, limit] as const; 10 | }; 11 | export const getLikes = async (ctx: QC, string | undefined>) => { 12 | const [, uid, uri, limit] = ctx.queryKey; 13 | 14 | const agent = await multiagent.connect(uid); 15 | 16 | const response = await agent.rpc.get('app.bsky.feed.getLikes', { 17 | signal: ctx.signal, 18 | params: { 19 | uri: uri, 20 | limit: limit, 21 | cursor: ctx.pageParam, 22 | }, 23 | }); 24 | 25 | const data = response.data; 26 | 27 | return { 28 | cursor: data.cursor, 29 | profiles: moderateProfileList( 30 | data.likes.map((like) => mergeProfile(uid, like.actor)), 31 | ctx.meta?.moderation, 32 | ), 33 | }; 34 | }; 35 | -------------------------------------------------------------------------------- /app/api/queries/get-list-info.ts: -------------------------------------------------------------------------------- 1 | import type { At } from '@atcute/client/lexicons'; 2 | import type { QueryFunctionContext as QC } from '@mary/solid-query'; 3 | 4 | import { multiagent } from '../globals/agent'; 5 | import { getCachedList, mergeList } from '../stores/lists'; 6 | 7 | export const getListInfoKey = (uid: At.DID, uri: string) => { 8 | return ['getListInfo', uid, uri] as const; 9 | }; 10 | export const getListInfo = async (ctx: QC>) => { 11 | const [, uid, uri] = ctx.queryKey; 12 | 13 | const agent = await multiagent.connect(uid); 14 | 15 | const response = await agent.rpc.get('app.bsky.graph.getList', { 16 | signal: ctx.signal, 17 | params: { 18 | list: uri, 19 | limit: 1, 20 | }, 21 | }); 22 | 23 | const data = response.data; 24 | return mergeList(uid, data.list); 25 | }; 26 | 27 | export const getInitialListInfo = (key: ReturnType) => { 28 | const [, uid, uri] = key; 29 | 30 | const list = getCachedList(uid, uri); 31 | 32 | return list; 33 | }; 34 | -------------------------------------------------------------------------------- /app/api/queries/get-list-members.ts: -------------------------------------------------------------------------------- 1 | import type { At } from '@atcute/client/lexicons'; 2 | import type { QueryFunctionContext as QC } from '@mary/solid-query'; 3 | 4 | import { multiagent } from '../globals/agent'; 5 | import { type SignalizedProfile, mergeProfile } from '../stores/profiles'; 6 | 7 | export interface ListMember { 8 | uri: string; 9 | profile: SignalizedProfile; 10 | } 11 | 12 | export interface ListMembersPage { 13 | cursor: string | undefined; 14 | members: Array; 15 | } 16 | 17 | export const getListMembersKey = (uid: At.DID, uri: string, limit = 25) => { 18 | return ['getListMembers', uid, uri, limit] as const; 19 | }; 20 | export const getListMembers = async (ctx: QC, string | undefined>) => { 21 | const [, uid, uri, limit] = ctx.queryKey; 22 | 23 | const agent = await multiagent.connect(uid); 24 | 25 | const response = await agent.rpc.get('app.bsky.graph.getList', { 26 | signal: ctx.signal, 27 | params: { 28 | list: uri, 29 | limit: limit, 30 | cursor: ctx.pageParam, 31 | }, 32 | }); 33 | 34 | const data = response.data; 35 | 36 | const page: ListMembersPage = { 37 | cursor: data.cursor, 38 | members: data.items.map((item) => ({ 39 | uri: item.uri, 40 | profile: mergeProfile(uid, item.subject), 41 | })), 42 | }; 43 | 44 | return page; 45 | }; 46 | -------------------------------------------------------------------------------- /app/api/queries/get-post-reposts.ts: -------------------------------------------------------------------------------- 1 | import type { At } from '@atcute/client/lexicons'; 2 | import type { QueryFunctionContext as QC } from '@mary/solid-query'; 3 | 4 | import { multiagent } from '../globals/agent'; 5 | import { moderateProfileList } from '../moderation/utils'; 6 | import { mergeProfile } from '../stores/profiles'; 7 | 8 | export const getPostRepostsKey = (uid: At.DID, uri: string, limit = 25) => { 9 | return ['getPostReposts', uid, uri, limit] as const; 10 | }; 11 | export const getPostReposts = async (ctx: QC, string | undefined>) => { 12 | const [, uid, uri, limit] = ctx.queryKey; 13 | 14 | const agent = await multiagent.connect(uid); 15 | 16 | const response = await agent.rpc.get('app.bsky.feed.getRepostedBy', { 17 | signal: ctx.signal, 18 | params: { 19 | uri: uri, 20 | limit: limit, 21 | cursor: ctx.pageParam, 22 | }, 23 | }); 24 | 25 | const data = response.data; 26 | 27 | return { 28 | cursor: data.cursor, 29 | profiles: moderateProfileList( 30 | data.repostedBy.map((actor) => mergeProfile(uid, actor)), 31 | ctx.meta?.moderation, 32 | ), 33 | }; 34 | }; 35 | -------------------------------------------------------------------------------- /app/api/queries/get-profile-feeds.ts: -------------------------------------------------------------------------------- 1 | import type { At } from '@atcute/client/lexicons'; 2 | import type { QueryFunctionContext as QC } from '@mary/solid-query'; 3 | 4 | import { multiagent } from '../globals/agent'; 5 | import { mergeFeed } from '../stores/feeds'; 6 | 7 | export const getProfileFeedsKey = (uid: At.DID, actor: string, limit: number = 30) => { 8 | return ['getProfileFeeds', uid, actor, limit] as const; 9 | }; 10 | export const getProfileFeeds = async (ctx: QC, string | undefined>) => { 11 | const [, uid, actor, limit] = ctx.queryKey; 12 | 13 | const agent = await multiagent.connect(uid); 14 | 15 | const response = await agent.rpc.get('app.bsky.feed.getActorFeeds', { 16 | signal: ctx.signal, 17 | params: { 18 | actor: actor, 19 | limit: limit, 20 | cursor: ctx.pageParam, 21 | }, 22 | }); 23 | 24 | const data = response.data; 25 | 26 | return { 27 | cursor: data.cursor, 28 | feeds: data.feeds.map((feed) => mergeFeed(uid, feed)), 29 | }; 30 | }; 31 | -------------------------------------------------------------------------------- /app/api/queries/get-profile-lists.ts: -------------------------------------------------------------------------------- 1 | import type { At } from '@atcute/client/lexicons'; 2 | import type { QueryFunctionContext as QC } from '@mary/solid-query'; 3 | 4 | import { multiagent } from '../globals/agent'; 5 | import { mergeList } from '../stores/lists'; 6 | 7 | export const getProfileListsKey = (uid: At.DID, actor: string, limit: number = 30) => { 8 | return ['getProfileLists', uid, actor, limit] as const; 9 | }; 10 | export const getProfileLists = async (ctx: QC, string | undefined>) => { 11 | const [, uid, actor, limit] = ctx.queryKey; 12 | 13 | const agent = await multiagent.connect(uid); 14 | 15 | const response = await agent.rpc.get('app.bsky.graph.getLists', { 16 | signal: ctx.signal, 17 | params: { 18 | actor: actor, 19 | limit: limit, 20 | cursor: ctx.pageParam, 21 | }, 22 | }); 23 | 24 | const data = response.data; 25 | 26 | return { 27 | cursor: data.cursor, 28 | lists: data.lists.map((list) => mergeList(uid, list)), 29 | }; 30 | }; 31 | -------------------------------------------------------------------------------- /app/api/queries/get-resolved-handle.ts: -------------------------------------------------------------------------------- 1 | import type { At } from '@atcute/client/lexicons'; 2 | import type { QueryFunctionContext as QC } from '@mary/solid-query'; 3 | 4 | import { multiagent } from '../globals/agent'; 5 | import { isDid } from '../utils/misc'; 6 | 7 | export const getResolvedHandleKey = (uid: At.DID, actor: string) => { 8 | return ['getResolvedHandle', uid, actor] as const; 9 | }; 10 | export const getResolvedHandle = async (ctx: QC>) => { 11 | const [, uid, actor] = ctx.queryKey; 12 | 13 | if (isDid(actor)) { 14 | return { did: actor }; 15 | } 16 | 17 | const agent = await multiagent.connect(uid); 18 | 19 | const response = await agent.rpc.get('com.atproto.identity.resolveHandle', { 20 | signal: ctx.signal, 21 | params: { 22 | handle: actor, 23 | }, 24 | }); 25 | 26 | return response.data; 27 | }; 28 | -------------------------------------------------------------------------------- /app/api/queries/get-suggested-follows.ts: -------------------------------------------------------------------------------- 1 | import type { At } from '@atcute/client/lexicons'; 2 | import type { QueryFunctionContext as QC } from '@mary/solid-query'; 3 | 4 | import { multiagent } from '../globals/agent'; 5 | import { mergeProfile } from '../stores/profiles'; 6 | 7 | export const getSuggestedFollowsKey = (uid: At.DID, limit = 30) => { 8 | return ['getSuggestedFollows', uid, limit] as const; 9 | }; 10 | export const getSuggestedFollows = async ( 11 | ctx: QC, string | undefined>, 12 | ) => { 13 | const [, uid, limit] = ctx.queryKey; 14 | 15 | const agent = await multiagent.connect(uid); 16 | 17 | const response = await agent.rpc.get('app.bsky.actor.getSuggestions', { 18 | signal: ctx.signal, 19 | params: { 20 | cursor: ctx.pageParam, 21 | limit: limit, 22 | }, 23 | }); 24 | 25 | const data = response.data; 26 | const profiles = data.actors; 27 | 28 | return { 29 | cursor: data.cursor, 30 | profiles: profiles.map((profile) => mergeProfile(uid, profile)), 31 | }; 32 | }; 33 | -------------------------------------------------------------------------------- /app/api/queries/get-translation.ts: -------------------------------------------------------------------------------- 1 | import type { QueryFunctionContext as QC } from '@mary/solid-query'; 2 | 3 | const BASE_URL = 'https://translate.googleapis.com/translate_a/single?client=gtx&dt=t&dj=1&source=input'; 4 | 5 | export const getTranslationKey = (source: string, target: string, text: string) => { 6 | return ['/getTranslation', source, target, text]; 7 | }; 8 | export const getTranslation = async (ctx: QC>) => { 9 | const [, source, target, text] = ctx.queryKey; 10 | 11 | const url = new URL(BASE_URL); 12 | const searchParams = url.searchParams; 13 | 14 | searchParams.set('sl', source); 15 | searchParams.set('tl', target); 16 | searchParams.set('q', text); 17 | 18 | const response = await fetch(url, { signal: ctx.signal }); 19 | if (!response.ok) { 20 | throw new Error(`Response error ${response.status}`); 21 | } 22 | 23 | const body: any = await response.json(); 24 | 25 | return { 26 | result: body.sentences.map((n: any) => (n && n.trans) || '').join('') as string, 27 | sources: body.ld_result.srclangs as string[], 28 | raw: body, 29 | }; 30 | }; 31 | -------------------------------------------------------------------------------- /app/api/queries/search-profiles-typeahead.ts: -------------------------------------------------------------------------------- 1 | import type { At } from '@atcute/client/lexicons'; 2 | import type { QueryFunctionContext as QC } from '@mary/solid-query'; 3 | 4 | import { multiagent } from '../globals/agent'; 5 | import { moderateProfileList } from '../moderation/utils'; 6 | import { mergeProfile } from '../stores/profiles'; 7 | 8 | export const searchProfilesTypeaheadKey = (uid: At.DID, query: string, limit = 30) => { 9 | return ['searchProfilesTypeahead', uid, query, limit] as const; 10 | }; 11 | export const searchProfilesTypeahead = async (ctx: QC>) => { 12 | const [, uid, query, limit] = ctx.queryKey; 13 | 14 | if (query === '' || query.includes(':') || query.includes('"')) { 15 | return []; 16 | } 17 | 18 | const agent = await multiagent.connect(uid); 19 | 20 | const response = await agent.rpc.get('app.bsky.actor.searchActorsTypeahead', { 21 | signal: ctx.signal, 22 | params: { 23 | q: query, 24 | limit: limit, 25 | }, 26 | }); 27 | 28 | const data = response.data; 29 | 30 | return moderateProfileList( 31 | data.actors.map((actor) => mergeProfile(uid, actor)), 32 | ctx.meta?.moderation, 33 | ); 34 | }; 35 | -------------------------------------------------------------------------------- /app/api/queries/search-profiles.ts: -------------------------------------------------------------------------------- 1 | import type { At } from '@atcute/client/lexicons'; 2 | import type { QueryFunctionContext as QC } from '@mary/solid-query'; 3 | 4 | import { multiagent } from '../globals/agent'; 5 | import { mergeProfile } from '../stores/profiles'; 6 | 7 | export const searchProfilesKey = (uid: At.DID, query: string, limit = 30) => { 8 | return ['searchProfiles', uid, query, limit] as const; 9 | }; 10 | export const searchProfiles = async (ctx: QC, string | undefined>) => { 11 | const [, uid, query, limit] = ctx.queryKey; 12 | 13 | const agent = await multiagent.connect(uid); 14 | 15 | const response = await agent.rpc.get('app.bsky.actor.searchActors', { 16 | signal: ctx.signal, 17 | params: { 18 | q: query, 19 | limit: limit, 20 | cursor: ctx.pageParam, 21 | }, 22 | }); 23 | 24 | const data = response.data; 25 | const profiles = data.actors; 26 | 27 | return { 28 | cursor: data.cursor, 29 | profiles: profiles.map((profile) => mergeProfile(uid, profile)), 30 | }; 31 | }; 32 | -------------------------------------------------------------------------------- /app/api/richtext/intl.ts: -------------------------------------------------------------------------------- 1 | var _graphemeLen: (text: string) => number; 2 | 3 | export const graphemeLen = (text: string) => { 4 | var length = asciiLen(text); 5 | 6 | if (length === undefined) { 7 | return _graphemeLen(text); 8 | } 9 | 10 | return length; 11 | }; 12 | 13 | export const asciiLen = (str: string) => { 14 | for (var idx = 0, len = str.length; idx < len; idx++) { 15 | const char = str.charCodeAt(idx); 16 | 17 | if (char > 127) { 18 | return undefined; 19 | } 20 | } 21 | 22 | return len; 23 | }; 24 | 25 | if (Intl.Segmenter) { 26 | var segmenter = new Intl.Segmenter(); 27 | 28 | _graphemeLen = (text) => { 29 | var iterator = segmenter.segment(text)[Symbol.iterator](); 30 | var count = 0; 31 | 32 | while (!iterator.next().done) { 33 | count++; 34 | } 35 | 36 | return count; 37 | }; 38 | } else { 39 | console.log('Intl.Segmenter API not available, falling back to polyfill...'); 40 | 41 | var { countGraphemes } = await import('./unicode-segmenter.ts'); 42 | _graphemeLen = countGraphemes; 43 | } 44 | -------------------------------------------------------------------------------- /app/api/richtext/types.ts: -------------------------------------------------------------------------------- 1 | import type { AppBskyRichtextFacet } from '@atcute/client/lexicons'; 2 | 3 | type UnwrapArray = T extends (infer V)[] ? V : never; 4 | 5 | export type LinkFeature = AppBskyRichtextFacet.Link; 6 | export type MentionFeature = AppBskyRichtextFacet.Mention; 7 | export type TagFeature = AppBskyRichtextFacet.Tag; 8 | 9 | export type Facet = AppBskyRichtextFacet.Main; 10 | export type FacetFeature = UnwrapArray; 11 | -------------------------------------------------------------------------------- /app/api/richtext/unicode.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Javascript uses utf16-encoded strings while most environments and specs 3 | * have standardized around utf8 (including JSON). 4 | * 5 | * After some lengthy debated we decided that richtext facets need to use 6 | * utf8 indices. This means we need tools to convert indices between utf8 7 | * and utf16, and that's precisely what this library handles. 8 | */ 9 | 10 | const encoder = new TextEncoder(); 11 | const decoder = new TextDecoder(); 12 | 13 | export interface UtfString { 14 | u16: string; 15 | u8: Uint8Array; 16 | } 17 | 18 | export const createUtfString = (utf16: string): UtfString => { 19 | return { 20 | u16: utf16, 21 | u8: encoder.encode(utf16), 22 | }; 23 | }; 24 | 25 | export const getUtf8Length = (utf: UtfString) => { 26 | return utf.u8.byteLength; 27 | }; 28 | 29 | export const sliceUtf8 = (utf: UtfString, start?: number, end?: number) => { 30 | return decoder.decode(utf.u8.slice(start, end)); 31 | }; 32 | -------------------------------------------------------------------------------- /app/api/richtext/utils.ts: -------------------------------------------------------------------------------- 1 | import { isLinkValid } from './renderer'; 2 | import { segmentRichText } from './segmentize'; 3 | import type { Facet } from './types'; 4 | 5 | const MDLINK_ESCAPE_RE = /([\\\]])/g; 6 | const ESCAPE_RE = /([@#\[\\])/g; 7 | 8 | export const serializeRichText = (text: string, facets: Facet[] | undefined, loose: boolean) => { 9 | const segments = segmentRichText(text, facets); 10 | 11 | let result = ''; 12 | 13 | for (let idx = 0, len = segments.length; idx < len; idx++) { 14 | const segment = segments[idx]; 15 | const text = segment.text; 16 | const feature = segment.feature; 17 | 18 | let substitute: string | undefined; 19 | 20 | if (feature) { 21 | const type = feature.$type; 22 | 23 | if (type === 'app.bsky.richtext.facet#link') { 24 | const uri = feature.uri; 25 | substitute = isLinkValid(uri, text) ? uri : `[${text.replace(MDLINK_ESCAPE_RE, '\\$1')}](${uri})`; 26 | } else if (type === 'app.bsky.richtext.facet#mention' || type === 'app.bsky.richtext.facet#tag') { 27 | substitute = text; 28 | } 29 | } else if (loose) { 30 | substitute = text; 31 | } 32 | 33 | if (substitute !== undefined) { 34 | result += substitute; 35 | } else { 36 | result += text.replace(ESCAPE_RE, '\\$1'); 37 | } 38 | } 39 | 40 | return result; 41 | }; 42 | -------------------------------------------------------------------------------- /app/api/types.ts: -------------------------------------------------------------------------------- 1 | export interface LanguagePreferences { 2 | /** Allow posts with these languages */ 3 | languages: string[]; 4 | /** Allow posts that matches the system's preferred languages */ 5 | useSystemLanguages: boolean; 6 | /** Default language to use when composing a new post */ 7 | defaultPostLanguage: 'none' | 'system' | (string & {}); 8 | /** Show posts that do not explicitly specify a language */ 9 | allowUnspecified: boolean; 10 | } 11 | 12 | export interface TranslationPreferences { 13 | /** Preferred language to translate posts into */ 14 | to: string; 15 | /** Do not offer the option to translate on these languages */ 16 | exclusions: string[]; 17 | } 18 | -------------------------------------------------------------------------------- /app/api/updaters/timeline-filter.ts: -------------------------------------------------------------------------------- 1 | import type { At } from '@atcute/client/lexicons'; 2 | import { type InfiniteData } from '@mary/solid-query'; 3 | 4 | import type { TimelineSlice } from '~/api/models/timeline'; 5 | import type { TimelinePage } from '~/api/queries/get-timeline'; 6 | 7 | import { produce } from '~/utils/immer'; 8 | 9 | export const produceTimelineFilter = (did: At.DID) => { 10 | const isSliceMatching = (slice: TimelineSlice) => { 11 | const items = slice.items; 12 | 13 | for (let k = items.length - 1; k >= 0; k--) { 14 | const item = items[k]; 15 | 16 | if (item.reason?.$type == 'app.bsky.feed.defs#reasonRepost') { 17 | if (item.reason.by.did === did) { 18 | return true; 19 | } 20 | } 21 | 22 | if (item.post.author.did === did) { 23 | return true; 24 | } 25 | } 26 | 27 | return false; 28 | }; 29 | 30 | const updateTimeline = produce((draft: InfiniteData) => { 31 | const pages = draft.pages; 32 | 33 | for (let i = 0, il = pages.length; i < il; i++) { 34 | const page = pages[i]; 35 | const slices = page.slices; 36 | 37 | for (let j = slices.length - 1; j >= 0; j--) { 38 | const slice = slices[j]; 39 | 40 | if (isSliceMatching(slice)) { 41 | slices.splice(j, 1); 42 | } 43 | } 44 | } 45 | }); 46 | 47 | return updateTimeline; 48 | }; 49 | -------------------------------------------------------------------------------- /app/api/utils/links.ts: -------------------------------------------------------------------------------- 1 | export const BSKY_PROFILE_URL_RE = /\/profile\/([^\/]+)$/; 2 | export const BSKY_POST_URL_RE = /\/profile\/([^\/]+)\/post\/([^\/]+)$/; 3 | export const BSKY_FEED_URL_RE = /\/profile\/([^\/]+)\/feed\/([^\/]+)$/; 4 | export const BSKY_LIST_URL_RE = /\/profile\/([^\/]+)\/lists\/([^\/]+)$/; 5 | 6 | export const isBskyUrl = (url: string) => { 7 | try { 8 | const urli = new URL(url); 9 | const host = urli.host; 10 | 11 | return host === 'bsky.app' || host === 'staging.bsky.app'; 12 | } catch {} 13 | 14 | return false; 15 | }; 16 | 17 | export interface ExtractedAppLink { 18 | type: string; 19 | author: string; 20 | rkey: string; 21 | } 22 | 23 | export const extractAppLink = (url: string): ExtractedAppLink | null => { 24 | let match: RegExpExecArray | null; 25 | 26 | if (isBskyUrl(url)) { 27 | if ((match = BSKY_POST_URL_RE.exec(url))) { 28 | return { type: 'app.bsky.feed.post', author: match[1], rkey: match[2] }; 29 | } 30 | 31 | if ((match = BSKY_FEED_URL_RE.exec(url))) { 32 | return { type: 'app.bsky.feed.generator', author: match[1], rkey: match[2] }; 33 | } 34 | 35 | if ((match = BSKY_LIST_URL_RE.exec(url))) { 36 | return { type: 'app.bsky.graph.list', author: match[1], rkey: match[2] }; 37 | } 38 | } 39 | 40 | return null; 41 | }; 42 | -------------------------------------------------------------------------------- /app/api/utils/query.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type InfiniteData, 3 | type QueryClient, 4 | type QueryFunctionContext, 5 | type QueryKey, 6 | } from '@mary/solid-query'; 7 | 8 | export const resetInfiniteData = (client: QueryClient, key: QueryKey) => { 9 | client.setQueryData>(key, (data) => { 10 | if (data && data.pages.length > 1) { 11 | return { 12 | pages: data.pages.slice(0, 1), 13 | pageParams: data.pageParams.slice(0, 1), 14 | }; 15 | } 16 | 17 | return data; 18 | }); 19 | }; 20 | 21 | const errorMap = new WeakMap(); 22 | 23 | export const wrapInfiniteQuery = , R>( 24 | fn: (ctx: C) => Promise, 25 | ) => { 26 | return async (ctx: C): Promise => { 27 | try { 28 | return await fn(ctx); 29 | } catch (err) { 30 | errorMap.set(err as any, { pageParam: ctx.pageParam, direction: ctx.direction }); 31 | throw err; 32 | } 33 | }; 34 | }; 35 | 36 | export const getQueryErrorInfo = (err: unknown) => { 37 | const info = errorMap.get(err as any); 38 | return info; 39 | }; 40 | -------------------------------------------------------------------------------- /app/com/assets/banner.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mary-ext/skeetdeck/750307ae2b9ff13fce655c1960601876618ba6d2/app/com/assets/banner.jpg -------------------------------------------------------------------------------- /app/com/assets/default-feed-avatar.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /app/com/assets/default-labeler-avatar.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /app/com/assets/default-list-avatar.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /app/com/assets/default-user-avatar.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /app/com/components/BlobImage.tsx: -------------------------------------------------------------------------------- 1 | import { type ComponentProps, onCleanup } from 'solid-js'; 2 | 3 | export interface BlobImageProps extends Omit, 'src'> { 4 | src: Blob | string | undefined; 5 | } 6 | 7 | interface BlobObject { 8 | u: string; 9 | c: number; 10 | } 11 | 12 | const map = new WeakMap(); 13 | 14 | export const getBlobSrc = (src: Blob | string | undefined) => { 15 | if (!(src instanceof Blob)) { 16 | return src; 17 | } 18 | 19 | let obj = map.get(src)!; 20 | if (!obj) { 21 | map.set(src, (obj = { u: URL.createObjectURL(src), c: 0 })); 22 | } 23 | 24 | obj.c++; 25 | 26 | onCleanup(() => { 27 | if (--obj.c < 1) { 28 | URL.revokeObjectURL(obj.u); 29 | map.delete(src); 30 | } 31 | }); 32 | 33 | return obj.u; 34 | }; 35 | 36 | const BlobImage = (props: BlobImageProps) => { 37 | return ; 38 | }; 39 | 40 | export default BlobImage; 41 | -------------------------------------------------------------------------------- /app/com/components/CircularProgress.tsx: -------------------------------------------------------------------------------- 1 | export interface CircularProgressProps { 2 | size?: number; 3 | } 4 | 5 | const CircularProgress = (props: CircularProgressProps) => { 6 | return ( 7 | 12 | 13 | 23 | 24 | ); 25 | }; 26 | 27 | export default CircularProgress; 28 | -------------------------------------------------------------------------------- /app/com/components/Keyed.ts: -------------------------------------------------------------------------------- 1 | import { type JSX, createMemo, untrack } from 'solid-js'; 2 | 3 | export interface KeyedProps { 4 | key: unknown; 5 | children: JSX.Element; 6 | } 7 | 8 | const Keyed = (props: KeyedProps) => { 9 | const key = createMemo(() => props.key); 10 | 11 | return (() => { 12 | key(); 13 | return untrack(() => props.children); 14 | }) as unknown as JSX.Element; 15 | }; 16 | 17 | export default Keyed; 18 | -------------------------------------------------------------------------------- /app/com/components/Tab.tsx: -------------------------------------------------------------------------------- 1 | import type { ComponentProps, JSX } from 'solid-js'; 2 | 3 | import { clsx } from '~/utils/misc'; 4 | 5 | type ButtonProps = ComponentProps<'button'>; 6 | 7 | export interface TabProps { 8 | active?: boolean; 9 | onClick?: ButtonProps['onClick']; 10 | children?: JSX.Element; 11 | } 12 | 13 | const Tab = (props: TabProps) => { 14 | return ( 15 | 27 | ); 28 | }; 29 | 30 | export default Tab; 31 | -------------------------------------------------------------------------------- /app/com/components/dialogs/BlockConfirmDialog.tsx: -------------------------------------------------------------------------------- 1 | import type { At } from '@atcute/client/lexicons'; 2 | 3 | import { closeModal } from '../../globals/modals'; 4 | import { Button } from '../../primitives/button'; 5 | import { DialogActions, DialogHeader, DialogRoot, DialogTitle } from '../../primitives/dialog'; 6 | 7 | import DialogOverlay from './DialogOverlay'; 8 | 9 | export interface BlockConfirmDialogProps { 10 | /** Expected to be static */ 11 | uid: At.DID; 12 | /** Expected to be static */ 13 | did: At.DID; 14 | } 15 | 16 | const BlockConfirmDialog = ({}: BlockConfirmDialogProps) => { 17 | return ( 18 | 19 |
20 |
21 |

Unimplemented

22 |
23 | 24 |
25 | 28 |
29 |
30 |
31 | ); 32 | }; 33 | 34 | export default BlockConfirmDialog; 35 | -------------------------------------------------------------------------------- /app/com/components/dialogs/DialogOverlay.tsx: -------------------------------------------------------------------------------- 1 | import type { JSX } from 'solid-js'; 2 | 3 | export interface DialogOverlayProps { 4 | children: JSX.Element; 5 | } 6 | 7 | const isDesktop = import.meta.env.VITE_MODE === 'desktop'; 8 | 9 | const DialogOverlay = (props: DialogOverlayProps) => { 10 | return ( 11 |
17 |
{props.children}
18 |
19 | ); 20 | }; 21 | 22 | export default DialogOverlay; 23 | -------------------------------------------------------------------------------- /app/com/components/dialogs/MuteConfirmDialog.tsx: -------------------------------------------------------------------------------- 1 | import type { At } from '@atcute/client/lexicons'; 2 | 3 | import { closeModal } from '../../globals/modals'; 4 | import { Button } from '../../primitives/button'; 5 | import { DialogActions, DialogHeader, DialogRoot, DialogTitle } from '../../primitives/dialog'; 6 | 7 | import DialogOverlay from './DialogOverlay'; 8 | 9 | export interface MuteConfirmDialogProps { 10 | /** Expected to be static */ 11 | uid: At.DID; 12 | /** Expected to be static */ 13 | did: At.DID; 14 | } 15 | 16 | const MuteConfirmDialog = ({}: MuteConfirmDialogProps) => { 17 | return ( 18 | 19 |
20 |
21 |

Unimplemented

22 |
23 | 24 |
25 | 28 |
29 |
30 |
31 | ); 32 | }; 33 | 34 | export default MuteConfirmDialog; 35 | -------------------------------------------------------------------------------- /app/com/components/dialogs/lists/AddProfileInListDialog.tsx: -------------------------------------------------------------------------------- 1 | import type { SignalizedProfile } from '~/api/stores/profiles'; 2 | 3 | import { closeModal } from '../../../globals/modals'; 4 | import { Button } from '../../../primitives/button'; 5 | import { DialogActions, DialogHeader, DialogRoot, DialogTitle } from '../../../primitives/dialog'; 6 | import DialogOverlay from '../DialogOverlay'; 7 | 8 | export interface AddProfileInListDialogProps { 9 | /** Expected to be static */ 10 | profile: SignalizedProfile; 11 | } 12 | 13 | const AddProfileInListDialog = ({}: AddProfileInListDialogProps) => { 14 | return ( 15 | 16 |
17 |
18 |

Unimplemented

19 |
20 | 21 |
22 | 25 |
26 |
27 |
28 | ); 29 | }; 30 | 31 | export default AddProfileInListDialog; 32 | -------------------------------------------------------------------------------- /app/com/components/dialogs/lists/CloneListMembersDialog.tsx: -------------------------------------------------------------------------------- 1 | import type { SignalizedList } from '~/api/stores/lists'; 2 | 3 | import { closeModal } from '../../../globals/modals'; 4 | import { Button } from '../../../primitives/button'; 5 | import { DialogActions, DialogHeader, DialogRoot, DialogTitle } from '../../../primitives/dialog'; 6 | import DialogOverlay from '../DialogOverlay'; 7 | 8 | export interface CloneListMembersDialogProps { 9 | /** Expected to be static */ 10 | list: SignalizedList; 11 | } 12 | 13 | const CloneListMembersDialog = ({}: CloneListMembersDialogProps) => { 14 | return ( 15 | 16 |
17 |
18 |

Unimplemented

19 |
20 | 21 |
22 | 25 |
26 |
27 |
28 | ); 29 | }; 30 | 31 | export default CloneListMembersDialog; 32 | -------------------------------------------------------------------------------- /app/com/components/embeds/EmbedRecordNotFound.tsx: -------------------------------------------------------------------------------- 1 | import { getCollectionId } from '~/api/utils/misc'; 2 | 3 | export interface EmbedRecordNotFoundProps { 4 | /** Expected to be static */ 5 | type?: 'post' | 'feed' | 'list' | 'record'; 6 | } 7 | 8 | const EmbedRecordNotFound = ({ type = 'record' }: EmbedRecordNotFoundProps) => { 9 | return ( 10 |
11 |

This {type} is unavailable

12 |
13 | ); 14 | }; 15 | 16 | export default EmbedRecordNotFound; 17 | 18 | const MAPPING: Record = { 19 | 'app.bsky.feed.post': 'post', 20 | 'app.bsky.feed.generator': 'feed', 21 | 'app.bsky.graph.list': 'list', 22 | }; 23 | 24 | export const getCollectionMapping = (uri: string): EmbedRecordNotFoundProps['type'] => { 25 | const collection = getCollectionId(uri); 26 | return MAPPING[collection]; 27 | }; 28 | -------------------------------------------------------------------------------- /app/com/components/embeds/images/ImageAltAction.tsx: -------------------------------------------------------------------------------- 1 | import type { JSX } from 'solid-js'; 2 | 3 | import { Button } from '../../../primitives/button'; 4 | import { Flyout, offsetlessMiddlewares } from '../../Flyout'; 5 | 6 | export interface ImageAltActionProps { 7 | alt: string; 8 | children: JSX.Element; 9 | } 10 | 11 | const ImageAltAction = (props: ImageAltActionProps) => { 12 | if (import.meta.env.VITE_MODE === 'desktop') { 13 | return ( 14 | 15 | {({ close, menuProps }) => ( 16 |
20 |

Image description

21 | 22 |

{props.alt}

23 | 24 |
25 | 28 |
29 |
30 | )} 31 |
32 | ); 33 | } 34 | 35 | return props.children; 36 | }; 37 | 38 | export default ImageAltAction; 39 | -------------------------------------------------------------------------------- /app/com/components/inputs/Checkbox.tsx: -------------------------------------------------------------------------------- 1 | import type { ComponentProps } from 'solid-js'; 2 | 3 | import CheckIcon from '../../icons/baseline-check'; 4 | 5 | const Checkbox = (props: ComponentProps<'input'>) => { 6 | return ( 7 |