// 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 = `${new Date(video.created_at * 1000).toLocaleString()}