fix: editable decrypt + windows realtime wake (v0.7.1)
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
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "ChatApp",
|
"productName": "ChatApp",
|
||||||
"version": "0.7.0",
|
"version": "0.7.1",
|
||||||
"identifier": "com.meinname.chatapp",
|
"identifier": "com.meinname.chatapp",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "pnpm vite:dev",
|
"beforeDevCommand": "pnpm vite:dev",
|
||||||
|
|||||||
@@ -213,7 +213,31 @@ export function ConversationsProvider({ children }: { children: ReactNode }) {
|
|||||||
)
|
)
|
||||||
.subscribe();
|
.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 () => {
|
return () => {
|
||||||
|
document.removeEventListener('visibilitychange', onAwake);
|
||||||
|
window.removeEventListener('focus', onAwake);
|
||||||
|
window.removeEventListener('online', onAwake);
|
||||||
void supabase.removeChannel(channel);
|
void supabase.removeChannel(channel);
|
||||||
};
|
};
|
||||||
}, [myId, refresh, markRead]);
|
}, [myId, refresh, markRead]);
|
||||||
|
|||||||
@@ -192,18 +192,71 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
|||||||
return { ...prev, messages: next };
|
return { ...prev, messages: next };
|
||||||
});
|
});
|
||||||
if (partial.editedAt && !partial.deletedAt) {
|
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;
|
if (!decrypted) return;
|
||||||
setState((prev) => {
|
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;
|
if (idx === -1) return prev;
|
||||||
const next = [...prev.messages];
|
const next = [...prev.messages];
|
||||||
next[idx] = decrypted;
|
next[idx] = decrypted!;
|
||||||
return { ...prev, messages: next };
|
return { ...prev, messages: next };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[decryptBatch],
|
[conversationId, deviceId, decryptBatch],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDelete = useCallback((row: Record<string, unknown>) => {
|
const handleDelete = useCallback((row: Record<string, unknown>) => {
|
||||||
@@ -256,7 +309,26 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
|||||||
)
|
)
|
||||||
.subscribe();
|
.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 () => {
|
return () => {
|
||||||
|
document.removeEventListener('visibilitychange', onAwake);
|
||||||
|
window.removeEventListener('focus', onAwake);
|
||||||
|
window.removeEventListener('online', onAwake);
|
||||||
void supabase.removeChannel(channel);
|
void supabase.removeChannel(channel);
|
||||||
};
|
};
|
||||||
}, [conversationId, userId, deviceId, refresh, handleInsert, handleUpdate, handleDelete]);
|
}, [conversationId, userId, deviceId, refresh, handleInsert, handleUpdate, handleDelete]);
|
||||||
|
|||||||
@@ -44,7 +44,24 @@ export function useFriendships(userId: string | undefined): FriendshipsState & {
|
|||||||
})
|
})
|
||||||
.subscribe();
|
.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 () => {
|
return () => {
|
||||||
|
document.removeEventListener('visibilitychange', onAwake);
|
||||||
|
window.removeEventListener('focus', onAwake);
|
||||||
|
window.removeEventListener('online', onAwake);
|
||||||
void supabase.removeChannel(channel);
|
void supabase.removeChannel(channel);
|
||||||
};
|
};
|
||||||
}, [userId, refresh]);
|
}, [userId, refresh]);
|
||||||
|
|||||||
Reference in New Issue
Block a user