The call-ended toast gets a Details button (and stays 10 s) that opens a card with who was in the call and the peak, connection verdict and reconnects, how long your / each person's / everyone's connection was poor (likely the server when everyone), RTT, packet loss and jitter, and your talk, mute, deafen and screenshare time, in technical wording. Deafened time is tracked by CallEmbed; the rest comes from the fork's new io.lotus.call_summary `details` (lotus.29). Settings → Calls gets "Summary After Calls" (on by default). Fixes #253 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
107 lines
4.1 KiB
TypeScript
107 lines
4.1 KiB
TypeScript
import { atom } from 'jotai';
|
|
import type { IconSrc } from 'folds';
|
|
|
|
export type ToastNotif = {
|
|
id: string;
|
|
avatarUrl?: string;
|
|
iconSrc?: IconSrc; // folds Icon src for a "system" toast (shown instead of an avatar/initials)
|
|
displayName: string;
|
|
body: string;
|
|
roomName: string;
|
|
roomId: string;
|
|
hashPath?: string; // overrides window.location.hash navigation when set
|
|
onClick?: () => void; // custom click handler; skips hash navigation when set
|
|
sticky?: boolean; // when true, does not auto-dismiss — use for action toasts that require a click
|
|
onDismiss?: () => void; // called when the user closes the toast with its X (not on click/eviction)
|
|
actionLabel?: string; // [Gitea #253] a small button on the toast, e.g. "Details"
|
|
onAction?: () => void;
|
|
createdAt?: number; // set by toastQueueAtom when enqueued; used for #80 eviction ordering
|
|
};
|
|
|
|
// 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,
|
|
});
|
|
|
|
// Build an error/system toast (e.g. a failed command or account action). Mirrors
|
|
// createDownloadToast: folds-free (icon src passed in), empty roomId + no-op
|
|
// onClick so a click only dismisses (never navigates to a room).
|
|
export const createErrorToast = (
|
|
body: string,
|
|
iconSrc?: IconSrc,
|
|
displayName = 'Something went wrong',
|
|
): ToastNotif => ({
|
|
id: `error-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
displayName,
|
|
body,
|
|
roomName: '',
|
|
roomId: '',
|
|
iconSrc,
|
|
onClick: () => undefined,
|
|
});
|
|
|
|
const baseAtom = atom<ToastNotif[]>([]);
|
|
|
|
// Cap concurrent toasts so a burst (e.g. many rooms lighting up while focused)
|
|
// can't stack unbounded and cover the viewport.
|
|
const MAX_TOASTS = 5;
|
|
|
|
// #80 — a toast younger than this hasn't had a fair chance to be read yet, so
|
|
// eviction prefers dropping an older, already-seen toast over a fresh one when
|
|
// a burst arrives while several toasts are still on screen.
|
|
const MIN_VISIBLE_MS = 1500;
|
|
|
|
// Pick the index (within [0, excludeLastIndex)) of the toast to evict: prefer
|
|
// the oldest non-sticky toast that's already been visible at least
|
|
// MIN_VISIBLE_MS; if none qualifies yet (e.g. a burst that all arrived within
|
|
// the same beat), fall back to the oldest non-sticky toast regardless of age
|
|
// so the cap is still enforced. Sticky action toasts are never evicted.
|
|
const findEvictionIndex = (toasts: ToastNotif[], excludeLastIndex: number, now: number): number => {
|
|
for (let i = 0; i < excludeLastIndex; i += 1) {
|
|
const t = toasts[i];
|
|
if (!t.sticky && now - (t.createdAt ?? now) >= MIN_VISIBLE_MS) return i;
|
|
}
|
|
for (let i = 0; i < excludeLastIndex; i += 1) {
|
|
if (!toasts[i].sticky) return i;
|
|
}
|
|
return -1;
|
|
};
|
|
|
|
// Write-only setter used in ClientNonUIFeatures
|
|
export const toastQueueAtom = atom<ToastNotif[], [ToastNotif | null], void>(
|
|
(get) => get(baseAtom),
|
|
(get, set, notif) => {
|
|
if (notif === null) return; // no-op guard
|
|
// Stamp in place (not a copy) so the queue keeps holding the exact object
|
|
// the caller passed in — callers/tests may rely on reference equality.
|
|
notif.createdAt = Date.now();
|
|
const now = notif.createdAt;
|
|
const next = [...get(baseAtom), notif];
|
|
// The last index (the just-appended newest toast) is never a candidate, so
|
|
// a fresh toast is never the one dropped — if everything older is sticky
|
|
// (or, now, too young) the cap simply stretches rather than eating the new
|
|
// notice.
|
|
while (next.length > MAX_TOASTS) {
|
|
const idx = findEvictionIndex(next, next.length - 1, now);
|
|
if (idx === -1) break;
|
|
next.splice(idx, 1);
|
|
}
|
|
set(baseAtom, next);
|
|
},
|
|
);
|
|
|
|
export const dismissToastAtom = atom<null, [string], void>(null, (get, set, id) =>
|
|
set(
|
|
baseAtom,
|
|
get(baseAtom).filter((t) => t.id !== id),
|
|
),
|
|
);
|