From 15b5fb3a2813dcd4705bc4a748a891683b8ab5fb Mon Sep 17 00:00:00 2001 From: "v.lagerev" Date: Thu, 14 May 2026 00:27:38 +0300 Subject: [PATCH] fix(calls): split per-session bubbles by joined-count boundary with expiry-aware ongoing, post-ring duration, and same-caller retry merging --- public/locales/en.json | 9 +- public/locales/ru.json | 11 +- src/app/features/room/RoomTimeline.tsx | 213 +++++++++++++++--- src/app/features/room/message/CallMessage.tsx | 91 +++++--- 4 files changed, 262 insertions(+), 62 deletions(-) diff --git a/public/locales/en.json b/public/locales/en.json index fe61b27b..478a2c1b 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -439,6 +439,10 @@ "bubble_cancelled": "Cancelled call", "bubble_ongoing": "Ongoing call", "bubble_in_progress": "In progress…", + "bubble_missed_count_one": "{{count}} missed call", + "bubble_missed_count_other": "{{count}} missed calls", + "bubble_cancelled_count_one": "{{count}} cancelled call", + "bubble_cancelled_count_other": "{{count}} cancelled calls", "duration_minutes_seconds": "{{minutes}} min {{seconds}} sec", "duration_seconds": "{{seconds}} sec" }, @@ -576,10 +580,7 @@ "member_name_removed": "{{user}} removed their display name", "member_avatar_changed": "{{user}} changed their avatar", "member_avatar_removed": "{{user}} removed their avatar", - "member_no_change": "Membership event with no changes", - - "member_ended_call": "{{user}} ended the call", - "member_joined_call": "{{user}} joined the call" + "member_no_change": "Membership event with no changes" }, "Inbox": { "invite_title": "Invite", diff --git a/public/locales/ru.json b/public/locales/ru.json index e0283b4b..7b4c6e99 100644 --- a/public/locales/ru.json +++ b/public/locales/ru.json @@ -443,6 +443,12 @@ "bubble_cancelled": "Отменённый звонок", "bubble_ongoing": "Идёт звонок", "bubble_in_progress": "Идёт сейчас…", + "bubble_missed_count_one": "{{count}} пропущенный звонок", + "bubble_missed_count_few": "{{count}} пропущенных звонка", + "bubble_missed_count_many": "{{count}} пропущенных звонков", + "bubble_cancelled_count_one": "{{count}} отменённый звонок", + "bubble_cancelled_count_few": "{{count}} отменённых звонка", + "bubble_cancelled_count_many": "{{count}} отменённых звонков", "duration_minutes_seconds": "{{minutes}} мин {{seconds}} сек", "duration_seconds": "{{seconds}} сек" }, @@ -588,10 +594,7 @@ "member_name_removed": "{{user}} убирает отображаемое имя", "member_avatar_changed": "{{user}} меняет аватар", "member_avatar_removed": "{{user}} убирает аватар", - "member_no_change": "Событие участия без изменений", - - "member_ended_call": "{{user}} больше не в звонке", - "member_joined_call": "{{user}} теперь в звонке" + "member_no_change": "Событие участия без изменений" }, "Inbox": { "invite_title": "Пригласить", diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 9f5f2f83..090fc4c7 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -24,7 +24,7 @@ import { import { HTMLReactParserOptions } from 'html-react-parser'; import classNames from 'classnames'; import { Editor } from 'slate'; -import { SessionMembershipData } from 'matrix-js-sdk/lib/matrixrtc'; +import { DEFAULT_EXPIRE_DURATION, SessionMembershipData } from 'matrix-js-sdk/lib/matrixrtc'; import to from 'await-to-js'; import { useAtomValue, useSetAtom } from 'jotai'; import { @@ -1253,77 +1253,231 @@ 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. + // Group `m.call.member` (StateEvent.GroupCallMemberPrefix) events into one + // aggregate bubble per CALL SESSION. Each session is delimited by «joined + // count went from 0 → ≥1, then back to 0». A session's anchor is its + // earliest event; the sender of that anchor is the call initiator and + // decides bubble side (own/peer). // - // 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` + // Why per-session and not just per-`call_id`: legacy MSC3401 room-scoped + // calls use an empty `call_id` (see matrix-js-sdk MembershipManager + // `INFO_SLOT_ID_LEGACY_CASE`), so every historical call in the same DM + // room shares the same slot id. Splitting on the «count returned to 0» + // boundary keeps each call's metrics — connectedAt / endedAt / duration — + // scoped to its own session, otherwise the bubble would smear the entire + // call history into one super-aggregate. + // + // Forward chronological walk per linked timeline tracks: (1) joined count + // per session so we can pin the conversation-duration window to «both + // joined» → «one left» and exclude the ringing prelude; (2) each + // currently-joined membership's absolute expiry so a crashed peer + // (join with no leave) doesn't keep the bubble in «Ongoing» forever — + // once the SDK's DEFAULT_EXPIRE_DURATION elapses past + // created_ts + expires, we treat the stale join as gone. Linear in + // #call-member events (rare); matches the inline `streamRenderableItemHasBefore` // IIFE pattern just below. const callAnchors: Map = (() => { type CallScan = { + slotId: string; anchorEventId: string; + anchorSenderId: string; startTs: number; endTs: number; - anchorSenderId: string; + // TS when joined count first reached 2 (both sides connected). Used as + // the duration window start; falls back to startTs on pagination edge. + connectedAt: number | null; + // TS when joined count dropped from 2 → 1 for the last time. Null + // while the conversation is still live (or never began). + endedAt: number | null; + // Running tally of currently-joined memberships (per stateKey). + joinedCount: number; participants: Set; - // state_key (memberId + device) -> is currently joined + // state_key (mxid + device) -> is currently joined memberStates: Map; + // state_key -> absolute expiry timestamp of the active join (ms since + // epoch). Cleared on leave. Used to detect crashed clients. + joinedAbsoluteExpiries: Map; + // At least one join observed in this session — guards session close + // and filters leave-only pagination fragments. + everJoined: boolean; + // Session terminated (count returned to 0 after everJoined). New + // events for this slot open a fresh session instead of appending. + closed: boolean; }; - const scans = new Map(); + // Per-slot ordered list of sessions. Newest session is `last(sessions)`; + // earlier entries are closed (count returned to 0). + const slotSessions = 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 = + const slotId = typeof content.call_id === 'string' ? content.call_id : typeof prevContent.call_id === 'string' ? prevContent.call_id : null; - if (callId == null) continue; + if (slotId == null) continue; const ts = ev.getTs(); const isJoin = !!content.application; + const wasPreviouslyJoined = !!prevContent.application; const sender = ev.getSender() ?? ''; const stateKey = ev.getStateKey() ?? ''; const evId = ev.getId() ?? ''; - let scan = scans.get(callId); - if (!scan) { + + let sessions = slotSessions.get(slotId); + if (!sessions) { + sessions = []; + slotSessions.set(slotId, sessions); + } + let scan = sessions[sessions.length - 1]; + if (!scan || scan.closed) { + // Don't open a session for a stray leave event with no prior join + // context (its session paginated out — there is nothing to render + // and absorbing it would corrupt the next real session's counts). + if (!isJoin && !wasPreviouslyJoined) continue; scan = { + slotId, anchorEventId: evId, + anchorSenderId: sender, startTs: ts, endTs: ts, - anchorSenderId: sender, + connectedAt: null, + endedAt: null, + joinedCount: 0, participants: new Set(), memberStates: new Map(), + joinedAbsoluteExpiries: new Map(), + everJoined: false, + closed: false, }; - scans.set(callId, scan); + sessions.push(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); + + const prevJoined = scan.memberStates.get(stateKey) ?? false; + if (isJoin && !prevJoined) { + scan.joinedCount += 1; + scan.everJoined = true; + if (scan.joinedCount === 2 && scan.connectedAt === null) { + scan.connectedAt = ts; + } + } else if (!isJoin && prevJoined) { + if (scan.joinedCount === 2) { + scan.endedAt = ts; + } + scan.joinedCount = Math.max(0, scan.joinedCount - 1); + } scan.memberStates.set(stateKey, isJoin); + if (isJoin) { + const createdTs = typeof content.created_ts === 'number' ? content.created_ts : ts; + // `expires === 0` (or any non-positive value) is a misbehaving + // client emitting an instantly-stale membership — refuse and fall + // back to the SDK default rather than declaring the join expired + // on arrival. + const expiresDelta = + typeof content.expires === 'number' && content.expires > 0 + ? content.expires + : DEFAULT_EXPIRE_DURATION; + scan.joinedAbsoluteExpiries.set(stateKey, createdTs + expiresDelta); + } else { + scan.joinedAbsoluteExpiries.delete(stateKey); + } + + // A leave with `prev_content.application` proves this sender was + // previously joined even if their join event paginated out of view. + // Counting it keeps `wasAnswered` correct when the user scrolls + // into the middle of a call. + if (sender && (isJoin || wasPreviouslyJoined)) scan.participants.add(sender); + + // Close the session once everyone has left. The next event for this + // slot opens a fresh session — this is what separates back-to-back + // calls that share the same legacy room-scoped slot id. + if (scan.joinedCount === 0 && scan.everJoined) { + scan.closed = true; + } } } + const now = Date.now(); + // Consecutive unsuccessful sessions from the same caller within this + // window collapse into one bubble (WhatsApp/iOS Recents-style anti-spam). + // Answered/ongoing calls always stand alone. + const MERGE_WINDOW_MS = 60 * 60 * 1000; + type SessionDisplay = { + scan: CallScan; + ongoing: boolean; + wasAnswered: boolean; + }; + type DisplayUnit = { + // Anchor scan — its `anchorEventId` is the event the merged bubble + // attaches to in the timeline. We pick the LATEST scan in the group + // so the bubble shows at the most recent attempt's position, while + // earlier attempts disappear from view (the spam we want to hide). + anchor: SessionDisplay; + mergedCount: number; + // false once any answered/ongoing scan lands in the unit — sealed + // units never absorb later scans. + mergeable: boolean; + // Used by the merge predicate: the latest scan's endTs decides the + // gap to the next candidate. + lastEndTs: number; + }; 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, - }); + for (const sessions of slotSessions.values()) { + const units: DisplayUnit[] = []; + for (const scan of sessions) { + if (!scan.everJoined && scan.participants.size === 0) continue; + const ongoing = Array.from(scan.joinedAbsoluteExpiries.values()).some( + (exp) => exp > now + ); + const wasAnswered = scan.connectedAt !== null || scan.participants.size >= 2; + const display: SessionDisplay = { scan, ongoing, wasAnswered }; + const mergeable = !ongoing && !wasAnswered; + const last = units[units.length - 1]; + if ( + mergeable && + last !== undefined && + last.mergeable && + last.anchor.scan.anchorSenderId === scan.anchorSenderId && + scan.startTs - last.lastEndTs <= MERGE_WINDOW_MS + ) { + last.anchor = display; + last.mergedCount += 1; + last.lastEndTs = scan.endTs; + } else { + units.push({ + anchor: display, + mergedCount: 1, + mergeable, + lastEndTs: scan.endTs, + }); + } + } + for (const unit of units) { + const { scan, ongoing, wasAnswered } = unit.anchor; + const conversationStart = wasAnswered ? (scan.connectedAt ?? scan.startTs) : null; + const conversationEnd = wasAnswered && !ongoing ? (scan.endedAt ?? scan.endTs) : null; + anchors.set(scan.anchorEventId, { + callId: scan.slotId, + startTs: scan.startTs, + endTs: ongoing ? null : scan.endTs, + conversationStart, + conversationEnd, + ongoing, + wasAnswered, + participants: Array.from(scan.participants), + anchorSenderId: scan.anchorSenderId, + mergedCount: unit.mergedCount, + }); + } } return anchors; })(); @@ -1920,6 +2074,9 @@ export function RoomTimeline({ streamRailEnd={streamRailEnd} layout={messageLayout} channelHeaderInBubble={channelHeaderInBubble} + memberPowerTag={getMemberPowerTag(aggregate.anchorSenderId)} + accessibleTagColors={accessiblePowerTagColors} + legacyUsernameColor={isOneOnOne} /> ); }, diff --git a/src/app/features/room/message/CallMessage.tsx b/src/app/features/room/message/CallMessage.tsx index 426c0d88..9b72004e 100644 --- a/src/app/features/room/message/CallMessage.tsx +++ b/src/app/features/room/message/CallMessage.tsx @@ -16,16 +16,40 @@ import { useDotColor } from '../../../hooks/useDotColor'; import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize'; import { getMemberDisplayName } from '../../../utils/room'; import { getMxIdLocalPart } from '../../../utils/matrix'; +import { MemberPowerTag } from '../../../../types/matrix/room'; +import colorMXID from '../../../../util/colorMXID'; export type CallAggregate = { callId: string; + // earliest m.call.member event TS — used only for anchor placement; not + // the conversation start (that excludes the ringing prelude). startTs: number; + // latest event TS, or null while the call is still ongoing. endTs: number | null; + // TS when both sides were first simultaneously joined. null if the call + // was never answered. + conversationStart: number | null; + // TS when joined count last dropped from 2 → 1. Correct for 1:1 DMs (the + // only place the call button is exposed today); for incidental group-room + // calls it underestimates duration once a third party joins. null while + // ongoing or when the call was never answered. + conversationEnd: number | null; ongoing: boolean; + // True if both sides joined the call at any point — set either when the + // walk observed the 1→2 transition (connectedAt) or when ≥2 distinct + // senders ever joined (pagination-edge fallback for windows that begin + // mid-call). + wasAnswered: boolean; // unique sender mxids that joined the call (any time) participants: string[]; // first joiner — call initiator anchorSenderId: string; + // Number of unsuccessful (missed/cancelled) sessions collapsed into this + // bubble. 1 means a standalone call; >1 means a chain of retries from the + // same caller within the merge window was folded together to reduce + // chat-spam, like WhatsApp/iOS Recents. Answered/ongoing calls are never + // merged and always have count 1. + mergedCount: number; }; export type CallMessageProps = { @@ -40,6 +64,13 @@ export type CallMessageProps = { streamRailEnd?: boolean; layout?: 'stream' | 'channel'; channelHeaderInBubble?: boolean; + // Username coloring — mirrors `Message.tsx` so call bubbles match the + // surrounding chat rhythm. In 1:1 DMs `legacyUsernameColor` is true and + // the username is rendered in the mxid-hash colour; in group rooms it + // uses the power-level tag colour. + memberPowerTag?: MemberPowerTag; + accessibleTagColors?: Map; + legacyUsernameColor?: 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; @@ -67,6 +98,9 @@ export function CallMessage({ streamRailEnd, layout = 'stream', channelHeaderInBubble, + memberPowerTag, + accessibleTagColors, + legacyUsernameColor, ...rest }: CallMessageProps) { const { t } = useTranslation(); @@ -78,37 +112,42 @@ export function CallMessage({ const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId) || senderId; + const tagColor = memberPowerTag?.color + ? accessibleTagColors?.get(memberPowerTag.color) + : undefined; + const usernameColor = legacyUsernameColor ? colorMXID(senderId) : tagColor; + const usernameStyle = { color: usernameColor ?? color.Primary.Main }; + 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); + // Other side never joined → caller's view «Cancelled», callee's view «Missed». + // When `mergedCount > 1` the bubble represents a chain of retries that + // we collapsed at the timeline aggregation step; switch to the plural + // _count keys so i18next picks the locale-aware form (en: one/other, + // ru: one/few/many). + const { wasAnswered, ongoing, mergedCount } = aggregate; + let title: string; + if (ongoing) { + title = t('Call.bubble_ongoing'); + } else if (wasAnswered) { + title = t(isOwnMessage ? 'Call.bubble_outgoing' : 'Call.bubble_incoming'); + } else if (mergedCount > 1) { + title = t(isOwnMessage ? 'Call.bubble_cancelled_count' : 'Call.bubble_missed_count', { + count: mergedCount, + }); + } else { + title = t(isOwnMessage ? 'Call.bubble_cancelled' : 'Call.bubble_missed'); + } const subtitle: string | null = (() => { - if (aggregate.ongoing) return t('Call.bubble_in_progress'); + if (ongoing) return t('Call.bubble_in_progress'); if (!wasAnswered) return null; - if (aggregate.endTs == null) return null; - return formatDuration(t, aggregate.endTs - aggregate.startTs); + if (aggregate.conversationStart == null || aggregate.conversationEnd == null) return null; + return formatDuration(t, aggregate.conversationEnd - aggregate.conversationStart); })(); - const iconSrc = aggregate.ongoing - ? Icons.Phone - : wasAnswered - ? Icons.Phone - : Icons.PhoneDown; - - const iconColor = aggregate.ongoing + const iconSrc = wasAnswered || ongoing ? Icons.Phone : Icons.PhoneDown; + const iconColor = ongoing ? color.Success.Main : wasAnswered ? color.Surface.OnContainer @@ -131,7 +170,7 @@ export function CallMessage({ ); const streamHeader = ( - + {isOwnMessage ? t('Direct.message_me_label') : senderName} @@ -162,7 +201,7 @@ export function CallMessage({ } header={ <> - + {isOwnMessage ? t('Direct.message_me_label') : senderName}