From 9ec670b8cf81104998f825d2dc24233a92eb5df1 Mon Sep 17 00:00:00 2001 From: "v.lagerev" Date: Wed, 13 May 2026 15:57:23 +0300 Subject: [PATCH] feat(calls): render m.call.member events as one aggregate chat bubble per call aligned to initiator side --- public/locales/en.json | 10 +- public/locales/ru.json | 10 +- src/app/features/room/RoomTimeline.tsx | 141 ++++++++----- src/app/features/room/message/CallMessage.tsx | 194 ++++++++++++++++++ src/app/features/room/message/index.ts | 1 + 5 files changed, 308 insertions(+), 48 deletions(-) create mode 100644 src/app/features/room/message/CallMessage.tsx diff --git a/public/locales/en.json b/public/locales/en.json index 2a097ed4..a6b870ee 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -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", diff --git a/public/locales/ru.json b/public/locales/ru.json index 6f31ddab..bf1112f8 100644 --- a/public/locales/ru.json +++ b/public/locales/ru.json @@ -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": "Потянуть вверх чтобы закрыть", diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index f293f14c..34d970ba 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -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 = (() => { + type CallScan = { + anchorEventId: string; + startTs: number; + endTs: number; + anchorSenderId: string; + participants: Set; + // state_key (memberId + device) -> is currently joined + memberStates: Map; + }; + const scans = new Map(); + 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(); + const prevContent = ev.getPrevContent() as Partial; + 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(); + 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(); - const prevContent = mEvent.getPrevContent(); - - const callJoined = content.application; - if (callJoined && 'application' in prevContent) { - return null; - } - - const timeJSX = ( -