redesign(p4): land Dawn RoomViewHeader for all rooms with peer chrome, presence, member-count subline, and reactive bridge gate.

This commit is contained in:
v.lagerev 2026-04-29 01:03:12 +03:00
parent 7ff25c7a95
commit 7cafd8e8aa
13 changed files with 914 additions and 639 deletions

View file

@ -80,7 +80,7 @@ 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** (~587 LOC), MembersDrawer, 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-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 |
@ -130,7 +130,7 @@ These are vojo additions on top of stock Cinny — they crossed many recent stab
| Path | Commits | Notes |
|---|---|---|
| `features/room/RoomViewHeader.tsx::DmCallButton` | calls phases 0-5.35 | Outgoing DM voice call entry point. Gated on **`useIsOneOnOne() && mDirectAtom.has(roomId) && !isBridgedRoom(room)`** after P3c — three guards: (1) strictly 2-member non-space, (2) aligned with the call lifecycle hooks (`useIncomingRtcNotifications`, `useCallerAutoHangup`) which still gate ring delivery and caller-side auto-hangup on `m.direct`, (3) explicit MSC2346 `m.bridge` exclusion so mautrix-telegram puppet rooms never expose a call button (Telegram has no Matrix-RTC equivalent). Lifecycle migration to a member-count gate stays load-bearing per §7 and is deferred to a separate plan. |
| `features/room/RoomViewHeaderDm.tsx::DmCallButton` | calls phases 0-5.35 | Outgoing DM voice call entry point. Gated on **`useIsOneOnOne() && mDirectAtom.has(roomId) && !isBridgedRoom(room)`** after P3c — three guards: (1) strictly 2-member non-space, (2) aligned with the call lifecycle hooks (`useIncomingRtcNotifications`, `useCallerAutoHangup`) which still gate ring delivery and caller-side auto-hangup on `m.direct`, (3) explicit MSC2346 `m.bridge` exclusion so mautrix-telegram puppet rooms never expose a call button (Telegram has no Matrix-RTC equivalent). Lifecycle migration to a member-count gate stays load-bearing per §7 and is deferred to a separate plan. |
| `features/call/*` (CallEmbed, CallView) | 91afffc, e8188d7, fab533e | Element Call widget; **don't unmount/remount the widget root on header redesign** — Android FGS is keyed on `joined` state |
| `features/call-status/IncomingCallStrip.tsx` | dabe5ca, 91afffc | Mounted as sibling in `Router.tsx:184` (not inside header). Decline / accept / mid-ring backgrounding handled here |
| `state/callEmbed.ts` (`callEmbedAtom`, `callChatAtom`) | call phases | Embed lifecycle |

View file

@ -13,13 +13,13 @@ src/app/components/room-card/RoomCard.tsx(259,18): error TS2345: Argument of typ
Types of property 'count' are incompatible.
Type 'string' is not assignable to type 'number'.
src/app/components/url-preview/UrlPreviewCard.tsx(57,27): error TS7006: Parameter 'evt' implicitly has an 'any' type.
src/app/components/user-profile/UserChips.tsx(267,13): error TS18048: 'room' is possibly 'undefined'.
src/app/components/user-profile/UserChips.tsx(268,28): error TS2345: Argument of type 'Room | undefined' is not assignable to parameter of type 'Room'.
src/app/components/user-profile/UserChips.tsx(271,13): error TS18048: 'room' is possibly 'undefined'.
src/app/components/user-profile/UserChips.tsx(272,28): error TS2345: Argument of type 'Room | undefined' is not assignable to parameter of type 'Room'.
Type 'undefined' is not assignable to type 'Room'.
src/app/components/user-profile/UserChips.tsx(271,30): error TS18048: 'room' is possibly 'undefined'.
src/app/components/user-profile/UserChips.tsx(272,29): error TS2345: Argument of type 'Room | undefined' is not assignable to parameter of type 'Room'.
src/app/components/user-profile/UserChips.tsx(275,26): error TS18048: 'room' is possibly 'undefined'.
src/app/components/user-profile/UserChips.tsx(276,29): error TS2345: Argument of type 'Room | undefined' is not assignable to parameter of type 'Room'.
Type 'undefined' is not assignable to type 'Room'.
src/app/components/user-profile/UserChips.tsx(275,25): error TS2345: Argument of type 'Room | undefined' is not assignable to parameter of type 'Room'.
src/app/components/user-profile/UserChips.tsx(279,25): error TS2345: Argument of type 'Room | undefined' is not assignable to parameter of type 'Room'.
Type 'undefined' is not assignable to type 'Room'.
src/app/features/add-existing/AddExisting.tsx(168,18): error TS2345: Argument of type '(Room | undefined)[]' is not assignable to parameter of type 'Room[]'.
Type 'Room | undefined' is not assignable to type 'Room'.
@ -28,14 +28,7 @@ src/app/features/call-status/LiveChip.tsx(90,35): error TS7006: Parameter 'evt'
src/app/features/call-status/MemberGlance.tsx(49,23): error TS7006: Parameter 'evt' implicitly has an 'any' type.
src/app/features/common-settings/general/RoomJoinRules.tsx(92,52): error TS2345: Argument of type 'string | undefined' is not assignable to parameter of type 'string'.
Type 'undefined' is not assignable to type 'string'.
src/app/features/room/MembersDrawer.tsx(80,16): error TS2345: Argument of type '["Room.members_count", { count: string; }]' is not assignable to parameter of type '[key: string | string[], options: TOptionsBase & $Dictionary & { defaultValue: string; }] | [key: string | string[], defaultValue: string, options?: (TOptionsBase & $Dictionary) | undefined] | [key: ...]'.
Type '["Room.members_count", { count: string; }]' is not assignable to type '[key: "Room.members_count" | "Room.members_count"[], options?: (TOptionsBase & $Dictionary) | undefined]'.
Type at position 1 in source is not compatible with type at position 1 in target.
Type '{ count: string; }' is not assignable to type 'TOptionsBase & $Dictionary'.
Type '{ count: string; }' is not assignable to type 'TOptionsBase'.
Types of property 'count' are incompatible.
Type 'string' is not assignable to type 'number'.
src/app/features/room/message/Message.tsx(963,31): error TS7006: Parameter 'ev' implicitly has an 'any' type.
src/app/features/room/message/Message.tsx(860,31): error TS7006: Parameter 'ev' implicitly has an 'any' type.
src/app/features/room/message/MessageEditor.tsx(156,39): error TS2345: Argument of type 'IContent' is not assignable to parameter of type 'RoomMessageEventContent'.
Type 'IContent' is not assignable to type 'BaseTimelineEvent & Without<(Without<ReplyEvent, NoRelationEvent> & NoRelationEvent) | (Without<NoRelationEvent, ReplyEvent> & ReplyEvent), (Without<...> & RelationEvent) | (Without<...> & ReplacementEvent<...>)> & Without<...> & ReplacementEvent<...> & FileContent'.
Property '"body"' is missing in type 'IContent' but required in type 'BaseTimelineEvent'.

View file

@ -451,12 +451,16 @@
"drop_typing": "Dismiss typing indicator",
"members": "Members",
"members_count": "{{count}} Members",
"members_count_one": "{{formattedCount}} Member",
"members_count_other": "{{formattedCount}} Members",
"hide_members": "Hide Members",
"show_members": "Show Members",
"more_options": "More Options",
"close": "Close",
"search": "Search",
"encrypted_short": "e2ee",
"status_online": "online",
"open_profile_of": "Open profile of {{name}}",
"notifications": "Notifications",
"invite": "Invite",
"room_settings": "Room Settings",

View file

@ -451,12 +451,18 @@
"drop_typing": "Скрыть индикатор набора",
"members": "Участники",
"members_count": "{{count}} участников",
"members_count_one": "{{formattedCount}} участник",
"members_count_few": "{{formattedCount}} участника",
"members_count_many": "{{formattedCount}} участников",
"members_count_other": "{{formattedCount}} участника",
"hide_members": "Скрыть участников",
"show_members": "Показать участников",
"more_options": "Ещё",
"close": "Закрыть",
"search": "Поиск",
"encrypted_short": "e2ee",
"status_online": "онлайн",
"open_profile_of": "Открыть профиль {{name}}",
"notifications": "Уведомления",
"invite": "Пригласить",
"room_settings": "Настройки комнаты",

View file

@ -73,11 +73,17 @@ function MemberDrawerHeader({ room }: MemberDrawerHeaderProps) {
<Box grow="Yes" alignItems="Center" gap="200">
<Box grow="Yes" alignItems="Center" gap="200">
<Text
title={t('Room.members_count', { count: room.getJoinedMemberCount() })}
title={t('Room.members_count', {
count: room.getJoinedMemberCount(),
formattedCount: room.getJoinedMemberCount(),
})}
size="H5"
truncate
>
{t('Room.members_count', { count: millify(room.getJoinedMemberCount()) })}
{t('Room.members_count', {
count: room.getJoinedMemberCount(),
formattedCount: millify(room.getJoinedMemberCount()),
})}
</Text>
</Box>
<Box shrink="No" alignItems="Center">

View file

@ -9,7 +9,7 @@ import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
import { PowerLevelsContextProvider, usePowerLevels } from '../../hooks/usePowerLevels';
import { useRoom } from '../../hooks/useRoom';
import { useIsOneOnOne, useRoom } from '../../hooks/useRoom';
import { useKeyDown } from '../../hooks/useKeyDown';
import { markAsRead } from '../../utils/notifications';
import { useMatrixClient } from '../../hooks/useMatrixClient';
@ -30,6 +30,12 @@ export function Room() {
const powerLevels = usePowerLevels(room);
const members = useRoomMembers(mx, room.roomId);
const chat = useAtomValue(callChatAtom);
// 1:1 rooms get peer-profile-sheet via avatar tap in the header instead of
// the members drawer — see dm_1x1_redesign.md §8 P4 deliverable 9. The
// value comes from the same `IsOneOnOneProvider` the header reads, so the
// drawer-suppression and header chrome flip together when membership
// changes (provider is reactive via `useIsOneOnOneRoom` upstream).
const isOneOnOne = useIsOneOnOne();
useKeyDown(
window,
@ -73,7 +79,7 @@ export function Room() {
<CallChatView />
</>
)}
{!callView && screenSize === ScreenSize.Desktop && isDrawer && (
{!callView && !isOneOnOne && screenSize === ScreenSize.Desktop && isDrawer && (
<>
<Line variant="Background" direction="Vertical" size="300" />
<MembersDrawer key={room.roomId} room={room} members={members} />

View file

@ -1,10 +0,0 @@
import { style } from '@vanilla-extract/css';
import { config } from 'folds';
export const HeaderTopic = style({
':hover': {
cursor: 'pointer',
opacity: config.opacity.P500,
textDecoration: 'underline',
},
});

View file

@ -1,611 +1,11 @@
import React, { MouseEventHandler, forwardRef, useState } from 'react';
import { useAtomValue } from 'jotai';
import FocusTrap from 'focus-trap-react';
import {
Box,
Avatar,
Text,
Overlay,
OverlayCenter,
OverlayBackdrop,
IconButton,
Icon,
Icons,
Tooltip,
TooltipProvider,
Menu,
MenuItem,
toRem,
config,
Line,
PopOut,
RectCords,
Badge,
Spinner,
} from 'folds';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Room } from 'matrix-js-sdk';
import { useStateEvent } from '../../hooks/useStateEvent';
import { mDirectAtom } from '../../state/mDirectList';
import { isBridgedRoom } from '../../utils/room';
import { PageHeader } from '../../components/page';
import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
import { UseStateProvider } from '../../components/UseStateProvider';
import { RoomTopicViewer } from '../../components/room-topic-viewer';
import { StateEvent } from '../../../types/matrix/room';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useIsOneOnOne, useRoom } from '../../hooks/useRoom';
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
import { useSpaceOptionally } from '../../hooks/useSpace';
import { getHomeSearchPath, getSpaceSearchPath, withSearchParam } from '../../pages/pathUtils';
import { getCanonicalAliasOrRoomId, isRoomAlias, mxcUrlToHttp } from '../../utils/matrix';
import { _SearchPathSearchParams } from '../../pages/paths';
import * as css from './RoomViewHeader.css';
import { useRoomUnread } from '../../state/hooks/unread';
import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
import { markAsRead } from '../../utils/notifications';
import { roomToUnreadAtom } from '../../state/room/roomToUnread';
import { copyToClipboard } from '../../utils/dom';
import { LeaveRoomPrompt } from '../../components/leave-room-prompt';
import { useRoomAvatar, useRoomName, useRoomTopic } from '../../hooks/useRoomMeta';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
import { stopPropagation } from '../../utils/keyboard';
import { getMatrixToRoom } from '../../plugins/matrix-to';
import { getViaServers } from '../../plugins/via-servers';
import { BackRouteHandler } from '../../components/BackRouteHandler';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useRoomPinnedEvents } from '../../hooks/useRoomPinnedEvents';
import { RoomPinMenu } from './room-pin-menu';
import { useOpenRoomSettings } from '../../state/hooks/roomSettings';
import { RoomNotificationModeSwitcher } from '../../components/RoomNotificationSwitcher';
import {
getRoomNotificationMode,
getRoomNotificationModeIcon,
useRoomsNotificationPreferencesContext,
} from '../../hooks/useRoomsNotificationPreferences';
import { JumpToTime } from './jump-to-time';
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
import { useRoomCreators } from '../../hooks/useRoomCreators';
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
import { InviteUserPrompt } from '../../components/invite-user-prompt';
import { ContainerColor } from '../../styles/ContainerColor.css';
import { RoomSettingsPage } from '../../state/roomSettings';
import { useLivekitSupport } from '../../hooks/useLivekitSupport';
import { useCallMembers, useCallSession } from '../../hooks/useCall';
import { useSwitchOrStartDmCall } from '../../hooks/useSwitchOrStartDmCall';
import { callEmbedAtom } from '../../state/callEmbed';
type RoomMenuProps = {
room: Room;
requestClose: () => void;
};
const RoomMenu = forwardRef<HTMLDivElement, RoomMenuProps>(({ room, requestClose }, ref) => {
const { t } = useTranslation();
const mx = useMatrixClient();
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
const powerLevels = usePowerLevelsContext();
const creators = useRoomCreators(room);
const permissions = useRoomPermissions(creators, powerLevels);
const canInvite = permissions.action('invite', mx.getSafeUserId());
const notificationPreferences = useRoomsNotificationPreferencesContext();
const notificationMode = getRoomNotificationMode(notificationPreferences, room.roomId);
const { navigateRoom } = useRoomNavigate();
const [invitePrompt, setInvitePrompt] = useState(false);
const handleMarkAsRead = () => {
markAsRead(mx, room.roomId, hideActivity);
requestClose();
};
const handleInvite = () => {
setInvitePrompt(true);
};
const handleCopyLink = () => {
const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, room.roomId);
const viaServers = isRoomAlias(roomIdOrAlias) ? undefined : getViaServers(room);
copyToClipboard(getMatrixToRoom(roomIdOrAlias, viaServers));
requestClose();
};
const openSettings = useOpenRoomSettings();
const parentSpace = useSpaceOptionally();
const handleOpenSettings = () => {
openSettings(room.roomId, parentSpace?.roomId);
requestClose();
};
return (
<Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}>
{invitePrompt && (
<InviteUserPrompt
room={room}
requestClose={() => {
setInvitePrompt(false);
requestClose();
}}
/>
)}
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
<MenuItem
onClick={handleMarkAsRead}
size="300"
after={<Icon size="100" src={Icons.CheckTwice} />}
radii="300"
disabled={!unread}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
{t('Room.mark_as_read')}
</Text>
</MenuItem>
<RoomNotificationModeSwitcher roomId={room.roomId} value={notificationMode}>
{(handleOpen, opened, changing) => (
<MenuItem
size="300"
after={
changing ? (
<Spinner size="100" variant="Secondary" />
) : (
<Icon size="100" src={getRoomNotificationModeIcon(notificationMode)} />
)
}
radii="300"
aria-pressed={opened}
onClick={handleOpen}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
{t('Room.notifications')}
</Text>
</MenuItem>
)}
</RoomNotificationModeSwitcher>
</Box>
<Line variant="Surface" size="300" />
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
<MenuItem
onClick={handleInvite}
variant="Primary"
fill="None"
size="300"
after={<Icon size="100" src={Icons.UserPlus} />}
radii="300"
aria-pressed={invitePrompt}
disabled={!canInvite}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
{t('Room.invite')}
</Text>
</MenuItem>
<MenuItem
onClick={handleCopyLink}
size="300"
after={<Icon size="100" src={Icons.Link} />}
radii="300"
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
{t('Room.copy_link')}
</Text>
</MenuItem>
<MenuItem
onClick={handleOpenSettings}
size="300"
after={<Icon size="100" src={Icons.Setting} />}
radii="300"
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
{t('Room.room_settings')}
</Text>
</MenuItem>
<UseStateProvider initial={false}>
{(promptJump, setPromptJump) => (
<>
<MenuItem
onClick={() => setPromptJump(true)}
size="300"
after={<Icon size="100" src={Icons.RecentClock} />}
radii="300"
aria-pressed={promptJump}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
{t('Room.jump_to_time')}
</Text>
</MenuItem>
{promptJump && (
<JumpToTime
onSubmit={(eventId) => {
setPromptJump(false);
navigateRoom(room.roomId, eventId);
requestClose();
}}
onCancel={() => setPromptJump(false)}
/>
)}
</>
)}
</UseStateProvider>
</Box>
<Line variant="Surface" size="300" />
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
<UseStateProvider initial={false}>
{(promptLeave, setPromptLeave) => (
<>
<MenuItem
onClick={() => setPromptLeave(true)}
variant="Critical"
fill="None"
size="300"
after={<Icon size="100" src={Icons.ArrowGoLeft} />}
radii="300"
aria-pressed={promptLeave}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
{t('Room.leave_room')}
</Text>
</MenuItem>
{promptLeave && (
<LeaveRoomPrompt
roomId={room.roomId}
onDone={requestClose}
onCancel={() => setPromptLeave(false)}
/>
)}
</>
)}
</UseStateProvider>
</Box>
</Menu>
);
});
function DmCallButton({ room }: { room: Room }) {
const { t } = useTranslation();
const mx = useMatrixClient();
const switchOrStartDmCall = useSwitchOrStartDmCall();
const livekitSupported = useLivekitSupport();
const session = useCallSession(room);
const members = useCallMembers(room, session);
const currentEmbed = useAtomValue(callEmbedAtom);
const myUserId = mx.getSafeUserId();
const inCallHere = currentEmbed?.roomId === room.roomId;
if (inCallHere) return null;
const ongoingByOthers = members.length > 0 && !members.some((m) => m.sender === myUserId);
const disabled = !livekitSupported;
let tooltipText: string;
if (!livekitSupported) tooltipText = t('Call.unavailable');
else if (ongoingByOthers) tooltipText = t('Call.join');
else tooltipText = t('Call.start');
const handleClick = () => {
if (disabled) return;
switchOrStartDmCall(room.roomId).catch((err: unknown) => {
// eslint-disable-next-line no-console
console.warn('[call] header switch/start failed', err);
});
};
return (
<TooltipProvider
position="Bottom"
offset={4}
tooltip={
<Tooltip>
<Text>{tooltipText}</Text>
</Tooltip>
}
>
{(triggerRef) => (
<IconButton
fill="None"
ref={triggerRef}
onClick={handleClick}
disabled={disabled}
aria-label={tooltipText}
>
<Icon size="400" src={Icons.Phone} />
</IconButton>
)}
</TooltipProvider>
);
}
import React from 'react';
import { RoomViewHeaderDm } from './RoomViewHeaderDm';
// Universal entry point. After P3c the Stream layout serves every room class
// (1:1 DM, group DM, non-DM group, bridged), and after P4 the new Dawn-styled
// `RoomViewHeaderDm` becomes the chrome for all of them. The `Dm` suffix is
// historical from the DM-only era of P3a — kept for now to keep the diff
// reviewable; rename is a P6 cleanup. See dm_1x1_redesign.md §8 P4.
export function RoomViewHeader({ callView }: { callView?: boolean }) {
const { t } = useTranslation();
const navigate = useNavigate();
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const screenSize = useScreenSizeContext();
const room = useRoom();
const space = useSpaceOptionally();
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
const [pinMenuAnchor, setPinMenuAnchor] = useState<RectCords>();
const isOneOnOne = useIsOneOnOne();
// Call surface is intentionally narrower than 1:1 chrome. Three gates:
// 1. `isOneOnOne` — only strictly 2-member non-space rooms; group calls
// ship as a separate plan after channels.md.
// 2. `mDirectAtom.has(roomId)` — aligns visibility with the lifecycle
// hooks (`useIncomingRtcNotifications`, `useCallerAutoHangup`) which
// still gate ring delivery and caller-side auto-hangup on `m.direct`.
// Moving the lifecycle to a member-count gate is a separate plan
// because those hooks are load-bearing per dm_1x1_redesign.md §7.
// 3. `!isBridgedRoom(room)` — defense in depth for bridged DMs (mautrix-
// telegram puppet rooms, etc.). Bridged networks like Telegram have no
// Matrix-RTC equivalent, so even if a bridge config writes `m.direct`
// for its puppet rooms (`bridge.sync_direct_chat_list: true`) we must
// not expose a call button that physically can't connect.
const mDirects = useAtomValue(mDirectAtom);
const callButtonVisible =
isOneOnOne && mDirects.has(room.roomId) && !isBridgedRoom(room);
const pinnedEvents = useRoomPinnedEvents(room);
const encryptionEvent = useStateEvent(room, StateEvent.RoomEncryption);
const encryptedRoom = !!encryptionEvent;
const avatarMxc = useRoomAvatar(room, isOneOnOne);
const name = useRoomName(room);
const topic = useRoomTopic(room);
const avatarUrl = avatarMxc
? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined
: undefined;
const [peopleDrawer, setPeopleDrawer] = useSetting(settingsAtom, 'isPeopleDrawer');
const handleSearchClick = () => {
const searchParams: _SearchPathSearchParams = {
rooms: room.roomId,
};
const path = space
? getSpaceSearchPath(getCanonicalAliasOrRoomId(mx, space.roomId))
: getHomeSearchPath();
navigate(withSearchParam(path, searchParams));
};
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
setMenuAnchor(evt.currentTarget.getBoundingClientRect());
};
const handleOpenPinMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
setPinMenuAnchor(evt.currentTarget.getBoundingClientRect());
};
const openSettings = useOpenRoomSettings();
const parentSpace = useSpaceOptionally();
const handleMemberToggle = () => {
if (callView) {
openSettings(room.roomId, parentSpace?.roomId, RoomSettingsPage.MembersPage);
return;
}
setPeopleDrawer(!peopleDrawer);
};
return (
<PageHeader
className={ContainerColor({ variant: 'Surface' })}
balance={screenSize === ScreenSize.Mobile}
>
<Box grow="Yes" gap="300">
{screenSize === ScreenSize.Mobile && (
<BackRouteHandler>
{(onBack) => (
<Box shrink="No" alignItems="Center">
<IconButton fill="None" onClick={onBack}>
<Icon src={Icons.ArrowLeft} />
</IconButton>
</Box>
)}
</BackRouteHandler>
)}
<Box grow="Yes" alignItems="Center" gap="300">
{screenSize !== ScreenSize.Mobile && (
<Avatar size="300">
<RoomAvatar
roomId={room.roomId}
src={avatarUrl}
alt={name}
renderFallback={() => (
<RoomIcon size="200" joinRule={room.getJoinRule()} roomType={room.getType()} />
)}
/>
</Avatar>
)}
<Box direction="Column">
<Text size={topic ? 'H5' : 'H3'} truncate>
{name}
</Text>
{topic && (
<UseStateProvider initial={false}>
{(viewTopic, setViewTopic) => (
<>
<Overlay open={viewTopic} backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
clickOutsideDeactivates: true,
onDeactivate: () => setViewTopic(false),
escapeDeactivates: stopPropagation,
}}
>
<RoomTopicViewer
name={name}
topic={topic}
requestClose={() => setViewTopic(false)}
/>
</FocusTrap>
</OverlayCenter>
</Overlay>
<Text
as="button"
type="button"
onClick={() => setViewTopic(true)}
className={css.HeaderTopic}
size="T200"
priority="300"
truncate
>
{topic}
</Text>
</>
)}
</UseStateProvider>
)}
</Box>
</Box>
<Box shrink="No">
{callButtonVisible && <DmCallButton room={room} />}
{/* In-room search currently only resolves when there's a parent
space the legacy /home/search redirect drops the `?rooms=…`
query, so a non-space click would land on /direct/ with no
search UI mounted. P4 lifts search into the `` menu and the
global SearchModalRenderer; until then we hide the chrome
outside spaces. */}
{!encryptedRoom && space && (
<TooltipProvider
position="Bottom"
offset={4}
tooltip={
<Tooltip>
<Text>{t('Room.search')}</Text>
</Tooltip>
}
>
{(triggerRef) => (
<IconButton fill="None" ref={triggerRef} onClick={handleSearchClick}>
<Icon size="400" src={Icons.Search} />
</IconButton>
)}
</TooltipProvider>
)}
<TooltipProvider
position="Bottom"
offset={4}
tooltip={
<Tooltip>
<Text>{t('Room.pinned_messages')}</Text>
</Tooltip>
}
>
{(triggerRef) => (
<IconButton
fill="None"
style={{ position: 'relative' }}
onClick={handleOpenPinMenu}
ref={triggerRef}
aria-pressed={!!pinMenuAnchor}
>
{pinnedEvents.length > 0 && (
<Badge
style={{
position: 'absolute',
left: toRem(3),
top: toRem(3),
}}
variant="Secondary"
size="400"
fill="Solid"
radii="Pill"
>
<Text as="span" size="L400">
{pinnedEvents.length}
</Text>
</Badge>
)}
<Icon size="400" src={Icons.Pin} filled={!!pinMenuAnchor} />
</IconButton>
)}
</TooltipProvider>
<PopOut
anchor={pinMenuAnchor}
position="Bottom"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
returnFocusOnDeactivate: false,
onDeactivate: () => setPinMenuAnchor(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
escapeDeactivates: stopPropagation,
}}
>
<RoomPinMenu room={room} requestClose={() => setPinMenuAnchor(undefined)} />
</FocusTrap>
}
/>
{screenSize === ScreenSize.Desktop && (
<TooltipProvider
position="Bottom"
offset={4}
tooltip={
<Tooltip>
{callView ? (
<Text>{t('Room.members')}</Text>
) : (
<Text>{peopleDrawer ? t('Room.hide_members') : t('Room.show_members')}</Text>
)}
</Tooltip>
}
>
{(triggerRef) => (
<IconButton fill="None" ref={triggerRef} onClick={handleMemberToggle}>
<Icon size="400" src={Icons.User} />
</IconButton>
)}
</TooltipProvider>
)}
<TooltipProvider
position="Bottom"
align="End"
offset={4}
tooltip={
<Tooltip>
<Text>{t('Room.more_options')}</Text>
</Tooltip>
}
>
{(triggerRef) => (
<IconButton
fill="None"
onClick={handleOpenMenu}
ref={triggerRef}
aria-pressed={!!menuAnchor}
>
<Icon size="400" src={Icons.VerticalDots} filled={!!menuAnchor} />
</IconButton>
)}
</TooltipProvider>
<PopOut
anchor={menuAnchor}
position="Bottom"
align="End"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
returnFocusOnDeactivate: false,
onDeactivate: () => setMenuAnchor(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
escapeDeactivates: stopPropagation,
}}
>
<RoomMenu room={room} requestClose={() => setMenuAnchor(undefined)} />
</FocusTrap>
}
/>
</Box>
</Box>
</PageHeader>
);
return <RoomViewHeaderDm callView={callView} />;
}

View file

@ -0,0 +1,119 @@
import { style } from '@vanilla-extract/css';
import { color, config, toRem } from 'folds';
// Tighten the header's left gutter so the avatar + title block sits
// further left than `PageHeader`'s desktop default (`S400` = 16px). We
// match the back-arrow icon-button's natural top/bottom clearance with
// `S200` (=8px) so its hover background looks symmetrically inset on all
// four sides. Right side padding is untouched, so the action buttons
// keep their inset.
export const HeaderShell = style({
paddingLeft: config.space.S200,
});
// Tap-target wrapper for the peer avatar — opens the user-room-profile sheet
// in 1:1 chrome. Reset native button styling so it sits flush with the
// surrounding header row.
export const PeerAvatarTrigger = style({
background: 'transparent',
border: 'none',
padding: 0,
cursor: 'pointer',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: '50%',
flexShrink: 0,
':focus-visible': {
outline: `2px solid ${color.Primary.Main}`,
outlineOffset: 2,
},
});
export const PeerAvatarStatic = style({
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
});
// Title row: name + «онлайн» presence tag on a single line, identical on
// mobile and desktop. Name truncates first; the tag never shrinks. Vertical
// alignment is `center` so the tag sits on the visual midline of the room
// name regardless of the font-size delta between the H4/H5 name and the
// T200 tag text.
export const TitleRow = style({
display: 'flex',
alignItems: 'center',
gap: config.space.S200,
minWidth: 0,
});
export const RoomName = style({
minWidth: 0,
flexShrink: 1,
});
// Inline-flex chip with `alignItems: center` gives a true visual-center
// alignment of the lock icon with the «e2ee» glyphs (not the baseline-plus-
// x-height/2 approximation that `vertical-align: middle` produces on inline
// elements). The parent `TitleRow` is also `alignItems: center`, so the
// chip's outer midline lands on the room name's midline — both centerings
// are real, no baseline math involved.
export const E2eeChip = style({
display: 'inline-flex',
alignItems: 'center',
gap: '0.25em',
color: color.Success.Main,
flexShrink: 0,
whiteSpace: 'nowrap',
});
// Icon sized 1.1× down from the chip's font (per spec). `1em` makes it track
// the chip's text size automatically — no rem/px literal. The flex parent
// handles vertical centering, so no `vertical-align` nudge is needed here.
export const E2eeIcon = style({
width: 'calc(1em / 1.1)',
height: 'calc(1em / 1.1)',
});
// Subline row — flex container hosting the muted identifier (handle for
// 1:1, member-count for groups) and, in 1:1, the e2ee chip. The handle
// takes the flex-shrink budget so it ellipsises first under pressure;
// the chip stays at intrinsic width via its own `flex-shrink: 0`. Vertical
// alignment is `center` so the chip's icon-and-text inline-flex sits on
// the same midline as the handle text.
export const Subline = style({
marginTop: toRem(1),
display: 'flex',
alignItems: 'center',
gap: config.space.S200,
minWidth: 0,
});
// Muted handle / member-count segment — surface-on-container with `P400`
// fade, takes the entire flex-shrink budget so it ellipsises first under
// pressure. `min-width: 0` is required for `text-overflow` to engage on
// flex children. Mono font: matches the canon (the handle reads as an
// identifier, not prose).
export const SublineMuted = style({
color: color.Surface.OnContainer,
opacity: config.opacity.P400,
minWidth: 0,
flex: '0 1 auto',
overflow: 'hidden',
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
fontFamily: 'ui-monospace, "JetBrains Mono Variable", "JetBrains Mono", monospace',
});
// «онлайн» presence tag — sibling of the room name in `TitleRow`. Same
// green as the e2ee chip (`color.Success.Main` = Dawn green `#7dd3a8`).
// `flex-shrink: 0` so the tag never collapses; `white-space: nowrap`
// prevents internal wrapping on tight viewports (the room name truncates
// first via `RoomName.flex-shrink: 1`).
export const OnlineTag = style({
color: color.Success.Main,
flexShrink: 0,
whiteSpace: 'nowrap',
});

View file

@ -0,0 +1,673 @@
/* eslint-disable react/destructuring-assignment */
import React, { forwardRef, MouseEventHandler, useState } from 'react';
import {
Avatar,
Badge,
Box,
Icon,
IconButton,
Icons,
Line,
Menu,
MenuItem,
PopOut,
RectCords,
Spinner,
Text,
Tooltip,
TooltipProvider,
config,
toRem,
} from 'folds';
import FocusTrap from 'focus-trap-react';
import { useTranslation } from 'react-i18next';
import { useAtomValue, useSetAtom } from 'jotai';
import { Room } from 'matrix-js-sdk';
import { PageHeader } from '../../components/page';
import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
import { UserAvatar } from '../../components/user-avatar';
import { UseStateProvider } from '../../components/UseStateProvider';
import { BackRouteHandler } from '../../components/BackRouteHandler';
import { LeaveRoomPrompt } from '../../components/leave-room-prompt';
import { InviteUserPrompt } from '../../components/invite-user-prompt';
import { RoomNotificationModeSwitcher } from '../../components/RoomNotificationSwitcher';
import { ContainerColor } from '../../styles/ContainerColor.css';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
import { useStateEvent } from '../../hooks/useStateEvent';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useIsOneOnOne, useRoom } from '../../hooks/useRoom';
import { useIsBridgedRoom } from '../../hooks/useIsBridgedRoom';
import { useRoomMemberCount } from '../../hooks/useRoomMemberCount';
import { Presence, useUserPresence } from '../../hooks/useUserPresence';
import { useRoomAvatar, useRoomName } from '../../hooks/useRoomMeta';
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
import { useRoomUnread } from '../../state/hooks/unread';
import { useRoomPinnedEvents } from '../../hooks/useRoomPinnedEvents';
import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
import { useRoomCreators } from '../../hooks/useRoomCreators';
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
import { useSetting } from '../../state/hooks/settings';
import { useSpaceOptionally } from '../../hooks/useSpace';
import { useOpenRoomSettings } from '../../state/hooks/roomSettings';
import {
getRoomNotificationMode,
getRoomNotificationModeIcon,
useRoomsNotificationPreferencesContext,
} from '../../hooks/useRoomsNotificationPreferences';
import { useLivekitSupport } from '../../hooks/useLivekitSupport';
import { useCallMembers, useCallSession } from '../../hooks/useCall';
import { useSwitchOrStartDmCall } from '../../hooks/useSwitchOrStartDmCall';
import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile';
import { settingsAtom } from '../../state/settings';
import { mDirectAtom } from '../../state/mDirectList';
import { callEmbedAtom } from '../../state/callEmbed';
import { searchModalAtom } from '../../state/searchModal';
import { roomToUnreadAtom } from '../../state/room/roomToUnread';
import { RoomSettingsPage } from '../../state/roomSettings';
import { StateEvent } from '../../../types/matrix/room';
import {
getCanonicalAliasOrRoomId,
getMxIdLocalPart,
getMxIdServer,
guessDmRoomUserId,
isRoomAlias,
mxcUrlToHttp,
} from '../../utils/matrix';
import { copyToClipboard } from '../../utils/dom';
import { stopPropagation } from '../../utils/keyboard';
import { markAsRead } from '../../utils/notifications';
import { getMatrixToRoom } from '../../plugins/matrix-to';
import { getViaServers } from '../../plugins/via-servers';
import { JumpToTime } from './jump-to-time';
import { RoomPinMenu } from './room-pin-menu';
import * as css from './RoomViewHeaderDm.css';
type RoomMenuProps = {
room: Room;
callView?: boolean;
onSearch: () => void;
onPin: (cords: RectCords) => void;
requestClose: () => void;
};
const RoomMenu = forwardRef<HTMLDivElement, RoomMenuProps>(
({ room, callView, onSearch, onPin, requestClose }, ref) => {
const { t } = useTranslation();
const mx = useMatrixClient();
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
const powerLevels = usePowerLevelsContext();
const creators = useRoomCreators(room);
const permissions = useRoomPermissions(creators, powerLevels);
const canInvite = permissions.action('invite', mx.getSafeUserId());
const notificationPreferences = useRoomsNotificationPreferencesContext();
const notificationMode = getRoomNotificationMode(notificationPreferences, room.roomId);
const { navigateRoom } = useRoomNavigate();
const pinnedEvents = useRoomPinnedEvents(room);
const openSettings = useOpenRoomSettings();
const parentSpace = useSpaceOptionally();
const [invitePrompt, setInvitePrompt] = useState(false);
const handleMarkAsRead = () => {
markAsRead(mx, room.roomId, hideActivity);
requestClose();
};
const handleSearch = () => {
onSearch();
requestClose();
};
const handleOpenPinned: MouseEventHandler<HTMLButtonElement> = (evt) => {
onPin(evt.currentTarget.getBoundingClientRect());
requestClose();
};
const handleCopyLink = () => {
const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, room.roomId);
const viaServers = isRoomAlias(roomIdOrAlias) ? undefined : getViaServers(room);
copyToClipboard(getMatrixToRoom(roomIdOrAlias, viaServers));
requestClose();
};
const handleOpenSettings = () => {
openSettings(room.roomId, parentSpace?.roomId);
requestClose();
};
const handleInvite = () => {
setInvitePrompt(true);
};
return (
<Menu ref={ref} style={{ maxWidth: toRem(220), width: '100vw' }}>
{invitePrompt && (
<InviteUserPrompt
room={room}
requestClose={() => {
setInvitePrompt(false);
requestClose();
}}
/>
)}
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
<MenuItem
onClick={handleMarkAsRead}
size="300"
after={<Icon size="100" src={Icons.CheckTwice} />}
radii="300"
disabled={!unread}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
{t('Room.mark_as_read')}
</Text>
</MenuItem>
<RoomNotificationModeSwitcher roomId={room.roomId} value={notificationMode}>
{(handleOpen, opened, changing) => (
<MenuItem
size="300"
after={
changing ? (
<Spinner size="100" variant="Secondary" />
) : (
<Icon size="100" src={getRoomNotificationModeIcon(notificationMode)} />
)
}
radii="300"
aria-pressed={opened}
onClick={handleOpen}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
{t('Room.notifications')}
</Text>
</MenuItem>
)}
</RoomNotificationModeSwitcher>
</Box>
<Line variant="Surface" size="300" />
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
<MenuItem
onClick={handleSearch}
size="300"
after={<Icon size="100" src={Icons.Search} />}
radii="300"
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
{t('Room.search')}
</Text>
</MenuItem>
<MenuItem
onClick={handleOpenPinned}
size="300"
after={
<Box gap="100" alignItems="Center">
{pinnedEvents.length > 0 && (
<Badge variant="Secondary" size="400" fill="Solid" radii="Pill">
<Text as="span" size="L400">
{pinnedEvents.length}
</Text>
</Badge>
)}
<Icon size="100" src={Icons.Pin} />
</Box>
}
radii="300"
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
{t('Room.pinned_messages')}
</Text>
</MenuItem>
{canInvite && (
<MenuItem
onClick={handleInvite}
variant="Primary"
fill="None"
size="300"
after={<Icon size="100" src={Icons.UserPlus} />}
radii="300"
aria-pressed={invitePrompt}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
{t('Room.invite')}
</Text>
</MenuItem>
)}
<MenuItem
onClick={handleCopyLink}
size="300"
after={<Icon size="100" src={Icons.Link} />}
radii="300"
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
{t('Room.copy_link')}
</Text>
</MenuItem>
<MenuItem
onClick={handleOpenSettings}
size="300"
after={<Icon size="100" src={Icons.Setting} />}
radii="300"
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
{t('Room.room_settings')}
</Text>
</MenuItem>
<UseStateProvider initial={false}>
{(promptJump, setPromptJump) => (
<>
<MenuItem
onClick={() => setPromptJump(true)}
size="300"
after={<Icon size="100" src={Icons.RecentClock} />}
radii="300"
aria-pressed={promptJump}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
{t('Room.jump_to_time')}
</Text>
</MenuItem>
{promptJump && (
<JumpToTime
onSubmit={(eventId) => {
setPromptJump(false);
navigateRoom(room.roomId, eventId);
requestClose();
}}
onCancel={() => setPromptJump(false)}
/>
)}
</>
)}
</UseStateProvider>
</Box>
<Line variant="Surface" size="300" />
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
<UseStateProvider initial={false}>
{(promptLeave, setPromptLeave) => (
<>
<MenuItem
onClick={() => setPromptLeave(true)}
variant="Critical"
fill="None"
size="300"
after={<Icon size="100" src={Icons.ArrowGoLeft} />}
radii="300"
aria-pressed={promptLeave}
disabled={callView}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
{t('Room.leave_room')}
</Text>
</MenuItem>
{promptLeave && (
<LeaveRoomPrompt
roomId={room.roomId}
onDone={requestClose}
onCancel={() => setPromptLeave(false)}
/>
)}
</>
)}
</UseStateProvider>
</Box>
</Menu>
);
}
);
function DmCallButton({ room }: { room: Room }) {
const { t } = useTranslation();
const mx = useMatrixClient();
const switchOrStartDmCall = useSwitchOrStartDmCall();
const livekitSupported = useLivekitSupport();
const session = useCallSession(room);
const members = useCallMembers(room, session);
const currentEmbed = useAtomValue(callEmbedAtom);
const myUserId = mx.getSafeUserId();
const inCallHere = currentEmbed?.roomId === room.roomId;
if (inCallHere) return null;
const ongoingByOthers = members.length > 0 && !members.some((m) => m.sender === myUserId);
const disabled = !livekitSupported;
let tooltipText: string;
if (!livekitSupported) tooltipText = t('Call.unavailable');
else if (ongoingByOthers) tooltipText = t('Call.join');
else tooltipText = t('Call.start');
const handleClick = () => {
if (disabled) return;
switchOrStartDmCall(room.roomId).catch((err: unknown) => {
// eslint-disable-next-line no-console
console.warn('[call] header switch/start failed', err);
});
};
return (
<TooltipProvider
position="Bottom"
offset={4}
tooltip={
<Tooltip>
<Text>{tooltipText}</Text>
</Tooltip>
}
>
{(triggerRef) => (
<IconButton
fill="None"
ref={triggerRef}
onClick={handleClick}
disabled={disabled}
aria-label={tooltipText}
>
<Icon size="400" src={Icons.Phone} />
</IconButton>
)}
</TooltipProvider>
);
}
export function RoomViewHeaderDm({ callView }: { callView?: boolean }) {
const { t } = useTranslation();
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const screenSize = useScreenSizeContext();
const room = useRoom();
const isOneOnOne = useIsOneOnOne();
const isMobile = screenSize === ScreenSize.Mobile;
// Three-gate call surface: only strictly 1:1 + still m.direct-tagged + not
// bridged. Call lifecycle hooks (`useIncomingRtcNotifications`,
// `useCallerAutoHangup`) still gate ring delivery on `m.direct`, so the
// visibility guard mirrors them. MSC2346 `m.bridge` exclusion keeps the
// button hidden for mautrix-telegram puppet rooms even if the bridge config
// ever writes `m.direct`. See dm_1x1_redesign.md §6.8b.
//
// `useIsBridgedRoom` is reactive — a late-arriving `m.bridge` state event
// flips the gate without waiting for an unrelated rerender, which a static
// `isBridgedRoom(room)` call would miss.
const mDirects = useAtomValue(mDirectAtom);
const isBridged = useIsBridgedRoom(room);
const callButtonVisible = !callView && isOneOnOne && mDirects.has(room.roomId) && !isBridged;
const encryptionEvent = useStateEvent(room, StateEvent.RoomEncryption);
const encryptedRoom = !!encryptionEvent;
const avatarMxc = useRoomAvatar(room, isOneOnOne);
const name = useRoomName(room);
const memberCount = useRoomMemberCount(room);
const avatarUrl = avatarMxc
? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined
: undefined;
// Peer details for 1:1 chrome — handle (`local:server`, no `@` prefix —
// we drop the sigil to keep the line lighter) and tap-to-open user-room-
// profile sheet. `guessDmRoomUserId` returns the oldest-joined non-self
// member, but its last fallback is `myUserId` itself — drop that case so
// we never render the room as if I were the peer (avatar trigger would
// open my own profile, handle would show my mxid).
//
// The server segment is kept so cross-server users with identical
// local-parts (e.g. `alex:vojo.chat` vs `alex:other.org`) stay visually
// distinct.
const myUserId = mx.getSafeUserId();
const peerCandidate = isOneOnOne ? guessDmRoomUserId(room, myUserId) : undefined;
const peerUserId = peerCandidate && peerCandidate !== myUserId ? peerCandidate : undefined;
const peerLocal = peerUserId ? getMxIdLocalPart(peerUserId) : undefined;
const peerServer = peerUserId ? getMxIdServer(peerUserId) : undefined;
const handle =
peerLocal && peerServer ? `${peerLocal}:${peerServer}` : peerUserId?.replace(/^@/, '');
// `useUserPresence` is a no-op when `peerUserId` is undefined (group rooms,
// unresolved peer) — it just returns undefined. Real presence comes from
// matrix-js-sdk `User` events; the hook subscribes to Presence /
// CurrentlyActive / LastPresenceTs and re-renders. We only show «в сети»
// when the peer is actively online; offline / unavailable hides the line.
const peerPresence = useUserPresence(peerUserId ?? '');
const peerOnline = !!peerUserId && peerPresence?.presence === Presence.Online;
const [peopleDrawer, setPeopleDrawer] = useSetting(settingsAtom, 'isPeopleDrawer');
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
const [pinMenuAnchor, setPinMenuAnchor] = useState<RectCords>();
const setSearchOpen = useSetAtom(searchModalAtom);
const openUserRoomProfile = useOpenUserRoomProfile();
const parentSpace = useSpaceOptionally();
const openSettings = useOpenRoomSettings();
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
setMenuAnchor(evt.currentTarget.getBoundingClientRect());
};
const handleSearch = () => {
// P4 routes in-room search through the global cmd+K modal — there is no
// /direct/{roomId}/search/ page yet (see desired_features.md §26). The
// modal lets the user pick the target room manually.
setSearchOpen(true);
};
const handlePeerProfile: MouseEventHandler<HTMLButtonElement> = (evt) => {
if (!peerUserId) return;
openUserRoomProfile(
room.roomId,
parentSpace?.roomId,
peerUserId,
evt.currentTarget.getBoundingClientRect(),
'Bottom'
);
};
const handleMemberToggle = () => {
if (callView) {
openSettings(room.roomId, parentSpace?.roomId, RoomSettingsPage.MembersPage);
return;
}
setPeopleDrawer(!peopleDrawer);
};
const avatarNode = (
<Avatar size="300">
{isOneOnOne && peerUserId ? (
<UserAvatar
userId={peerUserId}
src={avatarUrl}
alt={name}
renderFallback={() => <Icon size="200" src={Icons.User} filled />}
/>
) : (
<RoomAvatar
roomId={room.roomId}
src={avatarUrl}
alt={name}
renderFallback={() => (
<RoomIcon size="200" joinRule={room.getJoinRule()} roomType={room.getType()} />
)}
/>
)}
</Avatar>
);
const e2eeChip = encryptedRoom && (
<Text as="span" size="T200" className={css.E2eeChip}>
<Icon src={Icons.Lock} filled className={css.E2eeIcon} />
{t('Room.encrypted_short')}
</Text>
);
// Single subline format, identical on mobile + desktop:
// 1:1 → «@local:server» (mono) + (peer online ? « · в сети» : nothing)
// group → «N members»
// Title row carries the room name and the «онлайн» presence tag (1:1
// peer only, when actually Online via `useUserPresence`). Subline carries
// the muted identifier (handle for 1:1, member-count for groups) and,
// for 1:1 rooms, the e2ee chip — moved out of the title row so the chip
// sits near the technical identifier rather than the human name.
const showOnlineTag = isOneOnOne && peerOnline;
const showHandlePart = isOneOnOne && !!handle;
const showGroupSubline = !isOneOnOne;
return (
<PageHeader
className={`${ContainerColor({ variant: 'Surface' })} ${css.HeaderShell}`}
balance={isMobile}
>
{/* `gap="200"` (= S200 = 8px) keeps the avatar's right side flush with
the same `S200` left/top/bottom clearance the header gives using
the default `gap="300"` (12px) made the avatartitle spacing
visibly looser than the rest of its surround. */}
<Box grow="Yes" alignItems="Center" gap="200">
{isMobile && (
<BackRouteHandler>
{(onBack) => (
<Box shrink="No" alignItems="Center">
<IconButton fill="None" onClick={onBack} aria-label={t('Room.close')}>
<Icon src={Icons.ChevronLeft} />
</IconButton>
</Box>
)}
</BackRouteHandler>
)}
{isOneOnOne && peerUserId ? (
<button
type="button"
className={css.PeerAvatarTrigger}
onClick={handlePeerProfile}
aria-label={t('Room.open_profile_of', { name })}
>
{avatarNode}
</button>
) : (
<span className={css.PeerAvatarStatic}>{avatarNode}</span>
)}
<Box grow="Yes" direction="Column" style={{ minWidth: 0 }}>
<div className={css.TitleRow}>
<Text size={isMobile ? 'H5' : 'H4'} truncate className={css.RoomName}>
{name}
</Text>
{showOnlineTag && (
<Text as="span" size="T200" className={css.OnlineTag}>
{t('Room.status_online')}
</Text>
)}
</div>
{showHandlePart && (
<Text as="span" size="T200" className={css.Subline}>
<span className={css.SublineMuted}>{handle}</span>
{e2eeChip}
</Text>
)}
{showGroupSubline && (
<Text as="span" size="T200" className={css.Subline}>
<span className={css.SublineMuted}>
{t('Room.members_count', {
count: memberCount,
formattedCount: memberCount,
})}
</span>
{e2eeChip}
</Text>
)}
</Box>
<Box shrink="No" alignItems="Center">
{callButtonVisible && <DmCallButton room={room} />}
{/* Member toggle desktop-only. Visible in two cases:
- group rooms (>2 members): toggles the members drawer.
- call rooms (`callView=true`), regardless of size: routes to
Room Settings Members so 1:1 call rooms can still reach
the member list (drawer doesn't render in callView).
For non-call 1:1 rooms it stays hidden because the avatar tap
already opens the peer profile sheet. */}
{(callView || !isOneOnOne) && screenSize === ScreenSize.Desktop && (
<TooltipProvider
position="Bottom"
offset={4}
tooltip={
<Tooltip>
{callView ? (
<Text>{t('Room.members')}</Text>
) : (
<Text>{peopleDrawer ? t('Room.hide_members') : t('Room.show_members')}</Text>
)}
</Tooltip>
}
>
{(triggerRef) => (
<IconButton fill="None" ref={triggerRef} onClick={handleMemberToggle}>
<Icon size="400" src={Icons.User} />
</IconButton>
)}
</TooltipProvider>
)}
<TooltipProvider
position="Bottom"
align="End"
offset={4}
tooltip={
<Tooltip>
<Text>{t('Room.more_options')}</Text>
</Tooltip>
}
>
{(triggerRef) => (
<IconButton
fill="None"
onClick={handleOpenMenu}
ref={triggerRef}
aria-pressed={!!menuAnchor}
>
<Icon size="400" src={Icons.VerticalDots} filled={!!menuAnchor} />
</IconButton>
)}
</TooltipProvider>
<PopOut
anchor={menuAnchor}
position="Bottom"
align="End"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
returnFocusOnDeactivate: false,
onDeactivate: () => setMenuAnchor(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
escapeDeactivates: stopPropagation,
}}
>
<RoomMenu
room={room}
callView={callView}
onSearch={handleSearch}
onPin={(cords) => setPinMenuAnchor(cords)}
requestClose={() => setMenuAnchor(undefined)}
/>
</FocusTrap>
}
/>
<PopOut
anchor={pinMenuAnchor}
position="Bottom"
align="End"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
returnFocusOnDeactivate: false,
onDeactivate: () => setPinMenuAnchor(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
escapeDeactivates: stopPropagation,
}}
>
<RoomPinMenu room={room} requestClose={() => setPinMenuAnchor(undefined)} />
</FocusTrap>
}
/>
</Box>
</Box>
</PageHeader>
);
}

View file

@ -0,0 +1,44 @@
import { useEffect, useState } from 'react';
import { EventTimeline, MatrixEvent, Room, RoomStateEvent } from 'matrix-js-sdk';
import { isBridgedRoom } from '../utils/room';
import { StateEvent } from '../../types/matrix/room';
// Reactive «is this a bridged room» — re-runs when an `m.bridge` /
// `uk.half-shot.bridge` state event lands so the call-button gate flips
// immediately when a bridge bot finishes provisioning. Without the
// subscription the gate would freeze at first render and a late bridge
// event would let the phone button stay visible on a Telegram puppet
// room until something else triggered a rerender — see
// dm_1x1_redesign.md §6.8b for why bridged 1:1 rooms must never expose
// the call surface.
//
// `useStateEvent(room, StateEvent.RoomBridge)` is not enough here —
// `m.bridge` is keyed by the bridge bot mxid (`state_key` ≠ ''), so the
// «empty key» helper would never see the event. We subscribe to the
// generic `RoomStateEvent.Events` stream and re-check on any matching
// type.
//
// The captured `state` keeps cleanup leak-safe; we don't try to follow a
// rare live-timeline state-container swap on the same `room` identity —
// in that corner case the gate would go stale until the next mount.
export const useIsBridgedRoom = (room: Room): boolean => {
const [value, setValue] = useState<boolean>(() => isBridgedRoom(room));
useEffect(() => {
const state = room.getLiveTimeline().getState(EventTimeline.FORWARDS);
setValue(isBridgedRoom(room));
if (!state) return undefined;
const handler = (event: MatrixEvent) => {
const type = event.getType();
if (type === StateEvent.RoomBridge || type === StateEvent.RoomBridgeUnstable) {
setValue(isBridgedRoom(room));
}
};
state.on(RoomStateEvent.Events, handler);
return () => {
state.removeListener(RoomStateEvent.Events, handler);
};
}, [room]);
return value;
};

View file

@ -0,0 +1,29 @@
import { useEffect, useState } from 'react';
import { EventTimeline, Room, RoomStateEvent } from 'matrix-js-sdk';
// Reactive «invited + joined member count» — stays current when members
// join, leave, or are invited so the room header «N participants» line and
// any member-count-driven chrome flips immediately. Without the subscription
// the number freezes at mount-time and only refreshes on navigation.
//
// The captured `state` makes the cleanup detach from the same emitter we
// attached to (no orphan listeners). We don't try to follow re-subscription
// if matrix-js-sdk swaps the live-timeline state container while `room`
// identity is unchanged — that's a rare /sync corner case (room upgrade
// catch-up) and the count would simply go stale until the next mount.
export const useRoomMemberCount = (room: Room): number => {
const [count, setCount] = useState<number>(() => room.getInvitedAndJoinedMemberCount());
useEffect(() => {
const state = room.getLiveTimeline().getState(EventTimeline.FORWARDS);
setCount(room.getInvitedAndJoinedMemberCount());
if (!state) return undefined;
const handler = () => setCount(room.getInvitedAndJoinedMemberCount());
state.on(RoomStateEvent.Members, handler);
return () => {
state.removeListener(RoomStateEvent.Members, handler);
};
}, [room]);
return count;
};

View file

@ -29,6 +29,11 @@ export const useUserPresence = (userId: string): UserPresence | undefined => {
const [presence, setPresence] = useState(() => (user ? getUserPresence(user) : undefined));
useEffect(() => {
// Re-snapshot when `user` swaps so a stale value from the previous peer
// doesn't leak across the change. Without this the hook would keep the
// old presence until the new user's first Presence/CurrentlyActive/
// LastPresenceTs event arrives.
setPresence(user ? getUserPresence(user) : undefined);
const updatePresence: UserEventHandlerMap[UserEvent.Presence] = (event, u) => {
if (u.userId === user?.userId) {
setPresence(getUserPresence(user));