URL navigation in interface and other improvements (#1633)

* load room on url change

* add direct room list

* render space room list

* fix css syntax error

* update scroll virtualizer

* render subspaces room list

* improve sidebar notification badge perf

* add nav category components

* add space recursive direct component

* use nav category component in home, direct and space room list

* add empty home and direct list layout

* fix unread room menu ref

* add more navigation items in room, direct and space tab

* add more navigation

* fix unread room menu to links

* fix space lobby and search link

* add explore navigation section

* add notifications navigation menu

* redirect to initial path after login

* include unsupported room in rooms

* move router hooks in hooks/router folder

* add featured explore - WIP

* load featured room with room summary

* fix room card topic line clamp

* add react query

* load room summary using react query

* add join button in room card

* add content component

* use content component in featured community content

* fix content width

* add responsive room card grid

* fix async callback error status

* add room card error button

* fix client drawer shrink

* add room topic viewer

* open room card topic in viewer

* fix room topic close btn

* add get orphan parent util

* add room card error dialog

* add view featured room or space btn

* refactor orphanParent to orphanParents

* WIP - explore server

* show space hint in room card

* add room type filters

* add per page item limit popout

* reset scroll on public rooms load

* refactor explore ui

* refactor public rooms component

* reset search on server change

* fix typo

* add empty featured section info

* display user server on top

* make server room card view btn clickable

* add user server as default redirect for explore path

* make home empty btn clickable

* add thirdparty instance filter in server explore

* remove since param on instance change

* add server button in explore menu

* rename notifications path to inbox

* update react-virtual

* Add notification messages inbox - WIP

* add scroll top container component

* add useInterval hook

* add visibility change callback prop to scroll top container component

* auto refresh notifications every 10 seconds

* make message related component reusable

* refactor matrix event renderer hoook

* render notification message content

* refactor matrix event renderer hook

* update sequence card styles

* move room navigate hook in global hooks

* add open message button in notifications

* add mark room as read button in notification group

* show error in notification messages

* add more featured spaces

* render reply in notification messages

* make notification message reply clickable

* add outline prop for attachments

* make old settings dialog viewable

* add open featured communities as default config option

* add invite count notification badge in sidebar and inbox menu

* add element size observer hook

* improve element size observer hook props

* improve screen size hook

* fix room avatar util function

* allow Text props in Time component

* fix dm room util function

* add invitations

* add no invites and notification cards

* fix inbox tab unread badge visible without invite count

* update folds and change inbox icon

* memo search param construction

* add message search in home

* fix default message search order

* fix display edited message new content

* highlight search text in search messages

* fix message search loading

* disable log in production

* add use space context

* add useRoom context

* fix space room list

* fix inbox tab active state

* add hook to get space child room recursive

* add search for space

* add virtual tile component

* virtualize home and directs room list

* update nav category component

* use virtual tile component in more places

* fix message highlight when click on reply twice

* virtualize space room list

* fix space room list lag issue

* update folds

* add room nav item component in space room list

* use room nav item in home and direct room list

* make space categories closable and save it in local storage

* show unread room when category is collapsed

* make home and direct room list category closable

* rename room nav item show avatar prop

* fix explore server category text alignment

* rename closedRoomCategories to closedNavCategories

* add nav category handler hook

* save and restore last navigation path on space select

* filter space rooms category by activity when it is closed

* save and restore home and direct nav path state

* save and restore inbox active path on open

* save and restore explore tab active path

* remove notification badge unread menu

* add join room or space before navigate screen

* move room component to features folder and add new room header

* update folds

* add room header menu

* fix home room list activity sorting

* do not hide selected room item on category closed in home and direct tab

* replace old select room/tab call with navigate hook

* improve state event hooks

* show room card summary for joined rooms

* prevent room from opening in wrong tab

* only show message sender id on hover in modern layout

* revert state event hooks changes

* add key prop to room provider components

* add welcome page

* prevent excessive redirects

* fix sidebar style with no spaces

* move room settings in popup window

* remove invite option from room settings

* fix open room list search

* add leave room prompt

* standardize room and user avatar

* fix avatar text size

* add new reply layout

* rename space hierarchy hook

* add room topic hook

* add room name hook

* add room avatar hook and add direct room avatar util

* space lobby - WIP

* hide invalid space child event from space hierarchy in lobby

* move lobby to features

* fix element size observer hook width and height

* add lobby header and hero section

* add hierarchy room item error and loading state

* add first and last child prop in sequence card

* redirect to lobby from index path

* memo and retry hierarchy room summary error

* fix hierarchy room item styles

* rename lobby hierarchy item card to room item card

* show direct room avatar in space lobby

* add hierarchy space item

* add space item unknown room join button

* fix space hierarchy hook refresh after new space join

* change user avatar color and fallback render to user icon

* change room avatar fallback to room icon

* rename room/user avatar renderInitial prop to renderFallback

* add room join and view button in space lobby

* make power level api more reusable

* fix space hierarchy not updating on child update

* add menu to suggest or remove space children

* show reply arrow in place of reply bend in message

* fix typeerror in search because of wrong js-sdk t.ds

* do not refetch hierarchy room summary on window focus

* make room/user avatar un-draggable

* change welcome page support button copy

* drag-and-drop ordering of lobby spaces/rooms - WIP

* add ASCIILexicalTable algorithms

* fix wrong power level check in lobby items options

* fix lobby can drop checks

* fix join button error crash

* fix reply spacing

* fix m direct updated with other account data

* add option to open room/space settings from lobby

* add option in lobby to add new or existing room/spaces

* fix room nav item selected styles

* add space children reorder mechanism

* fix space child reorder bug

* fix hierarchy item sort function

* Apply reorder of lobby into room list

* add and improve space lobby menu items

* add existing spaces menu in lobby

* change restricted room allow params when dragging outside space

* move featured servers config from homeserver list

* removed unused features from space settings

* add canonical alias as name fallback in lobby item

* fix unreliable unread count update bug

* fix after login redirect

* fix room card topic hover style

* Add dnd and folders in sidebar spaces

* fix orphan space not visible in sidebar

* fix sso login has mix of icon and button

* fix space children not  visible in home upon leaving space

* recalculate notification on updating any space child

* fix user color saturation/lightness

* add user color to user avatar

* add background colors to room avatar

* show 2 length initial in sidebar space avatar

* improve link color

* add nav button component

* open legacy create room and create direct

* improve page route structure

* handle hash router in path utils

* mobile friendly router and navigation

* make room header member drawer icon mobile friendly

* setup index redirect for inbox and explore server route

* add leave space prompt

* improve member drawer filter menu

* add space context menu

* add context menu in home

* add leave button in lobby items

* render user tab avatar on sidebar

* force overwrite netlify - test

* netlify test

* fix reset-password path without server redirected to login

* add message link copy button in message menu

* reset unread on sync prepared

* fix stuck typing notifications

* show typing indication in room nav item

* refactor closedNavCategories atom to use userId in store key

* refactor closedLobbyCategoriesAtom to include userId in store key

* refactor navToActivePathAtom to use userId in storage key

* remove unused file

* refactor openedSidebarFolderAtom to include userId in storage key

* add context menu for sidebar space tab

* fix eslint not working

* add option to pin/unpin child spaces

* add context menu for directs tab

* add context menu for direct and home tab

* show lock icon for non-public space in header

* increase matrix max listener count

* wrap lobby add space room in callback hook
This commit is contained in:
Ajay Bura 2024-05-31 19:49:46 +05:30 committed by GitHub
parent 2b7d825694
commit 4c76a7fd18
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
290 changed files with 17447 additions and 3224 deletions

View file

@ -0,0 +1,91 @@
import { style } from '@vanilla-extract/css';
import { color, config, toRem } from 'folds';
import { ContainerColor } from '../../styles/ContainerColor.css';
export const ItemDraggableTarget = style([
ContainerColor({ variant: 'SurfaceVariant' }),
{
height: '100%',
position: 'absolute',
left: 0,
top: 0,
zIndex: 1,
cursor: 'grab',
borderRadius: config.radii.R400,
opacity: config.opacity.P300,
':active': {
cursor: 'ns-resize',
},
},
]);
const LineHeight = 4;
const DropTargetLine = style({
selectors: {
'&[data-hover=true]:before': {
content: '',
display: 'block',
width: '100%',
position: 'absolute',
left: 0,
top: '50%',
zIndex: 1,
transform: 'translateY(-50%)',
borderBottom: `${toRem(LineHeight)} solid currentColor`,
},
'&[data-hover=true]:after': {
content: '',
display: 'block',
width: toRem(LineHeight * 3),
height: toRem(LineHeight * 3),
position: 'absolute',
left: 0,
top: '50%',
zIndex: 1,
transform: 'translate(-50%, -50%)',
backgroundColor: color.Surface.Container,
border: `${toRem(LineHeight)} solid currentColor`,
borderRadius: '50%',
},
},
});
const BaseAfterRoomItemDropTarget = style({
width: '100%',
position: 'absolute',
left: 0,
bottom: 0,
zIndex: 99,
color: color.Success.Main,
selectors: {
'&[data-error=true]': {
color: color.Critical.Main,
},
},
});
const RoomTargetHeight = 32;
export const AfterRoomItemDropTarget = style([
BaseAfterRoomItemDropTarget,
{
height: toRem(RoomTargetHeight),
transform: `translateY(${toRem(RoomTargetHeight / 2 + LineHeight / 2)})`,
},
DropTargetLine,
]);
const SpaceTargetHeight = 14;
export const AfterSpaceItemDropTarget = style([
BaseAfterRoomItemDropTarget,
{
height: toRem(SpaceTargetHeight),
transform: `translateY(calc(100% - ${toRem(4)}))`,
},
DropTargetLine,
]);

View file

@ -0,0 +1,146 @@
import React, { RefObject, useEffect, useRef, useState } from 'react';
import {
dropTargetForElements,
draggable,
monitorForElements,
} from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
import { autoScrollForElements } from '@atlaskit/pragmatic-drag-and-drop-auto-scroll/element';
import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine';
import classNames from 'classnames';
import { Box, Icon, Icons, as } from 'folds';
import { HierarchyItem } from '../../hooks/useSpaceHierarchy';
import * as css from './DnD.css';
export type DropContainerData = {
item: HierarchyItem;
nextRoomId?: string;
};
export type CanDropCallback = (item: HierarchyItem, container: DropContainerData) => boolean;
export const useDraggableItem = (
item: HierarchyItem,
targetRef: RefObject<HTMLElement>,
onDragging: (item?: HierarchyItem) => void,
dragHandleRef?: RefObject<HTMLElement>
): boolean => {
const [dragging, setDragging] = useState(false);
useEffect(() => {
const target = targetRef.current;
const dragHandle = dragHandleRef?.current ?? undefined;
return !target
? undefined
: draggable({
element: target,
dragHandle,
getInitialData: () => item,
onDragStart: () => {
setDragging(true);
onDragging(item);
},
onDrop: () => {
setDragging(false);
onDragging(undefined);
},
});
}, [targetRef, dragHandleRef, item, onDragging]);
return dragging;
};
export const ItemDraggableTarget = as<'div'>(({ className, ...props }, ref) => (
<Box
justifyContent="Center"
alignItems="Center"
className={classNames(css.ItemDraggableTarget, className)}
ref={ref}
{...props}
>
<Icon size="50" src={Icons.VerticalDots} />
</Box>
));
type AfterItemDropTargetProps = {
item: HierarchyItem;
afterSpace?: boolean;
nextRoomId?: string;
canDrop: CanDropCallback;
};
export function AfterItemDropTarget({
item,
afterSpace,
nextRoomId,
canDrop,
}: AfterItemDropTargetProps) {
const targetRef = useRef<HTMLDivElement>(null);
const [dropState, setDropState] = useState<'idle' | 'allow' | 'not-allow'>('idle');
useEffect(() => {
const target = targetRef.current;
if (!target) {
throw Error('drop target ref is not set properly');
}
return dropTargetForElements({
element: target,
getData: () => {
const container: DropContainerData = {
item,
nextRoomId,
};
return container;
},
onDragEnter: ({ source }) => {
if (
canDrop(source.data as HierarchyItem, {
item,
nextRoomId,
})
) {
setDropState('allow');
} else {
setDropState('not-allow');
}
},
onDragLeave: () => setDropState('idle'),
onDrop: () => setDropState('idle'),
});
}, [item, nextRoomId, canDrop]);
return (
<div
className={afterSpace ? css.AfterSpaceItemDropTarget : css.AfterRoomItemDropTarget}
data-hover={dropState !== 'idle'}
data-error={dropState === 'not-allow'}
ref={targetRef}
/>
);
}
export const useDnDMonitor = (
scrollRef: RefObject<HTMLElement>,
onDragging: (item?: HierarchyItem) => void,
onReorder: (item: HierarchyItem, container: DropContainerData) => void
) => {
useEffect(() => {
const scrollElement = scrollRef.current;
if (!scrollElement) {
throw Error('Scroll element ref not configured');
}
return combine(
monitorForElements({
onDrop: ({ source, location }) => {
onDragging(undefined);
const { dropTargets } = location.current;
if (dropTargets.length === 0) return;
onReorder(source.data as HierarchyItem, dropTargets[0].data as DropContainerData);
},
}),
autoScrollForElements({
element: scrollElement,
})
);
}, [scrollRef, onDragging, onReorder]);
};

View file

@ -0,0 +1,306 @@
import React, { MouseEventHandler, useCallback, useEffect, useState } from 'react';
import FocusTrap from 'focus-trap-react';
import {
Box,
IconButton,
Icon,
Icons,
PopOut,
Menu,
MenuItem,
Text,
RectCords,
config,
Line,
Spinner,
toRem,
} from 'folds';
import { HierarchyItem } from '../../hooks/useSpaceHierarchy';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { MSpaceChildContent, StateEvent } from '../../../types/matrix/room';
import {
openInviteUser,
openSpaceSettings,
toggleRoomSettings,
} from '../../../client/action/navigation';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { UseStateProvider } from '../../components/UseStateProvider';
import { LeaveSpacePrompt } from '../../components/leave-space-prompt';
import { LeaveRoomPrompt } from '../../components/leave-room-prompt';
type HierarchyItemWithParent = HierarchyItem & {
parentId: string;
};
function SuggestMenuItem({
item,
requestClose,
}: {
item: HierarchyItemWithParent;
requestClose: () => void;
}) {
const mx = useMatrixClient();
const { roomId, parentId, content } = item;
const [toggleState, handleToggleSuggested] = useAsyncCallback(
useCallback(() => {
const newContent: MSpaceChildContent = { ...content, suggested: !content.suggested };
return mx.sendStateEvent(parentId, StateEvent.SpaceChild, newContent, roomId);
}, [mx, parentId, roomId, content])
);
useEffect(() => {
if (toggleState.status === AsyncStatus.Success) {
requestClose();
}
}, [requestClose, toggleState]);
return (
<MenuItem
onClick={handleToggleSuggested}
size="300"
radii="300"
before={toggleState.status === AsyncStatus.Loading && <Spinner size="100" />}
disabled={toggleState.status === AsyncStatus.Loading}
>
<Text as="span" size="T300" truncate>
{content.suggested ? 'Unset Suggested' : 'Set Suggested'}
</Text>
</MenuItem>
);
}
function RemoveMenuItem({
item,
requestClose,
}: {
item: HierarchyItemWithParent;
requestClose: () => void;
}) {
const mx = useMatrixClient();
const { roomId, parentId } = item;
const [removeState, handleRemove] = useAsyncCallback(
useCallback(
() => mx.sendStateEvent(parentId, StateEvent.SpaceChild, {}, roomId),
[mx, parentId, roomId]
)
);
useEffect(() => {
if (removeState.status === AsyncStatus.Success) {
requestClose();
}
}, [requestClose, removeState]);
return (
<MenuItem
onClick={handleRemove}
variant="Critical"
fill="None"
size="300"
radii="300"
before={
removeState.status === AsyncStatus.Loading && (
<Spinner variant="Critical" fill="Soft" size="100" />
)
}
disabled={removeState.status === AsyncStatus.Loading}
>
<Text as="span" size="T300" truncate>
Remove
</Text>
</MenuItem>
);
}
function InviteMenuItem({
item,
requestClose,
disabled,
}: {
item: HierarchyItemWithParent;
requestClose: () => void;
disabled?: boolean;
}) {
const handleInvite = () => {
openInviteUser(item.roomId);
requestClose();
};
return (
<MenuItem
onClick={handleInvite}
size="300"
radii="300"
variant="Primary"
fill="None"
disabled={disabled}
>
<Text as="span" size="T300" truncate>
Invite
</Text>
</MenuItem>
);
}
function SettingsMenuItem({
item,
requestClose,
disabled,
}: {
item: HierarchyItemWithParent;
requestClose: () => void;
disabled?: boolean;
}) {
const handleSettings = () => {
if (item.space) {
openSpaceSettings(item.roomId);
} else {
toggleRoomSettings(item.roomId);
}
requestClose();
};
return (
<MenuItem onClick={handleSettings} size="300" radii="300" disabled={disabled}>
<Text as="span" size="T300" truncate>
Settings
</Text>
</MenuItem>
);
}
type HierarchyItemMenuProps = {
item: HierarchyItem & {
parentId: string;
};
joined: boolean;
canInvite: boolean;
canEditChild: boolean;
pinned?: boolean;
onTogglePin?: (roomId: string) => void;
};
export function HierarchyItemMenu({
item,
joined,
canInvite,
canEditChild,
pinned,
onTogglePin,
}: HierarchyItemMenuProps) {
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
setMenuAnchor(evt.currentTarget.getBoundingClientRect());
};
const handleRequestClose = useCallback(() => setMenuAnchor(undefined), []);
if (!joined && !canEditChild) {
return null;
}
return (
<Box gap="200" alignItems="Center" shrink="No">
<IconButton
onClick={handleOpenMenu}
size="300"
variant="SurfaceVariant"
fill="None"
radii="300"
aria-pressed={!!menuAnchor}
>
<Icon size="50" src={Icons.VerticalDots} />
</IconButton>
{menuAnchor && (
<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',
}}
>
<Menu style={{ maxWidth: toRem(150), width: '100vw' }}>
{joined && (
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
{onTogglePin && (
<MenuItem
size="300"
radii="300"
onClick={() => {
onTogglePin(item.roomId);
handleRequestClose();
}}
>
<Text as="span" size="T300" truncate>
{pinned ? 'Unpin from Sidebar' : 'Pin to Sidebar'}
</Text>
</MenuItem>
)}
<InviteMenuItem
item={item}
requestClose={handleRequestClose}
disabled={!canInvite}
/>
<SettingsMenuItem item={item} requestClose={handleRequestClose} />
<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
</Text>
</MenuItem>
{promptLeave &&
(item.space ? (
<LeaveSpacePrompt
roomId={item.roomId}
onDone={handleRequestClose}
onCancel={() => setPromptLeave(false)}
/>
) : (
<LeaveRoomPrompt
roomId={item.roomId}
onDone={handleRequestClose}
onCancel={() => setPromptLeave(false)}
/>
))}
</>
)}
</UseStateProvider>
</Box>
)}
{(joined || canEditChild) && (
<Line size="300" variant="Surface" direction="Horizontal" />
)}
{canEditChild && (
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
<SuggestMenuItem item={item} requestClose={handleRequestClose} />
<RemoveMenuItem item={item} requestClose={handleRequestClose} />
</Box>
)}
</Menu>
</FocusTrap>
}
/>
)}
</Box>
);
}

View file

@ -0,0 +1,528 @@
import React, { MouseEventHandler, useCallback, useMemo, useRef, useState } from 'react';
import { Box, Icon, IconButton, Icons, Line, Scroll, config } from 'folds';
import { useVirtualizer } from '@tanstack/react-virtual';
import { useAtom, useAtomValue } from 'jotai';
import { useNavigate } from 'react-router-dom';
import { IJoinRuleEventContent, JoinRule, RestrictedAllowType, Room } from 'matrix-js-sdk';
import { useSpace } from '../../hooks/useSpace';
import { Page, PageContent, PageContentCenter, PageHeroSection } from '../../components/page';
import { HierarchyItem, useSpaceHierarchy } from '../../hooks/useSpaceHierarchy';
import { VirtualTile } from '../../components/virtualizer';
import { spaceRoomsAtom } from '../../state/spaceRooms';
import { MembersDrawer } from '../room/MembersDrawer';
import { useSetting } from '../../state/hooks/settings';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
import { settingsAtom } from '../../state/settings';
import { LobbyHeader } from './LobbyHeader';
import { LobbyHero } from './LobbyHero';
import { ScrollTopContainer } from '../../components/scroll-top-container';
import { useElementSizeObserver } from '../../hooks/useElementSizeObserver';
import {
IPowerLevels,
PowerLevelsContextProvider,
powerLevelAPI,
usePowerLevels,
useRoomsPowerLevels,
} from '../../hooks/usePowerLevels';
import { RoomItemCard } from './RoomItem';
import { mDirectAtom } from '../../state/mDirectList';
import { SpaceItemCard } from './SpaceItem';
import { makeLobbyCategoryId } from '../../state/closedLobbyCategories';
import { useCategoryHandler } from '../../hooks/useCategoryHandler';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { allRoomsAtom } from '../../state/room-list/roomList';
import { getCanonicalAliasOrRoomId } from '../../utils/matrix';
import { getSpaceRoomPath } from '../../pages/pathUtils';
import { HierarchyItemMenu } from './HierarchyItemMenu';
import { StateEvent } from '../../../types/matrix/room';
import { AfterItemDropTarget, CanDropCallback, useDnDMonitor } from './DnD';
import { ASCIILexicalTable, orderKeys } from '../../utils/ASCIILexicalTable';
import { getStateEvent } from '../../utils/room';
import { useClosedLobbyCategoriesAtom } from '../../state/hooks/closedLobbyCategories';
import {
makeCinnySpacesContent,
sidebarItemWithout,
useSidebarItems,
} from '../../hooks/useSidebarItems';
import { useOrphanSpaces } from '../../state/hooks/roomList';
import { roomToParentsAtom } from '../../state/room/roomToParents';
import { AccountDataEvent } from '../../../types/matrix/accountData';
export function Lobby() {
const navigate = useNavigate();
const mx = useMatrixClient();
const mDirects = useAtomValue(mDirectAtom);
const allRooms = useAtomValue(allRoomsAtom);
const allJoinedRooms = useMemo(() => new Set(allRooms), [allRooms]);
const space = useSpace();
const spacePowerLevels = usePowerLevels(space);
const lex = useMemo(() => new ASCIILexicalTable(' '.charCodeAt(0), '~'.charCodeAt(0), 6), []);
const scrollRef = useRef<HTMLDivElement>(null);
const heroSectionRef = useRef<HTMLDivElement>(null);
const [heroSectionHeight, setHeroSectionHeight] = useState<number>();
const [spaceRooms, setSpaceRooms] = useAtom(spaceRoomsAtom);
const [isDrawer] = useSetting(settingsAtom, 'isPeopleDrawer');
const screenSize = useScreenSizeContext();
const [onTop, setOnTop] = useState(true);
const [closedCategories, setClosedCategories] = useAtom(useClosedLobbyCategoriesAtom());
const [sidebarItems] = useSidebarItems(
useOrphanSpaces(mx, allRoomsAtom, useAtomValue(roomToParentsAtom))
);
const sidebarSpaces = useMemo(() => {
const sideSpaces = sidebarItems.flatMap((item) => {
if (typeof item === 'string') return item;
return item.content;
});
return new Set(sideSpaces);
}, [sidebarItems]);
useElementSizeObserver(
useCallback(() => heroSectionRef.current, []),
useCallback((w, height) => setHeroSectionHeight(height), [])
);
const getRoom = useCallback(
(rId: string) => {
if (allJoinedRooms.has(rId)) {
return mx.getRoom(rId) ?? undefined;
}
return undefined;
},
[mx, allJoinedRooms]
);
const canEditSpaceChild = useCallback(
(powerLevels: IPowerLevels) =>
powerLevelAPI.canSendStateEvent(
powerLevels,
StateEvent.SpaceChild,
powerLevelAPI.getPowerLevel(powerLevels, mx.getUserId() ?? undefined)
),
[mx]
);
const [draggingItem, setDraggingItem] = useState<HierarchyItem>();
const flattenHierarchy = useSpaceHierarchy(
space.roomId,
spaceRooms,
getRoom,
useCallback(
(childId) =>
closedCategories.has(makeLobbyCategoryId(space.roomId, childId)) || !!draggingItem?.space,
[closedCategories, space.roomId, draggingItem]
)
);
const virtualizer = useVirtualizer({
count: flattenHierarchy.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => 1,
overscan: 2,
paddingStart: heroSectionHeight ?? 258,
});
const vItems = virtualizer.getVirtualItems();
const roomsPowerLevels = useRoomsPowerLevels(
useMemo(
() => flattenHierarchy.map((i) => mx.getRoom(i.roomId)).filter((r) => !!r) as Room[],
[mx, flattenHierarchy]
)
);
const canDrop: CanDropCallback = useCallback(
(item, container): boolean => {
const restrictedItem = mx.getRoom(item.roomId)?.getJoinRule() === JoinRule.Restricted;
if (item.roomId === container.item.roomId || item.roomId === container.nextRoomId) {
// can not drop before or after itself
return false;
}
if (item.space) {
if (!container.item.space) return false;
const containerSpaceId = space.roomId;
if (
getRoom(containerSpaceId) === undefined ||
!canEditSpaceChild(roomsPowerLevels.get(containerSpaceId) ?? {})
) {
return false;
}
return true;
}
const containerSpaceId = container.item.space
? container.item.roomId
: container.item.parentId;
const dropOutsideSpace = item.parentId !== containerSpaceId;
if (dropOutsideSpace && restrictedItem) {
// do not allow restricted room to drop outside
// current space if can't change join rule allow
const itemPowerLevel = roomsPowerLevels.get(item.roomId) ?? {};
const userPLInItem = powerLevelAPI.getPowerLevel(
itemPowerLevel,
mx.getUserId() ?? undefined
);
const canChangeJoinRuleAllow = powerLevelAPI.canSendStateEvent(
itemPowerLevel,
StateEvent.RoomJoinRules,
userPLInItem
);
if (!canChangeJoinRuleAllow) {
return false;
}
}
if (
getRoom(containerSpaceId) === undefined ||
!canEditSpaceChild(roomsPowerLevels.get(containerSpaceId) ?? {})
) {
return false;
}
return true;
},
[getRoom, space.roomId, roomsPowerLevels, canEditSpaceChild, mx]
);
const reorderSpace = useCallback(
(item: HierarchyItem, containerItem: HierarchyItem) => {
if (!item.parentId) return;
const childItems = flattenHierarchy
.filter((i) => i.parentId && i.space)
.filter((i) => i.roomId !== item.roomId);
const beforeIndex = childItems.findIndex((i) => i.roomId === containerItem.roomId);
const insertIndex = beforeIndex + 1;
childItems.splice(insertIndex, 0, {
...item,
content: { ...item.content, order: undefined },
});
const currentOrders = childItems.map((i) => {
if (typeof i.content.order === 'string' && lex.has(i.content.order)) {
return i.content.order;
}
return undefined;
});
const newOrders = orderKeys(lex, currentOrders);
newOrders?.forEach((orderKey, index) => {
const itm = childItems[index];
if (!itm || !itm.parentId) return;
const parentPL = roomsPowerLevels.get(itm.parentId);
const canEdit = parentPL && canEditSpaceChild(parentPL);
if (canEdit && orderKey !== currentOrders[index]) {
mx.sendStateEvent(
itm.parentId,
StateEvent.SpaceChild,
{ ...itm.content, order: orderKey },
itm.roomId
);
}
});
},
[mx, flattenHierarchy, lex, roomsPowerLevels, canEditSpaceChild]
);
const reorderRoom = useCallback(
(item: HierarchyItem, containerItem: HierarchyItem): void => {
const itemRoom = mx.getRoom(item.roomId);
if (!item.parentId) {
return;
}
const containerParentId: string = containerItem.space
? containerItem.roomId
: containerItem.parentId;
const itemContent = item.content;
if (item.parentId !== containerParentId) {
mx.sendStateEvent(item.parentId, StateEvent.SpaceChild, {}, item.roomId);
}
if (
itemRoom &&
itemRoom.getJoinRule() === JoinRule.Restricted &&
item.parentId !== containerParentId
) {
// change join rule allow parameter when dragging
// restricted room from one space to another
const joinRuleContent = getStateEvent(
itemRoom,
StateEvent.RoomJoinRules
)?.getContent<IJoinRuleEventContent>();
if (joinRuleContent) {
const allow =
joinRuleContent.allow?.filter((allowRule) => allowRule.room_id !== item.parentId) ?? [];
allow.push({ type: RestrictedAllowType.RoomMembership, room_id: containerParentId });
mx.sendStateEvent(itemRoom.roomId, StateEvent.RoomJoinRules, {
...joinRuleContent,
allow,
});
}
}
const childItems = flattenHierarchy
.filter((i) => i.parentId === containerParentId && !i.space)
.filter((i) => i.roomId !== item.roomId);
const beforeItem: HierarchyItem | undefined = containerItem.space ? undefined : containerItem;
const beforeIndex = childItems.findIndex((i) => i.roomId === beforeItem?.roomId);
const insertIndex = beforeIndex + 1;
childItems.splice(insertIndex, 0, {
...item,
parentId: containerParentId,
content: { ...itemContent, order: undefined },
});
const currentOrders = childItems.map((i) => {
if (typeof i.content.order === 'string' && lex.has(i.content.order)) {
return i.content.order;
}
return undefined;
});
const newOrders = orderKeys(lex, currentOrders);
newOrders?.forEach((orderKey, index) => {
const itm = childItems[index];
if (itm && orderKey !== currentOrders[index]) {
mx.sendStateEvent(
containerParentId,
StateEvent.SpaceChild,
{ ...itm.content, order: orderKey },
itm.roomId
);
}
});
},
[mx, flattenHierarchy, lex]
);
useDnDMonitor(
scrollRef,
setDraggingItem,
useCallback(
(item, container) => {
if (!canDrop(item, container)) {
return;
}
if (item.space) {
reorderSpace(item, container.item);
} else {
reorderRoom(item, container.item);
}
},
[reorderRoom, reorderSpace, canDrop]
)
);
const addSpaceRoom = useCallback(
(roomId: string) => setSpaceRooms({ type: 'PUT', roomId }),
[setSpaceRooms]
);
const handleCategoryClick = useCategoryHandler(setClosedCategories, (categoryId) =>
closedCategories.has(categoryId)
);
const handleOpenRoom: MouseEventHandler<HTMLButtonElement> = (evt) => {
const rId = evt.currentTarget.getAttribute('data-room-id');
if (!rId) return;
const pSpaceIdOrAlias = getCanonicalAliasOrRoomId(mx, space.roomId);
navigate(getSpaceRoomPath(pSpaceIdOrAlias, getCanonicalAliasOrRoomId(mx, rId)));
};
const togglePinToSidebar = useCallback(
(rId: string) => {
const newItems = sidebarItemWithout(sidebarItems, rId);
if (!sidebarSpaces.has(rId)) {
newItems.push(rId);
}
const newSpacesContent = makeCinnySpacesContent(mx, newItems);
mx.setAccountData(AccountDataEvent.CinnySpaces, newSpacesContent);
},
[mx, sidebarItems, sidebarSpaces]
);
return (
<PowerLevelsContextProvider value={spacePowerLevels}>
<Box grow="Yes">
<Page>
<LobbyHeader
showProfile={!onTop}
powerLevels={roomsPowerLevels.get(space.roomId) ?? {}}
/>
<Box style={{ position: 'relative' }} grow="Yes">
<Scroll ref={scrollRef} hideTrack visibility="Hover">
<PageContent>
<PageContentCenter>
<ScrollTopContainer
scrollRef={scrollRef}
anchorRef={heroSectionRef}
onVisibilityChange={setOnTop}
>
<IconButton
onClick={() => virtualizer.scrollToOffset(0)}
variant="SurfaceVariant"
radii="Pill"
outlined
size="300"
aria-label="Scroll to Top"
>
<Icon src={Icons.ChevronTop} size="300" />
</IconButton>
</ScrollTopContainer>
<div
style={{
position: 'relative',
height: virtualizer.getTotalSize(),
}}
>
<PageHeroSection ref={heroSectionRef} style={{ paddingTop: 0 }}>
<LobbyHero />
</PageHeroSection>
{vItems.map((vItem) => {
const item = flattenHierarchy[vItem.index];
if (!item) return null;
const itemPowerLevel = roomsPowerLevels.get(item.roomId) ?? {};
const userPLInItem = powerLevelAPI.getPowerLevel(
itemPowerLevel,
mx.getUserId() ?? undefined
);
const canInvite = powerLevelAPI.canDoAction(
itemPowerLevel,
'invite',
userPLInItem
);
const isJoined = allJoinedRooms.has(item.roomId);
const nextRoomId: string | undefined =
flattenHierarchy[vItem.index + 1]?.roomId;
const dragging =
draggingItem?.roomId === item.roomId &&
draggingItem.parentId === item.parentId;
if (item.space) {
const categoryId = makeLobbyCategoryId(space.roomId, item.roomId);
const { parentId } = item;
const parentPowerLevels = parentId
? roomsPowerLevels.get(parentId) ?? {}
: undefined;
return (
<VirtualTile
virtualItem={vItem}
style={{
paddingTop: vItem.index === 0 ? 0 : config.space.S500,
}}
ref={virtualizer.measureElement}
key={vItem.index}
>
<SpaceItemCard
item={item}
joined={allJoinedRooms.has(item.roomId)}
categoryId={categoryId}
closed={closedCategories.has(categoryId) || !!draggingItem?.space}
handleClose={handleCategoryClick}
getRoom={getRoom}
canEditChild={canEditSpaceChild(
roomsPowerLevels.get(item.roomId) ?? {}
)}
canReorder={
parentPowerLevels ? canEditSpaceChild(parentPowerLevels) : false
}
options={
parentId &&
parentPowerLevels && (
<HierarchyItemMenu
item={{ ...item, parentId }}
canInvite={canInvite}
joined={isJoined}
canEditChild={canEditSpaceChild(parentPowerLevels)}
pinned={sidebarSpaces.has(item.roomId)}
onTogglePin={togglePinToSidebar}
/>
)
}
before={item.parentId ? undefined : undefined}
after={
<AfterItemDropTarget
item={item}
nextRoomId={nextRoomId}
afterSpace
canDrop={canDrop}
/>
}
onDragging={setDraggingItem}
data-dragging={dragging}
/>
</VirtualTile>
);
}
const parentPowerLevels = roomsPowerLevels.get(item.parentId) ?? {};
const prevItem: HierarchyItem | undefined = flattenHierarchy[vItem.index - 1];
const nextItem: HierarchyItem | undefined = flattenHierarchy[vItem.index + 1];
return (
<VirtualTile
virtualItem={vItem}
style={{ paddingTop: config.space.S100 }}
ref={virtualizer.measureElement}
key={vItem.index}
>
<RoomItemCard
item={item}
onSpaceFound={addSpaceRoom}
dm={mDirects.has(item.roomId)}
firstChild={!prevItem || prevItem.space === true}
lastChild={!nextItem || nextItem.space === true}
onOpen={handleOpenRoom}
getRoom={getRoom}
canReorder={canEditSpaceChild(parentPowerLevels)}
options={
<HierarchyItemMenu
item={item}
canInvite={canInvite}
joined={isJoined}
canEditChild={canEditSpaceChild(parentPowerLevels)}
/>
}
after={
<AfterItemDropTarget
item={item}
nextRoomId={nextRoomId}
canDrop={canDrop}
/>
}
data-dragging={dragging}
onDragging={setDraggingItem}
/>
</VirtualTile>
);
})}
</div>
</PageContentCenter>
</PageContent>
</Scroll>
</Box>
</Page>
{screenSize === ScreenSize.Desktop && isDrawer && (
<>
<Line variant="Background" direction="Vertical" size="300" />
<MembersDrawer room={space} />
</>
)}
</Box>
</PowerLevelsContextProvider>
);
}

View file

@ -0,0 +1,13 @@
import { style } from '@vanilla-extract/css';
import { config } from 'folds';
export const Header = style({
borderBottomColor: 'transparent',
});
export const HeaderTopic = style({
':hover': {
cursor: 'pointer',
opacity: config.opacity.P500,
textDecoration: 'underline',
},
});

View file

@ -0,0 +1,214 @@
import React, { MouseEventHandler, forwardRef, useState } from 'react';
import {
Avatar,
Box,
Icon,
IconButton,
Icons,
Line,
Menu,
MenuItem,
PopOut,
RectCords,
Text,
Tooltip,
TooltipProvider,
config,
toRem,
} from 'folds';
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 { openInviteUser, openSpaceSettings } from '../../../client/action/navigation';
import { IPowerLevels, usePowerLevelsAPI } from '../../hooks/usePowerLevels';
import { UseStateProvider } from '../../components/UseStateProvider';
import { LeaveSpacePrompt } from '../../components/leave-space-prompt';
type LobbyMenuProps = {
roomId: string;
powerLevels: IPowerLevels;
requestClose: () => void;
};
const LobbyMenu = forwardRef<HTMLDivElement, LobbyMenuProps>(
({ roomId, powerLevels, requestClose }, ref) => {
const mx = useMatrixClient();
const { getPowerLevel, canDoAction } = usePowerLevelsAPI(powerLevels);
const canInvite = canDoAction('invite', getPowerLevel(mx.getUserId() ?? ''));
const handleInvite = () => {
openInviteUser(roomId);
requestClose();
};
const handleRoomSettings = () => {
openSpaceSettings(roomId);
requestClose();
};
return (
<Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}>
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
<MenuItem
onClick={handleInvite}
variant="Primary"
fill="None"
size="300"
after={<Icon size="100" src={Icons.UserPlus} />}
radii="300"
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 }}>
<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={roomId}
onDone={requestClose}
onCancel={() => setPromptLeave(false)}
/>
)}
</>
)}
</UseStateProvider>
</Box>
</Menu>
);
}
);
type LobbyHeaderProps = {
showProfile?: boolean;
powerLevels: IPowerLevels;
};
export function LobbyHeader({ showProfile, powerLevels }: LobbyHeaderProps) {
const mx = useMatrixClient();
const space = useSpace();
const setPeopleDrawer = useSetSetting(settingsAtom, 'isPeopleDrawer');
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
const name = useRoomName(space);
const avatarMxc = useRoomAvatar(space);
const avatarUrl = avatarMxc ? mx.mxcUrlToHttp(avatarMxc, 96, 96, 'crop') ?? undefined : undefined;
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
setMenuAnchor(evt.currentTarget.getBoundingClientRect());
};
return (
<PageHeader className={showProfile ? undefined : css.Header}>
<Box grow="Yes" alignItems="Center" gap="200">
<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="Yes" basis="No" justifyContent="End">
<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',
}}
>
<LobbyMenu
roomId={space.roomId}
powerLevels={powerLevels}
requestClose={() => setMenuAnchor(undefined)}
/>
</FocusTrap>
}
/>
</Box>
</Box>
</PageHeader>
);
}

View file

@ -0,0 +1,15 @@
import { style } from '@vanilla-extract/css';
import { config } from 'folds';
export const LobbyHeroTopic = style({
display: '-webkit-box',
WebkitLineClamp: 3,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
':hover': {
cursor: 'pointer',
opacity: config.opacity.P500,
textDecoration: 'underline',
},
});

View file

@ -0,0 +1,77 @@
import React from 'react';
import { Avatar, Overlay, OverlayBackdrop, OverlayCenter, Text } from 'folds';
import FocusTrap from 'focus-trap-react';
import { useRoomAvatar, useRoomName, useRoomTopic } 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 { UseStateProvider } from '../../components/UseStateProvider';
import { RoomTopicViewer } from '../../components/room-topic-viewer';
import * as css from './LobbyHero.css';
import { PageHero } from '../../components/page';
import { onEnterOrSpace } from '../../utils/keyboard';
export function LobbyHero() {
const mx = useMatrixClient();
const space = useSpace();
const name = useRoomName(space);
const topic = useRoomTopic(space);
const avatarMxc = useRoomAvatar(space);
const avatarUrl = avatarMxc ? mx.mxcUrlToHttp(avatarMxc, 96, 96, 'crop') ?? undefined : undefined;
return (
<PageHero
icon={
<Avatar size="500">
<RoomAvatar
roomId={space.roomId}
src={avatarUrl}
alt={name}
renderFallback={() => <Text size="H4">{nameInitials(name)}</Text>}
/>
</Avatar>
}
title={name}
subTitle={
topic && (
<UseStateProvider initial={false}>
{(viewTopic, setViewTopic) => (
<>
<Overlay open={viewTopic} backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
clickOutsideDeactivates: true,
onDeactivate: () => setViewTopic(false),
}}
>
<RoomTopicViewer
name={name}
topic={topic}
requestClose={() => setViewTopic(false)}
/>
</FocusTrap>
</OverlayCenter>
</Overlay>
<Text
as="span"
onClick={() => setViewTopic(true)}
onKeyDown={onEnterOrSpace(() => setViewTopic(true))}
tabIndex={0}
className={css.LobbyHeroTopic}
size="Inherit"
priority="300"
>
{topic}
</Text>
</>
)}
</UseStateProvider>
)
}
/>
);
}

View file

@ -0,0 +1,22 @@
import { style } from '@vanilla-extract/css';
import { config, toRem } from 'folds';
export const RoomItemCard = style({
padding: config.space.S400,
borderRadius: 0,
position: 'relative',
selectors: {
'&[data-dragging=true]': {
opacity: config.opacity.Disabled,
},
},
});
export const RoomProfileTopic = style({
cursor: 'pointer',
':hover': {
textDecoration: 'underline',
},
});
export const ErrorNameContainer = style({
gap: toRem(2),
});

View file

@ -0,0 +1,441 @@
import React, { MouseEventHandler, ReactNode, useCallback, useEffect, useRef } from 'react';
import {
Avatar,
Badge,
Box,
Chip,
Icon,
Icons,
Line,
Overlay,
OverlayBackdrop,
OverlayCenter,
Spinner,
Text,
Tooltip,
TooltipProvider,
as,
color,
toRem,
} from 'folds';
import FocusTrap from 'focus-trap-react';
import { JoinRule, MatrixError, Room } from 'matrix-js-sdk';
import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
import { SequenceCard } from '../../components/sequence-card';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { HierarchyItem } from '../../hooks/useSpaceHierarchy';
import { millify } from '../../plugins/millify';
import {
HierarchyRoomSummaryLoader,
LocalRoomSummaryLoader,
} from '../../components/RoomSummaryLoader';
import { UseStateProvider } from '../../components/UseStateProvider';
import { RoomTopicViewer } from '../../components/room-topic-viewer';
import { onEnterOrSpace } from '../../utils/keyboard';
import { Membership, RoomType } from '../../../types/matrix/room';
import * as css from './RoomItem.css';
import * as styleCss from './style.css';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { ErrorCode } from '../../cs-errorcode';
import { getDirectRoomAvatarUrl, getRoomAvatarUrl } from '../../utils/room';
import { ItemDraggableTarget, useDraggableItem } from './DnD';
type RoomJoinButtonProps = {
roomId: string;
via?: string[];
};
function RoomJoinButton({ roomId, via }: RoomJoinButtonProps) {
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 (
<Box shrink="No" gap="200" alignItems="Center">
{joinState.status === AsyncStatus.Error && (
<TooltipProvider
tooltip={
<Tooltip variant="Critical" style={{ maxWidth: toRem(200) }}>
<Box direction="Column" gap="100">
<Text style={{ wordBreak: 'break-word' }} size="T400">
{joinState.error.data?.error || joinState.error.message}
</Text>
<Text size="T200">{joinState.error.name}</Text>
</Box>
</Tooltip>
}
>
{(triggerRef) => (
<Icon
ref={triggerRef}
style={{ color: color.Critical.Main, cursor: 'pointer' }}
src={Icons.Warning}
size="400"
filled
tabIndex={0}
aria-label={joinState.error.data?.error || joinState.error.message}
/>
)}
</TooltipProvider>
)}
<Chip
variant="Secondary"
fill="Soft"
size="400"
radii="Pill"
before={
canJoin ? <Icon src={Icons.Plus} size="50" /> : <Spinner variant="Secondary" size="100" />
}
onClick={join}
disabled={!canJoin}
>
<Text size="B300">Join</Text>
</Chip>
</Box>
);
}
function RoomProfileLoading() {
return (
<Box grow="Yes" gap="300">
<Avatar className={styleCss.AvatarPlaceholder} />
<Box grow="Yes" direction="Column" gap="100">
<Box gap="200" alignItems="Center">
<Box className={styleCss.LinePlaceholder} shrink="No" style={{ maxWidth: toRem(80) }} />
</Box>
<Box gap="200" alignItems="Center">
<Box className={styleCss.LinePlaceholder} shrink="No" style={{ maxWidth: toRem(40) }} />
<Box
className={styleCss.LinePlaceholder}
shrink="No"
style={{
maxWidth: toRem(120),
}}
/>
</Box>
</Box>
</Box>
);
}
type RoomProfileErrorProps = {
roomId: string;
error: Error;
suggested?: boolean;
via?: string[];
};
function RoomProfileError({ roomId, suggested, error, via }: RoomProfileErrorProps) {
const privateRoom = error.name === ErrorCode.M_FORBIDDEN;
return (
<Box grow="Yes" gap="300">
<Avatar>
<RoomAvatar
roomId={roomId}
src={undefined}
alt={roomId}
renderFallback={() => (
<RoomIcon
size="300"
joinRule={privateRoom ? JoinRule.Invite : JoinRule.Restricted}
filled
/>
)}
/>
</Avatar>
<Box grow="Yes" direction="Column" className={css.ErrorNameContainer}>
<Box gap="200" alignItems="Center">
<Text size="H5" truncate>
Unknown
</Text>
{suggested && (
<Box shrink="No" alignItems="Center">
<Badge variant="Success" fill="Soft" radii="Pill" outlined>
<Text size="L400">Suggested</Text>
</Badge>
</Box>
)}
</Box>
<Box gap="200" alignItems="Center">
{privateRoom && (
<>
<Badge variant="Secondary" fill="Soft" radii="Pill" outlined>
<Text size="L400">Private Room</Text>
</Badge>
<Line
variant="SurfaceVariant"
style={{ height: toRem(12) }}
direction="Vertical"
size="400"
/>
</>
)}
<Text size="T200" truncate>
{roomId}
</Text>
</Box>
</Box>
{!privateRoom && <RoomJoinButton roomId={roomId} via={via} />}
</Box>
);
}
type RoomProfileProps = {
roomId: string;
name: string;
topic?: string;
avatarUrl?: string;
suggested?: boolean;
memberCount?: number;
joinRule?: JoinRule;
options?: ReactNode;
};
function RoomProfile({
roomId,
name,
topic,
avatarUrl,
suggested,
memberCount,
joinRule,
options,
}: RoomProfileProps) {
return (
<Box grow="Yes" gap="300">
<Avatar>
<RoomAvatar
roomId={roomId}
src={avatarUrl}
alt={name}
renderFallback={() => (
<RoomIcon size="300" joinRule={joinRule ?? JoinRule.Restricted} filled />
)}
/>
</Avatar>
<Box grow="Yes" direction="Column">
<Box gap="200" alignItems="Center">
<Text size="H5" truncate>
{name}
</Text>
{suggested && (
<Box shrink="No" alignItems="Center">
<Badge variant="Success" fill="Soft" radii="Pill" outlined>
<Text size="L400">Suggested</Text>
</Badge>
</Box>
)}
</Box>
<Box gap="200" alignItems="Center">
{memberCount && (
<Box shrink="No" gap="200">
<Text size="T200" priority="300">{`${millify(memberCount)} Members`}</Text>
</Box>
)}
{memberCount && topic && (
<Line
variant="SurfaceVariant"
style={{ height: toRem(12) }}
direction="Vertical"
size="400"
/>
)}
{topic && (
<UseStateProvider initial={false}>
{(view, setView) => (
<>
<Text
className={css.RoomProfileTopic}
size="T200"
priority="300"
truncate
onClick={() => setView(true)}
onKeyDown={onEnterOrSpace(() => setView(true))}
tabIndex={0}
>
{topic}
</Text>
<Overlay open={view} backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
clickOutsideDeactivates: true,
onDeactivate: () => setView(false),
}}
>
<RoomTopicViewer
name={name}
topic={topic}
requestClose={() => setView(false)}
/>
</FocusTrap>
</OverlayCenter>
</Overlay>
</>
)}
</UseStateProvider>
)}
</Box>
</Box>
{options}
</Box>
);
}
function CallbackOnFoundSpace({
roomId,
onSpaceFound,
}: {
roomId: string;
onSpaceFound: (roomId: string) => void;
}) {
useEffect(() => {
onSpaceFound(roomId);
}, [roomId, onSpaceFound]);
return null;
}
type RoomItemCardProps = {
item: HierarchyItem;
onSpaceFound: (roomId: string) => void;
dm?: boolean;
firstChild?: boolean;
lastChild?: boolean;
onOpen: MouseEventHandler<HTMLButtonElement>;
options?: ReactNode;
before?: ReactNode;
after?: ReactNode;
onDragging: (item?: HierarchyItem) => void;
canReorder: boolean;
getRoom: (roomId: string) => Room | undefined;
};
export const RoomItemCard = as<'div', RoomItemCardProps>(
(
{
item,
onSpaceFound,
dm,
firstChild,
lastChild,
onOpen,
options,
before,
after,
onDragging,
canReorder,
getRoom,
...props
},
ref
) => {
const mx = useMatrixClient();
const { roomId, content } = item;
const room = getRoom(roomId);
const targetRef = useRef<HTMLDivElement>(null);
const targetHandleRef = useRef<HTMLDivElement>(null);
useDraggableItem(item, targetRef, onDragging, targetHandleRef);
const joined = room?.getMyMembership() === Membership.Join;
return (
<SequenceCard
className={css.RoomItemCard}
firstChild={firstChild}
lastChild={lastChild}
variant="SurfaceVariant"
gap="300"
alignItems="Center"
{...props}
ref={ref}
>
{before}
<Box ref={canReorder ? targetRef : null} grow="Yes">
{canReorder && <ItemDraggableTarget ref={targetHandleRef} />}
{room ? (
<LocalRoomSummaryLoader room={room}>
{(localSummary) => (
<RoomProfile
roomId={roomId}
name={localSummary.name}
topic={localSummary.topic}
avatarUrl={
dm ? getDirectRoomAvatarUrl(mx, room, 96) : getRoomAvatarUrl(mx, room, 96)
}
memberCount={localSummary.memberCount}
suggested={content.suggested}
joinRule={localSummary.joinRule}
options={
joined ? (
<Box shrink="No" gap="100" alignItems="Center">
<Chip
data-room-id={roomId}
onClick={onOpen}
variant="Secondary"
fill="None"
size="400"
radii="Pill"
aria-label="Open Room"
>
<Icon size="50" src={Icons.ArrowRight} />
</Chip>
</Box>
) : (
<RoomJoinButton roomId={roomId} via={content.via} />
)
}
/>
)}
</LocalRoomSummaryLoader>
) : (
<HierarchyRoomSummaryLoader roomId={roomId}>
{(summaryState) => (
<>
{summaryState.status === AsyncStatus.Loading && <RoomProfileLoading />}
{summaryState.status === AsyncStatus.Error && (
<RoomProfileError
roomId={roomId}
error={summaryState.error}
suggested={content.suggested}
via={content.via}
/>
)}
{summaryState.status === AsyncStatus.Success && (
<>
{summaryState.data.room_type === RoomType.Space && (
<CallbackOnFoundSpace
roomId={summaryState.data.room_id}
onSpaceFound={onSpaceFound}
/>
)}
<RoomProfile
roomId={roomId}
name={summaryState.data.name || summaryState.data.canonical_alias || roomId}
topic={summaryState.data.topic}
avatarUrl={
summaryState.data?.avatar_url
? mx.mxcUrlToHttp(summaryState.data.avatar_url, 96, 96, 'crop') ??
undefined
: undefined
}
memberCount={summaryState.data.num_joined_members}
suggested={content.suggested}
joinRule={summaryState.data.join_rule}
options={<RoomJoinButton roomId={roomId} via={content.via} />}
/>
</>
)}
</>
)}
</HierarchyRoomSummaryLoader>
)}
</Box>
{options}
{after}
</SequenceCard>
);
}
);

View file

@ -0,0 +1,39 @@
import { style } from '@vanilla-extract/css';
import { color, config, toRem } from 'folds';
import { recipe } from '@vanilla-extract/recipes';
export const SpaceItemCard = recipe({
base: {
paddingBottom: config.space.S100,
borderBottom: `${config.borderWidth.B300} solid transparent`,
position: 'relative',
selectors: {
'&[data-dragging=true]': {
opacity: config.opacity.Disabled,
},
},
},
variants: {
outlined: {
true: {
borderBottomColor: color.Surface.ContainerLine,
},
},
},
});
export const HeaderChip = style({
paddingLeft: config.space.S200,
selectors: {
[`&[data-ui-before="true"]`]: {
paddingLeft: config.space.S100,
},
},
});
export const HeaderChipPlaceholder = style([
{
borderRadius: config.radii.R400,
paddingLeft: config.space.S100,
paddingRight: config.space.S300,
height: toRem(32),
},
]);

View file

@ -0,0 +1,493 @@
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 { HierarchyItem } from '../../hooks/useSpaceHierarchy';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { RoomAvatar } from '../../components/room-avatar';
import { nameInitials } from '../../utils/common';
import {
HierarchyRoomSummaryLoader,
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 { ErrorCode } from '../../cs-errorcode';
import { useDraggableItem } from './DnD';
import { openCreateRoom, openSpaceAddExisting } from '../../../client/action/navigation';
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 UnknownPrivateSpaceProfileProps = {
roomId: string;
name?: string;
avatarUrl?: string;
suggested?: boolean;
};
function UnknownPrivateSpaceProfile({
roomId,
name,
avatarUrl,
suggested,
}: UnknownPrivateSpaceProfileProps) {
return (
<Chip
as="span"
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>
}
>
<Box alignItems="Center" gap="200">
<Text size="H4" truncate>
{name || 'Unknown'}
</Text>
<Badge variant="Secondary" fill="Soft" radii="Pill" outlined>
<Text size="L400">Private Space</Text>
</Badge>
{suggested && (
<Badge variant="Success" fill="Soft" radii="Pill" outlined>
<Text size="L400">Suggested</Text>
</Badge>
)}
</Box>
</Chip>
);
}
type UnknownSpaceProfileProps = {
roomId: string;
via?: string[];
name?: string;
avatarUrl?: string;
suggested?: boolean;
};
function UnknownSpaceProfile({
roomId,
via,
name,
avatarUrl,
suggested,
}: UnknownSpaceProfileProps) {
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 handleAddRoom: MouseEventHandler<HTMLButtonElement> = (evt) => {
setCords(evt.currentTarget.getBoundingClientRect());
};
const handleCreateRoom = () => {
openCreateRoom(false, item.roomId as any);
setCords(undefined);
};
const handleAddExisting = () => {
openSpaceAddExisting(item.roomId);
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',
}}
>
<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>
</PopOut>
);
}
function AddSpaceButton({ item }: { item: HierarchyItem }) {
const [cords, setCords] = useState<RectCords>();
const handleAddSpace: MouseEventHandler<HTMLButtonElement> = (evt) => {
setCords(evt.currentTarget.getBoundingClientRect());
};
const handleCreateSpace = () => {
openCreateRoom(true, item.roomId as any);
setCords(undefined);
};
const handleAddExisting = () => {
openSpaceAddExisting(item.roomId, 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',
}}
>
<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>
</PopOut>
);
}
type SpaceItemCardProps = {
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,
joined,
closed,
categoryId,
item,
handleClose,
options,
before,
after,
canEditChild,
canReorder,
onDragging,
getRoom,
...props
},
ref
) => {
const mx = useMatrixClient();
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)}
suggested={content.suggested}
closed={closed}
categoryId={categoryId}
handleClose={handleClose}
/>
) : (
<RootSpaceProfile
closed={closed}
categoryId={categoryId}
handleClose={handleClose}
/>
)
}
</LocalRoomSummaryLoader>
) : (
<HierarchyRoomSummaryLoader roomId={roomId}>
{(summaryState) => (
<>
{summaryState.status === AsyncStatus.Loading && <SpaceProfileLoading />}
{summaryState.status === AsyncStatus.Error &&
(summaryState.error.name === ErrorCode.M_FORBIDDEN ? (
<UnknownPrivateSpaceProfile roomId={roomId} suggested={content.suggested} />
) : (
<UnknownSpaceProfile
roomId={roomId}
via={item.content.via}
suggested={content.suggested}
/>
))}
{summaryState.status === AsyncStatus.Success && (
<UnknownSpaceProfile
roomId={roomId}
via={item.content.via}
name={summaryState.data.name || summaryState.data.canonical_alias || roomId}
avatarUrl={
summaryState.data?.avatar_url
? mx.mxcUrlToHttp(summaryState.data.avatar_url, 96, 96, 'crop') ??
undefined
: undefined
}
suggested={content.suggested}
/>
)}
</>
)}
</HierarchyRoomSummaryLoader>
)}
</Box>
{canEditChild && (
<Box alignItems="Inherit" gap="200">
<AddRoomButton item={item} />
{item.parentId === undefined && <AddSpaceButton item={item} />}
</Box>
)}
</Box>
{options}
{after}
</Box>
);
}
);

View file

@ -0,0 +1 @@
export * from './Lobby';

View file

@ -0,0 +1,15 @@
import { style } from '@vanilla-extract/css';
import { DefaultReset, color, config } from 'folds';
export const AvatarPlaceholder = style({
backgroundColor: color.Secondary.Container,
});
export const LinePlaceholder = style([
DefaultReset,
{
width: '100%',
height: config.lineHeight.T200,
borderRadius: config.radii.R300,
backgroundColor: color.Secondary.Container,
},
]);