index.tsx raw
1 import { useSecondaryPage } from '@/PageManager'
2 import ContentPreview from '@/components/ContentPreview'
3 import Note from '@/components/Note'
4 import NoteInteractions from '@/components/NoteInteractions'
5 import StuffStats from '@/components/StuffStats'
6 import UserAvatar from '@/components/UserAvatar'
7 import { Card } from '@/components/ui/card'
8 import { Separator } from '@/components/ui/separator'
9 import { Skeleton } from '@/components/ui/skeleton'
10 import { ExtendedKind } from '@/constants'
11 import { useFetchEvent } from '@/hooks'
12 import { useKeyboardNavigable } from '@/hooks/useKeyboardNavigable'
13 import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
14 import {
15 getEventKey,
16 getKeyFromTag,
17 getParentBech32Id,
18 getParentTag,
19 getRootBech32Id
20 } from '@/lib/event'
21 import { toExternalContent, toNote } from '@/lib/link'
22 import { tagNameEquals } from '@/lib/tag'
23 import { cn } from '@/lib/utils'
24 import { Ellipsis } from 'lucide-react'
25 import { Event } from 'nostr-tools'
26 import { forwardRef, useCallback, useMemo } from 'react'
27 import { useTranslation } from 'react-i18next'
28 import NotFound from './NotFound'
29
30 const NotePage = forwardRef(({ id, index }: { id?: string; index?: number }, ref) => {
31 const { t } = useTranslation()
32 const { event, isFetching } = useFetchEvent(id)
33 const parentEventId = useMemo(() => getParentBech32Id(event), [event])
34 const rootEventId = useMemo(() => getRootBech32Id(event), [event])
35 const rootITag = useMemo(
36 () => (event?.kind === ExtendedKind.COMMENT ? event.tags.find(tagNameEquals('I')) : undefined),
37 [event]
38 )
39 const { isFetching: isFetchingRootEvent, event: rootEvent } = useFetchEvent(rootEventId)
40 const { isFetching: isFetchingParentEvent, event: parentEvent } = useFetchEvent(parentEventId)
41
42 if (!event && isFetching) {
43 return (
44 <SecondaryPageLayout ref={ref} index={index} title={t('Note')}>
45 <div className="px-4 pt-3">
46 <div className="flex items-center space-x-2">
47 <Skeleton className="w-10 h-10 rounded-full" />
48 <div className={`flex-1 w-0`}>
49 <div className="py-1">
50 <Skeleton className="h-4 w-16" />
51 </div>
52 <div className="py-0.5">
53 <Skeleton className="h-4 w-12" />
54 </div>
55 </div>
56 </div>
57 <div className="pt-2">
58 <div className="my-1">
59 <Skeleton className="w-full h-4 my-1 mt-2" />
60 </div>
61 <div className="my-1">
62 <Skeleton className="w-2/3 h-4 my-1" />
63 </div>
64 </div>
65 </div>
66 </SecondaryPageLayout>
67 )
68 }
69 if (!event) {
70 return (
71 <SecondaryPageLayout ref={ref} index={index} title={t('Note')} displayScrollToTopButton>
72 <NotFound bech32Id={id} />
73 </SecondaryPageLayout>
74 )
75 }
76
77 // Calculate navIndex offset for replies based on how many parent notes exist
78 const hasRootNote = rootEventId && rootEventId !== parentEventId
79 const hasParentNote = !!parentEventId
80 const parentNoteCount = (hasRootNote ? 1 : 0) + (hasParentNote ? 1 : 0)
81
82 return (
83 <SecondaryPageLayout ref={ref} index={index} title={t('Note')} displayScrollToTopButton>
84 <div className="px-4 pt-3">
85 {rootITag && <ExternalRoot value={rootITag[1]} />}
86 {hasRootNote && (
87 <ParentNote
88 key={`root-note-${event.id}`}
89 isFetching={isFetchingRootEvent}
90 event={rootEvent}
91 eventBech32Id={rootEventId}
92 isConsecutive={isConsecutive(rootEvent, parentEvent)}
93 navIndex={0}
94 />
95 )}
96 {hasParentNote && (
97 <ParentNote
98 key={`parent-note-${event.id}`}
99 isFetching={isFetchingParentEvent}
100 event={parentEvent}
101 eventBech32Id={parentEventId}
102 navIndex={hasRootNote ? 1 : 0}
103 />
104 )}
105 <Note
106 key={`note-${event.id}`}
107 event={event}
108 className="select-text"
109 hideParentNotePreview
110 originalNoteId={id}
111 showFull
112 />
113 <StuffStats className="mt-3" stuff={event} fetchIfNotExisting displayTopZapsAndLikes />
114 </div>
115 <Separator className="mt-4" />
116 <NoteInteractions key={`note-interactions-${event.id}`} event={event} navIndexOffset={parentNoteCount} />
117 </SecondaryPageLayout>
118 )
119 })
120 NotePage.displayName = 'NotePage'
121 export default NotePage
122
123 function ExternalRoot({ value }: { value: string }) {
124 const { push } = useSecondaryPage()
125
126 return (
127 <div>
128 <Card
129 className="flex space-x-1 px-1.5 py-1 items-center clickable text-sm text-muted-foreground hover:text-foreground"
130 onClick={() => push(toExternalContent(value))}
131 >
132 <div className="truncate">{value}</div>
133 </Card>
134 <div className="ml-5 w-px h-2 bg-border" />
135 </div>
136 )
137 }
138
139 function ParentNote({
140 event,
141 eventBech32Id,
142 isFetching,
143 isConsecutive = true,
144 navIndex
145 }: {
146 event?: Event
147 eventBech32Id: string
148 isFetching: boolean
149 isConsecutive?: boolean
150 navIndex?: number
151 }) {
152 const { push } = useSecondaryPage()
153
154 const handleActivate = useCallback(() => {
155 push(toNote(event ?? eventBech32Id))
156 }, [push, event, eventBech32Id])
157
158 const { ref: navRef, isSelected } = useKeyboardNavigable(2, navIndex ?? 0, {
159 meta: { type: 'note', onActivate: handleActivate }
160 })
161
162 if (isFetching) {
163 return (
164 <div>
165 <div className="flex space-x-1 px-[0.4375rem] py-1 items-center rounded-full border clickable text-sm text-muted-foreground">
166 <Skeleton className="shrink w-4 h-4 rounded-full" />
167 <div className="py-1 flex-1">
168 <Skeleton className="h-3" />
169 </div>
170 </div>
171 <div className="ml-5 w-px h-3 bg-border" />
172 </div>
173 )
174 }
175
176 return (
177 <div ref={navRef} className="scroll-mt-[6.5rem]">
178 <div
179 className={cn(
180 'flex space-x-1 px-[0.4375rem] py-1 items-center rounded-full border clickable text-sm text-muted-foreground',
181 event && 'hover:text-foreground',
182 isSelected && 'ring-2 ring-primary'
183 )}
184 onClick={handleActivate}
185 >
186 {event && <UserAvatar userId={event.pubkey} size="tiny" className="shrink-0" />}
187 <ContentPreview className="truncate" event={event} />
188 </div>
189 {isConsecutive ? (
190 <div className="ml-5 w-px h-3 bg-border" />
191 ) : (
192 <Ellipsis className="ml-3.5 text-muted-foreground/60 size-3" />
193 )}
194 </div>
195 )
196 }
197
198 function isConsecutive(rootEvent?: Event, parentEvent?: Event) {
199 if (!rootEvent || !parentEvent) return false
200
201 const tag = getParentTag(parentEvent)
202 if (!tag) return false
203
204 return getEventKey(rootEvent) === getKeyFromTag(tag.tag)
205 }
206