// js/channelProfile.js import { nostrClient, DEFAULT_RELAY_URLS, convertEventToVideo as sharedConvertEventToVideo } from "./nostr.js"; import { subscriptions } from "./subscriptions.js"; // <-- NEW import import { attachHealthBadges } from "./gridHealth.js"; import { attachUrlHealthBadges } from "./urlHealthObserver.js"; import { accessControl } from "./accessControl.js"; import { getApplication } from "./applicationContext.js"; import { escapeHTML } from "./utils/domUtils.js"; import positionFloatingPanel from "./ui/utils/positionFloatingPanel.js"; import { VideoCard } from "./ui/components/VideoCard.js"; import { ALLOW_NSFW_CONTENT } from "./config.js"; import { calculateZapShares, describeShareType, fetchLightningMetadata, formatMinRequirement, getCachedLightningEntry, getCachedMetadataByUrl, getCachedPlatformLightningAddress, isMetadataEntryFresh, normalizeLightningAddressKey, rememberLightningMetadata, setCachedPlatformLightningAddress, validateInvoiceAmount } from "./payments/zapSharedState.js"; import { splitAndZap } from "./payments/zapSplit.js"; import { resolveLightningAddress, fetchPayServiceData, requestInvoice } from "./payments/lnurl.js"; import { getPlatformLightningAddress } from "./payments/platformAddress.js"; import { ensureWallet, sendPayment as sendWalletPayment } from "./payments/nwcClient.js"; const getApp = () => getApplication(); let currentChannelHex = null; let currentChannelNpub = null; let currentChannelLightningAddress = ""; const summarizeZapTracker = (tracker) => Array.isArray(tracker) ? tracker.map((entry) => ({ type: entry?.type || "unknown", status: entry?.status || "unknown", amount: Number.isFinite(entry?.amount) && entry.amount >= 0 ? entry.amount : undefined, address: entry?.address || undefined, hasPayment: Boolean(entry?.payment), errorMessage: typeof entry?.error?.message === "string" ? entry.error.message : entry?.error ? String(entry.error) : undefined })) : undefined; function logZapError(stage, details = {}, error) { const summary = { stage, shareType: details?.shareType, address: details?.address, amount: Number.isFinite(details?.amount) && details.amount >= 0 ? details.amount : undefined, overrideFee: typeof details?.overrideFee === "number" ? details.overrideFee : undefined, commentLength: typeof details?.comment === "string" ? details.comment.length : undefined, wallet: details?.walletSettings ? { hasUri: Boolean(details.walletSettings.nwcUri), type: details.walletSettings.type || details.walletSettings.name || details.walletSettings.client || undefined } : undefined, shares: details?.context?.shares ? { total: details.context.shares.total, creator: details.context.shares.creatorShare, platform: details.context.shares.platformShare } : undefined, tracker: summarizeZapTracker(details?.tracker), retryAttempt: Number.isInteger(details?.retryAttempt) && details.retryAttempt >= 0 ? details.retryAttempt : undefined }; console.error("[zap] Channel zap failure", summary, error); } let currentChannelProfileEvent = null; let cachedZapButton = null; let cachedChannelShareButton = null; let cachedChannelMenu = null; let cachedZapControls = null; let cachedZapForm = null; let cachedZapAmountInput = null; let cachedZapSplitSummary = null; let cachedZapStatus = null; let cachedZapReceipts = null; let cachedZapWalletPrompt = null; let cachedZapWalletLink = null; let cachedZapCloseBtn = null; let cachedZapSendBtn = null; let pendingZapRetry = null; let zapInFlight = false; let zapControlsOpen = false; let zapControlsPositioner = null; let currentVideoLoadToken = 0; let currentProfileLoadToken = 0; const FALLBACK_CHANNEL_BANNER = "assets/jpg/bitvid.jpg"; const FALLBACK_CHANNEL_AVATAR = "assets/svg/default-profile.svg"; const PROFILE_EVENT_CACHE_TTL_MS = 5 * 60 * 1000; const channelProfileMetadataCache = new Map(); function getChannelZapButton() { if (cachedZapButton && !document.body.contains(cachedZapButton)) { cachedZapButton = null; } if (!cachedZapButton) { cachedZapButton = document.getElementById("zapButton"); } return cachedZapButton; } function setChannelZapVisibility(visible) { const zapButton = getChannelZapButton(); const controls = getZapControlsContainer(); const amountInput = getZapAmountInput(); const sendButton = getZapSendButton(); if (!zapButton) { return; } const app = getApp(); const isLoggedIn = typeof app?.isUserLoggedIn === "function" ? app.isUserLoggedIn() : Boolean(app?.normalizeHexPubkey?.(app?.pubkey)); const shouldShow = !!visible && isLoggedIn; zapButton.toggleAttribute("hidden", !shouldShow); zapButton.disabled = !shouldShow; zapButton.setAttribute("aria-disabled", (!shouldShow).toString()); zapButton.setAttribute("aria-hidden", (!shouldShow).toString()); zapButton.setAttribute("aria-expanded", "false"); if (shouldShow) { zapButton.removeAttribute("tabindex"); } else { zapButton.setAttribute("tabindex", "-1"); resetZapRetryState(); zapInFlight = false; clearZapReceipts(); setZapStatus("", "neutral"); } closeZapControls(); if (controls) { controls.setAttribute("aria-hidden", "true"); controls.hidden = true; } if (amountInput) { amountInput.disabled = !shouldShow; } if (sendButton) { sendButton.disabled = !shouldShow; sendButton.setAttribute("aria-hidden", (!shouldShow).toString()); if (shouldShow) { sendButton.removeAttribute("tabindex"); sendButton.removeAttribute("aria-busy"); delete sendButton.dataset.state; } else { sendButton.setAttribute("tabindex", "-1"); delete sendButton.dataset.state; } } const hasWallet = typeof app?.hasActiveWalletConnection === "function" ? app.hasActiveWalletConnection() : (() => { const settings = typeof app?.getActiveNwcSettings === "function" ? app.getActiveNwcSettings() : {}; const uri = typeof settings?.nwcUri === "string" ? settings.nwcUri.trim() : ""; return uri.length > 0; })(); setZapWalletPromptVisible(shouldShow && !hasWallet); if (shouldShow) { setupZapWalletLink(); } } 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 getZapControlsContainer() { if (cachedZapControls && !document.body.contains(cachedZapControls)) { cachedZapControls = null; } if (!cachedZapControls) { cachedZapControls = document.getElementById("zapControls"); } if (cachedZapControls) { if (!cachedZapControls.dataset.state) { const isHidden = cachedZapControls.hasAttribute("hidden") || cachedZapControls.getAttribute("aria-hidden") === "true"; cachedZapControls.dataset.state = isHidden ? "closed" : "open"; } if (cachedZapControls.dataset.state !== "open") { cachedZapControls.hidden = true; cachedZapControls.setAttribute("aria-hidden", "true"); } } return cachedZapControls; } function getZapFormElement() { if (cachedZapForm && !document.body.contains(cachedZapForm)) { cachedZapForm = null; } if (!cachedZapForm) { cachedZapForm = document.getElementById("zapForm"); } return cachedZapForm; } function getZapAmountInput() { if (cachedZapAmountInput && !document.body.contains(cachedZapAmountInput)) { cachedZapAmountInput = null; } if (!cachedZapAmountInput) { cachedZapAmountInput = document.getElementById("zapAmountInput"); } return cachedZapAmountInput; } function getZapSplitSummaryElement() { if (cachedZapSplitSummary && !document.body.contains(cachedZapSplitSummary)) { cachedZapSplitSummary = null; } if (!cachedZapSplitSummary) { cachedZapSplitSummary = document.getElementById("zapSplitSummary"); } return cachedZapSplitSummary; } function getZapStatusElement() { if (cachedZapStatus && !document.body.contains(cachedZapStatus)) { cachedZapStatus = null; } if (!cachedZapStatus) { cachedZapStatus = document.getElementById("zapStatus"); } return cachedZapStatus; } function getZapReceiptsList() { if (cachedZapReceipts && !document.body.contains(cachedZapReceipts)) { cachedZapReceipts = null; } if (!cachedZapReceipts) { cachedZapReceipts = document.getElementById("zapReceipts"); } return cachedZapReceipts; } function getZapSendButton() { if (cachedZapSendBtn && !document.body.contains(cachedZapSendBtn)) { cachedZapSendBtn = null; } if (!cachedZapSendBtn) { cachedZapSendBtn = document.getElementById("zapSendBtn"); } return cachedZapSendBtn; } function getZapWalletPrompt() { if (cachedZapWalletPrompt && !document.body.contains(cachedZapWalletPrompt)) { cachedZapWalletPrompt = null; } if (!cachedZapWalletPrompt) { cachedZapWalletPrompt = document.getElementById("zapWalletPrompt"); } return cachedZapWalletPrompt; } function getZapWalletLink() { if (cachedZapWalletLink && !document.body.contains(cachedZapWalletLink)) { cachedZapWalletLink = null; } if (!cachedZapWalletLink) { cachedZapWalletLink = document.getElementById("zapWalletLink"); } return cachedZapWalletLink; } function getZapCloseButton() { if (cachedZapCloseBtn && !document.body.contains(cachedZapCloseBtn)) { cachedZapCloseBtn = null; } if (!cachedZapCloseBtn) { cachedZapCloseBtn = document.getElementById("zapCloseBtn"); } return cachedZapCloseBtn; } function setZapWalletPromptVisible(visible) { const prompt = getZapWalletPrompt(); if (!prompt) { return; } const shouldShow = !!visible; prompt.toggleAttribute("hidden", !shouldShow); prompt.setAttribute("aria-hidden", (!shouldShow).toString()); } function setupZapWalletLink() { const link = getZapWalletLink(); if (!link) { return; } if (link.dataset.initialized === "true") { return; } link.addEventListener("click", (event) => { event?.preventDefault?.(); const app = getApp(); if (typeof app?.openWalletPane === "function") { app.openWalletPane(); } }); link.dataset.initialized = "true"; } function focusZapAmountField() { const amountInput = getZapAmountInput(); if (amountInput && typeof amountInput.focus === "function") { try { amountInput.focus({ preventScroll: true }); } catch (error) { amountInput.focus(); } } } function isZapControlsOpen() { return zapControlsOpen; } function openZapControls({ focus = false } = {}) { const controls = getZapControlsContainer(); const zapButton = getChannelZapButton(); if (!controls || !zapButton) { return false; } if (!zapControlsPositioner) { zapControlsPositioner = positionFloatingPanel(zapButton, controls, { placement: "bottom", alignment: "end", offset: 8, viewportPadding: 16, }); } if (!zapControlsOpen) { controls.hidden = false; controls.dataset.state = "open"; controls.setAttribute("aria-hidden", "false"); zapButton.setAttribute("aria-expanded", "true"); zapControlsOpen = true; zapControlsPositioner?.update(); } if (focus) { focusZapAmountField(); } return true; } function closeZapControls({ focusButton = false } = {}) { const controls = getZapControlsContainer(); const zapButton = getChannelZapButton(); if (controls) { controls.dataset.state = "closed"; controls.setAttribute("aria-hidden", "true"); controls.hidden = true; } if (zapButton) { zapButton.setAttribute("aria-expanded", "false"); if (focusButton && typeof zapButton.focus === "function") { zapButton.focus(); } } zapControlsOpen = false; return Boolean(controls && zapButton); } function updateZapSplitSummary({ overrideFee = null } = {}) { const summaryEl = getZapSplitSummaryElement(); if (!summaryEl) { return; } const amountInput = getZapAmountInput(); const numeric = Math.max(0, Math.round(Number(amountInput?.value || 0))); if (!numeric) { summaryEl.textContent = "Enter an amount to view the split."; return; } const shares = calculateZapShares(numeric, overrideFee); const parts = []; const creatorEntry = getCachedLightningEntry(currentChannelLightningAddress); const creatorMin = formatMinRequirement(creatorEntry?.metadata); let creatorText = `Creator: ${shares.creatorShare} sats`; if (Number.isFinite(creatorMin) && creatorMin > 0) { creatorText += ` (min ${creatorMin})`; } parts.push(creatorText); if (shares.platformShare > 0) { const platformEntry = getCachedLightningEntry( getCachedPlatformLightningAddress() ); const platformMin = formatMinRequirement(platformEntry?.metadata); let platformText = `Platform: ${shares.platformShare} sats`; if (Number.isFinite(platformMin) && platformMin > 0) { platformText += ` (min ${platformMin})`; } parts.push(platformText); } summaryEl.textContent = parts.join(" • "); } function setZapStatus(message, tone = "neutral") { const statusEl = getZapStatusElement(); if (!statusEl) { return; } const normalizedTone = typeof tone === "string" ? tone : "neutral"; statusEl.textContent = message || ""; statusEl.classList.remove( "text-text", "text-muted", "text-info", "text-critical", "text-warning-strong" ); if (!message) { statusEl.classList.add("text-muted"); return; } switch (normalizedTone) { case "success": statusEl.classList.add("text-info"); break; case "error": statusEl.classList.add("text-critical"); break; case "warning": statusEl.classList.add("text-warning-strong"); break; default: statusEl.classList.add("text-text"); break; } } function clearZapReceipts() { const list = getZapReceiptsList(); if (!list) { return; } list.innerHTML = ""; } function renderZapReceipts(receipts, { partial = false } = {}) { const list = getZapReceiptsList(); if (!list) { return; } list.innerHTML = ""; if (!Array.isArray(receipts) || receipts.length === 0) { if (partial) { const emptyItem = document.createElement("li"); emptyItem.className = "rounded border border-border bg-panel/70 p-3 text-text"; emptyItem.textContent = "No receipts were returned for this attempt."; list.appendChild(emptyItem); } return; } receipts.forEach((receipt) => { if (!receipt) { return; } const li = document.createElement("li"); li.className = "rounded border border-border bg-panel/70 p-3"; const header = document.createElement("div"); header.className = "flex items-center justify-between text-xs font-semibold uppercase tracking-wide text-text"; const shareType = receipt.recipientType || receipt.type || "creator"; const shareLabel = document.createElement("span"); shareLabel.textContent = `${describeShareType(shareType)} • ${Math.max( 0, Math.round(Number(receipt.amount || 0)) )} sats`; const status = document.createElement("span"); const isSuccess = receipt.status ? receipt.status === "success" : !receipt.error; status.textContent = isSuccess ? "Success" : "Failed"; status.className = isSuccess ? "text-info" : "text-critical"; header.appendChild(shareLabel); header.appendChild(status); li.appendChild(header); const address = document.createElement("p"); address.className = "mt-1 text-xs text-text break-all"; const addressValue = receipt.address || ""; if (addressValue) { address.textContent = addressValue; li.appendChild(address); } const detail = document.createElement("p"); detail.className = "mt-2 text-xs text-muted"; if (isSuccess) { let detailMessage = "Invoice settled."; const preimage = receipt.payment?.result?.preimage; if (typeof preimage === "string" && preimage) { detailMessage = `Preimage: ${preimage.slice(0, 18)}${ preimage.length > 18 ? "…" : "" }`; } detail.textContent = detailMessage; } else { const errorMessage = (receipt.error && receipt.error.message) || (typeof receipt.error === "string" ? receipt.error : "Payment failed."); detail.textContent = errorMessage; } li.appendChild(detail); list.appendChild(li); }); } function resetZapRetryState() { pendingZapRetry = null; const zapButton = getChannelZapButton(); if (zapButton) { delete zapButton.dataset.retryPending; zapButton.setAttribute("aria-label", "Open zap dialog"); zapButton.title = "Open zap dialog"; } const sendButton = getZapSendButton(); if (sendButton) { delete sendButton.dataset.retryPending; sendButton.textContent = "Send"; sendButton.setAttribute("aria-label", "Send a zap"); sendButton.removeAttribute("title"); } } function markZapRetryPending(shares) { const validShares = Array.isArray(shares) ? shares.filter((share) => share && share.amount > 0) : []; if (!validShares.length) { resetZapRetryState(); return; } pendingZapRetry = { shares: validShares, createdAt: Date.now() }; const zapButton = getChannelZapButton(); if (zapButton) { zapButton.dataset.retryPending = "true"; zapButton.setAttribute("aria-label", "Retry failed zap shares"); zapButton.title = "Retry failed zap shares"; } const sendButton = getZapSendButton(); if (sendButton) { sendButton.dataset.retryPending = "true"; sendButton.textContent = "Retry"; sendButton.setAttribute("aria-label", "Retry failed zap shares"); sendButton.title = "Retry failed zap shares"; } } function getZapVideoEvent() { const lightningAddress = currentChannelLightningAddress || ""; const baseEvent = currentChannelProfileEvent && typeof currentChannelProfileEvent === "object" ? { ...currentChannelProfileEvent } : {}; return { kind: typeof baseEvent.kind === "number" ? baseEvent.kind : 0, id: baseEvent.id || currentChannelHex || "", pubkey: baseEvent.pubkey || currentChannelHex || "", tags: Array.isArray(baseEvent.tags) ? [...baseEvent.tags] : [], content: typeof baseEvent.content === "string" ? baseEvent.content : "", created_at: baseEvent.created_at || Math.floor(Date.now() / 1000), lightningAddress }; } async function prepareLightningContext({ amount, overrideFee = null }) { if (!currentChannelLightningAddress) { throw new Error("This creator has not configured a Lightning address yet."); } const shares = calculateZapShares(amount, overrideFee); if (!shares.total) { throw new Error("Enter a zap amount greater than zero."); } const creatorEntry = await fetchLightningMetadata( currentChannelLightningAddress ); if (shares.creatorShare > 0) { try { validateInvoiceAmount(creatorEntry.metadata, shares.creatorShare); } catch (error) { const detail = error?.message || "Unable to validate creator share."; throw new Error(`Creator share error: ${detail}`); } } let platformEntry = null; let platformAddress = ""; if (shares.platformShare > 0) { platformAddress = getCachedPlatformLightningAddress(); if (!platformAddress) { platformAddress = await getPlatformLightningAddress({ forceRefresh: false }); setCachedPlatformLightningAddress(platformAddress || ""); } if (!platformAddress) { throw new Error("Platform Lightning address is unavailable."); } platformEntry = await fetchLightningMetadata(platformAddress); try { validateInvoiceAmount(platformEntry.metadata, shares.platformShare); } catch (error) { const detail = error?.message || "Unable to validate platform share."; throw new Error(`Platform share error: ${detail}`); } } updateZapSplitSummary(); return { shares, creatorEntry, platformEntry, platformAddress }; } function createZapDependencies({ creatorEntry, platformEntry, shares, shareTracker, walletSettings }) { const creatorKey = normalizeLightningAddressKey( creatorEntry?.address || currentChannelLightningAddress ); const platformKey = normalizeLightningAddressKey( platformEntry?.address || getCachedPlatformLightningAddress() ); let activeShare = null; return { lnurl: { resolveLightningAddress: (value) => { const normalized = normalizeLightningAddressKey(value); if (normalized && normalized === creatorKey) { activeShare = "creator"; if (creatorEntry?.resolved) { return { ...creatorEntry.resolved }; } } else if (normalized && normalized === platformKey) { activeShare = "platform"; if (platformEntry?.resolved) { return { ...platformEntry.resolved }; } } else { activeShare = null; } const resolved = resolveLightningAddress(value); if (normalized) { rememberLightningMetadata({ key: normalized, address: resolved.address || value, resolved, fetchedAt: Date.now() }); } return resolved; }, fetchPayServiceData: async (url) => { const cached = getCachedMetadataByUrl(url); if (cached?.metadata && isMetadataEntryFresh(cached)) { return cached.metadata; } const metadata = await fetchPayServiceData(url); if (cached) { rememberLightningMetadata({ ...cached, metadata, fetchedAt: Date.now() }); } return metadata; }, validateInvoiceAmount, requestInvoice }, wallet: { ensureWallet: async (options) => { try { return await ensureWallet(options); } catch (error) { logZapError( "wallet.ensureWallet", { tracker: shareTracker, context: { shares }, walletSettings: options?.settings }, error ); throw error; } }, sendPayment: async (bolt11, params) => { const shareType = activeShare || "unknown"; const shareAmount = shareType === "platform" ? shares.platformShare : shares.creatorShare; const address = shareType === "platform" ? platformEntry?.address || getCachedPlatformLightningAddress() : creatorEntry?.address || currentChannelLightningAddress; const normalizedParams = { ...(params || {}) }; if ( !Number.isFinite(normalizedParams.amountSats) && Number.isFinite(shareAmount) ) { normalizedParams.amountSats = shareAmount; } if (!normalizedParams.settings && walletSettings) { normalizedParams.settings = walletSettings; } try { const payment = await sendWalletPayment(bolt11, normalizedParams); if (Array.isArray(shareTracker)) { shareTracker.push({ type: shareType, status: "success", amount: shareAmount, address, payment }); } return payment; } catch (error) { if (Array.isArray(shareTracker)) { shareTracker.push({ type: shareType, status: "error", amount: shareAmount, address, error }); } logZapError( "wallet.sendPayment", { shareType, amount: shareAmount, address, tracker: shareTracker, context: { shares } }, error ); throw error; } finally { activeShare = null; } } }, platformAddress: { getPlatformLightningAddress: async () => { if (platformEntry?.address) { return platformEntry.address; } const cachedAddress = getCachedPlatformLightningAddress(); if (cachedAddress) { return cachedAddress; } const fallback = await getPlatformLightningAddress({ forceRefresh: false }); setCachedPlatformLightningAddress(fallback || ""); return fallback; } } }; } function getWalletSettingsOrPrompt() { const app = getApp(); const settings = typeof app?.getActiveNwcSettings === "function" ? app.getActiveNwcSettings() : {}; const normalizedUri = typeof settings?.nwcUri === "string" ? settings.nwcUri.trim() : ""; if (!normalizedUri) { app?.showError?.("Connect a Lightning wallet to send zaps."); if (typeof app?.openWalletPane === "function") { app.openWalletPane(); } return null; } return { ...settings, nwcUri: normalizedUri }; } async function runZapAttempt({ amount, overrideFee = null, walletSettings }) { const settings = walletSettings || getWalletSettingsOrPrompt(); if (!settings) { return null; } let context; try { context = await prepareLightningContext({ amount, overrideFee }); } catch (error) { logZapError( "prepareLightningContext", { amount, overrideFee, walletSettings: settings }, error ); throw error; } const shareTracker = []; const dependencies = createZapDependencies({ ...context, shareTracker, walletSettings: settings }); const videoEvent = getZapVideoEvent(); let previousOverride; const hasGlobal = typeof globalThis !== "undefined"; if ( hasGlobal && typeof overrideFee === "number" && Number.isFinite(overrideFee) ) { previousOverride = globalThis.__BITVID_PLATFORM_FEE_OVERRIDE__; globalThis.__BITVID_PLATFORM_FEE_OVERRIDE__ = overrideFee; } try { const result = await splitAndZap( { videoEvent, amountSats: context.shares.total, walletSettings: settings }, dependencies ); return { context, result, shareTracker }; } catch (error) { if (Array.isArray(shareTracker) && shareTracker.length) { error.__zapShareTracker = shareTracker; } logZapError( "splitAndZap", { amount, overrideFee, walletSettings: settings, context, tracker: shareTracker }, error ); throw error; } finally { if ( hasGlobal && typeof overrideFee === "number" && Number.isFinite(overrideFee) ) { if (typeof previousOverride === "number") { globalThis.__BITVID_PLATFORM_FEE_OVERRIDE__ = previousOverride; } else if ( globalThis && "__BITVID_PLATFORM_FEE_OVERRIDE__" in globalThis ) { delete globalThis.__BITVID_PLATFORM_FEE_OVERRIDE__; } } } } function handleZapAmountChange() { resetZapRetryState(); updateZapSplitSummary(); } async function executePendingRetry({ walletSettings }) { const retryState = pendingZapRetry; const shares = Array.isArray(retryState?.shares) ? retryState.shares : []; if (!shares.length) { resetZapRetryState(); return; } const app = getApp(); const summary = shares .map((share) => `${describeShareType(share.type)} ${share.amount} sats`) .join(", "); setZapStatus(`Retrying failed share(s): ${summary}`, "warning"); clearZapReceipts(); const aggregatedReceipts = []; const aggregatedTracker = []; for (const share of shares) { const overrideFee = share.type === "platform" ? 100 : 0; try { const attempt = await runZapAttempt({ amount: share.amount, overrideFee, walletSettings }); if (!attempt) { setZapStatus("", "neutral"); return; } if (attempt?.result?.receipts) { aggregatedReceipts.push(...attempt.result.receipts); } else if (Array.isArray(attempt?.shareTracker)) { aggregatedTracker.push(...attempt.shareTracker); } } catch (error) { const tracker = Array.isArray(error?.__zapShareTracker) ? error.__zapShareTracker : []; logZapError( "retry.share", { shareType: share?.type || "unknown", amount: share?.amount, address: share?.address, walletSettings, tracker }, error ); if (tracker.length) { aggregatedTracker.push(...tracker); } const failureShares = tracker.filter( (entry) => entry && entry.status !== "success" && entry.amount > 0 ); markZapRetryPending(failureShares.length ? failureShares : [share]); const message = error?.message || "Retry failed."; setZapStatus(message, "error"); app?.showError?.(message); renderZapReceipts( aggregatedTracker.length ? aggregatedTracker : tracker, { partial: true } ); return; } } renderZapReceipts( aggregatedReceipts.length ? aggregatedReceipts : aggregatedTracker, { partial: false } ); setZapStatus("Retry completed successfully.", "success"); app?.showSuccess?.("Failed shares retried successfully."); resetZapRetryState(); } function handleZapButtonClick(event) { if (event) { event.preventDefault(); } const zapButton = getChannelZapButton(); if (!zapButton) { return; } if (!isZapControlsOpen()) { openZapControls({ focus: true }); return; } if (zapInFlight) { return; } closeZapControls({ focusButton: true }); } async function handleZapSend(event) { if (event?.preventDefault) { event.preventDefault(); } const zapButton = getChannelZapButton(); const amountInput = getZapAmountInput(); const sendButton = getZapSendButton(); const app = getApp(); if (!amountInput || !sendButton) { return; } if (!isZapControlsOpen()) { openZapControls({ focus: true }); return; } if (!currentChannelLightningAddress) { const message = "This creator has not configured a Lightning address yet."; setZapStatus(message, "error"); app?.showError?.(message); return; } if (zapInFlight) { return; } const walletSettings = getWalletSettingsOrPrompt(); if (!walletSettings) { return; } zapInFlight = true; if (zapButton) { zapButton.disabled = true; zapButton.setAttribute("aria-disabled", "true"); } sendButton.disabled = true; sendButton.setAttribute("aria-busy", "true"); sendButton.dataset.state = "loading"; amountInput.disabled = true; let attemptedAmount = null; try { if (pendingZapRetry?.shares?.length) { await executePendingRetry({ walletSettings }); return; } const amount = Math.max(0, Math.round(Number(amountInput.value || 0))); attemptedAmount = amount; if (!amount) { const message = "Enter a zap amount greater than zero."; setZapStatus(message, "error"); app?.showError?.(message); return; } clearZapReceipts(); setZapStatus(`Sending ${amount} sats…`, "warning"); const attempt = await runZapAttempt({ amount, walletSettings }); if (!attempt) { setZapStatus("", "neutral"); return; } const { context, result } = attempt; const receipts = Array.isArray(result?.receipts) ? result.receipts : []; renderZapReceipts(receipts, { partial: false }); const creatorShare = context.shares.creatorShare; const platformShare = context.shares.platformShare; const summary = platformShare ? `Sent ${context.shares.total} sats (creator ${creatorShare}, platform ${platformShare}).` : `Sent ${context.shares.total} sats to the creator.`; setZapStatus(summary, "success"); app?.showSuccess?.("Zap sent successfully!"); resetZapRetryState(); } catch (error) { const tracker = Array.isArray(error?.__zapShareTracker) ? error.__zapShareTracker : []; logZapError( "handleZapSend", { amount: attemptedAmount, walletSettings, tracker, retryAttempt: Array.isArray(pendingZapRetry?.shares) && pendingZapRetry.shares.length ? pendingZapRetry.shares.length : undefined }, error ); if (tracker.length) { renderZapReceipts(tracker, { partial: true }); } const failureShares = tracker.filter( (entry) => entry && entry.status !== "success" && entry.amount > 0 ); if (failureShares.length) { markZapRetryPending(failureShares); const summary = failureShares .map((share) => `${describeShareType(share.type)} ${share.amount} sats`) .join(", "); const tone = tracker.length > failureShares.length ? "warning" : "error"; const statusMessage = tracker.length > failureShares.length ? `Partial zap failure. Press Send again to retry: ${summary}.` : `Zap failed. Press Send again to retry: ${summary}.`; setZapStatus(statusMessage, tone); app?.showError?.(error?.message || statusMessage); } else { resetZapRetryState(); const message = error?.message || "Zap failed. Please try again."; setZapStatus(message, "error"); app?.showError?.(message); } } finally { zapInFlight = false; if (zapButton) { zapButton.disabled = false; zapButton.removeAttribute("aria-disabled"); } sendButton.disabled = false; sendButton.removeAttribute("aria-busy"); delete sendButton.dataset.state; amountInput.disabled = false; } } function buildChannelShareUrl() { if (!currentChannelNpub) { return ""; } const app = getApp(); 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; } const app = getApp(); 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; } const app = getApp(); app?.attachMoreMenuHandlers?.(container); } async function updateChannelMenuState() { const menu = getChannelMenuElement(); if (!menu) { return; } const app = getApp(); 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.removeAttribute("hidden"); button.setAttribute("aria-hidden", "false"); } else { delete button.dataset.npub; button.setAttribute("aria-hidden", "true"); button.setAttribute("hidden", ""); } return; } if (action === "block-author") { if (currentChannelHex) { button.dataset.author = currentChannelHex; button.removeAttribute("hidden"); button.setAttribute("aria-hidden", "false"); } else { delete button.dataset.author; button.setAttribute("aria-hidden", "true"); button.setAttribute("hidden", ""); } return; } if (action === "blacklist-author") { if (canBlacklist && currentChannelHex) { button.dataset.author = currentChannelHex; if (currentChannelNpub) { button.dataset.npub = currentChannelNpub; } else { delete button.dataset.npub; } button.removeAttribute("hidden"); button.setAttribute("aria-hidden", "false"); } else { delete button.dataset.author; delete button.dataset.npub; button.setAttribute("aria-hidden", "true"); button.setAttribute("hidden", ""); } } }); } /** * 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; currentChannelLightningAddress = ""; currentChannelProfileEvent = null; setCachedPlatformLightningAddress(""); resetZapRetryState(); clearZapReceipts(); setZapStatus("", "neutral"); // 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; const app = getApp(); setupChannelShareButton(); setupChannelMoreMenu(); const initialMenuRefresh = updateChannelMenuState().catch((error) => { console.error("Failed to prepare channel menu state:", error); }); let subscriptionsTask = null; // 3) If user is logged in, load subscriptions and show sub/unsub button if (app?.pubkey) { subscriptionsTask = subscriptions .loadSubscriptions(app.pubkey) .catch((error) => { console.error("Failed to load subscriptions for channel view:", error); }) .finally(() => { renderSubscribeButton(hexPub); }); } else { const btn = document.getElementById("subscribeBtnArea"); if (btn) btn.classList.add("hidden"); } setupZapButton(); syncChannelShareButtonState(); // 4) Load user’s profile (banner, avatar, etc.) and channel videos const profilePromise = loadUserProfile(hexPub) .then(() => updateChannelMenuState()) .catch((error) => { console.error("Failed to load channel profile:", error); }) .finally(() => { syncChannelShareButtonState(); }); const videosPromise = loadUserVideos(hexPub).catch((error) => { console.error("Failed to load channel videos:", error); }); const pendingTasks = [profilePromise, videosPromise]; if (initialMenuRefresh) { pendingTasks.push(initialMenuRefresh); } if (subscriptionsTask) { pendingTasks.push(subscriptionsTask); } await Promise.allSettled(pendingTasks); } function setupZapButton() { const zapButton = getChannelZapButton(); const amountInput = getZapAmountInput(); const controls = getZapControlsContainer(); const zapForm = getZapFormElement(); if ( !zapButton || !amountInput || !controls || !zapForm || !getZapSendButton() ) { return; } setChannelZapVisibility(false); controls.dataset.state = "closed"; controls.hidden = true; controls.setAttribute("aria-hidden", "true"); zapButton.setAttribute("aria-expanded", "false"); setupZapWalletLink(); const closeBtn = getZapCloseButton(); if (closeBtn && closeBtn.dataset.initialized !== "true") { closeBtn.addEventListener("click", (event) => { event?.preventDefault?.(); closeZapControls({ focusButton: true }); }); closeBtn.dataset.initialized = "true"; } if (zapButton.dataset.initialized === "true") { updateZapSplitSummary(); return; } const app = getApp(); const activeSettings = typeof app?.getActiveNwcSettings === "function" ? app.getActiveNwcSettings() : {}; if ( Number.isFinite(activeSettings?.defaultZap) && activeSettings.defaultZap > 0 ) { amountInput.value = Math.max(0, Math.round(activeSettings.defaultZap)); } updateZapSplitSummary(); amountInput.addEventListener("input", handleZapAmountChange); amountInput.addEventListener("change", handleZapAmountChange); if (zapForm.dataset.initialized !== "true") { zapForm.addEventListener("submit", handleZapSend); zapForm.dataset.initialized = "true"; } zapButton.addEventListener("click", handleZapButtonClick); zapButton.dataset.initialized = "true"; } /** * Renders a Subscribe / Unsubscribe button with an icon, * using the primary button token styling and the subscribe icon on the left. */ function renderSubscribeButton(channelHex) { const container = document.getElementById("subscribeBtnArea"); if (!container) return; const app = getApp(); container.classList.remove("hidden"); const alreadySubscribed = subscriptions.isSubscribed(channelHex); // Both subscribe/unsubscribe states share the primary styling and 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); } }); } } function trimProfileString(value) { if (typeof value !== "string") { return ""; } return value.trim(); } function normalizeChannelProfileMetadata(raw = {}) { const name = trimProfileString(raw.display_name) || trimProfileString(raw.name) || "Unknown User"; const picture = trimProfileString(raw.picture) || trimProfileString(raw.image) || FALLBACK_CHANNEL_AVATAR; const about = trimProfileString(raw.about || raw.bio); const website = trimProfileString(raw.website || raw.url); const banner = trimProfileString(raw.banner || raw.header || raw.background) || ""; const lud16 = trimProfileString(raw.lud16); const lud06 = trimProfileString(raw.lud06); const lightningAddress = trimProfileString(raw.lightningAddress) || lud16 || lud06 || ""; return { name, picture, about, website, banner, lud16, lud06, lightningAddress }; } function rememberChannelProfile(pubkey, { profile = {}, event = null } = {}) { if (typeof pubkey !== "string" || !pubkey) { return; } channelProfileMetadataCache.set(pubkey, { timestamp: Date.now(), profile: normalizeChannelProfileMetadata(profile), event: event ? { ...event } : null }); } function getCachedChannelProfile(pubkey) { const entry = channelProfileMetadataCache.get(pubkey); if (!entry) { return null; } if ( typeof entry.timestamp !== "number" || Date.now() - entry.timestamp > PROFILE_EVENT_CACHE_TTL_MS ) { channelProfileMetadataCache.delete(pubkey); return null; } return { profile: entry.profile, event: entry.event ? { ...entry.event } : null }; } function isValidRelayUrl(url) { if (typeof url !== "string") { return false; } const trimmed = url.trim(); if (!trimmed) { return false; } if (!/^wss?:\/\//i.test(trimmed)) { return false; } if (/\s/.test(trimmed)) { return false; } try { const parsed = new URL(trimmed); return Boolean(parsed.hostname); } catch (error) { return false; } } function getCachedChannelVideoEvents(pubkey) { if ( !nostrClient?.allEvents || typeof nostrClient.allEvents.forEach !== "function" ) { return []; } const normalized = typeof pubkey === "string" ? pubkey.trim().toLowerCase() : ""; if (!normalized) { return []; } const results = []; nostrClient.allEvents.forEach((event) => { if ( event && event.kind === 30078 && typeof event.pubkey === "string" && event.pubkey.trim().toLowerCase() === normalized ) { results.push(event); } }); return results; } function buildRenderableChannelVideos({ events = [], app } = {}) { if (!Array.isArray(events) || events.length === 0) { return []; } const convertedVideos = events .map((evt) => sharedConvertEventToVideo(evt)) .filter((vid) => vid && !vid.invalid); if (!convertedVideos.length) { return []; } const dedupedById = new Map(); convertedVideos.forEach((video) => { if (video?.id && !dedupedById.has(video.id)) { dedupedById.set(video.id, video); } }); const uniqueVideos = Array.from(dedupedById.values()); const newestByRoot = app?.dedupeVideosByRoot?.(uniqueVideos) ?? dedupeToNewestByRoot(uniqueVideos); let videos = newestByRoot.filter((video) => !video.deleted); videos = videos.filter((video) => { if (!video || !video.id) { return false; } if (app?.blacklistedEventIds?.has?.(video.id)) { return false; } if (!accessControl.canAccess(video)) { return false; } return true; }); videos.sort((a, b) => b.created_at - a.created_at); return videos; } function renderChannelVideosFromList({ videos = [], container, app, loadToken, allowEmptyMessage = true } = {}) { if (!container || loadToken !== currentVideoLoadToken) { return false; } if (!Array.isArray(videos) || videos.length === 0) { if (allowEmptyMessage) { container.dataset.hasChannelVideos = "false"; container.innerHTML = `

No videos to display.

`; return true; } return false; } container.dataset.hasChannelVideos = "true"; container.innerHTML = ""; const fragment = document.createDocumentFragment(); const allKnownEventsArray = Array.from(nostrClient.allEvents.values()); const loadedThumbnails = app?.loadedThumbnails instanceof Map ? app.loadedThumbnails : null; const unsupportedBtihMessage = typeof app?.getUnsupportedBtihMessage === "function" ? app.getUnsupportedBtihMessage() : "This magnet link is missing a compatible BitTorrent v1 info hash."; const fallbackNormalizePubkey = (value) => typeof value === "string" && value ? value.trim().toLowerCase() : ""; const normalizePubkey = typeof app?.normalizeHexPubkey === "function" ? (value) => app.normalizeHexPubkey(value) || fallbackNormalizePubkey(value) : fallbackNormalizePubkey; const normalizedViewerPubkey = normalizePubkey(app?.pubkey); let renderIndex = 0; const extractPlaybackDetail = (trigger, video) => { if ( app?.videoListView && typeof app.videoListView.extractPlaybackDetail === "function" ) { return app.videoListView.extractPlaybackDetail(trigger, video); } const element = trigger instanceof HTMLElement ? trigger : null; const target = element?.closest("[data-play-url],[data-play-magnet]") || element; const rawUrlValue = (target?.dataset && typeof target.dataset.playUrl === "string" ? target.dataset.playUrl : null) ?? target?.getAttribute?.("data-play-url") ?? ""; const rawMagnetValue = (target?.dataset && typeof target.dataset.playMagnet === "string" ? target.dataset.playMagnet : null) ?? target?.getAttribute?.("data-play-magnet") ?? ""; let url = ""; if (rawUrlValue) { try { url = decodeURIComponent(rawUrlValue); } catch (error) { url = rawUrlValue; } } const magnet = typeof rawMagnetValue === "string" ? rawMagnetValue : ""; const videoId = target?.dataset?.videoId || target?.getAttribute?.("data-video-id") || video?.id || ""; return { videoId, url, magnet, video }; }; const playbackHandler = typeof app?.videoListViewPlaybackHandler === "function" ? app.videoListViewPlaybackHandler.bind(app) : null; const startPlayback = (detail) => { if (playbackHandler) { playbackHandler(detail); return; } if (detail.videoId && typeof app?.playVideoByEventId === "function") { Promise.resolve( app.playVideoByEventId(detail.videoId, { url: detail.url, magnet: detail.magnet, title: detail.video?.title, description: detail.video?.description }) ).catch((error) => { console.error("Failed to play channel video via event id:", error); if (typeof app?.playVideoWithFallback === "function") { app.playVideoWithFallback({ url: detail.url, magnet: detail.magnet }); } }); return; } if (typeof app?.playVideoWithFallback === "function") { Promise.resolve( app.playVideoWithFallback({ url: detail.url, magnet: detail.magnet }) ).catch((error) => { console.error( "Failed to start fallback playback for channel video:", error ); }); } }; const allowNsfw = ALLOW_NSFW_CONTENT === true; videos.forEach((video) => { if (!video || !video.id || !video.title) { return; } const normalizedVideoPubkey = normalizePubkey(video.pubkey); const canEdit = Boolean(normalizedVideoPubkey) && Boolean(normalizedViewerPubkey) && normalizedVideoPubkey === normalizedViewerPubkey; if (video.isPrivate && !canEdit) { return; } if (!allowNsfw && video?.isNsfw === true && !canEdit) { return; } app?.videosMap?.set(video.id, video); const index = renderIndex++; let hasOlder = false; if (canEdit && video.videoRootId) { hasOlder = app?.hasOlderVersion?.(video, allKnownEventsArray) || false; } const pointerInfo = typeof app?.deriveVideoPointerInfo === "function" ? app.deriveVideoPointerInfo(video) : null; if ( pointerInfo && typeof app?.persistWatchHistoryMetadataForVideo === "function" ) { app.persistWatchHistoryMetadataForVideo(video, pointerInfo); } const shareUrl = typeof app?.buildShareUrlFromEventId === "function" ? app.buildShareUrlFromEventId(video.id) : "#"; const timeAgo = typeof app?.formatTimeAgo === "function" ? app.formatTimeAgo(video.created_at) : new Date(video.created_at * 1000).toLocaleString(); let cardState = ""; if (canEdit && video.isPrivate) { cardState = "private"; } if (!allowNsfw && video?.isNsfw === true && canEdit) { cardState = "critical"; } const videoCard = new VideoCard({ document, video, index, shareUrl, pointerInfo, timeAgo, cardState, capabilities: { canEdit, canDelete: canEdit, canRevert: hasOlder, canManageBlacklist: typeof app?.canCurrentUserManageBlacklist === "function" ? app.canCurrentUserManageBlacklist() : false }, nsfwContext: { isNsfw: video?.isNsfw === true, allowNsfw, viewerIsOwner: canEdit }, helpers: { escapeHtml: (value) => escapeHTML(value), isMagnetSupported: (magnet) => app?.isMagnetUriSupported?.(magnet), toLocaleString: (value) => typeof value === "number" ? value.toLocaleString() : value }, assets: { fallbackThumbnailSrc: "assets/jpg/video-thumbnail-fallback.jpg", unsupportedBtihMessage }, state: { loadedThumbnails }, ensureGlobalMoreMenuHandlers: () => app?.ensureGlobalMoreMenuHandlers?.(), onRequestCloseAllMenus: () => app?.closeAllMoreMenus?.(), formatters: { formatTimeAgo: (ts) => typeof app?.formatTimeAgo === "function" ? app.formatTimeAgo(ts) : new Date(ts * 1000).toLocaleString() } }); videoCard.onPlay = ({ event: domEvent, video: cardVideo }) => { const trigger = domEvent?.currentTarget || domEvent?.target; const detail = extractPlaybackDetail(trigger, cardVideo || video); detail.video = detail.video || video; startPlayback(detail); }; videoCard.onEdit = ({ video: editVideo, index: editIndex }) => { app?.handleEditVideo?.({ eventId: editVideo?.id || "", index: Number.isFinite(editIndex) ? editIndex : null }); }; videoCard.onRevert = ({ video: revertVideo, index: revertIndex }) => { app?.handleRevertVideo?.({ eventId: revertVideo?.id || "", index: Number.isFinite(revertIndex) ? revertIndex : null }); }; videoCard.onDelete = ({ video: deleteVideo, index: deleteIndex }) => { app?.handleFullDeleteVideo?.({ eventId: deleteVideo?.id || "", index: Number.isFinite(deleteIndex) ? deleteIndex : null }); }; videoCard.onMoreAction = ({ dataset = {} }) => { const action = dataset.action || ""; const detail = { ...dataset, eventId: dataset.eventId || video.id || "", context: dataset.context || "channel-grid" }; app?.handleMoreMenuAction?.(action || "copy-link", detail); }; videoCard.onAuthorNavigate = ({ pubkey }) => { const targetPubkey = pubkey || video.pubkey || ""; if (targetPubkey && typeof app?.goToProfile === "function") { app.goToProfile(targetPubkey); } }; const cardEl = videoCard.getRoot(); if (cardEl) { fragment.appendChild(cardEl); } }); if (renderIndex === 0) { if (allowEmptyMessage) { container.dataset.hasChannelVideos = "false"; container.innerHTML = `

No videos to display.

`; return true; } return false; } container.appendChild(fragment); attachHealthBadges(container); attachUrlHealthBadges(container, ({ badgeEl, url, eventId }) => { const video = app?.videosMap?.get(eventId) || { id: eventId }; app?.handleUrlHealthBadge?.({ video, url, badgeEl }); }); app?.mountVideoListView?.(); const lazyEls = container.querySelectorAll("[data-lazy]"); lazyEls.forEach((el) => app?.mediaLoader?.observe?.(el)); app?.attachMoreMenuHandlers?.(container); return true; } function applyChannelProfileMetadata({ profile = {}, event = null, pubkey = "", npub = "", loadToken = null } = {}) { if (loadToken !== null && loadToken !== currentProfileLoadToken) { return; } const normalized = normalizeChannelProfileMetadata(profile); const bannerEl = document.getElementById("channelBanner"); if (bannerEl) { bannerEl.src = normalized.banner || FALLBACK_CHANNEL_BANNER; } const avatarEl = document.getElementById("channelAvatar"); if (avatarEl) { avatarEl.src = normalized.picture || FALLBACK_CHANNEL_AVATAR; } const nameEl = document.getElementById("channelName"); if (nameEl) { nameEl.textContent = normalized.name || "Unknown User"; } const channelNpubEl = document.getElementById("channelNpub"); if (channelNpubEl) { if (npub) { channelNpubEl.textContent = npub; } else if (pubkey) { try { channelNpubEl.textContent = window.NostrTools.nip19.npubEncode(pubkey); } catch (error) { channelNpubEl.textContent = ""; } } } const aboutEl = document.getElementById("channelAbout"); if (aboutEl) { aboutEl.textContent = normalized.about || ""; } const websiteEl = document.getElementById("channelWebsite"); if (websiteEl) { if (normalized.website) { websiteEl.href = normalized.website; websiteEl.textContent = normalized.website; } else { websiteEl.textContent = ""; websiteEl.removeAttribute("href"); } } const previousLightning = currentChannelLightningAddress; const lightningAddress = normalized.lightningAddress || ""; currentChannelLightningAddress = lightningAddress; const lnEl = document.getElementById("channelLightning"); if (lnEl) { lnEl.textContent = lightningAddress || "No lightning address found."; } if (event) { currentChannelProfileEvent = { ...event }; } else if (loadToken === currentProfileLoadToken && !event) { currentChannelProfileEvent = null; } if (lightningAddress) { if (lightningAddress !== previousLightning) { fetchLightningMetadata(lightningAddress) .then(() => updateZapSplitSummary()) .catch(() => {}); } } else { updateZapSplitSummary(); } setChannelZapVisibility(Boolean(lightningAddress)); } async function fetchChannelProfileFromRelays(pubkey) { if (!nostrClient?.pool || !Array.isArray(nostrClient?.relays)) { return { event: null, profile: {} }; } try { const events = await nostrClient.pool.list(nostrClient.relays, [ { kinds: [0], authors: [pubkey], limit: 1 } ]); let newestEvent = null; for (const event of Array.isArray(events) ? events : []) { if (!event || !event.content) { continue; } if (!newestEvent || event.created_at > newestEvent.created_at) { newestEvent = event; } } if (newestEvent?.content) { try { const meta = JSON.parse(newestEvent.content); return { event: newestEvent, profile: meta }; } catch (error) { console.warn("Failed to parse channel metadata payload:", error); return { event: newestEvent, profile: {} }; } } return { event: newestEvent, profile: {} }; } catch (error) { throw error; } } /** * Fetches and displays the user's metadata (kind=0). */ async function loadUserProfile(pubkey) { const app = getApp(); const loadToken = ++currentProfileLoadToken; setChannelZapVisibility(false); const cachedEntry = getCachedChannelProfile(pubkey); if (cachedEntry) { applyChannelProfileMetadata({ profile: cachedEntry.profile, event: cachedEntry.event, pubkey, npub: currentChannelNpub, loadToken }); } else { const stateEntry = typeof app?.getProfileCacheEntry === "function" ? app.getProfileCacheEntry(pubkey) : null; if (stateEntry?.profile) { applyChannelProfileMetadata({ profile: stateEntry.profile, event: null, pubkey, npub: currentChannelNpub, loadToken }); } else { applyChannelProfileMetadata({ profile: {}, event: null, pubkey, npub: currentChannelNpub, loadToken }); } } try { const result = await fetchChannelProfileFromRelays(pubkey); if (loadToken !== currentProfileLoadToken) { return; } rememberChannelProfile(pubkey, result); if (typeof app?.setProfileCacheEntry === "function") { try { app.setProfileCacheEntry(pubkey, { ...result.profile, lightningAddress: result.profile?.lightningAddress || result.profile?.lud16 || result.profile?.lud06 || "" }); } catch (error) { console.warn( "Failed to persist channel profile metadata to cache:", error ); } } applyChannelProfileMetadata({ profile: result.profile, event: result.event, pubkey, npub: currentChannelNpub, loadToken }); } catch (error) { if (loadToken === currentProfileLoadToken) { console.error("Failed to fetch user profile data:", error); const lnEl = document.getElementById("channelLightning"); if (lnEl && !currentChannelLightningAddress) { lnEl.textContent = "No lightning address found."; } if (!currentChannelLightningAddress) { updateZapSplitSummary(); setChannelZapVisibility(false); } } } } /** * Fetches and displays this user's videos (kind=30078). * Filters out older overshadowed notes, blacklisted, etc. */ async function loadUserVideos(pubkey) { const app = getApp(); const container = document.getElementById("channelVideoList"); const loadToken = ++currentVideoLoadToken; const hadExistingContent = !!container && container.querySelector("[data-video-id]"); if (container) { container.dataset.loading = "true"; if (!hadExistingContent) { container.innerHTML = `
Loading videos…
`; } } let renderedFromCache = false; if (container) { const cachedEvents = getCachedChannelVideoEvents(pubkey); if (cachedEvents.length) { const cachedVideos = buildRenderableChannelVideos({ events: cachedEvents, app }); if (cachedVideos.length) { renderedFromCache = renderChannelVideosFromList({ videos: cachedVideos, container, app, loadToken, allowEmptyMessage: false }) || renderedFromCache; } } } const ensureAccessPromise = accessControl.ensureReady().catch((error) => { console.warn( "Failed to ensure admin lists were loaded before channel fetch:", error ); }); 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 = []; const knownRelays = Array.isArray(nostrClient.relays) ? nostrClient.relays : Array.from(nostrClient.relays || []); const relayList = knownRelays.filter((url) => isValidRelayUrl(url)); if (relayList.length === 0) { try { const fallbackEvents = await nostrClient.pool.list( Array.from(DEFAULT_RELAY_URLS), [filter] ); if (Array.isArray(fallbackEvents)) { events.push(...fallbackEvents); } } catch (error) { console.error("Relay error (default pool):", error); } } else { const relayPromises = relayList.map((url) => nostrClient.pool.list([url], [filter]) ); const settled = await Promise.allSettled(relayPromises); settled.forEach((result, index) => { const relayUrl = relayList[index]; if (result.status === "fulfilled") { const relayEvents = Array.isArray(result.value) ? result.value : []; events.push(...relayEvents); return; } if (result.reason) { console.error(`Relay error (${relayUrl}):`, result.reason); } }); } await ensureAccessPromise; if (loadToken !== currentVideoLoadToken) { return; } if (!container) { console.warn("channelVideoList element not found in DOM."); return; } const videos = buildRenderableChannelVideos({ events, app }); const rendered = renderChannelVideosFromList({ videos, container, app, loadToken, allowEmptyMessage: true }); renderedFromCache = rendered || renderedFromCache; } catch (err) { if ( loadToken === currentVideoLoadToken && container && !renderedFromCache ) { container.dataset.hasChannelVideos = "false"; container.innerHTML = `

Failed to load videos. Please try again.

`; } console.error("Error loading user videos:", err); } finally { await ensureAccessPromise; if (loadToken === currentVideoLoadToken && container) { delete container.dataset.loading; } } } 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 ); }); }); /** * 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()); }