mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 16:02:26 +00:00
10096 lines
294 KiB
JavaScript
10096 lines
294 KiB
JavaScript
// js/app.js
|
||
|
||
import { nostrClient } from "./nostrClientFacade.js";
|
||
import { recordVideoView } from "./nostrViewEventsFacade.js";
|
||
import { torrentClient } from "./webtorrent.js";
|
||
import {
|
||
isDevMode,
|
||
ADMIN_SUPER_NPUB,
|
||
ADMIN_DM_IMAGE_URL,
|
||
BITVID_WEBSITE_URL,
|
||
MAX_WALLET_DEFAULT_ZAP,
|
||
ALLOW_NSFW_CONTENT,
|
||
} from "./config.js";
|
||
import { accessControl } from "./accessControl.js";
|
||
import { safeDecodeMagnet } from "./magnetUtils.js";
|
||
import { deriveTorrentPlaybackConfig } from "./playbackUtils.js";
|
||
import {
|
||
URL_FIRST_ENABLED,
|
||
getTrustedMuteHideThreshold,
|
||
getTrustedSpamHideThreshold,
|
||
DEFAULT_AUTOPLAY_BLOCK_THRESHOLD,
|
||
DEFAULT_BLUR_THRESHOLD,
|
||
DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD,
|
||
DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD,
|
||
} from "./constants.js";
|
||
import { attachHealthBadges } from "./gridHealth.js";
|
||
import { attachUrlHealthBadges } from "./urlHealthObserver.js";
|
||
import { updateVideoCardSourceVisibility } from "./utils/cardSourceVisibility.js";
|
||
import { collectVideoTags } from "./utils/videoTags.js";
|
||
import { normalizeHashtag } from "./utils/hashtagNormalization.js";
|
||
import { sanitizeProfileMediaUrl } from "./utils/profileMedia.js";
|
||
import { ADMIN_INITIAL_EVENT_BLACKLIST } from "./lists.js";
|
||
import { userBlocks } from "./userBlocks.js";
|
||
import { relayManager } from "./relayManager.js";
|
||
import {
|
||
createFeedEngine,
|
||
createActiveNostrSource,
|
||
createBlacklistFilterStage,
|
||
createDisinterestFilterStage,
|
||
createDedupeByRootStage,
|
||
createExploreDiversitySorter,
|
||
createExploreScorerStage,
|
||
createModerationStage,
|
||
createResolvePostedAtStage,
|
||
createTagPreferenceFilterStage,
|
||
createChronologicalSorter,
|
||
createSubscriptionAuthorsSource,
|
||
registerWatchHistoryFeed,
|
||
} from "./feedEngine/index.js";
|
||
import watchHistoryService from "./watchHistoryService.js";
|
||
import r2Service from "./services/r2Service.js";
|
||
import {
|
||
getVideoNoteErrorMessage,
|
||
normalizeVideoNotePayload,
|
||
} from "./services/videoNotePayload.js";
|
||
import getAuthProvider, {
|
||
providers as authProviders,
|
||
} from "./services/authProviders/index.js";
|
||
import hashtagPreferences, {
|
||
HASHTAG_PREFERENCES_EVENTS,
|
||
} from "./services/hashtagPreferencesService.js";
|
||
import { initQuickR2Upload } from "./r2-quick.js";
|
||
import { getSidebarLoadingMarkup } from "./sidebarLoading.js";
|
||
import { subscriptions } from "./subscriptions.js";
|
||
import {
|
||
refreshActiveChannelVideoGrid,
|
||
clearChannelVideoCardRegistry,
|
||
} from "./channelProfile.js";
|
||
import { isWatchHistoryDebugEnabled } from "./watchHistoryDebug.js";
|
||
import { devLogger, userLogger } from "./utils/logger.js";
|
||
import createPopover from "./ui/overlay/popoverEngine.js";
|
||
import { createVideoSettingsMenuPanel } from "./ui/components/videoMenuRenderers.js";
|
||
import moderationService from "./services/moderationService.js";
|
||
import { sanitizeRelayList } from "./nostr/nip46Client.js";
|
||
import { buildDmRelayListEvent, buildShareEvent } from "./nostrEventSchemas.js";
|
||
import {
|
||
publishEventToRelays,
|
||
assertAnyRelayAccepted,
|
||
} from "./nostrPublish.js";
|
||
import {
|
||
getActiveSigner,
|
||
onActiveSignerChanged,
|
||
} from "./nostrClientRegistry.js";
|
||
import { queueSignEvent } from "./nostr/signRequestQueue.js";
|
||
import { DEFAULT_NIP07_PERMISSION_METHODS } from "./nostr/nip07Permissions.js";
|
||
import {
|
||
initViewCounter,
|
||
subscribeToVideoViewCount,
|
||
unsubscribeFromVideoViewCount,
|
||
formatViewCount,
|
||
ingestLocalViewEvent,
|
||
} from "./viewCounter.js";
|
||
import { splitAndZap as splitAndZapDefault } from "./payments/zapSplit.js";
|
||
import {
|
||
formatAbsoluteTimestamp as formatAbsoluteTimestampUtil,
|
||
formatAbsoluteDateWithOrdinal as formatAbsoluteDateWithOrdinalUtil,
|
||
formatTimeAgo as formatTimeAgoUtil,
|
||
truncateMiddle,
|
||
formatShortNpub,
|
||
} from "./utils/formatters.js";
|
||
import reactionCounter from "./reactionCounter.js";
|
||
import {
|
||
escapeHTML as escapeHtml,
|
||
} from "./utils/domUtils.js";
|
||
import {
|
||
prepareStaticModal,
|
||
openStaticModal,
|
||
closeStaticModal,
|
||
} from "./ui/components/staticModalAccessibility.js";
|
||
import LoginModalController from "./ui/loginModalController.js";
|
||
import TagPreferenceMenuController from "./ui/tagPreferenceMenuController.js";
|
||
import { pointerArrayToKey } from "./utils/pointer.js";
|
||
import resolveVideoPointer, {
|
||
buildVideoAddressPointer,
|
||
} from "./utils/videoPointer.js";
|
||
import { isValidMagnetUri } from "./utils/magnetValidators.js";
|
||
import { dedupeToNewestByRoot } from "./utils/videoDeduper.js";
|
||
import { buildServiceWorkerFallbackStatus } from "./utils/serviceWorkerFallbackMessages.js";
|
||
import { batchFetchProfilesFromRelays } from "./utils/profileBatchFetcher.js";
|
||
import {
|
||
getVideoRootIdentifier,
|
||
applyRootTimestampToVideosMap,
|
||
syncActiveVideoRootTimestamp,
|
||
} from "./utils/videoTimestamps.js";
|
||
import { getDesignSystemMode as getCanonicalDesignSystemMode } from "./designSystem.js";
|
||
import { getHashViewName, setHashView } from "./hashView.js";
|
||
import {
|
||
getPubkey as getStoredPubkey,
|
||
setPubkey as setStoredPubkey,
|
||
getCurrentUserNpub as getStoredCurrentUserNpub,
|
||
setCurrentUserNpub as setStoredCurrentUserNpub,
|
||
getCurrentVideo as getStoredCurrentVideo,
|
||
setCurrentVideo as setStoredCurrentVideo,
|
||
setModalState as setGlobalModalState,
|
||
subscribeToAppStateKey,
|
||
} from "./state/appState.js";
|
||
import {
|
||
getSavedProfiles,
|
||
getActiveProfilePubkey,
|
||
setActiveProfilePubkey as setStoredActiveProfilePubkey,
|
||
setSavedProfiles,
|
||
persistSavedProfiles,
|
||
getProfileCacheMap,
|
||
getCachedUrlHealth as readCachedUrlHealth,
|
||
storeUrlHealth as persistUrlHealth,
|
||
setInFlightUrlProbe,
|
||
getInFlightUrlProbe,
|
||
getModerationOverride,
|
||
setModerationOverride,
|
||
clearModerationOverride,
|
||
loadModerationOverridesFromStorage,
|
||
getModerationSettings,
|
||
getDefaultModerationSettings,
|
||
setModerationSettings,
|
||
resetModerationSettings,
|
||
loadModerationSettingsFromStorage,
|
||
loadDmPrivacySettingsFromStorage,
|
||
URL_PROBE_TIMEOUT_MS,
|
||
urlHealthConstants,
|
||
} from "./state/cache.js";
|
||
import ApplicationBootstrap from "./ui/applicationBootstrap.js";
|
||
import EngagementController from "./ui/engagementController.js";
|
||
import SimilarContentController from "./ui/similarContentController.js";
|
||
import UrlHealthController from "./ui/urlHealthController.js";
|
||
import VideoModalCommentController from "./ui/videoModalCommentController.js";
|
||
import TorrentStatusController from "./ui/torrentStatusController.js";
|
||
import ModerationActionController from "./services/moderationActionController.js";
|
||
import ModerationDecorator from "./services/moderationDecorator.js";
|
||
import { bootstrapTrustedSeeds } from "./services/trustBootstrap.js";
|
||
|
||
const recordVideoViewApi = (...args) => recordVideoView(nostrClient, ...args);
|
||
|
||
const UNSUPPORTED_BTITH_MESSAGE =
|
||
"This magnet link is missing a compatible BitTorrent v1 info hash.";
|
||
|
||
const FALLBACK_THUMBNAIL_SRC = "/assets/jpg/video-thumbnail-fallback.jpg";
|
||
const VIDEO_EVENT_KIND = 30078;
|
||
const HEX64_REGEX = /^[0-9a-f]{64}$/i;
|
||
/**
|
||
* Simple IntersectionObserver-based lazy loader for images (or videos).
|
||
*
|
||
* Usage:
|
||
* const mediaLoader = new MediaLoader();
|
||
* mediaLoader.observe(imgElement);
|
||
*
|
||
* This will load the real image source from `imgElement.dataset.lazy`
|
||
* once the image enters the viewport.
|
||
*/
|
||
class Application {
|
||
get pubkey() {
|
||
return getStoredPubkey();
|
||
}
|
||
|
||
set pubkey(value) {
|
||
setStoredPubkey(value ?? null);
|
||
}
|
||
|
||
get currentUserNpub() {
|
||
return getStoredCurrentUserNpub();
|
||
}
|
||
|
||
set currentUserNpub(value) {
|
||
setStoredCurrentUserNpub(value ?? null);
|
||
}
|
||
|
||
get currentVideo() {
|
||
return getStoredCurrentVideo();
|
||
}
|
||
|
||
set currentVideo(value) {
|
||
setStoredCurrentVideo(value ?? null);
|
||
}
|
||
|
||
constructor({ services = {}, ui = {}, helpers = {}, loadView: viewLoader } = {}) {
|
||
this.loadView = typeof viewLoader === "function" ? viewLoader : null;
|
||
|
||
const bootstrapServices = {
|
||
...services,
|
||
torrentClient,
|
||
deriveTorrentPlaybackConfig,
|
||
isValidMagnetUri,
|
||
authProviders,
|
||
getAuthProvider,
|
||
};
|
||
|
||
this.bootstrapper = new ApplicationBootstrap({
|
||
app: this,
|
||
services: bootstrapServices,
|
||
ui,
|
||
helpers,
|
||
documentRef: typeof document !== "undefined" ? document : null,
|
||
windowRef: typeof window !== "undefined" ? window : null,
|
||
assets: {
|
||
fallbackThumbnailSrc: FALLBACK_THUMBNAIL_SRC,
|
||
unsupportedBtihMessage: UNSUPPORTED_BTITH_MESSAGE,
|
||
},
|
||
});
|
||
|
||
const { modalManager } = this.bootstrapper.initialize();
|
||
this.modalManager = modalManager;
|
||
|
||
this.modalCreatorProfileRequestToken = null;
|
||
this.dmRecipientPubkey = null;
|
||
this.dmRelayHints = new Map();
|
||
|
||
this.commentController = null;
|
||
this.initializeCommentController();
|
||
|
||
this.torrentStatusController = new TorrentStatusController({
|
||
getVideoModal: () => this.videoModal,
|
||
onRemovePoster: (reason) => this.forceRemoveModalPoster(reason),
|
||
});
|
||
|
||
this.unsubscribeFromPubkeyState = subscribeToAppStateKey(
|
||
"pubkey",
|
||
(next, previous) => {
|
||
if (next !== previous) {
|
||
this.renderSavedProfiles();
|
||
}
|
||
}
|
||
);
|
||
|
||
this.unsubscribeFromCurrentUserState = subscribeToAppStateKey(
|
||
"currentUserNpub",
|
||
(next) => {
|
||
if (this.userPubKey) {
|
||
this.userPubKey.textContent = next || "";
|
||
}
|
||
}
|
||
);
|
||
|
||
this.tagPreferenceMenuController = new TagPreferenceMenuController({
|
||
services: { hashtagPreferences },
|
||
callbacks: {
|
||
isLoggedIn: () => this.isUserLoggedIn(),
|
||
getMembership: (tag) => this.getTagPreferenceMembership(tag),
|
||
showError: (msg) => this.showError(msg),
|
||
describeError: (err, opts) =>
|
||
this.describeHashtagPreferencesError(err, opts),
|
||
onPreferenceUpdate: () => {
|
||
this.updateCachedHashtagPreferences();
|
||
this.refreshTagPreferenceUi();
|
||
},
|
||
onMenuOpen: (trigger) => {
|
||
this.closeVideoSettingsMenu({ restoreFocus: false });
|
||
this.closeAllMoreMenus({
|
||
restoreFocus: false,
|
||
skipTrigger: trigger,
|
||
skipView: true,
|
||
});
|
||
},
|
||
getPubkey: () => this.normalizeHexPubkey(this.pubkey),
|
||
},
|
||
helpers: {
|
||
createPopover,
|
||
getDesignSystem: () => this.designSystemContext,
|
||
},
|
||
});
|
||
|
||
this.moderationDecorator = new ModerationDecorator({
|
||
getProfileCacheEntry: (pubkey) => this.getProfileCacheEntry(pubkey),
|
||
});
|
||
|
||
this.urlHealthController = new UrlHealthController({
|
||
state: {
|
||
getCachedUrlHealth: (eventId, url) =>
|
||
this.getCachedUrlHealth(eventId, url),
|
||
storeUrlHealth: (eventId, url, result, ttl) =>
|
||
this.storeUrlHealth(eventId, url, result, ttl),
|
||
getInFlightUrlProbe,
|
||
setInFlightUrlProbe,
|
||
},
|
||
utils: {
|
||
updateVideoCardSourceVisibility,
|
||
},
|
||
logger: devLogger,
|
||
constants: {
|
||
URL_PROBE_TIMEOUT_MS,
|
||
urlHealthConstants,
|
||
},
|
||
callbacks: {
|
||
getVideoListView: () => this.videoListView,
|
||
},
|
||
});
|
||
|
||
this.engagementController = new EngagementController({
|
||
services: {
|
||
nostrClient,
|
||
},
|
||
ui: {
|
||
showError: (msg) => this.showError(msg),
|
||
showSuccess: (msg) => this.showSuccess(msg),
|
||
showStatus: (msg, opts) => this.showStatus(msg, opts),
|
||
},
|
||
state: {
|
||
getCurrentVideo: () => this.currentVideo,
|
||
getCurrentVideoPointer: () => this.currentVideoPointer,
|
||
},
|
||
});
|
||
|
||
this.initializeModerationActionController();
|
||
this.initializeSimilarContentController();
|
||
|
||
this.handleShareNostrSignerChange = () => {
|
||
this.updateShareNostrAuthState({ reason: "signer-change" });
|
||
};
|
||
onActiveSignerChanged(this.handleShareNostrSignerChange);
|
||
this.updateShareNostrAuthState({ reason: "init" });
|
||
}
|
||
|
||
get modalVideo() {
|
||
return this.videoModal ? this.videoModal.getVideoElement() : null;
|
||
}
|
||
|
||
set modalVideo(videoElement) {
|
||
if (this.videoModal) {
|
||
this.videoModal.setVideoElement(videoElement);
|
||
}
|
||
}
|
||
|
||
hideModal() {
|
||
if (this.videoModal) {
|
||
this.videoModal.close();
|
||
}
|
||
// Also clear the active modal state if tracked elsewhere, though videoModal.close() usually handles it.
|
||
}
|
||
|
||
loadSavedProfilesFromStorage() {
|
||
const result = this.authService.loadSavedProfilesFromStorage();
|
||
this.renderSavedProfiles();
|
||
return result;
|
||
}
|
||
|
||
syncSavedProfileFromCache(pubkey, { persist = false } = {}) {
|
||
const updated = this.authService.syncSavedProfileFromCache(pubkey, {
|
||
persist,
|
||
});
|
||
if (updated) {
|
||
this.renderSavedProfiles();
|
||
}
|
||
return updated;
|
||
}
|
||
|
||
loadProfileCacheFromStorage() {
|
||
this.authService.loadProfileCacheFromStorage();
|
||
}
|
||
|
||
persistProfileCacheToStorage() {
|
||
this.authService.persistProfileCache();
|
||
}
|
||
|
||
getProfileCacheEntry(pubkey) {
|
||
return this.authService.getProfileCacheEntry(pubkey);
|
||
}
|
||
|
||
setProfileCacheEntry(pubkey, profile) {
|
||
return this.authService.setProfileCacheEntry(pubkey, profile);
|
||
}
|
||
|
||
persistActiveProfileSelection(pubkey, { persist = true } = {}) {
|
||
setStoredActiveProfilePubkey(pubkey, { persist });
|
||
this.renderSavedProfiles();
|
||
}
|
||
|
||
prepareForViewLoad() {
|
||
clearChannelVideoCardRegistry();
|
||
if (this.videoListView) {
|
||
this.videoListView.destroy();
|
||
}
|
||
|
||
this.videoList = null;
|
||
if (this.videoListPopularTags) {
|
||
this.videoListPopularTags.textContent = "";
|
||
this.videoListPopularTags.hidden = true;
|
||
}
|
||
this.videoListPopularTags = null;
|
||
|
||
if (this.mediaLoader && typeof this.mediaLoader.disconnect === "function") {
|
||
this.mediaLoader.disconnect();
|
||
}
|
||
}
|
||
|
||
forceRefreshAllProfiles() {
|
||
// 1) Grab the newest set of videos from nostrClient
|
||
const activeVideos = nostrClient.getActiveVideos();
|
||
|
||
// 2) Build a unique set of pubkeys
|
||
const uniqueAuthors = new Set(activeVideos.map((v) => v.pubkey));
|
||
|
||
// 3) For each author, fetchAndRenderProfile with forceRefresh = true
|
||
for (const authorPubkey of uniqueAuthors) {
|
||
this.authService.fetchAndRenderProfile(authorPubkey, true);
|
||
}
|
||
}
|
||
|
||
getCurrentUserNpub() {
|
||
return this.currentUserNpub;
|
||
}
|
||
|
||
isAuthorBlocked(pubkey) {
|
||
try {
|
||
if (userBlocks && typeof userBlocks.isBlocked === "function") {
|
||
if (userBlocks.isBlocked(pubkey)) {
|
||
return true;
|
||
}
|
||
}
|
||
|
||
if (!this.isUserLoggedIn()) {
|
||
const normalized = this.normalizeHexPubkey(pubkey);
|
||
if (
|
||
normalized &&
|
||
moderationService &&
|
||
typeof moderationService.getTrustedMutersForAuthor === "function"
|
||
) {
|
||
const muters = moderationService.getTrustedMutersForAuthor(normalized);
|
||
const threshold = getTrustedMuteHideThreshold();
|
||
if (Array.isArray(muters) && muters.length >= threshold) {
|
||
return true;
|
||
}
|
||
}
|
||
}
|
||
} catch (error) {
|
||
devLogger.warn("[Application] Failed to evaluate block status:", error);
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
async init() {
|
||
try {
|
||
if (typeof this.loadView !== "function") {
|
||
const module = await import("./viewManager.js");
|
||
this.loadView = module?.loadView || null;
|
||
}
|
||
|
||
// Force update of any registered service workers to ensure latest code is used.
|
||
if ("serviceWorker" in navigator) {
|
||
navigator.serviceWorker.getRegistrations().then((registrations) => {
|
||
registrations.forEach((registration) => registration.update());
|
||
});
|
||
}
|
||
|
||
this.authService.hydrateFromStorage();
|
||
this.renderSavedProfiles();
|
||
|
||
loadModerationOverridesFromStorage();
|
||
loadModerationSettingsFromStorage();
|
||
loadDmPrivacySettingsFromStorage();
|
||
this.moderationSettings = this.normalizeModerationSettings(
|
||
getModerationSettings(),
|
||
);
|
||
|
||
const videoModalPromise = this.videoModal.load().then(() => {
|
||
const modalRoot = this.videoModal.getRoot();
|
||
if (modalRoot) {
|
||
this.attachMoreMenuHandlers(modalRoot);
|
||
}
|
||
if (
|
||
this.videoModal &&
|
||
typeof this.videoModal.addEventListener === "function"
|
||
) {
|
||
this.videoModal.addEventListener("video:share-nostr", (event) => {
|
||
this.openShareNostrModal({
|
||
video: event?.detail?.video || null,
|
||
triggerElement: event?.detail?.trigger || null,
|
||
});
|
||
});
|
||
|
||
this.videoModal.addEventListener("video:copy-cdn", (event) => {
|
||
const video = event?.detail?.video || this.currentVideo;
|
||
const url = video?.url || "";
|
||
if (!url) {
|
||
this.showError("No CDN link available to copy.");
|
||
return;
|
||
}
|
||
navigator.clipboard
|
||
.writeText(url)
|
||
.then(() => this.showSuccess("CDN link copied to clipboard!"))
|
||
.catch(() => this.showError("Failed to copy CDN link."));
|
||
});
|
||
|
||
this.videoModal.addEventListener("video:copy-magnet", () => {
|
||
this.handleCopyMagnet();
|
||
});
|
||
|
||
this.videoModal.addEventListener("playback:switch-source", (event) => {
|
||
const detail = event?.detail || {};
|
||
const { source } = detail;
|
||
if (!source) {
|
||
return;
|
||
}
|
||
const modalVideo = detail?.video || null;
|
||
const fallbackVideo = this.currentVideo || null;
|
||
const video = {
|
||
...(fallbackVideo || {}),
|
||
...(modalVideo || {}),
|
||
};
|
||
const urlCandidate =
|
||
typeof video.url === "string" ? video.url.trim() : "";
|
||
const magnetCandidate =
|
||
typeof video.magnet === "string" ? video.magnet.trim() : "";
|
||
|
||
if (!modalVideo && !fallbackVideo) {
|
||
devLogger.warn("[app] Playback source switch missing video data.");
|
||
return;
|
||
}
|
||
|
||
const magnetAvailable = Boolean(magnetCandidate);
|
||
const cachedStreamHealth =
|
||
video?.id && this.streamHealthSnapshots instanceof Map
|
||
? this.streamHealthSnapshots.get(video.id)
|
||
: null;
|
||
const cachedPeers = Number.isFinite(cachedStreamHealth?.peers)
|
||
? cachedStreamHealth.peers
|
||
: null;
|
||
const hasActivePeers =
|
||
cachedPeers === null ? null : cachedPeers > 0;
|
||
const cachedUrlHealth =
|
||
video?.id && urlCandidate
|
||
? this.getCachedUrlHealth(video.id, urlCandidate)
|
||
: null;
|
||
const cdnUnavailable =
|
||
!urlCandidate ||
|
||
["offline", "timeout"].includes(cachedUrlHealth?.status);
|
||
|
||
if (source === "torrent" && !magnetAvailable) {
|
||
userLogger.warn(
|
||
"[app] Unable to switch to torrent playback: missing magnet.",
|
||
);
|
||
this.showError(
|
||
"Torrent playback is unavailable for this video. No magnet was provided.",
|
||
);
|
||
return;
|
||
}
|
||
|
||
if (source === "torrent" && hasActivePeers === false) {
|
||
userLogger.warn(
|
||
"[app] Switching to torrent playback despite 0 active peers detected.",
|
||
);
|
||
this.showStatus(
|
||
"Warning: No peers detected. Playback may fail or stall.",
|
||
{ autoHideMs: 5000 }
|
||
);
|
||
// Proceed anyway
|
||
}
|
||
|
||
if (source === "url" && cdnUnavailable) {
|
||
userLogger.warn(
|
||
"[app] Unable to switch to CDN playback: URL unavailable.",
|
||
);
|
||
this.showError(
|
||
"CDN playback is unavailable right now, staying on the torrent stream.",
|
||
);
|
||
return;
|
||
}
|
||
|
||
if (this.playSource && source === this.playSource) {
|
||
return;
|
||
}
|
||
|
||
this.playVideoWithFallback({
|
||
url: urlCandidate,
|
||
magnet: magnetCandidate,
|
||
forcedSource: source,
|
||
}).catch((error) => {
|
||
devLogger.warn(
|
||
"[app] Failed to switch playback source:",
|
||
error,
|
||
);
|
||
});
|
||
});
|
||
}
|
||
});
|
||
|
||
const uploadModalPromise = this.uploadModal
|
||
.load()
|
||
.catch((error) => {
|
||
devLogger.error("initUploadModal failed:", error);
|
||
this.showError(`Failed to initialize upload modal: ${error.message}`);
|
||
})
|
||
.finally(() => {
|
||
initQuickR2Upload(this);
|
||
});
|
||
|
||
const editModalPromise = this.editModal.load().catch((error) => {
|
||
devLogger.error("Failed to load edit modal:", error);
|
||
this.showError(`Failed to initialize edit modal: ${error.message}`);
|
||
});
|
||
|
||
const profileModalPromise = this.profileController
|
||
? this.profileController
|
||
.load()
|
||
.then(() => {
|
||
try {
|
||
this.renderSavedProfiles();
|
||
} catch (error) {
|
||
devLogger.warn(
|
||
"[profileModal] Failed to render saved profiles after load:",
|
||
error,
|
||
);
|
||
}
|
||
|
||
try {
|
||
this.profileController.refreshWalletPaneState();
|
||
} catch (error) {
|
||
devLogger.warn(
|
||
"[profileModal] Failed to refresh wallet pane after load:",
|
||
error,
|
||
);
|
||
}
|
||
return true;
|
||
})
|
||
.catch((error) => {
|
||
devLogger.error("Failed to load profile modal:", error);
|
||
return false;
|
||
})
|
||
: Promise.resolve(false);
|
||
|
||
const modalBootstrapPromise = Promise.all([
|
||
videoModalPromise,
|
||
uploadModalPromise,
|
||
editModalPromise,
|
||
profileModalPromise,
|
||
]);
|
||
|
||
// Initialize the pool early to unblock bootstrapTrustedSeeds,
|
||
// but do NOT await the full connection process here.
|
||
try {
|
||
await nostrClient.ensurePool();
|
||
} catch (poolError) {
|
||
devLogger.warn("[app.init()] Pool ensure failed:", poolError);
|
||
}
|
||
|
||
// Kick off relay connection in the background.
|
||
const nostrInitPromise = nostrClient.init().catch((err) => {
|
||
devLogger.warn("[app.init()] Background nostrClient.init failed:", err);
|
||
});
|
||
|
||
await modalBootstrapPromise;
|
||
|
||
try {
|
||
await bootstrapTrustedSeeds();
|
||
} catch (error) {
|
||
devLogger.warn("[app.init()] Trusted seed bootstrap failed:", error);
|
||
}
|
||
|
||
try {
|
||
initViewCounter({ nostrClient });
|
||
} catch (error) {
|
||
devLogger.warn("Failed to initialize view counter:", error);
|
||
}
|
||
|
||
const accessControlPromise = accessControl
|
||
.refresh()
|
||
.then(() => {
|
||
if (
|
||
accessControl.lastError &&
|
||
accessControl.lastError?.code === "nostr-unavailable"
|
||
) {
|
||
devLogger.warn(
|
||
"[app.init()] Access control refresh should not run before nostrClient.init()",
|
||
accessControl.lastError
|
||
);
|
||
}
|
||
})
|
||
.catch((error) => {
|
||
devLogger.warn(
|
||
"Failed to refresh admin lists after connecting to Nostr:",
|
||
error
|
||
);
|
||
});
|
||
|
||
const adminPanePromise = this.profileController
|
||
? Promise.resolve()
|
||
.then(() => this.profileController.refreshAdminPaneState())
|
||
.catch((error) => {
|
||
devLogger.warn(
|
||
"Failed to update admin pane after connecting to Nostr:",
|
||
error,
|
||
);
|
||
})
|
||
: Promise.resolve(null);
|
||
|
||
// await Promise.all([accessControlPromise, adminPanePromise]);
|
||
|
||
const syncSessionActorBlacklist = async (trigger) => {
|
||
if (this.pubkey) {
|
||
return;
|
||
}
|
||
|
||
const sessionActorPubkey = nostrClient.sessionActor?.pubkey;
|
||
if (!sessionActorPubkey) {
|
||
return;
|
||
}
|
||
|
||
const blacklist = accessControl.getBlacklist();
|
||
try {
|
||
await userBlocks.seedBaselineDelta(
|
||
sessionActorPubkey,
|
||
Array.from(blacklist || []),
|
||
);
|
||
} catch (error) {
|
||
devLogger.warn(
|
||
`[app.init()] Failed to sync session actor blacklist${
|
||
trigger ? ` (${trigger})` : ""
|
||
}:`,
|
||
error,
|
||
);
|
||
}
|
||
};
|
||
|
||
const handleSessionActorReady = async ({ pubkey, reason } = {}) => {
|
||
if (this.pubkey) {
|
||
return;
|
||
}
|
||
|
||
const normalizedPubkey = this.normalizeHexPubkey(pubkey);
|
||
if (!normalizedPubkey) {
|
||
return;
|
||
}
|
||
|
||
const triggerLabel = reason ? `session-actor-${reason}` : "session-actor";
|
||
await syncSessionActorBlacklist(triggerLabel);
|
||
|
||
const refreshReason = "session-actor-ready";
|
||
if (typeof this.refreshVisibleModerationUi === "function") {
|
||
try {
|
||
this.refreshVisibleModerationUi({ reason: refreshReason });
|
||
} catch (error) {
|
||
devLogger.warn(
|
||
"[app.init()] Failed to refresh moderation UI after session actor:",
|
||
error,
|
||
);
|
||
}
|
||
} else {
|
||
this.refreshAllVideoGrids({
|
||
reason: refreshReason,
|
||
forceMainReload: true,
|
||
}).catch((error) => {
|
||
devLogger.warn(
|
||
"[app.init()] Failed to refresh video grids after session actor:",
|
||
error,
|
||
);
|
||
});
|
||
}
|
||
};
|
||
|
||
if (typeof nostrClient.onSessionActorChange === "function") {
|
||
nostrClient.onSessionActorChange((detail) => {
|
||
handleSessionActorReady(detail).catch((error) => {
|
||
devLogger.warn(
|
||
"[app.init()] Failed to process session actor change:",
|
||
error,
|
||
);
|
||
});
|
||
});
|
||
}
|
||
|
||
await syncSessionActorBlacklist("post-refresh");
|
||
|
||
if (typeof accessControl.onBlacklistChange === "function") {
|
||
accessControl.onBlacklistChange(() => {
|
||
syncSessionActorBlacklist("blacklist-change");
|
||
if (this.isUserLoggedIn()) {
|
||
return;
|
||
}
|
||
|
||
const refreshReason = "admin-blacklist-change";
|
||
this.refreshAllVideoGrids({
|
||
reason: refreshReason,
|
||
forceMainReload: true,
|
||
}).catch((error) => {
|
||
devLogger.warn(
|
||
"[app.init()] Failed to refresh video grids after admin blacklist change:",
|
||
error,
|
||
);
|
||
});
|
||
|
||
if (typeof this.refreshVisibleModerationUi === "function") {
|
||
try {
|
||
this.refreshVisibleModerationUi({ reason: refreshReason });
|
||
} catch (error) {
|
||
devLogger.warn(
|
||
"[app.init()] Failed to refresh moderation UI after admin blacklist change:",
|
||
error,
|
||
);
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
if (typeof accessControl.onWhitelistChange === "function") {
|
||
accessControl.onWhitelistChange(() => {
|
||
const refreshReason = "admin-whitelist-change";
|
||
this.refreshAllVideoGrids({
|
||
reason: refreshReason,
|
||
forceMainReload: true,
|
||
}).catch((error) => {
|
||
devLogger.warn(
|
||
"[app.init()] Failed to refresh video grids after admin whitelist change:",
|
||
error,
|
||
);
|
||
});
|
||
});
|
||
}
|
||
|
||
// Grab the "Subscriptions" link by its id in the sidebar
|
||
this.subscriptionsLink = document.getElementById("subscriptionsLink");
|
||
|
||
this.syncAuthUiState();
|
||
|
||
if (typeof window !== "undefined") {
|
||
window.addEventListener("bitvid:auth-changed", (event) => {
|
||
const detail = event.detail || {};
|
||
const previousPubkey = detail.previousPubkey;
|
||
|
||
if (previousPubkey && typeof storageService !== "undefined" && typeof storageService.lock === "function") {
|
||
storageService.lock(previousPubkey);
|
||
}
|
||
|
||
if (this.uploadModal && typeof this.uploadModal.refreshState === "function") {
|
||
this.uploadModal.refreshState();
|
||
}
|
||
});
|
||
}
|
||
|
||
const savedPubKey = this.activeProfilePubkey;
|
||
if (savedPubKey) {
|
||
// Auto-login if a pubkey was saved
|
||
try {
|
||
await this.authService.login(savedPubKey, { persistActive: false });
|
||
} catch (error) {
|
||
devLogger.error("Auto-login failed:", error);
|
||
if (error && error.code === "site-lockdown") {
|
||
const message = this.describeLoginError(
|
||
error,
|
||
"Auto-login failed. Please sign in again once lockdown ends.",
|
||
);
|
||
this.showStatus(message, { autoHideMs: 12000 });
|
||
}
|
||
}
|
||
}
|
||
|
||
// 5. Setup general event listeners
|
||
this.setupEventListeners();
|
||
|
||
const watchHistoryInitPromise =
|
||
this.watchHistoryTelemetry?.initPreferenceSync?.().catch((error) => {
|
||
devLogger.warn(
|
||
"[app.init()] Failed to initialize watch history metadata sync:",
|
||
error
|
||
);
|
||
}) || Promise.resolve();
|
||
|
||
// 6) Load the default view ONLY if there's no #view= already
|
||
if (!window.location.hash || !window.location.hash.startsWith("#view=")) {
|
||
devLogger.log(
|
||
"[app.init()] No #view= in the URL, loading default home view"
|
||
);
|
||
const defaultView = this.isUserLoggedIn()
|
||
? "views/for-you.html"
|
||
: "views/most-recent-videos.html";
|
||
if (typeof this.loadView === "function") {
|
||
await Promise.all([
|
||
this.loadView(defaultView),
|
||
watchHistoryInitPromise,
|
||
]);
|
||
} else {
|
||
await watchHistoryInitPromise;
|
||
}
|
||
} else {
|
||
devLogger.log(
|
||
"[app.init()] Found hash:",
|
||
window.location.hash,
|
||
"so skipping default load"
|
||
);
|
||
await watchHistoryInitPromise;
|
||
}
|
||
|
||
// 7. Once loaded, get a reference to #videoList
|
||
this.mountVideoListView();
|
||
|
||
// 8. Subscribe or fetch videos
|
||
await this.loadVideos();
|
||
|
||
// 9. Check URL ?v= param
|
||
this.checkUrlParams();
|
||
|
||
} catch (error) {
|
||
devLogger.error("Init failed:", error);
|
||
this.showError("Failed to connect to Nostr relay");
|
||
}
|
||
}
|
||
|
||
goToProfile(pubkey) {
|
||
if (typeof pubkey !== "string") {
|
||
this.showError("No creator info available.");
|
||
return;
|
||
}
|
||
|
||
let candidate = pubkey.trim();
|
||
if (!candidate) {
|
||
this.showError("No creator info available.");
|
||
return;
|
||
}
|
||
|
||
if (candidate.startsWith("nostr:")) {
|
||
candidate = candidate.slice("nostr:".length);
|
||
}
|
||
|
||
const normalizedHex = this.normalizeHexPubkey(candidate);
|
||
const npub = normalizedHex ? this.safeEncodeNpub(normalizedHex) : null;
|
||
|
||
if (!npub) {
|
||
devLogger.warn(
|
||
"[Application] Invalid pubkey for profile navigation:",
|
||
candidate,
|
||
);
|
||
this.showError("Invalid creator profile.");
|
||
return;
|
||
}
|
||
|
||
window.location.hash = `#view=channel-profile&npub=${npub}`;
|
||
}
|
||
|
||
openCreatorChannel() {
|
||
if (!this.currentVideo || !this.currentVideo.pubkey) {
|
||
this.showError("No creator info available.");
|
||
return;
|
||
}
|
||
|
||
try {
|
||
// Encode the hex pubkey to npub
|
||
const npub = window.NostrTools.nip19.npubEncode(this.currentVideo.pubkey);
|
||
|
||
// Close the video modal
|
||
this.hideModal();
|
||
|
||
// Switch to channel profile view
|
||
window.location.hash = `#view=channel-profile&npub=${npub}`;
|
||
} catch (err) {
|
||
devLogger.error("Failed to open creator channel:", err);
|
||
this.showError("Could not open channel.");
|
||
}
|
||
}
|
||
|
||
openWalletPane() {
|
||
if (
|
||
!this.profileController ||
|
||
typeof this.profileController.showWalletPane !== "function"
|
||
) {
|
||
devLogger.warn(
|
||
"[Application] Wallet pane requested before profile controller initialized.",
|
||
);
|
||
return Promise.resolve(false);
|
||
}
|
||
|
||
try {
|
||
const result = this.profileController.showWalletPane();
|
||
if (result && typeof result.then === "function") {
|
||
return result.catch((error) => {
|
||
devLogger.error("[Application] Failed to open wallet pane:", error);
|
||
throw error;
|
||
});
|
||
}
|
||
return Promise.resolve(result);
|
||
} catch (error) {
|
||
devLogger.error("[Application] Failed to open wallet pane:", error);
|
||
return Promise.reject(error);
|
||
}
|
||
}
|
||
|
||
dispatchAuthChange(detail = {}) {
|
||
if (typeof window === "undefined" || typeof window.dispatchEvent !== "function") {
|
||
return false;
|
||
}
|
||
|
||
try {
|
||
const payload = { ...(typeof detail === "object" && detail ? detail : {}) };
|
||
window.dispatchEvent(new CustomEvent("bitvid:auth-changed", { detail: payload }));
|
||
return true;
|
||
} catch (error) {
|
||
devLogger.warn("[Application] Failed to dispatch auth change event:", error);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
normalizeModalTrigger(candidate) {
|
||
if (!candidate) {
|
||
return null;
|
||
}
|
||
const doc =
|
||
(this.videoModal && this.videoModal.document) ||
|
||
(typeof document !== "undefined" ? document : null);
|
||
const isElement =
|
||
typeof candidate === "object" &&
|
||
candidate !== null &&
|
||
typeof candidate.nodeType === "number" &&
|
||
candidate.nodeType === 1 &&
|
||
typeof candidate.focus === "function";
|
||
if (!isElement) {
|
||
return null;
|
||
}
|
||
if (doc && typeof doc.contains === "function" && !doc.contains(candidate)) {
|
||
return null;
|
||
}
|
||
return candidate;
|
||
}
|
||
|
||
setLastModalTrigger(candidate) {
|
||
this.lastModalTrigger = this.normalizeModalTrigger(candidate);
|
||
return this.lastModalTrigger;
|
||
}
|
||
|
||
getDesignSystemMode() {
|
||
return getCanonicalDesignSystemMode();
|
||
}
|
||
|
||
isDesignSystemNew() {
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* Show the modal and set the "Please stand by" poster on the video.
|
||
*/
|
||
async showModalWithPoster(video = this.currentVideo, options = {}) {
|
||
if (!this.videoModal) {
|
||
return null;
|
||
}
|
||
|
||
if (Object.prototype.hasOwnProperty.call(options || {}, "trigger")) {
|
||
this.setLastModalTrigger(options.trigger);
|
||
}
|
||
|
||
const targetVideo = video || this.currentVideo;
|
||
if (!targetVideo) {
|
||
this.log(
|
||
"[Application] Skipping video modal open; no target video is available.",
|
||
);
|
||
return null;
|
||
}
|
||
|
||
try {
|
||
const { root } = await this.ensureVideoModalReady({
|
||
ensureVideoElement: true,
|
||
});
|
||
|
||
if (!this.videoModal) {
|
||
return root || null;
|
||
}
|
||
|
||
this.videoModal.open(targetVideo, {
|
||
triggerElement: this.lastModalTrigger,
|
||
});
|
||
this.applyModalLoadingPoster();
|
||
|
||
return (
|
||
root ||
|
||
(typeof this.videoModal.getRoot === "function"
|
||
? this.videoModal.getRoot()
|
||
: null)
|
||
);
|
||
} catch (error) {
|
||
devLogger.error(
|
||
"[Application] Failed to open the video modal before playback:",
|
||
error
|
||
);
|
||
this.showError("Could not open the video player. Please try again.");
|
||
return null;
|
||
}
|
||
}
|
||
|
||
applyModalLoadingPoster() {
|
||
if (!this.videoModal) {
|
||
return;
|
||
}
|
||
this.videoModal.applyLoadingPoster();
|
||
}
|
||
|
||
forceRemoveModalPoster(reason = "manual-clear") {
|
||
if (!this.videoModal) {
|
||
return false;
|
||
}
|
||
return this.videoModal.forceRemovePoster(reason);
|
||
}
|
||
|
||
async ensureVideoModalReady({ ensureVideoElement = false } = {}) {
|
||
if (!this.videoModal) {
|
||
throw new Error("Video modal instance is not available.");
|
||
}
|
||
|
||
const getRoot = () =>
|
||
typeof this.videoModal.getRoot === "function"
|
||
? this.videoModal.getRoot()
|
||
: null;
|
||
const getVideoElement = () =>
|
||
typeof this.videoModal.getVideoElement === "function"
|
||
? this.videoModal.getVideoElement()
|
||
: null;
|
||
|
||
const existingRoot = getRoot();
|
||
const existingVideoElement = getVideoElement();
|
||
const rootConnected = Boolean(existingRoot && existingRoot.isConnected);
|
||
const hasVideoElement = Boolean(existingVideoElement);
|
||
const videoConnected = Boolean(
|
||
existingVideoElement && existingVideoElement.isConnected,
|
||
);
|
||
|
||
const needsRehydrate =
|
||
!rootConnected || !hasVideoElement || !videoConnected;
|
||
|
||
if (!needsRehydrate) {
|
||
this.modalVideo = existingVideoElement;
|
||
|
||
if (
|
||
existingVideoElement &&
|
||
this.videoModal &&
|
||
typeof this.videoModal.setVideoElement === "function"
|
||
) {
|
||
const modalVideo =
|
||
typeof this.videoModal.getVideoElement === "function"
|
||
? this.videoModal.getVideoElement()
|
||
: null;
|
||
if (modalVideo !== existingVideoElement) {
|
||
this.videoModal.setVideoElement(existingVideoElement);
|
||
}
|
||
}
|
||
|
||
return {
|
||
root: existingRoot,
|
||
videoElement: existingVideoElement,
|
||
};
|
||
}
|
||
|
||
if (!videoConnected) {
|
||
this.modalVideo = null;
|
||
}
|
||
|
||
if (!this.videoModalReadyPromise) {
|
||
if (typeof this.videoModal.load !== "function") {
|
||
throw new Error("Video modal does not expose a load() method.");
|
||
}
|
||
this.videoModalReadyPromise = Promise.resolve(this.videoModal.load());
|
||
}
|
||
|
||
try {
|
||
await this.videoModalReadyPromise;
|
||
} catch (error) {
|
||
this.videoModalReadyPromise = null;
|
||
throw error;
|
||
}
|
||
|
||
this.videoModalReadyPromise = null;
|
||
|
||
const readyRoot = getRoot();
|
||
const readyVideoElement = getVideoElement();
|
||
const readyVideoConnected = Boolean(
|
||
readyVideoElement && readyVideoElement.isConnected,
|
||
);
|
||
|
||
if (readyVideoConnected) {
|
||
this.modalVideo = readyVideoElement;
|
||
if (
|
||
this.videoModal &&
|
||
typeof this.videoModal.setVideoElement === "function"
|
||
) {
|
||
this.videoModal.setVideoElement(readyVideoElement);
|
||
}
|
||
} else {
|
||
this.modalVideo = null;
|
||
}
|
||
|
||
if (ensureVideoElement && !readyVideoConnected) {
|
||
throw new Error(
|
||
"Video modal video element is missing after load().",
|
||
);
|
||
}
|
||
|
||
return {
|
||
root: readyRoot,
|
||
videoElement: readyVideoConnected ? readyVideoElement : null,
|
||
};
|
||
}
|
||
|
||
initializeSimilarContentController() {
|
||
if (this.similarContentController) {
|
||
return;
|
||
}
|
||
|
||
this.similarContentController = new SimilarContentController({
|
||
services: {
|
||
nostrClient,
|
||
},
|
||
callbacks: {
|
||
isAuthorBlocked: (pubkey) => this.isAuthorBlocked(pubkey),
|
||
decorateVideoModeration: (video) => this.decorateVideoModeration(video),
|
||
decorateVideoCreatorIdentity: (video) => this.decorateVideoCreatorIdentity(video),
|
||
},
|
||
ui: {
|
||
videoModal: this.videoModal,
|
||
},
|
||
state: {
|
||
getVideoListView: () => this.videoListView,
|
||
getVideosMap: () => this.videosMap,
|
||
},
|
||
helpers: {
|
||
getKnownVideoPostedAt: (video) => this.getKnownVideoPostedAt(video),
|
||
buildShareUrlFromEventId: (id) => this.buildShareUrlFromEventId(id),
|
||
formatTimeAgo: (ts) => this.formatTimeAgo(ts),
|
||
}
|
||
});
|
||
}
|
||
|
||
extractDTagValue(tags) {
|
||
if (this.similarContentController) {
|
||
return this.similarContentController.extractDTagValue(tags);
|
||
}
|
||
// Fallback if controller not initialized (though it should be)
|
||
if (!Array.isArray(tags)) {
|
||
return "";
|
||
}
|
||
for (const tag of tags) {
|
||
if (!Array.isArray(tag) || tag.length < 2) {
|
||
continue;
|
||
}
|
||
if (tag[0] === "d" && typeof tag[1] === "string") {
|
||
return tag[1];
|
||
}
|
||
}
|
||
return "";
|
||
}
|
||
|
||
deriveVideoPointerInfo(video) {
|
||
if (this.similarContentController) {
|
||
return this.similarContentController.deriveVideoPointerInfo(video);
|
||
}
|
||
// Fallback if controller not initialized
|
||
if (!video || typeof video !== "object") {
|
||
return null;
|
||
}
|
||
|
||
const dTagValue = (this.extractDTagValue(video.tags) || "").trim();
|
||
|
||
return resolveVideoPointer({
|
||
kind: video.kind,
|
||
pubkey: video.pubkey,
|
||
videoRootId: video.videoRootId,
|
||
dTag: dTagValue,
|
||
fallbackEventId: video.id,
|
||
relay: video.relay,
|
||
});
|
||
}
|
||
|
||
computeSimilarContentCandidates(options = {}) {
|
||
if (this.similarContentController) {
|
||
return this.similarContentController.computeCandidates({
|
||
activeVideo: options.activeVideo || this.currentVideo,
|
||
maxItems: options.maxItems,
|
||
});
|
||
}
|
||
return [];
|
||
}
|
||
|
||
updateModalSimilarContent({ activeVideo = this.currentVideo, maxItems } = {}) {
|
||
if (this.similarContentController) {
|
||
this.similarContentController.ui.videoModal = this.videoModal;
|
||
this.similarContentController.updateModal({ activeVideo, maxItems });
|
||
}
|
||
}
|
||
|
||
formatViewCountLabel(total) {
|
||
const value = Number.isFinite(total) ? Number(total) : 0;
|
||
const label = value === 1 ? "view" : "views";
|
||
return `${formatViewCount(value)} ${label}`;
|
||
}
|
||
|
||
pruneDetachedViewCountElements() {
|
||
if (this.videoListView) {
|
||
this.videoListView.pruneDetachedViewCountElements();
|
||
}
|
||
}
|
||
|
||
teardownAllViewCountSubscriptions() {
|
||
if (this.videoListView) {
|
||
this.videoListView.teardownAllViewCountSubscriptions();
|
||
}
|
||
}
|
||
|
||
teardownModalViewCountSubscription() {
|
||
if (typeof this.modalViewCountUnsub === "function") {
|
||
try {
|
||
this.modalViewCountUnsub();
|
||
} catch (error) {
|
||
devLogger.warn("[viewCount] Failed to tear down modal subscription:", error);
|
||
}
|
||
}
|
||
this.modalViewCountUnsub = null;
|
||
if (this.videoModal) {
|
||
this.videoModal.updateViewCountLabel("– views");
|
||
this.videoModal.setViewCountPointer(null);
|
||
}
|
||
}
|
||
|
||
resetModalReactionState() {
|
||
this.modalReactionState = {
|
||
counts: { "+": 0, "-": 0 },
|
||
total: 0,
|
||
userReaction: "",
|
||
};
|
||
if (this.videoModal?.updateReactionSummary) {
|
||
this.videoModal.updateReactionSummary({
|
||
total: 0,
|
||
counts: { "+": 0, "-": 0 },
|
||
userReaction: "",
|
||
});
|
||
}
|
||
}
|
||
|
||
teardownModalReactionSubscription() {
|
||
if (typeof this.modalReactionUnsub === "function") {
|
||
try {
|
||
this.modalReactionUnsub();
|
||
} catch (error) {
|
||
devLogger.warn(
|
||
"[reaction] Failed to tear down modal subscription:",
|
||
error,
|
||
);
|
||
}
|
||
}
|
||
this.modalReactionUnsub = null;
|
||
this.modalReactionPointerKey = null;
|
||
this.resetModalReactionState();
|
||
}
|
||
|
||
subscribeModalReactions(pointer, pointerKey) {
|
||
if (!this.videoModal?.updateReactionSummary) {
|
||
this.teardownModalReactionSubscription();
|
||
return;
|
||
}
|
||
|
||
this.teardownModalReactionSubscription();
|
||
|
||
if (!pointer || !pointerKey) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const normalizedUser = this.normalizeHexPubkey(this.pubkey);
|
||
const unsubscribe = reactionCounter.subscribe(pointer, (snapshot) => {
|
||
const counts = { ...this.modalReactionState.counts };
|
||
if (snapshot?.counts && typeof snapshot.counts === "object") {
|
||
for (const [key, value] of Object.entries(snapshot.counts)) {
|
||
counts[key] = this.normalizeReactionCount(value);
|
||
}
|
||
}
|
||
if (!Object.prototype.hasOwnProperty.call(counts, "+")) {
|
||
counts["+"] = 0;
|
||
}
|
||
if (!Object.prototype.hasOwnProperty.call(counts, "-")) {
|
||
counts["-"] = 0;
|
||
}
|
||
|
||
let total = Number.isFinite(snapshot?.total)
|
||
? Math.max(0, Number(snapshot.total))
|
||
: 0;
|
||
if (!Number.isFinite(total) || total === 0) {
|
||
total = 0;
|
||
for (const value of Object.values(counts)) {
|
||
total += this.normalizeReactionCount(value);
|
||
}
|
||
}
|
||
|
||
let userReaction = "";
|
||
if (normalizedUser && snapshot?.reactions) {
|
||
const record = snapshot.reactions[normalizedUser] || null;
|
||
if (record && typeof record.content === "string") {
|
||
userReaction =
|
||
record.content === "+"
|
||
? "+"
|
||
: record.content === "-"
|
||
? "-"
|
||
: "";
|
||
}
|
||
}
|
||
|
||
this.modalReactionState = {
|
||
counts,
|
||
total,
|
||
userReaction,
|
||
};
|
||
this.videoModal.updateReactionSummary({
|
||
total,
|
||
counts,
|
||
userReaction,
|
||
});
|
||
});
|
||
|
||
this.modalReactionPointerKey = pointerKey;
|
||
this.modalReactionUnsub = () => {
|
||
try {
|
||
unsubscribe?.();
|
||
} catch (error) {
|
||
devLogger.warn(
|
||
"[reaction] Failed to tear down modal subscription:",
|
||
error,
|
||
);
|
||
} finally {
|
||
this.modalReactionUnsub = null;
|
||
this.modalReactionPointerKey = null;
|
||
}
|
||
};
|
||
} catch (error) {
|
||
devLogger.warn(
|
||
"[reaction] Failed to subscribe modal reaction counter:",
|
||
error,
|
||
);
|
||
this.resetModalReactionState();
|
||
}
|
||
}
|
||
|
||
initializeCommentController() {
|
||
if (!this.commentThreadService || !this.videoModal) {
|
||
return;
|
||
}
|
||
|
||
if (this.commentController) {
|
||
return;
|
||
}
|
||
|
||
this.commentController = new VideoModalCommentController({
|
||
commentThreadService: this.commentThreadService,
|
||
videoModal: this.videoModal,
|
||
auth: {
|
||
isLoggedIn: () => this.isUserLoggedIn(),
|
||
initializeLoginModalController: (options) =>
|
||
this.initializeLoginModalController(options),
|
||
getLoginModalController: () => this.loginModalController,
|
||
requestLogin: (options) =>
|
||
this.authService?.requestLogin?.(options) ?? Promise.resolve(false),
|
||
},
|
||
callbacks: {
|
||
showError: (message) => this.showError(message),
|
||
showStatus: (message, options) => this.showStatus(message, options),
|
||
muteAuthor: (pubkey) => userBlocks.addBlock(pubkey, this.pubkey),
|
||
shouldHideAuthor: (pubkey) => this.shouldHideCommentAuthor(pubkey),
|
||
},
|
||
services: {
|
||
publishComment: (target, eventData) =>
|
||
nostrClient.publishVideoComment(target, eventData),
|
||
},
|
||
utils: {
|
||
normalizeHexPubkey: (value) => this.normalizeHexPubkey(value),
|
||
},
|
||
});
|
||
}
|
||
|
||
shouldHideCommentAuthor(pubkey) {
|
||
const normalized = this.normalizeHexPubkey(pubkey);
|
||
if (!normalized) {
|
||
return false;
|
||
}
|
||
|
||
if (userBlocks?.isBlocked?.(normalized)) {
|
||
return true;
|
||
}
|
||
|
||
try {
|
||
if (moderationService?.isAuthorMutedByViewer?.(normalized)) {
|
||
return true;
|
||
}
|
||
} catch (error) {
|
||
devLogger.warn("[comment] Failed to check viewer mute state:", error);
|
||
}
|
||
|
||
try {
|
||
if (moderationService?.isAuthorMutedByTrusted?.(normalized)) {
|
||
return true;
|
||
}
|
||
} catch (error) {
|
||
devLogger.warn("[comment] Failed to check trusted mute state:", error);
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
subscribeModalViewCount(pointer, pointerKey) {
|
||
const viewEl = this.videoModal?.getViewCountElement() || null;
|
||
if (!viewEl) {
|
||
return;
|
||
}
|
||
|
||
this.teardownModalViewCountSubscription();
|
||
|
||
if (!pointer || !pointerKey) {
|
||
return;
|
||
}
|
||
|
||
if (this.videoModal) {
|
||
this.videoModal.updateViewCountLabel("Loading views…");
|
||
this.videoModal.setViewCountPointer(pointerKey);
|
||
}
|
||
try {
|
||
const token = subscribeToVideoViewCount(pointer, ({ total, status }) => {
|
||
const latestViewEl = this.videoModal?.getViewCountElement() || null;
|
||
if (!latestViewEl) {
|
||
return;
|
||
}
|
||
|
||
if (Number.isFinite(total)) {
|
||
const numeric = Number(total);
|
||
if (this.videoModal) {
|
||
this.videoModal.updateViewCountLabel(
|
||
this.formatViewCountLabel(numeric)
|
||
);
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (status === "hydrating") {
|
||
if (this.videoModal) {
|
||
this.videoModal.updateViewCountLabel("Loading views…");
|
||
}
|
||
} else {
|
||
if (this.videoModal) {
|
||
this.videoModal.updateViewCountLabel("– views");
|
||
}
|
||
}
|
||
});
|
||
|
||
this.modalViewCountUnsub = () => {
|
||
try {
|
||
unsubscribeFromVideoViewCount(pointer, token);
|
||
} catch (error) {
|
||
devLogger.warn(
|
||
"[viewCount] Failed to unsubscribe modal view counter:",
|
||
error
|
||
);
|
||
} finally {
|
||
this.modalViewCountUnsub = null;
|
||
}
|
||
};
|
||
} catch (error) {
|
||
devLogger.warn("[viewCount] Failed to subscribe modal view counter:", error);
|
||
if (this.videoModal) {
|
||
this.videoModal.updateViewCountLabel("– views");
|
||
this.videoModal.setViewCountPointer(null);
|
||
}
|
||
}
|
||
}
|
||
|
||
applyModalReactionOptimisticUpdate(nextReaction) {
|
||
if (nextReaction !== "+" && nextReaction !== "-") {
|
||
return null;
|
||
}
|
||
|
||
const previousCounts = {
|
||
...(this.modalReactionState?.counts || {}),
|
||
};
|
||
const previousTotalValue = Number.isFinite(this.modalReactionState?.total)
|
||
? Math.max(0, Number(this.modalReactionState.total))
|
||
: null;
|
||
const previousReaction = this.modalReactionState?.userReaction || "";
|
||
|
||
const likeBefore = this.normalizeReactionCount(previousCounts["+"]);
|
||
const dislikeBefore = this.normalizeReactionCount(previousCounts["-"]);
|
||
const otherCounts = {};
|
||
for (const [key, value] of Object.entries(previousCounts)) {
|
||
if (key === "+" || key === "-") {
|
||
continue;
|
||
}
|
||
otherCounts[key] = this.normalizeReactionCount(value);
|
||
}
|
||
|
||
let likeCount = likeBefore;
|
||
let dislikeCount = dislikeBefore;
|
||
|
||
if (previousReaction === "+") {
|
||
likeCount = Math.max(0, likeCount - 1);
|
||
} else if (previousReaction === "-") {
|
||
dislikeCount = Math.max(0, dislikeCount - 1);
|
||
}
|
||
|
||
if (nextReaction === "+") {
|
||
likeCount += 1;
|
||
} else if (nextReaction === "-") {
|
||
dislikeCount += 1;
|
||
}
|
||
|
||
const updatedCounts = {
|
||
...previousCounts,
|
||
"+": likeCount,
|
||
"-": dislikeCount,
|
||
};
|
||
|
||
let updatedTotal = likeCount + dislikeCount;
|
||
for (const value of Object.values(otherCounts)) {
|
||
updatedTotal += this.normalizeReactionCount(value);
|
||
}
|
||
|
||
this.modalReactionState = {
|
||
counts: updatedCounts,
|
||
total: updatedTotal,
|
||
userReaction: nextReaction,
|
||
};
|
||
|
||
if (this.videoModal?.updateReactionSummary) {
|
||
this.videoModal.updateReactionSummary({
|
||
total: updatedTotal,
|
||
counts: updatedCounts,
|
||
userReaction: nextReaction,
|
||
});
|
||
}
|
||
|
||
const fallbackPreviousTotal = Number.isFinite(previousTotalValue)
|
||
? previousTotalValue
|
||
: likeBefore +
|
||
dislikeBefore +
|
||
Object.values(otherCounts).reduce(
|
||
(sum, value) => sum + this.normalizeReactionCount(value),
|
||
0
|
||
);
|
||
|
||
return {
|
||
counts: previousCounts,
|
||
total: fallbackPreviousTotal,
|
||
userReaction: previousReaction,
|
||
};
|
||
}
|
||
|
||
restoreModalReactionSnapshot(snapshot) {
|
||
if (!snapshot) {
|
||
return;
|
||
}
|
||
|
||
const countsInput =
|
||
snapshot.counts && typeof snapshot.counts === "object"
|
||
? snapshot.counts
|
||
: {};
|
||
const counts = { ...countsInput };
|
||
for (const [key, value] of Object.entries(counts)) {
|
||
counts[key] = this.normalizeReactionCount(value);
|
||
}
|
||
if (!Object.prototype.hasOwnProperty.call(counts, "+")) {
|
||
counts["+"] = 0;
|
||
}
|
||
if (!Object.prototype.hasOwnProperty.call(counts, "-")) {
|
||
counts["-"] = 0;
|
||
}
|
||
|
||
let total = Number.isFinite(snapshot.total)
|
||
? Math.max(0, Number(snapshot.total))
|
||
: 0;
|
||
if (!Number.isFinite(total) || total === 0) {
|
||
total = 0;
|
||
for (const value of Object.values(counts)) {
|
||
total += this.normalizeReactionCount(value);
|
||
}
|
||
}
|
||
|
||
const userReaction =
|
||
snapshot.userReaction === "+"
|
||
? "+"
|
||
: snapshot.userReaction === "-"
|
||
? "-"
|
||
: "";
|
||
|
||
this.modalReactionState = {
|
||
counts,
|
||
total,
|
||
userReaction,
|
||
};
|
||
|
||
if (this.videoModal?.updateReactionSummary) {
|
||
this.videoModal.updateReactionSummary({
|
||
total,
|
||
counts,
|
||
userReaction,
|
||
});
|
||
}
|
||
}
|
||
|
||
async handleVideoReaction(detail = {}) {
|
||
if (!this.videoModal) {
|
||
return;
|
||
}
|
||
|
||
const requestedReaction =
|
||
typeof detail.reaction === "string" ? detail.reaction : "";
|
||
const normalizedReaction =
|
||
requestedReaction === "+"
|
||
? "+"
|
||
: requestedReaction === "-"
|
||
? "-"
|
||
: "";
|
||
|
||
if (!normalizedReaction) {
|
||
return;
|
||
}
|
||
|
||
const previousReaction = this.modalReactionState?.userReaction || "";
|
||
const pointer = this.currentVideoPointer;
|
||
const pointerKey = this.currentVideoPointerKey || pointerArrayToKey(pointer);
|
||
if (!pointer || !pointerKey) {
|
||
if (this.videoModal) {
|
||
this.videoModal.setUserReaction(previousReaction);
|
||
}
|
||
devLogger.info(
|
||
"[reaction] Ignoring reaction request until modal pointer is available.",
|
||
);
|
||
return;
|
||
}
|
||
if (normalizedReaction === previousReaction) {
|
||
return;
|
||
}
|
||
|
||
if (!this.isUserLoggedIn()) {
|
||
this.showError("Please login to react to videos.");
|
||
this.videoModal.setUserReaction(previousReaction);
|
||
return;
|
||
}
|
||
|
||
let rollbackSnapshot = null;
|
||
try {
|
||
rollbackSnapshot = this.applyModalReactionOptimisticUpdate(
|
||
normalizedReaction
|
||
);
|
||
} catch (error) {
|
||
devLogger.warn("[reaction] Failed to apply optimistic reaction state:", error);
|
||
}
|
||
|
||
try {
|
||
const result = await reactionCounter.publish(pointer, {
|
||
content: normalizedReaction,
|
||
video: this.currentVideo,
|
||
currentVideoPubkey: this.currentVideo?.pubkey,
|
||
pointerKey,
|
||
});
|
||
|
||
if (!result?.ok) {
|
||
if (rollbackSnapshot) {
|
||
this.restoreModalReactionSnapshot(rollbackSnapshot);
|
||
}
|
||
this.showError("Failed to send reaction. Please try again.");
|
||
}
|
||
} catch (error) {
|
||
devLogger.warn("[reaction] Failed to publish reaction:", error);
|
||
if (rollbackSnapshot) {
|
||
this.restoreModalReactionSnapshot(rollbackSnapshot);
|
||
}
|
||
this.showError("Failed to send reaction. Please try again.");
|
||
}
|
||
}
|
||
|
||
syncProfileModalState({
|
||
includeSavedProfiles = true,
|
||
includeActivePubkey = true,
|
||
} = {}) {
|
||
if (!this.profileController) {
|
||
return;
|
||
}
|
||
|
||
if (includeSavedProfiles) {
|
||
try {
|
||
if (typeof this.profileController.setSavedProfiles === "function") {
|
||
const entries = Array.isArray(this.savedProfiles)
|
||
? this.savedProfiles.slice()
|
||
: [];
|
||
this.profileController.setSavedProfiles(entries, {
|
||
persist: false,
|
||
persistActive: false,
|
||
});
|
||
}
|
||
} catch (error) {
|
||
devLogger.warn(
|
||
"[profileModal] Failed to synchronize saved profiles with controller:",
|
||
error,
|
||
);
|
||
}
|
||
}
|
||
|
||
if (includeActivePubkey) {
|
||
try {
|
||
if (typeof this.profileController.setActivePubkey === "function") {
|
||
this.profileController.setActivePubkey(
|
||
this.activeProfilePubkey || null,
|
||
{ persist: false },
|
||
);
|
||
}
|
||
} catch (error) {
|
||
devLogger.warn(
|
||
"[profileModal] Failed to synchronize active profile with controller:",
|
||
error,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
renderSavedProfiles() {
|
||
if (!this.profileController) {
|
||
return;
|
||
}
|
||
this.syncProfileModalState();
|
||
try {
|
||
this.profileController.renderSavedProfiles();
|
||
} catch (error) {
|
||
devLogger.warn(
|
||
"[profileModal] Failed to render saved profiles:",
|
||
error,
|
||
);
|
||
}
|
||
}
|
||
|
||
handleProfileModalClosed() {
|
||
this.closeAllMoreMenus();
|
||
}
|
||
|
||
handleProfileChannelLink(element) {
|
||
if (!(element instanceof HTMLElement)) {
|
||
return;
|
||
}
|
||
|
||
const targetNpub =
|
||
typeof element.dataset.targetNpub === "string"
|
||
? element.dataset.targetNpub
|
||
: "";
|
||
if (this.profileController) {
|
||
this.profileController.hide();
|
||
}
|
||
if (targetNpub) {
|
||
window.location.hash = `#view=channel-profile&npub=${encodeURIComponent(
|
||
targetNpub,
|
||
)}`;
|
||
}
|
||
}
|
||
|
||
async handleLoginModalSuccess(payload = {}) {
|
||
const result =
|
||
payload && typeof payload === "object" ? payload.result || null : null;
|
||
const pubkey =
|
||
(result &&
|
||
typeof result === "object" &&
|
||
typeof result.pubkey === "string"
|
||
? result.pubkey
|
||
: typeof result === "string"
|
||
? result
|
||
: null) || null;
|
||
|
||
devLogger.log("[LoginModal] Login result returned pubkey:", pubkey);
|
||
|
||
if (!result || typeof result !== "object") {
|
||
return;
|
||
}
|
||
|
||
const { detail } = result;
|
||
|
||
if (
|
||
pubkey &&
|
||
detail &&
|
||
typeof detail === "object" &&
|
||
detail.__handled !== true
|
||
) {
|
||
try {
|
||
await this.handleAuthLogin(detail);
|
||
} catch (error) {
|
||
devLogger.error(
|
||
"[LoginModal] handleAuthLogin fallback failed:",
|
||
error,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
async handleLoginModalError(payload = {}) {
|
||
const message =
|
||
typeof payload?.message === "string" && payload.message.trim()
|
||
? payload.message.trim()
|
||
: null;
|
||
const error = payload?.error || null;
|
||
const provider = payload?.provider || null;
|
||
|
||
const fallbackMessage =
|
||
message ||
|
||
this.describeLoginError(
|
||
error,
|
||
provider?.errorMessage || "Failed to login. Please try again.",
|
||
);
|
||
|
||
const normalizedMessage =
|
||
typeof fallbackMessage === "string" && fallbackMessage.trim()
|
||
? fallbackMessage.trim()
|
||
: "Failed to login. Please try again.";
|
||
|
||
userLogger.warn(
|
||
provider && provider.id
|
||
? `[LoginModal] Login failed for provider ${provider.id}.`
|
||
: "[LoginModal] Login failed for provider.",
|
||
);
|
||
this.showError(normalizedMessage);
|
||
}
|
||
|
||
async requestProfileAdditionLogin({ triggerElement } = {}) {
|
||
this.initializeLoginModalController();
|
||
if (
|
||
this.loginModalController &&
|
||
typeof this.loginModalController.requestAddProfileLogin === "function"
|
||
) {
|
||
try {
|
||
return await this.loginModalController.requestAddProfileLogin({
|
||
triggerElement,
|
||
requestOptions: {
|
||
allowAccountSelection: true,
|
||
autoApply: false,
|
||
},
|
||
});
|
||
} catch (error) {
|
||
if (
|
||
error &&
|
||
(error.code === "login-cancelled" || error.code === "user-cancelled")
|
||
) {
|
||
throw error;
|
||
}
|
||
if (
|
||
!error ||
|
||
(error.code !== "modal-unavailable" &&
|
||
error.code !== "modal-open-failed")
|
||
) {
|
||
throw error;
|
||
}
|
||
devLogger.warn(
|
||
"[profileModal] Falling back to direct login for profile addition:",
|
||
error,
|
||
);
|
||
}
|
||
}
|
||
|
||
return this.authService.requestLogin({
|
||
allowAccountSelection: true,
|
||
autoApply: false,
|
||
});
|
||
}
|
||
|
||
async handleAddProfile(payload = {}) {
|
||
const loginResult =
|
||
payload && typeof payload === "object" ? payload.loginResult : null;
|
||
|
||
if (!loginResult) {
|
||
devLogger.warn(
|
||
"[profileModal] Ignoring add profile callback without an authentication result.",
|
||
);
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const { pubkey, authType: loginAuthType, providerId } =
|
||
typeof loginResult === "object" && loginResult
|
||
? loginResult
|
||
: { pubkey: loginResult };
|
||
|
||
const detailAuthType =
|
||
typeof loginResult?.detail?.authType === "string"
|
||
? loginResult.detail.authType
|
||
: null;
|
||
const detailProviderId =
|
||
typeof loginResult?.detail?.providerId === "string"
|
||
? loginResult.detail.providerId
|
||
: null;
|
||
|
||
const resolvedAuthType = (() => {
|
||
const candidates = [
|
||
detailAuthType,
|
||
loginAuthType,
|
||
detailProviderId,
|
||
providerId,
|
||
];
|
||
for (const candidate of candidates) {
|
||
if (typeof candidate !== "string") {
|
||
continue;
|
||
}
|
||
const trimmed = candidate.trim();
|
||
if (trimmed) {
|
||
return trimmed;
|
||
}
|
||
}
|
||
return "nip07";
|
||
})();
|
||
|
||
const resolvedProviderId = (() => {
|
||
const candidates = [detailProviderId, providerId, resolvedAuthType];
|
||
for (const candidate of candidates) {
|
||
if (typeof candidate !== "string") {
|
||
continue;
|
||
}
|
||
const trimmed = candidate.trim();
|
||
if (trimmed) {
|
||
return trimmed;
|
||
}
|
||
}
|
||
return resolvedAuthType;
|
||
})();
|
||
|
||
const normalizedPubkey = this.normalizeHexPubkey(pubkey);
|
||
if (!normalizedPubkey) {
|
||
throw new Error(
|
||
"Received an invalid public key from the authentication provider.",
|
||
);
|
||
}
|
||
|
||
const alreadySaved = this.savedProfiles.some(
|
||
(entry) => this.normalizeHexPubkey(entry.pubkey) === normalizedPubkey,
|
||
);
|
||
if (alreadySaved) {
|
||
this.showSuccess("That profile is already saved on this device.");
|
||
return;
|
||
}
|
||
|
||
const npub = this.safeEncodeNpub(normalizedPubkey) || "";
|
||
let profileMeta = this.getProfileCacheEntry(normalizedPubkey)?.profile;
|
||
|
||
if (!profileMeta) {
|
||
await this.authService.loadOwnProfile(normalizedPubkey);
|
||
profileMeta = this.getProfileCacheEntry(normalizedPubkey)?.profile;
|
||
}
|
||
|
||
const name = profileMeta?.name || "";
|
||
const picture =
|
||
profileMeta?.picture || "assets/svg/default-profile.svg";
|
||
|
||
this.savedProfiles.push({
|
||
pubkey: normalizedPubkey,
|
||
npub,
|
||
name,
|
||
picture,
|
||
authType: resolvedAuthType,
|
||
providerId: resolvedProviderId,
|
||
});
|
||
|
||
persistSavedProfiles({ persistActive: false });
|
||
this.renderSavedProfiles();
|
||
|
||
this.showSuccess("Profile added. Select it when you're ready to switch.");
|
||
} catch (error) {
|
||
const cancellationCodes = new Set([
|
||
"login-cancelled",
|
||
"user-cancelled",
|
||
"modal-dismissed",
|
||
]);
|
||
if (
|
||
error &&
|
||
typeof error === "object" &&
|
||
typeof error.code === "string" &&
|
||
cancellationCodes.has(error.code)
|
||
) {
|
||
devLogger.log("[profileModal] Add profile flow cancelled.", error);
|
||
return;
|
||
}
|
||
|
||
devLogger.error(
|
||
"[profileModal] Failed to add profile via authentication provider:",
|
||
error,
|
||
);
|
||
|
||
const message = this.describeLoginError(
|
||
error,
|
||
"Couldn't add that profile. Please try again.",
|
||
);
|
||
|
||
const rejectionError =
|
||
error instanceof Error
|
||
? error
|
||
: new Error(message || "Couldn't add that profile. Please try again.");
|
||
|
||
if (message && rejectionError.message !== message) {
|
||
rejectionError.message = message;
|
||
}
|
||
|
||
if (
|
||
error &&
|
||
typeof error === "object" &&
|
||
error.code &&
|
||
!rejectionError.code
|
||
) {
|
||
rejectionError.code = error.code;
|
||
}
|
||
|
||
throw rejectionError;
|
||
}
|
||
}
|
||
|
||
describeLoginError(error, fallbackMessage = "Failed to login. Please try again.") {
|
||
const code =
|
||
error && typeof error.code === "string" && error.code.trim()
|
||
? error.code.trim()
|
||
: null;
|
||
if (code === "site-lockdown") {
|
||
return "This site is temporarily locked down. Only administrators may sign in right now.";
|
||
}
|
||
|
||
if (error && typeof error.message === "string") {
|
||
const trimmed = error.message.trim();
|
||
if (trimmed) {
|
||
return trimmed;
|
||
}
|
||
}
|
||
|
||
return fallbackMessage;
|
||
}
|
||
|
||
initializeLoginModalController(options = {}) {
|
||
const { logIfMissing = false } =
|
||
options && typeof options === "object" ? options : {};
|
||
|
||
if (this.loginModalController) {
|
||
return true;
|
||
}
|
||
|
||
let loginModalElement = null;
|
||
try {
|
||
loginModalElement = document.getElementById("loginModal") || null;
|
||
} catch (error) {
|
||
devLogger.warn(
|
||
"[Application] Failed to look up login modal container:",
|
||
error,
|
||
);
|
||
}
|
||
|
||
if (!(loginModalElement instanceof HTMLElement)) {
|
||
if (logIfMissing) {
|
||
devLogger.warn(
|
||
"[Application] Login modal controller disabled: modal container not found.",
|
||
);
|
||
}
|
||
return false;
|
||
}
|
||
|
||
const preparedModal =
|
||
prepareStaticModal({ root: loginModalElement }) || loginModalElement;
|
||
|
||
loginModalElement = preparedModal;
|
||
|
||
const closeLoginModal = () => {
|
||
if (closeStaticModal(loginModalElement)) {
|
||
setGlobalModalState("login", false);
|
||
}
|
||
};
|
||
|
||
const bringModalToFront = (modal) => {
|
||
if (!(modal instanceof HTMLElement)) {
|
||
return;
|
||
}
|
||
const parent = modal.parentElement;
|
||
if (parent && parent.lastElementChild !== modal) {
|
||
parent.appendChild(modal);
|
||
}
|
||
};
|
||
|
||
const openLoginModal = ({ modal, triggerElement } = {}) => {
|
||
const target =
|
||
modal instanceof HTMLElement ? modal : loginModalElement;
|
||
bringModalToFront(target);
|
||
return openStaticModal(target, { triggerElement });
|
||
};
|
||
|
||
const prepareLoginModal = (modal) =>
|
||
prepareStaticModal({ root: modal || loginModalElement });
|
||
|
||
try {
|
||
this.loginModalController = new LoginModalController({
|
||
modalElement: loginModalElement,
|
||
providers: authProviders,
|
||
services: {
|
||
authService: this.authService,
|
||
nostrClient,
|
||
},
|
||
callbacks: {
|
||
onProviderSelected: (providerId) => {
|
||
devLogger.log(`[LoginModal] Provider selected: ${providerId}.`);
|
||
this.maybeShowExperimentalLoginWarning(providerId);
|
||
},
|
||
onLoginSuccess: (payload) => {
|
||
const maybePromise = this.handleLoginModalSuccess(payload);
|
||
if (maybePromise && typeof maybePromise.then === "function") {
|
||
maybePromise.catch((error) => {
|
||
devLogger.error(
|
||
"[LoginModal] handleLoginModalSuccess threw:",
|
||
error,
|
||
);
|
||
});
|
||
}
|
||
},
|
||
onLoginError: (payload) => {
|
||
const maybePromise = this.handleLoginModalError(payload);
|
||
if (maybePromise && typeof maybePromise.then === "function") {
|
||
maybePromise.catch((error) => {
|
||
devLogger.error(
|
||
"[LoginModal] handleLoginModalError threw:",
|
||
error,
|
||
);
|
||
});
|
||
}
|
||
},
|
||
},
|
||
helpers: {
|
||
closeModal: closeLoginModal,
|
||
openModal: ({ modal, triggerElement } = {}) =>
|
||
openLoginModal({ modal, triggerElement }),
|
||
prepareModal: (modal) => prepareLoginModal(modal),
|
||
setModalState: (_, isOpen) =>
|
||
setGlobalModalState("login", isOpen === undefined ? false : !!isOpen),
|
||
describeLoginError: (error, fallbackMessage) =>
|
||
this.describeLoginError(
|
||
error,
|
||
typeof fallbackMessage === "string" && fallbackMessage.trim()
|
||
? fallbackMessage.trim()
|
||
: "Failed to login. Please try again.",
|
||
),
|
||
},
|
||
});
|
||
return true;
|
||
} catch (error) {
|
||
devLogger.error(
|
||
"[Application] Failed to initialize login modal controller:",
|
||
error,
|
||
);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
canCurrentUserManageBlacklist() {
|
||
const actorNpub = this.getCurrentUserNpub();
|
||
if (!actorNpub) {
|
||
return false;
|
||
}
|
||
|
||
try {
|
||
return accessControl.canEditAdminLists(actorNpub);
|
||
} catch (error) {
|
||
devLogger.warn("Unable to verify blacklist permissions:", error);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
describeAdminError(code) {
|
||
switch (code) {
|
||
case "invalid npub":
|
||
return "Please provide a valid npub address.";
|
||
case "immutable":
|
||
return "That account cannot be modified.";
|
||
case "self":
|
||
return "You cannot blacklist yourself.";
|
||
case "forbidden":
|
||
return "You do not have permission to perform that action.";
|
||
case "nostr-unavailable":
|
||
return "Unable to reach the configured Nostr relays. Please retry once your connection is restored.";
|
||
|
||
case "nostr-extension-missing":
|
||
return "Connect a Nostr extension before editing moderation lists.";
|
||
case "signature-failed":
|
||
return "We couldn’t sign the update with your Nostr key. Please reconnect your extension and try again.";
|
||
case "publish-failed":
|
||
return "Failed to publish the update to Nostr relays. Please try again.";
|
||
case "storage-error":
|
||
return "Unable to update moderation settings. Please try again.";
|
||
default:
|
||
return "Unable to update moderation settings. Please try again.";
|
||
}
|
||
}
|
||
|
||
describeNotificationError(code) {
|
||
switch (code) {
|
||
case "nostr-extension-missing":
|
||
return "List updated, but the DM notification failed because no Nostr extension is connected.";
|
||
case "nostr-uninitialized":
|
||
return "List updated, but the DM notification system is still connecting to Nostr relays. Please try again in a moment.";
|
||
case "nip04-unavailable":
|
||
return "List updated, but your Nostr extension does not support NIP-04 encryption, so the DM notification was not sent.";
|
||
case "sign-event-unavailable":
|
||
return "List updated, but your Nostr extension could not sign the DM notification.";
|
||
case "missing-actor-pubkey":
|
||
return "List updated, but we could not determine your public key to send the DM notification.";
|
||
case "publish-failed":
|
||
return "List updated, but the DM notification could not be delivered to any relay.";
|
||
case "encryption-failed":
|
||
case "signature-failed":
|
||
return "List updated, but the DM notification failed while preparing the encrypted message.";
|
||
case "invalid-target":
|
||
case "empty-message":
|
||
return "";
|
||
default:
|
||
return "List updated, but the DM notification could not be sent.";
|
||
}
|
||
}
|
||
|
||
describeUserBlockActionError(error) {
|
||
const code =
|
||
error && typeof error.code === "string" ? error.code.trim().toLowerCase() : "";
|
||
|
||
switch (code) {
|
||
case "nip04-missing":
|
||
return "Your Nostr extension must support NIP-04 to manage private block lists.";
|
||
case "extension-permission-denied":
|
||
return "Permission to update your block list was denied by your Nostr extension.";
|
||
case "sign-event-missing":
|
||
case "signer-missing":
|
||
return "Connect a Nostr signer that can encrypt and sign updates before managing block lists.";
|
||
case "nostr-extension-missing":
|
||
return "Connect a Nostr extension before updating your block list.";
|
||
case "invalid":
|
||
return "Unable to block that account. Please try again.";
|
||
case "self":
|
||
return "You cannot block yourself.";
|
||
default:
|
||
return "";
|
||
}
|
||
}
|
||
|
||
describeHashtagPreferencesError(error, options = {}) {
|
||
const { operation = "update", fallbackMessage } =
|
||
options && typeof options === "object" ? options : {};
|
||
const code =
|
||
error && typeof error.code === "string" ? error.code.trim() : "";
|
||
|
||
if (code === "hashtag-preferences-empty") {
|
||
return "";
|
||
}
|
||
|
||
switch (code) {
|
||
case "hashtag-preferences-missing-pubkey":
|
||
return "Select a profile before managing hashtag preferences.";
|
||
case "hashtag-preferences-missing-signer":
|
||
return "Connect a Nostr signer that supports encryption before managing hashtag preferences.";
|
||
case "hashtag-preferences-extension-denied":
|
||
return "Permission to manage hashtag preferences was denied by your signer.";
|
||
case "hashtag-preferences-no-decryptors":
|
||
return "Unable to decrypt hashtag preferences with the active signer.";
|
||
case "hashtag-preferences-decrypt-failed":
|
||
return "Failed to decrypt hashtag preferences. Please try again.";
|
||
case "hashtag-preferences-no-encryptor":
|
||
return "No supported encryption methods are available to publish hashtag preferences.";
|
||
case "hashtag-preferences-encrypt-failed":
|
||
return "Failed to encrypt hashtag preferences. Please try again.";
|
||
case "hashtag-preferences-no-relays":
|
||
return "No relays are configured to publish hashtag preferences.";
|
||
default:
|
||
break;
|
||
}
|
||
|
||
const normalizedOperation =
|
||
typeof operation === "string" ? operation.trim().toLowerCase() : "";
|
||
const fallback =
|
||
typeof fallbackMessage === "string" && fallbackMessage.trim()
|
||
? fallbackMessage.trim()
|
||
: normalizedOperation === "load"
|
||
? "Failed to load hashtag preferences. Please try again."
|
||
: normalizedOperation === "reset"
|
||
? "Failed to reset hashtag preferences."
|
||
: "Failed to update hashtag preferences. Please try again.";
|
||
|
||
return fallback;
|
||
}
|
||
|
||
normalizeHashtagPreferenceList(list) {
|
||
if (!Array.isArray(list)) {
|
||
return [];
|
||
}
|
||
|
||
const normalized = [];
|
||
const seen = new Set();
|
||
|
||
for (const entry of list) {
|
||
if (typeof entry !== "string") {
|
||
continue;
|
||
}
|
||
const trimmed = entry.trim();
|
||
if (!trimmed) {
|
||
continue;
|
||
}
|
||
const lowered = trimmed.toLowerCase();
|
||
if (seen.has(lowered)) {
|
||
continue;
|
||
}
|
||
seen.add(lowered);
|
||
normalized.push(lowered);
|
||
}
|
||
|
||
normalized.sort((a, b) => a.localeCompare(b));
|
||
return normalized;
|
||
}
|
||
|
||
normalizeTagPreferenceCandidate(tag) {
|
||
return normalizeHashtag(tag);
|
||
}
|
||
|
||
getTagPreferenceState(tag) {
|
||
const normalized = this.normalizeTagPreferenceCandidate(tag);
|
||
if (!normalized) {
|
||
return "neutral";
|
||
}
|
||
|
||
const { interests, disinterests } = this.getHashtagPreferences();
|
||
if (interests.includes(normalized)) {
|
||
return "interest";
|
||
}
|
||
if (disinterests.includes(normalized)) {
|
||
return "disinterest";
|
||
}
|
||
return "neutral";
|
||
}
|
||
|
||
getTagPreferenceMembership(tag) {
|
||
const state = this.getTagPreferenceState(tag);
|
||
return {
|
||
state,
|
||
interest: state === "interest",
|
||
disinterest: state === "disinterest",
|
||
};
|
||
}
|
||
|
||
createHashtagPreferencesSnapshot(detail = {}) {
|
||
const service = this.hashtagPreferences;
|
||
const sourceInterests = Array.isArray(detail?.interests)
|
||
? detail.interests
|
||
: typeof service?.getInterests === "function"
|
||
? service.getInterests()
|
||
: [];
|
||
const sourceDisinterests = Array.isArray(detail?.disinterests)
|
||
? detail.disinterests
|
||
: typeof service?.getDisinterests === "function"
|
||
? service.getDisinterests()
|
||
: [];
|
||
|
||
const rawEventId =
|
||
typeof detail?.eventId === "string" && detail.eventId.trim()
|
||
? detail.eventId.trim()
|
||
: typeof service?.eventId === "string" && service.eventId.trim()
|
||
? service.eventId.trim()
|
||
: "";
|
||
|
||
const createdAtSource =
|
||
detail?.createdAt ?? service?.eventCreatedAt ?? null;
|
||
const createdAtNumeric = Number(createdAtSource);
|
||
const createdAt = Number.isFinite(createdAtNumeric)
|
||
? createdAtNumeric
|
||
: null;
|
||
|
||
const action =
|
||
typeof detail?.action === "string" ? detail.action.trim() : "";
|
||
|
||
const loaded =
|
||
detail?.loaded === true ||
|
||
(detail?.loaded === false
|
||
? false
|
||
: Boolean(service && service.loaded));
|
||
|
||
return {
|
||
interests: this.normalizeHashtagPreferenceList(sourceInterests),
|
||
disinterests: this.normalizeHashtagPreferenceList(sourceDisinterests),
|
||
eventId: rawEventId || null,
|
||
createdAt,
|
||
loaded,
|
||
action,
|
||
};
|
||
}
|
||
|
||
computeHashtagPreferencesSignature(snapshot = {}) {
|
||
const interests = Array.isArray(snapshot.interests)
|
||
? snapshot.interests.join(",")
|
||
: "";
|
||
const disinterests = Array.isArray(snapshot.disinterests)
|
||
? snapshot.disinterests.join(",")
|
||
: "";
|
||
const eventId =
|
||
typeof snapshot.eventId === "string" && snapshot.eventId
|
||
? snapshot.eventId
|
||
: "";
|
||
const createdAt = Number.isFinite(snapshot.createdAt)
|
||
? Number(snapshot.createdAt)
|
||
: "";
|
||
const loaded = snapshot.loaded === true ? "1" : "0";
|
||
|
||
return [interests, disinterests, eventId, createdAt, loaded].join("|");
|
||
}
|
||
|
||
updateCachedHashtagPreferences(detail = {}) {
|
||
const snapshot = this.createHashtagPreferencesSnapshot(detail);
|
||
const signature = this.computeHashtagPreferencesSignature(snapshot);
|
||
const changed = signature !== this.hashtagPreferencesSnapshotSignature;
|
||
|
||
this.hashtagPreferencesSnapshot = {
|
||
interests: [...snapshot.interests],
|
||
disinterests: [...snapshot.disinterests],
|
||
eventId: snapshot.eventId,
|
||
createdAt: snapshot.createdAt,
|
||
loaded: snapshot.loaded,
|
||
action: snapshot.action,
|
||
};
|
||
this.hashtagPreferencesSnapshotSignature = signature;
|
||
|
||
return { snapshot: this.hashtagPreferencesSnapshot, changed };
|
||
}
|
||
|
||
handleHashtagPreferencesChange(detail = {}) {
|
||
const { changed } = this.updateCachedHashtagPreferences(detail);
|
||
|
||
this.refreshTagPreferenceUi();
|
||
|
||
if (
|
||
this.profileController &&
|
||
typeof this.profileController.handleHashtagPreferencesChange ===
|
||
"function"
|
||
) {
|
||
try {
|
||
this.profileController.handleHashtagPreferencesChange({
|
||
action:
|
||
typeof detail?.action === "string" ? detail.action : "",
|
||
preferences: this.getHashtagPreferences(),
|
||
});
|
||
} catch (error) {
|
||
devLogger.warn(
|
||
"[Application] Failed to notify profile controller about hashtag preferences change:",
|
||
error,
|
||
);
|
||
}
|
||
}
|
||
|
||
if (!changed) {
|
||
return;
|
||
}
|
||
|
||
const action =
|
||
typeof detail?.action === "string" && detail.action.trim()
|
||
? detail.action.trim()
|
||
: "";
|
||
const refreshReason = action
|
||
? `hashtag-preferences-${action}`
|
||
: "hashtag-preferences-change";
|
||
|
||
Promise.resolve(
|
||
this.onVideosShouldRefresh({ reason: refreshReason }),
|
||
).catch((error) => {
|
||
devLogger.warn(
|
||
"[Application] Failed to refresh videos after hashtag preference change:",
|
||
error,
|
||
);
|
||
});
|
||
}
|
||
|
||
getHashtagPreferences() {
|
||
const snapshot = this.hashtagPreferencesSnapshot || {};
|
||
return {
|
||
interests: Array.isArray(snapshot.interests)
|
||
? [...snapshot.interests]
|
||
: [],
|
||
disinterests: Array.isArray(snapshot.disinterests)
|
||
? [...snapshot.disinterests]
|
||
: [],
|
||
eventId:
|
||
typeof snapshot.eventId === "string" && snapshot.eventId
|
||
? snapshot.eventId
|
||
: null,
|
||
createdAt: Number.isFinite(snapshot.createdAt)
|
||
? Number(snapshot.createdAt)
|
||
: null,
|
||
loaded: snapshot.loaded === true,
|
||
};
|
||
}
|
||
|
||
refreshTagPreferenceUi() {
|
||
if (this.videoModal?.refreshTagPreferenceStates) {
|
||
try {
|
||
this.videoModal.refreshTagPreferenceStates();
|
||
} catch (error) {
|
||
devLogger.warn(
|
||
"[Application] Failed to refresh video modal tag preference states:",
|
||
error,
|
||
);
|
||
}
|
||
}
|
||
|
||
if (this.videoListView?.refreshTagPreferenceStates) {
|
||
try {
|
||
this.videoListView.refreshTagPreferenceStates();
|
||
} catch (error) {
|
||
devLogger.warn(
|
||
"[Application] Failed to refresh list view tag preference states:",
|
||
error,
|
||
);
|
||
}
|
||
}
|
||
|
||
this.tagPreferenceMenuController.refreshActiveMenus();
|
||
}
|
||
|
||
async loadHashtagPreferencesForPubkey(pubkey) {
|
||
if (
|
||
!this.hashtagPreferences ||
|
||
typeof this.hashtagPreferences.load !== "function"
|
||
) {
|
||
return null;
|
||
}
|
||
|
||
try {
|
||
await this.hashtagPreferences.load(pubkey);
|
||
return true;
|
||
} catch (error) {
|
||
devLogger.error(
|
||
"[Application] Failed to load hashtag preferences:",
|
||
error,
|
||
);
|
||
const message = this.describeHashtagPreferencesError(error, {
|
||
operation: "load",
|
||
});
|
||
if (message) {
|
||
this.showError(message);
|
||
}
|
||
return null;
|
||
}
|
||
}
|
||
|
||
resetHashtagPreferencesState() {
|
||
if (
|
||
!this.hashtagPreferences ||
|
||
typeof this.hashtagPreferences.reset !== "function"
|
||
) {
|
||
return false;
|
||
}
|
||
|
||
try {
|
||
this.hashtagPreferences.reset();
|
||
return true;
|
||
} catch (error) {
|
||
devLogger.error(
|
||
"[Application] Failed to reset hashtag preferences:",
|
||
error,
|
||
);
|
||
const message = this.describeHashtagPreferencesError(error, {
|
||
operation: "reset",
|
||
});
|
||
if (message) {
|
||
this.showError(message);
|
||
}
|
||
return false;
|
||
}
|
||
}
|
||
|
||
async onAccessControlUpdated() {
|
||
if (this.profileController) {
|
||
try {
|
||
await this.profileController.refreshAdminPaneState();
|
||
} catch (error) {
|
||
devLogger.error("Failed to refresh admin pane after update:", error);
|
||
}
|
||
}
|
||
|
||
this.refreshAllVideoGrids({
|
||
reason: "access-control-update",
|
||
forceMainReload: true,
|
||
}).catch((error) => {
|
||
devLogger.error("Failed to refresh video grids after admin update:", error);
|
||
});
|
||
window.dispatchEvent(new CustomEvent("bitvid:access-control-updated"));
|
||
}
|
||
|
||
async refreshAllVideoGrids({ reason, forceMainReload = false } = {}) {
|
||
const normalizedReason =
|
||
typeof reason === "string" && reason.trim() ? reason.trim() : undefined;
|
||
|
||
if (typeof moderationService?.awaitUserBlockRefresh === "function") {
|
||
moderationService.awaitUserBlockRefresh().catch((error) => {
|
||
const contextMessage = normalizedReason
|
||
? ` in background during ${normalizedReason}`
|
||
: " in background";
|
||
devLogger.warn(
|
||
`Failed to sync moderation summaries${contextMessage}:`,
|
||
error,
|
||
);
|
||
});
|
||
}
|
||
|
||
try {
|
||
await this.loadVideos(forceMainReload);
|
||
} catch (error) {
|
||
const contextMessage = normalizedReason
|
||
? ` after ${normalizedReason}`
|
||
: "";
|
||
devLogger.error(
|
||
`Failed to refresh recent videos${contextMessage}:`,
|
||
error,
|
||
);
|
||
}
|
||
|
||
const refreshTasks = [];
|
||
|
||
if (typeof subscriptions?.refreshActiveFeed === "function") {
|
||
const subscriptionPromise = subscriptions
|
||
.refreshActiveFeed({ reason: normalizedReason })
|
||
.catch((error) => {
|
||
const contextMessage = normalizedReason
|
||
? ` after ${normalizedReason}`
|
||
: "";
|
||
devLogger.warn(
|
||
`Failed to refresh subscriptions grid${contextMessage}:`,
|
||
error,
|
||
);
|
||
});
|
||
refreshTasks.push(subscriptionPromise);
|
||
}
|
||
|
||
if (typeof refreshActiveChannelVideoGrid === "function") {
|
||
try {
|
||
const maybePromise = refreshActiveChannelVideoGrid({
|
||
reason: normalizedReason,
|
||
});
|
||
if (maybePromise && typeof maybePromise.then === "function") {
|
||
refreshTasks.push(
|
||
maybePromise.catch((error) => {
|
||
const contextMessage = normalizedReason
|
||
? ` after ${normalizedReason}`
|
||
: "";
|
||
devLogger.warn(
|
||
`Failed to refresh channel grid${contextMessage}:`,
|
||
error,
|
||
);
|
||
}),
|
||
);
|
||
}
|
||
} catch (error) {
|
||
const contextMessage = normalizedReason
|
||
? ` after ${normalizedReason}`
|
||
: "";
|
||
devLogger.warn(
|
||
`Failed to trigger channel grid refresh${contextMessage}:`,
|
||
error,
|
||
);
|
||
}
|
||
}
|
||
|
||
if (refreshTasks.length) {
|
||
await Promise.allSettled(refreshTasks);
|
||
}
|
||
|
||
if (typeof this.refreshVisibleModerationUi === "function") {
|
||
const refreshReason =
|
||
normalizedReason || (forceMainReload ? "refresh-all-video-grids" : "refresh");
|
||
try {
|
||
this.refreshVisibleModerationUi({ reason: refreshReason });
|
||
} catch (error) {
|
||
const contextMessage = refreshReason ? ` after ${refreshReason}` : "";
|
||
devLogger.warn(
|
||
`[Application] Failed to refresh moderation UI${contextMessage}:`,
|
||
error,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
async onVideosShouldRefresh({ reason } = {}) {
|
||
try {
|
||
await this.refreshAllVideoGrids({ reason, forceMainReload: true });
|
||
} catch (error) {
|
||
const context = reason ? ` after ${reason}` : "";
|
||
devLogger.error(`Failed to refresh video grids${context}:`, error);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Setup general event listeners for logout, modals, etc.
|
||
*/
|
||
setupEventListeners() {
|
||
if (moderationService && typeof moderationService.on === "function") {
|
||
moderationService.on("trusted-mutes", () => {
|
||
if (!this.isUserLoggedIn()) {
|
||
this.refreshAllVideoGrids({ reason: "trusted-mutes" });
|
||
} else {
|
||
this.refreshVisibleModerationUi({ reason: "trusted-mutes" });
|
||
}
|
||
});
|
||
moderationService.on("user-blocks", () => {
|
||
this.refreshVisibleModerationUi({ reason: "user-blocks" });
|
||
});
|
||
moderationService.on("summary", () => {
|
||
this.refreshVisibleModerationUi({ reason: "moderation-summary" });
|
||
});
|
||
}
|
||
|
||
if (this.appChromeController) {
|
||
this.appChromeController.initialize();
|
||
return;
|
||
}
|
||
|
||
devLogger.warn(
|
||
"[Application] AppChromeController missing; global UI events were not bound.",
|
||
);
|
||
}
|
||
|
||
mountVideoListView({ container = null, includeTags = true } = {}) {
|
||
if (!this.videoListView) {
|
||
return null;
|
||
}
|
||
|
||
if (this.videoListViewController) {
|
||
const { videoList, popularTags } = this.videoListViewController.mount({
|
||
container,
|
||
view: this.videoListView,
|
||
currentVideoList: this.videoList,
|
||
includeTags,
|
||
});
|
||
this.videoList = videoList || null;
|
||
this.videoListPopularTags = popularTags || null;
|
||
return this.videoList;
|
||
}
|
||
|
||
const isElement = (value) =>
|
||
typeof HTMLElement !== "undefined" && value instanceof HTMLElement;
|
||
|
||
const target = container || document.getElementById("videoList");
|
||
this.videoList = target || null;
|
||
|
||
let tagsRoot = null;
|
||
if (includeTags) {
|
||
tagsRoot = document.getElementById("videoListTags");
|
||
}
|
||
this.videoListPopularTags = tagsRoot || null;
|
||
if (typeof this.videoListView.setPopularTagsContainer === "function") {
|
||
this.videoListView.setPopularTagsContainer(this.videoListPopularTags);
|
||
}
|
||
this.videoListView.mount(this.videoList || null);
|
||
return this.videoList;
|
||
}
|
||
|
||
reinitializeVideoListView({ reason, postLoginResult } = {}) {
|
||
if (!this.videoListView) {
|
||
return;
|
||
}
|
||
|
||
if (this.videoListViewController) {
|
||
const { videoList, popularTags } = this.videoListViewController.reinitialize({
|
||
view: this.videoListView,
|
||
reason,
|
||
postLoginResult,
|
||
currentVideoList: this.videoList,
|
||
});
|
||
this.videoList = videoList || null;
|
||
this.videoListPopularTags = popularTags || null;
|
||
return;
|
||
}
|
||
|
||
const isElement = (value) =>
|
||
typeof HTMLElement !== "undefined" && value instanceof HTMLElement;
|
||
|
||
const container = isElement(this.videoList)
|
||
? this.videoList
|
||
: document.getElementById("videoList");
|
||
|
||
try {
|
||
this.videoListView.destroy();
|
||
} catch (error) {
|
||
devLogger.warn(
|
||
"[Application] Failed to destroy VideoListView during reinitialization:",
|
||
error,
|
||
);
|
||
}
|
||
|
||
const messageContext =
|
||
reason === "login" && postLoginResult?.blocksLoaded !== false
|
||
? "Applying your filters…"
|
||
: "Refreshing videos…";
|
||
|
||
this.videoList = isElement(container) ? container : null;
|
||
const tagsRoot = document.getElementById("videoListTags");
|
||
this.videoListPopularTags = isElement(tagsRoot) ? tagsRoot : null;
|
||
if (typeof this.videoListView.setPopularTagsContainer === "function") {
|
||
this.videoListView.setPopularTagsContainer(this.videoListPopularTags);
|
||
}
|
||
|
||
if (this.videoList && this.videoList.children.length === 0) {
|
||
this.videoList.innerHTML = getSidebarLoadingMarkup(messageContext);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Attempt to load the user's own profile from Nostr (kind:0).
|
||
*/
|
||
async loadOwnProfile(pubkey) {
|
||
return this.authService.loadOwnProfile(pubkey);
|
||
}
|
||
|
||
async fetchAndRenderProfile(pubkey, forceRefresh = false) {
|
||
return this.authService.fetchAndRenderProfile(pubkey, forceRefresh);
|
||
}
|
||
|
||
async batchFetchProfiles(authorSet) {
|
||
return batchFetchProfilesFromRelays({
|
||
authorSet,
|
||
getProfileCacheEntry: (pubkey) => this.getProfileCacheEntry(pubkey),
|
||
setProfileCacheEntry: (pubkey, profile) =>
|
||
this.setProfileCacheEntry(pubkey, profile),
|
||
updateProfileInDOM: (pubkey, profile) =>
|
||
this.updateProfileInDOM(pubkey, profile),
|
||
hex64Regex: HEX64_REGEX,
|
||
});
|
||
}
|
||
|
||
getDmRecipientPubkey() {
|
||
return this.dmRecipientPubkey;
|
||
}
|
||
|
||
setDmRecipientPubkey(pubkey) {
|
||
const normalized = this.normalizeHexPubkey(pubkey);
|
||
this.dmRecipientPubkey = normalized || null;
|
||
return this.dmRecipientPubkey;
|
||
}
|
||
|
||
getDmRelayHints(pubkey) {
|
||
const normalized = this.normalizeHexPubkey(pubkey);
|
||
if (!normalized || !(this.dmRelayHints instanceof Map)) {
|
||
return [];
|
||
}
|
||
const hints = this.dmRelayHints.get(normalized);
|
||
return Array.isArray(hints) ? hints.slice() : [];
|
||
}
|
||
|
||
setDmRelayHints(pubkey, hints = []) {
|
||
const normalized = this.normalizeHexPubkey(pubkey);
|
||
if (!normalized) {
|
||
return [];
|
||
}
|
||
if (!(this.dmRelayHints instanceof Map)) {
|
||
this.dmRelayHints = new Map();
|
||
}
|
||
const stored = sanitizeRelayList(Array.isArray(hints) ? hints : []);
|
||
this.dmRelayHints.set(normalized, stored);
|
||
return stored.slice();
|
||
}
|
||
|
||
async publishDmRelayPreferences({ pubkey, relays } = {}) {
|
||
const normalized = this.normalizeHexPubkey(pubkey || this.pubkey);
|
||
if (!normalized) {
|
||
const error = new Error("A valid pubkey is required to publish DM relays.");
|
||
error.code = "invalid-pubkey";
|
||
throw error;
|
||
}
|
||
|
||
const relayHints = sanitizeRelayList(Array.isArray(relays) ? relays : []);
|
||
if (!relayHints.length) {
|
||
const error = new Error("Add at least one DM relay before publishing.");
|
||
error.code = "empty";
|
||
throw error;
|
||
}
|
||
|
||
if (!nostrClient?.pool) {
|
||
const error = new Error(
|
||
"Nostr is not connected yet. Please try again once relays are ready.",
|
||
);
|
||
error.code = "nostr-uninitialized";
|
||
throw error;
|
||
}
|
||
|
||
const signer = await nostrClient.ensureActiveSignerForPubkey(normalized);
|
||
if (!signer || typeof signer.signEvent !== "function") {
|
||
const error = new Error(
|
||
"An active signer with signEvent support is required to publish DM relays.",
|
||
);
|
||
error.code = "nostr-extension-missing";
|
||
throw error;
|
||
}
|
||
|
||
if (signer.type === "extension" && nostrClient.ensureExtensionPermissions) {
|
||
const permissionResult = await nostrClient.ensureExtensionPermissions();
|
||
if (!permissionResult?.ok) {
|
||
userLogger.warn(
|
||
"[Application] Signer permissions denied while publishing DM relays.",
|
||
permissionResult?.error,
|
||
);
|
||
const error = new Error(
|
||
"The active signer must allow signing before publishing DM relays.",
|
||
);
|
||
error.code = "extension-permission-denied";
|
||
error.cause = permissionResult?.error;
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
const event = buildDmRelayListEvent({
|
||
pubkey: normalized,
|
||
created_at: Math.floor(Date.now() / 1000),
|
||
relays: relayHints,
|
||
});
|
||
|
||
const signedEvent = await signer.signEvent(event);
|
||
|
||
const relayTargets = sanitizeRelayList(
|
||
Array.isArray(nostrClient.writeRelays) && nostrClient.writeRelays.length
|
||
? nostrClient.writeRelays
|
||
: Array.isArray(nostrClient.relays)
|
||
? nostrClient.relays
|
||
: [],
|
||
);
|
||
|
||
if (!relayTargets.length) {
|
||
const error = new Error("No relay targets are available for publishing.");
|
||
error.code = "no-targets";
|
||
throw error;
|
||
}
|
||
|
||
const publishResults = await publishEventToRelays(
|
||
nostrClient.pool,
|
||
relayTargets,
|
||
signedEvent,
|
||
);
|
||
|
||
let publishSummary;
|
||
try {
|
||
publishSummary = assertAnyRelayAccepted(publishResults, {
|
||
context: "dm relay hints update",
|
||
message: "No relays accepted the DM relay list.",
|
||
});
|
||
} catch (publishError) {
|
||
if (publishError?.relayFailures?.length) {
|
||
publishError.relayFailures.forEach(
|
||
({ url, error: relayError, reason }) => {
|
||
userLogger.error(
|
||
`[Application] Relay ${url} rejected DM relay list: ${reason}`,
|
||
relayError || reason,
|
||
);
|
||
},
|
||
);
|
||
}
|
||
throw publishError;
|
||
}
|
||
|
||
if (publishSummary.failed.length) {
|
||
publishSummary.failed.forEach(({ url, error: relayError }) => {
|
||
const reason =
|
||
relayError instanceof Error
|
||
? relayError.message
|
||
: relayError
|
||
? String(relayError)
|
||
: "publish failed";
|
||
userLogger.warn(
|
||
`[Application] Relay ${url} did not acknowledge DM relay hints: ${reason}`,
|
||
relayError,
|
||
);
|
||
});
|
||
}
|
||
|
||
return {
|
||
ok: true,
|
||
event: signedEvent,
|
||
accepted: publishSummary.accepted.map(({ url }) => url),
|
||
failed: publishSummary.failed.map(({ url, error: relayError }) => ({
|
||
url,
|
||
error: relayError || null,
|
||
})),
|
||
};
|
||
}
|
||
|
||
handleProfilePublishDmRelayPreferences({ relays, pubkey } = {}) {
|
||
return this.publishDmRelayPreferences({ relays, pubkey });
|
||
}
|
||
|
||
async fetchDmRelayHints(pubkey) {
|
||
const normalized = this.normalizeHexPubkey(pubkey);
|
||
if (!normalized) {
|
||
return [];
|
||
}
|
||
|
||
const cached = this.getDmRelayHints(normalized);
|
||
if (cached.length) {
|
||
return cached;
|
||
}
|
||
|
||
const relayCandidates =
|
||
Array.isArray(nostrClient?.readRelays) && nostrClient.readRelays.length
|
||
? nostrClient.readRelays
|
||
: Array.isArray(nostrClient?.relays)
|
||
? nostrClient.relays
|
||
: [];
|
||
|
||
const relayList = sanitizeRelayList(relayCandidates);
|
||
if (!relayList.length) {
|
||
return [];
|
||
}
|
||
|
||
if (!nostrClient?.pool || typeof nostrClient.pool.list !== "function") {
|
||
return [];
|
||
}
|
||
|
||
try {
|
||
const events = await nostrClient.pool.list(relayList, [
|
||
{ kinds: [10050], authors: [normalized], limit: 1 },
|
||
]);
|
||
const sorted = Array.isArray(events)
|
||
? events
|
||
.filter((event) => event && event.pubkey === normalized)
|
||
.sort((a, b) => (b?.created_at || 0) - (a?.created_at || 0))
|
||
: [];
|
||
if (!sorted.length) {
|
||
return [];
|
||
}
|
||
|
||
const tags = Array.isArray(sorted[0]?.tags) ? sorted[0].tags : [];
|
||
const relayHints = sanitizeRelayList(
|
||
tags
|
||
.filter((tag) => Array.isArray(tag) && tag[0] === "relay")
|
||
.map((tag) => (typeof tag[1] === "string" ? tag[1].trim() : "")),
|
||
);
|
||
|
||
this.setDmRelayHints(normalized, relayHints);
|
||
return relayHints;
|
||
} catch (error) {
|
||
devLogger.warn(
|
||
"[Application] Failed to load DM relay hints:",
|
||
error,
|
||
);
|
||
}
|
||
|
||
return [];
|
||
}
|
||
|
||
openDirectMessageComposer({ recipientPubkey, source = "" } = {}) {
|
||
const normalized = this.normalizeHexPubkey(recipientPubkey);
|
||
if (!normalized) {
|
||
this.showError("Please select a valid message recipient.");
|
||
return false;
|
||
}
|
||
|
||
this.setDmRecipientPubkey(normalized);
|
||
|
||
if (this.profileController) {
|
||
try {
|
||
if (typeof this.profileController.setDirectMessageRecipient === "function") {
|
||
this.profileController.setDirectMessageRecipient(normalized, {
|
||
reason: source || "external",
|
||
});
|
||
}
|
||
if (typeof this.profileController.show === "function") {
|
||
this.profileController.show("messages");
|
||
}
|
||
if (typeof this.profileController.focusMessageComposer === "function") {
|
||
this.profileController.focusMessageComposer();
|
||
}
|
||
} catch (error) {
|
||
devLogger.warn(
|
||
"[Application] Failed to open DM composer:",
|
||
error,
|
||
);
|
||
}
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
handleProfileSendDmRequest({ recipient } = {}) {
|
||
const recipientPubkey =
|
||
typeof recipient?.pubkey === "string"
|
||
? recipient.pubkey
|
||
: null;
|
||
|
||
return this.openDirectMessageComposer({
|
||
recipientPubkey,
|
||
source: "profile-modal",
|
||
});
|
||
}
|
||
|
||
handleProfileUseDmRelays({ recipient, controller } = {}) {
|
||
const relayHints = Array.isArray(recipient?.relayHints)
|
||
? recipient.relayHints
|
||
: [];
|
||
if (!relayHints.length) {
|
||
this.showError("No DM relays found for this recipient.");
|
||
return false;
|
||
}
|
||
|
||
if (recipient?.pubkey) {
|
||
this.setDmRelayHints(recipient.pubkey, relayHints);
|
||
}
|
||
|
||
this.showSuccess("Recipient DM relays ready for use.");
|
||
if (controller?.focusMessageComposer) {
|
||
controller.focusMessageComposer();
|
||
}
|
||
return true;
|
||
}
|
||
|
||
handleProfilePrivacyToggle({ enabled, controller, recipient } = {}) {
|
||
const relayHints = Array.isArray(recipient?.relayHints)
|
||
? recipient.relayHints
|
||
: [];
|
||
|
||
if (enabled && !relayHints.length) {
|
||
this.showStatus(
|
||
"Privacy warning: this recipient has not shared NIP-17 relays, so we'll use your default relays.",
|
||
);
|
||
}
|
||
|
||
this.showStatus(
|
||
enabled
|
||
? "NIP-17 privacy delivery enabled for this recipient."
|
||
: "Using standard DM delivery for this recipient.",
|
||
);
|
||
}
|
||
|
||
updateProfileInDOM(pubkey, profile) {
|
||
if (this.profileIdentityController) {
|
||
this.profileIdentityController.updateProfileIdentity({
|
||
pubkey,
|
||
profile,
|
||
});
|
||
} else {
|
||
devLogger.warn(
|
||
"[Application] ProfileIdentityController missing; profile identity was not refreshed in the DOM.",
|
||
);
|
||
}
|
||
|
||
const normalizedPubkey =
|
||
this.normalizeHexPubkey(pubkey) ||
|
||
(typeof pubkey === "string" ? pubkey.trim() : "");
|
||
if (!normalizedPubkey) {
|
||
return;
|
||
}
|
||
|
||
const maybeDecorateVideo = (video) => {
|
||
if (!video || typeof video !== "object") {
|
||
return;
|
||
}
|
||
const videoPubkey =
|
||
this.normalizeHexPubkey(video.pubkey) ||
|
||
(typeof video.pubkey === "string" ? video.pubkey.trim() : "");
|
||
if (videoPubkey !== normalizedPubkey) {
|
||
return;
|
||
}
|
||
this.decorateVideoCreatorIdentity(video);
|
||
};
|
||
|
||
if (this.videosMap instanceof Map) {
|
||
for (const video of this.videosMap.values()) {
|
||
maybeDecorateVideo(video);
|
||
}
|
||
}
|
||
|
||
if (
|
||
this.videoListView &&
|
||
Array.isArray(this.videoListView.currentVideos)
|
||
) {
|
||
this.videoListView.currentVideos.forEach((video) => {
|
||
maybeDecorateVideo(video);
|
||
});
|
||
}
|
||
|
||
if (this.currentVideo) {
|
||
maybeDecorateVideo(this.currentVideo);
|
||
}
|
||
}
|
||
|
||
async publishVideoNote(payload, { onSuccess, suppressModalClose } = {}) {
|
||
if (!this.pubkey) {
|
||
this.showError("Please login to post a video.");
|
||
return false;
|
||
}
|
||
|
||
const { payload: publishPayload, errors } = normalizeVideoNotePayload(
|
||
payload,
|
||
);
|
||
|
||
if (errors.length) {
|
||
const message = getVideoNoteErrorMessage(errors[0]);
|
||
this.showError(message);
|
||
return false;
|
||
}
|
||
|
||
try {
|
||
await this.nostrService.publishVideoNote(publishPayload, this.pubkey);
|
||
} catch (err) {
|
||
devLogger.error("Failed to publish video:", err);
|
||
this.showError("Failed to share video. Please try again later.");
|
||
return false;
|
||
}
|
||
|
||
if (typeof onSuccess === "function") {
|
||
await onSuccess();
|
||
}
|
||
|
||
if (suppressModalClose !== true && this.uploadModal) {
|
||
this.uploadModal.close();
|
||
}
|
||
|
||
let loadVideosError = null;
|
||
|
||
try {
|
||
await this.loadVideos();
|
||
} catch (error) {
|
||
loadVideosError = error;
|
||
devLogger.error(
|
||
"[Application] Failed to refresh videos after publishing:",
|
||
error
|
||
);
|
||
}
|
||
|
||
this.showSuccess("Video shared successfully!");
|
||
|
||
if (loadVideosError) {
|
||
this.showStatus(
|
||
"Video shared, but the feed may be out of date. Refresh the page to see the latest posts.",
|
||
{ autoHideMs: 8000 }
|
||
);
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* Actually handle the upload form submission.
|
||
*/
|
||
async handleUploadSubmitEvent(event) {
|
||
if (this.uploadSubmitPromise) {
|
||
devLogger.warn(
|
||
"[Application] Ignoring upload submit while a publish is already in progress.",
|
||
);
|
||
this.showStatus(
|
||
"Please wait for your current video to finish publishing before sharing another.",
|
||
{ autoHideMs: 6000, showSpinner: false },
|
||
);
|
||
if (this.uploadModal?.updateCustomSubmitButtonState) {
|
||
this.uploadModal.updateCustomSubmitButtonState();
|
||
}
|
||
return;
|
||
}
|
||
|
||
const payload = event?.detail?.payload || {};
|
||
|
||
const submitPromise = (async () => {
|
||
try {
|
||
const publishResult = await this.authService.handleUploadSubmit(payload, {
|
||
publish: (data) =>
|
||
this.publishVideoNote(data, {
|
||
onSuccess: () => {
|
||
if (this.uploadModal?.resetCustomForm) {
|
||
this.uploadModal.resetCustomForm();
|
||
}
|
||
},
|
||
}),
|
||
});
|
||
if (!publishResult && this.uploadModal?.cancelCustomSubmitCooldown) {
|
||
this.uploadModal.cancelCustomSubmitCooldown();
|
||
}
|
||
} catch (error) {
|
||
if (this.uploadModal?.cancelCustomSubmitCooldown) {
|
||
this.uploadModal.cancelCustomSubmitCooldown();
|
||
}
|
||
const message = this.describeLoginError(
|
||
error,
|
||
"Login required to publish videos.",
|
||
);
|
||
this.showError(message);
|
||
}
|
||
})();
|
||
|
||
this.uploadSubmitPromise = submitPromise;
|
||
|
||
try {
|
||
await submitPromise;
|
||
} finally {
|
||
if (this.uploadSubmitPromise === submitPromise) {
|
||
this.uploadSubmitPromise = null;
|
||
}
|
||
}
|
||
}
|
||
|
||
async handleProfileSwitchRequest({ pubkey, providerId } = {}) {
|
||
if (!pubkey) {
|
||
throw new Error("Missing pubkey for profile switch request.");
|
||
}
|
||
|
||
const result = await this.authService.switchProfile(pubkey, { providerId });
|
||
|
||
if (result?.switched) {
|
||
const detail = result.detail || null;
|
||
|
||
if (
|
||
detail?.postLoginPromise &&
|
||
typeof detail.postLoginPromise.then === "function"
|
||
) {
|
||
try {
|
||
await detail.postLoginPromise;
|
||
} catch (error) {
|
||
devLogger.warn(
|
||
"Failed to complete post-login hydration before continuing after profile switch:",
|
||
error,
|
||
);
|
||
}
|
||
}
|
||
|
||
try {
|
||
await this.handleModerationSettingsChange({
|
||
settings: getModerationSettings(),
|
||
skipRefresh: true,
|
||
});
|
||
} catch (error) {
|
||
devLogger.warn(
|
||
"Failed to sync moderation settings after profile switch:",
|
||
error,
|
||
);
|
||
}
|
||
|
||
const refreshCompleted = await this.waitForIdentityRefresh({
|
||
reason: "profile-switch",
|
||
});
|
||
|
||
if (!refreshCompleted) {
|
||
devLogger.warn(
|
||
"[Application] Fallback identity refresh was required after switching profiles.",
|
||
);
|
||
}
|
||
|
||
if (this.watchHistoryTelemetry?.resetPlaybackLoggingState) {
|
||
try {
|
||
this.watchHistoryTelemetry.resetPlaybackLoggingState();
|
||
} catch (error) {
|
||
devLogger.warn(
|
||
"Failed to reset watch history telemetry after profile switch:",
|
||
error,
|
||
);
|
||
}
|
||
}
|
||
|
||
if (this.watchHistoryTelemetry?.refreshPreferenceSettings) {
|
||
try {
|
||
this.watchHistoryTelemetry.refreshPreferenceSettings();
|
||
} catch (error) {
|
||
devLogger.warn(
|
||
"Failed to refresh watch history preferences after profile switch:",
|
||
error,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
async waitForIdentityRefresh({
|
||
reason = "identity-refresh",
|
||
attempts = 6,
|
||
} = {}) {
|
||
const maxAttempts = Number.isFinite(attempts)
|
||
? Math.max(1, Math.floor(attempts))
|
||
: 6;
|
||
const waitForTick = () =>
|
||
new Promise((resolve) => {
|
||
if (typeof queueMicrotask === "function") {
|
||
queueMicrotask(resolve);
|
||
} else if (typeof setTimeout === "function") {
|
||
setTimeout(resolve, 0);
|
||
} else {
|
||
resolve();
|
||
}
|
||
});
|
||
|
||
let promise = null;
|
||
|
||
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
||
const candidate = this.lastIdentityRefreshPromise;
|
||
if (candidate && typeof candidate.then === "function") {
|
||
promise = candidate;
|
||
break;
|
||
}
|
||
// Yield to allow the auth login flow to schedule the refresh promise.
|
||
// eslint-disable-next-line no-await-in-loop
|
||
await waitForTick();
|
||
}
|
||
|
||
if (promise && typeof promise.then === "function") {
|
||
try {
|
||
await promise;
|
||
return true;
|
||
} catch (error) {
|
||
devLogger.error(
|
||
"[Application] Identity refresh promise rejected:",
|
||
error,
|
||
);
|
||
}
|
||
}
|
||
|
||
try {
|
||
await this.refreshAllVideoGrids({
|
||
reason,
|
||
forceMainReload: true,
|
||
});
|
||
} catch (error) {
|
||
devLogger.error(
|
||
"[Application] Failed to refresh video grids after waiting for identity refresh:",
|
||
error,
|
||
);
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
async handleProfileLogoutRequest({ pubkey, entry } = {}) {
|
||
const candidatePubkey =
|
||
typeof pubkey === "string" && pubkey.trim()
|
||
? pubkey.trim()
|
||
: typeof entry?.pubkey === "string" && entry.pubkey.trim()
|
||
? entry.pubkey.trim()
|
||
: "";
|
||
|
||
if (!candidatePubkey) {
|
||
return { loggedOut: false, reason: "invalid-pubkey" };
|
||
}
|
||
|
||
const normalizedTarget =
|
||
this.normalizeHexPubkey(candidatePubkey) || candidatePubkey;
|
||
if (!normalizedTarget) {
|
||
return { loggedOut: false, reason: "invalid-pubkey" };
|
||
}
|
||
|
||
const activeNormalized = this.normalizeHexPubkey(getActiveProfilePubkey());
|
||
if (activeNormalized && activeNormalized === normalizedTarget) {
|
||
const detail = await this.requestLogout();
|
||
return {
|
||
loggedOut: true,
|
||
reason: "active-profile",
|
||
active: true,
|
||
detail,
|
||
};
|
||
}
|
||
|
||
let removalResult;
|
||
try {
|
||
removalResult = this.authService.removeSavedProfile(candidatePubkey);
|
||
} catch (error) {
|
||
devLogger.error(
|
||
"[Application] Failed to remove saved profile during logout request:",
|
||
error,
|
||
);
|
||
return { loggedOut: false, reason: "remove-failed", error };
|
||
}
|
||
|
||
if (!removalResult?.removed) {
|
||
if (removalResult?.error) {
|
||
devLogger.warn(
|
||
"[Application] removeSavedProfile returned an error during logout request:",
|
||
removalResult.error,
|
||
);
|
||
}
|
||
return { loggedOut: false, reason: "not-found" };
|
||
}
|
||
|
||
if (
|
||
this.nwcSettingsService &&
|
||
typeof this.nwcSettingsService.clearStoredNwcSettings === "function"
|
||
) {
|
||
try {
|
||
await this.nwcSettingsService.clearStoredNwcSettings(normalizedTarget, {
|
||
silent: true,
|
||
});
|
||
} catch (error) {
|
||
devLogger.warn(
|
||
"[Application] Failed to clear wallet settings for logged-out profile:",
|
||
error,
|
||
);
|
||
}
|
||
}
|
||
|
||
this.renderSavedProfiles();
|
||
|
||
return { loggedOut: true, removed: true };
|
||
}
|
||
|
||
async handleProfileRelayOperation({
|
||
action,
|
||
url,
|
||
activePubkey,
|
||
skipPublishIfUnchanged = true,
|
||
} = {}) {
|
||
const context = {
|
||
action,
|
||
url,
|
||
ok: false,
|
||
changed: false,
|
||
reason: null,
|
||
error: null,
|
||
publishResult: null,
|
||
operationResult: null,
|
||
};
|
||
|
||
if (!activePubkey) {
|
||
context.reason = "no-active-pubkey";
|
||
return context;
|
||
}
|
||
|
||
const previous = relayManager.snapshot();
|
||
|
||
let operationResult;
|
||
try {
|
||
switch (action) {
|
||
case "add":
|
||
operationResult = relayManager.addRelay(url);
|
||
break;
|
||
case "remove":
|
||
operationResult = relayManager.removeRelay(url);
|
||
break;
|
||
case "restore":
|
||
operationResult = relayManager.restoreDefaults();
|
||
break;
|
||
case "mode-toggle":
|
||
operationResult = relayManager.cycleRelayMode(url);
|
||
break;
|
||
default: {
|
||
const error = Object.assign(new Error("Unknown relay operation."), {
|
||
code: "invalid-operation",
|
||
});
|
||
throw error;
|
||
}
|
||
}
|
||
} catch (error) {
|
||
context.reason = error?.code || "operation-error";
|
||
context.error = error;
|
||
return context;
|
||
}
|
||
|
||
context.operationResult = operationResult;
|
||
context.changed = Boolean(operationResult?.changed);
|
||
|
||
if (!context.changed && skipPublishIfUnchanged) {
|
||
context.reason = operationResult?.reason || "unchanged";
|
||
return context;
|
||
}
|
||
|
||
try {
|
||
const publishResult = await relayManager.publishRelayList(activePubkey);
|
||
if (!publishResult?.ok) {
|
||
throw Object.assign(new Error("No relays accepted the update."), {
|
||
code: "publish-failed",
|
||
});
|
||
}
|
||
context.ok = true;
|
||
context.publishResult = publishResult;
|
||
|
||
const refreshReason = `relay-${action || "update"}`;
|
||
try {
|
||
await this.onVideosShouldRefresh({ reason: refreshReason });
|
||
} catch (refreshError) {
|
||
devLogger.warn(
|
||
"[Profile] Failed to refresh videos after relay update:",
|
||
refreshError,
|
||
);
|
||
}
|
||
|
||
return context;
|
||
} catch (error) {
|
||
context.reason = error?.code || "publish-failed";
|
||
context.error = error;
|
||
try {
|
||
if (Array.isArray(previous)) {
|
||
relayManager.setEntries(previous, { allowEmpty: false });
|
||
}
|
||
} catch (restoreError) {
|
||
devLogger.warn(
|
||
"[Profile] Failed to restore relay preferences after publish error:",
|
||
restoreError,
|
||
);
|
||
}
|
||
return context;
|
||
}
|
||
}
|
||
|
||
handleProfileRelayModeToggle(payload = {}) {
|
||
return payload?.context || null;
|
||
}
|
||
|
||
handleProfileRelayRestore(payload = {}) {
|
||
return payload?.context || null;
|
||
}
|
||
|
||
async handleProfileBlocklistMutation({
|
||
action,
|
||
actorHex,
|
||
targetHex,
|
||
} = {}) {
|
||
const context = { ok: false, reason: null, error: null };
|
||
|
||
if (!actorHex || !targetHex) {
|
||
context.reason = "invalid-target";
|
||
return context;
|
||
}
|
||
|
||
try {
|
||
await userBlocks.ensureLoaded(actorHex);
|
||
const isBlocked = userBlocks.isBlocked(targetHex);
|
||
|
||
if (action === "add") {
|
||
if (isBlocked) {
|
||
context.reason = "already-blocked";
|
||
return context;
|
||
}
|
||
await userBlocks.addBlock(targetHex, actorHex);
|
||
context.ok = true;
|
||
context.reason = "blocked";
|
||
} else if (action === "remove") {
|
||
if (!isBlocked) {
|
||
context.reason = "not-blocked";
|
||
return context;
|
||
}
|
||
await userBlocks.removeBlock(targetHex, actorHex);
|
||
context.ok = true;
|
||
context.reason = "unblocked";
|
||
} else {
|
||
context.reason = "invalid-action";
|
||
return context;
|
||
}
|
||
|
||
if (context.ok) {
|
||
try {
|
||
await this.onVideosShouldRefresh({ reason: `blocklist-${action}` });
|
||
} catch (refreshError) {
|
||
devLogger.error(
|
||
"Failed to refresh videos after blocklist mutation:",
|
||
refreshError,
|
||
);
|
||
}
|
||
}
|
||
|
||
return context;
|
||
} catch (error) {
|
||
context.error = error;
|
||
context.reason = error?.code || "service-error";
|
||
return context;
|
||
}
|
||
}
|
||
|
||
async handleProfileAdminMutation(payload = {}) {
|
||
const action = payload?.action;
|
||
const context = { ok: false, error: null, result: null };
|
||
|
||
try {
|
||
switch (action) {
|
||
case "ensure-ready":
|
||
await accessControl.waitForReady();
|
||
context.ok = true;
|
||
break;
|
||
case "add-moderator":
|
||
context.result = await accessControl.addModerator(
|
||
payload.actorNpub,
|
||
payload.targetNpub,
|
||
);
|
||
context.ok = !!context.result?.ok;
|
||
break;
|
||
case "remove-moderator":
|
||
context.result = await accessControl.removeModerator(
|
||
payload.actorNpub,
|
||
payload.targetNpub,
|
||
);
|
||
context.ok = !!context.result?.ok;
|
||
break;
|
||
case "list-mutation":
|
||
if (payload.listType === "whitelist") {
|
||
context.result =
|
||
payload.mode === "add"
|
||
? await accessControl.addToWhitelist(
|
||
payload.actorNpub,
|
||
payload.targetNpub,
|
||
)
|
||
: await accessControl.removeFromWhitelist(
|
||
payload.actorNpub,
|
||
payload.targetNpub,
|
||
);
|
||
} else {
|
||
context.result =
|
||
payload.mode === "add"
|
||
? await accessControl.addToBlacklist(
|
||
payload.actorNpub,
|
||
payload.targetNpub,
|
||
)
|
||
: await accessControl.removeFromBlacklist(
|
||
payload.actorNpub,
|
||
payload.targetNpub,
|
||
);
|
||
}
|
||
context.ok = !!context.result?.ok;
|
||
break;
|
||
default:
|
||
context.error = Object.assign(
|
||
new Error("Unknown admin mutation."),
|
||
{ code: "invalid-action" },
|
||
);
|
||
}
|
||
} catch (error) {
|
||
context.error = error;
|
||
return context;
|
||
}
|
||
|
||
return context;
|
||
}
|
||
|
||
async handleProfileWalletPersist(options = {}) {
|
||
return this.nwcSettingsService.handleProfileWalletPersist(options);
|
||
}
|
||
|
||
async handleProfileWalletTest({ nwcUri, defaultZap } = {}) {
|
||
return this.nwcSettingsService.ensureWallet({ nwcUri, defaultZap });
|
||
}
|
||
|
||
async handleProfileWalletDisconnect() {
|
||
return this.nwcSettingsService.updateActiveNwcSettings(
|
||
this.nwcSettingsService.createDefaultNwcSettings(),
|
||
);
|
||
}
|
||
|
||
handleProfileAdminNotifyError({ error } = {}) {
|
||
if (!error) {
|
||
return;
|
||
}
|
||
devLogger.warn("[admin] Notification dispatch issue:", error);
|
||
}
|
||
|
||
handleProfileHistoryEvent() {
|
||
return null;
|
||
}
|
||
|
||
async handleModerationSettingsChange({ settings, skipRefresh = false } = {}) {
|
||
const normalized = this.normalizeModerationSettings(settings);
|
||
this.moderationSettings = normalized;
|
||
const feedContext = {
|
||
feedName: this.feedName || "",
|
||
feedVariant: this.feedVariant || "",
|
||
};
|
||
|
||
if (this.videosMap instanceof Map) {
|
||
for (const video of this.videosMap.values()) {
|
||
if (video && typeof video === "object") {
|
||
this.decorateVideoModeration(video, feedContext);
|
||
}
|
||
}
|
||
}
|
||
|
||
if (
|
||
this.videoListView &&
|
||
Array.isArray(this.videoListView.videoCardInstances)
|
||
) {
|
||
for (const card of this.videoListView.videoCardInstances) {
|
||
if (!card || typeof card.refreshModerationUi !== "function") {
|
||
continue;
|
||
}
|
||
if (card.video && typeof card.video === "object") {
|
||
this.decorateVideoModeration(card.video, feedContext);
|
||
}
|
||
try {
|
||
card.refreshModerationUi();
|
||
} catch (error) {
|
||
devLogger.warn(
|
||
"[Application] Failed to refresh moderation UI:",
|
||
error,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
if (this.videoListView && Array.isArray(this.videoListView.currentVideos)) {
|
||
for (const video of this.videoListView.currentVideos) {
|
||
if (video && typeof video === "object") {
|
||
this.decorateVideoModeration(video, feedContext);
|
||
}
|
||
}
|
||
}
|
||
|
||
if (this.currentVideo && typeof this.currentVideo === "object") {
|
||
this.decorateVideoModeration(this.currentVideo, feedContext);
|
||
}
|
||
|
||
this.moderationDecorator.updateSettings(normalized);
|
||
|
||
if (!skipRefresh) {
|
||
try {
|
||
await this.onVideosShouldRefresh({ reason: "moderation-settings-change" });
|
||
} catch (error) {
|
||
devLogger.warn(
|
||
"[Application] Failed to refresh videos after moderation settings change:",
|
||
error,
|
||
);
|
||
}
|
||
}
|
||
|
||
return normalized;
|
||
}
|
||
|
||
refreshVisibleModerationUi({ reason } = {}) {
|
||
const context = reason ? ` after ${reason}` : "";
|
||
const feedContext = {
|
||
feedName: this.feedName || "",
|
||
feedVariant: this.feedVariant || "",
|
||
};
|
||
|
||
const redecorateVideo = (video) => {
|
||
if (!video || typeof video !== "object") {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
this.decorateVideoModeration(video, feedContext);
|
||
} catch (error) {
|
||
devLogger.warn(
|
||
`[Application] Failed to decorate video moderation${context}:`,
|
||
error,
|
||
);
|
||
}
|
||
};
|
||
|
||
if (this.videosMap instanceof Map) {
|
||
for (const video of this.videosMap.values()) {
|
||
redecorateVideo(video);
|
||
}
|
||
}
|
||
|
||
if (this.videoListView && Array.isArray(this.videoListView.currentVideos)) {
|
||
for (const video of this.videoListView.currentVideos) {
|
||
redecorateVideo(video);
|
||
}
|
||
}
|
||
|
||
if (this.videoListView && Array.isArray(this.videoListView.videoCardInstances)) {
|
||
for (const card of this.videoListView.videoCardInstances) {
|
||
if (!card || typeof card !== "object") {
|
||
continue;
|
||
}
|
||
|
||
if (card.video && typeof card.video === "object") {
|
||
redecorateVideo(card.video);
|
||
}
|
||
|
||
if (typeof card.refreshModerationUi === "function") {
|
||
try {
|
||
card.refreshModerationUi();
|
||
} catch (error) {
|
||
devLogger.warn(
|
||
`[Application] Failed to refresh moderation UI on card${context}:`,
|
||
error,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if (this.currentVideo && typeof this.currentVideo === "object") {
|
||
redecorateVideo(this.currentVideo);
|
||
|
||
try {
|
||
this.videoModal?.refreshActiveVideoModeration?.({ video: this.currentVideo });
|
||
} catch (error) {
|
||
devLogger.warn(
|
||
`[Application] Failed to refresh video modal moderation UI${context}:`,
|
||
error,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
updateActiveProfileUI(pubkey, profile = {}) {
|
||
if (this.profileController) {
|
||
this.profileController.handleProfileUpdated({
|
||
pubkey,
|
||
profile,
|
||
});
|
||
return;
|
||
}
|
||
|
||
const picture = profile.picture || "assets/svg/default-profile.svg";
|
||
|
||
if (this.profileAvatar) {
|
||
this.profileAvatar.src = picture;
|
||
}
|
||
|
||
const channelLink = document.getElementById("profileChannelLink");
|
||
if (channelLink instanceof HTMLElement) {
|
||
const targetNpub = this.safeEncodeNpub(pubkey);
|
||
if (targetNpub) {
|
||
channelLink.href = `#view=channel-profile&npub=${targetNpub}`;
|
||
channelLink.dataset.targetNpub = targetNpub;
|
||
channelLink.classList.remove("hidden");
|
||
} else {
|
||
channelLink.removeAttribute("href");
|
||
if (channelLink.dataset) {
|
||
delete channelLink.dataset.targetNpub;
|
||
}
|
||
channelLink.classList.add("hidden");
|
||
}
|
||
}
|
||
}
|
||
|
||
async hydrateNwcSettingsForPubkey(pubkey) {
|
||
return this.nwcSettingsService.hydrateNwcSettingsForPubkey(pubkey);
|
||
}
|
||
|
||
getActiveNwcSettings() {
|
||
return this.nwcSettingsService.getActiveNwcSettings();
|
||
}
|
||
|
||
hasActiveWalletConnection() {
|
||
return this.nwcSettingsService.hasActiveWalletConnection();
|
||
}
|
||
|
||
validateWalletUri(uri, options) {
|
||
return this.nwcSettingsService.validateWalletUri(uri, options);
|
||
}
|
||
|
||
isUserLoggedIn() {
|
||
const normalizedPubkey = this.normalizeHexPubkey(this.pubkey);
|
||
if (!normalizedPubkey) {
|
||
return false;
|
||
}
|
||
|
||
const clientPubkey = this.normalizeHexPubkey(nostrClient?.pubkey);
|
||
if (clientPubkey && clientPubkey !== normalizedPubkey) {
|
||
return false;
|
||
}
|
||
|
||
const sessionActor = nostrClient?.sessionActor || null;
|
||
const sessionActorPubkey = this.normalizeHexPubkey(sessionActor?.pubkey);
|
||
if (sessionActorPubkey && sessionActorPubkey !== normalizedPubkey) {
|
||
const rawPrivateKey =
|
||
typeof sessionActor?.privateKey === "string"
|
||
? sessionActor.privateKey.trim()
|
||
: "";
|
||
const hasEmbeddedPrivateKey =
|
||
rawPrivateKey.length > 0 && HEX64_REGEX.test(rawPrivateKey);
|
||
|
||
const declaredSource =
|
||
typeof sessionActor?.source === "string"
|
||
? sessionActor.source.trim()
|
||
: "";
|
||
const isPersisted = sessionActor?.persisted === true;
|
||
|
||
if (!hasEmbeddedPrivateKey || declaredSource || isPersisted) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
updateShareNostrAuthState({ reason = "" } = {}) {
|
||
if (!this.videoModal?.setShareNostrAuthState) {
|
||
return;
|
||
}
|
||
|
||
const isLoggedIn = this.isUserLoggedIn();
|
||
const hasSigner = Boolean(getActiveSigner());
|
||
|
||
this.videoModal.setShareNostrAuthState({
|
||
isLoggedIn,
|
||
hasSigner,
|
||
reason,
|
||
});
|
||
}
|
||
|
||
async updateActiveNwcSettings(partial = {}) {
|
||
return this.nwcSettingsService.updateActiveNwcSettings(partial);
|
||
}
|
||
|
||
async ensureWallet({ nwcUri, defaultZap } = {}) {
|
||
return this.nwcSettingsService.ensureWallet({ nwcUri, defaultZap });
|
||
}
|
||
|
||
async clearStoredNwcSettings(pubkey, options = {}) {
|
||
return this.nwcSettingsService.clearStoredNwcSettings(pubkey, options);
|
||
}
|
||
|
||
applyAuthenticatedUiState() {
|
||
if (this.loginButton) {
|
||
this.loginButton.classList.add("hidden");
|
||
this.loginButton.setAttribute("hidden", "");
|
||
}
|
||
|
||
if (this.logoutButton) {
|
||
this.logoutButton.classList.remove("hidden");
|
||
}
|
||
|
||
if (this.userStatus) {
|
||
this.userStatus.classList.add("hidden");
|
||
}
|
||
|
||
if (this.uploadButton) {
|
||
this.uploadButton.classList.remove("hidden");
|
||
this.uploadButton.removeAttribute("hidden");
|
||
}
|
||
|
||
if (this.profileButton) {
|
||
this.profileButton.classList.remove("hidden");
|
||
this.profileButton.removeAttribute("hidden");
|
||
}
|
||
|
||
const subscriptionsLink = this.resolveSubscriptionsLink();
|
||
if (subscriptionsLink) {
|
||
subscriptionsLink.classList.remove("hidden");
|
||
}
|
||
|
||
const forYouLink = this.resolveForYouLink();
|
||
if (forYouLink) {
|
||
forYouLink.classList.remove("hidden");
|
||
}
|
||
|
||
const exploreLink = this.resolveExploreLink();
|
||
if (exploreLink) {
|
||
exploreLink.classList.remove("hidden");
|
||
}
|
||
}
|
||
|
||
applyLoggedOutUiState() {
|
||
if (this.loginButton) {
|
||
this.loginButton.classList.remove("hidden");
|
||
this.loginButton.removeAttribute("hidden");
|
||
}
|
||
|
||
if (this.logoutButton) {
|
||
this.logoutButton.classList.add("hidden");
|
||
}
|
||
|
||
if (this.userStatus) {
|
||
this.userStatus.classList.add("hidden");
|
||
}
|
||
|
||
if (this.userPubKey) {
|
||
this.userPubKey.textContent = "";
|
||
}
|
||
|
||
if (this.uploadButton) {
|
||
this.uploadButton.classList.add("hidden");
|
||
this.uploadButton.setAttribute("hidden", "");
|
||
}
|
||
|
||
if (this.profileButton) {
|
||
this.profileButton.classList.add("hidden");
|
||
this.profileButton.setAttribute("hidden", "");
|
||
}
|
||
|
||
const subscriptionsLink = this.resolveSubscriptionsLink();
|
||
if (subscriptionsLink) {
|
||
subscriptionsLink.classList.add("hidden");
|
||
}
|
||
|
||
const forYouLink = this.resolveForYouLink();
|
||
if (forYouLink) {
|
||
forYouLink.classList.add("hidden");
|
||
}
|
||
|
||
const exploreLink = this.resolveExploreLink();
|
||
if (exploreLink) {
|
||
exploreLink.classList.add("hidden");
|
||
}
|
||
}
|
||
|
||
syncAuthUiState() {
|
||
if (this.isUserLoggedIn()) {
|
||
this.applyAuthenticatedUiState();
|
||
} else {
|
||
this.applyLoggedOutUiState();
|
||
}
|
||
}
|
||
|
||
refreshChromeElements() {
|
||
const doc = typeof document !== "undefined" ? document : null;
|
||
const findElement = (id) => {
|
||
if (!doc || typeof doc.getElementById !== "function") {
|
||
return null;
|
||
}
|
||
const element = doc.getElementById(id);
|
||
if (!element || typeof element.addEventListener !== "function") {
|
||
return null;
|
||
}
|
||
return element;
|
||
};
|
||
|
||
this.loginButton = findElement("loginButton");
|
||
this.logoutButton = findElement("logoutButton");
|
||
this.uploadButton = findElement("uploadButton");
|
||
this.profileButton = findElement("profileButton");
|
||
this.closeLoginModalBtn = findElement("closeLoginModal");
|
||
|
||
return {
|
||
loginButton: this.loginButton,
|
||
logoutButton: this.logoutButton,
|
||
uploadButton: this.uploadButton,
|
||
profileButton: this.profileButton,
|
||
closeLoginModalButton: this.closeLoginModalBtn,
|
||
};
|
||
}
|
||
|
||
resolveSubscriptionsLink() {
|
||
if (
|
||
this.subscriptionsLink instanceof HTMLElement &&
|
||
this.subscriptionsLink.isConnected
|
||
) {
|
||
return this.subscriptionsLink;
|
||
}
|
||
|
||
const linkCandidate = document.getElementById("subscriptionsLink");
|
||
if (linkCandidate instanceof HTMLElement) {
|
||
this.subscriptionsLink = linkCandidate;
|
||
return this.subscriptionsLink;
|
||
}
|
||
|
||
this.subscriptionsLink = null;
|
||
return null;
|
||
}
|
||
|
||
resolveForYouLink() {
|
||
if (this.forYouLink instanceof HTMLElement && this.forYouLink.isConnected) {
|
||
return this.forYouLink;
|
||
}
|
||
|
||
const linkCandidate = document.getElementById("forYouLink");
|
||
if (linkCandidate instanceof HTMLElement) {
|
||
this.forYouLink = linkCandidate;
|
||
return this.forYouLink;
|
||
}
|
||
|
||
this.forYouLink = null;
|
||
return null;
|
||
}
|
||
|
||
resolveExploreLink() {
|
||
if (
|
||
this.exploreLink instanceof HTMLElement &&
|
||
this.exploreLink.isConnected
|
||
) {
|
||
return this.exploreLink;
|
||
}
|
||
|
||
const linkCandidate = document.getElementById("exploreLink");
|
||
if (linkCandidate instanceof HTMLElement) {
|
||
this.exploreLink = linkCandidate;
|
||
return this.exploreLink;
|
||
}
|
||
|
||
this.exploreLink = null;
|
||
return null;
|
||
}
|
||
|
||
hydrateSidebarNavigation() {
|
||
const chromeElements = this.refreshChromeElements();
|
||
this.resolveSubscriptionsLink();
|
||
this.resolveForYouLink();
|
||
this.resolveExploreLink();
|
||
|
||
if (this.appChromeController) {
|
||
if (typeof this.appChromeController.setElements === "function") {
|
||
this.appChromeController.setElements(chromeElements);
|
||
} else {
|
||
if (this.appChromeController.elements) {
|
||
Object.assign(this.appChromeController.elements, chromeElements);
|
||
}
|
||
this.appChromeController.initialize();
|
||
}
|
||
}
|
||
|
||
this.syncAuthUiState();
|
||
}
|
||
|
||
maybeShowExperimentalLoginWarning(provider) {
|
||
const normalizedProvider =
|
||
typeof provider === "string" ? provider.trim().toLowerCase() : "";
|
||
|
||
if (normalizedProvider !== "nsec" && normalizedProvider !== "nip46") {
|
||
return;
|
||
}
|
||
|
||
const now = Date.now();
|
||
if (
|
||
this.lastExperimentalWarningKey === normalizedProvider &&
|
||
typeof this.lastExperimentalWarningAt === "number" &&
|
||
now - this.lastExperimentalWarningAt < 2000
|
||
) {
|
||
return;
|
||
}
|
||
|
||
this.lastExperimentalWarningKey = normalizedProvider;
|
||
this.lastExperimentalWarningAt = now;
|
||
|
||
const providerLabel =
|
||
normalizedProvider === "nsec"
|
||
? "Direct nsec or seed"
|
||
: "NIP-46 remote signer";
|
||
|
||
this.showStatus(
|
||
`${providerLabel} logins are still in development and may not work well yet. We recommend using a NIP-07 browser extension for the most reliable experience.`,
|
||
{ showSpinner: false, autoHideMs: 5000 },
|
||
);
|
||
}
|
||
|
||
async handleAuthLogin(detail = {}) {
|
||
const postLoginPromise =
|
||
detail && typeof detail.postLoginPromise?.then === "function"
|
||
? detail.postLoginPromise
|
||
: Promise.resolve(detail?.postLogin ?? null);
|
||
|
||
if (detail && typeof detail === "object") {
|
||
try {
|
||
detail.__handled = true;
|
||
} catch (error) {
|
||
// Ignore attempts to mutate read-only descriptors.
|
||
}
|
||
}
|
||
|
||
if (detail?.identityChanged) {
|
||
this.resetViewLoggingState();
|
||
}
|
||
|
||
this.applyAuthenticatedUiState();
|
||
this.commentController?.refreshAuthState?.();
|
||
this.updateShareNostrAuthState({ reason: "auth-login" });
|
||
if (typeof this.refreshUnreadDmIndicator === "function") {
|
||
void this.refreshUnreadDmIndicator({ reason: "auth-login" });
|
||
}
|
||
|
||
const currentView = getHashViewName();
|
||
const normalizedView =
|
||
typeof currentView === "string" ? currentView.toLowerCase() : "";
|
||
const urlParams = new URLSearchParams(window.location.search);
|
||
const hasVideoParam = urlParams.has("v");
|
||
|
||
if (!normalizedView || normalizedView === "most-recent-videos") {
|
||
setHashView("for-you", { preserveVideoParam: hasVideoParam });
|
||
}
|
||
|
||
const rawProviderId =
|
||
typeof detail?.providerId === "string" ? detail.providerId.trim() : "";
|
||
const rawAuthType =
|
||
typeof detail?.authType === "string" ? detail.authType.trim() : "";
|
||
const normalizedProvider =
|
||
(rawProviderId || rawAuthType).toLowerCase() || "";
|
||
|
||
this.maybeShowExperimentalLoginWarning(normalizedProvider);
|
||
|
||
const loginContext = {
|
||
pubkey: detail?.pubkey || this.pubkey,
|
||
previousPubkey: detail?.previousPubkey,
|
||
identityChanged: Boolean(detail?.identityChanged),
|
||
};
|
||
|
||
try {
|
||
await this.handleModerationSettingsChange({
|
||
settings: getModerationSettings(),
|
||
skipRefresh: true,
|
||
});
|
||
} catch (error) {
|
||
devLogger.warn(
|
||
"Failed to sync moderation settings after login:",
|
||
error,
|
||
);
|
||
}
|
||
|
||
if (loginContext.identityChanged) {
|
||
this.resetHashtagPreferencesState();
|
||
try {
|
||
this.watchHistoryTelemetry?.resetPlaybackLoggingState?.();
|
||
this.watchHistoryTelemetry?.refreshPreferenceSettings?.();
|
||
} catch (error) {
|
||
devLogger.warn(
|
||
"Failed to refresh watch history telemetry after identity change:",
|
||
error,
|
||
);
|
||
}
|
||
}
|
||
|
||
const hashtagPreferencesPromise = Promise.resolve(
|
||
this.loadHashtagPreferencesForPubkey(loginContext.pubkey),
|
||
);
|
||
|
||
if (this.zapController) {
|
||
try {
|
||
this.zapController.setVisibility(
|
||
Boolean(this.currentVideo?.lightningAddress),
|
||
);
|
||
} catch (error) {
|
||
devLogger.warn("[Application] Failed to refresh zap visibility after login:", error);
|
||
}
|
||
}
|
||
|
||
const shouldReopenZap = this.pendingModalZapOpen;
|
||
this.pendingModalZapOpen = false;
|
||
if (shouldReopenZap) {
|
||
const hasLightning = Boolean(this.currentVideo?.lightningAddress);
|
||
if (hasLightning && this.videoModal?.openZapDialog) {
|
||
Promise.resolve()
|
||
.then(() => this.videoModal.openZapDialog())
|
||
.then((opened) => {
|
||
if (opened) {
|
||
this.zapController?.open();
|
||
}
|
||
})
|
||
.catch((error) => {
|
||
devLogger.warn(
|
||
"[Application] Failed to reopen zap dialog after login:",
|
||
error,
|
||
);
|
||
});
|
||
}
|
||
}
|
||
|
||
this.dispatchAuthChange({
|
||
status: "login",
|
||
loggedIn: true,
|
||
pubkey: loginContext.pubkey || null,
|
||
previousPubkey: loginContext.previousPubkey || null,
|
||
});
|
||
|
||
const nwcPromise = Promise.resolve()
|
||
.then(() => this.nwcSettingsService.onLogin(loginContext))
|
||
.catch((error) => {
|
||
devLogger.error("Failed to process NWC settings during login:", error);
|
||
return null;
|
||
});
|
||
|
||
if (this.profileController) {
|
||
try {
|
||
const maybePromise = this.profileController.handleAuthLogin(detail);
|
||
if (maybePromise && typeof maybePromise.then === "function") {
|
||
maybePromise.catch((error) => {
|
||
devLogger.error(
|
||
"Failed to process login within the profile controller:",
|
||
error,
|
||
);
|
||
});
|
||
}
|
||
} catch (error) {
|
||
devLogger.error(
|
||
"Failed to process login within the profile controller:",
|
||
error,
|
||
);
|
||
}
|
||
} else {
|
||
this.renderSavedProfiles();
|
||
}
|
||
|
||
const activePubkey = detail?.pubkey || this.pubkey;
|
||
postLoginPromise
|
||
.then((postLogin) => {
|
||
if (activePubkey && postLogin?.profile) {
|
||
this.updateActiveProfileUI(activePubkey, postLogin.profile);
|
||
}
|
||
this.forceRefreshAllProfiles();
|
||
})
|
||
.catch((error) => {
|
||
devLogger.error("Post-login hydration failed:", error);
|
||
});
|
||
|
||
let postLoginResult = null;
|
||
try {
|
||
postLoginResult = await postLoginPromise;
|
||
} catch (error) {
|
||
devLogger.error("Post-login processing failed:", error);
|
||
}
|
||
|
||
await nwcPromise;
|
||
await hashtagPreferencesPromise;
|
||
|
||
try {
|
||
await accessControl.ensureReady();
|
||
} catch (error) {
|
||
userLogger.error(
|
||
"[Application] Failed to refresh admin lists after login:",
|
||
error,
|
||
);
|
||
}
|
||
|
||
if (activePubkey) {
|
||
const aggregatedBlacklist = accessControl.getBlacklist();
|
||
try {
|
||
await userBlocks.seedWithNpubs(
|
||
activePubkey,
|
||
Array.isArray(aggregatedBlacklist) ? aggregatedBlacklist : [],
|
||
);
|
||
} catch (error) {
|
||
if (
|
||
error?.code === "extension-permission-denied" ||
|
||
error?.code === "nip04-missing" ||
|
||
error?.name === "RelayPublishError"
|
||
) {
|
||
userLogger.error(
|
||
"[Application] Failed to seed shared block list after login:",
|
||
error,
|
||
);
|
||
} else {
|
||
devLogger.error(
|
||
"[Application] Unexpected error while seeding shared block list:",
|
||
error,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
try {
|
||
this.reinitializeVideoListView({ reason: "login", postLoginResult });
|
||
} catch (error) {
|
||
devLogger.warn("Failed to reinitialize video list view after login:", error);
|
||
}
|
||
|
||
try {
|
||
this.lastIdentityRefreshPromise = this.refreshAllVideoGrids({
|
||
reason: "auth-login",
|
||
forceMainReload: true,
|
||
});
|
||
await this.lastIdentityRefreshPromise;
|
||
} catch (error) {
|
||
devLogger.error("Failed to refresh video grids after login:", error);
|
||
} finally {
|
||
this.lastIdentityRefreshPromise = null;
|
||
}
|
||
|
||
this.forceRefreshAllProfiles();
|
||
|
||
if (this.uploadModal?.refreshCloudflareBucketPreview) {
|
||
await this.uploadModal.refreshCloudflareBucketPreview();
|
||
}
|
||
}
|
||
|
||
async requestLogout() {
|
||
const detail = await this.authService.logout();
|
||
|
||
if (detail && typeof detail === "object") {
|
||
if (detail.__handled === true) {
|
||
return detail;
|
||
}
|
||
|
||
try {
|
||
detail.__handled = true;
|
||
} catch (error) {
|
||
devLogger.warn("Failed to mark logout detail as handled:", error);
|
||
}
|
||
}
|
||
|
||
await this.handleAuthLogout(detail);
|
||
return detail ?? null;
|
||
}
|
||
|
||
async handleAuthLogout(detail = {}) {
|
||
if (detail && typeof detail === "object") {
|
||
try {
|
||
detail.__handled = true;
|
||
} catch (error) {
|
||
devLogger.warn("Failed to mark logout detail as handled:", error);
|
||
}
|
||
}
|
||
|
||
this.resetViewLoggingState();
|
||
this.pendingModalZapOpen = false;
|
||
|
||
this.resetHashtagPreferencesState();
|
||
|
||
await this.nwcSettingsService.onLogout({
|
||
pubkey: detail?.pubkey || this.pubkey,
|
||
previousPubkey: detail?.previousPubkey,
|
||
});
|
||
|
||
if (this.profileController) {
|
||
try {
|
||
await this.profileController.handleAuthLogout(detail);
|
||
} catch (error) {
|
||
devLogger.error(
|
||
"Failed to process logout within the profile controller:",
|
||
error,
|
||
);
|
||
}
|
||
} else {
|
||
this.renderSavedProfiles();
|
||
}
|
||
|
||
this.applyLoggedOutUiState();
|
||
this.updateShareNostrAuthState({ reason: "auth-logout" });
|
||
if (typeof this.refreshUnreadDmIndicator === "function") {
|
||
void this.refreshUnreadDmIndicator({ reason: "auth-logout" });
|
||
} else if (this.appChromeController?.setUnreadDmIndicator) {
|
||
this.appChromeController.setUnreadDmIndicator(false);
|
||
}
|
||
|
||
const logoutView = getHashViewName();
|
||
if (
|
||
typeof logoutView === "string" &&
|
||
logoutView.trim().toLowerCase() === "for-you"
|
||
) {
|
||
setHashView("most-recent-videos");
|
||
}
|
||
|
||
const activeModalVideo =
|
||
typeof this.videoModal?.getCurrentVideo === "function"
|
||
? this.videoModal.getCurrentVideo()
|
||
: this.commentController?.currentVideo || null;
|
||
|
||
if (this.commentController && activeModalVideo) {
|
||
// Regression guard: ensure logout refreshes the modal thread so comments stay visible without reopening.
|
||
this.commentController.load(activeModalVideo);
|
||
} else {
|
||
this.commentController?.refreshAuthState?.();
|
||
}
|
||
|
||
try {
|
||
await this.handleModerationSettingsChange({
|
||
settings: getModerationSettings(),
|
||
skipRefresh: true,
|
||
});
|
||
} catch (error) {
|
||
devLogger.warn(
|
||
"Failed to reset moderation settings after logout:",
|
||
error,
|
||
);
|
||
}
|
||
|
||
if (this.videoModal?.closeZapDialog) {
|
||
try {
|
||
this.videoModal.closeZapDialog({ silent: true, restoreFocus: false });
|
||
} catch (error) {
|
||
devLogger.warn("Failed to close zap dialog during logout:", error);
|
||
}
|
||
}
|
||
|
||
if (this.zapController) {
|
||
try {
|
||
this.zapController.resetState();
|
||
this.zapController.setVisibility(Boolean(this.currentVideo?.lightningAddress));
|
||
} catch (error) {
|
||
devLogger.warn("Failed to reset zap controller during logout:", error);
|
||
}
|
||
}
|
||
|
||
if (typeof this.nostrService?.clearVideoSubscription === "function") {
|
||
try {
|
||
this.nostrService.clearVideoSubscription();
|
||
} catch (error) {
|
||
devLogger.warn("Failed to clear video subscription during logout:", error);
|
||
}
|
||
}
|
||
|
||
if (typeof this.nostrService?.resetVideosCache === "function") {
|
||
try {
|
||
this.nostrService.resetVideosCache();
|
||
} catch (error) {
|
||
devLogger.warn("Failed to reset cached videos during logout:", error);
|
||
}
|
||
}
|
||
|
||
await this.renderVideoList({
|
||
videos: [],
|
||
metadata: { reason: "auth:logout" },
|
||
});
|
||
|
||
this.dispatchAuthChange({
|
||
status: "logout",
|
||
loggedIn: false,
|
||
pubkey: detail?.pubkey || null,
|
||
previousPubkey: detail?.previousPubkey || null,
|
||
});
|
||
|
||
try {
|
||
await this.loadVideos(true);
|
||
} catch (error) {
|
||
devLogger.error("Failed to refresh videos after logout:", error);
|
||
}
|
||
this.forceRefreshAllProfiles();
|
||
if (this.uploadModal?.refreshCloudflareBucketPreview) {
|
||
await this.uploadModal.refreshCloudflareBucketPreview();
|
||
}
|
||
}
|
||
|
||
handleProfileUpdated(detail = {}) {
|
||
if (this.profileController) {
|
||
this.profileController.handleProfileUpdated(detail);
|
||
} else if (Array.isArray(detail?.savedProfiles)) {
|
||
this.renderSavedProfiles();
|
||
}
|
||
|
||
const normalizedPubkey = detail?.pubkey
|
||
? this.normalizeHexPubkey(detail.pubkey)
|
||
: null;
|
||
const profile = detail?.profile;
|
||
|
||
if (normalizedPubkey && profile) {
|
||
this.updateProfileInDOM(normalizedPubkey, profile);
|
||
if (
|
||
!this.profileController &&
|
||
this.normalizeHexPubkey(this.pubkey) === normalizedPubkey
|
||
) {
|
||
this.updateActiveProfileUI(normalizedPubkey, profile);
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Cleanup resources on unload or modal close.
|
||
*
|
||
* When `preserveModals` is true the modal infrastructure is kept alive so the
|
||
* next playback session can reuse the existing controllers without
|
||
* reinitializing DOM bindings.
|
||
*/
|
||
async cleanup({
|
||
preserveSubscriptions = false,
|
||
preserveObservers = false,
|
||
preserveModals = false,
|
||
} = {}) {
|
||
this.log(
|
||
`[cleanup] Requested (preserveSubscriptions=${preserveSubscriptions}, preserveObservers=${preserveObservers}, preserveModals=${preserveModals})`
|
||
);
|
||
// Serialise teardown so overlapping calls (e.g. close button spam) don't
|
||
// race each other and clobber a fresh playback setup.
|
||
if (this.cleanupPromise) {
|
||
this.log("[cleanup] Waiting for in-flight cleanup to finish before starting a new run.");
|
||
try {
|
||
await this.cleanupPromise;
|
||
} catch (err) {
|
||
devLogger.warn("Previous cleanup rejected:", err);
|
||
}
|
||
}
|
||
|
||
const runCleanup = async () => {
|
||
this.log(
|
||
`[cleanup] Begin (preserveSubscriptions=${preserveSubscriptions}, preserveObservers=${preserveObservers}, preserveModals=${preserveModals})`
|
||
);
|
||
try {
|
||
this.cancelPendingViewLogging();
|
||
await this.flushWatchHistory("session-end", "cleanup").catch(
|
||
(error) => {
|
||
const message =
|
||
error && typeof error.message === "string"
|
||
? error.message
|
||
: String(error ?? "unknown error");
|
||
this.log(`[cleanup] Watch history flush failed: ${message}`);
|
||
}
|
||
);
|
||
this.clearActiveIntervals();
|
||
if (this.playbackService) {
|
||
this.playbackService.cleanupWatchdog();
|
||
}
|
||
this.teardownModalViewCountSubscription();
|
||
this.teardownModalReactionSubscription();
|
||
|
||
if (!preserveObservers && this.mediaLoader) {
|
||
this.mediaLoader.disconnect();
|
||
}
|
||
|
||
if (!preserveObservers) {
|
||
this.teardownAllViewCountSubscriptions();
|
||
} else {
|
||
this.pruneDetachedViewCountElements();
|
||
}
|
||
|
||
if (!preserveSubscriptions) {
|
||
this.nostrService.clearVideoSubscription();
|
||
this.videoSubscription = this.nostrService.getVideoSubscription() || null;
|
||
}
|
||
|
||
// If there's a small inline player
|
||
if (this.videoElement) {
|
||
this.videoElement = this.teardownVideoElement(this.videoElement);
|
||
}
|
||
if (
|
||
this.videoModal &&
|
||
typeof this.videoModal.clearPosterCleanup === "function"
|
||
) {
|
||
try {
|
||
this.videoModal.clearPosterCleanup();
|
||
} catch (err) {
|
||
devLogger.warn("[cleanup] video modal poster cleanup threw:", err);
|
||
}
|
||
}
|
||
|
||
const modalVideoEl = this.modalVideo;
|
||
if (modalVideoEl) {
|
||
const refreshedModal = this.teardownVideoElement(modalVideoEl, {
|
||
replaceNode: true,
|
||
});
|
||
if (refreshedModal) {
|
||
this.modalVideo = refreshedModal;
|
||
if (
|
||
this.videoModal &&
|
||
typeof this.videoModal.setVideoElement === "function"
|
||
) {
|
||
try {
|
||
this.videoModal.setVideoElement(refreshedModal);
|
||
} catch (err) {
|
||
devLogger.warn(
|
||
"[cleanup] Failed to sync video modal element after replacement:",
|
||
err
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
this.commentController?.dispose({ resetUi: false });
|
||
|
||
if (!preserveModals) {
|
||
if (this.modalManager) {
|
||
try {
|
||
this.modalManager.teardown();
|
||
} catch (error) {
|
||
devLogger.warn("[cleanup] Modal teardown failed:", error);
|
||
}
|
||
this.modalManager = null;
|
||
}
|
||
|
||
if (this.bootstrapper) {
|
||
try {
|
||
this.bootstrapper.teardown();
|
||
} catch (error) {
|
||
devLogger.warn("[cleanup] Bootstrap teardown failed:", error);
|
||
}
|
||
}
|
||
}
|
||
|
||
|
||
// Tell webtorrent to cleanup
|
||
await torrentClient.cleanup();
|
||
this.log("[cleanup] WebTorrent cleanup resolved.");
|
||
|
||
try {
|
||
if (navigator.serviceWorker && navigator.serviceWorker.controller) {
|
||
await fetch("/webtorrent/cancel/", { mode: "no-cors" });
|
||
}
|
||
} catch (err) {
|
||
// Ignore errors when cancelling the service worker stream; it may not be active.
|
||
}
|
||
} catch (err) {
|
||
devLogger.error("Cleanup error:", err);
|
||
} finally {
|
||
this.log("[cleanup] Finished.");
|
||
}
|
||
};
|
||
|
||
const cleanupPromise = runCleanup();
|
||
this.cleanupPromise = cleanupPromise;
|
||
|
||
try {
|
||
await cleanupPromise;
|
||
} finally {
|
||
if (this.cleanupPromise === cleanupPromise) {
|
||
this.cleanupPromise = null;
|
||
}
|
||
}
|
||
}
|
||
|
||
async waitForCleanup() {
|
||
if (!this.cleanupPromise) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
this.log("[waitForCleanup] Awaiting previous cleanup before continuing.");
|
||
await this.cleanupPromise;
|
||
this.log("[waitForCleanup] Previous cleanup completed.");
|
||
} catch (err) {
|
||
devLogger.warn("waitForCleanup observed a rejected cleanup:", err);
|
||
}
|
||
}
|
||
|
||
clearActiveIntervals() {
|
||
if (!Array.isArray(this.activeIntervals) || !this.activeIntervals.length) {
|
||
return;
|
||
}
|
||
this.activeIntervals.forEach((id) => clearInterval(id));
|
||
this.activeIntervals = [];
|
||
}
|
||
|
||
cancelPendingViewLogging() {
|
||
this.watchHistoryTelemetry?.cancelPlaybackLogging?.();
|
||
}
|
||
|
||
resetViewLoggingState() {
|
||
this.watchHistoryTelemetry?.resetPlaybackLoggingState?.();
|
||
}
|
||
|
||
persistWatchHistoryMetadataForVideo(video, pointerInfo) {
|
||
if (this.watchHistoryTelemetry) {
|
||
this.watchHistoryTelemetry.persistMetadataForVideo(video, pointerInfo);
|
||
return;
|
||
}
|
||
|
||
if (
|
||
!pointerInfo ||
|
||
!pointerInfo.key ||
|
||
typeof watchHistoryService?.setLocalMetadata !== "function"
|
||
) {
|
||
return;
|
||
}
|
||
|
||
if (!video || typeof video !== "object") {
|
||
return;
|
||
}
|
||
|
||
const metadata = {
|
||
video: {
|
||
id: typeof video.id === "string" ? video.id : "",
|
||
title: typeof video.title === "string" ? video.title : "",
|
||
thumbnail: typeof video.thumbnail === "string" ? video.thumbnail : "",
|
||
pubkey: typeof video.pubkey === "string" ? video.pubkey : "",
|
||
},
|
||
};
|
||
|
||
try {
|
||
watchHistoryService.setLocalMetadata(pointerInfo.key, metadata);
|
||
} catch (error) {
|
||
devLogger.warn(
|
||
"[watchHistory] Failed to persist local metadata for pointer:",
|
||
pointerInfo.key,
|
||
error,
|
||
);
|
||
}
|
||
}
|
||
|
||
dropWatchHistoryMetadata(pointerKey) {
|
||
if (this.watchHistoryTelemetry) {
|
||
this.watchHistoryTelemetry.dropMetadata(pointerKey);
|
||
return;
|
||
}
|
||
|
||
if (!pointerKey || typeof pointerKey !== "string") {
|
||
return;
|
||
}
|
||
if (typeof watchHistoryService?.removeLocalMetadata !== "function") {
|
||
return;
|
||
}
|
||
try {
|
||
watchHistoryService.removeLocalMetadata(pointerKey);
|
||
} catch (error) {
|
||
devLogger.warn(
|
||
"[watchHistory] Failed to remove cached metadata for pointer:",
|
||
pointerKey,
|
||
error,
|
||
);
|
||
}
|
||
}
|
||
|
||
async handleRemoveHistoryAction(dataset = {}, { trigger } = {}) {
|
||
if (!this.watchHistoryController) {
|
||
this.showError("Watch history sync is not available right now.");
|
||
return;
|
||
}
|
||
|
||
try {
|
||
await this.watchHistoryController.removeEntry({
|
||
dataset,
|
||
trigger,
|
||
removeCard: dataset.removeCard === "true",
|
||
reason: dataset.reason || "remove-item",
|
||
});
|
||
} catch (error) {
|
||
if (!error?.handled) {
|
||
this.showError("Failed to remove from history. Please try again.");
|
||
}
|
||
}
|
||
}
|
||
|
||
async handleWatchHistoryRemoval(payload = {}) {
|
||
if (!this.watchHistoryTelemetry) {
|
||
this.showError("Watch history sync is not available right now.");
|
||
const error = new Error("watch-history-disabled");
|
||
error.handled = true;
|
||
throw error;
|
||
}
|
||
return this.watchHistoryTelemetry.handleRemoval(payload);
|
||
}
|
||
|
||
flushWatchHistory(reason = "session-end", context = "watch-history") {
|
||
if (!this.watchHistoryTelemetry) {
|
||
return Promise.resolve();
|
||
}
|
||
return this.watchHistoryTelemetry.flush(reason, context);
|
||
}
|
||
|
||
getActiveViewIdentityKey() {
|
||
if (!this.watchHistoryTelemetry) {
|
||
const normalizedUser = this.normalizeHexPubkey(this.pubkey);
|
||
if (normalizedUser) {
|
||
return `actor:${normalizedUser}`;
|
||
}
|
||
|
||
const sessionActorPubkey = this.normalizeHexPubkey(
|
||
nostrClient?.sessionActor?.pubkey
|
||
);
|
||
if (sessionActorPubkey) {
|
||
return `actor:${sessionActorPubkey}`;
|
||
}
|
||
|
||
return "actor:anonymous";
|
||
}
|
||
|
||
return this.watchHistoryTelemetry.getActiveViewIdentityKey();
|
||
}
|
||
|
||
deriveViewIdentityKeyFromEvent(event) {
|
||
if (!this.watchHistoryTelemetry) {
|
||
if (!event || typeof event !== "object") {
|
||
return "";
|
||
}
|
||
|
||
const normalizedPubkey = this.normalizeHexPubkey(event.pubkey);
|
||
if (!normalizedPubkey) {
|
||
return "";
|
||
}
|
||
|
||
return `actor:${normalizedPubkey}`;
|
||
}
|
||
|
||
return this.watchHistoryTelemetry.deriveViewIdentityKeyFromEvent(event);
|
||
}
|
||
|
||
buildViewCooldownKey(pointerKey, identityKey) {
|
||
if (!this.watchHistoryTelemetry) {
|
||
const normalizedPointerKey =
|
||
typeof pointerKey === "string" && pointerKey.trim()
|
||
? pointerKey.trim()
|
||
: "";
|
||
if (!normalizedPointerKey) {
|
||
return "";
|
||
}
|
||
|
||
const normalizedIdentity =
|
||
typeof identityKey === "string" && identityKey.trim()
|
||
? identityKey.trim().toLowerCase()
|
||
: "";
|
||
|
||
return normalizedIdentity
|
||
? `${normalizedPointerKey}::${normalizedIdentity}`
|
||
: normalizedPointerKey;
|
||
}
|
||
|
||
return this.watchHistoryTelemetry.buildViewCooldownKey(
|
||
pointerKey,
|
||
identityKey
|
||
);
|
||
}
|
||
|
||
preparePlaybackLogging(videoEl) {
|
||
if (!this.watchHistoryTelemetry) {
|
||
this.cancelPendingViewLogging();
|
||
return;
|
||
}
|
||
|
||
const pointer = this.currentVideoPointer;
|
||
const pointerKey = this.currentVideoPointerKey || pointerArrayToKey(pointer);
|
||
|
||
if (!pointer || !pointerKey) {
|
||
this.watchHistoryTelemetry.cancelPlaybackLogging();
|
||
return;
|
||
}
|
||
|
||
this.watchHistoryTelemetry.preparePlaybackLogging({
|
||
videoElement: videoEl,
|
||
pointer,
|
||
pointerKey,
|
||
video: this.currentVideo,
|
||
});
|
||
}
|
||
|
||
teardownVideoElement(videoElement, { replaceNode = false } = {}) {
|
||
if (!videoElement) {
|
||
this.log(
|
||
`[teardownVideoElement] No video provided (replaceNode=${replaceNode}); skipping.`
|
||
);
|
||
return videoElement;
|
||
}
|
||
|
||
const safe = (fn) => {
|
||
try {
|
||
fn();
|
||
} catch (err) {
|
||
devLogger.warn("[teardownVideoElement]", err);
|
||
}
|
||
};
|
||
|
||
const describeSource = () => {
|
||
try {
|
||
return videoElement.currentSrc || videoElement.src || "<unset>";
|
||
} catch (err) {
|
||
return "<unavailable>";
|
||
}
|
||
};
|
||
|
||
this.log(
|
||
`[teardownVideoElement] Resetting video (replaceNode=${replaceNode}) readyState=${videoElement.readyState} networkState=${videoElement.networkState} src=${describeSource()}`
|
||
);
|
||
|
||
safe(() => videoElement.pause());
|
||
|
||
safe(() => {
|
||
videoElement.removeAttribute("src");
|
||
videoElement.src = "";
|
||
});
|
||
|
||
safe(() => {
|
||
videoElement.srcObject = null;
|
||
});
|
||
|
||
safe(() => {
|
||
if ("crossOrigin" in videoElement) {
|
||
videoElement.crossOrigin = null;
|
||
}
|
||
if (videoElement.hasAttribute("crossorigin")) {
|
||
videoElement.removeAttribute("crossorigin");
|
||
}
|
||
});
|
||
|
||
safe(() => {
|
||
if (typeof videoElement.load === "function") {
|
||
videoElement.load();
|
||
}
|
||
});
|
||
|
||
if (!replaceNode || !videoElement.parentNode) {
|
||
this.log(
|
||
`[teardownVideoElement] Completed without node replacement (readyState=${videoElement.readyState}).`
|
||
);
|
||
return videoElement;
|
||
}
|
||
|
||
const parent = videoElement.parentNode;
|
||
const clone = videoElement.cloneNode(false);
|
||
|
||
if (clone.dataset && "autoplayBound" in clone.dataset) {
|
||
delete clone.dataset.autoplayBound;
|
||
}
|
||
if (clone.hasAttribute("data-autoplay-bound")) {
|
||
clone.removeAttribute("data-autoplay-bound");
|
||
}
|
||
|
||
safe(() => {
|
||
clone.removeAttribute("src");
|
||
clone.src = "";
|
||
});
|
||
|
||
safe(() => {
|
||
clone.srcObject = null;
|
||
});
|
||
|
||
safe(() => {
|
||
if ("crossOrigin" in clone) {
|
||
clone.crossOrigin = null;
|
||
}
|
||
if (clone.hasAttribute("crossorigin")) {
|
||
clone.removeAttribute("crossorigin");
|
||
}
|
||
});
|
||
|
||
clone.autoplay = videoElement.autoplay;
|
||
clone.controls = videoElement.controls;
|
||
clone.loop = videoElement.loop;
|
||
clone.muted = videoElement.muted;
|
||
clone.defaultMuted = videoElement.defaultMuted;
|
||
clone.preload = videoElement.preload;
|
||
clone.playsInline = videoElement.playsInline;
|
||
|
||
clone.poster = "";
|
||
if (clone.hasAttribute("poster")) {
|
||
clone.removeAttribute("poster");
|
||
}
|
||
|
||
let replaced = false;
|
||
safe(() => {
|
||
parent.replaceChild(clone, videoElement);
|
||
replaced = true;
|
||
});
|
||
|
||
if (!replaced) {
|
||
return videoElement;
|
||
}
|
||
|
||
safe(() => {
|
||
if (typeof clone.load === "function") {
|
||
clone.load();
|
||
}
|
||
});
|
||
|
||
this.log(
|
||
`[teardownVideoElement] Replaced modal video node (readyState=${clone.readyState} networkState=${clone.networkState}).`
|
||
);
|
||
|
||
return clone;
|
||
}
|
||
|
||
resetTorrentStats() {
|
||
if (this.videoModal) {
|
||
this.videoModal.resetStats();
|
||
}
|
||
}
|
||
|
||
|
||
setShareButtonState(enabled) {
|
||
if (this.videoModal) {
|
||
this.videoModal.setShareEnabled(enabled);
|
||
}
|
||
}
|
||
|
||
getShareUrlBase() {
|
||
try {
|
||
const current = new URL(window.location.href);
|
||
return `${current.origin}${current.pathname}`;
|
||
} catch (err) {
|
||
const origin = window.location?.origin || "";
|
||
const pathname = window.location?.pathname || "";
|
||
if (origin || pathname) {
|
||
return `${origin}${pathname}`;
|
||
}
|
||
const href = window.location?.href || "";
|
||
if (href) {
|
||
const base = href.split(/[?#]/)[0];
|
||
if (base) {
|
||
return base;
|
||
}
|
||
}
|
||
devLogger.warn("Unable to determine share URL base:", err);
|
||
return "";
|
||
}
|
||
}
|
||
|
||
shouldDeferModeratedPlayback(video) {
|
||
if (!video || typeof video !== "object") {
|
||
return false;
|
||
}
|
||
|
||
const moderation =
|
||
video.moderation && typeof video.moderation === "object"
|
||
? video.moderation
|
||
: null;
|
||
|
||
if (!moderation) {
|
||
return false;
|
||
}
|
||
|
||
if (moderation.viewerOverride?.showAnyway === true) {
|
||
return false;
|
||
}
|
||
|
||
const blurActive = moderation.blurThumbnail === true;
|
||
const hiddenActive = moderation.hidden === true;
|
||
|
||
return blurActive || hiddenActive;
|
||
}
|
||
|
||
resumePendingModeratedPlayback(video) {
|
||
const pending = this.pendingModeratedPlayback;
|
||
if (!pending) {
|
||
return;
|
||
}
|
||
|
||
const activeVideo = this.currentVideo || null;
|
||
const targetVideo = video && typeof video === "object" ? video : activeVideo;
|
||
if (!targetVideo) {
|
||
return;
|
||
}
|
||
|
||
const pendingId =
|
||
typeof pending.videoId === "string" && pending.videoId ? pending.videoId : "";
|
||
const targetId =
|
||
typeof targetVideo.id === "string" && targetVideo.id ? targetVideo.id : "";
|
||
|
||
const matchesId = pendingId && targetId && pendingId === targetId;
|
||
const matchesActive = !pendingId && !targetId && targetVideo === activeVideo;
|
||
|
||
if (!matchesId && !matchesActive) {
|
||
return;
|
||
}
|
||
|
||
this.pendingModeratedPlayback = null;
|
||
|
||
if (typeof this.playVideoWithFallback !== "function") {
|
||
return;
|
||
}
|
||
|
||
const playbackOptions = {
|
||
url: pending.url || "",
|
||
magnet: pending.magnet || "",
|
||
};
|
||
|
||
if (pending.triggerProvided) {
|
||
playbackOptions.trigger = Object.prototype.hasOwnProperty.call(pending, "trigger")
|
||
? pending.trigger
|
||
: this.lastModalTrigger || null;
|
||
}
|
||
|
||
const playbackPromise = this.playVideoWithFallback(playbackOptions);
|
||
if (playbackPromise && typeof playbackPromise.catch === "function") {
|
||
playbackPromise.catch((error) => {
|
||
devLogger.error(
|
||
"[Application] Failed to resume moderated playback:",
|
||
error,
|
||
);
|
||
});
|
||
}
|
||
}
|
||
|
||
buildShareUrlFromNevent(nevent) {
|
||
if (!nevent) {
|
||
return "";
|
||
}
|
||
const base = this.getShareUrlBase();
|
||
if (!base) {
|
||
return "";
|
||
}
|
||
return `${base}?v=${encodeURIComponent(nevent)}`;
|
||
}
|
||
|
||
buildShareUrlFromEventId(eventId) {
|
||
if (!eventId) {
|
||
return "";
|
||
}
|
||
|
||
try {
|
||
const nevent = window.NostrTools.nip19.neventEncode({ id: eventId });
|
||
return this.buildShareUrlFromNevent(nevent);
|
||
} catch (err) {
|
||
devLogger.error("Error generating nevent for share URL:", err);
|
||
return "";
|
||
}
|
||
}
|
||
|
||
dedupeVideosByRoot(videos) {
|
||
if (!Array.isArray(videos) || videos.length === 0) {
|
||
return [];
|
||
}
|
||
return dedupeToNewestByRoot(videos);
|
||
}
|
||
|
||
autoplayModalVideo() {
|
||
if (this.currentVideo?.moderation?.blockAutoplay) {
|
||
this.log(
|
||
"[moderation] Skipping autoplay due to trusted reports or trusted mutes.",
|
||
);
|
||
return;
|
||
}
|
||
if (!this.modalVideo) return;
|
||
this.modalVideo.play().catch((err) => {
|
||
this.log("Autoplay failed:", err);
|
||
if (!this.modalVideo.muted) {
|
||
this.log("Falling back to muted autoplay.");
|
||
this.modalVideo.muted = true;
|
||
this.modalVideo.play().catch((err2) => {
|
||
this.log("Muted autoplay also failed:", err2);
|
||
});
|
||
}
|
||
});
|
||
}
|
||
|
||
startTorrentStatusMirrors(torrentInstance) {
|
||
if (!torrentInstance) {
|
||
return;
|
||
}
|
||
|
||
const updateInterval = setInterval(() => {
|
||
if (!document.body.contains(this.modalVideo)) {
|
||
clearInterval(updateInterval);
|
||
return;
|
||
}
|
||
this.updateTorrentStatus(torrentInstance);
|
||
}, 3000);
|
||
this.activeIntervals.push(updateInterval);
|
||
|
||
const mirrorInterval = setInterval(() => {
|
||
if (!document.body.contains(this.modalVideo)) {
|
||
clearInterval(mirrorInterval);
|
||
return;
|
||
}
|
||
const status = document.getElementById("status");
|
||
const progress = document.getElementById("progress");
|
||
const peers = document.getElementById("peers");
|
||
const speed = document.getElementById("speed");
|
||
const downloaded = document.getElementById("downloaded");
|
||
if (this.videoModal) {
|
||
if (status) {
|
||
this.videoModal.updateStatus(status.textContent);
|
||
}
|
||
if (progress) {
|
||
const doc = progress.ownerDocument;
|
||
const view = doc?.defaultView;
|
||
let widthValue = "";
|
||
if (view && typeof view.getComputedStyle === "function") {
|
||
const computed = view.getComputedStyle(progress);
|
||
widthValue =
|
||
computed?.getPropertyValue("--progress-width")?.trim() ||
|
||
computed?.getPropertyValue("width")?.trim() ||
|
||
"";
|
||
}
|
||
this.videoModal.updateProgress(widthValue);
|
||
}
|
||
if (peers) {
|
||
this.videoModal.updatePeers(peers.textContent);
|
||
}
|
||
if (speed) {
|
||
this.videoModal.updateSpeed(speed.textContent);
|
||
}
|
||
if (downloaded) {
|
||
this.videoModal.updateDownloaded(downloaded.textContent);
|
||
}
|
||
}
|
||
}, 3000);
|
||
this.activeIntervals.push(mirrorInterval);
|
||
}
|
||
|
||
/**
|
||
* Hide the video modal.
|
||
*/
|
||
async hideModal() {
|
||
// 1) Clear timers/listeners immediately so playback stats stop updating
|
||
this.cancelPendingViewLogging();
|
||
this.clearActiveIntervals();
|
||
this.teardownModalViewCountSubscription();
|
||
this.teardownModalReactionSubscription();
|
||
this.pendingModeratedPlayback = null;
|
||
if (
|
||
this.videoModal &&
|
||
typeof this.videoModal.clearSimilarContent === "function"
|
||
) {
|
||
try {
|
||
this.videoModal.clearSimilarContent();
|
||
} catch (error) {
|
||
devLogger.warn("[hideModal] Failed to clear similar content:", error);
|
||
}
|
||
}
|
||
|
||
// 2) Close the modal UI right away so the user gets instant feedback
|
||
const modalVideoElement =
|
||
(this.videoModal &&
|
||
typeof this.videoModal.getVideoElement === "function" &&
|
||
this.videoModal.getVideoElement()) ||
|
||
this.modalVideo ||
|
||
null;
|
||
if (modalVideoElement) {
|
||
try {
|
||
modalVideoElement.pause();
|
||
modalVideoElement.removeAttribute("src");
|
||
modalVideoElement.load();
|
||
} catch (error) {
|
||
devLogger.warn("[hideModal] Failed to reset modal video element:", error);
|
||
}
|
||
}
|
||
|
||
if (this.videoModal) {
|
||
try {
|
||
this.videoModal.close();
|
||
} catch (error) {
|
||
devLogger.warn("[hideModal] Failed to close video modal immediately:", error);
|
||
}
|
||
}
|
||
|
||
this.lastModalTrigger = null;
|
||
|
||
this.currentMagnetUri = null;
|
||
|
||
// 3) Kick off heavy cleanup work asynchronously. We still await it so
|
||
// callers that depend on teardown finishing behave the same, but the
|
||
// user-visible UI is already closed.
|
||
const performCleanup = async () => {
|
||
try {
|
||
if (navigator.serviceWorker && navigator.serviceWorker.controller) {
|
||
await fetch("/webtorrent/cancel/", { mode: "no-cors" });
|
||
}
|
||
} catch (err) {
|
||
devLogger.warn("[hideModal] webtorrent cancel fetch failed:", err);
|
||
}
|
||
|
||
await this.cleanup({
|
||
preserveSubscriptions: true,
|
||
preserveObservers: true,
|
||
preserveModals: true,
|
||
});
|
||
};
|
||
|
||
// 4) Remove only `?v=` but **keep** the hash
|
||
const url = new URL(window.location.href);
|
||
url.searchParams.delete("v"); // remove ?v= param
|
||
const newUrl = url.pathname + url.search + url.hash;
|
||
window.history.replaceState({}, "", newUrl);
|
||
|
||
try {
|
||
await performCleanup();
|
||
} catch (error) {
|
||
devLogger.error("[hideModal] Cleanup failed:", error);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Register the default "recent" feed pipeline.
|
||
*/
|
||
registerRecentFeed() {
|
||
if (!this.feedEngine || typeof this.feedEngine.registerFeed !== "function") {
|
||
return null;
|
||
}
|
||
|
||
const existingDefinition =
|
||
typeof this.feedEngine.getFeedDefinition === "function"
|
||
? this.feedEngine.getFeedDefinition("recent")
|
||
: null;
|
||
if (existingDefinition) {
|
||
return existingDefinition;
|
||
}
|
||
|
||
try {
|
||
const app = this;
|
||
const resolveThresholdFromApp = (key) => ({ runtimeValue, defaultValue }) => {
|
||
if (
|
||
Number.isFinite(runtimeValue) ||
|
||
runtimeValue === Number.POSITIVE_INFINITY
|
||
) {
|
||
return runtimeValue;
|
||
}
|
||
|
||
if (app && typeof app.getActiveModerationThresholds === "function") {
|
||
const active = app.getActiveModerationThresholds();
|
||
const candidate = active && typeof active === "object" ? active[key] : undefined;
|
||
if (
|
||
Number.isFinite(candidate) ||
|
||
candidate === Number.POSITIVE_INFINITY
|
||
) {
|
||
return candidate;
|
||
}
|
||
}
|
||
|
||
return defaultValue;
|
||
};
|
||
return this.feedEngine.registerFeed("recent", {
|
||
source: createActiveNostrSource({ service: this.nostrService }),
|
||
stages: [
|
||
createBlacklistFilterStage({
|
||
shouldIncludeVideo: (video, options) =>
|
||
this.nostrService.shouldIncludeVideo(video, options),
|
||
}),
|
||
createDedupeByRootStage({
|
||
dedupe: (videos) => this.dedupeVideosByRoot(videos),
|
||
}),
|
||
createModerationStage({
|
||
getService: () => this.nostrService.getModerationService(),
|
||
autoplayThreshold: resolveThresholdFromApp("autoplayBlockThreshold"),
|
||
blurThreshold: resolveThresholdFromApp("blurThreshold"),
|
||
trustedMuteHideThreshold: resolveThresholdFromApp("trustedMuteHideThreshold"),
|
||
trustedReportHideThreshold: resolveThresholdFromApp("trustedSpamHideThreshold"),
|
||
}),
|
||
createResolvePostedAtStage(),
|
||
],
|
||
sorter: createChronologicalSorter(),
|
||
hooks: {
|
||
timestamps: {
|
||
getKnownVideoPostedAt: (video) => this.getKnownVideoPostedAt(video),
|
||
resolveVideoPostedAt: (video) => this.resolveVideoPostedAt(video),
|
||
},
|
||
},
|
||
});
|
||
} catch (error) {
|
||
devLogger.warn("[Application] Failed to register recent feed:", error);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Register the "for-you" feed pipeline.
|
||
*/
|
||
registerForYouFeed() {
|
||
if (!this.feedEngine || typeof this.feedEngine.registerFeed !== "function") {
|
||
return null;
|
||
}
|
||
|
||
const existingDefinition =
|
||
typeof this.feedEngine.getFeedDefinition === "function"
|
||
? this.feedEngine.getFeedDefinition("for-you")
|
||
: null;
|
||
if (existingDefinition) {
|
||
return existingDefinition;
|
||
}
|
||
|
||
try {
|
||
const app = this;
|
||
const resolveThresholdFromApp = (key) => ({ runtimeValue, defaultValue }) => {
|
||
if (
|
||
Number.isFinite(runtimeValue) ||
|
||
runtimeValue === Number.POSITIVE_INFINITY
|
||
) {
|
||
return runtimeValue;
|
||
}
|
||
|
||
if (app && typeof app.getActiveModerationThresholds === "function") {
|
||
const active = app.getActiveModerationThresholds();
|
||
const candidate = active && typeof active === "object" ? active[key] : undefined;
|
||
if (
|
||
Number.isFinite(candidate) ||
|
||
candidate === Number.POSITIVE_INFINITY
|
||
) {
|
||
return candidate;
|
||
}
|
||
}
|
||
|
||
return defaultValue;
|
||
};
|
||
return this.feedEngine.registerFeed("for-you", {
|
||
source: createActiveNostrSource({ service: this.nostrService }),
|
||
stages: [
|
||
// Note: Tag-preference filtering is consolidated in createTagPreferenceFilterStage
|
||
// so each feed has a single source of truth for interest-based inclusion/ranking.
|
||
createTagPreferenceFilterStage(),
|
||
createBlacklistFilterStage({
|
||
shouldIncludeVideo: (video, options) =>
|
||
this.nostrService.shouldIncludeVideo(video, options),
|
||
}),
|
||
createDedupeByRootStage({
|
||
dedupe: (videos) => this.dedupeVideosByRoot(videos),
|
||
}),
|
||
createModerationStage({
|
||
getService: () => this.nostrService.getModerationService(),
|
||
autoplayThreshold: resolveThresholdFromApp("autoplayBlockThreshold"),
|
||
blurThreshold: resolveThresholdFromApp("blurThreshold"),
|
||
trustedMuteHideThreshold: resolveThresholdFromApp("trustedMuteHideThreshold"),
|
||
trustedReportHideThreshold: resolveThresholdFromApp("trustedSpamHideThreshold"),
|
||
}),
|
||
createResolvePostedAtStage(),
|
||
],
|
||
sorter: createChronologicalSorter(),
|
||
hooks: {
|
||
timestamps: {
|
||
getKnownVideoPostedAt: (video) => this.getKnownVideoPostedAt(video),
|
||
resolveVideoPostedAt: (video) => this.resolveVideoPostedAt(video),
|
||
},
|
||
},
|
||
});
|
||
} catch (error) {
|
||
devLogger.warn("[Application] Failed to register for-you feed:", error);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Register the "explore" feed pipeline.
|
||
*/
|
||
registerExploreFeed() {
|
||
if (!this.feedEngine || typeof this.feedEngine.registerFeed !== "function") {
|
||
return null;
|
||
}
|
||
|
||
const existingDefinition =
|
||
typeof this.feedEngine.getFeedDefinition === "function"
|
||
? this.feedEngine.getFeedDefinition("explore")
|
||
: null;
|
||
if (existingDefinition) {
|
||
return existingDefinition;
|
||
}
|
||
|
||
try {
|
||
const app = this;
|
||
const resolveThresholdFromApp = (key) => ({ runtimeValue, defaultValue }) => {
|
||
if (
|
||
Number.isFinite(runtimeValue) ||
|
||
runtimeValue === Number.POSITIVE_INFINITY
|
||
) {
|
||
return runtimeValue;
|
||
}
|
||
|
||
if (app && typeof app.getActiveModerationThresholds === "function") {
|
||
const active = app.getActiveModerationThresholds();
|
||
const candidate = active && typeof active === "object" ? active[key] : undefined;
|
||
if (
|
||
Number.isFinite(candidate) ||
|
||
candidate === Number.POSITIVE_INFINITY
|
||
) {
|
||
return candidate;
|
||
}
|
||
}
|
||
|
||
return defaultValue;
|
||
};
|
||
return this.feedEngine.registerFeed("explore", {
|
||
source: createActiveNostrSource({ service: this.nostrService }),
|
||
stages: [
|
||
createDisinterestFilterStage(),
|
||
createBlacklistFilterStage({
|
||
shouldIncludeVideo: (video, options) =>
|
||
this.nostrService.shouldIncludeVideo(video, options),
|
||
}),
|
||
createDedupeByRootStage({
|
||
dedupe: (videos) => this.dedupeVideosByRoot(videos),
|
||
}),
|
||
createModerationStage({
|
||
getService: () => this.nostrService.getModerationService(),
|
||
autoplayThreshold: resolveThresholdFromApp("autoplayBlockThreshold"),
|
||
blurThreshold: resolveThresholdFromApp("blurThreshold"),
|
||
trustedMuteHideThreshold: resolveThresholdFromApp("trustedMuteHideThreshold"),
|
||
trustedReportHideThreshold: resolveThresholdFromApp("trustedSpamHideThreshold"),
|
||
}),
|
||
createResolvePostedAtStage(),
|
||
createExploreScorerStage(),
|
||
],
|
||
sorter: createExploreDiversitySorter(),
|
||
hooks: {
|
||
timestamps: {
|
||
getKnownVideoPostedAt: (video) => this.getKnownVideoPostedAt(video),
|
||
resolveVideoPostedAt: (video) => this.resolveVideoPostedAt(video),
|
||
},
|
||
},
|
||
});
|
||
} catch (error) {
|
||
devLogger.warn("[Application] Failed to register explore feed:", error);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
registerSubscriptionsFeed() {
|
||
if (!this.feedEngine || typeof this.feedEngine.registerFeed !== "function") {
|
||
return null;
|
||
}
|
||
|
||
const existingDefinition =
|
||
typeof this.feedEngine.getFeedDefinition === "function"
|
||
? this.feedEngine.getFeedDefinition("subscriptions")
|
||
: null;
|
||
if (existingDefinition) {
|
||
return existingDefinition;
|
||
}
|
||
|
||
try {
|
||
const app = this;
|
||
const resolveThresholdFromApp = (key) => ({ runtimeValue, defaultValue }) => {
|
||
if (
|
||
Number.isFinite(runtimeValue) ||
|
||
runtimeValue === Number.POSITIVE_INFINITY
|
||
) {
|
||
return runtimeValue;
|
||
}
|
||
|
||
if (app && typeof app.getActiveModerationThresholds === "function") {
|
||
const active = app.getActiveModerationThresholds();
|
||
const candidate = active && typeof active === "object" ? active[key] : undefined;
|
||
if (
|
||
Number.isFinite(candidate) ||
|
||
candidate === Number.POSITIVE_INFINITY
|
||
) {
|
||
return candidate;
|
||
}
|
||
}
|
||
|
||
return defaultValue;
|
||
};
|
||
return this.feedEngine.registerFeed("subscriptions", {
|
||
source: createSubscriptionAuthorsSource({ service: this.nostrService }),
|
||
stages: [
|
||
// Note: Tag-preference filtering is consolidated in createTagPreferenceFilterStage
|
||
// so each feed has a single source of truth for interest-based inclusion/ranking.
|
||
createTagPreferenceFilterStage(),
|
||
createBlacklistFilterStage({
|
||
shouldIncludeVideo: (video, options) =>
|
||
this.nostrService.shouldIncludeVideo(video, options),
|
||
}),
|
||
createDedupeByRootStage({
|
||
dedupe: (videos) => this.dedupeVideosByRoot(videos),
|
||
}),
|
||
createModerationStage({
|
||
getService: () => this.nostrService.getModerationService(),
|
||
autoplayThreshold: resolveThresholdFromApp("autoplayBlockThreshold"),
|
||
blurThreshold: resolveThresholdFromApp("blurThreshold"),
|
||
trustedMuteHideThreshold: resolveThresholdFromApp("trustedMuteHideThreshold"),
|
||
trustedReportHideThreshold: resolveThresholdFromApp("trustedSpamHideThreshold"),
|
||
}),
|
||
createResolvePostedAtStage(),
|
||
],
|
||
sorter: createChronologicalSorter(),
|
||
hooks: {
|
||
subscriptions: {
|
||
resolveAuthors: () => subscriptions.getSubscribedAuthors(),
|
||
},
|
||
timestamps: {
|
||
getKnownVideoPostedAt: (video) => this.getKnownVideoPostedAt(video),
|
||
resolveVideoPostedAt: (video) => this.resolveVideoPostedAt(video),
|
||
},
|
||
},
|
||
});
|
||
} catch (error) {
|
||
devLogger.warn("[Application] Failed to register subscriptions feed:", error);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
registerWatchHistoryFeed() {
|
||
if (!this.feedEngine || typeof this.feedEngine.registerFeed !== "function") {
|
||
return null;
|
||
}
|
||
|
||
const existingDefinition =
|
||
typeof this.feedEngine.getFeedDefinition === "function"
|
||
? this.feedEngine.getFeedDefinition("watch-history")
|
||
: null;
|
||
if (existingDefinition) {
|
||
return existingDefinition;
|
||
}
|
||
|
||
try {
|
||
return registerWatchHistoryFeed(this.feedEngine, {
|
||
service: watchHistoryService,
|
||
nostr: this.nostrService,
|
||
});
|
||
} catch (error) {
|
||
devLogger.warn("[Application] Failed to register watch history feed:", error);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
buildForYouFeedRuntime() {
|
||
const blacklist =
|
||
this.blacklistedEventIds instanceof Set
|
||
? new Set(this.blacklistedEventIds)
|
||
: new Set();
|
||
|
||
const preferenceSource =
|
||
this.hashtagPreferencesSnapshot &&
|
||
typeof this.hashtagPreferencesSnapshot === "object"
|
||
? this.hashtagPreferencesSnapshot
|
||
: typeof this.createHashtagPreferencesSnapshot === "function"
|
||
? this.createHashtagPreferencesSnapshot()
|
||
: typeof this.getHashtagPreferences === "function"
|
||
? this.getHashtagPreferences()
|
||
: {};
|
||
const { interests = [], disinterests = [] } = preferenceSource || {};
|
||
const moderationThresholds = this.getActiveModerationThresholds();
|
||
|
||
return {
|
||
blacklistedEventIds: blacklist,
|
||
isAuthorBlocked: (pubkey) => this.isAuthorBlocked(pubkey),
|
||
tagPreferences: {
|
||
interests: Array.isArray(interests) ? [...interests] : [],
|
||
disinterests: Array.isArray(disinterests) ? [...disinterests] : [],
|
||
},
|
||
moderationThresholds: moderationThresholds
|
||
? { ...moderationThresholds }
|
||
: undefined,
|
||
};
|
||
}
|
||
|
||
buildExploreFeedRuntime() {
|
||
const exploreDataService =
|
||
this.exploreDataService && typeof this.exploreDataService === "object"
|
||
? this.exploreDataService
|
||
: null;
|
||
const blacklist =
|
||
this.blacklistedEventIds instanceof Set
|
||
? new Set(this.blacklistedEventIds)
|
||
: new Set();
|
||
|
||
const preferenceSource =
|
||
this.hashtagPreferencesSnapshot &&
|
||
typeof this.hashtagPreferencesSnapshot === "object"
|
||
? this.hashtagPreferencesSnapshot
|
||
: typeof this.createHashtagPreferencesSnapshot === "function"
|
||
? this.createHashtagPreferencesSnapshot()
|
||
: typeof this.getHashtagPreferences === "function"
|
||
? this.getHashtagPreferences()
|
||
: {};
|
||
const { interests = [], disinterests = [] } = preferenceSource || {};
|
||
const moderationThresholds = this.getActiveModerationThresholds();
|
||
|
||
const watchHistorySource =
|
||
exploreDataService && typeof exploreDataService.getWatchHistoryTagCounts === "function"
|
||
? exploreDataService.getWatchHistoryTagCounts()
|
||
: this.watchHistoryTagCounts;
|
||
const watchHistoryTagCounts =
|
||
watchHistorySource instanceof Map
|
||
? new Map(watchHistorySource)
|
||
: watchHistorySource && typeof watchHistorySource === "object"
|
||
? { ...watchHistorySource }
|
||
: undefined;
|
||
|
||
const exploreTagSource =
|
||
exploreDataService && typeof exploreDataService.getTagIdf === "function"
|
||
? exploreDataService.getTagIdf()
|
||
: this.exploreTagIdf;
|
||
const exploreTagIdf =
|
||
exploreTagSource instanceof Map
|
||
? new Map(exploreTagSource)
|
||
: exploreTagSource && typeof exploreTagSource === "object"
|
||
? { ...exploreTagSource }
|
||
: undefined;
|
||
|
||
return {
|
||
blacklistedEventIds: blacklist,
|
||
isAuthorBlocked: (pubkey) => this.isAuthorBlocked(pubkey),
|
||
tagPreferences: {
|
||
interests: Array.isArray(interests) ? [...interests] : [],
|
||
disinterests: Array.isArray(disinterests) ? [...disinterests] : [],
|
||
},
|
||
watchHistoryTagCounts,
|
||
exploreTagIdf,
|
||
moderationThresholds: moderationThresholds
|
||
? { ...moderationThresholds }
|
||
: undefined,
|
||
};
|
||
}
|
||
|
||
buildRecentFeedRuntime() {
|
||
const blacklist =
|
||
this.blacklistedEventIds instanceof Set
|
||
? new Set(this.blacklistedEventIds)
|
||
: new Set();
|
||
|
||
const moderationThresholds = this.getActiveModerationThresholds();
|
||
|
||
return {
|
||
blacklistedEventIds: blacklist,
|
||
isAuthorBlocked: (pubkey) => this.isAuthorBlocked(pubkey),
|
||
moderationThresholds: moderationThresholds
|
||
? { ...moderationThresholds }
|
||
: undefined,
|
||
};
|
||
}
|
||
|
||
refreshForYouFeed({ reason, fallbackVideos } = {}) {
|
||
const runtime = this.buildForYouFeedRuntime();
|
||
const normalizedReason = typeof reason === "string" ? reason : undefined;
|
||
const fallback = Array.isArray(fallbackVideos) ? fallbackVideos : [];
|
||
|
||
if (!this.feedEngine || typeof this.feedEngine.run !== "function") {
|
||
const metadata = {
|
||
reason: normalizedReason,
|
||
engine: "unavailable",
|
||
};
|
||
this.latestFeedMetadata = metadata;
|
||
this.videosMap = this.nostrService.getVideosMap();
|
||
if (this.videoListView) {
|
||
this.videoListView.state.videosMap = this.videosMap;
|
||
}
|
||
this.updateForYouTelemetryMetadata([], metadata);
|
||
this.renderVideoList({ videos: fallback, metadata });
|
||
return Promise.resolve({ videos: fallback, metadata });
|
||
}
|
||
|
||
return this.feedEngine
|
||
.run("for-you", { runtime })
|
||
.then((result) => {
|
||
const videos = Array.isArray(result?.videos) ? result.videos : [];
|
||
const metadata = {
|
||
...(result?.metadata || {}),
|
||
};
|
||
if (normalizedReason) {
|
||
metadata.reason = normalizedReason;
|
||
}
|
||
|
||
this.latestFeedMetadata = metadata;
|
||
this.videosMap = this.nostrService.getVideosMap();
|
||
if (this.videoListView) {
|
||
this.videoListView.state.videosMap = this.videosMap;
|
||
}
|
||
|
||
const items = Array.isArray(result?.items) ? result.items : [];
|
||
this.updateForYouTelemetryMetadata(items, metadata);
|
||
|
||
const payload = { videos, metadata };
|
||
this.renderVideoList(payload);
|
||
return payload;
|
||
})
|
||
.catch((error) => {
|
||
devLogger.error("[Application] Failed to run for-you feed:", error);
|
||
const metadata = {
|
||
reason: normalizedReason || "error:for-you-feed",
|
||
error: true,
|
||
};
|
||
this.latestFeedMetadata = metadata;
|
||
this.videosMap = this.nostrService.getVideosMap();
|
||
if (this.videoListView) {
|
||
this.videoListView.state.videosMap = this.videosMap;
|
||
}
|
||
this.updateForYouTelemetryMetadata([], metadata);
|
||
const payload = { videos: fallback, metadata };
|
||
this.renderVideoList(payload);
|
||
return payload;
|
||
});
|
||
}
|
||
|
||
refreshExploreFeed({ reason, fallbackVideos } = {}) {
|
||
const runtime = this.buildExploreFeedRuntime();
|
||
const normalizedReason = typeof reason === "string" ? reason : undefined;
|
||
const fallback = Array.isArray(fallbackVideos) ? fallbackVideos : [];
|
||
const applyExploreOrderingMetadata = (source) => {
|
||
const next = source && typeof source === "object" ? { ...source } : {};
|
||
if (!next.sortOrder) {
|
||
next.sortOrder = "explore";
|
||
}
|
||
next.preserveOrder = true;
|
||
return next;
|
||
};
|
||
|
||
if (!this.feedEngine || typeof this.feedEngine.run !== "function") {
|
||
const metadata = applyExploreOrderingMetadata({
|
||
reason: normalizedReason,
|
||
engine: "unavailable",
|
||
});
|
||
this.latestFeedMetadata = metadata;
|
||
this.videosMap = this.nostrService.getVideosMap();
|
||
if (this.videoListView) {
|
||
this.videoListView.state.videosMap = this.videosMap;
|
||
}
|
||
this.renderVideoList({ videos: fallback, metadata });
|
||
return Promise.resolve({ videos: fallback, metadata });
|
||
}
|
||
|
||
return this.feedEngine
|
||
.run("explore", { runtime })
|
||
.then((result) => {
|
||
const videos = Array.isArray(result?.videos) ? result.videos : [];
|
||
const metadata = applyExploreOrderingMetadata({
|
||
...(result?.metadata || {}),
|
||
...(normalizedReason ? { reason: normalizedReason } : {}),
|
||
});
|
||
|
||
this.latestFeedMetadata = metadata;
|
||
this.videosMap = this.nostrService.getVideosMap();
|
||
if (this.videoListView) {
|
||
this.videoListView.state.videosMap = this.videosMap;
|
||
}
|
||
|
||
const payload = { videos, metadata };
|
||
this.renderVideoList(payload);
|
||
return payload;
|
||
})
|
||
.catch((error) => {
|
||
devLogger.error("[Application] Failed to run explore feed:", error);
|
||
const metadata = applyExploreOrderingMetadata({
|
||
reason: normalizedReason || "error:explore-feed",
|
||
error: true,
|
||
});
|
||
this.latestFeedMetadata = metadata;
|
||
this.videosMap = this.nostrService.getVideosMap();
|
||
if (this.videoListView) {
|
||
this.videoListView.state.videosMap = this.videosMap;
|
||
}
|
||
const payload = { videos: fallback, metadata };
|
||
this.renderVideoList(payload);
|
||
return payload;
|
||
});
|
||
}
|
||
|
||
refreshRecentFeed({ reason, fallbackVideos } = {}) {
|
||
const runtime = this.buildRecentFeedRuntime();
|
||
const normalizedReason = typeof reason === "string" ? reason : undefined;
|
||
const fallback = Array.isArray(fallbackVideos) ? fallbackVideos : [];
|
||
|
||
if (!this.feedEngine || typeof this.feedEngine.run !== "function") {
|
||
const metadata = {
|
||
reason: normalizedReason,
|
||
engine: "unavailable",
|
||
};
|
||
this.latestFeedMetadata = metadata;
|
||
this.videosMap = this.nostrService.getVideosMap();
|
||
if (this.videoListView) {
|
||
this.videoListView.state.videosMap = this.videosMap;
|
||
}
|
||
this.renderVideoList({ videos: fallback, metadata });
|
||
return Promise.resolve({ videos: fallback, metadata });
|
||
}
|
||
|
||
return this.feedEngine
|
||
.run("recent", { runtime })
|
||
.then((result) => {
|
||
const videos = Array.isArray(result?.videos) ? result.videos : [];
|
||
const metadata = {
|
||
...(result?.metadata || {}),
|
||
};
|
||
if (normalizedReason) {
|
||
metadata.reason = normalizedReason;
|
||
}
|
||
|
||
this.latestFeedMetadata = metadata;
|
||
this.videosMap = this.nostrService.getVideosMap();
|
||
if (this.videoListView) {
|
||
this.videoListView.state.videosMap = this.videosMap;
|
||
}
|
||
|
||
const payload = { videos, metadata };
|
||
this.renderVideoList(payload);
|
||
return payload;
|
||
})
|
||
.catch((error) => {
|
||
devLogger.error("[Application] Failed to run recent feed:", error);
|
||
const metadata = {
|
||
reason: normalizedReason || "error:recent-feed",
|
||
error: true,
|
||
};
|
||
this.latestFeedMetadata = metadata;
|
||
this.videosMap = this.nostrService.getVideosMap();
|
||
if (this.videoListView) {
|
||
this.videoListView.state.videosMap = this.videosMap;
|
||
}
|
||
const payload = { videos: fallback, metadata };
|
||
this.renderVideoList(payload);
|
||
return payload;
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Subscribe to videos (older + new) and render them as they come in.
|
||
*/
|
||
async loadVideos(forceFetch = false) {
|
||
devLogger.log("Starting loadVideos... (forceFetch =", forceFetch, ")");
|
||
this.setFeedTelemetryContext("recent");
|
||
|
||
const container = this.mountVideoListView();
|
||
const hasCachedVideos =
|
||
this.nostrService &&
|
||
Array.isArray(this.nostrService.getFilteredActiveVideos()) &&
|
||
this.nostrService.getFilteredActiveVideos().length > 0;
|
||
|
||
if (!hasCachedVideos) {
|
||
if (this.videoListView && container) {
|
||
this.videoListView.showLoading("Fetching recent videos…");
|
||
} else if (container) {
|
||
container.innerHTML = getSidebarLoadingMarkup("Fetching recent videos…");
|
||
}
|
||
}
|
||
|
||
let initialRefreshPromise = null;
|
||
|
||
const videos = await this.nostrService.loadVideos({
|
||
forceFetch,
|
||
blacklistedEventIds: this.blacklistedEventIds,
|
||
isAuthorBlocked: (pubkey) => this.isAuthorBlocked(pubkey),
|
||
onVideos: (payload, detail = {}) => {
|
||
const promise = this.refreshRecentFeed({
|
||
reason: detail?.reason,
|
||
fallbackVideos: payload,
|
||
});
|
||
if (!initialRefreshPromise) {
|
||
initialRefreshPromise = promise;
|
||
}
|
||
},
|
||
});
|
||
|
||
if (initialRefreshPromise) {
|
||
await initialRefreshPromise;
|
||
} else if (!Array.isArray(videos) || videos.length === 0) {
|
||
await this.refreshRecentFeed({ reason: "initial", fallbackVideos: [] });
|
||
}
|
||
|
||
this.videoSubscription = this.nostrService.getVideoSubscription() || null;
|
||
this.videosMap = this.nostrService.getVideosMap();
|
||
if (this.videoListView) {
|
||
this.videoListView.state.videosMap = this.videosMap;
|
||
}
|
||
}
|
||
|
||
async loadForYouVideos(forceFetch = false) {
|
||
devLogger.log("Starting loadForYouVideos... (forceFetch =", forceFetch, ")");
|
||
this.setFeedTelemetryContext("for-you");
|
||
|
||
const container = this.mountVideoListView({ includeTags: false });
|
||
const hasCachedVideos =
|
||
this.nostrService &&
|
||
Array.isArray(this.nostrService.getFilteredActiveVideos()) &&
|
||
this.nostrService.getFilteredActiveVideos().length > 0;
|
||
|
||
if (!hasCachedVideos) {
|
||
if (this.videoListView && container) {
|
||
this.videoListView.showLoading("Fetching for-you videos…");
|
||
} else if (container) {
|
||
container.innerHTML = getSidebarLoadingMarkup("Fetching for-you videos…");
|
||
}
|
||
}
|
||
|
||
let initialRefreshPromise = null;
|
||
|
||
const videos = await this.nostrService.loadVideos({
|
||
forceFetch,
|
||
blacklistedEventIds: this.blacklistedEventIds,
|
||
isAuthorBlocked: (pubkey) => this.isAuthorBlocked(pubkey),
|
||
onVideos: (payload, detail = {}) => {
|
||
const promise = this.refreshForYouFeed({
|
||
reason: detail?.reason,
|
||
fallbackVideos: payload,
|
||
});
|
||
if (!initialRefreshPromise) {
|
||
initialRefreshPromise = promise;
|
||
}
|
||
},
|
||
});
|
||
|
||
if (initialRefreshPromise) {
|
||
await initialRefreshPromise;
|
||
} else if (!Array.isArray(videos) || videos.length === 0) {
|
||
await this.refreshForYouFeed({ reason: "initial", fallbackVideos: [] });
|
||
}
|
||
|
||
this.videoSubscription = this.nostrService.getVideoSubscription() || null;
|
||
this.videosMap = this.nostrService.getVideosMap();
|
||
if (this.videoListView) {
|
||
this.videoListView.state.videosMap = this.videosMap;
|
||
}
|
||
}
|
||
|
||
async loadExploreVideos(forceFetch = false) {
|
||
devLogger.log("Starting loadExploreVideos... (forceFetch =", forceFetch, ")");
|
||
this.setFeedTelemetryContext("explore");
|
||
|
||
const container = this.mountVideoListView({ includeTags: false });
|
||
const hasCachedVideos =
|
||
this.nostrService &&
|
||
Array.isArray(this.nostrService.getFilteredActiveVideos()) &&
|
||
this.nostrService.getFilteredActiveVideos().length > 0;
|
||
|
||
if (!hasCachedVideos) {
|
||
if (this.videoListView && container) {
|
||
this.videoListView.showLoading("Fetching explore videos…");
|
||
} else if (container) {
|
||
container.innerHTML = getSidebarLoadingMarkup("Fetching explore videos…");
|
||
}
|
||
}
|
||
|
||
let initialRefreshPromise = null;
|
||
|
||
const videos = await this.nostrService.loadVideos({
|
||
forceFetch,
|
||
blacklistedEventIds: this.blacklistedEventIds,
|
||
isAuthorBlocked: (pubkey) => this.isAuthorBlocked(pubkey),
|
||
onVideos: (payload, detail = {}) => {
|
||
const promise = this.refreshExploreFeed({
|
||
reason: detail?.reason,
|
||
fallbackVideos: payload,
|
||
});
|
||
if (!initialRefreshPromise) {
|
||
initialRefreshPromise = promise;
|
||
}
|
||
},
|
||
});
|
||
|
||
if (initialRefreshPromise) {
|
||
await initialRefreshPromise;
|
||
} else if (!Array.isArray(videos) || videos.length === 0) {
|
||
await this.refreshExploreFeed({ reason: "initial", fallbackVideos: [] });
|
||
}
|
||
|
||
this.videoSubscription = this.nostrService.getVideoSubscription() || null;
|
||
this.videosMap = this.nostrService.getVideosMap();
|
||
if (this.videoListView) {
|
||
this.videoListView.state.videosMap = this.videosMap;
|
||
}
|
||
}
|
||
|
||
async loadOlderVideos(lastTimestamp) {
|
||
const olderVideos = await this.nostrService.loadOlderVideos(lastTimestamp, {
|
||
blacklistedEventIds: this.blacklistedEventIds,
|
||
isAuthorBlocked: (pubkey) => this.isAuthorBlocked(pubkey),
|
||
});
|
||
|
||
if (!Array.isArray(olderVideos) || olderVideos.length === 0) {
|
||
this.showSuccess("No more older videos found.");
|
||
return;
|
||
}
|
||
|
||
await this.refreshRecentFeed({ reason: "older-fetch" });
|
||
}
|
||
|
||
/**
|
||
* Returns true if there's at least one strictly older version
|
||
* (same videoRootId, created_at < current) which is NOT deleted.
|
||
*/
|
||
hasOlderVersion(video, allEvents) {
|
||
if (!video || !video.videoRootId) return false;
|
||
|
||
const rootId = video.videoRootId;
|
||
const currentTs = video.created_at;
|
||
|
||
// among ALL known events (including overshadowed), find older, not deleted
|
||
const olderMatches = allEvents.filter(
|
||
(v) => v.videoRootId === rootId && v.created_at < currentTs && !v.deleted
|
||
);
|
||
return olderMatches.length > 0;
|
||
}
|
||
|
||
/**
|
||
* Centralised helper for other modules (channel profiles, subscriptions)
|
||
* so they can re-use the exact same badge skeleton. Keeping the markup in
|
||
* one place avoids subtle mismatches when we tweak copy or classes later.
|
||
*/
|
||
getUrlHealthPlaceholderMarkup(options = {}) {
|
||
return this.urlHealthController.getUrlHealthPlaceholderMarkup(options);
|
||
}
|
||
|
||
getTorrentHealthBadgeMarkup(options = {}) {
|
||
const includeMargin = options?.includeMargin !== false;
|
||
const classes = ["badge", "torrent-health-badge"];
|
||
if (includeMargin) {
|
||
classes.push("mt-sm");
|
||
}
|
||
|
||
return `
|
||
<span
|
||
class="${classes.join(" ")}"
|
||
data-stream-health-state="checking"
|
||
data-variant="neutral"
|
||
aria-live="polite"
|
||
role="status"
|
||
>
|
||
⏳ Torrent
|
||
</span>
|
||
`;
|
||
}
|
||
|
||
isMagnetUriSupported(magnet) {
|
||
return isValidMagnetUri(magnet);
|
||
}
|
||
|
||
getCachedUrlHealth(eventId, url) {
|
||
return readCachedUrlHealth(eventId, url);
|
||
}
|
||
|
||
storeUrlHealth(eventId, url, result, ttlMs) {
|
||
return persistUrlHealth(eventId, url, result, ttlMs);
|
||
}
|
||
|
||
updateUrlHealthBadge(badgeEl, state, videoId) {
|
||
return this.urlHealthController.updateUrlHealthBadge(badgeEl, state, videoId);
|
||
}
|
||
|
||
handleUrlHealthBadge(payload) {
|
||
return this.urlHealthController.handleUrlHealthBadge(payload);
|
||
}
|
||
|
||
handleStreamHealthBadgeUpdate(detail) {
|
||
if (!detail || typeof detail !== "object") {
|
||
return;
|
||
}
|
||
|
||
const card = detail.card;
|
||
if (!(card instanceof HTMLElement)) {
|
||
return;
|
||
}
|
||
|
||
let videoId =
|
||
(card.dataset && card.dataset.videoId) ||
|
||
(typeof card.getAttribute === "function" ? card.getAttribute("data-video-id") : "") ||
|
||
"";
|
||
|
||
if (!videoId && typeof card.querySelector === "function") {
|
||
const fallback = card.querySelector("[data-video-id]");
|
||
if (fallback instanceof HTMLElement && fallback.dataset.videoId) {
|
||
videoId = fallback.dataset.videoId;
|
||
}
|
||
}
|
||
|
||
if (!videoId) {
|
||
return;
|
||
}
|
||
|
||
if (this.videoListView && typeof this.videoListView.cacheStreamHealth === "function") {
|
||
this.videoListView.cacheStreamHealth(videoId, {
|
||
state: detail.state,
|
||
peers: detail.peers,
|
||
reason: detail.reason,
|
||
checkedAt: detail.checkedAt,
|
||
text: detail.text,
|
||
tooltip: detail.tooltip,
|
||
role: detail.role,
|
||
ariaLive: detail.ariaLive,
|
||
});
|
||
}
|
||
}
|
||
|
||
getFeedTelemetryState(feedName = "") {
|
||
if (!this.feedTelemetryState || typeof this.feedTelemetryState !== "object") {
|
||
this.feedTelemetryState = {
|
||
activeFeed: "",
|
||
feeds: new Map(),
|
||
};
|
||
}
|
||
|
||
if (!(this.feedTelemetryState.feeds instanceof Map)) {
|
||
this.feedTelemetryState.feeds = new Map();
|
||
}
|
||
|
||
const normalized =
|
||
typeof feedName === "string" ? feedName.trim().toLowerCase() : "";
|
||
if (!normalized) {
|
||
return null;
|
||
}
|
||
|
||
if (this.feedTelemetryState.feeds.has(normalized)) {
|
||
return this.feedTelemetryState.feeds.get(normalized);
|
||
}
|
||
|
||
const state = {
|
||
matchedTagsById: new Map(),
|
||
matchReasonsById: new Map(),
|
||
lastImpressionSignature: "",
|
||
activePlayback: null,
|
||
};
|
||
this.feedTelemetryState.feeds.set(normalized, state);
|
||
return state;
|
||
}
|
||
|
||
setFeedTelemetryContext(feedName = "") {
|
||
const normalized =
|
||
typeof feedName === "string" ? feedName.trim().toLowerCase() : "";
|
||
const previousFeed = this.feedTelemetryState?.activeFeed || "";
|
||
if (previousFeed && previousFeed !== normalized) {
|
||
const previousState = this.getFeedTelemetryState(previousFeed);
|
||
if (previousState) {
|
||
previousState.lastImpressionSignature = "";
|
||
previousState.activePlayback = null;
|
||
}
|
||
}
|
||
|
||
const nextState = this.getFeedTelemetryState(normalized);
|
||
if (previousFeed !== normalized && nextState) {
|
||
nextState.lastImpressionSignature = "";
|
||
nextState.activePlayback = null;
|
||
}
|
||
|
||
this.feedTelemetryState.activeFeed = normalized;
|
||
}
|
||
|
||
isForYouFeedActive() {
|
||
return this.feedTelemetryState?.activeFeed === "for-you";
|
||
}
|
||
|
||
updateForYouTelemetryMetadata(items = [], metadata = {}) {
|
||
if (!this.isForYouFeedActive()) {
|
||
return;
|
||
}
|
||
|
||
const forYouState = this.getFeedTelemetryState("for-you");
|
||
if (!forYouState) {
|
||
return;
|
||
}
|
||
|
||
const matchedTagsById = new Map();
|
||
const matchReasonsById = new Map();
|
||
|
||
if (Array.isArray(items)) {
|
||
items.forEach((item) => {
|
||
const videoId =
|
||
typeof item?.video?.id === "string" ? item.video.id : "";
|
||
if (!videoId) {
|
||
return;
|
||
}
|
||
const matched =
|
||
Array.isArray(item?.metadata?.matchedInterests)
|
||
? item.metadata.matchedInterests
|
||
: [];
|
||
matchedTagsById.set(videoId, matched);
|
||
});
|
||
}
|
||
|
||
const whyEntries = Array.isArray(metadata?.why) ? metadata.why : [];
|
||
whyEntries.forEach((entry) => {
|
||
if (!entry || entry.reason !== "matched-interests") {
|
||
return;
|
||
}
|
||
const videoId = typeof entry.videoId === "string" ? entry.videoId : "";
|
||
if (!videoId) {
|
||
return;
|
||
}
|
||
const reasons = matchReasonsById.get(videoId) || [];
|
||
reasons.push({
|
||
stage: typeof entry.stage === "string" ? entry.stage : "",
|
||
reason: entry.reason,
|
||
});
|
||
matchReasonsById.set(videoId, reasons);
|
||
});
|
||
|
||
forYouState.matchedTagsById = matchedTagsById;
|
||
forYouState.matchReasonsById = matchReasonsById;
|
||
}
|
||
|
||
resolveVideoForTelemetry(videoId) {
|
||
if (typeof videoId !== "string" || !videoId) {
|
||
return null;
|
||
}
|
||
|
||
if (this.videosMap instanceof Map && this.videosMap.has(videoId)) {
|
||
return this.videosMap.get(videoId) || null;
|
||
}
|
||
|
||
if (this.videoListView && Array.isArray(this.videoListView.currentVideos)) {
|
||
return this.videoListView.currentVideos.find((video) => video?.id === videoId) || null;
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
resolveVideoIndex(videoId) {
|
||
if (!this.videoListView || !Array.isArray(this.videoListView.currentVideos)) {
|
||
return null;
|
||
}
|
||
|
||
const index = this.videoListView.currentVideos.findIndex(
|
||
(video) => video?.id === videoId,
|
||
);
|
||
return index >= 0 ? index : null;
|
||
}
|
||
|
||
buildForYouTelemetryPayload({ video, videoId, position } = {}) {
|
||
if (!this.isForYouFeedActive()) {
|
||
return null;
|
||
}
|
||
|
||
const forYouState = this.getFeedTelemetryState("for-you");
|
||
if (!forYouState) {
|
||
return null;
|
||
}
|
||
|
||
const eventId =
|
||
typeof videoId === "string" && videoId
|
||
? videoId
|
||
: typeof video?.id === "string"
|
||
? video.id
|
||
: "";
|
||
if (!eventId) {
|
||
return null;
|
||
}
|
||
|
||
const matchedTagsRaw = forYouState.matchedTagsById?.get(eventId) || [];
|
||
const matchedTags = Array.isArray(matchedTagsRaw)
|
||
? Array.from(
|
||
new Set(
|
||
matchedTagsRaw
|
||
.filter((tag) => typeof tag === "string")
|
||
.map((tag) => tag.trim())
|
||
.filter(Boolean),
|
||
),
|
||
)
|
||
: [];
|
||
|
||
const whyRaw = forYouState.matchReasonsById?.get(eventId) || [];
|
||
const why = Array.isArray(whyRaw)
|
||
? whyRaw.map((entry) => ({
|
||
stage: typeof entry.stage === "string" ? entry.stage : "",
|
||
reason: typeof entry.reason === "string" ? entry.reason : "",
|
||
}))
|
||
: [];
|
||
|
||
const payload = {
|
||
feed: "for-you",
|
||
eventId,
|
||
videoId: eventId,
|
||
matchedTags,
|
||
why,
|
||
};
|
||
|
||
const videoRootId =
|
||
typeof video?.videoRootId === "string" ? video.videoRootId : "";
|
||
if (videoRootId) {
|
||
payload.videoRootId = videoRootId;
|
||
}
|
||
|
||
const pubkey = typeof video?.pubkey === "string" ? video.pubkey : "";
|
||
if (pubkey) {
|
||
payload.pubkey = pubkey;
|
||
}
|
||
|
||
if (Number.isFinite(position)) {
|
||
payload.position = Math.max(0, Math.floor(position));
|
||
}
|
||
|
||
return payload;
|
||
}
|
||
|
||
emitTelemetryEvent(eventName, payload) {
|
||
if (!eventName || !payload) {
|
||
return false;
|
||
}
|
||
|
||
const doc =
|
||
(this.videoModal && this.videoModal.document) ||
|
||
(typeof document !== "undefined" ? document : null);
|
||
|
||
if (!doc || typeof doc.dispatchEvent !== "function") {
|
||
return false;
|
||
}
|
||
|
||
try {
|
||
doc.dispatchEvent(
|
||
new CustomEvent("bitvid:telemetry", {
|
||
detail: { event: eventName, payload },
|
||
}),
|
||
);
|
||
return true;
|
||
} catch (error) {
|
||
userLogger.warn("[Application] Failed to emit telemetry event:", error);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
emitForYouTelemetryEvent(eventName, { video, videoId, position } = {}) {
|
||
const payload = this.buildForYouTelemetryPayload({
|
||
video,
|
||
videoId,
|
||
position,
|
||
});
|
||
if (!payload) {
|
||
return false;
|
||
}
|
||
|
||
return this.emitTelemetryEvent(eventName, payload);
|
||
}
|
||
|
||
emitForYouImpressions(videos = []) {
|
||
if (!this.isForYouFeedActive() || !Array.isArray(videos)) {
|
||
return;
|
||
}
|
||
|
||
const forYouState = this.getFeedTelemetryState("for-you");
|
||
if (!forYouState) {
|
||
return;
|
||
}
|
||
|
||
const signature = videos
|
||
.map((video) => (typeof video?.id === "string" ? video.id : ""))
|
||
.filter(Boolean)
|
||
.join("|");
|
||
|
||
if (signature && signature === forYouState.lastImpressionSignature) {
|
||
return;
|
||
}
|
||
|
||
forYouState.lastImpressionSignature = signature;
|
||
|
||
videos.forEach((video, index) => {
|
||
this.emitForYouTelemetryEvent("for_you_impression", {
|
||
video,
|
||
videoId: video?.id,
|
||
position: index,
|
||
});
|
||
});
|
||
}
|
||
|
||
recordForYouClick(videoId) {
|
||
if (!this.isForYouFeedActive() || !videoId) {
|
||
return;
|
||
}
|
||
|
||
const forYouState = this.getFeedTelemetryState("for-you");
|
||
if (!forYouState) {
|
||
return;
|
||
}
|
||
|
||
const video = this.resolveVideoForTelemetry(videoId);
|
||
const position = this.resolveVideoIndex(videoId);
|
||
|
||
forYouState.activePlayback = {
|
||
feed: "for-you",
|
||
videoId,
|
||
};
|
||
|
||
this.emitForYouTelemetryEvent("for_you_click", {
|
||
video,
|
||
videoId,
|
||
position,
|
||
});
|
||
}
|
||
|
||
handleFeedViewTelemetry(detail = {}) {
|
||
if (!this.isForYouFeedActive()) {
|
||
return;
|
||
}
|
||
|
||
const forYouState = this.getFeedTelemetryState("for-you");
|
||
if (!forYouState) {
|
||
return;
|
||
}
|
||
|
||
const activePlayback = forYouState.activePlayback;
|
||
if (!activePlayback || activePlayback.feed !== "for-you") {
|
||
return;
|
||
}
|
||
|
||
const currentVideoId =
|
||
typeof this.currentVideo?.id === "string"
|
||
? this.currentVideo.id
|
||
: activePlayback.videoId;
|
||
if (!currentVideoId || currentVideoId !== activePlayback.videoId) {
|
||
return;
|
||
}
|
||
|
||
const pointerKey =
|
||
typeof detail?.pointerKey === "string" ? detail.pointerKey : "";
|
||
if (activePlayback.pointerKey && pointerKey) {
|
||
if (activePlayback.pointerKey !== pointerKey) {
|
||
return;
|
||
}
|
||
}
|
||
|
||
const video = this.resolveVideoForTelemetry(currentVideoId);
|
||
const payload = this.buildForYouTelemetryPayload({
|
||
video,
|
||
videoId: currentVideoId,
|
||
});
|
||
if (!payload) {
|
||
return;
|
||
}
|
||
|
||
if (pointerKey) {
|
||
payload.pointerKey = pointerKey;
|
||
}
|
||
|
||
this.emitTelemetryEvent("for_you_watch", payload);
|
||
forYouState.activePlayback = null;
|
||
}
|
||
|
||
async renderVideoList(payload) {
|
||
if (!this.videoListView) {
|
||
return;
|
||
}
|
||
|
||
const container = this.mountVideoListView();
|
||
if (!container) {
|
||
return;
|
||
}
|
||
|
||
let videos = [];
|
||
let metadata = null;
|
||
|
||
if (Array.isArray(payload)) {
|
||
videos = payload;
|
||
} else if (payload && typeof payload === "object") {
|
||
if (Array.isArray(payload.videos)) {
|
||
videos = payload.videos;
|
||
}
|
||
if (payload.metadata && typeof payload.metadata === "object") {
|
||
metadata = { ...payload.metadata };
|
||
}
|
||
}
|
||
|
||
this.latestFeedMetadata = metadata;
|
||
if (this.videoListView) {
|
||
this.videoListView.state.feedMetadata = metadata;
|
||
}
|
||
|
||
const decoratedVideos = Array.isArray(videos)
|
||
? videos.map((video) => {
|
||
const moderated = this.decorateVideoModeration(video);
|
||
const targetVideo =
|
||
moderated && typeof moderated === "object" ? moderated : video;
|
||
const withIdentity = this.decorateVideoCreatorIdentity(targetVideo);
|
||
return withIdentity && typeof withIdentity === "object"
|
||
? withIdentity
|
||
: targetVideo;
|
||
})
|
||
: [];
|
||
|
||
this.videoListView.render(decoratedVideos, metadata);
|
||
this.emitForYouImpressions(decoratedVideos);
|
||
|
||
if (typeof this.refreshVisibleModerationUi === "function") {
|
||
const renderReason =
|
||
metadata && typeof metadata.reason === "string" && metadata.reason
|
||
? `render-${metadata.reason}`
|
||
: "render-video-list";
|
||
try {
|
||
this.refreshVisibleModerationUi({ reason: renderReason });
|
||
} catch (error) {
|
||
devLogger.warn(
|
||
"[Application] Failed to refresh moderation UI after rendering video list:",
|
||
error,
|
||
);
|
||
}
|
||
}
|
||
this.updateModalSimilarContent();
|
||
}
|
||
|
||
refreshVideoDiscussionCounts(videos = [], options = {}) {
|
||
if (!this.discussionCountService) {
|
||
return;
|
||
}
|
||
|
||
const { videoListRoot = this.videoList || null } = options;
|
||
|
||
this.discussionCountService.refreshCounts(videos, {
|
||
videoListRoot,
|
||
nostrClient,
|
||
});
|
||
}
|
||
|
||
deriveModerationReportType(summary) {
|
||
return this.moderationDecorator.deriveModerationReportType(summary);
|
||
}
|
||
|
||
deriveModerationTrustedCount(summary, reportType) {
|
||
return this.moderationDecorator.deriveModerationTrustedCount(summary, reportType);
|
||
}
|
||
|
||
getReporterDisplayName(pubkey) {
|
||
return this.moderationDecorator.getReporterDisplayName(pubkey);
|
||
}
|
||
|
||
normalizeModerationSettings(settings = null) {
|
||
return this.moderationDecorator.normalizeModerationSettings(settings);
|
||
}
|
||
|
||
getActiveModerationThresholds() {
|
||
this.moderationSettings = this.moderationDecorator.normalizeModerationSettings(this.moderationSettings);
|
||
return { ...this.moderationSettings };
|
||
}
|
||
|
||
decorateVideoModeration(video, feedContext = {}) {
|
||
const decorated = this.moderationDecorator.decorateVideo(video, feedContext);
|
||
if (
|
||
video &&
|
||
video.pubkey &&
|
||
this.isAuthorBlocked(video.pubkey) &&
|
||
decorated &&
|
||
decorated.moderation
|
||
) {
|
||
decorated.moderation.viewerMuted = true;
|
||
decorated.moderation.hidden = true;
|
||
decorated.moderation.hideReason = "viewer-block";
|
||
}
|
||
return decorated;
|
||
}
|
||
|
||
initializeModerationActionController() {
|
||
if (this.moderationActionController) {
|
||
return this.moderationActionController;
|
||
}
|
||
|
||
this.moderationActionController = new ModerationActionController({
|
||
services: {
|
||
setModerationOverride,
|
||
clearModerationOverride,
|
||
userBlocks,
|
||
},
|
||
selectors: {
|
||
getVideoById: (id) =>
|
||
this.videosMap instanceof Map && id ? this.videosMap.get(id) : null,
|
||
getCurrentVideo: () => this.currentVideo,
|
||
},
|
||
actions: {
|
||
decorateVideoModeration: (video) => this.decorateVideoModeration(video),
|
||
resumePlayback: (video) => this.resumePendingModeratedPlayback(video),
|
||
refreshVideos: (payload) => this.onVideosShouldRefresh(payload),
|
||
showStatus: (message, options) => this.showStatus(message, options),
|
||
showError: (message) => this.showError(message),
|
||
describeBlockError: (error) => this.describeUserBlockActionError(error),
|
||
},
|
||
auth: {
|
||
isLoggedIn: () => this.isUserLoggedIn(),
|
||
getViewerPubkey: () => this.pubkey,
|
||
normalizePubkey: (value) => this.normalizeHexPubkey(value),
|
||
},
|
||
ui: {
|
||
refreshCardModerationUi: (card, options) =>
|
||
this.refreshCardModerationUi(card, options),
|
||
dispatchModerationEvent: (eventName, detail) =>
|
||
this.dispatchModerationEvent(eventName, detail),
|
||
},
|
||
});
|
||
|
||
return this.moderationActionController;
|
||
}
|
||
|
||
refreshCardModerationUi(card, { reason } = {}) {
|
||
if (!card || typeof card.refreshModerationUi !== "function") {
|
||
return false;
|
||
}
|
||
|
||
try {
|
||
card.refreshModerationUi();
|
||
return true;
|
||
} catch (error) {
|
||
const suffix = reason ? ` ${reason}` : "";
|
||
devLogger.warn(
|
||
`[Application] Failed to refresh moderation UI${suffix}:`,
|
||
error,
|
||
);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
dispatchModerationEvent(eventName, detail = {}) {
|
||
const doc =
|
||
(this.videoModal && this.videoModal.document) ||
|
||
(typeof document !== "undefined" ? document : null);
|
||
|
||
if (!doc || typeof doc.dispatchEvent !== "function") {
|
||
return false;
|
||
}
|
||
|
||
try {
|
||
doc.dispatchEvent(new CustomEvent(eventName, { detail }));
|
||
return true;
|
||
} catch (error) {
|
||
const eventLabels = {
|
||
"video:moderation-override": "moderation override event",
|
||
"video:moderation-block": "moderation block event",
|
||
"video:moderation-hide": "moderation hide event",
|
||
};
|
||
const label = eventLabels[eventName] || eventName;
|
||
devLogger.warn(`[Application] Failed to dispatch ${label}:`, error);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
handleModerationOverride(payload = {}) {
|
||
const controller = this.initializeModerationActionController();
|
||
if (!controller) {
|
||
return false;
|
||
}
|
||
|
||
return controller.handleOverride(payload);
|
||
}
|
||
|
||
async handleModerationBlock(payload = {}) {
|
||
const controller = this.initializeModerationActionController();
|
||
if (!controller) {
|
||
return false;
|
||
}
|
||
|
||
return controller.handleBlock(payload);
|
||
}
|
||
|
||
handleModerationHide(payload = {}) {
|
||
const controller = this.initializeModerationActionController();
|
||
if (!controller) {
|
||
return false;
|
||
}
|
||
|
||
return controller.handleHide(payload);
|
||
}
|
||
|
||
getVideoAddressPointer(video) {
|
||
if (
|
||
this.discussionCountService &&
|
||
typeof this.discussionCountService.getVideoAddressPointer === "function"
|
||
) {
|
||
return this.discussionCountService.getVideoAddressPointer(video);
|
||
}
|
||
|
||
return buildVideoAddressPointer(video, { defaultKind: VIDEO_EVENT_KIND });
|
||
}
|
||
|
||
bindThumbnailFallbacks(container) {
|
||
if (!container || typeof container.querySelectorAll !== "function") {
|
||
return;
|
||
}
|
||
|
||
const thumbnails = container.querySelectorAll("[data-video-thumbnail]");
|
||
thumbnails.forEach((img) => {
|
||
if (!img) {
|
||
return;
|
||
}
|
||
|
||
const ensureFallbackSource = () => {
|
||
let fallbackSrc = "";
|
||
if (typeof img.dataset.fallbackSrc === "string") {
|
||
fallbackSrc = img.dataset.fallbackSrc.trim();
|
||
}
|
||
|
||
if (!fallbackSrc) {
|
||
const attr = img.getAttribute("data-fallback-src") || "";
|
||
fallbackSrc = attr.trim();
|
||
}
|
||
|
||
if (!fallbackSrc && img.tagName === "IMG") {
|
||
fallbackSrc = FALLBACK_THUMBNAIL_SRC;
|
||
}
|
||
|
||
if (fallbackSrc) {
|
||
if (img.dataset.fallbackSrc !== fallbackSrc) {
|
||
img.dataset.fallbackSrc = fallbackSrc;
|
||
}
|
||
if (!img.getAttribute("data-fallback-src")) {
|
||
img.setAttribute("data-fallback-src", fallbackSrc);
|
||
}
|
||
}
|
||
|
||
return fallbackSrc;
|
||
};
|
||
|
||
const applyFallback = () => {
|
||
const fallbackSrc = ensureFallbackSource() || FALLBACK_THUMBNAIL_SRC;
|
||
if (!fallbackSrc) {
|
||
return;
|
||
}
|
||
|
||
if (img.src !== fallbackSrc) {
|
||
img.src = fallbackSrc;
|
||
}
|
||
|
||
img.dataset.thumbnailFailed = "true";
|
||
};
|
||
|
||
const handleLoad = () => {
|
||
if (
|
||
(img.naturalWidth === 0 && img.naturalHeight === 0) ||
|
||
!img.currentSrc
|
||
) {
|
||
applyFallback();
|
||
} else {
|
||
delete img.dataset.thumbnailFailed;
|
||
}
|
||
};
|
||
|
||
ensureFallbackSource();
|
||
|
||
if (img.dataset.thumbnailFallbackBound === "true") {
|
||
if (img.complete) {
|
||
handleLoad();
|
||
}
|
||
return;
|
||
}
|
||
|
||
img.addEventListener("error", applyFallback);
|
||
img.addEventListener("load", handleLoad);
|
||
|
||
img.dataset.thumbnailFallbackBound = "true";
|
||
|
||
if (img.complete) {
|
||
handleLoad();
|
||
}
|
||
});
|
||
}
|
||
|
||
ensureGlobalMoreMenuHandlers() {
|
||
if (this.moreMenuController) {
|
||
this.moreMenuController.ensureGlobalMoreMenuHandlers();
|
||
}
|
||
}
|
||
|
||
closeAllMoreMenus(options = {}) {
|
||
if (this.moreMenuController) {
|
||
this.moreMenuController.closeAllMoreMenus(options);
|
||
}
|
||
|
||
this.closeVideoSettingsMenu({ restoreFocus: options?.restoreFocus !== false });
|
||
this.tagPreferenceMenuController.closeMenus({
|
||
restoreFocus: options?.restoreFocus !== false,
|
||
});
|
||
}
|
||
|
||
attachMoreMenuHandlers(container) {
|
||
if (this.moreMenuController) {
|
||
this.moreMenuController.attachMoreMenuHandlers(container);
|
||
}
|
||
}
|
||
|
||
requestMoreMenu(detail = {}) {
|
||
if (!this.moreMenuController) {
|
||
return;
|
||
}
|
||
|
||
const trigger = detail.trigger || null;
|
||
if (!trigger) {
|
||
return;
|
||
}
|
||
|
||
const capabilities = detail.capabilities || {};
|
||
const canManage =
|
||
typeof capabilities.canManageBlacklist === "boolean"
|
||
? capabilities.canManageBlacklist
|
||
: this.canCurrentUserManageBlacklist();
|
||
|
||
this.moreMenuController.toggleMoreMenu({
|
||
trigger,
|
||
video: detail.video || null,
|
||
pointerInfo: detail.pointerInfo || null,
|
||
playbackUrl: detail.playbackUrl || "",
|
||
playbackMagnet: detail.playbackMagnet || "",
|
||
context: detail.context || "card",
|
||
canManageBlacklist: canManage,
|
||
designSystem: detail.designSystem || this.designSystemContext,
|
||
onAction: detail.onAction || null,
|
||
onClose: detail.onClose || null,
|
||
restoreFocusOnClose: detail.restoreFocus !== false,
|
||
});
|
||
}
|
||
|
||
closeMoreMenu(detail = {}) {
|
||
if (!this.moreMenuController) {
|
||
return false;
|
||
}
|
||
|
||
const trigger = detail.trigger || null;
|
||
if (trigger) {
|
||
return this.moreMenuController.closePopoverForTrigger(trigger, {
|
||
restoreFocus: detail.restoreFocus !== false,
|
||
});
|
||
}
|
||
|
||
this.moreMenuController.closeAllMoreMenus({
|
||
restoreFocus: detail.restoreFocus !== false,
|
||
skipView: detail.skipView === true,
|
||
});
|
||
return true;
|
||
}
|
||
|
||
ensureSettingsPopover(detail = {}) {
|
||
const trigger = detail.trigger || null;
|
||
if (!trigger) {
|
||
return null;
|
||
}
|
||
|
||
let entry = this.videoSettingsPopovers.get(trigger);
|
||
if (!entry) {
|
||
entry = {
|
||
trigger,
|
||
context: {
|
||
card: detail.card || null,
|
||
video: detail.video || null,
|
||
index: Number.isFinite(detail.index) ? Math.floor(detail.index) : 0,
|
||
capabilities: detail.capabilities || {},
|
||
restoreFocusOnClose: detail.restoreFocus !== false,
|
||
},
|
||
popover: null,
|
||
};
|
||
|
||
const render = ({ document: documentRef, close }) => {
|
||
const panel = createVideoSettingsMenuPanel({
|
||
document: documentRef,
|
||
video: entry.context.video,
|
||
index: entry.context.index,
|
||
capabilities: entry.context.capabilities,
|
||
designSystem: this.designSystemContext,
|
||
});
|
||
|
||
if (!panel) {
|
||
return null;
|
||
}
|
||
|
||
const buttons = panel.querySelectorAll("button[data-action]");
|
||
buttons.forEach((button) => {
|
||
button.addEventListener("click", (event) => {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
|
||
const action = button.dataset.action || "";
|
||
const handled = entry.context.card?.handleSettingsMenuAction?.(
|
||
action,
|
||
{ event },
|
||
);
|
||
|
||
if (!handled && this.isDevMode) {
|
||
userLogger.warn(`[SettingsMenu] Unhandled action: ${action}`);
|
||
}
|
||
|
||
close();
|
||
});
|
||
});
|
||
|
||
return panel;
|
||
};
|
||
|
||
const documentRef =
|
||
trigger.ownerDocument ||
|
||
(typeof document !== "undefined" ? document : null);
|
||
|
||
const popover = createPopover(trigger, render, {
|
||
document: documentRef,
|
||
placement: "bottom-end",
|
||
});
|
||
|
||
const originalDestroy = popover.destroy?.bind(popover);
|
||
if (typeof originalDestroy === "function") {
|
||
popover.destroy = (...args) => {
|
||
originalDestroy(...args);
|
||
if (this.videoSettingsPopovers.get(trigger) === entry) {
|
||
this.videoSettingsPopovers.delete(trigger);
|
||
}
|
||
};
|
||
}
|
||
|
||
entry.popover = popover;
|
||
this.videoSettingsPopovers.set(trigger, entry);
|
||
}
|
||
|
||
entry.context = {
|
||
...entry.context,
|
||
card: detail.card || entry.context.card,
|
||
video: detail.video || entry.context.video,
|
||
index: Number.isFinite(detail.index)
|
||
? Math.floor(detail.index)
|
||
: entry.context.index,
|
||
capabilities: detail.capabilities || entry.context.capabilities,
|
||
restoreFocusOnClose: detail.restoreFocus !== false,
|
||
};
|
||
|
||
return entry;
|
||
}
|
||
|
||
requestVideoSettingsMenu(detail = {}) {
|
||
const entry = this.ensureSettingsPopover(detail);
|
||
if (!entry?.popover) {
|
||
return;
|
||
}
|
||
|
||
if (typeof entry.popover.isOpen === "function" && entry.popover.isOpen()) {
|
||
entry.popover.close({
|
||
restoreFocus: entry.context.restoreFocusOnClose !== false,
|
||
});
|
||
return;
|
||
}
|
||
|
||
entry.popover
|
||
.open()
|
||
.catch((error) =>
|
||
userLogger.error("[SettingsMenu] Failed to open popover:", error),
|
||
);
|
||
}
|
||
|
||
closeVideoSettingsMenu(detail = {}) {
|
||
const trigger = detail.trigger || null;
|
||
const restoreFocus = detail.restoreFocus !== false;
|
||
|
||
if (trigger) {
|
||
const entry = this.videoSettingsPopovers.get(trigger);
|
||
if (entry?.popover && typeof entry.popover.close === "function") {
|
||
return entry.popover.close({ restoreFocus });
|
||
}
|
||
return false;
|
||
}
|
||
|
||
let closed = false;
|
||
this.videoSettingsPopovers.forEach((entry) => {
|
||
if (entry?.popover && typeof entry.popover.close === "function") {
|
||
const result = entry.popover.close({ restoreFocus });
|
||
closed = closed || result;
|
||
}
|
||
});
|
||
return closed;
|
||
}
|
||
|
||
ensureTagPreferencePopover(detail) {
|
||
return this.tagPreferenceMenuController.ensurePopover(detail);
|
||
}
|
||
|
||
requestTagPreferenceMenu(detail) {
|
||
return this.tagPreferenceMenuController.requestMenu(detail);
|
||
}
|
||
|
||
closeTagPreferenceMenus(detail) {
|
||
return this.tagPreferenceMenuController.closeMenus(detail);
|
||
}
|
||
|
||
handleTagPreferenceActivation(detail = {}) {
|
||
this.tagPreferenceMenuController.handleActivation(detail);
|
||
}
|
||
|
||
syncModalMoreMenuData() {
|
||
if (this.moreMenuController) {
|
||
this.moreMenuController.syncModalMoreMenuData();
|
||
}
|
||
}
|
||
|
||
async handleMoreMenuAction(action, dataset = {}) {
|
||
if (!this.moreMenuController) {
|
||
return;
|
||
}
|
||
|
||
return this.moreMenuController.handleMoreMenuAction(action, dataset);
|
||
}
|
||
|
||
async handleRepostAction(dataset = {}) {
|
||
if (this.engagementController) {
|
||
return this.engagementController.handleRepostAction(dataset);
|
||
}
|
||
}
|
||
|
||
async handleMirrorAction(dataset = {}) {
|
||
if (this.engagementController) {
|
||
return this.engagementController.handleMirrorAction(dataset);
|
||
}
|
||
}
|
||
|
||
async handleEnsurePresenceAction(dataset = {}) {
|
||
if (this.engagementController) {
|
||
return this.engagementController.handleEnsurePresenceAction(dataset);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Updates the modal to reflect current torrent stats.
|
||
* We remove the unused torrent.status references,
|
||
* and do not re-trigger recursion here (no setTimeout).
|
||
*/
|
||
updateTorrentStatus(torrent) {
|
||
if (this.torrentStatusController) {
|
||
this.torrentStatusController.update(torrent);
|
||
}
|
||
}
|
||
|
||
normalizeActionTarget(target) {
|
||
const isElement = (value) =>
|
||
typeof Element !== "undefined" && value instanceof Element;
|
||
let triggerElement = null;
|
||
let providedVideo = null;
|
||
if (target && typeof target === "object") {
|
||
let eventId =
|
||
typeof target.eventId === "string" ? target.eventId.trim() : "";
|
||
let index = null;
|
||
if (typeof target.index === "number" && Number.isInteger(target.index)) {
|
||
index = target.index;
|
||
} else if (
|
||
typeof target.index === "string" && target.index.trim().length > 0
|
||
) {
|
||
const parsed = Number.parseInt(target.index.trim(), 10);
|
||
index = Number.isNaN(parsed) ? null : parsed;
|
||
}
|
||
const targetVideo = target.video;
|
||
if (targetVideo && typeof targetVideo === "object") {
|
||
const candidateId =
|
||
typeof targetVideo.id === "string" ? targetVideo.id.trim() : "";
|
||
if (candidateId) {
|
||
providedVideo = targetVideo;
|
||
if (!eventId) {
|
||
eventId = candidateId;
|
||
}
|
||
}
|
||
}
|
||
if (isElement(target.triggerElement)) {
|
||
triggerElement = target.triggerElement;
|
||
} else if (isElement(target.trigger)) {
|
||
triggerElement = target.trigger;
|
||
}
|
||
return { eventId, index, triggerElement, video: providedVideo };
|
||
}
|
||
|
||
if (typeof target === "string") {
|
||
const trimmed = target.trim();
|
||
if (!trimmed) {
|
||
return { eventId: "", index: null, triggerElement, video: null };
|
||
}
|
||
if (/^-?\d+$/.test(trimmed)) {
|
||
const parsed = Number.parseInt(trimmed, 10);
|
||
return {
|
||
eventId: "",
|
||
index: Number.isNaN(parsed) ? null : parsed,
|
||
triggerElement,
|
||
video: null,
|
||
};
|
||
}
|
||
return { eventId: trimmed, index: null, triggerElement, video: null };
|
||
}
|
||
|
||
if (typeof target === "number" && Number.isInteger(target)) {
|
||
return { eventId: "", index: target, triggerElement, video: null };
|
||
}
|
||
|
||
return { eventId: "", index: null, triggerElement, video: null };
|
||
}
|
||
|
||
async resolveVideoActionTarget({
|
||
eventId = "",
|
||
index = null,
|
||
preloadedList,
|
||
video: providedVideo = null,
|
||
} = {}) {
|
||
const trimmedEventId = typeof eventId === "string" ? eventId.trim() : "";
|
||
const normalizedIndex =
|
||
typeof index === "number" && Number.isInteger(index) && index >= 0
|
||
? index
|
||
: null;
|
||
|
||
const ensureCreatorIdentity = (video) => {
|
||
if (
|
||
!video ||
|
||
typeof video !== "object" ||
|
||
typeof this.decorateVideoCreatorIdentity !== "function"
|
||
) {
|
||
return video;
|
||
}
|
||
try {
|
||
const decorated = this.decorateVideoCreatorIdentity(video);
|
||
return decorated && typeof decorated === "object" ? decorated : video;
|
||
} catch (error) {
|
||
devLogger.warn(
|
||
"[Application] Failed to decorate video identity for action target:",
|
||
error,
|
||
);
|
||
return video;
|
||
}
|
||
};
|
||
|
||
const initialVideo =
|
||
providedVideo && typeof providedVideo === "object" ? providedVideo : null;
|
||
const preparedInitialVideo = ensureCreatorIdentity(initialVideo);
|
||
const providedId =
|
||
preparedInitialVideo && typeof preparedInitialVideo.id === "string"
|
||
? preparedInitialVideo.id.trim()
|
||
: "";
|
||
const targetEventId = trimmedEventId || providedId;
|
||
|
||
const candidateLists = Array.isArray(preloadedList)
|
||
? [preloadedList]
|
||
: [];
|
||
|
||
if (providedId) {
|
||
this.videosMap.set(providedId, preparedInitialVideo);
|
||
if (!trimmedEventId || providedId === trimmedEventId) {
|
||
return preparedInitialVideo;
|
||
}
|
||
}
|
||
|
||
for (const list of candidateLists) {
|
||
if (targetEventId) {
|
||
const match = list.find((video) => video?.id === targetEventId);
|
||
if (match) {
|
||
const preparedMatch = ensureCreatorIdentity(match);
|
||
this.videosMap.set(preparedMatch.id, preparedMatch);
|
||
return preparedMatch;
|
||
}
|
||
}
|
||
if (
|
||
normalizedIndex !== null &&
|
||
normalizedIndex >= 0 &&
|
||
normalizedIndex < list.length
|
||
) {
|
||
const match = list[normalizedIndex];
|
||
if (match) {
|
||
const preparedMatch = ensureCreatorIdentity(match);
|
||
this.videosMap.set(preparedMatch.id, preparedMatch);
|
||
return preparedMatch;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (targetEventId) {
|
||
const fromMap = this.videosMap.get(targetEventId);
|
||
if (fromMap) {
|
||
return fromMap;
|
||
}
|
||
|
||
const activeVideos = nostrClient.getActiveVideos();
|
||
const fromActive = activeVideos.find((video) => video.id === targetEventId);
|
||
if (fromActive) {
|
||
const preparedActive = ensureCreatorIdentity(fromActive);
|
||
this.videosMap.set(preparedActive.id, preparedActive);
|
||
return preparedActive;
|
||
}
|
||
|
||
const fromAll = nostrClient.allEvents.get(targetEventId);
|
||
if (fromAll) {
|
||
const preparedFromAll = ensureCreatorIdentity(fromAll);
|
||
this.videosMap.set(preparedFromAll.id, preparedFromAll);
|
||
return preparedFromAll;
|
||
}
|
||
|
||
const fetched = await nostrClient.getEventById(targetEventId);
|
||
if (fetched) {
|
||
const preparedFetched = ensureCreatorIdentity(fetched);
|
||
this.videosMap.set(preparedFetched.id, preparedFetched);
|
||
return preparedFetched;
|
||
}
|
||
}
|
||
|
||
if (normalizedIndex !== null) {
|
||
const activeVideos = nostrClient.getActiveVideos();
|
||
if (normalizedIndex >= 0 && normalizedIndex < activeVideos.length) {
|
||
const candidate = activeVideos[normalizedIndex];
|
||
if (candidate) {
|
||
const preparedCandidate = ensureCreatorIdentity(candidate);
|
||
this.videosMap.set(preparedCandidate.id, preparedCandidate);
|
||
return preparedCandidate;
|
||
}
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* Handle "Edit Video" from gear menu.
|
||
*/
|
||
async handleEditModalSubmit(event) {
|
||
const detail = event?.detail || {};
|
||
const { originalEvent, updatedData } = detail;
|
||
if (!originalEvent || !updatedData) {
|
||
return;
|
||
}
|
||
|
||
if (!this.pubkey) {
|
||
this.showError("Please login to edit videos.");
|
||
if (this.editModal?.setSubmitState) {
|
||
this.editModal.setSubmitState({ pending: false });
|
||
}
|
||
return;
|
||
}
|
||
|
||
try {
|
||
await this.nostrService.handleEditVideoSubmit({
|
||
originalEvent,
|
||
updatedData,
|
||
pubkey: this.pubkey,
|
||
});
|
||
await this.loadVideos();
|
||
this.videosMap.clear();
|
||
this.showSuccess("Video updated successfully!");
|
||
if (this.editModal?.setSubmitState) {
|
||
this.editModal.setSubmitState({ pending: false });
|
||
}
|
||
this.editModal.close();
|
||
this.forceRefreshAllProfiles();
|
||
} catch (error) {
|
||
devLogger.error("Failed to edit video:", error);
|
||
this.showError("Failed to edit video. Please try again.");
|
||
if (this.editModal?.setSubmitState) {
|
||
this.editModal.setSubmitState({ pending: false });
|
||
}
|
||
}
|
||
}
|
||
|
||
async handleEditVideo(target) {
|
||
try {
|
||
const normalizedTarget = this.normalizeActionTarget(target);
|
||
const { triggerElement } = normalizedTarget;
|
||
const latestVideos = await this.nostrService.fetchVideos({
|
||
blacklistedEventIds: this.blacklistedEventIds,
|
||
isAuthorBlocked: (pubkey) => this.isAuthorBlocked(pubkey),
|
||
});
|
||
const video = await this.resolveVideoActionTarget({
|
||
...normalizedTarget,
|
||
preloadedList: latestVideos,
|
||
});
|
||
|
||
// 2) Basic ownership checks
|
||
if (!this.pubkey) {
|
||
this.showError("Please login to edit videos.");
|
||
return;
|
||
}
|
||
const userPubkey = (this.pubkey || "").toLowerCase();
|
||
const videoPubkey = (video?.pubkey || "").toLowerCase();
|
||
if (!video || !videoPubkey || videoPubkey !== userPubkey) {
|
||
this.showError("You do not own this video.");
|
||
return;
|
||
}
|
||
|
||
try {
|
||
await this.editModal.load();
|
||
} catch (error) {
|
||
devLogger.error("Failed to load edit modal:", error);
|
||
this.showError(`Failed to initialize edit modal: ${error.message}`);
|
||
return;
|
||
}
|
||
|
||
try {
|
||
await this.editModal.open(video, { triggerElement });
|
||
} catch (error) {
|
||
devLogger.error("Failed to open edit modal:", error);
|
||
this.showError("Edit modal is not available right now.");
|
||
}
|
||
} catch (err) {
|
||
devLogger.error("Failed to edit video:", err);
|
||
this.showError("Failed to edit video. Please try again.");
|
||
}
|
||
}
|
||
|
||
async handleRevertVideo(target) {
|
||
try {
|
||
const normalizedTarget = this.normalizeActionTarget(target);
|
||
const { triggerElement } = normalizedTarget;
|
||
const activeVideos = await this.nostrService.fetchVideos({
|
||
blacklistedEventIds: this.blacklistedEventIds,
|
||
isAuthorBlocked: (pubkey) => this.isAuthorBlocked(pubkey),
|
||
});
|
||
const video = await this.resolveVideoActionTarget({
|
||
...normalizedTarget,
|
||
preloadedList: activeVideos,
|
||
});
|
||
|
||
if (!this.pubkey) {
|
||
this.showError("Please login to revert.");
|
||
return;
|
||
}
|
||
const userPubkey = (this.pubkey || "").toLowerCase();
|
||
const videoPubkey = (video?.pubkey || "").toLowerCase();
|
||
if (!video || !videoPubkey || videoPubkey !== userPubkey) {
|
||
this.showError("You do not own this video.");
|
||
return;
|
||
}
|
||
|
||
if (!this.revertModal) {
|
||
this.showError("Revert modal is not available right now.");
|
||
return;
|
||
}
|
||
|
||
const loaded = await this.revertModal.load();
|
||
if (!loaded) {
|
||
this.showError("Revert modal is not available right now.");
|
||
return;
|
||
}
|
||
|
||
const history = await nostrClient.hydrateVideoHistory(video);
|
||
|
||
this.revertModal.setHistory(video, history);
|
||
this.revertModal.open({ video }, { triggerElement });
|
||
} catch (err) {
|
||
devLogger.error("Failed to revert video:", err);
|
||
this.showError("Failed to load revision history. Please try again.");
|
||
}
|
||
}
|
||
|
||
async handleRevertModalConfirm(event) {
|
||
const detail = event?.detail || {};
|
||
const target = detail.target;
|
||
const entries = Array.isArray(detail.entries)
|
||
? detail.entries.slice()
|
||
: [];
|
||
|
||
if (!target || !entries.length) {
|
||
return;
|
||
}
|
||
|
||
if (!this.pubkey) {
|
||
this.showError("Please login to revert.");
|
||
return;
|
||
}
|
||
|
||
if (!this.revertModal) {
|
||
this.showError("Revert modal is not available right now.");
|
||
return;
|
||
}
|
||
|
||
this.revertModal.setBusy(true, "Reverting…");
|
||
|
||
try {
|
||
for (const entry of entries) {
|
||
await nostrClient.revertVideo(
|
||
{
|
||
id: entry.id,
|
||
pubkey: entry.pubkey,
|
||
tags: entry.tags,
|
||
},
|
||
this.pubkey
|
||
);
|
||
}
|
||
|
||
await this.loadVideos();
|
||
|
||
const timestampLabel = this.formatAbsoluteTimestamp(target.created_at);
|
||
this.showSuccess(`Reverted to revision from ${timestampLabel}.`);
|
||
this.revertModal.close();
|
||
this.forceRefreshAllProfiles();
|
||
} catch (err) {
|
||
devLogger.error("Failed to revert video:", err);
|
||
this.showError("Failed to revert video. Please try again.");
|
||
} finally {
|
||
this.revertModal.setBusy(false);
|
||
}
|
||
}
|
||
|
||
async handleDeleteModalConfirm(event) {
|
||
const detail = event?.detail || {};
|
||
const targetVideo = detail.video || this.deleteModal?.activeVideo || null;
|
||
|
||
if (!targetVideo) {
|
||
return;
|
||
}
|
||
|
||
if (!this.pubkey) {
|
||
this.showError("Please login to delete videos.");
|
||
return;
|
||
}
|
||
|
||
if (!this.deleteModal) {
|
||
this.showError("Delete modal is not available right now.");
|
||
return;
|
||
}
|
||
|
||
const rootId = targetVideo.videoRootId || targetVideo.id || "";
|
||
if (!rootId) {
|
||
this.showError("Unable to determine the video root for deletion.");
|
||
return;
|
||
}
|
||
|
||
this.deleteModal.setBusy(true, "Deleting…");
|
||
this.showStatus("Deleting. Please wait.", { showSpinner: true });
|
||
|
||
try {
|
||
await this.nostrService.handleFullDeleteVideo({
|
||
videoRootId: rootId,
|
||
video: targetVideo,
|
||
pubkey: this.pubkey,
|
||
confirm: false,
|
||
});
|
||
|
||
await this.loadVideos();
|
||
this.showSuccess("All versions deleted successfully!");
|
||
this.deleteModal.close();
|
||
this.forceRefreshAllProfiles();
|
||
} catch (err) {
|
||
devLogger.error("Failed to delete all versions:", err);
|
||
this.showError("Failed to delete all versions. Please try again.");
|
||
} finally {
|
||
if (this.deleteModal) {
|
||
this.deleteModal.setBusy(false);
|
||
}
|
||
this.showStatus("");
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Handle "Delete Video" from gear menu.
|
||
*/
|
||
async handleFullDeleteVideo(target) {
|
||
try {
|
||
const normalizedTarget = this.normalizeActionTarget(target);
|
||
const { triggerElement } = normalizedTarget;
|
||
const all = this.nostrService.getFilteredActiveVideos({
|
||
blacklistedEventIds: this.blacklistedEventIds,
|
||
isAuthorBlocked: (pubkey) => this.isAuthorBlocked(pubkey),
|
||
});
|
||
const video = await this.resolveVideoActionTarget({
|
||
...normalizedTarget,
|
||
preloadedList: all,
|
||
});
|
||
|
||
if (!this.pubkey) {
|
||
this.showError("Please login to delete videos.");
|
||
return;
|
||
}
|
||
const userPubkey = (this.pubkey || "").toLowerCase();
|
||
const videoPubkey = (video?.pubkey || "").toLowerCase();
|
||
if (!video || !videoPubkey || videoPubkey !== userPubkey) {
|
||
this.showError("You do not own this video.");
|
||
return;
|
||
}
|
||
if (!this.deleteModal) {
|
||
this.showError("Delete modal is not available right now.");
|
||
return;
|
||
}
|
||
|
||
const loaded = await this.deleteModal.load();
|
||
if (!loaded) {
|
||
this.showError("Delete modal is not available right now.");
|
||
return;
|
||
}
|
||
|
||
this.deleteModal.setVideo(video);
|
||
this.deleteModal.open({ video }, { triggerElement });
|
||
} catch (err) {
|
||
devLogger.error("Failed to delete all versions:", err);
|
||
this.showError("Failed to delete all versions. Please try again.");
|
||
}
|
||
}
|
||
|
||
/**
|
||
* If there's a ?v= param in the URL, auto-open that video.
|
||
*/
|
||
checkUrlParams() {
|
||
const urlParams = new URLSearchParams(window.location.search);
|
||
const maybeNevent = urlParams.get("v");
|
||
if (!maybeNevent) return; // no link param
|
||
|
||
try {
|
||
const decoded = window.NostrTools.nip19.decode(maybeNevent);
|
||
if (decoded.type === "nevent" && decoded.data.id) {
|
||
const eventId = decoded.data.id;
|
||
const relay =
|
||
Array.isArray(decoded.data.relays) && decoded.data.relays.length
|
||
? decoded.data.relays[0]
|
||
: null;
|
||
// 1) check local map
|
||
let localMatch = this.videosMap.get(eventId);
|
||
if (localMatch) {
|
||
this.playVideoByEventId(eventId, { relay });
|
||
} else {
|
||
// 2) fallback => getOldEventById
|
||
this.getOldEventById(eventId)
|
||
.then((video) => {
|
||
if (video) {
|
||
this.playVideoByEventId(eventId, { relay });
|
||
} else {
|
||
this.showError("No matching video found for that link.");
|
||
}
|
||
})
|
||
.catch((err) => {
|
||
devLogger.error("Error fetching older event by ID:", err);
|
||
this.showError("Could not load videos for the share link.");
|
||
});
|
||
}
|
||
}
|
||
} catch (err) {
|
||
devLogger.error("Error decoding nevent:", err);
|
||
this.showError("Invalid share link.");
|
||
}
|
||
}
|
||
|
||
async probeUrlWithVideoElement(url, timeoutMs) {
|
||
return this.urlHealthController.probeUrlWithVideoElement(url, timeoutMs);
|
||
}
|
||
|
||
async probeUrl(url, options = {}) {
|
||
return this.urlHealthController.probeUrl(url, options);
|
||
}
|
||
|
||
async playHttp(videoEl, url) {
|
||
const target = videoEl || this.modalVideo;
|
||
if (!target) {
|
||
return false;
|
||
}
|
||
|
||
const sanitizedUrl = typeof url === "string" ? url.trim() : "";
|
||
if (!sanitizedUrl) {
|
||
return false;
|
||
}
|
||
|
||
target.src = sanitizedUrl;
|
||
|
||
try {
|
||
await target.play();
|
||
return true;
|
||
} catch (err) {
|
||
devLogger.warn("[playHttp] Direct URL playback failed:", err);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
async playViaWebTorrent(
|
||
magnet,
|
||
{ fallbackMagnet = "", urlList = [] } = {}
|
||
) {
|
||
const sanitizedUrlList = Array.isArray(urlList)
|
||
? urlList
|
||
.map((entry) =>
|
||
typeof entry === "string" ? entry.trim() : ""
|
||
)
|
||
.filter((entry) => /^https?:\/\//i.test(entry))
|
||
: [];
|
||
|
||
const attemptStream = async (candidate) => {
|
||
const trimmedCandidate =
|
||
typeof candidate === "string" ? candidate.trim() : "";
|
||
if (!trimmedCandidate) {
|
||
throw new Error("No magnet URI provided for torrent playback.");
|
||
}
|
||
if (!isValidMagnetUri(trimmedCandidate)) {
|
||
if (this.videoModal) {
|
||
this.videoModal.updateStatus(UNSUPPORTED_BTITH_MESSAGE);
|
||
}
|
||
throw new Error(UNSUPPORTED_BTITH_MESSAGE);
|
||
}
|
||
if (!this.modalVideo) {
|
||
throw new Error(
|
||
"No modal video element available for torrent playback."
|
||
);
|
||
}
|
||
|
||
const timestamp = Date.now().toString();
|
||
const [magnetPrefix, magnetQuery = ""] = trimmedCandidate.split("?", 2);
|
||
let normalizedMagnet = magnetPrefix;
|
||
let queryParts = magnetQuery
|
||
.split("&")
|
||
.map((part) => part.trim())
|
||
.filter((part) => part && !/^ts=\d+$/.test(part));
|
||
|
||
if (queryParts.length) {
|
||
normalizedMagnet = `${magnetPrefix}?${queryParts.join("&")}`;
|
||
}
|
||
|
||
const separator = normalizedMagnet.includes("?") ? "&" : "?";
|
||
const cacheBustedMagnet = `${normalizedMagnet}${separator}ts=${timestamp}`;
|
||
|
||
await torrentClient.cleanup();
|
||
this.resetTorrentStats();
|
||
|
||
if (this.videoModal) {
|
||
this.videoModal.updateStatus("Streaming via WebTorrent");
|
||
this.videoModal.setTorrentStatsVisibility?.(true);
|
||
}
|
||
|
||
const torrentInstance = await torrentClient.streamVideo(
|
||
cacheBustedMagnet,
|
||
this.modalVideo,
|
||
{ urlList: sanitizedUrlList }
|
||
);
|
||
|
||
if (torrentClient.isServiceWorkerUnavailable()) {
|
||
const swError = torrentClient.getServiceWorkerInitError();
|
||
const statusMessage = buildServiceWorkerFallbackStatus(swError);
|
||
this.log(
|
||
"[playViaWebTorrent] Service worker unavailable; streaming directly via WebTorrent.",
|
||
swError
|
||
);
|
||
if (swError) {
|
||
userLogger.warn(
|
||
"[playViaWebTorrent] Service worker unavailable; direct streaming engaged.",
|
||
swError
|
||
);
|
||
}
|
||
if (this.videoModal) {
|
||
this.videoModal.updateStatus(statusMessage);
|
||
}
|
||
}
|
||
if (torrentInstance && torrentInstance.ready) {
|
||
// Some browsers delay `playing` events for MediaSource-backed torrents.
|
||
// Clearing the poster here prevents the historic "GIF stuck over the
|
||
// video" regression when WebTorrent is already feeding data.
|
||
this.forceRemoveModalPoster("webtorrent-ready");
|
||
}
|
||
this.startTorrentStatusMirrors(torrentInstance);
|
||
return torrentInstance;
|
||
};
|
||
|
||
const primaryTrimmed =
|
||
typeof magnet === "string" ? magnet.trim() : "";
|
||
const fallbackTrimmed =
|
||
typeof fallbackMagnet === "string" ? fallbackMagnet.trim() : "";
|
||
const hasFallback =
|
||
!!fallbackTrimmed && fallbackTrimmed !== primaryTrimmed;
|
||
|
||
try {
|
||
return await attemptStream(primaryTrimmed);
|
||
} catch (primaryError) {
|
||
if (!hasFallback) {
|
||
throw primaryError;
|
||
}
|
||
this.log(
|
||
`[playViaWebTorrent] Normalized magnet failed: ${primaryError.message}`
|
||
);
|
||
this.log(
|
||
"[playViaWebTorrent] Primary magnet failed, retrying original string."
|
||
);
|
||
try {
|
||
return await attemptStream(fallbackTrimmed);
|
||
} catch (fallbackError) {
|
||
throw fallbackError;
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Unified playback helper that prefers HTTP URL sources
|
||
* and falls back to WebTorrent when needed.
|
||
*/
|
||
async playVideoWithFallback(options = {}) {
|
||
const { url = "", magnet = "", trigger, forcedSource } = options || {};
|
||
const hasTrigger = Object.prototype.hasOwnProperty.call(
|
||
options || {},
|
||
"trigger"
|
||
);
|
||
if (hasTrigger) {
|
||
this.setLastModalTrigger(trigger);
|
||
}
|
||
const sanitizedUrl = typeof url === "string" ? url.trim() : "";
|
||
const trimmedMagnet = typeof magnet === "string" ? magnet.trim() : "";
|
||
const previousSource = this.playSource || null;
|
||
const requestSignature = JSON.stringify({
|
||
url: sanitizedUrl,
|
||
magnet: trimmedMagnet,
|
||
forcedSource,
|
||
});
|
||
|
||
const modalVideoIsConnected = (() => {
|
||
if (!this.modalVideo) {
|
||
return false;
|
||
}
|
||
if (typeof this.modalVideo.isConnected === "boolean") {
|
||
return this.modalVideo.isConnected;
|
||
}
|
||
const ownerDocument = this.modalVideo.ownerDocument ||
|
||
(typeof document !== "undefined" ? document : null);
|
||
if (ownerDocument?.contains) {
|
||
try {
|
||
return ownerDocument.contains(this.modalVideo);
|
||
} catch (error) {
|
||
devLogger.warn(
|
||
"[playVideoWithFallback] Failed to determine modal video connection state",
|
||
error,
|
||
);
|
||
}
|
||
}
|
||
return true;
|
||
})();
|
||
|
||
const shouldReuseActiveSession =
|
||
modalVideoIsConnected &&
|
||
this.activePlaybackSession &&
|
||
typeof this.activePlaybackSession.matchesRequestSignature === "function" &&
|
||
this.activePlaybackSession.matchesRequestSignature(requestSignature);
|
||
|
||
if (shouldReuseActiveSession) {
|
||
this.log(
|
||
"[playVideoWithFallback] Duplicate playback request detected; reusing active session."
|
||
);
|
||
if (this.activePlaybackResultPromise) {
|
||
return this.activePlaybackResultPromise;
|
||
}
|
||
if (typeof this.activePlaybackSession.getResult === "function") {
|
||
return this.activePlaybackSession.getResult();
|
||
}
|
||
return { source: null };
|
||
}
|
||
|
||
await this.waitForCleanup();
|
||
this.cancelPendingViewLogging();
|
||
|
||
if (
|
||
previousSource === "torrent" &&
|
||
sanitizedUrl &&
|
||
this.playbackService &&
|
||
this.playbackService.torrentClient &&
|
||
typeof this.playbackService.torrentClient.cleanup === "function"
|
||
) {
|
||
try {
|
||
this.log(
|
||
"[playVideoWithFallback] Previous playback used WebTorrent; cleaning up before preparing hosted session.",
|
||
);
|
||
await this.playbackService.torrentClient.cleanup();
|
||
} catch (error) {
|
||
devLogger.warn(
|
||
"[playVideoWithFallback] Pre-playback torrent cleanup threw:",
|
||
error,
|
||
);
|
||
}
|
||
}
|
||
|
||
let modalVideoEl = this.modalVideo;
|
||
const modalVideoFromController =
|
||
this.videoModal && typeof this.videoModal.getVideoElement === "function"
|
||
? this.videoModal.getVideoElement()
|
||
: null;
|
||
if (modalVideoFromController && modalVideoFromController !== modalVideoEl) {
|
||
modalVideoEl = modalVideoFromController;
|
||
this.modalVideo = modalVideoEl;
|
||
}
|
||
const modalVideoConnected = Boolean(
|
||
modalVideoEl && modalVideoEl.isConnected,
|
||
);
|
||
if (!modalVideoEl || !modalVideoConnected) {
|
||
try {
|
||
const { videoElement } = await this.ensureVideoModalReady({
|
||
ensureVideoElement: true,
|
||
});
|
||
modalVideoEl = videoElement;
|
||
this.modalVideo = modalVideoEl;
|
||
} catch (error) {
|
||
this.log(
|
||
"[playVideoWithFallback] Failed to load video modal before playback:",
|
||
error
|
||
);
|
||
this.showError("Could not prepare the video player. Please try again.");
|
||
return { source: null, error };
|
||
}
|
||
}
|
||
|
||
if (!modalVideoEl) {
|
||
const error = new Error("Video element is not ready for playback.");
|
||
this.log(
|
||
"[playVideoWithFallback] Video element missing after modal load attempt."
|
||
);
|
||
this.showError("Video player is not ready yet. Please try again.");
|
||
return { source: null, error };
|
||
}
|
||
|
||
if (
|
||
this.videoModal &&
|
||
typeof this.videoModal.clearPosterCleanup === "function"
|
||
) {
|
||
try {
|
||
this.videoModal.clearPosterCleanup();
|
||
} catch (err) {
|
||
devLogger.warn(
|
||
"[playVideoWithFallback] video modal poster cleanup threw:",
|
||
err
|
||
);
|
||
}
|
||
}
|
||
|
||
const refreshedModal = this.teardownVideoElement(modalVideoEl, {
|
||
replaceNode: true,
|
||
});
|
||
if (refreshedModal) {
|
||
if (
|
||
this.videoModal &&
|
||
typeof this.videoModal.setVideoElement === "function"
|
||
) {
|
||
this.videoModal.setVideoElement(refreshedModal);
|
||
}
|
||
this.modalVideo = refreshedModal;
|
||
modalVideoEl = this.modalVideo;
|
||
this.applyModalLoadingPoster();
|
||
} else {
|
||
this.applyModalLoadingPoster();
|
||
}
|
||
|
||
const session = this.playbackService.createSession({
|
||
url: sanitizedUrl,
|
||
magnet: trimmedMagnet,
|
||
requestSignature,
|
||
videoElement: this.modalVideo,
|
||
waitForCleanup: () => this.waitForCleanup(),
|
||
cancelPendingViewLogging: () => this.cancelPendingViewLogging(),
|
||
clearActiveIntervals: () => this.clearActiveIntervals(),
|
||
showModalWithPoster: () => this.showModalWithPoster(),
|
||
teardownVideoElement: (videoEl, options) =>
|
||
this.teardownVideoElement(videoEl, options),
|
||
probeUrl: (candidateUrl) => this.probeUrl(candidateUrl),
|
||
playViaWebTorrent: (magnetUri, options) =>
|
||
this.playViaWebTorrent(magnetUri, options),
|
||
autoplay: () => this.autoplayModalVideo(),
|
||
unsupportedBtihMessage: UNSUPPORTED_BTITH_MESSAGE,
|
||
forcedSource,
|
||
});
|
||
|
||
this.activePlaybackSession = session;
|
||
this.activePlaybackResultPromise = null;
|
||
|
||
this.resetTorrentStats();
|
||
this.playSource = null;
|
||
|
||
const playbackConfig = session.getPlaybackConfig();
|
||
const magnetForPlayback = session.getMagnetForPlayback();
|
||
const fallbackMagnet = session.getFallbackMagnet();
|
||
const magnetProvided = session.getMagnetProvided();
|
||
|
||
if (this.currentVideo) {
|
||
this.currentVideo.magnet = magnetForPlayback;
|
||
this.currentVideo.normalizedMagnet = magnetForPlayback;
|
||
this.currentVideo.normalizedMagnetFallback = fallbackMagnet;
|
||
if (playbackConfig?.infoHash && !this.currentVideo.legacyInfoHash) {
|
||
this.currentVideo.legacyInfoHash = playbackConfig.infoHash;
|
||
}
|
||
this.currentVideo.torrentSupported = !!magnetForPlayback;
|
||
}
|
||
this.currentMagnetUri = magnetForPlayback || null;
|
||
// this.setCopyMagnetState(!!magnetForPlayback); // Removed
|
||
|
||
const unsubscribers = [];
|
||
const subscribe = (eventName, handler) => {
|
||
const off = session.on(eventName, handler);
|
||
unsubscribers.push(off);
|
||
};
|
||
|
||
subscribe("status", ({ message } = {}) => {
|
||
if (this.videoModal) {
|
||
this.videoModal.updateStatus(
|
||
typeof message === "string" ? message : ""
|
||
);
|
||
}
|
||
});
|
||
|
||
subscribe("video-prepared", ({ videoElement } = {}) => {
|
||
if (videoElement && videoElement !== this.modalVideo) {
|
||
this.modalVideo = videoElement;
|
||
}
|
||
});
|
||
|
||
subscribe("view-logging-request", ({ videoElement } = {}) => {
|
||
if (videoElement) {
|
||
this.preparePlaybackLogging(videoElement);
|
||
}
|
||
});
|
||
|
||
subscribe("poster-remove", ({ reason } = {}) => {
|
||
this.forceRemoveModalPoster(reason || "playback");
|
||
});
|
||
|
||
subscribe("sourcechange", ({ source } = {}) => {
|
||
this.playSource = source || null;
|
||
const usingTorrent = source === "torrent";
|
||
if (this.videoModal) {
|
||
this.videoModal.setTorrentStatsVisibility?.(usingTorrent);
|
||
}
|
||
});
|
||
|
||
subscribe("error", ({ error, message } = {}) => {
|
||
const displayMessage =
|
||
typeof message === "string"
|
||
? message
|
||
: error && error.message
|
||
? `Playback error: ${error.message}`
|
||
: "Playback error";
|
||
this.showError(displayMessage);
|
||
});
|
||
|
||
subscribe("finished", () => {
|
||
if (this.activePlaybackSession === session) {
|
||
this.activePlaybackSession = null;
|
||
this.activePlaybackResultPromise = null;
|
||
}
|
||
while (unsubscribers.length) {
|
||
const off = unsubscribers.pop();
|
||
if (typeof off === "function") {
|
||
try {
|
||
off();
|
||
} catch (err) {
|
||
devLogger.warn(
|
||
"[playVideoWithFallback] Listener cleanup error:",
|
||
err
|
||
);
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
const startPromise = session.start();
|
||
this.activePlaybackResultPromise = startPromise;
|
||
const result = await startPromise;
|
||
|
||
if (!result || result.error) {
|
||
return result;
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
|
||
async playVideoByEventId(eventId, playbackHint = {}) {
|
||
if (!eventId) {
|
||
this.showError("No video identifier provided.");
|
||
return;
|
||
}
|
||
|
||
const hint = playbackHint && typeof playbackHint === "object"
|
||
? playbackHint
|
||
: {};
|
||
const fallbackUrl =
|
||
typeof hint.url === "string" ? hint.url.trim() : "";
|
||
const fallbackTitle =
|
||
typeof hint.title === "string" ? hint.title : "";
|
||
const fallbackDescription =
|
||
typeof hint.description === "string" ? hint.description : "";
|
||
const fallbackMagnetRaw =
|
||
typeof hint.magnet === "string" ? hint.magnet.trim() : "";
|
||
let fallbackMagnetCandidate = "";
|
||
if (fallbackMagnetRaw) {
|
||
const decoded = safeDecodeMagnet(fallbackMagnetRaw);
|
||
fallbackMagnetCandidate = decoded || fallbackMagnetRaw;
|
||
}
|
||
|
||
const hasTrigger = Object.prototype.hasOwnProperty.call(hint, "trigger");
|
||
if (hasTrigger) {
|
||
this.setLastModalTrigger(hint.trigger);
|
||
} else {
|
||
this.setLastModalTrigger(null);
|
||
}
|
||
|
||
this.currentVideoPointer = null;
|
||
this.currentVideoPointerKey = null;
|
||
this.pendingModeratedPlayback = null;
|
||
|
||
if (this.blacklistedEventIds.has(eventId)) {
|
||
this.showError("This content has been removed or is not allowed.");
|
||
return;
|
||
}
|
||
|
||
let video = this.videosMap.get(eventId);
|
||
if (!video) {
|
||
video = await this.getOldEventById(eventId);
|
||
}
|
||
if (!video) {
|
||
if (fallbackUrl || fallbackMagnetCandidate) {
|
||
return this.playVideoWithoutEvent({
|
||
url: fallbackUrl,
|
||
magnet: fallbackMagnetCandidate,
|
||
title: fallbackTitle || "Untitled",
|
||
description: fallbackDescription || "",
|
||
trigger: hasTrigger ? hint.trigger : null,
|
||
});
|
||
}
|
||
this.showError("Video not found or has been removed.");
|
||
return;
|
||
}
|
||
|
||
try {
|
||
await accessControl.waitForReady();
|
||
} catch (error) {
|
||
devLogger.warn(
|
||
"Failed to ensure admin lists were loaded before playback:",
|
||
error
|
||
);
|
||
}
|
||
const authorNpub = this.safeEncodeNpub(video.pubkey) || video.pubkey;
|
||
if (!accessControl.canAccess(authorNpub)) {
|
||
if (accessControl.isBlacklisted(authorNpub)) {
|
||
this.showError("This content has been removed or is not allowed.");
|
||
} else if (accessControl.whitelistMode()) {
|
||
this.showError("This content is not from a whitelisted author.");
|
||
} else {
|
||
this.showError("This content has been removed or is not allowed.");
|
||
}
|
||
return;
|
||
}
|
||
|
||
this.decorateVideoModeration(video);
|
||
|
||
let trimmedUrl = typeof video.url === "string" ? video.url.trim() : "";
|
||
if (!trimmedUrl && fallbackUrl) {
|
||
trimmedUrl = fallbackUrl;
|
||
}
|
||
const rawMagnet =
|
||
typeof video.magnet === "string" ? video.magnet.trim() : "";
|
||
let legacyInfoHash =
|
||
typeof video.infoHash === "string" ? video.infoHash.trim().toLowerCase() : "";
|
||
const fallbackMagnetForCandidate = fallbackMagnetCandidate || "";
|
||
if (!legacyInfoHash && fallbackMagnetForCandidate) {
|
||
const match = fallbackMagnetForCandidate.match(/xt=urn:btih:([0-9a-z]+)/i);
|
||
if (match && match[1]) {
|
||
legacyInfoHash = match[1].toLowerCase();
|
||
}
|
||
}
|
||
|
||
let magnetCandidate = rawMagnet || legacyInfoHash || "";
|
||
let decodedMagnetCandidate = safeDecodeMagnet(magnetCandidate);
|
||
let usableMagnetCandidate = decodedMagnetCandidate || magnetCandidate;
|
||
let magnetSupported = isValidMagnetUri(usableMagnetCandidate);
|
||
|
||
if (!magnetSupported && fallbackMagnetForCandidate) {
|
||
magnetCandidate = fallbackMagnetForCandidate;
|
||
decodedMagnetCandidate = safeDecodeMagnet(magnetCandidate);
|
||
usableMagnetCandidate = decodedMagnetCandidate || magnetCandidate;
|
||
magnetSupported = isValidMagnetUri(usableMagnetCandidate);
|
||
}
|
||
|
||
const sanitizedMagnet = magnetSupported ? usableMagnetCandidate : "";
|
||
|
||
const knownPostedAt = this.getKnownVideoPostedAt(video);
|
||
const normalizedEditedAt = Number.isFinite(video.created_at)
|
||
? Math.floor(video.created_at)
|
||
: null;
|
||
|
||
const normalizedCreatorPubkey =
|
||
this.normalizeHexPubkey(video.pubkey) || video.pubkey;
|
||
const cachedCreatorProfileEntry =
|
||
normalizedCreatorPubkey && typeof this.getProfileCacheEntry === "function"
|
||
? this.getProfileCacheEntry(normalizedCreatorPubkey)
|
||
: null;
|
||
const cachedCreatorProfile =
|
||
cachedCreatorProfileEntry &&
|
||
typeof cachedCreatorProfileEntry === "object"
|
||
? cachedCreatorProfileEntry.profile || null
|
||
: null;
|
||
const initialLightningAddress =
|
||
typeof video.lightningAddress === "string"
|
||
? video.lightningAddress.trim()
|
||
: "";
|
||
|
||
this.currentVideo = {
|
||
...video,
|
||
url: trimmedUrl,
|
||
magnet: sanitizedMagnet,
|
||
originalMagnet:
|
||
magnetCandidate || fallbackMagnetForCandidate || legacyInfoHash || "",
|
||
torrentSupported: magnetSupported,
|
||
legacyInfoHash: video.legacyInfoHash || legacyInfoHash,
|
||
lightningAddress: initialLightningAddress || null,
|
||
lastEditedAt: normalizedEditedAt,
|
||
};
|
||
|
||
this.decorateVideoModeration(this.currentVideo);
|
||
|
||
const modalTags = collectVideoTags(this.currentVideo);
|
||
this.currentVideo.displayTags = modalTags;
|
||
const creatorNpub = this.safeEncodeNpub(video.pubkey) || video.pubkey;
|
||
const displayNpub = formatShortNpub(creatorNpub) || creatorNpub;
|
||
const initialCreatorProfile = this.resolveModalCreatorProfile({
|
||
video: this.currentVideo,
|
||
pubkey: normalizedCreatorPubkey,
|
||
cachedProfile: cachedCreatorProfile,
|
||
});
|
||
this.currentVideo.creatorName = initialCreatorProfile.name;
|
||
this.currentVideo.creatorPicture = initialCreatorProfile.picture;
|
||
this.currentVideo.creatorNpub = displayNpub;
|
||
if (this.currentVideo.creator && typeof this.currentVideo.creator === "object") {
|
||
this.currentVideo.creator = {
|
||
...this.currentVideo.creator,
|
||
name: initialCreatorProfile.name,
|
||
picture: initialCreatorProfile.picture,
|
||
pubkey: normalizedCreatorPubkey,
|
||
};
|
||
} else {
|
||
this.currentVideo.creator = {
|
||
name: initialCreatorProfile.name,
|
||
picture: initialCreatorProfile.picture,
|
||
pubkey: normalizedCreatorPubkey,
|
||
};
|
||
}
|
||
this.updateModalSimilarContent({ activeVideo: this.currentVideo });
|
||
|
||
if (Number.isFinite(knownPostedAt)) {
|
||
this.cacheVideoRootCreatedAt(this.currentVideo, knownPostedAt);
|
||
} else if (this.currentVideo.rootCreatedAt) {
|
||
delete this.currentVideo.rootCreatedAt;
|
||
}
|
||
|
||
const dTagValue = (this.extractDTagValue(video.tags) || "").trim();
|
||
const pointerInfo = resolveVideoPointer({
|
||
kind: video.kind,
|
||
pubkey: video.pubkey,
|
||
videoRootId: video.videoRootId,
|
||
dTag: dTagValue,
|
||
fallbackEventId: video.id || eventId,
|
||
relay: hint.relay || video.relay,
|
||
});
|
||
|
||
this.currentVideoPointer = pointerInfo?.pointer || null;
|
||
this.currentVideoPointerKey = pointerInfo?.key || null;
|
||
|
||
if (this.currentVideo) {
|
||
this.currentVideo.pointer = this.currentVideoPointer;
|
||
this.currentVideo.pointerKey = this.currentVideoPointerKey;
|
||
}
|
||
|
||
const forYouState = this.getFeedTelemetryState("for-you");
|
||
if (
|
||
forYouState?.activePlayback?.feed === "for-you" &&
|
||
forYouState.activePlayback.videoId === eventId
|
||
) {
|
||
forYouState.activePlayback.pointerKey = this.currentVideoPointerKey || null;
|
||
}
|
||
|
||
this.subscribeModalViewCount(
|
||
this.currentVideoPointer,
|
||
this.currentVideoPointerKey
|
||
);
|
||
this.subscribeModalReactions(
|
||
this.currentVideoPointer,
|
||
this.currentVideoPointerKey
|
||
);
|
||
this.syncModalMoreMenuData();
|
||
|
||
this.currentMagnetUri = sanitizedMagnet || null;
|
||
|
||
// this.setCopyMagnetState(!!sanitizedMagnet); // Removed
|
||
// this.setShareButtonState(true); // Moved to after showModalWithPoster
|
||
|
||
const nevent = window.NostrTools.nip19.neventEncode({ id: eventId });
|
||
const pushUrl =
|
||
this.buildShareUrlFromNevent(nevent) ||
|
||
`${this.getShareUrlBase() || window.location.pathname}?v=${encodeURIComponent(
|
||
nevent
|
||
)}`;
|
||
window.history.pushState({}, "", pushUrl);
|
||
|
||
this.zapController?.resetState();
|
||
this.zapController?.setVisibility(Boolean(this.currentVideo.lightningAddress));
|
||
|
||
const magnetInput =
|
||
sanitizedMagnet ||
|
||
decodedMagnetCandidate ||
|
||
magnetCandidate ||
|
||
fallbackMagnetForCandidate ||
|
||
legacyInfoHash ||
|
||
"";
|
||
|
||
await this.showModalWithPoster(this.currentVideo);
|
||
|
||
this.setShareButtonState(true);
|
||
|
||
this.commentController?.load(this.currentVideo);
|
||
|
||
const playbackOptions = {
|
||
url: trimmedUrl,
|
||
magnet: magnetInput,
|
||
};
|
||
if (hasTrigger) {
|
||
playbackOptions.trigger = this.lastModalTrigger;
|
||
}
|
||
|
||
let playbackPromise = null;
|
||
if (this.shouldDeferModeratedPlayback(this.currentVideo)) {
|
||
const pendingVideoId =
|
||
(this.currentVideo && typeof this.currentVideo.id === "string" && this.currentVideo.id)
|
||
? this.currentVideo.id
|
||
: eventId || null;
|
||
this.pendingModeratedPlayback = {
|
||
...playbackOptions,
|
||
triggerProvided: hasTrigger,
|
||
videoId: pendingVideoId,
|
||
};
|
||
} else {
|
||
playbackPromise = this.playVideoWithFallback(playbackOptions);
|
||
}
|
||
|
||
if (this.videoModal) {
|
||
const timestampPayload = this.buildModalTimestampPayload({
|
||
postedAt: this.currentVideo?.rootCreatedAt ?? null,
|
||
editedAt: normalizedEditedAt,
|
||
});
|
||
this.videoModal.updateMetadata({
|
||
title: video.title || "Untitled",
|
||
description: video.description || "No description available.",
|
||
timestamps: timestampPayload,
|
||
tags: modalTags,
|
||
creator: {
|
||
name: initialCreatorProfile.name,
|
||
avatarUrl: initialCreatorProfile.picture,
|
||
npub: displayNpub,
|
||
},
|
||
});
|
||
}
|
||
|
||
const profileRequestToken = Symbol("modal-profile-request");
|
||
this.modalCreatorProfileRequestToken = profileRequestToken;
|
||
this.fetchModalCreatorProfile({
|
||
pubkey: normalizedCreatorPubkey,
|
||
displayNpub,
|
||
cachedProfile: cachedCreatorProfile,
|
||
requestToken: profileRequestToken,
|
||
}).catch((error) => {
|
||
devLogger.error(
|
||
"[Application] Failed to fetch creator profile for modal:",
|
||
error,
|
||
);
|
||
});
|
||
|
||
this.ensureModalPostedTimestamp(this.currentVideo);
|
||
|
||
const playbackResult =
|
||
playbackPromise && typeof playbackPromise.then === "function"
|
||
? await playbackPromise
|
||
: playbackPromise;
|
||
|
||
return playbackResult;
|
||
}
|
||
|
||
buildModalTimestampPayload({ postedAt = null, editedAt = null } = {}) {
|
||
const normalizedPostedAt = Number.isFinite(postedAt)
|
||
? Math.floor(postedAt)
|
||
: null;
|
||
const normalizedEditedAt = Number.isFinite(editedAt)
|
||
? Math.floor(editedAt)
|
||
: null;
|
||
|
||
const payload = {
|
||
posted: "",
|
||
edited: "",
|
||
};
|
||
|
||
const effectivePostedAt =
|
||
normalizedPostedAt !== null ? normalizedPostedAt : normalizedEditedAt;
|
||
|
||
if (effectivePostedAt !== null) {
|
||
payload.posted = `Posted ${this.formatTimeAgo(effectivePostedAt)}`;
|
||
}
|
||
|
||
const shouldShowEdited =
|
||
normalizedEditedAt !== null &&
|
||
(normalizedPostedAt === null || normalizedEditedAt - normalizedPostedAt >= 60);
|
||
|
||
if (shouldShowEdited) {
|
||
const abs = formatAbsoluteDateWithOrdinalUtil(normalizedEditedAt);
|
||
const rel = this.formatTimeAgo(normalizedEditedAt);
|
||
payload.edited = `Last edited: ${abs} (${rel})`;
|
||
}
|
||
|
||
return payload;
|
||
}
|
||
|
||
getKnownVideoPostedAt(video) {
|
||
if (!video || typeof video !== "object") {
|
||
return null;
|
||
}
|
||
|
||
const directValue = Number.isFinite(video.rootCreatedAt)
|
||
? Math.floor(video.rootCreatedAt)
|
||
: null;
|
||
if (directValue !== null) {
|
||
return directValue;
|
||
}
|
||
|
||
if (video.id && this.videosMap instanceof Map) {
|
||
const stored = this.videosMap.get(video.id);
|
||
const storedValue = Number.isFinite(stored?.rootCreatedAt)
|
||
? Math.floor(stored.rootCreatedAt)
|
||
: null;
|
||
if (storedValue !== null) {
|
||
video.rootCreatedAt = storedValue;
|
||
return storedValue;
|
||
}
|
||
}
|
||
|
||
const nip71Created = Number.isFinite(video?.nip71Source?.created_at)
|
||
? Math.floor(video.nip71Source.created_at)
|
||
: null;
|
||
|
||
if (nip71Created !== null) {
|
||
return nip71Created;
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
cacheVideoRootCreatedAt(video, timestamp) {
|
||
if (!Number.isFinite(timestamp)) {
|
||
return;
|
||
}
|
||
|
||
const normalized = Math.floor(timestamp);
|
||
const rootId = getVideoRootIdentifier(video);
|
||
|
||
if (video && typeof video === "object") {
|
||
video.rootCreatedAt = normalized;
|
||
}
|
||
|
||
applyRootTimestampToVideosMap({
|
||
videosMap: this.videosMap,
|
||
video,
|
||
rootId,
|
||
timestamp: normalized,
|
||
});
|
||
|
||
syncActiveVideoRootTimestamp({
|
||
activeVideo: this.currentVideo,
|
||
rootId,
|
||
timestamp: normalized,
|
||
buildModalTimestampPayload: (payload) =>
|
||
this.buildModalTimestampPayload(payload),
|
||
videoModal: this.videoModal,
|
||
});
|
||
|
||
if (nostrClient && typeof nostrClient.applyRootCreatedAt === "function") {
|
||
try {
|
||
nostrClient.applyRootCreatedAt(video);
|
||
} catch (error) {
|
||
devLogger.warn(
|
||
"[Application] Failed to sync cached root timestamp with nostrClient:",
|
||
error
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
async resolveVideoPostedAt(video) {
|
||
if (!video || typeof video !== "object") {
|
||
return null;
|
||
}
|
||
|
||
// Prioritize NIP-71 published_at metadata if available
|
||
const rawNip71PublishedAt =
|
||
video?.nip71?.publishedAt ||
|
||
video?.nip71?.published_at ||
|
||
video?.nip71?.["published-at"];
|
||
const parsedNip71PublishedAt = Number(rawNip71PublishedAt);
|
||
const nip71PublishedAt = Number.isFinite(parsedNip71PublishedAt)
|
||
? Math.floor(parsedNip71PublishedAt)
|
||
: null;
|
||
|
||
if (nip71PublishedAt !== null) {
|
||
this.cacheVideoRootCreatedAt(video, nip71PublishedAt);
|
||
return nip71PublishedAt;
|
||
}
|
||
|
||
const cached = this.getKnownVideoPostedAt(video);
|
||
if (cached !== null) {
|
||
return cached;
|
||
}
|
||
|
||
if (!nostrClient || typeof nostrClient.hydrateVideoHistory !== "function") {
|
||
const fallback = Number.isFinite(video.created_at)
|
||
? Math.floor(video.created_at)
|
||
: null;
|
||
if (fallback !== null) {
|
||
this.cacheVideoRootCreatedAt(video, fallback);
|
||
}
|
||
return fallback;
|
||
}
|
||
|
||
try {
|
||
const history = await nostrClient.hydrateVideoHistory(video);
|
||
if (Array.isArray(history) && history.length) {
|
||
let earliest = null;
|
||
for (const entry of history) {
|
||
if (!entry || entry.deleted) {
|
||
continue;
|
||
}
|
||
const created = Number.isFinite(entry.created_at)
|
||
? Math.floor(entry.created_at)
|
||
: null;
|
||
if (created === null) {
|
||
continue;
|
||
}
|
||
if (earliest === null || created < earliest) {
|
||
earliest = created;
|
||
}
|
||
}
|
||
|
||
if (earliest === null) {
|
||
const lastEntry = history[history.length - 1];
|
||
if (Number.isFinite(lastEntry?.created_at)) {
|
||
earliest = Math.floor(lastEntry.created_at);
|
||
}
|
||
}
|
||
|
||
if (earliest !== null) {
|
||
this.cacheVideoRootCreatedAt(video, earliest);
|
||
return earliest;
|
||
}
|
||
}
|
||
} catch (error) {
|
||
devLogger.warn(
|
||
"[Application] Failed to hydrate video history for timestamps:",
|
||
error
|
||
);
|
||
}
|
||
|
||
const fallback = Number.isFinite(video.created_at)
|
||
? Math.floor(video.created_at)
|
||
: null;
|
||
if (fallback !== null) {
|
||
this.cacheVideoRootCreatedAt(video, fallback);
|
||
}
|
||
return fallback;
|
||
}
|
||
|
||
async ensureModalPostedTimestamp(video) {
|
||
if (!video || !this.videoModal) {
|
||
return;
|
||
}
|
||
|
||
const postedAt = await this.resolveVideoPostedAt(video);
|
||
if (!this.videoModal || this.currentVideo !== video) {
|
||
return;
|
||
}
|
||
|
||
const editedAt = Number.isFinite(video.lastEditedAt)
|
||
? Math.floor(video.lastEditedAt)
|
||
: Number.isFinite(video.created_at)
|
||
? Math.floor(video.created_at)
|
||
: null;
|
||
|
||
const payload = this.buildModalTimestampPayload({
|
||
postedAt,
|
||
editedAt,
|
||
});
|
||
|
||
const modalTags = collectVideoTags(video);
|
||
video.displayTags = modalTags;
|
||
this.updateModalSimilarContent({ activeVideo: video });
|
||
|
||
this.videoModal.updateMetadata({ timestamps: payload, tags: modalTags });
|
||
}
|
||
|
||
async playVideoWithoutEvent(options = {}) {
|
||
const {
|
||
url = "",
|
||
magnet = "",
|
||
title = "Untitled",
|
||
description = "",
|
||
trigger,
|
||
tags: rawTags,
|
||
} = options || {};
|
||
const hasTrigger = Object.prototype.hasOwnProperty.call(
|
||
options || {},
|
||
"trigger"
|
||
);
|
||
if (hasTrigger) {
|
||
this.setLastModalTrigger(trigger);
|
||
} else {
|
||
this.setLastModalTrigger(null);
|
||
}
|
||
this.currentVideoPointer = null;
|
||
this.currentVideoPointerKey = null;
|
||
this.subscribeModalViewCount(null, null);
|
||
this.subscribeModalReactions(null, null);
|
||
this.pendingModeratedPlayback = null;
|
||
const sanitizedUrl = typeof url === "string" ? url.trim() : "";
|
||
const trimmedMagnet = typeof magnet === "string" ? magnet.trim() : "";
|
||
const decodedMagnet = safeDecodeMagnet(trimmedMagnet);
|
||
const usableMagnet = decodedMagnet || trimmedMagnet;
|
||
const magnetSupported = isValidMagnetUri(usableMagnet);
|
||
const sanitizedMagnet = magnetSupported ? usableMagnet : "";
|
||
|
||
const modalTags = collectVideoTags({
|
||
nip71: { hashtags: rawTags },
|
||
});
|
||
|
||
this.zapController?.setVisibility(false);
|
||
this.zapController?.resetState();
|
||
|
||
if (!sanitizedUrl && !sanitizedMagnet) {
|
||
const message = trimmedMagnet && !magnetSupported
|
||
? UNSUPPORTED_BTITH_MESSAGE
|
||
: "This video has no playable source.";
|
||
this.showError(message);
|
||
return;
|
||
}
|
||
|
||
this.currentVideo = {
|
||
id: null,
|
||
title,
|
||
description,
|
||
url: sanitizedUrl,
|
||
magnet: sanitizedMagnet,
|
||
originalMagnet: trimmedMagnet,
|
||
torrentSupported: magnetSupported,
|
||
lightningAddress: null,
|
||
pointer: null,
|
||
pointerKey: null,
|
||
displayTags: modalTags,
|
||
};
|
||
|
||
this.decorateVideoModeration(this.currentVideo);
|
||
this.updateModalSimilarContent({ activeVideo: this.currentVideo });
|
||
|
||
this.syncModalMoreMenuData();
|
||
|
||
this.currentMagnetUri = sanitizedMagnet || null;
|
||
|
||
// this.setCopyMagnetState(!!sanitizedMagnet);
|
||
// this.setShareButtonState(false);
|
||
|
||
if (this.videoModal) {
|
||
this.videoModal.updateMetadata({
|
||
title: title || "Untitled",
|
||
description: description || "No description available.",
|
||
timestamp: "",
|
||
tags: modalTags,
|
||
creator: {
|
||
name: "Unknown",
|
||
avatarUrl: "assets/svg/default-profile.svg",
|
||
npub: "",
|
||
},
|
||
});
|
||
}
|
||
|
||
await this.showModalWithPoster(this.currentVideo, hasTrigger ? { trigger } : {});
|
||
|
||
this.setShareButtonState(false);
|
||
|
||
this.commentController?.load(null);
|
||
|
||
const shareUrl = this.buildShareUrlFromEventId(this.currentVideo.id);
|
||
urlObj.searchParams.delete("v");
|
||
const cleaned = `${urlObj.pathname}${urlObj.search}${urlObj.hash}`;
|
||
window.history.replaceState({}, "", cleaned);
|
||
|
||
return this.playVideoWithFallback({
|
||
url: sanitizedUrl,
|
||
magnet: usableMagnet,
|
||
trigger: hasTrigger ? this.lastModalTrigger : null,
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Simple helper to safely encode an npub.
|
||
*/
|
||
safeEncodeNpub(pubkey) {
|
||
if (typeof pubkey !== "string") {
|
||
return null;
|
||
}
|
||
|
||
const trimmed = pubkey.trim();
|
||
if (!trimmed) {
|
||
return null;
|
||
}
|
||
|
||
if (trimmed.startsWith("npub1")) {
|
||
return trimmed;
|
||
}
|
||
|
||
try {
|
||
return window.NostrTools.nip19.npubEncode(trimmed);
|
||
} catch (err) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
safeDecodeNpub(npub) {
|
||
if (typeof npub !== "string") {
|
||
return null;
|
||
}
|
||
|
||
const trimmed = npub.trim();
|
||
if (!trimmed) {
|
||
return null;
|
||
}
|
||
|
||
try {
|
||
const decoded = window.NostrTools.nip19.decode(trimmed);
|
||
if (decoded.type === "npub" && typeof decoded.data === "string") {
|
||
return decoded.data;
|
||
}
|
||
} catch (err) {
|
||
return null;
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
selectPreferredCreatorName(candidates = []) {
|
||
for (const candidate of candidates) {
|
||
if (typeof candidate !== "string") {
|
||
continue;
|
||
}
|
||
const trimmed = candidate.trim();
|
||
if (!trimmed) {
|
||
continue;
|
||
}
|
||
if (HEX64_REGEX.test(trimmed)) {
|
||
continue;
|
||
}
|
||
return trimmed;
|
||
}
|
||
return "";
|
||
}
|
||
|
||
selectPreferredCreatorPicture(candidates = []) {
|
||
for (const candidate of candidates) {
|
||
if (typeof candidate !== "string") {
|
||
continue;
|
||
}
|
||
const sanitized = sanitizeProfileMediaUrl(candidate);
|
||
if (sanitized) {
|
||
return sanitized;
|
||
}
|
||
}
|
||
return "";
|
||
}
|
||
|
||
resolveCreatorProfileFromSources({
|
||
video,
|
||
pubkey,
|
||
cachedProfile = null,
|
||
fetchedProfile = null,
|
||
fallbackAvatar,
|
||
} = {}) {
|
||
const normalizedPubkey =
|
||
typeof pubkey === "string" && pubkey.trim() ? pubkey.trim() : "";
|
||
const fallbackAvatarCandidate =
|
||
typeof fallbackAvatar === "string" && fallbackAvatar.trim()
|
||
? fallbackAvatar.trim()
|
||
: normalizedPubkey
|
||
? `https://robohash.org/${normalizedPubkey}`
|
||
: "assets/svg/default-profile.svg";
|
||
const defaultAvatar =
|
||
sanitizeProfileMediaUrl(fallbackAvatarCandidate) ||
|
||
fallbackAvatarCandidate ||
|
||
"assets/svg/default-profile.svg";
|
||
|
||
const nameCandidates = [];
|
||
const pictureCandidates = [];
|
||
|
||
const collectFromSource = (source) => {
|
||
if (!source || typeof source !== "object") {
|
||
return;
|
||
}
|
||
const names = [
|
||
source.display_name,
|
||
source.displayName,
|
||
source.name,
|
||
source.username,
|
||
];
|
||
names.forEach((value) => {
|
||
if (typeof value === "string") {
|
||
nameCandidates.push(value);
|
||
}
|
||
});
|
||
const pictures = [source.picture, source.image, source.photo];
|
||
pictures.forEach((value) => {
|
||
if (typeof value === "string") {
|
||
pictureCandidates.push(value);
|
||
}
|
||
});
|
||
};
|
||
|
||
collectFromSource(fetchedProfile);
|
||
collectFromSource(cachedProfile);
|
||
|
||
if (video && typeof video === "object") {
|
||
collectFromSource(video.creator);
|
||
if (typeof video.creatorName === "string") {
|
||
nameCandidates.push(video.creatorName);
|
||
}
|
||
if (typeof video.creatorPicture === "string") {
|
||
pictureCandidates.push(video.creatorPicture);
|
||
}
|
||
collectFromSource(video.author);
|
||
if (typeof video.authorName === "string") {
|
||
nameCandidates.push(video.authorName);
|
||
}
|
||
if (typeof video.authorPicture === "string") {
|
||
pictureCandidates.push(video.authorPicture);
|
||
}
|
||
collectFromSource(video.profile);
|
||
const extraNames = [
|
||
video.shortNpub,
|
||
video.creatorNpub,
|
||
video.npub,
|
||
video.authorNpub,
|
||
];
|
||
extraNames.forEach((value) => {
|
||
if (typeof value === "string") {
|
||
nameCandidates.push(value);
|
||
}
|
||
});
|
||
}
|
||
|
||
const resolvedName =
|
||
this.selectPreferredCreatorName(nameCandidates) || "Unknown";
|
||
const resolvedPicture =
|
||
this.selectPreferredCreatorPicture(pictureCandidates) || defaultAvatar;
|
||
|
||
return { name: resolvedName, picture: resolvedPicture };
|
||
}
|
||
|
||
resolveModalCreatorProfile({
|
||
video,
|
||
pubkey,
|
||
cachedProfile = null,
|
||
fetchedProfile = null,
|
||
} = {}) {
|
||
return this.resolveCreatorProfileFromSources({
|
||
video,
|
||
pubkey,
|
||
cachedProfile,
|
||
fetchedProfile,
|
||
});
|
||
}
|
||
|
||
decorateVideoCreatorIdentity(video) {
|
||
if (!video || typeof video !== "object") {
|
||
return video;
|
||
}
|
||
|
||
const normalizedPubkey =
|
||
this.normalizeHexPubkey(video.pubkey) ||
|
||
(typeof video.pubkey === "string" ? video.pubkey.trim() : "");
|
||
if (!normalizedPubkey) {
|
||
return video;
|
||
}
|
||
|
||
let cachedProfile = null;
|
||
if (typeof this.getProfileCacheEntry === "function") {
|
||
const cacheEntry = this.getProfileCacheEntry(normalizedPubkey);
|
||
if (cacheEntry && typeof cacheEntry === "object") {
|
||
cachedProfile = cacheEntry.profile || null;
|
||
}
|
||
}
|
||
|
||
const resolvedProfile = this.resolveCreatorProfileFromSources({
|
||
video,
|
||
pubkey: normalizedPubkey,
|
||
cachedProfile,
|
||
});
|
||
|
||
if (!video.creator || typeof video.creator !== "object") {
|
||
video.creator = {};
|
||
}
|
||
|
||
if (!video.creator.pubkey) {
|
||
video.creator.pubkey = normalizedPubkey;
|
||
}
|
||
|
||
if (resolvedProfile.name) {
|
||
video.creator.name = resolvedProfile.name;
|
||
if (
|
||
typeof video.creatorName !== "string" ||
|
||
!video.creatorName.trim() ||
|
||
video.creatorName === "Unknown"
|
||
) {
|
||
video.creatorName = resolvedProfile.name;
|
||
}
|
||
if (
|
||
typeof video.authorName !== "string" ||
|
||
!video.authorName.trim() ||
|
||
video.authorName === "Unknown"
|
||
) {
|
||
video.authorName = resolvedProfile.name;
|
||
}
|
||
}
|
||
|
||
if (resolvedProfile.picture) {
|
||
video.creator.picture = resolvedProfile.picture;
|
||
video.creatorPicture = resolvedProfile.picture;
|
||
if (
|
||
typeof video.authorPicture !== "string" ||
|
||
!video.authorPicture.trim()
|
||
) {
|
||
video.authorPicture = resolvedProfile.picture;
|
||
}
|
||
}
|
||
|
||
const encodedNpub = this.safeEncodeNpub(normalizedPubkey);
|
||
if (encodedNpub) {
|
||
const shortNpub = formatShortNpub(encodedNpub) || encodedNpub;
|
||
if (typeof video.npub !== "string" || !video.npub.trim()) {
|
||
video.npub = encodedNpub;
|
||
}
|
||
if (typeof video.shortNpub !== "string" || !video.shortNpub.trim()) {
|
||
video.shortNpub = shortNpub;
|
||
}
|
||
if (
|
||
typeof video.creatorNpub !== "string" ||
|
||
!video.creatorNpub.trim()
|
||
) {
|
||
video.creatorNpub = shortNpub;
|
||
}
|
||
}
|
||
|
||
return video;
|
||
}
|
||
|
||
async fetchModalCreatorProfile({
|
||
pubkey,
|
||
displayNpub = "",
|
||
cachedProfile = null,
|
||
requestToken = null,
|
||
} = {}) {
|
||
const normalized = this.normalizeHexPubkey(pubkey);
|
||
if (!normalized) {
|
||
return;
|
||
}
|
||
|
||
const relayList =
|
||
Array.isArray(nostrClient?.relays) && nostrClient.relays.length
|
||
? nostrClient.relays
|
||
: null;
|
||
if (!relayList || !nostrClient?.pool || typeof nostrClient.pool.list !== "function") {
|
||
return;
|
||
}
|
||
|
||
const events = await nostrClient.pool.list(relayList, [
|
||
{ kinds: [0], authors: [normalized], limit: 1 },
|
||
]);
|
||
|
||
if (this.modalCreatorProfileRequestToken !== requestToken) {
|
||
return;
|
||
}
|
||
|
||
const newest = Array.isArray(events)
|
||
? events.reduce((latest, event) => {
|
||
if (!event || typeof event !== "object") {
|
||
return latest;
|
||
}
|
||
const eventPubkey = this.normalizeHexPubkey(event.pubkey);
|
||
if (eventPubkey && eventPubkey !== normalized) {
|
||
return latest;
|
||
}
|
||
const createdAt = Number.isFinite(event.created_at) ? event.created_at : 0;
|
||
if (!latest || createdAt > latest.createdAt) {
|
||
return { createdAt, event };
|
||
}
|
||
return latest;
|
||
}, null)
|
||
: null;
|
||
|
||
if (!newest || !newest.event) {
|
||
if (this.modalCreatorProfileRequestToken === requestToken) {
|
||
this.modalCreatorProfileRequestToken = null;
|
||
}
|
||
return;
|
||
}
|
||
|
||
let parsed = null;
|
||
try {
|
||
parsed = newest.event.content ? JSON.parse(newest.event.content) : null;
|
||
} catch (error) {
|
||
devLogger.warn(
|
||
`[Application] Failed to parse creator profile content for ${normalized}:`,
|
||
error,
|
||
);
|
||
if (this.modalCreatorProfileRequestToken === requestToken) {
|
||
this.modalCreatorProfileRequestToken = null;
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (this.modalCreatorProfileRequestToken !== requestToken) {
|
||
return;
|
||
}
|
||
|
||
const parsedLud16 =
|
||
typeof parsed?.lud16 === "string" ? parsed.lud16.trim() : "";
|
||
const parsedLud06 =
|
||
typeof parsed?.lud06 === "string" ? parsed.lud06.trim() : "";
|
||
const lightningAddressCandidate = (() => {
|
||
const fields = [parsedLud16, parsedLud06];
|
||
for (const field of fields) {
|
||
if (typeof field !== "string") {
|
||
continue;
|
||
}
|
||
const trimmed = field.trim();
|
||
if (trimmed) {
|
||
return trimmed;
|
||
}
|
||
}
|
||
return "";
|
||
})();
|
||
|
||
const fetchedProfile = {
|
||
display_name: parsed?.display_name,
|
||
name: parsed?.name,
|
||
username: parsed?.username,
|
||
picture: parsed?.picture,
|
||
image: parsed?.image,
|
||
photo: parsed?.photo,
|
||
};
|
||
|
||
const resolvedProfile = this.resolveModalCreatorProfile({
|
||
video: this.currentVideo,
|
||
pubkey: normalized,
|
||
cachedProfile,
|
||
fetchedProfile,
|
||
});
|
||
|
||
if (this.modalCreatorProfileRequestToken !== requestToken) {
|
||
return;
|
||
}
|
||
|
||
const activeVideoPubkey = this.normalizeHexPubkey(this.currentVideo?.pubkey);
|
||
if (activeVideoPubkey && activeVideoPubkey !== normalized) {
|
||
return;
|
||
}
|
||
|
||
const nextLightning = lightningAddressCandidate || "";
|
||
const previousLightning =
|
||
typeof this.currentVideo?.lightningAddress === "string"
|
||
? this.currentVideo.lightningAddress
|
||
: "";
|
||
|
||
if (this.currentVideo) {
|
||
this.currentVideo.lightningAddress = nextLightning ? nextLightning : null;
|
||
this.currentVideo.creatorName = resolvedProfile.name;
|
||
this.currentVideo.creatorPicture = resolvedProfile.picture;
|
||
this.currentVideo.creatorNpub = displayNpub;
|
||
if (this.currentVideo.creator && typeof this.currentVideo.creator === "object") {
|
||
this.currentVideo.creator = {
|
||
...this.currentVideo.creator,
|
||
name: resolvedProfile.name,
|
||
picture: resolvedProfile.picture,
|
||
pubkey: normalized,
|
||
lightningAddress: nextLightning ? nextLightning : null,
|
||
};
|
||
} else {
|
||
this.currentVideo.creator = {
|
||
name: resolvedProfile.name,
|
||
picture: resolvedProfile.picture,
|
||
pubkey: normalized,
|
||
lightningAddress: nextLightning ? nextLightning : null,
|
||
};
|
||
}
|
||
}
|
||
|
||
if (this.videoModal) {
|
||
this.videoModal.updateMetadata({
|
||
creator: {
|
||
name: resolvedProfile.name,
|
||
avatarUrl: resolvedProfile.picture,
|
||
npub: displayNpub,
|
||
},
|
||
});
|
||
}
|
||
|
||
this.zapController?.setVisibility(Boolean(this.currentVideo?.lightningAddress));
|
||
|
||
const sanitizedFetchedPicture = sanitizeProfileMediaUrl(
|
||
parsed?.picture || parsed?.image || parsed?.photo || "",
|
||
);
|
||
const fetchedNameCandidate = this.selectPreferredCreatorName([
|
||
parsed?.display_name,
|
||
parsed?.name,
|
||
parsed?.username,
|
||
]);
|
||
|
||
const cachedLightning =
|
||
typeof cachedProfile?.lightningAddress === "string"
|
||
? cachedProfile.lightningAddress.trim()
|
||
: "";
|
||
const shouldUpdateCache =
|
||
Boolean(fetchedNameCandidate) ||
|
||
Boolean(sanitizedFetchedPicture) ||
|
||
cachedLightning !== nextLightning ||
|
||
previousLightning !== nextLightning;
|
||
|
||
if (shouldUpdateCache) {
|
||
try {
|
||
const profileForCache = {
|
||
name: fetchedNameCandidate || resolvedProfile.name,
|
||
picture: sanitizedFetchedPicture || resolvedProfile.picture,
|
||
};
|
||
|
||
if (parsedLud16) {
|
||
profileForCache.lud16 = parsedLud16;
|
||
}
|
||
|
||
if (parsedLud06) {
|
||
profileForCache.lud06 = parsedLud06;
|
||
}
|
||
|
||
if (nextLightning) {
|
||
profileForCache.lightningAddress = nextLightning;
|
||
}
|
||
|
||
this.setProfileCacheEntry(
|
||
normalized,
|
||
profileForCache,
|
||
{ persist: false, reason: "modal-profile-fetch" },
|
||
);
|
||
} catch (error) {
|
||
devLogger.warn(
|
||
`[Application] Failed to update profile cache for ${normalized}:`,
|
||
error,
|
||
);
|
||
}
|
||
}
|
||
|
||
if (this.modalCreatorProfileRequestToken === requestToken) {
|
||
this.modalCreatorProfileRequestToken = null;
|
||
}
|
||
}
|
||
|
||
normalizeReactionCount(value) {
|
||
if (Number.isFinite(value)) {
|
||
return Math.max(0, Math.round(Number(value)));
|
||
}
|
||
const parsed = Number(value);
|
||
return Number.isFinite(parsed) ? Math.max(0, Math.round(parsed)) : 0;
|
||
}
|
||
|
||
normalizeHexPubkey(pubkey) {
|
||
if (typeof pubkey !== "string") {
|
||
return null;
|
||
}
|
||
|
||
const trimmed = pubkey.trim();
|
||
if (!trimmed) {
|
||
return null;
|
||
}
|
||
|
||
if (HEX64_REGEX.test(trimmed)) {
|
||
return trimmed.toLowerCase();
|
||
}
|
||
|
||
if (trimmed.startsWith("npub1")) {
|
||
const decoded = this.safeDecodeNpub(trimmed);
|
||
if (decoded && HEX64_REGEX.test(decoded)) {
|
||
return decoded.toLowerCase();
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* Attempts to fetch an older event by its ID if we can't find it in
|
||
* this.videosMap or from a bulk fetch. Uses nostrClient.getEventById.
|
||
*/
|
||
async getOldEventById(eventId) {
|
||
return this.nostrService.getOldEventById(eventId);
|
||
}
|
||
|
||
/**
|
||
* Format "time ago" for a given timestamp (in seconds).
|
||
*/
|
||
formatAbsoluteTimestamp(timestamp) {
|
||
return formatAbsoluteTimestampUtil(timestamp);
|
||
}
|
||
|
||
formatTimeAgo(timestamp) {
|
||
return formatTimeAgoUtil(timestamp);
|
||
}
|
||
|
||
escapeHTML(unsafe) {
|
||
return escapeHtml(unsafe);
|
||
}
|
||
|
||
updateNotificationPortalVisibility() {
|
||
const portal = this.notificationPortal;
|
||
const HTMLElementCtor =
|
||
portal?.ownerDocument?.defaultView?.HTMLElement ||
|
||
(typeof HTMLElement !== "undefined" ? HTMLElement : null);
|
||
|
||
if (!portal || !HTMLElementCtor || !(portal instanceof HTMLElementCtor)) {
|
||
return;
|
||
}
|
||
|
||
const containers = [
|
||
this.errorContainer,
|
||
this.statusContainer,
|
||
this.successContainer,
|
||
];
|
||
|
||
const hasVisibleBanner = containers.some((container) => {
|
||
if (!container || !(container instanceof HTMLElementCtor)) {
|
||
return false;
|
||
}
|
||
return !container.classList.contains("hidden");
|
||
});
|
||
|
||
portal.classList.toggle("notification-portal--active", hasVisibleBanner);
|
||
}
|
||
|
||
showError(msg) {
|
||
const container = this.errorContainer;
|
||
const HTMLElementCtor =
|
||
container?.ownerDocument?.defaultView?.HTMLElement ||
|
||
(typeof HTMLElement !== "undefined" ? HTMLElement : null);
|
||
|
||
if (!container || !HTMLElementCtor || !(container instanceof HTMLElementCtor)) {
|
||
if (msg) {
|
||
userLogger.error(msg);
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (!msg) {
|
||
// Remove any content, then hide
|
||
container.textContent = "";
|
||
container.classList.add("hidden");
|
||
this.updateNotificationPortalVisibility();
|
||
return;
|
||
}
|
||
|
||
// If there's a message, show it
|
||
container.textContent = msg;
|
||
container.classList.remove("hidden");
|
||
this.updateNotificationPortalVisibility();
|
||
|
||
userLogger.error(msg);
|
||
|
||
// Optional auto-hide after 5 seconds
|
||
setTimeout(() => {
|
||
if (container !== this.errorContainer) {
|
||
return;
|
||
}
|
||
container.textContent = "";
|
||
container.classList.add("hidden");
|
||
this.updateNotificationPortalVisibility();
|
||
}, 5000);
|
||
}
|
||
|
||
showStatus(msg, options = {}) {
|
||
const container = this.statusContainer;
|
||
const messageTarget = this.statusMessage;
|
||
const ownerDocument =
|
||
container?.ownerDocument || (typeof document !== "undefined" ? document : null);
|
||
const defaultView =
|
||
ownerDocument?.defaultView || (typeof window !== "undefined" ? window : null);
|
||
const clearScheduler =
|
||
defaultView?.clearTimeout ||
|
||
(typeof clearTimeout === "function" ? clearTimeout : null);
|
||
const schedule =
|
||
defaultView?.setTimeout || (typeof setTimeout === "function" ? setTimeout : null);
|
||
|
||
if (this.statusAutoHideHandle && typeof clearScheduler === "function") {
|
||
clearScheduler(this.statusAutoHideHandle);
|
||
this.statusAutoHideHandle = null;
|
||
}
|
||
|
||
const HTMLElementCtor =
|
||
container?.ownerDocument?.defaultView?.HTMLElement ||
|
||
(typeof HTMLElement !== "undefined" ? HTMLElement : null);
|
||
|
||
if (!container || !HTMLElementCtor || !(container instanceof HTMLElementCtor)) {
|
||
return;
|
||
}
|
||
|
||
const { autoHideMs, showSpinner } =
|
||
options && typeof options === "object" ? options : Object.create(null);
|
||
|
||
const shouldShowSpinner = showSpinner !== false;
|
||
const existingSpinner = container.querySelector(".status-spinner");
|
||
|
||
if (shouldShowSpinner) {
|
||
if (!(existingSpinner instanceof HTMLElementCtor)) {
|
||
const spinner = ownerDocument?.createElement?.("span") || null;
|
||
if (spinner) {
|
||
spinner.className = "status-spinner";
|
||
spinner.setAttribute("aria-hidden", "true");
|
||
if (messageTarget && container.contains(messageTarget)) {
|
||
container.insertBefore(spinner, messageTarget);
|
||
} else {
|
||
container.insertBefore(spinner, container.firstChild);
|
||
}
|
||
}
|
||
}
|
||
} else if (existingSpinner instanceof HTMLElementCtor) {
|
||
existingSpinner.remove();
|
||
}
|
||
|
||
if (!msg) {
|
||
if (messageTarget && messageTarget instanceof HTMLElementCtor) {
|
||
messageTarget.textContent = "";
|
||
}
|
||
container.classList.add("hidden");
|
||
this.updateNotificationPortalVisibility();
|
||
return;
|
||
}
|
||
|
||
if (messageTarget && messageTarget instanceof HTMLElementCtor) {
|
||
messageTarget.textContent = msg;
|
||
} else {
|
||
container.textContent = msg;
|
||
}
|
||
container.classList.remove("hidden");
|
||
this.updateNotificationPortalVisibility();
|
||
|
||
if (
|
||
Number.isFinite(autoHideMs) &&
|
||
autoHideMs > 0 &&
|
||
typeof schedule === "function"
|
||
) {
|
||
const expectedText =
|
||
messageTarget && messageTarget instanceof HTMLElementCtor
|
||
? messageTarget.textContent
|
||
: container.textContent;
|
||
this.statusAutoHideHandle = schedule(() => {
|
||
this.statusAutoHideHandle = null;
|
||
const activeContainer = this.statusContainer;
|
||
if (activeContainer !== container) {
|
||
return;
|
||
}
|
||
const activeMessageTarget =
|
||
this.statusMessage && typeof this.statusMessage.textContent === "string"
|
||
? this.statusMessage
|
||
: activeContainer;
|
||
if (activeMessageTarget.textContent !== expectedText) {
|
||
return;
|
||
}
|
||
if (activeMessageTarget === this.statusMessage) {
|
||
this.statusMessage.textContent = "";
|
||
} else {
|
||
activeContainer.textContent = "";
|
||
}
|
||
activeContainer.classList.add("hidden");
|
||
this.updateNotificationPortalVisibility();
|
||
}, autoHideMs);
|
||
}
|
||
}
|
||
|
||
showSuccess(msg) {
|
||
const container = this.successContainer;
|
||
const HTMLElementCtor =
|
||
container?.ownerDocument?.defaultView?.HTMLElement ||
|
||
(typeof HTMLElement !== "undefined" ? HTMLElement : null);
|
||
|
||
if (!container || !HTMLElementCtor || !(container instanceof HTMLElementCtor)) {
|
||
return;
|
||
}
|
||
|
||
if (!msg) {
|
||
container.textContent = "";
|
||
container.classList.add("hidden");
|
||
this.updateNotificationPortalVisibility();
|
||
return;
|
||
}
|
||
|
||
container.textContent = msg;
|
||
container.classList.remove("hidden");
|
||
this.updateNotificationPortalVisibility();
|
||
|
||
setTimeout(() => {
|
||
if (container !== this.successContainer) {
|
||
return;
|
||
}
|
||
container.textContent = "";
|
||
container.classList.add("hidden");
|
||
this.updateNotificationPortalVisibility();
|
||
}, 5000);
|
||
}
|
||
|
||
log(message, ...args) {
|
||
if (arguments.length === 0) {
|
||
return;
|
||
}
|
||
|
||
if (typeof message === "string") {
|
||
const prefix = message.startsWith("[") ? message : `[app] ${message}`;
|
||
devLogger.log(prefix, ...args);
|
||
return;
|
||
}
|
||
|
||
devLogger.log("[app]", message, ...args);
|
||
}
|
||
|
||
destroy() {
|
||
this.clearActiveIntervals();
|
||
this.teardownModalViewCountSubscription();
|
||
this.videoModalReadyPromise = null;
|
||
|
||
if (this.moderationActionController) {
|
||
try {
|
||
this.moderationActionController.destroy();
|
||
} catch (error) {
|
||
devLogger.warn(
|
||
"[Application] Failed to destroy moderation action controller:",
|
||
error,
|
||
);
|
||
}
|
||
this.moderationActionController = null;
|
||
}
|
||
|
||
if (this.statusAutoHideHandle) {
|
||
const ownerDocument =
|
||
this.statusContainer?.ownerDocument ||
|
||
(typeof document !== "undefined" ? document : null);
|
||
const defaultView =
|
||
ownerDocument?.defaultView ||
|
||
(typeof window !== "undefined" ? window : null);
|
||
const clearScheduler =
|
||
defaultView?.clearTimeout ||
|
||
(typeof clearTimeout === "function" ? clearTimeout : null);
|
||
if (typeof clearScheduler === "function") {
|
||
clearScheduler(this.statusAutoHideHandle);
|
||
}
|
||
this.statusAutoHideHandle = null;
|
||
}
|
||
|
||
if (this.watchHistoryTelemetry) {
|
||
try {
|
||
this.watchHistoryTelemetry.destroy();
|
||
} catch (error) {
|
||
devLogger.warn(
|
||
"[Application] Failed to destroy watch history telemetry:",
|
||
error
|
||
);
|
||
}
|
||
this.watchHistoryTelemetry = null;
|
||
}
|
||
|
||
if (this.exploreDataService && typeof this.exploreDataService.destroy === "function") {
|
||
try {
|
||
this.exploreDataService.destroy();
|
||
} catch (error) {
|
||
devLogger.warn(
|
||
"[Application] Failed to destroy explore data service:",
|
||
error,
|
||
);
|
||
}
|
||
this.exploreDataService = null;
|
||
}
|
||
|
||
if (typeof this.unsubscribeFromHashtagPreferencesChange === "function") {
|
||
try {
|
||
this.unsubscribeFromHashtagPreferencesChange();
|
||
} catch (error) {
|
||
devLogger.warn(
|
||
"[Application] Failed to unsubscribe hashtag preferences change listener:",
|
||
error,
|
||
);
|
||
}
|
||
this.unsubscribeFromHashtagPreferencesChange = null;
|
||
}
|
||
this.boundHashtagPreferencesChangeHandler = null;
|
||
|
||
if (typeof this.unsubscribeFromPubkeyState === "function") {
|
||
try {
|
||
this.unsubscribeFromPubkeyState();
|
||
} catch (error) {
|
||
devLogger.warn("[Application] Failed to unsubscribe pubkey state:", error);
|
||
}
|
||
this.unsubscribeFromPubkeyState = null;
|
||
}
|
||
|
||
if (typeof this.unsubscribeFromCurrentUserState === "function") {
|
||
try {
|
||
this.unsubscribeFromCurrentUserState();
|
||
} catch (error) {
|
||
devLogger.warn(
|
||
"[Application] Failed to unsubscribe current user state:",
|
||
error
|
||
);
|
||
}
|
||
this.unsubscribeFromCurrentUserState = null;
|
||
}
|
||
|
||
if (
|
||
this.boundNwcSettingsToastHandler &&
|
||
typeof window !== "undefined" &&
|
||
typeof window.removeEventListener === "function"
|
||
) {
|
||
window.removeEventListener(
|
||
"bitvid:toast",
|
||
this.boundNwcSettingsToastHandler
|
||
);
|
||
this.boundNwcSettingsToastHandler = null;
|
||
}
|
||
|
||
if (
|
||
this.nwcSettingsService &&
|
||
typeof this.nwcSettingsService.clearCache === "function"
|
||
) {
|
||
this.nwcSettingsService.clearCache();
|
||
}
|
||
|
||
this.authEventUnsubscribes.forEach((unsubscribe) => {
|
||
if (typeof unsubscribe === "function") {
|
||
try {
|
||
unsubscribe();
|
||
} catch (error) {
|
||
devLogger.warn("[Application] Auth listener unsubscribe failed:", error);
|
||
}
|
||
}
|
||
});
|
||
this.authEventUnsubscribes = [];
|
||
|
||
if (typeof this.unsubscribeFromNostrService === "function") {
|
||
try {
|
||
this.unsubscribeFromNostrService();
|
||
} catch (error) {
|
||
devLogger.warn("[Application] Failed to unsubscribe nostr service:", error);
|
||
}
|
||
this.unsubscribeFromNostrService = null;
|
||
}
|
||
|
||
if (this.commentController) {
|
||
try {
|
||
this.commentController.destroy({ resetUi: false });
|
||
} catch (error) {
|
||
devLogger.warn(
|
||
"[Application] Failed to destroy comment controller:",
|
||
error,
|
||
);
|
||
}
|
||
this.commentController = null;
|
||
}
|
||
|
||
if (this.modalManager) {
|
||
try {
|
||
this.modalManager.teardown();
|
||
} catch (error) {
|
||
devLogger.warn("[Application] Modal teardown failed:", error);
|
||
}
|
||
this.modalManager = null;
|
||
}
|
||
|
||
if (this.tagPreferenceMenuController) {
|
||
this.tagPreferenceMenuController.clear();
|
||
}
|
||
|
||
if (this.videoListView) {
|
||
if (this.moreMenuController) {
|
||
this.moreMenuController.detachVideoListView();
|
||
}
|
||
this.videoListView.setPlaybackHandler(null);
|
||
this.videoListView.setEditHandler(null);
|
||
this.videoListView.setRevertHandler(null);
|
||
this.videoListView.setDeleteHandler(null);
|
||
if (
|
||
typeof this.videoListView.setTagPreferenceStateResolver === "function"
|
||
) {
|
||
this.videoListView.setTagPreferenceStateResolver(null);
|
||
}
|
||
if (typeof this.videoListView.setTagActivationHandler === "function") {
|
||
this.videoListView.setTagActivationHandler(null);
|
||
}
|
||
this.videoListViewPlaybackHandler = null;
|
||
this.videoListViewEditHandler = null;
|
||
this.videoListViewRevertHandler = null;
|
||
this.videoListViewDeleteHandler = null;
|
||
try {
|
||
this.videoListView.destroy();
|
||
} catch (error) {
|
||
devLogger.warn("[Application] Failed to destroy VideoListView:", error);
|
||
}
|
||
this.videoListView = null;
|
||
}
|
||
|
||
if (this.moreMenuController) {
|
||
this.moreMenuController.setVideoModal(null);
|
||
this.moreMenuController.destroy();
|
||
this.moreMenuController = null;
|
||
}
|
||
|
||
if (this.profileController) {
|
||
if (typeof this.profileController.destroy === "function") {
|
||
try {
|
||
this.profileController.destroy();
|
||
} catch (error) {
|
||
devLogger.warn(
|
||
"[Application] Failed to destroy profile controller:",
|
||
error,
|
||
);
|
||
}
|
||
}
|
||
this.profileController = null;
|
||
}
|
||
|
||
if (this.loginModalController) {
|
||
if (typeof this.loginModalController.destroy === "function") {
|
||
try {
|
||
this.loginModalController.destroy();
|
||
} catch (error) {
|
||
devLogger.warn(
|
||
"[Application] Failed to destroy login modal controller:",
|
||
error,
|
||
);
|
||
}
|
||
}
|
||
this.loginModalController = null;
|
||
}
|
||
if (this.bootstrapper) {
|
||
try {
|
||
this.bootstrapper.teardown();
|
||
} catch (error) {
|
||
devLogger.warn("[Application] Bootstrap teardown failed:", error);
|
||
}
|
||
}
|
||
|
||
|
||
if (this.commentThreadService?.setCallbacks) {
|
||
this.commentThreadService.setCallbacks({
|
||
onThreadReady: null,
|
||
onCommentsAppended: null,
|
||
onError: null,
|
||
});
|
||
}
|
||
if (this.commentThreadService) {
|
||
try {
|
||
this.commentThreadService.teardown();
|
||
} catch (error) {
|
||
devLogger.warn(
|
||
"[Application] Failed to destroy comment thread service:",
|
||
error,
|
||
);
|
||
}
|
||
this.commentThreadService = null;
|
||
}
|
||
|
||
this.videoList = null;
|
||
if (this.videoListPopularTags) {
|
||
this.videoListPopularTags.textContent = "";
|
||
this.videoListPopularTags.hidden = true;
|
||
}
|
||
this.videoListPopularTags = null;
|
||
}
|
||
|
||
async handleShareNostrPost(payload = {}) {
|
||
const video = payload?.video || null;
|
||
const videoId = typeof video?.id === "string" ? video.id.trim() : "";
|
||
const videoTitle =
|
||
typeof video?.title === "string" ? video.title.trim() : "";
|
||
const videoPubkey =
|
||
typeof video?.pubkey === "string" ? video.pubkey.trim() : "";
|
||
|
||
if (!videoId || !videoTitle) {
|
||
userLogger.warn("[Application] Share post missing video details.");
|
||
this.showError("Missing video details for sharing.");
|
||
throw new Error("share-missing-video-details");
|
||
}
|
||
|
||
const signer = getActiveSigner();
|
||
if (!signer || typeof signer.signEvent !== "function") {
|
||
userLogger.warn("[Application] No active signer available for share.");
|
||
this.showError("Connect a Nostr signer to share.");
|
||
throw new Error("share-missing-signer");
|
||
}
|
||
|
||
const activePubkey = this.normalizeHexPubkey(this.pubkey);
|
||
const signerPubkey = this.normalizeHexPubkey(signer.pubkey);
|
||
const eventPubkey = activePubkey || signerPubkey;
|
||
|
||
if (!eventPubkey) {
|
||
userLogger.warn("[Application] Share post missing active pubkey.");
|
||
this.showError("Please log in to share on Nostr.");
|
||
throw new Error("share-missing-pubkey");
|
||
}
|
||
|
||
if (activePubkey && signerPubkey && activePubkey !== signerPubkey) {
|
||
userLogger.error(
|
||
"[Application] Active signer does not match current account for share.",
|
||
);
|
||
this.showError("Active signer does not match your account.");
|
||
throw new Error("share-signer-mismatch");
|
||
}
|
||
|
||
if (!nostrClient?.pool) {
|
||
userLogger.error("[Application] Share publish failed: relays not ready.");
|
||
this.showError("Nostr relays are not ready yet. Please try again.");
|
||
throw new Error("share-relays-unavailable");
|
||
}
|
||
|
||
const relayEntries = Array.isArray(payload?.relays) ? payload.relays : [];
|
||
const relayUrls = relayEntries
|
||
.map((entry) => {
|
||
if (typeof entry === "string") {
|
||
return entry;
|
||
}
|
||
if (Array.isArray(entry) && entry.length) {
|
||
if (entry[0] === "r") {
|
||
return typeof entry[1] === "string" ? entry[1] : "";
|
||
}
|
||
return typeof entry[0] === "string" ? entry[0] : "";
|
||
}
|
||
if (entry && typeof entry === "object") {
|
||
if (typeof entry.url === "string") {
|
||
return entry.url;
|
||
}
|
||
if (typeof entry.relay === "string") {
|
||
return entry.relay;
|
||
}
|
||
}
|
||
return "";
|
||
})
|
||
.filter(Boolean);
|
||
const relayTargets = sanitizeRelayList(relayUrls);
|
||
|
||
if (!relayTargets.length) {
|
||
userLogger.warn("[Application] Share post missing relay targets.");
|
||
this.showError("Please choose at least one relay to share to.");
|
||
throw new Error("share-missing-relays");
|
||
}
|
||
|
||
if (signer.type === "extension" && nostrClient.ensureExtensionPermissions) {
|
||
const permissionResult = await nostrClient.ensureExtensionPermissions(
|
||
DEFAULT_NIP07_PERMISSION_METHODS,
|
||
);
|
||
if (!permissionResult?.ok) {
|
||
userLogger.warn(
|
||
"[Application] Share publish blocked by signer permissions.",
|
||
permissionResult?.error,
|
||
);
|
||
this.showError("Signer permissions are required to post.");
|
||
throw new Error("share-permission-denied");
|
||
}
|
||
}
|
||
|
||
const event = buildShareEvent({
|
||
pubkey: eventPubkey,
|
||
created_at: Math.floor(Date.now() / 1000),
|
||
content: typeof payload?.content === "string" ? payload.content : "",
|
||
video: { id: videoId, pubkey: videoPubkey },
|
||
relays: relayEntries,
|
||
});
|
||
|
||
let signedEvent;
|
||
try {
|
||
signedEvent = await queueSignEvent(signer, event);
|
||
} catch (error) {
|
||
userLogger.error("[Application] Failed to sign share event.", error);
|
||
this.showError("Unable to sign the share event.");
|
||
throw error;
|
||
}
|
||
|
||
const publishResults = await publishEventToRelays(
|
||
nostrClient.pool,
|
||
relayTargets,
|
||
signedEvent,
|
||
);
|
||
|
||
let publishSummary;
|
||
try {
|
||
publishSummary = assertAnyRelayAccepted(publishResults, {
|
||
context: "share note",
|
||
});
|
||
} catch (publishError) {
|
||
if (publishError?.relayFailures?.length) {
|
||
publishError.relayFailures.forEach(
|
||
({ url, error: relayError, reason }) => {
|
||
userLogger.error(
|
||
`[Application] Relay ${url} rejected share note: ${reason}`,
|
||
relayError || reason,
|
||
);
|
||
},
|
||
);
|
||
}
|
||
this.showError("Failed to share on Nostr. Please try again.");
|
||
throw publishError;
|
||
}
|
||
|
||
if (publishSummary.failed.length) {
|
||
publishSummary.failed.forEach(({ url, error: relayError }) => {
|
||
const reason =
|
||
relayError instanceof Error
|
||
? relayError.message
|
||
: relayError
|
||
? String(relayError)
|
||
: "publish failed";
|
||
userLogger.warn(
|
||
`[Application] Relay ${url} did not accept share note: ${reason}`,
|
||
relayError,
|
||
);
|
||
});
|
||
}
|
||
|
||
userLogger.info(
|
||
"[Application] Share note published.",
|
||
publishSummary.accepted.map(({ url }) => url),
|
||
);
|
||
this.showSuccess("Shared to Nostr!");
|
||
|
||
return {
|
||
ok: true,
|
||
event: signedEvent,
|
||
accepted: publishSummary.accepted.map(({ url }) => url),
|
||
failed: publishSummary.failed.map(({ url }) => url),
|
||
};
|
||
}
|
||
|
||
shareActiveVideo() {
|
||
if (!this.currentVideo || !this.currentVideo.id) {
|
||
this.showError("No shareable video is loaded.");
|
||
return;
|
||
}
|
||
|
||
const shareUrl = this.buildShareUrlFromEventId(this.currentVideo.id);
|
||
if (!shareUrl) {
|
||
this.showError("Could not generate link.");
|
||
return;
|
||
}
|
||
|
||
navigator.clipboard
|
||
.writeText(shareUrl)
|
||
.then(() => this.showSuccess("Video link copied to clipboard!"))
|
||
.catch(() => this.showError("Failed to copy the link."));
|
||
}
|
||
|
||
async openShareNostrModal({ video, triggerElement } = {}) {
|
||
const targetVideo =
|
||
video && typeof video === "object" ? video : this.currentVideo || null;
|
||
if (!targetVideo) {
|
||
this.showError("No video is available to share.");
|
||
return;
|
||
}
|
||
|
||
if (!this.shareNostrModal) {
|
||
devLogger.warn("[Application] Share Nostr modal is unavailable.");
|
||
this.showError("Share modal is not ready yet.");
|
||
return;
|
||
}
|
||
|
||
const shareUrl =
|
||
typeof targetVideo.shareUrl === "string" && targetVideo.shareUrl.trim()
|
||
? targetVideo.shareUrl.trim()
|
||
: this.buildShareUrlFromEventId(targetVideo.id);
|
||
const payload = {
|
||
id: targetVideo.id,
|
||
title: targetVideo.title,
|
||
pubkey: targetVideo.pubkey,
|
||
authorName: targetVideo.creatorName || targetVideo.authorName || "",
|
||
thumbnail: targetVideo.thumbnail,
|
||
shareUrl,
|
||
};
|
||
|
||
try {
|
||
await this.shareNostrModal.open({
|
||
video: payload,
|
||
triggerElement,
|
||
});
|
||
} catch (error) {
|
||
devLogger.error("[Application] Failed to open Share Nostr modal:", error);
|
||
this.showError("Unable to open the share modal.");
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Copies the current video's magnet link to the clipboard.
|
||
*/
|
||
handleCopyMagnet() {
|
||
if (!this.currentVideo || !this.currentVideo.magnet) {
|
||
if (
|
||
this.currentVideo &&
|
||
this.currentVideo.originalMagnet &&
|
||
!this.currentVideo.torrentSupported
|
||
) {
|
||
this.showError(UNSUPPORTED_BTITH_MESSAGE);
|
||
return;
|
||
}
|
||
|
||
this.showError("No magnet link to copy.");
|
||
return;
|
||
}
|
||
try {
|
||
navigator.clipboard.writeText(this.currentVideo.magnet);
|
||
this.showSuccess("Magnet link copied to clipboard!");
|
||
} catch (err) {
|
||
devLogger.error("Failed to copy magnet link:", err);
|
||
this.showError("Could not copy magnet link. Please copy it manually.");
|
||
}
|
||
}
|
||
}
|
||
|
||
Application.recordVideoView = recordVideoViewApi;
|
||
|
||
/**
|
||
* Given an array of video objects,
|
||
* return only the newest (by created_at) for each videoRootId.
|
||
* If no videoRootId is present, treat the video’s own ID as its root.
|
||
*/
|
||
export { Application };
|
||
export default Application;
|