Add subscription history and backup/restore functionality

- Added `SUBSCRIPTION_BACKUP` note type schema
- Implemented `createBackup`, `fetchHistory`, `restoreBackup`, `rebroadcastBackup` in `SubscriptionsManager`
- Added Subscription History UI modal with list/details view
- Integrated History/Backup buttons into Profile Modal Subscriptions pane
- Fixed XSS vulnerability in history details view
- Added profile fetching for history details

Co-authored-by: PR0M3TH3AN <[email protected]>
This commit is contained in:
google-labs-jules[bot]andPR0M3TH3AN committed 2026-02-02 03:05:13 +00:00
1 parent 2398d63e53
commit a50e5c861a
6 files changed
+633 -10

No files matched your search

+96 -1
View File
@@ -1133,7 +1133,7 @@ class SubscriptionsManager {
* as kind=30000 with ["d", "subscriptions"] to be replaceable.
* The published event includes an ["encrypted", "<scheme>"] 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);
}