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'; import { color, config, toRem } from 'folds';
// 36px circular avatar — a notch above folds `Avatar size="200"` (32px) // 36px circular avatar — a notch above folds `Avatar size="200"` (32px)
@ -121,3 +121,111 @@ export const ChannelSyslineBody = style({
minWidth: 0, minWidth: 0,
flex: 1, 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; header?: ReactNode;
reactions?: ReactNode; reactions?: ReactNode;
threadSummary?: 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>; onContextMenu?: MouseEventHandler<HTMLDivElement>;
}; };
@ -33,20 +43,50 @@ export type ChannelLayoutProps = {
// thread-summary, reactions in vertical flow. // thread-summary, reactions in vertical flow.
export const ChannelLayout = as<'div', ChannelLayoutProps>( 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 ref
) => ( ) => (
<div <div
className={classNames(css.ChannelRow, className)} className={classNames(css.ChannelRow, className)}
onContextMenu={onContextMenu} onContextMenu={onContextMenu}
data-own={isOwn ? 'true' : 'false'}
data-bubble={headerInBubble ? 'true' : undefined}
{...props} {...props}
ref={ref} ref={ref}
> >
<div className={css.ChannelAvatarSlot}>{avatar}</div> <div className={css.ChannelAvatarSlot}>{avatar}</div>
<div className={css.ChannelBody}> <div className={css.ChannelBody}>
{header && <div className={css.ChannelHeader}>{header}</div>} {!headerInBubble && header && <div className={css.ChannelHeader}>{header}</div>}
<div className={css.ChannelMessageBody}>{children}</div> <div className={css.ChannelMessageBody}>
{threadSummary && <div className={css.ChannelThreadSummary}>{threadSummary}</div>} {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>} {reactions && <div className={css.ChannelReactions}>{reactions}</div>}
</div> </div>
</div> </div>

View file

@ -541,6 +541,11 @@ export function RoomTimeline({
// Channel layout too — visually consistent with native channels — only // Channel layout too — visually consistent with native channels — only
// the thread plus / cards differ (bridge has no thread semantic). // the thread plus / cards differ (bridge has no thread semantic).
const messageLayout: 'stream' | 'channel' = channelsMode ? 'channel' : 'stream'; 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 // M2: when the thread drawer is open, the channel composer is
// unmounted by RoomView (single-Slate-at-a-time pattern). Clicking // unmounted by RoomView (single-Slate-at-a-time pattern). Clicking
// the channel timeline's «Reply» menu would write a reply chip into // the channel timeline's «Reply» menu would write a reply chip into
@ -1342,6 +1347,7 @@ export function RoomTimeline({
) : undefined ) : undefined
} }
layout={messageLayout} layout={messageLayout}
channelHeaderInBubble={channelHeaderInBubble}
> >
{mEvent.isRedacted() ? ( {mEvent.isRedacted() ? (
<RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} /> <RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} />
@ -1452,6 +1458,7 @@ export function RoomTimeline({
) : undefined ) : undefined
} }
layout={messageLayout} layout={messageLayout}
channelHeaderInBubble={channelHeaderInBubble}
> >
{(() => { {(() => {
if (mEvent.isRedacted()) return <RedactedContent />; if (mEvent.isRedacted()) return <RedactedContent />;
@ -1573,6 +1580,7 @@ export function RoomTimeline({
) : undefined ) : undefined
} }
layout={messageLayout} layout={messageLayout}
channelHeaderInBubble={channelHeaderInBubble}
> >
{mEvent.isRedacted() ? ( {mEvent.isRedacted() ? (
<RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} /> <RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} />
@ -1637,6 +1645,7 @@ export function RoomTimeline({
railStart={streamRailStart} railStart={streamRailStart}
railEnd={streamRailEnd} railEnd={streamRailEnd}
layout={messageLayout} layout={messageLayout}
channelHeaderInBubble={channelHeaderInBubble}
iconSrc={iconSrc} iconSrc={iconSrc}
content={ content={
<Box grow="Yes" direction="Column"> <Box grow="Yes" direction="Column">
@ -1686,6 +1695,7 @@ export function RoomTimeline({
railStart={streamRailStart} railStart={streamRailStart}
railEnd={streamRailEnd} railEnd={streamRailEnd}
layout={messageLayout} layout={messageLayout}
channelHeaderInBubble={channelHeaderInBubble}
iconSrc={Icons.Hash} iconSrc={Icons.Hash}
content={ content={
<Box grow="Yes" direction="Column"> <Box grow="Yes" direction="Column">
@ -1736,6 +1746,7 @@ export function RoomTimeline({
railStart={streamRailStart} railStart={streamRailStart}
railEnd={streamRailEnd} railEnd={streamRailEnd}
layout={messageLayout} layout={messageLayout}
channelHeaderInBubble={channelHeaderInBubble}
iconSrc={Icons.Hash} iconSrc={Icons.Hash}
content={ content={
<Box grow="Yes" direction="Column"> <Box grow="Yes" direction="Column">
@ -1786,6 +1797,7 @@ export function RoomTimeline({
railStart={streamRailStart} railStart={streamRailStart}
railEnd={streamRailEnd} railEnd={streamRailEnd}
layout={messageLayout} layout={messageLayout}
channelHeaderInBubble={channelHeaderInBubble}
iconSrc={Icons.Hash} iconSrc={Icons.Hash}
content={ content={
<Box grow="Yes" direction="Column"> <Box grow="Yes" direction="Column">
@ -1844,6 +1856,7 @@ export function RoomTimeline({
railStart={streamRailStart} railStart={streamRailStart}
railEnd={streamRailEnd} railEnd={streamRailEnd}
layout={messageLayout} layout={messageLayout}
channelHeaderInBubble={channelHeaderInBubble}
iconSrc={callJoined ? Icons.Phone : Icons.PhoneDown} iconSrc={callJoined ? Icons.Phone : Icons.PhoneDown}
content={ content={
<Box grow="Yes" direction="Column"> <Box grow="Yes" direction="Column">
@ -1891,6 +1904,7 @@ export function RoomTimeline({
railStart={streamRailStart} railStart={streamRailStart}
railEnd={streamRailEnd} railEnd={streamRailEnd}
layout={messageLayout} layout={messageLayout}
channelHeaderInBubble={channelHeaderInBubble}
iconSrc={Icons.Code} iconSrc={Icons.Code}
content={ content={
<Box grow="Yes" direction="Column"> <Box grow="Yes" direction="Column">
@ -1940,6 +1954,7 @@ export function RoomTimeline({
railStart={streamRailStart} railStart={streamRailStart}
railEnd={streamRailEnd} railEnd={streamRailEnd}
layout={messageLayout} layout={messageLayout}
channelHeaderInBubble={channelHeaderInBubble}
iconSrc={Icons.Code} iconSrc={Icons.Code}
content={ content={
<Box grow="Yes" direction="Column"> <Box grow="Yes" direction="Column">

View file

@ -96,7 +96,11 @@ export const ThreadDrawer = style({
width: '100%', width: '100%',
display: 'flex', display: 'flex',
flexDirection: 'column', 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, minHeight: 0,
overflow: 'hidden', overflow: 'hidden',
borderTopLeftRadius: toRem(VOJO_HORSESHOE_RADIUS_PX), borderTopLeftRadius: toRem(VOJO_HORSESHOE_RADIUS_PX),
@ -120,7 +124,8 @@ export const ThreadDrawerMobile = style({
maxHeight: '100%', maxHeight: '100%',
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
backgroundColor: color.Surface.Container, // Same chat-surface tone as the desktop drawer above.
backgroundColor: color.SurfaceVariant.Container,
minHeight: 0, minHeight: 0,
paddingTop: 'var(--vojo-safe-top, 0px)', 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, // 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 // 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 // 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 // bar fully on the root.
// the original visual density. //
// 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({ export const ThreadDrawerContent = style({
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
gap: config.space.S400, 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({ export const ThreadDivider = style({
@ -212,11 +224,23 @@ export const ThreadCounterText = style({
opacity: 0.7, 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({ export const ThreadComposer = style({
flexShrink: 0, 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({ export const ThreadEmptyState = style({
padding: `${config.space.S500} ${config.space.S300}`, padding: `${config.space.S500} ${config.space.S300}`,
textAlign: 'center', textAlign: 'center',

View file

@ -30,6 +30,7 @@ import { HTMLReactParserOptions } from 'html-react-parser';
import { Opts as LinkifyOpts } from 'linkifyjs'; import { Opts as LinkifyOpts } from 'linkifyjs';
import * as css from './ThreadDrawer.css'; import * as css from './ThreadDrawer.css';
import { ChatComposer } from './RoomView.css';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useResizeObserver } from '../../hooks/useResizeObserver'; import { useResizeObserver } from '../../hooks/useResizeObserver';
@ -1091,6 +1092,7 @@ export function ThreadDrawer({
// to `[roomId, rootId]` so the chip surfaces there. // to `[roomId, rootId]` so the chip surfaces there.
hideMainReplyAffordance={false} hideMainReplyAffordance={false}
layout="channel" layout="channel"
channelHeaderInBubble
> >
{(() => { {(() => {
if (mEvent.isRedacted()) { if (mEvent.isRedacted()) {
@ -1307,7 +1309,7 @@ export function ThreadDrawer({
{renderBody()} {renderBody()}
</Scroll> </Scroll>
</div> </div>
<div className={css.ThreadComposer}> <div className={`${css.ThreadComposer} ${ChatComposer}`}>
{canMessage ? ( {canMessage ? (
<RoomInput <RoomInput
room={room} room={room}

View file

@ -705,6 +705,12 @@ export type MessageProps = {
// without bubble (Slack/Discord-style channels timeline). Default // without bubble (Slack/Discord-style channels timeline). Default
// `'stream'` so non-channels callers don't have to opt in. // `'stream'` so non-channels callers don't have to opt in.
layout?: 'stream' | 'channel'; 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>( export const Message = as<'div', MessageProps>(
( (
@ -739,6 +745,7 @@ export const Message = as<'div', MessageProps>(
msgType, msgType,
threadSummary, threadSummary,
layout = 'stream', layout = 'stream',
channelHeaderInBubble,
children, children,
...props ...props
}, },
@ -1140,6 +1147,8 @@ export const Message = as<'div', MessageProps>(
)} )}
{layout === 'channel' ? ( {layout === 'channel' ? (
<ChannelLayout <ChannelLayout
isOwn={isOwnMessage}
headerInBubble={channelHeaderInBubble}
avatar={ avatar={
!collapse ? ( !collapse ? (
<ChannelMessageAvatar <ChannelMessageAvatar
@ -1159,7 +1168,15 @@ export const Message = as<'div', MessageProps>(
onContextMenu={onUserClick} onContextMenu={onUserClick}
onClick={onUsernameClick} 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> <UsernameBold>
{isOwnMessage ? t('Direct.message_me_label') : senderDisplayName} {isOwnMessage ? t('Direct.message_me_label') : senderDisplayName}
</UsernameBold> </UsernameBold>