Merge pull request #971 from PR0M3TH3AN/codex/add-videomodalcommentcontroller-with-dependencies

Refactor video modal comments into dedicated controller
This commit is contained in:
thePR0M3TH3AN authored and GitHub committed 2025-10-27 13:59:20 -04:00
commit f185fc7ec8
4 files changed
+708 -523

No files matched your search

+49 -497
View File
@@ -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 {
+6 -6
View File
@@ -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",
-20
View File
@@ -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;
+653
View File
@@ -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.");
}
}
}