diff --git a/js/ui/videoModalCommentController.js b/js/ui/videoModalCommentController.js index ad88cc21..7cacca13 100644 --- a/js/ui/videoModalCommentController.js +++ b/js/ui/videoModalCommentController.js @@ -201,29 +201,40 @@ export default class VideoModalCommentController { this.videoModal.clearComments?.(); this.videoModal.resetCommentComposer?.(); this.videoModal.setCommentStatus?.("Loading comments…"); - this.applyCommentComposerAuthState(); - const loadPromise = this.commentThreadService.loadThread({ + const loadResult = this.commentThreadService.loadThread({ video, parentCommentId: null, limit: this.modalCommentLimit, }); - if (!loadPromise || typeof loadPromise.then !== "function") { + if (!loadResult) { this.applyCommentComposerAuthState(); return; } - this.modalCommentLoadPromise = loadPromise; - loadPromise - .then(() => { - if (this.modalCommentLoadPromise === loadPromise) { + if (typeof loadResult.then !== "function") { + const didRenderSnapshot = this.handleCommentThreadReady(loadResult); + if (didRenderSnapshot === false) { + this.applyCommentComposerAuthState(); + } + return; + } + + this.modalCommentLoadPromise = loadResult; + loadResult + .then((snapshot) => { + if (this.modalCommentLoadPromise === loadResult) { this.modalCommentLoadPromise = null; } - this.applyCommentComposerAuthState(); + + const didRenderSnapshot = this.handleCommentThreadReady(snapshot); + if (didRenderSnapshot === false) { + this.applyCommentComposerAuthState(); + } }) .catch((error) => { - if (this.modalCommentLoadPromise === loadPromise) { + if (this.modalCommentLoadPromise === loadResult) { this.modalCommentLoadPromise = null; } this.handleCommentThreadError(error); @@ -355,14 +366,14 @@ export default class VideoModalCommentController { handleCommentThreadReady(snapshot) { if (!snapshot || !this.videoModal) { - return; + return false; } const snapshotVideoEventId = normalizeHexId(snapshot.videoEventId); const modalVideoEventId = normalizeHexId(this.modalCommentState.videoEventId); if (!snapshotVideoEventId || snapshotVideoEventId !== modalVideoEventId) { - return; + return false; } this.modalCommentProfiles = this.createMapFromInput(snapshot.profiles, { @@ -402,9 +413,11 @@ export default class VideoModalCommentController { } const sanitizedSnapshot = this.buildModalCommentSnapshot(snapshot); + this.videoModal.setCommentsVisibility?.(true); this.videoModal.renderComments?.(sanitizedSnapshot); this.videoModal.setCommentStatus?.(""); this.applyCommentComposerAuthState(); + return true; } handleCommentThreadAppend(payload) { diff --git a/tests/video-modal-comments.test.mjs b/tests/video-modal-comments.test.mjs index 47b2edf6..147b554b 100644 --- a/tests/video-modal-comments.test.mjs +++ b/tests/video-modal-comments.test.mjs @@ -186,6 +186,174 @@ test( }, ); +test( + "Guest users render loaded thread snapshots before composer auth enforcement", + async (t) => { + const { modal, cleanup } = await setupModal(); + t.after(cleanup); + + const snapshot = { + videoEventId: "video-loaded-snapshot", + parentCommentId: null, + commentsById: new Map([ + [ + "loaded-comment", + createCommentEvent({ + id: "loaded-comment", + pubkey: "pk-loaded", + content: "Snapshot content shows up", + createdAt: 1700000100, + }), + ], + ]), + childrenByParent: new Map([[null, ["loaded-comment"]]]), + profiles: new Map([["pk-loaded", { name: "Loaded" }]]), + }; + + const controller = new VideoModalCommentController({ + commentThreadService: { + defaultLimit: 1, + setCallbacks: () => {}, + teardown: () => {}, + loadThread: () => Promise.resolve(snapshot), + }, + videoModal: modal, + auth: { + isLoggedIn: () => false, + }, + }); + + controller.load({ id: "video-loaded-snapshot", enableComments: true }); + await controller.modalCommentLoadPromise; + + const renderedComments = Array.from( + modal.commentsList.querySelectorAll("[data-comment-id]") || [], + ); + assert.equal(renderedComments.length, 1); + assert.equal(renderedComments[0].dataset.commentId, "loaded-comment"); + + const statusText = + modal.commentsStatusMessage?.textContent.trim().replace(/\s+/g, " ") || + ""; + assert.equal(statusText, ""); + assert.equal(modal.commentComposerState.reason, "login-required"); + }, +); + +test( + "Thread ready snapshots force comments visible before composer gating", + async (t) => { + const { modal, cleanup } = await setupModal(); + t.after(cleanup); + + const visibilityCalls = []; + const instrumentedModal = { + ...modal, + setCommentsVisibility: (isVisible) => { + visibilityCalls.push(isVisible); + modal.setCommentsVisibility(isVisible); + }, + }; + + const snapshot = { + videoEventId: "video-visibility-snapshot", + parentCommentId: null, + commentsById: new Map([ + [ + "visible-comment", + createCommentEvent({ + id: "visible-comment", + pubkey: "pk-visible", + content: "Rendered as soon as thread is ready", + createdAt: 1700000300, + }), + ], + ]), + childrenByParent: new Map([[null, ["visible-comment"]]]), + profiles: new Map([["pk-visible", { name: "Visible" }]]), + }; + + const controller = new VideoModalCommentController({ + commentThreadService: { + defaultLimit: 1, + setCallbacks: () => {}, + teardown: () => {}, + loadThread: () => Promise.resolve(snapshot), + }, + videoModal: instrumentedModal, + auth: { + isLoggedIn: () => false, + }, + }); + + controller.load({ id: "video-visibility-snapshot", enableComments: true }); + await controller.modalCommentLoadPromise; + + assert.ok(visibilityCalls.length >= 2); + assert.equal(visibilityCalls.pop(), true); + + const renderedComments = Array.from( + modal.commentsList.querySelectorAll("[data-comment-id]") || [], + ); + assert.equal(renderedComments.length, 1); + assert.equal(renderedComments[0].dataset.commentId, "visible-comment"); + }, +); + +test( + "Controller renders synchronous loadThread snapshots immediately", + async (t) => { + const { modal, cleanup } = await setupModal(); + t.after(cleanup); + + const snapshot = { + videoEventId: "video-sync-snapshot", + parentCommentId: null, + commentsById: new Map([ + [ + "sync-comment", + createCommentEvent({ + id: "sync-comment", + pubkey: "pk-sync", + content: "Snapshot delivered instantly", + createdAt: 1700000200, + }), + ], + ]), + childrenByParent: new Map([[null, ["sync-comment"]]]), + profiles: new Map([["pk-sync", { name: "Synchronous" }]]), + }; + + const controller = new VideoModalCommentController({ + commentThreadService: { + defaultLimit: 1, + setCallbacks: () => {}, + teardown: () => {}, + loadThread: () => snapshot, + }, + videoModal: modal, + auth: { + isLoggedIn: () => false, + }, + }); + + controller.load({ id: "video-sync-snapshot", enableComments: true }); + await Promise.resolve(); + + const renderedComments = Array.from( + modal.commentsList.querySelectorAll("[data-comment-id]") || [], + ); + assert.equal(renderedComments.length, 1); + assert.equal(renderedComments[0].dataset.commentId, "sync-comment"); + + const statusText = + modal.commentsStatusMessage?.textContent.trim().replace(/\s+/g, " ") || + ""; + assert.equal(statusText, ""); + assert.equal(modal.commentComposerState.reason, "login-required"); + }, +); + test( "VideoModalCommentController attaches profiles using normalized pubkeys", async () => {