Merge pull request 'feat(call): Details card after a call, with who/why/numbers/activity (#253)' (#442) from feat/call-summary-details into lotus
CI / Build & Quality Checks (push) Canceled after 18s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Secret scan (gitleaks) (push) Canceled after 0s
CI / Docker image build & smoke test (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
CI / Build & Quality Checks (push) Canceled after 18s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Secret scan (gitleaks) (push) Canceled after 0s
CI / Docker image build & smoke test (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
This commit was merged in pull request #442.
This commit is contained in:
Generated
+4
-4
@@ -81,7 +81,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@axe-core/playwright": "4.13.0",
|
"@axe-core/playwright": "4.13.0",
|
||||||
"@lotusguild/element-call-embedded": "0.25.0-lotus.28",
|
"@lotusguild/element-call-embedded": "0.25.0-lotus.29",
|
||||||
"@playwright/test": "1.63.0",
|
"@playwright/test": "1.63.0",
|
||||||
"@rollup/plugin-inject": "5.0.5",
|
"@rollup/plugin-inject": "5.0.5",
|
||||||
"@rollup/plugin-wasm": "6.2.2",
|
"@rollup/plugin-wasm": "6.2.2",
|
||||||
@@ -2695,9 +2695,9 @@
|
|||||||
"integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA=="
|
"integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA=="
|
||||||
},
|
},
|
||||||
"node_modules/@lotusguild/element-call-embedded": {
|
"node_modules/@lotusguild/element-call-embedded": {
|
||||||
"version": "0.25.0-lotus.28",
|
"version": "0.25.0-lotus.29",
|
||||||
"resolved": "https://code.lotusguild.org/api/packages/LotusGuild/npm/%40lotusguild%2Felement-call-embedded/-/0.25.0-lotus.28/element-call-embedded-0.25.0-lotus.28.tgz",
|
"resolved": "https://code.lotusguild.org/api/packages/LotusGuild/npm/%40lotusguild%2Felement-call-embedded/-/0.25.0-lotus.29/element-call-embedded-0.25.0-lotus.29.tgz",
|
||||||
"integrity": "sha512-IFBHHSVw0CzrrLgx3oIIcJmBB4YMb8at4oVuSKXifZ7MmyHydxg9U3sTlZGgLUjMVrrAXj4gFzCksGHet4LSQw==",
|
"integrity": "sha512-IAIEyiOFfkfb1k2JfBNOkX4LOVaGF8LzeKVQYCqKoyKDVao6rYEYA71LON8ntTaxkredXOK4CeLFsnkCiJHuGg==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/@matrix-org/matrix-sdk-crypto-wasm": {
|
"node_modules/@matrix-org/matrix-sdk-crypto-wasm": {
|
||||||
|
|||||||
+1
-1
@@ -108,7 +108,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@axe-core/playwright": "4.13.0",
|
"@axe-core/playwright": "4.13.0",
|
||||||
"@lotusguild/element-call-embedded": "0.25.0-lotus.28",
|
"@lotusguild/element-call-embedded": "0.25.0-lotus.29",
|
||||||
"@playwright/test": "1.63.0",
|
"@playwright/test": "1.63.0",
|
||||||
"@rollup/plugin-inject": "5.0.5",
|
"@rollup/plugin-inject": "5.0.5",
|
||||||
"@rollup/plugin-wasm": "6.2.2",
|
"@rollup/plugin-wasm": "6.2.2",
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import FocusTrap from 'focus-trap-react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
config,
|
||||||
|
Dialog,
|
||||||
|
Header,
|
||||||
|
Icon,
|
||||||
|
IconButton,
|
||||||
|
Icons,
|
||||||
|
Overlay,
|
||||||
|
OverlayBackdrop,
|
||||||
|
OverlayCenter,
|
||||||
|
Text,
|
||||||
|
} from 'folds';
|
||||||
|
import { useAtom } from 'jotai';
|
||||||
|
import { callSummaryViewAtom } from '../../state/callSummary';
|
||||||
|
import { useModalStyle } from '../../hooks/useModalStyle';
|
||||||
|
import { stopPropagation } from '../../utils/keyboard';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [Gitea #253] The post-call "Details" card: opened from the call-ended toast,
|
||||||
|
* shows who was in the call, connection quality and whose side it was on,
|
||||||
|
* network numbers and your own activity (technical wording).
|
||||||
|
*/
|
||||||
|
export function CallSummaryDialog() {
|
||||||
|
const [view, setView] = useAtom(callSummaryViewAtom);
|
||||||
|
const modalStyle = useModalStyle(400);
|
||||||
|
if (!view) return null;
|
||||||
|
const close = () => setView(undefined);
|
||||||
|
return (
|
||||||
|
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||||
|
<OverlayCenter>
|
||||||
|
<FocusTrap
|
||||||
|
focusTrapOptions={{
|
||||||
|
initialFocus: false,
|
||||||
|
onDeactivate: close,
|
||||||
|
clickOutsideDeactivates: true,
|
||||||
|
escapeDeactivates: stopPropagation,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Dialog
|
||||||
|
variant="Surface"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="call-summary-title"
|
||||||
|
style={modalStyle}
|
||||||
|
>
|
||||||
|
<Header
|
||||||
|
variant="Surface"
|
||||||
|
size="500"
|
||||||
|
style={{
|
||||||
|
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
|
||||||
|
borderBottomWidth: config.borderWidth.B300,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box grow="Yes" alignItems="Center" gap="200">
|
||||||
|
<Icon src={Icons.Phone} size="100" />
|
||||||
|
<Text id="call-summary-title" size="H4" truncate>
|
||||||
|
Call summary · {view.roomName}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<IconButton size="300" radii="300" onClick={close} aria-label="Close">
|
||||||
|
<Icon src={Icons.Cross} />
|
||||||
|
</IconButton>
|
||||||
|
</Header>
|
||||||
|
<Box direction="Column" gap="400" style={{ padding: config.space.S400 }}>
|
||||||
|
{view.sections.map((section) => (
|
||||||
|
<Box key={section.title} direction="Column" gap="100">
|
||||||
|
<Text size="L400">{section.title}</Text>
|
||||||
|
{section.lines.map((line) => (
|
||||||
|
<Text key={line} size="T300" style={{ fontVariantNumeric: 'tabular-nums' }}>
|
||||||
|
{line}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Dialog>
|
||||||
|
</FocusTrap>
|
||||||
|
</OverlayCenter>
|
||||||
|
</Overlay>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1749,6 +1749,7 @@ function Calls() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const [pttMode, setPttMode] = useSetting(settingsAtom, 'pttMode');
|
const [pttMode, setPttMode] = useSetting(settingsAtom, 'pttMode');
|
||||||
|
const [callSummaryToast, setCallSummaryToast] = useSetting(settingsAtom, 'callSummaryToast');
|
||||||
const [callRejoin, setCallRejoin] = useSetting(settingsAtom, 'callRejoinAfterRestart');
|
const [callRejoin, setCallRejoin] = useSetting(settingsAtom, 'callRejoinAfterRestart');
|
||||||
const [haptics, setHaptics] = useSetting(settingsAtom, 'hapticFeedback');
|
const [haptics, setHaptics] = useSetting(settingsAtom, 'hapticFeedback');
|
||||||
const [pttKey, setPttKey] = useSetting(settingsAtom, 'pttKey');
|
const [pttKey, setPttKey] = useSetting(settingsAtom, 'pttKey');
|
||||||
@@ -2062,6 +2063,13 @@ function Calls() {
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<SettingTile
|
||||||
|
title="Summary After Calls"
|
||||||
|
description="When a call ends, show how long it was and how the connection held up, with a Details button for who was there, network numbers and your own talk/mute time."
|
||||||
|
after={
|
||||||
|
<Switch variant="Primary" value={callSummaryToast} onChange={setCallSummaryToast} />
|
||||||
|
}
|
||||||
|
/>
|
||||||
{hapticsSupported() && (
|
{hapticsSupported() && (
|
||||||
<SettingTile
|
<SettingTile
|
||||||
title="Haptic Feedback"
|
title="Haptic Feedback"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useEffect, useRef, CSSProperties } from 'react';
|
import React, { useEffect, useRef, CSSProperties } from 'react';
|
||||||
import { useAtomValue, useSetAtom } from 'jotai';
|
import { useAtomValue, useSetAtom } from 'jotai';
|
||||||
import { color, config, Icon, IconButton, Icons } from 'folds';
|
import { Button, color, config, Icon, IconButton, Icons, Text } from 'folds';
|
||||||
import { ScreenSize, useScreenSize } from '../../hooks/useScreenSize';
|
import { ScreenSize, useScreenSize } from '../../hooks/useScreenSize';
|
||||||
import { toastQueueAtom, dismissToastAtom, ToastNotif } from '../../state/toast';
|
import { toastQueueAtom, dismissToastAtom, ToastNotif } from '../../state/toast';
|
||||||
import { useSetting } from '../../state/hooks/settings';
|
import { useSetting } from '../../state/hooks/settings';
|
||||||
@@ -43,13 +43,17 @@ function ToastCard({ toast }: ToastCardProps) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (toast.sticky) return;
|
if (toast.sticky) return;
|
||||||
timerRef.current = setTimeout(() => {
|
timerRef.current = setTimeout(
|
||||||
|
() => {
|
||||||
dismiss(toast.id);
|
dismiss(toast.id);
|
||||||
}, 4000);
|
},
|
||||||
|
// [Gitea #253] A toast with a button stays long enough to use it.
|
||||||
|
toast.actionLabel ? 10_000 : 4000,
|
||||||
|
);
|
||||||
return () => {
|
return () => {
|
||||||
if (timerRef.current !== null) clearTimeout(timerRef.current);
|
if (timerRef.current !== null) clearTimeout(timerRef.current);
|
||||||
};
|
};
|
||||||
}, [dismiss, toast.id, toast.sticky]);
|
}, [dismiss, toast.id, toast.sticky, toast.actionLabel]);
|
||||||
|
|
||||||
const handleCardClick = () => {
|
const handleCardClick = () => {
|
||||||
if (toast.onClick) {
|
if (toast.onClick) {
|
||||||
@@ -212,6 +216,24 @@ function ToastCard({ toast }: ToastCardProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div style={bodyStyle}>{toast.body}</div>
|
<div style={bodyStyle}>{toast.body}</div>
|
||||||
{toast.roomName && <div style={roomNameStyle}>{toast.roomName}</div>}
|
{toast.roomName && <div style={roomNameStyle}>{toast.roomName}</div>}
|
||||||
|
{toast.actionLabel && toast.onAction && (
|
||||||
|
<div style={{ marginTop: config.space.S200 }}>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
variant="Secondary"
|
||||||
|
fill="Soft"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
toast.onAction?.();
|
||||||
|
dismiss(toast.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text size="B300">{toast.actionLabel}</Text>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,10 @@ import { useSetAtom } from 'jotai';
|
|||||||
import { CallEmbed } from '../plugins/call';
|
import { CallEmbed } from '../plugins/call';
|
||||||
import { useCallHangupEvent } from './useCallEmbed';
|
import { useCallHangupEvent } from './useCallEmbed';
|
||||||
import { toastQueueAtom } from '../state/toast';
|
import { toastQueueAtom } from '../state/toast';
|
||||||
import { describeCallEnd } from '../utils/callSummary';
|
import { describeCallDetails, describeCallEnd } from '../utils/callSummary';
|
||||||
|
import { callSummaryViewAtom } from '../state/callSummary';
|
||||||
|
import { useSetting } from '../state/hooks/settings';
|
||||||
|
import { settingsAtom } from '../state/settings';
|
||||||
|
|
||||||
// The fork sends io.lotus.call_summary on SFU disconnect, which normally lands
|
// 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.
|
// before Element Call's hangup echo; give a late one this long before toasting.
|
||||||
@@ -16,6 +19,8 @@ const SUMMARY_GRACE_MS = 400;
|
|||||||
*/
|
*/
|
||||||
export function useCallEndedToast(embed: CallEmbed): void {
|
export function useCallEndedToast(embed: CallEmbed): void {
|
||||||
const setToast = useSetAtom(toastQueueAtom);
|
const setToast = useSetAtom(toastQueueAtom);
|
||||||
|
const setSummaryView = useSetAtom(callSummaryViewAtom);
|
||||||
|
const [callSummaryToast] = useSetting(settingsAtom, 'callSummaryToast');
|
||||||
const fired = useRef(false);
|
const fired = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -25,14 +30,25 @@ export function useCallEndedToast(embed: CallEmbed): void {
|
|||||||
useCallHangupEvent(embed, () => {
|
useCallHangupEvent(embed, () => {
|
||||||
if (fired.current || !embed.joined || embed.joinedAt === undefined) return;
|
if (fired.current || !embed.joined || embed.joinedAt === undefined) return;
|
||||||
fired.current = true;
|
fired.current = true;
|
||||||
|
if (!callSummaryToast) return;
|
||||||
const durationMs = Date.now() - embed.joinedAt;
|
const durationMs = Date.now() - embed.joinedAt;
|
||||||
|
const deafenedMs = embed.deafenedMs();
|
||||||
|
const roomName = embed.room.name ?? 'Voice call';
|
||||||
|
const nameOf = (userId: string) => embed.room.getMember(userId)?.name ?? userId;
|
||||||
const toast = () =>
|
const toast = () =>
|
||||||
setToast({
|
setToast({
|
||||||
id: `call-ended-${Date.now()}`,
|
id: `call-ended-${Date.now()}`,
|
||||||
displayName: 'Lotus Chat',
|
displayName: 'Lotus Chat',
|
||||||
body: describeCallEnd(durationMs, embed.lastSummary),
|
body: describeCallEnd(durationMs, embed.lastSummary),
|
||||||
roomName: embed.room.name ?? 'Voice call',
|
roomName,
|
||||||
roomId: embed.roomId,
|
roomId: embed.roomId,
|
||||||
|
// [Gitea #253] The full breakdown, snapshotted at hangup.
|
||||||
|
actionLabel: 'Details',
|
||||||
|
onAction: () =>
|
||||||
|
setSummaryView({
|
||||||
|
roomName,
|
||||||
|
sections: describeCallDetails(embed.lastSummary, durationMs, deafenedMs, nameOf),
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
if (embed.lastSummary) toast();
|
if (embed.lastSummary) toast();
|
||||||
else setTimeout(toast, SUMMARY_GRACE_MS);
|
else setTimeout(toast, SUMMARY_GRACE_MS);
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { useCompositionEndTracking } from '../hooks/useComposingCheck';
|
|||||||
import { settingsAtom } from '../state/settings';
|
import { settingsAtom } from '../state/settings';
|
||||||
import { isWithinTimeWindow } from '../utils/timeWindow';
|
import { isWithinTimeWindow } from '../utils/timeWindow';
|
||||||
import { LotusToastContainer } from '../features/toast/LotusToastContainer';
|
import { LotusToastContainer } from '../features/toast/LotusToastContainer';
|
||||||
|
import { CallSummaryDialog } from '../features/call-summary/CallSummaryDialog';
|
||||||
import { useTauriNotificationBadge } from '../hooks/useTauriNotificationBadge';
|
import { useTauriNotificationBadge } from '../hooks/useTauriNotificationBadge';
|
||||||
import { useSessionAppeared } from '../hooks/useSessionAppeared';
|
import { useSessionAppeared } from '../hooks/useSessionAppeared';
|
||||||
import { useTauriWindowChrome } from '../hooks/useTauriWindowChrome';
|
import { useTauriWindowChrome } from '../hooks/useTauriWindowChrome';
|
||||||
@@ -248,6 +249,7 @@ function App() {
|
|||||||
<SeasonalEffect />
|
<SeasonalEffect />
|
||||||
<NightLightOverlay />
|
<NightLightOverlay />
|
||||||
<LotusToastContainer />
|
<LotusToastContainer />
|
||||||
|
<CallSummaryDialog />
|
||||||
</JotaiProvider>
|
</JotaiProvider>
|
||||||
<ReactQueryDevtools initialIsOpen={false} />
|
<ReactQueryDevtools initialIsOpen={false} />
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
|
|||||||
@@ -26,13 +26,14 @@ import {
|
|||||||
ElementMediaStateDetail,
|
ElementMediaStateDetail,
|
||||||
ElementWidgetActions,
|
ElementWidgetActions,
|
||||||
} from './types';
|
} from './types';
|
||||||
import { CallControl } from './CallControl';
|
import { CallControl, CallControlEvent } from './CallControl';
|
||||||
import { CallControlState } from './CallControlState';
|
import { CallControlState } from './CallControlState';
|
||||||
import { verifyDenoiseAssets } from './denoiseSmokeCheck';
|
import { verifyDenoiseAssets } from './denoiseSmokeCheck';
|
||||||
import { canDelegateCapability } from './utils';
|
import { canDelegateCapability } from './utils';
|
||||||
import { getCallPageUrl } from './callPageUrl';
|
import { getCallPageUrl } from './callPageUrl';
|
||||||
import { restrictWidgetMessages } from '../widgetTransport';
|
import { restrictWidgetMessages } from '../widgetTransport';
|
||||||
import { isRingableRoom } from '../../utils/callRing';
|
import { isRingableRoom } from '../../utils/callRing';
|
||||||
|
import { parseCallSummaryDetails, type LotusCallSummaryDetails } from '../../utils/callSummary';
|
||||||
|
|
||||||
// Maximum time to wait for the embedded Element Call iframe to progress from
|
// Maximum time to wait for the embedded Element Call iframe to progress from
|
||||||
// initial load to a ready/joined state. If it hasn't by then, we assume the
|
// initial load to a ready/joined state. If it hasn't by then, we assume the
|
||||||
@@ -59,6 +60,8 @@ export interface LotusCallSummary {
|
|||||||
reconnects: number;
|
reconnects: number;
|
||||||
poorMs: number;
|
poorMs: number;
|
||||||
verdict: 'good' | 'fair' | 'poor' | 'unknown';
|
verdict: 'good' | 'fair' | 'poor' | 'unknown';
|
||||||
|
/** [Gitea #253] Breakdown for the Details card (fork lotus.29+). */
|
||||||
|
details?: LotusCallSummaryDetails;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class CallEmbed {
|
export class CallEmbed {
|
||||||
@@ -78,6 +81,25 @@ export class CallEmbed {
|
|||||||
/** [Gitea #143] The fork's end-of-call summary, once it arrives. */
|
/** [Gitea #143] The fork's end-of-call summary, once it arrives. */
|
||||||
public lastSummary: LotusCallSummary | undefined;
|
public lastSummary: LotusCallSummary | undefined;
|
||||||
|
|
||||||
|
// [Gitea #253] Time spent deafened (sound off), for the post-call Details.
|
||||||
|
private deafenedSince: number | undefined;
|
||||||
|
|
||||||
|
private deafenedTotal = 0;
|
||||||
|
|
||||||
|
public deafenedMs(now = Date.now()): number {
|
||||||
|
return this.deafenedTotal + (this.deafenedSince === undefined ? 0 : now - this.deafenedSince);
|
||||||
|
}
|
||||||
|
|
||||||
|
private trackDeafened = (): void => {
|
||||||
|
if (!this.joined) return;
|
||||||
|
const deaf = !this.control.sound;
|
||||||
|
if (deaf && this.deafenedSince === undefined) this.deafenedSince = Date.now();
|
||||||
|
if (!deaf && this.deafenedSince !== undefined) {
|
||||||
|
this.deafenedTotal += Date.now() - this.deafenedSince;
|
||||||
|
this.deafenedSince = 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;
|
||||||
@@ -339,6 +361,8 @@ export class CallEmbed {
|
|||||||
const controlState = initialControlState ?? new CallControlState(true, false, true);
|
const controlState = initialControlState ?? new CallControlState(true, false, true);
|
||||||
this.control = new CallControl(controlState, call, iframe);
|
this.control = new CallControl(controlState, call, iframe);
|
||||||
this.initialState = controlState;
|
this.initialState = controlState;
|
||||||
|
this.control.on(CallControlEvent.StateUpdate, this.trackDeafened);
|
||||||
|
this.disposables.push(() => this.control.off(CallControlEvent.StateUpdate, this.trackDeafened));
|
||||||
// If the iframe document itself fails to load, fail fast.
|
// If the iframe document itself fails to load, fail fast.
|
||||||
iframe.onerror = () => {
|
iframe.onerror = () => {
|
||||||
this.settleLoad('iframe');
|
this.settleLoad('iframe');
|
||||||
@@ -434,6 +458,7 @@ export class CallEmbed {
|
|||||||
reconnects: data.reconnects ?? 0,
|
reconnects: data.reconnects ?? 0,
|
||||||
poorMs: data.poorMs ?? 0,
|
poorMs: data.poorMs ?? 0,
|
||||||
verdict: data.verdict ?? 'unknown',
|
verdict: data.verdict ?? 'unknown',
|
||||||
|
details: parseCallSummaryDetails((data as { details?: unknown }).details),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
@@ -623,6 +648,7 @@ export class CallEmbed {
|
|||||||
}
|
}
|
||||||
this.joined = true;
|
this.joined = true;
|
||||||
this.joinedAt = Date.now();
|
this.joinedAt = Date.now();
|
||||||
|
this.trackDeafened();
|
||||||
// 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,6 @@
|
|||||||
|
import { atom } from 'jotai';
|
||||||
|
import type { CallSummarySection } from '../utils/callSummary';
|
||||||
|
|
||||||
|
/** [Gitea #253] The post-call Details card currently open (undefined = closed). */
|
||||||
|
export type CallSummaryView = { roomName: string; sections: CallSummarySection[] };
|
||||||
|
export const callSummaryViewAtom = atom<CallSummaryView | undefined>(undefined);
|
||||||
@@ -310,6 +310,8 @@ export interface Settings {
|
|||||||
screenshareFramerate: ScreenshareFramerate;
|
screenshareFramerate: ScreenshareFramerate;
|
||||||
// [P5-15] In-call soundboard
|
// [P5-15] In-call soundboard
|
||||||
soundboardEnabled: boolean;
|
soundboardEnabled: boolean;
|
||||||
|
// [Gitea #253] The call-ended toast (with its Details card).
|
||||||
|
callSummaryToast: boolean;
|
||||||
soundboardVolume: number; // 0–100
|
soundboardVolume: number; // 0–100
|
||||||
|
|
||||||
seasonalThemeOverride: SeasonalThemeOverride;
|
seasonalThemeOverride: SeasonalThemeOverride;
|
||||||
@@ -429,6 +431,7 @@ const defaultSettings: Settings = {
|
|||||||
screenshareBitrate: 'auto',
|
screenshareBitrate: 'auto',
|
||||||
screenshareFramerate: 'auto',
|
screenshareFramerate: 'auto',
|
||||||
soundboardEnabled: true,
|
soundboardEnabled: true,
|
||||||
|
callSummaryToast: true,
|
||||||
soundboardVolume: 80,
|
soundboardVolume: 80,
|
||||||
|
|
||||||
seasonalThemeOverride: 'auto',
|
seasonalThemeOverride: 'auto',
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ export type ToastNotif = {
|
|||||||
onClick?: () => void; // custom click handler; skips hash navigation when set
|
onClick?: () => void; // custom click handler; skips hash navigation when set
|
||||||
sticky?: boolean; // when true, does not auto-dismiss — use for action toasts that require a click
|
sticky?: boolean; // when true, does not auto-dismiss — use for action toasts that require a click
|
||||||
onDismiss?: () => void; // called when the user closes the toast with its X (not on click/eviction)
|
onDismiss?: () => void; // called when the user closes the toast with its X (not on click/eviction)
|
||||||
|
actionLabel?: string; // [Gitea #253] a small button on the toast, e.g. "Details"
|
||||||
|
onAction?: () => void;
|
||||||
createdAt?: number; // set by toastQueueAtom when enqueued; used for #80 eviction ordering
|
createdAt?: number; // set by toastQueueAtom when enqueued; used for #80 eviction ordering
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { describe, it } from 'node:test';
|
import { describe, it } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { describeCallDuration, describeCallEnd } from './callSummary';
|
import {
|
||||||
|
describeCallDuration,
|
||||||
|
describeCallEnd,
|
||||||
|
describeCallDetails,
|
||||||
|
parseCallSummaryDetails,
|
||||||
|
} from './callSummary';
|
||||||
|
|
||||||
describe('describeCallDuration', () => {
|
describe('describeCallDuration', () => {
|
||||||
it('picks the unit', () => {
|
it('picks the unit', () => {
|
||||||
@@ -65,3 +70,97 @@ describe('describeCallEnd', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('[#253] parseCallSummaryDetails keeps well-formed fields and drops the rest', () => {
|
||||||
|
const d = parseCallSummaryDetails({
|
||||||
|
participants: ['@bob:x', 7, '@carol:x'],
|
||||||
|
peakParticipants: 3,
|
||||||
|
quality: {
|
||||||
|
youPoorMs: 1000,
|
||||||
|
othersPoorMs: { '@bob:x': 500, evil: 9, '@c:x': -1 },
|
||||||
|
everyonePoorMs: 0,
|
||||||
|
},
|
||||||
|
network: { rttMedianMs: 38, rttP95Ms: 'fast', inboundLossPct: 0.5 },
|
||||||
|
activity: { speakingMs: 60_000, mutedMs: 'x', screenshareMs: 0 },
|
||||||
|
});
|
||||||
|
assert.deepEqual(d, {
|
||||||
|
participants: ['@bob:x', '@carol:x'],
|
||||||
|
peakParticipants: 3,
|
||||||
|
quality: { youPoorMs: 1000, othersPoorMs: { '@bob:x': 500 }, everyonePoorMs: 0 },
|
||||||
|
network: { rttMedianMs: 38, inboundLossPct: 0.5 },
|
||||||
|
activity: { speakingMs: 60_000, mutedMs: 0, screenshareMs: 0 },
|
||||||
|
});
|
||||||
|
assert.equal(parseCallSummaryDetails(undefined), undefined);
|
||||||
|
assert.equal(parseCallSummaryDetails('details'), undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('[#253] describeCallDetails: technical sections with names, blame and numbers', () => {
|
||||||
|
const names: Record<string, string> = { '@bob:x': 'Bob', '@carol:x': 'Carol' };
|
||||||
|
const sections = describeCallDetails(
|
||||||
|
{
|
||||||
|
durationMs: 2_460_000,
|
||||||
|
reconnects: 2,
|
||||||
|
poorMs: 120_000,
|
||||||
|
verdict: 'fair',
|
||||||
|
details: {
|
||||||
|
participants: ['@bob:x', '@carol:x'],
|
||||||
|
peakParticipants: 3,
|
||||||
|
quality: {
|
||||||
|
youPoorMs: 120_000,
|
||||||
|
othersPoorMs: { '@carol:x': 30_000 },
|
||||||
|
everyonePoorMs: 10_000,
|
||||||
|
},
|
||||||
|
network: {
|
||||||
|
rttMedianMs: 38,
|
||||||
|
rttP95Ms: 310,
|
||||||
|
outboundLossPct: 4.1,
|
||||||
|
inboundLossPct: 0.5,
|
||||||
|
jitterMs: 6,
|
||||||
|
},
|
||||||
|
activity: { speakingMs: 600_000, mutedMs: 1_200_000, screenshareMs: 300_000 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
2_460_000,
|
||||||
|
90_000,
|
||||||
|
(u) => names[u] ?? u,
|
||||||
|
);
|
||||||
|
assert.deepEqual(sections, [
|
||||||
|
{ title: 'Call', lines: ['Duration 41 min · peak 3 in the call', 'With Bob, Carol'] },
|
||||||
|
{
|
||||||
|
title: 'Connection',
|
||||||
|
lines: [
|
||||||
|
'Verdict: fair · 2 reconnects',
|
||||||
|
'Your connection poor/lost: 2 min',
|
||||||
|
'Carol poor/lost: 30 s',
|
||||||
|
'Everyone poor at once: 10 s (likely server or SFU side)',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Network',
|
||||||
|
lines: [
|
||||||
|
'RTT median 38 ms · p95 310 ms',
|
||||||
|
'Outbound packet loss 4.1%',
|
||||||
|
'Inbound packet loss 0.5%',
|
||||||
|
'Jitter 6 ms',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'You',
|
||||||
|
lines: ['Talking 10 min · muted 20 min · deafened 2 min', 'Screensharing 5 min'],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('[#253] describeCallDetails without fork details (older fork) still says something useful', () => {
|
||||||
|
const sections = describeCallDetails(
|
||||||
|
{ durationMs: 60_000, reconnects: 0, poorMs: 0, verdict: 'good' },
|
||||||
|
60_000,
|
||||||
|
0,
|
||||||
|
(u) => u,
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
sections.map((s) => s.title),
|
||||||
|
['Call', 'Connection'],
|
||||||
|
);
|
||||||
|
assert.deepEqual(sections[0].lines, ['Duration 1 min', 'Participants unknown']);
|
||||||
|
});
|
||||||
|
|||||||
@@ -32,3 +32,123 @@ export function describeCallEnd(durationMs: number, summary?: LotusCallSummary):
|
|||||||
}
|
}
|
||||||
return parts.join(' · ');
|
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<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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user