2026-09-20 13:48:14 -04:00
|
|
|
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(' · ');
|
|
|
|
|
}
|
2026-10-20 19:45:11 -04:00
|
|
|
|
|
|
|
|
/** [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<string, number>; 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<string, unknown> =>
|
|
|
|
|
typeof v === 'object' && v !== null && !Array.isArray(v) ? (v as Record<string, unknown>) : {};
|
|
|
|
|
|
|
|
|
|
/** 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<string, number> = {};
|
|
|
|
|
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;
|
|
|
|
|
}
|