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