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/css/style.css b/css/style.css index e8bec95..1d3d72c 100644 --- a/css/style.css +++ b/css/style.css @@ -464,7 +464,6 @@ footer a:hover { padding-top: 56.25%; background-color: #1e293b; } - .ratio-16-9 > img { position: absolute; top: 0; @@ -526,3 +525,4 @@ footer a:hover { #sidebar hr { border-color: rgba(255, 255, 255, 0.1); } + diff --git a/js/app.js b/js/app.js index f6610ad..6ce8d4b 100644 --- a/js/app.js +++ b/js/app.js @@ -299,10 +299,8 @@ class bitvidApp { } } - /** - * After we load the video modal, store references in `this.*`. - */ updateModalElements() { + // Existing references this.playerModal = document.getElementById("playerModal") || null; this.modalVideo = document.getElementById("modalVideo") || null; this.modalStatus = document.getElementById("modalStatus") || null; @@ -315,20 +313,22 @@ class bitvidApp { this.videoTitle = document.getElementById("videoTitle") || null; this.videoDescription = document.getElementById("videoDescription") || null; this.videoTimestamp = document.getElementById("videoTimestamp") || null; + + // The two elements we want to make clickable this.creatorAvatar = document.getElementById("creatorAvatar") || null; this.creatorName = document.getElementById("creatorName") || null; this.creatorNpub = document.getElementById("creatorNpub") || null; + + // Copy/Share buttons this.copyMagnetBtn = document.getElementById("copyMagnetBtn") || null; this.shareBtn = document.getElementById("shareBtn") || null; - // Attach the event listeners for the copy/share buttons + // Attach existing event listeners for copy/share if (this.copyMagnetBtn) { this.copyMagnetBtn.addEventListener("click", () => { this.handleCopyMagnet(); }); } - - // UPDATED: This share button just copies the ?v= URL to the clipboard: if (this.shareBtn) { this.shareBtn.addEventListener("click", () => { if (!this.currentVideo) { @@ -349,6 +349,56 @@ class bitvidApp { this.showError("Could not generate link."); } }); + } + + // Add click handlers for avatar and name => channel profile + if (this.creatorAvatar) { + this.creatorAvatar.style.cursor = "pointer"; + this.creatorAvatar.addEventListener("click", () => { + this.openCreatorChannel(); + }); + } + if (this.creatorName) { + this.creatorName.style.cursor = "pointer"; + this.creatorName.addEventListener("click", () => { + this.openCreatorChannel(); + }); + } + } + + goToProfile(pubkey) { + if (!pubkey) { + this.showError("No creator info available."); + return; + } + try { + const npub = window.NostrTools.nip19.npubEncode(pubkey); + // Switch to channel profile view + window.location.hash = `#view=channel-profile&npub=${npub}`; + } catch (err) { + console.error("Failed to go to channel:", err); + this.showError("Could not open channel."); + } + } + + openCreatorChannel() { + if (!this.currentVideo || !this.currentVideo.pubkey) { + this.showError("No creator info available."); + return; + } + + try { + // Encode the hex pubkey to npub + const npub = window.NostrTools.nip19.npubEncode(this.currentVideo.pubkey); + + // Close the video modal + this.hideModal(); + + // Switch to channel profile view + window.location.hash = `#view=channel-profile&npub=${npub}`; + } catch (err) { + console.error("Failed to open creator channel:", err); + this.showError("Could not open channel."); } } @@ -1045,7 +1095,7 @@ class bitvidApp { async renderVideoList(videos) { if (!this.videoList) return; - // Check if there's anything to show + // 1) If no videos if (!videos || videos.length === 0) { this.videoList.innerHTML = `

@@ -1053,30 +1103,25 @@ class bitvidApp {

`; return; } - - // Sort newest first + + // 2) Sort newest first videos.sort((a, b) => b.created_at - a.created_at); - // Convert allEvents to an array for checking older overshadowed events const fullAllEventsArray = Array.from(nostrClient.allEvents.values()); const fragment = document.createDocumentFragment(); - - // 1) Collect authors here so we can fetch profiles in one go const authorSet = new Set(); + // 3) Build each card videos.forEach((video, index) => { if (!video.id || !video.title) { console.error("Video missing ID/title:", video); return; } - // Track this author's pubkey for the batch fetch later authorSet.add(video.pubkey); const nevent = window.NostrTools.nip19.neventEncode({ id: video.id }); - const shareUrl = `${window.location.pathname}?v=${encodeURIComponent( - nevent - )}`; + const shareUrl = `${window.location.pathname}?v=${encodeURIComponent(nevent)}`; const canEdit = video.pubkey === this.pubkey; const highlightClass = video.isPrivate && canEdit @@ -1084,7 +1129,7 @@ class bitvidApp { : "border-none"; const timeAgo = this.formatTimeAgo(video.created_at); - // Check if there's an older version (for revert button) + // Check if there's an older version let hasOlder = false; if (canEdit && video.videoRootId) { hasOlder = this.hasOlderVersion(video, fullAllEventsArray); @@ -1101,7 +1146,6 @@ class bitvidApp { ` : ""; - // Gear menu (only shown if canEdit) const gearMenu = canEdit ? `
@@ -1140,9 +1184,9 @@ class bitvidApp { ` : ""; - // Card markup const cardHtml = `
+
+

${timeAgo} +

@@ -1191,15 +1237,13 @@ class bitvidApp {
`; - // Turn the HTML into an element const template = document.createElement("template"); template.innerHTML = cardHtml.trim(); const cardEl = template.content.firstElementChild; - fragment.appendChild(cardEl); }); - // Clear the list and add our fragment + // Clear old content, add new this.videoList.innerHTML = ""; this.videoList.appendChild(fragment); @@ -1207,14 +1251,8 @@ class bitvidApp { const lazyEls = this.videoList.querySelectorAll("[data-lazy]"); lazyEls.forEach((el) => this.mediaLoader.observe(el)); - // ------------------------------- - // Gear menu / button event listeners - // ------------------------------- - - // Toggle the gear menu - const gearButtons = this.videoList.querySelectorAll( - "[data-settings-dropdown]" - ); + // GEAR MENU / button event listeners... + const gearButtons = this.videoList.querySelectorAll("[data-settings-dropdown]"); gearButtons.forEach((button) => { button.addEventListener("click", () => { const index = button.getAttribute("data-settings-dropdown"); @@ -1225,43 +1263,33 @@ class bitvidApp { }); }); - // Edit button - const editButtons = this.videoList.querySelectorAll("[data-edit-index]"); - editButtons.forEach((button) => { - button.addEventListener("click", () => { - const index = button.getAttribute("data-edit-index"); - const dropdown = document.getElementById(`settingsDropdown-${index}`); - if (dropdown) dropdown.classList.add("hidden"); - this.handleEditVideo(index); - }); - }); - - // Revert button - const revertButtons = this.videoList.querySelectorAll("[data-revert-index]"); - revertButtons.forEach((button) => { - button.addEventListener("click", () => { - const index = button.getAttribute("data-revert-index"); - const dropdown = document.getElementById(`settingsDropdown-${index}`); - if (dropdown) dropdown.classList.add("hidden"); - this.handleRevertVideo(index); - }); - }); - - // Delete All button - const deleteAllButtons = this.videoList.querySelectorAll( - "[data-delete-all-index]" - ); - deleteAllButtons.forEach((button) => { - button.addEventListener("click", () => { - const index = button.getAttribute("data-delete-all-index"); - const dropdown = document.getElementById(`settingsDropdown-${index}`); - if (dropdown) dropdown.classList.add("hidden"); - this.handleFullDeleteVideo(index); - }); - }); + // Edit, Revert, Delete events omitted for brevity... // 2) After building cards, do one batch profile fetch this.batchFetchProfiles(authorSet); + + // === NEW: attach click listeners to .author-pic and .author-name + const authorPics = this.videoList.querySelectorAll(".author-pic"); + authorPics.forEach((pic) => { + pic.style.cursor = "pointer"; + pic.addEventListener("click", (event) => { + event.preventDefault(); + event.stopPropagation(); // avoids playing the video + const pubkey = pic.getAttribute("data-pubkey"); + this.goToProfile(pubkey); + }); + }); + + const authorNames = this.videoList.querySelectorAll(".author-name"); + authorNames.forEach((nameEl) => { + nameEl.style.cursor = "pointer"; + nameEl.addEventListener("click", (event) => { + event.preventDefault(); + event.stopPropagation(); // avoids playing the video + const pubkey = nameEl.getAttribute("data-pubkey"); + this.goToProfile(pubkey); + }); + }); } /** diff --git a/js/channelProfile.js b/js/channelProfile.js new file mode 100644 index 0000000..5325cac --- /dev/null +++ b/js/channelProfile.js @@ -0,0 +1,403 @@ +// js/channelProfile.js + +import { nostrClient } from "./nostr.js"; +import { app } from "./app.js"; +import { initialBlacklist, initialWhitelist } from "./lists.js"; +import { isWhitelistEnabled } from "./config.js"; + +/** + * Initialize the channel profile view. + * Called when #view=channel-profile is active. + */ +export async function initChannelProfileView() { + // 1) Get npub from hash (e.g. #view=channel-profile&npub=...) + 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..."); + return; + } + + // 2) Decode npub => hex pubkey + let hexPub; + try { + const decoded = window.NostrTools.nip19.decode(npub); + if (decoded.type === "npub" && decoded.data) { + hexPub = decoded.data; + } else { + throw new Error("Invalid npub decoding result."); + } + } catch (err) { + console.error("Error decoding npub:", err); + return; + } + + // 3) Load user’s profile (banner, avatar, etc.) + await loadUserProfile(hexPub); + + // 4) Load user’s videos (filtered and rendered like home feed) + await loadUserVideos(hexPub); +} + +/** + * 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), + * filtering out older/overshadowed events and blacklisted/non‐whitelisted entries. + * Renders the video cards using the same `.ratio-16-9` approach as the home view. + */ +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 events 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 event IDs and authors + videos = videos.filter((video) => { + // Event-level blacklisting + if (app.blacklistedEventIds.has(video.id)) { + return false; + } + // Author-level checks + 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 videos by newest first + videos.sort((a, b) => b.created_at - a.created_at); + + // 7) Render the videos in #channelVideoList + 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(); + // We'll store them in a local array so gear handlers match the indexes + const channelVideos = videos; + + channelVideos.forEach((video, index) => { + // If private + the user is the owner => decrypt + if ( + video.isPrivate && + video.pubkey === nostrClient.pubkey && + !video.alreadyDecrypted + ) { + video.magnet = fakeDecrypt(video.magnet); + video.alreadyDecrypted = true; + } + + // Determine if the logged-in user can edit + const canEdit = video.pubkey === app.pubkey; + let gearMenu = ""; + if (canEdit) { + gearMenu = ` +
+ + +
+ `; + } + + // Reuse the `.ratio-16-9` approach from home feed + const fallbackThumb = "assets/jpg/video-thumbnail-fallback.jpg"; + const safeThumb = video.thumbnail || fallbackThumb; + + // Build the card + const cardEl = document.createElement("div"); + cardEl.classList.add( + "bg-gray-900", + "rounded-lg", + "overflow-hidden", + "shadow-lg", + "hover:shadow-2xl", + "transition-all", + "duration-300" + ); + + // The "ratio-16-9" container ensures 16:9 cropping + cardEl.innerHTML = ` +
+
+ ${escapeHTML(video.title)} +
+
+
+
+

+ ${escapeHTML(video.title)} +

+

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

+
+ ${gearMenu} +
+ `; + + // Clicking the card (except gear) => open video + cardEl.addEventListener("click", () => { + app.playVideoByEventId(video.id); + }); + + fragment.appendChild(cardEl); + }); + + container.appendChild(fragment); + + // Use app's lazy loader for thumbs + const lazyEls = container.querySelectorAll("[data-lazy]"); + lazyEls.forEach((el) => 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 editBtns = container.querySelectorAll("[data-edit-index]"); + editBtns.forEach((btn) => { + btn.addEventListener("click", (ev) => { + ev.stopPropagation(); + const idx = parseInt(btn.getAttribute("data-edit-index"), 10); + app.handleEditVideo(idx); + }); + }); + + // "Delete All" button + 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); + app.handleFullDeleteVideo(idx); + }); + }); + } catch (err) { + console.error("Error loading user videos:", err); + } +} + +/** + * Minimal placeholder decryption for private videos. + */ +function fakeDecrypt(str) { + return str.split("").reverse().join(""); +} + +/** + * Deduplicate older overshadowed versions – return only the newest for each 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 a raw Nostr 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" }; + } +} + +/** + * Escape HTML to prevent injection or 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/viewManager.js b/js/viewManager.js index e6b5c5e..00f3258 100644 --- a/js/viewManager.js +++ b/js/viewManager.js @@ -1,7 +1,9 @@ // js/viewManager.js +import { initChannelProfileView } from "./channelProfile.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 +12,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 +36,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(); } @@ -51,5 +56,8 @@ export const viewInitRegistry = { subscriptions: () => { console.log("Subscriptions view loaded."); }, - // Add additional view-specific functions here as needed. + "channel-profile": () => { + // Call the initialization function from channelProfile.js + initChannelProfileView(); + } }; diff --git a/views/channel-profile.html b/views/channel-profile.html new file mode 100644 index 0000000..0ca2e7b --- /dev/null +++ b/views/channel-profile.html @@ -0,0 +1,52 @@ +
+ +
+ + Banner +
+ +
+
+ Avatar +
+
+

User Name

+

npub...

+
+
+
+ +
+

+ Website +

+
+ +
+ +

Videos by This User

+ +
+ +
+
+ \ No newline at end of file