From c9ea22d2d4233e4149c906b7d8dcdd2c6bbff38c Mon Sep 17 00:00:00 2001 From: "v.lagerev" Date: Wed, 13 May 2026 15:21:13 +0300 Subject: [PATCH] feat(channels): rebuild thread drawer and channel rows as chat-style bubble cards that merge thread-summary into the bubble footer --- .../components/message/layout/Channel.css.ts | 110 +++++++++++++++++- src/app/components/message/layout/Channel.tsx | 48 +++++++- src/app/features/room/RoomTimeline.tsx | 15 +++ src/app/features/room/ThreadDrawer.css.ts | 36 +++++- src/app/features/room/ThreadDrawer.tsx | 4 +- src/app/features/room/message/Message.tsx | 19 ++- 6 files changed, 219 insertions(+), 13 deletions(-) diff --git a/src/app/components/message/layout/Channel.css.ts b/src/app/components/message/layout/Channel.css.ts index 9b23b97c..aa197f9c 100644 --- a/src/app/components/message/layout/Channel.css.ts +++ b/src/app/components/message/layout/Channel.css.ts @@ -1,4 +1,4 @@ -import { style } from '@vanilla-extract/css'; +import { globalStyle, style } from '@vanilla-extract/css'; import { color, config, toRem } from 'folds'; // 36px circular avatar — a notch above folds `Avatar size="200"` (32px) @@ -121,3 +121,111 @@ export const ChannelSyslineBody = style({ minWidth: 0, flex: 1, }); + +// Bubble chrome applied when `ChannelLayout` is invoked with +// `headerInBubble` (thread drawer and channels main timeline pass it). +// Mirrors `StreamBubble` from the DM timeline so a channel row reads +// like a chat-bubble cluster: dark `Surface.Container` card with an +// asymmetric notch corner per `data-own`, sized `fit-content` so short +// bubbles shrink-wrap instead of stretching across the column. +// Reactions and the thread-summary card live as siblings of the body +// in `ChannelLayout`, so they stay OUTSIDE the bubble — identical +// composition to Stream. The `[data-bubble="true"]` row marker keeps +// the un-bubbled channel/sysline layout (pre-redesign callers) opt-in +// rather than forcing the look on every consumer. +globalStyle(`${ChannelRow}[data-bubble="true"] ${ChannelMessageBody}`, { + backgroundColor: color.Surface.Container, + color: color.SurfaceVariant.OnContainer, + border: `1px solid ${color.Surface.ContainerLine}`, + paddingTop: config.space.S200, + paddingBottom: config.space.S200, + paddingLeft: toRem(15), + paddingRight: toRem(15), + display: 'inline-block', + width: 'fit-content', + maxWidth: '100%', + minWidth: 0, + position: 'relative', + zIndex: 1, + // Clips the thread-summary footer's hover bg against the bubble's + // rounded BR/BL corners — without it the rectangular hover paint + // punches past the curve. No outflow content lives inside the bubble + // (option bar, reactions are siblings on the row) so clipping is + // safe. + overflow: 'hidden', +}); + +// Asymmetric corner per `data-own` — own messages flatten TOP-LEFT +// (4px), incoming messages flatten BOTTOM-LEFT. Same pattern as +// `StreamBubble.own`/`StreamBubble.others`. +globalStyle( + `${ChannelRow}[data-bubble="true"][data-own="true"] ${ChannelMessageBody}`, + { + borderRadius: `${toRem(4)} ${config.radii.R500} ${config.radii.R500} ${config.radii.R500}`, + } +); + +globalStyle( + `${ChannelRow}[data-bubble="true"][data-own="false"] ${ChannelMessageBody}`, + { + borderRadius: `${config.radii.R500} ${config.radii.R500} ${config.radii.R500} ${toRem(4)}`, + } +); + +// Small gap so the in-bubble header (username + time) doesn't sit flush +// against the first line of message text. Matches `StreamBubbleHeader`'s +// 2px gap. +globalStyle( + `${ChannelRow}[data-bubble="true"] ${ChannelHeader}[data-in-bubble="true"]`, + { + marginBottom: toRem(2), + } +); + +// Thread-summary footer rendered INSIDE the bubble (rather than as a +// separate pill below). Negative L/R margin (matches the bubble's +// `paddingLeft/Right: 15px`) stretches the wrapper to the bubble's +// inner border edge so the 1px top rule reads as a section divider +// spanning the whole bubble. Negative `marginBottom` cancels the +// bubble's S200 bottom pad so the footer flushes against the bubble's +// rounded bottom edge — bubble + summary read as one card with a +// horizontal rule splitting them. +// +// The footer body keeps no own border/radius — it inherits the bubble's +// bottom corners via clipping (`ChannelMessageBody` itself doesn't +// `overflow: hidden`, but the rounded bottom of the bubble visually +// caps the footer anyway because the divider line never reaches the +// curved corner pixel). +export const ChannelBubbleThreadSummary = style({ + marginTop: config.space.S200, + marginLeft: toRem(-15), + marginRight: toRem(-15), + marginBottom: `calc(-1 * ${config.space.S200})`, + borderTop: `1px solid ${color.Surface.ContainerLine}`, +}); + +// Footer button — strip the original ThreadSummaryCard pill chrome +// (own bg, radius, padding, max-width) so it reads as a flush bubble +// footer. Click target expands to the full footer width. Hover paints +// a subtle `SurfaceVariant.Container` shade that contrasts against +// the bubble's `Surface.Container` bg, signalling tappable footer +// without the pill silhouette returning. +globalStyle(`${ChannelBubbleThreadSummary} > button`, { + display: 'flex', + width: '100%', + maxWidth: 'none', + borderRadius: 0, + backgroundColor: 'transparent', + padding: `${config.space.S200} ${toRem(15)}`, +}); + +globalStyle(`${ChannelBubbleThreadSummary} > button:hover`, { + backgroundColor: color.SurfaceVariant.Container, +}); + +globalStyle(`${ChannelBubbleThreadSummary} > button:focus-visible`, { + // Inset the focus ring slightly so it doesn't punch through the + // bubble's rounded bottom corners on the BR/BL when the row is + // either own or incoming. + outlineOffset: toRem(-2), +}); diff --git a/src/app/components/message/layout/Channel.tsx b/src/app/components/message/layout/Channel.tsx index b4fb4c5d..1e81c2d8 100644 --- a/src/app/components/message/layout/Channel.tsx +++ b/src/app/components/message/layout/Channel.tsx @@ -25,6 +25,16 @@ export type ChannelLayoutProps = { header?: ReactNode; reactions?: ReactNode; threadSummary?: ReactNode; + // Forwarded onto the row root as `data-own="true"|"false"`. Channels + // main timeline doesn't style off it; the thread-drawer bubble CSS + // reads it to mirror `StreamBubble`'s own-vs-incoming notch corner. + isOwn?: boolean; + // When true, the header is rendered INSIDE the message-body slot + // (above content) instead of as a sibling row above the body. Thread + // drawer flips this on so the bubble wraps the username + time the + // same way `StreamBubble` does in DM chat. Channels main timeline + // keeps this false — name + time stay above an unbordered body. + headerInBubble?: boolean; onContextMenu?: MouseEventHandler; }; @@ -33,20 +43,50 @@ export type ChannelLayoutProps = { // thread-summary, reactions in vertical flow. export const ChannelLayout = as<'div', ChannelLayoutProps>( ( - { className, avatar, header, reactions, threadSummary, onContextMenu, children, ...props }, + { + className, + avatar, + header, + reactions, + threadSummary, + isOwn, + headerInBubble, + onContextMenu, + children, + ...props + }, ref ) => (
{avatar}
- {header &&
{header}
} -
{children}
- {threadSummary &&
{threadSummary}
} + {!headerInBubble && header &&
{header}
} +
+ {headerInBubble && header && ( +
+ {header} +
+ )} + {children} + {headerInBubble && threadSummary && ( + // Inside-bubble footer: stretches via negative margins to the + // bubble's inner border edge, paints a 1px top divider, and + // hosts the existing thread-summary button as a flush + // full-width chip. Reads as one continuous card with a + // section break instead of two stacked pills. +
{threadSummary}
+ )} +
+ {!headerInBubble && threadSummary && ( +
{threadSummary}
+ )} {reactions &&
{reactions}
}
diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index b4b2fac5..f293f14c 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -541,6 +541,11 @@ export function RoomTimeline({ // 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'; + // Channels main timeline shares the thread-drawer bubble silhouette: + // dark `Surface.Container` card with the username + time INSIDE the + // bubble. Stream layout (DM/Bots) keeps its native bubble header so + // the flag is gated on channels-mode only. + const channelHeaderInBubble = channelsMode; // 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 @@ -1342,6 +1347,7 @@ export function RoomTimeline({ ) : undefined } layout={messageLayout} + channelHeaderInBubble={channelHeaderInBubble} > {mEvent.isRedacted() ? ( @@ -1452,6 +1458,7 @@ export function RoomTimeline({ ) : undefined } layout={messageLayout} + channelHeaderInBubble={channelHeaderInBubble} > {(() => { if (mEvent.isRedacted()) return ; @@ -1573,6 +1580,7 @@ export function RoomTimeline({ ) : undefined } layout={messageLayout} + channelHeaderInBubble={channelHeaderInBubble} > {mEvent.isRedacted() ? ( @@ -1637,6 +1645,7 @@ export function RoomTimeline({ railStart={streamRailStart} railEnd={streamRailEnd} layout={messageLayout} + channelHeaderInBubble={channelHeaderInBubble} iconSrc={iconSrc} content={ @@ -1686,6 +1695,7 @@ export function RoomTimeline({ railStart={streamRailStart} railEnd={streamRailEnd} layout={messageLayout} + channelHeaderInBubble={channelHeaderInBubble} iconSrc={Icons.Hash} content={ @@ -1736,6 +1746,7 @@ export function RoomTimeline({ railStart={streamRailStart} railEnd={streamRailEnd} layout={messageLayout} + channelHeaderInBubble={channelHeaderInBubble} iconSrc={Icons.Hash} content={ @@ -1786,6 +1797,7 @@ export function RoomTimeline({ railStart={streamRailStart} railEnd={streamRailEnd} layout={messageLayout} + channelHeaderInBubble={channelHeaderInBubble} iconSrc={Icons.Hash} content={ @@ -1844,6 +1856,7 @@ export function RoomTimeline({ railStart={streamRailStart} railEnd={streamRailEnd} layout={messageLayout} + channelHeaderInBubble={channelHeaderInBubble} iconSrc={callJoined ? Icons.Phone : Icons.PhoneDown} content={ @@ -1891,6 +1904,7 @@ export function RoomTimeline({ railStart={streamRailStart} railEnd={streamRailEnd} layout={messageLayout} + channelHeaderInBubble={channelHeaderInBubble} iconSrc={Icons.Code} content={ @@ -1940,6 +1954,7 @@ export function RoomTimeline({ railStart={streamRailStart} railEnd={streamRailEnd} layout={messageLayout} + channelHeaderInBubble={channelHeaderInBubble} iconSrc={Icons.Code} content={ diff --git a/src/app/features/room/ThreadDrawer.css.ts b/src/app/features/room/ThreadDrawer.css.ts index dae8adf4..1144749e 100644 --- a/src/app/features/room/ThreadDrawer.css.ts +++ b/src/app/features/room/ThreadDrawer.css.ts @@ -96,7 +96,11 @@ export const ThreadDrawer = style({ width: '100%', display: 'flex', flexDirection: 'column', - backgroundColor: color.Surface.Container, + // Match the chat surface (`RoomView.tsx` paints + // `color.SurfaceVariant.Container` as its Page bg). Bubbles inside + // the drawer are `color.Surface.Container` so the «darker card on + // lighter surface» contrast reads identically to the main timeline. + backgroundColor: color.SurfaceVariant.Container, minHeight: 0, overflow: 'hidden', borderTopLeftRadius: toRem(VOJO_HORSESHOE_RADIUS_PX), @@ -120,7 +124,8 @@ export const ThreadDrawerMobile = style({ maxHeight: '100%', display: 'flex', flexDirection: 'column', - backgroundColor: color.Surface.Container, + // Same chat-surface tone as the desktop drawer above. + backgroundColor: color.SurfaceVariant.Container, minHeight: 0, paddingTop: 'var(--vojo-safe-top, 0px)', }); @@ -151,13 +156,20 @@ export const ThreadDrawerScroll = style({ // only has this container's top padding above it — S400 wasn't enough, // so the bar clipped at the scroll viewport's top edge on hover. S700 // gives the bar 32px of clearance, enough to render the ~28-32px hover -// bar fully on the root. Side / bottom padding stays S400 to preserve -// the original visual density. +// bar fully on the root. +// +// Left/right padding zeroed: `ChannelRow` already provides an S400 +// (16px) horizontal gutter per row, and stacking two S400 paddings +// pushed the avatar 32px from the drawer edge — a perceptibly off +// «double-gutter» look in the narrow side pane. With this gutter +// removed, the avatar sits at row-padding (16px) from the drawer's +// rounded edge, matching the channels main-timeline rhythm. export const ThreadDrawerContent = style({ display: 'flex', flexDirection: 'column', gap: config.space.S400, - padding: `${config.space.S700} ${config.space.S400} ${config.space.S400}`, + paddingTop: config.space.S700, + paddingBottom: config.space.S400, }); export const ThreadDivider = style({ @@ -212,11 +224,23 @@ export const ThreadCounterText = style({ opacity: 0.7, }); +// Thread composer wrap — geometry mirrors the personal-chat composer +// in `RoomView.tsx`: a 12px outer pad on the sides + bottom (matches +// the horseshoe void gap) frames a card whose visual chrome is owned +// by `ChatComposer` from `RoomView.css.ts`. The class itself is +// reapplied to the inner wrap in `ThreadDrawer.tsx` so the same +// `globalStyle` rules (`Surface.Container` bg, 32px radius, dark +// touch-hover gate) reach the Editor inside. export const ThreadComposer = style({ flexShrink: 0, - padding: `0 ${config.space.S400} ${config.space.S400}`, + padding: `0 ${toRem(VOJO_HORSESHOE_GAP_PX)} ${toRem(VOJO_HORSESHOE_GAP_PX)}`, }); +// Bubble chrome itself lives in `Channel.css.ts` and applies via the +// row's `data-bubble="true"` marker (set by `ChannelLayout` when +// `headerInBubble` is enabled). Both the thread drawer and the +// channels main timeline opt in, so the look is shared. + export const ThreadEmptyState = style({ padding: `${config.space.S500} ${config.space.S300}`, textAlign: 'center', diff --git a/src/app/features/room/ThreadDrawer.tsx b/src/app/features/room/ThreadDrawer.tsx index 776b1308..055bb9a7 100644 --- a/src/app/features/room/ThreadDrawer.tsx +++ b/src/app/features/room/ThreadDrawer.tsx @@ -30,6 +30,7 @@ import { HTMLReactParserOptions } from 'html-react-parser'; import { Opts as LinkifyOpts } from 'linkifyjs'; import * as css from './ThreadDrawer.css'; +import { ChatComposer } from './RoomView.css'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { useResizeObserver } from '../../hooks/useResizeObserver'; @@ -1091,6 +1092,7 @@ export function ThreadDrawer({ // to `[roomId, rootId]` so the chip surfaces there. hideMainReplyAffordance={false} layout="channel" + channelHeaderInBubble > {(() => { if (mEvent.isRedacted()) { @@ -1307,7 +1309,7 @@ export function ThreadDrawer({ {renderBody()} -
+
{canMessage ? ( ( ( @@ -739,6 +745,7 @@ export const Message = as<'div', MessageProps>( msgType, threadSummary, layout = 'stream', + channelHeaderInBubble, children, ...props }, @@ -1140,6 +1147,8 @@ export const Message = as<'div', MessageProps>( )} {layout === 'channel' ? ( ( onContextMenu={onUserClick} onClick={onUsernameClick} > - + {/* In-bubble (thread) uses Stream's compact T200 size + so the bubble header matches the DM-chat rhythm. + Channels main timeline keeps T400 for the prominent + avatar-and-name row above an unbordered body. */} + {isOwnMessage ? t('Direct.message_me_label') : senderDisplayName}