Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/app/ui/components/BareChatInput/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,7 @@ function BareChatInput(
handleMention,
handleSelectMention,
handleMentionEscape,
handleMentionSoftDismiss,
} = useMentions({ chatId: groupId ?? channelId, roleOptions });
const maxInputHeight = useKeyboardHeight(maxInputHeightBasic);
const inputRef = useRef<TextInput>(null);
Expand Down Expand Up @@ -906,6 +907,7 @@ function BareChatInput(
mentionOptions={validOptions}
mentionRef={mentionRef}
onSelectMention={onMentionSelect}
onDismissMentions={handleMentionSoftDismiss}
showAttachmentButton={showAttachmentButton}
isEditing={!!editingPost}
cancelEditing={handleCancelEditing}
Expand Down
9 changes: 9 additions & 0 deletions packages/app/ui/components/BareChatInput/useMentions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -303,6 +311,7 @@ export const useMentions = ({
isMentionModeActive,
setIsMentionModeActive,
handleMentionEscape,
handleMentionSoftDismiss,
hasMentionCandidates,
};
};
104 changes: 98 additions & 6 deletions packages/app/ui/components/MessageInput/InputMentionPopup.tsx
Original file line number Diff line number Diff line change
@@ -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;
}
Comment on lines +12 to +30
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 The useKeyboardHeight hook in InputMentionPopup.tsx initializes to 0 and relies solely on keyboardDidShow/keyboardDidHide events, so if the keyboard is already visible when the component mounts (e.g., navigating to a new channel while the keyboard stays up), keyboardHeight remains 0 for the session. This causes the mention popup bottomOffset to fall back to insets.bottom (~34px) instead of the actual keyboard height (~350-400px), placing the popup ~300px below its intended position—behind the keyboard—until the user dismisses and re-shows it. Fix: initialize with Keyboard.metrics()?.height ?? 0 to read the current keyboard state synchronously on mount.

Extended reasoning...

What the bug is and how it manifests

The new useKeyboardHeight hook in InputMentionPopup.tsx (lines 12-27) initializes state with useState(0) and updates only via keyboardDidShow/keyboardDidHide event listeners. This is a gap: if the keyboard is already visible at the time InputMentionPopupInternal first mounts, no keyboardDidShow event fires and keyboardHeight stays 0 for the entire lifetime of that component instance.

The specific code path that triggers it

  1. User is in channel A with the keyboard open.
  2. User navigates to channel B (in-app navigation that preserves keyboard visibility on iOS/Android).
  3. BareChatInput and MessageInputContainer remount fresh. useKeyboardHeight starts at 0.
  4. The keyboard is already displayed, so no keyboardDidShow fires again.
  5. User types @ to trigger a mention. isMentionModeActive becomes true, options.length > 0 — the Portal path executes.
  6. bottomOffset = (keyboardHeight > 0 ? keyboardHeight : insets.bottom) + containerHeight + 24 = (0 > 0 ? 0 : ~34) + 48 + 24 = ~106px.
  7. The correct value would be ~350 + 48 + 24 = ~422px.
  8. The popup renders ~316px too low — behind the keyboard or overlapping the input area — and stays there until the user dismisses and re-shows the keyboard.

Why existing code does not prevent it

The existing useKeyboardHeight in BareChatInput/index.tsx has the same event-listener-only pattern but uses Keyboard.metrics() inside the handler and has a DEFAULT_KEYBOARD_HEIGHT=300 fallback. The new hook in InputMentionPopup.tsx has no such fallback. The effect dependency array is [] so it never re-runs on re-render to pick up the current state.

What the impact would be

Cross-channel navigation with a persistent keyboard is a common and natural flow on iOS (keyboard often stays up as users switch channels) and even more so on Android. When it occurs, the mention popup is hidden behind the keyboard for the entire channel session — the feature that this PR specifically introduces for mobile. The visual regression is severe and immediate: the popup appears in the wrong place with no workaround short of keyboard dismiss and re-show.

How to fix it

Initialize the hook with the synchronous Keyboard.metrics() call:

Keyboard.metrics() returns the current keyboard dimensions synchronously if the keyboard is visible, so the initial render uses the real height and no event is needed for the common mount-with-keyboard-already-up case.

Step-by-step proof

  1. User opens channel A, taps the input — keyboard rises to ~350px, keyboardDidShow fires, keyboardHeight = 350.
  2. User navigates to channel B while the keyboard remains up. BareChatInput remounts. useKeyboardHeight reinitializes to useState(0). Keyboard is still shown but keyboardDidShow does not re-fire. keyboardHeight = 0.
  3. User types @fo. isMentionModeActive = true, options = [contactA, contactB].
  4. bottomOffset = (0 > 0 ? 0 : insets.bottom) + containerHeight + 24 = 34 + 48 + 24 = 106px.
  5. Portal renders the popup 106px from the bottom of the screen — behind the keyboard which occupies ~350px from the bottom.
  6. Popup is completely invisible. User cannot see or interact with it. The only recovery is dismissing and re-showing the keyboard.


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 (
<Portal>
{onDismiss ? (
<Pressable
onPress={onDismiss}
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: backdropBottom,
}}
/>
) : null}
<View
position="absolute"
bottom={bottomOffset}
left={0}
right={0}
alignItems="center"
pointerEvents="box-none"
>
<View
width="90%"
maxWidth={isNarrow ? undefined : 500}
pointerEvents="box-none"
>
<MentionPopup
onPress={onSelectMention}
matchText={mentionText}
options={options}
ref={ref}
/>
</View>
</View>
</Portal>
);
}

return (
<YStack
position="absolute"
bottom={containerHeight + 24}
zIndex={15}
// borderWidth={2}
// borderColor="orange"
width="90%"
maxWidth={isNarrow ? 'unset' : 500}
>
Expand All @@ -43,7 +135,7 @@ function InputMentionPopupInternal(
/>
</View>
</YStack>
) : null;
);
}

const InputMentionPopup = React.forwardRef(InputMentionPopupInternal);
Expand Down
23 changes: 21 additions & 2 deletions packages/app/ui/components/MessageInput/MessageInputBase.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -91,6 +92,7 @@ export const MessageInputContainer = memo(
mentionText,
mentionOptions,
onSelectMention,
onDismissMentions,
isEditing = false,
cancelEditing,
onPressEdit,
Expand All @@ -111,6 +113,7 @@ export const MessageInputContainer = memo(
mentionText?: string;
mentionOptions: MentionOption[];
onSelectMention: (option: MentionOption) => void;
onDismissMentions?: () => void;
isEditing?: boolean;
cancelEditing?: () => void;
onPressEdit?: () => void;
Expand All @@ -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 (
<YStack
Expand All @@ -134,10 +146,12 @@ export const MessageInputContainer = memo(
>
<InputMentionPopup
containerHeight={containerHeight}
inputBarHeight={measuredInputHeight}
isMentionModeActive={isMentionModeActive}
mentionText={mentionText}
options={mentionOptions}
onSelectMention={onSelectMention}
onDismiss={onDismissMentions}
ref={mentionRef}
/>
{!frameless ? (
Expand All @@ -149,6 +163,7 @@ export const MessageInputContainer = memo(
justifyContent="space-between"
backgroundColor="$background"
disableOptimization
onLayout={handleInputLayout}
>
{goBack ? (
<View paddingBottom="$xs">
Expand Down Expand Up @@ -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.
<XStack width="100%" backgroundColor="$background">
<XStack
width="100%"
backgroundColor="$background"
onLayout={handleInputLayout}
>
{children}
</XStack>
)}
Expand Down
Loading