From af1c0ee1846c42ec5ff2671d8a7353f944fa5f9c Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Sun, 20 Sep 2026 13:48:14 -0400 Subject: [PATCH] =?UTF-8?q?feat(calls):=20"Call=20ended=20=C2=B7=2041=20mi?= =?UTF-8?q?n=20=C2=B7=20connection=20was=20good"=20toast=20(#143)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One line in the existing toast style when a call you were in ends: the duration from our own join clock, plus the fork's io.lotus.call_summary readout (fork ≥ 0.25.0-lotus.10) when it arrives — "connection was good", "3 reconnects", "connection was poor for 4 min". Nothing is stored or sent; the summary is one postMessage at hangup. Without the fork summary the toast still shows the duration. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA --- src/app/components/CallEmbedProvider.tsx | 2 + src/app/hooks/useCallEndedToast.ts | 40 ++++++++++++++ src/app/plugins/call/CallEmbed.ts | 28 ++++++++++ src/app/utils/callSummary.test.ts | 67 ++++++++++++++++++++++++ src/app/utils/callSummary.ts | 34 ++++++++++++ 5 files changed, 171 insertions(+) create mode 100644 src/app/hooks/useCallEndedToast.ts create mode 100644 src/app/utils/callSummary.test.ts create mode 100644 src/app/utils/callSummary.ts diff --git a/src/app/components/CallEmbedProvider.tsx b/src/app/components/CallEmbedProvider.tsx index 0ba27e4da..35274a9a9 100644 --- a/src/app/components/CallEmbedProvider.tsx +++ b/src/app/components/CallEmbedProvider.tsx @@ -47,6 +47,7 @@ import { previewRingtone, startRingtone, unlockRingtoneAudio } from '../utils/ri import { useCallMembersChange, useCallSession } from '../hooks/useCall'; import { useCallJoinLeaveSounds } from '../hooks/useCallJoinLeaveSounds'; import { useCallPolicyRevokedToast } from '../hooks/useCallPolicyRevokedToast'; +import { useCallEndedToast } from '../hooks/useCallEndedToast'; import { useCallAnnouncements } from '../hooks/useCallAnnouncements'; import { useMutedTalkWarning } from '../hooks/useMutedTalkWarning'; import { callAnnouncementAtom } from '../state/callAnnouncement'; @@ -744,6 +745,7 @@ function CallUtils({ embed, joined }: { embed: CallEmbed; joined: boolean }) { useAfkAutoMute(joined ? embed : undefined); useCallJoinLeaveSounds(embed); useCallPolicyRevokedToast(embed, joined); + useCallEndedToast(embed); useCallAnnouncements(embed, joined); useMutedTalkWarning(embed, joined); useCallThemeSync(embed); diff --git a/src/app/hooks/useCallEndedToast.ts b/src/app/hooks/useCallEndedToast.ts new file mode 100644 index 000000000..62f1b710e --- /dev/null +++ b/src/app/hooks/useCallEndedToast.ts @@ -0,0 +1,40 @@ +import { useEffect, useRef } from 'react'; +import { useSetAtom } from 'jotai'; +import { CallEmbed } from '../plugins/call'; +import { useCallHangupEvent } from './useCallEmbed'; +import { toastQueueAtom } from '../state/toast'; +import { describeCallEnd } from '../utils/callSummary'; + +// 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. +const SUMMARY_GRACE_MS = 400; + +/** + * [Gitea #143] "Call ended · 41 min · connection was good" in the existing + * toast style when a call you were in ends. Duration comes from our own + * join clock; the quality readout from the fork, if it arrived. + */ +export function useCallEndedToast(embed: CallEmbed): void { + const setToast = useSetAtom(toastQueueAtom); + const fired = useRef(false); + + useEffect(() => { + fired.current = false; + }, [embed]); + + useCallHangupEvent(embed, () => { + if (fired.current || !embed.joined || embed.joinedAt === undefined) return; + fired.current = true; + const durationMs = Date.now() - embed.joinedAt; + const toast = () => + setToast({ + id: `call-ended-${Date.now()}`, + displayName: 'Lotus Chat', + body: describeCallEnd(durationMs, embed.lastSummary), + roomName: embed.room.name ?? 'Voice call', + roomId: embed.roomId, + }); + if (embed.lastSummary) toast(); + else setTimeout(toast, SUMMARY_GRACE_MS); + }); +} diff --git a/src/app/plugins/call/CallEmbed.ts b/src/app/plugins/call/CallEmbed.ts index 4d8b8b389..4a8b884fa 100644 --- a/src/app/plugins/call/CallEmbed.ts +++ b/src/app/plugins/call/CallEmbed.ts @@ -49,6 +49,14 @@ export interface LotusCallParticipant { speakingWhileMuted?: boolean; } +/** [Gitea #143] The fork's one-shot io.lotus.call_summary payload. */ +export interface LotusCallSummary { + durationMs: number; + reconnects: number; + poorMs: number; + verdict: 'good' | 'fair' | 'poor' | 'unknown'; +} + export class CallEmbed { private mx: MatrixClient; @@ -60,6 +68,12 @@ export class CallEmbed { public joined = false; + /** [Gitea #143] When the first JoinCall landed, for the hangup readout. */ + public joinedAt: number | undefined; + + /** [Gitea #143] The fork's end-of-call summary, once it arrives. */ + public lastSummary: LotusCallSummary | 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; @@ -390,6 +404,19 @@ export class CallEmbed { this.forkStateRequestListeners.forEach((l) => l()); }), ); + this.disposables.push( + this.listenAction('io.lotus.call_summary', (evt) => { + const data = (evt.detail as { data?: Partial } | undefined)?.data; + if (data && typeof data.durationMs === 'number') { + this.lastSummary = { + durationMs: data.durationMs, + reconnects: data.reconnects ?? 0, + poorMs: data.poorMs ?? 0, + verdict: data.verdict ?? 'unknown', + }; + } + }), + ); this.disposables.push( this.listenAction('io.lotus.call_state', (evt) => { const data = (evt.detail as { data?: { participants?: unknown } } | undefined)?.data; @@ -537,6 +564,7 @@ export class CallEmbed { return; } this.joined = true; + this.joinedAt = Date.now(); // 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/utils/callSummary.test.ts b/src/app/utils/callSummary.test.ts new file mode 100644 index 000000000..c01783c96 --- /dev/null +++ b/src/app/utils/callSummary.test.ts @@ -0,0 +1,67 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { describeCallDuration, describeCallEnd } from './callSummary'; + +describe('describeCallDuration', () => { + it('picks the unit', () => { + assert.equal(describeCallDuration(40_000), '40 s'); + assert.equal(describeCallDuration(41 * 60_000), '41 min'); + assert.equal(describeCallDuration(65 * 60_000), '1 h 05 min'); + }); +}); + +describe('describeCallEnd', () => { + const min = 60_000; + it('without a summary shows just the duration', () => { + assert.equal(describeCallEnd(41 * min), 'Call ended · 41 min'); + }); + it('good / unknown', () => { + assert.equal( + describeCallEnd(41 * min, { + durationMs: 41 * min, + reconnects: 1, + poorMs: 0, + verdict: 'good', + }), + 'Call ended · 41 min · connection was good', + ); + assert.equal( + describeCallEnd(2 * min, { + durationMs: 2 * min, + reconnects: 0, + poorMs: 0, + verdict: 'unknown', + }), + 'Call ended · 2 min', + ); + }); + it('reconnects and poor spells', () => { + assert.equal( + describeCallEnd(41 * min, { + durationMs: 41 * min, + reconnects: 3, + poorMs: 0, + verdict: 'fair', + }), + 'Call ended · 41 min · 3 reconnects', + ); + assert.equal( + describeCallEnd(12 * min, { + durationMs: 12 * min, + reconnects: 1, + poorMs: 4 * min, + verdict: 'poor', + }), + 'Call ended · 12 min · 1 reconnect, connection was poor for 4 min', + ); + assert.equal( + describeCallEnd(12 * min, { + durationMs: 12 * min, + reconnects: 0, + poorMs: 2_000, + verdict: 'fair', + }), + 'Call ended · 12 min · connection was fair', + ); + }); +}); diff --git a/src/app/utils/callSummary.ts b/src/app/utils/callSummary.ts new file mode 100644 index 000000000..f6dd47367 --- /dev/null +++ b/src/app/utils/callSummary.ts @@ -0,0 +1,34 @@ +import type { LotusCallSummary } from '../plugins/call/CallEmbed'; + +/** "41 min", "1 h 05 min", "40 s". */ +export const describeCallDuration = (ms: number): string => { + const s = Math.round(ms / 1000); + if (s < 60) return `${s} s`; + const m = Math.round(s / 60); + if (m < 60) return `${m} min`; + const h = Math.floor(m / 60); + return `${h} h ${String(m % 60).padStart(2, '0')} min`; +}; + +/** + * [Gitea #143] One line for the call-ended toast: duration (cinny's own + * clock) plus the fork's verdict when it arrived in time. + * "Call ended · 41 min · connection was good" + * "Call ended · 41 min · 3 reconnects" + * "Call ended · 12 min · connection was poor for 4 min" + */ +export function describeCallEnd(durationMs: number, summary?: LotusCallSummary): string { + const parts = ['Call ended', describeCallDuration(durationMs)]; + if (summary) { + if (summary.verdict === 'good') parts.push('connection was good'); + else if (summary.verdict === 'fair' || summary.verdict === 'poor') { + const bits: string[] = []; + if (summary.reconnects > 0) + bits.push(`${summary.reconnects} reconnect${summary.reconnects === 1 ? '' : 's'}`); + if (summary.poorMs >= 5_000) + bits.push(`connection was poor for ${describeCallDuration(summary.poorMs)}`); + parts.push(bits.length ? bits.join(', ') : `connection was ${summary.verdict}`); + } + } + return parts.join(' · '); +}