diff --git a/src/components/PushOnboardingPrompt.tsx b/src/components/PushOnboardingPrompt.tsx index 71dd4091..3503576c 100644 --- a/src/components/PushOnboardingPrompt.tsx +++ b/src/components/PushOnboardingPrompt.tsx @@ -1,8 +1,8 @@ import React from 'react'; -import { View, Text, StyleSheet, Pressable, Animated, Easing } from 'react-native'; +import { View, Text, StyleSheet, Pressable, Animated, Easing, useWindowDimensions } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useFloatingCardFrame, useLayout } from '../lib/use-layout'; -import { pushPromptBottom } from '../lib/push-prompt-frame'; +import { pushPromptFrame, usePromptListColumn } from '../lib/push-prompt-frame'; import { BellRing, X } from './icons'; import Button from './Button'; import { spacing, radius, typography, type ThemePalette } from '../theme/tokens'; @@ -53,8 +53,12 @@ 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. + // In the mail list column on a split view, clear of the reader's quick + // reply and bottom bar (see pushPromptFrame). const { isSplit } = useLayout(); + const { width: windowWidth } = useWindowDimensions(); + const listColumn = usePromptListColumn(); + const place = pushPromptFrame({ frame, split: isSplit, windowWidth, listColumn }); const duration = useAnimDuration(200); const appear = React.useRef(new Animated.Value(0)).current; @@ -121,7 +125,7 @@ export function PushOnboardingPrompt(): React.ReactElement | null { style={[ styles.wrap, { - left: frame.left, right: frame.right, bottom: insets.bottom + pushPromptBottom(frame.bottom, isSplit), + left: place.left, right: place.right, bottom: insets.bottom + place.bottom, 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 index 88eb37ab..4dd77e32 100644 --- a/src/lib/__tests__/push-prompt-frame.test.ts +++ b/src/lib/__tests__/push-prompt-frame.test.ts @@ -1,24 +1,46 @@ 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'; +import { + LIST_COLUMN_MARGIN, LIST_FAB_CLEARANCE, pushPromptBottom, pushPromptFrame, 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. +// corner, over the reading pane. Lifted over the Prev / Reply / Forward bar +// it still covered the quick reply's "More options" and Send row (portrait, +// y 2024-2272 of 2560 px). With the mail list on screen it now sits in the +// list column. + +const toastFrame = { left: 420, right: 20, bottom: 20 }; describe('push prompt placement', () => { - it('sits above the reading pane bar on a split view', () => { + it('sits in the list column, clear of the reader, on a split view', () => { + // An 800 dp portrait tablet: an 80 dp rail, then a 320 dp list column. + const place = pushPromptFrame({ frame: toastFrame, split: true, windowWidth: 800, listColumn: { x: 80, width: 320 } }); + expect(place).toEqual({ left: 80 + LIST_COLUMN_MARGIN, right: 800 - 400 + LIST_COLUMN_MARGIN, bottom: LIST_FAB_CLEARANCE }); + // Its right edge stays left of where the reader starts. + expect(800 - place.right).toBeLessThanOrEqual(400); + // Above the list's compose button. + expect(place.bottom).toBeGreaterThanOrEqual(16 + 56); + }); + + it('keeps the bottom right corner above a reader bar where no list is on screen', () => { + expect(pushPromptFrame({ frame: toastFrame, split: true, windowWidth: 800, listColumn: null })) + .toEqual({ left: 420, right: 20, bottom: 20 + READER_BAR_CLEARANCE }); 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); + const phone = { left: 12, right: 12, bottom: 64 }; + expect(pushPromptFrame({ frame: phone, split: false, windowWidth: 412, listColumn: { x: 0, width: 412 } })).toEqual(phone); }); - it('is what the prompt uses', () => { + it('is what the prompt uses, with the column the mail list reports', () => { const src = fs.readFileSync(path.resolve(__dirname, '..', '..', 'components/PushOnboardingPrompt.tsx'), 'utf8'); - expect(src).toMatch(/bottom: insets\.bottom \+ pushPromptBottom\(frame\.bottom, isSplit\)/); + expect(src).toMatch(/pushPromptFrame\(\{ frame, split: isSplit, windowWidth, listColumn \}\)/); + expect(src).toMatch(/bottom: insets\.bottom \+ place\.bottom/); + const list = fs.readFileSync(path.resolve(__dirname, '..', '..', 'screens/EmailListScreen.tsx'), 'utf8'); + expect(list).toMatch(/measureInWindow\(\(x, _y, width\) => setPromptListColumn\(\{ x, width \}\)\)/); + expect(list).toMatch(/onLayout=\{reportListColumn\}/); }); }); diff --git a/src/lib/push-prompt-frame.ts b/src/lib/push-prompt-frame.ts index ecdc28e4..9b4dba2f 100644 --- a/src/lib/push-prompt-frame.ts +++ b/src/lib/push-prompt-frame.ts @@ -1,13 +1,76 @@ // 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. +// (lib/use-layout floatingCardFrame), but stays until it is answered. On a +// tablet split view the bottom right corner is the reading pane: lifting the +// card over the Prev / Reply / Forward bar wasn't enough, since the quick +// reply sits right above that bar and scrolls with the message, and the card +// still covered its "More options" and Send row. While the mail list is on +// screen in a split view the card goes into the list column instead, above +// the compose button; the list scrolls out from under it, the reader never +// has anything over it. + +import React from 'react'; /** The reader's bottom bar (EmailThreadScreen: 60 + at least 4 for the inset) and a gap. */ export const READER_BAR_CLEARANCE = 64; +/** The list's compose button (16 from the bottom, 56 tall) and a 12 dp gap. */ +export const LIST_FAB_CLEARANCE = 16 + 56 + 12; + +/** Side margin of the card inside the list column. */ +export const LIST_COLUMN_MARGIN = 12; + +/** The mail list column in window coordinates, while it is on screen in a split view. */ +export interface ListColumnFrame { + x: number; + width: number; +} + +let listColumn: ListColumnFrame | null = null; +const listeners = new Set<() => void>(); + +/** The mail list reports its column (null when it leaves the screen or the split view). */ +export function setPromptListColumn(frame: ListColumnFrame | null): void { + const same = frame === listColumn + || (!!frame && !!listColumn && frame.x === listColumn.x && frame.width === listColumn.width); + if (same) return; + listColumn = frame; + for (const l of listeners) l(); +} + +function subscribe(listener: () => void): () => void { + listeners.add(listener); + return () => { listeners.delete(listener); }; +} + +export function usePromptListColumn(): ListColumnFrame | null { + return React.useSyncExternalStore(subscribe, () => listColumn, () => listColumn); +} + /** 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; } + +/** + * The prompt's left, right and bottom offsets (bottom before the safe-area + * inset). A phone keeps the toast frame; a split view with the mail list on + * screen puts it in the list column; any other split view keeps the bottom + * right corner, above where a reader's bar would be. + */ +export function pushPromptFrame(opts: { + frame: { left: number; right: number; bottom: number }; + split: boolean; + windowWidth: number; + listColumn: ListColumnFrame | null; +}): { left: number; right: number; bottom: number } { + const { frame, split, windowWidth, listColumn: column } = opts; + if (!split) return frame; + if (column && column.width > 2 * LIST_COLUMN_MARGIN) { + return { + left: column.x + LIST_COLUMN_MARGIN, + right: Math.max(LIST_COLUMN_MARGIN, windowWidth - (column.x + column.width) + LIST_COLUMN_MARGIN), + bottom: LIST_FAB_CLEARANCE, + }; + } + return { left: frame.left, right: frame.right, bottom: pushPromptBottom(frame.bottom, true) }; +} diff --git a/src/screens/EmailListScreen.tsx b/src/screens/EmailListScreen.tsx index 16200da7..533c6269 100644 --- a/src/screens/EmailListScreen.tsx +++ b/src/screens/EmailListScreen.tsx @@ -13,7 +13,7 @@ import { GlobalSearchSheet } from '../components/search/GlobalSearchSheet'; import { ActionSheet } from '../components/email/ActionSheet'; import { SORT_CHOICES, sortChoiceChanges, sortChoiceFor, type SortChoiceKey } from '../lib/sort-choices'; import { orderForMailbox, sanitizeSortLevels } from '../lib/message-list-order'; -import { useNavigation } from '@react-navigation/native'; +import { useIsFocused, useNavigation } from '@react-navigation/native'; import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { spacing, radius, typography, componentSizes, mix, type ThemePalette } from '../theme/tokens'; import { useColors, useResolvedTheme } from '../theme/colors'; @@ -91,6 +91,7 @@ import { createFrameMonitor, logModuleTimes, perfOnce, perfStart } from '../lib/ import { MODAL_ORIENTATIONS } from '../lib/modal-orientations'; import { searchEverythingPlacement } from '../lib/search-everything-entry'; import { fitToolbarActions } from '../lib/selection-toolbar'; +import { setPromptListColumn } from '../lib/push-prompt-frame'; // The server refuses keywords per message (a read-only shared folder; // Stalwart 1.0: over 128 bytes, or more than 100 on one message), and the @@ -486,6 +487,21 @@ export default function EmailListScreen({ // The undo bar floats over the list's bottom right on a phone (on a split // layout it sits over the reading pane instead). const { isSplit, shortHeight, widthClass } = useLayout(); + // In a split view the push prompt floats in this column, not over the + // reader's quick reply (lib/push-prompt-frame). + const isFocused = useIsFocused(); + const rootRef = React.useRef(null); + const reportListColumn = React.useCallback(() => { + if (!isSplit || !isFocused) { + setPromptListColumn(null); + return; + } + rootRef.current?.measureInWindow((x, _y, width) => setPromptListColumn({ x, width })); + }, [isSplit, isFocused]); + React.useEffect(() => { + reportListColumn(); + return () => setPromptListColumn(null); + }, [reportListColumn]); const selectionCountOnly = isSplit || widthClass === 'compact'; const listUndo = useEmailStore((s) => s.pendingUndo != null); const sendUndo = useSendUndoStore((s) => s.pending != null); @@ -1493,7 +1509,11 @@ export default function EmailListScreen({ ); return ( - + {/* Header */} {selectionMode ? ( // Keyed apart from the normal header: reused in place, its title