SubReplies.tsx raw
1 import { useSecondaryPage } from '@/PageManager'
2 import { useKeyboardNavigable } from '@/hooks/useKeyboardNavigable'
3 import { useAllDescendantThreads } from '@/hooks/useThread'
4 import { getEventKey, getKeyFromTag, getParentTag, isMentioningMutedUsers } from '@/lib/event'
5 import { toNote } from '@/lib/link'
6 import { generateBech32IdFromETag } from '@/lib/tag'
7 import { cn } from '@/lib/utils'
8 import { useContentPolicy } from '@/providers/ContentPolicyProvider'
9 import { useMuteList } from '@/providers/MuteListProvider'
10 import { useUserTrust } from '@/providers/UserTrustProvider'
11 import { ChevronDown, ChevronUp } from 'lucide-react'
12 import { NostrEvent } from 'nostr-tools'
13 import { useCallback, useMemo, useRef, useState } from 'react'
14 import { useTranslation } from 'react-i18next'
15 import ReplyNote from '../ReplyNote'
16
17 export default function SubReplies({
18 parentKey,
19 revealerNavIndex,
20 subReplyNavIndexStart
21 }: {
22 parentKey: string
23 revealerNavIndex?: number
24 subReplyNavIndexStart?: number
25 }) {
26 const { push } = useSecondaryPage()
27 const allThreads = useAllDescendantThreads(parentKey)
28 const { hideUntrustedInteractions, isUserTrusted } = useUserTrust()
29 const { mutePubkeySet } = useMuteList()
30 const { hideContentMentioningMutedUsers } = useContentPolicy()
31 const [isExpanded, setIsExpanded] = useState(false)
32 const replies = useMemo(() => {
33 const replyKeySet = new Set<string>()
34 const replyEvents: NostrEvent[] = []
35
36 let parentKeys = [parentKey]
37 while (parentKeys.length > 0) {
38 const events = parentKeys.flatMap((key) => allThreads.get(key) ?? [])
39 events.forEach((evt) => {
40 const key = getEventKey(evt)
41 if (replyKeySet.has(key)) return
42 if (mutePubkeySet.has(evt.pubkey)) return
43 if (hideContentMentioningMutedUsers && isMentioningMutedUsers(evt, mutePubkeySet)) return
44 if (hideUntrustedInteractions && !isUserTrusted(evt.pubkey)) {
45 const replyKey = getEventKey(evt)
46 const repliesForThisReply = allThreads.get(replyKey)
47 // If the reply is not trusted and there are no trusted replies for this reply, skip rendering
48 if (
49 !repliesForThisReply ||
50 repliesForThisReply.every((evt) => !isUserTrusted(evt.pubkey))
51 ) {
52 return
53 }
54 }
55
56 replyKeySet.add(key)
57 replyEvents.push(evt)
58 })
59 parentKeys = events.map((evt) => getEventKey(evt))
60 }
61 return replyEvents.sort((a, b) => a.created_at - b.created_at)
62 }, [
63 parentKey,
64 allThreads,
65 mutePubkeySet,
66 hideContentMentioningMutedUsers,
67 hideUntrustedInteractions
68 ])
69 const [highlightReplyKey, setHighlightReplyKey] = useState<string | undefined>(undefined)
70 const replyRefs = useRef<Record<string, HTMLDivElement | null>>({})
71
72 const highlightReply = useCallback((key: string, eventId?: string, scrollTo = true) => {
73 let found = false
74 if (scrollTo) {
75 const ref = replyRefs.current[key]
76 if (ref) {
77 found = true
78 ref.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
79 }
80 }
81 if (!found) {
82 if (eventId) push(toNote(eventId))
83 return
84 }
85
86 setHighlightReplyKey(key)
87 setTimeout(() => {
88 setHighlightReplyKey((pre) => (pre === key ? undefined : pre))
89 }, 1500)
90 }, [])
91
92 if (replies.length === 0) return null
93
94 return (
95 <div>
96 {replies.length > 1 && (
97 <Revealer
98 isExpanded={isExpanded}
99 onToggle={() => setIsExpanded((prev) => !prev)}
100 replyCount={replies.length}
101 navIndex={revealerNavIndex}
102 />
103 )}
104 {(isExpanded || replies.length === 1) && (
105 <div>
106 {replies.map((reply, index) => {
107 const currentReplyKey = getEventKey(reply)
108 const _parentTag = getParentTag(reply)
109 if (_parentTag?.type !== 'e') return null
110 const _parentKey = _parentTag ? getKeyFromTag(_parentTag.tag) : undefined
111 const _parentEventId = generateBech32IdFromETag(_parentTag.tag)
112 return (
113 <div
114 ref={(el) => (replyRefs.current[currentReplyKey] = el)}
115 key={currentReplyKey}
116 className="scroll-mt-12 flex relative"
117 >
118 <div className="absolute left-[34px] top-0 h-8 w-4 rounded-bl-lg border-l border-b z-20" />
119 {index < replies.length - 1 && (
120 <div className="absolute left-[34px] top-0 bottom-0 border-l z-20" />
121 )}
122 <ReplyNote
123 className="flex-1 w-0 pl-10"
124 event={reply}
125 navColumn={2}
126 navIndex={subReplyNavIndexStart !== undefined ? subReplyNavIndexStart + index : undefined}
127 parentEventId={_parentKey !== parentKey ? _parentEventId : undefined}
128 onClickParent={() => {
129 if (!_parentKey) return
130 highlightReply(_parentKey, _parentEventId)
131 }}
132 highlight={highlightReplyKey === currentReplyKey}
133 />
134 </div>
135 )
136 })}
137 </div>
138 )}
139 </div>
140 )
141 }
142
143 function Revealer({
144 isExpanded,
145 onToggle,
146 replyCount,
147 navIndex
148 }: {
149 isExpanded: boolean
150 onToggle: () => void
151 replyCount: number
152 navIndex?: number
153 }) {
154 const { t } = useTranslation()
155
156 const { ref: revealerRef, isSelected } = useKeyboardNavigable(2, navIndex ?? 0, {
157 meta: { type: 'note', onActivate: onToggle }
158 })
159
160 return (
161 <div ref={revealerRef} className="scroll-mt-[6.5rem]">
162 <button
163 onClick={(e) => {
164 e.stopPropagation()
165 onToggle()
166 }}
167 className={cn(
168 'relative w-full flex items-center gap-1.5 pl-14 py-2 text-sm text-muted-foreground hover:text-foreground transition-colors clickable',
169 isSelected && 'ring-2 ring-primary ring-inset'
170 )}
171 >
172 <div
173 className={cn('absolute left-[34px] top-0 bottom-0 w-px text-border z-20')}
174 style={{
175 background: isExpanded
176 ? 'currentColor'
177 : 'repeating-linear-gradient(to bottom, currentColor 0 3px, transparent 3px 7px)'
178 }}
179 />
180 {isExpanded ? (
181 <>
182 <ChevronUp className="size-3.5" />
183 <span>
184 {t('Hide replies')} ({replyCount})
185 </span>
186 </>
187 ) : (
188 <>
189 <ChevronDown className="size-3.5" />
190 <span>
191 {t('Show replies')} ({replyCount})
192 </span>
193 </>
194 )}
195 </button>
196 </div>
197 )
198 }
199