Toasts: slide in and out, three kinds, pause on hover, fold repeats, cap at three, optional action (#318)

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
2026-10-08 15:02:16 -04:00
co-authored by Claude Sonnet 5.5
parent 33129f9056
commit 2d1b8beb8d
6 changed files with 224 additions and 24 deletions
+1 -1
View File
@@ -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)
}
},
+63 -2
View File
@@ -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 --------------------------------------------- */
+70 -9
View File
@@ -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 (
<div className="toasts">
<div className="toasts" role="status" aria-live="polite">
{toasts.map((toast) => (
<div key={toast.id} className={`toast ${toast.kind}`}>
<Icon name={toast.kind === 'error' ? 'error' : 'info'} size={16} />
<span>{toast.text}</span>
<button type="button" onClick={() => store.dismissToast(toast.id)} title="Dismiss">
<Icon name="close" size={14} />
</button>
</div>
<ToastRow key={toast.id} toast={toast} />
))}
</div>
)
}
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 (
<div
className={`toast ${toast.kind}${leaving ? ' leaving' : ''}`}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
<Icon name={ICONS[toast.kind]} size={16} />
<span className="toast-text">
{toast.text}
{toast.count > 1 && <span className="toast-count">×{toast.count}</span>}
</span>
{toast.action && (
<button
type="button"
className="toast-action"
onClick={() => {
toast.action?.onClick()
leave()
}}
>
{toast.action.label}
</button>
)}
<button type="button" className="toast-close" onClick={leave} title="Dismiss" aria-label="Dismiss">
<Icon name="close" size={14} />
</button>
</div>
)
}
+37
View File
@@ -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)
})
})
+43
View File
@@ -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<ToastItem, 'kind' | 'action' | 'text'>): 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)
}
+10 -12
View File
@@ -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 {