···99import {Plural, Trans, useLingui} from '@lingui/react/macro'
1010import {useQueryClient} from '@tanstack/react-query'
11111212-import {detectFacetsWithoutResolution} from '#/lib/strings/detect-facets'
1312import {sanitizeHandle} from '#/lib/strings/handles'
1413import {logger} from '#/logger'
1514import {precacheFeedFromGeneratorView} from '#/state/queries/feed'
···214213 const rt = useMemo(() => {
215214 if (!description) return
216215 const rt = new RichTextApi({text: description || ''})
217217- detectFacetsWithoutResolution(rt)
216216+ rt.detectFacetsWithoutResolution()
218217 return rt
219218 }, [description])
220219 if (!rt) return null
+1-2
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'
2120import {sanitizeDisplayName} from '#/lib/strings/display-names'
2221import {sanitizeHandle} from '#/lib/strings/handles'
2322import {useProfileShadow} from '#/state/cache/profile-shadow'
···403402 const rt = useMemo(() => {
404403 if (!('description' in profile)) return
405404 const rt = new RichTextApi({text: profile.description || ''})
406406- detectFacetsWithoutResolution(rt)
405405+ rt.detectFacetsWithoutResolution()
407406 return rt
408407 }, [profile])
409408 if (!rt) return null
+1-2
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'
65import {toShortUrl} from '#/lib/strings/url-helpers'
76import {atoms as a, flatten, type TextStyleProp} from '#/alf'
87import {isOnlyEmoji} from '#/alf/typography'
···6261 return value
6362 } else {
6463 const rt = new RichTextAPI({text: value})
6565- detectFacetsWithoutResolution(rt)
6464+ rt.detectFacetsWithoutResolution()
6665 return rt
6766 }
6867 }, [value])
···11import {useEffect, useState} from 'react'
22import {RichText as RichTextAPI} from '@atproto/api'
3344-import {detectFacets} from '#/lib/strings/detect-facets'
54import {useAgent} from '#/state/session'
6576export function useRichText(text: string): [RichTextAPI, boolean] {
···2019 async function resolveRTFacets() {
2120 // new each time
2221 const resolvedRT = new RichTextAPI({text})
2323- await detectFacets(agent, resolvedRT)
2222+ await resolvedRT.detectFacets(agent)
2423 if (!ignore) {
2524 setResolvedRT(resolvedRT)
2625 }
+1-2
src/lib/api/index.ts
···4646} from '#/view/com/composer/state/composer'
4747import {IS_IOS, IS_WEB} from '#/env'
4848import {createGIFDescription} from '../gif-alt-text'
4949-import {detectFacets} from '../strings/detect-facets'
5049import {uploadBlob} from './upload-blob'
51505251export {uploadBlob}
···227226 parseMarkdownLinks(trimmedText)
228227229228 let rt = new RichText({text: parsedText})
230230- await detectFacets(agent, rt)
229229+ await rt.detectFacets(agent)
231230232231 if (markdownFacets.length > 0) {
233232 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'
2019import {
2120 convertBskyAppUrlIfNeeded,
2221 isBskyPostUrl,
···7776 setEmbed: (embedUrl: string | undefined) => void,
7877) {
7978 const rt = new RichTextAPI({text: message})
8080- detectFacetsWithoutResolution(rt)
7979+ rt.detectFacetsWithoutResolution()
81808281 let uriFromFacet: string | undefined
8382
+2-6
src/screens/Messages/components/MessagesList.tsx
···31313232import {mergeRefs} from '#/lib/merge-refs'
3333import {ScrollProvider} from '#/lib/ScrollContext'
3434-import {
3535- detectFacets,
3636- detectFacetsWithoutResolution,
3737-} from '#/lib/strings/detect-facets'
3834import {shortenLinks, stripInvalidMentions} from '#/lib/strings/rich-text-manip'
3935import {
4036 convertBskyAppUrlIfNeeded,
···294290 // detect facets without resolution first - this is used to see if there's
295291 // any post links in the text that we can embed. We do this first because
296292 // we want to remove the post link from the text, re-trim, then detect facets
297297- detectFacetsWithoutResolution(rt)
293293+ rt.detectFacetsWithoutResolution()
298294299295 let embed: $Typed<AppBskyEmbedRecord.Main> | undefined
300296···348344 }
349345 }
350346351351- await detectFacets(agent, rt)
347347+ await rt.detectFacets(agent)
352348353349 rt = shortenLinks(rt)
354350 rt = stripInvalidMentions(rt)
+2-3
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'
2221import {
2322 createStarterPackUri,
2423 httpStarterPackUriToAtUri,
···117116 let descriptionFacets: AppBskyRichtextFacet.Main[] | undefined
118117 if (description) {
119118 const rt = new RichText({text: description})
120120- await detectFacets(agent, rt)
119119+ await rt.detectFacets(agent)
121120 descriptionFacets = rt.facets
122121 }
123122···189188 let descriptionFacets: AppBskyRichtextFacet.Main[] | undefined
190189 if (description) {
191190 const rt = new RichText({text: description})
192192- await detectFacets(agent, rt)
191191+ await rt.detectFacets(agent)
193192 descriptionFacets = rt.facets
194193 }
195194
···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'
122import {Mark} from '@tiptap/core'
223import {type Node as ProsemirrorNode} from '@tiptap/pm/model'
324import {Plugin, PluginKey} from '@tiptap/pm/state'
425import {Decoration, DecorationSet} from '@tiptap/pm/view'
55-66-import {detectFacetRunsWithoutResolution} from '#/lib/strings/detect-facets'
726827function getDecorations(doc: ProsemirrorNode) {
928 const decorations: Decoration[] = []
10291130 doc.descendants((node, pos) => {
1231 if (node.isText && node.text) {
3232+ const regex = TAG_REGEX
1333 const textContent = node.textContent
1414- const facetRuns = detectFacetRunsWithoutResolution(textContent)
15341616- let currentOffset = 0
1717- for (const run of facetRuns) {
1818- const runLength = run.text.length
3535+ // Detect hashtags
3636+ let match
3737+ while ((match = regex.exec(textContent))) {
3838+ const [matchedString, __, tag] = match
19392020- if (run.features?.length) {
2121- const start = pos + currentOffset
2222- const end = start + runLength
4040+ if (!tag || tag.replace(TRAILING_PUNCTUATION_REGEX, '').length > 64)
4141+ continue
23422424- decorations.push(
2525- Decoration.inline(start, end, {
2626- class: 'autolink',
2727- }),
2828- )
2929- }
4343+ const [trailingPunc = ''] = tag.match(TRAILING_PUNCTUATION_REGEX) || []
4444+ const matchedFrom = match.index + matchedString.indexOf(tag)
4545+ const matchedTo = matchedFrom + (tag.length - trailingPunc.length)
30463131- currentOffset += runLength
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
6565+6666+ if (!ticker) continue
6767+6868+ // Calculate positions: leading char + $ + ticker
6969+ const matchedFrom = match.index + leading.length
7070+ const matchedTo = matchedFrom + 1 + ticker.length // +1 for $
7171+7272+ const start = pos + matchedFrom
7373+ const end = pos + matchedTo
7474+7575+ decorations.push(
7676+ Decoration.inline(start, end, {
7777+ class: 'autolink',
7878+ }),
7979+ )
3280 }
3381 }
3482 })
+1-2
src/view/screens/Profile.tsx
···2222 type NativeStackScreenProps,
2323 type NavigationProp,
2424} from '#/lib/routes/types'
2525-import {detectFacets} from '#/lib/strings/detect-facets'
2625import {combinedDisplayName} from '#/lib/strings/display-names'
2726import {cleanError} from '#/lib/strings/errors'
2827import {isInvalidHandle} from '#/lib/strings/handles'
···614613 async function resolveRTFacets() {
615614 // new each time
616615 const resolvedRT = new RichTextAPI({text})
617617- await detectFacets(agent, resolvedRT)
616616+ await resolvedRT.detectFacets(agent)
618617 if (!ignore) {
619618 setResolvedRT(resolvedRT)
620619 }