506 lines
18 KiB
TypeScript
506 lines
18 KiB
TypeScript
import React, { MouseEventHandler, forwardRef, useEffect, useMemo, useRef, useState } from 'react';
|
|
import { Avatar, Box, IconButton, Line, Menu, MenuItem, PopOut, RectCords, Text, Tooltip, TooltipProvider, config, toRem } from 'folds';
|
|
import { Icon, Icons } from '../../components/icons';
|
|
import FocusTrap from 'focus-trap-react';
|
|
import { PageHeader } from '../../components/page';
|
|
import { useSetSetting } from '../../state/hooks/settings';
|
|
import { settingsAtom } from '../../state/settings';
|
|
import { useRoomAvatar, useRoomName } from '../../hooks/useRoomMeta';
|
|
import { useSpace } from '../../hooks/useSpace';
|
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
|
import { RoomAvatar } from '../../components/room-avatar';
|
|
import { nameInitials } from '../../utils/common';
|
|
import * as css from './LobbyHeader.css';
|
|
import { IPowerLevels } from '../../hooks/usePowerLevels';
|
|
import { UseStateProvider } from '../../components/UseStateProvider';
|
|
import { LeaveSpacePrompt } from '../../components/leave-space-prompt';
|
|
import { RemoveInaccessiblePrompt } from '../../components/remove-inaccessible-prompt';
|
|
import { stopPropagation } from '../../utils/keyboard';
|
|
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
|
import { useBackRoute } from '../../hooks/useBackRoute';
|
|
import { mxcUrlToHttp } from '../../utils/matrix';
|
|
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
|
import { useOpenSpaceSettings } from '../../state/hooks/spaceSettings';
|
|
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
|
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
|
|
import { InviteUserPrompt } from '../../components/invite-user-prompt';
|
|
import { AddExistingModal } from '../add-existing';
|
|
import { SpeechBubble } from '../../components/speech-bubble/SpeechBubble';
|
|
import { SpaceOptionsMenu, type OpenAddExistingOptions } from '../space/SpaceOptionsMenu';
|
|
import type { HierarchyItemSpace } from '../../hooks/useSpaceHierarchy';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { getCanonicalAliasOrRoomId } from '../../utils/matrix';
|
|
import { getSpaceFeedPath } from '../../pages/pathUtils';
|
|
import { shouldShowForumLobby } from '../../utils/room';
|
|
import { useForumBoardContextOptionally } from '../forum/ForumBoardContext';
|
|
import { StateEvent } from '../../../types/matrix/room';
|
|
|
|
type LobbyMenuProps = {
|
|
powerLevels: IPowerLevels;
|
|
requestClose: () => void;
|
|
};
|
|
const LobbyMenu = forwardRef<HTMLDivElement, LobbyMenuProps>(
|
|
({ powerLevels, requestClose }, ref) => {
|
|
const mx = useMatrixClient();
|
|
const navigate = useNavigate();
|
|
const space = useSpace();
|
|
const isForum = shouldShowForumLobby(space);
|
|
const creators = useRoomCreators(space);
|
|
|
|
const permissions = useRoomPermissions(creators, powerLevels);
|
|
const canInvite = permissions.action('invite', mx.getSafeUserId());
|
|
const canEditChildren = permissions.stateEvent(StateEvent.SpaceChild, mx.getSafeUserId());
|
|
const openSpaceSettings = useOpenSpaceSettings();
|
|
|
|
const [invitePrompt, setInvitePrompt] = useState(false);
|
|
|
|
const handleInvite = () => {
|
|
setInvitePrompt(true);
|
|
};
|
|
|
|
const handleRoomSettings = () => {
|
|
openSpaceSettings(space.roomId);
|
|
requestClose();
|
|
};
|
|
|
|
const handleOpenFeed = () => {
|
|
navigate(getSpaceFeedPath(getCanonicalAliasOrRoomId(mx, space.roomId)));
|
|
requestClose();
|
|
};
|
|
|
|
return (
|
|
<Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}>
|
|
{invitePrompt && (
|
|
<InviteUserPrompt
|
|
room={space}
|
|
requestClose={() => {
|
|
setInvitePrompt(false);
|
|
requestClose();
|
|
}}
|
|
/>
|
|
)}
|
|
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
|
{isForum && (
|
|
<MenuItem
|
|
onClick={handleOpenFeed}
|
|
size="300"
|
|
after={<Icon size="100" src={Icons.Thread} />}
|
|
radii="300"
|
|
>
|
|
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
|
Feed
|
|
</Text>
|
|
</MenuItem>
|
|
)}
|
|
<MenuItem
|
|
onClick={handleInvite}
|
|
variant="Primary"
|
|
fill="None"
|
|
size="300"
|
|
after={<Icon size="100" src={Icons.UserPlus} />}
|
|
radii="300"
|
|
aria-pressed={invitePrompt}
|
|
disabled={!canInvite}
|
|
>
|
|
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
|
Invite
|
|
</Text>
|
|
</MenuItem>
|
|
<MenuItem
|
|
onClick={handleRoomSettings}
|
|
size="300"
|
|
after={<Icon size="100" src={Icons.Setting} />}
|
|
radii="300"
|
|
>
|
|
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
|
Space Settings
|
|
</Text>
|
|
</MenuItem>
|
|
</Box>
|
|
<Line variant="Surface" size="300" />
|
|
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
|
{canEditChildren && (
|
|
<UseStateProvider initial={false}>
|
|
{(promptRemove, setPromptRemove) => (
|
|
<>
|
|
<MenuItem
|
|
onClick={() => setPromptRemove(true)}
|
|
variant="Critical"
|
|
fill="None"
|
|
size="300"
|
|
after={<Icon size="100" src={Icons.Delete} />}
|
|
radii="300"
|
|
aria-pressed={promptRemove}
|
|
>
|
|
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
|
Remove Inaccessible
|
|
</Text>
|
|
</MenuItem>
|
|
{promptRemove && (
|
|
<RemoveInaccessiblePrompt
|
|
roomId={space.roomId}
|
|
onDone={requestClose}
|
|
onCancel={() => setPromptRemove(false)}
|
|
/>
|
|
)}
|
|
</>
|
|
)}
|
|
</UseStateProvider>
|
|
)}
|
|
<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}
|
|
>
|
|
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
|
Leave Space
|
|
</Text>
|
|
</MenuItem>
|
|
{promptLeave && (
|
|
<LeaveSpacePrompt
|
|
roomId={space.roomId}
|
|
onDone={requestClose}
|
|
onCancel={() => setPromptLeave(false)}
|
|
/>
|
|
)}
|
|
</>
|
|
)}
|
|
</UseStateProvider>
|
|
</Box>
|
|
</Menu>
|
|
);
|
|
}
|
|
);
|
|
|
|
type LobbyHeaderProps = {
|
|
showProfile?: boolean;
|
|
/** Space name on the left + add room/space + ⋮ menu (forum toolbar). */
|
|
showSpaceToolbar?: boolean;
|
|
powerLevels: IPowerLevels;
|
|
};
|
|
export function LobbyHeader({ showProfile, showSpaceToolbar, powerLevels }: LobbyHeaderProps) {
|
|
const mx = useMatrixClient();
|
|
const useAuthentication = useMediaAuthentication();
|
|
const space = useSpace();
|
|
const creators = useRoomCreators(space);
|
|
const spacePermissions = useRoomPermissions(creators, powerLevels);
|
|
const setPeopleDrawer = useSetSetting(settingsAtom, 'isPeopleDrawer');
|
|
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
|
|
const [addExisting, setAddExisting] = useState<OpenAddExistingOptions | null>(null);
|
|
const screenSize = useScreenSizeContext();
|
|
const { showInPageHeader, goBack } = useBackRoute();
|
|
|
|
const name = useRoomName(space);
|
|
const avatarMxc = useRoomAvatar(space);
|
|
const avatarUrl = avatarMxc
|
|
? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined
|
|
: undefined;
|
|
|
|
const forumBoard = useForumBoardContextOptionally();
|
|
const menuWrapperRef = useRef<HTMLDivElement | null>(null);
|
|
const menuTriggerElRef = useRef<HTMLButtonElement | null>(null);
|
|
const addParent = useMemo(() => {
|
|
const rootId = space.roomId;
|
|
if (!forumBoard) {
|
|
return { roomId: rootId, showAddSpace: true };
|
|
}
|
|
if (forumBoard.query.category) {
|
|
const section = forumBoard.sections.find((s) => s.title === forumBoard.query.category);
|
|
if (section?.spaceId) {
|
|
return { roomId: section.spaceId, showAddSpace: false };
|
|
}
|
|
}
|
|
return { roomId: rootId, showAddSpace: true };
|
|
}, [forumBoard, space.roomId]);
|
|
|
|
const addParentItem: HierarchyItemSpace = useMemo(
|
|
() => ({
|
|
roomId: addParent.roomId,
|
|
content: { via: [] },
|
|
ts: 0,
|
|
space: true,
|
|
parentId: addParent.roomId === space.roomId ? undefined : space.roomId,
|
|
}),
|
|
[addParent.roomId, space.roomId]
|
|
);
|
|
|
|
const canEditChildren = Boolean(
|
|
spacePermissions.stateEvent(StateEvent.SpaceChild, mx.getSafeUserId())
|
|
);
|
|
|
|
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
|
menuTriggerElRef.current = evt.currentTarget;
|
|
setMenuAnchor(evt.currentTarget.getBoundingClientRect());
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (!showSpaceToolbar || !menuAnchor || addExisting) return;
|
|
|
|
let outsideTimer: number | undefined;
|
|
const clearOutsideTimer = () => {
|
|
if (outsideTimer) {
|
|
window.clearTimeout(outsideTimer);
|
|
outsideTimer = undefined;
|
|
}
|
|
};
|
|
|
|
const closeMenu = () => {
|
|
clearOutsideTimer();
|
|
setMenuAnchor(undefined);
|
|
};
|
|
|
|
const isInside = (node: Node | null) => {
|
|
if (!node) return false;
|
|
return Boolean(
|
|
menuWrapperRef.current?.contains(node) || menuTriggerElRef.current?.contains(node)
|
|
);
|
|
};
|
|
|
|
const handlePointerDown = (evt: MouseEvent | TouchEvent) => {
|
|
const node = evt.target as Node | null;
|
|
if (!isInside(node)) {
|
|
closeMenu();
|
|
}
|
|
};
|
|
|
|
const handlePointerMove = (evt: PointerEvent | MouseEvent) => {
|
|
const node = evt.target as Node | null;
|
|
const inside = isInside(node);
|
|
if (inside) {
|
|
clearOutsideTimer();
|
|
return;
|
|
}
|
|
if (!outsideTimer) {
|
|
outsideTimer = window.setTimeout(closeMenu, 3000);
|
|
}
|
|
};
|
|
|
|
document.addEventListener('mousedown', handlePointerDown, true);
|
|
document.addEventListener('touchstart', handlePointerDown, true);
|
|
document.addEventListener('pointermove', handlePointerMove, true);
|
|
document.addEventListener('mousemove', handlePointerMove, true);
|
|
|
|
return () => {
|
|
clearOutsideTimer();
|
|
document.removeEventListener('mousedown', handlePointerDown, true);
|
|
document.removeEventListener('touchstart', handlePointerDown, true);
|
|
document.removeEventListener('pointermove', handlePointerMove, true);
|
|
document.removeEventListener('mousemove', handlePointerMove, true);
|
|
};
|
|
}, [addExisting, menuAnchor, showSpaceToolbar]);
|
|
|
|
if (showSpaceToolbar) {
|
|
return (
|
|
<>
|
|
<PageHeader outlined>
|
|
<Box grow="Yes" alignItems="Center" gap="300">
|
|
{showInPageHeader && (
|
|
<Box shrink="No">
|
|
<IconButton onClick={goBack} aria-label="Back">
|
|
<Icon src={Icons.ArrowLeft} />
|
|
</IconButton>
|
|
</Box>
|
|
)}
|
|
<Avatar size="300" shrink="No">
|
|
<RoomAvatar
|
|
roomId={space.roomId}
|
|
src={avatarUrl}
|
|
alt={name}
|
|
renderFallback={() => <Text size="H4">{nameInitials(name)}</Text>}
|
|
/>
|
|
</Avatar>
|
|
<Text size="H3" truncate style={{ minWidth: 0 }}>
|
|
{name}
|
|
</Text>
|
|
<Box grow="Yes" />
|
|
<TooltipProvider
|
|
position="Bottom"
|
|
align="End"
|
|
offset={4}
|
|
tooltip={
|
|
<Tooltip>
|
|
<Text>More Options</Text>
|
|
</Tooltip>
|
|
}
|
|
>
|
|
{(triggerRef) => (
|
|
<IconButton
|
|
onClick={handleOpenMenu}
|
|
ref={triggerRef}
|
|
aria-pressed={!!menuAnchor}
|
|
variant="SurfaceVariant"
|
|
fill="None"
|
|
size="300"
|
|
radii="300"
|
|
>
|
|
<Icon size="400" src={Icons.VerticalDots} />
|
|
</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,
|
|
}}
|
|
>
|
|
<div ref={menuWrapperRef}>
|
|
<SpeechBubble variant="menu" tail="top" tailX="calc(100% - 22px)">
|
|
<SpaceOptionsMenu
|
|
room={space}
|
|
requestClose={() => setMenuAnchor(undefined)}
|
|
onOpenAddExisting={(options) => {
|
|
setAddExisting(options);
|
|
setMenuAnchor(undefined);
|
|
}}
|
|
forumAdd={
|
|
forumBoard && canEditChildren
|
|
? {
|
|
item: addParentItem,
|
|
showAddSpace: addParent.showAddSpace,
|
|
}
|
|
: undefined
|
|
}
|
|
/>
|
|
</SpeechBubble>
|
|
</div>
|
|
</FocusTrap>
|
|
}
|
|
/>
|
|
</Box>
|
|
</PageHeader>
|
|
{addExisting && (
|
|
<AddExistingModal
|
|
parentId={addExisting.parentId}
|
|
space={addExisting.space}
|
|
requestClose={() => setAddExisting(null)}
|
|
/>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<PageHeader className={showProfile ? undefined : css.Header} balance>
|
|
<Box grow="Yes" alignItems="Center" gap="200">
|
|
{showInPageHeader ? (
|
|
<>
|
|
<Box shrink="No">
|
|
<IconButton onClick={goBack} aria-label="Back">
|
|
<Icon src={Icons.ArrowLeft} />
|
|
</IconButton>
|
|
</Box>
|
|
<Box grow="Yes" justifyContent="Center">
|
|
{showProfile && (
|
|
<Text size="H3" truncate>
|
|
{name}
|
|
</Text>
|
|
)}
|
|
</Box>
|
|
</>
|
|
) : (
|
|
<>
|
|
<Box grow="Yes" basis="No" />
|
|
<Box justifyContent="Center" alignItems="Center" gap="300">
|
|
{showProfile && (
|
|
<>
|
|
<Avatar size="300">
|
|
<RoomAvatar
|
|
roomId={space.roomId}
|
|
src={avatarUrl}
|
|
alt={name}
|
|
renderFallback={() => <Text size="H4">{nameInitials(name)}</Text>}
|
|
/>
|
|
</Avatar>
|
|
<Text size="H3" truncate>
|
|
{name}
|
|
</Text>
|
|
</>
|
|
)}
|
|
</Box>
|
|
</>
|
|
)}
|
|
<Box
|
|
shrink="No"
|
|
grow={screenSize === ScreenSize.Mobile ? 'No' : 'Yes'}
|
|
basis={screenSize === ScreenSize.Mobile ? 'Yes' : 'No'}
|
|
justifyContent="End"
|
|
>
|
|
{screenSize !== ScreenSize.Mobile && (
|
|
<TooltipProvider
|
|
position="Bottom"
|
|
offset={4}
|
|
tooltip={
|
|
<Tooltip>
|
|
<Text>Members</Text>
|
|
</Tooltip>
|
|
}
|
|
>
|
|
{(triggerRef) => (
|
|
<IconButton ref={triggerRef} onClick={() => setPeopleDrawer((drawer) => !drawer)}>
|
|
<Icon size="400" src={Icons.User} />
|
|
</IconButton>
|
|
)}
|
|
</TooltipProvider>
|
|
)}
|
|
<TooltipProvider
|
|
position="Bottom"
|
|
align="End"
|
|
offset={4}
|
|
tooltip={
|
|
<Tooltip>
|
|
<Text>More Options</Text>
|
|
</Tooltip>
|
|
}
|
|
>
|
|
{(triggerRef) => (
|
|
<IconButton 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,
|
|
}}
|
|
>
|
|
<LobbyMenu
|
|
powerLevels={powerLevels}
|
|
requestClose={() => setMenuAnchor(undefined)}
|
|
/>
|
|
</FocusTrap>
|
|
}
|
|
/>
|
|
</Box>
|
|
</Box>
|
|
</PageHeader>
|
|
);
|
|
}
|