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,14 @@
import { ReactNode } from 'react';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useBindAtoms } from '../../state/hooks/useBindAtoms';
type ClientBindAtomsProps = {
children: ReactNode;
};
export function ClientBindAtoms({ children }: ClientBindAtomsProps) {
const mx = useMatrixClient();
useBindAtoms(mx);
return children;
}

View file

@ -0,0 +1,38 @@
import React, { ReactNode, useMemo } from 'react';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { makeClosedNavCategoriesAtom } from '../../state/closedNavCategories';
import { ClosedNavCategoriesProvider } from '../../state/hooks/closedNavCategories';
import { makeClosedLobbyCategoriesAtom } from '../../state/closedLobbyCategories';
import { ClosedLobbyCategoriesProvider } from '../../state/hooks/closedLobbyCategories';
import { makeNavToActivePathAtom } from '../../state/navToActivePath';
import { NavToActivePathProvider } from '../../state/hooks/navToActivePath';
import { makeOpenedSidebarFolderAtom } from '../../state/openedSidebarFolder';
import { OpenedSidebarFolderProvider } from '../../state/hooks/openedSidebarFolder';
type ClientInitStorageAtomProps = {
children: ReactNode;
};
export function ClientInitStorageAtom({ children }: ClientInitStorageAtomProps) {
const mx = useMatrixClient();
const userId = mx.getUserId()!;
const closedNavCategoriesAtom = useMemo(() => makeClosedNavCategoriesAtom(userId), [userId]);
const closedLobbyCategoriesAtom = useMemo(() => makeClosedLobbyCategoriesAtom(userId), [userId]);
const navToActivePathAtom = useMemo(() => makeNavToActivePathAtom(userId), [userId]);
const openedSidebarFolderAtom = useMemo(() => makeOpenedSidebarFolderAtom(userId), [userId]);
return (
<ClosedNavCategoriesProvider value={closedNavCategoriesAtom}>
<ClosedLobbyCategoriesProvider value={closedLobbyCategoriesAtom}>
<NavToActivePathProvider value={navToActivePathAtom}>
<OpenedSidebarFolderProvider value={openedSidebarFolderAtom}>
{children}
</OpenedSidebarFolderProvider>
</NavToActivePathProvider>
</ClosedLobbyCategoriesProvider>
</ClosedNavCategoriesProvider>
);
}

View file

@ -0,0 +1,15 @@
import React, { ReactNode } from 'react';
import { Box } from 'folds';
type ClientLayoutProps = {
nav: ReactNode;
children: ReactNode;
};
export function ClientLayout({ nav, children }: ClientLayoutProps) {
return (
<Box style={{ height: '100%' }}>
<Box shrink="No">{nav}</Box>
<Box grow="Yes">{children}</Box>
</Box>
);
}

View file

@ -0,0 +1,87 @@
import { Box, Spinner, Text } from 'folds';
import React, { ReactNode, useEffect, useState } from 'react';
import initMatrix from '../../../client/initMatrix';
import { initHotkeys } from '../../../client/event/hotkeys';
import { initRoomListListener } from '../../../client/event/roomList';
import { getSecret } from '../../../client/state/auth';
import { SplashScreen } from '../../components/splash-screen';
import { CapabilitiesAndMediaConfigLoader } from '../../components/CapabilitiesAndMediaConfigLoader';
import { CapabilitiesProvider } from '../../hooks/useCapabilities';
import { MediaConfigProvider } from '../../hooks/useMediaConfig';
import { MatrixClientProvider } from '../../hooks/useMatrixClient';
import { SpecVersions } from './SpecVersions';
import Windows from '../../organisms/pw/Windows';
import Dialogs from '../../organisms/pw/Dialogs';
import ReusableContextMenu from '../../atoms/context-menu/ReusableContextMenu';
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
function SystemEmojiFeature() {
const [twitterEmoji] = useSetting(settingsAtom, 'twitterEmoji');
if (twitterEmoji) {
document.documentElement.style.setProperty('--font-emoji', 'Twemoji');
} else {
document.documentElement.style.setProperty('--font-emoji', 'Twemoji_DISABLED');
}
return null;
}
function ClientRootLoading() {
return (
<SplashScreen>
<Box direction="Column" grow="Yes" alignItems="Center" justifyContent="Center" gap="400">
<Spinner variant="Secondary" size="600" />
<Text>Heating up</Text>
</Box>
</SplashScreen>
);
}
type ClientRootProps = {
children: ReactNode;
};
export function ClientRoot({ children }: ClientRootProps) {
const [loading, setLoading] = useState(true);
const { baseUrl } = getSecret();
useEffect(() => {
const handleStart = () => {
initHotkeys();
initRoomListListener(initMatrix.roomList);
setLoading(false);
};
initMatrix.once('init_loading_finished', handleStart);
if (!initMatrix.matrixClient) initMatrix.init();
return () => {
initMatrix.removeListener('init_loading_finished', handleStart);
};
}, []);
return (
<SpecVersions baseUrl={baseUrl!}>
{loading ? (
<ClientRootLoading />
) : (
<MatrixClientProvider value={initMatrix.matrixClient!}>
<CapabilitiesAndMediaConfigLoader>
{(capabilities, mediaConfig) => (
<CapabilitiesProvider value={capabilities ?? {}}>
<MediaConfigProvider value={mediaConfig ?? {}}>
{children}
{/* TODO: remove these components after navigation refactor */}
<Windows />
<Dialogs />
<ReusableContextMenu />
<SystemEmojiFeature />
</MediaConfigProvider>
</CapabilitiesProvider>
)}
</CapabilitiesAndMediaConfigLoader>
</MatrixClientProvider>
)}
</SpecVersions>
);
}

View file

@ -0,0 +1,76 @@
import React, { useRef } from 'react';
import { Icon, Icons, Scroll } from 'folds';
import {
Sidebar,
SidebarContent,
SidebarStackSeparator,
SidebarStack,
SidebarAvatar,
SidebarItemTooltip,
SidebarItem,
} from '../../components/sidebar';
import { DirectTab, HomeTab, SpaceTabs, InboxTab, ExploreTab, UserTab } from './sidebar';
import { openCreateRoom, openSearch } from '../../../client/action/navigation';
export function SidebarNav() {
const scrollRef = useRef<HTMLDivElement>(null);
return (
<Sidebar>
<SidebarContent
scrollable={
<Scroll ref={scrollRef} variant="Background" size="0">
<SidebarStack>
<HomeTab />
<DirectTab />
</SidebarStack>
<SpaceTabs scrollRef={scrollRef} />
<SidebarStackSeparator />
<SidebarStack>
<ExploreTab />
<SidebarItem>
<SidebarItemTooltip tooltip="Create Space">
{(triggerRef) => (
<SidebarAvatar
as="button"
ref={triggerRef}
outlined
onClick={() => openCreateRoom(true)}
>
<Icon src={Icons.Plus} />
</SidebarAvatar>
)}
</SidebarItemTooltip>
</SidebarItem>
</SidebarStack>
</Scroll>
}
sticky={
<>
<SidebarStackSeparator />
<SidebarStack>
<SidebarItem>
<SidebarItemTooltip tooltip="Search">
{(triggerRef) => (
<SidebarAvatar
as="button"
ref={triggerRef}
outlined
onClick={() => openSearch()}
>
<Icon src={Icons.Search} />
</SidebarAvatar>
)}
</SidebarItemTooltip>
</SidebarItem>
<InboxTab />
<UserTab />
</SidebarStack>
</>
}
/>
</Sidebar>
);
}

View file

@ -0,0 +1,46 @@
import React, { ReactNode } from 'react';
import { Box, Dialog, config, Text, Button, Spinner } from 'folds';
import { SpecVersionsLoader } from '../../components/SpecVersionsLoader';
import { SpecVersionsProvider } from '../../hooks/useSpecVersions';
import { SplashScreen } from '../../components/splash-screen';
export function SpecVersions({ baseUrl, children }: { baseUrl: string; children: ReactNode }) {
return (
<SpecVersionsLoader
baseUrl={baseUrl}
fallback={() => (
<SplashScreen>
<Box direction="Column" grow="Yes" alignItems="Center" justifyContent="Center" gap="400">
<Spinner variant="Secondary" size="600" />
<Text>Connecting to server</Text>
</Box>
</SplashScreen>
)}
error={(err, retry, ignore) => (
<SplashScreen>
<Box direction="Column" grow="Yes" alignItems="Center" justifyContent="Center" gap="400">
<Dialog>
<Box direction="Column" gap="400" style={{ padding: config.space.S400 }}>
<Text>
Failed to connect to homeserver. Either homeserver is down or your internet.
</Text>
<Button variant="Critical" onClick={retry}>
<Text as="span" size="B400">
Retry
</Text>
</Button>
<Button variant="Critical" onClick={ignore} fill="Soft">
<Text as="span" size="B400">
Continue
</Text>
</Button>
</Box>
</Dialog>
</Box>
</SplashScreen>
)}
>
{(versions) => <SpecVersionsProvider value={versions}>{children}</SpecVersionsProvider>}
</SpecVersionsLoader>
);
}

View file

@ -0,0 +1,64 @@
import React from 'react';
import { Box, Button, Icon, Icons, Text, config, toRem } from 'folds';
import { Page, PageHero, PageHeroSection } from '../../components/page';
import CinnySVG from '../../../../public/res/svg/cinny.svg';
export function WelcomePage() {
return (
<Page>
<Box
grow="Yes"
style={{ padding: config.space.S400, paddingBottom: config.space.S700 }}
alignItems="Center"
justifyContent="Center"
>
<PageHeroSection>
<PageHero
icon={<img width="70" height="70" src={CinnySVG} alt="Cinny Logo" />}
title="Welcome to Cinny"
subTitle={
<span>
Yet anothor matrix client.{' '}
<a
href="https://github.com/cinnyapp/cinny/releases"
target="_blank"
rel="noreferrer noopener"
>
v3.2.0
</a>
</span>
}
>
<Box justifyContent="Center">
<Box grow="Yes" style={{ maxWidth: toRem(300) }} direction="Column" gap="300">
<Button
as="a"
href="https://github.com/cinnyapp/cinny"
target="_blank"
rel="noreferrer noopener"
before={<Icon size="200" src={Icons.Code} />}
>
<Text as="span" size="B400" truncate>
Source Code
</Text>
</Button>
<Button
as="a"
href="https://cinny.in/#sponsor"
target="_blank"
rel="noreferrer noopener"
fill="Soft"
before={<Icon size="200" src={Icons.Heart} />}
>
<Text as="span" size="B400" truncate>
Support
</Text>
</Button>
</Box>
</Box>
</PageHero>
</PageHeroSection>
</Box>
</Page>
);
}

View file

@ -0,0 +1,263 @@
import React, { MouseEventHandler, forwardRef, useMemo, useRef, useState } from 'react';
import { useAtom, useAtomValue } from 'jotai';
import {
Avatar,
Box,
Button,
Icon,
IconButton,
Icons,
Menu,
MenuItem,
PopOut,
RectCords,
Text,
config,
toRem,
} from 'folds';
import { useVirtualizer } from '@tanstack/react-virtual';
import FocusTrap from 'focus-trap-react';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { factoryRoomIdByActivity } from '../../../utils/sort';
import {
NavButton,
NavCategory,
NavCategoryHeader,
NavEmptyCenter,
NavEmptyLayout,
NavItem,
NavItemContent,
} from '../../../components/nav';
import { getDirectRoomPath } from '../../pathUtils';
import { getCanonicalAliasOrRoomId } from '../../../utils/matrix';
import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom';
import { VirtualTile } from '../../../components/virtualizer';
import { RoomNavCategoryButton, RoomNavItem } from '../../../features/room-nav';
import { muteChangesAtom } from '../../../state/room-list/mutedRoomList';
import { makeNavCategoryId } from '../../../state/closedNavCategories';
import { roomToUnreadAtom } from '../../../state/room/roomToUnread';
import { useCategoryHandler } from '../../../hooks/useCategoryHandler';
import { useNavToActivePathMapper } from '../../../hooks/useNavToActivePathMapper';
import { useDirectRooms } from './useDirectRooms';
import { openInviteUser } from '../../../../client/action/navigation';
import { PageNav, PageNavContent, PageNavHeader } from '../../../components/page';
import { useClosedNavCategoriesAtom } from '../../../state/hooks/closedNavCategories';
import { useRoomsUnread } from '../../../state/hooks/unread';
import { markAsRead } from '../../../../client/action/notifications';
type DirectMenuProps = {
requestClose: () => void;
};
const DirectMenu = forwardRef<HTMLDivElement, DirectMenuProps>(({ requestClose }, ref) => {
const orphanRooms = useDirectRooms();
const unread = useRoomsUnread(orphanRooms, roomToUnreadAtom);
const handleMarkAsRead = () => {
if (!unread) return;
orphanRooms.forEach((rId) => markAsRead(rId));
requestClose();
};
return (
<Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}>
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
<MenuItem
onClick={handleMarkAsRead}
size="300"
after={<Icon size="100" src={Icons.CheckTwice} />}
radii="300"
aria-disabled={!unread}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Mark as Read
</Text>
</MenuItem>
</Box>
</Menu>
);
});
function DirectHeader() {
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
const cords = evt.currentTarget.getBoundingClientRect();
setMenuAnchor((currentState) => {
if (currentState) return undefined;
return cords;
});
};
return (
<>
<PageNavHeader>
<Box alignItems="Center" grow="Yes" gap="300">
<Box grow="Yes">
<Text size="H4" truncate>
Direct Messages
</Text>
</Box>
<Box>
<IconButton aria-pressed={!!menuAnchor} variant="Background" onClick={handleOpenMenu}>
<Icon src={Icons.VerticalDots} size="200" />
</IconButton>
</Box>
</Box>
</PageNavHeader>
<PopOut
anchor={menuAnchor}
position="Bottom"
align="End"
offset={6}
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
returnFocusOnDeactivate: false,
onDeactivate: () => setMenuAnchor(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
}}
>
<DirectMenu requestClose={() => setMenuAnchor(undefined)} />
</FocusTrap>
}
/>
</>
);
}
function DirectEmpty() {
return (
<NavEmptyCenter>
<NavEmptyLayout
icon={<Icon size="600" src={Icons.Mention} />}
title={
<Text size="H5" align="Center">
No Direct Messages
</Text>
}
content={
<Text size="T300" align="Center">
You do not have any direct messages yet.
</Text>
}
options={
<Button variant="Secondary" size="300" onClick={() => openInviteUser()}>
<Text size="B300" truncate>
Direct Message
</Text>
</Button>
}
/>
</NavEmptyCenter>
);
}
const DEFAULT_CATEGORY_ID = makeNavCategoryId('direct', 'direct');
export function Direct() {
const mx = useMatrixClient();
useNavToActivePathMapper('direct');
const scrollRef = useRef<HTMLDivElement>(null);
const directs = useDirectRooms();
const muteChanges = useAtomValue(muteChangesAtom);
const mutedRooms = muteChanges.added;
const roomToUnread = useAtomValue(roomToUnreadAtom);
const selectedRoomId = useSelectedRoom();
const noRoomToDisplay = directs.length === 0;
const [closedCategories, setClosedCategories] = useAtom(useClosedNavCategoriesAtom());
const sortedDirects = useMemo(() => {
const items = Array.from(directs).sort(factoryRoomIdByActivity(mx));
if (closedCategories.has(DEFAULT_CATEGORY_ID)) {
return items.filter((rId) => roomToUnread.has(rId) || rId === selectedRoomId);
}
return items;
}, [mx, directs, closedCategories, roomToUnread, selectedRoomId]);
const virtualizer = useVirtualizer({
count: sortedDirects.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => 38,
overscan: 10,
});
const handleCategoryClick = useCategoryHandler(setClosedCategories, (categoryId) =>
closedCategories.has(categoryId)
);
return (
<PageNav>
<DirectHeader />
{noRoomToDisplay ? (
<DirectEmpty />
) : (
<PageNavContent scrollRef={scrollRef}>
<Box direction="Column" gap="300">
<NavCategory>
<NavItem variant="Background" radii="400">
<NavButton onClick={() => openInviteUser()}>
<NavItemContent>
<Box as="span" grow="Yes" alignItems="Center" gap="200">
<Avatar size="200" radii="400">
<Icon src={Icons.Plus} size="100" />
</Avatar>
<Box as="span" grow="Yes">
<Text as="span" size="Inherit" truncate>
Create Chat
</Text>
</Box>
</Box>
</NavItemContent>
</NavButton>
</NavItem>
</NavCategory>
<NavCategory>
<NavCategoryHeader>
<RoomNavCategoryButton
closed={closedCategories.has(DEFAULT_CATEGORY_ID)}
data-category-id={DEFAULT_CATEGORY_ID}
onClick={handleCategoryClick}
>
Chats
</RoomNavCategoryButton>
</NavCategoryHeader>
<div
style={{
position: 'relative',
height: virtualizer.getTotalSize(),
}}
>
{virtualizer.getVirtualItems().map((vItem) => {
const roomId = sortedDirects[vItem.index];
const room = mx.getRoom(roomId);
if (!room) return null;
const selected = selectedRoomId === roomId;
return (
<VirtualTile
virtualItem={vItem}
key={vItem.index}
ref={virtualizer.measureElement}
>
<RoomNavItem
room={room}
selected={selected}
showAvatar
direct
linkPath={getDirectRoomPath(getCanonicalAliasOrRoomId(mx, roomId))}
muted={mutedRooms.includes(roomId)}
/>
</VirtualTile>
);
})}
</div>
</NavCategory>
</Box>
</PageNavContent>
)}
</PageNav>
);
}

View file

@ -0,0 +1,26 @@
import React, { ReactNode } from 'react';
import { useParams } from 'react-router-dom';
import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom';
import { RoomProvider } from '../../../hooks/useRoom';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { JoinBeforeNavigate } from '../../../features/join-before-navigate';
import { useDirectRooms } from './useDirectRooms';
export function DirectRouteRoomProvider({ children }: { children: ReactNode }) {
const mx = useMatrixClient();
const rooms = useDirectRooms();
const { roomIdOrAlias } = useParams();
const roomId = useSelectedRoom();
const room = mx.getRoom(roomId);
if (!room || !rooms.includes(room.roomId)) {
return <JoinBeforeNavigate roomIdOrAlias={roomIdOrAlias!} />;
}
return (
<RoomProvider key={room.roomId} value={room}>
{children}
</RoomProvider>
);
}

View file

@ -0,0 +1,2 @@
export * from './Direct';
export * from './RoomProvider';

View file

@ -0,0 +1,12 @@
import { useAtomValue } from 'jotai';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { mDirectAtom } from '../../../state/mDirectList';
import { allRoomsAtom } from '../../../state/room-list/roomList';
import { useDirects } from '../../../state/hooks/roomList';
export const useDirectRooms = () => {
const mx = useMatrixClient();
const mDirects = useAtomValue(mDirectAtom);
const directs = useDirects(mx, allRoomsAtom, mDirects);
return directs;
};

View file

@ -0,0 +1,269 @@
import React, { FormEventHandler, useCallback, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import FocusTrap from 'focus-trap-react';
import {
Avatar,
Box,
Button,
Dialog,
Header,
Icon,
IconButton,
Icons,
Input,
Overlay,
OverlayBackdrop,
OverlayCenter,
Text,
color,
config,
} from 'folds';
import {
NavCategory,
NavCategoryHeader,
NavItem,
NavItemContent,
NavLink,
} from '../../../components/nav';
import { getExploreFeaturedPath, getExploreServerPath } from '../../pathUtils';
import { useClientConfig } from '../../../hooks/useClientConfig';
import {
useExploreFeaturedSelected,
useExploreServer,
} from '../../../hooks/router/useExploreSelected';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { getMxIdServer } from '../../../utils/matrix';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { useNavToActivePathMapper } from '../../../hooks/useNavToActivePathMapper';
import { PageNav, PageNavContent, PageNavHeader } from '../../../components/page';
export function AddServer() {
const mx = useMatrixClient();
const navigate = useNavigate();
const [dialog, setDialog] = useState(false);
const serverInputRef = useRef<HTMLInputElement>(null);
const [exploreState] = useAsyncCallback(
useCallback((server: string) => mx.publicRooms({ server, limit: 1 }), [mx])
);
const getInputServer = (): string | undefined => {
const serverInput = serverInputRef.current;
if (!serverInput) return undefined;
const server = serverInput.value.trim();
return server || undefined;
};
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
evt.preventDefault();
const server = getInputServer();
if (!server) return;
// explore(server);
navigate(getExploreServerPath(server));
setDialog(false);
};
const handleView = () => {
const server = getInputServer();
if (!server) return;
navigate(getExploreServerPath(server));
setDialog(false);
};
return (
<>
<Overlay open={dialog} backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
clickOutsideDeactivates: true,
onDeactivate: () => setDialog(false),
}}
>
<Dialog variant="Surface">
<Header
style={{
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
borderBottomWidth: config.borderWidth.B300,
}}
variant="Surface"
size="500"
>
<Box grow="Yes">
<Text size="H4">Add Server</Text>
</Box>
<IconButton size="300" onClick={() => setDialog(false)} radii="300">
<Icon src={Icons.Cross} />
</IconButton>
</Header>
<Box
as="form"
onSubmit={handleSubmit}
style={{ padding: config.space.S400 }}
direction="Column"
gap="400"
>
<Text priority="400">Add server name to explore public communities.</Text>
<Box direction="Column" gap="100">
<Text size="L400">Server Name</Text>
<Input ref={serverInputRef} name="serverInput" variant="Background" required />
{exploreState.status === AsyncStatus.Error && (
<Text style={{ color: color.Critical.Main }} size="T300">
Failed to load public rooms. Please try again.
</Text>
)}
</Box>
<Box direction="Column" gap="200">
{/* <Button
type="submit"
variant="Secondary"
before={
exploreState.status === AsyncStatus.Loading ? (
<Spinner fill="Solid" variant="Secondary" size="200" />
) : undefined
}
aria-disabled={exploreState.status === AsyncStatus.Loading}
>
<Text size="B400">Save</Text>
</Button> */}
<Button type="submit" onClick={handleView} variant="Secondary" fill="Soft">
<Text size="B400">View</Text>
</Button>
</Box>
</Box>
</Dialog>
</FocusTrap>
</OverlayCenter>
</Overlay>
<Button
variant="Secondary"
fill="Soft"
size="300"
before={<Icon size="100" src={Icons.Plus} />}
onClick={() => setDialog(true)}
>
<Text size="B300" truncate>
Add Server
</Text>
</Button>
</>
);
}
export function Explore() {
const mx = useMatrixClient();
useNavToActivePathMapper('explore');
const userId = mx.getUserId();
const clientConfig = useClientConfig();
const userServer = userId ? getMxIdServer(userId) : undefined;
const servers =
clientConfig.featuredCommunities?.servers?.filter((server) => server !== userServer) ?? [];
const featuredSelected = useExploreFeaturedSelected();
const selectedServer = useExploreServer();
return (
<PageNav>
<PageNavHeader>
<Box grow="Yes" gap="300">
<Box grow="Yes">
<Text size="H4" truncate>
Explore Community
</Text>
</Box>
</Box>
</PageNavHeader>
<PageNavContent>
<Box direction="Column" gap="300">
<NavCategory>
<NavItem variant="Background" radii="400" aria-selected={featuredSelected}>
<NavLink to={getExploreFeaturedPath()}>
<NavItemContent>
<Box as="span" grow="Yes" alignItems="Center" gap="200">
<Avatar size="200" radii="400">
<Icon src={Icons.Bulb} size="100" filled={featuredSelected} />
</Avatar>
<Box as="span" grow="Yes">
<Text as="span" size="Inherit" truncate>
Featured
</Text>
</Box>
</Box>
</NavItemContent>
</NavLink>
</NavItem>
{userServer && (
<NavItem
variant="Background"
radii="400"
aria-selected={selectedServer === userServer}
>
<NavLink to={getExploreServerPath(userServer)}>
<NavItemContent>
<Box as="span" grow="Yes" alignItems="Center" gap="200">
<Avatar size="200" radii="400">
<Icon
src={Icons.Category}
size="100"
filled={selectedServer === userServer}
/>
</Avatar>
<Box as="span" grow="Yes">
<Text as="span" size="Inherit" truncate>
{userServer}
</Text>
</Box>
</Box>
</NavItemContent>
</NavLink>
</NavItem>
)}
</NavCategory>
{servers.length > 0 && (
<NavCategory>
<NavCategoryHeader>
<Text size="O400" style={{ paddingLeft: config.space.S200 }}>
Servers
</Text>
</NavCategoryHeader>
{servers.map((server) => (
<NavItem
key={server}
variant="Background"
radii="400"
aria-selected={server === selectedServer}
>
<NavLink to={getExploreServerPath(server)}>
<NavItemContent>
<Box as="span" grow="Yes" alignItems="Center" gap="200">
<Avatar size="200" radii="400">
<Icon
src={Icons.Category}
size="100"
filled={server === selectedServer}
/>
</Avatar>
<Box as="span" grow="Yes">
<Text as="span" size="Inherit" truncate>
{server}
</Text>
</Box>
</Box>
</NavItemContent>
</NavLink>
</NavItem>
))}
</NavCategory>
)}
<Box direction="Column">
<AddServer />
</Box>
</Box>
</PageNavContent>
</PageNav>
);
}

View file

@ -0,0 +1,121 @@
import React from 'react';
import { Box, Icon, Icons, Scroll, Text } from 'folds';
import { useAtomValue } from 'jotai';
import { useClientConfig } from '../../../hooks/useClientConfig';
import { RoomCard, RoomCardGrid } from '../../../components/room-card';
import { allRoomsAtom } from '../../../state/room-list/roomList';
import { RoomSummaryLoader } from '../../../components/RoomSummaryLoader';
import {
Page,
PageContent,
PageContentCenter,
PageHero,
PageHeroSection,
} from '../../../components/page';
import { RoomTopicViewer } from '../../../components/room-topic-viewer';
import * as css from './style.css';
import { useRoomNavigate } from '../../../hooks/useRoomNavigate';
export function FeaturedRooms() {
const { featuredCommunities } = useClientConfig();
const { rooms, spaces } = featuredCommunities ?? {};
const allRooms = useAtomValue(allRoomsAtom);
const { navigateSpace, navigateRoom } = useRoomNavigate();
return (
<Page>
<Box grow="Yes">
<Scroll hideTrack visibility="Hover">
<PageContent>
<PageContentCenter>
<Box direction="Column" gap="200">
<PageHeroSection>
<PageHero
icon={<Icon size="600" src={Icons.Bulb} />}
title="Featured by Client"
subTitle="Find and explore public rooms and spaces featured by client provider."
/>
</PageHeroSection>
<Box direction="Column" gap="700">
{spaces && spaces.length > 0 && (
<Box direction="Column" gap="400">
<Text size="H4">Featured Spaces</Text>
<RoomCardGrid>
{spaces.map((roomIdOrAlias) => (
<RoomSummaryLoader key={roomIdOrAlias} roomIdOrAlias={roomIdOrAlias}>
{(roomSummary) => (
<RoomCard
roomIdOrAlias={roomIdOrAlias}
allRooms={allRooms}
avatarUrl={roomSummary?.avatar_url}
name={roomSummary?.name}
topic={roomSummary?.topic}
memberCount={roomSummary?.num_joined_members}
onView={navigateSpace}
renderTopicViewer={(name, topic, requestClose) => (
<RoomTopicViewer
name={name}
topic={topic}
requestClose={requestClose}
/>
)}
/>
)}
</RoomSummaryLoader>
))}
</RoomCardGrid>
</Box>
)}
{rooms && rooms.length > 0 && (
<Box direction="Column" gap="400">
<Text size="H4">Featured Rooms</Text>
<RoomCardGrid>
{rooms.map((roomIdOrAlias) => (
<RoomSummaryLoader key={roomIdOrAlias} roomIdOrAlias={roomIdOrAlias}>
{(roomSummary) => (
<RoomCard
roomIdOrAlias={roomIdOrAlias}
allRooms={allRooms}
avatarUrl={roomSummary?.avatar_url}
name={roomSummary?.name}
topic={roomSummary?.topic}
memberCount={roomSummary?.num_joined_members}
onView={navigateRoom}
renderTopicViewer={(name, topic, requestClose) => (
<RoomTopicViewer
name={name}
topic={topic}
requestClose={requestClose}
/>
)}
/>
)}
</RoomSummaryLoader>
))}
</RoomCardGrid>
</Box>
)}
{((spaces && spaces.length === 0 && rooms && rooms.length === 0) ||
(!spaces && !rooms)) && (
<Box
className={css.RoomsInfoCard}
direction="Column"
justifyContent="Center"
alignItems="Center"
gap="200"
>
<Icon size="400" src={Icons.Info} />
<Text size="T300" align="Center">
No rooms or spaces featured by client provider.
</Text>
</Box>
)}
</Box>
</Box>
</PageContentCenter>
</PageContent>
</Scroll>
</Box>
</Page>
);
}

View file

@ -0,0 +1,645 @@
import React, {
FormEventHandler,
MouseEventHandler,
RefObject,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import {
Box,
Button,
Chip,
Icon,
Icons,
Input,
Line,
Menu,
MenuItem,
PopOut,
RectCords,
Scroll,
Spinner,
Text,
config,
toRem,
} from 'folds';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import FocusTrap from 'focus-trap-react';
import { useAtomValue } from 'jotai';
import { useQuery } from '@tanstack/react-query';
import { MatrixClient, Method, RoomType } from 'matrix-js-sdk';
import { Page, PageContent, PageContentCenter, PageHeader } from '../../../components/page';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { RoomTopicViewer } from '../../../components/room-topic-viewer';
import { RoomCard, RoomCardBase, RoomCardGrid } from '../../../components/room-card';
import { ExploreServerPathSearchParams } from '../../paths';
import { getExploreServerPath, withSearchParam } from '../../pathUtils';
import * as css from './style.css';
import { allRoomsAtom } from '../../../state/room-list/roomList';
import { useRoomNavigate } from '../../../hooks/useRoomNavigate';
import { getMxIdServer } from '../../../utils/matrix';
const useServerSearchParams = (searchParams: URLSearchParams): ExploreServerPathSearchParams =>
useMemo(
() => ({
limit: searchParams.get('limit') ?? undefined,
since: searchParams.get('since') ?? undefined,
term: searchParams.get('term') ?? undefined,
type: searchParams.get('type') ?? undefined,
instance: searchParams.get('instance') ?? undefined,
}),
[searchParams]
);
type RoomTypeFilter = {
title: string;
value: string | undefined;
};
const useRoomTypeFilters = (): RoomTypeFilter[] =>
useMemo(
() => [
{
title: 'All',
value: undefined,
},
{
title: 'Spaces',
value: RoomType.Space,
},
{
title: 'Rooms',
value: 'null',
},
],
[]
);
const FALLBACK_ROOMS_LIMIT = 24;
type SearchProps = {
active?: boolean;
loading?: boolean;
searchInputRef: RefObject<HTMLInputElement>;
onSearch: (term: string) => void;
onReset: () => void;
};
function Search({ active, loading, searchInputRef, onSearch, onReset }: SearchProps) {
const handleSearchSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
evt.preventDefault();
const { searchInput } = evt.target as HTMLFormElement & {
searchInput: HTMLInputElement;
};
const searchTerm = searchInput.value.trim() || undefined;
if (searchTerm) {
onSearch(searchTerm);
}
};
return (
<Box as="form" direction="Column" gap="100" onSubmit={handleSearchSubmit}>
<span data-spacing-node />
<Text size="L400">Search</Text>
<Input
ref={searchInputRef}
style={{ paddingRight: config.space.S300 }}
name="searchInput"
size="500"
variant="Background"
placeholder="Search for keyword"
before={
active && loading ? (
<Spinner variant="Secondary" size="200" />
) : (
<Icon size="200" src={Icons.Search} />
)
}
after={
active ? (
<Chip
type="button"
variant="Secondary"
size="400"
radii="Pill"
outlined
after={<Icon size="50" src={Icons.Cross} />}
onClick={onReset}
>
<Text size="B300">Clear</Text>
</Chip>
) : (
<Chip type="submit" variant="Primary" size="400" radii="Pill" outlined>
<Text size="B300">Enter</Text>
</Chip>
)
}
/>
</Box>
);
}
const DEFAULT_INSTANCE_NAME = 'Matrix';
function ThirdPartyProtocolsSelector({
instanceId,
onChange,
}: {
instanceId?: string;
onChange: (instanceId?: string) => void;
}) {
const mx = useMatrixClient();
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
const { data } = useQuery({
queryKey: ['thirdparty', 'protocols'],
queryFn: () => mx.getThirdpartyProtocols(),
});
const handleInstanceSelect: MouseEventHandler<HTMLButtonElement> = (evt): void => {
const insId = evt.currentTarget.getAttribute('data-instance-id') ?? undefined;
onChange(insId);
setMenuAnchor(undefined);
};
const handleOpenMenu: MouseEventHandler<HTMLElement> = (evt) => {
setMenuAnchor(evt.currentTarget.getBoundingClientRect());
};
const instances = data && Object.keys(data).flatMap((protocol) => data[protocol].instances);
if (!instances || instances.length === 0) return null;
const selectedInstance = instances.find((instance) => instanceId === instance.instance_id);
return (
<PopOut
anchor={menuAnchor}
align="End"
position="Bottom"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setMenuAnchor(undefined),
clickOutsideDeactivates: true,
}}
>
<Menu variant="Surface">
<Box
direction="Column"
gap="100"
style={{ padding: config.space.S100, minWidth: toRem(100) }}
>
<Text style={{ padding: config.space.S100 }} size="L400" truncate>
Protocols
</Text>
<Box direction="Column">
<MenuItem
size="300"
variant="Surface"
aria-pressed={instanceId === undefined}
radii="300"
onClick={handleInstanceSelect}
>
<Text size="T200" truncate>
{DEFAULT_INSTANCE_NAME}
</Text>
</MenuItem>
{instances.map((instance) => (
<MenuItem
size="300"
key={instance.instance_id}
data-instance-id={instance.instance_id}
aria-pressed={instanceId === instance.instance_id}
variant="Surface"
radii="300"
onClick={handleInstanceSelect}
>
<Text size="T200" truncate>
{instance.desc}
</Text>
</MenuItem>
))}
</Box>
</Box>
</Menu>
</FocusTrap>
}
>
<Chip
onClick={handleOpenMenu}
aria-pressed={!!menuAnchor}
radii="Pill"
size="400"
variant={instanceId ? 'Success' : 'SurfaceVariant'}
after={<Icon size="100" src={Icons.ChevronBottom} />}
>
<Text size="T200" truncate>
{selectedInstance?.desc ?? DEFAULT_INSTANCE_NAME}
</Text>
</Chip>
</PopOut>
);
}
type LimitButtonProps = {
limit: number;
onLimitChange: (limit: string) => void;
};
function LimitButton({ limit, onLimitChange }: LimitButtonProps) {
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
const handleLimitSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
evt.preventDefault();
const limitInput = evt.currentTarget.limitInput as HTMLInputElement;
if (!limitInput) return;
const newLimit = limitInput.value.trim();
if (!newLimit) return;
onLimitChange(newLimit);
};
const setLimit = (l: string) => {
setMenuAnchor(undefined);
onLimitChange(l);
};
const handleOpenMenu: MouseEventHandler<HTMLElement> = (evt) => {
setMenuAnchor(evt.currentTarget.getBoundingClientRect());
};
return (
<PopOut
anchor={menuAnchor}
align="End"
position="Bottom"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setMenuAnchor(undefined),
clickOutsideDeactivates: true,
}}
>
<Menu variant="Surface">
<Box direction="Column" gap="400" style={{ padding: config.space.S300 }}>
<Box direction="Column" gap="100">
<Text size="L400">Presets</Text>
<Box gap="100" wrap="Wrap">
<Chip variant="SurfaceVariant" onClick={() => setLimit('24')} radii="Pill">
<Text size="T200">24</Text>
</Chip>
<Chip variant="SurfaceVariant" onClick={() => setLimit('48')} radii="Pill">
<Text size="T200">48</Text>
</Chip>
<Chip variant="SurfaceVariant" onClick={() => setLimit('96')} radii="Pill">
<Text size="T200">96</Text>
</Chip>
</Box>
</Box>
<Box as="form" onSubmit={handleLimitSubmit} direction="Column" gap="300">
<Box direction="Column" gap="100">
<Text size="L400">Custom Limit</Text>
<Input
name="limitInput"
size="300"
variant="Background"
defaultValue={limit}
min={1}
step={1}
outlined
type="number"
radii="400"
aria-label="Per Page Item Limit"
/>
</Box>
<Button type="submit" size="300" variant="Primary" radii="400">
<Text size="B300">Change Limit</Text>
</Button>
</Box>
</Box>
</Menu>
</FocusTrap>
}
>
<Chip
onClick={handleOpenMenu}
aria-pressed={!!menuAnchor}
radii="Pill"
size="400"
variant="SurfaceVariant"
after={<Icon size="100" src={Icons.ChevronBottom} />}
>
<Text size="T200" truncate>{`Page Limit: ${limit}`}</Text>
</Chip>
</PopOut>
);
}
export function PublicRooms() {
const { server } = useParams();
const mx = useMatrixClient();
const userId = mx.getUserId();
const userServer = userId && getMxIdServer(userId);
const allRooms = useAtomValue(allRoomsAtom);
const { navigateSpace, navigateRoom } = useRoomNavigate();
const [searchParams] = useSearchParams();
const serverSearchParams = useServerSearchParams(searchParams);
const isSearch = !!serverSearchParams.term;
const scrollRef = useRef<HTMLDivElement>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
const navigate = useNavigate();
const roomTypeFilters = useRoomTypeFilters();
const currentLimit: number = useMemo(() => {
const limitParam = serverSearchParams.limit;
if (!limitParam) return FALLBACK_ROOMS_LIMIT;
return parseInt(limitParam, 10) || FALLBACK_ROOMS_LIMIT;
}, [serverSearchParams.limit]);
const resetScroll = useCallback(() => {
const scroll = scrollRef.current;
if (scroll) scroll.scrollTop = 0;
}, []);
const fetchPublicRooms = useCallback(() => {
const limit =
typeof serverSearchParams.limit === 'string'
? parseInt(serverSearchParams.limit, 10)
: FALLBACK_ROOMS_LIMIT;
const roomType: string | null | undefined =
serverSearchParams.type === 'null' ? null : serverSearchParams.type;
return mx.http.authedRequest<Awaited<ReturnType<MatrixClient['publicRooms']>>>(
Method.Post,
'/publicRooms',
{
server,
},
{
limit,
since: serverSearchParams.since,
filter: {
generic_search_term: serverSearchParams.term,
room_types: roomType !== undefined ? [roomType] : undefined,
},
third_party_instance_id: serverSearchParams.instance,
}
);
}, [mx, server, serverSearchParams]);
const { data, isLoading, error } = useQuery({
queryKey: [
server,
'publicRooms',
serverSearchParams.limit,
serverSearchParams.since,
serverSearchParams.term,
serverSearchParams.type,
serverSearchParams.instance,
],
queryFn: fetchPublicRooms,
});
useEffect(() => {
if (isLoading) resetScroll();
}, [isLoading, resetScroll]);
const explore = (newSearchParams: ExploreServerPathSearchParams) => {
if (!server) return;
const sParams: Record<string, string> = {
...serverSearchParams,
...newSearchParams,
};
Object.keys(sParams).forEach((key) => {
if (sParams[key] === undefined) delete sParams[key];
});
const path = withSearchParam(getExploreServerPath(server), sParams);
navigate(path);
};
const paginateBack = () => {
const token = data?.prev_batch;
explore({ since: token });
};
const paginateFront = () => {
const token = data?.next_batch;
explore({ since: token });
};
const handleSearch = (term: string) => {
explore({
term,
since: undefined,
});
};
const handleSearchClear = () => {
if (searchInputRef.current) {
searchInputRef.current.value = '';
}
explore({
term: undefined,
since: undefined,
});
};
const handleRoomFilterClick: MouseEventHandler<HTMLButtonElement> = (evt) => {
const filter = evt.currentTarget.getAttribute('data-room-filter');
explore({
type: filter ?? undefined,
since: undefined,
});
};
const handleLimitChange = (limit: string) => {
explore({ limit });
};
const handleInstanceIdChange = (instanceId?: string) => {
explore({ instance: instanceId, since: undefined });
};
return (
<Page>
<PageHeader>
{isSearch ? (
<>
<Box grow="Yes" basis="No">
<Chip
size="500"
variant="Surface"
radii="Pill"
before={<Icon size="100" src={Icons.ArrowLeft} />}
onClick={handleSearchClear}
>
<Text size="T300">{server}</Text>
</Chip>
</Box>
<Box grow="No" justifyContent="Center" alignItems="Center" gap="200">
<Icon size="400" src={Icons.Search} />
<Text size="H3" truncate>
Search
</Text>
</Box>
<Box grow="Yes" />
</>
) : (
<Box grow="Yes" justifyContent="Center" alignItems="Center" gap="200">
<Icon size="400" src={Icons.Category} />
<Text size="H3" truncate>
{server}
</Text>
</Box>
)}
</PageHeader>
<Box grow="Yes">
<Scroll ref={scrollRef} hideTrack visibility="Hover">
<PageContent>
<PageContentCenter>
<Box direction="Column" gap="600">
<Search
key={server}
active={isSearch}
loading={isLoading}
searchInputRef={searchInputRef}
onSearch={handleSearch}
onReset={handleSearchClear}
/>
<Box direction="Column" gap="400">
<Box direction="Column" gap="300">
{isSearch ? (
<Text size="H4">{`Results for "${serverSearchParams.term}"`}</Text>
) : (
<Text size="H4">Popular Communities</Text>
)}
<Box gap="200">
{roomTypeFilters.map((filter) => (
<Chip
key={filter.title}
onClick={handleRoomFilterClick}
data-room-filter={filter.value}
variant={filter.value === serverSearchParams.type ? 'Success' : 'Surface'}
aria-pressed={filter.value === serverSearchParams.type}
before={
filter.value === serverSearchParams.type && (
<Icon size="100" src={Icons.Check} />
)
}
outlined
>
<Text size="T200">{filter.title}</Text>
</Chip>
))}
{userServer === server && (
<>
<Line
style={{ margin: `${config.space.S100} 0` }}
direction="Vertical"
variant="Surface"
size="300"
/>
<ThirdPartyProtocolsSelector
instanceId={serverSearchParams.instance}
onChange={handleInstanceIdChange}
/>
</>
)}
<Box grow="Yes" data-spacing-node />
<LimitButton limit={currentLimit} onLimitChange={handleLimitChange} />
</Box>
</Box>
{isLoading && (
<RoomCardGrid>
{[...Array(currentLimit).keys()].map((item) => (
<RoomCardBase key={item} style={{ minHeight: toRem(260) }} />
))}
</RoomCardGrid>
)}
{error && (
<Box direction="Column" className={css.PublicRoomsError} gap="200">
<Text size="L400">{error.name}</Text>
<Text size="T300">{error.message}</Text>
</Box>
)}
{data &&
(data.chunk.length > 0 ? (
<>
<RoomCardGrid>
{data?.chunk.map((chunkRoom) => (
<RoomCard
key={chunkRoom.room_id}
roomIdOrAlias={chunkRoom.canonical_alias ?? chunkRoom.room_id}
allRooms={allRooms}
avatarUrl={chunkRoom.avatar_url}
name={chunkRoom.name}
topic={chunkRoom.topic}
memberCount={chunkRoom.num_joined_members}
roomType={chunkRoom.room_type}
onView={
chunkRoom.room_type === RoomType.Space
? navigateSpace
: navigateRoom
}
renderTopicViewer={(name, topic, requestClose) => (
<RoomTopicViewer
name={name}
topic={topic}
requestClose={requestClose}
/>
)}
/>
))}
</RoomCardGrid>
{(data.prev_batch || data.next_batch) && (
<Box justifyContent="Center" gap="200">
<Button
onClick={paginateBack}
size="300"
fill="Soft"
disabled={!data.prev_batch}
>
<Text size="B300" truncate>
Previous Page
</Text>
</Button>
<Box data-spacing-node grow="Yes" />
<Button
onClick={paginateFront}
size="300"
fill="Solid"
disabled={!data.next_batch}
>
<Text size="B300" truncate>
Next Page
</Text>
</Button>
</Box>
)}
</>
) : (
<Box
className={css.RoomsInfoCard}
direction="Column"
justifyContent="Center"
alignItems="Center"
gap="200"
>
<Icon size="400" src={Icons.Info} />
<Text size="T300" align="Center">
No communities found!
</Text>
</Box>
))}
</Box>
</Box>
</PageContentCenter>
</PageContent>
</Scroll>
</Box>
</Page>
);
}

View file

@ -0,0 +1,3 @@
export * from './Explore';
export * from './Server';
export * from './Featured';

View file

@ -0,0 +1,19 @@
import { style } from '@vanilla-extract/css';
import { config } from 'folds';
import { ContainerColor } from '../../../styles/ContainerColor.css';
export const RoomsInfoCard = style([
ContainerColor({ variant: 'SurfaceVariant' }),
{
padding: `${config.space.S700} ${config.space.S300}`,
borderRadius: config.radii.R400,
},
]);
export const PublicRoomsError = style([
ContainerColor({ variant: 'Critical' }),
{
padding: config.space.S300,
borderRadius: config.radii.R400,
},
]);

View file

@ -0,0 +1,315 @@
import React, { MouseEventHandler, forwardRef, useMemo, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Avatar,
Box,
Button,
Icon,
IconButton,
Icons,
Menu,
MenuItem,
PopOut,
RectCords,
Text,
config,
toRem,
} from 'folds';
import { useVirtualizer } from '@tanstack/react-virtual';
import { useAtom, useAtomValue } from 'jotai';
import FocusTrap from 'focus-trap-react';
import { factoryRoomIdByActivity, factoryRoomIdByAtoZ } from '../../../utils/sort';
import {
NavButton,
NavCategory,
NavCategoryHeader,
NavEmptyCenter,
NavEmptyLayout,
NavItem,
NavItemContent,
NavLink,
} from '../../../components/nav';
import { getExplorePath, getHomeRoomPath, getHomeSearchPath } from '../../pathUtils';
import { getCanonicalAliasOrRoomId } from '../../../utils/matrix';
import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom';
import { useHomeSearchSelected } from '../../../hooks/router/useHomeSelected';
import { useHomeRooms } from './useHomeRooms';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { VirtualTile } from '../../../components/virtualizer';
import { RoomNavCategoryButton, RoomNavItem } from '../../../features/room-nav';
import { muteChangesAtom } from '../../../state/room-list/mutedRoomList';
import { makeNavCategoryId } from '../../../state/closedNavCategories';
import { roomToUnreadAtom } from '../../../state/room/roomToUnread';
import { useCategoryHandler } from '../../../hooks/useCategoryHandler';
import { useNavToActivePathMapper } from '../../../hooks/useNavToActivePathMapper';
import { openCreateRoom, openJoinAlias } from '../../../../client/action/navigation';
import { PageNav, PageNavHeader, PageNavContent } from '../../../components/page';
import { useRoomsUnread } from '../../../state/hooks/unread';
import { markAsRead } from '../../../../client/action/notifications';
import { useClosedNavCategoriesAtom } from '../../../state/hooks/closedNavCategories';
type HomeMenuProps = {
requestClose: () => void;
};
const HomeMenu = forwardRef<HTMLDivElement, HomeMenuProps>(({ requestClose }, ref) => {
const orphanRooms = useHomeRooms();
const unread = useRoomsUnread(orphanRooms, roomToUnreadAtom);
const handleMarkAsRead = () => {
if (!unread) return;
orphanRooms.forEach((rId) => markAsRead(rId));
requestClose();
};
return (
<Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}>
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
<MenuItem
onClick={handleMarkAsRead}
size="300"
after={<Icon size="100" src={Icons.CheckTwice} />}
radii="300"
aria-disabled={!unread}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Mark as Read
</Text>
</MenuItem>
</Box>
</Menu>
);
});
function HomeHeader() {
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
const cords = evt.currentTarget.getBoundingClientRect();
setMenuAnchor((currentState) => {
if (currentState) return undefined;
return cords;
});
};
return (
<>
<PageNavHeader>
<Box alignItems="Center" grow="Yes" gap="300">
<Box grow="Yes">
<Text size="H4" truncate>
Home
</Text>
</Box>
<Box>
<IconButton aria-pressed={!!menuAnchor} variant="Background" onClick={handleOpenMenu}>
<Icon src={Icons.VerticalDots} size="200" />
</IconButton>
</Box>
</Box>
</PageNavHeader>
<PopOut
anchor={menuAnchor}
position="Bottom"
align="End"
offset={6}
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
returnFocusOnDeactivate: false,
onDeactivate: () => setMenuAnchor(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
}}
>
<HomeMenu requestClose={() => setMenuAnchor(undefined)} />
</FocusTrap>
}
/>
</>
);
}
function HomeEmpty() {
const navigate = useNavigate();
return (
<NavEmptyCenter>
<NavEmptyLayout
icon={<Icon size="600" src={Icons.Hash} />}
title={
<Text size="H5" align="Center">
No Rooms
</Text>
}
content={
<Text size="T300" align="Center">
You do not have any rooms yet.
</Text>
}
options={
<>
<Button onClick={() => openCreateRoom()} variant="Secondary" size="300">
<Text size="B300" truncate>
Create Room
</Text>
</Button>
<Button
onClick={() => navigate(getExplorePath())}
variant="Secondary"
fill="Soft"
size="300"
>
<Text size="B300" truncate>
Explore Community Rooms
</Text>
</Button>
</>
}
/>
</NavEmptyCenter>
);
}
const DEFAULT_CATEGORY_ID = makeNavCategoryId('home', 'room');
export function Home() {
const mx = useMatrixClient();
useNavToActivePathMapper('home');
const scrollRef = useRef<HTMLDivElement>(null);
const rooms = useHomeRooms();
const muteChanges = useAtomValue(muteChangesAtom);
const mutedRooms = muteChanges.added;
const roomToUnread = useAtomValue(roomToUnreadAtom);
const selectedRoomId = useSelectedRoom();
const searchSelected = useHomeSearchSelected();
const noRoomToDisplay = rooms.length === 0;
const [closedCategories, setClosedCategories] = useAtom(useClosedNavCategoriesAtom());
const sortedRooms = useMemo(() => {
const items = Array.from(rooms).sort(
closedCategories.has(DEFAULT_CATEGORY_ID)
? factoryRoomIdByActivity(mx)
: factoryRoomIdByAtoZ(mx)
);
if (closedCategories.has(DEFAULT_CATEGORY_ID)) {
return items.filter((rId) => roomToUnread.has(rId) || rId === selectedRoomId);
}
return items;
}, [mx, rooms, closedCategories, roomToUnread, selectedRoomId]);
const virtualizer = useVirtualizer({
count: sortedRooms.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => 38,
overscan: 10,
});
const handleCategoryClick = useCategoryHandler(setClosedCategories, (categoryId) =>
closedCategories.has(categoryId)
);
return (
<PageNav>
<HomeHeader />
{noRoomToDisplay ? (
<HomeEmpty />
) : (
<PageNavContent scrollRef={scrollRef}>
<Box direction="Column" gap="300">
<NavCategory>
<NavItem variant="Background" radii="400">
<NavButton onClick={() => openCreateRoom()}>
<NavItemContent>
<Box as="span" grow="Yes" alignItems="Center" gap="200">
<Avatar size="200" radii="400">
<Icon src={Icons.Plus} size="100" />
</Avatar>
<Box as="span" grow="Yes">
<Text as="span" size="Inherit" truncate>
Create Room
</Text>
</Box>
</Box>
</NavItemContent>
</NavButton>
</NavItem>
<NavItem variant="Background" radii="400">
<NavButton onClick={() => openJoinAlias()}>
<NavItemContent>
<Box as="span" grow="Yes" alignItems="Center" gap="200">
<Avatar size="200" radii="400">
<Icon src={Icons.Link} size="100" />
</Avatar>
<Box as="span" grow="Yes">
<Text as="span" size="Inherit" truncate>
Join with Address
</Text>
</Box>
</Box>
</NavItemContent>
</NavButton>
</NavItem>
<NavItem variant="Background" radii="400" aria-selected={searchSelected}>
<NavLink to={getHomeSearchPath()}>
<NavItemContent>
<Box as="span" grow="Yes" alignItems="Center" gap="200">
<Avatar size="200" radii="400">
<Icon src={Icons.Search} size="100" filled={searchSelected} />
</Avatar>
<Box as="span" grow="Yes">
<Text as="span" size="Inherit" truncate>
Message Search
</Text>
</Box>
</Box>
</NavItemContent>
</NavLink>
</NavItem>
</NavCategory>
<NavCategory>
<NavCategoryHeader>
<RoomNavCategoryButton
closed={closedCategories.has(DEFAULT_CATEGORY_ID)}
data-category-id={DEFAULT_CATEGORY_ID}
onClick={handleCategoryClick}
>
Rooms
</RoomNavCategoryButton>
</NavCategoryHeader>
<div
style={{
position: 'relative',
height: virtualizer.getTotalSize(),
}}
>
{virtualizer.getVirtualItems().map((vItem) => {
const roomId = sortedRooms[vItem.index];
const room = mx.getRoom(roomId);
if (!room) return null;
const selected = selectedRoomId === roomId;
return (
<VirtualTile
virtualItem={vItem}
key={vItem.index}
ref={virtualizer.measureElement}
>
<RoomNavItem
room={room}
selected={selected}
linkPath={getHomeRoomPath(getCanonicalAliasOrRoomId(mx, roomId))}
muted={mutedRooms.includes(roomId)}
/>
</VirtualTile>
);
})}
</div>
</NavCategory>
</Box>
</PageNavContent>
)}
</PageNav>
);
}

View file

@ -0,0 +1,26 @@
import React, { ReactNode } from 'react';
import { useParams } from 'react-router-dom';
import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom';
import { RoomProvider } from '../../../hooks/useRoom';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { JoinBeforeNavigate } from '../../../features/join-before-navigate';
import { useHomeRooms } from './useHomeRooms';
export function HomeRouteRoomProvider({ children }: { children: ReactNode }) {
const mx = useMatrixClient();
const rooms = useHomeRooms();
const { roomIdOrAlias } = useParams();
const roomId = useSelectedRoom();
const room = mx.getRoom(roomId);
if (!room || !rooms.includes(room.roomId)) {
return <JoinBeforeNavigate roomIdOrAlias={roomIdOrAlias!} />;
}
return (
<RoomProvider key={room.roomId} value={room}>
{children}
</RoomProvider>
);
}

View file

@ -0,0 +1,37 @@
import React, { useRef } from 'react';
import { Box, Icon, Icons, Text, Scroll } from 'folds';
import { Page, PageContent, PageContentCenter, PageHeader } from '../../../components/page';
import { MessageSearch } from '../../../features/message-search';
import { useHomeRooms } from './useHomeRooms';
export function HomeSearch() {
const scrollRef = useRef<HTMLDivElement>(null);
const rooms = useHomeRooms();
return (
<Page>
<PageHeader>
<Box grow="Yes" justifyContent="Center" alignItems="Center" gap="200">
<Icon size="400" src={Icons.Search} />
<Text size="H3" truncate>
Message Search
</Text>
</Box>
</PageHeader>
<Box style={{ position: 'relative' }} grow="Yes">
<Scroll ref={scrollRef} hideTrack visibility="Hover">
<PageContent>
<PageContentCenter>
<MessageSearch
defaultRoomsFilterName="Home"
allowGlobal
rooms={rooms}
scrollRef={scrollRef}
/>
</PageContentCenter>
</PageContent>
</Scroll>
</Box>
</Page>
);
}

View file

@ -0,0 +1,3 @@
export * from './Home';
export * from './Search';
export * from './RoomProvider';

View file

@ -0,0 +1,14 @@
import { useAtomValue } from 'jotai';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { mDirectAtom } from '../../../state/mDirectList';
import { roomToParentsAtom } from '../../../state/room/roomToParents';
import { allRoomsAtom } from '../../../state/room-list/roomList';
import { useOrphanRooms } from '../../../state/hooks/roomList';
export const useHomeRooms = () => {
const mx = useMatrixClient();
const mDirects = useAtomValue(mDirectAtom);
const roomToParents = useAtomValue(roomToParentsAtom);
const rooms = useOrphanRooms(mx, allRoomsAtom, mDirects, roomToParents);
return rooms;
};

View file

@ -0,0 +1,87 @@
import React from 'react';
import { Avatar, Box, Icon, Icons, Text } from 'folds';
import { useAtomValue } from 'jotai';
import { NavCategory, NavItem, NavItemContent, NavLink } from '../../../components/nav';
import { getInboxInvitesPath, getInboxNotificationsPath } from '../../pathUtils';
import {
useInboxInvitesSelected,
useInboxNotificationsSelected,
} from '../../../hooks/router/useInbox';
import { UnreadBadge } from '../../../components/unread-badge';
import { allInvitesAtom } from '../../../state/room-list/inviteList';
import { useNavToActivePathMapper } from '../../../hooks/useNavToActivePathMapper';
import { PageNav, PageNavContent, PageNavHeader } from '../../../components/page';
function InvitesNavItem() {
const invitesSelected = useInboxInvitesSelected();
const allInvites = useAtomValue(allInvitesAtom);
const inviteCount = allInvites.length;
return (
<NavItem
variant="Background"
radii="400"
highlight={inviteCount > 0}
aria-selected={invitesSelected}
>
<NavLink to={getInboxInvitesPath()}>
<NavItemContent>
<Box as="span" grow="Yes" alignItems="Center" gap="200">
<Avatar size="200" radii="400">
<Icon src={Icons.Mail} size="100" filled={invitesSelected} />
</Avatar>
<Box as="span" grow="Yes">
<Text as="span" size="Inherit" truncate>
Invitations
</Text>
</Box>
{inviteCount > 0 && <UnreadBadge highlight count={inviteCount} />}
</Box>
</NavItemContent>
</NavLink>
</NavItem>
);
}
export function Inbox() {
useNavToActivePathMapper('inbox');
const notificationsSelected = useInboxNotificationsSelected();
return (
<PageNav>
<PageNavHeader>
<Box grow="Yes" gap="300">
<Box grow="Yes">
<Text size="H4" truncate>
Inbox
</Text>
</Box>
</Box>
</PageNavHeader>
<PageNavContent>
<Box direction="Column" gap="300">
<NavCategory>
<NavItem variant="Background" radii="400" aria-selected={notificationsSelected}>
<NavLink to={getInboxNotificationsPath()}>
<NavItemContent>
<Box as="span" grow="Yes" alignItems="Center" gap="200">
<Avatar size="200" radii="400">
<Icon src={Icons.MessageUnread} size="100" filled={notificationsSelected} />
</Avatar>
<Box as="span" grow="Yes">
<Text as="span" size="Inherit" truncate>
Notifications
</Text>
</Box>
</Box>
</NavItemContent>
</NavLink>
</NavItem>
<InvitesNavItem />
</NavCategory>
</Box>
</PageNavContent>
</PageNav>
);
}

View file

@ -0,0 +1,288 @@
import React, { useCallback, useRef, useState } from 'react';
import {
Avatar,
Box,
Button,
Icon,
Icons,
Overlay,
OverlayBackdrop,
OverlayCenter,
Scroll,
Spinner,
Text,
color,
config,
} from 'folds';
import { useAtomValue } from 'jotai';
import FocusTrap from 'focus-trap-react';
import { MatrixError, Room } from 'matrix-js-sdk';
import { Page, PageContent, PageContentCenter, PageHeader } from '../../../components/page';
import { useDirectInvites, useRoomInvites, useSpaceInvites } from '../../../state/hooks/inviteList';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { allInvitesAtom } from '../../../state/room-list/inviteList';
import { mDirectAtom } from '../../../state/mDirectList';
import { SequenceCard } from '../../../components/sequence-card';
import {
getDirectRoomAvatarUrl,
getMemberDisplayName,
getRoomAvatarUrl,
isDirectInvite,
} from '../../../utils/room';
import { nameInitials } from '../../../utils/common';
import { RoomAvatar } from '../../../components/room-avatar';
import { addRoomIdToMDirect, getMxIdLocalPart, guessDmRoomUserId } from '../../../utils/matrix';
import { Time } from '../../../components/message';
import { useElementSizeObserver } from '../../../hooks/useElementSizeObserver';
import { onEnterOrSpace } from '../../../utils/keyboard';
import { RoomTopicViewer } from '../../../components/room-topic-viewer';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { useRoomNavigate } from '../../../hooks/useRoomNavigate';
import { useRoomTopic } from '../../../hooks/useRoomMeta';
const COMPACT_CARD_WIDTH = 548;
type InviteCardProps = {
room: Room;
userId: string;
direct?: boolean;
compact?: boolean;
onNavigate: (roomId: string) => void;
};
function InviteCard({ room, userId, direct, compact, onNavigate }: InviteCardProps) {
const mx = useMatrixClient();
const roomName = room.name || room.getCanonicalAlias() || room.roomId;
const member = room.getMember(userId);
const memberEvent = member?.events.member;
const memberTs = memberEvent?.getTs() ?? 0;
const senderId = memberEvent?.getSender();
const senderName = senderId
? getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId
: undefined;
const topic = useRoomTopic(room);
const [viewTopic, setViewTopic] = useState(false);
const closeTopic = () => setViewTopic(false);
const openTopic = () => setViewTopic(true);
const [joinState, join] = useAsyncCallback<void, MatrixError, []>(
useCallback(async () => {
const dmUserId = isDirectInvite(room, userId) ? guessDmRoomUserId(room, userId) : undefined;
await mx.joinRoom(room.roomId);
if (dmUserId) {
await addRoomIdToMDirect(mx, room.roomId, dmUserId);
}
onNavigate(room.roomId);
}, [mx, room, userId, onNavigate])
);
const [leaveState, leave] = useAsyncCallback<Record<string, never>, MatrixError, []>(
useCallback(() => mx.leave(room.roomId), [mx, room])
);
const joining =
joinState.status === AsyncStatus.Loading || joinState.status === AsyncStatus.Success;
const leaving =
leaveState.status === AsyncStatus.Loading || leaveState.status === AsyncStatus.Success;
return (
<SequenceCard
variant="SurfaceVariant"
direction="Column"
gap="200"
style={{ padding: config.space.S400, paddingTop: config.space.S200 }}
>
<Box gap="200" alignItems="Baseline">
<Box grow="Yes">
<Text size="T200" priority="300" truncate>
Invited by <b>{senderName}</b>
</Text>
</Box>
<Box shrink="No">
<Time size="T200" ts={memberTs} priority="300" />
</Box>
</Box>
<Box gap="300">
<Avatar size="300">
<RoomAvatar
roomId={room.roomId}
src={direct ? getDirectRoomAvatarUrl(mx, room, 96) : getRoomAvatarUrl(mx, room, 96)}
alt={roomName}
renderFallback={() => (
<Text as="span" size="H6">
{nameInitials(roomName)}
</Text>
)}
/>
</Avatar>
<Box direction={compact ? 'Column' : 'Row'} grow="Yes" gap="200">
<Box grow="Yes" direction="Column" gap="200">
<Box direction="Column">
<Text size="T300" truncate>
<b>{roomName}</b>
</Text>
{topic && (
<Text
size="T200"
onClick={openTopic}
onKeyDown={onEnterOrSpace(openTopic)}
tabIndex={0}
truncate
>
{topic}
</Text>
)}
<Overlay open={viewTopic} backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
clickOutsideDeactivates: true,
onDeactivate: closeTopic,
}}
>
<RoomTopicViewer
name={roomName}
topic={topic ?? ''}
requestClose={closeTopic}
/>
</FocusTrap>
</OverlayCenter>
</Overlay>
</Box>
{joinState.status === AsyncStatus.Error && (
<Text size="T200" style={{ color: color.Critical.Main }}>
{joinState.error.message}
</Text>
)}
{leaveState.status === AsyncStatus.Error && (
<Text size="T200" style={{ color: color.Critical.Main }}>
{leaveState.error.message}
</Text>
)}
</Box>
<Box gap="200" shrink="No" alignItems="Center">
<Button
onClick={leave}
size="300"
variant="Secondary"
fill="Soft"
disabled={joining || leaving}
before={leaving ? <Spinner variant="Secondary" size="100" /> : undefined}
>
<Text size="B300">Decline</Text>
</Button>
<Button
onClick={join}
size="300"
variant="Primary"
fill="Soft"
outlined
disabled={joining || leaving}
before={joining ? <Spinner variant="Primary" fill="Soft" size="100" /> : undefined}
>
<Text size="B300">Accept</Text>
</Button>
</Box>
</Box>
</Box>
</SequenceCard>
);
}
export function Invites() {
const mx = useMatrixClient();
const userId = mx.getUserId()!;
const mDirects = useAtomValue(mDirectAtom);
const directInvites = useDirectInvites(mx, allInvitesAtom, mDirects);
const spaceInvites = useSpaceInvites(mx, allInvitesAtom);
const roomInvites = useRoomInvites(mx, allInvitesAtom, mDirects);
const containerRef = useRef<HTMLDivElement>(null);
const [compact, setCompact] = useState(document.body.clientWidth <= COMPACT_CARD_WIDTH);
useElementSizeObserver(
useCallback(() => containerRef.current, []),
useCallback((width) => setCompact(width <= COMPACT_CARD_WIDTH), [])
);
const { navigateRoom, navigateSpace } = useRoomNavigate();
const renderInvite = (roomId: string, direct: boolean, handleNavigate: (rId: string) => void) => {
const room = mx.getRoom(roomId);
if (!room) return null;
return (
<InviteCard
key={roomId}
room={room}
userId={userId}
compact={compact}
direct={direct}
onNavigate={handleNavigate}
/>
);
};
return (
<Page>
<PageHeader>
<Box grow="Yes" justifyContent="Center" alignItems="Center" gap="200">
<Icon size="400" src={Icons.Mail} />
<Text size="H3" truncate>
Invitations
</Text>
</Box>
</PageHeader>
<Box grow="Yes">
<Scroll hideTrack visibility="Hover">
<PageContent>
<PageContentCenter>
<Box ref={containerRef} direction="Column" gap="600">
{directInvites.length > 0 && (
<Box direction="Column" gap="200">
<Text size="H4">Direct Messages</Text>
<Box direction="Column" gap="100">
{directInvites.map((roomId) => renderInvite(roomId, true, navigateRoom))}
</Box>
</Box>
)}
{spaceInvites.length > 0 && (
<Box direction="Column" gap="200">
<Text size="H4">Spaces</Text>
<Box direction="Column" gap="100">
{spaceInvites.map((roomId) => renderInvite(roomId, false, navigateSpace))}
</Box>
</Box>
)}
{roomInvites.length > 0 && (
<Box direction="Column" gap="200">
<Text size="H4">Rooms</Text>
<Box direction="Column" gap="100">
{roomInvites.map((roomId) => renderInvite(roomId, false, navigateRoom))}
</Box>
</Box>
)}
{directInvites.length === 0 &&
spaceInvites.length === 0 &&
roomInvites.length === 0 && (
<div>
<SequenceCard
variant="SurfaceVariant"
style={{ padding: config.space.S400 }}
direction="Column"
gap="200"
>
<Text>No Pending Invitations</Text>
<Text size="T200">
You don&apos;t have any new pending invitations to display yet.
</Text>
</SequenceCard>
</div>
)}
</Box>
</PageContentCenter>
</PageContent>
</Scroll>
</Box>
</Page>
);
}

View file

@ -0,0 +1,609 @@
/* eslint-disable react/destructuring-assignment */
import React, { MouseEventHandler, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Avatar,
Box,
Chip,
Header,
Icon,
IconButton,
Icons,
Scroll,
Text,
config,
toRem,
} from 'folds';
import { useSearchParams } from 'react-router-dom';
import {
INotification,
INotificationsResponse,
IRoomEvent,
JoinRule,
Method,
Room,
} from 'matrix-js-sdk';
import { useVirtualizer } from '@tanstack/react-virtual';
import { HTMLReactParserOptions } from 'html-react-parser';
import { Page, PageContent, PageContentCenter, PageHeader } from '../../../components/page';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { getMxIdLocalPart, isRoomId, isUserId } from '../../../utils/matrix';
import { InboxNotificationsPathSearchParams } from '../../paths';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { SequenceCard } from '../../../components/sequence-card';
import { RoomAvatar, RoomIcon } from '../../../components/room-avatar';
import { getMemberAvatarMxc, getMemberDisplayName, getRoomAvatarUrl } from '../../../utils/room';
import { ScrollTopContainer } from '../../../components/scroll-top-container';
import { useInterval } from '../../../hooks/useInterval';
import {
AvatarBase,
ImageContent,
MSticker,
ModernLayout,
RedactedContent,
Reply,
Time,
Username,
} from '../../../components/message';
import colorMXID from '../../../../util/colorMXID';
import { getReactCustomHtmlParser } from '../../../plugins/react-custom-html-parser';
import { openJoinAlias, openProfileViewer } from '../../../../client/action/navigation';
import { RenderMessageContent } from '../../../components/RenderMessageContent';
import { useSetting } from '../../../state/hooks/settings';
import { settingsAtom } from '../../../state/settings';
import { Image } from '../../../components/media';
import { ImageViewer } from '../../../components/image-viewer';
import { GetContentCallback, MessageEvent, StateEvent } from '../../../../types/matrix/room';
import { useMatrixEventRenderer } from '../../../hooks/useMatrixEventRenderer';
import * as customHtmlCss from '../../../styles/CustomHtml.css';
import { useRoomNavigate } from '../../../hooks/useRoomNavigate';
import { useRoomUnread } from '../../../state/hooks/unread';
import { roomToUnreadAtom } from '../../../state/room/roomToUnread';
import { markAsRead } from '../../../../client/action/notifications';
import { ContainerColor } from '../../../styles/ContainerColor.css';
import { VirtualTile } from '../../../components/virtualizer';
import { UserAvatar } from '../../../components/user-avatar';
type RoomNotificationsGroup = {
roomId: string;
notifications: INotification[];
};
type NotificationTimeline = {
nextToken?: string;
groups: RoomNotificationsGroup[];
};
type LoadTimeline = (from?: string) => Promise<void>;
type SilentReloadTimeline = () => Promise<void>;
const groupNotifications = (notifications: INotification[]): RoomNotificationsGroup[] => {
const groups: RoomNotificationsGroup[] = [];
notifications.forEach((notification) => {
const groupIndex = groups.length - 1;
const lastAddedGroup: RoomNotificationsGroup | undefined = groups[groupIndex];
if (lastAddedGroup && notification.room_id === lastAddedGroup.roomId) {
lastAddedGroup.notifications.push(notification);
return;
}
groups.push({
roomId: notification.room_id,
notifications: [notification],
});
});
return groups;
};
const useNotificationTimeline = (
paginationLimit: number,
onlyHighlight?: boolean
): [NotificationTimeline, LoadTimeline, SilentReloadTimeline] => {
const mx = useMatrixClient();
const [notificationTimeline, setNotificationTimeline] = useState<NotificationTimeline>({
groups: [],
});
const fetchNotifications = useCallback(
(from?: string, limit?: number, only?: 'highlight') => {
const queryParams = { from, limit, only };
return mx.http.authedRequest<INotificationsResponse>(
Method.Get,
'/notifications',
queryParams
);
},
[mx]
);
const loadTimeline: LoadTimeline = useCallback(
async (from) => {
if (!from) {
setNotificationTimeline({ groups: [] });
}
const data = await fetchNotifications(
from,
paginationLimit,
onlyHighlight ? 'highlight' : undefined
);
const groups = groupNotifications(data.notifications);
setNotificationTimeline((currentTimeline) => {
if (currentTimeline.nextToken === from) {
return {
nextToken: data.next_token,
groups: from ? currentTimeline.groups.concat(groups) : groups,
};
}
return currentTimeline;
});
},
[paginationLimit, onlyHighlight, fetchNotifications]
);
/**
* Reload timeline silently i.e without setting to default
* before fetching notifications from start
*/
const silentReloadTimeline: SilentReloadTimeline = useCallback(async () => {
const data = await fetchNotifications(
undefined,
paginationLimit,
onlyHighlight ? 'highlight' : undefined
);
const groups = groupNotifications(data.notifications);
setNotificationTimeline({
nextToken: data.next_token,
groups,
});
}, [paginationLimit, onlyHighlight, fetchNotifications]);
return [notificationTimeline, loadTimeline, silentReloadTimeline];
};
type RoomNotificationsGroupProps = {
room: Room;
notifications: INotification[];
mediaAutoLoad?: boolean;
urlPreview?: boolean;
onOpen: (roomId: string, eventId: string) => void;
};
function RoomNotificationsGroupComp({
room,
notifications,
mediaAutoLoad,
urlPreview,
onOpen,
}: RoomNotificationsGroupProps) {
const mx = useMatrixClient();
const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
const { navigateRoom, navigateSpace } = useRoomNavigate();
const htmlReactParserOptions = useMemo<HTMLReactParserOptions>(
() =>
getReactCustomHtmlParser(mx, room, {
handleSpoilerClick: (evt) => {
const target = evt.currentTarget;
if (target.getAttribute('aria-pressed') === 'true') {
evt.stopPropagation();
target.setAttribute('aria-pressed', 'false');
target.style.cursor = 'initial';
}
},
handleMentionClick: (evt) => {
const target = evt.currentTarget;
const mentionId = target.getAttribute('data-mention-id');
if (typeof mentionId !== 'string') return;
if (isUserId(mentionId)) {
openProfileViewer(mentionId, room.roomId);
return;
}
if (isRoomId(mentionId) && mx.getRoom(mentionId)) {
if (mx.getRoom(mentionId)?.isSpaceRoom()) navigateSpace(mentionId);
else navigateRoom(mentionId);
return;
}
openJoinAlias(mentionId);
},
}),
[mx, room, navigateRoom, navigateSpace]
);
const renderMatrixEvent = useMatrixEventRenderer<[IRoomEvent, string, GetContentCallback]>(
{
[MessageEvent.RoomMessage]: (event, displayName, getContent) => {
if (event.unsigned?.redacted_because) {
return <RedactedContent reason={event.unsigned?.redacted_because.content.reason} />;
}
return (
<RenderMessageContent
displayName={displayName}
msgType={event.content.msgtype ?? ''}
ts={event.origin_server_ts}
getContent={getContent}
mediaAutoLoad={mediaAutoLoad}
urlPreview={urlPreview}
htmlReactParserOptions={htmlReactParserOptions}
outlineAttachment
/>
);
},
[MessageEvent.Sticker]: (event, displayName, getContent) => {
if (event.unsigned?.redacted_because) {
return <RedactedContent reason={event.unsigned?.redacted_because.content.reason} />;
}
return (
<MSticker
content={getContent()}
renderImageContent={(props) => (
<ImageContent
{...props}
autoPlay={mediaAutoLoad}
renderImage={(p) => <Image {...p} loading="lazy" />}
renderViewer={(p) => <ImageViewer {...p} />}
/>
)}
/>
);
},
[StateEvent.RoomTombstone]: (event) => {
const { content } = event;
return (
<Box grow="Yes" direction="Column">
<Text size="T400" priority="300">
Room Tombstone. {content.body}
</Text>
</Box>
);
},
},
undefined,
(event) => {
if (event.unsigned?.redacted_because) {
return <RedactedContent reason={event.unsigned?.redacted_because.content.reason} />;
}
return (
<Box grow="Yes" direction="Column">
<Text size="T400" priority="300">
<code className={customHtmlCss.Code}>{event.type}</code>
{' event'}
</Text>
</Box>
);
}
);
const handleOpenClick: MouseEventHandler<HTMLButtonElement> = (evt) => {
const eventId = evt.currentTarget.getAttribute('data-event-id');
if (!eventId) return;
onOpen(room.roomId, eventId);
};
const handleMarkAsRead = () => {
markAsRead(room.roomId);
};
return (
<Box direction="Column" gap="200">
<Header size="300">
<Box gap="200" grow="Yes">
<Avatar size="200" radii="300">
<RoomAvatar
roomId={room.roomId}
src={getRoomAvatarUrl(mx, room, 96)}
alt={room.name}
renderFallback={() => (
<RoomIcon size="50" joinRule={room.getJoinRule() ?? JoinRule.Restricted} filled />
)}
/>
</Avatar>
<Text size="H4" truncate>
{room.name}
</Text>
</Box>
<Box shrink="No">
{unread && (
<Chip
variant="Primary"
radii="Pill"
onClick={handleMarkAsRead}
before={<Icon size="100" src={Icons.CheckTwice} />}
>
<Text size="T200">Mark as Read</Text>
</Chip>
)}
</Box>
</Header>
<Box direction="Column" gap="100">
{notifications.map((notification) => {
const { event } = notification;
const displayName =
getMemberDisplayName(room, event.sender) ??
getMxIdLocalPart(event.sender) ??
event.sender;
const senderAvatarMxc = getMemberAvatarMxc(room, event.sender);
const getContent = (() => event.content) as GetContentCallback;
const replyEventId = event.content['m.relates_to']?.['m.in_reply_to']?.event_id;
return (
<SequenceCard
key={notification.event.event_id}
style={{ padding: config.space.S400 }}
variant="SurfaceVariant"
direction="Column"
>
<ModernLayout
before={
<AvatarBase>
<Avatar size="300">
<UserAvatar
userId={event.sender}
src={
senderAvatarMxc
? mx.mxcUrlToHttp(senderAvatarMxc, 48, 48, 'crop') ?? undefined
: undefined
}
alt={displayName}
renderFallback={() => <Icon size="200" src={Icons.User} filled />}
/>
</Avatar>
</AvatarBase>
}
>
<Box gap="300" justifyContent="SpaceBetween" alignItems="Center" grow="Yes">
<Box gap="200" alignItems="Baseline">
<Username style={{ color: colorMXID(event.sender) }}>
<Text as="span" truncate>
<b>{displayName}</b>
</Text>
</Username>
<Time ts={event.origin_server_ts} />
</Box>
<Box shrink="No" gap="200" alignItems="Center">
<Chip
data-event-id={event.event_id}
onClick={handleOpenClick}
variant="Secondary"
radii="400"
>
<Text size="T200">Open</Text>
</Chip>
</Box>
</Box>
{replyEventId && (
<Reply
as="button"
mx={mx}
room={room}
eventId={replyEventId}
data-event-id={replyEventId}
onClick={handleOpenClick}
/>
)}
{renderMatrixEvent(event.type, false, event, displayName, getContent)}
</ModernLayout>
</SequenceCard>
);
})}
</Box>
</Box>
);
}
const useNotificationsSearchParams = (
searchParams: URLSearchParams
): InboxNotificationsPathSearchParams =>
useMemo(
() => ({
only: searchParams.get('only') ?? undefined,
}),
[searchParams]
);
const DEFAULT_REFRESH_MS = 10000;
export function Notifications() {
const mx = useMatrixClient();
const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
const [urlPreview] = useSetting(settingsAtom, 'urlPreview');
const { navigateRoom } = useRoomNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const notificationsSearchParams = useNotificationsSearchParams(searchParams);
const scrollRef = useRef<HTMLDivElement>(null);
const scrollTopAnchorRef = useRef<HTMLDivElement>(null);
const [refreshIntervalTime, setRefreshIntervalTime] = useState(DEFAULT_REFRESH_MS);
const onlyHighlight = notificationsSearchParams.only === 'highlight';
const setOnlyHighlighted = (highlight: boolean) => {
if (highlight) {
setSearchParams(
new URLSearchParams({
only: 'highlight',
})
);
return;
}
setSearchParams();
};
const [notificationTimeline, _loadTimeline, silentReloadTimeline] = useNotificationTimeline(
24,
onlyHighlight
);
const [timelineState, loadTimeline] = useAsyncCallback(_loadTimeline);
const virtualizer = useVirtualizer({
count: notificationTimeline.groups.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => 40,
overscan: 4,
});
const vItems = virtualizer.getVirtualItems();
useInterval(
useCallback(() => {
if (document.hasFocus()) {
silentReloadTimeline();
}
}, [silentReloadTimeline]),
refreshIntervalTime
);
const handleScrollTopVisibility = useCallback(
(onTop: boolean) => setRefreshIntervalTime(onTop ? DEFAULT_REFRESH_MS : -1),
[]
);
useEffect(() => {
loadTimeline();
}, [loadTimeline]);
const lastVItem = vItems[vItems.length - 1];
const lastVItemIndex: number | undefined = lastVItem?.index;
useEffect(() => {
if (
timelineState.status === AsyncStatus.Success &&
notificationTimeline.groups.length - 1 === lastVItemIndex &&
notificationTimeline.nextToken
) {
loadTimeline(notificationTimeline.nextToken);
}
}, [timelineState, notificationTimeline, lastVItemIndex, loadTimeline]);
return (
<Page>
<PageHeader>
<Box grow="Yes" justifyContent="Center" alignItems="Center" gap="200">
<Icon size="400" src={Icons.Message} />
<Text size="H3" truncate>
Notification Messages
</Text>
</Box>
</PageHeader>
<Box style={{ position: 'relative' }} grow="Yes">
<Scroll ref={scrollRef} hideTrack visibility="Hover">
<PageContent>
<PageContentCenter>
<Box direction="Column" gap="200">
<Box ref={scrollTopAnchorRef} direction="Column" gap="100">
<span data-spacing-node />
<Text size="L400">Filter</Text>
<Box gap="200">
<Chip
onClick={() => setOnlyHighlighted(false)}
variant={!onlyHighlight ? 'Success' : 'Surface'}
aria-pressed={!onlyHighlight}
before={!onlyHighlight && <Icon size="100" src={Icons.Check} />}
outlined
>
<Text size="T200">All Notifications</Text>
</Chip>
<Chip
onClick={() => setOnlyHighlighted(true)}
variant={onlyHighlight ? 'Success' : 'Surface'}
aria-pressed={onlyHighlight}
before={onlyHighlight && <Icon size="100" src={Icons.Check} />}
outlined
>
<Text size="T200">Highlighted</Text>
</Chip>
</Box>
</Box>
<ScrollTopContainer
scrollRef={scrollRef}
anchorRef={scrollTopAnchorRef}
onVisibilityChange={handleScrollTopVisibility}
>
<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(),
}}
>
{vItems.map((vItem) => {
const group = notificationTimeline.groups[vItem.index];
if (!group) return null;
const groupRoom = mx.getRoom(group.roomId);
if (!groupRoom) return null;
return (
<VirtualTile
virtualItem={vItem}
style={{ paddingTop: config.space.S500 }}
ref={virtualizer.measureElement}
key={vItem.index}
>
<RoomNotificationsGroupComp
room={groupRoom}
notifications={group.notifications}
mediaAutoLoad={mediaAutoLoad}
urlPreview={urlPreview}
onOpen={navigateRoom}
/>
</VirtualTile>
);
})}
</div>
{timelineState.status === AsyncStatus.Success &&
notificationTimeline.groups.length === 0 && (
<Box
className={ContainerColor({ variant: 'SurfaceVariant' })}
style={{
padding: config.space.S300,
borderRadius: config.radii.R400,
}}
direction="Column"
gap="200"
>
<Text>No Notifications</Text>
<Text size="T200">
You don&apos;t have any new notifications to display yet.
</Text>
</Box>
)}
{timelineState.status === AsyncStatus.Loading && (
<Box direction="Column" gap="100">
{[...Array(8).keys()].map((key) => (
<SequenceCard
variant="SurfaceVariant"
key={key}
style={{ minHeight: toRem(80) }}
/>
))}
</Box>
)}
{timelineState.status === AsyncStatus.Error && (
<Box
className={ContainerColor({ variant: 'Critical' })}
style={{
padding: config.space.S300,
borderRadius: config.radii.R400,
}}
direction="Column"
gap="200"
>
<Text size="L400">{(timelineState.error as Error).name}</Text>
<Text size="T300">{(timelineState.error as Error).message}</Text>
</Box>
)}
</Box>
</PageContentCenter>
</PageContent>
</Scroll>
</Box>
</Page>
);
}

View file

@ -0,0 +1,3 @@
export * from './Inbox';
export * from './Notifications';
export * from './Invites';

View file

@ -0,0 +1,3 @@
export * from './ClientRoot';
export * from './ClientBindAtoms';
export * from './ClientLayout';

View file

@ -0,0 +1,132 @@
import React, { MouseEventHandler, forwardRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Box, Icon, Icons, Menu, MenuItem, PopOut, RectCords, Text, config, toRem } from 'folds';
import FocusTrap from 'focus-trap-react';
import { useAtomValue } from 'jotai';
import { useDirects } from '../../../state/hooks/roomList';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { mDirectAtom } from '../../../state/mDirectList';
import { allRoomsAtom } from '../../../state/room-list/roomList';
import { roomToUnreadAtom } from '../../../state/room/roomToUnread';
import { getDirectPath, joinPathComponent } from '../../pathUtils';
import { useRoomsUnread } from '../../../state/hooks/unread';
import {
SidebarAvatar,
SidebarItem,
SidebarItemBadge,
SidebarItemTooltip,
} from '../../../components/sidebar';
import { useDirectSelected } from '../../../hooks/router/useDirectSelected';
import { UnreadBadge } from '../../../components/unread-badge';
import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
import { useNavToActivePathAtom } from '../../../state/hooks/navToActivePath';
import { useDirectRooms } from '../direct/useDirectRooms';
import { markAsRead } from '../../../../client/action/notifications';
type DirectMenuProps = {
requestClose: () => void;
};
const DirectMenu = forwardRef<HTMLDivElement, DirectMenuProps>(({ requestClose }, ref) => {
const orphanRooms = useDirectRooms();
const unread = useRoomsUnread(orphanRooms, roomToUnreadAtom);
const handleMarkAsRead = () => {
if (!unread) return;
orphanRooms.forEach((rId) => markAsRead(rId));
requestClose();
};
return (
<Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}>
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
<MenuItem
onClick={handleMarkAsRead}
size="300"
after={<Icon size="100" src={Icons.CheckTwice} />}
radii="300"
aria-disabled={!unread}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Mark as Read
</Text>
</MenuItem>
</Box>
</Menu>
);
});
export function DirectTab() {
const navigate = useNavigate();
const mx = useMatrixClient();
const screenSize = useScreenSizeContext();
const navToActivePath = useAtomValue(useNavToActivePathAtom());
const mDirects = useAtomValue(mDirectAtom);
const directs = useDirects(mx, allRoomsAtom, mDirects);
const directUnread = useRoomsUnread(directs, roomToUnreadAtom);
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
const directSelected = useDirectSelected();
const handleDirectClick = () => {
const activePath = navToActivePath.get('direct');
if (activePath && screenSize !== ScreenSize.Mobile) {
navigate(joinPathComponent(activePath));
return;
}
navigate(getDirectPath());
};
const handleContextMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
evt.preventDefault();
const cords = evt.currentTarget.getBoundingClientRect();
setMenuAnchor((currentState) => {
if (currentState) return undefined;
return cords;
});
};
return (
<SidebarItem active={directSelected}>
<SidebarItemTooltip tooltip="Direct Messages">
{(triggerRef) => (
<SidebarAvatar
as="button"
ref={triggerRef}
outlined
onClick={handleDirectClick}
onContextMenu={handleContextMenu}
>
<Icon src={Icons.User} filled={directSelected} />
</SidebarAvatar>
)}
</SidebarItemTooltip>
{directUnread && (
<SidebarItemBadge hasCount={directUnread.total > 0}>
<UnreadBadge highlight={directUnread.highlight > 0} count={directUnread.total} />
</SidebarItemBadge>
)}
{menuAnchor && (
<PopOut
anchor={menuAnchor}
position="Right"
align="Start"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
returnFocusOnDeactivate: false,
onDeactivate: () => setMenuAnchor(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
}}
>
<DirectMenu requestClose={() => setMenuAnchor(undefined)} />
</FocusTrap>
}
/>
)}
</SidebarItem>
);
}

View file

@ -0,0 +1,64 @@
import React from 'react';
import { Icon, Icons } from 'folds';
import { useNavigate } from 'react-router-dom';
import { useAtomValue } from 'jotai';
import { SidebarAvatar, SidebarItem, SidebarItemTooltip } from '../../../components/sidebar';
import { useExploreSelected } from '../../../hooks/router/useExploreSelected';
import {
getExploreFeaturedPath,
getExplorePath,
getExploreServerPath,
joinPathComponent,
} from '../../pathUtils';
import { useClientConfig } from '../../../hooks/useClientConfig';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { getMxIdServer } from '../../../utils/matrix';
import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
import { useNavToActivePathAtom } from '../../../state/hooks/navToActivePath';
export function ExploreTab() {
const mx = useMatrixClient();
const screenSize = useScreenSizeContext();
const clientConfig = useClientConfig();
const navigate = useNavigate();
const navToActivePath = useAtomValue(useNavToActivePathAtom());
const exploreSelected = useExploreSelected();
const handleExploreClick = () => {
if (screenSize === ScreenSize.Mobile) {
navigate(getExplorePath());
return;
}
const activePath = navToActivePath.get('explore');
if (activePath) {
navigate(joinPathComponent(activePath));
return;
}
if (clientConfig.featuredCommunities?.openAsDefault) {
navigate(getExploreFeaturedPath());
return;
}
const userId = mx.getUserId();
const userServer = userId ? getMxIdServer(userId) : undefined;
if (userServer) {
navigate(getExploreServerPath(userServer));
return;
}
navigate(getExplorePath());
};
return (
<SidebarItem active={exploreSelected}>
<SidebarItemTooltip tooltip="Explore Community">
{(triggerRef) => (
<SidebarAvatar as="button" ref={triggerRef} outlined onClick={handleExploreClick}>
<Icon src={Icons.Explore} filled={exploreSelected} />
</SidebarAvatar>
)}
</SidebarItemTooltip>
</SidebarItem>
);
}

View file

@ -0,0 +1,134 @@
import React, { MouseEventHandler, forwardRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Box, Icon, Icons, Menu, MenuItem, PopOut, RectCords, Text, config, toRem } from 'folds';
import { useAtomValue } from 'jotai';
import FocusTrap from 'focus-trap-react';
import { useOrphanRooms } from '../../../state/hooks/roomList';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { mDirectAtom } from '../../../state/mDirectList';
import { roomToParentsAtom } from '../../../state/room/roomToParents';
import { allRoomsAtom } from '../../../state/room-list/roomList';
import { roomToUnreadAtom } from '../../../state/room/roomToUnread';
import { getHomePath, joinPathComponent } from '../../pathUtils';
import { useRoomsUnread } from '../../../state/hooks/unread';
import {
SidebarAvatar,
SidebarItem,
SidebarItemBadge,
SidebarItemTooltip,
} from '../../../components/sidebar';
import { useHomeSelected } from '../../../hooks/router/useHomeSelected';
import { UnreadBadge } from '../../../components/unread-badge';
import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
import { useNavToActivePathAtom } from '../../../state/hooks/navToActivePath';
import { useHomeRooms } from '../home/useHomeRooms';
import { markAsRead } from '../../../../client/action/notifications';
type HomeMenuProps = {
requestClose: () => void;
};
const HomeMenu = forwardRef<HTMLDivElement, HomeMenuProps>(({ requestClose }, ref) => {
const orphanRooms = useHomeRooms();
const unread = useRoomsUnread(orphanRooms, roomToUnreadAtom);
const handleMarkAsRead = () => {
if (!unread) return;
orphanRooms.forEach((rId) => markAsRead(rId));
requestClose();
};
return (
<Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}>
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
<MenuItem
onClick={handleMarkAsRead}
size="300"
after={<Icon size="100" src={Icons.CheckTwice} />}
radii="300"
aria-disabled={!unread}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Mark as Read
</Text>
</MenuItem>
</Box>
</Menu>
);
});
export function HomeTab() {
const navigate = useNavigate();
const mx = useMatrixClient();
const screenSize = useScreenSizeContext();
const navToActivePath = useAtomValue(useNavToActivePathAtom());
const mDirects = useAtomValue(mDirectAtom);
const roomToParents = useAtomValue(roomToParentsAtom);
const orphanRooms = useOrphanRooms(mx, allRoomsAtom, mDirects, roomToParents);
const homeUnread = useRoomsUnread(orphanRooms, roomToUnreadAtom);
const homeSelected = useHomeSelected();
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
const handleHomeClick = () => {
const activePath = navToActivePath.get('home');
if (activePath && screenSize !== ScreenSize.Mobile) {
navigate(joinPathComponent(activePath));
return;
}
navigate(getHomePath());
};
const handleContextMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
evt.preventDefault();
const cords = evt.currentTarget.getBoundingClientRect();
setMenuAnchor((currentState) => {
if (currentState) return undefined;
return cords;
});
};
return (
<SidebarItem active={homeSelected}>
<SidebarItemTooltip tooltip="Home">
{(triggerRef) => (
<SidebarAvatar
as="button"
ref={triggerRef}
outlined
onClick={handleHomeClick}
onContextMenu={handleContextMenu}
>
<Icon src={Icons.Home} filled={homeSelected} />
</SidebarAvatar>
)}
</SidebarItemTooltip>
{homeUnread && (
<SidebarItemBadge hasCount={homeUnread.total > 0}>
<UnreadBadge highlight={homeUnread.highlight > 0} count={homeUnread.total} />
</SidebarItemBadge>
)}
{menuAnchor && (
<PopOut
anchor={menuAnchor}
position="Right"
align="Start"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
returnFocusOnDeactivate: false,
onDeactivate: () => setMenuAnchor(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
}}
>
<HomeMenu requestClose={() => setMenuAnchor(undefined)} />
</FocusTrap>
}
/>
)}
</SidebarItem>
);
}

View file

@ -0,0 +1,62 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { Icon, Icons } from 'folds';
import { useAtomValue } from 'jotai';
import {
SidebarAvatar,
SidebarItem,
SidebarItemBadge,
SidebarItemTooltip,
} from '../../../components/sidebar';
import { allInvitesAtom } from '../../../state/room-list/inviteList';
import {
getInboxInvitesPath,
getInboxNotificationsPath,
getInboxPath,
joinPathComponent,
} from '../../pathUtils';
import { useInboxSelected } from '../../../hooks/router/useInbox';
import { UnreadBadge } from '../../../components/unread-badge';
import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
import { useNavToActivePathAtom } from '../../../state/hooks/navToActivePath';
export function InboxTab() {
const screenSize = useScreenSizeContext();
const navigate = useNavigate();
const navToActivePath = useAtomValue(useNavToActivePathAtom());
const inboxSelected = useInboxSelected();
const allInvites = useAtomValue(allInvitesAtom);
const inviteCount = allInvites.length;
const handleInboxClick = () => {
if (screenSize === ScreenSize.Mobile) {
navigate(getInboxPath());
return;
}
const activePath = navToActivePath.get('inbox');
if (activePath) {
navigate(joinPathComponent(activePath));
return;
}
const path = inviteCount > 0 ? getInboxInvitesPath() : getInboxNotificationsPath();
navigate(path);
};
return (
<SidebarItem active={inboxSelected}>
<SidebarItemTooltip tooltip="Inbox">
{(triggerRef) => (
<SidebarAvatar as="button" ref={triggerRef} outlined onClick={handleInboxClick}>
<Icon src={Icons.Inbox} filled={inboxSelected} />
</SidebarAvatar>
)}
</SidebarItemTooltip>
{inviteCount > 0 && (
<SidebarItemBadge hasCount>
<UnreadBadge highlight count={inviteCount} />
</SidebarItemBadge>
)}
</SidebarItem>
);
}

View file

@ -0,0 +1,844 @@
import React, {
MouseEventHandler,
ReactNode,
RefObject,
forwardRef,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { useNavigate } from 'react-router-dom';
import {
Box,
Icon,
IconButton,
Icons,
Line,
Menu,
MenuItem,
PopOut,
RectCords,
Text,
config,
toRem,
} from 'folds';
import { useAtom, useAtomValue } from 'jotai';
import { Room } from 'matrix-js-sdk';
import {
draggable,
dropTargetForElements,
monitorForElements,
} from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
import {
attachInstruction,
extractInstruction,
Instruction,
} from '@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item';
import { autoScrollForElements } from '@atlaskit/pragmatic-drag-and-drop-auto-scroll/element';
import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine';
import FocusTrap from 'focus-trap-react';
import {
useOrphanSpaces,
useRecursiveChildScopeFactory,
useSpaceChildren,
} from '../../../state/hooks/roomList';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { roomToParentsAtom } from '../../../state/room/roomToParents';
import { allRoomsAtom } from '../../../state/room-list/roomList';
import {
getOriginBaseUrl,
getSpaceLobbyPath,
getSpacePath,
joinPathComponent,
withOriginBaseUrl,
} from '../../pathUtils';
import {
SidebarAvatar,
SidebarItem,
SidebarItemBadge,
SidebarItemTooltip,
SidebarStack,
SidebarStackSeparator,
SidebarFolder,
SidebarFolderDropTarget,
} from '../../../components/sidebar';
import { RoomUnreadProvider, RoomsUnreadProvider } from '../../../components/RoomUnreadProvider';
import { useSelectedSpace } from '../../../hooks/router/useSelectedSpace';
import { UnreadBadge } from '../../../components/unread-badge';
import { getCanonicalAliasOrRoomId } from '../../../utils/matrix';
import { RoomAvatar } from '../../../components/room-avatar';
import { nameInitials, randomStr } from '../../../utils/common';
import {
ISidebarFolder,
SidebarItems,
TSidebarItem,
makeCinnySpacesContent,
parseSidebar,
sidebarItemWithout,
useSidebarItems,
} from '../../../hooks/useSidebarItems';
import { AccountDataEvent } from '../../../../types/matrix/accountData';
import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
import { useNavToActivePathAtom } from '../../../state/hooks/navToActivePath';
import { useOpenedSidebarFolderAtom } from '../../../state/hooks/openedSidebarFolder';
import { useClientConfig } from '../../../hooks/useClientConfig';
import { usePowerLevels, usePowerLevelsAPI } from '../../../hooks/usePowerLevels';
import { useRoomsUnread } from '../../../state/hooks/unread';
import { roomToUnreadAtom } from '../../../state/room/roomToUnread';
import { markAsRead } from '../../../../client/action/notifications';
import { copyToClipboard } from '../../../utils/dom';
import { openInviteUser, openSpaceSettings } from '../../../../client/action/navigation';
type SpaceMenuProps = {
room: Room;
requestClose: () => void;
onUnpin?: (roomId: string) => void;
};
const SpaceMenu = forwardRef<HTMLDivElement, SpaceMenuProps>(
({ room, requestClose, onUnpin }, ref) => {
const mx = useMatrixClient();
const { hashRouter } = useClientConfig();
const roomToParents = useAtomValue(roomToParentsAtom);
const powerLevels = usePowerLevels(room);
const { getPowerLevel, canDoAction } = usePowerLevelsAPI(powerLevels);
const canInvite = canDoAction('invite', getPowerLevel(mx.getUserId() ?? ''));
const allChild = useSpaceChildren(
allRoomsAtom,
room.roomId,
useRecursiveChildScopeFactory(mx, roomToParents)
);
const unread = useRoomsUnread(allChild, roomToUnreadAtom);
const handleMarkAsRead = () => {
allChild.forEach((childRoomId) => markAsRead(childRoomId));
requestClose();
};
const handleUnpin = () => {
onUnpin?.(room.roomId);
requestClose();
};
const handleCopyLink = () => {
const spacePath = getSpacePath(getCanonicalAliasOrRoomId(mx, room.roomId));
copyToClipboard(withOriginBaseUrl(getOriginBaseUrl(hashRouter), spacePath));
requestClose();
};
const handleInvite = () => {
openInviteUser(room.roomId);
requestClose();
};
const handleRoomSettings = () => {
openSpaceSettings(room.roomId);
requestClose();
};
return (
<Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}>
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
<MenuItem
onClick={handleMarkAsRead}
size="300"
after={<Icon size="100" src={Icons.CheckTwice} />}
radii="300"
disabled={!unread}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Mark as Read
</Text>
</MenuItem>
{onUnpin && (
<MenuItem
size="300"
radii="300"
onClick={handleUnpin}
after={<Icon size="100" src={Icons.Pin} />}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Unpin
</Text>
</MenuItem>
)}
</Box>
<Line variant="Surface" size="300" />
<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={handleCopyLink}
size="300"
after={<Icon size="100" src={Icons.Link} />}
radii="300"
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Copy Link
</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>
</Menu>
);
}
);
type InstructionType = Instruction['type'];
type FolderDraggable = {
folder: ISidebarFolder;
spaceId?: string;
open?: boolean;
};
type SidebarDraggable = string | FolderDraggable;
const useDraggableItem = (
item: SidebarDraggable,
targetRef: RefObject<HTMLElement>,
onDragging: (item?: SidebarDraggable) => 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;
};
const useDropTarget = (
item: SidebarDraggable,
targetRef: RefObject<HTMLElement>
): Instruction | undefined => {
const [dropState, setDropState] = useState<Instruction>();
useEffect(() => {
const target = targetRef.current;
if (!target) return undefined;
return dropTargetForElements({
element: target,
canDrop: ({ source }) => {
const dragItem = source.data.item as SidebarDraggable;
return dragItem !== item;
},
getData: ({ input, element }) => {
const block: Instruction['type'][] = ['reparent'];
if (typeof item === 'object' && item.spaceId) block.push('make-child');
const insData = attachInstruction(
{},
{
input,
element,
currentLevel: 0,
indentPerLevel: 0,
mode: 'standard',
block,
}
);
const instruction: Instruction | null = extractInstruction(insData);
setDropState(instruction ?? undefined);
return {
item,
instructionType: instruction ? instruction.type : undefined,
};
},
onDragLeave: () => setDropState(undefined),
onDrop: () => setDropState(undefined),
});
}, [item, targetRef]);
return dropState;
};
function useDropTargetInstruction<T extends InstructionType>(
item: SidebarDraggable,
targetRef: RefObject<HTMLElement>,
instructionType: T
): T | undefined {
const [dropState, setDropState] = useState<T>();
useEffect(() => {
const target = targetRef.current;
if (!target) return undefined;
return dropTargetForElements({
element: target,
canDrop: ({ source }) => {
const dragItem = source.data.item as SidebarDraggable;
return dragItem !== item;
},
getData: () => {
setDropState(instructionType);
return {
item,
instructionType,
};
},
onDragLeave: () => setDropState(undefined),
onDrop: () => setDropState(undefined),
});
}, [item, targetRef, instructionType]);
return dropState;
}
const useDnDMonitor = (
scrollRef: RefObject<HTMLElement>,
onDragging: (dragItem?: SidebarDraggable) => void,
onReorder: (
draggable: SidebarDraggable,
container: SidebarDraggable,
instruction: InstructionType
) => 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;
const item = source.data.item as SidebarDraggable;
const containerItem = dropTargets[0].data.item as SidebarDraggable;
const instructionType = dropTargets[0].data.instructionType as
| InstructionType
| undefined;
if (!instructionType) return;
onReorder(item, containerItem, instructionType);
},
}),
autoScrollForElements({
element: scrollElement,
})
);
}, [scrollRef, onDragging, onReorder]);
};
type SpaceTabProps = {
space: Room;
selected: boolean;
onClick: MouseEventHandler<HTMLButtonElement>;
folder?: ISidebarFolder;
onDragging: (dragItem?: SidebarDraggable) => void;
disabled?: boolean;
onUnpin?: (roomId: string) => void;
};
function SpaceTab({
space,
selected,
onClick,
folder,
onDragging,
disabled,
onUnpin,
}: SpaceTabProps) {
const mx = useMatrixClient();
const targetRef = useRef<HTMLDivElement>(null);
const spaceDraggable: SidebarDraggable = useMemo(
() =>
folder
? {
folder,
spaceId: space.roomId,
}
: space.roomId,
[folder, space]
);
useDraggableItem(spaceDraggable, targetRef, onDragging);
const dropState = useDropTarget(spaceDraggable, targetRef);
const dropType = dropState?.type;
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
const handleContextMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
evt.preventDefault();
const cords = evt.currentTarget.getBoundingClientRect();
setMenuAnchor((currentState) => {
if (currentState) return undefined;
return cords;
});
};
return (
<RoomUnreadProvider roomId={space.roomId}>
{(unread) => (
<SidebarItem
active={selected}
ref={targetRef}
aria-disabled={disabled}
data-drop-child={dropType === 'make-child'}
data-drop-above={dropType === 'reorder-above'}
data-drop-below={dropType === 'reorder-below'}
data-inside-folder={!!folder}
>
<SidebarItemTooltip tooltip={disabled ? undefined : space.name}>
{(triggerRef) => (
<SidebarAvatar
as="button"
data-id={space.roomId}
ref={triggerRef}
size={folder ? '300' : '400'}
onClick={onClick}
onContextMenu={handleContextMenu}
>
<RoomAvatar
roomId={space.roomId}
src={space.getAvatarUrl(mx.baseUrl, 96, 96, 'crop') ?? undefined}
alt={space.name}
renderFallback={() => (
<Text size={folder ? 'H6' : 'H4'}>{nameInitials(space.name, 2)}</Text>
)}
/>
</SidebarAvatar>
)}
</SidebarItemTooltip>
{unread && (
<SidebarItemBadge hasCount={unread.total > 0}>
<UnreadBadge highlight={unread.highlight > 0} count={unread.total} />
</SidebarItemBadge>
)}
{menuAnchor && (
<PopOut
anchor={menuAnchor}
position="Right"
align="Start"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
returnFocusOnDeactivate: false,
onDeactivate: () => setMenuAnchor(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
}}
>
<SpaceMenu
room={space}
requestClose={() => setMenuAnchor(undefined)}
onUnpin={onUnpin}
/>
</FocusTrap>
}
/>
)}
</SidebarItem>
)}
</RoomUnreadProvider>
);
}
type OpenedSpaceFolderProps = {
folder: ISidebarFolder;
onClose: MouseEventHandler<HTMLButtonElement>;
children?: ReactNode;
};
function OpenedSpaceFolder({ folder, onClose, children }: OpenedSpaceFolderProps) {
const aboveTargetRef = useRef<HTMLDivElement>(null);
const belowTargetRef = useRef<HTMLDivElement>(null);
const spaceDraggable: SidebarDraggable = useMemo(() => ({ folder, open: true }), [folder]);
const orderAbove = useDropTargetInstruction(spaceDraggable, aboveTargetRef, 'reorder-above');
const orderBelow = useDropTargetInstruction(spaceDraggable, belowTargetRef, 'reorder-below');
return (
<SidebarFolder
state="Open"
data-drop-above={orderAbove === 'reorder-above'}
data-drop-below={orderBelow === 'reorder-below'}
>
<SidebarFolderDropTarget ref={aboveTargetRef} position="Top" />
<SidebarAvatar size="300">
<IconButton data-id={folder.id} size="300" variant="Background" onClick={onClose}>
<Icon size="400" src={Icons.ChevronTop} filled />
</IconButton>
</SidebarAvatar>
{children}
<SidebarFolderDropTarget ref={belowTargetRef} position="Bottom" />
</SidebarFolder>
);
}
type ClosedSpaceFolderProps = {
folder: ISidebarFolder;
selected: boolean;
onOpen: MouseEventHandler<HTMLButtonElement>;
onDragging: (dragItem?: SidebarDraggable) => void;
disabled?: boolean;
};
function ClosedSpaceFolder({
folder,
selected,
onOpen,
onDragging,
disabled,
}: ClosedSpaceFolderProps) {
const mx = useMatrixClient();
const handlerRef = useRef<HTMLDivElement>(null);
const spaceDraggable: FolderDraggable = useMemo(() => ({ folder }), [folder]);
useDraggableItem(spaceDraggable, handlerRef, onDragging);
const dropState = useDropTarget(spaceDraggable, handlerRef);
const dropType = dropState?.type;
const tooltipName =
folder.name ?? folder.content.map((i) => mx.getRoom(i)?.name ?? '').join(', ') ?? 'Unnamed';
return (
<RoomsUnreadProvider rooms={folder.content}>
{(unread) => (
<SidebarItem
active={selected}
ref={handlerRef}
aria-disabled={disabled}
data-drop-child={dropType === 'make-child'}
data-drop-above={dropType === 'reorder-above'}
data-drop-below={dropType === 'reorder-below'}
>
<SidebarItemTooltip tooltip={disabled ? undefined : tooltipName}>
{(tooltipRef) => (
<SidebarFolder data-id={folder.id} as="button" ref={tooltipRef} onClick={onOpen}>
{folder.content.map((sId) => {
const space = mx.getRoom(sId);
if (!space) return null;
return (
<SidebarAvatar key={sId} size="200" radii="300">
<RoomAvatar
roomId={space.roomId}
src={space.getAvatarUrl(mx.baseUrl, 96, 96, 'crop') ?? undefined}
alt={space.name}
renderFallback={() => (
<Text size="Inherit">
<b>{nameInitials(space.name, 2)}</b>
</Text>
)}
/>
</SidebarAvatar>
);
})}
</SidebarFolder>
)}
</SidebarItemTooltip>
{unread && (
<SidebarItemBadge hasCount={unread.total > 0}>
<UnreadBadge highlight={unread.highlight > 0} count={unread.total} />
</SidebarItemBadge>
)}
</SidebarItem>
)}
</RoomsUnreadProvider>
);
}
type SpaceTabsProps = {
scrollRef: RefObject<HTMLDivElement>;
};
export function SpaceTabs({ scrollRef }: SpaceTabsProps) {
const navigate = useNavigate();
const mx = useMatrixClient();
const screenSize = useScreenSizeContext();
const roomToParents = useAtomValue(roomToParentsAtom);
const orphanSpaces = useOrphanSpaces(mx, allRoomsAtom, roomToParents);
const [sidebarItems, localEchoSidebarItem] = useSidebarItems(orphanSpaces);
const navToActivePath = useAtomValue(useNavToActivePathAtom());
const [openedFolder, setOpenedFolder] = useAtom(useOpenedSidebarFolderAtom());
const [draggingItem, setDraggingItem] = useState<SidebarDraggable>();
useDnDMonitor(
scrollRef,
setDraggingItem,
useCallback(
(item, containerItem, instructionType) => {
const newItems: SidebarItems = [];
const matchDest = (sI: TSidebarItem, dI: SidebarDraggable): boolean => {
if (typeof sI === 'string' && typeof dI === 'string') {
return sI === dI;
}
if (typeof sI === 'object' && typeof dI === 'object') {
return sI.id === dI.folder.id;
}
return false;
};
const itemAsFolderContent = (i: SidebarDraggable): string[] => {
if (typeof i === 'string') {
return [i];
}
if (i.spaceId) {
return [i.spaceId];
}
return [...i.folder.content];
};
sidebarItems.forEach((i) => {
const sameFolders =
typeof item === 'object' &&
typeof containerItem === 'object' &&
item.folder.id === containerItem.folder.id;
// remove draggable space from current position or folder
if (!sameFolders && matchDest(i, item)) {
if (typeof item === 'object' && item.spaceId) {
const folderContent = item.folder.content.filter((s) => s !== item.spaceId);
if (folderContent.length === 0) {
// remove open state from local storage
setOpenedFolder({ type: 'DELETE', id: item.folder.id });
return;
}
newItems.push({
...item.folder,
content: folderContent,
});
}
return;
}
if (matchDest(i, containerItem)) {
// we can make child only if
// container item is space or closed folder
if (instructionType === 'make-child') {
const child: string[] = itemAsFolderContent(item);
if (typeof containerItem === 'string') {
const folder: ISidebarFolder = {
id: randomStr(),
content: [containerItem].concat(child),
};
newItems.push(folder);
return;
}
newItems.push({
...containerItem.folder,
content: containerItem.folder.content.concat(child),
});
return;
}
// drop inside opened folder
// or reordering inside same folder
if (typeof containerItem === 'object' && containerItem.spaceId) {
const child = itemAsFolderContent(item);
const newContent: string[] = [];
containerItem.folder.content
.filter((sId) => !child.includes(sId))
.forEach((sId) => {
if (sId === containerItem.spaceId) {
if (instructionType === 'reorder-below') {
newContent.push(sId, ...child);
}
if (instructionType === 'reorder-above') {
newContent.push(...child, sId);
}
return;
}
newContent.push(sId);
});
const folder = {
...containerItem.folder,
content: newContent,
};
newItems.push(folder);
return;
}
// drop above or below space or closed/opened folder
if (typeof item === 'string') {
if (instructionType === 'reorder-below') newItems.push(i);
newItems.push(item);
if (instructionType === 'reorder-above') newItems.push(i);
} else if (item.spaceId) {
if (instructionType === 'reorder-above') {
newItems.push(item.spaceId);
}
if (sameFolders && typeof i === 'object') {
// remove from folder if placing around itself
const newI = { ...i, content: i.content.filter((sId) => sId !== item.spaceId) };
if (newI.content.length > 0) newItems.push(newI);
} else {
newItems.push(i);
}
if (instructionType === 'reorder-below') {
newItems.push(item.spaceId);
}
} else {
if (instructionType === 'reorder-below') newItems.push(i);
newItems.push(item.folder);
if (instructionType === 'reorder-above') newItems.push(i);
}
return;
}
newItems.push(i);
});
const newSpacesContent = makeCinnySpacesContent(mx, newItems);
localEchoSidebarItem(parseSidebar(mx, orphanSpaces, newSpacesContent));
mx.setAccountData(AccountDataEvent.CinnySpaces, newSpacesContent);
},
[mx, sidebarItems, setOpenedFolder, localEchoSidebarItem, orphanSpaces]
)
);
const selectedSpaceId = useSelectedSpace();
const handleSpaceClick: MouseEventHandler<HTMLButtonElement> = (evt) => {
const target = evt.currentTarget;
const targetSpaceId = target.getAttribute('data-id');
if (!targetSpaceId) return;
if (screenSize === ScreenSize.Mobile) {
navigate(getSpacePath(getCanonicalAliasOrRoomId(mx, targetSpaceId)));
return;
}
const activePath = navToActivePath.get(targetSpaceId);
if (activePath) {
navigate(joinPathComponent(activePath));
return;
}
navigate(getSpaceLobbyPath(getCanonicalAliasOrRoomId(mx, targetSpaceId)));
};
const handleFolderToggle: MouseEventHandler<HTMLButtonElement> = (evt) => {
const target = evt.currentTarget;
const targetFolderId = target.getAttribute('data-id');
if (!targetFolderId) return;
setOpenedFolder({
type: openedFolder.has(targetFolderId) ? 'DELETE' : 'PUT',
id: targetFolderId,
});
};
const handleUnpin = useCallback(
(roomId: string) => {
if (orphanSpaces.includes(roomId)) return;
const newItems = sidebarItemWithout(sidebarItems, roomId);
const newSpacesContent = makeCinnySpacesContent(mx, newItems);
localEchoSidebarItem(parseSidebar(mx, orphanSpaces, newSpacesContent));
mx.setAccountData(AccountDataEvent.CinnySpaces, newSpacesContent);
},
[mx, sidebarItems, orphanSpaces, localEchoSidebarItem]
);
if (sidebarItems.length === 0) return null;
return (
<>
<SidebarStackSeparator />
<SidebarStack>
{sidebarItems.map((item) => {
if (typeof item === 'object') {
if (openedFolder.has(item.id)) {
return (
<OpenedSpaceFolder key={item.id} folder={item} onClose={handleFolderToggle}>
{item.content.map((sId) => {
const space = mx.getRoom(sId);
if (!space) return null;
return (
<SpaceTab
key={space.roomId}
space={space}
selected={space.roomId === selectedSpaceId}
onClick={handleSpaceClick}
folder={item}
onDragging={setDraggingItem}
disabled={
typeof draggingItem === 'object'
? draggingItem.spaceId === space.roomId
: false
}
onUnpin={orphanSpaces.includes(space.roomId) ? undefined : handleUnpin}
/>
);
})}
</OpenedSpaceFolder>
);
}
return (
<ClosedSpaceFolder
key={item.id}
folder={item}
selected={!!selectedSpaceId && item.content.includes(selectedSpaceId)}
onOpen={handleFolderToggle}
onDragging={setDraggingItem}
disabled={
typeof draggingItem === 'object' ? draggingItem.folder.id === item.id : false
}
/>
);
}
const space = mx.getRoom(item);
if (!space) return null;
return (
<SpaceTab
key={space.roomId}
space={space}
selected={space.roomId === selectedSpaceId}
onClick={handleSpaceClick}
onDragging={setDraggingItem}
disabled={typeof draggingItem === 'string' ? draggingItem === space.roomId : false}
onUnpin={orphanSpaces.includes(space.roomId) ? undefined : handleUnpin}
/>
);
})}
</SidebarStack>
</>
);
}

View file

@ -0,0 +1,63 @@
import React, { useEffect, useState } from 'react';
import { Text } from 'folds';
import { UserEvent, UserEventHandlerMap } from 'matrix-js-sdk';
import { SidebarItem, SidebarItemTooltip, SidebarAvatar } from '../../../components/sidebar';
import { openSettings } from '../../../../client/action/navigation';
import { UserAvatar } from '../../../components/user-avatar';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { getMxIdLocalPart } from '../../../utils/matrix';
import { nameInitials } from '../../../utils/common';
type UserProfile = {
avatar_url?: string;
displayname?: string;
};
export function UserTab() {
const mx = useMatrixClient();
const userId = mx.getUserId()!;
const [profile, setProfile] = useState<UserProfile>({});
const displayName = profile.displayname ?? getMxIdLocalPart(userId) ?? userId;
const avatarUrl = profile.avatar_url
? mx.mxcUrlToHttp(profile.avatar_url, 96, 96, 'crop') ?? undefined
: undefined;
useEffect(() => {
const user = mx.getUser(userId);
const onAvatarChange: UserEventHandlerMap[UserEvent.AvatarUrl] = (event, myUser) => {
setProfile((cp) => ({
...cp,
avatar_url: myUser.avatarUrl,
}));
};
const onDisplayNameChange: UserEventHandlerMap[UserEvent.DisplayName] = (event, myUser) => {
setProfile((cp) => ({
...cp,
avatar_url: myUser.displayName,
}));
};
mx.getProfileInfo(userId).then((info) => setProfile(() => ({ ...info })));
user?.on(UserEvent.AvatarUrl, onAvatarChange);
user?.on(UserEvent.DisplayName, onDisplayNameChange);
return () => {
user?.removeListener(UserEvent.AvatarUrl, onAvatarChange);
user?.removeListener(UserEvent.DisplayName, onDisplayNameChange);
};
}, [mx, userId]);
return (
<SidebarItem>
<SidebarItemTooltip tooltip="User Settings">
{(triggerRef) => (
<SidebarAvatar as="button" ref={triggerRef} onClick={() => openSettings()}>
<UserAvatar
userId={userId}
src={avatarUrl}
renderFallback={() => <Text size="H4">{nameInitials(displayName)}</Text>}
/>
</SidebarAvatar>
)}
</SidebarItemTooltip>
</SidebarItem>
);
}

View file

@ -0,0 +1,6 @@
export * from './HomeTab';
export * from './DirectTab';
export * from './SpaceTabs';
export * from './InboxTab';
export * from './ExploreTab';
export * from './UserTab';

View file

@ -0,0 +1,37 @@
import React, { ReactNode } from 'react';
import { useParams } from 'react-router-dom';
import { useAtomValue } from 'jotai';
import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom';
import { RoomProvider } from '../../../hooks/useRoom';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { JoinBeforeNavigate } from '../../../features/join-before-navigate';
import { useSpace } from '../../../hooks/useSpace';
import { getAllParents } from '../../../utils/room';
import { roomToParentsAtom } from '../../../state/room/roomToParents';
import { allRoomsAtom } from '../../../state/room-list/roomList';
export function SpaceRouteRoomProvider({ children }: { children: ReactNode }) {
const mx = useMatrixClient();
const space = useSpace();
const roomToParents = useAtomValue(roomToParentsAtom);
const allRooms = useAtomValue(allRoomsAtom);
const { roomIdOrAlias } = useParams();
const roomId = useSelectedRoom();
const room = mx.getRoom(roomId);
if (
!room ||
room.isSpaceRoom() ||
!allRooms.includes(room.roomId) ||
!getAllParents(roomToParents, room.roomId).has(space.roomId)
) {
return <JoinBeforeNavigate roomIdOrAlias={roomIdOrAlias!} />;
}
return (
<RoomProvider key={room.roomId} value={room}>
{children}
</RoomProvider>
);
}

View file

@ -0,0 +1,52 @@
import React, { useRef } from 'react';
import { Box, Icon, Icons, Text, Scroll } from 'folds';
import { useAtomValue } from 'jotai';
import { Page, PageContent, PageContentCenter, PageHeader } from '../../../components/page';
import { MessageSearch } from '../../../features/message-search';
import { useSpace } from '../../../hooks/useSpace';
import { useRecursiveChildRoomScopeFactory, useSpaceChildren } from '../../../state/hooks/roomList';
import { allRoomsAtom } from '../../../state/room-list/roomList';
import { mDirectAtom } from '../../../state/mDirectList';
import { roomToParentsAtom } from '../../../state/room/roomToParents';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
export function SpaceSearch() {
const mx = useMatrixClient();
const scrollRef = useRef<HTMLDivElement>(null);
const space = useSpace();
const mDirects = useAtomValue(mDirectAtom);
const roomToParents = useAtomValue(roomToParentsAtom);
const rooms = useSpaceChildren(
allRoomsAtom,
space.roomId,
useRecursiveChildRoomScopeFactory(mx, mDirects, roomToParents)
);
return (
<Page>
<PageHeader>
<Box grow="Yes" justifyContent="Center" alignItems="Center" gap="200">
<Icon size="400" src={Icons.Search} />
<Text size="H3" truncate>
Message Search
</Text>
</Box>
</PageHeader>
<Box style={{ position: 'relative' }} grow="Yes">
<Scroll ref={scrollRef} hideTrack visibility="Hover">
<PageContent>
<PageContentCenter>
<MessageSearch
defaultRoomsFilterName={space.name}
allowGlobal
rooms={rooms}
scrollRef={scrollRef}
/>
</PageContentCenter>
</PageContent>
</Scroll>
</Box>
</Page>
);
}

View file

@ -0,0 +1,417 @@
import React, {
MouseEventHandler,
forwardRef,
useCallback,
useMemo,
useRef,
useState,
} from 'react';
import { useAtom, useAtomValue } from 'jotai';
import {
Avatar,
Box,
Icon,
IconButton,
Icons,
Line,
Menu,
MenuItem,
PopOut,
RectCords,
Text,
config,
toRem,
} from 'folds';
import { useVirtualizer } from '@tanstack/react-virtual';
import { IJoinRuleEventContent, JoinRule, Room } from 'matrix-js-sdk';
import FocusTrap from 'focus-trap-react';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { mDirectAtom } from '../../../state/mDirectList';
import {
NavCategory,
NavCategoryHeader,
NavItem,
NavItemContent,
NavLink,
} from '../../../components/nav';
import {
getOriginBaseUrl,
getSpaceLobbyPath,
getSpacePath,
getSpaceRoomPath,
getSpaceSearchPath,
withOriginBaseUrl,
} from '../../pathUtils';
import { getCanonicalAliasOrRoomId } from '../../../utils/matrix';
import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom';
import {
useSpaceLobbySelected,
useSpaceSearchSelected,
} from '../../../hooks/router/useSelectedSpace';
import { useSpace } from '../../../hooks/useSpace';
import { VirtualTile } from '../../../components/virtualizer';
import { RoomNavCategoryButton, RoomNavItem } from '../../../features/room-nav';
import { muteChangesAtom } from '../../../state/room-list/mutedRoomList';
import { makeNavCategoryId } from '../../../state/closedNavCategories';
import { roomToUnreadAtom } from '../../../state/room/roomToUnread';
import { useCategoryHandler } from '../../../hooks/useCategoryHandler';
import { useNavToActivePathMapper } from '../../../hooks/useNavToActivePathMapper';
import { useRoomName } from '../../../hooks/useRoomMeta';
import { useSpaceJoinedHierarchy } from '../../../hooks/useSpaceHierarchy';
import { allRoomsAtom } from '../../../state/room-list/roomList';
import { PageNav, PageNavContent, PageNavHeader } from '../../../components/page';
import { usePowerLevels, usePowerLevelsAPI } from '../../../hooks/usePowerLevels';
import { openInviteUser, openSpaceSettings } from '../../../../client/action/navigation';
import { useRecursiveChildScopeFactory, useSpaceChildren } from '../../../state/hooks/roomList';
import { roomToParentsAtom } from '../../../state/room/roomToParents';
import { markAsRead } from '../../../../client/action/notifications';
import { useRoomsUnread } from '../../../state/hooks/unread';
import { UseStateProvider } from '../../../components/UseStateProvider';
import { LeaveSpacePrompt } from '../../../components/leave-space-prompt';
import { copyToClipboard } from '../../../utils/dom';
import { useClientConfig } from '../../../hooks/useClientConfig';
import { useClosedNavCategoriesAtom } from '../../../state/hooks/closedNavCategories';
import { useStateEvent } from '../../../hooks/useStateEvent';
import { StateEvent } from '../../../../types/matrix/room';
type SpaceMenuProps = {
room: Room;
requestClose: () => void;
};
const SpaceMenu = forwardRef<HTMLDivElement, SpaceMenuProps>(({ room, requestClose }, ref) => {
const mx = useMatrixClient();
const { hashRouter } = useClientConfig();
const roomToParents = useAtomValue(roomToParentsAtom);
const powerLevels = usePowerLevels(room);
const { getPowerLevel, canDoAction } = usePowerLevelsAPI(powerLevels);
const canInvite = canDoAction('invite', getPowerLevel(mx.getUserId() ?? ''));
const allChild = useSpaceChildren(
allRoomsAtom,
room.roomId,
useRecursiveChildScopeFactory(mx, roomToParents)
);
const unread = useRoomsUnread(allChild, roomToUnreadAtom);
const handleMarkAsRead = () => {
allChild.forEach((childRoomId) => markAsRead(childRoomId));
requestClose();
};
const handleCopyLink = () => {
const spacePath = getSpacePath(getCanonicalAliasOrRoomId(mx, room.roomId));
copyToClipboard(withOriginBaseUrl(getOriginBaseUrl(hashRouter), spacePath));
requestClose();
};
const handleInvite = () => {
openInviteUser(room.roomId);
requestClose();
};
const handleRoomSettings = () => {
openSpaceSettings(room.roomId);
requestClose();
};
return (
<Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}>
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
<MenuItem
onClick={handleMarkAsRead}
size="300"
after={<Icon size="100" src={Icons.CheckTwice} />}
radii="300"
disabled={!unread}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Mark as Read
</Text>
</MenuItem>
</Box>
<Line variant="Surface" size="300" />
<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={handleCopyLink}
size="300"
after={<Icon size="100" src={Icons.Link} />}
radii="300"
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Copy Link
</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={room.roomId}
onDone={requestClose}
onCancel={() => setPromptLeave(false)}
/>
)}
</>
)}
</UseStateProvider>
</Box>
</Menu>
);
});
function SpaceHeader() {
const space = useSpace();
const spaceName = useRoomName(space);
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
const joinRules = useStateEvent(
space,
StateEvent.RoomJoinRules
)?.getContent<IJoinRuleEventContent>();
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
const cords = evt.currentTarget.getBoundingClientRect();
setMenuAnchor((currentState) => {
if (currentState) return undefined;
return cords;
});
};
return (
<>
<PageNavHeader>
<Box alignItems="Center" grow="Yes" gap="300">
<Box grow="Yes" alignItems="Center" gap="100">
<Text size="H4" truncate>
{spaceName}
</Text>
{joinRules?.join_rule !== JoinRule.Public && <Icon src={Icons.Lock} size="50" />}
</Box>
<Box>
<IconButton aria-pressed={!!menuAnchor} variant="Background" onClick={handleOpenMenu}>
<Icon src={Icons.VerticalDots} size="200" />
</IconButton>
</Box>
</Box>
</PageNavHeader>
{menuAnchor && (
<PopOut
anchor={menuAnchor}
position="Bottom"
align="End"
offset={6}
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
returnFocusOnDeactivate: false,
onDeactivate: () => setMenuAnchor(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
}}
>
<SpaceMenu room={space} requestClose={() => setMenuAnchor(undefined)} />
</FocusTrap>
}
/>
)}
</>
);
}
export function Space() {
const mx = useMatrixClient();
const space = useSpace();
useNavToActivePathMapper(space.roomId);
const spaceIdOrAlias = getCanonicalAliasOrRoomId(mx, space.roomId);
const scrollRef = useRef<HTMLDivElement>(null);
const mDirects = useAtomValue(mDirectAtom);
const roomToUnread = useAtomValue(roomToUnreadAtom);
const allRooms = useAtomValue(allRoomsAtom);
const allJoinedRooms = useMemo(() => new Set(allRooms), [allRooms]);
const muteChanges = useAtomValue(muteChangesAtom);
const mutedRooms = muteChanges.added;
const selectedRoomId = useSelectedRoom();
const lobbySelected = useSpaceLobbySelected(spaceIdOrAlias);
const searchSelected = useSpaceSearchSelected(spaceIdOrAlias);
const [closedCategories, setClosedCategories] = useAtom(useClosedNavCategoriesAtom());
const getRoom = useCallback(
(rId: string) => {
if (allJoinedRooms.has(rId)) {
return mx.getRoom(rId) ?? undefined;
}
return undefined;
},
[mx, allJoinedRooms]
);
const hierarchy = useSpaceJoinedHierarchy(
space.roomId,
getRoom,
useCallback(
(parentId, roomId) => {
if (!closedCategories.has(makeNavCategoryId(space.roomId, parentId))) {
return false;
}
const showRoom = roomToUnread.has(roomId) || roomId === selectedRoomId;
if (showRoom) return false;
return true;
},
[space.roomId, closedCategories, roomToUnread, selectedRoomId]
),
useCallback(
(sId) => closedCategories.has(makeNavCategoryId(space.roomId, sId)),
[closedCategories, space.roomId]
)
);
const virtualizer = useVirtualizer({
count: hierarchy.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => 0,
overscan: 10,
});
const handleCategoryClick = useCategoryHandler(setClosedCategories, (categoryId) =>
closedCategories.has(categoryId)
);
const getToLink = (roomId: string) =>
getSpaceRoomPath(spaceIdOrAlias, getCanonicalAliasOrRoomId(mx, roomId));
return (
<PageNav>
<SpaceHeader />
<PageNavContent scrollRef={scrollRef}>
<Box direction="Column" gap="300">
<NavCategory>
<NavItem variant="Background" radii="400" aria-selected={lobbySelected}>
<NavLink to={getSpaceLobbyPath(getCanonicalAliasOrRoomId(mx, space.roomId))}>
<NavItemContent>
<Box as="span" grow="Yes" alignItems="Center" gap="200">
<Avatar size="200" radii="400">
<Icon src={Icons.Flag} size="100" filled={lobbySelected} />
</Avatar>
<Box as="span" grow="Yes">
<Text as="span" size="Inherit" truncate>
Lobby
</Text>
</Box>
</Box>
</NavItemContent>
</NavLink>
</NavItem>
<NavItem variant="Background" radii="400" aria-selected={searchSelected}>
<NavLink to={getSpaceSearchPath(getCanonicalAliasOrRoomId(mx, space.roomId))}>
<NavItemContent>
<Box as="span" grow="Yes" alignItems="Center" gap="200">
<Avatar size="200" radii="400">
<Icon src={Icons.Search} size="100" filled={searchSelected} />
</Avatar>
<Box as="span" grow="Yes">
<Text as="span" size="Inherit" truncate>
Message Search
</Text>
</Box>
</Box>
</NavItemContent>
</NavLink>
</NavItem>
</NavCategory>
<NavCategory
style={{
height: virtualizer.getTotalSize(),
position: 'relative',
}}
>
{virtualizer.getVirtualItems().map((vItem) => {
const { roomId } = hierarchy[vItem.index] ?? {};
const room = mx.getRoom(roomId);
if (!room) return null;
if (room.isSpaceRoom()) {
const categoryId = makeNavCategoryId(space.roomId, roomId);
return (
<VirtualTile
virtualItem={vItem}
key={vItem.index}
ref={virtualizer.measureElement}
>
<div style={{ paddingTop: vItem.index === 0 ? undefined : config.space.S400 }}>
<NavCategoryHeader>
<RoomNavCategoryButton
data-category-id={categoryId}
onClick={handleCategoryClick}
closed={closedCategories.has(categoryId)}
>
{roomId === space.roomId ? 'Rooms' : room?.name}
</RoomNavCategoryButton>
</NavCategoryHeader>
</div>
</VirtualTile>
);
}
return (
<VirtualTile virtualItem={vItem} key={vItem.index} ref={virtualizer.measureElement}>
<RoomNavItem
room={room}
selected={selectedRoomId === roomId}
showAvatar={mDirects.has(roomId)}
direct={mDirects.has(roomId)}
linkPath={getToLink(roomId)}
muted={mutedRooms.includes(roomId)}
/>
</VirtualTile>
);
})}
</NavCategory>
</Box>
</PageNavContent>
</PageNav>
);
}

View file

@ -0,0 +1,30 @@
import React, { ReactNode } from 'react';
import { useParams } from 'react-router-dom';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useSpaces } from '../../../state/hooks/roomList';
import { allRoomsAtom } from '../../../state/room-list/roomList';
import { useSelectedSpace } from '../../../hooks/router/useSelectedSpace';
import { SpaceProvider } from '../../../hooks/useSpace';
import { JoinBeforeNavigate } from '../../../features/join-before-navigate';
type RouteSpaceProviderProps = {
children: ReactNode;
};
export function RouteSpaceProvider({ children }: RouteSpaceProviderProps) {
const mx = useMatrixClient();
const joinedSpaces = useSpaces(mx, allRoomsAtom);
const { spaceIdOrAlias } = useParams();
const selectedSpaceId = useSelectedSpace();
const space = mx.getRoom(selectedSpaceId);
if (!space || !joinedSpaces.includes(space.roomId)) {
return <JoinBeforeNavigate roomIdOrAlias={spaceIdOrAlias ?? ''} />;
}
return (
<SpaceProvider key={space.roomId} value={space}>
{children}
</SpaceProvider>
);
}

View file

@ -0,0 +1,4 @@
export * from './SpaceProvider';
export * from './Space';
export * from './Search';
export * from './RoomProvider';