feat(desktop): live @mention and #channel preview in message composer (#149)

Co-authored-by: Wes <wesb@block.xyz>
This commit is contained in:
Bradley Axen
2026-03-21 09:59:02 -07:00
committed by GitHub
co-authored by Wes
parent 4a2243d358
commit 3bdd86755a
2 changed files with 121 additions and 23 deletions
@@ -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>
);
}
@@ -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<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.
@@ -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<HTMLTextAreaElement>) => {
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({
</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}