From 11479e28e2222106e8bdec6c4ee864107293a6cd Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 27 Oct 2025 13:58:55 -0400 Subject: [PATCH] Refactor video modal comment handling into controller --- js/app.js | 546 ++-------------------- js/ui/ModalManager.js | 12 +- js/ui/applicationBootstrap.js | 20 - js/ui/videoModalCommentController.js | 653 +++++++++++++++++++++++++++ 4 files changed, 708 insertions(+), 523 deletions(-) create mode 100644 js/ui/videoModalCommentController.js diff --git a/js/app.js b/js/app.js index ede0b2eb..fb22b9ad 100644 --- a/js/app.js +++ b/js/app.js @@ -141,6 +141,7 @@ import { urlHealthConstants, } from "./state/cache.js"; import ApplicationBootstrap from "./ui/applicationBootstrap.js"; +import VideoModalCommentController from "./ui/videoModalCommentController.js"; const recordVideoViewApi = (...args) => recordVideoView(nostrClient, ...args); @@ -213,6 +214,9 @@ class Application { const { modalManager } = this.bootstrapper.initialize(); this.modalManager = modalManager; + this.commentController = null; + this.initializeCommentController(); + this.unsubscribeFromPubkeyState = subscribeToAppStateKey( "pubkey", (next, previous) => { @@ -1227,317 +1231,44 @@ class Application { } } - resetModalCommentState({ hide = true } = {}) { - if (!this.videoModal) { + initializeCommentController() { + if (!this.commentThreadService || !this.videoModal) { return; } - this.videoModal.clearComments?.(); - this.videoModal.resetCommentComposer?.(); - this.videoModal.setCommentComposerState?.({ - disabled: true, - reason: "disabled", - }); - if (hide) { - this.videoModal.setCommentsVisibility?.(false); - } - this.videoModal.setCommentStatus?.(""); - } - - teardownModalCommentSubscription({ resetUi = true } = {}) { - if (this.commentThreadService) { - try { - this.commentThreadService.teardown(); - } catch (error) { - devLogger.warn("[comment] Failed to teardown modal comment thread:", error); - } - } - this.modalCommentLoadPromise = null; - this.modalCommentPublishPromise = null; - this.modalCommentState = { - videoEventId: null, - videoDefinitionAddress: null, - parentCommentId: null, - }; - this.modalCommentProfiles = new Map(); - if (resetUi) { - this.resetModalCommentState(); - } - this.videoModal?.setCommentSectionCallbacks?.({ teardown: null }); - } - - subscribeModalComments(video) { - if (!this.videoModal) { + if (this.commentController) { return; } - if (!video) { - this.teardownModalCommentSubscription(); - return; - } - - this.videoModal.setCommentSectionCallbacks?.({ - teardown: () => this.teardownModalCommentSubscription(), - }); - - if (!this.commentThreadService) { - this.resetModalCommentState(); - return; - } - - this.teardownModalCommentSubscription({ resetUi: false }); - - this.videoModal.hideCommentsDisabledMessage?.(); - - if (video.enableComments === false) { - this.resetModalCommentState(); - this.videoModal.showCommentsDisabledMessage?.( - "Comments have been turned off for this video." - ); - return; - } - - const videoEventId = - typeof video.id === "string" && video.id.trim() ? video.id.trim() : ""; - const videoDefinitionAddress = buildVideoAddressPointer(video); - - if (!videoEventId || !videoDefinitionAddress) { - this.resetModalCommentState({ hide: false }); - this.videoModal.setCommentStatus?.( - "Comments are unavailable for this video.", - ); - return; - } - - this.modalCommentState = { - videoEventId, - videoDefinitionAddress, - parentCommentId: null, - }; - this.modalCommentProfiles = new Map(); - if (this.commentThreadService.defaultLimit) { - this.modalCommentLimit = this.commentThreadService.defaultLimit; - } - - this.videoModal.setCommentsVisibility?.(true); - this.videoModal.clearComments?.(); - this.videoModal.resetCommentComposer?.(); - this.videoModal.setCommentStatus?.("Loading comments…"); - this.applyCommentComposerAuthState(); - - const loadPromise = this.commentThreadService.loadThread({ - video, - parentCommentId: null, - limit: this.modalCommentLimit, - }); - - if (!loadPromise || typeof loadPromise.then !== "function") { - this.applyCommentComposerAuthState(); - return; - } - - this.modalCommentLoadPromise = loadPromise; - loadPromise - .then(() => { - if (this.modalCommentLoadPromise === loadPromise) { - this.modalCommentLoadPromise = null; - } - this.applyCommentComposerAuthState(); - }) - .catch((error) => { - if (this.modalCommentLoadPromise === loadPromise) { - this.modalCommentLoadPromise = null; - } - this.handleCommentThreadError(error); - }); - } - - applyCommentComposerAuthState() { - if (!this.videoModal) { - return; - } - - if (!this.modalCommentState.videoEventId) { - return; - } - - if (this.currentVideo?.enableComments === false) { - this.videoModal.showCommentsDisabledMessage?.( - "Comments have been turned off for this video." - ); - this.videoModal.setCommentComposerState?.({ - disabled: true, - reason: "disabled", - }); - return; - } - - this.videoModal.hideCommentsDisabledMessage?.(); - - if (!this.isUserLoggedIn()) { - this.videoModal.setCommentComposerState?.({ - disabled: true, - reason: "login-required", - }); - return; - } - - this.videoModal.setCommentComposerState?.({ - disabled: false, - reason: "", - }); - this.videoModal.setCommentStatus?.(""); - } - - handleCommentThreadReady(snapshot) { - if (!snapshot || !this.videoModal) { - return; - } - - if (snapshot.videoEventId !== this.modalCommentState.videoEventId) { - return; - } - - this.modalCommentProfiles = this.createMapFromInput(snapshot.profiles); - this.modalCommentState.parentCommentId = - typeof snapshot.parentCommentId === "string" && - snapshot.parentCommentId.trim() - ? snapshot.parentCommentId.trim() - : null; - - const sanitizedSnapshot = this.buildModalCommentSnapshot(snapshot); - this.videoModal.renderComments?.(sanitizedSnapshot); - this.videoModal.setCommentStatus?.(""); - this.applyCommentComposerAuthState(); - } - - handleCommentThreadAppend(payload) { - if (!payload || !this.videoModal) { - return; - } - - if (payload.videoEventId !== this.modalCommentState.videoEventId) { - return; - } - - const comments = this.createMapFromInput(payload.commentsById); - const profiles = this.createMapFromInput(payload.profiles); - profiles.forEach((profile, pubkey) => { - this.modalCommentProfiles.set(pubkey, profile); - }); - - const commentIds = Array.isArray(payload.commentIds) - ? payload.commentIds - : []; - - commentIds.forEach((commentId) => { - if (!comments.has(commentId)) { - return; - } - const event = comments.get(commentId); - if (!event || this.shouldHideModalComment(event)) { - return; - } - const enriched = this.enrichCommentEvent(event); - this.videoModal.appendComment?.(enriched); + this.commentController = new VideoModalCommentController({ + commentThreadService: this.commentThreadService, + videoModal: this.videoModal, + auth: { + isLoggedIn: () => this.isUserLoggedIn(), + initializeLoginModalController: (options) => + this.initializeLoginModalController(options), + getLoginModalController: () => this.loginModalController, + requestLogin: (options) => + this.authService?.requestLogin?.(options) ?? Promise.resolve(false), + }, + callbacks: { + showError: (message) => this.showError(message), + showStatus: (message, options) => this.showStatus(message, options), + muteAuthor: (pubkey) => userBlocks.addBlock(pubkey, this.pubkey), + shouldHideAuthor: (pubkey) => this.shouldHideCommentAuthor(pubkey), + }, + services: { + publishComment: (target, eventData) => + nostrClient.publishVideoComment(target, eventData), + }, + utils: { + normalizeHexPubkey: (value) => this.normalizeHexPubkey(value), + }, }); } - handleCommentThreadError(error) { - if (error) { - devLogger.warn("[comment]", error); - } - if (!this.videoModal) { - return; - } - this.videoModal.setCommentStatus?.( - "Failed to load comments. Please try again later.", - ); - if (!this.isUserLoggedIn()) { - this.videoModal.setCommentComposerState?.({ - disabled: true, - reason: "login-required", - }); - } - } - - createMapFromInput(input) { - if (input instanceof Map) { - return new Map(input); - } - const map = new Map(); - if (Array.isArray(input)) { - input.forEach((entry) => { - if (Array.isArray(entry) && entry.length >= 2) { - map.set(entry[0], entry[1]); - } - }); - return map; - } - if (input && typeof input === "object") { - Object.entries(input).forEach(([key, value]) => { - map.set(key, value); - }); - } - return map; - } - - createChildrenMapFromInput(input) { - const source = input instanceof Map ? input : this.createMapFromInput(input); - const result = new Map(); - source.forEach((value, key) => { - const list = Array.isArray(value) ? value.filter(Boolean) : []; - result.set(key, list); - }); - return result; - } - - enrichCommentEvent(event) { - const cloned = { ...(event || {}) }; - const normalized = this.normalizeHexPubkey(cloned.pubkey); - if (normalized && this.modalCommentProfiles.has(normalized)) { - cloned.profile = this.modalCommentProfiles.get(normalized); - } - return cloned; - } - - buildModalCommentSnapshot(snapshot) { - const comments = this.createMapFromInput(snapshot?.commentsById); - const children = this.createChildrenMapFromInput(snapshot?.childrenByParent); - const sanitizedComments = new Map(); - - comments.forEach((event, key) => { - if (!event || this.shouldHideModalComment(event)) { - return; - } - sanitizedComments.set(key, this.enrichCommentEvent(event)); - }); - - const sanitizedChildren = new Map(); - children.forEach((ids, parentId) => { - const seen = new Set(); - const filtered = []; - ids.forEach((id) => { - if (!sanitizedComments.has(id) || seen.has(id)) { - return; - } - seen.add(id); - filtered.push(id); - }); - sanitizedChildren.set(parentId, filtered); - }); - - return { - videoEventId: snapshot.videoEventId, - parentCommentId: snapshot.parentCommentId || null, - commentsById: sanitizedComments, - childrenByParent: sanitizedChildren, - profiles: this.modalCommentProfiles, - }; - } - - shouldHideModalComment(event) { - const normalized = this.normalizeHexPubkey(event?.pubkey); + shouldHideCommentAuthor(pubkey) { + const normalized = this.normalizeHexPubkey(pubkey); if (!normalized) { return false; } @@ -1565,195 +1296,6 @@ class Application { return false; } - async handleVideoModalCommentSubmit(detail = {}) { - if (this.modalCommentPublishPromise) { - return; - } - - const text = - typeof detail.text === "string" && detail.text.trim() - ? detail.text.trim() - : ""; - if (!text) { - return; - } - - if (!this.isUserLoggedIn()) { - this.videoModal.setCommentComposerState?.({ - disabled: true, - reason: "login-required", - }); - this.handleVideoModalCommentLoginRequired(detail); - return; - } - - const video = this.currentVideo; - if (!video || video.enableComments === false) { - return; - } - - const videoEventId = - typeof video.id === "string" && video.id.trim() ? video.id.trim() : ""; - const videoDefinitionAddress = buildVideoAddressPointer(video); - - if (!videoEventId || !videoDefinitionAddress) { - this.showError("Comments are unavailable for this video."); - return; - } - - const parentCommentId = - typeof detail.parentId === "string" && detail.parentId.trim() - ? detail.parentId.trim() - : null; - - this.videoModal.setCommentComposerState?.({ - disabled: true, - reason: "submitting", - }); - - const publishPromise = Promise.resolve( - nostrClient.publishVideoComment( - { - videoEventId, - videoDefinitionAddress, - parentCommentId, - }, - { - content: text, - }, - ), - ); - - this.modalCommentPublishPromise = publishPromise; - - try { - const result = await publishPromise; - if (!result?.ok || !result.event) { - throw result?.error || new Error("publish-failed"); - } - - const event = this.enrichCommentEvent(result.event); - if (this.commentThreadService) { - try { - this.commentThreadService.processIncomingEvent(event); - } catch (error) { - devLogger.warn( - "[comment] Failed to process optimistic comment event:", - error, - ); - this.videoModal.appendComment?.(event); - } - } else { - this.videoModal.appendComment?.(event); - } - - this.videoModal.resetCommentComposer?.(); - this.applyCommentComposerAuthState(); - this.videoModal.setCommentStatus?.("Comment posted."); - } catch (error) { - devLogger.warn("[comment] Failed to publish comment:", error); - this.videoModal.setCommentComposerState?.({ - disabled: false, - reason: "error", - }); - this.showError("Failed to post comment. Please try again."); - } finally { - if (this.modalCommentPublishPromise === publishPromise) { - this.modalCommentPublishPromise = null; - } - } - } - - handleVideoModalCommentRetry(detail = {}) { - this.handleVideoModalCommentSubmit(detail); - } - - handleVideoModalCommentLoadMore() { - if (!this.commentThreadService || !this.currentVideo) { - return; - } - - if (this.modalCommentLoadPromise) { - return; - } - - const increment = this.commentThreadService.defaultLimit || 40; - this.modalCommentLimit = (this.modalCommentLimit || increment) + increment; - - const loadPromise = this.commentThreadService.loadThread({ - video: this.currentVideo, - parentCommentId: this.modalCommentState.parentCommentId, - limit: this.modalCommentLimit, - }); - - if (!loadPromise || typeof loadPromise.then !== "function") { - return; - } - - this.modalCommentLoadPromise = loadPromise; - loadPromise - .then(() => { - if (this.modalCommentLoadPromise === loadPromise) { - this.modalCommentLoadPromise = null; - } - this.applyCommentComposerAuthState(); - }) - .catch((error) => { - if (this.modalCommentLoadPromise === loadPromise) { - this.modalCommentLoadPromise = null; - } - devLogger.warn("[comment] Failed to load more comments:", error); - this.showError("Failed to load more comments. Please try again."); - }); - } - - handleVideoModalCommentLoginRequired(detail = {}) { - this.applyCommentComposerAuthState(); - this.initializeLoginModalController({ logIfMissing: true }); - const triggerElement = detail?.triggerElement || null; - try { - const opened = this.loginModalController?.openModal?.({ triggerElement }); - if (opened) { - return; - } - } catch (error) { - devLogger.warn("[comment] Failed to open login modal:", error); - } - - this.authService - .requestLogin({ allowAccountSelection: true }) - .catch((error) => { - devLogger.warn("[comment] Login request failed:", error); - }); - } - - async handleVideoModalCommentMute(detail = {}) { - const pubkey = - typeof detail?.pubkey === "string" && detail.pubkey.trim() - ? detail.pubkey.trim() - : ""; - if (!pubkey) { - return; - } - - if (!this.isUserLoggedIn()) { - this.handleVideoModalCommentLoginRequired(detail); - return; - } - - try { - await userBlocks.addBlock(pubkey, this.pubkey); - const snapshot = this.commentThreadService?.getSnapshot?.(); - if (snapshot) { - this.handleCommentThreadReady(snapshot); - } - this.showStatus?.("Author muted."); - } catch (error) { - devLogger.warn("[comment] Failed to mute author:", error); - this.showError("Failed to mute this author. Please try again."); - } - } - subscribeModalViewCount(pointer, pointerKey) { const viewEl = this.videoModal?.getViewCountElement() || null; if (!viewEl) { @@ -4113,7 +3655,7 @@ class Application { } this.applyAuthenticatedUiState(); - this.applyCommentComposerAuthState(); + this.commentController?.refreshAuthState?.(); const rawProviderId = typeof detail?.providerId === "string" ? detail.providerId.trim() : ""; @@ -4359,7 +3901,7 @@ class Application { } this.applyLoggedOutUiState(); - this.applyCommentComposerAuthState(); + this.commentController?.refreshAuthState?.(); try { await this.handleModerationSettingsChange({ @@ -4553,7 +4095,7 @@ class Application { } } - this.teardownModalCommentSubscription({ resetUi: false }); + this.commentController?.dispose({ resetUi: false }); if (!preserveModals) { if (this.modalManager) { @@ -8923,7 +8465,7 @@ class Application { this.currentVideoPointer, this.currentVideoPointerKey ); - this.subscribeModalComments(this.currentVideo); + this.commentController?.load(this.currentVideo); this.syncModalMoreMenuData(); @@ -9254,7 +8796,7 @@ class Application { this.currentVideoPointerKey = null; this.subscribeModalViewCount(null, null); this.subscribeModalReactions(null, null); - this.subscribeModalComments(null); + this.commentController?.load(null); this.pendingModeratedPlayback = null; const sanitizedUrl = typeof url === "string" ? url.trim() : ""; @@ -9747,7 +9289,17 @@ class Application { this.unsubscribeFromNostrService = null; } - this.teardownModalCommentSubscription({ resetUi: false }); + if (this.commentController) { + try { + this.commentController.destroy({ resetUi: false }); + } catch (error) { + devLogger.warn( + "[Application] Failed to destroy comment controller:", + error, + ); + } + this.commentController = null; + } if (this.modalManager) { try { diff --git a/js/ui/ModalManager.js b/js/ui/ModalManager.js index ccc751d6..cc07193f 100644 --- a/js/ui/ModalManager.js +++ b/js/ui/ModalManager.js @@ -574,7 +574,7 @@ export default class ModalManager { ); this.videoModalHandlers.commentSubmit = (event) => { - app.handleVideoModalCommentSubmit(event?.detail || {}); + app.commentController?.submit(event?.detail || {}); }; this.videoModal.addEventListener( "comment:submit", @@ -582,15 +582,15 @@ export default class ModalManager { ); this.videoModalHandlers.commentRetry = (event) => { - app.handleVideoModalCommentRetry(event?.detail || {}); + app.commentController?.retry(event?.detail || {}); }; this.videoModal.addEventListener( "comment:retry", this.videoModalHandlers.commentRetry, ); - this.videoModalHandlers.commentLoadMore = (event) => { - app.handleVideoModalCommentLoadMore(event?.detail || {}); + this.videoModalHandlers.commentLoadMore = () => { + app.commentController?.loadMore(); }; this.videoModal.addEventListener( "comment:load-more", @@ -598,7 +598,7 @@ export default class ModalManager { ); this.videoModalHandlers.commentLogin = (event) => { - app.handleVideoModalCommentLoginRequired(event?.detail || {}); + app.commentController?.handleLoginRequired(event?.detail || {}); }; this.videoModal.addEventListener( "comment:login-required", @@ -606,7 +606,7 @@ export default class ModalManager { ); this.videoModalHandlers.commentMute = (event) => { - app.handleVideoModalCommentMute(event?.detail || {}); + app.commentController?.muteAuthor(event?.detail || {}); }; this.videoModal.addEventListener( "comment:mute-author", diff --git a/js/ui/applicationBootstrap.js b/js/ui/applicationBootstrap.js index cb2db2dd..453f6757 100644 --- a/js/ui/applicationBootstrap.js +++ b/js/ui/applicationBootstrap.js @@ -387,26 +387,6 @@ export default class ApplicationBootstrap { 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; diff --git a/js/ui/videoModalCommentController.js b/js/ui/videoModalCommentController.js new file mode 100644 index 00000000..fde9ba10 --- /dev/null +++ b/js/ui/videoModalCommentController.js @@ -0,0 +1,653 @@ +import { devLogger } from "../utils/logger.js"; +import { buildVideoAddressPointer } from "../utils/videoPointer.js"; + +const DEFAULT_LIMIT = 40; + +function normalizeString(value) { + return typeof value === "string" ? value.trim() : ""; +} + +export default class VideoModalCommentController { + constructor({ + commentThreadService = null, + videoModal = null, + auth = {}, + callbacks = {}, + services = {}, + utils = {}, + } = {}) { + this.commentThreadService = commentThreadService; + this.videoModal = videoModal; + + this.auth = { + isLoggedIn: + typeof auth.isLoggedIn === "function" ? () => auth.isLoggedIn() : () => false, + initializeLoginModalController: + typeof auth.initializeLoginModalController === "function" + ? (options) => auth.initializeLoginModalController(options) + : () => {}, + getLoginModalController: + typeof auth.getLoginModalController === "function" + ? () => auth.getLoginModalController() + : () => null, + requestLogin: + typeof auth.requestLogin === "function" + ? (options) => auth.requestLogin(options) + : () => Promise.resolve(false), + }; + + this.callbacks = { + showError: + typeof callbacks.showError === "function" + ? (message) => callbacks.showError(message) + : () => {}, + showStatus: + typeof callbacks.showStatus === "function" + ? (message, options) => callbacks.showStatus(message, options) + : () => {}, + muteAuthor: + typeof callbacks.muteAuthor === "function" + ? (pubkey) => callbacks.muteAuthor(pubkey) + : () => Promise.resolve(), + shouldHideAuthor: + typeof callbacks.shouldHideAuthor === "function" + ? (pubkey) => callbacks.shouldHideAuthor(pubkey) + : () => false, + }; + + this.services = { + publishComment: + typeof services.publishComment === "function" + ? (payload, eventData) => services.publishComment(payload, eventData) + : () => Promise.resolve(null), + }; + + this.utils = { + normalizeHexPubkey: + typeof utils.normalizeHexPubkey === "function" + ? (value) => utils.normalizeHexPubkey(value) + : () => null, + }; + + this.currentVideo = null; + this.modalCommentState = { + videoEventId: null, + videoDefinitionAddress: null, + parentCommentId: null, + }; + this.modalCommentProfiles = new Map(); + this.modalCommentLimit = this.commentThreadService?.defaultLimit || DEFAULT_LIMIT; + this.modalCommentLoadPromise = null; + this.modalCommentPublishPromise = null; + + this.boundThreadReadyHandler = (snapshot) => + this.handleCommentThreadReady(snapshot); + this.boundThreadAppendHandler = (payload) => + this.handleCommentThreadAppend(payload); + this.boundThreadErrorHandler = (error) => + this.handleCommentThreadError(error); + + if (this.commentThreadService?.setCallbacks) { + this.commentThreadService.setCallbacks({ + onThreadReady: this.boundThreadReadyHandler, + onCommentsAppended: this.boundThreadAppendHandler, + onError: this.boundThreadErrorHandler, + }); + } + } + + load(video) { + if (!this.videoModal) { + return; + } + + if (!video) { + this.dispose(); + return; + } + + this.currentVideo = video; + + this.videoModal.setCommentSectionCallbacks?.({ + teardown: () => this.dispose(), + }); + + if (!this.commentThreadService) { + this.resetModalCommentState(); + return; + } + + this.dispose({ resetUi: false }); + + this.videoModal.hideCommentsDisabledMessage?.(); + + if (video.enableComments === false) { + this.resetModalCommentState(); + this.videoModal.showCommentsDisabledMessage?.( + "Comments have been turned off for this video.", + ); + return; + } + + const videoEventId = normalizeString(video.id); + const videoDefinitionAddress = buildVideoAddressPointer(video); + + if (!videoEventId || !videoDefinitionAddress) { + this.resetModalCommentState({ hide: false }); + this.videoModal.setCommentStatus?.( + "Comments are unavailable for this video.", + ); + return; + } + + this.modalCommentState = { + videoEventId, + videoDefinitionAddress, + parentCommentId: null, + }; + this.modalCommentProfiles = new Map(); + if (this.commentThreadService?.defaultLimit) { + this.modalCommentLimit = this.commentThreadService.defaultLimit; + } + + this.videoModal.setCommentsVisibility?.(true); + this.videoModal.clearComments?.(); + this.videoModal.resetCommentComposer?.(); + this.videoModal.setCommentStatus?.("Loading comments…"); + this.applyCommentComposerAuthState(); + + const loadPromise = this.commentThreadService.loadThread({ + video, + parentCommentId: null, + limit: this.modalCommentLimit, + }); + + if (!loadPromise || typeof loadPromise.then !== "function") { + this.applyCommentComposerAuthState(); + return; + } + + this.modalCommentLoadPromise = loadPromise; + loadPromise + .then(() => { + if (this.modalCommentLoadPromise === loadPromise) { + this.modalCommentLoadPromise = null; + } + this.applyCommentComposerAuthState(); + }) + .catch((error) => { + if (this.modalCommentLoadPromise === loadPromise) { + this.modalCommentLoadPromise = null; + } + this.handleCommentThreadError(error); + }); + } + + submit(detail = {}) { + this.handleVideoModalCommentSubmit(detail); + } + + retry(detail = {}) { + this.handleVideoModalCommentSubmit(detail); + } + + loadMore() { + this.handleVideoModalCommentLoadMore(); + } + + handleLoginRequired(detail = {}) { + this.handleVideoModalCommentLoginRequired(detail); + } + + async muteAuthor(detail = {}) { + await this.handleVideoModalCommentMute(detail); + } + + refreshAuthState() { + this.applyCommentComposerAuthState(); + } + + dispose({ resetUi = true } = {}) { + if (this.commentThreadService) { + try { + this.commentThreadService.teardown(); + } catch (error) { + devLogger.warn("[comment] Failed to teardown modal comment thread:", error); + } + } + + this.modalCommentLoadPromise = null; + this.modalCommentPublishPromise = null; + this.modalCommentState = { + videoEventId: null, + videoDefinitionAddress: null, + parentCommentId: null, + }; + this.modalCommentProfiles = new Map(); + if (resetUi) { + this.resetModalCommentState(); + } + this.videoModal?.setCommentSectionCallbacks?.({ teardown: null }); + this.currentVideo = null; + } + + destroy({ resetUi = true } = {}) { + this.dispose({ resetUi }); + if (this.commentThreadService?.setCallbacks) { + this.commentThreadService.setCallbacks({ + onThreadReady: null, + onCommentsAppended: null, + onError: null, + }); + } + this.commentThreadService = null; + this.videoModal = null; + } + + resetModalCommentState({ hide = true } = {}) { + if (!this.videoModal) { + return; + } + + this.videoModal.clearComments?.(); + this.videoModal.resetCommentComposer?.(); + this.videoModal.setCommentComposerState?.({ + disabled: true, + reason: "disabled", + }); + if (hide) { + this.videoModal.setCommentsVisibility?.(false); + } + this.videoModal.setCommentStatus?.(""); + } + + applyCommentComposerAuthState() { + if (!this.videoModal) { + return; + } + + if (!this.modalCommentState.videoEventId) { + return; + } + + if (this.currentVideo?.enableComments === false) { + this.videoModal.showCommentsDisabledMessage?.( + "Comments have been turned off for this video.", + ); + this.videoModal.setCommentComposerState?.({ + disabled: true, + reason: "disabled", + }); + return; + } + + this.videoModal.hideCommentsDisabledMessage?.(); + + if (!this.auth.isLoggedIn()) { + this.videoModal.setCommentComposerState?.({ + disabled: true, + reason: "login-required", + }); + return; + } + + this.videoModal.setCommentComposerState?.({ + disabled: false, + reason: "", + }); + this.videoModal.setCommentStatus?.(""); + } + + handleCommentThreadReady(snapshot) { + if (!snapshot || !this.videoModal) { + return; + } + + if (snapshot.videoEventId !== this.modalCommentState.videoEventId) { + return; + } + + this.modalCommentProfiles = this.createMapFromInput(snapshot.profiles); + this.modalCommentState.parentCommentId = normalizeString( + snapshot.parentCommentId, + ) || null; + + const sanitizedSnapshot = this.buildModalCommentSnapshot(snapshot); + this.videoModal.renderComments?.(sanitizedSnapshot); + this.videoModal.setCommentStatus?.(""); + this.applyCommentComposerAuthState(); + } + + handleCommentThreadAppend(payload) { + if (!payload || !this.videoModal) { + return; + } + + if (payload.videoEventId !== this.modalCommentState.videoEventId) { + return; + } + + const comments = this.createMapFromInput(payload.commentsById); + const profiles = this.createMapFromInput(payload.profiles); + profiles.forEach((profile, pubkey) => { + this.modalCommentProfiles.set(pubkey, profile); + }); + + const commentIds = Array.isArray(payload.commentIds) + ? payload.commentIds + : []; + + commentIds.forEach((commentId) => { + if (!comments.has(commentId)) { + return; + } + const event = comments.get(commentId); + if (!event || this.shouldHideModalComment(event)) { + return; + } + const enriched = this.enrichCommentEvent(event); + this.videoModal.appendComment?.(enriched); + }); + } + + handleCommentThreadError(error) { + if (error) { + devLogger.warn("[comment]", error); + } + if (!this.videoModal) { + return; + } + this.videoModal.setCommentStatus?.( + "Failed to load comments. Please try again later.", + ); + if (!this.auth.isLoggedIn()) { + this.videoModal.setCommentComposerState?.({ + disabled: true, + reason: "login-required", + }); + } + } + + createMapFromInput(input) { + if (input instanceof Map) { + return new Map(input); + } + const map = new Map(); + if (Array.isArray(input)) { + input.forEach((entry) => { + if (Array.isArray(entry) && entry.length >= 2) { + map.set(entry[0], entry[1]); + } + }); + return map; + } + if (input && typeof input === "object") { + Object.entries(input).forEach(([key, value]) => { + map.set(key, value); + }); + } + return map; + } + + createChildrenMapFromInput(input) { + const source = + input instanceof Map ? input : this.createMapFromInput(input); + const result = new Map(); + source.forEach((value, key) => { + const list = Array.isArray(value) ? value.filter(Boolean) : []; + result.set(key, list); + }); + return result; + } + + enrichCommentEvent(event) { + const cloned = { ...(event || {}) }; + const normalized = this.utils.normalizeHexPubkey(cloned.pubkey); + if (normalized && this.modalCommentProfiles.has(normalized)) { + cloned.profile = this.modalCommentProfiles.get(normalized); + } + return cloned; + } + + buildModalCommentSnapshot(snapshot) { + const comments = this.createMapFromInput(snapshot?.commentsById); + const children = this.createChildrenMapFromInput( + snapshot?.childrenByParent, + ); + const sanitizedComments = new Map(); + + comments.forEach((event, key) => { + if (!event || this.shouldHideModalComment(event)) { + return; + } + sanitizedComments.set(key, this.enrichCommentEvent(event)); + }); + + const sanitizedChildren = new Map(); + children.forEach((ids, parentId) => { + const seen = new Set(); + const filtered = []; + ids.forEach((id) => { + if (!sanitizedComments.has(id) || seen.has(id)) { + return; + } + seen.add(id); + filtered.push(id); + }); + sanitizedChildren.set(parentId, filtered); + }); + + return { + videoEventId: snapshot.videoEventId, + parentCommentId: snapshot.parentCommentId || null, + commentsById: sanitizedComments, + childrenByParent: sanitizedChildren, + profiles: this.modalCommentProfiles, + }; + } + + shouldHideModalComment(event) { + const normalized = this.utils.normalizeHexPubkey(event?.pubkey); + if (!normalized) { + return false; + } + + try { + return Boolean(this.callbacks.shouldHideAuthor(normalized)); + } catch (error) { + devLogger.warn("[comment] Failed to evaluate comment visibility:", error); + return false; + } + } + + async handleVideoModalCommentSubmit(detail = {}) { + if (this.modalCommentPublishPromise) { + return; + } + + const text = normalizeString(detail.text); + if (!text) { + return; + } + + if (!this.auth.isLoggedIn()) { + this.videoModal.setCommentComposerState?.({ + disabled: true, + reason: "login-required", + }); + this.handleVideoModalCommentLoginRequired(detail); + return; + } + + const video = this.currentVideo; + if (!video || video.enableComments === false) { + return; + } + + const videoEventId = normalizeString(video.id); + const videoDefinitionAddress = buildVideoAddressPointer(video); + + if (!videoEventId || !videoDefinitionAddress) { + this.callbacks.showError("Comments are unavailable for this video."); + return; + } + + const parentCommentId = normalizeString(detail.parentId) || null; + + this.videoModal.setCommentComposerState?.({ + disabled: true, + reason: "submitting", + }); + + const publishPromise = Promise.resolve( + this.services.publishComment( + { + videoEventId, + videoDefinitionAddress, + parentCommentId, + }, + { + content: text, + }, + ), + ); + + this.modalCommentPublishPromise = publishPromise; + + try { + const result = await publishPromise; + if (!result?.ok || !result.event) { + throw result?.error || new Error("publish-failed"); + } + + const event = this.enrichCommentEvent(result.event); + if (this.commentThreadService) { + try { + this.commentThreadService.processIncomingEvent(event); + } catch (error) { + devLogger.warn( + "[comment] Failed to process optimistic comment event:", + error, + ); + this.videoModal.appendComment?.(event); + } + } else { + this.videoModal.appendComment?.(event); + } + + this.videoModal.resetCommentComposer?.(); + this.applyCommentComposerAuthState(); + this.videoModal.setCommentStatus?.("Comment posted."); + } catch (error) { + devLogger.warn("[comment] Failed to publish comment:", error); + this.videoModal.setCommentComposerState?.({ + disabled: false, + reason: "error", + }); + this.callbacks.showError("Failed to post comment. Please try again."); + } finally { + if (this.modalCommentPublishPromise === publishPromise) { + this.modalCommentPublishPromise = null; + } + } + } + + handleVideoModalCommentLoadMore() { + if (!this.commentThreadService || !this.currentVideo) { + return; + } + + if (this.modalCommentLoadPromise) { + return; + } + + const increment = this.commentThreadService.defaultLimit || DEFAULT_LIMIT; + this.modalCommentLimit = (this.modalCommentLimit || increment) + increment; + + const loadPromise = this.commentThreadService.loadThread({ + video: this.currentVideo, + parentCommentId: this.modalCommentState.parentCommentId, + limit: this.modalCommentLimit, + }); + + if (!loadPromise || typeof loadPromise.then !== "function") { + return; + } + + this.modalCommentLoadPromise = loadPromise; + loadPromise + .then(() => { + if (this.modalCommentLoadPromise === loadPromise) { + this.modalCommentLoadPromise = null; + } + this.applyCommentComposerAuthState(); + }) + .catch((error) => { + if (this.modalCommentLoadPromise === loadPromise) { + this.modalCommentLoadPromise = null; + } + devLogger.warn("[comment] Failed to load more comments:", error); + this.callbacks.showError( + "Failed to load more comments. Please try again.", + ); + }); + } + + handleVideoModalCommentLoginRequired(detail = {}) { + this.applyCommentComposerAuthState(); + try { + this.auth.initializeLoginModalController({ logIfMissing: true }); + } catch (error) { + devLogger.warn( + "[comment] Failed to initialize login modal controller:", + error, + ); + } + + const triggerElement = detail?.triggerElement || null; + try { + const loginModalController = this.auth.getLoginModalController(); + if ( + loginModalController && + typeof loginModalController.openModal === "function" + ) { + const opened = loginModalController.openModal({ triggerElement }); + if (opened) { + return; + } + } + } catch (error) { + devLogger.warn("[comment] Failed to open login modal:", error); + } + + Promise.resolve( + this.auth.requestLogin({ allowAccountSelection: true }), + ).catch((error) => { + devLogger.warn("[comment] Login request failed:", error); + }); + } + + async handleVideoModalCommentMute(detail = {}) { + const pubkey = normalizeString(detail?.pubkey); + if (!pubkey) { + return; + } + + if (!this.auth.isLoggedIn()) { + this.handleVideoModalCommentLoginRequired(detail); + return; + } + + try { + await Promise.resolve(this.callbacks.muteAuthor(pubkey)); + const snapshot = this.commentThreadService?.getSnapshot?.(); + if (snapshot) { + this.handleCommentThreadReady(snapshot); + } + this.callbacks.showStatus?.("Author muted."); + } catch (error) { + devLogger.warn("[comment] Failed to mute author:", error); + this.callbacks.showError("Failed to mute this author. Please try again."); + } + } +}