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
103d6ad8a1
commit
d2c77496a7
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 |
|
| 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 |
|
||||||
|
|
|
||||||
|
|
@ -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'.
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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": "Настройки комнаты",
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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} />
|
||||||
|
|
|
||||||
|
|
@ -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 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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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));
|
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));
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue