Wallet restore popup: re-offer on login when missing locally (like storage)

The wallet already rode the same login "restore from Nostr" offer as storage,
but a once-per-device "offered" flag suppressed it — so on a device without
your wallet you fell back to the manual Sync button. Now:

- encryptedSyncItem: add hasLocal(pubkey) (non-null payload => item present
  locally, e.g. a connected wallet / configured storage).
- settingsRestorePrompt: re-offer an item whenever it's MISSING locally and a
  Nostr backup exists — bypassing the "offered once" flag — so it surfaces
  exactly when you'd want it. Only respect the once-per-device flag when the
  item IS present locally (no nagging). A new per-device "dismissed" flag,
  set when you decline, keeps it quiet after an explicit "no".
- Accepting enables sync, so it auto-pulls silently thereafter (the "both"
  behavior: silent auto-pull when enabled + popup when missing & off).
- Unknown services (no hasLocal) keep the legacy behavior.
- tests: re-offer when missing despite prior offer; no re-offer when present;
  decline stays quiet.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-07-08 06:56:52 -04:00
1 parent c562d9ee65
commit 4f56a2bd26
3 files changed
+134 -5

No files matched your search

+17
View File
@@ -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,
+51 -3
View File
@@ -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 };
}
+66 -2
View File
@@ -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,7 +31,15 @@ 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();
@@ -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 });