redesign(p4): land Dawn RoomViewHeader for all rooms with peer chrome, presence, member-count subline, and reactive bridge gate.
This commit is contained in:
parent
7ff25c7a95
commit
7cafd8e8aa
13 changed files with 914 additions and 639 deletions
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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'.
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "Настройки комнаты",
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
});
|
||||
|
|
@ -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} />;
|
||||
}
|
||||
|
|
|
|||
119
src/app/features/room/RoomViewHeaderDm.css.ts
Normal file
119
src/app/features/room/RoomViewHeaderDm.css.ts
Normal 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',
|
||||
});
|
||||
673
src/app/features/room/RoomViewHeaderDm.tsx
Normal file
673
src/app/features/room/RoomViewHeaderDm.tsx
Normal 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 avatar→title 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>
|
||||
);
|
||||
}
|
||||
44
src/app/hooks/useIsBridgedRoom.ts
Normal file
44
src/app/hooks/useIsBridgedRoom.ts
Normal 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;
|
||||
};
|
||||
29
src/app/hooks/useRoomMemberCount.ts
Normal file
29
src/app/hooks/useRoomMemberCount.ts
Normal 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;
|
||||
};
|
||||
|
|
@ -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));
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue