feat(calls): "Call ended · 41 min · connection was good" toast (#143)
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
@@ -47,6 +47,7 @@ import { previewRingtone, startRingtone, unlockRingtoneAudio } from '../utils/ri
|
|||||||
import { useCallMembersChange, useCallSession } from '../hooks/useCall';
|
import { useCallMembersChange, useCallSession } from '../hooks/useCall';
|
||||||
import { useCallJoinLeaveSounds } from '../hooks/useCallJoinLeaveSounds';
|
import { useCallJoinLeaveSounds } from '../hooks/useCallJoinLeaveSounds';
|
||||||
import { useCallPolicyRevokedToast } from '../hooks/useCallPolicyRevokedToast';
|
import { useCallPolicyRevokedToast } from '../hooks/useCallPolicyRevokedToast';
|
||||||
|
import { useCallEndedToast } from '../hooks/useCallEndedToast';
|
||||||
import { useCallAnnouncements } from '../hooks/useCallAnnouncements';
|
import { useCallAnnouncements } from '../hooks/useCallAnnouncements';
|
||||||
import { useMutedTalkWarning } from '../hooks/useMutedTalkWarning';
|
import { useMutedTalkWarning } from '../hooks/useMutedTalkWarning';
|
||||||
import { callAnnouncementAtom } from '../state/callAnnouncement';
|
import { callAnnouncementAtom } from '../state/callAnnouncement';
|
||||||
@@ -744,6 +745,7 @@ function CallUtils({ embed, joined }: { embed: CallEmbed; joined: boolean }) {
|
|||||||
useAfkAutoMute(joined ? embed : undefined);
|
useAfkAutoMute(joined ? embed : undefined);
|
||||||
useCallJoinLeaveSounds(embed);
|
useCallJoinLeaveSounds(embed);
|
||||||
useCallPolicyRevokedToast(embed, joined);
|
useCallPolicyRevokedToast(embed, joined);
|
||||||
|
useCallEndedToast(embed);
|
||||||
useCallAnnouncements(embed, joined);
|
useCallAnnouncements(embed, joined);
|
||||||
useMutedTalkWarning(embed, joined);
|
useMutedTalkWarning(embed, joined);
|
||||||
useCallThemeSync(embed);
|
useCallThemeSync(embed);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -49,6 +49,14 @@ export interface LotusCallParticipant {
|
|||||||
speakingWhileMuted?: boolean;
|
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 {
|
export class CallEmbed {
|
||||||
private mx: MatrixClient;
|
private mx: MatrixClient;
|
||||||
|
|
||||||
@@ -60,6 +68,12 @@ export class CallEmbed {
|
|||||||
|
|
||||||
public joined = false;
|
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
|
// 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.
|
// whether the embed was already torn down by the normal Close/Hangup echo.
|
||||||
public disposed = false;
|
public disposed = false;
|
||||||
@@ -390,6 +404,19 @@ export class CallEmbed {
|
|||||||
this.forkStateRequestListeners.forEach((l) => l());
|
this.forkStateRequestListeners.forEach((l) => l());
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
this.disposables.push(
|
||||||
|
this.listenAction('io.lotus.call_summary', (evt) => {
|
||||||
|
const data = (evt.detail as { data?: Partial<LotusCallSummary> } | 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.disposables.push(
|
||||||
this.listenAction('io.lotus.call_state', (evt) => {
|
this.listenAction('io.lotus.call_state', (evt) => {
|
||||||
const data = (evt.detail as { data?: { participants?: unknown } } | undefined)?.data;
|
const data = (evt.detail as { data?: { participants?: unknown } } | undefined)?.data;
|
||||||
@@ -537,6 +564,7 @@ export class CallEmbed {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.joined = true;
|
this.joined = true;
|
||||||
|
this.joinedAt = Date.now();
|
||||||
// EC ignores io.element.device_mute before join; re-apply desired state now that EC is live
|
// EC ignores io.element.device_mute before join; re-apply desired state now that EC is live
|
||||||
this.control.forceState(this.initialState);
|
this.control.forceState(this.initialState);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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(' · ');
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user