Files
bitvid/js/ui/videoModalCommentController.js
T

900 lines
26 KiB
JavaScript

import { devLogger } from "../utils/logger.js";
import { buildVideoAddressPointer } from "../utils/videoPointer.js";
import { COMMENT_EVENT_KIND } from "../nostr/commentEvents.js";
import { normalizeHexString, normalizeHexPubkey } from "../utils/hex.js";
const DEFAULT_LIMIT = 40;
function normalizeString(value) {
return typeof value === "string" ? value.trim() : "";
}
const normalizeHexId = normalizeHexString;
function normalizeRelay(value) {
if (typeof value === "string") {
return value.trim();
}
return "";
}
function normalizeKind(value) {
if (Number.isFinite(value)) {
return String(Math.floor(value));
}
if (typeof value === "string" && value.trim()) {
return value.trim();
}
return "";
}
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)
: (value) => normalizeHexPubkey(value) || null,
};
this.currentVideo = null;
this.modalCommentState = {
videoEventId: null,
videoDefinitionAddress: null,
videoKind: null,
videoAuthorPubkey: null,
videoRootId: null,
videoRootRelay: null,
parentCommentId: null,
parentCommentKind: null,
parentCommentPubkey: 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.dispose({ resetUi: false });
this.currentVideo = video;
this.videoModal.setCommentSectionCallbacks?.({
teardown: () => this.dispose(),
});
if (!this.commentThreadService) {
this.resetModalCommentState();
return;
}
this.videoModal.hideCommentsDisabledMessage?.();
if (video.enableComments === false) {
this.resetModalCommentState();
this.videoModal.showCommentsDisabledMessage?.(
"Comments have been turned off for this video.",
);
return;
}
const videoEventId = normalizeHexId(video.id);
let videoDefinitionAddress = buildVideoAddressPointer(video);
if (!videoDefinitionAddress && this.modalCommentState.videoDefinitionAddress) {
videoDefinitionAddress = normalizeString(
this.modalCommentState.videoDefinitionAddress,
);
}
if (videoDefinitionAddress) {
videoDefinitionAddress = videoDefinitionAddress.trim().toLowerCase();
}
const videoKind = normalizeKind(video.kind);
const videoAuthorPubkey = normalizeHexPubkey(video.pubkey);
const pointerRootId = normalizeHexId(video.pointerIdentifiers?.videoRootId);
const videoRootId = normalizeHexId(video.videoRootId) || pointerRootId;
const videoRootRelay = normalizeRelay(
video.videoRootRelay || video.rootIdentifierRelay,
);
if (!videoEventId) {
this.resetModalCommentState({ hide: false });
this.videoModal.setCommentStatus?.(
"Comments are unavailable for this video.",
);
return;
}
this.modalCommentState = {
videoEventId,
videoDefinitionAddress: videoDefinitionAddress || null,
videoKind: videoKind || null,
videoAuthorPubkey: videoAuthorPubkey || null,
videoRootId: videoRootId || null,
videoRootRelay: videoRootRelay || null,
parentCommentId: null,
parentCommentKind: null,
parentCommentPubkey: 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…");
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((snapshot) => {
if (this.modalCommentLoadPromise === loadPromise) {
this.modalCommentLoadPromise = null;
}
const didRenderSnapshot = this.handleCommentThreadReady(snapshot);
if (didRenderSnapshot === false) {
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,
videoKind: null,
videoAuthorPubkey: null,
videoRootId: null,
videoRootRelay: null,
parentCommentId: null,
parentCommentKind: null,
parentCommentPubkey: 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",
});
this.videoModal.setCommentsVisibility?.(true);
this.videoModal.setCommentStatus?.("");
return;
}
this.videoModal.setCommentComposerState?.({
disabled: false,
reason: "",
});
this.videoModal.setCommentStatus?.("");
}
handleCommentThreadReady(snapshot) {
if (!snapshot || !this.videoModal) {
return false;
}
const snapshotVideoEventId = normalizeHexId(snapshot.videoEventId);
const modalVideoEventId = normalizeHexId(this.modalCommentState.videoEventId);
if (!snapshotVideoEventId || snapshotVideoEventId !== modalVideoEventId) {
return false;
}
this.modalCommentProfiles = this.createMapFromInput(snapshot.profiles, {
normalizeKey: this.utils.normalizeHexPubkey,
});
this.modalCommentState.parentCommentId =
normalizeHexId(snapshot.parentCommentId) || null;
if (typeof snapshot.videoDefinitionAddress === "string") {
const pointer = normalizeString(snapshot.videoDefinitionAddress);
if (pointer) {
this.modalCommentState.videoDefinitionAddress = pointer;
}
}
const snapshotVideoKind = normalizeKind(snapshot.videoKind);
if (snapshotVideoKind) {
this.modalCommentState.videoKind = snapshotVideoKind;
}
const snapshotVideoAuthor = normalizeHexPubkey(snapshot.videoAuthorPubkey);
if (snapshotVideoAuthor) {
this.modalCommentState.videoAuthorPubkey = snapshotVideoAuthor;
}
const snapshotRootIdentifier = normalizeHexId(snapshot.rootIdentifier);
if (snapshotRootIdentifier) {
this.modalCommentState.videoRootId = snapshotRootIdentifier;
}
const snapshotRootRelay = normalizeRelay(snapshot.rootIdentifierRelay);
if (snapshotRootRelay) {
this.modalCommentState.videoRootRelay = snapshotRootRelay;
}
const snapshotParentKind = normalizeKind(snapshot.parentCommentKind);
if (snapshotParentKind) {
this.modalCommentState.parentCommentKind = snapshotParentKind;
}
const snapshotParentPubkey = normalizeHexPubkey(snapshot.parentCommentPubkey);
if (snapshotParentPubkey) {
this.modalCommentState.parentCommentPubkey = snapshotParentPubkey;
}
const sanitizedSnapshot = this.buildModalCommentSnapshot(snapshot);
this.videoModal.renderComments?.(sanitizedSnapshot);
this.videoModal.setCommentStatus?.("");
this.applyCommentComposerAuthState();
return true;
}
handleCommentThreadAppend(payload) {
if (!payload || !this.videoModal) {
return;
}
const payloadVideoEventId = normalizeHexId(payload.videoEventId);
const modalVideoEventId = normalizeHexId(this.modalCommentState.videoEventId);
if (!payloadVideoEventId || payloadVideoEventId !== modalVideoEventId) {
return;
}
const comments = this.createMapFromInput(payload.commentsById);
const profiles = this.createMapFromInput(payload.profiles, {
normalizeKey: this.utils.normalizeHexPubkey,
});
profiles.forEach((profile, pubkey) => {
this.modalCommentProfiles.set(pubkey, profile);
});
const payloadRootIdentifier = normalizeHexId(payload.rootIdentifier);
if (payloadRootIdentifier) {
this.modalCommentState.videoRootId = payloadRootIdentifier;
}
const payloadRootRelay = normalizeRelay(payload.rootIdentifierRelay);
if (payloadRootRelay) {
this.modalCommentState.videoRootRelay = payloadRootRelay;
}
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);
if (
this.modalCommentState.parentCommentId &&
commentId === this.modalCommentState.parentCommentId
) {
const eventKind = normalizeKind(event.kind);
if (eventKind) {
this.modalCommentState.parentCommentKind = eventKind;
}
const eventPubkey = normalizeHexPubkey(event.pubkey);
if (eventPubkey) {
this.modalCommentState.parentCommentPubkey = eventPubkey;
}
}
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, { normalizeKey = null } = {}) {
const map = new Map();
const applyKey = (key) => {
if (!normalizeKey) {
return key;
}
const normalized = normalizeKey(key);
return normalized === null || normalized === undefined ? null : normalized;
};
const setEntry = (key, value) => {
const normalizedKey = applyKey(key);
if (normalizedKey === null || normalizedKey === undefined) {
return;
}
map.set(normalizedKey, value);
};
if (input instanceof Map) {
input.forEach((value, key) => {
setEntry(key, value);
});
return map;
}
if (Array.isArray(input)) {
input.forEach((entry) => {
if (Array.isArray(entry) && entry.length >= 2) {
setEntry(entry[0], entry[1]);
}
});
return map;
}
if (input && typeof input === "object") {
Object.entries(input).forEach(([key, value]) => {
setEntry(key, value);
});
}
return map;
}
createChildrenMapFromInput(input) {
const source =
input instanceof Map ? input : this.createMapFromInput(input);
const result = new Map();
source.forEach((value, key) => {
let normalizedKey = key;
if (normalizedKey === null || normalizedKey === undefined) {
normalizedKey = null;
} else if (typeof normalizedKey === "string") {
const trimmed = normalizedKey.trim();
if (
!trimmed ||
trimmed === "null" ||
trimmed === "undefined" ||
trimmed === "__root__"
) {
normalizedKey = null;
} else {
normalizedKey = normalizeHexId(trimmed) || trimmed;
}
}
const list = Array.isArray(value) ? value.filter(Boolean) : [];
result.set(normalizedKey, 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: normalizeHexId(snapshot.videoEventId),
parentCommentId: normalizeHexId(snapshot.parentCommentId) || null,
rootIdentifier: normalizeHexId(snapshot.rootIdentifier) || null,
rootIdentifierRelay: snapshot.rootIdentifierRelay || 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 = normalizeHexId(video.id);
let videoDefinitionAddress = buildVideoAddressPointer(video);
if (!videoDefinitionAddress && this.modalCommentState.videoDefinitionAddress) {
videoDefinitionAddress = normalizeString(
this.modalCommentState.videoDefinitionAddress,
);
}
const videoKind =
normalizeKind(this.modalCommentState.videoKind) || normalizeKind(video.kind);
const videoAuthorPubkey =
normalizeHexPubkey(this.modalCommentState.videoAuthorPubkey) ||
normalizeHexPubkey(video.pubkey);
const fallbackPointerRootId = normalizeHexId(video.pointerIdentifiers?.videoRootId);
const videoRootId =
normalizeHexId(this.modalCommentState.videoRootId) ||
normalizeHexId(video.videoRootId) ||
fallbackPointerRootId;
const videoRootRelay =
normalizeRelay(this.modalCommentState.videoRootRelay) ||
normalizeRelay(video.videoRootRelay || video.rootIdentifierRelay);
if (!videoEventId) {
this.callbacks.showError("Comments are unavailable for this video.");
return;
}
const parentCommentId = normalizeHexId(detail.parentId) || null;
let parentCommentKind = this.modalCommentState.parentCommentKind
? normalizeKind(this.modalCommentState.parentCommentKind)
: "";
let parentCommentPubkey = this.modalCommentState.parentCommentPubkey
? normalizeHexPubkey(this.modalCommentState.parentCommentPubkey)
: "";
if (parentCommentId && typeof this.commentThreadService?.getCommentEvent === "function") {
const parentEvent = this.commentThreadService.getCommentEvent(
parentCommentId,
);
if (parentEvent) {
const eventKind = normalizeKind(parentEvent.kind);
if (eventKind) {
parentCommentKind = eventKind;
this.modalCommentState.parentCommentKind = eventKind;
}
const eventPubkey = normalizeHexPubkey(parentEvent.pubkey);
if (eventPubkey) {
parentCommentPubkey = eventPubkey;
this.modalCommentState.parentCommentPubkey = eventPubkey;
}
}
}
if (!parentCommentKind && parentCommentId) {
parentCommentKind = String(COMMENT_EVENT_KIND);
}
this.videoModal.setCommentComposerState?.({
disabled: true,
reason: "submitting",
});
const publishPayload = {
videoEventId,
parentCommentId,
};
if (videoDefinitionAddress) {
publishPayload.videoDefinitionAddress = videoDefinitionAddress;
}
if (videoKind) {
publishPayload.videoKind = videoKind;
publishPayload.rootKind = videoKind;
}
if (videoAuthorPubkey) {
publishPayload.videoAuthorPubkey = videoAuthorPubkey;
publishPayload.rootAuthorPubkey = videoAuthorPubkey;
}
if (videoRootId) {
publishPayload.rootIdentifier = videoRootId;
}
if (videoRootRelay) {
publishPayload.rootIdentifierRelay = videoRootRelay;
}
if (parentCommentKind) {
publishPayload.parentCommentKind = parentCommentKind;
}
if (parentCommentPubkey) {
publishPayload.parentCommentPubkey = parentCommentPubkey;
publishPayload.parentAuthorPubkey = parentCommentPubkey;
}
const publishPromise = Promise.resolve(
this.services.publishComment(
publishPayload,
{
content: text,
},
),
);
this.modalCommentPublishPromise = publishPromise;
try {
const result = await publishPromise;
if (!result?.ok || !result.event) {
throw result || { 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) {
const authRequired =
(error && typeof error === "object" && error.error === "auth-required") ||
error === "auth-required";
if (authRequired) {
this.videoModal.setCommentComposerState?.({
disabled: true,
reason: "login-required",
});
this.handleVideoModalCommentLoginRequired(detail);
this.videoModal.setCommentComposerState?.({
disabled: true,
reason: "login-required",
});
return;
}
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 = normalizeHexPubkey(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.");
}
}
}