import { 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]+)|((?[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), }, }; }