From 3f199aa70d36245b4624d6fce59b87afa43f29ea Mon Sep 17 00:00:00 2001 From: asepharyana Date: Sun, 2 Aug 2026 10:27:41 +0700 Subject: [PATCH] fix(messages): merge partial WS updates + display edited content message_updated broadcasts only {id, edited_content, edited_at} (+ reset ai_* fields), but the frontend replaced the whole cached record, wiping username/content/channel_id/created_at -> blank cards and the 'the channel_id of undefined' crash on /messages. Merge partials over the existing record (list + detail), make list-patching channel-filter aware, show edited content/badge, and fix the message_updated WS type. Also broadcast type:'edited' + ai reset in message_updated so the live UI matches the DB update. --- .../modules/message-capture/messageCapture.ts | 14 ++++ .../src/components/messages/message-card.tsx | 7 +- .../messages/message-detail-view.tsx | 6 +- .../components/messages/message-detail.tsx | 6 +- services/frontend/src/hooks/use-messages.ts | 74 +++++++++++++++---- services/frontend/src/lib/ws/types.ts | 7 +- 6 files changed, 92 insertions(+), 22 deletions(-) diff --git a/services/discord-gateway/src/modules/message-capture/messageCapture.ts b/services/discord-gateway/src/modules/message-capture/messageCapture.ts index c45b4c8..b0b43cb 100644 --- a/services/discord-gateway/src/modules/message-capture/messageCapture.ts +++ b/services/discord-gateway/src/modules/message-capture/messageCapture.ts @@ -343,6 +343,20 @@ export function registerMessageCapture(client: Client): void { id: newMessage.id, edited_content: getDisplayContent(newMessage as Message), edited_at: editedAt, + type: "edited", + // Match the DB update (updateMessageAsEdited resets analysis to + // pending) so the live UI reflects the same state instead of + // lingering on the stale pre-edit verdict. + ai_status: "pending", + ai_moderation_flags: null, + ai_moderation_score: null, + ai_analysis: null, + ai_categories: null, + ai_severity: null, + ai_confidence: null, + ai_recommended_action: null, + ai_analyzed_at: null, + ai_error: null, }); } } else if (newMessage.author) { diff --git a/services/frontend/src/components/messages/message-card.tsx b/services/frontend/src/components/messages/message-card.tsx index 845e96d..d23f57c 100644 --- a/services/frontend/src/components/messages/message-card.tsx +++ b/services/frontend/src/components/messages/message-card.tsx @@ -86,7 +86,7 @@ export function MessageCard({ deleted )} - {msg.type === "edited" && ( + {(msg.type === "edited" || msg.edited_content) && ( - {renderMessageContent(msg.content, msg.metadata)} + {renderMessageContent( + msg.edited_content ?? msg.content, + msg.metadata, + )}

{(() => { const u = extractFirstImage(msg.metadata); diff --git a/services/frontend/src/components/messages/message-detail-view.tsx b/services/frontend/src/components/messages/message-detail-view.tsx index 760f386..216562c 100644 --- a/services/frontend/src/components/messages/message-detail-view.tsx +++ b/services/frontend/src/components/messages/message-detail-view.tsx @@ -44,8 +44,10 @@ export function MessageDetailView({ {/* Content */}
- {renderMessageContent(message.content, message.metadata) || - "(no text content)"} + {renderMessageContent( + message.edited_content ?? message.content, + message.metadata, + ) || "(no text content)"}
{/* Attachments */} diff --git a/services/frontend/src/components/messages/message-detail.tsx b/services/frontend/src/components/messages/message-detail.tsx index 3c727e8..3adb12b 100644 --- a/services/frontend/src/components/messages/message-detail.tsx +++ b/services/frontend/src/components/messages/message-detail.tsx @@ -44,8 +44,10 @@ export function MessageDetail({ {/* Content */}
- {renderMessageContent(message.content, message.metadata) || - "(no text content)"} + {renderMessageContent( + message.edited_content ?? message.content, + message.metadata, + ) || "(no text content)"}
{/* Attachments */} diff --git a/services/frontend/src/hooks/use-messages.ts b/services/frontend/src/hooks/use-messages.ts index e4565a4..ddddc50 100644 --- a/services/frontend/src/hooks/use-messages.ts +++ b/services/frontend/src/hooks/use-messages.ts @@ -183,43 +183,87 @@ export function useMessagesWsSync(ws: WsHook, guildId: string) { const { mutate } = useSWRConfig(); useEffect(() => { if (!guildId) return; - // Patch every message-list key for this guild (all channels + "__all__") + // Patch every message-list key for this guild (all channels + "**filtered**"). + // The updater receives the SWR key so we can honor its channel filter: + // a live `message_created`/updated for channel B must NOT be prepended to + // a list that is filtered down to channel A. const patchLists = ( + matcher: (key: unknown, msg: { channel_id?: string }) => boolean, updater: (old: MessagePage | undefined) => MessagePage | undefined, + msg: { channel_id?: string }, ) => { void mutate( (key) => - Array.isArray(key) && key[0] === "messages" && key[1] === guildId, + Array.isArray(key) && + key[0] === "messages" && + key[1] === guildId && + matcher(key, msg), updater, { revalidate: false }, ); }; + // A list key [messages, guildId, channelId] is "channel N" when channelId + // is a non-empty string and matches the incoming message; "__all__" (or + // any non-channel) lists accept every message of the guild. + const matchesFilter = (key: unknown[], msg: { channel_id?: string }) => { + const channelId = key[2] as string | undefined; + if (!channelId || channelId === "__all__") return true; + return msg.channel_id === channelId; + }; const unsub1 = ws.on("message_created", (data) => { const msg = data as MessageRecord; - patchLists((old) => (old ? { ...old, data: [msg, ...old.data] } : old)); + patchLists( + (_k, m) => matchesFilter(_k as unknown[], m), + (old) => (old ? { ...old, data: [msg, ...old.data] } : old), + msg, + ); }); const unsub2 = ws.on("message_updated", (data) => { - const msg = data as MessageRecord; - patchLists((old) => - old - ? { ...old, data: old.data.map((m) => (m.id === msg.id ? msg : m)) } - : old, + const msg = data as Partial & { id: string }; + // The gateway broadcasts a PARTIAL update ({ id, edited_content, + // edited_at, ... }) — merge it over the existing record instead of + // replacing it, or the card would lose username/content/channel/etc. + patchLists( + (_k, m) => + (m as Partial).channel_id === undefined || + matchesFilter(_k as unknown[], m), + (old) => + old + ? { + ...old, + data: old.data.map((m) => + m.id === msg.id ? { ...m, ...msg } : m, + ), + } + : old, + msg, + ); + void mutate( + msgKeys.detail(msg.id), + (old: MessageRecord | undefined) => (old ? { ...old, ...msg } : old), + { revalidate: false }, ); - void mutate(msgKeys.detail(msg.id), msg, { revalidate: false }); }); const unsub3 = ws.on("message_deleted", (data) => { const { id } = data as { id: string }; - patchLists((old) => - old ? { ...old, data: old.data.filter((m) => m.id !== id) } : old, + patchLists( + () => true, + (old) => + old ? { ...old, data: old.data.filter((m) => m.id !== id) } : old, + { channel_id: undefined }, ); }); const unsub4 = ws.on("message_analyzed", (data) => { const msg = data as MessageRecord; - patchLists((old) => - old - ? { ...old, data: old.data.map((m) => (m.id === msg.id ? msg : m)) } - : old, + // message_analyzed carries the FULL record — replace is fine. + patchLists( + (_k, m) => matchesFilter(_k as unknown[], m), + (old) => + old + ? { ...old, data: old.data.map((m) => (m.id === msg.id ? msg : m)) } + : old, + msg, ); void mutate(msgKeys.detail(msg.id), msg, { revalidate: false }); }); diff --git a/services/frontend/src/lib/ws/types.ts b/services/frontend/src/lib/ws/types.ts index a35684b..86be0f6 100644 --- a/services/frontend/src/lib/ws/types.ts +++ b/services/frontend/src/lib/ws/types.ts @@ -27,7 +27,12 @@ export interface WsBinaryEvent { export interface WsEventMap { message_created: MessageRecord; - message_updated: MessageRecord; + /** + * The gateway broadcasts a PARTIAL update: { id } plus the changed fields + * (edited_content, edited_at, type, and the reset ai_* fields). It is NOT a + * full MessageRecord — merge it, never rely on it carrying the full row. + */ + message_updated: Partial & { id: string }; /** Gateway emits { id, deleted_at } — NOT a bare string */ message_deleted: { id: string; deleted_at?: number }; message_analyzed: MessageRecord;