diff --git a/assets/svg/feed-icon.svg b/assets/svg/feed-icon.svg new file mode 100644 index 0000000..32109a8 --- /dev/null +++ b/assets/svg/feed-icon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/assets/svg/hot-icon.svg b/assets/svg/hot-icon.svg new file mode 100644 index 0000000..541693c --- /dev/null +++ b/assets/svg/hot-icon.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/assets/svg/recent-icon.svg b/assets/svg/recent-icon.svg new file mode 100644 index 0000000..a68790c --- /dev/null +++ b/assets/svg/recent-icon.svg @@ -0,0 +1,9 @@ + + + + + + + + + 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/components/disclaimer.html b/components/disclaimer.html index a190d9c..72bb5b1 100644 --- a/components/disclaimer.html +++ b/components/disclaimer.html @@ -25,28 +25,7 @@
- + >
diff --git a/components/sidebar.html b/components/sidebar.html index 9756ccf..21180f2 100644 --- a/components/sidebar.html +++ b/components/sidebar.html @@ -12,11 +12,11 @@ class="flex items-center py-2 px-4 hover:bg-gray-700 rounded font-semibold" > Home - Home + Recent - + + `; + + 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 { + const events = await nostrClient.pool.list(nostrClient.relays, [ + { kinds: [0], authors: [pubkey], limit: 1 }, + ]); + + if (events.length && events[0].content) { + const meta = JSON.parse(events[0].content); + + // Banner + const bannerEl = document.getElementById("channelBanner"); + if (bannerEl) { + bannerEl.src = meta.banner || "assets/jpg/default-banner.jpg"; + } + + // Avatar + const avatarEl = document.getElementById("channelAvatar"); + if (avatarEl) { + avatarEl.src = meta.picture || "assets/svg/default-profile.svg"; + } + + // Channel Name + const nameEl = document.getElementById("channelName"); + if (nameEl) { + nameEl.textContent = meta.display_name || meta.name || "Unknown User"; + } + + // Channel npub + const channelNpubEl = document.getElementById("channelNpub"); + if (channelNpubEl) { + const userNpub = window.NostrTools.nip19.npubEncode(pubkey); + channelNpubEl.textContent = userNpub; + } + + // About/Description + const aboutEl = document.getElementById("channelAbout"); + if (aboutEl) { + aboutEl.textContent = meta.about || ""; + } + + // Website + const websiteEl = document.getElementById("channelWebsite"); + if (websiteEl) { + if (meta.website) { + websiteEl.href = meta.website; + websiteEl.textContent = meta.website; + } else { + websiteEl.textContent = ""; + websiteEl.removeAttribute("href"); + } + } + + // Lightning Address + const lnEl = document.getElementById("channelLightning"); + if (lnEl) { + lnEl.textContent = + meta.lud16 || meta.lud06 || "No lightning address found."; + } + } else { + console.warn("No metadata found for this user."); + } + } catch (err) { + console.error("Failed to fetch user profile data:", err); + } +} + +/** + * Fetches and displays this user's videos (kind=30078). + * Filters out older overshadowed notes, blacklisted, etc. + */ +async function loadUserVideos(pubkey) { + try { + // 1) Build filter for videos from this pubkey + const filter = { + kinds: [30078], + authors: [pubkey], + "#t": ["video"], + limit: 200, + }; + + // 2) Collect raw events from all relays + const events = []; + for (const url of nostrClient.relays) { + try { + const result = await nostrClient.pool.list([url], [filter]); + events.push(...result); + } catch (relayErr) { + console.error(`Relay error (${url}):`, relayErr); + } + } + + // 3) Convert to "video" objects + let videos = []; + for (const evt of events) { + const vid = localConvertEventToVideo(evt); + if (!vid.invalid && !vid.deleted) { + videos.push(vid); + } + } + + // 4) Deduplicate older overshadowed versions => newest only + videos = dedupeToNewestByRoot(videos); + + // 5) Filter out blacklisted IDs / authors + videos = videos.filter((video) => { + // Event-level blacklisting + if (app.blacklistedEventIds.has(video.id)) return false; + + // Author-level + const authorNpub = app.safeEncodeNpub(video.pubkey) || video.pubkey; + if (initialBlacklist.includes(authorNpub)) return false; + if (isWhitelistEnabled && !initialWhitelist.includes(authorNpub)) { + return false; + } + return true; + }); + + // 6) Sort newest first + videos.sort((a, b) => b.created_at - a.created_at); + + // 7) Render them + const container = document.getElementById("channelVideoList"); + if (!container) { + console.warn("channelVideoList element not found in DOM."); + return; + } + container.innerHTML = ""; + if (!videos.length) { + container.innerHTML = `

No videos to display.

`; + return; + } + + const fragment = document.createDocumentFragment(); + const allKnownEventsArray = Array.from(nostrClient.allEvents.values()); + + videos.forEach((video, index) => { + // Decrypt if user owns a private video + if ( + video.isPrivate && + video.pubkey === nostrClient.pubkey && + !video.alreadyDecrypted + ) { + video.magnet = fakeDecrypt(video.magnet); + video.alreadyDecrypted = true; + } + + // Check if user can edit + const canEdit = video.pubkey === app.pubkey; + let hasOlder = false; + if (canEdit && video.videoRootId) { + hasOlder = app.hasOlderVersion(video, allKnownEventsArray); + } + + const revertButton = hasOlder + ? ` + + ` + : ""; + + let gearMenu = ""; + if (canEdit) { + gearMenu = ` +
+ + +
+ `; + } + + // Fallback thumbnail + const fallbackThumb = "assets/jpg/video-thumbnail-fallback.jpg"; + const safeThumb = video.thumbnail || fallbackThumb; + const safeTitle = escapeHTML(video.title); + + const cardEl = document.createElement("div"); + cardEl.classList.add( + "bg-gray-900", + "rounded-lg", + "overflow-hidden", + "shadow-lg", + "hover:shadow-2xl", + "transition-all", + "duration-300" + ); + + cardEl.innerHTML = ` +
+
+ ${safeTitle} +
+
+
+
+

+ ${safeTitle} +

+

+ ${new Date(video.created_at * 1000).toLocaleString()} +

+
+ ${gearMenu} +
+ `; + + // Clicking the card => open the video modal + cardEl.addEventListener("click", () => { + app.playVideoByEventId(video.id); + }); + + fragment.appendChild(cardEl); + }); + + container.appendChild(fragment); + + // Lazy-load images + const lazyEls = container.querySelectorAll("[data-lazy]"); + lazyEls.forEach((el) => app.mediaLoader.observe(el)); + + // Gear menu toggles + 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 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); + const dropdown = document.getElementById(`settingsDropdown-${idx}`); + if (dropdown) dropdown.classList.add("hidden"); + app.handleEditVideo(idx); + }); + }); + + // 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); + const dropdown = document.getElementById(`settingsDropdown-${idx}`); + if (dropdown) dropdown.classList.add("hidden"); + app.handleRevertVideo(idx); + }); + }); + + // 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); + const dropdown = document.getElementById(`settingsDropdown-${idx}`); + if (dropdown) dropdown.classList.add("hidden"); + app.handleFullDeleteVideo(idx); + }); + }); + } catch (err) { + console.error("Error loading user videos:", err); + } +} + +/** + * Minimal placeholder for private video decryption. + */ +function fakeDecrypt(str) { + return str.split("").reverse().join(""); +} + +/** + * Keep only the newest version of each video root. + */ +function dedupeToNewestByRoot(videos) { + const map = new Map(); + for (const vid of videos) { + const rootId = vid.videoRootId || vid.id; + const existing = map.get(rootId); + if (!existing || vid.created_at > existing.created_at) { + map.set(rootId, vid); + } + } + return Array.from(map.values()); +} + +/** + * Convert raw event => "video" object. + */ +function localConvertEventToVideo(event) { + try { + const content = JSON.parse(event.content || "{}"); + const isSupportedVersion = content.version >= 2; + const hasRequiredFields = !!(content.title && content.magnet); + + if (!isSupportedVersion) { + return { id: event.id, invalid: true, reason: "version <2" }; + } + if (!hasRequiredFields) { + return { id: event.id, invalid: true, reason: "missing title/magnet" }; + } + + return { + id: event.id, + videoRootId: content.videoRootId || event.id, + version: content.version, + isPrivate: content.isPrivate ?? false, + title: content.title ?? "", + magnet: content.magnet ?? "", + thumbnail: content.thumbnail ?? "", + description: content.description ?? "", + mode: content.mode ?? "live", + deleted: content.deleted === true, + pubkey: event.pubkey, + created_at: event.created_at, + tags: event.tags, + invalid: false, + }; + } catch (err) { + return { id: event.id, invalid: true, reason: "json parse error" }; + } +} + +/** + * Basic escaping to avoid XSS. + */ +function escapeHTML(unsafe = "") { + return unsafe + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} diff --git a/js/index.js b/js/index.js index 4cba2d8..ce3b6db 100644 --- a/js/index.js +++ b/js/index.js @@ -297,9 +297,14 @@ function handleQueryParams() { */ function handleHashChange() { console.log("handleHashChange called, current hash =", window.location.hash); + const hash = window.location.hash || ""; - const match = hash.match(/^#view=(.+)/); + // Use a regex that captures up to the first ampersand or end of string. + // E.g. "#view=channel-profile&npub=..." => viewName = "channel-profile" + const match = hash.match(/^#view=([^&]+)/); + if (!match || !match[1]) { + // No valid "#view=..." => default to "most-recent-videos" import("./viewManager.js").then(({ loadView, viewInitRegistry }) => { loadView("views/most-recent-videos.html").then(() => { const initFn = viewInitRegistry["most-recent-videos"]; @@ -310,8 +315,11 @@ function handleHashChange() { }); return; } - const viewName = match[1]; + + const viewName = match[1]; // only the chunk before any '&' const viewUrl = `views/${viewName}.html`; + + // Now dynamically load that partial, then call its init function import("./viewManager.js").then(({ loadView, viewInitRegistry }) => { loadView(viewUrl).then(() => { const initFn = viewInitRegistry[viewName]; diff --git a/js/lists.js b/js/lists.js index 5242a1a..ee7274a 100644 --- a/js/lists.js +++ b/js/lists.js @@ -12,6 +12,7 @@ const npubs = [ "npub1rcr8h76csgzhdhea4a7tq5w5gydcpg9clgf0cffu6z45rnc6yp5sj7cfuz", // djmeistro "npub1m5s9w4t03znyetxswhgq0ud7fq8ef8y3l4kscn2e8wkvmv42hh3qujgjl3", // mister_monster "npub13qexjtmajssuhz8gdchgx65dwsnr705drse294zz5vt4e78ya2vqzyg8lv", // SatoshiSignal + "npub1da7m2ksdj24995hm8afv88pjpvzt6t9vh70mg8t52yjwtxza3vjszyar58", // GoblinBox ]; console.log("DEBUG: lists.js loaded, npubs:", npubs); 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 e6b5c5e..2589e46 100644 --- a/js/viewManager.js +++ b/js/viewManager.js @@ -1,7 +1,10 @@ // js/viewManager.js +import { initChannelProfileView } from "./channelProfile.js"; +import { subscriptions } from "./subscriptions.js"; -// Load a partial view by URL into the #viewContainer -// js/viewManager.js +/** + * Load a partial view by URL into the #viewContainer. + */ export async function loadView(viewUrl) { try { const res = await fetch(viewUrl); @@ -10,14 +13,14 @@ export async function loadView(viewUrl) { } const text = await res.text(); - // DOMParser, parse out the body, inject + // Use a DOMParser to extract the body contents const parser = new DOMParser(); const doc = parser.parseFromString(text, "text/html"); const container = document.getElementById("viewContainer"); container.innerHTML = doc.body.innerHTML; - // Now copy and execute each script + // Copy and execute any inline scripts const scriptTags = doc.querySelectorAll("script"); scriptTags.forEach((oldScript) => { const newScript = document.createElement("script"); @@ -34,13 +37,16 @@ export async function loadView(viewUrl) { } } +/** + * Registry of view-specific initialization functions. + */ export const viewInitRegistry = { "most-recent-videos": () => { if (window.app && window.app.loadVideos) { window.app.videoList = document.getElementById("videoList"); window.app.loadVideos(); } - // Force the profiles to update after the new view is in place. + // Force profile updates after the new view is in place. if (window.app && window.app.forceRefreshAllProfiles) { window.app.forceRefreshAllProfiles(); } @@ -48,8 +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(); }, - // Add additional view-specific functions here as needed. }; diff --git a/views/channel-profile.html b/views/channel-profile.html new file mode 100644 index 0000000..693c7dd --- /dev/null +++ b/views/channel-profile.html @@ -0,0 +1,58 @@ +
+ +
+ + Banner +
+ +
+
+ Avatar +
+
+

User Name

+

npub...

+
+
+
+ + +
+

+ Website +

+
+ + +
+ +
+ +
+ +

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

+