import { useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState, } from 'react' import { Pressable, TextInput, type TextInputProps, type TextInputSubmitEditingEvent, View, } from 'react-native' import Animated, { useAnimatedStyle, useSharedValue, } from 'react-native-reanimated' import {Sift, SiftItem, useSift} from '@bsky.app/sift' import { type TapperActiveFacet, type TapperFacet, useTapper, } from '@bsky.app/tapper' import {useLingui} from '@lingui/react/macro' import {countGraphemes} from 'unicode-segmenter/grapheme' import {HITSLOP_10, MAX_DM_GRAPHEME_LENGTH} from '#/lib/constants' import {useHaptics} from '#/lib/haptics' import {mergeRefs} from '#/lib/merge-refs' import {isBskyPostUrl} from '#/lib/strings/url-helpers' import {useEmail} from '#/state/email-verification' import { useMessageDraft, useSaveMessageDraft, } from '#/state/messages/message-drafts' import {textInputWebEmitter} from '#/view/com/composer/text-input/textInputWebEmitter' import { type Emoji, EmojiPicker, type EmojiPickerState, } from '#/view/com/composer/text-input/web/EmojiPicker' import { atoms as a, extractPadding, type TextStyleProp, useAlf, useTheme, type ViewStyleProp, web, } from '#/alf' import {normalizeTextStyles} from '#/alf/typography' import {useInteractionState} from '#/components/hooks/useInteractionState' import {useOnKeyboard} from '#/components/hooks/useOnKeyboard' import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmile} from '#/components/icons/Emoji' import {PaperPlane_Stroke2_Corner0_Rounded as PaperPlane} from '#/components/icons/PaperPlane' import {Portal} from '#/components/Portal' import * as Toast from '#/components/Toast' import {Span, Text} from '#/components/Typography' import {IS_WEB, IS_WEB_TOUCH_DEVICE} from '#/env' export type SubmitRequest = | { platform: 'web' shiftKey: boolean metaKey: boolean nativeEvent: KeyboardEvent } | { platform: 'native' nativeEvent: TextInputSubmitEditingEvent } /** * Bail-out API for special cases where a parent component needs to * imperatively control the Composer (e.g. clearing the input on submit). * Prefer props/callbacks for normal data flow. */ export type ComposerInternalApi = { input?: ReturnType['input'] clear: () => void insert(text: string): void } export function useComposerInternalApiRef() { return useRef(null) } export type ComposerProps = Omit< TextInputProps, 'value' | 'onSelectionChange' | 'selection' | 'style' | 'onSubmitEditing' > & { /** * Required a11y label, used for accessibilityHint as well unless that prop is specified. */ label: string /** * Optional forwarded ref. */ ref?: React.Ref /** * Styles applied to the input container. To style the text, use the * `textStyle` prop. */ style?: ViewStyleProp['style'] /** * Padding applied to the `TextInput` and the facet preview container. */ padding?: Parameters[0] /** * Shared text style applied to both the preview overlay and the input. * Must match exactly for pixel-perfect alignment. */ textStyle?: TextStyleProp['style'] /** * Sets a default height on the input, but still allows for expansion */ initialNumberOfLines?: number /** * Sets the max height on the input */ maxNumberOfLines?: number /** * When a facet is active (e.g. the user is typing after a trigger), this callbacks is called with the active facet info. When the facet is committed (e.g. the user selects an autocomplete suggestion or finishes typing), the `onFacetCommitted` callback is called with the committed facet info. */ onActiveFacet?: (activeFacet: TapperActiveFacet | null) => void /** * Called when a facet is committed, either by selecting an autocomplete suggestion or by finishing typing. The committed facet info is passed as an argument. */ onFacetCommitted?: (facet: TapperFacet) => void /** * Called when the user presses Enter on web. Includes modifier key state * and the native event for calling `preventDefault()`. On native, fired * from a submit button press. */ onRequestSubmit?: (request: SubmitRequest) => void /** * Ref to the internal imperative API. See {@link ComposerInternalApi}. */ internalApiRef?: React.Ref } function Composer({ children, label, placeholder, defaultValue, style, padding, textStyle: rawTextStyle, initialNumberOfLines = 1, maxNumberOfLines, onChangeText: onChangeTextOuter, onActiveFacet: onActiveFacetOuter, onFacetCommitted: onFacetCommittedOuter, internalApiRef, onRequestSubmit, ...rest }: ComposerProps) { const {theme: t, fonts} = useAlf() const tapper = useTapper({ initialText: defaultValue, }) const callbackRefs = useRef({ onActiveFacetOuter, onFacetCommittedOuter, }) callbackRefs.current = { onActiveFacetOuter, onFacetCommittedOuter, } const scrollY = useSharedValue(0) useImperativeHandle( internalApiRef, () => ({ input: tapper.input, clear: () => { tapper.inputProps.onChangeText('') scrollY.value = 0 }, insert: tapper.insert, }), [tapper.inputProps, tapper.input, tapper.insert, scrollY], ) const [activeFacet, setActiveFacet] = useState(null) const sift = useSift({ offset: a.p_sm.padding, placement: 'top-start', dynamicWidth: IS_WEB, }) /* * Skip the initial mount to avoid an unnecessary re-render — the parent * already knows the initial value since it passed `defaultValue`. */ const isFirstRender = useRef(true) useEffect(() => { if (isFirstRender.current) { isFirstRender.current = false return } onChangeTextOuter?.(tapper.state.text) }, [tapper.state.text, onChangeTextOuter]) useEffect(() => { const offActiveFacet = tapper.on('activeFacet', activeFacet => { setActiveFacet(activeFacet) callbackRefs.current.onActiveFacetOuter?.(activeFacet) }) const offFacetCommitted = tapper.on('facetCommitted', facet => { callbackRefs.current.onFacetCommittedOuter?.(facet) }) return () => { offActiveFacet() offFacetCommitted() } }, []) const {textStyle, textAreaStyle, minHeight, maxHeight} = useMemo(() => { const textStyle = normalizeTextStyles( [a.leading_snug, rawTextStyle, t.atoms.text], { fontScale: fonts.scaleMultiplier, fontFamily: fonts.family, flags: {}, }, ) const p = padding ? extractPadding(padding) : { paddingTop: 0, paddingBottom: 0, } const lineHeight = textStyle.lineHeight || 20 const verticalSpace = p.paddingTop + p.paddingBottom const minHeight = lineHeight * initialNumberOfLines + verticalSpace const maxHeight = maxNumberOfLines ? lineHeight * maxNumberOfLines + verticalSpace : 999 const textAreaStyle = IS_WEB ? { height: (textStyle.lineHeight || 20) + p.paddingTop + p.paddingBottom, } : {minHeight, maxHeight} /* * On iOS especially, TextInput and Text line height does not render the * same way, but setting this to undefined and using the default font * metrics works fine. */ if (!IS_WEB) { // disabled for now to eval the text as children // delete textStyle.lineHeight } return { textStyle, textAreaStyle, minHeight, maxHeight, } }, [t, fonts, padding, rawTextStyle, initialNumberOfLines, maxNumberOfLines]) const updateAutocompletePosition = useCallback(() => { sift.updatePosition() }, [sift]) useOnKeyboard('keyboardDidShow', updateAutocompletePosition) useOnKeyboard('keyboardDidHide', updateAutocompletePosition) const prevHeight = useRef(0) useEffect(() => { if (IS_WEB) { const el = tapper.input.element as unknown as HTMLTextAreaElement if (!el) return el.style.height = '0px' const scrollHeight = el.scrollHeight const nextHeight = Math.min(Math.max(scrollHeight, minHeight), maxHeight) el.style.height = `${nextHeight}px` el.style.overflowY = scrollHeight > maxHeight ? 'auto' : 'hidden' if (nextHeight !== prevHeight.current) { prevHeight.current = nextHeight updateAutocompletePosition() } return } tapper.input.element?.measure((_x, _y, _w, h) => { if (h !== prevHeight.current) { prevHeight.current = h updateAutocompletePosition() } }) }, [tapper.state.text, minHeight, maxHeight, updateAutocompletePosition]) const previewScrollStyle = useAnimatedStyle(() => ({ transform: [{translateY: -scrollY.value}], })) const isComposing = useRef(false) const onKeyPressWeb = useCallback( (e: React.KeyboardEvent | any) => { /* * On mobile web phones, we want to keep the same behavior as the native * app. Do not submit the message in these cases. */ if (IS_WEB_TOUCH_DEVICE) return // Don't submit the form when the Japanese or any other IME is composing if (isComposing.current) return /** * On Safari, the final keydown event to dismiss the IME - which is the * enter key - is also "Enter" below. Obviously, this causes problems * because the final dismissal should _not_ submit the text, but should * just stop the IME editing. This is the behavior of Chrome and Firefox, * but not Safari. Keycode is deprecated, however the alternative seems * to only be to compare the timestamp from the onCompositionEnd event to * the timestamp of the keydown event, which is not reliable. For * example, this hack uses that method: * https://github.com/ProseMirror/prosemirror-view/pull/44. However, from * my 500ms resulted in far too long of a delay, and a subsequent enter * press would often just end up doing nothing. A shorter time frame was * also not great, since it was too short to be reliable (i.e. an older * system might have a larger time gap between the two events firing. * * @see https://github.com/bluesky-social/social-app/issues/4178 * @see https://www.stum.de/2016/06/24/handling-ime-events-in-javascript/ * @see https://lists.w3.org/Archives/Public/www-dom/2010JulSep/att-0182/keyCode-spec.html */ if (IS_WEB && e.key === 'Enter' && e.keyCode === 229) { return } if (e.key === 'Enter') { onRequestSubmit?.({ platform: 'web', shiftKey: e.shiftKey, metaKey: e.metaKey, nativeEvent: e.nativeEvent, }) } }, [onRequestSubmit], ) const textContent = ( {tapper.state.nodes.map((node, i) => { switch (node.type) { case 'text': { return {node.value} } case 'trigger': case 'facet': { return ( {node.raw} ) } } })} ) return ( <> {IS_WEB && ( {textContent} )} { onRequestSubmit?.({platform: 'native', nativeEvent: e}) }} style={[ textStyle, padding, a.relative, a.z_20, a.border_0, { color: 'transparent', background: 'transparent', textAlignVertical: 'top', includeFontPadding: false, }, textAreaStyle, web({ resize: 'none', outline: 'none', caretColor: textStyle.color ?? 'black', whiteSpace: 'pre-wrap', wordBreak: 'break-word', overscrollBehavior: 'none', ...textAreaStyle, }), ]} {...rest} {...tapper.inputProps} value={undefined} {...sift.targetProps} ref={mergeRefs([ rest.ref, tapper.inputProps.ref, sift.targetProps.ref, ])} onBlur={e => { rest.onBlur?.(e) setActiveFacet(null) }} onKeyPress={IS_WEB ? onKeyPressWeb : undefined} onScroll={e => { if (IS_WEB) { scrollY.value = (e.target as any).scrollTop } else { scrollY.value = e.nativeEvent.contentOffset.y } }} // @ts-ignore web only onCompositionStart={() => { isComposing.current = true }} // @ts-ignore web only onCompositionEnd={() => { isComposing.current = false }}> {IS_WEB ? null : textContent} {children} {activeFacet && ( { activeFacet?.replace(item.value) }} onDismiss={() => setActiveFacet(null)} style={[ a.overflow_hidden, a.rounded_md, a.border, t.atoms.border_contrast_low, t.atoms.bg, !IS_WEB && a.w_full, ]} render={({active, props, item}) => ( [ a.px_md, a.py_sm, (active || s.hovered) && t.atoms.bg_contrast_50, ]}> {item.label} )} /> )} ) } export function MessageComposer({ onSendMessage, hasEmbed, setEmbed, children, }: { onSendMessage: (message: string) => void hasEmbed: boolean setEmbed: (embedUrl: string | undefined) => void children?: React.ReactNode }) { const t = useTheme() const {t: l} = useLingui() const playHaptic = useHaptics() const {needsEmailVerification} = useEmail() const editable = !needsEmailVerification const {getDraft, clearDraft} = useMessageDraft() const [emojiPickerState, setEmojiPickerState] = useState({ isOpen: false, pos: {top: 0, left: 0, right: 0, bottom: 0, nextFocusRef: null}, }) const composerInternalApiRef = useComposerInternalApiRef() const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState() const { state: hovered, onIn: onHoverIn, onOut: onHoverOut, } = useInteractionState() const [text, setText] = useState(getDraft) useSaveMessageDraft(text) const openEmojiPicker = useCallback((pos: any) => { setEmojiPickerState({isOpen: true, pos}) }, []) const onSubmit = useCallback(() => { if (!editable) return if (!hasEmbed && text.trim() === '') return if (countGraphemes(text) > MAX_DM_GRAPHEME_LENGTH) { Toast.show(l`Message is too long`, { type: 'error', }) return } clearDraft() onSendMessage(text) playHaptic() setEmbed(undefined) composerInternalApiRef.current?.clear() if (IS_WEB) { composerInternalApiRef.current?.input?.focus() } }, [ l, editable, hasEmbed, text, clearDraft, onSendMessage, playHaptic, setEmbed, composerInternalApiRef, ]) useEffect(() => { function onEmojiInserted(emoji: Emoji) { composerInternalApiRef.current?.insert(emoji.native) } textInputWebEmitter.addListener('emoji-inserted', onEmojiInserted) return () => { textInputWebEmitter.removeListener('emoji-inserted', onEmojiInserted) } }, []) return ( <> {children} { if (facet.type === 'url' && isBskyPostUrl(facet.value)) { setEmbed(facet.value) } }} onRequestSubmit={req => { if (req.platform === 'web' && req.shiftKey) return req.nativeEvent.preventDefault() onSubmit() }}> {IS_WEB && ( { e.currentTarget.measure( (_fx, _fy, _width, _height, px, py) => { openEmojiPicker?.({ top: py, left: px, right: px, bottom: py, nextFocusRef: { current: composerInternalApiRef.current?.input?.element, }, }) }, ) }} style={[ a.overflow_hidden, a.absolute, a.rounded_full, a.align_center, a.justify_center, a.z_30, { height: 30, width: 30, top: 7, left: 7, }, ]} accessibilityLabel={l`Open emoji picker`} accessibilityHint=""> {state => ( )} )} {IS_WEB && ( setEmojiPickerState(prev => ({...prev, isOpen: false}))} /> )} ) }