From 307af24d1e5d8327d7bc82f159128039f9ed76d6 Mon Sep 17 00:00:00 2001 From: heaven Date: Sun, 10 May 2026 01:06:29 +0300 Subject: [PATCH] feat(channels): ship M3 channel timeline avatar-name layout with thread summary cards and drawer header counter polish --- public/locales/en.json | 7 +- public/locales/ru.json | 9 +- .../message/content/EventContent.tsx | 18 +- .../components/message/layout/Channel.css.ts | 123 +++++++++++++ src/app/components/message/layout/Channel.tsx | 123 +++++++++++++ src/app/components/message/layout/Stream.tsx | 7 + src/app/components/message/layout/index.ts | 1 + .../components/message/layout/layout.css.ts | 9 + src/app/features/room/RoomTimeline.tsx | 49 +++++- src/app/features/room/ThreadDrawer.css.ts | 45 +++++ src/app/features/room/ThreadDrawer.tsx | 37 +++- .../features/room/ThreadSummaryCard.css.ts | 72 ++++++++ src/app/features/room/ThreadSummaryCard.tsx | 164 ++++++++++++++++++ src/app/features/room/message/Message.tsx | 131 ++++++++++---- 14 files changed, 746 insertions(+), 49 deletions(-) create mode 100644 src/app/components/message/layout/Channel.css.ts create mode 100644 src/app/components/message/layout/Channel.tsx create mode 100644 src/app/features/room/ThreadSummaryCard.css.ts create mode 100644 src/app/features/room/ThreadSummaryCard.tsx diff --git a/public/locales/en.json b/public/locales/en.json index fce5c55c..1342eb89 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -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.", diff --git a/public/locales/ru.json b/public/locales/ru.json index 134b26d6..7c3eecbd 100644 --- a/public/locales/ru.json +++ b/public/locales/ru.json @@ -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": "Начало переписки.", diff --git a/src/app/components/message/content/EventContent.tsx b/src/app/components/message/content/EventContent.tsx index 343ecd8c..01f49390 100644 --- a/src/app/components/message/content/EventContent.tsx +++ b/src/app/components/message/content/EventContent.tsx @@ -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(null); const timeRef = useRef(null); @@ -32,6 +44,10 @@ export function EventContent({ time, iconSrc, content, railStart, railEnd }: Eve true ); + if (layout === 'channel') { + return ; + } + // 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 diff --git a/src/app/components/message/layout/Channel.css.ts b/src/app/components/message/layout/Channel.css.ts new file mode 100644 index 00000000..9b23b97c --- /dev/null +++ b/src/app/components/message/layout/Channel.css.ts @@ -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, +}); diff --git a/src/app/components/message/layout/Channel.tsx b/src/app/components/message/layout/Channel.tsx new file mode 100644 index 00000000..b4fb4c5d --- /dev/null +++ b/src/app/components/message/layout/Channel.tsx @@ -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; +}; + +// 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 + ) => ( +
+
{avatar}
+
+ {header &&
{header}
} +
{children}
+ {threadSummary &&
{threadSummary}
} + {reactions &&
{reactions}
} +
+
+ ) +); + +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) => ( +
+ + {label} + +
+ ) +); + +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 ( + + +
{content}
+
+ ); +} + +export type ChannelMessageAvatarProps = { + room: Room; + senderId: string; + senderDisplayName: string; +}; + +// Resolves the sender avatar (with auth + thumbnail) and renders the +// folds `` + `` 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 ( + + } + /> + + ); +} diff --git a/src/app/components/message/layout/Stream.tsx b/src/app/components/message/layout/Stream.tsx index d1fd67a8..6da75a66 100644 --- a/src/app/components/message/layout/Stream.tsx +++ b/src/app/components/message/layout/Stream.tsx @@ -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} + {threadSummary &&
{threadSummary}
} {reactions &&
{reactions}
} diff --git a/src/app/components/message/layout/index.ts b/src/app/components/message/layout/index.ts index cb968f92..cc5381bc 100644 --- a/src/app/components/message/layout/index.ts +++ b/src/app/components/message/layout/index.ts @@ -1,3 +1,4 @@ export * from './Modern'; export * from './Stream'; +export * from './Channel'; export * from './Base'; diff --git a/src/app/components/message/layout/layout.css.ts b/src/app/components/message/layout/layout.css.ts index 8001899d..5a301542 100644 --- a/src/app/components/message/layout/layout.css.ts +++ b/src/app/components/message/layout/layout.css.ts @@ -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, diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 3d420884..44657c94 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -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 ? ( + + ) : undefined + } + layout={messageLayout} > {mEvent.isRedacted() ? ( @@ -1407,6 +1427,12 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli msgType={mEvent.getContent().msgtype ?? ''} hideThreadReplyAffordance={hideThreadReplyAffordance} hideMainReplyAffordance={hideMainReplyAffordance} + threadSummary={ + showThreadSummary ? ( + + ) : undefined + } + layout={messageLayout} > {(() => { if (mEvent.isRedacted()) return ; @@ -1521,6 +1547,12 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli streamRailEnd={streamRailEnd} hideThreadReplyAffordance={hideThreadReplyAffordance} hideMainReplyAffordance={hideMainReplyAffordance} + threadSummary={ + showThreadSummary ? ( + + ) : undefined + } + layout={messageLayout} > {mEvent.isRedacted() ? ( @@ -1584,6 +1616,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli time={timeJSX} railStart={streamRailStart} railEnd={streamRailEnd} + layout={messageLayout} iconSrc={iconSrc} content={ @@ -1632,6 +1665,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli time={timeJSX} railStart={streamRailStart} railEnd={streamRailEnd} + layout={messageLayout} iconSrc={Icons.Hash} content={ @@ -1681,6 +1715,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli time={timeJSX} railStart={streamRailStart} railEnd={streamRailEnd} + layout={messageLayout} iconSrc={Icons.Hash} content={ @@ -1730,6 +1765,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli time={timeJSX} railStart={streamRailStart} railEnd={streamRailEnd} + layout={messageLayout} iconSrc={Icons.Hash} content={ @@ -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={ @@ -1833,6 +1870,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli time={timeJSX} railStart={streamRailStart} railEnd={streamRailEnd} + layout={messageLayout} iconSrc={Icons.Code} content={ @@ -1881,6 +1919,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli time={timeJSX} railStart={streamRailStart} railEnd={streamRailEnd} + layout={messageLayout} iconSrc={Icons.Code} content={ @@ -2092,8 +2131,14 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli })(); const renderDayDivider = () => ( - - + + {messageLayout === 'channel' ? ( + + ) : ( + + )} ); diff --git a/src/app/features/room/ThreadDrawer.css.ts b/src/app/features/room/ThreadDrawer.css.ts index 97c7aa52..16ecfd1b 100644 --- a/src/app/features/room/ThreadDrawer.css.ts +++ b/src/app/features/room/ThreadDrawer.css.ts @@ -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}`, diff --git a/src/app/features/room/ThreadDrawer.tsx b/src/app/features/room/ThreadDrawer.tsx index 93204799..c5011bf7 100644 --- a/src/app/features/room/ThreadDrawer.tsx +++ b/src/app/features/room/ThreadDrawer.tsx @@ -149,21 +149,21 @@ function ThreadEventCard({ return (
- + } + renderFallback={() => } />
- + {senderDisplayName} - {mEvent.isRedacted() ? ( -
+ {(() => { + // 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
; + return ( +
+ + + {t('Room.thread_summary_count', { count: counterCount })} + +
+ ); + })()} {coldLoadError && (
@@ -857,10 +874,12 @@ export function ThreadDrawer({ >
- - - - {t('Room.thread_in_channel', { channel: room.name ?? '' })} + + + {t('Room.thread_caption')} + + + {t('Room.thread_in_channel_subtitle', { channel: room.name ?? '' })} diff --git a/src/app/features/room/ThreadSummaryCard.css.ts b/src/app/features/room/ThreadSummaryCard.css.ts new file mode 100644 index 00000000..a63332d0 --- /dev/null +++ b/src/app/features/room/ThreadSummaryCard.css.ts @@ -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, +}); diff --git a/src/app/features/room/ThreadSummaryCard.tsx b/src/app/features/room/ThreadSummaryCard.tsx new file mode 100644 index 00000000..ad866e89 --- /dev/null +++ b/src/app/features/room/ThreadSummaryCard.tsx @@ -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///thread//` (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(() => + 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(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 ( + + ); + } +); diff --git a/src/app/features/room/message/Message.tsx b/src/app/features/room/message/Message.tsx index 545bad6d..692a9f6a 100644 --- a/src/app/features/room/message/Message.tsx +++ b/src/app/features/room/message/Message.tsx @@ -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>( (
)} - } - dotColor={dot.color} - dotOpacity={dot.opacity} - isOwn={isOwnMessage} - compact={isMobile} - railStart={streamRailStart} - railEnd={streamRailEnd} - mediaMode={mediaMode} - reactions={reactions} - header={ - mediaMode ? undefined : ( - - - - {isOwnMessage ? t('Direct.message_me_label') : senderDisplayName} - - - - ) - } - onContextMenu={handleContextMenu} - > - - {msgContentJSX} - - + {layout === 'channel' ? ( + + ) : undefined + } + header={ + !collapse ? ( + <> + + + + {isOwnMessage ? t('Direct.message_me_label') : senderDisplayName} + + + + + ) : ( + } + dotColor={dot.color} + dotOpacity={dot.opacity} + isOwn={isOwnMessage} + compact={isMobile} + railStart={streamRailStart} + railEnd={streamRailEnd} + mediaMode={mediaMode} + reactions={reactions} + threadSummary={threadSummary} + header={ + mediaMode ? undefined : ( + + + + {isOwnMessage ? t('Direct.message_me_label') : senderDisplayName} + + + + ) + } + onContextMenu={handleContextMenu} + > + + {msgContentJSX} + + + )} ); }