diff --git a/docs/ai/architecture.md b/docs/ai/architecture.md index 4e508589..737f1cd7 100644 --- a/docs/ai/architecture.md +++ b/docs/ai/architecture.md @@ -66,10 +66,11 @@ Router in `Router.tsx`. Each top-level tab (`/direct/`, `/space/...`, `/explore/ → 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. -- Group rooms (member-count > 2) get the room-style header (no peer fallback), no `DmCallButton`, and respect the `hideMembershipEvents` / `hideNickAvatarEvents` user settings for 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`, 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. `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 | |-----|---------| | `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-settings/` | Room-specific settings page | | `common-settings/` | Shared settings: general, members, permissions, emojis-stickers, developer-tools | | `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 | | `search/` | Global search | | `message-search/` | In-room message search | diff --git a/src/app/components/message/layout/Channel.css.ts b/src/app/components/message/layout/Channel.css.ts index ce811241..d1359e65 100644 --- a/src/app/components/message/layout/Channel.css.ts +++ b/src/app/components/message/layout/Channel.css.ts @@ -12,8 +12,12 @@ export const ChannelRow = style({ display: 'flex', alignItems: 'flex-start', gap: config.space.S300, - paddingLeft: config.space.S400, - paddingRight: config.space.S400, + // 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, minWidth: 0, @@ -105,8 +109,11 @@ export const ChannelDayDividerLabel = style({ // body would, indented past the avatar slot, so the column reads // continuous. export const ChannelSysline = style({ - paddingLeft: `calc(${ChannelAvatarWidth} + ${config.space.S300} + ${config.space.S400})`, - paddingRight: config.space.S400, + // 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})`, + paddingRight: config.space.S200, paddingTop: config.space.S100, paddingBottom: config.space.S100, color: color.SurfaceVariant.OnContainer, diff --git a/src/app/components/message/layout/Channel.tsx b/src/app/components/message/layout/Channel.tsx index 1e81c2d8..a72996a2 100644 --- a/src/app/components/message/layout/Channel.tsx +++ b/src/app/components/message/layout/Channel.tsx @@ -30,10 +30,13 @@ export type ChannelLayoutProps = { // 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. Thread - // drawer flips this on so the bubble wraps the username + time the - // same way `StreamBubble` does in DM chat. Channels main timeline - // keeps this false — name + time stay above an unbordered body. + // (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. headerInBubble?: boolean; onContextMenu?: MouseEventHandler; }; @@ -143,7 +146,11 @@ export type ChannelMessageAvatarProps = { // folds `` + `` combination. Lifted out of `Message` // so the `useMediaAuthentication` / `useMatrixClient` hook calls only run // 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 useAuthentication = useMediaAuthentication(); const avatarMxc = getMemberAvatarMxc(room, senderId); diff --git a/src/app/components/message/layout/layout.css.ts b/src/app/components/message/layout/layout.css.ts index 21748788..36eba0ad 100644 --- a/src/app/components/message/layout/layout.css.ts +++ b/src/app/components/message/layout/layout.css.ts @@ -168,15 +168,18 @@ export const UsernameBold = style({ // loosen on mobile without disturbing the screen-edge anchor (which the // user dialled in earlier and asked to keep). // -// Mobile: pad = S100 (minimal screen-edge anchor); gap = 2 × S100 = S200 -// (the user asked to double the inter-element gap on native). -// Desktop: pad = S500 (≈ 0.5 cm PageNav clearance, unchanged); gap = +// Mobile: pad = S100 (minimal screen-edge anchor — already at the limit, +// dropping further would push timestamp glyphs flush against the screen +// 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 // gap by 1.1× — keeps the layout tighter without dropping a whole token). const StreamRowPadVar = createVar(); const StreamRowGapVar = createVar(); const StreamRowPadMobile = config.space.S100; -const StreamRowPadDesktop = config.space.S500; +const StreamRowPadDesktop = config.space.S400; const StreamRowGapMobile = config.space.S200; const StreamRowGapDesktop = `calc(${config.space.S500} / 1.1)`; diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 877a68a4..e394154a 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -530,16 +530,19 @@ export function RoomTimeline({ // visible row in channels-mode is safe (`useVirtualPaginator` keeps // the rendered window bounded; SDK Room maxListeners is 100). const showThreadSummary = channelsMode && !isBridged; - // M3: channels timeline uses avatar-first ChannelLayout (no rail, no - // bubble). DM/Bots stay on Stream rail. Bridged Telegram channels use - // Channel layout too — visually consistent with native channels — only - // the thread plus / cards differ (bridge has no thread semantic). - const messageLayout: 'stream' | 'channel' = channelsMode ? 'channel' : 'stream'; - // Channels main timeline shares the thread-drawer bubble silhouette: - // dark `Surface.Container` card with the username + time INSIDE the - // bubble. Stream layout (DM/Bots) keeps its native bubble header so - // the flag is gated on channels-mode only. - const channelHeaderInBubble = channelsMode; + // Avatar-first ChannelLayout is route-agnostic for any non-1:1 room: + // group DMs in /direct/, group child rooms under /space/, and every + // channel under /channels/. 1:1 DMs and 1:1 bot control rooms keep + // the Stream rail — the dot/time chrome only carries meaning when there + // are exactly two participants. Channels-mode-specific behaviour + // (thread surfacing, RTC/edit hiding) stays gated on `channelsMode` — + // 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; // 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 diff --git a/src/app/features/room/message/Message.tsx b/src/app/features/room/message/Message.tsx index 40744ed7..21fd05b4 100644 --- a/src/app/features/room/message/Message.tsx +++ b/src/app/features/room/message/Message.tsx @@ -702,16 +702,16 @@ export type MessageProps = { // whether to mount the card based on `channelsMode && !isBridged` — // outside channels-mode this stays `undefined` and the slot collapses. threadSummary?: React.ReactNode; - // M3: choose timeline visual. `'stream'` = rail + dot + bubble (current - // DM/Bots layout). `'channel'` = avatar + name + time inline + body - // without bubble (Slack/Discord-style channels timeline). Default - // `'stream'` so non-channels callers don't have to opt in. + // Choose timeline visual. `'stream'` = rail + dot + bubble — used for + // 1:1 DMs and 1:1 bot control rooms. `'channel'` = avatar + bubble with + // an in-bubble username/time header — used for every non-1:1 room + // (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'; - // Opt-in for thread-drawer rendering: forces `ChannelLayout` to - // render the username + time header INSIDE the bubble slot (mirrors - // `StreamBubble`'s in-bubble header), so the thread reads like a - // chat-bubble cluster instead of avatar-name-then-body channel rows. - // Channels main timeline keeps this false. + // Forwarded to `ChannelLayout.headerInBubble`. Every current timeline + // caller passes `true` alongside `layout='channel'`; kept as a prop so + // un-bubbled `ChannelLayout` consumers stay possible. channelHeaderInBubble?: boolean; }; export const Message = as<'div', MessageProps>(