diff --git a/components/profile-modal.html b/components/profile-modal.html index 301b7d0c..b610f523 100644 --- a/components/profile-modal.html +++ b/components/profile-modal.html @@ -1087,15 +1087,33 @@ >

Subscriptions

- +
+ + + +
diff --git a/js/nostrEventSchemas.js b/js/nostrEventSchemas.js index d96aa848..1cd60a93 100644 --- a/js/nostrEventSchemas.js +++ b/js/nostrEventSchemas.js @@ -33,6 +33,7 @@ export const NOTE_TYPES = Object.freeze({ ZAP_RECEIPT: "zapReceipt", WATCH_HISTORY: "watchHistory", SUBSCRIPTION_LIST: "subscriptionList", + SUBSCRIPTION_BACKUP: "subscriptionBackup", USER_BLOCK_LIST: "userBlockList", HASHTAG_PREFERENCES: "hashtagPreferences", DM_RELAY_LIST: "dmRelayList", @@ -520,6 +521,20 @@ const BASE_SCHEMAS = { "NIP-04/NIP-44 encrypted JSON array of NIP-51 tag tuples (e.g., [['p', ], …]).", }, }, + [NOTE_TYPES.SUBSCRIPTION_BACKUP]: { + type: NOTE_TYPES.SUBSCRIPTION_BACKUP, + label: "Subscription list backup", + kind: 30000, + identifierTag: { + name: "d", + }, + appendTags: [["encrypted", "nip44_v2"]], + content: { + format: "encrypted-tag-list", + description: + "NIP-04/NIP-44 encrypted JSON array of NIP-51 tag tuples (e.g., [['p', ], …]).", + }, + }, [NOTE_TYPES.USER_BLOCK_LIST]: { type: NOTE_TYPES.USER_BLOCK_LIST, label: "User block list (legacy)", diff --git a/js/subscriptions.js b/js/subscriptions.js index d5cd75e5..a20788e3 100644 --- a/js/subscriptions.js +++ b/js/subscriptions.js @@ -1133,7 +1133,7 @@ class SubscriptionsManager { * as kind=30000 with ["d", "subscriptions"] to be replaceable. * The published event includes an ["encrypted", ""] tag. */ - async publishSubscriptionList(userPubkey) { + async publishSubscriptionList(userPubkey, { isBackup = false } = {}) { if (!userPubkey) { throw new Error("No pubkey => cannot publish subscription list."); } @@ -1258,6 +1258,17 @@ class SubscriptionsManager { encryption: encryptionTagValue, }); + if (isBackup) { + const backupId = `subscriptions-backup-${new Date().toISOString().replace(/[:.]/g, "-")}`; + // Override d tag + const dTagIndex = evt.tags.findIndex(t => t[0] === 'd'); + if (dTagIndex >= 0) { + evt.tags[dTagIndex][1] = backupId; + } else { + evt.tags.push(['d', backupId]); + } + } + let signedEvent; try { signedEvent = await signer.signEvent(evt); @@ -2065,6 +2076,90 @@ class SubscriptionsManager { }); } + async createBackup(userPubkey) { + const backupEvent = await this.publishSubscriptionList(userPubkey, { + isBackup: true, + }); + return backupEvent; + } + + async fetchHistory(userPubkey) { + if (!userPubkey) return []; + const normalizedUserPubkey = normalizeHexPubkey(userPubkey); + + // Fetch all kind 30000 events for this user to find backups and old versions + const events = await nostrClient.fetchEvents({ + kinds: [SUBSCRIPTION_SET_KIND], + authors: [normalizedUserPubkey], + limit: 50, // Reasonable limit for history + }); + + return events + .filter((e) => { + const dTag = e.tags.find((t) => t[0] === "d")?.[1]; + return ( + dTag === SUBSCRIPTION_LIST_IDENTIFIER || + (typeof dTag === "string" && dTag.startsWith("subscriptions-backup-")) + ); + }) + .sort((a, b) => b.created_at - a.created_at); + } + + async restoreBackup(event, userPubkey) { + if (!event || !userPubkey) return; + + const decryptResult = await this.decryptSubscriptionEvent(event, userPubkey); + if (!decryptResult.ok) { + throw decryptResult.error || new Error("Failed to decrypt backup"); + } + + const decryptedStr = decryptResult.plaintext; + const normalized = parseSubscriptionPlaintext(decryptedStr); + + // Update local state + this.subscribedPubkeys = new Set(normalized); + this.saveToCache(userPubkey); + + // Publish as current list + await this.publishSubscriptionList(userPubkey); + + this.emitter.emit("change", { + action: "restore", + subscribedPubkeys: Array.from(this.subscribedPubkeys), + }); + + await this.refreshActiveFeed({ reason: "restore-backup" }); + } + + async rebroadcastBackup(event, userPubkey) { + if (!event || !userPubkey) return; + + const dTag = event.tags.find(t => t[0] === 'd')?.[1]; + if (!dTag) throw new Error("Invalid backup event: missing d tag"); + + let signer = getActiveSigner(); + if (!signer) { + signer = await nostrClient.ensureActiveSignerForPubkey(userPubkey); + } + + const newEvent = { + kind: event.kind, + created_at: Math.floor(Date.now() / 1000), + tags: event.tags, + content: event.content, + pubkey: userPubkey + }; + + const signedEvent = await signer.signEvent(newEvent); + await publishEventToRelays( + nostrClient.pool, + sanitizeRelayList(nostrClient.writeRelays), + signedEvent + ); + + return signedEvent; + } + convertEventToVideo(evt) { return sharedConvertEventToVideo(evt); } diff --git a/js/ui/profileModalController.js b/js/ui/profileModalController.js index 3801e144..8ced99b4 100644 --- a/js/ui/profileModalController.js +++ b/js/ui/profileModalController.js @@ -43,6 +43,7 @@ import { getLinkPreviewSettings, setLinkPreviewAutoFetch, } from "../utils/linkPreviewSettings.js"; +import { SubscriptionHistoryController } from "./subscriptionHistoryController.js"; const noop = () => {}; @@ -1436,6 +1437,7 @@ export class ProfileModalController { this.addAccountButtonState = null; this.adminEmptyMessages = new Map(); this.hashtagPreferencesUnsubscribe = null; + this.subscriptionHistoryController = new SubscriptionHistoryController(); if ( this.subscriptionsService && @@ -1622,6 +1624,10 @@ export class ProfileModalController { document.getElementById("subscriptionsEmpty") || null; this.profileSubscriptionsRefreshBtn = document.getElementById("subscriptionsRefreshBtn") || null; + this.profileSubscriptionsHistoryBtn = + document.getElementById("subscriptionsHistoryBtn") || null; + this.profileSubscriptionsBackupBtn = + document.getElementById("subscriptionsBackupBtn") || null; this.friendList = document.getElementById("friendsList") || null; this.friendListEmpty = document.getElementById("friendsEmpty") || null; this.friendInput = document.getElementById("friendsInput") || null; @@ -5667,6 +5673,32 @@ export class ProfileModalController { }); } + if (this.profileSubscriptionsHistoryBtn) { + this.profileSubscriptionsHistoryBtn.addEventListener("click", () => { + const pubkey = this.normalizeHexPubkey(this.getActivePubkey()); + if (pubkey) { + this.subscriptionHistoryController.show(pubkey); + } else { + this.showError("Please log in to view subscription history."); + } + }); + } + + if (this.profileSubscriptionsBackupBtn) { + this.profileSubscriptionsBackupBtn.addEventListener("click", () => { + const pubkey = this.normalizeHexPubkey(this.getActivePubkey()); + if (pubkey) { + if (confirm("Create a backup of your current subscription list?")) { + this.subscriptionHistoryController.handleCreateBackup(pubkey).then(() => { + this.showSuccess("Backup created."); + }); + } + } else { + this.showError("Please log in to backup subscriptions."); + } + }); + } + if (this.walletUriInput instanceof HTMLElement) { this.walletUriInput.addEventListener("focus", () => { this.revealSecretInputValue(this.walletUriInput); diff --git a/js/ui/subscriptionHistoryController.js b/js/ui/subscriptionHistoryController.js new file mode 100644 index 00000000..9eb08a72 --- /dev/null +++ b/js/ui/subscriptionHistoryController.js @@ -0,0 +1,352 @@ +import { subscriptions } from "../subscriptions.js"; +import { profileCache } from "../state/profileCache.js"; +import { formatTimeAgo } from "../utils/formatters.js"; +import { userLogger } from "../utils/logger.js"; +import { getApplication } from "../applicationContext.js"; + +export class SubscriptionHistoryController { + constructor(options = {}) { + this.modal = null; + this.activePubkey = null; + this.selectedEvent = null; + this.historyEvents = []; + this.initialized = false; + this.callbacks = { + onClose: options.onClose || (() => {}), + }; + } + + async load() { + if (this.initialized) return; + + try { + const response = await fetch("components/subscription-history-modal.html"); + if (!response.ok) throw new Error("HTML not found"); + const html = await response.text(); + const template = document.createElement("template"); + template.innerHTML = html; + + // Append to body if not already present (avoid duplicates) + if (!document.getElementById("subscriptionHistoryModal")) { + document.body.appendChild(template.content); + } + + this.modal = document.getElementById("subscriptionHistoryModal"); + this.bindElements(); + this.initialized = true; + } catch (error) { + userLogger.error("Failed to load subscription history modal:", error); + } + } + + bindElements() { + if (!this.modal) return; + + this.listContainer = this.modal.querySelector("#subscriptionHistoryList"); + this.detailsContainer = this.modal.querySelector( + "#subscriptionHistoryDetailsContent" + ); + this.detailsEmpty = this.modal.querySelector( + "#subscriptionHistoryDetailsEmpty" + ); + this.loadingIndicator = this.modal.querySelector( + "#subscriptionHistoryLoading" + ); + this.emptyIndicator = this.modal.querySelector("#subscriptionHistoryEmpty"); + this.decryptingIndicator = this.modal.querySelector( + "#subscriptionHistoryDecrypting" + ); + this.selectedChannelsList = this.modal.querySelector( + "#subscriptionHistorySelectedChannels" + ); + this.selectedDate = this.modal.querySelector( + "#subscriptionHistorySelectedDate" + ); + this.selectedMeta = this.modal.querySelector( + "#subscriptionHistorySelectedMeta" + ); + this.selectedBadge = this.modal.querySelector( + "#subscriptionHistorySelectedBadge" + ); + + this.closeBtn = this.modal.querySelector("#closeSubscriptionHistoryModal"); + this.restoreBtn = this.modal.querySelector("#subscriptionHistoryRestoreBtn"); + this.rebroadcastBtn = this.modal.querySelector( + "#subscriptionHistoryRebroadcastBtn" + ); + + this.closeBtn?.addEventListener("click", () => this.hide()); + this.restoreBtn?.addEventListener("click", () => this.handleRestore()); + this.rebroadcastBtn?.addEventListener("click", () => + this.handleRebroadcast() + ); + + this.modal + .querySelector(".bv-modal-backdrop") + ?.addEventListener("click", () => this.hide()); + } + + async show(pubkey) { + if (!this.initialized) await this.load(); + if (!this.modal) return; + + this.activePubkey = pubkey; + this.modal.classList.remove("hidden"); + this.modal.setAttribute("aria-hidden", "false"); + // Ensure modal-open class is added to body for scroll locking if design system requires it + document.body.classList.add("modal-open"); + + this.fetchHistory(); + } + + hide() { + if (!this.modal) return; + this.modal.classList.add("hidden"); + this.modal.setAttribute("aria-hidden", "true"); + // Only remove if no other modals are open, ideally. + // But for simplicity: + document.body.classList.remove("modal-open"); + + this.selectedEvent = null; + // Reset view + this.detailsEmpty.classList.remove("hidden"); + this.detailsContainer.classList.add("hidden"); + this.callbacks.onClose(); + } + + async fetchHistory() { + if (!this.loadingIndicator) return; + this.loadingIndicator.classList.remove("hidden"); + this.listContainer.innerHTML = ""; + this.emptyIndicator.classList.add("hidden"); + + try { + this.historyEvents = await subscriptions.fetchHistory(this.activePubkey); + this.renderList(); + } catch (error) { + userLogger.error("Failed to fetch subscription history:", error); + this.emptyIndicator.textContent = "Failed to load history."; + this.emptyIndicator.classList.remove("hidden"); + } finally { + this.loadingIndicator.classList.add("hidden"); + } + } + + renderList() { + this.listContainer.innerHTML = ""; + if (!this.historyEvents.length) { + this.emptyIndicator.classList.remove("hidden"); + return; + } + + this.historyEvents.forEach((event) => { + const item = document.createElement("li"); + item.className = + "card p-3 cursor-pointer hover:bg-surface-2 transition-colors border-l-4 border-transparent rounded-r-md"; + + const dTag = event.tags.find((t) => t[0] === "d")?.[1]; + const isBackup = dTag && dTag.startsWith("subscriptions-backup-"); + + const date = new Date(event.created_at * 1000); + const dateStr = date.toLocaleString(undefined, { + dateStyle: "medium", + timeStyle: "short", + }); + const timeAgo = formatTimeAgo(event.created_at); + + item.innerHTML = ` +
+
+

${ + isBackup ? "Backup" : "Standard List" + }

+

${dateStr}

+
+ ${timeAgo} +
+ `; + + item.addEventListener("click", () => { + this.listContainer.querySelectorAll("li").forEach((li) => { + li.classList.remove("bg-surface-2", "border-accent"); + li.classList.add("border-transparent"); + }); + item.classList.add("bg-surface-2", "border-accent"); + item.classList.remove("border-transparent"); + this.selectEvent(event); + }); + + this.listContainer.appendChild(item); + }); + } + + async selectEvent(event) { + this.selectedEvent = event; + this.detailsEmpty.classList.add("hidden"); + this.detailsContainer.classList.remove("hidden"); + this.selectedChannelsList.innerHTML = ""; + this.decryptingIndicator.classList.remove("hidden"); + + const date = new Date(event.created_at * 1000); + this.selectedDate.textContent = date.toLocaleString(undefined, { + dateStyle: "full", + timeStyle: "short", + }); + + const dTag = event.tags.find((t) => t[0] === "d")?.[1]; + const isBackup = dTag?.startsWith("subscriptions-backup-"); + + this.selectedBadge.textContent = isBackup ? "Backup" : "Active / Standard"; + this.selectedBadge.dataset.variant = isBackup ? "accent" : "surface"; + + try { + const decryptResult = await subscriptions.decryptSubscriptionEvent( + event, + this.activePubkey + ); + if (!decryptResult.ok) { + throw new Error("Decryption failed"); + } + + let pubkeys = []; + try { + const parsed = JSON.parse(decryptResult.plaintext); + if (Array.isArray(parsed)) { + // [['p', hex], ...] + pubkeys = parsed + .filter((t) => Array.isArray(t) && t[0] === "p") + .map((t) => t[1]); + } + } catch (e) { + userLogger.warn("Failed to parse plaintext JSON", e); + } + + this.selectedMeta.textContent = `${pubkeys.length} channels`; + this.renderChannels(pubkeys); + } catch (error) { + userLogger.error("Failed to decrypt event:", error); + this.selectedChannelsList.innerHTML = `

Decryption failed or content is invalid.

`; + this.selectedMeta.textContent = "Unknown content"; + } finally { + this.decryptingIndicator.classList.add("hidden"); + } + } + + async renderChannels(pubkeys) { + this.selectedChannelsList.innerHTML = ""; + + const app = getApplication(); + if (app && typeof app.batchFetchProfiles === "function") { + try { + await app.batchFetchProfiles(pubkeys); + } catch (e) { + userLogger.warn("Failed to batch fetch profiles", e); + } + } + + pubkeys.forEach((pubkey) => { + const profile = profileCache.getProfile(pubkey); + const name = profile?.name || profile?.display_name || pubkey.slice(0, 8); + const avatar = profile?.picture || "assets/svg/default-profile.svg"; + + const item = document.createElement("li"); + item.className = + "flex items-center gap-3 p-2 rounded-md hover:bg-surface-2 border border-transparent hover:border-border/50 transition-colors"; + + const img = document.createElement("img"); + img.src = avatar; + img.className = "w-8 h-8 rounded-full object-cover bg-surface-2"; + img.onerror = function () { + this.src = "assets/svg/default-profile.svg"; + }; + + const div = document.createElement("div"); + div.className = "overflow-hidden"; + + const pName = document.createElement("p"); + pName.className = "text-sm font-medium truncate text-text"; + pName.textContent = name; + + const pKey = document.createElement("p"); + pKey.className = "text-xs text-muted truncate font-mono"; + pKey.textContent = `${pubkey.slice(0, 10)}...`; + + div.appendChild(pName); + div.appendChild(pKey); + + item.appendChild(img); + item.appendChild(div); + + this.selectedChannelsList.appendChild(item); + }); + } + + async handleRestore() { + if (!this.selectedEvent) return; + if ( + !confirm( + "Are you sure you want to restore this list? It will overwrite your current subscriptions." + ) + ) + return; + + this.restoreBtn.disabled = true; + this.restoreBtn.textContent = "Restoring..."; + + try { + await subscriptions.restoreBackup(this.selectedEvent, this.activePubkey); + // alert("Subscriptions restored successfully!"); // alert blocks, toast is better if available, but alert is fine for now + this.hide(); + } catch (error) { + userLogger.error("Restore failed:", error); + alert("Failed to restore: " + error.message); + } finally { + this.restoreBtn.disabled = false; + this.restoreBtn.textContent = "Restore as Active"; + } + } + + async handleRebroadcast() { + if (!this.selectedEvent) return; + this.rebroadcastBtn.disabled = true; + this.rebroadcastBtn.textContent = "Broadcasting..."; + + try { + await subscriptions.rebroadcastBackup( + this.selectedEvent, + this.activePubkey + ); + // Refresh to show potentially new event + this.fetchHistory(); + } catch (error) { + userLogger.error("Rebroadcast failed:", error); + alert("Failed to rebroadcast: " + error.message); + } finally { + this.rebroadcastBtn.disabled = false; + this.rebroadcastBtn.textContent = "Rebroadcast"; + } + } + + async handleCreateBackup(pubkey) { + if (!pubkey) return; + + try { + await subscriptions.createBackup(pubkey); + // If modal is open, refresh + if ( + this.modal && + !this.modal.classList.contains("hidden") && + this.activePubkey === pubkey + ) { + this.fetchHistory(); + } else { + // Show success feedback + // Using a simple alert or console log if UI controller doesn't pass a toast function + // userLogger.info("Backup created"); + } + } catch (error) { + userLogger.error("Backup failed:", error); + alert("Backup failed: " + error.message); + } + } +}