From 7554b8874dc47961792c70fff335c866317b4f80 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 27 Oct 2025 11:37:56 -0400 Subject: [PATCH 1/2] Refactor application bootstrap and modal management --- js/app.js | 1683 ++------------------------------- js/ui/ModalManager.js | 810 ++++++++++++++++ js/ui/applicationBootstrap.js | 976 +++++++++++++++++++ 3 files changed, 1853 insertions(+), 1616 deletions(-) create mode 100644 js/ui/ModalManager.js create mode 100644 js/ui/applicationBootstrap.js diff --git a/js/app.js b/js/app.js index 70c33279..2a4906bd 100644 --- a/js/app.js +++ b/js/app.js @@ -43,8 +43,6 @@ import { } from "./feedEngine/index.js"; import watchHistoryService from "./watchHistoryService.js"; import r2Service from "./services/r2Service.js"; -import PlaybackService from "./services/playbackService.js"; -import AuthService from "./services/authService.js"; import { getVideoNoteErrorMessage, normalizeVideoNotePayload, @@ -52,17 +50,10 @@ import { import getAuthProvider, { providers as authProviders, } from "./services/authProviders/index.js"; -import NwcSettingsService from "./services/nwcSettingsService.js"; -import nostrService from "./services/nostrService.js"; -import DiscussionCountService from "./services/discussionCountService.js"; -import CommentThreadService from "./services/commentThreadService.js"; import hashtagPreferences, { HASHTAG_PREFERENCES_EVENTS, } from "./services/hashtagPreferencesService.js"; import { initQuickR2Upload } from "./r2-quick.js"; -import { createWatchHistoryRenderer } from "./historyView.js"; -import WatchHistoryController from "./ui/watchHistoryController.js"; -import WatchHistoryTelemetry from "./services/watchHistoryTelemetry.js"; import { getSidebarLoadingMarkup } from "./sidebarLoading.js"; import { subscriptions } from "./subscriptions.js"; import { @@ -74,9 +65,6 @@ 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 AppChromeController from "./ui/appChromeController.js"; -import VideoListViewController from "./ui/videoListViewController.js"; -import ProfileIdentityController from "./ui/profileIdentityController.js"; import { initViewCounter, subscribeToVideoViewCount, @@ -85,7 +73,6 @@ import { ingestLocalViewEvent, } from "./viewCounter.js"; import { splitAndZap as splitAndZapDefault } from "./payments/zapSplit.js"; -import { showLoginRequiredToZapNotification } from "./payments/zapNotifications.js"; import { formatAbsoluteTimestamp as formatAbsoluteTimestampUtil, formatTimeAgo as formatTimeAgoUtil, @@ -95,28 +82,17 @@ import { import reactionCounter from "./reactionCounter.js"; import { escapeHTML as escapeHtml, - removeTrackingScripts, } from "./utils/domUtils.js"; -import { VideoModal } from "./ui/components/VideoModal.js"; -import { RevertModal } from "./ui/components/RevertModal.js"; import { prepareStaticModal, openStaticModal, closeStaticModal, } from "./ui/components/staticModalAccessibility.js"; -import { VideoListView } from "./ui/views/VideoListView.js"; import createTagPreferenceMenu, { TAG_PREFERENCE_ACTIONS, applyTagPreferenceMenuState, } from "./ui/components/tagPreferenceMenu.js"; -import MoreMenuController from "./ui/moreMenuController.js"; -import ProfileModalController from "./ui/profileModalController.js"; import LoginModalController from "./ui/loginModalController.js"; -import ZapController from "./ui/zapController.js"; -import initUploadModal from "./ui/initUploadModal.js"; -import initEditModal from "./ui/initEditModal.js"; -import initDeleteModal from "./ui/initDeleteModal.js"; -import { MediaLoader } from "./utils/mediaLoader.js"; import { pointerArrayToKey } from "./utils/pointer.js"; import resolveVideoPointer, { buildVideoAddressPointer, @@ -164,6 +140,7 @@ import { URL_PROBE_TIMEOUT_MS, urlHealthConstants, } from "./state/cache.js"; +import ApplicationBootstrap from "./ui/applicationBootstrap.js"; const recordVideoViewApi = (...args) => recordVideoView(nostrClient, ...args); @@ -211,1385 +188,30 @@ class Application { constructor({ services = {}, ui = {}, helpers = {}, loadView: viewLoader } = {}) { this.loadView = typeof viewLoader === "function" ? viewLoader : null; - // Basic auth/display elements - this.loginButton = document.getElementById("loginButton") || null; - this.logoutButton = document.getElementById("logoutButton") || null; - this.userStatus = document.getElementById("userStatus") || null; - this.userPubKey = document.getElementById("userPubKey") || null; - - const mediaLoaderFactory = - typeof helpers.mediaLoaderFactory === "function" - ? helpers.mediaLoaderFactory - : () => new MediaLoader(); - this.mediaLoader = - helpers.mediaLoader instanceof MediaLoader - ? helpers.mediaLoader - : mediaLoaderFactory(); - this.loadedThumbnails = new Map(); - this.urlHealthSnapshots = new Map(); - this.streamHealthSnapshots = new Map(); - this.boundStreamHealthBadgeHandler = (detail) => - this.handleStreamHealthBadgeUpdate(detail); - this.attachHealthBadgesWithCache = (container) => { - attachHealthBadges(container, { - onUpdate: this.boundStreamHealthBadgeHandler, - }); + const bootstrapServices = { + ...services, + torrentClient, + deriveTorrentPlaybackConfig, + isValidMagnetUri, + authProviders, + getAuthProvider, }; - this.defaultModerationSettings = getDefaultModerationSettings(); - this.moderationSettings = { ...this.defaultModerationSettings }; - this.relayManager = relayManager; - this.activeIntervals = []; - this.watchHistoryTelemetry = null; - this.authEventUnsubscribes = []; - this.unsubscribeFromNostrService = null; - this.designSystemContext = { - getMode: () => "new", - isNew: () => true, - }; - this.videoModalReadyPromise = null; - this.boundUploadSubmitHandler = null; - this.boundEditModalSubmitHandler = null; - this.boundEditModalCancelHandler = null; - this.boundRevertConfirmHandler = null; - this.boundDeleteConfirmHandler = null; - this.boundDeleteCancelHandler = null; - this.boundVideoModalCloseHandler = null; - this.boundVideoModalCopyHandler = null; - this.boundVideoModalShareHandler = null; - this.boundVideoModalCreatorHandler = null; - this.boundVideoModalZapHandler = null; - this.boundVideoModalZapWalletHandler = null; - this.boundVideoModalCommentSubmitHandler = null; - this.boundVideoModalCommentRetryHandler = null; - this.boundVideoModalCommentLoadMoreHandler = null; - this.boundVideoModalCommentLoginHandler = null; - this.boundVideoModalCommentMuteHandler = null; - this.boundVideoModalTagActivateHandler = null; - this.pendingModalZapOpen = false; - this.videoListViewPlaybackHandler = null; - this.videoListViewEditHandler = null; - this.videoListViewRevertHandler = null; - this.videoListViewDeleteHandler = null; - this.deleteModal = null; - this.moreMenuController = null; - this.latestFeedMetadata = null; - this.lastModalTrigger = null; - this.modalCommentState = { - videoEventId: null, - videoDefinitionAddress: null, - parentCommentId: null, - }; - this.modalCommentProfiles = new Map(); - this.modalCommentLimit = 40; - this.modalCommentLoadPromise = null; - this.modalCommentPublishPromise = null; - this.uploadSubmitPromise = null; - this.pendingModeratedPlayback = null; - this.lastIdentityRefreshPromise = null; - this.profileIdentityController = new ProfileIdentityController({ - callbacks: { - safeEncodeNpub: (pubkey) => this.safeEncodeNpub(pubkey), - formatShortNpub: (value) => formatShortNpub(value), - }, - logger: devLogger, - }); - this.videoListViewController = new VideoListViewController({ - getSidebarLoadingMarkup, - logger: devLogger, - }); - this.appChromeController = null; - this.nostrService = services.nostrService || nostrService; - this.r2Service = services.r2Service || r2Service; - - this.feedEngine = - services.feedEngine || - createFeedEngine({ - logger: (...args) => { - if (!isWatchHistoryDebugEnabled()) { - return; - } - devLogger.info(...args); - }, - }); - this.payments = services.payments || null; - this.splitAndZap = - (services.payments && services.payments.splitAndZap) || - splitAndZapDefault; - - this.discussionCountService = - services.discussionCountService || new DiscussionCountService(); - - this.nwcSettingsService = - services.nwcSettingsService || - new NwcSettingsService({ - normalizeHexPubkey: (value) => this.normalizeHexPubkey(value), - getActivePubkey: () => this.pubkey, - payments: this.payments, - logger: { - warn: (...args) => devLogger.warn(...args), - }, - notifyError: (message) => this.showError(message), - maxWalletDefaultZap: MAX_WALLET_DEFAULT_ZAP, - }); - this.hashtagPreferences = - services.hashtagPreferences || hashtagPreferences; - this.hashtagPreferencesSnapshot = - this.createHashtagPreferencesSnapshot(); - this.hashtagPreferencesSnapshotSignature = - this.computeHashtagPreferencesSignature( - this.hashtagPreferencesSnapshot, - ); - this.hashtagPreferencesPublishInFlight = false; - this.hashtagPreferencesPublishPromise = null; - this.boundHashtagPreferencesChangeHandler = null; - this.unsubscribeFromHashtagPreferencesChange = null; - if ( - this.hashtagPreferences && - typeof this.hashtagPreferences.on === "function" - ) { - this.boundHashtagPreferencesChangeHandler = (detail) => - this.handleHashtagPreferencesChange(detail); - try { - this.unsubscribeFromHashtagPreferencesChange = - this.hashtagPreferences.on( - HASHTAG_PREFERENCES_EVENTS.CHANGE, - this.boundHashtagPreferencesChangeHandler, - ); - } catch (error) { - devLogger.warn( - "[Application] Failed to subscribe to hashtag preferences changes:", - error, - ); - } - } - if ( - this.nwcSettingsService && - typeof this.nwcSettingsService.setActivePubkeyGetter === "function" - ) { - this.nwcSettingsService.setActivePubkeyGetter(() => this.pubkey); - } - if ( - this.nwcSettingsService && - typeof this.nwcSettingsService.setPayments === "function" - ) { - this.nwcSettingsService.setPayments(this.payments); - } - if ( - this.feedEngine && - typeof this.feedEngine.run !== "function" && - typeof this.feedEngine.runFeed === "function" - ) { - this.feedEngine.run = (...args) => this.feedEngine.runFeed(...args); - } - this.registerRecentFeed(); - this.registerSubscriptionsFeed(); - this.registerWatchHistoryFeed(); - - this.watchHistoryController = new WatchHistoryController({ - watchHistoryService, - nostrClient, - showError: (message) => this.showError(message), - showSuccess: (message) => this.showSuccess(message), - dropWatchHistoryMetadata: (pointerKey) => - this.dropWatchHistoryMetadata(pointerKey), - getActivePubkey: () => - typeof this.pubkey === "string" && this.pubkey ? this.pubkey : "", - designSystem: this.designSystemContext, - }); - - this.watchHistoryTelemetry = new WatchHistoryTelemetry({ - watchHistoryService, - watchHistoryController: this.watchHistoryController, - nostrClient, - log: (message, ...args) => this.log(message, ...args), - normalizeHexPubkey: (value) => this.normalizeHexPubkey(value), - getActiveUserPubkey: () => this.pubkey, - ingestLocalViewEvent, - }); - - this.playbackService = - services.playbackService || - new PlaybackService({ - logger: devLogger, - torrentClient, - deriveTorrentPlaybackConfig, - isValidMagnetUri, - urlFirstEnabled: URL_FIRST_ENABLED, - analyticsCallbacks: { - "session-start": (detail) => { - const urlProvided = detail?.urlProvided ? "true" : "false"; - const magnetProvided = detail?.magnetProvided ? "true" : "false"; - const magnetUsable = detail?.magnetUsable ? "true" : "false"; - this.log( - `[playVideoWithFallback] Session start urlProvided=${urlProvided} magnetProvided=${magnetProvided} magnetUsable=${magnetUsable}` - ); - }, - fallback: (detail) => { - if (detail?.reason) { - this.log( - `[playVideoWithFallback] Falling back to WebTorrent (${detail.reason}).` - ); - } - }, - error: (detail) => { - if (detail?.error) { - this.log("[playVideoWithFallback] Playback error observed", detail.error); - } - }, - }, - }); - this.activePlaybackResultPromise = null; - this.activePlaybackSession = null; - - this.authService = - services.authService || - new AuthService({ - nostrClient, - userBlocks, - relayManager, - logger: devLogger, - accessControl, - authProviders, - getAuthProvider, - }); - this.authEventUnsubscribes.push( - this.authService.on("auth:login", (detail) => { - const maybePromise = this.handleAuthLogin(detail); - if (maybePromise && typeof maybePromise.then === "function") { - maybePromise.catch((error) => { - devLogger.error("Failed to process auth login event:", error); - }); - } - }) - ); - this.authEventUnsubscribes.push( - this.authService.on("auth:logout", (detail) => { - if (detail && typeof detail === "object") { - if (detail.__handled === true) { - return; - } - try { - detail.__handled = true; - } catch (error) { - devLogger.warn( - "Failed to mark auth logout detail as handled:", - error, - ); - } - } - - const maybePromise = this.handleAuthLogout(detail); - if (maybePromise && typeof maybePromise.then === "function") { - maybePromise.catch((error) => { - devLogger.error("Failed to process auth logout event:", error); - }); - } - }) - ); - this.authEventUnsubscribes.push( - this.authService.on("profile:updated", (detail) => { - try { - this.handleProfileUpdated(detail); - } catch (error) { - devLogger.warn( - "Failed to process profile update event:", - error, - ); - } - }) - ); - - this.commentThreadService = - services.commentThreadService || - new CommentThreadService({ - fetchVideoComments: (target, options) => - nostrClient?.fetchVideoComments?.(target, options), - subscribeVideoComments: (target, options) => - nostrClient?.subscribeVideoComments?.(target, options), - getProfileCacheEntry: (pubkey) => this.getProfileCacheEntry(pubkey), - batchFetchProfiles: (pubkeys) => this.batchFetchProfiles(pubkeys), - logger: { - warn: (...args) => devLogger.warn(...args), - }, - }); - this.boundCommentThreadReadyHandler = (snapshot) => - this.handleCommentThreadReady(snapshot); - this.boundCommentThreadAppendHandler = (payload) => - this.handleCommentThreadAppend(payload); - this.boundCommentThreadErrorHandler = (error) => - this.handleCommentThreadError(error); - if ( - this.commentThreadService && - typeof this.commentThreadService.setCallbacks === "function" - ) { - this.commentThreadService.setCallbacks({ - onThreadReady: this.boundCommentThreadReadyHandler, - onCommentsAppended: this.boundCommentThreadAppendHandler, - onError: this.boundCommentThreadErrorHandler, - }); - if (this.commentThreadService.defaultLimit) { - this.modalCommentLimit = this.commentThreadService.defaultLimit; - } - } - - // Profile and login modal controller state - this.profileController = null; - this.loginModalController = null; - this.currentUserNpub = null; - - this.initializeLoginModalController({ logIfMissing: true }); - - // Optional: a "profile" button or avatar (if used) - this.profileButton = document.getElementById("profileButton") || null; - this.profileAvatar = document.getElementById("profileAvatar") || null; - - const modalContainer = document.getElementById("modalContainer") || null; - - // Upload modal component - this.uploadButton = document.getElementById("uploadButton") || null; - const uploadModalSetup = initUploadModal({ + this.bootstrapper = new ApplicationBootstrap({ app: this, - uploadModalOverride: ui.uploadModal, - container: modalContainer, - services: { - authService: this.authService, - r2Service: this.r2Service, - }, - utilities: { - removeTrackingScripts, - setGlobalModalState, - }, - callbacks: { - publishVideoNote: (payload, options) => - this.publishVideoNote(payload, options), - showError: (message) => this.showError(message), - showSuccess: (message) => this.showSuccess(message), - getCurrentPubkey: () => this.pubkey, - safeEncodeNpub: (pubkey) => this.safeEncodeNpub(pubkey), - onSubmit: (event) => this.handleUploadSubmitEvent(event), - }, - }); - this.uploadModal = uploadModalSetup.modal; - this.uploadModalEvents = uploadModalSetup.events; - this.boundUploadSubmitHandler = uploadModalSetup.handlers.submit; - - const editModalSetup = initEditModal({ - app: this, - editModalOverride: ui.editModal, - container: modalContainer, - services: { - getMode: ({ video } = {}) => { - const candidate = - typeof video?.mode === "string" ? video.mode.trim().toLowerCase() : ""; - return candidate === "dev" ? "dev" : "live"; - }, - sanitizers: { - text: (value) => (typeof value === "string" ? value.trim() : ""), - url: (value) => (typeof value === "string" ? value.trim() : ""), - magnet: (value) => (typeof value === "string" ? value.trim() : ""), - checkbox: (value) => !!value, - }, - }, - utilities: { - removeTrackingScripts, - setGlobalModalState, - escapeHtml: (value) => escapeHtml(value), - }, - callbacks: { - showError: (message) => this.showError(message), - onSubmit: (event) => this.handleEditModalSubmit(event), - onCancel: () => this.showError(""), - }, - }); - - this.editModal = editModalSetup.modal; - this.editModalEvents = editModalSetup.events; - this.boundEditModalSubmitHandler = editModalSetup.handlers.submit; - this.boundEditModalCancelHandler = editModalSetup.handlers.cancel; - - this.revertModal = - (typeof ui.revertModal === "function" - ? ui.revertModal({ app: this }) - : ui.revertModal) || - new RevertModal({ - removeTrackingScripts, - setGlobalModalState, - formatAbsoluteTimestamp: (timestamp) => - this.formatAbsoluteTimestamp(timestamp), - formatTimeAgo: (timestamp) => this.formatTimeAgo(timestamp), - escapeHTML: (value) => this.escapeHTML(value), - truncateMiddle, - formatShortNpub: (value) => formatShortNpub(value), - fallbackThumbnailSrc: FALLBACK_THUMBNAIL_SRC, - container: modalContainer, - }); - - this.boundRevertConfirmHandler = (event) => { - this.handleRevertModalConfirm(event); - }; - this.revertModal.addEventListener( - "video:revert-confirm", - this.boundRevertConfirmHandler - ); - - const deleteModalSetup = initDeleteModal({ - app: this, - deleteModalOverride: ui.deleteModal, - container: modalContainer, - utilities: { - removeTrackingScripts, - setGlobalModalState, - truncateMiddle, - }, - callbacks: { - onConfirm: (event) => this.handleDeleteModalConfirm(event), - onCancel: () => this.showError(""), - }, - }); - - this.deleteModal = deleteModalSetup.modal; - this.deleteModalEvents = deleteModalSetup.events; - this.boundDeleteConfirmHandler = deleteModalSetup.handlers.confirm; - this.boundDeleteCancelHandler = deleteModalSetup.handlers.cancel; - - try { - const profileModalContainer = document.getElementById("modalContainer") || null; - if (profileModalContainer) { - const profileModalServices = { - normalizeHexPubkey: (value) => this.normalizeHexPubkey(value), - safeEncodeNpub: (pubkey) => this.safeEncodeNpub(pubkey), - safeDecodeNpub: (npub) => this.safeDecodeNpub(npub), - truncateMiddle: (value, maxLength) => truncateMiddle(value, maxLength), - formatShortNpub: (value) => formatShortNpub(value), - getProfileCacheEntry: (pubkey) => this.getProfileCacheEntry(pubkey), - batchFetchProfiles: (authorSet) => this.batchFetchProfiles(authorSet), - switchProfile: (pubkey) => this.authService.switchProfile(pubkey), - removeSavedProfile: (pubkey) => - this.authService.removeSavedProfile(pubkey), - relayManager, - userBlocks, - nostrClient, - nostrService: this.nostrService, - subscriptions, - accessControl, - moderation: moderationService, - getCurrentUserNpub: () => this.getCurrentUserNpub(), - nwcSettings: this.nwcSettingsService, - moderationSettings: { - getDefaultModerationSettings: () => getDefaultModerationSettings(), - getActiveModerationSettings: () => getModerationSettings(), - updateModerationSettings: (partial = {}) => - setModerationSettings(partial), - resetModerationSettings: () => resetModerationSettings(), - }, - loadVideos: (forceFetch, context) => - this.loadVideos(forceFetch, context), - onVideosShouldRefresh: (context) => - this.onVideosShouldRefresh(context), - describeAdminError: (code) => this.describeAdminError(code), - describeNotificationError: (code) => - this.describeNotificationError(code), - onAccessControlUpdated: () => this.onAccessControlUpdated(), - persistSavedProfiles: (options) => persistSavedProfiles(options), - watchHistoryService, - authService: this.authService, - requestAddProfileLogin: (options) => - this.requestProfileAdditionLogin(options), - describeLoginError: (error, fallbackMessage) => - this.describeLoginError(error, fallbackMessage), - hashtagPreferences: this.hashtagPreferences, - getHashtagPreferences: () => this.getHashtagPreferences(), - describeHashtagPreferencesError: (error, fallbackMessage) => - this.describeHashtagPreferencesError(error, { - fallbackMessage, - }), - log: (...args) => this.log(...args), - closeAllMoreMenus: (options) => this.closeAllMoreMenus(options), - clipboard: - typeof navigator !== "undefined" && navigator?.clipboard - ? navigator.clipboard - : null, - }; - - const profileModalState = { - getSavedProfiles: () => getSavedProfiles(), - setSavedProfiles: (profiles, options) => - setSavedProfiles(Array.isArray(profiles) ? profiles : [], options), - persistSavedProfiles: (options) => persistSavedProfiles(options), - getActivePubkey: () => getActiveProfilePubkey(), - setActivePubkey: (pubkey, options) => { - const normalized = - typeof pubkey === "string" && pubkey.trim() - ? pubkey.trim() - : null; - setStoredActiveProfilePubkey(normalized, options); - return getActiveProfilePubkey(); - }, - }; - - const profileModalCallbacks = { - onClose: () => this.handleProfileModalClosed(), - onLogout: async () => this.requestLogout(), - onChannelLink: (element) => this.handleProfileChannelLink(element), - onAddAccount: (payload) => this.handleAddProfile(payload), - onRequestLogoutProfile: (payload) => - this.handleProfileLogoutRequest(payload), - onRequestSwitchProfile: (payload) => - this.handleProfileSwitchRequest(payload), - onRelayOperation: (payload) => - this.handleProfileRelayOperation(payload), - onRelayModeToggle: (payload) => - this.handleProfileRelayModeToggle(payload), - onRelayRestore: (payload) => - this.handleProfileRelayRestore(payload), - onBlocklistMutation: (payload) => - this.handleProfileBlocklistMutation(payload), - onWalletPersist: (payload) => - this.handleProfileWalletPersist(payload), - onWalletTestRequest: (payload) => - this.handleProfileWalletTest(payload), - onWalletDisconnectRequest: (payload) => - this.handleProfileWalletDisconnect(payload), - onAdminMutation: (payload) => - this.handleProfileAdminMutation(payload), - onAdminNotifyError: (payload) => - this.handleProfileAdminNotifyError(payload), - onHistoryReady: (payload) => - this.handleProfileHistoryEvent(payload), - onModerationSettingsChange: (payload) => - this.handleModerationSettingsChange(payload), - }; - - this.profileController = new ProfileModalController({ - modalContainer: profileModalContainer, - removeTrackingScripts, - createWatchHistoryRenderer, - setGlobalModalState, - showError: (message) => this.showError(message), - showSuccess: (message) => this.showSuccess(message), - showStatus: (message) => this.showStatus(message), - constants: { - MAX_WALLET_DEFAULT_ZAP, - ADMIN_SUPER_NPUB, - ADMIN_DM_IMAGE_URL, - BITVID_WEBSITE_URL, - }, - services: profileModalServices, - state: profileModalState, - callbacks: profileModalCallbacks, - designSystem: this.designSystemContext, - }); - } else { - devLogger.warn( - "[Application] Profile modal controller disabled: modal container not found.", - ); - } - } catch (error) { - devLogger.error("Failed to initialize profile modal controller:", error); - } - - - // Optional small inline player stats - this.status = document.getElementById("status") || null; - this.progressBar = document.getElementById("progress") || null; - this.peers = document.getElementById("peers") || null; - this.speed = document.getElementById("speed") || null; - this.downloaded = document.getElementById("downloaded") || null; - - this.cleanupPromise = null; - this.zapController = null; - - this.videoModal = - (typeof ui.videoModal === "function" - ? ui.videoModal({ app: this }) - : ui.videoModal) || - new VideoModal({ - removeTrackingScripts, - setGlobalModalState, - document, - logger: { - log: (message, ...args) => this.log(message, ...args), - }, - mediaLoader: this.mediaLoader, - assets: { - fallbackThumbnailSrc: FALLBACK_THUMBNAIL_SRC, - }, - state: { - loadedThumbnails: this.loadedThumbnails, - }, - helpers: { - safeEncodeNpub: (pubkey) => this.safeEncodeNpub(pubkey), - formatShortNpub: (value) => formatShortNpub(value), - }, - }); - - if ( - this.videoModal && - typeof this.videoModal.setMediaLoader === "function" - ) { - this.videoModal.setMediaLoader(this.mediaLoader); - } - if ( - this.videoModal && - typeof this.videoModal.setTagPreferenceStateResolver === "function" - ) { - this.videoModal.setTagPreferenceStateResolver((tag) => - this.getTagPreferenceState(tag), - ); - } - this.zapController = new ZapController({ - videoModal: this.videoModal, - getCurrentVideo: () => this.currentVideo, - nwcSettings: this.nwcSettingsService, - isUserLoggedIn: () => this.isUserLoggedIn(), - hasSessionActor: () => - Boolean( - typeof nostrClient?.sessionActor?.pubkey === "string" && - nostrClient.sessionActor.pubkey.trim() - ), - notifyLoginRequired: () => - showLoginRequiredToZapNotification({ - app: this, - document: - this.statusContainer?.ownerDocument || - (typeof document !== "undefined" ? document : null), - }), - splitAndZap: (...args) => this.splitAndZap(...args), - payments: this.payments, - callbacks: { - onSuccess: (message) => this.showSuccess(message), - onError: (message) => this.showError(message), - }, - requestWalletPane: () => this.openWalletPane(), - }); - this.boundVideoModalCloseHandler = () => { - this.hideModal(); - }; - this.videoModal.addEventListener( - "modal:close", - this.boundVideoModalCloseHandler - ); - this.boundVideoModalCopyHandler = () => { - this.handleCopyMagnet(); - }; - this.videoModal.addEventListener( - "video:copy-magnet", - this.boundVideoModalCopyHandler - ); - this.boundVideoModalShareHandler = () => { - this.shareActiveVideo(); - }; - this.videoModal.addEventListener( - "video:share", - this.boundVideoModalShareHandler - ); - this.boundVideoModalModerationOverrideHandler = (event) => { - const detail = event?.detail || {}; - const targetVideo = - detail && typeof detail.video === "object" - ? detail.video - : this.currentVideo || null; - if (!targetVideo) { - const trigger = detail?.trigger; - if (trigger) { - trigger.disabled = false; - trigger.removeAttribute("aria-busy"); - } - return; - } - - const handled = this.handleModerationOverride({ - video: targetVideo, - card: detail?.card || null, - }); - - if (handled === false) { - const trigger = detail?.trigger; - if (trigger) { - trigger.disabled = false; - trigger.removeAttribute("aria-busy"); - } - } - }; - this.videoModal.addEventListener( - "video:moderation-override", - this.boundVideoModalModerationOverrideHandler, - ); - this.boundVideoModalModerationBlockHandler = (event) => { - const detail = event?.detail || {}; - const targetVideo = - detail && typeof detail.video === "object" - ? detail.video - : this.currentVideo || null; - const trigger = detail?.trigger || null; - - if (!targetVideo) { - if (trigger) { - trigger.disabled = false; - trigger.removeAttribute("aria-busy"); - } - return; - } - - Promise.resolve( - this.handleModerationBlock({ - video: targetVideo, - card: detail?.card || null, - }), - ) - .then((handled) => { - if (handled === false && trigger) { - trigger.disabled = false; - trigger.removeAttribute("aria-busy"); - } - }) - .catch((error) => { - devLogger.warn( - "[Application] Failed to handle modal moderation block:", - error, - ); - if (trigger) { - trigger.disabled = false; - trigger.removeAttribute("aria-busy"); - } - }); - }; - this.videoModal.addEventListener( - "video:moderation-block", - this.boundVideoModalModerationBlockHandler, - ); - this.boundVideoModalModerationHideHandler = (event) => { - const detail = event?.detail || {}; - const targetVideo = - detail && typeof detail.video === "object" - ? detail.video - : this.currentVideo || null; - const trigger = detail?.trigger || null; - - if (!targetVideo) { - if (trigger) { - trigger.disabled = false; - trigger.removeAttribute("aria-busy"); - } - return; - } - - Promise.resolve( - this.handleModerationHide({ - video: targetVideo, - card: detail?.card || null, - }), - ) - .then((handled) => { - if (handled === false && trigger) { - trigger.disabled = false; - trigger.removeAttribute("aria-busy"); - } - }) - .catch((error) => { - devLogger.warn( - "[Application] Failed to handle modal moderation hide:", - error, - ); - if (trigger) { - trigger.disabled = false; - trigger.removeAttribute("aria-busy"); - } - }); - }; - this.videoModal.addEventListener( - "video:moderation-hide", - this.boundVideoModalModerationHideHandler, - ); - this.boundVideoModalTagActivateHandler = (event) => { - const detail = event?.detail || {}; - const nativeEvent = detail?.nativeEvent || null; - if (nativeEvent) { - nativeEvent.preventDefault?.(); - nativeEvent.stopPropagation?.(); - } - - this.handleTagPreferenceActivation({ - tag: detail?.tag, - trigger: detail?.trigger || null, - context: "modal", - video: detail?.video || this.currentVideo || null, - event: nativeEvent, - }); - }; - this.videoModal.addEventListener( - "tag:activate", - this.boundVideoModalTagActivateHandler, - ); - this.boundVideoModalSimilarSelectHandler = (event) => { - const detail = event?.detail || {}; - const selectedVideo = - detail && typeof detail.video === "object" ? detail.video : null; - const triggerCandidate = - detail?.event?.currentTarget || - (detail?.card && typeof detail.card.getRoot === "function" - ? detail.card.getRoot() - : null); - - this.setLastModalTrigger(triggerCandidate || null); - - if (detail?.event) { - detail.event.preventDefault?.(); - detail.event.stopPropagation?.(); - } - - if (!selectedVideo) { - return; - } - - const playbackOptions = { - trigger: this.lastModalTrigger, - }; - - if (typeof selectedVideo.url === "string" && selectedVideo.url) { - playbackOptions.url = selectedVideo.url; - } - if (typeof selectedVideo.magnet === "string" && selectedVideo.magnet) { - playbackOptions.magnet = selectedVideo.magnet; - } - - if (typeof selectedVideo.id === "string" && selectedVideo.id) { - Promise.resolve( - this.playVideoByEventId(selectedVideo.id, playbackOptions) - ).catch((error) => { - devLogger.error( - "[Application] Failed to play selected similar video:", - error - ); - }); - return; - } - - Promise.resolve(this.playVideoWithFallback(playbackOptions)).catch( - (error) => { - devLogger.error( - "[Application] Failed to start playback for similar video:", - error - ); - } - ); - }; - this.videoModal.addEventListener( - "similar:select", - this.boundVideoModalSimilarSelectHandler - ); - this.boundVideoModalReactionHandler = (event) => { - const detail = event?.detail || {}; - this.handleVideoReaction(detail); - }; - this.videoModal.addEventListener( - "video:reaction", - this.boundVideoModalReactionHandler - ); - this.boundVideoModalContextActionHandler = (event) => { - const detail = event?.detail || {}; - const action = typeof detail.action === "string" ? detail.action : ""; - if (!action) { - return; - } - const dataset = { - ...(detail.dataset || {}), - }; - if (!dataset.context) { - dataset.context = "modal"; - } - this.handleMoreMenuAction(action, dataset); - }; - this.videoModal.addEventListener( - "video:context-action", - this.boundVideoModalContextActionHandler - ); - this.boundVideoModalCreatorHandler = () => { - this.openCreatorChannel(); - }; - this.videoModal.addEventListener( - "creator:navigate", - this.boundVideoModalCreatorHandler - ); - this.boundVideoModalZapHandler = (event) => { - this.zapController?.sendZap(event?.detail || {}); - }; - this.boundVideoModalZapOpenHandler = (event) => { - const requiresLogin = Boolean(event?.detail?.requiresLogin); - this.pendingModalZapOpen = requiresLogin; - - const openResult = this.zapController?.open({ requiresLogin }); - if (!openResult) { - event?.preventDefault?.(); - if (!requiresLogin) { - this.pendingModalZapOpen = false; - } - this.videoModal?.closeZapDialog?.({ - silent: true, - restoreFocus: false, - }); - return; - } - - this.pendingModalZapOpen = false; - }; - this.boundVideoModalZapCloseHandler = () => { - this.zapController?.close(); - }; - this.boundVideoModalZapAmountHandler = (event) => { - this.zapController?.setAmount(event?.detail?.amount); - }; - this.boundVideoModalZapCommentHandler = (event) => { - this.zapController?.setComment(event?.detail?.comment); - }; - this.videoModal.addEventListener( - "video:zap", - this.boundVideoModalZapHandler - ); - this.videoModal.addEventListener( - "zap:open", - this.boundVideoModalZapOpenHandler - ); - this.videoModal.addEventListener( - "zap:close", - this.boundVideoModalZapCloseHandler - ); - this.videoModal.addEventListener( - "zap:amount-change", - this.boundVideoModalZapAmountHandler - ); - this.videoModal.addEventListener( - "zap:comment-change", - this.boundVideoModalZapCommentHandler - ); - this.boundVideoModalZapWalletHandler = () => { - this.zapController?.handleWalletLink(); - }; - this.videoModal.addEventListener( - "zap:wallet-link", - this.boundVideoModalZapWalletHandler - ); - this.boundVideoModalCommentSubmitHandler = (event) => { - this.handleVideoModalCommentSubmit(event?.detail || {}); - }; - this.videoModal.addEventListener( - "comment:submit", - this.boundVideoModalCommentSubmitHandler - ); - this.boundVideoModalCommentRetryHandler = (event) => { - this.handleVideoModalCommentRetry(event?.detail || {}); - }; - this.videoModal.addEventListener( - "comment:retry", - this.boundVideoModalCommentRetryHandler - ); - this.boundVideoModalCommentLoadMoreHandler = (event) => { - this.handleVideoModalCommentLoadMore(event?.detail || {}); - }; - this.videoModal.addEventListener( - "comment:load-more", - this.boundVideoModalCommentLoadMoreHandler - ); - this.boundVideoModalCommentLoginHandler = (event) => { - this.handleVideoModalCommentLoginRequired(event?.detail || {}); - }; - this.videoModal.addEventListener( - "comment:login-required", - this.boundVideoModalCommentLoginHandler - ); - this.boundVideoModalCommentMuteHandler = (event) => { - this.handleVideoModalCommentMute(event?.detail || {}); - }; - this.videoModal.addEventListener( - "comment:mute-author", - this.boundVideoModalCommentMuteHandler - ); - - this.moreMenuController = new MoreMenuController({ - document, - accessControl, - userBlocks, - subscriptions, - clipboard: - typeof navigator !== "undefined" && navigator?.clipboard - ? navigator.clipboard - : null, - isDevMode, - designSystem: this.designSystemContext, - callbacks: { - getCurrentVideo: () => this.currentVideo, - getCurrentUserNpub: () => this.getCurrentUserNpub(), - getCurrentUserPubkey: () => this.pubkey, - canCurrentUserManageBlacklist: () => - this.canCurrentUserManageBlacklist(), - openCreatorChannel: () => this.openCreatorChannel(), - goToProfile: (author) => this.goToProfile(author), - showError: (message) => this.showError(message), - showSuccess: (message) => this.showSuccess(message), - safeEncodeNpub: (pubkey) => this.safeEncodeNpub(pubkey), - safeDecodeNpub: (npub) => this.safeDecodeNpub(npub), - buildShareUrlFromEventId: (eventId) => - this.buildShareUrlFromEventId(eventId), - handleRemoveHistoryAction: (payload) => - this.handleRemoveHistoryAction(payload), - handleRepostAction: (payload) => this.handleRepostAction(payload), - handleMirrorAction: (payload) => this.handleMirrorAction(payload), - handleEnsurePresenceAction: (payload) => - this.handleEnsurePresenceAction(payload), - loadVideos: () => this.loadVideos(), - refreshAllVideoGrids: (options) => this.refreshAllVideoGrids(options), - onUserBlocksUpdated: () => { - if (this.profileController) { - try { - this.profileController.populateBlockedList(); - } catch (error) { - devLogger.warn( - "[profileModal] Failed to refresh blocked list after update:", - error, - ); - } - } - }, - }, - }); - this.moreMenuController.setVideoModal(this.videoModal); - this.videoSettingsPopovers = new Map(); - this.tagPreferencePopovers = new Map(); - - // Hide/Show Subscriptions Link - this.subscriptionsLink = null; - - // Notification containers - this.notificationPortal = - document.getElementById("notificationPortal") || null; - this.errorContainer = document.getElementById("errorContainer") || null; - this.successContainer = document.getElementById("successContainer") || null; - this.statusContainer = document.getElementById("statusContainer") || null; - this.statusMessage = - this.statusContainer?.querySelector("[data-status-message]") || null; - this.statusAutoHideHandle = null; - this.lastExperimentalWarningKey = null; - this.lastExperimentalWarningAt = 0; - - // Auth state - this.pubkey = null; - this.currentMagnetUri = null; - this.currentVideo = null; - this.currentVideoPointer = null; - this.currentVideoPointerKey = null; - this.videoSubscription = this.nostrService.getVideoSubscription() || null; - this.videoList = null; - this.videoListPopularTags = null; - this.modalViewCountUnsub = null; - this.modalReactionUnsub = null; - this.modalReactionPointerKey = null; - this.modalReactionState = { - counts: { "+": 0, "-": 0 }, - total: 0, - userReaction: "", - }; - - // Videos stored as a Map (key=event.id) - this.videosMap = this.nostrService.getVideosMap(); - this.unsubscribeFromNostrService = this.nostrService.on( - "subscription:changed", - ({ subscription }) => { - this.videoSubscription = subscription || null; - } - ); - this.boundNwcSettingsToastHandler = null; - Object.defineProperty(this, "savedProfiles", { - configurable: false, - enumerable: false, - get() { - return getSavedProfiles(); - }, - set(next) { - setSavedProfiles(Array.isArray(next) ? next : [], { - persist: false, - persistActive: false, - }); - }, - }); - - Object.defineProperty(this, "activeProfilePubkey", { - configurable: false, - enumerable: false, - get() { - return getActiveProfilePubkey(); - }, - set(value) { - setStoredActiveProfilePubkey(value, { persist: false }); - }, - }); - - Object.defineProperty(this, "profileCache", { - configurable: false, - enumerable: false, - get() { - return getProfileCacheMap(); - }, - }); - - if (typeof window !== "undefined" && typeof window.addEventListener === "function") { - this.boundNwcSettingsToastHandler = (event) => { - const detail = event?.detail || {}; - if (detail.source !== "nwc-settings") { - return; - } - const rawMessage = - typeof detail.message === "string" ? detail.message.trim() : ""; - const message = rawMessage || "Wallet settings storage issue detected."; - this.log(`[nwcSettings] ${message}`); - if (detail.variant === "warning") { - this.showStatus(message); - if (typeof window !== "undefined" && typeof window.setTimeout === "function") { - window.setTimeout(() => { - this.showStatus(""); - }, 5000); - } - } else { - this.showError(message); - } - }; - window.addEventListener("bitvid:toast", this.boundNwcSettingsToastHandler); - } - - const videoListViewConfig = { - document, - mediaLoader: this.mediaLoader, - badgeHelpers: { - attachHealthBadges: this.attachHealthBadgesWithCache, - attachUrlHealthBadges: (container, onCheck) => - attachUrlHealthBadges(container, onCheck), - }, - formatters: { - formatTimeAgo: (timestamp) => this.formatTimeAgo(timestamp), - formatViewCountLabel: (total) => this.formatViewCountLabel(total), - }, - helpers: { - escapeHtml: (value) => this.escapeHTML(value), - isMagnetSupported: (magnet) => isValidMagnetUri(magnet), - toLocaleString: (value) => - typeof value === "number" ? value.toLocaleString() : value, - }, + 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, }, - state: { - loadedThumbnails: this.loadedThumbnails, - videosMap: this.videosMap, - urlHealthByVideoId: this.urlHealthSnapshots, - streamHealthByVideoId: this.streamHealthSnapshots, - }, - utils: { - dedupeVideos: (videos) => this.dedupeVideosByRoot(videos), - getAllEvents: () => Array.from(nostrClient.allEvents.values()), - hasOlderVersion: (video, events) => this.hasOlderVersion(video, events), - derivePointerInfo: (video) => this.deriveVideoPointerInfo(video), - persistWatchHistoryMetadata: (video, pointerInfo) => - this.persistWatchHistoryMetadataForVideo(video, pointerInfo), - getShareUrlBase: () => this.getShareUrlBase(), - buildShareUrlFromNevent: (nevent) => this.buildShareUrlFromNevent(nevent), - buildShareUrlFromEventId: (eventId) => this.buildShareUrlFromEventId(eventId), - getKnownVideoPostedAt: (video) => this.getKnownVideoPostedAt(video), - resolveVideoPostedAt: (video) => this.resolveVideoPostedAt(video), - canManageBlacklist: () => this.canCurrentUserManageBlacklist(), - canEditVideo: (video) => video?.pubkey === this.pubkey, - canDeleteVideo: (video) => video?.pubkey === this.pubkey, - batchFetchProfiles: (authorSet) => this.batchFetchProfiles(authorSet), - bindThumbnailFallbacks: (container) => this.bindThumbnailFallbacks(container), - handleUrlHealthBadge: (payload) => this.handleUrlHealthBadge(payload), - refreshDiscussionCounts: (videos, { container } = {}) => - this.refreshVideoDiscussionCounts(videos, { - videoListRoot: container || this.videoList || null, - }), - ensureGlobalMoreMenuHandlers: () => this.ensureGlobalMoreMenuHandlers(), - requestMoreMenu: (detail = {}) => this.requestMoreMenu(detail), - closeMoreMenu: (detail = {}) => this.closeMoreMenu(detail), - requestSettingsMenu: (detail = {}) => - this.requestVideoSettingsMenu(detail), - closeSettingsMenu: (detail = {}) => - this.closeVideoSettingsMenu(detail), - closeAllMenus: (options) => this.closeAllMoreMenus(options), - }, - renderers: { - getLoadingMarkup: (message) => getSidebarLoadingMarkup(message), - }, - allowNsfw: ALLOW_NSFW_CONTENT === true, - designSystem: this.designSystemContext, - }; - this.videoListView = - (typeof ui.videoListView === "function" - ? ui.videoListView({ app: this, config: videoListViewConfig }) - : ui.videoListView) || - new VideoListView(videoListViewConfig); - - if ( - this.videoListView && - typeof this.videoListView.setTagPreferenceStateResolver === "function" - ) { - this.videoListView.setTagPreferenceStateResolver((tag) => - this.getTagPreferenceState(tag), - ); - } - - if ( - this.videoListView && - typeof this.videoListView.setTagActivationHandler === "function" - ) { - this.videoListView.setTagActivationHandler((detail = {}) => - this.handleTagPreferenceActivation(detail), - ); - } - - this.videoListViewPlaybackHandler = ({ - videoId, - url, - magnet, - trigger, - }) => { - if (videoId) { - Promise.resolve( - this.playVideoByEventId(videoId, { url, magnet, trigger }) - ).catch((error) => { - devLogger.error("[VideoListView] Failed to play by event id:", error); - }); - return; - } - Promise.resolve( - this.playVideoWithFallback({ url, magnet, trigger }) - ).catch( - (error) => { - devLogger.error("[VideoListView] Failed to start playback:", error); - } - ); - }; - this.videoListView.setPlaybackHandler(this.videoListViewPlaybackHandler); - - this.videoListViewEditHandler = ({ video, index, trigger }) => { - if (!video?.id) { - return; - } - this.handleEditVideo({ - eventId: video.id, - index: Number.isFinite(index) ? index : null, - triggerElement: trigger, - video, - }); - }; - this.videoListView.setEditHandler(this.videoListViewEditHandler); - - this.videoListViewRevertHandler = ({ video, index, trigger }) => { - if (!video?.id) { - return; - } - this.handleRevertVideo({ - eventId: video.id, - index: Number.isFinite(index) ? index : null, - triggerElement: trigger, - video, - }); - }; - this.videoListView.setRevertHandler(this.videoListViewRevertHandler); - - this.videoListViewDeleteHandler = ({ video, index }) => { - if (!video?.id) { - return; - } - this.handleFullDeleteVideo({ - eventId: video.id, - index: Number.isFinite(index) ? index : null, - video, - }); - }; - this.videoListView.setDeleteHandler(this.videoListViewDeleteHandler); - - this.videoListView.setModerationOverrideHandler((detail = {}) => - this.handleModerationOverride(detail) - ); - this.videoListView.setModerationBlockHandler((detail = {}) => - this.handleModerationBlock(detail) - ); - this.videoListView.setModerationHideHandler((detail = {}) => - this.handleModerationHide(detail) - ); - - if (this.moreMenuController) { - this.moreMenuController.attachVideoListView(this.videoListView); - } - - // NEW: reference to the login modal's close button - this.closeLoginModalBtn = - document.getElementById("closeLoginModal") || null; - - this.appChromeController = new AppChromeController({ - elements: { - logoutButton: this.logoutButton, - profileButton: this.profileButton, - uploadButton: this.uploadButton, - loginButton: this.loginButton, - closeLoginModalButton: this.closeLoginModalBtn, - }, - callbacks: { - requestLogout: () => this.requestLogout(), - showError: (message) => this.showError(message), - showProfileModal: () => - this.profileController && - typeof this.profileController.show === "function" - ? this.profileController.show() - : null, - openUploadModal: ({ triggerElement }) => { - if (this.uploadModal) { - this.uploadModal.open({ triggerElement }); - } - }, - onLoginModalOpened: () => setGlobalModalState("login", true), - onLoginModalClosed: () => setGlobalModalState("login", false), - flushWatchHistory: (reason, context) => - this.flushWatchHistory(reason, context), - cleanup: () => this.cleanup(), - hideModal: () => this.hideModal(), - }, - utilities: { - prepareLoginModal: () => - prepareStaticModal({ id: "loginModal" }) || - document.getElementById("loginModal"), - prepareApplicationModal: () => - prepareStaticModal({ id: "nostrFormModal" }) || - document.getElementById("nostrFormModal"), - openModal: (modal, options) => openStaticModal(modal, options), - closeModal: (modalId) => closeStaticModal(modalId), - }, - environment: { - document: typeof document !== "undefined" ? document : null, - window: typeof window !== "undefined" ? window : null, - }, - logger: devLogger, }); - // Build a set of blacklisted event IDs (hex) from nevent strings, skipping empties - this.blacklistedEventIds = new Set(); - for (const neventStr of ADMIN_INITIAL_EVENT_BLACKLIST) { - // Skip any empty or obviously invalid strings - if (!neventStr || neventStr.trim().length < 8) { - continue; - } - try { - const decoded = window.NostrTools.nip19.decode(neventStr); - if (decoded.type === "nevent" && decoded.data.id) { - this.blacklistedEventIds.add(decoded.data.id); - } - } catch (err) { - devLogger.error( - "[Application] Invalid nevent in blacklist:", - neventStr, - err - ); - } - } + const { modalManager } = this.bootstrapper.initialize(); + this.modalManager = modalManager; this.unsubscribeFromPubkeyState = subscribeToAppStateKey( "pubkey", @@ -5922,6 +4544,27 @@ class Application { } } } + + this.teardownModalCommentSubscription({ resetUi: false }); + + 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."); @@ -11093,217 +9736,15 @@ class Application { this.unsubscribeFromNostrService = null; } - if (this.uploadModal && this.boundUploadSubmitHandler) { - this.uploadModal.removeEventListener( - "upload:submit", - this.boundUploadSubmitHandler - ); - this.boundUploadSubmitHandler = null; - } - if (typeof this.uploadModal?.destroy === "function") { + this.teardownModalCommentSubscription({ resetUi: false }); + + if (this.modalManager) { try { - this.uploadModal.destroy(); + this.modalManager.teardown(); } catch (error) { - devLogger.warn("[Application] Failed to destroy upload modal:", error); - } - } - - if (this.editModal) { - if (this.boundEditModalSubmitHandler) { - this.editModal.removeEventListener( - "video:edit-submit", - this.boundEditModalSubmitHandler - ); - this.boundEditModalSubmitHandler = null; - } - if (this.boundEditModalCancelHandler) { - this.editModal.removeEventListener( - "video:edit-cancel", - this.boundEditModalCancelHandler - ); - this.boundEditModalCancelHandler = null; - } - if (typeof this.editModal.destroy === "function") { - try { - this.editModal.destroy(); - } catch (error) { - devLogger.warn("[Application] Failed to destroy edit modal:", error); - } - } - } - - if (this.revertModal && this.boundRevertConfirmHandler) { - this.revertModal.removeEventListener( - "video:revert-confirm", - this.boundRevertConfirmHandler - ); - this.boundRevertConfirmHandler = null; - } - if (typeof this.revertModal?.destroy === "function") { - try { - this.revertModal.destroy(); - } catch (error) { - devLogger.warn("[Application] Failed to destroy revert modal:", error); - } - } - - if (this.deleteModal && this.boundDeleteConfirmHandler) { - this.deleteModal.removeEventListener( - "video:delete-confirm", - this.boundDeleteConfirmHandler - ); - this.boundDeleteConfirmHandler = null; - } - if (this.deleteModal && this.boundDeleteCancelHandler) { - this.deleteModal.removeEventListener( - "video:delete-cancel", - this.boundDeleteCancelHandler - ); - this.boundDeleteCancelHandler = null; - } - if (typeof this.deleteModal?.destroy === "function") { - try { - this.deleteModal.destroy(); - } catch (error) { - devLogger.warn("[Application] Failed to destroy delete modal:", error); - } - } - - if (this.videoModal) { - this.teardownModalCommentSubscription({ resetUi: false }); - if (this.boundVideoModalCloseHandler) { - this.videoModal.removeEventListener( - "modal:close", - this.boundVideoModalCloseHandler - ); - this.boundVideoModalCloseHandler = null; - } - if (this.boundVideoModalCopyHandler) { - this.videoModal.removeEventListener( - "video:copy-magnet", - this.boundVideoModalCopyHandler - ); - this.boundVideoModalCopyHandler = null; - } - if (this.boundVideoModalShareHandler) { - this.videoModal.removeEventListener( - "video:share", - this.boundVideoModalShareHandler - ); - this.boundVideoModalShareHandler = null; - } - if (this.boundVideoModalModerationOverrideHandler) { - this.videoModal.removeEventListener( - "video:moderation-override", - this.boundVideoModalModerationOverrideHandler, - ); - this.boundVideoModalModerationOverrideHandler = null; - } - if (this.boundVideoModalTagActivateHandler) { - this.videoModal.removeEventListener( - "tag:activate", - this.boundVideoModalTagActivateHandler, - ); - this.boundVideoModalTagActivateHandler = null; - } - if (this.boundVideoModalSimilarSelectHandler) { - this.videoModal.removeEventListener( - "similar:select", - this.boundVideoModalSimilarSelectHandler - ); - this.boundVideoModalSimilarSelectHandler = null; - } - if (this.boundVideoModalCreatorHandler) { - this.videoModal.removeEventListener( - "creator:navigate", - this.boundVideoModalCreatorHandler - ); - this.boundVideoModalCreatorHandler = null; - } - if (this.boundVideoModalZapHandler) { - this.videoModal.removeEventListener( - "video:zap", - this.boundVideoModalZapHandler - ); - this.boundVideoModalZapHandler = null; - } - if (this.boundVideoModalZapOpenHandler) { - this.videoModal.removeEventListener( - "zap:open", - this.boundVideoModalZapOpenHandler - ); - this.boundVideoModalZapOpenHandler = null; - } - if (this.boundVideoModalZapCloseHandler) { - this.videoModal.removeEventListener( - "zap:close", - this.boundVideoModalZapCloseHandler - ); - this.boundVideoModalZapCloseHandler = null; - } - if (this.boundVideoModalZapAmountHandler) { - this.videoModal.removeEventListener( - "zap:amount-change", - this.boundVideoModalZapAmountHandler - ); - this.boundVideoModalZapAmountHandler = null; - } - if (this.boundVideoModalZapCommentHandler) { - this.videoModal.removeEventListener( - "zap:comment-change", - this.boundVideoModalZapCommentHandler - ); - this.boundVideoModalZapCommentHandler = null; - } - if (this.boundVideoModalZapWalletHandler) { - this.videoModal.removeEventListener( - "zap:wallet-link", - this.boundVideoModalZapWalletHandler - ); - this.boundVideoModalZapWalletHandler = null; - } - if (this.boundVideoModalCommentSubmitHandler) { - this.videoModal.removeEventListener( - "comment:submit", - this.boundVideoModalCommentSubmitHandler - ); - this.boundVideoModalCommentSubmitHandler = null; - } - if (this.boundVideoModalCommentRetryHandler) { - this.videoModal.removeEventListener( - "comment:retry", - this.boundVideoModalCommentRetryHandler - ); - this.boundVideoModalCommentRetryHandler = null; - } - if (this.boundVideoModalCommentLoadMoreHandler) { - this.videoModal.removeEventListener( - "comment:load-more", - this.boundVideoModalCommentLoadMoreHandler - ); - this.boundVideoModalCommentLoadMoreHandler = null; - } - if (this.boundVideoModalCommentLoginHandler) { - this.videoModal.removeEventListener( - "comment:login-required", - this.boundVideoModalCommentLoginHandler - ); - this.boundVideoModalCommentLoginHandler = null; - } - if (this.boundVideoModalCommentMuteHandler) { - this.videoModal.removeEventListener( - "comment:mute-author", - this.boundVideoModalCommentMuteHandler - ); - this.boundVideoModalCommentMuteHandler = null; - } - if (typeof this.videoModal.destroy === "function") { - try { - this.videoModal.destroy(); - } catch (error) { - devLogger.warn("[Application] Failed to destroy video modal:", error); - } + devLogger.warn("[Application] Modal teardown failed:", error); } + this.modalManager = null; } this.closeTagPreferenceMenus({ restoreFocus: false }); @@ -11315,20 +9756,22 @@ class Application { 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; + 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) { @@ -11370,6 +9813,14 @@ class Application { } 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({ diff --git a/js/ui/ModalManager.js b/js/ui/ModalManager.js new file mode 100644 index 00000000..eaad078e --- /dev/null +++ b/js/ui/ModalManager.js @@ -0,0 +1,810 @@ +import { devLogger } from "../utils/logger.js"; +import { removeTrackingScripts, escapeHTML as escapeHtml } from "../utils/domUtils.js"; +import { setModalState as setGlobalModalState } from "../state/appState.js"; +import { truncateMiddle, formatShortNpub } from "../utils/formatters.js"; +import { showLoginRequiredToZapNotification } from "../payments/zapNotifications.js"; +import { VideoModal } from "./components/VideoModal.js"; +import { RevertModal } from "./components/RevertModal.js"; +import initUploadModal from "./initUploadModal.js"; +import initEditModal from "./initEditModal.js"; +import initDeleteModal from "./initDeleteModal.js"; +import ZapController from "./zapController.js"; +import { nostrClient } from "../nostrClientFacade.js"; + +export default class ModalManager { + constructor({ + app, + ui = {}, + documentRef = typeof document !== "undefined" ? document : null, + assets = {}, + } = {}) { + this.app = app; + this.ui = ui; + this.document = documentRef; + this.assets = { + fallbackThumbnailSrc: assets.fallbackThumbnailSrc || null, + }; + + this.uploadModal = null; + this.uploadModalEvents = null; + this.uploadSubmitHandler = null; + + this.editModal = null; + this.editModalEvents = null; + this.editSubmitHandler = null; + this.editCancelHandler = null; + + this.revertModal = null; + this.revertConfirmHandler = null; + + this.deleteModal = null; + this.deleteModalEvents = null; + this.deleteConfirmHandler = null; + this.deleteCancelHandler = null; + + this.videoModal = null; + this.videoModalHandlers = {}; + + this.zapController = null; + } + + initialize() { + const app = this.app; + const doc = this.document; + const modalContainer = doc?.getElementById("modalContainer") || null; + + const uploadModalSetup = initUploadModal({ + app, + uploadModalOverride: this.ui.uploadModal, + container: modalContainer, + services: { + authService: app.authService, + r2Service: app.r2Service, + }, + utilities: { + removeTrackingScripts, + setGlobalModalState, + }, + callbacks: { + publishVideoNote: (payload, options) => + app.publishVideoNote(payload, options), + showError: (message) => app.showError(message), + showSuccess: (message) => app.showSuccess(message), + getCurrentPubkey: () => app.pubkey, + safeEncodeNpub: (pubkey) => app.safeEncodeNpub(pubkey), + onSubmit: (event) => app.handleUploadSubmitEvent(event), + }, + }); + this.uploadModal = uploadModalSetup.modal; + this.uploadModalEvents = uploadModalSetup.events; + this.uploadSubmitHandler = uploadModalSetup.handlers.submit; + app.uploadModal = this.uploadModal; + app.uploadModalEvents = this.uploadModalEvents; + + const editModalSetup = initEditModal({ + app, + editModalOverride: this.ui.editModal, + container: modalContainer, + services: { + getMode: ({ video } = {}) => { + const candidate = + typeof video?.mode === "string" ? video.mode.trim().toLowerCase() : ""; + return candidate === "dev" ? "dev" : "live"; + }, + sanitizers: { + text: (value) => (typeof value === "string" ? value.trim() : ""), + url: (value) => (typeof value === "string" ? value.trim() : ""), + magnet: (value) => (typeof value === "string" ? value.trim() : ""), + checkbox: (value) => !!value, + }, + }, + utilities: { + removeTrackingScripts, + setGlobalModalState, + escapeHtml: (value) => escapeHtml(value), + }, + callbacks: { + showError: (message) => app.showError(message), + onSubmit: (event) => app.handleEditModalSubmit(event), + onCancel: () => app.showError(""), + }, + }); + + this.editModal = editModalSetup.modal; + this.editModalEvents = editModalSetup.events; + this.editSubmitHandler = editModalSetup.handlers.submit; + this.editCancelHandler = editModalSetup.handlers.cancel; + app.editModal = this.editModal; + app.editModalEvents = this.editModalEvents; + + this.revertModal = + (typeof this.ui.revertModal === "function" + ? this.ui.revertModal({ app }) + : this.ui.revertModal) || + new RevertModal({ + removeTrackingScripts, + setGlobalModalState, + formatAbsoluteTimestamp: (timestamp) => app.formatAbsoluteTimestamp(timestamp), + formatTimeAgo: (timestamp) => app.formatTimeAgo(timestamp), + escapeHTML: (value) => app.escapeHTML(value), + truncateMiddle, + formatShortNpub: (value) => formatShortNpub(value), + fallbackThumbnailSrc: this.assets.fallbackThumbnailSrc, + container: modalContainer, + }); + + this.revertConfirmHandler = (event) => { + app.handleRevertModalConfirm(event); + }; + this.revertModal.addEventListener( + "video:revert-confirm", + this.revertConfirmHandler, + ); + app.revertModal = this.revertModal; + + const deleteModalSetup = initDeleteModal({ + app, + deleteModalOverride: this.ui.deleteModal, + container: modalContainer, + utilities: { + removeTrackingScripts, + setGlobalModalState, + truncateMiddle, + }, + callbacks: { + onConfirm: (event) => app.handleDeleteModalConfirm(event), + onCancel: () => app.showError(""), + }, + }); + + this.deleteModal = deleteModalSetup.modal; + this.deleteModalEvents = deleteModalSetup.events; + this.deleteConfirmHandler = deleteModalSetup.handlers.confirm; + this.deleteCancelHandler = deleteModalSetup.handlers.cancel; + app.deleteModal = this.deleteModal; + app.deleteModalEvents = this.deleteModalEvents; + + this.videoModal = + (typeof this.ui.videoModal === "function" + ? this.ui.videoModal({ app }) + : this.ui.videoModal) || + new VideoModal({ + removeTrackingScripts, + setGlobalModalState, + document: doc, + logger: { + log: (message, ...args) => app.log(message, ...args), + }, + mediaLoader: app.mediaLoader, + assets: { + fallbackThumbnailSrc: this.assets.fallbackThumbnailSrc, + }, + state: { + loadedThumbnails: app.loadedThumbnails, + }, + helpers: { + safeEncodeNpub: (pubkey) => app.safeEncodeNpub(pubkey), + formatShortNpub: (value) => formatShortNpub(value), + }, + }); + + if ( + this.videoModal && + typeof this.videoModal.setMediaLoader === "function" + ) { + this.videoModal.setMediaLoader(app.mediaLoader); + } + if ( + this.videoModal && + typeof this.videoModal.setTagPreferenceStateResolver === "function" + ) { + this.videoModal.setTagPreferenceStateResolver((tag) => + app.getTagPreferenceState(tag), + ); + } + + this.zapController = new ZapController({ + videoModal: this.videoModal, + getCurrentVideo: () => app.currentVideo, + nwcSettings: app.nwcSettingsService, + isUserLoggedIn: () => app.isUserLoggedIn(), + hasSessionActor: () => + Boolean( + typeof nostrClient?.sessionActor?.pubkey === "string" && + nostrClient.sessionActor.pubkey.trim(), + ), + notifyLoginRequired: () => + showLoginRequiredToZapNotification({ + app, + document: app.statusContainer?.ownerDocument || doc, + }), + splitAndZap: (...args) => app.splitAndZap(...args), + payments: app.payments, + callbacks: { + onSuccess: (message) => app.showSuccess(message), + onError: (message) => app.showError(message), + }, + requestWalletPane: () => app.openWalletPane(), + }); + app.zapController = this.zapController; + + this.videoModalHandlers.close = () => { + app.hideModal(); + }; + this.videoModal.addEventListener( + "modal:close", + this.videoModalHandlers.close, + ); + + this.videoModalHandlers.copy = () => { + app.handleCopyMagnet(); + }; + this.videoModal.addEventListener( + "video:copy-magnet", + this.videoModalHandlers.copy, + ); + + this.videoModalHandlers.share = () => { + app.shareActiveVideo(); + }; + this.videoModal.addEventListener( + "video:share", + this.videoModalHandlers.share, + ); + + this.videoModalHandlers.moderationOverride = (event) => { + const detail = event?.detail || {}; + const targetVideo = + detail && typeof detail.video === "object" + ? detail.video + : app.currentVideo || null; + if (!targetVideo) { + const trigger = detail?.trigger; + if (trigger) { + trigger.disabled = false; + trigger.removeAttribute("aria-busy"); + } + return; + } + + const handled = app.handleModerationOverride({ + video: targetVideo, + card: detail?.card || null, + }); + + if (handled === false) { + const trigger = detail?.trigger; + if (trigger) { + trigger.disabled = false; + trigger.removeAttribute("aria-busy"); + } + } + }; + this.videoModal.addEventListener( + "video:moderation-override", + this.videoModalHandlers.moderationOverride, + ); + + this.videoModalHandlers.moderationBlock = (event) => { + const detail = event?.detail || {}; + const targetVideo = + detail && typeof detail.video === "object" + ? detail.video + : app.currentVideo || null; + const trigger = detail?.trigger || null; + + if (!targetVideo) { + if (trigger) { + trigger.disabled = false; + trigger.removeAttribute("aria-busy"); + } + return; + } + + Promise.resolve( + app.handleModerationBlock({ + video: targetVideo, + card: detail?.card || null, + }), + ) + .then((handled) => { + if (handled === false && trigger) { + trigger.disabled = false; + trigger.removeAttribute("aria-busy"); + } + }) + .catch((error) => { + devLogger.warn( + "[Application] Failed to handle modal moderation block:", + error, + ); + if (trigger) { + trigger.disabled = false; + trigger.removeAttribute("aria-busy"); + } + }); + }; + this.videoModal.addEventListener( + "video:moderation-block", + this.videoModalHandlers.moderationBlock, + ); + + this.videoModalHandlers.moderationHide = (event) => { + const detail = event?.detail || {}; + const targetVideo = + detail && typeof detail.video === "object" + ? detail.video + : app.currentVideo || null; + const trigger = detail?.trigger || null; + + if (!targetVideo) { + if (trigger) { + trigger.disabled = false; + trigger.removeAttribute("aria-busy"); + } + return; + } + + Promise.resolve( + app.handleModerationHide({ + video: targetVideo, + card: detail?.card || null, + }), + ) + .then((handled) => { + if (handled === false && trigger) { + trigger.disabled = false; + trigger.removeAttribute("aria-busy"); + } + }) + .catch((error) => { + devLogger.warn( + "[Application] Failed to handle modal moderation hide:", + error, + ); + if (trigger) { + trigger.disabled = false; + trigger.removeAttribute("aria-busy"); + } + }); + }; + this.videoModal.addEventListener( + "video:moderation-hide", + this.videoModalHandlers.moderationHide, + ); + + this.videoModalHandlers.tagActivate = (event) => { + const detail = event?.detail || {}; + const nativeEvent = detail?.nativeEvent || null; + if (nativeEvent) { + nativeEvent.preventDefault?.(); + nativeEvent.stopPropagation?.(); + } + + app.handleTagPreferenceActivation({ + tag: detail?.tag, + trigger: detail?.trigger || null, + context: "modal", + video: detail?.video || app.currentVideo || null, + event: nativeEvent, + }); + }; + this.videoModal.addEventListener( + "tag:activate", + this.videoModalHandlers.tagActivate, + ); + + this.videoModalHandlers.similarSelect = (event) => { + const detail = event?.detail || {}; + const selectedVideo = + detail && typeof detail.video === "object" ? detail.video : null; + const triggerCandidate = + detail?.event?.currentTarget || + (detail?.card && typeof detail.card.getRoot === "function" + ? detail.card.getRoot() + : null); + + app.setLastModalTrigger(triggerCandidate || null); + + if (detail?.event) { + detail.event.preventDefault?.(); + detail.event.stopPropagation?.(); + } + + if (!selectedVideo) { + return; + } + + app.openVideoModal({ + video: selectedVideo, + triggerElement: triggerCandidate || null, + source: detail?.source || "modal", + }); + }; + this.videoModal.addEventListener( + "similar:select", + this.videoModalHandlers.similarSelect, + ); + + this.videoModalHandlers.reaction = (event) => { + const detail = event?.detail || {}; + app.handleVideoReaction(detail); + }; + this.videoModal.addEventListener( + "video:reaction", + this.videoModalHandlers.reaction, + ); + + this.videoModalHandlers.contextAction = (event) => { + const detail = event?.detail || {}; + const action = typeof detail.action === "string" ? detail.action : ""; + if (!action) { + return; + } + const dataset = { + ...(detail.dataset || {}), + }; + if (!dataset.context) { + dataset.context = "modal"; + } + app.handleMoreMenuAction(action, dataset); + }; + this.videoModal.addEventListener( + "video:context-action", + this.videoModalHandlers.contextAction, + ); + + this.videoModalHandlers.creatorNavigate = () => { + app.openCreatorChannel(); + }; + this.videoModal.addEventListener( + "creator:navigate", + this.videoModalHandlers.creatorNavigate, + ); + + this.videoModalHandlers.zap = (event) => { + this.zapController?.sendZap(event?.detail || {}); + }; + this.videoModal.addEventListener( + "video:zap", + this.videoModalHandlers.zap, + ); + + this.videoModalHandlers.zapOpen = (event) => { + const requiresLogin = Boolean(event?.detail?.requiresLogin); + app.pendingModalZapOpen = requiresLogin; + + const openResult = this.zapController?.open({ requiresLogin }); + if (!openResult) { + event?.preventDefault?.(); + if (!requiresLogin) { + app.pendingModalZapOpen = false; + } + this.videoModal?.closeZapDialog?.({ + silent: true, + restoreFocus: false, + }); + return; + } + + app.pendingModalZapOpen = false; + }; + this.videoModal.addEventListener( + "zap:open", + this.videoModalHandlers.zapOpen, + ); + + this.videoModalHandlers.zapClose = () => { + this.zapController?.close(); + }; + this.videoModal.addEventListener( + "zap:close", + this.videoModalHandlers.zapClose, + ); + + this.videoModalHandlers.zapAmount = (event) => { + this.zapController?.setAmount(event?.detail?.amount); + }; + this.videoModal.addEventListener( + "zap:amount-change", + this.videoModalHandlers.zapAmount, + ); + + this.videoModalHandlers.zapComment = (event) => { + this.zapController?.setComment(event?.detail?.comment); + }; + this.videoModal.addEventListener( + "zap:comment-change", + this.videoModalHandlers.zapComment, + ); + + this.videoModalHandlers.zapWallet = () => { + this.zapController?.handleWalletLink(); + }; + this.videoModal.addEventListener( + "zap:wallet-link", + this.videoModalHandlers.zapWallet, + ); + + this.videoModalHandlers.commentSubmit = (event) => { + app.handleVideoModalCommentSubmit(event?.detail || {}); + }; + this.videoModal.addEventListener( + "comment:submit", + this.videoModalHandlers.commentSubmit, + ); + + this.videoModalHandlers.commentRetry = (event) => { + app.handleVideoModalCommentRetry(event?.detail || {}); + }; + this.videoModal.addEventListener( + "comment:retry", + this.videoModalHandlers.commentRetry, + ); + + this.videoModalHandlers.commentLoadMore = (event) => { + app.handleVideoModalCommentLoadMore(event?.detail || {}); + }; + this.videoModal.addEventListener( + "comment:load-more", + this.videoModalHandlers.commentLoadMore, + ); + + this.videoModalHandlers.commentLogin = (event) => { + app.handleVideoModalCommentLoginRequired(event?.detail || {}); + }; + this.videoModal.addEventListener( + "comment:login-required", + this.videoModalHandlers.commentLogin, + ); + + this.videoModalHandlers.commentMute = (event) => { + app.handleVideoModalCommentMute(event?.detail || {}); + }; + this.videoModal.addEventListener( + "comment:mute-author", + this.videoModalHandlers.commentMute, + ); + + app.videoModal = this.videoModal; + } + + teardown() { + const app = this.app; + + if (this.uploadModal && this.uploadSubmitHandler) { + try { + this.uploadModal.removeEventListener( + "upload:submit", + this.uploadSubmitHandler, + ); + } catch (error) { + devLogger.warn("[ModalManager] Failed to remove upload submit handler:", error); + } + } + if (typeof this.uploadModal?.destroy === "function") { + try { + this.uploadModal.destroy(); + } catch (error) { + devLogger.warn("[ModalManager] Failed to destroy upload modal:", error); + } + } + + if (this.editModal) { + if (this.editSubmitHandler) { + try { + this.editModal.removeEventListener( + "video:edit-submit", + this.editSubmitHandler, + ); + } catch (error) { + devLogger.warn( + "[ModalManager] Failed to remove edit submit handler:", + error, + ); + } + } + if (this.editCancelHandler) { + try { + this.editModal.removeEventListener( + "video:edit-cancel", + this.editCancelHandler, + ); + } catch (error) { + devLogger.warn( + "[ModalManager] Failed to remove edit cancel handler:", + error, + ); + } + } + if (typeof this.editModal.destroy === "function") { + try { + this.editModal.destroy(); + } catch (error) { + devLogger.warn("[ModalManager] Failed to destroy edit modal:", error); + } + } + } + + if (this.revertModal && this.revertConfirmHandler) { + try { + this.revertModal.removeEventListener( + "video:revert-confirm", + this.revertConfirmHandler, + ); + } catch (error) { + devLogger.warn("[ModalManager] Failed to remove revert handler:", error); + } + this.revertConfirmHandler = null; + } + if (typeof this.revertModal?.destroy === "function") { + try { + this.revertModal.destroy(); + } catch (error) { + devLogger.warn("[ModalManager] Failed to destroy revert modal:", error); + } + } + + if (this.deleteModal) { + if (this.deleteConfirmHandler) { + try { + this.deleteModal.removeEventListener( + "video:delete-confirm", + this.deleteConfirmHandler, + ); + } catch (error) { + devLogger.warn( + "[ModalManager] Failed to remove delete confirm handler:", + error, + ); + } + } + if (this.deleteCancelHandler) { + try { + this.deleteModal.removeEventListener( + "video:delete-cancel", + this.deleteCancelHandler, + ); + } catch (error) { + devLogger.warn( + "[ModalManager] Failed to remove delete cancel handler:", + error, + ); + } + } + if (typeof this.deleteModal.destroy === "function") { + try { + this.deleteModal.destroy(); + } catch (error) { + devLogger.warn("[ModalManager] Failed to destroy delete modal:", error); + } + } + } + + if (this.videoModal) { + const entries = Object.entries(this.videoModalHandlers); + for (const [key, handler] of entries) { + if (!handler) { + continue; + } + let eventName = null; + switch (key) { + case "close": + eventName = "modal:close"; + break; + case "copy": + eventName = "video:copy-magnet"; + break; + case "share": + eventName = "video:share"; + break; + case "moderationOverride": + eventName = "video:moderation-override"; + break; + case "moderationBlock": + eventName = "video:moderation-block"; + break; + case "moderationHide": + eventName = "video:moderation-hide"; + break; + case "tagActivate": + eventName = "tag:activate"; + break; + case "similarSelect": + eventName = "similar:select"; + break; + case "reaction": + eventName = "video:reaction"; + break; + case "contextAction": + eventName = "video:context-action"; + break; + case "creatorNavigate": + eventName = "creator:navigate"; + break; + case "zap": + eventName = "video:zap"; + break; + case "zapOpen": + eventName = "zap:open"; + break; + case "zapClose": + eventName = "zap:close"; + break; + case "zapAmount": + eventName = "zap:amount-change"; + break; + case "zapComment": + eventName = "zap:comment-change"; + break; + case "zapWallet": + eventName = "zap:wallet-link"; + break; + case "commentSubmit": + eventName = "comment:submit"; + break; + case "commentRetry": + eventName = "comment:retry"; + break; + case "commentLoadMore": + eventName = "comment:load-more"; + break; + case "commentLogin": + eventName = "comment:login-required"; + break; + case "commentMute": + eventName = "comment:mute-author"; + break; + default: + break; + } + if (!eventName) { + continue; + } + try { + this.videoModal.removeEventListener(eventName, handler); + } catch (error) { + devLogger.warn( + `[ModalManager] Failed to remove handler for ${eventName}:`, + error, + ); + } + this.videoModalHandlers[key] = null; + } + + if (typeof this.videoModal.destroy === "function") { + try { + this.videoModal.destroy(); + } catch (error) { + devLogger.warn("[ModalManager] Failed to destroy video modal:", error); + } + } + } + + app.uploadModal = null; + app.uploadModalEvents = null; + app.editModal = null; + app.editModalEvents = null; + app.revertModal = null; + app.deleteModal = null; + app.deleteModalEvents = null; + app.videoModal = null; + app.zapController = null; + + this.uploadModal = null; + this.uploadModalEvents = null; + this.uploadSubmitHandler = null; + this.editModal = null; + this.editModalEvents = null; + this.editSubmitHandler = null; + this.editCancelHandler = null; + this.revertModal = null; + this.deleteModal = null; + this.deleteModalEvents = null; + this.deleteConfirmHandler = null; + this.deleteCancelHandler = null; + this.videoModal = null; + this.videoModalHandlers = {}; + this.zapController = null; + } +} diff --git a/js/ui/applicationBootstrap.js b/js/ui/applicationBootstrap.js new file mode 100644 index 00000000..d003f20b --- /dev/null +++ b/js/ui/applicationBootstrap.js @@ -0,0 +1,976 @@ +import { devLogger } from "../utils/logger.js"; +import { MediaLoader } from "../utils/mediaLoader.js"; +import { attachHealthBadges } from "../gridHealth.js"; +import { attachUrlHealthBadges } from "../urlHealthObserver.js"; +import { createWatchHistoryRenderer } from "../historyView.js"; +import WatchHistoryController from "./watchHistoryController.js"; +import WatchHistoryTelemetry from "../services/watchHistoryTelemetry.js"; +import PlaybackService from "../services/playbackService.js"; +import AuthService from "../services/authService.js"; +import DiscussionCountService from "../services/discussionCountService.js"; +import CommentThreadService from "../services/commentThreadService.js"; +import hashtagPreferences, { + HASHTAG_PREFERENCES_EVENTS, +} from "../services/hashtagPreferencesService.js"; +import NwcSettingsService from "../services/nwcSettingsService.js"; +import nostrService from "../services/nostrService.js"; +import watchHistoryService from "../watchHistoryService.js"; +import r2Service from "../services/r2Service.js"; +import { createFeedEngine } from "../feedEngine/index.js"; +import { + URL_FIRST_ENABLED, + ALLOW_NSFW_CONTENT, +} from "../constants.js"; +import { relayManager } from "../relayManager.js"; +import { userBlocks } from "../userBlocks.js"; +import { subscriptions } from "../subscriptions.js"; +import accessControl from "../accessControl.js"; +import moderationService from "../services/moderationService.js"; +import ProfileIdentityController from "./profileIdentityController.js"; +import VideoListViewController from "./videoListViewController.js"; +import ProfileModalController from "./profileModalController.js"; +import LoginModalController from "./loginModalController.js"; +import VideoListView from "./views/VideoListView.js"; +import MoreMenuController from "./moreMenuController.js"; +import AppChromeController from "./appChromeController.js"; +import { getSidebarLoadingMarkup } from "../sidebarLoading.js"; +import { isWatchHistoryDebugEnabled } from "../watchHistoryDebug.js"; +import { splitAndZap as splitAndZapDefault } from "../payments/zapSplit.js"; +import { + getDefaultModerationSettings, + getModerationSettings, + setModerationSettings, + resetModerationSettings, + persistSavedProfiles, + getSavedProfiles, + getActiveProfilePubkey, + setActiveProfilePubkey as setStoredActiveProfilePubkey, + getProfileCacheMap, + setSavedProfiles, +} from "../state/cache.js"; +import { nostrClient } from "../nostrClientFacade.js"; +import { ingestLocalViewEvent } from "../viewCounter.js"; +import { setModalState as setGlobalModalState } from "../state/appState.js"; +import { truncateMiddle, formatShortNpub } from "../utils/formatters.js"; +import { escapeHTML as escapeHtml, removeTrackingScripts } from "../utils/domUtils.js"; +import { deriveTorrentPlaybackConfig } from "../playbackUtils.js"; +import { isValidMagnetUri } from "../utils/magnetValidators.js"; +import { torrentClient } from "../webtorrent.js"; +import getAuthProvider, { + providers as authProviders, +} from "../services/authProviders/index.js"; +import { + MAX_WALLET_DEFAULT_ZAP, + ADMIN_SUPER_NPUB, + ADMIN_DM_IMAGE_URL, + BITVID_WEBSITE_URL, + isDevMode, +} from "../config.js"; +import { ADMIN_INITIAL_EVENT_BLACKLIST } from "../lists.js"; +import { + prepareStaticModal, + openStaticModal, + closeStaticModal, +} from "./components/staticModalAccessibility.js"; +import ModalManager from "./ModalManager.js"; + +export default class ApplicationBootstrap { + constructor({ + app, + services = {}, + ui = {}, + helpers = {}, + documentRef = typeof document !== "undefined" ? document : null, + windowRef = typeof window !== "undefined" ? window : null, + assets = {}, + } = {}) { + this.app = app; + this.services = services; + this.ui = ui; + this.helpers = helpers; + this.document = documentRef; + this.window = windowRef; + this.assets = assets; + + this.modalManager = null; + this.boundNwcSettingsToastHandler = null; + } + + initialize() { + const app = this.app; + const doc = this.document; + + app.loginButton = doc?.getElementById("loginButton") || null; + app.logoutButton = doc?.getElementById("logoutButton") || null; + app.userStatus = doc?.getElementById("userStatus") || null; + app.userPubKey = doc?.getElementById("userPubKey") || null; + + const mediaLoaderFactory = + typeof this.helpers.mediaLoaderFactory === "function" + ? this.helpers.mediaLoaderFactory + : () => new MediaLoader(); + app.mediaLoader = + this.helpers.mediaLoader instanceof MediaLoader + ? this.helpers.mediaLoader + : mediaLoaderFactory(); + app.loadedThumbnails = new Map(); + app.urlHealthSnapshots = new Map(); + app.streamHealthSnapshots = new Map(); + app.boundStreamHealthBadgeHandler = (detail) => + app.handleStreamHealthBadgeUpdate(detail); + app.attachHealthBadgesWithCache = (container) => { + attachHealthBadges(container, { + onUpdate: app.boundStreamHealthBadgeHandler, + }); + }; + app.defaultModerationSettings = getDefaultModerationSettings(); + app.moderationSettings = { ...app.defaultModerationSettings }; + app.relayManager = relayManager; + app.activeIntervals = []; + app.watchHistoryTelemetry = null; + app.authEventUnsubscribes = []; + app.unsubscribeFromNostrService = null; + app.designSystemContext = { + getMode: () => "new", + isNew: () => true, + }; + app.videoModalReadyPromise = null; + + app.pendingModalZapOpen = false; + app.videoListViewPlaybackHandler = null; + app.videoListViewEditHandler = null; + app.videoListViewRevertHandler = null; + app.videoListViewDeleteHandler = null; + app.deleteModal = null; + app.moreMenuController = null; + app.latestFeedMetadata = null; + app.lastModalTrigger = null; + app.modalCommentState = { + videoEventId: null, + videoDefinitionAddress: null, + parentCommentId: null, + }; + app.modalCommentProfiles = new Map(); + app.modalCommentLimit = 40; + app.modalCommentLoadPromise = null; + app.modalCommentPublishPromise = null; + app.uploadSubmitPromise = null; + app.pendingModeratedPlayback = null; + app.lastIdentityRefreshPromise = null; + + app.profileIdentityController = new ProfileIdentityController({ + callbacks: { + safeEncodeNpub: (pubkey) => app.safeEncodeNpub(pubkey), + formatShortNpub: (value) => formatShortNpub(value), + }, + logger: devLogger, + }); + app.videoListViewController = new VideoListViewController({ + getSidebarLoadingMarkup, + logger: devLogger, + }); + app.appChromeController = null; + + app.nostrService = this.services.nostrService || nostrService; + app.r2Service = this.services.r2Service || r2Service; + + app.feedEngine = + this.services.feedEngine || + createFeedEngine({ + logger: (...args) => { + if (!isWatchHistoryDebugEnabled()) { + return; + } + devLogger.info(...args); + }, + }); + app.payments = this.services.payments || null; + app.splitAndZap = + (this.services.payments && this.services.payments.splitAndZap) || + splitAndZapDefault; + + app.discussionCountService = + this.services.discussionCountService || new DiscussionCountService(); + + app.nwcSettingsService = + this.services.nwcSettingsService || + new NwcSettingsService({ + normalizeHexPubkey: (value) => app.normalizeHexPubkey(value), + getActivePubkey: () => app.pubkey, + payments: app.payments, + logger: { + warn: (...args) => devLogger.warn(...args), + }, + notifyError: (message) => app.showError(message), + maxWalletDefaultZap: MAX_WALLET_DEFAULT_ZAP, + }); + app.hashtagPreferences = this.services.hashtagPreferences || hashtagPreferences; + app.hashtagPreferencesSnapshot = app.createHashtagPreferencesSnapshot(); + app.hashtagPreferencesSnapshotSignature = + app.computeHashtagPreferencesSignature(app.hashtagPreferencesSnapshot); + app.hashtagPreferencesPublishInFlight = false; + app.hashtagPreferencesPublishPromise = null; + app.boundHashtagPreferencesChangeHandler = null; + app.unsubscribeFromHashtagPreferencesChange = null; + if ( + app.hashtagPreferences && + typeof app.hashtagPreferences.on === "function" + ) { + app.boundHashtagPreferencesChangeHandler = (detail) => + app.handleHashtagPreferencesChange(detail); + try { + app.unsubscribeFromHashtagPreferencesChange = + app.hashtagPreferences.on( + HASHTAG_PREFERENCES_EVENTS.CHANGE, + app.boundHashtagPreferencesChangeHandler, + ); + } catch (error) { + devLogger.warn( + "[Application] Failed to subscribe to hashtag preferences changes:", + error, + ); + } + } + if ( + app.nwcSettingsService && + typeof app.nwcSettingsService.setActivePubkeyGetter === "function" + ) { + app.nwcSettingsService.setActivePubkeyGetter(() => app.pubkey); + } + if ( + app.nwcSettingsService && + typeof app.nwcSettingsService.setPayments === "function" + ) { + app.nwcSettingsService.setPayments(app.payments); + } + if ( + app.feedEngine && + typeof app.feedEngine.run !== "function" && + typeof app.feedEngine.runFeed === "function" + ) { + app.feedEngine.run = (...args) => app.feedEngine.runFeed(...args); + } + app.registerRecentFeed(); + app.registerSubscriptionsFeed(); + app.registerWatchHistoryFeed(); + + app.watchHistoryController = new WatchHistoryController({ + watchHistoryService, + nostrClient, + showError: (message) => app.showError(message), + showSuccess: (message) => app.showSuccess(message), + dropWatchHistoryMetadata: (pointerKey) => + app.dropWatchHistoryMetadata(pointerKey), + getActivePubkey: () => + typeof app.pubkey === "string" && app.pubkey ? app.pubkey : "", + designSystem: app.designSystemContext, + }); + + app.watchHistoryTelemetry = new WatchHistoryTelemetry({ + watchHistoryService, + watchHistoryController: app.watchHistoryController, + nostrClient, + log: (message, ...args) => app.log(message, ...args), + normalizeHexPubkey: (value) => app.normalizeHexPubkey(value), + getActiveUserPubkey: () => app.pubkey, + ingestLocalViewEvent, + }); + + const playbackDependencies = { + torrentClient: this.services.torrentClient || torrentClient, + deriveTorrentPlaybackConfig: + this.services.deriveTorrentPlaybackConfig || deriveTorrentPlaybackConfig, + isValidMagnetUri: + this.services.isValidMagnetUri || isValidMagnetUri, + }; + + app.playbackService = + this.services.playbackService || + new PlaybackService({ + logger: devLogger, + torrentClient: playbackDependencies.torrentClient, + deriveTorrentPlaybackConfig: playbackDependencies.deriveTorrentPlaybackConfig, + isValidMagnetUri: playbackDependencies.isValidMagnetUri, + urlFirstEnabled: URL_FIRST_ENABLED, + analyticsCallbacks: { + "session-start": (detail) => { + const urlProvided = detail?.urlProvided ? "true" : "false"; + const magnetProvided = detail?.magnetProvided ? "true" : "false"; + const magnetUsable = detail?.magnetUsable ? "true" : "false"; + app.log( + `[playVideoWithFallback] Session start urlProvided=${urlProvided} magnetProvided=${magnetProvided} magnetUsable=${magnetUsable}`, + ); + }, + fallback: (detail) => { + if (detail?.reason) { + app.log( + `[playVideoWithFallback] Falling back to WebTorrent (${detail.reason}).`, + ); + } + }, + error: (detail) => { + if (detail?.error) { + app.log("[playVideoWithFallback] Playback error observed", detail.error); + } + }, + }, + }); + app.activePlaybackResultPromise = null; + app.activePlaybackSession = null; + + app.authService = + this.services.authService || + new AuthService({ + nostrClient, + userBlocks, + relayManager, + logger: devLogger, + accessControl, + authProviders: this.services.authProviders || authProviders, + getAuthProvider: this.services.getAuthProvider || getAuthProvider, + }); + app.authEventUnsubscribes.push( + app.authService.on("auth:login", (detail) => { + const maybePromise = app.handleAuthLogin(detail); + if (maybePromise && typeof maybePromise.then === "function") { + maybePromise.catch((error) => { + devLogger.error("Failed to process auth login event:", error); + }); + } + }), + ); + app.authEventUnsubscribes.push( + app.authService.on("auth:logout", (detail) => { + if (detail && typeof detail === "object") { + if (detail.__handled === true) { + return; + } + try { + detail.__handled = true; + } catch (error) { + devLogger.warn( + "Failed to mark auth logout detail as handled:", + error, + ); + } + } + + const maybePromise = app.handleAuthLogout(detail); + if (maybePromise && typeof maybePromise.then === "function") { + maybePromise.catch((error) => { + devLogger.error("Failed to process auth logout event:", error); + }); + } + }), + ); + app.authEventUnsubscribes.push( + app.authService.on("profile:updated", (detail) => { + try { + app.handleProfileUpdated(detail); + } catch (error) { + devLogger.warn( + "Failed to process profile update event:", + error, + ); + } + }), + ); + + app.commentThreadService = + this.services.commentThreadService || + new CommentThreadService({ + fetchVideoComments: (target, options) => + nostrClient?.fetchVideoComments?.(target, options), + subscribeVideoComments: (target, options) => + nostrClient?.subscribeVideoComments?.(target, options), + getProfileCacheEntry: (pubkey) => app.getProfileCacheEntry(pubkey), + batchFetchProfiles: (pubkeys) => app.batchFetchProfiles(pubkeys), + logger: { + warn: (...args) => devLogger.warn(...args), + }, + }); + app.boundCommentThreadReadyHandler = (snapshot) => + app.handleCommentThreadReady(snapshot); + app.boundCommentThreadAppendHandler = (payload) => + app.handleCommentThreadAppend(payload); + app.boundCommentThreadErrorHandler = (error) => + app.handleCommentThreadError(error); + if ( + app.commentThreadService && + typeof app.commentThreadService.setCallbacks === "function" + ) { + app.commentThreadService.setCallbacks({ + onThreadReady: app.boundCommentThreadReadyHandler, + onCommentsAppended: app.boundCommentThreadAppendHandler, + onError: app.boundCommentThreadErrorHandler, + }); + if (app.commentThreadService.defaultLimit) { + app.modalCommentLimit = app.commentThreadService.defaultLimit; + } + } + + app.profileController = null; + app.loginModalController = null; + app.currentUserNpub = null; + + app.initializeLoginModalController({ logIfMissing: true }); + + app.profileButton = doc?.getElementById("profileButton") || null; + app.profileAvatar = doc?.getElementById("profileAvatar") || null; + + const modalManager = new ModalManager({ + app, + ui: this.ui, + documentRef: doc, + assets: { + fallbackThumbnailSrc: this.assets.fallbackThumbnailSrc, + }, + }); + modalManager.initialize(); + this.modalManager = modalManager; + + const modalContainer = doc?.getElementById("modalContainer") || null; + try { + if (modalContainer) { + const profileModalServices = { + normalizeHexPubkey: (value) => app.normalizeHexPubkey(value), + safeEncodeNpub: (pubkey) => app.safeEncodeNpub(pubkey), + safeDecodeNpub: (npub) => app.safeDecodeNpub(npub), + truncateMiddle: (value, maxLength) => truncateMiddle(value, maxLength), + formatShortNpub: (value) => formatShortNpub(value), + getProfileCacheEntry: (pubkey) => app.getProfileCacheEntry(pubkey), + batchFetchProfiles: (authorSet) => app.batchFetchProfiles(authorSet), + switchProfile: (pubkey) => app.authService.switchProfile(pubkey), + removeSavedProfile: (pubkey) => + app.authService.removeSavedProfile(pubkey), + relayManager, + userBlocks, + nostrClient, + nostrService: app.nostrService, + subscriptions, + accessControl, + moderation: moderationService, + getCurrentUserNpub: () => app.getCurrentUserNpub(), + nwcSettings: app.nwcSettingsService, + moderationSettings: { + getDefaultModerationSettings: () => getDefaultModerationSettings(), + getActiveModerationSettings: () => getModerationSettings(), + updateModerationSettings: (partial = {}) => + setModerationSettings(partial), + resetModerationSettings: () => resetModerationSettings(), + }, + loadVideos: (forceFetch, context) => + app.loadVideos(forceFetch, context), + onVideosShouldRefresh: (context) => + app.onVideosShouldRefresh(context), + describeAdminError: (code) => app.describeAdminError(code), + describeNotificationError: (code) => + app.describeNotificationError(code), + onAccessControlUpdated: () => app.onAccessControlUpdated(), + persistSavedProfiles: (options) => persistSavedProfiles(options), + watchHistoryService, + authService: app.authService, + requestAddProfileLogin: (options) => + app.requestProfileAdditionLogin(options), + describeLoginError: (error, fallbackMessage) => + app.describeLoginError(error, fallbackMessage), + hashtagPreferences: app.hashtagPreferences, + getHashtagPreferences: () => app.getHashtagPreferences(), + describeHashtagPreferencesError: (error, fallbackMessage) => + app.describeHashtagPreferencesError(error, { + fallbackMessage, + }), + log: (...args) => app.log(...args), + closeAllMoreMenus: (options) => app.closeAllMoreMenus(options), + clipboard: + typeof navigator !== "undefined" && navigator?.clipboard + ? navigator.clipboard + : null, + }; + + const profileModalState = { + getSavedProfiles: () => getSavedProfiles(), + setSavedProfiles: (profiles, options) => + setSavedProfiles(Array.isArray(profiles) ? profiles : [], options), + persistSavedProfiles: (options) => persistSavedProfiles(options), + getActivePubkey: () => getActiveProfilePubkey(), + setActivePubkey: (pubkey, options) => { + const normalized = + typeof pubkey === "string" && pubkey.trim() + ? pubkey.trim() + : null; + setStoredActiveProfilePubkey(normalized, options); + return getActiveProfilePubkey(); + }, + }; + + const profileModalCallbacks = { + onClose: () => app.handleProfileModalClosed(), + onLogout: async () => app.requestLogout(), + onChannelLink: (element) => app.handleProfileChannelLink(element), + onAddAccount: (payload) => app.handleAddProfile(payload), + onRequestLogoutProfile: (payload) => + app.handleProfileLogoutRequest(payload), + onRequestSwitchProfile: (payload) => + app.handleProfileSwitchRequest(payload), + onRelayOperation: (payload) => + app.handleProfileRelayOperation(payload), + onRelayModeToggle: (payload) => + app.handleProfileRelayModeToggle(payload), + onRelayRestore: (payload) => + app.handleProfileRelayRestore(payload), + onBlocklistMutation: (payload) => + app.handleProfileBlocklistMutation(payload), + onWalletPersist: (payload) => + app.handleProfileWalletPersist(payload), + onWalletTestRequest: (payload) => + app.handleProfileWalletTest(payload), + onWalletDisconnectRequest: (payload) => + app.handleProfileWalletDisconnect(payload), + onAdminMutation: (payload) => + app.handleProfileAdminMutation(payload), + onAdminNotifyError: (payload) => + app.handleProfileAdminNotifyError(payload), + onHistoryReady: (payload) => + app.handleProfileHistoryEvent(payload), + onModerationSettingsChange: (payload) => + app.handleModerationSettingsChange(payload), + }; + + app.profileController = new ProfileModalController({ + modalContainer, + removeTrackingScripts, + createWatchHistoryRenderer, + setGlobalModalState, + showError: (message) => app.showError(message), + showSuccess: (message) => app.showSuccess(message), + showStatus: (message) => app.showStatus(message), + constants: { + MAX_WALLET_DEFAULT_ZAP, + ADMIN_SUPER_NPUB, + ADMIN_DM_IMAGE_URL, + BITVID_WEBSITE_URL, + }, + services: profileModalServices, + state: profileModalState, + callbacks: profileModalCallbacks, + designSystem: app.designSystemContext, + }); + } else { + devLogger.warn( + "[Application] Profile modal controller disabled: modal container not found.", + ); + } + } catch (error) { + devLogger.error("Failed to initialize profile modal controller:", error); + } + + app.status = doc?.getElementById("status") || null; + app.progressBar = doc?.getElementById("progress") || null; + app.peers = doc?.getElementById("peers") || null; + app.speed = doc?.getElementById("speed") || null; + app.downloaded = doc?.getElementById("downloaded") || null; + + app.cleanupPromise = null; + + app.moreMenuController = new MoreMenuController({ + document: doc, + accessControl, + userBlocks, + subscriptions, + clipboard: + typeof navigator !== "undefined" && navigator?.clipboard + ? navigator.clipboard + : null, + isDevMode, + designSystem: app.designSystemContext, + callbacks: { + getCurrentVideo: () => app.currentVideo, + getCurrentUserNpub: () => app.getCurrentUserNpub(), + getCurrentUserPubkey: () => app.pubkey, + canCurrentUserManageBlacklist: () => + app.canCurrentUserManageBlacklist(), + openCreatorChannel: () => app.openCreatorChannel(), + goToProfile: (author) => app.goToProfile(author), + showError: (message) => app.showError(message), + showSuccess: (message) => app.showSuccess(message), + safeEncodeNpub: (pubkey) => app.safeEncodeNpub(pubkey), + safeDecodeNpub: (npub) => app.safeDecodeNpub(npub), + buildShareUrlFromEventId: (eventId) => + app.buildShareUrlFromEventId(eventId), + handleRemoveHistoryAction: (payload) => + app.handleRemoveHistoryAction(payload), + handleRepostAction: (payload) => app.handleRepostAction(payload), + handleMirrorAction: (payload) => app.handleMirrorAction(payload), + handleEnsurePresenceAction: (payload) => + app.handleEnsurePresenceAction(payload), + loadVideos: () => app.loadVideos(), + refreshAllVideoGrids: (options) => app.refreshAllVideoGrids(options), + onUserBlocksUpdated: () => { + if (app.profileController) { + try { + app.profileController.populateBlockedList(); + } catch (error) { + devLogger.warn( + "[profileModal] Failed to refresh blocked list after update:", + error, + ); + } + } + }, + }, + }); + app.moreMenuController.setVideoModal(app.videoModal); + app.videoSettingsPopovers = new Map(); + app.tagPreferencePopovers = new Map(); + + app.subscriptionsLink = null; + + app.notificationPortal = doc?.getElementById("notificationPortal") || null; + app.errorContainer = doc?.getElementById("errorContainer") || null; + app.successContainer = doc?.getElementById("successContainer") || null; + app.statusContainer = doc?.getElementById("statusContainer") || null; + app.statusMessage = + app.statusContainer?.querySelector("[data-status-message]") || null; + app.statusAutoHideHandle = null; + app.lastExperimentalWarningKey = null; + app.lastExperimentalWarningAt = 0; + + app.pubkey = null; + app.currentMagnetUri = null; + app.currentVideo = null; + app.currentVideoPointer = null; + app.currentVideoPointerKey = null; + app.videoSubscription = app.nostrService.getVideoSubscription() || null; + app.videoList = null; + app.videoListPopularTags = null; + app.modalViewCountUnsub = null; + app.modalReactionUnsub = null; + app.modalReactionPointerKey = null; + app.modalReactionState = { + counts: { "+": 0, "-": 0 }, + total: 0, + userReaction: "", + }; + + app.videosMap = app.nostrService.getVideosMap(); + app.unsubscribeFromNostrService = app.nostrService.on( + "subscription:changed", + ({ subscription }) => { + app.videoSubscription = subscription || null; + }, + ); + app.boundNwcSettingsToastHandler = null; + Object.defineProperty(app, "savedProfiles", { + configurable: false, + enumerable: false, + get() { + return getSavedProfiles(); + }, + set(next) { + setSavedProfiles(Array.isArray(next) ? next : [], { + persist: false, + persistActive: false, + }); + }, + }); + + Object.defineProperty(app, "activeProfilePubkey", { + configurable: false, + enumerable: false, + get() { + return getActiveProfilePubkey(); + }, + set(value) { + setStoredActiveProfilePubkey(value, { persist: false }); + }, + }); + + Object.defineProperty(app, "profileCache", { + configurable: false, + enumerable: false, + get() { + return getProfileCacheMap(); + }, + }); + + if ( + this.window && + typeof this.window.addEventListener === "function" + ) { + this.boundNwcSettingsToastHandler = (event) => { + const detail = event?.detail || {}; + if (detail.source !== "nwc-settings") { + return; + } + const rawMessage = + typeof detail.message === "string" ? detail.message.trim() : ""; + const message = rawMessage || "Wallet settings storage issue detected."; + app.log(`[nwcSettings] ${message}`); + if (detail.variant === "warning") { + app.showStatus(message); + if (this.window && typeof this.window.setTimeout === "function") { + this.window.setTimeout(() => { + app.showStatus(""); + }, 5000); + } + } else { + app.showError(message); + } + }; + this.window.addEventListener("bitvid:toast", this.boundNwcSettingsToastHandler); + } + + const magnetValidator = playbackDependencies.isValidMagnetUri; + + const videoListViewConfig = { + document: doc, + mediaLoader: app.mediaLoader, + badgeHelpers: { + attachHealthBadges: app.attachHealthBadgesWithCache, + attachUrlHealthBadges: (container, onCheck) => + attachUrlHealthBadges(container, onCheck), + }, + formatters: { + formatTimeAgo: (timestamp) => app.formatTimeAgo(timestamp), + formatViewCountLabel: (total) => app.formatViewCountLabel(total), + }, + helpers: { + escapeHtml: (value) => app.escapeHTML(value), + isMagnetSupported: (magnet) => magnetValidator(magnet), + toLocaleString: (value) => + typeof value === "number" ? value.toLocaleString() : value, + }, + assets: { + fallbackThumbnailSrc: this.assets.fallbackThumbnailSrc, + unsupportedBtihMessage: this.assets.unsupportedBtihMessage, + }, + state: { + loadedThumbnails: app.loadedThumbnails, + videosMap: app.videosMap, + urlHealthByVideoId: app.urlHealthSnapshots, + streamHealthByVideoId: app.streamHealthSnapshots, + }, + utils: { + dedupeVideos: (videos) => app.dedupeVideosByRoot(videos), + getAllEvents: () => Array.from(nostrClient.allEvents.values()), + hasOlderVersion: (video, events) => app.hasOlderVersion(video, events), + derivePointerInfo: (video) => app.deriveVideoPointerInfo(video), + persistWatchHistoryMetadata: (video, pointerInfo) => + app.persistWatchHistoryMetadataForVideo(video, pointerInfo), + getShareUrlBase: () => app.getShareUrlBase(), + buildShareUrlFromNevent: (nevent) => app.buildShareUrlFromNevent(nevent), + buildShareUrlFromEventId: (eventId) => app.buildShareUrlFromEventId(eventId), + getKnownVideoPostedAt: (video) => app.getKnownVideoPostedAt(video), + resolveVideoPostedAt: (video) => app.resolveVideoPostedAt(video), + canManageBlacklist: () => app.canCurrentUserManageBlacklist(), + canEditVideo: (video) => video?.pubkey === app.pubkey, + canDeleteVideo: (video) => video?.pubkey === app.pubkey, + batchFetchProfiles: (authorSet) => app.batchFetchProfiles(authorSet), + bindThumbnailFallbacks: (container) => app.bindThumbnailFallbacks(container), + handleUrlHealthBadge: (payload) => app.handleUrlHealthBadge(payload), + refreshDiscussionCounts: (videos, { container } = {}) => + app.refreshVideoDiscussionCounts(videos, { + videoListRoot: container || app.videoList || null, + }), + ensureGlobalMoreMenuHandlers: () => app.ensureGlobalMoreMenuHandlers(), + requestMoreMenu: (detail = {}) => app.requestMoreMenu(detail), + closeMoreMenu: (detail = {}) => app.closeMoreMenu(detail), + requestSettingsMenu: (detail = {}) => + app.requestVideoSettingsMenu(detail), + closeSettingsMenu: (detail = {}) => + app.closeVideoSettingsMenu(detail), + closeAllMenus: (options) => app.closeAllMoreMenus(options), + }, + renderers: { + getLoadingMarkup: (message) => getSidebarLoadingMarkup(message), + }, + allowNsfw: ALLOW_NSFW_CONTENT === true, + designSystem: app.designSystemContext, + }; + app.videoListView = + (typeof this.ui.videoListView === "function" + ? this.ui.videoListView({ app, config: videoListViewConfig }) + : this.ui.videoListView) || + new VideoListView(videoListViewConfig); + + if ( + app.videoListView && + typeof app.videoListView.setTagPreferenceStateResolver === "function" + ) { + app.videoListView.setTagPreferenceStateResolver((tag) => + app.getTagPreferenceState(tag), + ); + } + + if ( + app.videoListView && + typeof app.videoListView.setTagActivationHandler === "function" + ) { + app.videoListView.setTagActivationHandler((detail = {}) => + app.handleTagPreferenceActivation(detail), + ); + } + + app.videoListViewPlaybackHandler = ({ + videoId, + url, + magnet, + trigger, + }) => { + if (videoId) { + Promise.resolve( + app.playVideoByEventId(videoId, { url, magnet, trigger }), + ).catch((error) => { + devLogger.error("[VideoListView] Failed to play by event id:", error); + }); + return; + } + Promise.resolve( + app.playVideoWithFallback({ url, magnet, trigger }), + ).catch((error) => { + devLogger.error("[VideoListView] Failed to start playback:", error); + }); + }; + app.videoListView.setPlaybackHandler(app.videoListViewPlaybackHandler); + + app.videoListViewEditHandler = ({ video, index, trigger }) => { + if (!video?.id) { + return; + } + app.handleEditVideo({ + eventId: video.id, + index: Number.isFinite(index) ? index : null, + triggerElement: trigger, + video, + }); + }; + app.videoListView.setEditHandler(app.videoListViewEditHandler); + + app.videoListViewRevertHandler = ({ video, index, trigger }) => { + if (!video?.id) { + return; + } + app.handleRevertVideo({ + eventId: video.id, + index: Number.isFinite(index) ? index : null, + triggerElement: trigger, + video, + }); + }; + app.videoListView.setRevertHandler(app.videoListViewRevertHandler); + + app.videoListViewDeleteHandler = ({ video, index }) => { + if (!video?.id) { + return; + } + app.handleFullDeleteVideo({ + eventId: video.id, + index: Number.isFinite(index) ? index : null, + video, + }); + }; + app.videoListView.setDeleteHandler(app.videoListViewDeleteHandler); + + app.videoListView.setModerationOverrideHandler((detail = {}) => + app.handleModerationOverride(detail), + ); + app.videoListView.setModerationBlockHandler((detail = {}) => + app.handleModerationBlock(detail), + ); + app.videoListView.setModerationHideHandler((detail = {}) => + app.handleModerationHide(detail), + ); + + if (app.moreMenuController) { + app.moreMenuController.attachVideoListView(app.videoListView); + } + + app.closeLoginModalBtn = doc?.getElementById("closeLoginModal") || null; + + app.appChromeController = new AppChromeController({ + elements: { + logoutButton: app.logoutButton, + profileButton: app.profileButton, + uploadButton: app.uploadButton, + loginButton: app.loginButton, + closeLoginModalButton: app.closeLoginModalBtn, + }, + callbacks: { + requestLogout: () => app.requestLogout(), + showError: (message) => app.showError(message), + showProfileModal: () => + app.profileController && + typeof app.profileController.show === "function" + ? app.profileController.show() + : null, + openUploadModal: ({ triggerElement }) => { + if (app.uploadModal) { + app.uploadModal.open({ triggerElement }); + } + }, + onLoginModalOpened: () => setGlobalModalState("login", true), + onLoginModalClosed: () => setGlobalModalState("login", false), + flushWatchHistory: (reason, context) => + app.flushWatchHistory(reason, context), + cleanup: () => app.cleanup(), + hideModal: () => app.hideModal(), + }, + utilities: { + prepareLoginModal: () => + prepareStaticModal({ id: "loginModal" }) || + doc?.getElementById("loginModal"), + prepareApplicationModal: () => + prepareStaticModal({ id: "nostrFormModal" }) || + doc?.getElementById("nostrFormModal"), + openModal: (modal, options) => openStaticModal(modal, options), + closeModal: (modalId) => closeStaticModal(modalId), + }, + environment: { + document: doc, + window: this.window, + }, + logger: devLogger, + }); + + app.blacklistedEventIds = new Set(); + if (this.window?.NostrTools?.nip19?.decode) { + for (const neventStr of ADMIN_INITIAL_EVENT_BLACKLIST) { + if (!neventStr || neventStr.trim().length < 8) { + continue; + } + try { + const decoded = this.window.NostrTools.nip19.decode(neventStr); + if (decoded?.type === "nevent" && decoded.data?.id) { + app.blacklistedEventIds.add(decoded.data.id); + } + } catch (err) { + devLogger.error( + "[Application] Invalid nevent in blacklist:", + neventStr, + err, + ); + } + } + } + + app.unsubscribeFromPubkeyState = null; + app.unsubscribeFromCurrentUserState = null; + + return { modalManager }; + } + + teardown() { + if ( + this.window && + this.boundNwcSettingsToastHandler && + typeof this.window.removeEventListener === "function" + ) { + this.window.removeEventListener( + "bitvid:toast", + this.boundNwcSettingsToastHandler, + ); + } + this.boundNwcSettingsToastHandler = null; + } +} From 1d9c8ae4b5b4fb0fe5e5c0481dbd2693015cb208 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 27 Oct 2025 12:39:58 -0400 Subject: [PATCH 2/2] Fix import of ALLOW_NSFW_CONTENT --- js/ui/applicationBootstrap.js | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/js/ui/applicationBootstrap.js b/js/ui/applicationBootstrap.js index d003f20b..5970f2c0 100644 --- a/js/ui/applicationBootstrap.js +++ b/js/ui/applicationBootstrap.js @@ -17,10 +17,8 @@ import nostrService from "../services/nostrService.js"; import watchHistoryService from "../watchHistoryService.js"; import r2Service from "../services/r2Service.js"; import { createFeedEngine } from "../feedEngine/index.js"; -import { - URL_FIRST_ENABLED, - ALLOW_NSFW_CONTENT, -} from "../constants.js"; +import { URL_FIRST_ENABLED } from "../constants.js"; +import { ALLOW_NSFW_CONTENT } from "../config.js"; import { relayManager } from "../relayManager.js"; import { userBlocks } from "../userBlocks.js"; import { subscriptions } from "../subscriptions.js";