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(' · '); } /** [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; }