Bluesky app fork with some witchin' additions 💫 witchsky.app
bluesky fork client
117
fork

Configure Feed

Select the types of activity you want to include in your feed.

revert "feat: nicer facet parsing"

This reverts commit 32abb8b7da85f9ccb02ad9081032eb4a6f55093b.

xan.lol 8e184eb0 3c06522a

+127 -195
+1 -2
__tests__/lib/string.test.ts
··· 1 1 import {RichText} from '@atproto/api' 2 2 import {i18n} from '@lingui/core' 3 3 4 - import {detectFacetsWithoutResolution} from '#/lib/strings/detect-facets' 5 4 import {parseEmbedPlayerFromUrl} from '#/lib/strings/embed-player' 6 5 import { 7 6 createStarterPackGooglePlayUri, ··· 329 328 for (let i = 0; i < inputs.length; i++) { 330 329 const input = inputs[i] 331 330 const inputRT = new RichText({text: input}) 332 - detectFacetsWithoutResolution(inputRT) 331 + inputRT.detectFacetsWithoutResolution() 333 332 const outputRT = shortenLinks(inputRT) 334 333 expect(outputRT.text).toEqual(outputs[i][0]) 335 334 expect(outputRT.facets?.length).toEqual(outputs[i][1].length)
-1
package.json
··· 103 103 "@bsky.app/tapper": "^0.5.1", 104 104 "@bsky.app/video": "0.3.4", 105 105 "@discord/bottom-sheet": "github:bluesky-social/react-native-bottom-sheet", 106 - "@easrng/tr58": "0.0.5", 107 106 "@emoji-mart/data": "^1.2.1", 108 107 "@emoji-mart/react": "^1.1.1", 109 108 "@expo/html-elements": "^0.12.5",
-8
pnpm-lock.yaml
··· 156 156 '@discord/bottom-sheet': 157 157 specifier: github:bluesky-social/react-native-bottom-sheet 158 158 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) 159 - '@easrng/tr58': 160 - specifier: 0.0.5 161 - version: 0.0.5 162 159 '@emoji-mart/data': 163 160 specifier: ^1.2.1 164 161 version: 1.2.1 ··· 2297 2294 '@discoveryjs/json-ext@0.5.7': 2298 2295 resolution: {integrity: sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==} 2299 2296 engines: {node: '>=10.0.0'} 2300 - 2301 - '@easrng/tr58@0.0.5': 2302 - resolution: {integrity: sha512-LJEAySm4hz77r+9Y2ULDQfTXYL3pp5QxedAITlHwkXBeivhQSLoVEwZHNkU9cByjfaldANZuHhqGKCivoLVeJg==} 2303 2297 2304 2298 '@egjs/hammerjs@2.0.17': 2305 2299 resolution: {integrity: sha512-XQsZgjm2EcVUiZQf11UBJQfmZeEmOW8DpI1gsFeln6w0ae0ii4dMQEQ0kjl6DspdWX1aGY1/loyXnP0JS06e/A==} ··· 15325 15319 '@types/react': 19.2.14 15326 15320 15327 15321 '@discoveryjs/json-ext@0.5.7': {} 15328 - 15329 - '@easrng/tr58@0.0.5': {} 15330 15322 15331 15323 '@egjs/hammerjs@2.0.17': 15332 15324 dependencies:
+1 -2
src/components/FeedCard.tsx
··· 9 9 import {Plural, Trans, useLingui} from '@lingui/react/macro' 10 10 import {useQueryClient} from '@tanstack/react-query' 11 11 12 - import {detectFacetsWithoutResolution} from '#/lib/strings/detect-facets' 13 12 import {sanitizeHandle} from '#/lib/strings/handles' 14 13 import {logger} from '#/logger' 15 14 import {precacheFeedFromGeneratorView} from '#/state/queries/feed' ··· 214 213 const rt = useMemo(() => { 215 214 if (!description) return 216 215 const rt = new RichTextApi({text: description || ''}) 217 - detectFacetsWithoutResolution(rt) 216 + rt.detectFacetsWithoutResolution() 218 217 return rt 219 218 }, [description]) 220 219 if (!rt) return null
+1 -2
src/components/ProfileCard.tsx
··· 17 17 import {makeProfileLink} from '#/lib/routes/links' 18 18 import {forceLTR} from '#/lib/strings/bidi' 19 19 import {NON_BREAKING_SPACE} from '#/lib/strings/constants' 20 - import {detectFacetsWithoutResolution} from '#/lib/strings/detect-facets' 21 20 import {sanitizeDisplayName} from '#/lib/strings/display-names' 22 21 import {sanitizeHandle} from '#/lib/strings/handles' 23 22 import {useProfileShadow} from '#/state/cache/profile-shadow' ··· 403 402 const rt = useMemo(() => { 404 403 if (!('description' in profile)) return 405 404 const rt = new RichTextApi({text: profile.description || ''}) 406 - detectFacetsWithoutResolution(rt) 405 + rt.detectFacetsWithoutResolution() 407 406 return rt 408 407 }, [profile]) 409 408 if (!rt) return null
+1 -2
src/components/RichText.tsx
··· 2 2 import {type StyleProp, type TextStyle} from 'react-native' 3 3 import {AppBskyRichtextFacet, RichText as RichTextAPI} from '@atproto/api' 4 4 5 - import {detectFacetsWithoutResolution} from '#/lib/strings/detect-facets' 6 5 import {toShortUrl} from '#/lib/strings/url-helpers' 7 6 import {atoms as a, flatten, type TextStyleProp} from '#/alf' 8 7 import {isOnlyEmoji} from '#/alf/typography' ··· 62 61 return value 63 62 } else { 64 63 const rt = new RichTextAPI({text: value}) 65 - detectFacetsWithoutResolution(rt) 64 + rt.detectFacetsWithoutResolution() 66 65 return rt 67 66 } 68 67 }, [value])
+3 -7
src/components/dialogs/lists/CreateOrEditListDialog.tsx
··· 5 5 import {useLingui} from '@lingui/react' 6 6 import {Plural, Trans} from '@lingui/react/macro' 7 7 8 - import { 9 - detectFacets, 10 - detectFacetsWithoutResolution, 11 - } from '#/lib/strings/detect-facets' 12 8 import {cleanError} from '#/lib/strings/errors' 13 9 import {isOverMaxGraphemeCount} from '#/lib/strings/helpers' 14 10 import {richTextToString} from '#/lib/strings/rich-text-helpers' ··· 173 169 const serialized = richTextToString(new RichTextAPI({text, facets}), false) 174 170 175 171 const richText = new RichTextAPI({text: serialized}) 176 - detectFacetsWithoutResolution(richText) 172 + richText.detectFacetsWithoutResolution() 177 173 178 174 return richText 179 175 }) ··· 231 227 {cleanNewlines: true}, 232 228 ) 233 229 234 - await detectFacets(agent, richText) 230 + await richText.detectFacets(agent) 235 231 richText = shortenLinks(richText) 236 232 richText = stripInvalidMentions(richText) 237 233 ··· 359 355 const onChangeDescription = useCallback( 360 356 (newText: string) => { 361 357 const richText = new RichTextAPI({text: newText}) 362 - detectFacetsWithoutResolution(richText) 358 + richText.detectFacetsWithoutResolution() 363 359 364 360 setDescriptionRt(richText) 365 361 },
+1 -2
src/components/hooks/useRichText.ts
··· 1 1 import {useEffect, useState} from 'react' 2 2 import {RichText as RichTextAPI} from '@atproto/api' 3 3 4 - import {detectFacets} from '#/lib/strings/detect-facets' 5 4 import {useAgent} from '#/state/session' 6 5 7 6 export function useRichText(text: string): [RichTextAPI, boolean] { ··· 20 19 async function resolveRTFacets() { 21 20 // new each time 22 21 const resolvedRT = new RichTextAPI({text}) 23 - await detectFacets(agent, resolvedRT) 22 + await resolvedRT.detectFacets(agent) 24 23 if (!ignore) { 25 24 setResolvedRT(resolvedRT) 26 25 }
+1 -2
src/lib/api/index.ts
··· 46 46 } from '#/view/com/composer/state/composer' 47 47 import {IS_IOS, IS_WEB} from '#/env' 48 48 import {createGIFDescription} from '../gif-alt-text' 49 - import {detectFacets} from '../strings/detect-facets' 50 49 import {uploadBlob} from './upload-blob' 51 50 52 51 export {uploadBlob} ··· 227 226 parseMarkdownLinks(trimmedText) 228 227 229 228 let rt = new RichText({text: parsedText}) 230 - await detectFacets(agent, rt) 229 + await rt.detectFacets(agent) 231 230 232 231 if (markdownFacets.length > 0) { 233 232 const nonOverlapping = (rt.facets || []).filter(f => {
-125
src/lib/strings/detect-facets.ts
··· 1 - import {type BskyAgent, type Facet, type RichText} from '@atproto/api' 2 - import {tokenize} from '@easrng/tr58' 3 - 4 - const TAG_CHARS = ['#', '#', '$'] 5 - 6 - export interface FacetRun { 7 - text: string 8 - features: Facet['features'] 9 - } 10 - 11 - export function detectFacetRunsWithoutResolution(text: string): FacetRun[] { 12 - const facetRuns: FacetRun[] = [] 13 - 14 - const tokens = tokenize(text, { 15 - nonStandard: {domainHandle: true, tags: TAG_CHARS}, 16 - }) 17 - 18 - for (const token of tokens) { 19 - if (token.type === 'URL') { 20 - const val = token.value 21 - 22 - // Handle mentions (@handle.com) 23 - if (/^[@@]/.test(token.value)) { 24 - facetRuns.push({ 25 - text: val, 26 - features: [ 27 - { 28 - $type: 'app.bsky.richtext.facet#mention', 29 - did: val.slice(1) as any, 30 - }, 31 - ], 32 - }) 33 - } 34 - // Handle tags (#tag or $cashtag) 35 - else if (TAG_CHARS.some(char => val.startsWith(char))) { 36 - const normalized = (val[0] === '$' ? val : val.slice(1)).normalize( 37 - 'NFKC', 38 - ) 39 - facetRuns.push({ 40 - text: val, 41 - features: /^\$?\d/.test(normalized) 42 - ? [] 43 - : [ 44 - { 45 - $type: 'app.bsky.richtext.facet#tag', 46 - tag: normalized, 47 - }, 48 - ], 49 - }) 50 - } else { 51 - let uri = val 52 - if (!/^[a-z][a-z0-9+.-]*:\/\//.test(uri)) { 53 - uri = `https://${uri}` 54 - } 55 - const NON_EMAIL = /^[^@@]+?([?/#:]|$)/ 56 - facetRuns.push({ 57 - text: val, 58 - // don't link email addresses 59 - features: NON_EMAIL.test(token.value) 60 - ? [ 61 - { 62 - $type: 'app.bsky.richtext.facet#link', 63 - uri: uri, 64 - }, 65 - ] 66 - : [], 67 - }) 68 - } 69 - } else { 70 - facetRuns.push({ 71 - text: token.value, 72 - features: [], 73 - }) 74 - } 75 - } 76 - return facetRuns 77 - } 78 - 79 - export function detectFacetsWithoutResolution(rt: RichText) { 80 - const facets: Facet[] = [] 81 - 82 - let currentByteOffset = 0 83 - 84 - for (const run of detectFacetRunsWithoutResolution(rt.text)) { 85 - const runBytes = new TextEncoder().encode(run.text) 86 - const start = currentByteOffset 87 - const end = start + runBytes.byteLength 88 - 89 - if (run.features.length) { 90 - facets.push({ 91 - index: {byteStart: start, byteEnd: end}, 92 - features: run.features, 93 - }) 94 - } 95 - 96 - currentByteOffset = end 97 - } 98 - 99 - rt.facets = facets 100 - return rt 101 - } 102 - 103 - export async function detectFacets(agent: BskyAgent, rt: RichText) { 104 - detectFacetsWithoutResolution(rt) 105 - if (rt.facets) { 106 - for (const facet of rt.facets) { 107 - for (const feature of facet.features) { 108 - if ( 109 - feature.$type === 'app.bsky.richtext.facet#mention' && 110 - 'did' in feature && 111 - !feature.did.startsWith('did:') 112 - ) { 113 - try { 114 - const res = await agent.resolveHandle({handle: feature.did}) 115 - feature.did = res.data.did 116 - } catch (e) { 117 - facet.features = facet.features.filter(f => f !== feature) 118 - } 119 - } 120 - } 121 - } 122 - } 123 - 124 - return rt 125 - }
+1 -2
src/screens/Messages/components/MessageInputEmbed.tsx
··· 16 16 type CommonNavigatorParams, 17 17 type NavigationProp, 18 18 } from '#/lib/routes/types' 19 - import {detectFacetsWithoutResolution} from '#/lib/strings/detect-facets' 20 19 import { 21 20 convertBskyAppUrlIfNeeded, 22 21 isBskyPostUrl, ··· 77 76 setEmbed: (embedUrl: string | undefined) => void, 78 77 ) { 79 78 const rt = new RichTextAPI({text: message}) 80 - detectFacetsWithoutResolution(rt) 79 + rt.detectFacetsWithoutResolution() 81 80 82 81 let uriFromFacet: string | undefined 83 82
+2 -6
src/screens/Messages/components/MessagesList.tsx
··· 31 31 32 32 import {mergeRefs} from '#/lib/merge-refs' 33 33 import {ScrollProvider} from '#/lib/ScrollContext' 34 - import { 35 - detectFacets, 36 - detectFacetsWithoutResolution, 37 - } from '#/lib/strings/detect-facets' 38 34 import {shortenLinks, stripInvalidMentions} from '#/lib/strings/rich-text-manip' 39 35 import { 40 36 convertBskyAppUrlIfNeeded, ··· 294 290 // detect facets without resolution first - this is used to see if there's 295 291 // any post links in the text that we can embed. We do this first because 296 292 // we want to remove the post link from the text, re-trim, then detect facets 297 - detectFacetsWithoutResolution(rt) 293 + rt.detectFacetsWithoutResolution() 298 294 299 295 let embed: $Typed<AppBskyEmbedRecord.Main> | undefined 300 296 ··· 348 344 } 349 345 } 350 346 351 - await detectFacets(agent, rt) 347 + await rt.detectFacets(agent) 352 348 353 349 rt = shortenLinks(rt) 354 350 rt = stripInvalidMentions(rt)
+2 -3
src/state/queries/starter-packs.ts
··· 18 18 19 19 import {until} from '#/lib/async/until' 20 20 import {createStarterPackList} from '#/lib/generate-starterpack' 21 - import {detectFacets} from '#/lib/strings/detect-facets' 22 21 import { 23 22 createStarterPackUri, 24 23 httpStarterPackUriToAtUri, ··· 117 116 let descriptionFacets: AppBskyRichtextFacet.Main[] | undefined 118 117 if (description) { 119 118 const rt = new RichText({text: description}) 120 - await detectFacets(agent, rt) 119 + await rt.detectFacets(agent) 121 120 descriptionFacets = rt.facets 122 121 } 123 122 ··· 189 188 let descriptionFacets: AppBskyRichtextFacet.Main[] | undefined 190 189 if (description) { 191 190 const rt = new RichText({text: description}) 192 - await detectFacets(agent, rt) 191 + await rt.detectFacets(agent) 193 192 descriptionFacets = rt.facets 194 193 } 195 194
+3 -4
src/view/com/composer/drafts/state/api.ts
··· 8 8 import {getDeviceName} from '#/lib/deviceName' 9 9 import {getImageDim} from '#/lib/media/manip' 10 10 import {mimeToExt} from '#/lib/media/video/util' 11 - import {detectFacetsWithoutResolution} from '#/lib/strings/detect-facets' 12 11 import {shortenLinks} from '#/lib/strings/rich-text-manip' 13 12 import {type ComposerImage} from '#/state/gallery' 14 13 import {type Gif} from '#/state/queries/tenor' ··· 424 423 const posts = await Promise.all( 425 424 draft.posts.map(async (post, index) => { 426 425 const richtext = new RichText({text: post.text || ''}) 427 - detectFacetsWithoutResolution(richtext) 426 + richtext.detectFacetsWithoutResolution() 428 427 429 428 const embed: EmbedDraft = { 430 429 quote: undefined, ··· 471 470 height, 472 471 mime: 'image/jpeg', 473 472 }, 474 - } as ComposerImage 473 + } 475 474 }) 476 475 477 476 const images = (await Promise.all(imagePromises)).filter( ··· 512 511 tinygif: mediaObject, 513 512 preview: mediaObject, 514 513 }, 515 - } as Gif, 514 + }, 516 515 alt: gifData.alt, 517 516 } 518 517 break
+4 -5
src/view/com/composer/state/composer.ts
··· 9 9 import {nanoid} from 'nanoid/non-secure' 10 10 11 11 import {type SelfLabel} from '#/lib/moderation' 12 - import {detectFacetsWithoutResolution} from '#/lib/strings/detect-facets' 13 12 import {insertMentionAt} from '#/lib/strings/mention-manip' 14 13 import {parseMarkdownLinks, shortenLinks} from '#/lib/strings/rich-text-manip' 15 14 import { ··· 686 685 * we suggest at most 1 of each. 687 686 */ 688 687 if (initText) { 689 - detectFacetsWithoutResolution(initRichText) 688 + initRichText.detectFacetsWithoutResolution() 690 689 const detectedExtUris = new Map<string, LinkFacetMatch>() 691 690 const detectedPostUris = new Map<string, LinkFacetMatch>() 692 691 if (initRichText.facets) { ··· 735 734 } 736 735 } else if (initMention) { 737 736 // highlight the mention 738 - detectFacetsWithoutResolution(initRichText) 737 + initRichText.detectFacetsWithoutResolution() 739 738 } 740 739 741 740 return { ··· 773 772 function getShortenedLength(rt: RichText) { 774 773 const {text} = parseMarkdownLinks(rt.text) 775 774 const newRt = new RichText({text}) 776 - detectFacetsWithoutResolution(newRt) 775 + newRt.detectFacetsWithoutResolution() 777 776 return shortenLinks(newRt).graphemeLength 778 777 } 779 778 ··· 786 785 }, 787 786 ): PostDraft { 788 787 const richtext = new RichText({text}) 789 - detectFacetsWithoutResolution(richtext) 788 + richtext.detectFacetsWithoutResolution() 790 789 791 790 return { 792 791 id: overrides?.id ?? nanoid(),
+1 -2
src/view/com/composer/text-input/TextInput.tsx
··· 19 19 import {POST_IMG_MAX} from '#/lib/constants' 20 20 import {downloadAndResize} from '#/lib/media/manip' 21 21 import {isUriImage} from '#/lib/media/util' 22 - import {detectFacetsWithoutResolution} from '#/lib/strings/detect-facets' 23 22 import {getMentionAt, insertMentionAt} from '#/lib/strings/mention-manip' 24 23 import {useTheme} from '#/lib/ThemeContext' 25 24 import { ··· 111 110 } 112 111 113 112 const newRt = new RichText({text: newText}) 114 - detectFacetsWithoutResolution(newRt) 113 + newRt.detectFacetsWithoutResolution() 115 114 116 115 const markdownFacets: AppBskyRichtextFacet.Main[] = [] 117 116 const regex = /\[([^\]]+)\]\s*\(([^)]+)\)/g
+1 -2
src/view/com/composer/text-input/TextInput.web.tsx
··· 24 24 25 25 import {useColorSchemeStyle} from '#/lib/hooks/useColorSchemeStyle' 26 26 import {blobToDataUri, isUriImage} from '#/lib/media/util' 27 - import {detectFacetsWithoutResolution} from '#/lib/strings/detect-facets' 28 27 import {useActorAutocompleteFn} from '#/state/queries/actor-autocomplete' 29 28 import { 30 29 type LinkFacetMatch, ··· 291 290 const isPaste = window.event?.type === 'paste' 292 291 293 292 const newRt = new RichText({text: newText}) 294 - detectFacetsWithoutResolution(newRt) 293 + newRt.detectFacetsWithoutResolution() 295 294 296 295 const markdownFacets: AppBskyRichtextFacet.Main[] = [] 297 296 const regex = /\[([^\]]+)\]\s*\(([^)]+)\)/g
+39
src/view/com/composer/text-input/web/LinkDecorator.ts
··· 14 14 * the facet-set. 15 15 */ 16 16 17 + import {URL_REGEX} from '@atproto/api' 17 18 import {Mark} from '@tiptap/core' 18 19 import {type Node as ProsemirrorNode} from '@tiptap/pm/model' 19 20 import {Plugin, PluginKey} from '@tiptap/pm/state' 20 21 import {Decoration, DecorationSet} from '@tiptap/pm/view' 22 + 23 + import {isValidDomain} from '#/lib/strings/url-helpers' 21 24 22 25 export const LinkDecorator = Mark.create({ 23 26 name: 'link-decorator', ··· 50 53 }), 51 54 ) 52 55 } 56 + 57 + // regular links 58 + iterateUris(textContent, (from, to) => { 59 + decorations.push( 60 + Decoration.inline(pos + from, pos + to, { 61 + class: 'autolink', 62 + }), 63 + ) 64 + }) 53 65 } 54 66 }) 55 67 ··· 78 90 }) 79 91 return linkDecoratorPlugin 80 92 } 93 + 94 + function iterateUris(str: string, cb: (from: number, to: number) => void) { 95 + let match 96 + const re = URL_REGEX 97 + while ((match = re.exec(str))) { 98 + let uri = match[2] 99 + if (!uri.startsWith('http')) { 100 + const domain = match.groups?.domain 101 + if (!domain || !isValidDomain(domain)) { 102 + continue 103 + } 104 + uri = `https://${uri}` 105 + } 106 + let from = str.indexOf(match[2], match.index) 107 + let to = from + match[2].length 108 + // strip ending puncuation 109 + if (/[.,;!?]$/.test(uri)) { 110 + uri = uri.slice(0, -1) 111 + to-- 112 + } 113 + if (/[)]$/.test(uri) && !uri.includes('(')) { 114 + uri = uri.slice(0, -1) 115 + to-- 116 + } 117 + cb(from, to) 118 + } 119 + }
+64 -16
src/view/com/composer/text-input/web/TagDecorator.ts
··· 1 + /** 2 + * TipTap is a stateful rich-text editor, which is extremely useful 3 + * when you _want_ it to be stateful formatting such as bold and italics. 4 + * 5 + * However we also use "stateless" behaviors, specifically for URLs 6 + * where the text itself drives the formatting. 7 + * 8 + * This plugin uses a regex to detect URIs and then applies 9 + * link decorations (a <span> with the "autolink") class. That avoids 10 + * adding any stateful formatting to TipTap's document model. 11 + * 12 + * We then run the URI detection again when constructing the 13 + * RichText object from TipTap's output and merge their features into 14 + * the facet-set. 15 + */ 16 + 17 + import { 18 + CASHTAG_REGEX, 19 + TAG_REGEX, 20 + TRAILING_PUNCTUATION_REGEX, 21 + } from '@atproto/api' 1 22 import {Mark} from '@tiptap/core' 2 23 import {type Node as ProsemirrorNode} from '@tiptap/pm/model' 3 24 import {Plugin, PluginKey} from '@tiptap/pm/state' 4 25 import {Decoration, DecorationSet} from '@tiptap/pm/view' 5 - 6 - import {detectFacetRunsWithoutResolution} from '#/lib/strings/detect-facets' 7 26 8 27 function getDecorations(doc: ProsemirrorNode) { 9 28 const decorations: Decoration[] = [] 10 29 11 30 doc.descendants((node, pos) => { 12 31 if (node.isText && node.text) { 32 + const regex = TAG_REGEX 13 33 const textContent = node.textContent 14 - const facetRuns = detectFacetRunsWithoutResolution(textContent) 15 34 16 - let currentOffset = 0 17 - for (const run of facetRuns) { 18 - const runLength = run.text.length 35 + // Detect hashtags 36 + let match 37 + while ((match = regex.exec(textContent))) { 38 + const [matchedString, __, tag] = match 19 39 20 - if (run.features?.length) { 21 - const start = pos + currentOffset 22 - const end = start + runLength 40 + if (!tag || tag.replace(TRAILING_PUNCTUATION_REGEX, '').length > 64) 41 + continue 23 42 24 - decorations.push( 25 - Decoration.inline(start, end, { 26 - class: 'autolink', 27 - }), 28 - ) 29 - } 43 + const [trailingPunc = ''] = tag.match(TRAILING_PUNCTUATION_REGEX) || [] 44 + const matchedFrom = match.index + matchedString.indexOf(tag) 45 + const matchedTo = matchedFrom + (tag.length - trailingPunc.length) 30 46 31 - currentOffset += runLength 47 + /* 48 + * The match is exclusive of `#` so we need to adjust the start of the 49 + * highlight by -1 to include the `#` 50 + */ 51 + const start = pos + matchedFrom - 1 52 + const end = pos + matchedTo 53 + 54 + decorations.push( 55 + Decoration.inline(start, end, { 56 + class: 'autolink', 57 + }), 58 + ) 59 + } 60 + 61 + // Detect cashtags 62 + const cashtagRegex = new RegExp(CASHTAG_REGEX.source, 'gu') 63 + while ((match = cashtagRegex.exec(textContent))) { 64 + const [_fullMatch, leading, ticker] = match 65 + 66 + if (!ticker) continue 67 + 68 + // Calculate positions: leading char + $ + ticker 69 + const matchedFrom = match.index + leading.length 70 + const matchedTo = matchedFrom + 1 + ticker.length // +1 for $ 71 + 72 + const start = pos + matchedFrom 73 + const end = pos + matchedTo 74 + 75 + decorations.push( 76 + Decoration.inline(start, end, { 77 + class: 'autolink', 78 + }), 79 + ) 32 80 } 33 81 } 34 82 })
+1 -2
src/view/screens/Profile.tsx
··· 22 22 type NativeStackScreenProps, 23 23 type NavigationProp, 24 24 } from '#/lib/routes/types' 25 - import {detectFacets} from '#/lib/strings/detect-facets' 26 25 import {combinedDisplayName} from '#/lib/strings/display-names' 27 26 import {cleanError} from '#/lib/strings/errors' 28 27 import {isInvalidHandle} from '#/lib/strings/handles' ··· 614 613 async function resolveRTFacets() { 615 614 // new each time 616 615 const resolvedRT = new RichTextAPI({text}) 617 - await detectFacets(agent, resolvedRT) 616 + await resolvedRT.detectFacets(agent) 618 617 if (!ignore) { 619 618 setResolvedRT(resolvedRT) 620 619 }