// js/channelProfile.js import { nostrClient } from "./nostrClientFacade.js"; import { convertEventToVideo as sharedConvertEventToVideo } from "./nostr/index.js"; import { DEFAULT_RELAY_URLS } from "./nostr/toolkit.js"; import { subscriptions } from "./subscriptions.js"; 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 { formatShortNpub } from "./utils/formatters.js"; import createPopover from "./ui/overlay/popoverEngine.js"; import { VideoCard } from "./ui/components/VideoCard.js"; import { createChannelProfileMenuPanel } from "./ui/components/videoMenuRenderers.js"; import { ALLOW_NSFW_CONTENT } from "./config.js"; import { sanitizeProfileMediaUrl } from "./utils/profileMedia.js"; import moderationService from "./services/moderationService.js"; import { createModerationStage } from "./feedEngine/stages.js"; import { DEFAULT_AUTOPLAY_BLOCK_THRESHOLD, DEFAULT_BLUR_THRESHOLD, DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD, DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD, } from "./constants.js"; import { applyModerationContextDatasets, getModerationOverrideActionLabels, normalizeVideoModerationContext, } from "./ui/moderationUiHelpers.js"; import { buildModerationBadgeText } from "./ui/moderationCopy.js"; import { calculateZapShares, describeShareType, fetchLightningMetadata, formatMinRequirement, getCachedLightningEntry, getCachedMetadataByUrl, getCachedPlatformLightningAddress, isZapAllowanceExhaustedError, isMetadataEntryFresh, normalizeLightningAddressKey, rememberLightningMetadata, setCachedPlatformLightningAddress, buildZapAllowanceExhaustedMessage, validateInvoiceAmount } from "./payments/zapSharedState.js"; import { splitAndZap } from "./payments/zapSplit.js"; import { showLoginRequiredToZapNotification } from "./payments/zapNotifications.js"; import { resolveLightningAddress, fetchPayServiceData, requestInvoice } from "./payments/lnurl.js"; import { getPlatformLightningAddress } from "./payments/platformAddress.js"; import { devLogger, userLogger } from "./utils/logger.js"; import { ensureWallet, sendPayment as sendWalletPayment, resetWalletClient } from "./payments/nwcClient.js"; import { prepareStaticModal, openStaticModal } from "./ui/components/staticModalAccessibility.js"; import { setModalState as setGlobalModalState } from "./state/appState.js"; import { attachFeedInfoPopover } from "./ui/components/FeedInfoPopover.js"; const getApp = () => getApplication(); const SVG_NAMESPACE = "http://www.w3.org/2000/svg"; const CHANNEL_MODERATION_BADGE_ID = "channel-moderation-badge"; let currentChannelModerationSourceVideo = null; const channelModerationBadgeState = { container: null, badgeEl: null, labelEl: null, textEl: null, iconSvg: null, iconWrapper: null, overrideButton: null, hideButton: null, blockButton: null, boundOverride: null, boundHide: null, boundBlock: null, badgeId: "", app: null, }; const channelVideoCardsById = new Map(); let channelBannerStyleCounter = 0; const channelBannerStyleRegistry = new WeakMap(); function getChannelBannerStyleState(doc) { if (!doc || typeof doc.createElement !== "function") { return null; } const existing = channelBannerStyleRegistry.get(doc); if (existing) { return existing; } let styleNode = doc.getElementById?.("channelBannerStyles") || null; if (!(styleNode instanceof HTMLStyleElement)) { styleNode = doc.createElement("style"); styleNode.id = "channelBannerStyles"; doc.head?.appendChild(styleNode); } const state = { styleNode, rules: new Map(), }; channelBannerStyleRegistry.set(doc, state); return state; } function updateChannelBannerBackground(el, url) { const doc = el?.ownerDocument || (typeof document !== "undefined" ? document : null); const state = getChannelBannerStyleState(doc); if (!state || !el) { return; } if (!el.dataset.channelBannerStyleId) { channelBannerStyleCounter += 1; el.dataset.channelBannerStyleId = `channel-banner-${channelBannerStyleCounter}`; } const selector = `[data-channel-banner-style-id="${el.dataset.channelBannerStyleId}"]`; if (url) { const escaped = url.replace(/(["\\])/g, "\\$1"); state.rules.set(selector, `${selector} { background-image: url("${escaped}"); }`); } else { state.rules.set(selector, `${selector} { background-image: none; }`); } state.styleNode.textContent = [...state.rules.values()].join("\n"); } export function clearChannelVideoCardRegistry() { channelVideoCardsById.clear(); } let currentChannelHex = null; let currentChannelNpub = null; let currentChannelLightningAddress = ""; let channelZapPendingOpen = false; function normalizeHex(value) { if (typeof value !== "string") { return ""; } const trimmed = value.trim().toLowerCase(); return /^[0-9a-f]{64}$/.test(trimmed) ? trimmed : ""; } function decodeNpubToHex(npub) { if (typeof npub !== "string" || !npub.trim()) { return ""; } try { const decoded = window?.NostrTools?.nip19?.decode?.(npub); if (decoded?.type !== "npub" || !decoded.data) { return ""; } if (typeof decoded.data === "string") { return normalizeHex(decoded.data); } let bufferSource = null; if (decoded.data instanceof Uint8Array) { bufferSource = decoded.data; } else if (Array.isArray(decoded.data)) { bufferSource = Uint8Array.from(decoded.data); } else if ( decoded.data?.type === "Buffer" && Array.isArray(decoded.data?.data) ) { bufferSource = Uint8Array.from(decoded.data.data); } if (!bufferSource) { return ""; } if (typeof window?.NostrTools?.utils?.bytesToHex === "function") { return normalizeHex(window.NostrTools.utils.bytesToHex(bufferSource)); } return normalizeHex( Array.from(bufferSource) .map((byte) => byte.toString(16).padStart(2, "0")) .join("") ); } catch (error) { devLogger.warn("[ChannelProfile] Failed to decode npub to hex", error); return ""; } } function decorateChannelVideo(video, app = getApp()) { if (!video || typeof video !== "object") { return null; } let decoratedVideo = video; if (typeof app?.decorateVideoModeration === "function") { try { const decorated = app.decorateVideoModeration(video); if (decorated && typeof decorated === "object") { decoratedVideo = decorated; } } catch (error) { devLogger.warn( "[ChannelProfile] Failed to decorate channel video moderation", error, ); } } if (typeof app?.decorateVideoCreatorIdentity === "function") { try { const identityDecorated = app.decorateVideoCreatorIdentity(decoratedVideo); if (identityDecorated && typeof identityDecorated === "object") { decoratedVideo = identityDecorated; } } catch (error) { devLogger.warn( "[ChannelProfile] Failed to decorate channel video identity", error, ); } } return decoratedVideo; } function collectChannelVideos(pubkey, app = getApp()) { if (!app || !(app.videosMap instanceof Map)) { return []; } const normalized = normalizeHex(pubkey); if (!normalized) { return []; } const matches = []; app.videosMap.forEach((video) => { if (!video || typeof video !== "object") { return; } const videoPubkey = normalizeHex(video.pubkey); if (videoPubkey === normalized) { matches.push(video); } }); return matches; } async function applyChannelModerationToVideos(videos = [], app = getApp()) { if (!Array.isArray(videos) || videos.length === 0) { return Array.isArray(videos) ? videos : []; } const resolvedApp = app || getApp(); const resolveThreshold = (value, fallback) => { if (value === Number.POSITIVE_INFINITY) { return Number.POSITIVE_INFINITY; } if (Number.isFinite(value)) { return Math.max(0, Math.floor(value)); } return fallback; }; const thresholds = typeof resolvedApp?.getActiveModerationThresholds === "function" ? resolvedApp.getActiveModerationThresholds() : null; const stage = createModerationStage({ getService: () => { try { if ( resolvedApp?.nostrService && typeof resolvedApp.nostrService.getModerationService === "function" ) { return resolvedApp.nostrService.getModerationService(); } } catch (error) { devLogger.warn( "[ChannelProfile] Failed to resolve channel moderation service", error, ); } return null; }, autoplayThreshold: resolveThreshold( thresholds?.autoplayBlockThreshold, DEFAULT_AUTOPLAY_BLOCK_THRESHOLD, ), blurThreshold: resolveThreshold( thresholds?.blurThreshold, DEFAULT_BLUR_THRESHOLD, ), trustedMuteHideThreshold: resolveThreshold( thresholds?.trustedMuteHideThreshold, DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD, ), trustedReportHideThreshold: resolveThreshold( thresholds?.trustedSpamHideThreshold, DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD, ), }); let stageResult; try { const items = videos.map((video) => ({ video, metadata: {} })); stageResult = await stage(items, { feedName: "channel-profile" }); } catch (error) { devLogger.warn( "[ChannelProfile] Failed to apply moderation stage to channel videos", error, ); return videos; } if (!Array.isArray(stageResult)) { return videos; } const decoratedVideos = []; for (const item of stageResult) { if (!item || typeof item !== "object") { continue; } const video = item.video; if (!video || typeof video !== "object") { continue; } const metadataModeration = item.metadata && typeof item.metadata === "object" && item.metadata.moderation && typeof item.metadata.moderation === "object" ? item.metadata.moderation : null; if (metadataModeration) { if (!video.moderation || typeof video.moderation !== "object") { video.moderation = {}; } Object.assign(video.moderation, metadataModeration); } decoratedVideos.push(decorateChannelVideo(video, resolvedApp) || video); } return decoratedVideos; } function buildChannelModerationFallback(pubkey) { const normalized = normalizeHex(pubkey); if (!normalized) { return null; } let viewerMuted = false; try { viewerMuted = moderationService.isAuthorMutedByViewer(normalized) === true; } catch (error) { devLogger.warn( "[ChannelProfile] Failed to resolve viewer mute state for channel", error, ); } let trustedMuted = false; let trustedMuters = []; try { if (typeof moderationService.isAuthorMutedByTrusted === "function") { trustedMuted = moderationService.isAuthorMutedByTrusted(normalized) === true; } if (trustedMuted) { if (typeof moderationService.getTrustedMutersForAuthor === "function") { const muters = moderationService.getTrustedMutersForAuthor(normalized) || []; if (Array.isArray(muters)) { trustedMuters = muters; } } } } catch (error) { devLogger.warn( "[ChannelProfile] Failed to resolve trusted mute state for channel", error, ); trustedMuted = false; trustedMuters = []; } if (!viewerMuted && !trustedMuted) { return null; } const trustedMuteCount = Array.isArray(trustedMuters) ? trustedMuters.length : 0; const fallback = { trustedMuted, trustedMuters: Array.isArray(trustedMuters) ? trustedMuters.slice() : [], trustedMuteCount, }; if (viewerMuted) { fallback.blurThumbnail = true; fallback.blurReason = "viewer-mute"; fallback.hidden = true; fallback.hideReason = "viewer-mute"; fallback.hideCounts = { trustedMuteCount, trustedReportCount: 0, }; } else if (trustedMuted) { fallback.blurThumbnail = true; fallback.blurReason = "trusted-mute"; } fallback.original = { blockAutoplay: fallback.blockAutoplay === true, blurThumbnail: fallback.blurThumbnail === true, hidden: fallback.hidden === true, hideReason: fallback.hideReason || "", hideCounts: fallback.hideCounts ? { ...fallback.hideCounts } : undefined, hideBypass: fallback.hideBypass || "", hideTriggered: fallback.hidden === true || Boolean(fallback.hideReason) || false, blurReason: fallback.blurReason || "", }; return fallback; } function getChannelModerationState(context) { if (!context) { return ""; } if (context.overrideActive) { return "override"; } if (context.activeHidden && !context.overrideActive) { return "hidden"; } if (context.trustedMuted) { return "trusted-mute"; } return "blocked"; } function shouldShowChannelBlockButton(context) { if (!context || !context.trustedMuted) { return false; } if (context.activeHidden && !context.overrideActive) { return false; } return true; } function getChannelModerationBadgeIconShape(state) { if (state === "override") { return { d: "M10 18a8 8 0 100-16 8 8 0 000 16zm3.78-9.72a.75.75 0 00-1.06-1.06L9 11.94l-1.72-1.72a.75.75 0 10-1.06 1.06l2.25 2.25a.75.75 0 001.06 0l3.25-3.25z", fillRule: "evenodd", clipRule: "evenodd", }; } return { d: "M10 18a8 8 0 100-16 8 8 0 000 16zm-.75-11.75a.75.75 0 011.5 0v4.5a.75.75 0 01-1.5 0v-4.5zm.75 8.5a1 1 0 100-2 1 1 0 000 2z", fillRule: "evenodd", clipRule: "evenodd", }; } function updateChannelModerationBadgeIcon(svg, state) { if (!svg) { return; } svg.dataset.iconState = state; const path = svg.firstElementChild; if (!path) { return; } const { d, fillRule, clipRule } = getChannelModerationBadgeIconShape(state); path.setAttribute("d", d); if (fillRule) { path.setAttribute("fill-rule", fillRule); } else { path.removeAttribute("fill-rule"); } if (clipRule) { path.setAttribute("clip-rule", clipRule); } else { path.removeAttribute("clip-rule"); } } function createChannelModerationBadgeIcon(doc, state) { const wrapper = doc.createElement("span"); wrapper.className = "moderation-badge__icon"; wrapper.setAttribute("aria-hidden", "true"); const svg = doc.createElementNS(SVG_NAMESPACE, "svg"); svg.setAttribute("viewBox", "0 0 20 20"); svg.setAttribute("focusable", "false"); svg.setAttribute("aria-hidden", "true"); svg.classList.add("moderation-badge__icon-mark"); const path = doc.createElementNS(SVG_NAMESPACE, "path"); path.setAttribute("fill", "currentColor"); svg.appendChild(path); wrapper.appendChild(svg); updateChannelModerationBadgeIcon(svg, state); return { wrapper, svg }; } function getChannelModerationBadgeContainer(doc = typeof document !== "undefined" ? document : null) { if (!doc || typeof doc.getElementById !== "function") { return null; } channelModerationBadgeState.container = doc.getElementById("channelModerationBadge") || null; return channelModerationBadgeState.container; } function ensureChannelModerationBadgeResources(doc) { const container = getChannelModerationBadgeContainer(doc); if (!container) { return null; } if (!channelModerationBadgeState.badgeEl) { const badge = doc.createElement("div"); badge.className = "moderation-badge"; badge.dataset.moderationBadge = "true"; const label = doc.createElement("span"); label.className = "moderation-badge__label inline-flex items-center gap-xs"; const { wrapper, svg } = createChannelModerationBadgeIcon(doc, "blocked"); label.appendChild(wrapper); const text = doc.createElement("span"); text.className = "moderation-badge__text"; label.appendChild(text); badge.appendChild(label); channelModerationBadgeState.badgeEl = badge; channelModerationBadgeState.labelEl = label; channelModerationBadgeState.textEl = text; channelModerationBadgeState.iconWrapper = wrapper; channelModerationBadgeState.iconSvg = svg; } if (typeof channelModerationBadgeState.boundOverride !== "function") { channelModerationBadgeState.boundOverride = (event) => handleChannelModerationOverride(event); } if (typeof channelModerationBadgeState.boundBlock !== "function") { channelModerationBadgeState.boundBlock = (event) => handleChannelModerationBlock(event); } if (!channelModerationBadgeState.overrideButton) { const { text: overrideLabel, ariaLabel: overrideAria } = getModerationOverrideActionLabels({ overrideActive: false }); const button = doc.createElement("button"); button.type = "button"; button.className = "moderation-badge__action flex-shrink-0"; button.dataset.moderationAction = "override"; button.textContent = overrideLabel; button.setAttribute("aria-label", overrideAria); button.addEventListener("click", channelModerationBadgeState.boundOverride); channelModerationBadgeState.overrideButton = button; } if (typeof channelModerationBadgeState.boundHide !== "function") { channelModerationBadgeState.boundHide = (event) => handleChannelModerationHide(event); } if (!channelModerationBadgeState.hideButton) { const { text: restoreLabel, ariaLabel: restoreAria } = getModerationOverrideActionLabels({ overrideActive: true }); const hideButton = doc.createElement("button"); hideButton.type = "button"; hideButton.className = "moderation-badge__action flex-shrink-0"; hideButton.dataset.moderationAction = "hide"; hideButton.textContent = restoreLabel; hideButton.setAttribute("aria-label", restoreAria); hideButton.addEventListener("click", channelModerationBadgeState.boundHide); channelModerationBadgeState.hideButton = hideButton; } if (!channelModerationBadgeState.blockButton) { const blockButton = doc.createElement("button"); blockButton.type = "button"; blockButton.className = "moderation-badge__action flex-shrink-0"; blockButton.dataset.moderationAction = "block"; blockButton.textContent = "Block"; blockButton.addEventListener("click", channelModerationBadgeState.boundBlock); channelModerationBadgeState.blockButton = blockButton; } return channelModerationBadgeState; } function teardownChannelModerationBadge() { const ref = channelModerationBadgeState; currentChannelModerationSourceVideo = null; if (ref.badgeEl && ref.badgeEl.parentNode) { ref.badgeEl.parentNode.removeChild(ref.badgeEl); } ref.labelEl = null; if (ref.container) { ref.container.removeAttribute("data-moderation-active"); } ref.app = null; } function renderChannelModerationBadge({ context = null, video = null, app = null, doc = typeof document !== "undefined" ? document : null, } = {}) { const container = getChannelModerationBadgeContainer(doc); if (!container) { teardownChannelModerationBadge(); return; } const normalizedContext = context || null; const shouldShow = Boolean(normalizedContext?.shouldShow); currentChannelModerationSourceVideo = video && typeof video === "object" && typeof video.id === "string" ? video : null; if (!shouldShow) { teardownChannelModerationBadge(); return; } const ref = ensureChannelModerationBadgeResources(doc); if (!ref || !ref.badgeEl || !ref.textEl) { teardownChannelModerationBadge(); return; } const badge = ref.badgeEl; const state = normalizedContext.overrideActive ? "override" : getChannelModerationState(normalizedContext); badge.dataset.variant = normalizedContext.overrideActive ? "neutral" : "warning"; badge.dataset.moderationState = state; if (normalizedContext.effectiveHideReason) { badge.dataset.moderationHideReason = normalizedContext.effectiveHideReason; } else if (badge.dataset.moderationHideReason) { delete badge.dataset.moderationHideReason; } updateChannelModerationBadgeIcon(ref.iconSvg, state); const textContent = buildModerationBadgeText(normalizedContext, { variant: "card" }) || "Moderated due to reports"; ref.textEl.textContent = textContent; if (CHANNEL_MODERATION_BADGE_ID) { badge.id = CHANNEL_MODERATION_BADGE_ID; } if (badge.parentNode !== container) { container.appendChild(badge); } container.dataset.moderationActive = "true"; const hasVideoTarget = Boolean(currentChannelModerationSourceVideo?.id); if ( hasVideoTarget && !normalizedContext.overrideActive && normalizedContext.allowOverride && ref.overrideButton ) { const { text: overrideLabel, ariaLabel: overrideAria } = getModerationOverrideActionLabels({ overrideActive: false }); ref.overrideButton.textContent = overrideLabel; ref.overrideButton.setAttribute("aria-label", overrideAria); if (ref.overrideButton.parentNode !== badge) { badge.appendChild(ref.overrideButton); } ref.overrideButton.disabled = false; ref.overrideButton.removeAttribute("aria-busy"); } else if (ref.overrideButton && ref.overrideButton.parentNode === badge) { badge.removeChild(ref.overrideButton); } if (hasVideoTarget && normalizedContext.overrideActive && ref.hideButton) { const { text: restoreLabel, ariaLabel: restoreAria } = getModerationOverrideActionLabels({ overrideActive: true }); ref.hideButton.textContent = restoreLabel; ref.hideButton.setAttribute("aria-label", restoreAria); if (ref.hideButton.parentNode !== badge) { badge.appendChild(ref.hideButton); } ref.hideButton.disabled = false; ref.hideButton.removeAttribute("aria-busy"); } else if (ref.hideButton && ref.hideButton.parentNode === badge) { badge.removeChild(ref.hideButton); } if (hasVideoTarget && shouldShowChannelBlockButton(normalizedContext) && ref.blockButton) { if (ref.blockButton.parentNode !== badge) { badge.appendChild(ref.blockButton); } ref.blockButton.disabled = false; ref.blockButton.removeAttribute("aria-busy"); } else if (ref.blockButton && ref.blockButton.parentNode === badge) { badge.removeChild(ref.blockButton); } if (ref.overrideButton) { if (badge.id) { ref.overrideButton.setAttribute("aria-describedby", badge.id); } else { ref.overrideButton.removeAttribute("aria-describedby"); } } if (ref.hideButton) { if (badge.id) { ref.hideButton.setAttribute("aria-describedby", badge.id); } else { ref.hideButton.removeAttribute("aria-describedby"); } } if (ref.blockButton) { if (badge.id) { ref.blockButton.setAttribute("aria-describedby", badge.id); } else { ref.blockButton.removeAttribute("aria-describedby"); } } ref.badgeId = badge.id || ""; ref.app = app || getApp(); } function handleChannelModerationOverride(event) { if (event) { if (typeof event.preventDefault === "function") { event.preventDefault(); } if (typeof event.stopPropagation === "function") { event.stopPropagation(); } } const sourceVideo = currentChannelModerationSourceVideo; const app = channelModerationBadgeState.app || getApp(); const targetVideo = app?.videosMap instanceof Map && sourceVideo?.id ? app.videosMap.get(sourceVideo.id) || sourceVideo : sourceVideo; if (!targetVideo || typeof targetVideo !== "object" || !targetVideo.id) { return; } const button = channelModerationBadgeState.overrideButton; if (button) { button.disabled = true; button.setAttribute("aria-busy", "true"); } let handled = false; try { handled = app?.handleModerationOverride?.({ video: targetVideo }) === true; } catch (error) { devLogger.warn("[ChannelProfile] Failed to handle channel moderation override", error); } if (handled) { updateChannelModerationVisuals({ app, pubkey: sourceVideo?.pubkey || currentChannelHex || "" }); } else if (button) { button.disabled = false; button.removeAttribute("aria-busy"); } } function handleChannelModerationHide(event) { if (event) { if (typeof event.preventDefault === "function") { event.preventDefault(); } if (typeof event.stopPropagation === "function") { event.stopPropagation(); } } const sourceVideo = currentChannelModerationSourceVideo; const app = channelModerationBadgeState.app || getApp(); const targetVideo = app?.videosMap instanceof Map && sourceVideo?.id ? app.videosMap.get(sourceVideo.id) || sourceVideo : sourceVideo; if (!targetVideo || typeof targetVideo !== "object" || !targetVideo.id) { return; } const button = channelModerationBadgeState.hideButton; if (button) { button.disabled = true; button.setAttribute("aria-busy", "true"); } let handled = false; try { handled = typeof app?.handleModerationHide === "function" ? app.handleModerationHide({ video: targetVideo, }) === true : false; } catch (error) { devLogger.warn( "[ChannelProfile] Failed to handle channel moderation hide", error, ); } if (handled) { updateChannelModerationVisuals({ app, pubkey: sourceVideo?.pubkey || currentChannelHex || "", }); } else if (button) { button.disabled = false; button.removeAttribute("aria-busy"); } } function handleChannelModerationBlock(event) { if (event) { if (typeof event.preventDefault === "function") { event.preventDefault(); } if (typeof event.stopPropagation === "function") { event.stopPropagation(); } } const sourceVideo = currentChannelModerationSourceVideo; const app = channelModerationBadgeState.app || getApp(); const targetVideo = app?.videosMap instanceof Map && sourceVideo?.id ? app.videosMap.get(sourceVideo.id) || sourceVideo : sourceVideo; if (!targetVideo || typeof targetVideo !== "object" || !targetVideo.id) { return; } const button = channelModerationBadgeState.blockButton; if (button) { button.disabled = true; button.setAttribute("aria-busy", "true"); } let handled = false; try { handled = app?.handleModerationBlock?.({ video: targetVideo }) === true; } catch (error) { devLogger.warn("[ChannelProfile] Failed to handle channel moderation block", error); } if (handled) { updateChannelModerationVisuals({ app, pubkey: sourceVideo?.pubkey || currentChannelHex || "" }); } else if (button) { button.disabled = false; button.removeAttribute("aria-busy"); } } function resolveChannelModeration(pubkey, app = getApp()) { const normalized = normalizeHex(pubkey); if (!normalized) { return null; } const matches = collectChannelVideos(normalized, app); let fallbackModeration = null; let fallbackVideo = null; for (const candidate of matches) { const decorated = decorateChannelVideo(candidate, app); if (!decorated || typeof decorated !== "object") { continue; } const moderation = decorated.moderation && typeof decorated.moderation === "object" ? decorated.moderation : null; if (!moderation) { continue; } if (moderation.viewerOverride?.showAnyway === true) { return { moderation, video: decorated }; } if (moderation.hidden === true || moderation.blurThumbnail === true) { return { moderation, video: decorated }; } if (!fallbackModeration) { fallbackModeration = moderation; fallbackVideo = decorated; } } if (fallbackModeration) { return { moderation: fallbackModeration, video: fallbackVideo }; } const fallback = buildChannelModerationFallback(normalized); if (fallback) { return { moderation: fallback, video: null }; } return null; } export function applyChannelVisualBlur({ bannerEl = null, avatarEl = null, pubkey = "", app = null, } = {}) { const resolvedApp = app || getApp(); const resolution = resolveChannelModeration(pubkey, resolvedApp); const moderation = resolution?.moderation || {}; const context = normalizeVideoModerationContext(moderation); const resolvedBanner = bannerEl || (typeof document !== "undefined" ? document.getElementById("channelBanner") : null); const resolvedAvatar = avatarEl || (typeof document !== "undefined" ? document.getElementById("channelAvatar") : null); const doc = resolvedBanner?.ownerDocument || resolvedAvatar?.ownerDocument || (typeof document !== "undefined" ? document : null); const badgeContainer = doc?.getElementById?.("channelModerationBadge") || null; const avatarElements = [resolvedAvatar, resolvedBanner].filter(Boolean); applyModerationContextDatasets(context, { root: resolvedBanner || null, roots: badgeContainer ? [badgeContainer] : undefined, avatar: resolvedAvatar || null, avatars: avatarElements.length > 1 ? avatarElements : undefined, }); return { context, moderation, video: resolution?.video || null }; } function updateChannelModerationVisuals({ pubkey = "", app = null, bannerEl = null, avatarEl = null, } = {}) { if (typeof document === "undefined") { return; } const resolvedApp = app || getApp(); const targetPubkey = pubkey || currentChannelHex || ""; if (!targetPubkey) { const result = applyChannelVisualBlur({ bannerEl, avatarEl, pubkey: "", app: resolvedApp, }); renderChannelModerationBadge({ context: result?.context, video: null, app: resolvedApp, doc: bannerEl?.ownerDocument || avatarEl?.ownerDocument || (typeof document !== "undefined" ? document : null), }); return; } const resolvedBanner = bannerEl || document.getElementById("channelBanner"); const resolvedAvatar = avatarEl || document.getElementById("channelAvatar"); const result = applyChannelVisualBlur({ bannerEl: resolvedBanner, avatarEl: resolvedAvatar, pubkey: targetPubkey, app: resolvedApp, }); renderChannelModerationBadge({ context: result?.context, video: result?.video, app: resolvedApp, doc: resolvedBanner?.ownerDocument || resolvedAvatar?.ownerDocument || (typeof document !== "undefined" ? document : null), }); } let channelModerationEventsBound = false; const channelModerationUnsubscribes = []; function handleTrustedMuteSignal() { if (!currentChannelHex) { return; } updateChannelModerationVisuals({ pubkey: currentChannelHex }); } function refreshChannelVideoCardModeration( videoId, { app = getApp(), fallbackVideo = null, decoratedVideo = null } = {}, ) { if (typeof videoId !== "string" || !videoId) { return false; } const resolvedApp = app || getApp(); const sourceMap = resolvedApp?.videosMap instanceof Map ? resolvedApp.videosMap : null; const card = channelVideoCardsById.get(videoId) || null; let nextVideo = decoratedVideo && typeof decoratedVideo === "object" ? decoratedVideo : null; if (!nextVideo) { const baseVideo = (sourceMap && sourceMap.get(videoId)) || (fallbackVideo && typeof fallbackVideo === "object" ? fallbackVideo : null) || (card && typeof card.video === "object" ? card.video : null); if (!baseVideo) { return false; } nextVideo = decorateChannelVideo(baseVideo, resolvedApp) || baseVideo; } if (sourceMap && nextVideo) { sourceMap.set(videoId, nextVideo); } if (!card || !nextVideo) { return Boolean(nextVideo); } card.video = nextVideo; if (typeof card.refreshModerationUi === "function") { try { card.refreshModerationUi(); } catch (error) { devLogger.warn( `[ChannelProfile] Failed to refresh moderation UI for video ${videoId}`, error, ); } } channelVideoCardsById.set(videoId, card); return true; } function handleModerationSummarySignal(detail = {}) { if (!detail || typeof detail !== "object") { return; } const eventId = typeof detail.eventId === "string" ? detail.eventId : ""; if (!eventId) { return; } const app = getApp(); const video = app?.videosMap instanceof Map ? app.videosMap.get(eventId) : null; if (!video || typeof video !== "object") { return; } const videoPubkey = normalizeHex(video.pubkey); if (!videoPubkey) { return; } const channelPubkey = normalizeHex(currentChannelHex); if (!channelPubkey || videoPubkey !== channelPubkey) { return; } const decoratedVideo = decorateChannelVideo(video, app) || video; refreshChannelVideoCardModeration(eventId, { app, fallbackVideo: decoratedVideo, decoratedVideo, }); updateChannelModerationVisuals({ pubkey: channelPubkey, app, }); } function handleVideoModerationEvent(event) { const detail = event?.detail; const video = detail && typeof detail === "object" ? detail.video : null; if (!video || typeof video !== "object") { return; } const videoPubkey = normalizeHex(video.pubkey); const channelPubkey = normalizeHex(currentChannelHex); if (!videoPubkey || !channelPubkey || videoPubkey !== channelPubkey) { return; } const videoId = typeof video.id === "string" ? video.id : ""; if (!videoId) { return; } const app = getApp(); const decoratedVideo = decorateChannelVideo(video, app) || video; if (app?.videosMap instanceof Map) { app.videosMap.set(videoId, decoratedVideo); } refreshChannelVideoCardModeration(videoId, { app, fallbackVideo: decoratedVideo, decoratedVideo, }); updateChannelModerationVisuals({ pubkey: channelPubkey, app, }); } function ensureChannelModerationEvents() { if (channelModerationEventsBound) { return; } channelModerationEventsBound = true; if (typeof document !== "undefined") { document.addEventListener("video:moderation-override", handleVideoModerationEvent); document.addEventListener("video:moderation-block", handleVideoModerationEvent); } if (moderationService && typeof moderationService.on === "function") { const trustedUnsub = moderationService.on( "trusted-mutes", handleTrustedMuteSignal, ); if (typeof trustedUnsub === "function") { channelModerationUnsubscribes.push(trustedUnsub); } const summaryUnsub = moderationService.on( "summary", handleModerationSummarySignal, ); if (typeof summaryUnsub === "function") { channelModerationUnsubscribes.push(summaryUnsub); } } } 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 }; userLogger.error("[zap] Channel zap failure", summary, error); } let currentChannelProfileEvent = null; let currentChannelProfileSnapshot = null; let currentChannelProfileHasExplicitPayload = false; 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 zapPopover = null; let zapPopoverTrigger = null; let zapShouldFocusOnOpen = false; let zapPopoverOpenPromise = null; let channelMenuPopover = null; let channelMenuOpen = false; 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 hasExplicitChannelProfilePayload(profile) { return ( profile && typeof profile === "object" && Object.keys(profile).length > 0 ); } function touchChannelProfileCacheEntry(pubkey) { if (typeof pubkey !== "string" || !pubkey) { return false; } const existing = channelProfileMetadataCache.get(pubkey); if (!existing) { return false; } channelProfileMetadataCache.set(pubkey, { timestamp: Date.now(), profile: { ...existing.profile }, event: existing.event ? { ...existing.event } : null }); return true; } const SUPPORTED_BANNER_REFERRER_POLICIES = new Set([ "no-referrer", "origin", "origin-when-cross-origin", "same-origin", "strict-origin", "strict-origin-when-cross-origin", "unsafe-url" ]); const DEFAULT_BANNER_REFERRER_POLICY_SEQUENCE = [ "no-referrer", "strict-origin-when-cross-origin", "origin-when-cross-origin", "unsafe-url" ]; const bannerLoadStates = new WeakMap(); function resolveToAbsoluteUrl(url) { if (typeof url !== "string") { return ""; } const trimmed = url.trim(); if (!trimmed) { return ""; } try { if (typeof window !== "undefined" && window.location) { return new URL(trimmed, window.location.href).href; } return new URL(trimmed).href; } catch (error) { return trimmed; } } function normalizeReferrerPolicy(policy) { if (policy === null) { return null; } if (typeof policy !== "string") { return undefined; } const trimmed = policy.trim(); if (!trimmed) { return null; } if (trimmed.toLowerCase() === "default") { return null; } if (!SUPPORTED_BANNER_REFERRER_POLICIES.has(trimmed)) { return undefined; } return trimmed; } function buildBannerPolicySequence(policies) { const normalized = []; const source = Array.isArray(policies) ? policies : []; const seen = new Set(); for (const entry of source) { if (entry === null) { continue; } if (typeof entry !== "string") { continue; } const trimmed = entry.trim(); if (!trimmed || trimmed.toLowerCase() === "default") { continue; } if (!SUPPORTED_BANNER_REFERRER_POLICIES.has(trimmed)) { continue; } if (seen.has(trimmed)) { continue; } normalized.push(trimmed); seen.add(trimmed); } normalized.push(null); return normalized; } function attemptBannerPolicy(bannerEl) { const state = bannerLoadStates.get(bannerEl); if (!state) { return false; } if (state.loadToken !== currentProfileLoadToken) { return false; } if (!state.url) { bannerLoadStates.delete(bannerEl); if (state.fallbackSrc) { setBannerVisual(bannerEl, state.fallbackSrc, { referrerPolicy: null }); } else { setBannerVisual(bannerEl, "", { referrerPolicy: null }); } return false; } while (state.attemptIndex < state.policies.length) { const candidate = state.policies[state.attemptIndex]; state.attemptIndex += 1; const normalizedPolicy = normalizeReferrerPolicy(candidate); if (normalizedPolicy === undefined) { continue; } bannerLoadStates.set(bannerEl, state); setBannerVisual(bannerEl, state.url, { referrerPolicy: normalizedPolicy }); return true; } bannerLoadStates.delete(bannerEl); if (state.url) { devLogger.warn( "[channelProfile.banner] Exhausted referrer policies for banner", { url: state.url } ); } if (state.fallbackSrc) { setBannerVisual(bannerEl, state.fallbackSrc, { referrerPolicy: null }); } else { setBannerVisual(bannerEl, "", { referrerPolicy: null }); } return false; } function setBannerVisual(el, url, { referrerPolicy } = {}) { if (!el) { return; } const resolvedUrl = typeof url === "string" ? url : ""; const tagName = el.tagName ? el.tagName.toLowerCase() : ""; if (tagName === "img") { if (referrerPolicy !== undefined) { const normalizedPolicy = referrerPolicy === null ? "" : typeof referrerPolicy === "string" ? referrerPolicy : ""; if ("referrerPolicy" in el) { try { el.referrerPolicy = normalizedPolicy; } catch (error) { devLogger.warn( "[channelProfile.banner] Failed to set banner referrer policy", error ); } } if (normalizedPolicy) { el.setAttribute("referrerpolicy", normalizedPolicy); } else { el.removeAttribute("referrerpolicy"); } el.dataset.bannerReferrerPolicy = normalizedPolicy; } if (el.src !== resolvedUrl) { el.src = resolvedUrl; } } else { updateChannelBannerBackground(el, resolvedUrl || ""); } if (el.dataset.bannerSrc !== resolvedUrl) { el.dataset.bannerSrc = resolvedUrl; } } function ensureBannerFallbackHandler(bannerEl) { if (!bannerEl) { return; } const tagName = bannerEl.tagName ? bannerEl.tagName.toLowerCase() : ""; if (tagName !== "img") { return; } if (!bannerEl.dataset.bannerReferrerPolicy) { const initialPolicy = bannerEl.getAttribute("referrerpolicy") || ""; bannerEl.dataset.bannerReferrerPolicy = initialPolicy; } if (bannerEl.dataset.bannerFallbackAttached === "true") { return; } const handleError = () => { const state = bannerLoadStates.get(bannerEl); if (state && state.loadToken !== currentProfileLoadToken) { return; } const activeSrc = bannerEl.currentSrc || bannerEl.src || ""; const normalizedActive = resolveToAbsoluteUrl(activeSrc); if ( state && state.resolvedUrl && normalizedActive && normalizedActive === state.resolvedUrl ) { const retried = attemptBannerPolicy(bannerEl); if (retried) { return; } return; } const fallbackAttr = (typeof bannerEl.dataset?.fallbackSrc === "string" ? bannerEl.dataset.fallbackSrc.trim() : "") || bannerEl.getAttribute("data-fallback-src") || ""; const fallbackSrc = (state && state.fallbackSrc) || fallbackAttr || FALLBACK_CHANNEL_BANNER; const fallbackResolved = (state && state.resolvedFallback) || resolveToAbsoluteUrl(fallbackSrc); bannerLoadStates.delete(bannerEl); if (fallbackSrc && normalizedActive !== fallbackResolved) { setBannerVisual(bannerEl, fallbackSrc, { referrerPolicy: null }); } else if (!fallbackSrc) { setBannerVisual(bannerEl, "", { referrerPolicy: null }); } }; bannerEl.addEventListener("error", handleError, { passive: true }); bannerEl.dataset.bannerFallbackAttached = "true"; } function applyBannerWithPolicies({ bannerEl, url, fallbackSrc, policies = DEFAULT_BANNER_REFERRER_POLICY_SEQUENCE, loadToken } = {}) { if (!bannerEl) { return; } const normalizedUrl = typeof url === "string" && url.trim() ? url.trim() : ""; const fallbackCandidate = (typeof fallbackSrc === "string" && fallbackSrc.trim()) || FALLBACK_CHANNEL_BANNER; const normalizedPolicies = buildBannerPolicySequence(policies); const expectedToken = Number.isInteger(loadToken) ? loadToken : currentProfileLoadToken; if (!normalizedUrl) { bannerLoadStates.delete(bannerEl); setBannerVisual(bannerEl, fallbackCandidate, { referrerPolicy: null }); return; } bannerLoadStates.set(bannerEl, { url: normalizedUrl, resolvedUrl: resolveToAbsoluteUrl(normalizedUrl), fallbackSrc: fallbackCandidate, resolvedFallback: resolveToAbsoluteUrl(fallbackCandidate), policies: normalizedPolicies, attemptIndex: 0, loadToken: expectedToken }); attemptBannerPolicy(bannerEl); } 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; const requiresLogin = shouldShow && !isLoggedIn; if ( shouldShow && (!zapPopover || zapPopoverTrigger !== zapButton) ) { setupZapButton({ force: true }); } zapButton.toggleAttribute("hidden", !shouldShow); zapButton.disabled = !shouldShow; zapButton.setAttribute( "aria-disabled", (requiresLogin || !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"); } if (requiresLogin) { zapButton.dataset.requiresLogin = "true"; } else { delete zapButton.dataset.requiresLogin; } closeZapControls(); if (controls) { controls.setAttribute("aria-hidden", "true"); controls.hidden = true; } const shouldEnableInputs = shouldShow && !requiresLogin; if (amountInput) { amountInput.disabled = !shouldEnableInputs; } if (sendButton) { sendButton.disabled = !shouldEnableInputs; sendButton.setAttribute("aria-hidden", (!shouldEnableInputs).toString()); if (shouldEnableInputs) { 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(shouldEnableInputs && !hasWallet); if (shouldEnableInputs) { setupZapWalletLink(); } } function isSessionActorWithoutLogin() { const app = getApp(); const isLoggedIn = typeof app?.isUserLoggedIn === "function" ? app.isUserLoggedIn() : Boolean(app?.normalizeHexPubkey?.(app?.pubkey) || app?.pubkey); if (isLoggedIn) { return false; } return getSessionActorPubkey().length > 0; } function getSessionActorPubkey() { return typeof nostrClient?.sessionActor?.pubkey === "string" ? nostrClient.sessionActor.pubkey.trim() : ""; } function getChannelShareButton() { if ( cachedChannelShareButton && !document.body.contains(cachedChannelShareButton) ) { cachedChannelShareButton = null; } if (!cachedChannelShareButton) { cachedChannelShareButton = document.getElementById("channelShareBtn"); } return cachedChannelShareButton; } function getChannelMenuElement() { if (cachedChannelMenu && cachedChannelMenu.isConnected) { return cachedChannelMenu; } const doc = typeof document !== "undefined" ? document : null; const existing = doc?.getElementById("channelProfileMoreMenu") || null; const HTMLElementCtor = doc?.defaultView?.HTMLElement || (typeof HTMLElement !== "undefined" ? HTMLElement : null); if (HTMLElementCtor && existing instanceof HTMLElementCtor) { cachedChannelMenu = existing; } else { cachedChannelMenu = null; } return cachedChannelMenu; } function getZapControlsContainer() { if (cachedZapControls) { if (cachedZapControls.isConnected) { return cachedZapControls; } const doc = typeof document !== "undefined" ? document : null; const HTMLElementCtor = doc?.defaultView?.HTMLElement || (typeof HTMLElement !== "undefined" ? HTMLElement : null); const existing = doc?.getElementById("zapControls") || null; if (existing && (!HTMLElementCtor || existing instanceof HTMLElementCtor)) { cachedZapControls = existing; return cachedZapControls; } return cachedZapControls; } const doc = typeof document !== "undefined" ? document : null; const existing = doc?.getElementById("zapControls") || null; const HTMLElementCtor = doc?.defaultView?.HTMLElement || (typeof HTMLElement !== "undefined" ? HTMLElement : null); if (existing && (!HTMLElementCtor || existing instanceof HTMLElementCtor)) { cachedZapControls = existing; } else { cachedZapControls = null; } return cachedZapControls; } function getZapFormElement() { if (cachedZapForm) { if (cachedZapForm.isConnected) { return cachedZapForm; } const container = getZapControlsContainer(); if (container) { const existing = container.querySelector("#zapForm"); if (existing) { cachedZapForm = existing; } } return cachedZapForm; } const container = getZapControlsContainer(); cachedZapForm = container?.querySelector("#zapForm") || null; return cachedZapForm; } function getZapAmountInput() { if (cachedZapAmountInput) { if (cachedZapAmountInput.isConnected) { return cachedZapAmountInput; } const container = getZapControlsContainer(); if (container) { const existing = container.querySelector("#zapAmountInput"); if (existing) { cachedZapAmountInput = existing; } } return cachedZapAmountInput; } const container = getZapControlsContainer(); cachedZapAmountInput = container?.querySelector("#zapAmountInput") || null; return cachedZapAmountInput; } function getZapSplitSummaryElement() { if (cachedZapSplitSummary) { if (cachedZapSplitSummary.isConnected) { return cachedZapSplitSummary; } const container = getZapControlsContainer(); if (container) { const existing = container.querySelector("#zapSplitSummary"); if (existing) { cachedZapSplitSummary = existing; } } return cachedZapSplitSummary; } const container = getZapControlsContainer(); cachedZapSplitSummary = container?.querySelector("#zapSplitSummary") || null; return cachedZapSplitSummary; } function getZapStatusElement() { if (cachedZapStatus) { if (cachedZapStatus.isConnected) { return cachedZapStatus; } const container = getZapControlsContainer(); if (container) { const existing = container.querySelector("#zapStatus"); if (existing) { cachedZapStatus = existing; } } return cachedZapStatus; } const container = getZapControlsContainer(); cachedZapStatus = container?.querySelector("#zapStatus") || null; return cachedZapStatus; } function getZapReceiptsList() { if (cachedZapReceipts) { if (cachedZapReceipts.isConnected) { return cachedZapReceipts; } const container = getZapControlsContainer(); if (container) { const existing = container.querySelector("#zapReceipts"); if (existing) { cachedZapReceipts = existing; } } return cachedZapReceipts; } const container = getZapControlsContainer(); cachedZapReceipts = container?.querySelector("#zapReceipts") || null; return cachedZapReceipts; } function getZapSendButton() { if (cachedZapSendBtn) { if (cachedZapSendBtn.isConnected) { return cachedZapSendBtn; } const container = getZapControlsContainer(); if (container) { const existing = container.querySelector("#zapSendBtn"); if (existing) { cachedZapSendBtn = existing; } } return cachedZapSendBtn; } const container = getZapControlsContainer(); cachedZapSendBtn = container?.querySelector("#zapSendBtn") || null; return cachedZapSendBtn; } function getZapWalletPrompt() { if (cachedZapWalletPrompt) { if (cachedZapWalletPrompt.isConnected) { return cachedZapWalletPrompt; } const container = getZapControlsContainer(); if (container) { const existing = container.querySelector("#zapWalletPrompt"); if (existing) { cachedZapWalletPrompt = existing; } } return cachedZapWalletPrompt; } const container = getZapControlsContainer(); cachedZapWalletPrompt = container?.querySelector("#zapWalletPrompt") || null; return cachedZapWalletPrompt; } function getZapWalletLink() { if (cachedZapWalletLink) { if (cachedZapWalletLink.isConnected) { return cachedZapWalletLink; } const container = getZapControlsContainer(); if (container) { const existing = container.querySelector("#zapWalletLink"); if (existing) { cachedZapWalletLink = existing; } } return cachedZapWalletLink; } const container = getZapControlsContainer(); cachedZapWalletLink = container?.querySelector("#zapWalletLink") || null; return cachedZapWalletLink; } function getZapCloseButton() { if (cachedZapCloseBtn) { if (cachedZapCloseBtn.isConnected) { return cachedZapCloseBtn; } const container = getZapControlsContainer(); if (container) { const existing = container.querySelector("#zapCloseBtn"); if (existing) { cachedZapCloseBtn = existing; } } return cachedZapCloseBtn; } const container = getZapControlsContainer(); cachedZapCloseBtn = container?.querySelector("#zapCloseBtn") || null; return cachedZapCloseBtn; } function cacheZapPanelElements(panel) { if (!panel || panel.nodeType !== 1) { cachedZapControls = null; cachedZapForm = null; cachedZapAmountInput = null; cachedZapSplitSummary = null; cachedZapStatus = null; cachedZapReceipts = null; cachedZapWalletPrompt = null; cachedZapWalletLink = null; cachedZapCloseBtn = null; cachedZapSendBtn = null; zapPopoverOpenPromise = null; zapShouldFocusOnOpen = false; return; } cachedZapControls = panel; cachedZapForm = panel.querySelector("#zapForm"); cachedZapAmountInput = panel.querySelector("#zapAmountInput"); cachedZapSplitSummary = panel.querySelector("#zapSplitSummary"); cachedZapStatus = panel.querySelector("#zapStatus"); cachedZapReceipts = panel.querySelector("#zapReceipts"); cachedZapWalletPrompt = panel.querySelector("#zapWalletPrompt"); cachedZapWalletLink = panel.querySelector("#zapWalletLink"); cachedZapCloseBtn = panel.querySelector("#zapCloseBtn"); cachedZapSendBtn = panel.querySelector("#zapSendBtn"); } function initializeZapPanel() { const zapButton = getChannelZapButton(); const amountInput = getZapAmountInput(); const zapForm = getZapFormElement(); const sendButton = getZapSendButton(); if (!zapButton || !amountInput || !zapForm || !sendButton) { return; } zapButton.setAttribute("aria-expanded", "false"); if (zapForm.dataset.initialized !== "true") { zapForm.addEventListener("submit", handleZapSend); zapForm.dataset.initialized = "true"; } if (amountInput.dataset.initialized !== "true") { amountInput.addEventListener("input", handleZapAmountChange); amountInput.addEventListener("change", handleZapAmountChange); amountInput.dataset.initialized = "true"; } const closeBtn = getZapCloseButton(); if (closeBtn && closeBtn.dataset.initialized !== "true") { closeBtn.addEventListener("click", (event) => { event?.preventDefault?.(); closeZapControls({ focusButton: true }); }); closeBtn.dataset.initialized = "true"; } setupZapWalletLink(); const app = getApp(); const activeSettings = typeof app?.getActiveNwcSettings === "function" ? app.getActiveNwcSettings() : {}; 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; })(); const shouldShowPrompt = !zapButton.hasAttribute("hidden") && !hasWallet; setZapWalletPromptVisible(shouldShowPrompt); if ( Number.isFinite(activeSettings?.defaultZap) && activeSettings.defaultZap > 0 ) { amountInput.value = Math.max(0, Math.round(activeSettings.defaultZap)); } else if (!amountInput.value) { amountInput.value = ""; } updateZapSplitSummary(); setZapStatus("", "neutral"); clearZapReceipts(); } 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") { Promise.resolve() .then(() => app.openWalletPane()) .catch((error) => { devLogger.warn( "[zap] Failed to open wallet pane from channel view:", error ); app?.showError?.("Wallet settings are not available right now."); }); } else { devLogger.warn( "[zap] Wallet pane requested but application did not expose openWalletPane()." ); app?.showError?.("Wallet settings are not available right now."); } }); 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() { if (typeof zapPopover?.isOpen === "function") { return zapPopover.isOpen(); } return zapControlsOpen; } function openZapControls({ focus = false } = {}) { const zapButton = getChannelZapButton(); if (!zapButton) { zapShouldFocusOnOpen = false; return false; } if (!zapPopover || zapPopoverTrigger !== zapButton) { const initialized = setupZapButton({ force: true }); if (!initialized || !zapPopover || zapPopoverTrigger !== zapButton) { zapShouldFocusOnOpen = false; return false; } } if (zapPopoverOpenPromise) { if (focus) { zapShouldFocusOnOpen = true; } return true; } zapShouldFocusOnOpen = focus; let result; try { result = zapPopover.open(); } catch (error) { zapShouldFocusOnOpen = false; zapPopoverOpenPromise = null; devLogger.warn("[zap] Failed to open zap popover", error); return false; } if (result && typeof result.then === "function") { const pending = result .catch((error) => { devLogger.warn("[zap] Zap popover open rejected", error); return false; }) .finally(() => { zapPopoverOpenPromise = null; zapShouldFocusOnOpen = false; }); zapPopoverOpenPromise = pending; return true; } zapShouldFocusOnOpen = false; zapPopoverOpenPromise = null; return Boolean(result); } function closeZapControls({ focusButton = false } = {}) { const zapButton = getChannelZapButton(); zapShouldFocusOnOpen = false; zapPopoverOpenPromise = null; if (zapPopover) { const result = zapPopover.close({ restoreFocus: focusButton }); if (!result && focusButton && zapButton && typeof zapButton.focus === "function") { try { zapButton.focus({ preventScroll: true }); } catch (error) { zapButton.focus(); } } return Boolean(result); } zapControlsOpen = false; if (zapButton) { zapButton.setAttribute("aria-expanded", "false"); if (focusButton && typeof zapButton.focus === "function") { try { zapButton.focus({ preventScroll: true }); } catch (error) { zapButton.focus(); } } } return false; } 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 = ""; const doc = list.ownerDocument || (typeof document !== "undefined" ? document : null); if (!Array.isArray(receipts) || receipts.length === 0) { if (partial) { const emptyItem = doc?.createElement?.("li"); emptyItem.className = "rounded border border-border bg-overlay-panel-soft 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 = doc?.createElement?.("li"); if (!li) { return; } li.className = "rounded border border-border bg-overlay-panel-soft p-3"; const header = doc?.createElement?.("div"); if (!header) { return; } 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 = doc?.createElement?.("span"); shareLabel.textContent = `${describeShareType(shareType)} • ${Math.max( 0, Math.round(Number(receipt.amount || 0)) )} sats`; const status = doc?.createElement?.("span"); if (!shareLabel || !status) { return; } 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 = doc?.createElement?.("p"); if (address) { address.className = "mt-1 text-xs text-text break-all"; } const addressValue = receipt.address || ""; if (address && addressValue) { address.textContent = addressValue; li.appendChild(address); } const detail = doc?.createElement?.("p"); if (!detail) { return; } 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 setZapCompleted(completed) { const sendButton = getZapSendButton(); if (!sendButton) { return; } if (completed) { delete sendButton.dataset.retryPending; sendButton.dataset.completed = "true"; sendButton.textContent = "Done"; sendButton.setAttribute("aria-label", "Close zap dialog"); sendButton.title = "Close zap dialog"; } else { delete sendButton.dataset.completed; if (!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) { const allowanceExhausted = isZapAllowanceExhaustedError(error); if (allowanceExhausted) { error.__zapAllowanceExhausted = true; } if (Array.isArray(shareTracker)) { shareTracker.push({ type: shareType, status: "error", amount: shareAmount, address, error, allowanceExhausted }); } if ( typeof error?.message === "string" && error.message.toLowerCase().includes("timed out") ) { try { resetWalletClient(); } catch (resetError) { devLogger?.warn?.( "[zap] Failed to reset wallet client after timeout", resetError ); } } 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(); setZapCompleted(false); 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(); if (typeof event.stopPropagation === "function") { event.stopPropagation(); } } const zapButton = getChannelZapButton(); if (!zapButton) { return; } const requiresLogin = zapButton.dataset?.requiresLogin === "true"; if (requiresLogin || isSessionActorWithoutLogin()) { channelZapPendingOpen = true; const doc = zapButton?.ownerDocument || (typeof document !== "undefined" ? document : null); showLoginRequiredToZapNotification({ document: doc }); return; } channelZapPendingOpen = false; if (!zapPopover || zapPopoverTrigger !== zapButton) { setupZapButton({ force: true }); } if (!zapPopover) { return; } if (zapPopoverOpenPromise) { zapShouldFocusOnOpen = true; return; } const popoverIsOpen = isZapControlsOpen(); if (!popoverIsOpen) { 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 (sendButton.dataset.completed === "true") { closeZapControls({ focusButton: true }); return; } if (!zapPopover || zapPopoverTrigger !== zapButton) { setupZapButton({ force: true }); } if (!isZapControlsOpen()) { if (zapPopoverOpenPromise) { zapShouldFocusOnOpen = true; return; } openZapControls({ focus: true }); return; } if (zapPopoverOpenPromise) { return; } if (!zapPopover) { 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 : []; const failureReceipts = receipts.filter((receipt) => { if (!receipt || typeof receipt !== "object") { return false; } if (typeof receipt.status === "string" && receipt.status !== "success") { return true; } return Boolean(receipt.error); }); if (failureReceipts.length) { renderZapReceipts(receipts, { partial: true }); const failureShares = failureReceipts .map((receipt) => { const amount = Math.max(0, Math.round(Number(receipt.amount) || 0)); return { type: receipt.recipientType || receipt.type || "creator", amount, address: typeof receipt.address === "string" && receipt.address ? receipt.address : undefined, error: receipt.error }; }) .filter((share) => share.amount > 0); if (failureShares.length) { markZapRetryPending(failureShares); } const shareSummary = failureShares .map((share) => `${describeShareType(share.type)} ${share.amount} sats`) .join(", "); const failureMessage = failureReceipts .map((receipt) => { if (typeof receipt?.error?.message === "string") { return receipt.error.message; } if (typeof receipt?.error === "string") { return receipt.error; } return ""; }) .find((message) => message); const budgetReceipt = failureReceipts.find((receipt) => isZapAllowanceExhaustedError(receipt?.error) ); const isBudgetExceeded = Boolean(budgetReceipt) || (typeof failureMessage === "string" && /budget exceeded/i.test(failureMessage)); let warningMessage; if (isBudgetExceeded) { warningMessage = "Budget exceeded. Increase your wallet zap limit or reduce the platform fee, then retry the remaining shares."; } else if (failureMessage) { warningMessage = failureMessage; } else { warningMessage = "Some zap shares failed. Press Send again to retry the remaining shares."; } if (shareSummary) { const normalized = warningMessage.replace(/\.*$/, ""); warningMessage = `${normalized}. Remaining share(s): ${shareSummary}.`; } setZapStatus(warningMessage, "warning"); app?.showError?.(warningMessage); return; } 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(); setZapCompleted(true); } 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 ); const allowanceExhausted = isZapAllowanceExhaustedError(error) || failureShares.some((share) => isZapAllowanceExhaustedError(share?.error) ); if (allowanceExhausted) { resetZapRetryState(); } if (failureShares.length) { if (allowanceExhausted) { const allowanceShare = failureShares.find((share) => isZapAllowanceExhaustedError(share?.error) ); const allowanceMessage = buildZapAllowanceExhaustedMessage( isZapAllowanceExhaustedError(error) ? error : allowanceShare?.error ); const partialSuccess = tracker.length > failureShares.length; const tone = partialSuccess ? "warning" : "error"; const statusMessage = partialSuccess ? `Some zap shares succeeded before your wallet allowance was exhausted. ${allowanceMessage}` : allowanceMessage; setZapStatus(statusMessage, tone); app?.showError?.(statusMessage); } else { 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(); if (allowanceExhausted) { const allowanceMessage = buildZapAllowanceExhaustedMessage(error); setZapStatus(allowanceMessage, "error"); app?.showError?.(allowanceMessage); } else { 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) { userLogger.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 resolveChannelHexForControls() { const normalized = normalizeHex(currentChannelHex); if (normalized) { return normalized; } return decodeNpubToHex(currentChannelNpub); } function setupChannelMessageControls() { const doc = typeof document !== "undefined" ? document : null; const messageBtn = doc?.getElementById("channelMessageBtn") || null; if (!messageBtn) { return; } const channelHex = resolveChannelHexForControls(); if (!channelHex) { devLogger.warn("[ChannelProfile] Unable to resolve channel hex for controls."); } if (messageBtn) { if (messageBtn.dataset.initialized !== "true") { messageBtn.addEventListener("click", () => { const app = getApp(); const resolvedHex = resolveChannelHexForControls(); if (!resolvedHex) { app?.showError?.("Unable to resolve channel messages."); userLogger.warn("Channel message clicked without a valid channel hex."); return; } if (typeof app?.openDirectMessageComposer === "function") { app.openDirectMessageComposer({ recipientPubkey: resolvedHex, source: "channel-profile", }); return; } const controller = app?.profileController; if (!controller) { app?.showError?.("Messages are unavailable right now."); userLogger.warn( "Profile controller unavailable for channel messages." ); return; } if (typeof controller.handleActiveDmIdentityChanged === "function") { controller.handleActiveDmIdentityChanged(resolvedHex); } else { userLogger.warn( "Profile controller missing handleActiveDmIdentityChanged." ); } if (typeof controller.show === "function") { controller.show("messages"); } else { app?.showError?.("Messages are unavailable right now."); userLogger.warn("Profile controller missing show() for messages."); } }); messageBtn.dataset.initialized = "true"; } } } function setupChannelMoreMenu() { const doc = typeof document !== "undefined" ? document : null; const moreBtn = doc?.getElementById("channelMoreBtn") || null; if (!moreBtn) { if (channelMenuPopover?.destroy) { channelMenuPopover.destroy(); } channelMenuPopover = null; cachedChannelMenu = null; channelMenuOpen = false; return; } if (channelMenuPopover?.destroy) { channelMenuPopover.destroy(); channelMenuPopover = null; } const documentRef = moreBtn.ownerDocument || (typeof document !== "undefined" ? document : null); const render = ({ document: docRef }) => { const panel = createChannelProfileMenuPanel({ document: docRef, context: "channel-profile", }); if (!panel) { return null; } panel.id = "channelProfileMoreMenu"; panel.dataset.state = panel.dataset.state || "closed"; panel.setAttribute("aria-hidden", "true"); panel.hidden = true; cachedChannelMenu = panel; const buttons = panel.querySelectorAll("button[data-action]"); buttons.forEach((button) => { if (button.dataset.channelMenuBound === "true") { return; } button.dataset.channelMenuBound = "true"; button.addEventListener("click", async (event) => { event.preventDefault(); event.stopPropagation(); const dataset = {}; Object.entries(button.dataset || {}).forEach(([key, value]) => { if (key === "channelMenuBound") { return; } dataset[key] = value; }); if (!dataset.context) { dataset.context = "channel-profile"; } if (!dataset.author && currentChannelHex) { dataset.author = currentChannelHex; } if (!dataset.npub && currentChannelNpub) { dataset.npub = currentChannelNpub; } const action = dataset.action || ""; try { await handleChannelMenuAction(action, dataset); } finally { channelMenuPopover?.close(); } }); }); updateChannelMenuState(); return panel; }; const popover = createPopover(moreBtn, render, { document: documentRef, placement: "bottom-end", restoreFocusOnClose: true, }); if (!popover) { return; } const originalOpen = popover.open?.bind(popover); if (originalOpen) { popover.open = async (...args) => { const result = await originalOpen(...args); if (result) { channelMenuOpen = true; const panel = getChannelMenuElement(); if (panel) { panel.dataset.state = "open"; panel.hidden = false; panel.setAttribute("aria-hidden", "false"); } moreBtn.setAttribute("aria-expanded", "true"); } return result; }; } const originalClose = popover.close?.bind(popover); if (originalClose) { popover.close = (options = {}) => { const wasOpen = typeof popover.isOpen === "function" && popover.isOpen(); const result = originalClose(options); if (wasOpen && result) { channelMenuOpen = false; const panel = getChannelMenuElement(); if (panel) { panel.dataset.state = "closed"; panel.hidden = true; panel.setAttribute("aria-hidden", "true"); } moreBtn.setAttribute("aria-expanded", "false"); } return result; }; } const originalDestroy = popover.destroy?.bind(popover); if (originalDestroy) { popover.destroy = (...args) => { originalDestroy(...args); if (channelMenuPopover === popover) { channelMenuPopover = null; } channelMenuOpen = false; cachedChannelMenu = null; }; } channelMenuPopover = popover; if (moreBtn.dataset.initialized !== "true") { moreBtn.addEventListener("click", (event) => { event.preventDefault(); event.stopPropagation(); if (!channelMenuPopover) { return; } channelMenuPopover.toggle(); }); moreBtn.dataset.initialized = "true"; } moreBtn.setAttribute("aria-expanded", "false"); } async function handleChannelMenuAction(action, dataset = {}) { const normalized = typeof action === "string" ? action.trim() : ""; if (!normalized) { return; } const detail = { ...dataset }; if (!detail.context) { detail.context = "channel-profile"; } if (!detail.author && currentChannelHex) { detail.author = currentChannelHex; } if (!detail.npub && currentChannelNpub) { detail.npub = currentChannelNpub; } const app = getApp(); try { await app?.handleMoreMenuAction?.(normalized, detail); } catch (error) { userLogger.error("Failed to handle channel menu action:", error); } } async function updateChannelMenuState() { const menu = getChannelMenuElement(); if (!menu) { return; } const app = getApp(); try { await accessControl.ensureReady(); } catch (error) { userLogger.warn("Failed to refresh moderation lists for channel menu:", error); } try { await moderationService.ensureViewerMuteListLoaded(); } catch (error) { userLogger.warn("Failed to refresh viewer mute list for channel menu:", error); } const canBlacklist = typeof app?.canCurrentUserManageBlacklist === "function" ? app.canCurrentUserManageBlacklist() : false; const buttons = menu.querySelectorAll("button[data-action]"); const toggleButtonVisibility = (button, shouldShow) => { if (!button) { return; } if (shouldShow) { button.removeAttribute("hidden"); button.setAttribute("aria-hidden", "false"); } else { button.setAttribute("hidden", ""); button.setAttribute("aria-hidden", "true"); } }; buttons.forEach((button) => { if (!button || button.nodeType !== 1) { 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 === "mute-author" || action === "unmute-author") { if (currentChannelHex) { button.dataset.author = currentChannelHex; } else { delete button.dataset.author; } let isMuted = false; if (currentChannelHex) { try { isMuted = moderationService.isAuthorMutedByViewer(currentChannelHex) === true; } catch (error) { userLogger.warn("Failed to resolve viewer mute state for channel menu:", error); } } const shouldShow = action === "mute-author" ? !isMuted : isMuted; toggleButtonVisibility(button, shouldShow && !!currentChannelHex); 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) { userLogger.error( "No npub found in hash (e.g. #view=channel-profile&npub=...)" ); return; } const infoTrigger = document.getElementById("channelInfoTrigger"); if (infoTrigger) { attachFeedInfoPopover( infoTrigger, "Videos published by this creator." ); } currentChannelHex = null; currentChannelNpub = null; currentChannelLightningAddress = ""; currentChannelProfileEvent = null; currentChannelProfileSnapshot = null; currentChannelProfileHasExplicitPayload = false; 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) { if (typeof decoded.data === "string") { hexPub = decoded.data; } else { let bufferSource = null; if (decoded.data instanceof Uint8Array) { bufferSource = decoded.data; } else if (Array.isArray(decoded.data)) { bufferSource = Uint8Array.from(decoded.data); } else if ( decoded.data?.type === "Buffer" && Array.isArray(decoded.data?.data) ) { bufferSource = Uint8Array.from(decoded.data.data); } if (bufferSource) { if (typeof window?.NostrTools?.utils?.bytesToHex === "function") { hexPub = window.NostrTools.utils.bytesToHex(bufferSource); } else { hexPub = Array.from(bufferSource) .map((byte) => byte.toString(16).padStart(2, "0")) .join(""); } } } if (typeof hexPub !== "string" || !hexPub) { throw new Error("Unable to normalize npub to hex string."); } hexPub = hexPub.trim().toLowerCase(); } else { throw new Error("Invalid npub decoding result."); } } catch (err) { userLogger.error("Error decoding npub:", err); return; } currentChannelHex = hexPub; currentChannelNpub = npub; const app = getApp(); ensureChannelModerationEvents(); updateChannelModerationVisuals({ app, pubkey: currentChannelHex }); setupChannelShareButton(); setupChannelMessageControls(); setupChannelMoreMenu(); const initialMenuRefresh = updateChannelMenuState().catch((error) => { userLogger.error("Failed to prepare channel menu state:", error); }); let subscriptionsTask = null; // 3) Load subscription state when logged in, but always render the toggle UI if (app?.pubkey) { subscriptionsTask = subscriptions .ensureLoaded(app.pubkey) .catch((error) => { userLogger.error("Failed to load subscriptions for channel view:", error); }) .finally(() => { renderSubscribeButton(hexPub); }); } else { renderSubscribeButton(hexPub); } setupZapButton(); setChannelZapVisibility(false); syncChannelShareButtonState(); // 4) Load user’s profile (banner, avatar, etc.) and channel videos const profilePromise = loadUserProfile(hexPub) .then(() => updateChannelMenuState()) .catch((error) => { userLogger.error("Failed to load channel profile:", error); }) .finally(() => { syncChannelShareButtonState(); }); const videosPromise = loadUserVideos(hexPub).catch((error) => { userLogger.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); } export function __setChannelProfileTestState({ pubkey, npub } = {}) { if (typeof pubkey !== "undefined") { if (typeof pubkey === "string" && pubkey) { currentChannelHex = pubkey; } else { currentChannelHex = null; } } if (typeof npub !== "undefined") { if (typeof npub === "string" && npub) { currentChannelNpub = npub; } else { currentChannelNpub = null; } } } export function __ensureChannelModerationEventsForTests() { ensureChannelModerationEvents(); } function setupZapButton({ force = false } = {}) { const zapButton = getChannelZapButton(); if (!zapButton) { return false; } if (!force && zapPopover && zapPopoverTrigger === zapButton) { if (zapButton.dataset.initialized === "true") { return true; } } if (zapPopover?.destroy) { zapPopover.destroy(); zapPopover = null; zapPopoverTrigger = null; zapPopoverOpenPromise = null; zapShouldFocusOnOpen = false; delete zapButton.dataset.zapPopoverWarmBound; } const documentRef = zapButton.ownerDocument || (typeof document !== "undefined" ? document : null); const render = ({ document: doc }) => { if (!doc || typeof doc.createElement !== "function") { return null; } const panel = doc.createElement("div"); panel.id = "zapControls"; panel.className = "popover__panel card w-72 max-w-popover-safe p-4"; panel.dataset.variant = "zap"; panel.setAttribute("role", "dialog"); panel.setAttribute("aria-label", "Channel zap dialog"); panel.setAttribute("aria-hidden", "true"); panel.dataset.state = panel.dataset.state || "closed"; panel.hidden = true; panel.innerHTML = `
No videos to display.
`; return true; } return false; } let preparedVideos = videos; try { preparedVideos = await applyChannelModerationToVideos(videos, app); } catch (error) { devLogger.warn( "[ChannelProfile] Failed to prepare channel videos for moderation", error, ); preparedVideos = videos; } if (loadToken !== currentVideoLoadToken) { return false; } const moderationApplied = Array.isArray(preparedVideos) && preparedVideos !== videos; const renderableVideos = Array.isArray(preparedVideos) ? preparedVideos : videos; if (!renderableVideos.length) { 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) => { userLogger.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) => { userLogger.error( "Failed to start fallback playback for channel video:", error ); }); } }; const allowNsfw = ALLOW_NSFW_CONTENT === true; for (const originalVideo of renderableVideos) { if (!originalVideo || !originalVideo.id || !originalVideo.title) { continue; } const video = moderationApplied ? originalVideo : decorateChannelVideo(originalVideo, app) || originalVideo; const normalizedVideoPubkey = normalizePubkey(video.pubkey); const canEdit = Boolean(normalizedVideoPubkey) && Boolean(normalizedViewerPubkey) && normalizedVideoPubkey === normalizedViewerPubkey; if (video.isPrivate && !canEdit) { continue; } if (!allowNsfw && video?.isNsfw === true && !canEdit) { continue; } 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 identity = { name: typeof video.creatorName === "string" && video.creatorName ? video.creatorName : typeof video.authorName === "string" ? video.authorName : "", picture: typeof video.creatorPicture === "string" && video.creatorPicture ? video.creatorPicture : typeof video.authorPicture === "string" ? video.authorPicture : "", pubkey: typeof video.pubkey === "string" ? video.pubkey : "", npub: typeof video.npub === "string" && video.npub ? video.npub : typeof video.authorNpub === "string" ? video.authorNpub : "", shortNpub: typeof video.shortNpub === "string" && video.shortNpub ? video.shortNpub : typeof video.creatorNpub === "string" ? video.creatorNpub : "", }; const videoCard = new VideoCard({ document, video, index, shareUrl, pointerInfo, timeAgo, cardState, identity, 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() } }); channelVideoCardsById.set(video.id, videoCard); 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.onModerationOverride = ({ video: overrideVideo, card: overrideCard }) => typeof app?.handleModerationOverride === "function" ? app.handleModerationOverride({ video: overrideVideo, card: overrideCard, }) : false; videoCard.onModerationBlock = ({ video: blockVideo, card: blockCard }) => typeof app?.handleModerationBlock === "function" ? app.handleModerationBlock({ video: blockVideo, card: blockCard, }) : false; videoCard.onModerationHide = ({ video: hideVideo, card: hideCard }) => typeof app?.handleModerationHide === "function" ? app.handleModerationHide({ video: hideVideo, card: hideCard, }) : false; videoCard.onEdit = ({ video: editVideo, index: editIndex }) => { app?.handleEditVideo?.({ eventId: editVideo?.id || "", index: Number.isFinite(editIndex) ? editIndex : null, video: editVideo || null }); }; videoCard.onRevert = ({ video: revertVideo, index: revertIndex }) => { app?.handleRevertVideo?.({ eventId: revertVideo?.id || "", index: Number.isFinite(revertIndex) ? revertIndex : null, video: revertVideo || null }); }; videoCard.onDelete = ({ video: deleteVideo, index: deleteIndex }) => { app?.handleFullDeleteVideo?.({ eventId: deleteVideo?.id || "", index: Number.isFinite(deleteIndex) ? deleteIndex : null, video: deleteVideo || 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); } }; videoCard.onRequestMoreMenu = (detail = {}) => { if (typeof app?.requestMoreMenu === "function") { app.requestMoreMenu({ ...detail, video: detail.video || video, pointerInfo: detail.pointerInfo || pointerInfo, }); } }; videoCard.onCloseMoreMenu = (detail = {}) => { if (typeof app?.closeMoreMenu === "function") { return app.closeMoreMenu(detail); } return false; }; videoCard.onRequestSettingsMenu = (detail = {}) => { if (typeof app?.requestVideoSettingsMenu === "function") { app.requestVideoSettingsMenu(detail); } }; videoCard.onCloseSettingsMenu = (detail = {}) => { if (typeof app?.closeVideoSettingsMenu === "function") { return app.closeVideoSettingsMenu(detail); } return false; }; 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); updateChannelModerationVisuals({ app }); return true; } function applyChannelProfileMetadata({ profile = {}, event = null, pubkey = "", npub = "", loadToken = null } = {}) { if (loadToken !== null && loadToken !== currentProfileLoadToken) { return; } const normalized = normalizeChannelProfileMetadata(profile); const hasExplicitProfilePayload = hasExplicitChannelProfilePayload(profile); const incomingEventTimestamp = typeof event?.created_at === "number" ? event.created_at : 0; const existingEventTimestamp = typeof currentChannelProfileEvent?.created_at === "number" ? currentChannelProfileEvent.created_at : 0; if (currentChannelProfileSnapshot) { const isOlderEvent = incomingEventTimestamp && existingEventTimestamp && incomingEventTimestamp < existingEventTimestamp; if (isOlderEvent) { return; } if (!hasExplicitProfilePayload) { if (currentChannelProfileHasExplicitPayload) { return; } const shouldIgnoreEmptyPayload = !incomingEventTimestamp || incomingEventTimestamp <= existingEventTimestamp; if (shouldIgnoreEmptyPayload) { return; } } } const expectedLoadToken = loadToken !== null ? loadToken : currentProfileLoadToken; const bannerEl = document.getElementById("channelBanner"); if (bannerEl) { const fallbackAttr = (typeof bannerEl.dataset?.fallbackSrc === "string" ? bannerEl.dataset.fallbackSrc.trim() : "") || bannerEl.getAttribute("data-fallback-src") || ""; const fallbackSrc = fallbackAttr || FALLBACK_CHANNEL_BANNER; if (bannerEl.dataset.fallbackSrc !== fallbackSrc) { bannerEl.dataset.fallbackSrc = fallbackSrc; } if (!bannerEl.getAttribute("data-fallback-src") && fallbackSrc) { bannerEl.setAttribute("data-fallback-src", fallbackSrc); } ensureBannerFallbackHandler(bannerEl); if (!normalized.banner) { bannerLoadStates.delete(bannerEl); setBannerVisual(bannerEl, fallbackSrc, { referrerPolicy: null }); } else { applyBannerWithPolicies({ bannerEl, url: normalized.banner, fallbackSrc, loadToken: expectedLoadToken }); } } const avatarEl = document.getElementById("channelAvatar"); if (avatarEl) { avatarEl.src = normalized.picture || FALLBACK_CHANNEL_AVATAR; } const blurPubkey = pubkey || currentChannelHex || ""; updateChannelModerationVisuals({ bannerEl, avatarEl, pubkey: blurPubkey, }); const nameEl = document.getElementById("channelName"); if (nameEl) { nameEl.textContent = normalized.name || "Unknown User"; } const channelNpubEl = document.getElementById("channelNpub"); if (channelNpubEl) { if (npub) { channelNpubEl.textContent = formatShortNpub(npub); } else if (pubkey) { try { const encoded = window.NostrTools.nip19.npubEncode(pubkey); channelNpubEl.textContent = formatShortNpub(encoded); } 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; } currentChannelProfileSnapshot = { ...normalized }; currentChannelProfileHasExplicitPayload = hasExplicitProfilePayload; if (lightningAddress) { if (lightningAddress !== previousLightning) { fetchLightningMetadata(lightningAddress) .then(() => updateZapSplitSummary()) .catch(() => {}); } } else { updateZapSplitSummary(); } setChannelZapVisibility(Boolean(lightningAddress)); } async function fetchChannelProfileFromRelays(pubkey) { try { await nostrClient.ensurePool(); } catch (error) { userLogger.error("Failed to initialize relay pool for channel profile:", error); return { event: null, profile: {} }; } const pool = nostrClient?.pool; const relayCandidates = Array.isArray(nostrClient?.relays) ? nostrClient.relays.filter( (relayUrl) => typeof relayUrl === "string" && relayUrl.trim().length > 0 ) : []; const relayUrls = relayCandidates.length > 0 ? relayCandidates : DEFAULT_RELAY_URLS; if (!pool || !Array.isArray(relayUrls) || relayUrls.length === 0) { return { event: null, profile: {} }; } try { const events = await pool.list(relayUrls, [ { 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) { userLogger.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; } const cachedUpdated = rememberChannelProfile(pubkey, result); if (cachedUpdated && typeof app?.setProfileCacheEntry === "function") { try { const profileForCache = result && typeof result.profile === "object" && result.profile ? { ...result.profile } : {}; if (!profileForCache.lightningAddress) { const lightningFallback = result.profile?.lightningAddress || result.profile?.lud16 || result.profile?.lud06 || ""; if (lightningFallback) { profileForCache.lightningAddress = lightningFallback; } } app.setProfileCacheEntry(pubkey, profileForCache); } catch (error) { userLogger.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) { userLogger.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. */ export function refreshActiveChannelVideoGrid({ reason } = {}) { if (typeof window === "undefined") { return Promise.resolve(); } const hashParams = new URLSearchParams(window.location.hash.slice(1)); if (hashParams.get("view") !== "channel-profile") { return Promise.resolve(); } if (!currentChannelHex) { return Promise.resolve(); } const activeNpub = hashParams.get("npub"); if (currentChannelNpub && activeNpub && activeNpub !== currentChannelNpub) { return Promise.resolve(); } try { const result = loadUserVideos(currentChannelHex); return result instanceof Promise ? result : Promise.resolve(result); } catch (error) { userLogger.warn( reason ? `Failed to trigger channel refresh after ${reason}:` : "Failed to trigger channel refresh:", error, ); return Promise.resolve(); } } async function loadUserVideos(pubkey) { const app = getApp(); const container = document.getElementById("channelVideoList"); const loadToken = ++currentVideoLoadToken; const hadExistingContent = !!container && container.querySelector("[data-video-id]"); let hasVisibleContent = Boolean(hadExistingContent); if (container) { container.dataset.loading = "true"; if (!hadExistingContent) { container.innerHTML = `Failed to load videos. Please try again.
`; } userLogger.error("Error loading user videos:", err); } finally { await ensureAccessPromise; if (loadToken === currentVideoLoadToken && container) { delete container.dataset.loading; } } } if (typeof window !== "undefined" && typeof window.addEventListener === "function") { 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) => { userLogger.error( "Failed to refresh channel videos after admin update:", error ); }); }); window.addEventListener("bitvid:auth-changed", (event) => { 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; } setChannelZapVisibility(Boolean(currentChannelLightningAddress)); syncChannelShareButtonState(); const detail = event?.detail; if (!detail || typeof detail !== "object") { return; } if (detail.status === "logout") { zapInFlight = false; zapPopoverOpenPromise = null; zapShouldFocusOnOpen = false; resetZapRetryState(); setZapStatus("", "neutral"); clearZapReceipts(); channelZapPendingOpen = false; const amountInput = getZapAmountInput(); if (amountInput) { amountInput.value = ""; } return; } if (detail.status === "login") { zapInFlight = false; resetZapRetryState(); setZapStatus("", "neutral"); if (channelZapPendingOpen) { channelZapPendingOpen = false; const zapButton = getChannelZapButton(); const requiresLogin = zapButton?.dataset?.requiresLogin === "true" || zapButton?.hasAttribute("hidden"); if (zapButton && !requiresLogin) { const opened = openZapControls({ focus: true }); if (!opened) { setupZapButton({ force: true }); openZapControls({ focus: true }); } } } return; } }); } /** * 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()); }