Merge pull request #105 from ArnasDon/fix/inbox-realtime-hydrate-new-conversations

fix(inbox): hydrate new conversations from realtime so they surface
This commit is contained in:
Arnas Donauskas 2026-05-21 17:36:48 +03:00 committed by GitHub
commit aecad2e769
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

View File

@ -37,6 +37,64 @@ export default function InboxPage() {
// elsewhere. // elsewhere.
const autoSelectedForDeepLinkRef = useRef<string | null>(null); const autoSelectedForDeepLinkRef = useRef<string | null>(null);
// Tracks conversations whose hydrate fetch is currently in flight. The
// conv-INSERT and the first-message-INSERT events both call into
// hydrateConversation; the dedupe here keeps it at one refetch per
// new conversation even when both events arrive within milliseconds.
const hydratingConvIdsRef = useRef<Set<string>>(new Set());
// Pull the conversation row with its `contact` joined and merge it
// into state. Needed because Supabase Realtime payloads only carry the
// row's own columns — a brand-new conversation arrives without a
// contact, which surfaced as "Unknown" names, empty avatars, and
// (when the conv-INSERT event was delayed past the message-INSERT)
// conversations stuck on "No messages yet" until the user reloaded.
// Also self-heals if a realtime event was missed: callers can invoke
// this whenever they reference a conversation id they don't recognise.
const hydrateConversation = useCallback(async (convId: string) => {
if (hydratingConvIdsRef.current.has(convId)) return;
hydratingConvIdsRef.current.add(convId);
try {
const supabase = createClient();
const { data, error } = await supabase
.from("conversations")
.select("*, contact:contacts(*)")
.eq("id", convId)
.maybeSingle();
if (error) {
// Supabase errors have non-enumerable properties — log fields
// explicitly so the console message isn't just `{}`.
console.error("Failed to hydrate conversation:", {
message: error.message,
details: error.details,
hint: error.hint,
code: error.code,
});
return;
}
if (!data) return;
const fetched = data as Conversation;
setConversations((prev) => {
const existing = prev.find((c) => c.id === fetched.id);
if (existing) {
// Already in state — keep its fields (a realtime UPDATE may
// have landed while the fetch was in flight and patched
// last_message_text / unread_count to fresher values than
// the row we just read). Only backfill `contact`, which the
// realtime payloads never carry.
return prev.map((c) =>
c.id === fetched.id
? { ...c, contact: c.contact ?? fetched.contact }
: c,
);
}
return [fetched, ...prev];
});
} finally {
hydratingConvIdsRef.current.delete(convId);
}
}, []);
// Check WhatsApp connection status on mount // Check WhatsApp connection status on mount
useEffect(() => { useEffect(() => {
const checkConnection = async () => { const checkConnection = async () => {
@ -84,9 +142,18 @@ export default function InboxPage() {
}); });
} }
// Update conversation list preview // Update conversation list preview. If the conv isn't in state
setConversations((prev) => // yet (its INSERT event was missed, delayed, or this is the
prev.map((c) => // first message for a conversation created out-of-band) the
// map() below is a no-op and the preview would never appear —
// hydrate the row from the DB instead so the inbox self-heals.
let foundInList = false;
setConversations((prev) => {
if (!prev.some((c) => c.id === newMsg.conversation_id)) {
return prev;
}
foundInList = true;
return prev.map((c) =>
c.id === newMsg.conversation_id c.id === newMsg.conversation_id
? { ? {
...c, ...c,
@ -97,9 +164,12 @@ export default function InboxPage() {
? 0 ? 0
: c.unread_count + 1, : c.unread_count + 1,
} }
: c : c,
)
); );
});
if (!foundInList) {
hydrateConversation(newMsg.conversation_id);
}
} }
if (event.eventType === "UPDATE") { if (event.eventType === "UPDATE") {
@ -109,7 +179,7 @@ export default function InboxPage() {
); );
} }
}, },
[activeConversation] [activeConversation, hydrateConversation]
); );
// Handle realtime conversation events // Handle realtime conversation events
@ -122,13 +192,29 @@ export default function InboxPage() {
const conv = event.new; const conv = event.new;
if (event.eventType === "INSERT") { if (event.eventType === "INSERT") {
setConversations((prev) => [conv, ...prev]); // Prepend immediately for snappy UX, then hydrate to fill in
// the `contact` join (realtime payloads omit it). The dedupe
// inside hydrateConversation keeps the result idempotent if
// the first-message-INSERT handler also triggers a hydrate.
setConversations((prev) => {
if (prev.some((c) => c.id === conv.id)) return prev;
return [conv, ...prev];
});
hydrateConversation(conv.id);
} }
if (event.eventType === "UPDATE") { if (event.eventType === "UPDATE") {
setConversations((prev) => let foundInList = false;
prev.map((c) => (c.id === conv.id ? { ...c, ...conv } : c)) setConversations((prev) => {
); if (!prev.some((c) => c.id === conv.id)) return prev;
foundInList = true;
return prev.map((c) => (c.id === conv.id ? { ...c, ...conv } : c));
});
if (!foundInList) {
// UPDATE arrived before the INSERT (or after a missed
// INSERT) — fetch the row so it surfaces with its contact.
hydrateConversation(conv.id);
}
// Update active conversation if it changed // Update active conversation if it changed
if (activeConversation && conv.id === activeConversation.id) { if (activeConversation && conv.id === activeConversation.id) {
@ -138,7 +224,7 @@ export default function InboxPage() {
} }
} }
}, },
[activeConversation] [activeConversation, hydrateConversation]
); );
// Subscribe to realtime // Subscribe to realtime