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:
parent
0f882567c5
commit
f6e374d551
6 changed files with 58 additions and 49 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue