feat(room): extend the centred message band, date pill and composer width to group and channel rooms, matching 1:1

This commit is contained in:
heaven 2026-06-06 21:13:34 +03:00
parent 4b7ad11620
commit 2581ff8137
6 changed files with 42 additions and 126 deletions

View file

@ -16,10 +16,12 @@ export const ChannelRow = style({
display: 'flex', display: 'flex',
alignItems: 'flex-start', alignItems: 'flex-start',
gap: ChannelAvatarGap, gap: ChannelAvatarGap,
// Span the full pane edge-to-edge so the hover highlight runs the whole // Span the full message-column width so the hover highlight runs edge-to-edge
// width like Discord: cancel MessageBase's S400/S200 horizontal padding with // like Discord: cancel MessageBase's S400/S200 horizontal padding with negative
// negative margins, then re-add the 16px avatar gutter as paddingLeft (so the // margins, then re-add the 16px avatar gutter as paddingLeft (so the avatar's
// avatar's left edge lands exactly 16px from the screen edge — Discord cozy). // left edge lands 16px from the column edge — Discord cozy). NB: the column is
// the centred BubbleTimelineBand, so that edge is the band's content edge, not
// the screen edge (the band adds 12px native / 40px desktop outside this).
marginLeft: `calc(-1 * ${config.space.S400})`, marginLeft: `calc(-1 * ${config.space.S400})`,
marginRight: `calc(-1 * ${config.space.S200})`, marginRight: `calc(-1 * ${config.space.S200})`,
paddingLeft: ChannelEdgePad, paddingLeft: ChannelEdgePad,
@ -82,35 +84,6 @@ export const ChannelThreadSummary = style({
marginTop: config.space.S100, marginTop: config.space.S100,
}); });
// Horizontal line + centered label. Spans the full row width including
// the avatar slot so the line reads as a section break, not a per-message
// chrome element.
export const ChannelDayDividerRoot = style({
display: 'flex',
alignItems: 'center',
gap: config.space.S300,
paddingLeft: config.space.S400,
paddingRight: config.space.S400,
paddingTop: config.space.S400,
paddingBottom: config.space.S400,
});
export const ChannelDayDividerLine = style({
flex: 1,
height: '1px',
backgroundColor: color.SurfaceVariant.ContainerLine,
});
export const ChannelDayDividerLabel = style({
fontSize: toRem(11),
fontWeight: 600,
letterSpacing: '0.06em',
textTransform: 'uppercase',
color: color.SurfaceVariant.OnContainer,
opacity: 0.7,
flexShrink: 0,
});
// Sysline (membership / room.create / pinned-events). Compact single // Sysline (membership / room.create / pinned-events). Compact single
// row aligned with the body gutter — the sysline sits where messages' // row aligned with the body gutter — the sysline sits where messages'
// body would, indented past the avatar slot, so the column reads // body would, indented past the avatar slot, so the column reads

View file

@ -98,29 +98,6 @@ export const ChannelLayout = as<'div', ChannelLayoutProps>(
) )
); );
export type ChannelDayDividerProps = {
label: ReactNode;
};
// Section break between days in the channels timeline. Horizontal line
// + centered uppercase label, spans full row including the avatar gutter
// so it reads as a structural separator.
export const ChannelDayDivider = as<'div', ChannelDayDividerProps>(
({ className, label, ...props }, ref) => (
<div
className={classNames(css.ChannelDayDividerRoot, className)}
role="separator"
aria-label={typeof label === 'string' ? label : undefined}
{...props}
ref={ref}
>
<span className={css.ChannelDayDividerLine} aria-hidden />
<span className={css.ChannelDayDividerLabel}>{label}</span>
<span className={css.ChannelDayDividerLine} aria-hidden />
</div>
)
);
export type ChannelEventContentProps = { export type ChannelEventContentProps = {
iconSrc: IconSrc; iconSrc: IconSrc;
content: ReactNode; content: ReactNode;

View file

@ -7,7 +7,8 @@ import {
VOJO_STICKY_DATE_TOP_PX, VOJO_STICKY_DATE_TOP_PX,
} from '../../styles/horseshoe'; } from '../../styles/horseshoe';
// Bubble (1:1 DM) timeline band — centre the message column in the same band // Timeline band (every room class — 1:1 bubble, group, channel) — centre the
// message column in the same band
// the AI-bot chat uses (ThreadDrawerContentAssistant), so on wide web viewports // the AI-bot chat uses (ThreadDrawerContentAssistant), so on wide web viewports
// the chat is centred instead of spreading edge-to-edge. Inert on mobile (band // the chat is centred instead of spreading edge-to-edge. Inert on mobile (band
// > viewport). The composer mirrors this via ComposerBubbleBand; both read the // > viewport). The composer mirrors this via ComposerBubbleBand; both read the
@ -32,7 +33,8 @@ export const BubbleTimelineBand = style({
}, },
}); });
// Day capsule for the bubble (1:1 DM) timeline («Среда, 4 июня» / «Сегодня») — // Day capsule for the timeline («Среда, 4 июня» / «Сегодня»), shared by every
// room class (1:1 bubble, group, channel) —
// a single dark-blue pill in the message-input tone (Surface.Container, the same // a single dark-blue pill in the message-input tone (Surface.Container, the same
// token the composer card paints with), centred, with generous rounding. No // token the composer card paints with), centred, with generous rounding. No
// border, no echo. // border, no echo.

View file

@ -48,8 +48,6 @@ import {
MSticker, MSticker,
ImageContent, ImageContent,
EventContent, EventContent,
CHANNEL_MESSAGE_SPACING,
ChannelDayDivider,
} from '../../components/message'; } from '../../components/message';
import { import {
factoryRenderLinkifyWithMention, factoryRenderLinkifyWithMention,
@ -1302,7 +1300,8 @@ export function RoomTimeline({
const { t } = useTranslation(); const { t } = useTranslation();
// Sticky day capsules (bubble layout only). Each day boundary renders a REAL // Sticky day capsules (every room class — 1:1 bubble, group, channel). Each
// day boundary renders a REAL
// capsule that is a CSS `position: sticky` element (see RoomTimeline.css // capsule that is a CSS `position: sticky` element (see RoomTimeline.css
// `[data-sticky-dates='on'] BubbleDayCapsuleRow`). The browser pins it on the // `[data-sticky-dates='on'] BubbleDayCapsuleRow`). The browser pins it on the
// compositor while you scroll through a day, then lets it settle back into its // compositor while you scroll through a day, then lets it settle back into its
@ -1319,7 +1318,6 @@ export function RoomTimeline({
// `offsetTop` is the in-flow position (sticky doesn't change it), so the // `offsetTop` is the in-flow position (sticky doesn't change it), so the
// front detection and the virtual paginator's offsetTop maths stay in agreement. // front detection and the virtual paginator's offsetTop maths stay in agreement.
useEffect(() => { useEffect(() => {
if (messageLayout === 'channel') return undefined;
const scrollEl = getScrollElement(); const scrollEl = getScrollElement();
if (!scrollEl) return undefined; if (!scrollEl) return undefined;
@ -1370,7 +1368,7 @@ export function RoomTimeline({
delete scrollEl.dataset.stickyDates; delete scrollEl.dataset.stickyDates;
showAll(); showAll();
}; };
}, [getScrollElement, messageLayout]); }, [getScrollElement]);
// Group `m.call.member` (StateEvent.GroupCallMemberPrefix) events into one // Group `m.call.member` (StateEvent.GroupCallMemberPrefix) events into one
// aggregate bubble per CALL SESSION. Each session is delimited by «joined // aggregate bubble per CALL SESSION. Each session is delimited by «joined
@ -2635,24 +2633,19 @@ export function RoomTimeline({
return timeDayMonYear(mEvent.getTs()); return timeDayMonYear(mEvent.getTs());
})(); })();
const renderDayDivider = () => const renderDayDivider = () => (
messageLayout === 'channel' ? ( // Every room class (1:1 bubble, group, channel) draws the same centred,
<MessageBase space={CHANNEL_MESSAGE_SPACING}> // single dark-blue date pill. The row is the real `position: sticky`
<ChannelDayDivider label={dayLabel} /> // element — `data-day-divider` is the hook the scroll effect uses to engage
</MessageBase> // stickiness and pick the front pill when several pile up. No MessageBase
) : ( // wrapper: the row must be a direct child of the timeline column so its
// Bubble (1:1 DM): a centred, single dark-blue date pill. The row is the // sticky containing block is the whole day, not a one-row box.
// real `position: sticky` element — `data-day-divider` is the hook the <div className={css.BubbleDayCapsuleRow} data-day-divider="true">
// scroll effect uses to engage stickiness and pick the front pill when <span className={css.BubbleDayCapsule} role="separator">
// several pile up. No MessageBase wrapper: the row must be a direct child {dayLabel}
// of the timeline column so its sticky containing block is the whole day, </span>
// not a one-row box. </div>
<div className={css.BubbleDayCapsuleRow} data-day-divider="true"> );
<span className={css.BubbleDayCapsule} role="separator">
{dayLabel}
</span>
</div>
);
const dayDividerJSX = dayDivider && eventJSX ? renderDayDivider() : null; const dayDividerJSX = dayDivider && eventJSX ? renderDayDivider() : null;
@ -2673,7 +2666,7 @@ export function RoomTimeline({
return ( return (
<Box grow="Yes" style={{ position: 'relative' }}> <Box grow="Yes" style={{ position: 'relative' }}>
{/* Bubble (1:1 DM) day dates are the inline capsules themselves, made {/* Day dates are the inline capsules themselves (every room class), made
sticky via real CSS `position: sticky` (engaged by the effect above) sticky via real CSS `position: sticky` (engaged by the effect above)
no separate floating pill. */} no separate floating pill. */}
{unreadFloatShown && ( {unreadFloatShown && (
@ -2703,8 +2696,8 @@ export function RoomTimeline({
<Box <Box
direction="Column" direction="Column"
justifyContent="End" justifyContent="End"
// Bubble (1:1 DM) layout: centre the message column in the AI-chat band. // Every room class centres its message column in the AI-chat band.
className={messageLayout === 'stream' ? css.BubbleTimelineBand : undefined} className={css.BubbleTimelineBand}
style={{ style={{
minHeight: '100%', minHeight: '100%',
// Bottom padding reserves room for the overlay composer painted // Bottom padding reserves room for the overlay composer painted

View file

@ -24,22 +24,11 @@ import {
// same Android-WebView stuck-:hover suppression. // same Android-WebView stuck-:hover suppression.
export const ChatComposer = style({}); export const ChatComposer = style({});
// Desktop web only: constrain the composer card to ~3/4 of the chat pane // Composer band (every room class) — fit the input form to the same ~960px
// width and centre it, instead of spanning edge-to-edge (user point 15). // centred band as the timeline (BubbleTimelineBand) + the AI-bot chat
// Applied conditionally in RoomView.tsx via `useScreenSizeContext` so native // (ThreadComposerAssistant), so on wide web the composer width matches the
// / mobile / tablet keep the full-width card the user is happy with. // messages. Horizontal gutter matches BubbleTimelineBand / the bot chat: 12px on
export const ComposerDesktopClamp = style({ // native, 40px on desktop, so the composer stays aligned with the message column.
maxWidth: '75%',
marginLeft: 'auto',
marginRight: 'auto',
});
// Bubble (1:1 DM) composer band — fit the input form to the same ~960px centred
// band as the bubble timeline + the AI-bot chat (ThreadComposerAssistant), so on
// wide web the composer width matches the messages. Horizontal gutter matches
// BubbleTimelineBand / the bot chat: 12px on native, 40px on desktop, so the
// composer stays aligned with the message column. Replaces ComposerDesktopClamp
// for 1:1 DMs.
export const ComposerBubbleBand = style({ export const ComposerBubbleBand = style({
width: '100%', width: '100%',
maxWidth: toRem(VOJO_BUBBLE_BAND_PX), maxWidth: toRem(VOJO_BUBBLE_BAND_PX),

View file

@ -1,10 +1,9 @@
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { Box, Text, color, config, toRem } from 'folds'; import { Box, Text, color, config } from 'folds';
import { EventType } from 'matrix-js-sdk'; import { EventType } from 'matrix-js-sdk';
import { ReactEditor } from 'slate-react'; import { ReactEditor } from 'slate-react';
import { isKeyHotkey } from 'is-hotkey'; import { isKeyHotkey } from 'is-hotkey';
import classNames from 'classnames'; import classNames from 'classnames';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
import { useStateEvent } from '../../hooks/useStateEvent'; import { useStateEvent } from '../../hooks/useStateEvent';
import { StateEvent } from '../../../types/matrix/room'; import { StateEvent } from '../../../types/matrix/room';
import { usePowerLevelsContext } from '../../hooks/usePowerLevels'; import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
@ -19,9 +18,8 @@ import { useKeyDown } from '../../hooks/useKeyDown';
import { editableActiveElement } from '../../utils/dom'; import { editableActiveElement } from '../../utils/dom';
import { useRoomPermissions } from '../../hooks/useRoomPermissions'; import { useRoomPermissions } from '../../hooks/useRoomPermissions';
import { useRoomCreators } from '../../hooks/useRoomCreators'; import { useRoomCreators } from '../../hooks/useRoomCreators';
import { useIsOneOnOne, useRoom } from '../../hooks/useRoom'; import { useRoom } from '../../hooks/useRoom';
import { useChannelsMode, useThreadDrawerOpen } from '../../hooks/useChannelsMode'; import { useThreadDrawerOpen } from '../../hooks/useChannelsMode';
import { VOJO_HORSESHOE_GAP_PX } from '../../styles/horseshoe';
import * as css from './RoomView.css'; import * as css from './RoomView.css';
const FN_KEYS_REGEX = /^F\d+$/; const FN_KEYS_REGEX = /^F\d+$/;
@ -91,16 +89,6 @@ export function RoomView({ eventId }: { eventId?: string }) {
// `TimelineRenderingType.Thread` context. // `TimelineRenderingType.Thread` context.
const threadDrawerOpen = useThreadDrawerOpen(); const threadDrawerOpen = useThreadDrawerOpen();
// Desktop web: centre the composer at ~3/4 width (user point 15). Native /
// mobile / tablet keep the full-width card.
const isDesktop = useScreenSizeContext() === ScreenSize.Desktop;
// 1:1 DM (bubble layout): fit the composer to the same centred ~960px band as
// the bubble timeline + the AI-bot chat, instead of the 75% desktop clamp.
const isOneOnOne = useIsOneOnOne();
const channelsMode = useChannelsMode();
const isBubble = isOneOnOne && !channelsMode;
useEffect(() => { useEffect(() => {
const el = composerWrapRef.current; const el = composerWrapRef.current;
if (!el) { if (!el) {
@ -183,17 +171,11 @@ export function RoomView({ eventId }: { eventId?: string }) {
onFocusCapture={() => setComposerHidden(false)} onFocusCapture={() => setComposerHidden(false)}
> >
<div <div
className={classNames( // Every room class fits its composer to the same centred ~960px band
css.ChatComposer, // as the timeline (BubbleTimelineBand) and the AI-bot chat, so on wide
isBubble ? css.ComposerBubbleBand : isDesktop && css.ComposerDesktopClamp // web the input lines up with the message column. The band owns its own
)} // responsive horizontal padding, so no inline padding here.
style={ className={classNames(css.ChatComposer, css.ComposerBubbleBand)}
// ComposerBubbleBand owns its own (responsive) horizontal padding;
// the non-bubble path keeps the fixed horseshoe-void inline padding.
isBubble
? undefined
: { padding: `0 ${toRem(VOJO_HORSESHOE_GAP_PX)} ${toRem(VOJO_HORSESHOE_GAP_PX)}` }
}
> >
{tombstoneEvent ? ( {tombstoneEvent ? (
<RoomTombstone <RoomTombstone