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 | | 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/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-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 |
@ -130,7 +130,7 @@ These are vojo additions on top of stock Cinny — they crossed many recent stab
| Path | Commits | Notes | | 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/*` (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 | | `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 | | `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. Types of property 'count' are incompatible.
Type 'string' is not assignable to type 'number'. 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/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(271,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(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'. 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(275,26): 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(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'. 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'. 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[]'. 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'. 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/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'. 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'. 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: ...]'. src/app/features/room/message/Message.tsx(860,31): error TS7006: Parameter 'ev' implicitly has an 'any' type.
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/MessageEditor.tsx(156,39): error TS2345: Argument of type 'IContent' is not assignable to parameter of type 'RoomMessageEventContent'. 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'. 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'. Property '"body"' is missing in type 'IContent' but required in type 'BaseTimelineEvent'.

View file

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

View file

@ -451,12 +451,18 @@
"drop_typing": "Скрыть индикатор набора", "drop_typing": "Скрыть индикатор набора",
"members": "Участники", "members": "Участники",
"members_count": "{{count}} участников", "members_count_one": "{{formattedCount}} участник",
"members_count_few": "{{formattedCount}} участника",
"members_count_many": "{{formattedCount}} участников",
"members_count_other": "{{formattedCount}} участника",
"hide_members": "Скрыть участников", "hide_members": "Скрыть участников",
"show_members": "Показать участников", "show_members": "Показать участников",
"more_options": "Ещё", "more_options": "Ещё",
"close": "Закрыть", "close": "Закрыть",
"search": "Поиск", "search": "Поиск",
"encrypted_short": "e2ee",
"status_online": "онлайн",
"open_profile_of": "Открыть профиль {{name}}",
"notifications": "Уведомления", "notifications": "Уведомления",
"invite": "Пригласить", "invite": "Пригласить",
"room_settings": "Настройки комнаты", "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">
<Box grow="Yes" alignItems="Center" gap="200"> <Box grow="Yes" alignItems="Center" gap="200">
<Text <Text
title={t('Room.members_count', { count: room.getJoinedMemberCount() })} title={t('Room.members_count', {
count: room.getJoinedMemberCount(),
formattedCount: room.getJoinedMemberCount(),
})}
size="H5" size="H5"
truncate truncate
> >
{t('Room.members_count', { count: millify(room.getJoinedMemberCount()) })} {t('Room.members_count', {
count: room.getJoinedMemberCount(),
formattedCount: millify(room.getJoinedMemberCount()),
})}
</Text> </Text>
</Box> </Box>
<Box shrink="No" alignItems="Center"> <Box shrink="No" alignItems="Center">

View file

@ -9,7 +9,7 @@ import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
import { useSetting } from '../../state/hooks/settings'; import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings'; import { settingsAtom } from '../../state/settings';
import { PowerLevelsContextProvider, usePowerLevels } from '../../hooks/usePowerLevels'; import { PowerLevelsContextProvider, usePowerLevels } from '../../hooks/usePowerLevels';
import { useRoom } from '../../hooks/useRoom'; import { useIsOneOnOne, useRoom } from '../../hooks/useRoom';
import { useKeyDown } from '../../hooks/useKeyDown'; import { useKeyDown } from '../../hooks/useKeyDown';
import { markAsRead } from '../../utils/notifications'; import { markAsRead } from '../../utils/notifications';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
@ -30,6 +30,12 @@ export function Room() {
const powerLevels = usePowerLevels(room); const powerLevels = usePowerLevels(room);
const members = useRoomMembers(mx, room.roomId); const members = useRoomMembers(mx, room.roomId);
const chat = useAtomValue(callChatAtom); 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( useKeyDown(
window, window,
@ -73,7 +79,7 @@ export function Room() {
<CallChatView /> <CallChatView />
</> </>
)} )}
{!callView && screenSize === ScreenSize.Desktop && isDrawer && ( {!callView && !isOneOnOne && screenSize === ScreenSize.Desktop && isDrawer && (
<> <>
<Line variant="Background" direction="Vertical" size="300" /> <Line variant="Background" direction="Vertical" size="300" />
<MembersDrawer key={room.roomId} room={room} members={members} /> <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 React from 'react';
import { useAtomValue } from 'jotai'; import { RoomViewHeaderDm } from './RoomViewHeaderDm';
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>
);
}
// 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 }) { export function RoomViewHeader({ callView }: { callView?: boolean }) {
const { t } = useTranslation(); return <RoomViewHeaderDm callView={callView} />;
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>
);
} }

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)); const [presence, setPresence] = useState(() => (user ? getUserPresence(user) : undefined));
useEffect(() => { 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) => { const updatePresence: UserEventHandlerMap[UserEvent.Presence] = (event, u) => {
if (u.userId === user?.userId) { if (u.userId === user?.userId) {
setPresence(getUserPresence(user)); setPresence(getUserPresence(user));