mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
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} from '@atproto/api'
15import PasteInput, {
16 type PastedFile,
17 type PasteInputRef, // @ts-expect-error no types when installing from github
18} from '@mattermost/react-native-paste-input'
19
20import {POST_IMG_MAX} from '#/lib/constants'
21import {downloadAndResize} from '#/lib/media/manip'
22import {isUriImage} from '#/lib/media/util'
23import {cleanError} from '#/lib/strings/errors'
24import {getMentionAt, insertMentionAt} from '#/lib/strings/mention-manip'
25import {useTheme} from '#/lib/ThemeContext'
26import {isAndroid, isNative} from '#/platform/detection'
27import {
28 type LinkFacetMatch,
29 suggestLinkCardUri,
30} from '#/view/com/composer/text-input/text-input-util'
31import {atoms as a, useAlf} from '#/alf'
32import {normalizeTextStyles} from '#/alf/typography'
33import {Autocomplete} from './mobile/Autocomplete'
34import {type TextInputProps} from './TextInput.types'
35
36interface Selection {
37 start: number
38 end: number
39}
40
41export function TextInput({
42 ref,
43 richtext,
44 placeholder,
45 hasRightPadding,
46 setRichText,
47 onPhotoPasted,
48 onNewLink,
49 onError,
50 ...props
51}: TextInputProps) {
52 const {theme: t, fonts} = useAlf()
53 const textInput = useRef<PasteInputRef>(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
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 const newRt = new RichText({text: newText})
75 newRt.detectFacetsWithoutResolution()
76 setRichText(newRt)
77
78 // NOTE: BinaryFiddler
79 // onChangeText happens before onSelectionChange, cursorPos is out of bound if the user deletes characters,
80 const cursorPos = textInputSelection.current?.start ?? 0
81 const prefix = getMentionAt(newText, Math.min(cursorPos, newText.length))
82
83 if (prefix) {
84 setAutocompletePrefix(prefix.value)
85 } else if (autocompletePrefix) {
86 setAutocompletePrefix('')
87 }
88
89 const nextDetectedUris = new Map<string, LinkFacetMatch>()
90 if (newRt.facets) {
91 for (const facet of newRt.facets) {
92 for (const feature of facet.features) {
93 if (AppBskyRichtextFacet.isLink(feature)) {
94 if (isUriImage(feature.uri)) {
95 const res = await downloadAndResize({
96 uri: feature.uri,
97 width: POST_IMG_MAX.width,
98 height: POST_IMG_MAX.height,
99 mode: 'contain',
100 maxSize: POST_IMG_MAX.size,
101 timeout: 15e3,
102 })
103
104 if (res !== undefined) {
105 onPhotoPasted(res.path)
106 }
107 } else {
108 nextDetectedUris.set(feature.uri, {facet, rt: newRt})
109 }
110 }
111 }
112 }
113 }
114 const suggestedUri = suggestLinkCardUri(
115 mayBePaste,
116 nextDetectedUris,
117 prevDetectedUris.current,
118 pastSuggestedUris.current,
119 )
120 prevDetectedUris.current = nextDetectedUris
121 if (suggestedUri) {
122 onNewLink(suggestedUri)
123 }
124 prevLength.current = newText.length
125 },
126 [setRichText, autocompletePrefix, onPhotoPasted, onNewLink],
127 )
128
129 const onPaste = useCallback(
130 async (err: string | undefined, files: PastedFile[]) => {
131 if (err) {
132 return onError(cleanError(err))
133 }
134
135 const uris = files.map(f => f.uri)
136 const uri = uris.find(isUriImage)
137
138 if (uri) {
139 onPhotoPasted(uri)
140 }
141 },
142 [onError, onPhotoPasted],
143 )
144
145 const onSelectionChange = useCallback(
146 (evt: NativeSyntheticEvent<TextInputSelectionChangeEventData>) => {
147 // NOTE we track the input selection using a ref to avoid excessive renders -prf
148 textInputSelection.current = evt.nativeEvent.selection
149 },
150 [textInputSelection],
151 )
152
153 const onSelectAutocompleteItem = useCallback(
154 (item: string) => {
155 onChangeText(
156 insertMentionAt(
157 richtext.text,
158 textInputSelection.current?.start || 0,
159 item,
160 ),
161 )
162 setAutocompletePrefix('')
163 },
164 [onChangeText, richtext, setAutocompletePrefix],
165 )
166
167 const inputTextStyle = useMemo(() => {
168 const style = normalizeTextStyles(
169 [a.text_lg, a.leading_snug, t.atoms.text],
170 {
171 fontScale: fonts.scaleMultiplier,
172 fontFamily: fonts.family,
173 flags: {},
174 },
175 )
176
177 /**
178 * PasteInput doesn't like `lineHeight`, results in jumpiness
179 */
180 if (isNative) {
181 style.lineHeight = undefined
182 }
183
184 /*
185 * Android impl of `PasteInput` doesn't support the array syntax for `fontVariant`
186 */
187 if (isAndroid) {
188 // @ts-ignore
189 style.fontVariant = style.fontVariant
190 ? style.fontVariant.join(' ')
191 : undefined
192 }
193 return style
194 }, [t, fonts])
195
196 const textDecorated = useMemo(() => {
197 let i = 0
198
199 return Array.from(richtext.segments()).map(segment => {
200 return (
201 <RNText
202 key={i++}
203 style={[
204 inputTextStyle,
205 {
206 color: segment.facet ? t.palette.primary_500 : t.atoms.text.color,
207 marginTop: -1,
208 },
209 ]}>
210 {segment.text}
211 </RNText>
212 )
213 })
214 }, [t, richtext, inputTextStyle])
215
216 return (
217 <View style={[a.flex_1, a.pl_md, hasRightPadding && a.pr_4xl]}>
218 <PasteInput
219 testID="composerTextInput"
220 ref={textInput}
221 onChangeText={onChangeText}
222 onPaste={onPaste}
223 onSelectionChange={onSelectionChange}
224 placeholder={placeholder}
225 placeholderTextColor={t.atoms.text_contrast_medium.color}
226 keyboardAppearance={theme.colorScheme}
227 autoFocus={true}
228 allowFontScaling
229 multiline
230 scrollEnabled={false}
231 numberOfLines={2}
232 // Note: should be the default value, but as of v1.104
233 // it switched to "none" on Android
234 autoCapitalize="sentences"
235 {...props}
236 style={[
237 inputTextStyle,
238 a.w_full,
239 !autocompletePrefix && a.h_full,
240 {
241 textAlignVertical: 'top',
242 minHeight: 60,
243 includeFontPadding: false,
244 },
245 {
246 borderWidth: 1,
247 borderColor: 'transparent',
248 },
249 props.style,
250 ]}>
251 {textDecorated}
252 </PasteInput>
253 <Autocomplete
254 prefix={autocompletePrefix}
255 onSelect={onSelectAutocompleteItem}
256 />
257 </View>
258 )
259}