Browse Source

more refactoring

imwald
Silberengel 1 month ago
parent
commit
9e549521bf
  1. 4
      src/components/NoteBoostBadges/index.tsx
  2. 9
      src/components/NoteInteractions/Tabs.tsx
  3. 14
      src/components/NoteInteractions/index.tsx
  4. 133
      src/components/NoteStats/Likes.tsx
  5. 98
      src/components/ReactionList/index.tsx
  6. 56
      src/components/ReplyNoteList/ZapReplyFeedRow.tsx
  7. 15
      src/components/ReplyNoteList/index.tsx
  8. 81
      src/components/RepostList/index.tsx
  9. 91
      src/components/ZapList/index.tsx
  10. 1
      src/i18n/locales/de.ts
  11. 1
      src/i18n/locales/en.ts
  12. 1
      src/pages/secondary/NotePage/index.tsx

4
src/components/NoteBoostBadges/index.tsx

@ -1,7 +1,7 @@
import { ExtendedKind } from '@/constants'
import { useNoteStatsById } from '@/hooks/useNoteStatsById' import { useNoteStatsById } from '@/hooks/useNoteStatsById'
import { shouldHideInteractions } from '@/lib/event-filtering' import { shouldHideInteractions } from '@/lib/event-filtering'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { ExtendedKind } from '@/constants'
import { useUserTrust } from '@/providers/UserTrustProvider' import { useUserTrust } from '@/providers/UserTrustProvider'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import { useMemo } from 'react' import { useMemo } from 'react'
@ -51,7 +51,7 @@ export default function NoteBoostBadges({ event, className }: { event: Event; cl
{overflow > 0 ? ( {overflow > 0 ? (
<span <span
className="-ml-1 rounded-full bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground ring-2 ring-background" className="-ml-1 rounded-full bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground ring-2 ring-background"
title={t('No more boosts')} title={t('n more boosts', { count: overflow })}
> >
+{overflow} +{overflow}
</span> </span>

9
src/components/NoteInteractions/Tabs.tsx

@ -2,11 +2,9 @@ import { cn } from '@/lib/utils'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useRef, useEffect, useState } from 'react' import { useRef, useEffect, useState } from 'react'
export type TTabValue = 'replies' | 'quotes' | 'reactions' | 'zaps' export type TTabValue = 'replies' | 'quotes'
const TABS = [ const TABS = [
{ value: 'replies', label: 'Replies' }, { value: 'replies', label: 'Replies' },
{ value: 'zaps', label: 'Zaps' },
{ value: 'reactions', label: 'Reactions' },
{ value: 'quotes', label: 'Quotes' } { value: 'quotes', label: 'Quotes' }
] as { value: TTabValue; label: string }[] ] as { value: TTabValue; label: string }[]
@ -25,10 +23,7 @@ export function Tabs({
const containerRef = useRef<HTMLDivElement | null>(null) const containerRef = useRef<HTMLDivElement | null>(null)
const [indicatorStyle, setIndicatorStyle] = useState({ width: 0, left: 0, top: 0 }) const [indicatorStyle, setIndicatorStyle] = useState({ width: 0, left: 0, top: 0 })
// Filter tabs based on hideBoostsAndQuotes const visibleTabs = hideQuotesForDiscussion ? TABS.filter((tab) => tab.value !== 'quotes') : TABS
const visibleTabs = hideBoostsAndQuotes
? TABS.filter((tab) => tab.value !== 'boosts' && tab.value !== 'quotes')
: TABS
useEffect(() => { useEffect(() => {
setTimeout(() => { setTimeout(() => {

14
src/components/NoteInteractions/index.tsx

@ -5,9 +5,7 @@ import { Event } from 'nostr-tools'
import { useState } from 'react' import { useState } from 'react'
import HideUntrustedContentButton from '../HideUntrustedContentButton' import HideUntrustedContentButton from '../HideUntrustedContentButton'
import QuoteList from '../QuoteList' import QuoteList from '../QuoteList'
import ReactionList from '../ReactionList'
import ReplyNoteList from '../ReplyNoteList' import ReplyNoteList from '../ReplyNoteList'
import ZapList from '../ZapList'
import { Tabs, TTabValue } from './Tabs' import { Tabs, TTabValue } from './Tabs'
import ReplySort, { ReplySortOption } from './ReplySort' import ReplySort, { ReplySortOption } from './ReplySort'
@ -36,16 +34,6 @@ export default function NoteInteractions({
if (isDiscussion) return null // Hide quotes for discussions if (isDiscussion) return null // Hide quotes for discussions
list = <QuoteList event={event} /> list = <QuoteList event={event} />
break break
case 'reactions':
list = <ReactionList event={event} />
break
case 'boosts':
if (isDiscussion) return null // Hide boosts for discussions
list = <RepostList event={event} />
break
case 'zaps':
list = <ZapList event={event} />
break
default: default:
break break
} }
@ -54,7 +42,7 @@ export default function NoteInteractions({
<> <>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex-1 w-0"> <div className="flex-1 w-0">
<Tabs selectedTab={type} onTabChange={setType} hideBoostsAndQuotes={isDiscussion} /> <Tabs selectedTab={type} onTabChange={setType} hideQuotesForDiscussion={isDiscussion} />
</div> </div>
<Separator orientation="vertical" className="h-6" /> <Separator orientation="vertical" className="h-6" />
{type === 'replies' && isDiscussion && ( {type === 'replies' && isDiscussion && (

133
src/components/NoteStats/Likes.tsx

@ -1,20 +1,25 @@
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card'
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area' import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'
import { ExtendedKind } from '@/constants'
import { useNoteStatsById } from '@/hooks/useNoteStatsById' import { useNoteStatsById } from '@/hooks/useNoteStatsById'
import { shouldHideInteractions } from '@/lib/event-filtering' import { shouldHideInteractions } from '@/lib/event-filtering'
import { createReactionDraftEvent } from '@/lib/draft-event' import { createReactionDraftEvent } from '@/lib/draft-event'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { useUserTrust } from '@/providers/UserTrustProvider'
import noteStatsService from '@/services/note-stats.service' import noteStatsService from '@/services/note-stats.service'
import { TEmoji } from '@/types' import { TEmoji } from '@/types'
import { Loader } from 'lucide-react' import { Loader } from 'lucide-react'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import { useMemo, useRef, useState } from 'react' import { useMemo, useRef, useState } from 'react'
import Emoji from '../Emoji' import Emoji from '../Emoji'
import Username from '../Username'
import logger from '@/lib/logger' import logger from '@/lib/logger'
export default function Likes({ event }: { event: Event }) { export default function Likes({ event }: { event: Event }) {
const inQuietMode = shouldHideInteractions(event) const inQuietMode = shouldHideInteractions(event)
const { pubkey, checkLogin, publish } = useNostr() const { pubkey, checkLogin, publish } = useNostr()
const { hideUntrustedInteractions, isUserTrusted } = useUserTrust()
const noteStats = useNoteStatsById(event.id) const noteStats = useNoteStatsById(event.id)
const [liking, setLiking] = useState<string | null>(null) const [liking, setLiking] = useState<string | null>(null)
const longPressTimerRef = useRef<NodeJS.Timeout | null>(null) const longPressTimerRef = useRef<NodeJS.Timeout | null>(null)
@ -22,9 +27,16 @@ export default function Likes({ event }: { event: Event }) {
const [isCompleted, setIsCompleted] = useState<string | null>(null) const [isCompleted, setIsCompleted] = useState<string | null>(null)
const likes = useMemo(() => { const likes = useMemo(() => {
const _likes = noteStats?.likes let _likes = noteStats?.likes
if (!_likes) return [] if (!_likes) return []
if (event.kind === ExtendedKind.DISCUSSION) {
_likes = _likes.filter((item) => item.emoji === '⬆' || item.emoji === '⬇')
}
if (hideUntrustedInteractions) {
_likes = _likes.filter((item) => isUserTrusted(item.pubkey))
}
const stats = new Map<string, { key: string; emoji: TEmoji | string; pubkeys: Set<string> }>() const stats = new Map<string, { key: string; emoji: TEmoji | string; pubkeys: Set<string> }>()
_likes.forEach((item) => { _likes.forEach((item) => {
// In quiet mode, normalize all emojis to "+" to prevent trolling with funny emojis // In quiet mode, normalize all emojis to "+" to prevent trolling with funny emojis
@ -42,8 +54,10 @@ export default function Likes({ event }: { event: Event }) {
stats.get(key)?.pubkeys.add(item.pubkey) stats.get(key)?.pubkeys.add(item.pubkey)
} }
}) })
return Array.from(stats.values()).sort((a, b) => b.pubkeys.size - a.pubkeys.size) return Array.from(stats.values())
}, [noteStats, event, inQuietMode]) .filter((g) => g.pubkeys.size > 0)
.sort((a, b) => b.pubkeys.size - a.pubkeys.size)
}, [noteStats, event, inQuietMode, hideUntrustedInteractions, isUserTrusted])
if (!likes.length) return null if (!likes.length) return null
@ -123,53 +137,78 @@ export default function Likes({ event }: { event: Event }) {
return ( return (
<ScrollArea className="pb-2 mb-1"> <ScrollArea className="pb-2 mb-1">
<div className="flex gap-1"> <div className="flex gap-1">
{likes.map(({ key, emoji, pubkeys }) => ( {likes.map(({ key, emoji, pubkeys }) => {
<div const contributorIds = Array.from(pubkeys).sort()
key={key} return (
className={cn( <HoverCard key={key} openDelay={250} closeDelay={50}>
'flex h-7 w-fit gap-2 px-2 rounded-full items-center border shrink-0 select-none relative overflow-hidden transition-all duration-200', <HoverCardTrigger asChild>
pubkey && pubkeys.has(pubkey)
? 'border-primary bg-primary/20 text-foreground cursor-not-allowed'
: 'bg-muted/80 text-muted-foreground cursor-pointer hover:bg-primary/40 hover:border-primary hover:text-foreground',
(isLongPressing === key || isCompleted === key) && 'border-primary bg-primary/20'
)}
onClick={(e) => e.stopPropagation()}
onMouseDown={() => handleMouseDown(key)}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseLeave}
onTouchStart={() => handleMouseDown(key)}
onTouchMove={handleTouchMove}
onTouchEnd={handleMouseUp}
onTouchCancel={handleMouseLeave}
>
{(isLongPressing === key || isCompleted === key) && (
<div className="absolute inset-0 rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-primary/40 via-primary/60 to-primary/80"
style={{
width: isCompleted === key ? '100%' : '0%',
animation:
isLongPressing === key ? 'progressFill 1000ms ease-out forwards' : 'none'
}}
/>
</div>
)}
<div className="relative z-10 flex items-center gap-2">
{liking === key ? (
<Loader className="animate-spin size-4" />
) : (
<div <div
style={{ className={cn(
animation: isCompleted === key ? 'shake 0.5s ease-in-out infinite' : undefined 'flex h-7 w-fit gap-2 px-2 rounded-full items-center border shrink-0 select-none relative overflow-hidden transition-all duration-200',
}} pubkey && pubkeys.has(pubkey)
? 'border-primary bg-primary/20 text-foreground cursor-not-allowed'
: 'bg-muted/80 text-muted-foreground cursor-pointer hover:bg-primary/40 hover:border-primary hover:text-foreground',
(isLongPressing === key || isCompleted === key) && 'border-primary bg-primary/20'
)}
onClick={(e) => e.stopPropagation()}
onMouseDown={() => handleMouseDown(key)}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseLeave}
onTouchStart={() => handleMouseDown(key)}
onTouchMove={handleTouchMove}
onTouchEnd={handleMouseUp}
onTouchCancel={handleMouseLeave}
> >
<Emoji emoji={emoji} classNames={{ img: 'size-4' }} /> {(isLongPressing === key || isCompleted === key) && (
<div className="absolute inset-0 rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-primary/40 via-primary/60 to-primary/80"
style={{
width: isCompleted === key ? '100%' : '0%',
animation:
isLongPressing === key ? 'progressFill 1000ms ease-out forwards' : 'none'
}}
/>
</div>
)}
<div className="relative z-10 flex items-center gap-2">
{liking === key ? (
<Loader className="animate-spin size-4" />
) : (
<div
style={{
animation: isCompleted === key ? 'shake 0.5s ease-in-out infinite' : undefined
}}
>
<Emoji emoji={emoji} classNames={{ img: 'size-4' }} />
</div>
)}
<div className="text-sm">{pubkeys.size}</div>
</div>
</div> </div>
)} </HoverCardTrigger>
<div className="text-sm">{pubkeys.size}</div> <HoverCardContent
</div> className="w-72 max-h-64 p-0 overflow-hidden"
</div> side="top"
))} align="center"
onClick={(e) => e.stopPropagation()}
>
<ScrollArea className="max-h-60">
<div className="flex flex-col gap-1.5 p-3 pr-4">
{contributorIds.map((userId) => (
<Username
key={userId}
userId={userId}
className="text-sm truncate text-foreground"
skeletonClassName="h-4"
/>
))}
</div>
</ScrollArea>
</HoverCardContent>
</HoverCard>
)
})}
</div> </div>
<ScrollBar orientation="horizontal" /> <ScrollBar orientation="horizontal" />
</ScrollArea> </ScrollArea>

98
src/components/ReactionList/index.tsx

@ -1,98 +0,0 @@
import { useSecondaryPage } from '@/PageManager'
import { ExtendedKind } from '@/constants'
import { useNoteStatsById } from '@/hooks/useNoteStatsById'
import { shouldHideInteractions } from '@/lib/event-filtering'
import { toProfile } from '@/lib/link'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { useUserTrust } from '@/providers/UserTrustProvider'
import { Event } from 'nostr-tools'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Emoji from '../Emoji'
import { FormattedTimestamp } from '../FormattedTimestamp'
import Nip05 from '../Nip05'
import UserAvatar from '../UserAvatar'
import Username from '../Username'
const SHOW_COUNT = 20
export default function ReactionList({ event }: { event: Event }) {
const inQuietMode = shouldHideInteractions(event)
const { t } = useTranslation()
const { push } = useSecondaryPage()
const { isSmallScreen } = useScreenSize()
const { hideUntrustedInteractions, isUserTrusted } = useUserTrust()
const noteStats = useNoteStatsById(event.id)
const filteredLikes = useMemo(() => {
let likes = noteStats?.likes ?? []
// For discussion events (kind 11), only show up/down arrow reactions
if (event.kind === ExtendedKind.DISCUSSION) {
likes = likes.filter(like => like.emoji === '⬆' || like.emoji === '⬇')
}
return likes
.filter((like) => !hideUntrustedInteractions || isUserTrusted(like.pubkey))
.sort((a, b) => b.created_at - a.created_at)
}, [noteStats, event.id, hideUntrustedInteractions, isUserTrusted, event.kind])
const [showCount, setShowCount] = useState(SHOW_COUNT)
const bottomRef = useRef<HTMLDivElement | null>(null)
useEffect(() => {
if (!bottomRef.current || filteredLikes.length <= showCount) return
const obs = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) setShowCount((c) => c + SHOW_COUNT)
},
{ rootMargin: '10px', threshold: 0.1 }
)
obs.observe(bottomRef.current)
return () => obs.disconnect()
}, [filteredLikes.length, showCount])
return (
<div className="min-h-[80vh]">
{filteredLikes.slice(0, showCount).map((like) => (
<div
key={like.id}
className="px-4 py-3 border-b transition-colors clickable flex items-center gap-3"
onClick={() => push(toProfile(like.pubkey))}
>
<div className="w-6 flex flex-col items-center">
<Emoji
emoji={inQuietMode ? '+' : like.emoji}
classNames={{
text: 'text-xl'
}}
/>
</div>
<UserAvatar userId={like.pubkey} size="medium" className="shrink-0" />
<div className="flex-1 w-0">
<Username
userId={like.pubkey}
className="text-sm font-semibold text-muted-foreground hover:text-foreground max-w-fit truncate"
skeletonClassName="h-3"
/>
<div className="flex items-center gap-1 text-sm text-muted-foreground">
<Nip05 pubkey={like.pubkey} append="·" />
<FormattedTimestamp
timestamp={like.created_at}
className="shrink-0"
short={isSmallScreen}
/>
</div>
</div>
</div>
))}
<div ref={bottomRef} />
<div className="text-sm mt-2 text-center text-muted-foreground">
{filteredLikes.length > 0 ? t('No more reactions') : t('No reactions yet')}
</div>
</div>
)
}

56
src/components/ReplyNoteList/ZapReplyFeedRow.tsx

@ -0,0 +1,56 @@
import Content from '@/components/Content'
import { FormattedTimestamp } from '@/components/FormattedTimestamp'
import Nip05 from '@/components/Nip05'
import UserAvatar from '@/components/UserAvatar'
import Username from '@/components/Username'
import { formatAmount } from '@/lib/lightning'
import { toProfile } from '@/lib/link'
import { useSecondaryPage } from '@/PageManager'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import type { TNoteStats } from '@/services/note-stats.service'
import { Zap } from 'lucide-react'
import { useTranslation } from 'react-i18next'
export type TZapFeedEntry = TNoteStats['zaps'][number]
export default function ZapReplyFeedRow({ zap }: { zap: TZapFeedEntry }) {
const { t } = useTranslation()
const { push } = useSecondaryPage()
const { isSmallScreen } = useScreenSize()
return (
<div
className="clickable pb-3 border-b transition-colors duration-500"
onClick={() => push(toProfile(zap.pubkey))}
>
<div className="flex items-start space-x-2 px-4 pt-3">
<UserAvatar userId={zap.pubkey} size="medium" className="mt-0.5 shrink-0" />
<div className="min-w-0 w-full overflow-hidden">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-1.5">
<Zap className="size-4 shrink-0 text-primary" strokeWidth={2.5} aria-hidden />
<Username
userId={zap.pubkey}
className="truncate text-sm font-semibold text-muted-foreground hover:text-foreground"
skeletonClassName="h-3"
/>
</div>
<div className="mt-0.5 flex flex-wrap items-center gap-1 text-sm text-muted-foreground">
<span className="font-semibold tabular-nums text-foreground">
{formatAmount(zap.amount)} {t('sats')}
</span>
<span className="text-muted-foreground/80" aria-hidden>
·
</span>
<Nip05 pubkey={zap.pubkey} append="·" />
<FormattedTimestamp timestamp={zap.created_at} className="shrink-0" short={isSmallScreen} />
</div>
</div>
</div>
{zap.comment ? <Content className="mt-2 text-sm" content={zap.comment} /> : null}
</div>
</div>
</div>
)
}

15
src/components/ReplyNoteList/index.tsx

@ -9,6 +9,7 @@ import {
isReplaceableEvent, isReplaceableEvent,
isReplyNoteEvent isReplyNoteEvent
} from '@/lib/event' } from '@/lib/event'
import { shouldHideInteractions } from '@/lib/event-filtering'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { toNote } from '@/lib/link' import { toNote } from '@/lib/link'
import { generateBech32IdFromETag, tagNameEquals } from '@/lib/tag' import { generateBech32IdFromETag, tagNameEquals } from '@/lib/tag'
@ -25,10 +26,12 @@ import noteStatsService from '@/services/note-stats.service'
import discussionFeedCache from '@/services/discussion-feed-cache.service' import discussionFeedCache from '@/services/discussion-feed-cache.service'
import { buildReplyReadRelayList } from '@/lib/relay-list-builder' import { buildReplyReadRelayList } from '@/lib/relay-list-builder'
import { Filter, Event as NEvent, kinds } from 'nostr-tools' import { Filter, Event as NEvent, kinds } from 'nostr-tools'
import { useNoteStatsById } from '@/hooks/useNoteStatsById'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { LoadingBar } from '../LoadingBar' import { LoadingBar } from '../LoadingBar'
import ReplyNote, { ReplyNoteSkeleton } from '../ReplyNote' import ReplyNote, { ReplyNoteSkeleton } from '../ReplyNote'
import ZapReplyFeedRow from './ZapReplyFeedRow'
type TRootInfo = type TRootInfo =
| { type: 'E'; id: string; pubkey: string } | { type: 'E'; id: string; pubkey: string }
@ -43,6 +46,7 @@ function ReplyNoteList({ index, event, sort = 'oldest' }: { index?: number; even
const { navigateToNote } = useSmartNoteNavigation() const { navigateToNote } = useSmartNoteNavigation()
const { currentIndex } = useSecondaryPage() const { currentIndex } = useSecondaryPage()
const { hideUntrustedInteractions, isUserTrusted } = useUserTrust() const { hideUntrustedInteractions, isUserTrusted } = useUserTrust()
const noteStats = useNoteStatsById(event.id)
const { mutePubkeySet } = useMuteList() const { mutePubkeySet } = useMuteList()
const { hideContentMentioningMutedUsers } = useContentPolicy() const { hideContentMentioningMutedUsers } = useContentPolicy()
const { relayList: userRelayList, pubkey: userPubkey } = useNostr() const { relayList: userRelayList, pubkey: userPubkey } = useNostr()
@ -179,6 +183,14 @@ function ReplyNoteList({ index, event, sort = 'oldest' }: { index?: number; even
return replyEvents.sort((a, b) => b.created_at - a.created_at) return replyEvents.sort((a, b) => b.created_at - a.created_at)
} }
}, [event.id, repliesMap, mutePubkeySet, hideContentMentioningMutedUsers, sort]) }, [event.id, repliesMap, mutePubkeySet, hideContentMentioningMutedUsers, sort])
const zapsForFeed = useMemo(() => {
if (shouldHideInteractions(event)) return []
const raw = noteStats?.zaps ?? []
const filtered = hideUntrustedInteractions ? raw.filter((z) => isUserTrusted(z.pubkey)) : raw
return [...filtered].sort((a, b) => b.amount - a.amount)
}, [event, noteStats, hideUntrustedInteractions, isUserTrusted])
const [timelineKey] = useState<string | undefined>(undefined) const [timelineKey] = useState<string | undefined>(undefined)
const [until, setUntil] = useState<number | undefined>(undefined) const [until, setUntil] = useState<number | undefined>(undefined)
const [loading, setLoading] = useState<boolean>(false) const [loading, setLoading] = useState<boolean>(false)
@ -470,6 +482,9 @@ function ReplyNoteList({ index, event, sort = 'oldest' }: { index?: number; even
return ( return (
<div className="min-h-[80vh]"> <div className="min-h-[80vh]">
{loading && <LoadingBar />} {loading && <LoadingBar />}
{zapsForFeed.map((zap) => (
<ZapReplyFeedRow key={zap.pr} zap={zap} />
))}
{!loading && until && ( {!loading && until && (
<div <div
className={`text-sm text-center text-muted-foreground border-b py-2 ${!loading ? 'hover:text-foreground cursor-pointer' : ''}`} className={`text-sm text-center text-muted-foreground border-b py-2 ${!loading ? 'hover:text-foreground cursor-pointer' : ''}`}

81
src/components/RepostList/index.tsx

@ -1,81 +0,0 @@
import { useSecondaryPage } from '@/PageManager'
import { useNoteStatsById } from '@/hooks/useNoteStatsById'
import { toProfile } from '@/lib/link'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { useUserTrust } from '@/providers/UserTrustProvider'
import { Repeat } from 'lucide-react'
import { Event } from 'nostr-tools'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { FormattedTimestamp } from '../FormattedTimestamp'
import Nip05 from '../Nip05'
import UserAvatar from '../UserAvatar'
import Username from '../Username'
const SHOW_COUNT = 20
export default function RepostList({ event }: { event: Event }) {
const { t } = useTranslation()
const { push } = useSecondaryPage()
const { isSmallScreen } = useScreenSize()
const { hideUntrustedInteractions, isUserTrusted } = useUserTrust()
const noteStats = useNoteStatsById(event.id)
const filteredReposts = useMemo(() => {
return (noteStats?.reposts ?? [])
.filter((repost) => !hideUntrustedInteractions || isUserTrusted(repost.pubkey))
.sort((a, b) => b.created_at - a.created_at)
}, [noteStats, event.id, hideUntrustedInteractions, isUserTrusted])
const [showCount, setShowCount] = useState(SHOW_COUNT)
const bottomRef = useRef<HTMLDivElement | null>(null)
useEffect(() => {
if (!bottomRef.current || filteredReposts.length <= showCount) return
const obs = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) setShowCount((c) => c + SHOW_COUNT)
},
{ rootMargin: '10px', threshold: 0.1 }
)
obs.observe(bottomRef.current)
return () => obs.disconnect()
}, [filteredReposts.length, showCount])
return (
<div className="min-h-[80vh]">
{filteredReposts.slice(0, showCount).map((repost) => (
<div
key={repost.id}
className="px-4 py-3 border-b transition-colors clickable flex items-center gap-3"
onClick={() => push(toProfile(repost.pubkey))}
>
<Repeat className="text-green-400 size-5" />
<UserAvatar userId={repost.pubkey} size="medium" className="shrink-0" />
<div className="flex-1 w-0">
<Username
userId={repost.pubkey}
className="text-sm font-semibold text-muted-foreground hover:text-foreground max-w-fit truncate"
skeletonClassName="h-3"
/>
<div className="flex items-center gap-1 text-sm text-muted-foreground">
<Nip05 pubkey={repost.pubkey} append="·" />
<FormattedTimestamp
timestamp={repost.created_at}
className="shrink-0"
short={isSmallScreen}
/>
</div>
</div>
</div>
))}
<div ref={bottomRef} />
<div className="text-sm mt-2 text-center text-muted-foreground">
{filteredReposts.length > 0 ? t('No more boosts') : t('No boosts yet')}
</div>
</div>
)
}

91
src/components/ZapList/index.tsx

@ -1,91 +0,0 @@
import { useSecondaryPage } from '@/PageManager'
import { useNoteStatsById } from '@/hooks/useNoteStatsById'
import { shouldHideInteractions } from '@/lib/event-filtering'
import { formatAmount } from '@/lib/lightning'
import { toProfile } from '@/lib/link'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { Zap } from 'lucide-react'
import { Event } from 'nostr-tools'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Content from '../Content'
import { FormattedTimestamp } from '../FormattedTimestamp'
import Nip05 from '../Nip05'
import UserAvatar from '../UserAvatar'
import Username from '../Username'
const SHOW_COUNT = 20
export default function ZapList({ event }: { event: Event }) {
const inQuietMode = shouldHideInteractions(event)
// Hide zap receipts in quiet mode as they contain emojis and text
if (inQuietMode) {
return null
}
const { t } = useTranslation()
const { push } = useSecondaryPage()
const { isSmallScreen } = useScreenSize()
const noteStats = useNoteStatsById(event.id)
const filteredZaps = useMemo(() => {
return (noteStats?.zaps ?? []).sort((a, b) => b.amount - a.amount)
}, [noteStats, event.id])
const [showCount, setShowCount] = useState(SHOW_COUNT)
const bottomRef = useRef<HTMLDivElement | null>(null)
useEffect(() => {
if (!bottomRef.current || filteredZaps.length <= showCount) return
const obs = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) setShowCount((c) => c + SHOW_COUNT)
},
{ rootMargin: '10px', threshold: 0.1 }
)
obs.observe(bottomRef.current)
return () => obs.disconnect()
}, [filteredZaps.length, showCount])
return (
<div className="min-h-[80vh]">
{filteredZaps.slice(0, showCount).map((zap) => (
<div
key={zap.pr}
className="px-4 py-3 border-b transition-colors clickable flex gap-2"
onClick={() => push(toProfile(zap.pubkey))}
>
<div className="w-8 flex flex-col items-center mt-0.5">
<Zap className="text-yellow-400 size-5" />
<div className="text-sm font-semibold text-yellow-400">{formatAmount(zap.amount)}</div>
</div>
<div className="flex space-x-2 items-start">
<UserAvatar userId={zap.pubkey} size="medium" className="shrink-0 mt-0.5" />
<div className="flex-1">
<Username
userId={zap.pubkey}
className="text-sm font-semibold text-muted-foreground hover:text-foreground max-w-fit truncate"
skeletonClassName="h-3"
/>
<div className="flex items-center gap-1 text-sm text-muted-foreground">
<Nip05 pubkey={zap.pubkey} append="·" />
<FormattedTimestamp
timestamp={zap.created_at}
className="shrink-0"
short={isSmallScreen}
/>
</div>
<Content className="mt-2" content={zap.comment} />
</div>
</div>
</div>
))}
<div ref={bottomRef} />
<div className="text-sm mt-2 text-center text-muted-foreground">
{filteredZaps.length > 0 ? t('No more zaps') : t('No zaps yet')}
</div>
</div>
)
}

1
src/i18n/locales/de.ts

@ -498,6 +498,7 @@ export default {
'No zaps yet': 'Noch keine Zaps', 'No zaps yet': 'Noch keine Zaps',
'No more boosts': 'Keine weiteren Boosts', 'No more boosts': 'Keine weiteren Boosts',
'No boosts yet': 'Noch keine Boosts', 'No boosts yet': 'Noch keine Boosts',
'n more boosts': '{{count}} weitere Boosts',
Boosts: 'Boosts', Boosts: 'Boosts',
FollowListNotFoundConfirmation: FollowListNotFoundConfirmation:
'Folgeliste nicht gefunden. Möchten Sie eine neue erstellen? Wenn Sie zuvor Benutzer gefolgt haben, bestätigen Sie bitte NICHT, da diese Operation dazu führt, dass Sie Ihre vorherige Folgeliste verlieren.', 'Folgeliste nicht gefunden. Möchten Sie eine neue erstellen? Wenn Sie zuvor Benutzer gefolgt haben, bestätigen Sie bitte NICHT, da diese Operation dazu führt, dass Sie Ihre vorherige Folgeliste verlieren.',

1
src/i18n/locales/en.ts

@ -568,6 +568,7 @@ export default {
'No zaps yet': 'No zaps yet', 'No zaps yet': 'No zaps yet',
'No more boosts': 'No more boosts', 'No more boosts': 'No more boosts',
'No boosts yet': 'No boosts yet', 'No boosts yet': 'No boosts yet',
'n more boosts': '{{count}} more boosts',
Boosts: 'Boosts', Boosts: 'Boosts',
FollowListNotFoundConfirmation: FollowListNotFoundConfirmation:
'Follow list not found. Do you want to create a new one? If you have followed users before, please DO NOT confirm as this operation will cause you to lose your previous follow list.', 'Follow list not found. Do you want to create a new one? If you have followed users before, please DO NOT confirm as this operation will cause you to lose your previous follow list.',

1
src/pages/secondary/NotePage/index.tsx

@ -493,6 +493,7 @@ const NotePage = forwardRef(({ id, index, hideTitlebar = false, initialEvent }:
: undefined : undefined
} }
/> />
<NoteBoostBadges event={finalEvent} className="mt-2" />
<NoteStats className="mt-3" event={finalEvent} fetchIfNotExisting displayTopZapsAndLikes /> <NoteStats className="mt-3" event={finalEvent} fetchIfNotExisting displayTopZapsAndLikes />
</div> </div>
<Separator className="mt-4" /> <Separator className="mt-4" />

Loading…
Cancel
Save