From 290deb22329045c904a92ee33f39592115e8dcd0 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Sat, 31 Oct 2026 20:35:16 +0200 Subject: [PATCH 01/10] use inline menus for list, chat, avatar and account menus Co-Authored-By: Claude Opus 5.5 --- src/components/dms/ConvoMenu.tsx | 141 ++++++++++++------ .../ConversationSettings/MemberMenu.tsx | 23 ++- .../Messages/components/RequestButtons.tsx | 6 +- .../components/MoreOptionsMenu.tsx | 2 +- .../ProfileList/components/SubscribeMenu.tsx | 2 +- src/screens/Settings/Settings.tsx | 2 +- src/view/com/util/UserAvatar.tsx | 2 +- 7 files changed, 121 insertions(+), 57 deletions(-) diff --git a/src/components/dms/ConvoMenu.tsx b/src/components/dms/ConvoMenu.tsx index e3414fd070e..e7b93bedfe0 100644 --- a/src/components/dms/ConvoMenu.tsx +++ b/src/components/dms/ConvoMenu.tsx @@ -43,6 +43,7 @@ import * as Menu from '#/components/Menu' import {ReportDialog} from '#/components/moderation/ReportDialog' import * as Prompt from '#/components/Prompt' import * as Toast from '#/components/Toast' +import {IS_NATIVE} from '#/env' import type * as bsky from '#/types/bsky' let ConvoMenu = ({ @@ -80,45 +81,59 @@ let ConvoMenu = ({ const reportSubject = getConvoReportSubject(convo, currentAccount?.did) + const trigger = hideTrigger ? null : ( + + {({props}) => ( + + + + )} + + ) + const contentProps = { + profile, + showMarkAsRead, + blockInfo, + convo, + canReport: !!reportSubject, + leaveConvoControl, + reportControl, + blockedByListControl, + } + return ( <> - - {!hideTrigger && ( - - - {({props}) => ( - - )} - - - )} - - - - - + {/* + * Native menus can only open from their trigger, so the chat list's + * long-press menu, which has none, stays a sheet. + */} + {IS_NATIVE && trigger ? ( + + ) : ( + + {trigger} + + + + + )} canReport: boolean @@ -183,7 +189,48 @@ function MenuContent({ leaveConvoControl: Prompt.PromptControlProps reportControl: Prompt.PromptControlProps blockedByListControl: Prompt.PromptControlProps +} + +/** + * An inline menu needs its groups as direct children of `Menu.Outer`, so its + * items' hooks run as soon as it mounts. That's fine for the one in the + * conversation header. + */ +function InlineConvoMenu({ + control, + trigger, + ...props +}: MenuContentProps & { + control?: Menu.MenuControlProps + trigger: React.ReactElement }) { + const items = useConvoMenuItems(props) + return ( + + {trigger} + {items} + + ) +} + +/** + * Sheet menus only mount their content while open, so a chat list row doesn't + * fetch its convo until its menu opens. + */ +function MenuContent(props: MenuContentProps) { + return useConvoMenuItems(props) +} + +function useConvoMenuItems({ + convo: initialConvo, + profile, + canReport, + showMarkAsRead, + blockInfo, + leaveConvoControl, + reportControl, + blockedByListControl, +}: MenuContentProps) { const navigation = useNavigation() const {t: l} = useLingui() const {mutate: markAsRead} = useMarkAsReadMutation() diff --git a/src/screens/Messages/ConversationSettings/MemberMenu.tsx b/src/screens/Messages/ConversationSettings/MemberMenu.tsx index f128951f8fe..c6c74d53ece 100644 --- a/src/screens/Messages/ConversationSettings/MemberMenu.tsx +++ b/src/screens/Messages/ConversationSettings/MemberMenu.tsx @@ -54,9 +54,10 @@ export function MemberMenu({ const removeMemberPrompt = Prompt.usePromptControl() const [menuDidOpen, setMenuDidOpen] = useState(false) - const {data: convoAvailability} = useGetConvoAvailabilityQuery(profile.did, { - enabled: menuDidOpen, - }) + const {data: prefetchedAvailability, refetch: fetchAvailability} = + useGetConvoAvailabilityQuery(profile.did, { + enabled: menuDidOpen, + }) const {mutate: initiateConvo} = useGetConvoForMembers({ onSuccess: ({convo}) => { ax.metric('chat:open', {logContext: 'ConvoSettings'}) @@ -78,7 +79,7 @@ export function MemberMenu({ }) const [queueBlock, queueUnblock] = useProfileBlockMutationQueue(profile) - const messageMember = () => { + const openChat = (convoAvailability: typeof prefetchedAvailability) => { if (!convoAvailability?.canChat) { return } @@ -94,6 +95,18 @@ export function MemberMenu({ } } + const messageMember = () => { + if (prefetchedAvailability) { + openChat(prefetchedAvailability) + } else { + /* + * The trigger prefetches this, but on iOS the native menu takes the + * tap, so the trigger's onPress never runs. + */ + void fetchAvailability().then(({data}) => openChat(data)) + } + } + const handleMessageMember = requireEmailVerification(messageMember, { instructions: [ @@ -138,7 +151,7 @@ export function MemberMenu({ return ( <> - + {({props, state, control: menuControl}) => { const isActive = diff --git a/src/screens/Messages/components/RequestButtons.tsx b/src/screens/Messages/components/RequestButtons.tsx index d0a5cb486fa..3618ea58df0 100644 --- a/src/screens/Messages/components/RequestButtons.tsx +++ b/src/screens/Messages/components/RequestButtons.tsx @@ -121,7 +121,11 @@ export function RejectMenu({ return ( <> - + {/* + * In the conversation, the button stretches across the row, and an + * inline menu's trigger is only as wide as its content. + */} + {({props: triggerProps}) => (