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,
|
||||
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 */}
|
||||
|
||||
@@ -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) =>
|
||||
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 ? msg : m)) }
|
||||
? {
|
||||
...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) =>
|
||||
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) =>
|
||||
// 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 });
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user