Skip to content

fix(mobile): tap outside to dismiss mention popup#5740

Open
wca4a wants to merge 2 commits into
developfrom
wa/mobile-mention-tap-dismiss
Open

fix(mobile): tap outside to dismiss mention popup#5740
wca4a wants to merge 2 commits into
developfrom
wa/mobile-mention-tap-dismiss

Conversation

@wca4a
Copy link
Copy Markdown
Contributor

@wca4a wca4a commented Apr 21, 2026

Summary

On mobile there's no Escape key, so an open @-mention popup can feel stuck until the user types enough to clear all candidates or picks one. Tapping elsewhere in the chat should dismiss the popup — this is standard mobile dropdown UX.

Fix

Pass handleMentionEscape through MessageInputContainerInputMentionPopup. On Platform.OS !== 'web' (and only while candidates are visible), render a transparent Pressable backdrop positioned behind the popup card that extends above/around the popup but not over the message input, and dismisses on tap.

Web is unchanged and still uses the Escape key.

Test plan

  • Verified on iOS simulator (iPhone 17 Pro): type ~ or @ in a DM or channel input → popup appears → tap on chat messages above → popup dismisses
  • Android — behavior should match iOS (same Platform.OS !== 'web' guard)
  • Web — no change, Escape still works

Copy link
Copy Markdown

@claude claude Bot left a comment

Choose a reason for hiding this comment

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

Claude Code Review

This repository is configured for manual code reviews. Comment @claude review to trigger a review and subscribe this PR to future pushes, or @claude review once for a one-time review.

Tip: disable this comment in your organization's Code Review settings.

@wca4a
Copy link
Copy Markdown
Contributor Author

wca4a commented Apr 21, 2026

@codex review

@wca4a
Copy link
Copy Markdown
Contributor Author

wca4a commented Apr 21, 2026

@claude review once

@chatgpt-codex-connector
Copy link
Copy Markdown

Codex Review: Didn't find any major issues. What shall we delve into next?

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/app/ui/components/MessageInput/InputMentionPopup.tsx Outdated
Comment thread packages/app/ui/components/MessageInput/InputMentionPopup.tsx Outdated
@wca4a wca4a force-pushed the wa/mobile-mention-tap-dismiss branch from 152b058 to 74583ef Compare April 21, 2026 18:40
@wca4a
Copy link
Copy Markdown
Contributor Author

wca4a commented Apr 21, 2026

@claude review once

@wca4a
Copy link
Copy Markdown
Contributor Author

wca4a commented Apr 21, 2026

@codex review

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 74583ef620

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +60 to +64
(keyboardHeight > 0 ? keyboardHeight : insets.bottom) +
containerHeight +
24;
const inputTopFromBottom =
(keyboardHeight > 0 ? keyboardHeight : insets.bottom) + containerHeight;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Stop double-counting keyboard height in mobile popup offset

On Android, this popup is now rendered in a Portal and its vertical position is computed with keyboardHeight added to bottom, but the app activity is configured with android:windowSoftInputMode="adjustResize" (apps/tlon-mobile/android/app/src/main/AndroidManifest.xml), which already resizes the root view when the IME appears. In that context, adding keyboardHeight again pushes both the mention card and dismiss backdrop too high above the input, so the mention UI is detached/mispositioned whenever the keyboard is open (most Android chat usage).

Useful? React with 👍 / 👎.

@wca4a wca4a force-pushed the wa/mobile-mention-tap-dismiss branch from 74583ef to 67cac20 Compare April 21, 2026 18:52
@wca4a
Copy link
Copy Markdown
Contributor Author

wca4a commented Apr 21, 2026

@codex review

@chatgpt-codex-connector
Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Nice work!

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +12 to +27
function useKeyboardHeight() {
const [height, setHeight] = useState(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;
}
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.

Comment on lines +68 to +79
const inputTopFromBottom = effectiveBottomInset + containerHeight;

return (
<Portal>
{onDismiss ? (
<Pressable
onPress={onDismiss}
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
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 transparent Pressable backdrop at lines 68-79 covers the entire chat area above the keyboard inside a Portal, which places it as a z-order sibling of the chat ScrollView/FlatList rather than an ancestor - so the standard Pressable/ScrollView responder-handoff mechanism does not apply, and any swipe starting in the chat area is silently swallowed: the popup neither dismisses nor does the chat scroll. Use a transparent={true} React Native Modal as the backdrop, or a gesture handler that distinguishes taps from pans, so scroll gestures can pass through to the underlying chat view.

Extended reasoning...

What the bug is and how it manifests

The PR renders a full-screen transparent Pressable inside a Tamagui Portal (InputMentionPopup.tsx lines 68-79). The Portal places its content at the root of the React Native view hierarchy, above the normal chat view tree in z-order. The Pressable's style covers the entire chat area: top: 0, left: 0, right: 0, bottom: inputTopFromBottom. When the mention popup is open, any swipe gesture starting in the chat area lands on this Pressable, which intercepts it. The chat neither scrolls nor does the popup dismiss for swipe movements - the gesture is silently consumed with no effect.

The specific code path that triggers it

On a touch start anywhere in the covered area: (1) UIKit/Android hit-testing finds the Pressable view (it is topmost in z-order). (2) React Native's responder system calls the Pressable's onStartShouldSetResponder, which returns true. (3) The Pressable becomes the active responder and begins tracking the press. (4) As the user moves their finger (swipe gesture), onResponderMove is dispatched to the Pressable. (5) The movement exceeds Pressable's tap threshold, so onPress is not fired - but because the Pressable still holds the responder, the chat ScrollView/FlatList never receives the move events. The gesture is fully consumed by the Pressable with no visible effect.

Why existing code does not prevent it

One verifier refuted this bug, arguing that React Native's Pressable returns true from onResponderTerminationRequest, allowing a ScrollView to steal the responder on a scroll gesture. This is correct in the normal case where the Pressable is rendered inside the ScrollView's view hierarchy - the ScrollView can request the responder via onMoveShouldSetResponder as the event bubbles up through the common ancestor chain. However, the Portal breaks this assumption: the Pressable is a sibling of the ScrollView at the root native view level, not a descendant. On both iOS (UIKit hit-testing) and Android (ViewGroup touch dispatching), the Portal's overlay view intercepts the native touch event before it reaches the ScrollView's native view. Since the ScrollView never receives the native touch start, it has no opportunity to issue a requestTerminate call. The responder termination path simply never executes.

What the impact would be

This is a regression relative to the pre-PR state where there was no overlay at all. While the mention popup is open, the chat is effectively frozen: users cannot scroll through messages. The backdrop is transparent, so users will naturally attempt to scroll (seeing chat content behind the popup). Each attempted swipe is silently discarded - no scroll, no dismiss. The workaround is to tap precisely (not swipe) to dismiss, then scroll in a second gesture - a confusing two-step that violates standard mobile UX expectations.

How to fix it

Replace the Portal + Pressable pattern with a React Native Modal using transparent={true} and animationType=none. A Modal renders in a separate native layer (an independent Window on iOS / Dialog on Android) that is completely disjoint from the chat ScrollView's native view hierarchy. Because the two native layers are independent, a transparent backdrop can be given a tap-dismiss handler while scroll events on the underlying layer pass through via the normal responder system. Alternatively, react-native-gesture-handler's TapGestureHandler with a hitSlop guard that lets scroll velocities fall through is another valid approach.

Step-by-step proof

  1. User types @fo in a channel input on iOS. The Portal renders the Pressable with style {position:absolute, top:0, left:0, right:0, bottom:inputTopFromBottom}.
  2. User places finger on a chat message above the popup and begins a downward swipe.
  3. UIKit's hitTest:withEvent: checks the Portal's overlay view first (it is on top in z-order). The touch point falls within the Pressable's bounds. hitTest returns the Pressable's underlying UIView.
  4. React Native dispatches onStartShouldSetResponder to the Pressable which returns true. Pressable becomes the active responder.
  5. The user moves their finger (swipe). onResponderMove fires on the Pressable. The delta exceeds Pressable's press threshold, so onPress/onDismiss is NOT called. But the ScrollView is a sibling - it receives no move events through its own subtree and cannot call requestTerminate.
  6. The swipe ends. No scroll occurred. The popup is still visible. The user's gesture was silently discarded.

@wca4a wca4a force-pushed the wa/mobile-mention-tap-dismiss branch from 67cac20 to c4304de Compare April 21, 2026 20:18
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c4304deef9

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

top: 0,
left: 0,
right: 0,
bottom: inputTopFromBottom,
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Base dismiss backdrop on actual input height

The mobile tap-outside overlay stops at inputTopFromBottom, which is derived from containerHeight; in BareChatInput this prop is hard-coded to 48 while the multiline composer can grow taller (TextInput has multiline with variable height). When the user has a taller draft and mention candidates are open, the backdrop overlaps the upper part of the composer, so taps intended to move the cursor inside the input are intercepted and only dismiss mentions. This regression comes from the new backdrop math and is reproducible on mobile with multi-line input.

Useful? React with 👍 / 👎.

@blacksmith-sh

This comment has been minimized.

On mobile there's no Escape key, so an open @-mention popup has no
obvious dismiss. Add a transparent backdrop above the input that
dismisses on tap, and render it through a Portal so parent View
bounds (Android clipChildren) don't block hits above the popup.

Uses a soft dismiss path (only toggles isMentionModeActive) rather
than reusing handleMentionEscape — otherwise the trigger index would
stay "poisoned" and the popup wouldn't reopen as the user keeps typing.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
@wca4a wca4a force-pushed the wa/mobile-mention-tap-dismiss branch from c4304de to 21bb862 Compare April 21, 2026 20:49
@wca4a
Copy link
Copy Markdown
Contributor Author

wca4a commented Apr 22, 2026

Addressed the review feedback across several amends — current commit is 21bb86240. Rundown:

Claude bot — soft vs. hard dismiss. Backdrop tap was calling handleMentionEscape, which also sets wasDismissedByEscape and "poisons" the current trigger index so the popup stays hidden for subsequent keystrokes at the same @. Added a separate handleMentionSoftDismiss in useMentions that only toggles isMentionModeActive — the next keystroke re-evaluates the trigger normally.

Claude bot — Android clipping. top: -9999 on a Pressable inside a bounded YStack doesn't work on Android (clipChildren default). Moved the backdrop + popup into a tamagui Portal so ancestor View bounds don't constrain hit-testing.

Codex P1 — double-counting keyboard height on Android. Android uses adjustResize, so the activity already shrinks above the keyboard and a Portal's bottom: 0 is the keyboard top. Only add keyboardHeight manually on iOS.

Claude bot — keyboard-already-visible on mount. Event-listener-only hook stayed at 0 if the component mounted while the keyboard was already up (e.g., channel-to-channel navigation). Initialized with Keyboard.metrics()?.height ?? 0 so initial state reflects reality.

Claude bot — scroll gestures swallowed by backdrop. Decided to accept this as standard mobile UX. Every comparable app (Slack, Discord, iMessage autocompletes) treats the mention dropdown as modal-lite: dismiss or pick first, then scroll. The pre-PR state was "no way to dismiss at all," so trading free scroll for tap-dismiss is the right direction.

Codex P2 — backdrop over tall multi-line composer. containerHeight is hardcoded to 48 but the real composer grows with multi-line drafts, so the backdrop extended into the actual input area and ate cursor-placement taps. MessageInputContainer now measures the input bar's layout height and passes it to InputMentionPopup as a separate inputBarHeight prop used only for the backdrop's lower edge. The popup itself still anchors to the static containerHeight — intentionally, so a huge draft doesn't push the popup off-screen and out of reach.

Tested on iOS simulator: tap-outside dismisses with soft-dismiss behavior (continued typing reopens), keyboard-open and keyboard-closed positioning both correct, tall multi-line composer accepts cursor taps.

@blacksmith-sh
Copy link
Copy Markdown

blacksmith-sh Bot commented Apr 28, 2026

Found 18 test failures on Blacksmith runners:

Failures

Test View Logs
channel-details.spec.ts/channel details back button returns to channel conversation View Logs
channel-details.spec.ts/
channel edit meta back button returns to channel details with sidebar context
View Logs
channel-details.spec.ts/
channel header title navigates to channel details with sidebar context
View Logs
channel-details.spec.ts/
channel overflow menu: Channel info & settings maintains sidebar context
View Logs
channel-details.spec.ts/channel privacy back button returns to channel details View Logs
channel-details.spec.ts/
group details back button returns to channel conversation (single-channel)
View Logs
channel-details.spec.ts/
multi-channel group: notifications back button returns to channel details
View Logs
channel-details.spec.ts/
should display channel details screen with all expected elements
View Logs
channel-details.spec.ts/should navigate to channel edit settings from channel details View Logs
channel-details.spec.ts/
should navigate to channel privacy settings from channel details
View Logs
channel-details.spec.ts/should toggle pin/unpin from channel details View Logs
channel-details.spec.ts/
single-channel group: notifications back button returns to group details
View Logs
channel-permissions-members.spec.ts/Members appears in SelectChannelRoles screen View Logs
channel-permissions-members.spec.ts/Members row appears in GroupRolesScreen View Logs
channel-permissions-members.spec.ts/
Members row appears with correct defaults when enabling Custom Permissions
View Logs
chat-core-functionality.spec.ts/
should allow admin to delete another user message with confirmation
View Logs
chat-core-functionality.spec.ts/
should require confirmation before deleting a message (cancel prevents deletion)
View Logs
chat-core-functionality.spec.ts/should test comprehensive chat functionality View Logs

Fix in Cursor

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants