mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-31 09:52:24 +00:00
Merge pull request #2065 from PR0M3TH3AN/ai/subscription-history-feature-13920162172659924347
Add Subscription History & Backup/Restore
This commit is contained in:
6 files changed
+633
-10
No files matched your search
@@ -1087,15 +1087,33 @@
|
||||
></p>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<h3 class="text-base font-semibold text-text">Subscriptions</h3>
|
||||
<button
|
||||
id="subscriptionsRefreshBtn"
|
||||
type="button"
|
||||
class="btn-ghost btn-icon"
|
||||
data-size="sm"
|
||||
aria-label="Refresh subscriptions"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"></path><path d="M3 3v5h5"></path><path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16"></path><path d="M16 21h5v-5"></path></svg>
|
||||
</button>
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
id="subscriptionsHistoryBtn"
|
||||
type="button"
|
||||
class="btn-ghost focus-ring text-xs px-2"
|
||||
aria-label="View history"
|
||||
>
|
||||
History
|
||||
</button>
|
||||
<button
|
||||
id="subscriptionsBackupBtn"
|
||||
type="button"
|
||||
class="btn-ghost focus-ring text-xs px-2"
|
||||
aria-label="Backup now"
|
||||
>
|
||||
Backup
|
||||
</button>
|
||||
<button
|
||||
id="subscriptionsRefreshBtn"
|
||||
type="button"
|
||||
class="btn-ghost btn-icon"
|
||||
data-size="sm"
|
||||
aria-label="Refresh subscriptions"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"></path><path d="M3 3v5h5"></path><path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16"></path><path d="M16 21h5v-5"></path></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
id="subscriptionsEmpty"
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
<div
|
||||
id="subscriptionHistoryModal"
|
||||
class="bv-modal hidden"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="subscriptionHistoryTitle"
|
||||
tabindex="-1"
|
||||
data-ds="new"
|
||||
>
|
||||
<div class="bv-modal-backdrop"></div>
|
||||
|
||||
<div
|
||||
class="bv-modal__panel w-full max-w-4xl h-4/5 rounded-xl border border-border/50 bg-surface shadow-2xl overflow-hidden flex flex-col"
|
||||
tabindex="-1"
|
||||
>
|
||||
<!-- Modal Header -->
|
||||
<div class="modal-header border-b border-border/60 p-4 flex items-center justify-between bg-surface-2">
|
||||
<h2 id="subscriptionHistoryTitle" class="text-lg font-bold text-text">Subscription History</h2>
|
||||
<button id="closeSubscriptionHistoryModal" class="modal-close btn-ghost btn-icon p-1 rounded-md text-muted hover:text-text focus-ring">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Modal Body -->
|
||||
<div class="flex flex-1 overflow-hidden">
|
||||
<!-- Left Column: List -->
|
||||
<div class="w-full md:w-1/3 border-r border-border/60 flex flex-col bg-surface-1">
|
||||
<div class="p-3 border-b border-border/60 bg-surface-2/50">
|
||||
<p class="text-xs text-muted font-medium uppercase tracking-wider">Backups & Versions</p>
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto p-2" id="subscriptionHistoryListContainer">
|
||||
<ul id="subscriptionHistoryList" class="space-y-1"></ul>
|
||||
|
||||
<div id="subscriptionHistoryLoading" class="hidden p-4 text-center">
|
||||
<span class="status-spinner status-spinner--inline"></span>
|
||||
<span class="text-sm text-muted ml-2">Loading history...</span>
|
||||
</div>
|
||||
|
||||
<div id="subscriptionHistoryEmpty" class="hidden p-8 text-center flex flex-col items-center justify-center h-full text-muted">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-10 w-10 mb-3 opacity-20" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<p class="text-sm">No backups found.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Column: Details -->
|
||||
<div class="hidden md:flex md:w-2/3 flex-col bg-surface relative">
|
||||
<!-- Empty State for Details -->
|
||||
<div id="subscriptionHistoryDetailsEmpty" class="absolute inset-0 flex flex-col items-center justify-center text-muted p-8 text-center">
|
||||
<div class="p-4 rounded-full bg-surface-2 mb-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 opacity-50" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
||||
</svg>
|
||||
</div>
|
||||
<p class="text-base font-medium text-text">Select a backup to view details</p>
|
||||
<p class="text-sm mt-1 max-w-xs">You can preview the channels in a backup before restoring it.</p>
|
||||
</div>
|
||||
|
||||
<!-- Content State -->
|
||||
<div id="subscriptionHistoryDetailsContent" class="hidden flex-col h-full w-full">
|
||||
<div class="p-4 border-b border-border/60 bg-surface flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h3 id="subscriptionHistorySelectedDate" class="text-base font-bold text-text"></h3>
|
||||
<p id="subscriptionHistorySelectedMeta" class="text-xs text-muted"></p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span id="subscriptionHistorySelectedBadge" class="badge" data-variant="surface"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto p-4">
|
||||
<div id="subscriptionHistoryDecrypting" class="hidden flex flex-col items-center justify-center h-40 text-muted">
|
||||
<span class="status-spinner mb-2"></span>
|
||||
<span class="text-sm">Decrypting content...</span>
|
||||
</div>
|
||||
<ul id="subscriptionHistorySelectedChannels" class="grid grid-cols-1 sm:grid-cols-2 gap-3 pb-4"></ul>
|
||||
</div>
|
||||
|
||||
<div class="p-4 border-t border-border/60 bg-surface-2/30 flex items-center justify-between gap-3">
|
||||
<div class="text-xs text-muted">
|
||||
Actions apply to this specific backup.
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<button id="subscriptionHistoryRebroadcastBtn" type="button" class="btn-ghost focus-ring text-sm">
|
||||
Rebroadcast
|
||||
</button>
|
||||
<button id="subscriptionHistoryRestoreBtn" type="button" class="btn focus-ring text-sm">
|
||||
Restore as Active
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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', <hex>], …]).",
|
||||
},
|
||||
},
|
||||
[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', <hex>], …]).",
|
||||
},
|
||||
},
|
||||
[NOTE_TYPES.USER_BLOCK_LIST]: {
|
||||
type: NOTE_TYPES.USER_BLOCK_LIST,
|
||||
label: "User block list (legacy)",
|
||||
|
||||
+96
-1
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 = `
|
||||
<div class="flex justify-between items-start">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-text">${
|
||||
isBackup ? "Backup" : "Standard List"
|
||||
}</p>
|
||||
<p class="text-xs text-muted">${dateStr}</p>
|
||||
</div>
|
||||
<span class="text-xs text-muted whitespace-nowrap ml-2">${timeAgo}</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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 = `<p class="text-critical text-sm text-center w-full mt-4">Decryption failed or content is invalid.</p>`;
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user