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:
parent
3f873a5041
commit
c9ea22d2d4
6 changed files with 219 additions and 13 deletions
|
|
@ -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),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue