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;