···99import {Plural, Trans, useLingui} from '@lingui/react/macro'
1010import {useQueryClient} from '@tanstack/react-query'
11111212+import {detectFacetsWithoutResolution} from '#/lib/strings/detect-facets'
1213import {sanitizeHandle} from '#/lib/strings/handles'
1314import {logger} from '#/logger'
1415import {precacheFeedFromGeneratorView} from '#/state/queries/feed'
···213214 const rt = useMemo(() => {
214215 if (!description) return
215216 const rt = new RichTextApi({text: description || ''})
216216- rt.detectFacetsWithoutResolution()
217217+ detectFacetsWithoutResolution(rt)
217218 return rt
218219 }, [description])
219220 if (!rt) return null
+2-1
src/components/ProfileCard.tsx
···1717import {makeProfileLink} from '#/lib/routes/links'
1818import {forceLTR} from '#/lib/strings/bidi'
1919import {NON_BREAKING_SPACE} from '#/lib/strings/constants'
2020+import {detectFacetsWithoutResolution} from '#/lib/strings/detect-facets'
2021import {sanitizeDisplayName} from '#/lib/strings/display-names'
2122import {sanitizeHandle} from '#/lib/strings/handles'
2223import {useProfileShadow} from '#/state/cache/profile-shadow'
···400401 const rt = useMemo(() => {
401402 if (!('description' in profile)) return
402403 const rt = new RichTextApi({text: profile.description || ''})
403403- rt.detectFacetsWithoutResolution()
404404+ detectFacetsWithoutResolution(rt)
404405 return rt
405406 }, [profile])
406407 if (!rt) return null
+2-1
src/components/RichText.tsx
···22import {type StyleProp, type TextStyle} from 'react-native'
33import {AppBskyRichtextFacet, RichText as RichTextAPI} from '@atproto/api'
4455+import {detectFacetsWithoutResolution} from '#/lib/strings/detect-facets'
56import {toShortUrl} from '#/lib/strings/url-helpers'
67import {atoms as a, flatten, type TextStyleProp} from '#/alf'
78import {isOnlyEmoji} from '#/alf/typography'
···6162 return value
6263 } else {
6364 const rt = new RichTextAPI({text: value})
6464- rt.detectFacetsWithoutResolution()
6565+ detectFacetsWithoutResolution(rt)
6566 return rt
6667 }
6768 }, [value])
···11import {useEffect, useState} from 'react'
22import {RichText as RichTextAPI} from '@atproto/api'
3344+import {detectFacets} from '#/lib/strings/detect-facets'
45import {useAgent} from '#/state/session'
5667export function useRichText(text: string): [RichTextAPI, boolean] {
···1920 async function resolveRTFacets() {
2021 // new each time
2122 const resolvedRT = new RichTextAPI({text})
2222- await resolvedRT.detectFacets(agent)
2323+ await detectFacets(agent, resolvedRT)
2324 if (!ignore) {
2425 setResolvedRT(resolvedRT)
2526 }
+2-1
src/lib/api/index.ts
···4545 type ThreadDraft,
4646} from '#/view/com/composer/state/composer'
4747import {createGIFDescription} from '../gif-alt-text'
4848+import {detectFacets} from '../strings/detect-facets'
4849import {uploadBlob} from './upload-blob'
49505051export {uploadBlob}
···215216 parseMarkdownLinks(trimmedText)
216217217218 let rt = new RichText({text: parsedText})
218218- await rt.detectFacets(agent)
219219+ await detectFacets(agent, rt)
219220220221 if (markdownFacets.length > 0) {
221222 const nonOverlapping = (rt.facets || []).filter(f => {
···1616 type CommonNavigatorParams,
1717 type NavigationProp,
1818} from '#/lib/routes/types'
1919+import {detectFacetsWithoutResolution} from '#/lib/strings/detect-facets'
1920import {
2021 convertBskyAppUrlIfNeeded,
2122 isBskyPostUrl,
···7677 setEmbed: (embedUrl: string | undefined) => void,
7778) {
7879 const rt = new RichTextAPI({text: message})
7979- rt.detectFacetsWithoutResolution()
8080+ detectFacetsWithoutResolution(rt)
80818182 let uriFromFacet: string | undefined
8283
+6-2
src/screens/Messages/components/MessagesList.tsx
···19192020import {useHideBottomBarBorderForScreen} from '#/lib/hooks/useHideBottomBarBorder'
2121import {ScrollProvider} from '#/lib/ScrollContext'
2222+import {
2323+ detectFacets,
2424+ detectFacetsWithoutResolution,
2525+} from '#/lib/strings/detect-facets'
2226import {shortenLinks, stripInvalidMentions} from '#/lib/strings/rich-text-manip'
2327import {
2428 convertBskyAppUrlIfNeeded,
···325329 // detect facets without resolution first - this is used to see if there's
326330 // any post links in the text that we can embed. We do this first because
327331 // we want to remove the post link from the text, re-trim, then detect facets
328328- rt.detectFacetsWithoutResolution()
332332+ detectFacetsWithoutResolution(rt)
329333330334 let embed: $Typed<AppBskyEmbedRecord.Main> | undefined
331335···379383 }
380384 }
381385382382- await rt.detectFacets(agent)
386386+ await detectFacets(agent, rt)
383387384388 rt = shortenLinks(rt)
385389 rt = stripInvalidMentions(rt)
+3-2
src/state/queries/starter-packs.ts
···18181919import {until} from '#/lib/async/until'
2020import {createStarterPackList} from '#/lib/generate-starterpack'
2121+import {detectFacets} from '#/lib/strings/detect-facets'
2122import {
2223 createStarterPackUri,
2324 httpStarterPackUriToAtUri,
···116117 let descriptionFacets: AppBskyRichtextFacet.Main[] | undefined
117118 if (description) {
118119 const rt = new RichText({text: description})
119119- await rt.detectFacets(agent)
120120+ await detectFacets(agent, rt)
120121 descriptionFacets = rt.facets
121122 }
122123···188189 let descriptionFacets: AppBskyRichtextFacet.Main[] | undefined
189190 if (description) {
190191 const rt = new RichText({text: description})
191191- await rt.detectFacets(agent)
192192+ await detectFacets(agent, rt)
192193 descriptionFacets = rt.facets
193194 }
194195
+2-1
src/view/com/composer/drafts/state/api.ts
···88import {getDeviceName} from '#/lib/deviceName'
99import {getImageDim} from '#/lib/media/manip'
1010import {mimeToExt} from '#/lib/media/video/util'
1111+import {detectFacetsWithoutResolution} from '#/lib/strings/detect-facets'
1112import {shortenLinks} from '#/lib/strings/rich-text-manip'
1213import {type ComposerImage} from '#/state/gallery'
1314import {type Gif} from '#/state/queries/tenor'
···420421 const posts = await Promise.all(
421422 draft.posts.map(async (post, index) => {
422423 const richtext = new RichText({text: post.text || ''})
423423- richtext.detectFacetsWithoutResolution()
424424+ detectFacetsWithoutResolution(richtext)
424425425426 const embed: EmbedDraft = {
426427 quote: undefined,
+5-4
src/view/com/composer/state/composer.ts
···99import {nanoid} from 'nanoid/non-secure'
10101111import {type SelfLabel} from '#/lib/moderation'
1212+import {detectFacetsWithoutResolution} from '#/lib/strings/detect-facets'
1213import {insertMentionAt} from '#/lib/strings/mention-manip'
1314import {parseMarkdownLinks, shortenLinks} from '#/lib/strings/rich-text-manip'
1415import {
···685686 * we suggest at most 1 of each.
686687 */
687688 if (initText) {
688688- initRichText.detectFacetsWithoutResolution()
689689+ detectFacetsWithoutResolution(initRichText)
689690 const detectedExtUris = new Map<string, LinkFacetMatch>()
690691 const detectedPostUris = new Map<string, LinkFacetMatch>()
691692 if (initRichText.facets) {
···734735 }
735736 } else if (initMention) {
736737 // highlight the mention
737737- initRichText.detectFacetsWithoutResolution()
738738+ detectFacetsWithoutResolution(initRichText)
738739 }
739740740741 return {
···772773function getShortenedLength(rt: RichText) {
773774 const {text} = parseMarkdownLinks(rt.text)
774775 const newRt = new RichText({text})
775775- newRt.detectFacetsWithoutResolution()
776776+ detectFacetsWithoutResolution(newRt)
776777 return shortenLinks(newRt).graphemeLength
777778}
778779···785786 },
786787): PostDraft {
787788 const richtext = new RichText({text})
788788- richtext.detectFacetsWithoutResolution()
789789+ detectFacetsWithoutResolution(richtext)
789790790791 return {
791792 id: overrides?.id ?? nanoid(),
+2-1
src/view/com/composer/text-input/TextInput.tsx
···1919import {POST_IMG_MAX} from '#/lib/constants'
2020import {downloadAndResize} from '#/lib/media/manip'
2121import {isUriImage} from '#/lib/media/util'
2222+import {detectFacetsWithoutResolution} from '#/lib/strings/detect-facets'
2223import {getMentionAt, insertMentionAt} from '#/lib/strings/mention-manip'
2324import {useTheme} from '#/lib/ThemeContext'
2425import {
···110111 }
111112112113 const newRt = new RichText({text: newText})
113113- newRt.detectFacetsWithoutResolution()
114114+ detectFacetsWithoutResolution(newRt)
114115115116 const markdownFacets: AppBskyRichtextFacet.Main[] = []
116117 const regex = /\[([^\]]+)\]\s*\(([^)]+)\)/g
···11-/**
22- * TipTap is a stateful rich-text editor, which is extremely useful
33- * when you _want_ it to be stateful formatting such as bold and italics.
44- *
55- * However we also use "stateless" behaviors, specifically for URLs
66- * where the text itself drives the formatting.
77- *
88- * This plugin uses a regex to detect URIs and then applies
99- * link decorations (a <span> with the "autolink") class. That avoids
1010- * adding any stateful formatting to TipTap's document model.
1111- *
1212- * We then run the URI detection again when constructing the
1313- * RichText object from TipTap's output and merge their features into
1414- * the facet-set.
1515- */
1616-1717-import {
1818- CASHTAG_REGEX,
1919- TAG_REGEX,
2020- TRAILING_PUNCTUATION_REGEX,
2121-} from '@atproto/api'
221import {Mark} from '@tiptap/core'
232import {type Node as ProsemirrorNode} from '@tiptap/pm/model'
243import {Plugin, PluginKey} from '@tiptap/pm/state'
254import {Decoration, DecorationSet} from '@tiptap/pm/view'
55+66+import {detectFacetRunsWithoutResolution} from '#/lib/strings/detect-facets'
267278function getDecorations(doc: ProsemirrorNode) {
289 const decorations: Decoration[] = []
29103011 doc.descendants((node, pos) => {
3112 if (node.isText && node.text) {
3232- const regex = TAG_REGEX
3313 const textContent = node.textContent
3434-3535- // Detect hashtags
3636- let match
3737- while ((match = regex.exec(textContent))) {
3838- const [matchedString, __, tag] = match
3939-4040- if (!tag || tag.replace(TRAILING_PUNCTUATION_REGEX, '').length > 64)
4141- continue
4242-4343- const [trailingPunc = ''] = tag.match(TRAILING_PUNCTUATION_REGEX) || []
4444- const matchedFrom = match.index + matchedString.indexOf(tag)
4545- const matchedTo = matchedFrom + (tag.length - trailingPunc.length)
4646-4747- /*
4848- * The match is exclusive of `#` so we need to adjust the start of the
4949- * highlight by -1 to include the `#`
5050- */
5151- const start = pos + matchedFrom - 1
5252- const end = pos + matchedTo
5353-5454- decorations.push(
5555- Decoration.inline(start, end, {
5656- class: 'autolink',
5757- }),
5858- )
5959- }
6060-6161- // Detect cashtags
6262- const cashtagRegex = new RegExp(CASHTAG_REGEX.source, 'gu')
6363- while ((match = cashtagRegex.exec(textContent))) {
6464- const [_fullMatch, leading, ticker] = match
1414+ const facetRuns = detectFacetRunsWithoutResolution(textContent)
65156666- if (!ticker) continue
1616+ let currentOffset = 0
1717+ for (const run of facetRuns) {
1818+ const runLength = run.text.length
67196868- // Calculate positions: leading char + $ + ticker
6969- const matchedFrom = match.index + leading.length
7070- const matchedTo = matchedFrom + 1 + ticker.length // +1 for $
2020+ if (run.features?.length) {
2121+ const start = pos + currentOffset
2222+ const end = start + runLength
71237272- const start = pos + matchedFrom
7373- const end = pos + matchedTo
2424+ decorations.push(
2525+ Decoration.inline(start, end, {
2626+ class: 'autolink',
2727+ }),
2828+ )
2929+ }
74307575- decorations.push(
7676- Decoration.inline(start, end, {
7777- class: 'autolink',
7878- }),
7979- )
3131+ currentOffset += runLength
8032 }
8133 }
8234 })
+2-1
src/view/screens/Profile.tsx
···2222 type NativeStackScreenProps,
2323 type NavigationProp,
2424} from '#/lib/routes/types'
2525+import {detectFacets} from '#/lib/strings/detect-facets'
2526import {combinedDisplayName} from '#/lib/strings/display-names'
2627import {cleanError} from '#/lib/strings/errors'
2728import {isInvalidHandle} from '#/lib/strings/handles'
···616617 async function resolveRTFacets() {
617618 // new each time
618619 const resolvedRT = new RichTextAPI({text})
619619- await resolvedRT.detectFacets(agent)
620620+ await detectFacets(agent, resolvedRT)
620621 if (!ignore) {
621622 setResolvedRT(resolvedRT)
622623 }