mirror of
				https://github.com/cinnyapp/cinny.git
				synced 2025-11-04 06:20:28 +03:00 
			
		
		
		
	Redesign space/room creation panel (#2408)
* add new create room * rename create room modal file * default restrict access for space children in room create modal * move create room kind selector to components * add radii variant to sequence card component * more more reusable create room logic to components * add create space * update address input description * add new space modal * fix add room button visible on left room in space lobby
This commit is contained in:
		
							parent
							
								
									e9798a22c3
								
							
						
					
					
						commit
						faa952295f
					
				
					 33 changed files with 1637 additions and 53 deletions
				
			
		
							
								
								
									
										118
									
								
								src/app/components/create-room/CreateRoomAliasInput.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										118
									
								
								src/app/components/create-room/CreateRoomAliasInput.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,118 @@
 | 
			
		|||
import React, {
 | 
			
		||||
  FormEventHandler,
 | 
			
		||||
  KeyboardEventHandler,
 | 
			
		||||
  useCallback,
 | 
			
		||||
  useEffect,
 | 
			
		||||
  useRef,
 | 
			
		||||
  useState,
 | 
			
		||||
} from 'react';
 | 
			
		||||
import { MatrixError } from 'matrix-js-sdk';
 | 
			
		||||
import { Box, color, Icon, Icons, Input, Spinner, Text, toRem } from 'folds';
 | 
			
		||||
import { isKeyHotkey } from 'is-hotkey';
 | 
			
		||||
import { getMxIdServer } from '../../utils/matrix';
 | 
			
		||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
 | 
			
		||||
import { replaceSpaceWithDash } from '../../utils/common';
 | 
			
		||||
import { AsyncState, AsyncStatus, useAsync } from '../../hooks/useAsyncCallback';
 | 
			
		||||
import { useDebounce } from '../../hooks/useDebounce';
 | 
			
		||||
 | 
			
		||||
export function CreateRoomAliasInput({ disabled }: { disabled?: boolean }) {
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
  const aliasInputRef = useRef<HTMLInputElement>(null);
 | 
			
		||||
  const [aliasAvail, setAliasAvail] = useState<AsyncState<boolean, Error>>({
 | 
			
		||||
    status: AsyncStatus.Idle,
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    if (aliasAvail.status === AsyncStatus.Success && aliasInputRef.current?.value === '') {
 | 
			
		||||
      setAliasAvail({ status: AsyncStatus.Idle });
 | 
			
		||||
    }
 | 
			
		||||
  }, [aliasAvail]);
 | 
			
		||||
 | 
			
		||||
  const checkAliasAvail = useAsync(
 | 
			
		||||
    useCallback(
 | 
			
		||||
      async (aliasLocalPart: string) => {
 | 
			
		||||
        const roomAlias = `#${aliasLocalPart}:${getMxIdServer(mx.getSafeUserId())}`;
 | 
			
		||||
        try {
 | 
			
		||||
          const result = await mx.getRoomIdForAlias(roomAlias);
 | 
			
		||||
          return typeof result.room_id !== 'string';
 | 
			
		||||
        } catch (e) {
 | 
			
		||||
          if (e instanceof MatrixError && e.httpStatus === 404) {
 | 
			
		||||
            return true;
 | 
			
		||||
          }
 | 
			
		||||
          throw e;
 | 
			
		||||
        }
 | 
			
		||||
      },
 | 
			
		||||
      [mx]
 | 
			
		||||
    ),
 | 
			
		||||
    setAliasAvail
 | 
			
		||||
  );
 | 
			
		||||
  const aliasAvailable: boolean | undefined =
 | 
			
		||||
    aliasAvail.status === AsyncStatus.Success ? aliasAvail.data : undefined;
 | 
			
		||||
 | 
			
		||||
  const debounceCheckAliasAvail = useDebounce(checkAliasAvail, { wait: 500 });
 | 
			
		||||
 | 
			
		||||
  const handleAliasChange: FormEventHandler<HTMLInputElement> = (evt) => {
 | 
			
		||||
    const aliasInput = evt.currentTarget;
 | 
			
		||||
    const aliasLocalPart = replaceSpaceWithDash(aliasInput.value);
 | 
			
		||||
    if (aliasLocalPart) {
 | 
			
		||||
      aliasInput.value = aliasLocalPart;
 | 
			
		||||
      debounceCheckAliasAvail(aliasLocalPart);
 | 
			
		||||
    } else {
 | 
			
		||||
      setAliasAvail({ status: AsyncStatus.Idle });
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const handleAliasKeyDown: KeyboardEventHandler<HTMLInputElement> = (evt) => {
 | 
			
		||||
    if (isKeyHotkey('enter', evt)) {
 | 
			
		||||
      evt.preventDefault();
 | 
			
		||||
 | 
			
		||||
      const aliasInput = evt.currentTarget;
 | 
			
		||||
      const aliasLocalPart = replaceSpaceWithDash(aliasInput.value);
 | 
			
		||||
      if (aliasLocalPart) {
 | 
			
		||||
        checkAliasAvail(aliasLocalPart);
 | 
			
		||||
      } else {
 | 
			
		||||
        setAliasAvail({ status: AsyncStatus.Idle });
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Box shrink="No" direction="Column" gap="100">
 | 
			
		||||
      <Text size="L400">Address (Optional)</Text>
 | 
			
		||||
      <Text size="T200" priority="300">
 | 
			
		||||
        Pick an unique address to make it discoverable.
 | 
			
		||||
      </Text>
 | 
			
		||||
      <Input
 | 
			
		||||
        ref={aliasInputRef}
 | 
			
		||||
        onChange={handleAliasChange}
 | 
			
		||||
        before={
 | 
			
		||||
          aliasAvail.status === AsyncStatus.Loading ? (
 | 
			
		||||
            <Spinner size="100" variant="Secondary" />
 | 
			
		||||
          ) : (
 | 
			
		||||
            <Icon size="100" src={Icons.Hash} />
 | 
			
		||||
          )
 | 
			
		||||
        }
 | 
			
		||||
        after={
 | 
			
		||||
          <Text style={{ maxWidth: toRem(150) }} truncate>
 | 
			
		||||
            :{getMxIdServer(mx.getSafeUserId())}
 | 
			
		||||
          </Text>
 | 
			
		||||
        }
 | 
			
		||||
        onKeyDown={handleAliasKeyDown}
 | 
			
		||||
        name="aliasInput"
 | 
			
		||||
        size="500"
 | 
			
		||||
        variant={aliasAvailable === true ? 'Success' : 'SurfaceVariant'}
 | 
			
		||||
        radii="400"
 | 
			
		||||
        autoComplete="off"
 | 
			
		||||
        disabled={disabled}
 | 
			
		||||
      />
 | 
			
		||||
      {aliasAvailable === false && (
 | 
			
		||||
        <Box style={{ color: color.Critical.Main }} alignItems="Center" gap="100">
 | 
			
		||||
          <Icon src={Icons.Warning} filled size="50" />
 | 
			
		||||
          <Text size="T200">
 | 
			
		||||
            <b>This address is already taken. Please select a different one.</b>
 | 
			
		||||
          </Text>
 | 
			
		||||
        </Box>
 | 
			
		||||
      )}
 | 
			
		||||
    </Box>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										94
									
								
								src/app/components/create-room/CreateRoomKindSelector.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										94
									
								
								src/app/components/create-room/CreateRoomKindSelector.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,94 @@
 | 
			
		|||
import React from 'react';
 | 
			
		||||
import { Box, Text, Icon, Icons, config, IconSrc } from 'folds';
 | 
			
		||||
import { SequenceCard } from '../sequence-card';
 | 
			
		||||
import { SettingTile } from '../setting-tile';
 | 
			
		||||
 | 
			
		||||
export enum CreateRoomKind {
 | 
			
		||||
  Private = 'private',
 | 
			
		||||
  Restricted = 'restricted',
 | 
			
		||||
  Public = 'public',
 | 
			
		||||
}
 | 
			
		||||
type CreateRoomKindSelectorProps = {
 | 
			
		||||
  value?: CreateRoomKind;
 | 
			
		||||
  onSelect: (value: CreateRoomKind) => void;
 | 
			
		||||
  canRestrict?: boolean;
 | 
			
		||||
  disabled?: boolean;
 | 
			
		||||
  getIcon: (kind: CreateRoomKind) => IconSrc;
 | 
			
		||||
};
 | 
			
		||||
export function CreateRoomKindSelector({
 | 
			
		||||
  value,
 | 
			
		||||
  onSelect,
 | 
			
		||||
  canRestrict,
 | 
			
		||||
  disabled,
 | 
			
		||||
  getIcon,
 | 
			
		||||
}: CreateRoomKindSelectorProps) {
 | 
			
		||||
  return (
 | 
			
		||||
    <Box shrink="No" direction="Column" gap="100">
 | 
			
		||||
      {canRestrict && (
 | 
			
		||||
        <SequenceCard
 | 
			
		||||
          style={{ padding: config.space.S300 }}
 | 
			
		||||
          variant={value === CreateRoomKind.Restricted ? 'Primary' : 'SurfaceVariant'}
 | 
			
		||||
          direction="Column"
 | 
			
		||||
          gap="100"
 | 
			
		||||
          as="button"
 | 
			
		||||
          type="button"
 | 
			
		||||
          aria-pressed={value === CreateRoomKind.Restricted}
 | 
			
		||||
          onClick={() => onSelect(CreateRoomKind.Restricted)}
 | 
			
		||||
          disabled={disabled}
 | 
			
		||||
        >
 | 
			
		||||
          <SettingTile
 | 
			
		||||
            before={<Icon size="400" src={getIcon(CreateRoomKind.Restricted)} />}
 | 
			
		||||
            after={value === CreateRoomKind.Restricted && <Icon src={Icons.Check} />}
 | 
			
		||||
          >
 | 
			
		||||
            <Text size="H6">Restricted</Text>
 | 
			
		||||
            <Text size="T300" priority="300">
 | 
			
		||||
              Only member of parent space can join.
 | 
			
		||||
            </Text>
 | 
			
		||||
          </SettingTile>
 | 
			
		||||
        </SequenceCard>
 | 
			
		||||
      )}
 | 
			
		||||
      <SequenceCard
 | 
			
		||||
        style={{ padding: config.space.S300 }}
 | 
			
		||||
        variant={value === CreateRoomKind.Private ? 'Primary' : 'SurfaceVariant'}
 | 
			
		||||
        direction="Column"
 | 
			
		||||
        gap="100"
 | 
			
		||||
        as="button"
 | 
			
		||||
        type="button"
 | 
			
		||||
        aria-pressed={value === CreateRoomKind.Private}
 | 
			
		||||
        onClick={() => onSelect(CreateRoomKind.Private)}
 | 
			
		||||
        disabled={disabled}
 | 
			
		||||
      >
 | 
			
		||||
        <SettingTile
 | 
			
		||||
          before={<Icon size="400" src={getIcon(CreateRoomKind.Private)} />}
 | 
			
		||||
          after={value === CreateRoomKind.Private && <Icon src={Icons.Check} />}
 | 
			
		||||
        >
 | 
			
		||||
          <Text size="H6">Private</Text>
 | 
			
		||||
          <Text size="T300" priority="300">
 | 
			
		||||
            Only people with invite can join.
 | 
			
		||||
          </Text>
 | 
			
		||||
        </SettingTile>
 | 
			
		||||
      </SequenceCard>
 | 
			
		||||
      <SequenceCard
 | 
			
		||||
        style={{ padding: config.space.S300 }}
 | 
			
		||||
        variant={value === CreateRoomKind.Public ? 'Primary' : 'SurfaceVariant'}
 | 
			
		||||
        direction="Column"
 | 
			
		||||
        gap="100"
 | 
			
		||||
        as="button"
 | 
			
		||||
        type="button"
 | 
			
		||||
        aria-pressed={value === CreateRoomKind.Public}
 | 
			
		||||
        onClick={() => onSelect(CreateRoomKind.Public)}
 | 
			
		||||
        disabled={disabled}
 | 
			
		||||
      >
 | 
			
		||||
        <SettingTile
 | 
			
		||||
          before={<Icon size="400" src={getIcon(CreateRoomKind.Public)} />}
 | 
			
		||||
          after={value === CreateRoomKind.Public && <Icon src={Icons.Check} />}
 | 
			
		||||
        >
 | 
			
		||||
          <Text size="H6">Public</Text>
 | 
			
		||||
          <Text size="T300" priority="300">
 | 
			
		||||
            Anyone with the address can join.
 | 
			
		||||
          </Text>
 | 
			
		||||
        </SettingTile>
 | 
			
		||||
      </SequenceCard>
 | 
			
		||||
    </Box>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										117
									
								
								src/app/components/create-room/RoomVersionSelector.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										117
									
								
								src/app/components/create-room/RoomVersionSelector.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,117 @@
 | 
			
		|||
import React, { MouseEventHandler, useState } from 'react';
 | 
			
		||||
import {
 | 
			
		||||
  Box,
 | 
			
		||||
  Button,
 | 
			
		||||
  Chip,
 | 
			
		||||
  config,
 | 
			
		||||
  Icon,
 | 
			
		||||
  Icons,
 | 
			
		||||
  Menu,
 | 
			
		||||
  PopOut,
 | 
			
		||||
  RectCords,
 | 
			
		||||
  Text,
 | 
			
		||||
  toRem,
 | 
			
		||||
} from 'folds';
 | 
			
		||||
import FocusTrap from 'focus-trap-react';
 | 
			
		||||
import { SettingTile } from '../setting-tile';
 | 
			
		||||
import { SequenceCard } from '../sequence-card';
 | 
			
		||||
import { stopPropagation } from '../../utils/keyboard';
 | 
			
		||||
 | 
			
		||||
export function RoomVersionSelector({
 | 
			
		||||
  versions,
 | 
			
		||||
  value,
 | 
			
		||||
  onChange,
 | 
			
		||||
  disabled,
 | 
			
		||||
}: {
 | 
			
		||||
  versions: string[];
 | 
			
		||||
  value: string;
 | 
			
		||||
  onChange: (value: string) => void;
 | 
			
		||||
  disabled?: boolean;
 | 
			
		||||
}) {
 | 
			
		||||
  const [menuCords, setMenuCords] = useState<RectCords>();
 | 
			
		||||
 | 
			
		||||
  const handleMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
 | 
			
		||||
    setMenuCords(evt.currentTarget.getBoundingClientRect());
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const handleSelect = (version: string) => {
 | 
			
		||||
    setMenuCords(undefined);
 | 
			
		||||
    onChange(version);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <SequenceCard
 | 
			
		||||
      style={{ padding: config.space.S300 }}
 | 
			
		||||
      variant="SurfaceVariant"
 | 
			
		||||
      direction="Column"
 | 
			
		||||
      gap="500"
 | 
			
		||||
    >
 | 
			
		||||
      <SettingTile
 | 
			
		||||
        title="Room Version"
 | 
			
		||||
        after={
 | 
			
		||||
          <PopOut
 | 
			
		||||
            anchor={menuCords}
 | 
			
		||||
            offset={5}
 | 
			
		||||
            position="Bottom"
 | 
			
		||||
            align="End"
 | 
			
		||||
            content={
 | 
			
		||||
              <FocusTrap
 | 
			
		||||
                focusTrapOptions={{
 | 
			
		||||
                  initialFocus: false,
 | 
			
		||||
                  onDeactivate: () => setMenuCords(undefined),
 | 
			
		||||
                  clickOutsideDeactivates: true,
 | 
			
		||||
                  isKeyForward: (evt: KeyboardEvent) =>
 | 
			
		||||
                    evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
 | 
			
		||||
                  isKeyBackward: (evt: KeyboardEvent) =>
 | 
			
		||||
                    evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
 | 
			
		||||
                  escapeDeactivates: stopPropagation,
 | 
			
		||||
                }}
 | 
			
		||||
              >
 | 
			
		||||
                <Menu>
 | 
			
		||||
                  <Box
 | 
			
		||||
                    direction="Column"
 | 
			
		||||
                    gap="200"
 | 
			
		||||
                    style={{ padding: config.space.S200, maxWidth: toRem(300) }}
 | 
			
		||||
                  >
 | 
			
		||||
                    <Text size="L400">Versions</Text>
 | 
			
		||||
                    <Box wrap="Wrap" gap="100">
 | 
			
		||||
                      {versions.map((version) => (
 | 
			
		||||
                        <Chip
 | 
			
		||||
                          key={version}
 | 
			
		||||
                          variant={value === version ? 'Primary' : 'SurfaceVariant'}
 | 
			
		||||
                          aria-pressed={value === version}
 | 
			
		||||
                          outlined={value === version}
 | 
			
		||||
                          radii="300"
 | 
			
		||||
                          onClick={() => handleSelect(version)}
 | 
			
		||||
                          type="button"
 | 
			
		||||
                        >
 | 
			
		||||
                          <Text truncate size="T300">
 | 
			
		||||
                            {version}
 | 
			
		||||
                          </Text>
 | 
			
		||||
                        </Chip>
 | 
			
		||||
                      ))}
 | 
			
		||||
                    </Box>
 | 
			
		||||
                  </Box>
 | 
			
		||||
                </Menu>
 | 
			
		||||
              </FocusTrap>
 | 
			
		||||
            }
 | 
			
		||||
          >
 | 
			
		||||
            <Button
 | 
			
		||||
              type="button"
 | 
			
		||||
              onClick={handleMenu}
 | 
			
		||||
              size="300"
 | 
			
		||||
              variant="Secondary"
 | 
			
		||||
              fill="Soft"
 | 
			
		||||
              radii="300"
 | 
			
		||||
              aria-pressed={!!menuCords}
 | 
			
		||||
              before={<Icon size="50" src={menuCords ? Icons.ChevronTop : Icons.ChevronBottom} />}
 | 
			
		||||
              disabled={disabled}
 | 
			
		||||
            >
 | 
			
		||||
              <Text size="B300">{value}</Text>
 | 
			
		||||
            </Button>
 | 
			
		||||
          </PopOut>
 | 
			
		||||
        }
 | 
			
		||||
      />
 | 
			
		||||
    </SequenceCard>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										4
									
								
								src/app/components/create-room/index.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								src/app/components/create-room/index.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,4 @@
 | 
			
		|||
export * from './CreateRoomKindSelector';
 | 
			
		||||
export * from './CreateRoomAliasInput';
 | 
			
		||||
export * from './RoomVersionSelector';
 | 
			
		||||
export * from './utils';
 | 
			
		||||
							
								
								
									
										131
									
								
								src/app/components/create-room/utils.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										131
									
								
								src/app/components/create-room/utils.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,131 @@
 | 
			
		|||
import {
 | 
			
		||||
  ICreateRoomOpts,
 | 
			
		||||
  ICreateRoomStateEvent,
 | 
			
		||||
  JoinRule,
 | 
			
		||||
  MatrixClient,
 | 
			
		||||
  RestrictedAllowType,
 | 
			
		||||
  Room,
 | 
			
		||||
} from 'matrix-js-sdk';
 | 
			
		||||
import { RoomJoinRulesEventContent } from 'matrix-js-sdk/lib/types';
 | 
			
		||||
import { CreateRoomKind } from './CreateRoomKindSelector';
 | 
			
		||||
import { RoomType, StateEvent } from '../../../types/matrix/room';
 | 
			
		||||
import { getViaServers } from '../../plugins/via-servers';
 | 
			
		||||
import { getMxIdServer } from '../../utils/matrix';
 | 
			
		||||
 | 
			
		||||
export const createRoomCreationContent = (
 | 
			
		||||
  type: RoomType | undefined,
 | 
			
		||||
  allowFederation: boolean
 | 
			
		||||
): object => {
 | 
			
		||||
  const content: Record<string, any> = {};
 | 
			
		||||
  if (typeof type === 'string') {
 | 
			
		||||
    content.type = type;
 | 
			
		||||
  }
 | 
			
		||||
  if (allowFederation === false) {
 | 
			
		||||
    content['m.federate'] = false;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return content;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const createRoomJoinRulesState = (
 | 
			
		||||
  kind: CreateRoomKind,
 | 
			
		||||
  parent: Room | undefined,
 | 
			
		||||
  knock: boolean
 | 
			
		||||
) => {
 | 
			
		||||
  let content: RoomJoinRulesEventContent = {
 | 
			
		||||
    join_rule: knock ? JoinRule.Knock : JoinRule.Invite,
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  if (kind === CreateRoomKind.Public) {
 | 
			
		||||
    content = {
 | 
			
		||||
      join_rule: JoinRule.Public,
 | 
			
		||||
    };
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (kind === CreateRoomKind.Restricted && parent) {
 | 
			
		||||
    content = {
 | 
			
		||||
      join_rule: knock ? ('knock_restricted' as JoinRule) : JoinRule.Restricted,
 | 
			
		||||
      allow: [
 | 
			
		||||
        {
 | 
			
		||||
          type: RestrictedAllowType.RoomMembership,
 | 
			
		||||
          room_id: parent.roomId,
 | 
			
		||||
        },
 | 
			
		||||
      ],
 | 
			
		||||
    };
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return {
 | 
			
		||||
    type: StateEvent.RoomJoinRules,
 | 
			
		||||
    state_key: '',
 | 
			
		||||
    content,
 | 
			
		||||
  };
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const createRoomParentState = (parent: Room) => ({
 | 
			
		||||
  type: StateEvent.SpaceParent,
 | 
			
		||||
  state_key: parent.roomId,
 | 
			
		||||
  content: {
 | 
			
		||||
    canonical: true,
 | 
			
		||||
    via: getViaServers(parent),
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const createRoomEncryptionState = () => ({
 | 
			
		||||
  type: 'm.room.encryption',
 | 
			
		||||
  state_key: '',
 | 
			
		||||
  content: {
 | 
			
		||||
    algorithm: 'm.megolm.v1.aes-sha2',
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export type CreateRoomData = {
 | 
			
		||||
  version: string;
 | 
			
		||||
  type?: RoomType;
 | 
			
		||||
  parent?: Room;
 | 
			
		||||
  kind: CreateRoomKind;
 | 
			
		||||
  name: string;
 | 
			
		||||
  topic?: string;
 | 
			
		||||
  aliasLocalPart?: string;
 | 
			
		||||
  encryption?: boolean;
 | 
			
		||||
  knock: boolean;
 | 
			
		||||
  allowFederation: boolean;
 | 
			
		||||
};
 | 
			
		||||
export const createRoom = async (mx: MatrixClient, data: CreateRoomData): Promise<string> => {
 | 
			
		||||
  const initialState: ICreateRoomStateEvent[] = [];
 | 
			
		||||
 | 
			
		||||
  if (data.encryption) {
 | 
			
		||||
    initialState.push(createRoomEncryptionState());
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (data.parent) {
 | 
			
		||||
    initialState.push(createRoomParentState(data.parent));
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  initialState.push(createRoomJoinRulesState(data.kind, data.parent, data.knock));
 | 
			
		||||
 | 
			
		||||
  const options: ICreateRoomOpts = {
 | 
			
		||||
    room_version: data.version,
 | 
			
		||||
    name: data.name,
 | 
			
		||||
    topic: data.topic,
 | 
			
		||||
    room_alias_name: data.aliasLocalPart,
 | 
			
		||||
    creation_content: createRoomCreationContent(data.type, data.allowFederation),
 | 
			
		||||
    initial_state: initialState,
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const result = await mx.createRoom(options);
 | 
			
		||||
 | 
			
		||||
  if (data.parent) {
 | 
			
		||||
    await mx.sendStateEvent(
 | 
			
		||||
      data.parent.roomId,
 | 
			
		||||
      StateEvent.SpaceChild as any,
 | 
			
		||||
      {
 | 
			
		||||
        auto_join: false,
 | 
			
		||||
        suggested: false,
 | 
			
		||||
        via: [getMxIdServer(mx.getUserId() ?? '') ?? ''],
 | 
			
		||||
      },
 | 
			
		||||
      result.room_id
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return result.room_id;
 | 
			
		||||
};
 | 
			
		||||
| 
						 | 
				
			
			@ -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}
 | 
			
		||||
    />
 | 
			
		||||
  )
 | 
			
		||||
);
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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>;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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();
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										277
									
								
								src/app/features/create-room/CreateRoom.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										277
									
								
								src/app/features/create-room/CreateRoom.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,277 @@
 | 
			
		|||
import React, { FormEventHandler, useCallback, useState } from 'react';
 | 
			
		||||
import { MatrixError, Room } from 'matrix-js-sdk';
 | 
			
		||||
import {
 | 
			
		||||
  Box,
 | 
			
		||||
  Button,
 | 
			
		||||
  Chip,
 | 
			
		||||
  color,
 | 
			
		||||
  config,
 | 
			
		||||
  Icon,
 | 
			
		||||
  Icons,
 | 
			
		||||
  Input,
 | 
			
		||||
  Spinner,
 | 
			
		||||
  Switch,
 | 
			
		||||
  Text,
 | 
			
		||||
  TextArea,
 | 
			
		||||
} from 'folds';
 | 
			
		||||
import { SettingTile } from '../../components/setting-tile';
 | 
			
		||||
import { SequenceCard } from '../../components/sequence-card';
 | 
			
		||||
import { knockRestrictedSupported, knockSupported, restrictedSupported } from '../../utils/matrix';
 | 
			
		||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
 | 
			
		||||
import { millisecondsToMinutes, replaceSpaceWithDash } from '../../utils/common';
 | 
			
		||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
 | 
			
		||||
import { useCapabilities } from '../../hooks/useCapabilities';
 | 
			
		||||
import { useAlive } from '../../hooks/useAlive';
 | 
			
		||||
import { ErrorCode } from '../../cs-errorcode';
 | 
			
		||||
import {
 | 
			
		||||
  createRoom,
 | 
			
		||||
  CreateRoomAliasInput,
 | 
			
		||||
  CreateRoomData,
 | 
			
		||||
  CreateRoomKind,
 | 
			
		||||
  CreateRoomKindSelector,
 | 
			
		||||
  RoomVersionSelector,
 | 
			
		||||
} from '../../components/create-room';
 | 
			
		||||
 | 
			
		||||
const getCreateRoomKindToIcon = (kind: CreateRoomKind) => {
 | 
			
		||||
  if (kind === CreateRoomKind.Private) return Icons.HashLock;
 | 
			
		||||
  if (kind === CreateRoomKind.Restricted) return Icons.Hash;
 | 
			
		||||
  return Icons.HashGlobe;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
type CreateRoomFormProps = {
 | 
			
		||||
  defaultKind?: CreateRoomKind;
 | 
			
		||||
  space?: Room;
 | 
			
		||||
  onCreate?: (roomId: string) => void;
 | 
			
		||||
};
 | 
			
		||||
export function CreateRoomForm({ defaultKind, space, onCreate }: CreateRoomFormProps) {
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
  const alive = useAlive();
 | 
			
		||||
 | 
			
		||||
  const capabilities = useCapabilities();
 | 
			
		||||
  const roomVersions = capabilities['m.room_versions'];
 | 
			
		||||
  const [selectedRoomVersion, selectRoomVersion] = useState(roomVersions?.default ?? '1');
 | 
			
		||||
 | 
			
		||||
  const allowRestricted = space && restrictedSupported(selectedRoomVersion);
 | 
			
		||||
 | 
			
		||||
  const [kind, setKind] = useState(
 | 
			
		||||
    defaultKind ?? allowRestricted ? CreateRoomKind.Restricted : CreateRoomKind.Private
 | 
			
		||||
  );
 | 
			
		||||
  const [federation, setFederation] = useState(true);
 | 
			
		||||
  const [encryption, setEncryption] = useState(false);
 | 
			
		||||
  const [knock, setKnock] = useState(false);
 | 
			
		||||
  const [advance, setAdvance] = useState(false);
 | 
			
		||||
 | 
			
		||||
  const allowKnock = kind === CreateRoomKind.Private && knockSupported(selectedRoomVersion);
 | 
			
		||||
  const allowKnockRestricted =
 | 
			
		||||
    kind === CreateRoomKind.Restricted && knockRestrictedSupported(selectedRoomVersion);
 | 
			
		||||
 | 
			
		||||
  const handleRoomVersionChange = (version: string) => {
 | 
			
		||||
    if (!restrictedSupported(version)) {
 | 
			
		||||
      setKind(CreateRoomKind.Private);
 | 
			
		||||
    }
 | 
			
		||||
    selectRoomVersion(version);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const [createState, create] = useAsyncCallback<string, Error | MatrixError, [CreateRoomData]>(
 | 
			
		||||
    useCallback((data) => createRoom(mx, data), [mx])
 | 
			
		||||
  );
 | 
			
		||||
  const loading = createState.status === AsyncStatus.Loading;
 | 
			
		||||
  const error = createState.status === AsyncStatus.Error ? createState.error : undefined;
 | 
			
		||||
  const disabled = createState.status === AsyncStatus.Loading;
 | 
			
		||||
 | 
			
		||||
  const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
 | 
			
		||||
    evt.preventDefault();
 | 
			
		||||
    if (disabled) return;
 | 
			
		||||
    const form = evt.currentTarget;
 | 
			
		||||
 | 
			
		||||
    const nameInput = form.nameInput as HTMLInputElement | undefined;
 | 
			
		||||
    const topicTextArea = form.topicTextAria as HTMLTextAreaElement | undefined;
 | 
			
		||||
    const aliasInput = form.aliasInput as HTMLInputElement | undefined;
 | 
			
		||||
    const roomName = nameInput?.value.trim();
 | 
			
		||||
    const roomTopic = topicTextArea?.value.trim();
 | 
			
		||||
    const aliasLocalPart =
 | 
			
		||||
      aliasInput && aliasInput.value ? replaceSpaceWithDash(aliasInput.value) : undefined;
 | 
			
		||||
 | 
			
		||||
    if (!roomName) return;
 | 
			
		||||
    const publicRoom = kind === CreateRoomKind.Public;
 | 
			
		||||
    let roomKnock = false;
 | 
			
		||||
    if (allowKnock && kind === CreateRoomKind.Private) {
 | 
			
		||||
      roomKnock = knock;
 | 
			
		||||
    }
 | 
			
		||||
    if (allowKnockRestricted && kind === CreateRoomKind.Restricted) {
 | 
			
		||||
      roomKnock = knock;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    create({
 | 
			
		||||
      version: selectedRoomVersion,
 | 
			
		||||
      parent: space,
 | 
			
		||||
      kind,
 | 
			
		||||
      name: roomName,
 | 
			
		||||
      topic: roomTopic || undefined,
 | 
			
		||||
      aliasLocalPart: publicRoom ? aliasLocalPart : undefined,
 | 
			
		||||
      encryption: publicRoom ? false : encryption,
 | 
			
		||||
      knock: roomKnock,
 | 
			
		||||
      allowFederation: federation,
 | 
			
		||||
    }).then((roomId) => {
 | 
			
		||||
      if (alive()) {
 | 
			
		||||
        onCreate?.(roomId);
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Box as="form" onSubmit={handleSubmit} grow="Yes" direction="Column" gap="500">
 | 
			
		||||
      <Box direction="Column" gap="100">
 | 
			
		||||
        <Text size="L400">Access</Text>
 | 
			
		||||
        <CreateRoomKindSelector
 | 
			
		||||
          value={kind}
 | 
			
		||||
          onSelect={setKind}
 | 
			
		||||
          canRestrict={allowRestricted}
 | 
			
		||||
          disabled={disabled}
 | 
			
		||||
          getIcon={getCreateRoomKindToIcon}
 | 
			
		||||
        />
 | 
			
		||||
      </Box>
 | 
			
		||||
      <Box shrink="No" direction="Column" gap="100">
 | 
			
		||||
        <Text size="L400">Name</Text>
 | 
			
		||||
        <Input
 | 
			
		||||
          required
 | 
			
		||||
          before={<Icon size="100" src={getCreateRoomKindToIcon(kind)} />}
 | 
			
		||||
          name="nameInput"
 | 
			
		||||
          autoFocus
 | 
			
		||||
          size="500"
 | 
			
		||||
          variant="SurfaceVariant"
 | 
			
		||||
          radii="400"
 | 
			
		||||
          autoComplete="off"
 | 
			
		||||
          disabled={disabled}
 | 
			
		||||
        />
 | 
			
		||||
      </Box>
 | 
			
		||||
      <Box shrink="No" direction="Column" gap="100">
 | 
			
		||||
        <Text size="L400">Topic (Optional)</Text>
 | 
			
		||||
        <TextArea
 | 
			
		||||
          name="topicTextAria"
 | 
			
		||||
          size="500"
 | 
			
		||||
          variant="SurfaceVariant"
 | 
			
		||||
          radii="400"
 | 
			
		||||
          disabled={disabled}
 | 
			
		||||
        />
 | 
			
		||||
      </Box>
 | 
			
		||||
 | 
			
		||||
      {kind === CreateRoomKind.Public && <CreateRoomAliasInput disabled={disabled} />}
 | 
			
		||||
 | 
			
		||||
      <Box shrink="No" direction="Column" gap="100">
 | 
			
		||||
        <Box gap="200" alignItems="End">
 | 
			
		||||
          <Text size="L400">Options</Text>
 | 
			
		||||
          <Box grow="Yes" justifyContent="End">
 | 
			
		||||
            <Chip
 | 
			
		||||
              radii="Pill"
 | 
			
		||||
              before={<Icon src={advance ? Icons.ChevronTop : Icons.ChevronBottom} size="50" />}
 | 
			
		||||
              onClick={() => setAdvance(!advance)}
 | 
			
		||||
              type="button"
 | 
			
		||||
            >
 | 
			
		||||
              <Text size="T200">Advance Options</Text>
 | 
			
		||||
            </Chip>
 | 
			
		||||
          </Box>
 | 
			
		||||
        </Box>
 | 
			
		||||
        {kind !== CreateRoomKind.Public && (
 | 
			
		||||
          <>
 | 
			
		||||
            <SequenceCard
 | 
			
		||||
              style={{ padding: config.space.S300 }}
 | 
			
		||||
              variant="SurfaceVariant"
 | 
			
		||||
              direction="Column"
 | 
			
		||||
              gap="500"
 | 
			
		||||
            >
 | 
			
		||||
              <SettingTile
 | 
			
		||||
                title="End-to-End Encryption"
 | 
			
		||||
                description="Once this feature is enabled, it can't be disabled after the room is created."
 | 
			
		||||
                after={
 | 
			
		||||
                  <Switch
 | 
			
		||||
                    variant="Primary"
 | 
			
		||||
                    value={encryption}
 | 
			
		||||
                    onChange={setEncryption}
 | 
			
		||||
                    disabled={disabled}
 | 
			
		||||
                  />
 | 
			
		||||
                }
 | 
			
		||||
              />
 | 
			
		||||
            </SequenceCard>
 | 
			
		||||
            {advance && (allowKnock || allowKnockRestricted) && (
 | 
			
		||||
              <SequenceCard
 | 
			
		||||
                style={{ padding: config.space.S300 }}
 | 
			
		||||
                variant="SurfaceVariant"
 | 
			
		||||
                direction="Column"
 | 
			
		||||
                gap="500"
 | 
			
		||||
              >
 | 
			
		||||
                <SettingTile
 | 
			
		||||
                  title="Knock to Join"
 | 
			
		||||
                  description="Anyone can send request to join this room."
 | 
			
		||||
                  after={
 | 
			
		||||
                    <Switch
 | 
			
		||||
                      variant="Primary"
 | 
			
		||||
                      value={knock}
 | 
			
		||||
                      onChange={setKnock}
 | 
			
		||||
                      disabled={disabled}
 | 
			
		||||
                    />
 | 
			
		||||
                  }
 | 
			
		||||
                />
 | 
			
		||||
              </SequenceCard>
 | 
			
		||||
            )}
 | 
			
		||||
          </>
 | 
			
		||||
        )}
 | 
			
		||||
 | 
			
		||||
        <SequenceCard
 | 
			
		||||
          style={{ padding: config.space.S300 }}
 | 
			
		||||
          variant="SurfaceVariant"
 | 
			
		||||
          direction="Column"
 | 
			
		||||
          gap="500"
 | 
			
		||||
        >
 | 
			
		||||
          <SettingTile
 | 
			
		||||
            title="Allow Federation"
 | 
			
		||||
            description="Users from other servers can join."
 | 
			
		||||
            after={
 | 
			
		||||
              <Switch
 | 
			
		||||
                variant="Primary"
 | 
			
		||||
                value={federation}
 | 
			
		||||
                onChange={setFederation}
 | 
			
		||||
                disabled={disabled}
 | 
			
		||||
              />
 | 
			
		||||
            }
 | 
			
		||||
          />
 | 
			
		||||
        </SequenceCard>
 | 
			
		||||
        {advance && (
 | 
			
		||||
          <RoomVersionSelector
 | 
			
		||||
            versions={roomVersions?.available ? Object.keys(roomVersions.available) : ['1']}
 | 
			
		||||
            value={selectedRoomVersion}
 | 
			
		||||
            onChange={handleRoomVersionChange}
 | 
			
		||||
            disabled={disabled}
 | 
			
		||||
          />
 | 
			
		||||
        )}
 | 
			
		||||
      </Box>
 | 
			
		||||
 | 
			
		||||
      {error && (
 | 
			
		||||
        <Box style={{ color: color.Critical.Main }} alignItems="Center" gap="200">
 | 
			
		||||
          <Icon src={Icons.Warning} filled size="100" />
 | 
			
		||||
          <Text size="T300" style={{ color: color.Critical.Main }}>
 | 
			
		||||
            <b>
 | 
			
		||||
              {error instanceof MatrixError && error.name === ErrorCode.M_LIMIT_EXCEEDED
 | 
			
		||||
                ? `Server rate-limited your request for ${millisecondsToMinutes(
 | 
			
		||||
                    (error.data.retry_after_ms as number | undefined) ?? 0
 | 
			
		||||
                  )} minutes!`
 | 
			
		||||
                : error.message}
 | 
			
		||||
            </b>
 | 
			
		||||
          </Text>
 | 
			
		||||
        </Box>
 | 
			
		||||
      )}
 | 
			
		||||
      <Box shrink="No" direction="Column" gap="200">
 | 
			
		||||
        <Button
 | 
			
		||||
          type="submit"
 | 
			
		||||
          size="500"
 | 
			
		||||
          variant="Primary"
 | 
			
		||||
          radii="400"
 | 
			
		||||
          disabled={disabled}
 | 
			
		||||
          before={loading && <Spinner variant="Primary" fill="Solid" size="200" />}
 | 
			
		||||
        >
 | 
			
		||||
          <Text size="B500">Create</Text>
 | 
			
		||||
        </Button>
 | 
			
		||||
      </Box>
 | 
			
		||||
    </Box>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										95
									
								
								src/app/features/create-room/CreateRoomModal.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										95
									
								
								src/app/features/create-room/CreateRoomModal.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,95 @@
 | 
			
		|||
import React from 'react';
 | 
			
		||||
import {
 | 
			
		||||
  Box,
 | 
			
		||||
  config,
 | 
			
		||||
  Header,
 | 
			
		||||
  Icon,
 | 
			
		||||
  IconButton,
 | 
			
		||||
  Icons,
 | 
			
		||||
  Modal,
 | 
			
		||||
  Overlay,
 | 
			
		||||
  OverlayBackdrop,
 | 
			
		||||
  OverlayCenter,
 | 
			
		||||
  Scroll,
 | 
			
		||||
  Text,
 | 
			
		||||
} from 'folds';
 | 
			
		||||
import FocusTrap from 'focus-trap-react';
 | 
			
		||||
import { useAllJoinedRoomsSet, useGetRoom } from '../../hooks/useGetRoom';
 | 
			
		||||
import { SpaceProvider } from '../../hooks/useSpace';
 | 
			
		||||
import { CreateRoomForm } from './CreateRoom';
 | 
			
		||||
import {
 | 
			
		||||
  useCloseCreateRoomModal,
 | 
			
		||||
  useCreateRoomModalState,
 | 
			
		||||
} from '../../state/hooks/createRoomModal';
 | 
			
		||||
import { CreateRoomModalState } from '../../state/createRoomModal';
 | 
			
		||||
import { stopPropagation } from '../../utils/keyboard';
 | 
			
		||||
 | 
			
		||||
type CreateRoomModalProps = {
 | 
			
		||||
  state: CreateRoomModalState;
 | 
			
		||||
};
 | 
			
		||||
function CreateRoomModal({ state }: CreateRoomModalProps) {
 | 
			
		||||
  const { spaceId } = state;
 | 
			
		||||
  const closeDialog = useCloseCreateRoomModal();
 | 
			
		||||
 | 
			
		||||
  const allJoinedRooms = useAllJoinedRoomsSet();
 | 
			
		||||
  const getRoom = useGetRoom(allJoinedRooms);
 | 
			
		||||
  const space = spaceId ? getRoom(spaceId) : undefined;
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <SpaceProvider value={space ?? null}>
 | 
			
		||||
      <Overlay open backdrop={<OverlayBackdrop />}>
 | 
			
		||||
        <OverlayCenter>
 | 
			
		||||
          <FocusTrap
 | 
			
		||||
            focusTrapOptions={{
 | 
			
		||||
              initialFocus: false,
 | 
			
		||||
              clickOutsideDeactivates: true,
 | 
			
		||||
              onDeactivate: closeDialog,
 | 
			
		||||
              escapeDeactivates: stopPropagation,
 | 
			
		||||
            }}
 | 
			
		||||
          >
 | 
			
		||||
            <Modal size="300" flexHeight>
 | 
			
		||||
              <Box direction="Column">
 | 
			
		||||
                <Header
 | 
			
		||||
                  size="500"
 | 
			
		||||
                  style={{
 | 
			
		||||
                    padding: config.space.S200,
 | 
			
		||||
                    paddingLeft: config.space.S400,
 | 
			
		||||
                    borderBottomWidth: config.borderWidth.B300,
 | 
			
		||||
                  }}
 | 
			
		||||
                >
 | 
			
		||||
                  <Box grow="Yes">
 | 
			
		||||
                    <Text size="H4">New Room</Text>
 | 
			
		||||
                  </Box>
 | 
			
		||||
                  <Box shrink="No">
 | 
			
		||||
                    <IconButton size="300" radii="300" onClick={closeDialog}>
 | 
			
		||||
                      <Icon src={Icons.Cross} />
 | 
			
		||||
                    </IconButton>
 | 
			
		||||
                  </Box>
 | 
			
		||||
                </Header>
 | 
			
		||||
                <Scroll size="300" hideTrack>
 | 
			
		||||
                  <Box
 | 
			
		||||
                    style={{
 | 
			
		||||
                      padding: config.space.S400,
 | 
			
		||||
                      paddingRight: config.space.S200,
 | 
			
		||||
                    }}
 | 
			
		||||
                    direction="Column"
 | 
			
		||||
                    gap="500"
 | 
			
		||||
                  >
 | 
			
		||||
                    <CreateRoomForm space={space} onCreate={closeDialog} />
 | 
			
		||||
                  </Box>
 | 
			
		||||
                </Scroll>
 | 
			
		||||
              </Box>
 | 
			
		||||
            </Modal>
 | 
			
		||||
          </FocusTrap>
 | 
			
		||||
        </OverlayCenter>
 | 
			
		||||
      </Overlay>
 | 
			
		||||
    </SpaceProvider>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function CreateRoomModalRenderer() {
 | 
			
		||||
  const state = useCreateRoomModalState();
 | 
			
		||||
 | 
			
		||||
  if (!state) return null;
 | 
			
		||||
  return <CreateRoomModal state={state} />;
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										2
									
								
								src/app/features/create-room/index.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										2
									
								
								src/app/features/create-room/index.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,2 @@
 | 
			
		|||
export * from './CreateRoom';
 | 
			
		||||
export * from './CreateRoomModal';
 | 
			
		||||
							
								
								
									
										249
									
								
								src/app/features/create-space/CreateSpace.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										249
									
								
								src/app/features/create-space/CreateSpace.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,249 @@
 | 
			
		|||
import React, { FormEventHandler, useCallback, useState } from 'react';
 | 
			
		||||
import { MatrixError, Room } from 'matrix-js-sdk';
 | 
			
		||||
import {
 | 
			
		||||
  Box,
 | 
			
		||||
  Button,
 | 
			
		||||
  Chip,
 | 
			
		||||
  color,
 | 
			
		||||
  config,
 | 
			
		||||
  Icon,
 | 
			
		||||
  Icons,
 | 
			
		||||
  Input,
 | 
			
		||||
  Spinner,
 | 
			
		||||
  Switch,
 | 
			
		||||
  Text,
 | 
			
		||||
  TextArea,
 | 
			
		||||
} from 'folds';
 | 
			
		||||
import { SettingTile } from '../../components/setting-tile';
 | 
			
		||||
import { SequenceCard } from '../../components/sequence-card';
 | 
			
		||||
import { knockRestrictedSupported, knockSupported, restrictedSupported } from '../../utils/matrix';
 | 
			
		||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
 | 
			
		||||
import { millisecondsToMinutes, replaceSpaceWithDash } from '../../utils/common';
 | 
			
		||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
 | 
			
		||||
import { useCapabilities } from '../../hooks/useCapabilities';
 | 
			
		||||
import { useAlive } from '../../hooks/useAlive';
 | 
			
		||||
import { ErrorCode } from '../../cs-errorcode';
 | 
			
		||||
import {
 | 
			
		||||
  createRoom,
 | 
			
		||||
  CreateRoomAliasInput,
 | 
			
		||||
  CreateRoomData,
 | 
			
		||||
  CreateRoomKind,
 | 
			
		||||
  CreateRoomKindSelector,
 | 
			
		||||
  RoomVersionSelector,
 | 
			
		||||
} from '../../components/create-room';
 | 
			
		||||
import { RoomType } from '../../../types/matrix/room';
 | 
			
		||||
 | 
			
		||||
const getCreateSpaceKindToIcon = (kind: CreateRoomKind) => {
 | 
			
		||||
  if (kind === CreateRoomKind.Private) return Icons.SpaceLock;
 | 
			
		||||
  if (kind === CreateRoomKind.Restricted) return Icons.Space;
 | 
			
		||||
  return Icons.SpaceGlobe;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
type CreateSpaceFormProps = {
 | 
			
		||||
  defaultKind?: CreateRoomKind;
 | 
			
		||||
  space?: Room;
 | 
			
		||||
  onCreate?: (roomId: string) => void;
 | 
			
		||||
};
 | 
			
		||||
export function CreateSpaceForm({ defaultKind, space, onCreate }: CreateSpaceFormProps) {
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
  const alive = useAlive();
 | 
			
		||||
 | 
			
		||||
  const capabilities = useCapabilities();
 | 
			
		||||
  const roomVersions = capabilities['m.room_versions'];
 | 
			
		||||
  const [selectedRoomVersion, selectRoomVersion] = useState(roomVersions?.default ?? '1');
 | 
			
		||||
 | 
			
		||||
  const allowRestricted = space && restrictedSupported(selectedRoomVersion);
 | 
			
		||||
 | 
			
		||||
  const [kind, setKind] = useState(
 | 
			
		||||
    defaultKind ?? allowRestricted ? CreateRoomKind.Restricted : CreateRoomKind.Private
 | 
			
		||||
  );
 | 
			
		||||
  const [federation, setFederation] = useState(true);
 | 
			
		||||
  const [knock, setKnock] = useState(false);
 | 
			
		||||
  const [advance, setAdvance] = useState(false);
 | 
			
		||||
 | 
			
		||||
  const allowKnock = kind === CreateRoomKind.Private && knockSupported(selectedRoomVersion);
 | 
			
		||||
  const allowKnockRestricted =
 | 
			
		||||
    kind === CreateRoomKind.Restricted && knockRestrictedSupported(selectedRoomVersion);
 | 
			
		||||
 | 
			
		||||
  const handleRoomVersionChange = (version: string) => {
 | 
			
		||||
    if (!restrictedSupported(version)) {
 | 
			
		||||
      setKind(CreateRoomKind.Private);
 | 
			
		||||
    }
 | 
			
		||||
    selectRoomVersion(version);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const [createState, create] = useAsyncCallback<string, Error | MatrixError, [CreateRoomData]>(
 | 
			
		||||
    useCallback((data) => createRoom(mx, data), [mx])
 | 
			
		||||
  );
 | 
			
		||||
  const loading = createState.status === AsyncStatus.Loading;
 | 
			
		||||
  const error = createState.status === AsyncStatus.Error ? createState.error : undefined;
 | 
			
		||||
  const disabled = createState.status === AsyncStatus.Loading;
 | 
			
		||||
 | 
			
		||||
  const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
 | 
			
		||||
    evt.preventDefault();
 | 
			
		||||
    if (disabled) return;
 | 
			
		||||
    const form = evt.currentTarget;
 | 
			
		||||
 | 
			
		||||
    const nameInput = form.nameInput as HTMLInputElement | undefined;
 | 
			
		||||
    const topicTextArea = form.topicTextAria as HTMLTextAreaElement | undefined;
 | 
			
		||||
    const aliasInput = form.aliasInput as HTMLInputElement | undefined;
 | 
			
		||||
    const roomName = nameInput?.value.trim();
 | 
			
		||||
    const roomTopic = topicTextArea?.value.trim();
 | 
			
		||||
    const aliasLocalPart =
 | 
			
		||||
      aliasInput && aliasInput.value ? replaceSpaceWithDash(aliasInput.value) : undefined;
 | 
			
		||||
 | 
			
		||||
    if (!roomName) return;
 | 
			
		||||
    const publicRoom = kind === CreateRoomKind.Public;
 | 
			
		||||
    let roomKnock = false;
 | 
			
		||||
    if (allowKnock && kind === CreateRoomKind.Private) {
 | 
			
		||||
      roomKnock = knock;
 | 
			
		||||
    }
 | 
			
		||||
    if (allowKnockRestricted && kind === CreateRoomKind.Restricted) {
 | 
			
		||||
      roomKnock = knock;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    create({
 | 
			
		||||
      version: selectedRoomVersion,
 | 
			
		||||
      type: RoomType.Space,
 | 
			
		||||
      parent: space,
 | 
			
		||||
      kind,
 | 
			
		||||
      name: roomName,
 | 
			
		||||
      topic: roomTopic || undefined,
 | 
			
		||||
      aliasLocalPart: publicRoom ? aliasLocalPart : undefined,
 | 
			
		||||
      knock: roomKnock,
 | 
			
		||||
      allowFederation: federation,
 | 
			
		||||
    }).then((roomId) => {
 | 
			
		||||
      if (alive()) {
 | 
			
		||||
        onCreate?.(roomId);
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Box as="form" onSubmit={handleSubmit} grow="Yes" direction="Column" gap="500">
 | 
			
		||||
      <Box direction="Column" gap="100">
 | 
			
		||||
        <Text size="L400">Access</Text>
 | 
			
		||||
        <CreateRoomKindSelector
 | 
			
		||||
          value={kind}
 | 
			
		||||
          onSelect={setKind}
 | 
			
		||||
          canRestrict={allowRestricted}
 | 
			
		||||
          disabled={disabled}
 | 
			
		||||
          getIcon={getCreateSpaceKindToIcon}
 | 
			
		||||
        />
 | 
			
		||||
      </Box>
 | 
			
		||||
      <Box shrink="No" direction="Column" gap="100">
 | 
			
		||||
        <Text size="L400">Name</Text>
 | 
			
		||||
        <Input
 | 
			
		||||
          required
 | 
			
		||||
          before={<Icon size="100" src={getCreateSpaceKindToIcon(kind)} />}
 | 
			
		||||
          name="nameInput"
 | 
			
		||||
          autoFocus
 | 
			
		||||
          size="500"
 | 
			
		||||
          variant="SurfaceVariant"
 | 
			
		||||
          radii="400"
 | 
			
		||||
          autoComplete="off"
 | 
			
		||||
          disabled={disabled}
 | 
			
		||||
        />
 | 
			
		||||
      </Box>
 | 
			
		||||
      <Box shrink="No" direction="Column" gap="100">
 | 
			
		||||
        <Text size="L400">Topic (Optional)</Text>
 | 
			
		||||
        <TextArea
 | 
			
		||||
          name="topicTextAria"
 | 
			
		||||
          size="500"
 | 
			
		||||
          variant="SurfaceVariant"
 | 
			
		||||
          radii="400"
 | 
			
		||||
          disabled={disabled}
 | 
			
		||||
        />
 | 
			
		||||
      </Box>
 | 
			
		||||
 | 
			
		||||
      {kind === CreateRoomKind.Public && <CreateRoomAliasInput disabled={disabled} />}
 | 
			
		||||
 | 
			
		||||
      <Box shrink="No" direction="Column" gap="100">
 | 
			
		||||
        <Box gap="200" alignItems="End">
 | 
			
		||||
          <Text size="L400">Options</Text>
 | 
			
		||||
          <Box grow="Yes" justifyContent="End">
 | 
			
		||||
            <Chip
 | 
			
		||||
              radii="Pill"
 | 
			
		||||
              before={<Icon src={advance ? Icons.ChevronTop : Icons.ChevronBottom} size="50" />}
 | 
			
		||||
              onClick={() => setAdvance(!advance)}
 | 
			
		||||
              type="button"
 | 
			
		||||
            >
 | 
			
		||||
              <Text size="T200">Advance Options</Text>
 | 
			
		||||
            </Chip>
 | 
			
		||||
          </Box>
 | 
			
		||||
        </Box>
 | 
			
		||||
        {kind !== CreateRoomKind.Public && advance && (allowKnock || allowKnockRestricted) && (
 | 
			
		||||
          <SequenceCard
 | 
			
		||||
            style={{ padding: config.space.S300 }}
 | 
			
		||||
            variant="SurfaceVariant"
 | 
			
		||||
            direction="Column"
 | 
			
		||||
            gap="500"
 | 
			
		||||
          >
 | 
			
		||||
            <SettingTile
 | 
			
		||||
              title="Knock to Join"
 | 
			
		||||
              description="Anyone can send request to join this space."
 | 
			
		||||
              after={
 | 
			
		||||
                <Switch variant="Primary" value={knock} onChange={setKnock} disabled={disabled} />
 | 
			
		||||
              }
 | 
			
		||||
            />
 | 
			
		||||
          </SequenceCard>
 | 
			
		||||
        )}
 | 
			
		||||
 | 
			
		||||
        <SequenceCard
 | 
			
		||||
          style={{ padding: config.space.S300 }}
 | 
			
		||||
          variant="SurfaceVariant"
 | 
			
		||||
          direction="Column"
 | 
			
		||||
          gap="500"
 | 
			
		||||
        >
 | 
			
		||||
          <SettingTile
 | 
			
		||||
            title="Allow Federation"
 | 
			
		||||
            description="Users from other servers can join."
 | 
			
		||||
            after={
 | 
			
		||||
              <Switch
 | 
			
		||||
                variant="Primary"
 | 
			
		||||
                value={federation}
 | 
			
		||||
                onChange={setFederation}
 | 
			
		||||
                disabled={disabled}
 | 
			
		||||
              />
 | 
			
		||||
            }
 | 
			
		||||
          />
 | 
			
		||||
        </SequenceCard>
 | 
			
		||||
        {advance && (
 | 
			
		||||
          <RoomVersionSelector
 | 
			
		||||
            versions={roomVersions?.available ? Object.keys(roomVersions.available) : ['1']}
 | 
			
		||||
            value={selectedRoomVersion}
 | 
			
		||||
            onChange={handleRoomVersionChange}
 | 
			
		||||
            disabled={disabled}
 | 
			
		||||
          />
 | 
			
		||||
        )}
 | 
			
		||||
      </Box>
 | 
			
		||||
 | 
			
		||||
      {error && (
 | 
			
		||||
        <Box style={{ color: color.Critical.Main }} alignItems="Center" gap="200">
 | 
			
		||||
          <Icon src={Icons.Warning} filled size="100" />
 | 
			
		||||
          <Text size="T300" style={{ color: color.Critical.Main }}>
 | 
			
		||||
            <b>
 | 
			
		||||
              {error instanceof MatrixError && error.name === ErrorCode.M_LIMIT_EXCEEDED
 | 
			
		||||
                ? `Server rate-limited your request for ${millisecondsToMinutes(
 | 
			
		||||
                    (error.data.retry_after_ms as number | undefined) ?? 0
 | 
			
		||||
                  )} minutes!`
 | 
			
		||||
                : error.message}
 | 
			
		||||
            </b>
 | 
			
		||||
          </Text>
 | 
			
		||||
        </Box>
 | 
			
		||||
      )}
 | 
			
		||||
      <Box shrink="No" direction="Column" gap="200">
 | 
			
		||||
        <Button
 | 
			
		||||
          type="submit"
 | 
			
		||||
          size="500"
 | 
			
		||||
          variant="Primary"
 | 
			
		||||
          radii="400"
 | 
			
		||||
          disabled={disabled}
 | 
			
		||||
          before={loading && <Spinner variant="Primary" fill="Solid" size="200" />}
 | 
			
		||||
        >
 | 
			
		||||
          <Text size="B500">Create</Text>
 | 
			
		||||
        </Button>
 | 
			
		||||
      </Box>
 | 
			
		||||
    </Box>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										95
									
								
								src/app/features/create-space/CreateSpaceModal.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										95
									
								
								src/app/features/create-space/CreateSpaceModal.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,95 @@
 | 
			
		|||
import React from 'react';
 | 
			
		||||
import {
 | 
			
		||||
  Box,
 | 
			
		||||
  config,
 | 
			
		||||
  Header,
 | 
			
		||||
  Icon,
 | 
			
		||||
  IconButton,
 | 
			
		||||
  Icons,
 | 
			
		||||
  Modal,
 | 
			
		||||
  Overlay,
 | 
			
		||||
  OverlayBackdrop,
 | 
			
		||||
  OverlayCenter,
 | 
			
		||||
  Scroll,
 | 
			
		||||
  Text,
 | 
			
		||||
} from 'folds';
 | 
			
		||||
import FocusTrap from 'focus-trap-react';
 | 
			
		||||
import { useAllJoinedRoomsSet, useGetRoom } from '../../hooks/useGetRoom';
 | 
			
		||||
import { SpaceProvider } from '../../hooks/useSpace';
 | 
			
		||||
import { CreateSpaceForm } from './CreateSpace';
 | 
			
		||||
import {
 | 
			
		||||
  useCloseCreateSpaceModal,
 | 
			
		||||
  useCreateSpaceModalState,
 | 
			
		||||
} from '../../state/hooks/createSpaceModal';
 | 
			
		||||
import { CreateSpaceModalState } from '../../state/createSpaceModal';
 | 
			
		||||
import { stopPropagation } from '../../utils/keyboard';
 | 
			
		||||
 | 
			
		||||
type CreateSpaceModalProps = {
 | 
			
		||||
  state: CreateSpaceModalState;
 | 
			
		||||
};
 | 
			
		||||
function CreateSpaceModal({ state }: CreateSpaceModalProps) {
 | 
			
		||||
  const { spaceId } = state;
 | 
			
		||||
  const closeDialog = useCloseCreateSpaceModal();
 | 
			
		||||
 | 
			
		||||
  const allJoinedRooms = useAllJoinedRoomsSet();
 | 
			
		||||
  const getRoom = useGetRoom(allJoinedRooms);
 | 
			
		||||
  const space = spaceId ? getRoom(spaceId) : undefined;
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <SpaceProvider value={space ?? null}>
 | 
			
		||||
      <Overlay open backdrop={<OverlayBackdrop />}>
 | 
			
		||||
        <OverlayCenter>
 | 
			
		||||
          <FocusTrap
 | 
			
		||||
            focusTrapOptions={{
 | 
			
		||||
              initialFocus: false,
 | 
			
		||||
              clickOutsideDeactivates: true,
 | 
			
		||||
              onDeactivate: closeDialog,
 | 
			
		||||
              escapeDeactivates: stopPropagation,
 | 
			
		||||
            }}
 | 
			
		||||
          >
 | 
			
		||||
            <Modal size="300" flexHeight>
 | 
			
		||||
              <Box direction="Column">
 | 
			
		||||
                <Header
 | 
			
		||||
                  size="500"
 | 
			
		||||
                  style={{
 | 
			
		||||
                    padding: config.space.S200,
 | 
			
		||||
                    paddingLeft: config.space.S400,
 | 
			
		||||
                    borderBottomWidth: config.borderWidth.B300,
 | 
			
		||||
                  }}
 | 
			
		||||
                >
 | 
			
		||||
                  <Box grow="Yes">
 | 
			
		||||
                    <Text size="H4">New Space</Text>
 | 
			
		||||
                  </Box>
 | 
			
		||||
                  <Box shrink="No">
 | 
			
		||||
                    <IconButton size="300" radii="300" onClick={closeDialog}>
 | 
			
		||||
                      <Icon src={Icons.Cross} />
 | 
			
		||||
                    </IconButton>
 | 
			
		||||
                  </Box>
 | 
			
		||||
                </Header>
 | 
			
		||||
                <Scroll size="300" hideTrack>
 | 
			
		||||
                  <Box
 | 
			
		||||
                    style={{
 | 
			
		||||
                      padding: config.space.S400,
 | 
			
		||||
                      paddingRight: config.space.S200,
 | 
			
		||||
                    }}
 | 
			
		||||
                    direction="Column"
 | 
			
		||||
                    gap="500"
 | 
			
		||||
                  >
 | 
			
		||||
                    <CreateSpaceForm space={space} onCreate={closeDialog} />
 | 
			
		||||
                  </Box>
 | 
			
		||||
                </Scroll>
 | 
			
		||||
              </Box>
 | 
			
		||||
            </Modal>
 | 
			
		||||
          </FocusTrap>
 | 
			
		||||
        </OverlayCenter>
 | 
			
		||||
      </Overlay>
 | 
			
		||||
    </SpaceProvider>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function CreateSpaceModalRenderer() {
 | 
			
		||||
  const state = useCreateSpaceModalState();
 | 
			
		||||
 | 
			
		||||
  if (!state) return null;
 | 
			
		||||
  return <CreateSpaceModal state={state} />;
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										2
									
								
								src/app/features/create-space/index.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										2
									
								
								src/app/features/create-space/index.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,2 @@
 | 
			
		|||
export * from './CreateSpace';
 | 
			
		||||
export * from './CreateSpaceModal';
 | 
			
		||||
| 
						 | 
				
			
			@ -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]
 | 
			
		||||
    )
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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} />}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										12
									
								
								src/app/hooks/router/useCreateSelected.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								src/app/hooks/router/useCreateSelected.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,12 @@
 | 
			
		|||
import { useMatch } from 'react-router-dom';
 | 
			
		||||
import { getCreatePath } from '../../pages/pathUtils';
 | 
			
		||||
 | 
			
		||||
export const useCreateSelected = (): boolean => {
 | 
			
		||||
  const match = useMatch({
 | 
			
		||||
    path: getCreatePath(),
 | 
			
		||||
    caseSensitive: true,
 | 
			
		||||
    end: false,
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  return !!match;
 | 
			
		||||
};
 | 
			
		||||
| 
						 | 
				
			
			@ -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={
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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>
 | 
			
		||||
        }
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										38
									
								
								src/app/pages/client/create/Create.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										38
									
								
								src/app/pages/client/create/Create.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,38 @@
 | 
			
		|||
import React from 'react';
 | 
			
		||||
import { Box, Icon, Icons, Scroll } from 'folds';
 | 
			
		||||
import {
 | 
			
		||||
  Page,
 | 
			
		||||
  PageContent,
 | 
			
		||||
  PageContentCenter,
 | 
			
		||||
  PageHero,
 | 
			
		||||
  PageHeroSection,
 | 
			
		||||
} from '../../../components/page';
 | 
			
		||||
import { CreateSpaceForm } from '../../../features/create-space';
 | 
			
		||||
import { useRoomNavigate } from '../../../hooks/useRoomNavigate';
 | 
			
		||||
 | 
			
		||||
export function Create() {
 | 
			
		||||
  const { navigateSpace } = useRoomNavigate();
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Page>
 | 
			
		||||
      <Box grow="Yes">
 | 
			
		||||
        <Scroll hideTrack visibility="Hover">
 | 
			
		||||
          <PageContent>
 | 
			
		||||
            <PageContentCenter>
 | 
			
		||||
              <PageHeroSection>
 | 
			
		||||
                <Box direction="Column" gap="700">
 | 
			
		||||
                  <PageHero
 | 
			
		||||
                    icon={<Icon size="600" src={Icons.Space} />}
 | 
			
		||||
                    title="Create Space"
 | 
			
		||||
                    subTitle="Build a space for your community."
 | 
			
		||||
                  />
 | 
			
		||||
                  <CreateSpaceForm onCreate={navigateSpace} />
 | 
			
		||||
                </Box>
 | 
			
		||||
              </PageHeroSection>
 | 
			
		||||
            </PageContentCenter>
 | 
			
		||||
          </PageContent>
 | 
			
		||||
        </Scroll>
 | 
			
		||||
      </Box>
 | 
			
		||||
    </Page>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										1
									
								
								src/app/pages/client/create/index.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								src/app/pages/client/create/index.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1 @@
 | 
			
		|||
export * from './Create';
 | 
			
		||||
							
								
								
									
										56
									
								
								src/app/pages/client/home/CreateRoom.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										56
									
								
								src/app/pages/client/home/CreateRoom.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,56 @@
 | 
			
		|||
import React from 'react';
 | 
			
		||||
import { Box, Icon, Icons, Scroll, IconButton } from 'folds';
 | 
			
		||||
import {
 | 
			
		||||
  Page,
 | 
			
		||||
  PageContent,
 | 
			
		||||
  PageContentCenter,
 | 
			
		||||
  PageHeader,
 | 
			
		||||
  PageHero,
 | 
			
		||||
  PageHeroSection,
 | 
			
		||||
} from '../../../components/page';
 | 
			
		||||
import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
 | 
			
		||||
import { BackRouteHandler } from '../../../components/BackRouteHandler';
 | 
			
		||||
import { CreateRoomForm } from '../../../features/create-room';
 | 
			
		||||
import { useRoomNavigate } from '../../../hooks/useRoomNavigate';
 | 
			
		||||
 | 
			
		||||
export function HomeCreateRoom() {
 | 
			
		||||
  const screenSize = useScreenSizeContext();
 | 
			
		||||
 | 
			
		||||
  const { navigateRoom } = useRoomNavigate();
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Page>
 | 
			
		||||
      {screenSize === ScreenSize.Mobile && (
 | 
			
		||||
        <PageHeader balance outlined={false}>
 | 
			
		||||
          <Box grow="Yes" alignItems="Center" gap="200">
 | 
			
		||||
            <BackRouteHandler>
 | 
			
		||||
              {(onBack) => (
 | 
			
		||||
                <IconButton onClick={onBack}>
 | 
			
		||||
                  <Icon src={Icons.ArrowLeft} />
 | 
			
		||||
                </IconButton>
 | 
			
		||||
              )}
 | 
			
		||||
            </BackRouteHandler>
 | 
			
		||||
          </Box>
 | 
			
		||||
        </PageHeader>
 | 
			
		||||
      )}
 | 
			
		||||
      <Box grow="Yes">
 | 
			
		||||
        <Scroll hideTrack visibility="Hover">
 | 
			
		||||
          <PageContent>
 | 
			
		||||
            <PageContentCenter>
 | 
			
		||||
              <PageHeroSection>
 | 
			
		||||
                <Box direction="Column" gap="700">
 | 
			
		||||
                  <PageHero
 | 
			
		||||
                    icon={<Icon size="600" src={Icons.Hash} />}
 | 
			
		||||
                    title="Create Room"
 | 
			
		||||
                    subTitle="Build a Room for Real-Time Conversations"
 | 
			
		||||
                  />
 | 
			
		||||
                  <CreateRoomForm onCreate={navigateRoom} />
 | 
			
		||||
                </Box>
 | 
			
		||||
              </PageHeroSection>
 | 
			
		||||
            </PageContentCenter>
 | 
			
		||||
          </PageContent>
 | 
			
		||||
        </Scroll>
 | 
			
		||||
      </Box>
 | 
			
		||||
    </Page>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -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">
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										111
									
								
								src/app/pages/client/sidebar/CreateTab.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										111
									
								
								src/app/pages/client/sidebar/CreateTab.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,111 @@
 | 
			
		|||
import React, { MouseEventHandler, useState } from 'react';
 | 
			
		||||
import { Box, config, Icon, Icons, Menu, PopOut, RectCords, Text } from 'folds';
 | 
			
		||||
import FocusTrap from 'focus-trap-react';
 | 
			
		||||
import { useNavigate } from 'react-router-dom';
 | 
			
		||||
import { SidebarAvatar, SidebarItem, SidebarItemTooltip } from '../../../components/sidebar';
 | 
			
		||||
import { stopPropagation } from '../../../utils/keyboard';
 | 
			
		||||
import { SequenceCard } from '../../../components/sequence-card';
 | 
			
		||||
import { SettingTile } from '../../../components/setting-tile';
 | 
			
		||||
import { ContainerColor } from '../../../styles/ContainerColor.css';
 | 
			
		||||
import { openJoinAlias } from '../../../../client/action/navigation';
 | 
			
		||||
import { getCreatePath } from '../../pathUtils';
 | 
			
		||||
import { useCreateSelected } from '../../../hooks/router/useCreateSelected';
 | 
			
		||||
 | 
			
		||||
export function CreateTab() {
 | 
			
		||||
  const createSelected = useCreateSelected();
 | 
			
		||||
 | 
			
		||||
  const navigate = useNavigate();
 | 
			
		||||
  const [menuCords, setMenuCords] = useState<RectCords>();
 | 
			
		||||
 | 
			
		||||
  const handleMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
 | 
			
		||||
    setMenuCords(menuCords ? undefined : evt.currentTarget.getBoundingClientRect());
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const handleCreateSpace = () => {
 | 
			
		||||
    navigate(getCreatePath());
 | 
			
		||||
    setMenuCords(undefined);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const handleJoinWithAddress = () => {
 | 
			
		||||
    openJoinAlias();
 | 
			
		||||
    setMenuCords(undefined);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <SidebarItem active={createSelected}>
 | 
			
		||||
      <SidebarItemTooltip tooltip="Add Space">
 | 
			
		||||
        {(triggerRef) => (
 | 
			
		||||
          <PopOut
 | 
			
		||||
            anchor={menuCords}
 | 
			
		||||
            position="Right"
 | 
			
		||||
            align="Center"
 | 
			
		||||
            content={
 | 
			
		||||
              <FocusTrap
 | 
			
		||||
                focusTrapOptions={{
 | 
			
		||||
                  returnFocusOnDeactivate: false,
 | 
			
		||||
                  initialFocus: false,
 | 
			
		||||
                  onDeactivate: () => setMenuCords(undefined),
 | 
			
		||||
                  clickOutsideDeactivates: true,
 | 
			
		||||
                  isKeyForward: (evt: KeyboardEvent) =>
 | 
			
		||||
                    evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
 | 
			
		||||
                  isKeyBackward: (evt: KeyboardEvent) =>
 | 
			
		||||
                    evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
 | 
			
		||||
                  escapeDeactivates: stopPropagation,
 | 
			
		||||
                }}
 | 
			
		||||
              >
 | 
			
		||||
                <Menu>
 | 
			
		||||
                  <Box direction="Column">
 | 
			
		||||
                    <SequenceCard
 | 
			
		||||
                      style={{ padding: config.space.S300 }}
 | 
			
		||||
                      variant="Surface"
 | 
			
		||||
                      direction="Column"
 | 
			
		||||
                      gap="100"
 | 
			
		||||
                      radii="0"
 | 
			
		||||
                      as="button"
 | 
			
		||||
                      type="button"
 | 
			
		||||
                      onClick={handleCreateSpace}
 | 
			
		||||
                    >
 | 
			
		||||
                      <SettingTile before={<Icon size="400" src={Icons.Space} />}>
 | 
			
		||||
                        <Text size="H6">Create Space</Text>
 | 
			
		||||
                        <Text size="T300" priority="300">
 | 
			
		||||
                          Build a space for your community.
 | 
			
		||||
                        </Text>
 | 
			
		||||
                      </SettingTile>
 | 
			
		||||
                    </SequenceCard>
 | 
			
		||||
                    <SequenceCard
 | 
			
		||||
                      style={{ padding: config.space.S300 }}
 | 
			
		||||
                      variant="Surface"
 | 
			
		||||
                      direction="Column"
 | 
			
		||||
                      gap="100"
 | 
			
		||||
                      radii="0"
 | 
			
		||||
                      as="button"
 | 
			
		||||
                      type="button"
 | 
			
		||||
                      onClick={handleJoinWithAddress}
 | 
			
		||||
                    >
 | 
			
		||||
                      <SettingTile before={<Icon size="400" src={Icons.Link} />}>
 | 
			
		||||
                        <Text size="H6">Join with Address</Text>
 | 
			
		||||
                        <Text size="T300" priority="300">
 | 
			
		||||
                          Become a part of existing community.
 | 
			
		||||
                        </Text>
 | 
			
		||||
                      </SettingTile>
 | 
			
		||||
                    </SequenceCard>
 | 
			
		||||
                  </Box>
 | 
			
		||||
                </Menu>
 | 
			
		||||
              </FocusTrap>
 | 
			
		||||
            }
 | 
			
		||||
          >
 | 
			
		||||
            <SidebarAvatar
 | 
			
		||||
              className={menuCords ? ContainerColor({ variant: 'Surface' }) : undefined}
 | 
			
		||||
              as="button"
 | 
			
		||||
              ref={triggerRef}
 | 
			
		||||
              outlined
 | 
			
		||||
              onClick={handleMenu}
 | 
			
		||||
            >
 | 
			
		||||
              <Icon src={Icons.Plus} />
 | 
			
		||||
            </SidebarAvatar>
 | 
			
		||||
          </PopOut>
 | 
			
		||||
        )}
 | 
			
		||||
      </SidebarItemTooltip>
 | 
			
		||||
    </SidebarItem>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -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;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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/';
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										7
									
								
								src/app/state/createRoomModal.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								src/app/state/createRoomModal.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,7 @@
 | 
			
		|||
import { atom } from 'jotai';
 | 
			
		||||
 | 
			
		||||
export type CreateRoomModalState = {
 | 
			
		||||
  spaceId?: string;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const createRoomModalAtom = atom<CreateRoomModalState | undefined>(undefined);
 | 
			
		||||
							
								
								
									
										7
									
								
								src/app/state/createSpaceModal.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								src/app/state/createSpaceModal.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,7 @@
 | 
			
		|||
import { atom } from 'jotai';
 | 
			
		||||
 | 
			
		||||
export type CreateSpaceModalState = {
 | 
			
		||||
  spaceId?: string;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const createSpaceModalAtom = atom<CreateSpaceModalState | undefined>(undefined);
 | 
			
		||||
							
								
								
									
										34
									
								
								src/app/state/hooks/createRoomModal.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								src/app/state/hooks/createRoomModal.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,34 @@
 | 
			
		|||
import { useCallback } from 'react';
 | 
			
		||||
import { useAtomValue, useSetAtom } from 'jotai';
 | 
			
		||||
import { createRoomModalAtom, CreateRoomModalState } from '../createRoomModal';
 | 
			
		||||
 | 
			
		||||
export const useCreateRoomModalState = (): CreateRoomModalState | undefined => {
 | 
			
		||||
  const data = useAtomValue(createRoomModalAtom);
 | 
			
		||||
 | 
			
		||||
  return data;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
type CloseCallback = () => void;
 | 
			
		||||
export const useCloseCreateRoomModal = (): CloseCallback => {
 | 
			
		||||
  const setSettings = useSetAtom(createRoomModalAtom);
 | 
			
		||||
 | 
			
		||||
  const close: CloseCallback = useCallback(() => {
 | 
			
		||||
    setSettings(undefined);
 | 
			
		||||
  }, [setSettings]);
 | 
			
		||||
 | 
			
		||||
  return close;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
type OpenCallback = (space?: string) => void;
 | 
			
		||||
export const useOpenCreateRoomModal = (): OpenCallback => {
 | 
			
		||||
  const setSettings = useSetAtom(createRoomModalAtom);
 | 
			
		||||
 | 
			
		||||
  const open: OpenCallback = useCallback(
 | 
			
		||||
    (spaceId) => {
 | 
			
		||||
      setSettings({ spaceId });
 | 
			
		||||
    },
 | 
			
		||||
    [setSettings]
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  return open;
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										34
									
								
								src/app/state/hooks/createSpaceModal.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								src/app/state/hooks/createSpaceModal.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,34 @@
 | 
			
		|||
import { useCallback } from 'react';
 | 
			
		||||
import { useAtomValue, useSetAtom } from 'jotai';
 | 
			
		||||
import { createSpaceModalAtom, CreateSpaceModalState } from '../createSpaceModal';
 | 
			
		||||
 | 
			
		||||
export const useCreateSpaceModalState = (): CreateSpaceModalState | undefined => {
 | 
			
		||||
  const data = useAtomValue(createSpaceModalAtom);
 | 
			
		||||
 | 
			
		||||
  return data;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
type CloseCallback = () => void;
 | 
			
		||||
export const useCloseCreateSpaceModal = (): CloseCallback => {
 | 
			
		||||
  const setSettings = useSetAtom(createSpaceModalAtom);
 | 
			
		||||
 | 
			
		||||
  const close: CloseCallback = useCallback(() => {
 | 
			
		||||
    setSettings(undefined);
 | 
			
		||||
  }, [setSettings]);
 | 
			
		||||
 | 
			
		||||
  return close;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
type OpenCallback = (space?: string) => void;
 | 
			
		||||
export const useOpenCreateSpaceModal = (): OpenCallback => {
 | 
			
		||||
  const setSettings = useSetAtom(createSpaceModalAtom);
 | 
			
		||||
 | 
			
		||||
  const open: OpenCallback = useCallback(
 | 
			
		||||
    (spaceId) => {
 | 
			
		||||
      setSettings({ spaceId });
 | 
			
		||||
    },
 | 
			
		||||
    [setSettings]
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  return open;
 | 
			
		||||
};
 | 
			
		||||
| 
						 | 
				
			
			@ -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({
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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);
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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);
 | 
			
		||||
};
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue