diff --git a/desktop/src/features/messages/ui/ComposerMentionOverlay.tsx b/desktop/src/features/messages/ui/ComposerMentionOverlay.tsx new file mode 100644 index 000000000..dd28334ea --- /dev/null +++ b/desktop/src/features/messages/ui/ComposerMentionOverlay.tsx @@ -0,0 +1,76 @@ +import type React from "react"; + +type Segment = { type: "text" | "mention" | "channel"; value: string }; + +const MENTION_OR_CHANNEL_RE = /@\S+|#[a-zA-Z0-9][\w-]*/g; + +function parseSegments(text: string): Segment[] { + const segments: Segment[] = []; + let lastIndex = 0; + + for (const match of text.matchAll(MENTION_OR_CHANNEL_RE)) { + const matchStart = match.index; + if (matchStart > lastIndex) { + segments.push({ type: "text", value: text.slice(lastIndex, matchStart) }); + } + + const value = match[0]; + segments.push({ + type: value.startsWith("@") ? "mention" : "channel", + value, + }); + lastIndex = matchStart + value.length; + } + + if (lastIndex < text.length) { + segments.push({ type: "text", value: text.slice(lastIndex) }); + } + + return segments; +} + +type ComposerMentionOverlayProps = { + content: string; + scrollTop: number; +}; + +export function ComposerMentionOverlay({ + content, + scrollTop, +}: ComposerMentionOverlayProps) { + const segments = parseSegments(content); + + return ( +
+ { + segments.reduce<{ offset: number; nodes: React.ReactNode[] }>( + (acc, segment) => { + const key = `${acc.offset}`; + if (segment.type === "mention" || segment.type === "channel") { + acc.nodes.push( + + {segment.value} + , + ); + } else { + acc.nodes.push( + + {segment.value} + , + ); + } + acc.offset += segment.value.length; + return acc; + }, + { offset: 0, nodes: [] }, + ).nodes + } +
+ ); +} diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index 3aea663f0..3e5f2f003 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -11,6 +11,7 @@ import { import { Button } from "@/shared/ui/button"; import { Textarea } from "@/shared/ui/textarea"; import { ChannelAutocomplete } from "./ChannelAutocomplete"; +import { ComposerMentionOverlay } from "./ComposerMentionOverlay"; import { MentionAutocomplete, type MentionSuggestion, @@ -69,6 +70,7 @@ export function MessageComposer({ const pendingSelectionRef = React.useRef(null); const draftSelectionRef = React.useRef({ end: 0, start: 0 }); const [isEmojiPickerOpen, setIsEmojiPickerOpen] = React.useState(false); + const [composerScrollTop, setComposerScrollTop] = React.useState(0); const lineHeightRef = React.useRef(null); // Keep contentRef in sync — no extra re-render, just a ref assignment. @@ -101,6 +103,7 @@ export function MessageComposer({ setPendingImeta([]); setUploadState({ status: "idle" }); setIsEmojiPickerOpen(false); + setComposerScrollTop(0); mentions.clearMentions(); channelLinks.clearChannels(); draftSelectionRef.current = { end: 0, start: 0 }; @@ -309,6 +312,13 @@ export function MessageComposer({ [onUploaded], ); + const handleScroll = React.useCallback( + (event: React.UIEvent) => { + setComposerScrollTop(event.currentTarget.scrollTop); + }, + [], + ); + const submitMessage = React.useCallback(async () => { const trimmed = contentRef.current.trim(); const currentPendingImeta = pendingImetaRef.current; @@ -556,29 +566,41 @@ export function MessageComposer({ ) : null} -