Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions desktop/src/features/messages/ui/ComposerMentionOverlay.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div
className="whitespace-pre-wrap break-words px-0 py-0 text-sm leading-6"
style={{ transform: `translateY(-${scrollTop}px)` }}
>
{
segments.reduce<{ offset: number; nodes: React.ReactNode[] }>(
(acc, segment) => {
const key = `${acc.offset}`;
if (segment.type === "mention" || segment.type === "channel") {
acc.nodes.push(
<span
className="rounded-sm bg-primary/15 text-primary"
key={key}
>
{segment.value}
</span>,
);
} else {
acc.nodes.push(
<span className="text-foreground" key={key}>
{segment.value}
</span>,
);
}
acc.offset += segment.value.length;
return acc;
},
{ offset: 0, nodes: [] },
).nodes
}
</div>
);
}
68 changes: 45 additions & 23 deletions desktop/src/features/messages/ui/MessageComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -69,6 +70,7 @@ export function MessageComposer({
const pendingSelectionRef = React.useRef<number | null>(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<number | null>(null);

// Keep contentRef in sync — no extra re-render, just a ref assignment.
Expand Down Expand Up @@ -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 };
Expand Down Expand Up @@ -309,6 +312,13 @@ export function MessageComposer({
[onUploaded],
);

const handleScroll = React.useCallback(
(event: React.UIEvent<HTMLTextAreaElement>) => {
setComposerScrollTop(event.currentTarget.scrollTop);
},
[],
);

const submitMessage = React.useCallback(async () => {
const trimmed = contentRef.current.trim();
const currentPendingImeta = pendingImetaRef.current;
Expand Down Expand Up @@ -556,29 +566,41 @@ export function MessageComposer({
</div>
) : null}

<Textarea
aria-label="Message channel"
className="min-h-0 resize-none overflow-y-hidden border-0 bg-transparent px-0 py-0 text-sm leading-6 shadow-none focus-visible:ring-0"
data-testid="message-input"
disabled={disabled}
onChange={handleChange}
onKeyDown={handleKeyDown}
onPaste={(e) => {
void handlePaste(e);
}}
onSelect={(event) => {
updateDraftSelection(event.currentTarget);
}}
placeholder={
placeholder ??
(replyTarget
? `Reply to ${replyTarget.author} in #${channelName}`
: `Message #${channelName}`)
}
ref={textareaRef}
rows={1}
value={content}
/>
<div className="relative">
<div
aria-hidden
className="pointer-events-none absolute inset-0 overflow-hidden"
>
<ComposerMentionOverlay
content={content}
scrollTop={composerScrollTop}
/>
</div>
<Textarea
aria-label="Message channel"
className="min-h-0 resize-none overflow-y-hidden border-0 bg-transparent px-0 py-0 text-sm leading-6 shadow-none focus-visible:ring-0 caret-foreground text-transparent selection:bg-primary/20 selection:text-transparent"
data-testid="message-input"
disabled={disabled}
onChange={handleChange}
onKeyDown={handleKeyDown}
onPaste={(e) => {
void handlePaste(e);
}}
onScroll={handleScroll}
onSelect={(event) => {
updateDraftSelection(event.currentTarget);
}}
placeholder={
placeholder ??
(replyTarget
? `Reply to ${replyTarget.author} in #${channelName}`
: `Message #${channelName}`)
}
ref={textareaRef}
rows={1}
value={content}
/>
</div>

<MessageComposerToolbar
composerDisabled={disabled}
Expand Down
Loading