Compare commits

..
Author SHA1 Message Date
Lotus CIandClaude Opus 5.5 ac1a1c9a48 chore(deps): pin @lotusguild/element-call-embedded 0.25.0-lotus.29 (#253)
CI / Build & Quality Checks (pull_request) Successful in 3m22s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 10s
CI / Playwright smoke (e2e) (pull_request) Successful in 20m13s
lotus.29 adds the call summary `details` (element-call#60).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-20 20:33:13 -04:00
Lotus CIandClaude Opus 5.5 8645a55769 feat(call): Details card for the call-ended toast (#253)
The call-ended toast gets a Details button (and stays 10 s) that opens a
card with who was in the call and the peak, connection verdict and
reconnects, how long your / each person's / everyone's connection was
poor (likely the server when everyone), RTT, packet loss and jitter, and
your talk, mute, deafen and screenshare time, in technical wording.
Deafened time is tracked by CallEmbed; the rest comes from the fork's new
io.lotus.call_summary `details` (lotus.29). Settings → Calls gets
"Summary After Calls" (on by default).

Fixes #253

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-20 20:33:07 -04:00
24 changed files with 535 additions and 604 deletions
+4 -4
View File
@@ -81,7 +81,7 @@
},
"devDependencies": {
"@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",
"@rollup/plugin-inject": "5.0.5",
"@rollup/plugin-wasm": "6.2.2",
@@ -2695,9 +2695,9 @@
"integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA=="
},
"node_modules/@lotusguild/element-call-embedded": {
"version": "0.25.0-lotus.28",
"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",
"integrity": "sha512-IFBHHSVw0CzrrLgx3oIIcJmBB4YMb8at4oVuSKXifZ7MmyHydxg9U3sTlZGgLUjMVrrAXj4gFzCksGHet4LSQw==",
"version": "0.25.0-lotus.29",
"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-IAIEyiOFfkfb1k2JfBNOkX4LOVaGF8LzeKVQYCqKoyKDVao6rYEYA71LON8ntTaxkredXOK4CeLFsnkCiJHuGg==",
"dev": true
},
"node_modules/@matrix-org/matrix-sdk-crypto-wasm": {
+1 -1
View File
@@ -108,7 +108,7 @@
},
"devDependencies": {
"@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",
"@rollup/plugin-inject": "5.0.5",
"@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>
);
}
+1 -12
View File
@@ -1,4 +1,4 @@
import { globalStyle, style } from '@vanilla-extract/css';
import { style } from '@vanilla-extract/css';
import { DefaultReset, FocusOutline, color, config, toRem } from 'folds';
export const MessageBase = style({
@@ -77,14 +77,3 @@ export const ReactionsMore = style([
export const ReactionsTooltipText = style({
wordBreak: 'break-word',
});
// [Gitea #199] Long-press opens the message action sheet on touch screens. On
// iOS a long-press otherwise belongs to the system: a link/image callout, or
// the text-selection loupe, both of which race (and win against) the 450 ms
// press. No callout on message rows, and no selection while a finger is
// down; copying text is in the sheet.
globalStyle('[data-touch-actions]', { WebkitTouchCallout: 'none' });
globalStyle('[data-touch-actions][data-pressing], [data-touch-actions][data-pressing] *', {
userSelect: 'none',
WebkitUserSelect: 'none',
});
@@ -1,76 +0,0 @@
import React, { useEffect, useSyncExternalStore } from 'react';
import { Box, color, config, Icon, IconButton, Icons, Text } from 'folds';
import { useSetAtom } from 'jotai';
import {
destroyAllWidgetSessions,
destroyWidgetSession,
FLOATING_WIDGET,
floatingWidgetSessions,
subscribeWidgetSessions,
widgetSessionsVersion,
} from './widgetSessions';
import { useRoomNavigate } from '../../../hooks/useRoomNavigate';
import { widgetViewingAtom, widgetsPanelAtom } from '../../../state/widgetsPanel';
import { zIndices } from '../../../styles/zIndex';
/**
* [Gitea #205] Title bar for a widget kept on screen (MSC2931) after its room
* or the Widgets panel was left. The widget's own frame is positioned by
* widgetSessions.ts right below this bar. Mounted once for the signed-in
* client; unmounting (logout) stops every widget.
*/
export function FloatingWidget() {
useSyncExternalStore(subscribeWidgetSessions, widgetSessionsVersion);
const { navigateRoom } = useRoomNavigate();
const setPanel = useSetAtom(widgetsPanelAtom);
const setViewing = useSetAtom(widgetViewingAtom);
useEffect(() => () => destroyAllWidgetSessions(), []);
const session = floatingWidgetSessions()[0];
if (!session) return null;
const f = FLOATING_WIDGET;
const back = () => {
setViewing((prev) => ({ ...prev, [session.roomId]: session.widgetId }));
setPanel(true);
navigateRoom(session.roomId);
};
return (
<Box
role="region"
aria-label={`${session.name} (kept on screen)`}
alignItems="Center"
gap="100"
style={{
position: 'fixed',
right: f.right,
bottom: f.bottom + f.height,
width: f.width,
height: f.bar,
zIndex: zIndices.floatingWidget,
padding: `0 ${config.space.S100} 0 ${config.space.S300}`,
background: color.Surface.Container,
color: color.Surface.OnContainer,
borderRadius: '8px 8px 0 0',
boxShadow: '0 8px 24px rgba(0,0,0,0.35)',
}}
>
<Icon size="100" src={Icons.Category} />
<Text size="T300" truncate style={{ flexGrow: 1 }}>
{session.name}
</Text>
<IconButton size="300" radii="300" variant="Surface" aria-label="Back to room" onClick={back}>
<Icon size="100" src={Icons.ArrowGoLeft} />
</IconButton>
<IconButton
size="300"
radii="300"
variant="Surface"
aria-label={`Close ${session.name}`}
onClick={() => destroyWidgetSession(session.key)}
>
<Icon size="100" src={Icons.Cross} />
</IconButton>
</Box>
);
}
+125 -35
View File
@@ -1,48 +1,138 @@
import React, { useEffect, useRef, useSyncExternalStore } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import { Box, Icon, Icons, Text, color } from 'folds';
import { Room } from 'matrix-js-sdk';
import { Widget } from 'matrix-widget-api';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { WidgetPermissionPrompt } from './WidgetPermissionPrompt';
import {
attachWidgetSession,
detachWidgetSession,
getWidgetSession,
subscribeWidgetSessions,
widgetSessionKey,
widgetSessionsVersion,
} from './widgetSessions';
MatrixEvent,
MatrixEventEvent,
Room,
RoomEvent,
RoomEventHandlerMap,
RoomStateEvent,
} from 'matrix-js-sdk';
import { Capability, ClientWidgetApi, IRoomEvent, Widget } from 'matrix-widget-api';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { GeneralWidgetDriver } from './GeneralWidgetDriver';
import { WidgetPermissionRequest, isWidgetUrlSafe } from './widgetUtils';
import { restrictWidgetMessages } from '../../../plugins/widgetTransport';
import { WidgetPermissionPrompt } from './WidgetPermissionPrompt';
import { isFromFilteredTimelineSet } from '../../../utils/room';
type PendingAsk = {
requests: WidgetPermissionRequest[];
origin: string;
resolve: (allowed: Set<Capability>, remember: boolean) => void;
};
type RoomWidgetViewProps = {
room: Room;
widget: Widget;
};
// Shows one room widget in the Widgets panel. The widget itself runs in a
// session (widgetSessions.ts) whose sandboxed iframe lives in an app-level
// layer and is positioned over this placeholder, so a widget that asked to
// stay on screen can keep running in a floating window after the panel closes.
// Display-only access is granted silently; reading/sending in this room is
// asked for (WidgetPermissionPrompt) and, once granted, the room's live events
// are fed to the widget. Re-attaches only when the widget id or its (template)
// URL changes, so viewing a widget doesn't reload it on unrelated state updates.
// Hosts one room widget in a sandboxed iframe via ClientWidgetApi (so widgets
// that wait on the client handshake load). Display-only access is granted
// silently; reading/sending in this room is asked for (WidgetPermissionPrompt)
// and, once granted, the room's live events are fed to the widget.
// Re-mounts only when the widget id or its (template) URL changes — not on every
// unrelated room-state update — so viewing a widget doesn't reload constantly.
export function RoomWidgetView({ room, widget }: RoomWidgetViewProps) {
const mx = useMatrixClient();
const slotRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const widgetRef = useRef(widget);
widgetRef.current = widget;
const key = widgetSessionKey(room.roomId, widget.id);
useSyncExternalStore(subscribeWidgetSessions, widgetSessionsVersion);
const session = getWidgetSession(key);
const [blocked, setBlocked] = useState(false);
const [pendingAsk, setPendingAsk] = useState<PendingAsk>();
useEffect(() => {
const slot = slotRef.current;
if (!slot) return undefined;
attachWidgetSession(mx, room, widgetRef.current, slot);
return () => detachWidgetSession(key, slot);
}, [mx, room, key, widget.templateUrl]);
const container = containerRef.current;
if (!container) return undefined;
const current = widgetRef.current;
if (session?.blocked) {
const completeUrl = current.getCompleteUrl({
currentUserId: mx.getSafeUserId(),
widgetRoomId: room.roomId,
deviceId: mx.getDeviceId() ?? undefined,
baseUrl: mx.baseUrl,
});
// Security: never render a same-origin widget with allow-same-origin (a
// same-origin frame could break out of the sandbox against our own origin).
if (!isWidgetUrlSafe(completeUrl, window.location.origin)) {
setBlocked(true);
return undefined;
}
setBlocked(false);
const iframe = document.createElement('iframe');
iframe.title = current.name || 'Widget';
iframe.sandbox.value =
'allow-forms allow-scripts allow-same-origin allow-popups allow-downloads';
iframe.allow = 'autoplay; clipboard-write;';
iframe.src = completeUrl;
iframe.style.width = '100%';
iframe.style.height = '100%';
iframe.style.border = 'none';
container.append(iframe);
let origin = completeUrl;
try {
origin = new URL(completeUrl).host;
} catch {
// isWidgetUrlSafe already rejected unparsable URLs
}
let cancelAsk: (() => void) | undefined;
const driver = new GeneralWidgetDriver(
mx,
room.roomId,
{ id: current.id, url: current.templateUrl },
(requests) =>
new Promise((resolve) => {
let settled = false;
const done = (allowed: Set<Capability>, remember: boolean) => {
if (settled) return;
settled = true;
cancelAsk = undefined;
setPendingAsk(undefined);
resolve({ allowed, remember });
};
cancelAsk = () => done(new Set(), false);
setPendingAsk({ requests, origin, resolve: done });
}),
);
const clientApi = new ClientWidgetApi(current, iframe, driver);
// Only messages from this widget's own frame and origin are handled, so a
// widget can't impersonate another one (or the call).
restrictWidgetMessages(clientApi, iframe, current.origin);
clientApi.setViewedRoomId(room.roomId);
// Live room events for widgets that were allowed to see them
// (ClientWidgetApi drops anything the widget has no capability for).
const feed = (ev: MatrixEvent) => {
if (ev.getRoomId() !== room.roomId) return;
if (ev.isBeingDecrypted() || ev.isDecryptionFailure()) return;
clientApi.feedEvent(ev.getEffectiveEvent() as IRoomEvent).catch(() => undefined);
};
const onTimeline: RoomEventHandlerMap[RoomEvent.Timeline] = (ev, r, toStart, removed, data) => {
if (toStart || removed || !data?.liveEvent) return;
if (isFromFilteredTimelineSet(data)) return; // [Gitea #259] don't feed widgets each event twice
if (ev.isBeingDecrypted()) ev.once(MatrixEventEvent.Decrypted, feed);
else feed(ev);
};
const onState = (ev: MatrixEvent) => {
if (ev.getRoomId() !== room.roomId) return;
clientApi.feedStateUpdate(ev.getEffectiveEvent() as IRoomEvent).catch(() => undefined);
};
mx.on(RoomEvent.Timeline, onTimeline);
mx.on(RoomStateEvent.Events, onState);
return () => {
mx.off(RoomEvent.Timeline, onTimeline);
mx.off(RoomStateEvent.Events, onState);
cancelAsk?.();
clientApi.stop();
iframe.remove();
};
}, [mx, room.roomId, widget.id, widget.templateUrl]);
if (blocked) {
return (
<Box direction="Column" grow="Yes" alignItems="Center" justifyContent="Center" gap="200">
<Icon size="400" src={Icons.Warning} style={{ color: color.Warning.Main }} />
@@ -59,14 +149,14 @@ export function RoomWidgetView({ room, widget }: RoomWidgetViewProps) {
return (
<>
<Box ref={slotRef} grow="Yes" style={{ height: '100%', minHeight: 0 }} />
{session?.pendingAsk && (
<Box ref={containerRef} grow="Yes" style={{ height: '100%', minHeight: 0 }} />
{pendingAsk && (
<WidgetPermissionPrompt
widgetName={widget.name || 'Widget'}
origin={session.pendingAsk.origin}
origin={pendingAsk.origin}
addedBy={addedBy}
requests={session.pendingAsk.requests}
onDone={session.pendingAsk.resolve}
requests={pendingAsk.requests}
onDone={pendingAsk.resolve}
/>
)}
</>
+3 -50
View File
@@ -16,9 +16,7 @@ import {
config,
} from 'folds';
import { Room } from 'matrix-js-sdk';
import { useAtom } from 'jotai';
import classNames from 'classnames';
import { widgetViewingAtom } from '../../../state/widgetsPanel';
import * as css from './WidgetsPanel.css';
import { ContainerColor } from '../../../styles/ContainerColor.css';
import { RoomWidgetView } from './RoomWidgetView';
@@ -29,13 +27,7 @@ import { useRoomCreators } from '../../../hooks/useRoomCreators';
import { useRoomPermissions } from '../../../hooks/useRoomPermissions';
import { StateEvent } from '../../../../types/matrix/room';
import { sendStateEvent } from '../../../utils/room';
import {
generateWidgetId,
validateWidgetUrl,
widgetCompleteUrl,
widgetPopoutUrl,
WidgetUrlError,
} from './widgetUtils';
import { generateWidgetId, validateWidgetUrl, WidgetUrlError } from './widgetUtils';
const urlErrorMessage = (err: WidgetUrlError): string => {
switch (err) {
@@ -62,23 +54,12 @@ export function WidgetsPanel({ room, requestClose }: WidgetsPanelProps) {
const permissions = useRoomPermissions(creators, powerLevels);
const canModify = permissions.stateEvent(StateEvent.Widget, mx.getSafeUserId());
const [viewingByRoom, setViewingByRoom] = useAtom(widgetViewingAtom);
const viewingId = viewingByRoom[room.roomId] ?? null;
const setViewingId = (id: string | null) =>
setViewingByRoom((prev) => ({ ...prev, [room.roomId]: id }));
const [viewingId, setViewingId] = useState<string | null>(null);
const [adding, setAdding] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string>();
const viewing = widgets.find((w) => w.id === viewingId) ?? null;
// [Gitea #205] Open a widget in its own window, beside the chat. It runs
// standalone there (no widget API), as in Element's popout.
const popoutUrl = (widget: (typeof widgets)[number]) =>
widgetPopoutUrl(widgetCompleteUrl(widget, mx, room.roomId), window.location.origin);
const popout = (widget: (typeof widgets)[number]) => {
const url = popoutUrl(widget);
if (url) window.open(url, '_blank', 'noopener,noreferrer');
};
const handleAdd: FormEventHandler<HTMLFormElement> = async (evt) => {
evt.preventDefault();
@@ -163,13 +144,7 @@ export function WidgetsPanel({ room, requestClose }: WidgetsPanelProps) {
<Box grow="Yes" className={css.WidgetsPanelContent}>
{viewing ? (
<Box grow="Yes" direction="Column">
<Box
shrink="No"
alignItems="Center"
justifyContent="SpaceBetween"
gap="200"
style={{ padding: config.space.S200 }}
>
<Box shrink="No" style={{ padding: config.space.S200 }}>
<Button
variant="Secondary"
fill="Soft"
@@ -182,17 +157,6 @@ export function WidgetsPanel({ room, requestClose }: WidgetsPanelProps) {
{viewing.name || 'Widget'}
</Text>
</Button>
{popoutUrl(viewing) && (
<IconButton
size="300"
radii="300"
variant="Background"
aria-label={`Open ${viewing.name || 'widget'} in a new window`}
onClick={() => popout(viewing)}
>
<Icon size="100" src={Icons.External} />
</IconButton>
)}
</Box>
<RoomWidgetView room={room} widget={viewing} />
</Box>
@@ -220,17 +184,6 @@ export function WidgetsPanel({ room, requestClose }: WidgetsPanelProps) {
{widget.name || widget.templateUrl}
</Text>
</Box>
{popoutUrl(widget) && (
<IconButton
size="300"
radii="300"
variant="Background"
aria-label={`Open ${widget.name || 'widget'} in a new window`}
onClick={() => popout(widget)}
>
<Icon size="100" src={Icons.External} />
</IconButton>
)}
{canModify && (
<IconButton
size="300"
@@ -1,341 +0,0 @@
import {
MatrixClient,
MatrixEvent,
MatrixEventEvent,
Room,
RoomEvent,
RoomEventHandlerMap,
RoomStateEvent,
} from 'matrix-js-sdk';
import {
Capability,
ClientWidgetApi,
IRoomEvent,
IWidgetApiRequest,
MatrixCapabilities,
Widget,
WidgetApiFromWidgetAction,
} from 'matrix-widget-api';
import { GeneralWidgetDriver } from './GeneralWidgetDriver';
import { WidgetPermissionRequest, isWidgetUrlSafe, widgetCompleteUrl } from './widgetUtils';
import { restrictWidgetMessages } from '../../../plugins/widgetTransport';
import { isFromFilteredTimelineSet } from '../../../utils/room';
import { zIndices } from '../../../styles/zIndex';
/**
* [Gitea #205] Running room widgets, kept outside React so a widget that asked
* to stay on screen (MSC2931 `set_always_on_screen`) survives closing the
* Widgets panel or switching rooms.
*
* Moving an iframe in the DOM reloads it, so every widget frame lives from the
* start in one app-level fixed layer. While the panel shows the widget, the
* frame is positioned over the panel's placeholder (`attach`), so it looks and
* behaves as before. When the placeholder goes away (`detach`) the widget is
* torn down, unless it is always-on-screen: then the same live frame moves to
* a small floating window (see FloatingWidget) until it is closed, the widget
* says it no longer needs to be on screen, or it is removed from the room.
*/
export type PendingAsk = {
requests: WidgetPermissionRequest[];
origin: string;
resolve: (allowed: Set<Capability>, remember: boolean) => void;
};
export type WidgetSession = {
readonly key: string;
readonly roomId: string;
readonly widgetId: string;
readonly name: string;
readonly templateUrl: string;
/** The URL is on this app's own origin: never rendered. */
readonly blocked: boolean;
alwaysOnScreen: boolean;
floating: boolean;
pendingAsk?: PendingAsk;
};
type Internal = WidgetSession & {
frame: HTMLDivElement;
slot: HTMLElement | null;
raf: number | undefined;
stop: () => void;
};
/** The floating window's geometry (FloatingWidget draws its title bar above). */
export const FLOATING_WIDGET = { right: 16, bottom: 96, width: 360, height: 240, bar: 36 };
const DOCKED_Z = 600; // just above the side panel (500 on phones), below popovers/modals
const sessions = new Map<string, Internal>();
const listeners = new Set<() => void>();
let version = 0;
const notify = () => {
version += 1;
listeners.forEach((l) => l());
};
export const subscribeWidgetSessions = (l: () => void): (() => void) => {
listeners.add(l);
return () => listeners.delete(l);
};
export const widgetSessionsVersion = (): number => version;
export const widgetSessionKey = (roomId: string, widgetId: string): string =>
`${roomId}|${widgetId}`;
export const getWidgetSession = (key: string): WidgetSession | undefined => sessions.get(key);
export const floatingWidgetSessions = (): WidgetSession[] =>
[...sessions.values()].filter((s) => s.floating);
let layerEl: HTMLDivElement | undefined;
const layer = (): HTMLDivElement => {
if (!layerEl || !layerEl.isConnected) {
layerEl = document.createElement('div');
layerEl.id = 'lotus-widget-layer';
// The layer itself is click-through; only the frames inside take input.
Object.assign(layerEl.style, { position: 'fixed', inset: '0', pointerEvents: 'none' });
document.body.append(layerEl);
}
return layerEl;
};
const hideFrame = (s: Internal) => {
s.frame.style.visibility = 'hidden';
};
const placeDocked = (s: Internal) => {
const slot = s.slot;
if (!slot) return;
const r = slot.getBoundingClientRect();
if (r.width === 0 || r.height === 0) {
hideFrame(s);
} else {
Object.assign(s.frame.style, {
visibility: 'visible',
left: `${r.left}px`,
top: `${r.top}px`,
width: `${r.width}px`,
height: `${r.height}px`,
right: '',
bottom: '',
borderRadius: '0',
boxShadow: 'none',
zIndex: String(DOCKED_Z),
});
}
s.raf = requestAnimationFrame(() => placeDocked(s));
};
const placeFloating = (s: Internal) => {
const f = FLOATING_WIDGET;
Object.assign(s.frame.style, {
visibility: 'visible',
left: '',
top: '',
right: `${f.right}px`,
bottom: `${f.bottom}px`,
width: `${f.width}px`,
height: `${f.height}px`,
borderRadius: '0 0 8px 8px',
boxShadow: '0 8px 24px rgba(0,0,0,0.35)',
zIndex: String(zIndices.floatingWidget),
});
};
const stopPlacing = (s: Internal) => {
if (s.raf !== undefined) cancelAnimationFrame(s.raf);
s.raf = undefined;
};
export const destroyWidgetSession = (key: string): void => {
const s = sessions.get(key);
if (!s) return;
sessions.delete(key);
stopPlacing(s);
s.stop();
s.frame.remove();
notify();
};
export const destroyAllWidgetSessions = (): void => {
[...sessions.keys()].forEach(destroyWidgetSession);
};
/** Start (or reuse) the widget's session and dock its frame over `slot`. */
export function attachWidgetSession(
mx: MatrixClient,
room: Room,
widget: Widget,
slot: HTMLElement,
): WidgetSession {
const key = widgetSessionKey(room.roomId, widget.id);
let s = sessions.get(key);
if (s && s.templateUrl !== widget.templateUrl) {
destroyWidgetSession(key);
s = undefined;
}
if (!s) s = startSession(mx, room, widget, key);
stopPlacing(s);
s.slot = slot;
s.floating = false;
if (!s.blocked) placeDocked(s);
notify();
return s;
}
/** The panel stopped showing the widget: float it, or tear it down. */
export function detachWidgetSession(key: string, slot: HTMLElement): void {
const s = sessions.get(key);
if (!s || s.slot !== slot) return;
s.slot = null;
stopPlacing(s);
if (!s.alwaysOnScreen || s.blocked) {
destroyWidgetSession(key);
return;
}
// One floating widget at a time: a newer one replaces an older one.
floatingWidgetSessions()
.filter((o) => o.key !== key)
.forEach((o) => destroyWidgetSession(o.key));
// Nobody is there to answer a permission prompt any more.
s.pendingAsk?.resolve(new Set(), false);
s.floating = true;
placeFloating(s);
notify();
}
function startSession(mx: MatrixClient, room: Room, widget: Widget, key: string): Internal {
const frame = document.createElement('div');
Object.assign(frame.style, {
position: 'fixed',
pointerEvents: 'auto',
overflow: 'hidden',
visibility: 'hidden',
});
const completeUrl = widgetCompleteUrl(widget, mx, room.roomId);
const blocked = !isWidgetUrlSafe(completeUrl, window.location.origin);
const s: Internal = {
key,
roomId: room.roomId,
widgetId: widget.id,
name: widget.name || 'Widget',
templateUrl: widget.templateUrl,
blocked,
alwaysOnScreen: false,
floating: false,
frame,
slot: null,
raf: undefined,
stop: () => undefined,
};
sessions.set(key, s);
if (blocked) return s;
// Security: never render a same-origin widget with allow-same-origin (a
// same-origin frame could break out of the sandbox against our own origin).
const iframe = document.createElement('iframe');
iframe.title = widget.name || 'Widget';
iframe.sandbox.value = 'allow-forms allow-scripts allow-same-origin allow-popups allow-downloads';
iframe.allow = 'autoplay; clipboard-write;';
iframe.src = completeUrl;
Object.assign(iframe.style, { width: '100%', height: '100%', border: 'none' });
frame.append(iframe);
layer().append(frame);
let origin = completeUrl;
try {
origin = new URL(completeUrl).host;
} catch {
// isWidgetUrlSafe already rejected unparsable URLs
}
let cancelAsk: (() => void) | undefined;
const driver = new GeneralWidgetDriver(
mx,
room.roomId,
{ id: widget.id, url: widget.templateUrl },
(requests) =>
new Promise((resolve) => {
let settled = false;
const done = (allowed: Set<Capability>, remember: boolean) => {
if (settled) return;
settled = true;
cancelAsk = undefined;
s.pendingAsk = undefined;
notify();
resolve({ allowed, remember });
};
cancelAsk = () => done(new Set(), false);
// A floating widget has nobody to ask: grant nothing extra.
if (s.floating) {
done(new Set(), false);
return;
}
s.pendingAsk = { requests, origin, resolve: done };
notify();
}),
);
const clientApi = new ClientWidgetApi(widget, iframe, driver);
// Only messages from this widget's own frame and origin are handled, so a
// widget can't impersonate another one (or the call).
restrictWidgetMessages(clientApi, iframe, widget.origin);
clientApi.setViewedRoomId(room.roomId);
// MSC2931: the widget asks to stay visible when the user navigates away.
const onAlwaysOnScreen = (ev: CustomEvent<IWidgetApiRequest>) => {
ev.preventDefault();
if (!clientApi.hasCapability(MatrixCapabilities.AlwaysOnScreen)) {
clientApi.transport.reply(ev.detail, {
error: { message: 'Missing capability' },
});
return;
}
const value = (ev.detail.data as { value?: unknown } | undefined)?.value === true;
s.alwaysOnScreen = value;
clientApi.transport.reply(ev.detail, {});
// Floating only because it asked: once it no longer needs to be, go.
if (!value && s.floating) destroyWidgetSession(key);
else notify();
};
clientApi.on(`action:${WidgetApiFromWidgetAction.UpdateAlwaysOnScreen}`, onAlwaysOnScreen);
// Live room events for widgets that were allowed to see them
// (ClientWidgetApi drops anything the widget has no capability for).
const feed = (ev: MatrixEvent) => {
if (ev.getRoomId() !== room.roomId) return;
if (ev.isBeingDecrypted() || ev.isDecryptionFailure()) return;
clientApi.feedEvent(ev.getEffectiveEvent() as IRoomEvent).catch(() => undefined);
};
const onTimeline: RoomEventHandlerMap[RoomEvent.Timeline] = (ev, r, toStart, removed, data) => {
if (toStart || removed || !data?.liveEvent) return;
if (isFromFilteredTimelineSet(data)) return; // [Gitea #259] don't feed widgets each event twice
if (ev.isBeingDecrypted()) ev.once(MatrixEventEvent.Decrypted, feed);
else feed(ev);
};
const onState = (ev: MatrixEvent) => {
if (ev.getRoomId() !== room.roomId) return;
// The widget was removed from the room while it was floating.
if (
s.floating &&
(ev.getType() === 'im.vector.modular.widgets' || ev.getType() === 'm.widget') &&
ev.getStateKey() === widget.id &&
!ev.getContent()?.url
) {
destroyWidgetSession(key);
return;
}
clientApi.feedStateUpdate(ev.getEffectiveEvent() as IRoomEvent).catch(() => undefined);
};
mx.on(RoomEvent.Timeline, onTimeline);
mx.on(RoomStateEvent.Events, onState);
s.stop = () => {
mx.off(RoomEvent.Timeline, onTimeline);
mx.off(RoomStateEvent.Events, onState);
clientApi.off(`action:${WidgetApiFromWidgetAction.UpdateAlwaysOnScreen}`, onAlwaysOnScreen);
cancelAsk?.();
clientApi.stop();
};
return s;
}
@@ -8,7 +8,6 @@ import {
generateWidgetId,
classifyWidgetCapabilities,
loadWidgetConsent,
widgetPopoutUrl,
} from './widgetUtils';
const APP = 'https://chat.lotusguild.org';
@@ -123,17 +122,3 @@ test('[#266] space structure, call membership and Lotus room policy can never be
assert.equal(auto.size, 0);
assert.deepEqual(ask, []);
});
test('[#205] widgetPopoutUrl: only cross-origin https opens in a new window', () => {
// Built at runtime so the no-script-url lint rule doesn't flag the test data.
const scriptUrl = ['javascript', 'alert(1)'].join(':');
assert.equal(
widgetPopoutUrl('https://pad.example.org/p/room', APP),
'https://pad.example.org/p/room',
);
assert.equal(widgetPopoutUrl('https://chat.lotusguild.org/evil', APP), null, 'same origin');
assert.equal(widgetPopoutUrl('http://pad.example.org/', APP), null, 'not https');
assert.equal(widgetPopoutUrl(scriptUrl, APP), null, 'script URL');
assert.equal(widgetPopoutUrl('data:text/html,<b>x</b>', APP), null, 'data:');
assert.equal(widgetPopoutUrl('not a url', APP), null);
});
@@ -1,10 +1,8 @@
import type { MatrixClient } from 'matrix-js-sdk';
import {
Capability,
EventDirection,
EventKind,
MatrixCapabilities,
type Widget,
WidgetEventCapability,
} from 'matrix-widget-api';
@@ -50,29 +48,6 @@ export const isWidgetUrlSafe = (completeUrl: string, appOrigin: string): boolean
}
};
/** The widget's URL with its template variables filled in for this viewer. */
export const widgetCompleteUrl = (widget: Widget, mx: MatrixClient, roomId: string): string =>
widget.getCompleteUrl({
currentUserId: mx.getSafeUserId(),
widgetRoomId: roomId,
deviceId: mx.getDeviceId() ?? undefined,
baseUrl: mx.baseUrl,
});
/**
* [Gitea #205] The URL to open a widget in its own window, or null when it
* mustn't be opened: only cross-origin https (room state can carry anything,
* e.g. a `javascript:` URL from another client, whose origin is "null").
*/
export const widgetPopoutUrl = (completeUrl: string, appOrigin: string): string | null => {
try {
const u = new URL(completeUrl);
return u.protocol === 'https:' && u.origin !== appOrigin ? u.href : null;
} catch {
return null;
}
};
export const generateWidgetId = (): string =>
`lotus_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
@@ -1749,6 +1749,7 @@ function Calls() {
);
const [pttMode, setPttMode] = useSetting(settingsAtom, 'pttMode');
const [callSummaryToast, setCallSummaryToast] = useSetting(settingsAtom, 'callSummaryToast');
const [callRejoin, setCallRejoin] = useSetting(settingsAtom, 'callRejoinAfterRestart');
const [haptics, setHaptics] = useSetting(settingsAtom, 'hapticFeedback');
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() && (
<SettingTile
title="Haptic Feedback"
+27 -5
View File
@@ -1,6 +1,6 @@
import React, { useEffect, useRef, CSSProperties } from 'react';
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 { toastQueueAtom, dismissToastAtom, ToastNotif } from '../../state/toast';
import { useSetting } from '../../state/hooks/settings';
@@ -43,13 +43,17 @@ function ToastCard({ toast }: ToastCardProps) {
useEffect(() => {
if (toast.sticky) return;
timerRef.current = setTimeout(() => {
dismiss(toast.id);
}, 4000);
timerRef.current = setTimeout(
() => {
dismiss(toast.id);
},
// [Gitea #253] A toast with a button stays long enough to use it.
toast.actionLabel ? 10_000 : 4000,
);
return () => {
if (timerRef.current !== null) clearTimeout(timerRef.current);
};
}, [dismiss, toast.id, toast.sticky]);
}, [dismiss, toast.id, toast.sticky, toast.actionLabel]);
const handleCardClick = () => {
if (toast.onClick) {
@@ -212,6 +216,24 @@ function ToastCard({ toast }: ToastCardProps) {
</div>
<div style={bodyStyle}>{toast.body}</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>
);
}
+18 -2
View File
@@ -3,7 +3,10 @@ import { useSetAtom } from 'jotai';
import { CallEmbed } from '../plugins/call';
import { useCallHangupEvent } from './useCallEmbed';
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
// 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 {
const setToast = useSetAtom(toastQueueAtom);
const setSummaryView = useSetAtom(callSummaryViewAtom);
const [callSummaryToast] = useSetting(settingsAtom, 'callSummaryToast');
const fired = useRef(false);
useEffect(() => {
@@ -25,14 +30,25 @@ export function useCallEndedToast(embed: CallEmbed): void {
useCallHangupEvent(embed, () => {
if (fired.current || !embed.joined || embed.joinedAt === undefined) return;
fired.current = true;
if (!callSummaryToast) return;
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 = () =>
setToast({
id: `call-ended-${Date.now()}`,
displayName: 'Lotus Chat',
body: describeCallEnd(durationMs, embed.lastSummary),
roomName: embed.room.name ?? 'Voice call',
roomName,
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();
else setTimeout(toast, SUMMARY_GRACE_MS);
+4 -28
View File
@@ -17,37 +17,22 @@ export function useLongPress(onLongPress: (x: number, y: number) => void) {
const timer = useRef<number | undefined>(undefined);
const start = useRef<{ x: number; y: number } | null>(null);
const fired = useRef(false);
// [Gitea #199] The element being pressed carries `data-pressing` (set on the
// DOM directly, no re-render) so iOS can't start a text selection under
// the finger; see `[data-touch-actions]` in room/message/styles.css.ts.
const pressed = useRef<HTMLElement | null>(null);
const stopTimer = useCallback(() => {
const clear = useCallback(() => {
if (timer.current !== undefined) window.clearTimeout(timer.current);
timer.current = undefined;
start.current = null;
}, []);
// The finger is up (or the gesture was cancelled): only now drop
// `data-pressing`, so the system selection can't start under a finger that
// is still down after the sheet opened.
const clear = useCallback(() => {
stopTimer();
pressed.current?.removeAttribute('data-pressing');
pressed.current = null;
}, [stopTimer]);
const onTouchStart = useCallback(
(evt: React.TouchEvent) => {
if (!coarse || evt.touches.length !== 1) return;
const t = evt.touches[0];
start.current = { x: t.clientX, y: t.clientY };
fired.current = false;
pressed.current = evt.currentTarget as HTMLElement;
pressed.current.setAttribute('data-pressing', '');
timer.current = window.setTimeout(() => {
const s = start.current;
stopTimer();
clear();
if (!s) return;
fired.current = true;
window.setTimeout(() => {
@@ -56,7 +41,7 @@ export function useLongPress(onLongPress: (x: number, y: number) => void) {
onLongPress(s.x, s.y);
}, LONG_PRESS_MS);
},
[coarse, stopTimer, onLongPress],
[coarse, clear, onLongPress],
);
const onTouchMove = useCallback(
@@ -87,16 +72,7 @@ export function useLongPress(onLongPress: (x: number, y: number) => void) {
return {
coarse,
handlers: coarse
? {
// [Gitea #199] Marks the element for the iOS hardening CSS.
'data-touch-actions': '',
onTouchStart,
onTouchMove,
onTouchEnd,
onTouchCancel: clear,
}
: {},
handlers: coarse ? { onTouchStart, onTouchMove, onTouchEnd, onTouchCancel: clear } : {},
/** True for ~1 s after a long-press fired (drop the Android contextmenu echo). */
suppressContextMenu: fired,
};
+2
View File
@@ -25,6 +25,7 @@ import { useCompositionEndTracking } from '../hooks/useComposingCheck';
import { settingsAtom } from '../state/settings';
import { isWithinTimeWindow } from '../utils/timeWindow';
import { LotusToastContainer } from '../features/toast/LotusToastContainer';
import { CallSummaryDialog } from '../features/call-summary/CallSummaryDialog';
import { useTauriNotificationBadge } from '../hooks/useTauriNotificationBadge';
import { useSessionAppeared } from '../hooks/useSessionAppeared';
import { useTauriWindowChrome } from '../hooks/useTauriWindowChrome';
@@ -248,6 +249,7 @@ function App() {
<SeasonalEffect />
<NightLightOverlay />
<LotusToastContainer />
<CallSummaryDialog />
</JotaiProvider>
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
-2
View File
@@ -68,7 +68,6 @@ import { SHARE_PAGE_PATH } from '../../swShare';
import { RouteError } from './RouteError';
import { CallStatusRenderer } from './CallStatusRenderer';
import { CallEmbedProvider } from '../components/CallEmbedProvider';
import { FloatingWidget } from '../features/room/widgets/FloatingWidget';
const Share = React.lazy(() => import('./client/share/Share').then((m) => ({ default: m.Share })));
@@ -198,7 +197,6 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
<Outlet />
</ClientLayout>
<CallStatusRenderer />
<FloatingWidget />
</CallEmbedProvider>
<React.Suspense fallback={null}>
<SearchModalRenderer />
+27 -1
View File
@@ -26,13 +26,14 @@ import {
ElementMediaStateDetail,
ElementWidgetActions,
} from './types';
import { CallControl } from './CallControl';
import { CallControl, CallControlEvent } from './CallControl';
import { CallControlState } from './CallControlState';
import { verifyDenoiseAssets } from './denoiseSmokeCheck';
import { canDelegateCapability } from './utils';
import { getCallPageUrl } from './callPageUrl';
import { restrictWidgetMessages } from '../widgetTransport';
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
// 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;
poorMs: number;
verdict: 'good' | 'fair' | 'poor' | 'unknown';
/** [Gitea #253] Breakdown for the Details card (fork lotus.29+). */
details?: LotusCallSummaryDetails;
}
export class CallEmbed {
@@ -78,6 +81,25 @@ export class CallEmbed {
/** [Gitea #143] The fork's end-of-call summary, once it arrives. */
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
// whether the embed was already torn down by the normal Close/Hangup echo.
public disposed = false;
@@ -339,6 +361,8 @@ export class CallEmbed {
const controlState = initialControlState ?? new CallControlState(true, false, true);
this.control = new CallControl(controlState, call, iframe);
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.
iframe.onerror = () => {
this.settleLoad('iframe');
@@ -434,6 +458,7 @@ export class CallEmbed {
reconnects: data.reconnects ?? 0,
poorMs: data.poorMs ?? 0,
verdict: data.verdict ?? 'unknown',
details: parseCallSummaryDetails((data as { details?: unknown }).details),
};
}
}),
@@ -623,6 +648,7 @@ export class CallEmbed {
}
this.joined = true;
this.joinedAt = Date.now();
this.trackDeafened();
// EC ignores io.element.device_mute before join; re-apply desired state now that EC is live
this.control.forceState(this.initialState);
}
+6
View File
@@ -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);
+3
View File
@@ -310,6 +310,8 @@ export interface Settings {
screenshareFramerate: ScreenshareFramerate;
// [P5-15] In-call soundboard
soundboardEnabled: boolean;
// [Gitea #253] The call-ended toast (with its Details card).
callSummaryToast: boolean;
soundboardVolume: number; // 0–100
seasonalThemeOverride: SeasonalThemeOverride;
@@ -429,6 +431,7 @@ const defaultSettings: Settings = {
screenshareBitrate: 'auto',
screenshareFramerate: 'auto',
soundboardEnabled: true,
callSummaryToast: true,
soundboardVolume: 80,
seasonalThemeOverride: 'auto',
+2
View File
@@ -13,6 +13,8 @@ export type ToastNotif = {
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
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
};
-4
View File
@@ -2,7 +2,3 @@ import { atom } from 'jotai';
// Whether the room's Widgets side-panel is open (mirrors mediaGalleryAtom).
export const widgetsPanelAtom = atom<boolean>(false);
// [Gitea #205] Which widget the panel is showing, per room, so a floating
// widget's "Back to room" can open the panel on it.
export const widgetViewingAtom = atom<Record<string, string | null>>({});
-2
View File
@@ -5,8 +5,6 @@
* z-index values and is intentionally not listed here.
*/
export const zIndices = {
/** [Gitea #205] A room widget kept on screen after leaving its room. */
floatingWidget: 9980,
/** In-call incoming-call banner — below seasonal/night-light/modals. */
inCallBanner: 9990,
/** Seasonal particle effect — below the night-light tint so particles tint. */
+100 -1
View File
@@ -1,6 +1,11 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { describeCallDuration, describeCallEnd } from './callSummary';
import {
describeCallDuration,
describeCallEnd,
describeCallDetails,
parseCallSummaryDetails,
} from './callSummary';
describe('describeCallDuration', () => {
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']);
});
+120
View File
@@ -32,3 +32,123 @@ export function describeCallEnd(durationMs: number, summary?: LotusCallSummary):
}
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;
}