feat(room): redesign the group/channel chat into a Discord-style layout with plain-text messages, avatar/timestamp grouping and the 1:1 media renderer

This commit is contained in:
heaven 2026-05-30 00:28:31 +03:00
parent 0f882567c5
commit f6e374d551
6 changed files with 58 additions and 49 deletions

View file

@ -1,29 +1,36 @@
import { globalStyle, 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;
// 40px circular avatar — Discord's cozy-mode avatar size. 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 = 40;
const ChannelAvatarWidth = toRem(CHANNEL_AVATAR_PX);
// Discord cozy-mode geometry: avatar 16px from the list edge, 16px gap to the
// content, so the message column starts at 16 + 40 + 16 = 72px.
const ChannelEdgePad = toRem(16);
const ChannelAvatarGap = toRem(16);
export const ChannelRow = style({
display: 'flex',
alignItems: 'flex-start',
gap: config.space.S300,
// S200 (8px) sits inside MessageBase's S400 (16px) left pad, so the
// avatar's left edge lands ~24px from the screen edge — tighter than
// the previous 32px (S400+S400) without crowding the bubble cluster.
// Mirror on the right for symmetric breathing room.
paddingLeft: config.space.S200,
paddingRight: config.space.S200,
paddingTop: config.space.S100,
paddingBottom: config.space.S100,
gap: ChannelAvatarGap,
// Span the full pane edge-to-edge so the hover highlight runs the whole
// width like Discord: cancel MessageBase's S400/S200 horizontal padding with
// negative margins, then re-add the 16px avatar gutter as paddingLeft (so the
// avatar's left edge lands exactly 16px from the screen edge — Discord cozy).
marginLeft: `calc(-1 * ${config.space.S400})`,
marginRight: `calc(-1 * ${config.space.S200})`,
paddingLeft: ChannelEdgePad,
paddingRight: ChannelEdgePad,
// Tight vertical rhythm (Discord stacks lines on line-height); group
// separation comes from MessageBase's `space` marginTop on the run head.
paddingTop: toRem(2),
paddingBottom: toRem(2),
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.
// Hover bg subtle so adjacent rows still read as distinct units. `@media
// (hover: hover)` keeps this inert on touch where there's no pointer.
'@media': {
'(hover: hover) and (pointer: fine)': {
selectors: {
@ -109,10 +116,11 @@ export const ChannelDayDividerLabel = style({
// body would, indented past the avatar slot, so the column reads
// continuous.
export const ChannelSysline = style({
// Indent past the avatar gutter so the sysline body aligns with the
// body column of the surrounding message rows (avatar + gap + row pad).
// Tracks `ChannelRow.paddingLeft/Right` (S200) in lockstep.
paddingLeft: `calc(${ChannelAvatarWidth} + ${config.space.S300} + ${config.space.S200})`,
// Indent past the avatar gutter so the sysline body aligns with the message
// body column (72px). The sysline sits inside MessageBase's S400 (16px) left
// pad (it has no edge-to-edge negative margin), so paddingLeft = avatar (40)
// + gap (16) = 56 lands the content at 16 + 56 = 72px.
paddingLeft: `calc(${ChannelAvatarWidth} + ${ChannelAvatarGap})`,
paddingRight: config.space.S200,
paddingTop: config.space.S100,
paddingBottom: config.space.S100,

View file

@ -29,14 +29,13 @@ export type ChannelLayoutProps = {
// 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, and the
// body element gets the `data-bubble="true"` marker so `Channel.css.ts`
// paints it as a chat bubble (same silhouette as `StreamBubble`). All
// current timeline callers (channels main, group rooms, thread drawer)
// pass `true`. The flag stays as an opt-in so consumers that render
// un-bubbled rows (`ChannelEventContent` / future picker previews) can
// still get the avatar-name-then-body shape without the bubble.
// `true` (thread drawer): the header (name + time) renders INSIDE the body
// slot above the content, and the body is a chat bubble — the compact
// in-bubble look.
// `false` (Discord-style main timeline): the header renders as a sibling row
// ABOVE the body (next to the avatar) and the body is plain text (no bubble)
// for everyone — Discord groups don't bubble messages. See `data-bubble`
// below and `Channel.css.ts`.
headerInBubble?: boolean;
onContextMenu?: MouseEventHandler<HTMLDivElement>;
};
@ -64,6 +63,9 @@ export const ChannelLayout = as<'div', ChannelLayoutProps>(
className={classNames(css.ChannelRow, className)}
onContextMenu={onContextMenu}
data-own={isOwn ? 'true' : 'false'}
// Bubbles only in the compact in-bubble mode (thread drawer). The Discord
// header-above main timeline renders ALL messages as plain text (no
// bubbles) — own and peer alike.
data-bubble={headerInBubble ? 'true' : undefined}
{...props}
ref={ref}

View file

@ -540,10 +540,11 @@ export function RoomTimeline({
// this flag is purely visual.
const channelStyleLayout = channelsMode || !isOneOnOne;
const messageLayout: 'stream' | 'channel' = channelStyleLayout ? 'channel' : 'stream';
// Bubble silhouette with the username + time INSIDE the bubble (mirrors
// the thread-drawer look). Stream layout keeps its native in-bubble
// header anyway, so the flag is only meaningful on the channel branch.
const channelHeaderInBubble = channelStyleLayout;
// Discord-style channel rows: avatar + the username + time on a header line
// ABOVE the message body (not inside the bubble). `false` puts the header
// above and lets ChannelLayout bubble only the peer body (own = plain text);
// the thread drawer passes `true` separately for its compact in-bubble look.
const channelHeaderInBubble = false;
// 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

View file

@ -200,9 +200,8 @@ export function CallMessage({
<>
<Username as="span" style={usernameStyle}>
<Text as="span" size={channelHeaderInBubble ? 'T200' : 'T400'} truncate>
<UsernameBold>
{isOwnMessage ? t('Direct.message_me_label') : senderName}
</UsernameBold>
{/* Own events show the user's own nick too (not a «me» label). */}
<UsernameBold>{senderName}</UsernameBold>
</Text>
</Username>
<Time ts={mEvent.getTs()} compact size="T200" priority="300" />

View file

@ -813,11 +813,13 @@ const MessageInner = as<'div', MessageProps>(
const streamMediaCtx = useMemo(
() =>
// Media chrome only needs `own` (for the bubble's notch corner). The
// sender nick is rendered ABOVE the media by the Stream name header
// now (like text) — there's no overlay on the image any more.
mediaMode && layout === 'stream' ? { own: isOwnMessage } : null,
[mediaMode, layout, isOwnMessage]
// Image / video chrome (the StreamMediaImage/Video shell) — used in
// BOTH the 1:1 Stream layout and the Discord-style channel layout, so
// group-chat media renders identically to 1:1 (rounded shell, capped
// size) instead of the legacy boxed attachment. Only `own` is needed
// (the bubble's notch corner); the nick is rendered above by the header.
mediaMode ? { own: isOwnMessage } : null,
[mediaMode, isOwnMessage]
);
const msgContentJSX = (
@ -1183,9 +1185,9 @@ const MessageInner = as<'div', MessageProps>(
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>
{/* Own messages show the user's own nick too (not a «me»
label) matches the 1:1 Stream layout. */}
<UsernameBold>{senderDisplayName}</UsernameBold>
</Text>
</Username>
<Time ts={mEvent.getTs()} compact size="T200" priority="300" />

View file

@ -1,7 +1,6 @@
import React, { ReactNode } from 'react';
import { Box, Icons, Text } from 'folds';
import { MatrixEvent, Room } from 'matrix-js-sdk';
import { useTranslation } from 'react-i18next';
import {
ChannelLayout,
ChannelMessageAvatar,
@ -49,7 +48,6 @@ export function SyslineMessage({
channelHeaderInBubble,
...rest
}: SyslineMessageProps) {
const { t } = useTranslation();
const mx = useMatrixClient();
const senderId = mEvent.getSender() ?? '';
@ -78,9 +76,8 @@ export function SyslineMessage({
<>
<Username as="span">
<Text as="span" size={channelHeaderInBubble ? 'T200' : 'T400'} truncate>
<UsernameBold>
{isOwnMessage ? t('Direct.message_me_label') : senderName}
</UsernameBold>
{/* Own events show the user's own nick too (not a «me» label). */}
<UsernameBold>{senderName}</UsernameBold>
</Text>
</Username>
<Time ts={mEvent.getTs()} compact size="T200" priority="300" />