mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-30 09:22:24 +00:00
feat(sync): offer-to-pull encrypted settings on login (one-time, non-blocking)
Completes 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. - js/nostr/encryptedSync.js: exists(dTag) — cheap list-only existence peek (NO decrypt, so it doesn't spend the signer budget); decryption only on accept. - storage/walletSyncService: hasRemote() wrappers over exists(). - js/services/settingsRestorePrompt.js: one-time per-pubkey offer. Only prompts for items with a remote copy not already enabled locally; if nothing remote exists it does NOT consume the one-time offer (a note published later can still prompt next login). On accept, pulls + applies. - app.js handleLoginModalSuccess: fires maybeOfferSettingsRestore(pubkey) after login, deferred ~2s so relays connect; best-effort, never blocks login; toasts on restore. tests/settings-restore-prompt.test.mjs: offer+restore-only-remote-items, decline, prompt-once, skip-already-enabled, don't-consume-when-empty. Mutation-verified. Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
1 parent
d552e9017c
commit
4186b4c4ba
6 files changed
+374
-1
No files matched your search
@@ -57,6 +57,9 @@ import {
|
||||
import watchHistoryService from "./watchHistoryService.js";
|
||||
import r2Service from "./services/r2Service.js";
|
||||
import storageService from "./services/storageService.js";
|
||||
import { storageSyncService } from "./services/storageSyncService.js";
|
||||
import { createWalletSyncService } from "./services/walletSyncService.js";
|
||||
import { createSettingsRestorePrompt } from "./services/settingsRestorePrompt.js";
|
||||
import {
|
||||
getVideoNoteErrorMessage,
|
||||
normalizeVideoNotePayload,
|
||||
@@ -1624,6 +1627,57 @@ class Application {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (pubkey) {
|
||||
// Best-effort, never blocks login: offer to restore encrypted settings
|
||||
// synced from another device.
|
||||
this.maybeOfferSettingsRestore(pubkey);
|
||||
}
|
||||
}
|
||||
|
||||
// One-time offer to restore encrypted storage/wallet settings on login (todo
|
||||
// #15). Existence check is list-only (no decrypt); decryption happens only if
|
||||
// the user accepts. Deferred so relays can connect first; failures are silent.
|
||||
maybeOfferSettingsRestore(pubkey) {
|
||||
const key =
|
||||
this.normalizeHexPubkey(pubkey) ||
|
||||
(typeof pubkey === "string" ? pubkey : "");
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
setTimeout(() => {
|
||||
try {
|
||||
if (!this._settingsRestorePrompt) {
|
||||
this._settingsRestorePrompt = createSettingsRestorePrompt({
|
||||
storageSync: storageSyncService,
|
||||
walletSync: createWalletSyncService({
|
||||
nwcSettings: this.nwcSettingsService,
|
||||
}),
|
||||
});
|
||||
}
|
||||
Promise.resolve(
|
||||
this._settingsRestorePrompt.maybeOffer(key, {
|
||||
onRestored: (items) => {
|
||||
const hasStorage = items.includes("storage");
|
||||
const hasWallet = items.includes("wallet");
|
||||
const label =
|
||||
hasStorage && hasWallet
|
||||
? "storage and wallet settings"
|
||||
: hasWallet
|
||||
? "wallet connection"
|
||||
: "storage settings";
|
||||
this.showSuccess(
|
||||
`Restored your ${label} from your Nostr account.`,
|
||||
);
|
||||
},
|
||||
}),
|
||||
).catch((error) => {
|
||||
devLogger.warn("[settingsRestore] offer failed:", error);
|
||||
});
|
||||
} catch (error) {
|
||||
devLogger.warn("[settingsRestore] offer setup failed:", error);
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
async handleLoginModalError(payload = {}) {
|
||||
|
||||
@@ -260,6 +260,48 @@ export function createEncryptedSyncManager(deps = {}) {
|
||||
return { found: true, payload, createdAt: newest.created_at, event: newest };
|
||||
}
|
||||
|
||||
// Cheap existence peek for the login-restore prompt: list-only, NO decrypt
|
||||
// (does not spend the signer's decrypt budget). Returns whether a non-cleared
|
||||
// note is present for the d-tag.
|
||||
async function exists(dTag) {
|
||||
const tag = normalizeDTag(dTag);
|
||||
const pubkey = resolvePubkey();
|
||||
if (!tag || !pubkey) {
|
||||
return { exists: false };
|
||||
}
|
||||
const relays =
|
||||
(typeof getReadRelays === "function" ? getReadRelays() : []) || [];
|
||||
if (typeof listEvents !== "function" || !relays.length) {
|
||||
return { exists: false };
|
||||
}
|
||||
let events = [];
|
||||
try {
|
||||
events = await listEvents(relays, [
|
||||
{ kinds: [APP_DATA_KIND], authors: [pubkey], "#d": [tag], limit: 20 },
|
||||
]);
|
||||
} catch (err) {
|
||||
return { exists: false, error: "fetch-failed" };
|
||||
}
|
||||
const newest = selectNewest(Array.isArray(events) ? events.flat() : []);
|
||||
if (!newest) {
|
||||
return { exists: false };
|
||||
}
|
||||
const rawContent =
|
||||
typeof newest.content === "string" ? newest.content.trim() : "";
|
||||
if (!rawContent) {
|
||||
return { exists: false, cleared: true };
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(rawContent);
|
||||
if (parsed && parsed.cleared === true) {
|
||||
return { exists: false, cleared: true };
|
||||
}
|
||||
} catch (err) {
|
||||
// Unparseable content still counts as "something is there".
|
||||
}
|
||||
return { exists: true, createdAt: newest.created_at };
|
||||
}
|
||||
|
||||
async function clear(dTag, options = {}) {
|
||||
const tag = normalizeDTag(dTag);
|
||||
if (!tag) {
|
||||
@@ -287,7 +329,7 @@ export function createEncryptedSyncManager(deps = {}) {
|
||||
return publishTemplate(template);
|
||||
}
|
||||
|
||||
return { isAvailable, push, pull, clear, APP_DATA_KIND };
|
||||
return { isAvailable, push, pull, exists, clear, APP_DATA_KIND };
|
||||
}
|
||||
|
||||
export default createEncryptedSyncManager;
|
||||
@@ -0,0 +1,127 @@
|
||||
// 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";
|
||||
|
||||
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 {};
|
||||
}
|
||||
}
|
||||
|
||||
function markOffered(pubkey) {
|
||||
try {
|
||||
if (typeof localStorage === "undefined") {
|
||||
return;
|
||||
}
|
||||
const map = readOffered();
|
||||
map[pubkey] = true;
|
||||
localStorage.setItem(OFFERED_KEY, JSON.stringify(map));
|
||||
} catch (error) {
|
||||
// Best-effort.
|
||||
}
|
||||
}
|
||||
|
||||
function hasOffered(pubkey) {
|
||||
return readOffered()[pubkey] === true;
|
||||
}
|
||||
|
||||
const ITEM_LABELS = {
|
||||
storage: "storage settings",
|
||||
wallet: "wallet connection",
|
||||
};
|
||||
|
||||
export function createSettingsRestorePrompt({
|
||||
storageSync = defaultStorageSync,
|
||||
walletSync = null,
|
||||
confirm = (message) =>
|
||||
typeof window !== "undefined" && typeof window.confirm === "function"
|
||||
? window.confirm(message)
|
||||
: false,
|
||||
logger = null,
|
||||
} = {}) {
|
||||
// Items not already enabled on THIS device that have a remote copy to restore.
|
||||
async function collectCandidates(pubkey) {
|
||||
const candidates = [];
|
||||
const services = [
|
||||
["storage", storageSync],
|
||||
["wallet", walletSync],
|
||||
];
|
||||
for (const [kind, service] of services) {
|
||||
if (!service?.isAvailable?.() || service.isEnabled(pubkey)) {
|
||||
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" };
|
||||
}
|
||||
if (hasOffered(key)) {
|
||||
return { offered: false, reason: "already-offered" };
|
||||
}
|
||||
|
||||
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 so we never nag again on this device.
|
||||
markOffered(key);
|
||||
|
||||
const list = candidates.map((kind) => ITEM_LABELS[kind]).join(" and ");
|
||||
const accepted = 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);
|
||||
}
|
||||
} 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;
|
||||
@@ -35,6 +35,16 @@ export function createStorageSyncService({
|
||||
setSyncEnabled(normalizePubkey(pubkey), SYNC_KIND, enabled);
|
||||
}
|
||||
|
||||
// Cheap check (no decrypt) for whether a synced note exists — for the
|
||||
// login-restore prompt.
|
||||
async function hasRemote() {
|
||||
if (typeof encryptedSync.exists !== "function") {
|
||||
return false;
|
||||
}
|
||||
const result = await encryptedSync.exists(STORAGE_SYNC_DTAG);
|
||||
return Boolean(result?.exists);
|
||||
}
|
||||
|
||||
// Push the current local storage account record to the user's encrypted note.
|
||||
async function push(pubkey) {
|
||||
const key = normalizePubkey(pubkey);
|
||||
@@ -90,6 +100,7 @@ export function createStorageSyncService({
|
||||
STORAGE_SYNC_DTAG,
|
||||
isAvailable,
|
||||
isEnabled,
|
||||
hasRemote,
|
||||
push,
|
||||
pull,
|
||||
enable,
|
||||
|
||||
@@ -48,6 +48,16 @@ export function createWalletSyncService({
|
||||
return payload;
|
||||
}
|
||||
|
||||
// Cheap check (no decrypt) for whether a synced note exists — for the
|
||||
// login-restore prompt.
|
||||
async function hasRemote() {
|
||||
if (typeof encryptedSync.exists !== "function") {
|
||||
return false;
|
||||
}
|
||||
const result = await encryptedSync.exists(WALLET_SYNC_DTAG);
|
||||
return Boolean(result?.exists);
|
||||
}
|
||||
|
||||
async function push(pubkey) {
|
||||
const key = normalizePubkey(pubkey);
|
||||
if (!key) {
|
||||
@@ -101,6 +111,7 @@ export function createWalletSyncService({
|
||||
WALLET_SYNC_DTAG,
|
||||
isAvailable,
|
||||
isEnabled,
|
||||
hasRemote,
|
||||
push,
|
||||
pull,
|
||||
enable,
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
// Offer-to-pull on login (todo #15): after login, if an encrypted copy exists on
|
||||
// the user's account and this device hasn't opted in, offer to restore — once.
|
||||
// Scenarios assert observable outcomes:
|
||||
// - prompts and restores only the items that have a remote copy + are not
|
||||
// already enabled locally
|
||||
// - declining does not restore
|
||||
// - it never nags twice once it has prompted (per pubkey)
|
||||
// - if nothing remote exists, it does NOT consume the one-time offer
|
||||
|
||||
import "./test-helpers/setup-localstorage.mjs";
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { createSettingsRestorePrompt } from "../js/services/settingsRestorePrompt.js";
|
||||
|
||||
const PUBKEY = "c".repeat(64);
|
||||
|
||||
function makeSyncStub({ available = true, enabled = false, remote = false } = {}) {
|
||||
return {
|
||||
pulls: 0,
|
||||
isAvailable: () => available,
|
||||
isEnabled: () => enabled,
|
||||
hasRemote: async () => remote,
|
||||
pull: async function () {
|
||||
this.pulls += 1;
|
||||
return { found: true, imported: true };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("offers and restores items that have a remote copy and aren't already enabled", async () => {
|
||||
localStorage.clear();
|
||||
const storageSync = makeSyncStub({ remote: true });
|
||||
const walletSync = makeSyncStub({ remote: false }); // no wallet note
|
||||
const restoredCalls = [];
|
||||
const prompt = createSettingsRestorePrompt({
|
||||
storageSync,
|
||||
walletSync,
|
||||
confirm: () => true,
|
||||
});
|
||||
|
||||
const result = await prompt.maybeOffer(PUBKEY, {
|
||||
onRestored: (items) => restoredCalls.push(items),
|
||||
});
|
||||
|
||||
assert.equal(result.offered, true);
|
||||
assert.equal(result.accepted, true);
|
||||
assert.deepEqual(result.restored, ["storage"]);
|
||||
assert.equal(storageSync.pulls, 1, "storage (has remote) must be pulled");
|
||||
assert.equal(walletSync.pulls, 0, "wallet (no remote) must NOT be pulled");
|
||||
assert.deepEqual(restoredCalls, [["storage"]]);
|
||||
});
|
||||
|
||||
test("declining the prompt restores nothing", async () => {
|
||||
localStorage.clear();
|
||||
const storageSync = makeSyncStub({ remote: true });
|
||||
const prompt = createSettingsRestorePrompt({
|
||||
storageSync,
|
||||
walletSync: makeSyncStub({ remote: false }),
|
||||
confirm: () => false,
|
||||
});
|
||||
|
||||
const result = await prompt.maybeOffer(PUBKEY);
|
||||
assert.equal(result.offered, true);
|
||||
assert.equal(result.accepted, false);
|
||||
assert.equal(storageSync.pulls, 0, "decline must not pull");
|
||||
});
|
||||
|
||||
test("does not offer twice once it has prompted (per pubkey)", async () => {
|
||||
localStorage.clear();
|
||||
let confirmCount = 0;
|
||||
const prompt = createSettingsRestorePrompt({
|
||||
storageSync: makeSyncStub({ remote: true }),
|
||||
walletSync: makeSyncStub({ remote: false }),
|
||||
confirm: () => {
|
||||
confirmCount += 1;
|
||||
return false;
|
||||
},
|
||||
});
|
||||
|
||||
await prompt.maybeOffer(PUBKEY);
|
||||
const second = await prompt.maybeOffer(PUBKEY);
|
||||
assert.equal(confirmCount, 1, "must prompt only once");
|
||||
assert.equal(second.offered, false);
|
||||
assert.equal(second.reason, "already-offered");
|
||||
});
|
||||
|
||||
test("skips items already enabled on this device", async () => {
|
||||
localStorage.clear();
|
||||
const storageSync = makeSyncStub({ remote: true, enabled: true }); // already opted in here
|
||||
const walletSync = makeSyncStub({ remote: true, enabled: false });
|
||||
const prompt = createSettingsRestorePrompt({
|
||||
storageSync,
|
||||
walletSync,
|
||||
confirm: () => true,
|
||||
});
|
||||
|
||||
const result = await prompt.maybeOffer(PUBKEY);
|
||||
assert.deepEqual(result.restored, ["wallet"], "only the not-yet-enabled item is offered");
|
||||
assert.equal(storageSync.pulls, 0);
|
||||
assert.equal(walletSync.pulls, 1);
|
||||
});
|
||||
|
||||
test("nothing remote: does NOT consume the one-time offer (can prompt later)", async () => {
|
||||
localStorage.clear();
|
||||
const storageSync = makeSyncStub({ remote: false });
|
||||
const walletSync = makeSyncStub({ remote: false });
|
||||
let confirmCount = 0;
|
||||
const prompt = createSettingsRestorePrompt({
|
||||
storageSync,
|
||||
walletSync,
|
||||
confirm: () => {
|
||||
confirmCount += 1;
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
const first = await prompt.maybeOffer(PUBKEY);
|
||||
assert.equal(first.offered, false);
|
||||
assert.equal(first.reason, "no-remote");
|
||||
assert.equal(confirmCount, 0);
|
||||
|
||||
// Later a note appears; the offer is still available.
|
||||
storageSync.hasRemote = async () => true;
|
||||
const later = await prompt.maybeOffer(PUBKEY);
|
||||
assert.equal(later.offered, true);
|
||||
assert.equal(later.accepted, true);
|
||||
assert.deepEqual(later.restored, ["storage"]);
|
||||
});
|
||||
Reference in new issue
Block a user