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 (newText: string) => {
89 /*
90 * This is a hack to bump the rendering of our styled
91 * `textDecorated` to _after_ whatever processing is happening
92 * within the `PasteInput` library. Without this, the elements in
93 * `textDecorated` are not correctly painted to screen.
94 *
95 * NB: we tried a `0` timeout as well, but only positive values worked.
96 *
97 * @see https://github.com/bluesky-social/social-app/issues/929
98 */
99 setTimeout(async () => {
100 const mayBePaste = newText.length > prevLength.current + 1
101
102 const newRt = new RichText({text: newText})
103 newRt.detectFacetsWithoutResolution()
104 setRichText(newRt)
105
106 const prefix = getMentionAt(
107 newText,
108 textInputSelection.current?.start || 0,
109 )
110 if (prefix) {
111 setAutocompletePrefix(prefix.value)
112 } else if (autocompletePrefix) {
113 setAutocompletePrefix('')
114 }
115
116 const nextDetectedUris = new Map<string, LinkFacetMatch>()
117 if (newRt.facets) {
118 for (const facet of newRt.facets) {
119 for (const feature of facet.features) {
120 if (AppBskyRichtextFacet.isLink(feature)) {
121 if (isUriImage(feature.uri)) {
122 const res = await downloadAndResize({
123 uri: feature.uri,
124 width: POST_IMG_MAX.width,
125 height: POST_IMG_MAX.height,
126 mode: 'contain',
127 maxSize: POST_IMG_MAX.size,
128 timeout: 15e3,
129 })
130
131 if (res !== undefined) {
132 onPhotoPasted(res.path)
133 }
134 } else {
135 nextDetectedUris.set(feature.uri, {facet, rt: newRt})
136 }
137 }
138 }
139 }
140 }
141 const suggestedUri = suggestLinkCardUri(
142 mayBePaste,
143 nextDetectedUris,
144 prevDetectedUris.current,
145 pastSuggestedUris.current,
146 )
147 prevDetectedUris.current = nextDetectedUris
148 if (suggestedUri) {
149 onNewLink(suggestedUri)
150 }
151 prevLength.current = newText.length
152 }, 1)
153 },
154 [setRichText, autocompletePrefix, onPhotoPasted, onNewLink],
155 )
156
157 const onPaste = useCallback(
158 async (err: string | undefined, files: PastedFile[]) => {
159 if (err) {
160 return onError(cleanError(err))
161 }
162
163 const uris = files.map(f => f.uri)
164 const uri = uris.find(isUriImage)
165
166 if (uri) {
167 onPhotoPasted(uri)
168 }
169 },
170 [onError, onPhotoPasted],
171 )
172
173 const onSelectionChange = useCallback(
174 (evt: NativeSyntheticEvent<TextInputSelectionChangeEventData>) => {
175 // NOTE we track the input selection using a ref to avoid excessive renders -prf
176 textInputSelection.current = evt.nativeEvent.selection
177 },
178 [textInputSelection],
179 )
180
181 const onSelectAutocompleteItem = useCallback(
182 (item: string) => {
183 onChangeText(
184 insertMentionAt(
185 richtext.text,
186 textInputSelection.current?.start || 0,
187 item,
188 ),
189 )
190 setAutocompletePrefix('')
191 },
192 [onChangeText, richtext, setAutocompletePrefix],
193 )
194
195 const textDecorated = useMemo(() => {
196 let i = 0
197
198 return Array.from(richtext.segments()).map(segment => {
199 return (
200 <Text
201 key={i++}
202 style={[
203 segment.facet ? pal.link : pal.text,
204 styles.textInputFormatting,
205 ]}>
206 {segment.text}
207 </Text>
208 )
209 })
210 }, [richtext, pal.link, pal.text])
211
212 return (
213 <View style={styles.container}>
214 <PasteInput
215 testID="composerTextInput"
216 ref={textInput}
217 onChangeText={onChangeText}
218 onPaste={onPaste}
219 onSelectionChange={onSelectionChange}
220 placeholder={placeholder}
221 placeholderTextColor={pal.colors.textLight}
222 keyboardAppearance={theme.colorScheme}
223 autoFocus={true}
224 allowFontScaling
225 multiline
226 scrollEnabled={false}
227 numberOfLines={4}
228 style={[
229 pal.text,
230 styles.textInput,
231 styles.textInputFormatting,
232 {textAlignVertical: 'top'},
233 ]}
234 {...props}>
235 {textDecorated}
236 </PasteInput>
237 <Autocomplete
238 prefix={autocompletePrefix}
239 onSelect={onSelectAutocompleteItem}
240 />
241 </View>
242 )
243})
244
245const styles = StyleSheet.create({
246 container: {
247 flex: 1,
248 },
249 textInput: {
250 flex: 1,
251 width: '100%',
252 padding: 5,
253 paddingBottom: 20,
254 marginLeft: 8,
255 alignSelf: 'flex-start',
256 },
257 textInputFormatting: {
258 fontSize: 18,
259 letterSpacing: 0.2,
260 fontWeight: '400',
261 // This is broken on ios right now, so don't set it there.
262 lineHeight: isIOS ? undefined : 23.4, // 1.3*16
263 },
264})