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
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233import { isNil, unique } from "/js/utils.js";import { CDN_URL, FOLLOWING_FEED_URI, IN_APP_LINK_DOMAINS, LIVE_ALLOWED_DOMAINS,} 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 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);}
// 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 }, };}
export function flattenParents(postThread) { const parents = []; let current = postThread.parent; while (current) { parents.unshift(current); current = current.parent; } return parents;}
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 getImagesFromPost(post) { if (post?.embed?.$type === "app.bsky.embed.images#view") { return post.embed.images; } if (post?.embed?.$type === "app.bsky.embed.recordWithMedia#view") { if (post.embed.media?.$type === "app.bsky.embed.images#view") { return post.embed.media.images; } } return [];}
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 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", 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", 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 }];}
// 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_DOMAINS.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 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;}