fix(calls): split per-session bubbles by joined-count boundary with expiry-aware ongoing, post-ring duration, and same-caller retry merging

This commit is contained in:
heaven 2026-05-14 00:27:38 +03:00
parent f5e992daad
commit 3c7c79fb6c
4 changed files with 262 additions and 62 deletions

View file

@ -439,6 +439,10 @@
"bubble_cancelled": "Cancelled call", "bubble_cancelled": "Cancelled call",
"bubble_ongoing": "Ongoing call", "bubble_ongoing": "Ongoing call",
"bubble_in_progress": "In progress…", "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_minutes_seconds": "{{minutes}} min {{seconds}} sec",
"duration_seconds": "{{seconds}} sec" "duration_seconds": "{{seconds}} sec"
}, },
@ -576,10 +580,7 @@
"member_name_removed": "<bold>{{user}}</bold> removed their display name", "member_name_removed": "<bold>{{user}}</bold> removed their display name",
"member_avatar_changed": "<bold>{{user}}</bold> changed their avatar", "member_avatar_changed": "<bold>{{user}}</bold> changed their avatar",
"member_avatar_removed": "<bold>{{user}}</bold> removed their avatar", "member_avatar_removed": "<bold>{{user}}</bold> removed their avatar",
"member_no_change": "Membership event with no changes", "member_no_change": "Membership event with no changes"
"member_ended_call": "<bold>{{user}}</bold> ended the call",
"member_joined_call": "<bold>{{user}}</bold> joined the call"
}, },
"Inbox": { "Inbox": {
"invite_title": "Invite", "invite_title": "Invite",

View file

@ -443,6 +443,12 @@
"bubble_cancelled": "Отменённый звонок", "bubble_cancelled": "Отменённый звонок",
"bubble_ongoing": "Идёт звонок", "bubble_ongoing": "Идёт звонок",
"bubble_in_progress": "Идёт сейчас…", "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_minutes_seconds": "{{minutes}} мин {{seconds}} сек",
"duration_seconds": "{{seconds}} сек" "duration_seconds": "{{seconds}} сек"
}, },
@ -588,10 +594,7 @@
"member_name_removed": "<bold>{{user}}</bold> убирает отображаемое имя", "member_name_removed": "<bold>{{user}}</bold> убирает отображаемое имя",
"member_avatar_changed": "<bold>{{user}}</bold> меняет аватар", "member_avatar_changed": "<bold>{{user}}</bold> меняет аватар",
"member_avatar_removed": "<bold>{{user}}</bold> убирает аватар", "member_avatar_removed": "<bold>{{user}}</bold> убирает аватар",
"member_no_change": "Событие участия без изменений", "member_no_change": "Событие участия без изменений"
"member_ended_call": "<bold>{{user}}</bold> больше не в звонке",
"member_joined_call": "<bold>{{user}}</bold> теперь в звонке"
}, },
"Inbox": { "Inbox": {
"invite_title": "Пригласить", "invite_title": "Пригласить",

View file

@ -24,7 +24,7 @@ import {
import { HTMLReactParserOptions } from 'html-react-parser'; import { HTMLReactParserOptions } from 'html-react-parser';
import classNames from 'classnames'; import classNames from 'classnames';
import { Editor } from 'slate'; 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 to from 'await-to-js';
import { useAtomValue, useSetAtom } from 'jotai'; import { useAtomValue, useSetAtom } from 'jotai';
import { import {
@ -1253,78 +1253,232 @@ export function RoomTimeline({
const { t } = useTranslation(); const { t } = useTranslation();
// Group `m.call.member` (StateEvent.GroupCallMemberPrefix) events by their // Group `m.call.member` (StateEvent.GroupCallMemberPrefix) events into one
// `call_id` and pick one anchor event per call — the earliest join, whose // aggregate bubble per CALL SESSION. Each session is delimited by «joined
// sender is the call initiator. Only the anchor renders a bubble; all // count went from 0 → ≥1, then back to 0». A session's anchor is its
// other join/leave events for the same call_id collapse into it. // 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 // Why per-session and not just per-`call_id`: legacy MSC3401 room-scoped
// linked timelines (rare), so it's effectively free. Keeping it inline // calls use an empty `call_id` (see matrix-js-sdk MembershipManager
// (no useMemo) matches the existing `streamRenderableItemHasBefore` // `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. // IIFE pattern just below.
const callAnchors: Map<string, CallAggregate> = (() => { const callAnchors: Map<string, CallAggregate> = (() => {
type CallScan = { type CallScan = {
slotId: string;
anchorEventId: string; anchorEventId: string;
anchorSenderId: string;
startTs: number; startTs: number;
endTs: 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<string>; participants: Set<string>;
// state_key (memberId + device) -> is currently joined // state_key (mxid + device) -> is currently joined
memberStates: Map<string, boolean>; memberStates: Map<string, boolean>;
// state_key -> absolute expiry timestamp of the active join (ms since
// epoch). Cleared on leave. Used to detect crashed clients.
joinedAbsoluteExpiries: Map<string, number>;
// 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<string, CallScan>(); // Per-slot ordered list of sessions. Newest session is `last(sessions)`;
// earlier entries are closed (count returned to 0).
const slotSessions = new Map<string, CallScan[]>();
for (const tl of timeline.linkedTimelines) { for (const tl of timeline.linkedTimelines) {
const events = tl.getEvents(); const events = tl.getEvents();
for (const ev of events) { for (const ev of events) {
if (ev.getType() !== StateEvent.GroupCallMemberPrefix) continue; if (ev.getType() !== StateEvent.GroupCallMemberPrefix) continue;
const content = ev.getContent<SessionMembershipData>(); const content = ev.getContent<SessionMembershipData>();
const prevContent = ev.getPrevContent() as Partial<SessionMembershipData>; const prevContent = ev.getPrevContent() as Partial<SessionMembershipData>;
const callId = const slotId =
typeof content.call_id === 'string' typeof content.call_id === 'string'
? content.call_id ? content.call_id
: typeof prevContent.call_id === 'string' : typeof prevContent.call_id === 'string'
? prevContent.call_id ? prevContent.call_id
: null; : null;
if (callId == null) continue; if (slotId == null) continue;
const ts = ev.getTs(); const ts = ev.getTs();
const isJoin = !!content.application; const isJoin = !!content.application;
const wasPreviouslyJoined = !!prevContent.application;
const sender = ev.getSender() ?? ''; const sender = ev.getSender() ?? '';
const stateKey = ev.getStateKey() ?? ''; const stateKey = ev.getStateKey() ?? '';
const evId = ev.getId() ?? ''; 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 = { scan = {
slotId,
anchorEventId: evId, anchorEventId: evId,
anchorSenderId: sender,
startTs: ts, startTs: ts,
endTs: ts, endTs: ts,
anchorSenderId: sender, connectedAt: null,
endedAt: null,
joinedCount: 0,
participants: new Set(), participants: new Set(),
memberStates: new Map(), memberStates: new Map(),
joinedAbsoluteExpiries: new Map(),
everJoined: false,
closed: false,
}; };
scans.set(callId, scan); sessions.push(scan);
} }
if (ts < scan.startTs) { if (ts < scan.startTs) {
scan.startTs = ts; scan.startTs = ts;
scan.anchorEventId = evId; scan.anchorEventId = evId;
scan.anchorSenderId = sender; scan.anchorSenderId = sender;
} }
if (ts > scan.endTs) scan.endTs = ts; 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); 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<string, CallAggregate>(); const anchors = new Map<string, CallAggregate>();
for (const [callId, scan] of scans) { for (const sessions of slotSessions.values()) {
const ongoing = Array.from(scan.memberStates.values()).some((v) => v); 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, { anchors.set(scan.anchorEventId, {
callId, callId: scan.slotId,
startTs: scan.startTs, startTs: scan.startTs,
endTs: ongoing ? null : scan.endTs, endTs: ongoing ? null : scan.endTs,
conversationStart,
conversationEnd,
ongoing, ongoing,
wasAnswered,
participants: Array.from(scan.participants), participants: Array.from(scan.participants),
anchorSenderId: scan.anchorSenderId, anchorSenderId: scan.anchorSenderId,
mergedCount: unit.mergedCount,
}); });
} }
}
return anchors; return anchors;
})(); })();
@ -1920,6 +2074,9 @@ export function RoomTimeline({
streamRailEnd={streamRailEnd} streamRailEnd={streamRailEnd}
layout={messageLayout} layout={messageLayout}
channelHeaderInBubble={channelHeaderInBubble} channelHeaderInBubble={channelHeaderInBubble}
memberPowerTag={getMemberPowerTag(aggregate.anchorSenderId)}
accessibleTagColors={accessiblePowerTagColors}
legacyUsernameColor={isOneOnOne}
/> />
); );
}, },

View file

@ -16,16 +16,40 @@ import { useDotColor } from '../../../hooks/useDotColor';
import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize'; import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
import { getMemberDisplayName } from '../../../utils/room'; import { getMemberDisplayName } from '../../../utils/room';
import { getMxIdLocalPart } from '../../../utils/matrix'; import { getMxIdLocalPart } from '../../../utils/matrix';
import { MemberPowerTag } from '../../../../types/matrix/room';
import colorMXID from '../../../../util/colorMXID';
export type CallAggregate = { export type CallAggregate = {
callId: string; callId: string;
// earliest m.call.member event TS — used only for anchor placement; not
// the conversation start (that excludes the ringing prelude).
startTs: number; startTs: number;
// latest event TS, or null while the call is still ongoing.
endTs: number | null; 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; 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) // unique sender mxids that joined the call (any time)
participants: string[]; participants: string[];
// first joiner — call initiator // first joiner — call initiator
anchorSenderId: string; 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 = { export type CallMessageProps = {
@ -40,6 +64,13 @@ export type CallMessageProps = {
streamRailEnd?: boolean; streamRailEnd?: boolean;
layout?: 'stream' | 'channel'; layout?: 'stream' | 'channel';
channelHeaderInBubble?: boolean; 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<string, string>;
legacyUsernameColor?: boolean;
// Spread onto Event wrapper (e.g. data-message-item, data-message-id) // Spread onto Event wrapper (e.g. data-message-item, data-message-id)
// so the virtual paginator can locate the row. // so the virtual paginator can locate the row.
[dataAttr: `data-${string}`]: string | number | undefined; [dataAttr: `data-${string}`]: string | number | undefined;
@ -67,6 +98,9 @@ export function CallMessage({
streamRailEnd, streamRailEnd,
layout = 'stream', layout = 'stream',
channelHeaderInBubble, channelHeaderInBubble,
memberPowerTag,
accessibleTagColors,
legacyUsernameColor,
...rest ...rest
}: CallMessageProps) { }: CallMessageProps) {
const { t } = useTranslation(); const { t } = useTranslation();
@ -78,37 +112,42 @@ export function CallMessage({
const senderName = const senderName =
getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId) || senderId; 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); const dot = useDotColor(room, mEvent, true, hideReadReceipts);
// 1-participant call = the other side never joined. // Other side never joined → caller's view «Cancelled», callee's view «Missed».
// Caller's view → "Cancelled"; callee's view → "Missed". // When `mergedCount > 1` the bubble represents a chain of retries that
const wasAnswered = aggregate.participants.length >= 2; // we collapsed at the timeline aggregation step; switch to the plural
const titleKey = aggregate.ongoing // _count keys so i18next picks the locale-aware form (en: one/other,
? 'Call.bubble_ongoing' // ru: one/few/many).
: wasAnswered const { wasAnswered, ongoing, mergedCount } = aggregate;
? isOwnMessage let title: string;
? 'Call.bubble_outgoing' if (ongoing) {
: 'Call.bubble_incoming' title = t('Call.bubble_ongoing');
: isOwnMessage } else if (wasAnswered) {
? 'Call.bubble_cancelled' title = t(isOwnMessage ? 'Call.bubble_outgoing' : 'Call.bubble_incoming');
: 'Call.bubble_missed'; } else if (mergedCount > 1) {
title = t(isOwnMessage ? 'Call.bubble_cancelled_count' : 'Call.bubble_missed_count', {
const title = t(titleKey); count: mergedCount,
});
} else {
title = t(isOwnMessage ? 'Call.bubble_cancelled' : 'Call.bubble_missed');
}
const subtitle: string | null = (() => { 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 (!wasAnswered) return null;
if (aggregate.endTs == null) return null; if (aggregate.conversationStart == null || aggregate.conversationEnd == null) return null;
return formatDuration(t, aggregate.endTs - aggregate.startTs); return formatDuration(t, aggregate.conversationEnd - aggregate.conversationStart);
})(); })();
const iconSrc = aggregate.ongoing const iconSrc = wasAnswered || ongoing ? Icons.Phone : Icons.PhoneDown;
? Icons.Phone const iconColor = ongoing
: wasAnswered
? Icons.Phone
: Icons.PhoneDown;
const iconColor = aggregate.ongoing
? color.Success.Main ? color.Success.Main
: wasAnswered : wasAnswered
? color.Surface.OnContainer ? color.Surface.OnContainer
@ -131,7 +170,7 @@ export function CallMessage({
); );
const streamHeader = ( const streamHeader = (
<Username as="span"> <Username as="span" style={usernameStyle}>
<Text as="span" size="T200" truncate> <Text as="span" size="T200" truncate>
<UsernameBold>{isOwnMessage ? t('Direct.message_me_label') : senderName}</UsernameBold> <UsernameBold>{isOwnMessage ? t('Direct.message_me_label') : senderName}</UsernameBold>
</Text> </Text>
@ -162,7 +201,7 @@ export function CallMessage({
} }
header={ header={
<> <>
<Username as="span"> <Username as="span" style={usernameStyle}>
<Text as="span" size={channelHeaderInBubble ? 'T200' : 'T400'} truncate> <Text as="span" size={channelHeaderInBubble ? 'T200' : 'T400'} truncate>
<UsernameBold> <UsernameBold>
{isOwnMessage ? t('Direct.message_me_label') : senderName} {isOwnMessage ? t('Direct.message_me_label') : senderName}