feat(channels): rebuild thread drawer and channel rows as chat-style bubble cards that merge thread-summary into the bubble footer

This commit is contained in:
v.lagerev 2026-05-13 15:21:13 +03:00
parent 3f873a5041
commit c9ea22d2d4
6 changed files with 219 additions and 13 deletions

View file

@ -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),
});

View file

@ -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<HTMLDivElement>;
};
@ -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
) => (
<div
className={classNames(css.ChannelRow, className)}
onContextMenu={onContextMenu}
data-own={isOwn ? 'true' : 'false'}
data-bubble={headerInBubble ? 'true' : undefined}
{...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>}
{!headerInBubble && header && <div className={css.ChannelHeader}>{header}</div>}
<div className={css.ChannelMessageBody}>
{headerInBubble && header && (
<div className={css.ChannelHeader} data-in-bubble="true">
{header}
</div>
)}
{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.
<div className={css.ChannelBubbleThreadSummary}>{threadSummary}</div>
)}
</div>
{!headerInBubble && threadSummary && (
<div className={css.ChannelThreadSummary}>{threadSummary}</div>
)}
{reactions && <div className={css.ChannelReactions}>{reactions}</div>}
</div>
</div>

View file

@ -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() ? (
<RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} />
@ -1452,6 +1458,7 @@ export function RoomTimeline({
) : undefined
}
layout={messageLayout}
channelHeaderInBubble={channelHeaderInBubble}
>
{(() => {
if (mEvent.isRedacted()) return <RedactedContent />;
@ -1573,6 +1580,7 @@ export function RoomTimeline({
) : undefined
}
layout={messageLayout}
channelHeaderInBubble={channelHeaderInBubble}
>
{mEvent.isRedacted() ? (
<RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} />
@ -1637,6 +1645,7 @@ export function RoomTimeline({
railStart={streamRailStart}
railEnd={streamRailEnd}
layout={messageLayout}
channelHeaderInBubble={channelHeaderInBubble}
iconSrc={iconSrc}
content={
<Box grow="Yes" direction="Column">
@ -1686,6 +1695,7 @@ export function RoomTimeline({
railStart={streamRailStart}
railEnd={streamRailEnd}
layout={messageLayout}
channelHeaderInBubble={channelHeaderInBubble}
iconSrc={Icons.Hash}
content={
<Box grow="Yes" direction="Column">
@ -1736,6 +1746,7 @@ export function RoomTimeline({
railStart={streamRailStart}
railEnd={streamRailEnd}
layout={messageLayout}
channelHeaderInBubble={channelHeaderInBubble}
iconSrc={Icons.Hash}
content={
<Box grow="Yes" direction="Column">
@ -1786,6 +1797,7 @@ export function RoomTimeline({
railStart={streamRailStart}
railEnd={streamRailEnd}
layout={messageLayout}
channelHeaderInBubble={channelHeaderInBubble}
iconSrc={Icons.Hash}
content={
<Box grow="Yes" direction="Column">
@ -1844,6 +1856,7 @@ export function RoomTimeline({
railStart={streamRailStart}
railEnd={streamRailEnd}
layout={messageLayout}
channelHeaderInBubble={channelHeaderInBubble}
iconSrc={callJoined ? Icons.Phone : Icons.PhoneDown}
content={
<Box grow="Yes" direction="Column">
@ -1891,6 +1904,7 @@ export function RoomTimeline({
railStart={streamRailStart}
railEnd={streamRailEnd}
layout={messageLayout}
channelHeaderInBubble={channelHeaderInBubble}
iconSrc={Icons.Code}
content={
<Box grow="Yes" direction="Column">
@ -1940,6 +1954,7 @@ export function RoomTimeline({
railStart={streamRailStart}
railEnd={streamRailEnd}
layout={messageLayout}
channelHeaderInBubble={channelHeaderInBubble}
iconSrc={Icons.Code}
content={
<Box grow="Yes" direction="Column">

View file

@ -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',

View file

@ -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()}
</Scroll>
</div>
<div className={css.ThreadComposer}>
<div className={`${css.ThreadComposer} ${ChatComposer}`}>
{canMessage ? (
<RoomInput
room={room}

View file

@ -705,6 +705,12 @@ export type MessageProps = {
// without bubble (Slack/Discord-style channels timeline). Default
// `'stream'` so non-channels callers don't have to opt in.
layout?: 'stream' | 'channel';
// Opt-in for thread-drawer rendering: forces `ChannelLayout` to
// render the username + time header INSIDE the bubble slot (mirrors
// `StreamBubble`'s in-bubble header), so the thread reads like a
// chat-bubble cluster instead of avatar-name-then-body channel rows.
// Channels main timeline keeps this false.
channelHeaderInBubble?: boolean;
};
export const Message = as<'div', MessageProps>(
(
@ -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' ? (
<ChannelLayout
isOwn={isOwnMessage}
headerInBubble={channelHeaderInBubble}
avatar={
!collapse ? (
<ChannelMessageAvatar
@ -1159,7 +1168,15 @@ export const Message = as<'div', MessageProps>(
onContextMenu={onUserClick}
onClick={onUsernameClick}
>
<Text as="span" size="T400" truncate>
{/* 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. */}
<Text
as="span"
size={channelHeaderInBubble ? 'T200' : 'T400'}
truncate
>
<UsernameBold>
{isOwnMessage ? t('Direct.message_me_label') : senderDisplayName}
</UsernameBold>