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);
});
}