index.tsx raw
1 import { usePrimaryPage, useSecondaryPage } from '@/PageManager'
2 import { ExtendedKind, NSFW_DISPLAY_POLICY, SUPPORTED_KINDS } from '@/constants'
3 import { getParentStuff, isNsfwEvent } from '@/lib/event'
4 import { toExternalContent, toNote } from '@/lib/link'
5 import { useContentPolicy } from '@/providers/ContentPolicyProvider'
6 import { useDM } from '@/providers/DMProvider'
7 import { useMuteList } from '@/providers/MuteListProvider'
8 import { useNostr } from '@/providers/NostrProvider'
9 import { useScreenSize } from '@/providers/ScreenSizeProvider'
10 import { Event, kinds } from 'nostr-tools'
11 import { useMemo, useState } from 'react'
12 import AudioPlayer from '../AudioPlayer'
13 import ClientTag from '../ClientTag'
14 import Content from '../Content'
15 import FollowingBadge from '../FollowingBadge'
16 import { FormattedTimestamp } from '../FormattedTimestamp'
17 import Nip05 from '../Nip05'
18 import NoteOptions from '../NoteOptions'
19 import ParentNotePreview from '../ParentNotePreview'
20 import TrustScoreBadge from '../TrustScoreBadge'
21 import UserAvatar from '../UserAvatar'
22 import Username from '../Username'
23 import { Code, Mail, Type } from 'lucide-react'
24 import CommunityDefinition from './CommunityDefinition'
25 import EmojiPack from './EmojiPack'
26 import FollowPack from './FollowPack'
27 import GroupMetadata from './GroupMetadata'
28 import Highlight from './Highlight'
29 import LiveEvent from './LiveEvent'
30 import LongFormArticle from './LongFormArticle'
31 import LongFormArticlePreview from './LongFormArticlePreview'
32 import MutedNote from './MutedNote'
33 import NsfwNote from './NsfwNote'
34 import PictureNote from './PictureNote'
35 import Poll from './Poll'
36 import RelayReview from './RelayReview'
37 import UnknownNote from './UnknownNote'
38 import VideoNote from './VideoNote'
39
40 export default function Note({
41 event,
42 originalNoteId,
43 size = 'normal',
44 className,
45 hideParentNotePreview = false,
46 showFull = false
47 }: {
48 event: Event
49 originalNoteId?: string
50 size?: 'normal' | 'small'
51 className?: string
52 hideParentNotePreview?: boolean
53 showFull?: boolean
54 }) {
55 const { push } = useSecondaryPage()
56 const { navigate } = usePrimaryPage()
57 const { isSmallScreen } = useScreenSize()
58 const { pubkey } = useNostr()
59 const { startConversation } = useDM()
60 const { parentEventId, parentExternalContent } = useMemo(() => {
61 return getParentStuff(event)
62 }, [event])
63 const { nsfwDisplayPolicy, enableMarkdown: globalEnableMarkdown } = useContentPolicy()
64 const [showNsfw, setShowNsfw] = useState(false)
65 const { mutePubkeySet } = useMuteList()
66 const [showMuted, setShowMuted] = useState(false)
67 const [markdownOverride, setMarkdownOverride] = useState<boolean | null>(null)
68 const effectiveMarkdown = markdownOverride ?? globalEnableMarkdown
69
70 const handleStartConversation = (e: React.MouseEvent) => {
71 e.stopPropagation()
72 startConversation(event.pubkey)
73 navigate('inbox')
74 }
75 const isNsfw = useMemo(
76 () => (nsfwDisplayPolicy === NSFW_DISPLAY_POLICY.SHOW ? false : isNsfwEvent(event)),
77 [event, nsfwDisplayPolicy]
78 )
79
80 let content: React.ReactNode
81 if (
82 ![
83 ...SUPPORTED_KINDS,
84 kinds.CommunityDefinition,
85 kinds.LiveEvent,
86 ExtendedKind.GROUP_METADATA
87 ].includes(event.kind)
88 ) {
89 content = <UnknownNote className="mt-2" event={event} />
90 } else if (mutePubkeySet.has(event.pubkey) && !showMuted) {
91 content = <MutedNote show={() => setShowMuted(true)} />
92 } else if (isNsfw && !showNsfw) {
93 content = <NsfwNote show={() => setShowNsfw(true)} />
94 } else if (event.kind === kinds.Highlights) {
95 content = <Highlight className="mt-2" event={event} />
96 } else if (event.kind === kinds.LongFormArticle) {
97 content = showFull ? (
98 <LongFormArticle className="mt-2" event={event} />
99 ) : (
100 <LongFormArticlePreview className="mt-2" event={event} />
101 )
102 } else if (event.kind === kinds.LiveEvent) {
103 content = <LiveEvent className="mt-2" event={event} />
104 } else if (event.kind === ExtendedKind.GROUP_METADATA) {
105 content = <GroupMetadata className="mt-2" event={event} originalNoteId={originalNoteId} />
106 } else if (event.kind === kinds.CommunityDefinition) {
107 content = <CommunityDefinition className="mt-2" event={event} />
108 } else if (event.kind === ExtendedKind.POLL) {
109 content = (
110 <>
111 <Content className="mt-2" event={event} enableMarkdown={effectiveMarkdown} />
112 <Poll className="mt-2" event={event} />
113 </>
114 )
115 } else if (event.kind === ExtendedKind.VOICE || event.kind === ExtendedKind.VOICE_COMMENT) {
116 content = <AudioPlayer className="mt-2" src={event.content} />
117 } else if (event.kind === ExtendedKind.PICTURE) {
118 content = <PictureNote className="mt-2" event={event} />
119 } else if (
120 event.kind === ExtendedKind.VIDEO ||
121 event.kind === ExtendedKind.SHORT_VIDEO ||
122 event.kind === ExtendedKind.ADDRESSABLE_NORMAL_VIDEO ||
123 event.kind === ExtendedKind.ADDRESSABLE_SHORT_VIDEO
124 ) {
125 content = <VideoNote className="mt-2" event={event} />
126 } else if (event.kind === ExtendedKind.RELAY_REVIEW) {
127 content = <RelayReview className="mt-2" event={event} />
128 } else if (event.kind === kinds.Emojisets) {
129 content = <EmojiPack className="mt-2" event={event} />
130 } else if (event.kind === ExtendedKind.FOLLOW_PACK) {
131 content = <FollowPack className="mt-2" event={event} />
132 } else {
133 content = <Content className="mt-2" event={event} enableHighlight enableMarkdown={effectiveMarkdown} />
134 }
135
136 return (
137 <div className={className}>
138 <div className="flex justify-between items-start gap-2">
139 <div className="flex items-center space-x-2 flex-1">
140 <UserAvatar userId={event.pubkey} size={size === 'small' ? 'medium' : 'normal'} />
141 <div className="flex-1 w-0">
142 <div className="flex gap-2 items-center">
143 <Username
144 userId={event.pubkey}
145 className={`font-semibold flex truncate ${size === 'small' ? 'text-sm' : ''}`}
146 skeletonClassName={size === 'small' ? 'h-3' : 'h-4'}
147 />
148 <FollowingBadge pubkey={event.pubkey} />
149 <TrustScoreBadge pubkey={event.pubkey} />
150 <ClientTag event={event} />
151 {pubkey && pubkey !== event.pubkey && (
152 <button
153 onClick={handleStartConversation}
154 className="p-1 rounded hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
155 title="Start conversation"
156 >
157 <Mail className="size-3.5" />
158 </button>
159 )}
160 </div>
161 <div className="flex items-center gap-1 text-sm text-muted-foreground">
162 <Nip05 pubkey={event.pubkey} append="ยท" />
163 <FormattedTimestamp
164 timestamp={event.created_at}
165 className="shrink-0"
166 short={isSmallScreen}
167 />
168 </div>
169 </div>
170 </div>
171 {size === 'normal' && (
172 <div className="flex items-center shrink-0">
173 <button
174 onClick={(e) => {
175 e.stopPropagation()
176 setMarkdownOverride((prev) => {
177 if (prev === null) return !globalEnableMarkdown
178 return null
179 })
180 }}
181 className={`p-1 rounded hover:bg-accent transition-colors ${
182 markdownOverride !== null ? 'text-foreground' : 'text-muted-foreground'
183 }`}
184 title={effectiveMarkdown ? 'Show plain text' : 'Show markdown'}
185 >
186 {effectiveMarkdown ? <Type className="size-4" /> : <Code className="size-4" />}
187 </button>
188 <NoteOptions event={event} className="py-1 [&_svg]:size-5" />
189 </div>
190 )}
191 </div>
192 {!hideParentNotePreview && (
193 <ParentNotePreview
194 eventId={parentEventId}
195 externalContent={parentExternalContent}
196 className="mt-2"
197 onClick={(e) => {
198 e.stopPropagation()
199 if (parentExternalContent) {
200 push(toExternalContent(parentExternalContent))
201 } else if (parentEventId) {
202 push(toNote(parentEventId))
203 }
204 }}
205 />
206 )}
207 {content}
208 </div>
209 )
210 }
211