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