ToastContext.tsx 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505
  1. import { AlertCircle, CheckCircle, ChevronDown, ChevronUp, Info, Loader2, X, XCircle } from 'lucide-react';
  2. import { createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from 'react';
  3. import { useTranslation } from 'react-i18next';
  4. import { formatFileSize } from '../utils/file';
  5. type ToastType = 'success' | 'error' | 'warning' | 'info' | 'loading';
  6. // Dispatch-toast types — ported verbatim from
  7. // 0b43ac0d:frontend/src/contexts/ToastContext.tsx. The visual rendering
  8. // block below is the legacy code 1:1; the only swap is the event ingestion
  9. // (now sourced from `bambuddy:dispatch-toast` window events that
  10. // useWebSocket forwards from the four backend WS event types added in
  11. // the #1625 follow-up). Same shape, same DOM, same styling, same i18n
  12. // surface — guarantees the modal looks identical to the pre-scheduler
  13. // experience that users remember.
  14. type DispatchJobStatus = 'processing' | 'completed' | 'failed';
  15. interface DispatchToastJob {
  16. jobId: number;
  17. sourceName: string;
  18. printerName: string;
  19. status: DispatchJobStatus;
  20. uploadBytes?: number;
  21. uploadTotalBytes?: number;
  22. uploadProgressPct?: number;
  23. failReason?: string;
  24. }
  25. interface DispatchToastData {
  26. total: number;
  27. processing: number;
  28. completed: number;
  29. failed: number;
  30. jobs: DispatchToastJob[];
  31. }
  32. interface ToastAction {
  33. label: string;
  34. href: string;
  35. onClick?: () => void;
  36. }
  37. type ShowPersistentToast = (
  38. id: string,
  39. message: string,
  40. type?: ToastType,
  41. options?: { action?: ToastAction },
  42. ) => void;
  43. interface Toast {
  44. id: string;
  45. message: string;
  46. type: ToastType;
  47. persistent?: boolean;
  48. action?: ToastAction;
  49. dispatchData?: DispatchToastData;
  50. }
  51. interface ToastContextType {
  52. showToast: (message: string, type?: ToastType) => void;
  53. showPersistentToast: ShowPersistentToast;
  54. dismissToast: (id: string) => void;
  55. setViewportSuppressed: (suppressed: boolean) => void;
  56. }
  57. const ToastContext = createContext<ToastContextType | undefined>(undefined);
  58. export function useToast() {
  59. const context = useContext(ToastContext);
  60. if (!context) {
  61. throw new Error('useToast must be used within a ToastProvider');
  62. }
  63. return context;
  64. }
  65. const icons = {
  66. success: <CheckCircle className="w-5 h-5 text-green-400" />,
  67. error: <XCircle className="w-5 h-5 text-red-400" />,
  68. warning: <AlertCircle className="w-5 h-5 text-yellow-400" />,
  69. info: <Info className="w-5 h-5 text-blue-400" />,
  70. loading: <Loader2 className="w-5 h-5 text-bambu-green animate-spin" />,
  71. };
  72. const bgColors = {
  73. success: 'bg-green-500/10 border-green-500/30',
  74. error: 'bg-red-500/10 border-red-500/30',
  75. warning: 'bg-yellow-500/10 border-yellow-500/30',
  76. info: 'bg-blue-500/10 border-blue-500/30',
  77. loading: 'bg-bambu-green/10 border-bambu-green/30',
  78. };
  79. const DISPATCH_TOAST_ID = 'background-dispatch';
  80. const DISPATCH_TERMINAL_DISMISS_MS = 3500;
  81. // Auto-dismiss windows for the plain (non-persistent) toasts. Errors and
  82. // warnings get double the default because they carry far more text than a
  83. // success confirmation — a backend failure reason or a validation message
  84. // often runs to a couple of lines, and 3s isn't long enough to finish
  85. // reading one before it slides away. Success/info stay short: they confirm
  86. // something the user just did and are skimmed, not read.
  87. const TOAST_DISMISS_MS = 3000;
  88. const TOAST_DISMISS_LONG_MS = 2 * TOAST_DISMISS_MS;
  89. const LONG_LIVED_TOAST_TYPES: ReadonlySet<ToastType> = new Set(['error', 'warning']);
  90. interface DispatchEventDetail {
  91. type: string;
  92. queue_item_id: number;
  93. printer_id?: number | null;
  94. printer_name?: string | null;
  95. file_name?: string;
  96. total_bytes?: number;
  97. bytes_transferred?: number;
  98. pct?: number;
  99. reason?: string;
  100. }
  101. function isAwaitingPrinter(job: DispatchToastJob): boolean {
  102. // Same trick the legacy code used to derive "Awaiting printer…" without
  103. // a separate status. While the job is still 'processing' AND upload pct
  104. // has reached 99.9%, the printer hasn't yet acked our project_file.
  105. return (
  106. job.status === 'processing'
  107. && typeof job.uploadProgressPct === 'number'
  108. && job.uploadProgressPct >= 99.9
  109. );
  110. }
  111. function recomputeAggregate(jobs: DispatchToastJob[]): DispatchToastData {
  112. return {
  113. total: jobs.length,
  114. processing: jobs.filter((j) => j.status === 'processing').length,
  115. completed: jobs.filter((j) => j.status === 'completed').length,
  116. failed: jobs.filter((j) => j.status === 'failed').length,
  117. jobs,
  118. };
  119. }
  120. export function ToastProvider({ children }: { children: ReactNode }) {
  121. const { t } = useTranslation();
  122. const [toasts, setToasts] = useState<Toast[]>([]);
  123. const [viewportSuppressed, setViewportSuppressed] = useState(false);
  124. const [isDispatchCollapsed, setIsDispatchCollapsed] = useState(false);
  125. const timeoutRefs = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
  126. // Tracks whether the provider is still mounted. A toast can be triggered by
  127. // an async callback that resolves AFTER React has unmounted us (common in
  128. // tests: `cleanup()` runs while a login promise is still in flight, then
  129. // the error handler calls showToast). In that case, scheduling a setTimeout
  130. // that later calls setToasts produces "window is not defined" once the jsdom
  131. // environment is torn down. Guard every setToasts call behind this ref so a
  132. // post-unmount showToast is a no-op instead of crashing.
  133. const isMountedRef = useRef(true);
  134. // Clean up all timeouts on unmount
  135. useEffect(() => {
  136. isMountedRef.current = true;
  137. const timeouts = timeoutRefs.current;
  138. return () => {
  139. isMountedRef.current = false;
  140. timeouts.forEach((timeout) => clearTimeout(timeout));
  141. timeouts.clear();
  142. };
  143. }, []);
  144. const showToast = useCallback((message: string, type: ToastType = 'success') => {
  145. if (!isMountedRef.current) return;
  146. const id = Math.random().toString(36).substr(2, 9);
  147. setToasts((prev) => [...prev, { id, message, type }]);
  148. // Auto-dismiss — longer for the types that carry more to read.
  149. const timeout = setTimeout(() => {
  150. if (!isMountedRef.current) return;
  151. setToasts((prev) => prev.filter((t) => t.id !== id));
  152. timeoutRefs.current.delete(id);
  153. }, LONG_LIVED_TOAST_TYPES.has(type) ? TOAST_DISMISS_LONG_MS : TOAST_DISMISS_MS);
  154. timeoutRefs.current.set(id, timeout);
  155. }, []);
  156. const showPersistentToast = useCallback(
  157. (id: string, message: string, type: ToastType = 'info', options?: { action?: ToastAction }) => {
  158. if (!isMountedRef.current) return;
  159. setToasts((prev) => {
  160. // Update existing toast if same id, otherwise add new one
  161. const exists = prev.find((t) => t.id === id);
  162. if (exists) {
  163. return prev.map((t) =>
  164. t.id === id ? { ...t, message, type, persistent: true, action: options?.action } : t,
  165. );
  166. }
  167. return [...prev, { id, message, type, persistent: true, action: options?.action }];
  168. });
  169. },
  170. [],
  171. );
  172. const dismissToast = useCallback((id: string) => {
  173. if (!isMountedRef.current) return;
  174. // Clear any pending auto-dismiss timeout
  175. const timeout = timeoutRefs.current.get(id);
  176. if (timeout) {
  177. clearTimeout(timeout);
  178. timeoutRefs.current.delete(id);
  179. }
  180. setToasts((prev) => prev.filter((t) => t.id !== id));
  181. }, []);
  182. // Dispatch-toast ingestion. The four event types from the backend
  183. // (queue_item_uploading / upload_progress / acked / failed) map to
  184. // the legacy DispatchToastJob shape, then the same auto-dismiss +
  185. // aggregate-recompute logic from 0b43ac0d takes over.
  186. useEffect(() => {
  187. const onDispatchEvent = (event: Event) => {
  188. if (!isMountedRef.current) return;
  189. const detail = (event as CustomEvent<DispatchEventDetail>).detail;
  190. if (!detail || typeof detail.queue_item_id !== 'number') return;
  191. const jobId = detail.queue_item_id;
  192. setToasts((prev) => {
  193. const existing = prev.find((toastItem) => toastItem.id === DISPATCH_TOAST_ID);
  194. const existingJobs = existing?.dispatchData?.jobs ?? [];
  195. const existingJobIndex = existingJobs.findIndex((j) => j.jobId === jobId);
  196. const existingJob = existingJobIndex >= 0 ? existingJobs[existingJobIndex] : undefined;
  197. let nextJob: DispatchToastJob | null = null;
  198. const sourceName =
  199. detail.file_name
  200. || existingJob?.sourceName
  201. || t('dispatchToast.untitled');
  202. const printerName =
  203. detail.printer_name
  204. || existingJob?.printerName
  205. || (detail.printer_id ? `Printer ${detail.printer_id}` : '');
  206. switch (detail.type) {
  207. case 'queue_item_uploading':
  208. // Materialization point — job appears here, never on queue-add.
  209. nextJob = {
  210. jobId,
  211. sourceName,
  212. printerName,
  213. status: 'processing',
  214. uploadBytes: 0,
  215. uploadTotalBytes: detail.total_bytes,
  216. uploadProgressPct: 0,
  217. };
  218. break;
  219. case 'queue_item_upload_progress':
  220. if (!existingJob) return prev;
  221. nextJob = {
  222. ...existingJob,
  223. uploadBytes: detail.bytes_transferred,
  224. uploadTotalBytes: detail.total_bytes ?? existingJob.uploadTotalBytes,
  225. uploadProgressPct: detail.pct,
  226. };
  227. break;
  228. case 'queue_item_acked':
  229. if (!existingJob) return prev;
  230. nextJob = {
  231. ...existingJob,
  232. status: 'completed',
  233. uploadProgressPct: 100,
  234. };
  235. break;
  236. case 'queue_item_failed':
  237. if (!existingJob) return prev;
  238. nextJob = {
  239. ...existingJob,
  240. status: 'failed',
  241. failReason: detail.reason,
  242. };
  243. break;
  244. default:
  245. return prev;
  246. }
  247. // Compose the updated jobs list
  248. let updatedJobs: DispatchToastJob[];
  249. if (existingJob) {
  250. updatedJobs = [...existingJobs];
  251. updatedJobs[existingJobIndex] = nextJob;
  252. } else {
  253. updatedJobs = [...existingJobs, nextJob];
  254. }
  255. const dispatchData = recomputeAggregate(updatedJobs);
  256. const toastShape: Toast = {
  257. id: DISPATCH_TOAST_ID,
  258. message: t('dispatchToast.startingPrints'),
  259. type: 'loading',
  260. persistent: true,
  261. dispatchData,
  262. };
  263. if (existing) {
  264. return prev.map((toastItem) =>
  265. toastItem.id === DISPATCH_TOAST_ID ? toastShape : toastItem,
  266. );
  267. }
  268. return [...prev, toastShape];
  269. });
  270. };
  271. window.addEventListener('bambuddy:dispatch-toast', onDispatchEvent);
  272. return () => window.removeEventListener('bambuddy:dispatch-toast', onDispatchEvent);
  273. }, [t]);
  274. // Auto-dismiss the wrapper once every job has reached a terminal state.
  275. useEffect(() => {
  276. const dispatchToast = toasts.find((tst) => tst.id === DISPATCH_TOAST_ID);
  277. if (!dispatchToast?.dispatchData) return;
  278. const data = dispatchToast.dispatchData;
  279. if (data.total === 0 || data.processing !== 0) return;
  280. const existing = timeoutRefs.current.get(DISPATCH_TOAST_ID);
  281. if (existing) clearTimeout(existing);
  282. const timeout = setTimeout(() => {
  283. if (!isMountedRef.current) return;
  284. setToasts((prev) => prev.filter((tst) => tst.id !== DISPATCH_TOAST_ID));
  285. timeoutRefs.current.delete(DISPATCH_TOAST_ID);
  286. }, DISPATCH_TERMINAL_DISMISS_MS);
  287. timeoutRefs.current.set(DISPATCH_TOAST_ID, timeout);
  288. }, [toasts]);
  289. return (
  290. <ToastContext.Provider value={{ showToast, showPersistentToast, dismissToast, setViewportSuppressed }}>
  291. {children}
  292. {/* Toast Container — to the left of the bug-report bubble (bottom-4 right-4 w-12).
  293. The kiosk layout suppresses this entire viewport so SpoolBuddy displays stay
  294. free of main-app notifications.
  295. Position is set via safe-area-aware calc() rather than bottom-4/right-20 so an
  296. installed PWA on a notched phone clears the home indicator / landscape notch
  297. (#2612): the 5rem right offset keeps clearance for the bug bubble. */}
  298. <div
  299. data-testid="toast-viewport"
  300. className={`fixed z-[60] flex flex-col items-end gap-2 ${viewportSuppressed ? 'hidden' : ''}`}
  301. style={{
  302. bottom: 'calc(1rem + env(safe-area-inset-bottom))',
  303. right: 'calc(5rem + env(safe-area-inset-right))',
  304. }}
  305. >
  306. {toasts.map((toast) => (
  307. <div
  308. key={toast.id}
  309. className={`rounded-lg border shadow-lg backdrop-blur-sm animate-slide-in ${bgColors[toast.type]} ${
  310. toast.dispatchData ? 'w-[420px] p-3' : 'flex items-center gap-3 px-4 py-3'
  311. }`}
  312. // Cap width to the viewport so the fixed-width dispatch toast (420px)
  313. // can't run off the left edge on a phone (#2612). At the cap the toast
  314. // sits 1rem + safe-area from the left; on desktop the 420px wins. The
  315. // 6rem = the 5rem right offset above + a 1rem left gutter.
  316. style={{
  317. maxWidth:
  318. 'calc(100vw - 6rem - env(safe-area-inset-left) - env(safe-area-inset-right))',
  319. }}
  320. data-testid={toast.dispatchData ? 'dispatch-toast-wrapper' : undefined}
  321. >
  322. {toast.dispatchData ? (
  323. // Legacy dispatch-toast rendering — verbatim port from
  324. // 0b43ac0d:frontend/src/contexts/ToastContext.tsx lines
  325. // 515–650. Same DOM, same Tailwind classes, same uppercase
  326. // status chip, same `awaitingPrinter` derivation. Only
  327. // diff vs legacy: no cancel button (the BG dispatch
  328. // cancel endpoint doesn't exist in the scheduler model).
  329. <>
  330. <div className="flex items-start justify-between gap-3">
  331. <div className="flex items-start gap-2">
  332. {icons[toast.type]}
  333. <div>
  334. <p className="text-white text-sm font-medium">{t('dispatchToast.startingPrints')}</p>
  335. <p className="text-xs text-bambu-gray mt-0.5">
  336. {t('dispatchToast.progressSummary', {
  337. complete: toast.dispatchData.completed + toast.dispatchData.failed,
  338. total: toast.dispatchData.total,
  339. processing: toast.dispatchData.processing,
  340. })}
  341. </p>
  342. </div>
  343. </div>
  344. <div className="flex items-center gap-1">
  345. <button
  346. onClick={() => setIsDispatchCollapsed((prev) => !prev)}
  347. className="text-bambu-gray hover:text-white transition-colors"
  348. aria-label={isDispatchCollapsed ? t('dispatchToast.expandDetails') : t('dispatchToast.collapseDetails')}
  349. data-testid="dispatch-toast-collapse"
  350. >
  351. {isDispatchCollapsed ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
  352. </button>
  353. <button
  354. onClick={() => dismissToast(toast.id)}
  355. className="text-bambu-gray hover:text-white transition-colors"
  356. aria-label={t('dispatchToast.dismiss')}
  357. data-testid="dispatch-toast-dismiss"
  358. >
  359. <X className="w-4 h-4" />
  360. </button>
  361. </div>
  362. </div>
  363. {!isDispatchCollapsed && (
  364. <div className="mt-3 space-y-2 max-h-64 overflow-y-auto pr-1">
  365. {toast.dispatchData.jobs.map((job) => {
  366. const uploadDoneAwaitingPrinter = isAwaitingPrinter(job);
  367. const barColorByStatus: Record<DispatchJobStatus, string> = {
  368. processing: 'bg-bambu-green',
  369. completed: 'bg-green-500',
  370. failed: 'bg-red-500',
  371. };
  372. const progressByStatus: Record<DispatchJobStatus, number> = {
  373. processing: 60,
  374. completed: 100,
  375. failed: 100,
  376. };
  377. return (
  378. <div
  379. key={job.jobId}
  380. className="rounded border border-white/10 bg-black/15 p-2"
  381. data-testid={`dispatch-toast-job-${job.jobId}`}
  382. >
  383. <div className="flex items-center justify-between gap-2">
  384. {/* min-w-0 + flex-1 lets truncate actually kick in
  385. when the toast is capped to a phone's width
  386. (#2612); the status chip stays put with shrink-0. */}
  387. <span className="text-xs text-white truncate min-w-0 flex-1" title={job.sourceName}>
  388. {job.sourceName}
  389. </span>
  390. <span
  391. className="text-[11px] uppercase tracking-wide text-bambu-gray shrink-0"
  392. data-testid={`dispatch-toast-status-${job.jobId}`}
  393. >
  394. {t(`dispatchToast.status.${job.status}`)}
  395. </span>
  396. </div>
  397. {job.printerName && (
  398. <div className="text-[11px] text-bambu-gray truncate" title={job.printerName}>
  399. {job.printerName}
  400. </div>
  401. )}
  402. {job.status === 'processing' ? (
  403. uploadDoneAwaitingPrinter ? (
  404. <div className="text-[11px] text-bambu-gray truncate">
  405. {t('dispatchToast.awaitingPrinter')}
  406. </div>
  407. ) : typeof job.uploadBytes === 'number'
  408. && typeof job.uploadTotalBytes === 'number'
  409. && job.uploadTotalBytes > 0 ? (
  410. <div className="text-[11px] text-bambu-gray truncate">
  411. {formatFileSize(job.uploadBytes)} / {formatFileSize(job.uploadTotalBytes)}
  412. {typeof job.uploadProgressPct === 'number' ? ` (${job.uploadProgressPct.toFixed(1)}%)` : ''}
  413. </div>
  414. ) : null
  415. ) : job.status === 'failed' && job.failReason ? (
  416. <div className="text-[11px] text-red-400 truncate">
  417. {t(`dispatchToast.failed.${job.failReason}`, { defaultValue: t('dispatchToast.failed.generic') })}
  418. </div>
  419. ) : null}
  420. <div className="mt-1 h-1.5 w-full rounded bg-white/10 overflow-hidden">
  421. <div
  422. className={`h-full ${barColorByStatus[job.status]} transition-all duration-300 ${uploadDoneAwaitingPrinter ? 'animate-pulse' : ''}`}
  423. style={{
  424. width: `${
  425. job.status === 'processing' && typeof job.uploadProgressPct === 'number'
  426. ? Math.max(0, Math.min(100, job.uploadProgressPct))
  427. : progressByStatus[job.status]
  428. }%`,
  429. }}
  430. />
  431. </div>
  432. </div>
  433. );
  434. })}
  435. </div>
  436. )}
  437. </>
  438. ) : (
  439. <>
  440. {icons[toast.type]}
  441. <span className="text-white text-sm">{toast.message}</span>
  442. {toast.action && (
  443. <a
  444. href={toast.action.href}
  445. target="_blank"
  446. rel="noopener noreferrer"
  447. onClick={() => {
  448. toast.action?.onClick?.();
  449. dismissToast(toast.id);
  450. }}
  451. className="ml-2 px-2 py-1 rounded text-xs font-medium bg-bambu-green/20 text-bambu-green hover:bg-bambu-green/30 whitespace-nowrap"
  452. >
  453. {toast.action.label}
  454. </a>
  455. )}
  456. <button
  457. onClick={() => dismissToast(toast.id)}
  458. className="ml-2 text-bambu-gray hover:text-white transition-colors"
  459. >
  460. <X className="w-4 h-4" />
  461. </button>
  462. </>
  463. )}
  464. </div>
  465. ))}
  466. </div>
  467. </ToastContext.Provider>
  468. );
  469. }