763 lines
28 KiB
TypeScript
763 lines
28 KiB
TypeScript
/* 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 (
|
|
<MenuItem
|
|
onClick={handleClick}
|
|
size="300"
|
|
after={<Icon size="100" src={Icons.Terminal} />}
|
|
radii="300"
|
|
>
|
|
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
|
{t(failed ? 'Bots.retry_widget' : 'Bots.show_widget')}
|
|
</Text>
|
|
</MenuItem>
|
|
);
|
|
}
|
|
|
|
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<HTMLDivElement, RoomMenuProps>(
|
|
({ 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<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();
|
|
}}
|
|
/>
|
|
)}
|
|
{botControlRoom && botPreset && (
|
|
<>
|
|
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
|
<BotShowWidgetMenuItem
|
|
roomId={room.roomId}
|
|
botId={botPreset.id}
|
|
requestClose={requestClose}
|
|
/>
|
|
</Box>
|
|
<Line variant="Surface" size="300" />
|
|
</>
|
|
)}
|
|
<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.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 (
|
|
<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;
|
|
|
|
// 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<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;
|
|
|
|
// 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 = (
|
|
<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>
|
|
);
|
|
|
|
// 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 ? (
|
|
<button
|
|
type="button"
|
|
className={css.PeerIdentityTrigger}
|
|
onClick={handlePeerProfile}
|
|
aria-label={t('Room.open_profile_of', { name })}
|
|
>
|
|
{avatarNode}
|
|
{titleBlock}
|
|
</button>
|
|
) : (
|
|
<>
|
|
<span className={css.PeerAvatarStatic}>{avatarNode}</span>
|
|
{titleBlock}
|
|
</>
|
|
);
|
|
|
|
return (
|
|
<PageHeader
|
|
className={`${ContainerColor({ variant: 'SurfaceVariant' })} ${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>
|
|
)}
|
|
|
|
{identityArea}
|
|
|
|
<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}
|
|
botControlRoom={isBotControlRoom}
|
|
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>
|
|
);
|
|
}
|