456 lines
17 KiB
TypeScript
456 lines
17 KiB
TypeScript
/* 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 (
|
|
<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 [menuAnchor, setMenuAnchor] = useState<RectCords>();
|
|
const [pinMenuAnchor, setPinMenuAnchor] = useState<RectCords>();
|
|
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<HTMLButtonElement> = (evt) => {
|
|
setMenuAnchor(evt.currentTarget.getBoundingClientRect());
|
|
};
|
|
const handlePeerProfile: MouseEventHandler<HTMLButtonElement> = (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 = (
|
|
<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. 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 = (
|
|
<button
|
|
type="button"
|
|
className={css.PeerIdentityTrigger}
|
|
onClick={handlePeerProfile}
|
|
aria-label={t('Room.open_profile_of', { name })}
|
|
>
|
|
{avatarNode}
|
|
{titleBlock}
|
|
</button>
|
|
);
|
|
} else if (!isOneOnOne && !callView) {
|
|
identityArea = (
|
|
<button
|
|
type="button"
|
|
className={css.PeerIdentityTrigger}
|
|
onClick={handleGroupIdentityClick}
|
|
aria-pressed={membersSheetOpen}
|
|
aria-label={t('Room.open_members_of', { name })}
|
|
>
|
|
{avatarNode}
|
|
{titleBlock}
|
|
</button>
|
|
);
|
|
} else {
|
|
identityArea = (
|
|
<>
|
|
<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 — 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 && (
|
|
<TooltipProvider
|
|
position="Bottom"
|
|
offset={4}
|
|
tooltip={
|
|
<Tooltip>
|
|
<Text>{t('Room.members')}</Text>
|
|
</Tooltip>
|
|
}
|
|
>
|
|
{(triggerRef) => (
|
|
<IconButton fill="None" ref={triggerRef} onClick={handleCallViewMembers}>
|
|
<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,
|
|
}}
|
|
>
|
|
<RoomActionsMenu
|
|
room={room}
|
|
callView={callView}
|
|
botControlRoom={isBotControlRoom}
|
|
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>
|
|
);
|
|
}
|