feat: zap (#107)

This commit is contained in:
Cody Tseng 2025-03-01 23:52:05 +08:00 committed by GitHub
parent 407a6fb802
commit 249593d547
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
72 changed files with 2582 additions and 818 deletions

View file

@ -1,10 +1,13 @@
import { Dialog, DialogContent, DialogTrigger } from '@/components/ui/dialog'
import { Drawer, DrawerContent, DrawerTrigger } from '@/components/ui/drawer'
import { CODY_PUBKEY } from '@/constants'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { Drawer, DrawerContent, DrawerTrigger } from '../ui/drawer'
import { useState } from 'react'
import Username from '../Username'
export default function AboutInfoDialog({ children }: { children: React.ReactNode }) {
const { isSmallScreen } = useScreenSize()
const [open, setOpen] = useState(false)
const content = (
<>
@ -13,12 +16,7 @@ export default function AboutInfoDialog({ children }: { children: React.ReactNod
A beautiful nostr client focused on browsing relay feeds
</div>
<div>
Made by{' '}
<Username
userId={'npub1syjmjy0dp62dhccq3g97fr87tngvpvzey08llyt6ul58m2zqpzps9wf6wl'}
className="inline-block text-primary"
showAt
/>
Made by <Username userId={CODY_PUBKEY} className="inline-block text-primary" showAt />
</div>
<div>
Source code:{' '}
@ -30,30 +28,26 @@ export default function AboutInfoDialog({ children }: { children: React.ReactNod
>
GitHub
</a>
</div>
<div>
If you like this project, you can buy me a coffee <br />
<div className="font-semibold"> codytseng@getalby.com </div>
</div>
<div className="text-muted-foreground">
Version: v{__APP_VERSION__} ({__GIT_COMMIT__})
<div className="text-sm text-muted-foreground">
If you like Jumble, please consider giving it a star
</div>
</div>
</>
)
if (isSmallScreen) {
return (
<Drawer>
<Drawer open={open} onOpenChange={setOpen}>
<DrawerTrigger asChild>{children}</DrawerTrigger>
<DrawerContent>
<div className="p-4">{content}</div>
<div className="p-4 space-y-4">{content}</div>
</DrawerContent>
</Drawer>
)
}
return (
<Dialog>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent>{content}</DialogContent>
</Dialog>

View file

@ -1,16 +1,23 @@
import { usePrimaryPage } from '@/PageManager'
import { useNotification } from '@/providers/NotificationProvider'
import { Bell } from 'lucide-react'
import BottomNavigationBarItem from './BottomNavigationBarItem'
export default function NotificationsButton() {
const { navigate, current } = usePrimaryPage()
const { hasNewNotification } = useNotification()
return (
<BottomNavigationBarItem
active={current === 'notifications'}
onClick={() => navigate('notifications')}
>
<Bell />
<div className="relative">
<Bell />
{hasNewNotification && (
<div className="absolute -top-1 -right-1 w-2 h-2 bg-primary rounded-full" />
)}
</div>
</BottomNavigationBarItem>
)
}

View file

@ -0,0 +1,49 @@
import { Button } from '@/components/ui/button'
import { CODY_PUBKEY } from '@/constants'
import { cn } from '@/lib/utils'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import ZapDialog from '../ZapDialog'
export default function Donation({ className }: { className?: string }) {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const [donationAmount, setDonationAmount] = useState<number | undefined>(undefined)
return (
<div className={cn('p-4 border rounded-lg space-y-4', className)}>
<div className="text-center font-semibold">{t('Enjoying Jumble?')}</div>
<div className="text-center text-muted-foreground">
{t('Your donation helps me maintain Jumble and make it better! 😊')}
</div>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{[
{ amount: 1000, text: '☕️ 1k' },
{ amount: 10000, text: '🍜 10k' },
{ amount: 100000, text: '🍣 100k' },
{ amount: 1000000, text: '✈️ 1M' }
].map(({ amount, text }) => {
return (
<Button
variant="secondary"
className=""
key={amount}
onClick={() => {
setDonationAmount(amount)
setOpen(true)
}}
>
{text}
</Button>
)
})}
</div>
<ZapDialog
open={open}
setOpen={setOpen}
pubkey={CODY_PUBKEY}
defaultAmount={donationAmount}
/>
</div>
)
}

View file

@ -13,14 +13,7 @@ export function EmbeddedMention({ userId }: { userId: string }) {
}
export function EmbeddedMentionText({ userId }: { userId: string }) {
return (
<SimpleUsername
userId={userId}
showAt
className="font-normal inline truncate"
withoutSkeleton
/>
)
return <SimpleUsername userId={userId} showAt className="inline truncate" withoutSkeleton />
}
export const embeddedNostrNpubRenderer: TEmbeddedRenderer = {

View file

@ -10,11 +10,11 @@ export default function FollowButton({ pubkey }: { pubkey: string }) {
const { t } = useTranslation()
const { toast } = useToast()
const { pubkey: accountPubkey, checkLogin } = useNostr()
const { followListEvent, followings, isFetching, follow, unfollow } = useFollowList()
const { followings, follow, unfollow } = useFollowList()
const [updating, setUpdating] = useState(false)
const isFollowing = useMemo(() => followings.includes(pubkey), [followings, pubkey])
if (!accountPubkey || isFetching || (pubkey && pubkey === accountPubkey)) return null
if (!accountPubkey || (pubkey && pubkey === accountPubkey)) return null
const handleFollow = async (e: React.MouseEvent) => {
e.stopPropagation()
@ -39,7 +39,7 @@ export default function FollowButton({ pubkey }: { pubkey: string }) {
const handleUnfollow = async (e: React.MouseEvent) => {
e.stopPropagation()
checkLogin(async () => {
if (!isFollowing || !followListEvent) return
if (!isFollowing) return
setUpdating(true)
try {

View file

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

View file

@ -23,7 +23,7 @@ export default function Nip05({ pubkey }: { pubkey: string }) {
return (
nip05Name &&
nip05Domain && (
<div className="flex items-center space-x-1 truncate [&_svg]:size-5">
<div className="flex items-center space-x-1 truncate">
{nip05Name !== '_' ? (
<div className="text-sm text-muted-foreground truncate">@{nip05Name}</div>
) : null}
@ -33,7 +33,7 @@ export default function Nip05({ pubkey }: { pubkey: string }) {
className={`flex items-center space-x-1 hover:underline truncate ${nip05IsVerified ? 'text-highlight' : 'text-muted-foreground'}`}
rel="noreferrer"
>
{nip05IsVerified ? <BadgeCheck /> : <BadgeAlert />}
{nip05IsVerified ? <BadgeCheck className="size-4" /> : <BadgeAlert className="size-4" />}
<div className="text-sm truncate">{nip05Domain}</div>
</a>
</div>

View file

@ -17,7 +17,7 @@ export default function RepostDescription({
<div className={cn('flex gap-1 text-sm items-center text-muted-foreground mb-1', className)}>
<Repeat2 size={16} className="shrink-0" />
<Username userId={reposter} className="font-semibold truncate" skeletonClassName="h-3" />
<div>{t('reposted')}</div>
<div className="shrink-0">{t('reposted')}</div>
</div>
)
}

View file

@ -22,7 +22,7 @@ import PictureNoteCard from '../PictureNoteCard'
const LIMIT = 100
const ALGO_LIMIT = 500
const SHOW_COUNT = 20
const SHOW_COUNT = 10
export default function NoteList({
relayUrls,
@ -266,7 +266,7 @@ function ListModeSwitch({
return (
<div
className={cn(
'sticky top-12 bg-background z-30 duration-700 transition-transform',
'sticky top-12 bg-background z-30 duration-700 transition-transform select-none',
deepBrowsing && lastScrollTop > 800 ? '-translate-y-[calc(100%+12rem)]' : ''
)}
>

View file

@ -5,57 +5,44 @@ import { useNoteStats } from '@/providers/NoteStatsProvider'
import client from '@/services/client.service'
import { Heart, Loader } from 'lucide-react'
import { Event } from 'nostr-tools'
import { useEffect, useMemo, useState } from 'react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { formatCount } from './utils'
export default function LikeButton({
event,
canFetch = false
}: {
event: Event
canFetch?: boolean
}) {
export default function LikeButton({ event }: { event: Event }) {
const { t } = useTranslation()
const { publish, checkLogin } = useNostr()
const { noteStatsMap, fetchNoteLikedStatus, fetchNoteLikeCount, markNoteAsLiked } = useNoteStats()
const { pubkey, publish, checkLogin } = useNostr()
const { noteStatsMap, updateNoteStatsByEvents, fetchNoteStats } = useNoteStats()
const [liking, setLiking] = useState(false)
const { likeCount, hasLiked } = useMemo(
() => noteStatsMap.get(event.id) ?? {},
[noteStatsMap, event.id]
)
const { likeCount, hasLiked } = useMemo(() => {
const stats = noteStatsMap.get(event.id) || {}
return { likeCount: stats.likes?.size, hasLiked: pubkey ? stats.likes?.has(pubkey) : false }
}, [noteStatsMap, event, pubkey])
const canLike = !hasLiked && !liking
useEffect(() => {
if (!canFetch) return
if (likeCount === undefined) {
fetchNoteLikeCount(event)
}
if (hasLiked === undefined) {
fetchNoteLikedStatus(event)
}
}, [canFetch, event])
const like = async (e: React.MouseEvent) => {
e.stopPropagation()
checkLogin(async () => {
if (!canLike) return
if (!canLike || !pubkey) return
setLiking(true)
const timer = setTimeout(() => setLiking(false), 5000)
try {
const [liked] = await Promise.all([
hasLiked === undefined ? fetchNoteLikedStatus(event) : hasLiked,
likeCount === undefined ? fetchNoteLikeCount(event) : likeCount
])
if (liked) return
const noteStats = noteStatsMap.get(event.id)
const hasLiked = noteStats?.likes?.has(pubkey)
if (hasLiked) return
if (!noteStats?.updatedAt) {
const stats = await fetchNoteStats(event)
if (stats?.likes?.has(pubkey)) return
}
const targetRelayList = await client.fetchRelayList(event.pubkey)
const reaction = createReactionDraftEvent(event)
await publish(reaction, { additionalRelayUrls: targetRelayList.read.slice(0, 4) })
markNoteAsLiked(event.id)
const evt = await publish(reaction, {
additionalRelayUrls: targetRelayList.read.slice(0, 4)
})
updateNoteStatsByEvents([evt])
} catch (error) {
console.error('like failed', error)
} finally {

View file

@ -12,60 +12,47 @@ import { useNoteStats } from '@/providers/NoteStatsProvider'
import client from '@/services/client.service'
import { Loader, PencilLine, Repeat } from 'lucide-react'
import { Event } from 'nostr-tools'
import { useEffect, useMemo, useState } from 'react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import PostEditor from '../PostEditor'
import { formatCount } from './utils'
export default function RepostButton({
event,
canFetch = false
}: {
event: Event
canFetch?: boolean
}) {
export default function RepostButton({ event }: { event: Event }) {
const { t } = useTranslation()
const { publish, checkLogin } = useNostr()
const { noteStatsMap, fetchNoteRepostCount, fetchNoteRepostedStatus, markNoteAsReposted } =
useNoteStats()
const { publish, checkLogin, pubkey } = useNostr()
const { noteStatsMap, updateNoteStatsByEvents, fetchNoteStats } = useNoteStats()
const [reposting, setReposting] = useState(false)
const [isPostDialogOpen, setIsPostDialogOpen] = useState(false)
const { repostCount, hasReposted } = useMemo(
() => noteStatsMap.get(event.id) ?? {},
[noteStatsMap, event.id]
)
const { repostCount, hasReposted } = useMemo(() => {
const stats = noteStatsMap.get(event.id) || {}
return {
repostCount: stats.reposts?.size,
hasReposted: pubkey ? stats.reposts?.has(pubkey) : false
}
}, [noteStatsMap, event.id])
const canRepost = !hasReposted && !reposting
useEffect(() => {
if (!canFetch) return
if (repostCount === undefined) {
fetchNoteRepostCount(event)
}
if (hasReposted === undefined) {
fetchNoteRepostedStatus(event)
}
}, [canFetch, event])
const repost = async (e: React.MouseEvent) => {
e.stopPropagation()
checkLogin(async () => {
if (!canRepost) return
if (!canRepost || !pubkey) return
setReposting(true)
const timer = setTimeout(() => setReposting(false), 5000)
try {
const [reposted] = await Promise.all([
hasReposted === undefined ? fetchNoteRepostedStatus(event) : hasReposted,
repostCount === undefined ? fetchNoteRepostCount(event) : repostCount
])
if (reposted) return
const noteStats = noteStatsMap.get(event.id)
const hasReposted = noteStats?.reposts?.has(pubkey)
if (hasReposted) return
if (!noteStats?.updatedAt) {
const stats = await fetchNoteStats(event)
if (stats?.reposts?.has(pubkey)) return
}
const targetRelayList = await client.fetchRelayList(event.pubkey)
const repost = createRepostDraftEvent(event)
await publish(repost, { additionalRelayUrls: targetRelayList.read.slice(0, 5) })
markNoteAsReposted(event.id)
const evt = await publish(repost, { additionalRelayUrls: targetRelayList.read.slice(0, 5) })
updateNoteStatsByEvents([evt])
} catch (error) {
console.error('repost failed', error)
} finally {

View file

@ -0,0 +1,43 @@
import { useSecondaryPage } from '@/PageManager'
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'
import { formatAmount } from '@/lib/lightning'
import { toProfile } from '@/lib/link'
import { useNoteStats } from '@/providers/NoteStatsProvider'
import { Zap } from 'lucide-react'
import { Event } from 'nostr-tools'
import { useMemo } from 'react'
import { SimpleUserAvatar } from '../UserAvatar'
export default function TopZaps({ event }: { event: Event }) {
const { push } = useSecondaryPage()
const { noteStatsMap } = useNoteStats()
const topZaps = useMemo(() => {
const stats = noteStatsMap.get(event.id) || {}
return stats.zaps?.slice(0, 10) || []
}, [noteStatsMap, event])
if (!topZaps.length) return null
return (
<ScrollArea className="pb-2 mb-1">
<div className="flex gap-1">
{topZaps.map((zap) => (
<div
key={zap.pr}
className="flex gap-1 py-1 pl-1 pr-2 text-sm rounded-full bg-muted items-center text-yellow-400 clickable"
onClick={(e) => {
e.stopPropagation()
push(toProfile(zap.pubkey))
}}
>
<SimpleUserAvatar userId={zap.pubkey} size="xSmall" />
<Zap className="size-3 fill-yellow-400" />
<div className="font-semibold">{formatAmount(zap.amount)}</div>
<div className="truncate">{zap.comment}</div>
</div>
))}
</div>
<ScrollBar orientation="horizontal" />
</ScrollArea>
)
}

View file

@ -0,0 +1,147 @@
import { useToast } from '@/hooks'
import { getLightningAddressFromProfile } from '@/lib/lightning'
import { cn } from '@/lib/utils'
import { useNostr } from '@/providers/NostrProvider'
import { useNoteStats } from '@/providers/NoteStatsProvider'
import { useZap } from '@/providers/ZapProvider'
import client from '@/services/client.service'
import lightning from '@/services/lightning.service'
import { Loader, Zap } from 'lucide-react'
import { Event } from 'nostr-tools'
import { MouseEvent, TouchEvent, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import ZapDialog from '../ZapDialog'
export default function ZapButton({ event }: { event: Event }) {
const { t } = useTranslation()
const { toast } = useToast()
const { checkLogin, pubkey } = useNostr()
const { noteStatsMap, addZap } = useNoteStats()
const { defaultZapSats, defaultZapComment, quickZap } = useZap()
const [openZapDialog, setOpenZapDialog] = useState(false)
const [zapping, setZapping] = useState(false)
const { zapAmount, hasZapped } = useMemo(() => {
const stats = noteStatsMap.get(event.id) || {}
return {
zapAmount: stats.zaps?.reduce((acc, zap) => acc + zap.amount, 0),
hasZapped: pubkey ? stats.zaps?.some((zap) => zap.pubkey === pubkey) : false
}
}, [noteStatsMap, event, pubkey])
const [showButton, setShowButton] = useState(false)
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const isLongPressRef = useRef(false)
useEffect(() => {
client.fetchProfile(event.pubkey).then((profile) => {
if (!profile) return
const lightningAddress = getLightningAddressFromProfile(profile)
if (lightningAddress) setShowButton(true)
})
}, [event])
if (!showButton) return null
const handleZap = async () => {
try {
if (!pubkey) {
throw new Error('You need to be logged in to zap')
}
setZapping(true)
const { invoice } = await lightning.zap(
pubkey,
event.pubkey,
defaultZapSats,
defaultZapComment,
event.id
)
addZap(event.id, invoice, defaultZapSats, defaultZapComment)
} catch (error) {
toast({
title: t('Zap failed'),
description: (error as Error).message,
variant: 'destructive'
})
} finally {
setZapping(false)
}
}
const handleClickStart = (e: MouseEvent | TouchEvent) => {
e.stopPropagation()
e.preventDefault()
isLongPressRef.current = false
if (quickZap) {
timerRef.current = setTimeout(() => {
isLongPressRef.current = true
checkLogin(() => {
setOpenZapDialog(true)
setZapping(true)
})
}, 500)
}
}
const handleClickEnd = (e: MouseEvent | TouchEvent) => {
e.stopPropagation()
e.preventDefault()
if (timerRef.current) {
clearTimeout(timerRef.current)
}
if (!quickZap) {
checkLogin(() => {
setOpenZapDialog(true)
setZapping(true)
})
} else if (!isLongPressRef.current) {
checkLogin(() => handleZap())
}
isLongPressRef.current = false
}
const handleMouseLeave = () => {
if (timerRef.current) {
clearTimeout(timerRef.current)
}
}
return (
<>
<button
className={cn(
'flex items-center enabled:hover:text-yellow-400 gap-1 select-none',
hasZapped ? 'text-yellow-400' : 'text-muted-foreground'
)}
title={t('Zap')}
onMouseDown={handleClickStart}
onMouseUp={handleClickEnd}
onMouseLeave={handleMouseLeave}
onTouchStart={handleClickStart}
onTouchEnd={handleClickEnd}
>
{zapping ? (
<Loader className="animate-spin" size={16} />
) : (
<Zap size={16} className={hasZapped ? 'fill-yellow-400' : ''} />
)}
{!!zapAmount && <div className="text-sm">{formatAmount(zapAmount)}</div>}
</button>
<ZapDialog
open={openZapDialog}
setOpen={(open) => {
setOpenZapDialog(open)
setZapping(open)
}}
pubkey={event.pubkey}
eventId={event.id}
/>
</>
)
}
function formatAmount(amount: number) {
if (amount < 1000) return amount
if (amount < 1000000) return `${Math.round(amount / 100) / 10}k`
return `${Math.round(amount / 100000) / 10}M`
}

View file

@ -1,10 +1,14 @@
import { cn } from '@/lib/utils'
import { useNoteStats } from '@/providers/NoteStatsProvider'
import { Event } from 'nostr-tools'
import { useEffect } from 'react'
import LikeButton from './LikeButton'
import NoteOptions from './NoteOptions'
import ReplyButton from './ReplyButton'
import RepostButton from './RepostButton'
import SeenOnButton from './SeenOnButton'
import TopZaps from './TopZaps'
import ZapButton from './ZapButton'
export default function NoteStats({
event,
@ -17,16 +21,27 @@ export default function NoteStats({
fetchIfNotExisting?: boolean
variant?: 'note' | 'reply'
}) {
const { fetchNoteStats } = useNoteStats()
useEffect(() => {
if (!fetchIfNotExisting) return
fetchNoteStats(event)
}, [event, fetchIfNotExisting])
return (
<div className={cn('flex justify-between', className)}>
<div className="flex gap-5 h-4 items-center" onClick={(e) => e.stopPropagation()}>
<ReplyButton event={event} variant={variant} />
<RepostButton event={event} canFetch={fetchIfNotExisting} />
<LikeButton event={event} canFetch={fetchIfNotExisting} />
</div>
<div className="flex gap-5 h-4 items-center" onClick={(e) => e.stopPropagation()}>
<SeenOnButton event={event} />
<NoteOptions event={event} />
<div className={cn('select-none', className)}>
<TopZaps event={event} />
<div className="flex justify-between">
<div className="flex gap-5 h-4 items-center" onClick={(e) => e.stopPropagation()}>
<ReplyButton event={event} variant={variant} />
<RepostButton event={event} />
<LikeButton event={event} />
<ZapButton event={event} />
</div>
<div className="flex gap-5 h-4 items-center" onClick={(e) => e.stopPropagation()}>
<SeenOnButton event={event} />
<NoteOptions event={event} />
</div>
</div>
</div>
)

View file

@ -0,0 +1,48 @@
import { PICTURE_EVENT_KIND } from '@/constants'
import { toNote } from '@/lib/link'
import { tagNameEquals } from '@/lib/tag'
import { cn } from '@/lib/utils'
import { useSecondaryPage } from '@/PageManager'
import { MessageCircle } from 'lucide-react'
import { Event, kinds } from 'nostr-tools'
import ContentPreview from '../../ContentPreview'
import { FormattedTimestamp } from '../../FormattedTimestamp'
import UserAvatar from '../../UserAvatar'
export function CommentNotification({
notification,
isNew = false
}: {
notification: Event
isNew?: boolean
}) {
const { push } = useSecondaryPage()
const rootEventId = notification.tags.find(tagNameEquals('E'))?.[1]
const rootPubkey = notification.tags.find(tagNameEquals('P'))?.[1]
const rootKind = notification.tags.find(tagNameEquals('K'))?.[1]
if (
!rootEventId ||
!rootPubkey ||
!rootKind ||
![kinds.ShortTextNote, PICTURE_EVENT_KIND].includes(parseInt(rootKind))
) {
return null
}
return (
<div
className="flex gap-2 items-center cursor-pointer py-2"
onClick={() => push(toNote({ id: rootEventId, pubkey: rootPubkey }))}
>
<UserAvatar userId={notification.pubkey} size="small" />
<MessageCircle size={24} className="text-blue-400" />
<ContentPreview
className={cn('truncate flex-1 w-0', isNew ? 'font-semibold' : 'text-muted-foreground')}
event={notification}
/>
<div className="text-muted-foreground">
<FormattedTimestamp timestamp={notification.created_at} short />
</div>
</div>
)
}

View file

@ -0,0 +1,60 @@
import { PICTURE_EVENT_KIND } from '@/constants'
import { useFetchEvent } from '@/hooks'
import { toNote } from '@/lib/link'
import { tagNameEquals } from '@/lib/tag'
import { cn } from '@/lib/utils'
import { useSecondaryPage } from '@/PageManager'
import { useNostr } from '@/providers/NostrProvider'
import { Heart } from 'lucide-react'
import { Event, kinds } from 'nostr-tools'
import { useMemo } from 'react'
import ContentPreview from '../../ContentPreview'
import { FormattedTimestamp } from '../../FormattedTimestamp'
import UserAvatar from '../../UserAvatar'
export function ReactionNotification({
notification,
isNew = false
}: {
notification: Event
isNew?: boolean
}) {
const { push } = useSecondaryPage()
const { pubkey } = useNostr()
const eventId = useMemo(() => {
const targetPubkey = notification.tags.findLast(tagNameEquals('p'))?.[1]
if (targetPubkey !== pubkey) return undefined
const eTag = notification.tags.findLast(tagNameEquals('e'))
return eTag?.[1]
}, [notification, pubkey])
const { event } = useFetchEvent(eventId)
if (!event || !eventId || ![kinds.ShortTextNote, PICTURE_EVENT_KIND].includes(event.kind)) {
return null
}
return (
<div
className="flex items-center justify-between cursor-pointer py-2"
onClick={() => push(toNote(event))}
>
<div className="flex gap-2 items-center flex-1">
<UserAvatar userId={notification.pubkey} size="small" />
<div className="text-xl min-w-6 text-center">
{!notification.content || notification.content === '+' ? (
<Heart size={24} className="text-red-400" />
) : (
notification.content
)}
</div>
<ContentPreview
className={cn('truncate flex-1 w-0', isNew ? 'font-semibold' : 'text-muted-foreground')}
event={event}
/>
</div>
<div className="text-muted-foreground">
<FormattedTimestamp timestamp={notification.created_at} short />
</div>
</div>
)
}

View file

@ -0,0 +1,34 @@
import { toNote } from '@/lib/link'
import { cn } from '@/lib/utils'
import { useSecondaryPage } from '@/PageManager'
import { MessageCircle } from 'lucide-react'
import { Event } from 'nostr-tools'
import ContentPreview from '../../ContentPreview'
import { FormattedTimestamp } from '../../FormattedTimestamp'
import UserAvatar from '../../UserAvatar'
export function ReplyNotification({
notification,
isNew = false
}: {
notification: Event
isNew?: boolean
}) {
const { push } = useSecondaryPage()
return (
<div
className="flex gap-2 items-center cursor-pointer py-2"
onClick={() => push(toNote(notification))}
>
<UserAvatar userId={notification.pubkey} size="small" />
<MessageCircle size={24} className="text-blue-400" />
<ContentPreview
className={cn('truncate flex-1 w-0', isNew ? 'font-semibold' : 'text-muted-foreground')}
event={notification}
/>
<div className="text-muted-foreground">
<FormattedTimestamp timestamp={notification.created_at} short />
</div>
</div>
)
}

View file

@ -0,0 +1,49 @@
import { toNote } from '@/lib/link'
import { cn } from '@/lib/utils'
import { useSecondaryPage } from '@/PageManager'
import client from '@/services/client.service'
import { Repeat } from 'lucide-react'
import { Event, validateEvent } from 'nostr-tools'
import { useMemo } from 'react'
import ContentPreview from '../../ContentPreview'
import { FormattedTimestamp } from '../../FormattedTimestamp'
import UserAvatar from '../../UserAvatar'
export function RepostNotification({
notification,
isNew = false
}: {
notification: Event
isNew?: boolean
}) {
const { push } = useSecondaryPage()
const event = useMemo(() => {
try {
const event = JSON.parse(notification.content) as Event
const isValid = validateEvent(event)
if (!isValid) return null
client.addEventToCache(event)
return event
} catch {
return null
}
}, [notification.content])
if (!event) return null
return (
<div
className="flex gap-2 items-center cursor-pointer py-2"
onClick={() => push(toNote(event))}
>
<UserAvatar userId={notification.pubkey} size="small" />
<Repeat size={24} className="text-green-400" />
<ContentPreview
className={cn('truncate flex-1 w-0', isNew ? 'font-semibold' : 'text-muted-foreground')}
event={event}
/>
<div className="text-muted-foreground">
<FormattedTimestamp timestamp={notification.created_at} short />
</div>
</div>
)
}

View file

@ -0,0 +1,56 @@
import { useFetchEvent } from '@/hooks'
import { extractZapInfoFromReceipt } from '@/lib/event'
import { formatAmount } from '@/lib/lightning'
import { toNote, toProfile } from '@/lib/link'
import { cn } from '@/lib/utils'
import { useSecondaryPage } from '@/PageManager'
import { useNostr } from '@/providers/NostrProvider'
import { Zap } from 'lucide-react'
import { Event } from 'nostr-tools'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import ContentPreview from '../../ContentPreview'
import { FormattedTimestamp } from '../../FormattedTimestamp'
import UserAvatar from '../../UserAvatar'
export function ZapNotification({
notification,
isNew = false
}: {
notification: Event
isNew?: boolean
}) {
const { t } = useTranslation()
const { push } = useSecondaryPage()
const { pubkey } = useNostr()
const { senderPubkey, eventId, amount, comment } = useMemo(
() => extractZapInfoFromReceipt(notification) ?? ({} as any),
[notification]
)
const { event } = useFetchEvent(eventId)
if (!senderPubkey || !amount) return null
return (
<div
className="flex items-center justify-between cursor-pointer py-2"
onClick={() => (event ? push(toNote(event)) : pubkey ? push(toProfile(pubkey)) : null)}
>
<div className="flex gap-2 items-center flex-1 w-0">
<UserAvatar userId={senderPubkey} size="small" />
<Zap size={24} className="text-yellow-400 shrink-0" />
<div className="font-semibold text-yellow-400 shrink-0">
{formatAmount(amount)} {t('sats')}
</div>
{comment && <div className="text-yellow-400 truncate">{comment}</div>}
<ContentPreview
className={cn('truncate flex-1 w-0', isNew ? 'font-semibold' : 'text-muted-foreground')}
event={event}
/>
</div>
<div className="text-muted-foreground shrink-0">
<FormattedTimestamp timestamp={notification.created_at} short />
</div>
</div>
)
}

View file

@ -0,0 +1,37 @@
import { COMMENT_EVENT_KIND } from '@/constants'
import { useMuteList } from '@/providers/MuteListProvider'
import { Event, kinds } from 'nostr-tools'
import { CommentNotification } from './CommentNotification'
import { ReactionNotification } from './ReactionNotification'
import { ReplyNotification } from './ReplyNotification'
import { RepostNotification } from './RepostNotification'
import { ZapNotification } from './ZapNotification'
export function NotificationItem({
notification,
isNew = false
}: {
notification: Event
isNew?: boolean
}) {
const { mutePubkeys } = useMuteList()
if (mutePubkeys.includes(notification.pubkey)) {
return null
}
if (notification.kind === kinds.Reaction) {
return <ReactionNotification notification={notification} isNew={isNew} />
}
if (notification.kind === kinds.ShortTextNote) {
return <ReplyNotification notification={notification} isNew={isNew} />
}
if (notification.kind === kinds.Repost) {
return <RepostNotification notification={notification} isNew={isNew} />
}
if (notification.kind === kinds.Zap) {
return <ZapNotification notification={notification} isNew={isNew} />
}
if (notification.kind === COMMENT_EVENT_KIND) {
return <CommentNotification notification={notification} isNew={isNew} />
}
return null
}

View file

@ -1,15 +1,15 @@
import { Separator } from '@/components/ui/separator'
import { Skeleton } from '@/components/ui/skeleton'
import { BIG_RELAY_URLS, COMMENT_EVENT_KIND, PICTURE_EVENT_KIND } from '@/constants'
import { useFetchEvent } from '@/hooks'
import { toNote } from '@/lib/link'
import { tagNameEquals } from '@/lib/tag'
import { useSecondaryPage } from '@/PageManager'
import { useMuteList } from '@/providers/MuteListProvider'
import { BIG_RELAY_URLS, COMMENT_EVENT_KIND } from '@/constants'
import { cn } from '@/lib/utils'
import { useDeepBrowsing } from '@/providers/DeepBrowsingProvider'
import { useNostr } from '@/providers/NostrProvider'
import { useNoteStats } from '@/providers/NoteStatsProvider'
import client from '@/services/client.service'
import storage from '@/services/local-storage.service'
import { TNotificationType } from '@/types'
import dayjs from 'dayjs'
import { Heart, MessageCircle, Repeat, ThumbsUp } from 'lucide-react'
import { Event, kinds, validateEvent } from 'nostr-tools'
import { Event, kinds } from 'nostr-tools'
import {
forwardRef,
useCallback,
@ -21,9 +21,7 @@ import {
} from 'react'
import { useTranslation } from 'react-i18next'
import PullToRefresh from 'react-simple-pull-to-refresh'
import ContentPreview from '../ContentPreview'
import { FormattedTimestamp } from '../FormattedTimestamp'
import UserAvatar from '../UserAvatar'
import { NotificationItem } from './NotificationItem'
const LIMIT = 100
const SHOW_COUNT = 30
@ -31,13 +29,30 @@ const SHOW_COUNT = 30
const NotificationList = forwardRef((_, ref) => {
const { t } = useTranslation()
const { pubkey } = useNostr()
const { updateNoteStatsByEvents } = useNoteStats()
const [notificationType, setNotificationType] = useState<TNotificationType>('all')
const [lastReadTime, setLastReadTime] = useState(0)
const [refreshCount, setRefreshCount] = useState(0)
const [timelineKey, setTimelineKey] = useState<string | undefined>(undefined)
const [refreshing, setRefreshing] = useState(true)
const [notifications, setNotifications] = useState<Event[]>([])
const [newNotifications, setNewNotifications] = useState<Event[]>([])
const [oldNotifications, setOldNotifications] = useState<Event[]>([])
const [showCount, setShowCount] = useState(SHOW_COUNT)
const [until, setUntil] = useState<number | undefined>(dayjs().unix())
const bottomRef = useRef<HTMLDivElement | null>(null)
const filterKinds = useMemo(() => {
switch (notificationType) {
case 'mentions':
return [kinds.ShortTextNote, COMMENT_EVENT_KIND]
case 'reactions':
return [kinds.Reaction, kinds.Repost]
case 'zaps':
return [kinds.Zap]
default:
return [kinds.ShortTextNote, kinds.Repost, kinds.Reaction, kinds.Zap, COMMENT_EVENT_KIND]
}
}, [notificationType])
useImperativeHandle(
ref,
() => ({
@ -57,6 +72,9 @@ const NotificationList = forwardRef((_, ref) => {
const init = async () => {
setRefreshing(true)
setNotifications([])
setShowCount(SHOW_COUNT)
setLastReadTime(storage.getLastReadNotificationTime(pubkey))
const relayList = await client.fetchRelayList(pubkey)
let eventCount = 0
const { closer, timelineKey } = await client.subscribeTimeline(
@ -65,7 +83,7 @@ const NotificationList = forwardRef((_, ref) => {
: relayList.read.concat(BIG_RELAY_URLS).slice(0, 4),
{
'#p': [pubkey],
kinds: [kinds.ShortTextNote, kinds.Repost, kinds.Reaction, COMMENT_EVENT_KIND],
kinds: filterKinds,
limit: LIMIT
},
{
@ -76,6 +94,7 @@ const NotificationList = forwardRef((_, ref) => {
if (eosed) {
setRefreshing(false)
setUntil(events.length > 0 ? events[events.length - 1].created_at - 1 : undefined)
updateNoteStatsByEvents(events)
}
},
onNew: (event) => {
@ -89,6 +108,7 @@ const NotificationList = forwardRef((_, ref) => {
}
return [...oldEvents.slice(0, index), event, ...oldEvents.slice(index)]
})
updateNoteStatsByEvents([event])
}
}
)
@ -100,7 +120,19 @@ const NotificationList = forwardRef((_, ref) => {
return () => {
promise.then((closer) => closer?.())
}
}, [pubkey, refreshCount])
}, [pubkey, refreshCount, filterKinds])
useEffect(() => {
const visibleNotifications = notifications.slice(0, showCount)
const index = visibleNotifications.findIndex((event) => event.created_at <= lastReadTime)
if (index === -1) {
setNewNotifications(visibleNotifications)
setOldNotifications([])
} else {
setNewNotifications(visibleNotifications.slice(0, index))
setOldNotifications(visibleNotifications.slice(index))
}
}, [notifications, lastReadTime, showCount])
const loadMore = useCallback(async () => {
if (showCount < notifications.length) {
@ -153,160 +185,103 @@ const NotificationList = forwardRef((_, ref) => {
}, [loadMore])
return (
<PullToRefresh
onRefresh={async () => {
setRefreshCount((count) => count + 1)
await new Promise((resolve) => setTimeout(resolve, 1000))
}}
pullingContent=""
>
<div>
{notifications.slice(0, showCount).map((notification) => (
<NotificationItem key={notification.id} notification={notification} />
))}
<div className="text-center text-sm text-muted-foreground">
{until || refreshing ? (
<div ref={bottomRef}>
<div className="flex gap-2 items-center h-11 py-2">
<Skeleton className="w-7 h-7 rounded-full" />
<Skeleton className="h-6 flex-1 w-0" />
</div>
<div>
<NotificationTypeSwitch
type={notificationType}
setType={(type) => {
setShowCount(SHOW_COUNT)
setNotificationType(type)
}}
/>
<PullToRefresh
onRefresh={async () => {
setRefreshCount((count) => count + 1)
await new Promise((resolve) => setTimeout(resolve, 1000))
}}
pullingContent=""
>
<div className="px-4 pt-2">
{newNotifications.map((notification) => (
<NotificationItem key={notification.id} notification={notification} isNew />
))}
{!!newNotifications.length && (
<div className="relative my-2">
<Separator />
<span className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-background px-2 text-xs text-muted-foreground">
{t('Earlier notifications')}
</span>
</div>
) : (
t('no more notifications')
)}
{oldNotifications.map((notification) => (
<NotificationItem key={notification.id} notification={notification} />
))}
<div className="text-center text-sm text-muted-foreground">
{until || refreshing ? (
<div ref={bottomRef}>
<div className="flex gap-2 items-center h-11 py-2">
<Skeleton className="w-7 h-7 rounded-full" />
<Skeleton className="h-6 flex-1 w-0" />
</div>
</div>
) : (
t('no more notifications')
)}
</div>
</div>
</div>
</PullToRefresh>
</PullToRefresh>
</div>
)
})
NotificationList.displayName = 'NotificationList'
export default NotificationList
function NotificationItem({ notification }: { notification: Event }) {
const { mutePubkeys } = useMuteList()
if (mutePubkeys.includes(notification.pubkey)) {
return null
}
if (notification.kind === kinds.Reaction) {
return <ReactionNotification notification={notification} />
}
if (notification.kind === kinds.ShortTextNote) {
return <ReplyNotification notification={notification} />
}
if (notification.kind === kinds.Repost) {
return <RepostNotification notification={notification} />
}
if (notification.kind === COMMENT_EVENT_KIND) {
return <CommentNotification notification={notification} />
}
return null
}
function ReactionNotification({ notification }: { notification: Event }) {
const { push } = useSecondaryPage()
const { pubkey } = useNostr()
const eventId = useMemo(() => {
const targetPubkey = notification.tags.findLast(tagNameEquals('p'))?.[1]
if (targetPubkey !== pubkey) return undefined
const eTag = notification.tags.findLast(tagNameEquals('e'))
return eTag?.[1]
}, [notification, pubkey])
const { event } = useFetchEvent(eventId)
if (!event || !eventId || ![kinds.ShortTextNote, PICTURE_EVENT_KIND].includes(event.kind)) {
return null
}
function NotificationTypeSwitch({
type,
setType
}: {
type: TNotificationType
setType: (type: TNotificationType) => void
}) {
const { t } = useTranslation()
const { deepBrowsing, lastScrollTop } = useDeepBrowsing()
return (
<div
className="flex items-center justify-between cursor-pointer py-2"
onClick={() => push(toNote(event))}
className={cn(
'sticky top-12 bg-background z-30 duration-700 transition-transform select-none',
deepBrowsing && lastScrollTop > 800 ? '-translate-y-[calc(100%+12rem)]' : ''
)}
>
<div className="flex gap-2 items-center flex-1">
<UserAvatar userId={notification.pubkey} size="small" />
<Heart size={24} className="text-red-400" />
<div>{notification.content === '+' ? <ThumbsUp size={14} /> : notification.content}</div>
<ContentPreview className="truncate flex-1 w-0" event={event} />
<div className="flex">
<div
className={`w-1/4 text-center py-2 font-semibold clickable cursor-pointer rounded-lg ${type === 'all' ? '' : 'text-muted-foreground'}`}
onClick={() => setType('all')}
>
{t('All')}
</div>
<div
className={`w-1/4 text-center py-2 font-semibold clickable cursor-pointer rounded-lg ${type === 'mentions' ? '' : 'text-muted-foreground'}`}
onClick={() => setType('mentions')}
>
{t('Mentions')}
</div>
<div
className={`w-1/4 text-center py-2 font-semibold clickable cursor-pointer rounded-lg ${type === 'reactions' ? '' : 'text-muted-foreground'}`}
onClick={() => setType('reactions')}
>
{t('Reactions')}
</div>
<div
className={`w-1/4 text-center py-2 font-semibold clickable cursor-pointer rounded-lg ${type === 'zaps' ? '' : 'text-muted-foreground'}`}
onClick={() => setType('zaps')}
>
{t('Zaps')}
</div>
</div>
<div className="text-muted-foreground">
<FormattedTimestamp timestamp={notification.created_at} short />
</div>
</div>
)
}
function ReplyNotification({ notification }: { notification: Event }) {
const { push } = useSecondaryPage()
return (
<div
className="flex gap-2 items-center cursor-pointer py-2"
onClick={() => push(toNote(notification))}
>
<UserAvatar userId={notification.pubkey} size="small" />
<MessageCircle size={24} className="text-blue-400" />
<ContentPreview className="truncate flex-1 w-0" event={notification} />
<div className="text-muted-foreground">
<FormattedTimestamp timestamp={notification.created_at} short />
</div>
</div>
)
}
function RepostNotification({ notification }: { notification: Event }) {
const { push } = useSecondaryPage()
const event = useMemo(() => {
try {
const event = JSON.parse(notification.content) as Event
const isValid = validateEvent(event)
if (!isValid) return null
client.addEventToCache(event)
return event
} catch {
return null
}
}, [notification.content])
if (!event) return null
return (
<div
className="flex gap-2 items-center cursor-pointer py-2"
onClick={() => push(toNote(event))}
>
<UserAvatar userId={notification.pubkey} size="small" />
<Repeat size={24} className="text-green-400" />
<ContentPreview className="truncate flex-1 w-0" event={event} />
<div className="text-muted-foreground">
<FormattedTimestamp timestamp={notification.created_at} short />
</div>
</div>
)
}
function CommentNotification({ notification }: { notification: Event }) {
const { push } = useSecondaryPage()
const rootEventId = notification.tags.find(tagNameEquals('E'))?.[1]
const rootPubkey = notification.tags.find(tagNameEquals('P'))?.[1]
const rootKind = notification.tags.find(tagNameEquals('K'))?.[1]
if (
!rootEventId ||
!rootPubkey ||
!rootKind ||
![kinds.ShortTextNote, PICTURE_EVENT_KIND].includes(parseInt(rootKind))
) {
return null
}
return (
<div
className="flex gap-2 items-center cursor-pointer py-2"
onClick={() => push(toNote({ id: rootEventId, pubkey: rootPubkey }))}
>
<UserAvatar userId={notification.pubkey} size="small" />
<MessageCircle size={24} className="text-blue-400" />
<ContentPreview className="truncate flex-1 w-0" event={notification} />
<div className="text-muted-foreground">
<FormattedTimestamp timestamp={notification.created_at} short />
<div
className={`w-1/4 px-4 sm:px-6 transition-transform duration-500 ${type === 'mentions' ? 'translate-x-full' : type === 'reactions' ? 'translate-x-[200%]' : type === 'zaps' ? 'translate-x-[300%]' : ''} `}
>
<div className="w-full h-1 bg-primary rounded-full" />
</div>
</div>
)

View file

@ -1,5 +1,6 @@
import { Event } from 'nostr-tools'
import { useTranslation } from 'react-i18next'
import ContentPreview from '../ContentPreview'
import { SimpleUserAvatar } from '../UserAvatar'
export default function Title({ parentEvent }: { parentEvent?: Event }) {
@ -9,7 +10,7 @@ export default function Title({ parentEvent }: { parentEvent?: Event }) {
<div className="flex gap-2 items-center w-full">
<div className="shrink-0">{t('Reply to')}</div>
<SimpleUserAvatar userId={parentEvent.pubkey} size="tiny" />
<div className="flex-1 w-0 truncate">{parentEvent.content}</div>
<ContentPreview className="flex-1 w-0 truncate h-5" event={parentEvent} />
</div>
) : (
t('New Note')

View file

@ -1,5 +1,4 @@
import { generateImageByPubkey } from '@/lib/pubkey'
import { cn } from '@/lib/utils'
import { useEffect, useMemo, useState } from 'react'
import Image from '../Image'
@ -27,7 +26,7 @@ export default function ProfileBanner({
<Image
image={{ url: bannerUrl }}
alt={`${pubkey} banner`}
className={cn('rounded-lg', className)}
className={className}
onError={() => setBannerUrl(defaultBanner)}
/>
)

View file

@ -22,7 +22,7 @@ export default function ProfileOptions({ pubkey }: { pubkey: string }) {
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="secondary" size="icon" className="rounded-full">
<Ellipsis className="text-muted-foreground hover:text-foreground cursor-pointer" />
<Ellipsis />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent collisionPadding={8}>

View file

@ -0,0 +1,24 @@
import { Button } from '@/components/ui/button'
import { useNostr } from '@/providers/NostrProvider'
import { Zap } from 'lucide-react'
import { useState } from 'react'
import ZapDialog from '../ZapDialog'
export default function ProfileZapButton({ pubkey }: { pubkey: string }) {
const { checkLogin } = useNostr()
const [open, setOpen] = useState(false)
return (
<>
<Button
variant="secondary"
size="icon"
className="rounded-full"
onClick={() => checkLogin(() => setOpen(true))}
>
<Zap className="text-yellow-400" />
</Button>
<ZapDialog open={open} setOpen={setOpen} pubkey={pubkey} />
</>
)
}

View file

@ -4,13 +4,14 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { toProfile } from '@/lib/link'
import { toProfile, toWallet } from '@/lib/link'
import { formatPubkey, generateImageByPubkey } from '@/lib/pubkey'
import { useSecondaryPage } from '@/PageManager'
import { useNostr } from '@/providers/NostrProvider'
import { LogIn } from 'lucide-react'
import { ArrowDownUp, LogIn, LogOut, UserRound, Wallet } from 'lucide-react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import LoginDialog from '../LoginDialog'
@ -57,15 +58,26 @@ function ProfileButton() {
</div>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem onClick={() => push(toProfile(pubkey))}>{t('Profile')}</DropdownMenuItem>
<DropdownMenuContent className="w-56" side="top">
<DropdownMenuItem onClick={() => push(toProfile(pubkey))}>
<UserRound />
{t('Profile')}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => push(toWallet())}>
<Wallet />
{t('Wallet')}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setLoginDialogOpen(true)}>
<ArrowDownUp />
{t('Switch account')}
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => setLogoutDialogOpen(true)}
>
<LogOut />
{t('Logout')}
</DropdownMenuItem>
</DropdownMenuContent>

View file

@ -1,9 +1,11 @@
import { usePrimaryPage } from '@/PageManager'
import { useNotification } from '@/providers/NotificationProvider'
import { Bell } from 'lucide-react'
import SidebarItem from './SidebarItem'
export default function NotificationsButton() {
const { navigate, current } = usePrimaryPage()
const { hasNewNotification } = useNotification()
return (
<SidebarItem
@ -11,7 +13,12 @@ export default function NotificationsButton() {
onClick={() => navigate('notifications')}
active={current === 'notifications'}
>
<Bell strokeWidth={3} />
<div className="relative">
<Bell strokeWidth={3} />
{hasNewNotification && (
<div className="absolute -top-1 -right-1 w-2 h-2 bg-primary rounded-full" />
)}
</div>
</SidebarItem>
)
}

View file

@ -12,7 +12,7 @@ export default function PrimaryPageSidebar() {
return (
<div className="w-16 xl:w-52 hidden sm:flex flex-col pb-2 pt-4 px-2 justify-between h-full shrink-0">
<div className="space-y-2">
<div className="px-2 mb-8 w-full">
<div className="px-3 xl:px-4 mb-6 w-full">
<Icon className="xl:hidden" />
<Logo className="max-xl:hidden" />
</div>

View file

@ -34,7 +34,9 @@ export default function UserAvatar({
)
if (!profile) {
return <Skeleton className={cn(UserAvatarSizeCnMap[size], 'rounded-full', className)} />
return (
<Skeleton className={cn('shrink-0', UserAvatarSizeCnMap[size], 'rounded-full', className)} />
)
}
const { avatar, pubkey } = profile
@ -42,7 +44,7 @@ export default function UserAvatar({
<HoverCard>
<HoverCardTrigger>
<SecondaryPageLink to={toProfile(pubkey)} onClick={(e) => e.stopPropagation()}>
<Avatar className={cn(UserAvatarSizeCnMap[size], className)}>
<Avatar className={cn('shrink-0', UserAvatarSizeCnMap[size], className)}>
<AvatarImage src={avatar} className="object-cover object-center" />
<AvatarFallback>
<img src={defaultAvatar} alt={pubkey} />
@ -64,7 +66,7 @@ export function SimpleUserAvatar({
onClick
}: {
userId: string
size?: 'large' | 'big' | 'normal' | 'small' | 'tiny'
size?: 'large' | 'big' | 'normal' | 'small' | 'xSmall' | 'tiny'
className?: string
onClick?: (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => void
}) {
@ -75,12 +77,14 @@ export function SimpleUserAvatar({
)
if (!profile) {
return <Skeleton className={cn(UserAvatarSizeCnMap[size], 'rounded-full', className)} />
return (
<Skeleton className={cn('shrink-0', UserAvatarSizeCnMap[size], 'rounded-full', className)} />
)
}
const { avatar, pubkey } = profile
return (
<Avatar className={cn(UserAvatarSizeCnMap[size], className)} onClick={onClick}>
<Avatar className={cn('shrink-0', UserAvatarSizeCnMap[size], className)} onClick={onClick}>
<AvatarImage src={avatar} className="object-cover object-center" />
<AvatarFallback>
<img src={defaultAvatar} alt={pubkey} />

View file

@ -16,9 +16,9 @@ export default function VideoPlayer({
<div className="relative">
<video
controls
preload="none"
className={cn('rounded-lg', size === 'small' ? 'max-h-[20vh]' : 'max-h-[50vh]', className)}
className={cn('rounded-lg', size === 'small' ? 'h-[15vh]' : 'h-[30vh]', className)}
src={src}
onClick={(e) => e.stopPropagation()}
/>
{isNsfw && <NsfwOverlay className="rounded-lg" />}
</div>

View file

@ -0,0 +1,162 @@
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { useToast } from '@/hooks'
import { useNostr } from '@/providers/NostrProvider'
import { useNoteStats } from '@/providers/NoteStatsProvider'
import { useZap } from '@/providers/ZapProvider'
import lightning from '@/services/lightning.service'
import { Loader } from 'lucide-react'
import { Dispatch, SetStateAction, useState } from 'react'
import { useTranslation } from 'react-i18next'
import UserAvatar from '../UserAvatar'
import Username from '../Username'
export default function ZapDialog({
open,
setOpen,
pubkey,
eventId,
defaultAmount
}: {
open: boolean
setOpen: Dispatch<SetStateAction<boolean>>
pubkey: string
eventId?: string
defaultAmount?: number
}) {
const { t } = useTranslation()
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex gap-2 items-center">
<div className="shrink-0">{t('Zap to')}</div>
<UserAvatar size="small" userId={pubkey} />
<Username userId={pubkey} className="truncate flex-1 w-0 text-start h-5" />
</DialogTitle>
</DialogHeader>
<ZapDialogContent
open={open}
setOpen={setOpen}
recipient={pubkey}
eventId={eventId}
defaultAmount={defaultAmount}
/>
</DialogContent>
</Dialog>
)
}
function ZapDialogContent({
setOpen,
recipient,
eventId,
defaultAmount
}: {
open: boolean
setOpen: Dispatch<SetStateAction<boolean>>
recipient: string
eventId?: string
defaultAmount?: number
}) {
const { t } = useTranslation()
const { toast } = useToast()
const { pubkey } = useNostr()
const { defaultZapSats, defaultZapComment } = useZap()
const { addZap } = useNoteStats()
const [sats, setSats] = useState(defaultAmount ?? defaultZapSats)
const [comment, setComment] = useState(defaultZapComment)
const [zapping, setZapping] = useState(false)
const handleZap = async () => {
try {
if (!pubkey) {
throw new Error('You need to be logged in to zap')
}
setZapping(true)
const { invoice } = await lightning.zap(pubkey, recipient, sats, comment, eventId, () =>
setOpen(false)
)
if (eventId) {
addZap(eventId, invoice, sats, comment)
}
} catch (error) {
toast({
title: t('Zap failed'),
description: (error as Error).message,
variant: 'destructive'
})
} finally {
setZapping(false)
}
}
return (
<>
{/* Sats slider or input */}
<div className="flex flex-col items-center">
<div className="flex justify-center w-full">
<input
id="sats"
value={sats}
onChange={(e) => {
setSats((pre) => {
if (e.target.value === '') {
return 0
}
let num = parseInt(e.target.value, 10)
if (isNaN(num) || num < 0) {
num = pre
}
return num
})
}}
onFocus={(e) => {
requestAnimationFrame(() => {
const val = e.target.value
e.target.setSelectionRange(val.length, val.length)
})
}}
className="bg-transparent text-center w-full p-0 focus-visible:outline-none text-6xl font-bold"
/>
</div>
<Label htmlFor="sats">{t('Sats')}</Label>
</div>
{/* Preset sats buttons */}
<div className="grid grid-cols-6 gap-2">
{[
{ display: '21', val: 21 },
{ display: '66', val: 66 },
{ display: '210', val: 210 },
{ display: '666', val: 666 },
{ display: '1k', val: 1000 },
{ display: '2.1k', val: 2100 },
{ display: '6.6k', val: 6666 },
{ display: '10k', val: 10000 },
{ display: '21k', val: 21000 },
{ display: '66k', val: 66666 },
{ display: '100k', val: 100000 },
{ display: '210k', val: 210000 }
].map(({ display, val }) => (
<Button variant="secondary" key={val} onClick={() => setSats(val)}>
{display}
</Button>
))}
</div>
{/* Comment input */}
<div>
<Label htmlFor="comment">{t('zapComment')}</Label>
<Input id="comment" value={comment} onChange={(e) => setComment(e.target.value)} />
</div>
<Button onClick={handleZap}>
{zapping && <Loader className="animate-spin" />} {t('Zap n sats', { n: sats })}
</Button>
</>
)
}