mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
feat(sync): Storage pane UI for encrypted settings sync (opt-in toggle + restore)
Wires the storage-credential sync (6f7f4196) into the profile Storage pane.
UI (components/profile-modal.html): a "Cross-device sync" card shown only when
storage is unlocked — an opt-in toggle ("Sync these settings to my Nostr account
(encrypted)"), a "Restore from my Nostr account" button, the public-relay /
key-compromise warning copy, and a status line.
Controller (ProfileStorageController):
- toggle on -> storageSyncService.enable() (encrypt + publish), with rollback +
message if nothing is stored yet; toggle off -> disable() (wipe the note)
- restore -> pull() + re-render the pane so the restored connection shows
- after a successful Save, re-push the synced copy when sync is enabled
Also routes the core's relay READ through the subscription manager (injected
listEvents) instead of pool.list, satisfying lint:pool-access for the new file;
the facade supplies it via nostrClient.getSubscriptionManager().list.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
1 parent
6f7f419636
commit
7376ff91e0
5 files changed
+185
-3
No files matched your search
@@ -942,6 +942,33 @@
|
||||
</button>
|
||||
</div>
|
||||
<p id="storageFormStatus" class="text-sm" role="status" aria-live="polite"></p>
|
||||
|
||||
<div id="profileStorageSync" class="mt-2 rounded-lg border border-default p-4 space-y-3 hidden">
|
||||
<div class="space-y-1">
|
||||
<h4 class="text-sm font-semibold text-text">Cross-device sync</h4>
|
||||
<p class="text-xs text-muted">
|
||||
Save an <strong>encrypted</strong> copy of these storage
|
||||
settings to your Nostr account so you don't have to
|
||||
re-enter them on other devices.
|
||||
</p>
|
||||
</div>
|
||||
<label class="flex items-start gap-2 text-sm text-muted-strong">
|
||||
<input id="storageSyncToggle" type="checkbox" class="checkbox mt-0.5" />
|
||||
<span>Sync these settings to my Nostr account (encrypted)</span>
|
||||
</label>
|
||||
<p class="text-xs text-status-warning">
|
||||
The encrypted copy lives on public relays. It is readable
|
||||
only with your Nostr key, but anyone who controls your key
|
||||
could decrypt it — and the relays can see that your account
|
||||
stores bitvid settings.
|
||||
</p>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<button id="storageSyncRestoreBtn" type="button" class="btn-ghost focus-ring">
|
||||
Restore from my Nostr account
|
||||
</button>
|
||||
</div>
|
||||
<p id="storageSyncStatus" class="text-xs" role="status" aria-live="polite"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -90,6 +90,7 @@ export function createEncryptedSyncManager(deps = {}) {
|
||||
getWriteRelays,
|
||||
getReadRelays,
|
||||
getPool,
|
||||
listEvents,
|
||||
publishEventToRelays,
|
||||
summarizePublishResults,
|
||||
signEvent,
|
||||
@@ -206,14 +207,15 @@ export function createEncryptedSyncManager(deps = {}) {
|
||||
|
||||
const relays =
|
||||
(typeof getReadRelays === "function" ? getReadRelays() : []) || [];
|
||||
const pool = typeof getPool === "function" ? getPool() : null;
|
||||
if (!pool || !relays.length) {
|
||||
if (typeof listEvents !== "function" || !relays.length) {
|
||||
return { found: false, error: "no-relays" };
|
||||
}
|
||||
|
||||
// Reads go through the injected listEvents (subscription manager in prod) —
|
||||
// never pool.list directly (lint:pool-access).
|
||||
let events = [];
|
||||
try {
|
||||
events = await pool.list(relays, [
|
||||
events = await listEvents(relays, [
|
||||
{ kinds: [APP_DATA_KIND], authors: [pubkey], "#d": [tag], limit: 20 },
|
||||
]);
|
||||
} catch (err) {
|
||||
|
||||
@@ -29,6 +29,18 @@ export const encryptedSync = createEncryptedSyncManager({
|
||||
: [],
|
||||
getReadRelays: resolveReadRelays,
|
||||
getPool: () => nostrClient?.pool || null,
|
||||
// Reads are routed through the subscription manager (deduped + health-gated),
|
||||
// never pool.list directly (lint:pool-access).
|
||||
listEvents: (relays, filters) => {
|
||||
const manager =
|
||||
typeof nostrClient?.getSubscriptionManager === "function"
|
||||
? nostrClient.getSubscriptionManager()
|
||||
: null;
|
||||
if (!manager || typeof manager.list !== "function") {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
return manager.list({ filters, relays });
|
||||
},
|
||||
publishEventToRelays,
|
||||
summarizePublishResults,
|
||||
signEvent: async (template) => {
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from "../../services/s3Service.js";
|
||||
import { getActiveSigner } from "../../nostr/client.js";
|
||||
import { DEFAULT_NIP07_ENCRYPTION_METHODS } from "../../nostr/nip07Permissions.js";
|
||||
import { storageSyncService } from "../../services/storageSyncService.js";
|
||||
|
||||
export class ProfileStorageController {
|
||||
constructor(mainController) {
|
||||
@@ -34,6 +35,11 @@ export class ProfileStorageController {
|
||||
this.storageForcePathStyleInput = null;
|
||||
this.storageForcePathStyleLabel = null;
|
||||
|
||||
this.storageSyncSection = null;
|
||||
this.storageSyncToggle = null;
|
||||
this.storageSyncRestoreBtn = null;
|
||||
this.storageSyncStatus = null;
|
||||
|
||||
this.storageUnlockFailure = null;
|
||||
}
|
||||
|
||||
@@ -60,6 +66,10 @@ export class ProfileStorageController {
|
||||
this.storageS3Helper = document.getElementById("storageS3Helper") || null;
|
||||
this.storageForcePathStyleInput = document.getElementById("storageForcePathStyle") || null;
|
||||
this.storageForcePathStyleLabel = document.getElementById("storageForcePathStyleLabel") || null;
|
||||
this.storageSyncSection = document.getElementById("profileStorageSync") || null;
|
||||
this.storageSyncToggle = document.getElementById("storageSyncToggle") || null;
|
||||
this.storageSyncRestoreBtn = document.getElementById("storageSyncRestoreBtn") || null;
|
||||
this.storageSyncStatus = document.getElementById("storageSyncStatus") || null;
|
||||
}
|
||||
|
||||
registerEventListeners() {
|
||||
@@ -98,6 +108,18 @@ export class ProfileStorageController {
|
||||
this.handlePublicUrlInput();
|
||||
});
|
||||
}
|
||||
|
||||
if (this.storageSyncToggle instanceof HTMLElement) {
|
||||
this.storageSyncToggle.addEventListener("change", () => {
|
||||
void this.handleToggleSync();
|
||||
});
|
||||
}
|
||||
|
||||
if (this.storageSyncRestoreBtn instanceof HTMLElement) {
|
||||
this.storageSyncRestoreBtn.addEventListener("click", () => {
|
||||
void this.handleRestoreSync();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async populateStoragePane() {
|
||||
@@ -188,9 +210,115 @@ export class ProfileStorageController {
|
||||
this.storageFormSection.classList.add("hidden");
|
||||
}
|
||||
|
||||
this.renderSyncSection(isUnlocked ? pubkey : "");
|
||||
this.updateStorageFormVisibility();
|
||||
}
|
||||
|
||||
renderSyncSection(pubkey) {
|
||||
if (!(this.storageSyncSection instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
// Only meaningful once unlocked: there is something to sync and the signer
|
||||
// that encrypts is present.
|
||||
if (!pubkey || !storageSyncService.isAvailable()) {
|
||||
this.storageSyncSection.classList.add("hidden");
|
||||
return;
|
||||
}
|
||||
this.storageSyncSection.classList.remove("hidden");
|
||||
if (this.storageSyncToggle instanceof HTMLInputElement) {
|
||||
this.storageSyncToggle.checked = storageSyncService.isEnabled(pubkey);
|
||||
}
|
||||
this.setSyncStatus("");
|
||||
}
|
||||
|
||||
setSyncStatus(message, tone = "info") {
|
||||
if (!(this.storageSyncStatus instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
this.storageSyncStatus.textContent = message || "";
|
||||
const toneClass =
|
||||
tone === "success"
|
||||
? "text-status-success"
|
||||
: tone === "error"
|
||||
? "text-status-danger"
|
||||
: "text-muted";
|
||||
this.storageSyncStatus.className = `text-xs ${toneClass}`;
|
||||
}
|
||||
|
||||
async handleToggleSync() {
|
||||
const pubkey = this.mainController.normalizeHexPubkey(
|
||||
this.mainController.getActivePubkey()
|
||||
);
|
||||
if (!pubkey) {
|
||||
return;
|
||||
}
|
||||
const enabled =
|
||||
this.storageSyncToggle instanceof HTMLInputElement
|
||||
? this.storageSyncToggle.checked
|
||||
: false;
|
||||
try {
|
||||
if (enabled) {
|
||||
this.setSyncStatus("Encrypting and publishing…");
|
||||
const result = await storageSyncService.enable(pubkey);
|
||||
if (result?.ok) {
|
||||
this.setSyncStatus(
|
||||
`Synced to ${result.accepted}/${result.total} relays.`,
|
||||
"success"
|
||||
);
|
||||
this.mainController.showSuccess("Storage settings synced (encrypted).");
|
||||
} else {
|
||||
// Roll the toggle back so it reflects reality.
|
||||
if (this.storageSyncToggle instanceof HTMLInputElement) {
|
||||
this.storageSyncToggle.checked = false;
|
||||
}
|
||||
await storageSyncService.disable(pubkey).catch(() => {});
|
||||
this.setSyncStatus(
|
||||
result?.error === "nothing-to-sync"
|
||||
? "Nothing to sync yet — save a connection first."
|
||||
: "Could not publish the encrypted copy. Try again.",
|
||||
"error"
|
||||
);
|
||||
}
|
||||
} else {
|
||||
this.setSyncStatus("Removing the synced copy…");
|
||||
await storageSyncService.disable(pubkey);
|
||||
this.setSyncStatus("Sync turned off; the synced copy was cleared.");
|
||||
this.mainController.showSuccess("Storage sync turned off.");
|
||||
}
|
||||
} catch (error) {
|
||||
devLogger.error("[ProfileModal] Storage sync toggle failed:", error);
|
||||
this.setSyncStatus("Sync failed. Please try again.", "error");
|
||||
}
|
||||
}
|
||||
|
||||
async handleRestoreSync() {
|
||||
const pubkey = this.mainController.normalizeHexPubkey(
|
||||
this.mainController.getActivePubkey()
|
||||
);
|
||||
if (!pubkey) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
this.setSyncStatus("Fetching and decrypting…");
|
||||
const result = await storageSyncService.pull(pubkey);
|
||||
if (result?.found && result.imported) {
|
||||
this.setSyncStatus("Restored from your Nostr account.", "success");
|
||||
this.mainController.showSuccess("Storage settings restored.");
|
||||
// Re-render the pane so the restored connection shows.
|
||||
await this.populateStoragePane();
|
||||
} else if (result?.found && !result.imported) {
|
||||
this.setSyncStatus("Found a copy but could not import it.", "error");
|
||||
} else if (result?.cleared) {
|
||||
this.setSyncStatus("No synced settings found (it was cleared).");
|
||||
} else {
|
||||
this.setSyncStatus("No synced settings found on your account.");
|
||||
}
|
||||
} catch (error) {
|
||||
devLogger.error("[ProfileModal] Storage sync restore failed:", error);
|
||||
this.setSyncStatus("Restore failed. Please try again.", "error");
|
||||
}
|
||||
}
|
||||
|
||||
fillStorageForm(conn) {
|
||||
if (!conn) return;
|
||||
const {
|
||||
@@ -594,6 +722,18 @@ export class ProfileStorageController {
|
||||
await storageService.saveConnection(pubkey, "default", payload, meta);
|
||||
this.setStorageFormStatus("Connection saved.", "success");
|
||||
this.mainController.showSuccess("Storage connection saved.");
|
||||
|
||||
// Keep the encrypted synced copy current if the user opted in.
|
||||
if (storageSyncService.isEnabled(pubkey)) {
|
||||
try {
|
||||
const syncResult = await storageSyncService.push(pubkey);
|
||||
if (syncResult?.ok) {
|
||||
this.setSyncStatus("Synced copy updated.", "success");
|
||||
}
|
||||
} catch (syncError) {
|
||||
devLogger.warn("[ProfileModal] Storage re-sync after save failed:", syncError);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
devLogger.error("Failed to save connection:", error);
|
||||
this.setStorageFormStatus("Failed to save connection.", "error");
|
||||
|
||||
@@ -78,6 +78,7 @@ function makeManager({ nip44 = true } = {}) {
|
||||
getWriteRelays: () => ["wss://write.relay"],
|
||||
getReadRelays: () => ["wss://read.relay"],
|
||||
getPool: () => relay,
|
||||
listEvents: (relays, filters) => relay.list(relays, filters),
|
||||
publishEventToRelays,
|
||||
summarizePublishResults,
|
||||
signEvent: async (tpl) => ({
|
||||
|
||||
Reference in new issue
Block a user