diff --git a/packages/app/ui/components/BareChatInput/index.tsx b/packages/app/ui/components/BareChatInput/index.tsx index 0b8b45520a..b839693b8d 100644 --- a/packages/app/ui/components/BareChatInput/index.tsx +++ b/packages/app/ui/components/BareChatInput/index.tsx @@ -292,6 +292,7 @@ function BareChatInput( handleMention, handleSelectMention, handleMentionEscape, + handleMentionSoftDismiss, } = useMentions({ chatId: groupId ?? channelId, roleOptions }); const maxInputHeight = useKeyboardHeight(maxInputHeightBasic); const inputRef = useRef(null); @@ -906,6 +907,7 @@ function BareChatInput( mentionOptions={validOptions} mentionRef={mentionRef} onSelectMention={onMentionSelect} + onDismissMentions={handleMentionSoftDismiss} showAttachmentButton={showAttachmentButton} isEditing={!!editingPost} cancelEditing={handleCancelEditing} diff --git a/packages/app/ui/components/BareChatInput/useMentions.tsx b/packages/app/ui/components/BareChatInput/useMentions.tsx index 7b94c04204..548187a8fd 100644 --- a/packages/app/ui/components/BareChatInput/useMentions.tsx +++ b/packages/app/ui/components/BareChatInput/useMentions.tsx @@ -292,6 +292,14 @@ export const useMentions = ({ setLastDismissedTriggerIndex(mentionStartIndex); }; + // Light dismiss for gestures like tapping outside the popup. Unlike + // handleMentionEscape (which "poisons" the current trigger index so the + // popup doesn't reopen as the user keeps typing), this just hides the + // popup; the next keystroke re-evaluates the mention trigger normally. + const handleMentionSoftDismiss = () => { + setIsMentionModeActive(false); + }; + return { mentions, validOptions, @@ -303,6 +311,7 @@ export const useMentions = ({ isMentionModeActive, setIsMentionModeActive, handleMentionEscape, + handleMentionSoftDismiss, hasMentionCandidates, }; }; diff --git a/packages/app/ui/components/MessageInput/InputMentionPopup.tsx b/packages/app/ui/components/MessageInput/InputMentionPopup.tsx index e2e5526115..ddac1ee13a 100644 --- a/packages/app/ui/components/MessageInput/InputMentionPopup.tsx +++ b/packages/app/ui/components/MessageInput/InputMentionPopup.tsx @@ -1,36 +1,128 @@ import * as db from '@tloncorp/shared/db'; -import { PropsWithRef } from 'react'; +import { PropsWithRef, useEffect, useState } from 'react'; import React from 'react'; -import { View, YStack } from 'tamagui'; +import { Keyboard, Platform, Pressable } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { Portal, View, YStack } from 'tamagui'; import { MentionOption } from '../BareChatInput/useMentions'; import { useIsWindowNarrow } from '../Emoji'; import MentionPopup, { MentionPopupRef } from '../MentionPopup'; +function useKeyboardHeight() { + // Read the keyboard state synchronously on mount so the popup positions + // correctly when the component mounts with the keyboard already visible + // (e.g. navigating between channels without dismissing the keyboard). + const [height, setHeight] = useState(() => Keyboard.metrics()?.height ?? 0); + useEffect(() => { + const showSub = Keyboard.addListener('keyboardDidShow', (e) => { + setHeight(e.endCoordinates.height); + }); + const hideSub = Keyboard.addListener('keyboardDidHide', () => { + setHeight(0); + }); + return () => { + showSub.remove(); + hideSub.remove(); + }; + }, []); + return height; +} + function InputMentionPopupInternal( { containerHeight, + inputBarHeight, isMentionModeActive, mentionText, options, onSelectMention, + onDismiss, }: PropsWithRef<{ containerHeight: number; + // Measured height of the actual input bar, used to stop the mobile + // dismiss backdrop above a multi-line composer. Falls back to + // containerHeight (static) when unavailable. + inputBarHeight?: number; isMentionModeActive: boolean; mentionText?: string; options: MentionOption[]; onSelectMention: (option: MentionOption) => void; + onDismiss?: () => void; }>, ref: MentionPopupRef ) { const isNarrow = useIsWindowNarrow(); - return isMentionModeActive ? ( + const insets = useSafeAreaInsets(); + const keyboardHeight = useKeyboardHeight(); + const isMobile = Platform.OS !== 'web'; + + if (!isMentionModeActive) return null; + + // On mobile, render in a Portal so the tap-outside backdrop isn't clipped + // by ancestor View bounds (Android clipChildren defaults to true). + // MentionPopup itself returns null when options.length === 0, so the + // backdrop would otherwise catch taps with nothing visible — skip both. + if (isMobile && options.length > 0) { + // Android uses adjustResize (see AndroidManifest), so the root view + // already shrinks above the keyboard — a Portal's `bottom: 0` is the + // keyboard top. On iOS the root does not resize, so we add the + // keyboard height ourselves. + const effectiveBottomInset = + Platform.OS === 'ios' && keyboardHeight > 0 + ? keyboardHeight + : insets.bottom; + const bottomOffset = effectiveBottomInset + containerHeight + 24; + // Backdrop stops just above the real composer (measured, or the static + // fallback) so taps inside a tall multi-line input still place the + // cursor instead of dismissing the popup. + const backdropBottom = + effectiveBottomInset + (inputBarHeight ?? containerHeight); + + return ( + + {onDismiss ? ( + + ) : null} + + + + + + + ); + } + + return ( @@ -43,7 +135,7 @@ function InputMentionPopupInternal( /> - ) : null; + ); } const InputMentionPopup = React.forwardRef(InputMentionPopupInternal); diff --git a/packages/app/ui/components/MessageInput/MessageInputBase.tsx b/packages/app/ui/components/MessageInput/MessageInputBase.tsx index 4f347b4bc4..30bde6d8a0 100644 --- a/packages/app/ui/components/MessageInput/MessageInputBase.tsx +++ b/packages/app/ui/components/MessageInput/MessageInputBase.tsx @@ -4,8 +4,9 @@ import * as db from '@tloncorp/shared/db'; import type * as domain from '@tloncorp/shared/domain'; import { Button, FloatingActionButton, Icon } from '@tloncorp/ui'; import { ImagePickerAsset } from 'expo-image-picker'; -import { memo } from 'react'; +import { memo, useState } from 'react'; import { PropsWithChildren } from 'react'; +import { LayoutChangeEvent } from 'react-native'; import { SpaceTokens, styled } from 'tamagui'; import { ThemeTokens, @@ -91,6 +92,7 @@ export const MessageInputContainer = memo( mentionText, mentionOptions, onSelectMention, + onDismissMentions, isEditing = false, cancelEditing, onPressEdit, @@ -111,6 +113,7 @@ export const MessageInputContainer = memo( mentionText?: string; mentionOptions: MentionOption[]; onSelectMention: (option: MentionOption) => void; + onDismissMentions?: () => void; isEditing?: boolean; cancelEditing?: () => void; onPressEdit?: () => void; @@ -124,6 +127,15 @@ export const MessageInputContainer = memo( const secondaryBackgroundColor = getVariableValue( theme.secondaryBackground ); + // Track the real input-bar height so the mention backdrop (mobile + // tap-outside dismiss area) stops above the actual composer. The popup + // itself stays anchored to the static containerHeight so it remains + // accessible even if the user writes a huge multi-line draft. + const [measuredInputHeight, setMeasuredInputHeight] = + useState(containerHeight); + const handleInputLayout = (e: LayoutChangeEvent) => { + setMeasuredInputHeight(e.nativeEvent.layout.height); + }; return ( {!frameless ? ( @@ -149,6 +163,7 @@ export const MessageInputContainer = memo( justifyContent="space-between" backgroundColor="$background" disableOptimization + onLayout={handleInputLayout} > {goBack ? ( @@ -217,7 +232,11 @@ export const MessageInputContainer = memo( ) : ( // Note: This **must** be an XStack (not a YStack, View, or Stack), otherwise the WebView in MessageInput will not // be interactive on Android. - + {children} )}