mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
1import React, {
2 ComponentProps,
3 forwardRef,
4 useCallback,
5 useMemo,
6 useRef,
7 useState,
8} from 'react'
9import {
10 NativeSyntheticEvent,
11 Text as RNText,
12 TextInput as RNTextInput,
13 TextInputSelectionChangeEventData,
14 View,
15} from 'react-native'
16import {AppBskyRichtextFacet, RichText} from '@atproto/api'
17import PasteInput, {
18 PastedFile,
19 PasteInputRef,
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 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 const prefix = getMentionAt(
100 newText,
101 textInputSelection.current?.start || 0,
102 )
103 if (prefix) {
104 setAutocompletePrefix(prefix.value)
105 } else if (autocompletePrefix) {
106 setAutocompletePrefix('')
107 }
108
109 const nextDetectedUris = new Map<string, LinkFacetMatch>()
110 if (newRt.facets) {
111 for (const facet of newRt.facets) {
112 for (const feature of facet.features) {
113 if (AppBskyRichtextFacet.isLink(feature)) {
114 if (isUriImage(feature.uri)) {
115 const res = await downloadAndResize({
116 uri: feature.uri,
117 width: POST_IMG_MAX.width,
118 height: POST_IMG_MAX.height,
119 mode: 'contain',
120 maxSize: POST_IMG_MAX.size,
121 timeout: 15e3,
122 })
123
124 if (res !== undefined) {
125 onPhotoPasted(res.path)
126 }
127 } else {
128 nextDetectedUris.set(feature.uri, {facet, rt: newRt})
129 }
130 }
131 }
132 }
133 }
134 const suggestedUri = suggestLinkCardUri(
135 mayBePaste,
136 nextDetectedUris,
137 prevDetectedUris.current,
138 pastSuggestedUris.current,
139 )
140 prevDetectedUris.current = nextDetectedUris
141 if (suggestedUri) {
142 onNewLink(suggestedUri)
143 }
144 prevLength.current = newText.length
145 },
146 [setRichText, autocompletePrefix, onPhotoPasted, onNewLink],
147 )
148
149 const onPaste = useCallback(
150 async (err: string | undefined, files: PastedFile[]) => {
151 if (err) {
152 return onError(cleanError(err))
153 }
154
155 const uris = files.map(f => f.uri)
156 const uri = uris.find(isUriImage)
157
158 if (uri) {
159 onPhotoPasted(uri)
160 }
161 },
162 [onError, onPhotoPasted],
163 )
164
165 const onSelectionChange = useCallback(
166 (evt: NativeSyntheticEvent<TextInputSelectionChangeEventData>) => {
167 // NOTE we track the input selection using a ref to avoid excessive renders -prf
168 textInputSelection.current = evt.nativeEvent.selection
169 },
170 [textInputSelection],
171 )
172
173 const onSelectAutocompleteItem = useCallback(
174 (item: string) => {
175 onChangeText(
176 insertMentionAt(
177 richtext.text,
178 textInputSelection.current?.start || 0,
179 item,
180 ),
181 )
182 setAutocompletePrefix('')
183 },
184 [onChangeText, richtext, setAutocompletePrefix],
185 )
186
187 const inputTextStyle = React.useMemo(() => {
188 const style = normalizeTextStyles(
189 [a.text_xl, a.leading_snug, t.atoms.text],
190 {
191 fontScale: fonts.scaleMultiplier,
192 fontFamily: fonts.family,
193 flags: {},
194 },
195 )
196
197 /**
198 * PasteInput doesn't like `lineHeight`, results in jumpiness
199 */
200 if (isNative) {
201 style.lineHeight = undefined
202 }
203
204 /*
205 * Android impl of `PasteInput` doesn't support the array syntax for `fontVariant`
206 */
207 if (isAndroid) {
208 // @ts-ignore
209 style.fontVariant = style.fontVariant
210 ? style.fontVariant.join(' ')
211 : undefined
212 }
213 return style
214 }, [t, fonts])
215
216 const textDecorated = useMemo(() => {
217 let i = 0
218
219 return Array.from(richtext.segments()).map(segment => {
220 return (
221 <RNText
222 key={i++}
223 style={[
224 inputTextStyle,
225 {
226 color: segment.facet ? t.palette.primary_500 : t.atoms.text.color,
227 marginTop: -1,
228 },
229 ]}>
230 {segment.text}
231 </RNText>
232 )
233 })
234 }, [t, richtext, inputTextStyle])
235
236 return (
237 <View style={[a.flex_1, a.pl_md, hasRightPadding && a.pr_4xl]}>
238 <PasteInput
239 testID="composerTextInput"
240 ref={textInput}
241 onChangeText={onChangeText}
242 onPaste={onPaste}
243 onSelectionChange={onSelectionChange}
244 placeholder={placeholder}
245 placeholderTextColor={t.atoms.text_contrast_medium.color}
246 keyboardAppearance={theme.colorScheme}
247 autoFocus={true}
248 allowFontScaling
249 multiline
250 scrollEnabled={false}
251 numberOfLines={2}
252 style={[
253 inputTextStyle,
254 a.w_full,
255 {
256 textAlignVertical: 'top',
257 minHeight: 60,
258 includeFontPadding: false,
259 },
260 {
261 borderWidth: 1,
262 borderColor: 'transparent',
263 },
264 ]}
265 {...props}>
266 {textDecorated}
267 </PasteInput>
268 <Autocomplete
269 prefix={autocompletePrefix}
270 onSelect={onSelectAutocompleteItem}
271 />
272 </View>
273 )
274})