Redesign space/room creation panel (#2408)
authorAjay Bura <32841439+ajbura@users.noreply.github.com>
Tue, 5 Aug 2025 13:07:07 +0000 (18:37 +0530)
committerGitHub <noreply@github.com>
Tue, 5 Aug 2025 13:07:07 +0000 (23:07 +1000)
* 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

33 files changed:
src/app/components/create-room/CreateRoomAliasInput.tsx [new file with mode: 0644]
src/app/components/create-room/CreateRoomKindSelector.tsx [new file with mode: 0644]
src/app/components/create-room/RoomVersionSelector.tsx [new file with mode: 0644]
src/app/components/create-room/index.ts [new file with mode: 0644]
src/app/components/create-room/utils.ts [new file with mode: 0644]
src/app/components/sequence-card/SequenceCard.tsx
src/app/components/sequence-card/style.css.ts
src/app/features/common-settings/general/RoomJoinRules.tsx
src/app/features/create-room/CreateRoom.tsx [new file with mode: 0644]
src/app/features/create-room/CreateRoomModal.tsx [new file with mode: 0644]
src/app/features/create-room/index.ts [new file with mode: 0644]
src/app/features/create-space/CreateSpace.tsx [new file with mode: 0644]
src/app/features/create-space/CreateSpaceModal.tsx [new file with mode: 0644]
src/app/features/create-space/index.ts [new file with mode: 0644]
src/app/features/lobby/Lobby.tsx
src/app/features/lobby/SpaceItem.tsx
src/app/hooks/router/useCreateSelected.ts [new file with mode: 0644]
src/app/pages/Router.tsx
src/app/pages/client/SidebarNav.tsx
src/app/pages/client/create/Create.tsx [new file with mode: 0644]
src/app/pages/client/create/index.ts [new file with mode: 0644]
src/app/pages/client/home/CreateRoom.tsx [new file with mode: 0644]
src/app/pages/client/home/Home.tsx
src/app/pages/client/sidebar/CreateTab.tsx [new file with mode: 0644]
src/app/pages/pathUtils.ts
src/app/pages/paths.ts
src/app/state/createRoomModal.ts [new file with mode: 0644]
src/app/state/createSpaceModal.ts [new file with mode: 0644]
src/app/state/hooks/createRoomModal.ts [new file with mode: 0644]
src/app/state/hooks/createSpaceModal.ts [new file with mode: 0644]
src/app/styles/ContainerColor.css.ts
src/app/utils/common.ts
src/app/utils/matrix.ts

diff --git a/src/app/components/create-room/CreateRoomAliasInput.tsx b/src/app/components/create-room/CreateRoomAliasInput.tsx
new file mode 100644 (file)
index 0000000..e84658c
--- /dev/null
@@ -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>
+  );
+}
diff --git a/src/app/components/create-room/CreateRoomKindSelector.tsx b/src/app/components/create-room/CreateRoomKindSelector.tsx
new file mode 100644 (file)
index 0000000..096954f
--- /dev/null
@@ -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>
+  );
+}
diff --git a/src/app/components/create-room/RoomVersionSelector.tsx b/src/app/components/create-room/RoomVersionSelector.tsx
new file mode 100644 (file)
index 0000000..281f520
--- /dev/null
@@ -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>
+  );
+}
diff --git a/src/app/components/create-room/index.ts b/src/app/components/create-room/index.ts
new file mode 100644 (file)
index 0000000..ffca558
--- /dev/null
@@ -0,0 +1,4 @@
+export * from './CreateRoomKindSelector';
+export * from './CreateRoomAliasInput';
+export * from './RoomVersionSelector';
+export * from './utils';
diff --git a/src/app/components/create-room/utils.ts b/src/app/components/create-room/utils.ts
new file mode 100644 (file)
index 0000000..9abff4f
--- /dev/null
@@ -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;
+};
index 4036b9630d74487594ed434dfaf034bab7785bbd..d0e77ae681e7b30fc77bb38a3d298bbabeef4c33 100644 (file)
@@ -7,12 +7,31 @@ import * as css from './style.css';
 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}
+    />
+  )
+);
index c8ed48b851ef9ca537a05b9fc5d1d2e465421c76..9d5032646d566867c749f967ed4e21342a9751bd 100644 (file)
@@ -3,6 +3,7 @@ import { RecipeVariants, recipe } from '@vanilla-extract/recipes';
 import { config } from 'folds';
 
 const outlinedWidth = createVar('0');
+const radii = createVar(config.radii.R400);
 export const SequenceCard = recipe({
   base: {
     vars: {
@@ -13,33 +14,59 @@ export const SequenceCard = recipe({
     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: {
@@ -48,5 +75,8 @@ export const SequenceCard = recipe({
       },
     },
   },
+  defaultVariants: {
+    radii: '400',
+  },
 });
 export type SequenceCardVariants = RecipeVariants<typeof SequenceCard>;
index c0d62a6acf2bfcea92bb3109245b563ec6fffdc0..f47ff757196bfe3bc5e228d75172099d65cc9b3a 100644 (file)
@@ -27,6 +27,11 @@ import {
 } 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;
@@ -39,10 +44,9 @@ type RoomJoinRulesProps = {
 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();
diff --git a/src/app/features/create-room/CreateRoom.tsx b/src/app/features/create-room/CreateRoom.tsx
new file mode 100644 (file)
index 0000000..c88bf68
--- /dev/null
@@ -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>
+  );
+}
diff --git a/src/app/features/create-room/CreateRoomModal.tsx b/src/app/features/create-room/CreateRoomModal.tsx
new file mode 100644 (file)
index 0000000..c1c9ba3
--- /dev/null
@@ -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} />;
+}
diff --git a/src/app/features/create-room/index.ts b/src/app/features/create-room/index.ts
new file mode 100644 (file)
index 0000000..f60c94b
--- /dev/null
@@ -0,0 +1,2 @@
+export * from './CreateRoom';
+export * from './CreateRoomModal';
diff --git a/src/app/features/create-space/CreateSpace.tsx b/src/app/features/create-space/CreateSpace.tsx
new file mode 100644 (file)
index 0000000..d964152
--- /dev/null
@@ -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>
+  );
+}
diff --git a/src/app/features/create-space/CreateSpaceModal.tsx b/src/app/features/create-space/CreateSpaceModal.tsx
new file mode 100644 (file)
index 0000000..c1bc689
--- /dev/null
@@ -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} />;
+}
diff --git a/src/app/features/create-space/index.ts b/src/app/features/create-space/index.ts
new file mode 100644 (file)
index 0000000..d203993
--- /dev/null
@@ -0,0 +1,2 @@
+export * from './CreateSpace';
+export * from './CreateSpaceModal';
index 069e925eb99c00727f4d7bca63e3bc3731ba2e6e..45610ff3e2eefb8e0df5fca74fde0507ea4cdf60 100644 (file)
@@ -220,14 +220,12 @@ export function Lobby() {
       () =>
         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]
     )
   );
 
index 0a4d9de5a067db97a7726b89e7a41fde50ab0a69..e881a9717d77a34d2214a1e9e9c0b055cd0947ce 100644 (file)
@@ -30,10 +30,12 @@ import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
 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 (
@@ -240,13 +242,14 @@ function RootSpaceProfile({ closed, categoryId, handleClose }: RootSpaceProfileP
 
 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);
   };
 
@@ -303,13 +306,14 @@ function AddRoomButton({ item }: { item: HierarchyItem }) {
 
 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);
   };
 
@@ -470,7 +474,7 @@ export const SpaceItemCard = as<'div', SpaceItemCardProps>(
               </>
             )}
           </Box>
-          {canEditChild && (
+          {space && canEditChild && (
             <Box shrink="No" alignItems="Inherit" gap="200">
               <AddRoomButton item={item} />
               {item.parentId === undefined && <AddSpaceButton item={item} />}
diff --git a/src/app/hooks/router/useCreateSelected.ts b/src/app/hooks/router/useCreateSelected.ts
new file mode 100644 (file)
index 0000000..2034a44
--- /dev/null
@@ -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;
+};
index 89743693c579bc33b7b0c10d31189ad929bd7489..d6d93aa4ce12efe1bf524d7d7aedf2ef03706210 100644 (file)
@@ -28,6 +28,7 @@ import {
   _ROOM_PATH,
   _SEARCH_PATH,
   _SERVER_PATH,
+  CREATE_PATH,
 } from './paths';
 import { isAuthenticated } from '../../client/state/auth';
 import {
@@ -61,6 +62,10 @@ import { AutoRestoreBackupOnVerification } from '../components/BackupRestore';
 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;
@@ -125,6 +130,8 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
                       >
                         <Outlet />
                       </ClientLayout>
+                      <CreateRoomModalRenderer />
+                      <CreateSpaceModalRenderer />
                       <RoomSettingsRenderer />
                       <SpaceSettingsRenderer />
                       <ReceiveSelfDeviceVerification />
@@ -152,7 +159,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
           }
         >
           {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
@@ -253,6 +260,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
           <Route path={_FEATURED_PATH} element={<FeaturedRooms />} />
           <Route path={_SERVER_PATH} element={<PublicRooms />} />
         </Route>
+        <Route path={CREATE_PATH} element={<Create />} />
         <Route
           path={INBOX_PATH}
           element={
index 27759a2de33ae01956498742b59a960a6ae4d70c..6139f1fed3c4320ef807f4fd0e9c8125401d1db8 100644 (file)
@@ -19,7 +19,8 @@ import {
   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);
@@ -37,20 +38,7 @@ export function SidebarNav() {
             <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>
         }
diff --git a/src/app/pages/client/create/Create.tsx b/src/app/pages/client/create/Create.tsx
new file mode 100644 (file)
index 0000000..288169b
--- /dev/null
@@ -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>
+  );
+}
diff --git a/src/app/pages/client/create/index.ts b/src/app/pages/client/create/index.ts
new file mode 100644 (file)
index 0000000..48cba6e
--- /dev/null
@@ -0,0 +1 @@
+export * from './Create';
diff --git a/src/app/pages/client/home/CreateRoom.tsx b/src/app/pages/client/home/CreateRoom.tsx
new file mode 100644 (file)
index 0000000..20c01ba
--- /dev/null
@@ -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>
+  );
+}
index af4164fda88990949d5c357b2e5f931892a842c2..d23339199b30b54040a40e8b511ebc270d5428f0 100644 (file)
@@ -29,10 +29,18 @@ import {
   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';
@@ -41,7 +49,7 @@ import { makeNavCategoryId } from '../../../state/closedNavCategories';
 import { roomToUnreadAtom } from '../../../state/room/roomToUnread';
 import { useCategoryHandler } from '../../../hooks/useCategoryHandler';
 import { useNavToActivePathMapper } from '../../../hooks/useNavToActivePathMapper';
-import { openCreateRoom, openJoinAlias } from '../../../../client/action/navigation';
+import { openJoinAlias } from '../../../../client/action/navigation';
 import { PageNav, PageNavHeader, PageNavContent } from '../../../components/page';
 import { useRoomsUnread } from '../../../state/hooks/unread';
 import { markAsRead } from '../../../../client/action/notifications';
@@ -174,7 +182,7 @@ function HomeEmpty() {
         }
         options={
           <>
-            <Button onClick={() => openCreateRoom()} variant="Secondary" size="300">
+            <Button onClick={() => navigate(getHomeCreatePath())} variant="Secondary" size="300">
               <Text size="B300" truncate>
                 Create Room
               </Text>
@@ -204,8 +212,10 @@ export function Home() {
   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());
@@ -242,8 +252,8 @@ export function Home() {
         <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">
diff --git a/src/app/pages/client/sidebar/CreateTab.tsx b/src/app/pages/client/sidebar/CreateTab.tsx
new file mode 100644 (file)
index 0000000..a7f9350
--- /dev/null
@@ -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>
+  );
+}
index cbd453ae30e55e18a220b92e5d078f6db02bef17..817d21d1f37e2402c8d07aa44e06b88b92edc349 100644 (file)
@@ -22,6 +22,7 @@ import {
   SPACE_PATH,
   SPACE_ROOM_PATH,
   SPACE_SEARCH_PATH,
+  CREATE_PATH,
 } from './paths';
 import { trimLeadingSlash, trimTrailingSlash } from '../utils/common';
 import { HashRouterConfig } from '../hooks/useClientConfig';
@@ -152,6 +153,8 @@ export const getExploreServerPath = (server: string): string => {
   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;
index 54da892200031de035759425e2189d15b351f0d3..9cf4bfb963c0e2e0126fe2f0f06a45e935adfa2d 100644 (file)
@@ -74,6 +74,8 @@ export type ExploreServerPathSearchParams = {
 };
 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/';
diff --git a/src/app/state/createRoomModal.ts b/src/app/state/createRoomModal.ts
new file mode 100644 (file)
index 0000000..81af5d5
--- /dev/null
@@ -0,0 +1,7 @@
+import { atom } from 'jotai';
+
+export type CreateRoomModalState = {
+  spaceId?: string;
+};
+
+export const createRoomModalAtom = atom<CreateRoomModalState | undefined>(undefined);
diff --git a/src/app/state/createSpaceModal.ts b/src/app/state/createSpaceModal.ts
new file mode 100644 (file)
index 0000000..fe4db75
--- /dev/null
@@ -0,0 +1,7 @@
+import { atom } from 'jotai';
+
+export type CreateSpaceModalState = {
+  spaceId?: string;
+};
+
+export const createSpaceModalAtom = atom<CreateSpaceModalState | undefined>(undefined);
diff --git a/src/app/state/hooks/createRoomModal.ts b/src/app/state/hooks/createRoomModal.ts
new file mode 100644 (file)
index 0000000..15db728
--- /dev/null
@@ -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;
+};
diff --git a/src/app/state/hooks/createSpaceModal.ts b/src/app/state/hooks/createSpaceModal.ts
new file mode 100644 (file)
index 0000000..ea7cb47
--- /dev/null
@@ -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;
+};
index cb1f933daab9a15eeb57f3bcc9a6f5c0ce9cb047..cefc5256a2f1028696b6231c048e311255d2fe29 100644 (file)
@@ -1,6 +1,6 @@
 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: {
@@ -9,6 +9,20 @@ const getVariant = (variant: TContainerColor): ComplexStyleRule => ({
     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({
index 34e1ecbf78f6b2ee1fe7a66e6ec1ca0bba6cc398..678f1b6ef8eb0ffbec7821161ea8089c05a7e253 100644 (file)
@@ -18,6 +18,13 @@ export const millisecondsToMinutesAndSeconds = (milliseconds: number): string =>
   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);
index 610ef0af221fd0227f2cfc580af2b77aa89e513c..b31677a0f06ed9c0fb8e744ab4a2d94f39435588 100644 (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);
+};