+30
-24
src/view/com/composer/state/composer.ts
+30
-24
src/view/com/composer/state/composer.ts
···
9
9
10
10
import {type SelfLabel} from '#/lib/moderation'
11
11
import {insertMentionAt} from '#/lib/strings/mention-manip'
12
-
import {shortenLinks} from '#/lib/strings/rich-text-manip'
12
+
import {
13
+
parseMarkdownLinks,
14
+
shortenLinks,
15
+
} from '#/lib/strings/rich-text-manip'
13
16
import {
14
17
isBskyPostUrl,
15
18
postUriToRelativePath,
···
78
81
| {type: 'embed_update_image'; image: ComposerImage}
79
82
| {type: 'embed_remove_image'; image: ComposerImage}
80
83
| {
81
-
type: 'embed_add_video'
82
-
asset: ImagePickerAsset
83
-
abortController: AbortController
84
-
}
84
+
type: 'embed_add_video'
85
+
asset: ImagePickerAsset
86
+
abortController: AbortController
87
+
}
85
88
| {type: 'embed_remove_video'}
86
89
| {type: 'embed_update_video'; videoAction: VideoAction}
87
90
| {type: 'embed_add_uri'; uri: string}
···
107
110
| {type: 'update_postgate'; postgate: AppBskyFeedPostgate.Record}
108
111
| {type: 'update_threadgate'; threadgate: ThreadgateAllowUISetting[]}
109
112
| {
110
-
type: 'update_post'
111
-
postId: string
112
-
postAction: PostAction
113
-
}
113
+
type: 'update_post'
114
+
postId: string
115
+
postAction: PostAction
116
+
}
114
117
| {
115
-
type: 'add_post'
116
-
}
118
+
type: 'add_post'
119
+
}
117
120
| {
118
-
type: 'remove_post'
119
-
postId: string
120
-
}
121
+
type: 'remove_post'
122
+
postId: string
123
+
}
121
124
| {
122
-
type: 'focus_post'
123
-
postId: string
124
-
}
125
+
type: 'focus_post'
126
+
postId: string
127
+
}
125
128
126
129
export const MAX_IMAGES = 4
127
130
···
494
497
initImageUris: ComposerOpts['imageUris']
495
498
initQuoteUri: string | undefined
496
499
initInteractionSettings:
497
-
| BskyPreferences['postInteractionSettings']
498
-
| undefined
500
+
| BskyPreferences['postInteractionSettings']
501
+
| undefined
499
502
}): ComposerState {
500
503
let media: ImagesMedia | undefined
501
504
if (initImageUris?.length) {
···
520
523
? initText
521
524
: initMention
522
525
? insertMentionAt(
523
-
`@${initMention}`,
524
-
initMention.length + 1,
525
-
`${initMention}`,
526
-
)
526
+
`@${initMention}`,
527
+
initMention.length + 1,
528
+
`${initMention}`,
529
+
)
527
530
: '',
528
531
})
529
532
···
620
623
}
621
624
622
625
function getShortenedLength(rt: RichText) {
623
-
return shortenLinks(rt).graphemeLength
626
+
const {text} = parseMarkdownLinks(rt.text)
627
+
const newRt = new RichText({text})
628
+
newRt.detectFacetsWithoutResolution()
629
+
return shortenLinks(newRt).graphemeLength
624
630
}
+50
-1
src/view/com/composer/text-input/TextInput.tsx
+50
-1
src/view/com/composer/text-input/TextInput.tsx
···
11
11
type TextInputSelectionChangeEventData,
12
12
View,
13
13
} from 'react-native'
14
-
import {AppBskyRichtextFacet, RichText} from '@atproto/api'
14
+
import {AppBskyRichtextFacet, RichText, UnicodeString} from '@atproto/api'
15
15
import PasteInput, {
16
16
type PastedFile,
17
17
type PasteInputRef, // @ts-expect-error no types when installing from github
···
73
73
74
74
const newRt = new RichText({text: newText})
75
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
+
76
125
setRichText(newRt)
77
126
78
127
// NOTE: BinaryFiddler
+49
-1
src/view/com/composer/text-input/TextInput.web.tsx
+49
-1
src/view/com/composer/text-input/TextInput.web.tsx
···
8
8
} from 'react'
9
9
import {StyleSheet, View} from 'react-native'
10
10
import Animated, {FadeIn, FadeOut} from 'react-native-reanimated'
11
-
import {AppBskyRichtextFacet, RichText} from '@atproto/api'
11
+
import {AppBskyRichtextFacet, RichText, UnicodeString} from '@atproto/api'
12
12
import {Trans} from '@lingui/macro'
13
13
import {Document} from '@tiptap/extension-document'
14
14
import Hardbreak from '@tiptap/extension-hard-break'
···
265
265
266
266
const newRt = new RichText({text: newText})
267
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
+
268
316
setRichText(newRt)
269
317
270
318
const nextDetectedUris = new Map<string, LinkFacetMatch>()
+14
-1
src/view/com/composer/text-input/web/LinkDecorator.ts
+14
-1
src/view/com/composer/text-input/web/LinkDecorator.ts
···
41
41
if (node.isText && node.text) {
42
42
const textContent = node.textContent
43
43
44
-
// links
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
45
58
iterateUris(textContent, (from, to) => {
46
59
decorations.push(
47
60
Decoration.inline(pos + from, pos + to, {