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