/**
* Renders a post/thread view and its subviews.
*/
class PostComponent {
/**
* Post component's root HTML element, if built.
* @type {HTMLElement | undefined}
*/
_rootElement;
/**
Contexts:
- thread - a post in the thread tree
- parent - parent reference above the thread root
- quote - a quote embed
- quotes - a post on the quotes page
- feed - a post on the hashtag feed page
@typedef {'thread' | 'parent' | 'quote' | 'quotes' | 'feed'} PostContext
@param {AnyPost} post, @param {PostContext} context
*/
constructor(post, context) {
this.post = /** @type {Post}, TODO */ (post);
this.context = context;
}
/**
* @returns {HTMLElement}
*/
get rootElement() {
if (!this._rootElement) {
throw new Error("rootElement not initialized");
}
return this._rootElement;
}
/** @returns {boolean} */
get isRoot() {
return this.post.isRoot;
}
/** @returns {string} */
get linkToAuthor() {
if (this.post.author.handle != 'handle.invalid') {
return 'https://bsky.app/profile/' + this.post.author.handle;
} else {
return 'https://bsky.app/profile/' + this.post.author.did;
}
}
/** @returns {string} */
get linkToPost() {
return this.linkToAuthor + '/post/' + this.post.rkey;
}
/** @returns {string} */
get didLinkToAuthor() {
let { repo } = atURI(this.post.uri);
return `https://bsky.app/profile/${repo}`;
}
/** @returns {string} */
get didLinkToPost() {
let { repo, rkey } = atURI(this.post.uri);
return `https://bsky.app/profile/${repo}/post/${rkey}`;
}
/** @returns {string} */
get authorName() {
if (this.post.author.displayName) {
return this.post.author.displayName;
} else if (this.post.author.handle.endsWith('.bsky.social')) {
return this.post.author.handle.replace(/\.bsky\.social$/, '');
} else {
return this.post.author.handle;
}
}
/** @returns {json} */
get timeFormatForTimestamp() {
if (this.context == 'quotes' || this.context == 'feed') {
return { weekday: 'short', day: 'numeric', month: 'short', year: 'numeric', hour: 'numeric', minute: 'numeric' };
} else if (this.isRoot || this.context != 'thread') {
return { day: 'numeric', month: 'short', year: 'numeric', hour: 'numeric', minute: 'numeric' };
} else if (this.post.pageRoot && !sameDay(this.post.createdAt, this.post.pageRoot.createdAt)) {
return { day: 'numeric', month: 'short', hour: 'numeric', minute: 'numeric' };
} else {
return { hour: 'numeric', minute: 'numeric' };
}
}
/** @param {HTMLElement} nodeToUpdate */
installIntoElement(nodeToUpdate) {
let view = this.buildElement();
let oldContent = $(nodeToUpdate.querySelector('.content'));
let newContent = $(view.querySelector('.content'));
oldContent.replaceWith(newContent);
this._rootElement = nodeToUpdate;
}
/** @returns {HTMLElement} */
buildElement() {
if (this._rootElement) {
return this._rootElement;
}
let div = $tag('div.post', `post-${this.context}`);
this._rootElement = div;
if (this.post.muted) {
div.classList.add('muted');
}
if (this.post instanceof BlockedPost) {
this.buildBlockedPostElement(div);
return div;
} else if (this.post instanceof DetachedQuotePost) {
this.buildDetachedQuoteElement(div);
return div;
} else if (this.post instanceof MissingPost) {
this.buildMissingPostElement(div);
return div;
}
let header = this.buildPostHeader();
div.appendChild(header);
let content = $tag('div.content');
if (this.context == 'thread' && !this.isRoot) {
let edgeMargin = this.buildEdgeMargin();
div.appendChild(edgeMargin);
}
let wrapper;
if (this.post.muted) {
let details = $tag('details');
let summary = $tag('summary');
summary.innerText = this.post.muteList ? `Muted (${this.post.muteList})` : 'Muted - click to show';
details.appendChild(summary);
content.appendChild(details);
wrapper = details;
} else {
wrapper = content;
}
let p = this.buildPostBody();
wrapper.appendChild(p);
if (this.post.tags) {
let tagsRow = this.buildTagsRow(this.post.tags);
wrapper.appendChild(tagsRow);
}
if (this.post.embed) {
let embed = new EmbedComponent(this.post, this.post.embed).buildElement();
wrapper.appendChild(embed);
if (this.post.originalFediURL) {
if (this.post.embed instanceof InlineLinkEmbed && this.post.embed.title.startsWith('Original post on ')) {
embed.remove();
}
}
}
if (this.post.originalFediURL) {
let link = this.buildFediSourceLink(this.post.originalFediURL);
if (link) {
wrapper.appendChild(link);
}
}
if (this.post.likeCount !== undefined && this.post.repostCount !== undefined) {
let stats = this.buildStatsFooter();
wrapper.appendChild(stats);
}
if (this.post.replyCount == 1 && this.post.replies[0]?.author?.did == this.post.author.did) {
let component = new PostComponent(this.post.replies[0], 'thread');
let element = component.buildElement();
element.classList.add('flat');
content.appendChild(element);
} else {
for (let reply of this.post.replies) {
if (reply instanceof MissingPost) { continue }
if (reply instanceof BlockedPost && window.biohazardEnabled === false) { continue }
let component = new PostComponent(reply, 'thread');
content.appendChild(component.buildElement());
}
}
if (this.context == 'thread') {
if (this.post.hasMoreReplies) {
let loadMore = this.buildLoadMoreLink();
content.appendChild(loadMore);
} else if (this.post.hasHiddenReplies && window.biohazardEnabled !== false) {
let loadMore = this.buildHiddenRepliesLink();
content.appendChild(loadMore);
}
}
div.appendChild(content);
return div;
}
/** @returns {HTMLElement} */
buildPostHeader() {
let timeFormat = this.timeFormatForTimestamp;
let formattedTime = this.post.createdAt.toLocaleString(window.dateLocale, timeFormat);
let isoTime = this.post.createdAt.toISOString();
let h = $tag('h2');
h.innerHTML = `${escapeHTML(this.authorName)} `;
if (this.post.isFediPost) {
let handle = `@${this.post.authorFediHandle}`;
h.innerHTML += `${handle} ` +
`
`;
} else {
let handle = (this.post.author.handle != 'handle.invalid') ? `@${this.post.author.handle}` : '[invalid handle]';
h.innerHTML += `${handle} `;
}
h.innerHTML += `• ` +
`${formattedTime} `;
if (this.post.replyCount > 0 && !this.isRoot || ['quote', 'quotes', 'feed'].includes(this.context)) {
h.innerHTML +=
`• ` +
`` +
` `;
}
if (this.post.muted) {
h.prepend($tag('i', 'missing fa-regular fa-circle-user fa-2x'));
} else if (this.post.author.avatar) {
h.prepend(this.buildUserAvatar(this.post.author.avatar));
} else {
h.prepend($tag('i', 'missing fa-regular fa-face-smile fa-2x'));
}
return h;
}
buildEdgeMargin() {
let div = $tag('div.margin');
let edge = $tag('div.edge');
let line = $tag('div.line');
edge.appendChild(line);
div.appendChild(edge);
let plus = $tag('img.plus', { src: 'icons/subtract-square.png' });
div.appendChild(plus);
[edge, plus].forEach(x => {
x.addEventListener('click', (e) => {
e.preventDefault();
this.toggleSectionFold();
});
});
return div;
}
/** @param {string} url, @returns {HTMLImageElement} */
buildUserAvatar(url) {
let avatar = $tag('img.avatar', { loading: 'lazy' }, HTMLImageElement); // needs to be set before src!
avatar.src = url;
window.avatarPreloader.observe(avatar);
return avatar;
}
/** @returns {HTMLElement} */
buildPostBody() {
if (this.post.originalFediContent) {
return $tag('div.body', { html: sanitizeHTML(this.post.originalFediContent) });
}
let p = $tag('p.body');
let richText = new RichText({ text: this.post.text, facets: this.post.facets });
for (let seg of richText.segments()) {
if (seg.mention) {
p.append($tag('a', { href: `https://bsky.app/profile/${seg.mention.did}`, text: seg.text }));
} else if (seg.link) {
p.append($tag('a', { href: seg.link.uri, text: seg.text }));
} else if (seg.tag) {
let url = new URL(getLocation());
url.searchParams.set('hash', seg.tag.tag);
p.append($tag('a', { href: url.toString(), text: seg.text }));
} else if (seg.text.includes("\n")) {
let span = $tag('span', { text: seg.text });
span.innerHTML = span.innerHTML.replaceAll("\n", "
");
p.append(span);
} else {
p.append(seg.text);
}
}
return p;
}
/** @param {string[]} terms */
highlightSearchResults(terms) {
let regexp = new RegExp(`\\b(${terms.join('|')})\\b`, 'gi');
let root = this.rootElement;
let body = $(root.querySelector(':scope > .content > .body, :scope > .content > details .body'));
let walker = document.createTreeWalker(body, NodeFilter.SHOW_TEXT);
let textNodes = [];
while (walker.nextNode()) {
textNodes.push(walker.currentNode);
}
for (let node of textNodes) {
if (!node.textContent) { continue; }
let markedText = document.createDocumentFragment();
let currentPosition = 0;
for (;;) {
let match = regexp.exec(node.textContent);
if (match === null) break;
if (match.index > currentPosition) {
let earlierText = node.textContent.slice(currentPosition, match.index);
markedText.appendChild(document.createTextNode(earlierText));
}
let span = $tag('span.highlight', { text: match[0] });
markedText.appendChild(span);
currentPosition = match.index + match[0].length;
}
if (currentPosition < node.textContent.length) {
let remainingText = node.textContent.slice(currentPosition);
markedText.appendChild(document.createTextNode(remainingText));
}
$(node.parentNode).replaceChild(markedText, node);
}
}
/** @param {string[]} tags, @returns {HTMLElement} */
buildTagsRow(tags) {
let p = $tag('p.tags');
for (let tag of tags) {
let url = new URL(getLocation());
url.searchParams.set('hash', tag);
let tagLink = $tag('a', { href: url.toString(), text: '# ' + tag });
p.append(tagLink);
}
return p;
}
/** @returns {HTMLElement} */
buildStatsFooter() {
let stats = $tag('p.stats');
let span = $tag('span');
let heart = $tag('i', 'fa-solid fa-heart ' + (this.post.liked ? 'liked' : ''));
heart.addEventListener('click', (e) => this.onHeartClick(heart));
span.append(heart, ' ', $tag('output', { text: this.post.likeCount }));
stats.append(span);
if (this.post.repostCount > 0) {
let span = $tag('span', { html: ` ${this.post.repostCount}` });
stats.append(span);
}
if (this.post.replyCount > 0 && (this.context == 'quotes' || this.context == 'feed')) {
let pluralizedCount = (this.post.replyCount > 1) ? `${this.post.replyCount} replies` : '1 reply';
let span = $tag('span', {
html: ` ${pluralizedCount}`
});
stats.append(span);
}
if (!this.isRoot && this.context != 'quote' && this.post.quoteCount) {
let expanded = this.context == 'quotes' || this.context == 'feed';
let quotesLink = this.buildQuotesIconLink(this.post.quoteCount, expanded);
stats.append(quotesLink);
}
if (this.context == 'thread' && this.post.isRestrictingReplies) {
let span = $tag('span', { html: ` Limited replies` });
stats.append(span);
}
return stats;
}
/** @param {number} count, @param {boolean} expanded, @returns {HTMLElement} */
buildQuotesIconLink(count, expanded) {
let q = new URL(getLocation());
q.searchParams.set('quotes', this.linkToPost);
let url = q.toString();
let icon = ``;
if (expanded) {
let span = $tag('span', { html: `${icon} ` });
let link = $tag('a', { text: (count > 1) ? `${count} quotes` : '1 quote', href: url });
span.append(link);
return span;
} else {
return $tag('a', { html: `${icon} ${count}`, href: url });
}
}
/** @param {number} quoteCount, @param {boolean} expanded */
appendQuotesIconLink(quoteCount, expanded) {
let stats = $(this.rootElement.querySelector(':scope > .content > p.stats'));
let quotesLink = this.buildQuotesIconLink(quoteCount, expanded);
stats.append(quotesLink);
}
/** @returns {HTMLElement} */
buildLoadMoreLink() {
let loadMore = $tag('p');
let link = $tag('a', {
href: linkToPostThread(this.post),
text: "Load more replies…"
});
link.addEventListener('click', (e) => {
e.preventDefault();
loadMore.innerHTML = `
`;
this.loadSubtree(this.post, this.rootElement);
});
loadMore.appendChild(link);
return loadMore;
}
/** @returns {HTMLElement} */
buildHiddenRepliesLink() {
let loadMore = $tag('p.hidden-replies');
let link = $tag('a', {
href: linkToPostThread(this.post),
text: "Load hidden replies…"
});
link.addEventListener('click', (e) => {
e.preventDefault();
if (window.biohazardEnabled === true) {
this.loadHiddenReplies(loadMore);
} else {
window.loadInfohazard = () => this.loadHiddenReplies(loadMore);
showDialog($id('biohazard_dialog'));
}
});
loadMore.append("☣️ ", link);
return loadMore;
}
/** @param {HTMLElement} loadMoreButton */
loadHiddenReplies(loadMoreButton) {
loadMoreButton.innerHTML = `
`;
this.loadHiddenSubtree(this.post, this.rootElement);
}
/** @param {string} url, @returns {HTMLElement | undefined} */
buildFediSourceLink(url) {
try {
let hostname = new URL(url).hostname;
let a = $tag('a.fedi-link', { href: url, target: '_blank' });
let box = $tag('div', { html: ` View on ${hostname}` });
a.append(box);
return a;
} catch (error) {
console.log("Invalid Fedi URL:" + error);
return undefined;
}
}
/** @param {HTMLLinkElement} authorLink */
loadReferencedPostAuthor(authorLink) {
let did = atURI(this.post.uri).repo;
api.fetchHandleForDid(did).then(handle => {
if (this.post.author) {
this.post.author.handle = handle;
} else {
this.post.author = { did, handle };
}
authorLink.href = this.linkToAuthor;
authorLink.innerText = `@${handle}`;
});
}
/** @param {HTMLElement} div, @returns {HTMLElement} */
buildBlockedPostElement(div) {
let p = $tag('p.blocked-header');
p.innerHTML = ` Blocked post`;
if (window.biohazardEnabled === false) {
div.appendChild(p);
div.classList.add('blocked');
return p;
}
let blockStatus = this.post.blockedByUser ? 'has blocked you' : this.post.blocksUser ? "you've blocked them" : '';
blockStatus = blockStatus ? `, ${blockStatus}` : '';
let authorLink = $tag('a', { href: this.didLinkToAuthor, target: '_blank', text: 'see author' }, HTMLLinkElement);
p.append(' (', authorLink, blockStatus, ') ');
div.appendChild(p);
this.loadReferencedPostAuthor(authorLink);
let loadPost = $tag('p.load-post');
let a = $tag('a', { href: '#', text: "Load post…" });
a.addEventListener('click', (e) => {
e.preventDefault();
loadPost.innerHTML = ' ';
this.loadBlockedPost(this.post.uri, div);
});
loadPost.appendChild(a);
div.appendChild(loadPost);
div.classList.add('blocked');
return div;
}
/** @param {HTMLElement} div, @returns {HTMLElement} */
buildDetachedQuoteElement(div) {
let p = $tag('p.blocked-header');
p.innerHTML = ` Hidden quote`;
if (window.biohazardEnabled === false) {
div.appendChild(p);
div.classList.add('blocked');
return p;
}
let authorLink = $tag('a', { href: this.didLinkToAuthor, target: '_blank', text: 'see author' }, HTMLLinkElement);
p.append(' (', authorLink, ') ');
div.appendChild(p);
this.loadReferencedPostAuthor(authorLink);
let loadPost = $tag('p.load-post');
let a = $tag('a', { href: '#', text: "Load post…" });
a.addEventListener('click', (e) => {
e.preventDefault();
loadPost.innerHTML = ' ';
this.loadBlockedPost(this.post.uri, div);
});
loadPost.appendChild(a);
div.appendChild(loadPost);
div.classList.add('blocked');
return div;
}
/** @param {HTMLElement} div, @returns {HTMLElement} */
buildMissingPostElement(div) {
let p = $tag('p.blocked-header');
p.innerHTML = ` Deleted post`;
let authorLink = $tag('a', { href: this.didLinkToAuthor, target: '_blank', text: 'see author' }, HTMLLinkElement);
p.append(' (', authorLink, ') ');
this.loadReferencedPostAuthor(authorLink);
div.appendChild(p);
div.classList.add('blocked');
return div;
}
/** @param {string} uri, @param {HTMLElement} div, @returns Promise */
async loadBlockedPost(uri, div) {
let record = await appView.loadPostIfExists(this.post.uri);
if (!record) {
let post = new MissingPost({ uri: this.post.uri });
let postView = new PostComponent(post, 'quote').buildElement();
div.replaceWith(postView);
return;
}
this.post = new Post(record);
let userView = await api.getRequest('app.bsky.actor.getProfile', { actor: this.post.author.did });
if (!userView.viewer || !(userView.viewer.blockedBy || userView.viewer.blocking)) {
let { repo, rkey } = atURI(this.post.uri);
let a = $tag('a', {
href: linkToPostById(repo, rkey),
className: 'action',
title: "Load thread",
html: ``
});
let header = $(div.querySelector('p.blocked-header'));
let separator = $tag('span.separator', { html: '•' });
header.append(separator, ' ', a);
}
let loadPost = $(div.querySelector('p.load-post'));
loadPost.remove();
if (this.isRoot && this.post.parentReference) {
let { repo, rkey } = atURI(this.post.parentReference.uri);
let url = linkToPostById(repo, rkey);
let handle = api.findHandleByDid(repo);
let link = handle ? `See parent post (@${handle})` : "See parent post";
let p = $tag('p.back', { html: `${link}` });
div.appendChild(p);
}
let p = this.buildPostBody();
div.appendChild(p);
if (this.post.embed) {
let embed = new EmbedComponent(this.post, this.post.embed).buildElement();
div.appendChild(embed);
// TODO
Array.from(div.querySelectorAll('a.link-card')).forEach(x => x.remove());
}
}
/** @param {Post} post, @param {HTMLElement} nodeToUpdate, @returns {Promise} */
async loadSubtree(post, nodeToUpdate) {
try {
let json = await api.loadThreadByAtURI(post.uri);
let root = Post.parseThreadPost(json.thread, post.pageRoot, 0, post.absoluteLevel);
post.updateDataFromPost(root);
window.subtreeRoot = post;
let component = new PostComponent(post, 'thread');
component.installIntoElement(nodeToUpdate);
} catch (error) {
showError(error);
}
}
/** @param {Post} post, @param {HTMLElement} nodeToUpdate, @returns {Promise} */
async loadHiddenSubtree(post, nodeToUpdate) {
let content = $(nodeToUpdate.querySelector('.content'));
let hiddenRepliesDiv = $(content.querySelector(':scope > .hidden-replies'));
try {
var expectedReplyURIs = await blueAPI.getReplies(post.uri);
} catch (error) {
hiddenRepliesDiv.remove();
if (error instanceof APIError && error.code == 404) {
let info = $tag('p.missing-replies-info', {
html: ` Hidden replies not available (post too old)`
});
content.append(info);
} else {
setTimeout(() => showError(error), 1);
}
return;
}
let missingReplyURIs = expectedReplyURIs.filter(r => !post.replies.some(x => x.uri === r));
let promises = missingReplyURIs.map(uri => api.loadThreadByAtURI(uri));
try {
// TODO
var responses = await Promise.allSettled(promises);
} catch (error) {
hiddenRepliesDiv.remove();
setTimeout(() => showError(error), 1);
return;
}
let replies = responses
.map(r => r.status == 'fulfilled' ? r.value : undefined)
.filter(v => v)
.map(json => Post.parseThreadPost(json.thread, post.pageRoot, 1, post.absoluteLevel + 1));
post.setReplies(replies);
hiddenRepliesDiv.remove();
for (let reply of post.replies) {
let component = new PostComponent(reply, 'thread');
let view = component.buildElement();
content.append(view);
}
if (replies.length < responses.length) {
let notFoundCount = responses.length - replies.length;
let pluralizedCount = (notFoundCount > 1) ? `${notFoundCount} replies are` : '1 reply is';
let info = $tag('p.missing-replies-info', {
html: ` ${pluralizedCount} missing (likely taken down by moderation)`
});
content.append(info);
}
}
/** @returns {boolean} */
isCollapsed() {
return this.rootElement.classList.contains('collapsed');
}
toggleSectionFold() {
let plus = $(this.rootElement.querySelector(':scope > .margin .plus'), HTMLImageElement);
if (this.isCollapsed()) {
this.rootElement.classList.remove('collapsed');
plus.src = 'icons/subtract-square.png'
} else {
this.rootElement.classList.add('collapsed');
plus.src = 'icons/add-square.png'
}
}
/** @param {HTMLElement} heart, @returns {Promise} */
async onHeartClick(heart) {
try {
if (!this.post.hasViewerInfo) {
if (accountAPI.isLoggedIn) {
let data = await this.loadViewerInfo();
if (data) {
if (this.post.liked) {
heart.classList.add('liked');
return;
} else {
// continue down
}
} else {
this.showPostAsBlocked();
return;
}
} else {
// not logged in
showDialog(loginDialog);
return;
}
}
let countField = $(heart.nextElementSibling);
let likeCount = parseInt(countField.innerText, 10);
if (!heart.classList.contains('liked')) {
let like = await accountAPI.likePost(this.post);
this.post.viewerLike = like.uri;
heart.classList.add('liked');
countField.innerText = String(likeCount + 1);
} else {
await accountAPI.removeLike(this.post.viewerLike);
this.post.viewerLike = undefined;
heart.classList.remove('liked');
countField.innerText = String(likeCount - 1);
}
} catch (error) {
showError(error);
}
}
showPostAsBlocked() {
let stats = $(this.rootElement.querySelector(':scope > .content > p.stats'));
if (!stats.querySelector('.blocked-info')) {
let span = $tag('span.blocked-info', { text: '🚫 Post unavailable' });
stats.append(span);
}
}
/** @returns {Promise} */
async loadViewerInfo() {
let data = await accountAPI.loadPostIfExists(this.post.uri);
if (data) {
this.post.author = data.author;
this.post.viewerData = data.viewer;
this.post.viewerLike = data.viewer?.like;
}
return data;
}
}