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 { 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) // 40px circular avatar — Discord's cozy-mode avatar size. Consumers override
// for visual weight matching the channels mockup. Consumers override // the folds preset via inline style; the shared `CHANNEL_AVATAR_PX` constant
// the folds preset via inline style; the shared `CHANNEL_AVATAR_PX` // keeps the CSS slot width and the inline override in sync.
// constant keeps the CSS slot width and the inline override in sync. export const CHANNEL_AVATAR_PX = 40;
export const CHANNEL_AVATAR_PX = 36;
const ChannelAvatarWidth = toRem(CHANNEL_AVATAR_PX); 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({ export const ChannelRow = style({
display: 'flex', display: 'flex',
alignItems: 'flex-start', alignItems: 'flex-start',
gap: config.space.S300, gap: ChannelAvatarGap,
// S200 (8px) sits inside MessageBase's S400 (16px) left pad, so the // Span the full pane edge-to-edge so the hover highlight runs the whole
// avatar's left edge lands ~24px from the screen edge — tighter than // width like Discord: cancel MessageBase's S400/S200 horizontal padding with
// the previous 32px (S400+S400) without crowding the bubble cluster. // negative margins, then re-add the 16px avatar gutter as paddingLeft (so the
// Mirror on the right for symmetric breathing room. // avatar's left edge lands exactly 16px from the screen edge — Discord cozy).
paddingLeft: config.space.S200, marginLeft: `calc(-1 * ${config.space.S400})`,
paddingRight: config.space.S200, marginRight: `calc(-1 * ${config.space.S200})`,
paddingTop: config.space.S100, paddingLeft: ChannelEdgePad,
paddingBottom: config.space.S100, 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, minWidth: 0,
// Hover bg subtle so adjacent rows still read as distinct units even // Hover bg subtle so adjacent rows still read as distinct units. `@media
// without bubble borders. `@media (hover: hover)` keeps this inert on // (hover: hover)` keeps this inert on touch where there's no pointer.
// touch where there's no pointer to follow.
'@media': { '@media': {
'(hover: hover) and (pointer: fine)': { '(hover: hover) and (pointer: fine)': {
selectors: { selectors: {
@ -109,10 +116,11 @@ export const ChannelDayDividerLabel = style({
// body would, indented past the avatar slot, so the column reads // body would, indented past the avatar slot, so the column reads
// continuous. // continuous.
export const ChannelSysline = style({ export const ChannelSysline = style({
// Indent past the avatar gutter so the sysline body aligns with the // Indent past the avatar gutter so the sysline body aligns with the message
// body column of the surrounding message rows (avatar + gap + row pad). // body column (72px). The sysline sits inside MessageBase's S400 (16px) left
// Tracks `ChannelRow.paddingLeft/Right` (S200) in lockstep. // pad (it has no edge-to-edge negative margin), so paddingLeft = avatar (40)
paddingLeft: `calc(${ChannelAvatarWidth} + ${config.space.S300} + ${config.space.S200})`, // + gap (16) = 56 lands the content at 16 + 56 = 72px.
paddingLeft: `calc(${ChannelAvatarWidth} + ${ChannelAvatarGap})`,
paddingRight: config.space.S200, paddingRight: config.space.S200,
paddingTop: config.space.S100, paddingTop: config.space.S100,
paddingBottom: 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 // main timeline doesn't style off it; the thread-drawer bubble CSS
// reads it to mirror `StreamBubble`'s own-vs-incoming notch corner. // reads it to mirror `StreamBubble`'s own-vs-incoming notch corner.
isOwn?: boolean; isOwn?: boolean;
// When true, the header is rendered INSIDE the message-body slot // `true` (thread drawer): the header (name + time) renders INSIDE the body
// (above content) instead of as a sibling row above the body, and the // slot above the content, and the body is a chat bubble — the compact
// body element gets the `data-bubble="true"` marker so `Channel.css.ts` // in-bubble look.
// paints it as a chat bubble (same silhouette as `StreamBubble`). All // `false` (Discord-style main timeline): the header renders as a sibling row
// current timeline callers (channels main, group rooms, thread drawer) // ABOVE the body (next to the avatar) and the body is plain text (no bubble)
// pass `true`. The flag stays as an opt-in so consumers that render // for everyone — Discord groups don't bubble messages. See `data-bubble`
// un-bubbled rows (`ChannelEventContent` / future picker previews) can // below and `Channel.css.ts`.
// still get the avatar-name-then-body shape without the bubble.
headerInBubble?: boolean; headerInBubble?: boolean;
onContextMenu?: MouseEventHandler<HTMLDivElement>; onContextMenu?: MouseEventHandler<HTMLDivElement>;
}; };
@ -64,6 +63,9 @@ export const ChannelLayout = as<'div', ChannelLayoutProps>(
className={classNames(css.ChannelRow, className)} className={classNames(css.ChannelRow, className)}
onContextMenu={onContextMenu} onContextMenu={onContextMenu}
data-own={isOwn ? 'true' : 'false'} 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} data-bubble={headerInBubble ? 'true' : undefined}
{...props} {...props}
ref={ref} ref={ref}

View file

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

View file

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

View file

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

View file

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