diff --git a/apps/mobile/src/components/AndroidAnchoredMenu.tsx b/apps/mobile/src/components/AndroidAnchoredMenu.tsx index 7a27e0c3b13..045fd97e293 100644 --- a/apps/mobile/src/components/AndroidAnchoredMenu.tsx +++ b/apps/mobile/src/components/AndroidAnchoredMenu.tsx @@ -6,6 +6,7 @@ import type { StyleProp, ViewStyle } from "react-native"; import { BackHandler, Pressable, ScrollView, View } from "react-native"; import { useKeyboardState } from "react-native-keyboard-controller"; import Animated, { FadeIn } from "react-native-reanimated"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; import { appBlurTargetRef } from "../lib/appBlurTarget"; import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; @@ -82,6 +83,7 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) { const { themeAppearance } = useAppearancePreferences(); const isDarkMode = themeAppearance === "dark"; + const insets = useSafeAreaInsets(); const keyboardVisible = useKeyboardState((state) => state.isVisible); const keyboardHeight = useKeyboardState((state) => state.height); const rippleColor = useThemeColor("--color-subtle"); @@ -164,9 +166,13 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) { ); // The keyboard stays up while the menu is open (in-window overlay, no // focus change), so the space it covers is not usable — without this the - // composer-pill menus "open down" into the IME and can't be tapped. + // composer-pill menus "open down" into the IME and can't be tapped. The + // portal host spans the whole window, so the gesture bar is unusable too; + // the keyboard already covers it when up, hence the max rather than a sum. const usableBottom = - overlay === null ? 0 : overlay.height - (keyboardVisible ? keyboardHeight : 0); + overlay === null + ? 0 + : overlay.height - Math.max(keyboardVisible ? keyboardHeight : 0, insets.bottom); const spaceBelow = local === null || overlay === null ? 0 diff --git a/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx index 801862086b9..ab46f8a49aa 100644 --- a/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx +++ b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx @@ -511,6 +511,7 @@ export function ArchivedThreadsScreen(props: { readonly onUnarchiveThread: (thread: EnvironmentThreadShell) => void; }) { const { onDeleteThread, onUnarchiveThread } = props; + const insets = useSafeAreaInsets(); const openSwipeableRef = useRef(null); const archiveScrollGesture = useMemo(() => Gesture.Native(), []); const refreshTint = useThemeColor("--color-icon"); @@ -629,7 +630,11 @@ export function ArchivedThreadsScreen(props: { } renderItem={renderItem} ListEmptyComponent={ diff --git a/apps/mobile/src/features/review/ReviewSheet.tsx b/apps/mobile/src/features/review/ReviewSheet.tsx index 0524371738f..441c981cfc8 100644 --- a/apps/mobile/src/features/review/ReviewSheet.tsx +++ b/apps/mobile/src/features/review/ReviewSheet.tsx @@ -275,7 +275,9 @@ function ReviewFileNavigator({ keyExtractor={(file) => file.id} contentContainerStyle={{ paddingHorizontal: 8, - paddingBottom: 8, + // iOS hosts this list in a native Screen whose content insets already + // cover the home indicator; Android needs the gesture bar reserved. + paddingBottom: Platform.OS === "android" ? Math.max(insets.bottom, 8) + 8 : 8, // The nested native header is translucent; start the list below it so // the scroll-edge effect can sample the content (same treatment as // FileTreeBrowser in the Files pane). diff --git a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx index f370401e8ec..4ecaf88d2c8 100644 --- a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx +++ b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx @@ -12,6 +12,7 @@ import { KeyboardStickyView, useKeyboardState, } from "react-native-keyboard-controller"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AndroidHeaderIconButton, AndroidScreenHeader } from "../../components/AndroidScreenHeader"; import { @@ -503,9 +504,13 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) height: state.height, isVisible: state.isVisible, })); + const insets = useSafeAreaInsets(); const isAccessoryVisible = keyboardState.isVisible && !isAccessoryDismissed; + // With the keyboard up its height already clears the gesture bar and the + // home indicator; with it down the terminal owns the bottom edge, so the + // last rows need the safe area or they scroll under the system chrome. const terminalBottomInset = - (keyboardState.isVisible ? keyboardState.height : 0) + + (keyboardState.isVisible ? keyboardState.height : insets.bottom) + (isAccessoryVisible ? TERMINAL_ACCESSORY_HEIGHT : 0); useEffect(() => { @@ -1289,7 +1294,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) accessibilityRole="button" onPress={handleShowKeyboard} style={({ pressed }) => ({ - bottom: 16, + bottom: Math.max(insets.bottom, 16) + 16, borderRadius: 28, opacity: pressed ? 0.72 : 1, position: "absolute", diff --git a/oxlint-plugin-t3code/index.ts b/oxlint-plugin-t3code/index.ts index 6556bfe109a..323f699a8bf 100644 --- a/oxlint-plugin-t3code/index.ts +++ b/oxlint-plugin-t3code/index.ts @@ -5,6 +5,7 @@ import noGlobalProcessRuntime from "./rules/no-global-process-runtime.ts"; import noInlineSchemaCompile from "./rules/no-inline-schema-compile.ts"; import noManualEffectRuntimeInTests from "./rules/no-manual-effect-runtime-in-tests.ts"; import noNativeTitleTooltip from "./rules/no-native-title-tooltip.ts"; +import requireBottomSafeAreaInset from "./rules/require-bottom-safe-area-inset.ts"; export default definePlugin({ meta: { @@ -16,5 +17,6 @@ export default definePlugin({ "no-inline-schema-compile": noInlineSchemaCompile, "no-manual-effect-runtime-in-tests": noManualEffectRuntimeInTests, "no-native-title-tooltip": noNativeTitleTooltip, + "require-bottom-safe-area-inset": requireBottomSafeAreaInset, }, }); diff --git a/oxlint-plugin-t3code/rules/require-bottom-safe-area-inset.test.ts b/oxlint-plugin-t3code/rules/require-bottom-safe-area-inset.test.ts new file mode 100644 index 00000000000..ea6d16a142e --- /dev/null +++ b/oxlint-plugin-t3code/rules/require-bottom-safe-area-inset.test.ts @@ -0,0 +1,168 @@ +import { assert, describe } from "@effect/vitest"; + +import { createOxlintRuleHarness } from "../test/utils.ts"; + +const rule = createOxlintRuleHarness("t3code/require-bottom-safe-area-inset", { + filename: "fixture.tsx", +}); + +describe("t3code/require-bottom-safe-area-inset", () => { + rule.valid( + "allows bottom offsets outside React Native files", + ` + export const style = { position: "absolute", bottom: 16, right: 16 }; + `, + ); + + rule.valid( + "allows a floating button that reserves the safe-area inset", + ` + import { Pressable } from "react-native"; + import { useSafeAreaInsets } from "react-native-safe-area-context"; + + export function ShowKeyboardButton() { + const insets = useSafeAreaInsets(); + return ( + + ); + } + `, + ); + + rule.valid( + "allows an edge-attached overlay whose child owns the padding", + ` + import { View } from "react-native"; + import { KeyboardStickyView } from "react-native-keyboard-controller"; + + export function ComposerOverlay(props: { readonly bottomInset: number }) { + return ( + + + + ); + } + `, + ); + + rule.valid( + "allows list padding that is derived from the inset", + ` + import { FlatList } from "react-native"; + import { useSafeAreaInsets } from "react-native-safe-area-context"; + + export function FileList() { + const insets = useSafeAreaInsets(); + return ( + null} + contentContainerStyle={{ paddingTop: 8, paddingBottom: Math.max(insets.bottom, 18) + 18 }} + /> + ); + } + `, + ); + + rule.valid( + "allows a nested callback that closes over its component's inset", + ` + import { FlatList, View } from "react-native"; + import { useSafeAreaInsets } from "react-native-safe-area-context"; + + export function FileList() { + const insets = useSafeAreaInsets(); + return ( + } + contentContainerStyle={{ paddingBottom: insets.bottom }} + /> + ); + } + `, + ); + + rule.invalid( + "reports a component that ignores the inset even when a sibling component reads it", + ` + import { Pressable, View } from "react-native"; + import { useSafeAreaInsets } from "react-native-safe-area-context"; + + function Header() { + const insets = useSafeAreaInsets(); + return ; + } + + export function Screen() { + return ( + +
+ + + ); + } + `, + (output) => { + assert.match(output, /safe-area inset/); + }, + ); + + rule.invalid( + "reports a fixed bottom padding inside a style array", + ` + import { FlatList, StyleSheet } from "react-native"; + + const styles = StyleSheet.create({ base: { paddingTop: 8 } }); + + export function FileList() { + return ( + null} + contentContainerStyle={[styles.base, { paddingBottom: 8 }]} + /> + ); + } + `, + (output) => { + assert.match(output, /safe-area inset/); + }, + ); + + rule.invalid( + "reports a floating button pinned with a fixed bottom offset", + ` + import { Pressable } from "react-native"; + + export function ShowKeyboardButton() { + return ; + } + `, + (output) => { + assert.match(output, /safe-area inset/); + }, + ); + + rule.invalid( + "reports list content padding that ignores the bottom inset", + ` + import { FlatList } from "react-native"; + + export function FileList() { + return ( + null} + contentContainerStyle={{ paddingTop: 8, paddingBottom: 8 }} + /> + ); + } + `, + (output) => { + assert.match(output, /insets\.bottom/); + }, + ); +}); diff --git a/oxlint-plugin-t3code/rules/require-bottom-safe-area-inset.ts b/oxlint-plugin-t3code/rules/require-bottom-safe-area-inset.ts new file mode 100644 index 00000000000..fb05597fc5f --- /dev/null +++ b/oxlint-plugin-t3code/rules/require-bottom-safe-area-inset.ts @@ -0,0 +1,211 @@ +import { defineRule } from "@oxlint/plugins"; +import * as Option from "effect/Option"; + +import { getPropertyName, unwrapExpression } from "../utils.ts"; + +// Chrome pinned to the bottom edge renders under the Android gesture bar and +// the iOS home indicator unless it pads itself with the safe-area inset. The +// house convention is `Math.max(insets.bottom, N)` (see AndroidHomeFab and +// HomeScreen), so a React Native file that anchors content to the bottom and +// never reads the inset is almost always missing it. +const REACT_NATIVE_MODULE = /^react-native(\/|-|$)/u; +// `useSafeAreaInsets` alone proves nothing: a screen that only reads +// `insets.top` still leaves its bottom edge unpadded. Only a marker that +// names the bottom counts — `SafeAreaView` (which pads every edge by +// default), or a `bottomInset` prop handed down by whoever owns the inset. +const SAFE_AREA_BOTTOM_IDENTIFIERS = new Set(["SafeAreaView", "bottomInset"]); +const INSET_HOLDER_NAMES = new Set(["insets", "safeAreaInsets"]); +const SCROLL_CONTENT_ATTRIBUTES = new Set(["contentContainerStyle", "contentInset"]); +const SCROLL_BOTTOM_PROPERTIES = new Set(["bottom", "paddingBottom"]); + +const getLiteralValue = (node: unknown): Option.Option => { + if (typeof node !== "object" || node === null) return Option.none(); + if (!("type" in node) || node.type !== "Literal") return Option.none(); + if (!("value" in node)) return Option.none(); + return typeof node.value === "string" || typeof node.value === "number" + ? Option.some(node.value) + : Option.none(); +}; + +// Style props take an object or an array of them (`style={[base, {…}]}`), so +// array elements are flattened; a non-literal element (a StyleSheet +// reference) contributes nothing and is simply skipped. +const getObjectProperties = (node: unknown): ReadonlyArray => { + const expression = unwrapExpression(node); + if (Option.isNone(expression)) return []; + + if (expression.value.type === "ArrayExpression") { + const { elements } = expression.value; + return Array.isArray(elements) ? elements.flatMap(getObjectProperties) : []; + } + + if (expression.value.type !== "ObjectExpression") return []; + const { properties } = expression.value; + return Array.isArray(properties) ? properties : []; +}; + +const findProperty = ( + properties: ReadonlyArray, + name: string, +): Option.Option<{ readonly value: unknown }> => { + for (const property of properties) { + if (typeof property !== "object" || property === null) continue; + if (!("key" in property) || !("value" in property)) continue; + + const key = getPropertyName(property.key); + if (Option.isSome(key) && key.value === name) { + return Option.some(property as { readonly value: unknown }); + } + } + + return Option.none(); +}; + +const isAbsolutelyPositioned = (properties: ReadonlyArray): boolean => + findProperty(properties, "position").pipe( + Option.flatMap((property) => getLiteralValue(property.value)), + Option.exists((value) => value === "absolute"), + ); + +const getJsxAttributeName = (node: unknown): Option.Option => { + if (typeof node !== "object" || node === null || !("name" in node)) return Option.none(); + const name = node.name; + if (typeof name !== "object" || name === null || !("type" in name)) return Option.none(); + return name.type === "JSXIdentifier" && "name" in name && typeof name.name === "string" + ? Option.some(name.name) + : Option.none(); +}; + +const getJsxAttributeExpression = (node: unknown): unknown => { + if (typeof node !== "object" || node === null || !("value" in node)) return undefined; + const value = node.value; + if (typeof value !== "object" || value === null || !("type" in value)) return undefined; + return value.type === "JSXExpressionContainer" && "expression" in value + ? value.expression + : undefined; +}; + +const MESSAGE = + "Bottom-anchored chrome must reserve the safe-area inset: this file pins content to the bottom edge with a fixed offset but never reads insets.bottom, so it renders under the Android gesture bar and the iOS home indicator. Read useSafeAreaInsets() and pad with Math.max(insets.bottom, N), or take the inset from the parent as a bottomInset prop."; + +export default defineRule({ + meta: { + type: "problem", + docs: { + description: + "Require React Native surfaces that anchor content to the bottom edge to account for the safe-area inset.", + }, + }, + createOnce(context) { + let isReactNativeFile = false; + // Inset reads are tracked per top-level function, not per file: a file + // holding a header component that reads the inset and a list component + // that does not must still report the list. Nested functions (renderItem, + // callbacks) share their component's id, since the hook is called once in + // the component body and closed over. + let functionDepth = 0; + let lastComponentId = 0; + let currentComponentId = 0; + const componentsReadingInset = new Set(); + const candidates: Array<{ readonly node: unknown; readonly componentId: number }> = []; + + const reset = () => { + isReactNativeFile = false; + functionDepth = 0; + lastComponentId = 0; + currentComponentId = 0; + componentsReadingInset.clear(); + candidates.length = 0; + }; + + const enterFunction = () => { + if (functionDepth === 0) { + lastComponentId += 1; + currentComponentId = lastComponentId; + } + functionDepth += 1; + }; + + const exitFunction = () => { + functionDepth -= 1; + if (functionDepth === 0) { + // Module scope shares id 0: a style object declared there belongs to + // no component and can never be excused by a component's inset read. + currentComponentId = 0; + } + }; + + return { + before: reset, + FunctionDeclaration: enterFunction, + "FunctionDeclaration:exit": exitFunction, + FunctionExpression: enterFunction, + "FunctionExpression:exit": exitFunction, + ArrowFunctionExpression: enterFunction, + "ArrowFunctionExpression:exit": exitFunction, + ImportDeclaration(node) { + const source = getLiteralValue(node.source); + if ( + Option.exists( + source, + (value) => typeof value === "string" && REACT_NATIVE_MODULE.test(value), + ) + ) { + isReactNativeFile = true; + } + }, + Identifier(node) { + if (typeof node.name !== "string") return; + if (SAFE_AREA_BOTTOM_IDENTIFIERS.has(node.name)) { + componentsReadingInset.add(currentComponentId); + } + }, + MemberExpression(node) { + const property = getPropertyName(node.property); + if (Option.isNone(property) || property.value !== "bottom") return; + + const object = unwrapExpression(node.object); + if (Option.isNone(object) || object.value.type !== "Identifier") return; + if (INSET_HOLDER_NAMES.has(object.value.name)) { + componentsReadingInset.add(currentComponentId); + } + }, + ObjectExpression(node) { + const properties = getObjectProperties(node); + if (!isAbsolutelyPositioned(properties)) return; + + const bottom = findProperty(properties, "bottom"); + if (Option.isNone(bottom)) return; + + // `bottom: 0` is how a keyboard-synced overlay attaches to the very + // edge while its child owns the padding; only a fixed non-zero gap + // claims to have measured the bottom edge itself. + const value = getLiteralValue(bottom.value.value); + if (Option.exists(value, (literal) => typeof literal === "number" && literal !== 0)) { + candidates.push({ node: bottom.value, componentId: currentComponentId }); + } + }, + JSXAttribute(node) { + const name = getJsxAttributeName(node); + if (Option.isNone(name) || !SCROLL_CONTENT_ATTRIBUTES.has(name.value)) return; + + const properties = getObjectProperties(getJsxAttributeExpression(node)); + for (const propertyName of SCROLL_BOTTOM_PROPERTIES) { + const property = findProperty(properties, propertyName); + if (Option.isNone(property)) continue; + if (Option.isSome(getLiteralValue(property.value.value))) { + candidates.push({ node: property.value, componentId: currentComponentId }); + } + } + }, + "Program:exit"() { + if (!isReactNativeFile) return; + + for (const candidate of candidates) { + if (componentsReadingInset.has(candidate.componentId)) continue; + context.report({ node: candidate.node as never, message: MESSAGE }); + } + }, + }; + }, +}); diff --git a/vite.config.ts b/vite.config.ts index 2175be8c8ba..d502fc5ef60 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -123,6 +123,7 @@ export default defineConfig({ "t3code/no-manual-effect-runtime-in-tests": "error", "t3code/no-native-title-tooltip": "error", "t3code/namespace-node-imports": "error", + "t3code/require-bottom-safe-area-inset": "error", }, options: { // Revisit once Oxlint's tsgolint path can integrate with @effect/tsgo diagnostics.