diff --git a/assets/svg/subscribe-button-icon.svg b/assets/svg/subscribe-button-icon.svg new file mode 100644 index 0000000..5f3bb64 --- /dev/null +++ b/assets/svg/subscribe-button-icon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/css/style.css b/css/style.css index 1d3d72c..839ecf2 100644 --- a/css/style.css +++ b/css/style.css @@ -40,7 +40,7 @@ header img { padding: 1rem; } -/* Video Grid */ +/* Video Grids */ #videoList { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); @@ -48,6 +48,22 @@ header img { padding: 1.5rem 0; } +/* Subscriptions grid: same pattern as #videoList */ +#subscriptionsVideoList { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); + gap: 2rem; + padding: 1.5rem 0; +} + +/* Now also match for channelVideoList (channel profile) */ +#channelVideoList { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); + gap: 2rem; + padding: 1.5rem 0; +} + /* Video Cards */ .video-card { background-color: var(--color-card); @@ -525,4 +541,3 @@ footer a:hover { #sidebar hr { border-color: rgba(255, 255, 255, 0.1); } - diff --git a/js/channelProfile.js b/js/channelProfile.js index 05a12c7..6f8b70c 100644 --- a/js/channelProfile.js +++ b/js/channelProfile.js @@ -2,20 +2,21 @@ import { nostrClient } from "./nostr.js"; import { app } from "./app.js"; +import { subscriptions } from "./subscriptions.js"; // <-- NEW import import { initialBlacklist, initialWhitelist } from "./lists.js"; import { isWhitelistEnabled } from "./config.js"; /** * Initialize the channel profile view. - * Called when #view=channel-profile is active. + * Called when #view=channel-profile&npub=... */ export async function initChannelProfileView() { - // 1) Get npub from hash (e.g. #view=channel-profile&npub=...) + // 1) Get npub from hash const hashParams = new URLSearchParams(window.location.hash.slice(1)); const npub = hashParams.get("npub"); if (!npub) { console.error( - "No npub found in hash. Example: #view=channel-profile&npub=npub1..." + "No npub found in hash (e.g. #view=channel-profile&npub=...)" ); return; } @@ -34,15 +35,75 @@ export async function initChannelProfileView() { return; } - // 3) Load user’s profile (banner, avatar, etc.) + // 3) If user is logged in, load subscriptions and show sub/unsub button + if (app.pubkey) { + await subscriptions.loadSubscriptions(app.pubkey); + renderSubscribeButton(hexPub); + } else { + const btn = document.getElementById("subscribeBtnArea"); + if (btn) btn.classList.add("hidden"); + } + + // 4) Load user’s profile (banner, avatar, etc.) await loadUserProfile(hexPub); - // 4) Load user’s videos (filtered + rendered like the home feed) + // 5) Load user’s videos (filtered + rendered like the home feed) await loadUserVideos(hexPub); } /** - * Fetches and displays the user’s metadata (kind=0). + * Renders a Subscribe / Unsubscribe button with an icon, + * using color #fe0032 and the subscribe-button-icon.svg on the left. + */ +function renderSubscribeButton(channelHex) { + const container = document.getElementById("subscribeBtnArea"); + if (!container) return; + + container.classList.remove("hidden"); + const alreadySubscribed = subscriptions.isSubscribed(channelHex); + + // We'll use #fe0032 for both subscribe/unsubscribe, + // and the same icon. If you prefer separate logic for unsub, you can do it here. + container.innerHTML = ` + + `; + + const toggleBtn = document.getElementById("subscribeToggleBtn"); + if (toggleBtn) { + toggleBtn.addEventListener("click", async () => { + if (!app.pubkey) { + console.error("Not logged in => cannot subscribe/unsubscribe."); + return; + } + try { + if (alreadySubscribed) { + await subscriptions.removeChannel(channelHex, app.pubkey); + } else { + await subscriptions.addChannel(channelHex, app.pubkey); + } + // Re-render the button so it toggles state + renderSubscribeButton(channelHex); + } catch (err) { + console.error("Failed to update subscription:", err); + } + }); + } +} + +/** + * Fetches and displays the user's metadata (kind=0). */ async function loadUserProfile(pubkey) { try { @@ -111,8 +172,8 @@ async function loadUserProfile(pubkey) { } /** - * Fetches and displays this user's videos (kind=30078), - * filtering out older overshadowed notes, blacklisted, non‐whitelisted, etc. + * Fetches and displays this user's videos (kind=30078). + * Filters out older overshadowed notes, blacklisted, etc. */ async function loadUserVideos(pubkey) { try { @@ -177,13 +238,10 @@ async function loadUserVideos(pubkey) { } const fragment = document.createDocumentFragment(); - const channelVideos = videos; - - // We'll need all known events for revert-check const allKnownEventsArray = Array.from(nostrClient.allEvents.values()); - channelVideos.forEach((video, index) => { - // Private => decrypt if owned by the user + videos.forEach((video, index) => { + // Decrypt if user owns a private video if ( video.isPrivate && video.pubkey === nostrClient.pubkey && @@ -197,15 +255,13 @@ async function loadUserVideos(pubkey) { const canEdit = video.pubkey === app.pubkey; let hasOlder = false; if (canEdit && video.videoRootId) { - // Use the same hasOlderVersion approach as home feed hasOlder = app.hasOlderVersion(video, allKnownEventsArray); } - // If there's an older overshadowed version, show revert const revertButton = hasOlder ? `
-

- ${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 + ? ` +
+ + +
+ ` + : ""; + + const safeTitle = window.app?.escapeHTML(video.title) || "Untitled"; + const safeThumb = window.app?.escapeHTML(video.thumbnail) || ""; + const cardHtml = ` +
+ +
+ ${safeTitle} +
+
+
+

+ ${safeTitle} +

+
+
+
+ Placeholder +
+
+

+ 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 -
- -
-
- Avatar -
-
-

User Name

-

npub...

-
+ +
+ + Banner +
+ +
+
+ Avatar +
+
+

User Name

+

npub...

- -
-

- Website -

-
- -
- -

Videos by This User

- -
+ + +
+

+ Website - -
-
- \ 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

+