+ )}
{pending.map((req) => {
const busy = busyId === req.deviceId;
const errored = errorId === req.deviceId;
diff --git a/apps/desktop/src/lib/deviceApproval.ts b/apps/desktop/src/lib/deviceApproval.ts
index edd7e1f..539008f 100644
--- a/apps/desktop/src/lib/deviceApproval.ts
+++ b/apps/desktop/src/lib/deviceApproval.ts
@@ -166,22 +166,56 @@ async function loadInitialPending(ctx: DeviceApprovalListenerCtx): Promise
}
const rows = (data ?? []) as DeviceRowLite[];
- // Find OWN device's createdAt — anything older than that is a pre-existing
- // device that was already legit before this client came online and should
- // NOT raise an approval banner. Without this guard, a freshly-installed
- // client lights up with one banner per pre-existing device of the user
- // ("zich messages zur freigabe") which is the opposite of what we want:
- // approval makes sense on the OLDER device judging the NEWER one, never
- // the other way round.
+ // Pre-filter: anything already in approved/dismissed sets, plus own
+ // device, never surfaces.
+ const candidates = rows.filter((r) => shouldSurface(r.id, ctx.ownDeviceId));
+ if (candidates.length === 0) return;
+
+ // Semantic skip: any candidate that already has a `conversation_keys`
+ // wrap somewhere is by definition legit — it was either auto-shared
+ // back when that path was enabled (pre-0.16.2) or explicitly approved
+ // earlier. Surfacing it now would just nag the user about something
+ // already taken care of. Bulk query against `conversation_keys` for
+ // all candidate device IDs at once; cheap, avoids N+1.
+ const candidateIds = candidates.map((c) => c.id);
+ const wrappedIds = new Set();
+ try {
+ const { data: keyRows, error: kErr } = await (
+ supabase as unknown as {
+ from: (t: string) => {
+ select: (cols: string) => {
+ in: (
+ col: string,
+ vals: string[],
+ ) => Promise<{ data: { recipient_device_id: string }[] | null; error: unknown }>;
+ };
+ };
+ }
+ )
+ .from('conversation_keys')
+ .select('recipient_device_id')
+ .in('recipient_device_id', candidateIds);
+ if (!kErr && keyRows) {
+ for (const r of keyRows) wrappedIds.add(r.recipient_device_id);
+ }
+ } catch (err) {
+ console.warn('deviceApproval: conv-key existence probe failed', err);
+ }
+
+ // Defense in depth: also keep the created_at guard. Devices older than
+ // own can't reasonably need approval — when own came online a fanout
+ // pass already covered them. Helps when the conv_keys probe returns
+ // partial data due to RLS.
const own = rows.find((r) => r.id === ctx.ownDeviceId);
const ownCreatedAt = own ? Date.parse(own.created_at) : Number.NEGATIVE_INFINITY;
- for (const row of rows) {
- if (!shouldSurface(row.id, ctx.ownDeviceId)) continue;
+ for (const row of candidates) {
+ if (wrappedIds.has(row.id)) {
+ persistApproved(row.id);
+ continue;
+ }
const rowCreatedAt = Date.parse(row.created_at);
if (Number.isFinite(rowCreatedAt) && rowCreatedAt <= ownCreatedAt) {
- // Older / equal-age device — auto-treat as already approved on this
- // side so it never re-prompts (covers reloads + future fetches).
persistApproved(row.id);
continue;
}
@@ -274,3 +308,15 @@ export function denyDevice(deviceId: string): void {
persistDismissed(deviceId);
removePending(deviceId);
}
+
+// Bulk-deny: persists every currently-pending deviceId into the dismissed
+// set and clears the in-memory list in one notify. Useful when a user has
+// accumulated stale entries from old test devices / migrations.
+export function denyAllPending(): void {
+ if (pending.length === 0) return;
+ const dismissed = readIdSet(DISMISSED_KEY);
+ for (const p of pending) dismissed.add(p.deviceId);
+ writeIdSet(DISMISSED_KEY, dismissed);
+ pending = [];
+ notify();
+}