Something went wrong. Try again.
[READ-ONLY] Mirror of https://github.com/improsocial/impro
An extensible Bluesky client for web impro.social
Something went wrong. Try again.
JavaScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684import { 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`<div class="error-state" data-testid="post-not-found"> <div>Post not found</div> ${tryAgainButtonTemplate()} </div>`; } else { console.error(error); return html`<div class="error-state" data-testid="thread-error"> <div>Error loading thread</div> ${tryAgainButtonTemplate()} </div>`; } }
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`<div class="post-thread-reply-chain"> ${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, }); })} </div>`; }
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` <div class="post-thread-replies"> ${isEmpty ? html`<plugin-slot name="post-thread-view:replies-empty" context-uri=${postUri} .pluginService=${pluginService} ></plugin-slot>` : html`<plugin-slot name="post-thread-view:replies-header" context-uri=${postUri} .pluginService=${pluginService} ></plugin-slot> <div class="post-thread-reply-chains"> ${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, }), )} </div> ${hiddenSectionReplies.length > 0 ? html`<hidden-replies-section> ${hiddenSectionReplies.map((reply) => smallPostTemplate({ post: reply.post, currentUser, isAuthenticated, isUserPost: currentUser?.did === reply.post?.author?.did, postInteractionHandler, ignoreContentWarning: true, ignoreMuteWarning: true, lazyLoadImages: true, pluginService, }), )} </hidden-replies-section>` : ""} `} <plugin-slot name="post-thread-view:after-replies" context-uri=${postUri} .pluginService=${pluginService} ></plugin-slot> <div class="post-thread-extra-space"></div> </div> `; }
function repliesSkeletonTemplate({ numReplies }) { return html` <div class="post-thread-replies-skeleton"> ${Array.from({ length: Math.min(numReplies, 10) }).map(() => postSkeletonTemplate(), )} </div> `; }
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`<div class="post small-post"> <div class="post-content-with-space"> <div class="post-content-left"> ${replyContext === "parent" || replyContext === "reply" ? html`<div class="reply-context-line-in"></div>` : ""} <div class="no-unauthenticated-avatar"> <app-icon icon="lock-line"></app-icon> </div> ${replyContext === "root" || replyContext === "parent" ? html`<div class="reply-context-line-out-container"> <div class="reply-context-line-out"></div> </div>` : ""} </div> <div class="post-content-right"> <div class="no-unauthenticated-message"> ${NO_UNAUTHENTICATED_MESSAGE} </div> </div> </div> </div>`; }
function noUnauthenticatedLargePostTemplate() { return html`<div class="post large-post no-unauthenticated-post"> <div class="no-unauthenticated-header"> <div class="no-unauthenticated-avatar"> <app-icon icon="lock-line"></app-icon> </div> <div class="no-unauthenticated-skeleton-text"> <div class="skeleton-line skeleton-line-short"></div> <div class="skeleton-line skeleton-line-medium"></div> </div> </div> <div class="no-unauthenticated-message no-unauthenticated-message-large"> ${NO_UNAUTHENTICATED_MESSAGE} </div> </div>`; }
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` <div class="post-thread"> <plugin-slot name="post-thread-view:top" context-uri=${postUri} .pluginService=${pluginService} ></plugin-slot> ${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`<div class="load-more-link"> <div class="load-more-spacer"> <div class="reply-context-line-gap"></div> </div> <a href=${linkToPostFromUri(replyParent.uri)} data-testid="post-thread-load-parent" >Load parent post</a > </div>` : ""} <plugin-slot name="post-thread-view:before-main" context-uri=${postUri} .pluginService=${pluginService} ></plugin-slot> <div class="post-thread-main-section"> ${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, })} <plugin-slot name="post-thread-view:after-main" context-uri=${postUri} .pluginService=${pluginService} ></plugin-slot> ${!postThread.__isEmbeddedPrefill && isAuthenticated && currentUser && canReplyToPost(mainPost) ? html` <div class="post-thread-reply-prompt" @click=${async () => { await handleClickReply(mainPost, root, currentUser); }} > <div class="post-thread-reply-prompt-inner"> ${avatarTemplate({ author: currentUser, clickAction: "none", })} <span class="post-thread-reply-prompt-text"> Write your reply </span> </div> </div> ` : ""} ${(() => { if (hiddenUnauthenticated) { return ""; } if (replies) { return postThreadRepliesTemplate({ replies, postAuthor, currentUser, }); } const numReplies = mainPost?.replyCount; if (numReplies > 0) { return repliesSkeletonTemplate({ numReplies }); } return ""; })()} </div> </div> `; } catch (error) { return postThreadErrorTemplate({ error }); } }
function threadSkeletonTemplate() { return html`<div class="post-thread"> ${Array.from({ length: 3 }).map(() => { return postSkeletonTemplate(); })} </div>`; }
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`<div id="post-detail-view"> ${headerTemplate({ title: "Post" })} <main> ${(() => { if (postThreadRequestStatus.error) { return postThreadErrorTemplate({ error: postThreadRequestStatus.error, }); } else if (postThread) { return threadTemplate({ postThread, currentUser, hasFollowedInView, }); } else { return threadSkeletonTemplate(); } })()} </main> </div>`, 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); });}