Files
cinny/src/app/features/lobby/LobbyHeader.tsx

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>
);
}