diff --git a/apps/desktop/src/lib/useConversationMessages.ts b/apps/desktop/src/lib/useConversationMessages.ts index 3b10179..1e4c9ab 100644 --- a/apps/desktop/src/lib/useConversationMessages.ts +++ b/apps/desktop/src/lib/useConversationMessages.ts @@ -1,3 +1,4 @@ +import { fetchPeerPublicKeys } from '@chat-app/shared/auth'; import { type AttachmentHandle, type DecryptedMessage, @@ -8,6 +9,8 @@ import { MAX_ATTACHMENT_BYTES, type MessageWithCipher, sendEncryptedMessage, + shareConvKeyToUser, + tryGetConvKey, } from '@chat-app/shared/chat'; import { bytesToPgHex, pgBytesToBytes } from '@chat-app/shared/supabase'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; @@ -98,6 +101,96 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar }); }, [userId]); + // Proactive rewrap sweep: when a conversation opens, walk every accepted + // member and ensure the active conv-key has a `recipient_user_id` bundle + // for them. Members who are missing one (typically peers who haven't yet + // migrated to the per-user key model) get a best-effort wrap from the + // local conv-key handle. Closes the legacy migration gap so peer B can + // read on first unlock without manual intervention from A. + useEffect(() => { + if (!conversationId || !userId) return; + let cancelled = false; + const run = async (): Promise => { + // Wait for the private key ref to be populated. Loop with backoff + // because it's set asynchronously by another effect; bail if the + // conversation switches. + for (let i = 0; i < 20 && !privateKeyRef.current && !cancelled; i++) { + await new Promise((r) => setTimeout(r, 100)); + } + const priv = privateKeyRef.current; + if (!priv || cancelled) return; + + try { + const { data: convRow, error: convErr } = await supabase + .from('conversations') + .select('active_key_version') + .eq('id', conversationId) + .single(); + if (convErr || !convRow) return; + // db-types snapshot predates the active_key_version column; cast via unknown. + const version = (convRow as unknown as { active_key_version: number }).active_key_version; + + const handle = await tryGetConvKey(supabase, conversationId, userId, priv, version); + if (!handle || cancelled) return; + + const { data: members, error: mErr } = await supabase + .from('conversation_members') + .select('user_id, accepted') + .eq('conversation_id', conversationId); + if (mErr || !members) return; + const memberIds = (members as Array<{ user_id: string; accepted: boolean }>) + .filter((m) => m.accepted && m.user_id !== userId) + .map((m) => m.user_id); + if (memberIds.length === 0) return; + + const peers = await fetchPeerPublicKeys(supabase, memberIds); + for (const peer of peers) { + if (cancelled) return; + const { count, error: cntErr } = await ( + supabase as unknown as { + from: (t: string) => { + select: (s: string, o?: object) => { + eq: (...a: unknown[]) => { + eq: (...a: unknown[]) => { + eq: ( + ...a: unknown[] + ) => Promise<{ count: number | null; error: unknown }>; + }; + }; + }; + }; + } + ) + .from('conversation_keys') + .select('recipient_user_id', { count: 'exact', head: true }) + .eq('conversation_id', conversationId) + .eq('recipient_user_id', peer.userId) + .eq('key_version', version); + if (cntErr) continue; + if ((count ?? 0) === 0) { + try { + await shareConvKeyToUser( + supabase, + conversationId, + peer.userId, + peer.publicKey, + { userId, privateKey: priv }, + ); + } catch (err) { + console.warn('proactive rewrap failed for', peer.userId, err); + } + } + } + } catch (err) { + console.warn('proactive rewrap sweep failed', err); + } + }; + void run(); + return () => { + cancelled = true; + }; + }, [conversationId, userId]); + const decryptBatch = useCallback( async (messages: MessageWithCipher[]): Promise => { const priv = privateKeyRef.current;