feat(calls): render m.call.member events as one aggregate chat bubble per call aligned to initiator side
This commit is contained in:
parent
6a6b7acf15
commit
30e477d2cd
5 changed files with 308 additions and 48 deletions
|
|
@ -428,7 +428,15 @@
|
||||||
"in_call": "In call",
|
"in_call": "In call",
|
||||||
"in_call_count": "{{count}} in call",
|
"in_call_count": "{{count}} in call",
|
||||||
"connecting": "Connecting…",
|
"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": {
|
"Room": {
|
||||||
"drag_to_close": "Drag up to close",
|
"drag_to_close": "Drag up to close",
|
||||||
|
|
|
||||||
|
|
@ -432,7 +432,15 @@
|
||||||
"in_call": "В звонке",
|
"in_call": "В звонке",
|
||||||
"in_call_count": "{{count}} в звонке",
|
"in_call_count": "{{count}} в звонке",
|
||||||
"connecting": "Соединение…",
|
"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": {
|
"Room": {
|
||||||
"drag_to_close": "Потянуть вверх чтобы закрыть",
|
"drag_to_close": "Потянуть вверх чтобы закрыть",
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@ import {
|
||||||
} from 'folds';
|
} from 'folds';
|
||||||
import { isKeyHotkey } from 'is-hotkey';
|
import { isKeyHotkey } from 'is-hotkey';
|
||||||
import { Opts as LinkifyOpts } from 'linkifyjs';
|
import { Opts as LinkifyOpts } from 'linkifyjs';
|
||||||
import { Trans, useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
import { getMxIdLocalPart } from '../../utils/matrix';
|
import { getMxIdLocalPart } from '../../utils/matrix';
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
|
|
@ -88,6 +88,8 @@ import {
|
||||||
Reactions,
|
Reactions,
|
||||||
Message,
|
Message,
|
||||||
Event,
|
Event,
|
||||||
|
CallMessage,
|
||||||
|
CallAggregate,
|
||||||
EncryptedContent,
|
EncryptedContent,
|
||||||
useMessageInteractionHandlers,
|
useMessageInteractionHandlers,
|
||||||
} from './message';
|
} from './message';
|
||||||
|
|
@ -1251,6 +1253,81 @@ export function RoomTimeline({
|
||||||
|
|
||||||
const { t } = useTranslation();
|
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<string, CallAggregate> = (() => {
|
||||||
|
type CallScan = {
|
||||||
|
anchorEventId: string;
|
||||||
|
startTs: number;
|
||||||
|
endTs: number;
|
||||||
|
anchorSenderId: string;
|
||||||
|
participants: Set<string>;
|
||||||
|
// state_key (memberId + device) -> is currently joined
|
||||||
|
memberStates: Map<string, boolean>;
|
||||||
|
};
|
||||||
|
const scans = new Map<string, CallScan>();
|
||||||
|
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<SessionMembershipData>();
|
||||||
|
const prevContent = ev.getPrevContent() as Partial<SessionMembershipData>;
|
||||||
|
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<string, CallAggregate>();
|
||||||
|
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<
|
const renderMatrixEvent = useMatrixEventRenderer<
|
||||||
[string, MatrixEvent, number, EventTimelineSet, boolean, boolean, boolean]
|
[string, MatrixEvent, number, EventTimelineSet, boolean, boolean, boolean]
|
||||||
>(
|
>(
|
||||||
|
|
@ -1820,57 +1897,30 @@ export function RoomTimeline({
|
||||||
streamRailStart,
|
streamRailStart,
|
||||||
streamRailEnd
|
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 highlighted = focusItem?.index === item && focusItem.highlight;
|
||||||
const senderId = mEvent.getSender() ?? '';
|
|
||||||
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
|
|
||||||
|
|
||||||
const content = mEvent.getContent<SessionMembershipData>();
|
|
||||||
const prevContent = mEvent.getPrevContent();
|
|
||||||
|
|
||||||
const callJoined = content.application;
|
|
||||||
if (callJoined && 'application' in prevContent) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const timeJSX = (
|
|
||||||
<Time
|
|
||||||
ts={mEvent.getTs()}
|
|
||||||
compact
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Event
|
<CallMessage
|
||||||
key={mEvent.getId()}
|
key={mEvent.getId()}
|
||||||
data-message-item={item}
|
data-message-item={item}
|
||||||
data-message-id={mEventId}
|
data-message-id={mEventId}
|
||||||
room={room}
|
room={room}
|
||||||
mEvent={mEvent}
|
mEvent={mEvent}
|
||||||
|
aggregate={aggregate}
|
||||||
highlight={highlighted}
|
highlight={highlighted}
|
||||||
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
|
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
|
||||||
hideReadReceipts={hideActivity}
|
hideReadReceipts={hideActivity}
|
||||||
showDeveloperTools={showDeveloperTools}
|
showDeveloperTools={showDeveloperTools}
|
||||||
>
|
streamRailStart={streamRailStart}
|
||||||
<EventContent
|
streamRailEnd={streamRailEnd}
|
||||||
time={timeJSX}
|
layout={messageLayout}
|
||||||
railStart={streamRailStart}
|
channelHeaderInBubble={channelHeaderInBubble}
|
||||||
railEnd={streamRailEnd}
|
/>
|
||||||
layout={messageLayout}
|
|
||||||
channelHeaderInBubble={channelHeaderInBubble}
|
|
||||||
iconSrc={callJoined ? Icons.Phone : Icons.PhoneDown}
|
|
||||||
content={
|
|
||||||
<Box grow="Yes" direction="Column">
|
|
||||||
<Text size="T300" priority="300">
|
|
||||||
<Trans
|
|
||||||
i18nKey={callJoined ? 'Room.member_joined_call' : 'Room.member_ended_call'}
|
|
||||||
values={{ user: senderName }}
|
|
||||||
components={{ bold: <b /> }}
|
|
||||||
/>
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Event>
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -2018,11 +2068,10 @@ export function RoomTimeline({
|
||||||
}
|
}
|
||||||
|
|
||||||
if (eventType === StateEvent.GroupCallMemberPrefix) {
|
if (eventType === StateEvent.GroupCallMemberPrefix) {
|
||||||
const content = event.getContent<SessionMembershipData>();
|
// Mirror the renderer: only the per-call anchor event is renderable;
|
||||||
const prevContent = event.getPrevContent();
|
// all other joins/leaves for the same call_id collapse into the
|
||||||
const callJoined = content.application;
|
// aggregate bubble. Rail-endpoint scan must agree with the renderer.
|
||||||
if (callJoined && 'application' in prevContent) return false;
|
return callAnchors.has(event.getId() ?? '');
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
|
|
||||||
194
src/app/features/room/message/CallMessage.tsx
Normal file
194
src/app/features/room/message/CallMessage.tsx
Normal file
|
|
@ -0,0 +1,194 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { Box, Icon, Icons, Text, color } from 'folds';
|
||||||
|
import { MatrixEvent, Room } from 'matrix-js-sdk';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import {
|
||||||
|
ChannelLayout,
|
||||||
|
ChannelMessageAvatar,
|
||||||
|
StreamLayout,
|
||||||
|
Time,
|
||||||
|
Username,
|
||||||
|
UsernameBold,
|
||||||
|
} from '../../../components/message';
|
||||||
|
import { Event } from './Message';
|
||||||
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
|
import { useDotColor } from '../../../hooks/useDotColor';
|
||||||
|
import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
|
||||||
|
import { getMemberDisplayName } from '../../../utils/room';
|
||||||
|
import { getMxIdLocalPart } from '../../../utils/matrix';
|
||||||
|
|
||||||
|
export type CallAggregate = {
|
||||||
|
callId: string;
|
||||||
|
startTs: number;
|
||||||
|
endTs: number | null;
|
||||||
|
ongoing: boolean;
|
||||||
|
// unique sender mxids that joined the call (any time)
|
||||||
|
participants: string[];
|
||||||
|
// first joiner — call initiator
|
||||||
|
anchorSenderId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CallMessageProps = {
|
||||||
|
room: Room;
|
||||||
|
mEvent: MatrixEvent;
|
||||||
|
aggregate: CallAggregate;
|
||||||
|
highlight: boolean;
|
||||||
|
canDelete?: boolean;
|
||||||
|
hideReadReceipts?: boolean;
|
||||||
|
showDeveloperTools?: boolean;
|
||||||
|
streamRailStart?: boolean;
|
||||||
|
streamRailEnd?: boolean;
|
||||||
|
layout?: 'stream' | 'channel';
|
||||||
|
channelHeaderInBubble?: 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;
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatDuration(t: ReturnType<typeof useTranslation>['t'], ms: number): string {
|
||||||
|
const total = Math.max(0, Math.round(ms / 1000));
|
||||||
|
const minutes = Math.floor(total / 60);
|
||||||
|
const seconds = total % 60;
|
||||||
|
if (minutes === 0) {
|
||||||
|
return t('Call.duration_seconds', { seconds });
|
||||||
|
}
|
||||||
|
return t('Call.duration_minutes_seconds', { minutes, seconds });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CallMessage({
|
||||||
|
room,
|
||||||
|
mEvent,
|
||||||
|
aggregate,
|
||||||
|
highlight,
|
||||||
|
canDelete,
|
||||||
|
hideReadReceipts,
|
||||||
|
showDeveloperTools,
|
||||||
|
streamRailStart,
|
||||||
|
streamRailEnd,
|
||||||
|
layout = 'stream',
|
||||||
|
channelHeaderInBubble,
|
||||||
|
...rest
|
||||||
|
}: CallMessageProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const isMobile = useScreenSizeContext() === ScreenSize.Mobile;
|
||||||
|
|
||||||
|
const senderId = aggregate.anchorSenderId;
|
||||||
|
const isOwnMessage = !!mx.getUserId() && senderId === mx.getUserId();
|
||||||
|
const senderName =
|
||||||
|
getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId) || senderId;
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
const subtitle: string | null = (() => {
|
||||||
|
if (aggregate.ongoing) return t('Call.bubble_in_progress');
|
||||||
|
if (!wasAnswered) return null;
|
||||||
|
if (aggregate.endTs == null) return null;
|
||||||
|
return formatDuration(t, aggregate.endTs - aggregate.startTs);
|
||||||
|
})();
|
||||||
|
|
||||||
|
const iconSrc = aggregate.ongoing
|
||||||
|
? Icons.Phone
|
||||||
|
: wasAnswered
|
||||||
|
? Icons.Phone
|
||||||
|
: Icons.PhoneDown;
|
||||||
|
|
||||||
|
const iconColor = aggregate.ongoing
|
||||||
|
? color.Success.Main
|
||||||
|
: wasAnswered
|
||||||
|
? color.Surface.OnContainer
|
||||||
|
: color.Critical.Main;
|
||||||
|
|
||||||
|
const bubbleBody = (
|
||||||
|
<Box gap="300" alignItems="Center" style={{ minWidth: 0 }}>
|
||||||
|
<Icon src={iconSrc} size="300" style={{ color: iconColor, flexShrink: 0 }} />
|
||||||
|
<Box direction="Column" style={{ minWidth: 0 }}>
|
||||||
|
<Text as="span" size="T300" truncate>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
{subtitle && (
|
||||||
|
<Text as="span" size="T200" priority="300" truncate>
|
||||||
|
{subtitle}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
|
||||||
|
const streamHeader = (
|
||||||
|
<Username as="span">
|
||||||
|
<Text as="span" size="T200" truncate>
|
||||||
|
<UsernameBold>{isOwnMessage ? t('Direct.message_me_label') : senderName}</UsernameBold>
|
||||||
|
</Text>
|
||||||
|
</Username>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Event
|
||||||
|
key={mEvent.getId()}
|
||||||
|
room={room}
|
||||||
|
mEvent={mEvent}
|
||||||
|
highlight={highlight}
|
||||||
|
canDelete={canDelete}
|
||||||
|
hideReadReceipts={hideReadReceipts}
|
||||||
|
showDeveloperTools={showDeveloperTools}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
{layout === 'channel' ? (
|
||||||
|
<ChannelLayout
|
||||||
|
isOwn={isOwnMessage}
|
||||||
|
headerInBubble={channelHeaderInBubble}
|
||||||
|
avatar={
|
||||||
|
<ChannelMessageAvatar
|
||||||
|
room={room}
|
||||||
|
senderId={senderId}
|
||||||
|
senderDisplayName={senderName}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
header={
|
||||||
|
<>
|
||||||
|
<Username as="span">
|
||||||
|
<Text as="span" size={channelHeaderInBubble ? 'T200' : 'T400'} truncate>
|
||||||
|
<UsernameBold>
|
||||||
|
{isOwnMessage ? t('Direct.message_me_label') : senderName}
|
||||||
|
</UsernameBold>
|
||||||
|
</Text>
|
||||||
|
</Username>
|
||||||
|
<Time ts={mEvent.getTs()} compact size="T200" priority="300" />
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{bubbleBody}
|
||||||
|
</ChannelLayout>
|
||||||
|
) : (
|
||||||
|
<StreamLayout
|
||||||
|
time={<Time ts={mEvent.getTs()} compact />}
|
||||||
|
dotColor={dot.color}
|
||||||
|
dotOpacity={dot.opacity}
|
||||||
|
isOwn={isOwnMessage}
|
||||||
|
compact={isMobile}
|
||||||
|
railStart={streamRailStart}
|
||||||
|
railEnd={streamRailEnd}
|
||||||
|
header={streamHeader}
|
||||||
|
>
|
||||||
|
{bubbleBody}
|
||||||
|
</StreamLayout>
|
||||||
|
)}
|
||||||
|
</Event>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
export * from './Reactions';
|
export * from './Reactions';
|
||||||
export * from './Message';
|
export * from './Message';
|
||||||
|
export * from './CallMessage';
|
||||||
export * from './EncryptedContent';
|
export * from './EncryptedContent';
|
||||||
export * from './useMessageInteractionHandlers';
|
export * from './useMessageInteractionHandlers';
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue