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:
parent
ec095fa1db
commit
1523d576b3
8
bun.lock
8
bun.lock
@ -26,6 +26,8 @@
|
|||||||
"@dnd-kit/modifiers": "^9.0.0",
|
"@dnd-kit/modifiers": "^9.0.0",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"@emoji-mart/data": "^1.2.1",
|
||||||
|
"@emoji-mart/react": "^1.1.1",
|
||||||
"@hookform/resolvers": "^5.2.2",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
"@json-render/core": "^0.4.0",
|
"@json-render/core": "^0.4.0",
|
||||||
"@json-render/react": "^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=="],
|
"@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/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=="],
|
"@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=="],
|
"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=="],
|
"emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
|
||||||
|
|
||||||
"encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
|
"encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
|
||||||
|
|||||||
@ -53,6 +53,8 @@
|
|||||||
"@dnd-kit/modifiers": "^9.0.0",
|
"@dnd-kit/modifiers": "^9.0.0",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"@emoji-mart/data": "^1.2.1",
|
||||||
|
"@emoji-mart/react": "^1.1.1",
|
||||||
"@hookform/resolvers": "^5.2.2",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
"@json-render/core": "^0.4.0",
|
"@json-render/core": "^0.4.0",
|
||||||
"@json-render/react": "^0.4.0",
|
"@json-render/react": "^0.4.0",
|
||||||
|
|||||||
@ -26,7 +26,10 @@ export default async function ChannelPage({
|
|||||||
|
|
||||||
return (
|
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
|
<ChannelHeader
|
||||||
name={channel.name}
|
name={channel.name}
|
||||||
description={channel.description ?? undefined}
|
description={channel.description ?? undefined}
|
||||||
|
|||||||
@ -231,6 +231,23 @@
|
|||||||
height: 0;
|
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 pill styling in messages and editor */
|
||||||
.mention {
|
.mention {
|
||||||
border-radius: calc(var(--radius) - 4px);
|
border-radius: calc(var(--radius) - 4px);
|
||||||
|
|||||||
@ -20,7 +20,7 @@ export function MainContent({
|
|||||||
<div
|
<div
|
||||||
{...rest}
|
{...rest}
|
||||||
className={cn(
|
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",
|
"transition-[flex,opacity] duration-300 ease-in-out",
|
||||||
isCollapsed
|
isCollapsed
|
||||||
? "flex-[0_0_0%] opacity-0 overflow-hidden pointer-events-none"
|
? "flex-[0_0_0%] opacity-0 overflow-hidden pointer-events-none"
|
||||||
@ -31,7 +31,7 @@ export function MainContent({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className={cn(
|
<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"
|
isConversations && "overflow-hidden"
|
||||||
)}>
|
)}>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@ -6,15 +6,79 @@ import StarterKit from "@tiptap/starter-kit"
|
|||||||
import Placeholder from "@tiptap/extension-placeholder"
|
import Placeholder from "@tiptap/extension-placeholder"
|
||||||
import Link from "@tiptap/extension-link"
|
import Link from "@tiptap/extension-link"
|
||||||
import Mention from "@tiptap/extension-mention"
|
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 { 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 { sendMessage } from "@/app/actions/chat-messages"
|
||||||
import { setTyping } from "@/app/actions/conversations-realtime"
|
import { setTyping } from "@/app/actions/conversations-realtime"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
|
import { useTheme } from "next-themes"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { createMentionSuggestion } from "./mention-suggestion"
|
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 = {
|
type MessageComposerProps = {
|
||||||
readonly channelId: string
|
readonly channelId: string
|
||||||
readonly channelName: string
|
readonly channelName: string
|
||||||
@ -29,7 +93,9 @@ type MentionInput = {
|
|||||||
targetId: string | null
|
targetId: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractMentions(json: Record<string, unknown>): Array<MentionInput> {
|
function extractMentions(
|
||||||
|
json: Record<string, unknown>,
|
||||||
|
): Array<MentionInput> {
|
||||||
const mentions: Array<MentionInput> = []
|
const mentions: Array<MentionInput> = []
|
||||||
|
|
||||||
function walk(node: Record<string, unknown>) {
|
function walk(node: Record<string, unknown>) {
|
||||||
@ -42,7 +108,10 @@ function extractMentions(json: Record<string, unknown>): Array<MentionInput> {
|
|||||||
} else if (id === "here") {
|
} else if (id === "here") {
|
||||||
mentions.push({ mentionType: "here", targetId: null })
|
mentions.push({ mentionType: "here", targetId: null })
|
||||||
} else if (id === "compass-agent") {
|
} else if (id === "compass-agent") {
|
||||||
mentions.push({ mentionType: "agent", targetId: "compass-agent" })
|
mentions.push({
|
||||||
|
mentionType: "agent",
|
||||||
|
targetId: "compass-agent",
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
mentions.push({ mentionType: "user", targetId: id })
|
mentions.push({ mentionType: "user", targetId: id })
|
||||||
}
|
}
|
||||||
@ -68,10 +137,13 @@ export function MessageComposer({
|
|||||||
onSent,
|
onSent,
|
||||||
}: MessageComposerProps) {
|
}: MessageComposerProps) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const { resolvedTheme } = useTheme()
|
||||||
|
const emojiThemeVars = useEmojiThemeVars()
|
||||||
const [isSending, setIsSending] = React.useState(false)
|
const [isSending, setIsSending] = React.useState(false)
|
||||||
const [error, setError] = React.useState<string | null>(null)
|
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 lastTypingSentRef = React.useRef<number>(0)
|
||||||
const TYPING_DEBOUNCE_MS = 3000
|
const TYPING_DEBOUNCE_MS = 3000
|
||||||
|
|
||||||
@ -80,7 +152,10 @@ export function MessageComposer({
|
|||||||
if (now - lastTypingSentRef.current >= TYPING_DEBOUNCE_MS) {
|
if (now - lastTypingSentRef.current >= TYPING_DEBOUNCE_MS) {
|
||||||
lastTypingSentRef.current = now
|
lastTypingSentRef.current = now
|
||||||
setTyping(channelId).catch((err) => {
|
setTyping(channelId).catch((err) => {
|
||||||
console.error("[MessageComposer] typing indicator error:", err)
|
console.error(
|
||||||
|
"[MessageComposer] typing indicator error:",
|
||||||
|
err,
|
||||||
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}, [channelId])
|
}, [channelId])
|
||||||
@ -94,7 +169,7 @@ export function MessageComposer({
|
|||||||
blockquote: false,
|
blockquote: false,
|
||||||
}),
|
}),
|
||||||
Placeholder.configure({
|
Placeholder.configure({
|
||||||
placeholder: placeholder ?? `Message #${channelName}...`,
|
placeholder: placeholder ?? `Message #${channelName}`,
|
||||||
}),
|
}),
|
||||||
Link.configure({
|
Link.configure({
|
||||||
openOnClick: false,
|
openOnClick: false,
|
||||||
@ -112,7 +187,11 @@ export function MessageComposer({
|
|||||||
],
|
],
|
||||||
editorProps: {
|
editorProps: {
|
||||||
attributes: {
|
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: () => {
|
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 () => {
|
const handleSend = React.useCallback(async () => {
|
||||||
if (!editor || isSending) return
|
if (!editor || isSending) return
|
||||||
|
|
||||||
@ -131,7 +219,9 @@ export function MessageComposer({
|
|||||||
setError(null)
|
setError(null)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const mentions = extractMentions(editor.getJSON() as Record<string, unknown>)
|
const mentions = extractMentions(
|
||||||
|
editor.getJSON() as Record<string, unknown>,
|
||||||
|
)
|
||||||
const contentHtml = editor.getHTML()
|
const contentHtml = editor.getHTML()
|
||||||
|
|
||||||
const result = await sendMessage({
|
const result = await sendMessage({
|
||||||
@ -150,7 +240,9 @@ export function MessageComposer({
|
|||||||
setError(result.error ?? "Failed to send message")
|
setError(result.error ?? "Failed to send message")
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Failed to send message")
|
setError(
|
||||||
|
err instanceof Error ? err.message : "Failed to send message",
|
||||||
|
)
|
||||||
} finally {
|
} finally {
|
||||||
setIsSending(false)
|
setIsSending(false)
|
||||||
}
|
}
|
||||||
@ -175,102 +267,205 @@ export function MessageComposer({
|
|||||||
}, [editor, handleSend])
|
}, [editor, handleSend])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="shrink-0 border-t bg-background p-4">
|
<div className="min-h-[68px] px-2 pb-4 pt-2 sm:px-4">
|
||||||
<div className="rounded-lg border bg-background">
|
{/* formatting toolbar */}
|
||||||
<EditorContent editor={editor} className="max-h-[200px] overflow-y-auto" />
|
{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 rounded-md text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={() =>
|
||||||
|
editor.chain().focus().toggleBold().run()
|
||||||
|
}
|
||||||
|
disabled={
|
||||||
|
!editor.can().chain().focus().toggleBold().run()
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Bold
|
||||||
|
className={cn(
|
||||||
|
"h-3.5 w-3.5",
|
||||||
|
editor.isActive("bold") && "text-primary",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
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(
|
||||||
|
"h-3.5 w-3.5",
|
||||||
|
editor.isActive("italic") && "text-primary",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
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(
|
||||||
|
"h-3.5 w-3.5",
|
||||||
|
editor.isActive("code") && "text-primary",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 rounded-md text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={() =>
|
||||||
|
editor.chain().focus().toggleBulletList().run()
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<List
|
||||||
|
className={cn(
|
||||||
|
"h-3.5 w-3.5",
|
||||||
|
editor.isActive("bulletList") && "text-primary",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 rounded-md text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={() =>
|
||||||
|
editor.chain().focus().toggleOrderedList().run()
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ListOrdered
|
||||||
|
className={cn(
|
||||||
|
"h-3.5 w-3.5",
|
||||||
|
editor.isActive("orderedList") && "text-primary",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{editor && (
|
{/* main composer bar */}
|
||||||
<div className="flex items-center justify-between border-t p-2">
|
<div
|
||||||
<div className="flex items-center gap-1">
|
className={cn(
|
||||||
<Button
|
"relative flex items-end rounded-lg",
|
||||||
variant="ghost"
|
"bg-muted/50 ring-1 ring-border",
|
||||||
size="icon"
|
"focus-within:ring-2 focus-within:ring-ring",
|
||||||
className="h-7 w-7"
|
"transition-shadow",
|
||||||
onClick={() => editor.chain().focus().toggleBold().run()}
|
|
||||||
disabled={!editor.can().chain().focus().toggleBold().run()}
|
|
||||||
>
|
|
||||||
<Bold className={cn(
|
|
||||||
"h-3.5 w-3.5",
|
|
||||||
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()}
|
|
||||||
>
|
|
||||||
<Italic className={cn(
|
|
||||||
"h-3.5 w-3.5",
|
|
||||||
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()}
|
|
||||||
>
|
|
||||||
<Code className={cn(
|
|
||||||
"h-3.5 w-3.5",
|
|
||||||
editor.isActive("code") && "text-primary"
|
|
||||||
)} />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-7 w-7"
|
|
||||||
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
|
||||||
>
|
|
||||||
<List className={cn(
|
|
||||||
"h-3.5 w-3.5",
|
|
||||||
editor.isActive("bulletList") && "text-primary"
|
|
||||||
)} />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-7 w-7"
|
|
||||||
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
|
||||||
>
|
|
||||||
<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
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
>
|
||||||
|
{/* + 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>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -165,7 +165,7 @@ export function MessageList({ channelId, initialMessages }: MessageListProps) {
|
|||||||
|
|
||||||
if (messages.length === 0) {
|
if (messages.length === 0) {
|
||||||
return (
|
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">
|
<p className="text-sm text-muted-foreground">
|
||||||
No messages yet. Start the conversation!
|
No messages yet. Start the conversation!
|
||||||
</p>
|
</p>
|
||||||
@ -174,7 +174,7 @@ export function MessageList({ channelId, initialMessages }: MessageListProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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">
|
<div className="flex flex-col gap-4 p-4">
|
||||||
{hasMore && (
|
{hasMore && (
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user