Skip to content

Use inline menus for list, chat, avatar and account menus - #12296

Open
mozzius wants to merge 10 commits into
samuel/app-3734-inline-native-menusfrom
samuel/app-3734-more-inline-menus
Open

mozzius wants to merge 10 commits into
samuel/app-3734-inline-native-menusfrom
samuel/app-3734-more-inline-menus

Conversation

@mozzius

@mozzius mozzius commented Oct 31, 2026 •

Copy link
Copy Markdown
Member

Part of APP-3734. Stacked on #12271.

Moves more menus to <Menu.Root inline>. They come from an audit of every Menu.Root in the app, with the showCancel (sheet-style) ones first.

Menu Change
List … (curate and mod lists) inline
Mod list "Subscribe" inline
Settings, account … ("Remove account") inline. The absolute positioning moves from the trigger to a View around Menu.Root, since an inline trigger sits inside the menu's native host
Group chat member … inline. "Message" now fetches the member's chat availability if the trigger's prefetch hasn't run (see below)
In-chat … (ConvoMenu) inline in the conversation header, with a refactor (see below)
iPad repost button (RepostButtonMenu) inline. iPhone keeps its dialog, and web its dropdown
Starter pack … inline. "Opt out of Starter Pack" gets the leave-chat icon (an undo arrow when it reads "Undo opt-out"), and the trigger's label, copied from the repost menu, now says "Open Starter Pack menu"
Native lightbox image options (Lightbox/chrome/ImageMenu) inline, replacing the hand-rolled Modal and animated card. It's wrapped in ThemeProvider theme="dark" so it stays dark like the lightbox, as the web lightbox already does. The native header's buttons grow from 32pt to 44pt circles (24pt icons): on iOS, hitSlop doesn't apply to a native menu trigger, so the circle itself is the tap target

ConvoMenu

The same menu is the chat list's long-press menu, which has no trigger and opens from code. Native menus can't, so that one stays a sheet. For the header:

  • The trigger's View wrapper moved inside the trigger, so Menu.Root finds Menu.Trigger as a direct child.
  • The items moved from the MenuContent component into a useConvoMenuItems hook, which returns the groups. The inline menu puts them straight into Menu.Outer, so it can see the groups. The sheet keeps them in MenuContent, which only mounts while the sheet is open. That way, chat list rows still don't each fetch their convo.

The trigger's onPress on iOS

On iOS, the SwiftUI menu takes the tap, so the trigger's own onPress doesn't run (checked with a temporary log on the simulator). #12271 now calls it the first time each menu opens, which is the only open that SwiftUI reports. Here:

  • Group chat member menu: its trigger prefetches the member's chat availability. The prefetch can still miss: the "Admin" badge trigger keeps its onPress in pressableProps, where the menu can't find it. So "Message" now fetches on press when the data isn't there.
  • iPad repost button: long-press-to-quote no longer works. A long press opens the menu, iOS's usual press-and-hold, so you can hold, slide to "Quote post" and let go. The longPressToQuote prop and its tests are removed, since iPad was its only user.

Not converted

  • Edit profile banner: its trigger is full width, and an inline trigger is sized to its content, so the banner collapsed. It stays a sheet until inline menus can stretch their trigger.
  • Edit profile avatar: it worked, but it didn't feel better than the sheet.
  • Chat request "Reject": it also stays a sheet. In the conversation, its button stretches across the row, which an inline trigger can't do.
  • Chat tab long-press menu (BottomBar): no trigger, and it opens from code.
  • Other good candidates from the audit:
    • Pickers: invite-friends theme picker, mod inbox filter, search language, post language, advanced search dropdowns.
    • Others: feed header options, muted words renew menu.
  • Not candidates:
    • PostMenu and ShareMenu: they render their contents lazily.
    • RichTextTag: an inline-text trigger.
    • Long-press context menus.
    • LeftNav.

Test plan

  • pnpm typecheck, pnpm lint, pnpm test src/components/Menu
  • iOS, iPhone 17 Pro simulator (iOS 27):
    • Edit profile banner and avatar: the banner collapsed with inline, so both are back to sheets. Banner full width again
    • A mod list (@skywatch.blue "Spam"): "Subscribe" and … open, with sections and icons
    • In-chat …: three sections, destructive items in red. "Go to profile" navigates
    • Chat list long-press: still a sheet, with its sections
  • Not checked yet: the Settings account … (needs two accounts signed in) and the group chat member menu (needs a group chat)
  • iPad, iPad Air 11-inch simulator (iOS 27): the feed's repost button opens "Repost" and "Quote post". A long press opens the menu
  • Starter pack …, own and someone else's (not yet run on a device)
  • Native lightbox: the image options menu, its dark theme, and Share/Save (not yet run on a device)
  • Feed scrolling on a real iPad, now that each repost button has its own native host
  • Android

🤖 Generated with Claude Code

@mozzius
mozzius added this pull request to stack #12297 October 31, 2026 18:57
@linear-code

linear-code Bot commented Oct 31, 2026

Copy link
Copy Markdown

APP-3734

@mozzius mozzius changed the title use inline menus for list, chat, avatar and account menus Use inline menus for list, chat, avatar and account menus Oct 31, 2026
@github-actions

Copy link
Copy Markdown
Contributor

The Pull Request introduced native fingerprint changes against the base commit.


Generated by PR labeler 🤖

@github-actions

github-actions Bot commented Oct 31, 2026 •

Copy link
Copy Markdown
Contributor
Size Old size New size Diff
Uncompressed 9.78 MB 9.78 MB +1.03 KB (0.01%)
Gzip 2.86 MB 2.86 MB +392 B (0.01%)

@mozzius
mozzius force-pushed the samuel/app-3734-more-inline-menus branch 2 times, most recently from 7229842 to 2b2bf8d Compare November 1, 2026 09:46
@mozzius
mozzius force-pushed the samuel/app-3734-more-inline-menus branch 2 times, most recently from 92bf9bf to 1aa5707 Compare November 2, 2026 08:00
@mozzius
mozzius marked this pull request as ready for review November 2, 2026 17:42
Comment thread src/screens/Settings/Settings.tsx Resolved
Comment thread src/components/dms/ConvoMenu.tsx Resolved
@github-actions

github-actions Bot commented Nov 2, 2026

Copy link
Copy Markdown
Contributor

I found two problems and left an inline comment on each:

  • blocking, src/screens/Settings/Settings.tsx:675: Making this menu inline puts the account … trigger inside the native menu container. On native, that means its a.absolute positioning is measured inside that container instead of against the account row, so the button no longer sits on the row. It either collapses or shows up underneath, with extra space between rows. The test plan also lists this menu as not yet checked.
  • non-blocking, src/components/dms/ConvoMenu.tsx:87-94: The trigger's root element is now the wrapping View, which has no onPress. On iOS the menu takes the tap and only calls the trigger's root onPress, so Keyboard.dismiss() never runs when the in-chat … opens.

Claude review, run

@mozzius
mozzius force-pushed the samuel/app-3734-more-inline-menus branch from 1aa5707 to abd73a9 Compare November 2, 2026 18:27
Comment thread src/screens/Settings/Settings.tsx Resolved
Comment thread src/components/dms/ConvoMenu.tsx Resolved
@github-actions

github-actions Bot commented Nov 2, 2026

Copy link
Copy Markdown
Contributor

I found two non-blocking issues and posted them as inline comments:

  • src/screens/Settings/Settings.tsx:675: the account … button no longer sits over the right side of each account row on iOS and Android. Its trigger uses absolute positioning, which now applies inside the inline menu's own view. That view lays out after the row, so the button ends up below it or collapses.
  • src/components/dms/ConvoMenu.tsx:91: in a conversation on iOS, Keyboard.dismiss() now only runs the first time the header menu opens. Opening it again leaves the keyboard up, including when an item then opens a prompt such as "Report conversation" or "Leave conversation".

Claude review, run

@mozzius
mozzius force-pushed the samuel/app-3734-more-inline-menus branch from abd73a9 to 6687caa Compare November 3, 2026 07:57
Comment thread src/screens/Settings/Settings.tsx Resolved
@github-actions

github-actions Bot commented Nov 3, 2026

Copy link
Copy Markdown
Contributor

non-blocking: the Settings account … trigger loses its absolute placement under inline (src/screens/Settings/Settings.tsx:675)

The trigger Pressable is placed with a.absolute, {top: 10, right: tokens.space.lg} inside the row's a.relative wrapper. An inline menu puts the trigger inside Host matchContents > RNHostView matchContents (src/components/Menu/inline/index.tsx:60-67, and the same in the Android variant). That host sits in normal flow below SettingsList.PressableItem and takes no style.

The absolutely positioned trigger doesn't add to the host's size, so the host collapses. Its top/right offsets then resolve against the collapsed host, not the row. On iOS and Android, whenever a second account is signed in, the dots button ends up misplaced or clipped, and "Remove account" may become hard or impossible to reach. The PR description lists this menu as not yet checked.

To fix it, either keep this menu as a sheet, or move the absolute positioning onto a wrapper View around Menu.Root. Details are in the inline comment.

Claude review, run

@mozzius
mozzius force-pushed the samuel/app-3734-more-inline-menus branch from 6687caa to 8d4f2f1 Compare November 3, 2026 08:05
Comment thread src/components/dms/ConvoMenu.tsx Resolved
@github-actions

github-actions Bot commented Nov 3, 2026

Copy link
Copy Markdown
Contributor

One non-blocking finding, posted inline on src/components/dms/ConvoMenu.tsx:86-95: on iOS, tapping the chat header's … no longer dismisses the keyboard. The trigger's root element is now a View with no onPress, so Keyboard.dismiss() never runs, not even on the menu's first open. I found nothing else to report.

Claude review, run

Comment thread src/components/dms/ConvoMenu.tsx Resolved
@github-actions

github-actions Bot commented Nov 3, 2026

Copy link
Copy Markdown
Contributor

I found one non-blocking issue and posted it as an inline comment on src/components/dms/ConvoMenu.tsx:86-94.

On iOS, the conversation header's … menu no longer dismisses the keyboard. The View wrapper is now the element the trigger's render prop returns, so Menu.Trigger finds no onPress to forward on iOS, and the Keyboard.dismiss() wrapper never runs. Android isn't affected.

I didn't find any other actionable defects.

Claude review, run

{({props, state}) => (
<Pressable
{...props}
hitSlop={HITSLOP_10}

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.

non-blocking: iOS tap targets lose their hitSlop when they become inline triggers

The PR description says it: on iOS, hitSlop doesn't apply to an inline trigger, because the SwiftUI Menu label only receives taps inside the RNHostView bounds. This PR fixes that for the lightbox button by growing it to 44pt, but leaves several converted triggers relying on hitSlop.

Here the trigger is a 20pt icon plus p_xs, so about 28pt. With HITSLOP_10 the target used to be about 48pt; on iOS it's now 28pt. The account … sits on top of SettingsList.PressableItem, so a tap that lands inside the old slop area but outside the circle now hits the row. That switches to the account instead of opening "Remove account". Before this change, the same tap opened the menu.

The same root cause makes other converted triggers smaller on iOS:

  • the starter pack …: a size="small" round button with HITSLOP_20 at StarterPackScreen.tsx:604
  • the in-chat header … in ConvoMenu
  • the group member … and "Admin" badge in MemberMenu

To fix it, give these triggers a 44pt hit area of their own through padding or min size, as the lightbox button does, and don't depend on hitSlop.

@github-actions

github-actions Bot commented Nov 6, 2026

Copy link
Copy Markdown
Contributor

One non-blocking finding, posted inline at src/screens/Settings/Settings.tsx:685: on iOS, the new inline-menu triggers are smaller to tap.

The cause is the one the PR description gives for the lightbox button: on iOS, hitSlop doesn't work on an inline menu trigger. Only the lightbox button was enlarged to make up for it.

  • Settings account …: the tap area shrinks from about 48pt to about 28pt. A tap just outside the dots now lands on the account row and switches accounts instead of opening the menu.
  • Same cause elsewhere: the starter pack …, the in-chat header …, and the group chat member … and "Admin" badge also lose their tap padding on iOS.

CI was still running when I finished, so it gives no evidence either way.

Claude review, run

@mozzius
mozzius force-pushed the samuel/app-3734-more-inline-menus branch from 655338a to e37284e Compare November 9, 2026 09:33
Comment on lines +86 to +94
{({props}) => (
<View style={[style]}>
<Button
label={props.accessibilityLabel}
{...props}
onPress={() => {
Keyboard.dismiss()
props.onPress()
}}

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.

non-blocking: on iOS, the chat header's … menu no longer closes the keyboard

On iOS, an inline trigger never gets the tap. Instead, Menu.Trigger reads the root element's props.onPress (src/components/Menu/index.tsx:195-201) and calls it when the menu opens. The root element here is now the View, which has no onPress. So inlineTriggerOnPress is set to undefined, and the Keyboard.dismiss() wrapper on the Button never runs.

How to reproduce: in a conversation, type in the composer, then tap … in the header. The SwiftUI menu opens and the keyboard stays up. If you then pick "Report conversation", "Leave conversation" or "Block account", that prompt also opens with the keyboard raised. Before this change, the sheet's trigger always dismissed the keyboard first.

Suggested fix: make the Button the trigger's root element again, so the menu can find its onPress, and move style onto it or into a View around Menu.Root. Even then, SwiftUI only calls it on the first open. A reliable fix would dismiss the keyboard another way, for example from the menu's open hook.

@github-actions

github-actions Bot commented Nov 9, 2026

Copy link
Copy Markdown
Contributor

I found one non-blocking issue and left it as an inline comment.

  • non-blocking: on iOS, the chat header's … menu no longer closes the keyboard (src/components/dms/ConvoMenu.tsx:86-94). The trigger's root element is now a View with no onPress, so the inline menu never calls the Keyboard.dismiss() wrapper. If you type in the composer and then open the menu, the keyboard stays up, and so do any prompts the menu opens.

Claude review, run

@mozzius
mozzius force-pushed the samuel/app-3734-more-inline-menus branch from e37284e to a66b274 Compare November 9, 2026 16:27
{({props, state}) => (
<Pressable
{...props}
hitSlop={HITSLOP_10}

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.

non-blocking: iOS account … loses its hit slop, so near-misses switch accounts

hitSlop={HITSLOP_10} stops working on iOS now that this is an inline trigger. You note in CircleChromeButton.tsx that hit slop doesn't apply to a native menu trigger, and that's why you grew the lightbox button to 44pt. This trigger is only a.p_xs around a md icon, which is about 28pt. Before this change, the hit slop made the target about 48pt.

The trigger sits on top of the SettingsList.PressableItem row, whose onPress is onSwitchAccount. On iOS, a tap that lands up to 10pt outside the dots, which used to open this menu, now reaches the row and switches to that account. To fix it, make the trigger's own box at least 44pt (for example, larger padding or a fixed size on the Pressable) instead of relying on hitSlop.

@github-actions

github-actions Bot commented Nov 9, 2026

Copy link
Copy Markdown
Contributor

I found one non-blocking defect and left it as an inline comment:

  • non-blocking: iOS account … loses its hit slop, so near-misses switch accounts (src/screens/Settings/Settings.tsx:685). This trigger is now an inline menu, and on iOS hitSlop doesn't apply to an inline trigger. The tap target drops from about 48pt to about 28pt. A tap just outside the dots now reaches the row underneath and switches to that account instead of opening the menu.

Claude review, run

@mozzius
mozzius force-pushed the samuel/app-3734-more-inline-menus branch from a66b274 to d6ea427 Compare November 9, 2026 16:30
})
: undefined
}>
{...props}>

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.

non-blocking: iPad long press does nothing while the native-menus gate is off

Menu.Root only renders a native menu when ax.features.NativeMenusEnable is on (src/components/Menu/index.tsx:78-79). When the gate is off, this is still the bottom-sheet menu, and the "press and hold, then slide to Quote post" replacement doesn't exist.

With onLongPress removed, a long press does nothing at all. PostControlButton always passes its own handleLongPress to the pressable (PostControlButton.tsx:71-82), and it returns early when onLongPress is undefined. Because a long-press handler is attached, Pressability treats the press as consumed and doesn't call onPress on release.

So for every iPad user outside the rollout, long-press-to-quote stops working and long press does nothing instead. iPhone keeps long-press-to-quote in RepostButton.tsx:46. A fix is to keep the long-press handler when the menu isn't inline, for example by reading useMenuContext().inline or the gate.

@github-actions

github-actions Bot commented Nov 9, 2026

Copy link
Copy Markdown
Contributor

I left 1 non-blocking finding as an inline comment: when the native-menus gate is off, a long press on the iPad repost button now does nothing (src/components/PostControls/RepostButtonMenu.tsx:50). I found no other actionable defects.

Claude review, run

mozzius and others added 10 commits November 10, 2026 11:16
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Also fixes the trigger's label, which was copied from the repost menu.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comment on lines +19 to +20
// The lightbox is always dark, so its menu is too.
<ThemeProvider theme="dark">

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.

non-blocking: With the gate off, the image menu becomes a light sheet with dark-theme items

Menu.Root inline only renders a native menu when NativeMenusEnable is on (src/components/Menu/index.tsx:78-79). Otherwise it falls back to Dialog.Outer. That gate is off by default, so most users will see the fallback. The native Dialog.Outer takes the sheet's backgroundColor and colorScheme from useTheme(useThemeName()) (src/components/Dialog/index.tsx:90-91, 301-302), which is the app's colour mode. It ignores this ThemeProvider. The items inside use plain useTheme(), so they do get the dark theme.

How to reproduce: use the app in light mode with the gate off, open an image in the lightbox, and tap …. You get a white sheet, a light-mode handle, and dark bg_contrast_25 item cards with light text. The old hand-rolled card was dark in every colour mode.

Possible fixes: apply the ThemeProvider only when the menu really is inline, or keep the old dark card for the fallback until the gate rolls out.

) : (
<RepostButtonDialog {...props} />
)
IS_IPAD ? <RepostButtonMenu {...props} /> : <RepostButtonDialog {...props} />

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.

non-blocking: Long press on the iPad repost button does nothing when the gate is off

Removing longPressToQuote assumes a long press opens the native menu. That only happens when NativeMenusEnable is on. With the gate off (the default), Menu.Root inline falls back to the bottom sheet (src/components/Menu/index.tsx:78-82). The trigger is now just PostControlButton {...props} with no onLongPress, so on iPad a long press neither quotes the post (the old behaviour) nor opens the menu. Today that's a silent regression for every iPad user.

Possible fix: keep the long-press handler for the non-inline case, for example by reading useMenuContext().inline in the trigger, until the gate is fully rolled out.

@github-actions

Copy link
Copy Markdown
Contributor
Rating Why
Risk 🟡 Medium Swaps several menus and the lightbox chrome onto SwiftUI/Compose native menu hosts; gate-off fallbacks also change behaviour.
Impact 🟡 Medium The image lightbox menu, chat header and iPad feed repost button are common flows; JS-only, so an OTA update can fix them.

Findings: 2 non-blocking, posted inline.

  • non-blocking: With the gate off, the image menu becomes a light sheet with dark-theme items (src/components/Lightbox/chrome/ImageMenu.tsx:19)
  • non-blocking: Long press on the iPad repost button does nothing when the gate is off (src/components/PostControls/RepostButton.tsx:25)

Worth a close look

  • Needs a device src/components/Lightbox/chrome/CircleChromeButton.tsx:34: The header buttons grow from 32pt to 44pt. On a small iPhone in portrait and in landscape, check that the close and options buttons still fit within the safe-area insets and don't cover the image counter or alt text.
  • Needs a device src/components/PostControls/RepostButtonMenu.tsx:41: Each feed repost button on iPad now mounts its own SwiftUI Host when the gate is on. Scroll a long feed on a physical iPad and watch for dropped frames or blank cells.
  • Fragile src/screens/Messages/ConversationSettings/MemberMenu.tsx:676: When the fetch fails, refetch().then(({data}) => openChat(data)) returns nothing and "Message" fails silently. Check that this is acceptable, compared with showing a toast.

Claude review, run

This branch was successfully deployed

1 active deployment
samuel/app-3734-more-inline-menus - social-app PR #12296 — 34b7d5fa Deployed Nov 10, 2026 by arcalinea
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants