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:
2026-09-20 13:48:14 -04:00
co-authored by Claude Opus 5
parent 96a97a2f86
commit af1c0ee184
5 changed files with 171 additions and 0 deletions
+67
View File
@@ -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',
);
});
});
+34
View File
@@ -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(' · ');
}