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,
}),
)}
`;
}
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`
`;
}
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`
${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`