feat: zap (#107)
This commit is contained in:
167
package-lock.json
generated
167
package-lock.json
generated
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -11,12 +11,14 @@ 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 (
|
||||
<ThemeProvider>
|
||||
<ScreenSizeProvider>
|
||||
<NostrProvider>
|
||||
<ZapProvider>
|
||||
<RelaySetsProvider>
|
||||
<FollowListProvider>
|
||||
<MuteListProvider>
|
||||
@@ -29,6 +31,7 @@ export default function App(): JSX.Element {
|
||||
</MuteListProvider>
|
||||
</FollowListProvider>
|
||||
</RelaySetsProvider>
|
||||
</ZapProvider>
|
||||
</NostrProvider>
|
||||
</ScreenSizeProvider>
|
||||
</ThemeProvider>
|
||||
|
||||
@@ -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,6 +227,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
|
||||
: 0
|
||||
}}
|
||||
>
|
||||
<NotificationProvider>
|
||||
{!!secondaryStack.length &&
|
||||
secondaryStack.map((item, index) => (
|
||||
<div
|
||||
@@ -248,6 +250,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
|
||||
{element}
|
||||
</div>
|
||||
))}
|
||||
</NotificationProvider>
|
||||
</SecondaryPageContext.Provider>
|
||||
</PrimaryPageContext.Provider>
|
||||
)
|
||||
@@ -267,6 +270,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
|
||||
currentIndex: secondaryStack.length ? secondaryStack[secondaryStack.length - 1].index : 0
|
||||
}}
|
||||
>
|
||||
<NotificationProvider>
|
||||
<div className="flex h-screen overflow-hidden">
|
||||
<Sidebar />
|
||||
<Separator orientation="vertical" />
|
||||
@@ -299,6 +303,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</NotificationProvider>
|
||||
</SecondaryPageContext.Provider>
|
||||
</PrimaryPageContext.Provider>
|
||||
)
|
||||
|
||||
@@ -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
|
||||
</div>
|
||||
<div>
|
||||
Made by{' '}
|
||||
<Username
|
||||
userId={'npub1syjmjy0dp62dhccq3g97fr87tngvpvzey08llyt6ul58m2zqpzps9wf6wl'}
|
||||
className="inline-block text-primary"
|
||||
showAt
|
||||
/>
|
||||
Made by <Username userId={CODY_PUBKEY} className="inline-block text-primary" showAt />
|
||||
</div>
|
||||
<div>
|
||||
Source code:{' '}
|
||||
@@ -30,30 +28,26 @@ export default function AboutInfoDialog({ children }: { children: React.ReactNod
|
||||
>
|
||||
GitHub
|
||||
</a>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
If you like Jumble, please consider giving it a star ⭐
|
||||
</div>
|
||||
<div>
|
||||
If you like this project, you can buy me a coffee ☕️ <br />
|
||||
<div className="font-semibold">⚡️ codytseng@getalby.com ⚡️</div>
|
||||
</div>
|
||||
<div className="text-muted-foreground">
|
||||
Version: v{__APP_VERSION__} ({__GIT_COMMIT__})
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
if (isSmallScreen) {
|
||||
return (
|
||||
<Drawer>
|
||||
<Drawer open={open} onOpenChange={setOpen}>
|
||||
<DrawerTrigger asChild>{children}</DrawerTrigger>
|
||||
<DrawerContent>
|
||||
<div className="p-4">{content}</div>
|
||||
<div className="p-4 space-y-4">{content}</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||
<DialogContent>{content}</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -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 (
|
||||
<BottomNavigationBarItem
|
||||
active={current === 'notifications'}
|
||||
onClick={() => navigate('notifications')}
|
||||
>
|
||||
<div className="relative">
|
||||
<Bell />
|
||||
{hasNewNotification && (
|
||||
<div className="absolute -top-1 -right-1 w-2 h-2 bg-primary rounded-full" />
|
||||
)}
|
||||
</div>
|
||||
</BottomNavigationBarItem>
|
||||
)
|
||||
}
|
||||
|
||||
49
src/components/Donation/index.tsx
Normal file
49
src/components/Donation/index.tsx
Normal file
@@ -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<number | undefined>(undefined)
|
||||
|
||||
return (
|
||||
<div className={cn('p-4 border rounded-lg space-y-4', className)}>
|
||||
<div className="text-center font-semibold">{t('Enjoying Jumble?')}</div>
|
||||
<div className="text-center text-muted-foreground">
|
||||
{t('Your donation helps me maintain Jumble and make it better! 😊')}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{[
|
||||
{ amount: 1000, text: '☕️ 1k' },
|
||||
{ amount: 10000, text: '🍜 10k' },
|
||||
{ amount: 100000, text: '🍣 100k' },
|
||||
{ amount: 1000000, text: '✈️ 1M' }
|
||||
].map(({ amount, text }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="secondary"
|
||||
className=""
|
||||
key={amount}
|
||||
onClick={() => {
|
||||
setDonationAmount(amount)
|
||||
setOpen(true)
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<ZapDialog
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
pubkey={CODY_PUBKEY}
|
||||
defaultAmount={donationAmount}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -13,14 +13,7 @@ export function EmbeddedMention({ userId }: { userId: string }) {
|
||||
}
|
||||
|
||||
export function EmbeddedMentionText({ userId }: { userId: string }) {
|
||||
return (
|
||||
<SimpleUsername
|
||||
userId={userId}
|
||||
showAt
|
||||
className="font-normal inline truncate"
|
||||
withoutSkeleton
|
||||
/>
|
||||
)
|
||||
return <SimpleUsername userId={userId} showAt className="inline truncate" withoutSkeleton />
|
||||
}
|
||||
|
||||
export const embeddedNostrNpubRenderer: TEmbeddedRenderer = {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -61,6 +61,7 @@ export default function Image({
|
||||
)}
|
||||
onLoad={() => {
|
||||
setIsLoading(false)
|
||||
setHasError(false)
|
||||
setTimeout(() => setDisplayBlurHash(false), 500)
|
||||
}}
|
||||
onError={() => {
|
||||
|
||||
@@ -23,7 +23,7 @@ export default function Nip05({ pubkey }: { pubkey: string }) {
|
||||
return (
|
||||
nip05Name &&
|
||||
nip05Domain && (
|
||||
<div className="flex items-center space-x-1 truncate [&_svg]:size-5">
|
||||
<div className="flex items-center space-x-1 truncate">
|
||||
{nip05Name !== '_' ? (
|
||||
<div className="text-sm text-muted-foreground truncate">@{nip05Name}</div>
|
||||
) : 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 ? <BadgeCheck /> : <BadgeAlert />}
|
||||
{nip05IsVerified ? <BadgeCheck className="size-4" /> : <BadgeAlert className="size-4" />}
|
||||
<div className="text-sm truncate">{nip05Domain}</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -17,7 +17,7 @@ export default function RepostDescription({
|
||||
<div className={cn('flex gap-1 text-sm items-center text-muted-foreground mb-1', className)}>
|
||||
<Repeat2 size={16} className="shrink-0" />
|
||||
<Username userId={reposter} className="font-semibold truncate" skeletonClassName="h-3" />
|
||||
<div>{t('reposted')}</div>
|
||||
<div className="shrink-0">{t('reposted')}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
className={cn(
|
||||
'sticky top-12 bg-background z-30 duration-700 transition-transform',
|
||||
'sticky top-12 bg-background z-30 duration-700 transition-transform select-none',
|
||||
deepBrowsing && lastScrollTop > 800 ? '-translate-y-[calc(100%+12rem)]' : ''
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 { repostCount, hasReposted } = useMemo(() => {
|
||||
const stats = noteStatsMap.get(event.id) || {}
|
||||
return {
|
||||
repostCount: stats.reposts?.size,
|
||||
hasReposted: pubkey ? stats.reposts?.has(pubkey) : false
|
||||
}
|
||||
}, [noteStatsMap, event.id])
|
||||
const canRepost = !hasReposted && !reposting
|
||||
|
||||
useEffect(() => {
|
||||
if (!canFetch) return
|
||||
|
||||
if (repostCount === undefined) {
|
||||
fetchNoteRepostCount(event)
|
||||
}
|
||||
if (hasReposted === undefined) {
|
||||
fetchNoteRepostedStatus(event)
|
||||
}
|
||||
}, [canFetch, event])
|
||||
|
||||
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 {
|
||||
|
||||
43
src/components/NoteStats/TopZaps.tsx
Normal file
43
src/components/NoteStats/TopZaps.tsx
Normal file
@@ -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 (
|
||||
<ScrollArea className="pb-2 mb-1">
|
||||
<div className="flex gap-1">
|
||||
{topZaps.map((zap) => (
|
||||
<div
|
||||
key={zap.pr}
|
||||
className="flex gap-1 py-1 pl-1 pr-2 text-sm rounded-full bg-muted items-center text-yellow-400 clickable"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
push(toProfile(zap.pubkey))
|
||||
}}
|
||||
>
|
||||
<SimpleUserAvatar userId={zap.pubkey} size="xSmall" />
|
||||
<Zap className="size-3 fill-yellow-400" />
|
||||
<div className="font-semibold">{formatAmount(zap.amount)}</div>
|
||||
<div className="truncate">{zap.comment}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<ScrollBar orientation="horizontal" />
|
||||
</ScrollArea>
|
||||
)
|
||||
}
|
||||
147
src/components/NoteStats/ZapButton.tsx
Normal file
147
src/components/NoteStats/ZapButton.tsx
Normal file
@@ -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<ReturnType<typeof setTimeout> | 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 (
|
||||
<>
|
||||
<button
|
||||
className={cn(
|
||||
'flex items-center enabled:hover:text-yellow-400 gap-1 select-none',
|
||||
hasZapped ? 'text-yellow-400' : 'text-muted-foreground'
|
||||
)}
|
||||
title={t('Zap')}
|
||||
onMouseDown={handleClickStart}
|
||||
onMouseUp={handleClickEnd}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onTouchStart={handleClickStart}
|
||||
onTouchEnd={handleClickEnd}
|
||||
>
|
||||
{zapping ? (
|
||||
<Loader className="animate-spin" size={16} />
|
||||
) : (
|
||||
<Zap size={16} className={hasZapped ? 'fill-yellow-400' : ''} />
|
||||
)}
|
||||
{!!zapAmount && <div className="text-sm">{formatAmount(zapAmount)}</div>}
|
||||
</button>
|
||||
<ZapDialog
|
||||
open={openZapDialog}
|
||||
setOpen={(open) => {
|
||||
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`
|
||||
}
|
||||
@@ -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,17 +21,28 @@ export default function NoteStats({
|
||||
fetchIfNotExisting?: boolean
|
||||
variant?: 'note' | 'reply'
|
||||
}) {
|
||||
const { fetchNoteStats } = useNoteStats()
|
||||
|
||||
useEffect(() => {
|
||||
if (!fetchIfNotExisting) return
|
||||
fetchNoteStats(event)
|
||||
}, [event, fetchIfNotExisting])
|
||||
|
||||
return (
|
||||
<div className={cn('flex justify-between', className)}>
|
||||
<div className={cn('select-none', className)}>
|
||||
<TopZaps event={event} />
|
||||
<div className="flex justify-between">
|
||||
<div className="flex gap-5 h-4 items-center" onClick={(e) => e.stopPropagation()}>
|
||||
<ReplyButton event={event} variant={variant} />
|
||||
<RepostButton event={event} canFetch={fetchIfNotExisting} />
|
||||
<LikeButton event={event} canFetch={fetchIfNotExisting} />
|
||||
<RepostButton event={event} />
|
||||
<LikeButton event={event} />
|
||||
<ZapButton event={event} />
|
||||
</div>
|
||||
<div className="flex gap-5 h-4 items-center" onClick={(e) => e.stopPropagation()}>
|
||||
<SeenOnButton event={event} />
|
||||
<NoteOptions event={event} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
className="flex gap-2 items-center cursor-pointer py-2"
|
||||
onClick={() => push(toNote({ id: rootEventId, pubkey: rootPubkey }))}
|
||||
>
|
||||
<UserAvatar userId={notification.pubkey} size="small" />
|
||||
<MessageCircle size={24} className="text-blue-400" />
|
||||
<ContentPreview
|
||||
className={cn('truncate flex-1 w-0', isNew ? 'font-semibold' : 'text-muted-foreground')}
|
||||
event={notification}
|
||||
/>
|
||||
<div className="text-muted-foreground">
|
||||
<FormattedTimestamp timestamp={notification.created_at} short />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<div
|
||||
className="flex items-center justify-between cursor-pointer py-2"
|
||||
onClick={() => push(toNote(event))}
|
||||
>
|
||||
<div className="flex gap-2 items-center flex-1">
|
||||
<UserAvatar userId={notification.pubkey} size="small" />
|
||||
<div className="text-xl min-w-6 text-center">
|
||||
{!notification.content || notification.content === '+' ? (
|
||||
<Heart size={24} className="text-red-400" />
|
||||
) : (
|
||||
notification.content
|
||||
)}
|
||||
</div>
|
||||
<ContentPreview
|
||||
className={cn('truncate flex-1 w-0', isNew ? 'font-semibold' : 'text-muted-foreground')}
|
||||
event={event}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-muted-foreground">
|
||||
<FormattedTimestamp timestamp={notification.created_at} short />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<div
|
||||
className="flex gap-2 items-center cursor-pointer py-2"
|
||||
onClick={() => push(toNote(notification))}
|
||||
>
|
||||
<UserAvatar userId={notification.pubkey} size="small" />
|
||||
<MessageCircle size={24} className="text-blue-400" />
|
||||
<ContentPreview
|
||||
className={cn('truncate flex-1 w-0', isNew ? 'font-semibold' : 'text-muted-foreground')}
|
||||
event={notification}
|
||||
/>
|
||||
<div className="text-muted-foreground">
|
||||
<FormattedTimestamp timestamp={notification.created_at} short />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<div
|
||||
className="flex gap-2 items-center cursor-pointer py-2"
|
||||
onClick={() => push(toNote(event))}
|
||||
>
|
||||
<UserAvatar userId={notification.pubkey} size="small" />
|
||||
<Repeat size={24} className="text-green-400" />
|
||||
<ContentPreview
|
||||
className={cn('truncate flex-1 w-0', isNew ? 'font-semibold' : 'text-muted-foreground')}
|
||||
event={event}
|
||||
/>
|
||||
<div className="text-muted-foreground">
|
||||
<FormattedTimestamp timestamp={notification.created_at} short />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<div
|
||||
className="flex items-center justify-between cursor-pointer py-2"
|
||||
onClick={() => (event ? push(toNote(event)) : pubkey ? push(toProfile(pubkey)) : null)}
|
||||
>
|
||||
<div className="flex gap-2 items-center flex-1 w-0">
|
||||
<UserAvatar userId={senderPubkey} size="small" />
|
||||
<Zap size={24} className="text-yellow-400 shrink-0" />
|
||||
<div className="font-semibold text-yellow-400 shrink-0">
|
||||
{formatAmount(amount)} {t('sats')}
|
||||
</div>
|
||||
{comment && <div className="text-yellow-400 truncate">{comment}</div>}
|
||||
<ContentPreview
|
||||
className={cn('truncate flex-1 w-0', isNew ? 'font-semibold' : 'text-muted-foreground')}
|
||||
event={event}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-muted-foreground shrink-0">
|
||||
<FormattedTimestamp timestamp={notification.created_at} short />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
37
src/components/NotificationList/NotificationItem/index.tsx
Normal file
37
src/components/NotificationList/NotificationItem/index.tsx
Normal file
@@ -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 <ReactionNotification notification={notification} isNew={isNew} />
|
||||
}
|
||||
if (notification.kind === kinds.ShortTextNote) {
|
||||
return <ReplyNotification notification={notification} isNew={isNew} />
|
||||
}
|
||||
if (notification.kind === kinds.Repost) {
|
||||
return <RepostNotification notification={notification} isNew={isNew} />
|
||||
}
|
||||
if (notification.kind === kinds.Zap) {
|
||||
return <ZapNotification notification={notification} isNew={isNew} />
|
||||
}
|
||||
if (notification.kind === COMMENT_EVENT_KIND) {
|
||||
return <CommentNotification notification={notification} isNew={isNew} />
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -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<TNotificationType>('all')
|
||||
const [lastReadTime, setLastReadTime] = useState(0)
|
||||
const [refreshCount, setRefreshCount] = useState(0)
|
||||
const [timelineKey, setTimelineKey] = useState<string | undefined>(undefined)
|
||||
const [refreshing, setRefreshing] = useState(true)
|
||||
const [notifications, setNotifications] = useState<Event[]>([])
|
||||
const [newNotifications, setNewNotifications] = useState<Event[]>([])
|
||||
const [oldNotifications, setOldNotifications] = useState<Event[]>([])
|
||||
const [showCount, setShowCount] = useState(SHOW_COUNT)
|
||||
const [until, setUntil] = useState<number | undefined>(dayjs().unix())
|
||||
const bottomRef = useRef<HTMLDivElement | null>(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,6 +185,14 @@ const NotificationList = forwardRef((_, ref) => {
|
||||
}, [loadMore])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<NotificationTypeSwitch
|
||||
type={notificationType}
|
||||
setType={(type) => {
|
||||
setShowCount(SHOW_COUNT)
|
||||
setNotificationType(type)
|
||||
}}
|
||||
/>
|
||||
<PullToRefresh
|
||||
onRefresh={async () => {
|
||||
setRefreshCount((count) => count + 1)
|
||||
@@ -160,8 +200,19 @@ const NotificationList = forwardRef((_, ref) => {
|
||||
}}
|
||||
pullingContent=""
|
||||
>
|
||||
<div>
|
||||
{notifications.slice(0, showCount).map((notification) => (
|
||||
<div className="px-4 pt-2">
|
||||
{newNotifications.map((notification) => (
|
||||
<NotificationItem key={notification.id} notification={notification} isNew />
|
||||
))}
|
||||
{!!newNotifications.length && (
|
||||
<div className="relative my-2">
|
||||
<Separator />
|
||||
<span className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-background px-2 text-xs text-muted-foreground">
|
||||
{t('Earlier notifications')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{oldNotifications.map((notification) => (
|
||||
<NotificationItem key={notification.id} notification={notification} />
|
||||
))}
|
||||
<div className="text-center text-sm text-muted-foreground">
|
||||
@@ -178,135 +229,59 @@ const NotificationList = forwardRef((_, ref) => {
|
||||
</div>
|
||||
</div>
|
||||
</PullToRefresh>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
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 <ReactionNotification notification={notification} />
|
||||
}
|
||||
if (notification.kind === kinds.ShortTextNote) {
|
||||
return <ReplyNotification notification={notification} />
|
||||
}
|
||||
if (notification.kind === kinds.Repost) {
|
||||
return <RepostNotification notification={notification} />
|
||||
}
|
||||
if (notification.kind === COMMENT_EVENT_KIND) {
|
||||
return <CommentNotification notification={notification} />
|
||||
}
|
||||
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
|
||||
}
|
||||
function NotificationTypeSwitch({
|
||||
type,
|
||||
setType
|
||||
}: {
|
||||
type: TNotificationType
|
||||
setType: (type: TNotificationType) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { deepBrowsing, lastScrollTop } = useDeepBrowsing()
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-between cursor-pointer py-2"
|
||||
onClick={() => 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)]' : ''
|
||||
)}
|
||||
>
|
||||
<div className="flex gap-2 items-center flex-1">
|
||||
<UserAvatar userId={notification.pubkey} size="small" />
|
||||
<Heart size={24} className="text-red-400" />
|
||||
<div>{notification.content === '+' ? <ThumbsUp size={14} /> : notification.content}</div>
|
||||
<ContentPreview className="truncate flex-1 w-0" event={event} />
|
||||
<div className="flex">
|
||||
<div
|
||||
className={`w-1/4 text-center py-2 font-semibold clickable cursor-pointer rounded-lg ${type === 'all' ? '' : 'text-muted-foreground'}`}
|
||||
onClick={() => setType('all')}
|
||||
>
|
||||
{t('All')}
|
||||
</div>
|
||||
<div className="text-muted-foreground">
|
||||
<FormattedTimestamp timestamp={notification.created_at} short />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ReplyNotification({ notification }: { notification: Event }) {
|
||||
const { push } = useSecondaryPage()
|
||||
return (
|
||||
<div
|
||||
className="flex gap-2 items-center cursor-pointer py-2"
|
||||
onClick={() => push(toNote(notification))}
|
||||
>
|
||||
<UserAvatar userId={notification.pubkey} size="small" />
|
||||
<MessageCircle size={24} className="text-blue-400" />
|
||||
<ContentPreview className="truncate flex-1 w-0" event={notification} />
|
||||
<div className="text-muted-foreground">
|
||||
<FormattedTimestamp timestamp={notification.created_at} short />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex gap-2 items-center cursor-pointer py-2"
|
||||
onClick={() => push(toNote(event))}
|
||||
>
|
||||
<UserAvatar userId={notification.pubkey} size="small" />
|
||||
<Repeat size={24} className="text-green-400" />
|
||||
<ContentPreview className="truncate flex-1 w-0" event={event} />
|
||||
<div className="text-muted-foreground">
|
||||
<FormattedTimestamp timestamp={notification.created_at} short />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div
|
||||
className="flex gap-2 items-center cursor-pointer py-2"
|
||||
onClick={() => push(toNote({ id: rootEventId, pubkey: rootPubkey }))}
|
||||
>
|
||||
<UserAvatar userId={notification.pubkey} size="small" />
|
||||
<MessageCircle size={24} className="text-blue-400" />
|
||||
<ContentPreview className="truncate flex-1 w-0" event={notification} />
|
||||
<div className="text-muted-foreground">
|
||||
<FormattedTimestamp timestamp={notification.created_at} short />
|
||||
<div
|
||||
className={`w-1/4 text-center py-2 font-semibold clickable cursor-pointer rounded-lg ${type === 'mentions' ? '' : 'text-muted-foreground'}`}
|
||||
onClick={() => setType('mentions')}
|
||||
>
|
||||
{t('Mentions')}
|
||||
</div>
|
||||
<div
|
||||
className={`w-1/4 text-center py-2 font-semibold clickable cursor-pointer rounded-lg ${type === 'reactions' ? '' : 'text-muted-foreground'}`}
|
||||
onClick={() => setType('reactions')}
|
||||
>
|
||||
{t('Reactions')}
|
||||
</div>
|
||||
<div
|
||||
className={`w-1/4 text-center py-2 font-semibold clickable cursor-pointer rounded-lg ${type === 'zaps' ? '' : 'text-muted-foreground'}`}
|
||||
onClick={() => setType('zaps')}
|
||||
>
|
||||
{t('Zaps')}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`w-1/4 px-4 sm:px-6 transition-transform duration-500 ${type === 'mentions' ? 'translate-x-full' : type === 'reactions' ? 'translate-x-[200%]' : type === 'zaps' ? 'translate-x-[300%]' : ''} `}
|
||||
>
|
||||
<div className="w-full h-1 bg-primary rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -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 }) {
|
||||
<div className="flex gap-2 items-center w-full">
|
||||
<div className="shrink-0">{t('Reply to')}</div>
|
||||
<SimpleUserAvatar userId={parentEvent.pubkey} size="tiny" />
|
||||
<div className="flex-1 w-0 truncate">{parentEvent.content}</div>
|
||||
<ContentPreview className="flex-1 w-0 truncate h-5" event={parentEvent} />
|
||||
</div>
|
||||
) : (
|
||||
t('New Note')
|
||||
|
||||
@@ -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({
|
||||
<Image
|
||||
image={{ url: bannerUrl }}
|
||||
alt={`${pubkey} banner`}
|
||||
className={cn('rounded-lg', className)}
|
||||
className={className}
|
||||
onError={() => setBannerUrl(defaultBanner)}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -22,7 +22,7 @@ export default function ProfileOptions({ pubkey }: { pubkey: string }) {
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="secondary" size="icon" className="rounded-full">
|
||||
<Ellipsis className="text-muted-foreground hover:text-foreground cursor-pointer" />
|
||||
<Ellipsis />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent collisionPadding={8}>
|
||||
|
||||
24
src/components/ProfileZapButton/index.tsx
Normal file
24
src/components/ProfileZapButton/index.tsx
Normal file
@@ -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 (
|
||||
<>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="rounded-full"
|
||||
onClick={() => checkLogin(() => setOpen(true))}
|
||||
>
|
||||
<Zap className="text-yellow-400" />
|
||||
</Button>
|
||||
<ZapDialog open={open} setOpen={setOpen} pubkey={pubkey} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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() {
|
||||
</div>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem onClick={() => push(toProfile(pubkey))}>{t('Profile')}</DropdownMenuItem>
|
||||
<DropdownMenuContent className="w-56" side="top">
|
||||
<DropdownMenuItem onClick={() => push(toProfile(pubkey))}>
|
||||
<UserRound />
|
||||
{t('Profile')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => push(toWallet())}>
|
||||
<Wallet />
|
||||
{t('Wallet')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => setLoginDialogOpen(true)}>
|
||||
<ArrowDownUp />
|
||||
{t('Switch account')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={() => setLogoutDialogOpen(true)}
|
||||
>
|
||||
<LogOut />
|
||||
{t('Logout')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
|
||||
@@ -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 (
|
||||
<SidebarItem
|
||||
@@ -11,7 +13,12 @@ export default function NotificationsButton() {
|
||||
onClick={() => navigate('notifications')}
|
||||
active={current === 'notifications'}
|
||||
>
|
||||
<div className="relative">
|
||||
<Bell strokeWidth={3} />
|
||||
{hasNewNotification && (
|
||||
<div className="absolute -top-1 -right-1 w-2 h-2 bg-primary rounded-full" />
|
||||
)}
|
||||
</div>
|
||||
</SidebarItem>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ export default function PrimaryPageSidebar() {
|
||||
return (
|
||||
<div className="w-16 xl:w-52 hidden sm:flex flex-col pb-2 pt-4 px-2 justify-between h-full shrink-0">
|
||||
<div className="space-y-2">
|
||||
<div className="px-2 mb-8 w-full">
|
||||
<div className="px-3 xl:px-4 mb-6 w-full">
|
||||
<Icon className="xl:hidden" />
|
||||
<Logo className="max-xl:hidden" />
|
||||
</div>
|
||||
|
||||
@@ -34,7 +34,9 @@ export default function UserAvatar({
|
||||
)
|
||||
|
||||
if (!profile) {
|
||||
return <Skeleton className={cn(UserAvatarSizeCnMap[size], 'rounded-full', className)} />
|
||||
return (
|
||||
<Skeleton className={cn('shrink-0', UserAvatarSizeCnMap[size], 'rounded-full', className)} />
|
||||
)
|
||||
}
|
||||
const { avatar, pubkey } = profile
|
||||
|
||||
@@ -42,7 +44,7 @@ export default function UserAvatar({
|
||||
<HoverCard>
|
||||
<HoverCardTrigger>
|
||||
<SecondaryPageLink to={toProfile(pubkey)} onClick={(e) => e.stopPropagation()}>
|
||||
<Avatar className={cn(UserAvatarSizeCnMap[size], className)}>
|
||||
<Avatar className={cn('shrink-0', UserAvatarSizeCnMap[size], className)}>
|
||||
<AvatarImage src={avatar} className="object-cover object-center" />
|
||||
<AvatarFallback>
|
||||
<img src={defaultAvatar} alt={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<HTMLDivElement, MouseEvent>) => void
|
||||
}) {
|
||||
@@ -75,12 +77,14 @@ export function SimpleUserAvatar({
|
||||
)
|
||||
|
||||
if (!profile) {
|
||||
return <Skeleton className={cn(UserAvatarSizeCnMap[size], 'rounded-full', className)} />
|
||||
return (
|
||||
<Skeleton className={cn('shrink-0', UserAvatarSizeCnMap[size], 'rounded-full', className)} />
|
||||
)
|
||||
}
|
||||
const { avatar, pubkey } = profile
|
||||
|
||||
return (
|
||||
<Avatar className={cn(UserAvatarSizeCnMap[size], className)} onClick={onClick}>
|
||||
<Avatar className={cn('shrink-0', UserAvatarSizeCnMap[size], className)} onClick={onClick}>
|
||||
<AvatarImage src={avatar} className="object-cover object-center" />
|
||||
<AvatarFallback>
|
||||
<img src={defaultAvatar} alt={pubkey} />
|
||||
|
||||
@@ -16,9 +16,9 @@ export default function VideoPlayer({
|
||||
<div className="relative">
|
||||
<video
|
||||
controls
|
||||
preload="none"
|
||||
className={cn('rounded-lg', size === 'small' ? 'max-h-[20vh]' : 'max-h-[50vh]', className)}
|
||||
className={cn('rounded-lg', size === 'small' ? 'h-[15vh]' : 'h-[30vh]', className)}
|
||||
src={src}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
{isNsfw && <NsfwOverlay className="rounded-lg" />}
|
||||
</div>
|
||||
|
||||
162
src/components/ZapDialog/index.tsx
Normal file
162
src/components/ZapDialog/index.tsx
Normal file
@@ -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<SetStateAction<boolean>>
|
||||
pubkey: string
|
||||
eventId?: string
|
||||
defaultAmount?: number
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex gap-2 items-center">
|
||||
<div className="shrink-0">{t('Zap to')}</div>
|
||||
<UserAvatar size="small" userId={pubkey} />
|
||||
<Username userId={pubkey} className="truncate flex-1 w-0 text-start h-5" />
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<ZapDialogContent
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
recipient={pubkey}
|
||||
eventId={eventId}
|
||||
defaultAmount={defaultAmount}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function ZapDialogContent({
|
||||
setOpen,
|
||||
recipient,
|
||||
eventId,
|
||||
defaultAmount
|
||||
}: {
|
||||
open: boolean
|
||||
setOpen: Dispatch<SetStateAction<boolean>>
|
||||
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 */}
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="flex justify-center w-full">
|
||||
<input
|
||||
id="sats"
|
||||
value={sats}
|
||||
onChange={(e) => {
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<Label htmlFor="sats">{t('Sats')}</Label>
|
||||
</div>
|
||||
|
||||
{/* Preset sats buttons */}
|
||||
<div className="grid grid-cols-6 gap-2">
|
||||
{[
|
||||
{ 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 }) => (
|
||||
<Button variant="secondary" key={val} onClick={() => setSats(val)}>
|
||||
{display}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Comment input */}
|
||||
<div>
|
||||
<Label htmlFor="comment">{t('zapComment')}</Label>
|
||||
<Input id="comment" value={comment} onChange={(e) => setComment(e.target.value)} />
|
||||
</div>
|
||||
|
||||
<Button onClick={handleZap}>
|
||||
{zapping && <Loader className="animate-spin" />} {t('Zap n sats', { n: sats })}
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -6,5 +6,4 @@ export * from './useFetchProfile'
|
||||
export * from './useFetchRelayInfo'
|
||||
export * from './useFetchRelayInfos'
|
||||
export * from './useFetchRelayList'
|
||||
export * from './useSearchParams'
|
||||
export * from './useSearchProfiles'
|
||||
|
||||
@@ -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()}`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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': '更早的通知'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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<string, string[]>({ max: 10000 })
|
||||
const EVENT_IS_REPLY_NOTE_CACHE = new LRUCache<string, boolean>({ 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
|
||||
|
||||
32
src/lib/lightning.ts
Normal file
32
src/lib/lightning.ts
Normal file
@@ -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
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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) => {
|
||||
<UserRound />
|
||||
{t('Profile')}
|
||||
</Item>
|
||||
<Item onClick={() => push(toWallet())}>
|
||||
<Wallet />
|
||||
{t('Wallet')}
|
||||
</Item>
|
||||
<Item onClick={() => setLoginDialogOpen(true)}>
|
||||
<ArrowDownUp /> {t('Switch account')}
|
||||
</Item>
|
||||
|
||||
@@ -24,9 +24,7 @@ const NotificationListPage = forwardRef((_, ref) => {
|
||||
titlebar={<NotificationListPageTitlebar />}
|
||||
displayScrollToTopButton
|
||||
>
|
||||
<div className="px-4">
|
||||
<NotificationList ref={notificationListRef} />
|
||||
</div>
|
||||
</PrimaryPageLayout>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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 (
|
||||
<SecondaryPageLayout ref={ref} index={index} title={title} displayScrollToTopButton>
|
||||
|
||||
@@ -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<string>('')
|
||||
const [nip05, setNip05] = useState<string>('')
|
||||
const [nip05Error, setNip05Error] = useState<string>('')
|
||||
const [lightningAddress, setLightningAddress] = useState<string>('')
|
||||
const [lightningAddressError, setLightningAddressError] = useState<string>('')
|
||||
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 (
|
||||
<SecondaryPageLayout ref={ref} index={index} title={profile.username} controls={controls}>
|
||||
<div className="px-4">
|
||||
<div className="relative bg-cover bg-center w-full aspect-[21/9] rounded-lg mb-2">
|
||||
<div className="relative bg-cover bg-center rounded-lg mb-2">
|
||||
<Uploader
|
||||
onUploadSuccess={onBannerUploadSuccess}
|
||||
onUploadingChange={(uploading) => setTimeout(() => setUploadingBanner(uploading), 50)}
|
||||
@@ -109,7 +129,7 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
|
||||
<ProfileBanner
|
||||
banner={banner}
|
||||
pubkey={account.pubkey}
|
||||
className="w-full aspect-video object-cover"
|
||||
className="w-full aspect-video object-cover rounded-lg"
|
||||
/>
|
||||
<div className="absolute top-0 bg-muted/30 w-full h-full rounded-lg flex flex-col justify-center items-center">
|
||||
{uploadingBanner ? (
|
||||
@@ -170,6 +190,21 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
|
||||
/>
|
||||
{nip05Error && <div className="text-xs text-destructive pl-3">{nip05Error}</div>}
|
||||
</Item>
|
||||
<Item>
|
||||
<ItemTitle>{t('Lightning Address (or LNURL)')}</ItemTitle>
|
||||
<Input
|
||||
value={lightningAddress}
|
||||
onChange={(e) => {
|
||||
setLightningAddressError('')
|
||||
setLightningAddress(e.target.value)
|
||||
setHasChanged(true)
|
||||
}}
|
||||
className={lightningAddressError ? 'border-destructive' : ''}
|
||||
/>
|
||||
{lightningAddressError && (
|
||||
<div className="text-xs text-destructive pl-3">{lightningAddressError}</div>
|
||||
)}
|
||||
</Item>
|
||||
</div>
|
||||
</div>
|
||||
</SecondaryPageLayout>
|
||||
@@ -179,7 +214,7 @@ ProfileEditorPage.displayName = 'ProfileEditorPage'
|
||||
export default ProfileEditorPage
|
||||
|
||||
function ItemTitle({ children }: { children: React.ReactNode }) {
|
||||
return <div className="text-sm font-semibold text-muted-foreground pl-3">{children}</div>
|
||||
return <div className="text-sm font-semibold text-muted-foreground">{children}</div>
|
||||
}
|
||||
|
||||
function Item({ children }: { children: React.ReactNode }) {
|
||||
|
||||
@@ -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<number>(() => dayjs().unix())
|
||||
@@ -22,12 +21,13 @@ const ProfileListPage = forwardRef(({ index }: { index?: number }, ref) => {
|
||||
const bottomRef = useRef<HTMLDivElement>(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])
|
||||
|
||||
@@ -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 (
|
||||
<SecondaryPageLayout index={index} ref={ref}>
|
||||
<div className="px-4">
|
||||
<div className="relative bg-cover bg-center w-full aspect-[21/9] rounded-lg mb-2">
|
||||
<Skeleton className="w-full h-full object-cover rounded-lg" />
|
||||
<Skeleton className="w-24 h-24 absolute bottom-0 left-4 translate-y-1/2 border-4 border-background rounded-full" />
|
||||
<div className="sm:px-4">
|
||||
<div className="relative bg-cover bg-center mb-2">
|
||||
<Skeleton className="w-full aspect-video sm:rounded-lg" />
|
||||
<Skeleton className="w-24 h-24 absolute bottom-0 left-3 translate-y-1/2 border-4 border-background rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-4">
|
||||
<Skeleton className="h-5 w-28 mt-14 mb-1" />
|
||||
<Skeleton className="h-5 w-56 mt-2 my-1 rounded-full" />
|
||||
</div>
|
||||
@@ -68,29 +71,32 @@ const ProfilePage = forwardRef(({ id, index }: { id?: string; index?: number },
|
||||
}
|
||||
if (!profile) return <NotFoundPage />
|
||||
|
||||
const { banner, username, about, avatar, pubkey, website } = profile
|
||||
const { banner, username, about, avatar, pubkey, website, lightningAddress } = profile
|
||||
return (
|
||||
<SecondaryPageLayout index={index} title={username} displayScrollToTopButton ref={ref}>
|
||||
<div className="px-4">
|
||||
<div className="relative bg-cover bg-center w-full aspect-[21/9] rounded-lg mb-2">
|
||||
<div className="sm:px-4">
|
||||
<div className="relative bg-cover bg-center mb-2">
|
||||
<ProfileBanner
|
||||
banner={banner}
|
||||
pubkey={pubkey}
|
||||
className="w-full aspect-video object-cover"
|
||||
className="w-full aspect-video sm:rounded-lg"
|
||||
/>
|
||||
<Avatar className="w-24 h-24 absolute bottom-0 left-4 translate-y-1/2 border-4 border-background">
|
||||
<Avatar className="w-24 h-24 absolute left-3 bottom-0 translate-y-1/2 border-4 border-background">
|
||||
<AvatarImage src={avatar} className="object-cover object-center" />
|
||||
<AvatarFallback>
|
||||
<img src={defaultImage} />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-4">
|
||||
<div className="flex justify-end h-8 gap-2 items-center">
|
||||
{isFollowingYou && (
|
||||
<div className="text-muted-foreground rounded-full bg-muted text-xs h-fit px-2">
|
||||
{t('Follows you')}
|
||||
</div>
|
||||
)}
|
||||
<ProfileOptions pubkey={pubkey} />
|
||||
{isSelf ? (
|
||||
<Button
|
||||
className="w-20 min-w-20 rounded-full"
|
||||
@@ -100,13 +106,21 @@ const ProfilePage = forwardRef(({ id, index }: { id?: string; index?: number },
|
||||
{t('Edit')}
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
{!!lightningAddress && <ProfileZapButton pubkey={pubkey} />}
|
||||
<FollowButton pubkey={pubkey} />
|
||||
</>
|
||||
)}
|
||||
<ProfileOptions pubkey={pubkey} />
|
||||
</div>
|
||||
<div className="pt-2">
|
||||
<div className="text-xl font-semibold">{username}</div>
|
||||
<Nip05 pubkey={pubkey} />
|
||||
{lightningAddress && (
|
||||
<div className="text-sm text-yellow-400 flex gap-1 items-center">
|
||||
<Zap className="size-4" />
|
||||
{lightningAddress}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-1 mt-1">
|
||||
<PubkeyCopy pubkey={pubkey} />
|
||||
<QrCodePopover pubkey={pubkey} />
|
||||
|
||||
@@ -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) => {
|
||||
</div>
|
||||
<ChevronRight />
|
||||
</SettingItem>
|
||||
<SettingItem onClick={() => push(toWallet())}>
|
||||
<div className="flex items-center gap-4">
|
||||
<Wallet />
|
||||
<div>{t('Wallet')}</div>
|
||||
</div>
|
||||
<ChevronRight />
|
||||
</SettingItem>
|
||||
{!!nsec && (
|
||||
<SettingItem
|
||||
onClick={() => {
|
||||
@@ -110,6 +128,9 @@ const SettingsPage = forwardRef(({ index }: { index?: number }, ref) => {
|
||||
</div>
|
||||
</SettingItem>
|
||||
</AboutInfoDialog>
|
||||
<div className="px-4 mt-4">
|
||||
<Donation />
|
||||
</div>
|
||||
</SecondaryPageLayout>
|
||||
)
|
||||
})
|
||||
|
||||
38
src/pages/secondary/WalletPage/DefaultZapAmountInput.tsx
Normal file
38
src/pages/secondary/WalletPage/DefaultZapAmountInput.tsx
Normal file
@@ -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 (
|
||||
<div className="w-full space-y-1">
|
||||
<Label htmlFor="default-zap-amount-input">{t('Default zap amount')}</Label>
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<Input
|
||||
id="default-zap-amount-input"
|
||||
value={defaultZapAmountInput}
|
||||
onChange={(e) => {
|
||||
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)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
27
src/pages/secondary/WalletPage/DefaultZapCommentInput.tsx
Normal file
27
src/pages/secondary/WalletPage/DefaultZapCommentInput.tsx
Normal file
@@ -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 (
|
||||
<div className="w-full space-y-1">
|
||||
<Label htmlFor="default-zap-comment-input">{t('Default zap comment')}</Label>
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<Input
|
||||
id="default-zap-comment-input"
|
||||
value={defaultZapCommentInput}
|
||||
onChange={(e) => setDefaultZapCommentInput(e.target.value)}
|
||||
onBlur={() => {
|
||||
updateDefaultComment(defaultZapCommentInput)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
82
src/pages/secondary/WalletPage/LightningAddressInput.tsx
Normal file
82
src/pages/secondary/WalletPage/LightningAddressInput.tsx
Normal file
@@ -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 (
|
||||
<div className="w-full space-y-1">
|
||||
<Label htmlFor="ln-address">{t('Lightning Address (or LNURL)')}</Label>
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<Input
|
||||
id="ln-address"
|
||||
placeholder="xxxxxxxx@xxx.xxx"
|
||||
value={lightningAddress}
|
||||
onChange={(e) => {
|
||||
setLightningAddress(e.target.value)
|
||||
setHasChanged(true)
|
||||
}}
|
||||
/>
|
||||
<Button onClick={handleSave} disabled={saving || !hasChanged} className="w-20">
|
||||
{saving ? <Loader className="animate-spin" /> : 'Save'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
21
src/pages/secondary/WalletPage/QuickZapSwitch.tsx
Normal file
21
src/pages/secondary/WalletPage/QuickZapSwitch.tsx
Normal file
@@ -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 (
|
||||
<div className="w-full flex justify-between items-center">
|
||||
<Label htmlFor="quick-zap-switch">
|
||||
<div className="text-base font-medium">{t('Quick zap')}</div>
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{t('If enabled, you can zap with a single click')}
|
||||
</div>
|
||||
</Label>
|
||||
<Switch id="quick-zap-switch" checked={quickZap} onCheckedChange={updateQuickZap} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
26
src/pages/secondary/WalletPage/index.tsx
Normal file
26
src/pages/secondary/WalletPage/index.tsx
Normal file
@@ -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 (
|
||||
<SecondaryPageLayout ref={ref} index={index} title={t('Wallet')}>
|
||||
<div className="px-4 pt-2 space-y-4">
|
||||
<BcButton />
|
||||
<LightningAddressInput />
|
||||
<DefaultZapAmountInput />
|
||||
<DefaultZapCommentInput />
|
||||
<QuickZapSwitch />
|
||||
</div>
|
||||
</SecondaryPageLayout>
|
||||
)
|
||||
})
|
||||
WalletPage.displayName = 'WalletPage'
|
||||
export default WalletPage
|
||||
@@ -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<TFeedType>(storage.getFeedType())
|
||||
const [feedType, setFeedType] = useState<TFeedType>(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({
|
||||
|
||||
@@ -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<string[]>
|
||||
follow: (pubkey: string) => Promise<void>
|
||||
unfollow: (pubkey: string) => Promise<void>
|
||||
}
|
||||
@@ -26,81 +21,42 @@ export const useFollowList = () => {
|
||||
}
|
||||
|
||||
export function FollowListProvider({ children }: { children: React.ReactNode }) {
|
||||
const { pubkey: accountPubkey, publish } = useNostr()
|
||||
const [followListEvent, setFollowListEvent] = useState<Event | undefined>(undefined)
|
||||
const [isFetching, setIsFetching] = useState(true)
|
||||
const { pubkey: accountPubkey, followListEvent, publish, updateFollowListEvent } = useNostr()
|
||||
const followings = useMemo(
|
||||
() => (followListEvent ? extractPubkeysFromEventTags(followListEvent.tags) : []),
|
||||
[followListEvent]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const follow = async (pubkey: string) => {
|
||||
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
|
||||
|
||||
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 (
|
||||
<FollowListContext.Provider
|
||||
value={{
|
||||
followListEvent,
|
||||
followings,
|
||||
isFetching,
|
||||
getFollowings,
|
||||
follow,
|
||||
unfollow
|
||||
}}
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import { BIG_RELAY_URLS } from '@/constants'
|
||||
import { createMuteListDraftEvent } from '@/lib/draft-event'
|
||||
import { getLatestEvent } from '@/lib/event'
|
||||
import { extractPubkeysFromEventTags, isSameTag } 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 { z } from 'zod'
|
||||
import { useNostr } from './NostrProvider'
|
||||
@@ -26,39 +22,21 @@ export const useMuteList = () => {
|
||||
}
|
||||
|
||||
export function MuteListProvider({ children }: { children: React.ReactNode }) {
|
||||
const { pubkey: accountPubkey, publish, relayList, nip04Decrypt, nip04Encrypt } = useNostr()
|
||||
const [muteListEvent, setMuteListEvent] = useState<Event | undefined>(undefined)
|
||||
const {
|
||||
pubkey: accountPubkey,
|
||||
muteListEvent,
|
||||
publish,
|
||||
updateMuteListEvent,
|
||||
nip04Decrypt,
|
||||
nip04Encrypt
|
||||
} = useNostr()
|
||||
const [tags, setTags] = useState<string[][]>([])
|
||||
const mutePubkeys = useMemo(() => extractPubkeysFromEventTags(tags), [tags])
|
||||
|
||||
useEffect(() => {
|
||||
if (!accountPubkey) return
|
||||
const updateMuteTags = async () => {
|
||||
if (!muteListEvent) 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)
|
||||
}
|
||||
}
|
||||
|
||||
init()
|
||||
}, [accountPubkey])
|
||||
|
||||
const extractMuteTags = async (muteListEvent: Event) => {
|
||||
const tags = [...muteListEvent.tags]
|
||||
if (muteListEvent.content) {
|
||||
const storedDecryptedTags = await indexedDb.getMuteDecryptedTags(muteListEvent.id)
|
||||
@@ -76,16 +54,10 @@ export function MuteListProvider({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
}
|
||||
}
|
||||
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 (
|
||||
|
||||
@@ -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<string>
|
||||
startLogin: () => void
|
||||
checkLogin: <T>(cb?: () => T) => Promise<T | void>
|
||||
getRelayList: (pubkey: string) => Promise<TRelayList>
|
||||
updateRelayListEvent: (relayListEvent: Event) => Promise<void>
|
||||
updateProfileEvent: (profileEvent: Event) => Promise<void>
|
||||
updateFollowListEvent: (followListEvent: Event) => Promise<void>
|
||||
updateMuteListEvent: (muteListEvent: Event, tags: string[][]) => Promise<void>
|
||||
}
|
||||
|
||||
const NostrContext = createContext<TNostrContext | undefined>(undefined)
|
||||
@@ -71,6 +77,8 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
|
||||
const [profile, setProfile] = useState<TProfile | null>(null)
|
||||
const [profileEvent, setProfileEvent] = useState<Event | null>(null)
|
||||
const [relayList, setRelayList] = useState<TRelayList | null>(null)
|
||||
const [followListEvent, setFollowListEvent] = useState<Event | undefined>(undefined)
|
||||
const [muteListEvent, setMuteListEvent] = useState<Event | undefined>(undefined)
|
||||
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
@@ -122,9 +130,12 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
|
||||
} else {
|
||||
setNcryptsec(null)
|
||||
}
|
||||
const [storedRelayListEvent, storedProfileEvent] = await Promise.all([
|
||||
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.Metadata),
|
||||
indexedDb.getReplaceableEvent(account.pubkey, kinds.Contacts),
|
||||
indexedDb.getReplaceableEvent(account.pubkey, kinds.Mutelist)
|
||||
])
|
||||
if (storedRelayListEvent) {
|
||||
setRelayList(
|
||||
@@ -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
|
||||
const relayListEvents = await client.fetchEvents(BIG_RELAY_URLS, {
|
||||
kinds: [kinds.RelayList],
|
||||
authors: [account.pubkey]
|
||||
})
|
||||
const relayListEvent = getLatestEvent(relayListEvents) ?? storedRelayListEvent
|
||||
const relayList = getRelayListFromRelayListEvent(relayListEvent)
|
||||
if (relayListEvent) {
|
||||
client.updateRelayListCache(relayListEvent)
|
||||
await indexedDb.putReplaceableEvent(relayListEvent)
|
||||
}
|
||||
setRelayList(relayList)
|
||||
|
||||
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 events = await client.fetchEvents(relayList.write.concat(BIG_RELAY_URLS).slice(0, 4), {
|
||||
kinds: [kinds.Metadata, kinds.Contacts, kinds.Mutelist],
|
||||
authors: [account.pubkey]
|
||||
})
|
||||
client.fetchProfileEvent(account.pubkey).then(async (profileEvent) => {
|
||||
if (!profileEvent) {
|
||||
if (storedProfileEvent) return
|
||||
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)
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
})
|
||||
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}
|
||||
|
||||
@@ -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<string>
|
||||
reposts: Set<string>
|
||||
zaps: { pr: string; pubkey: string; amount: number; comment?: string }[]
|
||||
replyCount: number
|
||||
hasLiked: boolean
|
||||
hasReposted: boolean
|
||||
updatedAt?: number
|
||||
}
|
||||
|
||||
type TNoteStatsContext = {
|
||||
noteStatsMap: Map<string, Partial<TNoteStats>>
|
||||
updateNoteReplyCount: (noteId: string, replyCount: number) => void
|
||||
markNoteAsLiked: (noteId: string) => void
|
||||
markNoteAsReposted: (noteId: string) => void
|
||||
fetchNoteLikeCount: (event: Event) => Promise<number>
|
||||
fetchNoteRepostCount: (event: Event) => Promise<number>
|
||||
fetchNoteLikedStatus: (event: Event) => Promise<boolean>
|
||||
fetchNoteRepostedStatus: (event: Event) => Promise<boolean>
|
||||
addZap: (eventId: string, pr: string, amount: number, comment?: string) => void
|
||||
updateNoteStatsByEvents: (events: Event[]) => void
|
||||
fetchNoteStats: (event: Event) => Promise<Partial<TNoteStats> | undefined>
|
||||
}
|
||||
|
||||
const NoteStatsContext = createContext<TNoteStatsContext | undefined>(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 })
|
||||
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
|
||||
}
|
||||
return newMap
|
||||
})
|
||||
])
|
||||
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), {
|
||||
const fetchNoteStats = async (event: Event) => {
|
||||
const oldStats = noteStatsMap.get(event.id)
|
||||
let since: number | undefined
|
||||
if (oldStats?.updatedAt) {
|
||||
since = oldStats.updatedAt
|
||||
}
|
||||
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
|
||||
})
|
||||
const countMap = new Map<string, number>()
|
||||
for (const e of events) {
|
||||
const targetEventId = e.tags.findLast(tagNameEquals('e'))?.[1]
|
||||
if (targetEventId) {
|
||||
countMap.set(targetEventId, (countMap.get(targetEventId) || 0) + 1)
|
||||
}
|
||||
}
|
||||
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 }
|
||||
)
|
||||
}
|
||||
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
|
||||
}
|
||||
]
|
||||
|
||||
if (authorProfile?.lightningAddress) {
|
||||
filters.push({
|
||||
'#e': [event.id],
|
||||
kinds: [kinds.Zap],
|
||||
limit: 500
|
||||
})
|
||||
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
|
||||
}
|
||||
|
||||
const fetchNoteLikedStatus = async (event: Event) => {
|
||||
if (!pubkey) return false
|
||||
|
||||
const relayList = await client.fetchRelayList(pubkey)
|
||||
const events = await client.fetchEvents(relayList.write, {
|
||||
if (pubkey) {
|
||||
filters.push({
|
||||
'#e': [event.id],
|
||||
authors: [pubkey],
|
||||
kinds: [kinds.Reaction]
|
||||
kinds: [kinds.Reaction, kinds.Repost]
|
||||
})
|
||||
const likedEventIds = events
|
||||
.map((e) => e.tags.findLast(tagNameEquals('e'))?.[1])
|
||||
.filter(Boolean) as string[]
|
||||
|
||||
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 (!likedEventIds.includes(event.id)) {
|
||||
const old = newMap.get(event.id)
|
||||
newMap.set(event.id, old ? { ...old, hasLiked: false } : { hasLiked: false })
|
||||
}
|
||||
return newMap
|
||||
})
|
||||
return likedEventIds.includes(event.id)
|
||||
}
|
||||
|
||||
const fetchNoteRepostedStatus = async (event: Event) => {
|
||||
if (!pubkey) return false
|
||||
|
||||
const relayList = await client.fetchRelayList(pubkey)
|
||||
const events = await client.fetchEvents(relayList.write, {
|
||||
if (authorProfile?.lightningAddress) {
|
||||
filters.push({
|
||||
'#e': [event.id],
|
||||
authors: [pubkey],
|
||||
kinds: [kinds.Repost]
|
||||
'#P': [pubkey],
|
||||
kinds: [kinds.Zap]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
if (since) {
|
||||
filters.forEach((filter) => {
|
||||
filter.since = since
|
||||
})
|
||||
return events.length > 0
|
||||
}
|
||||
const events = await client.fetchEvents(relayList.read.slice(0, 4), filters)
|
||||
updateNoteStatsByEvents(events)
|
||||
let stats: Partial<TNoteStats> | 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 stats
|
||||
}
|
||||
|
||||
const updateNoteStatsByEvents = (events: Event[]) => {
|
||||
const newRepostsMap = new Map<string, Set<string>>()
|
||||
const newLikesMap = new Map<string, Set<string>>()
|
||||
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
|
||||
}
|
||||
|
||||
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) => {
|
||||
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
|
||||
}
|
||||
|
||||
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 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)
|
||||
})
|
||||
}
|
||||
|
||||
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 }
|
||||
)
|
||||
return new Map(prev)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -184,13 +221,10 @@ export function NoteStatsProvider({ children }: { children: React.ReactNode }) {
|
||||
<NoteStatsContext.Provider
|
||||
value={{
|
||||
noteStatsMap,
|
||||
fetchNoteLikeCount,
|
||||
fetchNoteLikedStatus,
|
||||
fetchNoteRepostCount,
|
||||
fetchNoteRepostedStatus,
|
||||
fetchNoteStats,
|
||||
updateNoteReplyCount,
|
||||
markNoteAsLiked,
|
||||
markNoteAsReposted
|
||||
addZap,
|
||||
updateNoteStatsByEvents
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
143
src/providers/NotificationProvider.tsx
Normal file
143
src/providers/NotificationProvider.tsx
Normal file
@@ -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<TNotificationContext | undefined>(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<TPrimaryPageName | null>(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 (
|
||||
<NotificationContext.Provider value={{ hasNewNotification }}>
|
||||
{children}
|
||||
</NotificationContext.Provider>
|
||||
)
|
||||
}
|
||||
57
src/providers/ZapProvider.tsx
Normal file
57
src/providers/ZapProvider.tsx
Normal file
@@ -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<TZapContext | undefined>(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<number>(storage.getDefaultZapSats())
|
||||
const [defaultZapComment, setDefaultZapComment] = useState<string>(storage.getDefaultZapComment())
|
||||
const [quickZap, setQuickZap] = useState<boolean>(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 (
|
||||
<ZapContext.Provider
|
||||
value={{
|
||||
defaultZapSats,
|
||||
updateDefaultSats,
|
||||
defaultZapComment,
|
||||
updateDefaultComment,
|
||||
quickZap,
|
||||
updateQuickZap
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ZapContext.Provider>
|
||||
)
|
||||
}
|
||||
@@ -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: <NoteListPage /> },
|
||||
@@ -21,6 +22,7 @@ const ROUTES = [
|
||||
{ path: '/users/:id/relays', element: <OthersRelaySettingsPage /> },
|
||||
{ path: '/relay-settings', element: <RelaySettingsPage /> },
|
||||
{ path: '/settings', element: <SettingsPage /> },
|
||||
{ path: '/wallet', element: <WalletPage /> },
|
||||
{ path: '/profile-editor', element: <ProfileEditorPage /> },
|
||||
{ path: '/relays/:url', element: <RelayPage /> },
|
||||
{ path: '/mutes', element: <MuteListPage /> }
|
||||
|
||||
@@ -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<string, NEvent | undefined>(
|
||||
this.eventBatchLoadFn.bind(this),
|
||||
{ cache: false }
|
||||
)
|
||||
private profileEventDataloader = new DataLoader<string, NEvent | undefined>(
|
||||
(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<string, NEvent | undefined>(
|
||||
this.profileEventBatchLoadFn.bind(this),
|
||||
{
|
||||
batchScheduleFn: (callback) => setTimeout(callback, 200),
|
||||
cacheMap: new LRUCache<string, Promise<NEvent | undefined>>({ max: 1000 }),
|
||||
maxBatchSize: 50
|
||||
maxBatchSize: 20
|
||||
}
|
||||
)
|
||||
private relayListEventDataLoader = new DataLoader<string, NEvent | undefined>(
|
||||
this.relayListEventBatchLoadFn.bind(this),
|
||||
{
|
||||
batchScheduleFn: (callback) => setTimeout(callback, 200),
|
||||
cacheMap: new LRUCache<string, Promise<NEvent | undefined>>({ max: 1000 }),
|
||||
maxBatchSize: 50
|
||||
maxBatchSize: 20
|
||||
}
|
||||
)
|
||||
private followListCache = new LRUCache<string, Promise<NEvent | undefined>>({
|
||||
@@ -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<string>()
|
||||
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<void>((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<NEvent | undefined> {
|
||||
return await this.profileEventDataloader.load(id)
|
||||
async fetchProfileEvent(id: string, skipCache: boolean = false): Promise<NEvent | undefined> {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
async fetchProfile(id: string): Promise<TProfile | undefined> {
|
||||
const profileEvent = await this.fetchProfileEvent(id)
|
||||
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, skipCache: boolean = false): Promise<TProfile | undefined> {
|
||||
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<TProfile[]> {
|
||||
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<NEvent | undefined> {
|
||||
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,11 +819,9 @@ class ClientService extends EventTarget {
|
||||
return eventsMap.get(pubkey)
|
||||
})
|
||||
|
||||
await Promise.allSettled(
|
||||
profileEvents.map(
|
||||
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],
|
||||
|
||||
@@ -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<void>((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
|
||||
|
||||
192
src/services/lightning.service.ts
Normal file
192
src/services/lightning.service.ts
Normal file
@@ -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<typeof setInterval> | 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<null | {
|
||||
callback: string
|
||||
lnurl: string
|
||||
}> {
|
||||
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
|
||||
@@ -48,6 +48,10 @@ class LocalStorageService {
|
||||
private accounts: TAccount[] = []
|
||||
private currentAccount: TAccount | null = null
|
||||
private noteListMode: TNoteListMode = 'posts'
|
||||
private lastReadNotificationTimeMap: Record<string, number> = {}
|
||||
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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 & {
|
||||
|
||||
Reference in New Issue
Block a user