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.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466import { isNil, unique, minBy } from "/js/utils.js";import { CDN_URL, FOLLOWING_FEED_URI, IN_APP_LINK_DOMAINS, LIVE_ALLOWED_SERVICES,} from "/js/config.js";
export const INVALID_HANDLE = "handle.invalid";export const MISSING_HANDLE = "missing.invalid";
export function isModerationList(list) { return list?.purpose === "app.bsky.graph.defs#modlist";}
export function isReferenceList(list) { return list?.purpose === "app.bsky.graph.defs#referencelist";}
export function isCurateList(list) { return list?.purpose === "app.bsky.graph.defs#curatelist";}
export function hasValidHandle(profile) { return ( !!profile.handle && profile.handle !== INVALID_HANDLE && profile.handle !== MISSING_HANDLE );}
const CDN_PREFIXES = new Set([ "avatar", "avatar_thumbnail", "banner", "feed_thumbnail", "feed_fullsize",]);
export function buildCdnUrl(prefix, did, cid) { if (!CDN_PREFIXES.has(prefix)) { throw new Error(`Invalid CDN prefix: ${prefix}`); } return `${CDN_URL}/img/${prefix}/plain/${did}/${cid}@jpeg`;}
const BSKY_CDN_URL = "https://cdn.bsky.app";const BSKY_CDN_HOSTNAME = new URL(BSKY_CDN_URL).hostname;
export function cdnImageUrl(url) { if (CDN_URL === BSKY_CDN_URL || !url) return url; let parsed; try { parsed = new URL(url); } catch { return url; } if (parsed.hostname !== BSKY_CDN_HOSTNAME) return url; const cdn = new URL(CDN_URL); parsed.protocol = cdn.protocol; parsed.host = cdn.host; return parsed.href;}
function blobCdnUrl(prefix, did, blob) { const cid = blob?.ref?.$link ?? null; if (!cid) return null; return buildCdnUrl(prefix, did, cid);}
export function createStatusView({ did, cid, record }) { const external = record.embed.external; const expiresAt = new Date( Date.parse(record.createdAt) + record.durationMinutes * 60 * 1000, ).toISOString(); return { $type: "app.bsky.actor.defs#statusView", status: record.status, record, uri: `at://${did}/app.bsky.actor.status/self`, cid, embed: { $type: "app.bsky.embed.external#view", external: { uri: external.uri, title: external.title, description: external.description, ...(external.thumb ? { thumb: buildCdnUrl( "feed_thumbnail", did, external.thumb.ref.$link, ), } : {}), }, }, isActive: true, expiresAt, };}
// A profileViewDetailed-shaped object assembled from the raw// app.bsky.actor.profile record, for when the appview is unreachable.// Appview-computed fields (counts, viewer, labels, associated) are absent.export function buildProfileFromRecord({ did, handle, record }) { const value = record?.value ?? {}; return { did, handle, displayName: value.displayName ?? null, description: value.description ?? null, avatar: blobCdnUrl("avatar", did, value.avatar), banner: blobCdnUrl("banner", did, value.banner), pinnedPost: value.pinnedPost ?? null, createdAt: value.createdAt ?? null, labels: [], isPartial: true, };}
export function avatarThumbnailUrl(avatarUrl) { if (!avatarUrl) { console.warn("avatarUrl is null"); return ""; } return avatarUrl.replace( "/img/avatar/plain/", "/img/avatar_thumbnail/plain/", );}
export function parseUri(uri) { // e.g. at://did:plc:p572wxnsuoogcrhlfrlizlrb/app.bsky.feed.repost/3m47r3v7spm2b // -> { repo: "did:plc:p572wxnsuoogcrhlfrlizlrb", rkey: "3m47r3v7spm2b", collection: "app.bsky.feed.repost" } const [repo, collection, rkey] = uri.replace("at://", "").split("/"); return { repo, collection, rkey };}
export function buildUri({ repo, collection, rkey }) { return `at://${repo}/${collection}/${rkey}`;}
export function isListFeed(feedURI) { return feedURI.includes("/app.bsky.graph.list/");}
export function isFollowingFeedUri(feedURI) { return feedURI === FOLLOWING_FEED_URI;}
// The `value` used in savedFeedsPrefV2 for a hydrated pinned itemexport function valueForPinnedItem(item) { return item.type === "timeline" ? FOLLOWING_FEED_URI : item.data.uri;}
// The atproto-proxy value that routes app.bsky.feed.sendInteractions (and// post-seen tracking) to a specific feed generator's own service, so// algorithmic feeds actually receive the signal instead of it silently// going nowhere useful.export function getFeedGeneratorProxyUrl(feedGenerator) { if (!feedGenerator?.did) { return null; } return `${feedGenerator.did}#bsky_fg`;}
export function getRKey(record) { return record.uri.split("/").pop();}export function getIsLiked(post) { return !!post.viewer?.like;}export function getQuotedPost(post) { const embed = post.embeds ? post.embeds[0] : post.embed; if (!embed) { return null; } if (embed.$type === "app.bsky.embed.record#view") { return embed.record; } if (embed.$type === "app.bsky.embed.recordWithMedia#view") { return embed.record.record; } return null;}
export function isBlockingUser(blockedQuote) { return blockedQuote.author.viewer?.blockedBy;}
export function isBlockedByViewer(blockedPost) { return !!blockedPost.author?.viewer?.blocking;}
export function getBlockedQuote(post) { const quotedPost = getQuotedPost(post); if (!quotedPost) { return null; } if (quotedPost.$type === "app.bsky.embed.record#viewBlocked") { return quotedPost; } return null;}
export function getMutedQuote(post) { const quotedPost = getQuotedPost(post); if (!quotedPost) { return null; } // Note - using a custom property here if (quotedPost.author?.viewer?.muted || quotedPost.hasMutedWord) { return true; } return false;}
export function isMutedPost(post) { return post.author?.viewer?.muted || post.viewer?.hasMutedWord;}
export function embedViewRecordToPostView(viewRecord) { return { uri: viewRecord.uri, cid: viewRecord.cid, author: viewRecord.author, record: viewRecord.value, embed: viewRecord.embeds?.[0], labels: viewRecord.labels, likeCount: viewRecord.likeCount, replyCount: viewRecord.replyCount, repostCount: viewRecord.repostCount, quoteCount: viewRecord.quoteCount, indexedAt: viewRecord.indexedAt, };}
export function createEmbedFromPost(post) { const embed = { $type: "app.bsky.embed.record#viewRecord", author: { ...post.author }, value: { ...post.record }, uri: post.uri, cid: post.cid, indexedAt: post.indexedAt, labels: post.labels, likeCount: post.likeCount, replyCount: post.replyCount, repostCount: post.repostCount, quoteCount: post.quoteCount, }; if (post.embed) { embed.embeds = [post.embed]; } return embed;}
export function createThreadViewPostFromPost(post) { return { $type: "app.bsky.feed.defs#threadViewPost", post: { ...post }, };}
// Transforms a flat list of posts into a nested threadViewPost chainexport function createNestedThreadViewPost(posts) { let current = null; for (const post of posts.toReversed()) { current = { $type: "app.bsky.feed.defs#threadViewPost", post, replies: current ? [current] : [], }; } return current;}
export function insertReplyIntoThread(node, parentUri, reply) { if (!node.post || !node.replies) { return node; } if (node.post.uri === parentUri) { return { ...node, replies: [reply, ...node.replies] }; } let didChange = false; const replies = node.replies.map((child) => { const updatedChild = insertReplyIntoThread(child, parentUri, reply); if (updatedChild !== child) { didChange = true; } return updatedChild; }); return didChange ? { ...node, replies } : node;}
export function flattenParents(postThread) { const parents = []; let current = postThread.parent; while (current) { parents.unshift(current); current = current.parent; } return parents;}
function isThreadViewPost(node) { return node?.$type === "app.bsky.feed.defs#threadViewPost" && !!node.post;}
// The contiguous run of posts by the thread's original poster starting at the rootfunction getOpThreadRun(postThread) { const chain = [...flattenParents(postThread), postThread]; if (!chain.every(isThreadViewPost)) { return null; } const root = chain[0]; const mainPostReplyRoot = getReplyRootFromPost(postThread.post); if (root.post.record?.reply || root.post.uri !== mainPostReplyRoot.uri) { return null; } const opDid = root.post.author?.did; if (!opDid) { return null; } const run = [root]; let chainIndex = 1; while ( chainIndex < chain.length && chain[chainIndex].post.author?.did === opDid ) { run.push(chain[chainIndex]); chainIndex++; } if (chainIndex < chain.length) { // This means the run ends above the main post. // The replies of the last post aren't loaded, // so if it has any we just bail const lastRunNode = run[run.length - 1]; if ((lastRunNode.post.replyCount ?? 0) !== 1) { return null; } return run; } let current = postThread; while (true) { const replies = current.replies; if (!replies) { if ((current.post.replyCount ?? 0) > 0) { return null; } return run; } const opReplies = replies.filter( (reply) => isThreadViewPost(reply) && reply.post.author?.did === opDid, ); if (opReplies.length === 0) { return run; } const next = minBy(opReplies, (reply) => getRKey(reply.post)); run.push(next); current = next; }}
export function getPostNumberingForPostThread(postThread) { const numbering = new Map(); const opThreadRun = getOpThreadRun(postThread); if (!opThreadRun || opThreadRun.length < 2) { return numbering; } opThreadRun.forEach((node, index) => { numbering.set(node.post.uri, { index: index + 1, count: opThreadRun.length, }); }); return numbering;}
export function getParentPosts(postThread) { return flattenParents(postThread) .map((parent) => parent.post) .filter(Boolean);}
export function replaceTopParent(postThread, newParent) { let current = postThread.parent; if (!current) { throw new Error("No parent found"); } // If the immediate parent has no parent, it is the top if (!current.parent) { return { ...postThread, parent: newParent }; } // Otherwise, traverse to find the parent whose parent is the top while (current.parent?.parent) { current = current.parent; } current.parent = newParent; return postThread;}
export function getReplyPosts(postThread) { if (!postThread.replies) { return []; } return postThread.replies.map((reply) => reply.post).filter(Boolean);}
export function getReplyRootFromPost(post) { // If the post is not a reply, return the post itself return post.record?.reply?.root ?? { uri: post.uri, cid: post.cid };}
export function getNestedReplyPosts(postThread) { if (!postThread.replies) { return []; } const posts = []; for (const reply of postThread.replies) { if (reply.post) { posts.push(reply.post); } posts.push(...getNestedReplyPosts(reply)); } return posts;}
function updateNested(objOrArray, updater) { if (Array.isArray(objOrArray)) { return objOrArray.map((item) => updateNested(item, updater)); } else if (objOrArray !== null && typeof objOrArray === "object") { // Apply updater to the object itself before recursing const updated = updater(objOrArray); // If updater returns a different object, stop recursion if (updated !== objOrArray) { return updated; } const newObj = {}; for (let key in objOrArray) { if (Object.prototype.hasOwnProperty.call(objOrArray, key)) { newObj[key] = updateNested(objOrArray[key], updater); } } return newObj; } else { return updater(objOrArray); }}
export function replaceBlockedQuote(post, fullBlockedQuote) { return updateNested(post, (obj) => { if ( obj.$type === "app.bsky.embed.record#viewBlocked" && obj.uri === fullBlockedQuote.uri ) { return fullBlockedQuote; } return obj; });}
export function markBlockedQuoteNotFound(post, uri) { return updateNested(post, (obj) => { if (obj.$type === "app.bsky.embed.record#viewBlocked" && obj.uri === uri) { return { ...obj, $type: "app.bsky.embed.record#viewNotFound", uri }; } return obj; });}
export function isBlockedPost(post) { return post.$type === "app.bsky.feed.defs#blockedPost";}
export function createBlockedPost({ uri, author }) { return { $type: "app.bsky.feed.defs#blockedPost", uri, author, blocked: true, };}
export function isNotFoundPost(post) { return post.$type === "app.bsky.feed.defs#notFoundPost";}
export function createNotFoundPost(uri) { return { $type: "app.bsky.feed.defs#notFoundPost", uri, };}
const UNAVAILABLE_POST_TYPE = "__unavailablePost";
export function isUnavailablePost(post) { return post.$type === UNAVAILABLE_POST_TYPE;}
export function createUnavailablePost(uri) { return { $type: UNAVAILABLE_POST_TYPE, uri, };}
export function isEmptyPost(post) { return isBlockedPost(post) || isNotFoundPost(post) || isUnavailablePost(post);}
export function isPostView(post) { return post.$type === "app.bsky.feed.defs#postView";}
export function isSelfOrFollowing(profile, userDid) { return profile.did === userDid || profile.viewer?.following;}
export function getReplyAuthors(reply) { return { parentAuthor: reply?.parent?.author, grandparentAuthor: reply?.grandparentAuthor, rootAuthor: reply?.root?.author, };}
export function getRootUri(feedItem) { const reply = feedItem.reply; if (reply && reply.root) { return reply.root.uri; } return feedItem.post.uri;}
export function getPostLabels(post) { const authorLabels = post.author?.labels || []; const postLabels = post.labels || []; return [...authorLabels, ...postLabels];}
export function getPostUrisFromNotifications(notifications) { const postUris = [];
for (const notification of notifications) { // For likes and reposts, fetch the post if (notification.reason === "like" || notification.reason === "repost") { postUris.push(notification.reasonSubject); } // For replies, mentions and quotes, fetch the post and the parent post(s) else if ( notification.reason === "reply" || notification.reason === "mention" || notification.reason === "quote" ) { postUris.push(notification.uri); if (notification.reasonSubject) { postUris.push(notification.reasonSubject); } if (notification.record?.reply?.parent?.uri) { postUris.push(notification.record.reply.parent.uri); } if (notification.record?.reply?.root?.uri) { postUris.push(notification.record.reply.root.uri); } } else if (notification.reason === "subscribed-post") { postUris.push(notification.uri); } else if ( notification.reason === "like-via-repost" || notification.reason === "repost-via-repost" ) { // Note, this is a post uri, not a repost uri. // That way, if we delete the repost, the post will still be available to display / navigate to. postUris.push(notification.record.subject.uri); } } return unique(postUris);}
export function getPostUriFromRepost(repost) { return repost.value.subject.uri;}
export function getPostUrisFromReposts(reposts) { return unique(reposts.map((repost) => getPostUriFromRepost(repost)));}
export function galleryItemsToImages(items) { return (items ?? []) .filter( (item) => !item.$type || item.$type === "app.bsky.embed.gallery#viewImage", ) .map(({ thumbnail, ...rest }) => ({ thumb: thumbnail, ...rest }));}
function getImagesFromMediaEmbed(embed) { if (embed?.$type === "app.bsky.embed.images#view") { return embed.images; } if (embed?.$type === "app.bsky.embed.gallery#view") { return galleryItemsToImages(embed.items); } return [];}
export function getImagesFromPost(post) { if (post?.embed?.$type === "app.bsky.embed.recordWithMedia#view") { return getImagesFromMediaEmbed(post.embed.media); } return getImagesFromMediaEmbed(post?.embed);}
export function getVideoFromPost(post) { if (post?.embed?.$type === "app.bsky.embed.video#view") { return post.embed; } if (post?.embed?.$type === "app.bsky.embed.recordWithMedia#view") { if (post.embed.media?.$type === "app.bsky.embed.video#view") { return post.embed.media; } } return null;}
const CHECK_MARKS_RE = /[\u2705\u2713\u2714\u2611]/gu;const CONTROL_CHARS_RE = /[\u0000-\u001F\u007F-\u009F\u061C\u200E\u200F\u202A-\u202E\u2066-\u2069]/g;const MULTIPLE_SPACES_RE = /[\s][\s\u200B]+/g;
// Strip out invalid characters to match behavior of social-appfunction sanitizeDisplayName(displayName) { return displayName .replace(CHECK_MARKS_RE, "") .replace(CONTROL_CHARS_RE, "") .replace(MULTIPLE_SPACES_RE, " ") .trim();}
export function getDisplayName(profile) { if (profile.displayName) { return sanitizeDisplayName(profile.displayName); } if (profile.handle === MISSING_HANDLE) { return "Deleted Account"; } else if (profile.handle === INVALID_HANDLE) { return "Invalid Handle"; } return profile.handle;}
export function getLastInteraction(convo) { // Interaction = message or reaction const lastMessage = convo.lastMessage; const lastReaction = convo.lastReaction; if (!lastMessage && !lastReaction) { return null; } if (!lastMessage) { return lastReaction; } else if (!lastReaction) { return lastMessage; } else { return new Date(lastMessage.sentAt) > new Date(lastReaction.reaction.createdAt) ? lastMessage : lastReaction; }}
export function getInteractionTimestamp(interaction) { switch (interaction.$type) { case "chat.bsky.convo.defs#messageView": case "chat.bsky.convo.defs#deletedMessageView": case "chat.bsky.convo.defs#systemMessageView": return interaction.sentAt; case "chat.bsky.convo.defs#messageAndReactionView": return interaction.reaction.createdAt; default: console.warn(`Unknown interaction type: ${interaction.$type}`); return null; }}
const SYSTEM_MESSAGE_DISPLAY_TEXT = { "chat.bsky.convo.defs#systemMessageDataAddMember": "Someone was added to the group", "chat.bsky.convo.defs#systemMessageDataRemoveMember": "Someone was removed from the group", "chat.bsky.convo.defs#systemMessageDataMemberJoin": "Someone joined the group", "chat.bsky.convo.defs#systemMessageDataMemberLeave": "Someone left the group", "chat.bsky.convo.defs#systemMessageDataLockConvo": "Chat locked", "chat.bsky.convo.defs#systemMessageDataUnlockConvo": "Chat unlocked", "chat.bsky.convo.defs#systemMessageDataLockConvoPermanently": "Chat ended", "chat.bsky.convo.defs#systemMessageDataEditGroup": "Chat title changed", "chat.bsky.convo.defs#systemMessageDataCreateJoinLink": "Invite link created", "chat.bsky.convo.defs#systemMessageDataEditJoinLink": "Invite link edited", "chat.bsky.convo.defs#systemMessageDataEnableJoinLink": "Invite link enabled", "chat.bsky.convo.defs#systemMessageDataDisableJoinLink": "Invite link disabled",};
const MEMBER_SYSTEM_MESSAGE_VERBS = { "chat.bsky.convo.defs#systemMessageDataAddMember": "was added to the group", "chat.bsky.convo.defs#systemMessageDataRemoveMember": "was removed from the group", "chat.bsky.convo.defs#systemMessageDataMemberJoin": "joined the group", "chat.bsky.convo.defs#systemMessageDataMemberLeave": "left the group",};
export function getSystemMessageDisplayText( systemMessage, { memberName = null } = {},) { const dataType = systemMessage.data?.$type; if (memberName) { const verb = MEMBER_SYSTEM_MESSAGE_VERBS[dataType]; if (verb) { return `${memberName} ${verb}`; } } if ( dataType === "chat.bsky.convo.defs#systemMessageDataEditGroup" && systemMessage.data.newName ) { return `Chat title changed to ${systemMessage.data.newName}`; } return SYSTEM_MESSAGE_DISPLAY_TEXT[dataType] ?? "Chat updated";}
function getSenderDisplayName(senderDid, { currentUser, profiles }) { if (senderDid === currentUser?.did) { return "You"; } const sender = profiles.find((profile) => profile.did === senderDid); return sender ? getDisplayName(sender) : "Someone";}
function getEmbedPreviewText(embed) { if (!embed) { return ""; } if (embed.$type === "app.bsky.embed.record#view") { return "(quoted post)"; } return "(embedded content)";}
export function getConvoPreviewText( interaction, { currentUser, convo, profiles },) { switch (interaction.$type) { case "chat.bsky.convo.defs#messageView": { const text = interaction.text || getEmbedPreviewText(interaction.embed); if (isGroupConvo(convo)) { const senderName = getSenderDisplayName(interaction.sender.did, { currentUser, profiles, }); return `${senderName}: ${text}`; } return interaction.sender.did === currentUser?.did ? "You: " + text : text; } case "chat.bsky.convo.defs#messageAndReactionView": { let text = ""; if (interaction.message) { text = interaction.message.text ? '"' + interaction.message.text + '"' : getEmbedPreviewText(interaction.message.embed); } const displayName = getSenderDisplayName( interaction.reaction.sender.did, { currentUser, profiles }, ); return `${displayName} reacted ${interaction.reaction.value} to ${text}`; } case "chat.bsky.convo.defs#deletedMessageView": return "Deleted message"; case "chat.bsky.convo.defs#systemMessageView": { const memberDid = interaction.data?.member?.did; const memberProfile = memberDid ? profiles.find((profile) => profile.did === memberDid) : null; return getSystemMessageDisplayText(interaction, { memberName: memberProfile ? getDisplayName(memberProfile) : null, }); } default: console.warn(`Unknown interaction type: ${interaction.$type}`); return ""; }}
export function getInteractionProfileDids(interaction) { if (!interaction) { return []; } return [ interaction.sender?.did, interaction.message?.sender?.did, interaction.reaction?.sender?.did, interaction.data?.member?.did, interaction.data?.addedBy?.did, ...(interaction.reactions?.map((reaction) => reaction.sender?.did) ?? []), ...(interaction.message?.reactions?.map( (reaction) => reaction.sender?.did, ) ?? []), ].filter(Boolean);}
// Group reactions by value// Returns: [{ value, senders, count }, ...]export function groupReactions(reactions) { const groups = new Map(); for (const reaction of reactions || []) { const existing = groups.get(reaction.value); if (existing) { existing.senders.push(reaction.sender); existing.count += 1; } else { groups.set(reaction.value, { value: reaction.value, senders: [reaction.sender], count: 1, }); } } return [...groups.values()];}
export function getGroupConvoDetails(convo) { if (convo.kind?.$type === "chat.bsky.convo.defs#groupConvo") { return convo.kind; } return null;}
export function isGroupConvo(convo) { return getGroupConvoDetails(convo) !== null;}
export function getGroupConvoOwner(convo) { // The owner may have left the group, in which case there is no owner return ( convo.members.find( (member) => member.kind?.$type === "chat.bsky.actor.defs#groupConvoMember" && member.kind.role === "owner", ) ?? null );}
export function getLastInteractionTimestamp(convo) { const lastInteraction = getLastInteraction(convo); if (!lastInteraction) { return null; } return getInteractionTimestamp(lastInteraction);}
export function doHideAuthorOnUnauthenticated(author) { const authorLabels = author.labels || []; return authorLabels.some((label) => label.val === "!no-unauthenticated");}
export function isAutomatedAccount(profile) { const labels = profile?.labels || []; return labels.some((label) => label.val === "bot");}
export function isLabelerProfile(profile) { return profile.associated?.labeler;}
export function getLabelNameAndDescription( labelDefinition, preferredLang = "en",) { const defaultName = labelDefinition.identifier; if (!labelDefinition.locales || labelDefinition.locales.length === 0) { return { name: defaultName, description: "" }; } const locale = labelDefinition.locales.find((l) => l.lang === preferredLang) || labelDefinition.locales[0]; return { name: locale.name || defaultName, description: locale.description || "", };}
export function getModerationWarningLabelName(labelDefinition) { if ( labelDefinition.identifier === "porn" || labelDefinition.identifier === "sexual" ) { return "Adult Content"; } return getLabelNameAndDescription(labelDefinition).name;}
export function isSelfLabel(label) { const subjectDid = label.uri.startsWith("did:") ? label.uri : parseUri(label.uri).repo; return label.src === subjectDid;}
export function getLabelerForLabel(label, labelers) { const matchingLabeler = labelers.find( (labeler) => labeler.creator.did === label.src, ); return matchingLabeler ?? null;}
export function getDefinitionForLabel(label, labeler) { return labeler.policies.labelValueDefinitions.find( (definition) => definition.identifier === label.val, );}
export function isBadgeLabel(labelDefinition) { return !( labelDefinition.blurs === "media" || labelDefinition.blurs === "content" );}
export function getDefaultLabelSetting(labelDefinition) { const defaultSetting = labelDefinition.defaultSetting; if (!defaultSetting || !["ignore", "warn", "hide"].includes(defaultSetting)) { return "warn"; } return defaultSetting;}
// https://docs.bsky.app/docs/advanced-guides/moderationexport const GLOBAL_LABELS = [ { identifier: "!hide", configurable: false, defaultSetting: "hide", blurs: "content", severity: "alert", locales: [ { lang: "en", name: "Content Hidden", description: "This content has been hidden by the moderators.", }, ], }, { identifier: "!warn", configurable: false, defaultSetting: "warn", blurs: "content", severity: "alert", locales: [ { lang: "en", name: "Content Warning", description: "This content has received a general warning from moderators.", }, ], }, // Self-label values (users can apply to their own content) { identifier: "porn", configurable: true, defaultSetting: "hide", blurs: "media", severity: "none", adultOnly: true, locales: [ { lang: "en", name: "Adult Content", description: "Explicit sexual images.", }, ], }, { identifier: "sexual", configurable: true, defaultSetting: "warn", blurs: "media", severity: "none", adultOnly: true, locales: [ { lang: "en", name: "Sexually Suggestive", description: "Does not include nudity.", }, ], }, { identifier: "nudity", configurable: true, defaultSetting: "ignore", blurs: "media", severity: "none", locales: [ { lang: "en", name: "Non-sexual Nudity", description: "E.g. artistic nudes.", }, ], }, { identifier: "graphic-media", configurable: true, defaultSetting: "warn", blurs: "media", severity: "none", adultOnly: true, locales: [ { lang: "en", name: "Graphic Media", description: "Explicit or potentially disturbing media.", }, ], }, // Legacy label (maps to graphic-media) { identifier: "gore", configurable: true, defaultSetting: "warn", blurs: "media", severity: "none", adultOnly: true, locales: [ { lang: "en", name: "Graphic Media", description: "Explicit or potentially disturbing media.", }, ], },];
export function getGlobalLabelDefinition(labelValue) { return GLOBAL_LABELS.find((label) => label.identifier === labelValue) ?? null;}
export function isGlobalLabel(labelValue) { return GLOBAL_LABELS.some((label) => label.identifier === labelValue);}
export function isPinnedPost(feedItem) { return feedItem.reason?.$type === "app.bsky.feed.defs#reasonPin";}
// Normalize allow settings to array// - null/undefined allow (or no record) -> [{ type: "everybody" }]// - empty allow array -> [{ type: "nobody" }]// - otherwise one entry per rule:// { type: "mention" | "followers" | "following" }// { type: "list", list: <at-uri> }// { type: "unknown", raw: <rule> }export function normalizeThreadgateAllowSettings(allow) { if (isNil(allow)) { return [{ type: "everybody" }]; } if (allow.length === 0) { return [{ type: "nobody" }]; } return allow.map((rule) => { switch (rule.$type) { case "app.bsky.feed.threadgate#mentionRule": return { type: "mention" }; case "app.bsky.feed.threadgate#followerRule": return { type: "followers" }; case "app.bsky.feed.threadgate#followingRule": return { type: "following" }; case "app.bsky.feed.threadgate#listRule": return { type: "list", list: rule.list }; default: return { type: "unknown", raw: rule }; } });}
export function buildThreadgateAllowFromNormalized(settings) { if (settings.some((setting) => setting.type === "everybody")) { return null; } if (settings.some((setting) => setting.type === "nobody")) { return []; } return settings.map((setting) => { switch (setting.type) { case "mention": return { $type: "app.bsky.feed.threadgate#mentionRule" }; case "followers": return { $type: "app.bsky.feed.threadgate#followerRule" }; case "following": return { $type: "app.bsky.feed.threadgate#followingRule" }; case "list": return { $type: "app.bsky.feed.threadgate#listRule", list: setting.list, }; case "unknown": return setting.raw; default: throw new Error(`Unknown threadgate setting type: ${setting.type}`); } });}
const DISABLE_EMBEDDING_RULE_TYPE = "app.bsky.feed.postgate#disableRule";
export function hasDisableEmbeddingRule(embeddingRules) { return !!embeddingRules?.some( (rule) => rule.$type === DISABLE_EMBEDDING_RULE_TYPE, );}
export function buildPostgateEmbeddingRules(quotesEnabled) { return quotesEnabled ? null : [{ $type: DISABLE_EMBEDDING_RULE_TYPE }];}
export const MAX_DETACHED_EMBEDDING_URIS = 50;
export const MAX_HIDDEN_REPLIES = 300;
export function isThreadRootAuthor(post, did) { const rootUri = post.record?.reply?.root?.uri; return !!rootUri && parseUri(rootUri).repo === did;}
export function createDetachedQuote(uri) { return { $type: "app.bsky.embed.record#viewDetached", uri, detached: true };}
// Returns the quote embed of `post` if it embeds a post authored by `did`,// as `{ uri, isDetached }`, or null otherwise.export function getOwnQuoteEmbed(post, did) { const quotedPost = getQuotedPost(post); if (!quotedPost) { return null; } if (quotedPost.$type === "app.bsky.embed.record#viewDetached") { return parseUri(quotedPost.uri).repo === did ? { uri: quotedPost.uri, isDetached: true } : null; } if ( quotedPost.$type === "app.bsky.embed.record#viewRecord" && quotedPost.author?.did === did ) { return { uri: quotedPost.uri, isDetached: false }; } return null;}
export function replaceQuotedPost(post, quotedPost) { const embed = post.embed; if (embed?.$type === "app.bsky.embed.record#view") { return { ...post, embed: { ...embed, record: quotedPost } }; } if (embed?.$type === "app.bsky.embed.recordWithMedia#view") { return { ...post, embed: { ...embed, record: { ...embed.record, record: quotedPost } }, }; } return post;}
// Adds a feed item to the beginning of a feed, preserving pinned post position.export function addFeedItemToFeed(feedItem, feed) { const newFeed = []; const pinnedPost = feed.find((item) => isPinnedPost(item)); if (pinnedPost) { newFeed.push(pinnedPost); } newFeed.push(feedItem); newFeed.push(...feed.filter((item) => !isPinnedPost(item))); return newFeed;}
// Returns a new feed with the given post pinned to the top. Any previously// pinned item is unpinned. If the post is already in the feed it's removed// from its old position, otherwise a new feed item is created.export function pinPostInFeed(feed, post) { const reasonPin = { $type: "app.bsky.feed.defs#reasonPin" }; const unpinned = feed .filter((item) => item.post?.uri !== post.uri) .map((item) => isPinnedPost(item) ? { ...item, reason: undefined } : item, ); return [{ post, reason: reasonPin }, ...unpinned];}
// Returns a new feed with the given post unpinned. The item is left in place// (a follow-up feed reload will move it to its natural chronological position).export function unpinPostInFeed(feed, post) { return feed.map((item) => isPinnedPost(item) && item.post?.uri === post.uri ? { ...item, reason: undefined } : item, );}
export function canReplyToPost(post) { if (isBlockedPost(post) || isNotFoundPost(post) || isUnavailablePost(post)) { return false; } if (post.viewer?.replyDisabled) { return false; } return true;}
function replaceEmbed(post, embed) { if (post.embeds) { return { ...post, embeds: [embed, ...post.embeds.slice(1)] }; } return { ...post, embed };}
function transformQuote(post, updater) { const embed = post.embeds ? post.embeds[0] : post.embed; if (!embed) return post; if (embed.$type === "app.bsky.embed.record#view") { const updated = updater(embed.record); if (updated === embed.record) return post; return replaceEmbed(post, { ...embed, record: updated }); } if (embed.$type === "app.bsky.embed.recordWithMedia#view") { const updated = updater(embed.record.record); if (updated === embed.record.record) return post; return replaceEmbed(post, { ...embed, record: { ...embed.record, record: updated }, }); } return post;}
// Up to two levels deepexport function transformNestedQuotes(post, transform) { return transformQuote(post, (quotedPost) => { const updatedQuote = transform(quotedPost); return transformQuote(updatedQuote, (nestedQuotedPost) => transform(nestedQuotedPost), ); });}
export function isInAppLinkHostname(hostname) { return ( hostname === window.location.hostname || IN_APP_LINK_DOMAINS.includes(hostname) );}
// Display form of a link's host, e.g. "https://www.twitch.tv/x" -> "twitch.tv"export function getDisplayDomain(url) { try { return new URL(url).hostname.replace(/^www\./, ""); } catch { return url; }}
export function isAllowedLiveHost(url) { let hostname; try { hostname = new URL(url).hostname.toLowerCase(); } catch { return false; } return LIVE_ALLOWED_SERVICES.some( ({ domain }) => hostname === domain || hostname.endsWith("." + domain), );}
export function isStatusValid(statusView) { if (!statusView || typeof statusView !== "object") return false; if (statusView.status !== "app.bsky.actor.status#live") return false; if (!statusView.uri || !statusView.expiresAt) return false; if (statusView.embed?.$type !== "app.bsky.embed.external#view") return false; const externalUri = statusView.embed.external?.uri; return !!externalUri && isAllowedLiveHost(externalUri);}
const CHAT_INVITE_PATH_REGEX = /^\/chat\/([a-zA-Z0-9]{7,10})$/;
export function getInviteCodeFromUrl(url) { if (!url) return null; let pathname; try { const parsed = new URL( url.startsWith("/") ? `https://bsky.app${url}` : url, ); if (!url.startsWith("/") && !isInAppLinkHostname(parsed.hostname)) { return null; } pathname = parsed.pathname; } catch { return null; } return pathname.match(CHAT_INVITE_PATH_REGEX)?.[1] ?? null;}
export function isInviteLinkUrl(url) { return getInviteCodeFromUrl(url) !== null;}
const YOUTUBE_VIDEO_ID_REGEX = /^[A-Za-z0-9_-]+$/;const YOUTUBE_HOSTNAMES = [ "www.youtube.com", "youtube.com", "m.youtube.com", "music.youtube.com",];
export function parseYouTubeVideoFromUrl(url) { if (!url) return null; let parsedUrl; try { parsedUrl = new URL(url); } catch { return null; } let videoId = null; let isShort = false; if (parsedUrl.hostname === "youtu.be") { videoId = parsedUrl.pathname.split("/")[1]; } else if (YOUTUBE_HOSTNAMES.includes(parsedUrl.hostname)) { const [, page, pathVideoId] = parsedUrl.pathname.split("/"); isShort = page === "shorts"; if (isShort || page === "live") { videoId = pathVideoId; } else { videoId = parsedUrl.searchParams.get("v"); } } if (!videoId || !YOUTUBE_VIDEO_ID_REGEX.test(videoId)) return null; const startTime = parseYouTubeStartTime(parsedUrl.searchParams.get("t")); return { videoId, startTime, isShort };}
function parseYouTubeStartTime(rawValue) { if (!rawValue) return 0; const match = rawValue.match(/^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s?)?$/); if (!match || !(match[1] || match[2] || match[3])) return 0; const [, hours, minutes, seconds] = match; return ( Number(hours ?? 0) * 3600 + Number(minutes ?? 0) * 60 + Number(seconds ?? 0) );}
export function isVideoLink(url) { return parseYouTubeVideoFromUrl(url) !== null;}
export function getJoinLinkCodeFromEmbed(embed) { if (embed?.$type === "chat.bsky.embed.joinLink#view") { return embed.joinLinkPreview?.code ?? null; } if (embed?.$type === "app.bsky.embed.external#view") { return getInviteCodeFromUrl(embed.external?.uri); } return null;}
export function getJoinLinkCodesFromPosts(posts) { return (posts ?? []) .map((post) => getJoinLinkCodeFromEmbed(post?.embed)) .filter(Boolean);}
export function getJoinLinkCodesFromMessages(messages) { return (messages ?? []) .map((message) => getJoinLinkCodeFromEmbed(message?.embed)) .filter(Boolean);}
export function attachJoinLinkPreviewToEmbed(embed, preview) { if (embed?.$type === "chat.bsky.embed.joinLink#view") { if (preview === embed.joinLinkPreview) return null; return { ...embed, joinLinkPreview: preview }; } if (embed?.$type === "app.bsky.embed.external#view") { return { $type: "chat.bsky.embed.joinLink#view", joinLinkPreview: preview, }; } return null;}
const JOIN_LINK_PREVIEW_TYPE = "chat.bsky.group.defs#joinLinkPreviewView";
export function isAvailableJoinLinkPreview(preview) { return preview?.$type === JOIN_LINK_PREVIEW_TYPE;}
export function getPostsFromPostThread(postThread) { const posts = []; if (postThread.post) { posts.push(postThread.post); } posts.push(...getParentPosts(postThread)); posts.push(...getNestedReplyPosts(postThread)); return unique(posts, { by: "uri" });}
export function getPostsFromFeed(feed) { const posts = []; for (const feedItem of feed.feed) { posts.push(feedItem.post); if (feedItem.reply) { const root = feedItem.reply.root; if (root.$type === "app.bsky.feed.defs#postView") { posts.push(root); } const parent = feedItem.reply.parent; if (parent.$type === "app.bsky.feed.defs#postView") { posts.push(parent); } } } return unique(posts, { by: "uri" });}
export function getRepostersFromFeed(feed) { return feed.feed .map((feedItem) => feedItem.reason?.by) .filter((profile) => !isNil(profile));}
export function getImagesFromDraftPost(draftPost) { const items = draftPost.embedGallery?.items ?? draftPost.embedImages ?? []; return items.filter((item) => item.localRef?.path);}
export function getLocalRefsFromDraft(draft) { const refs = []; for (const draftPost of draft.posts ?? []) { for (const image of getImagesFromDraftPost(draftPost)) { refs.push(image.localRef.path); } for (const video of draftPost.embedVideos ?? []) { if (video.localRef?.path) { refs.push(video.localRef.path); } } } return refs;}