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, isMobileViewport, classnames, } 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, 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"; const THREAD_TREE_MAX_DEPTH_MOBILE = 4; const THREAD_TREE_MAX_DEPTH_DESKTOP = 6; const THREAD_TREE_BRANCHING_FACTOR = 10; 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 isNotFoundError(error) { return ( error instanceof ApiError && error.status === 400 && error.data?.error === "NotFound" ); } function threadLoadErrorTemplate({ onRetry }) { return html`
Couldn't load this thread.
${tryAgainButtonTemplate({ onClick: onRetry })}
`; } function postThreadErrorTemplate({ error }) { if (isNotFoundError(error)) { 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 replyHasHideLabel(reply) { return ( reply.post.mediaLabel?.visibility === "hide" || !!reply.post.badgeLabels?.some((badge) => badge.visibility === "hide") ); } function doShowReply(reply) { const post = reply.post; if (!post) { return false; } if ( isBlockedPost(post) || isNotFoundPost(post) || isMutedPost(post) || post.isBlockedReply || replyHasContentLabel(reply) || replyHasHideLabel(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 sortReplies(replies, postAuthor) { let sortedReplies = sortBy( replies, (reply) => getLikesWithoutUser(reply.post), { direction: "desc", }, ); // Put replies by the post author first if (postAuthor) { sortedReplies = [ ...sortedReplies.filter( (reply) => reply.post.author?.did === postAuthor.did, ), ...sortedReplies.filter( (reply) => reply.post.author?.did !== postAuthor.did, ), ]; } // If there's a recent reply from the user, put it at the top const recentReplyFromUser = sortedReplies.find( (reply) => reply.post.viewer?.priorityReply, ); if (recentReplyFromUser) { sortedReplies = [ recentReplyFromUser, ...sortedReplies.filter((reply) => reply !== recentReplyFromUser), ]; } return sortedReplies; } function buildReplyChains(replies, postAuthor) { return sortReplies(getShownReplies(replies), postAuthor).map((reply) => buildReplyChain(reply), ); } function buildThreadTree(postThread, { maxDepth, postAuthor }) { function walk( reply, depth, parentSkippedIndices, isLastSibling, parentHasMore, rows, ) { const skippedIndentIndices = new Set(parentSkippedIndices); if (depth > 1 && isLastSibling && !parentHasMore) { skippedIndentIndices.add(depth - 2); } const replies = depth < maxDepth ? (reply.replies ?? []) : []; const shownReplies = sortReplies(getShownReplies(replies), postAuthor); const keptReplies = shownReplies.slice(0, THREAD_TREE_BRANCHING_FACTOR); const numLoadedReplies = replies.filter( (child) => !isNotFoundPost(child.post ?? child), ).length; const moreReplies = Math.max(0, (reply.post.replyCount ?? 0) - numLoadedReplies) + (shownReplies.length - keptReplies.length); rows.push({ type: "post", key: reply.post.uri, reply, depth, skippedIndentIndices, showChildReplyLine: keptReplies.length > 0 || moreReplies > 0, }); keptReplies.forEach((child, i) => { walk( child, depth + 1, skippedIndentIndices, i === keptReplies.length - 1, moreReplies > 0, rows, ); }); if (moreReplies > 0) { rows.push({ type: "readMore", key: `readMore:${reply.post.uri}`, depth, ownerUri: reply.post.uri, moreReplies, skippedIndentIndices, }); } } const topLevelReplies = postThread.replies ?? []; const items = []; for (const reply of sortReplies( getShownReplies(topLevelReplies), postAuthor, )) { walk(reply, 1, new Set(), true, false, items); } const hiddenItems = []; for (const reply of topLevelReplies.filter( (reply) => !doShowReply(reply) && doPutReplyInHiddenSection(reply), )) { walk(reply, 1, new Set(), true, false, hiddenItems); } return { items, hiddenItems }; } 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), postNumbering: reply.postNumbering, 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) || replyHasHideLabel(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 linearRepliesTemplate({ replies, postAuthor, currentUser }) { const hiddenSectionReplies = replies.filter((reply) => doPutReplyInHiddenSection(reply), ); const replyChains = buildReplyChains(replies, postAuthor); const isEmpty = replyChains.length === 0 && hiddenSectionReplies.length === 0; const content = 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, }), )} ` : ""}`; return { isEmpty, content }; } function treeIndentColumnsTemplate({ depth, skippedIndentIndices }) { return Array.from({ length: Math.max(0, depth - 1) }).map( (_, i) => html``, ); } function treeReplyRowTemplate({ row, currentUser, lazyLoadImages, isHiddenRoot, }) { const post = row.reply.post; return html`
${treeIndentColumnsTemplate(row)}
${row.depth > 1 ? html`` : ""} ${row.showChildReplyLine ? html`` : ""} ${smallPostTemplate({ post, currentUser, isAuthenticated, isUserPost: currentUser?.did === post.author?.did, postInteractionHandler, replyContext: null, postNumbering: row.reply.postNumbering, ignoreContentWarning: isHiddenRoot, ignoreMuteWarning: isHiddenRoot, lazyLoadImages, pluginService, })}
`; } function treeReadMoreTemplate({ row }) { return html`
${treeIndentColumnsTemplate(row)} Read ${row.moreReplies} more ${row.moreReplies === 1 ? "reply" : "replies"}
`; } function treeRowsTemplate({ rows, currentUser, isHiddenSection }) { return rows.map((row, i) => row.type === "readMore" ? treeReadMoreTemplate({ row }) : treeReplyRowTemplate({ row, currentUser, lazyLoadImages: isHiddenSection || i > 20, isHiddenRoot: isHiddenSection && row.depth === 1, }), ); } function treeRepliesTemplate({ postThread, postAuthor, currentUser }) { const { items, hiddenItems } = buildThreadTree(postThread, { maxDepth: isMobileViewport() ? THREAD_TREE_MAX_DEPTH_MOBILE : THREAD_TREE_MAX_DEPTH_DESKTOP, postAuthor, }); const isEmpty = items.length === 0 && hiddenItems.length === 0; const content = html`
${treeRowsTemplate({ rows: items, currentUser, isHiddenSection: false, })}
${hiddenItems.length > 0 ? html` ${treeRowsTemplate({ rows: hiddenItems, currentUser, isHiddenSection: true, })} ` : ""}`; return { isEmpty, content }; } function postThreadRepliesTemplate({ postThread, postAuthor, currentUser, threadView, }) { const { isEmpty, content } = threadView === "tree" ? treeRepliesTemplate({ postThread, postAuthor, currentUser }) : linearRepliesTemplate({ replies: postThread.replies, postAuthor, currentUser, }); return html`
${isEmpty ? html`` : html` ${content}`}
`; } 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, loadError, threadView, }) { 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, postNumbering: parent.postNumbering, ignoreMuteWarning: true, pluginService, }), }); })} ${hasBrokenReplyRef ? html`` : ""}
${hiddenUnauthenticated ? noUnauthenticatedLargePostTemplate() : largePostTemplate({ post: mainPost, currentUser, isAuthenticated, pluginService, isUserPost: currentUser?.did === mainPost?.author?.did, postInteractionHandler, postNumbering: postThread.postNumbering, 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, showActionBar: !postThread.__isEmbeddedPrefill, })} ${!postThread.__isEmbeddedPrefill && isAuthenticated && currentUser && canReplyToPost(mainPost) ? html`
{ await handleClickReply(mainPost, root, currentUser); }} >
${avatarTemplate({ author: currentUser, clickAction: "none", })} Write your reply
` : ""} ${(() => { if (loadError) { return threadLoadErrorTemplate({ onRetry: retryLoadPostThread, }); } if (hiddenUnauthenticated) { return ""; } if (replies && threadView !== null) { return postThreadRepliesTemplate({ postThread, postAuthor, currentUser, threadView, }); } 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, postNumbering: dataLayer.derived.$postNumbering.get(postUri), 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(); const threadView = dataLayer.derived.$threadView.get(); render( html`
${headerTemplate({ title: "Post" })}
${(() => { const loadError = postThreadRequestStatus.error; if (loadError && (!postThread || isNotFoundError(loadError))) { return postThreadErrorTemplate({ error: loadError }); } else if (postThread) { return threadTemplate({ postThread, currentUser, hasFollowedInView, loadError, threadView, }); } 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; dataLayer.preferencesProvider.requirePreferences().catch((error) => { console.warn("Failed to load preferences", error); }); 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); }); async function retryLoadPostThread() { await dataLayer.requests.loadPostThread(postUri); } onPageHide(root, () => { state.$hasFollowedInView.set(false); }); }