Skip to content
Merged

Dev #15

Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions apps/api/src/lib/helpers/openapi/message-schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,20 @@ export const messageEmbedSchema = z.object({
siteName: z.string().optional(),
})

export const referencedMessageSchema = z
.object({
id: z.string().uuid(),
content: z.string().nullable(),
author: messageAuthorSchema,
})
.nullable()

export const messageWithAuthorSchema = selectMessageSchema.extend({
author: messageAuthorSchema,
mentions: z.array(messageAuthorSchema),
reactions: z.array(messageReactionSchema),
embeds: z.array(messageEmbedSchema),
referencedMessage: referencedMessageSchema,
})

export const listMessagesQuerySchema = paginationQuerySchema
Expand Down
42 changes: 42 additions & 0 deletions apps/api/src/lib/queries/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,45 @@ export async function fetchMessagePage(
.where(inArray(messageReaction.messageId, messageIds))
: []

// Batch-fetch referenced messages for replies
const referencedMessageIds = messages
.map((msg) => msg.referencedMessageId)
.filter((id): id is string => id !== null)

const referencedMessageRows =
referencedMessageIds.length > 0
? await db
.select({
id: message.id,
content: message.content,
authorId: user.id,
authorName: user.name,
authorUsername: user.username,
authorDisplayUsername: user.displayUsername,
authorImage: user.image,
})
.from(message)
.innerJoin(user, eq(message.authorId, user.id))
.where(inArray(message.id, referencedMessageIds))
: []

const referencedMessagesById = new Map(
referencedMessageRows.map((row) => [
row.id,
{
id: row.id,
content: row.content,
author: {
id: row.authorId,
name: row.authorName,
username: row.authorUsername,
displayUsername: row.authorDisplayUsername,
image: row.authorImage,
},
},
])
)

const mentionsByMessageId = new Map<
string,
Array<{
Expand Down Expand Up @@ -138,6 +177,9 @@ export async function fetchMessagePage(
embeds: msg.embeds ?? [],
mentions: mentionsByMessageId.get(msg.id) ?? [],
reactions: Array.from(reactionsByMessageId.get(msg.id)?.values() ?? []),
referencedMessage: msg.referencedMessageId
? (referencedMessagesById.get(msg.referencedMessageId) ?? null)
: null,
}))

return {
Expand Down
39 changes: 38 additions & 1 deletion apps/realtime/src/services/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,14 +36,17 @@ export async function createMessage(input: CreateMessageInput) {
input.payload.channelId
)

const hasReply = !!input.payload.referencedMessageId

const messageWithAuthor = await db.transaction(async (tx) => {
const insertedMessage = await tx
.insert(schema.message)
.values({
channelId: input.payload.channelId,
authorId: input.userId,
content: input.payload.content,
type: "default",
type: hasReply ? "reply" : "default",
referencedMessageId: input.payload.referencedMessageId ?? null,
})
.returning({
id: schema.message.id,
Expand Down Expand Up @@ -85,6 +88,39 @@ export async function createMessage(input: CreateMessageInput) {
return createdMessageWithAuthor
})

let referencedMessage: RealtimeMessage["referencedMessage"] = null
if (hasReply && input.payload.referencedMessageId) {
const refMsg = await db
.select({
id: schema.message.id,
content: schema.message.content,
authorId: schema.user.id,
authorName: schema.user.name,
authorUsername: schema.user.username,
authorDisplayUsername: schema.user.displayUsername,
authorImage: schema.user.image,
})
.from(schema.message)
.innerJoin(schema.user, eq(schema.message.authorId, schema.user.id))
.where(eq(schema.message.id, input.payload.referencedMessageId))
.limit(1)
.then((rows) => rows[0])

if (refMsg) {
referencedMessage = {
id: refMsg.id,
content: refMsg.content,
author: {
id: refMsg.authorId,
name: refMsg.authorName,
username: refMsg.authorUsername,
displayUsername: refMsg.authorDisplayUsername,
image: refMsg.authorImage,
},
}
}
}

const createdMessage: RealtimeMessage = {
id: messageWithAuthor.id,
channelId: messageWithAuthor.channelId,
Expand All @@ -101,6 +137,7 @@ export async function createMessage(input: CreateMessageInput) {
mentions: [],
reactions: [],
embeds: [],
referencedMessage,
}

if (input.payload.nonce) {
Expand Down
65 changes: 61 additions & 4 deletions apps/web/src/components/chat/composer/message-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
Send,
Smile,
Strikethrough,
X,
} from "lucide-react"
import {
type ComponentType,
Expand Down Expand Up @@ -96,10 +97,18 @@ const SLASH_COMMANDS: SlashCommandItem[] = [

interface MessageInputProps {
context: ChatContext
onSend: (content: string, options?: { mentions: Message["mentions"] }) => void
onSend: (
content: string,
options?: {
mentions: Message["mentions"]
referencedMessage?: Message["referencedMessage"]
}
) => void
Comment on lines +95 to +102

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Only clear reply mode after a successful send.

onSend is still fire-and-forget, but useMessageSending can return early when the socket or user is unavailable and can roll the optimistic row back on ACK failure in apps/web/src/hooks/use-message-sending.ts, Lines 114-150. Line 580 clears replyingTo anyway, so a failed send silently exits reply mode and the retry becomes a plain message. Have onSend resolve a success signal and only call onCancelReply once the send actually sticks.

🛠️ Proposed fix
 interface MessageInputProps {
   context: ChatContext
   onSend: (
     content: string,
     options?: {
       mentions: Message["mentions"]
       referencedMessage?: Message["referencedMessage"]
     }
-  ) => void
+  ) => Promise<boolean> | boolean
   isSending?: boolean
   currentUserId?: string
   mentionCandidates?: MentionCandidate[]
   replyingTo?: Message | null
   onCancelReply?: () => void
 }
@@
-  const handleSend = useCallback(() => {
+  const handleSend = useCallback(async () => {
     if (!editor) return
@@
-    onSend(trimmed, {
+    const didSend = await onSend(trimmed, {
       mentions,
       referencedMessage: replyingTo
         ? {
             id: replyingTo.id,
             content: replyingTo.content,
             author: replyingTo.author,
           }
         : undefined,
     })
+    if (!didSend) return
     editor.commands.clearContent(true)
     editor.commands.focus("end")
     setPlainText("")
     onCancelReply?.()
   }, [

Also applies to: 567-580

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/components/chat/composer/message-input.tsx` around lines 100 -
106, The onSend prop should signal actual send success so reply mode is only
cleared after the message is persisted: change the onSend signature used in
message-input.tsx to return a Promise<boolean> (or some success indicator)
instead of being fire-and-forget, update callers (notably useMessageSending) to
resolve true only on ACK/persist and false on early-return or failure, and then
in the component call onCancelReply (or clear replyingTo) only when the returned
promise indicates success; reference the onSend prop in message-input.tsx, the
useMessageSending hook (the send logic/ACK handling), and the
replyingTo/onCancelReply flow so the optimistic row rollback path does not clear
reply mode prematurely.

isSending?: boolean
currentUserId?: string
mentionCandidates?: MentionCandidate[]
replyingTo?: Message | null
onCancelReply?: () => void
}

function toStoredMarkdown(markdown: string) {
Expand Down Expand Up @@ -428,6 +437,8 @@ export function MessageInput({
isSending,
currentUserId,
mentionCandidates = [],
replyingTo,
onCancelReply,
}: MessageInputProps) {
const [plainText, setPlainText] = useState("")
const [isAttachmentMenuOpen, setIsAttachmentMenuOpen] = useState(false)
Expand Down Expand Up @@ -553,11 +564,34 @@ export function MessageInput({
}
)

onSend(trimmed, { mentions })
onSend(trimmed, {
mentions,
referencedMessage: replyingTo
? {
id: replyingTo.id,
content: replyingTo.content,
author: replyingTo.author,
}
: undefined,
})
editor.commands.clearContent(true)
editor.commands.focus("end")
setPlainText("")
}, [editor, isSending, normalizedMentionCandidates, onSend])
onCancelReply?.()
}, [
editor,
isSending,
normalizedMentionCandidates,
onSend,
replyingTo,
onCancelReply,
])

useEffect(() => {
if (replyingTo && editor) {
editor.commands.focus("end")
}
}, [replyingTo, editor])

useEffect(() => {
if (!editor || !editor.view || !editor.view.dom) {
Expand Down Expand Up @@ -659,7 +693,30 @@ export function MessageInput({

return (
<div className="shrink-0 px-4 pb-3">
<div className="flex items-center gap-2 rounded-lg border border-input bg-muted/40 px-3 py-2">
{replyingTo && (
<div className="flex items-center gap-2 rounded-t-lg border border-b-0 border-input bg-muted/60 px-3 py-1.5 text-sm">
<span className="truncate text-muted-foreground">
Replying to{" "}
<span className="font-semibold text-foreground">
{replyingTo.author.displayUsername ?? replyingTo.author.name}
</span>
</span>
<button
type="button"
onClick={onCancelReply}
className="ml-auto shrink-0 text-muted-foreground hover:text-foreground"
aria-label="Cancel reply"
>
<X className="size-4" />
</button>
</div>
)}
<div
className={cn(
"flex items-center gap-2 border border-input bg-muted/40 px-3 py-2",
replyingTo ? "rounded-b-lg" : "rounded-lg"
)}
>
<Popover
open={isAttachmentMenuOpen}
onOpenChange={setIsAttachmentMenuOpen}
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/chat/date-divider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ interface DateDividerProps {

export function DateDivider({ date }: DateDividerProps) {
return (
<div className="py-3">
<div className="py-3" data-date-divider={formatDateDivider(date)}>
<div className="relative flex items-center">
<div className="h-px w-full bg-border/70" />
<span className="absolute left-1/2 -translate-x-1/2 rounded-full border border-border/70 bg-background px-2.5 py-0.5 text-xs font-medium text-muted-foreground">
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/components/chat/message-action-bar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
TooltipContent,
TooltipTrigger,
} from "@repo/ui/components/tooltip"
import { MessageSquarePlus, MoreHorizontal } from "lucide-react"
import { MoreHorizontal, Reply } from "lucide-react"
import { useEffect, useState } from "react"
import { EmojiReactionPicker } from "./emoji-reaction-picker"

Expand Down Expand Up @@ -55,7 +55,7 @@ export function MessageActionBar({
onClick={onReply}
aria-label="Reply"
>
<MessageSquarePlus className="size-4" />
<Reply className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">Reply</TooltipContent>
Expand Down
Loading