Skip to content

feat[new-channels]: mobile action drawers and other mobile improvements#2337

Merged
peterchinman merged 24 commits intomainfrom
new-channel-mobile-input-scroll
Apr 1, 2026
Merged

feat[new-channels]: mobile action drawers and other mobile improvements#2337
peterchinman merged 24 commits intomainfrom
new-channel-mobile-input-scroll

Conversation

@peterchinman
Copy link
Copy Markdown
Contributor

@peterchinman peterchinman commented Apr 1, 2026

Message action drawer opens on long-press.

image

autofocus thread replies

Unfortunately this requires us to do an ugly hacky workaround where, on click, we synchronously create a temporary input that we focus, to open the virtual keyboard, before we shift focus to our actual input target, once it is available.

scroll thread reply inputs above virtual keyboard

@peterchinman peterchinman requested a review from a team as a code owner April 1, 2026 19:35
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Apr 1, 2026

Caution

Review failed

Pull request was closed or merged during review

📝 Walkthrough

Summary by CodeRabbit

Release Notes

New Features

  • Mobile message action drawer with long-press access to reactions, reply, edit, and delete options
  • Improved mobile keyboard handling with viewport-aware scrolling to keep inputs visible
  • Haptic feedback on long-press message interactions
  • Enhanced emoji picker with customizable grid layout

Style & UI Updates

  • Refreshed mobile dock styling
  • Touch-optimized message interactions and visibility management

Walkthrough

This PR introduces a mobile-specific message action drawer system, adds mobile keyboard and virtual keyboard height tracking, creates a new MobileDrawer component, and refactors channel reply input management with improved focus and scroll handling for mobile.

Changes

Cohort / File(s) Summary
Mobile Drawer Components
js/app/packages/app/component/mobile/MobileDock.tsx, js/app/packages/app/component/mobile/MobileDrawer.tsx, js/app/packages/app/component/next-soup/soup-view/filters-bar/mobile-filter-drawer.tsx
Replaced bg-page with bg-panel utility in MobileDock; introduced new MobileDrawer component with focus-to-input scroll helper and dynamic padding/height based on virtual keyboard state; migrated filter drawer from Corvu Drawer to MobileDrawer with accordion scroll-to-top and focus-aware keyboard behavior.
Message Action Drawer System
js/app/packages/channel/Mobile/ActionDrawer.tsx, js/app/packages/channel/Mobile/MessageActionDrawerManager.tsx, js/app/packages/channel/Mobile/message-action-drawer-context.ts
Added new mobile message action drawer UI with emoji quick-react, emoji search, and destructive/non-destructive action sections; created context-based manager for drawer state and open/close handlers; integrated haptic feedback on long-press.
Keyboard & Virtual Keyboard Handling
js/app/packages/app/component/useAppSquishHandlers.ts, js/app/packages/core/mobile/focus-and-open-keyboard.ts, js/app/packages/channel/scroll-utils.ts
Added --virtual-keyboard-height CSS variable tracking for native mobile keyboard; introduced focusAndOpenKeyboard utility for iOS keyboard triggering with dummy input and MutationObserver fallback; added scrollReplyInputAboveKeyboard to scroll reply inputs above keyboard with configurable offset.
Channel Reply Input & Thread Management
js/app/packages/channel/Channel/Channel.tsx, js/app/packages/channel/Channel/ChannelThread.tsx, js/app/packages/channel/Thread/ThreadReplyInput.tsx, js/app/packages/channel/Channel/thread-manager.ts, js/app/packages/channel/Thread/types.ts
Wired focusAndOpenKeyboard to reply actions on mobile; added replyInputEl DOM element tracking to thread state; introduced isChannelInputHidden signal with focusin/focusout handlers for mobile input visibility; extended ThreadReplyInput with ref callback for element tracking; updated ThreadState to expose reply input element accessors.
Channel Input Container
js/app/packages/channel/Input/ChannelInputContainer.tsx
Added new ChannelInputContainer component with fixed padding, centered flex layout, and optional visibility toggling via isHidden prop for managing input display on mobile.
Message Rendering & Interaction
js/app/packages/channel/Message/ChannelMessage.tsx, js/app/packages/channel/Message/HoverActions.tsx, js/app/packages/channel/Message/Root.tsx, js/app/packages/channel/Message/render-icon.tsx
Integrated message action drawer with long-press handler; updated HoverActions to hide on touch devices and switch opacity logic to hidden/block visibility; added touch:no-select-children to Root; extended renderIcon with optional className parameter.
Emoji Selector & Utilities
js/app/packages/core/component/Emoji/EmojiSelector.tsx, js/app/packages/core/directive/touchHandler.ts
Converted emoji grid from flex-wrap to CSS Grid with configurable columns; adjusted emoji sizing to 90cqi; added haptic impact feedback on long-press detection.
Other Updates
js/app/packages/channel/Channel/ThreadList.tsx, js/app/packages/block-channel/component/NewChannelBlockAdapter.tsx, js/app/packages/core/constant/featureFlags.ts
Added data-channel-scroll attribute to ThreadList scroll container; removed unnecessary fragment wrapper in NewChannelBlockAdapter Suspense boundary; hardcoded ENABLE_NEW_CHANNELS override to true.

Possibly related PRs

🚥 Pre-merge checks | ✅ 2
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title follows conventional commits format with 'feat' prefix and clearly describes the main changes: mobile action drawers and mobile improvements including new keyboard handling and drawer functionality.
Description check ✅ Passed The description is related to the changeset, covering three key improvements: message action drawer on long-press, autofocus for thread replies with keyboard workaround, and scrolling replies above virtual keyboard.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions
Copy link
Copy Markdown

github-actions bot commented Apr 1, 2026

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 9

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@js/app/packages/app/component/mobile/MobileDrawer.tsx`:
- Around line 67-70: The drawer height currently uses raw "vh" values which
bypass the dynamic viewport variable; in MobileDrawer.tsx update the height
strings inside the className (the bucket using h-[80vh] and
h-[calc(80vh-var(--virtual-keyboard-height))]) to base both states on var(--dvh)
instead (e.g., use h-[var(--dvh)] and
h-[calc(var(--dvh)-var(--virtual-keyboard-height))]) so inputFocused() still
applies the native bottom offset via --virtual-keyboard-height while respecting
the shared dynamic viewport managed by useAppSquishHandlers.
- Around line 12-17: The JSDoc usage example incorrectly passes the container
HTMLElement as the second argument to scrollToFocusedInput, causing NaN; update
the example to call scrollToFocusedInput with the focus event (e) and a numeric
offset (e.g., 10) as the second parameter instead of e.currentTarget so the
signature matches scrollToFocusedInput(event, offset) and the function receives
a number for the offset.

In `@js/app/packages/block-channel/component/NewChannelBlockAdapter.tsx`:
- Line 44: The non-null assertion on channelType() is unsafe — useChannelType
(the createMemo returned by useChannelType) can be undefined when the channel
does not exist, and the Suspense around participantsQuery does not guard this
synchronous memo; remove the `!` and either (a) guard rendering of
ChannelTopLeft by checking channelType() != null before rendering it, or (b)
pass a safe default value (e.g., a fallback enum/string) to ChannelTopLeft so it
can handle missing channels; update the code around ChannelTopLeft and any
consumers of channelType() to handle undefined rather than asserting non-null.

In `@js/app/packages/channel/Message/ActionDrawer.tsx`:
- Around line 56-59: EmojiSearchView declares an onBack prop but never uses it;
update the view so the "More reactions" action/button (inside EmojiSearchView,
lines rendering the reactions list and the "More reactions" control) invokes
props.onBack() when tapped/clicked so the drawer navigates back into the parent
Reply/Edit/Delete state; ensure the event handler is attached to the exact
control that currently opens the full reactions picker and that the handler
prevents default/propagation if needed.

In `@js/app/packages/channel/Message/HoverActions.tsx`:
- Around line 16-17: The hover-only visibility uses `hidden` with
`group-hover/message:block` (and `isTouchDevice()` hides on touch), which
prevents keyboard users from tabbing to actions; update the visibility logic so
keyboard focus also reveals the actions—e.g., add focus-based classes alongside
hover (use `group-focus/message:block` or `focus-within`/`focus:block` on the
action container) and ensure `props.persistentVisible` still overrides; locate
the conditional class construction around `props.persistentVisible` and
`isTouchDevice()` in HoverActions.tsx and add the corresponding focus-focused
class variants so keyboard users can access the controls.

In `@js/app/packages/channel/Thread/ThreadReplyInput.tsx`:
- Around line 28-29: The keyboardWillShow handler and the hidden mobile input
state aren’t cleaned up on unmount/send, so add a cleanup that always removes
the window listener and restores the channel input visibility: inside
ThreadReplyInput, wrap the logic that assigns keyboardWillShowHandler and
registers the listener with a useEffect that returns a cleanup function which
calls window.removeEventListener(...) with the same keyboardWillShowHandler and
also resets the visibility via the useMobileChannelInputVisibility API (e.g.,
mobileChannelInputVisibility.setHidden(false) or
mobileChannelInputVisibility.reset()/restoreDefault()), ensuring both
keyboardWillShowHandler and mobileChannelInputVisibility are cleared on unmount
and on every exit path (send/close).
- Around line 53-63: The onFocusIn handler registers a one-time
'keyboardWillShow' listener (keyboardWillShowHandler) but misses the case where
the keyboard is already visible; update the handler in ThreadReplyInput.tsx to
detect current keyboard state/height (via your app's keyboard height
signal/context or platform API) and call
scrollReplyInputAboveKeyboard(props.messageId, currentHeight) immediately when
the keyboard is already shown, while still keeping the once listener for future
show events; reference keyboardWillShowHandler, onFocusIn,
mobileChannelInputVisibility, window.addEventListener and
scrollReplyInputAboveKeyboard when making the change.

In `@js/app/packages/core/component/Emoji/EmojiSelector.tsx`:
- Around line 155-174: The bug is that inside the inner <For
each={group.emojis}> you compare selectedIndex() to the inner index() (local to
the group) so the same position in every group appears selected; fix by
computing the emoji's global flat index and comparing that to selectedIndex():
capture the outer group index (use the second param of the outer For, e.g.,
groupIndex), compute the starting offset for that group as the cumulative sum of
emojis in previous groups (sum of groups[0..groupIndex-1].emojis.length), then
pass isSelected={selectedIndex() === offset + index()} into EmojiOption (i.e.,
replace the current isSelected prop with a comparison against the computed
global index).

In `@js/app/packages/core/mobile/focus-and-open-keyboard.ts`:
- Around line 35-44: The temporary input is appended to document.body but
positioned using positionNear?.offsetTop/offsetLeft which are relative to
positionNear's offsetParent, causing misplacement in scrolled containers; update
focusOnDummyElementToOpenIOSKeyboard to compute coordinates from
positionNear.getBoundingClientRect() and convert to document coordinates by
adding window.scrollY (or pageYOffset) and window.scrollX (or pageXOffset), then
set tempEl.style.top/left using those summed values (with the existing +7px
vertical offset) so the dummy element is placed correctly regardless of
scroll/offset parents; keep existing fallbacks when positionNear is null.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 3042d55d-c93b-445e-a85c-e94834cacfcd

📥 Commits

Reviewing files that changed from the base of the PR and between 1446624 and 1931a1e.

📒 Files selected for processing (23)
  • js/app/packages/app/component/mobile/MobileDock.tsx
  • js/app/packages/app/component/mobile/MobileDrawer.tsx
  • js/app/packages/app/component/next-soup/soup-view/filters-bar/mobile-filter-drawer.tsx
  • js/app/packages/app/component/useAppSquishHandlers.ts
  • js/app/packages/block-channel/component/NewChannelBlockAdapter.tsx
  • js/app/packages/channel/Channel/Channel.tsx
  • js/app/packages/channel/Channel/ThreadList.tsx
  • js/app/packages/channel/Channel/mobile-channel-input-visibility.tsx
  • js/app/packages/channel/Input/ChannelInputContainer.tsx
  • js/app/packages/channel/Message/ActionDrawer.tsx
  • js/app/packages/channel/Message/ChannelMessage.tsx
  • js/app/packages/channel/Message/HoverActions.tsx
  • js/app/packages/channel/Message/MessageActionDrawerManager.tsx
  • js/app/packages/channel/Message/Root.tsx
  • js/app/packages/channel/Message/context.ts
  • js/app/packages/channel/Message/index.ts
  • js/app/packages/channel/Message/render-icon.tsx
  • js/app/packages/channel/Thread/ChannelThread.tsx
  • js/app/packages/channel/Thread/ThreadReplyInput.tsx
  • js/app/packages/channel/scroll-utils.ts
  • js/app/packages/core/component/Emoji/EmojiSelector.tsx
  • js/app/packages/core/directive/touchHandler.ts
  • js/app/packages/core/mobile/focus-and-open-keyboard.ts

Comment on lines +12 to +17
* Call this from a scroll container's `onFocusIn` to smoothly scroll a
* focused input/textarea to `offset` px from the container's top edge.
*
* Usage:
* <div onFocusIn={(e) => scrollToFocusedInput(e, e.currentTarget)}>
*/
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Fix the JSDoc usage example.

The second parameter is an offset, not the container element. Copy-pasting the current snippet would pass an HTMLElement where a number is expected and end up scrolling to NaN.

📝 Suggested doc fix
- *   <div onFocusIn={(e) => scrollToFocusedInput(e, e.currentTarget)}>
+ *   <div onFocusIn={(e) => scrollToFocusedInput(e)}>
+ *   <div onFocusIn={(e) => scrollToFocusedInput(e, 40)}>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
* Call this from a scroll container's `onFocusIn` to smoothly scroll a
* focused input/textarea to `offset` px from the container's top edge.
*
* Usage:
* <div onFocusIn={(e) => scrollToFocusedInput(e, e.currentTarget)}>
*/
* Call this from a scroll container's `onFocusIn` to smoothly scroll a
* focused input/textarea to `offset` px from the container's top edge.
*
* Usage:
* <div onFocusIn={(e) => scrollToFocusedInput(e)}>
* <div onFocusIn={(e) => scrollToFocusedInput(e, 40)}>
*/
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@js/app/packages/app/component/mobile/MobileDrawer.tsx` around lines 12 - 17,
The JSDoc usage example incorrectly passes the container HTMLElement as the
second argument to scrollToFocusedInput, causing NaN; update the example to call
scrollToFocusedInput with the focus event (e) and a numeric offset (e.g., 10) as
the second parameter instead of e.currentTarget so the signature matches
scrollToFocusedInput(event, offset) and the function receives a number for the
offset.

Comment on lines +67 to +70
'bottom-(--virtual-keyboard-height) fixed left-0 right-0 z-modal bg-page rounded-t-2xl flex flex-col h-[80vh] data-transitioning:transition-transform data-transitioning:duration-200 ease-out',
inputFocused()
? 'pb-0 h-[calc(80vh-var(--virtual-keyboard-height))]'
: 'pb-(--safe-bottom)',
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Use the shared dynamic viewport variable for drawer height.

useAppSquishHandlers updates --dvh on the iOS/web path, but both height states here are still based on raw vh. That bypasses the existing keyboard-resize contract on mobile web, so the drawer can remain too tall and still sit under the keyboard on Safari. Base the height on var(--dvh) and keep --virtual-keyboard-height for the native bottom offset.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@js/app/packages/app/component/mobile/MobileDrawer.tsx` around lines 67 - 70,
The drawer height currently uses raw "vh" values which bypass the dynamic
viewport variable; in MobileDrawer.tsx update the height strings inside the
className (the bucket using h-[80vh] and
h-[calc(80vh-var(--virtual-keyboard-height))]) to base both states on var(--dvh)
instead (e.g., use h-[var(--dvh)] and
h-[calc(var(--dvh)-var(--virtual-keyboard-height))]) so inputFocused() still
applies the native bottom offset via --virtual-keyboard-height while respecting
the shared dynamic viewport managed by useAppSquishHandlers.

</>
<ChannelTopLeft
channelId={props.channelId}
channelType={channelType()!}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Check useChannelType return type and implementation to verify non-null assertion safety

# Find the useChannelType implementation
echo "=== useChannelType implementation ==="
ast-grep --pattern 'export function useChannelType($$$) {
  $$$
}'

echo -e "\n=== useChannelType type signature ==="
rg -n -A 5 'export.*useChannelType' --type=ts

echo -e "\n=== Check if it returns a resource or signal that can suspend ==="
rg -n -C 5 'function useChannelType' --type=ts

Repository: macro-inc/macro

Length of output: 1912


🏁 Script executed:

# View the NewChannelBlockAdapter.tsx file around lines 31-50 to understand context
cd js/app/packages/block-channel/component && sed -n '25,55p' NewChannelBlockAdapter.tsx

Repository: macro-inc/macro

Length of output: 1014


Remove the unsafe non-null assertion on channelType().

The useChannelType hook returns a createMemo that derives ctx.channelsById()[channelId]?.channel_type, which can legitimately return undefined when the channel doesn't exist. The non-null assertion (!) bypasses this safety and will pass undefined to ChannelTopLeft if the channel is missing, causing a potential runtime error.

This is also inconsistent with line 36, where channelType() is safely used in an equality comparison without any assertion. Either handle the undefined case properly or ensure the channel is guaranteed to exist before rendering.

The Suspense boundary only suspends the participantsQuery resource; it does not protect channelType() since that's a synchronous memo derivation.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@js/app/packages/block-channel/component/NewChannelBlockAdapter.tsx` at line
44, The non-null assertion on channelType() is unsafe — useChannelType (the
createMemo returned by useChannelType) can be undefined when the channel does
not exist, and the Suspense around participantsQuery does not guard this
synchronous memo; remove the `!` and either (a) guard rendering of
ChannelTopLeft by checking channelType() != null before rendering it, or (b)
pass a safe default value (e.g., a fallback enum/string) to ChannelTopLeft so it
can handle missing channels; update the code around ChannelTopLeft and any
consumers of channelType() to handle undefined rather than asserting non-null.

Comment on lines +56 to +59
function EmojiSearchView(props: {
onSelect: (emoji: string) => void;
onBack: () => void;
}) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Expose the back path from emoji search.

EmojiSearchView accepts onBack, but nothing in this view calls it. After tapping More reactions, users have to dismiss and reopen the entire drawer to get back to Reply/Edit/Delete.

Also applies to: 69-97

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@js/app/packages/channel/Message/ActionDrawer.tsx` around lines 56 - 59,
EmojiSearchView declares an onBack prop but never uses it; update the view so
the "More reactions" action/button (inside EmojiSearchView, lines rendering the
reactions list and the "More reactions" control) invokes props.onBack() when
tapped/clicked so the drawer navigates back into the parent Reply/Edit/Delete
state; ensure the event handler is attached to the exact control that currently
opens the full reactions picker and that the handler prevents
default/propagation if needed.

Comment on lines +16 to +17
props.persistentVisible ? '' : 'hidden group-hover/message:block',
isTouchDevice() && 'hidden',
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

hidden + hover-only reveal can make actions unreachable via keyboard.

At Line 16, actions are only shown on hover. With display:none, keyboard users may not be able to tab into controls unless focus-based visibility is also enabled.

Proposed fix
-        props.persistentVisible ? '' : 'hidden group-hover/message:block',
+        props.persistentVisible
+          ? ''
+          : 'hidden group-hover/message:block group-focus-within/message:block',
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@js/app/packages/channel/Message/HoverActions.tsx` around lines 16 - 17, The
hover-only visibility uses `hidden` with `group-hover/message:block` (and
`isTouchDevice()` hides on touch), which prevents keyboard users from tabbing to
actions; update the visibility logic so keyboard focus also reveals the
actions—e.g., add focus-based classes alongside hover (use
`group-focus/message:block` or `focus-within`/`focus:block` on the action
container) and ensure `props.persistentVisible` still overrides; locate the
conditional class construction around `props.persistentVisible` and
`isTouchDevice()` in HoverActions.tsx and add the corresponding focus-focused
class variants so keyboard users can access the controls.

Comment on lines +28 to +29
const mobileChannelInputVisibility = useMobileChannelInputVisibility();
let keyboardWillShowHandler: ((e: Event) => void) | undefined;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cd js/app/packages/channel/Thread && cat -n ThreadReplyInput.tsx | head -100

Repository: macro-inc/macro

Length of output: 4666


Clean up the mobile reply-focus state on every exit path.

The pending keyboardWillShow callback and hidden-channel-input state are only cleared when focus moves to a node outside this wrapper. If the component unmounts before this happens—when closing or sending a reply—the main channel input stays hidden and the window listener is never removed, causing memory leaks and persistent UI issues.

Suggested cleanup pattern
-import type { Accessor, Setter } from 'solid-js';
+import { onCleanup, type Accessor, type Setter } from 'solid-js';

 export function ThreadReplyInput(props: ThreadReplyInputProps) {
   const mobileChannelInputVisibility = useMobileChannelInputVisibility();
   let keyboardWillShowHandler: ((e: Event) => void) | undefined;
+  const clearKeyboardWillShow = () => {
+    if (!keyboardWillShowHandler) return;
+    window.removeEventListener('keyboardWillShow', keyboardWillShowHandler);
+    keyboardWillShowHandler = undefined;
+  };
+
+  onCleanup(() => {
+    mobileChannelInputVisibility?.show();
+    clearKeyboardWillShow();
+  });

       onFocusIn={() => {
         mobileChannelInputVisibility?.hide();
+        clearKeyboardWillShow();
         keyboardWillShowHandler = (event: Event) => {
           const height =
             (event as CustomEvent<{ height: number }>).detail?.height ?? 0;
           scrollReplyInputAboveKeyboard(props.messageId, height);
           keyboardWillShowHandler = undefined;
@@
       onFocusOut={(e) => {
         if (!e.currentTarget.contains(e.relatedTarget as Node)) {
           mobileChannelInputVisibility?.show();
-          if (keyboardWillShowHandler) {
-            window.removeEventListener(
-              'keyboardWillShow',
-              keyboardWillShowHandler
-            );
-            keyboardWillShowHandler = undefined;
-          }
+          clearKeyboardWillShow();
         }
       }}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@js/app/packages/channel/Thread/ThreadReplyInput.tsx` around lines 28 - 29,
The keyboardWillShow handler and the hidden mobile input state aren’t cleaned up
on unmount/send, so add a cleanup that always removes the window listener and
restores the channel input visibility: inside ThreadReplyInput, wrap the logic
that assigns keyboardWillShowHandler and registers the listener with a useEffect
that returns a cleanup function which calls window.removeEventListener(...) with
the same keyboardWillShowHandler and also resets the visibility via the
useMobileChannelInputVisibility API (e.g.,
mobileChannelInputVisibility.setHidden(false) or
mobileChannelInputVisibility.reset()/restoreDefault()), ensuring both
keyboardWillShowHandler and mobileChannelInputVisibility are cleared on unmount
and on every exit path (send/close).

Comment on lines +53 to +63
onFocusIn={() => {
mobileChannelInputVisibility?.hide();
keyboardWillShowHandler = (event: Event) => {
const height =
(event as CustomEvent<{ height: number }>).detail?.height ?? 0;
scrollReplyInputAboveKeyboard(props.messageId, height);
keyboardWillShowHandler = undefined;
};
window.addEventListener('keyboardWillShow', keyboardWillShowHandler, {
once: true,
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

fd -t f "ThreadReplyInput.tsx" --path "*channel*"

Repository: macro-inc/macro

Length of output: 292


🏁 Script executed:

cat -n js/app/packages/channel/Thread/ThreadReplyInput.tsx | head -100

Repository: macro-inc/macro

Length of output: 4666


🏁 Script executed:

cat -n js/app/packages/channel/Thread/ThreadReplyInput.tsx | sed -n '40,80p'

Repository: macro-inc/macro

Length of output: 1623


🏁 Script executed:

rg -n "scrollReplyInputAboveKeyboard" js/app/packages/channel --context 3

Repository: macro-inc/macro

Length of output: 2047


🏁 Script executed:

rg -n "keyboardWillShow" js/app/packages/channel

Repository: macro-inc/macro

Length of output: 905


🏁 Script executed:

fd -t f "scroll-utils" js/app/packages/channel

Repository: macro-inc/macro

Length of output: 98


🏁 Script executed:

cat -n js/app/packages/channel/scroll-utils.ts | sed -n '50,100p'

Repository: macro-inc/macro

Length of output: 1566


🏁 Script executed:

rg -n "keyboardWillShow\|keyboardDidShow\|keyboard" js/app/packages/channel/Input --context 2 | head -60

Repository: macro-inc/macro

Length of output: 41


🏁 Script executed:

rg -n "onFocusIn\|addEventListener.*keyboard" js/app/packages/channel/Input -A 10 | head -80

Repository: macro-inc/macro

Length of output: 41


🏁 Script executed:

fd -t f "ChannelInput" js/app/packages/channel/Input

Repository: macro-inc/macro

Length of output: 161


🏁 Script executed:

rg -n "keyboardWillShow\|keyboardDidShow" js/app/packages/channel -B 2 -A 5

Repository: macro-inc/macro

Length of output: 41


🏁 Script executed:

rg -n "window.innerHeight\|keyboard" js/app/packages/channel/Input/ChannelInput.tsx | head -30

Repository: macro-inc/macro

Length of output: 41


🏁 Script executed:

cat -n js/app/packages/channel/Input/ChannelInput.tsx | head -150

Repository: macro-inc/macro

Length of output: 5981


🏁 Script executed:

rg -n "addEventListener.*keyboard\|onFocusIn\|keyboardHeight" js/app/packages/channel -B 2 -A 5 | head -100

Repository: macro-inc/macro

Length of output: 41


🏁 Script executed:

rg -n "window.addEventListener" js/app/packages/channel/Input/ -B 3 -A 3

Repository: macro-inc/macro

Length of output: 41


🏁 Script executed:

rg -n "mobileChannelInputVisibility\|keyboardHeight" js/app/packages/channel -B 2 -A 5 | head -80

Repository: macro-inc/macro

Length of output: 41


🏁 Script executed:

cat -n js/app/packages/channel/Input/ChannelInputContainer.tsx | head -200

Repository: macro-inc/macro

Length of output: 756


🏁 Script executed:

rg -n "window.addEventListener\|keyboardDidShow\|keyboard" js/app/packages/channel/Channel/ -B 2 -A 5 | head -100

Repository: macro-inc/macro

Length of output: 41


🏁 Script executed:

rg -n "addEventListener.*keyboardWillShow\|addEventListener.*keyboard" js/app/packages/channel --context 5

Repository: macro-inc/macro

Length of output: 41


🏁 Script executed:

fd -t f "mobile-channel-input-visibility" js/app/packages/channel

Repository: macro-inc/macro

Length of output: 126


🏁 Script executed:

rg -n "class.*Keyboard\|function.*Keyboard\|keyboard" js/app/packages/channel/Channel/mobile-channel-input-visibility -B 2 -A 8

Repository: macro-inc/macro

Length of output: 162


🏁 Script executed:

cat -n js/app/packages/channel/Channel/mobile-channel-input-visibility.tsx

Repository: macro-inc/macro

Length of output: 1525


🏁 Script executed:

rg -n "addEventListener\|keyboardWillShow\|keyboardDidShow" js/app/packages/channel/Thread/ThreadReplyInput.tsx -B 3 -A 3

Repository: macro-inc/macro

Length of output: 41


🏁 Script executed:

rg -n "onFocusIn" js/app/packages/channel --context 10 | head -120

Repository: macro-inc/macro

Length of output: 1908


🏁 Script executed:

rg -n "keyboardDidShow\|Keyboard\|keyboard" js/app/packages/channel -t tsx -t ts | grep -i "show\|hide\|visible\|height" | head -50

Repository: macro-inc/macro

Length of output: 86


🏁 Script executed:

rg -n "Capacitor\|window\.innerHeight\|visualViewport" js/app --context 3 | head -100

Repository: macro-inc/macro

Length of output: 41


🏁 Script executed:

rg -n "already.*visible\|keyboard.*already" js/app --context 5

Repository: macro-inc/macro

Length of output: 41


🏁 Script executed:

rg -n "keyboardWillShow" js/app --context 5

Repository: macro-inc/macro

Length of output: 5220


🏁 Script executed:

rg -n "initialKeyboard\|startingKeyboard\|Capacitor" js/app/packages/channel --context 3

Repository: macro-inc/macro

Length of output: 41


🏁 Script executed:

cat -n js/app/packages/channel/Thread/ThreadReplyInput.tsx | sed -n '1,120p'

Repository: macro-inc/macro

Length of output: 5583


🏁 Script executed:

cat -n js/app/packages/app/component/useAppSquishHandlers.ts | sed -n '30,80p'

Repository: macro-inc/macro

Length of output: 2286


🏁 Script executed:

rg -n "keyboardDidShow\|keyboardDidHide\|visualViewport" js/app --context 3

Repository: macro-inc/macro

Length of output: 41


🏁 Script executed:

rg -n "--virtual-keyboard-height" js/app --context 3 | head -40

Repository: macro-inc/macro

Length of output: 486


🏁 Script executed:

rg -n "virtual-keyboard-height" js/app --context 2 | head -60

Repository: macro-inc/macro

Length of output: 2761


🏁 Script executed:

rg -n "virtualKeyboardHeight\|virtualKeyboardVisible" js/app/packages --context 3 | head -80

Repository: macro-inc/macro

Length of output: 41


🏁 Script executed:

cat -n js/app/packages/app/component/useAppSquishHandlers.ts | sed -n '1,50p'

Repository: macro-inc/macro

Length of output: 2224


Handle replies opened while the keyboard is already visible.

When a thread reply is opened while the channel input remains focused (keyboard already visible), no keyboardWillShow event fires. The listener registered in onFocusIn with {once: true} cannot catch an event that occurred before it was registered, causing scrollReplyInputAboveKeyboard() to never be called in this scenario.

Consider detecting the current keyboard state on reply open and calling the scroll function immediately if the keyboard is already visible, or use a keyboard height signal/context that tracks the current keyboard state.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@js/app/packages/channel/Thread/ThreadReplyInput.tsx` around lines 53 - 63,
The onFocusIn handler registers a one-time 'keyboardWillShow' listener
(keyboardWillShowHandler) but misses the case where the keyboard is already
visible; update the handler in ThreadReplyInput.tsx to detect current keyboard
state/height (via your app's keyboard height signal/context or platform API) and
call scrollReplyInputAboveKeyboard(props.messageId, currentHeight) immediately
when the keyboard is already shown, while still keeping the once listener for
future show events; reference keyboardWillShowHandler, onFocusIn,
mobileChannelInputVisibility, window.addEventListener and
scrollReplyInputAboveKeyboard when making the change.

Comment on lines +155 to +174
<For each={groups}>
{(group): JSX.Element => (
<Show when={group.emojis.length > 0}>
<div class="mt-2 w-full">
<p class="pl-1 text-ink-extra-muted text-xs">{group.name}</p>
<div style={gridStyle()}>
<For each={group.emojis}>
{(emojiItem, index): JSX.Element => (
<EmojiOption
emoji={emojiItem}
onEmojiClick={props.onEmojiClick}
isSelected={selectedIndex() === index()}
/>
)}
</For>
</div>
</div>
</Show>
)}
</For>
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Bug: isSelected comparison uses local group index instead of global flat index.

In the grouped emoji view, index() inside the inner <For each={group.emojis}> is the local index within that specific group. However, selectedIndex() is a global index computed from groups.flatMap((g) => g.emojis) in the keyboard handler.

This causes emojis at the same local position in every group to appear selected simultaneously. For example, if selectedIndex() is 3, then the emoji at local index 3 in group 1, group 2, etc., will all show the selected highlight.

🐛 Proposed fix: track cumulative offset for global index comparison
         <For each={groups}>
-          {(group): JSX.Element => (
+          {(group, groupIndex): JSX.Element => {
+            const groupOffset = () => {
+              let offset = 0;
+              for (let i = 0; i < groupIndex(); i++) {
+                offset += groups[i].emojis.length;
+              }
+              return offset;
+            };
+            return (
             <Show when={group.emojis.length > 0}>
               <div class="mt-2 w-full">
                 <p class="pl-1 text-ink-extra-muted text-xs">{group.name}</p>
                 <div style={gridStyle()}>
                   <For each={group.emojis}>
                     {(emojiItem, index): JSX.Element => (
                       <EmojiOption
                         emoji={emojiItem}
                         onEmojiClick={props.onEmojiClick}
-                        isSelected={selectedIndex() === index()}
+                        isSelected={selectedIndex() === groupOffset() + index()}
                       />
                     )}
                   </For>
                 </div>
               </div>
             </Show>
-          )}
+            );
+          }}
         </For>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@js/app/packages/core/component/Emoji/EmojiSelector.tsx` around lines 155 -
174, The bug is that inside the inner <For each={group.emojis}> you compare
selectedIndex() to the inner index() (local to the group) so the same position
in every group appears selected; fix by computing the emoji's global flat index
and comparing that to selectedIndex(): capture the outer group index (use the
second param of the outer For, e.g., groupIndex), compute the starting offset
for that group as the cumulative sum of emojis in previous groups (sum of
groups[0..groupIndex-1].emojis.length), then pass isSelected={selectedIndex()
=== offset + index()} into EmojiOption (i.e., replace the current isSelected
prop with a comparison against the computed global index).

Comment on lines +35 to +44
function focusOnDummyElementToOpenIOSKeyboard() {
tempEl.style.position = 'absolute';
tempEl.style.top = `${(positionNear?.offsetTop ?? 0) + 7}px`;
tempEl.style.left = `${positionNear?.offsetLeft ?? 0}px`;
tempEl.style.height = '0';
tempEl.style.opacity = '0';
tempEl.style.fontSize = '16px';
document.body.appendChild(tempEl);
tempEl.focus();
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

find . -name "focus-and-open-keyboard.ts" -type f

Repository: macro-inc/macro

Length of output: 115


🏁 Script executed:

cat -n js/app/packages/core/mobile/focus-and-open-keyboard.ts

Repository: macro-inc/macro

Length of output: 2552


Fix temporary input positioning for scrolled contexts.

tempEl is appended to document.body but positioned using positionNear?.offsetTop/offsetLeft, which are relative to positionNear's offset parent. In scrollable views (e.g., scrolled thread), when positionNear is nested within a scrolled container, these coordinates are relative to that container—not document.body—causing misalignment and reintroducing the viewport jump this workaround prevents.

Suggested coordinate fix
   function focusOnDummyElementToOpenIOSKeyboard() {
+    const rect = positionNear?.getBoundingClientRect();
     tempEl.style.position = 'absolute';
-    tempEl.style.top = `${(positionNear?.offsetTop ?? 0) + 7}px`;
-    tempEl.style.left = `${positionNear?.offsetLeft ?? 0}px`;
+    tempEl.style.top = `${(rect?.top ?? 0) + window.scrollY + 7}px`;
+    tempEl.style.left = `${(rect?.left ?? 0) + window.scrollX}px`;
     tempEl.style.height = '0';
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@js/app/packages/core/mobile/focus-and-open-keyboard.ts` around lines 35 - 44,
The temporary input is appended to document.body but positioned using
positionNear?.offsetTop/offsetLeft which are relative to positionNear's
offsetParent, causing misplacement in scrolled containers; update
focusOnDummyElementToOpenIOSKeyboard to compute coordinates from
positionNear.getBoundingClientRect() and convert to document coordinates by
adding window.scrollY (or pageYOffset) and window.scrollX (or pageXOffset), then
set tempEl.style.top/left using those summed values (with the existing +7px
vertical offset) so the dummy element is placed correctly regardless of
scroll/offset parents; keep existing fallbacks when positionNear is null.

@peterchinman peterchinman changed the title feat[new-channels] scroll thread reply inputs above the virtual keyboard feat[new-channels]: mobile action drawers and other mobile improvements Apr 1, 2026
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
js/app/packages/core/constant/featureFlags.ts (1)

283-291: ⚠️ Potential issue | 🔴 Critical

Hardcoded override bypasses feature flag infrastructure and creates dead code.

Changing ENABLE_NEW_CHANNELS_OVERRIDE from getFeatureFlagOverride('ENABLE_NEW_CHANNELS') to a hardcoded true has significant implications:

  1. Dead code: Line 290 becomes unreachable since ENABLE_NEW_CHANNELS_OVERRIDE !== undefined is always true.
  2. No rollback path: The VITE_ENABLE_NEW_CHANNELS=false environment variable override no longer works.
  3. Bypasses PostHog: Gradual rollout via PostHog is now impossible.
  4. Broad impact: This gates critical logic in message caching, sync operations, optimistic updates, and reaction handling across 20+ call sites.

This change appears unrelated to the PR's stated objectives (mobile keyboard/input scroll behavior). If this is an intentional full rollout, clean up the dead code. If not, this may be a debugging artifact that should be reverted.

If intentional rollout, clean up dead code
-const ENABLE_NEW_CHANNELS_OVERRIDE = true;
-
-export function ENABLE_NEW_CHANNELS(): boolean {
-  if (ENABLE_NEW_CHANNELS_OVERRIDE !== undefined) {
-    return ENABLE_NEW_CHANNELS_OVERRIDE;
-  }
-
-  return analytics.posthog.isFeatureEnabled('enable-new-channels') ?? false;
-}
+export const ENABLE_NEW_CHANNELS = true;

Note: This would require updating all call sites from ENABLE_NEW_CHANNELS() to ENABLE_NEW_CHANNELS.

If debugging artifact, revert to original
-const ENABLE_NEW_CHANNELS_OVERRIDE = true;
+const ENABLE_NEW_CHANNELS_OVERRIDE = getFeatureFlagOverride('ENABLE_NEW_CHANNELS');
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@js/app/packages/core/constant/featureFlags.ts` around lines 283 - 291, The
ENABLE_NEW_CHANNELS override was hardcoded to true, making ENABLE_NEW_CHANNELS()
always return the override and rendering analytics.posthog logic dead; revert
the change by restoring the original override source (use
getFeatureFlagOverride('ENABLE_NEW_CHANNELS') or read VITE_ENABLE_NEW_CHANNELS)
so ENABLE_NEW_CHANNELS_OVERRIDE can be undefined and allow ENABLE_NEW_CHANNELS()
to fall back to analytics.posthog.isFeatureEnabled('enable-new-channels') OR, if
this is an intentional permanent rollout, remove the function wrapper and export
a single constant ENABLE_NEW_CHANNELS (and update all call sites from
ENABLE_NEW_CHANNELS() to ENABLE_NEW_CHANNELS) to eliminate dead code and restore
correct rollback behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@js/app/packages/channel/Channel/Channel.tsx`:
- Around line 308-364: When handling mobile reply focus in the createEffect for
state.replyInputEl, detect if the virtual keyboard is already visible by reading
the CSS variable --virtual-keyboard-height (via
getComputedStyle(document.documentElement).getPropertyValue('--virtual-keyboard-height')
and parsing to a number) and if > 0 call scrollReplyInputAboveKeyboard(item.id,
height) immediately; keep the existing keyboardWillShow handler/once listener
for the case keyboard appears later, and ensure keyboardWillShowHandler is
set/cleared exactly as currently done so removeEventListener still works (update
the focusin block around setIsChannelInputHidden and keyboardWillShowHandler to
perform the immediate check and call).

In `@js/app/packages/channel/Thread/ThreadReplyInput.tsx`:
- Line 50: ThreadReplyInput currently sets props.setReplyInputEl only when the
DOM node mounts but never clears it on unmount, leaving a stale element
reference; update ThreadReplyInput so the element is assigned and cleared
reliably by using a stable ref (e.g., localReplyInputRef) and a useEffect that
calls props.setReplyInputEl(localReplyInputRef.current) and returns a cleanup
that calls props.setReplyInputEl(null), ensuring Channel.tsx’s mobile keyboard
handler won’t get a detached element; reference the prop setReplyInputEl and the
ThreadReplyInput component when implementing this change.

---

Outside diff comments:
In `@js/app/packages/core/constant/featureFlags.ts`:
- Around line 283-291: The ENABLE_NEW_CHANNELS override was hardcoded to true,
making ENABLE_NEW_CHANNELS() always return the override and rendering
analytics.posthog logic dead; revert the change by restoring the original
override source (use getFeatureFlagOverride('ENABLE_NEW_CHANNELS') or read
VITE_ENABLE_NEW_CHANNELS) so ENABLE_NEW_CHANNELS_OVERRIDE can be undefined and
allow ENABLE_NEW_CHANNELS() to fall back to
analytics.posthog.isFeatureEnabled('enable-new-channels') OR, if this is an
intentional permanent rollout, remove the function wrapper and export a single
constant ENABLE_NEW_CHANNELS (and update all call sites from
ENABLE_NEW_CHANNELS() to ENABLE_NEW_CHANNELS) to eliminate dead code and restore
correct rollback behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 66e2c4e0-2118-4d63-b9e4-474c9c3c82a0

📥 Commits

Reviewing files that changed from the base of the PR and between 1931a1e and 12db848.

📒 Files selected for processing (7)
  • js/app/packages/channel/Channel/Channel.tsx
  • js/app/packages/channel/Channel/thread-manager.ts
  • js/app/packages/channel/Input/ChannelInputContainer.tsx
  • js/app/packages/channel/Thread/ChannelThread.tsx
  • js/app/packages/channel/Thread/ThreadReplyInput.tsx
  • js/app/packages/channel/Thread/types.ts
  • js/app/packages/core/constant/featureFlags.ts

@peterchinman peterchinman merged commit 6c24c93 into main Apr 1, 2026
21 checks passed
@peterchinman peterchinman deleted the new-channel-mobile-input-scroll branch April 1, 2026 22:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant