feat: zap (#107)

This commit is contained in:
Cody Tseng
2025-03-01 23:52:05 +08:00
committed by GitHub
parent 407a6fb802
commit 249593d547
72 changed files with 2582 additions and 818 deletions

167
package-lock.json generated
View File

@@ -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
}
}
}
}
}

View File

@@ -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",

View File

@@ -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>

View File

@@ -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>
)

View File

@@ -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>

View File

@@ -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>
)
}

View 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>
)
}

View File

@@ -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 = {

View File

@@ -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 {

View File

@@ -61,6 +61,7 @@ export default function Image({
)}
onLoad={() => {
setIsLoading(false)
setHasError(false)
setTimeout(() => setDisplayBlurHash(false), 500)
}}
onError={() => {

View File

@@ -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>

View File

@@ -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>
)
}

View File

@@ -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)]' : ''
)}
>

View File

@@ -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 {

View File

@@ -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 {

View 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>
)
}

View 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`
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View 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
}

View File

@@ -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>
)

View File

@@ -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')

View File

@@ -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)}
/>
)

View File

@@ -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}>

View 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} />
</>
)
}

View File

@@ -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>

View File

@@ -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>
)
}

View File

@@ -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>

View File

@@ -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} />

View File

@@ -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>

View 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>
</>
)
}

View File

@@ -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'

View File

@@ -6,5 +6,4 @@ export * from './useFetchProfile'
export * from './useFetchRelayInfo'
export * from './useFetchRelayInfos'
export * from './useFetchRelayList'
export * from './useSearchParams'
export * from './useSearchProfiles'

View File

@@ -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()}`
)
}
}
}

View File

@@ -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'
}
}

View File

@@ -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': '更早的通知'
}
}

View File

@@ -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 {

View File

@@ -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)
}

View File

@@ -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
View 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
}

View File

@@ -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'

View File

@@ -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'

View File

@@ -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>

View File

@@ -24,9 +24,7 @@ const NotificationListPage = forwardRef((_, ref) => {
titlebar={<NotificationListPageTitlebar />}
displayScrollToTopButton
>
<div className="px-4">
<NotificationList ref={notificationListRef} />
</div>
</PrimaryPageLayout>
)
})

View File

@@ -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>

View File

@@ -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 }) {

View File

@@ -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])

View File

@@ -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} />

View File

@@ -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>
)
})

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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

View File

@@ -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({

View File

@@ -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
}}

View File

@@ -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 (

View File

@@ -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}

View File

@@ -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}

View 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>
)
}

View 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>
)
}

View File

@@ -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 /> }

View File

@@ -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],

View File

@@ -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

View 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

View File

@@ -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()

View File

@@ -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
}
}

View File

@@ -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 & {