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
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258import { getByteIndex, sliceByByte, getByteLength } from "/js/utils.js";import tlds from "/js/lib/tlds.js";
// Matches logic in atproto/packages/api/src/rich-text/detection.ts
const urlRegex = /(^|\s|\()((https?:\/\/[\S]+)|((?<domain>[a-z][a-z0-9]*(\.[a-z0-9]+)+)[\S]*))/gim;
function isValidDomain(domain) { return tlds.some((tld) => domain.endsWith(`.${tld}`));}
function trimUri(uri) { let trimmed = uri; if (".,;:!?".includes(trimmed.at(-1))) trimmed = trimmed.slice(0, -1); if (trimmed.endsWith(")") && !trimmed.includes("(")) { trimmed = trimmed.slice(0, -1); } return trimmed;}
function getLinkFacetsFromText(text) { const facets = []; for (const match of text.matchAll(urlRegex)) { const leading = match[1]; const isHttp = match[2].startsWith("http");
if (!isHttp) { const domain = match.groups?.domain; if (!domain || !isValidDomain(domain)) continue; }
const uri = trimUri(match[2]); const start = match.index + leading.length; facets.push({ index: { byteStart: getByteIndex(text, start), byteEnd: getByteIndex(text, start + uri.length), }, features: [ { $type: "app.bsky.richtext.facet#link", uri: isHttp ? uri : `https://${uri}`, }, ], }); } return facets;}
const hashtagRegex = /(^|\s)[##]((?!️)[^\s ⃢]*[^\d\s\p{P} ⃢]+[^\s ⃢]*)?/gu;const trailingPunctuationRegex = /\p{P}+$/gu;
function getHashtags(text) { return [...text.matchAll(hashtagRegex)] .map((match) => { const leading = match[1]; let tag = match[2]; if (!tag) return null; tag = tag.trim().replace(trailingPunctuationRegex, ""); if (tag.length === 0 || tag.length > 64) return null; const start = match.index + leading.length; const byteStart = getByteIndex(text, start); const byteEnd = getByteIndex(text, start + 1 + tag.length); return { index: { byteStart, byteEnd }, features: [{ $type: "app.bsky.richtext.facet#tag", tag }], }; }) .filter(Boolean);}
const cashtagRegex = /(^|\s|\()\$([A-Za-z][A-Za-z0-9]{0,4})(?=\s|$|[.,;:!?)"'’])/gu;
function getCashtags(text) { return [...text.matchAll(cashtagRegex)].map((match) => { const leading = match[1]; const ticker = match[2].toUpperCase(); const start = match.index + leading.length; const byteStart = getByteIndex(text, start); const byteEnd = getByteIndex(text, start + 1 + ticker.length); return { index: { byteStart, byteEnd }, features: [{ $type: "app.bsky.richtext.facet#tag", tag: `$${ticker}` }], }; });}
const mentionRegex = /(^|\s|\()(@)([a-zA-Z0-9.-]+)(\b)/g;
function getUnresolvedMentions(text) { return [...text.matchAll(mentionRegex)] .map((match) => { const leading = match[1]; const handle = match[3]; if (!isValidDomain(handle)) return null; const start = match.index + leading.length; const byteStart = getByteIndex(text, start); const byteEnd = getByteIndex(text, start + 1 + handle.length); return { index: { byteStart, byteEnd }, features: [ { $type: "app.bsky.richtext.facet#mention", handle, }, ], }; }) .filter(Boolean);}
async function resolveMentions(mentions, identityResolver) { const resolvedMentions = []; await Promise.all( mentions.map(async (mention) => { let did = null; try { did = await identityResolver.resolveHandle(mention.features[0].handle); } catch (error) { // if we can't resolve the mention, just leave it out } if (did) { resolvedMentions.push({ ...mention, features: [{ $type: "app.bsky.richtext.facet#mention", did }], }); } }), ); return resolvedMentions;}
export function getUnresolvedFacetsFromText(text) { if (!text) { return []; } const links = getLinkFacetsFromText(text); const hashtags = getHashtags(text); const cashtags = getCashtags(text); const unresolvedMentions = getUnresolvedMentions(text); return [...links, ...hashtags, ...cashtags, ...unresolvedMentions];}
export async function resolveFacets(facets, identityResolver) { const resolvedFacets = []; const unresolvedMentions = []; for (const facet of facets) { // Only handle one feature for now const feature = facet.features?.[0]; if (!feature) { resolvedFacets.push(facet); continue; } if (feature.$type === "app.bsky.richtext.facet#mention" && !feature.did) { unresolvedMentions.push(facet); } else { resolvedFacets.push(facet); } } const resolvedMentions = await resolveMentions( unresolvedMentions, identityResolver, ); return [...resolvedFacets, ...resolvedMentions];}
export async function getFacetsFromText(text, identityResolver) { const unresolvedFacets = getUnresolvedFacetsFromText(text); const resolvedFacets = await resolveFacets( unresolvedFacets, identityResolver, ); return resolvedFacets;}
export function getLinkUrlsFromText(text) { if (!text) { return []; } return getLinkFacetsFromText(text).map((facet) => facet.features[0].uri);}
// Removes the link matching `url` from the text when it sits at the very// start or end of the message (ignoring surrounding whitespace). Matching is// facet-based because a typed link without a scheme (e.g. "bsky.app/...")// produces a normalized facet uri that doesn't appear verbatim in the text.export function stripLeadingOrTrailingLink(text, url) { const linkFacets = getLinkFacetsFromText(text).filter( (facet) => facet.features[0].uri === url, ); const totalBytes = getByteLength(text); for (const facet of linkFacets) { const before = sliceByByte(text, 0, facet.index.byteStart); const after = sliceByByte(text, facet.index.byteEnd, totalBytes); if (before.trim() === "") { return after.trimStart(); } if (after.trim() === "") { return before.trimEnd(); } } return text;}
export function getTagsFromFacets(facets) { return facets.filter( (facet) => facet.features?.[0]?.$type === "app.bsky.richtext.facet#tag", );}
// Reconstructs the plain-text representation of a post, substituting// shortened link display text with the full URI from its facet. This matches// the behavior of social-app's "Copy post text" action.export function richTextToString(text, facets) { if (!text) { return ""; } if (!facets?.length) { return text; } const linkFacets = facets .filter( (facet) => facet.features?.[0]?.$type === "app.bsky.richtext.facet#link" && facet.features[0].uri, ) .slice() .sort((a, b) => a.index.byteStart - b.index.byteStart);
const totalBytes = getByteLength(text); let result = ""; let cursor = 0; for (const facet of linkFacets) { const { byteStart, byteEnd } = facet.index; if (byteStart < cursor || byteEnd > totalBytes) { continue; } result += sliceByByte(text, cursor, byteStart); result += facet.features[0].uri; cursor = byteEnd; } result += sliceByByte(text, cursor, totalBytes); return result;}
export function clampFacetIndex(facet, { byteStart, byteEnd }) { return { ...facet, index: { byteStart: Math.max(facet.index.byteStart, byteStart), byteEnd: Math.min(facet.index.byteEnd, byteEnd), }, };}