feat(conversations): discord-style composer + emoji picker

Redesign message composer to match Discord's compact input bar
with + button, inline editor, and action icons. Add emoji-mart
picker in popover that follows the active theme. Switch channel
page to CSS grid layout to fix composer overflow issues.
This commit is contained in:
Nicholai Vogel 2026-02-16 02:51:14 -07:00
parent ec095fa1db
commit 1523d576b3
7 changed files with 330 additions and 105 deletions

View File

@ -26,6 +26,8 @@
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@emoji-mart/data": "^1.2.1",
"@emoji-mart/react": "^1.1.1",
"@hookform/resolvers": "^5.2.2",
"@json-render/core": "^0.4.0",
"@json-render/react": "^0.4.0",
@ -372,6 +374,10 @@
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="],
"@emoji-mart/data": ["@emoji-mart/data@1.2.1", "", {}, "sha512-no2pQMWiBy6gpBEiqGeU77/bFejDqUTRY7KX+0+iur13op3bqUsXdnwoZs6Xb1zbv0gAj5VvS1PWoUUckSr5Dw=="],
"@emoji-mart/react": ["@emoji-mart/react@1.1.1", "", { "peerDependencies": { "emoji-mart": "^5.2", "react": "^16.8 || ^17 || ^18" } }, "sha512-NMlFNeWgv1//uPsvLxvGQoIerPuVdXwK/EUek8OOkJ6wVOWPUizRBJU0hDqWZCOROVpfBgCemaC3m6jDOXi03g=="],
"@esbuild-kit/core-utils": ["@esbuild-kit/core-utils@3.3.2", "", { "dependencies": { "esbuild": "~0.18.20", "source-map-support": "^0.5.21" } }, "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ=="],
"@esbuild-kit/esm-loader": ["@esbuild-kit/esm-loader@2.6.5", "", { "dependencies": { "@esbuild-kit/core-utils": "^3.3.2", "get-tsconfig": "^4.7.0" } }, "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA=="],
@ -1554,6 +1560,8 @@
"embla-carousel-reactive-utils": ["embla-carousel-reactive-utils@8.6.0", "", { "peerDependencies": { "embla-carousel": "8.6.0" } }, "sha512-fMVUDUEx0/uIEDM0Mz3dHznDhfX+znCCDCeIophYb1QGVM7YThSWX+wz11zlYwWFOr74b4QLGg0hrGPJeG2s4A=="],
"emoji-mart": ["emoji-mart@5.6.0", "", {}, "sha512-eJp3QRe79pjwa+duv+n7+5YsNhRcMl812EcFVwrnRvYKoNPoQb5qxU8DG6Bgwji0akHdp6D4Ln6tYLG58MFSow=="],
"emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
"encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],

View File

@ -53,6 +53,8 @@
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@emoji-mart/data": "^1.2.1",
"@emoji-mart/react": "^1.1.1",
"@hookform/resolvers": "^5.2.2",
"@json-render/core": "^0.4.0",
"@json-render/react": "^0.4.0",

View File

@ -26,7 +26,10 @@ export default async function ChannelPage({
return (
<>
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
<div
className="grid h-full overflow-hidden"
style={{ gridTemplateRows: "auto 1fr auto" }}
>
<ChannelHeader
name={channel.name}
description={channel.description ?? undefined}

View File

@ -231,6 +231,23 @@
height: 0;
}
/* constrain composer editor height */
.composer-editor .ProseMirror {
max-height: 200px;
overflow-y: auto;
}
@media (max-width: 639px) {
.composer-editor .ProseMirror {
max-height: 30vh;
}
}
/* emoji-mart theme overrides */
em-emoji-picker {
--border-radius: 12px;
max-height: 350px;
}
/* mention pill styling in messages and editor */
.mention {
border-radius: calc(var(--radius) - 4px);

View File

@ -20,7 +20,7 @@ export function MainContent({
<div
{...rest}
className={cn(
"flex flex-col overflow-x-hidden min-w-0",
"flex flex-col overflow-x-hidden min-w-0 min-h-0",
"transition-[flex,opacity] duration-300 ease-in-out",
isCollapsed
? "flex-[0_0_0%] opacity-0 overflow-hidden pointer-events-none"
@ -31,7 +31,7 @@ export function MainContent({
)}
>
<div className={cn(
"@container/main flex flex-1 flex-col min-w-0",
"@container/main flex flex-1 flex-col min-w-0 min-h-0",
isConversations && "overflow-hidden"
)}>
{children}

View File

@ -6,15 +6,79 @@ import StarterKit from "@tiptap/starter-kit"
import Placeholder from "@tiptap/extension-placeholder"
import Link from "@tiptap/extension-link"
import Mention from "@tiptap/extension-mention"
import { Bold, Italic, Code, Link as LinkIcon, List, ListOrdered, Send, Paperclip, Smile } from "lucide-react"
import {
Bold,
Italic,
Code,
List,
ListOrdered,
Plus,
Smile,
Sticker,
Gift,
} from "lucide-react"
import { Button } from "@/components/ui/button"
import { Separator } from "@/components/ui/separator"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import { sendMessage } from "@/app/actions/chat-messages"
import { setTyping } from "@/app/actions/conversations-realtime"
import { useRouter } from "next/navigation"
import { useTheme } from "next-themes"
import { cn } from "@/lib/utils"
import { createMentionSuggestion } from "./mention-suggestion"
// lazy-load emoji picker to keep initial bundle small
const EmojiPicker = React.lazy(() =>
import("@emoji-mart/react").then((mod) => ({ default: mod.default })),
)
type EmojiData = {
readonly native: string
}
/** read a CSS custom property and resolve it to an "R, G, B" string */
function cssVarToRgb(varName: string): string | null {
if (typeof window === "undefined") return null
const style = getComputedStyle(document.documentElement)
const raw = style.getPropertyValue(varName).trim()
if (!raw) return null
// create a temporary element to resolve the color
const el = document.createElement("div")
el.style.color = raw
document.body.appendChild(el)
const computed = getComputedStyle(el).color
document.body.removeChild(el)
// computed is like "rgb(R, G, B)" or "rgba(R, G, B, A)"
const match = computed.match(
/rgba?\(\s*([\d.]+),?\s*([\d.]+),?\s*([\d.]+)/,
)
if (!match) return null
return `${Math.round(Number(match[1]))}, ${Math.round(Number(match[2]))}, ${Math.round(Number(match[3]))}`
}
function useEmojiThemeVars(): Record<string, string> {
const [vars, setVars] = React.useState<Record<string, string>>({})
const { resolvedTheme } = useTheme()
React.useEffect(() => {
const bg = cssVarToRgb("--popover")
const fg = cssVarToRgb("--popover-foreground")
const input = cssVarToRgb("--muted")
const next: Record<string, string> = {}
if (bg) next["--em-rgb-background"] = bg
if (fg) next["--em-rgb-color"] = fg
if (input) next["--em-rgb-input"] = input
setVars(next)
}, [resolvedTheme])
return vars
}
type MessageComposerProps = {
readonly channelId: string
readonly channelName: string
@ -29,7 +93,9 @@ type MentionInput = {
targetId: string | null
}
function extractMentions(json: Record<string, unknown>): Array<MentionInput> {
function extractMentions(
json: Record<string, unknown>,
): Array<MentionInput> {
const mentions: Array<MentionInput> = []
function walk(node: Record<string, unknown>) {
@ -42,7 +108,10 @@ function extractMentions(json: Record<string, unknown>): Array<MentionInput> {
} else if (id === "here") {
mentions.push({ mentionType: "here", targetId: null })
} else if (id === "compass-agent") {
mentions.push({ mentionType: "agent", targetId: "compass-agent" })
mentions.push({
mentionType: "agent",
targetId: "compass-agent",
})
} else {
mentions.push({ mentionType: "user", targetId: id })
}
@ -68,10 +137,13 @@ export function MessageComposer({
onSent,
}: MessageComposerProps) {
const router = useRouter()
const { resolvedTheme } = useTheme()
const emojiThemeVars = useEmojiThemeVars()
const [isSending, setIsSending] = React.useState(false)
const [error, setError] = React.useState<string | null>(null)
const [showToolbar, setShowToolbar] = React.useState(false)
const [emojiOpen, setEmojiOpen] = React.useState(false)
// typing indicator - debounce to avoid spamming server
const lastTypingSentRef = React.useRef<number>(0)
const TYPING_DEBOUNCE_MS = 3000
@ -80,7 +152,10 @@ export function MessageComposer({
if (now - lastTypingSentRef.current >= TYPING_DEBOUNCE_MS) {
lastTypingSentRef.current = now
setTyping(channelId).catch((err) => {
console.error("[MessageComposer] typing indicator error:", err)
console.error(
"[MessageComposer] typing indicator error:",
err,
)
})
}
}, [channelId])
@ -94,7 +169,7 @@ export function MessageComposer({
blockquote: false,
}),
Placeholder.configure({
placeholder: placeholder ?? `Message #${channelName}...`,
placeholder: placeholder ?? `Message #${channelName}`,
}),
Link.configure({
openOnClick: false,
@ -112,7 +187,11 @@ export function MessageComposer({
],
editorProps: {
attributes: {
class: "prose prose-sm max-w-none focus:outline-none min-h-[80px] p-3",
class: cn(
"prose prose-sm max-w-none focus:outline-none",
"min-h-[22px] py-[11px] px-0",
"text-sm leading-[22px]",
),
},
},
onUpdate: () => {
@ -121,6 +200,15 @@ export function MessageComposer({
},
})
const handleEmojiSelect = React.useCallback(
(emoji: EmojiData) => {
if (!editor) return
editor.chain().focus().insertContent(emoji.native).run()
setEmojiOpen(false)
},
[editor],
)
const handleSend = React.useCallback(async () => {
if (!editor || isSending) return
@ -131,7 +219,9 @@ export function MessageComposer({
setError(null)
try {
const mentions = extractMentions(editor.getJSON() as Record<string, unknown>)
const mentions = extractMentions(
editor.getJSON() as Record<string, unknown>,
)
const contentHtml = editor.getHTML()
const result = await sendMessage({
@ -150,7 +240,9 @@ export function MessageComposer({
setError(result.error ?? "Failed to send message")
}
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to send message")
setError(
err instanceof Error ? err.message : "Failed to send message",
)
} finally {
setIsSending(false)
}
@ -175,102 +267,205 @@ export function MessageComposer({
}, [editor, handleSend])
return (
<div className="shrink-0 border-t bg-background p-4">
<div className="rounded-lg border bg-background">
<EditorContent editor={editor} className="max-h-[200px] overflow-y-auto" />
{editor && (
<div className="flex items-center justify-between border-t p-2">
<div className="flex items-center gap-1">
<div className="min-h-[68px] px-2 pb-4 pt-2 sm:px-4">
{/* formatting toolbar */}
{editor && showToolbar && (
<div className="mb-1.5 flex items-center gap-0.5 pl-10 sm:pl-12">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => editor.chain().focus().toggleBold().run()}
disabled={!editor.can().chain().focus().toggleBold().run()}
className="h-7 w-7 rounded-md text-muted-foreground hover:text-foreground"
onClick={() =>
editor.chain().focus().toggleBold().run()
}
disabled={
!editor.can().chain().focus().toggleBold().run()
}
>
<Bold className={cn(
<Bold
className={cn(
"h-3.5 w-3.5",
editor.isActive("bold") && "text-primary"
)} />
editor.isActive("bold") && "text-primary",
)}
/>
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => editor.chain().focus().toggleItalic().run()}
disabled={!editor.can().chain().focus().toggleItalic().run()}
className="h-7 w-7 rounded-md text-muted-foreground hover:text-foreground"
onClick={() =>
editor.chain().focus().toggleItalic().run()
}
disabled={
!editor.can().chain().focus().toggleItalic().run()
}
>
<Italic className={cn(
<Italic
className={cn(
"h-3.5 w-3.5",
editor.isActive("italic") && "text-primary"
)} />
editor.isActive("italic") && "text-primary",
)}
/>
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => editor.chain().focus().toggleCode().run()}
disabled={!editor.can().chain().focus().toggleCode().run()}
className="h-7 w-7 rounded-md text-muted-foreground hover:text-foreground"
onClick={() =>
editor.chain().focus().toggleCode().run()
}
disabled={
!editor.can().chain().focus().toggleCode().run()
}
>
<Code className={cn(
<Code
className={cn(
"h-3.5 w-3.5",
editor.isActive("code") && "text-primary"
)} />
editor.isActive("code") && "text-primary",
)}
/>
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => editor.chain().focus().toggleBulletList().run()}
className="h-7 w-7 rounded-md text-muted-foreground hover:text-foreground"
onClick={() =>
editor.chain().focus().toggleBulletList().run()
}
>
<List className={cn(
<List
className={cn(
"h-3.5 w-3.5",
editor.isActive("bulletList") && "text-primary"
)} />
editor.isActive("bulletList") && "text-primary",
)}
/>
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => editor.chain().focus().toggleOrderedList().run()}
className="h-7 w-7 rounded-md text-muted-foreground hover:text-foreground"
onClick={() =>
editor.chain().focus().toggleOrderedList().run()
}
>
<ListOrdered className={cn(
<ListOrdered
className={cn(
"h-3.5 w-3.5",
editor.isActive("orderedList") && "text-primary"
)} />
</Button>
<Separator orientation="vertical" className="mx-1 h-6" />
<Button variant="ghost" size="icon" className="h-7 w-7" disabled>
<Paperclip className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7">
<Smile className="h-3.5 w-3.5" />
</Button>
</div>
<Button
size="sm"
onClick={handleSend}
disabled={isSending || !editor.getText().trim()}
>
<Send className="mr-1.5 h-3.5 w-3.5" />
Send
editor.isActive("orderedList") && "text-primary",
)}
/>
</Button>
</div>
)}
{/* main composer bar */}
<div
className={cn(
"relative flex items-end rounded-lg",
"bg-muted/50 ring-1 ring-border",
"focus-within:ring-2 focus-within:ring-ring",
"transition-shadow",
)}
>
{/* + button */}
<button
type="button"
className={cn(
"flex h-[44px] w-[44px] shrink-0 items-center justify-center",
"text-muted-foreground",
"hover:text-foreground transition-colors",
)}
onClick={() => setShowToolbar((prev) => !prev)}
aria-label="Toggle formatting"
>
<Plus
className={cn(
"h-5 w-5 transition-transform duration-200",
showToolbar && "rotate-45",
)}
/>
</button>
{/* editor area */}
<EditorContent
editor={editor}
className="composer-editor min-w-0 flex-1"
/>
{/* right-side action icons */}
<div className="flex h-[44px] shrink-0 items-center gap-0 pr-1 sm:pr-1.5">
<button
type="button"
className={cn(
"hidden sm:flex",
"h-8 w-8 items-center justify-center rounded-md",
"text-muted-foreground hover:text-foreground transition-colors",
)}
aria-label="Stickers"
>
<Sticker className="h-[18px] w-[18px]" />
</button>
<button
type="button"
className={cn(
"hidden sm:flex",
"h-8 w-8 items-center justify-center rounded-md",
"text-muted-foreground hover:text-foreground transition-colors",
)}
aria-label="GIF"
>
<Gift className="h-[18px] w-[18px]" />
</button>
{/* emoji picker */}
<Popover open={emojiOpen} onOpenChange={setEmojiOpen}>
<PopoverTrigger asChild>
<button
type="button"
className={cn(
"flex h-8 w-8 items-center justify-center rounded-md",
"text-muted-foreground hover:text-foreground transition-colors",
emojiOpen && "text-foreground",
)}
aria-label="Emoji"
>
<Smile className="h-[18px] w-[18px]" />
</button>
</PopoverTrigger>
<PopoverContent
side="top"
align="end"
sideOffset={8}
className="w-auto border-none bg-transparent p-0 shadow-none"
>
<React.Suspense
fallback={
<div className="flex h-[350px] w-[352px] items-center justify-center rounded-lg border bg-popover">
<p className="text-sm text-muted-foreground">
Loading...
</p>
</div>
}
>
<div style={emojiThemeVars as React.CSSProperties}>
<EmojiPicker
onEmojiSelect={handleEmojiSelect}
theme={resolvedTheme === "dark" ? "dark" : "light"}
set="native"
skinTonePosition="search"
previewPosition="none"
maxFrequentRows={2}
/>
</div>
</React.Suspense>
</PopoverContent>
</Popover>
</div>
</div>
{error && (
<p className="mt-2 text-xs text-destructive">{error}</p>
<p className="mt-1.5 text-xs text-destructive">{error}</p>
)}
<p className="mt-2 text-xs text-muted-foreground">
<kbd className="rounded border bg-muted px-1.5 py-0.5 font-mono text-xs">Enter</kbd> to send,{" "}
<kbd className="rounded border bg-muted px-1.5 py-0.5 font-mono text-xs">Shift+Enter</kbd> for new line
</p>
</div>
)
}

View File

@ -165,7 +165,7 @@ export function MessageList({ channelId, initialMessages }: MessageListProps) {
if (messages.length === 0) {
return (
<div className="flex min-h-0 flex-1 items-center justify-center p-8">
<div className="flex h-full min-h-0 items-center justify-center p-8 overflow-hidden">
<p className="text-sm text-muted-foreground">
No messages yet. Start the conversation!
</p>
@ -174,7 +174,7 @@ export function MessageList({ channelId, initialMessages }: MessageListProps) {
}
return (
<ScrollArea className="flex-1" ref={scrollRef}>
<ScrollArea className="h-full min-h-0 flex-1" ref={scrollRef}>
<div className="flex flex-col gap-4 p-4">
{hasMore && (
<div className="flex justify-center">