From 7cafd8e8aa77e3b71dc5a72ecbfabe7594c92125 Mon Sep 17 00:00:00 2001 From: "v.lagerev" Date: Wed, 29 Apr 2026 01:03:12 +0300 Subject: [PATCH] redesign(p4): land Dawn RoomViewHeader for all rooms with peer chrome, presence, member-count subline, and reactive bridge gate. --- docs/ai/architecture.md | 4 +- .../typecheck.snapshot.txt | 19 +- public/locales/en.json | 6 +- public/locales/ru.json | 8 +- src/app/features/room/MembersDrawer.tsx | 10 +- src/app/features/room/Room.tsx | 10 +- src/app/features/room/RoomViewHeader.css.ts | 10 - src/app/features/room/RoomViewHeader.tsx | 616 +--------------- src/app/features/room/RoomViewHeaderDm.css.ts | 119 ++++ src/app/features/room/RoomViewHeaderDm.tsx | 673 ++++++++++++++++++ src/app/hooks/useIsBridgedRoom.ts | 44 ++ src/app/hooks/useRoomMemberCount.ts | 29 + src/app/hooks/useUserPresence.ts | 5 + 13 files changed, 914 insertions(+), 639 deletions(-) delete mode 100644 src/app/features/room/RoomViewHeader.css.ts create mode 100644 src/app/features/room/RoomViewHeaderDm.css.ts create mode 100644 src/app/features/room/RoomViewHeaderDm.tsx create mode 100644 src/app/hooks/useIsBridgedRoom.ts create mode 100644 src/app/hooks/useRoomMemberCount.ts diff --git a/docs/ai/architecture.md b/docs/ai/architecture.md index 506ba75b..2b75ea8c 100644 --- a/docs/ai/architecture.md +++ b/docs/ai/architecture.md @@ -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 | diff --git a/docs/known-tech-debt-lint/typecheck.snapshot.txt b/docs/known-tech-debt-lint/typecheck.snapshot.txt index 437fdd27..2b2a66ea 100644 --- a/docs/known-tech-debt-lint/typecheck.snapshot.txt +++ b/docs/known-tech-debt-lint/typecheck.snapshot.txt @@ -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 & NoRelationEvent) | (Without & ReplyEvent), (Without<...> & RelationEvent) | (Without<...> & ReplacementEvent<...>)> & Without<...> & ReplacementEvent<...> & FileContent'. Property '"body"' is missing in type 'IContent' but required in type 'BaseTimelineEvent'. diff --git a/public/locales/en.json b/public/locales/en.json index 95c1edaa..e7ee6525 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -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", diff --git a/public/locales/ru.json b/public/locales/ru.json index 8fd83a5d..1aaff847 100644 --- a/public/locales/ru.json +++ b/public/locales/ru.json @@ -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": "Настройки комнаты", diff --git a/src/app/features/room/MembersDrawer.tsx b/src/app/features/room/MembersDrawer.tsx index 13020063..a8e49d7b 100644 --- a/src/app/features/room/MembersDrawer.tsx +++ b/src/app/features/room/MembersDrawer.tsx @@ -73,11 +73,17 @@ function MemberDrawerHeader({ room }: MemberDrawerHeaderProps) { - {t('Room.members_count', { count: millify(room.getJoinedMemberCount()) })} + {t('Room.members_count', { + count: room.getJoinedMemberCount(), + formattedCount: millify(room.getJoinedMemberCount()), + })} diff --git a/src/app/features/room/Room.tsx b/src/app/features/room/Room.tsx index 5e5d7d78..6455ac0c 100644 --- a/src/app/features/room/Room.tsx +++ b/src/app/features/room/Room.tsx @@ -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() { )} - {!callView && screenSize === ScreenSize.Desktop && isDrawer && ( + {!callView && !isOneOnOne && screenSize === ScreenSize.Desktop && isDrawer && ( <> diff --git a/src/app/features/room/RoomViewHeader.css.ts b/src/app/features/room/RoomViewHeader.css.ts deleted file mode 100644 index 19e1afea..00000000 --- a/src/app/features/room/RoomViewHeader.css.ts +++ /dev/null @@ -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', - }, -}); diff --git a/src/app/features/room/RoomViewHeader.tsx b/src/app/features/room/RoomViewHeader.tsx index 39626aed..21164cb7 100644 --- a/src/app/features/room/RoomViewHeader.tsx +++ b/src/app/features/room/RoomViewHeader.tsx @@ -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(({ 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 ( - - {invitePrompt && ( - { - setInvitePrompt(false); - requestClose(); - }} - /> - )} - - } - radii="300" - disabled={!unread} - > - - {t('Room.mark_as_read')} - - - - {(handleOpen, opened, changing) => ( - - ) : ( - - ) - } - radii="300" - aria-pressed={opened} - onClick={handleOpen} - > - - {t('Room.notifications')} - - - )} - - - - - } - radii="300" - aria-pressed={invitePrompt} - disabled={!canInvite} - > - - {t('Room.invite')} - - - } - radii="300" - > - - {t('Room.copy_link')} - - - } - radii="300" - > - - {t('Room.room_settings')} - - - - {(promptJump, setPromptJump) => ( - <> - setPromptJump(true)} - size="300" - after={} - radii="300" - aria-pressed={promptJump} - > - - {t('Room.jump_to_time')} - - - {promptJump && ( - { - setPromptJump(false); - navigateRoom(room.roomId, eventId); - requestClose(); - }} - onCancel={() => setPromptJump(false)} - /> - )} - - )} - - - - - - {(promptLeave, setPromptLeave) => ( - <> - setPromptLeave(true)} - variant="Critical" - fill="None" - size="300" - after={} - radii="300" - aria-pressed={promptLeave} - > - - {t('Room.leave_room')} - - - {promptLeave && ( - setPromptLeave(false)} - /> - )} - - )} - - - - ); -}); - -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 ( - - {tooltipText} - - } - > - {(triggerRef) => ( - - - - )} - - ); -} +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(); - const [pinMenuAnchor, setPinMenuAnchor] = useState(); - 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 = (evt) => { - setMenuAnchor(evt.currentTarget.getBoundingClientRect()); - }; - - const handleOpenPinMenu: MouseEventHandler = (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 ( - - - {screenSize === ScreenSize.Mobile && ( - - {(onBack) => ( - - - - - - )} - - )} - - {screenSize !== ScreenSize.Mobile && ( - - ( - - )} - /> - - )} - - - {name} - - {topic && ( - - {(viewTopic, setViewTopic) => ( - <> - }> - - setViewTopic(false), - escapeDeactivates: stopPropagation, - }} - > - setViewTopic(false)} - /> - - - - setViewTopic(true)} - className={css.HeaderTopic} - size="T200" - priority="300" - truncate - > - {topic} - - - )} - - )} - - - - - {callButtonVisible && } - {/* 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 && ( - - {t('Room.search')} - - } - > - {(triggerRef) => ( - - - - )} - - )} - - {t('Room.pinned_messages')} - - } - > - {(triggerRef) => ( - - {pinnedEvents.length > 0 && ( - - - {pinnedEvents.length} - - - )} - - - )} - - setPinMenuAnchor(undefined), - clickOutsideDeactivates: true, - isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', - isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', - escapeDeactivates: stopPropagation, - }} - > - setPinMenuAnchor(undefined)} /> - - } - /> - - {screenSize === ScreenSize.Desktop && ( - - {callView ? ( - {t('Room.members')} - ) : ( - {peopleDrawer ? t('Room.hide_members') : t('Room.show_members')} - )} - - } - > - {(triggerRef) => ( - - - - )} - - )} - - - {t('Room.more_options')} - - } - > - {(triggerRef) => ( - - - - )} - - setMenuAnchor(undefined), - clickOutsideDeactivates: true, - isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', - isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', - escapeDeactivates: stopPropagation, - }} - > - setMenuAnchor(undefined)} /> - - } - /> - - - - ); + return ; } diff --git a/src/app/features/room/RoomViewHeaderDm.css.ts b/src/app/features/room/RoomViewHeaderDm.css.ts new file mode 100644 index 00000000..660324cf --- /dev/null +++ b/src/app/features/room/RoomViewHeaderDm.css.ts @@ -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', +}); diff --git a/src/app/features/room/RoomViewHeaderDm.tsx b/src/app/features/room/RoomViewHeaderDm.tsx new file mode 100644 index 00000000..54dbaa59 --- /dev/null +++ b/src/app/features/room/RoomViewHeaderDm.tsx @@ -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( + ({ 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 = (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 ( + + {invitePrompt && ( + { + setInvitePrompt(false); + requestClose(); + }} + /> + )} + + } + radii="300" + disabled={!unread} + > + + {t('Room.mark_as_read')} + + + + {(handleOpen, opened, changing) => ( + + ) : ( + + ) + } + radii="300" + aria-pressed={opened} + onClick={handleOpen} + > + + {t('Room.notifications')} + + + )} + + + + + } + radii="300" + > + + {t('Room.search')} + + + + {pinnedEvents.length > 0 && ( + + + {pinnedEvents.length} + + + )} + + + } + radii="300" + > + + {t('Room.pinned_messages')} + + + {canInvite && ( + } + radii="300" + aria-pressed={invitePrompt} + > + + {t('Room.invite')} + + + )} + } + radii="300" + > + + {t('Room.copy_link')} + + + } + radii="300" + > + + {t('Room.room_settings')} + + + + {(promptJump, setPromptJump) => ( + <> + setPromptJump(true)} + size="300" + after={} + radii="300" + aria-pressed={promptJump} + > + + {t('Room.jump_to_time')} + + + {promptJump && ( + { + setPromptJump(false); + navigateRoom(room.roomId, eventId); + requestClose(); + }} + onCancel={() => setPromptJump(false)} + /> + )} + + )} + + + + + + {(promptLeave, setPromptLeave) => ( + <> + setPromptLeave(true)} + variant="Critical" + fill="None" + size="300" + after={} + radii="300" + aria-pressed={promptLeave} + disabled={callView} + > + + {t('Room.leave_room')} + + + {promptLeave && ( + setPromptLeave(false)} + /> + )} + + )} + + + + ); + } +); + +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 ( + + {tooltipText} + + } + > + {(triggerRef) => ( + + + + )} + + ); +} + +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(); + const [pinMenuAnchor, setPinMenuAnchor] = useState(); + const setSearchOpen = useSetAtom(searchModalAtom); + const openUserRoomProfile = useOpenUserRoomProfile(); + const parentSpace = useSpaceOptionally(); + const openSettings = useOpenRoomSettings(); + + const handleOpenMenu: MouseEventHandler = (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 = (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 = ( + + {isOneOnOne && peerUserId ? ( + } + /> + ) : ( + ( + + )} + /> + )} + + ); + + const e2eeChip = encryptedRoom && ( + + + {t('Room.encrypted_short')} + + ); + + // 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 ( + + {/* `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. */} + + {isMobile && ( + + {(onBack) => ( + + + + + + )} + + )} + + {isOneOnOne && peerUserId ? ( + + ) : ( + {avatarNode} + )} + + +
+ + {name} + + {showOnlineTag && ( + + {t('Room.status_online')} + + )} +
+ {showHandlePart && ( + + {handle} + {e2eeChip} + + )} + {showGroupSubline && ( + + + {t('Room.members_count', { + count: memberCount, + formattedCount: memberCount, + })} + + {e2eeChip} + + )} +
+ + + {callButtonVisible && } + + {/* 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 && ( + + {callView ? ( + {t('Room.members')} + ) : ( + {peopleDrawer ? t('Room.hide_members') : t('Room.show_members')} + )} + + } + > + {(triggerRef) => ( + + + + )} + + )} + + + {t('Room.more_options')} + + } + > + {(triggerRef) => ( + + + + )} + + + setMenuAnchor(undefined), + clickOutsideDeactivates: true, + isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', + isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', + escapeDeactivates: stopPropagation, + }} + > + setPinMenuAnchor(cords)} + requestClose={() => setMenuAnchor(undefined)} + /> + + } + /> + + setPinMenuAnchor(undefined), + clickOutsideDeactivates: true, + isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', + isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', + escapeDeactivates: stopPropagation, + }} + > + setPinMenuAnchor(undefined)} /> + + } + /> + +
+
+ ); +} diff --git a/src/app/hooks/useIsBridgedRoom.ts b/src/app/hooks/useIsBridgedRoom.ts new file mode 100644 index 00000000..cd4a5756 --- /dev/null +++ b/src/app/hooks/useIsBridgedRoom.ts @@ -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(() => 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; +}; diff --git a/src/app/hooks/useRoomMemberCount.ts b/src/app/hooks/useRoomMemberCount.ts new file mode 100644 index 00000000..082ec464 --- /dev/null +++ b/src/app/hooks/useRoomMemberCount.ts @@ -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(() => 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; +}; diff --git a/src/app/hooks/useUserPresence.ts b/src/app/hooks/useUserPresence.ts index 5137950e..629224b6 100644 --- a/src/app/hooks/useUserPresence.ts +++ b/src/app/hooks/useUserPresence.ts @@ -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));