Implement composer emoji picker and tighten message spacing (#82)

This commit is contained in:
Wes
2026-03-16 12:31:41 -07:00
committed by GitHub
parent fa939ec1c3
commit c8dd3890fd
6 changed files with 163 additions and 33 deletions
@@ -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![image](${descriptor.url})\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;
}
+20
View File
@@ -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();