Something went wrong. Try again.
Mirror of https://github.com/improsocial/improAn extensible Bluesky client for web
Something went wrong. Try again.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109import { unique } from "/js/utils.js";import { BSKY_CDN_URL, FOLLOWING_FEED_URI, IN_APP_LINK_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 `${BSKY_CDN_URL}/img/${prefix}/plain/${did}/${cid}@jpeg`;}
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 isNotFoundPost(post) { return post.$type === "app.bsky.feed.defs#notFoundPost";}
export function createNotFoundPost(uri) { return { $type: "app.bsky.feed.defs#notFoundPost", uri, };}
export function isUnavailablePost(post) { return post.$type === "social.impro.feed.defs#unavailablePost";}
export function createUnavailablePost(uri) { return { $type: "social.impro.feed.defs#unavailablePost", 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";}
export function getThreadgateAllowSettings(post) { // - undefined allow -> { type: "everybody" } // - empty allow array -> { type: "nobody" } // - otherwise -> array of { type: "mention" | "followers" | "following" | "list" | "unknown", list? } const threadgate = post?.threadgate; if (!threadgate || !threadgate.record) { return { type: "everybody" }; } const allow = threadgate.record.allow; if (allow === undefined) { return { type: "everybody" }; } if (allow.length === 0) { return { type: "nobody" }; } const lists = threadgate.lists || []; 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: lists.find((listView) => listView.uri === rule.list) ?? null, }; default: return { type: "unknown" }; } });}
// 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), ); });}
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("/") && !IN_APP_LINK_DOMAINS.includes(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;}