Something went wrong. Try again.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454import { api } from '../api.js';import { linkToBluesky } from '../linker.js';import { atURI, castToInt } from '../utils.js';import { ATProtoRecord, FeedGeneratorRecord, StarterPackRecord, UserListRecord } from './records.js';import { Embed } from './embeds.js';
/** * Thrown when parsing post JSON fails. */
export class PostDataError extends Error { constructor(message: string) { super(message); }}
/** * Base class shared by the full Post and post stubs like BlockedPost, MissingPost etc. */
export class BasePost extends ATProtoRecord {
// Set to true if the post was loaded from the "hidden replies" link (as a direct descendant) isHiddenReply = false;
// Set to true if the author of the post has the 'needs-review' label labelledNeedsReview = false;
get didLinkToAuthor(): string { let { repo } = atURI(this.uri); return linkToBluesky(`/profile/${repo}`); }}
/** * View of a post as part of a thread, as returned from getPostThread. * Expected to be #threadViewPost, but may be blocked or missing. */
export function parseThreadPost(json: json, pageRoot: Post | null = null, level = 0, absoluteLevel = 0): AnyPost { switch (json.$type) { case 'app.bsky.feed.defs#threadViewPost': let post = new Post(json.post, { level: level, absoluteLevel: absoluteLevel });
post.pageRoot = pageRoot ?? post;
if (json.replies) { let replies = json.replies.map((x: json) => parseThreadPost(x, post.pageRoot, level + 1, absoluteLevel + 1)); post.setReplies(replies); }
if (absoluteLevel <= 0 && json.parent) { post.parent = parseThreadPost(json.parent, post.pageRoot, level - 1, absoluteLevel - 1); }
return post;
case 'app.bsky.feed.defs#notFoundPost': return new MissingPost(json);
case 'app.bsky.feed.defs#blockedPost': return new BlockedPost(json);
default: throw new PostDataError(`Unexpected record type: ${json.$type}`); }}
/** * View of a post embedded as a quote. * Expected to be app.bsky.embed.record#viewRecord, but may be blocked, missing or a different type of record * (e.g. a list or a feed generator). For unknown record embeds, we fall back to generic ATProtoRecord. */
export function parseViewRecord(json: json): ATProtoRecord { switch (json.$type) { case 'app.bsky.embed.record#viewRecord': return new Post(json, { isEmbed: true });
case 'app.bsky.embed.record#viewNotFound': return new MissingPost(json);
case 'app.bsky.embed.record#viewBlocked': return new BlockedPost(json);
case 'app.bsky.embed.record#viewDetached': return new DetachedQuotePost(json);
case 'app.bsky.feed.defs#generatorView': return new FeedGeneratorRecord(json);
case 'app.bsky.graph.defs#listView': return new UserListRecord(json);
case 'app.bsky.graph.defs#starterPackViewBasic': return new StarterPackRecord(json);
default: console.warn('Unknown record type:', json.$type); return new ATProtoRecord(json); }}
/** * View of a post as part of a feed (e.g. a profile feed, home timeline or a custom feed). It should be an * app.bsky.feed.defs#feedViewPost - blocked or missing posts don't appear here, they just aren't included. */
export function parseFeedPost(json: json): Post { let post = new Post(json.post);
if (json.reply) { post.parent = parsePostView(json.reply.parent); post.threadRoot = parsePostView(json.reply.root);
if (json.reply.grandparentAuthor) { post.grandparentAuthor = json.reply.grandparentAuthor; } }
if (json.reason) { post.reason = json.reason; }
return post;}
/** * Parses a #postView - the inner post object that includes the actual post - but still checks if it's not * a blocked or missing post. The #postView must include a $type. * (This is used for e.g. parent/root of a #feedViewPost.) */
export function parsePostView(json: json): AnyPost { switch (json.$type) { case 'app.bsky.feed.defs#postView': return new Post(json);
case 'app.bsky.feed.defs#notFoundPost': return new MissingPost(json);
case 'app.bsky.feed.defs#blockedPost': return new BlockedPost(json);
default: throw new PostDataError(`Unexpected record type: ${json.$type}`); }}
/** * Standard Bluesky post record. */
export class Post extends BasePost { // Post object which is the direct parent of this post parent: AnyPost | undefined;
// Post object which is the root of the whole thread (as specified in the post record) threadRoot: AnyPost | undefined;
// Post which is at the top of the (sub)thread currently loaded on the page (might not be the same as threadRoot) pageRoot: Post | undefined;
// Post's direct replies (if it's displayed in a thread) replies: AnyPost[];
// Info about the author of the "grandparent" post. Included only in feedPost views, for the purposes of feed filtering algorithm grandparentAuthor: json | undefined;
// Depth of the post in the getPostThread response it was loaded from, starting from 0. May be negative. level: number | undefined;
// Depth of the post in the whole tree visible on the page (pageRoot's absoluteLevel is 0). May be negative. absoluteLevel: number | undefined;
// For posts in feeds and timelines - specifies e.g. that a post was reposted by someone reason: object | undefined;
// True if the post was extracted from inner embed of a quote, not from a #postView isEmbed: boolean | undefined;
record: json; author: json; embed: Embed | undefined; viewerData: json | undefined; viewerLike: string | undefined; _lowercaseText: string | undefined;
constructor(data: json, extra?: json) { super(data); Object.assign(this, extra ?? {});
if (this.absoluteLevel === 0) { this.pageRoot = this; }
this.record = this.isPostView ? data.record : data.value;
if (this.isPostView && data.embed) { this.embed = Embed.parseInlineEmbed(data.embed); } else if (this.isEmbed && data.embeds && data.embeds[0]) { this.embed = Embed.parseInlineEmbed(data.embeds[0]); } else if (this.record.embed) { this.embed = Embed.parseRawEmbed(this.record.embed); }
this.author = this.author ?? data.author; this.replies = [];
this.viewerData = data.viewer; this.viewerLike = data.viewer?.like;
if (this.author) { api.cacheProfile(this.author); } }
updateDataFromPost(post: Post) { this.record = post.record; this.embed = post.embed; this.author = post.author; this.viewerData = post.viewerData; this.viewerLike = post.viewerLike; this.level = post.level; this.absoluteLevel = post.absoluteLevel; this.setReplies(post.replies); }
setReplies(replies: AnyPost[]) { this.replies = replies; this.replies.sort(this.sortReplies.bind(this)); }
sortReplies(a: AnyPost, b: AnyPost): -1 | 0 | 1 { if (a instanceof Post && b instanceof Post) { if (a.author.did == this.author.did && b.author.did != this.author.did) { return -1; } else if (a.author.did != this.author.did && b.author.did == this.author.did) { return 1; } else if (a.text != "📌" && b.text == "📌") { return -1; } else if (a.text == "📌" && b.text != "📌") { return 1; } else if (a.createdAt.getTime() < b.createdAt.getTime()) { return -1; } else if (a.createdAt.getTime() > b.createdAt.getTime()) { return 1; } else { return 0; } } else if (a instanceof Post) { return -1; } else if (b instanceof Post) { return 1; } else { return 0; } }
get isPostView(): boolean { return !this.isEmbed; }
get isFediPost(): boolean { return this.author?.handle.endsWith('.ap.brid.gy'); }
get originalFediContent(): string | undefined { return this.record.bridgyOriginalText; }
get originalFediURL(): string | undefined { return this.record.bridgyOriginalUrl; }
get isPageRoot(): boolean { // I AM ROOOT return (this.pageRoot === this); }
get authorFediHandle(): string { if (this.isFediPost) { return this.author.handle.replace(/\.ap\.brid\.gy$/, '').replace('.', '@'); } else { throw "Not a Fedi post"; } }
get hasValidHandle(): boolean { return this.author.handle != 'handle.invalid'; }
get authorDisplayName(): string { if (this.author.displayName) { return this.author.displayName.trim(); } else if (this.author.handle.endsWith('.bsky.social')) { return this.author.handle.replace(/\.bsky\.social$/, ''); } else { return this.author.handle; } }
get linkToAuthor(): string { return linkToBluesky('/profile/' + (this.hasValidHandle ? this.author.handle : this.author.did)); }
get linkToPost(): string { return this.linkToAuthor + '/post/' + this.rkey; }
get text(): string { return this.record.text; }
get lowercaseText(): string { if (!this._lowercaseText) { this._lowercaseText = this.record.text.toLowerCase(); }
return this._lowercaseText!; }
get facets(): json { return this.record.facets; }
get tags(): string[] | undefined { return this.record.tags; }
get createdAt(): Date { return new Date(this.record.createdAt); }
get likeCount(): number | null | undefined { return castToInt(this.data.likeCount); }
get replyCount(): number | null | undefined { return castToInt(this.data.replyCount); }
get quoteCount(): number | null | undefined { return castToInt(this.data.quoteCount); }
get hasMoreReplies(): boolean { let shouldHaveMoreReplies = (this.replyCount != null && this.replyCount > this.replies.length);
return shouldHaveMoreReplies && (this.replies.length === 0) && (this.level !== undefined && this.level > 4); }
get hasHiddenReplies(): boolean { let shouldHaveMoreReplies = (this.replyCount != null && this.replyCount > this.replies.length);
return shouldHaveMoreReplies && (this.replies.length > 0 || (this.level !== undefined && this.level <= 4)); }
get isRestrictingReplies(): boolean { return !!(this.data.threadgate && this.data.threadgate.record.allow); }
get hasDisabledReplies(): boolean { return !!(this.data.threadgate && this.data.threadgate.record.allow !== undefined && this.data.threadgate.record.allow.length == 0); }
get hasBeenEdited(): boolean { return !!(this.record.originalText); }
get originalText(): string | undefined { return this.record.originalText; }
get repostCount(): number | null | undefined { return castToInt(this.data.repostCount); }
get liked(): boolean { return (this.viewerLike !== undefined); }
get muted(): boolean | undefined { return this.author.viewer?.muted; }
get muteList(): string | undefined { return this.author.viewer?.mutedByList?.name; }
get hasViewerInfo(): boolean { return (this.viewerData !== undefined); }
get authorIsBot(): boolean { return this.author.labels?.some((x: json) => ( x.src == this.author.did && x.uri == `at://${x.src}/app.bsky.actor.profile/self` && x.val == 'bot' )); }
get parentReference(): ATProtoRecord | undefined { return this.record.reply?.parent && new ATProtoRecord(this.record.reply?.parent); }
get rootReference(): ATProtoRecord | undefined { return this.record.reply?.root && new ATProtoRecord(this.record.reply?.root); }}
/** * Post which is blocked for some reason (the author is blocked, the author has blocked you, or there is a block * between the post author and the parent author). It only includes a reference, but no post content. */
export class BlockedPost extends BasePost {
author: json;
constructor(data: json) { super(data); this.author = data.author; }
get blocksUser(): boolean { return !!this.author.viewer?.blocking; }
get blockedByUser(): boolean { return this.author.viewer?.blockedBy; }}
/** * Stub of a post which was deleted or hidden. */
export class MissingPost extends BasePost { get isBskyPost(): boolean { return atURI(this.uri).collection == 'app.bsky.feed.post'; }}
/** * Stub of a quoted post which was un-quoted by the original author. */
export class DetachedQuotePost extends BasePost {}