ChatContent.tsx raw
1 import {
2 EmbeddedEventParser,
3 EmbeddedMentionParser,
4 EmbeddedUrlParser,
5 EmbeddedHashtagParser,
6 EmbeddedEmojiParser,
7 parseContent
8 } from '@/lib/content-parser'
9 import { EmbeddedMention, EmbeddedHashtag } from '../Embedded'
10 import { SecondaryPageLink } from '@/PageManager'
11 import { toNote } from '@/lib/link'
12 import { truncateUrl } from '@/lib/url'
13 import { getEmojiInfosFromEmojiTags } from '@/lib/tag'
14 import Emoji from '../Emoji'
15 import { useMemo } from 'react'
16 import { Event } from 'nostr-tools'
17
18 /**
19 * Lightweight inline content renderer for NIRC chat messages.
20 * Reuses the same parseContent pipeline as the full Content component
21 * but renders everything inline to fit the IRC-style monospace layout.
22 */
23 export default function ChatContent({ content, event }: { content: string; event?: Event }) {
24 const { nodes, emojiInfos } = useMemo(() => {
25 if (!content) return { nodes: [], emojiInfos: [] }
26 const nodes = parseContent(content, [
27 EmbeddedEventParser,
28 EmbeddedMentionParser,
29 EmbeddedUrlParser,
30 EmbeddedHashtagParser,
31 EmbeddedEmojiParser
32 ])
33 const emojiInfos = getEmojiInfosFromEmojiTags(event?.tags)
34 return { nodes, emojiInfos }
35 }, [content, event])
36
37 if (!nodes || nodes.length === 0) return null
38
39 return (
40 <span className="break-words whitespace-pre-wrap min-w-0">
41 {nodes.map((node, i) => {
42 if (node.type === 'text') {
43 return <span key={i}>{node.data}</span>
44 }
45 if (node.type === 'mention') {
46 const userId = (node.data as string).split(':')[1]
47 if (!userId) return <span key={i}>{node.data as string}</span>
48 return <EmbeddedMention key={i} userId={userId} className="inline" />
49 }
50 if (node.type === 'url') {
51 return (
52 <a
53 key={i}
54 href={node.data as string}
55 target="_blank"
56 rel="noopener noreferrer"
57 className="text-primary hover:underline"
58 onClick={(e) => e.stopPropagation()}
59 >
60 {truncateUrl(node.data as string)}
61 </a>
62 )
63 }
64 if (node.type === 'image' || node.type === 'images') {
65 const url = Array.isArray(node.data) ? node.data[0] : node.data
66 return (
67 <a
68 key={i}
69 href={url}
70 target="_blank"
71 rel="noopener noreferrer"
72 className="text-primary hover:underline"
73 onClick={(e) => e.stopPropagation()}
74 >
75 [image]
76 </a>
77 )
78 }
79 if (node.type === 'media') {
80 return (
81 <a
82 key={i}
83 href={node.data as string}
84 target="_blank"
85 rel="noopener noreferrer"
86 className="text-primary hover:underline"
87 onClick={(e) => e.stopPropagation()}
88 >
89 [media]
90 </a>
91 )
92 }
93 if (node.type === 'event') {
94 const id = (node.data as string).split(':')[1]
95 if (!id) return <span key={i}>{node.data as string}</span>
96 return (
97 <SecondaryPageLink
98 key={i}
99 to={toNote(id)}
100 className="text-primary hover:underline"
101 onClick={(e) => e.stopPropagation()}
102 >
103 [note]
104 </SecondaryPageLink>
105 )
106 }
107 if (node.type === 'hashtag') {
108 return <EmbeddedHashtag key={i} hashtag={node.data as string} />
109 }
110 if (node.type === 'emoji') {
111 const shortcode = (node.data as string).split(':')[1]
112 const emoji = emojiInfos.find((e) => e.shortcode === shortcode)
113 if (!emoji) return <span key={i}>{node.data as string}</span>
114 return <Emoji key={i} emoji={emoji} classNames={{ img: 'size-4 inline' }} />
115 }
116 if (node.type === 'youtube' || node.type === 'x-post') {
117 return (
118 <a
119 key={i}
120 href={node.data as string}
121 target="_blank"
122 rel="noopener noreferrer"
123 className="text-primary hover:underline"
124 onClick={(e) => e.stopPropagation()}
125 >
126 {truncateUrl(node.data as string)}
127 </a>
128 )
129 }
130 if (node.type === 'invoice') {
131 return (
132 <span key={i} className="text-primary">
133 [ln-invoice]
134 </span>
135 )
136 }
137 return null
138 })}
139 </span>
140 )
141 }
142