mirror of
https://github.com/cinnyapp/cinny.git
synced 2025-11-14 19:20:28 +03:00
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:
parent
2b7d825694
commit
4c76a7fd18
290 changed files with 17447 additions and 3224 deletions
14
src/app/pages/client/ClientBindAtoms.ts
Normal file
14
src/app/pages/client/ClientBindAtoms.ts
Normal 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;
|
||||
}
|
||||
38
src/app/pages/client/ClientInitStorageAtom.tsx
Normal file
38
src/app/pages/client/ClientInitStorageAtom.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
15
src/app/pages/client/ClientLayout.tsx
Normal file
15
src/app/pages/client/ClientLayout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
87
src/app/pages/client/ClientRoot.tsx
Normal file
87
src/app/pages/client/ClientRoot.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
76
src/app/pages/client/SidebarNav.tsx
Normal file
76
src/app/pages/client/SidebarNav.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
46
src/app/pages/client/SpecVersions.tsx
Normal file
46
src/app/pages/client/SpecVersions.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
64
src/app/pages/client/WelcomePage.tsx
Normal file
64
src/app/pages/client/WelcomePage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
263
src/app/pages/client/direct/Direct.tsx
Normal file
263
src/app/pages/client/direct/Direct.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
26
src/app/pages/client/direct/RoomProvider.tsx
Normal file
26
src/app/pages/client/direct/RoomProvider.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
2
src/app/pages/client/direct/index.ts
Normal file
2
src/app/pages/client/direct/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from './Direct';
|
||||
export * from './RoomProvider';
|
||||
12
src/app/pages/client/direct/useDirectRooms.ts
Normal file
12
src/app/pages/client/direct/useDirectRooms.ts
Normal 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;
|
||||
};
|
||||
269
src/app/pages/client/explore/Explore.tsx
Normal file
269
src/app/pages/client/explore/Explore.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
121
src/app/pages/client/explore/Featured.tsx
Normal file
121
src/app/pages/client/explore/Featured.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
645
src/app/pages/client/explore/Server.tsx
Normal file
645
src/app/pages/client/explore/Server.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
3
src/app/pages/client/explore/index.ts
Normal file
3
src/app/pages/client/explore/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export * from './Explore';
|
||||
export * from './Server';
|
||||
export * from './Featured';
|
||||
19
src/app/pages/client/explore/style.css.ts
Normal file
19
src/app/pages/client/explore/style.css.ts
Normal 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,
|
||||
},
|
||||
]);
|
||||
315
src/app/pages/client/home/Home.tsx
Normal file
315
src/app/pages/client/home/Home.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
26
src/app/pages/client/home/RoomProvider.tsx
Normal file
26
src/app/pages/client/home/RoomProvider.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
37
src/app/pages/client/home/Search.tsx
Normal file
37
src/app/pages/client/home/Search.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
3
src/app/pages/client/home/index.ts
Normal file
3
src/app/pages/client/home/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export * from './Home';
|
||||
export * from './Search';
|
||||
export * from './RoomProvider';
|
||||
14
src/app/pages/client/home/useHomeRooms.ts
Normal file
14
src/app/pages/client/home/useHomeRooms.ts
Normal 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;
|
||||
};
|
||||
87
src/app/pages/client/inbox/Inbox.tsx
Normal file
87
src/app/pages/client/inbox/Inbox.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
288
src/app/pages/client/inbox/Invites.tsx
Normal file
288
src/app/pages/client/inbox/Invites.tsx
Normal 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't have any new pending invitations to display yet.
|
||||
</Text>
|
||||
</SequenceCard>
|
||||
</div>
|
||||
)}
|
||||
</Box>
|
||||
</PageContentCenter>
|
||||
</PageContent>
|
||||
</Scroll>
|
||||
</Box>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
609
src/app/pages/client/inbox/Notifications.tsx
Normal file
609
src/app/pages/client/inbox/Notifications.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
3
src/app/pages/client/inbox/index.ts
Normal file
3
src/app/pages/client/inbox/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export * from './Inbox';
|
||||
export * from './Notifications';
|
||||
export * from './Invites';
|
||||
3
src/app/pages/client/index.ts
Normal file
3
src/app/pages/client/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export * from './ClientRoot';
|
||||
export * from './ClientBindAtoms';
|
||||
export * from './ClientLayout';
|
||||
132
src/app/pages/client/sidebar/DirectTab.tsx
Normal file
132
src/app/pages/client/sidebar/DirectTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
64
src/app/pages/client/sidebar/ExploreTab.tsx
Normal file
64
src/app/pages/client/sidebar/ExploreTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
134
src/app/pages/client/sidebar/HomeTab.tsx
Normal file
134
src/app/pages/client/sidebar/HomeTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
62
src/app/pages/client/sidebar/InboxTab.tsx
Normal file
62
src/app/pages/client/sidebar/InboxTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
844
src/app/pages/client/sidebar/SpaceTabs.tsx
Normal file
844
src/app/pages/client/sidebar/SpaceTabs.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
63
src/app/pages/client/sidebar/UserTab.tsx
Normal file
63
src/app/pages/client/sidebar/UserTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
6
src/app/pages/client/sidebar/index.ts
Normal file
6
src/app/pages/client/sidebar/index.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export * from './HomeTab';
|
||||
export * from './DirectTab';
|
||||
export * from './SpaceTabs';
|
||||
export * from './InboxTab';
|
||||
export * from './ExploreTab';
|
||||
export * from './UserTab';
|
||||
37
src/app/pages/client/space/RoomProvider.tsx
Normal file
37
src/app/pages/client/space/RoomProvider.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
52
src/app/pages/client/space/Search.tsx
Normal file
52
src/app/pages/client/space/Search.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
417
src/app/pages/client/space/Space.tsx
Normal file
417
src/app/pages/client/space/Space.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
30
src/app/pages/client/space/SpaceProvider.tsx
Normal file
30
src/app/pages/client/space/SpaceProvider.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
4
src/app/pages/client/space/index.ts
Normal file
4
src/app/pages/client/space/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export * from './SpaceProvider';
|
||||
export * from './Space';
|
||||
export * from './Search';
|
||||
export * from './RoomProvider';
|
||||
Loading…
Add table
Add a link
Reference in a new issue