// js/channelProfile.js import { nostrClient, convertEventToVideo as sharedConvertEventToVideo, } from "./nostr.js"; import { app } from "./app.js"; import { subscriptions } from "./subscriptions.js"; // <-- NEW import import { attachHealthBadges } from "./gridHealth.js"; import { attachUrlHealthBadges } from "./urlHealthObserver.js"; import { accessControl } from "./accessControl.js"; let currentChannelHex = null; let currentChannelNpub = null; let cachedZapButton = null; let cachedChannelShareButton = null; let cachedChannelMenu = null; function getChannelZapButton() { if (cachedZapButton && !document.body.contains(cachedZapButton)) { cachedZapButton = null; } if (!cachedZapButton) { cachedZapButton = document.getElementById("zapButton"); } return cachedZapButton; } function setChannelZapVisibility(visible) { const zapButton = getChannelZapButton(); if (!zapButton) { return; } const shouldShow = !!visible; zapButton.classList.toggle("hidden", !shouldShow); zapButton.disabled = !shouldShow; zapButton.setAttribute("aria-disabled", (!shouldShow).toString()); zapButton.setAttribute("aria-hidden", (!shouldShow).toString()); if (shouldShow) { zapButton.removeAttribute("tabindex"); } else { zapButton.setAttribute("tabindex", "-1"); } } function getChannelShareButton() { if (cachedChannelShareButton && !document.body.contains(cachedChannelShareButton)) { cachedChannelShareButton = null; } if (!cachedChannelShareButton) { cachedChannelShareButton = document.getElementById("channelShareBtn"); } return cachedChannelShareButton; } function getChannelMenuElement() { if (cachedChannelMenu && !document.body.contains(cachedChannelMenu)) { cachedChannelMenu = null; } if (!cachedChannelMenu) { cachedChannelMenu = document.getElementById("moreDropdown-channel-profile"); } return cachedChannelMenu; } function buildChannelShareUrl() { if (!currentChannelNpub) { return ""; } const base = typeof app.getShareUrlBase === "function" ? app.getShareUrlBase() : ""; if (base) { return `${base}#view=channel-profile&npub=${encodeURIComponent(currentChannelNpub)}`; } try { const url = new URL(window.location.href); url.search = ""; url.hash = `#view=channel-profile&npub=${currentChannelNpub}`; return url.toString(); } catch (error) { console.warn("Falling back to basic channel share URL:", error); const origin = window.location?.origin || ""; const pathname = window.location?.pathname || ""; if (!origin && !pathname) { return ""; } return `${origin}${pathname}#view=channel-profile&npub=${currentChannelNpub}`; } } function syncChannelShareButtonState() { const shareBtn = getChannelShareButton(); if (!shareBtn) { return; } const hasNpub = typeof currentChannelNpub === "string" && currentChannelNpub; shareBtn.disabled = !hasNpub; shareBtn.setAttribute("aria-disabled", (!hasNpub).toString()); shareBtn.classList.toggle("opacity-50", !hasNpub); shareBtn.classList.toggle("cursor-not-allowed", !hasNpub); } function setupChannelShareButton() { const shareBtn = getChannelShareButton(); if (!shareBtn) { return; } syncChannelShareButtonState(); if (shareBtn.dataset.initialized === "true") { return; } shareBtn.addEventListener("click", () => { const shareUrl = buildChannelShareUrl(); if (!shareUrl) { app.showError("Could not generate channel link."); return; } navigator.clipboard .writeText(shareUrl) .then(() => app.showSuccess("Channel link copied to clipboard!")) .catch(() => app.showError("Failed to copy the link.")); }); shareBtn.dataset.initialized = "true"; } function setupChannelMoreMenu() { const container = document.querySelector(".channel-profile-container"); if (!container) { return; } app.attachMoreMenuHandlers(container); } async function updateChannelMenuState() { const menu = getChannelMenuElement(); if (!menu) { return; } try { await accessControl.ensureReady(); } catch (error) { console.warn("Failed to refresh moderation lists for channel menu:", error); } const canBlacklist = typeof app.canCurrentUserManageBlacklist === "function" ? app.canCurrentUserManageBlacklist() : false; const buttons = menu.querySelectorAll("button[data-action]"); buttons.forEach((button) => { if (!(button instanceof HTMLElement)) { return; } const action = button.dataset.action || ""; if (action === "copy-npub") { if (currentChannelNpub) { button.dataset.npub = currentChannelNpub; button.classList.remove("hidden"); button.setAttribute("aria-hidden", "false"); } else { delete button.dataset.npub; button.classList.add("hidden"); button.setAttribute("aria-hidden", "true"); } return; } if (action === "block-author") { if (currentChannelHex) { button.dataset.author = currentChannelHex; button.classList.remove("hidden"); button.setAttribute("aria-hidden", "false"); } else { delete button.dataset.author; button.classList.add("hidden"); button.setAttribute("aria-hidden", "true"); } return; } if (action === "blacklist-author") { if (canBlacklist && currentChannelHex) { button.dataset.author = currentChannelHex; if (currentChannelNpub) { button.dataset.npub = currentChannelNpub; } else { delete button.dataset.npub; } button.classList.remove("hidden"); button.setAttribute("aria-hidden", "false"); } else { delete button.dataset.author; delete button.dataset.npub; button.classList.add("hidden"); button.setAttribute("aria-hidden", "true"); } } }); } /** * Initialize the channel profile view. * Called when #view=channel-profile&npub=... */ export async function initChannelProfileView() { // 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 (e.g. #view=channel-profile&npub=...)" ); return; } currentChannelHex = null; currentChannelNpub = null; // 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; } currentChannelHex = hexPub; currentChannelNpub = npub; setupChannelShareButton(); setupChannelMoreMenu(); await updateChannelMenuState(); // 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"); } setupZapButton(); syncChannelShareButtonState(); // 4) Load user’s profile (banner, avatar, etc.) await loadUserProfile(hexPub); await updateChannelMenuState(); syncChannelShareButtonState(); // 5) Load user’s videos (filtered + rendered like the home feed) await loadUserVideos(hexPub); } function setupZapButton() { const zapButton = getChannelZapButton(); if (!zapButton) { return; } setChannelZapVisibility(false); if (zapButton.dataset.initialized === "true") { return; } zapButton.addEventListener("click", () => { window.alert("Zaps coming soon."); }); zapButton.dataset.initialized = "true"; } /** * 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 { 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"); const lightningAddress = (meta.lud16 || meta.lud06 || "").trim(); if (lnEl) { lnEl.textContent = lightningAddress || "No lightning address found."; } setChannelZapVisibility(!!lightningAddress); } else { console.warn("No metadata found for this user."); setChannelZapVisibility(false); const lnEl = document.getElementById("channelLightning"); if (lnEl) { lnEl.textContent = "No lightning address found."; } } } catch (err) { console.error("Failed to fetch user profile data:", err); setChannelZapVisibility(false); const lnEl = document.getElementById("channelLightning"); if (lnEl) { lnEl.textContent = "No lightning address found."; } } } /** * Fetches and displays this user's videos (kind=30078). * Filters out older overshadowed notes, blacklisted, etc. */ async function loadUserVideos(pubkey) { try { try { await accessControl.ensureReady(); } catch (error) { console.warn("Failed to ensure admin lists were loaded before channel fetch:", error); } // 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 and keep everything (including tombstones) const convertedVideos = events .map((evt) => sharedConvertEventToVideo(evt)) .filter((vid) => !vid.invalid); // 4) Deduplicate older overshadowed versions => newest only const newestByRoot = app?.dedupeVideosByRoot?.(convertedVideos) ?? dedupeToNewestByRoot(convertedVideos); // 5) Filter out tombstones, blacklisted IDs / authors let videos = newestByRoot.filter((video) => !video.deleted); videos = videos.filter((video) => { // Event-level blacklisting if (app.blacklistedEventIds.has(video.id)) return false; // Author-level if (!accessControl.canAccess(video)) 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; } // Ensure the global videos map is kept up to date so delegated handlers // have the freshest metadata for this event. window.app?.videosMap?.set(video.id, video); // 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 = `
`; } const moreMenu = `
`; const cardControls = `
${moreMenu}${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( "video-card", "bg-gray-900", "rounded-lg", "overflow-hidden", "shadow-lg", "hover:shadow-2xl", "transition-all", "duration-300" ); cardEl.dataset.ownerIsViewer = canEdit ? "true" : "false"; if (typeof video.pubkey === "string" && video.pubkey) { cardEl.dataset.ownerPubkey = video.pubkey; } else if (cardEl.dataset.ownerPubkey) { delete cardEl.dataset.ownerPubkey; } const rawMagnet = typeof video.magnet === "string" ? video.magnet : ""; const trimmedMagnet = rawMagnet ? rawMagnet.trim() : ""; const legacyInfoHash = typeof video.infoHash === "string" ? video.infoHash.trim() : ""; const playbackUrl = typeof video.url === "string" ? video.url : ""; const trimmedUrl = playbackUrl ? playbackUrl.trim() : ""; const playbackMagnet = trimmedMagnet || legacyInfoHash || ""; const magnetProvided = playbackMagnet.length > 0; const magnetSupported = app.isMagnetUriSupported(playbackMagnet); const showUnsupportedTorrentBadge = !trimmedUrl && magnetProvided && !magnetSupported; const urlBadgeHtml = trimmedUrl ? app.getUrlHealthPlaceholderMarkup({ includeMargin: false }) : ""; const torrentHealthBadgeHtml = magnetSupported && magnetProvided ? app.getTorrentHealthBadgeMarkup({ includeMargin: false }) : ""; const connectionBadgesHtml = urlBadgeHtml || torrentHealthBadgeHtml ? `
${urlBadgeHtml}${torrentHealthBadgeHtml}
` : ""; cardEl.innerHTML = `
${safeTitle}

${safeTitle}

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

${cardControls}
${connectionBadgesHtml}
`; if (showUnsupportedTorrentBadge) { cardEl.dataset.torrentSupported = "false"; } else if (magnetProvided && magnetSupported) { cardEl.dataset.torrentSupported = "true"; } else if (cardEl.dataset.torrentSupported) { delete cardEl.dataset.torrentSupported; } if (trimmedUrl) { cardEl.dataset.urlHealthState = "checking"; if (cardEl.dataset.urlHealthReason) { delete cardEl.dataset.urlHealthReason; } cardEl.dataset.urlHealthEventId = video.id || ""; cardEl.dataset.urlHealthUrl = encodeURIComponent(trimmedUrl); } else { cardEl.dataset.urlHealthState = "offline"; cardEl.dataset.urlHealthReason = "missing-source"; if (cardEl.dataset.urlHealthEventId) { delete cardEl.dataset.urlHealthEventId; } if (cardEl.dataset.urlHealthUrl) { delete cardEl.dataset.urlHealthUrl; } } if (magnetProvided && magnetSupported) { cardEl.dataset.streamHealthState = "checking"; if (cardEl.dataset.streamHealthReason) { delete cardEl.dataset.streamHealthReason; } } else { cardEl.dataset.streamHealthState = "unhealthy"; cardEl.dataset.streamHealthReason = magnetProvided ? "unsupported" : "missing-source"; } if (magnetProvided) { cardEl.dataset.magnet = playbackMagnet; } else if (cardEl.dataset.magnet) { delete cardEl.dataset.magnet; } // Leave the data-play-* attributes empty in the template markup so the raw // URL/magnet strings can be assigned after parsing without HTML entity // escaping, keeping this renderer consistent with app.js. The stored URL is // encoded so it stays intact within data-* attributes, and the click // handler decodes it while leaving magnets untouched until // safeDecodeMagnet() runs. const interactiveEls = cardEl.querySelectorAll("[data-video-id]"); interactiveEls.forEach((el) => { if (!el.dataset) return; if (trimmedUrl) { el.dataset.playUrl = encodeURIComponent(trimmedUrl); } else { delete el.dataset.playUrl; } el.dataset.playMagnet = playbackMagnet || ""; if (magnetProvided) { el.dataset.torrentSupported = magnetSupported ? "true" : "false"; } else if (el.dataset.torrentSupported) { delete el.dataset.torrentSupported; } }); const badgeEl = cardEl.querySelector("[data-url-health-state]"); if (badgeEl) { if (trimmedUrl) { badgeEl.dataset.urlHealthEventId = video.id || ""; badgeEl.dataset.urlHealthUrl = encodeURIComponent(trimmedUrl); } else { if (badgeEl.dataset.urlHealthEventId) { delete badgeEl.dataset.urlHealthEventId; } if (badgeEl.dataset.urlHealthUrl) { delete badgeEl.dataset.urlHealthUrl; } } } fragment.appendChild(cardEl); }); container.appendChild(fragment); attachHealthBadges(container); attachUrlHealthBadges(container, ({ badgeEl, url, eventId }) => { const video = app.videosMap.get(eventId) || { id: eventId }; app.handleUrlHealthBadge({ video, url, badgeEl }); }); window.app.attachVideoListHandler(); // Lazy-load images const lazyEls = container.querySelectorAll("[data-lazy]"); lazyEls.forEach((el) => app.mediaLoader.observe(el)); app.attachMoreMenuHandlers(container); // 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 idxAttr = btn.getAttribute("data-edit-index"); const idx = Number.parseInt(idxAttr, 10); const dropdown = document.getElementById(`settingsDropdown-${idxAttr}`); if (dropdown) dropdown.classList.add("hidden"); const eventId = btn.getAttribute("data-edit-event-id") || ""; app.handleEditVideo({ eventId, index: Number.isNaN(idx) ? null : idx, }); }); }); // Revert handler const revertBtns = container.querySelectorAll("[data-revert-index]"); revertBtns.forEach((btn) => { btn.addEventListener("click", (ev) => { ev.stopPropagation(); const idxAttr = btn.getAttribute("data-revert-index"); const idx = Number.parseInt(idxAttr, 10); const dropdown = document.getElementById(`settingsDropdown-${idxAttr}`); if (dropdown) dropdown.classList.add("hidden"); const eventId = btn.getAttribute("data-revert-event-id") || ""; app.handleRevertVideo({ eventId, index: Number.isNaN(idx) ? null : idx, }); }); }); // Delete All handler const deleteAllBtns = container.querySelectorAll("[data-delete-all-index]"); deleteAllBtns.forEach((btn) => { btn.addEventListener("click", (ev) => { ev.stopPropagation(); const idxAttr = btn.getAttribute("data-delete-all-index"); const idx = Number.parseInt(idxAttr, 10); const dropdown = document.getElementById(`settingsDropdown-${idxAttr}`); if (dropdown) dropdown.classList.add("hidden"); const eventId = btn.getAttribute("data-delete-all-event-id") || ""; app.handleFullDeleteVideo({ eventId, index: Number.isNaN(idx) ? null : idx, }); }); }); } catch (err) { console.error("Error loading user videos:", err); } } window.addEventListener("bitvid:access-control-updated", () => { const hashParams = new URLSearchParams(window.location.hash.slice(1)); if (hashParams.get("view") !== "channel-profile") { return; } if (!currentChannelHex) { return; } const activeNpub = hashParams.get("npub"); if (currentChannelNpub && activeNpub && activeNpub !== currentChannelNpub) { return; } loadUserVideos(currentChannelHex).catch((error) => { console.error("Failed to refresh channel videos after admin update:", error); }); }); /** * 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()); } /** * Basic escaping to avoid XSS. */ function escapeHTML(unsafe = "") { return unsafe .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); }