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}