/**
* Renders a post/thread view and its subviews.
*/
class PostComponent {
/** @param {Post} post, @param {Post} [root] */
constructor(post, root) {
this.post = post;
this.root = root ?? post;
this.isRoot = (this.post === this.root);
}
/** @returns {string} */
get linkToAuthor() {
return 'https://bsky.app/profile/' + this.post.author.handle;
}
/** @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.isRoot) {
return { day: 'numeric', month: 'short', year: 'numeric', hour: 'numeric', minute: 'numeric' };
} else if (!sameDay(this.post.createdAt, this.root.createdAt)) {
return { day: 'numeric', month: 'short', hour: 'numeric', minute: 'numeric' };
} else {
return { hour: 'numeric', minute: 'numeric' };
}
}
/**
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 {PostContext} context
@returns {AnyElement}
*/
buildElement(context) {
let div = $tag('div.post');
if (this.post.muted) {
div.classList.add('muted');
}
if (this.post instanceof BlockedPost) {
this.buildBlockedPostElement(div);
return div;
} else if (this.post instanceof MissingPost) {
this.buildMissingPostElement(div);
return div;
}
let header = this.buildPostHeader(context);
div.appendChild(header);
let content = $tag('div.content');
if (!this.isRoot) {
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(div);
});
});
}
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.embed) {
let embed = new EmbedComponent(this.post, this.post.embed).buildElement();
wrapper.appendChild(embed);
}
if (this.post.likeCount !== undefined && this.post.repostCount !== undefined) {
let stats = this.buildStatsFooter();
wrapper.appendChild(stats);
}
if (this.post.replies.length == 1 && this.post.replies[0].author?.did == this.post.author.did) {
let component = new PostComponent(this.post.replies[0], this.root);
let element = component.buildElement('thread');
element.classList.add('flat');
content.appendChild(element);
} else {
for (let reply of this.post.replies) {
if (reply instanceof MissingPost) { continue }
let component = new PostComponent(reply, this.root);
content.appendChild(component.buildElement('thread'));
}
}
if (context == 'thread' && this.post.hasMoreReplies) {
let loadMore = this.buildLoadMoreLink()
content.appendChild(loadMore);
}
div.appendChild(content);
return div;
}
/** @param {PostContext} context, @returns {AnyElement} */
buildPostHeader(context) {
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 {
h.innerHTML += `@${this.post.author.handle} `;
}
h.innerHTML += `• ` +
`${formattedTime} `;
if (this.post.replyCount > 0 && !this.isRoot || ['quote', 'quotes', 'feed'].includes(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;
}
/** @param {string} url, @returns {HTMLImageElement} */
buildUserAvatar(url) {
let avatar = $tag('img.avatar', { src: url });
let tries = 0;
let errorHandler = function(e) {
if (tries < 3) {
tries++;
setTimeout(() => { avatar.src = url }, Math.random() * 5 * tries);
} else {
avatar.removeEventListener('error', errorHandler);
}
};
avatar.addEventListener('error', errorHandler);
return avatar;
}
/** @returns {AnyElement} */
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);
}
}
if (this.post.isTruncatedFediPost) {
if (this.post.embed && ('url' in this.post.embed) && typeof this.post.embed.url == 'string') {
let link = this.buildLoadFediPostLink(this.post.embed.url, p);
p.append(' ', link);
}
}
return p;
}
/** @returns {AnyElement} */
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);
}
return stats;
}
/** @param {string} originalURL, @param {HTMLElement} p, @returns {AnyElement} */
buildLoadFediPostLink(originalURL, p) {
let link = $tag('a', {
href: originalURL,
text: "(Load full post)"
});
link.addEventListener('click', (e) => {
e.preventDefault();
link.remove();
this.loadFediPost(originalURL, p);
});
return link;
}
/** @returns {AnyElement} */
buildLoadMoreLink() {
let loadMore = $tag('p');
let link = $tag('a', {
href: linkToPostThread(this.post),
text: "Load more replies…"
});
link.addEventListener('click', (e) => {
e.preventDefault();
link.innerHTML = `
`;
if (this.post.mastodonURL) {
loadMastodonThread(this.post.mastodonURL, loadMore.parentNode.parentNode);
} else {
loadThread(this.post.author.handle, this.post.rkey, loadMore.parentNode.parentNode);
}
});
loadMore.appendChild(link);
return loadMore;
}
/** @param {AnyElement} div, @returns {AnyElement} */
buildBlockedPostElement(div) {
let p = $tag('p.blocked-header');
p.innerHTML = ` Blocked post ` +
`(see author) `;
div.appendChild(p);
let authorLink = p.querySelector('a');
let did = atURI(this.post.uri).repo;
let cachedHandle = api.findHandleByDid(did);
let blockStatus = this.post.blockedByUser ? 'has blocked you' : this.post.blocksUser ? "you've blocked them" : '';
if (cachedHandle) {
this.post.author.handle = cachedHandle;
authorLink.href = this.linkToAuthor;
authorLink.innerText = `@${cachedHandle}`;
if (blockStatus) {
authorLink.after(`, ${blockStatus}`);
}
} else {
api.loadUserProfile(did).then((author) => {
this.post.author = author;
authorLink.href = this.linkToAuthor;
authorLink.innerText = `@${author.handle}`;
if (blockStatus) {
authorLink.after(`, ${blockStatus}`);
}
});
}
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 {AnyElement} div, @returns {AnyElement} */
buildMissingPostElement(div) {
let p = $tag('p.blocked-header');
p.innerHTML = ` Deleted post`;
div.appendChild(p);
div.classList.add('blocked');
return div;
}
/** @param {string} uri, @param {AnyElement} div, @returns Promise */
async loadBlockedPost(uri, div) {
let record = await appView.loadPost(this.post.uri);
this.post = new Post(record);
div.querySelector('p.load-post').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);
}
}
/** @param {string} url, @param {HTMLElement} p, @returns Promise */
async loadFediPost(url, p) {
let host = new URL(url).host;
let postId = url.replace(/\/$/, '').split('/').reverse()[0];
let statusURL = `https://${host}/api/v1/statuses/${postId}`;
let response = await fetch(statusURL);
let json = await response.json();
if (json.content) {
let div = $tag('div.body', { html: sanitizeHTML(json.content) });
p.replaceWith(div);
}
}
/** @param {AnyElement} div */
toggleSectionFold(div) {
let plus = div.querySelector('.plus');
if (div.classList.contains('collapsed')) {
div.classList.remove('collapsed');
plus.src = 'icons/subtract-square.png'
} else {
div.classList.add('collapsed');
plus.src = 'icons/add-square.png'
}
}
/** @param {AnyElement} heart */
onHeartClick(heart) {
if (!this.post.hasViewerInfo) {
if (accountAPI.isLoggedIn) {
accountAPI.loadPost(this.post.uri).then(data => {
this.post = new Post(data);
if (this.post.liked) {
heart.classList.add('liked');
} else {
this.onHeartClick(heart);
}
}).catch(error => {
console.log(error);
alert("Sorry, this post is blocked.");
});
} else {
showLogin();
}
return;
}
let count = heart.nextElementSibling;
if (!heart.classList.contains('liked')) {
accountAPI.likePost(this.post).then((like) => {
this.post.viewerLike = like.uri;
heart.classList.add('liked');
count.innerText = String(parseInt(count.innerText, 10) + 1);
}).catch((error) => {
console.log(error);
alert(error);
});
} else {
accountAPI.removeLike(this.post.viewerLike).then(() => {
this.post.viewerLike = undefined;
heart.classList.remove('liked');
count.innerText = String(parseInt(count.innerText, 10) - 1);
}).catch((error) => {
console.log(error);
alert(error);
});
}
}
}