-
- ${escapeHTML(video.title)}
+ ${safeTitle}
${new Date(video.created_at * 1000).toLocaleString()}
@@ -301,7 +356,7 @@ async function loadUserVideos(pubkey) {
`;
- // Clicking the card (except gear) => open video
+ // Clicking the card => open the video modal
cardEl.addEventListener("click", () => {
app.playVideoByEventId(video.id);
});
@@ -311,7 +366,7 @@ async function loadUserVideos(pubkey) {
container.appendChild(fragment);
- // Lazy-load
+ // Lazy-load images
const lazyEls = container.querySelectorAll("[data-lazy]");
lazyEls.forEach((el) => app.mediaLoader.observe(el));
@@ -328,39 +383,36 @@ async function loadUserVideos(pubkey) {
});
});
- // "Edit" handler
+ // Edit handler
const editBtns = container.querySelectorAll("[data-edit-index]");
editBtns.forEach((btn) => {
btn.addEventListener("click", (ev) => {
ev.stopPropagation();
const idx = parseInt(btn.getAttribute("data-edit-index"), 10);
- // Hide the dropdown
const dropdown = document.getElementById(`settingsDropdown-${idx}`);
if (dropdown) dropdown.classList.add("hidden");
app.handleEditVideo(idx);
});
});
- // "Revert" handler
+ // Revert handler
const revertBtns = container.querySelectorAll("[data-revert-index]");
revertBtns.forEach((btn) => {
btn.addEventListener("click", (ev) => {
ev.stopPropagation();
const idx = parseInt(btn.getAttribute("data-revert-index"), 10);
- // Hide the dropdown
const dropdown = document.getElementById(`settingsDropdown-${idx}`);
if (dropdown) dropdown.classList.add("hidden");
app.handleRevertVideo(idx);
});
});
- // "Delete All" handler
+ // Delete All handler
const deleteAllBtns = container.querySelectorAll("[data-delete-all-index]");
deleteAllBtns.forEach((btn) => {
btn.addEventListener("click", (ev) => {
ev.stopPropagation();
const idx = parseInt(btn.getAttribute("data-delete-all-index"), 10);
- // Hide the dropdown
const dropdown = document.getElementById(`settingsDropdown-${idx}`);
if (dropdown) dropdown.classList.add("hidden");
app.handleFullDeleteVideo(idx);
@@ -372,14 +424,14 @@ async function loadUserVideos(pubkey) {
}
/**
- * Minimal placeholder decryption for private videos.
+ * Minimal placeholder for private video decryption.
*/
function fakeDecrypt(str) {
return str.split("").reverse().join("");
}
/**
- * Deduplicate older overshadowed versions – return only the newest for each root.
+ * Keep only the newest version of each video root.
*/
function dedupeToNewestByRoot(videos) {
const map = new Map();
@@ -394,7 +446,7 @@ function dedupeToNewestByRoot(videos) {
}
/**
- * Convert a raw Nostr event => "video" object.
+ * Convert raw event => "video" object.
*/
function localConvertEventToVideo(event) {
try {
@@ -431,7 +483,7 @@ function localConvertEventToVideo(event) {
}
/**
- * Escape HTML to prevent injection or XSS.
+ * Basic escaping to avoid XSS.
*/
function escapeHTML(unsafe = "") {
return unsafe
diff --git a/js/subscriptions.js b/js/subscriptions.js
new file mode 100644
index 0000000..802c5b0
--- /dev/null
+++ b/js/subscriptions.js
@@ -0,0 +1,523 @@
+// js/subscriptions.js
+import { nostrClient } from "./nostr.js";
+
+/**
+ * Manages the user's subscription list (kind=30002) *privately*,
+ * using NIP-04 encryption for the content field.
+ * Also handles fetching and rendering subscribed channels' videos
+ * in the same card style as your home page.
+ */
+class SubscriptionsManager {
+ constructor() {
+ this.subscribedPubkeys = new Set();
+ this.subsEventId = null;
+ this.loaded = false;
+ }
+
+ /**
+ * Decrypt the subscription list from kind=30002 (d="subscriptions").
+ */
+ async loadSubscriptions(userPubkey) {
+ if (!userPubkey) {
+ console.warn("[SubscriptionsManager] No pubkey => cannot load subs.");
+ return;
+ }
+ try {
+ const filter = {
+ kinds: [30002],
+ authors: [userPubkey],
+ "#d": ["subscriptions"],
+ limit: 1,
+ };
+
+ const events = [];
+ for (const url of nostrClient.relays) {
+ try {
+ const result = await nostrClient.pool.list([url], [filter]);
+ if (result && result.length) {
+ events.push(...result);
+ }
+ } catch (err) {
+ console.error(`[SubscriptionsManager] Relay error at ${url}`, err);
+ }
+ }
+
+ if (!events.length) {
+ this.subscribedPubkeys.clear();
+ this.subsEventId = null;
+ this.loaded = true;
+ return;
+ }
+
+ // Sort by created_at desc, pick newest
+ events.sort((a, b) => b.created_at - a.created_at);
+ const newest = events[0];
+ this.subsEventId = newest.id;
+
+ let decryptedStr = "";
+ try {
+ decryptedStr = await window.nostr.nip04.decrypt(
+ userPubkey,
+ newest.content
+ );
+ } catch (errDecrypt) {
+ console.error("[SubscriptionsManager] Decryption failed:", errDecrypt);
+ this.subscribedPubkeys.clear();
+ this.subsEventId = null;
+ this.loaded = true;
+ return;
+ }
+
+ const parsed = JSON.parse(decryptedStr);
+ const subArray = Array.isArray(parsed.subPubkeys)
+ ? parsed.subPubkeys
+ : [];
+ this.subscribedPubkeys = new Set(subArray);
+
+ this.loaded = true;
+ } catch (err) {
+ console.error("[SubscriptionsManager] Failed to load subs:", err);
+ }
+ }
+
+ isSubscribed(channelHex) {
+ return this.subscribedPubkeys.has(channelHex);
+ }
+
+ async addChannel(channelHex, userPubkey) {
+ if (!userPubkey) {
+ throw new Error("No user pubkey => cannot addChannel.");
+ }
+ if (this.subscribedPubkeys.has(channelHex)) {
+ console.log("Already subscribed to", channelHex);
+ return;
+ }
+ this.subscribedPubkeys.add(channelHex);
+ await this.publishSubscriptionList(userPubkey);
+ }
+
+ async removeChannel(channelHex, userPubkey) {
+ if (!userPubkey) {
+ throw new Error("No user pubkey => cannot removeChannel.");
+ }
+ if (!this.subscribedPubkeys.has(channelHex)) {
+ console.log("Channel not found in subscription list:", channelHex);
+ return;
+ }
+ this.subscribedPubkeys.delete(channelHex);
+ await this.publishSubscriptionList(userPubkey);
+ }
+
+ /**
+ * Encrypt (NIP-04) + publish the updated subscription set
+ * as kind=30002 with ["d", "subscriptions"] to be replaceable.
+ */
+ async publishSubscriptionList(userPubkey) {
+ if (!userPubkey) {
+ throw new Error("No pubkey => cannot publish subscription list.");
+ }
+
+ const plainObj = { subPubkeys: Array.from(this.subscribedPubkeys) };
+ const plainStr = JSON.stringify(plainObj);
+
+ let cipherText = "";
+ try {
+ cipherText = await window.nostr.nip04.encrypt(userPubkey, plainStr);
+ } catch (err) {
+ console.error("Encryption failed:", err);
+ throw err;
+ }
+
+ const evt = {
+ kind: 30002,
+ pubkey: userPubkey,
+ created_at: Math.floor(Date.now() / 1000),
+ tags: [["d", "subscriptions"]],
+ content: cipherText,
+ };
+
+ try {
+ const signedEvent = await window.nostr.signEvent(evt);
+ await Promise.all(
+ nostrClient.relays.map(async (relay) => {
+ try {
+ await nostrClient.pool.publish([relay], signedEvent);
+ } catch (e) {
+ console.error(
+ `[SubscriptionsManager] Failed to publish to ${relay}`,
+ e
+ );
+ }
+ })
+ );
+ this.subsEventId = signedEvent.id;
+ console.log("Subscription list published, event id:", signedEvent.id);
+ } catch (signErr) {
+ console.error("Failed to sign/publish subscription list:", signErr);
+ }
+ }
+
+ /**
+ * If not loaded, load subs, then fetch + render videos
+ * in #subscriptionsVideoList with the same style as app.renderVideoList.
+ */
+ async showSubscriptionVideos(
+ userPubkey,
+ containerId = "subscriptionsVideoList"
+ ) {
+ if (!userPubkey) {
+ const c = document.getElementById(containerId);
+ if (c) c.innerHTML = "
Please log in first.
";
+ return;
+ }
+ if (!this.loaded) {
+ await this.loadSubscriptions(userPubkey);
+ }
+
+ const channelHexes = Array.from(this.subscribedPubkeys);
+ const container = document.getElementById(containerId);
+ if (!container) return;
+
+ if (!channelHexes.length) {
+ container.innerHTML =
+ "
No subscriptions found.
";
+ return;
+ }
+
+ // Gather all videos
+ const videos = await this.fetchSubscribedVideos(channelHexes);
+ this.renderSameGridStyle(videos, containerId);
+ }
+
+ /**
+ * Pull all events from subscribed authors, convert, dedupe => newest
+ */
+ async fetchSubscribedVideos(authorPubkeys) {
+ try {
+ const filter = {
+ kinds: [30078],
+ "#t": ["video"],
+ authors: authorPubkeys,
+ limit: 500,
+ };
+
+ const allEvents = [];
+ for (const relay of nostrClient.relays) {
+ try {
+ const res = await nostrClient.pool.list([relay], [filter]);
+ allEvents.push(...res);
+ } catch (rErr) {
+ console.error(`[SubscriptionsManager] Error at ${relay}`, rErr);
+ }
+ }
+
+ const videos = [];
+ for (const evt of allEvents) {
+ const vid = this.convertEventToVideo(evt);
+ if (!vid.invalid && !vid.deleted) videos.push(vid);
+ }
+
+ const deduped = this.dedupeToNewestByRoot(videos);
+ deduped.sort((a, b) => b.created_at - a.created_at);
+ return deduped;
+ } catch (err) {
+ console.error("fetchSubscribedVideos error:", err);
+ return [];
+ }
+ }
+
+ /**
+ * Renders the feed in the same style as home.
+ * This includes gear menu, time-ago, lazy load, clickable authors, etc.
+ */
+ renderSameGridStyle(videos, containerId) {
+ const container = document.getElementById(containerId);
+ if (!container) return;
+
+ if (!videos.length) {
+ container.innerHTML = `
+
+ No videos available yet.
+
`;
+ return;
+ }
+
+ // Sort newest first
+ videos.sort((a, b) => b.created_at - a.created_at);
+
+ const fullAllEventsArray = Array.from(nostrClient.allEvents.values());
+ const fragment = document.createDocumentFragment();
+ // Only declare localAuthorSet once
+ const localAuthorSet = new Set();
+
+ videos.forEach((video, index) => {
+ if (!video.id || !video.title) {
+ console.error("Missing ID or title:", video);
+ return;
+ }
+
+ localAuthorSet.add(video.pubkey);
+
+ const nevent = window.NostrTools.nip19.neventEncode({ id: video.id });
+ const shareUrl = `${window.location.pathname}?v=${encodeURIComponent(
+ nevent
+ )}`;
+ const canEdit = window.app?.pubkey === video.pubkey;
+
+ const highlightClass =
+ video.isPrivate && canEdit
+ ? "border-2 border-yellow-500"
+ : "border-none";
+
+ const timeAgo = window.app?.formatTimeAgo
+ ? window.app.formatTimeAgo(video.created_at)
+ : new Date(video.created_at * 1000).toLocaleString();
+
+ let hasOlder = false;
+ if (canEdit && video.videoRootId && window.app?.hasOlderVersion) {
+ hasOlder = window.app.hasOlderVersion(video, fullAllEventsArray);
+ }
+
+ const revertButton = hasOlder
+ ? `
+
+ `
+ : "";
+
+ const gearMenu = canEdit
+ ? `
+
+
+
+
+
+ ${revertButton}
+
+
+
+
+ `
+ : "";
+
+ const safeTitle = window.app?.escapeHTML(video.title) || "Untitled";
+ const safeThumb = window.app?.escapeHTML(video.thumbnail) || "";
+ const cardHtml = `
+
+
+
+

+
+
+
+
+ ${safeTitle}
+
+
+
+
+

+
+
+
+ Loading name...
+
+
+ ${timeAgo}
+
+
+
+ ${gearMenu}
+
+
+
+ `;
+
+ const t = document.createElement("template");
+ t.innerHTML = cardHtml.trim();
+ const cardEl = t.content.firstElementChild;
+ fragment.appendChild(cardEl);
+ });
+
+ container.appendChild(fragment);
+
+ // Lazy-load
+ const lazyEls = container.querySelectorAll("[data-lazy]");
+ lazyEls.forEach((el) => window.app?.mediaLoader.observe(el));
+
+ // Gear menus
+ const gearButtons = container.querySelectorAll("[data-settings-dropdown]");
+ gearButtons.forEach((btn) => {
+ btn.addEventListener("click", (ev) => {
+ ev.stopPropagation();
+ const idx = btn.getAttribute("data-settings-dropdown");
+ const dropdown = document.getElementById(`settingsDropdown-${idx}`);
+ if (dropdown) dropdown.classList.toggle("hidden");
+ });
+ });
+
+ // Edit button
+ const editButtons = container.querySelectorAll("[data-edit-index]");
+ editButtons.forEach((btn) => {
+ btn.addEventListener("click", (ev) => {
+ ev.stopPropagation();
+ const idx = btn.getAttribute("data-edit-index");
+ const dropdown = document.getElementById(`settingsDropdown-${idx}`);
+ if (dropdown) dropdown.classList.add("hidden");
+ window.app?.handleEditVideo(idx);
+ });
+ });
+
+ // Revert
+ const revertButtons = container.querySelectorAll("[data-revert-index]");
+ revertButtons.forEach((btn) => {
+ btn.addEventListener("click", (ev) => {
+ ev.stopPropagation();
+ const idx = btn.getAttribute("data-revert-index");
+ const dropdown = document.getElementById(`settingsDropdown-${idx}`);
+ if (dropdown) dropdown.classList.add("hidden");
+ window.app?.handleRevertVideo(idx);
+ });
+ });
+
+ // Delete All
+ const deleteAllButtons = container.querySelectorAll(
+ "[data-delete-all-index]"
+ );
+ deleteAllButtons.forEach((btn) => {
+ btn.addEventListener("click", (ev) => {
+ ev.stopPropagation();
+ const idx = btn.getAttribute("data-delete-all-index");
+ const dd = document.getElementById(`settingsDropdown-${idx}`);
+ if (dd) dd.classList.add("hidden");
+ window.app?.handleFullDeleteVideo(idx);
+ });
+ });
+
+ // Now fetch author profiles
+ const authorPics = container.querySelectorAll(".author-pic");
+ const authorNames = container.querySelectorAll(".author-name");
+
+ // We only declare localAuthorSet once at the top
+ // so we don't cause a "duplicate" variable error.
+ authorPics.forEach((pic) => {
+ localAuthorSet.add(pic.getAttribute("data-pubkey"));
+ });
+ authorNames.forEach((nameEl) => {
+ localAuthorSet.add(nameEl.getAttribute("data-pubkey"));
+ });
+
+ if (window.app?.batchFetchProfiles && localAuthorSet.size > 0) {
+ window.app.batchFetchProfiles(localAuthorSet);
+ }
+
+ // Make author name/pic clickable => open channel
+ authorPics.forEach((pic) => {
+ pic.style.cursor = "pointer";
+ pic.addEventListener("click", (ev) => {
+ ev.preventDefault();
+ ev.stopPropagation();
+ const pubkey = pic.getAttribute("data-pubkey");
+ window.app?.goToProfile(pubkey);
+ });
+ });
+
+ authorNames.forEach((nameEl) => {
+ nameEl.style.cursor = "pointer";
+ nameEl.addEventListener("click", (ev) => {
+ ev.preventDefault();
+ ev.stopPropagation();
+ const pubkey = nameEl.getAttribute("data-pubkey");
+ window.app?.goToProfile(pubkey);
+ });
+ });
+ }
+
+ convertEventToVideo(evt) {
+ try {
+ const content = JSON.parse(evt.content || "{}");
+ const hasFields = !!(content.title && content.magnet);
+ const versionOk = content.version >= 2;
+ if (!versionOk || !hasFields) {
+ return { id: evt.id, invalid: true };
+ }
+ return {
+ id: evt.id,
+ pubkey: evt.pubkey,
+ created_at: evt.created_at,
+ videoRootId: content.videoRootId || evt.id,
+ version: content.version,
+ deleted: content.deleted === true,
+ isPrivate: content.isPrivate === true,
+ title: content.title || "",
+ magnet: content.magnet || "",
+ thumbnail: content.thumbnail || "",
+ description: content.description || "",
+ tags: evt.tags || [],
+ invalid: false,
+ };
+ } catch (err) {
+ return { id: evt.id, invalid: true };
+ }
+ }
+
+ dedupeToNewestByRoot(videos) {
+ const map = new Map();
+ for (const v of videos) {
+ const rootId = v.videoRootId || v.id;
+ const existing = map.get(rootId);
+ if (!existing || v.created_at > existing.created_at) {
+ map.set(rootId, v);
+ }
+ }
+ return Array.from(map.values());
+ }
+}
+
+export const subscriptions = new SubscriptionsManager();
diff --git a/js/viewManager.js b/js/viewManager.js
index 00f3258..2589e46 100644
--- a/js/viewManager.js
+++ b/js/viewManager.js
@@ -1,5 +1,6 @@
// js/viewManager.js
import { initChannelProfileView } from "./channelProfile.js";
+import { subscriptions } from "./subscriptions.js";
/**
* Load a partial view by URL into the #viewContainer.
@@ -53,11 +54,33 @@ export const viewInitRegistry = {
explore: () => {
console.log("Explore view loaded.");
},
- subscriptions: () => {
+
+ /**
+ * Subscriptions view:
+ * - If user is logged in, calls subscriptions.showSubscriptionVideos
+ * which loads subs if needed and renders the video grid in #subscriptionsVideoList
+ */
+ subscriptions: async () => {
console.log("Subscriptions view loaded.");
+
+ if (!window.app.pubkey) {
+ const container = document.getElementById("subscriptionsVideoList");
+ if (container) {
+ container.innerHTML =
+ "
Please log in to see your subscriptions.
";
+ }
+ return;
+ }
+
+ // If user is logged in, let the SubscriptionsManager do everything:
+ await subscriptions.showSubscriptionVideos(
+ window.app.pubkey,
+ "subscriptionsVideoList"
+ );
},
+
"channel-profile": () => {
// Call the initialization function from channelProfile.js
initChannelProfileView();
- }
+ },
};
diff --git a/views/channel-profile.html b/views/channel-profile.html
index 0ca2e7b..693c7dd 100644
--- a/views/channel-profile.html
+++ b/views/channel-profile.html
@@ -1,52 +1,58 @@
-
-
-
-
![Banner]()
-
-
-
-
-

-
-
+
+
+
+
![Banner]()
+
+
+
+
+

+
+
-
-
-
-
-
-
Videos by This User
-
-
+
+
+
-
-
\ No newline at end of file
+
+
+
+
+
+
+
+
+
Videos by This User
+
+
+
+
diff --git a/views/subscriptions.html b/views/subscriptions.html
index 38545fa..309018a 100644
--- a/views/subscriptions.html
+++ b/views/subscriptions.html
@@ -1,5 +1,13 @@
- Subscriptions
- Coming Soon...
+ Subscriptions
+