mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Implement composer emoji picker and tighten message spacing (#82)
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
import { SmilePlus } from "lucide-react";
|
||||
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
|
||||
import { DEFAULT_EMOJI_OPTIONS } from "./messageTimelineUtils";
|
||||
|
||||
type ComposerEmojiPickerProps = {
|
||||
disabled?: boolean;
|
||||
onEmojiSelect: (emoji: string) => void;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onTriggerMouseDown: () => void;
|
||||
open: boolean;
|
||||
};
|
||||
|
||||
export function ComposerEmojiPicker({
|
||||
disabled = false,
|
||||
onEmojiSelect,
|
||||
onOpenChange,
|
||||
onTriggerMouseDown,
|
||||
open,
|
||||
}: ComposerEmojiPickerProps) {
|
||||
return (
|
||||
<Popover onOpenChange={onOpenChange} open={open}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
aria-label="Insert emoji"
|
||||
data-testid="composer-emoji-button"
|
||||
disabled={disabled}
|
||||
onMouseDown={onTriggerMouseDown}
|
||||
size="icon"
|
||||
title="Insert emoji"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<SmilePlus className="h-4 w-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
className="w-56 rounded-2xl p-3"
|
||||
side="top"
|
||||
sideOffset={10}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">
|
||||
Emoji
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Insert an emoji into your message.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-1">
|
||||
{DEFAULT_EMOJI_OPTIONS.map((emoji) => (
|
||||
<button
|
||||
aria-label={`Insert ${emoji}`}
|
||||
className="flex h-10 items-center justify-center rounded-xl border border-border/70 bg-muted/40 text-lg transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
data-testid="composer-emoji-option"
|
||||
key={emoji}
|
||||
onClick={() => {
|
||||
onEmojiSelect(emoji);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{emoji}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Paperclip, SendHorizontal, SmilePlus } from "lucide-react";
|
||||
import { Paperclip, SendHorizontal } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { useManagedAgentsQuery } from "@/features/agents/hooks";
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from "@/shared/api/tauri";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Textarea } from "@/shared/ui/textarea";
|
||||
import { ComposerEmojiPicker } from "./ComposerEmojiPicker";
|
||||
import {
|
||||
MentionAutocomplete,
|
||||
type MentionSuggestion,
|
||||
@@ -36,16 +37,11 @@ type MessageComposerProps = {
|
||||
|
||||
const MAX_TEXTAREA_ROWS = 4;
|
||||
|
||||
/**
|
||||
* Detect an @mention query at the cursor position.
|
||||
* Returns the query string (after @) or null if no active mention trigger.
|
||||
*/
|
||||
function detectMentionQuery(
|
||||
value: string,
|
||||
cursorPosition: number,
|
||||
): { query: string; startIndex: number } | null {
|
||||
const beforeCursor = value.slice(0, cursorPosition);
|
||||
// Find the last @ that is preceded by whitespace or is at the start
|
||||
const match = beforeCursor.match(/(?:^|[\s])@([^\s]*)$/);
|
||||
if (!match) {
|
||||
return null;
|
||||
@@ -69,14 +65,14 @@ export function MessageComposer({
|
||||
const [content, setContent] = React.useState("");
|
||||
const textareaRef = React.useRef<HTMLTextAreaElement>(null);
|
||||
const pendingSelectionRef = React.useRef<number | null>(null);
|
||||
const draftSelectionRef = React.useRef({ end: 0, start: 0 });
|
||||
const [isEmojiPickerOpen, setIsEmojiPickerOpen] = React.useState(false);
|
||||
|
||||
// Mention state
|
||||
const [mentionQuery, setMentionQuery] = React.useState<string | null>(null);
|
||||
const [mentionStartIndex, setMentionStartIndex] = React.useState(0);
|
||||
const [mentionSelectedIndex, setMentionSelectedIndex] = React.useState(0);
|
||||
const mentionMapRef = React.useRef<Map<string, string>>(new Map());
|
||||
|
||||
// Upload state
|
||||
const [uploadState, setUploadState] = React.useState<{
|
||||
status: "idle" | "uploading" | "error";
|
||||
message?: string;
|
||||
@@ -144,6 +140,10 @@ export function MessageComposer({
|
||||
const nextCursor = before.length + inserted.length;
|
||||
|
||||
mentionMapRef.current.set(displayName, suggestion.pubkey);
|
||||
draftSelectionRef.current = {
|
||||
end: nextCursor,
|
||||
start: nextCursor,
|
||||
};
|
||||
pendingSelectionRef.current = nextCursor;
|
||||
setContent(nextContent);
|
||||
setMentionQuery(null);
|
||||
@@ -156,10 +156,6 @@ export function MessageComposer({
|
||||
(text: string): string[] => {
|
||||
const pubkeys: string[] = [];
|
||||
|
||||
/** Test whether `@name` appears as a mention token in `text`.
|
||||
* Left boundary: start-of-string or whitespace.
|
||||
* Right boundary: end-of-string, whitespace, or punctuation.
|
||||
* Case-insensitive. Prevents @Ann from matching @Anna or foo@Ann. */
|
||||
const hasMention = (name: string): boolean => {
|
||||
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const pattern = new RegExp(
|
||||
@@ -169,18 +165,15 @@ export function MessageComposer({
|
||||
return pattern.test(text);
|
||||
};
|
||||
|
||||
// 1. Check the autocomplete-inserted mention map.
|
||||
for (const [displayName, pubkey] of mentionMapRef.current) {
|
||||
if (hasMention(displayName)) {
|
||||
pubkeys.push(pubkey);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Fall back to scanning channel members for manually-typed @mentions
|
||||
// that bypassed autocomplete (e.g. user typed "@Daphne" without selecting).
|
||||
for (const member of members) {
|
||||
if (pubkeys.includes(member.pubkey)) {
|
||||
continue; // already matched via autocomplete map
|
||||
continue;
|
||||
}
|
||||
const name =
|
||||
member.displayName ??
|
||||
@@ -195,7 +188,41 @@ export function MessageComposer({
|
||||
[members, managedAgentNamesByPubkey],
|
||||
);
|
||||
|
||||
// Shared handler: got a descriptor back from any upload path.
|
||||
const updateDraftSelection = React.useCallback(
|
||||
(target: HTMLTextAreaElement | null) => {
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
|
||||
draftSelectionRef.current = {
|
||||
end: target.selectionEnd ?? target.value.length,
|
||||
start: target.selectionStart ?? target.value.length,
|
||||
};
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const insertEmoji = React.useCallback(
|
||||
(emoji: string) => {
|
||||
const { end, start } = draftSelectionRef.current;
|
||||
const nextStart = Math.min(start, content.length);
|
||||
const nextEnd = Math.min(end, content.length);
|
||||
const nextCursor = nextStart + emoji.length;
|
||||
const nextContent = `${content.slice(0, nextStart)}${emoji}${content.slice(nextEnd)}`;
|
||||
|
||||
draftSelectionRef.current = {
|
||||
end: nextCursor,
|
||||
start: nextCursor,
|
||||
};
|
||||
pendingSelectionRef.current = nextCursor;
|
||||
setContent(nextContent);
|
||||
setIsEmojiPickerOpen(false);
|
||||
setMentionQuery(null);
|
||||
setMentionSelectedIndex(0);
|
||||
},
|
||||
[content],
|
||||
);
|
||||
|
||||
const onUploaded = React.useCallback((descriptor: BlobDescriptor) => {
|
||||
const markdown = `\n\n`;
|
||||
setContent((prev) => prev + markdown);
|
||||
@@ -203,8 +230,6 @@ export function MessageComposer({
|
||||
setUploadState({ status: "idle" });
|
||||
}, []);
|
||||
|
||||
// 📎 Paperclip: native file dialog + read + upload, all in trusted Rust.
|
||||
// The renderer never touches the filesystem.
|
||||
const handlePaperclip = React.useCallback(async () => {
|
||||
setUploadState({ status: "uploading" });
|
||||
try {
|
||||
@@ -212,15 +237,13 @@ export function MessageComposer({
|
||||
if (descriptor) {
|
||||
onUploaded(descriptor);
|
||||
} else {
|
||||
setUploadState({ status: "idle" }); // user cancelled dialog
|
||||
setUploadState({ status: "idle" });
|
||||
}
|
||||
} catch (err) {
|
||||
setUploadState({ status: "error", message: String(err) });
|
||||
}
|
||||
}, [onUploaded]);
|
||||
|
||||
// 🖱️ Drop: read bytes in JS, send via IPC to Rust for upload.
|
||||
// No filesystem access needed — bytes come from the drag event.
|
||||
const handleDrop = React.useCallback(
|
||||
async (event: React.DragEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
@@ -263,7 +286,6 @@ export function MessageComposer({
|
||||
[],
|
||||
);
|
||||
|
||||
// 📋 Paste: read bytes from clipboard, send via IPC to Rust for upload.
|
||||
const handlePaste = React.useCallback(
|
||||
async (event: React.ClipboardEvent<HTMLTextAreaElement>) => {
|
||||
const items = Array.from(event.clipboardData.items);
|
||||
@@ -301,7 +323,6 @@ export function MessageComposer({
|
||||
|
||||
const pubkeys = extractMentionPubkeys(trimmed);
|
||||
|
||||
// Build imeta tags from pending descriptors
|
||||
const mediaTags =
|
||||
pendingImeta.length > 0
|
||||
? pendingImeta.map((d) => [
|
||||
@@ -320,9 +341,11 @@ export function MessageComposer({
|
||||
const savedImeta = [...pendingImeta];
|
||||
|
||||
setContent("");
|
||||
draftSelectionRef.current = { end: 0, start: 0 };
|
||||
setPendingImeta([]);
|
||||
mentionMapRef.current.clear();
|
||||
setMentionQuery(null);
|
||||
setIsEmojiPickerOpen(false);
|
||||
|
||||
try {
|
||||
await onSend(trimmed, pubkeys, mediaTags);
|
||||
@@ -352,6 +375,7 @@ export function MessageComposer({
|
||||
const nextContent = event.target.value;
|
||||
const cursorPos = event.target.selectionStart;
|
||||
setContent(nextContent);
|
||||
updateDraftSelection(event.target);
|
||||
|
||||
const mention = detectMentionQuery(nextContent, cursorPos);
|
||||
if (mention) {
|
||||
@@ -362,12 +386,11 @@ export function MessageComposer({
|
||||
setMentionQuery(null);
|
||||
}
|
||||
},
|
||||
[],
|
||||
[updateDraftSelection],
|
||||
);
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(event: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
// Handle mention autocomplete keyboard navigation
|
||||
if (isMentionOpen) {
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
@@ -416,6 +439,10 @@ export function MessageComposer({
|
||||
|
||||
event.preventDefault();
|
||||
pendingSelectionRef.current = selectionStart + 1;
|
||||
draftSelectionRef.current = {
|
||||
end: selectionStart + 1,
|
||||
start: selectionStart + 1,
|
||||
};
|
||||
setContent(nextContent);
|
||||
return;
|
||||
}
|
||||
@@ -457,6 +484,7 @@ export function MessageComposer({
|
||||
|
||||
const pendingSelection = pendingSelectionRef.current;
|
||||
if (pendingSelection !== null) {
|
||||
textarea.focus();
|
||||
textarea.setSelectionRange(pendingSelection, pendingSelection);
|
||||
pendingSelectionRef.current = null;
|
||||
}
|
||||
@@ -540,6 +568,9 @@ export function MessageComposer({
|
||||
onPaste={(e) => {
|
||||
void handlePaste(e);
|
||||
}}
|
||||
onSelect={(event) => {
|
||||
updateDraftSelection(event.currentTarget);
|
||||
}}
|
||||
placeholder={
|
||||
placeholder ??
|
||||
(replyTarget
|
||||
@@ -569,9 +600,15 @@ export function MessageComposer({
|
||||
<Paperclip className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button disabled size="icon" type="button" variant="ghost">
|
||||
<SmilePlus className="h-4 w-4" />
|
||||
</Button>
|
||||
<ComposerEmojiPicker
|
||||
disabled={disabled}
|
||||
onEmojiSelect={insertEmoji}
|
||||
onOpenChange={setIsEmojiPickerOpen}
|
||||
onTriggerMouseDown={() => {
|
||||
updateDraftSelection(textareaRef.current);
|
||||
}}
|
||||
open={isEmojiPickerOpen}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
|
||||
@@ -122,7 +122,7 @@ export function MessageRow({
|
||||
|
||||
<article
|
||||
className={cn(
|
||||
"group/message flex gap-3 rounded-2xl px-2 py-2 transition-colors",
|
||||
"group/message flex gap-3 rounded-2xl px-2 py-1.5 transition-colors",
|
||||
message.highlighted ? "bg-primary/10 ring-1 ring-primary/30" : "",
|
||||
activeReplyTargetId === message.id
|
||||
? "bg-muted/60 ring-1 ring-border"
|
||||
|
||||
@@ -66,7 +66,7 @@ export function MessageTimeline({
|
||||
ref={timelineRef}
|
||||
>
|
||||
<div
|
||||
className="mx-auto flex w-full max-w-4xl flex-col gap-4"
|
||||
className="mx-auto flex w-full max-w-4xl flex-col gap-3"
|
||||
ref={contentRef}
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { TimelineReaction } from "@/features/messages/types";
|
||||
|
||||
const BOTTOM_THRESHOLD_PX = 72;
|
||||
const DEFAULT_REACTION_OPTIONS = [
|
||||
export const DEFAULT_EMOJI_OPTIONS = [
|
||||
"👍",
|
||||
"❤️",
|
||||
"🎉",
|
||||
@@ -32,7 +32,7 @@ export function getReactionOptions(reactions: TimelineReaction[]) {
|
||||
options.push(reaction.emoji);
|
||||
}
|
||||
|
||||
for (const emoji of DEFAULT_REACTION_OPTIONS) {
|
||||
for (const emoji of DEFAULT_EMOJI_OPTIONS) {
|
||||
if (seen.has(emoji)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -64,6 +64,26 @@ test("message input clears after send", async ({ page }) => {
|
||||
await expect(input).toHaveValue("");
|
||||
});
|
||||
|
||||
test("emoji picker inserts emoji into the draft and keeps focus in the composer", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
await page.getByTestId("channel-general").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
||||
|
||||
const input = page.getByTestId("message-input");
|
||||
await input.fill("Ship");
|
||||
|
||||
await page.getByTestId("composer-emoji-button").click();
|
||||
await page.getByRole("button", { name: "Insert 🚀" }).click();
|
||||
|
||||
await expect(input).toHaveValue("Ship🚀");
|
||||
await expect(input).toBeFocused();
|
||||
|
||||
await input.pressSequentially(" now");
|
||||
await expect(input).toHaveValue("Ship🚀 now");
|
||||
});
|
||||
|
||||
test("empty message cannot be sent", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.getByTestId("channel-general").click();
|
||||
|
||||
Reference in New Issue
Block a user