diff --git a/src/app/components/sequence-card/SequenceCard.tsx b/src/app/components/sequence-card/SequenceCard.tsx index 4036b963..8e48817c 100644 --- a/src/app/components/sequence-card/SequenceCard.tsx +++ b/src/app/components/sequence-card/SequenceCard.tsx @@ -7,12 +7,18 @@ 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, 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..dcd693a1 100644 --- a/src/app/components/sequence-card/style.css.ts +++ b/src/app/components/sequence-card/style.css.ts @@ -37,6 +37,10 @@ export const SequenceCard = recipe({ borderBottomLeftRadius: 0, borderBottomRightRadius: 0, }, + + 'button&': { + cursor: 'pointer', + }, }, }, variants: { 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..a83f7ca7 --- /dev/null +++ b/src/app/features/create-room/CreateRoom.tsx @@ -0,0 +1,680 @@ +import React, { + FormEventHandler, + KeyboardEventHandler, + MouseEventHandler, + useCallback, + useEffect, + useRef, + useState, +} from 'react'; +import { + ICreateRoomOpts, + ICreateRoomStateEvent, + JoinRule, + MatrixClient, + MatrixError, + RestrictedAllowType, + Room, +} from 'matrix-js-sdk'; +import { + Box, + Button, + Chip, + color, + config, + 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 { useMatrixClient } from '../../hooks/useMatrixClient'; +import { millisecondsToMinutes, replaceSpaceWithDash } from '../../utils/common'; +import { AsyncState, AsyncStatus, useAsync, useAsyncCallback } from '../../hooks/useAsyncCallback'; +import { useDebounce } from '../../hooks/useDebounce'; +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'; + +export enum CreateRoomKind { + Private = 'private', + Restricted = 'restricted', + Public = 'public', +} +type CreateRoomKindSelectorProps = { + value?: CreateRoomKind; + onSelect: (value: CreateRoomKind) => void; + canRestrict?: boolean; + disabled?: boolean; +}; +export function CreateRoomKindSelector({ + value, + onSelect, + canRestrict, + disabled, +}: CreateRoomKindSelectorProps) { + return ( + + onSelect(CreateRoomKind.Private)} + disabled={disabled} + > + } + after={value === CreateRoomKind.Private && } + > + Private + + Only people with invite can join. + + + + {canRestrict && ( + onSelect(CreateRoomKind.Restricted)} + disabled={disabled} + > + } + after={value === CreateRoomKind.Restricted && } + > + Restricted + + Only member of parent space can join. + + + + )} + onSelect(CreateRoomKind.Public)} + disabled={disabled} + > + } + after={value === CreateRoomKind.Public && } + > + Public + + Anyone with the room address can join. + + + + + ); +} + +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; +}; + +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 roomVersion = capabilities['m.room_versions']; + const [selectedRoomVersion, selectRoomVersion] = useState(roomVersion?.default ?? '1'); + + const [kind, setKind] = useState(defaultKind ?? CreateRoomKind.Private); + const [federation, setFederation] = useState(true); + const [encryption, setEncryption] = useState(false); + const [knock, setKnock] = useState(false); + const [advance, setAdvance] = useState(false); + + const allowRestricted = space && restrictedSupported(selectedRoomVersion); + 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" + disabled={disabled} + /> + + + Topic (Optional) +