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,
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, utils} 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
79 const markdownFacets: AppBskyRichtextFacet.Main[] = []
80 const regex = /\[([^\]]+)\]\s*\(([^)]+)\)/g
81 let match
82 while ((match = regex.exec(newText)) !== null) {
83 const [fullMatch, _linkText, linkUrl] = match
84 const matchStart = match.index
85 const matchEnd = matchStart + fullMatch.length
86 const prefix = newText.slice(0, matchStart)
87 const matchStr = newText.slice(matchStart, matchEnd)
88 const byteStart = new UnicodeString(prefix).length
89 const byteEnd = byteStart + new UnicodeString(matchStr).length
90
91 let validUrl = linkUrl
92 if (
93 !validUrl.startsWith('http://') &&
94 !validUrl.startsWith('https://') &&
95 !validUrl.startsWith('mailto:')
96 ) {
97 validUrl = `https://${validUrl}`
98 }
99
100 markdownFacets.push({
101 index: {byteStart, byteEnd},
102 features: [{$type: 'app.bsky.richtext.facet#link', uri: validUrl}],
103 })
104 }
105
106 if (markdownFacets.length > 0) {
107 const nonOverlapping = (newRt.facets || []).filter(f => {
108 return !markdownFacets.some(mf => {
109 return (
110 (f.index.byteStart >= mf.index.byteStart &&
111 f.index.byteStart < mf.index.byteEnd) ||
112 (f.index.byteEnd > mf.index.byteStart &&
113 f.index.byteEnd <= mf.index.byteEnd) ||
114 (mf.index.byteStart >= f.index.byteStart &&
115 mf.index.byteStart < f.index.byteEnd)
116 )
117 })
118 })
119 newRt.facets = [...nonOverlapping, ...markdownFacets].sort(
120 (a, b) => a.index.byteStart - b.index.byteStart,
121 )
122 }
123
124 setRichText(newRt)
125
126 // NOTE: BinaryFiddler
127 // onChangeText happens before onSelectionChange, cursorPos is out of bound if the user deletes characters,
128 const cursorPos = textInputSelection.current?.start ?? 0
129 const prefix = getMentionAt(newText, Math.min(cursorPos, newText.length))
130
131 if (prefix) {
132 setAutocompletePrefix(prefix.value)
133 } else if (autocompletePrefix) {
134 setAutocompletePrefix('')
135 }
136
137 const nextDetectedUris = new Map<string, LinkFacetMatch>()
138 if (newRt.facets) {
139 for (const facet of newRt.facets) {
140 for (const feature of facet.features) {
141 if (AppBskyRichtextFacet.isLink(feature)) {
142 if (isUriImage(feature.uri)) {
143 const res = await downloadAndResize({
144 uri: feature.uri,
145 width: POST_IMG_MAX.width,
146 height: POST_IMG_MAX.height,
147 mode: 'contain',
148 maxSize: POST_IMG_MAX.size,
149 timeout: 15e3,
150 })
151
152 if (res !== undefined) {
153 onPhotoPasted(res.path)
154 }
155 } else {
156 nextDetectedUris.set(feature.uri, {facet, rt: newRt})
157 }
158 }
159 }
160 }
161 }
162 const suggestedUri = suggestLinkCardUri(
163 mayBePaste,
164 nextDetectedUris,
165 prevDetectedUris.current,
166 pastSuggestedUris.current,
167 )
168 prevDetectedUris.current = nextDetectedUris
169 if (suggestedUri) {
170 onNewLink(suggestedUri)
171 }
172 prevLength.current = newText.length
173 },
174 [setRichText, autocompletePrefix, onPhotoPasted, onNewLink],
175 )
176
177 const onPaste = useCallback(
178 async (err: string | undefined, files: PastedFile[]) => {
179 if (err) {
180 return onError(cleanError(err))
181 }
182
183 const uris = files.map(f => f.uri)
184 const uri = uris.find(isUriImage)
185
186 if (uri) {
187 onPhotoPasted(uri)
188 }
189 },
190 [onError, onPhotoPasted],
191 )
192
193 const onSelectionChange = useCallback(
194 (evt: NativeSyntheticEvent<TextInputSelectionChangeEventData>) => {
195 // NOTE we track the input selection using a ref to avoid excessive renders -prf
196 textInputSelection.current = evt.nativeEvent.selection
197 },
198 [textInputSelection],
199 )
200
201 const onSelectAutocompleteItem = useCallback(
202 (item: string) => {
203 onChangeText(
204 insertMentionAt(
205 richtext.text,
206 textInputSelection.current?.start || 0,
207 item,
208 ),
209 )
210 setAutocompletePrefix('')
211 },
212 [onChangeText, richtext, setAutocompletePrefix],
213 )
214
215 const inputTextStyle = useMemo(() => {
216 const style = normalizeTextStyles(
217 [a.text_lg, a.leading_snug, t.atoms.text],
218 {
219 fontScale: fonts.scaleMultiplier,
220 fontFamily: fonts.family,
221 flags: {},
222 },
223 )
224
225 /**
226 * PasteInput doesn't like `lineHeight`, results in jumpiness
227 */
228 if (IS_NATIVE) {
229 style.lineHeight = undefined
230 }
231
232 /*
233 * Android impl of `PasteInput` doesn't support the array syntax for `fontVariant`
234 */
235 if (IS_ANDROID) {
236 // @ts-ignore
237 style.fontVariant = style.fontVariant
238 ? style.fontVariant.join(' ')
239 : undefined
240 }
241 return style
242 }, [t, fonts])
243
244 const textDecorated = useMemo(() => {
245 let i = 0
246
247 return Array.from(richtext.segments()).map(segment => {
248 return (
249 <RNText
250 key={i++}
251 style={[
252 inputTextStyle,
253 {
254 color: segment.facet ? t.palette.primary_500 : t.atoms.text.color,
255 marginTop: -1,
256 },
257 ]}>
258 {segment.text}
259 </RNText>
260 )
261 })
262 }, [t, richtext, inputTextStyle])
263
264 return (
265 <View style={[a.flex_1, a.pl_md, hasRightPadding && a.pr_4xl]}>
266 <PasteInput
267 testID="composerTextInput"
268 ref={textInput}
269 onChangeText={onChangeText}
270 onPaste={onPaste}
271 onSelectionChange={onSelectionChange}
272 placeholder={placeholder}
273 placeholderTextColor={t.atoms.text_contrast_low.color}
274 keyboardAppearance={theme.colorScheme}
275 autoFocus={props.autoFocus !== undefined ? props.autoFocus : true}
276 allowFontScaling
277 multiline
278 scrollEnabled={false}
279 numberOfLines={2}
280 // Note: should be the default value, but as of v1.104
281 // it switched to "none" on Android
282 autoCapitalize="sentences"
283 selectionColor={utils.alpha(t.palette.primary_500, 0.4)}
284 cursorColor={t.palette.primary_500}
285 selectionHandleColor={t.palette.primary_500}
286 {...props}
287 style={[
288 inputTextStyle,
289 a.w_full,
290 !autocompletePrefix && a.h_full,
291 {
292 textAlignVertical: 'top',
293 minHeight: 60,
294 includeFontPadding: false,
295 },
296 {
297 borderWidth: 1,
298 borderColor: 'transparent',
299 },
300 props.style,
301 ]}>
302 {textDecorated}
303 </PasteInput>
304 <Autocomplete
305 prefix={autocompletePrefix}
306 onSelect={onSelectAutocompleteItem}
307 />
308 </View>
309 )
310}