fix: character limit for hyperlinks is reduced and full is highlighted #2

merged
opened by scanash.com targeting main
Changed files
+143 -27
src
view
com
+30 -24
src/view/com/composer/state/composer.ts
··· 9 10 import {type SelfLabel} from '#/lib/moderation' 11 import {insertMentionAt} from '#/lib/strings/mention-manip' 12 - import {shortenLinks} from '#/lib/strings/rich-text-manip' 13 import { 14 isBskyPostUrl, 15 postUriToRelativePath, ··· 78 | {type: 'embed_update_image'; image: ComposerImage} 79 | {type: 'embed_remove_image'; image: ComposerImage} 80 | { 81 - type: 'embed_add_video' 82 - asset: ImagePickerAsset 83 - abortController: AbortController 84 - } 85 | {type: 'embed_remove_video'} 86 | {type: 'embed_update_video'; videoAction: VideoAction} 87 | {type: 'embed_add_uri'; uri: string} ··· 107 | {type: 'update_postgate'; postgate: AppBskyFeedPostgate.Record} 108 | {type: 'update_threadgate'; threadgate: ThreadgateAllowUISetting[]} 109 | { 110 - type: 'update_post' 111 - postId: string 112 - postAction: PostAction 113 - } 114 | { 115 - type: 'add_post' 116 - } 117 | { 118 - type: 'remove_post' 119 - postId: string 120 - } 121 | { 122 - type: 'focus_post' 123 - postId: string 124 - } 125 126 export const MAX_IMAGES = 4 127 ··· 494 initImageUris: ComposerOpts['imageUris'] 495 initQuoteUri: string | undefined 496 initInteractionSettings: 497 - | BskyPreferences['postInteractionSettings'] 498 - | undefined 499 }): ComposerState { 500 let media: ImagesMedia | undefined 501 if (initImageUris?.length) { ··· 520 ? initText 521 : initMention 522 ? insertMentionAt( 523 - `@${initMention}`, 524 - initMention.length + 1, 525 - `${initMention}`, 526 - ) 527 : '', 528 }) 529 ··· 620 } 621 622 function getShortenedLength(rt: RichText) { 623 - return shortenLinks(rt).graphemeLength 624 }
··· 9 10 import {type SelfLabel} from '#/lib/moderation' 11 import {insertMentionAt} from '#/lib/strings/mention-manip' 12 + import { 13 + parseMarkdownLinks, 14 + shortenLinks, 15 + } from '#/lib/strings/rich-text-manip' 16 import { 17 isBskyPostUrl, 18 postUriToRelativePath, ··· 81 | {type: 'embed_update_image'; image: ComposerImage} 82 | {type: 'embed_remove_image'; image: ComposerImage} 83 | { 84 + type: 'embed_add_video' 85 + asset: ImagePickerAsset 86 + abortController: AbortController 87 + } 88 | {type: 'embed_remove_video'} 89 | {type: 'embed_update_video'; videoAction: VideoAction} 90 | {type: 'embed_add_uri'; uri: string} ··· 110 | {type: 'update_postgate'; postgate: AppBskyFeedPostgate.Record} 111 | {type: 'update_threadgate'; threadgate: ThreadgateAllowUISetting[]} 112 | { 113 + type: 'update_post' 114 + postId: string 115 + postAction: PostAction 116 + } 117 | { 118 + type: 'add_post' 119 + } 120 | { 121 + type: 'remove_post' 122 + postId: string 123 + } 124 | { 125 + type: 'focus_post' 126 + postId: string 127 + } 128 129 export const MAX_IMAGES = 4 130 ··· 497 initImageUris: ComposerOpts['imageUris'] 498 initQuoteUri: string | undefined 499 initInteractionSettings: 500 + | BskyPreferences['postInteractionSettings'] 501 + | undefined 502 }): ComposerState { 503 let media: ImagesMedia | undefined 504 if (initImageUris?.length) { ··· 523 ? initText 524 : initMention 525 ? insertMentionAt( 526 + `@${initMention}`, 527 + initMention.length + 1, 528 + `${initMention}`, 529 + ) 530 : '', 531 }) 532 ··· 623 } 624 625 function getShortenedLength(rt: RichText) { 626 + const {text} = parseMarkdownLinks(rt.text) 627 + const newRt = new RichText({text}) 628 + newRt.detectFacetsWithoutResolution() 629 + return shortenLinks(newRt).graphemeLength 630 }
+50 -1
src/view/com/composer/text-input/TextInput.tsx
··· 11 type TextInputSelectionChangeEventData, 12 View, 13 } from 'react-native' 14 - import {AppBskyRichtextFacet, RichText} from '@atproto/api' 15 import PasteInput, { 16 type PastedFile, 17 type PasteInputRef, // @ts-expect-error no types when installing from github ··· 73 74 const newRt = new RichText({text: newText}) 75 newRt.detectFacetsWithoutResolution() 76 setRichText(newRt) 77 78 // NOTE: BinaryFiddler
··· 11 type TextInputSelectionChangeEventData, 12 View, 13 } from 'react-native' 14 + import {AppBskyRichtextFacet, RichText, UnicodeString} from '@atproto/api' 15 import PasteInput, { 16 type PastedFile, 17 type PasteInputRef, // @ts-expect-error no types when installing from github ··· 73 74 const newRt = new RichText({text: newText}) 75 newRt.detectFacetsWithoutResolution() 76 + 77 + const markdownFacets: AppBskyRichtextFacet.Main[] = [] 78 + const regex = /\[([^\]]+)\]\s*\(([^)]+)\)/g 79 + let match 80 + while ((match = regex.exec(newText)) !== null) { 81 + const [fullMatch, _linkText, linkUrl] = match 82 + const matchStart = match.index 83 + const matchEnd = matchStart + fullMatch.length 84 + const prefix = newText.slice(0, matchStart) 85 + const matchStr = newText.slice(matchStart, matchEnd) 86 + const byteStart = new UnicodeString(prefix).length 87 + const byteEnd = byteStart + new UnicodeString(matchStr).length 88 + 89 + let validUrl = linkUrl 90 + if ( 91 + !validUrl.startsWith('http://') && 92 + !validUrl.startsWith('https://') && 93 + !validUrl.startsWith('mailto:') 94 + ) { 95 + validUrl = `https://${validUrl}` 96 + } 97 + 98 + markdownFacets.push({ 99 + index: {byteStart, byteEnd}, 100 + features: [ 101 + {$type: 'app.bsky.richtext.facet#link', uri: validUrl}, 102 + ], 103 + }) 104 + } 105 + 106 + if (markdownFacets.length > 0) { 107 + 108 + const nonOverlapping = (newRt.facets || []).filter(f => { 109 + return !markdownFacets.some(mf => { 110 + return ( 111 + (f.index.byteStart >= mf.index.byteStart && 112 + f.index.byteStart < mf.index.byteEnd) || 113 + (f.index.byteEnd > mf.index.byteStart && 114 + f.index.byteEnd <= mf.index.byteEnd) || 115 + (mf.index.byteStart >= f.index.byteStart && 116 + mf.index.byteStart < f.index.byteEnd) 117 + ) 118 + }) 119 + }) 120 + newRt.facets = [...nonOverlapping, ...markdownFacets].sort( 121 + (a, b) => a.index.byteStart - b.index.byteStart, 122 + ) 123 + } 124 + 125 setRichText(newRt) 126 127 // NOTE: BinaryFiddler
+49 -1
src/view/com/composer/text-input/TextInput.web.tsx
··· 8 } from 'react' 9 import {StyleSheet, View} from 'react-native' 10 import Animated, {FadeIn, FadeOut} from 'react-native-reanimated' 11 - import {AppBskyRichtextFacet, RichText} from '@atproto/api' 12 import {Trans} from '@lingui/macro' 13 import {Document} from '@tiptap/extension-document' 14 import Hardbreak from '@tiptap/extension-hard-break' ··· 265 266 const newRt = new RichText({text: newText}) 267 newRt.detectFacetsWithoutResolution() 268 setRichText(newRt) 269 270 const nextDetectedUris = new Map<string, LinkFacetMatch>()
··· 8 } from 'react' 9 import {StyleSheet, View} from 'react-native' 10 import Animated, {FadeIn, FadeOut} from 'react-native-reanimated' 11 + import {AppBskyRichtextFacet, RichText, UnicodeString} from '@atproto/api' 12 import {Trans} from '@lingui/macro' 13 import {Document} from '@tiptap/extension-document' 14 import Hardbreak from '@tiptap/extension-hard-break' ··· 265 266 const newRt = new RichText({text: newText}) 267 newRt.detectFacetsWithoutResolution() 268 + 269 + const markdownFacets: AppBskyRichtextFacet.Main[] = [] 270 + const regex = /\[([^\]]+)\]\s*\(([^)]+)\)/g 271 + let match 272 + while ((match = regex.exec(newText)) !== null) { 273 + const [fullMatch, _linkText, linkUrl] = match 274 + const matchStart = match.index 275 + const matchEnd = matchStart + fullMatch.length 276 + const prefix = newText.slice(0, matchStart) 277 + const matchStr = newText.slice(matchStart, matchEnd) 278 + const byteStart = new UnicodeString(prefix).length 279 + const byteEnd = byteStart + new UnicodeString(matchStr).length 280 + 281 + let validUrl = linkUrl 282 + if ( 283 + !validUrl.startsWith('http://') && 284 + !validUrl.startsWith('https://') && 285 + !validUrl.startsWith('mailto:') 286 + ) { 287 + validUrl = `https://${validUrl}` 288 + } 289 + 290 + markdownFacets.push({ 291 + index: {byteStart, byteEnd}, 292 + features: [ 293 + { $type: 'app.bsky.richtext.facet#link', uri: validUrl }, 294 + ], 295 + }) 296 + } 297 + 298 + if (markdownFacets.length > 0) { 299 + const nonOverlapping = (newRt.facets || []).filter(f => { 300 + return !markdownFacets.some(mf => { 301 + return ( 302 + (f.index.byteStart >= mf.index.byteStart && 303 + f.index.byteStart < mf.index.byteEnd) || 304 + (f.index.byteEnd > mf.index.byteStart && 305 + f.index.byteEnd <= mf.index.byteEnd) || 306 + (mf.index.byteStart >= f.index.byteStart && 307 + mf.index.byteStart < f.index.byteEnd) 308 + ) 309 + }) 310 + }) 311 + newRt.facets = [...nonOverlapping, ...markdownFacets].sort( 312 + (a, b) => a.index.byteStart - b.index.byteStart, 313 + ) 314 + } 315 + 316 setRichText(newRt) 317 318 const nextDetectedUris = new Map<string, LinkFacetMatch>()
+14 -1
src/view/com/composer/text-input/web/LinkDecorator.ts
··· 41 if (node.isText && node.text) { 42 const textContent = node.textContent 43 44 - // links 45 iterateUris(textContent, (from, to) => { 46 decorations.push( 47 Decoration.inline(pos + from, pos + to, {
··· 41 if (node.isText && node.text) { 42 const textContent = node.textContent 43 44 + // markdown links [text](url) 45 + const markdownRegex = /\[([^\]]+)\]\s*\(([^)]+)\)/g 46 + let markdownMatch 47 + while ((markdownMatch = markdownRegex.exec(textContent)) !== null) { 48 + const from = markdownMatch.index 49 + const to = from + markdownMatch[0].length 50 + decorations.push( 51 + Decoration.inline(pos + from, pos + to, { 52 + class: 'autolink', 53 + }), 54 + ) 55 + } 56 + 57 + // regular links 58 iterateUris(textContent, (from, to) => { 59 decorations.push( 60 Decoration.inline(pos + from, pos + to, {