import { html, render } from "/js/lib/lit-html.js"; import { avatarTemplate } from "/js/templates/avatar.template.js"; import { sortBy, pinScrollPosition } from "/js/utils.js"; import { bindToPage, pageEffect } from "/js/router.js"; import { headerTemplate } from "/js/templates/header.template.js"; import { smallPostTemplate } from "/js/templates/smallPost.template.js"; import { mutedParentToggleTemplate } from "/js/templates/mutedParentToggle.template.js"; import { largePostTemplate } from "/js/templates/largePost.template.js"; import { postSkeletonTemplate } from "/js/templates/postSkeleton.template.js"; import { flattenParents, isBlockedPost, isNotFoundPost, isUnavailablePost, isEmptyPost, isMutedPost, getReplyRootFromPost, doHideAuthorOnUnauthenticated, canReplyToPost, } from "/js/dataHelpers.js"; import { lockIconTemplate } from "/js/templates/icons/lockIcon.template.js"; import { ApiError } from "/js/api.js"; import { View } from "/js/views/view.js"; import "/js/components/hidden-replies-section.js"; import "/js/components/plugin-slot.js"; import { linkToPostFromUri } from "/js/navigation.js"; import { Signal, ReactiveStore } from "/js/signals.js"; class PostThreadView extends View { async render({ root, router, params, context: { dataLayer, identityResolver, postComposerService, isAuthenticated, pluginService, interactionHandlers, }, }) { const { handleOrDid, rkey } = params; let authorDid = null; if (handleOrDid.startsWith("did:")) { authorDid = handleOrDid; } else { authorDid = await identityResolver.resolveHandle(handleOrDid); } const postUri = `at://${authorDid}/app.bsky.feed.post/${rkey}`; const { postInteractionHandler } = interactionHandlers; function postThreadErrorTemplate({ error }) { if ( error instanceof ApiError && error.status === 400 && error.data?.error === "NotFound" ) { return html`
Post not found
`; } else { console.error(error); return html`
Error loading thread
`; } } function replyHasContentLabel(reply) { return ( reply.post.contentLabel && reply.post.contentLabel.visibility !== "ignore" ); } function doShowReply(reply) { const post = reply.post; if (!post) { return false; } if ( isBlockedPost(post) || isNotFoundPost(post) || isMutedPost(post) || post.isBlockedReply || replyHasContentLabel(reply) || post.isHidden ) { return false; } if ( !isAuthenticated && post.author && doHideAuthorOnUnauthenticated(post.author) ) { return false; } return true; } function getShownReplies(replies) { return replies.filter((reply) => doShowReply(reply)); } function buildReplyChain(post) { const chain = [post]; let currentPost = post; while (currentPost.replies && currentPost.replies.length > 0) { // get most liked reply const shownReplies = getShownReplies(currentPost.replies); if (shownReplies.length > 0) { let mostLikedReply = sortBy( shownReplies, (reply) => getLikesWithoutUser(reply.post), { direction: "desc" }, )[0]; chain.push(mostLikedReply); currentPost = mostLikedReply; } else { break; } } return chain; } // Get likes without the user's like, so that liking posts doesn't affect the order of the replies. function getLikesWithoutUser(post) { const likeCount = post.likeCount; return !!post.viewer?.like ? likeCount - 1 : likeCount; } function buildReplyChains(replies, postAuthor) { const replyChains = []; for (const reply of replies) { if (doShowReply(reply)) { replyChains.push(buildReplyChain(reply)); } } let sortedReplyChains = sortBy( replyChains, (chain) => getLikesWithoutUser(chain[0].post), { direction: "desc", }, ); // Put replies by the post author first if (postAuthor) { sortedReplyChains = [ ...sortedReplyChains.filter( (chain) => chain[0].post.author?.did === postAuthor.did, ), ...sortedReplyChains.filter( (chain) => chain[0].post.author?.did !== postAuthor.did, ), ]; } // If there's a recent reply from the user, put it at the top const recentReplyFromUser = sortedReplyChains.find( (chain) => chain[0].post.viewer?.priorityReply, ); if (recentReplyFromUser) { sortedReplyChains = [ recentReplyFromUser, ...sortedReplyChains.filter((chain) => chain !== recentReplyFromUser), ]; } return sortedReplyChains; } function getReplyContext(replyIndex, numReplies) { if (numReplies === 1) { return null; } if (replyIndex === 0) { return "root"; } else if (replyIndex === numReplies - 1) { return "reply"; } return "parent"; } function replyChainTemplate({ replyChain, currentUser, lazyLoadImages }) { const numReplies = replyChain.length; return html`
${replyChain.map((reply, i) => { const post = dataLayer.derived.$hydratedPosts.get(reply.post.uri); if (!post) return ""; return smallPostTemplate({ post, currentUser, isAuthenticated, isUserPost: currentUser?.did === post.author?.did, postInteractionHandler, replyContext: getReplyContext(i, numReplies), lazyLoadImages, pluginService, }); })}
`; } async function handleClickReply(post, replyRoot, currentUser) { await postComposerService.composePost({ currentUser, replyTo: post, replyRoot, }); } // Note, this is different from hiding a reply entirely, that's why this name is weirdly specific. // Things shown here will also need to be filtered out from the reply chain separately (doShowReply()) function doPutReplyInHiddenSection(reply) { if (!reply.post) { return false; } if (isMutedPost(reply.post) || replyHasContentLabel(reply)) { return true; } // If the post author blocked the replier, put the reply in the hidden section if (reply.post.isBlockedReply) { return true; } // Replies can be marked as hidden by bsky sentiment analysis (app.bsky.unspecced.getPostThreadOtherV2) if (reply.post.isHidden) { return true; } return false; } function postThreadRepliesTemplate({ replies, postAuthor, currentUser }) { const hiddenSectionReplies = replies.filter((reply) => doPutReplyInHiddenSection(reply), ); const replyChains = buildReplyChains(replies, postAuthor); const isEmpty = replyChains.length === 0 && hiddenSectionReplies.length === 0; return html`
${isEmpty ? html`` : html`
${replyChains.map((replyChain, i) => // there can be a lot of images in a reply chain, so lazy load them after the first few replyChainTemplate({ replyChain, currentUser, lazyLoadImages: i > 20, }), )}
${hiddenSectionReplies.length > 0 ? html` ${hiddenSectionReplies.map((reply) => smallPostTemplate({ post: reply.post, currentUser, isAuthenticated, isUserPost: currentUser?.did === reply.post?.author?.did, postInteractionHandler, ignoreContentWarning: true, ignoreMuteWarning: true, lazyLoadImages: true, pluginService, }), )} ` : ""} `}
`; } function repliesSkeletonTemplate({ numReplies }) { return html`
${Array.from({ length: Math.min(numReplies, 10) }).map(() => postSkeletonTemplate(), )}
`; } const NO_UNAUTHENTICATED_MESSAGE = "This author has chosen to make their posts visible only to people who are signed in."; function noUnauthenticatedSmallPostTemplate({ replyContext = null } = {}) { return html`
${replyContext === "parent" || replyContext === "reply" ? html`
` : ""}
${lockIconTemplate()}
${replyContext === "root" || replyContext === "parent" ? html`
` : ""}
${NO_UNAUTHENTICATED_MESSAGE}
`; } function noUnauthenticatedLargePostTemplate() { return html`
${lockIconTemplate()}
${NO_UNAUTHENTICATED_MESSAGE}
`; } function threadTemplate({ postThread, currentUser }) { try { const mainPost = isEmptyPost(postThread) ? postThread : postThread.post; const parents = flattenParents(postThread); // A post might still have a parent even if it isn't loaded by the appview - // this happens if the client has malformed reply refs. const replyParent = mainPost?.record?.reply?.parent; const hasParent = !!replyParent; // Don't set this to true unless the full post thread has loaded const hasBrokenReplyRef = hasParent && !postThread.__isPrefill && parents.length === 0; const root = getReplyRootFromPost(mainPost); const replies = postThread.replies; const postAuthor = mainPost?.author; const hiddenUnauthenticated = !isAuthenticated && mainPost?.author && doHideAuthorOnUnauthenticated(mainPost.author); return html`
${parents.map((parent, i) => { const parentPost = parent.post ? parent.post : parent; const replyContext = i === 0 ? "root" : "parent"; if ( !isAuthenticated && parentPost.author && doHideAuthorOnUnauthenticated(parentPost.author) ) { return noUnauthenticatedSmallPostTemplate({ replyContext }); } return mutedParentToggleTemplate({ post: parentPost, children: smallPostTemplate({ post: parentPost, currentUser, isAuthenticated, isUserPost: currentUser?.did === parentPost.author?.did, postInteractionHandler, replyContext, ignoreMuteWarning: true, pluginService, }), }); })} ${hasBrokenReplyRef ? html`` : ""}
${hiddenUnauthenticated ? noUnauthenticatedLargePostTemplate() : largePostTemplate({ post: mainPost, currentUser, isAuthenticated, pluginService, isUserPost: currentUser?.did === mainPost?.author?.did, postInteractionHandler, afterHide: () => { // if the main post is hidden, go back to the previous page router.back(); }, afterDelete: () => { // if the main post is deleted, go back to the previous page router.back(); }, onClickReply: async () => { await handleClickReply(mainPost, root, currentUser); }, replyContext: hasParent ? "reply" : null, showActions: !postThread.__isEmbeddedPrefill, })} ${!postThread.__isEmbeddedPrefill && isAuthenticated && currentUser && canReplyToPost(mainPost) ? html`
{ await handleClickReply(mainPost, root, currentUser); }} >
${avatarTemplate({ author: currentUser, clickAction: "none", })} Write your reply
` : ""} ${(() => { if (hiddenUnauthenticated) { return ""; } if (replies) { return postThreadRepliesTemplate({ replies, postAuthor, currentUser, }); } const numReplies = mainPost?.replyCount; if (numReplies > 0) { return repliesSkeletonTemplate({ numReplies }); } return ""; })()}
`; } catch (error) { return postThreadErrorTemplate({ error }); } } function threadSkeletonTemplate() { return html`
${Array.from({ length: 3 }).map(() => { return postSkeletonTemplate(); })}
`; } const state = new ReactiveStore("postThreadView"); state.$postThread = new Signal.Computed(() => { const hydratedPostThread = dataLayer.derived.$hydratedPostThreads.get(postUri); if (hydratedPostThread) { return hydratedPostThread; } // Prefill with saved post if available const post = dataLayer.derived.$hydratedPosts.get(postUri); if (post) { return { __isPrefill: true, post, parent: null, replies: null, }; } const embeddedPost = dataLayer.derived.$hydratedEmbeddedPosts.get(postUri); if (embeddedPost) { return { __isPrefill: true, __isEmbeddedPrefill: true, post: embeddedPost, parent: null, replies: null, }; } return null; }); let hasScrolledToLargePost = false; // The pin only starts once the full thread has loaded, skip it if the user has already scrolled let userHasScrolled = false; const markUserScrolled = () => { userHasScrolled = true; }; bindToPage(root, window, "touchmove", markUserScrolled); bindToPage(root, window, "wheel", markUserScrolled); bindToPage(root, window, "keydown", markUserScrolled); pageEffect(root, () => { const postThread = state.$postThread.get(); const currentUser = dataLayer.derived.$currentUser.get(); const postThreadRequestStatus = dataLayer.requests.statusStore.$statuses.get( "loadPostThread-" + postUri, ); render( html`
${headerTemplate({ title: "Post" })}
${(() => { if (postThreadRequestStatus.error) { return postThreadErrorTemplate({ error: postThreadRequestStatus.error, }); } else if (postThread) { return threadTemplate({ postThread, currentUser }); } else { return threadSkeletonTemplate(); } })()}
`, root, ); // Pin large post on first load const largePost = root.querySelector(".large-post"); const header = root.querySelector("header"); if ( largePost && header && !postThread.__isPrefill && !hasScrolledToLargePost ) { hasScrolledToLargePost = true; if (!userHasScrolled) { scrollToLargePost(largePost, header); } } }); function getLargePostPinOffset(largePost, header) { const headerHeight = header.getBoundingClientRect().height; return largePost.getBoundingClientRect().top - headerHeight; } // The browser clamps scrolling at the document height, so when there // isn't enough content below the post to pin it under the header, // stretch the main section to provide the missing scroll runway. function ensureScrollRunway(largePost, header) { const mainSection = root.querySelector(".post-thread-main-section"); if (!mainSection) { return; } mainSection.style.minHeight = ""; const targetScrollY = window.scrollY + getLargePostPinOffset(largePost, header); const shortfall = targetScrollY + window.innerHeight - document.documentElement.scrollHeight; if (shortfall > -1) { const sectionHeight = mainSection.getBoundingClientRect().height; mainSection.style.minHeight = `${Math.ceil(sectionHeight + shortfall) + 1}px`; } } function scrollToLargePost(largePost, header) { ensureScrollRunway(largePost, header); pinScrollPosition({ targetY: () => { const offset = getLargePostPinOffset(largePost, header); return window.scrollY + offset; }, }); } root.addEventListener("page-enter", async () => { userHasScrolled = false; try { await dataLayer.declarative.ensurePostThread(postUri); } catch (error) { // pass } }); root.addEventListener("page-restore", async (e) => { userHasScrolled = false; const scrollY = e.detail?.scrollY ?? 0; const isBack = e.detail?.isBack ?? false; if (isBack) { window.scrollTo(0, scrollY); } else { const largePost = root.querySelector(".large-post"); const header = root.querySelector("header"); if (largePost && header) { scrollToLargePost(largePost, header); } } // Revalidate await dataLayer.requests.loadPostThread(postUri); }); } } export default new PostThreadView();