Bluesky app fork with some witchin' additions 馃挮
witchsky.app
bluesky
fork
client
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 {detectFacetsWithoutResolution} from '#/lib/strings/detect-facets'
23import {getMentionAt, insertMentionAt} from '#/lib/strings/mention-manip'
24import {useTheme} from '#/lib/ThemeContext'
25import {
26 type LinkFacetMatch,
27 suggestLinkCardUri,
28} from '#/view/com/composer/text-input/text-input-util'
29import {atoms as a, useAlf, utils} from '#/alf'
30import {normalizeTextStyles} from '#/alf/typography'
31import {IS_ANDROID, IS_NATIVE} from '#/env'
32import {Autocomplete} from './mobile/Autocomplete'
33import {type TextInputProps} from './TextInput.types'
34
35interface Selection {
36 start: number
37 end: number
38}
39
40export function TextInput({
41 ref,
42 richtext,
43 placeholder,
44 hasRightPadding,
45 setRichText,
46 onPhotoPasted,
47 onNewLink,
48 onError,
49 ...props
50}: TextInputProps) {
51 const {t: l} = useLingui()
52 const {theme: t, fonts} = useAlf()
53 const textInput = useRef<RNTextInput>(null)
54 const textInputSelection = useRef<Selection>({start: 0, end: 0})
55 const theme = useTheme()
56 const [autocompletePrefix, setAutocompletePrefix] = useState('')
57 const prevLength = useRef(richtext.length)
58 const prevText = useRef(richtext.text)
59
60 useImperativeHandle(ref, () => ({
61 focus: () => textInput.current?.focus(),
62 blur: () => {
63 textInput.current?.blur()
64 },
65 getCursorPosition: () => undefined, // Not implemented on native
66 maybeClosePopup: () => false, // Not needed on native
67 }))
68
69 const pastSuggestedUris = useRef(new Set<string>())
70 const prevDetectedUris = useRef(new Map<string, LinkFacetMatch>())
71 const onChangeText = useCallback(
72 async (newText: string) => {
73 const mayBePaste = newText.length > prevLength.current + 1
74
75 // Check if this is a paste over selected text with a URL
76 // NOTE: onChangeText happens before onSelectionChange, so textInputSelection.current
77 // still contains the selection from before the paste
78 if (
79 mayBePaste &&
80 textInputSelection.current.start !== textInputSelection.current.end
81 ) {
82 const selectionStart = textInputSelection.current.start
83 const selectionEnd = textInputSelection.current.end
84 const selectedText = prevText.current.substring(
85 selectionStart,
86 selectionEnd,
87 )
88
89 // Calculate what was pasted
90 const beforeSelection = prevText.current.substring(0, selectionStart)
91 const afterSelection = prevText.current.substring(selectionEnd)
92 const expectedLength = beforeSelection.length + afterSelection.length
93 const pastedLength = newText.length - expectedLength
94
95 if (pastedLength > 0 && selectedText.length > 0) {
96 const pastedText = newText.substring(
97 selectionStart,
98 selectionStart + pastedLength,
99 )
100
101 // Check if pasted text is a URL
102 const urlPattern =
103 /^(?:(?:(?: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
104
105 if (urlPattern.test(pastedText.trim())) {
106 // Create markdown-style link: [selectedText](url)
107 const markdownLink = `[${selectedText}](${pastedText.trim()})`
108 newText = beforeSelection + markdownLink + afterSelection
109 }
110 }
111 }
112
113 const newRt = new RichText({text: newText})
114 detectFacetsWithoutResolution(newRt)
115
116 const markdownFacets: AppBskyRichtextFacet.Main[] = []
117 const regex = /\[([^\]]+)\]\s*\(([^)]+)\)/g
118 let match
119 while ((match = regex.exec(newText)) !== null) {
120 const [fullMatch, _linkText, linkUrl] = match
121 const matchStart = match.index
122 const matchEnd = matchStart + fullMatch.length
123 const prefix = newText.slice(0, matchStart)
124 const matchStr = newText.slice(matchStart, matchEnd)
125 const byteStart = new UnicodeString(prefix).length
126 const byteEnd = byteStart + new UnicodeString(matchStr).length
127
128 let validUrl = linkUrl
129 if (
130 !validUrl.startsWith('http://') &&
131 !validUrl.startsWith('https://') &&
132 !validUrl.startsWith('mailto:')
133 ) {
134 validUrl = `https://${validUrl}`
135 }
136
137 markdownFacets.push({
138 index: {byteStart, byteEnd},
139 features: [{$type: 'app.bsky.richtext.facet#link', uri: validUrl}],
140 })
141 }
142
143 if (markdownFacets.length > 0) {
144 const nonOverlapping = (newRt.facets || []).filter(f => {
145 return !markdownFacets.some(mf => {
146 return (
147 (f.index.byteStart >= mf.index.byteStart &&
148 f.index.byteStart < mf.index.byteEnd) ||
149 (f.index.byteEnd > mf.index.byteStart &&
150 f.index.byteEnd <= mf.index.byteEnd) ||
151 (mf.index.byteStart >= f.index.byteStart &&
152 mf.index.byteStart < f.index.byteEnd)
153 )
154 })
155 })
156 newRt.facets = [...nonOverlapping, ...markdownFacets].sort(
157 (a, b) => a.index.byteStart - b.index.byteStart,
158 )
159 }
160
161 setRichText(newRt)
162
163 // NOTE: BinaryFiddler
164 // onChangeText happens before onSelectionChange, cursorPos is out of bound if the user deletes characters,
165 const cursorPos = textInputSelection.current?.start ?? 0
166 const prefix = getMentionAt(newText, Math.min(cursorPos, newText.length))
167
168 if (prefix) {
169 setAutocompletePrefix(prefix.value)
170 } else if (autocompletePrefix) {
171 setAutocompletePrefix('')
172 }
173
174 const nextDetectedUris = new Map<string, LinkFacetMatch>()
175 if (newRt.facets) {
176 for (const facet of newRt.facets) {
177 for (const feature of facet.features) {
178 if (AppBskyRichtextFacet.isLink(feature)) {
179 if (isUriImage(feature.uri)) {
180 const res = await downloadAndResize({
181 uri: feature.uri,
182 width: POST_IMG_MAX.width,
183 height: POST_IMG_MAX.height,
184 mode: 'contain',
185 maxSize: POST_IMG_MAX.size,
186 timeout: 15e3,
187 })
188
189 if (res !== undefined) {
190 onPhotoPasted(res.path)
191 }
192 } else {
193 nextDetectedUris.set(feature.uri, {facet, rt: newRt})
194 }
195 }
196 }
197 }
198 }
199 const suggestedUri = suggestLinkCardUri(
200 mayBePaste,
201 nextDetectedUris,
202 prevDetectedUris.current,
203 pastSuggestedUris.current,
204 )
205 prevDetectedUris.current = nextDetectedUris
206 if (suggestedUri) {
207 onNewLink(suggestedUri)
208 }
209 prevLength.current = newText.length
210 prevText.current = newText
211 },
212 [setRichText, autocompletePrefix, onPhotoPasted, onNewLink],
213 )
214
215 const onPaste = useCallback(
216 (payload: PasteEventPayload) => {
217 if (payload.type === 'unsupported') {
218 onError(l`Unsupported clipboard content`)
219 return
220 }
221
222 if (payload.type === 'images') {
223 for (const uri of payload.uris) {
224 if (isUriImage(uri)) {
225 onPhotoPasted(uri)
226 }
227 }
228 }
229 },
230 [l, 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 <TextInputWrapper onPaste={onPaste}>
307 <RNTextInput
308 testID="composerTextInput"
309 ref={textInput}
310 onChangeText={onChangeText}
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 </RNTextInput>
344 </TextInputWrapper>
345 <Autocomplete
346 prefix={autocompletePrefix}
347 onSelect={onSelectAutocompleteItem}
348 />
349 </View>
350 )
351}