import { html, render } from "/js/lib/lit-html.js"; import { resolveDidFromHandleOrDid } from "/js/atproto.js"; import { avatarTemplate } from "/js/templates/avatar.template.js"; import { sortBy, maxBy, pinScrollPosition } from "/js/utils.js"; import { bindToPage, pageEffect, bindPageTitle, onPageShow, onPageHide, } 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 { ApiError } from "/js/api.js"; import "/js/components/app-icon.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"; import { tryAgainButtonTemplate } from "/js/templates/tryAgainButton.template.js"; export default async function postThreadView({ root, router, params, context: { dataLayer, identityResolver, postComposerService, isAuthenticated, pluginService, interactionHandlers, }, }) { const { handleOrDid, rkey } = params; const authorDid = await resolveDidFromHandleOrDid( handleOrDid, identityResolver, ); const postUri = `at://${authorDid}/app.bsky.feed.post/${rkey}`; const { postInteractionHandler, profileInteractionHandler } = interactionHandlers; // Mirrors social-app: the follow button only shows for authors the viewer // isn't following, but stays visible (as "Following") after an in-view // follow so it can be undone. function doShowFollowButton( author, rootPost, currentUser, hasFollowedInView, ) { if (!isAuthenticated || !currentUser || !author?.did) { return false; } if (author.did === currentUser.did) { return false; } const isRootAuthor = rootPost?.author?.did === author.did; const onlyFollowersCanReply = !!rootPost?.threadgate?.record?.allow?.some( (rule) => rule.$type === "app.bsky.feed.threadgate#followerRule", ); if (isRootAuthor && onlyFollowersCanReply) { return false; } if (author.viewer?.blocking || author.viewer?.blockedBy) { return false; } return !author.viewer?.following || hasFollowedInView; } function postThreadErrorTemplate({ error }) { if ( error instanceof ApiError && error.status === 400 && error.data?.error === "NotFound" ) { return html`
Post not found
${tryAgainButtonTemplate()}
`; } else { console.error(error); return html`
Error loading thread
${tryAgainButtonTemplate()}
`; } } 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) { const mostLikedReply = maxBy(shownReplies, (reply) => getLikesWithoutUser(reply.post), ); 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 = reply.post; 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`
` : ""}
${replyContext === "root" || replyContext === "parent" ? html`
` : ""}
${NO_UNAUTHENTICATED_MESSAGE}
`; } function noUnauthenticatedLargePostTemplate() { return html`
${NO_UNAUTHENTICATED_MESSAGE}
`; } function threadTemplate({ postThread, currentUser, hasFollowedInView }) { 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 rootCandidate = parents.length ? parents[0].post : mainPost; const rootPost = rootCandidate?.uri === root?.uri ? rootCandidate : null; 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, showFollowButton: doShowFollowButton( postAuthor, rootPost, currentUser, hasFollowedInView, ), isFollowPending: postAuthor?.did ? dataLayer.derived.$isFollowPending.get(postAuthor.did) : false, onClickFollow: (profile, doFollow) => { if (doFollow) { state.$hasFollowedInView.set(true); } profileInteractionHandler.handleFollow(profile, doFollow); }, 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(); }, afterBlock: () => { // if the main post's author is blocked, 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.$hasFollowedInView = new Signal.State(false); 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; }); bindPageTitle(root, () => { const postThread = state.$postThread.get(); const handle = postThread?.post?.author?.handle; if (handle) { return `Post by @${handle}`; } 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); const hasFollowedInView = state.$hasFollowedInView.get(); render( html`
${headerTemplate({ title: "Post" })}
${(() => { if (postThreadRequestStatus.error) { return postThreadErrorTemplate({ error: postThreadRequestStatus.error, }); } else if (postThread) { return threadTemplate({ postThread, currentUser, hasFollowedInView, }); } 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; } function scrollToLargePost(largePost, header) { pinScrollPosition({ targetY: () => { const offset = getLargePostPinOffset(largePost, header); return window.scrollY + offset; }, }); } onPageShow(root, async ({ action, scrollY }) => { userHasScrolled = false; if (action === "restore") { window.scrollTo(0, scrollY); } else { // On a revisit the thread is already rendered, so pin it under the header const largePost = root.querySelector(".large-post"); const header = root.querySelector("header"); if (largePost && header) { scrollToLargePost(largePost, header); } } // Revalidate await dataLayer.requests.loadPostThread(postUri); }); onPageHide(root, () => { state.$hasFollowedInView.set(false); }); }