feat: P1 features — quick switcher, media gallery, DM previews, knock-to-join, syntax highlighting

P1-1: Quick room switcher (Ctrl+K/Cmd+K) — QuickSwitcher.tsx + ClientNonUIFeatures hotkey
P1-2: Media gallery drawer (images/videos/files) — MediaGallery.tsx + RoomViewHeader toggle
P1-4: DM last message preview + relative timestamp in RoomNavItem when direct=true
P1-7: Code syntax highlighting — TDS tokenizer (syntaxHighlight.ts), custom CSS theme
       (.prism-tds-dark/.prism-tds-light), applied in react-custom-html-parser.tsx
P1-11: Knock-to-join — "Request to Join" in RoomIntro + Pending Requests in MembersDrawer

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-02 19:45:57 -04:00
parent afe957015b
commit d43044ccbf
11 changed files with 1468 additions and 271 deletions
+90 -21
View File
@@ -27,6 +27,9 @@ import {
import { useFocusWithin, useHover } from 'react-aria';
import FocusTrap from 'focus-trap-react';
import { useAtom, useAtomValue } from 'jotai';
import dayjs from 'dayjs';
import isToday from 'dayjs/plugin/isToday';
import isYesterday from 'dayjs/plugin/isYesterday';
import { NavItem, NavItemContent, NavItemOptions, NavLink } from '../../components/nav';
import { UnreadBadge, UnreadBadgeCenter } from '../../components/unread-badge';
import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
@@ -67,8 +70,31 @@ import { callChatAtom } from '../../state/callEmbed';
import { useCallPreferencesAtom } from '../../state/hooks/callPreferences';
import { useAutoDiscoveryInfo } from '../../hooks/useAutoDiscoveryInfo';
import { livekitSupport } from '../../hooks/useLivekitSupport';
import { StateEvent } from '../../../types/matrix/room';
import { MessageEvent, StateEvent } from '../../../types/matrix/room';
import { webRTCSupported } from '../../utils/rtc';
import { useRoomLatestRenderedEvent } from '../../hooks/useRoomLatestRenderedEvent';
dayjs.extend(isToday);
dayjs.extend(isYesterday);
const PREVIEW_MAX_CHARS = 48;
function formatDmTimestamp(ts: number): string {
const d = dayjs(ts);
const now = dayjs();
const diffMinutes = now.diff(d, 'minute');
if (diffMinutes < 60) {
return `${diffMinutes < 1 ? 0 : diffMinutes}m`;
}
const diffHours = now.diff(d, 'hour');
if (diffHours < 24) {
return `${diffHours}h`;
}
if (d.isYesterday()) {
return 'Yesterday';
}
return d.format('D MMM');
}
type RenameRoomDialogProps = {
room: Room;
@@ -419,6 +445,28 @@ function RoomNavItem_({
const roomName = useLocalRoomName(room);
const hasLocalName = useHasLocalRoomName(room.roomId);
const latestEvent = useRoomLatestRenderedEvent(room);
const dmPreview = (() => {
if (!direct || !latestEvent) return null;
const type = latestEvent.getType();
const ts = latestEvent.getTs();
if (!ts) return null;
// Skip pure membership events
if (type === StateEvent.RoomMember) return null;
let body: string;
if (latestEvent.isEncrypted()) {
body = 'Encrypted message';
} else if (type === MessageEvent.Sticker) {
body = 'Sticker';
} else {
const rawBody: unknown = latestEvent.getContent()?.body;
body = typeof rawBody === 'string' ? rawBody.replace(/\s+/g, ' ').trim() : '';
}
if (!body) return null;
const preview = body.length > PREVIEW_MAX_CHARS ? `${body.slice(0, PREVIEW_MAX_CHARS)}` : body;
return { preview, time: formatDmTimestamp(ts) };
})();
const handleContextMenu: MouseEventHandler<HTMLElement> = (evt) => {
evt.preventDefault();
setMenuAnchor({
@@ -510,26 +558,47 @@ function RoomNavItem_({
/>
)}
</Avatar>
<Box as="span" grow="Yes" alignItems="Center" gap="100">
<Text priority={unread ? '500' : '300'} as="span" size="Inherit" truncate>
{roomName}
</Text>
{hasLocalName && (
<Icon
size="50"
src={Icons.Pencil}
aria-label="Custom local name"
style={{ opacity: config.opacity.P300, flexShrink: 0 }}
/>
)}
{isFavorite && (
<Icon
size="50"
src={Icons.Star}
filled
aria-label="Favorited"
style={{ opacity: config.opacity.P300, flexShrink: 0 }}
/>
<Box as="span" direction="Column" grow="Yes" style={{ minWidth: 0 }}>
<Box as="span" grow="Yes" alignItems="Center" gap="100">
<Text priority={unread ? '500' : '300'} as="span" size="Inherit" truncate>
{roomName}
</Text>
{hasLocalName && (
<Icon
size="50"
src={Icons.Pencil}
aria-label="Custom local name"
style={{ opacity: config.opacity.P300, flexShrink: 0 }}
/>
)}
{isFavorite && (
<Icon
size="50"
src={Icons.Star}
filled
aria-label="Favorited"
style={{ opacity: config.opacity.P300, flexShrink: 0 }}
/>
)}
</Box>
{dmPreview && (
<Box as="span" alignItems="Center" gap="100" style={{ minWidth: 0 }}>
<Text
as="span"
size="T200"
truncate
style={{ opacity: config.opacity.P300, flexGrow: 1 }}
>
{dmPreview.preview}
</Text>
<Text
as="span"
size="T200"
style={{ opacity: config.opacity.P300, flexShrink: 0, whiteSpace: 'nowrap' }}
>
{dmPreview.time}
</Text>
</Box>
)}
</Box>
{!optionsVisible && !unread && !selected && typingMember.length > 0 && (
+334
View File
@@ -0,0 +1,334 @@
import React, { useCallback, useEffect, useState } from 'react';
import {
Box,
Button,
Header,
Icon,
IconButton,
Icons,
Scroll,
Spinner,
Text,
Tooltip,
TooltipProvider,
config,
} from 'folds';
import { Direction, EventType, MatrixEvent, MsgType, Room } from 'matrix-js-sdk';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { mxcUrlToHttp } from '../../utils/matrix';
import { ContainerColor } from '../../styles/ContainerColor.css';
type GalleryTab = 'image' | 'video' | 'file';
type MediaGalleryProps = {
room: Room;
onClose: () => void;
};
const TAB_LABELS: Record<GalleryTab, string> = {
image: 'Images',
video: 'Videos',
file: 'Files',
};
const TAB_MSGTYPES: Record<GalleryTab, MsgType> = {
image: MsgType.Image,
video: MsgType.Video,
file: MsgType.File,
};
function TabButton({
label,
active,
onClick,
}: {
label: string;
active: boolean;
onClick: () => void;
}) {
return (
<Button
size="300"
variant={active ? 'Primary' : 'Secondary'}
fill={active ? 'Soft' : 'None'}
radii="300"
onClick={onClick}
>
<Text size="B300">{label}</Text>
</Button>
);
}
export function MediaGallery({ room, onClose }: MediaGalleryProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const [tab, setTab] = useState<GalleryTab>('image');
const [events, setEvents] = useState<MatrixEvent[]>([]);
const [loading, setLoading] = useState(false);
const [paginationToken, setPaginationToken] = useState<string | null>(null);
const msgtype = TAB_MSGTYPES[tab];
const loadMedia = useCallback(
async (fromToken: string | null, append: boolean) => {
setLoading(true);
try {
const response = await mx.createMessagesRequest(
room.roomId,
fromToken,
100,
Direction.Backward,
undefined,
);
const { end, chunk } = response;
const filtered = chunk
.filter(
(ev) =>
ev.type === EventType.RoomMessage &&
ev.content?.msgtype === msgtype &&
!ev.unsigned?.redacted_because,
)
.map((ev) => new MatrixEvent(ev));
setEvents((prev) => (append ? [...prev, ...filtered] : filtered));
setPaginationToken(end ?? null);
} catch {
// silently swallow fetch errors — gallery stays showing what it has
} finally {
setLoading(false);
}
},
[mx, room.roomId, msgtype],
);
useEffect(() => {
setEvents([]);
setPaginationToken(null);
loadMedia(null, false).catch(() => undefined);
}, [loadMedia]);
const handleLoadMore = () => {
if (paginationToken) loadMedia(paginationToken, true).catch(() => undefined);
};
return (
<Box
className={ContainerColor({ variant: 'Surface' })}
direction="Column"
style={{
position: 'fixed',
top: 0,
right: 0,
bottom: 0,
width: '320px',
zIndex: 500,
background: 'var(--bg-surface)',
overflow: 'hidden',
}}
>
{/* Header */}
<Header
variant="Background"
size="600"
style={{
flexShrink: 0,
padding: `0 ${config.space.S200} 0 ${config.space.S300}`,
borderBottomWidth: config.borderWidth.B300,
}}
>
<Box grow="Yes" alignItems="Center" gap="200">
<Box grow="Yes" alignItems="Center" gap="200">
<Icon size="200" src={Icons.Photo} />
<Text size="H5" truncate>
Media
</Text>
</Box>
<Box shrink="No" alignItems="Center">
<TooltipProvider
position="Bottom"
align="End"
offset={4}
tooltip={
<Tooltip>
<Text>Close</Text>
</Tooltip>
}
>
{(triggerRef) => (
<IconButton
ref={triggerRef}
variant="Background"
aria-label="Close media gallery"
onClick={onClose}
>
<Icon src={Icons.Cross} />
</IconButton>
)}
</TooltipProvider>
</Box>
</Box>
</Header>
{/* Tab bar */}
<Box
shrink="No"
gap="100"
style={{
padding: config.space.S200,
}}
>
{(Object.keys(TAB_LABELS) as GalleryTab[]).map((t) => (
<TabButton key={t} label={TAB_LABELS[t]} active={tab === t} onClick={() => setTab(t)} />
))}
</Box>
{/* Content */}
<Box
grow="Yes"
style={{
position: 'relative',
overflow: 'hidden',
}}
>
<Scroll variant="Background" size="300" visibility="Hover" hideTrack>
<Box direction="Column" gap="200" style={{ padding: config.space.S200 }}>
{loading && events.length === 0 && (
<Box justifyContent="Center" style={{ padding: config.space.S400 }}>
<Spinner />
</Box>
)}
{!loading && events.length === 0 && (
<Box justifyContent="Center" style={{ padding: config.space.S400 }}>
<Text size="T300" priority="300" align="Center">
{`No ${TAB_LABELS[tab].toLowerCase()} found in this room.`}
</Text>
</Box>
)}
{/* Image/Video grid */}
{(tab === 'image' || tab === 'video') && events.length > 0 && (
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(3, 1fr)',
gap: config.space.S100,
}}
>
{events.map((mEvent) => {
const content = mEvent.getContent();
const mxcUrl: string | undefined = content.url ?? content.file?.url;
if (!mxcUrl) return null;
const thumbUrl =
mxcUrlToHttp(mx, mxcUrl, useAuthentication, 120, 120, 'crop') ?? '';
const body: string = content.body ?? '';
return (
<a
key={mEvent.getId()}
href={mxcUrlToHttp(mx, mxcUrl, useAuthentication) ?? '#'}
target="_blank"
rel="noreferrer"
style={{
display: 'block',
aspectRatio: '1',
overflow: 'hidden',
borderRadius: config.radii.R300,
background: 'var(--bg-surface)',
}}
title={body}
>
<img
src={thumbUrl}
alt={body}
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
display: 'block',
}}
/>
</a>
);
})}
</div>
)}
{/* File list */}
{tab === 'file' && events.length > 0 && (
<Box direction="Column" gap="100">
{events.map((mEvent) => {
const content = mEvent.getContent();
const mxcUrl: string | undefined = content.url ?? content.file?.url;
const body: string = content.body ?? 'Unnamed file';
const downloadUrl = mxcUrl
? (mxcUrlToHttp(mx, mxcUrl, useAuthentication) ?? '#')
: '#';
return (
<Box
key={mEvent.getId()}
alignItems="Center"
gap="200"
style={{
padding: `${config.space.S100} ${config.space.S200}`,
borderRadius: config.radii.R300,
background: 'var(--bg-surface)',
overflow: 'hidden',
}}
>
<Icon size="200" src={Icons.File} />
<Box grow="Yes" style={{ overflow: 'hidden' }}>
<Text size="T300" truncate title={body}>
{body}
</Text>
</Box>
<IconButton
variant="Background"
size="300"
radii="300"
aria-label={`Download ${body}`}
onClick={() => {
const anchor = document.createElement('a');
anchor.href = downloadUrl;
anchor.download = body;
anchor.target = '_blank';
anchor.rel = 'noreferrer';
anchor.click();
}}
>
<Icon size="200" src={Icons.Download} />
</IconButton>
</Box>
);
})}
</Box>
)}
{/* Load more */}
{paginationToken !== null && !loading && (
<Box justifyContent="Center" style={{ padding: config.space.S200 }}>
<Button
size="300"
variant="Secondary"
fill="Soft"
radii="300"
onClick={handleLoadMore}
>
<Text size="B300">Load more</Text>
</Button>
</Box>
)}
{/* Loading more spinner */}
{loading && events.length > 0 && (
<Box justifyContent="Center" style={{ padding: config.space.S200 }}>
<Spinner />
</Box>
)}
</Box>
</Scroll>
</Box>
</Box>
);
}
+88 -1
View File
@@ -10,6 +10,7 @@ import {
Avatar,
Badge,
Box,
Button,
Chip,
Header,
Icon,
@@ -29,6 +30,7 @@ import {
import { MatrixClient, Room, RoomMember } from 'matrix-js-sdk';
import { useVirtualizer } from '@tanstack/react-virtual';
import classNames from 'classnames';
import { Membership } from '../../../types/matrix/room';
import * as css from './MembersDrawer.css';
import { useMatrixClient } from '../../hooks/useMatrixClient';
@@ -51,7 +53,11 @@ import { useRoomTypingMember } from '../../hooks/useRoomTypingMembers';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useMembershipFilter, useMembershipFilterMenu } from '../../hooks/useMemberFilter';
import { useMemberPowerSort, useMemberSort, useMemberSortMenu } from '../../hooks/useMemberSort';
import { useGetMemberPowerLevel, usePowerLevelsContext } from '../../hooks/usePowerLevels';
import {
readPowerLevel,
useGetMemberPowerLevel,
usePowerLevelsContext,
} from '../../hooks/usePowerLevels';
import { MembershipFilterMenu } from '../../components/MembershipFilterMenu';
import { MemberSortMenu } from '../../components/MemberSortMenu';
import { useOpenUserRoomProfile, useUserRoomProfileState } from '../../state/hooks/userRoomProfile';
@@ -225,6 +231,15 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) {
const typingMembers = useRoomTypingMember(room.roomId);
const myUserId = mx.getUserId();
const myPowerLevel = readPowerLevel.user(powerLevels, myUserId ?? undefined);
const invitePowerLevel = readPowerLevel.action(powerLevels, 'invite');
const canApproveKnock = myPowerLevel >= invitePowerLevel;
const knockMembers = useMemo(
() => (canApproveKnock ? room.getMembersWithMembership(Membership.Knock) : []),
[room, canApproveKnock],
);
const filteredMembers = useMemo(
() => members.filter(membershipFilter.filterFn).sort(memberSort.sortFn).sort(memberPowerSort),
[members, membershipFilter, memberSort, memberPowerSort],
@@ -392,6 +407,78 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) {
</IconButton>
</ScrollTopContainer>
{knockMembers.length > 0 && (
<Box direction="Column" gap="100">
<Text
style={{ padding: `${config.space.S100} ${config.space.S200}` }}
size="L400"
priority="300"
>
Pending Requests
</Text>
{knockMembers.map((knockMember) => {
const knockName =
getMemberDisplayName(room, knockMember.userId) ??
getMxIdLocalPart(knockMember.userId) ??
knockMember.userId;
const knockAvatarMxc = knockMember.getMxcAvatarUrl();
const knockAvatarUrl = knockAvatarMxc
? mx.mxcUrlToHttp(
knockAvatarMxc,
100,
100,
'crop',
undefined,
false,
useAuthentication,
)
: undefined;
return (
<Box
key={knockMember.userId}
alignItems="Center"
gap="200"
style={{ padding: `0 ${config.space.S200}` }}
>
<Avatar size="200">
<UserAvatar
userId={knockMember.userId}
src={knockAvatarUrl ?? undefined}
alt={knockName}
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
/>
</Avatar>
<Box grow="Yes" direction="Column">
<Text size="T400" truncate>
{knockName}
</Text>
</Box>
<Box shrink="No" gap="100">
<Button
size="300"
variant="Success"
radii="300"
fill="Soft"
onClick={() => mx.invite(room.roomId, knockMember.userId)}
>
<Text size="B300">Approve</Text>
</Button>
<Button
size="300"
variant="Critical"
radii="300"
fill="Soft"
onClick={() => mx.kick(room.roomId, knockMember.userId)}
>
<Text size="B300">Deny</Text>
</Button>
</Box>
</Box>
);
})}
</Box>
)}
{!fetchingMembers && !result && processMembers.length === 0 && (
<Text style={{ padding: config.space.S300 }} align="Center">
{`No "${membershipFilter.name}" Members`}
+273 -245
View File
@@ -72,6 +72,7 @@ import { RoomSettingsPage } from '../../state/roomSettings';
import { useCallEmbed, useCallStart } from '../../hooks/useCallEmbed';
import { useLivekitSupport } from '../../hooks/useLivekitSupport';
import { webRTCSupported } from '../../utils/rtc';
import { MediaGallery } from './MediaGallery';
type RoomMenuProps = {
room: Room;
@@ -431,6 +432,7 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
: undefined;
const [peopleDrawer, setPeopleDrawer] = useSetting(settingsAtom, 'isPeopleDrawer');
const [galleryOpen, setGalleryOpen] = useState(false);
const handleSearchClick = () => {
const searchParams: _SearchPathSearchParams = {
@@ -461,258 +463,284 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
};
return (
<PageHeader
className={ContainerColor({ variant: 'Surface' })}
balance={screenSize === ScreenSize.Mobile}
>
<Box grow="Yes" gap="300">
{screenSize === ScreenSize.Mobile && (
<BackRouteHandler>
{(onBack) => (
<Box shrink="No" alignItems="Center">
<IconButton fill="None" onClick={onBack} aria-label="Back">
<Icon src={Icons.ArrowLeft} />
</IconButton>
</Box>
)}
</BackRouteHandler>
)}
<Box grow="Yes" alignItems="Center" gap="300">
{screenSize !== ScreenSize.Mobile && (
<Avatar size="300">
<RoomAvatar
roomId={room.roomId}
src={avatarUrl}
alt={name}
renderFallback={() => (
<RoomIcon size="200" joinRule={room.getJoinRule()} roomType={room.getType()} />
)}
/>
</Avatar>
<>
<PageHeader
className={ContainerColor({ variant: 'Surface' })}
balance={screenSize === ScreenSize.Mobile}
>
<Box grow="Yes" gap="300">
{screenSize === ScreenSize.Mobile && (
<BackRouteHandler>
{(onBack) => (
<Box shrink="No" alignItems="Center">
<IconButton fill="None" onClick={onBack} aria-label="Back">
<Icon src={Icons.ArrowLeft} />
</IconButton>
</Box>
)}
</BackRouteHandler>
)}
<Box direction="Column">
<Box alignItems="Center" gap="200">
<Text size={topic ? 'H5' : 'H3'} truncate>
{name}
</Text>
{room.getType() === 'm.server_notice' && (
<TooltipProvider
position="Bottom"
offset={4}
tooltip={
<Tooltip>
<Text>System messages from your homeserver administrator.</Text>
</Tooltip>
}
>
{(triggerRef) => (
<Chip ref={triggerRef} size="400" variant="Warning" radii="Pill" outlined>
<Text size="T200">Server Notice</Text>
</Chip>
<Box grow="Yes" alignItems="Center" gap="300">
{screenSize !== ScreenSize.Mobile && (
<Avatar size="300">
<RoomAvatar
roomId={room.roomId}
src={avatarUrl}
alt={name}
renderFallback={() => (
<RoomIcon size="200" joinRule={room.getJoinRule()} roomType={room.getType()} />
)}
</TooltipProvider>
/>
</Avatar>
)}
<Box direction="Column">
<Box alignItems="Center" gap="200">
<Text size={topic ? 'H5' : 'H3'} truncate>
{name}
</Text>
{room.getType() === 'm.server_notice' && (
<TooltipProvider
position="Bottom"
offset={4}
tooltip={
<Tooltip>
<Text>System messages from your homeserver administrator.</Text>
</Tooltip>
}
>
{(triggerRef) => (
<Chip ref={triggerRef} size="400" variant="Warning" radii="Pill" outlined>
<Text size="T200">Server Notice</Text>
</Chip>
)}
</TooltipProvider>
)}
</Box>
{topic && (
<UseStateProvider initial={false}>
{(viewTopic, setViewTopic) => (
<>
<Overlay open={viewTopic} backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
returnFocusOnDeactivate: false,
clickOutsideDeactivates: true,
onDeactivate: () => setViewTopic(false),
escapeDeactivates: stopPropagation,
}}
>
<RoomTopicViewer
name={name}
topic={topic}
requestClose={() => setViewTopic(false)}
/>
</FocusTrap>
</OverlayCenter>
</Overlay>
<Text
as="button"
type="button"
onClick={() => setViewTopic(true)}
className={css.HeaderTopic}
size="T200"
priority="300"
truncate
>
{topic.topic}
</Text>
</>
)}
</UseStateProvider>
)}
</Box>
{topic && (
<UseStateProvider initial={false}>
{(viewTopic, setViewTopic) => (
<>
<Overlay open={viewTopic} backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
returnFocusOnDeactivate: false,
clickOutsideDeactivates: true,
onDeactivate: () => setViewTopic(false),
escapeDeactivates: stopPropagation,
}}
>
<RoomTopicViewer
name={name}
topic={topic}
requestClose={() => setViewTopic(false)}
/>
</FocusTrap>
</OverlayCenter>
</Overlay>
<Text
as="button"
type="button"
onClick={() => setViewTopic(true)}
className={css.HeaderTopic}
size="T200"
priority="300"
truncate
>
{topic.topic}
</Text>
</>
</Box>
<Box shrink="No">
{!encryptedRoom && (
<TooltipProvider
position="Bottom"
offset={4}
tooltip={
<Tooltip>
<Text>Search</Text>
</Tooltip>
}
>
{(triggerRef) => (
<IconButton
fill="None"
ref={triggerRef}
onClick={handleSearchClick}
aria-label="Search"
>
<Icon size="400" src={Icons.Search} />
</IconButton>
)}
</UseStateProvider>
</TooltipProvider>
)}
<TooltipProvider
position="Bottom"
offset={4}
tooltip={
<Tooltip>
<Text>Pinned Messages</Text>
</Tooltip>
}
>
{(triggerRef) => (
<IconButton
fill="None"
style={{ position: 'relative' }}
onClick={handleOpenPinMenu}
ref={triggerRef}
aria-pressed={!!pinMenuAnchor}
>
{pinnedEvents.length > 0 && (
<Badge
style={{
position: 'absolute',
left: toRem(3),
top: toRem(3),
}}
variant="Secondary"
size="400"
fill="Solid"
radii="Pill"
>
<Text as="span" size="L400">
{pinnedEvents.length}
</Text>
</Badge>
)}
<Icon size="400" src={Icons.Pin} filled={!!pinMenuAnchor} />
</IconButton>
)}
</TooltipProvider>
<PopOut
anchor={pinMenuAnchor}
position="Bottom"
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>
}
/>
{!room.isCallRoom() &&
livekitSupported &&
rtcSupported &&
hasCallPermission &&
(direct ||
(room.getJoinRule() === 'invite' &&
getStateEvents(room, StateEvent.SpaceParent).length === 0)) && <CallButton />}
{screenSize === ScreenSize.Desktop && (
<TooltipProvider
position="Bottom"
offset={4}
tooltip={
<Tooltip>
<Text>{galleryOpen ? 'Hide Gallery' : 'Media Gallery'}</Text>
</Tooltip>
}
>
{(triggerRef) => (
<IconButton
fill="None"
ref={triggerRef}
onClick={() => setGalleryOpen(!galleryOpen)}
aria-label="Toggle media gallery"
aria-pressed={galleryOpen}
>
<Icon size="400" src={Icons.Photo} filled={galleryOpen} />
</IconButton>
)}
</TooltipProvider>
)}
{screenSize === ScreenSize.Desktop && (
<TooltipProvider
position="Bottom"
offset={4}
tooltip={
<Tooltip>
{callView ? (
<Text>Members</Text>
) : (
<Text>{peopleDrawer ? 'Hide Members' : 'Show Members'}</Text>
)}
</Tooltip>
}
>
{(triggerRef) => (
<IconButton
fill="None"
ref={triggerRef}
onClick={handleMemberToggle}
aria-label="Toggle member list"
>
<Icon size="400" src={Icons.User} />
</IconButton>
)}
</TooltipProvider>
)}
<TooltipProvider
position="Bottom"
align="End"
offset={4}
tooltip={
<Tooltip>
<Text>More Options</Text>
</Tooltip>
}
>
{(triggerRef) => (
<IconButton
fill="None"
onClick={handleOpenMenu}
ref={triggerRef}
aria-label="More options"
aria-expanded={!!menuAnchor}
aria-haspopup="menu"
>
<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} requestClose={() => setMenuAnchor(undefined)} />
</FocusTrap>
}
/>
</Box>
</Box>
<Box shrink="No">
{!encryptedRoom && (
<TooltipProvider
position="Bottom"
offset={4}
tooltip={
<Tooltip>
<Text>Search</Text>
</Tooltip>
}
>
{(triggerRef) => (
<IconButton
fill="None"
ref={triggerRef}
onClick={handleSearchClick}
aria-label="Search"
>
<Icon size="400" src={Icons.Search} />
</IconButton>
)}
</TooltipProvider>
)}
<TooltipProvider
position="Bottom"
offset={4}
tooltip={
<Tooltip>
<Text>Pinned Messages</Text>
</Tooltip>
}
>
{(triggerRef) => (
<IconButton
fill="None"
style={{ position: 'relative' }}
onClick={handleOpenPinMenu}
ref={triggerRef}
aria-pressed={!!pinMenuAnchor}
>
{pinnedEvents.length > 0 && (
<Badge
style={{
position: 'absolute',
left: toRem(3),
top: toRem(3),
}}
variant="Secondary"
size="400"
fill="Solid"
radii="Pill"
>
<Text as="span" size="L400">
{pinnedEvents.length}
</Text>
</Badge>
)}
<Icon size="400" src={Icons.Pin} filled={!!pinMenuAnchor} />
</IconButton>
)}
</TooltipProvider>
<PopOut
anchor={pinMenuAnchor}
position="Bottom"
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>
}
/>
{!room.isCallRoom() &&
livekitSupported &&
rtcSupported &&
hasCallPermission &&
(direct ||
(room.getJoinRule() === 'invite' &&
getStateEvents(room, StateEvent.SpaceParent).length === 0)) && <CallButton />}
{screenSize === ScreenSize.Desktop && (
<TooltipProvider
position="Bottom"
offset={4}
tooltip={
<Tooltip>
{callView ? (
<Text>Members</Text>
) : (
<Text>{peopleDrawer ? 'Hide Members' : 'Show Members'}</Text>
)}
</Tooltip>
}
>
{(triggerRef) => (
<IconButton
fill="None"
ref={triggerRef}
onClick={handleMemberToggle}
aria-label="Toggle member list"
>
<Icon size="400" src={Icons.User} />
</IconButton>
)}
</TooltipProvider>
)}
<TooltipProvider
position="Bottom"
align="End"
offset={4}
tooltip={
<Tooltip>
<Text>More Options</Text>
</Tooltip>
}
>
{(triggerRef) => (
<IconButton
fill="None"
onClick={handleOpenMenu}
ref={triggerRef}
aria-label="More options"
aria-expanded={!!menuAnchor}
aria-haspopup="menu"
>
<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} requestClose={() => setMenuAnchor(undefined)} />
</FocusTrap>
}
/>
</Box>
</Box>
</PageHeader>
</PageHeader>
{galleryOpen && <MediaGallery room={room} onClose={() => setGalleryOpen(false)} />}
</>
);
}