From a4c9b959a93eecb39ba193c0e6de485a60bd739c Mon Sep 17 00:00:00 2001 From: Dennis Landmann Date: Mon, 20 Apr 2026 22:15:49 +0200 Subject: [PATCH] fix: editable decrypt + windows realtime wake (v0.7.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Edit message decrypt: - handleUpdate in useConversationMessages now refetches the canonical row via REST after a realtime UPDATE instead of trusting the realtime payload's bytea encoding. Same pattern as handleInsert — base64 vs `\x…` hex serialisation varies across supabase/postgrest versions and was silently producing undecryptable ciphertext for edited messages on the receiver side Windows WebView2 background throttling: - ConversationsContext, useFriendships and useConversationMessages now listen for visibilitychange / focus / online events and trigger both a fresh REST refresh and a best-effort channel.subscribe() on wake. WebView2 aggressively throttles background WebSockets and was dropping realtime events entirely while the window was minimised, so new messages and friend acceptances only surfaced after a manual reload Bump tauri version 0.7.0 -> 0.7.1 --- apps/desktop/src-tauri/tauri.conf.json | 2 +- .../src/context/ConversationsContext.tsx | 24 ++++++ .../src/lib/useConversationMessages.ts | 80 ++++++++++++++++++- apps/desktop/src/lib/useFriendships.ts | 17 ++++ 4 files changed, 118 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index 0380f3e..509581d 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "ChatApp", - "version": "0.7.0", + "version": "0.7.1", "identifier": "com.meinname.chatapp", "build": { "beforeDevCommand": "pnpm vite:dev", diff --git a/apps/desktop/src/context/ConversationsContext.tsx b/apps/desktop/src/context/ConversationsContext.tsx index d3549b2..76fc405 100644 --- a/apps/desktop/src/context/ConversationsContext.tsx +++ b/apps/desktop/src/context/ConversationsContext.tsx @@ -213,7 +213,31 @@ export function ConversationsProvider({ children }: { children: ReactNode }) { ) .subscribe(); + // Windows WebView2 aggressively throttles background WebSockets and + // sometimes drops events entirely while the window is minimised. Force a + // refresh + realtime reconnect on visibility/focus regain so we never + // leave stale conversation lists on a Windows client after the user + // returns to the app. + const onAwake = () => { + if (document.visibilityState !== 'visible') return; + void refresh(); + try { + // If the socket got wedged during background throttle, a no-op + // unsubscribe+resubscribe brings it back. `subscribe()` on an already + // joined channel is a no-op so this is safe. + channel.subscribe(); + } catch { + /* ignore — already live */ + } + }; + document.addEventListener('visibilitychange', onAwake); + window.addEventListener('focus', onAwake); + window.addEventListener('online', onAwake); + return () => { + document.removeEventListener('visibilitychange', onAwake); + window.removeEventListener('focus', onAwake); + window.removeEventListener('online', onAwake); void supabase.removeChannel(channel); }; }, [myId, refresh, markRead]); diff --git a/apps/desktop/src/lib/useConversationMessages.ts b/apps/desktop/src/lib/useConversationMessages.ts index 28fecc2..2b81c8f 100644 --- a/apps/desktop/src/lib/useConversationMessages.ts +++ b/apps/desktop/src/lib/useConversationMessages.ts @@ -192,18 +192,71 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar return { ...prev, messages: next }; }); if (partial.editedAt && !partial.deletedAt) { - const [decrypted] = await decryptBatch([partial]); + // Realtime bytea encoding varies (base64 vs hex, even null for + // unchanged columns on some configs). Refetch via REST to get the + // canonical `\x…` hex then decrypt — same pattern as handleInsert. + if (!conversationId || !deviceId) return; + let decrypted: DecryptedMessage | null = null; + for (let attempt = 0; attempt < 6; attempt++) { + const { data, error } = await supabase + .from('messages') + .select( + 'id, conversation_id, sender_id, sender_device_id, reply_to_id, edited_at, deleted_at, created_at, ciphertext, nonce, key_version', + ) + .eq('id', partial.id) + .maybeSingle(); + if (error) { + console.warn('handleUpdate refetch failed', error); + return; + } + if (!data) { + await new Promise((r) => window.setTimeout(r, 120 * (attempt + 1))); + continue; + } + const r = data as unknown as { + id: string; + conversation_id: string; + sender_id: string; + sender_device_id: string | null; + reply_to_id: string | null; + edited_at: string | null; + deleted_at: string | null; + created_at: string; + ciphertext: string; + nonce: string; + key_version: number; + }; + const msg: MessageWithCipher = { + id: r.id, + conversationId: r.conversation_id, + senderId: r.sender_id, + senderDeviceId: r.sender_device_id, + replyToId: r.reply_to_id, + editedAt: r.edited_at, + deletedAt: r.deleted_at, + createdAt: r.created_at, + ciphertext: pgBytesToBytes(String(r.ciphertext)), + nonce: pgBytesToBytes(String(r.nonce)), + keyVersion: r.key_version, + }; + const [d] = await decryptBatch([msg]); + if (d) { + decrypted = d; + if (d.plaintext !== null) break; + } + await new Promise((r) => window.setTimeout(r, 200 * (attempt + 1))); + } if (!decrypted) return; setState((prev) => { - const idx = prev.messages.findIndex((m) => m.id === decrypted.id); + const idx = prev.messages.findIndex((m) => m.id === decrypted!.id); if (idx === -1) return prev; const next = [...prev.messages]; - next[idx] = decrypted; + next[idx] = decrypted!; return { ...prev, messages: next }; }); } }, - [decryptBatch], + [conversationId, deviceId, decryptBatch], ); const handleDelete = useCallback((row: Record) => { @@ -256,7 +309,26 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar ) .subscribe(); + // Refresh + reconnect on wake from background throttle (mostly Windows + // WebView2). Without this, messages inserted while the window is + // minimised never arrive until the user explicitly reloads. + const onAwake = () => { + if (document.visibilityState !== 'visible') return; + void refresh(); + try { + channel.subscribe(); + } catch { + /* already live */ + } + }; + document.addEventListener('visibilitychange', onAwake); + window.addEventListener('focus', onAwake); + window.addEventListener('online', onAwake); + return () => { + document.removeEventListener('visibilitychange', onAwake); + window.removeEventListener('focus', onAwake); + window.removeEventListener('online', onAwake); void supabase.removeChannel(channel); }; }, [conversationId, userId, deviceId, refresh, handleInsert, handleUpdate, handleDelete]); diff --git a/apps/desktop/src/lib/useFriendships.ts b/apps/desktop/src/lib/useFriendships.ts index abf6adf..b8b18cf 100644 --- a/apps/desktop/src/lib/useFriendships.ts +++ b/apps/desktop/src/lib/useFriendships.ts @@ -44,7 +44,24 @@ export function useFriendships(userId: string | undefined): FriendshipsState & { }) .subscribe(); + // Windows WebView2 throttles background sockets — refresh on wake. + const onAwake = () => { + if (document.visibilityState !== 'visible') return; + void refresh(); + try { + channel.subscribe(); + } catch { + /* already live */ + } + }; + document.addEventListener('visibilitychange', onAwake); + window.addEventListener('focus', onAwake); + window.addEventListener('online', onAwake); + return () => { + document.removeEventListener('visibilitychange', onAwake); + window.removeEventListener('focus', onAwake); + window.removeEventListener('online', onAwake); void supabase.removeChannel(channel); }; }, [userId, refresh]);