feat(timeline): use Channel avatar+bubble layout for every non-1:1 room and shift 1:1 Stream rail ~4px left on desktop

This commit is contained in:
heaven 2026-05-28 18:07:23 +03:00
parent 2a24ee60ff
commit 1665cb185f
6 changed files with 58 additions and 37 deletions

View file

@ -66,10 +66,11 @@ Router in `Router.tsx`. Each top-level tab (`/direct/`, `/space/...`, `/explore/
→ RoomTimeline + RoomViewTyping + RoomInput → RoomTimeline + RoomViewTyping + RoomInput
``` ```
After P3c the Stream layout is the only timeline layout. There is no DM-vs-non-DM render gate. The classification that used to drive Stream now collapses into a single **member-count** check, mirroring Element-Web's tier-2 pattern (`room.getInvitedAndJoinedMemberCount() === 2`): After P3c the timeline picks between two layouts via a single **member-count** check, mirroring Element-Web's tier-2 pattern (`room.getInvitedAndJoinedMemberCount() === 2`). There is no DM-vs-non-DM render gate — the classification is purely participant-count + channels-route:
- 1:1 rooms (member-count = 2) get peer-style header chrome (peer avatar fallback in `useRoomAvatar(room, isOneOnOne)`), the `DmCallButton`, and unconditionally hide membership/nick/avatar syslines. - 1:1 rooms (member-count = 2) get peer-style header chrome (peer avatar fallback in `useRoomAvatar(room, isOneOnOne)`), the `DmCallButton`, the **Stream** timeline layout (rail + dot + bubble), and unconditionally hide membership/nick/avatar syslines.
- Group rooms (member-count > 2) get the room-style header (no peer fallback), no `DmCallButton`, and respect the `hideMembershipEvents` / `hideNickAvatarEvents` user settings for syslines. - Group rooms (member-count > 2) get the room-style header (no peer fallback), no `DmCallButton`, the **Channel** timeline layout (avatar + in-bubble header + bubble — same silhouette as channels), and respect the `hideMembershipEvents` / `hideNickAvatarEvents` user settings for syslines.
- Rooms under `/channels/` always get the Channel layout regardless of member count — `channelsMode` short-circuits the 1:1 check and additionally enables channels-only filtering (thread surfacing, RTC/edit hiding). See `RoomTimeline.tsx::channelStyleLayout`.
- Bridged Telegram puppet rooms automatically classify correctly because the gate is server-side authoritative. - Bridged Telegram puppet rooms automatically classify correctly because the gate is server-side authoritative.
`useAutoDirectSync` (commit 84eeac9) still round-trips `m.direct` on join — **interop only**, so other Matrix clients (Element, FluffyChat) still categorize the same room as a DM. Vojo no longer reads `m.direct` for UI classification; the `mDirectAtom` is kept alive for `useDirectRooms` ordering and other read-only consumers but its truth is no longer load-bearing for the layout. `useAutoDirectSync` (commit 84eeac9) still round-trips `m.direct` on join — **interop only**, so other Matrix clients (Element, FluffyChat) still categorize the same room as a DM. Vojo no longer reads `m.direct` for UI classification; the `mDirectAtom` is kept alive for `useDirectRooms` ordering and other read-only consumers but its truth is no longer load-bearing for the layout.
@ -81,12 +82,12 @@ Use `useIsOneOnOne()` from `hooks/useRoom.ts` whenever you need the 1:1 vs group
| Dir | Purpose | | Dir | Purpose |
|-----|---------| |-----|---------|
| `room/` | Core room view — **RoomTimeline.tsx** (~1700 LOC after P3c collapse), **RoomInput.tsx** (~691 LOC), **RoomViewHeader.tsx** (thin wrapper after P4) → **RoomViewHeaderDm.tsx** (Dawn header for every room class; 1:1 chrome via avatar fallback + peer-profile-sheet, group chrome via `N members` line; phone button three-gated per §6.8b; search/pinned/invite/leave moved into the `…` menu), MembersDrawer (suppressed for 1:1 in `Room.tsx`), MessageEditor, RoomTombstone, RoomViewTyping, CallChatView, CommandAutocomplete | | `room/` | Core room view — **RoomTimeline.tsx** (~1700 LOC after P3c collapse), **RoomInput.tsx** (~691 LOC), **RoomViewHeader.tsx** (thin wrapper after P4) → **RoomViewHeaderDm.tsx** (Dawn header for every room class; 1:1 chrome via avatar fallback + peer-profile-sheet, group chrome via `N members` line; phone button three-gated per §6.8b; search/pinned/invite/leave moved into the `…` menu), MembersDrawer (suppressed for 1:1 in `Room.tsx`), MessageEditor, RoomTombstone, RoomViewTyping, CallChatView, CommandAutocomplete |
| `room/message/` | `Message.tsx` (~1170 LOC after P3c) — renders Stream layout unconditionally for every room (1:1 DM, group DM, non-DM, bridged). No layout switch, no `isStream` gate. Renders edit/delete/react menu, mention/hashtag links, reactions viewer. The legacy `Compact`/`Bubble` layouts and `MessageLayout` enum are gone; `Modern.tsx` survives only as a card-preview layout for pin-menu / message-search / inbox. | | `room/message/` | `Message.tsx` (~1170 LOC after P3c) — branches between **Stream** (1:1 rooms) and **Channel** (groups + channels) layouts on the `layout` prop driven by `RoomTimeline.tsx::channelStyleLayout`. Renders edit/delete/react menu, mention/hashtag links, reactions viewer. The legacy `Compact`/`Bubble` layouts and `MessageLayout` enum are gone; `Modern.tsx` survives only as a card-preview layout for pin-menu / message-search / inbox. |
| `room-nav/` | `RoomNavItem.tsx` (~435 LOC) — list-row component, used by Home/Direct/Spaces. Carries call-room behaviour (`useCallSession`, `useCallMembers`, `useCallStart`) | | `room-nav/` | `RoomNavItem.tsx` (~435 LOC) — list-row component, used by Home/Direct/Spaces. Carries call-room behaviour (`useCallSession`, `useCallMembers`, `useCallStart`) |
| `room-settings/` | Room-specific settings page | | `room-settings/` | Room-specific settings page |
| `common-settings/` | Shared settings: general, members, permissions, emojis-stickers, developer-tools | | `common-settings/` | Shared settings: general, members, permissions, emojis-stickers, developer-tools |
| `space-settings/` | Space-specific settings | | `space-settings/` | Space-specific settings |
| `settings/` | User settings (general, account, notifications, devices, emojis, about, dev-tools). `MessageLayout` / `messageSpacing` / `legacyUsernameColor` were removed in P3c — Stream is now the only layout, and user-settings cleanup migration drops orphan persisted fields on first load. `hideMembershipEvents` / `hideNickAvatarEvents` survive — they still gate the group-room syslines. **Logout lives here only.** | | `settings/` | User settings (general, account, notifications, devices, emojis, about, dev-tools). `MessageLayout` / `messageSpacing` / `legacyUsernameColor` were removed in P3c — layout is no longer user-configurable (Stream/Channel pick automatically on member count), and the cleanup migration drops orphan persisted fields on first load. `hideMembershipEvents` / `hideNickAvatarEvents` survive — they still gate the group-room syslines. **Logout lives here only.** |
| `lobby/` | Space/room lobby view | | `lobby/` | Space/room lobby view |
| `search/` | Global search | | `search/` | Global search |
| `message-search/` | In-room message search | | `message-search/` | In-room message search |

View file

@ -12,8 +12,12 @@ export const ChannelRow = style({
display: 'flex', display: 'flex',
alignItems: 'flex-start', alignItems: 'flex-start',
gap: config.space.S300, gap: config.space.S300,
paddingLeft: config.space.S400, // S200 (8px) sits inside MessageBase's S400 (16px) left pad, so the
paddingRight: config.space.S400, // 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, paddingTop: config.space.S100,
paddingBottom: config.space.S100, paddingBottom: config.space.S100,
minWidth: 0, minWidth: 0,
@ -105,8 +109,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({
paddingLeft: `calc(${ChannelAvatarWidth} + ${config.space.S300} + ${config.space.S400})`, // Indent past the avatar gutter so the sysline body aligns with the
paddingRight: config.space.S400, // 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})`,
paddingRight: config.space.S200,
paddingTop: config.space.S100, paddingTop: config.space.S100,
paddingBottom: config.space.S100, paddingBottom: config.space.S100,
color: color.SurfaceVariant.OnContainer, color: color.SurfaceVariant.OnContainer,

View file

@ -30,10 +30,13 @@ export type ChannelLayoutProps = {
// 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 // When true, the header is rendered INSIDE the message-body slot
// (above content) instead of as a sibling row above the body. Thread // (above content) instead of as a sibling row above the body, and the
// drawer flips this on so the bubble wraps the username + time the // body element gets the `data-bubble="true"` marker so `Channel.css.ts`
// same way `StreamBubble` does in DM chat. Channels main timeline // paints it as a chat bubble (same silhouette as `StreamBubble`). All
// keeps this false — name + time stay above an unbordered body. // 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.
headerInBubble?: boolean; headerInBubble?: boolean;
onContextMenu?: MouseEventHandler<HTMLDivElement>; onContextMenu?: MouseEventHandler<HTMLDivElement>;
}; };
@ -143,7 +146,11 @@ export type ChannelMessageAvatarProps = {
// folds `<Avatar>` + `<UserAvatar>` combination. Lifted out of `Message` // folds `<Avatar>` + `<UserAvatar>` combination. Lifted out of `Message`
// so the `useMediaAuthentication` / `useMatrixClient` hook calls only run // so the `useMediaAuthentication` / `useMatrixClient` hook calls only run
// when channel layout is selected (Stream rows don't need an avatar). // when channel layout is selected (Stream rows don't need an avatar).
export function ChannelMessageAvatar({ room, senderId, senderDisplayName }: ChannelMessageAvatarProps) { export function ChannelMessageAvatar({
room,
senderId,
senderDisplayName,
}: ChannelMessageAvatarProps) {
const mx = useMatrixClient(); const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication(); const useAuthentication = useMediaAuthentication();
const avatarMxc = getMemberAvatarMxc(room, senderId); const avatarMxc = getMemberAvatarMxc(room, senderId);

View file

@ -168,15 +168,18 @@ export const UsernameBold = style({
// loosen on mobile without disturbing the screen-edge anchor (which the // loosen on mobile without disturbing the screen-edge anchor (which the
// user dialled in earlier and asked to keep). // user dialled in earlier and asked to keep).
// //
// Mobile: pad = S100 (minimal screen-edge anchor); gap = 2 × S100 = S200 // Mobile: pad = S100 (minimal screen-edge anchor — already at the limit,
// (the user asked to double the inter-element gap on native). // dropping further would push timestamp glyphs flush against the screen
// Desktop: pad = S500 (≈ 0.5 cm PageNav clearance, unchanged); gap = // edge); gap = 2 × S100 = S200 (the user asked to double the inter-element
// gap on native).
// Desktop: pad = S400 (16px ≈ 0.42 cm — shifted ~4px / 1mm closer to the
// PageNav per user request, the column still clears the nav rail); gap =
// S500 / 1.1 ≈ 18.2 px (the user asked to shrink the desktop inter-element // S500 / 1.1 ≈ 18.2 px (the user asked to shrink the desktop inter-element
// gap by 1.1× — keeps the layout tighter without dropping a whole token). // gap by 1.1× — keeps the layout tighter without dropping a whole token).
const StreamRowPadVar = createVar(); const StreamRowPadVar = createVar();
const StreamRowGapVar = createVar(); const StreamRowGapVar = createVar();
const StreamRowPadMobile = config.space.S100; const StreamRowPadMobile = config.space.S100;
const StreamRowPadDesktop = config.space.S500; const StreamRowPadDesktop = config.space.S400;
const StreamRowGapMobile = config.space.S200; const StreamRowGapMobile = config.space.S200;
const StreamRowGapDesktop = `calc(${config.space.S500} / 1.1)`; const StreamRowGapDesktop = `calc(${config.space.S500} / 1.1)`;

View file

@ -530,16 +530,19 @@ export function RoomTimeline({
// visible row in channels-mode is safe (`useVirtualPaginator` keeps // visible row in channels-mode is safe (`useVirtualPaginator` keeps
// the rendered window bounded; SDK Room maxListeners is 100). // the rendered window bounded; SDK Room maxListeners is 100).
const showThreadSummary = channelsMode && !isBridged; const showThreadSummary = channelsMode && !isBridged;
// M3: channels timeline uses avatar-first ChannelLayout (no rail, no // Avatar-first ChannelLayout is route-agnostic for any non-1:1 room:
// bubble). DM/Bots stay on Stream rail. Bridged Telegram channels use // group DMs in /direct/, group child rooms under /space/, and every
// Channel layout too — visually consistent with native channels — only // channel under /channels/. 1:1 DMs and 1:1 bot control rooms keep
// the thread plus / cards differ (bridge has no thread semantic). // the Stream rail — the dot/time chrome only carries meaning when there
const messageLayout: 'stream' | 'channel' = channelsMode ? 'channel' : 'stream'; // are exactly two participants. Channels-mode-specific behaviour
// Channels main timeline shares the thread-drawer bubble silhouette: // (thread surfacing, RTC/edit hiding) stays gated on `channelsMode` —
// dark `Surface.Container` card with the username + time INSIDE the // this flag is purely visual.
// bubble. Stream layout (DM/Bots) keeps its native bubble header so const channelStyleLayout = channelsMode || !isOneOnOne;
// the flag is gated on channels-mode only. const messageLayout: 'stream' | 'channel' = channelStyleLayout ? 'channel' : 'stream';
const channelHeaderInBubble = channelsMode; // 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;
// 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

@ -702,16 +702,16 @@ export type MessageProps = {
// whether to mount the card based on `channelsMode && !isBridged` — // whether to mount the card based on `channelsMode && !isBridged` —
// outside channels-mode this stays `undefined` and the slot collapses. // outside channels-mode this stays `undefined` and the slot collapses.
threadSummary?: React.ReactNode; threadSummary?: React.ReactNode;
// M3: choose timeline visual. `'stream'` = rail + dot + bubble (current // Choose timeline visual. `'stream'` = rail + dot + bubble — used for
// DM/Bots layout). `'channel'` = avatar + name + time inline + body // 1:1 DMs and 1:1 bot control rooms. `'channel'` = avatar + bubble with
// without bubble (Slack/Discord-style channels timeline). Default // an in-bubble username/time header — used for every non-1:1 room
// `'stream'` so non-channels callers don't have to opt in. // (group DMs, group rooms under /space/, channels). Default `'stream'`
// so card-preview callers (pin menu, message search, inbox) get the
// legacy DM look.
layout?: 'stream' | 'channel'; layout?: 'stream' | 'channel';
// Opt-in for thread-drawer rendering: forces `ChannelLayout` to // Forwarded to `ChannelLayout.headerInBubble`. Every current timeline
// render the username + time header INSIDE the bubble slot (mirrors // caller passes `true` alongside `layout='channel'`; kept as a prop so
// `StreamBubble`'s in-bubble header), so the thread reads like a // un-bubbled `ChannelLayout` consumers stay possible.
// chat-bubble cluster instead of avatar-name-then-body channel rows.
// Channels main timeline keeps this false.
channelHeaderInBubble?: boolean; channelHeaderInBubble?: boolean;
}; };
export const Message = as<'div', MessageProps>( export const Message = as<'div', MessageProps>(