mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
fix: show toasts above the phone's bottom bars, newest at the bottom, and let a swipe dismiss them
This commit is contained in:
+3
-3
@@ -492,10 +492,10 @@ optgroup {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Toast animations */
|
||||
/* Toast animations. They slide in from the end edge: --toast-slide is -1 in RTL. */
|
||||
@keyframes toast-enter {
|
||||
from {
|
||||
transform: translateX(calc(100% + 1.25rem));
|
||||
transform: translateX(calc((100% + 1.25rem) * var(--toast-slide, 1)));
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
@@ -510,7 +510,7 @@ optgroup {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
transform: translateX(calc(100% + 1.25rem));
|
||||
transform: translateX(calc((100% + 1.25rem) * var(--toast-slide, 1)));
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import { useCopyLink } from "@/hooks/use-copy-link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
import { SubMenuPanel } from "@/components/ui/context-menu";
|
||||
import { useBottomControls } from "@/hooks/use-bottom-controls";
|
||||
import { ReaderSelectionMenu, useReaderSelectionMenu, watchBodySelection, watchElementSelection } from "./reader-selection-menu";
|
||||
import { onPanelRequest } from "@/lib/plugin-sandbox/panel-requests";
|
||||
import { formatFileSize, cn, MailboxNode, formatDate, formatDateTime, formatRelativeAgo, generateUUID, cleanPreview } from "@/lib/utils";
|
||||
@@ -1174,6 +1175,8 @@ export function EmailViewer({
|
||||
// and has no bar fixed at the bottom; previous / next move to the top bar.
|
||||
const mvMobileViewer = interfaceLayout === 'mountain-view' && isMobile;
|
||||
const mvReplyRow = mvViewer || mvMobileViewer;
|
||||
// The phone's bottom action bar: toasts sit above it.
|
||||
const bottomBarRef = useBottomControls(isMobile && !mvMobileViewer);
|
||||
// The open conversation around the open message, split at it.
|
||||
const mvOpenIndex = (mvViewer || mvMobileViewer) && mvConversation && email ? mvConversation.findIndex((e) => e.id === email.id) : -1;
|
||||
const mvBefore = mvOpenIndex > 0 ? mvConversation!.slice(0, mvOpenIndex) : [];
|
||||
@@ -6529,7 +6532,7 @@ export function EmailViewer({
|
||||
viewport `sm:hidden` guard would otherwise hide it there, and
|
||||
`fixed` would span the whole app instead of the pane). */}
|
||||
{isMobile && !mvMobileViewer && (
|
||||
<nav className={cn(
|
||||
<nav ref={bottomBarRef} className={cn(
|
||||
"z-50 bg-background border-t border-border overflow-hidden pb-[calc(env(safe-area-inset-bottom)/2)]",
|
||||
isPaneScoped ? "absolute bottom-0 left-0 right-0" : "fixed bottom-0 left-0 right-0 sm:hidden",
|
||||
)}>
|
||||
|
||||
@@ -31,6 +31,7 @@ import { Avatar } from "@/components/ui/avatar";
|
||||
import { toUnicodeEmail } from "@/lib/idn";
|
||||
import { usePluginPages, pluginPageHref, isPluginPageActive } from "@/hooks/use-plugin-pages";
|
||||
import { NotificationBell } from "@/components/notifications/notification-center";
|
||||
import { useBottomControls } from "@/hooks/use-bottom-controls";
|
||||
|
||||
interface NavItem {
|
||||
id: string;
|
||||
@@ -213,6 +214,8 @@ export function NavigationRail({
|
||||
}: NavigationRailProps) {
|
||||
const t = useTranslations("sidebar");
|
||||
const tGlobalSearch = useTranslations("global_search");
|
||||
// The phone tab bar: toasts sit above it.
|
||||
const tabBarRef = useBottomControls(orientation === "horizontal");
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const { appLogoLightUrl, appLogoDarkUrl } = useConfig();
|
||||
@@ -350,6 +353,7 @@ export function NavigationRail({
|
||||
if (orientation === "horizontal") {
|
||||
return (
|
||||
<nav
|
||||
ref={tabBarRef}
|
||||
data-mobile-navigation=""
|
||||
className={cn(
|
||||
"flex items-center bg-background border-t border-border shrink-0 overflow-x-auto mobile-scroll-hidden pb-[calc(env(safe-area-inset-bottom)/2)]",
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ToastContainer } from '../toast';
|
||||
import { toast, useToastStore } from '@/stores/toast-store';
|
||||
import { useBottomControls } from '@/hooks/use-bottom-controls';
|
||||
|
||||
function Toasts() {
|
||||
const toasts = useToastStore((s) => s.toasts);
|
||||
const removeToast = useToastStore((s) => s.removeToast);
|
||||
return <ToastContainer toasts={toasts} onClose={removeToast} />;
|
||||
}
|
||||
|
||||
const card = (title: string) => screen.getByText(title).closest('.toast-item') as HTMLElement;
|
||||
|
||||
describe('toast layout', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
useToastStore.setState({ toasts: [] });
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('stacks the newest toast at the bottom, at the end corner', () => {
|
||||
render(<Toasts />);
|
||||
act(() => { toast.info('First'); });
|
||||
act(() => { toast.info('Second'); });
|
||||
const container = screen.getByRole('status');
|
||||
expect(container).toHaveClass('flex-col', 'end-5', 'max-sm:inset-x-3');
|
||||
expect(container).not.toHaveClass('flex-col-reverse');
|
||||
expect(container.className).not.toMatch(/\bright-5\b/);
|
||||
const titles = Array.from(container.querySelectorAll('.toast-item p:first-child')).map((p) => p.textContent);
|
||||
expect(titles).toEqual(['First', 'Second']);
|
||||
});
|
||||
|
||||
it('sits above the bottom controls on a phone, and over an open composer', () => {
|
||||
render(<Toasts />);
|
||||
const container = screen.getByRole('status');
|
||||
expect(container.className).toContain('max-sm:bottom-[calc(var(--bottom-controls-height,3.75rem)+0.75rem)]');
|
||||
expect(container).toHaveClass('[html[data-composer-open]_&]:bottom-20');
|
||||
});
|
||||
|
||||
it('names the close button and hides it on a phone', () => {
|
||||
render(<Toasts />);
|
||||
act(() => { toast.success('Saved'); });
|
||||
const close = screen.getByRole('button', { name: 'close' });
|
||||
expect(close).toHaveClass('max-sm:hidden');
|
||||
});
|
||||
|
||||
it('is dismissed by a sideways swipe on a touch screen', () => {
|
||||
const onDismiss = vi.fn();
|
||||
render(<Toasts />);
|
||||
act(() => { useToastStore.getState().addToast({ type: 'info', title: 'Moved', onDismiss }); });
|
||||
const el = card('Moved');
|
||||
fireEvent.pointerDown(el, { pointerType: 'touch', pointerId: 1, clientX: 100, clientY: 10 });
|
||||
fireEvent.pointerMove(el, { pointerType: 'touch', pointerId: 1, clientX: 140, clientY: 12 });
|
||||
fireEvent.pointerMove(el, { pointerType: 'touch', pointerId: 1, clientX: 200, clientY: 12 });
|
||||
expect(el.style.transform).toBe('translateX(100px)');
|
||||
fireEvent.pointerUp(el, { pointerType: 'touch', pointerId: 1, clientX: 200, clientY: 12 });
|
||||
expect(onDismiss).toHaveBeenCalledTimes(1);
|
||||
act(() => { vi.advanceTimersByTime(250); });
|
||||
expect(screen.queryByText('Moved')).toBeNull();
|
||||
});
|
||||
|
||||
it('snaps back after a short swipe, and ignores mouse drags and vertical moves', () => {
|
||||
render(<Toasts />);
|
||||
act(() => { toast.info('Stays'); });
|
||||
const el = card('Stays');
|
||||
fireEvent.pointerDown(el, { pointerType: 'touch', pointerId: 1, clientX: 100, clientY: 10 });
|
||||
fireEvent.pointerMove(el, { pointerType: 'touch', pointerId: 1, clientX: 130, clientY: 10 });
|
||||
fireEvent.pointerUp(el, { pointerType: 'touch', pointerId: 1, clientX: 130, clientY: 10 });
|
||||
expect(el.style.transform).toBe('');
|
||||
|
||||
fireEvent.pointerDown(el, { pointerType: 'mouse', pointerId: 2, clientX: 100, clientY: 10 });
|
||||
fireEvent.pointerMove(el, { pointerType: 'mouse', pointerId: 2, clientX: 300, clientY: 10 });
|
||||
fireEvent.pointerUp(el, { pointerType: 'mouse', pointerId: 2, clientX: 300, clientY: 10 });
|
||||
|
||||
fireEvent.pointerDown(el, { pointerType: 'touch', pointerId: 3, clientX: 100, clientY: 10 });
|
||||
fireEvent.pointerMove(el, { pointerType: 'touch', pointerId: 3, clientX: 110, clientY: 200 });
|
||||
fireEvent.pointerUp(el, { pointerType: 'touch', pointerId: 3, clientX: 190, clientY: 200 });
|
||||
act(() => { vi.advanceTimersByTime(300); });
|
||||
expect(screen.getByText('Stays')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('useBottomControls', () => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
function Bar({ active = true }: { active?: boolean }) {
|
||||
const ref = useBottomControls(active);
|
||||
return <nav ref={ref}>tabs</nav>;
|
||||
}
|
||||
|
||||
it('publishes how far the bar reaches from the bottom while it is mounted', () => {
|
||||
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(
|
||||
() => ({ left: 0, top: window.innerHeight - 64, width: 390, height: 64, right: 390, bottom: window.innerHeight, x: 0, y: 0, toJSON: () => ({}) }) as DOMRect,
|
||||
);
|
||||
const { unmount } = render(<Bar />);
|
||||
expect(document.documentElement.style.getPropertyValue('--bottom-controls-height')).toBe('64px');
|
||||
unmount();
|
||||
expect(document.documentElement.style.getPropertyValue('--bottom-controls-height')).toBe('');
|
||||
});
|
||||
});
|
||||
+76
-4
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect, useState, useCallback, useRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { X, CheckCircle, AlertCircle, Info, AlertTriangle, Loader2 } from "@/components/icons";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -74,9 +75,17 @@ const progressBarStyles = {
|
||||
warning: "bg-warning",
|
||||
};
|
||||
|
||||
// A sideways swipe this far (px) on a touch screen dismisses the toast.
|
||||
const SWIPE_DISMISS_DISTANCE = 72;
|
||||
|
||||
export function ToastItem({ toast, onClose }: ToastProps) {
|
||||
const tCommon = useTranslations("common");
|
||||
const Icon = icons[toast.type];
|
||||
const [exiting, setExiting] = useState(false);
|
||||
// Swipe to dismiss (touch only): how far it is dragged, and where it flew off to.
|
||||
const [dragX, setDragX] = useState(0);
|
||||
const [flungTo, setFlungTo] = useState<number | null>(null);
|
||||
const swipeRef = useRef<{ id: number; x: number; y: number; horizontal: boolean | null } | null>(null);
|
||||
const [paused, setPaused] = useState(false);
|
||||
const remainingRef = useRef(toast.duration ?? 5000);
|
||||
const startRef = useRef(Date.now());
|
||||
@@ -120,6 +129,44 @@ export function ToastItem({ toast, onClose }: ToastProps) {
|
||||
};
|
||||
}, [toast.duration, paused, dismiss, isProgress]);
|
||||
|
||||
const onPointerDown = (e: React.PointerEvent) => {
|
||||
if (e.pointerType !== "touch" || exiting) return;
|
||||
swipeRef.current = { id: e.pointerId, x: e.clientX, y: e.clientY, horizontal: null };
|
||||
};
|
||||
const onPointerMove = (e: React.PointerEvent) => {
|
||||
const swipe = swipeRef.current;
|
||||
if (!swipe || swipe.id !== e.pointerId) return;
|
||||
const dx = e.clientX - swipe.x;
|
||||
const dy = e.clientY - swipe.y;
|
||||
if (swipe.horizontal === null) {
|
||||
if (Math.abs(dx) < 8 && Math.abs(dy) < 8) return;
|
||||
swipe.horizontal = Math.abs(dx) > Math.abs(dy);
|
||||
if (swipe.horizontal) setPaused(true);
|
||||
}
|
||||
if (swipe.horizontal) setDragX(dx);
|
||||
};
|
||||
const endSwipe = (e: React.PointerEvent) => {
|
||||
const swipe = swipeRef.current;
|
||||
if (!swipe || swipe.id !== e.pointerId) return;
|
||||
swipeRef.current = null;
|
||||
if (!swipe.horizontal) return;
|
||||
const dx = e.clientX - swipe.x;
|
||||
if (Math.abs(dx) >= SWIPE_DISMISS_DISTANCE) {
|
||||
setFlungTo(Math.sign(dx) * window.innerWidth);
|
||||
try {
|
||||
toast.onDismiss?.();
|
||||
} catch {
|
||||
// A failing listener must not keep the toast on screen.
|
||||
}
|
||||
setTimeout(() => onClose(toast.id), 200);
|
||||
} else {
|
||||
setDragX(0);
|
||||
setPaused(false);
|
||||
}
|
||||
};
|
||||
// The click that ends a swipe is not a tap on the toast.
|
||||
const swiped = dragX !== 0 || flungTo !== null;
|
||||
|
||||
const actionButton = (action: ToastAction) => (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
@@ -147,12 +194,26 @@ export function ToastItem({ toast, onClose }: ToastProps) {
|
||||
"bg-popover text-popover-foreground border border-border",
|
||||
"shadow-[0_6px_16px_rgb(0,0,0,0.1)] dark:shadow-[0_6px_16px_rgb(0,0,0,0.4)]",
|
||||
"overflow-hidden pt-3 pe-2.5 pb-3.5 ps-3.5",
|
||||
// Phone: as wide as the screen allows (the container keeps 12px).
|
||||
"max-sm:w-full max-sm:max-w-none touch-pan-y rtl:[--toast-slide:-1]",
|
||||
exiting ? "toast-exit" : "toast-enter",
|
||||
toast.onClick && !toast.action && "cursor-pointer"
|
||||
)}
|
||||
style={
|
||||
flungTo !== null
|
||||
? { transform: `translateX(${flungTo}px)`, opacity: 0, transition: "transform 200ms ease-out, opacity 200ms ease-out" }
|
||||
: dragX !== 0
|
||||
? { transform: `translateX(${dragX}px)`, opacity: Math.max(0.3, 1 - Math.abs(dragX) / 240) }
|
||||
: undefined
|
||||
}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={endSwipe}
|
||||
onPointerCancel={endSwipe}
|
||||
onMouseEnter={() => { if (toast.pauseOnHover !== false) setPaused(true); }}
|
||||
onMouseLeave={() => setPaused(false)}
|
||||
onClick={() => {
|
||||
if (swiped) return;
|
||||
if (toast.onClick && !toast.action) {
|
||||
toast.onClick();
|
||||
dismissByUser();
|
||||
@@ -190,16 +251,19 @@ export function ToastItem({ toast, onClose }: ToastProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Close button */}
|
||||
{/* Close button. Phones swipe the toast away instead. */}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={tCommon("close")}
|
||||
title={tCommon("close")}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
dismissByUser();
|
||||
}}
|
||||
className={cn(
|
||||
"flex-shrink-0 p-1 rounded-md transition-all",
|
||||
"flex-shrink-0 p-1 rounded-md transition-all max-sm:hidden",
|
||||
"text-muted-foreground hover:text-foreground hover:bg-foreground/5",
|
||||
"opacity-0 group-hover:opacity-100 group-focus-within:opacity-100"
|
||||
"opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 pointer-coarse:opacity-100"
|
||||
)}
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
@@ -248,7 +312,15 @@ export function ToastContainer({ toasts, onClose }: { toasts: Toast[]; onClose:
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed bottom-5 right-5 z-[99999] flex flex-col-reverse gap-2.5 [html[data-composer-open]_&]:bottom-20"
|
||||
className={cn(
|
||||
// Newest at the bottom, at the end corner (bottom left in RTL);
|
||||
// lifted over an open composer's footer.
|
||||
"fixed bottom-5 end-5 z-[99999] flex flex-col gap-2.5 [html[data-composer-open]_&]:bottom-20",
|
||||
// Phone: full width with 12px margins, above the bottom controls
|
||||
// (the tab bar, or whichever bar registered with useBottomControls).
|
||||
"max-sm:inset-x-3 max-sm:bottom-[calc(var(--bottom-controls-height,3.75rem)+0.75rem)]",
|
||||
"max-sm:[html[data-composer-open]_&]:bottom-[max(5rem,calc(var(--bottom-controls-height,3.75rem)+0.75rem))]"
|
||||
)}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
style={{ pointerEvents: "none" }}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
/**
|
||||
* Bars pinned to the bottom of the screen on a phone (the tab bar, a
|
||||
* viewer's action bar) register here. While any is on screen,
|
||||
* `--bottom-controls-height` on <html> says how far from the bottom of the
|
||||
* window the topmost of them reaches, and toasts sit above it.
|
||||
*/
|
||||
const bars = new Set<HTMLElement>();
|
||||
let frame = 0;
|
||||
|
||||
function update() {
|
||||
frame = 0;
|
||||
if (typeof document === 'undefined') return;
|
||||
let height = 0;
|
||||
for (const bar of bars) {
|
||||
const rect = bar.getBoundingClientRect();
|
||||
if (rect.width === 0 && rect.height === 0) continue;
|
||||
height = Math.max(height, window.innerHeight - rect.top);
|
||||
}
|
||||
const root = document.documentElement.style;
|
||||
if (bars.size === 0) root.removeProperty('--bottom-controls-height');
|
||||
else root.setProperty('--bottom-controls-height', `${Math.max(0, Math.round(height))}px`);
|
||||
}
|
||||
|
||||
function schedule() {
|
||||
if (frame) return;
|
||||
if (typeof requestAnimationFrame === 'undefined') update();
|
||||
else frame = requestAnimationFrame(update);
|
||||
}
|
||||
|
||||
/**
|
||||
* Measure a bottom bar into `--bottom-controls-height` while it is mounted
|
||||
* and `active`. Returns the ref to put on the bar:
|
||||
*
|
||||
* const barRef = useBottomControls(isMobile);
|
||||
* <nav ref={barRef} className="fixed bottom-0 ...">
|
||||
*/
|
||||
export function useBottomControls(active = true): (el: HTMLElement | null) => void {
|
||||
const [el, setEl] = useState<HTMLElement | null>(null);
|
||||
useEffect(() => {
|
||||
if (!el || !active) return;
|
||||
bars.add(el);
|
||||
update();
|
||||
const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(schedule);
|
||||
observer?.observe(el);
|
||||
window.addEventListener('resize', schedule);
|
||||
return () => {
|
||||
observer?.disconnect();
|
||||
window.removeEventListener('resize', schedule);
|
||||
bars.delete(el);
|
||||
update();
|
||||
};
|
||||
}, [el, active]);
|
||||
return setEl;
|
||||
}
|
||||
Reference in New Issue
Block a user