├── .codesandbox
└── tasks.json
├── .devcontainer
└── devcontainer.json
├── .eslintrc
├── .github
└── workflows
│ └── ci.yml
├── .gitignore
├── .lighthouserc.js
├── .nvmrc
├── .prettierignore
├── .prettierrc
├── Dockerfile
├── LICENSE
├── README.md
├── config
├── README.md
├── default.json
├── dev.json
└── nestr.json
├── custom.d.ts
├── docker-compose.yml
├── index.html
├── package.json
├── playwright.config.ts
├── postcss.config.js
├── public
├── .well-known
│ └── assetlinks.json
├── _redirects
├── cashu
│ ├── assets
│ │ ├── AlreadyRunning.3bb200a1.js
│ │ ├── AndroidPWAPrompt.302a1c60.js
│ │ ├── AndroidPWAPrompt.eb57d776.css
│ │ ├── BlankLayout.41d1327b.js
│ │ ├── ErrorNotFound.0a671c4a.js
│ │ ├── FullscreenLayout.02ec423b.js
│ │ ├── KFOkCnqEu92Fr1MmgVxIIzQ.34e9582c.woff
│ │ ├── KFOlCnqEu92Fr1MmEU9fBBc-.9ce7f3ac.woff
│ │ ├── KFOlCnqEu92Fr1MmSU5fBBc-.bf14c7d7.woff
│ │ ├── KFOlCnqEu92Fr1MmWUlfBBc-.e0fd57c0.woff
│ │ ├── KFOlCnqEu92Fr1MmYUtfBBc-.f6537e32.woff
│ │ ├── KFOmCnqEu92Fr1Mu4mxM.f2abf7fb.woff
│ │ ├── MainLayout.87d4a103.js
│ │ ├── MainLayout.aee306ae.css
│ │ ├── QHeader.76ba7108.js
│ │ ├── QInput.265349d8.js
│ │ ├── QItem.18ca9374.js
│ │ ├── QLayout.098b1f25.js
│ │ ├── QLinearProgress.00741bf9.js
│ │ ├── QList.1f08d45c.js
│ │ ├── QResizeObserver.d45b2ee0.js
│ │ ├── QSpinnerHourglass.12e6d1e7.js
│ │ ├── QToolbar.4ef97f48.js
│ │ ├── QToolbarTitle.7349d924.js
│ │ ├── Restore.ceb30b82.js
│ │ ├── Settings.b7424e6d.js
│ │ ├── Settings.f9af94b6.css
│ │ ├── TermsPage.8329ea15.css
│ │ ├── TermsPage.ae1dd3e0.js
│ │ ├── WalletPage.072ef1f1.js
│ │ ├── WalletPage.d7bc9a06.css
│ │ ├── WelcomePage.9146aeed.js
│ │ ├── WelcomePage.b07b1606.css
│ │ ├── WelcomeSlide4.4f3ce9ed.js
│ │ ├── WelcomeSlide4.8505cd1c.css
│ │ ├── base.72e2c220.js
│ │ ├── cashu.5d75870c.js
│ │ ├── flUhRq6tzZclQEJ-Vdg-IuiaDsNa.fd84f88b.woff
│ │ ├── flUhRq6tzZclQEJ-Vdg-IuiaDsNcIhQ8tQ.4a4dbc62.woff2
│ │ ├── focusout.be8db32e.js
│ │ ├── global-components.f92d7ae1.js
│ │ ├── index.14146baf.css
│ │ ├── index.42f06193.js
│ │ ├── index.916cb52e.js
│ │ ├── material-icons-v50.fbba257d.woff2
│ │ ├── npubcash.daddea67.js
│ │ ├── private.use-form.be065f06.js
│ │ ├── qr-scanner-worker.min.56d417f3.js
│ │ ├── restore.025c481c.js
│ │ ├── scroll.b2adc88e.js
│ │ ├── selection.7b8f2356.js
│ │ ├── touch.9135741d.js
│ │ ├── ui.b6121134.js
│ │ ├── use-checkbox.bd46c6a9.js
│ │ ├── use-timeout.c52bf43b.js
│ │ ├── vue-qrcode.esm.c5e039a4.js
│ │ ├── web.050b05b0.js
│ │ ├── web.e3bae036.js
│ │ └── welcome.62644ccc.js
│ ├── clean.png
│ ├── favicon.ico
│ ├── icons
│ │ ├── apple-icon-120x120.png
│ │ ├── apple-icon-152x152.png
│ │ ├── apple-icon-167x167.png
│ │ ├── apple-icon-180x180.png
│ │ ├── apple-launch-1080x2340.png
│ │ ├── apple-launch-1125x2436.png
│ │ ├── apple-launch-1170x2532.png
│ │ ├── apple-launch-1179x2556.png
│ │ ├── apple-launch-1242x2208.png
│ │ ├── apple-launch-1242x2688.png
│ │ ├── apple-launch-1284x2778.png
│ │ ├── apple-launch-1290x2796.png
│ │ ├── apple-launch-1536x2048.png
│ │ ├── apple-launch-1620x2160.png
│ │ ├── apple-launch-1668x2224.png
│ │ ├── apple-launch-1668x2388.png
│ │ ├── apple-launch-2048x2732.png
│ │ ├── apple-launch-750x1334.png
│ │ ├── apple-launch-828x1792.png
│ │ ├── favicon-128x128.png
│ │ ├── favicon-16x16.png
│ │ ├── favicon-32x32.png
│ │ ├── favicon-96x96.png
│ │ ├── icon-128x128.png
│ │ ├── icon-192x192.png
│ │ ├── icon-256x256.png
│ │ ├── icon-384x384.png
│ │ ├── icon-512x512.png
│ │ ├── ms-icon-144x144.png
│ │ └── safari-pinned-tab.svg
│ ├── index.html
│ ├── nostr-icon.svg
│ ├── screenshots
│ │ ├── narrow-1.png
│ │ ├── narrow-2.png
│ │ ├── wide-1.png
│ │ └── wide-2.png
│ └── x-logo.svg
├── clean.png
├── favicon.png
├── img
│ ├── android-chrome-192x192.png
│ ├── android-chrome-512x512.png
│ ├── apple-touch-icon.png
│ ├── icon128.png
│ ├── irisconnects.png
│ ├── maskable_icon.png
│ ├── maskable_icon_x192.png
│ └── zap.png
├── manifest.json
└── robots.txt
├── scripts
└── updateSocialGraph.ts
├── src
├── assets
│ ├── Bitcoin.png
│ ├── alby-logo.avif
│ ├── banner.jpg
│ ├── chrome-logo.png
│ ├── default-repo-pic.webp
│ ├── default_profile_pic.jpg
│ ├── feed-icon.png
│ ├── firefox-logo.png
│ ├── git-logo.png
│ ├── highlights-icon.png
│ ├── hornet-storage.png
│ ├── landing-page-img-coder.avif
│ ├── landing-page-img-nostrich.avif
│ ├── long-form-icon.png
│ ├── nestr-logo.png
│ ├── note-icon.png
│ ├── quotes-icon.png
│ ├── reels-icon.png
│ ├── running-ostrich.gif
│ ├── satoshi-white.png
│ ├── satoshi.svg
│ ├── wallet-icon.png
│ └── wallet-icon.svg
├── index.css
├── main.tsx
├── pages
│ ├── HelpPage.tsx
│ ├── NostrLinkHandler.tsx
│ ├── Page404.tsx
│ ├── chats
│ │ ├── NewChat.tsx
│ │ ├── components
│ │ │ └── ChatContainer.tsx
│ │ ├── index.tsx
│ │ ├── list
│ │ │ ├── ChatList.tsx
│ │ │ └── ChatListItem.tsx
│ │ ├── message
│ │ │ ├── Message.tsx
│ │ │ ├── MessageForm.tsx
│ │ │ ├── MessageFormReplyPreview.tsx
│ │ │ └── ReplyPreview.tsx
│ │ ├── private
│ │ │ ├── PrivateChat.tsx
│ │ │ ├── PrivateChatCreation.tsx
│ │ │ └── PrivateChatHeader.tsx
│ │ ├── public
│ │ │ ├── PopularChannelItem.tsx
│ │ │ ├── PopularChannels.tsx
│ │ │ ├── PublicChat.tsx
│ │ │ ├── PublicChatContext.tsx
│ │ │ ├── PublicChatCreation.tsx
│ │ │ ├── PublicChatDetails.tsx
│ │ │ └── PublicChatHeader.tsx
│ │ ├── reaction
│ │ │ ├── MessageReactionButton.tsx
│ │ │ └── MessageReactions.tsx
│ │ └── utils
│ │ │ ├── channelMetadata.ts
│ │ │ ├── channelSearch.ts
│ │ │ ├── constants.ts
│ │ │ ├── doubleRatchetUsers.ts
│ │ │ └── messageGrouping.ts
│ ├── home
│ │ ├── feed
│ │ │ └── components
│ │ │ │ ├── HomeFeedEvents.tsx
│ │ │ │ ├── Wallet.tsx
│ │ │ │ └── WalletFeedItem.tsx
│ │ └── index.tsx
│ ├── index.tsx
│ ├── notifications
│ │ ├── Notifications.tsx
│ │ └── NotificationsFeedItem.tsx
│ ├── search
│ │ └── index.tsx
│ ├── settings
│ │ ├── Account.tsx
│ │ ├── Appearance.tsx
│ │ ├── Backup.tsx
│ │ ├── Content.tsx
│ │ ├── IrisAccount
│ │ │ ├── AccountName.tsx
│ │ │ ├── ActiveAccount.tsx
│ │ │ ├── ChallengeForm.tsx
│ │ │ ├── IrisAccount.tsx
│ │ │ ├── IrisSettings.tsx
│ │ │ └── RegisterForm.tsx
│ │ ├── Mediaservers.tsx
│ │ ├── Network.tsx
│ │ ├── NotificationSettings.tsx
│ │ ├── Privacy.tsx
│ │ ├── Profile.tsx
│ │ ├── SocialGraphSettings.tsx
│ │ ├── SystemSettings.tsx
│ │ ├── WalletSettings.tsx
│ │ └── index.tsx
│ ├── subscription
│ │ └── index.tsx
│ ├── thread
│ │ └── index.tsx
│ ├── user
│ │ ├── ProfileHeader.tsx
│ │ ├── components
│ │ │ ├── FollowList.tsx
│ │ │ ├── FollowerCount.tsx
│ │ │ ├── FollowsCount.tsx
│ │ │ ├── MediaTab.tsx
│ │ │ ├── ProfileAvatar.tsx
│ │ │ ├── ProfileDetails.tsx
│ │ │ ├── ProfileItem.tsx
│ │ │ └── ProfileName.tsx
│ │ └── index.tsx
│ └── wallet
│ │ └── WalletPage.tsx
├── service-worker.ts
├── shared
│ ├── components
│ │ ├── Footer.tsx
│ │ ├── HyperText.tsx
│ │ ├── Icons
│ │ │ ├── Icon.tsx
│ │ │ └── icons.svg
│ │ ├── InstallPWAPrompt.tsx
│ │ ├── Layout.tsx
│ │ ├── LoadingFallback.tsx
│ │ ├── NotificationPrompt.tsx
│ │ ├── ProxyImg.tsx
│ │ ├── QRScanner.tsx
│ │ ├── RightColumn.tsx
│ │ ├── button
│ │ │ ├── CopyButton.tsx
│ │ │ ├── FollowButton.tsx
│ │ │ └── UploadButton.tsx
│ │ ├── connection
│ │ │ └── ConnectionStatus.tsx
│ │ ├── create
│ │ │ ├── NoteCreator.tsx
│ │ │ └── Textarea.tsx
│ │ ├── embed
│ │ │ ├── Audio.tsx
│ │ │ ├── Hashtag.tsx
│ │ │ ├── LightningUri.tsx
│ │ │ ├── Url.tsx
│ │ │ ├── apple
│ │ │ │ ├── AppleMusic.tsx
│ │ │ │ ├── AppleMusicComponent.tsx
│ │ │ │ ├── ApplePodcast.tsx
│ │ │ │ └── ApplePodcastComponent.tsx
│ │ │ ├── index.ts
│ │ │ ├── instagram
│ │ │ │ ├── Instagram.tsx
│ │ │ │ └── InstagramComponent.tsx
│ │ │ ├── media
│ │ │ │ ├── Carousel.tsx
│ │ │ │ ├── ImageComponent.tsx
│ │ │ │ ├── MediaEmbed.tsx
│ │ │ │ ├── SmallImage.tsx
│ │ │ │ ├── SmallImageComponent.tsx
│ │ │ │ ├── SmallThumbnail.tsx
│ │ │ │ ├── SmallThumbnailComponent.tsx
│ │ │ │ ├── VideoComponent.tsx
│ │ │ │ └── mediaUtils.ts
│ │ │ ├── nostr
│ │ │ │ ├── CustomEmoji.tsx
│ │ │ │ ├── CustomEmojiComponent.tsx
│ │ │ │ ├── InlineMention.tsx
│ │ │ │ ├── Nip19.tsx
│ │ │ │ ├── NostrNote.tsx
│ │ │ │ └── NostrNpub.tsx
│ │ │ ├── soundcloud
│ │ │ │ ├── SoundCloud.tsx
│ │ │ │ └── SoundCloudComponent.tsx
│ │ │ ├── spotify
│ │ │ │ ├── SpotifyAlbum.tsx
│ │ │ │ ├── SpotifyAlbumComponent.tsx
│ │ │ │ ├── SpotifyPlaylist.tsx
│ │ │ │ ├── SpotifyPlaylistComponent.tsx
│ │ │ │ ├── SpotifyPodcast.tsx
│ │ │ │ ├── SpotifyPodcastComponent.tsx
│ │ │ │ ├── SpotifyTrack.tsx
│ │ │ │ └── SpotifyTrackComponent.tsx
│ │ │ ├── tidal
│ │ │ │ ├── TidalPlaylist.tsx
│ │ │ │ ├── TidalPlaylistComponent.tsx
│ │ │ │ ├── TidalTrack.tsx
│ │ │ │ └── TidalTrackComponent.tsx
│ │ │ ├── tiktok
│ │ │ │ ├── TikTok.tsx
│ │ │ │ └── TikTokComponent.tsx
│ │ │ ├── twitch
│ │ │ │ ├── Twitch.tsx
│ │ │ │ ├── TwitchChannel.tsx
│ │ │ │ ├── TwitchChannelComponent.tsx
│ │ │ │ └── TwitchComponent.tsx
│ │ │ ├── wavlake
│ │ │ │ ├── WavLake.tsx
│ │ │ │ └── WavLakeComponent.tsx
│ │ │ └── youtube
│ │ │ │ ├── YouTube.tsx
│ │ │ │ └── YoutubeComponent.tsx
│ │ ├── emoji
│ │ │ ├── EmojiButton.tsx
│ │ │ └── FloatingEmojiPicker.tsx
│ │ ├── event
│ │ │ ├── ChannelCreation.tsx
│ │ │ ├── EventBorderless.tsx
│ │ │ ├── FeedItem
│ │ │ │ ├── FeedItem.tsx
│ │ │ │ ├── FeedItemContent.tsx
│ │ │ │ ├── FeedItemHeader.tsx
│ │ │ │ ├── FeedItemPlaceholder.tsx
│ │ │ │ ├── FeedItemTitle.tsx
│ │ │ │ └── utils.ts
│ │ │ ├── Highlight.tsx
│ │ │ ├── LikeHeader.tsx
│ │ │ ├── LongForm.tsx
│ │ │ ├── MuteUser.tsx
│ │ │ ├── RawJSON.tsx
│ │ │ ├── RelativeTime.tsx
│ │ │ ├── ReportReasonForm.tsx
│ │ │ ├── ReportUser.tsx
│ │ │ ├── RepostHeader.tsx
│ │ │ ├── SimpleFeedItemDropdown.tsx
│ │ │ ├── TextNote.tsx
│ │ │ ├── ZapModal.tsx
│ │ │ ├── ZapReceipt.tsx
│ │ │ ├── Zapraiser.tsx
│ │ │ ├── reactions
│ │ │ │ ├── FeedItemActions.tsx
│ │ │ │ ├── FeedItemComment.tsx
│ │ │ │ ├── FeedItemDropdown.tsx
│ │ │ │ ├── FeedItemLike.tsx
│ │ │ │ ├── FeedItemRepost.tsx
│ │ │ │ ├── FeedItemShare.tsx
│ │ │ │ ├── FeedItemZap.tsx
│ │ │ │ ├── Likes.tsx
│ │ │ │ ├── ReactionContent.tsx
│ │ │ │ ├── Reactions.tsx
│ │ │ │ ├── Reposts.tsx
│ │ │ │ └── Zaps.tsx
│ │ │ └── utils.ts
│ │ ├── feed
│ │ │ ├── DisplayAsSelector.tsx
│ │ │ ├── Feed.tsx
│ │ │ ├── ImageGridItem.tsx
│ │ │ ├── MediaFeed.tsx
│ │ │ ├── NewEventsButton.tsx
│ │ │ ├── NotificationsFeed.tsx
│ │ │ ├── Trending.tsx
│ │ │ ├── UnknownUserEvents.tsx
│ │ │ └── utils.ts
│ │ ├── header
│ │ │ ├── Header.tsx
│ │ │ ├── NotificationButton.tsx
│ │ │ ├── UnseenNotificationsBadge.tsx
│ │ │ └── WalletButton.tsx
│ │ ├── market
│ │ │ ├── MarketDetails.tsx
│ │ │ ├── MarketGridItem.tsx
│ │ │ ├── MarketImage.tsx
│ │ │ └── MarketListing.tsx
│ │ ├── media
│ │ │ ├── MediaModal.tsx
│ │ │ └── PreloadImages.tsx
│ │ ├── messages
│ │ │ └── UnseenMessagesBadge.tsx
│ │ ├── nav
│ │ │ ├── MessagesNavItem.tsx
│ │ │ ├── NavItem.tsx
│ │ │ ├── NavLink.tsx
│ │ │ ├── NavSideBar.tsx
│ │ │ ├── NotificationNavItem.tsx
│ │ │ ├── SubscriptionNavItem.tsx
│ │ │ └── navConfig.ts
│ │ ├── ui
│ │ │ ├── Dropdown.tsx
│ │ │ ├── ErrorBoundary.tsx
│ │ │ ├── Hovercard.tsx
│ │ │ ├── InfiniteScroll.tsx
│ │ │ ├── Modal.tsx
│ │ │ ├── PublishButton.tsx
│ │ │ ├── SearchBox.tsx
│ │ │ └── Widget.tsx
│ │ ├── user
│ │ │ ├── Avatar.tsx
│ │ │ ├── AvatarGroup.tsx
│ │ │ ├── Badge.tsx
│ │ │ ├── FollowedBy.tsx
│ │ │ ├── LoginDialog.tsx
│ │ │ ├── MinidenticonImg.tsx
│ │ │ ├── MutedBy.tsx
│ │ │ ├── Name.tsx
│ │ │ ├── ProfileAbout.tsx
│ │ │ ├── ProfileCard.tsx
│ │ │ ├── PublicKeyQRCodeButton.tsx
│ │ │ ├── QRCodeButton.tsx
│ │ │ ├── SignIn.tsx
│ │ │ ├── SignUp.tsx
│ │ │ ├── SubscriberBadge.tsx
│ │ │ ├── UserRow.tsx
│ │ │ ├── const.ts
│ │ │ └── useHoverCard.ts
│ │ └── ux
│ │ │ └── Timeout.tsx
│ ├── hooks
│ │ ├── useAutosizeTextarea.ts
│ │ ├── useCachedFetch.ts
│ │ ├── useFeedEvents.ts
│ │ ├── useFollows.ts
│ │ ├── useHistoryState.ts
│ │ ├── useInviteFromUrl.ts
│ │ ├── useIsMobile.ts
│ │ ├── useMutes.ts
│ │ ├── useNip05Validation.ts
│ │ ├── useOnlineStatus.ts
│ │ ├── useProfile.ts
│ │ ├── useSearchParam.ts
│ │ ├── useSubscriptionStatus.ts
│ │ ├── useWalletBalance.ts
│ │ └── useWebLNProvider.ts
│ ├── services
│ │ └── Mute.tsx
│ ├── upload.ts
│ └── utils
│ │ ├── Hex.ts
│ │ ├── PublicKey.ts
│ │ ├── imgproxy.ts
│ │ ├── isTouchDevice.ts
│ │ ├── marketUtils.ts
│ │ └── subscriptionIcons.tsx
├── stores
│ ├── draft.ts
│ ├── events.ts
│ ├── feed.ts
│ ├── notifications.ts
│ ├── search.ts
│ ├── sessions.ts
│ ├── settings.ts
│ ├── ui.ts
│ ├── user.ts
│ ├── wallet.ts
│ └── zap.ts
├── types
│ ├── dom-types.d.ts
│ ├── emoji.ts
│ └── global.d.ts
├── utils
│ ├── AnimalName.ts
│ ├── IrisAPI.ts
│ ├── SortedMap
│ │ ├── SortedMap.test.ts
│ │ └── SortedMap.tsx
│ ├── chat
│ │ └── webrtc
│ │ │ └── PeerConnection.ts
│ ├── cloudflare_banned_users.ts
│ ├── memcache.ts
│ ├── messageRepository.ts
│ ├── migration.ts
│ ├── ndk.ts
│ ├── nostr.ts
│ ├── notifications.ts
│ ├── profileSearch.ts
│ ├── socialGraph.ts
│ ├── utils.ts
│ └── visibility.ts
└── vite-env.d.ts
├── tailwind.config.js
├── tests
├── auth.setup.ts
├── chat-invite.spec.ts
├── draft-persistence.spec.ts
├── follow-user.spec.ts
├── hashtag.spec.ts
├── home.spec.ts
├── like-post.spec.ts
├── message-form.spec.ts
├── navigation.spec.ts
├── new-post.spec.ts
├── notification.spec.ts
├── profile-qr.spec.ts
├── reply-post.spec.ts
├── repost.spec.ts
├── search.spec.ts
├── session-persistence.spec.ts
├── settings.spec.ts
├── signup.spec.ts
├── thread-view.spec.ts
└── view-profile.spec.ts
├── tsconfig.json
├── tsconfig.node.json
├── vite.config.ts
└── yarn.lock
/.codesandbox/tasks.json:
--------------------------------------------------------------------------------
1 | {
2 | // These tasks will run in order when initializing your CodeSandbox project.
3 | "setupTasks": [
4 | {
5 | "name": "Install Dependencies",
6 | "command": "npm install"
7 | }
8 | ],
9 |
10 | // These tasks can be run from CodeSandbox. Running one will open a log in the app.
11 | "tasks": {
12 | "dev": {
13 | "name": "dev",
14 | "command": "npm run dev",
15 | "runAtStart": true
16 | },
17 | "start": {
18 | "name": "start",
19 | "command": "npm run start",
20 | "runAtStart": false
21 | },
22 | "build": {
23 | "name": "build",
24 | "command": "npm run build",
25 | "runAtStart": false
26 | },
27 | "lint": {
28 | "name": "lint",
29 | "command": "npm run lint",
30 | "runAtStart": false
31 | },
32 | "preview": {
33 | "name": "preview",
34 | "command": "npm run preview",
35 | "runAtStart": false
36 | },
37 | "test": {
38 | "name": "test",
39 | "command": "npm run test",
40 | "runAtStart": false
41 | }
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/.devcontainer/devcontainer.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "Node.js & TypeScript",
3 | "dockerComposeFile": "../docker-compose.yml",
4 | "service": "app",
5 | "workspaceFolder": "/app",
6 | "forwardPorts": [5173],
7 | "shutdownAction": "stopCompose"
8 | }
9 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 |
3 | on:
4 | push:
5 | branches: [ main ]
6 | pull_request:
7 | branches: [ main ]
8 |
9 | jobs:
10 | test:
11 | runs-on: ubuntu-latest
12 |
13 | steps:
14 | - uses: actions/checkout@v4
15 |
16 | - name: Setup Node.js
17 | uses: actions/setup-node@v4
18 | with:
19 | node-version: '20'
20 | cache: 'yarn'
21 |
22 | - name: Install dependencies
23 | run: yarn install --frozen-lockfile
24 |
25 | - name: Install Playwright browsers
26 | run: npx playwright install chromium
27 |
28 | - name: Run tests
29 | run: yarn test
30 |
31 | - name: Build
32 | run: yarn build
33 |
34 | - name: Upload test results
35 | uses: actions/upload-artifact@v4
36 | if: always()
37 | with:
38 | name: playwright-report
39 | path: playwright-report/
40 | retention-days: 30
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 | build
15 | dev-dist
16 |
17 | # Test results
18 | playwright-report/
19 | test-results/
20 | /test-results/
21 | /playwright-report/
22 | /blob-report/
23 | /playwright/.cache/
24 |
25 | # Editor directories and files
26 | .vscode/*
27 | !.vscode/extensions.json
28 | .idea
29 | .DS_Store
30 | *.suo
31 | *.ntvs*
32 | *.njsproj
33 | *.sln
34 | *.sw?
35 |
--------------------------------------------------------------------------------
/.lighthouserc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | ci: {
3 | collect: {
4 | startServerCommand: 'yarn build && yarn preview',
5 | url: ['http://localhost:3000'],
6 | numberOfRuns: 3,
7 | settings: {
8 | onlyCategories: ['performance', 'accessibility', 'best-practices', 'seo'],
9 | throttling: {
10 | rttMs: 150,
11 | throughputKbps: 1638.4,
12 | cpuSlowdownMultiplier: 2,
13 | },
14 | },
15 | },
16 | assert: {
17 | assertions: {
18 | 'categories:performance': ['error', { minScore: 0.9 }],
19 | 'categories:accessibility': ['error', { minScore: 0.9 }],
20 | 'categories:best-practices': ['error', { minScore: 0.9 }],
21 | 'categories:seo': ['error', { minScore: 0.9 }],
22 | },
23 | },
24 | },
25 | };
--------------------------------------------------------------------------------
/.nvmrc:
--------------------------------------------------------------------------------
1 | 20
2 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | # Ignore artifacts:
2 | build
3 | dist
4 | public
5 | node_modules
6 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "plugins": ["prettier-plugin-sort-imports"],
3 | "printWidth": 90,
4 | "bracketSpacing": false,
5 | "trailingComma": "es5",
6 | "arrowParens": "always",
7 | "semi": false,
8 | "tabWidth": 2,
9 | "singleQuote": false
10 | }
11 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | # Use the updated VS Code dev container image for JavaScript/Node.js
2 | FROM mcr.microsoft.com/devcontainers/javascript-node:0-18
3 |
4 | # Set the working directory
5 | WORKDIR /app
6 |
7 | # Copy the package.json and yarn.lock files to the container
8 | COPY package*.json ./
9 | COPY yarn.lock ./
10 |
11 | # Install Yarn
12 | RUN npm install -g yarn
13 |
14 | # Install dependencies using Yarn
15 | RUN yarn install
16 |
17 | # Copy the rest of the application code to the container
18 | COPY . .
19 |
20 | # Expose the port your application runs on
21 | EXPOSE 5173
22 |
23 | # Start the application using Yarn
24 | CMD ["yarn", "run", "preview", "--", "--host"]
25 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 Martti Malmi
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Iris
2 |
3 | Source code for [iris.to](https://iris.to)
4 |
5 | [](https://github.com/irislib/iris-client/actions/workflows/ci.yml)
6 | [](https://github.com/irislib/iris-client/actions/workflows/ci.yml)
7 | [](https://github.com/irislib/iris-client/actions/workflows/ci.yml)
8 |
9 | ## Development
10 |
11 | ```bash
12 | # Install dependencies
13 | yarn
14 |
15 | # Start development server
16 | yarn dev
17 |
18 | # Build for production
19 | yarn build
20 |
21 | # Run tests
22 | yarn test # Run all tests
23 | yarn test:ui # E2E tests with UI mode
24 | ```
25 |
--------------------------------------------------------------------------------
/config/README.md:
--------------------------------------------------------------------------------
1 | Choose config with NODE_CONFIG_ENV: `NODE_CONFIG_ENV=iris yarn start`
2 |
--------------------------------------------------------------------------------
/config/default.json:
--------------------------------------------------------------------------------
1 | {
2 | "appName": "iris",
3 | "appNameCapitalized": "Iris",
4 | "hostname": "iris.to",
5 | "nip05Domain": "iris.to",
6 | "icon": "/img/icon128.png",
7 | "navLogo": "/img/icon128.png",
8 | "defaultTheme": "iris",
9 | "aboutText": "Iris - Connecting People",
10 | "repository": "https://github.com/irislib/iris-client",
11 | "features": {
12 | "analytics": false,
13 | "showSubscriptionSettings": true
14 | },
15 | "defaultSettings": {
16 | "notificationServer": "https://notifications.iris.to",
17 | "irisApiUrl": "https://api.iris.to"
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/config/dev.json:
--------------------------------------------------------------------------------
1 | {
2 | "appName": "iris",
3 | "appNameCapitalized": "Iris",
4 | "hostname": "iris.to",
5 | "nip05Domain": "iris.to",
6 | "icon": "/img/icon128.png",
7 | "navLogo": "/img/icon128.png",
8 | "defaultTheme": "iris",
9 | "aboutText": "Iris - Connecting People",
10 | "repository": "https://github.com/irislib/iris-client",
11 | "features": {
12 | "analytics": false,
13 | "showSubscriptionSettings": true
14 | },
15 | "defaultSettings": {
16 | "notificationServer": "https://notifications.iris.to",
17 | "irisApiUrl": ""
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/config/nestr.json:
--------------------------------------------------------------------------------
1 | {
2 | "appName": "Nestr",
3 | "appNameCapitalized": "Nestr",
4 | "hostname": "nestr.iris.to",
5 | "nip05Domain": "iris.to",
6 | "icon": "/img/icon128.png",
7 | "navLogo": "/nestr-logo-no-name.png",
8 | "defaultTheme": "dark",
9 | "navItems": [
10 | "home",
11 | "search",
12 | "notifications",
13 | "organizations",
14 | "repositories",
15 | "settings",
16 | "about"
17 | ],
18 | "aboutText": "Nestr aims to facilitate truly sovereign and censorship-resistant code development with social network features provided by the Nostr protocol.",
19 | "repository": "",
20 | "features": {
21 | "git": true,
22 | "cashu": false,
23 | "analytics": false
24 | },
25 | "defaultSettings": {
26 | "youtubePrivacyMode": false,
27 | "notificationServer": "https://notifications.iris.to"
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/custom.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
3 | declare const CONFIG: {
4 | appName: string
5 | appNameCapitalized: string
6 | appTitle: string
7 | hostname: string
8 | nip05Domain: string
9 | icon: string
10 | navLogo: string
11 | defaultTheme: string
12 | navItems: string[]
13 | aboutText: string
14 | repository: string
15 | features: {
16 | analytics: boolean
17 | showSubscriptionSettings: boolean
18 | }
19 | defaultSettings: {
20 | notificationServer: string
21 | irisApiUrl: string
22 | }
23 | }
24 |
25 | interface Performance {
26 | memory?: {
27 | jsHeapSizeLimit: number
28 | totalJSHeapSize: number
29 | usedJSHeapSize: number
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: "3.8"
2 |
3 | services:
4 | app:
5 | build: .
6 | ports:
7 | - "5173:5173"
8 | volumes:
9 | - .:/app
10 | - /app/node_modules
11 | environment:
12 | NODE_ENV: development
13 | command: ["yarn", "start", "--", "--host"]
14 |
--------------------------------------------------------------------------------
/playwright.config.ts:
--------------------------------------------------------------------------------
1 | import {defineConfig, devices} from "@playwright/test"
2 |
3 | export default defineConfig({
4 | testDir: "./tests",
5 | fullyParallel: true,
6 | forbidOnly: !!process.env.CI,
7 | retries: process.env.CI ? 2 : 0,
8 | workers: process.env.CI ? 1 : undefined,
9 | reporter: "html",
10 | use: {
11 | baseURL: "http://localhost:5173",
12 | trace: "on-first-retry",
13 | video: "on-first-retry",
14 | },
15 | projects: [
16 | {
17 | name: "chromium",
18 | use: {...devices["Desktop Chrome"]},
19 | },
20 | ],
21 | webServer: {
22 | command: "yarn dev",
23 | url: "http://localhost:5173",
24 | reuseExistingServer: !process.env.CI,
25 | },
26 | })
27 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | export default {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | }
7 |
--------------------------------------------------------------------------------
/public/.well-known/assetlinks.json:
--------------------------------------------------------------------------------
1 | [
2 | {
3 | "relation": ["delegate_permission/common.handle_all_urls"],
4 | "target": {
5 | "namespace": "android_app",
6 | "package_name": "to.iris.twa",
7 | "sha256_cert_fingerprints": [
8 | "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"
9 | ]
10 | }
11 | }
12 | ]
13 |
--------------------------------------------------------------------------------
/public/_redirects:
--------------------------------------------------------------------------------
1 | /cashu /cashu 200
2 | /* /index.html 200
--------------------------------------------------------------------------------
/public/cashu/assets/AlreadyRunning.3bb200a1.js:
--------------------------------------------------------------------------------
1 | import{K as t,j as a,L as n,W as s,a5 as e,Q as o,X as r}from"./index.916cb52e.js";const c=a({name:"AlreadyRunning"}),l={class:"fullscreen bg-dark text-white text-center q-pa-md flex flex-center"},d=e("div",{class:"text-h3"},"Nope.",-1),i=e("div",{class:"text-h5 q-ma-lg text-grey"}," Another tab is already running. Close this tab and try again. ",-1);function _(p,u,x,f,h,m){return n(),s("div",l,[e("div",null,[d,i,o(r,{rounded:"",class:"q-mt-md",color:"white","text-color":"black",unelevated:"",to:"/",label:"Retry"})])])}var v=t(c,[["render",_]]);export{v as default};
2 |
--------------------------------------------------------------------------------
/public/cashu/assets/AndroidPWAPrompt.eb57d776.css:
--------------------------------------------------------------------------------
1 | @keyframes moveUpDown-371778e1{0%,to{transform:translateY(0)}50%{transform:translateY(-10px)}}.pwa-prompt[data-v-371778e1]{position:fixed;bottom:0;left:0;right:0;margin:0 auto;z-index:9999;text-align:center;display:flex;flex-direction:column;align-items:center;justify-content:center;animation:moveUpDown-371778e1 1s infinite}.pwa-prompt-content[data-v-371778e1]{display:inline-flex;align-items:center;background-color:#000;padding:10px;border:1px solid #ccc;border-radius:8px}.pwa-prompt-content q-icon[data-v-371778e1]{margin-right:5px}.pwa-prompt-arrow[data-v-371778e1]{width:0;height:0;border-left:10px solid transparent;border-right:10px solid transparent;border-top:10px solid white;margin:2px auto;text-align:center}@keyframes moveUpDown-06af58bd{0%,to{transform:translateY(0)}50%{transform:translateY(-10px)}}.pwa-prompt[data-v-06af58bd]{position:fixed;top:20px;right:20px;margin:0 auto;z-index:9999;text-align:center;display:flex;flex-direction:column;align-items:center;justify-content:center;animation:moveUpDown-06af58bd 1s infinite}.pwa-prompt-content[data-v-06af58bd]{display:inline-flex;align-items:center;background-color:#000;padding:10px;border:1px solid #ccc;border-radius:8px}.pwa-prompt-content q-icon[data-v-06af58bd]{margin-right:5px}.pwa-prompt-arrow[data-v-06af58bd]{position:relative;width:0;height:0;bottom:60px;left:45%;border-left:10px solid transparent;border-right:10px solid transparent;border-bottom:10px solid white;text-align:center;margin:0 auto}
2 |
--------------------------------------------------------------------------------
/public/cashu/assets/BlankLayout.41d1327b.js:
--------------------------------------------------------------------------------
1 | import{Q as a,a as n}from"./QLayout.098b1f25.js";import{K as r,j as s,V as i,L as p,M as c,O as e,Q as o}from"./index.916cb52e.js";import"./scroll.b2adc88e.js";import"./QResizeObserver.d45b2ee0.js";const m=s({name:"BlankLayout",mixins:[windowMixin],components:{}});function _(l,u,f,d,w,x){const t=i("router-view");return p(),c(a,{view:"lHh Lpr lFf"},{default:e(()=>[o(n,null,{default:e(()=>[o(t)]),_:1})]),_:1})}var C=r(m,[["render",_]]);export{C as default};
2 |
--------------------------------------------------------------------------------
/public/cashu/assets/ErrorNotFound.0a671c4a.js:
--------------------------------------------------------------------------------
1 | import{K as t,j as o,L as s,W as a,a5 as e,Q as r,X as n}from"./index.916cb52e.js";const c=o({name:"ErrorNotFound"}),l={class:"fullscreen bg-dark text-white text-center q-pa-md flex flex-center"},d=e("div",{style:{"font-size":"30vh"}},"404",-1),i=e("div",{class:"text-h3 q-pb-lg",style:{opacity:"0.8"}}," Oops. Nothing here... ",-1);function _(p,f,h,u,x,m){return s(),a("div",l,[e("div",null,[d,i,r(n,{rounded:"",size:"lg",class:"q-mt-xl",color:"white","text-color":"black",unelevated:"",to:"/",label:"Go back home"})])])}var b=t(c,[["render",_]]);export{b as default};
2 |
--------------------------------------------------------------------------------
/public/cashu/assets/FullscreenLayout.02ec423b.js:
--------------------------------------------------------------------------------
1 | import{Q as f,a as w}from"./QLayout.098b1f25.js";import{j as n,K as r,L as t,M as s,O as e,Q as o,X as x,a5 as $,V as a}from"./index.916cb52e.js";import{Q}from"./QToolbar.4ef97f48.js";import{Q as F}from"./QHeader.76ba7108.js";import"./scroll.b2adc88e.js";import"./QResizeObserver.d45b2ee0.js";const b=n({name:"FullscreenHeader",mixins:[windowMixin],props:{},components:{},setup(){return{}}}),h=$("span",{class:"q-mx-md text-weight-bold"},"Wallet",-1);function v(c,l,i,p,u,_){return t(),s(F,{class:"bg-marginal-bg"},{default:e(()=>[o(Q,null,{default:e(()=>[o(x,{flat:"",dense:"",rounded:"",icon:"arrow_back_ios_new",to:"/",color:"primary","aria-label":"Menu","no-caps":""},{default:e(()=>[h]),_:1})]),_:1})]),_:1})}var H=r(b,[["render",v]]);const g=n({name:"FullscreenLayout",mixins:[windowMixin],components:{FullscreenHeader:H}});function L(c,l,i,p,u,_){const d=a("FullscreenHeader"),m=a("router-view");return t(),s(f,{view:"lHh Lpr lFf"},{default:e(()=>[o(d),o(w,null,{default:e(()=>[o(m)]),_:1})]),_:1})}var N=r(g,[["render",L]]);export{N as default};
2 |
--------------------------------------------------------------------------------
/public/cashu/assets/KFOkCnqEu92Fr1MmgVxIIzQ.34e9582c.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/irislib/iris-client/90cad78bca32db028d063af64008212d027d4d8a/public/cashu/assets/KFOkCnqEu92Fr1MmgVxIIzQ.34e9582c.woff
--------------------------------------------------------------------------------
/public/cashu/assets/KFOlCnqEu92Fr1MmEU9fBBc-.9ce7f3ac.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/irislib/iris-client/90cad78bca32db028d063af64008212d027d4d8a/public/cashu/assets/KFOlCnqEu92Fr1MmEU9fBBc-.9ce7f3ac.woff
--------------------------------------------------------------------------------
/public/cashu/assets/KFOlCnqEu92Fr1MmSU5fBBc-.bf14c7d7.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/irislib/iris-client/90cad78bca32db028d063af64008212d027d4d8a/public/cashu/assets/KFOlCnqEu92Fr1MmSU5fBBc-.bf14c7d7.woff
--------------------------------------------------------------------------------
/public/cashu/assets/KFOlCnqEu92Fr1MmWUlfBBc-.e0fd57c0.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/irislib/iris-client/90cad78bca32db028d063af64008212d027d4d8a/public/cashu/assets/KFOlCnqEu92Fr1MmWUlfBBc-.e0fd57c0.woff
--------------------------------------------------------------------------------
/public/cashu/assets/KFOlCnqEu92Fr1MmYUtfBBc-.f6537e32.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/irislib/iris-client/90cad78bca32db028d063af64008212d027d4d8a/public/cashu/assets/KFOlCnqEu92Fr1MmYUtfBBc-.f6537e32.woff
--------------------------------------------------------------------------------
/public/cashu/assets/KFOmCnqEu92Fr1Mu4mxM.f2abf7fb.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/irislib/iris-client/90cad78bca32db028d063af64008212d027d4d8a/public/cashu/assets/KFOmCnqEu92Fr1Mu4mxM.f2abf7fb.woff
--------------------------------------------------------------------------------
/public/cashu/assets/MainLayout.aee306ae.css:
--------------------------------------------------------------------------------
1 | .q-header[data-v-9bc794ce]{position:relative;z-index:auto;overflow-x:hidden}.q-toolbar[data-v-9bc794ce]{flex-wrap:nowrap;min-height:50px}.q-toolbar-title[data-v-9bc794ce]{flex:0 1 auto}.q-toolbar>.q-badge[data-v-9bc794ce]{flex-shrink:0}
2 |
--------------------------------------------------------------------------------
/public/cashu/assets/QList.1f08d45c.js:
--------------------------------------------------------------------------------
1 | import{B as r,E as o,k as i,ac as d,I as u,f as s}from"./index.916cb52e.js";import{u as g,a as c}from"./QItem.18ca9374.js";const b=["top","middle","bottom"];var q=r({name:"QBadge",props:{color:String,textColor:String,floating:Boolean,transparent:Boolean,multiLine:Boolean,outline:Boolean,rounded:Boolean,label:[Number,String],align:{type:String,validator:e=>b.includes(e)}},setup(e,{slots:a}){const l=o(()=>e.align!==void 0?{verticalAlign:e.align}:null),n=o(()=>{const t=e.outline===!0&&e.color||e.textColor;return`q-badge flex inline items-center no-wrap q-badge--${e.multiLine===!0?"multi":"single"}-line`+(e.outline===!0?" q-badge--outline":e.color!==void 0?` bg-${e.color}`:"")+(t!==void 0?` text-${t}`:"")+(e.floating===!0?" q-badge--floating":"")+(e.rounded===!0?" q-badge--rounded":"")+(e.transparent===!0?" q-badge--transparent":"")});return()=>i("div",{class:n.value,style:l.value,role:"status","aria-label":e.label},d(a.default,e.label!==void 0?[e.label]:[]))}}),B=r({name:"QList",props:{...g,bordered:Boolean,dense:Boolean,separator:Boolean,padding:Boolean,tag:{type:String,default:"div"}},setup(e,{slots:a}){const l=s(),n=c(e,l.proxy.$q),t=o(()=>"q-list"+(e.bordered===!0?" q-list--bordered":"")+(e.dense===!0?" q-list--dense":"")+(e.separator===!0?" q-list--separator":"")+(n.value===!0?" q-list--dark":"")+(e.padding===!0?" q-list--padding":""));return()=>i(e.tag,{class:t.value},u(a.default))}});export{q as Q,B as a};
2 |
--------------------------------------------------------------------------------
/public/cashu/assets/QResizeObserver.d45b2ee0.js:
--------------------------------------------------------------------------------
1 | import{r as g,a9 as y,o as d,B as z,F as f,m as w,n as v,k as O,f as x,a8 as b}from"./index.916cb52e.js";function E(){const r=g(!y.value);return r.value===!1&&d(()=>{r.value=!0}),{isHydrated:r}}const h=typeof ResizeObserver<"u",m=h===!0?{}:{style:"display:block;position:absolute;top:0;left:0;right:0;bottom:0;height:100%;width:100%;overflow:hidden;pointer-events:none;z-index:-1;",url:"about:blank"};var L=z({name:"QResizeObserver",props:{debounce:{type:[String,Number],default:100}},emits:["resize"],setup(r,{emit:p}){let n=null,t,o={width:-1,height:-1};function s(e){e===!0||r.debounce===0||r.debounce==="0"?u():n===null&&(n=setTimeout(u,r.debounce))}function u(){if(n!==null&&(clearTimeout(n),n=null),t){const{offsetWidth:e,offsetHeight:i}=t;(e!==o.width||i!==o.height)&&(o={width:e,height:i},p("resize",o))}}const{proxy:l}=x();if(l.trigger=s,h===!0){let e;const i=a=>{t=l.$el.parentNode,t?(e=new ResizeObserver(s),e.observe(t),u()):a!==!0&&v(()=>{i(!0)})};return d(()=>{i()}),f(()=>{n!==null&&clearTimeout(n),e!==void 0&&(e.disconnect!==void 0?e.disconnect():t&&e.unobserve(t))}),w}else{let a=function(){n!==null&&(clearTimeout(n),n=null),i!==void 0&&(i.removeEventListener!==void 0&&i.removeEventListener("resize",s,b.passive),i=void 0)},c=function(){a(),t&&t.contentDocument&&(i=t.contentDocument.defaultView,i.addEventListener("resize",s,b.passive),u())};const{isHydrated:e}=E();let i;return d(()=>{v(()=>{t=l.$el,t&&c()})}),f(a),()=>{if(e.value===!0)return O("object",{class:"q--avoid-card-border",style:m.style,tabindex:-1,type:"text/html",data:m.url,"aria-hidden":"true",onLoad:c})}}}});export{L as Q};
2 |
--------------------------------------------------------------------------------
/public/cashu/assets/QSpinnerHourglass.12e6d1e7.js:
--------------------------------------------------------------------------------
1 | import{k as e,B as a,aO as s,aP as o}from"./index.916cb52e.js";const n=[e("g",[e("path",{fill:"none",stroke:"currentColor","stroke-width":"5","stroke-miterlimit":"10",d:"M58.4,51.7c-0.9-0.9-1.4-2-1.4-2.3s0.5-0.4,1.4-1.4 C70.8,43.8,79.8,30.5,80,15.5H70H30H20c0.2,15,9.2,28.1,21.6,32.3c0.9,0.9,1.4,1.2,1.4,1.5s-0.5,1.6-1.4,2.5 C29.2,56.1,20.2,69.5,20,85.5h10h40h10C79.8,69.5,70.8,55.9,58.4,51.7z"}),e("clipPath",{id:"uil-hourglass-clip1"},[e("rect",{x:"15",y:"20",width:"70",height:"25"},[e("animate",{attributeName:"height",from:"25",to:"0",dur:"1s",repeatCount:"indefinite",values:"25;0;0",keyTimes:"0;0.5;1"}),e("animate",{attributeName:"y",from:"20",to:"45",dur:"1s",repeatCount:"indefinite",values:"20;45;45",keyTimes:"0;0.5;1"})])]),e("clipPath",{id:"uil-hourglass-clip2"},[e("rect",{x:"15",y:"55",width:"70",height:"25"},[e("animate",{attributeName:"height",from:"0",to:"25",dur:"1s",repeatCount:"indefinite",values:"0;25;25",keyTimes:"0;0.5;1"}),e("animate",{attributeName:"y",from:"80",to:"55",dur:"1s",repeatCount:"indefinite",values:"80;55;55",keyTimes:"0;0.5;1"})])]),e("path",{d:"M29,23c3.1,11.4,11.3,19.5,21,19.5S67.9,34.4,71,23H29z","clip-path":"url(#uil-hourglass-clip1)",fill:"currentColor"}),e("path",{d:"M71.6,78c-3-11.6-11.5-20-21.5-20s-18.5,8.4-21.5,20H71.6z","clip-path":"url(#uil-hourglass-clip2)",fill:"currentColor"}),e("animateTransform",{attributeName:"transform",type:"rotate",from:"0 50 50",to:"180 50 50",repeatCount:"indefinite",dur:"1s",values:"0 50 50;0 50 50;180 50 50",keyTimes:"0;0.7;1"})])];var l=a({name:"QSpinnerHourglass",props:s,setup(i){const{cSize:t,classes:r}=o(i);return()=>e("svg",{class:r.value,width:t.value,height:t.value,viewBox:"0 0 100 100",preserveAspectRatio:"xMidYMid",xmlns:"http://www.w3.org/2000/svg"},n)}});export{l as Q};
2 |
--------------------------------------------------------------------------------
/public/cashu/assets/QToolbar.4ef97f48.js:
--------------------------------------------------------------------------------
1 | import{B as t,E as r,k as s,I as l}from"./index.916cb52e.js";var p=t({name:"QToolbar",props:{inset:Boolean},setup(o,{slots:e}){const a=r(()=>"q-toolbar row no-wrap items-center"+(o.inset===!0?" q-toolbar--inset":""));return()=>s("div",{class:a.value,role:"toolbar"},l(e.default))}});export{p as Q};
2 |
--------------------------------------------------------------------------------
/public/cashu/assets/QToolbarTitle.7349d924.js:
--------------------------------------------------------------------------------
1 | import{B as a,E as t,k as l,I as r}from"./index.916cb52e.js";var n=a({name:"QToolbarTitle",props:{shrink:Boolean},setup(o,{slots:s}){const e=t(()=>"q-toolbar__title ellipsis"+(o.shrink===!0?" col-shrink":""));return()=>l("div",{class:e.value},r(s.default))}});export{n as Q};
2 |
--------------------------------------------------------------------------------
/public/cashu/assets/Settings.f9af94b6.css:
--------------------------------------------------------------------------------
1 | .section-divider{display:flex;align-items:center;width:100%;margin-bottom:24px}.divider-line{flex:1;height:1px;background-color:#48484a}.divider-text{padding:0 10px;font-size:14px;font-weight:600;color:#fff}
2 |
--------------------------------------------------------------------------------
/public/cashu/assets/TermsPage.8329ea15.css:
--------------------------------------------------------------------------------
1 | .q-dialog__inner[data-v-7395ac9f]{height:100%;width:100%;margin:0}.q-card[data-v-7395ac9f]{display:flex;flex-direction:column;height:100%}.q-carousel[data-v-7395ac9f]{flex:1}.custom-navigation[data-v-7395ac9f]{display:flex;justify-content:space-between;padding:16px}
2 |
--------------------------------------------------------------------------------
/public/cashu/assets/TermsPage.ae1dd3e0.js:
--------------------------------------------------------------------------------
1 | import{f as o,Q as a}from"./welcome.62644ccc.js";import{W as r}from"./WelcomeSlide4.4f3ce9ed.js";import{K as s,o as n,V as i,L as p,W as m,Q as e,O as c,a0 as _}from"./index.916cb52e.js";import"./QItem.18ca9374.js";import"./private.use-form.be065f06.js";import"./use-timeout.c52bf43b.js";import"./scroll.b2adc88e.js";import"./focusout.be8db32e.js";import"./index.42f06193.js";import"./use-checkbox.bd46c6a9.js";const l={name:"TermsPage",components:{WelcomeSlide4:r},setup(){return n(()=>{}),{}}};function d(u,f,g,h,v,x){const t=i("WelcomeSlide4");return p(),m(_,null,[e(o,{class:"bg-dark q-pa-none",style:{height:"100%"}},{default:c(()=>[e(t)]),_:1}),e(a,{persistent:"","transition-show":"slide-up","transition-hide":"fadeOut"})],64)}var B=s(l,[["render",d],["__scopeId","data-v-7395ac9f"]]);export{B as default};
2 |
--------------------------------------------------------------------------------
/public/cashu/assets/WelcomePage.b07b1606.css:
--------------------------------------------------------------------------------
1 | h2[data-v-55e7479b]{font-weight:700}p[data-v-55e7479b]{font-size:large}.relative-position[data-v-60a57b40]{position:relative}.instruction[data-v-60a57b40]{display:flex;align-items:center;justify-content:center;margin-bottom:1rem}.instruction span[data-v-60a57b40]{margin-left:.5rem;font-size:1rem}h2[data-v-60a57b40]{font-weight:700}h6[data-v-60a57b40]{font-weight:700;margin-top:1rem;margin-bottom:.5rem}p[data-v-60a57b40]{font-size:large}.sub-instruction[data-v-60a57b40]{margin-left:.5rem}h2[data-v-338b79e4]{font-weight:700}p[data-v-338b79e4]{font-size:large}.seed-phrase[data-v-338b79e4] .q-field__control{padding:12px!important}.seed-phrase[data-v-338b79e4]{font-size:.9rem;font-family:monospace;padding:12px!important}.q-dialog__inner[data-v-ca15906a]{height:100%;width:100%;margin:0}.q-card[data-v-ca15906a]{display:flex;flex-direction:column;height:100%}.q-carousel[data-v-ca15906a]{flex:1}.custom-navigation[data-v-ca15906a]{display:flex;justify-content:space-between;padding:16px}
2 |
--------------------------------------------------------------------------------
/public/cashu/assets/WelcomeSlide4.8505cd1c.css:
--------------------------------------------------------------------------------
1 | h2[data-v-304b4918]{font-weight:700}p[data-v-304b4918]{font-size:.82rem;color:#c6c6c6}
2 |
--------------------------------------------------------------------------------
/public/cashu/assets/cashu.5d75870c.js:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/public/cashu/assets/flUhRq6tzZclQEJ-Vdg-IuiaDsNa.fd84f88b.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/irislib/iris-client/90cad78bca32db028d063af64008212d027d4d8a/public/cashu/assets/flUhRq6tzZclQEJ-Vdg-IuiaDsNa.fd84f88b.woff
--------------------------------------------------------------------------------
/public/cashu/assets/flUhRq6tzZclQEJ-Vdg-IuiaDsNcIhQ8tQ.4a4dbc62.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/irislib/iris-client/90cad78bca32db028d063af64008212d027d4d8a/public/cashu/assets/flUhRq6tzZclQEJ-Vdg-IuiaDsNcIhQ8tQ.4a4dbc62.woff2
--------------------------------------------------------------------------------
/public/cashu/assets/focusout.be8db32e.js:
--------------------------------------------------------------------------------
1 | import{c as i}from"./index.916cb52e.js";const e=[];function o(n){e[e.length-1](n)}function c(n){i.is.desktop===!0&&(e.push(n),e.length===1&&document.body.addEventListener("focusin",o))}function u(n){const t=e.indexOf(n);t!==-1&&(e.splice(t,1),e.length===0&&document.body.removeEventListener("focusin",o))}export{c as a,u as r};
2 |
--------------------------------------------------------------------------------
/public/cashu/assets/global-components.f92d7ae1.js:
--------------------------------------------------------------------------------
1 | import{h as m}from"./index.916cb52e.js";import{i as o}from"./vue-qrcode.esm.c5e039a4.js";var e=m(async({app:a})=>{a.component(o.name,o)});export{e as default};
2 |
--------------------------------------------------------------------------------
/public/cashu/assets/material-icons-v50.fbba257d.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/irislib/iris-client/90cad78bca32db028d063af64008212d027d4d8a/public/cashu/assets/material-icons-v50.fbba257d.woff2
--------------------------------------------------------------------------------
/public/cashu/assets/scroll.b2adc88e.js:
--------------------------------------------------------------------------------
1 | import{ad as s,ae as l}from"./index.916cb52e.js";const d=[Element,String],r=[null,document,document.body,document.scrollingElement,document.documentElement];function a(o,t){let e=s(t);if(e===void 0){if(o==null)return window;e=o.closest(".scroll,.scroll-y,.overflow-auto")}return r.includes(e)?window:e}function u(o){return o===window?window.pageYOffset||window.scrollY||document.body.scrollTop||0:o.scrollTop}function f(o){return o===window?window.pageXOffset||window.scrollX||document.body.scrollLeft||0:o.scrollLeft}let n;function w(){if(n!==void 0)return n;const o=document.createElement("p"),t=document.createElement("div");l(o,{width:"100%",height:"200px"}),l(t,{position:"absolute",top:"0px",left:"0px",visibility:"hidden",width:"200px",height:"150px",overflow:"hidden"}),t.appendChild(o),document.body.appendChild(t);const e=o.offsetWidth;t.style.overflow="scroll";let i=o.offsetWidth;return e===i&&(i=t.clientWidth),t.remove(),n=e-i,n}function p(o,t=!0){return!o||o.nodeType!==Node.ELEMENT_NODE?!1:t?o.scrollHeight>o.clientHeight&&(o.classList.contains("scroll")||o.classList.contains("overflow-auto")||["auto","scroll"].includes(window.getComputedStyle(o)["overflow-y"])):o.scrollWidth>o.clientWidth&&(o.classList.contains("scroll")||o.classList.contains("overflow-auto")||["auto","scroll"].includes(window.getComputedStyle(o)["overflow-x"]))}export{u as a,f as b,w as c,a as g,p as h,d as s};
2 |
--------------------------------------------------------------------------------
/public/cashu/assets/selection.7b8f2356.js:
--------------------------------------------------------------------------------
1 | import{ao as o}from"./index.916cb52e.js";function i(){if(window.getSelection!==void 0){const e=window.getSelection();e.empty!==void 0?e.empty():e.removeAllRanges!==void 0&&(e.removeAllRanges(),o.is.mobile!==!0&&e.addRange(document.createRange()))}else document.selection!==void 0&&document.selection.empty()}export{i as c};
2 |
--------------------------------------------------------------------------------
/public/cashu/assets/touch.9135741d.js:
--------------------------------------------------------------------------------
1 | const r={left:!0,right:!0,up:!0,down:!0,horizontal:!0,vertical:!0},o=Object.keys(r);r.all=!0;function n(t){const e={};for(const i of o)t[i]===!0&&(e[i]=!0);return Object.keys(e).length===0?r:(e.horizontal===!0?e.left=e.right=!0:e.left===!0&&e.right===!0&&(e.horizontal=!0),e.vertical===!0?e.up=e.down=!0:e.up===!0&&e.down===!0&&(e.vertical=!0),e.horizontal===!0&&e.vertical===!0&&(e.all=!0),e)}const u=["INPUT","TEXTAREA"];function l(t,e){return e.event===void 0&&t.target!==void 0&&t.target.draggable!==!0&&typeof e.handler=="function"&&u.includes(t.target.nodeName.toUpperCase())===!1&&(t.qClonedBy===void 0||t.qClonedBy.indexOf(e.uid)===-1)}export{n as g,l as s};
2 |
--------------------------------------------------------------------------------
/public/cashu/assets/web.050b05b0.js:
--------------------------------------------------------------------------------
1 | import{Z as t}from"./ui.b6121134.js";import"./index.916cb52e.js";import"./index.42f06193.js";class o extends t{async getSafeAreaInsets(){return{insets:{top:0,left:0,right:0,bottom:0}}}async getStatusBarHeight(){return{statusBarHeight:0}}setImmersiveNavigationBar(){throw this.unimplemented("Method not supported on Web.")}}export{o as SafeAreaWeb};
2 |
--------------------------------------------------------------------------------
/public/cashu/assets/web.e3bae036.js:
--------------------------------------------------------------------------------
1 | import{Z as a,_ as i,$ as r}from"./ui.b6121134.js";import"./index.916cb52e.js";import"./index.42f06193.js";class l extends a{constructor(){super(...arguments),this.selectionStarted=!1}async impact(t){const e=this.patternForImpact(t?.style);this.vibrateWithPattern(e)}async notification(t){const e=this.patternForNotification(t?.type);this.vibrateWithPattern(e)}async vibrate(t){const e=t?.duration||300;this.vibrateWithPattern([e])}async selectionStart(){this.selectionStarted=!0}async selectionChanged(){this.selectionStarted&&this.vibrateWithPattern([70])}async selectionEnd(){this.selectionStarted=!1}patternForImpact(t=i.Heavy){return t===i.Medium?[43]:t===i.Light?[20]:[61]}patternForNotification(t=r.Success){return t===r.Warning?[30,40,30,50,60]:t===r.Error?[27,45,50]:[35,65,21]}vibrateWithPattern(t){if(navigator.vibrate)navigator.vibrate(t);else throw this.unavailable("Browser does not support the vibrate API")}}export{l as HapticsWeb};
2 |
--------------------------------------------------------------------------------
/public/cashu/clean.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/irislib/iris-client/90cad78bca32db028d063af64008212d027d4d8a/public/cashu/clean.png
--------------------------------------------------------------------------------
/public/cashu/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/irislib/iris-client/90cad78bca32db028d063af64008212d027d4d8a/public/cashu/favicon.ico
--------------------------------------------------------------------------------
/public/cashu/icons/apple-icon-120x120.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/irislib/iris-client/90cad78bca32db028d063af64008212d027d4d8a/public/cashu/icons/apple-icon-120x120.png
--------------------------------------------------------------------------------
/public/cashu/icons/apple-icon-152x152.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/irislib/iris-client/90cad78bca32db028d063af64008212d027d4d8a/public/cashu/icons/apple-icon-152x152.png
--------------------------------------------------------------------------------
/public/cashu/icons/apple-icon-167x167.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/irislib/iris-client/90cad78bca32db028d063af64008212d027d4d8a/public/cashu/icons/apple-icon-167x167.png
--------------------------------------------------------------------------------
/public/cashu/icons/apple-icon-180x180.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/irislib/iris-client/90cad78bca32db028d063af64008212d027d4d8a/public/cashu/icons/apple-icon-180x180.png
--------------------------------------------------------------------------------
/public/cashu/icons/apple-launch-1080x2340.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/irislib/iris-client/90cad78bca32db028d063af64008212d027d4d8a/public/cashu/icons/apple-launch-1080x2340.png
--------------------------------------------------------------------------------
/public/cashu/icons/apple-launch-1125x2436.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/irislib/iris-client/90cad78bca32db028d063af64008212d027d4d8a/public/cashu/icons/apple-launch-1125x2436.png
--------------------------------------------------------------------------------
/public/cashu/icons/apple-launch-1170x2532.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/irislib/iris-client/90cad78bca32db028d063af64008212d027d4d8a/public/cashu/icons/apple-launch-1170x2532.png
--------------------------------------------------------------------------------
/public/cashu/icons/apple-launch-1179x2556.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/irislib/iris-client/90cad78bca32db028d063af64008212d027d4d8a/public/cashu/icons/apple-launch-1179x2556.png
--------------------------------------------------------------------------------
/public/cashu/icons/apple-launch-1242x2208.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/irislib/iris-client/90cad78bca32db028d063af64008212d027d4d8a/public/cashu/icons/apple-launch-1242x2208.png
--------------------------------------------------------------------------------
/public/cashu/icons/apple-launch-1242x2688.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/irislib/iris-client/90cad78bca32db028d063af64008212d027d4d8a/public/cashu/icons/apple-launch-1242x2688.png
--------------------------------------------------------------------------------
/public/cashu/icons/apple-launch-1284x2778.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/irislib/iris-client/90cad78bca32db028d063af64008212d027d4d8a/public/cashu/icons/apple-launch-1284x2778.png
--------------------------------------------------------------------------------
/public/cashu/icons/apple-launch-1290x2796.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/irislib/iris-client/90cad78bca32db028d063af64008212d027d4d8a/public/cashu/icons/apple-launch-1290x2796.png
--------------------------------------------------------------------------------
/public/cashu/icons/apple-launch-1536x2048.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/irislib/iris-client/90cad78bca32db028d063af64008212d027d4d8a/public/cashu/icons/apple-launch-1536x2048.png
--------------------------------------------------------------------------------
/public/cashu/icons/apple-launch-1620x2160.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/irislib/iris-client/90cad78bca32db028d063af64008212d027d4d8a/public/cashu/icons/apple-launch-1620x2160.png
--------------------------------------------------------------------------------
/public/cashu/icons/apple-launch-1668x2224.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/irislib/iris-client/90cad78bca32db028d063af64008212d027d4d8a/public/cashu/icons/apple-launch-1668x2224.png
--------------------------------------------------------------------------------
/public/cashu/icons/apple-launch-1668x2388.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/irislib/iris-client/90cad78bca32db028d063af64008212d027d4d8a/public/cashu/icons/apple-launch-1668x2388.png
--------------------------------------------------------------------------------
/public/cashu/icons/apple-launch-2048x2732.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/irislib/iris-client/90cad78bca32db028d063af64008212d027d4d8a/public/cashu/icons/apple-launch-2048x2732.png
--------------------------------------------------------------------------------
/public/cashu/icons/apple-launch-750x1334.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/irislib/iris-client/90cad78bca32db028d063af64008212d027d4d8a/public/cashu/icons/apple-launch-750x1334.png
--------------------------------------------------------------------------------
/public/cashu/icons/apple-launch-828x1792.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/irislib/iris-client/90cad78bca32db028d063af64008212d027d4d8a/public/cashu/icons/apple-launch-828x1792.png
--------------------------------------------------------------------------------
/public/cashu/icons/favicon-128x128.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/irislib/iris-client/90cad78bca32db028d063af64008212d027d4d8a/public/cashu/icons/favicon-128x128.png
--------------------------------------------------------------------------------
/public/cashu/icons/favicon-16x16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/irislib/iris-client/90cad78bca32db028d063af64008212d027d4d8a/public/cashu/icons/favicon-16x16.png
--------------------------------------------------------------------------------
/public/cashu/icons/favicon-32x32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/irislib/iris-client/90cad78bca32db028d063af64008212d027d4d8a/public/cashu/icons/favicon-32x32.png
--------------------------------------------------------------------------------
/public/cashu/icons/favicon-96x96.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/irislib/iris-client/90cad78bca32db028d063af64008212d027d4d8a/public/cashu/icons/favicon-96x96.png
--------------------------------------------------------------------------------
/public/cashu/icons/icon-128x128.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/irislib/iris-client/90cad78bca32db028d063af64008212d027d4d8a/public/cashu/icons/icon-128x128.png
--------------------------------------------------------------------------------
/public/cashu/icons/icon-192x192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/irislib/iris-client/90cad78bca32db028d063af64008212d027d4d8a/public/cashu/icons/icon-192x192.png
--------------------------------------------------------------------------------
/public/cashu/icons/icon-256x256.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/irislib/iris-client/90cad78bca32db028d063af64008212d027d4d8a/public/cashu/icons/icon-256x256.png
--------------------------------------------------------------------------------
/public/cashu/icons/icon-384x384.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/irislib/iris-client/90cad78bca32db028d063af64008212d027d4d8a/public/cashu/icons/icon-384x384.png
--------------------------------------------------------------------------------
/public/cashu/icons/icon-512x512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/irislib/iris-client/90cad78bca32db028d063af64008212d027d4d8a/public/cashu/icons/icon-512x512.png
--------------------------------------------------------------------------------
/public/cashu/icons/ms-icon-144x144.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/irislib/iris-client/90cad78bca32db028d063af64008212d027d4d8a/public/cashu/icons/ms-icon-144x144.png
--------------------------------------------------------------------------------
/public/cashu/index.html:
--------------------------------------------------------------------------------
1 |
Cashu.me
2 |
3 |
--------------------------------------------------------------------------------
/public/cashu/nostr-icon.svg:
--------------------------------------------------------------------------------
1 |
20 |
--------------------------------------------------------------------------------
/public/cashu/screenshots/narrow-1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/irislib/iris-client/90cad78bca32db028d063af64008212d027d4d8a/public/cashu/screenshots/narrow-1.png
--------------------------------------------------------------------------------
/public/cashu/screenshots/narrow-2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/irislib/iris-client/90cad78bca32db028d063af64008212d027d4d8a/public/cashu/screenshots/narrow-2.png
--------------------------------------------------------------------------------
/public/cashu/screenshots/wide-1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/irislib/iris-client/90cad78bca32db028d063af64008212d027d4d8a/public/cashu/screenshots/wide-1.png
--------------------------------------------------------------------------------
/public/cashu/screenshots/wide-2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/irislib/iris-client/90cad78bca32db028d063af64008212d027d4d8a/public/cashu/screenshots/wide-2.png
--------------------------------------------------------------------------------
/public/cashu/x-logo.svg:
--------------------------------------------------------------------------------
1 |
20 |
--------------------------------------------------------------------------------
/public/clean.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/irislib/iris-client/90cad78bca32db028d063af64008212d027d4d8a/public/clean.png
--------------------------------------------------------------------------------
/public/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/irislib/iris-client/90cad78bca32db028d063af64008212d027d4d8a/public/favicon.png
--------------------------------------------------------------------------------
/public/img/android-chrome-192x192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/irislib/iris-client/90cad78bca32db028d063af64008212d027d4d8a/public/img/android-chrome-192x192.png
--------------------------------------------------------------------------------
/public/img/android-chrome-512x512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/irislib/iris-client/90cad78bca32db028d063af64008212d027d4d8a/public/img/android-chrome-512x512.png
--------------------------------------------------------------------------------
/public/img/apple-touch-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/irislib/iris-client/90cad78bca32db028d063af64008212d027d4d8a/public/img/apple-touch-icon.png
--------------------------------------------------------------------------------
/public/img/icon128.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/irislib/iris-client/90cad78bca32db028d063af64008212d027d4d8a/public/img/icon128.png
--------------------------------------------------------------------------------
/public/img/irisconnects.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/irislib/iris-client/90cad78bca32db028d063af64008212d027d4d8a/public/img/irisconnects.png
--------------------------------------------------------------------------------
/public/img/maskable_icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/irislib/iris-client/90cad78bca32db028d063af64008212d027d4d8a/public/img/maskable_icon.png
--------------------------------------------------------------------------------
/public/img/maskable_icon_x192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/irislib/iris-client/90cad78bca32db028d063af64008212d027d4d8a/public/img/maskable_icon_x192.png
--------------------------------------------------------------------------------
/public/img/zap.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/irislib/iris-client/90cad78bca32db028d063af64008212d027d4d8a/public/img/zap.png
--------------------------------------------------------------------------------
/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "Iris",
3 | "name": "Iris",
4 | "description": "Connecting People",
5 | "id": "/",
6 | "icons": [
7 | {
8 | "src": "/img/android-chrome-192x192.png",
9 | "sizes": "192x192",
10 | "type": "image/png"
11 | },
12 | {
13 | "src": "/img/android-chrome-512x512.png",
14 | "sizes": "512x512",
15 | "type": "image/png",
16 | "purpose": "any"
17 | },
18 | {
19 | "src": "/img/maskable_icon.png",
20 | "sizes": "640x640",
21 | "type": "image/png",
22 | "purpose": "maskable"
23 | },
24 | {
25 | "src": "/img/maskable_icon_x192.png",
26 | "sizes": "192x192",
27 | "type": "image/png",
28 | "purpose": "maskable"
29 | }
30 | ],
31 | "start_url": "/",
32 | "display": "standalone",
33 | "theme_color": "#000000",
34 | "background_color": "#000000",
35 | "protocol_handlers": [
36 | {
37 | "protocol": "web+nostr",
38 | "url": "/%s"
39 | }
40 | ],
41 | "screenshots": [],
42 | "display_override": ["fullscreen"]
43 | }
44 |
--------------------------------------------------------------------------------
/public/robots.txt:
--------------------------------------------------------------------------------
1 | # https://www.robotstxt.org/robotstxt.html
2 | User-agent: *
3 | Disallow:
4 |
--------------------------------------------------------------------------------
/src/assets/Bitcoin.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/irislib/iris-client/90cad78bca32db028d063af64008212d027d4d8a/src/assets/Bitcoin.png
--------------------------------------------------------------------------------
/src/assets/alby-logo.avif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/irislib/iris-client/90cad78bca32db028d063af64008212d027d4d8a/src/assets/alby-logo.avif
--------------------------------------------------------------------------------
/src/assets/banner.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/irislib/iris-client/90cad78bca32db028d063af64008212d027d4d8a/src/assets/banner.jpg
--------------------------------------------------------------------------------
/src/assets/chrome-logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/irislib/iris-client/90cad78bca32db028d063af64008212d027d4d8a/src/assets/chrome-logo.png
--------------------------------------------------------------------------------
/src/assets/default-repo-pic.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/irislib/iris-client/90cad78bca32db028d063af64008212d027d4d8a/src/assets/default-repo-pic.webp
--------------------------------------------------------------------------------
/src/assets/default_profile_pic.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/irislib/iris-client/90cad78bca32db028d063af64008212d027d4d8a/src/assets/default_profile_pic.jpg
--------------------------------------------------------------------------------
/src/assets/feed-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/irislib/iris-client/90cad78bca32db028d063af64008212d027d4d8a/src/assets/feed-icon.png
--------------------------------------------------------------------------------
/src/assets/firefox-logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/irislib/iris-client/90cad78bca32db028d063af64008212d027d4d8a/src/assets/firefox-logo.png
--------------------------------------------------------------------------------
/src/assets/git-logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/irislib/iris-client/90cad78bca32db028d063af64008212d027d4d8a/src/assets/git-logo.png
--------------------------------------------------------------------------------
/src/assets/highlights-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/irislib/iris-client/90cad78bca32db028d063af64008212d027d4d8a/src/assets/highlights-icon.png
--------------------------------------------------------------------------------
/src/assets/hornet-storage.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/irislib/iris-client/90cad78bca32db028d063af64008212d027d4d8a/src/assets/hornet-storage.png
--------------------------------------------------------------------------------
/src/assets/landing-page-img-coder.avif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/irislib/iris-client/90cad78bca32db028d063af64008212d027d4d8a/src/assets/landing-page-img-coder.avif
--------------------------------------------------------------------------------
/src/assets/landing-page-img-nostrich.avif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/irislib/iris-client/90cad78bca32db028d063af64008212d027d4d8a/src/assets/landing-page-img-nostrich.avif
--------------------------------------------------------------------------------
/src/assets/long-form-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/irislib/iris-client/90cad78bca32db028d063af64008212d027d4d8a/src/assets/long-form-icon.png
--------------------------------------------------------------------------------
/src/assets/nestr-logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/irislib/iris-client/90cad78bca32db028d063af64008212d027d4d8a/src/assets/nestr-logo.png
--------------------------------------------------------------------------------
/src/assets/note-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/irislib/iris-client/90cad78bca32db028d063af64008212d027d4d8a/src/assets/note-icon.png
--------------------------------------------------------------------------------
/src/assets/quotes-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/irislib/iris-client/90cad78bca32db028d063af64008212d027d4d8a/src/assets/quotes-icon.png
--------------------------------------------------------------------------------
/src/assets/reels-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/irislib/iris-client/90cad78bca32db028d063af64008212d027d4d8a/src/assets/reels-icon.png
--------------------------------------------------------------------------------
/src/assets/running-ostrich.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/irislib/iris-client/90cad78bca32db028d063af64008212d027d4d8a/src/assets/running-ostrich.gif
--------------------------------------------------------------------------------
/src/assets/satoshi-white.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/irislib/iris-client/90cad78bca32db028d063af64008212d027d4d8a/src/assets/satoshi-white.png
--------------------------------------------------------------------------------
/src/assets/satoshi.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/assets/wallet-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/irislib/iris-client/90cad78bca32db028d063af64008212d027d4d8a/src/assets/wallet-icon.png
--------------------------------------------------------------------------------
/src/pages/Page404.tsx:
--------------------------------------------------------------------------------
1 | import {useNavigate} from "react-router"
2 |
3 | export const Page404 = () => {
4 | const navigate = useNavigate()
5 |
6 | const handleHomeClick = () => {
7 | navigate("/")
8 | }
9 |
10 | return (
11 |
12 |
13 |
14 |
404
15 |
The page you are looking for could not be found.
16 |
19 |
20 |
21 |
22 | )
23 | }
24 |
--------------------------------------------------------------------------------
/src/pages/chats/NewChat.tsx:
--------------------------------------------------------------------------------
1 | import NotificationPrompt from "@/shared/components/NotificationPrompt"
2 | import InstallPWAPrompt from "@/shared/components/InstallPWAPrompt"
3 | import PrivateChatCreation from "./private/PrivateChatCreation"
4 | import {Link, Routes, Route, useLocation} from "react-router"
5 | import PublicChatCreation from "./public/PublicChatCreation"
6 | import Header from "@/shared/components/header/Header"
7 |
8 | const TabSelector = () => {
9 | const location = useLocation()
10 | const isPublic = location.pathname === "/chats/new/public"
11 |
12 | const getClasses = (isActive: boolean) => {
13 | const baseClasses = "border-highlight cursor-pointer flex justify-center flex-1 p-3"
14 | return isActive
15 | ? `${baseClasses} border-b border-1`
16 | : `${baseClasses} text-base-content/70 hover:text-base-content border-b border-1 border-transparent`
17 | }
18 |
19 | return (
20 |
21 |
22 | Private
23 |
24 |
25 | Public
26 |
27 |
28 | )
29 | }
30 |
31 | const NewChat = () => {
32 | return (
33 | <>
34 |
35 |
36 |
37 |
38 | } />
39 | } />
40 |
41 |
42 | >
43 | )
44 | }
45 |
46 | export default NewChat
47 |
--------------------------------------------------------------------------------
/src/pages/chats/index.tsx:
--------------------------------------------------------------------------------
1 | import PublicChatDetails from "./public/PublicChatDetails"
2 | import {useLocation, Routes, Route} from "react-router"
3 | import PrivateChat from "./private/PrivateChat"
4 | import PublicChat from "./public/PublicChat"
5 | import ChatList from "./list/ChatList"
6 | import {Helmet} from "react-helmet"
7 | import classNames from "classnames"
8 | import NewChat from "./NewChat"
9 |
10 | function Messages() {
11 | const location = useLocation()
12 | const isMessagesRoot = location.pathname === "/chats"
13 |
14 | return (
15 |
16 |
25 |
31 |
32 | } />
33 | }
36 | />
37 | } />
38 | } />
39 | } />
40 |
41 |
42 |
43 | Messages
44 |
45 |
46 | )
47 | }
48 |
49 | export default Messages
50 |
--------------------------------------------------------------------------------
/src/pages/chats/public/PublicChatContext.tsx:
--------------------------------------------------------------------------------
1 | import {createContext, Dispatch, SetStateAction} from "react"
2 |
3 | export const PublicChatContext = createContext<{
4 | setPublicChatTimestamps: Dispatch>> | null
5 | }>({setPublicChatTimestamps: null})
6 |
--------------------------------------------------------------------------------
/src/pages/chats/utils/channelSearch.ts:
--------------------------------------------------------------------------------
1 | import {ChannelMetadata} from "./channelMetadata"
2 | import {LRUCache} from "typescript-lru-cache"
3 | import Fuse from "fuse.js"
4 |
5 | // LRU cache for channel metadata
6 | const channelCache = new LRUCache({maxSize: 200})
7 |
8 | // Fuse.js search index
9 | let fuse: Fuse | null = null
10 |
11 | // Update the search index with new channel metadata
12 | export const updateChannelSearchIndex = (
13 | channelId: string,
14 | metadata: ChannelMetadata
15 | ) => {
16 | // Update the cache
17 | channelCache.set(channelId, metadata)
18 |
19 | // Recreate the Fuse.js index
20 | const channels = Array.from(channelCache.values())
21 | fuse = new Fuse(channels, {
22 | keys: ["name", "about"],
23 | threshold: 0.3,
24 | includeScore: true,
25 | })
26 | }
27 |
28 | // Search for channels using the Fuse.js index
29 | export const searchChannels = (query: string): ChannelMetadata[] => {
30 | if (!fuse || !query.trim()) {
31 | return []
32 | }
33 |
34 | const results = fuse.search(query)
35 | // Deduplicate results by channel ID
36 | const seenIds = new Set()
37 | return results
38 | .map((result) => result.item)
39 | .filter((metadata) => {
40 | if (seenIds.has(metadata.founderPubkey)) {
41 | return false
42 | }
43 | seenIds.add(metadata.founderPubkey)
44 | return true
45 | })
46 | }
47 |
48 | // Get a channel from the cache
49 | export const getCachedChannel = (channelId: string): ChannelMetadata | undefined => {
50 | return channelCache.get(channelId) ?? undefined
51 | }
52 |
--------------------------------------------------------------------------------
/src/pages/chats/utils/constants.ts:
--------------------------------------------------------------------------------
1 | // NIP-28 event kinds
2 | export const CHANNEL_CREATE = 40
3 | export const CHANNEL_MESSAGE = 42
4 | export const CHANNEL_HIDE_MESSAGE = 43
5 | export const CHANNEL_MUTE_USER = 44
6 |
7 | // NIP-25 reactions
8 | export const REACTION_KIND = 7
9 |
--------------------------------------------------------------------------------
/src/pages/home/feed/components/WalletFeedItem.tsx:
--------------------------------------------------------------------------------
1 | import {RiFlashlightLine} from "@remixicon/react"
2 |
3 | import {Avatar} from "@/shared/components/user/Avatar.tsx"
4 | import {Name} from "@/shared/components/user/Name.tsx"
5 |
6 | const WalletFeedItem = () => {
7 | return (
8 |
9 |
10 |
11 | -1
12 |
13 |
14 |
15 |
16 |
17 |
18 | Zapped you for a total of -1 sats.
19 |
20 | Zap message here.
21 |
22 |
23 |
24 | )
25 | }
26 |
27 | export default WalletFeedItem
28 |
--------------------------------------------------------------------------------
/src/pages/home/index.tsx:
--------------------------------------------------------------------------------
1 | import HomeFeedEvents from "@/pages/home/feed/components/HomeFeedEvents.tsx"
2 | import RightColumn from "@/shared/components/RightColumn.tsx"
3 | import Trending from "@/shared/components/feed/Trending.tsx"
4 | import Widget from "@/shared/components/ui/Widget.tsx"
5 |
6 | function Index() {
7 | return (
8 |
9 |
10 |
11 |
12 |
13 | {() => (
14 | <>
15 |
16 |
17 |
18 |
19 |
20 |
21 | >
22 | )}
23 |
24 |
25 | )
26 | }
27 |
28 | export default Index
29 |
--------------------------------------------------------------------------------
/src/pages/notifications/Notifications.tsx:
--------------------------------------------------------------------------------
1 | import NotificationsFeed from "@/shared/components/feed/NotificationsFeed.tsx"
2 | import RightColumn from "@/shared/components/RightColumn"
3 | import Trending from "@/shared/components/feed/Trending"
4 | import Header from "@/shared/components/header/Header"
5 | import Widget from "@/shared/components/ui/Widget"
6 |
7 | import {subscribeToNotifications} from "@/utils/notifications"
8 | import {useEffect} from "react"
9 | let subscribed = false
10 |
11 | function Notifications() {
12 | useEffect(() => {
13 | if (subscribed) {
14 | return
15 | }
16 | subscribeToNotifications()
17 | subscribed = true
18 | })
19 |
20 | return (
21 |
22 |
23 |
24 |
25 |
26 |
27 | {() => (
28 | <>
29 |
30 |
31 |
32 | >
33 | )}
34 |
35 |
36 | )
37 | }
38 |
39 | export default Notifications
40 |
--------------------------------------------------------------------------------
/src/pages/settings/Appearance.tsx:
--------------------------------------------------------------------------------
1 | import {useSettingsStore} from "@/stores/settings"
2 | import {ChangeEvent} from "react"
3 |
4 | function AppearanceSettings() {
5 | const {appearance, updateAppearance} = useSettingsStore()
6 |
7 | function handleThemeChange(e: ChangeEvent) {
8 | updateAppearance({theme: e.target.value})
9 | }
10 |
11 | return (
12 |
13 |
Appearance
14 |
15 |
16 |
Theme
17 |
18 |
28 |
29 |
30 |
31 |
32 | )
33 | }
34 |
35 | export default AppearanceSettings
36 |
--------------------------------------------------------------------------------
/src/pages/settings/Backup.tsx:
--------------------------------------------------------------------------------
1 | import CopyButton from "@/shared/components/button/CopyButton.tsx"
2 | import {hexToBytes} from "@noble/hashes/utils"
3 | import {useUserStore} from "@/stores/user"
4 | import {nip19} from "nostr-tools"
5 |
6 | function Backup() {
7 | const privateKey = useUserStore((state) => state.privateKey)
8 |
9 | return (
10 |
11 |
Backup
12 |
13 | {privateKey && (
14 |
15 |
Backup your Nostr key
16 |
Copy and securely store your secret key.
17 |
18 |
23 |
24 |
25 | )}
26 |
27 |
Backup your Notes
28 |
Export all your notes for safekeeping.
29 |
30 |
33 |
34 |
35 |
36 |
37 | )
38 | }
39 |
40 | export default Backup
41 |
--------------------------------------------------------------------------------
/src/pages/settings/IrisAccount/AccountName.tsx:
--------------------------------------------------------------------------------
1 | import {useNavigate} from "react-router"
2 |
3 | interface AccountNameProps {
4 | name?: string
5 | link?: boolean
6 | }
7 |
8 | export default function AccountName({name = "", link = true}: AccountNameProps) {
9 | const navigate = useNavigate()
10 | return (
11 | <>
12 |
13 | Username: {name}
14 |
15 |
31 |
32 | Nostr address (nip05): {name}@iris.to
33 |
34 | >
35 | )
36 | }
37 |
--------------------------------------------------------------------------------
/src/pages/settings/IrisAccount/ActiveAccount.tsx:
--------------------------------------------------------------------------------
1 | import AccountName from "./AccountName"
2 | import {ndk} from "@/utils/ndk"
3 |
4 | interface ActiveAccountProps {
5 | name?: string
6 | setAsPrimary: () => void
7 | myPub?: string
8 | }
9 |
10 | export default function ActiveAccount({
11 | name = "",
12 | setAsPrimary = () => {},
13 | myPub = "",
14 | }: ActiveAccountProps) {
15 | async function saveProfile(nip05: string) {
16 | const user = ndk().getUser({pubkey: myPub})
17 | user.profile = user.profile || {nip05}
18 | user.publish()
19 | }
20 |
21 | const onClick = async () => {
22 | const profile = ndk().getUser({pubkey: myPub}).profile
23 | const newNip = name + "@iris.to"
24 | const timeout = setTimeout(() => {
25 | saveProfile(newNip)
26 | }, 2000)
27 | if (profile) {
28 | clearTimeout(timeout)
29 | if (profile.nip05 !== newNip) {
30 | saveProfile(newNip)
31 | setAsPrimary()
32 | }
33 | }
34 | }
35 |
36 | return (
37 |
38 |
39 | You have an active iris.to username:
40 |
41 |
42 |
43 |
46 |
47 |
48 | )
49 | }
50 |
--------------------------------------------------------------------------------
/src/pages/settings/IrisAccount/ChallengeForm.tsx:
--------------------------------------------------------------------------------
1 | import {useEffect} from "react"
2 |
3 | interface ChallengeFormProps {
4 | onVerify: (token: string) => void
5 | }
6 |
7 | function ChallengeForm({onVerify}: ChallengeFormProps) {
8 | useEffect(() => {
9 | // Setup callback for Cloudflare
10 | window.cf_turnstile_callback = (token: string) => onVerify(token)
11 |
12 | // Load Cloudflare script
13 | if (
14 | !document.querySelector(
15 | 'script[src="https://challenges.cloudflare.com/turnstile/v0/api.js"]'
16 | )
17 | ) {
18 | const script = document.createElement("script")
19 | script.src = "https://challenges.cloudflare.com/turnstile/v0/api.js"
20 | script.async = true
21 | script.defer = true
22 | document.body.appendChild(script)
23 | }
24 |
25 | return () => {
26 | delete window.cf_turnstile_callback
27 | }
28 | }, [onVerify])
29 |
30 | return (
31 |
40 | )
41 | }
42 |
43 | export default ChallengeForm
44 |
--------------------------------------------------------------------------------
/src/pages/settings/IrisAccount/IrisSettings.tsx:
--------------------------------------------------------------------------------
1 | import IrisAccount from "./IrisAccount"
2 |
3 | function IrisSettings() {
4 | return (
5 |
6 |
Iris.to username
7 |
12 |
13 | )
14 | }
15 |
16 | export default IrisSettings
17 |
--------------------------------------------------------------------------------
/src/pages/settings/Privacy.tsx:
--------------------------------------------------------------------------------
1 | import {useSettingsStore} from "@/stores/settings"
2 | import {ChangeEvent} from "react"
3 |
4 | function PrivacySettings() {
5 | const {privacy, updatePrivacy} = useSettingsStore()
6 |
7 | function handleEnableAnalyticsChange(e: ChangeEvent) {
8 | updatePrivacy({enableAnalytics: e.target.checked})
9 | }
10 |
11 | return (
12 |
13 |
Privacy
14 |
15 |
16 |
25 |
26 |
27 |
28 | )
29 | }
30 |
31 | export default PrivacySettings
32 |
--------------------------------------------------------------------------------
/src/pages/user/components/FollowList.tsx:
--------------------------------------------------------------------------------
1 | import {useState} from "react"
2 |
3 | import InfiniteScroll from "@/shared/components/ui/InfiniteScroll.tsx" // Make sure to import InfiniteScroll
4 | import ProfileCard from "@/shared/components/user/ProfileCard"
5 | import useFollows from "@/shared/hooks/useFollows"
6 |
7 | interface FollowListProps {
8 | follows?: string[]
9 | pubKey?: string
10 | initialDisplayCount?: number
11 | showAbout?: boolean
12 | }
13 |
14 | function FollowList({
15 | follows,
16 | pubKey = "",
17 | initialDisplayCount = 10,
18 | showAbout = false,
19 | }: FollowListProps) {
20 | const [displayCount, setDisplayCount] = useState(initialDisplayCount) // Start by displaying 10 items
21 | const f = useFollows(pubKey)
22 |
23 | if (!pubKey && !follows) {
24 | throw new Error("FollowList needs follows or pubKey param")
25 | }
26 |
27 | const localFollows = follows || f
28 |
29 | const loadMoreFollows = () => {
30 | if (displayCount < localFollows.length) {
31 | setDisplayCount((prevCount) =>
32 | Math.min(prevCount + initialDisplayCount * 2, localFollows.length)
33 | ) // Load 10 more items at a time
34 | }
35 | }
36 |
37 | return (
38 | <>
39 |
40 |
41 | {localFollows.slice(0, displayCount).map((pubkey) => (
42 |
43 | ))}
44 |
45 |
46 | >
47 | )
48 | }
49 |
50 | export default FollowList
51 |
--------------------------------------------------------------------------------
/src/pages/user/components/MediaTab.tsx:
--------------------------------------------------------------------------------
1 | function MediaTab() {
2 | return
3 | }
4 |
5 | export default MediaTab
6 |
--------------------------------------------------------------------------------
/src/pages/user/components/ProfileAvatar.tsx:
--------------------------------------------------------------------------------
1 | import {NDKUserProfile} from "@nostr-dev-kit/ndk"
2 | import {useNavigate} from "react-router"
3 |
4 | interface ProfileAvatarProps {
5 | profile: NDKUserProfile | null | undefined
6 | pubkey: string
7 | }
8 |
9 | function ProfileAvatar({profile, pubkey}: ProfileAvatarProps) {
10 | const navigate = useNavigate()
11 |
12 | const handleUserNameClick = () => {
13 | navigate(`/${pubkey}`)
14 | }
15 |
16 | // ndk's fetchProfile returns a profile with .image
17 | // but kind 0 events have profiles with .picture
18 | let image = profile?.image
19 | if (!image && typeof profile?.picture === "string") image = profile?.picture
20 |
21 | return (
22 |
23 | {/* Replace MUI Avatar with a custom Avatar component */}
24 |
25 | )
26 | }
27 |
28 | export default ProfileAvatar
29 |
--------------------------------------------------------------------------------
/src/pages/user/components/ProfileName.tsx:
--------------------------------------------------------------------------------
1 | import {RiVerifiedBadgeLine, RiErrorWarningLine} from "@remixicon/react"
2 | import {useNip05Validation} from "@/shared/hooks/useNip05Validation"
3 | import {NDKUserProfile} from "@nostr-dev-kit/ndk"
4 | import {useNavigate} from "react-router"
5 | import {useCallback} from "react"
6 |
7 | interface ProfileNameProps {
8 | profile?: NDKUserProfile
9 | pubkey: string
10 | }
11 |
12 | function ProfileName({profile, pubkey}: ProfileNameProps) {
13 | const navigate = useNavigate()
14 | const nip05valid = useNip05Validation(pubkey, profile?.nip05)
15 |
16 | const handleClick = useCallback(() => navigate(`/${pubkey}`), [pubkey])
17 |
18 | return (
19 |
20 |
21 | {profile?.name && {profile.name}}
22 | {profile?.name && profile?.displayName && (
23 | {profile?.displayName}
24 | )}
25 | {!profile?.name && profile?.displayName && {profile?.displayName}}
26 |
27 | {!profile?.name && !profile?.displayName && Anonymous Nostrich}
28 | {profile?.nip05 && (
29 |
30 | {nip05valid ? (
31 |
32 | ) : (
33 |
34 | )}
35 | {profile?.nip05}
36 |
37 | )}
38 |
39 | )
40 | }
41 |
42 | export default ProfileName
43 |
--------------------------------------------------------------------------------
/src/pages/wallet/WalletPage.tsx:
--------------------------------------------------------------------------------
1 | import RightColumn from "@/shared/components/RightColumn.tsx"
2 | import Trending from "@/shared/components/feed/Trending.tsx"
3 | import Widget from "@/shared/components/ui/Widget"
4 | import {useUserStore} from "@/stores/user"
5 | import {useNavigate} from "react-router"
6 | import {useEffect} from "react"
7 |
8 | export default function WalletPage() {
9 | const navigate = useNavigate()
10 | const myPubKey = useUserStore((state) => state.publicKey)
11 | const cashuEnabled = useUserStore((state) => state.cashuEnabled)
12 |
13 | useEffect(() => {
14 | if (!cashuEnabled) {
15 | navigate("/settings/wallet", {replace: true})
16 | }
17 | }, [navigate, cashuEnabled])
18 |
19 | if (!cashuEnabled) {
20 | return null
21 | }
22 |
23 | return (
24 |
25 |
26 | {myPubKey && (
27 |
28 |
39 |
40 | )}
41 |
42 |
43 | {() => (
44 | <>
45 |
46 |
47 |
48 | >
49 | )}
50 |
51 |
52 | )
53 | }
54 |
--------------------------------------------------------------------------------
/src/shared/components/Icons/Icon.tsx:
--------------------------------------------------------------------------------
1 | import {MouseEventHandler} from "react"
2 |
3 | import IconsSvg from "./icons.svg"
4 |
5 | export interface IconProps {
6 | name: string
7 | size?: number
8 | height?: number
9 | className?: string
10 | onClick?: MouseEventHandler
11 | }
12 |
13 | const Icon = (props: IconProps) => {
14 | const size = props.size || 20
15 | const href = `${IconsSvg}#` + props.name
16 |
17 | return (
18 |
26 | )
27 | }
28 |
29 | export default Icon
30 |
--------------------------------------------------------------------------------
/src/shared/components/LoadingFallback.tsx:
--------------------------------------------------------------------------------
1 | export const LoadingFallback = () => Loading...
2 |
--------------------------------------------------------------------------------
/src/shared/components/NotificationPrompt.tsx:
--------------------------------------------------------------------------------
1 | import {subscribeToDMNotifications, subscribeToNotifications} from "@/utils/notifications"
2 | import {useNotificationsStore} from "@/stores/notifications"
3 | import {useUserStore} from "@/stores/user"
4 | import {useEffect, useState} from "react"
5 |
6 | const NotificationPrompt = () => {
7 | const [showPrompt, setShowPrompt] = useState(false)
8 | const {notificationsDeclined, setNotificationsDeclined} = useNotificationsStore()
9 | const {publicKey: myPubKey} = useUserStore()
10 |
11 | useEffect(() => {
12 | setShowPrompt(
13 | !!myPubKey &&
14 | window.Notification?.permission === "default" &&
15 | !notificationsDeclined
16 | )
17 | }, [notificationsDeclined, myPubKey])
18 |
19 | const handleEnableNotifications = () => {
20 | window.Notification?.requestPermission().then((permission) => {
21 | if (permission === "granted" || permission === "denied") {
22 | setShowPrompt(false)
23 | }
24 | subscribeToNotifications()
25 | subscribeToDMNotifications()
26 | })
27 | }
28 |
29 | const handleDeclineNotifications = () => {
30 | setNotificationsDeclined(true)
31 | setShowPrompt(false)
32 | }
33 |
34 | if (!showPrompt) return null
35 |
36 | return (
37 |
38 |
Enable push notifications?
39 |
40 |
43 |
46 |
47 |
48 | )
49 | }
50 |
51 | export default NotificationPrompt
52 |
--------------------------------------------------------------------------------
/src/shared/components/RightColumn.tsx:
--------------------------------------------------------------------------------
1 | import SearchBox from "@/shared/components/ui/SearchBox.tsx"
2 | import React, {useState, useEffect} from "react"
3 | import ErrorBoundary from "./ui/ErrorBoundary"
4 |
5 | interface RightColumnProps {
6 | children: () => React.ReactNode
7 | }
8 |
9 | function useWindowWidth() {
10 | const [windowWidth, setWindowWidth] = useState(window.innerWidth)
11 |
12 | useEffect(() => {
13 | const handleResize = () => setWindowWidth(window.innerWidth)
14 | window.addEventListener("resize", handleResize)
15 | return () => window.removeEventListener("resize", handleResize)
16 | }, [])
17 |
18 | return windowWidth
19 | }
20 |
21 | function RightColumn({children}: RightColumnProps) {
22 | const windowWidth = useWindowWidth()
23 |
24 | const isTestEnvironment =
25 | typeof window !== "undefined" && window.location.href.includes("localhost:5173")
26 |
27 | if (windowWidth < 1024 && !isTestEnvironment) {
28 | return null
29 | }
30 |
31 | return (
32 |
33 |
34 |
35 | {children()}
36 |
37 |
38 | )
39 | }
40 |
41 | export default RightColumn
42 |
--------------------------------------------------------------------------------
/src/shared/components/embed/Audio.tsx:
--------------------------------------------------------------------------------
1 | import Embed from "./index.ts"
2 |
3 | const Audio: Embed = {
4 | regex: /(https?:\/\/\S+\.(?:mp3|wav|ogg|flac)(?:\?\S*)?)\b/gi,
5 | settingsKey: "enableAudio",
6 | component: ({match}) => {
7 | return
8 | },
9 | }
10 |
11 | export default Audio
12 |
--------------------------------------------------------------------------------
/src/shared/components/embed/Hashtag.tsx:
--------------------------------------------------------------------------------
1 | import {Link} from "react-router"
2 |
3 | import Embed from "./index.ts"
4 |
5 | const Hashtag: Embed = {
6 | regex: /(#\w+)/g,
7 | component: ({match}) => {
8 | return (
9 |
10 | {match}
11 |
12 | )
13 | },
14 | inline: true,
15 | }
16 |
17 | export default Hashtag
18 |
--------------------------------------------------------------------------------
/src/shared/components/embed/LightningUri.tsx:
--------------------------------------------------------------------------------
1 | import Embed from "./index.ts"
2 |
3 | const TorrentEmbed: Embed = {
4 | regex:
5 | /(lightning:[\w.-]+@[\w.-]+|lightning:\w+\?amount=\d+|(?:lightning:)?(?:lnurl|lnbc)[\da-z0-9]+)/gi,
6 | component: ({match, key}) => {
7 | if (!match.startsWith("lightning:")) {
8 | match = `lightning:${match}`
9 | }
10 | // todo: parse invoice and show amount
11 | return (
12 |
13 | ⚡ Pay with lightning
14 |
15 | )
16 | },
17 | inline: true,
18 | }
19 |
20 | export default TorrentEmbed
21 |
--------------------------------------------------------------------------------
/src/shared/components/embed/Url.tsx:
--------------------------------------------------------------------------------
1 | import {Link} from "react-router"
2 | import Embed from "./index.ts"
3 |
4 | const Url: Embed = {
5 | regex: /(https?:\/\/[^\s,\\.]+(?:\.[^\s,.]+)*)/g,
6 | component: ({match}) => {
7 | const url = new URL(match)
8 | const displayText = match.replace(/^https?:\/\//, "").replace(/\/$/, "")
9 |
10 | if (url.hostname === "iris.to") {
11 | return (
12 |
13 | {displayText}
14 |
15 | )
16 | }
17 |
18 | return (
19 |
20 | {displayText}
21 |
22 | )
23 | },
24 | inline: true,
25 | }
26 |
27 | export default Url
28 |
--------------------------------------------------------------------------------
/src/shared/components/embed/apple/AppleMusic.tsx:
--------------------------------------------------------------------------------
1 | import AppleMusicComponent from "./AppleMusicComponent.tsx"
2 | import Embed from "../index.ts"
3 |
4 | const AppleMusic: Embed = {
5 | regex: /(?:https?:\/\/)(?:.*?)(music\.apple\.com\/.*)/gi,
6 | settingsKey: "enableAppleMusic",
7 | component: ({match}) => ,
8 | }
9 |
10 | export default AppleMusic
11 |
--------------------------------------------------------------------------------
/src/shared/components/embed/apple/AppleMusicComponent.tsx:
--------------------------------------------------------------------------------
1 | interface AppleMusicComponentProps {
2 | match: string
3 | }
4 |
5 | function AppleMusicComponent({match}: AppleMusicComponentProps) {
6 | return (
7 |
18 | )
19 | }
20 |
21 | export default AppleMusicComponent
22 |
--------------------------------------------------------------------------------
/src/shared/components/embed/apple/ApplePodcast.tsx:
--------------------------------------------------------------------------------
1 | import ApplePodcastComponent from "./ApplePodcastComponent.tsx"
2 | import Embed from "../index.ts"
3 |
4 | const ApplePodcast: Embed = {
5 | regex: /(?:https?:\/\/)(?:.*?)(music\.apple\.com\/.*)/gi,
6 | settingsKey: "enableAppleMusic",
7 | component: ({match}) => ,
8 | }
9 |
10 | export default ApplePodcast
11 |
--------------------------------------------------------------------------------
/src/shared/components/embed/apple/ApplePodcastComponent.tsx:
--------------------------------------------------------------------------------
1 | interface ApplePodcastComponentProps {
2 | match: string
3 | }
4 |
5 | function ApplePodcastComponent({match}: ApplePodcastComponentProps) {
6 | const cssClass = match.includes("?i=") ? "applepodcast-small" : "applepodcast-large"
7 | return (
8 |
20 | )
21 | }
22 |
23 | export default ApplePodcastComponent
24 |
--------------------------------------------------------------------------------
/src/shared/components/embed/instagram/Instagram.tsx:
--------------------------------------------------------------------------------
1 | import InstagramComponent from "./InstagramComponent.tsx"
2 | import Embed from "../index.ts"
3 |
4 | const Instagram: Embed = {
5 | regex: /(?:https?:\/\/)?(?:www\.)?(?:instagram\.com\/)((?:p|reel)\/[\w-]{11})(?:\S+)?/g,
6 | settingsKey: "enableInstagram",
7 | component: ({match}) => ,
8 | }
9 |
10 | export default Instagram
11 |
--------------------------------------------------------------------------------
/src/shared/components/embed/instagram/InstagramComponent.tsx:
--------------------------------------------------------------------------------
1 | interface InstagramComponentProps {
2 | match: string
3 | }
4 |
5 | function InstagramComponent({match}: InstagramComponentProps) {
6 | return (
7 |
17 | )
18 | }
19 |
20 | export default InstagramComponent
21 |
--------------------------------------------------------------------------------
/src/shared/components/embed/media/MediaEmbed.tsx:
--------------------------------------------------------------------------------
1 | import Embed, {EmbedProps} from "../index.ts"
2 | import Carousel from "./Carousel.tsx"
3 |
4 | export const IMAGE_REGEX =
5 | /(https?:\/\/[^\s]+?\.(?:jpg|jpeg|png|gif|webp)(?:\?[^\s#]*)?(?:#[^\s]*)?(?:\s+https?:\/\/[^\s]+?\.(?:jpg|jpeg|png|gif|webp)(?:\?[^\s#]*)?(?:#[^\s]*)?)*)/gi
6 | export const VIDEO_REGEX =
7 | /(https?:\/\/[^\s]+?\.(?:mp4|webm|ogg|mov|m3u8)(?:\?[^\s#]*)?(?:#[^\s]*)?(?:\s+https?:\/\/[^\s]+?\.(?:mp4|webm|ogg|mov|m3u8)(?:\?[^\s#]*)?(?:#[^\s]*)?)*)/gi
8 |
9 | // Define the MediaItem type to match the one in Carousel
10 | interface MediaItem {
11 | url: string
12 | type: "image" | "video"
13 | imeta?: string[]
14 | }
15 |
16 | const MediaEmbed: Embed = {
17 | settingsKey: "mediaEmbed",
18 | regex:
19 | /(https?:\/\/[^\s]+?\.(?:jpg|jpeg|png|gif|webp|mp4|webm|ogg|mov|m3u8)(?:\?[^\s#]*)?(?:#[^\s]*)?(?:\s+https?:\/\/[^\s]+?\.(?:jpg|jpeg|png|gif|webp|mp4|webm|ogg|mov|m3u8)(?:\?[^\s#]*)?(?:#[^\s]*)?)*)/gi,
20 | component: ({match, event}: EmbedProps) => {
21 | const urls = match.trim().split(/\s+/)
22 |
23 | // Extract imeta tags for each URL
24 | const mediaItems: MediaItem[] = urls.map((url) => {
25 | // Find imeta tag for this URL
26 | const imetaTag = event?.tags.find(
27 | (tag) => tag[0] === "imeta" && tag[1] && tag[1].includes(url)
28 | )
29 |
30 | return {
31 | url,
32 | type: url.match(/\.(mp4|webm|ogg|mov|m3u8)(?:\?|$)/) ? "video" : "image",
33 | imeta: imetaTag ? imetaTag : undefined,
34 | }
35 | })
36 |
37 | return
38 | },
39 | }
40 |
41 | export default MediaEmbed
42 |
--------------------------------------------------------------------------------
/src/shared/components/embed/media/SmallImage.tsx:
--------------------------------------------------------------------------------
1 | import SmallImageComponent from "./SmallImageComponent"
2 | import Embed from "../index"
3 |
4 | const SmallImage: Embed = {
5 | regex:
6 | /(https?:\/\/[^\s]+?\.(?:jpg|jpeg|png|gif|webp)(?:\?[^\s#]*)?(?:#[^\s]*)?(?:\s+https?:\/\/[^\s]+?\.(?:jpg|jpeg|png|gif|webp)(?:\?[^\s#]*)?(?:#[^\s]*)?)*)/gi,
7 | settingsKey: "enableSmallImage",
8 | component: ({match, event}) => ,
9 | }
10 |
11 | export default SmallImage
12 |
--------------------------------------------------------------------------------
/src/shared/components/embed/media/SmallThumbnail.tsx:
--------------------------------------------------------------------------------
1 | import SmallThumbnailComponent from "./SmallThumbnailComponent.tsx"
2 | import Embed from "../index.ts"
3 |
4 | const SmallThumbnail: Embed = {
5 | regex: /(https?:\/\/\S+?\.(?:mp4|webm|ogg|mov|m3u8)(?:\?\S*)?)/gi,
6 | settingsKey: "enableSmallThumbnail",
7 | component: ({match, event}) => ,
8 | }
9 |
10 | export default SmallThumbnail
11 |
--------------------------------------------------------------------------------
/src/shared/components/embed/media/SmallThumbnailComponent.tsx:
--------------------------------------------------------------------------------
1 | import {useSettingsStore} from "@/stores/settings"
2 | import {RiVideoLine} from "@remixicon/react"
3 | import {NDKEvent} from "@nostr-dev-kit/ndk"
4 | import {useState, MouseEvent} from "react"
5 | import ProxyImg from "../../ProxyImg"
6 | import classNames from "classnames"
7 |
8 | interface SmallThumbnailComponentProps {
9 | match: string
10 | event: NDKEvent | undefined
11 | }
12 |
13 | function SmallThumbnailComponent({match, event}: SmallThumbnailComponentProps) {
14 | const {content} = useSettingsStore()
15 | const [isBlurred, setIsBlurred] = useState(
16 | content.blurNSFW &&
17 | (!!event?.content.toLowerCase().includes("#nsfw") ||
18 | event?.tags.some((t) => t[0] === "content-warning"))
19 | )
20 | const [error, setError] = useState(false)
21 |
22 | const onClick = (e: MouseEvent) => {
23 | if (isBlurred) {
24 | setIsBlurred(false)
25 | e.stopPropagation()
26 | }
27 | }
28 |
29 | return (
30 |
31 | {error ? (
32 |
33 | ) : (
34 |
setError(true)}
38 | className={classNames("rounded object-cover w-24 h-24", {"blur-xl": isBlurred})}
39 | src={match}
40 | width={90}
41 | alt="thumbnail"
42 | />
43 | )}
44 |
45 | )
46 | }
47 |
48 | export default SmallThumbnailComponent
49 |
--------------------------------------------------------------------------------
/src/shared/components/embed/nostr/CustomEmoji.tsx:
--------------------------------------------------------------------------------
1 | import {CustomEmojiComponent} from "./CustomEmojiComponent"
2 |
3 | // Export the Embed configuration separately
4 | const CustomEmoji = {
5 | regex: /:([a-zA-Z0-9_-]+):/g,
6 | component: CustomEmojiComponent,
7 | inline: true,
8 | settingsKey: "customEmoji",
9 | }
10 |
11 | export default CustomEmoji
12 |
--------------------------------------------------------------------------------
/src/shared/components/embed/nostr/CustomEmojiComponent.tsx:
--------------------------------------------------------------------------------
1 | import {NDKEvent} from "@nostr-dev-kit/ndk"
2 | import ProxyImg from "../../ProxyImg"
3 | import {useState} from "react"
4 |
5 | interface CustomEmojiProps {
6 | match: string
7 | event?: NDKEvent
8 | }
9 |
10 | export const CustomEmojiComponent = ({match, event}: CustomEmojiProps) => {
11 | const [imgFailed, setImgFailed] = useState(false)
12 | if (!event || imgFailed) return <>{`:${match}:`}>
13 |
14 | // The match is already the shortcode from the capture group
15 | const shortcode = match
16 |
17 | // Limit shortcode length to prevent abuse
18 | if (shortcode.length > 50) {
19 | return <>{`:${shortcode}:`}>
20 | }
21 |
22 | // Find matching emoji tag
23 | const emojiTag = event.tags.find((tag) => {
24 | return tag[0] === "emoji" && tag[1] === shortcode
25 | })
26 |
27 | if (!emojiTag || !emojiTag[2]) {
28 | return <>{`:${shortcode}:`}>
29 | }
30 |
31 | return (
32 | setImgFailed(true)}
38 | />
39 | )
40 | }
41 |
--------------------------------------------------------------------------------
/src/shared/components/embed/nostr/InlineMention.tsx:
--------------------------------------------------------------------------------
1 | // mentions like #[3], can refer to event or user
2 |
3 | import {Link} from "react-router"
4 | import {nip19} from "nostr-tools"
5 |
6 | import {Name} from "@/shared/components/user/Name.tsx"
7 |
8 | import FeedItem from "@/shared/components/event/FeedItem/FeedItem.tsx"
9 | import Embed from "../index.ts"
10 |
11 | const fail = (s: string) => `#[${s}]`
12 |
13 | const InlineMention: Embed = {
14 | regex: /#\[([0-9]+)]/g,
15 | component: ({match, index, event, key}) => {
16 | if (!event?.tags) {
17 | console.warn("no tags", event)
18 | return <>{fail(match)}>
19 | }
20 | const tag = event.tags[parseInt(match)]
21 | if (!tag) {
22 | console.warn("no matching tag", index, event)
23 | return <>{fail(match)}>
24 | }
25 | const [type, id] = tag
26 | if (type === "p") {
27 | return (
28 |
29 |
30 |
31 | )
32 | } else if (type === "e") {
33 | return (
34 |
35 |
42 |
43 | )
44 | } else {
45 | console.warn("unknown tag type in InlineMention", type, index, event)
46 | return <>{fail(match)}>
47 | }
48 | },
49 | inline: true,
50 | }
51 |
52 | export default InlineMention
53 |
--------------------------------------------------------------------------------
/src/shared/components/embed/nostr/NostrNote.tsx:
--------------------------------------------------------------------------------
1 | import FeedItem from "@/shared/components/event/FeedItem/FeedItem.tsx"
2 | import Embed, {allEmbeds} from "../index.ts"
3 | import {nip19} from "nostr-tools"
4 |
5 | export const eventRegex =
6 | /(?:^|nostr:|(?:https?:\/\/[\w./]+)|iris\.to\/|snort\.social\/e\/|damus\.io\/)((?:@)?note[a-zA-Z0-9]{59,60})(?![\w/])/gi
7 |
8 | const NostrNote: Embed = {
9 | regex: eventRegex,
10 | component: ({match}) => {
11 | try {
12 | const hex = nip19.decode(match.replace("@", ""))
13 | if (!hex) throw new Error(`Invalid hex: ${match}`)
14 | return (
15 |
16 |
23 |
24 | )
25 | } catch (error) {
26 | return match
27 | }
28 | },
29 | }
30 |
31 | // need to add this to allEmbeds here to prevent runtime circular dependency
32 | allEmbeds.unshift(NostrNote)
33 |
34 | export default NostrNote
35 |
--------------------------------------------------------------------------------
/src/shared/components/embed/nostr/NostrNpub.tsx:
--------------------------------------------------------------------------------
1 | import {Name} from "@/shared/components/user/Name.tsx"
2 | import {Link} from "react-router"
3 | import Embed from "../index.ts"
4 |
5 | const pubKeyRegex =
6 | /(?:nostr:|(?:https?:\/\/[\w./]+)|iris\.to\/|snort\.social\/p\/|damus\.io\/)+((?:@)?npub[a-zA-Z0-9]{59,60})(?![\w/])/gi
7 |
8 | const NostrNpub: Embed = {
9 | regex: pubKeyRegex,
10 | component: ({match}) => {
11 | const pub = match.replace("@", "")
12 | return (
13 |
14 |
15 |
16 | )
17 | },
18 | inline: true,
19 | }
20 |
21 | export default NostrNpub
22 |
--------------------------------------------------------------------------------
/src/shared/components/embed/soundcloud/SoundCloud.tsx:
--------------------------------------------------------------------------------
1 | import SoundCloudComponent from "./SoundCloudComponent.tsx"
2 | import Embed from "../index.ts"
3 |
4 | const SoundCloud: Embed = {
5 | regex:
6 | /(?:https?:\/\/)?(?:www\.)?(soundcloud\.com\/(?!live)[a-zA-Z0-9-_]+\/[a-zA-Z0-9-_]+)(?:\?.*)?/g,
7 | settingsKey: "enableSoundCloud",
8 | component: ({match}) => ,
9 | }
10 |
11 | export default SoundCloud
12 |
--------------------------------------------------------------------------------
/src/shared/components/embed/soundcloud/SoundCloudComponent.tsx:
--------------------------------------------------------------------------------
1 | interface SoundCloudComponentProps {
2 | match: string
3 | }
4 |
5 | function SoundCloudComponent({match}: SoundCloudComponentProps) {
6 | return (
7 |
16 | )
17 | }
18 |
19 | export default SoundCloudComponent
20 |
--------------------------------------------------------------------------------
/src/shared/components/embed/spotify/SpotifyAlbum.tsx:
--------------------------------------------------------------------------------
1 | import SpotifyAlbumComponent from "./SpotifyAlbumComponent.tsx"
2 | import Embed from "../index.ts"
3 |
4 | const SpotifyAlbum: Embed = {
5 | regex: /(?:https?:\/\/)?(?:www\.)?(?:open\.spotify\.com\/album\/)([\w-]+)(?:\S+)?/g,
6 | settingsKey: "enableSpotify",
7 | component: ({match}) => ,
8 | }
9 |
10 | export default SpotifyAlbum
11 |
--------------------------------------------------------------------------------
/src/shared/components/embed/spotify/SpotifyAlbumComponent.tsx:
--------------------------------------------------------------------------------
1 | interface SpotifyAlbumComponentProps {
2 | match: string
3 | }
4 |
5 | function SpotifyAlbumComponent({match}: SpotifyAlbumComponentProps) {
6 | return (
7 |
17 | )
18 | }
19 |
20 | export default SpotifyAlbumComponent
21 |
--------------------------------------------------------------------------------
/src/shared/components/embed/spotify/SpotifyPlaylist.tsx:
--------------------------------------------------------------------------------
1 | import SpotifyPlaylistComponent from "./SpotifyPlaylistComponent.tsx"
2 | import Embed from "../index.ts"
3 |
4 | const SpotifyPlaylist: Embed = {
5 | regex: /(?:https?:\/\/)(?:.*?)(music\.apple\.com\/.*)/gi,
6 | settingsKey: "enableSpotify",
7 | component: ({match}) => ,
8 | }
9 |
10 | export default SpotifyPlaylist
11 |
--------------------------------------------------------------------------------
/src/shared/components/embed/spotify/SpotifyPlaylistComponent.tsx:
--------------------------------------------------------------------------------
1 | interface SpotifyPlaylistComponentProps {
2 | match: string
3 | }
4 |
5 | function SpotifyPlaylistComponent({match}: SpotifyPlaylistComponentProps) {
6 | return (
7 |
18 | )
19 | }
20 |
21 | export default SpotifyPlaylistComponent
22 |
--------------------------------------------------------------------------------
/src/shared/components/embed/spotify/SpotifyPodcast.tsx:
--------------------------------------------------------------------------------
1 | import SpotifyPodcastComponent from "./SpotifyPodcastComponent.tsx"
2 | import Embed from "../index.ts"
3 |
4 | const SpotifyPodcast: Embed = {
5 | regex:
6 | /(?:https?:\/\/)?(?:www\.)?(?:open\.spotify\.com\/episode\/)([\w-]+)(?:\S+)?(?:t=(\d+))?/g,
7 | settingsKey: "enableSpotify",
8 | component: ({match}) => ,
9 | }
10 |
11 | export default SpotifyPodcast
12 |
--------------------------------------------------------------------------------
/src/shared/components/embed/spotify/SpotifyPodcastComponent.tsx:
--------------------------------------------------------------------------------
1 | interface SpotifyPodcastComponentProps {
2 | match: string
3 | }
4 |
5 | function SpotifyPodcastComponent({match}: SpotifyPodcastComponentProps) {
6 | return (
7 |
18 | )
19 | }
20 |
21 | export default SpotifyPodcastComponent
22 |
--------------------------------------------------------------------------------
/src/shared/components/embed/spotify/SpotifyTrack.tsx:
--------------------------------------------------------------------------------
1 | import SpotifyTrackComponent from "./SpotifyTrackComponent.tsx"
2 | import Embed from "../index.ts"
3 |
4 | const SpotifyTrack: Embed = {
5 | regex: /(?:https?:\/\/)?(?:www\.)?(?:open\.spotify\.com\/track\/)([\w-]+)(?:\S+)?/g,
6 | settingsKey: "enableSpotify",
7 | component: ({match}) => ,
8 | }
9 |
10 | export default SpotifyTrack
11 |
--------------------------------------------------------------------------------
/src/shared/components/embed/spotify/SpotifyTrackComponent.tsx:
--------------------------------------------------------------------------------
1 | interface SpotifyTrackComponentProps {
2 | match: string
3 | }
4 |
5 | function SpotifyTrackComponent({match}: SpotifyTrackComponentProps) {
6 | return (
7 |
17 | )
18 | }
19 |
20 | export default SpotifyTrackComponent
21 |
--------------------------------------------------------------------------------
/src/shared/components/embed/tidal/TidalPlaylist.tsx:
--------------------------------------------------------------------------------
1 | import TidalPlaylistComponent from "./TidalPlaylistComponent.tsx"
2 | import Embed from "../index.ts"
3 |
4 | const TidalPlaylist: Embed = {
5 | regex: /(?:https?:\/\/)?(?:www\.)?tidal\.com(?:\/browse)?\/playlist\/([\w\d-]+)/g,
6 | settingsKey: "enableTidal",
7 | component: ({match}) => ,
8 | }
9 |
10 | export default TidalPlaylist
11 |
--------------------------------------------------------------------------------
/src/shared/components/embed/tidal/TidalPlaylistComponent.tsx:
--------------------------------------------------------------------------------
1 | interface TidalPlaylistComponentProps {
2 | match: string
3 | }
4 |
5 | function TidalPlaylistComponent({match}: TidalPlaylistComponentProps) {
6 | return (
7 |
17 | )
18 | }
19 |
20 | export default TidalPlaylistComponent
21 |
--------------------------------------------------------------------------------
/src/shared/components/embed/tidal/TidalTrack.tsx:
--------------------------------------------------------------------------------
1 | import TidalTrackComponent from "./TidalTrackComponent.tsx"
2 | import Embed from "../index.ts"
3 |
4 | const TidalTrack: Embed = {
5 | regex: /(?:https?:\/\/)?(?:www\.)?(?:tidal\.com(?:\/browse)?\/track\/)([\d]+)?/g,
6 | settingsKey: "enableTidal",
7 | component: ({match}) => ,
8 | }
9 |
10 | export default TidalTrack
11 |
--------------------------------------------------------------------------------
/src/shared/components/embed/tidal/TidalTrackComponent.tsx:
--------------------------------------------------------------------------------
1 | interface TidalTrackComponentProps {
2 | match: string
3 | }
4 |
5 | function TidalTrackComponent({match}: TidalTrackComponentProps) {
6 | return (
7 |
17 | )
18 | }
19 |
20 | export default TidalTrackComponent
21 |
--------------------------------------------------------------------------------
/src/shared/components/embed/tiktok/TikTok.tsx:
--------------------------------------------------------------------------------
1 | import TikTokComponent from "./TikTokComponent.tsx"
2 | import Embed from "../index.ts"
3 |
4 | const TikTok: Embed = {
5 | regex: /(?:https?:\/\/)?(?:www\.)?tiktok\.com\/.*?video\/(\d{1,19})/g,
6 | settingsKey: "enableTiktok",
7 | component: ({match}) => ,
8 | }
9 |
10 | export default TikTok
11 |
--------------------------------------------------------------------------------
/src/shared/components/embed/tiktok/TikTokComponent.tsx:
--------------------------------------------------------------------------------
1 | interface TikTokComponentProps {
2 | match: string
3 | }
4 |
5 | function TikTokComponent({match}: TikTokComponentProps) {
6 | return (
7 |
17 | )
18 | }
19 |
20 | export default TikTokComponent
21 |
--------------------------------------------------------------------------------
/src/shared/components/embed/twitch/Twitch.tsx:
--------------------------------------------------------------------------------
1 | import TwitchComponent from "./TwitchChannelComponent.tsx"
2 | import Embed from "../index.ts"
3 |
4 | const Twitch: Embed = {
5 | regex: /(?:https?:\/\/)?(?:www\.)?(?:twitch\.tv\/videos\/)([\d]+)?/g,
6 | settingsKey: "enableTwitch",
7 | component: ({match}) => ,
8 | }
9 |
10 | export default Twitch
11 |
--------------------------------------------------------------------------------
/src/shared/components/embed/twitch/TwitchChannel.tsx:
--------------------------------------------------------------------------------
1 | import TwitchChannelComponent from "./TwitchChannelComponent.tsx"
2 | import Embed from "../index.ts"
3 |
4 | const Twitch: Embed = {
5 | regex: /(?:https?:\/\/)?(?:www\.)?(?:twitch\.tv\/)([\w-]+)?/g,
6 | settingsKey: "enableTwitch",
7 | component: ({match}) => ,
8 | }
9 |
10 | export default Twitch
11 |
--------------------------------------------------------------------------------
/src/shared/components/embed/twitch/TwitchChannelComponent.tsx:
--------------------------------------------------------------------------------
1 | interface TwitchChannelComponentProps {
2 | match: string
3 | }
4 |
5 | function TwitchComponent({match}: TwitchChannelComponentProps) {
6 | return (
7 |
18 | )
19 | }
20 |
21 | export default TwitchComponent
22 |
--------------------------------------------------------------------------------
/src/shared/components/embed/twitch/TwitchComponent.tsx:
--------------------------------------------------------------------------------
1 | interface TwitchComponentProps {
2 | match: string
3 | }
4 |
5 | function TwitchComponent({match}: TwitchComponentProps) {
6 | return (
7 |
18 | )
19 | }
20 |
21 | export default TwitchComponent
22 |
--------------------------------------------------------------------------------
/src/shared/components/embed/wavlake/WavLake.tsx:
--------------------------------------------------------------------------------
1 | import WavLakeComponent from "./WavLakeComponent.tsx"
2 | import Embed from "../index.ts"
3 |
4 | const WavLake: Embed = {
5 | regex:
6 | /https:\/\/(?:player\.)?wavlake\.com\/(?!feed\/|artists)(track\/[.a-zA-Z0-9-]+|album\/[.a-zA-Z0-9-]+|[.a-zA-Z0-9-]+)/i,
7 | settingsKey: "enableWavLake",
8 | component: ({match}) => ,
9 | }
10 |
11 | export default WavLake
12 |
--------------------------------------------------------------------------------
/src/shared/components/embed/wavlake/WavLakeComponent.tsx:
--------------------------------------------------------------------------------
1 | interface WavLakeComponentProps {
2 | match: string
3 | }
4 |
5 | function WavLakeComponent({match}: WavLakeComponentProps) {
6 | return (
7 |
15 | )
16 | }
17 |
18 | export default WavLakeComponent
19 |
--------------------------------------------------------------------------------
/src/shared/components/embed/youtube/YouTube.tsx:
--------------------------------------------------------------------------------
1 | import YoutubeComponent from "./YoutubeComponent.tsx"
2 | import Embed from "../index.ts"
3 |
4 | const YouTube: Embed = {
5 | regex:
6 | /(?:https?:\/\/)?(?:www\.|m\.)?(?:youtu\.be\/|youtube\.com\/(?:embed\/|v\/|watch\?v=|watch\?.+&v=|shorts\/|live\/))([\w-]{11})(?:\S+)?/g,
7 | settingsKey: "enableYoutube",
8 | component: ({match}) => ,
9 | }
10 |
11 | export default YouTube
12 |
--------------------------------------------------------------------------------
/src/shared/components/embed/youtube/YoutubeComponent.tsx:
--------------------------------------------------------------------------------
1 | interface YoutubeComponentProps {
2 | match: string
3 | }
4 |
5 | function YoutubeComponent({match}: YoutubeComponentProps) {
6 | return (
7 |
16 | )
17 | }
18 |
19 | export default YoutubeComponent
20 |
--------------------------------------------------------------------------------
/src/shared/components/event/FeedItem/FeedItemContent.tsx:
--------------------------------------------------------------------------------
1 | import MarketListing from "../../market/MarketListing"
2 | import ChannelCreation from "../ChannelCreation.tsx"
3 | import {NDKEvent} from "@nostr-dev-kit/ndk"
4 | import ZapReceipt from "../ZapReceipt.tsx"
5 | import Zapraiser from "../Zapraiser.tsx"
6 | import Highlight from "../Highlight.tsx"
7 | import TextNote from "../TextNote.tsx"
8 | import LongForm from "../LongForm.tsx"
9 | import {memo} from "react"
10 |
11 | type ContentProps = {
12 | event: NDKEvent | undefined
13 | referredEvent: NDKEvent | undefined
14 | standalone?: boolean
15 | truncate: number
16 | }
17 |
18 | const FeedItemContent = ({event, referredEvent, standalone, truncate}: ContentProps) => {
19 | if (!event) {
20 | return ""
21 | } else if (referredEvent) {
22 | return
23 | } else if (event.kind === 9735) {
24 | return
25 | } else if (event.kind === 1 && event.tagValue("zapraiser")) {
26 | return
27 | } else if (event.kind === 9802) {
28 | return
29 | } else if (event.kind === 30023) {
30 | return
31 | } else if (event.kind === 30402) {
32 | return (
33 | 0}`}
35 | event={event}
36 | truncate={truncate}
37 | />
38 | )
39 | } else if (event.kind === 40) {
40 | return
41 | } else {
42 | return
43 | }
44 | }
45 |
46 | export default memo(FeedItemContent)
47 |
--------------------------------------------------------------------------------
/src/shared/components/event/FeedItem/FeedItemPlaceholder.tsx:
--------------------------------------------------------------------------------
1 | import SimpleFeedItemDropdown from "@/shared/components/event/SimpleFeedItemDropdown.tsx"
2 | import {MouseEvent as ReactMouseEvent} from "react"
3 | import classNames from "classnames"
4 | import {nip19} from "nostr-tools"
5 |
6 | type FeedItemPlaceholderProps = {
7 | standalone?: boolean
8 | asEmbed: boolean
9 | eventIdHex: string
10 | onClick: (e: ReactMouseEvent) => void
11 | }
12 |
13 | const FeedItemPlaceholder = ({
14 | standalone,
15 | asEmbed,
16 | eventIdHex,
17 | onClick,
18 | }: FeedItemPlaceholderProps) => {
19 | return (
20 |
35 | )
36 | }
37 |
38 | export default FeedItemPlaceholder
39 |
--------------------------------------------------------------------------------
/src/shared/components/event/FeedItem/FeedItemTitle.tsx:
--------------------------------------------------------------------------------
1 | import useProfile from "@/shared/hooks/useProfile.ts"
2 | import {NDKEvent} from "@nostr-dev-kit/ndk"
3 | import {Helmet} from "react-helmet"
4 | import {useMemo} from "react"
5 |
6 | type FeedItemTitleProps = {
7 | event?: NDKEvent
8 | }
9 |
10 | const FeedItemTitle = ({event}: FeedItemTitleProps) => {
11 | const authorProfile = useProfile(event?.pubkey)
12 |
13 | const authorTitle = useMemo(() => {
14 | const name =
15 | authorProfile?.name ||
16 | authorProfile?.display_name ||
17 | authorProfile?.username ||
18 | authorProfile?.nip05?.split("@")[0]
19 | return name ? `Post by ${name}` : "Post"
20 | }, [authorProfile])
21 |
22 | return (
23 |
24 | {authorTitle}
25 |
26 | )
27 | }
28 |
29 | export default FeedItemTitle
30 |
--------------------------------------------------------------------------------
/src/shared/components/event/FeedItem/utils.ts:
--------------------------------------------------------------------------------
1 | import {NDKEvent} from "@nostr-dev-kit/ndk"
2 | import {useNavigate} from "react-router"
3 | import {nip19} from "nostr-tools"
4 | import {MouseEvent} from "react"
5 |
6 | export const TRUNCATE_LENGTH = 300
7 |
8 | export const isTextSelected = () => {
9 | const selection = window.getSelection()
10 | return selection && selection.toString().length > 0
11 | }
12 |
13 | export function onClick(
14 | e: MouseEvent,
15 | event: NDKEvent | undefined,
16 | ReferredEvent: NDKEvent | undefined,
17 | eventId: string | undefined,
18 | navigate: ReturnType
19 | ) {
20 | if (
21 | event?.kind === 6927 ||
22 | event?.kind === 30078 ||
23 | e.target instanceof HTMLAnchorElement ||
24 | e.target instanceof HTMLImageElement ||
25 | e.target instanceof HTMLVideoElement ||
26 | (e.target instanceof HTMLElement && e.target.closest("a")) ||
27 | (e.target instanceof HTMLElement && e.target.closest("button")) ||
28 | isTextSelected()
29 | ) {
30 | return
31 | }
32 | navigate(`/${nip19.noteEncode(ReferredEvent?.id || eventId || event!.id)}`)
33 | e.stopPropagation()
34 | }
35 |
--------------------------------------------------------------------------------
/src/shared/components/event/Highlight.tsx:
--------------------------------------------------------------------------------
1 | import {RiExternalLinkLine} from "@remixicon/react"
2 | import {NDKEvent} from "@nostr-dev-kit/ndk"
3 | import {useEffect, useState} from "react"
4 | import HyperText from "../HyperText"
5 |
6 | interface HighlightProps {
7 | event: NDKEvent
8 | }
9 |
10 | function Highlight({event}: HighlightProps) {
11 | const [link, setLink] = useState("")
12 |
13 | useEffect(() => {
14 | const rTag = event.tagValue("r")
15 | // todo: clean URL from trackers
16 | if (rTag) setLink(rTag)
17 | }, [event])
18 |
19 | return (
20 |
21 | {event.content && (
22 |
23 | {event.content}
24 |
25 | )}
26 | {link && (
27 |
28 |
29 |
30 | {link}
31 |
32 |
33 | )}
34 |
35 | )
36 | }
37 |
38 | export default Highlight
39 |
--------------------------------------------------------------------------------
/src/shared/components/event/LikeHeader.tsx:
--------------------------------------------------------------------------------
1 | import {CustomEmojiComponent} from "../embed/nostr/CustomEmojiComponent"
2 | import {Name} from "@/shared/components/user/Name"
3 | import {NDKEvent} from "@nostr-dev-kit/ndk"
4 | import {Link} from "react-router"
5 | import {nip19} from "nostr-tools"
6 |
7 | interface LikeHeaderProps {
8 | event: NDKEvent
9 | }
10 |
11 | function LikeHeader({event}: LikeHeaderProps) {
12 | const reactionText =
13 | event.content === "+" ? (
14 | liked
15 | ) : (
16 | <>
17 | reacted with
18 | {event.content.startsWith(":") && event.content.endsWith(":") ? (
19 |
20 | ) : (
21 | {event.content}
22 | )}
23 | >
24 | )
25 |
26 | return (
27 |
31 |
32 | {reactionText}
33 |
34 | )
35 | }
36 |
37 | export default LikeHeader
38 |
--------------------------------------------------------------------------------
/src/shared/components/event/LongForm.tsx:
--------------------------------------------------------------------------------
1 | import {NDKEvent} from "@nostr-dev-kit/ndk"
2 | import {useEffect, useState} from "react"
3 | import Markdown from "markdown-to-jsx"
4 |
5 | interface LongFormProps {
6 | event: NDKEvent
7 | standalone: boolean | undefined
8 | }
9 |
10 | function LongForm({event, standalone}: LongFormProps) {
11 | const [title, setTitle] = useState("")
12 | const [topics, setTopics] = useState()
13 | const [textBody, setTextBody] = useState("")
14 | const [summary, setSummary] = useState("")
15 |
16 | useEffect(() => {
17 | const title = event.tagValue("title")
18 | if (title) setTitle(title)
19 |
20 | const hashtags = event.tagValue("t")
21 | if (hashtags) setTopics(hashtags)
22 |
23 | const textBody = event.content
24 | setTextBody(textBody)
25 |
26 | const summaryTag = event.tagValue("summary")
27 | if (summaryTag) setSummary(summaryTag)
28 | }, [event])
29 |
30 | return (
31 |
32 |
{title}
33 |
37 | {standalone ? textBody : summary || `${textBody.substring(0, 100)}...`}
38 |
39 | {topics && #{topics}}
40 |
41 | )
42 | }
43 |
44 | export default LongForm
45 |
--------------------------------------------------------------------------------
/src/shared/components/event/RawJSON.tsx:
--------------------------------------------------------------------------------
1 | import CopyButton from "@/shared/components/button/CopyButton.tsx"
2 | import {NDKEvent} from "@nostr-dev-kit/ndk"
3 |
4 | type RawJSONProps = {
5 | event: NDKEvent
6 | }
7 |
8 | function RawJSON({event}: RawJSONProps) {
9 | const rawEvent = {
10 | created_at: event.created_at,
11 | content: event.content,
12 | tags: event.tags,
13 | kind: event.kind,
14 | pubkey: event.pubkey,
15 | id: event.id,
16 | sig: event.sig,
17 | }
18 |
19 | return (
20 |
21 |
{JSON.stringify(rawEvent, null, 4)}
22 |
23 |
24 | )
25 | }
26 |
27 | export default RawJSON
28 |
--------------------------------------------------------------------------------
/src/shared/components/event/ReportUser.tsx:
--------------------------------------------------------------------------------
1 | import {Hexpubkey, NDKEvent} from "@nostr-dev-kit/ndk"
2 | import {useState} from "react"
3 |
4 | import ReportReasonForm from "./ReportReasonForm.tsx"
5 |
6 | interface MuteUserProps {
7 | user: Hexpubkey
8 | event?: NDKEvent
9 | }
10 |
11 | function ReportUser({user, event}: MuteUserProps) {
12 | const [reported, setReported] = useState(false)
13 | return (
14 |
15 |
16 |
Would you like to submit a report?
17 | {reported ? (
18 |
Thank you for your report!
19 | ) : (
20 |
21 | )}
22 |
23 |
24 | )
25 | }
26 |
27 | export default ReportUser
28 |
--------------------------------------------------------------------------------
/src/shared/components/event/RepostHeader.tsx:
--------------------------------------------------------------------------------
1 | import {Name} from "@/shared/components/user/Name"
2 | import {RiRepeatFill} from "@remixicon/react"
3 | import {NDKEvent} from "@nostr-dev-kit/ndk"
4 | import {Link} from "react-router"
5 | import {nip19} from "nostr-tools"
6 |
7 | interface RepostHeaderProps {
8 | event: NDKEvent
9 | }
10 |
11 | function RepostHeader({event}: RepostHeaderProps) {
12 | return (
13 |
17 |
18 | reposted
19 |
20 |
21 | )
22 | }
23 |
24 | export default RepostHeader
25 |
--------------------------------------------------------------------------------
/src/shared/components/event/SimpleFeedItemDropdown.tsx:
--------------------------------------------------------------------------------
1 | import {RiMoreLine} from "@remixicon/react"
2 |
3 | type FeedItemDropdownProps = {
4 | eventId: string
5 | }
6 |
7 | function SimpleFeedItemDropdown({eventId}: FeedItemDropdownProps) {
8 | const handleCopyNoteID = () => {
9 | navigator.clipboard.writeText(eventId)
10 | }
11 |
12 | return (
13 | e.stopPropagation()}>
14 |
15 |
16 |
17 |
18 |
22 | -
23 |
24 |
25 |
26 |
27 |
28 | )
29 | }
30 |
31 | export default SimpleFeedItemDropdown
32 |
--------------------------------------------------------------------------------
/src/shared/components/event/TextNote.tsx:
--------------------------------------------------------------------------------
1 | import {NDKEvent} from "@nostr-dev-kit/ndk"
2 |
3 | import HyperText from "@/shared/components/HyperText.tsx"
4 | import ErrorBoundary from "../ui/ErrorBoundary"
5 |
6 | type TextNoteProps = {
7 | event: NDKEvent
8 | truncate?: number
9 | }
10 |
11 | function TextNote({event, truncate}: TextNoteProps) {
12 | return (
13 |
14 |
15 | {event?.content || ""}
16 |
17 |
18 | )
19 | }
20 |
21 | export default TextNote
22 |
--------------------------------------------------------------------------------
/src/shared/components/event/ZapReceipt.tsx:
--------------------------------------------------------------------------------
1 | import {decode} from "light-bolt11-decoder"
2 | import {NDKEvent} from "@nostr-dev-kit/ndk"
3 | import {useEffect, useState} from "react"
4 | import {UserRow} from "../user/UserRow"
5 |
6 | interface ZapReceiptProps {
7 | event: NDKEvent
8 | }
9 |
10 | function ZapReceipt({event}: ZapReceiptProps) {
11 | const [zappedAmount, setZappedAmount] = useState()
12 |
13 | useEffect(() => {
14 | const invoice = event.tagValue("bolt11")
15 | if (invoice) {
16 | const decodedInvoice = decode(invoice)
17 | const amountSection = decodedInvoice.sections.find(
18 | (section) => section.name === "amount"
19 | )
20 | if (amountSection && "value" in amountSection) {
21 | setZappedAmount(Math.floor(parseInt(amountSection.value) / 1000))
22 | }
23 | }
24 | }, [])
25 |
26 | return (
27 |
28 |
29 |
Zapped {zappedAmount} sats to
30 |
31 |
32 |
{event.content}
33 |
34 | )
35 | }
36 |
37 | export default ZapReceipt
38 |
--------------------------------------------------------------------------------
/src/shared/components/event/Zapraiser.tsx:
--------------------------------------------------------------------------------
1 | import {fetchZappedAmount} from "@/utils/nostr"
2 | import {NDKEvent} from "@nostr-dev-kit/ndk"
3 | import {useEffect, useState} from "react"
4 | import HyperText from "../HyperText"
5 |
6 | interface ZapraiserProps {
7 | event: NDKEvent
8 | }
9 |
10 | function Zapraiser({event}: ZapraiserProps) {
11 | const [zapProgress, setZapProgress] = useState(0)
12 |
13 | useEffect(() => {
14 | fetchZappedAmount(event).then((amount: number) => {
15 | if (amount > 0) {
16 | try {
17 | const targetAmount = Number(event.tagValue("zapraiser"))
18 | const percent = Math.round((amount / targetAmount) * 100)
19 | if (percent > 100) {
20 | setZapProgress(100)
21 | } else {
22 | setZapProgress(percent)
23 | }
24 | } catch (error) {
25 | // ignore, event is probably malformed
26 | }
27 | }
28 | })
29 | }, [event])
30 |
31 | return (
32 |
33 |
34 | {event.tagValue("title")}
35 |
36 | in repository {event.tagValue("repo")}
37 |
38 |
39 |
{event.content}
40 |
41 |
Zap Goal {zapProgress} %
42 |
48 |
49 |
50 | )
51 | }
52 |
53 | export default Zapraiser
54 |
--------------------------------------------------------------------------------
/src/shared/components/event/reactions/FeedItemActions.tsx:
--------------------------------------------------------------------------------
1 | import {NDKEvent} from "@nostr-dev-kit/ndk"
2 |
3 | import FeedItemComment from "./FeedItemComment.tsx"
4 | import FeedItemRepost from "./FeedItemRepost.tsx"
5 | import FeedItemShare from "./FeedItemShare.tsx"
6 | import {FeedItemLike} from "./FeedItemLike.tsx"
7 | import FeedItemZap from "./FeedItemZap.tsx"
8 | import {RefObject} from "react"
9 |
10 | type FeedItemActionsProps = {
11 | event: NDKEvent
12 | feedItemRef: RefObject
13 | }
14 |
15 | function FeedItemActions({event, feedItemRef}: FeedItemActionsProps) {
16 | return (
17 | e.stopPropagation()}
19 | className={
20 | "py-2 flex flex-row gap-4 z-20 items-center max-w-full select-none text-base-content/50"
21 | }
22 | >
23 | {event.kind !== 30078 && }
24 | {event.kind !== 30078 && }
25 |
26 |
27 |
28 |
29 | )
30 | }
31 |
32 | export default FeedItemActions
33 |
--------------------------------------------------------------------------------
/src/shared/components/event/reactions/FeedItemShare.tsx:
--------------------------------------------------------------------------------
1 | import Icon from "@/shared/components/Icons/Icon"
2 | import {NDKEvent} from "@nostr-dev-kit/ndk"
3 | import {nip19} from "nostr-tools"
4 |
5 | const FeedItemShare = ({event}: {event: NDKEvent}) => {
6 | if (!navigator.share) {
7 | return null
8 | }
9 |
10 | const handleShare = async () => {
11 | if (navigator.share) {
12 | try {
13 | await navigator.share({
14 | url: `https://iris.to/${nip19.noteEncode(event.id)}`,
15 | })
16 | } catch (error) {
17 | console.error("Error sharing:", error)
18 | }
19 | } else {
20 | console.warn("Web Share API is not supported in this browser.")
21 | }
22 | }
23 |
24 | return (
25 |
28 | )
29 | }
30 |
31 | export default FeedItemShare
32 |
--------------------------------------------------------------------------------
/src/shared/components/event/reactions/ReactionContent.tsx:
--------------------------------------------------------------------------------
1 | import {CustomEmojiComponent} from "../../embed/nostr/CustomEmojiComponent"
2 | import {NDKEvent} from "@nostr-dev-kit/ndk"
3 |
4 | export function ReactionContent({content, event}: {content: string; event: NDKEvent}) {
5 | if (content === "+") return "❤️"
6 |
7 | // Check if the content contains emoji shortcodes
8 | const emojiRegex = /:([a-zA-Z0-9_-]+):/g
9 | if (emojiRegex.test(content)) {
10 | const shortcode = content.replace(/:/g, "")
11 | return
12 | }
13 |
14 | return content
15 | }
16 |
--------------------------------------------------------------------------------
/src/shared/components/event/reactions/Reactions.tsx:
--------------------------------------------------------------------------------
1 | import Reposts from "@/shared/components/event/reactions/Reposts.tsx"
2 | import Likes from "@/shared/components/event/reactions/Likes.tsx"
3 | import Zaps from "@/shared/components/event/reactions/Zaps.tsx"
4 | import {NDKEvent} from "@nostr-dev-kit/ndk"
5 | import {useState} from "react"
6 |
7 | export default function Reactions({event}: {event: NDKEvent}) {
8 | const [activeTab, setActiveTab] = useState("likes")
9 |
10 | return (
11 |
12 |
13 |
19 |
25 |
31 |
32 |
33 | {activeTab === "likes" && }
34 | {activeTab === "reposts" && }
35 | {activeTab === "zaps" && }
36 |
37 |
38 | )
39 | }
40 |
--------------------------------------------------------------------------------
/src/shared/components/event/utils.ts:
--------------------------------------------------------------------------------
1 | import {getTag, NDKEventFromRawEvent, fetchEvent} from "@/utils/nostr.ts"
2 | import {NDKEvent} from "@nostr-dev-kit/ndk"
3 | import {nip19} from "nostr-tools"
4 |
5 | export const handleEventContent = async (
6 | event: NDKEvent,
7 | setReferredEvent: (event: NDKEvent) => void
8 | ) => {
9 | try {
10 | if (event.kind === 6 || event.kind === 7) {
11 | let originalEvent
12 | try {
13 | originalEvent = event.content ? JSON.parse(event.content) : undefined
14 | } catch (error) {
15 | // ignore
16 | }
17 | if (originalEvent && originalEvent?.id) {
18 | const ndkEvent = NDKEventFromRawEvent(originalEvent)
19 | setReferredEvent(ndkEvent)
20 | } else {
21 | const eTag = getTag("e", event.tags)
22 | if (eTag) {
23 | const origEvent = await fetchEvent({ids: [eTag]})
24 | if (origEvent) setReferredEvent(origEvent)
25 | }
26 | }
27 | }
28 | } catch (error) {
29 | console.warn(error)
30 | }
31 | }
32 | export const getEventIdHex = (event?: NDKEvent, eventId?: string) => {
33 | if (event?.id) {
34 | return event.id
35 | }
36 | if (eventId!.indexOf("n") === 0) {
37 | const data = nip19.decode(eventId!).data
38 | if (typeof data === "string") {
39 | return data
40 | }
41 | return (data as nip19.EventPointer).id || ""
42 | }
43 | if (!eventId) {
44 | throw new Error("FeedItem requires either an event or an eventId")
45 | }
46 | return eventId
47 | }
48 |
--------------------------------------------------------------------------------
/src/shared/components/feed/DisplayAsSelector.tsx:
--------------------------------------------------------------------------------
1 | import Icon from "../Icons/Icon"
2 |
3 | export type DisplayAs = "list" | "grid"
4 |
5 | type DisplaySelectorProps = {
6 | activeSelection: DisplayAs
7 | onSelect: (display: DisplayAs) => void
8 | show?: boolean
9 | }
10 |
11 | export const DisplayAsSelector = ({
12 | activeSelection,
13 | onSelect,
14 | show = true,
15 | }: DisplaySelectorProps) => {
16 | const getClasses = (displayType: DisplayAs) => {
17 | const baseClasses = "border-highlight cursor-pointer flex justify-center flex-1 p-3"
18 | return activeSelection === displayType
19 | ? `${baseClasses} border-b border-1`
20 | : `${baseClasses} text-base-content/70 hover:text-base-content border-b border-1 border-transparent`
21 | }
22 |
23 | if (!show) return null
24 |
25 | return (
26 |
27 |
onSelect("list")}>
28 |
29 |
30 |
31 |
32 |
onSelect("grid")}>
33 |
34 |
35 |
36 | )
37 | }
38 |
--------------------------------------------------------------------------------
/src/shared/components/feed/NewEventsButton.tsx:
--------------------------------------------------------------------------------
1 | import {AvatarGroup} from "@/shared/components/user/AvatarGroup.tsx"
2 | import {NDKEvent} from "@nostr-dev-kit/ndk"
3 | import {RefObject} from "react"
4 |
5 | interface NewEventsButtonProps {
6 | newEventsFiltered: NDKEvent[]
7 | newEventsFrom: Set
8 | showNewEvents: () => void
9 | firstFeedItemRef: RefObject
10 | }
11 |
12 | const NewEventsButton = ({
13 | newEventsFiltered,
14 | newEventsFrom,
15 | showNewEvents,
16 | firstFeedItemRef,
17 | }: NewEventsButtonProps) => {
18 | if (newEventsFiltered.length === 0) return null
19 |
20 | return (
21 |
22 |
33 |
34 | )
35 | }
36 |
37 | export default NewEventsButton
38 |
--------------------------------------------------------------------------------
/src/shared/components/feed/UnknownUserEvents.tsx:
--------------------------------------------------------------------------------
1 | import InfiniteScroll from "@/shared/components/ui/InfiniteScroll"
2 | import {useState, useRef, useCallback, useMemo} from "react"
3 | import FeedItem from "../event/FeedItem/FeedItem"
4 | import {NDKEvent} from "@nostr-dev-kit/ndk"
5 | import {DISPLAY_INCREMENT} from "./utils"
6 |
7 | interface UnknownUserEventsProps {
8 | eventsByUnknownUsers: NDKEvent[]
9 | showRepliedTo: boolean
10 | asReply: boolean
11 | }
12 |
13 | const UnknownUserEvents = ({
14 | eventsByUnknownUsers,
15 | showRepliedTo,
16 | asReply,
17 | }: UnknownUserEventsProps) => {
18 | const [displayCount, setDisplayCount] = useState(DISPLAY_INCREMENT)
19 | const firstFeedItemRef = useRef(null)
20 |
21 | const loadMoreItems = useCallback(() => {
22 | if (eventsByUnknownUsers.length > displayCount) {
23 | setDisplayCount(displayCount + DISPLAY_INCREMENT)
24 | }
25 | }, [displayCount, eventsByUnknownUsers.length])
26 |
27 | const displayedEvents = useMemo(() => {
28 | return eventsByUnknownUsers.slice(0, displayCount)
29 | }, [eventsByUnknownUsers, displayCount])
30 |
31 | return (
32 |
33 | {displayedEvents.map((event, index) => (
34 |
35 |
41 |
42 | ))}
43 |
44 | )
45 | }
46 |
47 | export default UnknownUserEvents
48 |
--------------------------------------------------------------------------------
/src/shared/components/feed/utils.ts:
--------------------------------------------------------------------------------
1 | import {NDKEvent} from "@nostr-dev-kit/ndk"
2 |
3 | export const eventComparator = ([, a]: [string, NDKEvent], [, b]: [string, NDKEvent]) => {
4 | if (b.created_at && a.created_at) return b.created_at - a.created_at
5 | return 0
6 | }
7 |
8 | export const INITIAL_DISPLAY_COUNT = 10
9 | export const DISPLAY_INCREMENT = 10
10 |
--------------------------------------------------------------------------------
/src/shared/components/header/NotificationButton.tsx:
--------------------------------------------------------------------------------
1 | import UnseenNotificationsBadge from "./UnseenNotificationsBadge"
2 | import {usePublicKey} from "@/stores/user"
3 | import {NavLink} from "react-router"
4 | import Icon from "../Icons/Icon"
5 |
6 | export default function NotificationButton() {
7 | const myPubKey = usePublicKey()
8 |
9 | return (
10 | <>
11 | {myPubKey && (
12 |
15 | `btn btn-ghost btn-circle -ml-2 ${isActive ? "active" : ""}`
16 | }
17 | >
18 | {({isActive}) => (
19 |
20 |
21 |
22 |
23 | )}
24 |
25 | )}
26 | >
27 | )
28 | }
29 |
--------------------------------------------------------------------------------
/src/shared/components/header/UnseenNotificationsBadge.tsx:
--------------------------------------------------------------------------------
1 | import {useNotificationsStore} from "@/stores/notifications"
2 |
3 | export default function UnseenNotificationsBadge() {
4 | const {latestNotification} = useNotificationsStore()
5 | const notificationsSeenAt = useNotificationsStore(
6 | (state) => state.notificationsSeenAt || 0
7 | )
8 |
9 | return (
10 | <>
11 | {notificationsSeenAt < latestNotification && (
12 |
13 | )}
14 | >
15 | )
16 | }
17 |
--------------------------------------------------------------------------------
/src/shared/components/header/WalletButton.tsx:
--------------------------------------------------------------------------------
1 | import Wallet from "@/pages/home/feed/components/Wallet.tsx"
2 | import Icon from "@/shared/components/Icons/Icon"
3 | import {usePublicKey} from "@/stores/user"
4 | import Modal from "../ui/Modal"
5 | import {useState} from "react"
6 |
7 | export default function WalletButton() {
8 | const [showWallet, setShowWallet] = useState(false)
9 | const pubKey = usePublicKey()
10 |
11 | if (!pubKey) {
12 | return null
13 | }
14 |
15 | return (
16 | <>
17 |
25 | {showWallet && (
26 | setShowWallet(false)}>
27 |
28 |
29 | )}
30 | >
31 | )
32 | }
33 |
--------------------------------------------------------------------------------
/src/shared/components/market/MarketDetails.tsx:
--------------------------------------------------------------------------------
1 | import {formatTagValue} from "@/shared/utils/marketUtils"
2 | import {useState} from "react"
3 |
4 | type MarketDetailsProps = {
5 | tags: string[][]
6 | }
7 |
8 | /**
9 | * A reusable component for displaying market listing details
10 | */
11 | const MarketDetails = ({tags}: MarketDetailsProps) => {
12 | const [showDetails, setShowDetails] = useState(false)
13 |
14 | if (tags.length === 0) return null
15 |
16 | return (
17 |
18 |
24 |
25 | {showDetails && (
26 |
27 |
28 | {tags.map((tag, index) => (
29 | -
30 | {tag[0]}:
31 | {formatTagValue(tag)}
32 |
33 | ))}
34 |
35 |
36 | )}
37 |
38 | )
39 | }
40 |
41 | export default MarketDetails
42 |
--------------------------------------------------------------------------------
/src/shared/components/market/MarketImage.tsx:
--------------------------------------------------------------------------------
1 | import SmallImageComponent from "../embed/media/SmallImageComponent"
2 | import {RiImageLine} from "@remixicon/react"
3 | import {NDKEvent} from "@nostr-dev-kit/ndk"
4 |
5 | type MarketImageProps = {
6 | event: NDKEvent
7 | imageUrl: string | null
8 | size?: number
9 | className?: string
10 | }
11 |
12 | /**
13 | * A reusable component for displaying market listing images
14 | */
15 | const MarketImage = ({event, imageUrl, size = 160, className = ""}: MarketImageProps) => {
16 | return (
17 |
18 | {imageUrl ? (
19 |
20 | ) : (
21 |
25 |
26 |
27 | )}
28 |
29 | )
30 | }
31 |
32 | export default MarketImage
33 |
--------------------------------------------------------------------------------
/src/shared/components/media/PreloadImages.tsx:
--------------------------------------------------------------------------------
1 | import ProxyImg from "../ProxyImg"
2 |
3 | interface PreloadImagesProps {
4 | images: string[]
5 | currentIndex: number
6 | size?: number | null
7 | }
8 |
9 | function PreloadImages({images, currentIndex, size}: PreloadImagesProps) {
10 | if (images.length === 0) return null
11 |
12 | const nextIndex = (currentIndex + 1) % images.length
13 | const prevIndex = (currentIndex - 1 + images.length) % images.length
14 |
15 | return (
16 |
28 | )
29 | }
30 |
31 | export default PreloadImages
32 |
--------------------------------------------------------------------------------
/src/shared/components/messages/UnseenMessagesBadge.tsx:
--------------------------------------------------------------------------------
1 | import {getMillisecondTimestamp} from "nostr-double-ratchet/src"
2 | import {useSessionsStore} from "@/stores/sessions"
3 | import {useEventsStore} from "@/stores/events"
4 | import {useMemo} from "react"
5 |
6 | export default function UnseenMessagesBadge() {
7 | const {lastSeen} = useSessionsStore()
8 | const {events} = useEventsStore()
9 |
10 | const hasUnread = useMemo(() => {
11 | return Array.from(events.entries()).some(([sessionId, sessionEvents]) => {
12 | const [, latest] = sessionEvents.last() ?? []
13 | if (!latest) return false
14 |
15 | const latestTime = getMillisecondTimestamp(latest)
16 | const lastSeenTime = lastSeen.get(sessionId) || 0
17 | return latestTime > lastSeenTime
18 | })
19 | }, [events, lastSeen])
20 |
21 | return (
22 | <>
23 | {hasUnread && }
24 | >
25 | )
26 | }
27 |
--------------------------------------------------------------------------------
/src/shared/components/nav/MessagesNavItem.tsx:
--------------------------------------------------------------------------------
1 | import UnseenMessagesBadge from "@/shared/components/messages/UnseenMessagesBadge"
2 | import Icon from "@/shared/components/Icons/Icon"
3 | import {MouseEventHandler} from "react"
4 | import {useUIStore} from "@/stores/ui"
5 | import classNames from "classnames"
6 | import NavLink from "./NavLink"
7 |
8 | interface MessagesNavItemProps {
9 | to: string
10 | onClick?: MouseEventHandler
11 | }
12 |
13 | export const MessagesNavItem = ({to, onClick}: MessagesNavItemProps) => {
14 | const {setIsSidebarOpen} = useUIStore()
15 |
16 | const handleClick: MouseEventHandler = (e) => {
17 | setIsSidebarOpen(false)
18 | onClick?.(e)
19 | }
20 |
21 | return (
22 |
23 |
28 | classNames({
29 | "bg-base-100": isActive,
30 | "rounded-full md:aspect-square xl:aspect-auto flex items-center": true,
31 | })
32 | }
33 | >
34 | {({isActive}) => (
35 |
36 |
37 |
38 | Messages
39 |
40 | )}
41 |
42 |
43 | )
44 | }
45 |
--------------------------------------------------------------------------------
/src/shared/components/nav/NavLink.tsx:
--------------------------------------------------------------------------------
1 | import {NavLink as RouterNavLink, NavLinkProps, useLocation} from "react-router"
2 | import {useNotificationsStore} from "@/stores/notifications"
3 | import {MouseEvent} from "react"
4 |
5 | export default function NavLink(props: NavLinkProps) {
6 | const {to, onClick, ...rest} = props
7 | const location = useLocation()
8 |
9 | const isActive = location.pathname === to.toString()
10 |
11 | const handleClick = (event: MouseEvent) => {
12 | if (onClick) {
13 | onClick(event)
14 | }
15 |
16 | if (isActive) {
17 | if (window.scrollY === 0) {
18 | const {updateRefreshRouteSignal} = useNotificationsStore.getState()
19 | updateRefreshRouteSignal()
20 | } else {
21 | window.scrollTo({top: 0, behavior: "instant"})
22 | }
23 | }
24 | }
25 |
26 | return
27 | }
28 |
--------------------------------------------------------------------------------
/src/shared/components/nav/NotificationNavItem.tsx:
--------------------------------------------------------------------------------
1 | import UnseenNotificationsBadge from "@/shared/components/header/UnseenNotificationsBadge"
2 | import Icon from "@/shared/components/Icons/Icon"
3 | import {MouseEventHandler} from "react"
4 | import {useUIStore} from "@/stores/ui"
5 | import classNames from "classnames"
6 | import NavLink from "./NavLink"
7 |
8 | interface NotificationNavItemProps {
9 | to: string
10 | onClick?: MouseEventHandler
11 | }
12 |
13 | export const NotificationNavItem = ({to, onClick}: NotificationNavItemProps) => {
14 | const {setIsSidebarOpen} = useUIStore()
15 |
16 | const handleClick: MouseEventHandler = (e) => {
17 | setIsSidebarOpen(false)
18 | onClick?.(e)
19 | }
20 |
21 | return (
22 |
23 |
28 | classNames({
29 | "bg-base-100": isActive,
30 | "rounded-full md:aspect-square xl:aspect-auto flex items-center": true,
31 | })
32 | }
33 | >
34 | {({isActive}) => (
35 |
36 |
37 |
38 | Notifications
39 |
40 | )}
41 |
42 |
43 | )
44 | }
45 |
--------------------------------------------------------------------------------
/src/shared/components/nav/navConfig.ts:
--------------------------------------------------------------------------------
1 | import {MouseEventHandler} from "react"
2 |
3 | export interface NavItemConfig {
4 | to: string
5 | label: string
6 | icon?: string
7 | activeIcon?: string
8 | inactiveIcon?: string
9 | requireLogin?: boolean
10 | onClick?: MouseEventHandler
11 | }
12 |
13 | export const navItemsConfig = (): Record => ({
14 | home: {to: "/", icon: "home", label: "Home"},
15 | search: {to: "/search", icon: "search", label: "Search"},
16 | messages: {
17 | to: "/chats",
18 | icon: "mail",
19 | label: "Chats",
20 | requireLogin: true,
21 | },
22 | notifications: {
23 | to: "/notifications",
24 | icon: "notifications",
25 | label: "Notifications",
26 | requireLogin: true,
27 | },
28 | wallet: {
29 | to: "/wallet",
30 | icon: "wallet",
31 | label: "Wallet",
32 | requireLogin: true,
33 | },
34 | settings: {to: "/settings", icon: "settings", label: "Settings", requireLogin: true},
35 | subscription: {
36 | to: "/subscribe",
37 | icon: "star",
38 | label: "Subscription",
39 | requireLogin: true,
40 | },
41 | about: {to: "/about", icon: "info", label: "About"},
42 | })
43 |
--------------------------------------------------------------------------------
/src/shared/components/ui/Dropdown.tsx:
--------------------------------------------------------------------------------
1 | import {ReactNode, useEffect} from "react"
2 |
3 | type DropdownProps = {
4 | children: ReactNode
5 | onClose: () => void
6 | }
7 |
8 | function Dropdown({children, onClose}: DropdownProps) {
9 | useEffect(() => {
10 | const onEscape = (e: KeyboardEvent) => {
11 | if (e.key === "Escape") {
12 | onClose()
13 | }
14 | }
15 |
16 | const onClickOutside = (e: MouseEvent) => {
17 | if (!(e.target as HTMLElement).closest(".dropdown-container")) {
18 | e.stopPropagation()
19 | e.preventDefault()
20 | onClose()
21 | }
22 | }
23 |
24 | window.addEventListener("keydown", onEscape)
25 | window.addEventListener("click", onClickOutside, {capture: true})
26 |
27 | return () => {
28 | window.removeEventListener("keydown", onEscape)
29 | window.removeEventListener("click", onClickOutside, {capture: true})
30 | }
31 | }, [onClose])
32 |
33 | return (
34 |
35 | {children}
36 |
37 | )
38 | }
39 |
40 | export default Dropdown
41 |
--------------------------------------------------------------------------------
/src/shared/components/ui/Hovercard.tsx:
--------------------------------------------------------------------------------
1 | import React from "react"
2 | interface HoverCardProps {
3 | children: React.ReactNode
4 | content: React.ReactNode
5 | onClick?: () => void
6 | }
7 |
8 | function HoverCard({children, content, onClick}: HoverCardProps) {
9 | return (
10 |
11 | {children}
12 |
13 | {content}
14 |
15 |
16 | )
17 | }
18 |
19 | export default HoverCard
20 |
--------------------------------------------------------------------------------
/src/shared/components/ui/InfiniteScroll.tsx:
--------------------------------------------------------------------------------
1 | import {ReactNode, useCallback, useEffect, useRef} from "react"
2 |
3 | type Props = {
4 | onLoadMore: () => void
5 | children: ReactNode
6 | }
7 |
8 | const InfiniteScroll = ({onLoadMore, children}: Props) => {
9 | const observerRef = useRef(null)
10 |
11 | const handleObserver = useCallback(
12 | (entries: IntersectionObserverEntry[]) => {
13 | const target = entries[0]
14 | if (target.isIntersecting) {
15 | onLoadMore()
16 | }
17 | },
18 | [onLoadMore]
19 | )
20 |
21 | useEffect(() => {
22 | const observerOptions = {
23 | rootMargin: "1000px",
24 | threshold: 1.0,
25 | }
26 |
27 | const observer = new IntersectionObserver(handleObserver, observerOptions)
28 | if (observerRef.current) {
29 | observer.observe(observerRef.current)
30 | }
31 |
32 | return () => {
33 | if (observerRef.current) {
34 | observer.unobserve(observerRef.current)
35 | }
36 | }
37 | }, [handleObserver])
38 |
39 | return (
40 | <>
41 | {children}
42 |
43 | >
44 | )
45 | }
46 |
47 | export default InfiniteScroll
48 |
--------------------------------------------------------------------------------
/src/shared/components/ui/PublishButton.tsx:
--------------------------------------------------------------------------------
1 | import {RiAddCircleLine, RiAddLine} from "@remixicon/react" // Import Plus icon from Remix Icons
2 | import {usePublicKey} from "@/stores/user"
3 | import {useUIStore} from "@/stores/ui"
4 | import classNames from "classnames" // Import classnames library
5 | import {useCallback} from "react"
6 |
7 | function PublishButton({
8 | className,
9 | showLabel = true,
10 | }: {
11 | className?: string
12 | showLabel?: boolean
13 | }) {
14 | // Add className prop
15 | const {newPostOpen, setNewPostOpen} = useUIStore()
16 | const myPubKey = usePublicKey()
17 |
18 | const handlePress = useCallback(
19 | () => setNewPostOpen(!newPostOpen),
20 | [newPostOpen, setNewPostOpen]
21 | )
22 |
23 | if (!myPubKey) return null
24 |
25 | return (
26 | <>
27 |
40 |
41 |
42 | {showLabel && New post}
43 |
44 | >
45 | )
46 | }
47 |
48 | export default PublishButton
49 |
--------------------------------------------------------------------------------
/src/shared/components/ui/Widget.tsx:
--------------------------------------------------------------------------------
1 | import React from "react"
2 |
3 | interface WidgetProps {
4 | title: string
5 | children: React.ReactNode
6 | }
7 |
8 | function Widget({title, children}: WidgetProps) {
9 | return (
10 |
11 |
12 | {title}
13 |
14 |
{children}
15 |
16 | )
17 | }
18 |
19 | export default Widget
20 |
--------------------------------------------------------------------------------
/src/shared/components/user/AvatarGroup.tsx:
--------------------------------------------------------------------------------
1 | import {Avatar} from "@/shared/components/user/Avatar.tsx"
2 |
3 | export function AvatarGroup({
4 | pubKeys,
5 | onClick,
6 | avatarWidth = 30,
7 | }: {
8 | pubKeys: string[]
9 | avatarWidth?: number
10 | onClick?: () => void
11 | }) {
12 | return (
13 |
14 | {pubKeys.map((a, index) => (
15 |
0 ? "-ml-2" : ""}`}
18 | key={a}
19 | style={{zIndex: pubKeys.length - index}}
20 | >
21 |
22 |
23 | ))}
24 |
25 | )
26 | }
27 |
--------------------------------------------------------------------------------
/src/shared/components/user/Badge.tsx:
--------------------------------------------------------------------------------
1 | import socialGraph from "@/utils/socialGraph.ts"
2 | import {RiCheckLine} from "@remixicon/react"
3 | import {useUserStore} from "@/stores/user"
4 |
5 | export const Badge = ({
6 | pubKeyHex,
7 | className,
8 | }: {
9 | pubKeyHex: string
10 | className?: string
11 | }) => {
12 | const publicKey = useUserStore((state) => state.publicKey)
13 | const loggedIn = !!publicKey
14 |
15 | if (!loggedIn) {
16 | return null
17 | }
18 | const distance = socialGraph().getFollowDistance(pubKeyHex)
19 | if (distance <= 2) {
20 | let tooltip
21 | let badgeClass
22 | if (distance === 0) {
23 | tooltip = "You"
24 | badgeClass = "bg-primary"
25 | } else if (distance === 1) {
26 | tooltip = "Following"
27 | badgeClass = "bg-primary"
28 | } else if (distance === 2) {
29 | const followedByFriends = socialGraph().followedByFriends(pubKeyHex)
30 | tooltip = `Followed by ${followedByFriends.size} friends`
31 | badgeClass = followedByFriends.size > 10 ? "bg-accent" : "bg-neutral"
32 | }
33 | return (
34 |
38 |
39 |
40 | )
41 | }
42 | return null
43 | }
44 |
--------------------------------------------------------------------------------
/src/shared/components/user/LoginDialog.tsx:
--------------------------------------------------------------------------------
1 | import SignUp from "@/shared/components/user/SignUp"
2 | import SignIn from "@/shared/components/user/SignIn"
3 | import {useState} from "react"
4 |
5 | export default function LoginDialog() {
6 | const [showSignIn, setShowSignIn] = useState(!!window.nostr)
7 |
8 | return (
9 |
10 |
11 |

12 | {showSignIn ? (
13 |
setShowSignIn(false)} />
14 | ) : (
15 | setShowSignIn(true)} />
16 | )}
17 |
18 |
19 | )
20 | }
21 |
--------------------------------------------------------------------------------
/src/shared/components/user/MinidenticonImg.tsx:
--------------------------------------------------------------------------------
1 | import {ImgHTMLAttributes, useMemo} from "react"
2 | import {minidenticon} from "minidenticons"
3 |
4 | type Props = {
5 | username: string
6 | saturation?: number
7 | lightness?: number
8 | } & ImgHTMLAttributes
9 |
10 | const MinidenticonImg = ({username, saturation, lightness, ...props}: Props) => {
11 | const svgURI = useMemo(
12 | () =>
13 | "data:image/svg+xml;utf8," +
14 | encodeURIComponent(minidenticon(username, saturation, lightness)),
15 | [username, saturation, lightness]
16 | )
17 | return
18 | }
19 |
20 | export default MinidenticonImg
21 |
--------------------------------------------------------------------------------
/src/shared/components/user/Name.tsx:
--------------------------------------------------------------------------------
1 | import {PublicKey} from "@/shared/utils/PublicKey"
2 | import classNames from "classnames"
3 | import {useMemo} from "react"
4 |
5 | import useProfile from "@/shared/hooks/useProfile.ts"
6 | import animalName from "@/utils/AnimalName"
7 |
8 | export function Name({pubKey, className}: {pubKey: string; className?: string}) {
9 | const pubKeyHex = useMemo(() => {
10 | if (!pubKey || pubKey === "follows") {
11 | return ""
12 | }
13 | try {
14 | return new PublicKey(pubKey).toString()
15 | } catch (error) {
16 | console.warn(error)
17 | return ""
18 | }
19 | }, [pubKey])
20 |
21 | const profile = useProfile(pubKey, false)
22 |
23 | const name =
24 | profile?.display_name ||
25 | profile?.name ||
26 | profile?.username ||
27 | profile?.nip05?.split("@")[0]
28 |
29 | const animal = useMemo(() => {
30 | if (name) {
31 | return ""
32 | }
33 | if (!pubKeyHex) {
34 | return ""
35 | }
36 | return animalName(pubKeyHex)
37 | }, [profile, pubKeyHex])
38 |
39 | return (
40 |
49 | {name || animal}
50 |
51 | )
52 | }
53 |
--------------------------------------------------------------------------------
/src/shared/components/user/ProfileAbout.tsx:
--------------------------------------------------------------------------------
1 | import useProfile from "@/shared/hooks/useProfile.ts"
2 | import HyperText from "../HyperText"
3 |
4 | export function ProfileAbout({pubKey, className}: {pubKey: string; className?: string}) {
5 | const profile = useProfile(pubKey)
6 |
7 | if (profile?.about) {
8 | return (
9 |
10 |
11 | {profile.about}
12 |
13 |
14 | )
15 | }
16 |
17 | return ""
18 | }
19 |
--------------------------------------------------------------------------------
/src/shared/components/user/ProfileCard.tsx:
--------------------------------------------------------------------------------
1 | import {FollowButton} from "@/shared/components/button/FollowButton"
2 | import {ProfileAbout} from "@/shared/components/user/ProfileAbout"
3 | import {UserRow} from "@/shared/components/user/UserRow"
4 | import FollowedBy from "./FollowedBy"
5 |
6 | const ProfileCard = ({
7 | pubKey,
8 | showAbout = true,
9 | showFollows = false,
10 | showHoverCard = false,
11 | }: {
12 | pubKey: string
13 | showAbout?: boolean
14 | showFollows?: boolean
15 | showHoverCard?: boolean
16 | }) => {
17 | return (
18 |
19 |
20 |
21 |
22 |
23 | {showAbout &&
}
24 | {showFollows &&
}
25 |
26 | )
27 | }
28 |
29 | export default ProfileCard
30 |
--------------------------------------------------------------------------------
/src/shared/components/user/PublicKeyQRCodeButton.tsx:
--------------------------------------------------------------------------------
1 | import QRCodeButton from "./QRCodeButton"
2 | import {nip19} from "nostr-tools"
3 | import {useMemo} from "react"
4 |
5 | interface PublicKeyQRCodeButtonProps {
6 | publicKey: string
7 | onScanSuccess?: (data: string) => void
8 | "data-testid"?: string
9 | }
10 |
11 | function PublicKeyQRCodeButton({
12 | publicKey,
13 | onScanSuccess,
14 | "data-testid": dataTestId,
15 | }: PublicKeyQRCodeButtonProps) {
16 | const npub = useMemo(() => {
17 | if (publicKey.startsWith("npub")) {
18 | return publicKey
19 | } else {
20 | return nip19.npubEncode(publicKey)
21 | }
22 | }, [publicKey])
23 |
24 | const data = `nostr:${npub}`
25 |
26 | return (
27 |
28 | )
29 | }
30 |
31 | export default PublicKeyQRCodeButton
32 |
--------------------------------------------------------------------------------
/src/shared/components/user/const.ts:
--------------------------------------------------------------------------------
1 | export const AVATAR_DEFAULT_WIDTH = 45
2 | export const EVENT_AVATAR_WIDTH = 38
3 | export const PROFILE_AVATAR_WIDTH = 92
4 |
5 | export const MOBILE_BREAKPOINT = 768
6 |
--------------------------------------------------------------------------------
/src/shared/components/user/useHoverCard.ts:
--------------------------------------------------------------------------------
1 | import {useState, useRef, useEffect} from "react"
2 |
3 | export function useHoverCard(showHoverCard: boolean) {
4 | const [isOpen, setIsOpen] = useState(false)
5 | const timeoutRef = useRef(null)
6 |
7 | const closeCard = () => {
8 | setIsOpen(false)
9 | }
10 |
11 | const hoverProps = showHoverCard
12 | ? {
13 | onMouseEnter: () => {
14 | if (timeoutRef.current) clearTimeout(timeoutRef.current)
15 | timeoutRef.current = setTimeout(() => setIsOpen(true), 300)
16 | },
17 | onMouseLeave: () => {
18 | if (timeoutRef.current) clearTimeout(timeoutRef.current)
19 | timeoutRef.current = setTimeout(() => setIsOpen(false), 300)
20 | },
21 | }
22 | : {}
23 |
24 | useEffect(() => {
25 | return () => {
26 | if (timeoutRef.current) clearTimeout(timeoutRef.current)
27 | }
28 | }, [])
29 |
30 | return {hoverProps, showCard: showHoverCard && isOpen, closeCard}
31 | }
32 |
--------------------------------------------------------------------------------
/src/shared/components/ux/Timeout.tsx:
--------------------------------------------------------------------------------
1 | // Timeout.tsx
2 | import {useEffect, useState} from "react"
3 |
4 | interface TimeoutProps {
5 | loading: boolean
6 | time?: number //in milliseconds. Default is 8000 (below)
7 | }
8 |
9 | function Timeout({loading, time = 8000}: TimeoutProps) {
10 | const [loadingTimeout, setLoadingTimeout] = useState(false)
11 |
12 | useEffect(() => {
13 | let timeoutId: number
14 | if (loading && !loadingTimeout) {
15 | timeoutId = window.setTimeout(() => {
16 | setLoadingTimeout(true)
17 | }, time)
18 | }
19 |
20 | return () => {
21 | if (timeoutId) {
22 | window.clearTimeout(timeoutId)
23 | }
24 | }
25 | }, [loading, loadingTimeout])
26 |
27 | if (loadingTimeout) {
28 | return (
29 |
30 | Loading is taking longer than expected. Please{" "}
31 |
reload the page.
32 |
33 | )
34 | }
35 | return null
36 | }
37 |
38 | export default Timeout
39 |
--------------------------------------------------------------------------------
/src/shared/hooks/useAutosizeTextarea.ts:
--------------------------------------------------------------------------------
1 | import {useLayoutEffect, useRef} from "react"
2 |
3 | export function useAutosizeTextarea(value: string, {maxRows = 6} = {}) {
4 | const ref = useRef(null)
5 |
6 | useLayoutEffect(() => {
7 | const el = ref.current
8 | if (!el) return
9 | const line = parseFloat(getComputedStyle(el).lineHeight)
10 | el.style.height = "auto" // Reset height to auto (resets after send or removing line)
11 | el.style.height = Math.min(el.scrollHeight, line * maxRows) + "px"
12 | el.style.textAlign = el.scrollHeight <= line + 1 ? "center" : "left"
13 | }, [value, maxRows])
14 |
15 | return ref
16 | }
17 |
--------------------------------------------------------------------------------
/src/shared/hooks/useHistoryState.ts:
--------------------------------------------------------------------------------
1 | import {useEffect, useRef, useState} from "react"
2 | import throttle from "lodash/throttle"
3 |
4 | const throttledReplaceState = throttle(
5 | (newState: Record) => {
6 | history.replaceState(newState, "")
7 | },
8 | 1000,
9 | {leading: true}
10 | )
11 |
12 | export default function useHistoryState(initialValue: T, key: string) {
13 | const currentHistoryState = history.state ? history.state[key] : undefined
14 | const myInitialValue =
15 | currentHistoryState === undefined ? initialValue : currentHistoryState
16 | const [state, setState] = useState(myInitialValue)
17 |
18 | const latestValue = useRef(state)
19 |
20 | useEffect(() => {
21 | if (state !== latestValue.current) {
22 | const newHistoryState = {...history.state, [key]: state}
23 | throttledReplaceState(newHistoryState)
24 | latestValue.current = state
25 | }
26 |
27 | return () => {
28 | throttledReplaceState.cancel()
29 | if (state !== latestValue.current) {
30 | const newHistoryState = {...history.state, [key]: state}
31 | throttledReplaceState(newHistoryState)
32 | }
33 | }
34 | }, [state, key])
35 |
36 | const popStateListener = (event: PopStateEvent) => {
37 | if (event.state && key in event.state) {
38 | setState(event.state[key])
39 | }
40 | }
41 |
42 | useEffect(() => {
43 | window.addEventListener("popstate", popStateListener)
44 | return () => {
45 | window.removeEventListener("popstate", popStateListener)
46 | }
47 | }, [])
48 |
49 | return [state, setState]
50 | }
51 |
--------------------------------------------------------------------------------
/src/shared/hooks/useInviteFromUrl.ts:
--------------------------------------------------------------------------------
1 | import {useNavigate, useLocation} from "react-router"
2 | import {useSessionsStore} from "@/stores/sessions"
3 | import {useUserStore} from "@/stores/user"
4 | import {useUIStore} from "@/stores/ui"
5 | import {useEffect} from "react"
6 |
7 | export const useInviteFromUrl = () => {
8 | const navigate = useNavigate()
9 | const location = useLocation()
10 | const publicKey = useUserStore((state) => state.publicKey)
11 | const privateKey = useUserStore((state) => state.privateKey)
12 | const setShowLoginDialog = useUIStore((state) => state.setShowLoginDialog)
13 |
14 | useEffect(() => {
15 | let timeoutId: NodeJS.Timeout | null = null
16 |
17 | // if hash not present, do nothing
18 | if (!location.hash) {
19 | return
20 | }
21 |
22 | if (!publicKey) {
23 | timeoutId = setTimeout(() => {
24 | setShowLoginDialog(true)
25 | }, 500)
26 | } else {
27 | const acceptInviteFromUrl = async () => {
28 | const fullUrl = `${window.location.origin}${location.pathname}${location.search}${location.hash}`
29 |
30 | // Clear the invite from URL history by replacing current state with a clean URL
31 | const cleanUrl = `${window.location.origin}${location.pathname}${location.search}`
32 | window.history.replaceState({}, document.title, cleanUrl)
33 |
34 | const sessionId = await useSessionsStore.getState().acceptInvite(fullUrl)
35 | navigate("/chats/chat", {state: {id: sessionId}})
36 | }
37 |
38 | acceptInviteFromUrl()
39 | }
40 |
41 | return () => {
42 | if (timeoutId) {
43 | clearTimeout(timeoutId)
44 | }
45 | }
46 | }, [location, publicKey, privateKey, navigate, setShowLoginDialog])
47 | }
48 |
--------------------------------------------------------------------------------
/src/shared/hooks/useIsMobile.ts:
--------------------------------------------------------------------------------
1 | import {useState, useEffect} from "react"
2 |
3 | export function useIsMobile() {
4 | const [isMobile, setIsMobile] = useState(false)
5 | useEffect(() => {
6 | const mediaQuery = window.matchMedia("(max-width: 768px)")
7 | setIsMobile(mediaQuery.matches)
8 | const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches)
9 | mediaQuery.addEventListener("change", handler)
10 | return () => mediaQuery.removeEventListener("change", handler)
11 | }, [])
12 | return isMobile
13 | }
14 |
--------------------------------------------------------------------------------
/src/shared/hooks/useNip05Validation.ts:
--------------------------------------------------------------------------------
1 | import {nip05VerificationCache} from "@/utils/memcache"
2 | import {useEffect, useState} from "react"
3 | import {ndk} from "@/utils/ndk"
4 |
5 | export function useNip05Validation(pubkey: string, nip05?: string) {
6 | const [isValid, setIsValid] = useState(null)
7 |
8 | useEffect(() => {
9 | if (!pubkey || !nip05) {
10 | setIsValid(null)
11 | return
12 | }
13 |
14 | const cacheKey = `${pubkey}:${nip05}`
15 | const cachedResult = nip05VerificationCache.get(cacheKey)
16 |
17 | if (cachedResult !== undefined) {
18 | setIsValid(cachedResult)
19 | return
20 | }
21 |
22 | // Start validation
23 | ndk()
24 | .getUser({hexpubkey: pubkey})
25 | ?.validateNip05(nip05)
26 | .then((result) => {
27 | const validationResult = result ?? false
28 | nip05VerificationCache.set(cacheKey, validationResult)
29 | setIsValid(validationResult)
30 | })
31 | .catch((error) => {
32 | console.warn("NIP-05 validation error:", error)
33 | setIsValid(false)
34 | })
35 | }, [pubkey, nip05])
36 |
37 | return isValid
38 | }
39 |
--------------------------------------------------------------------------------
/src/shared/hooks/useOnlineStatus.ts:
--------------------------------------------------------------------------------
1 | import {useState, useEffect} from "react"
2 |
3 | let isClient = false
4 |
5 | // Shared state that all instances will reference
6 | let onlineStatus = true
7 | const listeners = new Set<(status: boolean) => void>()
8 |
9 | if (typeof window !== "undefined") {
10 | isClient = true
11 | onlineStatus = navigator.onLine
12 |
13 | window.addEventListener("online", () => {
14 | onlineStatus = true
15 | listeners.forEach((listener) => listener(true))
16 | })
17 |
18 | window.addEventListener("offline", () => {
19 | onlineStatus = false
20 | listeners.forEach((listener) => listener(false))
21 | })
22 | }
23 |
24 | export function useOnlineStatus() {
25 | const [isOnline, setIsOnline] = useState(onlineStatus)
26 |
27 | useEffect(() => {
28 | if (!isClient) return
29 |
30 | const listener = (status: boolean) => setIsOnline(status)
31 | listeners.add(listener)
32 |
33 | return () => {
34 | listeners.delete(listener)
35 | }
36 | }, [])
37 |
38 | return isOnline
39 | }
40 |
--------------------------------------------------------------------------------
/src/shared/hooks/useProfile.ts:
--------------------------------------------------------------------------------
1 | import {NDKEvent, NDKUserProfile} from "@nostr-dev-kit/ndk"
2 | import {handleProfile} from "@/utils/profileSearch"
3 | import {PublicKey} from "@/shared/utils/PublicKey"
4 | import {useEffect, useMemo, useState} from "react"
5 | import {profileCache} from "@/utils/memcache"
6 | import {ndk} from "@/utils/ndk"
7 |
8 | export default function useProfile(pubKey?: string, subscribe = true) {
9 | const pubKeyHex = useMemo(() => {
10 | if (!pubKey) {
11 | return ""
12 | }
13 | try {
14 | return new PublicKey(pubKey).toString()
15 | } catch (e) {
16 | console.warn(`Invalid pubkey: ${pubKey}`)
17 | return ""
18 | }
19 | }, [pubKey])
20 |
21 | const [profile, setProfile] = useState(
22 | profileCache.get(pubKeyHex || "") || null
23 | )
24 |
25 | useEffect(() => {
26 | if (!pubKeyHex) {
27 | return
28 | }
29 | const newProfile = profileCache.get(pubKeyHex || "") || null
30 | setProfile(newProfile)
31 | if (newProfile && !subscribe) {
32 | return
33 | }
34 | const sub = ndk().subscribe({kinds: [0], authors: [pubKeyHex]}, {closeOnEose: false})
35 | let latest = 0
36 | sub.on("event", (event: NDKEvent) => {
37 | if (event.pubkey === pubKeyHex && event.kind === 0) {
38 | if (!event.created_at || event.created_at <= latest) {
39 | return
40 | }
41 | latest = event.created_at
42 | const profile = JSON.parse(event.content)
43 | profile.created_at = event.created_at
44 | profileCache.set(pubKeyHex, profile)
45 | setProfile(profile)
46 | handleProfile(pubKeyHex, profile)
47 | }
48 | })
49 | return () => {
50 | sub.stop()
51 | }
52 | }, [pubKeyHex])
53 |
54 | return profile
55 | }
56 |
--------------------------------------------------------------------------------
/src/shared/hooks/useSearchParam.ts:
--------------------------------------------------------------------------------
1 | import {useSearchParams} from "react-router"
2 |
3 | export default function useSearchParam(param: string, defaultValue: string) {
4 | const [searchParams] = useSearchParams()
5 | return searchParams.get(param) ?? defaultValue
6 | }
7 |
--------------------------------------------------------------------------------
/src/shared/hooks/useWalletBalance.ts:
--------------------------------------------------------------------------------
1 | import {useWebLNProvider} from "./useWebLNProvider"
2 | import {useWalletStore} from "@/stores/wallet"
3 | import {useUserStore} from "@/stores/user"
4 | import {useEffect} from "react"
5 |
6 | export const useWalletBalance = () => {
7 | const isWalletConnect = useUserStore((state) => state.walletConnect)
8 | const {balance, setBalance, provider, setProvider} = useWalletStore()
9 | const webLNProvider = useWebLNProvider()
10 |
11 | useEffect(() => {
12 | setProvider(webLNProvider)
13 | }, [webLNProvider])
14 |
15 | useEffect(() => {
16 | if (provider) {
17 | const updateBalance = async () => {
18 | const balanceInfo = await provider.getBalance()
19 | setBalance(balanceInfo.balance)
20 | }
21 | updateBalance()
22 |
23 | if (provider.on) {
24 | provider.on("accountChanged", updateBalance)
25 | return () => {
26 | provider.off?.("accountChanged", updateBalance)
27 | }
28 | }
29 | } else {
30 | setBalance(null)
31 | }
32 | }, [provider])
33 |
34 | return {balance, isWalletConnect}
35 | }
36 |
--------------------------------------------------------------------------------
/src/shared/hooks/useWebLNProvider.ts:
--------------------------------------------------------------------------------
1 | import {onConnected} from "@getalby/bitcoin-connect"
2 | import {WebLNProvider} from "@/types/global"
3 | import {useEffect, useState} from "react"
4 |
5 | let nwcUnsubscribe: (() => void) | null = null
6 |
7 | export const useWebLNProvider = () => {
8 | const [provider, setProvider] = useState(null)
9 |
10 | useEffect(() => {
11 | const checkNativeWebLN = async () => {
12 | if (window.webln) {
13 | try {
14 | const enabled = await window.webln.isEnabled()
15 | if (enabled) {
16 | setProvider(window.webln)
17 | return true
18 | }
19 | } catch (error) {
20 | console.warn("Failed to enable native WebLN provider:", error)
21 | }
22 | }
23 | return false
24 | }
25 |
26 | // Check native WebLN first
27 | checkNativeWebLN().then((hasNativeWebLN) => {
28 | if (!hasNativeWebLN) {
29 | // Only set up NWC if native WebLN is not available
30 | nwcUnsubscribe = onConnected(async (newProvider) => {
31 | try {
32 | const enabled = await newProvider.isEnabled()
33 | if (enabled) {
34 | setProvider(newProvider)
35 | }
36 | } catch (error) {
37 | console.warn("Failed to enable NWC provider:", error)
38 | }
39 | })
40 | }
41 | })
42 |
43 | return () => {
44 | if (nwcUnsubscribe) {
45 | nwcUnsubscribe()
46 | nwcUnsubscribe = null
47 | }
48 | }
49 | }, [])
50 |
51 | return provider
52 | }
53 |
--------------------------------------------------------------------------------
/src/shared/utils/Hex.ts:
--------------------------------------------------------------------------------
1 | import {hexToBytes} from "@noble/hashes/utils"
2 | import {nip19} from "nostr-tools"
3 |
4 | /**
5 | * Hex encoded string.
6 | */
7 | export class Hex {
8 | value: string
9 |
10 | /**
11 | * @throws Error if the provided string is not a valid hex value or does not match the expected length
12 | */
13 | constructor(str: string, expectedLength?: number) {
14 | // maybe should accept bech32 input and convert to hex?
15 | this.validateHex(str, expectedLength)
16 | this.value = str
17 | }
18 |
19 | private validateHex(str: string, expectedLength?: number): void {
20 | if (!/^[0-9a-fA-F]+$/.test(str)) {
21 | throw new Error(`The provided string is not a valid hex value: "${str}"`)
22 | }
23 |
24 | if (expectedLength && str.length !== expectedLength) {
25 | throw new Error(
26 | `The provided hex value does not match the expected length of ${expectedLength} characters: ${str}`
27 | )
28 | }
29 | }
30 |
31 | toBech32(prefix: string): string {
32 | if (!prefix) {
33 | throw new Error("prefix is required")
34 | }
35 |
36 | const data = hexToBytes(this.value)
37 |
38 | return nip19.encodeBytes(prefix, data)
39 | }
40 |
41 | get hex(): string {
42 | return this.value
43 | }
44 |
45 | toString(): string {
46 | return this.value
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/src/shared/utils/PublicKey.ts:
--------------------------------------------------------------------------------
1 | import {nip19} from "nostr-tools"
2 |
3 | import {Hex} from "./Hex"
4 |
5 | /**
6 | * Nostr public key hex encoded string.
7 | */
8 | export class PublicKey extends Hex {
9 | private npubValue: string | undefined
10 |
11 | /**
12 | * @param str hex or npub encoded string
13 | * @throws Error if the provided string is not a valid nostr public key
14 | */
15 | constructor(str: string) {
16 | const isNpub = str.startsWith("npub")
17 | let hexValue = str
18 | if (isNpub) {
19 | const res = nip19.decode(str)
20 | if (res.type === "npub") {
21 | hexValue = res.data
22 | } else {
23 | throw new Error(`failed to decode npub ${str}`)
24 | }
25 | }
26 | super(hexValue, 64)
27 | if (isNpub) {
28 | this.npubValue = str // preserve the original Bech32 value
29 | }
30 | }
31 |
32 | get npub(): string {
33 | if (!this.npubValue) {
34 | this.npubValue = super.toBech32("npub")
35 | }
36 | return this.npubValue as string
37 | }
38 |
39 | equals(other: PublicKey | string): boolean {
40 | if (typeof other === "string") {
41 | if (other === this.toString()) {
42 | return true
43 | }
44 | other = new PublicKey(other)
45 | }
46 | return this.toString() === other.toString()
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/src/shared/utils/isTouchDevice.ts:
--------------------------------------------------------------------------------
1 | export const isTouchDevice =
2 | typeof window !== "undefined" &&
3 | ("ontouchstart" in window || navigator.maxTouchPoints > 0)
4 |
--------------------------------------------------------------------------------
/src/shared/utils/subscriptionIcons.tsx:
--------------------------------------------------------------------------------
1 | import {RiHeartFill, RiTrophyFill, RiShieldFill} from "@remixicon/react"
2 |
3 | export type SubscriptionTier = "patron" | "champion" | "vanguard"
4 |
5 | export const getSubscriptionIcon = (
6 | tier: SubscriptionTier | undefined,
7 | className = "text-warning",
8 | size?: number
9 | ) => {
10 | const iconProps = {
11 | className,
12 | ...(size ? {size} : {}),
13 | }
14 |
15 | switch (tier) {
16 | case "patron":
17 | return
18 | case "champion":
19 | return
20 | case "vanguard":
21 | return
22 | default:
23 | return
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/src/stores/feed.ts:
--------------------------------------------------------------------------------
1 | import {persist} from "zustand/middleware"
2 | import {create} from "zustand"
3 |
4 | interface FeedState {
5 | activeHomeTab: string
6 | displayCount: number
7 | feedDisplayAs: "list" | "grid"
8 |
9 | setActiveHomeTab: (tab: string) => void
10 | setDisplayCount: (count: number) => void
11 | incrementDisplayCount: (increment: number) => void
12 | setFeedDisplayAs: (displayAs: "list" | "grid") => void
13 | }
14 |
15 | export const useFeedStore = create()(
16 | persist(
17 | (set, get) => {
18 | const initialState = {
19 | activeHomeTab: "unseen",
20 | displayCount: 20,
21 | feedDisplayAs: "list" as const,
22 | }
23 |
24 | const actions = {
25 | setActiveHomeTab: (activeHomeTab: string) => set({activeHomeTab}),
26 | setDisplayCount: (displayCount: number) => set({displayCount}),
27 | incrementDisplayCount: (increment: number) =>
28 | set({displayCount: get().displayCount + increment}),
29 | setFeedDisplayAs: (feedDisplayAs: "list" | "grid") => set({feedDisplayAs}),
30 | }
31 |
32 | return {
33 | ...initialState,
34 | ...actions,
35 | }
36 | },
37 | {
38 | name: "feed-storage",
39 | }
40 | )
41 | )
42 |
43 | export const useActiveHomeTab = () => useFeedStore((state) => state.activeHomeTab)
44 | export const useDisplayCount = () => useFeedStore((state) => state.displayCount)
45 | export const useFeedDisplayAs = () => useFeedStore((state) => state.feedDisplayAs)
46 |
--------------------------------------------------------------------------------
/src/stores/search.ts:
--------------------------------------------------------------------------------
1 | import {SearchResult} from "@/utils/profileSearch"
2 | import {persist} from "zustand/middleware"
3 | import {create} from "zustand"
4 |
5 | export type CustomSearchResult = SearchResult & {
6 | query?: string
7 | pubKey: string
8 | }
9 |
10 | interface SearchState {
11 | recentSearches: CustomSearchResult[]
12 |
13 | setRecentSearches: (searches: CustomSearchResult[]) => void
14 | }
15 |
16 | export const useSearchStore = create()(
17 | persist(
18 | (set) => {
19 | const initialState = {
20 | recentSearches: [] as CustomSearchResult[],
21 | }
22 |
23 | const actions = {
24 | setRecentSearches: (recentSearches: CustomSearchResult[]) =>
25 | set({recentSearches}),
26 | }
27 |
28 | return {
29 | ...initialState,
30 | ...actions,
31 | }
32 | },
33 | {
34 | name: "search-storage",
35 | }
36 | )
37 | )
38 |
39 | export const useRecentSearches = () => useSearchStore((state) => state.recentSearches)
40 |
--------------------------------------------------------------------------------
/src/stores/wallet.ts:
--------------------------------------------------------------------------------
1 | import {persist} from "zustand/middleware"
2 | import {create} from "zustand"
3 |
4 | import {WebLNProvider} from "@/types/global"
5 |
6 | interface WalletState {
7 | balance: number | null
8 | provider: WebLNProvider | null
9 |
10 | setBalance: (balance: number | null) => void
11 | setProvider: (provider: WebLNProvider | null) => void
12 | }
13 |
14 | export const useWalletStore = create()(
15 | persist(
16 | (set) => {
17 | const initialState = {
18 | balance: null,
19 | provider: null,
20 | }
21 |
22 | const actions = {
23 | setBalance: (balance: number | null) => set({balance}),
24 | setProvider: (provider: WebLNProvider | null) => set({provider}),
25 | }
26 |
27 | return {
28 | ...initialState,
29 | ...actions,
30 | }
31 | },
32 | {
33 | name: "wallet-storage",
34 | }
35 | )
36 | )
37 |
38 | export const useBalance = () => useWalletStore((state) => state.balance)
39 | export const useProvider = () => useWalletStore((state) => state.provider)
40 |
--------------------------------------------------------------------------------
/src/stores/zap.ts:
--------------------------------------------------------------------------------
1 | import {persist} from "zustand/middleware"
2 | import {create} from "zustand"
3 |
4 | interface ZapState {
5 | defaultZapAmount: number
6 |
7 | setDefaultZapAmount: (amount: number) => void
8 | }
9 |
10 | export const useZapStore = create()(
11 | persist(
12 | (set) => {
13 | const initialState = {
14 | defaultZapAmount: 21,
15 | }
16 |
17 | const actions = {
18 | setDefaultZapAmount: (defaultZapAmount: number) => set({defaultZapAmount}),
19 | }
20 |
21 | return {
22 | ...initialState,
23 | ...actions,
24 | }
25 | },
26 | {
27 | name: "zap-storage",
28 | }
29 | )
30 | )
31 |
32 | export const useDefaultZapAmount = () => useZapStore((state) => state.defaultZapAmount)
33 |
--------------------------------------------------------------------------------
/src/types/dom-types.d.ts:
--------------------------------------------------------------------------------
1 | export interface RTCSessionDescriptionInit {
2 | type: RTCSdpType
3 | sdp: string
4 | }
5 |
6 | export type RTCSdpType = "answer" | "offer" | "pranswer" | "rollback"
7 |
8 | export interface RTCIceCandidateInit {
9 | candidate?: string
10 | sdpMid?: string
11 | sdpMLineIndex?: number
12 | usernameFragment?: string
13 | }
14 |
--------------------------------------------------------------------------------
/src/types/emoji.ts:
--------------------------------------------------------------------------------
1 | interface EmojiType {
2 | native: string
3 | [key: string]: unknown
4 | }
5 |
6 | export default EmojiType
7 |
--------------------------------------------------------------------------------
/src/types/global.d.ts:
--------------------------------------------------------------------------------
1 | declare global {
2 | interface Window {
3 | cf_turnstile_callback?: (token: string) => void
4 | webln?: WebLNProvider
5 | }
6 | }
7 |
8 | export interface WebLNProvider {
9 | isEnabled: () => Promise
10 | sendPayment: (pr: string) => Promise
11 | getBalance: () => Promise<{balance: number}>
12 | on: (eventName: "accountChanged", listener: () => void) => void
13 | off: (eventName: "accountChanged", listener: () => void) => void
14 | }
15 |
16 | export {}
17 |
--------------------------------------------------------------------------------
/src/utils/cloudflare_banned_users.ts:
--------------------------------------------------------------------------------
1 | // Hashes of public keys flagged as CSAM by CloudFlare
2 | // They are hidden by default on iris.to domain
3 | // CloudFlare hosting policies require this
4 | // even if content related to the public key is not hosted on CloudFlare
5 | // even though it's not efficient, does not address the underlying issue
6 | // and is not required by law.
7 | // Optics is what matters.
8 | // see https://iris.to/note1pu5kvxwfzytxsw6vkqd4eu6e0xr8znaur6sl38r4swl3klgsn6dqzlpnsl
9 | export const CLOUDFLARE_CSAM_FLAGGED = [
10 | "011b3e4c20524582293fb9070701013e2570a3a27b6cac32a3edb9e3400b6c00",
11 | ]
12 |
--------------------------------------------------------------------------------
/src/utils/memcache.ts:
--------------------------------------------------------------------------------
1 | import {NDKEvent, NDKUserProfile} from "@nostr-dev-kit/ndk"
2 | import {SortedMap} from "./SortedMap/SortedMap"
3 | import {LRUCache} from "typescript-lru-cache"
4 | import debounce from "lodash/debounce"
5 | import localforage from "localforage"
6 |
7 | export const eventsByIdCache = new LRUCache({maxSize: 500})
8 | export const feedCache = new LRUCache>({maxSize: 10})
9 | export const seenEventIds = new LRUCache({maxSize: 10000})
10 | export const profileCache = new LRUCache({maxSize: 100000})
11 |
12 | // Cache for NIP-05 verification results
13 | export const nip05VerificationCache = new LRUCache({maxSize: 1000})
14 |
15 | localforage
16 | .getItem("seenEventIds")
17 | .then((s) => {
18 | if (s) {
19 | s.forEach((id) => seenEventIds.set(id, true))
20 | }
21 | })
22 | .catch((e) => {
23 | console.error("failed to load seenEventIds:", e)
24 | })
25 |
26 | const debouncedSave = debounce(
27 | () => localforage.setItem("seenEventIds", [...seenEventIds.keys()]),
28 | 5000
29 | )
30 |
31 | export const addSeenEventId = (id: string) => {
32 | seenEventIds.set(id, true)
33 | debouncedSave()
34 | }
35 |
--------------------------------------------------------------------------------
/src/utils/messageRepository.ts:
--------------------------------------------------------------------------------
1 | import {comparator} from "@/pages/chats/utils/messageGrouping"
2 | import type {MessageType} from "@/pages/chats/message/Message"
3 | import {SortedMap} from "@/utils/SortedMap/SortedMap"
4 | import Dexie, {type EntityTable} from "dexie"
5 |
6 | export type MessageEntity = MessageType & {session_id: string}
7 |
8 | class MessageDb extends Dexie {
9 | public messages!: EntityTable
10 | constructor() {
11 | super("Messages")
12 | this.version(1).stores({
13 | messages: "id, session_id, created_at",
14 | })
15 | }
16 | }
17 |
18 | const db = new MessageDb()
19 |
20 | export async function loadAll(): Promise