diff --git a/components/files/file-browser.tsx b/components/files/file-browser.tsx index 7b167eb73..902e58f2c 100644 --- a/components/files/file-browser.tsx +++ b/components/files/file-browser.tsx @@ -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(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({ )} - {uploadProgress && ( - - )} @@ -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 ( -
-
- -

- {progress.totalFiles > 1 - ? t("uploading_count", { current: progress.current, total: progress.totalFiles }) - : t("uploading")} -

- {percent === null ? "…" : `${percent}%`} - -
-

{progress.name}

-
-
-
-
- ); -} - function ListSkeleton() { return (
diff --git a/components/ui/__tests__/toast-progress.test.tsx b/components/ui/__tests__/toast-progress.test.tsx new file mode 100644 index 000000000..3eb46e0cd --- /dev/null +++ b/components/ui/__tests__/toast-progress.test.tsx @@ -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 ; +} + +describe('progress toasts', () => { + beforeEach(() => { + vi.useFakeTimers(); + useToastStore.setState({ toasts: [] }); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it('shows progress on the bottom line and stays until removed', () => { + render(); + 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(); + 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(); + 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); + }); +}); diff --git a/components/ui/toast.tsx b/components/ui/toast.tsx index fde6d04f5..011282cca 100644 --- a/components/ui/toast.tsx +++ b/components/ui/toast.tsx @@ -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 | 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) => ( + {/* Progress line: the same line as the countdown, filled by the work done */} + {isProgress && ( +
+
+
+ )} + {/* Countdown line */} - {toast.duration && toast.duration > 0 && ( + {!isProgress && !!toast.duration && toast.duration > 0 && (
) => void; + addToast: (toast: Omit) => string; + /** Change a shown toast in place, e.g. to move a progress toast along. */ + updateToast: (id: string, patch: Partial>) => void; removeToast: (id: string) => void; clearToasts: () => void; } @@ -27,6 +29,13 @@ export const useToastStore = create((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>) => 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),