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
f5e992daad
commit
3c7c79fb6c
4 changed files with 262 additions and 62 deletions
|
|
@ -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": "<bold>{{user}}</bold> removed their display name",
|
||||
"member_avatar_changed": "<bold>{{user}}</bold> changed their avatar",
|
||||
"member_avatar_removed": "<bold>{{user}}</bold> removed their avatar",
|
||||
"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"
|
||||
"member_no_change": "Membership event with no changes"
|
||||
},
|
||||
"Inbox": {
|
||||
"invite_title": "Invite",
|
||||
|
|
|
|||
|
|
@ -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": "<bold>{{user}}</bold> убирает отображаемое имя",
|
||||
"member_avatar_changed": "<bold>{{user}}</bold> меняет аватар",
|
||||
"member_avatar_removed": "<bold>{{user}}</bold> убирает аватар",
|
||||
"member_no_change": "Событие участия без изменений",
|
||||
|
||||
"member_ended_call": "<bold>{{user}}</bold> больше не в звонке",
|
||||
"member_joined_call": "<bold>{{user}}</bold> теперь в звонке"
|
||||
"member_no_change": "Событие участия без изменений"
|
||||
},
|
||||
"Inbox": {
|
||||
"invite_title": "Пригласить",
|
||||
|
|
|
|||
|
|
@ -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<string, CallAggregate> = (() => {
|
||||
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<string>;
|
||||
// state_key (memberId + device) -> is currently joined
|
||||
// state_key (mxid + device) -> is currently joined
|
||||
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) {
|
||||
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 =
|
||||
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<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,
|
||||
});
|
||||
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}
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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<string, string>;
|
||||
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 = (
|
||||
<Username as="span">
|
||||
<Username as="span" style={usernameStyle}>
|
||||
<Text as="span" size="T200" truncate>
|
||||
<UsernameBold>{isOwnMessage ? t('Direct.message_me_label') : senderName}</UsernameBold>
|
||||
</Text>
|
||||
|
|
@ -162,7 +201,7 @@ export function CallMessage({
|
|||
}
|
||||
header={
|
||||
<>
|
||||
<Username as="span">
|
||||
<Username as="span" style={usernameStyle}>
|
||||
<Text as="span" size={channelHeaderInBubble ? 'T200' : 'T400'} truncate>
|
||||
<UsernameBold>
|
||||
{isOwnMessage ? t('Direct.message_me_label') : senderName}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue