Files
bitvid/js/services/encryptedSyncItem.js
thePR0M3TH3ANandClaude Opus 4.8 4f56a2bd26 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]>
2026-07-08 06:56:52 -04:00

180 lines
5.7 KiB
JavaScript

// One encrypted cross-login sync implementation, shared by every syncable item
// (NWC wallet, storage credentials, …). Previously walletSyncService and
// storageSyncService were near-identical copies; the only per-item differences
// are the d-tag, the sync "kind", the availability check, and how the payload is
// built (push) / applied (pull). This factory captures the shared machinery —
// per-pubkey enable flags, conflict-checked push, cheap remote existence check,
// enable/disable — so the two "systems" become one.
//
// Item config:
// id "wallet" | "storage" — the per-pubkey sync-flag kind
// dtag stable d-tag for the encrypted note
// encryptedSync the encrypted-sync facade
// available () => boolean (extra availability beyond encryptedSync)
// buildPayload async (pubkey) => payload | null (null => "nothing-to-sync")
// applyPayload async (pubkey, payload) => void | { imported:false, error }
// logLabel short label for warnings
import { encryptedSync as defaultEncryptedSync } from "../nostr/encryptedSyncFacade.js";
import {
isSyncEnabled,
setSyncEnabled,
getSyncPushedAt,
setSyncPushedAt,
} from "./settingsSyncFlags.js";
import { pushWithConflictCheck } from "./syncConflict.js";
import { userLogger } from "../utils/logger.js";
export function createEncryptedSyncItem({
id,
dtag,
encryptedSync = defaultEncryptedSync,
available = () => true,
buildPayload,
applyPayload,
logLabel = id || "sync",
} = {}) {
function normalizePubkey(pubkey) {
return typeof pubkey === "string" ? pubkey.trim() : "";
}
function isAvailable() {
return Boolean(encryptedSync?.isAvailable?.()) && Boolean(available());
}
function isEnabled(pubkey) {
return isSyncEnabled(normalizePubkey(pubkey), id);
}
function setEnabledFlag(pubkey, enabled) {
setSyncEnabled(normalizePubkey(pubkey), id, enabled);
}
// Cheap existence check (no decrypt) for the login-restore offer.
async function hasRemote() {
if (typeof encryptedSync.exists !== "function") {
return false;
}
const result = await encryptedSync.exists(dtag);
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) {
return { ok: false, error: "missing-pubkey" };
}
let payload;
try {
payload = await buildPayload(key);
} catch (error) {
userLogger.warn(`[${logLabel}] Failed to build sync payload:`, error);
return { ok: false, error: "export-failed", cause: error };
}
if (!payload) {
return { ok: false, error: "nothing-to-sync" };
}
return pushWithConflictCheck({
encryptedSync,
dTag: dtag,
kind: id,
pubkey: key,
payload,
confirmOverwrite,
});
}
async function pull(pubkey) {
const key = normalizePubkey(pubkey);
if (!key) {
return { found: false, error: "missing-pubkey" };
}
const result = await encryptedSync.pull(dtag);
if (!result?.found) {
return result;
}
try {
const applied = await applyPayload(key, result.payload || {});
if (applied && applied.imported === false) {
return { ...result, imported: false, error: applied.error };
}
} catch (error) {
userLogger.warn(`[${logLabel}] Failed to apply pulled settings:`, error);
return { ...result, imported: false, error: "import-failed", cause: error };
}
return { ...result, imported: true };
}
// #15 follow-up ("doesn't sync unless I click Restore"): a device with sync
// ENABLED auto-pulls a NEWER remote note at login. Previously enabled devices
// only pushed-on-save — edits from another device sat unseen until the manual
// Restore click. The per-device freshness marker doubles as "local state
// matches note X": it's recorded on push AND after a successful pull, so the
// next conflict check stays honest and the same note is never re-pulled.
async function autoPullIfNewer(pubkey) {
const key = normalizePubkey(pubkey);
if (!key || !isAvailable() || !isEnabled(key)) {
return { pulled: false, reason: "not-enabled" };
}
let remoteCreatedAt = 0;
try {
const peek = await encryptedSync.exists(dtag);
remoteCreatedAt = Number(peek?.createdAt) || 0;
} catch (error) {
return { pulled: false, reason: "peek-failed", cause: error };
}
const lastSynced = getSyncPushedAt(key, id);
if (!remoteCreatedAt || remoteCreatedAt <= lastSynced) {
return { pulled: false, reason: "up-to-date" };
}
const result = await pull(key);
if (result?.imported) {
setSyncPushedAt(key, id, remoteCreatedAt);
return { pulled: true, remoteCreatedAt };
}
return { pulled: false, reason: result?.error || "pull-failed" };
}
async function enable(pubkey, options = {}) {
setEnabledFlag(pubkey, true);
return push(pubkey, options);
}
async function disable(pubkey) {
setEnabledFlag(pubkey, false);
return encryptedSync.clear(dtag);
}
return {
id,
dtag,
isAvailable,
isEnabled,
hasRemote,
hasLocal,
push,
pull,
autoPullIfNewer,
enable,
disable,
};
}
export default createEncryptedSyncItem;