MessageContent.tsx raw
1 import { useSecondaryPage } from '@/PageManager'
2 import {
3 EmbeddedEventParser,
4 EmbeddedMentionParser,
5 EmbeddedUrlParser,
6 parseContent
7 } from '@/lib/content-parser'
8 import { toNote, toProfile } from '@/lib/link'
9 import { truncateUrl } from '@/lib/url'
10 import { cn } from '@/lib/utils'
11 import { useMemo } from 'react'
12
13 interface MessageContentProps {
14 content: string
15 className?: string
16 /** If true, links will be styled for dark background (primary-foreground color) */
17 isOwnMessage?: boolean
18 }
19
20 /**
21 * Renders DM message content with linkified URLs and nostr entities.
22 * - URLs open in new tab
23 * - nostr:npub/nprofile opens user profile in secondary pane
24 * - nostr:note1/nevent opens note in secondary pane
25 */
26 export default function MessageContent({ content, className, isOwnMessage }: MessageContentProps) {
27 const { push } = useSecondaryPage()
28
29 const nodes = useMemo(() => {
30 return parseContent(content, [EmbeddedEventParser, EmbeddedMentionParser, EmbeddedUrlParser])
31 }, [content])
32
33 const linkClass = cn(
34 'underline cursor-pointer hover:opacity-80',
35 isOwnMessage ? 'text-primary-foreground' : 'text-primary'
36 )
37
38 return (
39 <span className={cn('whitespace-pre-wrap break-words', className)}>
40 {nodes.map((node, index) => {
41 if (node.type === 'text') {
42 return node.data
43 }
44
45 // URLs - open in new tab
46 if (node.type === 'url' || node.type === 'image' || node.type === 'media') {
47 const url = node.data as string
48 return (
49 <a
50 key={index}
51 href={url}
52 target="_blank"
53 rel="noreferrer"
54 className={linkClass}
55 onClick={(e) => e.stopPropagation()}
56 >
57 {truncateUrl(url)}
58 </a>
59 )
60 }
61
62 // YouTube and X posts - open in new tab
63 if (node.type === 'youtube' || node.type === 'x-post') {
64 const url = node.data as string
65 return (
66 <a
67 key={index}
68 href={url}
69 target="_blank"
70 rel="noreferrer"
71 className={linkClass}
72 onClick={(e) => e.stopPropagation()}
73 >
74 {truncateUrl(url)}
75 </a>
76 )
77 }
78
79 // nostr: mention (npub/nprofile) - open profile in secondary pane
80 if (node.type === 'mention') {
81 const bech32 = (node.data as string).replace('nostr:', '')
82 return (
83 <button
84 key={index}
85 className={linkClass}
86 onClick={(e) => {
87 e.stopPropagation()
88 push(toProfile(bech32))
89 }}
90 >
91 @{bech32.slice(0, 12)}...
92 </button>
93 )
94 }
95
96 // nostr: event (note1/nevent/naddr) - open note in secondary pane
97 if (node.type === 'event') {
98 const bech32 = (node.data as string).replace('nostr:', '')
99 // Determine display based on prefix
100 const isNote = bech32.startsWith('note1')
101 const prefix = isNote ? 'note' : bech32.startsWith('nevent') ? 'nevent' : 'naddr'
102 return (
103 <button
104 key={index}
105 className={linkClass}
106 onClick={(e) => {
107 e.stopPropagation()
108 push(toNote(bech32))
109 }}
110 >
111 {prefix}:{bech32.slice(prefix.length, prefix.length + 8)}...
112 </button>
113 )
114 }
115
116 return null
117 })}
118 </span>
119 )
120 }
121