mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 16:02:26 +00:00
5304 lines
143 KiB
JavaScript
5304 lines
143 KiB
JavaScript
// 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";
|
||
|
||
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();
|
||
|
||
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 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 {
|
||
const value = resolvedUrl ? `url("${resolvedUrl}")` : "";
|
||
if (el.style.backgroundImage !== value) {
|
||
el.style.backgroundImage = value;
|
||
}
|
||
}
|
||
|
||
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 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();
|
||
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 (!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();
|
||
} 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 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;
|
||
}
|
||
|
||
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();
|
||
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 = `
|
||
<div class="grid gap-4">
|
||
<div class="flex items-center justify-between gap-4">
|
||
<h3 class="text-xs font-semibold uppercase tracking-wide text-muted-strong">
|
||
Send a zap
|
||
</h3>
|
||
<button
|
||
type="button"
|
||
id="zapCloseBtn"
|
||
class="btn-ghost px-2 py-1 text-xs"
|
||
aria-label="Close zap dialog"
|
||
>
|
||
✕
|
||
</button>
|
||
</div>
|
||
<p
|
||
id="zapWalletPrompt"
|
||
class="text-sm text-muted"
|
||
aria-hidden="true"
|
||
hidden
|
||
>
|
||
Connect a wallet in
|
||
<button
|
||
type="button"
|
||
id="zapWalletLink"
|
||
class="btn-ghost px-2 py-1 text-xs"
|
||
>
|
||
Wallet Connect settings
|
||
</button>
|
||
to send zaps.
|
||
</p>
|
||
<form id="zapForm" class="bv-stack bv-stack--tight">
|
||
<div>
|
||
<label
|
||
for="zapAmountInput"
|
||
id="zapAmountLabel"
|
||
class="mb-2 block text-xs font-semibold uppercase tracking-wide text-muted-strong"
|
||
>
|
||
Zap amount (sats)
|
||
</label>
|
||
<input
|
||
id="zapAmountInput"
|
||
type="number"
|
||
min="1"
|
||
step="1"
|
||
inputmode="numeric"
|
||
class="input"
|
||
placeholder="Enter sats"
|
||
aria-labelledby="zapAmountLabel"
|
||
/>
|
||
</div>
|
||
<p
|
||
id="zapSplitSummary"
|
||
class="text-sm text-muted"
|
||
aria-live="polite"
|
||
>
|
||
Enter an amount to view the split.
|
||
</p>
|
||
<p
|
||
id="zapStatus"
|
||
class="text-xs text-muted"
|
||
role="status"
|
||
aria-live="polite"
|
||
></p>
|
||
<ul
|
||
id="zapReceipts"
|
||
class="space-y-2 text-xs text-muted"
|
||
></ul>
|
||
<div class="flex items-center justify-end gap-2">
|
||
<button
|
||
type="submit"
|
||
id="zapSendBtn"
|
||
class="btn"
|
||
aria-label="Send a zap"
|
||
>
|
||
Send
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
`;
|
||
|
||
cacheZapPanelElements(panel);
|
||
initializeZapPanel();
|
||
|
||
return panel;
|
||
};
|
||
|
||
const popover = createPopover(zapButton, render, {
|
||
document: documentRef,
|
||
placement: "bottom-end",
|
||
restoreFocusOnClose: true,
|
||
maxWidthToken: "max-w-popover-safe",
|
||
});
|
||
|
||
if (!popover) {
|
||
zapPopoverOpenPromise = null;
|
||
zapShouldFocusOnOpen = false;
|
||
return false;
|
||
}
|
||
|
||
const originalOpen = popover.open?.bind(popover);
|
||
if (originalOpen) {
|
||
popover.open = async (...args) => {
|
||
let result;
|
||
try {
|
||
result = await originalOpen(...args);
|
||
} catch (error) {
|
||
zapPopoverOpenPromise = null;
|
||
zapShouldFocusOnOpen = false;
|
||
throw error;
|
||
}
|
||
if (result) {
|
||
zapControlsOpen = true;
|
||
const controls = getZapControlsContainer();
|
||
if (controls) {
|
||
controls.dataset.state = "open";
|
||
controls.hidden = false;
|
||
controls.setAttribute("aria-hidden", "false");
|
||
}
|
||
zapButton.setAttribute("aria-expanded", "true");
|
||
if (zapShouldFocusOnOpen) {
|
||
focusZapAmountField();
|
||
}
|
||
}
|
||
zapShouldFocusOnOpen = false;
|
||
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) {
|
||
zapControlsOpen = false;
|
||
const controls = getZapControlsContainer();
|
||
if (controls) {
|
||
controls.dataset.state = "closed";
|
||
controls.hidden = true;
|
||
controls.setAttribute("aria-hidden", "true");
|
||
}
|
||
zapButton.setAttribute("aria-expanded", "false");
|
||
}
|
||
zapShouldFocusOnOpen = false;
|
||
zapPopoverOpenPromise = null;
|
||
return result;
|
||
};
|
||
}
|
||
|
||
const originalDestroy = popover.destroy?.bind(popover);
|
||
if (originalDestroy) {
|
||
popover.destroy = (...args) => {
|
||
originalDestroy(...args);
|
||
if (zapPopover === popover) {
|
||
zapPopover = null;
|
||
zapPopoverTrigger = null;
|
||
}
|
||
zapPopoverOpenPromise = null;
|
||
zapShouldFocusOnOpen = false;
|
||
cacheZapPanelElements(null);
|
||
};
|
||
}
|
||
|
||
zapPopover = popover;
|
||
zapPopoverTrigger = zapButton;
|
||
|
||
const warmZapPopover = () => {
|
||
if (!zapPopover || zapPopover !== popover) {
|
||
return;
|
||
}
|
||
if (zapButton.hasAttribute("hidden")) {
|
||
return;
|
||
}
|
||
if (typeof popover.preload !== "function") {
|
||
return;
|
||
}
|
||
try {
|
||
popover.preload();
|
||
} catch (error) {
|
||
devLogger.warn("[zap] Failed to preload zap controls", error);
|
||
}
|
||
};
|
||
|
||
if (
|
||
typeof popover.preload === "function" &&
|
||
!zapButton.hasAttribute("hidden")
|
||
) {
|
||
if (
|
||
typeof window !== "undefined" &&
|
||
typeof window.requestIdleCallback === "function"
|
||
) {
|
||
window.requestIdleCallback(() => warmZapPopover(), { timeout: 250 });
|
||
} else {
|
||
setTimeout(() => {
|
||
warmZapPopover();
|
||
}, 0);
|
||
}
|
||
}
|
||
|
||
if (zapButton.dataset.zapPopoverWarmBound !== "true") {
|
||
const handleWarm = () => {
|
||
warmZapPopover();
|
||
};
|
||
zapButton.addEventListener("pointerenter", handleWarm, { once: true });
|
||
zapButton.addEventListener("pointerdown", handleWarm, { once: true });
|
||
zapButton.addEventListener("focus", handleWarm, { once: true });
|
||
zapButton.dataset.zapPopoverWarmBound = "true";
|
||
}
|
||
|
||
if (zapButton.dataset.initialized !== "true") {
|
||
zapButton.addEventListener("click", handleZapButtonClick);
|
||
zapButton.dataset.initialized = "true";
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* Renders a Subscribe / Unsubscribe button with an icon,
|
||
* using the primary button token styling and the subscribe icon on the left.
|
||
*/
|
||
function renderSubscribeButton(channelHex) {
|
||
const container = document.getElementById("subscribeBtnArea");
|
||
if (!container) return;
|
||
|
||
const app = getApp();
|
||
const isLoggedIn = Boolean(app?.pubkey);
|
||
|
||
container.classList.remove("hidden");
|
||
const alreadySubscribed =
|
||
isLoggedIn && subscriptions.isSubscribed(channelHex);
|
||
|
||
// Both subscribe/unsubscribe states share the primary styling and icon.
|
||
// If you prefer separate logic for unsub, you can do it here.
|
||
container.innerHTML = `
|
||
<button
|
||
id="subscribeToggleBtn"
|
||
type="button"
|
||
class="btn normal-case rounded hover:opacity-90 focus-visible:bg-primary"
|
||
data-state="${alreadySubscribed ? "subscribed" : "unsubscribed"}"
|
||
>
|
||
<img
|
||
src="assets/svg/subscribe-button-icon.svg"
|
||
alt="Subscribe Icon"
|
||
class="w-5 h-5"
|
||
/>
|
||
<span>${alreadySubscribed ? "Unsubscribe" : "Subscribe"}</span>
|
||
</button>
|
||
`;
|
||
|
||
const toggleBtn = document.getElementById("subscribeToggleBtn");
|
||
if (toggleBtn) {
|
||
toggleBtn.addEventListener("click", async () => {
|
||
const currentApp = getApp();
|
||
if (!currentApp?.pubkey) {
|
||
const loginModal =
|
||
prepareStaticModal({ id: "loginModal" }) ||
|
||
document.getElementById("loginModal");
|
||
|
||
if (
|
||
loginModal &&
|
||
openStaticModal(loginModal, { triggerElement: toggleBtn })
|
||
) {
|
||
setGlobalModalState("login", true);
|
||
} else {
|
||
userLogger.warn(
|
||
"Unable to open login modal for subscription toggle."
|
||
);
|
||
}
|
||
return;
|
||
}
|
||
try {
|
||
if (alreadySubscribed) {
|
||
await subscriptions.removeChannel(channelHex, currentApp.pubkey);
|
||
} else {
|
||
await subscriptions.addChannel(channelHex, currentApp.pubkey);
|
||
}
|
||
// Re-render the button so it toggles state
|
||
renderSubscribeButton(channelHex);
|
||
} catch (err) {
|
||
userLogger.error("Failed to update subscription:", err);
|
||
const permissionErrorCodes = new Set([
|
||
"extension-permission-denied",
|
||
"nip04-missing",
|
||
]);
|
||
const message = permissionErrorCodes.has(err?.code)
|
||
? "Your Nostr extension must support NIP-04 to manage private lists."
|
||
: "Failed to update your subscriptions. Please try again.";
|
||
currentApp?.showError?.(message);
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
function trimProfileString(value) {
|
||
if (typeof value !== "string") {
|
||
return "";
|
||
}
|
||
|
||
return value.trim();
|
||
}
|
||
|
||
function normalizeChannelProfileMetadata(raw = {}) {
|
||
const name =
|
||
trimProfileString(raw.display_name) ||
|
||
trimProfileString(raw.name) ||
|
||
"Unknown User";
|
||
const picture =
|
||
sanitizeProfileMediaUrl(raw.picture) ||
|
||
sanitizeProfileMediaUrl(raw.image) ||
|
||
FALLBACK_CHANNEL_AVATAR;
|
||
const about = trimProfileString(raw.about || raw.bio);
|
||
const website = trimProfileString(raw.website || raw.url);
|
||
const banner =
|
||
sanitizeProfileMediaUrl(
|
||
raw.banner ||
|
||
raw.header ||
|
||
raw.background ||
|
||
raw.cover ||
|
||
raw.cover_image ||
|
||
raw.coverImage
|
||
) || "";
|
||
const lud16 = trimProfileString(raw.lud16);
|
||
const lud06 = trimProfileString(raw.lud06);
|
||
const lightningAddress =
|
||
trimProfileString(raw.lightningAddress) || lud16 || lud06 || "";
|
||
|
||
return {
|
||
name,
|
||
picture,
|
||
about,
|
||
website,
|
||
banner,
|
||
lud16,
|
||
lud06,
|
||
lightningAddress
|
||
};
|
||
}
|
||
|
||
function rememberChannelProfile(pubkey, { profile = {}, event = null } = {}) {
|
||
if (typeof pubkey !== "string" || !pubkey) {
|
||
return false;
|
||
}
|
||
|
||
const hasPayload = hasExplicitChannelProfilePayload(profile);
|
||
if (!hasPayload) {
|
||
touchChannelProfileCacheEntry(pubkey);
|
||
return false;
|
||
}
|
||
|
||
const incomingEventTimestamp =
|
||
typeof event?.created_at === "number" ? event.created_at : 0;
|
||
const existing = channelProfileMetadataCache.get(pubkey);
|
||
const existingEventTimestamp =
|
||
typeof existing?.event?.created_at === "number"
|
||
? existing.event.created_at
|
||
: 0;
|
||
|
||
if (
|
||
existingEventTimestamp &&
|
||
incomingEventTimestamp &&
|
||
incomingEventTimestamp < existingEventTimestamp
|
||
) {
|
||
touchChannelProfileCacheEntry(pubkey);
|
||
return false;
|
||
}
|
||
|
||
channelProfileMetadataCache.set(pubkey, {
|
||
timestamp: Date.now(),
|
||
profile: normalizeChannelProfileMetadata(profile),
|
||
event: event ? { ...event } : null
|
||
});
|
||
|
||
return true;
|
||
}
|
||
|
||
function getCachedChannelProfile(pubkey) {
|
||
const entry = channelProfileMetadataCache.get(pubkey);
|
||
if (!entry) {
|
||
return null;
|
||
}
|
||
|
||
if (
|
||
typeof entry.timestamp !== "number" ||
|
||
Date.now() - entry.timestamp > PROFILE_EVENT_CACHE_TTL_MS
|
||
) {
|
||
channelProfileMetadataCache.delete(pubkey);
|
||
return null;
|
||
}
|
||
|
||
return {
|
||
profile: entry.profile,
|
||
event: entry.event ? { ...entry.event } : null
|
||
};
|
||
}
|
||
|
||
function isValidRelayUrl(url) {
|
||
if (typeof url !== "string") {
|
||
return false;
|
||
}
|
||
|
||
const trimmed = url.trim();
|
||
if (!trimmed) {
|
||
return false;
|
||
}
|
||
|
||
if (!/^wss?:\/\//i.test(trimmed)) {
|
||
return false;
|
||
}
|
||
|
||
if (/\s/.test(trimmed)) {
|
||
return false;
|
||
}
|
||
|
||
try {
|
||
const parsed = new URL(trimmed);
|
||
return Boolean(parsed.hostname);
|
||
} catch (error) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function getCachedChannelVideoEvents(pubkey) {
|
||
const normalized =
|
||
typeof pubkey === "string" ? pubkey.trim().toLowerCase() : "";
|
||
if (!normalized) {
|
||
return [];
|
||
}
|
||
|
||
const collectMatches = (source, iterator) => {
|
||
const matches = [];
|
||
if (!source || typeof iterator !== "function") {
|
||
return matches;
|
||
}
|
||
|
||
iterator((event) => {
|
||
const candidate = event || null;
|
||
if (!candidate) {
|
||
return;
|
||
}
|
||
|
||
const candidatePubkey =
|
||
typeof candidate.pubkey === "string"
|
||
? candidate.pubkey.trim().toLowerCase()
|
||
: "";
|
||
|
||
if (!candidatePubkey || candidatePubkey !== normalized) {
|
||
return;
|
||
}
|
||
|
||
matches.push(candidate);
|
||
});
|
||
|
||
return matches;
|
||
};
|
||
|
||
const rawMatches = collectMatches(nostrClient?.rawEvents, (callback) => {
|
||
if (
|
||
nostrClient?.rawEvents &&
|
||
typeof nostrClient.rawEvents.forEach === "function"
|
||
) {
|
||
nostrClient.rawEvents.forEach((event) => {
|
||
if (event?.kind === 30078) {
|
||
callback(event);
|
||
}
|
||
});
|
||
}
|
||
});
|
||
|
||
if (rawMatches.length) {
|
||
return rawMatches;
|
||
}
|
||
|
||
const processedMatches = collectMatches(
|
||
nostrClient?.allEvents,
|
||
(callback) => {
|
||
if (
|
||
nostrClient?.allEvents &&
|
||
typeof nostrClient.allEvents.forEach === "function"
|
||
) {
|
||
nostrClient.allEvents.forEach((video) => {
|
||
if (video) {
|
||
callback(video);
|
||
}
|
||
});
|
||
}
|
||
}
|
||
);
|
||
|
||
return processedMatches;
|
||
}
|
||
|
||
function normalizeRenderableChannelVideo(entry) {
|
||
if (!entry || typeof entry !== "object") {
|
||
return null;
|
||
}
|
||
|
||
const looksLikeRawEvent =
|
||
typeof entry.content === "string" ||
|
||
Array.isArray(entry.tags) ||
|
||
typeof entry.kind === "number";
|
||
|
||
if (looksLikeRawEvent) {
|
||
const converted = sharedConvertEventToVideo(entry);
|
||
if (converted && !converted.invalid) {
|
||
return converted;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
const looksLikeConvertedVideo =
|
||
typeof entry.id === "string" &&
|
||
typeof entry.title === "string" &&
|
||
typeof entry.pubkey === "string" &&
|
||
Number.isFinite(entry.created_at);
|
||
|
||
if (looksLikeConvertedVideo) {
|
||
if (entry.invalid) {
|
||
return null;
|
||
}
|
||
|
||
return {
|
||
...entry,
|
||
invalid: false,
|
||
};
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function buildRenderableChannelVideos({ events = [], app } = {}) {
|
||
if (!Array.isArray(events) || events.length === 0) {
|
||
return [];
|
||
}
|
||
|
||
const convertedVideos = events
|
||
.map((entry) => normalizeRenderableChannelVideo(entry))
|
||
.filter((vid) => vid && !vid.invalid);
|
||
|
||
if (!convertedVideos.length) {
|
||
return [];
|
||
}
|
||
|
||
const dedupedById = new Map();
|
||
convertedVideos.forEach((video) => {
|
||
if (video?.id && !dedupedById.has(video.id)) {
|
||
dedupedById.set(video.id, video);
|
||
}
|
||
});
|
||
|
||
const uniqueVideos = Array.from(dedupedById.values());
|
||
|
||
const newestByRoot =
|
||
app?.dedupeVideosByRoot?.(uniqueVideos) ??
|
||
dedupeToNewestByRoot(uniqueVideos);
|
||
|
||
let videos = newestByRoot.filter((video) => !video.deleted);
|
||
videos = videos.filter((video) => {
|
||
if (!video || !video.id) {
|
||
return false;
|
||
}
|
||
|
||
if (app?.blacklistedEventIds?.has?.(video.id)) {
|
||
return false;
|
||
}
|
||
|
||
if (!accessControl.canAccess(video)) {
|
||
return false;
|
||
}
|
||
|
||
return true;
|
||
});
|
||
|
||
videos.sort((a, b) => b.created_at - a.created_at);
|
||
return videos;
|
||
}
|
||
|
||
export async function renderChannelVideosFromList({
|
||
videos = [],
|
||
container,
|
||
app,
|
||
loadToken,
|
||
allowEmptyMessage = true
|
||
} = {}) {
|
||
if (!container) {
|
||
if (loadToken === currentVideoLoadToken) {
|
||
clearChannelVideoCardRegistry();
|
||
}
|
||
return false;
|
||
}
|
||
|
||
if (loadToken !== currentVideoLoadToken) {
|
||
return false;
|
||
}
|
||
|
||
clearChannelVideoCardRegistry();
|
||
|
||
if (!Array.isArray(videos) || videos.length === 0) {
|
||
if (allowEmptyMessage) {
|
||
container.dataset.hasChannelVideos = "false";
|
||
container.innerHTML = `<p class="text-muted-strong">No videos to display.</p>`;
|
||
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 = `<p class="text-muted-strong">No videos to display.</p>`;
|
||
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 = `<p class="text-muted-strong">No videos to display.</p>`;
|
||
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 = `
|
||
<div class="py-16 flex justify-center">
|
||
<span class="text-muted animate-pulse">Loading videos…</span>
|
||
</div>
|
||
`;
|
||
}
|
||
}
|
||
|
||
let renderedFromCache = false;
|
||
if (container) {
|
||
const cachedEvents = getCachedChannelVideoEvents(pubkey);
|
||
if (cachedEvents.length) {
|
||
const cachedVideos = buildRenderableChannelVideos({
|
||
events: cachedEvents,
|
||
app
|
||
});
|
||
if (cachedVideos.length) {
|
||
const rendered = await renderChannelVideosFromList({
|
||
videos: cachedVideos,
|
||
container,
|
||
app,
|
||
loadToken,
|
||
allowEmptyMessage: false
|
||
});
|
||
if (rendered) {
|
||
hasVisibleContent = true;
|
||
}
|
||
renderedFromCache = rendered || renderedFromCache;
|
||
}
|
||
}
|
||
}
|
||
|
||
const ensureAccessPromise = accessControl.ensureReady().catch((error) => {
|
||
userLogger.warn(
|
||
"Failed to ensure admin lists were loaded before channel fetch:",
|
||
error
|
||
);
|
||
});
|
||
|
||
try {
|
||
// 1) Build filter for videos from this pubkey
|
||
const filter = {
|
||
kinds: [30078],
|
||
authors: [pubkey],
|
||
"#t": ["video"],
|
||
limit: 200
|
||
};
|
||
|
||
// 2) Collect raw events from all relays
|
||
const events = [];
|
||
const knownRelays = Array.isArray(nostrClient.relays)
|
||
? nostrClient.relays
|
||
: Array.from(nostrClient.relays || []);
|
||
const relayList = knownRelays.filter((url) => isValidRelayUrl(url));
|
||
|
||
if (relayList.length === 0) {
|
||
try {
|
||
const fallbackEvents = await nostrClient.pool.list(
|
||
Array.from(DEFAULT_RELAY_URLS),
|
||
[filter]
|
||
);
|
||
if (Array.isArray(fallbackEvents)) {
|
||
events.push(...fallbackEvents);
|
||
}
|
||
} catch (error) {
|
||
userLogger.error("Relay error (default pool):", error);
|
||
}
|
||
} else {
|
||
const relayPromises = relayList.map((url) =>
|
||
nostrClient.pool.list([url], [filter])
|
||
);
|
||
|
||
const settled = await Promise.allSettled(relayPromises);
|
||
settled.forEach((result, index) => {
|
||
const relayUrl = relayList[index];
|
||
|
||
if (result.status === "fulfilled") {
|
||
const relayEvents = Array.isArray(result.value) ? result.value : [];
|
||
events.push(...relayEvents);
|
||
return;
|
||
}
|
||
|
||
if (result.reason) {
|
||
userLogger.error(`Relay error (${relayUrl}):`, result.reason);
|
||
}
|
||
});
|
||
}
|
||
|
||
await ensureAccessPromise;
|
||
|
||
if (loadToken !== currentVideoLoadToken) {
|
||
return;
|
||
}
|
||
|
||
if (!container) {
|
||
userLogger.warn("channelVideoList element not found in DOM.");
|
||
return;
|
||
}
|
||
|
||
const videos = buildRenderableChannelVideos({ events, app });
|
||
const rendered = await renderChannelVideosFromList({
|
||
videos,
|
||
container,
|
||
app,
|
||
loadToken,
|
||
allowEmptyMessage: !hasVisibleContent
|
||
});
|
||
|
||
if (rendered) {
|
||
hasVisibleContent = true;
|
||
}
|
||
|
||
renderedFromCache = rendered || renderedFromCache;
|
||
} catch (err) {
|
||
if (
|
||
loadToken === currentVideoLoadToken &&
|
||
container &&
|
||
!renderedFromCache &&
|
||
!hasVisibleContent
|
||
) {
|
||
container.dataset.hasChannelVideos = "false";
|
||
container.innerHTML = `
|
||
<p class="text-critical">Failed to load videos. Please try again.</p>
|
||
`;
|
||
}
|
||
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());
|
||
}
|