/* 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 { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { useAtom, 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 { useDmCallVisible } from '../../hooks/useDmCallVisible'; 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 { 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 { useBotPresets } from '../bots/catalog'; import { findBotPresetForRoom, isCatalogBotControlRoom } from '../bots/room'; import { botFailedAtomFamily, botShowChatAtomFamily } from '../bots/botExperienceState'; import { getBotPath } from '../../pages/pathUtils'; import { JumpToTime } from './jump-to-time'; import { RoomPinMenu } from './room-pin-menu'; import * as css from './RoomViewHeaderDm.css'; // Single bot-aware menu item rendered at the top of RoomMenu when the // current room is a Vojo bot control DM. Reads `botFailedAtomFamily` to // label correctly («Retry widget» when a prior load failed, «Show widget» // otherwise) and clears both atoms on click. // // IMPORTANT: this menu surfaces in BOTH `/bots/:botId` (chat-fallback) and // `/direct/:roomId` (regular DM that happens to be a bot's control room). // In the second case clearing the atoms alone is invisible — the user is // not on the route that observes them. We navigate to `/bots/:botId` so // the BotShell actually mounts. function BotShowWidgetMenuItem({ roomId, botId, requestClose, }: { roomId: string; botId: string; requestClose: () => void; }) { const { t } = useTranslation(); const navigate = useNavigate(); const setShowChat = useSetAtom(botShowChatAtomFamily(roomId)); const [failed, setFailed] = useAtom(botFailedAtomFamily(roomId)); const handleClick = () => { setFailed(false); setShowChat(false); navigate(getBotPath(botId)); requestClose(); }; return ( } radii="300" > {t(failed ? 'Bots.retry_widget' : 'Bots.show_widget')} ); } type RoomMenuProps = { room: Room; callView?: boolean; // When true the room is a Vojo bot control DM rendered in chat-fallback // mode. The menu prepends a «Show widget / Retry widget» item so the // user can return to BotShell without hunting for an overlay button. // Other items stay standard — bots in chat-fallback should look like // normal rooms beyond that one affordance. botControlRoom?: boolean; onSearch: () => void; onPin: (cords: RectCords) => void; requestClose: () => void; }; const RoomMenu = forwardRef( ({ room, callView, botControlRoom, 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(); // Look up the matching bot preset only when this menu IS the bot // variant. The lookup walks the preset list once; cheap. Returns // undefined for non-bot rooms — we won't render the menu item. const bots = useBotPresets(); const botPreset = botControlRoom ? findBotPresetForRoom(mx, room, bots) : undefined; 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(); }} /> )} {botControlRoom && botPreset && ( <> )} } 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.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 [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; // 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. Groups keep the avatar non-clickable // and the title as a plain row. const identityArea = isOneOnOne && peerUserId ? ( ) : ( <> {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 — 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)} /> } /> ); }