From 249593d547d3a056047b6ddf245631becd4c0647 Mon Sep 17 00:00:00 2001 From: Cody Tseng Date: Sat, 1 Mar 2025 23:52:05 +0800 Subject: [PATCH] feat: zap (#107) --- package-lock.json | 167 ++++++++++ package.json | 2 + src/App.tsx | 27 +- src/PageManager.tsx | 93 +++--- src/components/AboutInfoDialog/index.tsx | 28 +- .../NotificationsButton.tsx | 9 +- src/components/Donation/index.tsx | 49 +++ src/components/Embedded/EmbeddedMention.tsx | 9 +- src/components/FollowButton/index.tsx | 6 +- src/components/Image/index.tsx | 1 + src/components/Nip05/index.tsx | 4 +- src/components/NoteCard/RepostDescription.tsx | 2 +- src/components/NoteList/index.tsx | 4 +- src/components/NoteStats/LikeButton.tsx | 53 ++-- src/components/NoteStats/RepostButton.tsx | 55 ++-- src/components/NoteStats/TopZaps.tsx | 43 +++ src/components/NoteStats/ZapButton.tsx | 147 +++++++++ src/components/NoteStats/index.tsx | 33 +- .../NotificationItem/CommentNotification.tsx | 48 +++ .../NotificationItem/ReactionNotification.tsx | 60 ++++ .../NotificationItem/ReplyNotification.tsx | 34 +++ .../NotificationItem/RepostNotification.tsx | 49 +++ .../NotificationItem/ZapNotification.tsx | 56 ++++ .../NotificationItem/index.tsx | 37 +++ src/components/NotificationList/index.tsx | 283 ++++++++--------- src/components/PostEditor/Title.tsx | 3 +- src/components/ProfileBanner/index.tsx | 3 +- src/components/ProfileOptions/index.tsx | 2 +- src/components/ProfileZapButton/index.tsx | 24 ++ src/components/Sidebar/AccountButton.tsx | 20 +- src/components/Sidebar/NotificationButton.tsx | 9 +- src/components/Sidebar/index.tsx | 2 +- src/components/UserAvatar/index.tsx | 14 +- src/components/VideoPlayer/index.tsx | 4 +- src/components/ZapDialog/index.tsx | 162 ++++++++++ src/constants.ts | 7 + src/hooks/index.tsx | 1 - src/hooks/useSearchParams.tsx | 24 -- src/i18n/en.ts | 20 +- src/i18n/zh.ts | 20 +- src/index.css | 8 + src/lib/common.ts | 4 + src/lib/event.ts | 79 ++++- src/lib/lightning.ts | 32 ++ src/lib/link.ts | 1 + src/main.tsx | 1 + src/pages/primary/MePage/index.tsx | 8 +- .../primary/NotificationListPage/index.tsx | 4 +- src/pages/secondary/NoteListPage/index.tsx | 6 +- .../secondary/ProfileEditorPage/index.tsx | 47 ++- src/pages/secondary/ProfileListPage/index.tsx | 6 +- src/pages/secondary/ProfilePage/index.tsx | 38 ++- src/pages/secondary/SettingsPage/index.tsx | 25 +- .../WalletPage/DefaultZapAmountInput.tsx | 38 +++ .../WalletPage/DefaultZapCommentInput.tsx | 27 ++ .../WalletPage/LightningAddressInput.tsx | 82 +++++ .../secondary/WalletPage/QuickZapSwitch.tsx | 21 ++ src/pages/secondary/WalletPage/index.tsx | 26 ++ src/providers/FeedProvider.tsx | 8 +- src/providers/FollowListProvider.tsx | 60 +--- src/providers/MuteListProvider.tsx | 86 ++---- src/providers/NostrProvider/index.tsx | 123 +++++--- src/providers/NoteStatsProvider.tsx | 288 ++++++++++-------- src/providers/NotificationProvider.tsx | 143 +++++++++ src/providers/ZapProvider.tsx | 57 ++++ src/routes.tsx | 2 + src/services/client.service.ts | 235 +++++++------- src/services/indexed-db.service.ts | 55 ++-- src/services/lightning.service.ts | 192 ++++++++++++ src/services/local-storage.service.ts | 56 ++++ src/services/relay-info.service.ts | 19 +- src/types.ts | 5 + 72 files changed, 2580 insertions(+), 816 deletions(-) create mode 100644 src/components/Donation/index.tsx create mode 100644 src/components/NoteStats/TopZaps.tsx create mode 100644 src/components/NoteStats/ZapButton.tsx create mode 100644 src/components/NotificationList/NotificationItem/CommentNotification.tsx create mode 100644 src/components/NotificationList/NotificationItem/ReactionNotification.tsx create mode 100644 src/components/NotificationList/NotificationItem/ReplyNotification.tsx create mode 100644 src/components/NotificationList/NotificationItem/RepostNotification.tsx create mode 100644 src/components/NotificationList/NotificationItem/ZapNotification.tsx create mode 100644 src/components/NotificationList/NotificationItem/index.tsx create mode 100644 src/components/ProfileZapButton/index.tsx create mode 100644 src/components/ZapDialog/index.tsx delete mode 100644 src/hooks/useSearchParams.tsx create mode 100644 src/lib/lightning.ts create mode 100644 src/pages/secondary/WalletPage/DefaultZapAmountInput.tsx create mode 100644 src/pages/secondary/WalletPage/DefaultZapCommentInput.tsx create mode 100644 src/pages/secondary/WalletPage/LightningAddressInput.tsx create mode 100644 src/pages/secondary/WalletPage/QuickZapSwitch.tsx create mode 100644 src/pages/secondary/WalletPage/index.tsx create mode 100644 src/providers/NotificationProvider.tsx create mode 100644 src/providers/ZapProvider.tsx create mode 100644 src/services/lightning.service.ts diff --git a/package-lock.json b/package-lock.json index b9e84cc..b0e848b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "license": "MIT", "dependencies": { + "@getalby/bitcoin-connect-react": "^3.6.3", "@noble/hashes": "^1.6.1", "@radix-ui/react-alert-dialog": "^1.1.4", "@radix-ui/react-avatar": "^1.1.2", @@ -24,6 +25,7 @@ "@radix-ui/react-switch": "^1.1.2", "@radix-ui/react-tabs": "^1.1.2", "@radix-ui/react-toast": "^1.2.4", + "@webbtc/webln-types": "^3.0.0", "blurhash": "^2.0.5", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -2107,6 +2109,92 @@ "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.8.tgz", "integrity": "sha512-kym7SodPp8/wloecOpcmSnWJsK7M0E5Wg8UcFA+uO4B9s5d0ywXOEro/8HM9x0rW+TljRzul/14UYz3TleT3ig==" }, + "node_modules/@getalby/bitcoin-connect": { + "version": "3.6.3", + "resolved": "https://registry.npmjs.org/@getalby/bitcoin-connect/-/bitcoin-connect-3.6.3.tgz", + "integrity": "sha512-mS3hmKGF8P7RH06DFtawc6T738iwz+wGz28XR46tMDKGfZjPrpcCG7R8Wy7n0w1JBgf7Nec79edQ5cDM1Pbrrw==", + "dependencies": { + "@getalby/lightning-tools": "^5.1.0", + "@getalby/sdk": "^3.8.0", + "@lightninglabs/lnc-web": "^0.3.2-alpha", + "qrcode-generator": "^1.4.4", + "zustand": "^4.5.5" + } + }, + "node_modules/@getalby/bitcoin-connect-react": { + "version": "3.6.3", + "resolved": "https://registry.npmjs.org/@getalby/bitcoin-connect-react/-/bitcoin-connect-react-3.6.3.tgz", + "integrity": "sha512-tDomhNtXl94Z2YNQa52UpZUfZhdSwLEWgaOg6bCoLEJO0SqemUcKOrIIB/Y6DYm5XliktD7bLtvB4rJoFE74QQ==", + "dependencies": { + "@getalby/bitcoin-connect": "^3.6.3" + }, + "peerDependencies": { + "react": "^18.2.0" + } + }, + "node_modules/@getalby/lightning-tools": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@getalby/lightning-tools/-/lightning-tools-5.1.2.tgz", + "integrity": "sha512-BwGm8eGbPh59BVa1gI5yJMantBl/Fdps6X4p1ZACnmxz9vDINX8/3aFoOnDlF7yyA2boXWCsReVQSr26Q2yjiQ==", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "lightning", + "url": "lightning:hello@getalby.com" + } + }, + "node_modules/@getalby/sdk": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/@getalby/sdk/-/sdk-3.9.0.tgz", + "integrity": "sha512-qgNXr4FsX0a+PPvWgb112Q8h1/ov31zVP4LjsDYr5+W0CkrRbW9pQnsHPycVPLB5H8k5WVRRNkxYBBoWIBAwyw==", + "dependencies": { + "emittery": "^1.0.3", + "nostr-tools": "2.9.4" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "type": "lightning", + "url": "lightning:hello@getalby.com" + } + }, + "node_modules/@getalby/sdk/node_modules/@noble/hashes": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.1.tgz", + "integrity": "sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA==", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@getalby/sdk/node_modules/nostr-tools": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.9.4.tgz", + "integrity": "sha512-Powumwkp+EWbdK1T8IsEX4daTLQhtWJvitfZ6OP2BdU1jJZvNlUp3SQB541UYw4uc9jgLbxZW6EZSdZoSfIygQ==", + "dependencies": { + "@noble/ciphers": "^0.5.1", + "@noble/curves": "1.2.0", + "@noble/hashes": "1.3.1", + "@scure/base": "1.1.1", + "@scure/bip32": "1.3.1", + "@scure/bip39": "1.2.1" + }, + "optionalDependencies": { + "nostr-wasm": "v0.1.0" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -2237,6 +2325,20 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@lightninglabs/lnc-core": { + "version": "0.3.2-alpha", + "resolved": "https://registry.npmjs.org/@lightninglabs/lnc-core/-/lnc-core-0.3.2-alpha.tgz", + "integrity": "sha512-H6tG+X9txCIdxTR+GPsbImzP2Juo+6Uvq/Ipaijd7xPISzgEU4J4GNE5PEHuIZqbnBo1RmpuXnFG6dmsl3PTzQ==" + }, + "node_modules/@lightninglabs/lnc-web": { + "version": "0.3.2-alpha", + "resolved": "https://registry.npmjs.org/@lightninglabs/lnc-web/-/lnc-web-0.3.2-alpha.tgz", + "integrity": "sha512-3aCBugBf0NzczpJqmHn03Oq2Ju9W5n0+nOdAe+Y/Zhf6YLXdqG1PTJ2J+7TXncpiogfPYDCw95tVQqSi4Zi/ZA==", + "dependencies": { + "@lightninglabs/lnc-core": "0.3.2-alpha", + "crypto-js": "4.2.0" + } + }, "node_modules/@noble/ciphers": { "version": "0.5.3", "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-0.5.3.tgz", @@ -4034,6 +4136,15 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0" } }, + "node_modules/@webbtc/webln-types": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@webbtc/webln-types/-/webln-types-3.0.0.tgz", + "integrity": "sha512-aXfTHLKz5lysd+6xTeWl+qHNh/p3qVYbeLo+yDN5cUDmhie2ZoGvkppfWxzbGkcFBzb6dJyQ2/i2cbmDHas+zQ==", + "funding": { + "type": "lightning", + "url": "lightning:hello@getalby.com" + } + }, "node_modules/acorn": { "version": "8.14.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", @@ -4993,6 +5104,11 @@ "node": ">= 8" } }, + "node_modules/crypto-js": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz", + "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==" + }, "node_modules/crypto-random-string": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-2.0.0.tgz", @@ -5226,6 +5342,17 @@ "embla-carousel": "8.5.1" } }, + "node_modules/emittery": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/emittery/-/emittery-1.1.0.tgz", + "integrity": "sha512-rsX7ktqARv/6UQDgMaLfIqUWAEzzbCQiVh7V9rhDXp6c37yoJcks12NVD+XPkgl4AEavmNhVfrhGoqYwIsMYYA==", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sindresorhus/emittery?sponsor=1" + } + }, "node_modules/emoji-regex": { "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", @@ -7433,6 +7560,11 @@ "node": ">=6" } }, + "node_modules/qrcode-generator": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/qrcode-generator/-/qrcode-generator-1.4.4.tgz", + "integrity": "sha512-HM7yY8O2ilqhmULxGMpcHSF1EhJJ9yBj8gvDEuZ6M+KGJ0YY2hKpnXvRD+hZPLrDVck3ExIGhmPtSdcjC+guuw==" + }, "node_modules/qrcode.react": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz", @@ -8879,6 +9011,14 @@ } } }, + "node_modules/use-sync-external-store": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.4.0.tgz", + "integrity": "sha512-9WXSPC5fMv61vaupRkCKCxsPxBocVnwakBEkMIHHpkTTg6icbJtg6jzgtLDm4bl3cSHAca52rYWih0k4K3PfHw==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -9607,6 +9747,33 @@ "funding": { "url": "https://github.com/sponsors/colinhacks" } + }, + "node_modules/zustand": { + "version": "4.5.6", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.6.tgz", + "integrity": "sha512-ibr/n1hBzLLj5Y+yUcU7dYw8p6WnIVzdJbnX+1YpaScvZVF2ziugqHs+LAmHw4lWO9c/zRj+K1ncgWDQuthEdQ==", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } } } } diff --git a/package.json b/package.json index 00272c9..fb9904e 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "preview": "vite preview" }, "dependencies": { + "@getalby/bitcoin-connect-react": "^3.6.3", "@noble/hashes": "^1.6.1", "@radix-ui/react-alert-dialog": "^1.1.4", "@radix-ui/react-avatar": "^1.1.2", @@ -34,6 +35,7 @@ "@radix-ui/react-switch": "^1.1.2", "@radix-ui/react-tabs": "^1.1.2", "@radix-ui/react-toast": "^1.2.4", + "@webbtc/webln-types": "^3.0.0", "blurhash": "^2.0.5", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/src/App.tsx b/src/App.tsx index 141ca07..ca959b2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -11,24 +11,27 @@ import { NostrProvider } from './providers/NostrProvider' import { NoteStatsProvider } from './providers/NoteStatsProvider' import { RelaySetsProvider } from './providers/RelaySetsProvider' import { ScreenSizeProvider } from './providers/ScreenSizeProvider' +import { ZapProvider } from './providers/ZapProvider' export default function App(): JSX.Element { return ( - - - - - - - - - - - - + + + + + + + + + + + + + + diff --git a/src/PageManager.tsx b/src/PageManager.tsx index 475e9c7..481de5d 100644 --- a/src/PageManager.tsx +++ b/src/PageManager.tsx @@ -17,6 +17,7 @@ import { import ExplorePage from './pages/primary/ExplorePage' import MePage from './pages/primary/MePage' import NotificationListPage from './pages/primary/NotificationListPage' +import { NotificationProvider } from './providers/NotificationProvider' import { useScreenSize } from './providers/ScreenSizeProvider' import { routes } from './routes' @@ -226,28 +227,30 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { : 0 }} > - {!!secondaryStack.length && - secondaryStack.map((item, index) => ( + + {!!secondaryStack.length && + secondaryStack.map((item, index) => ( +
+ {item.component} +
+ ))} + {primaryPages.map(({ name, element }) => (
- {item.component} + {element}
))} - {primaryPages.map(({ name, element }) => ( -
- {element} -
- ))} +
) @@ -267,38 +270,40 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { currentIndex: secondaryStack.length ? secondaryStack[secondaryStack.length - 1].index : 0 }} > -
- - -
-
- {primaryPages.map(({ name, element }) => ( -
- {element} -
- ))} -
-
- {secondaryStack.map((item, index) => ( -
- {item.component} + +
+ + +
+
+ {primaryPages.map(({ name, element }) => ( +
+ {element} +
+ ))} +
+
+ {secondaryStack.map((item, index) => ( +
+ {item.component} +
+ ))} +
+
- ))} -
-
-
+
) diff --git a/src/components/AboutInfoDialog/index.tsx b/src/components/AboutInfoDialog/index.tsx index f57e51e..27d699a 100644 --- a/src/components/AboutInfoDialog/index.tsx +++ b/src/components/AboutInfoDialog/index.tsx @@ -1,10 +1,13 @@ import { Dialog, DialogContent, DialogTrigger } from '@/components/ui/dialog' +import { Drawer, DrawerContent, DrawerTrigger } from '@/components/ui/drawer' +import { CODY_PUBKEY } from '@/constants' import { useScreenSize } from '@/providers/ScreenSizeProvider' -import { Drawer, DrawerContent, DrawerTrigger } from '../ui/drawer' +import { useState } from 'react' import Username from '../Username' export default function AboutInfoDialog({ children }: { children: React.ReactNode }) { const { isSmallScreen } = useScreenSize() + const [open, setOpen] = useState(false) const content = ( <> @@ -13,12 +16,7 @@ export default function AboutInfoDialog({ children }: { children: React.ReactNod A beautiful nostr client focused on browsing relay feeds
- Made by{' '} - + Made by
Source code:{' '} @@ -30,30 +28,26 @@ export default function AboutInfoDialog({ children }: { children: React.ReactNod > GitHub -
-
- If you like this project, you can buy me a coffee ☕️
-
⚡️ codytseng@getalby.com ⚡️
-
-
- Version: v{__APP_VERSION__} ({__GIT_COMMIT__}) +
+ If you like Jumble, please consider giving it a star ⭐ +
) if (isSmallScreen) { return ( - + {children} -
{content}
+
{content}
) } return ( - + {children} {content} diff --git a/src/components/BottomNavigationBar/NotificationsButton.tsx b/src/components/BottomNavigationBar/NotificationsButton.tsx index a7cb2c8..2497baf 100644 --- a/src/components/BottomNavigationBar/NotificationsButton.tsx +++ b/src/components/BottomNavigationBar/NotificationsButton.tsx @@ -1,16 +1,23 @@ import { usePrimaryPage } from '@/PageManager' +import { useNotification } from '@/providers/NotificationProvider' import { Bell } from 'lucide-react' import BottomNavigationBarItem from './BottomNavigationBarItem' export default function NotificationsButton() { const { navigate, current } = usePrimaryPage() + const { hasNewNotification } = useNotification() return ( navigate('notifications')} > - +
+ + {hasNewNotification && ( +
+ )} +
) } diff --git a/src/components/Donation/index.tsx b/src/components/Donation/index.tsx new file mode 100644 index 0000000..1361515 --- /dev/null +++ b/src/components/Donation/index.tsx @@ -0,0 +1,49 @@ +import { Button } from '@/components/ui/button' +import { CODY_PUBKEY } from '@/constants' +import { cn } from '@/lib/utils' +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import ZapDialog from '../ZapDialog' + +export default function Donation({ className }: { className?: string }) { + const { t } = useTranslation() + const [open, setOpen] = useState(false) + const [donationAmount, setDonationAmount] = useState(undefined) + + return ( +
+
{t('Enjoying Jumble?')}
+
+ {t('Your donation helps me maintain Jumble and make it better! 😊')} +
+
+ {[ + { amount: 1000, text: '☕️ 1k' }, + { amount: 10000, text: '🍜 10k' }, + { amount: 100000, text: '🍣 100k' }, + { amount: 1000000, text: '✈️ 1M' } + ].map(({ amount, text }) => { + return ( + + ) + })} +
+ +
+ ) +} diff --git a/src/components/Embedded/EmbeddedMention.tsx b/src/components/Embedded/EmbeddedMention.tsx index ded95fe..540b699 100644 --- a/src/components/Embedded/EmbeddedMention.tsx +++ b/src/components/Embedded/EmbeddedMention.tsx @@ -13,14 +13,7 @@ export function EmbeddedMention({ userId }: { userId: string }) { } export function EmbeddedMentionText({ userId }: { userId: string }) { - return ( - - ) + return } export const embeddedNostrNpubRenderer: TEmbeddedRenderer = { diff --git a/src/components/FollowButton/index.tsx b/src/components/FollowButton/index.tsx index 0dfacbc..d44ae8c 100644 --- a/src/components/FollowButton/index.tsx +++ b/src/components/FollowButton/index.tsx @@ -10,11 +10,11 @@ export default function FollowButton({ pubkey }: { pubkey: string }) { const { t } = useTranslation() const { toast } = useToast() const { pubkey: accountPubkey, checkLogin } = useNostr() - const { followListEvent, followings, isFetching, follow, unfollow } = useFollowList() + const { followings, follow, unfollow } = useFollowList() const [updating, setUpdating] = useState(false) const isFollowing = useMemo(() => followings.includes(pubkey), [followings, pubkey]) - if (!accountPubkey || isFetching || (pubkey && pubkey === accountPubkey)) return null + if (!accountPubkey || (pubkey && pubkey === accountPubkey)) return null const handleFollow = async (e: React.MouseEvent) => { e.stopPropagation() @@ -39,7 +39,7 @@ export default function FollowButton({ pubkey }: { pubkey: string }) { const handleUnfollow = async (e: React.MouseEvent) => { e.stopPropagation() checkLogin(async () => { - if (!isFollowing || !followListEvent) return + if (!isFollowing) return setUpdating(true) try { diff --git a/src/components/Image/index.tsx b/src/components/Image/index.tsx index 0a5dbe1..26124d3 100644 --- a/src/components/Image/index.tsx +++ b/src/components/Image/index.tsx @@ -61,6 +61,7 @@ export default function Image({ )} onLoad={() => { setIsLoading(false) + setHasError(false) setTimeout(() => setDisplayBlurHash(false), 500) }} onError={() => { diff --git a/src/components/Nip05/index.tsx b/src/components/Nip05/index.tsx index 83efbcf..c4fda4a 100644 --- a/src/components/Nip05/index.tsx +++ b/src/components/Nip05/index.tsx @@ -23,7 +23,7 @@ export default function Nip05({ pubkey }: { pubkey: string }) { return ( nip05Name && nip05Domain && ( -
+
{nip05Name !== '_' ? (
@{nip05Name}
) : null} @@ -33,7 +33,7 @@ export default function Nip05({ pubkey }: { pubkey: string }) { className={`flex items-center space-x-1 hover:underline truncate ${nip05IsVerified ? 'text-highlight' : 'text-muted-foreground'}`} rel="noreferrer" > - {nip05IsVerified ? : } + {nip05IsVerified ? : }
{nip05Domain}
diff --git a/src/components/NoteCard/RepostDescription.tsx b/src/components/NoteCard/RepostDescription.tsx index c914492..e063ccd 100644 --- a/src/components/NoteCard/RepostDescription.tsx +++ b/src/components/NoteCard/RepostDescription.tsx @@ -17,7 +17,7 @@ export default function RepostDescription({
-
{t('reposted')}
+
{t('reposted')}
) } diff --git a/src/components/NoteList/index.tsx b/src/components/NoteList/index.tsx index 48daa31..c6d6f6a 100644 --- a/src/components/NoteList/index.tsx +++ b/src/components/NoteList/index.tsx @@ -22,7 +22,7 @@ import PictureNoteCard from '../PictureNoteCard' const LIMIT = 100 const ALGO_LIMIT = 500 -const SHOW_COUNT = 20 +const SHOW_COUNT = 10 export default function NoteList({ relayUrls, @@ -266,7 +266,7 @@ function ListModeSwitch({ return (
800 ? '-translate-y-[calc(100%+12rem)]' : '' )} > diff --git a/src/components/NoteStats/LikeButton.tsx b/src/components/NoteStats/LikeButton.tsx index d94d6c4..c57144b 100644 --- a/src/components/NoteStats/LikeButton.tsx +++ b/src/components/NoteStats/LikeButton.tsx @@ -5,57 +5,44 @@ import { useNoteStats } from '@/providers/NoteStatsProvider' import client from '@/services/client.service' import { Heart, Loader } from 'lucide-react' import { Event } from 'nostr-tools' -import { useEffect, useMemo, useState } from 'react' +import { useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { formatCount } from './utils' -export default function LikeButton({ - event, - canFetch = false -}: { - event: Event - canFetch?: boolean -}) { +export default function LikeButton({ event }: { event: Event }) { const { t } = useTranslation() - const { publish, checkLogin } = useNostr() - const { noteStatsMap, fetchNoteLikedStatus, fetchNoteLikeCount, markNoteAsLiked } = useNoteStats() + const { pubkey, publish, checkLogin } = useNostr() + const { noteStatsMap, updateNoteStatsByEvents, fetchNoteStats } = useNoteStats() const [liking, setLiking] = useState(false) - const { likeCount, hasLiked } = useMemo( - () => noteStatsMap.get(event.id) ?? {}, - [noteStatsMap, event.id] - ) + const { likeCount, hasLiked } = useMemo(() => { + const stats = noteStatsMap.get(event.id) || {} + return { likeCount: stats.likes?.size, hasLiked: pubkey ? stats.likes?.has(pubkey) : false } + }, [noteStatsMap, event, pubkey]) const canLike = !hasLiked && !liking - useEffect(() => { - if (!canFetch) return - - if (likeCount === undefined) { - fetchNoteLikeCount(event) - } - if (hasLiked === undefined) { - fetchNoteLikedStatus(event) - } - }, [canFetch, event]) - const like = async (e: React.MouseEvent) => { e.stopPropagation() checkLogin(async () => { - if (!canLike) return + if (!canLike || !pubkey) return setLiking(true) const timer = setTimeout(() => setLiking(false), 5000) try { - const [liked] = await Promise.all([ - hasLiked === undefined ? fetchNoteLikedStatus(event) : hasLiked, - likeCount === undefined ? fetchNoteLikeCount(event) : likeCount - ]) - if (liked) return + const noteStats = noteStatsMap.get(event.id) + const hasLiked = noteStats?.likes?.has(pubkey) + if (hasLiked) return + if (!noteStats?.updatedAt) { + const stats = await fetchNoteStats(event) + if (stats?.likes?.has(pubkey)) return + } const targetRelayList = await client.fetchRelayList(event.pubkey) const reaction = createReactionDraftEvent(event) - await publish(reaction, { additionalRelayUrls: targetRelayList.read.slice(0, 4) }) - markNoteAsLiked(event.id) + const evt = await publish(reaction, { + additionalRelayUrls: targetRelayList.read.slice(0, 4) + }) + updateNoteStatsByEvents([evt]) } catch (error) { console.error('like failed', error) } finally { diff --git a/src/components/NoteStats/RepostButton.tsx b/src/components/NoteStats/RepostButton.tsx index 018c8e9..2dbc5bc 100644 --- a/src/components/NoteStats/RepostButton.tsx +++ b/src/components/NoteStats/RepostButton.tsx @@ -12,60 +12,47 @@ import { useNoteStats } from '@/providers/NoteStatsProvider' import client from '@/services/client.service' import { Loader, PencilLine, Repeat } from 'lucide-react' import { Event } from 'nostr-tools' -import { useEffect, useMemo, useState } from 'react' +import { useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import PostEditor from '../PostEditor' import { formatCount } from './utils' -export default function RepostButton({ - event, - canFetch = false -}: { - event: Event - canFetch?: boolean -}) { +export default function RepostButton({ event }: { event: Event }) { const { t } = useTranslation() - const { publish, checkLogin } = useNostr() - const { noteStatsMap, fetchNoteRepostCount, fetchNoteRepostedStatus, markNoteAsReposted } = - useNoteStats() + const { publish, checkLogin, pubkey } = useNostr() + const { noteStatsMap, updateNoteStatsByEvents, fetchNoteStats } = useNoteStats() const [reposting, setReposting] = useState(false) const [isPostDialogOpen, setIsPostDialogOpen] = useState(false) - const { repostCount, hasReposted } = useMemo( - () => noteStatsMap.get(event.id) ?? {}, - [noteStatsMap, event.id] - ) - const canRepost = !hasReposted && !reposting - - useEffect(() => { - if (!canFetch) return - - if (repostCount === undefined) { - fetchNoteRepostCount(event) - } - if (hasReposted === undefined) { - fetchNoteRepostedStatus(event) + const { repostCount, hasReposted } = useMemo(() => { + const stats = noteStatsMap.get(event.id) || {} + return { + repostCount: stats.reposts?.size, + hasReposted: pubkey ? stats.reposts?.has(pubkey) : false } - }, [canFetch, event]) + }, [noteStatsMap, event.id]) + const canRepost = !hasReposted && !reposting const repost = async (e: React.MouseEvent) => { e.stopPropagation() checkLogin(async () => { - if (!canRepost) return + if (!canRepost || !pubkey) return setReposting(true) const timer = setTimeout(() => setReposting(false), 5000) try { - const [reposted] = await Promise.all([ - hasReposted === undefined ? fetchNoteRepostedStatus(event) : hasReposted, - repostCount === undefined ? fetchNoteRepostCount(event) : repostCount - ]) - if (reposted) return + const noteStats = noteStatsMap.get(event.id) + const hasReposted = noteStats?.reposts?.has(pubkey) + if (hasReposted) return + if (!noteStats?.updatedAt) { + const stats = await fetchNoteStats(event) + if (stats?.reposts?.has(pubkey)) return + } const targetRelayList = await client.fetchRelayList(event.pubkey) const repost = createRepostDraftEvent(event) - await publish(repost, { additionalRelayUrls: targetRelayList.read.slice(0, 5) }) - markNoteAsReposted(event.id) + const evt = await publish(repost, { additionalRelayUrls: targetRelayList.read.slice(0, 5) }) + updateNoteStatsByEvents([evt]) } catch (error) { console.error('repost failed', error) } finally { diff --git a/src/components/NoteStats/TopZaps.tsx b/src/components/NoteStats/TopZaps.tsx new file mode 100644 index 0000000..0f6b8ae --- /dev/null +++ b/src/components/NoteStats/TopZaps.tsx @@ -0,0 +1,43 @@ +import { useSecondaryPage } from '@/PageManager' +import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area' +import { formatAmount } from '@/lib/lightning' +import { toProfile } from '@/lib/link' +import { useNoteStats } from '@/providers/NoteStatsProvider' +import { Zap } from 'lucide-react' +import { Event } from 'nostr-tools' +import { useMemo } from 'react' +import { SimpleUserAvatar } from '../UserAvatar' + +export default function TopZaps({ event }: { event: Event }) { + const { push } = useSecondaryPage() + const { noteStatsMap } = useNoteStats() + const topZaps = useMemo(() => { + const stats = noteStatsMap.get(event.id) || {} + return stats.zaps?.slice(0, 10) || [] + }, [noteStatsMap, event]) + + if (!topZaps.length) return null + + return ( + +
+ {topZaps.map((zap) => ( +
{ + e.stopPropagation() + push(toProfile(zap.pubkey)) + }} + > + + +
{formatAmount(zap.amount)}
+
{zap.comment}
+
+ ))} +
+ +
+ ) +} diff --git a/src/components/NoteStats/ZapButton.tsx b/src/components/NoteStats/ZapButton.tsx new file mode 100644 index 0000000..5d08cb9 --- /dev/null +++ b/src/components/NoteStats/ZapButton.tsx @@ -0,0 +1,147 @@ +import { useToast } from '@/hooks' +import { getLightningAddressFromProfile } from '@/lib/lightning' +import { cn } from '@/lib/utils' +import { useNostr } from '@/providers/NostrProvider' +import { useNoteStats } from '@/providers/NoteStatsProvider' +import { useZap } from '@/providers/ZapProvider' +import client from '@/services/client.service' +import lightning from '@/services/lightning.service' +import { Loader, Zap } from 'lucide-react' +import { Event } from 'nostr-tools' +import { MouseEvent, TouchEvent, useEffect, useMemo, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import ZapDialog from '../ZapDialog' + +export default function ZapButton({ event }: { event: Event }) { + const { t } = useTranslation() + const { toast } = useToast() + const { checkLogin, pubkey } = useNostr() + const { noteStatsMap, addZap } = useNoteStats() + const { defaultZapSats, defaultZapComment, quickZap } = useZap() + const [openZapDialog, setOpenZapDialog] = useState(false) + const [zapping, setZapping] = useState(false) + const { zapAmount, hasZapped } = useMemo(() => { + const stats = noteStatsMap.get(event.id) || {} + return { + zapAmount: stats.zaps?.reduce((acc, zap) => acc + zap.amount, 0), + hasZapped: pubkey ? stats.zaps?.some((zap) => zap.pubkey === pubkey) : false + } + }, [noteStatsMap, event, pubkey]) + const [showButton, setShowButton] = useState(false) + const timerRef = useRef | null>(null) + const isLongPressRef = useRef(false) + + useEffect(() => { + client.fetchProfile(event.pubkey).then((profile) => { + if (!profile) return + const lightningAddress = getLightningAddressFromProfile(profile) + if (lightningAddress) setShowButton(true) + }) + }, [event]) + + if (!showButton) return null + + const handleZap = async () => { + try { + if (!pubkey) { + throw new Error('You need to be logged in to zap') + } + setZapping(true) + const { invoice } = await lightning.zap( + pubkey, + event.pubkey, + defaultZapSats, + defaultZapComment, + event.id + ) + addZap(event.id, invoice, defaultZapSats, defaultZapComment) + } catch (error) { + toast({ + title: t('Zap failed'), + description: (error as Error).message, + variant: 'destructive' + }) + } finally { + setZapping(false) + } + } + + const handleClickStart = (e: MouseEvent | TouchEvent) => { + e.stopPropagation() + e.preventDefault() + isLongPressRef.current = false + + if (quickZap) { + timerRef.current = setTimeout(() => { + isLongPressRef.current = true + checkLogin(() => { + setOpenZapDialog(true) + setZapping(true) + }) + }, 500) + } + } + + const handleClickEnd = (e: MouseEvent | TouchEvent) => { + e.stopPropagation() + e.preventDefault() + if (timerRef.current) { + clearTimeout(timerRef.current) + } + + if (!quickZap) { + checkLogin(() => { + setOpenZapDialog(true) + setZapping(true) + }) + } else if (!isLongPressRef.current) { + checkLogin(() => handleZap()) + } + isLongPressRef.current = false + } + + const handleMouseLeave = () => { + if (timerRef.current) { + clearTimeout(timerRef.current) + } + } + + return ( + <> + + { + setOpenZapDialog(open) + setZapping(open) + }} + pubkey={event.pubkey} + eventId={event.id} + /> + + ) +} + +function formatAmount(amount: number) { + if (amount < 1000) return amount + if (amount < 1000000) return `${Math.round(amount / 100) / 10}k` + return `${Math.round(amount / 100000) / 10}M` +} diff --git a/src/components/NoteStats/index.tsx b/src/components/NoteStats/index.tsx index 4973c8c..16c449b 100644 --- a/src/components/NoteStats/index.tsx +++ b/src/components/NoteStats/index.tsx @@ -1,10 +1,14 @@ import { cn } from '@/lib/utils' +import { useNoteStats } from '@/providers/NoteStatsProvider' import { Event } from 'nostr-tools' +import { useEffect } from 'react' import LikeButton from './LikeButton' import NoteOptions from './NoteOptions' import ReplyButton from './ReplyButton' import RepostButton from './RepostButton' import SeenOnButton from './SeenOnButton' +import TopZaps from './TopZaps' +import ZapButton from './ZapButton' export default function NoteStats({ event, @@ -17,16 +21,27 @@ export default function NoteStats({ fetchIfNotExisting?: boolean variant?: 'note' | 'reply' }) { + const { fetchNoteStats } = useNoteStats() + + useEffect(() => { + if (!fetchIfNotExisting) return + fetchNoteStats(event) + }, [event, fetchIfNotExisting]) + return ( -
-
e.stopPropagation()}> - - - -
-
e.stopPropagation()}> - - +
+ +
+
e.stopPropagation()}> + + + + +
+
e.stopPropagation()}> + + +
) diff --git a/src/components/NotificationList/NotificationItem/CommentNotification.tsx b/src/components/NotificationList/NotificationItem/CommentNotification.tsx new file mode 100644 index 0000000..b07e9af --- /dev/null +++ b/src/components/NotificationList/NotificationItem/CommentNotification.tsx @@ -0,0 +1,48 @@ +import { PICTURE_EVENT_KIND } from '@/constants' +import { toNote } from '@/lib/link' +import { tagNameEquals } from '@/lib/tag' +import { cn } from '@/lib/utils' +import { useSecondaryPage } from '@/PageManager' +import { MessageCircle } from 'lucide-react' +import { Event, kinds } from 'nostr-tools' +import ContentPreview from '../../ContentPreview' +import { FormattedTimestamp } from '../../FormattedTimestamp' +import UserAvatar from '../../UserAvatar' + +export function CommentNotification({ + notification, + isNew = false +}: { + notification: Event + isNew?: boolean +}) { + const { push } = useSecondaryPage() + const rootEventId = notification.tags.find(tagNameEquals('E'))?.[1] + const rootPubkey = notification.tags.find(tagNameEquals('P'))?.[1] + const rootKind = notification.tags.find(tagNameEquals('K'))?.[1] + if ( + !rootEventId || + !rootPubkey || + !rootKind || + ![kinds.ShortTextNote, PICTURE_EVENT_KIND].includes(parseInt(rootKind)) + ) { + return null + } + + return ( +
push(toNote({ id: rootEventId, pubkey: rootPubkey }))} + > + + + +
+ +
+
+ ) +} diff --git a/src/components/NotificationList/NotificationItem/ReactionNotification.tsx b/src/components/NotificationList/NotificationItem/ReactionNotification.tsx new file mode 100644 index 0000000..c9c4cac --- /dev/null +++ b/src/components/NotificationList/NotificationItem/ReactionNotification.tsx @@ -0,0 +1,60 @@ +import { PICTURE_EVENT_KIND } from '@/constants' +import { useFetchEvent } from '@/hooks' +import { toNote } from '@/lib/link' +import { tagNameEquals } from '@/lib/tag' +import { cn } from '@/lib/utils' +import { useSecondaryPage } from '@/PageManager' +import { useNostr } from '@/providers/NostrProvider' +import { Heart } from 'lucide-react' +import { Event, kinds } from 'nostr-tools' +import { useMemo } from 'react' +import ContentPreview from '../../ContentPreview' +import { FormattedTimestamp } from '../../FormattedTimestamp' +import UserAvatar from '../../UserAvatar' + +export function ReactionNotification({ + notification, + isNew = false +}: { + notification: Event + isNew?: boolean +}) { + const { push } = useSecondaryPage() + const { pubkey } = useNostr() + const eventId = useMemo(() => { + const targetPubkey = notification.tags.findLast(tagNameEquals('p'))?.[1] + if (targetPubkey !== pubkey) return undefined + + const eTag = notification.tags.findLast(tagNameEquals('e')) + return eTag?.[1] + }, [notification, pubkey]) + const { event } = useFetchEvent(eventId) + if (!event || !eventId || ![kinds.ShortTextNote, PICTURE_EVENT_KIND].includes(event.kind)) { + return null + } + + return ( +
push(toNote(event))} + > +
+ +
+ {!notification.content || notification.content === '+' ? ( + + ) : ( + notification.content + )} +
+ +
+
+ +
+
+ ) +} diff --git a/src/components/NotificationList/NotificationItem/ReplyNotification.tsx b/src/components/NotificationList/NotificationItem/ReplyNotification.tsx new file mode 100644 index 0000000..4cf51b9 --- /dev/null +++ b/src/components/NotificationList/NotificationItem/ReplyNotification.tsx @@ -0,0 +1,34 @@ +import { toNote } from '@/lib/link' +import { cn } from '@/lib/utils' +import { useSecondaryPage } from '@/PageManager' +import { MessageCircle } from 'lucide-react' +import { Event } from 'nostr-tools' +import ContentPreview from '../../ContentPreview' +import { FormattedTimestamp } from '../../FormattedTimestamp' +import UserAvatar from '../../UserAvatar' + +export function ReplyNotification({ + notification, + isNew = false +}: { + notification: Event + isNew?: boolean +}) { + const { push } = useSecondaryPage() + return ( +
push(toNote(notification))} + > + + + +
+ +
+
+ ) +} diff --git a/src/components/NotificationList/NotificationItem/RepostNotification.tsx b/src/components/NotificationList/NotificationItem/RepostNotification.tsx new file mode 100644 index 0000000..ee58feb --- /dev/null +++ b/src/components/NotificationList/NotificationItem/RepostNotification.tsx @@ -0,0 +1,49 @@ +import { toNote } from '@/lib/link' +import { cn } from '@/lib/utils' +import { useSecondaryPage } from '@/PageManager' +import client from '@/services/client.service' +import { Repeat } from 'lucide-react' +import { Event, validateEvent } from 'nostr-tools' +import { useMemo } from 'react' +import ContentPreview from '../../ContentPreview' +import { FormattedTimestamp } from '../../FormattedTimestamp' +import UserAvatar from '../../UserAvatar' + +export function RepostNotification({ + notification, + isNew = false +}: { + notification: Event + isNew?: boolean +}) { + const { push } = useSecondaryPage() + const event = useMemo(() => { + try { + const event = JSON.parse(notification.content) as Event + const isValid = validateEvent(event) + if (!isValid) return null + client.addEventToCache(event) + return event + } catch { + return null + } + }, [notification.content]) + if (!event) return null + + return ( +
push(toNote(event))} + > + + + +
+ +
+
+ ) +} diff --git a/src/components/NotificationList/NotificationItem/ZapNotification.tsx b/src/components/NotificationList/NotificationItem/ZapNotification.tsx new file mode 100644 index 0000000..79b9ac1 --- /dev/null +++ b/src/components/NotificationList/NotificationItem/ZapNotification.tsx @@ -0,0 +1,56 @@ +import { useFetchEvent } from '@/hooks' +import { extractZapInfoFromReceipt } from '@/lib/event' +import { formatAmount } from '@/lib/lightning' +import { toNote, toProfile } from '@/lib/link' +import { cn } from '@/lib/utils' +import { useSecondaryPage } from '@/PageManager' +import { useNostr } from '@/providers/NostrProvider' +import { Zap } from 'lucide-react' +import { Event } from 'nostr-tools' +import { useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import ContentPreview from '../../ContentPreview' +import { FormattedTimestamp } from '../../FormattedTimestamp' +import UserAvatar from '../../UserAvatar' + +export function ZapNotification({ + notification, + isNew = false +}: { + notification: Event + isNew?: boolean +}) { + const { t } = useTranslation() + const { push } = useSecondaryPage() + const { pubkey } = useNostr() + const { senderPubkey, eventId, amount, comment } = useMemo( + () => extractZapInfoFromReceipt(notification) ?? ({} as any), + [notification] + ) + const { event } = useFetchEvent(eventId) + + if (!senderPubkey || !amount) return null + + return ( +
(event ? push(toNote(event)) : pubkey ? push(toProfile(pubkey)) : null)} + > +
+ + +
+ {formatAmount(amount)} {t('sats')} +
+ {comment &&
{comment}
} + +
+
+ +
+
+ ) +} diff --git a/src/components/NotificationList/NotificationItem/index.tsx b/src/components/NotificationList/NotificationItem/index.tsx new file mode 100644 index 0000000..784f948 --- /dev/null +++ b/src/components/NotificationList/NotificationItem/index.tsx @@ -0,0 +1,37 @@ +import { COMMENT_EVENT_KIND } from '@/constants' +import { useMuteList } from '@/providers/MuteListProvider' +import { Event, kinds } from 'nostr-tools' +import { CommentNotification } from './CommentNotification' +import { ReactionNotification } from './ReactionNotification' +import { ReplyNotification } from './ReplyNotification' +import { RepostNotification } from './RepostNotification' +import { ZapNotification } from './ZapNotification' + +export function NotificationItem({ + notification, + isNew = false +}: { + notification: Event + isNew?: boolean +}) { + const { mutePubkeys } = useMuteList() + if (mutePubkeys.includes(notification.pubkey)) { + return null + } + if (notification.kind === kinds.Reaction) { + return + } + if (notification.kind === kinds.ShortTextNote) { + return + } + if (notification.kind === kinds.Repost) { + return + } + if (notification.kind === kinds.Zap) { + return + } + if (notification.kind === COMMENT_EVENT_KIND) { + return + } + return null +} diff --git a/src/components/NotificationList/index.tsx b/src/components/NotificationList/index.tsx index 5eb62b2..0f179a9 100644 --- a/src/components/NotificationList/index.tsx +++ b/src/components/NotificationList/index.tsx @@ -1,15 +1,15 @@ +import { Separator } from '@/components/ui/separator' import { Skeleton } from '@/components/ui/skeleton' -import { BIG_RELAY_URLS, COMMENT_EVENT_KIND, PICTURE_EVENT_KIND } from '@/constants' -import { useFetchEvent } from '@/hooks' -import { toNote } from '@/lib/link' -import { tagNameEquals } from '@/lib/tag' -import { useSecondaryPage } from '@/PageManager' -import { useMuteList } from '@/providers/MuteListProvider' +import { BIG_RELAY_URLS, COMMENT_EVENT_KIND } from '@/constants' +import { cn } from '@/lib/utils' +import { useDeepBrowsing } from '@/providers/DeepBrowsingProvider' import { useNostr } from '@/providers/NostrProvider' +import { useNoteStats } from '@/providers/NoteStatsProvider' import client from '@/services/client.service' +import storage from '@/services/local-storage.service' +import { TNotificationType } from '@/types' import dayjs from 'dayjs' -import { Heart, MessageCircle, Repeat, ThumbsUp } from 'lucide-react' -import { Event, kinds, validateEvent } from 'nostr-tools' +import { Event, kinds } from 'nostr-tools' import { forwardRef, useCallback, @@ -21,9 +21,7 @@ import { } from 'react' import { useTranslation } from 'react-i18next' import PullToRefresh from 'react-simple-pull-to-refresh' -import ContentPreview from '../ContentPreview' -import { FormattedTimestamp } from '../FormattedTimestamp' -import UserAvatar from '../UserAvatar' +import { NotificationItem } from './NotificationItem' const LIMIT = 100 const SHOW_COUNT = 30 @@ -31,13 +29,30 @@ const SHOW_COUNT = 30 const NotificationList = forwardRef((_, ref) => { const { t } = useTranslation() const { pubkey } = useNostr() + const { updateNoteStatsByEvents } = useNoteStats() + const [notificationType, setNotificationType] = useState('all') + const [lastReadTime, setLastReadTime] = useState(0) const [refreshCount, setRefreshCount] = useState(0) const [timelineKey, setTimelineKey] = useState(undefined) const [refreshing, setRefreshing] = useState(true) const [notifications, setNotifications] = useState([]) + const [newNotifications, setNewNotifications] = useState([]) + const [oldNotifications, setOldNotifications] = useState([]) const [showCount, setShowCount] = useState(SHOW_COUNT) const [until, setUntil] = useState(dayjs().unix()) const bottomRef = useRef(null) + const filterKinds = useMemo(() => { + switch (notificationType) { + case 'mentions': + return [kinds.ShortTextNote, COMMENT_EVENT_KIND] + case 'reactions': + return [kinds.Reaction, kinds.Repost] + case 'zaps': + return [kinds.Zap] + default: + return [kinds.ShortTextNote, kinds.Repost, kinds.Reaction, kinds.Zap, COMMENT_EVENT_KIND] + } + }, [notificationType]) useImperativeHandle( ref, () => ({ @@ -57,6 +72,9 @@ const NotificationList = forwardRef((_, ref) => { const init = async () => { setRefreshing(true) + setNotifications([]) + setShowCount(SHOW_COUNT) + setLastReadTime(storage.getLastReadNotificationTime(pubkey)) const relayList = await client.fetchRelayList(pubkey) let eventCount = 0 const { closer, timelineKey } = await client.subscribeTimeline( @@ -65,7 +83,7 @@ const NotificationList = forwardRef((_, ref) => { : relayList.read.concat(BIG_RELAY_URLS).slice(0, 4), { '#p': [pubkey], - kinds: [kinds.ShortTextNote, kinds.Repost, kinds.Reaction, COMMENT_EVENT_KIND], + kinds: filterKinds, limit: LIMIT }, { @@ -76,6 +94,7 @@ const NotificationList = forwardRef((_, ref) => { if (eosed) { setRefreshing(false) setUntil(events.length > 0 ? events[events.length - 1].created_at - 1 : undefined) + updateNoteStatsByEvents(events) } }, onNew: (event) => { @@ -89,6 +108,7 @@ const NotificationList = forwardRef((_, ref) => { } return [...oldEvents.slice(0, index), event, ...oldEvents.slice(index)] }) + updateNoteStatsByEvents([event]) } } ) @@ -100,7 +120,19 @@ const NotificationList = forwardRef((_, ref) => { return () => { promise.then((closer) => closer?.()) } - }, [pubkey, refreshCount]) + }, [pubkey, refreshCount, filterKinds]) + + useEffect(() => { + const visibleNotifications = notifications.slice(0, showCount) + const index = visibleNotifications.findIndex((event) => event.created_at <= lastReadTime) + if (index === -1) { + setNewNotifications(visibleNotifications) + setOldNotifications([]) + } else { + setNewNotifications(visibleNotifications.slice(0, index)) + setOldNotifications(visibleNotifications.slice(index)) + } + }, [notifications, lastReadTime, showCount]) const loadMore = useCallback(async () => { if (showCount < notifications.length) { @@ -153,160 +185,103 @@ const NotificationList = forwardRef((_, ref) => { }, [loadMore]) return ( - { - setRefreshCount((count) => count + 1) - await new Promise((resolve) => setTimeout(resolve, 1000)) - }} - pullingContent="" - > -
- {notifications.slice(0, showCount).map((notification) => ( - - ))} -
- {until || refreshing ? ( -
-
- - -
+
+ { + setShowCount(SHOW_COUNT) + setNotificationType(type) + }} + /> + { + setRefreshCount((count) => count + 1) + await new Promise((resolve) => setTimeout(resolve, 1000)) + }} + pullingContent="" + > +
+ {newNotifications.map((notification) => ( + + ))} + {!!newNotifications.length && ( +
+ + + {t('Earlier notifications')} +
- ) : ( - t('no more notifications') )} + {oldNotifications.map((notification) => ( + + ))} +
+ {until || refreshing ? ( +
+
+ + +
+
+ ) : ( + t('no more notifications') + )} +
-
- + +
) }) NotificationList.displayName = 'NotificationList' export default NotificationList -function NotificationItem({ notification }: { notification: Event }) { - const { mutePubkeys } = useMuteList() - if (mutePubkeys.includes(notification.pubkey)) { - return null - } - if (notification.kind === kinds.Reaction) { - return - } - if (notification.kind === kinds.ShortTextNote) { - return - } - if (notification.kind === kinds.Repost) { - return - } - if (notification.kind === COMMENT_EVENT_KIND) { - return - } - return null -} - -function ReactionNotification({ notification }: { notification: Event }) { - const { push } = useSecondaryPage() - const { pubkey } = useNostr() - const eventId = useMemo(() => { - const targetPubkey = notification.tags.findLast(tagNameEquals('p'))?.[1] - if (targetPubkey !== pubkey) return undefined - - const eTag = notification.tags.findLast(tagNameEquals('e')) - return eTag?.[1] - }, [notification, pubkey]) - const { event } = useFetchEvent(eventId) - if (!event || !eventId || ![kinds.ShortTextNote, PICTURE_EVENT_KIND].includes(event.kind)) { - return null - } - - return ( -
push(toNote(event))} - > -
- - -
{notification.content === '+' ? : notification.content}
- -
-
- -
-
- ) -} - -function ReplyNotification({ notification }: { notification: Event }) { - const { push } = useSecondaryPage() - return ( -
push(toNote(notification))} - > - - - -
- -
-
- ) -} - -function RepostNotification({ notification }: { notification: Event }) { - const { push } = useSecondaryPage() - const event = useMemo(() => { - try { - const event = JSON.parse(notification.content) as Event - const isValid = validateEvent(event) - if (!isValid) return null - client.addEventToCache(event) - return event - } catch { - return null - } - }, [notification.content]) - if (!event) return null +function NotificationTypeSwitch({ + type, + setType +}: { + type: TNotificationType + setType: (type: TNotificationType) => void +}) { + const { t } = useTranslation() + const { deepBrowsing, lastScrollTop } = useDeepBrowsing() return (
push(toNote(event))} + className={cn( + 'sticky top-12 bg-background z-30 duration-700 transition-transform select-none', + deepBrowsing && lastScrollTop > 800 ? '-translate-y-[calc(100%+12rem)]' : '' + )} > - - - -
- +
+
setType('all')} + > + {t('All')} +
+
setType('mentions')} + > + {t('Mentions')} +
+
setType('reactions')} + > + {t('Reactions')} +
+
setType('zaps')} + > + {t('Zaps')} +
-
- ) -} - -function CommentNotification({ notification }: { notification: Event }) { - const { push } = useSecondaryPage() - const rootEventId = notification.tags.find(tagNameEquals('E'))?.[1] - const rootPubkey = notification.tags.find(tagNameEquals('P'))?.[1] - const rootKind = notification.tags.find(tagNameEquals('K'))?.[1] - if ( - !rootEventId || - !rootPubkey || - !rootKind || - ![kinds.ShortTextNote, PICTURE_EVENT_KIND].includes(parseInt(rootKind)) - ) { - return null - } - - return ( -
push(toNote({ id: rootEventId, pubkey: rootPubkey }))} - > - - - -
- +
+
) diff --git a/src/components/PostEditor/Title.tsx b/src/components/PostEditor/Title.tsx index 4a8bbc8..1fbd435 100644 --- a/src/components/PostEditor/Title.tsx +++ b/src/components/PostEditor/Title.tsx @@ -1,5 +1,6 @@ import { Event } from 'nostr-tools' import { useTranslation } from 'react-i18next' +import ContentPreview from '../ContentPreview' import { SimpleUserAvatar } from '../UserAvatar' export default function Title({ parentEvent }: { parentEvent?: Event }) { @@ -9,7 +10,7 @@ export default function Title({ parentEvent }: { parentEvent?: Event }) {
{t('Reply to')}
-
{parentEvent.content}
+
) : ( t('New Note') diff --git a/src/components/ProfileBanner/index.tsx b/src/components/ProfileBanner/index.tsx index 604f437..3335237 100644 --- a/src/components/ProfileBanner/index.tsx +++ b/src/components/ProfileBanner/index.tsx @@ -1,5 +1,4 @@ import { generateImageByPubkey } from '@/lib/pubkey' -import { cn } from '@/lib/utils' import { useEffect, useMemo, useState } from 'react' import Image from '../Image' @@ -27,7 +26,7 @@ export default function ProfileBanner({ {`${pubkey} setBannerUrl(defaultBanner)} /> ) diff --git a/src/components/ProfileOptions/index.tsx b/src/components/ProfileOptions/index.tsx index 0b54840..e81d17e 100644 --- a/src/components/ProfileOptions/index.tsx +++ b/src/components/ProfileOptions/index.tsx @@ -22,7 +22,7 @@ export default function ProfileOptions({ pubkey }: { pubkey: string }) { diff --git a/src/components/ProfileZapButton/index.tsx b/src/components/ProfileZapButton/index.tsx new file mode 100644 index 0000000..8372c51 --- /dev/null +++ b/src/components/ProfileZapButton/index.tsx @@ -0,0 +1,24 @@ +import { Button } from '@/components/ui/button' +import { useNostr } from '@/providers/NostrProvider' +import { Zap } from 'lucide-react' +import { useState } from 'react' +import ZapDialog from '../ZapDialog' + +export default function ProfileZapButton({ pubkey }: { pubkey: string }) { + const { checkLogin } = useNostr() + const [open, setOpen] = useState(false) + + return ( + <> + + + + ) +} diff --git a/src/components/Sidebar/AccountButton.tsx b/src/components/Sidebar/AccountButton.tsx index 22fe8b6..4f512de 100644 --- a/src/components/Sidebar/AccountButton.tsx +++ b/src/components/Sidebar/AccountButton.tsx @@ -4,13 +4,14 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' -import { toProfile } from '@/lib/link' +import { toProfile, toWallet } from '@/lib/link' import { formatPubkey, generateImageByPubkey } from '@/lib/pubkey' import { useSecondaryPage } from '@/PageManager' import { useNostr } from '@/providers/NostrProvider' -import { LogIn } from 'lucide-react' +import { ArrowDownUp, LogIn, LogOut, UserRound, Wallet } from 'lucide-react' import { useState } from 'react' import { useTranslation } from 'react-i18next' import LoginDialog from '../LoginDialog' @@ -57,15 +58,26 @@ function ProfileButton() {
- - push(toProfile(pubkey))}>{t('Profile')} + + push(toProfile(pubkey))}> + + {t('Profile')} + + + push(toWallet())}> + + {t('Wallet')} + + setLoginDialogOpen(true)}> + {t('Switch account')} setLogoutDialogOpen(true)} > + {t('Logout')} diff --git a/src/components/Sidebar/NotificationButton.tsx b/src/components/Sidebar/NotificationButton.tsx index a89a75f..7adab38 100644 --- a/src/components/Sidebar/NotificationButton.tsx +++ b/src/components/Sidebar/NotificationButton.tsx @@ -1,9 +1,11 @@ import { usePrimaryPage } from '@/PageManager' +import { useNotification } from '@/providers/NotificationProvider' import { Bell } from 'lucide-react' import SidebarItem from './SidebarItem' export default function NotificationsButton() { const { navigate, current } = usePrimaryPage() + const { hasNewNotification } = useNotification() return ( navigate('notifications')} active={current === 'notifications'} > - +
+ + {hasNewNotification && ( +
+ )} +
) } diff --git a/src/components/Sidebar/index.tsx b/src/components/Sidebar/index.tsx index 4854600..afc44b2 100644 --- a/src/components/Sidebar/index.tsx +++ b/src/components/Sidebar/index.tsx @@ -12,7 +12,7 @@ export default function PrimaryPageSidebar() { return (
-
+
diff --git a/src/components/UserAvatar/index.tsx b/src/components/UserAvatar/index.tsx index 1783e65..1d8afba 100644 --- a/src/components/UserAvatar/index.tsx +++ b/src/components/UserAvatar/index.tsx @@ -34,7 +34,9 @@ export default function UserAvatar({ ) if (!profile) { - return + return ( + + ) } const { avatar, pubkey } = profile @@ -42,7 +44,7 @@ export default function UserAvatar({ e.stopPropagation()}> - + {pubkey} @@ -64,7 +66,7 @@ export function SimpleUserAvatar({ onClick }: { userId: string - size?: 'large' | 'big' | 'normal' | 'small' | 'tiny' + size?: 'large' | 'big' | 'normal' | 'small' | 'xSmall' | 'tiny' className?: string onClick?: (e: React.MouseEvent) => void }) { @@ -75,12 +77,14 @@ export function SimpleUserAvatar({ ) if (!profile) { - return + return ( + + ) } const { avatar, pubkey } = profile return ( - + {pubkey} diff --git a/src/components/VideoPlayer/index.tsx b/src/components/VideoPlayer/index.tsx index 29c4302..4e95be2 100644 --- a/src/components/VideoPlayer/index.tsx +++ b/src/components/VideoPlayer/index.tsx @@ -16,9 +16,9 @@ export default function VideoPlayer({
diff --git a/src/components/ZapDialog/index.tsx b/src/components/ZapDialog/index.tsx new file mode 100644 index 0000000..7ba8beb --- /dev/null +++ b/src/components/ZapDialog/index.tsx @@ -0,0 +1,162 @@ +import { Button } from '@/components/ui/button' +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { useToast } from '@/hooks' +import { useNostr } from '@/providers/NostrProvider' +import { useNoteStats } from '@/providers/NoteStatsProvider' +import { useZap } from '@/providers/ZapProvider' +import lightning from '@/services/lightning.service' +import { Loader } from 'lucide-react' +import { Dispatch, SetStateAction, useState } from 'react' +import { useTranslation } from 'react-i18next' +import UserAvatar from '../UserAvatar' +import Username from '../Username' + +export default function ZapDialog({ + open, + setOpen, + pubkey, + eventId, + defaultAmount +}: { + open: boolean + setOpen: Dispatch> + pubkey: string + eventId?: string + defaultAmount?: number +}) { + const { t } = useTranslation() + + return ( + + + + +
{t('Zap to')}
+ + +
+
+ +
+
+ ) +} + +function ZapDialogContent({ + setOpen, + recipient, + eventId, + defaultAmount +}: { + open: boolean + setOpen: Dispatch> + recipient: string + eventId?: string + defaultAmount?: number +}) { + const { t } = useTranslation() + const { toast } = useToast() + const { pubkey } = useNostr() + const { defaultZapSats, defaultZapComment } = useZap() + const { addZap } = useNoteStats() + const [sats, setSats] = useState(defaultAmount ?? defaultZapSats) + const [comment, setComment] = useState(defaultZapComment) + const [zapping, setZapping] = useState(false) + + const handleZap = async () => { + try { + if (!pubkey) { + throw new Error('You need to be logged in to zap') + } + setZapping(true) + const { invoice } = await lightning.zap(pubkey, recipient, sats, comment, eventId, () => + setOpen(false) + ) + if (eventId) { + addZap(eventId, invoice, sats, comment) + } + } catch (error) { + toast({ + title: t('Zap failed'), + description: (error as Error).message, + variant: 'destructive' + }) + } finally { + setZapping(false) + } + } + + return ( + <> + {/* Sats slider or input */} +
+
+ { + setSats((pre) => { + if (e.target.value === '') { + return 0 + } + let num = parseInt(e.target.value, 10) + if (isNaN(num) || num < 0) { + num = pre + } + return num + }) + }} + onFocus={(e) => { + requestAnimationFrame(() => { + const val = e.target.value + e.target.setSelectionRange(val.length, val.length) + }) + }} + className="bg-transparent text-center w-full p-0 focus-visible:outline-none text-6xl font-bold" + /> +
+ +
+ + {/* Preset sats buttons */} +
+ {[ + { display: '21', val: 21 }, + { display: '66', val: 66 }, + { display: '210', val: 210 }, + { display: '666', val: 666 }, + { display: '1k', val: 1000 }, + { display: '2.1k', val: 2100 }, + { display: '6.6k', val: 6666 }, + { display: '10k', val: 10000 }, + { display: '21k', val: 21000 }, + { display: '66k', val: 66666 }, + { display: '100k', val: 100000 }, + { display: '210k', val: 210000 } + ].map(({ display, val }) => ( + + ))} +
+ + {/* Comment input */} +
+ + setComment(e.target.value)} /> +
+ + + + ) +} diff --git a/src/constants.ts b/src/constants.ts index 778db3a..24627cf 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -7,6 +7,11 @@ export const StorageKey = { CURRENT_ACCOUNT: 'currentAccount', ADD_CLIENT_TAG: 'addClientTag', NOTE_LIST_MODE: 'noteListMode', + NOTIFICATION_TYPE: 'notificationType', + DEFAULT_ZAP_SATS: 'defaultZapSats', + DEFAULT_ZAP_COMMENT: 'defaultZapComment', + QUICK_ZAP: 'quickZap', + LAST_READ_NOTIFICATION_TIME_MAP: 'lastReadNotificationTimeMap', ACCOUNT_RELAY_LIST_EVENT_MAP: 'accountRelayListEventMap', // deprecated ACCOUNT_FOLLOW_LIST_EVENT_MAP: 'accountFollowListEventMap', // deprecated ACCOUNT_MUTE_LIST_EVENT_MAP: 'accountMuteListEventMap', // deprecated @@ -32,3 +37,5 @@ export const EMAIL_REGEX = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/ export const MONITOR = '9bbbb845e5b6c831c29789900769843ab43bb5047abe697870cb50b6fc9bf923' export const MONITOR_RELAYS = ['wss://relay.nostr.watch/'] + +export const CODY_PUBKEY = '8125b911ed0e94dbe3008a0be48cfe5cd0c0b05923cfff917ae7e87da8400883' diff --git a/src/hooks/index.tsx b/src/hooks/index.tsx index f7ec7bb..f3be2c3 100644 --- a/src/hooks/index.tsx +++ b/src/hooks/index.tsx @@ -6,5 +6,4 @@ export * from './useFetchProfile' export * from './useFetchRelayInfo' export * from './useFetchRelayInfos' export * from './useFetchRelayList' -export * from './useSearchParams' export * from './useSearchProfiles' diff --git a/src/hooks/useSearchParams.tsx b/src/hooks/useSearchParams.tsx deleted file mode 100644 index 2f492d3..0000000 --- a/src/hooks/useSearchParams.tsx +++ /dev/null @@ -1,24 +0,0 @@ -export function useSearchParams() { - const searchParams = new URLSearchParams(window.location.search) - - return { - searchParams, - get: (key: string) => searchParams.get(key), - set: (key: string, value: string) => { - searchParams.set(key, value) - window.history.replaceState( - null, - '', - `${window.location.pathname}?${searchParams.toString()}` - ) - }, - delete: (key: string) => { - searchParams.delete(key) - window.history.replaceState( - null, - '', - `${window.location.pathname}?${searchParams.toString()}` - ) - } - } -} diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 304c2e8..d1e1abb 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -183,6 +183,24 @@ export default { 'Open in a': 'Open in {{a}}', 'Cannot handle event of kind k': 'Cannot handle event of kind {{k}}', 'Sorry! The note cannot be found 😔': 'Sorry! The note cannot be found 😔', - 'This user has been muted': 'This user has been muted' + 'This user has been muted': 'This user has been muted', + Wallet: 'Wallet', + Sats: 'Sats', + sats: 'sats', + 'Zap to': 'Zap to', + 'Zap n sats': 'Zap {{n}} sats', + zapComment: 'Comment', + 'Default zap amount': 'Default zap amount', + 'Default zap comment': 'Default zap comment', + 'Lightning Address (or LNURL)': 'Lightning Address (or LNURL)', + 'Quick zap': 'Quick zap', + 'If enabled, you can zap with a single click': 'If enabled, you can zap with a single click', + All: 'All', + Reactions: 'Reactions', + Zaps: 'Zaps', + 'Enjoying Jumble?': 'Enjoying Jumble?', + 'Your donation helps me maintain Jumble and make it better! 😊': + 'Your donation helps me maintain Jumble and make it better! 😊', + 'Earlier notifications': 'Earlier notifications' } } diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index e7f9e3d..1d8c9a1 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -184,6 +184,24 @@ export default { 'Open in a': '在 {{a}} 中打开', 'Cannot handle event of kind k': '无法处理类型为 {{k}} 的事件', 'Sorry! The note cannot be found 😔': '抱歉!找不到该笔记 😔', - 'This user has been muted': '该用户已被屏蔽' + 'This user has been muted': '该用户已被屏蔽', + Wallet: '钱包', + Sats: '聪', + sats: '聪', + 'Zap to': '打闪给', + 'Zap n sats': '打闪 {{n}} 聪', + zapComment: '附言', + 'Default zap amount': '默认打闪金额', + 'Default zap comment': '默认打闪附言', + 'Lightning Address (or LNURL)': '闪电地址 (或 LNURL)', + 'Quick zap': '快速打闪', + 'If enabled, you can zap with a single click': '启用后,您可以单击打闪', + All: '全部', + Reactions: '互动', + Zaps: '打闪', + 'Enjoying Jumble?': '喜欢 Jumble 吗?', + 'Your donation helps me maintain Jumble and make it better! 😊': + '您的捐赠帮助我维护 Jumble 并使其更好!😊', + 'Earlier notifications': '更早的通知' } } diff --git a/src/index.css b/src/index.css index 8b13e83..09cdc0b 100644 --- a/src/index.css +++ b/src/index.css @@ -10,6 +10,14 @@ -webkit-tap-highlight-color: transparent; } + html { + --bc-color-brand: hsl(var(--primary)); + --bc-color-brand-dark: hsl(var(--primary)); + --bc-brand-mix: 100%; + --bc-color-brand-button-text: hsl(var(--primary-foreground)); + --bc-color-brand-button-text-dark: hsl(var(--primary-foreground)); + } + input, textarea, button { diff --git a/src/lib/common.ts b/src/lib/common.ts index 2169dee..eab1097 100644 --- a/src/lib/common.ts +++ b/src/lib/common.ts @@ -1,3 +1,7 @@ export function isTouchDevice() { return 'ontouchstart' in window || navigator.maxTouchPoints > 0 } + +export function isEmail(email: string) { + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) +} diff --git a/src/lib/event.ts b/src/lib/event.ts index 1a213d7..699f8dd 100644 --- a/src/lib/event.ts +++ b/src/lib/event.ts @@ -3,11 +3,13 @@ import client from '@/services/client.service' import { TImageInfo, TRelayList } from '@/types' import { LRUCache } from 'lru-cache' import { Event, kinds, nip19 } from 'nostr-tools' +import { getAmountFromInvoice, getLightningAddressFromProfile } from './lightning' import { formatPubkey } from './pubkey' import { extractImageInfoFromTag, isReplyETag, isRootETag, tagNameEquals } from './tag' import { isWebsocketUrl, normalizeHttpUrl, normalizeUrl } from './url' const EVENT_EMBEDDED_EVENT_IDS_CACHE = new LRUCache({ max: 10000 }) +const EVENT_IS_REPLY_NOTE_CACHE = new LRUCache({ max: 10000 }) export function isNsfwEvent(event: Event) { return event.tags.some( @@ -19,15 +21,23 @@ export function isNsfwEvent(event: Event) { export function isReplyNoteEvent(event: Event) { if (event.kind !== kinds.ShortTextNote) return false + const cache = EVENT_IS_REPLY_NOTE_CACHE.get(event.id) + if (cache !== undefined) return cache + const mentionsEventIds: string[] = [] for (const [tagName, eventId, , marker] of event.tags) { if (tagName !== 'e' || !eventId) continue mentionsEventIds.push(eventId) - if (['root', 'reply'].includes(marker)) return true + if (['root', 'reply'].includes(marker)) { + EVENT_IS_REPLY_NOTE_CACHE.set(event.id, true) + return true + } } const embeddedEventIds = extractEmbeddedEventIds(event) - return mentionsEventIds.some((id) => !embeddedEventIds.includes(id)) + const result = mentionsEventIds.some((id) => !embeddedEventIds.includes(id)) + EVENT_IS_REPLY_NOTE_CACHE.set(event.id, result) + return result } export function isCommentEvent(event: Event) { @@ -159,6 +169,9 @@ export function getProfileFromProfileEvent(event: Event) { nip05: profileObj.nip05, about: profileObj.about, website: profileObj.website ? normalizeHttpUrl(profileObj.website) : undefined, + lud06: profileObj.lud06, + lud16: profileObj.lud16, + lightningAddress: getLightningAddressFromProfile(profileObj), created_at: event.created_at } } catch (err) { @@ -363,6 +376,68 @@ export function extractEmbeddedNotesFromContent(content: string) { return { embeddedNotes, contentWithoutEmbeddedNotes: c } } +export function extractZapInfoFromReceipt(receiptEvent: Event) { + if (receiptEvent.kind !== kinds.Zap) return null + + let senderPubkey: string | undefined + let recipientPubkey: string | undefined + let eventId: string | undefined + let invoice: string | undefined + let amount: number | undefined + let comment: string | undefined + let description: string | undefined + let preimage: string | undefined + try { + receiptEvent.tags.forEach(([tagName, tagValue]) => { + switch (tagName) { + case 'P': + senderPubkey = tagValue + break + case 'p': + recipientPubkey = tagValue + break + case 'e': + eventId = tagValue + break + case 'bolt11': + invoice = tagValue + break + case 'description': + description = tagValue + break + case 'preimage': + preimage = tagValue + break + } + }) + if (!recipientPubkey || !invoice) return null + amount = invoice ? getAmountFromInvoice(invoice) : 0 + if (description) { + try { + const zapRequest = JSON.parse(description) + comment = zapRequest.content + if (!senderPubkey) { + senderPubkey = zapRequest.pubkey + } + } catch { + // ignore + } + } + + return { + senderPubkey, + recipientPubkey, + eventId, + invoice, + amount, + comment, + preimage + } + } catch { + return null + } +} + export function extractEmbeddedEventIds(event: Event) { const cache = EVENT_EMBEDDED_EVENT_IDS_CACHE.get(event.id) if (cache) return cache diff --git a/src/lib/lightning.ts b/src/lib/lightning.ts new file mode 100644 index 0000000..7f019eb --- /dev/null +++ b/src/lib/lightning.ts @@ -0,0 +1,32 @@ +import { TProfile } from '@/types' +import { Invoice } from '@getalby/lightning-tools' +import { isEmail } from './common' + +export function getAmountFromInvoice(invoice: string): number { + const _invoice = new Invoice({ pr: invoice }) // TODO: need to validate + return _invoice.satoshi +} + +export function formatAmount(amount: number) { + if (amount < 1000) return amount + if (amount < 1000000) return `${Math.round(amount / 100) / 10}k` + return `${Math.round(amount / 100000) / 10}M` +} + +export function getLightningAddressFromProfile(profile: TProfile) { + // Some clients have incorrectly filled in the positions for lud06 and lud16 + const { lud16: a, lud06: b } = profile + let lud16: string | undefined + let lud06: string | undefined + if (a && isEmail(a)) { + lud16 = a + } else if (b && isEmail(b)) { + lud16 = b + } else if (b && b.startsWith('lnurl')) { + lud06 = b + } else if (a && a.startsWith('lnurl')) { + lud06 = a + } + + return lud16 || lud06 || undefined +} diff --git a/src/lib/link.ts b/src/lib/link.ts index dd9e80e..d9e31a3 100644 --- a/src/lib/link.ts +++ b/src/lib/link.ts @@ -39,6 +39,7 @@ export const toRelaySettings = (tag?: 'mailbox' | 'relay-sets') => { return '/relay-settings' + (tag ? '#' + tag : '') } export const toSettings = () => '/settings' +export const toWallet = () => '/wallet' export const toProfileEditor = () => '/profile-editor' export const toRelay = (url: string) => `/relays/${encodeURIComponent(url)}` export const toMuteList = () => '/mutes' diff --git a/src/main.tsx b/src/main.tsx index 43050f6..e7958e5 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,6 +1,7 @@ import './i18n' import './index.css' import './polyfill' +import './services/lightning.service' import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' diff --git a/src/pages/primary/MePage/index.tsx b/src/pages/primary/MePage/index.tsx index 98bf0d6..8c7e65c 100644 --- a/src/pages/primary/MePage/index.tsx +++ b/src/pages/primary/MePage/index.tsx @@ -8,11 +8,11 @@ import { Separator } from '@/components/ui/separator' import { SimpleUserAvatar } from '@/components/UserAvatar' import { SimpleUsername } from '@/components/Username' import PrimaryPageLayout from '@/layouts/PrimaryPageLayout' -import { toProfile, toSettings } from '@/lib/link' +import { toProfile, toSettings, toWallet } from '@/lib/link' import { cn } from '@/lib/utils' import { useSecondaryPage } from '@/PageManager' import { useNostr } from '@/providers/NostrProvider' -import { ArrowDownUp, ChevronRight, LogOut, Settings, UserRound } from 'lucide-react' +import { ArrowDownUp, ChevronRight, LogOut, Settings, UserRound, Wallet } from 'lucide-react' import { forwardRef, HTMLProps, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -54,6 +54,10 @@ const MePage = forwardRef((_, ref) => { {t('Profile')} + push(toWallet())}> + + {t('Wallet')} + setLoginDialogOpen(true)}> {t('Switch account')} diff --git a/src/pages/primary/NotificationListPage/index.tsx b/src/pages/primary/NotificationListPage/index.tsx index 82f16b3..b4a5a17 100644 --- a/src/pages/primary/NotificationListPage/index.tsx +++ b/src/pages/primary/NotificationListPage/index.tsx @@ -24,9 +24,7 @@ const NotificationListPage = forwardRef((_, ref) => { titlebar={} displayScrollToTopButton > -
- -
+ ) }) diff --git a/src/pages/secondary/NoteListPage/index.tsx b/src/pages/secondary/NoteListPage/index.tsx index 12e11b8..50aff27 100644 --- a/src/pages/secondary/NoteListPage/index.tsx +++ b/src/pages/secondary/NoteListPage/index.tsx @@ -1,6 +1,6 @@ import NoteList from '@/components/NoteList' import { SEARCHABLE_RELAY_URLS } from '@/constants' -import { useFetchRelayInfos, useSearchParams } from '@/hooks' +import { useFetchRelayInfos } from '@/hooks' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' import { useFeed } from '@/providers/FeedProvider' import { Filter } from 'nostr-tools' @@ -11,7 +11,6 @@ const NoteListPage = forwardRef(({ index }: { index?: number }, ref) => { const { t } = useTranslation() const { relayUrls } = useFeed() const { searchableRelayUrls } = useFetchRelayInfos(relayUrls) - const { searchParams } = useSearchParams() const { title = '', filter, @@ -21,6 +20,7 @@ const NoteListPage = forwardRef(({ index }: { index?: number }, ref) => { filter?: Filter urls: string[] }>(() => { + const searchParams = new URLSearchParams(window.location.search) const hashtag = searchParams.get('t') if (hashtag) { return { @@ -40,7 +40,7 @@ const NoteListPage = forwardRef(({ index }: { index?: number }, ref) => { } } return { urls: relayUrls } - }, [searchParams, JSON.stringify(relayUrls)]) + }, [JSON.stringify(relayUrls)]) return ( diff --git a/src/pages/secondary/ProfileEditorPage/index.tsx b/src/pages/secondary/ProfileEditorPage/index.tsx index abb91e8..9b16759 100644 --- a/src/pages/secondary/ProfileEditorPage/index.tsx +++ b/src/pages/secondary/ProfileEditorPage/index.tsx @@ -4,8 +4,8 @@ import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Textarea } from '@/components/ui/textarea' -import { EMAIL_REGEX } from '@/constants' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' +import { isEmail } from '@/lib/common' import { createProfileDraftEvent } from '@/lib/draft-event' import { generateImageByPubkey } from '@/lib/pubkey' import { useSecondaryPage } from '@/PageManager' @@ -24,6 +24,8 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => { const [about, setAbout] = useState('') const [nip05, setNip05] = useState('') const [nip05Error, setNip05Error] = useState('') + const [lightningAddress, setLightningAddress] = useState('') + const [lightningAddressError, setLightningAddressError] = useState('') const [hasChanged, setHasChanged] = useState(false) const [saving, setSaving] = useState(false) const [uploadingBanner, setUploadingBanner] = useState(false) @@ -40,22 +42,38 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => { setUsername(profile.original_username ?? '') setAbout(profile.about ?? '') setNip05(profile.nip05 ?? '') + setLightningAddress(profile.lightningAddress || '') } else { setBanner('') setAvatar('') setUsername('') setAbout('') setNip05('') + setLightningAddress('') } }, [profile]) if (!account || !profile) return null const save = async () => { - if (nip05 && !EMAIL_REGEX.test(nip05)) { + if (nip05 && !isEmail(nip05)) { setNip05Error(t('Invalid NIP-05 address')) return } + + let lud06 = profile.lud06 + let lud16 = profile.lud16 + if (lightningAddress) { + if (isEmail(lightningAddress)) { + lud16 = lightningAddress + } else if (lightningAddress.startsWith('lnurl')) { + lud06 = lightningAddress + } else { + setLightningAddressError(t('Invalid Lightning Address')) + return + } + } + setSaving(true) setHasChanged(false) const oldProfileContent = profileEvent ? JSON.parse(profileEvent.content) : {} @@ -67,7 +85,9 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => { about, nip05, banner, - picture: avatar + picture: avatar, + lud06, + lud16 } const profileDraftEvent = createProfileDraftEvent( JSON.stringify(newProfileContent), @@ -100,7 +120,7 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => { return (
-
+
setTimeout(() => setUploadingBanner(uploading), 50)} @@ -109,7 +129,7 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
{uploadingBanner ? ( @@ -170,6 +190,21 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => { /> {nip05Error &&
{nip05Error}
} + + {t('Lightning Address (or LNURL)')} + { + setLightningAddressError('') + setLightningAddress(e.target.value) + setHasChanged(true) + }} + className={lightningAddressError ? 'border-destructive' : ''} + /> + {lightningAddressError && ( +
{lightningAddressError}
+ )} +
@@ -179,7 +214,7 @@ ProfileEditorPage.displayName = 'ProfileEditorPage' export default ProfileEditorPage function ItemTitle({ children }: { children: React.ReactNode }) { - return
{children}
+ return
{children}
} function Item({ children }: { children: React.ReactNode }) { diff --git a/src/pages/secondary/ProfileListPage/index.tsx b/src/pages/secondary/ProfileListPage/index.tsx index 5907c4f..dc35108 100644 --- a/src/pages/secondary/ProfileListPage/index.tsx +++ b/src/pages/secondary/ProfileListPage/index.tsx @@ -1,6 +1,6 @@ import UserItem from '@/components/UserItem' import { SEARCHABLE_RELAY_URLS } from '@/constants' -import { useFetchRelayInfos, useSearchParams } from '@/hooks' +import { useFetchRelayInfos } from '@/hooks' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' import { useFeed } from '@/providers/FeedProvider' import client from '@/services/client.service' @@ -13,7 +13,6 @@ const LIMIT = 50 const ProfileListPage = forwardRef(({ index }: { index?: number }, ref) => { const { t } = useTranslation() - const { searchParams } = useSearchParams() const { relayUrls } = useFeed() const { searchableRelayUrls } = useFetchRelayInfos(relayUrls) const [until, setUntil] = useState(() => dayjs().unix()) @@ -22,12 +21,13 @@ const ProfileListPage = forwardRef(({ index }: { index?: number }, ref) => { const bottomRef = useRef(null) const filter = useMemo(() => { const f: Filter = { until } + const searchParams = new URLSearchParams(window.location.search) const search = searchParams.get('s') if (search) { f.search = search } return f - }, [searchParams, until]) + }, [until]) const urls = useMemo(() => { return filter.search ? searchableRelayUrls.concat(SEARCHABLE_RELAY_URLS).slice(0, 4) : relayUrls }, [relayUrls, searchableRelayUrls, filter]) diff --git a/src/pages/secondary/ProfilePage/index.tsx b/src/pages/secondary/ProfilePage/index.tsx index bf9db6c..8bfe9ea 100644 --- a/src/pages/secondary/ProfilePage/index.tsx +++ b/src/pages/secondary/ProfilePage/index.tsx @@ -4,6 +4,7 @@ import NoteList from '@/components/NoteList' import ProfileAbout from '@/components/ProfileAbout' import ProfileBanner from '@/components/ProfileBanner' import ProfileOptions from '@/components/ProfileOptions' +import ProfileZapButton from '@/components/ProfileZapButton' import PubkeyCopy from '@/components/PubkeyCopy' import QrCodePopover from '@/components/QrCodePopover' import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' @@ -18,7 +19,7 @@ import { SecondaryPageLink, useSecondaryPage } from '@/PageManager' import { useFeed } from '@/providers/FeedProvider' import { useMuteList } from '@/providers/MuteListProvider' import { useNostr } from '@/providers/NostrProvider' -import { Link } from 'lucide-react' +import { Link, Zap } from 'lucide-react' import { forwardRef, useMemo } from 'react' import { useTranslation } from 'react-i18next' import NotFoundPage from '../NotFoundPage' @@ -55,11 +56,13 @@ const ProfilePage = forwardRef(({ id, index }: { id?: string; index?: number }, if (!profile && isFetching) { return ( -
-
- - +
+
+ +
+
+
@@ -68,29 +71,32 @@ const ProfilePage = forwardRef(({ id, index }: { id?: string; index?: number }, } if (!profile) return - const { banner, username, about, avatar, pubkey, website } = profile + const { banner, username, about, avatar, pubkey, website, lightningAddress } = profile return ( -
-
+
+
- +
+
+
{isFollowingYou && (
{t('Follows you')}
)} + {isSelf ? (
{username}
+ {lightningAddress && ( +
+ + {lightningAddress} +
+ )}
diff --git a/src/pages/secondary/SettingsPage/index.tsx b/src/pages/secondary/SettingsPage/index.tsx index a7653e9..7fc44b4 100644 --- a/src/pages/secondary/SettingsPage/index.tsx +++ b/src/pages/secondary/SettingsPage/index.tsx @@ -1,14 +1,25 @@ import AboutInfoDialog from '@/components/AboutInfoDialog' +import Donation from '@/components/Donation' import { Select, SelectContent, SelectItem, SelectTrigger } from '@/components/ui/select' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' -import { toRelaySettings } from '@/lib/link' +import { toRelaySettings, toWallet } from '@/lib/link' import { cn } from '@/lib/utils' import { useSecondaryPage } from '@/PageManager' import { useNostr } from '@/providers/NostrProvider' import { useTheme } from '@/providers/ThemeProvider' import { TLanguage } from '@/types' import { SelectValue } from '@radix-ui/react-select' -import { Check, ChevronRight, Copy, Info, KeyRound, Languages, Server, SunMoon } from 'lucide-react' +import { + Check, + ChevronRight, + Copy, + Info, + KeyRound, + Languages, + Server, + SunMoon, + Wallet +} from 'lucide-react' import { forwardRef, HTMLProps, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -66,6 +77,13 @@ const SettingsPage = forwardRef(({ index }: { index?: number }, ref) => {
+ push(toWallet())}> +
+ +
{t('Wallet')}
+
+ +
{!!nsec && ( { @@ -110,6 +128,9 @@ const SettingsPage = forwardRef(({ index }: { index?: number }, ref) => {
+
+ +
) }) diff --git a/src/pages/secondary/WalletPage/DefaultZapAmountInput.tsx b/src/pages/secondary/WalletPage/DefaultZapAmountInput.tsx new file mode 100644 index 0000000..107a7c6 --- /dev/null +++ b/src/pages/secondary/WalletPage/DefaultZapAmountInput.tsx @@ -0,0 +1,38 @@ +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { useZap } from '@/providers/ZapProvider' +import { useState } from 'react' +import { useTranslation } from 'react-i18next' + +export default function DefaultZapAmountInput() { + const { t } = useTranslation() + const { defaultZapSats, updateDefaultSats } = useZap() + const [defaultZapAmountInput, setDefaultZapAmountInput] = useState(defaultZapSats) + + return ( +
+ +
+ { + setDefaultZapAmountInput((pre) => { + if (e.target.value === '') { + return 0 + } + let num = parseInt(e.target.value, 10) + if (isNaN(num) || num < 0) { + num = pre + } + return num + }) + }} + onBlur={() => { + updateDefaultSats(defaultZapAmountInput) + }} + /> +
+
+ ) +} diff --git a/src/pages/secondary/WalletPage/DefaultZapCommentInput.tsx b/src/pages/secondary/WalletPage/DefaultZapCommentInput.tsx new file mode 100644 index 0000000..5401c13 --- /dev/null +++ b/src/pages/secondary/WalletPage/DefaultZapCommentInput.tsx @@ -0,0 +1,27 @@ +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { useZap } from '@/providers/ZapProvider' +import { useState } from 'react' +import { useTranslation } from 'react-i18next' + +export default function DefaultZapCommentInput() { + const { t } = useTranslation() + const { defaultZapComment, updateDefaultComment } = useZap() + const [defaultZapCommentInput, setDefaultZapCommentInput] = useState(defaultZapComment) + + return ( +
+ +
+ setDefaultZapCommentInput(e.target.value)} + onBlur={() => { + updateDefaultComment(defaultZapCommentInput) + }} + /> +
+
+ ) +} diff --git a/src/pages/secondary/WalletPage/LightningAddressInput.tsx b/src/pages/secondary/WalletPage/LightningAddressInput.tsx new file mode 100644 index 0000000..c785b90 --- /dev/null +++ b/src/pages/secondary/WalletPage/LightningAddressInput.tsx @@ -0,0 +1,82 @@ +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { useToast } from '@/hooks' +import { isEmail } from '@/lib/common' +import { createProfileDraftEvent } from '@/lib/draft-event' +import { useNostr } from '@/providers/NostrProvider' +import { Loader } from 'lucide-react' +import { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' + +export default function LightningAddressInput() { + const { t } = useTranslation() + const { toast } = useToast() + const { profile, profileEvent, publish, updateProfileEvent } = useNostr() + const [lightningAddress, setLightningAddress] = useState('') + const [hasChanged, setHasChanged] = useState(false) + const [saving, setSaving] = useState(false) + + useEffect(() => { + if (profile) { + setLightningAddress(profile.lightningAddress || '') + } + }, [profile]) + + if (!profile || !profileEvent) { + return null + } + + const handleSave = async () => { + setSaving(true) + let lud06 = profile.lud06 + let lud16 = profile.lud16 + if (lightningAddress.startsWith('lnurl')) { + lud06 = lightningAddress + } else if (isEmail(lightningAddress)) { + lud16 = lightningAddress + } else { + toast({ + title: 'Invalid Lightning Address', + description: 'Please enter a valid Lightning Address or LNURL', + variant: 'destructive' + }) + setSaving(false) + return + } + + const oldProfileContent = profileEvent ? JSON.parse(profileEvent.content) : {} + const newProfileContent = { + ...oldProfileContent, + lud06, + lud16 + } + const profileDraftEvent = createProfileDraftEvent( + JSON.stringify(newProfileContent), + profileEvent?.tags + ) + const newProfileEvent = await publish(profileDraftEvent) + await updateProfileEvent(newProfileEvent) + setSaving(false) + } + + return ( +
+ +
+ { + setLightningAddress(e.target.value) + setHasChanged(true) + }} + /> + +
+
+ ) +} diff --git a/src/pages/secondary/WalletPage/QuickZapSwitch.tsx b/src/pages/secondary/WalletPage/QuickZapSwitch.tsx new file mode 100644 index 0000000..399ef6e --- /dev/null +++ b/src/pages/secondary/WalletPage/QuickZapSwitch.tsx @@ -0,0 +1,21 @@ +import { Label } from '@/components/ui/label' +import { Switch } from '@/components/ui/switch' +import { useZap } from '@/providers/ZapProvider' +import { useTranslation } from 'react-i18next' + +export default function QuickZapSwitch() { + const { t } = useTranslation() + const { quickZap, updateQuickZap } = useZap() + + return ( +
+ + +
+ ) +} diff --git a/src/pages/secondary/WalletPage/index.tsx b/src/pages/secondary/WalletPage/index.tsx new file mode 100644 index 0000000..8ae18b7 --- /dev/null +++ b/src/pages/secondary/WalletPage/index.tsx @@ -0,0 +1,26 @@ +import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' +import { Button as BcButton } from '@getalby/bitcoin-connect-react' +import { forwardRef } from 'react' +import { useTranslation } from 'react-i18next' +import DefaultZapAmountInput from './DefaultZapAmountInput' +import DefaultZapCommentInput from './DefaultZapCommentInput' +import LightningAddressInput from './LightningAddressInput' +import QuickZapSwitch from './QuickZapSwitch' + +const WalletPage = forwardRef(({ index }: { index?: number }, ref) => { + const { t } = useTranslation() + + return ( + +
+ + + + + +
+
+ ) +}) +WalletPage.displayName = 'WalletPage' +export default WalletPage diff --git a/src/providers/FeedProvider.tsx b/src/providers/FeedProvider.tsx index efd79e7..8ecd1d0 100644 --- a/src/providers/FeedProvider.tsx +++ b/src/providers/FeedProvider.tsx @@ -7,7 +7,6 @@ import relayInfoService from '@/services/relay-info.service' import { TFeedType } from '@/types' import { Filter } from 'nostr-tools' import { createContext, useContext, useEffect, useRef, useState } from 'react' -import { useFollowList } from './FollowListProvider' import { useNostr } from './NostrProvider' import { useRelaySets } from './RelaySetsProvider' @@ -36,8 +35,7 @@ export const useFeed = () => { export function FeedProvider({ children }: { children: React.ReactNode }) { const isFirstRenderRef = useRef(true) - const { pubkey, getRelayList } = useNostr() - const { getFollowings } = useFollowList() + const { pubkey } = useNostr() const { relaySets } = useRelaySets() const feedTypeRef = useRef(storage.getFeedType()) const [feedType, setFeedType] = useState(feedTypeRef.current) @@ -120,8 +118,8 @@ export function FeedProvider({ children }: { children: React.ReactNode }) { setFeedType(feedType) setActiveRelaySetId(null) const [relayList, followings] = await Promise.all([ - getRelayList(options.pubkey), - getFollowings(options.pubkey) + client.fetchRelayList(options.pubkey), + client.fetchFollowings(options.pubkey, true) ]) setRelayUrls(relayList.read.concat(BIG_RELAY_URLS).slice(0, 4)) setFilter({ diff --git a/src/providers/FollowListProvider.tsx b/src/providers/FollowListProvider.tsx index be52194..4f063ab 100644 --- a/src/providers/FollowListProvider.tsx +++ b/src/providers/FollowListProvider.tsx @@ -1,16 +1,11 @@ import { createFollowListDraftEvent } from '@/lib/draft-event' import { extractPubkeysFromEventTags } from '@/lib/tag' import client from '@/services/client.service' -import indexedDb from '@/services/indexed-db.service' -import { Event, kinds } from 'nostr-tools' -import { createContext, useContext, useEffect, useMemo, useState } from 'react' +import { createContext, useContext, useMemo } from 'react' import { useNostr } from './NostrProvider' type TFollowListContext = { - followListEvent: Event | undefined followings: string[] - isFetching: boolean - getFollowings: (pubkey: string) => Promise follow: (pubkey: string) => Promise unfollow: (pubkey: string) => Promise } @@ -26,81 +21,42 @@ export const useFollowList = () => { } export function FollowListProvider({ children }: { children: React.ReactNode }) { - const { pubkey: accountPubkey, publish } = useNostr() - const [followListEvent, setFollowListEvent] = useState(undefined) - const [isFetching, setIsFetching] = useState(true) + const { pubkey: accountPubkey, followListEvent, publish, updateFollowListEvent } = useNostr() const followings = useMemo( () => (followListEvent ? extractPubkeysFromEventTags(followListEvent.tags) : []), [followListEvent] ) - useEffect(() => { - if (!accountPubkey) return - - const init = async () => { - setIsFetching(true) - setFollowListEvent(undefined) - const storedFollowListEvent = await indexedDb.getReplaceableEvent( - accountPubkey, - kinds.Contacts - ) - if (storedFollowListEvent) { - setFollowListEvent(storedFollowListEvent) - } - const event = await client.fetchFollowListEvent(accountPubkey, true) - if (event) { - await updateFollowListEvent(event) - } - setIsFetching(false) - } - - init() - }, [accountPubkey]) - - const updateFollowListEvent = async (event: Event) => { - const newEvent = await indexedDb.putReplaceableEvent(event) - setFollowListEvent(newEvent) - } - const follow = async (pubkey: string) => { - if (isFetching || !accountPubkey) return + if (!accountPubkey) return + const followListEvent = await client.fetchFollowListEvent(accountPubkey) const newFollowListDraftEvent = createFollowListDraftEvent( (followListEvent?.tags ?? []).concat([['p', pubkey]]), followListEvent?.content ) const newFollowListEvent = await publish(newFollowListDraftEvent) - client.updateFollowListCache(accountPubkey, newFollowListEvent) await updateFollowListEvent(newFollowListEvent) } const unfollow = async (pubkey: string) => { - if (isFetching || !accountPubkey || !followListEvent) return + if (!accountPubkey) return + + const followListEvent = await client.fetchFollowListEvent(accountPubkey) + if (!followListEvent) return const newFollowListDraftEvent = createFollowListDraftEvent( followListEvent.tags.filter(([tagName, tagValue]) => tagName !== 'p' || tagValue !== pubkey), followListEvent.content ) const newFollowListEvent = await publish(newFollowListDraftEvent) - client.updateFollowListCache(accountPubkey, newFollowListEvent) await updateFollowListEvent(newFollowListEvent) } - const getFollowings = async (pubkey: string) => { - const followListEvent = await indexedDb.getReplaceableEvent(pubkey, kinds.Contacts) - if (followListEvent) { - return extractPubkeysFromEventTags(followListEvent.tags) - } - return await client.fetchFollowings(pubkey) - } - return ( { } export function MuteListProvider({ children }: { children: React.ReactNode }) { - const { pubkey: accountPubkey, publish, relayList, nip04Decrypt, nip04Encrypt } = useNostr() - const [muteListEvent, setMuteListEvent] = useState(undefined) + const { + pubkey: accountPubkey, + muteListEvent, + publish, + updateMuteListEvent, + nip04Decrypt, + nip04Encrypt + } = useNostr() const [tags, setTags] = useState([]) const mutePubkeys = useMemo(() => extractPubkeysFromEventTags(tags), [tags]) useEffect(() => { - if (!accountPubkey) return - - const init = async () => { - setMuteListEvent(undefined) - const storedMuteListEvent = await indexedDb.getReplaceableEvent(accountPubkey, kinds.Mutelist) - if (storedMuteListEvent) { - setMuteListEvent(storedMuteListEvent) - const tags = await extractMuteTags(storedMuteListEvent) - setTags(tags) - } - const events = await client.fetchEvents(relayList?.write ?? BIG_RELAY_URLS, { - kinds: [kinds.Mutelist], - authors: [accountPubkey] - }) - const muteEvent = getLatestEvent(events) as Event | undefined - if (muteEvent) { - const newMuteEvent = await indexedDb.putReplaceableEvent(muteEvent) - setMuteListEvent(newMuteEvent) - const tags = await extractMuteTags(newMuteEvent) - setTags(tags) - } - } + const updateMuteTags = async () => { + if (!muteListEvent) return - init() - }, [accountPubkey]) + const tags = [...muteListEvent.tags] + if (muteListEvent.content) { + const storedDecryptedTags = await indexedDb.getMuteDecryptedTags(muteListEvent.id) - const extractMuteTags = async (muteListEvent: Event) => { - const tags = [...muteListEvent.tags] - if (muteListEvent.content) { - const storedDecryptedTags = await indexedDb.getMuteDecryptedTags(muteListEvent.id) - - if (storedDecryptedTags) { - tags.push(...storedDecryptedTags) - } else { - try { - const plainText = await nip04Decrypt(muteListEvent.pubkey, muteListEvent.content) - const contentTags = z.array(z.array(z.string())).parse(JSON.parse(plainText)) - await indexedDb.putMuteDecryptedTags(muteListEvent.id, contentTags) - tags.push(...contentTags.filter((tag) => tags.every((t) => !isSameTag(t, tag)))) - } catch (error) { - console.error('Failed to decrypt mute list content', error) + if (storedDecryptedTags) { + tags.push(...storedDecryptedTags) + } else { + try { + const plainText = await nip04Decrypt(muteListEvent.pubkey, muteListEvent.content) + const contentTags = z.array(z.array(z.string())).parse(JSON.parse(plainText)) + await indexedDb.putMuteDecryptedTags(muteListEvent.id, contentTags) + tags.push(...contentTags.filter((tag) => tags.every((t) => !isSameTag(t, tag)))) + } catch (error) { + console.error('Failed to decrypt mute list content', error) + } } } + setTags(tags) } - return tags - } - - const update = async (event: Event, tags: string[][]) => { - const isNew = await indexedDb.putReplaceableEvent(event) - if (!isNew) return - await indexedDb.putMuteDecryptedTags(event.id, tags) - setMuteListEvent(event) - setTags(tags) - } + updateMuteTags() + }, [muteListEvent]) const mutePubkey = async (pubkey: string) => { if (!accountPubkey) return @@ -94,7 +66,7 @@ export function MuteListProvider({ children }: { children: React.ReactNode }) { const cipherText = await nip04Encrypt(accountPubkey, JSON.stringify(newTags)) const newMuteListDraftEvent = createMuteListDraftEvent(muteListEvent?.tags ?? [], cipherText) const newMuteListEvent = await publish(newMuteListDraftEvent) - await update(newMuteListEvent, newTags) + await updateMuteListEvent(newMuteListEvent, newTags) } const unmutePubkey = async (pubkey: string) => { @@ -107,7 +79,7 @@ export function MuteListProvider({ children }: { children: React.ReactNode }) { cipherText ) const newMuteListEvent = await publish(newMuteListDraftEvent) - await update(newMuteListEvent, newTags) + await updateMuteListEvent(newMuteListEvent, newTags) } return ( diff --git a/src/providers/NostrProvider/index.tsx b/src/providers/NostrProvider/index.tsx index e20cb79..18804ef 100644 --- a/src/providers/NostrProvider/index.tsx +++ b/src/providers/NostrProvider/index.tsx @@ -1,11 +1,14 @@ import LoginDialog from '@/components/LoginDialog' import { BIG_RELAY_URLS } from '@/constants' import { useToast } from '@/hooks' -import { getProfileFromProfileEvent, getRelayListFromRelayListEvent } from '@/lib/event' -import { formatPubkey } from '@/lib/pubkey' +import { + getLatestEvent, + getProfileFromProfileEvent, + getRelayListFromRelayListEvent +} from '@/lib/event' import client from '@/services/client.service' -import storage from '@/services/local-storage.service' import indexedDb from '@/services/indexed-db.service' +import storage from '@/services/local-storage.service' import { ISigner, TAccount, TAccountPointer, TDraftEvent, TProfile, TRelayList } from '@/types' import dayjs from 'dayjs' import { Event, kinds, VerifiedEvent } from 'nostr-tools' @@ -22,6 +25,8 @@ type TNostrContext = { profile: TProfile | null profileEvent: Event | null relayList: TRelayList | null + followListEvent?: Event + muteListEvent?: Event account: TAccountPointer | null accounts: TAccountPointer[] nsec: string | null @@ -45,9 +50,10 @@ type TNostrContext = { nip04Decrypt: (pubkey: string, cipherText: string) => Promise startLogin: () => void checkLogin: (cb?: () => T) => Promise - getRelayList: (pubkey: string) => Promise updateRelayListEvent: (relayListEvent: Event) => Promise updateProfileEvent: (profileEvent: Event) => Promise + updateFollowListEvent: (followListEvent: Event) => Promise + updateMuteListEvent: (muteListEvent: Event, tags: string[][]) => Promise } const NostrContext = createContext(undefined) @@ -71,6 +77,8 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { const [profile, setProfile] = useState(null) const [profileEvent, setProfileEvent] = useState(null) const [relayList, setRelayList] = useState(null) + const [followListEvent, setFollowListEvent] = useState(undefined) + const [muteListEvent, setMuteListEvent] = useState(undefined) useEffect(() => { const init = async () => { @@ -122,10 +130,13 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { } else { setNcryptsec(null) } - const [storedRelayListEvent, storedProfileEvent] = await Promise.all([ - indexedDb.getReplaceableEvent(account.pubkey, kinds.RelayList), - indexedDb.getReplaceableEvent(account.pubkey, kinds.Metadata) - ]) + const [storedRelayListEvent, storedProfileEvent, storedFollowListEvent, storedMuteListEvent] = + await Promise.all([ + indexedDb.getReplaceableEvent(account.pubkey, kinds.RelayList), + indexedDb.getReplaceableEvent(account.pubkey, kinds.Metadata), + indexedDb.getReplaceableEvent(account.pubkey, kinds.Contacts), + indexedDb.getReplaceableEvent(account.pubkey, kinds.Mutelist) + ]) if (storedRelayListEvent) { setRelayList( storedRelayListEvent ? getRelayListFromRelayListEvent(storedRelayListEvent) : null @@ -135,35 +146,47 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { setProfileEvent(storedProfileEvent) setProfile(getProfileFromProfileEvent(storedProfileEvent)) } + if (storedFollowListEvent) { + setFollowListEvent(storedFollowListEvent) + } + if (storedMuteListEvent) { + setMuteListEvent(storedMuteListEvent) + } - client.fetchRelayListEvent(account.pubkey).then(async (relayListEvent) => { - if (!relayListEvent) { - if (storedRelayListEvent) return - - setRelayList({ write: BIG_RELAY_URLS, read: BIG_RELAY_URLS, originalRelays: [] }) - return - } - const event = await indexedDb.getReplaceableEvent(account.pubkey, kinds.RelayList) - if (event) { - setRelayList(getRelayListFromRelayListEvent(event)) - } + const relayListEvents = await client.fetchEvents(BIG_RELAY_URLS, { + kinds: [kinds.RelayList], + authors: [account.pubkey] }) - client.fetchProfileEvent(account.pubkey).then(async (profileEvent) => { - if (!profileEvent) { - if (storedProfileEvent) return - - setProfile({ - pubkey: account.pubkey, - username: formatPubkey(account.pubkey) - }) - return - } - const event = await indexedDb.getReplaceableEvent(account.pubkey, kinds.Metadata) - if (event) { - setProfileEvent(event) - setProfile(getProfileFromProfileEvent(event)) - } + const relayListEvent = getLatestEvent(relayListEvents) ?? storedRelayListEvent + const relayList = getRelayListFromRelayListEvent(relayListEvent) + if (relayListEvent) { + client.updateRelayListCache(relayListEvent) + await indexedDb.putReplaceableEvent(relayListEvent) + } + setRelayList(relayList) + + const events = await client.fetchEvents(relayList.write.concat(BIG_RELAY_URLS).slice(0, 4), { + kinds: [kinds.Metadata, kinds.Contacts, kinds.Mutelist], + authors: [account.pubkey] }) + const sortedEvents = events.sort((a, b) => b.created_at - a.created_at) + const profileEvent = sortedEvents.find((e) => e.kind === kinds.Metadata) + const followListEvent = sortedEvents.find((e) => e.kind === kinds.Contacts) + const muteListEvent = sortedEvents.find((e) => e.kind === kinds.Mutelist) + if (profileEvent) { + setProfileEvent(profileEvent) + setProfile(getProfileFromProfileEvent(profileEvent)) + await indexedDb.putReplaceableEvent(profileEvent) + } + if (followListEvent) { + setFollowListEvent(followListEvent) + await indexedDb.putReplaceableEvent(followListEvent) + } + if (muteListEvent) { + setMuteListEvent(muteListEvent) + await indexedDb.putReplaceableEvent(muteListEvent) + } + client.initUserIndexFromFollowings(account.pubkey, controller.signal) return controller } @@ -396,14 +419,6 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { return setOpenLoginDialog(true) } - const getRelayList = async (pubkey: string) => { - const storedRelayListEvent = await indexedDb.getReplaceableEvent(pubkey, kinds.RelayList) - if (storedRelayListEvent) { - return getRelayListFromRelayListEvent(storedRelayListEvent) - } - return await client.fetchRelayList(pubkey) - } - const updateRelayListEvent = async (relayListEvent: Event) => { const newRelayList = await indexedDb.putReplaceableEvent(relayListEvent) setRelayList(getRelayListFromRelayListEvent(newRelayList)) @@ -413,7 +428,22 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { const newProfileEvent = await indexedDb.putReplaceableEvent(profileEvent) setProfileEvent(newProfileEvent) setProfile(getProfileFromProfileEvent(newProfileEvent)) - client.updateProfileCache(newProfileEvent) + } + + const updateFollowListEvent = async (followListEvent: Event) => { + const newFollowListEvent = await indexedDb.putReplaceableEvent(followListEvent) + if (newFollowListEvent.id !== followListEvent.id) return + + setFollowListEvent(newFollowListEvent) + client.updateFollowListCache(newFollowListEvent) + } + + const updateMuteListEvent = async (muteListEvent: Event, tags: string[][]) => { + const newMuteListEvent = await indexedDb.putReplaceableEvent(muteListEvent) + if (newMuteListEvent.id !== muteListEvent.id) return + + await indexedDb.putMuteDecryptedTags(muteListEvent.id, tags) + setMuteListEvent(muteListEvent) } return ( @@ -423,6 +453,8 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { profile, profileEvent, relayList, + followListEvent, + muteListEvent, account, accounts: storage .getAccounts() @@ -442,9 +474,10 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { startLogin: () => setOpenLoginDialog(true), checkLogin, signEvent, - getRelayList, updateRelayListEvent, - updateProfileEvent + updateProfileEvent, + updateFollowListEvent, + updateMuteListEvent }} > {children} diff --git a/src/providers/NoteStatsProvider.tsx b/src/providers/NoteStatsProvider.tsx index 7d73114..f4cc195 100644 --- a/src/providers/NoteStatsProvider.tsx +++ b/src/providers/NoteStatsProvider.tsx @@ -1,26 +1,25 @@ +import { extractZapInfoFromReceipt } from '@/lib/event' import { tagNameEquals } from '@/lib/tag' import client from '@/services/client.service' -import { Event, kinds } from 'nostr-tools' +import dayjs from 'dayjs' +import { Event, Filter, kinds } from 'nostr-tools' import { createContext, useContext, useEffect, useState } from 'react' import { useNostr } from './NostrProvider' export type TNoteStats = { - likeCount: number - repostCount: number + likes: Set + reposts: Set + zaps: { pr: string; pubkey: string; amount: number; comment?: string }[] replyCount: number - hasLiked: boolean - hasReposted: boolean + updatedAt?: number } type TNoteStatsContext = { noteStatsMap: Map> updateNoteReplyCount: (noteId: string, replyCount: number) => void - markNoteAsLiked: (noteId: string) => void - markNoteAsReposted: (noteId: string) => void - fetchNoteLikeCount: (event: Event) => Promise - fetchNoteRepostCount: (event: Event) => Promise - fetchNoteLikedStatus: (event: Event) => Promise - fetchNoteRepostedStatus: (event: Event) => Promise + addZap: (eventId: string, pr: string, amount: number, comment?: string) => void + updateNoteStatsByEvents: (events: Event[]) => void + fetchNoteStats: (event: Event) => Promise | undefined> } const NoteStatsContext = createContext(undefined) @@ -38,145 +37,183 @@ export function NoteStatsProvider({ children }: { children: React.ReactNode }) { const { pubkey } = useNostr() useEffect(() => { - setNoteStatsMap((prev) => { - const newMap = new Map() - for (const [noteId, stats] of prev) { - newMap.set(noteId, { ...stats, hasLiked: undefined, hasReposted: undefined }) - } - return newMap - }) + const init = async () => { + if (!pubkey) return + const relayList = await client.fetchRelayList(pubkey) + const events = await client.fetchEvents(relayList.write.slice(0, 4), [ + { + authors: [pubkey], + kinds: [kinds.Reaction, kinds.Repost], + limit: 100 + }, + { + '#P': [pubkey], + kinds: [kinds.Zap], + limit: 100 + } + ]) + updateNoteStatsByEvents(events) + } + init() }, [pubkey]) - const fetchNoteLikeCount = async (event: Event) => { - const relayList = await client.fetchRelayList(event.pubkey) - const events = await client.fetchEvents(relayList.read.slice(0, 3), { - '#e': [event.id], - kinds: [kinds.Reaction], - limit: 500 - }) - const countMap = new Map() - for (const e of events) { - const targetEventId = e.tags.findLast(tagNameEquals('e'))?.[1] - if (targetEventId) { - countMap.set(targetEventId, (countMap.get(targetEventId) || 0) + 1) - } + const fetchNoteStats = async (event: Event) => { + const oldStats = noteStatsMap.get(event.id) + let since: number | undefined + if (oldStats?.updatedAt) { + since = oldStats.updatedAt } - setNoteStatsMap((prev) => { - const newMap = new Map(prev) - for (const [eventId, count] of countMap) { - const old = prev.get(eventId) - newMap.set( - eventId, - old ? { ...old, likeCount: Math.max(count, old.likeCount ?? 0) } : { likeCount: count } - ) + const [relayList, authorProfile] = await Promise.all([ + client.fetchRelayList(event.pubkey), + client.fetchProfile(event.pubkey) + ]) + const filters: Filter[] = [ + { + '#e': [event.id], + kinds: [kinds.Reaction], + limit: 500 + }, + { + '#e': [event.id], + kinds: [kinds.Repost], + limit: 100 } - return newMap - }) - return countMap.get(event.id) || 0 - } + ] - const fetchNoteRepostCount = async (event: Event) => { - const relayList = await client.fetchRelayList(event.pubkey) - const events = await client.fetchEvents(relayList.read.slice(0, 3), { - '#e': [event.id], - kinds: [kinds.Repost], - limit: 100 - }) - setNoteStatsMap((prev) => { - const newMap = new Map(prev) - const old = prev.get(event.id) - newMap.set( - event.id, - old - ? { ...old, repostCount: Math.max(events.length, old.repostCount ?? 0) } - : { repostCount: events.length } - ) - return newMap - }) - return events.length - } + if (authorProfile?.lightningAddress) { + filters.push({ + '#e': [event.id], + kinds: [kinds.Zap], + limit: 500 + }) + } - const fetchNoteLikedStatus = async (event: Event) => { - if (!pubkey) return false + if (pubkey) { + filters.push({ + '#e': [event.id], + authors: [pubkey], + kinds: [kinds.Reaction, kinds.Repost] + }) - const relayList = await client.fetchRelayList(pubkey) - const events = await client.fetchEvents(relayList.write, { - '#e': [event.id], - authors: [pubkey], - kinds: [kinds.Reaction] - }) - const likedEventIds = events - .map((e) => e.tags.findLast(tagNameEquals('e'))?.[1]) - .filter(Boolean) as string[] + if (authorProfile?.lightningAddress) { + filters.push({ + '#e': [event.id], + '#P': [pubkey], + kinds: [kinds.Zap] + }) + } + } - setNoteStatsMap((prev) => { - const newMap = new Map(prev) - likedEventIds.forEach((eventId) => { - const old = newMap.get(eventId) - newMap.set(eventId, old ? { ...old, hasLiked: true } : { hasLiked: true }) + if (since) { + filters.forEach((filter) => { + filter.since = since }) - if (!likedEventIds.includes(event.id)) { - const old = newMap.get(event.id) - newMap.set(event.id, old ? { ...old, hasLiked: false } : { hasLiked: false }) - } - return newMap + } + const events = await client.fetchEvents(relayList.read.slice(0, 4), filters) + updateNoteStatsByEvents(events) + let stats: Partial | undefined + setNoteStatsMap((prev) => { + const old = prev.get(event.id) || {} + prev.set(event.id, { ...old, updatedAt: dayjs().unix() }) + stats = prev.get(event.id) + return new Map(prev) }) - return likedEventIds.includes(event.id) + return stats } - const fetchNoteRepostedStatus = async (event: Event) => { - if (!pubkey) return false + const updateNoteStatsByEvents = (events: Event[]) => { + const newRepostsMap = new Map>() + const newLikesMap = new Map>() + const newZapsMap = new Map< + string, + { pr: string; pubkey: string; amount: number; comment?: string }[] + >() + events.forEach((evt) => { + if (evt.kind === kinds.Repost) { + const eventId = evt.tags.find(tagNameEquals('e'))?.[1] + if (!eventId) return + const newReposts = newRepostsMap.get(eventId) || new Set() + newReposts.add(evt.pubkey) + newRepostsMap.set(eventId, newReposts) + return + } + + if (evt.kind === kinds.Reaction) { + const targetEventId = evt.tags.findLast(tagNameEquals('e'))?.[1] + if (targetEventId) { + const newLikes = newLikesMap.get(targetEventId) || new Set() + newLikes.add(evt.pubkey) + newLikesMap.set(targetEventId, newLikes) + } + return + } - const relayList = await client.fetchRelayList(pubkey) - const events = await client.fetchEvents(relayList.write, { - '#e': [event.id], - authors: [pubkey], - kinds: [kinds.Repost] + if (evt.kind === kinds.Zap) { + const info = extractZapInfoFromReceipt(evt) + if (!info) return + const { eventId, senderPubkey, invoice, amount, comment } = info + if (!eventId || !senderPubkey) return + const newZaps = newZapsMap.get(eventId) || [] + newZaps.push({ pr: invoice, pubkey: senderPubkey, amount, comment }) + newZapsMap.set(eventId, newZaps) + return + } }) - setNoteStatsMap((prev) => { - const hasReposted = events.length > 0 - const newMap = new Map(prev) - const old = prev.get(event.id) - newMap.set(event.id, old ? { ...old, hasReposted } : { hasReposted }) - return newMap + newRepostsMap.forEach((newReposts, eventId) => { + const old = prev.get(eventId) || {} + const reposts = old.reposts || new Set() + newReposts.forEach((repost) => reposts.add(repost)) + prev.set(eventId, { ...old, reposts }) + }) + newLikesMap.forEach((newLikes, eventId) => { + const old = prev.get(eventId) || {} + const likes = old.likes || new Set() + newLikes.forEach((like) => likes.add(like)) + prev.set(eventId, { ...old, likes }) + }) + newZapsMap.forEach((newZaps, eventId) => { + const old = prev.get(eventId) || {} + const zaps = old.zaps || [] + const exists = new Set(zaps.map((zap) => zap.pr)) + newZaps.forEach((zap) => { + if (!exists.has(zap.pr)) { + exists.add(zap.pr) + zaps.push(zap) + } + }) + zaps.sort((a, b) => b.amount - a.amount) + prev.set(eventId, { ...old, zaps }) + }) + return new Map(prev) }) - return events.length > 0 + return } const updateNoteReplyCount = (noteId: string, replyCount: number) => { setNoteStatsMap((prev) => { const old = prev.get(noteId) if (!old) { - return new Map(prev).set(noteId, { replyCount }) + prev.set(noteId, { replyCount }) + return new Map(prev) } else if (old.replyCount === undefined || old.replyCount < replyCount) { - return new Map(prev).set(noteId, { ...old, replyCount }) + prev.set(noteId, { ...old, replyCount }) + return new Map(prev) } return prev }) } - const markNoteAsLiked = (noteId: string) => { + const addZap = (eventId: string, pr: string, amount: number, comment?: string) => { + if (!pubkey) return setNoteStatsMap((prev) => { - const old = prev.get(noteId) - return new Map(prev).set( - noteId, - old - ? { ...old, hasLiked: true, likeCount: (old.likeCount ?? 0) + 1 } - : { hasLiked: true, likeCount: 1 } - ) - }) - } - - const markNoteAsReposted = (noteId: string) => { - setNoteStatsMap((prev) => { - const old = prev.get(noteId) - return new Map(prev).set( - noteId, - old - ? { ...old, hasReposted: true, repostCount: (old.repostCount ?? 0) + 1 } - : { hasReposted: true, repostCount: 1 } - ) + const old = prev.get(eventId) + const zaps = old?.zaps || [] + prev.set(eventId, { + ...old, + zaps: [...zaps, { pr, pubkey, amount, comment }].sort((a, b) => b.amount - a.amount) + }) + return new Map(prev) }) } @@ -184,13 +221,10 @@ export function NoteStatsProvider({ children }: { children: React.ReactNode }) { {children} diff --git a/src/providers/NotificationProvider.tsx b/src/providers/NotificationProvider.tsx new file mode 100644 index 0000000..3242073 --- /dev/null +++ b/src/providers/NotificationProvider.tsx @@ -0,0 +1,143 @@ +import { BIG_RELAY_URLS, COMMENT_EVENT_KIND } from '@/constants' +import { TPrimaryPageName, usePrimaryPage } from '@/PageManager' +import client from '@/services/client.service' +import storage from '@/services/local-storage.service' +import dayjs from 'dayjs' +import { kinds } from 'nostr-tools' +import { SubCloser } from 'nostr-tools/abstract-pool' +import { createContext, useContext, useEffect, useRef, useState } from 'react' +import { useNostr } from './NostrProvider' + +type TNotificationContext = { + hasNewNotification: boolean +} + +const NotificationContext = createContext(undefined) + +export const useNotification = () => { + const context = useContext(NotificationContext) + if (!context) { + throw new Error('useNotification must be used within a NotificationProvider') + } + return context +} + +export function NotificationProvider({ children }: { children: React.ReactNode }) { + const { pubkey } = useNostr() + const { current } = usePrimaryPage() + const [hasNewNotification, setHasNewNotification] = useState(false) + const [lastReadTime, setLastReadTime] = useState(-1) + const previousPageRef = useRef(null) + + useEffect(() => { + if (current !== 'notifications' && previousPageRef.current === 'notifications') { + // navigate from notifications to other pages + setLastReadTime(dayjs().unix()) + setHasNewNotification(false) + } else if (current === 'notifications' && previousPageRef.current !== null) { + // navigate to notifications + setHasNewNotification(false) + } + previousPageRef.current = current + }, [current]) + + useEffect(() => { + if (!pubkey || lastReadTime < 0) return + storage.setLastReadNotificationTime(pubkey, lastReadTime) + }, [lastReadTime]) + + useEffect(() => { + if (!pubkey) return + setLastReadTime(storage.getLastReadNotificationTime(pubkey)) + setHasNewNotification(false) + }, [pubkey]) + + useEffect(() => { + if (!pubkey || lastReadTime < 0) return + + // Track if component is mounted + const isMountedRef = { current: true } + let currentSubCloser: SubCloser | null = null + + const subscribe = async () => { + if (!isMountedRef.current) return null + + try { + const relayList = await client.fetchRelayList(pubkey) + const relayUrls = relayList.read.concat(BIG_RELAY_URLS).slice(0, 4) + const subCloser = client.subscribe( + relayUrls, + [ + { + kinds: [ + kinds.ShortTextNote, + COMMENT_EVENT_KIND, + kinds.Reaction, + kinds.Repost, + kinds.Zap + ], + '#p': [pubkey], + since: lastReadTime ?? dayjs().unix(), + limit: 10 + } + ], + { + onevent: (evt) => { + if (evt.pubkey !== pubkey) { + setHasNewNotification(true) + subCloser.close() + } + }, + onclose: (reasons) => { + if (reasons.every((reason) => reason === 'closed by caller')) { + return + } + + // Only reconnect if still mounted and not a manual close + if (isMountedRef.current && currentSubCloser) { + setTimeout(() => { + if (isMountedRef.current) { + subscribe() + } + }, 5000) + } + } + } + ) + + currentSubCloser = subCloser + return subCloser + } catch (error) { + console.error('Subscription error:', error) + + // Retry on error if still mounted + if (isMountedRef.current) { + setTimeout(() => { + if (isMountedRef.current) { + subscribe() + } + }, 5000) + } + return null + } + } + + // Initial subscription + subscribe() + + // Cleanup function + return () => { + isMountedRef.current = false + if (currentSubCloser) { + currentSubCloser.close() + currentSubCloser = null + } + } + }, [lastReadTime, pubkey]) + + return ( + + {children} + + ) +} diff --git a/src/providers/ZapProvider.tsx b/src/providers/ZapProvider.tsx new file mode 100644 index 0000000..b7c4fe0 --- /dev/null +++ b/src/providers/ZapProvider.tsx @@ -0,0 +1,57 @@ +import storage from '@/services/local-storage.service' +import { createContext, useContext, useState } from 'react' + +type TZapContext = { + defaultZapSats: number + updateDefaultSats: (sats: number) => void + defaultZapComment: string + updateDefaultComment: (comment: string) => void + quickZap: boolean + updateQuickZap: (quickZap: boolean) => void +} + +const ZapContext = createContext(undefined) + +export const useZap = () => { + const context = useContext(ZapContext) + if (!context) { + throw new Error('useZap must be used within a ZapProvider') + } + return context +} + +export function ZapProvider({ children }: { children: React.ReactNode }) { + const [defaultZapSats, setDefaultZapSats] = useState(storage.getDefaultZapSats()) + const [defaultZapComment, setDefaultZapComment] = useState(storage.getDefaultZapComment()) + const [quickZap, setQuickZap] = useState(storage.getQuickZap()) + + const updateDefaultSats = (sats: number) => { + storage.setDefaultZapSats(sats) + setDefaultZapSats(sats) + } + + const updateDefaultComment = (comment: string) => { + storage.setDefaultZapComment(comment) + setDefaultZapComment(comment) + } + + const updateQuickZap = (quickZap: boolean) => { + storage.setQuickZap(quickZap) + setQuickZap(quickZap) + } + + return ( + + {children} + + ) +} diff --git a/src/routes.tsx b/src/routes.tsx index 5d7e9b9..1c703af 100644 --- a/src/routes.tsx +++ b/src/routes.tsx @@ -11,6 +11,7 @@ import ProfilePage from './pages/secondary/ProfilePage' import RelayPage from './pages/secondary/RelayPage' import RelaySettingsPage from './pages/secondary/RelaySettingsPage' import SettingsPage from './pages/secondary/SettingsPage' +import WalletPage from './pages/secondary/WalletPage' const ROUTES = [ { path: '/notes', element: }, @@ -21,6 +22,7 @@ const ROUTES = [ { path: '/users/:id/relays', element: }, { path: '/relay-settings', element: }, { path: '/settings', element: }, + { path: '/wallet', element: }, { path: '/profile-editor', element: }, { path: '/relays/:url', element: }, { path: '/mutes', element: } diff --git a/src/services/client.service.ts b/src/services/client.service.ts index 33ddf4a..481cd1f 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -17,6 +17,7 @@ import { SimplePool, VerifiedEvent } from 'nostr-tools' +import { SubscribeManyParams } from 'nostr-tools/abstract-pool' import { AbstractRelay } from 'nostr-tools/abstract-relay' import indexedDb from './indexed-db.service' @@ -44,28 +45,23 @@ class ClientService extends EventTarget { { cacheMap: this.eventCache } ) private fetchEventFromBigRelaysDataloader = new DataLoader( - this.eventBatchLoadFn.bind(this), - { cache: false } - ) - private profileEventDataloader = new DataLoader( - (ids) => Promise.all(ids.map((id) => this._fetchProfileEvent(id))), - { - cache: false, - maxBatchSize: 50 - } + this.fetchEventsFromBigRelays.bind(this), + { cache: false, batchScheduleFn: (callback) => setTimeout(callback, 200) } ) private fetchProfileEventFromBigRelaysDataloader = new DataLoader( this.profileEventBatchLoadFn.bind(this), { + batchScheduleFn: (callback) => setTimeout(callback, 200), cacheMap: new LRUCache>({ max: 1000 }), - maxBatchSize: 50 + maxBatchSize: 20 } ) private relayListEventDataLoader = new DataLoader( this.relayListEventBatchLoadFn.bind(this), { + batchScheduleFn: (callback) => setTimeout(callback, 200), cacheMap: new LRUCache>({ max: 1000 }), - maxBatchSize: 50 + maxBatchSize: 20 } ) private followListCache = new LRUCache>({ @@ -166,7 +162,8 @@ class ClientService extends EventTarget { needSort?: boolean } = {} ) { - const key = this.generateTimelineKey(urls, filter) + const relays = Array.from(new Set(urls)) + const key = this.generateTimelineKey(relays, filter) const timeline = this.timelines[key] let cachedEvents: NEvent[] = [] let since: number | undefined @@ -183,7 +180,7 @@ class ClientService extends EventTarget { } if (!timeline && needSort) { - this.timelines[key] = { refs: [], filter, urls } + this.timelines[key] = { refs: [], filter, urls: relays } } // eslint-disable-next-line @typescript-eslint/no-this-alias @@ -193,7 +190,7 @@ class ClientService extends EventTarget { let startedCount = 0 let eosedCount = 0 let eosed = false - const subPromises = urls.map(async (url) => { + const subPromises = relays.map(async (url) => { const relay = await this.pool.ensureRelay(url) let hasAuthed = false @@ -345,11 +342,19 @@ class ClientService extends EventTarget { } } - async query(urls: string[], filter: Filter) { + subscribe(urls: string[], filter: Filter | Filter[], params: SubscribeManyParams) { + const relays = Array.from(new Set(urls)) + const filters = Array.isArray(filter) ? filter : [filter] + return this.pool.subscribeMany(relays, filters, params) + } + + private async query(urls: string[], filter: Filter | Filter[], onevent?: (evt: NEvent) => void) { + const relays = Array.from(new Set(urls)) + const filters = Array.isArray(filter) ? filter : [filter] const _knownIds = new Set() const events: NEvent[] = [] await Promise.allSettled( - urls.map(async (url) => { + relays.map(async (url) => { // eslint-disable-next-line @typescript-eslint/no-this-alias const that = this const relay = await this.pool.ensureRelay(url) @@ -357,7 +362,7 @@ class ClientService extends EventTarget { return new Promise((resolve, reject) => { const startQuery = () => { - relay.subscribe([filter], { + relay.subscribe(filters, { receivedEvent(relay, id) { that.trackEventSeenOn(id, relay) }, @@ -384,6 +389,7 @@ class ClientService extends EventTarget { if (_knownIds.has(evt.id)) return _knownIds.add(evt.id) events.push(evt) + onevent?.(evt) } }) } @@ -421,10 +427,22 @@ class ClientService extends EventTarget { return events } - async fetchEvents(relayUrls: string[], filter: Filter, cache = false) { + async fetchEvents( + urls: string[], + filter: Filter | Filter[], + { + onevent, + cache = false + }: { + onevent?: (evt: NEvent) => void + cache?: boolean + } = {} + ) { + const relays = Array.from(new Set(urls)) const events = await this.query( - relayUrls.length > 0 ? relayUrls : this.currentRelayUrls.concat(BIG_RELAY_URLS), - filter + relays.length > 0 ? relays : this.currentRelayUrls.concat(BIG_RELAY_URLS), + filter, + onevent ) if (cache) { events.forEach((evt) => { @@ -460,12 +478,70 @@ class ClientService extends EventTarget { this.eventDataLoader.prime(event.id, Promise.resolve(event)) } - async fetchProfileEvent(id: string): Promise { - return await this.profileEventDataloader.load(id) + async fetchProfileEvent(id: string, skipCache: boolean = false): Promise { + let pubkey: string | undefined + let relays: string[] = [] + if (/^[0-9a-f]{64}$/.test(id)) { + pubkey = id + } else { + const { data, type } = nip19.decode(id) + switch (type) { + case 'npub': + pubkey = data + break + case 'nprofile': + pubkey = data.pubkey + if (data.relays) relays = data.relays + break + } + } + + if (!pubkey) { + throw new Error('Invalid id') + } + if (!skipCache) { + const localProfile = await indexedDb.getReplaceableEvent(pubkey, kinds.Metadata) + if (localProfile) { + this.addUsernameToIndex(localProfile) + return localProfile + } + } + const profileFromBigRelays = await this.fetchProfileEventFromBigRelaysDataloader.load(pubkey) + if (profileFromBigRelays) { + this.addUsernameToIndex(profileFromBigRelays) + await indexedDb.putReplaceableEvent(profileFromBigRelays) + return profileFromBigRelays + } + + if (!relays.length) { + return undefined + } + + const profileEvent = await this.tryHarderToFetchEvent( + relays, + { + authors: [pubkey], + kinds: [kinds.Metadata], + limit: 1 + }, + true + ) + + if (profileEvent) { + this.addUsernameToIndex(profileEvent) + indexedDb.putReplaceableEvent(profileEvent) + } + + return profileEvent } - async fetchProfile(id: string): Promise { - const profileEvent = await this.fetchProfileEvent(id) + async fetchProfile(id: string, skipCache: boolean = false): Promise { + let profileEvent: NEvent | undefined + if (skipCache) { + profileEvent = await this.fetchProfileEvent(id, skipCache) + } else { + profileEvent = await this.fetchProfileEvent(id) + } if (profileEvent) { return getProfileFromProfileEvent(profileEvent) } @@ -478,11 +554,6 @@ class ClientService extends EventTarget { } } - updateProfileCache(event: NEvent) { - this.profileEventDataloader.clear(event.pubkey) - this.profileEventDataloader.prime(event.pubkey, Promise.resolve(event)) - } - async fetchProfiles(relayUrls: string[], filter: Filter): Promise { const events = await this.query(relayUrls, { ...filter, @@ -490,7 +561,6 @@ class ClientService extends EventTarget { }) const profileEvents = events.sort((a, b) => b.created_at - a.created_at) - profileEvents.forEach((profile) => this.profileEventDataloader.prime(profile.pubkey, profile)) await Promise.all(profileEvents.map((profile) => this.addUsernameToIndex(profile))) return profileEvents.map((profileEvent) => getProfileFromProfileEvent(profileEvent)) } @@ -519,17 +589,22 @@ class ClientService extends EventTarget { return event } - async fetchFollowings(pubkey: string) { - const followListEvent = await this.fetchFollowListEvent(pubkey) + async fetchFollowings(pubkey: string, storeToIndexedDb = false) { + const followListEvent = await this.fetchFollowListEvent(pubkey, storeToIndexedDb) return followListEvent ? extractPubkeysFromEventTags(followListEvent.tags) : [] } - updateFollowListCache(pubkey: string, event: NEvent) { - this.followListCache.set(pubkey, Promise.resolve(event)) + updateFollowListCache(event: NEvent) { + this.followListCache.set(event.pubkey, Promise.resolve(event)) + } + + updateRelayListCache(event: NEvent) { + this.relayListEventDataLoader.clear(event.pubkey) + this.relayListEventDataLoader.prime(event.pubkey, Promise.resolve(event)) } async calculateOptimalReadRelays(pubkey: string) { - const followings = await this.fetchFollowings(pubkey) + const followings = await this.fetchFollowings(pubkey, true) const [selfRelayListEvent, ...relayListEvents] = await this.relayListEventDataLoader.loadMany([ pubkey, ...followings @@ -544,7 +619,6 @@ class ClientService extends EventTarget { pubkeyRelayListMap.set(evt.pubkey, getRelayListFromRelayListEvent(evt).write) } }) - let uncoveredPubkeys = [...followings] const readRelays: { url: string; pubkeys: string[] }[] = [] while (uncoveredPubkeys.length) { @@ -571,7 +645,6 @@ class ClientService extends EventTarget { } } if (!maxCoveredRelay) break - readRelays.push(maxCoveredRelay) uncoveredPubkeys = uncoveredPubkeys.filter( (pubkey) => !maxCoveredRelay!.pubkeys.includes(pubkey) @@ -588,12 +661,13 @@ class ClientService extends EventTarget { } async initUserIndexFromFollowings(pubkey: string, signal: AbortSignal) { - const followings = await this.fetchFollowings(pubkey) - for (let i = 0; i * 50 < followings.length; i++) { + const followings = await this.fetchFollowings(pubkey, true) + for (let i = 0; i * 20 < followings.length; i++) { if (signal.aborted) return - - await this.profileEventDataloader.loadMany(followings.slice(i * 50, (i + 1) * 50)) - await new Promise((resolve) => setTimeout(resolve, 30000)) + await Promise.all( + followings.slice(i * 20, (i + 1) * 20).map((pubkey) => this.fetchProfileEvent(pubkey)) + ) + await new Promise((resolve) => setTimeout(resolve, 1000)) } } @@ -665,9 +739,7 @@ class ClientService extends EventTarget { let event: NEvent | undefined if (filter.ids) { event = await this.fetchEventById(relays, filter.ids[0]) - } - - if (!event) { + } else { event = await this.tryHarderToFetchEvent(relays, filter) } @@ -678,62 +750,6 @@ class ClientService extends EventTarget { return event } - private async _fetchProfileEvent(id: string): Promise { - let pubkey: string | undefined - let relays: string[] = [] - if (/^[0-9a-f]{64}$/.test(id)) { - pubkey = id - } else { - const { data, type } = nip19.decode(id) - switch (type) { - case 'npub': - pubkey = data - break - case 'nprofile': - pubkey = data.pubkey - if (data.relays) relays = data.relays - break - } - } - - if (!pubkey) { - throw new Error('Invalid id') - } - const localProfile = await indexedDb.getReplaceableEvent(pubkey, kinds.Metadata) - if (localProfile) { - this.addUsernameToIndex(localProfile) - return localProfile - } - const profileFromBigRelays = await this.fetchProfileEventFromBigRelaysDataloader.load(pubkey) - if (profileFromBigRelays) { - this.addUsernameToIndex(profileFromBigRelays) - await indexedDb.putReplaceableEvent(profileFromBigRelays) - return profileFromBigRelays - } - - const profileEvent = await this.tryHarderToFetchEvent( - relays, - { - authors: [pubkey], - kinds: [kinds.Metadata], - limit: 1 - }, - true - ) - if (pubkey !== id) { - this.profileEventDataloader.prime(pubkey, Promise.resolve(profileEvent)) - } - - if (profileEvent) { - await Promise.allSettled([ - this.addUsernameToIndex(profileEvent), - indexedDb.putReplaceableEvent(profileEvent) - ]) - } - - return profileEvent - } - private async addUsernameToIndex(profileEvent: NEvent) { try { const profileObj = JSON.parse(profileEvent.content) @@ -772,7 +788,7 @@ class ClientService extends EventTarget { return events.sort((a, b) => b.created_at - a.created_at)[0] } - private async eventBatchLoadFn(ids: readonly string[]) { + private async fetchEventsFromBigRelays(ids: readonly string[]) { const events = await this.query(BIG_RELAY_URLS, { ids: Array.from(new Set(ids)), limit: ids.length @@ -803,10 +819,8 @@ class ClientService extends EventTarget { return eventsMap.get(pubkey) }) - await Promise.allSettled( - profileEvents.map( - (profileEvent) => profileEvent && indexedDb.putReplaceableEvent(profileEvent) - ) + profileEvents.forEach( + (profileEvent) => profileEvent && indexedDb.putReplaceableEvent(profileEvent) ) return profileEvents } @@ -830,9 +844,7 @@ class ClientService extends EventTarget { eventsMap.set(pubkey, event) } } - await Promise.allSettled( - Array.from(eventsMap.values()).map((evt) => indexedDb.putReplaceableEvent(evt)) - ) + Array.from(eventsMap.values()).forEach((evt) => indexedDb.putReplaceableEvent(evt)) nonExistingPubkeys.forEach((pubkey) => { const event = eventsMap.get(pubkey) if (event) { @@ -846,6 +858,11 @@ class ClientService extends EventTarget { } private async _fetchFollowListEvent(pubkey: string) { + const storedFollowListEvent = await indexedDb.getReplaceableEvent(pubkey, kinds.Contacts) + if (storedFollowListEvent) { + return storedFollowListEvent + } + const relayList = await this.fetchRelayList(pubkey) const followListEvents = await this.query(relayList.write.concat(BIG_RELAY_URLS), { authors: [pubkey], diff --git a/src/services/indexed-db.service.ts b/src/services/indexed-db.service.ts index 2e382e9..7b4d178 100644 --- a/src/services/indexed-db.service.ts +++ b/src/services/indexed-db.service.ts @@ -44,25 +44,26 @@ class IndexedDbService { } request.onupgradeneeded = () => { - this.db = request.result - if (!this.db.objectStoreNames.contains(StoreNames.PROFILE_EVENTS)) { - this.db.createObjectStore(StoreNames.PROFILE_EVENTS, { keyPath: 'key' }) + const db = request.result + if (!db.objectStoreNames.contains(StoreNames.PROFILE_EVENTS)) { + db.createObjectStore(StoreNames.PROFILE_EVENTS, { keyPath: 'key' }) } - if (!this.db.objectStoreNames.contains(StoreNames.RELAY_LIST_EVENTS)) { - this.db.createObjectStore(StoreNames.RELAY_LIST_EVENTS, { keyPath: 'key' }) + if (!db.objectStoreNames.contains(StoreNames.RELAY_LIST_EVENTS)) { + db.createObjectStore(StoreNames.RELAY_LIST_EVENTS, { keyPath: 'key' }) } - if (!this.db.objectStoreNames.contains(StoreNames.FOLLOW_LIST_EVENTS)) { - this.db.createObjectStore(StoreNames.FOLLOW_LIST_EVENTS, { keyPath: 'key' }) + if (!db.objectStoreNames.contains(StoreNames.FOLLOW_LIST_EVENTS)) { + db.createObjectStore(StoreNames.FOLLOW_LIST_EVENTS, { keyPath: 'key' }) } - if (!this.db.objectStoreNames.contains(StoreNames.MUTE_LIST_EVENTS)) { - this.db.createObjectStore(StoreNames.MUTE_LIST_EVENTS, { keyPath: 'key' }) + if (!db.objectStoreNames.contains(StoreNames.MUTE_LIST_EVENTS)) { + db.createObjectStore(StoreNames.MUTE_LIST_EVENTS, { keyPath: 'key' }) } - if (!this.db.objectStoreNames.contains(StoreNames.MUTE_DECRYPTED_TAGS)) { - this.db.createObjectStore(StoreNames.MUTE_DECRYPTED_TAGS, { keyPath: 'key' }) + if (!db.objectStoreNames.contains(StoreNames.MUTE_DECRYPTED_TAGS)) { + db.createObjectStore(StoreNames.MUTE_DECRYPTED_TAGS, { keyPath: 'key' }) } - if (!this.db.objectStoreNames.contains(StoreNames.RELAY_INFO_EVENTS)) { - this.db.createObjectStore(StoreNames.RELAY_INFO_EVENTS, { keyPath: 'key' }) + if (!db.objectStoreNames.contains(StoreNames.RELAY_INFO_EVENTS)) { + db.createObjectStore(StoreNames.RELAY_INFO_EVENTS, { keyPath: 'key' }) } + this.db = db } }) setTimeout(() => this.cleanUp(), 1000 * 60) // 1 minute @@ -98,6 +99,10 @@ class IndexedDbService { reject(event) } } + + getRequest.onerror = (event) => { + reject(event) + } }) } @@ -264,12 +269,28 @@ class IndexedDbService { return } - const expirationTimestamp = Date.now() - 1000 * 60 * 60 * 24 // 1 day - const transaction = this.db!.transaction(Object.values(StoreNames), 'readwrite') + const stores = [ + { name: StoreNames.PROFILE_EVENTS, expirationTimestamp: Date.now() - 1000 * 60 * 60 * 24 }, // 1 day + { name: StoreNames.RELAY_LIST_EVENTS, expirationTimestamp: Date.now() - 1000 * 60 * 60 * 24 }, // 1 day + { + name: StoreNames.FOLLOW_LIST_EVENTS, + expirationTimestamp: Date.now() - 1000 * 60 * 60 * 24 + }, // 1 day + { name: StoreNames.RELAY_INFO_EVENTS, expirationTimestamp: -1 }, + { name: StoreNames.MUTE_LIST_EVENTS, expirationTimestamp: -1 }, + { name: StoreNames.MUTE_DECRYPTED_TAGS, expirationTimestamp: -1 } + ] + const transaction = this.db!.transaction( + stores.map((store) => store.name), + 'readwrite' + ) await Promise.allSettled( - Object.values(StoreNames).map((storeName) => { + stores.map(({ name, expirationTimestamp }) => { + if (expirationTimestamp < 0) { + return Promise.resolve() + } return new Promise((resolve, reject) => { - const store = transaction.objectStore(storeName) + const store = transaction.objectStore(name) const request = store.openCursor() request.onsuccess = (event) => { const cursor = (event.target as IDBRequest).result diff --git a/src/services/lightning.service.ts b/src/services/lightning.service.ts new file mode 100644 index 0000000..acc5cb3 --- /dev/null +++ b/src/services/lightning.service.ts @@ -0,0 +1,192 @@ +import { BIG_RELAY_URLS } from '@/constants' +import { extractZapInfoFromReceipt } from '@/lib/event' +import { TProfile } from '@/types' +import { + init, + launchPaymentModal, + onConnected, + onDisconnected +} from '@getalby/bitcoin-connect-react' +import { Invoice } from '@getalby/lightning-tools' +import { bech32 } from '@scure/base' +import { WebLNProvider } from '@webbtc/webln-types' +import dayjs from 'dayjs' +import { Filter, kinds } from 'nostr-tools' +import { SubCloser } from 'nostr-tools/abstract-pool' +import { makeZapRequest } from 'nostr-tools/nip57' +import { utf8Decoder } from 'nostr-tools/utils' +import client from './client.service' + +class LightningService { + static instance: LightningService + private provider: WebLNProvider | null = null + + constructor() { + if (!LightningService.instance) { + LightningService.instance = this + init({ + appName: 'Jumble', + showBalance: false + }) + onConnected((provider) => { + this.provider = provider + }) + onDisconnected(() => { + this.provider = null + }) + } + return LightningService.instance + } + + async zap( + sender: string, + recipient: string, + sats: number, + comment: string, + eventId?: string, + closeOuterModel?: () => void + ): Promise<{ preimage: string; invoice: string }> { + if (!client.signer) { + throw new Error('You need to be logged in to zap') + } + + const [profile, receiptRelayList, senderRelayList] = await Promise.all([ + client.fetchProfile(recipient, true), + client.fetchRelayList(recipient), + sender + ? client.fetchRelayList(sender) + : Promise.resolve({ read: BIG_RELAY_URLS, write: BIG_RELAY_URLS }) + ]) + if (!profile) { + throw new Error('Recipient not found') + } + const zapEndpoint = await this.getZapEndpoint(profile) + if (!zapEndpoint) { + throw new Error("Recipient's lightning address is invalid") + } + const { callback, lnurl } = zapEndpoint + const amount = sats * 1000 + const zapRequestDraft = makeZapRequest({ + profile: recipient, + event: eventId ?? null, + amount, + relays: receiptRelayList.read + .slice(0, 4) + .concat(senderRelayList.write.slice(0, 3)) + .concat(BIG_RELAY_URLS), + comment + }) + const zapRequest = await client.signer(zapRequestDraft) + const zapRequestRes = await fetch( + `${callback}?amount=${amount}&nostr=${encodeURI(JSON.stringify(zapRequest))}&lnurl=${lnurl}` + ) + const zapRequestResBody = await zapRequestRes.json() + if (zapRequestResBody.error) { + throw new Error(zapRequestResBody.error) + } + const { pr, verify } = zapRequestResBody + if (!pr) { + throw new Error('Failed to create invoice') + } + + if (this.provider) { + const { preimage } = await this.provider.sendPayment(pr) + closeOuterModel?.() + return { preimage, invoice: pr } + } + + return new Promise((resolve) => { + closeOuterModel?.() + let checkPaymentInterval: ReturnType | undefined + let subCloser: SubCloser | undefined + const { setPaid } = launchPaymentModal({ + invoice: pr, + onPaid: (response) => { + clearInterval(checkPaymentInterval) + subCloser?.close() + resolve({ preimage: response.preimage, invoice: pr }) + }, + onCancelled: () => { + clearInterval(checkPaymentInterval) + subCloser?.close() + } + }) + + if (verify) { + checkPaymentInterval = setInterval(async () => { + const invoice = new Invoice({ pr, verify }) + const paid = await invoice.verifyPayment() + + if (paid && invoice.preimage) { + setPaid({ + preimage: invoice.preimage + }) + } + }, 1000) + } else { + const filter: Filter = { + kinds: [kinds.Zap], + '#p': [recipient], + since: dayjs().subtract(1, 'minute').unix() + } + if (eventId) { + filter['#e'] = [eventId] + } + subCloser = client.subscribe( + senderRelayList.write.concat(BIG_RELAY_URLS).slice(0, 4), + filter, + { + onevent: (evt) => { + const info = extractZapInfoFromReceipt(evt) + if (!info) return + + if (info.invoice === pr) { + setPaid({ preimage: info.preimage ?? '' }) + } + } + } + ) + } + }) + } + + private async getZapEndpoint(profile: TProfile): Promise { + try { + let lnurl: string = '' + + // Some clients have incorrectly filled in the positions for lud06 and lud16 + if (!profile.lightningAddress) { + return null + } + + if (profile.lightningAddress.includes('@')) { + const [name, domain] = profile.lightningAddress.split('@') + lnurl = new URL(`/.well-known/lnurlp/${name}`, `https://${domain}`).toString() + } else { + const { words } = bech32.decode(profile.lightningAddress, 1000) + const data = bech32.fromWords(words) + lnurl = utf8Decoder.decode(data) + } + + const res = await fetch(lnurl) + const body = await res.json() + + if (body.allowsNostr && body.nostrPubkey) { + return { + callback: body.callback, + lnurl + } + } + } catch (err) { + console.error(err) + } + + return null + } +} + +const instance = new LightningService() +export default instance diff --git a/src/services/local-storage.service.ts b/src/services/local-storage.service.ts index ce51106..946aaa2 100644 --- a/src/services/local-storage.service.ts +++ b/src/services/local-storage.service.ts @@ -48,6 +48,10 @@ class LocalStorageService { private accounts: TAccount[] = [] private currentAccount: TAccount | null = null private noteListMode: TNoteListMode = 'posts' + private lastReadNotificationTimeMap: Record = {} + private defaultZapSats: number = 21 + private defaultZapComment: string = 'Zap!' + private quickZap: boolean = false constructor() { if (!LocalStorageService.instance) { @@ -75,6 +79,9 @@ class LocalStorageService { noteListModeStr && ['posts', 'postsAndReplies', 'pictures'].includes(noteListModeStr) ? (noteListModeStr as TNoteListMode) : 'posts' + const lastReadNotificationTimeMapStr = + window.localStorage.getItem(StorageKey.LAST_READ_NOTIFICATION_TIME_MAP) ?? '{}' + this.lastReadNotificationTimeMap = JSON.parse(lastReadNotificationTimeMapStr) const relaySetsStr = window.localStorage.getItem(StorageKey.RELAY_SETS) if (!relaySetsStr) { @@ -103,6 +110,16 @@ class LocalStorageService { this.activeRelaySetId = window.localStorage.getItem(StorageKey.ACTIVE_RELAY_SET_ID) ?? null } + const defaultZapSatsStr = window.localStorage.getItem(StorageKey.DEFAULT_ZAP_SATS) + if (defaultZapSatsStr) { + const num = parseInt(defaultZapSatsStr) + if (!isNaN(num)) { + this.defaultZapSats = num + } + } + this.defaultZapComment = window.localStorage.getItem(StorageKey.DEFAULT_ZAP_COMMENT) ?? 'Zap!' + this.quickZap = window.localStorage.getItem(StorageKey.QUICK_ZAP) === 'true' + // Clean up deprecated data window.localStorage.removeItem(StorageKey.ACCOUNT_PROFILE_EVENT_MAP) window.localStorage.removeItem(StorageKey.ACCOUNT_FOLLOW_LIST_EVENT_MAP) @@ -209,6 +226,45 @@ class LocalStorageService { this.currentAccount = act window.localStorage.setItem(StorageKey.CURRENT_ACCOUNT, JSON.stringify(act)) } + + getDefaultZapSats() { + return this.defaultZapSats + } + + setDefaultZapSats(sats: number) { + this.defaultZapSats = sats + window.localStorage.setItem(StorageKey.DEFAULT_ZAP_SATS, sats.toString()) + } + + getDefaultZapComment() { + return this.defaultZapComment + } + + setDefaultZapComment(comment: string) { + this.defaultZapComment = comment + window.localStorage.setItem(StorageKey.DEFAULT_ZAP_COMMENT, comment) + } + + getQuickZap() { + return this.quickZap + } + + setQuickZap(quickZap: boolean) { + this.quickZap = quickZap + window.localStorage.setItem(StorageKey.QUICK_ZAP, quickZap.toString()) + } + + getLastReadNotificationTime(pubkey: string) { + return this.lastReadNotificationTimeMap[pubkey] ?? 0 + } + + setLastReadNotificationTime(pubkey: string, time: number) { + this.lastReadNotificationTimeMap[pubkey] = time + window.localStorage.setItem( + StorageKey.LAST_READ_NOTIFICATION_TIME_MAP, + JSON.stringify(this.lastReadNotificationTimeMap) + ) + } } const instance = new LocalStorageService() diff --git a/src/services/relay-info.service.ts b/src/services/relay-info.service.ts index 35af7b5..95eca04 100644 --- a/src/services/relay-info.service.ts +++ b/src/services/relay-info.service.ts @@ -139,7 +139,7 @@ class RelayInfoService { relayInfos.forEach((relayInfo) => this.addRelayInfo(relayInfo)) this.relayUrlsForRandom = Array.from(this.relayInfoMap.keys()) - const loadFromInternet = async () => { + const loadFromInternet = async (slowFetch: boolean = true) => { let until: number = Math.round(Date.now() / 1000) const since = until - 60 * 60 * 48 @@ -149,23 +149,28 @@ class RelayInfoService { kinds: [30166], since, until, - limit: 1000 + limit: slowFetch ? 100 : 1000 }) const events = relayInfoEvents.sort((a, b) => b.created_at - a.created_at) if (events.length === 0) { break } - await Promise.allSettled(events.map((event) => indexedDb.putRelayInfoEvent(event))) + for (const event of events) { + await indexedDb.putRelayInfoEvent(event) + const relayInfo = formatRelayInfoEvents([event])[0] + await this.addRelayInfo(relayInfo) + } until = events[events.length - 1].created_at - 1 - const relayInfos = formatRelayInfoEvents(events) - relayInfos.forEach((relayInfo) => this.addRelayInfo(relayInfo)) + if (slowFetch) { + await new Promise((resolve) => setTimeout(resolve, 1000)) + } } this.relayUrlsForRandom = Array.from(this.relayInfoMap.keys()) } if (localRelayInfos.length === 0) { - await loadFromInternet() + await loadFromInternet(false) } else { - loadFromInternet() + setTimeout(loadFromInternet, 1000 * 20) // 20 seconds } } diff --git a/src/types.ts b/src/types.ts index 35d4aee..9e277c1 100644 --- a/src/types.ts +++ b/src/types.ts @@ -9,6 +9,9 @@ export type TProfile = { nip05?: string about?: string website?: string + lud06?: string + lud16?: string + lightningAddress?: string created_at?: number } export type TMailboxRelayScope = 'read' | 'write' | 'both' @@ -98,6 +101,8 @@ export type TImageInfo = { url: string; blurHash?: string; dim?: { width: number export type TNoteListMode = 'posts' | 'postsAndReplies' | 'pictures' +export type TNotificationType = 'all' | 'mentions' | 'reactions' | 'zaps' + export type TPageRef = { scrollToTop: () => void } export type TNip66RelayInfo = TRelayInfo & {