Redesign space/room creation panel (#2408)

* add new create room

* rename create room modal file

* default restrict access for space children in room create modal

* move create room kind selector to components

* add radii variant to sequence card component

* more more reusable create room logic to components

* add create space

* update address input description

* add new space modal

* fix add room button visible on left room in space lobby
This commit is contained in:
Ajay Bura 2025-08-05 18:37:07 +05:30 committed by GitHub
parent e9798a22c3
commit faa952295f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 1637 additions and 53 deletions

View file

@ -0,0 +1,118 @@
import React, {
FormEventHandler,
KeyboardEventHandler,
useCallback,
useEffect,
useRef,
useState,
} from 'react';
import { MatrixError } from 'matrix-js-sdk';
import { Box, color, Icon, Icons, Input, Spinner, Text, toRem } from 'folds';
import { isKeyHotkey } from 'is-hotkey';
import { getMxIdServer } from '../../utils/matrix';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { replaceSpaceWithDash } from '../../utils/common';
import { AsyncState, AsyncStatus, useAsync } from '../../hooks/useAsyncCallback';
import { useDebounce } from '../../hooks/useDebounce';
export function CreateRoomAliasInput({ disabled }: { disabled?: boolean }) {
const mx = useMatrixClient();
const aliasInputRef = useRef<HTMLInputElement>(null);
const [aliasAvail, setAliasAvail] = useState<AsyncState<boolean, Error>>({
status: AsyncStatus.Idle,
});
useEffect(() => {
if (aliasAvail.status === AsyncStatus.Success && aliasInputRef.current?.value === '') {
setAliasAvail({ status: AsyncStatus.Idle });
}
}, [aliasAvail]);
const checkAliasAvail = useAsync(
useCallback(
async (aliasLocalPart: string) => {
const roomAlias = `#${aliasLocalPart}:${getMxIdServer(mx.getSafeUserId())}`;
try {
const result = await mx.getRoomIdForAlias(roomAlias);
return typeof result.room_id !== 'string';
} catch (e) {
if (e instanceof MatrixError && e.httpStatus === 404) {
return true;
}
throw e;
}
},
[mx]
),
setAliasAvail
);
const aliasAvailable: boolean | undefined =
aliasAvail.status === AsyncStatus.Success ? aliasAvail.data : undefined;
const debounceCheckAliasAvail = useDebounce(checkAliasAvail, { wait: 500 });
const handleAliasChange: FormEventHandler<HTMLInputElement> = (evt) => {
const aliasInput = evt.currentTarget;
const aliasLocalPart = replaceSpaceWithDash(aliasInput.value);
if (aliasLocalPart) {
aliasInput.value = aliasLocalPart;
debounceCheckAliasAvail(aliasLocalPart);
} else {
setAliasAvail({ status: AsyncStatus.Idle });
}
};
const handleAliasKeyDown: KeyboardEventHandler<HTMLInputElement> = (evt) => {
if (isKeyHotkey('enter', evt)) {
evt.preventDefault();
const aliasInput = evt.currentTarget;
const aliasLocalPart = replaceSpaceWithDash(aliasInput.value);
if (aliasLocalPart) {
checkAliasAvail(aliasLocalPart);
} else {
setAliasAvail({ status: AsyncStatus.Idle });
}
}
};
return (
<Box shrink="No" direction="Column" gap="100">
<Text size="L400">Address (Optional)</Text>
<Text size="T200" priority="300">
Pick an unique address to make it discoverable.
</Text>
<Input
ref={aliasInputRef}
onChange={handleAliasChange}
before={
aliasAvail.status === AsyncStatus.Loading ? (
<Spinner size="100" variant="Secondary" />
) : (
<Icon size="100" src={Icons.Hash} />
)
}
after={
<Text style={{ maxWidth: toRem(150) }} truncate>
:{getMxIdServer(mx.getSafeUserId())}
</Text>
}
onKeyDown={handleAliasKeyDown}
name="aliasInput"
size="500"
variant={aliasAvailable === true ? 'Success' : 'SurfaceVariant'}
radii="400"
autoComplete="off"
disabled={disabled}
/>
{aliasAvailable === false && (
<Box style={{ color: color.Critical.Main }} alignItems="Center" gap="100">
<Icon src={Icons.Warning} filled size="50" />
<Text size="T200">
<b>This address is already taken. Please select a different one.</b>
</Text>
</Box>
)}
</Box>
);
}

View file

@ -0,0 +1,94 @@
import React from 'react';
import { Box, Text, Icon, Icons, config, IconSrc } from 'folds';
import { SequenceCard } from '../sequence-card';
import { SettingTile } from '../setting-tile';
export enum CreateRoomKind {
Private = 'private',
Restricted = 'restricted',
Public = 'public',
}
type CreateRoomKindSelectorProps = {
value?: CreateRoomKind;
onSelect: (value: CreateRoomKind) => void;
canRestrict?: boolean;
disabled?: boolean;
getIcon: (kind: CreateRoomKind) => IconSrc;
};
export function CreateRoomKindSelector({
value,
onSelect,
canRestrict,
disabled,
getIcon,
}: CreateRoomKindSelectorProps) {
return (
<Box shrink="No" direction="Column" gap="100">
{canRestrict && (
<SequenceCard
style={{ padding: config.space.S300 }}
variant={value === CreateRoomKind.Restricted ? 'Primary' : 'SurfaceVariant'}
direction="Column"
gap="100"
as="button"
type="button"
aria-pressed={value === CreateRoomKind.Restricted}
onClick={() => onSelect(CreateRoomKind.Restricted)}
disabled={disabled}
>
<SettingTile
before={<Icon size="400" src={getIcon(CreateRoomKind.Restricted)} />}
after={value === CreateRoomKind.Restricted && <Icon src={Icons.Check} />}
>
<Text size="H6">Restricted</Text>
<Text size="T300" priority="300">
Only member of parent space can join.
</Text>
</SettingTile>
</SequenceCard>
)}
<SequenceCard
style={{ padding: config.space.S300 }}
variant={value === CreateRoomKind.Private ? 'Primary' : 'SurfaceVariant'}
direction="Column"
gap="100"
as="button"
type="button"
aria-pressed={value === CreateRoomKind.Private}
onClick={() => onSelect(CreateRoomKind.Private)}
disabled={disabled}
>
<SettingTile
before={<Icon size="400" src={getIcon(CreateRoomKind.Private)} />}
after={value === CreateRoomKind.Private && <Icon src={Icons.Check} />}
>
<Text size="H6">Private</Text>
<Text size="T300" priority="300">
Only people with invite can join.
</Text>
</SettingTile>
</SequenceCard>
<SequenceCard
style={{ padding: config.space.S300 }}
variant={value === CreateRoomKind.Public ? 'Primary' : 'SurfaceVariant'}
direction="Column"
gap="100"
as="button"
type="button"
aria-pressed={value === CreateRoomKind.Public}
onClick={() => onSelect(CreateRoomKind.Public)}
disabled={disabled}
>
<SettingTile
before={<Icon size="400" src={getIcon(CreateRoomKind.Public)} />}
after={value === CreateRoomKind.Public && <Icon src={Icons.Check} />}
>
<Text size="H6">Public</Text>
<Text size="T300" priority="300">
Anyone with the address can join.
</Text>
</SettingTile>
</SequenceCard>
</Box>
);
}

View file

@ -0,0 +1,117 @@
import React, { MouseEventHandler, useState } from 'react';
import {
Box,
Button,
Chip,
config,
Icon,
Icons,
Menu,
PopOut,
RectCords,
Text,
toRem,
} from 'folds';
import FocusTrap from 'focus-trap-react';
import { SettingTile } from '../setting-tile';
import { SequenceCard } from '../sequence-card';
import { stopPropagation } from '../../utils/keyboard';
export function RoomVersionSelector({
versions,
value,
onChange,
disabled,
}: {
versions: string[];
value: string;
onChange: (value: string) => void;
disabled?: boolean;
}) {
const [menuCords, setMenuCords] = useState<RectCords>();
const handleMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
setMenuCords(evt.currentTarget.getBoundingClientRect());
};
const handleSelect = (version: string) => {
setMenuCords(undefined);
onChange(version);
};
return (
<SequenceCard
style={{ padding: config.space.S300 }}
variant="SurfaceVariant"
direction="Column"
gap="500"
>
<SettingTile
title="Room Version"
after={
<PopOut
anchor={menuCords}
offset={5}
position="Bottom"
align="End"
content={
<FocusTrap
focusTrapOptions={{
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"
gap="200"
style={{ padding: config.space.S200, maxWidth: toRem(300) }}
>
<Text size="L400">Versions</Text>
<Box wrap="Wrap" gap="100">
{versions.map((version) => (
<Chip
key={version}
variant={value === version ? 'Primary' : 'SurfaceVariant'}
aria-pressed={value === version}
outlined={value === version}
radii="300"
onClick={() => handleSelect(version)}
type="button"
>
<Text truncate size="T300">
{version}
</Text>
</Chip>
))}
</Box>
</Box>
</Menu>
</FocusTrap>
}
>
<Button
type="button"
onClick={handleMenu}
size="300"
variant="Secondary"
fill="Soft"
radii="300"
aria-pressed={!!menuCords}
before={<Icon size="50" src={menuCords ? Icons.ChevronTop : Icons.ChevronBottom} />}
disabled={disabled}
>
<Text size="B300">{value}</Text>
</Button>
</PopOut>
}
/>
</SequenceCard>
);
}

View file

@ -0,0 +1,4 @@
export * from './CreateRoomKindSelector';
export * from './CreateRoomAliasInput';
export * from './RoomVersionSelector';
export * from './utils';

View file

@ -0,0 +1,131 @@
import {
ICreateRoomOpts,
ICreateRoomStateEvent,
JoinRule,
MatrixClient,
RestrictedAllowType,
Room,
} from 'matrix-js-sdk';
import { RoomJoinRulesEventContent } from 'matrix-js-sdk/lib/types';
import { CreateRoomKind } from './CreateRoomKindSelector';
import { RoomType, StateEvent } from '../../../types/matrix/room';
import { getViaServers } from '../../plugins/via-servers';
import { getMxIdServer } from '../../utils/matrix';
export const createRoomCreationContent = (
type: RoomType | undefined,
allowFederation: boolean
): object => {
const content: Record<string, any> = {};
if (typeof type === 'string') {
content.type = type;
}
if (allowFederation === false) {
content['m.federate'] = false;
}
return content;
};
export const createRoomJoinRulesState = (
kind: CreateRoomKind,
parent: Room | undefined,
knock: boolean
) => {
let content: RoomJoinRulesEventContent = {
join_rule: knock ? JoinRule.Knock : JoinRule.Invite,
};
if (kind === CreateRoomKind.Public) {
content = {
join_rule: JoinRule.Public,
};
}
if (kind === CreateRoomKind.Restricted && parent) {
content = {
join_rule: knock ? ('knock_restricted' as JoinRule) : JoinRule.Restricted,
allow: [
{
type: RestrictedAllowType.RoomMembership,
room_id: parent.roomId,
},
],
};
}
return {
type: StateEvent.RoomJoinRules,
state_key: '',
content,
};
};
export const createRoomParentState = (parent: Room) => ({
type: StateEvent.SpaceParent,
state_key: parent.roomId,
content: {
canonical: true,
via: getViaServers(parent),
},
});
export const createRoomEncryptionState = () => ({
type: 'm.room.encryption',
state_key: '',
content: {
algorithm: 'm.megolm.v1.aes-sha2',
},
});
export type CreateRoomData = {
version: string;
type?: RoomType;
parent?: Room;
kind: CreateRoomKind;
name: string;
topic?: string;
aliasLocalPart?: string;
encryption?: boolean;
knock: boolean;
allowFederation: boolean;
};
export const createRoom = async (mx: MatrixClient, data: CreateRoomData): Promise<string> => {
const initialState: ICreateRoomStateEvent[] = [];
if (data.encryption) {
initialState.push(createRoomEncryptionState());
}
if (data.parent) {
initialState.push(createRoomParentState(data.parent));
}
initialState.push(createRoomJoinRulesState(data.kind, data.parent, data.knock));
const options: ICreateRoomOpts = {
room_version: data.version,
name: data.name,
topic: data.topic,
room_alias_name: data.aliasLocalPart,
creation_content: createRoomCreationContent(data.type, data.allowFederation),
initial_state: initialState,
};
const result = await mx.createRoom(options);
if (data.parent) {
await mx.sendStateEvent(
data.parent.roomId,
StateEvent.SpaceChild as any,
{
auto_join: false,
suggested: false,
via: [getMxIdServer(mx.getUserId() ?? '') ?? ''],
},
result.room_id
);
}
return result.room_id;
};

View file

@ -7,12 +7,31 @@ import * as css from './style.css';
export const SequenceCard = as< export const SequenceCard = as<
'div', 'div',
ComponentProps<typeof Box> & ContainerColorVariants & css.SequenceCardVariants ComponentProps<typeof Box> & ContainerColorVariants & css.SequenceCardVariants
>(({ className, variant, firstChild, lastChild, outlined, ...props }, ref) => ( >(
(
{
as: AsSequenceCard = 'div',
className,
variant,
radii,
firstChild,
lastChild,
outlined,
...props
},
ref
) => (
<Box <Box
className={classNames(css.SequenceCard({ outlined }), ContainerColor({ variant }), className)} as={AsSequenceCard}
className={classNames(
css.SequenceCard({ radii, outlined }),
ContainerColor({ variant }),
className
)}
data-first-child={firstChild} data-first-child={firstChild}
data-last-child={lastChild} data-last-child={lastChild}
{...props} {...props}
ref={ref} ref={ref}
/> />
)); )
);

View file

@ -3,6 +3,7 @@ import { RecipeVariants, recipe } from '@vanilla-extract/recipes';
import { config } from 'folds'; import { config } from 'folds';
const outlinedWidth = createVar('0'); const outlinedWidth = createVar('0');
const radii = createVar(config.radii.R400);
export const SequenceCard = recipe({ export const SequenceCard = recipe({
base: { base: {
vars: { vars: {
@ -13,33 +14,59 @@ export const SequenceCard = recipe({
borderBottomWidth: 0, borderBottomWidth: 0,
selectors: { selectors: {
'&:first-child, :not(&) + &': { '&:first-child, :not(&) + &': {
borderTopLeftRadius: config.radii.R400, borderTopLeftRadius: [radii],
borderTopRightRadius: config.radii.R400, borderTopRightRadius: [radii],
}, },
'&:last-child, &:not(:has(+&))': { '&:last-child, &:not(:has(+&))': {
borderBottomLeftRadius: config.radii.R400, borderBottomLeftRadius: [radii],
borderBottomRightRadius: config.radii.R400, borderBottomRightRadius: [radii],
borderBottomWidth: outlinedWidth, borderBottomWidth: outlinedWidth,
}, },
[`&[data-first-child="true"]`]: { [`&[data-first-child="true"]`]: {
borderTopLeftRadius: config.radii.R400, borderTopLeftRadius: [radii],
borderTopRightRadius: config.radii.R400, borderTopRightRadius: [radii],
}, },
[`&[data-first-child="false"]`]: { [`&[data-first-child="false"]`]: {
borderTopLeftRadius: 0, borderTopLeftRadius: 0,
borderTopRightRadius: 0, borderTopRightRadius: 0,
}, },
[`&[data-last-child="true"]`]: { [`&[data-last-child="true"]`]: {
borderBottomLeftRadius: config.radii.R400, borderBottomLeftRadius: [radii],
borderBottomRightRadius: config.radii.R400, borderBottomRightRadius: [radii],
}, },
[`&[data-last-child="false"]`]: { [`&[data-last-child="false"]`]: {
borderBottomLeftRadius: 0, borderBottomLeftRadius: 0,
borderBottomRightRadius: 0, borderBottomRightRadius: 0,
}, },
'button&': {
cursor: 'pointer',
},
}, },
}, },
variants: { variants: {
radii: {
'0': {
vars: {
[radii]: config.radii.R0,
},
},
'300': {
vars: {
[radii]: config.radii.R300,
},
},
'400': {
vars: {
[radii]: config.radii.R400,
},
},
'500': {
vars: {
[radii]: config.radii.R500,
},
},
},
outlined: { outlined: {
true: { true: {
vars: { vars: {
@ -48,5 +75,8 @@ export const SequenceCard = recipe({
}, },
}, },
}, },
defaultVariants: {
radii: '400',
},
}); });
export type SequenceCardVariants = RecipeVariants<typeof SequenceCard>; export type SequenceCardVariants = RecipeVariants<typeof SequenceCard>;

View file

@ -27,6 +27,11 @@ import {
} from '../../../state/hooks/roomList'; } from '../../../state/hooks/roomList';
import { allRoomsAtom } from '../../../state/room-list/roomList'; import { allRoomsAtom } from '../../../state/room-list/roomList';
import { roomToParentsAtom } from '../../../state/room/roomToParents'; import { roomToParentsAtom } from '../../../state/room/roomToParents';
import {
knockRestrictedSupported,
knockSupported,
restrictedSupported,
} from '../../../utils/matrix';
type RestrictedRoomAllowContent = { type RestrictedRoomAllowContent = {
room_id: string; room_id: string;
@ -39,10 +44,9 @@ type RoomJoinRulesProps = {
export function RoomJoinRules({ powerLevels }: RoomJoinRulesProps) { export function RoomJoinRules({ powerLevels }: RoomJoinRulesProps) {
const mx = useMatrixClient(); const mx = useMatrixClient();
const room = useRoom(); const room = useRoom();
const roomVersion = parseInt(room.getVersion(), 10); const allowKnockRestricted = knockRestrictedSupported(room.getVersion());
const allowKnockRestricted = roomVersion >= 10; const allowRestricted = restrictedSupported(room.getVersion());
const allowRestricted = roomVersion >= 8; const allowKnock = knockSupported(room.getVersion());
const allowKnock = roomVersion >= 7;
const roomIdToParents = useAtomValue(roomToParentsAtom); const roomIdToParents = useAtomValue(roomToParentsAtom);
const space = useSpaceOptionally(); const space = useSpaceOptionally();

View file

@ -0,0 +1,277 @@
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';
const getCreateRoomKindToIcon = (kind: CreateRoomKind) => {
if (kind === CreateRoomKind.Private) return Icons.HashLock;
if (kind === CreateRoomKind.Restricted) return Icons.Hash;
return Icons.HashGlobe;
};
type CreateRoomFormProps = {
defaultKind?: CreateRoomKind;
space?: Room;
onCreate?: (roomId: string) => void;
};
export function CreateRoomForm({ defaultKind, space, onCreate }: CreateRoomFormProps) {
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 [encryption, setEncryption] = useState(false);
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,
parent: space,
kind,
name: roomName,
topic: roomTopic || undefined,
aliasLocalPart: publicRoom ? aliasLocalPart : undefined,
encryption: publicRoom ? false : encryption,
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={getCreateRoomKindToIcon}
/>
</Box>
<Box shrink="No" direction="Column" gap="100">
<Text size="L400">Name</Text>
<Input
required
before={<Icon size="100" src={getCreateRoomKindToIcon(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 && (
<>
<SequenceCard
style={{ padding: config.space.S300 }}
variant="SurfaceVariant"
direction="Column"
gap="500"
>
<SettingTile
title="End-to-End Encryption"
description="Once this feature is enabled, it can't be disabled after the room is created."
after={
<Switch
variant="Primary"
value={encryption}
onChange={setEncryption}
disabled={disabled}
/>
}
/>
</SequenceCard>
{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 room."
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,95 @@
import React from 'react';
import {
Box,
config,
Header,
Icon,
IconButton,
Icons,
Modal,
Overlay,
OverlayBackdrop,
OverlayCenter,
Scroll,
Text,
} from 'folds';
import FocusTrap from 'focus-trap-react';
import { useAllJoinedRoomsSet, useGetRoom } from '../../hooks/useGetRoom';
import { SpaceProvider } from '../../hooks/useSpace';
import { CreateRoomForm } from './CreateRoom';
import {
useCloseCreateRoomModal,
useCreateRoomModalState,
} from '../../state/hooks/createRoomModal';
import { CreateRoomModalState } from '../../state/createRoomModal';
import { stopPropagation } from '../../utils/keyboard';
type CreateRoomModalProps = {
state: CreateRoomModalState;
};
function CreateRoomModal({ state }: CreateRoomModalProps) {
const { spaceId } = state;
const closeDialog = useCloseCreateRoomModal();
const allJoinedRooms = useAllJoinedRoomsSet();
const getRoom = useGetRoom(allJoinedRooms);
const space = spaceId ? getRoom(spaceId) : undefined;
return (
<SpaceProvider value={space ?? null}>
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
clickOutsideDeactivates: true,
onDeactivate: closeDialog,
escapeDeactivates: stopPropagation,
}}
>
<Modal size="300" flexHeight>
<Box direction="Column">
<Header
size="500"
style={{
padding: config.space.S200,
paddingLeft: config.space.S400,
borderBottomWidth: config.borderWidth.B300,
}}
>
<Box grow="Yes">
<Text size="H4">New Room</Text>
</Box>
<Box shrink="No">
<IconButton size="300" radii="300" onClick={closeDialog}>
<Icon src={Icons.Cross} />
</IconButton>
</Box>
</Header>
<Scroll size="300" hideTrack>
<Box
style={{
padding: config.space.S400,
paddingRight: config.space.S200,
}}
direction="Column"
gap="500"
>
<CreateRoomForm space={space} onCreate={closeDialog} />
</Box>
</Scroll>
</Box>
</Modal>
</FocusTrap>
</OverlayCenter>
</Overlay>
</SpaceProvider>
);
}
export function CreateRoomModalRenderer() {
const state = useCreateRoomModalState();
if (!state) return null;
return <CreateRoomModal state={state} />;
}

View file

@ -0,0 +1,2 @@
export * from './CreateRoom';
export * from './CreateRoomModal';

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,95 @@
import React from 'react';
import {
Box,
config,
Header,
Icon,
IconButton,
Icons,
Modal,
Overlay,
OverlayBackdrop,
OverlayCenter,
Scroll,
Text,
} from 'folds';
import FocusTrap from 'focus-trap-react';
import { useAllJoinedRoomsSet, useGetRoom } from '../../hooks/useGetRoom';
import { SpaceProvider } from '../../hooks/useSpace';
import { CreateSpaceForm } from './CreateSpace';
import {
useCloseCreateSpaceModal,
useCreateSpaceModalState,
} from '../../state/hooks/createSpaceModal';
import { CreateSpaceModalState } from '../../state/createSpaceModal';
import { stopPropagation } from '../../utils/keyboard';
type CreateSpaceModalProps = {
state: CreateSpaceModalState;
};
function CreateSpaceModal({ state }: CreateSpaceModalProps) {
const { spaceId } = state;
const closeDialog = useCloseCreateSpaceModal();
const allJoinedRooms = useAllJoinedRoomsSet();
const getRoom = useGetRoom(allJoinedRooms);
const space = spaceId ? getRoom(spaceId) : undefined;
return (
<SpaceProvider value={space ?? null}>
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
clickOutsideDeactivates: true,
onDeactivate: closeDialog,
escapeDeactivates: stopPropagation,
}}
>
<Modal size="300" flexHeight>
<Box direction="Column">
<Header
size="500"
style={{
padding: config.space.S200,
paddingLeft: config.space.S400,
borderBottomWidth: config.borderWidth.B300,
}}
>
<Box grow="Yes">
<Text size="H4">New Space</Text>
</Box>
<Box shrink="No">
<IconButton size="300" radii="300" onClick={closeDialog}>
<Icon src={Icons.Cross} />
</IconButton>
</Box>
</Header>
<Scroll size="300" hideTrack>
<Box
style={{
padding: config.space.S400,
paddingRight: config.space.S200,
}}
direction="Column"
gap="500"
>
<CreateSpaceForm space={space} onCreate={closeDialog} />
</Box>
</Scroll>
</Box>
</Modal>
</FocusTrap>
</OverlayCenter>
</Overlay>
</SpaceProvider>
);
}
export function CreateSpaceModalRenderer() {
const state = useCreateSpaceModalState();
if (!state) return null;
return <CreateSpaceModal state={state} />;
}

View file

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

View file

@ -220,14 +220,12 @@ export function Lobby() {
() => () =>
hierarchy hierarchy
.flatMap((i) => { .flatMap((i) => {
const childRooms = Array.isArray(i.rooms) const childRooms = Array.isArray(i.rooms) ? i.rooms.map((r) => getRoom(r.roomId)) : [];
? i.rooms.map((r) => mx.getRoom(r.roomId))
: [];
return [mx.getRoom(i.space.roomId), ...childRooms]; return [getRoom(i.space.roomId), ...childRooms];
}) })
.filter((r) => !!r) as Room[], .filter((r) => !!r) as Room[],
[mx, hierarchy] [hierarchy, getRoom]
) )
); );

View file

@ -30,10 +30,12 @@ import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import * as css from './SpaceItem.css'; import * as css from './SpaceItem.css';
import * as styleCss from './style.css'; import * as styleCss from './style.css';
import { useDraggableItem } from './DnD'; import { useDraggableItem } from './DnD';
import { openCreateRoom, openSpaceAddExisting } from '../../../client/action/navigation'; import { openSpaceAddExisting } from '../../../client/action/navigation';
import { stopPropagation } from '../../utils/keyboard'; import { stopPropagation } from '../../utils/keyboard';
import { mxcUrlToHttp } from '../../utils/matrix'; import { mxcUrlToHttp } from '../../utils/matrix';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useOpenCreateRoomModal } from '../../state/hooks/createRoomModal';
import { useOpenCreateSpaceModal } from '../../state/hooks/createSpaceModal';
function SpaceProfileLoading() { function SpaceProfileLoading() {
return ( return (
@ -240,13 +242,14 @@ function RootSpaceProfile({ closed, categoryId, handleClose }: RootSpaceProfileP
function AddRoomButton({ item }: { item: HierarchyItem }) { function AddRoomButton({ item }: { item: HierarchyItem }) {
const [cords, setCords] = useState<RectCords>(); const [cords, setCords] = useState<RectCords>();
const openCreateRoomModal = useOpenCreateRoomModal();
const handleAddRoom: MouseEventHandler<HTMLButtonElement> = (evt) => { const handleAddRoom: MouseEventHandler<HTMLButtonElement> = (evt) => {
setCords(evt.currentTarget.getBoundingClientRect()); setCords(evt.currentTarget.getBoundingClientRect());
}; };
const handleCreateRoom = () => { const handleCreateRoom = () => {
openCreateRoom(false, item.roomId as any); openCreateRoomModal(item.roomId);
setCords(undefined); setCords(undefined);
}; };
@ -303,13 +306,14 @@ function AddRoomButton({ item }: { item: HierarchyItem }) {
function AddSpaceButton({ item }: { item: HierarchyItem }) { function AddSpaceButton({ item }: { item: HierarchyItem }) {
const [cords, setCords] = useState<RectCords>(); const [cords, setCords] = useState<RectCords>();
const openCreateSpaceModal = useOpenCreateSpaceModal();
const handleAddSpace: MouseEventHandler<HTMLButtonElement> = (evt) => { const handleAddSpace: MouseEventHandler<HTMLButtonElement> = (evt) => {
setCords(evt.currentTarget.getBoundingClientRect()); setCords(evt.currentTarget.getBoundingClientRect());
}; };
const handleCreateSpace = () => { const handleCreateSpace = () => {
openCreateRoom(true, item.roomId as any); openCreateSpaceModal(item.roomId as any);
setCords(undefined); setCords(undefined);
}; };
@ -470,7 +474,7 @@ export const SpaceItemCard = as<'div', SpaceItemCardProps>(
</> </>
)} )}
</Box> </Box>
{canEditChild && ( {space && canEditChild && (
<Box shrink="No" alignItems="Inherit" gap="200"> <Box shrink="No" alignItems="Inherit" gap="200">
<AddRoomButton item={item} /> <AddRoomButton item={item} />
{item.parentId === undefined && <AddSpaceButton item={item} />} {item.parentId === undefined && <AddSpaceButton item={item} />}

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 {
@ -61,6 +62,10 @@ import { AutoRestoreBackupOnVerification } from '../components/BackupRestore';
import { RoomSettingsRenderer } from '../features/room-settings'; import { RoomSettingsRenderer } from '../features/room-settings';
import { ClientRoomsNotificationPreferences } from './client/ClientRoomsNotificationPreferences'; import { ClientRoomsNotificationPreferences } from './client/ClientRoomsNotificationPreferences';
import { SpaceSettingsRenderer } from '../features/space-settings'; import { SpaceSettingsRenderer } from '../features/space-settings';
import { CreateRoomModalRenderer } from '../features/create-room';
import { HomeCreateRoom } from './client/home/CreateRoom';
import { Create } from './client/create';
import { CreateSpaceModalRenderer } from '../features/create-space';
export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) => { export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) => {
const { hashRouter } = clientConfig; const { hashRouter } = clientConfig;
@ -125,6 +130,8 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
> >
<Outlet /> <Outlet />
</ClientLayout> </ClientLayout>
<CreateRoomModalRenderer />
<CreateSpaceModalRenderer />
<RoomSettingsRenderer /> <RoomSettingsRenderer />
<SpaceSettingsRenderer /> <SpaceSettingsRenderer />
<ReceiveSelfDeviceVerification /> <ReceiveSelfDeviceVerification />
@ -152,7 +159,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
} }
> >
{mobile ? null : <Route index element={<WelcomePage />} />} {mobile ? null : <Route index element={<WelcomePage />} />}
<Route path={_CREATE_PATH} element={<p>create</p>} /> <Route path={_CREATE_PATH} element={<HomeCreateRoom />} />
<Route path={_JOIN_PATH} element={<p>join</p>} /> <Route path={_JOIN_PATH} element={<p>join</p>} />
<Route path={_SEARCH_PATH} element={<HomeSearch />} /> <Route path={_SEARCH_PATH} element={<HomeSearch />} />
<Route <Route
@ -253,6 +260,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,38 @@
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';
import { useRoomNavigate } from '../../../hooks/useRoomNavigate';
export function Create() {
const { navigateSpace } = useRoomNavigate();
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 onCreate={navigateSpace} />
</Box>
</PageHeroSection>
</PageContentCenter>
</PageContent>
</Scroll>
</Box>
</Page>
);
}

View file

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

View file

@ -0,0 +1,56 @@
import React from 'react';
import { Box, Icon, Icons, Scroll, IconButton } from 'folds';
import {
Page,
PageContent,
PageContentCenter,
PageHeader,
PageHero,
PageHeroSection,
} from '../../../components/page';
import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
import { BackRouteHandler } from '../../../components/BackRouteHandler';
import { CreateRoomForm } from '../../../features/create-room';
import { useRoomNavigate } from '../../../hooks/useRoomNavigate';
export function HomeCreateRoom() {
const screenSize = useScreenSizeContext();
const { navigateRoom } = useRoomNavigate();
return (
<Page>
{screenSize === ScreenSize.Mobile && (
<PageHeader balance outlined={false}>
<Box grow="Yes" alignItems="Center" gap="200">
<BackRouteHandler>
{(onBack) => (
<IconButton onClick={onBack}>
<Icon src={Icons.ArrowLeft} />
</IconButton>
)}
</BackRouteHandler>
</Box>
</PageHeader>
)}
<Box grow="Yes">
<Scroll hideTrack visibility="Hover">
<PageContent>
<PageContentCenter>
<PageHeroSection>
<Box direction="Column" gap="700">
<PageHero
icon={<Icon size="600" src={Icons.Hash} />}
title="Create Room"
subTitle="Build a Room for Real-Time Conversations"
/>
<CreateRoomForm onCreate={navigateRoom} />
</Box>
</PageHeroSection>
</PageContentCenter>
</PageContent>
</Scroll>
</Box>
</Page>
);
}

View file

@ -29,10 +29,18 @@ import {
NavItemContent, NavItemContent,
NavLink, NavLink,
} from '../../../components/nav'; } from '../../../components/nav';
import { getExplorePath, getHomeRoomPath, getHomeSearchPath } from '../../pathUtils'; import {
getExplorePath,
getHomeCreatePath,
getHomeRoomPath,
getHomeSearchPath,
} from '../../pathUtils';
import { getCanonicalAliasOrRoomId } from '../../../utils/matrix'; import { getCanonicalAliasOrRoomId } from '../../../utils/matrix';
import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom'; import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom';
import { useHomeSearchSelected } from '../../../hooks/router/useHomeSelected'; import {
useHomeCreateSelected,
useHomeSearchSelected,
} from '../../../hooks/router/useHomeSelected';
import { useHomeRooms } from './useHomeRooms'; import { useHomeRooms } from './useHomeRooms';
import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { VirtualTile } from '../../../components/virtualizer'; import { VirtualTile } from '../../../components/virtualizer';
@ -41,7 +49,7 @@ import { makeNavCategoryId } from '../../../state/closedNavCategories';
import { roomToUnreadAtom } from '../../../state/room/roomToUnread'; import { roomToUnreadAtom } from '../../../state/room/roomToUnread';
import { useCategoryHandler } from '../../../hooks/useCategoryHandler'; import { useCategoryHandler } from '../../../hooks/useCategoryHandler';
import { useNavToActivePathMapper } from '../../../hooks/useNavToActivePathMapper'; import { useNavToActivePathMapper } from '../../../hooks/useNavToActivePathMapper';
import { openCreateRoom, openJoinAlias } from '../../../../client/action/navigation'; import { openJoinAlias } from '../../../../client/action/navigation';
import { PageNav, PageNavHeader, PageNavContent } from '../../../components/page'; import { PageNav, PageNavHeader, PageNavContent } from '../../../components/page';
import { useRoomsUnread } from '../../../state/hooks/unread'; import { useRoomsUnread } from '../../../state/hooks/unread';
import { markAsRead } from '../../../../client/action/notifications'; import { markAsRead } from '../../../../client/action/notifications';
@ -174,7 +182,7 @@ function HomeEmpty() {
} }
options={ options={
<> <>
<Button onClick={() => openCreateRoom()} variant="Secondary" size="300"> <Button onClick={() => navigate(getHomeCreatePath())} variant="Secondary" size="300">
<Text size="B300" truncate> <Text size="B300" truncate>
Create Room Create Room
</Text> </Text>
@ -204,8 +212,10 @@ export function Home() {
const rooms = useHomeRooms(); const rooms = useHomeRooms();
const notificationPreferences = useRoomsNotificationPreferencesContext(); const notificationPreferences = useRoomsNotificationPreferencesContext();
const roomToUnread = useAtomValue(roomToUnreadAtom); const roomToUnread = useAtomValue(roomToUnreadAtom);
const navigate = useNavigate();
const selectedRoomId = useSelectedRoom(); const selectedRoomId = useSelectedRoom();
const createRoomSelected = useHomeCreateSelected();
const searchSelected = useHomeSearchSelected(); const searchSelected = useHomeSearchSelected();
const noRoomToDisplay = rooms.length === 0; const noRoomToDisplay = rooms.length === 0;
const [closedCategories, setClosedCategories] = useAtom(useClosedNavCategoriesAtom()); const [closedCategories, setClosedCategories] = useAtom(useClosedNavCategoriesAtom());
@ -242,8 +252,8 @@ export function Home() {
<PageNavContent scrollRef={scrollRef}> <PageNavContent scrollRef={scrollRef}>
<Box direction="Column" gap="300"> <Box direction="Column" gap="300">
<NavCategory> <NavCategory>
<NavItem variant="Background" radii="400"> <NavItem variant="Background" radii="400" aria-selected={createRoomSelected}>
<NavButton onClick={() => openCreateRoom()}> <NavButton onClick={() => navigate(getHomeCreatePath())}>
<NavItemContent> <NavItemContent>
<Box as="span" grow="Yes" alignItems="Center" gap="200"> <Box as="span" grow="Yes" alignItems="Center" gap="200">
<Avatar size="200" radii="400"> <Avatar size="200" radii="400">

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/';

View file

@ -0,0 +1,7 @@
import { atom } from 'jotai';
export type CreateRoomModalState = {
spaceId?: string;
};
export const createRoomModalAtom = atom<CreateRoomModalState | undefined>(undefined);

View file

@ -0,0 +1,7 @@
import { atom } from 'jotai';
export type CreateSpaceModalState = {
spaceId?: string;
};
export const createSpaceModalAtom = atom<CreateSpaceModalState | undefined>(undefined);

View file

@ -0,0 +1,34 @@
import { useCallback } from 'react';
import { useAtomValue, useSetAtom } from 'jotai';
import { createRoomModalAtom, CreateRoomModalState } from '../createRoomModal';
export const useCreateRoomModalState = (): CreateRoomModalState | undefined => {
const data = useAtomValue(createRoomModalAtom);
return data;
};
type CloseCallback = () => void;
export const useCloseCreateRoomModal = (): CloseCallback => {
const setSettings = useSetAtom(createRoomModalAtom);
const close: CloseCallback = useCallback(() => {
setSettings(undefined);
}, [setSettings]);
return close;
};
type OpenCallback = (space?: string) => void;
export const useOpenCreateRoomModal = (): OpenCallback => {
const setSettings = useSetAtom(createRoomModalAtom);
const open: OpenCallback = useCallback(
(spaceId) => {
setSettings({ spaceId });
},
[setSettings]
);
return open;
};

View file

@ -0,0 +1,34 @@
import { useCallback } from 'react';
import { useAtomValue, useSetAtom } from 'jotai';
import { createSpaceModalAtom, CreateSpaceModalState } from '../createSpaceModal';
export const useCreateSpaceModalState = (): CreateSpaceModalState | undefined => {
const data = useAtomValue(createSpaceModalAtom);
return data;
};
type CloseCallback = () => void;
export const useCloseCreateSpaceModal = (): CloseCallback => {
const setSettings = useSetAtom(createSpaceModalAtom);
const close: CloseCallback = useCallback(() => {
setSettings(undefined);
}, [setSettings]);
return close;
};
type OpenCallback = (space?: string) => void;
export const useOpenCreateSpaceModal = (): OpenCallback => {
const setSettings = useSetAtom(createSpaceModalAtom);
const open: OpenCallback = useCallback(
(spaceId) => {
setSettings({ spaceId });
},
[setSettings]
);
return open;
};

View file

@ -1,6 +1,6 @@
import { ComplexStyleRule } from '@vanilla-extract/css'; import { ComplexStyleRule } from '@vanilla-extract/css';
import { RecipeVariants, recipe } from '@vanilla-extract/recipes'; import { RecipeVariants, recipe } from '@vanilla-extract/recipes';
import { ContainerColor as TContainerColor, DefaultReset, color } from 'folds'; import { ContainerColor as TContainerColor, DefaultReset, color, config } from 'folds';
const getVariant = (variant: TContainerColor): ComplexStyleRule => ({ const getVariant = (variant: TContainerColor): ComplexStyleRule => ({
vars: { vars: {
@ -9,6 +9,20 @@ const getVariant = (variant: TContainerColor): ComplexStyleRule => ({
outlineColor: color[variant].ContainerLine, outlineColor: color[variant].ContainerLine,
color: color[variant].OnContainer, color: color[variant].OnContainer,
}, },
selectors: {
'button&[aria-pressed=true]': {
backgroundColor: color[variant].ContainerActive,
},
'button&:hover, &:focus-visible': {
backgroundColor: color[variant].ContainerHover,
},
'button&:active': {
backgroundColor: color[variant].ContainerActive,
},
'button&[disabled]': {
opacity: config.opacity.Disabled,
},
},
}); });
export const ContainerColor = recipe({ export const ContainerColor = recipe({

View file

@ -18,6 +18,13 @@ export const millisecondsToMinutesAndSeconds = (milliseconds: number): string =>
return `${mm}:${ss < 10 ? '0' : ''}${ss}`; return `${mm}:${ss < 10 ? '0' : ''}${ss}`;
}; };
export const millisecondsToMinutes = (milliseconds: number): string => {
const seconds = Math.floor(milliseconds / 1000);
const mm = Math.floor(seconds / 60);
return mm.toString();
};
export const secondsToMinutesAndSeconds = (seconds: number): string => { export const secondsToMinutesAndSeconds = (seconds: number): string => {
const mm = Math.floor(seconds / 60); const mm = Math.floor(seconds / 60);
const ss = Math.round(seconds % 60); const ss = Math.round(seconds % 60);

View file

@ -344,3 +344,16 @@ export const rateLimitedActions = async <T, R = void>(
} }
} }
}; };
export const knockSupported = (version: string): boolean => {
const unsupportedVersion = ['1', '2', '3', '4', '5', '6'];
return !unsupportedVersion.includes(version);
};
export const restrictedSupported = (version: string): boolean => {
const unsupportedVersion = ['1', '2', '3', '4', '5', '6', '7'];
return !unsupportedVersion.includes(version);
};
export const knockRestrictedSupported = (version: string): boolean => {
const unsupportedVersion = ['1', '2', '3', '4', '5', '6', '7', '8', '9'];
return !unsupportedVersion.includes(version);
};