72 changed files with 2580 additions and 816 deletions
@ -1,16 +1,23 @@ |
|||||||
import { usePrimaryPage } from '@/PageManager' |
import { usePrimaryPage } from '@/PageManager' |
||||||
|
import { useNotification } from '@/providers/NotificationProvider' |
||||||
import { Bell } from 'lucide-react' |
import { Bell } from 'lucide-react' |
||||||
import BottomNavigationBarItem from './BottomNavigationBarItem' |
import BottomNavigationBarItem from './BottomNavigationBarItem' |
||||||
|
|
||||||
export default function NotificationsButton() { |
export default function NotificationsButton() { |
||||||
const { navigate, current } = usePrimaryPage() |
const { navigate, current } = usePrimaryPage() |
||||||
|
const { hasNewNotification } = useNotification() |
||||||
|
|
||||||
return ( |
return ( |
||||||
<BottomNavigationBarItem |
<BottomNavigationBarItem |
||||||
active={current === 'notifications'} |
active={current === 'notifications'} |
||||||
onClick={() => navigate('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> |
</BottomNavigationBarItem> |
||||||
) |
) |
||||||
} |
} |
||||||
|
|||||||
@ -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 @@ |
|||||||
|
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 @@ |
|||||||
|
import { PICTURE_EVENT_KIND } from '@/constants' |
||||||
|
import { toNote } from '@/lib/link' |
||||||
|
import { tagNameEquals } from '@/lib/tag' |
||||||
|
import { cn } from '@/lib/utils' |
||||||
|
import { useSecondaryPage } from '@/PageManager' |
||||||
|
import { MessageCircle } from 'lucide-react' |
||||||
|
import { Event, kinds } from 'nostr-tools' |
||||||
|
import ContentPreview from '../../ContentPreview' |
||||||
|
import { FormattedTimestamp } from '../../FormattedTimestamp' |
||||||
|
import UserAvatar from '../../UserAvatar' |
||||||
|
|
||||||
|
export function CommentNotification({ |
||||||
|
notification, |
||||||
|
isNew = false |
||||||
|
}: { |
||||||
|
notification: Event |
||||||
|
isNew?: boolean |
||||||
|
}) { |
||||||
|
const { push } = useSecondaryPage() |
||||||
|
const rootEventId = notification.tags.find(tagNameEquals('E'))?.[1] |
||||||
|
const rootPubkey = notification.tags.find(tagNameEquals('P'))?.[1] |
||||||
|
const rootKind = notification.tags.find(tagNameEquals('K'))?.[1] |
||||||
|
if ( |
||||||
|
!rootEventId || |
||||||
|
!rootPubkey || |
||||||
|
!rootKind || |
||||||
|
![kinds.ShortTextNote, PICTURE_EVENT_KIND].includes(parseInt(rootKind)) |
||||||
|
) { |
||||||
|
return null |
||||||
|
} |
||||||
|
|
||||||
|
return ( |
||||||
|
<div |
||||||
|
className="flex gap-2 items-center cursor-pointer py-2" |
||||||
|
onClick={() => push(toNote({ id: rootEventId, pubkey: rootPubkey }))} |
||||||
|
> |
||||||
|
<UserAvatar userId={notification.pubkey} size="small" /> |
||||||
|
<MessageCircle size={24} className="text-blue-400" /> |
||||||
|
<ContentPreview |
||||||
|
className={cn('truncate flex-1 w-0', isNew ? 'font-semibold' : 'text-muted-foreground')} |
||||||
|
event={notification} |
||||||
|
/> |
||||||
|
<div className="text-muted-foreground"> |
||||||
|
<FormattedTimestamp timestamp={notification.created_at} short /> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
) |
||||||
|
} |
||||||
@ -0,0 +1,60 @@ |
|||||||
|
import { PICTURE_EVENT_KIND } from '@/constants' |
||||||
|
import { useFetchEvent } from '@/hooks' |
||||||
|
import { toNote } from '@/lib/link' |
||||||
|
import { tagNameEquals } from '@/lib/tag' |
||||||
|
import { cn } from '@/lib/utils' |
||||||
|
import { useSecondaryPage } from '@/PageManager' |
||||||
|
import { useNostr } from '@/providers/NostrProvider' |
||||||
|
import { Heart } from 'lucide-react' |
||||||
|
import { Event, kinds } from 'nostr-tools' |
||||||
|
import { useMemo } from 'react' |
||||||
|
import ContentPreview from '../../ContentPreview' |
||||||
|
import { FormattedTimestamp } from '../../FormattedTimestamp' |
||||||
|
import UserAvatar from '../../UserAvatar' |
||||||
|
|
||||||
|
export function ReactionNotification({ |
||||||
|
notification, |
||||||
|
isNew = false |
||||||
|
}: { |
||||||
|
notification: Event |
||||||
|
isNew?: boolean |
||||||
|
}) { |
||||||
|
const { push } = useSecondaryPage() |
||||||
|
const { pubkey } = useNostr() |
||||||
|
const eventId = useMemo(() => { |
||||||
|
const targetPubkey = notification.tags.findLast(tagNameEquals('p'))?.[1] |
||||||
|
if (targetPubkey !== pubkey) return undefined |
||||||
|
|
||||||
|
const eTag = notification.tags.findLast(tagNameEquals('e')) |
||||||
|
return eTag?.[1] |
||||||
|
}, [notification, pubkey]) |
||||||
|
const { event } = useFetchEvent(eventId) |
||||||
|
if (!event || !eventId || ![kinds.ShortTextNote, PICTURE_EVENT_KIND].includes(event.kind)) { |
||||||
|
return null |
||||||
|
} |
||||||
|
|
||||||
|
return ( |
||||||
|
<div |
||||||
|
className="flex items-center justify-between cursor-pointer py-2" |
||||||
|
onClick={() => push(toNote(event))} |
||||||
|
> |
||||||
|
<div className="flex gap-2 items-center flex-1"> |
||||||
|
<UserAvatar userId={notification.pubkey} size="small" /> |
||||||
|
<div className="text-xl min-w-6 text-center"> |
||||||
|
{!notification.content || notification.content === '+' ? ( |
||||||
|
<Heart size={24} className="text-red-400" /> |
||||||
|
) : ( |
||||||
|
notification.content |
||||||
|
)} |
||||||
|
</div> |
||||||
|
<ContentPreview |
||||||
|
className={cn('truncate flex-1 w-0', isNew ? 'font-semibold' : 'text-muted-foreground')} |
||||||
|
event={event} |
||||||
|
/> |
||||||
|
</div> |
||||||
|
<div className="text-muted-foreground"> |
||||||
|
<FormattedTimestamp timestamp={notification.created_at} short /> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
) |
||||||
|
} |
||||||
@ -0,0 +1,34 @@ |
|||||||
|
import { toNote } from '@/lib/link' |
||||||
|
import { cn } from '@/lib/utils' |
||||||
|
import { useSecondaryPage } from '@/PageManager' |
||||||
|
import { MessageCircle } from 'lucide-react' |
||||||
|
import { Event } from 'nostr-tools' |
||||||
|
import ContentPreview from '../../ContentPreview' |
||||||
|
import { FormattedTimestamp } from '../../FormattedTimestamp' |
||||||
|
import UserAvatar from '../../UserAvatar' |
||||||
|
|
||||||
|
export function ReplyNotification({ |
||||||
|
notification, |
||||||
|
isNew = false |
||||||
|
}: { |
||||||
|
notification: Event |
||||||
|
isNew?: boolean |
||||||
|
}) { |
||||||
|
const { push } = useSecondaryPage() |
||||||
|
return ( |
||||||
|
<div |
||||||
|
className="flex gap-2 items-center cursor-pointer py-2" |
||||||
|
onClick={() => push(toNote(notification))} |
||||||
|
> |
||||||
|
<UserAvatar userId={notification.pubkey} size="small" /> |
||||||
|
<MessageCircle size={24} className="text-blue-400" /> |
||||||
|
<ContentPreview |
||||||
|
className={cn('truncate flex-1 w-0', isNew ? 'font-semibold' : 'text-muted-foreground')} |
||||||
|
event={notification} |
||||||
|
/> |
||||||
|
<div className="text-muted-foreground"> |
||||||
|
<FormattedTimestamp timestamp={notification.created_at} short /> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
) |
||||||
|
} |
||||||
@ -0,0 +1,49 @@ |
|||||||
|
import { toNote } from '@/lib/link' |
||||||
|
import { cn } from '@/lib/utils' |
||||||
|
import { useSecondaryPage } from '@/PageManager' |
||||||
|
import client from '@/services/client.service' |
||||||
|
import { Repeat } from 'lucide-react' |
||||||
|
import { Event, validateEvent } from 'nostr-tools' |
||||||
|
import { useMemo } from 'react' |
||||||
|
import ContentPreview from '../../ContentPreview' |
||||||
|
import { FormattedTimestamp } from '../../FormattedTimestamp' |
||||||
|
import UserAvatar from '../../UserAvatar' |
||||||
|
|
||||||
|
export function RepostNotification({ |
||||||
|
notification, |
||||||
|
isNew = false |
||||||
|
}: { |
||||||
|
notification: Event |
||||||
|
isNew?: boolean |
||||||
|
}) { |
||||||
|
const { push } = useSecondaryPage() |
||||||
|
const event = useMemo(() => { |
||||||
|
try { |
||||||
|
const event = JSON.parse(notification.content) as Event |
||||||
|
const isValid = validateEvent(event) |
||||||
|
if (!isValid) return null |
||||||
|
client.addEventToCache(event) |
||||||
|
return event |
||||||
|
} catch { |
||||||
|
return null |
||||||
|
} |
||||||
|
}, [notification.content]) |
||||||
|
if (!event) return null |
||||||
|
|
||||||
|
return ( |
||||||
|
<div |
||||||
|
className="flex gap-2 items-center cursor-pointer py-2" |
||||||
|
onClick={() => push(toNote(event))} |
||||||
|
> |
||||||
|
<UserAvatar userId={notification.pubkey} size="small" /> |
||||||
|
<Repeat size={24} className="text-green-400" /> |
||||||
|
<ContentPreview |
||||||
|
className={cn('truncate flex-1 w-0', isNew ? 'font-semibold' : 'text-muted-foreground')} |
||||||
|
event={event} |
||||||
|
/> |
||||||
|
<div className="text-muted-foreground"> |
||||||
|
<FormattedTimestamp timestamp={notification.created_at} short /> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
) |
||||||
|
} |
||||||
@ -0,0 +1,56 @@ |
|||||||
|
import { useFetchEvent } from '@/hooks' |
||||||
|
import { extractZapInfoFromReceipt } from '@/lib/event' |
||||||
|
import { formatAmount } from '@/lib/lightning' |
||||||
|
import { toNote, toProfile } from '@/lib/link' |
||||||
|
import { cn } from '@/lib/utils' |
||||||
|
import { useSecondaryPage } from '@/PageManager' |
||||||
|
import { useNostr } from '@/providers/NostrProvider' |
||||||
|
import { Zap } from 'lucide-react' |
||||||
|
import { Event } from 'nostr-tools' |
||||||
|
import { useMemo } from 'react' |
||||||
|
import { useTranslation } from 'react-i18next' |
||||||
|
import ContentPreview from '../../ContentPreview' |
||||||
|
import { FormattedTimestamp } from '../../FormattedTimestamp' |
||||||
|
import UserAvatar from '../../UserAvatar' |
||||||
|
|
||||||
|
export function ZapNotification({ |
||||||
|
notification, |
||||||
|
isNew = false |
||||||
|
}: { |
||||||
|
notification: Event |
||||||
|
isNew?: boolean |
||||||
|
}) { |
||||||
|
const { t } = useTranslation() |
||||||
|
const { push } = useSecondaryPage() |
||||||
|
const { pubkey } = useNostr() |
||||||
|
const { senderPubkey, eventId, amount, comment } = useMemo( |
||||||
|
() => extractZapInfoFromReceipt(notification) ?? ({} as any), |
||||||
|
[notification] |
||||||
|
) |
||||||
|
const { event } = useFetchEvent(eventId) |
||||||
|
|
||||||
|
if (!senderPubkey || !amount) return null |
||||||
|
|
||||||
|
return ( |
||||||
|
<div |
||||||
|
className="flex items-center justify-between cursor-pointer py-2" |
||||||
|
onClick={() => (event ? push(toNote(event)) : pubkey ? push(toProfile(pubkey)) : null)} |
||||||
|
> |
||||||
|
<div className="flex gap-2 items-center flex-1 w-0"> |
||||||
|
<UserAvatar userId={senderPubkey} size="small" /> |
||||||
|
<Zap size={24} className="text-yellow-400 shrink-0" /> |
||||||
|
<div className="font-semibold text-yellow-400 shrink-0"> |
||||||
|
{formatAmount(amount)} {t('sats')} |
||||||
|
</div> |
||||||
|
{comment && <div className="text-yellow-400 truncate">{comment}</div>} |
||||||
|
<ContentPreview |
||||||
|
className={cn('truncate flex-1 w-0', isNew ? 'font-semibold' : 'text-muted-foreground')} |
||||||
|
event={event} |
||||||
|
/> |
||||||
|
</div> |
||||||
|
<div className="text-muted-foreground shrink-0"> |
||||||
|
<FormattedTimestamp timestamp={notification.created_at} short /> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
) |
||||||
|
} |
||||||
@ -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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
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 @@ |
|||||||
export function isTouchDevice() { |
export function isTouchDevice() { |
||||||
return 'ontouchstart' in window || navigator.maxTouchPoints > 0 |
return 'ontouchstart' in window || navigator.maxTouchPoints > 0 |
||||||
} |
} |
||||||
|
|
||||||
|
export function isEmail(email: string) { |
||||||
|
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) |
||||||
|
} |
||||||
|
|||||||
@ -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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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