diff --git a/src/app/components/create-room/CreateRoomAliasInput.tsx b/src/app/components/create-room/CreateRoomAliasInput.tsx new file mode 100644 index 00000000..e84658c0 --- /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 it discoverable. + + + ) : ( + + ) + } + 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/create-room/CreateRoomKindSelector.tsx b/src/app/components/create-room/CreateRoomKindSelector.tsx new file mode 100644 index 00000000..096954fb --- /dev/null +++ b/src/app/components/create-room/CreateRoomKindSelector.tsx @@ -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 ( + + {canRestrict && ( + onSelect(CreateRoomKind.Restricted)} + disabled={disabled} + > + } + after={value === CreateRoomKind.Restricted && } + > + Restricted + + Only member of parent space can join. + + + + )} + onSelect(CreateRoomKind.Private)} + disabled={disabled} + > + } + after={value === CreateRoomKind.Private && } + > + Private + + Only people with invite can join. + + + + onSelect(CreateRoomKind.Public)} + disabled={disabled} + > + } + after={value === CreateRoomKind.Public && } + > + Public + + Anyone with the address can join. + + + + + ); +} 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/components/sequence-card/SequenceCard.tsx b/src/app/components/sequence-card/SequenceCard.tsx index 4036b963..d0e77ae6 100644 --- a/src/app/components/sequence-card/SequenceCard.tsx +++ b/src/app/components/sequence-card/SequenceCard.tsx @@ -7,12 +7,31 @@ import * as css from './style.css'; export const SequenceCard = as< 'div', ComponentProps & ContainerColorVariants & css.SequenceCardVariants ->(({ className, variant, firstChild, lastChild, outlined, ...props }, ref) => ( - -)); +>( + ( + { + as: AsSequenceCard = 'div', + className, + variant, + radii, + firstChild, + lastChild, + outlined, + ...props + }, + ref + ) => ( + + ) +); diff --git a/src/app/components/sequence-card/style.css.ts b/src/app/components/sequence-card/style.css.ts index c8ed48b8..9d503264 100644 --- a/src/app/components/sequence-card/style.css.ts +++ b/src/app/components/sequence-card/style.css.ts @@ -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; diff --git a/src/app/features/common-settings/general/RoomJoinRules.tsx b/src/app/features/common-settings/general/RoomJoinRules.tsx index c0d62a6a..f47ff757 100644 --- a/src/app/features/common-settings/general/RoomJoinRules.tsx +++ b/src/app/features/common-settings/general/RoomJoinRules.tsx @@ -27,6 +27,11 @@ import { } from '../../../state/hooks/roomList'; import { allRoomsAtom } from '../../../state/room-list/roomList'; import { roomToParentsAtom } from '../../../state/room/roomToParents'; +import { + knockRestrictedSupported, + knockSupported, + restrictedSupported, +} from '../../../utils/matrix'; type RestrictedRoomAllowContent = { room_id: string; @@ -39,10 +44,9 @@ type RoomJoinRulesProps = { export function RoomJoinRules({ powerLevels }: RoomJoinRulesProps) { const mx = useMatrixClient(); const room = useRoom(); - const roomVersion = parseInt(room.getVersion(), 10); - const allowKnockRestricted = roomVersion >= 10; - const allowRestricted = roomVersion >= 8; - const allowKnock = roomVersion >= 7; + const allowKnockRestricted = knockRestrictedSupported(room.getVersion()); + const allowRestricted = restrictedSupported(room.getVersion()); + const allowKnock = knockSupported(room.getVersion()); const roomIdToParents = useAtomValue(roomToParentsAtom); const space = useSpaceOptionally(); diff --git a/src/app/features/create-room/CreateRoom.tsx b/src/app/features/create-room/CreateRoom.tsx new file mode 100644 index 00000000..c88bf680 --- /dev/null +++ b/src/app/features/create-room/CreateRoom.tsx @@ -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( + 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 = (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 ( + + + Access + + + + Name + } + name="nameInput" + autoFocus + size="500" + variant="SurfaceVariant" + radii="400" + autoComplete="off" + disabled={disabled} + /> + + + Topic (Optional) +