index.tsx raw
1 import { useSecondaryPage } from '@/PageManager'
2 import { Button } from '@/components/ui/button'
3 import { Skeleton } from '@/components/ui/skeleton'
4 import { useKeyboardNavigable } from '@/hooks/useKeyboardNavigable'
5 import { useThread } from '@/hooks/useThread'
6 import { getEventKey, isMentioningMutedUsers } from '@/lib/event'
7 import { toNote } from '@/lib/link'
8 import { cn } from '@/lib/utils'
9 import { useContentPolicy } from '@/providers/ContentPolicyProvider'
10 import { TNavigationColumn } from '@/providers/KeyboardNavigationProvider'
11 import { useMuteList } from '@/providers/MuteListProvider'
12 import { useScreenSize } from '@/providers/ScreenSizeProvider'
13 import { useUserTrust } from '@/providers/UserTrustProvider'
14 import { Event } from 'nostr-tools'
15 import { useMemo, useState } from 'react'
16 import { useTranslation } from 'react-i18next'
17 import ClientTag from '../ClientTag'
18 import Collapsible from '../Collapsible'
19 import Content from '../Content'
20 import { FormattedTimestamp } from '../FormattedTimestamp'
21 import Nip05 from '../Nip05'
22 import NoteOptions from '../NoteOptions'
23 import ParentNotePreview from '../ParentNotePreview'
24 import StuffStats from '../StuffStats'
25 import TrustScoreBadge from '../TrustScoreBadge'
26 import UserAvatar from '../UserAvatar'
27 import Username from '../Username'
28
29 export default function ReplyNote({
30 event,
31 parentEventId,
32 onClickParent = () => {},
33 highlight = false,
34 className = '',
35 navColumn,
36 navIndex
37 }: {
38 event: Event
39 parentEventId?: string
40 onClickParent?: () => void
41 highlight?: boolean
42 className?: string
43 navColumn?: TNavigationColumn
44 navIndex?: number
45 }) {
46 const { t } = useTranslation()
47 const { isSmallScreen } = useScreenSize()
48 const { push } = useSecondaryPage()
49 const { mutePubkeySet } = useMuteList()
50 const { hideUntrustedInteractions, isUserTrusted } = useUserTrust()
51 const { hideContentMentioningMutedUsers } = useContentPolicy()
52 const eventKey = useMemo(() => getEventKey(event), [event])
53 const replies = useThread(eventKey)
54 const [showMuted, setShowMuted] = useState(false)
55
56 // Keyboard navigation
57 const { ref: navRef, isSelected } = useKeyboardNavigable(
58 navColumn ?? 2,
59 navIndex ?? 0,
60 { meta: { type: 'note', event } }
61 )
62 const show = useMemo(() => {
63 if (showMuted) {
64 return true
65 }
66 if (mutePubkeySet.has(event.pubkey)) {
67 return false
68 }
69 if (hideContentMentioningMutedUsers && isMentioningMutedUsers(event, mutePubkeySet)) {
70 return false
71 }
72 return true
73 }, [showMuted, mutePubkeySet, event, hideContentMentioningMutedUsers])
74 const hasReplies = useMemo(() => {
75 if (!replies || replies.length === 0) {
76 return false
77 }
78
79 for (const reply of replies) {
80 if (hideUntrustedInteractions && !isUserTrusted(reply.pubkey)) {
81 continue
82 }
83 if (mutePubkeySet.has(reply.pubkey)) {
84 continue
85 }
86 if (hideContentMentioningMutedUsers && isMentioningMutedUsers(reply, mutePubkeySet)) {
87 continue
88 }
89 return true
90 }
91 }, [replies])
92
93 return (
94 <div
95 ref={navRef}
96 className={cn(
97 'relative pb-3 transition-colors duration-500 clickable scroll-mt-[6.5rem]',
98 highlight ? 'bg-primary/40' : '',
99 isSelected && 'ring-2 ring-primary ring-inset',
100 className
101 )}
102 onClick={() => push(toNote(event))}
103 >
104 {hasReplies && <div className="absolute left-[34px] top-14 bottom-0 border-l z-20" />}
105 <Collapsible>
106 <div className="flex space-x-2 items-start px-4 pt-3">
107 <UserAvatar userId={event.pubkey} size="medium" className="shrink-0 mt-0.5" />
108 <div className="w-full overflow-hidden">
109 <div className="flex items-start justify-between gap-2">
110 <div className="flex-1 w-0">
111 <div className="flex gap-1 items-center">
112 <Username
113 userId={event.pubkey}
114 className="text-sm font-semibold text-muted-foreground hover:text-foreground truncate"
115 skeletonClassName="h-3"
116 />
117 <TrustScoreBadge pubkey={event.pubkey} className="!size-3.5" />
118 <ClientTag event={event} />
119 </div>
120 <div className="flex items-center gap-1 text-sm text-muted-foreground">
121 <Nip05 pubkey={event.pubkey} append="ยท" />
122 <FormattedTimestamp
123 timestamp={event.created_at}
124 className="shrink-0"
125 short={isSmallScreen}
126 />
127 </div>
128 </div>
129 <NoteOptions event={event} className="shrink-0 [&_svg]:size-5" />
130 </div>
131 {parentEventId && (
132 <ParentNotePreview
133 className="mt-2"
134 eventId={parentEventId}
135 onClick={(e) => {
136 e.stopPropagation()
137 onClickParent()
138 }}
139 />
140 )}
141 {show ? (
142 <Content className="mt-2" event={event} />
143 ) : (
144 <Button
145 variant="outline"
146 className="text-muted-foreground font-medium mt-2"
147 onClick={(e) => {
148 e.stopPropagation()
149 setShowMuted(true)
150 }}
151 >
152 {t('Temporarily display this reply')}
153 </Button>
154 )}
155 </div>
156 </div>
157 </Collapsible>
158 {show && <StuffStats className="ml-14 pl-1 mr-4 mt-2" stuff={event} displayTopZapsAndLikes />}
159 </div>
160 )
161 }
162
163 export function ReplyNoteSkeleton() {
164 return (
165 <div className="px-4 py-3 flex items-start space-x-2 w-full">
166 <Skeleton className="w-9 h-9 rounded-full shrink-0 mt-0.5" />
167 <div className="w-full">
168 <div className="py-1">
169 <Skeleton className="h-3 w-16" />
170 </div>
171 <div className="my-1">
172 <Skeleton className="w-full h-4 my-1 mt-2" />
173 </div>
174 <div className="my-1">
175 <Skeleton className="w-2/3 h-4 my-1" />
176 </div>
177 </div>
178 </div>
179 )
180 }
181