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.
This commit is contained in:
@@ -343,6 +343,20 @@ export function registerMessageCapture(client: Client): void {
|
|||||||
id: newMessage.id,
|
id: newMessage.id,
|
||||||
edited_content: getDisplayContent(newMessage as Message),
|
edited_content: getDisplayContent(newMessage as Message),
|
||||||
edited_at: editedAt,
|
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) {
|
} else if (newMessage.author) {
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ export function MessageCard({
|
|||||||
deleted
|
deleted
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
{msg.type === "edited" && (
|
{(msg.type === "edited" || msg.edited_content) && (
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="text-[10px] px-1.5 py-0 h-4"
|
className="text-[10px] px-1.5 py-0 h-4"
|
||||||
@@ -102,7 +102,10 @@ export function MessageCard({
|
|||||||
"italic text-muted-foreground line-through",
|
"italic text-muted-foreground line-through",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{renderMessageContent(msg.content, msg.metadata)}
|
{renderMessageContent(
|
||||||
|
msg.edited_content ?? msg.content,
|
||||||
|
msg.metadata,
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
{(() => {
|
{(() => {
|
||||||
const u = extractFirstImage(msg.metadata);
|
const u = extractFirstImage(msg.metadata);
|
||||||
|
|||||||
@@ -44,8 +44,10 @@ export function MessageDetailView({
|
|||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="text-sm text-text-primary/90 leading-relaxed mb-4 whitespace-pre-wrap">
|
<div className="text-sm text-text-primary/90 leading-relaxed mb-4 whitespace-pre-wrap">
|
||||||
{renderMessageContent(message.content, message.metadata) ||
|
{renderMessageContent(
|
||||||
"(no text content)"}
|
message.edited_content ?? message.content,
|
||||||
|
message.metadata,
|
||||||
|
) || "(no text content)"}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Attachments */}
|
{/* Attachments */}
|
||||||
|
|||||||
@@ -44,8 +44,10 @@ export function MessageDetail({
|
|||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="text-sm text-text-primary/90 leading-relaxed mb-4 whitespace-pre-wrap">
|
<div className="text-sm text-text-primary/90 leading-relaxed mb-4 whitespace-pre-wrap">
|
||||||
{renderMessageContent(message.content, message.metadata) ||
|
{renderMessageContent(
|
||||||
"(no text content)"}
|
message.edited_content ?? message.content,
|
||||||
|
message.metadata,
|
||||||
|
) || "(no text content)"}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Attachments */}
|
{/* Attachments */}
|
||||||
|
|||||||
@@ -183,43 +183,87 @@ export function useMessagesWsSync(ws: WsHook, guildId: string) {
|
|||||||
const { mutate } = useSWRConfig();
|
const { mutate } = useSWRConfig();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!guildId) return;
|
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 = (
|
const patchLists = (
|
||||||
|
matcher: (key: unknown, msg: { channel_id?: string }) => boolean,
|
||||||
updater: (old: MessagePage | undefined) => MessagePage | undefined,
|
updater: (old: MessagePage | undefined) => MessagePage | undefined,
|
||||||
|
msg: { channel_id?: string },
|
||||||
) => {
|
) => {
|
||||||
void mutate(
|
void mutate(
|
||||||
(key) =>
|
(key) =>
|
||||||
Array.isArray(key) && key[0] === "messages" && key[1] === guildId,
|
Array.isArray(key) &&
|
||||||
|
key[0] === "messages" &&
|
||||||
|
key[1] === guildId &&
|
||||||
|
matcher(key, msg),
|
||||||
updater,
|
updater,
|
||||||
{ revalidate: false },
|
{ 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 unsub1 = ws.on("message_created", (data) => {
|
||||||
const msg = data as MessageRecord;
|
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 unsub2 = ws.on("message_updated", (data) => {
|
||||||
const msg = data as MessageRecord;
|
const msg = data as Partial<MessageRecord> & { id: string };
|
||||||
patchLists((old) =>
|
// The gateway broadcasts a PARTIAL update ({ id, edited_content,
|
||||||
old
|
// edited_at, ... }) — merge it over the existing record instead of
|
||||||
? { ...old, data: old.data.map((m) => (m.id === msg.id ? msg : m)) }
|
// replacing it, or the card would lose username/content/channel/etc.
|
||||||
: old,
|
patchLists(
|
||||||
|
(_k, m) =>
|
||||||
|
(m as Partial<MessageRecord>).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 unsub3 = ws.on("message_deleted", (data) => {
|
||||||
const { id } = data as { id: string };
|
const { id } = data as { id: string };
|
||||||
patchLists((old) =>
|
patchLists(
|
||||||
old ? { ...old, data: old.data.filter((m) => m.id !== id) } : old,
|
() => true,
|
||||||
|
(old) =>
|
||||||
|
old ? { ...old, data: old.data.filter((m) => m.id !== id) } : old,
|
||||||
|
{ channel_id: undefined },
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
const unsub4 = ws.on("message_analyzed", (data) => {
|
const unsub4 = ws.on("message_analyzed", (data) => {
|
||||||
const msg = data as MessageRecord;
|
const msg = data as MessageRecord;
|
||||||
patchLists((old) =>
|
// message_analyzed carries the FULL record — replace is fine.
|
||||||
old
|
patchLists(
|
||||||
? { ...old, data: old.data.map((m) => (m.id === msg.id ? msg : m)) }
|
(_k, m) => matchesFilter(_k as unknown[], m),
|
||||||
: old,
|
(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 });
|
void mutate(msgKeys.detail(msg.id), msg, { revalidate: false });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -27,7 +27,12 @@ export interface WsBinaryEvent {
|
|||||||
|
|
||||||
export interface WsEventMap {
|
export interface WsEventMap {
|
||||||
message_created: MessageRecord;
|
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<MessageRecord> & { id: string };
|
||||||
/** Gateway emits { id, deleted_at } — NOT a bare string */
|
/** Gateway emits { id, deleted_at } — NOT a bare string */
|
||||||
message_deleted: { id: string; deleted_at?: number };
|
message_deleted: { id: string; deleted_at?: number };
|
||||||
message_analyzed: MessageRecord;
|
message_analyzed: MessageRecord;
|
||||||
|
|||||||
Reference in New Issue
Block a user