Compare commits

...

2 Commits

Author SHA1 Message Date
byGalax 58fa9487e3 fix(auth): replace navigator.locks with in-process serial lock to avoid 'lock stolen' aborts
Release desktop app / build (, windows-latest) (push) Has been cancelled
Release desktop app / build (--target universal-apple-darwin --bundles app,updater, macos-14) (push) Has been cancelled
2026-04-19 20:38:42 +02:00
byGalax d9b08592da fix(messages): refetch row via REST on realtime insert + optimistic sender update
Release desktop app / build (, windows-latest) (push) Has been cancelled
Release desktop app / build (--target universal-apple-darwin --bundles app,updater, macos-14) (push) Has been cancelled
2026-04-19 20:35:00 +02:00
3 changed files with 102 additions and 11 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "ChatApp",
"version": "0.3.5",
"version": "0.3.7",
"identifier": "com.meinname.chatapp",
"build": {
"beforeDevCommand": "pnpm vite:dev",
+86 -10
View File
@@ -98,26 +98,81 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
}
}, [conversationId, decryptBatch]);
// Realtime INSERT handler — decrypt + append (with retry for envelope race).
// Realtime INSERT handler — refetches the row via REST so we get the
// canonical bytea encoding (postgres_changes payloads serialize bytea
// differently and decoding them inline is brittle). Then decrypt + append.
// Skips if the message is already in state (e.g. optimistic insert from our
// own send), so the sender's cached copy isn't overwritten with a flicker.
const handleInsert = useCallback(
async (row: Record<string, unknown>) => {
if (!deviceId) return;
const msg = rowToMessage(row);
let decrypted: DecryptedMessage = { ...msg, plaintext: null };
if (!conversationId || !deviceId) return;
const id = String(row.id);
let alreadyHave = false;
setState((prev) => {
if (prev.messages.some((m) => m.id === id)) alreadyHave = true;
return prev;
});
if (alreadyHave) 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', id)
.maybeSingle();
if (error) {
console.warn('handleInsert refetch failed', error);
return;
}
if (!data) {
await new Promise((r) => window.setTimeout(r, 120 * (attempt + 1)));
continue;
}
// db-types snapshot predates the sender-key columns; cast to bypass.
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, 120 * (attempt + 1)));
await new Promise((r) => window.setTimeout(r, 200 * (attempt + 1)));
}
if (!decrypted) return;
setState((prev) => {
if (prev.messages.some((m) => m.id === decrypted.id)) return prev;
return { ...prev, messages: [...prev.messages, decrypted] };
if (prev.messages.some((m) => m.id === decrypted!.id)) return prev;
return { ...prev, messages: [...prev.messages, decrypted!] };
});
},
[deviceId, decryptBatch],
[conversationId, deviceId, decryptBatch],
);
const handleUpdate = useCallback(
@@ -219,7 +274,7 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
blobNonceHexByHandleId.set(res.handle.id, bytesToPgHex(res.nonce));
}
// 2. Send message (inserts messages + envelopes in one helper).
// 2. Send message (inserts messages + per-conversation key bundles).
const msg = await sendEncryptedMessage({
client: supabase,
conversationId,
@@ -230,7 +285,28 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
...(handles.length > 0 ? { attachmentHandles: handles } : {}),
});
// 3. Insert public attachment metadata rows pointing at the new message.
// 3. Optimistic insert — we already have the plaintext in hand and the
// server returned the row id, so add the message to local state
// immediately. Realtime will then no-op (handleInsert dedupes by id).
const attachmentsPayload =
handles.length === 0
? trimmed
: JSON.stringify({ v: 1, text: trimmed, attachments: handles });
setState((prev) => {
if (prev.messages.some((m) => m.id === msg.id)) return prev;
return {
...prev,
messages: [
...prev.messages,
{
...msg,
plaintext: attachmentsPayload,
} as DecryptedMessage,
],
};
});
// 4. Insert public attachment metadata rows pointing at the new message.
for (const h of handles) {
const blobNonce = blobNonceHexByHandleId.get(h.id) ?? '\\x';
await insertAttachmentRow(supabase, msg.id, h, blobNonce);
+15
View File
@@ -5,6 +5,20 @@ import type { Database, SupabaseConfig } from './types.js';
// Typed client alias used throughout the app.
export type AppSupabaseClient = SupabaseClient<Database>;
// Inline serial lock — replaces Supabase's default `navigator.locks` based
// lock that occasionally throws "Lock was stolen by another request" when
// the same origin opens multiple tabs / Tauri windows / HMR-reloaded
// modules. We only have one client instance per process so a simple promise
// chain serialises token-refresh fine without cross-tab coordination.
const acquireLock = (() => {
let chain: Promise<unknown> = Promise.resolve();
return async <R>(_name: string, _acquireTimeout: number, fn: () => Promise<R>): Promise<R> => {
const next = chain.then(() => fn(), () => fn());
chain = next.catch(() => undefined);
return next;
};
})();
export function createClient(config: SupabaseConfig): AppSupabaseClient {
return createSupabaseClient<Database>(config.url, config.anonKey, {
auth: {
@@ -12,6 +26,7 @@ export function createClient(config: SupabaseConfig): AppSupabaseClient {
autoRefreshToken: true,
persistSession: true,
detectSessionInUrl: config.detectSessionInUrl ?? false,
lock: acquireLock,
},
});
}