--- /dev/null
+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>
+ );
+}
--- /dev/null
+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>
+ );
+}
--- /dev/null
+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>
+ );
+}
--- /dev/null
+export * from './CreateRoomKindSelector';
+export * from './CreateRoomAliasInput';
+export * from './RoomVersionSelector';
+export * from './utils';
--- /dev/null
+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;
+};
export const SequenceCard = as<
'div',
ComponentProps<typeof Box> & ContainerColorVariants & css.SequenceCardVariants
->(({ className, variant, firstChild, lastChild, outlined, ...props }, ref) => (
- <Box
- className={classNames(css.SequenceCard({ outlined }), ContainerColor({ variant }), className)}
- data-first-child={firstChild}
- data-last-child={lastChild}
- {...props}
- ref={ref}
- />
-));
+>(
+ (
+ {
+ as: AsSequenceCard = 'div',
+ className,
+ variant,
+ radii,
+ firstChild,
+ lastChild,
+ outlined,
+ ...props
+ },
+ ref
+ ) => (
+ <Box
+ as={AsSequenceCard}
+ className={classNames(
+ css.SequenceCard({ radii, outlined }),
+ ContainerColor({ variant }),
+ className
+ )}
+ data-first-child={firstChild}
+ data-last-child={lastChild}
+ {...props}
+ ref={ref}
+ />
+ )
+);
import { config } from 'folds';
const outlinedWidth = createVar('0');
+const radii = createVar(config.radii.R400);
export const SequenceCard = recipe({
base: {
vars: {
borderBottomWidth: 0,
selectors: {
'&:first-child, :not(&) + &': {
- borderTopLeftRadius: config.radii.R400,
- borderTopRightRadius: config.radii.R400,
+ borderTopLeftRadius: [radii],
+ borderTopRightRadius: [radii],
},
'&:last-child, &:not(:has(+&))': {
- borderBottomLeftRadius: config.radii.R400,
- borderBottomRightRadius: config.radii.R400,
+ borderBottomLeftRadius: [radii],
+ borderBottomRightRadius: [radii],
borderBottomWidth: outlinedWidth,
},
[`&[data-first-child="true"]`]: {
- borderTopLeftRadius: config.radii.R400,
- borderTopRightRadius: config.radii.R400,
+ borderTopLeftRadius: [radii],
+ borderTopRightRadius: [radii],
},
[`&[data-first-child="false"]`]: {
borderTopLeftRadius: 0,
borderTopRightRadius: 0,
},
[`&[data-last-child="true"]`]: {
- borderBottomLeftRadius: config.radii.R400,
- borderBottomRightRadius: config.radii.R400,
+ borderBottomLeftRadius: [radii],
+ borderBottomRightRadius: [radii],
},
[`&[data-last-child="false"]`]: {
borderBottomLeftRadius: 0,
borderBottomRightRadius: 0,
},
+
+ 'button&': {
+ cursor: 'pointer',
+ },
},
},
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: {
true: {
vars: {
},
},
},
+ defaultVariants: {
+ radii: '400',
+ },
});
export type SequenceCardVariants = RecipeVariants<typeof SequenceCard>;
} from '../../../state/hooks/roomList';
import { allRoomsAtom } from '../../../state/room-list/roomList';
import { roomToParentsAtom } from '../../../state/room/roomToParents';
+import {
+ knockRestrictedSupported,
+ knockSupported,
+ restrictedSupported,
+} from '../../../utils/matrix';
type RestrictedRoomAllowContent = {
room_id: string;
export function RoomJoinRules({ powerLevels }: RoomJoinRulesProps) {
const mx = useMatrixClient();
const room = useRoom();
- const roomVersion = parseInt(room.getVersion(), 10);
- const allowKnockRestricted = roomVersion >= 10;
- const allowRestricted = roomVersion >= 8;
- const allowKnock = roomVersion >= 7;
+ const allowKnockRestricted = knockRestrictedSupported(room.getVersion());
+ const allowRestricted = restrictedSupported(room.getVersion());
+ const allowKnock = knockSupported(room.getVersion());
const roomIdToParents = useAtomValue(roomToParentsAtom);
const space = useSpaceOptionally();
--- /dev/null
+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>
+ );
+}
--- /dev/null
+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} />;
+}
--- /dev/null
+export * from './CreateRoom';
+export * from './CreateRoomModal';
--- /dev/null
+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>
+ );
+}
--- /dev/null
+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} />;
+}
--- /dev/null
+export * from './CreateSpace';
+export * from './CreateSpaceModal';
() =>
hierarchy
.flatMap((i) => {
- const childRooms = Array.isArray(i.rooms)
- ? i.rooms.map((r) => mx.getRoom(r.roomId))
- : [];
+ const childRooms = Array.isArray(i.rooms) ? i.rooms.map((r) => getRoom(r.roomId)) : [];
- return [mx.getRoom(i.space.roomId), ...childRooms];
+ return [getRoom(i.space.roomId), ...childRooms];
})
.filter((r) => !!r) as Room[],
- [mx, hierarchy]
+ [hierarchy, getRoom]
)
);
import * as css from './SpaceItem.css';
import * as styleCss from './style.css';
import { useDraggableItem } from './DnD';
-import { openCreateRoom, openSpaceAddExisting } from '../../../client/action/navigation';
+import { openSpaceAddExisting } from '../../../client/action/navigation';
import { stopPropagation } from '../../utils/keyboard';
import { mxcUrlToHttp } from '../../utils/matrix';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
+import { useOpenCreateRoomModal } from '../../state/hooks/createRoomModal';
+import { useOpenCreateSpaceModal } from '../../state/hooks/createSpaceModal';
function SpaceProfileLoading() {
return (
function AddRoomButton({ item }: { item: HierarchyItem }) {
const [cords, setCords] = useState<RectCords>();
+ const openCreateRoomModal = useOpenCreateRoomModal();
const handleAddRoom: MouseEventHandler<HTMLButtonElement> = (evt) => {
setCords(evt.currentTarget.getBoundingClientRect());
};
const handleCreateRoom = () => {
- openCreateRoom(false, item.roomId as any);
+ openCreateRoomModal(item.roomId);
setCords(undefined);
};
function AddSpaceButton({ item }: { item: HierarchyItem }) {
const [cords, setCords] = useState<RectCords>();
+ const openCreateSpaceModal = useOpenCreateSpaceModal();
const handleAddSpace: MouseEventHandler<HTMLButtonElement> = (evt) => {
setCords(evt.currentTarget.getBoundingClientRect());
};
const handleCreateSpace = () => {
- openCreateRoom(true, item.roomId as any);
+ openCreateSpaceModal(item.roomId as any);
setCords(undefined);
};
</>
)}
</Box>
- {canEditChild && (
+ {space && canEditChild && (
<Box shrink="No" alignItems="Inherit" gap="200">
<AddRoomButton item={item} />
{item.parentId === undefined && <AddSpaceButton item={item} />}
--- /dev/null
+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;
+};
_ROOM_PATH,
_SEARCH_PATH,
_SERVER_PATH,
+ CREATE_PATH,
} from './paths';
import { isAuthenticated } from '../../client/state/auth';
import {
import { RoomSettingsRenderer } from '../features/room-settings';
import { ClientRoomsNotificationPreferences } from './client/ClientRoomsNotificationPreferences';
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) => {
const { hashRouter } = clientConfig;
>
<Outlet />
</ClientLayout>
+ <CreateRoomModalRenderer />
+ <CreateSpaceModalRenderer />
<RoomSettingsRenderer />
<SpaceSettingsRenderer />
<ReceiveSelfDeviceVerification />
}
>
{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={_SEARCH_PATH} element={<HomeSearch />} />
<Route
<Route path={_FEATURED_PATH} element={<FeaturedRooms />} />
<Route path={_SERVER_PATH} element={<PublicRooms />} />
</Route>
+ <Route path={CREATE_PATH} element={<Create />} />
<Route
path={INBOX_PATH}
element={
SettingsTab,
UnverifiedTab,
} from './sidebar';
-import { openCreateRoom, openSearch } from '../../../client/action/navigation';
+import { openSearch } from '../../../client/action/navigation';
+import { CreateTab } from './sidebar/CreateTab';
export function SidebarNav() {
const scrollRef = useRef<HTMLDivElement>(null);
<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>
+ <CreateTab />
</SidebarStack>
</Scroll>
}
--- /dev/null
+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>
+ );
+}
--- /dev/null
+export * from './Create';
--- /dev/null
+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>
+ );
+}
NavItemContent,
NavLink,
} from '../../../components/nav';
-import { getExplorePath, getHomeRoomPath, getHomeSearchPath } from '../../pathUtils';
+import {
+ getExplorePath,
+ getHomeCreatePath,
+ getHomeRoomPath,
+ getHomeSearchPath,
+} from '../../pathUtils';
import { getCanonicalAliasOrRoomId } from '../../../utils/matrix';
import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom';
-import { useHomeSearchSelected } from '../../../hooks/router/useHomeSelected';
+import {
+ useHomeCreateSelected,
+ useHomeSearchSelected,
+} from '../../../hooks/router/useHomeSelected';
import { useHomeRooms } from './useHomeRooms';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { VirtualTile } from '../../../components/virtualizer';
import { roomToUnreadAtom } from '../../../state/room/roomToUnread';
import { useCategoryHandler } from '../../../hooks/useCategoryHandler';
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 { useRoomsUnread } from '../../../state/hooks/unread';
import { markAsRead } from '../../../../client/action/notifications';
}
options={
<>
- <Button onClick={() => openCreateRoom()} variant="Secondary" size="300">
+ <Button onClick={() => navigate(getHomeCreatePath())} variant="Secondary" size="300">
<Text size="B300" truncate>
Create Room
</Text>
const rooms = useHomeRooms();
const notificationPreferences = useRoomsNotificationPreferencesContext();
const roomToUnread = useAtomValue(roomToUnreadAtom);
+ const navigate = useNavigate();
const selectedRoomId = useSelectedRoom();
+ const createRoomSelected = useHomeCreateSelected();
const searchSelected = useHomeSearchSelected();
const noRoomToDisplay = rooms.length === 0;
const [closedCategories, setClosedCategories] = useAtom(useClosedNavCategoriesAtom());
<PageNavContent scrollRef={scrollRef}>
<Box direction="Column" gap="300">
<NavCategory>
- <NavItem variant="Background" radii="400">
- <NavButton onClick={() => openCreateRoom()}>
+ <NavItem variant="Background" radii="400" aria-selected={createRoomSelected}>
+ <NavButton onClick={() => navigate(getHomeCreatePath())}>
<NavItemContent>
<Box as="span" grow="Yes" alignItems="Center" gap="200">
<Avatar size="200" radii="400">
--- /dev/null
+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>
+ );
+}
SPACE_PATH,
SPACE_ROOM_PATH,
SPACE_SEARCH_PATH,
+ CREATE_PATH,
} from './paths';
import { trimLeadingSlash, trimTrailingSlash } from '../utils/common';
import { HashRouterConfig } from '../hooks/useClientConfig';
return generatePath(EXPLORE_SERVER_PATH, params);
};
+export const getCreatePath = (): string => CREATE_PATH;
+
export const getInboxPath = (): string => INBOX_PATH;
export const getInboxNotificationsPath = (): string => INBOX_NOTIFICATIONS_PATH;
export const getInboxInvitesPath = (): string => INBOX_INVITES_PATH;
};
export const EXPLORE_SERVER_PATH = `/explore/${_SERVER_PATH}`;
+export const CREATE_PATH = '/create';
+
export const _NOTIFICATIONS_PATH = 'notifications/';
export const _INVITES_PATH = 'invites/';
export const INBOX_PATH = '/inbox/';
--- /dev/null
+import { atom } from 'jotai';
+
+export type CreateRoomModalState = {
+ spaceId?: string;
+};
+
+export const createRoomModalAtom = atom<CreateRoomModalState | undefined>(undefined);
--- /dev/null
+import { atom } from 'jotai';
+
+export type CreateSpaceModalState = {
+ spaceId?: string;
+};
+
+export const createSpaceModalAtom = atom<CreateSpaceModalState | undefined>(undefined);
--- /dev/null
+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;
+};
--- /dev/null
+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;
+};
import { ComplexStyleRule } from '@vanilla-extract/css';
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 => ({
vars: {
outlineColor: color[variant].ContainerLine,
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({
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 => {
const mm = Math.floor(seconds / 60);
const ss = Math.round(seconds % 60);
}
}
};
+
+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);
+};