From d604f7715976ac8622283f150dc8be2787056913 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Tue, 31 Mar 2026 15:47:07 -0500 Subject: [PATCH] New MessageComposer component --- package.json | 2 + src/alf/util/flatten.ts | 21 + src/lib/merge-refs.ts | 2 +- .../Messages/components/MessageComposer.tsx | 749 ++++++++++++++++++ .../Messages/components/MessagesList.tsx | 33 +- yarn.lock | 18 +- 6 files changed, 792 insertions(+), 33 deletions(-) create mode 100644 src/screens/Messages/components/MessageComposer.tsx diff --git a/package.json b/package.json index a2b220df2c..c6a35f0503 100644 --- a/package.json +++ b/package.json @@ -88,6 +88,8 @@ "@bsky.app/expo-image-crop-tool": "^0.5.0", "@bsky.app/expo-translate-text": "^0.2.9", "@bsky.app/react-native-mmkv": "2.12.5", + "@bsky.app/sift": "^0.2.4", + "@bsky.app/tapper": "^0.4.0", "@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet", "@emoji-mart/data": "^1.2.1", "@emoji-mart/react": "^1.1.1", diff --git a/src/alf/util/flatten.ts b/src/alf/util/flatten.ts index 448716a082..df5397f466 100644 --- a/src/alf/util/flatten.ts +++ b/src/alf/util/flatten.ts @@ -1,3 +1,24 @@ import {StyleSheet} from 'react-native' export const flatten = StyleSheet.flatten + +type PaddingStyle = { + padding?: number + paddingHorizontal?: number + paddingVertical?: number + paddingTop?: number + paddingBottom?: number + paddingLeft?: number + paddingRight?: number +} + +export function extractPadding(style: PaddingStyle | PaddingStyle[]) { + const s = flatten(style) + const base = s.padding ?? 0 + return { + paddingTop: s.paddingTop ?? s.paddingVertical ?? base, + paddingBottom: s.paddingBottom ?? s.paddingVertical ?? base, + paddingLeft: s.paddingLeft ?? s.paddingHorizontal ?? base, + paddingRight: s.paddingRight ?? s.paddingHorizontal ?? base, + } +} diff --git a/src/lib/merge-refs.ts b/src/lib/merge-refs.ts index b038995473..bf8cbfddaa 100644 --- a/src/lib/merge-refs.ts +++ b/src/lib/merge-refs.ts @@ -13,7 +13,7 @@ * returns a ref callback function that can be used to merge multiple refs into a single ref. */ export function mergeRefs( - refs: Array | React.Ref>, + refs: Array | React.Ref | undefined>, ): React.RefCallback { return value => { refs.forEach(ref => { diff --git a/src/screens/Messages/components/MessageComposer.tsx b/src/screens/Messages/components/MessageComposer.tsx new file mode 100644 index 0000000000..8aca011ece --- /dev/null +++ b/src/screens/Messages/components/MessageComposer.tsx @@ -0,0 +1,749 @@ +/** + * All + * - look at Text as children option to preserve lineHeight + * + * Native + * + * Web + */ + +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 textInputRef = useRef(null) + 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) { + 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 = textInputRef.current 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 + } + + textInputRef.current?.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], + ) + + return ( + <> + + {/* PREVIEW */} + + + + {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, + 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) + setActiveFacet(null) + }} + onKeyPress={IS_WEB ? onKeyPressWeb : undefined} + onScroll={e => { + if (IS_WEB) { + // TODO why does compiler not like this? + 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 + }} + /> + + {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}))} + /> + )} + + ) +} diff --git a/src/screens/Messages/components/MessagesList.tsx b/src/screens/Messages/components/MessagesList.tsx index e06c9659c4..f318c2130c 100644 --- a/src/screens/Messages/components/MessagesList.tsx +++ b/src/screens/Messages/components/MessagesList.tsx @@ -37,21 +37,16 @@ import { import {useGetPost} from '#/state/queries/post' import {useAgent} from '#/state/session' import {useShellLayout} from '#/state/shell/shell-layout' -import { - EmojiPicker, - type EmojiPickerState, -} from '#/view/com/composer/text-input/web/EmojiPicker' import {List, type ListMethods} from '#/view/com/util/List' import {ChatDisabled} from '#/screens/Messages/components/ChatDisabled' -import {MessageInput} from '#/screens/Messages/components/MessageInput' +import {MessageComposer} from '#/screens/Messages/components/MessageComposer' import {MessageListError} from '#/screens/Messages/components/MessageListError' import {ChatEmptyPill} from '#/components/dms/ChatEmptyPill' import {MessageItem} from '#/components/dms/MessageItem' import {NewMessagesPill} from '#/components/dms/NewMessagesPill' import {Loader} from '#/components/Loader' import {Text} from '#/components/Typography' -import {IS_NATIVE} from '#/env' -import {IS_WEB} from '#/env' +import {IS_NATIVE, IS_WEB} from '#/env' import {ChatStatusInfo} from './ChatStatusInfo' import {MessageInputEmbed, useMessageEmbed} from './MessageInputEmbed' @@ -116,11 +111,6 @@ export function MessagesList({ startContentOffset: 0, }) - const [emojiPickerState, setEmojiPickerState] = useState({ - isOpen: false, - pos: {top: 0, left: 0, right: 0, bottom: 0, nextFocusRef: null}, - }) - // We need to keep track of when the scroll offset is at the bottom of the list to know when to scroll as new items // are added to the list. For example, if the user is scrolled up to 1iew older messages, we don't want to scroll to // the bottom. @@ -412,10 +402,6 @@ export function MessagesList({ }) }, [flatListRef]) - const onOpenEmojiPicker = useCallback((pos: any) => { - setEmojiPickerState({isOpen: true, pos}) - }, []) - return ( <> {/* Custom scroll provider so that we can use the `onScroll` event in our custom List implementation */} @@ -457,25 +443,16 @@ export function MessagesList({ - + setEmbed={setEmbed}> - + )} - {IS_WEB && ( - setEmojiPickerState(prev => ({...prev, isOpen: false}))} - /> - )} - {newMessagesPill.show && } ) diff --git a/yarn.lock b/yarn.lock index 2f083a7ebf..92b22dcf93 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2413,6 +2413,16 @@ resolved "https://registry.yarnpkg.com/@bsky.app/react-native-mmkv/-/react-native-mmkv-2.12.5.tgz#eb17d31a6158c74393f617a1763ac223ff3f83a6" integrity sha512-3vUz1nQY1DiKIPAWRkpp5ZGxH5f2G6Ui0UuQuEYjYv81xx1qFcSzS9KQ2sHcOKYdkOM9amWV2Q8TQCxt1lrAHg== +"@bsky.app/sift@^0.2.4": + version "0.2.4" + resolved "https://registry.yarnpkg.com/@bsky.app/sift/-/sift-0.2.4.tgz#cfdc67d5236b3fb4d26b9b5d482420d3f804cfe1" + integrity sha512-2tUoKhTjULMPgqhIUiNaNPyyTh2vPhOMRT/2S1HWhalEsBNeMeoNqUs7KWgOtgcDtT4breGkHmR1XOo2tzYmgA== + +"@bsky.app/tapper@^0.4.0": + version "0.4.0" + resolved "https://registry.yarnpkg.com/@bsky.app/tapper/-/tapper-0.4.0.tgz#46c004eb9b6a2d842b4fd4b80d69bb4f2bdd4c5b" + integrity sha512-5w9kRIrIFiXVdQrbYyJPvim/H3Ujhy/EPUx1M7AwcPmfq9fOJY+gqI8uXSb50ZKdr//qWnf7eMuxBHsOGbCizw== + "@crowdin/cli@^4.14.1": version "4.14.1" resolved "https://registry.yarnpkg.com/@crowdin/cli/-/cli-4.14.1.tgz#1239922681235b6b14bcacd4fd622bc2217dd6c5" @@ -14569,10 +14579,10 @@ react-test-renderer@19.1.0: react-is "^19.1.0" scheduler "^0.26.0" -react-textarea-autosize@^8.5.3: - version "8.5.3" - resolved "https://registry.yarnpkg.com/react-textarea-autosize/-/react-textarea-autosize-8.5.3.tgz#d1e9fe760178413891484847d3378706052dd409" - integrity sha512-XT1024o2pqCuZSuBt9FwHlaDeNtVrtCXu0Rnz88t1jUGheCLa3PhjE1GH8Ctm2axEtvdCl5SUHYschyQ0L5QHQ== +react-textarea-autosize@^8.5.9: + version "8.5.9" + resolved "https://registry.yarnpkg.com/react-textarea-autosize/-/react-textarea-autosize-8.5.9.tgz#ab8627b09aa04d8a2f45d5b5cd94c84d1d4a8893" + integrity sha512-U1DGlIQN5AwgjTyOEnI1oCcMuEr1pv1qOtklB2l4nyMGbHzWrI0eFsYK0zos2YWqAolJyG0IWJaqWmWj5ETh0A== dependencies: "@babel/runtime" "^7.20.13" use-composed-ref "^1.3.0"