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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 27 additions & 8 deletions apps/mobile/src/components/ui/AndroidAnchoredMenu.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import type { MenuAction, MenuComponentProps } from "@react-native-menu/menu";
import type { ReactNode } from "react";
import { use, useRef, useState } from "react";
import { use, useEffect, useEffectEvent, useRef, useState } from "react";
import type { StyleProp, ViewInstance, ViewStyle } from "react-native";
import { Pressable, ScrollView, View } from "react-native";
import { useKeyboardState } from "react-native-keyboard-controller";
import Animated, { FadeIn } from "react-native-reanimated";

import { useHardwareBack } from "../../lib/backHandler";
import type { VerticalHinge } from "../../lib/layout";
import { deriveMenuHorizontalPlacement } from "../../lib/menu-placement";
import { WindowHingeContext } from "../../native/window-hinge-context";
import { OverlayPortal } from "./OverlayPortal";
Expand All @@ -17,11 +18,13 @@ const SCREEN_MARGIN = 12;
const ANCHOR_GAP = 6;

// Freeze both snapshots at opening so keyboard and screen reflows cannot flip menu direction.
// A hinge change moves the anchor with its pane, so both are re-measured once the panes settle.
type AnchorSnapshot = {
readonly x: number;
readonly y: number;
readonly width: number;
readonly height: number;
readonly hinge: VerticalHinge | null;
readonly keyboardWasVisible: boolean;
};

Expand All @@ -47,26 +50,41 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) {
const [path, setPath] = useState<readonly MenuAction[]>([]);
const [rootHeight, setRootHeight] = useState<number | null>(null);
const [overlay, setOverlay] = useState<OverlayFrame | null>(null);
const windowHinge = use(WindowHingeContext);
const { hinge: windowHinge, panesSettled } = use(WindowHingeContext);
// State, not a ref: `open` reaches render-prop children, and the compiler
// bails out of the whole component when a ref might be read during render.
const [anchorNode, setAnchorNode] = useState<ViewInstance | null>(null);
const overlayRef = useRef<ViewInstance>(null);

const keyboardVisible = useKeyboardState((state) => state.isVisible);
const keyboardHeight = useKeyboardState((state) => state.height);
const [opening, setOpening] = useState<{ readonly keyboardWasVisible: boolean } | null>(null);
const close = () => {
setOpening(null);
setAnchor(null);
setPath([]);
setOverlay(null);
setRootHeight(null);
};

const open = () => {
anchorNode?.measureInWindow((x, y, width, height) => {
setAnchor({ x, y, width, height, keyboardWasVisible: keyboardVisible });
const open = () => setOpening({ keyboardWasVisible: keyboardVisible });

const placeAnchor = useEffectEvent(
(x: number, y: number, width: number, height: number, keyboardWasVisible: boolean) =>
setAnchor({ x, y, width, height, hinge: windowHinge, keyboardWasVisible }),
);
// oxlint-disable-next-line t3code/no-unreasoned-state-sync -- measures the native anchor view; cleanup drops a superseded or closed measurement
useEffect(() => {
if (opening === null || anchorNode === null) return;
let current = true;
anchorNode.measureInWindow((x, y, width, height) => {
if (current) placeAnchor(x, y, width, height, opening.keyboardWasVisible);
});
};
return () => {
current = false;
};
// oxlint-disable-next-line react/exhaustive-effect-dependencies -- A pane settle moves the anchor, so it is measured again.
}, [opening, anchorNode, panesSettled]);

const measureOverlay = () => {
overlayRef.current?.measureInWindow((x, y, width, height) => {
Expand All @@ -91,6 +109,7 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) {
(action) => !(action.attributes?.hidden ?? false),
);

const hinge = anchor?.hinge ?? null;
const local =
anchor === null || overlay === null
? null
Expand All @@ -109,7 +128,7 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) {
overlayWidth: overlay.width,
desiredWidth: desiredMenuWidth,
margin: SCREEN_MARGIN,
hinge: windowHinge && { x: windowHinge.x - overlay.x, width: windowHinge.width },
hinge: hinge && { x: hinge.x - overlay.x, width: hinge.width },
});
// This in-window menu preserves keyboard focus; subtract the IME area so its items remain tappable.
const usableBottom =
Expand Down Expand Up @@ -171,7 +190,7 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) {
{!placeable || local === null ? null : !anchor.keyboardWasVisible ? (
<MenuPopup
// Compose aligns to the trigger's start if it fits; hinge-safe triggers keep menus on their side.
anchor={windowHinge ? { ...local, x: left, width: menuWidth } : local}
anchor={hinge ? { ...local, x: left, width: menuWidth } : local}
menuWidth={menuWidth}
actions={levelActions}
title={props.title}
Expand Down
32 changes: 23 additions & 9 deletions apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -639,14 +639,16 @@ function AdaptiveWorkspaceLayoutContent(
listPaneFloor: layout.listPaneWidth,
listPaneCeiling: layout.listPaneWidth,
revision: 0,
hinge: windowHinge,
// Counts settles that moved a pane edge, so open overlays re-measure their anchors.
settled: 0,
});
if (
Platform.OS === "android" &&
(listPaneSizing.viewportWidth !== viewportWidth ||
listPaneSizing.workspacePage !== workspacePage ||
listPaneSizing.sidebarVisible !== panes.primarySidebarVisible ||
listPaneSizing.listPaneTarget !== layout.listPaneWidth)
) {
const listPaneChanged =
listPaneSizing.viewportWidth !== viewportWidth ||
listPaneSizing.workspacePage !== workspacePage ||
listPaneSizing.sidebarVisible !== panes.primarySidebarVisible ||
listPaneSizing.listPaneTarget !== layout.listPaneWidth;
if (Platform.OS === "android" && (listPaneChanged || listPaneSizing.hinge !== windowHinge)) {
// A book-posture hinge resizes the visible list pane instead of revealing it.
const resizingSidebar =
listPaneSizing.viewportWidth === viewportWidth &&
Expand All @@ -666,7 +668,13 @@ function AdaptiveWorkspaceLayoutContent(
listPaneCeiling: resizingSidebar
? Math.max(listPaneSizing.listPaneCeiling ?? 0, layout.listPaneWidth ?? 0)
: layout.listPaneWidth,
revision: listPaneSizing.revision + 1,
revision: listPaneChanged ? listPaneSizing.revision + 1 : listPaneSizing.revision,
hinge: windowHinge,
// A crease the list doesn't slide to never settles, so overlays re-measure right away.
settled:
listPaneSizing.hinge !== windowHinge && !resizingSidebar
? listPaneSizing.settled + 1
: listPaneSizing.settled,
});
}
const settleListPane = (revision: number) => {
Expand All @@ -679,12 +687,18 @@ function AdaptiveWorkspaceLayoutContent(
...current,
listPaneFloor: current.listPaneTarget,
listPaneCeiling: current.listPaneTarget,
settled: current.settled + 1,
},
);
};
const listPaneFloor = listPaneSizing.listPaneFloor ?? 0;
const listPaneCeiling = listPaneSizing.listPaneCeiling ?? 0;
const listPaneRevision = listPaneSizing.revision;
const panesSettled = listPaneSizing.settled;
const windowHingeContext = useMemo(
() => ({ hinge: windowHinge, panesSettled }),
[windowHinge, panesSettled],
);
// Android lays the content out at its new width as soon as a pane toggles, then holds it
// against the edge that stays put while the other one slides, so nothing reflows after the
// panes land. A sliding sidebar uncovers the strip it is about to fill, or covers the content
Expand Down Expand Up @@ -1019,7 +1033,7 @@ function AdaptiveWorkspaceLayoutContent(
savedFilters={props.savedThreadListFilters}
>
<AdaptiveWorkspaceContext.Provider value={contextValue}>
<WindowHingeContext value={windowHinge}>
<WindowHingeContext value={windowHingeContext}>
{usesPresentationHost ? (
<WorkspaceColumnsRendererContext value={renderColumns}>
{props.children}
Expand Down
11 changes: 9 additions & 2 deletions apps/mobile/src/native/window-hinge-context.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
import { createContext } from "react";
import type { VerticalHinge } from "../lib/layout";

/** A creased vertical hinge in window coordinates, for overlays placed in window space. */
export const WindowHingeContext = createContext<VerticalHinge | null>(null);
/**
* A creased vertical hinge in window coordinates, for overlays placed in window space.
* `panesSettled` counts finished workspace pane animations. Panes move what an overlay is
* anchored to, such as a sidebar button when the hinge resizes the list.
*/
export const WindowHingeContext = createContext<{
readonly hinge: VerticalHinge | null;
readonly panesSettled: number;
}>({ hinge: null, panesSettled: 0 });
Loading