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 {