/* eslint-disable react/destructuring-assignment */ import React, { MouseEventHandler, useState } from 'react'; import { Avatar, Box, Icon, IconButton, Icons, PopOut, RectCords, Text, Tooltip, TooltipProvider, } from 'folds'; import FocusTrap from 'focus-trap-react'; import { useTranslation } from 'react-i18next'; import { useAtomValue } 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 { BackRouteHandler } from '../../components/BackRouteHandler'; 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 { useDmCallVisible } from '../../hooks/useDmCallVisible'; import { useRoomMemberCount } from '../../hooks/useRoomMemberCount'; import { Presence, useUserPresence } from '../../hooks/useUserPresence'; import { useRoomAvatar, useRoomName } from '../../hooks/useRoomMeta'; import { useSpaceOptionally } from '../../hooks/useSpace'; import { useOpenRoomSettings } from '../../state/hooks/roomSettings'; import { useLivekitSupport } from '../../hooks/useLivekitSupport'; import { useCallMembers, useCallSession } from '../../hooks/useCall'; import { useSwitchOrStartDmCall } from '../../hooks/useSwitchOrStartDmCall'; import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile'; import { useCloseRoomMembersSheet, useOpenRoomMembersSheet, useRoomMembersSheetState, } from '../../state/hooks/roomMembersSheet'; import { callEmbedAtom } from '../../state/callEmbed'; import { RoomSettingsPage } from '../../state/roomSettings'; import { StateEvent } from '../../../types/matrix/room'; import { getMxIdLocalPart, getMxIdServer, guessDmRoomUserId, mxcUrlToHttp, } from '../../utils/matrix'; import { stopPropagation } from '../../utils/keyboard'; import { useBotPresets } from '../bots/catalog'; import { isCatalogBotControlRoom } from '../bots/room'; import { RoomPinMenu } from './room-pin-menu'; import { RoomActionsMenu } from './room-actions'; import * as css from './RoomViewHeaderDm.css'; 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.userId === 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; // Call surface visibility is shared with the `Call` button inside // `UserRoomProfile` via `useDmCallVisible` — the hook captures all // four gates (1:1 + m.direct + !bridged + !bot-control) so the two // call-entry surfaces can't drift apart. Header-specific extras // here: hide in callView (we're already showing CallView). The // hook itself documents the call-lifecycle reasoning. const dmCallVisible = useDmCallVisible(room); const callButtonVisible = !callView && dmCallVisible; // `isBotControlRoom` is also passed down to `RoomMenu` to gate the // bot-config menu item — kept here as its own derivation because // the hook above is purpose-built for the call gate, not for // generic «is this a bot DM?» queries. const bots = useBotPresets(); const isBotControlRoom = isCatalogBotControlRoom(mx, room, bots); 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 [menuAnchor, setMenuAnchor] = useState(); const [pinMenuAnchor, setPinMenuAnchor] = useState(); const openUserRoomProfile = useOpenUserRoomProfile(); const openRoomMembersSheet = useOpenRoomMembersSheet(); const closeRoomMembersSheet = useCloseRoomMembersSheet(); const membersSheetState = useRoomMembersSheetState(); const membersSheetOpen = membersSheetState?.roomId === room.roomId; const parentSpace = useSpaceOptionally(); const openSettings = useOpenRoomSettings(); // The ⋮ overflow opens the RoomActionsMenu in an anchored PopOut — same // chrome on desktop and mobile. const handleOpenMenu: MouseEventHandler = (evt) => { setMenuAnchor(evt.currentTarget.getBoundingClientRect()); }; const handlePeerProfile: MouseEventHandler = (evt) => { if (!peerUserId) return; openUserRoomProfile( room.roomId, parentSpace?.roomId, peerUserId, evt.currentTarget.getBoundingClientRect(), 'Bottom' ); }; const handleGroupIdentityClick = () => { if (membersSheetOpen) { closeRoomMembersSheet(); } else { openRoomMembersSheet(room.roomId); } }; // `callView` keeps the legacy «open Members in Settings» fallback — // the members side-pane isn't mounted while we're inside the call // surface, so a separate path is still needed to reach the list. const handleCallViewMembers = () => { openSettings(room.roomId, parentSpace?.roomId, RoomSettingsPage.MembersPage); }; 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; // Title block (name row + subline) — identical for 1:1 and group; the // difference is purely in the wrapper (clickable identity button for // 1:1, plain row for groups). const titleBlock = (
{name} {showOnlineTag && ( {t('Room.status_online')} )}
{showHandlePart && ( {handle} {e2eeChip} )} {showGroupSubline && ( {t('Room.members_count', { count: memberCount, formattedCount: memberCount, })} {e2eeChip} )}
); // For 1:1 rooms wrap the avatar AND title in a single button so a tap // anywhere on the user identity (avatar, name, handle, online tag) // opens the user-room-profile sheet — one tab stop, one focus // outline, single popout anchor. For group rooms the same button // pattern opens the members sheet instead — keeps the gesture // language symmetrical across 1:1 and group. `callView` falls back // to a static row because neither sheet is reachable inside the // call surface. let identityArea: React.ReactNode; if (isOneOnOne && peerUserId) { identityArea = ( ); } else if (!isOneOnOne && !callView) { identityArea = ( ); } else { identityArea = ( <> {avatarNode} {titleBlock} ); } 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) => ( )} )} {identityArea} {callButtonVisible && } {/* Member toggle — kept only for `callView` (1:1 or group): the members side-pane / horseshoe isn't mounted under the call surface, so we still need a way to reach the member list via Room Settings → Members. Non-call group rooms now use the identity button above (tap on avatar+title) — keeps the gesture symmetrical with 1:1 peer profile. */} {callView && screenSize === ScreenSize.Desktop && ( {t('Room.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)} /> } /> ); }