mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
feat: show file uploads as a toast like every other notice
Uploads in Files had their own card with a thick progress bar, larger corners and a heavier shadow, so they looked unlike the toasts around them. Toasts can now show work in progress: the thin line at the bottom fills with progress instead of counting down, and the toast stays until it is updated or removed. Uploads use it, with the file and percentage below the title and Cancel as the toast action.
This commit is contained in:
@@ -5,7 +5,7 @@ import { useTranslations } from "next-intl";
|
||||
import {
|
||||
Upload, FolderPlus, Download, Trash2, Pencil, Copy, Clipboard, Scissors, Info,
|
||||
FilePlus, CopyPlus, AlertCircle, FolderUp, Share2, SquarePen, Eye, FolderOpen,
|
||||
RefreshCw, Star, StarOff, Plus, X, CheckSquare,
|
||||
RefreshCw, Star, StarOff, Plus, CheckSquare,
|
||||
} from "@/components/icons";
|
||||
import { useIsDesktop, useIsMobile, useMediaQuery } from "@/hooks/use-media-query";
|
||||
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||
@@ -27,6 +27,7 @@ import { INTERNAL_DRAG_TYPE, anchorEvent, isInternalDrag, readDraggedNames } fro
|
||||
import { ResizeHandle } from "@/components/layout/resize-handle";
|
||||
import { getDroppedFilesAndFolders } from "@/lib/webdav/drop-utils";
|
||||
import { useFileStore, type FileResource } from "@/stores/file-store";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { ShareCollectionDialog } from "@/components/settings/share-collection-dialog";
|
||||
import type { IJMAPClient } from "@/lib/jmap/client-interface";
|
||||
import type { FileNodeRights } from "@/lib/jmap/types";
|
||||
@@ -266,6 +267,36 @@ export function FileBrowser({
|
||||
if (scrollAreaRef.current) scrollAreaRef.current.scrollTop = 0;
|
||||
}, [currentPath]);
|
||||
|
||||
// ---- Upload progress, shown as a toast like every other notice ----------------
|
||||
const uploadToastId = useRef<string | null>(null);
|
||||
const cancelUploadRef = useRef(onCancelUpload);
|
||||
cancelUploadRef.current = onCancelUpload;
|
||||
useEffect(() => {
|
||||
if (!uploadProgress) {
|
||||
if (uploadToastId.current) toast.dismiss(uploadToastId.current);
|
||||
uploadToastId.current = null;
|
||||
return;
|
||||
}
|
||||
const progress = uploadProgress.total > 0 ? uploadProgress.loaded / uploadProgress.total : null;
|
||||
const title = uploadProgress.totalFiles > 1
|
||||
? t("uploading_count", { current: uploadProgress.current, total: uploadProgress.totalFiles })
|
||||
: t("uploading");
|
||||
const message = progress === null ? uploadProgress.name : `${uploadProgress.name} · ${Math.round(progress * 100)}%`;
|
||||
if (uploadToastId.current) {
|
||||
// Closing the toast only hides it; the upload carries on.
|
||||
toast.update(uploadToastId.current, { title, message, progress });
|
||||
} else {
|
||||
uploadToastId.current = toast.progress(title, {
|
||||
message,
|
||||
progress,
|
||||
action: { label: t("cancel"), onClick: () => cancelUploadRef.current() },
|
||||
});
|
||||
}
|
||||
}, [uploadProgress, t]);
|
||||
useEffect(() => () => {
|
||||
if (uploadToastId.current) toast.dismiss(uploadToastId.current);
|
||||
}, []);
|
||||
|
||||
// ---- Sharing ---------------------------------------------------------------
|
||||
// A node is shareable when the server supports JMAP Sharing, the viewer owns
|
||||
// it (not a shared-with-me node), and holds the mayShare right (owned nodes
|
||||
@@ -1056,13 +1087,6 @@ export function FileBrowser({
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{uploadProgress && (
|
||||
<UploadProgressCard
|
||||
progress={uploadProgress}
|
||||
onCancel={onCancelUpload}
|
||||
className={cn("absolute end-4 z-30 w-80 max-w-[calc(100%-2rem)]", isNarrow && canCreate ? "bottom-20" : "bottom-4")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1183,35 +1207,6 @@ function SharedWithMe({ folders, onOpen, iconOptions }: {
|
||||
);
|
||||
}
|
||||
|
||||
function UploadProgressCard({ progress, onCancel, className }: {
|
||||
progress: { name: string; loaded: number; total: number; current: number; totalFiles: number };
|
||||
onCancel: () => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const t = useTranslations("files");
|
||||
const percent = progress.total > 0 ? Math.round((progress.loaded / progress.total) * 100) : null;
|
||||
return (
|
||||
<div role="status" className={cn("rounded-lg border border-border bg-background shadow-lg p-3", className)}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Upload className="w-4 h-4 text-primary shrink-0" />
|
||||
<p className="text-sm font-medium flex-1 min-w-0 truncate">
|
||||
{progress.totalFiles > 1
|
||||
? t("uploading_count", { current: progress.current, total: progress.totalFiles })
|
||||
: t("uploading")}
|
||||
</p>
|
||||
<span className="text-xs text-muted-foreground tabular-nums">{percent === null ? "…" : `${percent}%`}</span>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6 -me-1" onClick={onCancel} aria-label={t("cancel_upload")} title={t("cancel_upload")}>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground truncate mt-0.5 ms-6">{progress.name}</p>
|
||||
<div className="mt-2 h-1.5 bg-primary/15 rounded-full overflow-hidden" role="progressbar" aria-valuemin={0} aria-valuemax={100} aria-valuenow={percent ?? undefined}>
|
||||
<div className="h-full bg-primary rounded-full transition-all duration-300" style={{ width: `${percent ?? 0}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ListSkeleton() {
|
||||
return (
|
||||
<div className="px-4" aria-hidden>
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, render, screen } from '@testing-library/react';
|
||||
import { ToastContainer } from '../toast';
|
||||
import { toast, useToastStore } from '@/stores/toast-store';
|
||||
|
||||
function Toasts() {
|
||||
const toasts = useToastStore((s) => s.toasts);
|
||||
const removeToast = useToastStore((s) => s.removeToast);
|
||||
return <ToastContainer toasts={toasts} onClose={removeToast} />;
|
||||
}
|
||||
|
||||
describe('progress toasts', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
useToastStore.setState({ toasts: [] });
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('shows progress on the bottom line and stays until removed', () => {
|
||||
render(<Toasts />);
|
||||
let id = '';
|
||||
act(() => { id = toast.progress('Uploading...', { message: 'a.stl', progress: 0.25 }); });
|
||||
expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '25');
|
||||
|
||||
act(() => { vi.advanceTimersByTime(60_000); });
|
||||
expect(screen.getByText('Uploading...')).toBeInTheDocument();
|
||||
|
||||
act(() => { toast.update(id, { progress: 0.8, message: 'a.stl · 80%' }); });
|
||||
expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '80');
|
||||
expect(screen.getByText('a.stl · 80%')).toBeInTheDocument();
|
||||
|
||||
act(() => { toast.dismiss(id); });
|
||||
expect(screen.queryByText('Uploading...')).toBeNull();
|
||||
});
|
||||
|
||||
it('runs the action, e.g. Cancel, from the toast', () => {
|
||||
const onCancel = vi.fn();
|
||||
render(<Toasts />);
|
||||
act(() => { toast.progress('Uploading...', { progress: null, action: { label: 'Cancel', onClick: onCancel } }); });
|
||||
act(() => { screen.getByText('Cancel').click(); });
|
||||
expect(onCancel).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('counts down normally once turned into a regular toast', () => {
|
||||
render(<Toasts />);
|
||||
let id = '';
|
||||
act(() => { id = toast.progress('Uploading...', { progress: 0.5 }); });
|
||||
act(() => { toast.update(id, { title: 'Uploaded', progress: undefined, duration: 1000, type: 'success' }); });
|
||||
expect(screen.getByText('Uploaded')).toBeInTheDocument();
|
||||
act(() => { vi.advanceTimersByTime(1500); });
|
||||
expect(useToastStore.getState().toasts).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
+36
-4
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useState, useCallback, useRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { X, CheckCircle, AlertCircle, Info, AlertTriangle } from "@/components/icons";
|
||||
import { X, CheckCircle, AlertCircle, Info, AlertTriangle, Loader2 } from "@/components/icons";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type ToastType = "success" | "error" | "info" | "warning";
|
||||
@@ -27,6 +27,12 @@ export interface Toast {
|
||||
* that has one lays its actions out on a row of their own.
|
||||
*/
|
||||
tertiaryAction?: ToastAction;
|
||||
/**
|
||||
* Work in progress, e.g. an upload: 0..1, or null while the amount is
|
||||
* unknown. The bottom line then shows progress instead of a countdown,
|
||||
* and the toast stays until it is updated or removed.
|
||||
*/
|
||||
progress?: number | null;
|
||||
}
|
||||
|
||||
interface ToastProps {
|
||||
@@ -64,6 +70,14 @@ export function ToastItem({ toast, onClose }: ToastProps) {
|
||||
const remainingRef = useRef(toast.duration ?? 5000);
|
||||
const startRef = useRef(Date.now());
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const isProgress = toast.progress !== undefined;
|
||||
|
||||
// A toast updated in place (a finished upload) starts a fresh countdown.
|
||||
const lastDurationRef = useRef(toast.duration);
|
||||
if (lastDurationRef.current !== toast.duration) {
|
||||
lastDurationRef.current = toast.duration;
|
||||
remainingRef.current = toast.duration ?? 5000;
|
||||
}
|
||||
|
||||
const dismiss = useCallback(() => {
|
||||
setExiting(true);
|
||||
@@ -71,7 +85,7 @@ export function ToastItem({ toast, onClose }: ToastProps) {
|
||||
}, [onClose, toast.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!toast.duration || toast.duration <= 0) return;
|
||||
if (!toast.duration || toast.duration <= 0 || isProgress) return;
|
||||
|
||||
if (paused) {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
@@ -84,7 +98,7 @@ export function ToastItem({ toast, onClose }: ToastProps) {
|
||||
return () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
};
|
||||
}, [toast.duration, paused, dismiss]);
|
||||
}, [toast.duration, paused, dismiss, isProgress]);
|
||||
|
||||
const actionButton = (action: ToastAction) => (
|
||||
<button
|
||||
@@ -128,6 +142,8 @@ export function ToastItem({ toast, onClose }: ToastProps) {
|
||||
{/* Icon */}
|
||||
{toast.icon !== undefined ? (
|
||||
toast.icon
|
||||
) : isProgress ? (
|
||||
<Loader2 className="w-[18px] h-[18px] flex-shrink-0 mt-px text-muted-foreground animate-spin" />
|
||||
) : (
|
||||
<Icon className={cn("w-[18px] h-[18px] flex-shrink-0 mt-px", iconStyles[toast.type])} />
|
||||
)}
|
||||
@@ -169,8 +185,24 @@ export function ToastItem({ toast, onClose }: ToastProps) {
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
|
||||
{/* Progress line: the same line as the countdown, filled by the work done */}
|
||||
{isProgress && (
|
||||
<div
|
||||
className="absolute bottom-0 left-0 right-0 h-[2px] bg-foreground/[0.06]"
|
||||
role="progressbar"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={toast.progress == null ? undefined : Math.round(toast.progress * 100)}
|
||||
>
|
||||
<div
|
||||
className={cn("h-full opacity-60 transition-[width] duration-300", progressBarStyles[toast.type], toast.progress == null && "w-1/3 animate-pulse")}
|
||||
style={toast.progress == null ? undefined : { width: `${Math.max(0, Math.min(1, toast.progress)) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Countdown line */}
|
||||
{toast.duration && toast.duration > 0 && (
|
||||
{!isProgress && !!toast.duration && toast.duration > 0 && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-[2px] bg-foreground/[0.06]">
|
||||
<div
|
||||
className={cn("h-full opacity-60", progressBarStyles[toast.type])}
|
||||
|
||||
@@ -3720,7 +3720,6 @@
|
||||
"choose_account": "Choose an account",
|
||||
"drop_to_upload": "Drop to upload to {folder}",
|
||||
"uploading_count": "Uploading {current} of {total}",
|
||||
"cancel_upload": "Cancel upload",
|
||||
"dismiss": "Dismiss",
|
||||
"zoom_in": "Zoom in",
|
||||
"zoom_out": "Zoom out",
|
||||
|
||||
+25
-1
@@ -4,7 +4,9 @@ import { generateUUID } from "@/lib/utils";
|
||||
|
||||
interface ToastStore {
|
||||
toasts: Toast[];
|
||||
addToast: (toast: Omit<Toast, "id">) => void;
|
||||
addToast: (toast: Omit<Toast, "id">) => string;
|
||||
/** Change a shown toast in place, e.g. to move a progress toast along. */
|
||||
updateToast: (id: string, patch: Partial<Omit<Toast, "id">>) => void;
|
||||
removeToast: (id: string) => void;
|
||||
clearToasts: () => void;
|
||||
}
|
||||
@@ -27,6 +29,13 @@ export const useToastStore = create<ToastStore>((set) => ({
|
||||
set((state) => ({
|
||||
toasts: [...state.toasts, newToast],
|
||||
}));
|
||||
return id;
|
||||
},
|
||||
|
||||
updateToast: (id, patch) => {
|
||||
set((state) => ({
|
||||
toasts: state.toasts.map((t) => (t.id === id ? { ...t, ...patch } : t)),
|
||||
}));
|
||||
},
|
||||
|
||||
removeToast: (id) => {
|
||||
@@ -61,7 +70,22 @@ function showToast(type: Toast["type"], title: string, options?: string | ToastO
|
||||
});
|
||||
}
|
||||
|
||||
interface ProgressToastOptions {
|
||||
message?: string;
|
||||
/** 0..1, or null while unknown. */
|
||||
progress: number | null;
|
||||
action?: ToastAction;
|
||||
}
|
||||
|
||||
export const toast = {
|
||||
/**
|
||||
* A toast for work in progress. It stays until updated or dismissed; move
|
||||
* it along with `toast.update(id, { progress, message })`.
|
||||
*/
|
||||
progress: (title: string, options: ProgressToastOptions): string =>
|
||||
useToastStore.getState().addToast({ type: "info", title, duration: 0, ...options }),
|
||||
update: (id: string, patch: Partial<Omit<Toast, "id">>) => useToastStore.getState().updateToast(id, patch),
|
||||
dismiss: (id: string) => useToastStore.getState().removeToast(id),
|
||||
success: (title: string, options?: string | ToastOptions) => showToast("success", title, options),
|
||||
error: (title: string, options?: string | ToastOptions) => showToast("error", title, options, 10000),
|
||||
info: (title: string, options?: string | ToastOptions) => showToast("info", title, options),
|
||||
|
||||
Reference in New Issue
Block a user