From e0184cfb46ad5f5a2eaab725225539618a5edf41 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 13:54:48 +0100 Subject: [PATCH] fix: keep the notification prompt clear of the reader's bottom bar on tablets --- src/components/PushOnboardingPrompt.tsx | 7 ++++-- src/lib/__tests__/push-prompt-frame.test.ts | 24 +++++++++++++++++++++ src/lib/push-prompt-frame.ts | 13 +++++++++++ 3 files changed, 42 insertions(+), 2 deletions(-) create mode 100644 src/lib/__tests__/push-prompt-frame.test.ts create mode 100644 src/lib/push-prompt-frame.ts diff --git a/src/components/PushOnboardingPrompt.tsx b/src/components/PushOnboardingPrompt.tsx index f6bbd171..71dd4091 100644 --- a/src/components/PushOnboardingPrompt.tsx +++ b/src/components/PushOnboardingPrompt.tsx @@ -1,7 +1,8 @@ import React from 'react'; import { View, Text, StyleSheet, Pressable, Animated, Easing } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import { useFloatingCardFrame } from '../lib/use-layout'; +import { useFloatingCardFrame, useLayout } from '../lib/use-layout'; +import { pushPromptBottom } from '../lib/push-prompt-frame'; import { BellRing, X } from './icons'; import Button from './Button'; import { spacing, radius, typography, type ThemePalette } from '../theme/tokens'; @@ -52,6 +53,8 @@ export function PushOnboardingPrompt(): React.ReactElement | null { const insets = useSafeAreaInsets(); // Full width above the tab bar on a phone; bottom right on a tablet. const frame = useFloatingCardFrame(); + // Above the reading pane's bottom bar on a split view. + const { isSplit } = useLayout(); const duration = useAnimDuration(200); const appear = React.useRef(new Animated.Value(0)).current; @@ -118,7 +121,7 @@ export function PushOnboardingPrompt(): React.ReactElement | null { style={[ styles.wrap, { - left: frame.left, right: frame.right, bottom: insets.bottom + frame.bottom, + left: frame.left, right: frame.right, bottom: insets.bottom + pushPromptBottom(frame.bottom, isSplit), opacity: appear, transform: [{ translateY: appear.interpolate({ inputRange: [0, 1], outputRange: [24, 0] }) }], }, diff --git a/src/lib/__tests__/push-prompt-frame.test.ts b/src/lib/__tests__/push-prompt-frame.test.ts new file mode 100644 index 00000000..88eb37ab --- /dev/null +++ b/src/lib/__tests__/push-prompt-frame.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import { pushPromptBottom, READER_BAR_CLEARANCE } from '../push-prompt-frame'; + +// On a tablet split view the notification prompt sat in the bottom right +// corner, over the reading pane's Prev / Reply / Forward bar, until it was +// dismissed. + +describe('push prompt placement', () => { + it('sits above the reading pane bar on a split view', () => { + expect(pushPromptBottom(20, true)).toBe(20 + READER_BAR_CLEARANCE); + expect(READER_BAR_CLEARANCE).toBeGreaterThanOrEqual(64); + }); + + it('keeps the toast placement above the tab bar on a phone', () => { + expect(pushPromptBottom(64, false)).toBe(64); + }); + + it('is what the prompt uses', () => { + const src = fs.readFileSync(path.resolve(__dirname, '..', '..', 'components/PushOnboardingPrompt.tsx'), 'utf8'); + expect(src).toMatch(/bottom: insets\.bottom \+ pushPromptBottom\(frame\.bottom, isSplit\)/); + }); +}); diff --git a/src/lib/push-prompt-frame.ts b/src/lib/push-prompt-frame.ts new file mode 100644 index 00000000..ecdc28e4 --- /dev/null +++ b/src/lib/push-prompt-frame.ts @@ -0,0 +1,13 @@ +// Where the push notification prompt floats. It takes the toast placement +// (lib/use-layout floatingCardFrame), but stays until it is answered: on a +// tablet split view the bottom right corner is the reading pane's Prev / +// Reply / Forward bar, which the card covered until it was dismissed. There +// it sits above that bar. + +/** The reader's bottom bar (EmailThreadScreen: 60 + at least 4 for the inset) and a gap. */ +export const READER_BAR_CLEARANCE = 64; + +/** The prompt's distance from the bottom, before the safe-area inset. */ +export function pushPromptBottom(frameBottom: number, split: boolean): number { + return split ? frameBottom + READER_BAR_CLEARANCE : frameBottom; +}