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:
asepharyana
2026-08-02 10:27:41 +07:00
parent a82265f4a9
commit 3f199aa70d
6 changed files with 92 additions and 22 deletions
@@ -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) {
@@ -86,7 +86,7 @@ export function MessageCard({
deleted
</Badge>
)}
{msg.type === "edited" && (
{(msg.type === "edited" || msg.edited_content) && (
<Badge
variant="outline"
className="text-[10px] px-1.5 py-0 h-4"
@@ -102,7 +102,10 @@ export function MessageCard({
"italic text-muted-foreground line-through",
)}
>
{renderMessageContent(msg.content, msg.metadata)}
{renderMessageContent(
msg.edited_content ?? msg.content,
msg.metadata,
)}
</p>
{(() => {
const u = extractFirstImage(msg.metadata);
@@ -44,8 +44,10 @@ export function MessageDetailView({
{/* Content */}
<div className="text-sm text-text-primary/90 leading-relaxed mb-4 whitespace-pre-wrap">
{renderMessageContent(message.content, message.metadata) ||
"(no text content)"}
{renderMessageContent(
message.edited_content ?? message.content,
message.metadata,
) || "(no text content)"}
</div>
{/* Attachments */}
@@ -44,8 +44,10 @@ export function MessageDetail({
{/* Content */}
<div className="text-sm text-text-primary/90 leading-relaxed mb-4 whitespace-pre-wrap">
{renderMessageContent(message.content, message.metadata) ||
"(no text content)"}
{renderMessageContent(
message.edited_content ?? message.content,
message.metadata,
) || "(no text content)"}
</div>
{/* Attachments */}
+59 -15
View File
@@ -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<MessageRecord> & { 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<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 { 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 });
});
+6 -1
View File
@@ -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<MessageRecord> & { id: string };
/** Gateway emits { id, deleted_at } — NOT a bare string */
message_deleted: { id: string; deleted_at?: number };
message_analyzed: MessageRecord;