diff --git a/js/services/encryptedSyncItem.js b/js/services/encryptedSyncItem.js index 50cace7b..0c658f4f 100644 --- a/js/services/encryptedSyncItem.js +++ b/js/services/encryptedSyncItem.js @@ -59,6 +59,22 @@ export function createEncryptedSyncItem({ return Boolean(result?.exists); } + // Does this device already have the item locally? (A non-null payload means + // there's something to sync — e.g. a connected wallet / configured storage.) + // Used by the login offer to re-surface a restore when the item is MISSING + // here but a Nostr backup exists. + async function hasLocal(pubkey) { + const key = normalizePubkey(pubkey); + if (!key) { + return false; + } + try { + return Boolean(await buildPayload(key)); + } catch (error) { + return false; + } + } + async function push(pubkey, { confirmOverwrite } = {}) { const key = normalizePubkey(pubkey); if (!key) { @@ -151,6 +167,7 @@ export function createEncryptedSyncItem({ isAvailable, isEnabled, hasRemote, + hasLocal, push, pull, autoPullIfNewer, diff --git a/js/services/settingsRestorePrompt.js b/js/services/settingsRestorePrompt.js index e4eca292..0896d65f 100644 --- a/js/services/settingsRestorePrompt.js +++ b/js/services/settingsRestorePrompt.js @@ -10,13 +10,16 @@ import { showConfirm } from "../ui/confirmDialog.js"; import { setSyncEnabled } from "./settingsSyncFlags.js"; const OFFERED_KEY = "bitvid:settings-sync:offered:v1"; +// Set when the user explicitly declines a restore offer, so the "re-offer while +// the item is missing locally" behavior below doesn't nag them on every login. +const DISMISSED_KEY = "bitvid:settings-sync:dismissed:v1"; -function readOffered() { +function readMap(storageKey) { try { if (typeof localStorage === "undefined") { return {}; } - const raw = localStorage.getItem(OFFERED_KEY); + const raw = localStorage.getItem(storageKey); const parsed = raw ? JSON.parse(raw) : {}; return parsed && typeof parsed === "object" ? parsed : {}; } catch (error) { @@ -24,6 +27,31 @@ function readOffered() { } } +function readOffered() { + return readMap(OFFERED_KEY); +} + +function isDismissed(pubkey, kind) { + const entry = readMap(DISMISSED_KEY)[pubkey]; + return Boolean(entry && typeof entry === "object" && entry[kind]); +} + +function markDismissed(pubkey, kind) { + try { + if (typeof localStorage === "undefined") { + return; + } + const map = readMap(DISMISSED_KEY); + const kinds = + map[pubkey] && typeof map[pubkey] === "object" ? { ...map[pubkey] } : {}; + kinds[kind] = true; + map[pubkey] = kinds; + localStorage.setItem(DISMISSED_KEY, JSON.stringify(map)); + } catch (error) { + // Best-effort. + } +} + // Per-pubkey, PER-ITEM offered state. Tracking only per-pubkey meant the first // item offered (usually storage) flagged the whole pubkey, so a wallet note // published later was never offered — "the wallet doesn't restore like storage". @@ -78,13 +106,29 @@ export function createSettingsRestorePrompt({ ["wallet", walletSync], ]; for (const [kind, service] of services) { + // Enabled items auto-pull silently elsewhere; declined items stay quiet. if ( !service?.isAvailable?.() || service.isEnabled(pubkey) || - hasOffered(pubkey, kind) + isDismissed(pubkey, kind) ) { continue; } + // Re-offer whenever the item is MISSING locally (e.g. a fresh device or a + // cleared wallet) so a Nostr backup gets surfaced — "restore like storage". + // Only when we DO have it locally do we respect the once-per-device flag + // (no nagging). Unknown (no hasLocal) → assume present → keep old behavior. + let localCopy = true; + if (typeof service.hasLocal === "function") { + try { + localCopy = Boolean(await service.hasLocal(pubkey)); + } catch (error) { + localCopy = true; + } + } + if (localCopy && hasOffered(pubkey, kind)) { + continue; + } try { if (await service.hasRemote()) { candidates.push(kind); @@ -147,6 +191,10 @@ export function createSettingsRestorePrompt({ `Restore ${candidates.length > 1 ? "them" : "it"} to this device?` ); if (!accepted) { + // Remember the decline so we don't re-offer a missing item every login. + for (const kind of candidates) { + markDismissed(key, kind); + } return { offered: true, accepted: false }; } diff --git a/tests/settings-restore-prompt.test.mjs b/tests/settings-restore-prompt.test.mjs index 56824e3d..c40b7572 100644 --- a/tests/settings-restore-prompt.test.mjs +++ b/tests/settings-restore-prompt.test.mjs @@ -15,8 +15,13 @@ import { isSyncEnabled } from "../js/services/settingsSyncFlags.js"; const PUBKEY = "c".repeat(64); -function makeSyncStub({ available = true, enabled = false, remote = false } = {}) { - return { +function makeSyncStub({ + available = true, + enabled = false, + remote = false, + local = undefined, +} = {}) { + const stub = { pulls: 0, isAvailable: () => available, isEnabled: () => enabled, @@ -26,8 +31,16 @@ function makeSyncStub({ available = true, enabled = false, remote = false } = {} return { found: true, imported: true }; }, }; + // Only real services expose hasLocal; omitting it (local === undefined) keeps + // the legacy "assume present" behavior the older tests rely on. + if (local !== undefined) { + stub.hasLocal = async () => local; + } + return stub; } +const OFFERED_KEY = "bitvid:settings-sync:offered:v1"; + test("offers and restores items that have a remote copy and aren't already enabled", async () => { localStorage.clear(); const storageSync = makeSyncStub({ remote: true }); @@ -51,6 +64,57 @@ test("offers and restores items that have a remote copy and aren't already enabl assert.deepEqual(restoredCalls, [["storage"]]); }); +test("re-offers a MISSING item even after it was already offered once (restore-like-storage)", async () => { + localStorage.clear(); + // Simulate a prior offer for wallet on this device (the old blocker). + localStorage.setItem(OFFERED_KEY, JSON.stringify({ [PUBKEY]: { wallet: true } })); + const walletSync = makeSyncStub({ remote: true, local: false }); + const prompt = createSettingsRestorePrompt({ + storageSync: makeSyncStub({ remote: false, local: true }), + walletSync, + confirm: () => true, + }); + + const result = await prompt.maybeOffer(PUBKEY); + assert.equal(result.accepted, true); + assert.deepEqual(result.restored, ["wallet"]); + assert.equal(walletSync.pulls, 1, "missing wallet is re-surfaced despite the prior offer"); +}); + +test("does NOT re-offer an item that is present locally once prompted", async () => { + localStorage.clear(); + localStorage.setItem(OFFERED_KEY, JSON.stringify({ [PUBKEY]: { wallet: true } })); + const walletSync = makeSyncStub({ remote: true, local: true }); + const prompt = createSettingsRestorePrompt({ + storageSync: makeSyncStub({ remote: false, local: true }), + walletSync, + confirm: () => true, + }); + + const result = await prompt.maybeOffer(PUBKEY); + assert.equal(result.offered, false, "present + already offered → no nag"); + assert.equal(walletSync.pulls, 0); +}); + +test("dismissing a missing item stops it re-offering every login", async () => { + localStorage.clear(); + const walletSync = makeSyncStub({ remote: true, local: false }); + const prompt = createSettingsRestorePrompt({ + storageSync: makeSyncStub({ remote: false, local: true }), + walletSync, + confirm: () => false, // decline + }); + + const first = await prompt.maybeOffer(PUBKEY); + assert.equal(first.offered, true); + assert.equal(first.accepted, false); + + // Next login: still missing locally, but the decline is remembered. + const second = await prompt.maybeOffer(PUBKEY); + assert.equal(second.offered, false, "declined → stays quiet even though still missing"); + assert.equal(walletSync.pulls, 0); +}); + test("declining the prompt restores nothing", async () => { localStorage.clear(); const storageSync = makeSyncStub({ remote: true });