{
if (toast.sticky) return;
- timerRef.current = setTimeout(() => {
- dismiss(toast.id);
- }, 4000);
+ timerRef.current = setTimeout(
+ () => {
+ dismiss(toast.id);
+ },
+ // [Gitea #253] A toast with a button stays long enough to use it.
+ toast.actionLabel ? 10_000 : 4000,
+ );
return () => {
if (timerRef.current !== null) clearTimeout(timerRef.current);
};
- }, [dismiss, toast.id, toast.sticky]);
+ }, [dismiss, toast.id, toast.sticky, toast.actionLabel]);
const handleCardClick = () => {
if (toast.onClick) {
@@ -212,6 +216,24 @@ function ToastCard({ toast }: ToastCardProps) {
{toast.body}
{toast.roomName && {toast.roomName}
}
+ {toast.actionLabel && toast.onAction && (
+
+
+
+ )}
);
}
diff --git a/src/app/hooks/useCallEndedToast.ts b/src/app/hooks/useCallEndedToast.ts
index 62f1b710e..5973ecfd9 100644
--- a/src/app/hooks/useCallEndedToast.ts
+++ b/src/app/hooks/useCallEndedToast.ts
@@ -3,7 +3,10 @@ import { useSetAtom } from 'jotai';
import { CallEmbed } from '../plugins/call';
import { useCallHangupEvent } from './useCallEmbed';
import { toastQueueAtom } from '../state/toast';
-import { describeCallEnd } from '../utils/callSummary';
+import { describeCallDetails, describeCallEnd } from '../utils/callSummary';
+import { callSummaryViewAtom } from '../state/callSummary';
+import { useSetting } from '../state/hooks/settings';
+import { settingsAtom } from '../state/settings';
// The fork sends io.lotus.call_summary on SFU disconnect, which normally lands
// before Element Call's hangup echo; give a late one this long before toasting.
@@ -16,6 +19,8 @@ const SUMMARY_GRACE_MS = 400;
*/
export function useCallEndedToast(embed: CallEmbed): void {
const setToast = useSetAtom(toastQueueAtom);
+ const setSummaryView = useSetAtom(callSummaryViewAtom);
+ const [callSummaryToast] = useSetting(settingsAtom, 'callSummaryToast');
const fired = useRef(false);
useEffect(() => {
@@ -25,14 +30,25 @@ export function useCallEndedToast(embed: CallEmbed): void {
useCallHangupEvent(embed, () => {
if (fired.current || !embed.joined || embed.joinedAt === undefined) return;
fired.current = true;
+ if (!callSummaryToast) return;
const durationMs = Date.now() - embed.joinedAt;
+ const deafenedMs = embed.deafenedMs();
+ const roomName = embed.room.name ?? 'Voice call';
+ const nameOf = (userId: string) => embed.room.getMember(userId)?.name ?? userId;
const toast = () =>
setToast({
id: `call-ended-${Date.now()}`,
displayName: 'Lotus Chat',
body: describeCallEnd(durationMs, embed.lastSummary),
- roomName: embed.room.name ?? 'Voice call',
+ roomName,
roomId: embed.roomId,
+ // [Gitea #253] The full breakdown, snapshotted at hangup.
+ actionLabel: 'Details',
+ onAction: () =>
+ setSummaryView({
+ roomName,
+ sections: describeCallDetails(embed.lastSummary, durationMs, deafenedMs, nameOf),
+ }),
});
if (embed.lastSummary) toast();
else setTimeout(toast, SUMMARY_GRACE_MS);
diff --git a/src/app/pages/App.tsx b/src/app/pages/App.tsx
index 9c9488996..c005066b3 100644
--- a/src/app/pages/App.tsx
+++ b/src/app/pages/App.tsx
@@ -25,6 +25,7 @@ import { useCompositionEndTracking } from '../hooks/useComposingCheck';
import { settingsAtom } from '../state/settings';
import { isWithinTimeWindow } from '../utils/timeWindow';
import { LotusToastContainer } from '../features/toast/LotusToastContainer';
+import { CallSummaryDialog } from '../features/call-summary/CallSummaryDialog';
import { useTauriNotificationBadge } from '../hooks/useTauriNotificationBadge';
import { useSessionAppeared } from '../hooks/useSessionAppeared';
import { useTauriWindowChrome } from '../hooks/useTauriWindowChrome';
@@ -248,6 +249,7 @@ function App() {
+
diff --git a/src/app/plugins/call/CallEmbed.ts b/src/app/plugins/call/CallEmbed.ts
index 0ca525df0..7eb023032 100644
--- a/src/app/plugins/call/CallEmbed.ts
+++ b/src/app/plugins/call/CallEmbed.ts
@@ -26,13 +26,14 @@ import {
ElementMediaStateDetail,
ElementWidgetActions,
} from './types';
-import { CallControl } from './CallControl';
+import { CallControl, CallControlEvent } from './CallControl';
import { CallControlState } from './CallControlState';
import { verifyDenoiseAssets } from './denoiseSmokeCheck';
import { canDelegateCapability } from './utils';
import { getCallPageUrl } from './callPageUrl';
import { restrictWidgetMessages } from '../widgetTransport';
import { isRingableRoom } from '../../utils/callRing';
+import { parseCallSummaryDetails, type LotusCallSummaryDetails } from '../../utils/callSummary';
// Maximum time to wait for the embedded Element Call iframe to progress from
// initial load to a ready/joined state. If it hasn't by then, we assume the
@@ -59,6 +60,8 @@ export interface LotusCallSummary {
reconnects: number;
poorMs: number;
verdict: 'good' | 'fair' | 'poor' | 'unknown';
+ /** [Gitea #253] Breakdown for the Details card (fork lotus.29+). */
+ details?: LotusCallSummaryDetails;
}
export class CallEmbed {
@@ -78,6 +81,25 @@ export class CallEmbed {
/** [Gitea #143] The fork's end-of-call summary, once it arrives. */
public lastSummary: LotusCallSummary | undefined;
+ // [Gitea #253] Time spent deafened (sound off), for the post-call Details.
+ private deafenedSince: number | undefined;
+
+ private deafenedTotal = 0;
+
+ public deafenedMs(now = Date.now()): number {
+ return this.deafenedTotal + (this.deafenedSince === undefined ? 0 : now - this.deafenedSince);
+ }
+
+ private trackDeafened = (): void => {
+ if (!this.joined) return;
+ const deaf = !this.control.sound;
+ if (deaf && this.deafenedSince === undefined) this.deafenedSince = Date.now();
+ if (!deaf && this.deafenedSince !== undefined) {
+ this.deafenedTotal += Date.now() - this.deafenedSince;
+ this.deafenedSince = undefined;
+ }
+ };
+
// C-M4: set once dispose() has run so the hangup fallback timer can tell
// whether the embed was already torn down by the normal Close/Hangup echo.
public disposed = false;
@@ -339,6 +361,8 @@ export class CallEmbed {
const controlState = initialControlState ?? new CallControlState(true, false, true);
this.control = new CallControl(controlState, call, iframe);
this.initialState = controlState;
+ this.control.on(CallControlEvent.StateUpdate, this.trackDeafened);
+ this.disposables.push(() => this.control.off(CallControlEvent.StateUpdate, this.trackDeafened));
// If the iframe document itself fails to load, fail fast.
iframe.onerror = () => {
this.settleLoad('iframe');
@@ -434,6 +458,7 @@ export class CallEmbed {
reconnects: data.reconnects ?? 0,
poorMs: data.poorMs ?? 0,
verdict: data.verdict ?? 'unknown',
+ details: parseCallSummaryDetails((data as { details?: unknown }).details),
};
}
}),
@@ -623,6 +648,7 @@ export class CallEmbed {
}
this.joined = true;
this.joinedAt = Date.now();
+ this.trackDeafened();
// EC ignores io.element.device_mute before join; re-apply desired state now that EC is live
this.control.forceState(this.initialState);
}
diff --git a/src/app/state/callSummary.ts b/src/app/state/callSummary.ts
new file mode 100644
index 000000000..c7be36a7f
--- /dev/null
+++ b/src/app/state/callSummary.ts
@@ -0,0 +1,6 @@
+import { atom } from 'jotai';
+import type { CallSummarySection } from '../utils/callSummary';
+
+/** [Gitea #253] The post-call Details card currently open (undefined = closed). */
+export type CallSummaryView = { roomName: string; sections: CallSummarySection[] };
+export const callSummaryViewAtom = atom(undefined);
diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts
index 575be7fae..6f6631140 100644
--- a/src/app/state/settings.ts
+++ b/src/app/state/settings.ts
@@ -310,6 +310,8 @@ export interface Settings {
screenshareFramerate: ScreenshareFramerate;
// [P5-15] In-call soundboard
soundboardEnabled: boolean;
+ // [Gitea #253] The call-ended toast (with its Details card).
+ callSummaryToast: boolean;
soundboardVolume: number; // 0–100
seasonalThemeOverride: SeasonalThemeOverride;
@@ -429,6 +431,7 @@ const defaultSettings: Settings = {
screenshareBitrate: 'auto',
screenshareFramerate: 'auto',
soundboardEnabled: true,
+ callSummaryToast: true,
soundboardVolume: 80,
seasonalThemeOverride: 'auto',
diff --git a/src/app/state/toast.ts b/src/app/state/toast.ts
index 4a228d03c..2d8bb2f62 100644
--- a/src/app/state/toast.ts
+++ b/src/app/state/toast.ts
@@ -13,6 +13,8 @@ export type ToastNotif = {
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
};
diff --git a/src/app/utils/callSummary.test.ts b/src/app/utils/callSummary.test.ts
index c01783c96..33652a47a 100644
--- a/src/app/utils/callSummary.test.ts
+++ b/src/app/utils/callSummary.test.ts
@@ -1,6 +1,11 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
-import { describeCallDuration, describeCallEnd } from './callSummary';
+import {
+ describeCallDuration,
+ describeCallEnd,
+ describeCallDetails,
+ parseCallSummaryDetails,
+} from './callSummary';
describe('describeCallDuration', () => {
it('picks the unit', () => {
@@ -65,3 +70,97 @@ describe('describeCallEnd', () => {
);
});
});
+
+it('[#253] parseCallSummaryDetails keeps well-formed fields and drops the rest', () => {
+ const d = parseCallSummaryDetails({
+ participants: ['@bob:x', 7, '@carol:x'],
+ peakParticipants: 3,
+ quality: {
+ youPoorMs: 1000,
+ othersPoorMs: { '@bob:x': 500, evil: 9, '@c:x': -1 },
+ everyonePoorMs: 0,
+ },
+ network: { rttMedianMs: 38, rttP95Ms: 'fast', inboundLossPct: 0.5 },
+ activity: { speakingMs: 60_000, mutedMs: 'x', screenshareMs: 0 },
+ });
+ assert.deepEqual(d, {
+ participants: ['@bob:x', '@carol:x'],
+ peakParticipants: 3,
+ quality: { youPoorMs: 1000, othersPoorMs: { '@bob:x': 500 }, everyonePoorMs: 0 },
+ network: { rttMedianMs: 38, inboundLossPct: 0.5 },
+ activity: { speakingMs: 60_000, mutedMs: 0, screenshareMs: 0 },
+ });
+ assert.equal(parseCallSummaryDetails(undefined), undefined);
+ assert.equal(parseCallSummaryDetails('details'), undefined);
+});
+
+it('[#253] describeCallDetails: technical sections with names, blame and numbers', () => {
+ const names: Record = { '@bob:x': 'Bob', '@carol:x': 'Carol' };
+ const sections = describeCallDetails(
+ {
+ durationMs: 2_460_000,
+ reconnects: 2,
+ poorMs: 120_000,
+ verdict: 'fair',
+ details: {
+ participants: ['@bob:x', '@carol:x'],
+ peakParticipants: 3,
+ quality: {
+ youPoorMs: 120_000,
+ othersPoorMs: { '@carol:x': 30_000 },
+ everyonePoorMs: 10_000,
+ },
+ network: {
+ rttMedianMs: 38,
+ rttP95Ms: 310,
+ outboundLossPct: 4.1,
+ inboundLossPct: 0.5,
+ jitterMs: 6,
+ },
+ activity: { speakingMs: 600_000, mutedMs: 1_200_000, screenshareMs: 300_000 },
+ },
+ },
+ 2_460_000,
+ 90_000,
+ (u) => names[u] ?? u,
+ );
+ assert.deepEqual(sections, [
+ { title: 'Call', lines: ['Duration 41 min · peak 3 in the call', 'With Bob, Carol'] },
+ {
+ title: 'Connection',
+ lines: [
+ 'Verdict: fair · 2 reconnects',
+ 'Your connection poor/lost: 2 min',
+ 'Carol poor/lost: 30 s',
+ 'Everyone poor at once: 10 s (likely server or SFU side)',
+ ],
+ },
+ {
+ title: 'Network',
+ lines: [
+ 'RTT median 38 ms · p95 310 ms',
+ 'Outbound packet loss 4.1%',
+ 'Inbound packet loss 0.5%',
+ 'Jitter 6 ms',
+ ],
+ },
+ {
+ title: 'You',
+ lines: ['Talking 10 min · muted 20 min · deafened 2 min', 'Screensharing 5 min'],
+ },
+ ]);
+});
+
+it('[#253] describeCallDetails without fork details (older fork) still says something useful', () => {
+ const sections = describeCallDetails(
+ { durationMs: 60_000, reconnects: 0, poorMs: 0, verdict: 'good' },
+ 60_000,
+ 0,
+ (u) => u,
+ );
+ assert.deepEqual(
+ sections.map((s) => s.title),
+ ['Call', 'Connection'],
+ );
+ assert.deepEqual(sections[0].lines, ['Duration 1 min', 'Participants unknown']);
+});
diff --git a/src/app/utils/callSummary.ts b/src/app/utils/callSummary.ts
index f6dd47367..c6c17834d 100644
--- a/src/app/utils/callSummary.ts
+++ b/src/app/utils/callSummary.ts
@@ -32,3 +32,123 @@ export function describeCallEnd(durationMs: number, summary?: LotusCallSummary):
}
return parts.join(' · ');
}
+
+/** [Gitea #253] The fork's `details` in io.lotus.call_summary (lotus.29+). */
+export type LotusCallSummaryDetails = {
+ participants: string[];
+ peakParticipants: number;
+ quality: { youPoorMs: number; othersPoorMs: Record; everyonePoorMs: number };
+ network: {
+ rttMedianMs?: number;
+ rttP95Ms?: number;
+ outboundLossPct?: number;
+ inboundLossPct?: number;
+ jitterMs?: number;
+ };
+ activity: { speakingMs: number; mutedMs: number; screenshareMs: number };
+};
+
+const num = (v: unknown): number | undefined =>
+ typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : undefined;
+const obj = (v: unknown): Record =>
+ typeof v === 'object' && v !== null && !Array.isArray(v) ? (v as Record) : {};
+
+/** Defensive: the frame is another origin; take only well-formed fields. */
+export function parseCallSummaryDetails(raw: unknown): LotusCallSummaryDetails | undefined {
+ if (typeof raw !== 'object' || raw === null) return undefined;
+ const r = obj(raw);
+ const quality = obj(r.quality);
+ const network = obj(r.network);
+ const activity = obj(r.activity);
+ const othersPoorMs: Record = {};
+ Object.entries(obj(quality.othersPoorMs)).forEach(([user, ms]) => {
+ const n = num(ms);
+ if (n !== undefined && user.startsWith('@')) othersPoorMs[user] = n;
+ });
+ const net: LotusCallSummaryDetails['network'] = {};
+ (['rttMedianMs', 'rttP95Ms', 'outboundLossPct', 'inboundLossPct', 'jitterMs'] as const).forEach(
+ (k) => {
+ const n = num(network[k]);
+ if (n !== undefined) net[k] = n;
+ },
+ );
+ return {
+ participants: Array.isArray(r.participants)
+ ? r.participants.filter((p): p is string => typeof p === 'string').slice(0, 200)
+ : [],
+ peakParticipants: num(r.peakParticipants) ?? 1,
+ quality: {
+ youPoorMs: num(quality.youPoorMs) ?? 0,
+ othersPoorMs,
+ everyonePoorMs: num(quality.everyonePoorMs) ?? 0,
+ },
+ network: net,
+ activity: {
+ speakingMs: num(activity.speakingMs) ?? 0,
+ mutedMs: num(activity.mutedMs) ?? 0,
+ screenshareMs: num(activity.screenshareMs) ?? 0,
+ },
+ };
+}
+
+export type CallSummarySection = { title: string; lines: string[] };
+
+/**
+ * [Gitea #253] The Details card, in technical wording (the owner's choice):
+ * who was there, connection quality and whose side it was on, network
+ * numbers, and your own activity. `nameOf` turns a user id into a display name.
+ */
+export function describeCallDetails(
+ summary: LotusCallSummary | undefined,
+ durationMs: number,
+ deafenedMs: number,
+ nameOf: (userId: string) => string,
+): CallSummarySection[] {
+ const sections: CallSummarySection[] = [];
+ const d = summary?.details;
+ const dur = describeCallDuration;
+ const people = d?.participants ?? [];
+ sections.push({
+ title: 'Call',
+ lines: [
+ `Duration ${dur(durationMs)}${d ? ` · peak ${d.peakParticipants} in the call` : ''}`,
+ people.length
+ ? `With ${people.map(nameOf).join(', ')}`
+ : d
+ ? 'Nobody else joined'
+ : 'Participants unknown',
+ ],
+ });
+ if (summary) {
+ const quality: string[] = [
+ `Verdict: ${summary.verdict} · ${summary.reconnects} reconnect${summary.reconnects === 1 ? '' : 's'}`,
+ ];
+ if (d) {
+ quality.push(`Your connection poor/lost: ${dur(d.quality.youPoorMs)}`);
+ Object.entries(d.quality.othersPoorMs)
+ .sort((a, b) => b[1] - a[1])
+ .forEach(([user, ms]) => quality.push(`${nameOf(user)} poor/lost: ${dur(ms)}`));
+ if (d.quality.everyonePoorMs > 0)
+ quality.push(
+ `Everyone poor at once: ${dur(d.quality.everyonePoorMs)} (likely server or SFU side)`,
+ );
+ } else quality.push(`Your connection poor/lost: ${dur(summary.poorMs)}`);
+ sections.push({ title: 'Connection', lines: quality });
+ }
+ if (d) {
+ const n = d.network;
+ const net: string[] = [];
+ if (n.rttMedianMs !== undefined)
+ net.push(`RTT median ${n.rttMedianMs} ms · p95 ${n.rttP95Ms ?? n.rttMedianMs} ms`);
+ if (n.outboundLossPct !== undefined) net.push(`Outbound packet loss ${n.outboundLossPct}%`);
+ if (n.inboundLossPct !== undefined) net.push(`Inbound packet loss ${n.inboundLossPct}%`);
+ if (n.jitterMs !== undefined) net.push(`Jitter ${n.jitterMs} ms`);
+ if (net.length) sections.push({ title: 'Network', lines: net });
+ const act = [
+ `Talking ${dur(d.activity.speakingMs)} · muted ${dur(d.activity.mutedMs)} · deafened ${dur(deafenedMs)}`,
+ ];
+ if (d.activity.screenshareMs > 0) act.push(`Screensharing ${dur(d.activity.screenshareMs)}`);
+ sections.push({ title: 'You', lines: act });
+ }
+ return sections;
+}