add create space

This commit is contained in:
Ajay Bura 2025-08-04 14:06:22 +05:30
parent 8a72f40514
commit ae5ebb5af5
10 changed files with 420 additions and 15 deletions

View file

@ -0,0 +1,249 @@
import React, { FormEventHandler, useCallback, useState } from 'react';
import { MatrixError, Room } from 'matrix-js-sdk';
import {
Box,
Button,
Chip,
color,
config,
Icon,
Icons,
Input,
Spinner,
Switch,
Text,
TextArea,
} from 'folds';
import { SettingTile } from '../../components/setting-tile';
import { SequenceCard } from '../../components/sequence-card';
import { knockRestrictedSupported, knockSupported, restrictedSupported } from '../../utils/matrix';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { millisecondsToMinutes, replaceSpaceWithDash } from '../../utils/common';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { useCapabilities } from '../../hooks/useCapabilities';
import { useAlive } from '../../hooks/useAlive';
import { ErrorCode } from '../../cs-errorcode';
import {
createRoom,
CreateRoomAliasInput,
CreateRoomData,
CreateRoomKind,
CreateRoomKindSelector,
RoomVersionSelector,
} from '../../components/create-room';
import { RoomType } from '../../../types/matrix/room';
const getCreateSpaceKindToIcon = (kind: CreateRoomKind) => {
if (kind === CreateRoomKind.Private) return Icons.SpaceLock;
if (kind === CreateRoomKind.Restricted) return Icons.Space;
return Icons.SpaceGlobe;
};
type CreateSpaceFormProps = {
defaultKind?: CreateRoomKind;
space?: Room;
onCreate?: (roomId: string) => void;
};
export function CreateSpaceForm({ defaultKind, space, onCreate }: CreateSpaceFormProps) {
const mx = useMatrixClient();
const alive = useAlive();
const capabilities = useCapabilities();
const roomVersions = capabilities['m.room_versions'];
const [selectedRoomVersion, selectRoomVersion] = useState(roomVersions?.default ?? '1');
const allowRestricted = space && restrictedSupported(selectedRoomVersion);
const [kind, setKind] = useState(
defaultKind ?? allowRestricted ? CreateRoomKind.Restricted : CreateRoomKind.Private
);
const [federation, setFederation] = useState(true);
const [knock, setKnock] = useState(false);
const [advance, setAdvance] = useState(false);
const allowKnock = kind === CreateRoomKind.Private && knockSupported(selectedRoomVersion);
const allowKnockRestricted =
kind === CreateRoomKind.Restricted && knockRestrictedSupported(selectedRoomVersion);
const handleRoomVersionChange = (version: string) => {
if (!restrictedSupported(version)) {
setKind(CreateRoomKind.Private);
}
selectRoomVersion(version);
};
const [createState, create] = useAsyncCallback<string, Error | MatrixError, [CreateRoomData]>(
useCallback((data) => createRoom(mx, data), [mx])
);
const loading = createState.status === AsyncStatus.Loading;
const error = createState.status === AsyncStatus.Error ? createState.error : undefined;
const disabled = createState.status === AsyncStatus.Loading;
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
evt.preventDefault();
if (disabled) return;
const form = evt.currentTarget;
const nameInput = form.nameInput as HTMLInputElement | undefined;
const topicTextArea = form.topicTextAria as HTMLTextAreaElement | undefined;
const aliasInput = form.aliasInput as HTMLInputElement | undefined;
const roomName = nameInput?.value.trim();
const roomTopic = topicTextArea?.value.trim();
const aliasLocalPart =
aliasInput && aliasInput.value ? replaceSpaceWithDash(aliasInput.value) : undefined;
if (!roomName) return;
const publicRoom = kind === CreateRoomKind.Public;
let roomKnock = false;
if (allowKnock && kind === CreateRoomKind.Private) {
roomKnock = knock;
}
if (allowKnockRestricted && kind === CreateRoomKind.Restricted) {
roomKnock = knock;
}
create({
version: selectedRoomVersion,
type: RoomType.Space,
parent: space,
kind,
name: roomName,
topic: roomTopic || undefined,
aliasLocalPart: publicRoom ? aliasLocalPart : undefined,
knock: roomKnock,
allowFederation: federation,
}).then((roomId) => {
if (alive()) {
onCreate?.(roomId);
}
});
};
return (
<Box as="form" onSubmit={handleSubmit} grow="Yes" direction="Column" gap="500">
<Box direction="Column" gap="100">
<Text size="L400">Access</Text>
<CreateRoomKindSelector
value={kind}
onSelect={setKind}
canRestrict={allowRestricted}
disabled={disabled}
getIcon={getCreateSpaceKindToIcon}
/>
</Box>
<Box shrink="No" direction="Column" gap="100">
<Text size="L400">Name</Text>
<Input
required
before={<Icon size="100" src={getCreateSpaceKindToIcon(kind)} />}
name="nameInput"
autoFocus
size="500"
variant="SurfaceVariant"
radii="400"
autoComplete="off"
disabled={disabled}
/>
</Box>
<Box shrink="No" direction="Column" gap="100">
<Text size="L400">Topic (Optional)</Text>
<TextArea
name="topicTextAria"
size="500"
variant="SurfaceVariant"
radii="400"
disabled={disabled}
/>
</Box>
{kind === CreateRoomKind.Public && <CreateRoomAliasInput disabled={disabled} />}
<Box shrink="No" direction="Column" gap="100">
<Box gap="200" alignItems="End">
<Text size="L400">Options</Text>
<Box grow="Yes" justifyContent="End">
<Chip
radii="Pill"
before={<Icon src={advance ? Icons.ChevronTop : Icons.ChevronBottom} size="50" />}
onClick={() => setAdvance(!advance)}
type="button"
>
<Text size="T200">Advance Options</Text>
</Chip>
</Box>
</Box>
{kind !== CreateRoomKind.Public && advance && (allowKnock || allowKnockRestricted) && (
<SequenceCard
style={{ padding: config.space.S300 }}
variant="SurfaceVariant"
direction="Column"
gap="500"
>
<SettingTile
title="Knock to Join"
description="Anyone can send request to join this space."
after={
<Switch variant="Primary" value={knock} onChange={setKnock} disabled={disabled} />
}
/>
</SequenceCard>
)}
<SequenceCard
style={{ padding: config.space.S300 }}
variant="SurfaceVariant"
direction="Column"
gap="500"
>
<SettingTile
title="Allow Federation"
description="Users from other servers can join."
after={
<Switch
variant="Primary"
value={federation}
onChange={setFederation}
disabled={disabled}
/>
}
/>
</SequenceCard>
{advance && (
<RoomVersionSelector
versions={roomVersions?.available ? Object.keys(roomVersions.available) : ['1']}
value={selectedRoomVersion}
onChange={handleRoomVersionChange}
disabled={disabled}
/>
)}
</Box>
{error && (
<Box style={{ color: color.Critical.Main }} alignItems="Center" gap="200">
<Icon src={Icons.Warning} filled size="100" />
<Text size="T300" style={{ color: color.Critical.Main }}>
<b>
{error instanceof MatrixError && error.name === ErrorCode.M_LIMIT_EXCEEDED
? `Server rate-limited your request for ${millisecondsToMinutes(
(error.data.retry_after_ms as number | undefined) ?? 0
)} minutes!`
: error.message}
</b>
</Text>
</Box>
)}
<Box shrink="No" direction="Column" gap="200">
<Button
type="submit"
size="500"
variant="Primary"
radii="400"
disabled={disabled}
before={loading && <Spinner variant="Primary" fill="Solid" size="200" />}
>
<Text size="B500">Create</Text>
</Button>
</Box>
</Box>
);
}

View file

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

View file

@ -0,0 +1,12 @@
import { useMatch } from 'react-router-dom';
import { getCreatePath } from '../../pages/pathUtils';
export const useCreateSelected = (): boolean => {
const match = useMatch({
path: getCreatePath(),
caseSensitive: true,
end: false,
});
return !!match;
};

View file

@ -28,6 +28,7 @@ import {
_ROOM_PATH, _ROOM_PATH,
_SEARCH_PATH, _SEARCH_PATH,
_SERVER_PATH, _SERVER_PATH,
CREATE_PATH,
} from './paths'; } from './paths';
import { isAuthenticated } from '../../client/state/auth'; import { isAuthenticated } from '../../client/state/auth';
import { import {
@ -63,6 +64,7 @@ import { ClientRoomsNotificationPreferences } from './client/ClientRoomsNotifica
import { SpaceSettingsRenderer } from '../features/space-settings'; import { SpaceSettingsRenderer } from '../features/space-settings';
import { CreateRoomModalRenderer } from '../features/create-room'; import { CreateRoomModalRenderer } from '../features/create-room';
import { HomeCreateRoom } from './client/home/CreateRoom'; import { HomeCreateRoom } from './client/home/CreateRoom';
import { Create } from './client/create';
export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) => { export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) => {
const { hashRouter } = clientConfig; const { hashRouter } = clientConfig;
@ -256,6 +258,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
<Route path={_FEATURED_PATH} element={<FeaturedRooms />} /> <Route path={_FEATURED_PATH} element={<FeaturedRooms />} />
<Route path={_SERVER_PATH} element={<PublicRooms />} /> <Route path={_SERVER_PATH} element={<PublicRooms />} />
</Route> </Route>
<Route path={CREATE_PATH} element={<Create />} />
<Route <Route
path={INBOX_PATH} path={INBOX_PATH}
element={ element={

View file

@ -19,7 +19,8 @@ import {
SettingsTab, SettingsTab,
UnverifiedTab, UnverifiedTab,
} from './sidebar'; } from './sidebar';
import { openCreateRoom, openSearch } from '../../../client/action/navigation'; import { openSearch } from '../../../client/action/navigation';
import { CreateTab } from './sidebar/CreateTab';
export function SidebarNav() { export function SidebarNav() {
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
@ -37,20 +38,7 @@ export function SidebarNav() {
<SidebarStackSeparator /> <SidebarStackSeparator />
<SidebarStack> <SidebarStack>
<ExploreTab /> <ExploreTab />
<SidebarItem> <CreateTab />
<SidebarItemTooltip tooltip="Create Space">
{(triggerRef) => (
<SidebarAvatar
as="button"
ref={triggerRef}
outlined
onClick={() => openCreateRoom(true)}
>
<Icon src={Icons.Plus} />
</SidebarAvatar>
)}
</SidebarItemTooltip>
</SidebarItem>
</SidebarStack> </SidebarStack>
</Scroll> </Scroll>
} }

View file

@ -0,0 +1,35 @@
import React from 'react';
import { Box, Icon, Icons, Scroll } from 'folds';
import {
Page,
PageContent,
PageContentCenter,
PageHero,
PageHeroSection,
} from '../../../components/page';
import { CreateSpaceForm } from '../../../features/create-space';
export function Create() {
return (
<Page>
<Box grow="Yes">
<Scroll hideTrack visibility="Hover">
<PageContent>
<PageContentCenter>
<PageHeroSection>
<Box direction="Column" gap="700">
<PageHero
icon={<Icon size="600" src={Icons.Space} />}
title="Create Space"
subTitle="Build a space for your community."
/>
<CreateSpaceForm />
</Box>
</PageHeroSection>
</PageContentCenter>
</PageContent>
</Scroll>
</Box>
</Page>
);
}

View file

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

View file

@ -0,0 +1,111 @@
import React, { MouseEventHandler, useState } from 'react';
import { Box, config, Icon, Icons, Menu, PopOut, RectCords, Text } from 'folds';
import FocusTrap from 'focus-trap-react';
import { useNavigate } from 'react-router-dom';
import { SidebarAvatar, SidebarItem, SidebarItemTooltip } from '../../../components/sidebar';
import { stopPropagation } from '../../../utils/keyboard';
import { SequenceCard } from '../../../components/sequence-card';
import { SettingTile } from '../../../components/setting-tile';
import { ContainerColor } from '../../../styles/ContainerColor.css';
import { openJoinAlias } from '../../../../client/action/navigation';
import { getCreatePath } from '../../pathUtils';
import { useCreateSelected } from '../../../hooks/router/useCreateSelected';
export function CreateTab() {
const createSelected = useCreateSelected();
const navigate = useNavigate();
const [menuCords, setMenuCords] = useState<RectCords>();
const handleMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
setMenuCords(menuCords ? undefined : evt.currentTarget.getBoundingClientRect());
};
const handleCreateSpace = () => {
navigate(getCreatePath());
setMenuCords(undefined);
};
const handleJoinWithAddress = () => {
openJoinAlias();
setMenuCords(undefined);
};
return (
<SidebarItem active={createSelected}>
<SidebarItemTooltip tooltip="Add Space">
{(triggerRef) => (
<PopOut
anchor={menuCords}
position="Right"
align="Center"
content={
<FocusTrap
focusTrapOptions={{
returnFocusOnDeactivate: false,
initialFocus: false,
onDeactivate: () => setMenuCords(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) =>
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
isKeyBackward: (evt: KeyboardEvent) =>
evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
escapeDeactivates: stopPropagation,
}}
>
<Menu>
<Box direction="Column">
<SequenceCard
style={{ padding: config.space.S300 }}
variant="Surface"
direction="Column"
gap="100"
radii="0"
as="button"
type="button"
onClick={handleCreateSpace}
>
<SettingTile before={<Icon size="400" src={Icons.Space} />}>
<Text size="H6">Create Space</Text>
<Text size="T300" priority="300">
Build a space for your community.
</Text>
</SettingTile>
</SequenceCard>
<SequenceCard
style={{ padding: config.space.S300 }}
variant="Surface"
direction="Column"
gap="100"
radii="0"
as="button"
type="button"
onClick={handleJoinWithAddress}
>
<SettingTile before={<Icon size="400" src={Icons.Link} />}>
<Text size="H6">Join with Address</Text>
<Text size="T300" priority="300">
Become a part of existing community.
</Text>
</SettingTile>
</SequenceCard>
</Box>
</Menu>
</FocusTrap>
}
>
<SidebarAvatar
className={menuCords ? ContainerColor({ variant: 'Surface' }) : undefined}
as="button"
ref={triggerRef}
outlined
onClick={handleMenu}
>
<Icon src={Icons.Plus} />
</SidebarAvatar>
</PopOut>
)}
</SidebarItemTooltip>
</SidebarItem>
);
}

View file

@ -22,6 +22,7 @@ import {
SPACE_PATH, SPACE_PATH,
SPACE_ROOM_PATH, SPACE_ROOM_PATH,
SPACE_SEARCH_PATH, SPACE_SEARCH_PATH,
CREATE_PATH,
} from './paths'; } from './paths';
import { trimLeadingSlash, trimTrailingSlash } from '../utils/common'; import { trimLeadingSlash, trimTrailingSlash } from '../utils/common';
import { HashRouterConfig } from '../hooks/useClientConfig'; import { HashRouterConfig } from '../hooks/useClientConfig';
@ -152,6 +153,8 @@ export const getExploreServerPath = (server: string): string => {
return generatePath(EXPLORE_SERVER_PATH, params); return generatePath(EXPLORE_SERVER_PATH, params);
}; };
export const getCreatePath = (): string => CREATE_PATH;
export const getInboxPath = (): string => INBOX_PATH; export const getInboxPath = (): string => INBOX_PATH;
export const getInboxNotificationsPath = (): string => INBOX_NOTIFICATIONS_PATH; export const getInboxNotificationsPath = (): string => INBOX_NOTIFICATIONS_PATH;
export const getInboxInvitesPath = (): string => INBOX_INVITES_PATH; export const getInboxInvitesPath = (): string => INBOX_INVITES_PATH;

View file

@ -74,6 +74,8 @@ export type ExploreServerPathSearchParams = {
}; };
export const EXPLORE_SERVER_PATH = `/explore/${_SERVER_PATH}`; export const EXPLORE_SERVER_PATH = `/explore/${_SERVER_PATH}`;
export const CREATE_PATH = '/create';
export const _NOTIFICATIONS_PATH = 'notifications/'; export const _NOTIFICATIONS_PATH = 'notifications/';
export const _INVITES_PATH = 'invites/'; export const _INVITES_PATH = 'invites/';
export const INBOX_PATH = '/inbox/'; export const INBOX_PATH = '/inbox/';