diff --git a/src/components/Composer/index.tsx b/src/components/Composer/index.tsx index 590816ea23..5e6fd8e85f 100644 --- a/src/components/Composer/index.tsx +++ b/src/components/Composer/index.tsx @@ -1,7 +1,5 @@ import { - createContext, useCallback, - useContext, useEffect, useImperativeHandle, useMemo, @@ -15,7 +13,6 @@ import { View, } from 'react-native' import Animated, { - type SharedValue, useAnimatedStyle, useSharedValue, } from 'react-native-reanimated' @@ -23,7 +20,6 @@ import {useSift, type UseSiftReturn} from '@bsky.app/sift' import { type TapperActiveFacet, type TapperFacet, - type TapperSnapshot, useTapper, } from '@bsky.app/tapper' @@ -80,57 +76,27 @@ export function useComposerInternalApiRef() { } /* - * ─── Contexts ───────────────────────────────────────────────────────────────── + * ─── Composer ───────────────────────────────────────────────────────────────── */ -type ComposerContextValue = { - tapper: { - on: ReturnType['on'] - insert: ReturnType['insert'] - input: ReturnType['input'] - inputProps: ReturnType['inputProps'] - } - sift: UseSiftReturn - inputScrollSharedValue: SharedValue - onRequestSubmit?: (request: SubmitRequest) => void -} - -const ComposerContext = createContext(null) -ComposerContext.displayName = 'ComposerContext' - -export function useComposerContext() { - const ctx = useContext(ComposerContext) - if (!ctx) { - throw new Error('useComposerContext must be used within a Composer.Root') - } - return ctx -} - -type ComposerStateContextValue = { - state: TapperSnapshot -} - -const ComposerStateContext = createContext( - null, -) -ComposerStateContext.displayName = 'ComposerStateContext' - -export function useComposerStateContext() { - const ctx = useContext(ComposerStateContext) - if (!ctx) { - throw new Error( - 'useComposerStateContext must be used within a Composer.Root', - ) - } - return ctx -} - -/* - * ─── Root ───────────────────────────────────────────────────────────────────── - */ - -export type RootProps = { - children: React.ReactNode +export type ComposerProps = Omit< + TextInputProps, + | 'value' + | 'onChange' + | 'onChangeText' + | 'onSelectionChange' + | 'selection' + | 'style' + | 'onSubmitEditing' +> & { + children?: React.ReactNode + label: string + ref?: React.Ref + style?: ViewStyleProp['style'] + padding?: Parameters[0] + textStyle?: TextStyleProp['style'] + initialNumberOfLines?: number + maxNumberOfLines?: number initialText?: string onChange?: (text: string) => void onActiveFacet?: (activeFacet: TapperActiveFacet | null) => void @@ -139,34 +105,42 @@ export type RootProps = { internalApiRef?: React.Ref } -export function Root({ +export function Composer({ children, + label, + placeholder, + style, + padding, + textStyle: rawTextStyle, + initialNumberOfLines = 1, + maxNumberOfLines, initialText, onChange: onChangeOuter, onActiveFacet: onActiveFacetOuter, onFacetCommitted: onFacetCommittedOuter, onRequestSubmit, internalApiRef, -}: RootProps) { - const tapper = useTapper({ - initialText, - }) + ...rest +}: ComposerProps) { + const {theme: t, fonts} = useAlf() + const textInputRef = useRef(null) + + const tapper = useTapper({initialText}) const sift = useSift({ offset: a.p_sm.padding, placement: 'top-start', dynamicWidth: IS_WEB, }) const inputScrollSharedValue = useSharedValue(0) + const [activeFacet, setActiveFacet] = useState(null) const callbackRefs = useRef({ onActiveFacetOuter, onFacetCommittedOuter, - focus: tapper.input.focus, }) callbackRefs.current = { onActiveFacetOuter, onFacetCommittedOuter, - focus: tapper.input.focus, } useImperativeHandle( @@ -197,95 +171,23 @@ export function Root({ useEffect(() => { const offActiveFacet = tapper.on('activeFacet', facet => { + setActiveFacet(facet) callbackRefs.current.onActiveFacetOuter?.(facet) }) const offFacetCommitted = tapper.on('facetCommitted', facet => { callbackRefs.current.onFacetCommittedOuter?.(facet) }) const offAfterInsert = tapper.on('afterInsert', () => { - callbackRefs.current?.focus() + tapper.input.focus() }) return () => { offActiveFacet() offFacetCommitted() offAfterInsert() } - }, [tapper.on]) + }, [tapper.on, tapper.input]) - const composerCtx = useMemo( - () => ({ - tapper: { - on: tapper.on, - insert: tapper.insert, - input: tapper.input, - inputProps: tapper.inputProps, - }, - sift, - inputScrollSharedValue, - onRequestSubmit, - }), - [ - tapper.on, - tapper.insert, - tapper.input, - tapper.inputProps, - sift, - inputScrollSharedValue, - onRequestSubmit, - ], - ) - - const stateCtx = useMemo( - () => ({state: tapper.state}), - [tapper.state], - ) - - return ( - - - {children} - - - ) -} - -/* - * ─── Input ──────────────────────────────────────────────────────────────────── - */ - -export type InputProps = Omit< - TextInputProps, - | 'value' - | 'onChangeText' - | 'onSelectionChange' - | 'selection' - | 'style' - | 'onSubmitEditing' -> & { - label: string - ref?: React.Ref - style?: ViewStyleProp['style'] - padding?: Parameters[0] - textStyle?: TextStyleProp['style'] - initialNumberOfLines?: number - maxNumberOfLines?: number -} - -export function Input({ - label, - placeholder, - style, - padding, - textStyle: rawTextStyle, - initialNumberOfLines = 1, - maxNumberOfLines, - ...rest -}: InputProps) { - const {theme: t, fonts} = useAlf() - const {tapper, sift, inputScrollSharedValue, onRequestSubmit} = - useComposerContext() - const {state} = useComposerStateContext() - const textInputRef = useRef(null) + // ─── Text style computation ─────────────────────────────────────────── const {textStyle, textAreaStyle, minHeight, maxHeight} = useMemo(() => { const ts = normalizeTextStyles( @@ -309,11 +211,6 @@ export function Input({ ? {height: lineHeight + verticalSpace} : {minHeight: mh, maxHeight: xh} - /* - * 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) { delete ts.lineHeight } @@ -321,6 +218,15 @@ export function Input({ return {textStyle: ts, textAreaStyle: tas, minHeight: mh, maxHeight: xh} }, [t, fonts, padding, rawTextStyle, initialNumberOfLines, maxNumberOfLines]) + // ─── Height auto-resize + sift positioning ──────────────────────────── + + const updateAutocompletePosition = useCallback(() => { + sift.updatePosition() + }, [sift]) + + useOnKeyboard('keyboardDidShow', updateAutocompletePosition) + useOnKeyboard('keyboardDidHide', updateAutocompletePosition) + const prevHeight = useRef(0) useEffect(() => { if (IS_WEB) { @@ -333,7 +239,7 @@ export function Input({ el.style.overflowY = scrollHeight > maxHeight ? 'auto' : 'hidden' if (nextHeight !== prevHeight.current) { prevHeight.current = nextHeight - sift.updatePosition() + updateAutocompletePosition() } return } @@ -341,15 +247,19 @@ export function Input({ textInputRef.current?.measure((_x, _y, _w, h) => { if (h !== prevHeight.current) { prevHeight.current = h - sift.updatePosition() + updateAutocompletePosition() } }) - }, [state.text, minHeight, maxHeight, sift]) + }, [tapper.state.text, minHeight, maxHeight, updateAutocompletePosition]) + + // ─── Scroll sync ────────────────────────────────────────────────────── const previewScrollStyle = useAnimatedStyle(() => ({ transform: [{translateY: -inputScrollSharedValue.value}], })) + // ─── Web keyboard handling ──────────────────────────────────────────── + const isComposing = useRef(false) const onKeyPressWeb = useCallback( (e: React.KeyboardEvent | any) => { @@ -377,137 +287,126 @@ export function Input({ ) return ( - - - + + + + + {tapper.state.nodes.map((node, i) => { + switch (node.type) { + case 'text': + return {node.value} + case 'trigger': + case 'facet': + return ( + + {node.raw} + + ) + } + })} + + + + { + onRequestSubmit?.({platform: 'native', nativeEvent: e}) + }} style={[ + textStyle, padding, - {position: 'absolute', left: 0, right: 0}, - previewScrollStyle, - ]}> - - {state.nodes.map((node, i) => { - switch (node.type) { - case 'text': - return {node.value} - case 'trigger': - case 'facet': - return ( - - {node.raw} - - ) - } - })} - - + 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} + {...sift.targetProps} + ref={mergeRefs([ + textInputRef, + rest.ref, + tapper.inputProps.ref, + sift.targetProps.ref, + ])} + onBlur={e => { + rest.onBlur?.(e) + }} + onKeyPress={IS_WEB ? onKeyPressWeb : undefined} + onScroll={e => { + if (IS_WEB) { + inputScrollSharedValue.value = (e.target as any).scrollTop + } else { + inputScrollSharedValue.value = e.nativeEvent.contentOffset.y + } + }} + // @ts-ignore web only + onCompositionStart={() => { + isComposing.current = true + }} + // @ts-ignore web only + onCompositionEnd={() => { + isComposing.current = false + }} + /> + + {children} - { - 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} - {...sift.targetProps} - ref={mergeRefs([ - textInputRef, - rest.ref, - tapper.inputProps.ref, - sift.targetProps.ref, - ])} - onBlur={e => { - rest.onBlur?.(e) - }} - onKeyPress={IS_WEB ? onKeyPressWeb : undefined} - onScroll={e => { - if (IS_WEB) { - inputScrollSharedValue.value = (e.target as any).scrollTop - } else { - inputScrollSharedValue.value = e.nativeEvent.contentOffset.y - } - }} - // @ts-ignore web only - onCompositionStart={() => { - isComposing.current = true - }} - // @ts-ignore web only - onCompositionEnd={() => { - isComposing.current = false - }} - /> - + + {activeFacet && ( + setActiveFacet(null)} + /> + )} + ) } -export function Autocomplete() { - const {tapper, sift} = useComposerContext() - const [activeFacet, setActiveFacet] = useState(null) - - useEffect(() => { - const off = tapper.on('activeFacet', facet => { - setActiveFacet(facet) - }) - return off - }, [tapper.on]) - - const updatePosition = useCallback(() => { - sift.updatePosition() - }, [sift]) - - useOnKeyboard('keyboardDidShow', updatePosition) - useOnKeyboard('keyboardDidHide', updatePosition) - - if (!activeFacet) return null - - return ( - setActiveFacet(null)} - /> - ) -} +/* + * ─── Autocomplete (private) ─────────────────────────────────────────────────── + */ function AutocompleteInner({ sift, @@ -523,6 +422,13 @@ function AutocompleteInner({ query: activeFacet.value, }) + const updatePosition = useCallback(() => { + sift.updatePosition() + }, [sift]) + + useOnKeyboard('keyboardDidShow', updatePosition) + useOnKeyboard('keyboardDidHide', updatePosition) + return data && data.length ? ( { activeFacet.replace(item.value) + onDismiss() }} onDismiss={onDismiss} /> diff --git a/src/screens/Messages/components/MessageComposer.tsx b/src/screens/Messages/components/MessageComposer.tsx index 9ee5896426..48a557bd70 100644 --- a/src/screens/Messages/components/MessageComposer.tsx +++ b/src/screens/Messages/components/MessageComposer.tsx @@ -18,7 +18,7 @@ import { type EmojiPickerState, } from '#/view/com/composer/text-input/web/EmojiPicker' import {atoms as a, useTheme} from '#/alf' -import * as Composer from '#/components/Composer' +import {Composer, useComposerInternalApiRef} from '#/components/Composer' import {useInteractionState} from '#/components/hooks/useInteractionState' import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmile} from '#/components/icons/Emoji' import {PaperPlane_Stroke2_Corner0_Rounded as PaperPlane} from '#/components/icons/PaperPlane' @@ -46,7 +46,7 @@ export function MessageComposer({ isOpen: false, pos: {top: 0, left: 0, right: 0, bottom: 0, nextFocusRef: null}, }) - const composerInternalApiRef = Composer.useComposerInternalApiRef() + const composerInternalApiRef = useComposerInternalApiRef() const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState() const { @@ -107,62 +107,59 @@ export function MessageComposer({ <> {children} - { - if (facet.type === 'url' && isBskyPostUrl(facet.value)) { - setEmbed(facet.value) - } - }} - onRequestSubmit={req => { - if (req.platform === 'web' && req.shiftKey) return - req.nativeEvent.preventDefault() - onSubmit() - }}> - - + - + ]} + padding={[ + a.p_md, + { + paddingRight: 35 + a.p_sm.padding, + }, + IS_WEB + ? { + paddingLeft: 30 + a.p_sm.padding, + } + : {}, + ]} + textStyle={[a.text_md, a.leading_snug]} + onFocus={onFocus} + onBlur={onBlur} + onFacetCommitted={facet => { + 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 && ( { @@ -242,10 +239,8 @@ export function MessageComposer({ style={[a.relative, {left: 1}]} /> - - - - + + {IS_WEB && (