mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 00:07:01 +00:00
fix: float the notification prompt in the list column on a tablet, clear of the reader
This commit is contained in:
@@ -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] }) }],
|
||||
},
|
||||
|
||||
@@ -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\}/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
|
||||
@@ -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<View>(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 (
|
||||
<View style={[styles.container, { paddingTop: insets.top, paddingLeft: insets.left, paddingRight: insets.right }]}>
|
||||
<View
|
||||
ref={rootRef}
|
||||
onLayout={reportListColumn}
|
||||
style={[styles.container, { paddingTop: insets.top, paddingLeft: insets.left, paddingRight: insets.right }]}
|
||||
>
|
||||
{/* Header */}
|
||||
{selectionMode ? (
|
||||
// Keyed apart from the normal header: reused in place, its title
|
||||
|
||||
Reference in New Issue
Block a user