mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(desktop): live @mention and #channel preview in message composer (#149)
Co-authored-by: Wes <wesb@block.xyz>
This commit is contained in:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user