diff --git a/bun.lock b/bun.lock index 691450f..40f07ac 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/package.json b/package.json index f0ea055..72e2b87 100755 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app/dashboard/conversations/[channelId]/page.tsx b/src/app/dashboard/conversations/[channelId]/page.tsx index b044514..6b2b373 100644 --- a/src/app/dashboard/conversations/[channelId]/page.tsx +++ b/src/app/dashboard/conversations/[channelId]/page.tsx @@ -26,7 +26,10 @@ export default async function ChannelPage({ return ( <> -
+
{children} diff --git a/src/components/conversations/message-composer.tsx b/src/components/conversations/message-composer.tsx index 3de7181..32fa352 100644 --- a/src/components/conversations/message-composer.tsx +++ b/src/components/conversations/message-composer.tsx @@ -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 { + const [vars, setVars] = React.useState>({}) + const { resolvedTheme } = useTheme() + + React.useEffect(() => { + const bg = cssVarToRgb("--popover") + const fg = cssVarToRgb("--popover-foreground") + const input = cssVarToRgb("--muted") + const next: Record = {} + 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): Array { +function extractMentions( + json: Record, +): Array { const mentions: Array = [] function walk(node: Record) { @@ -42,7 +108,10 @@ function extractMentions(json: Record): Array { } 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(null) + const [showToolbar, setShowToolbar] = React.useState(false) + const [emojiOpen, setEmojiOpen] = React.useState(false) - // typing indicator - debounce to avoid spamming server const lastTypingSentRef = React.useRef(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) + const mentions = extractMentions( + editor.getJSON() as Record, + ) 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 ( -
-
- +
+ {/* formatting toolbar */} + {editor && showToolbar && ( +
+ + + + + +
+ )} - {editor && ( -
-
- - - - - - - - - - -
- - -
+ {/* main composer bar */} +
+ {/* + button */} + + + {/* editor area */} + + + {/* right-side action icons */} +
+ + + + {/* emoji picker */} + + + + + + +

+ Loading... +

+
+ } + > +
+ +
+ + + +
{error && ( -

{error}

+

{error}

)} - -

- Enter to send,{" "} - Shift+Enter for new line -

) } diff --git a/src/components/conversations/message-list.tsx b/src/components/conversations/message-list.tsx index 8e6ea8d..c890dfb 100644 --- a/src/components/conversations/message-list.tsx +++ b/src/components/conversations/message-list.tsx @@ -165,7 +165,7 @@ export function MessageList({ channelId, initialMessages }: MessageListProps) { if (messages.length === 0) { return ( -
+

No messages yet. Start the conversation!

@@ -174,7 +174,7 @@ export function MessageList({ channelId, initialMessages }: MessageListProps) { } return ( - +
{hasMore && (