import PropTypes from 'prop-types';
import './CreateRoom.scss';
+import { twemojify } from '../../../util/twemojify';
import initMatrix from '../../../client/initMatrix';
import cons from '../../../client/state/cons';
-import { isRoomAliasAvailable } from '../../../util/matrixUtil';
+import navigation from '../../../client/state/navigation';
+import { selectRoom, openReusableContextMenu } from '../../../client/action/navigation';
import * as roomActions from '../../../client/action/room';
-import { selectRoom } from '../../../client/action/navigation';
+import { isRoomAliasAvailable, getIdServer } from '../../../util/matrixUtil';
+import { getEventCords } from '../../../util/common';
import Text from '../../atoms/text/Text';
import Button from '../../atoms/button/Button';
import Toggle from '../../atoms/button/Toggle';
import IconButton from '../../atoms/button/IconButton';
+import { MenuHeader, MenuItem } from '../../atoms/context-menu/ContextMenu';
import Input from '../../atoms/input/Input';
import Spinner from '../../atoms/spinner/Spinner';
import SegmentControl from '../../atoms/segmented-controls/SegmentedControls';
-import PopupWindow from '../../molecules/popup-window/PopupWindow';
+import Dialog from '../../molecules/dialog/Dialog';
import SettingTile from '../../molecules/setting-tile/SettingTile';
import HashPlusIC from '../../../../public/res/ic/outlined/hash-plus.svg';
+import SpacePlusIC from '../../../../public/res/ic/outlined/space-plus.svg';
+import HashIC from '../../../../public/res/ic/outlined/hash.svg';
+import HashLockIC from '../../../../public/res/ic/outlined/hash-lock.svg';
+import HashGlobeIC from '../../../../public/res/ic/outlined/hash-globe.svg';
+import SpaceIC from '../../../../public/res/ic/outlined/space.svg';
+import SpaceLockIC from '../../../../public/res/ic/outlined/space-lock.svg';
+import SpaceGlobeIC from '../../../../public/res/ic/outlined/space-globe.svg';
+import ChevronBottomIC from '../../../../public/res/ic/outlined/chevron-bottom.svg';
import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
-function CreateRoom({ isOpen, onRequestClose }) {
- const [isPublic, togglePublic] = useState(false);
- const [isEncrypted, toggleEncrypted] = useState(true);
- const [isValidAddress, updateIsValidAddress] = useState(null);
- const [isCreatingRoom, updateIsCreatingRoom] = useState(false);
- const [creatingError, updateCreatingError] = useState(null);
+function CreateRoomContent({ isSpace, parentId, onRequestClose }) {
+ const [joinRule, setJoinRule] = useState(parentId ? 'restricted' : 'invite');
+ const [isEncrypted, setIsEncrypted] = useState(true);
+ const [isCreatingRoom, setIsCreatingRoom] = useState(false);
+ const [creatingError, setCreatingError] = useState(null);
- const [titleValue, updateTitleValue] = useState(undefined);
- const [topicValue, updateTopicValue] = useState(undefined);
- const [addressValue, updateAddressValue] = useState(undefined);
+ const [isValidAddress, setIsValidAddress] = useState(null);
+ const [addressValue, setAddressValue] = useState(undefined);
const [roleIndex, setRoleIndex] = useState(0);
const addressRef = useRef(null);
- const topicRef = useRef(null);
- const nameRef = useRef(null);
-
- const userId = initMatrix.matrixClient.getUserId();
- const hsString = userId.slice(userId.indexOf(':'));
-
- function resetForm() {
- togglePublic(false);
- toggleEncrypted(true);
- updateIsValidAddress(null);
- updateIsCreatingRoom(false);
- updateCreatingError(null);
- updateTitleValue(undefined);
- updateTopicValue(undefined);
- updateAddressValue(undefined);
- setRoleIndex(0);
- }
-
- const onCreated = (roomId) => {
- resetForm();
- selectRoom(roomId);
- onRequestClose();
- };
+
+ const mx = initMatrix.matrixClient;
+ const userHs = getIdServer(mx.getUserId());
useEffect(() => {
const { roomList } = initMatrix;
+ const onCreated = (roomId) => {
+ setJoinRule(false);
+ setIsEncrypted(true);
+ setIsValidAddress(null);
+ setIsCreatingRoom(false);
+ setCreatingError(null);
+ setAddressValue(undefined);
+ setRoleIndex(0);
+
+ if (!mx.getRoom(roomId)?.isSpaceRoom()) {
+ selectRoom(roomId);
+ }
+ onRequestClose();
+ };
roomList.on(cons.events.roomList.ROOM_CREATED, onCreated);
return () => {
roomList.removeListener(cons.events.roomList.ROOM_CREATED, onCreated);
};
}, []);
- async function createRoom() {
+ const handleSubmit = async (evt) => {
+ evt.preventDefault();
+ const { target } = evt;
+
if (isCreatingRoom) return;
- updateIsCreatingRoom(true);
- updateCreatingError(null);
- const name = nameRef.current.value;
- let topic = topicRef.current.value;
+ setIsCreatingRoom(true);
+ setCreatingError(null);
+
+ const name = target.name.value;
+ let topic = target.topic.value;
if (topic.trim() === '') topic = undefined;
let roomAlias;
- if (isPublic) {
+ if (joinRule === 'public') {
roomAlias = addressRef?.current?.value;
if (roomAlias.trim() === '') roomAlias = undefined;
}
const powerLevel = roleIndex === 1 ? 101 : undefined;
try {
- await roomActions.create({
- name, topic, isPublic, roomAlias, isEncrypted, powerLevel,
+ await roomActions.createRoom({
+ name,
+ topic,
+ joinRule,
+ alias: roomAlias,
+ isEncrypted: (isSpace || joinRule === 'public') ? false : isEncrypted,
+ powerLevel,
+ isSpace,
+ parentId,
});
} catch (e) {
if (e.message === 'M_UNKNOWN: Invalid characters in room alias') {
- updateCreatingError('ERROR: Invalid characters in room address');
- updateIsValidAddress(false);
+ setCreatingError('ERROR: Invalid characters in address');
+ setIsValidAddress(false);
} else if (e.message === 'M_ROOM_IN_USE: Room alias already taken') {
- updateCreatingError('ERROR: Room address is already in use');
- updateIsValidAddress(false);
- } else updateCreatingError(e.message);
- updateIsCreatingRoom(false);
+ setCreatingError('ERROR: This address is already in use');
+ setIsValidAddress(false);
+ } else setCreatingError(e.message);
+ setIsCreatingRoom(false);
}
- }
+ };
- function validateAddress(e) {
+ const validateAddress = (e) => {
const myAddress = e.target.value;
- updateIsValidAddress(null);
- updateAddressValue(e.target.value);
- updateCreatingError(null);
+ setIsValidAddress(null);
+ setAddressValue(e.target.value);
+ setCreatingError(null);
setTimeout(async () => {
if (myAddress !== addressRef.current.value) return;
const roomAlias = addressRef.current.value;
if (roomAlias === '') return;
- const roomAddress = `#${roomAlias}${hsString}`;
+ const roomAddress = `#${roomAlias}:${userHs}`;
if (await isRoomAliasAvailable(roomAddress)) {
- updateIsValidAddress(true);
+ setIsValidAddress(true);
} else {
- updateIsValidAddress(false);
+ setIsValidAddress(false);
}
}, 1000);
- }
- function handleTitleChange(e) {
- if (e.target.value.trim() === '') updateTitleValue(undefined);
- updateTitleValue(e.target.value);
- }
- function handleTopicChange(e) {
- if (e.target.value.trim() === '') updateTopicValue(undefined);
- updateTopicValue(e.target.value);
- }
+ };
+
+ const joinRules = ['invite', 'restricted', 'public'];
+ const joinRuleShortText = ['Private', 'Restricted', 'Public'];
+ const joinRuleText = ['Private (invite only)', 'Restricted (space member can join)', 'Public (anyone can join)'];
+ const jrRoomIC = [HashLockIC, HashIC, HashGlobeIC];
+ const jrSpaceIC = [SpaceLockIC, SpaceIC, SpaceGlobeIC];
+ const handleJoinRule = (evt) => {
+ openReusableContextMenu(
+ 'bottom',
+ getEventCords(evt, '.btn-surface'),
+ (closeMenu) => (
+ <>
+ <MenuHeader>Visibility (who can join)</MenuHeader>
+ {
+ joinRules.map((rule) => (
+ <MenuItem
+ key={rule}
+ variant={rule === joinRule ? 'positive' : 'surface'}
+ iconSrc={
+ isSpace
+ ? jrSpaceIC[joinRules.indexOf(rule)]
+ : jrRoomIC[joinRules.indexOf(rule)]
+ }
+ onClick={() => { closeMenu(); setJoinRule(rule); }}
+ disabled={!parentId && rule === 'restricted'}
+ >
+ { joinRuleText[joinRules.indexOf(rule)] }
+ </MenuItem>
+ ))
+ }
+ </>
+ ),
+ );
+ };
return (
- <PopupWindow
- isOpen={isOpen}
- title="Create room"
- contentOptions={<IconButton src={CrossIC} onClick={onRequestClose} tooltip="Close" />}
- onRequestClose={onRequestClose}
- >
- <div className="create-room">
- <form className="create-room__form" onSubmit={(e) => { e.preventDefault(); createRoom(); }}>
+ <div className="create-room">
+ <form className="create-room__form" onSubmit={handleSubmit}>
+ <SettingTile
+ title="Visibility"
+ options={(
+ <Button onClick={handleJoinRule} iconSrc={ChevronBottomIC}>
+ {joinRuleShortText[joinRules.indexOf(joinRule)]}
+ </Button>
+ )}
+ content={<Text variant="b3">{`Select who can join this ${isSpace ? 'space' : 'room'}.`}</Text>}
+ />
+ {joinRule === 'public' && (
+ <div>
+ <Text className="create-room__address__label" variant="b2">{isSpace ? 'Space address' : 'Room address'}</Text>
+ <div className="create-room__address">
+ <Text variant="b1">#</Text>
+ <Input
+ value={addressValue}
+ onChange={validateAddress}
+ state={(isValidAddress === false) ? 'error' : 'normal'}
+ forwardRef={addressRef}
+ placeholder="my_address"
+ required
+ />
+ <Text variant="b1">{`:${userHs}`}</Text>
+ </div>
+ {isValidAddress === false && <Text className="create-room__address__tip" variant="b3"><span style={{ color: 'var(--bg-danger)' }}>{`#${addressValue}:${userHs} is already in use`}</span></Text>}
+ </div>
+ )}
+ {!isSpace && joinRule !== 'public' && (
<SettingTile
- title="Make room public"
- options={<Toggle isActive={isPublic} onToggle={togglePublic} />}
- content={<Text variant="b3">Public room can be joined by anyone.</Text>}
+ title="Enable end-to-end encryption"
+ options={<Toggle isActive={isEncrypted} onToggle={setIsEncrypted} />}
+ content={<Text variant="b3">You can’t disable this later. Bridges & most bots won’t work yet.</Text>}
/>
- {isPublic && (
- <div>
- <Text className="create-room__address__label" variant="b2">Room address</Text>
- <div className="create-room__address">
- <Text variant="b1">#</Text>
- <Input value={addressValue} onChange={validateAddress} state={(isValidAddress === false) ? 'error' : 'normal'} forwardRef={addressRef} placeholder="my_room" required />
- <Text variant="b1">{hsString}</Text>
- </div>
- {isValidAddress === false && <Text className="create-room__address__tip" variant="b3"><span style={{ color: 'var(--bg-danger)' }}>{`#${addressValue}${hsString} is already in use`}</span></Text>}
- </div>
- )}
- {!isPublic && (
- <SettingTile
- title="Enable end-to-end encryption"
- options={<Toggle isActive={isEncrypted} onToggle={toggleEncrypted} />}
- content={<Text variant="b3">You can’t disable this later. Bridges & most bots won’t work yet.</Text>}
+ )}
+ <SettingTile
+ title="Select your role"
+ options={(
+ <SegmentControl
+ selected={roleIndex}
+ segments={[{ text: 'Admin' }, { text: 'Founder' }]}
+ onSelect={setRoleIndex}
/>
)}
- <SettingTile
- title="Select your role"
- options={(
- <SegmentControl
- selected={roleIndex}
- segments={[{ text: 'Admin' }, { text: 'Founder' }]}
- onSelect={setRoleIndex}
- />
- )}
- content={(
- <Text variant="b3">Override the default (100) power level.</Text>
- )}
- />
- <Input value={topicValue} onChange={handleTopicChange} forwardRef={topicRef} minHeight={174} resizable label="Topic (optional)" />
- <div className="create-room__name-wrapper">
- <Input value={titleValue} onChange={handleTitleChange} forwardRef={nameRef} label="Room name" required />
- <Button disabled={isValidAddress === false || isCreatingRoom} iconSrc={HashPlusIC} type="submit" variant="primary">Create</Button>
- </div>
- {isCreatingRoom && (
- <div className="create-room__loading">
- <Spinner size="small" />
- <Text>Creating room...</Text>
- </div>
+ content={(
+ <Text variant="b3">Override the default (100) power level.</Text>
)}
- {typeof creatingError === 'string' && <Text className="create-room__error" variant="b3">{creatingError}</Text>}
- </form>
- </div>
- </PopupWindow>
+ />
+ <Input name="topic" minHeight={174} resizable label="Topic (optional)" />
+ <div className="create-room__name-wrapper">
+ <Input name="name" label={`${isSpace ? 'Space' : 'Room'} name`} required />
+ <Button
+ disabled={isValidAddress === false || isCreatingRoom}
+ iconSrc={isSpace ? SpacePlusIC : HashPlusIC}
+ type="submit"
+ variant="primary"
+ >
+ Create
+ </Button>
+ </div>
+ {isCreatingRoom && (
+ <div className="create-room__loading">
+ <Spinner size="small" />
+ <Text>{`Creating ${isSpace ? 'space' : 'room'}...`}</Text>
+ </div>
+ )}
+ {typeof creatingError === 'string' && <Text className="create-room__error" variant="b3">{creatingError}</Text>}
+ </form>
+ </div>
);
}
-
-CreateRoom.propTypes = {
- isOpen: PropTypes.bool.isRequired,
+CreateRoomContent.defaultProps = {
+ parentId: null,
+};
+CreateRoomContent.propTypes = {
+ isSpace: PropTypes.bool.isRequired,
+ parentId: PropTypes.string,
onRequestClose: PropTypes.func.isRequired,
};
+function useWindowToggle() {
+ const [create, setCreate] = useState(null);
+
+ useEffect(() => {
+ const handleOpen = (isSpace, parentId) => {
+ setCreate({
+ isSpace,
+ parentId,
+ });
+ };
+ navigation.on(cons.events.navigation.CREATE_ROOM_OPENED, handleOpen);
+ return () => {
+ navigation.removeListener(cons.events.navigation.CREATE_ROOM_OPENED, handleOpen);
+ };
+ }, []);
+
+ const onRequestClose = () => setCreate(null);
+
+ return [create, onRequestClose];
+}
+
+function CreateRoom() {
+ const [create, onRequestClose] = useWindowToggle();
+ const { isSpace, parentId } = create ?? {};
+ const mx = initMatrix.matrixClient;
+ const room = mx.getRoom(parentId);
+
+ return (
+ <Dialog
+ isOpen={create !== null}
+ title={(
+ <Text variant="s1" weight="medium" primary>
+ {parentId ? twemojify(room.name) : 'Home'}
+ <span style={{ color: 'var(--tc-surface-low)' }}>
+ {` — create ${isSpace ? 'space' : 'room'}`}
+ </span>
+ </Text>
+ )}
+ contentOptions={<IconButton src={CrossIC} onClick={onRequestClose} tooltip="Close" />}
+ onRequestClose={onRequestClose}
+ >
+ {
+ create
+ ? (
+ <CreateRoomContent
+ isSpace={isSpace}
+ parentId={parentId}
+ onRequestClose={onRequestClose}
+ />
+ ) : <div />
+ }
+ </Dialog>
+ );
+}
+
export default CreateRoom;
import initMatrix from '../initMatrix';
import appDispatcher from '../dispatcher';
import cons from '../state/cons';
+import { getIdServer } from '../../util/matrixUtil';
/**
* https://github.com/matrix-org/matrix-react-sdk/blob/1e6c6e9d800890c732d60429449bc280de01a647/src/Rooms.js#L73
}).catch();
}
-/**
- * Create a room.
- * @param {Object} opts
- * @param {string} [opts.name]
- * @param {string} [opts.topic]
- * @param {boolean} [opts.isPublic=false] Sets room visibility to public
- * @param {string} [opts.roomAlias] Sets the room address
- * @param {boolean} [opts.isEncrypted=false] Makes room encrypted
- * @param {boolean} [opts.isDirect=false] Makes room as direct message
- * @param {string[]} [opts.invite=[]] An array of userId's to invite
- * @param{number} [opts.powerLevel=100] My power level
- */
-async function create(opts) {
+async function create(options, isDM = false) {
const mx = initMatrix.matrixClient;
- const customPowerLevels = [101];
- const options = {
- name: opts.name,
- topic: opts.topic,
- visibility: opts.isPublic === true ? 'public' : 'private',
- room_alias_name: opts.roomAlias,
- is_direct: opts.isDirect === true,
- invite: opts.invite || [],
- initial_state: [],
- preset: opts.isDirect === true ? 'trusted_private_chat' : undefined,
- power_level_content_override: customPowerLevels.indexOf(opts.powerLevel) === -1 ? undefined : {
- users: { [initMatrix.matrixClient.getUserId()]: opts.powerLevel },
- },
- };
-
- if (opts.isPublic !== true && opts.isEncrypted === true) {
- options.initial_state.push({
- type: 'm.room.encryption',
- state_key: '',
- content: {
- algorithm: 'm.megolm.v1.aes-sha2',
- },
- });
- }
-
try {
const result = await mx.createRoom(options);
- if (opts.isDirect === true && typeof opts.invite[0] !== 'undefined') {
- await addRoomToMDirect(result.room_id, opts.invite[0]);
+ if (isDM && typeof options.invite?.[0] === 'string') {
+ await addRoomToMDirect(result.room_id, options.invite[0]);
}
appDispatcher.dispatch({
type: cons.actions.room.CREATE,
roomId: result.room_id,
- isDM: opts.isDirect === true,
+ isDM,
});
return result;
} catch (e) {
const errcodes = ['M_UNKNOWN', 'M_BAD_JSON', 'M_ROOM_IN_USE', 'M_INVALID_ROOM_STATE', 'M_UNSUPPORTED_ROOM_VERSION'];
- if (errcodes.find((errcode) => errcode === e.errcode)) {
- appDispatcher.dispatch({
- type: cons.actions.room.error.CREATE,
- error: e,
- });
+ if (errcodes.includes(e.errcode)) {
throw new Error(e);
}
throw new Error('Something went wrong!');
}
}
+async function createDM(userId, isEncrypted = true) {
+ const options = {
+ is_direct: true,
+ invite: [userId],
+ visibility: 'private',
+ preset: 'trusted_private_chat',
+ initial_state: [],
+ };
+ if (isEncrypted) {
+ options.initial_state.push({
+ type: 'm.room.encryption',
+ state_key: '',
+ content: {
+ algorithm: 'm.megolm.v1.aes-sha2',
+ },
+ });
+ }
+
+ const result = await create(options, true);
+ return result;
+}
+
+async function createRoom(opts) {
+ // joinRule: 'public' | 'invite' | 'restricted'
+ const { name, topic, joinRule } = opts;
+ const alias = opts.alias ?? undefined;
+ const parentId = opts.parentId ?? undefined;
+ const isSpace = opts.isSpace ?? false;
+ const isEncrypted = opts.isEncrypted ?? false;
+ const powerLevel = opts.powerLevel ?? undefined;
+ const blockFederation = opts.blockFederation ?? false;
+
+ const mx = initMatrix.matrixClient;
+ const visibility = joinRule === 'public' ? 'public' : 'private';
+ const options = {
+ creation_content: undefined,
+ name,
+ topic,
+ visibility,
+ room_alias_name: alias,
+ initial_state: [],
+ power_level_content_override: undefined,
+ };
+ if (isSpace) {
+ options.creation_content = { type: 'm.space' };
+ }
+ if (blockFederation) {
+ options.creation_content = { 'm.federate': false };
+ }
+ if (isEncrypted) {
+ options.initial_state.push({
+ type: 'm.room.encryption',
+ state_key: '',
+ content: {
+ algorithm: 'm.megolm.v1.aes-sha2',
+ },
+ });
+ }
+ if (powerLevel) {
+ options.power_level_content_override = {
+ users: {
+ [mx.getUserId()]: powerLevel,
+ },
+ };
+ }
+ if (parentId) {
+ options.initial_state.push({
+ type: 'm.space.parent',
+ state_key: parentId,
+ content: {
+ canonical: true,
+ via: [getIdServer(mx.getUserId())],
+ },
+ });
+ }
+ if (parentId && joinRule === 'restricted') {
+ options.initial_state.push({
+ type: 'm.room.join_rules',
+ content: {
+ join_rule: 'restricted',
+ allow: [{
+ type: 'm.room_membership',
+ room_id: parentId,
+ }],
+ },
+ });
+ }
+
+ const result = await create(options);
+
+ if (parentId) {
+ await mx.sendStateEvent(parentId, 'm.space.child', {
+ auto_join: false,
+ suggested: false,
+ via: [getIdServer(mx.getUserId())],
+ }, result.room_id);
+ }
+
+ return result;
+}
+
async function invite(roomId, userId) {
const mx = initMatrix.matrixClient;
export {
join, leave,
- create, invite, kick, ban, unban,
+ createDM, createRoom,
+ invite, kick, ban, unban,
setPowerLevel,
createSpaceShortcut, deleteSpaceShortcut,
};