feat(calls): render m.call.member events as one aggregate chat bubble per call aligned to initiator side

This commit is contained in:
v.lagerev 2026-05-13 15:57:23 +03:00
parent c9ea22d2d4
commit 9ec670b8cf
5 changed files with 308 additions and 48 deletions

View file

@ -428,7 +428,15 @@
"in_call": "In call",
"in_call_count": "{{count}} in call",
"connecting": "Connecting…",
"open_call_room": "Open call room"
"open_call_room": "Open call room",
"bubble_outgoing": "Outgoing call",
"bubble_incoming": "Incoming call",
"bubble_missed": "Missed call",
"bubble_cancelled": "Cancelled call",
"bubble_ongoing": "Ongoing call",
"bubble_in_progress": "In progress…",
"duration_minutes_seconds": "{{minutes}} min {{seconds}} sec",
"duration_seconds": "{{seconds}} sec"
},
"Room": {
"drag_to_close": "Drag up to close",

View file

@ -432,7 +432,15 @@
"in_call": "В звонке",
"in_call_count": "{{count}} в звонке",
"connecting": "Соединение…",
"open_call_room": "Открыть чат звонка"
"open_call_room": "Открыть чат звонка",
"bubble_outgoing": "Исходящий звонок",
"bubble_incoming": "Входящий звонок",
"bubble_missed": "Пропущенный звонок",
"bubble_cancelled": "Отменённый звонок",
"bubble_ongoing": "Идёт звонок",
"bubble_in_progress": "Идёт сейчас…",
"duration_minutes_seconds": "{{minutes}} мин {{seconds}} сек",
"duration_seconds": "{{seconds}} сек"
},
"Room": {
"drag_to_close": "Потянуть вверх чтобы закрыть",

View file

@ -40,7 +40,7 @@ import {
} from 'folds';
import { isKeyHotkey } from 'is-hotkey';
import { Opts as LinkifyOpts } from 'linkifyjs';
import { Trans, useTranslation } from 'react-i18next';
import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router-dom';
import { getMxIdLocalPart } from '../../utils/matrix';
import { useMatrixClient } from '../../hooks/useMatrixClient';
@ -88,6 +88,8 @@ import {
Reactions,
Message,
Event,
CallMessage,
CallAggregate,
EncryptedContent,
useMessageInteractionHandlers,
} from './message';
@ -1251,6 +1253,81 @@ export function RoomTimeline({
const { t } = useTranslation();
// Group `m.call.member` (StateEvent.GroupCallMemberPrefix) events by their
// `call_id` and pick one anchor event per call — the earliest join, whose
// sender is the call initiator. Only the anchor renders a bubble; all
// other join/leave events for the same call_id collapse into it.
//
// The walk is per-render and linear in #call-member events across all
// linked timelines (rare), so it's effectively free. Keeping it inline
// (no useMemo) matches the existing `streamRenderableItemHasBefore`
// IIFE pattern just below.
const callAnchors: Map<string, CallAggregate> = (() => {
type CallScan = {
anchorEventId: string;
startTs: number;
endTs: number;
anchorSenderId: string;
participants: Set<string>;
// state_key (memberId + device) -> is currently joined
memberStates: Map<string, boolean>;
};
const scans = new Map<string, CallScan>();
for (const tl of timeline.linkedTimelines) {
const events = tl.getEvents();
for (const ev of events) {
if (ev.getType() !== StateEvent.GroupCallMemberPrefix) continue;
const content = ev.getContent<SessionMembershipData>();
const prevContent = ev.getPrevContent() as Partial<SessionMembershipData>;
const callId =
typeof content.call_id === 'string'
? content.call_id
: typeof prevContent.call_id === 'string'
? prevContent.call_id
: null;
if (callId == null) continue;
const ts = ev.getTs();
const isJoin = !!content.application;
const sender = ev.getSender() ?? '';
const stateKey = ev.getStateKey() ?? '';
const evId = ev.getId() ?? '';
let scan = scans.get(callId);
if (!scan) {
scan = {
anchorEventId: evId,
startTs: ts,
endTs: ts,
anchorSenderId: sender,
participants: new Set(),
memberStates: new Map(),
};
scans.set(callId, scan);
}
if (ts < scan.startTs) {
scan.startTs = ts;
scan.anchorEventId = evId;
scan.anchorSenderId = sender;
}
if (ts > scan.endTs) scan.endTs = ts;
if (isJoin && sender) scan.participants.add(sender);
scan.memberStates.set(stateKey, isJoin);
}
}
const anchors = new Map<string, CallAggregate>();
for (const [callId, scan] of scans) {
const ongoing = Array.from(scan.memberStates.values()).some((v) => v);
anchors.set(scan.anchorEventId, {
callId,
startTs: scan.startTs,
endTs: ongoing ? null : scan.endTs,
ongoing,
participants: Array.from(scan.participants),
anchorSenderId: scan.anchorSenderId,
});
}
return anchors;
})();
const renderMatrixEvent = useMatrixEventRenderer<
[string, MatrixEvent, number, EventTimelineSet, boolean, boolean, boolean]
>(
@ -1820,57 +1897,30 @@ export function RoomTimeline({
streamRailStart,
streamRailEnd
) => {
// One aggregate bubble per call — only the call's anchor event
// renders, all other join/leave events for the same call_id are
// collapsed (null). `callAnchors` is computed above and mirrored
// in `isRenderableTimelineEvent` so rail-endpoints agree.
const aggregate = callAnchors.get(mEventId);
if (!aggregate) return null;
const highlighted = focusItem?.index === item && focusItem.highlight;
const senderId = mEvent.getSender() ?? '';
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
const content = mEvent.getContent<SessionMembershipData>();
const prevContent = mEvent.getPrevContent();
const callJoined = content.application;
if (callJoined && 'application' in prevContent) {
return null;
}
const timeJSX = (
<Time
ts={mEvent.getTs()}
compact
/>
);
return (
<Event
<CallMessage
key={mEvent.getId()}
data-message-item={item}
data-message-id={mEventId}
room={room}
mEvent={mEvent}
aggregate={aggregate}
highlight={highlighted}
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
hideReadReceipts={hideActivity}
showDeveloperTools={showDeveloperTools}
>
<EventContent
time={timeJSX}
railStart={streamRailStart}
railEnd={streamRailEnd}
layout={messageLayout}
channelHeaderInBubble={channelHeaderInBubble}
iconSrc={callJoined ? Icons.Phone : Icons.PhoneDown}
content={
<Box grow="Yes" direction="Column">
<Text size="T300" priority="300">
<Trans
i18nKey={callJoined ? 'Room.member_joined_call' : 'Room.member_ended_call'}
values={{ user: senderName }}
components={{ bold: <b /> }}
/>
</Text>
</Box>
}
/>
</Event>
streamRailStart={streamRailStart}
streamRailEnd={streamRailEnd}
layout={messageLayout}
channelHeaderInBubble={channelHeaderInBubble}
/>
);
},
},
@ -2018,11 +2068,10 @@ export function RoomTimeline({
}
if (eventType === StateEvent.GroupCallMemberPrefix) {
const content = event.getContent<SessionMembershipData>();
const prevContent = event.getPrevContent();
const callJoined = content.application;
if (callJoined && 'application' in prevContent) return false;
return true;
// Mirror the renderer: only the per-call anchor event is renderable;
// all other joins/leaves for the same call_id collapse into the
// aggregate bubble. Rail-endpoint scan must agree with the renderer.
return callAnchors.has(event.getId() ?? '');
}
if (

View file

@ -0,0 +1,194 @@
import React from 'react';
import { Box, Icon, Icons, Text, color } from 'folds';
import { MatrixEvent, Room } from 'matrix-js-sdk';
import { useTranslation } from 'react-i18next';
import {
ChannelLayout,
ChannelMessageAvatar,
StreamLayout,
Time,
Username,
UsernameBold,
} from '../../../components/message';
import { Event } from './Message';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useDotColor } from '../../../hooks/useDotColor';
import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
import { getMemberDisplayName } from '../../../utils/room';
import { getMxIdLocalPart } from '../../../utils/matrix';
export type CallAggregate = {
callId: string;
startTs: number;
endTs: number | null;
ongoing: boolean;
// unique sender mxids that joined the call (any time)
participants: string[];
// first joiner — call initiator
anchorSenderId: string;
};
export type CallMessageProps = {
room: Room;
mEvent: MatrixEvent;
aggregate: CallAggregate;
highlight: boolean;
canDelete?: boolean;
hideReadReceipts?: boolean;
showDeveloperTools?: boolean;
streamRailStart?: boolean;
streamRailEnd?: boolean;
layout?: 'stream' | 'channel';
channelHeaderInBubble?: boolean;
// Spread onto Event wrapper (e.g. data-message-item, data-message-id)
// so the virtual paginator can locate the row.
[dataAttr: `data-${string}`]: string | number | undefined;
};
function formatDuration(t: ReturnType<typeof useTranslation>['t'], ms: number): string {
const total = Math.max(0, Math.round(ms / 1000));
const minutes = Math.floor(total / 60);
const seconds = total % 60;
if (minutes === 0) {
return t('Call.duration_seconds', { seconds });
}
return t('Call.duration_minutes_seconds', { minutes, seconds });
}
export function CallMessage({
room,
mEvent,
aggregate,
highlight,
canDelete,
hideReadReceipts,
showDeveloperTools,
streamRailStart,
streamRailEnd,
layout = 'stream',
channelHeaderInBubble,
...rest
}: CallMessageProps) {
const { t } = useTranslation();
const mx = useMatrixClient();
const isMobile = useScreenSizeContext() === ScreenSize.Mobile;
const senderId = aggregate.anchorSenderId;
const isOwnMessage = !!mx.getUserId() && senderId === mx.getUserId();
const senderName =
getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId) || senderId;
const dot = useDotColor(room, mEvent, true, hideReadReceipts);
// 1-participant call = the other side never joined.
// Caller's view → "Cancelled"; callee's view → "Missed".
const wasAnswered = aggregate.participants.length >= 2;
const titleKey = aggregate.ongoing
? 'Call.bubble_ongoing'
: wasAnswered
? isOwnMessage
? 'Call.bubble_outgoing'
: 'Call.bubble_incoming'
: isOwnMessage
? 'Call.bubble_cancelled'
: 'Call.bubble_missed';
const title = t(titleKey);
const subtitle: string | null = (() => {
if (aggregate.ongoing) return t('Call.bubble_in_progress');
if (!wasAnswered) return null;
if (aggregate.endTs == null) return null;
return formatDuration(t, aggregate.endTs - aggregate.startTs);
})();
const iconSrc = aggregate.ongoing
? Icons.Phone
: wasAnswered
? Icons.Phone
: Icons.PhoneDown;
const iconColor = aggregate.ongoing
? color.Success.Main
: wasAnswered
? color.Surface.OnContainer
: color.Critical.Main;
const bubbleBody = (
<Box gap="300" alignItems="Center" style={{ minWidth: 0 }}>
<Icon src={iconSrc} size="300" style={{ color: iconColor, flexShrink: 0 }} />
<Box direction="Column" style={{ minWidth: 0 }}>
<Text as="span" size="T300" truncate>
{title}
</Text>
{subtitle && (
<Text as="span" size="T200" priority="300" truncate>
{subtitle}
</Text>
)}
</Box>
</Box>
);
const streamHeader = (
<Username as="span">
<Text as="span" size="T200" truncate>
<UsernameBold>{isOwnMessage ? t('Direct.message_me_label') : senderName}</UsernameBold>
</Text>
</Username>
);
return (
<Event
key={mEvent.getId()}
room={room}
mEvent={mEvent}
highlight={highlight}
canDelete={canDelete}
hideReadReceipts={hideReadReceipts}
showDeveloperTools={showDeveloperTools}
{...rest}
>
{layout === 'channel' ? (
<ChannelLayout
isOwn={isOwnMessage}
headerInBubble={channelHeaderInBubble}
avatar={
<ChannelMessageAvatar
room={room}
senderId={senderId}
senderDisplayName={senderName}
/>
}
header={
<>
<Username as="span">
<Text as="span" size={channelHeaderInBubble ? 'T200' : 'T400'} truncate>
<UsernameBold>
{isOwnMessage ? t('Direct.message_me_label') : senderName}
</UsernameBold>
</Text>
</Username>
<Time ts={mEvent.getTs()} compact size="T200" priority="300" />
</>
}
>
{bubbleBody}
</ChannelLayout>
) : (
<StreamLayout
time={<Time ts={mEvent.getTs()} compact />}
dotColor={dot.color}
dotOpacity={dot.opacity}
isOwn={isOwnMessage}
compact={isMobile}
railStart={streamRailStart}
railEnd={streamRailEnd}
header={streamHeader}
>
{bubbleBody}
</StreamLayout>
)}
</Event>
);
}

View file

@ -1,4 +1,5 @@
export * from './Reactions';
export * from './Message';
export * from './CallMessage';
export * from './EncryptedContent';
export * from './useMessageInteractionHandlers';