feat(channels): ship M3 channel timeline avatar-name layout with thread summary cards and drawer header counter polish

This commit is contained in:
v.lagerev 2026-05-10 01:06:29 +03:00
parent a2ee725e4b
commit e84c4da093
14 changed files with 746 additions and 49 deletions

View file

@ -509,12 +509,17 @@
"empty_message": "Empty message",
"edited": " (edited)",
"thread_in_channel": "Thread in #{{channel}}",
"thread_caption": "Thread",
"thread_in_channel_subtitle": "in #{{channel}}",
"thread_close": "Close thread",
"thread_no_replies": "No one has replied yet",
"thread_root_error": "Could not load the original message",
"thread_paginate_error": "Could not load replies",
"thread_retry": "Retry",
"thread_summary_count_one": "{{count}} reply",
"thread_summary_count_other": "{{count}} replies",
"thread_summary_open_thread": "Open thread",
"thread_summary_last_reply_by": "last reply from {{name}}",
"no_post_permission": "You do not have permission to post in this room",
"conversation_beginning": "This is the beginning of conversation.",

View file

@ -513,12 +513,19 @@
"empty_message": "Пустое сообщение",
"edited": " (изменено)",
"thread_in_channel": "Тред в #{{channel}}",
"thread_caption": "Тред",
"thread_in_channel_subtitle": "в #{{channel}}",
"thread_close": "Закрыть тред",
"thread_no_replies": "Никто пока не ответил",
"thread_root_error": "Не удалось загрузить исходное сообщение",
"thread_paginate_error": "Не удалось загрузить ответы",
"thread_retry": "Повторить",
"thread_summary_count_one": "{{count}} ответ",
"thread_summary_count_few": "{{count}} ответа",
"thread_summary_count_many": "{{count}} ответов",
"thread_summary_count_other": "{{count}} ответа",
"thread_summary_open_thread": "Открыть тред",
"thread_summary_last_reply_by": "последний ответ от {{name}}",
"no_post_permission": "У вас нет разрешения на отправку сообщений в этой комнате",
"conversation_beginning": "Начало переписки.",

View file

@ -2,6 +2,7 @@ import { Box, Icon, IconSrc, color } from 'folds';
import React, { ReactNode, useRef } from 'react';
import classNames from 'classnames';
import * as layoutCss from '../layout/layout.css';
import { ChannelEventContent } from '../layout/Channel';
import { useStreamLayoutDebug } from '../layout/streamDebug';
import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
@ -11,8 +12,19 @@ export type EventContentProps = {
content: ReactNode;
railStart?: boolean;
railEnd?: boolean;
// M3: pick rendering. `'stream'` = 3-track rail/dot grid (DM/Bots).
// `'channel'` = no-rail flex row (channels timeline). Time slot is
// dropped for channels — syslines render compact without timestamp.
layout?: 'stream' | 'channel';
};
export function EventContent({ time, iconSrc, content, railStart, railEnd }: EventContentProps) {
export function EventContent({
time,
iconSrc,
content,
railStart,
railEnd,
layout = 'stream',
}: EventContentProps) {
const compact = useScreenSizeContext() === ScreenSize.Mobile;
const rootRef = useRef<HTMLDivElement>(null);
const timeRef = useRef<HTMLDivElement>(null);
@ -32,6 +44,10 @@ export function EventContent({ time, iconSrc, content, railStart, railEnd }: Eve
true
);
if (layout === 'channel') {
return <ChannelEventContent iconSrc={iconSrc} content={content} />;
}
// Sysline = thin one-line state-event row that lives ON the rail.
// Same 3-track grid as message rows (StreamRoot) — track 1 timestamp,
// track 2 dot column, track 3 body — so the dot's X aligns with the

View file

@ -0,0 +1,123 @@
import { style } from '@vanilla-extract/css';
import { color, config, toRem } from 'folds';
// 36px circular avatar — a notch above folds `Avatar size="200"` (32px)
// for visual weight matching the channels mockup. Consumers override
// the folds preset via inline style; the shared `CHANNEL_AVATAR_PX`
// constant keeps the CSS slot width and the inline override in sync.
export const CHANNEL_AVATAR_PX = 36;
const ChannelAvatarWidth = toRem(CHANNEL_AVATAR_PX);
export const ChannelRow = style({
display: 'flex',
alignItems: 'flex-start',
gap: config.space.S300,
paddingLeft: config.space.S400,
paddingRight: config.space.S400,
paddingTop: config.space.S100,
paddingBottom: config.space.S100,
minWidth: 0,
// Hover bg subtle so adjacent rows still read as distinct units even
// without bubble borders. `@media (hover: hover)` keeps this inert on
// touch where there's no pointer to follow.
'@media': {
'(hover: hover) and (pointer: fine)': {
selectors: {
'&:hover': {
backgroundColor: color.SurfaceVariant.Container,
},
},
},
},
});
// Fixed-width slot keeps the body column aligned across collapsed rows
// (where `avatar` is `undefined` — the slot still occupies `ChannelAvatarWidth`,
// so the body's left edge stays put).
export const ChannelAvatarSlot = style({
width: ChannelAvatarWidth,
flexShrink: 0,
display: 'flex',
justifyContent: 'center',
paddingTop: toRem(2),
});
export const ChannelBody = style({
flex: 1,
minWidth: 0,
display: 'flex',
flexDirection: 'column',
gap: config.space.S100,
});
export const ChannelHeader = style({
display: 'flex',
alignItems: 'baseline',
gap: config.space.S200,
minWidth: 0,
});
export const ChannelMessageBody = style({
minWidth: 0,
});
export const ChannelReactions = style({
minWidth: 0,
marginTop: config.space.S100,
});
export const ChannelThreadSummary = style({
minWidth: 0,
marginTop: config.space.S100,
});
// Horizontal line + centered label. Spans the full row width including
// the avatar slot so the line reads as a section break, not a per-message
// chrome element.
export const ChannelDayDividerRoot = style({
display: 'flex',
alignItems: 'center',
gap: config.space.S300,
paddingLeft: config.space.S400,
paddingRight: config.space.S400,
paddingTop: config.space.S400,
paddingBottom: config.space.S400,
});
export const ChannelDayDividerLine = style({
flex: 1,
height: '1px',
backgroundColor: color.SurfaceVariant.ContainerLine,
});
export const ChannelDayDividerLabel = style({
fontSize: toRem(11),
fontWeight: 600,
letterSpacing: '0.06em',
textTransform: 'uppercase',
color: color.SurfaceVariant.OnContainer,
opacity: 0.7,
flexShrink: 0,
});
// Sysline (membership / room.create / pinned-events). Compact single
// row aligned with the body gutter — the sysline sits where messages'
// body would, indented past the avatar slot, so the column reads
// continuous.
export const ChannelSysline = style({
paddingLeft: `calc(${ChannelAvatarWidth} + ${config.space.S300} + ${config.space.S400})`,
paddingRight: config.space.S400,
paddingTop: config.space.S100,
paddingBottom: config.space.S100,
color: color.SurfaceVariant.OnContainer,
});
export const ChannelSyslineIcon = style({
flexShrink: 0,
opacity: 0.5,
});
export const ChannelSyslineBody = style({
minWidth: 0,
flex: 1,
});

View file

@ -0,0 +1,123 @@
import React, { type MouseEventHandler, type ReactNode } from 'react';
import classNames from 'classnames';
import { Avatar, Box, Icon, Icons, type IconSrc, as } from 'folds';
import { type Room } from 'matrix-js-sdk';
import * as css from './Channel.css';
import { CHANNEL_AVATAR_PX } from './Channel.css';
import { UserAvatar } from '../../user-avatar';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { getMemberAvatarMxc } from '../../../utils/room';
import { mxcUrlToHttp } from '../../../utils/matrix';
// MessageBase recipe `space` variant for channel rows. '300' tightens the
// vertical rhythm vs the '400' Stream uses for bubble rows — channel
// rows have no bubble border, so they don't need as much breathing room.
export const CHANNEL_MESSAGE_SPACING = '300' as const;
export type ChannelLayoutProps = {
// Avatar slot — pass `undefined` for collapsed rows (adjacent same-user)
// so the slot stays a fixed-width spacer and the body column aligns
// across all rows in the cluster.
avatar?: ReactNode;
// Username + time inline. `undefined` on collapsed rows.
header?: ReactNode;
reactions?: ReactNode;
threadSummary?: ReactNode;
onContextMenu?: MouseEventHandler<HTMLDivElement>;
};
// Channels timeline message row primitive. No rail. No bubble. Avatar
// + body two-column flex; body holds header (name + time), content,
// thread-summary, reactions in vertical flow.
export const ChannelLayout = as<'div', ChannelLayoutProps>(
(
{ className, avatar, header, reactions, threadSummary, onContextMenu, children, ...props },
ref
) => (
<div
className={classNames(css.ChannelRow, className)}
onContextMenu={onContextMenu}
{...props}
ref={ref}
>
<div className={css.ChannelAvatarSlot}>{avatar}</div>
<div className={css.ChannelBody}>
{header && <div className={css.ChannelHeader}>{header}</div>}
<div className={css.ChannelMessageBody}>{children}</div>
{threadSummary && <div className={css.ChannelThreadSummary}>{threadSummary}</div>}
{reactions && <div className={css.ChannelReactions}>{reactions}</div>}
</div>
</div>
)
);
export type ChannelDayDividerProps = {
label: ReactNode;
};
// Section break between days in the channels timeline. Horizontal line
// + centered uppercase label, spans full row including the avatar gutter
// so it reads as a structural separator.
export const ChannelDayDivider = as<'div', ChannelDayDividerProps>(
({ className, label, ...props }, ref) => (
<div
className={classNames(css.ChannelDayDividerRoot, className)}
role="separator"
aria-label={typeof label === 'string' ? label : undefined}
{...props}
ref={ref}
>
<span className={css.ChannelDayDividerLine} aria-hidden />
<span className={css.ChannelDayDividerLabel}>{label}</span>
<span className={css.ChannelDayDividerLine} aria-hidden />
</div>
)
);
export type ChannelEventContentProps = {
iconSrc: IconSrc;
content: ReactNode;
};
// Sysline (membership change / room.create / pinned events / etc) row
// for channels. Single-line, indented past the avatar slot so it visually
// belongs to the body column, no rail/timestamp chrome.
export function ChannelEventContent({ iconSrc, content }: ChannelEventContentProps) {
return (
<Box className={css.ChannelSysline} alignItems="Center" gap="200">
<Icon className={css.ChannelSyslineIcon} size="50" src={iconSrc} />
<div className={css.ChannelSyslineBody}>{content}</div>
</Box>
);
}
export type ChannelMessageAvatarProps = {
room: Room;
senderId: string;
senderDisplayName: string;
};
// Resolves the sender avatar (with auth + thumbnail) and renders the
// folds `<Avatar>` + `<UserAvatar>` combination. Lifted out of `Message`
// so the `useMediaAuthentication` / `useMatrixClient` hook calls only run
// when channel layout is selected (Stream rows don't need an avatar).
export function ChannelMessageAvatar({ room, senderId, senderDisplayName }: ChannelMessageAvatarProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const avatarMxc = getMemberAvatarMxc(room, senderId);
const avatarUrl = avatarMxc
? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined
: undefined;
return (
<Avatar size="300" style={{ width: CHANNEL_AVATAR_PX, height: CHANNEL_AVATAR_PX }}>
<UserAvatar
userId={senderId}
src={avatarUrl}
alt={senderDisplayName}
renderFallback={() => <Icon size="100" src={Icons.User} filled />}
/>
</Avatar>
);
}

View file

@ -43,6 +43,11 @@ export type StreamLayoutProps = {
// column — outside the bubble's bg/border so reactions read as
// floating chips on the page background, not as a part of the bubble.
reactions?: ReactNode;
// M3a: thread summary pill rendered BETWEEN the bubble and reactions
// for thread roots in channels-mode. Same column as reactions so it
// aligns with the bubble's left edge. Optional — caller passes
// `undefined` outside channels-mode or when the root has no replies.
threadSummary?: ReactNode;
};
export type StreamDayDividerProps = {
@ -101,6 +106,7 @@ export const StreamLayout = as<'div', StreamLayoutProps>(
railEnd,
mediaMode,
reactions,
threadSummary,
children,
...props
},
@ -174,6 +180,7 @@ export const StreamLayout = as<'div', StreamLayoutProps>(
)}
{children}
</div>
{threadSummary && <div className={css.StreamThreadSummary}>{threadSummary}</div>}
{reactions && <div className={css.StreamReactions}>{reactions}</div>}
</div>
</div>

View file

@ -1,3 +1,4 @@
export * from './Modern';
export * from './Stream';
export * from './Channel';
export * from './Base';

View file

@ -402,6 +402,15 @@ export const StreamReactions = style({
minWidth: 0,
});
// M3a: thread summary pill slot, sits between bubble and reactions in
// the same column. The card itself owns its margins/spacing — the
// wrapper just contributes `align-items: flex-start` from StreamColumn
// so the pill aligns to the bubble's left edge.
export const StreamThreadSummary = style({
maxWidth: '100%',
minWidth: 0,
});
export const StreamBubble = recipe({
base: {
backgroundColor: color.SurfaceVariant.Container,

View file

@ -63,6 +63,8 @@ import {
EventContent,
STREAM_MESSAGE_SPACING,
StreamDayDivider,
CHANNEL_MESSAGE_SPACING,
ChannelDayDivider,
} from '../../components/message';
import {
factoryRenderLinkifyWithMention,
@ -87,6 +89,7 @@ import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
import { useMatrixEventRenderer } from '../../hooks/useMatrixEventRenderer';
import { Reactions, Message, Event, EncryptedContent } from './message';
import { ThreadSummaryCard } from './ThreadSummaryCard';
import { useMemberEventParser } from '../../hooks/useMemberEventParser';
import * as customHtmlCss from '../../styles/CustomHtml.css';
import { RoomIntro } from '../../components/room-intro';
@ -490,6 +493,17 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
// nowhere to lead. Pre-M2 the button existed in those surfaces but
// never produced a visible thread.
const hideThreadReplyAffordance = !channelsMode || isBridged;
// M3a: thread summary pill rides a tighter gate — non-bridged channels
// are the only surface where threads are first-class. The card itself
// returns null when the root has no replies, so mounting on every
// visible row in channels-mode is safe (`useVirtualPaginator` keeps
// the rendered window bounded; SDK Room maxListeners is 100).
const showThreadSummary = channelsMode && !isBridged;
// M3: channels timeline uses avatar-first ChannelLayout (no rail, no
// bubble). DM/Bots stay on Stream rail. Bridged Telegram channels use
// Channel layout too — visually consistent with native channels — only
// the thread plus / cards differ (bridge has no thread semantic).
const messageLayout: 'stream' | 'channel' = channelsMode ? 'channel' : 'stream';
// M2: when the thread drawer is open, the channel composer is
// unmounted by RoomView (single-Slate-at-a-time pattern). Clicking
// the channel timeline's «Reply» menu would write a reply chip into
@ -1304,6 +1318,12 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
msgType={mEvent.getContent().msgtype ?? ''}
hideThreadReplyAffordance={hideThreadReplyAffordance}
hideMainReplyAffordance={hideMainReplyAffordance}
threadSummary={
showThreadSummary ? (
<ThreadSummaryCard room={room} rootEvent={mEvent} />
) : undefined
}
layout={messageLayout}
>
{mEvent.isRedacted() ? (
<RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} />
@ -1407,6 +1427,12 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
msgType={mEvent.getContent().msgtype ?? ''}
hideThreadReplyAffordance={hideThreadReplyAffordance}
hideMainReplyAffordance={hideMainReplyAffordance}
threadSummary={
showThreadSummary ? (
<ThreadSummaryCard room={room} rootEvent={mEvent} />
) : undefined
}
layout={messageLayout}
>
{(() => {
if (mEvent.isRedacted()) return <RedactedContent />;
@ -1521,6 +1547,12 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
streamRailEnd={streamRailEnd}
hideThreadReplyAffordance={hideThreadReplyAffordance}
hideMainReplyAffordance={hideMainReplyAffordance}
threadSummary={
showThreadSummary ? (
<ThreadSummaryCard room={room} rootEvent={mEvent} />
) : undefined
}
layout={messageLayout}
>
{mEvent.isRedacted() ? (
<RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} />
@ -1584,6 +1616,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
time={timeJSX}
railStart={streamRailStart}
railEnd={streamRailEnd}
layout={messageLayout}
iconSrc={iconSrc}
content={
<Box grow="Yes" direction="Column">
@ -1632,6 +1665,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
time={timeJSX}
railStart={streamRailStart}
railEnd={streamRailEnd}
layout={messageLayout}
iconSrc={Icons.Hash}
content={
<Box grow="Yes" direction="Column">
@ -1681,6 +1715,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
time={timeJSX}
railStart={streamRailStart}
railEnd={streamRailEnd}
layout={messageLayout}
iconSrc={Icons.Hash}
content={
<Box grow="Yes" direction="Column">
@ -1730,6 +1765,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
time={timeJSX}
railStart={streamRailStart}
railEnd={streamRailEnd}
layout={messageLayout}
iconSrc={Icons.Hash}
content={
<Box grow="Yes" direction="Column">
@ -1787,6 +1823,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
time={timeJSX}
railStart={streamRailStart}
railEnd={streamRailEnd}
layout={messageLayout}
iconSrc={callJoined ? Icons.Phone : Icons.PhoneDown}
content={
<Box grow="Yes" direction="Column">
@ -1833,6 +1870,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
time={timeJSX}
railStart={streamRailStart}
railEnd={streamRailEnd}
layout={messageLayout}
iconSrc={Icons.Code}
content={
<Box grow="Yes" direction="Column">
@ -1881,6 +1919,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
time={timeJSX}
railStart={streamRailStart}
railEnd={streamRailEnd}
layout={messageLayout}
iconSrc={Icons.Code}
content={
<Box grow="Yes" direction="Column">
@ -2092,8 +2131,14 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
})();
const renderDayDivider = () => (
<MessageBase space={STREAM_MESSAGE_SPACING}>
<StreamDayDivider label={dayLabel} />
<MessageBase
space={messageLayout === 'channel' ? CHANNEL_MESSAGE_SPACING : STREAM_MESSAGE_SPACING}
>
{messageLayout === 'channel' ? (
<ChannelDayDivider label={dayLabel} />
) : (
<StreamDayDivider label={dayLabel} />
)}
</MessageBase>
);

View file

@ -83,6 +83,51 @@ export const ThreadDivider = style({
flexShrink: 0,
});
// Two-line drawer header caption + subtitle (M3 polish). The caption is
// uppercase tracking-wide ALL-CAPS «ТРЕД», the subtitle is the channel
// reference «в #channel».
export const ThreadDrawerHeaderCaption = style({
textTransform: 'uppercase',
letterSpacing: '0.08em',
fontWeight: 600,
color: color.SurfaceVariant.OnContainer,
opacity: 0.6,
});
export const ThreadDrawerHeaderSubtitle = style({
color: color.Surface.OnContainer,
fontWeight: 500,
});
// Counter row between the root and replies: dot + «N ОТВЕТОВ» uppercase
// label. Replaces the static 1-px divider when at least one reply exists.
export const ThreadCounterRow = style({
display: 'flex',
alignItems: 'center',
gap: config.space.S200,
padding: `${config.space.S100} ${config.space.S400}`,
borderTop: `1px solid ${color.Surface.ContainerLine}`,
borderBottom: `1px solid ${color.Surface.ContainerLine}`,
flexShrink: 0,
});
export const ThreadCounterDot = style({
flexShrink: 0,
width: '6px',
height: '6px',
borderRadius: '50%',
backgroundColor: color.Primary.Main,
});
export const ThreadCounterText = style({
textTransform: 'uppercase',
letterSpacing: '0.08em',
fontSize: '11px',
fontWeight: 600,
color: color.SurfaceVariant.OnContainer,
opacity: 0.7,
});
export const ThreadComposer = style({
flexShrink: 0,
padding: `0 ${config.space.S400} ${config.space.S400}`,

View file

@ -149,21 +149,21 @@ function ThreadEventCard({
return (
<div className={css.ThreadEventCard}>
<div className={css.ThreadEventAvatar}>
<Avatar size="200">
<Avatar size="300" style={{ width: '32px', height: '32px' }}>
<UserAvatar
userId={senderId}
src={avatarUrl ?? undefined}
alt={senderDisplayName}
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
renderFallback={() => <Icon size="100" src={Icons.User} filled />}
/>
</Avatar>
</div>
<div className={css.ThreadEventBody}>
<Box alignItems="Center" gap="200">
<Text size="T300">
<Text size="T400">
<b>{senderDisplayName}</b>
</Text>
<Time ts={mEvent.getTs()} compact />
<Time ts={mEvent.getTs()} compact size="T200" priority="300" />
</Box>
{mEvent.isRedacted() ? (
<RedactedContent
@ -781,7 +781,24 @@ export function ThreadDrawer({
mediaAutoLoad={mediaAutoLoad}
showUrlPreview={showUrlPreview}
/>
<div className={css.ThreadDivider} />
{(() => {
// Counter prefers the larger of materialized `thread.length`
// (replyCount + pending) and loaded `replies.length` — covers
// the cold-load window where the SDK can synthesize an empty
// Thread (length=0) shortly before the relations fetch lands
// a populated `replies` list (`??` would have collapsed to 0
// and silently shown the divider despite present replies).
const counterCount = Math.max(thread?.length ?? 0, replies.length);
if (counterCount === 0) return <div className={css.ThreadDivider} />;
return (
<div className={css.ThreadCounterRow} aria-hidden>
<span className={css.ThreadCounterDot} />
<span className={css.ThreadCounterText}>
{t('Room.thread_summary_count', { count: counterCount })}
</span>
</div>
);
})()}
{coldLoadError && (
<div className={css.ThreadErrorState}>
<Text size="T300" align="Center" priority="400">
@ -857,10 +874,12 @@ export function ThreadDrawer({
>
<Header className={css.ThreadDrawerHeader} variant="Background" size="600">
<Box grow="Yes" alignItems="Center" gap="200">
<Box grow="Yes" alignItems="Center" gap="200" id={headerId}>
<Icon src={Icons.Hash} size="100" />
<Text size="H5" truncate>
{t('Room.thread_in_channel', { channel: room.name ?? '' })}
<Box grow="Yes" direction="Column" id={headerId}>
<Text className={css.ThreadDrawerHeaderCaption} as="span" size="T200">
{t('Room.thread_caption')}
</Text>
<Text className={css.ThreadDrawerHeaderSubtitle} as="span" size="T200" truncate>
{t('Room.thread_in_channel_subtitle', { channel: room.name ?? '' })}
</Text>
</Box>
<Box shrink="No" alignItems="Center">

View file

@ -0,0 +1,72 @@
import { style } from '@vanilla-extract/css';
import { color, config, toRem } from 'folds';
// Compact dark pill rendered inside `ChannelLayout.threadSummary` slot
// (between the message body and reactions). Click target is the whole
// pill — no inner focusable elements, single tab stop.
//
// Visual: dot indicator + bold accent count + bullet + time + chevron.
// Bg uses the SurfaceVariant elevation chain (Container → ContainerHover
// → ContainerActive on hover/focus) so the pill reads as a tappable
// raised target against both the canvas and the row's own hover bg.
export const Root = style({
display: 'inline-flex',
alignItems: 'center',
gap: config.space.S200,
padding: `${config.space.S100} ${config.space.S300}`,
borderRadius: config.radii.R400,
border: 'none',
// One step above `SurfaceVariant.Container` so the pill reads as a
// tappable, raised target against both the canvas (`Surface.Container`)
// and the row's hover bg.
background: color.SurfaceVariant.ContainerHover,
color: color.SurfaceVariant.OnContainer,
cursor: 'pointer',
maxWidth: toRem(540),
minWidth: 0,
textAlign: 'left',
appearance: 'none',
font: 'inherit',
selectors: {
'&:hover': {
backgroundColor: color.SurfaceVariant.ContainerActive,
},
'&:focus-visible': {
outline: `${config.borderWidth.B400} solid ${color.Primary.Main}`,
outlineOffset: toRem(1),
backgroundColor: color.SurfaceVariant.ContainerActive,
},
},
});
// 6px dot indicator. Filled `Primary.Main` — on M4 will flip to
// `OnContainerLine` for read threads, kept static on M3.
export const Dot = style({
flexShrink: 0,
width: toRem(6),
height: toRem(6),
borderRadius: '50%',
backgroundColor: color.Primary.Main,
});
export const Count = style({
flexShrink: 0,
color: color.Primary.Main,
});
export const Separator = style({
flexShrink: 0,
color: color.SurfaceVariant.OnContainer,
opacity: 0.5,
});
export const TimeText = style({
flexShrink: 0,
});
export const Chevron = style({
flexShrink: 0,
marginLeft: config.space.S100,
color: color.SurfaceVariant.OnContainer,
opacity: 0.5,
});

View file

@ -0,0 +1,164 @@
import React, { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import classNames from 'classnames';
import { Icon, Icons, Text, as } from 'folds';
import {
type IThreadBundledRelationship,
type MatrixEvent,
RelationType,
type Room,
type Thread,
ThreadEvent,
} from 'matrix-js-sdk';
import * as css from './ThreadSummaryCard.css';
import { Time } from '../../components/message';
import { getChannelsThreadPath } from '../../pages/pathUtils';
import { getMemberDisplayName } from '../../utils/room';
import { getMxIdLocalPart } from '../../utils/matrix';
export type ThreadSummaryCardProps = {
room: Room;
rootEvent: MatrixEvent;
};
// Compact dark-pill summary rendered under thread roots in the channels
// timeline. Shows: dot indicator + reply count (bold, Primary.Main) +
// last reply time + chevron. Click navigates to
// `/channels/<space>/<room>/thread/<rootId>/` (M2 drawer).
//
// State model: tracks the SDK `Thread` object (created lazily on first
// reply, fired via `ThreadEvent.New` on the room) and re-renders on
// `ThreadEvent.Update` / `NewReply` for live count and time updates.
// Falls back to `mEvent.getServerAggregatedRelation('m.thread')` count
// + bundled `latest_event.origin_server_ts` for cold-load before the
// Thread is materialized.
//
// Returns `null` when there are no replies — mounting on every channel
// root is fine because of viewport virtualization in `RoomTimeline`.
export const ThreadSummaryCard = as<'button', ThreadSummaryCardProps>(
({ room, rootEvent, className, ...props }, ref) => {
const { t } = useTranslation();
const navigate = useNavigate();
const { spaceIdOrAlias, roomIdOrAlias } = useParams();
const rootId = rootEvent.getId();
const [thread, setThread] = useState<Thread | null>(() =>
rootId ? rootEvent.getThread() ?? room.getThread(rootId) ?? null : null
);
// Catch the moment SDK promotes the root into a Thread (first reply
// arrives or relations come in via cold-load fetch). `ThreadEvent.New`
// fires on `Room` (not Thread). Listener is one-shot — once we match
// our rootId we detach so unrelated thread births in the same room
// don't keep waking this row up. Skipping attach entirely when we
// already have a thread keeps non-null instances listener-free.
useEffect(() => {
if (!rootId || thread) return undefined;
const handleNew = (newThread: Thread) => {
if (newThread.id === rootId) {
setThread(newThread);
room.off(ThreadEvent.New, handleNew);
}
};
room.on(ThreadEvent.New, handleNew);
// Race re-check: SDK may have created the Thread between the lazy
// useState initializer and this effect mount. Cheap insurance —
// mirrors the cold-load repair pattern in ThreadDrawer.tsx.
const existing = room.getThread(rootId);
if (existing) {
setThread(existing);
room.off(ThreadEvent.New, handleNew);
return undefined;
}
return () => {
room.off(ThreadEvent.New, handleNew);
};
}, [room, rootId, thread]);
// Force a re-render on thread metadata changes. `ThreadEvent.NewReply`
// is intentionally NOT subscribed: SDK emits Update right after every
// NewReply (`models/thread.js::addEvent` → `updateThreadMetadata`), so
// listening on both fires the tick twice per arrival. Capture the
// emitter ref inside the effect for cleanup leak-safety against rare
// snapshot replacements after timeline reset.
const [, setVersion] = useState(0);
useEffect(() => {
if (!thread) return undefined;
const localThread = thread;
const tick = () => setVersion((v) => v + 1);
localThread.on(ThreadEvent.Update, tick);
return () => {
localThread.off(ThreadEvent.Update, tick);
};
}, [thread]);
const bundled = rootId
? rootEvent.getServerAggregatedRelation<IThreadBundledRelationship>(RelationType.Thread)
: undefined;
// When a Thread is materialized, trust its length even if it's 0 —
// bundled aggregations don't shrink on redaction so they'd otherwise
// surface stale counts on threads where every reply was redacted.
// Bundled is the cold-load fallback only.
const count = thread !== null ? thread.length : bundled?.count ?? 0;
if (!rootId || count === 0) return null;
const latestReply: MatrixEvent | undefined = thread?.replyToEvent ?? undefined;
// Time source: prefer live Thread.replyToEvent timestamp; fall back to
// bundled aggregation's `latest_event.origin_server_ts` so cold-loaded
// cards still show a time before the SDK materializes the Thread.
const lastTs =
latestReply?.getTs() ??
(typeof bundled?.latest_event?.origin_server_ts === 'number'
? bundled.latest_event.origin_server_ts
: undefined);
// Last-sender display name kept for screen-reader aria-label only —
// visual surface is dot + count + time + chevron per current mockup.
const senderId = latestReply?.getSender();
const senderDisplayName = senderId
? getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId
: null;
const countLabel = t('Room.thread_summary_count', { count });
const ariaParts = [t('Room.thread_summary_open_thread'), countLabel];
if (senderDisplayName) {
ariaParts.push(t('Room.thread_summary_last_reply_by', { name: senderDisplayName }));
}
const ariaLabel = ariaParts.join(', ');
const handleOpen = () => {
if (!spaceIdOrAlias || !roomIdOrAlias) return;
navigate(getChannelsThreadPath(spaceIdOrAlias, roomIdOrAlias, rootId));
};
return (
<button
type="button"
ref={ref}
className={classNames(css.Root, className)}
onClick={handleOpen}
aria-label={ariaLabel}
data-event-id={rootId}
{...props}
>
<span className={css.Dot} aria-hidden />
<Text className={css.Count} as="span" size="T200">
<b>{countLabel}</b>
</Text>
{lastTs && (
<>
<span className={css.Separator} aria-hidden>
·
</span>
<Time className={css.TimeText} ts={lastTs} compact size="T200" priority="300" />
</>
)}
<Icon className={css.Chevron} src={Icons.ChevronRight} size="100" />
</button>
);
}
);

View file

@ -38,6 +38,9 @@ import { Relations } from 'matrix-js-sdk/lib/models/relations';
import classNames from 'classnames';
import { RoomPinnedEventsEventContent } from 'matrix-js-sdk/lib/types';
import {
CHANNEL_MESSAGE_SPACING,
ChannelLayout,
ChannelMessageAvatar,
MessageBase,
STREAM_MESSAGE_SPACING,
StreamLayout,
@ -692,6 +695,16 @@ export type MessageProps = {
// mediaMode reliably when EncryptedContent re-renders post-decrypt —
// local useState would race against the commit↔effect gap.
msgType?: string;
// M3a: thread summary pill (count + last reply preview) rendered
// between the bubble and reactions. Caller (`RoomTimeline`) decides
// whether to mount the card based on `channelsMode && !isBridged` —
// outside channels-mode this stays `undefined` and the slot collapses.
threadSummary?: React.ReactNode;
// M3: choose timeline visual. `'stream'` = rail + dot + bubble (current
// DM/Bots layout). `'channel'` = avatar + name + time inline + body
// without bubble (Slack/Discord-style channels timeline). Default
// `'stream'` so non-channels callers don't have to opt in.
layout?: 'stream' | 'channel';
};
export const Message = as<'div', MessageProps>(
(
@ -724,6 +737,8 @@ export const Message = as<'div', MessageProps>(
hideThreadReplyAffordance,
hideMainReplyAffordance,
msgType,
threadSummary,
layout = 'stream',
children,
...props
},
@ -775,7 +790,10 @@ export const Message = as<'div', MessageProps>(
const streamMediaCtx = useMemo(
() =>
mediaMode
// Stream-only: the overlay username on top of media collapses the
// bubble's header slot. Channel layout renders username above the
// image normally, so the overlay is suppressed (`null` ctx).
mediaMode && layout === 'stream'
? {
own: isOwnMessage,
username: isOwnMessage ? t('Direct.message_me_label') : senderDisplayName,
@ -786,6 +804,7 @@ export const Message = as<'div', MessageProps>(
: null,
[
mediaMode,
layout,
isOwnMessage,
senderDisplayName,
senderId,
@ -866,7 +885,7 @@ export const Message = as<'div', MessageProps>(
<MessageBase
className={classNames(css.MessageBase, className)}
tabIndex={0}
space={STREAM_MESSAGE_SPACING}
space={layout === 'channel' ? CHANNEL_MESSAGE_SPACING : STREAM_MESSAGE_SPACING}
collapse={collapse}
highlight={highlight}
selected={!!menuAnchor || !!emojiBoardAnchor}
@ -1119,39 +1138,81 @@ export const Message = as<'div', MessageProps>(
</Menu>
</div>
)}
<StreamLayout
time={<Time ts={mEvent.getTs()} compact />}
dotColor={dot.color}
dotOpacity={dot.opacity}
isOwn={isOwnMessage}
compact={isMobile}
railStart={streamRailStart}
railEnd={streamRailEnd}
mediaMode={mediaMode}
reactions={reactions}
header={
mediaMode ? undefined : (
<Username
as="button"
style={{ color: usernameColor ?? color.Primary.Main }}
data-user-id={senderId}
onContextMenu={onUserClick}
onClick={onUsernameClick}
>
<Text as="span" size="T200" truncate>
<UsernameBold>
{isOwnMessage ? t('Direct.message_me_label') : senderDisplayName}
</UsernameBold>
</Text>
</Username>
)
}
onContextMenu={handleContextMenu}
>
<StreamMediaContext.Provider value={streamMediaCtx}>
{msgContentJSX}
</StreamMediaContext.Provider>
</StreamLayout>
{layout === 'channel' ? (
<ChannelLayout
avatar={
!collapse ? (
<ChannelMessageAvatar
room={room}
senderId={senderId}
senderDisplayName={senderDisplayName}
/>
) : undefined
}
header={
!collapse ? (
<>
<Username
as="button"
style={{ color: usernameColor ?? color.Primary.Main }}
data-user-id={senderId}
onContextMenu={onUserClick}
onClick={onUsernameClick}
>
<Text as="span" size="T400" truncate>
<UsernameBold>
{isOwnMessage ? t('Direct.message_me_label') : senderDisplayName}
</UsernameBold>
</Text>
</Username>
<Time ts={mEvent.getTs()} compact size="T200" priority="300" />
</>
) : undefined
}
reactions={reactions}
threadSummary={threadSummary}
onContextMenu={handleContextMenu}
>
<StreamMediaContext.Provider value={streamMediaCtx}>
{msgContentJSX}
</StreamMediaContext.Provider>
</ChannelLayout>
) : (
<StreamLayout
time={<Time ts={mEvent.getTs()} compact />}
dotColor={dot.color}
dotOpacity={dot.opacity}
isOwn={isOwnMessage}
compact={isMobile}
railStart={streamRailStart}
railEnd={streamRailEnd}
mediaMode={mediaMode}
reactions={reactions}
threadSummary={threadSummary}
header={
mediaMode ? undefined : (
<Username
as="button"
style={{ color: usernameColor ?? color.Primary.Main }}
data-user-id={senderId}
onContextMenu={onUserClick}
onClick={onUsernameClick}
>
<Text as="span" size="T200" truncate>
<UsernameBold>
{isOwnMessage ? t('Direct.message_me_label') : senderDisplayName}
</UsernameBold>
</Text>
</Username>
)
}
onContextMenu={handleContextMenu}
>
<StreamMediaContext.Provider value={streamMediaCtx}>
{msgContentJSX}
</StreamMediaContext.Provider>
</StreamLayout>
)}
</MessageBase>
);
}