cinny/src/app/features/lobby/SpaceItem.tsx
Ajay Bura 78a0d11f24
Some checks failed
Deploy to Netlify (dev) / Deploy to Netlify (push) Has been cancelled
New add existing room/space modal (#2451)
2025-08-19 22:39:31 +10:00

497 lines
14 KiB
TypeScript

import React, { MouseEventHandler, ReactNode, useCallback, useRef, useState } from 'react';
import {
Box,
Avatar,
Text,
Chip,
Icon,
Icons,
as,
Badge,
toRem,
Spinner,
PopOut,
Menu,
MenuItem,
RectCords,
config,
} from 'folds';
import FocusTrap from 'focus-trap-react';
import classNames from 'classnames';
import { MatrixError, Room } from 'matrix-js-sdk';
import { IHierarchyRoom } from 'matrix-js-sdk/lib/@types/spaces';
import { HierarchyItem } from '../../hooks/useSpaceHierarchy';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { RoomAvatar } from '../../components/room-avatar';
import { nameInitials } from '../../utils/common';
import { LocalRoomSummaryLoader } from '../../components/RoomSummaryLoader';
import { getRoomAvatarUrl } from '../../utils/room';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import * as css from './SpaceItem.css';
import * as styleCss from './style.css';
import { useDraggableItem } from './DnD';
import { stopPropagation } from '../../utils/keyboard';
import { mxcUrlToHttp } from '../../utils/matrix';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useOpenCreateRoomModal } from '../../state/hooks/createRoomModal';
import { useOpenCreateSpaceModal } from '../../state/hooks/createSpaceModal';
import { AddExistingModal } from '../add-existing';
function SpaceProfileLoading() {
return (
<Box gap="200" alignItems="Center">
<Box grow="Yes" gap="200" alignItems="Center" className={css.HeaderChipPlaceholder}>
<Avatar className={styleCss.AvatarPlaceholder} size="200" radii="300" />
<Box
className={styleCss.LinePlaceholder}
shrink="No"
style={{ width: '100vw', maxWidth: toRem(120) }}
/>
</Box>
</Box>
);
}
type InaccessibleSpaceProfileProps = {
roomId: string;
suggested?: boolean;
};
function InaccessibleSpaceProfile({ roomId, suggested }: InaccessibleSpaceProfileProps) {
return (
<Chip
as="span"
className={css.HeaderChip}
variant="Surface"
size="500"
before={
<Avatar size="200" radii="300">
<RoomAvatar
roomId={roomId}
renderFallback={() => (
<Text as="span" size="H6">
U
</Text>
)}
/>
</Avatar>
}
>
<Box alignItems="Center" gap="200">
<Text size="H4" truncate>
Unknown
</Text>
<Badge variant="Secondary" fill="Soft" radii="Pill" outlined>
<Text size="L400">Inaccessible</Text>
</Badge>
{suggested && (
<Badge variant="Success" fill="Soft" radii="Pill" outlined>
<Text size="L400">Suggested</Text>
</Badge>
)}
</Box>
</Chip>
);
}
type UnjoinedSpaceProfileProps = {
roomId: string;
via?: string[];
name?: string;
avatarUrl?: string;
suggested?: boolean;
};
function UnjoinedSpaceProfile({
roomId,
via,
name,
avatarUrl,
suggested,
}: UnjoinedSpaceProfileProps) {
const mx = useMatrixClient();
const [joinState, join] = useAsyncCallback<Room, MatrixError, []>(
useCallback(() => mx.joinRoom(roomId, { viaServers: via }), [mx, roomId, via])
);
const canJoin = joinState.status === AsyncStatus.Idle || joinState.status === AsyncStatus.Error;
return (
<Chip
className={css.HeaderChip}
variant="Surface"
size="500"
onClick={join}
disabled={!canJoin}
before={
<Avatar size="200" radii="300">
<RoomAvatar
roomId={roomId}
src={avatarUrl}
alt={name}
renderFallback={() => (
<Text as="span" size="H6">
{nameInitials(name)}
</Text>
)}
/>
</Avatar>
}
after={
canJoin ? <Icon src={Icons.Plus} size="50" /> : <Spinner variant="Secondary" size="200" />
}
>
<Box alignItems="Center" gap="200">
<Text size="H4" truncate>
{name || 'Unknown'}
</Text>
{suggested && (
<Badge variant="Success" fill="Soft" radii="Pill" outlined>
<Text size="L400">Suggested</Text>
</Badge>
)}
{joinState.status === AsyncStatus.Error && (
<Badge variant="Critical" fill="Soft" radii="Pill" outlined>
<Text size="L400" truncate>
{joinState.error.name}
</Text>
</Badge>
)}
</Box>
</Chip>
);
}
type SpaceProfileProps = {
roomId: string;
name: string;
avatarUrl?: string;
suggested?: boolean;
closed: boolean;
categoryId: string;
handleClose?: MouseEventHandler<HTMLButtonElement>;
};
function SpaceProfile({
roomId,
name,
avatarUrl,
suggested,
closed,
categoryId,
handleClose,
}: SpaceProfileProps) {
return (
<Chip
data-category-id={categoryId}
onClick={handleClose}
className={css.HeaderChip}
variant="Surface"
size="500"
before={
<Avatar size="200" radii="300">
<RoomAvatar
roomId={roomId}
src={avatarUrl}
alt={name}
renderFallback={() => (
<Text as="span" size="H6">
{nameInitials(name)}
</Text>
)}
/>
</Avatar>
}
after={<Icon src={closed ? Icons.ChevronRight : Icons.ChevronBottom} size="50" />}
>
<Box alignItems="Center" gap="200">
<Text size="H4" truncate>
{name}
</Text>
{suggested && (
<Badge variant="Success" fill="Soft" radii="Pill" outlined>
<Text size="L400">Suggested</Text>
</Badge>
)}
</Box>
</Chip>
);
}
type RootSpaceProfileProps = {
closed: boolean;
categoryId: string;
handleClose?: MouseEventHandler<HTMLButtonElement>;
};
function RootSpaceProfile({ closed, categoryId, handleClose }: RootSpaceProfileProps) {
return (
<Chip
data-category-id={categoryId}
onClick={handleClose}
className={css.HeaderChip}
variant="Surface"
size="500"
after={<Icon src={closed ? Icons.ChevronRight : Icons.ChevronBottom} size="50" />}
>
<Box alignItems="Center" gap="200">
<Text size="H4" truncate>
Rooms
</Text>
</Box>
</Chip>
);
}
function AddRoomButton({ item }: { item: HierarchyItem }) {
const [cords, setCords] = useState<RectCords>();
const openCreateRoomModal = useOpenCreateRoomModal();
const [addExisting, setAddExisting] = useState(false);
const handleAddRoom: MouseEventHandler<HTMLButtonElement> = (evt) => {
setCords(evt.currentTarget.getBoundingClientRect());
};
const handleCreateRoom = () => {
openCreateRoomModal(item.roomId);
setCords(undefined);
};
const handleAddExisting = () => {
setAddExisting(true);
setCords(undefined);
};
return (
<PopOut
anchor={cords}
position="Bottom"
align="End"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setCords(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
escapeDeactivates: stopPropagation,
}}
>
<Menu style={{ padding: config.space.S100 }}>
<MenuItem
size="300"
radii="300"
variant="Primary"
fill="None"
onClick={handleCreateRoom}
>
<Text size="T300">New Room</Text>
</MenuItem>
<MenuItem size="300" radii="300" fill="None" onClick={handleAddExisting}>
<Text size="T300">Existing Room</Text>
</MenuItem>
</Menu>
</FocusTrap>
}
>
<Chip
variant="Primary"
radii="Pill"
before={<Icon src={Icons.Plus} size="50" />}
onClick={handleAddRoom}
aria-pressed={!!cords}
>
<Text size="B300">Add Room</Text>
</Chip>
{addExisting && (
<AddExistingModal parentId={item.roomId} requestClose={() => setAddExisting(false)} />
)}
</PopOut>
);
}
function AddSpaceButton({ item }: { item: HierarchyItem }) {
const [cords, setCords] = useState<RectCords>();
const openCreateSpaceModal = useOpenCreateSpaceModal();
const [addExisting, setAddExisting] = useState(false);
const handleAddSpace: MouseEventHandler<HTMLButtonElement> = (evt) => {
setCords(evt.currentTarget.getBoundingClientRect());
};
const handleCreateSpace = () => {
openCreateSpaceModal(item.roomId as any);
setCords(undefined);
};
const handleAddExisting = () => {
setAddExisting(true);
setCords(undefined);
};
return (
<PopOut
anchor={cords}
position="Bottom"
align="End"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setCords(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
escapeDeactivates: stopPropagation,
}}
>
<Menu style={{ padding: config.space.S100 }}>
<MenuItem
size="300"
radii="300"
variant="Primary"
fill="None"
onClick={handleCreateSpace}
>
<Text size="T300">New Space</Text>
</MenuItem>
<MenuItem size="300" radii="300" fill="None" onClick={handleAddExisting}>
<Text size="T300">Existing Space</Text>
</MenuItem>
</Menu>
</FocusTrap>
}
>
<Chip
variant="SurfaceVariant"
radii="Pill"
before={<Icon src={Icons.Plus} size="50" />}
onClick={handleAddSpace}
aria-pressed={!!cords}
>
<Text size="B300">Add Space</Text>
</Chip>
{addExisting && (
<AddExistingModal space parentId={item.roomId} requestClose={() => setAddExisting(false)} />
)}
</PopOut>
);
}
type SpaceItemCardProps = {
summary: IHierarchyRoom | undefined;
loading?: boolean;
item: HierarchyItem;
joined?: boolean;
categoryId: string;
closed: boolean;
handleClose?: MouseEventHandler<HTMLButtonElement>;
options?: ReactNode;
before?: ReactNode;
after?: ReactNode;
canEditChild: boolean;
canReorder: boolean;
onDragging: (item?: HierarchyItem) => void;
getRoom: (roomId: string) => Room | undefined;
};
export const SpaceItemCard = as<'div', SpaceItemCardProps>(
(
{
className,
summary,
loading,
joined,
closed,
categoryId,
item,
handleClose,
options,
before,
after,
canEditChild,
canReorder,
onDragging,
getRoom,
...props
},
ref
) => {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const { roomId, content } = item;
const space = getRoom(roomId);
const targetRef = useRef<HTMLDivElement>(null);
useDraggableItem(item, targetRef, onDragging);
return (
<Box
shrink="No"
alignItems="Center"
gap="200"
className={classNames(css.SpaceItemCard({ outlined: !joined || closed }), className)}
{...props}
ref={ref}
>
{before}
<Box grow="Yes" gap="100" alignItems="Inherit" justifyContent="SpaceBetween">
<Box ref={canReorder ? targetRef : null}>
{space ? (
<LocalRoomSummaryLoader room={space}>
{(localSummary) =>
item.parentId ? (
<SpaceProfile
roomId={roomId}
name={localSummary.name}
avatarUrl={getRoomAvatarUrl(mx, space, 96, useAuthentication)}
suggested={content.suggested}
closed={closed}
categoryId={categoryId}
handleClose={handleClose}
/>
) : (
<RootSpaceProfile
closed={closed}
categoryId={categoryId}
handleClose={handleClose}
/>
)
}
</LocalRoomSummaryLoader>
) : (
<>
{!summary &&
(loading ? (
<SpaceProfileLoading />
) : (
<InaccessibleSpaceProfile
roomId={item.roomId}
suggested={item.content.suggested}
/>
))}
{summary && (
<UnjoinedSpaceProfile
roomId={roomId}
via={item.content.via}
name={summary.name || summary.canonical_alias || roomId}
avatarUrl={
summary?.avatar_url
? mxcUrlToHttp(mx, summary.avatar_url, useAuthentication, 96, 96, 'crop') ??
undefined
: undefined
}
suggested={content.suggested}
/>
)}
</>
)}
</Box>
{space && canEditChild && (
<Box shrink="No" alignItems="Inherit" gap="200">
<AddRoomButton item={item} />
{item.parentId === undefined && <AddSpaceButton item={item} />}
</Box>
)}
</Box>
{options}
{after}
</Box>
);
}
);