diff --git a/scripts/ui-shots.mjs b/scripts/ui-shots.mjs index 0e6f995..f6f186e 100644 --- a/scripts/ui-shots.mjs +++ b/scripts/ui-shots.mjs @@ -321,7 +321,7 @@ const SCENES = [ name: 'toasts', setup: async (p) => { await openChannel(p, '#general') - await p.evaluate(`window.__mohoShots.store.toast('info', 'Copied to the clipboard'), window.__mohoShots.store.toast('error', "Couldn't send: the connection dropped")`) + await p.evaluate(`(() => { const t = window.__mohoShots.store; t.toast('info', 'Copied to the clipboard'); t.toast('success', 'Link copied'); t.toast('error', "Couldn't send: the connection dropped"); t.toast('error', "Couldn't send: the connection dropped"); t.toast('error', "Couldn't send: the connection dropped"); return true })()`) await sleep(300) } }, diff --git a/src/renderer/src/app.css b/src/renderer/src/app.css index fd91db2..a896fb5 100644 --- a/src/renderer/src/app.css +++ b/src/renderer/src/app.css @@ -2756,16 +2756,77 @@ img.composer-emoji { border-radius: var(--corner-radius); background: var(--surface-container-highest); border: 1px solid var(--outline-medium); + border-left-width: 3px; box-shadow: var(--shadow-2); font-size: var(--font-size-small); + animation: toast-in var(--motion) var(--ease); + transition: opacity 180ms ease, transform 180ms ease; +} + +/* The kind is said by the edge and the icon, not by recolouring the words. */ +.toast.info { + border-left-color: var(--primary); +} + +.toast.success { + border-left-color: var(--success); } .toast.error { - color: var(--error); + border-left-color: var(--danger); } -.toast span { +.toast.error > .icon { + color: var(--danger-text); +} + +.toast.success > .icon { + color: var(--success); +} + +.toast.info > .icon { + color: var(--primary); +} + +.toast.leaving { + opacity: 0; + transform: translateX(24px); +} + +.toast-text { flex: 1; + min-width: 0; +} + +.toast-count { + margin-left: var(--spacing-xs); + color: var(--text-muted); + font-variant-numeric: tabular-nums; +} + +.toast-action { + flex: none; + padding: 2px var(--spacing-s); + border-radius: var(--corner-radius-small); + color: var(--primary); + font-weight: var(--weight-semibold); +} + +.toast-action:hover { + background: var(--primary-hover-light); +} + +@keyframes toast-in { + from { + opacity: 0; + transform: translateX(24px); + } +} + +@media (prefers-reduced-motion: reduce) { + .toast { + animation: none; + } } /* --- rich message content --------------------------------------------- */ diff --git a/src/renderer/src/components/Toasts.tsx b/src/renderer/src/components/Toasts.tsx index 945bbef..271df0c 100644 --- a/src/renderer/src/components/Toasts.tsx +++ b/src/renderer/src/components/Toasts.tsx @@ -1,20 +1,81 @@ +import { useEffect, useRef, useState } from 'react' import { Icon } from './Icon' import { useChat, useStore } from '../state/hooks' +import { toastLifetime, type ToastItem } from '../lib/toasts' +const ICONS = { info: 'info', success: 'check_circle', error: 'error' } as const + +/** How long a toast takes to leave, which is what the exit animation lasts. */ +const LEAVING_MS = 180 + +/** + * Things that happened, top right. + * + * They slide in and out, wait while the pointer is on them (a message half + * read is not one to take away), say how many times the same thing happened + * rather than stacking copies, and carry a button where there is something to + * do about it. `role=status` so a screen reader says them as they arrive. + */ export function Toasts(): JSX.Element { - const store = useStore() const toasts = useChat((s) => s.toasts) return ( -
+
{toasts.map((toast) => ( -
- - {toast.text} - -
+ ))}
) } + +function ToastRow({ toast }: { toast: ToastItem }): JSX.Element { + const store = useStore() + const [hovered, setHovered] = useState(false) + const [leaving, setLeaving] = useState(false) + const gone = useRef(false) + + const leave = (): void => { + if (gone.current) return + gone.current = true + setLeaving(true) + setTimeout(() => store.dismissToast(toast.id), LEAVING_MS) + } + + // Its time, started over each time it is said again and held while the + // pointer is on it. + useEffect(() => { + if (hovered || leaving) return + const t = setTimeout(leave, toastLifetime(toast)) + return () => clearTimeout(t) + // `leave` only closes over refs and stable store methods. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [hovered, leaving, toast.stamp]) + + return ( +
setHovered(true)} + onMouseLeave={() => setHovered(false)} + > + + + {toast.text} + {toast.count > 1 && ×{toast.count}} + + {toast.action && ( + + )} + +
+ ) +} diff --git a/src/renderer/src/lib/toasts.test.ts b/src/renderer/src/lib/toasts.test.ts new file mode 100644 index 0000000..205a05b --- /dev/null +++ b/src/renderer/src/lib/toasts.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest' +import { MAX_TOASTS, addToast, toastLifetime, type ToastItem } from './toasts' + +const add = (list: ToastItem[], id: number, text: string, kind: ToastItem['kind'] = 'info', now = 1000): ToastItem[] => + addToast(list, { id, kind, text }, now) + +describe('the toast stack', () => { + it('folds the same words into one toast that says how many', () => { + let list = add([], 1, 'Copied') + list = add(list, 2, 'Copied', 'info', 2000) + list = add(list, 3, 'Copied', 'info', 3000) + expect(list).toHaveLength(1) + expect(list[0].count).toBe(3) + // Repeating it starts its time over. + expect(list[0].stamp).toBe(3000) + }) + + it('keeps the same words of a different kind apart', () => { + const list = add(add([], 1, 'Done'), 2, 'Done', 'error') + expect(list).toHaveLength(2) + }) + + it('never holds more than a few, the oldest giving way', () => { + let list: ToastItem[] = [] + for (let i = 1; i <= MAX_TOASTS + 2; i++) list = add(list, i, `message ${i}`) + expect(list).toHaveLength(MAX_TOASTS) + expect(list.map((t) => t.text)).toEqual(['message 3', 'message 4', 'message 5']) + }) + + it('gives errors and ones with something to press longer to be read', () => { + const quiet = toastLifetime({ kind: 'info', text: 'Copied', action: undefined }) + const error = toastLifetime({ kind: 'error', text: 'Copied', action: undefined }) + const actionable = toastLifetime({ kind: 'info', text: 'Copied', action: { label: 'Undo', onClick: () => {} } }) + expect(error).toBeGreaterThan(quiet) + expect(actionable).toBeGreaterThan(quiet) + }) +}) diff --git a/src/renderer/src/lib/toasts.ts b/src/renderer/src/lib/toasts.ts new file mode 100644 index 0000000..b46b8a3 --- /dev/null +++ b/src/renderer/src/lib/toasts.ts @@ -0,0 +1,43 @@ +/** + * What the toast stack does with a new message. + * + * Kept apart from the store so the two rules can be tested without one: the + * same words saying the same thing again are one toast that says how many + * times, not a column of copies; and the column never holds more than a few, + * the oldest giving way - a burst of errors must not paper over the window. + */ +export interface ToastAction { + label: string + onClick: () => void +} + +export interface ToastItem { + id: number + kind: 'info' | 'success' | 'error' + text: string + /** How many times this has been said while it was showing. */ + count: number + /** When it was last said, so repeating it starts its time over. */ + stamp: number + action?: ToastAction +} + +export const MAX_TOASTS = 3 + +export function addToast( + list: ToastItem[], + next: { id: number; kind: ToastItem['kind']; text: string; action?: ToastAction }, + now = Date.now() +): ToastItem[] { + const same = list.find((t) => t.kind === next.kind && t.text === next.text) + if (same) { + return list.map((t) => (t === same ? { ...t, count: t.count + 1, stamp: now, action: next.action ?? t.action } : t)) + } + return [...list, { ...next, count: 1, stamp: now }].slice(-MAX_TOASTS) +} + +/** How long a toast stays: errors and anything with an action are given longer to be read. */ +export function toastLifetime(t: Pick): number { + const reading = Math.min(8000, 3000 + t.text.length * 40) + return t.kind === 'error' || t.action ? Math.max(8000, reading) : Math.max(5000, reading) +} diff --git a/src/renderer/src/state/store.ts b/src/renderer/src/state/store.ts index 97fa95c..23a9751 100644 --- a/src/renderer/src/state/store.ts +++ b/src/renderer/src/state/store.ts @@ -21,6 +21,7 @@ import type { VoicePrefs, VoiceSession } from '../../../shared/wire' +import { addToast, type ToastAction, type ToastItem } from '../lib/toasts' import { buildSmilieIndex, type SmilieEntry, type SmilieIndex } from '../lib/format' import { bufferDisplayName, isChatKind, isImageFile, resolveMediaUrl } from '../lib/util' import { runExport } from '../lib/exporter' @@ -674,11 +675,7 @@ export interface ChatState { matrixLoginStatus: string } -export interface Toast { - id: number - kind: 'info' | 'error' - text: string -} +export type Toast = ToastItem const INITIAL: ChatState = { newRelease: null, @@ -3960,7 +3957,7 @@ export class ChatStore { try { const { url } = await window.moho.rpc<{ url: string }>('getDiscordMessageLink', { bufferId, messageId }) void window.moho.copyText(url) - this.toast('info', 'Link copied') + this.toast('success', 'Link copied') } catch (e) { this.toast('error', (e as Error).message) } @@ -4757,12 +4754,13 @@ export class ChatStore { }) } - toast(kind: 'info' | 'error', text: string): void { - const id = ++this.toastSeq - this.set({ toasts: [...this.state.toasts, { id, kind, text }] }) - setTimeout(() => { - this.set({ toasts: this.state.toasts.filter((t) => t.id !== id) }) - }, 6000) + /** + * Says something happened. How long it stays, and going away when the + * pointer is not on it, is the stack's own business (components/Toasts.tsx): + * a toast that vanished while being read was the old behaviour. + */ + toast(kind: 'info' | 'success' | 'error', text: string, action?: ToastAction): void { + this.set({ toasts: addToast(this.state.toasts, { id: ++this.toastSeq, kind, text, action }) }) } dismissToast(id: number): void {