72 changed files with 2580 additions and 816 deletions
@ -1,16 +1,23 @@
@@ -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')} |
||||
> |
||||
<Bell /> |
||||
<div className="relative"> |
||||
<Bell /> |
||||
{hasNewNotification && ( |
||||
<div className="absolute -top-1 -right-1 w-2 h-2 bg-primary rounded-full" /> |
||||
)} |
||||
</div> |
||||
</BottomNavigationBarItem> |
||||
) |
||||
} |
||||
|
||||
@ -0,0 +1,43 @@
@@ -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> |
||||
) |
||||
} |
||||
@ -0,0 +1,147 @@
@@ -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` |
||||
} |
||||
@ -0,0 +1,48 @@
@@ -0,0 +1,48 @@
|
||||
import { PICTURE_EVENT_KIND } from '@/constants' |
||||
import { toNote } from '@/lib/link' |
||||
import { tagNameEquals } from '@/lib/tag' |
||||
import { cn } from '@/lib/utils' |
||||
import { useSecondaryPage } from '@/PageManager' |
||||
import { MessageCircle } from 'lucide-react' |
||||
import { Event, kinds } from 'nostr-tools' |
||||
import ContentPreview from '../../ContentPreview' |
||||
import { FormattedTimestamp } from '../../FormattedTimestamp' |
||||
import UserAvatar from '../../UserAvatar' |
||||
|
||||
export function CommentNotification({ |
||||
notification, |
||||
isNew = false |
||||
}: { |
||||
notification: Event |
||||
isNew?: boolean |
||||
}) { |
||||
const { push } = useSecondaryPage() |
||||
const rootEventId = notification.tags.find(tagNameEquals('E'))?.[1] |
||||
const rootPubkey = notification.tags.find(tagNameEquals('P'))?.[1] |
||||
const rootKind = notification.tags.find(tagNameEquals('K'))?.[1] |
||||
if ( |
||||
!rootEventId || |
||||
!rootPubkey || |
||||
!rootKind || |
||||
![kinds.ShortTextNote, PICTURE_EVENT_KIND].includes(parseInt(rootKind)) |
||||
) { |
||||
return null |
||||
} |
||||
|
||||
return ( |
||||
<div |
||||
className="flex gap-2 items-center cursor-pointer py-2" |
||||
onClick={() => push(toNote({ id: rootEventId, pubkey: rootPubkey }))} |
||||
> |
||||
<UserAvatar userId={notification.pubkey} size="small" /> |
||||
<MessageCircle size={24} className="text-blue-400" /> |
||||
<ContentPreview |
||||
className={cn('truncate flex-1 w-0', isNew ? 'font-semibold' : 'text-muted-foreground')} |
||||
event={notification} |
||||
/> |
||||
<div className="text-muted-foreground"> |
||||
<FormattedTimestamp timestamp={notification.created_at} short /> |
||||
</div> |
||||
</div> |
||||
) |
||||
} |
||||
@ -0,0 +1,60 @@
@@ -0,0 +1,60 @@
|
||||
import { PICTURE_EVENT_KIND } from '@/constants' |
||||
import { useFetchEvent } from '@/hooks' |
||||
import { toNote } from '@/lib/link' |
||||
import { tagNameEquals } from '@/lib/tag' |
||||
import { cn } from '@/lib/utils' |
||||
import { useSecondaryPage } from '@/PageManager' |
||||
import { useNostr } from '@/providers/NostrProvider' |
||||
import { Heart } from 'lucide-react' |
||||
import { Event, kinds } from 'nostr-tools' |
||||
import { useMemo } from 'react' |
||||
import ContentPreview from '../../ContentPreview' |
||||
import { FormattedTimestamp } from '../../FormattedTimestamp' |
||||
import UserAvatar from '../../UserAvatar' |
||||
|
||||
export function ReactionNotification({ |
||||
notification, |
||||
isNew = false |
||||
}: { |
||||
notification: Event |
||||
isNew?: boolean |
||||
}) { |
||||
const { push } = useSecondaryPage() |
||||
const { pubkey } = useNostr() |
||||
const eventId = useMemo(() => { |
||||
const targetPubkey = notification.tags.findLast(tagNameEquals('p'))?.[1] |
||||
if (targetPubkey !== pubkey) return undefined |
||||
|
||||
const eTag = notification.tags.findLast(tagNameEquals('e')) |
||||
return eTag?.[1] |
||||
}, [notification, pubkey]) |
||||
const { event } = useFetchEvent(eventId) |
||||
if (!event || !eventId || ![kinds.ShortTextNote, PICTURE_EVENT_KIND].includes(event.kind)) { |
||||
return null |
||||
} |
||||
|
||||
return ( |
||||
<div |
||||
className="flex items-center justify-between cursor-pointer py-2" |
||||
onClick={() => push(toNote(event))} |
||||
> |
||||
<div className="flex gap-2 items-center flex-1"> |
||||
<UserAvatar userId={notification.pubkey} size="small" /> |
||||
<div className="text-xl min-w-6 text-center"> |
||||
{!notification.content || notification.content === '+' ? ( |
||||
<Heart size={24} className="text-red-400" /> |
||||
) : ( |
||||
notification.content |
||||
)} |
||||
</div> |
||||
<ContentPreview |
||||
className={cn('truncate flex-1 w-0', isNew ? 'font-semibold' : 'text-muted-foreground')} |
||||
event={event} |
||||
/> |
||||
</div> |
||||
<div className="text-muted-foreground"> |
||||
<FormattedTimestamp timestamp={notification.created_at} short /> |
||||
</div> |
||||
</div> |
||||
) |
||||
} |
||||
@ -0,0 +1,34 @@
@@ -0,0 +1,34 @@
|
||||
import { toNote } from '@/lib/link' |
||||
import { cn } from '@/lib/utils' |
||||
import { useSecondaryPage } from '@/PageManager' |
||||
import { MessageCircle } from 'lucide-react' |
||||
import { Event } from 'nostr-tools' |
||||
import ContentPreview from '../../ContentPreview' |
||||
import { FormattedTimestamp } from '../../FormattedTimestamp' |
||||
import UserAvatar from '../../UserAvatar' |
||||
|
||||
export function ReplyNotification({ |
||||
notification, |
||||
isNew = false |
||||
}: { |
||||
notification: Event |
||||
isNew?: boolean |
||||
}) { |
||||
const { push } = useSecondaryPage() |
||||
return ( |
||||
<div |
||||
className="flex gap-2 items-center cursor-pointer py-2" |
||||
onClick={() => push(toNote(notification))} |
||||
> |
||||
<UserAvatar userId={notification.pubkey} size="small" /> |
||||
<MessageCircle size={24} className="text-blue-400" /> |
||||
<ContentPreview |
||||
className={cn('truncate flex-1 w-0', isNew ? 'font-semibold' : 'text-muted-foreground')} |
||||
event={notification} |
||||
/> |
||||
<div className="text-muted-foreground"> |
||||
<FormattedTimestamp timestamp={notification.created_at} short /> |
||||
</div> |
||||
</div> |
||||
) |
||||
} |
||||
@ -0,0 +1,49 @@
@@ -0,0 +1,49 @@
|
||||
import { toNote } from '@/lib/link' |
||||
import { cn } from '@/lib/utils' |
||||
import { useSecondaryPage } from '@/PageManager' |
||||
import client from '@/services/client.service' |
||||
import { Repeat } from 'lucide-react' |
||||
import { Event, validateEvent } from 'nostr-tools' |
||||
import { useMemo } from 'react' |
||||
import ContentPreview from '../../ContentPreview' |
||||
import { FormattedTimestamp } from '../../FormattedTimestamp' |
||||
import UserAvatar from '../../UserAvatar' |
||||
|
||||
export function RepostNotification({ |
||||
notification, |
||||
isNew = false |
||||
}: { |
||||
notification: Event |
||||
isNew?: boolean |
||||
}) { |
||||
const { push } = useSecondaryPage() |
||||
const event = useMemo(() => { |
||||
try { |
||||
const event = JSON.parse(notification.content) as Event |
||||
const isValid = validateEvent(event) |
||||
if (!isValid) return null |
||||
client.addEventToCache(event) |
||||
return event |
||||
} catch { |
||||
return null |
||||
} |
||||
}, [notification.content]) |
||||
if (!event) return null |
||||
|
||||
return ( |
||||
<div |
||||
className="flex gap-2 items-center cursor-pointer py-2" |
||||
onClick={() => push(toNote(event))} |
||||
> |
||||
<UserAvatar userId={notification.pubkey} size="small" /> |
||||
<Repeat size={24} className="text-green-400" /> |
||||
<ContentPreview |
||||
className={cn('truncate flex-1 w-0', isNew ? 'font-semibold' : 'text-muted-foreground')} |
||||
event={event} |
||||
/> |
||||
<div className="text-muted-foreground"> |
||||
<FormattedTimestamp timestamp={notification.created_at} short /> |
||||
</div> |
||||
</div> |
||||
) |
||||
} |
||||
@ -0,0 +1,56 @@
@@ -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> |
||||
) |
||||
} |
||||
@ -0,0 +1,37 @@
@@ -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 |
||||
} |
||||
@ -0,0 +1,24 @@
@@ -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} /> |
||||
</> |
||||
) |
||||
} |
||||
@ -0,0 +1,162 @@
@@ -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> |
||||
</> |
||||
) |
||||
} |
||||
@ -1,24 +0,0 @@
@@ -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()}` |
||||
) |
||||
} |
||||
} |
||||
} |
||||
@ -1,3 +1,7 @@
@@ -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) |
||||
} |
||||
|
||||
@ -0,0 +1,32 @@
@@ -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 |
||||
} |
||||
@ -0,0 +1,38 @@
@@ -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> |
||||
) |
||||
} |
||||
@ -0,0 +1,27 @@
@@ -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> |
||||
) |
||||
} |
||||
@ -0,0 +1,82 @@
@@ -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> |
||||
) |
||||
} |
||||
@ -0,0 +1,21 @@
@@ -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> |
||||
) |
||||
} |
||||
@ -0,0 +1,26 @@
@@ -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 |
||||
@ -0,0 +1,143 @@
@@ -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> |
||||
) |
||||
} |
||||
@ -0,0 +1,57 @@
@@ -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> |
||||
) |
||||
} |
||||
@ -0,0 +1,192 @@
@@ -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 |
||||
Loading…
Reference in new issue