diff --git a/src/app/components/create-room/CreateRoomAliasInput.tsx b/src/app/components/create-room/CreateRoomAliasInput.tsx new file mode 100644 index 00000000..9b9a8ec5 --- /dev/null +++ b/src/app/components/create-room/CreateRoomAliasInput.tsx @@ -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(null); + const [aliasAvail, setAliasAvail] = useState>({ + 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 = (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 = (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 ( + + Address (Optional) + + Pick an unique address to make your community discoverable to public. + + + ) : ( + + ) + } + after={ + + :{getMxIdServer(mx.getSafeUserId())} + + } + onKeyDown={handleAliasKeyDown} + name="aliasInput" + size="500" + variant={aliasAvailable === true ? 'Success' : 'SurfaceVariant'} + radii="400" + autoComplete="off" + disabled={disabled} + /> + {aliasAvailable === false && ( + + + + This address is already taken. Please select a different one. + + + )} + + ); +} diff --git a/src/app/components/CreateRoomKindSelector.tsx b/src/app/components/create-room/CreateRoomKindSelector.tsx similarity index 85% rename from src/app/components/CreateRoomKindSelector.tsx rename to src/app/components/create-room/CreateRoomKindSelector.tsx index e11a11c6..096954fb 100644 --- a/src/app/components/CreateRoomKindSelector.tsx +++ b/src/app/components/create-room/CreateRoomKindSelector.tsx @@ -1,7 +1,7 @@ import React from 'react'; -import { Box, Text, Icon, Icons, config } from 'folds'; -import { SettingTile } from './setting-tile'; -import { SequenceCard } from './sequence-card'; +import { Box, Text, Icon, Icons, config, IconSrc } from 'folds'; +import { SequenceCard } from '../sequence-card'; +import { SettingTile } from '../setting-tile'; export enum CreateRoomKind { Private = 'private', @@ -13,12 +13,14 @@ type CreateRoomKindSelectorProps = { onSelect: (value: CreateRoomKind) => void; canRestrict?: boolean; disabled?: boolean; + getIcon: (kind: CreateRoomKind) => IconSrc; }; export function CreateRoomKindSelector({ value, onSelect, canRestrict, disabled, + getIcon, }: CreateRoomKindSelectorProps) { return ( @@ -35,7 +37,7 @@ export function CreateRoomKindSelector({ disabled={disabled} > } + before={} after={value === CreateRoomKind.Restricted && } > Restricted @@ -57,7 +59,7 @@ export function CreateRoomKindSelector({ disabled={disabled} > } + before={} after={value === CreateRoomKind.Private && } > Private @@ -78,7 +80,7 @@ export function CreateRoomKindSelector({ disabled={disabled} > } + before={} after={value === CreateRoomKind.Public && } > Public diff --git a/src/app/components/create-room/RoomVersionSelector.tsx b/src/app/components/create-room/RoomVersionSelector.tsx new file mode 100644 index 00000000..281f520a --- /dev/null +++ b/src/app/components/create-room/RoomVersionSelector.tsx @@ -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(); + + const handleMenu: MouseEventHandler = (evt) => { + setMenuCords(evt.currentTarget.getBoundingClientRect()); + }; + + const handleSelect = (version: string) => { + setMenuCords(undefined); + onChange(version); + }; + + return ( + + setMenuCords(undefined), + clickOutsideDeactivates: true, + isKeyForward: (evt: KeyboardEvent) => + evt.key === 'ArrowDown' || evt.key === 'ArrowRight', + isKeyBackward: (evt: KeyboardEvent) => + evt.key === 'ArrowUp' || evt.key === 'ArrowLeft', + escapeDeactivates: stopPropagation, + }} + > + + + Versions + + {versions.map((version) => ( + handleSelect(version)} + type="button" + > + + {version} + + + ))} + + + + + } + > + + + } + /> + + ); +} diff --git a/src/app/components/create-room/index.ts b/src/app/components/create-room/index.ts new file mode 100644 index 00000000..ffca558d --- /dev/null +++ b/src/app/components/create-room/index.ts @@ -0,0 +1,4 @@ +export * from './CreateRoomKindSelector'; +export * from './CreateRoomAliasInput'; +export * from './RoomVersionSelector'; +export * from './utils'; diff --git a/src/app/components/create-room/utils.ts b/src/app/components/create-room/utils.ts new file mode 100644 index 00000000..9abff4ff --- /dev/null +++ b/src/app/components/create-room/utils.ts @@ -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 = {}; + 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 => { + 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; +}; diff --git a/src/app/features/create-room/CreateRoom.tsx b/src/app/features/create-room/CreateRoom.tsx index b2d0e0d0..c88bf680 100644 --- a/src/app/features/create-room/CreateRoom.tsx +++ b/src/app/features/create-room/CreateRoom.tsx @@ -1,21 +1,5 @@ -import React, { - FormEventHandler, - KeyboardEventHandler, - MouseEventHandler, - useCallback, - useEffect, - useRef, - useState, -} from 'react'; -import { - ICreateRoomOpts, - ICreateRoomStateEvent, - JoinRule, - MatrixClient, - MatrixError, - RestrictedAllowType, - Room, -} from 'matrix-js-sdk'; +import React, { FormEventHandler, useCallback, useState } from 'react'; +import { MatrixError, Room } from 'matrix-js-sdk'; import { Box, Button, @@ -25,333 +9,28 @@ import { Icon, Icons, Input, - Menu, - PopOut, - RectCords, Spinner, Switch, Text, TextArea, - toRem, } from 'folds'; -import FocusTrap from 'focus-trap-react'; -import { RoomJoinRulesEventContent } from 'matrix-js-sdk/lib/types'; -import { isKeyHotkey } from 'is-hotkey'; import { SettingTile } from '../../components/setting-tile'; import { SequenceCard } from '../../components/sequence-card'; -import { - getMxIdServer, - knockRestrictedSupported, - knockSupported, - restrictedSupported, -} from '../../utils/matrix'; +import { knockRestrictedSupported, knockSupported, restrictedSupported } from '../../utils/matrix'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { millisecondsToMinutes, replaceSpaceWithDash } from '../../utils/common'; -import { AsyncState, AsyncStatus, useAsync, useAsyncCallback } from '../../hooks/useAsyncCallback'; -import { useDebounce } from '../../hooks/useDebounce'; +import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { useCapabilities } from '../../hooks/useCapabilities'; -import { stopPropagation } from '../../utils/keyboard'; -import { getViaServers } from '../../plugins/via-servers'; -import { StateEvent } from '../../../types/matrix/room'; -import { getIdServer } from '../../../util/matrixUtil'; import { useAlive } from '../../hooks/useAlive'; import { ErrorCode } from '../../cs-errorcode'; -import { CreateRoomKind, CreateRoomKindSelector } from '../../components/CreateRoomKindSelector'; - -export function AliasInput({ disabled }: { disabled?: boolean }) { - const mx = useMatrixClient(); - const aliasInputRef = useRef(null); - const [aliasAvail, setAliasAvail] = useState>({ - 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 = (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 = (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 ( - - Address (Optional) - - Pick an unique address to make your room discoverable to public. - - - ) : ( - - ) - } - after={ - - :{getMxIdServer(mx.getSafeUserId())} - - } - onKeyDown={handleAliasKeyDown} - name="aliasInput" - size="500" - variant={aliasAvailable === true ? 'Success' : 'SurfaceVariant'} - radii="400" - disabled={disabled} - /> - {aliasAvailable === false && ( - - - - This address is already taken. Please select a different one. - - - )} - - ); -} - -export function RoomVersionSelector({ - versions, - value, - onChange, - disabled, -}: { - versions: string[]; - value: string; - onChange: (value: string) => void; - disabled?: boolean; -}) { - const [menuCords, setMenuCords] = useState(); - - const handleMenu: MouseEventHandler = (evt) => { - setMenuCords(evt.currentTarget.getBoundingClientRect()); - }; - - const handleSelect = (version: string) => { - setMenuCords(undefined); - onChange(version); - }; - - return ( - - setMenuCords(undefined), - clickOutsideDeactivates: true, - isKeyForward: (evt: KeyboardEvent) => - evt.key === 'ArrowDown' || evt.key === 'ArrowRight', - isKeyBackward: (evt: KeyboardEvent) => - evt.key === 'ArrowUp' || evt.key === 'ArrowLeft', - escapeDeactivates: stopPropagation, - }} - > - - - Room Versions - - {versions.map((version) => ( - handleSelect(version)} - type="button" - > - - {version} - - - ))} - - - - - } - > - - - } - /> - - ); -} - -type CreateRoomData = { - version: string; - parent?: Room; - kind: CreateRoomKind; - name: string; - topic?: string; - aliasLocalPart?: string; - encryption: boolean; - knock: boolean; - allowFederation: boolean; -}; -const createRoom = async (mx: MatrixClient, data: CreateRoomData): Promise => { - const creationContent = { - 'm.federate': data.allowFederation, - }; - - const initialState: ICreateRoomStateEvent[] = []; - - if (data.encryption) { - initialState.push({ - type: 'm.room.encryption', - state_key: '', - content: { - algorithm: 'm.megolm.v1.aes-sha2', - }, - }); - } - - if (data.parent) { - initialState.push({ - type: StateEvent.SpaceParent, - state_key: data.parent.roomId, - content: { - canonical: true, - via: getViaServers(data.parent), - }, - }); - } - - const getJoinRuleContent = (): RoomJoinRulesEventContent => { - if (data.kind === CreateRoomKind.Public) { - return { - join_rule: JoinRule.Public, - }; - } - - if (data.kind === CreateRoomKind.Restricted && data.parent) { - return { - join_rule: data.knock ? ('knock_restricted' as JoinRule) : JoinRule.Restricted, - allow: [ - { - type: RestrictedAllowType.RoomMembership, - room_id: data.parent.roomId, - }, - ], - }; - } - - return { - join_rule: data.knock ? JoinRule.Knock : JoinRule.Invite, - }; - }; - - initialState.push({ - type: StateEvent.RoomJoinRules, - content: getJoinRuleContent(), - }); - - const options: ICreateRoomOpts = { - room_version: data.version, - name: data.name, - topic: data.topic, - room_alias_name: data.aliasLocalPart, - creation_content: creationContent, - 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: [getIdServer(mx.getUserId())], - }, - result.room_id - ); - } - - return result.room_id; -}; +import { + createRoom, + CreateRoomAliasInput, + CreateRoomData, + CreateRoomKind, + CreateRoomKindSelector, + RoomVersionSelector, +} from '../../components/create-room'; const getCreateRoomKindToIcon = (kind: CreateRoomKind) => { if (kind === CreateRoomKind.Private) return Icons.HashLock; @@ -369,8 +48,9 @@ export function CreateRoomForm({ defaultKind, space, onCreate }: CreateRoomFormP const alive = useAlive(); const capabilities = useCapabilities(); - const roomVersion = capabilities['m.room_versions']; - const [selectedRoomVersion, selectRoomVersion] = useState(roomVersion?.default ?? '1'); + const roomVersions = capabilities['m.room_versions']; + const [selectedRoomVersion, selectRoomVersion] = useState(roomVersions?.default ?? '1'); + const allowRestricted = space && restrictedSupported(selectedRoomVersion); const [kind, setKind] = useState( @@ -448,6 +128,7 @@ export function CreateRoomForm({ defaultKind, space, onCreate }: CreateRoomFormP onSelect={setKind} canRestrict={allowRestricted} disabled={disabled} + getIcon={getCreateRoomKindToIcon} /> @@ -460,6 +141,7 @@ export function CreateRoomForm({ defaultKind, space, onCreate }: CreateRoomFormP size="500" variant="SurfaceVariant" radii="400" + autoComplete="off" disabled={disabled} /> @@ -474,7 +156,7 @@ export function CreateRoomForm({ defaultKind, space, onCreate }: CreateRoomFormP /> - {kind === CreateRoomKind.Public && } + {kind === CreateRoomKind.Public && } @@ -556,7 +238,7 @@ export function CreateRoomForm({ defaultKind, space, onCreate }: CreateRoomFormP {advance && ( (null); const screenSize = useScreenSizeContext(); const { navigateRoom } = useRoomNavigate(); @@ -34,8 +33,8 @@ export function HomeCreateRoom() { )} - - + +