From bccbc4c1caf6026d7b9fe9167668115427ee8895 Mon Sep 17 00:00:00 2001 From: RobbyV2 Date: Thu, 9 Jul 2026 14:40:01 -0400 Subject: [PATCH] Fix embeds intermittently lost until page refresh EmbedsResolved arrives as a separate ws event from the message it belongs to, with no ordering guarantee tying it to the message being present in the store. The handler applied embeds only via patchMessage, a silent no-op when the target message is not yet in any cache; merge also lets a later duplicate message echo (embeds: []) clobber embeds already applied. Both drop the embed until a refresh reloads history with embeds baked in. Buffer resolved embeds in a bounded map keyed by message id and enrich every recent-message insertion from it, so embeds survive regardless of event ordering. Co-Authored-By: Claude Opus 4.8 --- app/lib/__tests__/store_reducer.test.ts | 15 ++++++++++ app/lib/store.ts | 37 ++++++++++++++++++++----- 2 files changed, 45 insertions(+), 7 deletions(-) diff --git a/app/lib/__tests__/store_reducer.test.ts b/app/lib/__tests__/store_reducer.test.ts index cba5c1a..a95c194 100644 --- a/app/lib/__tests__/store_reducer.test.ts +++ b/app/lib/__tests__/store_reducer.test.ts @@ -106,6 +106,21 @@ describe('media / embeds patching via patchMessage', () => { expect(st().messages.c1[0].embeds.map(e => e.ord)).toEqual([0]) }) + test('embeds_resolved before the message is inserted still lands on the later insert', () => { + useStore.setState({ messages: { c1: [msg(10)] } }) + dispatch({ type: 'embeds_resolved', ...scoped, message_id: 80, embeds: [embed(0)] }) + dispatch({ type: 'message', ...scoped, message: msg(80, { channel_id: 1 }) }) + const stored = st().messages.c1.find(m => m.id === 80) + expect(stored?.embeds.map(e => e.ord)).toEqual([0]) + }) + + test('a later duplicate message echo does not clobber resolved embeds', () => { + useStore.setState({ messages: { c1: [msg(80, { channel_id: 1 })] } }) + dispatch({ type: 'embeds_resolved', ...scoped, message_id: 80, embeds: [embed(0)] }) + dispatch({ type: 'message', ...scoped, message: msg(80, { channel_id: 1, embeds: [] }) }) + expect(st().messages.c1[0].embeds.map(e => e.ord)).toEqual([0]) + }) + test('embeds_removed banner marks banner_removed; full removes the embed', () => { useStore.setState({ messages: { c1: [msg(90, { embeds: [embed(0), embed(1)] })] } }) dispatch({ type: 'embeds_removed', ...scoped, message_id: 90, ord: 0, banner: true }) diff --git a/app/lib/store.ts b/app/lib/store.ts index 2af87ad..d7452f8 100644 --- a/app/lib/store.ts +++ b/app/lib/store.ts @@ -185,6 +185,22 @@ const OPT_BASE = 1e15 let optSeq = 0 const draining = new Set() +const pendingEmbeds = new Map() +const PENDING_EMBEDS_MAX = 256 +const rememberEmbeds = (id: number, embeds: Embed[]) => { + pendingEmbeds.delete(id) + pendingEmbeds.set(id, embeds) + if (pendingEmbeds.size > PENDING_EMBEDS_MAX) + pendingEmbeds.delete(pendingEmbeds.keys().next().value as number) +} +const applyEmbeds = (list: Message[]) => + pendingEmbeds.size === 0 + ? list + : list.map(m => { + const e = pendingEmbeds.get(m.id) + return e ? { ...m, embeds: e } : m + }) + interface RChatState { token: string | null me: Me | null @@ -417,7 +433,7 @@ export const useStore = create()((set, get) => { view.kind === 'channel' ? await api.channelMessages(view.channelId) : await api.dmMessages(view.dmId) - set(s => ({ messages: { ...s.messages, [viewKey(view)]: [...msgs].sort(byId) } })) + set(s => ({ messages: { ...s.messages, [viewKey(view)]: applyEmbeds([...msgs].sort(byId)) } })) askP2p(msgs) if (view.kind === 'channel') { const detail = await api.getServer(view.server) @@ -807,7 +823,7 @@ export const useStore = create()((set, get) => { } set(s => ({ outbox: dropOutgoing(s.outbox, key, head.tempId), - messages: { ...s.messages, [key]: merge(s.messages[key] ?? [], [real]) }, + messages: { ...s.messages, [key]: applyEmbeds(merge(s.messages[key] ?? [], [real])) }, })) if (key[0] !== 't') get().markRead(key, real.id) } @@ -1019,7 +1035,9 @@ export const useStore = create()((set, get) => { const kind = get().servers[server]?.channels.find(c => c.id === channelId)?.kind if (kind === 'voice') return const msgs = await api.channelMessages(channelId) - set(s => ({ messages: { ...s.messages, [`c${channelId}`]: [...msgs].sort(byId) } })) + set(s => ({ + messages: { ...s.messages, [`c${channelId}`]: applyEmbeds([...msgs].sort(byId)) }, + })) askP2p(msgs) }), @@ -1034,7 +1052,7 @@ export const useStore = create()((set, get) => { syncUrl(view, nav) get().setViewing(null) const msgs = await api.dmMessages(dmId) - set(s => ({ messages: { ...s.messages, [`d${dmId}`]: [...msgs].sort(byId) } })) + set(s => ({ messages: { ...s.messages, [`d${dmId}`]: applyEmbeds([...msgs].sort(byId)) } })) askP2p(msgs) }), @@ -1151,7 +1169,9 @@ export const useStore = create()((set, get) => { act(async () => { set({ panel: { kind: 'thread', root }, threadPending: null }) const msgs = await api.threadMessages(root.id) - set(s => ({ messages: { ...s.messages, [`t${root.id}`]: [...msgs].sort(byId) } })) + set(s => ({ + messages: { ...s.messages, [`t${root.id}`]: applyEmbeds([...msgs].sort(byId)) }, + })) askP2p(msgs) }), @@ -1535,7 +1555,7 @@ export const useStore = create()((set, get) => { set(s => { const messages = { ...s.messages } const tkey = `t${rootId}` - if (messages[tkey]) messages[tkey] = merge(messages[tkey], [m]) + if (messages[tkey]) messages[tkey] = applyEmbeds(merge(messages[tkey], [m])) const ckey = m.channel_id !== null ? `c${m.channel_id}` : null if (ckey && messages[ckey]) { messages[ckey] = messages[ckey].map(r => @@ -1558,7 +1578,7 @@ export const useStore = create()((set, get) => { if (key) { set(s => s.messages[key] - ? { messages: { ...s.messages, [key]: merge(s.messages[key], [m]) } } + ? { messages: { ...s.messages, [key]: applyEmbeds(merge(s.messages[key], [m])) } } : {} ) } @@ -1652,10 +1672,13 @@ export const useStore = create()((set, get) => { return } case 'embeds_resolved': { + rememberEmbeds(ev.message_id, ev.embeds) patchMessage(ev.message_id, m => ({ ...m, embeds: ev.embeds })) return } case 'embeds_removed': { + const cur = pendingEmbeds.get(ev.message_id) + if (cur) rememberEmbeds(ev.message_id, withoutEmbed(cur, ev.ord, ev.banner)) patchMessage(ev.message_id, m => ({ ...m, embeds: withoutEmbed(m.embeds, ev.ord, ev.banner),