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