From 5f1db12f387b1b3b895f3bf21502519512b24a22 Mon Sep 17 00:00:00 2001 From: Jacob Owens Date: Tue, 3 Mar 2026 21:59:21 -0800 Subject: [PATCH 1/3] feat(chat): migrate composer to Tiptap and implement markdown mentions end-to-end - replace textarea composer with Tiptap + mention suggestion UI - move compose-related chat pieces into the composer component folder - wire Enter/Shift+Enter behavior (Enter sends, Shift+Enter inserts newline) - prevent self-mentions and keep mention selection Enter behavior intact - add plus-button popover UX (UI button + rotating plus-to-x interaction) - improve composer vertical alignment/spacing in the chat layout - add markdown message rendering with react-markdown + remark-gfm - render mentions as highlighted inline tokens with hover-card shell UI - set hover-card open delay to 500ms - standardize user-facing mention labels to displayUsername first - add mention metadata through API/realtime payloads and schemas - include resolved mention users on realtime broadcast/ack payloads - support optimistic mention metadata to avoid temporary "unknown user" flashes - keep mention storage ID-based (`<@userId>`) while rendering display labels --- .../lib/helpers/openapi/message-schemas.ts | 1 + apps/api/src/lib/queries/messages.ts | 51 +- apps/api/src/routes/v1/guilds/handlers.ts | 4 + apps/api/src/routes/v1/guilds/schema.ts | 2 + apps/realtime/src/index.ts | 15 +- apps/realtime/src/services/messages.ts | 1 + apps/realtime/src/services/notifications.ts | 45 + apps/web/package.json | 8 + .../chat/composer/mention-suggestion-list.tsx | 105 ++ .../components/chat/composer/mention-types.ts | 9 + .../chat/composer/message-input.tsx | 398 ++++ .../web/src/components/chat/message-input.tsx | 94 - apps/web/src/components/chat/message-item.tsx | 8 +- .../src/components/chat/message-markdown.tsx | 166 ++ .../sidebar/channel-panel/channel-list.tsx | 8 +- apps/web/src/lib/realtime-adapter.ts | 5 +- .../_authenticated/$guildSlug/$channelId.tsx | 43 +- .../src/routes/_authenticated/dms/$dmId.tsx | 34 +- packages/realtime-types/src/events.ts | 9 + packages/ui/package.json | 6 +- packages/ui/src/components/calendar.tsx | 219 +++ packages/ui/src/components/combobox.tsx | 310 +++ packages/ui/src/components/command.tsx | 183 ++ packages/ui/src/components/hover-card.tsx | 43 + packages/ui/src/components/input-group.tsx | 171 ++ packages/ui/src/components/popover.tsx | 88 + packages/ui/src/components/select.tsx | 189 ++ packages/ui/src/components/sheet.tsx | 142 ++ packages/ui/src/components/switch.tsx | 34 + packages/ui/src/components/textarea.tsx | 17 + pnpm-lock.yaml | 1665 ++++++++++++++++- 31 files changed, 3943 insertions(+), 130 deletions(-) create mode 100644 apps/web/src/components/chat/composer/mention-suggestion-list.tsx create mode 100644 apps/web/src/components/chat/composer/mention-types.ts create mode 100644 apps/web/src/components/chat/composer/message-input.tsx delete mode 100644 apps/web/src/components/chat/message-input.tsx create mode 100644 apps/web/src/components/chat/message-markdown.tsx create mode 100644 packages/ui/src/components/calendar.tsx create mode 100644 packages/ui/src/components/combobox.tsx create mode 100644 packages/ui/src/components/command.tsx create mode 100644 packages/ui/src/components/hover-card.tsx create mode 100644 packages/ui/src/components/input-group.tsx create mode 100644 packages/ui/src/components/popover.tsx create mode 100644 packages/ui/src/components/select.tsx create mode 100644 packages/ui/src/components/sheet.tsx create mode 100644 packages/ui/src/components/switch.tsx create mode 100644 packages/ui/src/components/textarea.tsx diff --git a/apps/api/src/lib/helpers/openapi/message-schemas.ts b/apps/api/src/lib/helpers/openapi/message-schemas.ts index b13bbc4..de67751 100644 --- a/apps/api/src/lib/helpers/openapi/message-schemas.ts +++ b/apps/api/src/lib/helpers/openapi/message-schemas.ts @@ -12,6 +12,7 @@ export const messageAuthorSchema = z.object({ export const messageWithAuthorSchema = selectMessageSchema.extend({ author: messageAuthorSchema, + mentions: z.array(messageAuthorSchema), }) export const listMessagesQuerySchema = paginationQuerySchema diff --git a/apps/api/src/lib/queries/messages.ts b/apps/api/src/lib/queries/messages.ts index 981e7ee..807109f 100644 --- a/apps/api/src/lib/queries/messages.ts +++ b/apps/api/src/lib/queries/messages.ts @@ -1,6 +1,6 @@ import { db } from "@repo/db" -import { message, user } from "@repo/db/schema" -import { count, desc, eq } from "drizzle-orm" +import { message, messageMention, user } from "@repo/db/schema" +import { count, desc, eq, inArray } from "drizzle-orm" export async function fetchMessagePage( channelId: string, @@ -45,12 +45,57 @@ export async function fetchMessagePage( const itemsTotal = countResult[0]?.total ?? 0 const totalPages = Math.ceil(itemsTotal / perPage) + const messageIds = messages.map((msg) => msg.id) + + const mentionRows = + messageIds.length > 0 + ? await db + .select({ + messageId: messageMention.messageId, + id: user.id, + name: user.name, + username: user.username, + displayUsername: user.displayUsername, + image: user.image, + }) + .from(messageMention) + .innerJoin(user, eq(messageMention.mentionedUserId, user.id)) + .where(inArray(messageMention.messageId, messageIds)) + : [] + + const mentionsByMessageId = new Map< + string, + Array<{ + id: string + name: string + username: string | null + displayUsername: string | null + image: string | null + }> + >() + + for (const mentionRow of mentionRows) { + const existingMentions = mentionsByMessageId.get(mentionRow.messageId) ?? [] + existingMentions.push({ + id: mentionRow.id, + name: mentionRow.name, + username: mentionRow.username, + displayUsername: mentionRow.displayUsername, + image: mentionRow.image, + }) + mentionsByMessageId.set(mentionRow.messageId, existingMentions) + } + + const messagesWithMentions = messages.map((msg) => ({ + ...msg, + mentions: mentionsByMessageId.get(msg.id) ?? [], + })) return { itemsTotal, currentPage: page, nextPage: page < totalPages ? page + 1 : null, prevPage: page > 1 ? page - 1 : null, - data: messages, + data: messagesWithMentions, } } diff --git a/apps/api/src/routes/v1/guilds/handlers.ts b/apps/api/src/routes/v1/guilds/handlers.ts index a483915..8f45982 100644 --- a/apps/api/src/routes/v1/guilds/handlers.ts +++ b/apps/api/src/routes/v1/guilds/handlers.ts @@ -47,6 +47,8 @@ export const listGuildMembers: AppRouteHandler = async ( userId: schema.guildMember.userId, role: schema.guildMember.role, name: schema.user.name, + username: schema.user.username, + displayUsername: schema.user.displayUsername, image: schema.user.image, }) .from(schema.guildMember) @@ -65,6 +67,8 @@ export const listGuildMembers: AppRouteHandler = async ( members: memberRows.map((member) => ({ userId: member.userId, name: member.name, + username: member.username, + displayUsername: member.displayUsername, image: member.image, role: member.role, status: onlineUserIds.has(member.userId) diff --git a/apps/api/src/routes/v1/guilds/schema.ts b/apps/api/src/routes/v1/guilds/schema.ts index c34b020..3876528 100644 --- a/apps/api/src/routes/v1/guilds/schema.ts +++ b/apps/api/src/routes/v1/guilds/schema.ts @@ -6,6 +6,8 @@ export { guildSlugParamsSchema } export const guildMemberPresenceSchema = z.object({ userId: z.string().uuid(), name: z.string(), + username: z.string().nullable(), + displayUsername: z.string().nullable(), image: z.string().nullable(), role: z.string(), status: z.enum(["online", "offline"]), diff --git a/apps/realtime/src/index.ts b/apps/realtime/src/index.ts index 409972f..aaab77c 100644 --- a/apps/realtime/src/index.ts +++ b/apps/realtime/src/index.ts @@ -296,16 +296,21 @@ io.on("connection", (socket) => { payload: parsed, }) - socket - .to(channelRoom(parsed.channelId)) - .emit("message:created", createdMessage.message) - const fanout = await buildMessageFanout({ authorId: socket.data.user.id, channel: createdMessage.channel, message: createdMessage.message, }) + const messageWithMentions = { + ...createdMessage.message, + mentions: fanout.messageMentions, + } + + socket + .to(channelRoom(parsed.channelId)) + .emit("message:created", messageWithMentions) + for (const unreadNotification of fanout.unreadNotifications) { io.to(userRoom(unreadNotification.userId)).emit( "notification:unread", @@ -320,7 +325,7 @@ io.on("connection", (socket) => { ) } - ack?.({ ok: true, message: createdMessage.message }) + ack?.({ ok: true, message: messageWithMentions }) } catch (error) { ack?.({ ok: false, error: toErrorMessage(error) }) } diff --git a/apps/realtime/src/services/messages.ts b/apps/realtime/src/services/messages.ts index 4259436..d5708df 100644 --- a/apps/realtime/src/services/messages.ts +++ b/apps/realtime/src/services/messages.ts @@ -83,6 +83,7 @@ export async function createMessage(input: CreateMessageInput) { displayUsername: messageWithAuthor.authorDisplayUsername, image: messageWithAuthor.authorImage, }, + mentions: [], } if (input.payload.nonce) { diff --git a/apps/realtime/src/services/notifications.ts b/apps/realtime/src/services/notifications.ts index d986f94..09bc8d4 100644 --- a/apps/realtime/src/services/notifications.ts +++ b/apps/realtime/src/services/notifications.ts @@ -2,6 +2,7 @@ import { and, db, eq, inArray, schema } from "@repo/db" import type { MentionNotification, RealtimeMessage, + RealtimeMessageMention, UnreadNotification, } from "@repo/realtime-types" import type { AccessibleChannel } from "./channel-access" @@ -26,6 +27,8 @@ type NotificationInsertType = Extract< const USER_MENTION_REGEX = /<@([0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})>/gi +const MARKDOWN_USER_MENTION_REGEX = + /\[@[^\]]*?\bid="([0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})"[^\]]*]/gi const EVERYONE_MENTION_REGEX = /(^|\s)@everyone\b/i const mentionNotificationTypes = ["direct_mention", "everyone_mention"] as const @@ -39,6 +42,13 @@ function extractDirectMentionUserIds(content: string) { } } + for (const match of content.matchAll(MARKDOWN_USER_MENTION_REGEX)) { + const userId = match[1] + if (userId) { + userIds.add(userId) + } + } + return userIds } @@ -129,9 +139,43 @@ export async function buildMessageFanout(input: MessageFanoutInput) { messageContent: input.message.content ?? "", }) + const mentionedUserIds = Array.from(mentionTypeByUserId.keys()) + const mentionUsers = + mentionedUserIds.length > 0 + ? await db + .select({ + id: schema.user.id, + name: schema.user.name, + username: schema.user.username, + displayUsername: schema.user.displayUsername, + image: schema.user.image, + }) + .from(schema.user) + .where(inArray(schema.user.id, mentionedUserIds)) + : [] + + const mentionUserMap = new Map(mentionUsers.map((user) => [user.id, user])) + const messageMentions: RealtimeMessageMention[] = mentionedUserIds.flatMap( + (userId) => { + const mentionUser = mentionUserMap.get(userId) + if (!mentionUser) return [] + + return [ + { + id: mentionUser.id, + name: mentionUser.name, + username: mentionUser.username, + displayUsername: mentionUser.displayUsername, + image: mentionUser.image, + }, + ] + } + ) + if (mentionTypeByUserId.size === 0) { return { unreadNotifications, + messageMentions, mentionNotifications: [] as Array< UserTargetedPayload >, @@ -255,6 +299,7 @@ export async function buildMessageFanout(input: MessageFanoutInput) { return { unreadNotifications, + messageMentions, mentionNotifications, } } diff --git a/apps/web/package.json b/apps/web/package.json index 4688d6c..424f953 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -22,6 +22,12 @@ "@tailwindcss/postcss": "^4.1.18", "@tanstack/react-query": "^5.90.21", "@tanstack/react-router": "^1.120.3", + "@tiptap/extension-mention": "^3.20.0", + "@tiptap/markdown": "^3.20.0", + "@tiptap/pm": "^3.20.0", + "@tiptap/react": "^3.20.0", + "@tiptap/starter-kit": "^3.20.0", + "@tiptap/suggestion": "^3.20.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.563.0", @@ -31,6 +37,8 @@ "radix-ui": "^1.4.3", "react": "^19.2.4", "react-dom": "^19.2.4", + "react-markdown": "^10.1.0", + "remark-gfm": "^4.0.1", "socket.io-client": "^4.8.3", "tailwind-merge": "^3.4.0", "tailwindcss": "^4.1.18", diff --git a/apps/web/src/components/chat/composer/mention-suggestion-list.tsx b/apps/web/src/components/chat/composer/mention-suggestion-list.tsx new file mode 100644 index 0000000..68b510c --- /dev/null +++ b/apps/web/src/components/chat/composer/mention-suggestion-list.tsx @@ -0,0 +1,105 @@ +import { cn } from "@repo/ui/lib/utils" +import type { SuggestionKeyDownProps } from "@tiptap/suggestion" +import { + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useState, +} from "react" +import type { MentionCandidate } from "./mention-types" + +export interface MentionSuggestionListRef { + onKeyDown: (props: SuggestionKeyDownProps) => boolean +} + +export interface MentionSuggestionListProps { + items: MentionCandidate[] + command: (item: MentionCandidate) => void +} + +export const MentionSuggestionList = forwardRef< + MentionSuggestionListRef, + MentionSuggestionListProps +>(function MentionSuggestionList({ items, command }, ref) { + const [selectedIndex, setSelectedIndex] = useState(0) + + const selectItem = useCallback( + (index: number) => { + const item = items[index] + if (!item) return false + command(item) + return true + }, + [items, command] + ) + + useEffect(() => { + setSelectedIndex((currentIndex) => { + if (items.length === 0) return 0 + return Math.min(currentIndex, items.length - 1) + }) + }, [items.length]) + + useImperativeHandle( + ref, + () => ({ + onKeyDown: ({ event }) => { + if (items.length === 0) return false + + if (event.key === "ArrowDown") { + event.preventDefault() + setSelectedIndex((currentIndex) => (currentIndex + 1) % items.length) + return true + } + + if (event.key === "ArrowUp") { + event.preventDefault() + setSelectedIndex( + (currentIndex) => (currentIndex + items.length - 1) % items.length + ) + return true + } + + if (event.key === "Enter") { + event.preventDefault() + return selectItem(selectedIndex) + } + + return false + }, + }), + [items.length, selectedIndex, selectItem] + ) + + if (items.length === 0) { + return ( +
+ No matches +
+ ) + } + + return ( +
+ {items.map((item, index) => ( + + ))} +
+ ) +}) diff --git a/apps/web/src/components/chat/composer/mention-types.ts b/apps/web/src/components/chat/composer/mention-types.ts new file mode 100644 index 0000000..be30696 --- /dev/null +++ b/apps/web/src/components/chat/composer/mention-types.ts @@ -0,0 +1,9 @@ +export interface MentionCandidate { + id: string + label: string + search?: string + name?: string + username?: string | null + displayUsername?: string | null + image?: string | null +} diff --git a/apps/web/src/components/chat/composer/message-input.tsx b/apps/web/src/components/chat/composer/message-input.tsx new file mode 100644 index 0000000..8bfbb10 --- /dev/null +++ b/apps/web/src/components/chat/composer/message-input.tsx @@ -0,0 +1,398 @@ +import { Button } from "@repo/ui/components/button" +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@repo/ui/components/popover" +import { cn } from "@repo/ui/lib/utils" +import Mention, { type MentionOptions } from "@tiptap/extension-mention" +import { Markdown } from "@tiptap/markdown" +import { EditorContent, ReactRenderer, useEditor } from "@tiptap/react" +import StarterKit from "@tiptap/starter-kit" +import type { SuggestionProps } from "@tiptap/suggestion" +import { FileUp, ImagePlus, Link2, Plus, Send, Smile } from "lucide-react" +import { useCallback, useEffect, useMemo, useRef, useState } from "react" +import type { Message } from "@/lib/api-types" +import type { ChatContext } from "../header" +import { + MentionSuggestionList, + type MentionSuggestionListProps, + type MentionSuggestionListRef, +} from "./mention-suggestion-list" +import type { MentionCandidate } from "./mention-types" + +const MAX_MENTION_RESULTS = 8 +const MAX_MESSAGE_LENGTH = 2000 +const POPUP_HORIZONTAL_PADDING = 8 +const POPUP_VERTICAL_PADDING = 8 +const POPUP_GAP = 6 +const TIPTAP_MARKDOWN_MENTION_REGEX = /\[@[^\]]*?\bid="([^"]+)"[^\]]*]/g +const STORED_MENTION_REGEX = + /<@([0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})>/gi +const ATTACHMENT_ACTIONS = [ + { id: "upload-file", label: "Upload File", icon: FileUp }, + { id: "upload-image", label: "Upload Image", icon: ImagePlus }, + { id: "attach-link", label: "Attach Link", icon: Link2 }, +] as const + +interface MessageInputProps { + context: ChatContext + onSend: (content: string, options?: { mentions: Message["mentions"] }) => void + isSending?: boolean + currentUserId?: string + mentionCandidates?: MentionCandidate[] +} + +function toStoredMarkdown(markdown: string) { + return markdown + .replace(/\u00A0/g, " ") + .replace(TIPTAP_MARKDOWN_MENTION_REGEX, "<@$1>") +} + +function extractMentionIds(content: string) { + const mentionIds = new Set() + + for (const match of content.matchAll(STORED_MENTION_REGEX)) { + const mentionId = match[1] + if (mentionId) { + mentionIds.add(mentionId) + } + } + + return Array.from(mentionIds) +} + +function createMentionSuggestion( + getMentionCandidates: () => MentionCandidate[] +): MentionOptions["suggestion"] { + return { + char: "@", + items: ({ query }) => { + const normalized = query.trim().toLowerCase() + const results = getMentionCandidates().filter((candidate) => { + if (!normalized) return true + return ( + candidate.label.toLowerCase().includes(normalized) || + candidate.search?.toLowerCase().includes(normalized) + ) + }) + return results.slice(0, MAX_MENTION_RESULTS) + }, + render: () => { + let popup: HTMLDivElement | null = null + let currentProps: SuggestionProps< + MentionCandidate, + MentionCandidate + > | null = null + let reactRenderer: ReactRenderer< + MentionSuggestionListRef, + MentionSuggestionListProps + > | null = null + + const positionPopup = () => { + const clientRect = currentProps?.clientRect?.() + if (!popup || !clientRect) return + + const popupWidth = popup.offsetWidth || 240 + const popupHeight = popup.offsetHeight || 240 + const maxLeft = Math.max( + POPUP_HORIZONTAL_PADDING, + window.innerWidth - popupWidth - POPUP_HORIZONTAL_PADDING + ) + const left = Math.min( + Math.max(POPUP_HORIZONTAL_PADDING, clientRect.left), + maxLeft + ) + const top = Math.max( + POPUP_VERTICAL_PADDING, + clientRect.top - popupHeight - POPUP_GAP + ) + + popup.style.left = `${left}px` + popup.style.top = `${top}px` + } + + const cleanup = () => { + window.removeEventListener("resize", positionPopup) + window.removeEventListener("scroll", positionPopup, true) + reactRenderer?.destroy() + reactRenderer = null + popup?.remove() + popup = null + currentProps = null + } + + return { + onStart: (props) => { + cleanup() + currentProps = props + + popup = document.createElement("div") + popup.className = + "fixed z-50 w-60 overflow-hidden rounded-md border border-border bg-popover text-popover-foreground shadow-md" + popup.dataset.mentionSuggestionOpen = "true" + document.body.append(popup) + + reactRenderer = new ReactRenderer(MentionSuggestionList, { + editor: props.editor, + props: { + items: props.items, + command: props.command, + }, + }) + + popup.append(reactRenderer.element) + window.addEventListener("resize", positionPopup) + window.addEventListener("scroll", positionPopup, true) + positionPopup() + }, + onUpdate: (props) => { + currentProps = props + reactRenderer?.updateProps({ + items: props.items, + command: props.command, + }) + positionPopup() + }, + onKeyDown: (props) => { + if (props.event.key === "Escape") { + props.event.preventDefault() + cleanup() + return true + } + + const suggestionList = + reactRenderer?.ref as MentionSuggestionListRef | null + return suggestionList?.onKeyDown(props) ?? false + }, + onExit: cleanup, + } + }, + } +} + +export function MessageInput({ + context, + onSend, + isSending, + currentUserId, + mentionCandidates = [], +}: MessageInputProps) { + const [plainText, setPlainText] = useState("") + const [isAttachmentMenuOpen, setIsAttachmentMenuOpen] = useState(false) + const mentionCandidatesRef = useRef([]) + + const placeholder = + context.type === "channel" + ? `Message #${context.name}` + : `Send a Raven to ${context.name}` + + const normalizedMentionCandidates = useMemo(() => { + const uniqueCandidates = new Map() + for (const candidate of mentionCandidates) { + if (currentUserId && candidate.id === currentUserId) { + continue + } + + const label = candidate.label.trim() + if (!label) continue + uniqueCandidates.set(candidate.id, { + id: candidate.id, + label, + search: candidate.search, + name: candidate.name, + username: candidate.username, + displayUsername: candidate.displayUsername, + image: candidate.image, + }) + } + return Array.from(uniqueCandidates.values()) + }, [currentUserId, mentionCandidates]) + + useEffect(() => { + mentionCandidatesRef.current = normalizedMentionCandidates + }, [normalizedMentionCandidates]) + + const mentionSuggestion = useMemo( + () => createMentionSuggestion(() => mentionCandidatesRef.current), + [] + ) + + const editor = useEditor( + { + extensions: [ + StarterKit.configure({ + heading: false, + blockquote: false, + codeBlock: false, + horizontalRule: false, + }), + Markdown, + Mention.configure({ + HTMLAttributes: { + class: "rounded bg-primary/15 px-1 py-0.5 font-medium text-primary", + }, + renderText: ({ options, node }) => + `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}`, + suggestion: mentionSuggestion, + }), + ], + editorProps: { + attributes: { + class: + "min-h-[24px] max-h-[200px] overflow-y-auto whitespace-pre-wrap break-words text-sm leading-6 text-foreground/90 outline-none", + }, + }, + onCreate: ({ editor: tiptapEditor }) => { + setPlainText(tiptapEditor.getText({ blockSeparator: "\n" })) + }, + onUpdate: ({ editor: tiptapEditor }) => { + setPlainText(tiptapEditor.getText({ blockSeparator: "\n" })) + }, + }, + [] + ) + + const handleSend = useCallback(() => { + const markdown = toStoredMarkdown(editor.getMarkdown()) + const trimmed = markdown.trim() + if (!trimmed || trimmed.length > MAX_MESSAGE_LENGTH || isSending) return + + const mentionCandidatesById = new Map( + normalizedMentionCandidates.map((candidate) => [candidate.id, candidate]) + ) + + const mentions: Message["mentions"] = extractMentionIds(trimmed).flatMap( + (mentionId) => { + const mentionCandidate = mentionCandidatesById.get(mentionId) + if (!mentionCandidate) return [] + + return [ + { + id: mentionCandidate.id, + name: mentionCandidate.name ?? mentionCandidate.label, + username: mentionCandidate.username ?? null, + displayUsername: mentionCandidate.displayUsername ?? null, + image: mentionCandidate.image ?? null, + }, + ] + } + ) + + onSend(trimmed, { mentions }) + editor.commands.clearContent(true) + editor.commands.focus("end") + setPlainText("") + }, [editor, isSending, normalizedMentionCandidates, onSend]) + + useEffect(() => { + const isMentionSuggestionOpen = () => + Boolean(document.querySelector("[data-mention-suggestion-open='true']")) + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Enter" && !event.shiftKey) { + if (isMentionSuggestionOpen()) { + return + } + event.preventDefault() + event.stopPropagation() + handleSend() + } + } + + const domNode = editor.view.dom + domNode.addEventListener("keydown", handleKeyDown, { capture: true }) + return () => { + domNode.removeEventListener("keydown", handleKeyDown, { capture: true }) + } + }, [editor, handleSend]) + + const trimmedValue = plainText.trim() + const canSend = + trimmedValue.length > 0 && + trimmedValue.length <= MAX_MESSAGE_LENGTH && + !isSending + const isEmpty = trimmedValue.length === 0 + + return ( +
+
+ + + + + +
+ {ATTACHMENT_ACTIONS.map((action) => ( + + ))} +
+
+
+
+ {isEmpty && ( + + {placeholder} + + )} + +
+
+ + +
+
+
+ ) +} diff --git a/apps/web/src/components/chat/message-input.tsx b/apps/web/src/components/chat/message-input.tsx deleted file mode 100644 index a62a773..0000000 --- a/apps/web/src/components/chat/message-input.tsx +++ /dev/null @@ -1,94 +0,0 @@ -import { Button } from "@repo/ui/components/button" -import { cn } from "@repo/ui/lib/utils" -import { PlusCircle, Send, Smile } from "lucide-react" -import { useRef, useState } from "react" -import type { ChatContext } from "./header" - -interface MessageInputProps { - context: ChatContext - onSend: (content: string) => void - isSending?: boolean -} - -export function MessageInput({ - context, - onSend, - isSending, -}: MessageInputProps) { - const [value, setValue] = useState("") - const textareaRef = useRef(null) - - const placeholder = - context.type === "channel" - ? `Message #${context.name}` - : `Send a Raven to ${context.name}` - - const handleSend = () => { - const trimmed = value.trim() - if (!trimmed || isSending) return - onSend(trimmed) - setValue("") - if (textareaRef.current) { - textareaRef.current.style.height = "auto" - textareaRef.current.focus() - } - } - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "Enter" && !e.shiftKey) { - e.preventDefault() - handleSend() - } - } - - return ( -
-
- -