Files
bitvid/js/services/settingsRestorePrompt.js
T
thePR0M3TH3ANandClaude Fable 5 e2d4251035 sync/zaps: enabled devices auto-pull newer wallet/storage notes at login; zap errors name the failing address (#15 follow-up)
Two fixes from live testing:

- "NWC doesn't sync unless I click Restore": devices with sync ENABLED only
  ever pushed-on-save — edits from another device sat unseen until the
  manual Restore click. encryptedSyncItem.autoPullIfNewer pulls the remote
  note at login when its created_at is newer than this device's last-synced
  marker (set on push AND now after a successful pull, so the same note is
  never re-pulled and the next conflict check stays honest). Wired into the
  login restore flow ahead of the one-time offer; disabled items are never
  touched.

- "Could not get user information" on a known-good creator address: that
  reason string comes from the LNURL SERVER, and the failing fetch was the
  PLATFORM-FEE address, not the creator's. prepareLightningContext now
  wraps both metadata fetches so the toast names which address failed
  (e.g. "Platform fee Lightning address (…): Could not get user
  information") instead of blaming the creator.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-04 08:56:05 -04:00

180 lines
6.1 KiB
JavaScript

// One-time "offer to pull on login" for encrypted settings sync (todo #15).
// On login, if an encrypted copy of the user's storage and/or wallet settings
// exists on their Nostr account — and this device hasn't already opted in — offer
// to restore it. Runs AFTER login (never blocks it) and respects the signer
// budget: the existence check is list-only (no decrypt); decryption happens only
// if the user accepts.
import { storageSyncService as defaultStorageSync } from "./storageSyncService.js";
import { showConfirm } from "../ui/confirmDialog.js";
import { setSyncEnabled } from "./settingsSyncFlags.js";
const OFFERED_KEY = "bitvid:settings-sync:offered:v1";
function readOffered() {
try {
if (typeof localStorage === "undefined") {
return {};
}
const raw = localStorage.getItem(OFFERED_KEY);
const parsed = raw ? JSON.parse(raw) : {};
return parsed && typeof parsed === "object" ? parsed : {};
} catch (error) {
return {};
}
}
// 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".
// Returns { storage?: true, wallet?: true }. Legacy boolean `true` (pre per-item)
// is read as storage-only so existing users still get the wallet offer.
function readOfferedKinds(pubkey) {
const entry = readOffered()[pubkey];
if (entry === true) {
return { storage: true };
}
return entry && typeof entry === "object" ? entry : {};
}
function markOffered(pubkey, kind) {
try {
if (typeof localStorage === "undefined") {
return;
}
const map = readOffered();
const current = map[pubkey] === true ? { storage: true } : map[pubkey];
const kinds = current && typeof current === "object" ? { ...current } : {};
kinds[kind] = true;
map[pubkey] = kinds;
localStorage.setItem(OFFERED_KEY, JSON.stringify(map));
} catch (error) {
// Best-effort.
}
}
function hasOffered(pubkey, kind) {
return readOfferedKinds(pubkey)[kind] === true;
}
const ITEM_LABELS = {
storage: "storage settings",
wallet: "wallet connection",
};
export function createSettingsRestorePrompt({
storageSync = defaultStorageSync,
walletSync = null,
confirm = (message) => showConfirm(message, { confirmLabel: "Restore" }),
logger = null,
} = {}) {
// Items not already enabled / not already offered on THIS device that have a
// remote copy to restore. Each kind is considered INDEPENDENTLY so a wallet note
// published after storage was already offered is still surfaced.
async function collectCandidates(pubkey) {
const candidates = [];
const services = [
["storage", storageSync],
["wallet", walletSync],
];
for (const [kind, service] of services) {
if (
!service?.isAvailable?.() ||
service.isEnabled(pubkey) ||
hasOffered(pubkey, kind)
) {
continue;
}
try {
if (await service.hasRemote()) {
candidates.push(kind);
}
} catch (error) {
logger?.warn?.(`[settingsRestore] ${kind} hasRemote check failed`, error);
}
}
return candidates;
}
async function maybeOffer(pubkey, { onRestored } = {}) {
const key = typeof pubkey === "string" ? pubkey.trim() : "";
if (!key) {
return { offered: false, reason: "no-pubkey" };
}
// Items ALREADY enabled on this device sync silently: pull the remote note
// when it's newer than what this device last pushed/pulled (i.e. another
// device changed it). No prompt — the user opted in when enabling sync.
// Without this, enabled devices only ever pushed, and edits from another
// device sat unseen until the manual Restore click ("doesn't sync").
const autoPulled = [];
for (const [kind, service] of [
["storage", storageSync],
["wallet", walletSync],
]) {
if (typeof service?.autoPullIfNewer !== "function") {
continue;
}
try {
const result = await service.autoPullIfNewer(key);
if (result?.pulled) {
autoPulled.push(kind);
}
} catch (error) {
logger?.warn?.(`[settingsRestore] ${kind} auto-pull failed`, error);
}
}
if (autoPulled.length && typeof onRestored === "function") {
onRestored(autoPulled);
}
const candidates = await collectCandidates(key);
if (!candidates.length) {
// Don't mark offered — a note may be published later from another device;
// the existence check is cheap (list-only) so re-checking next login is fine.
return { offered: false, reason: "no-remote" };
}
// We are about to prompt — mark EACH offered kind so we never nag again for it
// on this device (but other kinds stay eligible for a future login).
for (const kind of candidates) {
markOffered(key, kind);
}
const list = candidates.map((kind) => ITEM_LABELS[kind]).join(" and ");
const accepted = await confirm(
`Found an encrypted copy of your ${list} on your Nostr account. ` +
`Restore ${candidates.length > 1 ? "them" : "it"} to this device?`
);
if (!accepted) {
return { offered: true, accepted: false };
}
const restored = [];
const serviceByKind = { storage: storageSync, wallet: walletSync };
for (const kind of candidates) {
try {
const result = await serviceByKind[kind].pull(key);
if (result?.imported) {
restored.push(kind);
// Accepting the offer means "keep this device in sync", not just a
// one-time pull — enable the flag so future saves auto-push (the toggle
// then reflects reality). Without this, users restored once but never
// synced again ("not syncing").
setSyncEnabled(key, kind, true);
}
} catch (error) {
logger?.warn?.(`[settingsRestore] ${kind} pull failed`, error);
}
}
if (restored.length && typeof onRestored === "function") {
onRestored(restored);
}
return { offered: true, accepted: true, restored };
}
return { maybeOffer };
}
export default createSettingsRestorePrompt;