style: adjust the style of NoteStats (#222)
This commit is contained in:
@@ -167,14 +167,16 @@ export default function Nip22ReplyNoteList({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={`text-sm text-center text-muted-foreground ${!loading ? 'hover:text-foreground cursor-pointer' : ''}`}
|
||||
onClick={loadMore}
|
||||
>
|
||||
{loading ? t('loading...') : until ? t('load more older replies') : null}
|
||||
</div>
|
||||
{replies.length > 0 && (loading || until) && <Separator className="my-2" />}
|
||||
<div className={cn('mb-4', className)}>
|
||||
{(loading || until) && (
|
||||
<div
|
||||
className={`text-sm text-center text-muted-foreground mt-2 ${!loading ? 'hover:text-foreground cursor-pointer' : ''}`}
|
||||
onClick={loadMore}
|
||||
>
|
||||
{loading ? t('loading...') : t('load more older replies')}
|
||||
</div>
|
||||
)}
|
||||
{replies.length > 0 && (loading || until) && <Separator className="mt-2" />}
|
||||
<div className={cn('mb-2', className)}>
|
||||
{replies.map((reply) => {
|
||||
const info = replyMap[reply.id]
|
||||
return (
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Event } from 'nostr-tools'
|
||||
import { useMemo } from 'react'
|
||||
import Content from '../Content'
|
||||
import { FormattedTimestamp } from '../FormattedTimestamp'
|
||||
import NoteOptions from '../NoteOptions'
|
||||
import NoteStats from '../NoteStats'
|
||||
import ParentNotePreview from '../ParentNotePreview'
|
||||
import UserAvatar from '../UserAvatar'
|
||||
@@ -36,25 +37,28 @@ export default function Note({
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="flex items-center space-x-2">
|
||||
<UserAvatar userId={event.pubkey} size={size === 'small' ? 'small' : 'normal'} />
|
||||
<div
|
||||
className={`flex-1 w-0 ${size === 'small' ? 'flex space-x-2 items-center overflow-hidden' : ''}`}
|
||||
>
|
||||
<div className="flex gap-2 items-center">
|
||||
<Username
|
||||
userId={event.pubkey}
|
||||
className={`font-semibold flex truncate ${size === 'small' ? 'text-sm' : ''}`}
|
||||
skeletonClassName={size === 'small' ? 'h-3' : 'h-4'}
|
||||
/>
|
||||
{usingClient && size === 'normal' && (
|
||||
<div className="text-xs text-muted-foreground shrink-0">using {usingClient}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground shrink-0">
|
||||
<FormattedTimestamp timestamp={event.created_at} />
|
||||
<div className="flex justify-between items-start gap-2">
|
||||
<div className="flex items-center space-x-2 flex-1">
|
||||
<UserAvatar userId={event.pubkey} size={size === 'small' ? 'small' : 'normal'} />
|
||||
<div
|
||||
className={`flex-1 w-0 ${size === 'small' ? 'flex space-x-2 items-center overflow-hidden' : ''}`}
|
||||
>
|
||||
<div className="flex gap-2 items-center">
|
||||
<Username
|
||||
userId={event.pubkey}
|
||||
className={`font-semibold flex truncate ${size === 'small' ? 'text-sm' : ''}`}
|
||||
skeletonClassName={size === 'small' ? 'h-3' : 'h-4'}
|
||||
/>
|
||||
{usingClient && size === 'normal' && (
|
||||
<div className="text-xs text-muted-foreground shrink-0">using {usingClient}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground shrink-0">
|
||||
<FormattedTimestamp timestamp={event.created_at} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{size === 'normal' && <NoteOptions event={event} className="shrink-0 [&_svg]:size-5" />}
|
||||
</div>
|
||||
{parentEventId && (
|
||||
<ParentNotePreview
|
||||
|
||||
152
src/components/NoteOptions/index.tsx
Normal file
152
src/components/NoteOptions/index.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Drawer, DrawerContent, DrawerOverlay } from '@/components/ui/drawer'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { getSharableEventId } from '@/lib/event'
|
||||
import { pubkeyToNpub } from '@/lib/pubkey'
|
||||
import { useMuteList } from '@/providers/MuteListProvider'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||
import { Bell, BellOff, Code, Copy, Ellipsis } from 'lucide-react'
|
||||
import { Event } from 'nostr-tools'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import RawEventDialog from './RawEventDialog'
|
||||
|
||||
export default function NoteOptions({ event, className }: { event: Event; className?: string }) {
|
||||
const { t } = useTranslation()
|
||||
const { isSmallScreen } = useScreenSize()
|
||||
const { pubkey } = useNostr()
|
||||
const [isRawEventDialogOpen, setIsRawEventDialogOpen] = useState(false)
|
||||
const [isDrawerOpen, setIsDrawerOpen] = useState(false)
|
||||
const { mutePubkey, unmutePubkey, mutePubkeys } = useMuteList()
|
||||
const isMuted = useMemo(() => mutePubkeys.includes(event.pubkey), [mutePubkeys, event])
|
||||
|
||||
const trigger = (
|
||||
<button
|
||||
className="flex items-center text-muted-foreground hover:text-foreground pl-3 h-full"
|
||||
onClick={() => setIsDrawerOpen(true)}
|
||||
>
|
||||
<Ellipsis />
|
||||
</button>
|
||||
)
|
||||
|
||||
const rawEventDialog = (
|
||||
<RawEventDialog
|
||||
event={event}
|
||||
isOpen={isRawEventDialogOpen}
|
||||
onClose={() => setIsRawEventDialogOpen(false)}
|
||||
/>
|
||||
)
|
||||
|
||||
if (isSmallScreen) {
|
||||
return (
|
||||
<div className={className} onClick={(e) => e.stopPropagation()}>
|
||||
{trigger}
|
||||
<Drawer open={isDrawerOpen} onOpenChange={setIsDrawerOpen}>
|
||||
<DrawerOverlay onClick={() => setIsDrawerOpen(false)} />
|
||||
<DrawerContent hideOverlay>
|
||||
<div className="py-2">
|
||||
<Button
|
||||
onClick={() => {
|
||||
setIsDrawerOpen(false)
|
||||
navigator.clipboard.writeText(getSharableEventId(event))
|
||||
}}
|
||||
className="w-full p-6 justify-start text-lg gap-4 [&_svg]:size-5"
|
||||
variant="ghost"
|
||||
>
|
||||
<Copy />
|
||||
{t('Copy event ID')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(pubkeyToNpub(event.pubkey) ?? '')
|
||||
setIsDrawerOpen(false)
|
||||
}}
|
||||
className="w-full p-6 justify-start text-lg gap-4 [&_svg]:size-5"
|
||||
variant="ghost"
|
||||
>
|
||||
<Copy />
|
||||
{t('Copy user ID')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setIsDrawerOpen(false)
|
||||
setIsRawEventDialogOpen(true)
|
||||
}}
|
||||
className="w-full p-6 justify-start text-lg gap-4 [&_svg]:size-5"
|
||||
variant="ghost"
|
||||
>
|
||||
<Code />
|
||||
{t('View raw event')}
|
||||
</Button>
|
||||
{pubkey && (
|
||||
<Button
|
||||
onClick={() => {
|
||||
setIsDrawerOpen(false)
|
||||
if (isMuted) {
|
||||
unmutePubkey(event.pubkey)
|
||||
} else {
|
||||
mutePubkey(event.pubkey)
|
||||
}
|
||||
}}
|
||||
className="w-full p-6 justify-start text-destructive text-lg gap-4 [&_svg]:size-5 focus:text-destructive"
|
||||
variant="ghost"
|
||||
>
|
||||
{isMuted ? <Bell /> : <BellOff />}
|
||||
{isMuted ? t('Unmute user') : t('Mute user')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
{rawEventDialog}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className} onClick={(e) => e.stopPropagation()}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
|
||||
<DropdownMenuContent collisionPadding={8} className="min-w-52">
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigator.clipboard.writeText(getSharableEventId(event))}
|
||||
>
|
||||
<Copy />
|
||||
{t('Copy event ID')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigator.clipboard.writeText(pubkeyToNpub(event.pubkey) ?? '')}
|
||||
>
|
||||
<Copy />
|
||||
{t('Copy user ID')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => setIsRawEventDialogOpen(true)}>
|
||||
<Code />
|
||||
{t('View raw event')}
|
||||
</DropdownMenuItem>
|
||||
{pubkey && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => (isMuted ? unmutePubkey(event.pubkey) : mutePubkey(event.pubkey))}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
{isMuted ? <Bell /> : <BellOff />}
|
||||
{isMuted ? t('Unmute user') : t('Mute user')}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{rawEventDialog}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -55,7 +55,7 @@ export default function LikeButton({ event }: { event: Event }) {
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'flex items-center enabled:hover:text-red-400 gap-1',
|
||||
'flex items-center enabled:hover:text-red-400 gap-1 px-3 h-full',
|
||||
hasLiked ? 'text-red-400' : 'text-muted-foreground'
|
||||
)}
|
||||
onClick={like}
|
||||
@@ -63,9 +63,9 @@ export default function LikeButton({ event }: { event: Event }) {
|
||||
title={t('Like')}
|
||||
>
|
||||
{liking ? (
|
||||
<Loader className="animate-spin" size={16} />
|
||||
<Loader className="animate-spin" />
|
||||
) : (
|
||||
<Heart size={16} className={hasLiked ? 'fill-red-400' : ''} />
|
||||
<Heart className={hasLiked ? 'fill-red-400' : ''} />
|
||||
)}
|
||||
{!!likeCount && <div className="text-sm">{formatCount(likeCount)}</div>}
|
||||
</button>
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { getSharableEventId } from '@/lib/event'
|
||||
import { pubkeyToNpub } from '@/lib/pubkey'
|
||||
import { useMuteList } from '@/providers/MuteListProvider'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import { Bell, BellOff, Code, Copy, Ellipsis } from 'lucide-react'
|
||||
import { Event } from 'nostr-tools'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import RawEventDialog from './RawEventDialog'
|
||||
|
||||
export default function NoteOptions({ event }: { event: Event }) {
|
||||
const { t } = useTranslation()
|
||||
const { pubkey } = useNostr()
|
||||
const [isRawEventDialogOpen, setIsRawEventDialogOpen] = useState(false)
|
||||
const { mutePubkey, unmutePubkey, mutePubkeys } = useMuteList()
|
||||
const isMuted = useMemo(() => mutePubkeys.includes(event.pubkey), [mutePubkeys, event])
|
||||
|
||||
return (
|
||||
<div className="h-4" onClick={(e) => e.stopPropagation()}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<Ellipsis
|
||||
size={16}
|
||||
className="text-muted-foreground hover:text-foreground cursor-pointer"
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent collisionPadding={8}>
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigator.clipboard.writeText(getSharableEventId(event))}
|
||||
>
|
||||
<Copy />
|
||||
{t('Copy event ID')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigator.clipboard.writeText(pubkeyToNpub(event.pubkey) ?? '')}
|
||||
>
|
||||
<Copy />
|
||||
{t('Copy user ID')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setIsRawEventDialogOpen(true)}>
|
||||
<Code />
|
||||
{t('View raw event')}
|
||||
</DropdownMenuItem>
|
||||
{pubkey && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => (isMuted ? unmutePubkey(event.pubkey) : mutePubkey(event.pubkey))}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
{isMuted ? <Bell /> : <BellOff />}
|
||||
{isMuted ? t('Unmute user') : t('Mute user')}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<RawEventDialog
|
||||
event={event}
|
||||
isOpen={isRawEventDialogOpen}
|
||||
onClose={() => setIsRawEventDialogOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -26,7 +26,7 @@ export default function ReplyButton({
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
className="flex gap-1 items-center text-muted-foreground enabled:hover:text-blue-400"
|
||||
className="flex gap-1 items-center text-muted-foreground enabled:hover:text-blue-400 pr-3 h-full"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
checkLogin(() => {
|
||||
@@ -35,7 +35,7 @@ export default function ReplyButton({
|
||||
}}
|
||||
title={t('Reply')}
|
||||
>
|
||||
<MessageCircle size={16} />
|
||||
<MessageCircle />
|
||||
{variant !== 'reply' && !!replyCount && (
|
||||
<div className="text-sm">{formatCount(replyCount)}</div>
|
||||
)}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Drawer, DrawerContent, DrawerOverlay } from '@/components/ui/drawer'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -9,6 +11,7 @@ import { getSharableEventId } from '@/lib/event'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import { useNoteStats } from '@/providers/NoteStatsProvider'
|
||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||
import client from '@/services/client.service'
|
||||
import { Loader, PencilLine, Repeat } from 'lucide-react'
|
||||
import { Event } from 'nostr-tools'
|
||||
@@ -19,10 +22,12 @@ import { formatCount } from './utils'
|
||||
|
||||
export default function RepostButton({ event }: { event: Event }) {
|
||||
const { t } = useTranslation()
|
||||
const { isSmallScreen } = useScreenSize()
|
||||
const { publish, checkLogin, pubkey } = useNostr()
|
||||
const { noteStatsMap, updateNoteStatsByEvents, fetchNoteStats } = useNoteStats()
|
||||
const [reposting, setReposting] = useState(false)
|
||||
const [isPostDialogOpen, setIsPostDialogOpen] = useState(false)
|
||||
const [isDrawerOpen, setIsDrawerOpen] = useState(false)
|
||||
const { repostCount, hasReposted } = useMemo(() => {
|
||||
const stats = noteStatsMap.get(event.id) || {}
|
||||
return {
|
||||
@@ -62,22 +67,74 @@ export default function RepostButton({ event }: { event: Event }) {
|
||||
})
|
||||
}
|
||||
|
||||
const trigger = (
|
||||
<button
|
||||
className={cn(
|
||||
'flex gap-1 items-center enabled:hover:text-lime-500 px-3 h-full',
|
||||
hasReposted ? 'text-lime-500' : 'text-muted-foreground'
|
||||
)}
|
||||
title={t('Repost')}
|
||||
onClick={() => {
|
||||
if (isSmallScreen) {
|
||||
setIsDrawerOpen(true)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{reposting ? <Loader className="animate-spin" /> : <Repeat />}
|
||||
{!!repostCount && <div className="text-sm">{formatCount(repostCount)}</div>}
|
||||
</button>
|
||||
)
|
||||
|
||||
const postEditor = (
|
||||
<PostEditor
|
||||
open={isPostDialogOpen}
|
||||
setOpen={setIsPostDialogOpen}
|
||||
defaultContent={'\nnostr:' + getSharableEventId(event)}
|
||||
/>
|
||||
)
|
||||
|
||||
if (isSmallScreen) {
|
||||
return (
|
||||
<>
|
||||
{trigger}
|
||||
<Drawer open={isDrawerOpen} onOpenChange={setIsDrawerOpen}>
|
||||
<DrawerOverlay onClick={() => setIsDrawerOpen(false)} />
|
||||
<DrawerContent hideOverlay>
|
||||
<div className="py-2">
|
||||
<Button
|
||||
onClick={repost}
|
||||
disabled={!canRepost}
|
||||
className="w-full p-6 justify-start text-lg gap-4 [&_svg]:size-5"
|
||||
variant="ghost"
|
||||
>
|
||||
<Repeat /> {t('Repost')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
checkLogin(() => {
|
||||
setIsDrawerOpen(false)
|
||||
setIsPostDialogOpen(true)
|
||||
})
|
||||
}}
|
||||
className="w-full p-6 justify-start text-lg gap-4 [&_svg]:size-5"
|
||||
variant="ghost"
|
||||
>
|
||||
<PencilLine /> {t('Quote')}
|
||||
</Button>
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
{postEditor}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
'flex gap-1 items-center enabled:hover:text-lime-500',
|
||||
hasReposted ? 'text-lime-500' : 'text-muted-foreground'
|
||||
)}
|
||||
title={t('Repost')}
|
||||
>
|
||||
{reposting ? <Loader className="animate-spin" size={16} /> : <Repeat size={16} />}
|
||||
{!!repostCount && <div className="text-sm">{formatCount(repostCount)}</div>}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="min-w-44">
|
||||
<DropdownMenuItem onClick={repost} disabled={!canRepost}>
|
||||
<Repeat /> {t('Repost')}
|
||||
</DropdownMenuItem>
|
||||
@@ -93,11 +150,7 @@ export default function RepostButton({ event }: { event: Event }) {
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<PostEditor
|
||||
open={isPostDialogOpen}
|
||||
setOpen={setIsPostDialogOpen}
|
||||
defaultContent={'\nnostr:' + getSharableEventId(event)}
|
||||
/>
|
||||
{postEditor}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useSecondaryPage } from '@/PageManager'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Drawer, DrawerContent, DrawerOverlay } from '@/components/ui/drawer'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -9,39 +11,78 @@ import {
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { toRelay } from '@/lib/link'
|
||||
import { simplifyUrl } from '@/lib/url'
|
||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||
import client from '@/services/client.service'
|
||||
import { Server } from 'lucide-react'
|
||||
import { Event } from 'nostr-tools'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import RelayIcon from '../RelayIcon'
|
||||
|
||||
export default function SeenOnButton({ event }: { event: Event }) {
|
||||
const { t } = useTranslation()
|
||||
const { isSmallScreen } = useScreenSize()
|
||||
const { push } = useSecondaryPage()
|
||||
const [relays, setRelays] = useState<string[]>([])
|
||||
const [isDrawerOpen, setIsDrawerOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const seenOn = client.getSeenEventRelayUrls(event.id)
|
||||
setRelays(seenOn)
|
||||
}, [])
|
||||
|
||||
const trigger = (
|
||||
<button
|
||||
className="flex gap-1 items-center text-muted-foreground enabled:hover:text-primary pl-3 h-full"
|
||||
title={t('Seen on')}
|
||||
disabled={relays.length === 0}
|
||||
onClick={() => {
|
||||
if (isSmallScreen) {
|
||||
setIsDrawerOpen(true)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Server />
|
||||
{relays.length > 0 && <div className="text-sm">{relays.length}</div>}
|
||||
</button>
|
||||
)
|
||||
|
||||
if (isSmallScreen) {
|
||||
return (
|
||||
<>
|
||||
{trigger}
|
||||
<Drawer open={isDrawerOpen} onOpenChange={setIsDrawerOpen}>
|
||||
<DrawerOverlay onClick={() => setIsDrawerOpen(false)} />
|
||||
<DrawerContent hideOverlay>
|
||||
<div className="py-2">
|
||||
{relays.map((relay) => (
|
||||
<Button
|
||||
className="w-full p-6 justify-start text-lg gap-4"
|
||||
variant="ghost"
|
||||
key={relay}
|
||||
onClick={() => {
|
||||
setIsDrawerOpen(false)
|
||||
push(toRelay(relay))
|
||||
}}
|
||||
>
|
||||
<RelayIcon url={relay} /> {simplifyUrl(relay)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
className="flex gap-1 items-center text-muted-foreground enabled:hover:text-primary"
|
||||
title={t('Seen on')}
|
||||
disabled={relays.length === 0}
|
||||
>
|
||||
<Server size={16} />
|
||||
{relays.length > 0 && <div className="text-sm">{relays.length}</div>}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
|
||||
<DropdownMenuContent collisionPadding={8}>
|
||||
<DropdownMenuLabel>{t('Seen on')}</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{relays.map((relay) => (
|
||||
<DropdownMenuItem key={relay} onClick={() => push(toRelay(relay))}>
|
||||
<DropdownMenuItem key={relay} onClick={() => push(toRelay(relay))} className="min-w-52">
|
||||
<RelayIcon url={relay} />
|
||||
{simplifyUrl(relay)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
|
||||
@@ -18,6 +18,7 @@ export default function ZapButton({ event }: { event: Event }) {
|
||||
const { checkLogin, pubkey } = useNostr()
|
||||
const { noteStatsMap, addZap } = useNoteStats()
|
||||
const { defaultZapSats, defaultZapComment, quickZap } = useZap()
|
||||
const [touchStart, setTouchStart] = useState<{ x: number; y: number } | null>(null)
|
||||
const [openZapDialog, setOpenZapDialog] = useState(false)
|
||||
const [zapping, setZapping] = useState(false)
|
||||
const { zapAmount, hasZapped } = useMemo(() => {
|
||||
@@ -71,6 +72,11 @@ export default function ZapButton({ event }: { event: Event }) {
|
||||
e.preventDefault()
|
||||
isLongPressRef.current = false
|
||||
|
||||
if ('touches' in e) {
|
||||
const touch = e.touches[0]
|
||||
setTouchStart({ x: touch.clientX, y: touch.clientY })
|
||||
}
|
||||
|
||||
if (quickZap) {
|
||||
timerRef.current = setTimeout(() => {
|
||||
isLongPressRef.current = true
|
||||
@@ -89,6 +95,15 @@ export default function ZapButton({ event }: { event: Event }) {
|
||||
clearTimeout(timerRef.current)
|
||||
}
|
||||
|
||||
if ('touches' in e) {
|
||||
setTouchStart(null)
|
||||
if (!touchStart) return
|
||||
const touch = e.changedTouches[0]
|
||||
const diffX = Math.abs(touch.clientX - touchStart.x)
|
||||
const diffY = Math.abs(touch.clientY - touchStart.y)
|
||||
if (diffX > 10 || diffY > 10) return
|
||||
}
|
||||
|
||||
if (!quickZap) {
|
||||
checkLogin(() => {
|
||||
setOpenZapDialog(true)
|
||||
@@ -110,7 +125,7 @@ export default function ZapButton({ event }: { event: Event }) {
|
||||
<>
|
||||
<button
|
||||
className={cn(
|
||||
'flex items-center enabled:hover:text-yellow-400 gap-1 select-none',
|
||||
'flex items-center enabled:hover:text-yellow-400 gap-1 select-none px-3 h-full',
|
||||
hasZapped ? 'text-yellow-400' : 'text-muted-foreground'
|
||||
)}
|
||||
title={t('Zap')}
|
||||
@@ -121,9 +136,9 @@ export default function ZapButton({ event }: { event: Event }) {
|
||||
onTouchEnd={handleClickEnd}
|
||||
>
|
||||
{zapping ? (
|
||||
<Loader className="animate-spin" size={16} />
|
||||
<Loader className="animate-spin" />
|
||||
) : (
|
||||
<Zap size={16} className={hasZapped ? 'fill-yellow-400' : ''} />
|
||||
<Zap className={hasZapped ? 'fill-yellow-400' : ''} />
|
||||
)}
|
||||
{!!zapAmount && <div className="text-sm">{formatAmount(zapAmount)}</div>}
|
||||
</button>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useNoteStats } from '@/providers/NoteStatsProvider'
|
||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||
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'
|
||||
@@ -13,14 +13,19 @@ import ZapButton from './ZapButton'
|
||||
export default function NoteStats({
|
||||
event,
|
||||
className,
|
||||
classNames,
|
||||
fetchIfNotExisting = false,
|
||||
variant = 'note'
|
||||
}: {
|
||||
event: Event
|
||||
className?: string
|
||||
classNames?: {
|
||||
buttonBar?: string
|
||||
}
|
||||
fetchIfNotExisting?: boolean
|
||||
variant?: 'note' | 'reply'
|
||||
}) {
|
||||
const { isSmallScreen } = useScreenSize()
|
||||
const { fetchNoteStats } = useNoteStats()
|
||||
|
||||
useEffect(() => {
|
||||
@@ -28,19 +33,39 @@ export default function NoteStats({
|
||||
fetchNoteStats(event)
|
||||
}, [event, fetchIfNotExisting])
|
||||
|
||||
if (isSmallScreen) {
|
||||
return (
|
||||
<div className={cn('select-none', className)}>
|
||||
<TopZaps event={event} />
|
||||
<div
|
||||
className={cn(
|
||||
'flex justify-between items-center h-5 [&_svg]:size-5',
|
||||
classNames?.buttonBar
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<ReplyButton event={event} variant={variant} />
|
||||
<RepostButton event={event} />
|
||||
<LikeButton event={event} />
|
||||
<ZapButton event={event} />
|
||||
<SeenOnButton event={event} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<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()}>
|
||||
<div className="flex justify-between h-5 [&_svg]:size-4">
|
||||
<div className="flex 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()}>
|
||||
<div className="flex items-center" onClick={(e) => e.stopPropagation()}>
|
||||
<SeenOnButton event={event} />
|
||||
<NoteOptions event={event} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ import NoteStats from '../NoteStats'
|
||||
import UserAvatar from '../UserAvatar'
|
||||
import Username from '../Username'
|
||||
import PictureContent from '../PictureContent'
|
||||
import NoteOptions from '../NoteOptions'
|
||||
|
||||
export default function PictureNote({
|
||||
event,
|
||||
@@ -22,23 +23,26 @@ export default function PictureNote({
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="px-4 flex items-center space-x-2">
|
||||
<UserAvatar userId={event.pubkey} />
|
||||
<div className="flex-1 w-0">
|
||||
<div className="flex gap-2 items-center">
|
||||
<Username
|
||||
userId={event.pubkey}
|
||||
className="font-semibold flex"
|
||||
skeletonClassName="h-4"
|
||||
/>
|
||||
{usingClient && (
|
||||
<div className="text-xs text-muted-foreground truncate">using {usingClient}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground line-clamp-1">
|
||||
<FormattedTimestamp timestamp={event.created_at} />
|
||||
<div className="px-4 flex justify-between items-start gap-2">
|
||||
<div className="flex items-center space-x-2 flex-1">
|
||||
<UserAvatar userId={event.pubkey} />
|
||||
<div className="flex-1 w-0">
|
||||
<div className="flex gap-2 items-center">
|
||||
<Username
|
||||
userId={event.pubkey}
|
||||
className="font-semibold flex truncate"
|
||||
skeletonClassName="h-4"
|
||||
/>
|
||||
{usingClient && (
|
||||
<div className="text-xs text-muted-foreground truncate">using {usingClient}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground line-clamp-1">
|
||||
<FormattedTimestamp timestamp={event.created_at} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<NoteOptions event={event} className="shrink-0 [&_svg]:size-5" />
|
||||
</div>
|
||||
<PictureContent className="mt-2" event={event} />
|
||||
{!hideStats && (
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Content from '../Content'
|
||||
import { FormattedTimestamp } from '../FormattedTimestamp'
|
||||
import NoteOptions from '../NoteOptions'
|
||||
import NoteStats from '../NoteStats'
|
||||
import ParentNotePreview from '../ParentNotePreview'
|
||||
import UserAvatar from '../UserAvatar'
|
||||
@@ -31,19 +32,22 @@ export default function ReplyNote({
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex space-x-2 items-start rounded-lg p-2 transition-colors duration-500 ${highlight ? 'bg-highlight/50' : ''}`}
|
||||
className={`flex space-x-2 items-start px-4 py-3 border-b transition-colors duration-500 ${highlight ? 'bg-highlight/50' : ''}`}
|
||||
>
|
||||
<UserAvatar userId={event.pubkey} size="small" className="shrink-0" />
|
||||
<div className="w-full overflow-hidden">
|
||||
<div className="flex gap-2 items-center">
|
||||
<Username
|
||||
userId={event.pubkey}
|
||||
className="text-sm font-semibold text-muted-foreground hover:text-foreground truncate"
|
||||
skeletonClassName="h-3"
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground shrink-0">
|
||||
<FormattedTimestamp timestamp={event.created_at} />
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex gap-2 items-center flex-1">
|
||||
<Username
|
||||
userId={event.pubkey}
|
||||
className="text-sm font-semibold text-muted-foreground hover:text-foreground truncate"
|
||||
skeletonClassName="h-3"
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground shrink-0">
|
||||
<FormattedTimestamp timestamp={event.created_at} />
|
||||
</div>
|
||||
</div>
|
||||
<NoteOptions event={event} className="shrink-0 [&_svg]:size-5" />
|
||||
</div>
|
||||
{parentEvent && (
|
||||
<ParentNotePreview
|
||||
@@ -58,7 +62,12 @@ export default function ReplyNote({
|
||||
{show ? (
|
||||
<>
|
||||
<Content className="mt-1" event={event} size="small" />
|
||||
<NoteStats className="mt-2" event={event} variant="reply" />
|
||||
<NoteStats
|
||||
className="mt-2"
|
||||
classNames={{ buttonBar: 'justify-start gap-1' }}
|
||||
event={event}
|
||||
variant="reply"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
|
||||
@@ -178,17 +178,19 @@ export default function ReplyNoteList({ event, className }: { event: NEvent; cla
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={`text-sm text-center text-muted-foreground ${!loading ? 'hover:text-foreground cursor-pointer' : ''}`}
|
||||
onClick={loadMore}
|
||||
>
|
||||
{loading ? t('loading...') : until ? t('load more older replies') : null}
|
||||
</div>
|
||||
{replies.length === 0 && !loading && !until && (
|
||||
<div className="text-sm text-center text-muted-foreground">{t('no replies')}</div>
|
||||
{(loading || until) && (
|
||||
<div
|
||||
className={`text-sm text-center text-muted-foreground mt-2 ${!loading ? 'hover:text-foreground cursor-pointer' : ''}`}
|
||||
onClick={loadMore}
|
||||
>
|
||||
{loading ? t('loading...') : t('load more older replies')}
|
||||
</div>
|
||||
)}
|
||||
{replies.length > 0 && (loading || until) && <Separator className="my-2" />}
|
||||
<div className={cn('mb-4', className)}>
|
||||
{replies.length === 0 && !loading && !until && (
|
||||
<div className="text-sm mt-2 text-center text-muted-foreground">{t('no replies')}</div>
|
||||
)}
|
||||
{replies.length > 0 && (loading || until) && <Separator className="mt-2" />}
|
||||
<div className={cn('mb-2', className)}>
|
||||
{replies.map((reply) => {
|
||||
const info = replyMap[reply.id]
|
||||
return (
|
||||
|
||||
@@ -31,10 +31,10 @@ DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName
|
||||
|
||||
const DrawerContent = React.forwardRef<
|
||||
React.ElementRef<typeof DrawerPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content> & { hideOverlay?: boolean }
|
||||
>(({ className, children, hideOverlay = false, ...props }, ref) => (
|
||||
<DrawerPortal>
|
||||
<DrawerOverlay />
|
||||
{!hideOverlay && <DrawerOverlay />}
|
||||
<DrawerPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
|
||||
@@ -58,12 +58,8 @@ const NotePage = forwardRef(({ id, index }: { id?: string; index?: number }, ref
|
||||
return (
|
||||
<SecondaryPageLayout ref={ref} index={index} title={t('Note')} displayScrollToTopButton>
|
||||
<PictureNote key={`note-${event.id}`} event={event} fetchNoteStats />
|
||||
<Separator className="mb-2 mt-4" />
|
||||
<Nip22ReplyNoteList
|
||||
key={`nip22-reply-note-list-${event.id}`}
|
||||
event={event}
|
||||
className="px-2"
|
||||
/>
|
||||
<Separator className="mt-4" />
|
||||
<Nip22ReplyNoteList key={`nip22-reply-note-list-${event.id}`} event={event} />
|
||||
</SecondaryPageLayout>
|
||||
)
|
||||
}
|
||||
@@ -77,15 +73,11 @@ const NotePage = forwardRef(({ id, index }: { id?: string; index?: number }, ref
|
||||
<ParentNote key={`parent-note-${event.id}`} eventId={parentEventId} />
|
||||
<Note key={`note-${event.id}`} event={event} fetchNoteStats hideParentNotePreview />
|
||||
</div>
|
||||
<Separator className="mb-2 mt-4" />
|
||||
<Separator className="mt-4" />
|
||||
{event.kind === kinds.ShortTextNote ? (
|
||||
<ReplyNoteList key={`reply-note-list-${event.id}`} event={event} className="px-2" />
|
||||
<ReplyNoteList key={`reply-note-list-${event.id}`} event={event} />
|
||||
) : isPictureEvent(event) ? (
|
||||
<Nip22ReplyNoteList
|
||||
key={`nip22-reply-note-list-${event.id}`}
|
||||
event={event}
|
||||
className="px-2"
|
||||
/>
|
||||
<Nip22ReplyNoteList key={`nip22-reply-note-list-${event.id}`} event={event} />
|
||||
) : null}
|
||||
</SecondaryPageLayout>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user