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 StyleSheet,
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 {usePalette} from 'lib/hooks/usePalette'
24import {downloadAndResize} from 'lib/media/manip'
25import {isUriImage} from 'lib/media/util'
26import {cleanError} from 'lib/strings/errors'
27import {getMentionAt, insertMentionAt} from 'lib/strings/mention-manip'
28import {useTheme} from 'lib/ThemeContext'
29import {isIOS} from 'platform/detection'
30import {
31 LinkFacetMatch,
32 suggestLinkCardUri,
33} from 'view/com/composer/text-input/text-input-util'
34import {Text} from 'view/com/util/text/Text'
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 setRichText: (v: RichText | ((v: RichText) => RichText)) => void
47 onPhotoPasted: (uri: string) => void
48 onPressPublish: (richtext: RichText) => Promise<void>
49 onNewLink: (uri: string) => void
50 onError: (err: string) => void
51}
52
53interface Selection {
54 start: number
55 end: number
56}
57
58export const TextInput = forwardRef(function TextInputImpl(
59 {
60 richtext,
61 placeholder,
62 setRichText,
63 onPhotoPasted,
64 onNewLink,
65 onError,
66 ...props
67 }: TextInputProps,
68 ref,
69) {
70 const pal = usePalette('default')
71 const textInput = useRef<PasteInputRef>(null)
72 const textInputSelection = useRef<Selection>({start: 0, end: 0})
73 const theme = useTheme()
74 const [autocompletePrefix, setAutocompletePrefix] = useState('')
75 const prevLength = React.useRef(richtext.length)
76
77 React.useImperativeHandle(ref, () => ({
78 focus: () => textInput.current?.focus(),
79 blur: () => {
80 textInput.current?.blur()
81 },
82 getCursorPosition: () => undefined, // Not implemented on native
83 }))
84
85 const pastSuggestedUris = useRef(new Set<string>())
86 const prevDetectedUris = useRef(new Map<string, LinkFacetMatch>())
87 const onChangeText = useCallback(
88 async (newText: string) => {
89 const mayBePaste = newText.length > prevLength.current + 1
90
91 const newRt = new RichText({text: newText})
92 newRt.detectFacetsWithoutResolution()
93 setRichText(newRt)
94
95 const prefix = getMentionAt(
96 newText,
97 textInputSelection.current?.start || 0,
98 )
99 if (prefix) {
100 setAutocompletePrefix(prefix.value)
101 } else if (autocompletePrefix) {
102 setAutocompletePrefix('')
103 }
104
105 const nextDetectedUris = new Map<string, LinkFacetMatch>()
106 if (newRt.facets) {
107 for (const facet of newRt.facets) {
108 for (const feature of facet.features) {
109 if (AppBskyRichtextFacet.isLink(feature)) {
110 if (isUriImage(feature.uri)) {
111 const res = await downloadAndResize({
112 uri: feature.uri,
113 width: POST_IMG_MAX.width,
114 height: POST_IMG_MAX.height,
115 mode: 'contain',
116 maxSize: POST_IMG_MAX.size,
117 timeout: 15e3,
118 })
119
120 if (res !== undefined) {
121 onPhotoPasted(res.path)
122 }
123 } else {
124 nextDetectedUris.set(feature.uri, {facet, rt: newRt})
125 }
126 }
127 }
128 }
129 }
130 const suggestedUri = suggestLinkCardUri(
131 mayBePaste,
132 nextDetectedUris,
133 prevDetectedUris.current,
134 pastSuggestedUris.current,
135 )
136 prevDetectedUris.current = nextDetectedUris
137 if (suggestedUri) {
138 onNewLink(suggestedUri)
139 }
140 prevLength.current = newText.length
141 },
142 [setRichText, autocompletePrefix, onPhotoPasted, onNewLink],
143 )
144
145 const onPaste = useCallback(
146 async (err: string | undefined, files: PastedFile[]) => {
147 if (err) {
148 return onError(cleanError(err))
149 }
150
151 const uris = files.map(f => f.uri)
152 const uri = uris.find(isUriImage)
153
154 if (uri) {
155 onPhotoPasted(uri)
156 }
157 },
158 [onError, onPhotoPasted],
159 )
160
161 const onSelectionChange = useCallback(
162 (evt: NativeSyntheticEvent<TextInputSelectionChangeEventData>) => {
163 // NOTE we track the input selection using a ref to avoid excessive renders -prf
164 textInputSelection.current = evt.nativeEvent.selection
165 },
166 [textInputSelection],
167 )
168
169 const onSelectAutocompleteItem = useCallback(
170 (item: string) => {
171 onChangeText(
172 insertMentionAt(
173 richtext.text,
174 textInputSelection.current?.start || 0,
175 item,
176 ),
177 )
178 setAutocompletePrefix('')
179 },
180 [onChangeText, richtext, setAutocompletePrefix],
181 )
182
183 const textDecorated = useMemo(() => {
184 let i = 0
185
186 return Array.from(richtext.segments()).map(segment => {
187 return (
188 <Text
189 key={i++}
190 style={[
191 segment.facet ? pal.link : pal.text,
192 styles.textInputFormatting,
193 ]}>
194 {segment.text}
195 </Text>
196 )
197 })
198 }, [richtext, pal.link, pal.text])
199
200 return (
201 <View style={styles.container}>
202 <PasteInput
203 testID="composerTextInput"
204 ref={textInput}
205 onChangeText={onChangeText}
206 onPaste={onPaste}
207 onSelectionChange={onSelectionChange}
208 placeholder={placeholder}
209 placeholderTextColor={pal.colors.textLight}
210 keyboardAppearance={theme.colorScheme}
211 autoFocus={true}
212 allowFontScaling
213 multiline
214 scrollEnabled={false}
215 numberOfLines={4}
216 style={[
217 pal.text,
218 styles.textInput,
219 styles.textInputFormatting,
220 {textAlignVertical: 'top'},
221 ]}
222 {...props}>
223 {textDecorated}
224 </PasteInput>
225 <Autocomplete
226 prefix={autocompletePrefix}
227 onSelect={onSelectAutocompleteItem}
228 />
229 </View>
230 )
231})
232
233const styles = StyleSheet.create({
234 container: {
235 flex: 1,
236 },
237 textInput: {
238 flex: 1,
239 width: '100%',
240 padding: 5,
241 paddingBottom: 20,
242 marginLeft: 8,
243 alignSelf: 'flex-start',
244 },
245 textInputFormatting: {
246 fontSize: 18,
247 letterSpacing: 0.2,
248 fontWeight: '400',
249 // This is broken on ios right now, so don't set it there.
250 lineHeight: isIOS ? undefined : 23.4, // 1.3*16
251 },
252})