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
61 useImperativeHandle(ref, () => ({
62 focus: () => textInput.current?.focus(),
63 blur: () => {
64 textInput.current?.blur()
65 },
66 getCursorPosition: () => undefined, // Not implemented on native
67 maybeClosePopup: () => false, // Not needed on native
68 }))
69
70 const pastSuggestedUris = useRef(new Set<string>())
71 const prevDetectedUris = useRef(new Map<string, LinkFacetMatch>())
72 const onChangeText = useCallback(
73 async (newText: string) => {
74 const mayBePaste = newText.length > prevLength.current + 1
75
76 const newRt = new RichText({text: newText})
77 newRt.detectFacetsWithoutResolution()
78
79 const markdownFacets: AppBskyRichtextFacet.Main[] = []
80 const regex = /\[([^\]]+)\]\s*\(([^)]+)\)/g
81 let match
82 while ((match = regex.exec(newText)) !== null) {
83 const [fullMatch, _linkText, linkUrl] = match
84 const matchStart = match.index
85 const matchEnd = matchStart + fullMatch.length
86 const prefix = newText.slice(0, matchStart)
87 const matchStr = newText.slice(matchStart, matchEnd)
88 const byteStart = new UnicodeString(prefix).length
89 const byteEnd = byteStart + new UnicodeString(matchStr).length
90
91 let validUrl = linkUrl
92 if (
93 !validUrl.startsWith('http://') &&
94 !validUrl.startsWith('https://') &&
95 !validUrl.startsWith('mailto:')
96 ) {
97 validUrl = `https://${validUrl}`
98 }
99
100 markdownFacets.push({
101 index: {byteStart, byteEnd},
102 features: [
103 {$type: 'app.bsky.richtext.facet#link', uri: validUrl},
104 ],
105 })
106 }
107
108 if (markdownFacets.length > 0) {
109
110 const nonOverlapping = (newRt.facets || []).filter(f => {
111 return !markdownFacets.some(mf => {
112 return (
113 (f.index.byteStart >= mf.index.byteStart &&
114 f.index.byteStart < mf.index.byteEnd) ||
115 (f.index.byteEnd > mf.index.byteStart &&
116 f.index.byteEnd <= mf.index.byteEnd) ||
117 (mf.index.byteStart >= f.index.byteStart &&
118 mf.index.byteStart < f.index.byteEnd)
119 )
120 })
121 })
122 newRt.facets = [...nonOverlapping, ...markdownFacets].sort(
123 (a, b) => a.index.byteStart - b.index.byteStart,
124 )
125 }
126
127 setRichText(newRt)
128
129 // NOTE: BinaryFiddler
130 // onChangeText happens before onSelectionChange, cursorPos is out of bound if the user deletes characters,
131 const cursorPos = textInputSelection.current?.start ?? 0
132 const prefix = getMentionAt(newText, Math.min(cursorPos, newText.length))
133
134 if (prefix) {
135 setAutocompletePrefix(prefix.value)
136 } else if (autocompletePrefix) {
137 setAutocompletePrefix('')
138 }
139
140 const nextDetectedUris = new Map<string, LinkFacetMatch>()
141 if (newRt.facets) {
142 for (const facet of newRt.facets) {
143 for (const feature of facet.features) {
144 if (AppBskyRichtextFacet.isLink(feature)) {
145 if (isUriImage(feature.uri)) {
146 const res = await downloadAndResize({
147 uri: feature.uri,
148 width: POST_IMG_MAX.width,
149 height: POST_IMG_MAX.height,
150 mode: 'contain',
151 maxSize: POST_IMG_MAX.size,
152 timeout: 15e3,
153 })
154
155 if (res !== undefined) {
156 onPhotoPasted(res.path)
157 }
158 } else {
159 nextDetectedUris.set(feature.uri, {facet, rt: newRt})
160 }
161 }
162 }
163 }
164 }
165 const suggestedUri = suggestLinkCardUri(
166 mayBePaste,
167 nextDetectedUris,
168 prevDetectedUris.current,
169 pastSuggestedUris.current,
170 )
171 prevDetectedUris.current = nextDetectedUris
172 if (suggestedUri) {
173 onNewLink(suggestedUri)
174 }
175 prevLength.current = newText.length
176 },
177 [setRichText, autocompletePrefix, onPhotoPasted, onNewLink],
178 )
179
180 const onPaste = useCallback(
181 async (err: string | undefined, files: PastedFile[]) => {
182 if (err) {
183 return onError(cleanError(err))
184 }
185
186 const uris = files.map(f => f.uri)
187 const uri = uris.find(isUriImage)
188
189 if (uri) {
190 onPhotoPasted(uri)
191 }
192 },
193 [onError, onPhotoPasted],
194 )
195
196 const onSelectionChange = useCallback(
197 (evt: NativeSyntheticEvent<TextInputSelectionChangeEventData>) => {
198 // NOTE we track the input selection using a ref to avoid excessive renders -prf
199 textInputSelection.current = evt.nativeEvent.selection
200 },
201 [textInputSelection],
202 )
203
204 const onSelectAutocompleteItem = useCallback(
205 (item: string) => {
206 onChangeText(
207 insertMentionAt(
208 richtext.text,
209 textInputSelection.current?.start || 0,
210 item,
211 ),
212 )
213 setAutocompletePrefix('')
214 },
215 [onChangeText, richtext, setAutocompletePrefix],
216 )
217
218 const inputTextStyle = useMemo(() => {
219 const style = normalizeTextStyles(
220 [a.text_lg, a.leading_snug, t.atoms.text],
221 {
222 fontScale: fonts.scaleMultiplier,
223 fontFamily: fonts.family,
224 flags: {},
225 },
226 )
227
228 /**
229 * PasteInput doesn't like `lineHeight`, results in jumpiness
230 */
231 if (IS_NATIVE) {
232 style.lineHeight = undefined
233 }
234
235 /*
236 * Android impl of `PasteInput` doesn't support the array syntax for `fontVariant`
237 */
238 if (IS_ANDROID) {
239 // @ts-ignore
240 style.fontVariant = style.fontVariant
241 ? style.fontVariant.join(' ')
242 : undefined
243 }
244 return style
245 }, [t, fonts])
246
247 const textDecorated = useMemo(() => {
248 let i = 0
249
250 return Array.from(richtext.segments()).map(segment => {
251 return (
252 <RNText
253 key={i++}
254 style={[
255 inputTextStyle,
256 {
257 color: segment.facet ? t.palette.primary_500 : t.atoms.text.color,
258 marginTop: -1,
259 },
260 ]}>
261 {segment.text}
262 </RNText>
263 )
264 })
265 }, [t, richtext, inputTextStyle])
266
267 return (
268 <View style={[a.flex_1, a.pl_md, hasRightPadding && a.pr_4xl]}>
269 <PasteInput
270 testID="composerTextInput"
271 ref={textInput}
272 onChangeText={onChangeText}
273 onPaste={onPaste}
274 onSelectionChange={onSelectionChange}
275 placeholder={placeholder}
276 placeholderTextColor={t.atoms.text_contrast_medium.color}
277 keyboardAppearance={theme.colorScheme}
278 autoFocus={true}
279 allowFontScaling
280 multiline
281 scrollEnabled={false}
282 numberOfLines={2}
283 // Note: should be the default value, but as of v1.104
284 // it switched to "none" on Android
285 autoCapitalize="sentences"
286 selectionColor={utils.alpha(t.palette.primary_500, 0.4)}
287 cursorColor={t.palette.primary_500}
288 selectionHandleColor={t.palette.primary_500}
289 {...props}
290 style={[
291 inputTextStyle,
292 a.w_full,
293 !autocompletePrefix && a.h_full,
294 {
295 textAlignVertical: 'top',
296 minHeight: 60,
297 includeFontPadding: false,
298 },
299 {
300 borderWidth: 1,
301 borderColor: 'transparent',
302 },
303 props.style,
304 ]}>
305 {textDecorated}
306 </PasteInput>
307 <Autocomplete
308 prefix={autocompletePrefix}
309 onSelect={onSelectAutocompleteItem}
310 />
311 </View>
312 )
313}