forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
1import {
2 useCallback,
3 useImperativeHandle,
4 useMemo,
5 useRef,
6 useState,
7} from 'react'
8import {
9 type NativeSyntheticEvent,
10 Text as RNText,
11 type TextInputSelectionChangeEventData,
12 View,
13} from 'react-native'
14import {AppBskyRichtextFacet, RichText, UnicodeString} from '@atproto/api'
15import PasteInput, {
16 type PastedFile,
17 type PasteInputRef,
18 // @ts-expect-error no types when installing from github
19 // eslint-disable-next-line import-x/no-unresolved
20} from '@mattermost/react-native-paste-input'
21
22import {POST_IMG_MAX} from '#/lib/constants'
23import {downloadAndResize} from '#/lib/media/manip'
24import {isUriImage} from '#/lib/media/util'
25import {cleanError} from '#/lib/strings/errors'
26import {getMentionAt, insertMentionAt} from '#/lib/strings/mention-manip'
27import {useTheme} from '#/lib/ThemeContext'
28import {
29 type LinkFacetMatch,
30 suggestLinkCardUri,
31} from '#/view/com/composer/text-input/text-input-util'
32import {atoms as a, useAlf, utils} from '#/alf'
33import {normalizeTextStyles} from '#/alf/typography'
34import {IS_ANDROID, IS_NATIVE} from '#/env'
35import {Autocomplete} from './mobile/Autocomplete'
36import {type TextInputProps} from './TextInput.types'
37
38interface Selection {
39 start: number
40 end: number
41}
42
43export function TextInput({
44 ref,
45 richtext,
46 placeholder,
47 hasRightPadding,
48 setRichText,
49 onPhotoPasted,
50 onNewLink,
51 onError,
52 ...props
53}: TextInputProps) {
54 const {theme: t, fonts} = useAlf()
55 const textInput = useRef<PasteInputRef>(null)
56 const textInputSelection = useRef<Selection>({start: 0, end: 0})
57 const theme = useTheme()
58 const [autocompletePrefix, setAutocompletePrefix] = useState('')
59 const prevLength = useRef(richtext.length)
60 const prevText = useRef(richtext.text)
61
62 useImperativeHandle(ref, () => ({
63 focus: () => textInput.current?.focus(),
64 blur: () => {
65 textInput.current?.blur()
66 },
67 getCursorPosition: () => undefined, // Not implemented on native
68 maybeClosePopup: () => false, // Not needed on native
69 }))
70
71 const pastSuggestedUris = useRef(new Set<string>())
72 const prevDetectedUris = useRef(new Map<string, LinkFacetMatch>())
73 const onChangeText = useCallback(
74 async (newText: string) => {
75 const mayBePaste = newText.length > prevLength.current + 1
76
77 // Check if this is a paste over selected text with a URL
78 // NOTE: onChangeText happens before onSelectionChange, so textInputSelection.current
79 // still contains the selection from before the paste
80 if (
81 mayBePaste &&
82 textInputSelection.current.start !== textInputSelection.current.end
83 ) {
84 const selectionStart = textInputSelection.current.start
85 const selectionEnd = textInputSelection.current.end
86 const selectedText = prevText.current.substring(
87 selectionStart,
88 selectionEnd,
89 )
90
91 // Calculate what was pasted
92 const beforeSelection = prevText.current.substring(0, selectionStart)
93 const afterSelection = prevText.current.substring(selectionEnd)
94 const expectedLength = beforeSelection.length + afterSelection.length
95 const pastedLength = newText.length - expectedLength
96
97 if (pastedLength > 0 && selectedText.length > 0) {
98 const pastedText = newText.substring(
99 selectionStart,
100 selectionStart + pastedLength,
101 )
102
103 // Check if pasted text is a URL
104 const urlPattern =
105 /^(?:(?:(?:https?|ftp):)?\/\/)?(?:\S+(?::\S*)?@)?(?:(?!(?:10|127)(?:\.\d{1,3}){3})(?!(?:169\.254|192\.168)(?:\.\d{1,3}){2})(?!172\.(?:1[6-9]|2\d|3[0-1])(?:\.\d{1,3}){2})(?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])(?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5])){2}(?:\.(?:[1-9]\d?|1\d\d|2[0-4]\d|25[0-4]))|(?:(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)(?:\.(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)*(?:\.(?:[a-z\u00a1-\uffff]{2,})))(?::\d{2,5})?(?:[/?#]\S*)?$/i
106
107 if (urlPattern.test(pastedText.trim())) {
108 // Create markdown-style link: [selectedText](url)
109 const markdownLink = `[${selectedText}](${pastedText.trim()})`
110 newText = beforeSelection + markdownLink + afterSelection
111 }
112 }
113 }
114
115 const newRt = new RichText({text: newText})
116 newRt.detectFacetsWithoutResolution()
117
118 const markdownFacets: AppBskyRichtextFacet.Main[] = []
119 const regex = /\[([^\]]+)\]\s*\(([^)]+)\)/g
120 let match
121 while ((match = regex.exec(newText)) !== null) {
122 const [fullMatch, _linkText, linkUrl] = match
123 const matchStart = match.index
124 const matchEnd = matchStart + fullMatch.length
125 const prefix = newText.slice(0, matchStart)
126 const matchStr = newText.slice(matchStart, matchEnd)
127 const byteStart = new UnicodeString(prefix).length
128 const byteEnd = byteStart + new UnicodeString(matchStr).length
129
130 let validUrl = linkUrl
131 if (
132 !validUrl.startsWith('http://') &&
133 !validUrl.startsWith('https://') &&
134 !validUrl.startsWith('mailto:')
135 ) {
136 validUrl = `https://${validUrl}`
137 }
138
139 markdownFacets.push({
140 index: {byteStart, byteEnd},
141 features: [{$type: 'app.bsky.richtext.facet#link', uri: validUrl}],
142 })
143 }
144
145 if (markdownFacets.length > 0) {
146 const nonOverlapping = (newRt.facets || []).filter(f => {
147 return !markdownFacets.some(mf => {
148 return (
149 (f.index.byteStart >= mf.index.byteStart &&
150 f.index.byteStart < mf.index.byteEnd) ||
151 (f.index.byteEnd > mf.index.byteStart &&
152 f.index.byteEnd <= mf.index.byteEnd) ||
153 (mf.index.byteStart >= f.index.byteStart &&
154 mf.index.byteStart < f.index.byteEnd)
155 )
156 })
157 })
158 newRt.facets = [...nonOverlapping, ...markdownFacets].sort(
159 (a, b) => a.index.byteStart - b.index.byteStart,
160 )
161 }
162
163 setRichText(newRt)
164
165 // NOTE: BinaryFiddler
166 // onChangeText happens before onSelectionChange, cursorPos is out of bound if the user deletes characters,
167 const cursorPos = textInputSelection.current?.start ?? 0
168 const prefix = getMentionAt(newText, Math.min(cursorPos, newText.length))
169
170 if (prefix) {
171 setAutocompletePrefix(prefix.value)
172 } else if (autocompletePrefix) {
173 setAutocompletePrefix('')
174 }
175
176 const nextDetectedUris = new Map<string, LinkFacetMatch>()
177 if (newRt.facets) {
178 for (const facet of newRt.facets) {
179 for (const feature of facet.features) {
180 if (AppBskyRichtextFacet.isLink(feature)) {
181 if (isUriImage(feature.uri)) {
182 const res = await downloadAndResize({
183 uri: feature.uri,
184 width: POST_IMG_MAX.width,
185 height: POST_IMG_MAX.height,
186 mode: 'contain',
187 maxSize: POST_IMG_MAX.size,
188 timeout: 15e3,
189 })
190
191 if (res !== undefined) {
192 onPhotoPasted(res.path)
193 }
194 } else {
195 nextDetectedUris.set(feature.uri, {facet, rt: newRt})
196 }
197 }
198 }
199 }
200 }
201 const suggestedUri = suggestLinkCardUri(
202 mayBePaste,
203 nextDetectedUris,
204 prevDetectedUris.current,
205 pastSuggestedUris.current,
206 )
207 prevDetectedUris.current = nextDetectedUris
208 if (suggestedUri) {
209 onNewLink(suggestedUri)
210 }
211 prevLength.current = newText.length
212 prevText.current = newText
213 },
214 [setRichText, autocompletePrefix, onPhotoPasted, onNewLink],
215 )
216
217 const onPaste = useCallback(
218 async (err: string | undefined, files: PastedFile[]) => {
219 if (err) {
220 return onError(cleanError(err))
221 }
222
223 const uris = files.map(f => f.uri)
224 const uri = uris.find(isUriImage)
225
226 if (uri) {
227 onPhotoPasted(uri)
228 }
229 },
230 [onError, onPhotoPasted],
231 )
232
233 const onSelectionChange = useCallback(
234 (evt: NativeSyntheticEvent<TextInputSelectionChangeEventData>) => {
235 // NOTE we track the input selection using a ref to avoid excessive renders -prf
236 textInputSelection.current = evt.nativeEvent.selection
237 },
238 [textInputSelection],
239 )
240
241 const onSelectAutocompleteItem = useCallback(
242 (item: string) => {
243 onChangeText(
244 insertMentionAt(
245 richtext.text,
246 textInputSelection.current?.start || 0,
247 item,
248 ),
249 )
250 setAutocompletePrefix('')
251 },
252 [onChangeText, richtext, setAutocompletePrefix],
253 )
254
255 const inputTextStyle = useMemo(() => {
256 const style = normalizeTextStyles(
257 [a.text_lg, a.leading_snug, t.atoms.text],
258 {
259 fontScale: fonts.scaleMultiplier,
260 fontFamily: fonts.family,
261 flags: {},
262 },
263 )
264
265 /**
266 * PasteInput doesn't like `lineHeight`, results in jumpiness
267 */
268 if (IS_NATIVE) {
269 style.lineHeight = undefined
270 }
271
272 /*
273 * Android impl of `PasteInput` doesn't support the array syntax for `fontVariant`
274 */
275 if (IS_ANDROID) {
276 // @ts-ignore
277 style.fontVariant = style.fontVariant
278 ? style.fontVariant.join(' ')
279 : undefined
280 }
281 return style
282 }, [t, fonts])
283
284 const textDecorated = useMemo(() => {
285 let i = 0
286
287 return Array.from(richtext.segments()).map(segment => {
288 return (
289 <RNText
290 key={i++}
291 style={[
292 inputTextStyle,
293 {
294 color: segment.facet ? t.palette.primary_500 : t.atoms.text.color,
295 marginTop: -1,
296 },
297 ]}>
298 {segment.text}
299 </RNText>
300 )
301 })
302 }, [t, richtext, inputTextStyle])
303
304 return (
305 <View style={[a.flex_1, a.pl_md, hasRightPadding && a.pr_4xl]}>
306 <PasteInput
307 testID="composerTextInput"
308 ref={textInput}
309 onChangeText={onChangeText}
310 onPaste={onPaste}
311 onSelectionChange={onSelectionChange}
312 placeholder={placeholder}
313 placeholderTextColor={t.atoms.text_contrast_low.color}
314 keyboardAppearance={theme.colorScheme}
315 autoFocus={props.autoFocus !== undefined ? props.autoFocus : true}
316 allowFontScaling
317 multiline
318 scrollEnabled={false}
319 numberOfLines={2}
320 // Note: should be the default value, but as of v1.104
321 // it switched to "none" on Android
322 autoCapitalize="sentences"
323 selectionColor={utils.alpha(t.palette.primary_500, 0.4)}
324 cursorColor={t.palette.primary_500}
325 selectionHandleColor={t.palette.primary_500}
326 {...props}
327 style={[
328 inputTextStyle,
329 a.w_full,
330 !autocompletePrefix && a.h_full,
331 {
332 textAlignVertical: 'top',
333 minHeight: 60,
334 includeFontPadding: false,
335 },
336 {
337 borderWidth: 1,
338 borderColor: 'transparent',
339 },
340 props.style,
341 ]}>
342 {textDecorated}
343 </PasteInput>
344 <Autocomplete
345 prefix={autocompletePrefix}
346 onSelect={onSelectAutocompleteItem}
347 />
348 </View>
349 )
350}