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:
parent
093a908571
commit
15b5fb3a28
4 changed files with 262 additions and 62 deletions
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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": "Пригласить",
|
||||||
|
|
|
||||||
|
|
@ -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,77 +1253,231 @@ 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[] = [];
|
||||||
anchors.set(scan.anchorEventId, {
|
for (const scan of sessions) {
|
||||||
callId,
|
if (!scan.everJoined && scan.participants.size === 0) continue;
|
||||||
startTs: scan.startTs,
|
const ongoing = Array.from(scan.joinedAbsoluteExpiries.values()).some(
|
||||||
endTs: ongoing ? null : scan.endTs,
|
(exp) => exp > now
|
||||||
ongoing,
|
);
|
||||||
participants: Array.from(scan.participants),
|
const wasAnswered = scan.connectedAt !== null || scan.participants.size >= 2;
|
||||||
anchorSenderId: scan.anchorSenderId,
|
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;
|
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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue