forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 💫
1import {useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react'
2import {
3 type TextInput,
4 type TextInputSubmitEditingEvent,
5 View,
6} from 'react-native'
7import Animated, {
8 useAnimatedStyle,
9 useSharedValue,
10} from 'react-native-reanimated'
11import {useSafeAreaInsets} from 'react-native-safe-area-context'
12import {useSift, type UseSiftReturn} from '@bsky.app/sift'
13import {
14 facets,
15 type TapperActiveFacet,
16 type TapperFacet,
17 useTapper,
18} from '@bsky.app/tapper'
19
20import {mergeRefs} from '#/lib/merge-refs'
21import {
22 atoms as a,
23 type TextStyleProp,
24 useAlf,
25 type ViewStyleProp,
26 web,
27} from '#/alf'
28import {normalizeTextStyles} from '#/alf/typography'
29import {
30 Autocomplete as AutocompleteBase,
31 AutocompleteItemEmoji,
32 AutocompleteItemProfile,
33 parseAutocompleteItemType,
34 useAutocomplete,
35} from '#/components/Autocomplete'
36import {
37 AutosizedTextarea,
38 type AutosizedTextareaProps,
39} from '#/components/forms/AutosizedTextarea'
40import {Span, Text} from '#/components/Typography'
41import {IS_IOS, IS_WEB, IS_WEB_TOUCH_DEVICE} from '#/env'
42
43export type SubmitRequest =
44 | {
45 platform: 'web'
46 shiftKey: boolean
47 metaKey: boolean
48 nativeEvent: KeyboardEvent
49 }
50 | {
51 platform: 'native'
52 nativeEvent: TextInputSubmitEditingEvent
53 }
54
55/**
56 * Imperative API exposed via `internalApiRef` prop for parent components that
57 * need to control the composer programmatically, e.g. to clear the input or
58 * insert text at the current cursor position.
59 */
60export type ComposerInternalApi = {
61 input?: ReturnType<typeof useTapper>['input']
62 clear: () => void
63 insert(text: string): void
64 setAutocompleteAnchor: (node: View | null) => void
65}
66
67export function useComposerInternalApiRef() {
68 return useRef<ComposerInternalApi>(null)
69}
70
71/*
72 * ─── Composer ─────────────────────────────────────────────────────────────────
73 */
74
75export type ComposerProps = Omit<
76 AutosizedTextareaProps,
77 | 'value'
78 | 'onChange'
79 | 'onChangeText'
80 | 'onSelectionChange'
81 | 'selection'
82 | 'style'
83 | 'onSubmitEditing'
84> & {
85 label: string
86 ref?: React.RefObject<TextInput>
87 internalApiRef?: React.Ref<ComposerInternalApi>
88 outerStyle?: ViewStyleProp['style']
89 contentTextStyle?: TextStyleProp['style']
90 contentPaddingStyle?: {
91 paddingTop?: number
92 paddingBottom?: number
93 paddingLeft?: number
94 paddingRight?: number
95 }
96 onChange?: (text: string) => void
97 onActiveFacet?: (activeFacet: TapperActiveFacet | null) => void
98 onFacetCommitted?: (facet: TapperFacet) => void
99 onRequestSubmit?: (request: SubmitRequest) => void
100 autocompletePlacement?: Exclude<
101 Parameters<typeof useSift>[0],
102 undefined
103 >['placement']
104 disableEmojiFacets?: boolean
105}
106
107export function Composer({
108 label,
109 ref,
110 internalApiRef,
111 outerStyle,
112 contentTextStyle,
113 contentPaddingStyle,
114 onChange: onChangeOuter,
115 onActiveFacet: onActiveFacetOuter,
116 onFacetCommitted: onFacetCommittedOuter,
117 onRequestSubmit,
118 autocompletePlacement,
119 defaultValue,
120 disableEmojiFacets = !IS_WEB,
121 ...rest
122}: ComposerProps) {
123 const {theme: t, fonts} = useAlf()
124 const insets = useSafeAreaInsets()
125
126 /*
127 * Meat and potatoes
128 */
129 const tapper = useTapper({
130 initialText: defaultValue ?? '',
131 facets: disableEmojiFacets
132 ? {
133 mention: facets.mention,
134 tag: facets.tag,
135 url: facets.url,
136 }
137 : facets,
138 })
139 const sift = useSift({
140 offset: a.p_sm.padding,
141 placement: autocompletePlacement,
142 dynamicWidth: IS_WEB,
143 insets,
144 })
145
146 /*
147 * Active facet state for controlling the visibility of the Autocomplete.
148 */
149 const [activeFacet, setActiveFacet] = useState<TapperActiveFacet | null>(null)
150
151 /*
152 * Reanimated shared value for syncing scroll on all platforms.
153 */
154 const inputScrollSharedValue = useSharedValue(0)
155
156 /*
157 * Expose imperative internal API
158 */
159 useImperativeHandle(
160 internalApiRef,
161 () => ({
162 input: tapper.input,
163 clear: () => {
164 tapper.inputProps.onChangeText('')
165 inputScrollSharedValue.value = 0
166 },
167 insert: tapper.insert,
168 setAutocompleteAnchor: sift.refs.setAnchor,
169 }),
170 [tapper.input, tapper.insert, inputScrollSharedValue, sift.refs.setAnchor],
171 )
172
173 /*
174 * Skip the initial mount to avoid an unnecessary re-render — the parent
175 * already knows the initial value since it passed `initialText`.
176 */
177 const isFirstRender = useRef(true)
178 useEffect(() => {
179 if (isFirstRender.current) {
180 isFirstRender.current = false
181 return
182 }
183 onChangeOuter?.(tapper.state.text)
184 }, [tapper.state.text, onChangeOuter])
185
186 /*
187 * Tapper callbacks
188 */
189 const callbackRefs = useRef({
190 onActiveFacetOuter,
191 onFacetCommittedOuter,
192 })
193 callbackRefs.current = {
194 onActiveFacetOuter,
195 onFacetCommittedOuter,
196 }
197 useEffect(() => {
198 const offActiveFacet = tapper.on('activeFacet', facet => {
199 setActiveFacet(facet)
200 callbackRefs.current.onActiveFacetOuter?.(facet)
201 })
202 const offFacetCommitted = tapper.on('facetCommitted', facet => {
203 callbackRefs.current.onFacetCommittedOuter?.(facet)
204 })
205 const offAfterInsert = tapper.on('afterInsert', () => {
206 tapper.input.focus()
207 })
208 return () => {
209 offActiveFacet()
210 offFacetCommitted()
211 offAfterInsert()
212 }
213 }, [tapper.on, tapper.input])
214
215 /*
216 * Styles
217 */
218 const previewScrollStyle = useAnimatedStyle(() => ({
219 transform: [{translateY: -inputScrollSharedValue.value}],
220 }))
221 const textStyle = useMemo(() => {
222 const ts = normalizeTextStyles(
223 [a.leading_snug, t.atoms.text, contentTextStyle],
224 {
225 fontScale: fonts.scaleMultiplier,
226 fontFamily: fonts.family,
227 flags: {},
228 },
229 )
230 /**
231 * On iOS, having a lineHeight on the Text component causes the text to be
232 * vertically misaligned with the TextInput.
233 *
234 * This only seems to be an issue on iOS, and not on Android or web. It's
235 * possible that this is a bug in React Native's Text component on iOS,
236 * but in the meantime, we'll just remove the lineHeight on iOS to ensure
237 * the text is properly aligned.
238 */
239 if (IS_IOS) {
240 delete ts.lineHeight
241 }
242 return ts
243 }, [contentTextStyle, fonts])
244
245 /*
246 * Web keyboard handling
247 */
248 const isComposing = useRef(false)
249 const onKeyPressWeb = (e: React.KeyboardEvent | any) => {
250 if (IS_WEB_TOUCH_DEVICE) return
251 if (isComposing.current) return
252
253 /*
254 * On Safari, the final keydown to dismiss an IME is also "Enter" with
255 * keyCode 229. Chrome/Firefox don't have this problem.
256 *
257 * @see https://github.com/bluesky-social/social-app/issues/4178
258 */
259 if (e.key === 'Enter' && e.keyCode === 229) return
260
261 if (e.key === 'Enter') {
262 onRequestSubmit?.({
263 platform: 'web',
264 shiftKey: e.shiftKey,
265 metaKey: e.metaKey,
266 nativeEvent: e.nativeEvent,
267 })
268 }
269 }
270
271 /*
272 * Sift popover positioning
273 */
274 const updateAutocompletePosition = () => {
275 sift.updatePosition()
276 }
277
278 const textContent = (
279 <Text style={[textStyle, web({whiteSpace: 'pre-wrap'})]}>
280 {tapper.state.nodes.map((node, i) => {
281 switch (node.type) {
282 case 'text':
283 return <Span key={i}>{node.value}</Span>
284 case 'trigger':
285 case 'facet':
286 return (
287 <Span
288 key={i}
289 ref={IS_WEB ? sift.refs.setAnchor : undefined}
290 style={
291 node.type === 'facet' && {
292 color: t.palette.primary_500,
293 }
294 }>
295 {node.raw}
296 </Span>
297 )
298 }
299 })}
300 </Text>
301 )
302
303 return (
304 <>
305 <View style={[a.relative, outerStyle]}>
306 {IS_WEB && (
307 <View
308 pointerEvents="none"
309 style={[a.absolute, a.inset_0, a.z_10, {overflow: 'hidden'}]}>
310 <Animated.View
311 style={[
312 contentPaddingStyle,
313 {position: 'absolute', left: 0, right: 0},
314 previewScrollStyle,
315 ]}>
316 {textContent}
317 </Animated.View>
318 </View>
319 )}
320 <AutosizedTextarea
321 placeholderTextColor={t.palette.contrast_500}
322 accessibilityLabel={label}
323 accessibilityHint={label}
324 onSubmitEditing={e => {
325 onRequestSubmit?.({platform: 'native', nativeEvent: e})
326 }}
327 style={[
328 textStyle,
329 contentPaddingStyle,
330 a.z_20,
331 {
332 color: 'transparent',
333 background: 'transparent',
334 },
335 web({
336 caretColor: textStyle.color ?? 'black',
337 overscrollBehavior: 'none',
338 }),
339 ]}
340 {...rest}
341 {...tapper.inputProps}
342 {...sift.targetProps}
343 ref={mergeRefs([ref, tapper.inputProps.ref, sift.targetProps.ref])}
344 onBlur={e => {
345 rest.onBlur?.(e)
346 setActiveFacet(null)
347 }}
348 onKeyPress={IS_WEB ? onKeyPressWeb : undefined}
349 onScroll={e => {
350 if (IS_WEB) {
351 inputScrollSharedValue.value = (e.target as any).scrollTop
352 } else {
353 inputScrollSharedValue.value = e.nativeEvent.contentOffset.y
354 }
355 }}
356 // @ts-ignore web only
357 onCompositionStart={() => {
358 isComposing.current = true
359 }}
360 // @ts-ignore web only
361 onCompositionEnd={() => {
362 isComposing.current = false
363 }}
364 onUpdateHeight={updateAutocompletePosition}>
365 {IS_WEB ? null : textContent}
366 </AutosizedTextarea>
367 </View>
368
369 {activeFacet && activeFacet.type !== 'url' && (
370 <AutocompleteInner
371 sift={sift}
372 activeFacet={activeFacet}
373 onDismiss={() => setActiveFacet(null)}
374 />
375 )}
376 </>
377 )
378}
379
380/*
381 * ─── Autocomplete (private) ───────────────────────────────────────────────────
382 */
383
384function AutocompleteInner({
385 sift,
386 activeFacet,
387 onDismiss,
388}: {
389 sift: UseSiftReturn
390 activeFacet: TapperActiveFacet
391 onDismiss: () => void
392}) {
393 const {items} = useAutocomplete({
394 type: parseAutocompleteItemType(activeFacet.type),
395 query: activeFacet.value,
396 })
397
398 useEffect(() => {
399 if (
400 activeFacet?.type === 'emoji' &&
401 !!activeFacet.value.length &&
402 activeFacet.raw.endsWith(':')
403 ) {
404 if (items?.[0]) {
405 activeFacet.replace(items[0].value, {noTrailingSpace: true})
406 onDismiss()
407 }
408 }
409 }, [items, activeFacet])
410
411 return items && items.length ? (
412 <AutocompleteBase
413 inverted={!IS_WEB}
414 sift={sift}
415 data={items}
416 render={props => {
417 if (props.item.type === 'profile') {
418 return <AutocompleteItemProfile {...props} />
419 }
420 if (props.item.type === 'emoji') {
421 return <AutocompleteItemEmoji {...props} />
422 }
423 return <View />
424 }}
425 onSelect={item => {
426 activeFacet.replace(item.value)
427 onDismiss()
428 }}
429 onDismiss={onDismiss}
430 />
431 ) : null
432}