diff --git a/src/app/components/create-room/AdditionalCreatorInput.tsx b/src/app/components/create-room/AdditionalCreatorInput.tsx new file mode 100644 index 00000000..84485b5b --- /dev/null +++ b/src/app/components/create-room/AdditionalCreatorInput.tsx @@ -0,0 +1,300 @@ +import { + Box, + Button, + Chip, + config, + Icon, + Icons, + Input, + Line, + Menu, + MenuItem, + PopOut, + RectCords, + Scroll, + Text, + toRem, +} from 'folds'; +import { isKeyHotkey } from 'is-hotkey'; +import FocusTrap from 'focus-trap-react'; +import React, { + ChangeEventHandler, + KeyboardEventHandler, + MouseEventHandler, + useMemo, + useState, +} from 'react'; +import { getMxIdLocalPart, getMxIdServer, isUserId } from '../../utils/matrix'; +import { useDirectUsers } from '../../hooks/useDirectUsers'; +import { SettingTile } from '../setting-tile'; +import { useMatrixClient } from '../../hooks/useMatrixClient'; +import { stopPropagation } from '../../utils/keyboard'; +import { useAsyncSearch, UseAsyncSearchOptions } from '../../hooks/useAsyncSearch'; +import { findAndReplace } from '../../utils/findAndReplace'; +import { highlightText } from '../../styles/CustomHtml.css'; +import { makeHighlightRegex } from '../../plugins/react-custom-html-parser'; + +export const useAdditionalCreators = () => { + const mx = useMatrixClient(); + const [additionalCreators, setAdditionalCreators] = useState([]); + + const addAdditionalCreator = (userId: string) => { + if (userId === mx.getSafeUserId()) return; + + setAdditionalCreators((creators) => { + const creatorsSet = new Set(creators); + creatorsSet.add(userId); + return Array.from(creatorsSet); + }); + }; + + const removeAdditionalCreator = (userId: string) => { + setAdditionalCreators((creators) => { + const creatorsSet = new Set(creators); + creatorsSet.delete(userId); + return Array.from(creatorsSet); + }); + }; + + return { + additionalCreators, + addAdditionalCreator, + removeAdditionalCreator, + }; +}; + +const SEARCH_OPTIONS: UseAsyncSearchOptions = { + limit: 1000, + matchOptions: { + contain: true, + }, +}; +const getUserIdString = (userId: string) => getMxIdLocalPart(userId) ?? userId; + +type AdditionalCreatorInputProps = { + additionalCreators: string[]; + onSelect: (userId: string) => void; + onRemove: (userId: string) => void; +}; +export function AdditionalCreatorInput({ + additionalCreators, + onSelect, + onRemove, +}: AdditionalCreatorInputProps) { + const mx = useMatrixClient(); + const [menuCords, setMenuCords] = useState(); + const directUsers = useDirectUsers(); + + const [validUserId, setValidUserId] = useState(); + const filteredUsers = useMemo( + () => directUsers.filter((userId) => !additionalCreators.includes(userId)), + [directUsers, additionalCreators] + ); + const [result, search, resetSearch] = useAsyncSearch( + filteredUsers, + getUserIdString, + SEARCH_OPTIONS + ); + const queryHighlighRegex = result?.query ? makeHighlightRegex([result.query]) : undefined; + + const suggestionUsers = result + ? result.items + : filteredUsers.sort((a, b) => (a.toLocaleLowerCase() >= b.toLocaleLowerCase() ? 1 : -1)); + + const handleOpenMenu: MouseEventHandler = (evt) => { + setMenuCords(evt.currentTarget.getBoundingClientRect()); + }; + const handleCloseMenu = () => { + setMenuCords(undefined); + setValidUserId(undefined); + resetSearch(); + }; + + const handleCreatorChange: ChangeEventHandler = (evt) => { + const creatorInput = evt.currentTarget; + const creator = creatorInput.value.trim(); + if (isUserId(creator)) { + setValidUserId(creator); + } else { + setValidUserId(undefined); + const term = + getMxIdLocalPart(creator) ?? (creator.startsWith('@') ? creator.slice(1) : creator); + if (term) { + search(term); + } else { + resetSearch(); + } + } + }; + + const handleSelectUserId = (userId?: string) => { + if (userId && isUserId(userId)) { + onSelect(userId); + handleCloseMenu(); + } + }; + + const handleCreatorKeyDown: KeyboardEventHandler = (evt) => { + if (isKeyHotkey('enter', evt)) { + evt.preventDefault(); + const creator = evt.currentTarget.value.trim(); + handleSelectUserId(isUserId(creator) ? creator : suggestionUsers[0]); + } + }; + + const handleEnterClick = () => { + handleSelectUserId(validUserId); + }; + + return ( + + + + + {mx.getSafeUserId()} + + {additionalCreators.map((creator) => ( + } + onClick={() => onRemove(creator)} + > + {creator} + + ))} + evt.key === 'ArrowDown', + isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', + escapeDeactivates: stopPropagation, + }} + > + + + + + + + + + + + {!validUserId && suggestionUsers.length > 0 ? ( + + + {suggestionUsers.map((userId) => ( + handleSelectUserId(userId)} + after={ + + {getMxIdServer(userId)} + + } + > + + + + {queryHighlighRegex + ? findAndReplace( + getMxIdLocalPart(userId) ?? userId, + queryHighlighRegex, + (match, pushIndex) => ( + + {match[0]} + + ), + (txt) => txt + ) + : getMxIdLocalPart(userId)} + + + + + ))} + + + ) : ( + + + No Suggestions + + + Please provide the user ID and hit Enter. + + + )} + + + + + } + > + + + + + + + + ); +} diff --git a/src/app/components/create-room/index.ts b/src/app/components/create-room/index.ts index ffca558d..ffd9cb3d 100644 --- a/src/app/components/create-room/index.ts +++ b/src/app/components/create-room/index.ts @@ -2,3 +2,4 @@ export * from './CreateRoomKindSelector'; export * from './CreateRoomAliasInput'; export * from './RoomVersionSelector'; export * from './utils'; +export * from './AdditionalCreatorInput'; diff --git a/src/app/components/create-room/utils.ts b/src/app/components/create-room/utils.ts index 9abff4ff..a0ca7488 100644 --- a/src/app/components/create-room/utils.ts +++ b/src/app/components/create-room/utils.ts @@ -14,7 +14,8 @@ import { getMxIdServer } from '../../utils/matrix'; export const createRoomCreationContent = ( type: RoomType | undefined, - allowFederation: boolean + allowFederation: boolean, + additionalCreators: string[] | undefined ): object => { const content: Record = {}; if (typeof type === 'string') { @@ -23,6 +24,9 @@ export const createRoomCreationContent = ( if (allowFederation === false) { content['m.federate'] = false; } + if (Array.isArray(additionalCreators)) { + content.additional_creators = additionalCreators; + } return content; }; @@ -89,6 +93,7 @@ export type CreateRoomData = { encryption?: boolean; knock: boolean; allowFederation: boolean; + additionalCreators?: string[]; }; export const createRoom = async (mx: MatrixClient, data: CreateRoomData): Promise => { const initialState: ICreateRoomStateEvent[] = []; @@ -108,7 +113,11 @@ export const createRoom = async (mx: MatrixClient, data: CreateRoomData): Promis name: data.name, topic: data.topic, room_alias_name: data.aliasLocalPart, - creation_content: createRoomCreationContent(data.type, data.allowFederation), + creation_content: createRoomCreationContent( + data.type, + data.allowFederation, + data.additionalCreators + ), initial_state: initialState, }; diff --git a/src/app/features/create-room/CreateRoom.tsx b/src/app/features/create-room/CreateRoom.tsx index c88bf680..6ad469c9 100644 --- a/src/app/features/create-room/CreateRoom.tsx +++ b/src/app/features/create-room/CreateRoom.tsx @@ -1,4 +1,4 @@ -import React, { FormEventHandler, useCallback, useState } from 'react'; +import React, { FormEventHandler, useCallback, useEffect, useState } from 'react'; import { MatrixError, Room } from 'matrix-js-sdk'; import { Box, @@ -16,7 +16,12 @@ import { } from 'folds'; import { SettingTile } from '../../components/setting-tile'; import { SequenceCard } from '../../components/sequence-card'; -import { knockRestrictedSupported, knockSupported, restrictedSupported } from '../../utils/matrix'; +import { + creatorsSupported, + knockRestrictedSupported, + knockSupported, + restrictedSupported, +} from '../../utils/matrix'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { millisecondsToMinutes, replaceSpaceWithDash } from '../../utils/common'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; @@ -24,12 +29,14 @@ import { useCapabilities } from '../../hooks/useCapabilities'; import { useAlive } from '../../hooks/useAlive'; import { ErrorCode } from '../../cs-errorcode'; import { + AdditionalCreatorInput, createRoom, CreateRoomAliasInput, CreateRoomData, CreateRoomKind, CreateRoomKindSelector, RoomVersionSelector, + useAdditionalCreators, } from '../../components/create-room'; const getCreateRoomKindToIcon = (kind: CreateRoomKind) => { @@ -50,12 +57,19 @@ export function CreateRoomForm({ defaultKind, space, onCreate }: CreateRoomFormP const capabilities = useCapabilities(); const roomVersions = capabilities['m.room_versions']; const [selectedRoomVersion, selectRoomVersion] = useState(roomVersions?.default ?? '1'); + useEffect(() => { + // capabilities load async + selectRoomVersion(roomVersions?.default ?? '1'); + }, [roomVersions?.default]); const allowRestricted = space && restrictedSupported(selectedRoomVersion); const [kind, setKind] = useState( defaultKind ?? allowRestricted ? CreateRoomKind.Restricted : CreateRoomKind.Private ); + const allowAdditionalCreators = creatorsSupported(selectedRoomVersion); + const { additionalCreators, addAdditionalCreator, removeAdditionalCreator } = + useAdditionalCreators(); const [federation, setFederation] = useState(true); const [encryption, setEncryption] = useState(false); const [knock, setKnock] = useState(false); @@ -112,6 +126,7 @@ export function CreateRoomForm({ defaultKind, space, onCreate }: CreateRoomFormP encryption: publicRoom ? false : encryption, knock: roomKnock, allowFederation: federation, + additionalCreators: allowAdditionalCreators ? additionalCreators : undefined, }).then((roomId) => { if (alive()) { onCreate?.(roomId); @@ -172,6 +187,20 @@ export function CreateRoomForm({ defaultKind, space, onCreate }: CreateRoomFormP + {allowAdditionalCreators && ( + + + + )} {kind !== CreateRoomKind.Public && ( <> { + // capabilities load async + selectRoomVersion(roomVersions?.default ?? '1'); + }, [roomVersions?.default]); const allowRestricted = space && restrictedSupported(selectedRoomVersion); const [kind, setKind] = useState( defaultKind ?? allowRestricted ? CreateRoomKind.Restricted : CreateRoomKind.Private ); + + const allowAdditionalCreators = creatorsSupported(selectedRoomVersion); + const { additionalCreators, addAdditionalCreator, removeAdditionalCreator } = + useAdditionalCreators(); const [federation, setFederation] = useState(true); const [knock, setKnock] = useState(false); const [advance, setAdvance] = useState(false); @@ -112,6 +127,7 @@ export function CreateSpaceForm({ defaultKind, space, onCreate }: CreateSpaceFor aliasLocalPart: publicRoom ? aliasLocalPart : undefined, knock: roomKnock, allowFederation: federation, + additionalCreators: allowAdditionalCreators ? additionalCreators : undefined, }).then((roomId) => { if (alive()) { onCreate?.(roomId); @@ -172,6 +188,20 @@ export function CreateSpaceForm({ defaultKind, space, onCreate }: CreateSpaceFor + {allowAdditionalCreators && ( + + + + )} {kind !== CreateRoomKind.Public && advance && (allowKnock || allowKnockRestricted) && (