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