Repository: v0l/snort Branch: main Commit: c29b4914a523 Files: 743 Total size: 4.8 MB Directory structure: gitextract_dgfwre9y/ ├── .dockerignore ├── .drone.yml ├── .github/ │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.md │ │ └── feature_request.md │ └── workflows/ │ ├── docker.yml │ ├── nsite.yml │ └── release.yml ├── .gitignore ├── .vscode/ │ ├── extensions.json │ └── settings.json ├── AGENTS.md ├── Dockerfile ├── Dockerfile.prebuilt ├── LICENSE ├── README.md ├── biome.json ├── crowdin.yml ├── docker/ │ └── nginx.conf ├── functions/ │ ├── _middleware.ts │ └── tsconfig.json ├── maintainers.yaml ├── package.json ├── packages/ │ ├── app/ │ │ ├── .gitignore │ │ ├── CHANGELOG.md │ │ ├── README.md │ │ ├── babel.config.json │ │ ├── bun-env.d.ts │ │ ├── bunfig.toml │ │ ├── config/ │ │ │ ├── README.md │ │ │ ├── default.json │ │ │ ├── iris.json │ │ │ ├── meku.json │ │ │ ├── nostr.json │ │ │ ├── phoenix.json │ │ │ └── soloco.json │ │ ├── custom.d.ts │ │ ├── index.html │ │ ├── package.json │ │ ├── public/ │ │ │ ├── iris/ │ │ │ │ ├── .well-known/ │ │ │ │ │ └── assetlinks.json │ │ │ │ ├── _headers │ │ │ │ ├── manifest.json │ │ │ │ └── robots.txt │ │ │ ├── nostr/ │ │ │ │ └── _headers │ │ │ ├── phoenix/ │ │ │ │ ├── .well-known/ │ │ │ │ │ ├── apple-app-site-association │ │ │ │ │ └── assetlinks.json │ │ │ │ ├── _headers │ │ │ │ ├── manifest.json │ │ │ │ └── robots.txt │ │ │ └── snort/ │ │ │ ├── .well-known/ │ │ │ │ ├── apple-app-site-association │ │ │ │ └── assetlinks.json │ │ │ ├── _headers │ │ │ ├── manifest.json │ │ │ └── robots.txt │ │ ├── src/ │ │ │ ├── Agent/ │ │ │ │ └── system-prompt.ts │ │ │ ├── Cache/ │ │ │ │ ├── CommunityLeadersStore.tsx │ │ │ │ ├── GiftWrapCache.ts │ │ │ │ ├── ProfileWorkerCache.ts │ │ │ │ ├── RefreshFeedCache.ts │ │ │ │ ├── RelaysWorkerCache.ts │ │ │ │ ├── UserFollowsWorker.ts │ │ │ │ ├── index.ts │ │ │ │ └── worker-cached.ts │ │ │ ├── Components/ │ │ │ │ ├── AskSnort/ │ │ │ │ │ └── AskSnortInput.tsx │ │ │ │ ├── Button/ │ │ │ │ │ ├── AsyncButton.tsx │ │ │ │ │ ├── AsyncIcon.tsx │ │ │ │ │ ├── BackButton.tsx │ │ │ │ │ ├── CloseButton.tsx │ │ │ │ │ ├── IconButton.tsx │ │ │ │ │ ├── LogoutButton.tsx │ │ │ │ │ └── NavLink.tsx │ │ │ │ ├── Collapsed.tsx │ │ │ │ ├── CommunityLeaders/ │ │ │ │ │ ├── Award.tsx │ │ │ │ │ └── LeaderBadge.tsx │ │ │ │ ├── Copy/ │ │ │ │ │ └── Copy.tsx │ │ │ │ ├── DvmSelector.tsx │ │ │ │ ├── Embed/ │ │ │ │ │ ├── AppleMusicEmbed.tsx │ │ │ │ │ ├── BlossomBlob.tsx │ │ │ │ │ ├── CashuNuts.tsx │ │ │ │ │ ├── GenericPlayer.tsx │ │ │ │ │ ├── Hashtag.tsx │ │ │ │ │ ├── HyperText.tsx │ │ │ │ │ ├── Invoice.tsx │ │ │ │ │ ├── LinkPreview.tsx │ │ │ │ │ ├── MagnetLink.tsx │ │ │ │ │ ├── MediaElement.tsx │ │ │ │ │ ├── Mention.tsx │ │ │ │ │ ├── MixCloudEmbed.tsx │ │ │ │ │ ├── NostrLink.tsx │ │ │ │ │ ├── NostrNestsEmbed.tsx │ │ │ │ │ ├── PubkeyList.tsx │ │ │ │ │ ├── SoundCloudEmded.tsx │ │ │ │ │ ├── SpotifyEmbed.tsx │ │ │ │ │ ├── TidalEmbed.tsx │ │ │ │ │ ├── TwitchEmbed.tsx │ │ │ │ │ ├── UrlStatusCheck.tsx │ │ │ │ │ ├── WavlakeEmbed.tsx │ │ │ │ │ ├── YoutubeEmbed.tsx │ │ │ │ │ ├── ZapstrEmbed.css │ │ │ │ │ └── ZapstrEmbed.tsx │ │ │ │ ├── ErrorBoundary.tsx │ │ │ │ ├── ErrorOrOffline.tsx │ │ │ │ ├── Event/ │ │ │ │ │ ├── Application.tsx │ │ │ │ │ ├── Create/ │ │ │ │ │ │ ├── NoteCreator.tsx │ │ │ │ │ │ ├── NoteCreatorButton.tsx │ │ │ │ │ │ ├── OkResponseRow.tsx │ │ │ │ │ │ └── util.ts │ │ │ │ │ ├── DVMJobFeedback.tsx │ │ │ │ │ ├── EventComponent.tsx │ │ │ │ │ ├── FileUpload.tsx │ │ │ │ │ ├── HiddenNote.tsx │ │ │ │ │ ├── LoadMore.tsx │ │ │ │ │ ├── LongFormText.tsx │ │ │ │ │ ├── Markdown.tsx │ │ │ │ │ ├── NostrFileHeader.tsx │ │ │ │ │ ├── Note/ │ │ │ │ │ │ ├── ClientFingerprinting.tsx │ │ │ │ │ │ ├── ClientTag.tsx │ │ │ │ │ │ ├── Note.tsx │ │ │ │ │ │ ├── NoteAppHandler.tsx │ │ │ │ │ │ ├── NoteContent.tsx │ │ │ │ │ │ ├── NoteContext.tsx │ │ │ │ │ │ ├── NoteContextMenu.tsx │ │ │ │ │ │ ├── NoteFooter/ │ │ │ │ │ │ │ ├── AsyncFooterIcon.tsx │ │ │ │ │ │ │ ├── FooterZapButton.tsx │ │ │ │ │ │ │ ├── LikeButton.tsx │ │ │ │ │ │ │ ├── NoteFooter.tsx │ │ │ │ │ │ │ ├── PowIcon.tsx │ │ │ │ │ │ │ ├── ReplyButton.tsx │ │ │ │ │ │ │ ├── RepostButton.tsx │ │ │ │ │ │ │ └── ZapperQueue.tsx │ │ │ │ │ │ ├── NoteHeader.tsx │ │ │ │ │ │ ├── NoteQuote.tsx │ │ │ │ │ │ ├── NoteText.tsx │ │ │ │ │ │ ├── NoteTime.tsx │ │ │ │ │ │ ├── ReactionsModal.tsx │ │ │ │ │ │ ├── ReplyTag.tsx │ │ │ │ │ │ ├── TranslationInfo.tsx │ │ │ │ │ │ └── types.tsx │ │ │ │ │ ├── NoteReaction.tsx │ │ │ │ │ ├── Poll.tsx │ │ │ │ │ ├── Reveal.tsx │ │ │ │ │ ├── RevealMedia.tsx │ │ │ │ │ ├── Thread/ │ │ │ │ │ │ ├── Subthread.tsx │ │ │ │ │ │ ├── Thread.tsx │ │ │ │ │ │ ├── ThreadRoute.tsx │ │ │ │ │ │ └── util.ts │ │ │ │ │ ├── Zap.tsx │ │ │ │ │ ├── ZapButton.tsx │ │ │ │ │ ├── ZapGoal.tsx │ │ │ │ │ └── ZapsSummary.tsx │ │ │ │ ├── Feed/ │ │ │ │ │ ├── ImageGridItem.tsx │ │ │ │ │ ├── LoadMore.tsx │ │ │ │ │ ├── RootTabItems.tsx │ │ │ │ │ ├── RootTabs.tsx │ │ │ │ │ ├── Timeline.tsx │ │ │ │ │ ├── TimelineChunk.tsx │ │ │ │ │ ├── TimelineFollows.tsx │ │ │ │ │ ├── TimelineFragment.tsx │ │ │ │ │ ├── TimelineRenderer.tsx │ │ │ │ │ └── UsersFeed.tsx │ │ │ │ ├── Icons/ │ │ │ │ │ ├── Alby.tsx │ │ │ │ │ ├── BlueWallet.tsx │ │ │ │ │ ├── Cashu.tsx │ │ │ │ │ ├── ECash.tsx │ │ │ │ │ ├── Icon.tsx │ │ │ │ │ ├── NWC.tsx │ │ │ │ │ ├── Nostrich.tsx │ │ │ │ │ ├── Spinner.tsx │ │ │ │ │ └── Toggle.tsx │ │ │ │ ├── IntlProvider/ │ │ │ │ │ ├── IntlProvider.tsx │ │ │ │ │ ├── IntlProviderUtils.tsx │ │ │ │ │ ├── langStore.tsx │ │ │ │ │ └── useLocale.tsx │ │ │ │ ├── Invite.tsx │ │ │ │ ├── LiveStream/ │ │ │ │ │ ├── LiveEvent.tsx │ │ │ │ │ ├── LiveStreams.tsx │ │ │ │ │ ├── VU.tsx │ │ │ │ │ ├── livekit.tsx │ │ │ │ │ └── nests-participants.tsx │ │ │ │ ├── Modal/ │ │ │ │ │ └── Modal.tsx │ │ │ │ ├── Nip5Service.tsx │ │ │ │ ├── Offline.tsx │ │ │ │ ├── PageSpinner.tsx │ │ │ │ ├── PinPrompt/ │ │ │ │ │ └── PinPrompt.tsx │ │ │ │ ├── Progress/ │ │ │ │ │ └── Progress.tsx │ │ │ │ ├── ProxyImg.tsx │ │ │ │ ├── QrCode.tsx │ │ │ │ ├── ReBroadcaster.tsx │ │ │ │ ├── Relay/ │ │ │ │ │ ├── Relay.tsx │ │ │ │ │ ├── RelaysMetadata.tsx │ │ │ │ │ ├── name.tsx │ │ │ │ │ ├── paid.tsx │ │ │ │ │ ├── permissions.tsx │ │ │ │ │ ├── software.tsx │ │ │ │ │ ├── status-label.tsx │ │ │ │ │ ├── uptime-label.tsx │ │ │ │ │ └── uptime.tsx │ │ │ │ ├── Review.tsx │ │ │ │ ├── RightWidgets/ │ │ │ │ │ ├── articles.tsx │ │ │ │ │ ├── base.tsx │ │ │ │ │ ├── index.tsx │ │ │ │ │ ├── invite-friends.tsx │ │ │ │ │ └── mini-stream.tsx │ │ │ │ ├── ScrollToTop.tsx │ │ │ │ ├── SearchBox/ │ │ │ │ │ └── SearchBox.tsx │ │ │ │ ├── Spotlight/ │ │ │ │ │ ├── SpotlightMedia.tsx │ │ │ │ │ ├── SpotlightThreadModal.tsx │ │ │ │ │ └── context.tsx │ │ │ │ ├── SuggestedProfiles.tsx │ │ │ │ ├── TabSelectors/ │ │ │ │ │ └── TabSelectors.tsx │ │ │ │ ├── Tasks/ │ │ │ │ │ ├── BackupKey.tsx │ │ │ │ │ ├── DonateTask.tsx │ │ │ │ │ ├── FollowMorePeople.tsx │ │ │ │ │ ├── Nip5Task.tsx │ │ │ │ │ ├── NoticeZapPool.tsx │ │ │ │ │ ├── PendingChangesTask.tsx │ │ │ │ │ ├── RenewSubscription.tsx │ │ │ │ │ ├── TaskList.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── Text/ │ │ │ │ │ ├── DisableMedia.tsx │ │ │ │ │ ├── HighlightedText.tsx │ │ │ │ │ ├── Text.tsx │ │ │ │ │ └── const.ts │ │ │ │ ├── Textarea/ │ │ │ │ │ ├── Textarea.css │ │ │ │ │ └── Textarea.tsx │ │ │ │ ├── Toaster/ │ │ │ │ │ └── Toaster.tsx │ │ │ │ ├── Trending/ │ │ │ │ │ ├── ShortNote.tsx │ │ │ │ │ ├── TrendingHashtags.tsx │ │ │ │ │ ├── TrendingPosts.tsx │ │ │ │ │ └── TrendingUsers.tsx │ │ │ │ ├── Upload/ │ │ │ │ │ └── file-picker.tsx │ │ │ │ ├── User/ │ │ │ │ │ ├── AnimalName.ts │ │ │ │ │ ├── Avatar.tsx │ │ │ │ │ ├── AvatarEditor.tsx │ │ │ │ │ ├── AvatarGroup.tsx │ │ │ │ │ ├── BadgeList.tsx │ │ │ │ │ ├── Bookmarks.tsx │ │ │ │ │ ├── Debug.tsx │ │ │ │ │ ├── DisplayName.tsx │ │ │ │ │ ├── FollowButton.tsx │ │ │ │ │ ├── FollowDistanceIndicator.tsx │ │ │ │ │ ├── FollowListBase.tsx │ │ │ │ │ ├── FollowedBy.tsx │ │ │ │ │ ├── Following.tsx │ │ │ │ │ ├── FollowsYou.tsx │ │ │ │ │ ├── MuteButton.tsx │ │ │ │ │ ├── MutedList.tsx │ │ │ │ │ ├── Nip05.tsx │ │ │ │ │ ├── NoteToSelf.tsx │ │ │ │ │ ├── ProfileCard.tsx │ │ │ │ │ ├── ProfileCardWrapper.tsx │ │ │ │ │ ├── ProfileImage.tsx │ │ │ │ │ ├── ProfileLink.tsx │ │ │ │ │ ├── ProfilePreview.tsx │ │ │ │ │ ├── UserWebsiteLink.tsx │ │ │ │ │ └── Username.tsx │ │ │ │ ├── WarningNotice/ │ │ │ │ │ └── WarningNotice.tsx │ │ │ │ ├── ZapModal/ │ │ │ │ │ ├── SuccessAction.tsx │ │ │ │ │ ├── ZapModal.tsx │ │ │ │ │ ├── ZapModalInput.tsx │ │ │ │ │ ├── ZapModalInvoice.tsx │ │ │ │ │ ├── ZapModalTitle.tsx │ │ │ │ │ ├── ZapType.tsx │ │ │ │ │ └── ZapTypeSelector.tsx │ │ │ │ ├── flyout.tsx │ │ │ │ ├── json.tsx │ │ │ │ ├── kind-name.tsx │ │ │ │ ├── messages.ts │ │ │ │ ├── nip.tsx │ │ │ │ └── zap-amount.tsx │ │ │ ├── Db/ │ │ │ │ └── FuzzySearch.ts │ │ │ ├── External/ │ │ │ │ ├── NostrBand.ts │ │ │ │ ├── NostrServices.ts │ │ │ │ ├── SnortApi.ts │ │ │ │ ├── base.ts │ │ │ │ └── index.ts │ │ │ ├── Feed/ │ │ │ │ ├── ArticlesFeed.ts │ │ │ │ ├── BadgesFeed.ts │ │ │ │ ├── FollowersFeed.ts │ │ │ │ ├── FollowsFeed.ts │ │ │ │ ├── HashtagsFeed.ts │ │ │ │ ├── LoginFeed.ts │ │ │ │ ├── RelayState.ts │ │ │ │ ├── RelaysFeed.tsx │ │ │ │ ├── StatusFeed.ts │ │ │ │ ├── TimelineFeed.ts │ │ │ │ ├── WorkerRelayView.ts │ │ │ │ └── ZapsFeed.ts │ │ │ ├── Hooks/ │ │ │ │ ├── useAiAgent.ts │ │ │ │ ├── useAppHandler.ts │ │ │ │ ├── useBlindSpot.ts │ │ │ │ ├── useBlossomServers.ts │ │ │ │ ├── useCloseRelays.ts │ │ │ │ ├── useCommunityLeaders.tsx │ │ │ │ ├── useContentDiscovery.ts │ │ │ │ ├── useCopy.ts │ │ │ │ ├── useDiscoverMediaServers.ts │ │ │ │ ├── useDvmLinks.ts │ │ │ │ ├── useEventPublisher.tsx │ │ │ │ ├── useFollowControls.ts │ │ │ │ ├── useHistoryState.tsx │ │ │ │ ├── useHorizontalScroll.tsx │ │ │ │ ├── useHovering.ts │ │ │ │ ├── useImgProxy.ts │ │ │ │ ├── useKeyboardShortcut.ts │ │ │ │ ├── useLists.tsx │ │ │ │ ├── useLiveStreams.ts │ │ │ │ ├── useLoading.tsx │ │ │ │ ├── useLogin.tsx │ │ │ │ ├── useLoginHandler.tsx │ │ │ │ ├── useLoginRelays.tsx │ │ │ │ ├── useMediaServerList.ts │ │ │ │ ├── useModeration.tsx │ │ │ │ ├── usePageDimensions.tsx │ │ │ │ ├── usePreferences.ts │ │ │ │ ├── useProfileLink.ts │ │ │ │ ├── useProfileSearch.tsx │ │ │ │ ├── useRates.tsx │ │ │ │ ├── useRelays.tsx │ │ │ │ ├── useTextTransformCache.tsx │ │ │ │ ├── useTheme.tsx │ │ │ │ ├── useTimelineChunks.ts │ │ │ │ ├── useTimelineWindow.tsx │ │ │ │ ├── useTraceTimeline.tsx │ │ │ │ ├── useWindowSize.ts │ │ │ │ └── useWoT.ts │ │ │ ├── Pages/ │ │ │ │ ├── About.tsx │ │ │ │ ├── Agent/ │ │ │ │ │ └── AgentPage.tsx │ │ │ │ ├── CacheDebug.tsx │ │ │ │ ├── ComponentDebug.tsx │ │ │ │ ├── Deck/ │ │ │ │ │ ├── Articles.tsx │ │ │ │ │ ├── Columns.tsx │ │ │ │ │ └── DeckLayout.tsx │ │ │ │ ├── Discover.tsx │ │ │ │ ├── Donate/ │ │ │ │ │ ├── DonatePage.tsx │ │ │ │ │ ├── ZapPoolDonateSection.tsx │ │ │ │ │ └── const.ts │ │ │ │ ├── ErrorPage.tsx │ │ │ │ ├── FixedPage.tsx │ │ │ │ ├── FreeNostrAddressPage.tsx │ │ │ │ ├── HashTagsPage.tsx │ │ │ │ ├── HelpPage.tsx │ │ │ │ ├── Layout/ │ │ │ │ │ ├── Footer.tsx │ │ │ │ │ ├── HasNotificationsMarker.tsx │ │ │ │ │ ├── Header.tsx │ │ │ │ │ ├── LogoHeader.tsx │ │ │ │ │ ├── NavSidebar.tsx │ │ │ │ │ ├── NotificationsHeader.tsx │ │ │ │ │ ├── ProfileMenu.tsx │ │ │ │ │ ├── RightColumn.tsx │ │ │ │ │ ├── WalletBalance.tsx │ │ │ │ │ └── index.tsx │ │ │ │ ├── ListFeedPage.tsx │ │ │ │ ├── Messages/ │ │ │ │ │ ├── ChatParticipant.tsx │ │ │ │ │ ├── DM.tsx │ │ │ │ │ ├── DmWindow.tsx │ │ │ │ │ ├── MessagesPage.tsx │ │ │ │ │ ├── NewChatWindow.tsx │ │ │ │ │ ├── UnreadCount.tsx │ │ │ │ │ └── WriteMessage.tsx │ │ │ │ ├── NostrAddressPage.tsx │ │ │ │ ├── NostrLinkHandler.tsx │ │ │ │ ├── Notifications/ │ │ │ │ │ ├── NotificationGroup.tsx │ │ │ │ │ ├── Notifications.tsx │ │ │ │ │ ├── getNotificationContext.tsx │ │ │ │ │ └── notificationContext.tsx │ │ │ │ ├── Profile/ │ │ │ │ │ ├── AvatarSection.tsx │ │ │ │ │ ├── MusicStatus.tsx │ │ │ │ │ ├── ProfileDetails.tsx │ │ │ │ │ ├── ProfilePage.tsx │ │ │ │ │ ├── ProfileTabComponents.tsx │ │ │ │ │ ├── ProfileTabSelectors.tsx │ │ │ │ │ └── ProfileTabType.tsx │ │ │ │ ├── Root/ │ │ │ │ │ ├── BlindSpots.tsx │ │ │ │ │ ├── ConversationsTab.tsx │ │ │ │ │ ├── DefaultTab.tsx │ │ │ │ │ ├── FollowSets.tsx │ │ │ │ │ ├── FollowedByFriendsTab.tsx │ │ │ │ │ ├── ForYouTab.tsx │ │ │ │ │ ├── Media.tsx │ │ │ │ │ ├── NotesTab.tsx │ │ │ │ │ ├── RelayFeedPage.tsx │ │ │ │ │ ├── RootRoutes.tsx │ │ │ │ │ ├── RootTabRoutes.tsx │ │ │ │ │ └── TagsTab.tsx │ │ │ │ ├── SearchPage.tsx │ │ │ │ ├── TopicsPage.tsx │ │ │ │ ├── ZapPool/ │ │ │ │ │ ├── ZapPool.css │ │ │ │ │ ├── ZapPool.tsx │ │ │ │ │ ├── ZapPoolPageInner.tsx │ │ │ │ │ └── ZapPoolTarget.tsx │ │ │ │ ├── messages.ts │ │ │ │ ├── onboarding/ │ │ │ │ │ ├── discover.tsx │ │ │ │ │ ├── fixedModeration.tsx │ │ │ │ │ ├── fixedTopics.tsx │ │ │ │ │ ├── index.tsx │ │ │ │ │ ├── moderation.tsx │ │ │ │ │ ├── profile.tsx │ │ │ │ │ ├── routes.ts │ │ │ │ │ ├── sign-in.tsx │ │ │ │ │ ├── sign-up.tsx │ │ │ │ │ └── topics.tsx │ │ │ │ ├── settings/ │ │ │ │ │ ├── Accounts.tsx │ │ │ │ │ ├── Cache.tsx │ │ │ │ │ ├── Keys.css │ │ │ │ │ ├── Keys.tsx │ │ │ │ │ ├── Menu/ │ │ │ │ │ │ ├── Menu.tsx │ │ │ │ │ │ └── SettingsMenuComponent.tsx │ │ │ │ │ ├── Moderation.tsx │ │ │ │ │ ├── Notifications.tsx │ │ │ │ │ ├── Preferences.tsx │ │ │ │ │ ├── Profile.tsx │ │ │ │ │ ├── Referrals.tsx │ │ │ │ │ ├── RelayInfo.tsx │ │ │ │ │ ├── Relays.tsx │ │ │ │ │ ├── Routes.tsx │ │ │ │ │ ├── SnortNostrAddressService.tsx │ │ │ │ │ ├── WalletSettings.tsx │ │ │ │ │ ├── handle/ │ │ │ │ │ │ ├── LNAddress.tsx │ │ │ │ │ │ ├── ListHandles.tsx │ │ │ │ │ │ ├── Manage.tsx │ │ │ │ │ │ ├── TransferHandle.tsx │ │ │ │ │ │ ├── index.tsx │ │ │ │ │ │ └── routes.tsx │ │ │ │ │ ├── index.tsx │ │ │ │ │ ├── media-settings.tsx │ │ │ │ │ ├── messages.ts │ │ │ │ │ ├── relays/ │ │ │ │ │ │ └── discover.tsx │ │ │ │ │ ├── saveRelays.tsx │ │ │ │ │ ├── tools/ │ │ │ │ │ │ ├── follows-relay-health.tsx │ │ │ │ │ │ ├── index.tsx │ │ │ │ │ │ ├── prune-follows.tsx │ │ │ │ │ │ ├── routes.tsx │ │ │ │ │ │ └── sync-account.tsx │ │ │ │ │ └── wallet/ │ │ │ │ │ ├── Alby.tsx │ │ │ │ │ ├── LNDHub.tsx │ │ │ │ │ ├── NWC.tsx │ │ │ │ │ ├── index.tsx │ │ │ │ │ ├── routes.tsx │ │ │ │ │ └── utils.ts │ │ │ │ ├── subscribe/ │ │ │ │ │ ├── ManageSubscription.tsx │ │ │ │ │ ├── RenewSub.tsx │ │ │ │ │ ├── SubscriptionCard.tsx │ │ │ │ │ ├── index.css │ │ │ │ │ ├── index.tsx │ │ │ │ │ └── utils.tsx │ │ │ │ └── wallet/ │ │ │ │ ├── index.tsx │ │ │ │ ├── price-chart.tsx │ │ │ │ ├── receive.tsx │ │ │ │ └── send.tsx │ │ │ ├── State/ │ │ │ │ └── NoteCreator.ts │ │ │ ├── Utils/ │ │ │ │ ├── Const.ts │ │ │ │ ├── Login/ │ │ │ │ │ ├── Functions.ts │ │ │ │ │ ├── LoginSession.ts │ │ │ │ │ ├── MultiAccountStore.ts │ │ │ │ │ ├── Nip7OsSigner.ts │ │ │ │ │ ├── Preferences.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── Nip05/ │ │ │ │ │ ├── ServiceProvider.ts │ │ │ │ │ └── SnortServiceProvider.ts │ │ │ │ ├── Notifications.ts │ │ │ │ ├── Number.ts │ │ │ │ ├── Subscription/ │ │ │ │ │ └── index.ts │ │ │ │ ├── Thread/ │ │ │ │ │ ├── ThreadContextWrapper.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── Upload/ │ │ │ │ │ ├── blossom.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── ZapPoolController.ts │ │ │ │ ├── emoji-search.ts │ │ │ │ ├── getEventMedia.ts │ │ │ │ ├── index.ts │ │ │ │ ├── nip6.ts │ │ │ │ ├── stream.ts │ │ │ │ └── wasm.ts │ │ │ ├── Wallet/ │ │ │ │ └── index.ts │ │ │ ├── assets/ │ │ │ │ └── fonts/ │ │ │ │ └── inter.css │ │ │ ├── bench.html │ │ │ ├── benchmarks.ts │ │ │ ├── chat/ │ │ │ │ ├── index.ts │ │ │ │ └── nip17.ts │ │ │ ├── hug.json │ │ │ ├── index.css │ │ │ ├── index.tsx │ │ │ ├── lang.json │ │ │ ├── service-worker.ts │ │ │ ├── setupTests.ts │ │ │ ├── system.ts │ │ │ ├── translations/ │ │ │ │ ├── af_ZA.json │ │ │ │ ├── ar_SA.json │ │ │ │ ├── az_AZ.json │ │ │ │ ├── ca_ES.json │ │ │ │ ├── cs_CZ.json │ │ │ │ ├── da_DK.json │ │ │ │ ├── de_DE.json │ │ │ │ ├── el_GR.json │ │ │ │ ├── en.json │ │ │ │ ├── es_ES.json │ │ │ │ ├── fa_IR.json │ │ │ │ ├── fi_FI.json │ │ │ │ ├── fr_FR.json │ │ │ │ ├── he_IL.json │ │ │ │ ├── hr_HR.json │ │ │ │ ├── hu_HU.json │ │ │ │ ├── id_ID.json │ │ │ │ ├── it_IT.json │ │ │ │ ├── ja_JP.json │ │ │ │ ├── ko_KR.json │ │ │ │ ├── ms_MY.json │ │ │ │ ├── nl_NL.json │ │ │ │ ├── no_NO.json │ │ │ │ ├── pa_IN.json │ │ │ │ ├── pl_PL.json │ │ │ │ ├── pt_BR.json │ │ │ │ ├── pt_PT.json │ │ │ │ ├── ro_RO.json │ │ │ │ ├── ru_RU.json │ │ │ │ ├── sr_SP.json │ │ │ │ ├── sv_SE.json │ │ │ │ ├── sw_KE.json │ │ │ │ ├── ta_IN.json │ │ │ │ ├── th_TH.json │ │ │ │ ├── tr_TR.json │ │ │ │ ├── uk_UA.json │ │ │ │ ├── vi_VN.json │ │ │ │ ├── zh_CN.json │ │ │ │ └── zh_TW.json │ │ │ └── tz.json │ │ ├── tests/ │ │ │ ├── Utils.test.ts │ │ │ └── worker-cached.test.ts │ │ ├── tsconfig.json │ │ └── vite.config.ts │ ├── bot/ │ │ ├── README.md │ │ ├── example/ │ │ │ └── simple.ts │ │ ├── package.json │ │ ├── src/ │ │ │ └── index.ts │ │ ├── tsconfig.json │ │ └── typedoc.json │ ├── shared/ │ │ ├── package.json │ │ ├── src/ │ │ │ ├── SortedMap/ │ │ │ │ ├── SortedMap.test.ts │ │ │ │ └── SortedMap.ts │ │ │ ├── cache-store.ts │ │ │ ├── const.ts │ │ │ ├── custom.d.ts │ │ │ ├── external-store.ts │ │ │ ├── feed-cache.ts │ │ │ ├── imgproxy.ts │ │ │ ├── index.ts │ │ │ ├── invoices.ts │ │ │ ├── lnurl.ts │ │ │ ├── tlv.ts │ │ │ ├── utils.ts │ │ │ └── work-queue.ts │ │ ├── tsconfig.json │ │ └── typedoc.json │ ├── system/ │ │ ├── .npmignore │ │ ├── AUDIT.md │ │ ├── README.md │ │ ├── examples/ │ │ │ └── simple.ts │ │ ├── package.json │ │ ├── src/ │ │ │ ├── background-loader.ts │ │ │ ├── cache/ │ │ │ │ ├── index.ts │ │ │ │ ├── user-follows-lists.ts │ │ │ │ ├── user-metadata.ts │ │ │ │ └── user-relays.ts │ │ │ ├── cache-relay.ts │ │ │ ├── connection-cache-relay.ts │ │ │ ├── connection-pool.ts │ │ │ ├── connection-stats.ts │ │ │ ├── connection.ts │ │ │ ├── const.ts │ │ │ ├── encryption/ │ │ │ │ ├── index.ts │ │ │ │ ├── nip44.ts │ │ │ │ └── pin-encrypted.ts │ │ │ ├── event-builder.ts │ │ │ ├── event-ext.ts │ │ │ ├── event-kind.ts │ │ │ ├── event-publisher.ts │ │ │ ├── filter-cache-layer.ts │ │ │ ├── impl/ │ │ │ │ ├── nip10.ts │ │ │ │ ├── nip11.ts │ │ │ │ ├── nip18.ts │ │ │ │ ├── nip22.ts │ │ │ │ ├── nip25.ts │ │ │ │ ├── nip4.ts │ │ │ │ ├── nip44.ts │ │ │ │ ├── nip46.ts │ │ │ │ ├── nip55.ts │ │ │ │ ├── nip57.ts │ │ │ │ ├── nip7.ts │ │ │ │ ├── nip90.ts │ │ │ │ ├── nip92.ts │ │ │ │ └── nip94.ts │ │ │ ├── index.ts │ │ │ ├── negentropy/ │ │ │ │ ├── accumulator.ts │ │ │ │ ├── negentropy-flow.ts │ │ │ │ ├── negentropy.ts │ │ │ │ ├── utils.ts │ │ │ │ ├── vector-storage.ts │ │ │ │ └── wrapped-buffer.ts │ │ │ ├── nips.ts │ │ │ ├── nostr-link.ts │ │ │ ├── nostr-system.ts │ │ │ ├── nostr.ts │ │ │ ├── note-collection.ts │ │ │ ├── outbox/ │ │ │ │ ├── index.ts │ │ │ │ ├── outbox-model.ts │ │ │ │ └── relay-loader.ts │ │ │ ├── pow-util.ts │ │ │ ├── pow-worker.ts │ │ │ ├── pow.ts │ │ │ ├── profile-cache.ts │ │ │ ├── query-manager.ts │ │ │ ├── query-optimizer/ │ │ │ │ ├── index.ts │ │ │ │ ├── request-expander.ts │ │ │ │ ├── request-merger.ts │ │ │ │ └── request-splitter.ts │ │ │ ├── query.ts │ │ │ ├── relays.ts │ │ │ ├── request-builder.ts │ │ │ ├── request-matcher.ts │ │ │ ├── request-router.ts │ │ │ ├── request-trim.ts │ │ │ ├── signer.ts │ │ │ ├── sync/ │ │ │ │ ├── diff-sync.ts │ │ │ │ ├── index.ts │ │ │ │ ├── json-in-event-sync.ts │ │ │ │ ├── range-sync.ts │ │ │ │ └── safe-sync.ts │ │ │ ├── system-base.ts │ │ │ ├── system.ts │ │ │ ├── text.ts │ │ │ ├── trace-timeline.ts │ │ │ ├── user-state.ts │ │ │ └── utils.ts │ │ ├── tests/ │ │ │ ├── background-loader.test.ts │ │ │ ├── event-ext.test.ts │ │ │ ├── feed-cache-subscribe.test.ts │ │ │ ├── negentropy.test.ts │ │ │ ├── nip10.test.ts │ │ │ ├── nip18.test.ts │ │ │ ├── node.ts │ │ │ ├── nostr-link.test.ts │ │ │ ├── note-collection-comprehensive.test.ts │ │ │ ├── note-collection.test.ts │ │ │ ├── pin-encrypted.test.ts │ │ │ ├── query-comprehensive.test.ts │ │ │ ├── query-manager-comprehensive.test.ts │ │ │ ├── query-manager-race.test.ts │ │ │ ├── query-system-edge-cases.test.ts │ │ │ ├── request-builder.test.ts │ │ │ ├── request-expander.test.ts │ │ │ ├── request-matcher.test.ts │ │ │ ├── request-merger.test.ts │ │ │ ├── request-splitter.test.ts │ │ │ ├── setupTests.ts │ │ │ ├── text.test.ts │ │ │ └── utils.test.ts │ │ ├── tsconfig.json │ │ └── typedoc.json │ ├── system-react/ │ │ ├── README.md │ │ ├── example/ │ │ │ └── example.tsx │ │ ├── package.json │ │ ├── src/ │ │ │ ├── TraceTimeline/ │ │ │ │ ├── TraceStatsView.tsx │ │ │ │ ├── TraceTimeline.css │ │ │ │ ├── TraceTimelineDetailPopup.tsx │ │ │ │ ├── TraceTimelineOverlay.tsx │ │ │ │ └── TraceTimelineView.tsx │ │ │ ├── context.tsx │ │ │ ├── index.ts │ │ │ ├── useCached.ts │ │ │ ├── useEventFeed.ts │ │ │ ├── useEventReactions.tsx │ │ │ ├── useReactions.ts │ │ │ ├── useRequestBuilder.tsx │ │ │ ├── useSystemState.tsx │ │ │ ├── useUserProfile.ts │ │ │ └── useUserSearch.tsx │ │ ├── tsconfig.json │ │ └── typedoc.json │ ├── system-svelte/ │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── index.ts │ │ │ └── request-builder.ts │ │ ├── tsconfig.json │ │ └── typedoc.json │ ├── system-wasm/ │ │ ├── .gitignore │ │ ├── Cargo.toml │ │ ├── README.md │ │ ├── benches/ │ │ │ └── basic.rs │ │ ├── package.json │ │ ├── pkg/ │ │ │ ├── README.md │ │ │ ├── package.json │ │ │ ├── system_wasm.d.ts │ │ │ ├── system_wasm.js │ │ │ ├── system_wasm_bg.js │ │ │ ├── system_wasm_bg.wasm │ │ │ └── system_wasm_bg.wasm.d.ts │ │ ├── src/ │ │ │ ├── diff.rs │ │ │ ├── filter.rs │ │ │ ├── lib.rs │ │ │ ├── merge.rs │ │ │ ├── pow.rs │ │ │ └── verify.rs │ │ ├── system-query.iml │ │ └── typedoc.json │ ├── wallet/ │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── AlbyWallet.ts │ │ │ ├── LNDHub.ts │ │ │ ├── NostrWalletConnect.ts │ │ │ ├── WebLN.ts │ │ │ ├── custom.d.ts │ │ │ ├── index.ts │ │ │ └── zapper.ts │ │ ├── tsconfig.json │ │ └── typedoc.json │ └── worker-relay/ │ ├── README.md │ ├── example/ │ │ └── basic.ts │ ├── package.json │ ├── src/ │ │ ├── custom.d.ts │ │ ├── debug.ts │ │ ├── forYouFeed.ts │ │ ├── index.ts │ │ ├── interface.ts │ │ ├── memory-relay.ts │ │ ├── queue.ts │ │ ├── sqlite/ │ │ │ ├── fixers.ts │ │ │ ├── migrations.ts │ │ │ └── sqlite-relay.ts │ │ ├── types.ts │ │ └── worker.ts │ ├── tsconfig.json │ └── typedoc.json ├── src-tauri/ │ ├── .gitignore │ ├── Cargo.toml │ ├── build.rs │ ├── capabilities/ │ │ └── migrated.json │ ├── gen/ │ │ └── schemas/ │ │ ├── acl-manifests.json │ │ ├── capabilities.json │ │ ├── desktop-schema.json │ │ └── linux-schema.json │ ├── src/ │ │ └── main.rs │ └── tauri.conf.json └── zapstore.yaml ================================================ FILE CONTENTS ================================================ ================================================ FILE: .dockerignore ================================================ **/node_modules **/.idea **/target ================================================ FILE: .drone.yml ================================================ --- kind: pipeline type: kubernetes name: docker concurrency: limit: 1 trigger: branch: - main event: - push metadata: namespace: git steps: - name: Fetch tags image: alpine/git commands: - git fetch --tags - name: Build site image: node:current volumes: - name: cache path: /cache environment: NODE_CONFIG_ENV: default commands: - apt update && apt install -y git curl unzip - curl -fsSL https://bun.sh/install | bash - export PATH="$HOME/.bun/bin:$PATH" - bun install - bun run build - name: build docker image image: docker privileged: true volumes: - name: cache path: /cache environment: TOKEN: from_secret: docker_hub commands: - dockerd & - docker login -u voidic -p $TOKEN - docker buildx create --platform linux/amd64,linux/arm64 --bootstrap --use - docker buildx build -t voidic/snort:latest --platform linux/amd64,linux/arm64 --push -f Dockerfile.prebuilt . - kill $(cat /var/run/docker.pid) volumes: - name: cache claim: name: docker-cache --- kind: pipeline type: kubernetes name: test-lint concurrency: limit: 1 metadata: namespace: git steps: - name: Test/Lint image: node:current volumes: - name: cache path: /cache environment: NODE_CONFIG_ENV: default commands: - curl -fsSL https://bun.sh/install | bash - export PATH="$HOME/.bun/bin:$PATH" - bun install - bun run build - bun test - bunx --bun biome lint volumes: - name: cache claim: name: docker-cache --- kind: pipeline type: kubernetes name: crowdin concurrency: limit: 1 trigger: branch: - main event: - push metadata: namespace: git steps: - name: Push/Pull translations image: node:current volumes: - name: cache path: /cache environment: NODE_CONFIG_ENV: default TOKEN: from_secret: gitea CTOKEN: from_secret: crowdin commands: - git config --global user.email drone@v0l.io - git config --global user.name "Drone CI" - git remote set-url origin https://drone:$TOKEN@git.v0l.io/Kieran/snort.git - curl -fsSL https://bun.sh/install | bash - export PATH="$HOME/.bun/bin:$PATH" - bun install - bunx @crowdin/cli upload sources -b main -T $CTOKEN - bunx @crowdin/cli pull -b main -T $CTOKEN - bunx --bun biome lint --write - git add . - > if output=$(git status --porcelain) && [ -n "$output" ]; then git commit -a -m "chore: Update translations" git push -u origin main fi volumes: - name: cache claim: name: docker-cache --- kind: pipeline type: kubernetes name: docker-release concurrency: limit: 1 trigger: event: - tag metadata: namespace: git steps: - name: Fetch tags image: alpine/git commands: - git fetch --tags - name: Build site image: node:current volumes: - name: cache path: /cache environment: NODE_CONFIG_ENV: default commands: - apt update && apt install -y git curl unzip - curl -fsSL https://bun.sh/install | bash - export PATH="$HOME/.bun/bin:$PATH" - bun install - bun run build - name: build docker image image: docker privileged: true volumes: - name: cache path: /cache environment: TOKEN: from_secret: docker_hub commands: - dockerd & - docker login -u voidic -p $TOKEN - docker buildx create --platform linux/amd64,linux/arm64 --bootstrap --use - docker buildx build -t voidic/snort:$DRONE_TAG --platform linux/amd64,linux/arm64 --push -f Dockerfile.prebuilt . - kill $(cat /var/run/docker.pid) volumes: - name: cache claim: name: docker-cache ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.md ================================================ --- name: Bug report about: Create a report to help us improve title: "" labels: "" assignees: "" --- **Describe the bug** A clear and concise description of what the bug is. **To Reproduce** Steps to reproduce the behavior: 1. Go to '...' 2. Click on '....' 3. Scroll down to '....' 4. See error **Expected behavior** A clear and concise description of what you expected to happen. **Screenshots** If applicable, add screenshots to help explain your problem. **Desktop (please complete the following information):** - OS: [e.g. iOS] - Browser: [e.g. chrome, safari] - Version: [e.g. 22] **Smartphone (please complete the following information):** - Device: [e.g. iPhone6] - OS: [e.g. iOS8.1] - Browser: [e.g. stock browser, safari] - Version: [e.g. 22] **Additional context** Add any other context about the problem here. ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.md ================================================ --- name: Feature request about: Suggest an idea for this project title: "" labels: "" assignees: "" --- **Is your feature request related to a problem? Please describe.** A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] **Describe the solution you'd like** A clear and concise description of what you want to happen. **Describe alternatives you've considered** A clear and concise description of any alternative solutions or features you've considered. **Additional context** Add any other context or screenshots about the feature request here. ================================================ FILE: .github/workflows/docker.yml ================================================ name: Docker on: push: branches: - main tags: - v[0-9]+.[0-9]+.[0-9]+ - v[0-9]+.[0-9]+.[0-9]+-* jobs: docker-latest: runs-on: ubuntu-latest if: github.ref == 'refs/heads/main' permissions: contents: read packages: write steps: - name: Checkout code uses: actions/checkout@v4 - name: Setup Bun uses: oven-sh/setup-bun@v2 - name: Install dependencies run: bun install - name: Build site run: bun run build - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Login to Docker Hub uses: docker/login-action@v3 with: username: voidic password: ${{ secrets.DOCKER_HUB_TOKEN }} - name: Build and push Docker image uses: docker/build-push-action@v6 with: context: . file: Dockerfile.prebuilt platforms: linux/amd64,linux/arm64 push: true tags: voidic/snort:latest docker-release: runs-on: ubuntu-latest if: startsWith(github.ref, 'refs/tags/') permissions: contents: read packages: write steps: - name: Checkout code uses: actions/checkout@v4 - name: Setup Bun uses: oven-sh/setup-bun@v2 - name: Install dependencies run: bun install - name: Build site run: bun run build - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Login to Docker Hub uses: docker/login-action@v3 with: username: voidic password: ${{ secrets.DOCKER_HUB_TOKEN }} - name: Build and push Docker image uses: docker/build-push-action@v6 with: context: . file: Dockerfile.prebuilt platforms: linux/amd64,linux/arm64 push: true tags: voidic/snort:${{ github.ref_name }} ================================================ FILE: .github/workflows/nsite.yml ================================================ name: Deploy nsite on: workflow_dispatch: # temporarily disabled - nsite-cli upload hangs indefinitely # push: # branches: # - main jobs: deploy: runs-on: ubuntu-latest steps: - name: Checkout Repo uses: actions/checkout@v4 - name: Setup Bun uses: oven-sh/setup-bun@v2 - name: Install Dependencies run: bun install - name: Build run: bun run build - name: Redirect 404 to Index for SPA run: cp packages/app/build/index.html packages/app/build/404.html - name: Deploy nsite run: bunx nsite-cli upload packages/app/build --verbose --purge --privatekey ${{ secrets.NSITE_KEY }} --servers 'https://nostr.download,https://blossom.band' ================================================ FILE: .github/workflows/release.yml ================================================ name: Release on: push: tags: - v[0-9]+.[0-9]+.[0-9]+ - v[0-9]+.[0-9]+.[0-9]+-* env: DOCKER_CLI_EXPERIMENTAL: enabled TAG_FMT: "^refs/tags/(((.?[0-9]+){3,4}))$" jobs: app: runs-on: ubuntu-latest permissions: contents: write steps: - name: Checkout code uses: actions/checkout@v4 - name: Set up JDK 21 uses: actions/setup-java@v4 with: distribution: temurin java-version: 21 - name: Setup Bun uses: oven-sh/setup-bun@v2 - name: Install frontend dependencies run: bun install - name: Build Site run: bun run build - name: Copy files run: |- git clone --depth 1 --branch ${{ github.ref_name }} https://github.com/v0l/snort_android.git mkdir -p snort_android/app/src/main/assets/ cp -r packages/app/build/* snort_android/app/src/main/assets/ - name: Build AAB working-directory: snort_android run: ./gradlew clean bundleRelease --stacktrace - name: Build APK working-directory: snort_android run: ./gradlew assembleRelease --stacktrace - name: Sign AAB uses: r0adkll/sign-android-release@v1 with: releaseDirectory: snort_android/app/build/outputs/bundle/release signingKeyBase64: ${{ secrets.SIGNING_KEY }} alias: ${{ secrets.KEY_ALIAS }} keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }} keyPassword: ${{ secrets.KEY_PASSWORD }} env: BUILD_TOOLS_VERSION: "35.0.0" - name: Sign APK uses: r0adkll/sign-android-release@v1 with: releaseDirectory: snort_android/app/build/outputs/apk/release signingKeyBase64: ${{ secrets.SIGNING_KEY }} alias: ${{ secrets.KEY_ALIAS }} keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }} keyPassword: ${{ secrets.KEY_PASSWORD }} env: BUILD_TOOLS_VERSION: "35.0.0" - name: Rename files run: |- mkdir -p snort_android/app/release mv snort_android/app/build/outputs/bundle/release/app-release.aab snort_android/app/release/snort-${{ github.ref_name }}.aab mv snort_android/app/build/outputs/apk/release/app-universal-release-unsigned-signed.apk snort_android/app/release/snort-universal-${{ github.ref_name }}.apk mv snort_android/app/build/outputs/apk/release/app-arm64-v8a-release-unsigned-signed.apk snort_android/app/release/snort-arm64-v8a-${{ github.ref_name }}.apk mv snort_android/app/build/outputs/apk/release/app-x86_64-release-unsigned-signed.apk snort_android/app/release/snort-x86_64-${{ github.ref_name }}.apk mv snort_android/app/build/outputs/apk/release/app-armeabi-v7a-release-unsigned-signed.apk snort_android/app/release/snort-armeabi-v7a-${{ github.ref_name }}.apk - name: Upload assets uses: softprops/action-gh-release@v1 with: files: | snort_android/app/release/snort-${{ github.ref_name }}.aab snort_android/app/release/snort-universal-${{ github.ref_name }}.apk snort_android/app/release/snort-arm64-v8a-${{ github.ref_name }}.apk snort_android/app/release/snort-x86_64-${{ github.ref_name }}.apk snort_android/app/release/snort-armeabi-v7a-${{ github.ref_name }}.apk ================================================ FILE: .gitignore ================================================ node_modules/ .idea .pnp.* dist/ *.tgz *.log .DS_Store .pnp* docs/ .wrangler/ ================================================ FILE: .vscode/extensions.json ================================================ { "recommendations": [ "arcanis.vscode-zipfs", "biomejs.biome" ] } ================================================ FILE: .vscode/settings.json ================================================ { "files.exclude": { "**/.git": true, "**/.svn": true, "**/.hg": true, "**/CVS": true, "**/.DS_Store": true, "**/Thumbs.db": true, "**/node_modules": true }, "search.exclude": {}, "typescript.tsdk": "node_modules/typescript/lib", "typescript.enablePromptUseWorkspaceTsdk": true } ================================================ FILE: AGENTS.md ================================================ # AGENTS.md - Snort Codebase Guidelines This document provides guidelines for AI coding agents working in the Snort codebase. ## Project Overview Snort is a **Nostr UI client** built with: - **Language**: TypeScript (strict mode) - **Framework**: React 19 (main app) - **Build Tool**: Vite - **Package Manager**: Bun (required - do not use npm/yarn/pnpm) - **Monorepo**: Bun workspaces ### Package Structure ``` packages/ app/ # Main React web application (@snort/app) system/ # Core Nostr system library (@snort/system) shared/ # Shared utilities (@snort/shared) wallet/ # Wallet integration (@snort/wallet) worker-relay/ # Service worker relay (@snort/worker-relay) system-react/ # React hooks for system (@snort/system-react) ``` ## Build Commands ```bash # Install dependencies bun install # Build all packages (order matters - shared -> system -> wallet -> worker-relay -> app) bun run build # Start dev server bun run start # Build specific package bun --cwd=packages/app run build bun --cwd=packages/system run build ``` ## Testing **Framework**: Bun's built-in test runner (`bun:test`) ```bash # Run all tests bun test # Run tests in a specific package cd packages/system && bun test # Run a single test file bun test packages/system/tests/nip10.test.ts # Run tests matching a pattern bun test --test-name-pattern="parseThread" # Run test files matching a name bun test nip10 ``` **Test file locations**: - `packages/system/tests/*.test.ts` (most tests) - `packages/app/tests/*.test.ts` - `packages/shared/src/**/*.test.ts` ## Linting & Formatting **Tool**: Biome (not ESLint/Prettier) ```bash # Lint and fix bunx --bun biome lint --write # Pre-commit (extract translations + lint) bun run pre:commit ``` ## Code Style Guidelines ### Formatting (Biome) - **Indentation**: 2 spaces - **Line width**: 120 characters - **Semicolons**: as needed (omit when optional) - **Quotes**: single quotes for JS/TS, double quotes for JSX attributes - **Trailing commas**: all - **Arrow parentheses**: as needed - **Line endings**: LF ### TypeScript - **Strict mode** is enabled - **Target**: ESNext - **Module resolution**: Bundler - Use `type` imports for types: `import type { Foo } from './bar'` - Path alias in app: `@/*` maps to `./src/*` ### Imports - Biome auto-organizes imports - Group order: external packages, then internal modules - Use workspace packages: `@snort/shared`, `@snort/system`, etc. ### Naming Conventions - **Files**: PascalCase for React components (`Note.tsx`, `EventBuilder.ts`) - **Files**: kebab-case or camelCase for utilities (`event-builder.ts`, `nostr-link.ts`) - **Components**: PascalCase (`function Note()`, `function EventComponent()`) - **Hooks**: camelCase with `use` prefix (`useLogin`, `useModeration`) - **Types/Interfaces**: PascalCase (`TaggedNostrEvent`, `NoteProps`) - **Constants**: UPPER_SNAKE_CASE or PascalCase - **Private class fields**: Use `#` prefix (`#kind`, `#content`) ### React Patterns - Functional components only - Use hooks for state management - Custom hooks in `src/Hooks/` directory - Components in `src/Components/` with subdirectories by feature - Pages in `src/Pages/` ### Error Handling - Use try/catch for async operations - Prefer optional chaining (`?.`) and nullish coalescing (`??`) - Return `undefined` for not-found cases rather than throwing ### Nostr-Specific Patterns - Event kinds defined in `packages/system/src/event-kind.ts` - NIP implementations in `packages/system/src/impl/nip*.ts` - Use `EventBuilder` class for constructing events - Use `NostrLink` for referencing events/profiles - Tags are arrays: `["e", "eventId", "relay", "marker"]` ## Project-Specific Notes ### Bun Requirements - Always use `bun` instead of `node`, `npm`, `yarn`, or `pnpm` - Use `bun ` instead of `node ` or `ts-node ` - Use `bun test` instead of `jest` or `vitest` - Use `bun install` instead of `npm install` - Use `bun run ================================================ FILE: packages/app/package.json ================================================ { "name": "@snort/app", "version": "0.5.2", "type": "module", "dependencies": { "@cashu/cashu-ts": "^2.7.2", "@livekit/components-react": "^2.9.15", "@livekit/protocol": "^1.42.2", "@noble/curves": "^2.0.1", "@noble/hashes": "^2.0.1", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-hover-card": "^1.1.15", "@scure/base": "^2.0.0", "@scure/bip32": "^2.0.1", "@scure/bip39": "^2.0.1", "@snort/shared": "workspace:*", "@snort/system": "workspace:*", "@snort/system-react": "workspace:*", "@snort/system-wasm": "workspace:*", "@snort/wallet": "workspace:*", "@openai/agents": "^0.8.3", "@snort/worker-relay": "workspace:*", "openai": "^6.34.0", "zod": "^4.0.0", "@uidotdev/usehooks": "^2.4.1", "@void-cat/api": "^1.0.12", "classnames": "^2.5.1", "comlink": "^4.4.2", "debug": "^4.4.3", "emojilib": "^4.0.2", "eventemitter3": "^5.0.1", "fuse.js": "^7.1.0", "latlon-geohash": "^2.0.0", "light-bolt11-decoder": "^3.2.0", "livekit-client": "^2.15.11", "lottie-react": "^2.4.1", "marked": "^16.4.1", "marked-footnote": "^1.4.0", "match-sorter": "^8.1.0", "qr-code-styling": "^1.9.2", "react": "^19.2.3", "react-dom": "^19.2.3", "react-force-graph-3d": "^1.29.0", "react-intersection-observer": "^9.16.0", "react-intl": "^7.1.14", "react-router-dom": "^7.9.4", "react-tag-input-component": "^2.0.2", "react-textarea-autosize": "^8.5.9", "recharts": "^3.3.0", "tslib": "^2.8.1", "typescript-lru-cache": "^2.0.0", "use-long-press": "^3.3.0", "use-sync-external-store": "^1.6.0", "uuid": "^13.0.0", "workbox-cacheable-response": "^7.3.0", "workbox-core": "^7.3.0", "workbox-expiration": "^7.3.0", "workbox-precaching": "^7.3.0", "workbox-routing": "^7.3.0", "workbox-strategies": "^7.3.0" }, "scripts": { "start": "bunx --bun vite", "build": "bunx vite build", "serve": "bunx --bun vite preview", "intl-extract": "bunx --bun formatjs extract 'src/**/*.ts*' --ignore='**/*.d.ts' --out-file src/lang.json --flatten true", "intl-compile": "bunx --bun formatjs compile src/lang.json --ast --out-file src/translations/en.json", "eslint": "bunx --bun eslint ." }, "browserslist": { "production": ["chrome >= 67", "edge >= 79", "firefox >= 68", "opera >= 54", "safari >= 14"], "development": ["last 1 chrome version", "last 1 firefox version", "last 1 safari version"] }, "devDependencies": { "@formatjs/cli": "^6.7.4", "@types/config": "^3.3.5", "@types/debug": "^4.1.12", "@types/latlon-geohash": "^2.0.4", "@types/node": "^24.8.1", "@types/react": "^19.2.2", "@types/react-dom": "^19.2.2", "@types/three": "^0.180.0", "@types/use-sync-external-store": "^1.5.0", "@types/uuid": "^11.0.0", "@types/webscopeio__react-textarea-autocomplete": "^4.7.5", "@types/webtorrent": "^0.110.1", "@vitejs/plugin-basic-ssl": "^2.1.0", "@vitejs/plugin-react": "^5.0.4", "@webbtc/webln-types": "^3.0.0", "@webscopeio/react-textarea-autocomplete": "^4.9.2", "babel-plugin-formatjs": "^10.5.41", "@tailwindcss/vite": "^4.1.18", "config": "^4.1.1", "prop-types": "^15.8.1", "rollup-plugin-visualizer": "^6.0.5", "tailwindcss": "^4.1.14", "tinybench": "^5.0.1", "typescript": "^5.9.3", "vite": "^7.1.10", "vite-plugin-eslint": "^1.8.1", "vite-plugin-pwa": "^1.1.0", "vite-plugin-version-mark": "^0.2.2", "vitest": "^3.2.4" } } ================================================ FILE: packages/app/public/iris/.well-known/assetlinks.json ================================================ [ { "relation": ["delegate_permission/common.handle_all_urls"], "target": { "namespace": "android_app", "package_name": "to.iris.twa", "sha256_cert_fingerprints": [ "63:B5:70:E8:F1:75:7E:D6:EF:81:11:66:F4:9D:47:AB:49:3C:2E:00:B9:67:92:40:89:A5:03:0B:96:B9:40:09" ] } } ] ================================================ FILE: packages/app/public/iris/_headers ================================================ /* Content-Security-Policy: default-src 'self'; manifest-src *; child-src 'none'; worker-src 'self'; frame-src https://youtube.com https://www.youtube.com https://platform.twitter.com https://embed.tidal.com https://w.soundcloud.com https://www.mixcloud.com https://open.spotify.com https://player.twitch.tv https://embed.music.apple.com https://embed.wavlake.com https://challenges.cloudflare.com; style-src 'self' 'unsafe-inline'; connect-src *; img-src * data: blob:; font-src 'self'; media-src * blob:; script-src 'self' 'wasm-unsafe-eval' https://platform.twitter.com https://embed.tidal.com https://challenges.cloudflare.com; /service-worker.js Cache-Control: max-age=604800, must-revalidate; ================================================ FILE: packages/app/public/iris/manifest.json ================================================ { "short_name": "Iris", "name": "Iris", "description": "Fast nostr web ui", "id": "/", "icons": [ { "src": "/img/android-chrome-192x192.png", "sizes": "192x192", "type": "image/png" }, { "src": "/img/android-chrome-512x512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" }, { "src": "/img/maskable_icon.png", "sizes": "640x640", "type": "image/png", "purpose": "maskable" }, { "src": "/img/maskable_icon_x192.png", "sizes": "192x192", "type": "image/png", "purpose": "maskable" } ], "start_url": "/", "display": "standalone", "theme_color": "#000000", "background_color": "#000000", "protocol_handlers": [ { "protocol": "web+nostr", "url": "/%s" } ] } ================================================ FILE: packages/app/public/iris/robots.txt ================================================ # https://www.robotstxt.org/robotstxt.html User-agent: * Disallow: ================================================ FILE: packages/app/public/nostr/_headers ================================================ /* Content-Security-Policy: default-src 'self'; manifest-src *; child-src 'none'; worker-src 'self'; frame-src https://youtube.com https://www.youtube.com https://platform.twitter.com https://embed.tidal.com https://w.soundcloud.com https://www.mixcloud.com https://open.spotify.com https://player.twitch.tv https://embed.music.apple.com https://embed.wavlake.com https://challenges.cloudflare.com; style-src 'self' 'unsafe-inline'; connect-src *; img-src * data: blob:; font-src 'self'; media-src * blob:; script-src 'self' 'wasm-unsafe-eval' https://platform.twitter.com https://embed.tidal.com https://challenges.cloudflare.com; ================================================ FILE: packages/app/public/phoenix/.well-known/apple-app-site-association ================================================ { "applinks": { "details": [ { "appIDs": [ "snort.social.app" ] } ] }, "webcredentials": { "apps": [ "snort.social.app" ] } } ================================================ FILE: packages/app/public/phoenix/.well-known/assetlinks.json ================================================ [ { "relation": ["delegate_permission/common.handle_all_urls"], "target": { "namespace": "android_app", "package_name": "social.snort.app", "sha256_cert_fingerprints": [ "78:CE:8A:F7:C1:E2:30:12:77:55:BF:0E:86:E4:5C:BA:99:93:A0:D7:D7:42:F8:27:8B:C9:1B:AC:FC:8A:85:05", "FC:C1:CA:02:C0:81:81:0C:1F:EC:1E:38:CA:38:61:62:6B:6E:90:88:62:DE:4A:66:FC:EC:08:33:B6:94:EE:3C" ] } } ] ================================================ FILE: packages/app/public/phoenix/_headers ================================================ /* Content-Security-Policy: default-src 'self'; manifest-src *; child-src 'none'; worker-src 'self'; frame-src https://youtube.com https://www.youtube.com https://platform.twitter.com https://embed.tidal.com https://w.soundcloud.com https://www.mixcloud.com https://open.spotify.com https://player.twitch.tv https://embed.music.apple.com https://embed.wavlake.com https://challenges.cloudflare.com; style-src 'self' 'unsafe-inline'; connect-src *; img-src * data: blob:; font-src 'self'; media-src * blob:; script-src 'self' 'wasm-unsafe-eval' https://platform.twitter.com https://embed.tidal.com https://challenges.cloudflare.com; /service-worker.js Cache-Control: max-age=604800, must-revalidate; ================================================ FILE: packages/app/public/phoenix/manifest.json ================================================ { "short_name": "Phoenix", "name": "phoenix.social - Nostr interface", "description": "Fast nostr web ui", "id": "/", "icons": [ { "src": "phoenix_256.png", "type": "image/png", "sizes": "256x256" } ], "start_url": "/", "display": "standalone", "theme_color": "#000000", "background_color": "#000000", "protocol_handlers": [ { "protocol": "web+nostr", "url": "/%s" } ], "screenshots": [], "display_override": ["fullscreen"], "related_applications": [ { "platform": "play", "url": "https://play.google.com/store/apps/details?id=social.snort.app", "id": "social.snort.app" } ] } ================================================ FILE: packages/app/public/phoenix/robots.txt ================================================ # https://www.robotstxt.org/robotstxt.html User-agent: * Disallow: Sitemap: https://api.snort.social/api/v1/sitemap/index.xml ================================================ FILE: packages/app/public/snort/.well-known/apple-app-site-association ================================================ { "applinks": { "details": [ { "appIDs": [ "snort.social.app" ] } ] }, "webcredentials": { "apps": [ "snort.social.app" ] } } ================================================ FILE: packages/app/public/snort/.well-known/assetlinks.json ================================================ [ { "relation": ["delegate_permission/common.handle_all_urls"], "target": { "namespace": "android_app", "package_name": "social.snort.app", "sha256_cert_fingerprints": [ "78:CE:8A:F7:C1:E2:30:12:77:55:BF:0E:86:E4:5C:BA:99:93:A0:D7:D7:42:F8:27:8B:C9:1B:AC:FC:8A:85:05", "FC:C1:CA:02:C0:81:81:0C:1F:EC:1E:38:CA:38:61:62:6B:6E:90:88:62:DE:4A:66:FC:EC:08:33:B6:94:EE:3C" ] } } ] ================================================ FILE: packages/app/public/snort/_headers ================================================ /* Content-Security-Policy: default-src 'self'; manifest-src *; child-src 'none'; worker-src 'self'; frame-src https://youtube.com https://www.youtube.com https://platform.twitter.com https://embed.tidal.com https://w.soundcloud.com https://www.mixcloud.com https://open.spotify.com https://player.twitch.tv https://embed.music.apple.com https://embed.wavlake.com https://challenges.cloudflare.com; style-src 'self' 'unsafe-inline'; connect-src *; img-src * data: blob:; font-src 'self'; media-src * blob:; script-src 'self' 'wasm-unsafe-eval' https://platform.twitter.com https://embed.tidal.com https://challenges.cloudflare.com; /service-worker.js Cache-Control: max-age=604800, must-revalidate; ================================================ FILE: packages/app/public/snort/manifest.json ================================================ { "short_name": "Snort", "name": "snort.social - Nostr interface", "description": "Fast nostr web ui", "id": "/", "icons": [ { "src": "nostrich_256.png", "type": "image/png", "sizes": "256x256" }, { "src": "nostrich_512.png", "type": "image/png", "sizes": "512x512" } ], "start_url": "/", "display": "standalone", "theme_color": "#000000", "background_color": "#000000", "protocol_handlers": [ { "protocol": "web+nostr", "url": "/%s" } ], "screenshots": [], "display_override": ["fullscreen"], "related_applications": [ { "platform": "play", "url": "https://play.google.com/store/apps/details?id=social.snort.app", "id": "social.snort.app" } ] } ================================================ FILE: packages/app/public/snort/robots.txt ================================================ # https://www.robotstxt.org/robotstxt.html User-agent: * Disallow: Sitemap: https://api.snort.social/api/v1/sitemap/index.xml ================================================ FILE: packages/app/src/Agent/system-prompt.ts ================================================ import { unixNow } from "@snort/shared" export const SnortSystemPrompt = `You are an AI assistant integrated with Snort, a decentralized social media client built on the Nostr protocol. You can perform actions on behalf of the user by using the available tools. ## About Snort Snort is a web-based Nostr client that allows users to: - Post text notes and media - Follow other users - React to and repost content - Send and receive Lightning Network payments (zaps) - Manage relays and privacy settings - Browse decentralized social content ### User Search Use **snort_search_username** to find users by name or NIP-05 address: - Returns matching profiles with pubkeys, display names, and NIP-05 addresses - Use this before following or mentioning users when you only know their name - Example: snort_search_username({ query: "jack" }) ## Querying Nostr Relays with snort_query_nostr When using snort_query_nostr, you need to construct Nostr REQ filter objects. The filters are passed as an array of filter objects. ### Filter Structure Each filter object can contain these keys: - **authors**: Array of 64-character hex pubkeys (NOT npub format). Example: ["8e9f..."] - **kinds**: Array of event kind numbers. Common kinds: - 1: Text notes - 2: Recommended relays - 3: Contact list (follows) - 4: Direct messages - 6: Reposts - 7: Reactions - 40-41: Channel events - 9734-9735: Zap requests - **ids**: Array of event IDs (64-char hex) - **#e**: Array of event IDs referenced (for replies/mentions) - **#p**: Array of pubkey hex that were mentioned - **#t**: Array of hashtags (lowercase, without #) - **#a**: Array of replace event tag references ({kind}:{author-pubkey-hex}:{d-tag}) - **search**: Free-text search string - **since**: Unix timestamp (seconds) - only events after this time - **until**: Unix timestamp (seconds) - only events before this time - **limit**: Maximum number of results (default 50) ### Common Query Patterns **Get recent posts from specific users:** \`\`\` [{ "authors": ["hex_pubkey_1", "hex_pubkey_2"], "kinds": [1], "limit": 50 }] \`\`\` **Search for hashtag:** \`\`\` [{ "kinds": [1], "#t": ["bitcoin"], "limit": 20 }] \`\`\` **Find replies to an event:** \`\`\` [{ "kinds": [1], "#e": ["event_id"], "limit": 50 }] \`\`\` **Find posts mentioning a user:** \`\`\` [{ "kinds": [1], "#p": ["hex_pubkey"], "limit": 20 }] \`\`\` **Text search:** \`\`\` [{ "kinds": [1], "search": "bitcoin price", "limit": 20 }] \`\`\` **Get user's contact list (follows):** \`\`\` [{ "authors": ["hex_pubkey"], "kinds": [3], "limit": 1 }] \`\`\` ## Guidelines - Always verify user is logged in before posting or making changes - Use proper Nostr identifiers (hex for filters, npub for displaying to users) - Respect privacy - don't expose private keys or sensitive info - Be explicit when performing irreversible actions (posting, following, paying) - Confirm content with user before publishing posts - Handle errors gracefully - ALWAYS prefix npub/nprofile/naddr/nevent strings with nostr: for improved rendering - When mentioning other users or events in the content of notes, ALWAYS use nostr:npub NIP-21 entities, or use @npub for npub mentions The current unix timestamp is ${unixNow()} or ${new Date()} ` ================================================ FILE: packages/app/src/Cache/CommunityLeadersStore.tsx ================================================ import { ExternalStore } from "@snort/shared" class CommunityLeadersStore extends ExternalStore> { #leaders: Array = [] setLeaders(arr: Array) { this.#leaders = arr this.notifyChange() } takeSnapshot(): string[] { return [...this.#leaders] } } export const LeadersStore = new CommunityLeadersStore() ================================================ FILE: packages/app/src/Cache/GiftWrapCache.ts ================================================ import { EventKind, type EventPublisher, type NostrEvent, type TaggedNostrEvent } from "@snort/system" import type { CacheRelay } from "@snort/system" import { findTag, unwrap } from "@/Utils" import { RefreshFeedCache, type TWithCreated } from "./RefreshFeedCache" export interface UnwrappedGift { id: string to: string created_at: number inner: NostrEvent tags?: Array> } const DecryptedContentCache = new Map() export function getCachedDecryptedContent(id: string): string | undefined { return DecryptedContentCache.get(id) } export function setCachedDecryptedContent(id: string, content: string) { DecryptedContentCache.set(id, content) } const NIP44_MIN_PAYLOAD_LEN = 132 function isValidNip44Content(content: string): boolean { return content.length >= NIP44_MIN_PAYLOAD_LEN } export class GiftWrapCache extends RefreshFeedCache { #relay: CacheRelay | undefined #persistedIds: Set = new Set() constructor() { super("GiftWrapCache") } setRelay(relay: CacheRelay) { this.#relay = relay } key(of: UnwrappedGift): string { return of.id } buildSub(): void {} takeSnapshot(): Array { return [...this.cache.values()] } override async preload(): Promise { await super.preload() } override async onEvent(evs: Readonly>, _: string, pub?: EventPublisher) { if (!pub) return const fresh = evs.filter(v => !this.#persistedIds.has(v.id) && !this.cache.has(v.id)) if (fresh.length === 0) return const valid = fresh.filter(v => isValidNip44Content(v.content)) const unwrapped = ( await Promise.all( valid.map(async v => { try { return { id: v.id, to: findTag(v, "p"), created_at: v.created_at, inner: await pub.unwrapGift(v), raw: v, } as UnwrappedGift & { raw: TaggedNostrEvent } } catch (e) { console.debug(e, v) } }), ) ) .filter(a => a !== undefined) .map(unwrap) const failed = new Set() for (let i = 0; i < unwrapped.length; i++) { const u = unwrapped[i] if (u.inner.kind === EventKind.SealedRumor) { try { if (!isValidNip44Content(u.inner.content)) { failed.add(i) continue } const unsealed = await pub.unsealRumor(u.inner) u.tags = unsealed.tags } catch (e) { console.debug("Failed to unseal rumor", u.id, e) failed.add(i) } } } const good = unwrapped.filter((_, i) => !failed.has(i)) if (this.#relay) { const toPersist = good.filter(u => !this.#persistedIds.has(u.id)) if (toPersist.length > 0) { try { await Promise.all(toPersist.map(u => this.#relay?.event(u.raw))) for (const u of toPersist) { this.#persistedIds.add(u.id) } } catch (e) { console.warn("GiftWrapCache: failed to persist to worker relay", e) } } } const cleaned = good.map(({ raw, ...rest }) => rest) await this.bulkSet(cleaned) } async loadPersistedAndDecrypt(pub: EventPublisher): Promise { if (!this.#relay) return try { const existing = await this.#relay.query(["REQ", "giftwrap-load", { kinds: [EventKind.GiftWrap] }]) for (const ev of existing) { this.#persistedIds.add(ev.id) } const newEvs = existing.filter(a => !this.cache.has(a.id)) if (newEvs.length === 0) return await this.onEvent(newEvs, "", pub) } catch (e) { console.warn("GiftWrapCache: failed to load persisted gift wraps", e) } } override async clear(): Promise { if (this.#relay) { try { await this.#relay.delete(["REQ", "giftwrap-clear", { kinds: [EventKind.GiftWrap] }]) } catch (e) { console.warn("GiftWrapCache: failed to clear worker relay", e) } } this.#persistedIds.clear() DecryptedContentCache.clear() await super.clear() } search(): Promise[]> { throw new Error("Method not implemented.") } } ================================================ FILE: packages/app/src/Cache/ProfileWorkerCache.ts ================================================ import { type CachedMetadata, type CacheRelay, EventKind, mapEventToProfile, type NostrEvent } from "@snort/system" import { WorkerBaseCache } from "./worker-cached" export class ProfileCacheRelayWorker extends WorkerBaseCache { constructor(relay: CacheRelay) { super(EventKind.SetMetadata, relay) } name(): string { return "Profiles" } maxSize(): number { return 5_000 } mapper(ev: NostrEvent): CachedMetadata | undefined { return mapEventToProfile(ev) } override async preload(follows?: Array) { await super.preload() // load relay lists for follows if (follows) { await this.preloadTable(`${this.name()}-preload-follows`, { kinds: [EventKind.SetMetadata], authors: follows, }) } } } ================================================ FILE: packages/app/src/Cache/RefreshFeedCache.ts ================================================ import { FeedCache } from "@snort/shared" import type { EventPublisher, RequestBuilder, TaggedNostrEvent } from "@snort/system" import type { LoginSession } from "@/Utils/Login" export type TWithCreated = (T | Readonly) & { created_at: number } export abstract class RefreshFeedCache extends FeedCache> { abstract buildSub(session: LoginSession, rb: RequestBuilder): void abstract onEvent(evs: Readonly>, pubKey: string, pub?: EventPublisher): void /** * Get latest event */ protected newest(filter?: (e: TWithCreated) => boolean) { let ret = 0 this.cache.forEach(v => { if (!filter || filter(v)) { ret = v.created_at > ret ? v.created_at : ret } }) return ret } override async preload(): Promise { await super.preload() await this.buffer([...this.onTable]) } } ================================================ FILE: packages/app/src/Cache/RelaysWorkerCache.ts ================================================ import { unixNowMs } from "@snort/shared" import { type CacheRelay, EventKind, type NostrEvent, type UsersRelays, parseRelaysFromKind } from "@snort/system" import { WorkerBaseCache } from "./worker-cached" export class RelaysWorkerCache extends WorkerBaseCache { constructor(relay: CacheRelay) { super(EventKind.Relays, relay) } name(): string { return "Relays" } maxSize(): number { return 5_000 } mapper(ev: NostrEvent): UsersRelays | undefined { const relays = parseRelaysFromKind(ev) if (!relays) return return { pubkey: ev.pubkey, loaded: unixNowMs(), created: ev.created_at, relays: relays, } } override async preload(follows?: Array) { await super.preload() // load relay lists for follows if (follows) { await this.preloadTable(`${this.name()}-preload-follows`, { kinds: [EventKind.Relays], authors: follows, }) } } } ================================================ FILE: packages/app/src/Cache/UserFollowsWorker.ts ================================================ import { unixNowMs } from "@snort/shared" import { type CacheRelay, EventKind, type NostrEvent, type UsersFollows } from "@snort/system" import { WorkerBaseCache } from "./worker-cached" export class UserFollowsWorker extends WorkerBaseCache { constructor(relay: CacheRelay) { super(EventKind.ContactList, relay) } name(): string { return "Follows" } maxSize(): number { return 5_000 } mapper(ev: NostrEvent): UsersFollows | undefined { if (ev.kind !== EventKind.ContactList) return return { pubkey: ev.pubkey, loaded: unixNowMs(), created: ev.created_at, follows: ev.tags, } } override async preload(follows?: Array) { await super.preload() // load relay lists for follows if (follows) { await this.preloadTable(`${this.name()}-preload-follows`, { kinds: [EventKind.ContactList], authors: follows, }) } } } ================================================ FILE: packages/app/src/Cache/index.ts ================================================ import { type CacheRelay, Connection, ConnectionCacheRelay, UserFollowsCache, UserProfileCache, UserRelaysCache, } from "@snort/system" import { WorkerRelayInterface } from "@snort/worker-relay" import WorkerVite from "@snort/worker-relay/src/worker?worker" import { GiftWrapCache } from "./GiftWrapCache" import { ProfileCacheRelayWorker } from "./ProfileWorkerCache" import { UserFollowsWorker } from "./UserFollowsWorker" import { RelaysWorkerCache } from "./RelaysWorkerCache" import { hasWasm } from "@/Utils/wasm" const cacheRelay = localStorage.getItem("cache-relay") const workerRelay = hasWasm ? new WorkerRelayInterface( import.meta.env.DEV ? new URL("@snort/worker-relay/dist/esm/worker.mjs", import.meta.url) : new WorkerVite(), ) : undefined export const Relay: CacheRelay | undefined = cacheRelay ? new ConnectionCacheRelay(new Connection(cacheRelay, { read: true, write: true })) : workerRelay async function tryUseCacheRelay(url: string) { try { const conn = new Connection(url, { read: true, write: true }) await conn.connect(true) localStorage.setItem("cache-relay", url) return conn } catch (e) { console.warn(e) } } export async function tryUseLocalRelay() { let conn = await tryUseCacheRelay("ws://localhost:4869") if (!conn) { conn = await tryUseCacheRelay("ws://umbrel:4848") } return conn } export async function initRelayWorker() { try { if (Relay instanceof ConnectionCacheRelay) { await Relay.connection.connect(true) return } } catch (e) { localStorage.removeItem("cache-relay") console.error(e) if (cacheRelay) { window.location.reload() } } try { if (workerRelay) { await workerRelay.debug("*") await workerRelay.init({ databasePath: "relay.db", insertBatchSize: 100, }) await workerRelay.configureSearchIndex({ 1: [], // add index for kind 1, dont index tags }) } } catch (e) { console.error(e) } } export const UserRelays = Relay ? new RelaysWorkerCache(Relay) : new UserRelaysCache() export const UserFollows = Relay ? new UserFollowsWorker(Relay) : new UserFollowsCache() export const ProfilesCache = Relay ? new ProfileCacheRelayWorker(Relay) : new UserProfileCache() export const GiftsCache = new GiftWrapCache() if (Relay) { GiftsCache.setRelay(Relay) } export async function preload(follows?: Array) { const preloads = [ ProfilesCache.preload(follows), GiftsCache.preload(), UserRelays.preload(follows), UserFollows.preload(follows), ] await Promise.all(preloads) } ================================================ FILE: packages/app/src/Cache/worker-cached.ts ================================================ import { type CachedTable, type CacheEvents, removeUndefined, unixNowMs } from "@snort/shared" import type { CachedBase, CacheRelay, NostrEvent, ReqFilter } from "@snort/system" import debug from "debug" import { EventEmitter } from "eventemitter3" import { LRUCache } from "typescript-lru-cache" /** * Generic worker relay based cache, key by pubkey */ export abstract class WorkerBaseCache extends EventEmitter> implements CachedTable { #relay: CacheRelay #cache = new LRUCache({ maxSize: this.maxSize() }) #keys = new Set() #log = debug(this.name()) /** Per-key subscribers for O(1) targeted notifications */ #keyListeners = new Map void>>() constructor( readonly kind: number, relay: CacheRelay, ) { super() this.#relay = relay } async clear() { this.#cache.clear() this.emit("change", []) } key(of: T): string { return of.pubkey } abstract name(): string abstract maxSize(): number abstract mapper(ev: NostrEvent): T | undefined /** * Preload only the ids from the worker relay */ async preload() { await this.preloadTable(`${this.name()}-preload-ids`, { kinds: [this.kind], ids_only: true }) } /** * Reload the table with a request filter */ protected async preloadTable(id: string, f: ReqFilter) { const start = unixNowMs() const data = await this.#relay.query(["REQ", id, f]) if (f.ids_only === true) { this.#keys = new Set(data as unknown as Array) } else { const mapped = removeUndefined(data.map(a => this.mapper(a))) for (const o of mapped) { this.#cache.set(o.pubkey, o) } } this.#log(`Loaded %d/%d in %d ms`, this.#cache.size, this.#keys.size, (unixNowMs() - start).toLocaleString()) } async search(q: string) { const results = await this.#relay.query([ "REQ", `${this.name()}-search`, { kinds: [this.kind], search: q, }, ]) return removeUndefined(results.map(this.mapper)) } keysOnTable(): string[] { return [...this.#keys] } getFromCache(key?: string | undefined) { if (key) { return this.#cache.get(key) || undefined } } discover(ev: NostrEvent) { this.#keys.add(ev.pubkey) } async get(key?: string | undefined): Promise { if (key) { const res = await this.bulkGet([key]) if (res.length > 0) { return res[0] } } } async bulkGet(keys: string[]) { if (keys.length === 0) return [] const results = await this.#relay.query([ "REQ", `${this.name()}-bulk`, { authors: keys, kinds: [this.kind], }, ]) const mapped = removeUndefined(results.map(this.mapper)) for (const pf of mapped) { this.#cache.set(pf.pubkey, pf) } this.emit( "change", mapped.map(a => a.pubkey), ) return mapped } /** * Because the internal type is different than T we cannot actually persist this value into the worker relay * meaning that we can only update our internal cache, implementations must ensure that their data is externally * persisted into the worker relay */ private setInternal(obj: T) { const k = this.key(obj) const cached = this.#cache.get(k) if (cached?.loaded && cached?.loaded >= obj.loaded) { return //skip if newer is in cache } this.#keys.add(k) this.#cache.set(k, obj) } async set(obj: T) { const k = this.key(obj) this.setInternal(obj) this.emit("change", [k]) this.#notifyKeyListeners(k) } async bulkSet(obj: T[] | readonly T[]) { obj.map(a => this.setInternal(a)) this.emit("change", obj.map(this.key)) for (const v of obj) { this.#notifyKeyListeners(this.key(v)) } } async update(obj: T): Promise<"new" | "refresh" | "updated" | "no_change"> { const k = this.key(obj) const existing = this.getFromCache(k) as (T & { created: number; loaded: number }) | undefined if (existing) { const typedObj = obj as T & { created?: number; loaded?: number } // If we have a newer or same-age entry already cached, skip the overwrite if (existing.created !== undefined && typedObj.created !== undefined && existing.created > typedObj.created) { return "no_change" } if (existing.loaded !== undefined && typedObj.loaded !== undefined && existing.loaded >= typedObj.loaded) { return "no_change" } } await this.set(obj) return existing ? "updated" : "new" } /** * Subscribe to changes for a specific key only. * O(1) per notification — more efficient than listening to the broad "change" event. * Returns an unsubscribe function. */ subscribe(key: string, cb: () => void): () => void { let listeners = this.#keyListeners.get(key) if (!listeners) { listeners = new Set() this.#keyListeners.set(key, listeners) } listeners.add(cb) return () => { const s = this.#keyListeners.get(key) if (s) { s.delete(cb) if (s.size === 0) { this.#keyListeners.delete(key) } } } } #notifyKeyListeners(key: string) { const listeners = this.#keyListeners.get(key) if (listeners) { for (const cb of listeners) { cb() } } } async buffer(keys: string[]): Promise { const missing = keys.filter(a => !this.#cache.has(a)) const res = await this.bulkGet(missing) return missing.filter(a => !res.some(b => this.key(b) === a)) } snapshot(): T[] { return [...this.#cache.values()] } } ================================================ FILE: packages/app/src/Components/AskSnort/AskSnortInput.tsx ================================================ import { useState } from "react" import { useNavigate } from "react-router-dom" import { FormattedMessage, useIntl } from "react-intl" import { AsyncIcon } from "@/Components/Button/AsyncIcon" import Textarea from "@/Components/Textarea/Textarea" import Icon from "@/Components/Icons/Icon" export function AskSnortInput() { const navigate = useNavigate() const [input, setInput] = useState("") const { formatMessage } = useIntl(); function handleSubmit() { if (!input.trim()) return navigate("/agent", { state: { initialMessage: input } }) } function handleKeyDown(e: React.KeyboardEvent) { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault() e.stopPropagation() handleSubmit() } } return (
{ const words = Object.entries(FixedModeration) .filter(([k]) => topics.includes(k)) .flatMap(([, v]) => v.words) .concat( extraTerms .split(",") .map(a => a.trim()) .filter(a => a.length > 1), ) if (words.length > 0) { await addMutedWord(words) } navigate("/") }} >
) } ================================================ FILE: packages/app/src/Pages/onboarding/profile.tsx ================================================ import { NotEncrypted } from "@snort/system" import { SnortContext } from "@snort/system-react" import { use, useEffect, useState } from "react" import { FormattedMessage } from "react-intl" import { useLocation, useNavigate } from "react-router-dom" import AsyncButton from "@/Components/Button/AsyncButton" import AvatarEditor from "@/Components/User/AvatarEditor" import { trackEvent } from "@/Utils" import { generateNewLogin, generateNewLoginKeys } from "@/Utils/Login" import type { NewUserState } from "." export default function Profile() { const system = use(SnortContext) const [keys, setNewKeys] = useState<{ entropy: Uint8Array; privateKey: string }>() const [picture, setPicture] = useState() const [error, setError] = useState("") const navigate = useNavigate() const location = useLocation() const state = location.state as NewUserState useEffect(() => { generateNewLoginKeys().then(setNewKeys) }, []) async function loginNewKeys() { try { if (!keys) return setError("") await generateNewLogin(keys, system, key => Promise.resolve(new NotEncrypted(key)), { name: state.name, picture, }) trackEvent("Login", { newAccount: true }) navigate("/login/sign-up/topics") } catch (e) { if (e instanceof Error) { setError(e.message) } } } return (

setPicture(p)} privKey={keys?.privateKey} /> loginNewKeys()}> {error && {error}}
) } ================================================ FILE: packages/app/src/Pages/onboarding/routes.ts ================================================ export const OnboardingRoutes = { path: "/login", async lazy() { const { OnboardingLayout } = await import(".") return { Component: OnboardingLayout } }, children: [ { index: true, async lazy() { const { SignIn } = await import(".") return { Component: SignIn } }, }, { path: "sign-up", async lazy() { const { SignUp } = await import(".") return { Component: SignUp } }, }, { path: "sign-up/profile", async lazy() { const { Profile } = await import(".") return { Component: Profile } }, }, { path: "sign-up/topics", async lazy() { const { Topics } = await import(".") return { Component: Topics } }, }, { path: "sign-up/discover", async lazy() { const { Discover } = await import(".") return { Component: Discover } }, }, { path: "sign-up/moderation", async lazy() { const { Moderation } = await import(".") return { Component: Moderation } }, }, ], } ================================================ FILE: packages/app/src/Pages/onboarding/sign-in.tsx ================================================ import { Nip46Signer, Nip7Signer, NotEncrypted, PrivateKeySigner } from "@snort/system" import classNames from "classnames" import { useState } from "react" import { FormattedMessage, useIntl } from "react-intl" import { Link, useNavigate } from "react-router-dom" import AsyncButton from "@/Components/Button/AsyncButton" import Icon from "@/Components/Icons/Icon" import useLoginHandler from "@/Hooks/useLoginHandler" import { trackEvent } from "@/Utils" import { LoginSessionType, LoginStore } from "@/Utils/Login" import { Bech32Regex, unwrap } from "@snort/shared" const isAndroid = /Android/i.test(navigator.userAgent) const NIP46_PERMS = "nip04_encrypt,nip04_decrypt,sign_event:0,sign_event:1,sign_event:3,sign_event:4,sign_event:6,sign_event:7,sign_event:30078" export default function SignIn() { const navigate = useNavigate() const { formatMessage } = useIntl() const [key, setKey] = useState("") const [error, setError] = useState("") const [useKey, setUseKey] = useState(false) const loginHandler = useLoginHandler() const hasNip7 = "nostr" in window const hasNip46 = isAndroid async function doNip07Login() { setError("") try { const signer = new Nip7Signer() const pubKey = await signer.getPubKey() LoginStore.loginWithPubkey(pubKey, LoginSessionType.Nip7) trackEvent("Login", { type: "NIP7" }) navigate("/") } catch (e) { setError(e instanceof Error ? e.message : formatMessage({ defaultMessage: "Unknown login error", id: "OLEm6z" })) } } async function doNip46Login() { setError("") try { const clientSigner = PrivateKeySigner.random() const clientPubkey = await clientSigner.getPubKey() const secret = crypto.randomUUID().replace(/-/g, "") const relay = Object.keys(CONFIG.defaultRelays)[0] const connectUrl = `nostrconnect://${clientPubkey}?relay=${encodeURIComponent(relay)}&secret=${secret}&perms=${NIP46_PERMS}` const nip46 = new Nip46Signer(connectUrl, clientSigner) const onVisible = () => { if (document.visibilityState === "visible") { globalThis.location.href = connectUrl } } document.addEventListener("visibilitychange", onVisible) const relayReady = new Promise(resolve => { nip46.once("ready", () => resolve()) }) const initPromise = nip46.init() await relayReady globalThis.location.href = connectUrl await initPromise document.removeEventListener("visibilitychange", onVisible) const loginPubkey = await nip46.getPubKey() LoginStore.loginWithPubkey( loginPubkey, LoginSessionType.Nip46, undefined, nip46.relays, new NotEncrypted(unwrap(nip46.privateKey)), ) nip46.close() trackEvent("Login", { type: "NIP46" }) navigate("/") } catch (e) { setError(e instanceof Error ? e.message : formatMessage({ defaultMessage: "Unknown login error", id: "OLEm6z" })) } } async function onSubmit(e: Event) { e.preventDefault() doLogin(key) } async function doLogin(key: string) { setError("") try { await loginHandler.doLogin(key, key => Promise.resolve(new NotEncrypted(key))) trackEvent("Login", { type: "Key" }) navigate("/") } catch (e) { if (e instanceof Error) { setError(e.message) } else { setError( formatMessage({ defaultMessage: "Unknown login error", id: "OLEm6z", }), ) } console.error(e) } } const onChange = (e: React.ChangeEvent) => { const val = e.target.value if (val.match(Bech32Regex)) { doLogin(val) } else { setKey(val) } } const signerExtLogin = (hasNip7 || hasNip46) && !useKey return (

{signerExtLogin && }
{error && {error}}
{signerExtLogin && ( <> {hasNip7 && (
)} {hasNip46 && (
)} setUseKey(true)}> )} {(!signerExtLogin || useKey) && (
)}
navigate("/login/sign-up")}>
) } ================================================ FILE: packages/app/src/Pages/onboarding/sign-up.tsx ================================================ import AsyncButton from "@/Components/Button/AsyncButton" import { trackEvent } from "@/Utils" import { generateNewLogin, generateNewLoginKeys } from "@/Utils/Login" import { NotEncrypted } from "@snort/system" import { SnortContext } from "@snort/system-react" import { useState, use, type FormEvent } from "react" import { useIntl, FormattedMessage } from "react-intl" import { useNavigate, Link } from "react-router-dom" import type { NewUserState } from "." import { Bech32Regex } from "@snort/shared" export default function SignUp() { const { formatMessage } = useIntl() const navigate = useNavigate() const [name, setName] = useState("") const system = use(SnortContext) const onSubmit = async (e: FormEvent) => { e.preventDefault() if (CONFIG.signUp.quickStart) { return generateNewLogin(await generateNewLoginKeys(), system, key => Promise.resolve(new NotEncrypted(key)), { name, }).then(() => { trackEvent("Login", { newAccount: true }) navigate("/trending/notes") }) } navigate("/login/sign-up/profile", { state: { name: name, } as NewUserState, }) } const onChange = (e: React.ChangeEvent) => { const val = e.target.value if (val.match(Bech32Regex)) { e.preventDefault() } else { setName(val) } } return (

{CONFIG.signUp.quickStart ? ( ) : ( )}
navigate("/login")}>
) } ================================================ FILE: packages/app/src/Pages/onboarding/topics.tsx ================================================ import { EventKind } from "@snort/system" import classNames from "classnames" import { type ReactNode, useState } from "react" import { FormattedMessage } from "react-intl" import { useNavigate } from "react-router-dom" import AsyncButton from "@/Components/Button/AsyncButton" import useEventPublisher from "@/Hooks/useEventPublisher" import { FixedTopics } from "@/Pages/onboarding/fixedTopics" import { appendDedupe } from "@/Utils" export default function Topics() { const { publisher, system } = useEventPublisher() const [topics, setTopics] = useState>([]) const navigate = useNavigate() function tab(name: string, text: ReactNode) { const active = topics.includes(name) return (
setTopics(s => (active ? s.filter(a => a !== name) : appendDedupe(s, [name])))} > {text}
) } return (

{Object.entries(FixedTopics).map(([k, v]) => tab(k, v.text))}
{ const tags = Object.entries(FixedTopics) .filter(([k]) => topics.includes(k)) .flatMap(([, v]) => v.tags) if (tags.length > 0) { const ev = await publisher?.generic(eb => { eb.kind(EventKind.InterestsList) tags.forEach(a => eb.tag(["t", a])) return eb }) if (ev) { await system.BroadcastEvent(ev) } } navigate("/login/sign-up/discover") }} >
) } ================================================ FILE: packages/app/src/Pages/settings/Accounts.tsx ================================================ import { FormattedMessage } from "react-intl" import { Link } from "react-router-dom" import ProfilePreview from "@/Components/User/ProfilePreview" import { LoginStore } from "@/Utils/Login" import { getActiveSubscriptions } from "@/Utils/Subscription" export default function AccountsPage() { const logins = LoginStore.getSessions() const sub = getActiveSubscriptions(LoginStore.allSubscriptions()) return (

{logins.map(a => (
} />
))} {sub && ( )}
) } ================================================ FILE: packages/app/src/Pages/settings/Cache.tsx ================================================ import type { CachedTable } from "@snort/shared" import { ConnectionCacheRelay } from "@snort/system" import { WorkerRelayInterface } from "@snort/worker-relay" import { type ReactNode, use, useEffect, useState, } from "react" import { FormattedMessage, FormattedNumber } from "react-intl" import { useNavigate } from "react-router-dom" import { GiftsCache, Relay, tryUseLocalRelay, } from "@/Cache" import AsyncButton from "@/Components/Button/AsyncButton" import useLogin from "@/Hooks/useLogin" import { SnortContext } from "@snort/system-react" import { CollapsedSection } from "@/Components/Collapsed" export function CacheSettings() { const system = use(SnortContext) return (

{Relay && } } /> } /> } /> } />
) } function CacheDetails({ cache, name }: { cache: CachedTable; name: ReactNode }) { const [snapshot, setSnapshot] = useState>(cache.snapshot()) useEffect(() => { const h = () => { setSnapshot(cache.snapshot()) } cache.on("change", h) return () => { cache.off("change", h) } }, [cache]) return (
{name} , count2: , }} />
cache.clear()}>
) } function RelayCacheStats() { const [counts, setCounts] = useState>({}) const [myEvents, setMyEvents] = useState(0) const login = useLogin() const navigate = useNavigate() useEffect(() => { if (Relay instanceof WorkerRelayInterface) { Relay.summary().then(setCounts) if (login.publicKey) { Relay.count(["REQ", "my", { authors: [login.publicKey] }]).then(setMyEvents) } } }, [login.publicKey]) function relayType() { if (Relay instanceof WorkerRelayInterface) { return } else if (Relay instanceof ConnectionCacheRelay) { return } } return (
{myEvents > 0 && (
, }} />
)} }> {Object.entries(counts) .sort(([, a], [, b]) => (a > b ? -1 : 1)) .map(([k, v]) => { return ( ) })}
{Relay instanceof WorkerRelayInterface && ( <> { if (Relay instanceof WorkerRelayInterface) { await Relay.wipe() window.location.reload() } }} > { const data = Relay instanceof WorkerRelayInterface ? await Relay.dump() : undefined if (data) { const url = URL.createObjectURL( new File([data.buffer as ArrayBuffer], "snort.db", { type: "application/octet-stream", }), ) const a = document.createElement("a") a.href = url a.download = "snort.db" a.click() } }} > )} navigate("/cache-debug")}> {!(Relay instanceof ConnectionCacheRelay) && ( { if (await tryUseLocalRelay()) { window.location.reload() } else { alert("No local relay found") } }} > )}
) } ================================================ FILE: packages/app/src/Pages/settings/Keys.css ================================================ .mnemonic-grid { display: grid; text-align: center; grid-template-columns: repeat(4, 1fr); gap: 8px; } .mnemonic-grid > div { border: 1px solid #222222; border-radius: 5px; overflow: hidden; user-select: none; } .mnemonic-grid .word > div:nth-of-type(1) { background-color: var(--color-neutral-700); padding: 4px 8px; min-width: 2em; font-variant-numeric: ordinal; } .mnemonic-grid .word > div:nth-of-type(2) { flex-grow: 1; } ================================================ FILE: packages/app/src/Pages/settings/Keys.tsx ================================================ import "./Keys.css" import { KeyStorage } from "@snort/system" import { FormattedMessage } from "react-intl" import Copy from "@/Components/Copy/Copy" import useLogin from "@/Hooks/useLogin" import { seedToMnemonic } from "@/Utils/nip6" import { encodeTLV, hexToBech32, NostrPrefix } from "@snort/shared" import { hexToBytes } from "@noble/hashes/utils.js" export default function ExportKeys() { const { publicKey, privateKeyData, generatedEntropy } = useLogin() const copyClass = "p-3 rounded-lg border border-dashed" return (
{privateKeyData instanceof KeyStorage && ( <>
)} {generatedEntropy && ( <>
{seedToMnemonic(generatedEntropy ?? "") .split(" ") .map((a, i) => (
{i + 1}
{a}
))}
)}
) } ================================================ FILE: packages/app/src/Pages/settings/Menu/Menu.tsx ================================================ import { type ReactNode, useCallback } from "react" import { FormattedMessage } from "react-intl" import { useNavigate } from "react-router-dom" import useLogin from "@/Hooks/useLogin" import { SettingsMenuComponent } from "@/Pages/settings/Menu/SettingsMenuComponent" import { LoginStore, logout } from "@/Utils/Login" import { getCurrentSubscription } from "@/Utils/Subscription" export type SettingsMenuItems = Array<{ title: ReactNode items: Array<{ icon: string iconBg: string message: ReactNode path?: string action?: () => void }> }> const SettingsIndex = () => { const login = useLogin() const navigate = useNavigate() const sub = getCurrentSubscription(LoginStore.allSubscriptions()) const handleLogout = useCallback(() => { logout(login.id) navigate("/") }, [login.id, navigate]) const settingsGroups = [ { title: , items: [ { icon: "profile", iconBg: "bg-green-500", message: , path: "profile", }, { icon: "key", iconBg: "bg-amber-500", message: , path: "keys", }, ...(CONFIG.features.nostrAddress ? [ { icon: "badge", iconBg: "bg-pink-500", message: , path: "handle", }, ] : []), { icon: "gear", iconBg: "bg-slate-500", message: , path: "preferences", }, { icon: "wallet", iconBg: "bg-emerald-500", message: , path: "wallet", }, ...(sub ? [ { icon: "code-circle", iconBg: "bg-indigo-500", message: , path: "accounts", }, ] : []), { icon: "tool", iconBg: "bg-slate-800", message: , path: "tools", }, ], }, { title: , items: [ { icon: "relay", iconBg: "bg-dark bg-opacity-20", message: , path: "relays", }, { icon: "shield-tick", iconBg: "bg-yellow-500", message: , path: "moderation", }, ...(CONFIG.features.pushNotifications ? [ { icon: "bell-outline", iconBg: "bg-red-500", message: , path: "notifications", }, ] : []), ...(CONFIG.features.communityLeaders ? [ { icon: "link", iconBg: "bg-blue-500", message: , path: "invite", }, ] : []), { icon: "hard-drive", iconBg: "bg-cyan-500", message: , path: "cache", }, { icon: "camera-plus", iconBg: "bg-lime-500", message: , path: "media", }, ], }, { title: , items: [ { icon: "heart", iconBg: "bg-purple-500", message: , path: "/about", }, ...(CONFIG.features.subscriptions ? [ { icon: "diamond", iconBg: "bg-violet-500", message: , path: "/subscribe/manage", }, ] : []), ...(CONFIG.features.zapPool ? [ { icon: "piggy-bank", iconBg: "bg-rose-500", message: , path: "/zap-pool", }, ] : []), ], }, { title: , items: [ { icon: "logout", iconBg: "bg-red-500", message: , action: handleLogout, }, ], }, ] as SettingsMenuItems return } export default SettingsIndex ================================================ FILE: packages/app/src/Pages/settings/Menu/SettingsMenuComponent.tsx ================================================ import classNames from "classnames" import { Link } from "react-router-dom" import Icon from "@/Components/Icons/Icon" import type { SettingsMenuItems } from "@/Pages/settings/Menu/Menu" export function SettingsMenuComponent({ menu }: { menu: SettingsMenuItems }) { return (
{menu.map((group, groupIndex) => (
{group.title}
{group.items.map(({ icon, iconBg, message, path, action }, index) => (
{message}
))}
))}
) } ================================================ FILE: packages/app/src/Pages/settings/Moderation.tsx ================================================ import { useState } from "react" import { FormattedMessage } from "react-intl" import AsyncButton from "@/Components/Button/AsyncButton" import useModeration from "@/Hooks/useModeration" import { useAllPreferences } from "@/Hooks/usePreferences" export default function ModerationSettingsPage() { const { addMutedWord, removeMutedWord, getMutedWords } = useModeration() const preferences = useAllPreferences() const [muteWord, setMuteWord] = useState("") return ( <>

preferences.update({ ...preferences.preferences, showContentWarningPosts: !preferences.preferences.showContentWarningPosts, }) } className="mr-2" id="showContentWarningPosts" />

setMuteWord(e.target.value.toLowerCase())} /> { await addMutedWord(muteWord) setMuteWord("") }} >
{getMutedWords().map(v => (
{v}
removeMutedWord(v)}>
))}
) } ================================================ FILE: packages/app/src/Pages/settings/Notifications.tsx ================================================ import { useEffect, useState } from "react" import { FormattedMessage } from "react-intl" import Icon from "@/Components/Icons/Icon" import useEventPublisher from "@/Hooks/useEventPublisher" import useLogin from "@/Hooks/useLogin" import { subscribeToNotifications } from "@/Utils/Notifications" import messages from "./messages" interface StatusIndicatorProps { status: boolean enabledMessage: React.ComponentProps disabledMessage: React.ComponentProps } const StatusIndicator = ({ status, enabledMessage, disabledMessage }: StatusIndicatorProps) => { return status ? (
) : (
) } const PreferencesPage = () => { const login = useLogin() const { publisher } = useEventPublisher() const [serviceWorkerReady, setServiceWorkerReady] = useState(false) const hasNotificationsApi = "Notification" in window const [notificationsAllowed, setNotificationsAllowed] = useState( hasNotificationsApi && Notification.permission === "granted", ) const [subscribedToPush, setSubscribedToPush] = useState(false) const allGood = !login.readonly && hasNotificationsApi && notificationsAllowed && serviceWorkerReady useEffect(() => { if ("serviceWorker" in navigator) { navigator.serviceWorker.ready.then(registration => { if (registration.active) { setServiceWorkerReady(true) } }) } }, []) const trySubscribePush = async () => { try { if (allGood && publisher && !subscribedToPush) { await subscribeToNotifications(publisher) setSubscribedToPush(true) } } catch (e) { console.error(e) } } useEffect(() => { trySubscribePush() }, [trySubscribePush]) const requestNotificationPermission = () => { Notification.requestPermission().then(permission => { const allowed = permission === "granted" setNotificationsAllowed(allowed) if (!allowed) { alert("Please allow notifications in your browser settings and try again.") } }) } if (!login.publicKey) { return null } return (

{hasNotificationsApi && !notificationsAllowed && ( )}
{allGood && !subscribedToPush && ( )}
) } export default PreferencesPage ================================================ FILE: packages/app/src/Pages/settings/Preferences.tsx ================================================ import { type ReactNode, useState } from "react" import { FormattedMessage, useIntl } from "react-intl" import AsyncButton from "@/Components/Button/AsyncButton" import { AllLanguageCodes } from "@/Components/IntlProvider/IntlProviderUtils" import { useLocale } from "@/Components/IntlProvider/useLocale" import { useAllPreferences } from "@/Hooks/usePreferences" import { unwrap } from "@/Utils" import { DefaultImgProxy } from "@/Utils/Const" import type { UserPreferences } from "@/Utils/Login" import messages from "./messages" const PreferencesPage = () => { const { formatMessage } = useIntl() const { preferences: pref, update: setPref, save } = useAllPreferences() const [error, _setError] = useState("") const { lang } = useLocale() function row(title: ReactNode, description: ReactNode | undefined, control: ReactNode) { return (

{title}

{description && {description}}
{control}
) } return (

save()}> {error && {error}} {/** START CONTROLS */} {row( , undefined, , )} {row( , undefined, , )} {row( , undefined, , )} {row( , , setPref({ ...pref, telemetry: e.target.checked })} />, )} {row( , , , )} {row( , , setPref({ ...pref, checkSigs: e.target.checked })} />, )} {row( , , setPref({ ...pref, muteWithWoT: e.target.checked })} />, )} {row( , , setPref({ ...pref, hideMutedNotes: e.target.checked })} />, )} {row( , , setPref({ ...pref, autoTranslate: e.target.checked })} />, )} {row( , , setPref({ ...pref, pow: parseInt(e.target.value || "0", 10) })} />, )} {row( , undefined, setPref({ ...pref, defaultZapAmount: parseInt(e.target.value || "0", 10) })} />, )} {row( , , setPref({ ...pref, showBadges: e.target.checked })} />, )} {row( , , setPref({ ...pref, showStatus: e.target.checked })} />, )} {row( , , setPref({ ...pref, autoZap: e.target.checked })} />, )} {row( , , setPref({ ...pref, imgProxyConfig: e.target.checked ? DefaultImgProxy : undefined, }) } />, )} {pref.imgProxyConfig && (
setPref({ ...pref, imgProxyConfig: { ...unwrap(pref.imgProxyConfig), url: e.target.value, }, }) } />
setPref({ ...pref, imgProxyConfig: { ...unwrap(pref.imgProxyConfig), key: e.target.value, }, }) } />
setPref({ ...pref, imgProxyConfig: { ...unwrap(pref.imgProxyConfig), salt: e.target.value, }, }) } />
)} {row( , , setPref({ ...pref, enableReactions: e.target.checked })} />, )} {row( , , setPref({ ...pref, agentUrl: e.target.value })} className="w-64" />, )} {row( , , setPref({ ...pref, agentKey: e.target.value })} className="w-64" />, )} {pref.agentUrl && (
setPref({ ...pref, agentModel: e.target.value })} className="w-64" />
)} {row( , , { const split = e.target.value.match(/[\p{L}\S]{1}/u) setPref({ ...pref, reactionEmoji: split?.[0] ?? "", }) }} />, )} {row( , , setPref({ ...pref, confirmReposts: e.target.checked })} />, )} {row( , , setPref({ ...pref, autoShowLatest: e.target.checked })} />, )} {row( , , setPref({ ...pref, showDebugMenus: e.target.checked })} />, )} save()}> {error && {error}}
) } export default PreferencesPage ================================================ FILE: packages/app/src/Pages/settings/Profile.tsx ================================================ import { fetchNip05Pubkey, LNURL } from "@snort/shared" import { mapEventToProfile } from "@snort/system" import { useUserProfile } from "@snort/system-react" import { useCallback, useEffect, useRef, useState } from "react" import { FormattedMessage, useIntl } from "react-intl" import { Link } from "react-router-dom" import AsyncButton from "@/Components/Button/AsyncButton" import { ErrorOrOffline } from "@/Components/ErrorOrOffline" import messages from "@/Components/messages" import AvatarEditor from "@/Components/User/AvatarEditor" import useEventPublisher from "@/Hooks/useEventPublisher" import useLogin from "@/Hooks/useLogin" import { debounce, openFile } from "@/Utils" import { MaxAboutLength, MaxUsernameLength } from "@/Utils/Const" import useFileUpload from "@/Utils/Upload" export interface ProfileSettingsProps { avatar?: boolean banner?: boolean } export default function ProfileSettings(props: ProfileSettingsProps) { const { formatMessage } = useIntl() const { id, publicKey, readonly } = useLogin(s => ({ id: s.id, publicKey: s.publicKey, readonly: s.readonly })) const user = useUserProfile(publicKey ?? "") const { publisher, system } = useEventPublisher() const uploader = useFileUpload() const [error, setError] = useState() const isDirty = useRef(false) const [name, setName] = useState() const [picture, setPicture] = useState() const [banner, setBanner] = useState() const [about, setAbout] = useState() const [website, setWebsite] = useState() const [nip05, setNip05] = useState() const [lud16, setLud16] = useState() const [nip05AddressValid, setNip05AddressValid] = useState() const [invalidNip05AddressMessage, setInvalidNip05AddressMessage] = useState() const [usernameValid, setUsernameValid] = useState() const [invalidUsernameMessage, setInvalidUsernameMessage] = useState() const [aboutValid, setAboutValid] = useState() const [invalidAboutMessage, setInvalidAboutMessage] = useState() const [lud16Valid, setLud16Valid] = useState() const [invalidLud16Message, setInvalidLud16Message] = useState() useEffect(() => { if (user && !isDirty.current) { setName(user.name) setPicture(user.picture) setBanner(user.banner) setAbout(user.about) setWebsite(user.website) setNip05(user.nip05) setLud16(user.lud16) } }, [user]) const nip05NostrAddressVerification = useCallback( async (nip05Domain: string | undefined, nip05Name: string | undefined) => { try { const result = await fetchNip05Pubkey(nip05Name!, nip05Domain!) if (result) { if (result === publicKey) { setNip05AddressValid(true) } else { setInvalidNip05AddressMessage( formatMessage({ defaultMessage: "Nostr address does not belong to you", id: "01iNut" }), ) } } else { setNip05AddressValid(false) setInvalidNip05AddressMessage(formatMessage(messages.InvalidNip05Address)) } } catch (_e) { setNip05AddressValid(false) setInvalidNip05AddressMessage(formatMessage(messages.InvalidNip05Address)) } }, [publicKey, formatMessage], ) useEffect(() => { return debounce(500, async () => { if (lud16) { try { await new LNURL(lud16).load() setLud16Valid(true) setInvalidLud16Message("") } catch (_e) { setLud16Valid(false) setInvalidLud16Message(formatMessage(messages.InvalidLud16)) } } else { setInvalidLud16Message("") } }) }, [lud16, formatMessage]) useEffect(() => { return debounce(500, async () => { const Nip05AddressElements = nip05?.split("@") ?? [] if ((nip05?.length ?? 0) === 0) { setNip05AddressValid(false) setInvalidNip05AddressMessage("") } else if (Nip05AddressElements.length < 2) { setNip05AddressValid(false) setInvalidNip05AddressMessage(formatMessage(messages.InvalidNip05Address)) } else if (Nip05AddressElements.length === 2) { nip05NostrAddressVerification(Nip05AddressElements.pop(), Nip05AddressElements.pop()) } else { setNip05AddressValid(false) } }) }, [nip05, formatMessage, nip05NostrAddressVerification]) async function saveProfile() { // copy user object and delete internal fields const userCopy = { ...user, name, about, picture, banner, website, nip05, lud16, } as Record delete userCopy.loaded delete userCopy.created delete userCopy.pubkey delete userCopy.npub delete userCopy.deleted delete userCopy.zapService delete userCopy.isNostrAddressValid console.debug(userCopy) if (publisher) { const ev = await publisher.metadata(userCopy) await system.BroadcastEvent(ev) const newProfile = mapEventToProfile(ev) if (newProfile) { await system.config.profiles.set(newProfile) } } } async function uploadFile() { try { setError(undefined) const file = await openFile() if (file && uploader) { const rsp = await uploader.upload(file) if ("error" in rsp && typeof rsp?.error === "string") { throw new Error(`Upload failed ${rsp.error}`) } return rsp.url } } catch (e) { if (e instanceof Error) { setError(e) } } } async function setNewBanner() { const rsp = await uploadFile() if (rsp) { setBanner(rsp) } } async function _setNewAvatar() { const rsp = await uploadFile() if (rsp) { setPicture(rsp) } } async function onNip05Change(e: React.ChangeEvent) { isDirty.current = true const Nip05Address = e.target.value.toLowerCase() setNip05(Nip05Address) } async function onLimitCheck(val: string, field: string) { isDirty.current = true if (field === "username") { setName(val) if (val?.length >= MaxUsernameLength) { setUsernameValid(false) setInvalidUsernameMessage( formatMessage(messages.UserNameLengthError, { limit: MaxUsernameLength, }), ) } else { setUsernameValid(true) setInvalidUsernameMessage("") } } else if (field === "about") { setAbout(val) if (val?.length >= MaxAboutLength) { setAboutValid(false) setInvalidAboutMessage( formatMessage(messages.AboutLengthError, { limit: MaxAboutLength, }), ) } else { setAboutValid(true) setInvalidAboutMessage("") } } } async function onLud16Change(address: string) { isDirty.current = true setLud16(address) } function editor() { return (

onLimitCheck(e.target.value, "username")} disabled={readonly} maxLength={MaxUsernameLength} />
{usernameValid === false ? {invalidUsernameMessage} : <>}

{aboutValid === false ? {invalidAboutMessage} : <>}

{ isDirty.current = true setWebsite(e.target.value) }} disabled={readonly} />

onNip05Change(e)} disabled={readonly} />
{!nip05AddressValid && {invalidNip05AddressMessage}}
{/* */}

onLud16Change(e.target.value.toLowerCase())} disabled={readonly} />
{lud16Valid === false ? {invalidLud16Message} : <>}
saveProfile()} disabled={readonly}>
) } function settings() { if (!publicKey) return null return ( <>
{(props.banner ?? true) && (
0 ? `no-repeat center/cover url("${banner}")` : undefined, }} className="bg-layer-1 -mx-3 min-h-[140px] flex items-center justify-center" > setNewBanner()} disabled={readonly}>
)} {(props.avatar ?? true) && setPicture(p)} picture={picture} />}
{error && } {editor()} ) } return
{settings()}
} ================================================ FILE: packages/app/src/Pages/settings/Referrals.tsx ================================================ import { useCallback } from "react" import { FormattedMessage, FormattedNumber } from "react-intl" import { Link } from "react-router-dom" import AsyncButton from "@/Components/Button/AsyncButton" import { LeaderBadge } from "@/Components/CommunityLeaders/LeaderBadge" import Copy from "@/Components/Copy/Copy" import SnortApi, { type RefCodeResponse } from "@/External/SnortApi" import useEventPublisher from "@/Hooks/useEventPublisher" import { useCached } from "@snort/system-react" export function ReferralsPage() { const { publisher } = useEventPublisher() const loader = useCallback(() => { const api = new SnortApi(undefined, publisher?.signer) return api.getRefCode() }, [publisher]) const { data: refCode, reloadNow } = useCached( publisher ? `ref:${publisher.pubKey}` : undefined, loader, 60 * 60 * 24, ) async function applyNow() { const api = new SnortApi(undefined, publisher?.signer) await api.applyForLeader() await reloadNow() } function becomeLeader() { return ( <>

) } function leaderPending() { return ( <>

) } function leaderInfo() { return ( <>

{c}, percent: , }} />

) } return ( <>

{refCode?.code}, }} />

{refCode?.leaderState === undefined && becomeLeader()} {refCode?.leaderState === "pending" && leaderPending()} {refCode?.leaderState === "approved" && leaderInfo()} ) } ================================================ FILE: packages/app/src/Pages/settings/RelayInfo.tsx ================================================ import { Nip11, type RelayInfoDocument } from "@snort/system" import { useEffect, useState } from "react" import { FormattedMessage } from "react-intl" import { Link, useParams } from "react-router-dom" import { CollapsedSection } from "@/Components/Collapsed" import NipDescription from "@/Components/nip" import RelayPaymentLabel from "@/Components/Relay/paid" import RelayPermissions from "@/Components/Relay/permissions" import { RelayFavicon } from "@/Components/Relay/RelaysMetadata" import RelaySoftware from "@/Components/Relay/software" import RelayStatusLabel from "@/Components/Relay/status-label" import RelayUptime from "@/Components/Relay/uptime" import ProfileImage from "@/Components/User/ProfileImage" import useRelayState from "@/Feed/RelayState" import { getRelayName, parseId } from "@/Utils" const RelayInfo = () => { const params = useParams() const [info, setInfo] = useState() const conn = useRelayState(params.id ?? "") useEffect(() => { let cancelled = false Nip11.loadRelayDocument(params.id ?? "") .then(info => { if (!cancelled) setInfo(info) }) .catch(console.error) return () => { cancelled = true } }, [params.id]) return (
{info?.name ?? getRelayName(params.id ?? "")}
{info && }
{params.id}
{info && (
{info?.pubkey && }
{info?.software && }
{conn && ( <>
)}
)}

{/* {stats && (
} startClosed={false}>
  •  
  •  
  •  
  •   acc + v, 0) / stats.latency.length} /> ), }} />
  •   {new Date(stats.lastSeen).toLocaleString()}
)} */}
{info?.supported_nips && ( } startClosed={false} >
    {info.supported_nips.map(n => (
  • ))}
)} ) } export default RelayInfo ================================================ FILE: packages/app/src/Pages/settings/Relays.tsx ================================================ import { removeUndefined } from "@snort/shared" import { useState } from "react" import { FormattedMessage } from "react-intl" import AsyncButton from "@/Components/Button/AsyncButton" import Relay from "@/Components/Relay/Relay" import useEventPublisher from "@/Hooks/useEventPublisher" import useLogin from "@/Hooks/useLogin" import useRelays from "@/Hooks/useRelays" import { saveRelays } from "@/Pages/settings/saveRelays" import { sanitizeRelayUrl } from "@/Utils" import { DiscoverRelays } from "./relays/discover" const RelaySettingsPage = () => { const { publisher, system } = useEventPublisher() const relays = useRelays() const { readonly, state } = useLogin(s => ({ v: s.state.version, state: s.state, readonly: s.readonly })) const [newRelay, setNewRelay] = useState() async function addNewRelay() { const urls = removeUndefined( (newRelay?.trim()?.split("\n") ?? []).map(a => { if (!a.startsWith("wss://") && !a.startsWith("ws://")) { a = `wss://${a}` } return sanitizeRelayUrl(a) }), ) for (const url of urls) { state.addRelay(url, { read: true, write: true }) } // Note: Not saving here - caller should handle persistence if needed setNewRelay("") } function addRelay() { return (