diff --git a/__tests__/lib/string.test.ts b/__tests__/lib/string.test.ts index d26be3fd3..b8c5a4f89 100644 --- a/__tests__/lib/string.test.ts +++ b/__tests__/lib/string.test.ts @@ -1,7 +1,6 @@ import {RichText} from '@atproto/api' import {i18n} from '@lingui/core' -import {detectFacetsWithoutResolution} from '#/lib/strings/detect-facets' import {parseEmbedPlayerFromUrl} from '#/lib/strings/embed-player' import { createStarterPackGooglePlayUri, @@ -329,7 +328,7 @@ describe('shortenLinks', () => { for (let i = 0; i < inputs.length; i++) { const input = inputs[i] const inputRT = new RichText({text: input}) - detectFacetsWithoutResolution(inputRT) + inputRT.detectFacetsWithoutResolution() const outputRT = shortenLinks(inputRT) expect(outputRT.text).toEqual(outputs[i][0]) expect(outputRT.facets?.length).toEqual(outputs[i][1].length) diff --git a/package.json b/package.json index ecd1adca7..a6aa0ab7b 100644 --- a/package.json +++ b/package.json @@ -103,7 +103,6 @@ "@bsky.app/tapper": "^0.5.1", "@bsky.app/video": "0.3.4", "@discord/bottom-sheet": "github:bluesky-social/react-native-bottom-sheet", - "@easrng/tr58": "0.0.5", "@emoji-mart/data": "^1.2.1", "@emoji-mart/react": "^1.1.1", "@expo/html-elements": "^0.12.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a8cfa6112..b48ed2c5c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -156,9 +156,6 @@ importers: '@discord/bottom-sheet': specifier: github:bluesky-social/react-native-bottom-sheet version: https://codeload.github.com/bluesky-social/react-native-bottom-sheet/tar.gz/28a87d1bb55e10fc355fa1455545a30734995908(patch_hash=c1f55b9e514f17d0fb14cb8f63be8c29c13813dc92825ad1b068319a89b78058)(@shopify/flash-list@2.3.1(@babel/runtime@7.25.9)(react-native@0.81.5(patch_hash=1708bf9fa9265ebd463d53fa71c037e9387eb16fb483287e49616407b4dc342e)(@babel/core@7.25.2)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0))(@types/react@19.2.14)(react-native-gesture-handler@2.28.0(react-native@0.81.5(patch_hash=1708bf9fa9265ebd463d53fa71c037e9387eb16fb483287e49616407b4dc342e)(@babel/core@7.25.2)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0))(react-native-reanimated@3.19.5(patch_hash=c07ea02fe4c889e65498c2fb39d82e93a0745a06e7800850054fbf0cb95ee1e4)(@babel/core@7.25.2)(react-native@0.81.5(patch_hash=1708bf9fa9265ebd463d53fa71c037e9387eb16fb483287e49616407b4dc342e)(@babel/core@7.25.2)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=1708bf9fa9265ebd463d53fa71c037e9387eb16fb483287e49616407b4dc342e)(@babel/core@7.25.2)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0) - '@easrng/tr58': - specifier: 0.0.5 - version: 0.0.5 '@emoji-mart/data': specifier: ^1.2.1 version: 1.2.1 @@ -2298,9 +2295,6 @@ packages: resolution: {integrity: sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==} engines: {node: '>=10.0.0'} - '@easrng/tr58@0.0.5': - resolution: {integrity: sha512-LJEAySm4hz77r+9Y2ULDQfTXYL3pp5QxedAITlHwkXBeivhQSLoVEwZHNkU9cByjfaldANZuHhqGKCivoLVeJg==} - '@egjs/hammerjs@2.0.17': resolution: {integrity: sha512-XQsZgjm2EcVUiZQf11UBJQfmZeEmOW8DpI1gsFeln6w0ae0ii4dMQEQ0kjl6DspdWX1aGY1/loyXnP0JS06e/A==} engines: {node: '>=0.8.0'} @@ -15326,8 +15320,6 @@ snapshots: '@discoveryjs/json-ext@0.5.7': {} - '@easrng/tr58@0.0.5': {} - '@egjs/hammerjs@2.0.17': dependencies: '@types/hammerjs': 2.0.46 diff --git a/src/components/FeedCard.tsx b/src/components/FeedCard.tsx index c00cb06c3..efd14be4b 100644 --- a/src/components/FeedCard.tsx +++ b/src/components/FeedCard.tsx @@ -9,7 +9,6 @@ import { import {Plural, Trans, useLingui} from '@lingui/react/macro' import {useQueryClient} from '@tanstack/react-query' -import {detectFacetsWithoutResolution} from '#/lib/strings/detect-facets' import {sanitizeHandle} from '#/lib/strings/handles' import {logger} from '#/logger' import {precacheFeedFromGeneratorView} from '#/state/queries/feed' @@ -214,7 +213,7 @@ export function Description({ const rt = useMemo(() => { if (!description) return const rt = new RichTextApi({text: description || ''}) - detectFacetsWithoutResolution(rt) + rt.detectFacetsWithoutResolution() return rt }, [description]) if (!rt) return null diff --git a/src/components/ProfileCard.tsx b/src/components/ProfileCard.tsx index e9b7a6f77..47f31649c 100644 --- a/src/components/ProfileCard.tsx +++ b/src/components/ProfileCard.tsx @@ -17,7 +17,6 @@ import {getModerationCauseKey} from '#/lib/moderation' import {makeProfileLink} from '#/lib/routes/links' import {forceLTR} from '#/lib/strings/bidi' import {NON_BREAKING_SPACE} from '#/lib/strings/constants' -import {detectFacetsWithoutResolution} from '#/lib/strings/detect-facets' import {sanitizeDisplayName} from '#/lib/strings/display-names' import {sanitizeHandle} from '#/lib/strings/handles' import {useProfileShadow} from '#/state/cache/profile-shadow' @@ -403,7 +402,7 @@ export function Description({ const rt = useMemo(() => { if (!('description' in profile)) return const rt = new RichTextApi({text: profile.description || ''}) - detectFacetsWithoutResolution(rt) + rt.detectFacetsWithoutResolution() return rt }, [profile]) if (!rt) return null diff --git a/src/components/RichText.tsx b/src/components/RichText.tsx index 2d02bb0c6..535ec5bcd 100644 --- a/src/components/RichText.tsx +++ b/src/components/RichText.tsx @@ -2,7 +2,6 @@ import {useMemo} from 'react' import {type StyleProp, type TextStyle} from 'react-native' import {AppBskyRichtextFacet, RichText as RichTextAPI} from '@atproto/api' -import {detectFacetsWithoutResolution} from '#/lib/strings/detect-facets' import {toShortUrl} from '#/lib/strings/url-helpers' import {atoms as a, flatten, type TextStyleProp} from '#/alf' import {isOnlyEmoji} from '#/alf/typography' @@ -62,7 +61,7 @@ export function RichText({ return value } else { const rt = new RichTextAPI({text: value}) - detectFacetsWithoutResolution(rt) + rt.detectFacetsWithoutResolution() return rt } }, [value]) diff --git a/src/components/dialogs/lists/CreateOrEditListDialog.tsx b/src/components/dialogs/lists/CreateOrEditListDialog.tsx index 95b2d4e2f..3b2342530 100644 --- a/src/components/dialogs/lists/CreateOrEditListDialog.tsx +++ b/src/components/dialogs/lists/CreateOrEditListDialog.tsx @@ -5,10 +5,6 @@ import {msg} from '@lingui/core/macro' import {useLingui} from '@lingui/react' import {Plural, Trans} from '@lingui/react/macro' -import { - detectFacets, - detectFacetsWithoutResolution, -} from '#/lib/strings/detect-facets' import {cleanError} from '#/lib/strings/errors' import {isOverMaxGraphemeCount} from '#/lib/strings/helpers' import {richTextToString} from '#/lib/strings/rich-text-helpers' @@ -173,7 +169,7 @@ function DialogInner({ const serialized = richTextToString(new RichTextAPI({text, facets}), false) const richText = new RichTextAPI({text: serialized}) - detectFacetsWithoutResolution(richText) + richText.detectFacetsWithoutResolution() return richText }) @@ -231,7 +227,7 @@ function DialogInner({ {cleanNewlines: true}, ) - await detectFacets(agent, richText) + await richText.detectFacets(agent) richText = shortenLinks(richText) richText = stripInvalidMentions(richText) @@ -359,7 +355,7 @@ function DialogInner({ const onChangeDescription = useCallback( (newText: string) => { const richText = new RichTextAPI({text: newText}) - detectFacetsWithoutResolution(richText) + richText.detectFacetsWithoutResolution() setDescriptionRt(richText) }, diff --git a/src/components/hooks/useRichText.ts b/src/components/hooks/useRichText.ts index d86dd6236..a1334bb63 100644 --- a/src/components/hooks/useRichText.ts +++ b/src/components/hooks/useRichText.ts @@ -1,7 +1,6 @@ import {useEffect, useState} from 'react' import {RichText as RichTextAPI} from '@atproto/api' -import {detectFacets} from '#/lib/strings/detect-facets' import {useAgent} from '#/state/session' export function useRichText(text: string): [RichTextAPI, boolean] { @@ -20,7 +19,7 @@ export function useRichText(text: string): [RichTextAPI, boolean] { async function resolveRTFacets() { // new each time const resolvedRT = new RichTextAPI({text}) - await detectFacets(agent, resolvedRT) + await resolvedRT.detectFacets(agent) if (!ignore) { setResolvedRT(resolvedRT) } diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index 2a99cff0b..5b010416a 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -46,7 +46,6 @@ import { } from '#/view/com/composer/state/composer' import {IS_IOS, IS_WEB} from '#/env' import {createGIFDescription} from '../gif-alt-text' -import {detectFacets} from '../strings/detect-facets' import {uploadBlob} from './upload-blob' export {uploadBlob} @@ -227,7 +226,7 @@ async function resolveRT(agent: BskyAgent, richtext: RichText) { parseMarkdownLinks(trimmedText) let rt = new RichText({text: parsedText}) - await detectFacets(agent, rt) + await rt.detectFacets(agent) if (markdownFacets.length > 0) { const nonOverlapping = (rt.facets || []).filter(f => { diff --git a/src/lib/strings/detect-facets.ts b/src/lib/strings/detect-facets.ts deleted file mode 100644 index b44700d96..000000000 --- a/src/lib/strings/detect-facets.ts +++ /dev/null @@ -1,125 +0,0 @@ -import {type BskyAgent, type Facet, type RichText} from '@atproto/api' -import {tokenize} from '@easrng/tr58' - -const TAG_CHARS = ['#', '#', '$'] - -export interface FacetRun { - text: string - features: Facet['features'] -} - -export function detectFacetRunsWithoutResolution(text: string): FacetRun[] { - const facetRuns: FacetRun[] = [] - - const tokens = tokenize(text, { - nonStandard: {domainHandle: true, tags: TAG_CHARS}, - }) - - for (const token of tokens) { - if (token.type === 'URL') { - const val = token.value - - // Handle mentions (@handle.com) - if (/^[@@]/.test(token.value)) { - facetRuns.push({ - text: val, - features: [ - { - $type: 'app.bsky.richtext.facet#mention', - did: val.slice(1) as any, - }, - ], - }) - } - // Handle tags (#tag or $cashtag) - else if (TAG_CHARS.some(char => val.startsWith(char))) { - const normalized = (val[0] === '$' ? val : val.slice(1)).normalize( - 'NFKC', - ) - facetRuns.push({ - text: val, - features: /^\$?\d/.test(normalized) - ? [] - : [ - { - $type: 'app.bsky.richtext.facet#tag', - tag: normalized, - }, - ], - }) - } else { - let uri = val - if (!/^[a-z][a-z0-9+.-]*:\/\//.test(uri)) { - uri = `https://${uri}` - } - const NON_EMAIL = /^[^@@]+?([?/#:]|$)/ - facetRuns.push({ - text: val, - // don't link email addresses - features: NON_EMAIL.test(token.value) - ? [ - { - $type: 'app.bsky.richtext.facet#link', - uri: uri, - }, - ] - : [], - }) - } - } else { - facetRuns.push({ - text: token.value, - features: [], - }) - } - } - return facetRuns -} - -export function detectFacetsWithoutResolution(rt: RichText) { - const facets: Facet[] = [] - - let currentByteOffset = 0 - - for (const run of detectFacetRunsWithoutResolution(rt.text)) { - const runBytes = new TextEncoder().encode(run.text) - const start = currentByteOffset - const end = start + runBytes.byteLength - - if (run.features.length) { - facets.push({ - index: {byteStart: start, byteEnd: end}, - features: run.features, - }) - } - - currentByteOffset = end - } - - rt.facets = facets - return rt -} - -export async function detectFacets(agent: BskyAgent, rt: RichText) { - detectFacetsWithoutResolution(rt) - if (rt.facets) { - for (const facet of rt.facets) { - for (const feature of facet.features) { - if ( - feature.$type === 'app.bsky.richtext.facet#mention' && - 'did' in feature && - !feature.did.startsWith('did:') - ) { - try { - const res = await agent.resolveHandle({handle: feature.did}) - feature.did = res.data.did - } catch (e) { - facet.features = facet.features.filter(f => f !== feature) - } - } - } - } - } - - return rt -} diff --git a/src/screens/Messages/components/MessageInputEmbed.tsx b/src/screens/Messages/components/MessageInputEmbed.tsx index 527d962e1..1244a5d9e 100644 --- a/src/screens/Messages/components/MessageInputEmbed.tsx +++ b/src/screens/Messages/components/MessageInputEmbed.tsx @@ -16,7 +16,6 @@ import { type CommonNavigatorParams, type NavigationProp, } from '#/lib/routes/types' -import {detectFacetsWithoutResolution} from '#/lib/strings/detect-facets' import { convertBskyAppUrlIfNeeded, isBskyPostUrl, @@ -77,7 +76,7 @@ export function useExtractEmbedFromFacets( setEmbed: (embedUrl: string | undefined) => void, ) { const rt = new RichTextAPI({text: message}) - detectFacetsWithoutResolution(rt) + rt.detectFacetsWithoutResolution() let uriFromFacet: string | undefined diff --git a/src/screens/Messages/components/MessagesList.tsx b/src/screens/Messages/components/MessagesList.tsx index 1ba233652..e8263e423 100644 --- a/src/screens/Messages/components/MessagesList.tsx +++ b/src/screens/Messages/components/MessagesList.tsx @@ -31,10 +31,6 @@ import {useScrollEdgeEffectRef} from '@bsky.app/expo-scroll-edge-effect' import {mergeRefs} from '#/lib/merge-refs' import {ScrollProvider} from '#/lib/ScrollContext' -import { - detectFacets, - detectFacetsWithoutResolution, -} from '#/lib/strings/detect-facets' import {shortenLinks, stripInvalidMentions} from '#/lib/strings/rich-text-manip' import { convertBskyAppUrlIfNeeded, @@ -294,7 +290,7 @@ export function MessagesList({ // detect facets without resolution first - this is used to see if there's // any post links in the text that we can embed. We do this first because // we want to remove the post link from the text, re-trim, then detect facets - detectFacetsWithoutResolution(rt) + rt.detectFacetsWithoutResolution() let embed: $Typed | undefined @@ -348,7 +344,7 @@ export function MessagesList({ } } - await detectFacets(agent, rt) + await rt.detectFacets(agent) rt = shortenLinks(rt) rt = stripInvalidMentions(rt) diff --git a/src/state/queries/starter-packs.ts b/src/state/queries/starter-packs.ts index 0dbf965a9..65c9b3c34 100644 --- a/src/state/queries/starter-packs.ts +++ b/src/state/queries/starter-packs.ts @@ -18,7 +18,6 @@ import chunk from 'lodash.chunk' import {until} from '#/lib/async/until' import {createStarterPackList} from '#/lib/generate-starterpack' -import {detectFacets} from '#/lib/strings/detect-facets' import { createStarterPackUri, httpStarterPackUriToAtUri, @@ -117,7 +116,7 @@ export function useCreateStarterPackMutation({ let descriptionFacets: AppBskyRichtextFacet.Main[] | undefined if (description) { const rt = new RichText({text: description}) - await detectFacets(agent, rt) + await rt.detectFacets(agent) descriptionFacets = rt.facets } @@ -189,7 +188,7 @@ export function useEditStarterPackMutation({ let descriptionFacets: AppBskyRichtextFacet.Main[] | undefined if (description) { const rt = new RichText({text: description}) - await detectFacets(agent, rt) + await rt.detectFacets(agent) descriptionFacets = rt.facets } diff --git a/src/view/com/composer/drafts/state/api.ts b/src/view/com/composer/drafts/state/api.ts index 60b03f0b5..73c4f607d 100644 --- a/src/view/com/composer/drafts/state/api.ts +++ b/src/view/com/composer/drafts/state/api.ts @@ -8,7 +8,6 @@ import {resolveLink} from '#/lib/api/resolve' import {getDeviceName} from '#/lib/deviceName' import {getImageDim} from '#/lib/media/manip' import {mimeToExt} from '#/lib/media/video/util' -import {detectFacetsWithoutResolution} from '#/lib/strings/detect-facets' import {shortenLinks} from '#/lib/strings/rich-text-manip' import {type ComposerImage} from '#/state/gallery' import {type Gif} from '#/state/queries/tenor' @@ -424,7 +423,7 @@ export async function draftToComposerPosts( const posts = await Promise.all( draft.posts.map(async (post, index) => { const richtext = new RichText({text: post.text || ''}) - detectFacetsWithoutResolution(richtext) + richtext.detectFacetsWithoutResolution() const embed: EmbedDraft = { quote: undefined, @@ -471,7 +470,7 @@ export async function draftToComposerPosts( height, mime: 'image/jpeg', }, - } as ComposerImage + } }) const images = (await Promise.all(imagePromises)).filter( @@ -512,7 +511,7 @@ export async function draftToComposerPosts( tinygif: mediaObject, preview: mediaObject, }, - } as Gif, + }, alt: gifData.alt, } break diff --git a/src/view/com/composer/state/composer.ts b/src/view/com/composer/state/composer.ts index 3b8446853..a621e8a96 100644 --- a/src/view/com/composer/state/composer.ts +++ b/src/view/com/composer/state/composer.ts @@ -9,7 +9,6 @@ import { import {nanoid} from 'nanoid/non-secure' import {type SelfLabel} from '#/lib/moderation' -import {detectFacetsWithoutResolution} from '#/lib/strings/detect-facets' import {insertMentionAt} from '#/lib/strings/mention-manip' import {parseMarkdownLinks, shortenLinks} from '#/lib/strings/rich-text-manip' import { @@ -686,7 +685,7 @@ export function createComposerState({ * we suggest at most 1 of each. */ if (initText) { - detectFacetsWithoutResolution(initRichText) + initRichText.detectFacetsWithoutResolution() const detectedExtUris = new Map() const detectedPostUris = new Map() if (initRichText.facets) { @@ -735,7 +734,7 @@ export function createComposerState({ } } else if (initMention) { // highlight the mention - detectFacetsWithoutResolution(initRichText) + initRichText.detectFacetsWithoutResolution() } return { @@ -773,7 +772,7 @@ export function createComposerState({ function getShortenedLength(rt: RichText) { const {text} = parseMarkdownLinks(rt.text) const newRt = new RichText({text}) - detectFacetsWithoutResolution(newRt) + newRt.detectFacetsWithoutResolution() return shortenLinks(newRt).graphemeLength } @@ -786,7 +785,7 @@ function createPostDraftFromText( }, ): PostDraft { const richtext = new RichText({text}) - detectFacetsWithoutResolution(richtext) + richtext.detectFacetsWithoutResolution() return { id: overrides?.id ?? nanoid(), diff --git a/src/view/com/composer/text-input/TextInput.tsx b/src/view/com/composer/text-input/TextInput.tsx index b28d34c4d..bde00604a 100644 --- a/src/view/com/composer/text-input/TextInput.tsx +++ b/src/view/com/composer/text-input/TextInput.tsx @@ -19,7 +19,6 @@ import {useLingui} from '@lingui/react/macro' import {POST_IMG_MAX} from '#/lib/constants' import {downloadAndResize} from '#/lib/media/manip' import {isUriImage} from '#/lib/media/util' -import {detectFacetsWithoutResolution} from '#/lib/strings/detect-facets' import {getMentionAt, insertMentionAt} from '#/lib/strings/mention-manip' import {useTheme} from '#/lib/ThemeContext' import { @@ -111,7 +110,7 @@ export function TextInput({ } const newRt = new RichText({text: newText}) - detectFacetsWithoutResolution(newRt) + newRt.detectFacetsWithoutResolution() const markdownFacets: AppBskyRichtextFacet.Main[] = [] const regex = /\[([^\]]+)\]\s*\(([^)]+)\)/g diff --git a/src/view/com/composer/text-input/TextInput.web.tsx b/src/view/com/composer/text-input/TextInput.web.tsx index 0313108e3..e9ac4cd6c 100644 --- a/src/view/com/composer/text-input/TextInput.web.tsx +++ b/src/view/com/composer/text-input/TextInput.web.tsx @@ -24,7 +24,6 @@ import {splitGraphemes} from 'unicode-segmenter/grapheme' import {useColorSchemeStyle} from '#/lib/hooks/useColorSchemeStyle' import {blobToDataUri, isUriImage} from '#/lib/media/util' -import {detectFacetsWithoutResolution} from '#/lib/strings/detect-facets' import {useActorAutocompleteFn} from '#/state/queries/actor-autocomplete' import { type LinkFacetMatch, @@ -291,7 +290,7 @@ export function TextInput({ const isPaste = window.event?.type === 'paste' const newRt = new RichText({text: newText}) - detectFacetsWithoutResolution(newRt) + newRt.detectFacetsWithoutResolution() const markdownFacets: AppBskyRichtextFacet.Main[] = [] const regex = /\[([^\]]+)\]\s*\(([^)]+)\)/g diff --git a/src/view/com/composer/text-input/web/LinkDecorator.ts b/src/view/com/composer/text-input/web/LinkDecorator.ts index 0a0fde66d..57c2bc13d 100644 --- a/src/view/com/composer/text-input/web/LinkDecorator.ts +++ b/src/view/com/composer/text-input/web/LinkDecorator.ts @@ -14,11 +14,14 @@ * the facet-set. */ +import {URL_REGEX} from '@atproto/api' import {Mark} from '@tiptap/core' import {type Node as ProsemirrorNode} from '@tiptap/pm/model' import {Plugin, PluginKey} from '@tiptap/pm/state' import {Decoration, DecorationSet} from '@tiptap/pm/view' +import {isValidDomain} from '#/lib/strings/url-helpers' + export const LinkDecorator = Mark.create({ name: 'link-decorator', priority: 1000, @@ -50,6 +53,15 @@ function getDecorations(doc: ProsemirrorNode) { }), ) } + + // regular links + iterateUris(textContent, (from, to) => { + decorations.push( + Decoration.inline(pos + from, pos + to, { + class: 'autolink', + }), + ) + }) } }) @@ -78,3 +90,30 @@ function linkDecorator() { }) return linkDecoratorPlugin } + +function iterateUris(str: string, cb: (from: number, to: number) => void) { + let match + const re = URL_REGEX + while ((match = re.exec(str))) { + let uri = match[2] + if (!uri.startsWith('http')) { + const domain = match.groups?.domain + if (!domain || !isValidDomain(domain)) { + continue + } + uri = `https://${uri}` + } + let from = str.indexOf(match[2], match.index) + let to = from + match[2].length + // strip ending puncuation + if (/[.,;!?]$/.test(uri)) { + uri = uri.slice(0, -1) + to-- + } + if (/[)]$/.test(uri) && !uri.includes('(')) { + uri = uri.slice(0, -1) + to-- + } + cb(from, to) + } +} diff --git a/src/view/com/composer/text-input/web/TagDecorator.ts b/src/view/com/composer/text-input/web/TagDecorator.ts index 7a9ac18f5..8f1142b86 100644 --- a/src/view/com/composer/text-input/web/TagDecorator.ts +++ b/src/view/com/composer/text-input/web/TagDecorator.ts @@ -1,34 +1,82 @@ +/** + * TipTap is a stateful rich-text editor, which is extremely useful + * when you _want_ it to be stateful formatting such as bold and italics. + * + * However we also use "stateless" behaviors, specifically for URLs + * where the text itself drives the formatting. + * + * This plugin uses a regex to detect URIs and then applies + * link decorations (a with the "autolink") class. That avoids + * adding any stateful formatting to TipTap's document model. + * + * We then run the URI detection again when constructing the + * RichText object from TipTap's output and merge their features into + * the facet-set. + */ + +import { + CASHTAG_REGEX, + TAG_REGEX, + TRAILING_PUNCTUATION_REGEX, +} from '@atproto/api' import {Mark} from '@tiptap/core' import {type Node as ProsemirrorNode} from '@tiptap/pm/model' import {Plugin, PluginKey} from '@tiptap/pm/state' import {Decoration, DecorationSet} from '@tiptap/pm/view' -import {detectFacetRunsWithoutResolution} from '#/lib/strings/detect-facets' - function getDecorations(doc: ProsemirrorNode) { const decorations: Decoration[] = [] doc.descendants((node, pos) => { if (node.isText && node.text) { + const regex = TAG_REGEX const textContent = node.textContent - const facetRuns = detectFacetRunsWithoutResolution(textContent) - let currentOffset = 0 - for (const run of facetRuns) { - const runLength = run.text.length + // Detect hashtags + let match + while ((match = regex.exec(textContent))) { + const [matchedString, __, tag] = match + + if (!tag || tag.replace(TRAILING_PUNCTUATION_REGEX, '').length > 64) + continue + + const [trailingPunc = ''] = tag.match(TRAILING_PUNCTUATION_REGEX) || [] + const matchedFrom = match.index + matchedString.indexOf(tag) + const matchedTo = matchedFrom + (tag.length - trailingPunc.length) + + /* + * The match is exclusive of `#` so we need to adjust the start of the + * highlight by -1 to include the `#` + */ + const start = pos + matchedFrom - 1 + const end = pos + matchedTo + + decorations.push( + Decoration.inline(start, end, { + class: 'autolink', + }), + ) + } + + // Detect cashtags + const cashtagRegex = new RegExp(CASHTAG_REGEX.source, 'gu') + while ((match = cashtagRegex.exec(textContent))) { + const [_fullMatch, leading, ticker] = match + + if (!ticker) continue - if (run.features?.length) { - const start = pos + currentOffset - const end = start + runLength + // Calculate positions: leading char + $ + ticker + const matchedFrom = match.index + leading.length + const matchedTo = matchedFrom + 1 + ticker.length // +1 for $ - decorations.push( - Decoration.inline(start, end, { - class: 'autolink', - }), - ) - } + const start = pos + matchedFrom + const end = pos + matchedTo - currentOffset += runLength + decorations.push( + Decoration.inline(start, end, { + class: 'autolink', + }), + ) } } }) diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx index f87170bd4..c1ec149a0 100644 --- a/src/view/screens/Profile.tsx +++ b/src/view/screens/Profile.tsx @@ -22,7 +22,6 @@ import { type NativeStackScreenProps, type NavigationProp, } from '#/lib/routes/types' -import {detectFacets} from '#/lib/strings/detect-facets' import {combinedDisplayName} from '#/lib/strings/display-names' import {cleanError} from '#/lib/strings/errors' import {isInvalidHandle} from '#/lib/strings/handles' @@ -614,7 +613,7 @@ function useRichText(text: string): [RichTextAPI, boolean] { async function resolveRTFacets() { // new each time const resolvedRT = new RichTextAPI({text}) - await detectFacets(agent, resolvedRT) + await resolvedRT.detectFacets(agent) if (!ignore) { setResolvedRT(resolvedRT) }