@@ -10,6 +10,8 @@ import type { IRCMessage, MessageDensity, TimestampFormat } from "@/lib/types"
1010import { ScrollArea } from "@/components/ui/scroll-area"
1111import { Tooltip , TooltipContent , TooltipTrigger , TooltipProvider } from "@/components/ui/tooltip"
1212import { ImagePreview } from "./image-preview"
13+ import { ContextMenu , ContextMenuContent , ContextMenuItem , ContextMenuSeparator , ContextMenuTrigger } from "@/components/ui/context-menu"
14+ import { emitInputInsert } from "@/lib/input-bridge"
1315
1416function formatTimestamp ( date : Date , format : TimestampFormat ) : string {
1517 const h = date . getHours ( )
@@ -124,9 +126,107 @@ function MessageLine({
124126 // Image URLs for preview
125127 const imageUrls = inlineImagePreviews ? extractImageUrls ( message . content ) : [ ]
126128
129+ let lastContextSelection = ""
130+
131+ const getSelectedText = ( ) => {
132+ if ( typeof window === "undefined" ) return ""
133+ const sel = window . getSelection ( )
134+ return sel ? sel . toString ( ) : ""
135+ }
136+
137+ const copyToClipboard = async ( text : string ) => {
138+ if ( ! text ) return
139+ try {
140+ if ( navigator . clipboard && navigator . clipboard . writeText ) {
141+ await navigator . clipboard . writeText ( text )
142+ return
143+ }
144+ } catch {
145+ // fall through to fallback
146+ }
147+ try {
148+ const textarea = document . createElement ( "textarea" )
149+ textarea . value = text
150+ textarea . style . position = "fixed"
151+ textarea . style . opacity = "0"
152+ document . body . appendChild ( textarea )
153+ textarea . select ( )
154+ document . execCommand ( "copy" )
155+ document . body . removeChild ( textarea )
156+ } catch {
157+ // ignore
158+ }
159+ }
160+
161+ const wrapWithContextMenu = ( node : React . ReactNode ) => {
162+ const nick = message . nickname
163+ const baseText = message . content
164+ const serverId = message . serverId
165+
166+ const handleContextMenuCapture : React . MouseEventHandler < HTMLDivElement > = ( ) => {
167+ const sel = getSelectedText ( ) . trim ( )
168+ if ( sel ) {
169+ lastContextSelection = sel
170+ }
171+ }
172+
173+ const handleCopySelection = async ( ) => {
174+ const txt = ( lastContextSelection || getSelectedText ( ) . trim ( ) )
175+ if ( txt ) await copyToClipboard ( txt )
176+ }
177+
178+ const handleCopyMessage = async ( ) => {
179+ if ( baseText ) await copyToClipboard ( baseText )
180+ }
181+
182+ const handleQuoteUser = ( ) => {
183+ if ( ! nick ) return
184+ emitInputInsert ( { text : `@${ nick } ` , mode : "append" } )
185+ }
186+
187+ const handleQuoteText = ( ) => {
188+ const sel = getSelectedText ( ) . trim ( )
189+ const text = sel || baseText
190+ if ( ! text ) return
191+ const quoted = `> ${ nick } : ${ text } `
192+ emitInputInsert ( { text : quoted , mode : "append" } )
193+ }
194+
195+ const handleWhoisUser = ( ) => {
196+ if ( ! nick ) return
197+ emitInputInsert ( { text : `/whois ${ nick } ` , mode : "replace" } )
198+ }
199+
200+ const handleDmUser = ( ) => {
201+ if ( ! nick || ! serverId ) return
202+ // Open or focus DM with this user
203+ useIRCStore . getState ( ) . openDM ( serverId , nick )
204+ }
205+
206+ return (
207+ < ContextMenu >
208+ < ContextMenuTrigger asChild >
209+ < div onContextMenuCapture = { handleContextMenuCapture } >
210+ { node }
211+ </ div >
212+ </ ContextMenuTrigger >
213+ < ContextMenuContent >
214+ < ContextMenuItem onSelect = { handleCopySelection } > Copy selection</ ContextMenuItem >
215+ < ContextMenuItem onSelect = { handleCopyMessage } > Copy entire message</ ContextMenuItem >
216+ < ContextMenuSeparator />
217+ < ContextMenuItem onSelect = { handleQuoteUser } > Quote user</ ContextMenuItem >
218+ < ContextMenuItem onSelect = { handleQuoteText } > Quote text</ ContextMenuItem >
219+ < ContextMenuSeparator />
220+ < ContextMenuItem onSelect = { handleWhoisUser } > Whois user</ ContextMenuItem >
221+ < ContextMenuItem onSelect = { handleDmUser } > DM user</ ContextMenuItem >
222+ </ ContextMenuContent >
223+ </ ContextMenu >
224+ )
225+ }
226+
127227 // Nick change
128228 if ( message . type === "nick_change" ) {
129- return (
229+ return wrapWithContextMenu (
130230 < div className = { cn ( rowBase , paddingClass ) } style = { { fontSize } } >
131231 < TimestampBadge date = { message . timestamp } format = { timestampFormat } fontSize = { fontSize } />
132232 < span className = "min-w-0 text-muted-foreground/60 italic" style = { { overflowWrap : "anywhere" , wordBreak : "break-word" } } >
@@ -138,7 +238,7 @@ function MessageLine({
138238
139239 // Kick
140240 if ( message . type === "kick" ) {
141- return (
241+ return wrapWithContextMenu (
142242 < div className = { cn ( rowBase , "bg-red-500/5 border-l-2 border-red-500/30" , paddingClass ) } style = { { fontSize } } >
143243 < TimestampBadge date = { message . timestamp } format = { timestampFormat } fontSize = { fontSize } />
144244 < span className = "min-w-0 text-red-400/80" style = { { overflowWrap : "anywhere" , wordBreak : "break-word" } } >
@@ -150,7 +250,7 @@ function MessageLine({
150250
151251 // Mode change
152252 if ( message . type === "mode" ) {
153- return (
253+ return wrapWithContextMenu (
154254 < div className = { cn ( rowBase , paddingClass ) } style = { { fontSize } } >
155255 < TimestampBadge date = { message . timestamp } format = { timestampFormat } fontSize = { fontSize } />
156256 < span className = "min-w-0 text-muted-foreground" style = { { overflowWrap : "anywhere" , wordBreak : "break-word" } } >
@@ -162,7 +262,7 @@ function MessageLine({
162262
163263 // CTCP
164264 if ( message . type === "ctcp" ) {
165- return (
265+ return wrapWithContextMenu (
166266 < div className = { cn ( rowBase , paddingClass ) } style = { { fontSize } } >
167267 < TimestampBadge date = { message . timestamp } format = { timestampFormat } fontSize = { fontSize } />
168268 < span className = "min-w-0 text-cyan-400/80" style = { { overflowWrap : "anywhere" , wordBreak : "break-word" } } >
@@ -173,7 +273,7 @@ function MessageLine({
173273 }
174274
175275 if ( message . type === "join" || message . type === "part" || message . type === "quit" ) {
176- return (
276+ return wrapWithContextMenu (
177277 < div className = { cn ( rowBase , paddingClass ) } style = { { fontSize } } >
178278 < TimestampBadge date = { message . timestamp } format = { timestampFormat } fontSize = { fontSize } />
179279 < span className = "min-w-0 text-muted-foreground/60 italic" style = { { overflowWrap : "anywhere" , wordBreak : "break-word" } } >
@@ -184,7 +284,7 @@ function MessageLine({
184284 }
185285
186286 if ( message . type === "system" ) {
187- return (
287+ return wrapWithContextMenu (
188288 < div className = { cn ( rowBase , paddingClass ) } style = { { fontSize } } >
189289 < TimestampBadge date = { message . timestamp } format = { timestampFormat } fontSize = { fontSize } />
190290 < span className = "min-w-0 text-muted-foreground" style = { { overflowWrap : "anywhere" , wordBreak : "break-word" } } > *** { message . content } </ span >
@@ -193,7 +293,7 @@ function MessageLine({
193293 }
194294
195295 if ( message . type === "notice" ) {
196- return (
296+ return wrapWithContextMenu (
197297 < div className = { cn ( rowBase , "bg-yellow-500/5 border-l-2 border-yellow-500/30" , paddingClass ) } style = { { fontSize } } >
198298 < TimestampBadge date = { message . timestamp } format = { timestampFormat } fontSize = { fontSize } />
199299 < span className = "shrink-0 font-bold text-yellow-500" > -{ message . nickname } -</ span >
@@ -205,7 +305,7 @@ function MessageLine({
205305 }
206306
207307 if ( message . type === "action" ) {
208- return (
308+ return wrapWithContextMenu (
209309 < div className = { cn ( rowBase , paddingClass ) } style = { { fontSize } } >
210310 < TimestampBadge date = { message . timestamp } format = { timestampFormat } fontSize = { fontSize } />
211311 < span className = "min-w-0 italic text-foreground/80" style = { { overflowWrap : "anywhere" , wordBreak : "break-word" } } >
@@ -231,7 +331,7 @@ function MessageLine({
231331 const prefix = user . isOp ? "@" : user . isVoiced ? "+" : ""
232332 return prefix ? `${ prefix } ${ message . nickname } ` : message . nickname
233333 } ) ( )
234- return (
334+ return wrapWithContextMenu (
235335 < div
236336 className = { cn (
237337 rowBase ,
0 commit comments