feat(download): show a toast + button check when a file is saved
The desktop (Tauri) app has no native download UI, so FileSaver.saveAs saved files silently — no visual or audio confirmation. Users re-clicked because nothing said it worked (one report: 5 copies of the same file). Add a small useSaveFile() hook that saves AND raises a 'Downloaded <filename>' toast, and route every download call site through it (file attachments, image viewer, PDF viewer, plus the recovery-key / key-backup exports). The file-message download button also shows a green check on success. Toast system extended with an optional iconSrc so system toasts render an icon instead of an avatar/initials, and an empty roomName is no longer rendered. Tests: createDownloadToast covered; 701/701 pass; typecheck + build clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -13,9 +13,9 @@ import {
|
|||||||
color,
|
color,
|
||||||
Spinner,
|
Spinner,
|
||||||
} from 'folds';
|
} from 'folds';
|
||||||
import FileSaver from 'file-saver';
|
|
||||||
import to from 'await-to-js';
|
import to from 'await-to-js';
|
||||||
import { AuthDict, IAuthData, MatrixError, UIAuthCallback } from 'matrix-js-sdk';
|
import { AuthDict, IAuthData, MatrixError, UIAuthCallback } from 'matrix-js-sdk';
|
||||||
|
import { useSaveFile } from '../hooks/useSaveFile';
|
||||||
import { useModalStyle } from '../hooks/useModalStyle';
|
import { useModalStyle } from '../hooks/useModalStyle';
|
||||||
import { PasswordInput } from './password-input';
|
import { PasswordInput } from './password-input';
|
||||||
import { ContainerColor } from '../styles/ContainerColor.css';
|
import { ContainerColor } from '../styles/ContainerColor.css';
|
||||||
@@ -230,6 +230,7 @@ type RecoveryKeyDisplayProps = {
|
|||||||
};
|
};
|
||||||
function RecoveryKeyDisplay({ recoveryKey }: RecoveryKeyDisplayProps) {
|
function RecoveryKeyDisplay({ recoveryKey }: RecoveryKeyDisplayProps) {
|
||||||
const [show, setShow] = useState(false);
|
const [show, setShow] = useState(false);
|
||||||
|
const saveFile = useSaveFile();
|
||||||
|
|
||||||
const handleCopy = () => {
|
const handleCopy = () => {
|
||||||
copyToClipboard(recoveryKey);
|
copyToClipboard(recoveryKey);
|
||||||
@@ -239,7 +240,7 @@ function RecoveryKeyDisplay({ recoveryKey }: RecoveryKeyDisplayProps) {
|
|||||||
const blob = new Blob([recoveryKey], {
|
const blob = new Blob([recoveryKey], {
|
||||||
type: 'text/plain;charset=us-ascii',
|
type: 'text/plain;charset=us-ascii',
|
||||||
});
|
});
|
||||||
FileSaver.saveAs(blob, 'recovery-key.txt');
|
saveFile(blob, 'recovery-key.txt');
|
||||||
};
|
};
|
||||||
|
|
||||||
const safeToDisplayKey = show ? recoveryKey : recoveryKey.replace(/[^\s]/g, '*');
|
const safeToDisplayKey = show ? recoveryKey : recoveryKey.replace(/[^\s]/g, '*');
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import {
|
|||||||
config,
|
config,
|
||||||
} from 'folds';
|
} from 'folds';
|
||||||
import FocusTrap from 'focus-trap-react';
|
import FocusTrap from 'focus-trap-react';
|
||||||
import FileSaver from 'file-saver';
|
import { useSaveFile } from '../../hooks/useSaveFile';
|
||||||
import * as css from './PdfViewer.css';
|
import * as css from './PdfViewer.css';
|
||||||
import { AsyncStatus } from '../../hooks/useAsyncCallback';
|
import { AsyncStatus } from '../../hooks/useAsyncCallback';
|
||||||
import { useZoom } from '../../hooks/useZoom';
|
import { useZoom } from '../../hooks/useZoom';
|
||||||
@@ -36,6 +36,7 @@ export const PdfViewer = as<'div', PdfViewerProps>(
|
|||||||
({ className, name, src, requestClose, ...props }, ref) => {
|
({ className, name, src, requestClose, ...props }, ref) => {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const saveFile = useSaveFile();
|
||||||
const { zoom, zoomIn, zoomOut, setZoom } = useZoom(0.2);
|
const { zoom, zoomIn, zoomOut, setZoom } = useZoom(0.2);
|
||||||
|
|
||||||
const [pdfJSState, loadPdfJS] = usePdfJSLoader();
|
const [pdfJSState, loadPdfJS] = usePdfJSLoader();
|
||||||
@@ -76,7 +77,7 @@ export const PdfViewer = as<'div', PdfViewerProps>(
|
|||||||
}, [docState, pageNo, zoom]);
|
}, [docState, pageNo, zoom]);
|
||||||
|
|
||||||
const handleDownload = () => {
|
const handleDownload = () => {
|
||||||
FileSaver.saveAs(src, name);
|
saveFile(src, name);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleJumpSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
|
const handleJumpSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import FileSaver from 'file-saver';
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { Box, Chip, Header, Icon, IconButton, Icons, Text, as } from 'folds';
|
import { Box, Chip, Header, Icon, IconButton, Icons, Text, as } from 'folds';
|
||||||
|
import { useSaveFile } from '../../hooks/useSaveFile';
|
||||||
import * as css from './ImageViewer.css';
|
import * as css from './ImageViewer.css';
|
||||||
import { useZoom } from '../../hooks/useZoom';
|
import { useZoom } from '../../hooks/useZoom';
|
||||||
import { usePan } from '../../hooks/usePan';
|
import { usePan } from '../../hooks/usePan';
|
||||||
@@ -17,12 +17,13 @@ export type ImageViewerProps = {
|
|||||||
export const ImageViewer = as<'div', ImageViewerProps>(
|
export const ImageViewer = as<'div', ImageViewerProps>(
|
||||||
({ className, alt, src, requestClose, ...props }, ref) => {
|
({ className, alt, src, requestClose, ...props }, ref) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const saveFile = useSaveFile();
|
||||||
const { zoom, zoomIn, zoomOut, setZoom } = useZoom(0.2);
|
const { zoom, zoomIn, zoomOut, setZoom } = useZoom(0.2);
|
||||||
const { pan, cursor, onMouseDown } = usePan(zoom !== 1);
|
const { pan, cursor, onMouseDown } = usePan(zoom !== 1);
|
||||||
|
|
||||||
const handleDownload = async () => {
|
const handleDownload = async () => {
|
||||||
const fileContent = await downloadMedia(src);
|
const fileContent = await downloadMedia(src);
|
||||||
FileSaver.saveAs(fileContent, alt);
|
saveFile(fileContent, alt);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { Badge, Box, Icon, IconButton, Icons, Spinner, Text, as, toRem } from 'folds';
|
import { Badge, Box, Icon, IconButton, Icons, Spinner, Text, as, toRem } from 'folds';
|
||||||
import React, { ReactNode, useCallback } from 'react';
|
import React, { ReactNode, useCallback } from 'react';
|
||||||
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
|
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
|
||||||
import FileSaver from 'file-saver';
|
|
||||||
import { mimeTypeToExt } from '../../utils/mimeTypes';
|
import { mimeTypeToExt } from '../../utils/mimeTypes';
|
||||||
|
import { useSaveFile } from '../../hooks/useSaveFile';
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||||
@@ -24,6 +24,7 @@ type FileDownloadButtonProps = {
|
|||||||
export function FileDownloadButton({ filename, url, mimeType, encInfo }: FileDownloadButtonProps) {
|
export function FileDownloadButton({ filename, url, mimeType, encInfo }: FileDownloadButtonProps) {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const useAuthentication = useMediaAuthentication();
|
const useAuthentication = useMediaAuthentication();
|
||||||
|
const saveFile = useSaveFile();
|
||||||
|
|
||||||
const [downloadState, download] = useAsyncCallback(
|
const [downloadState, download] = useAsyncCallback(
|
||||||
useCallback(async () => {
|
useCallback(async () => {
|
||||||
@@ -34,18 +35,19 @@ export function FileDownloadButton({ filename, url, mimeType, encInfo }: FileDow
|
|||||||
: await downloadMedia(mediaUrl);
|
: await downloadMedia(mediaUrl);
|
||||||
|
|
||||||
const fileURL = URL.createObjectURL(fileContent);
|
const fileURL = URL.createObjectURL(fileContent);
|
||||||
FileSaver.saveAs(fileURL, filename);
|
saveFile(fileURL, filename);
|
||||||
return fileURL;
|
return fileURL;
|
||||||
}, [mx, url, useAuthentication, mimeType, encInfo, filename]),
|
}, [mx, url, useAuthentication, mimeType, encInfo, filename, saveFile]),
|
||||||
);
|
);
|
||||||
|
|
||||||
const downloading = downloadState.status === AsyncStatus.Loading;
|
const downloading = downloadState.status === AsyncStatus.Loading;
|
||||||
const hasError = downloadState.status === AsyncStatus.Error;
|
const hasError = downloadState.status === AsyncStatus.Error;
|
||||||
|
const succeeded = downloadState.status === AsyncStatus.Success;
|
||||||
return (
|
return (
|
||||||
<IconButton
|
<IconButton
|
||||||
disabled={downloading}
|
disabled={downloading}
|
||||||
onClick={download}
|
onClick={download}
|
||||||
variant={hasError ? 'Critical' : 'SurfaceVariant'}
|
variant={hasError ? 'Critical' : succeeded ? 'Success' : 'SurfaceVariant'}
|
||||||
size="300"
|
size="300"
|
||||||
radii="300"
|
radii="300"
|
||||||
aria-label={
|
aria-label={
|
||||||
@@ -53,13 +55,15 @@ export function FileDownloadButton({ filename, url, mimeType, encInfo }: FileDow
|
|||||||
? 'Downloading...'
|
? 'Downloading...'
|
||||||
: hasError
|
: hasError
|
||||||
? 'Download failed, click to retry'
|
? 'Download failed, click to retry'
|
||||||
|
: succeeded
|
||||||
|
? 'Downloaded — click to download again'
|
||||||
: 'Download file'
|
: 'Download file'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{downloading ? (
|
{downloading ? (
|
||||||
<Spinner size="100" variant={hasError ? 'Critical' : 'Secondary'} />
|
<Spinner size="100" variant={hasError ? 'Critical' : 'Secondary'} />
|
||||||
) : (
|
) : (
|
||||||
<Icon size="100" src={Icons.Download} />
|
<Icon size="100" src={succeeded ? Icons.Check : Icons.Download} />
|
||||||
)}
|
)}
|
||||||
</IconButton>
|
</IconButton>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -14,10 +14,10 @@ import {
|
|||||||
TooltipProvider,
|
TooltipProvider,
|
||||||
as,
|
as,
|
||||||
} from 'folds';
|
} from 'folds';
|
||||||
import FileSaver from 'file-saver';
|
|
||||||
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
|
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
|
||||||
import FocusTrap from 'focus-trap-react';
|
import FocusTrap from 'focus-trap-react';
|
||||||
import { IFileInfo } from '../../../../types/matrix/common';
|
import { IFileInfo } from '../../../../types/matrix/common';
|
||||||
|
import { useSaveFile } from '../../../hooks/useSaveFile';
|
||||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
import { bytesToSize } from '../../../utils/common';
|
import { bytesToSize } from '../../../utils/common';
|
||||||
@@ -252,6 +252,7 @@ export type DownloadFileProps = {
|
|||||||
export function DownloadFile({ body, mimeType, url, info, encInfo }: DownloadFileProps) {
|
export function DownloadFile({ body, mimeType, url, info, encInfo }: DownloadFileProps) {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const useAuthentication = useMediaAuthentication();
|
const useAuthentication = useMediaAuthentication();
|
||||||
|
const saveFile = useSaveFile();
|
||||||
|
|
||||||
const [downloadState, download] = useAsyncCallback(
|
const [downloadState, download] = useAsyncCallback(
|
||||||
useCallback(async () => {
|
useCallback(async () => {
|
||||||
@@ -262,9 +263,9 @@ export function DownloadFile({ body, mimeType, url, info, encInfo }: DownloadFil
|
|||||||
: await downloadMedia(mediaUrl);
|
: await downloadMedia(mediaUrl);
|
||||||
|
|
||||||
const fileURL = URL.createObjectURL(fileContent);
|
const fileURL = URL.createObjectURL(fileContent);
|
||||||
FileSaver.saveAs(fileURL, body);
|
saveFile(fileURL, body);
|
||||||
return fileURL;
|
return fileURL;
|
||||||
}, [mx, url, useAuthentication, mimeType, encInfo, body]),
|
}, [mx, url, useAuthentication, mimeType, encInfo, body, saveFile]),
|
||||||
);
|
);
|
||||||
|
|
||||||
return downloadState.status === AsyncStatus.Error ? (
|
return downloadState.status === AsyncStatus.Error ? (
|
||||||
@@ -277,7 +278,7 @@ export function DownloadFile({ body, mimeType, url, info, encInfo }: DownloadFil
|
|||||||
size="400"
|
size="400"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
downloadState.status === AsyncStatus.Success
|
downloadState.status === AsyncStatus.Success
|
||||||
? FileSaver.saveAs(downloadState.data, body)
|
? saveFile(downloadState.data, body)
|
||||||
: download()
|
: download()
|
||||||
}
|
}
|
||||||
disabled={downloadState.status === AsyncStatus.Loading}
|
disabled={downloadState.status === AsyncStatus.Loading}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { FormEventHandler, useCallback, useEffect, useState } from 'react';
|
import React, { FormEventHandler, useCallback, useEffect, useState } from 'react';
|
||||||
import { Box, Button, color, Icon, Icons, Spinner, Text, toRem } from 'folds';
|
import { Box, Button, color, Icon, Icons, Spinner, Text, toRem } from 'folds';
|
||||||
import FileSaver from 'file-saver';
|
import { useSaveFile } from '../../../hooks/useSaveFile';
|
||||||
import { SequenceCard } from '../../../components/sequence-card';
|
import { SequenceCard } from '../../../components/sequence-card';
|
||||||
import { SettingTile } from '../../../components/setting-tile';
|
import { SettingTile } from '../../../components/setting-tile';
|
||||||
import { SequenceCardStyle } from '../styles.css';
|
import { SequenceCardStyle } from '../styles.css';
|
||||||
@@ -15,6 +15,7 @@ import { useFilePicker } from '../../../hooks/useFilePicker';
|
|||||||
function ExportKeys() {
|
function ExportKeys() {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const alive = useAlive();
|
const alive = useAlive();
|
||||||
|
const saveFile = useSaveFile();
|
||||||
|
|
||||||
const [exportState, exportKeys] = useAsyncCallback<void, Error, [string]>(
|
const [exportState, exportKeys] = useAsyncCallback<void, Error, [string]>(
|
||||||
useCallback(
|
useCallback(
|
||||||
@@ -28,9 +29,9 @@ function ExportKeys() {
|
|||||||
const blob = new Blob([encKeys], {
|
const blob = new Blob([encKeys], {
|
||||||
type: 'text/plain;charset=us-ascii',
|
type: 'text/plain;charset=us-ascii',
|
||||||
});
|
});
|
||||||
FileSaver.saveAs(blob, 'lotus-keys.txt');
|
saveFile(blob, 'lotus-keys.txt');
|
||||||
},
|
},
|
||||||
[mx],
|
[mx, saveFile],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -171,7 +171,11 @@ function ToastCard({ toast }: ToastCardProps) {
|
|||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter' || e.key === ' ') handleCardClick();
|
if (e.key === 'Enter' || e.key === ' ') handleCardClick();
|
||||||
}}
|
}}
|
||||||
aria-label={`Notification from ${toast.displayName} in ${toast.roomName}`}
|
aria-label={
|
||||||
|
toast.roomName
|
||||||
|
? `Notification from ${toast.displayName} in ${toast.roomName}`
|
||||||
|
: `${toast.displayName}: ${toast.body}`
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<span style={{ position: 'absolute', top: config.space.S100, right: config.space.S100 }}>
|
<span style={{ position: 'absolute', top: config.space.S100, right: config.space.S100 }}>
|
||||||
<IconButton
|
<IconButton
|
||||||
@@ -187,7 +191,11 @@ function ToastCard({ toast }: ToastCardProps) {
|
|||||||
</IconButton>
|
</IconButton>
|
||||||
</span>
|
</span>
|
||||||
<div style={rowStyle}>
|
<div style={rowStyle}>
|
||||||
{toast.avatarUrl ? (
|
{toast.iconSrc ? (
|
||||||
|
<div style={initialsStyle} aria-hidden="true">
|
||||||
|
<Icon size="100" src={toast.iconSrc} />
|
||||||
|
</div>
|
||||||
|
) : toast.avatarUrl ? (
|
||||||
<img src={toast.avatarUrl} alt="" style={avatarStyle} aria-hidden="true" />
|
<img src={toast.avatarUrl} alt="" style={avatarStyle} aria-hidden="true" />
|
||||||
) : (
|
) : (
|
||||||
<div style={initialsStyle} aria-hidden="true">
|
<div style={initialsStyle} aria-hidden="true">
|
||||||
@@ -197,7 +205,7 @@ function ToastCard({ toast }: ToastCardProps) {
|
|||||||
<span style={nameStyle}>{toast.displayName}</span>
|
<span style={nameStyle}>{toast.displayName}</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={bodyStyle}>{toast.body}</div>
|
<div style={bodyStyle}>{toast.body}</div>
|
||||||
<div style={roomNameStyle}>{toast.roomName}</div>
|
{toast.roomName && <div style={roomNameStyle}>{toast.roomName}</div>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { useCallback } from 'react';
|
||||||
|
import { useSetAtom } from 'jotai';
|
||||||
|
import { Icons } from 'folds';
|
||||||
|
import FileSaver from 'file-saver';
|
||||||
|
import { createDownloadToast, toastQueueAtom } from '../state/toast';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save a blob/URL to disk AND surface a "Downloaded <filename>" toast.
|
||||||
|
*
|
||||||
|
* The desktop (Tauri) app has no native download UI, so `FileSaver.saveAs` saved
|
||||||
|
* files silently — users re-clicked because nothing confirmed success. This gives
|
||||||
|
* uniform, visible feedback across web + desktop for every download call site.
|
||||||
|
*/
|
||||||
|
export const useSaveFile = () => {
|
||||||
|
const setToast = useSetAtom(toastQueueAtom);
|
||||||
|
|
||||||
|
return useCallback(
|
||||||
|
(data: Blob | string, filename: string) => {
|
||||||
|
FileSaver.saveAs(data, filename);
|
||||||
|
setToast(createDownloadToast(filename, Icons.Check));
|
||||||
|
},
|
||||||
|
[setToast],
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { createStore } from 'jotai';
|
import { createStore } from 'jotai';
|
||||||
import { toastQueueAtom, dismissToastAtom, ToastNotif } from './toast';
|
import { toastQueueAtom, dismissToastAtom, ToastNotif, createDownloadToast } from './toast';
|
||||||
|
|
||||||
// The queue lives in an unexported baseAtom; we drive the two write-only setters
|
// The queue lives in an unexported baseAtom; we drive the two write-only setters
|
||||||
// (toastQueueAtom append + null no-op guard, dismissToastAtom remove-by-id)
|
// (toastQueueAtom append + null no-op guard, dismissToastAtom remove-by-id)
|
||||||
@@ -85,3 +85,15 @@ test('dismissToastAtom for an unknown id is a no-op', () => {
|
|||||||
['a'],
|
['a'],
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('createDownloadToast: filename in body, no room navigation, unique ids', () => {
|
||||||
|
const a = createDownloadToast('photo.jpg');
|
||||||
|
assert.equal(a.displayName, 'Downloaded');
|
||||||
|
assert.equal(a.body, 'photo.jpg');
|
||||||
|
// roomId empty + an onClick present → clicking dismisses without navigating to a room.
|
||||||
|
assert.equal(a.roomId, '');
|
||||||
|
assert.equal(a.roomName, '');
|
||||||
|
assert.equal(typeof a.onClick, 'function');
|
||||||
|
const b = createDownloadToast('photo.jpg');
|
||||||
|
assert.notEqual(a.id, b.id);
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { atom } from 'jotai';
|
import { atom } from 'jotai';
|
||||||
|
import type { IconSrc } from 'folds';
|
||||||
|
|
||||||
export type ToastNotif = {
|
export type ToastNotif = {
|
||||||
id: string;
|
id: string;
|
||||||
avatarUrl?: string;
|
avatarUrl?: string;
|
||||||
|
iconSrc?: IconSrc; // folds Icon src for a "system" toast (shown instead of an avatar/initials)
|
||||||
displayName: string;
|
displayName: string;
|
||||||
body: string;
|
body: string;
|
||||||
roomName: string;
|
roomName: string;
|
||||||
@@ -12,6 +14,19 @@ export type ToastNotif = {
|
|||||||
sticky?: boolean; // when true, does not auto-dismiss — use for action toasts that require a click
|
sticky?: boolean; // when true, does not auto-dismiss — use for action toasts that require a click
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Build a "download complete" system toast. Kept folds-free here (the icon src is
|
||||||
|
// passed in) so this stays a pure, testable builder. roomId is empty + onClick is
|
||||||
|
// set so a click only dismisses (never navigates to a room).
|
||||||
|
export const createDownloadToast = (filename: string, iconSrc?: IconSrc): ToastNotif => ({
|
||||||
|
id: `download-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||||
|
displayName: 'Downloaded',
|
||||||
|
body: filename,
|
||||||
|
roomName: '',
|
||||||
|
roomId: '',
|
||||||
|
iconSrc,
|
||||||
|
onClick: () => undefined,
|
||||||
|
});
|
||||||
|
|
||||||
const baseAtom = atom<ToastNotif[]>([]);
|
const baseAtom = atom<ToastNotif[]>([]);
|
||||||
|
|
||||||
// Write-only setter used in ClientNonUIFeatures
|
// Write-only setter used in ClientNonUIFeatures
|
||||||
|
|||||||
Reference in New Issue
Block a user