Merge branch 'develop' into travis/message-states

This commit is contained in:
Travis Ralston 2021-03-05 09:29:39 -07:00
commit 017c2af663
47 changed files with 2360 additions and 143 deletions

View file

@ -223,7 +223,14 @@ class LoggedInView extends React.Component<IProps, IState> {
let size;
let collapsed;
const collapseConfig: ICollapseConfig = {
toggleSize: 260 - 50,
// TODO: the space panel currently does not have a fixed width,
// just the headers at each level have a max-width of 150px
// Taking 222px for the space panel for now,
// so this will look slightly off for now,
// depending on the depth of your space tree.
// To fix this, we'll need to turn toggleSize
// into a callback so it can be measured when starting the resize operation
toggleSize: 222 + 68,
onCollapsed: (_collapsed) => {
collapsed = _collapsed;
if (_collapsed) {
@ -244,6 +251,9 @@ class LoggedInView extends React.Component<IProps, IState> {
if (!collapsed) window.localStorage.setItem("mx_lhs_size", '' + size);
this.props.resizeNotifier.stopResizing();
},
isItemCollapsed: domNode => {
return domNode.classList.contains("mx_LeftPanel_minimized");
},
};
const resizer = new Resizer(this._resizeContainer.current, CollapseDistributor, collapseConfig);
resizer.setClassNames({

View file

@ -82,6 +82,8 @@ import {UIFeature} from "../../settings/UIFeature";
import { CommunityPrototypeStore } from "../../stores/CommunityPrototypeStore";
import DialPadModal from "../views/voip/DialPadModal";
import { showToast as showMobileGuideToast } from '../../toasts/MobileGuideToast';
import SpaceStore from "../../stores/SpaceStore";
import SpaceRoomDirectory from "./SpaceRoomDirectory";
/** constants for MatrixChat.state.view */
export enum Views {
@ -691,10 +693,17 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
break;
}
case Action.ViewRoomDirectory: {
const RoomDirectory = sdk.getComponent("structures.RoomDirectory");
Modal.createTrackedDialog('Room directory', '', RoomDirectory, {
initialText: payload.initialText,
}, 'mx_RoomDirectory_dialogWrapper', false, true);
if (SpaceStore.instance.activeSpace) {
Modal.createTrackedDialog("Space room directory", "", SpaceRoomDirectory, {
space: SpaceStore.instance.activeSpace,
initialText: payload.initialText,
}, "mx_SpaceRoomDirectory_dialogWrapper", false, true);
} else {
const RoomDirectory = sdk.getComponent("structures.RoomDirectory");
Modal.createTrackedDialog('Room directory', '', RoomDirectory, {
initialText: payload.initialText,
}, 'mx_RoomDirectory_dialogWrapper', false, true);
}
// View the welcome or home page if we need something to look at
this.viewSomethingBehindModal();
@ -1072,6 +1081,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
private leaveRoomWarnings(roomId: string) {
const roomToLeave = MatrixClientPeg.get().getRoom(roomId);
const isSpace = roomToLeave?.isSpaceRoom();
// Show a warning if there are additional complications.
const joinRules = roomToLeave.currentState.getStateEvents('m.room.join_rules', '');
const warnings = [];
@ -1081,7 +1091,9 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
warnings.push((
<span className="warning" key="non_public_warning">
{' '/* Whitespace, otherwise the sentences get smashed together */ }
{ _t("This room is not public. You will not be able to rejoin without an invite.") }
{ isSpace
? _t("This space is not public. You will not be able to rejoin without an invite.")
: _t("This room is not public. You will not be able to rejoin without an invite.") }
</span>
));
}
@ -1094,11 +1106,14 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
const roomToLeave = MatrixClientPeg.get().getRoom(roomId);
const warnings = this.leaveRoomWarnings(roomId);
Modal.createTrackedDialog('Leave room', '', QuestionDialog, {
title: _t("Leave room"),
const isSpace = roomToLeave?.isSpaceRoom();
Modal.createTrackedDialog(isSpace ? "Leave space" : "Leave room", '', QuestionDialog, {
title: isSpace ? _t("Leave space") : _t("Leave room"),
description: (
<span>
{ _t("Are you sure you want to leave the room '%(roomName)s'?", {roomName: roomToLeave.name}) }
{ isSpace
? _t("Are you sure you want to leave the space '%(spaceName)s'?", {spaceName: roomToLeave.name})
: _t("Are you sure you want to leave the room '%(roomName)s'?", {roomName: roomToLeave.name}) }
{ warnings }
</span>
),
@ -1112,6 +1127,10 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
const modal = Modal.createDialog(Loader, null, 'mx_Dialog_spinner');
d.finally(() => modal.close());
dis.dispatch({
action: "after_leave_room",
room_id: roomId,
});
}
},
});

View file

@ -192,6 +192,7 @@ export interface IState {
rejecting?: boolean;
rejectError?: Error;
hasPinnedWidgets?: boolean;
dragCounter: number;
}
export default class RoomView extends React.Component<IProps, IState> {
@ -242,6 +243,7 @@ export default class RoomView extends React.Component<IProps, IState> {
canReply: false,
layout: SettingsStore.getValue("layout"),
matrixClientIsReady: this.context && this.context.isInitialSyncComplete(),
dragCounter: 0,
};
this.dispatcherRef = dis.register(this.onAction);
@ -535,8 +537,8 @@ export default class RoomView extends React.Component<IProps, IState> {
if (!roomView.ondrop) {
roomView.addEventListener('drop', this.onDrop);
roomView.addEventListener('dragover', this.onDragOver);
roomView.addEventListener('dragleave', this.onDragLeaveOrEnd);
roomView.addEventListener('dragend', this.onDragLeaveOrEnd);
roomView.addEventListener('dragenter', this.onDragEnter);
roomView.addEventListener('dragleave', this.onDragLeave);
}
}
@ -580,8 +582,8 @@ export default class RoomView extends React.Component<IProps, IState> {
const roomView = this.roomView.current;
roomView.removeEventListener('drop', this.onDrop);
roomView.removeEventListener('dragover', this.onDragOver);
roomView.removeEventListener('dragleave', this.onDragLeaveOrEnd);
roomView.removeEventListener('dragend', this.onDragLeaveOrEnd);
roomView.removeEventListener('dragenter', this.onDragEnter);
roomView.removeEventListener('dragleave', this.onDragLeave);
}
dis.unregister(this.dispatcherRef);
if (this.context) {
@ -1141,6 +1143,31 @@ export default class RoomView extends React.Component<IProps, IState> {
this.updateTopUnreadMessagesBar();
};
private onDragEnter = ev => {
ev.stopPropagation();
ev.preventDefault();
this.setState({
dragCounter: this.state.dragCounter + 1,
draggingFile: true,
});
};
private onDragLeave = ev => {
ev.stopPropagation();
ev.preventDefault();
this.setState({
dragCounter: this.state.dragCounter - 1,
});
if (this.state.dragCounter === 0) {
this.setState({
draggingFile: false,
});
}
};
private onDragOver = ev => {
ev.stopPropagation();
ev.preventDefault();
@ -1148,7 +1175,6 @@ export default class RoomView extends React.Component<IProps, IState> {
ev.dataTransfer.dropEffect = 'none';
if (ev.dataTransfer.types.includes("Files") || ev.dataTransfer.types.includes("application/x-moz-file")) {
this.setState({ draggingFile: true });
ev.dataTransfer.dropEffect = 'copy';
}
};
@ -1159,14 +1185,12 @@ export default class RoomView extends React.Component<IProps, IState> {
ContentMessages.sharedInstance().sendContentListToRoom(
ev.dataTransfer.files, this.state.room.roomId, this.context,
);
this.setState({ draggingFile: false });
dis.fire(Action.FocusComposer);
};
private onDragLeaveOrEnd = ev => {
ev.stopPropagation();
ev.preventDefault();
this.setState({ draggingFile: false });
this.setState({
draggingFile: false,
dragCounter: this.state.dragCounter - 1,
});
};
private injectSticker(url, info, text) {
@ -1768,6 +1792,19 @@ export default class RoomView extends React.Component<IProps, IState> {
}
}
let fileDropTarget = null;
if (this.state.draggingFile) {
fileDropTarget = (
<div className="mx_RoomView_fileDropTarget">
<img
src={require("../../../res/img/upload-big.svg")}
className="mx_RoomView_fileDropTarget_image"
/>
{ _t("Drop file here to upload") }
</div>
);
}
// We have successfully loaded this room, and are not previewing.
// Display the "normal" room view.
@ -1891,7 +1928,6 @@ export default class RoomView extends React.Component<IProps, IState> {
room={this.state.room}
fullHeight={false}
userId={this.context.credentials.userId}
draggingFile={this.state.draggingFile}
maxHeight={this.state.auxPanelMaxHeight}
showApps={this.state.showApps}
onResize={this.onResize}
@ -2060,6 +2096,7 @@ export default class RoomView extends React.Component<IProps, IState> {
<div className="mx_RoomView_body">
{auxPanel}
<div className={timelineClasses}>
{fileDropTarget}
{topUnreadMessagesBar}
{jumpToBottom}
{messagePanel}

View file

@ -0,0 +1,576 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, {useMemo, useRef, useState} from "react";
import Room from "matrix-js-sdk/src/models/room";
import MatrixEvent from "matrix-js-sdk/src/models/event";
import {EventType, RoomType} from "matrix-js-sdk/src/@types/event";
import {MatrixClientPeg} from "../../MatrixClientPeg";
import dis from "../../dispatcher/dispatcher";
import {_t} from "../../languageHandler";
import AccessibleButton from "../views/elements/AccessibleButton";
import BaseDialog from "../views/dialogs/BaseDialog";
import FormButton from "../views/elements/FormButton";
import SearchBox from "./SearchBox";
import RoomAvatar from "../views/avatars/RoomAvatar";
import RoomName from "../views/elements/RoomName";
import {useAsyncMemo} from "../../hooks/useAsyncMemo";
import {shouldShowSpaceSettings} from "../../utils/space";
import {EnhancedMap} from "../../utils/maps";
import StyledCheckbox from "../views/elements/StyledCheckbox";
import AutoHideScrollbar from "./AutoHideScrollbar";
import BaseAvatar from "../views/avatars/BaseAvatar";
interface IProps {
space: Room;
initialText?: string;
onFinished(): void;
}
/* eslint-disable camelcase */
export interface ISpaceSummaryRoom {
canonical_alias?: string;
aliases: string[];
avatar_url?: string;
guest_can_join: boolean;
name?: string;
num_joined_members: number
room_id: string;
topic?: string;
world_readable: boolean;
num_refs: number;
room_type: string;
}
export interface ISpaceSummaryEvent {
room_id: string;
event_id: string;
origin_server_ts: number;
type: string;
state_key: string;
content: {
order?: string;
auto_join?: boolean;
via?: string;
};
}
/* eslint-enable camelcase */
interface ISubspaceProps {
space: ISpaceSummaryRoom;
event?: MatrixEvent;
editing?: boolean;
onPreviewClick?(): void;
queueAction?(action: IAction): void;
onJoinClick?(): void;
}
const SubSpace: React.FC<ISubspaceProps> = ({
space,
editing,
event,
queueAction,
onJoinClick,
onPreviewClick,
children,
}) => {
const name = space.name || space.canonical_alias || space.aliases?.[0] || _t("Unnamed Space");
const evContent = event?.getContent();
const [autoJoin, _setAutoJoin] = useState(evContent?.auto_join);
const [removed, _setRemoved] = useState(!evContent?.via);
const cli = MatrixClientPeg.get();
const cliRoom = cli.getRoom(space.room_id);
const myMembership = cliRoom?.getMyMembership();
// TODO DRY code
let actions;
if (editing && queueAction) {
if (event && cli.getRoom(event.getRoomId())?.currentState.maySendStateEvent(event.getType(), cli.getUserId())) {
const setAutoJoin = () => {
_setAutoJoin(v => {
queueAction({
event,
removed,
autoJoin: !v,
});
return !v;
});
};
const setRemoved = () => {
_setRemoved(v => {
queueAction({
event,
removed: !v,
autoJoin,
});
return !v;
});
};
if (removed) {
actions = <React.Fragment>
<FormButton kind="danger" onClick={setRemoved} label={_t("Undo")} />
</React.Fragment>;
} else {
actions = <React.Fragment>
<FormButton kind="danger" onClick={setRemoved} label={_t("Remove from Space")} />
<StyledCheckbox checked={autoJoin} onChange={setAutoJoin} />
</React.Fragment>;
}
} else {
actions = <span className="mx_SpaceRoomDirectory_actionsText">
{ _t("No permissions")}
</span>;
}
// TODO confirm remove from space click behaviour here
} else {
if (myMembership === "join") {
actions = <span className="mx_SpaceRoomDirectory_actionsText">
{ _t("You're in this space")}
</span>;
} else if (onJoinClick) {
actions = <React.Fragment>
<AccessibleButton onClick={onPreviewClick} kind="link">
{ _t("Preview") }
</AccessibleButton>
<FormButton onClick={onJoinClick} label={_t("Join")} />
</React.Fragment>
}
}
let url: string;
if (space.avatar_url) {
url = MatrixClientPeg.get().mxcUrlToHttp(
space.avatar_url,
Math.floor(24 * window.devicePixelRatio),
Math.floor(24 * window.devicePixelRatio),
"crop",
);
}
return <div className="mx_SpaceRoomDirectory_subspace">
<div className="mx_SpaceRoomDirectory_subspace_info">
<BaseAvatar name={name} idName={space.room_id} url={url} width={24} height={24} />
{ name }
<div className="mx_SpaceRoomDirectory_actions">
{ actions }
</div>
</div>
<div className="mx_SpaceRoomDirectory_subspace_children">
{ children }
</div>
</div>
};
interface IAction {
event: MatrixEvent;
removed: boolean;
autoJoin: boolean;
}
interface IRoomTileProps {
room: ISpaceSummaryRoom;
event?: MatrixEvent;
editing?: boolean;
onPreviewClick(): void;
queueAction?(action: IAction): void;
onJoinClick?(): void;
}
const RoomTile = ({ room, event, editing, queueAction, onPreviewClick, onJoinClick }: IRoomTileProps) => {
const name = room.name || room.canonical_alias || room.aliases?.[0] || _t("Unnamed Room");
const evContent = event?.getContent();
const [autoJoin, _setAutoJoin] = useState(evContent?.auto_join);
const [removed, _setRemoved] = useState(!evContent?.via);
const cli = MatrixClientPeg.get();
const cliRoom = cli.getRoom(room.room_id);
const myMembership = cliRoom?.getMyMembership();
let actions;
if (editing && queueAction) {
if (event && cli.getRoom(event.getRoomId())?.currentState.maySendStateEvent(event.getType(), cli.getUserId())) {
const setAutoJoin = () => {
_setAutoJoin(v => {
queueAction({
event,
removed,
autoJoin: !v,
});
return !v;
});
};
const setRemoved = () => {
_setRemoved(v => {
queueAction({
event,
removed: !v,
autoJoin,
});
return !v;
});
};
if (removed) {
actions = <React.Fragment>
<FormButton kind="danger" onClick={setRemoved} label={_t("Undo")} />
</React.Fragment>;
} else {
actions = <React.Fragment>
<FormButton kind="danger" onClick={setRemoved} label={_t("Remove from Space")} />
<StyledCheckbox checked={autoJoin} onChange={setAutoJoin} />
</React.Fragment>;
}
} else {
actions = <span className="mx_SpaceRoomDirectory_actionsText">
{ _t("No permissions")}
</span>;
}
// TODO confirm remove from space click behaviour here
} else {
if (myMembership === "join") {
actions = <span className="mx_SpaceRoomDirectory_actionsText">
{ _t("You're in this room")}
</span>;
} else if (onJoinClick) {
actions = <React.Fragment>
<AccessibleButton onClick={onPreviewClick} kind="link">
{ _t("Preview") }
</AccessibleButton>
<FormButton onClick={onJoinClick} label={_t("Join")} />
</React.Fragment>
}
}
let url: string;
if (room.avatar_url) {
url = cli.mxcUrlToHttp(
room.avatar_url,
Math.floor(32 * window.devicePixelRatio),
Math.floor(32 * window.devicePixelRatio),
"crop",
);
}
const content = <React.Fragment>
<BaseAvatar name={name} idName={room.room_id} url={url} width={32} height={32} />
<div className="mx_SpaceRoomDirectory_roomTile_info">
<div className="mx_SpaceRoomDirectory_roomTile_name">
{ name }
</div>
<div className="mx_SpaceRoomDirectory_roomTile_topic">
{ room.topic }
</div>
</div>
<div className="mx_SpaceRoomDirectory_roomTile_memberCount">
{ room.num_joined_members }
</div>
<div className="mx_SpaceRoomDirectory_actions">
{ actions }
</div>
</React.Fragment>;
if (editing) {
return <div className="mx_SpaceRoomDirectory_roomTile">
{ content }
</div>
}
return <AccessibleButton className="mx_SpaceRoomDirectory_roomTile" onClick={onPreviewClick}>
{ content }
</AccessibleButton>;
};
export const showRoom = (room: ISpaceSummaryRoom, viaServers?: string[], autoJoin = false) => {
// Don't let the user view a room they won't be able to either peek or join:
// fail earlier so they don't have to click back to the directory.
if (MatrixClientPeg.get().isGuest()) {
if (!room.world_readable && !room.guest_can_join) {
dis.dispatch({ action: "require_registration" });
return;
}
}
const roomAlias = getDisplayAliasForRoom(room) || undefined;
dis.dispatch({
action: "view_room",
auto_join: autoJoin,
should_peek: true,
_type: "room_directory", // instrumentation
room_alias: roomAlias,
room_id: room.room_id,
via_servers: viaServers,
oob_data: {
avatarUrl: room.avatar_url,
// XXX: This logic is duplicated from the JS SDK which would normally decide what the name is.
name: room.name || roomAlias || _t("Unnamed room"),
},
});
};
interface IHierarchyLevelProps {
spaceId: string;
rooms: Map<string, ISpaceSummaryRoom>;
editing?: boolean;
relations: EnhancedMap<string, string[]>;
parents: Set<string>;
queueAction?(action: IAction): void;
onPreviewClick(roomId: string): void;
onRemoveFromSpaceClick?(roomId: string): void;
onJoinClick?(roomId: string): void;
}
export const HierarchyLevel = ({
spaceId,
rooms,
editing,
relations,
parents,
onPreviewClick,
onJoinClick,
queueAction,
}: IHierarchyLevelProps) => {
const cli = MatrixClientPeg.get();
const space = cli.getRoom(spaceId);
// TODO respect order
const [subspaces, childRooms] = relations.get(spaceId)?.reduce((result, roomId: string) => {
if (!rooms.has(roomId)) return result; // TODO wat
result[rooms.get(roomId).room_type === RoomType.Space ? 0 : 1].push(roomId);
return result;
}, [[], []]) || [[], []];
// Don't render this subspace if it has no rooms we can show
// TODO this is broken - as a space may have subspaces we still need to show
// if (!childRooms.length) return null;
const userId = cli.getUserId();
const newParents = new Set(parents).add(spaceId);
return <React.Fragment>
{
childRooms.map(roomId => (
<RoomTile
key={roomId}
room={rooms.get(roomId)}
event={space?.currentState.maySendStateEvent(EventType.SpaceChild, userId)
? space?.currentState.getStateEvents(EventType.SpaceChild, roomId)
: undefined}
editing={editing}
queueAction={queueAction}
onPreviewClick={() => {
onPreviewClick(roomId);
}}
onJoinClick={onJoinClick ? () => {
onJoinClick(roomId);
} : undefined}
/>
))
}
{
subspaces.filter(roomId => !newParents.has(roomId)).map(roomId => (
<SubSpace
key={roomId}
space={rooms.get(roomId)}
event={space?.currentState.getStateEvents(EventType.SpaceChild, roomId)}
editing={editing}
queueAction={queueAction}
onPreviewClick={() => {
onPreviewClick(roomId);
}}
onJoinClick={() => {
onJoinClick(roomId);
}}
>
<HierarchyLevel
spaceId={roomId}
rooms={rooms}
editing={editing}
relations={relations}
parents={newParents}
onPreviewClick={onPreviewClick}
onJoinClick={onJoinClick}
queueAction={queueAction}
/>
</SubSpace>
))
}
</React.Fragment>
};
const SpaceRoomDirectory: React.FC<IProps> = ({ space, initialText = "", onFinished }) => {
// TODO pagination
const cli = MatrixClientPeg.get();
const [query, setQuery] = useState(initialText);
const [isEditing, setIsEditing] = useState(false);
const onCreateRoomClick = () => {
dis.dispatch({
action: 'view_create_room',
public: true,
});
onFinished();
};
// stored within a ref as we don't need to re-render when it changes
const pendingActions = useRef(new Map<string, IAction>());
let adminButton;
if (shouldShowSpaceSettings(cli, space)) { // TODO this is an imperfect test
const onManageButtonClicked = () => {
setIsEditing(true);
};
const onSaveButtonClicked = () => {
// TODO setBusy
pendingActions.current.forEach(({event, autoJoin, removed}) => {
const content = {
...event.getContent(),
auto_join: autoJoin,
};
if (removed) {
delete content["via"];
}
cli.sendStateEvent(event.getRoomId(), event.getType(), content, event.getStateKey());
});
setIsEditing(false);
};
if (isEditing) {
adminButton = <React.Fragment>
<FormButton label={_t("Save changes")} onClick={onSaveButtonClicked} />
<span>{ _t("All users join by default") }</span>
</React.Fragment>;
} else {
adminButton = <FormButton label={_t("Manage rooms")} onClick={onManageButtonClicked} />;
}
}
const [rooms, relations, viaMap] = useAsyncMemo(async () => {
try {
const data = await cli.getSpaceSummary(space.roomId);
const parentChildRelations = new EnhancedMap<string, string[]>();
const viaMap = new EnhancedMap<string, Set<string>>();
data.events.map((ev: ISpaceSummaryEvent) => {
if (ev.type === EventType.SpaceChild) {
parentChildRelations.getOrCreate(ev.room_id, []).push(ev.state_key);
}
if (Array.isArray(ev.content["via"])) {
const set = viaMap.getOrCreate(ev.state_key, new Set());
ev.content["via"].forEach(via => set.add(via));
}
});
return [data.rooms, parentChildRelations, viaMap];
} catch (e) {
console.error(e); // TODO
}
return [];
}, [space], []);
const roomsMap = useMemo(() => {
if (!rooms) return null;
const lcQuery = query.toLowerCase();
const filteredRooms = rooms.filter(r => {
return r.room_type === RoomType.Space // always include spaces to allow filtering of sub-space rooms
|| r.name?.toLowerCase().includes(lcQuery)
|| r.topic?.toLowerCase().includes(lcQuery);
});
return new Map<string, ISpaceSummaryRoom>(filteredRooms.map(r => [r.room_id, r]));
// const root = rooms.get(space.roomId);
}, [rooms, query]);
const title = <React.Fragment>
<RoomAvatar room={space} height={40} width={40} />
<div>
<h1>{ _t("Explore rooms") }</h1>
<div><RoomName room={space} /></div>
</div>
</React.Fragment>;
const explanation =
_t("If you can't find the room you're looking for, ask for an invite or <a>Create a new room</a>.", null,
{a: sub => {
return <AccessibleButton kind="link" onClick={onCreateRoomClick}>{sub}</AccessibleButton>;
}},
);
let content;
if (roomsMap) {
content = <AutoHideScrollbar className="mx_SpaceRoomDirectory_list">
<HierarchyLevel
spaceId={space.roomId}
rooms={roomsMap}
editing={isEditing}
relations={relations}
parents={new Set()}
queueAction={action => {
pendingActions.current.set(action.event.room_id, action);
}}
onPreviewClick={roomId => {
showRoom(roomsMap.get(roomId), Array.from(viaMap.get(roomId) || []), false);
onFinished();
}}
onJoinClick={(roomId) => {
showRoom(roomsMap.get(roomId), Array.from(viaMap.get(roomId) || []), true);
onFinished();
}}
/>
</AutoHideScrollbar>;
}
// TODO loading state/error state
return (
<BaseDialog className="mx_SpaceRoomDirectory" hasCancel={true} onFinished={onFinished} title={title}>
<div className="mx_Dialog_content">
{ explanation }
<SearchBox
className="mx_textinput_icon mx_textinput_search"
placeholder={ _t("Find a room...") }
onSearch={setQuery}
/>
<div className="mx_SpaceRoomDirectory_listHeader">
{ adminButton }
</div>
{ content }
</div>
</BaseDialog>
);
};
export default SpaceRoomDirectory;
// Similar to matrix-react-sdk's MatrixTools.getDisplayAliasForRoom
// but works with the objects we get from the public room list
function getDisplayAliasForRoom(room: ISpaceSummaryRoom) {
return room.canonical_alias || (room.aliases ? room.aliases[0] : "");
}

View file

@ -15,7 +15,7 @@ limitations under the License.
*/
import React, {RefObject, useContext, useRef, useState} from "react";
import {EventType} from "matrix-js-sdk/src/@types/event";
import {EventType, RoomType} from "matrix-js-sdk/src/@types/event";
import {Room} from "matrix-js-sdk/src/models/room";
import MatrixClientContext from "../../contexts/MatrixClientContext";
@ -24,8 +24,9 @@ import {_t} from "../../languageHandler";
import AccessibleButton from "../views/elements/AccessibleButton";
import RoomName from "../views/elements/RoomName";
import RoomTopic from "../views/elements/RoomTopic";
import InlineSpinner from "../views/elements/InlineSpinner";
import FormButton from "../views/elements/FormButton";
import {inviteMultipleToRoom, showSpaceInviteDialog} from "../../RoomInvite";
import {inviteMultipleToRoom, showRoomInviteDialog} from "../../RoomInvite";
import {useRoomMembers} from "../../hooks/useRoomMembers";
import createRoom, {IOpts, Preset} from "../../createRoom";
import Field from "../views/elements/Field";
@ -46,8 +47,13 @@ import {RightPanelPhases} from "../../stores/RightPanelStorePhases";
import {SetRightPanelPhasePayload} from "../../dispatcher/payloads/SetRightPanelPhasePayload";
import {useStateArray} from "../../hooks/useStateArray";
import SpacePublicShare from "../views/spaces/SpacePublicShare";
import {shouldShowSpaceSettings} from "../../utils/space";
import {showAddExistingRooms, showCreateNewRoom, shouldShowSpaceSettings, showSpaceSettings} from "../../utils/space";
import {HierarchyLevel, ISpaceSummaryEvent, ISpaceSummaryRoom, showRoom} from "./SpaceRoomDirectory";
import {useAsyncMemo} from "../../hooks/useAsyncMemo";
import {EnhancedMap} from "../../utils/maps";
import AutoHideScrollbar from "./AutoHideScrollbar";
import MemberAvatar from "../views/avatars/MemberAvatar";
import {useStateToggle} from "../../hooks/useStateToggle";
interface IProps {
space: Room;
@ -108,6 +114,94 @@ const SpaceLanding = ({ space, onJoinButtonClicked, onRejectButtonClicked }) =>
</div>;
}
let inviteButton;
if (myMembership === "join" && space.canInvite(userId)) {
inviteButton = (
<AccessibleButton className="mx_SpaceRoomView_landing_inviteButton" onClick={() => {
showRoomInviteDialog(space.roomId);
}}>
{ _t("Invite people") }
</AccessibleButton>
);
}
const canAddRooms = myMembership === "join" && space.currentState.maySendStateEvent(EventType.SpaceChild, userId);
const [_, forceUpdate] = useStateToggle(false); // TODO
let addRoomButtons;
if (canAddRooms) {
addRoomButtons = <React.Fragment>
<AccessibleButton className="mx_SpaceRoomView_landing_addButton" onClick={async () => {
const [added] = await showAddExistingRooms(cli, space);
if (added) {
forceUpdate();
}
}}>
{ _t("Add existing rooms & spaces") }
</AccessibleButton>
<AccessibleButton className="mx_SpaceRoomView_landing_createButton" onClick={() => {
showCreateNewRoom(cli, space);
}}>
{ _t("Create a new room") }
</AccessibleButton>
</React.Fragment>;
}
let settingsButton;
if (shouldShowSpaceSettings(cli, space)) {
settingsButton = <AccessibleButton className="mx_SpaceRoomView_landing_settingsButton" onClick={() => {
showSpaceSettings(cli, space);
}}>
{ _t("Settings") }
</AccessibleButton>;
}
const [loading, roomsMap, relations, numRooms] = useAsyncMemo(async () => {
try {
const data = await cli.getSpaceSummary(space.roomId, undefined, myMembership !== "join");
const parentChildRelations = new EnhancedMap<string, string[]>();
data.events.map((ev: ISpaceSummaryEvent) => {
if (ev.type === EventType.SpaceChild) {
parentChildRelations.getOrCreate(ev.room_id, []).push(ev.state_key);
}
});
const roomsMap = new Map<string, ISpaceSummaryRoom>(data.rooms.map(r => [r.room_id, r]));
const numRooms = data.rooms.filter(r => r.room_type !== RoomType.Space).length;
return [false, roomsMap, parentChildRelations, numRooms];
} catch (e) {
console.error(e); // TODO
}
return [false];
}, [space, _], [true]);
let previewRooms;
if (roomsMap) {
previewRooms = <AutoHideScrollbar className="mx_SpaceRoomDirectory_list">
<div className="mx_SpaceRoomDirectory_roomCount">
<h3>{ myMembership === "join" ? _t("Rooms") : _t("Default Rooms")}</h3>
<span>{ numRooms }</span>
</div>
<HierarchyLevel
spaceId={space.roomId}
rooms={roomsMap}
editing={false}
relations={relations}
parents={new Set()}
onPreviewClick={roomId => {
showRoom(roomsMap.get(roomId), [], false); // TODO
}}
/>
</AutoHideScrollbar>;
} else if (loading) {
previewRooms = <InlineSpinner />;
} else {
previewRooms = <p>{_t("Your server does not support showing space hierarchies.")}</p>;
}
return <div className="mx_SpaceRoomView_landing">
<RoomAvatar room={space} height={80} width={80} viewAvatarOnClick={true} />
<div className="mx_SpaceRoomView_landing_name">
@ -167,6 +261,13 @@ const SpaceLanding = ({ space, onJoinButtonClicked, onRejectButtonClicked }) =>
<RoomTopic room={space} />
</div>
{ joinButtons }
<div className="mx_SpaceRoomView_landing_adminButtons">
{ inviteButton }
{ addRoomButtons }
{ settingsButton }
</div>
{ previewRooms }
</div>;
};
@ -361,7 +462,7 @@ const SpaceSetupPrivateInvite = ({ space, onFinished }) => {
<div className="mx_SpaceRoomView_inviteTeammates_buttons">
<AccessibleButton
className="mx_SpaceRoomView_inviteTeammates_inviteDialogButton"
onClick={() => showSpaceInviteDialog(space.roomId)}
onClick={() => showRoomInviteDialog(space.roomId)}
>
{ _t("Invite by username") }
</AccessibleButton>

View file

@ -0,0 +1,208 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, {useState} from "react";
import classNames from "classnames";
import {Room} from "matrix-js-sdk/src/models/room";
import {MatrixClient} from "matrix-js-sdk/src/client";
import {_t} from '../../../languageHandler';
import {IDialogProps} from "./IDialogProps";
import BaseDialog from "./BaseDialog";
import FormButton from "../elements/FormButton";
import Dropdown from "../elements/Dropdown";
import SearchBox from "../../structures/SearchBox";
import SpaceStore from "../../../stores/SpaceStore";
import RoomAvatar from "../avatars/RoomAvatar";
import {getDisplayAliasForRoom} from "../../../Rooms";
import AccessibleButton from "../elements/AccessibleButton";
import AutoHideScrollbar from "../../structures/AutoHideScrollbar";
import {allSettled} from "../../../utils/promise";
import DMRoomMap from "../../../utils/DMRoomMap";
import {calculateRoomVia} from "../../../utils/permalinks/Permalinks";
import StyledCheckbox from "../elements/StyledCheckbox";
interface IProps extends IDialogProps {
matrixClient: MatrixClient;
space: Room;
onCreateRoomClick(cli: MatrixClient, space: Room): void;
}
const Entry = ({ room, checked, onChange }) => {
return <div className="mx_AddExistingToSpaceDialog_entry">
<RoomAvatar room={room} height={32} width={32} />
<span className="mx_AddExistingToSpaceDialog_entry_name">{ room.name }</span>
<StyledCheckbox onChange={(e) => onChange(e.target.checked)} checked={checked} />
</div>;
};
const AddExistingToSpaceDialog: React.FC<IProps> = ({ matrixClient: cli, space, onCreateRoomClick, onFinished }) => {
const [query, setQuery] = useState("");
const lcQuery = query.toLowerCase();
const [selectedSpace, setSelectedSpace] = useState(space);
const [selectedToAdd, setSelectedToAdd] = useState(new Set<Room>());
const existingSubspaces = SpaceStore.instance.getChildSpaces(space.roomId);
const existingSubspacesSet = new Set(existingSubspaces);
const spaces = SpaceStore.instance.getSpaces().filter(s => {
return !existingSubspacesSet.has(s) // not already in space
&& space !== s // not the top-level space
&& selectedSpace !== s // not the selected space
&& s.name.toLowerCase().includes(lcQuery); // contains query
});
const existingRooms = SpaceStore.instance.getChildRooms(space.roomId);
const existingRoomsSet = new Set(existingRooms);
const rooms = cli.getVisibleRooms().filter(room => {
return !existingRoomsSet.has(room) // not already in space
&& room.name.toLowerCase().includes(lcQuery) // contains query
&& !DMRoomMap.shared().getUserIdForRoomId(room.roomId); // not a DM
});
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
let spaceOptionSection;
if (existingSubspacesSet.size > 0) {
const options = [space, ...existingSubspaces].map((space) => {
const classes = classNames("mx_AddExistingToSpaceDialog_dropdownOption", {
mx_AddExistingToSpaceDialog_dropdownOptionActive: space === selectedSpace,
});
return <div key={space.roomId} className={classes}>
<RoomAvatar room={space} width={24} height={24} />
{ space.name || getDisplayAliasForRoom(space) || space.roomId }
</div>;
});
spaceOptionSection = (
<Dropdown
id="mx_SpaceSelectDropdown"
onOptionChange={(key: string) => {
setSelectedSpace(existingSubspaces.find(space => space.roomId === key) || space);
}}
value={selectedSpace.roomId}
label={_t("Space selection")}
>
{ options }
</Dropdown>
);
} else {
spaceOptionSection = <div className="mx_AddExistingToSpaceDialog_onlySpace">
{ space.name || getDisplayAliasForRoom(space) || space.roomId }
</div>;
}
const title = <React.Fragment>
<RoomAvatar room={selectedSpace} height={40} width={40} />
<div>
<h1>{ _t("Add existing spaces/rooms") }</h1>
{ spaceOptionSection }
</div>
</React.Fragment>;
return <BaseDialog
title={title}
className="mx_AddExistingToSpaceDialog"
contentId="mx_AddExistingToSpaceDialog"
onFinished={onFinished}
fixedWidth={false}
>
{ error && <div className="mx_AddExistingToSpaceDialog_errorText">{ error }</div> }
<SearchBox
className="mx_textinput_icon mx_textinput_search"
placeholder={ _t("Filter your rooms and spaces") }
onSearch={setQuery}
/>
<AutoHideScrollbar className="mx_AddExistingToSpaceDialog_content" id="mx_AddExistingToSpaceDialog">
{ spaces.length > 0 ? (
<div className="mx_AddExistingToSpaceDialog_section mx_AddExistingToSpaceDialog_section_spaces">
<h3>{ _t("Spaces") }</h3>
{ spaces.map(space => {
return <Entry
key={space.roomId}
room={space}
checked={selectedToAdd.has(space)}
onChange={(checked) => {
if (checked) {
selectedToAdd.add(space);
} else {
selectedToAdd.delete(space);
}
setSelectedToAdd(new Set(selectedToAdd));
}}
/>;
}) }
</div>
) : null }
{ rooms.length > 0 ? (
<div className="mx_AddExistingToSpaceDialog_section">
<h3>{ _t("Rooms") }</h3>
{ rooms.map(room => {
return <Entry
key={room.roomId}
room={room}
checked={selectedToAdd.has(room)}
onChange={(checked) => {
if (checked) {
selectedToAdd.add(room);
} else {
selectedToAdd.delete(room);
}
setSelectedToAdd(new Set(selectedToAdd));
}}
/>;
}) }
</div>
) : undefined }
{ spaces.length + rooms.length < 1 ? <span className="mx_AddExistingToSpaceDialog_noResults">
{ _t("No results") }
</span> : undefined }
</AutoHideScrollbar>
<div className="mx_AddExistingToSpaceDialog_footer">
<span>
<div>{ _t("Don't want to add an existing room?") }</div>
<AccessibleButton onClick={() => onCreateRoomClick(cli, space)} kind="link">
{ _t("Create a new room") }
</AccessibleButton>
</span>
<FormButton
label={busy ? _t("Applying...") : _t("Apply")}
disabled={busy || selectedToAdd.size < 1}
onClick={async () => {
setBusy(true);
try {
await allSettled(Array.from(selectedToAdd).map((room) =>
SpaceStore.instance.addRoomToSpace(space, room.roomId, calculateRoomVia(room))));
onFinished(true);
} catch (e) {
console.error("Failed to add rooms to space", e);
setError(_t("Failed to add rooms to space"));
}
setBusy(false);
}}
/>
</div>
</BaseDialog>;
};
export default AddExistingToSpaceDialog;

View file

@ -17,6 +17,8 @@ limitations under the License.
import React from 'react';
import PropTypes from 'prop-types';
import {Room} from "matrix-js-sdk/src/models/room";
import * as sdk from '../../../index';
import SdkConfig from '../../../SdkConfig';
import withValidation from '../elements/Validation';
@ -30,6 +32,7 @@ export default class CreateRoomDialog extends React.Component {
static propTypes = {
onFinished: PropTypes.func.isRequired,
defaultPublic: PropTypes.bool,
parentSpace: PropTypes.instanceOf(Room),
};
constructor(props) {
@ -85,6 +88,10 @@ export default class CreateRoomDialog extends React.Component {
opts.associatedWithCommunity = CommunityPrototypeStore.instance.getSelectedCommunityId();
}
if (this.props.parentSpace) {
opts.parentSpace = this.props.parentSpace;
}
return opts;
}

View file

@ -27,7 +27,7 @@ export default class InfoDialog extends React.Component {
className: PropTypes.string,
title: PropTypes.string,
description: PropTypes.node,
button: PropTypes.string,
button: PropTypes.oneOfType(PropTypes.string, PropTypes.bool),
onFinished: PropTypes.func,
hasCloseButton: PropTypes.bool,
onKeyDown: PropTypes.func,
@ -60,11 +60,11 @@ export default class InfoDialog extends React.Component {
<div className={classNames("mx_Dialog_content", this.props.className)} id="mx_Dialog_content">
{ this.props.description }
</div>
<DialogButtons primaryButton={this.props.button || _t('OK')}
{ this.props.button !== false && <DialogButtons primaryButton={this.props.button || _t('OK')}
onPrimaryButtonClick={this.onFinished}
hasCancel={false}
>
</DialogButtons>
</DialogButtons> }
</BaseDialog>
);
}

View file

@ -0,0 +1,162 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, {useState} from 'react';
import {Room} from "matrix-js-sdk/src/models/room";
import {MatrixClient} from "matrix-js-sdk/src/client";
import {EventType} from "matrix-js-sdk/src/@types/event";
import {_t} from '../../../languageHandler';
import {IDialogProps} from "./IDialogProps";
import BaseDialog from "./BaseDialog";
import DevtoolsDialog from "./DevtoolsDialog";
import SpaceBasicSettings from '../spaces/SpaceBasicSettings';
import {getTopic} from "../elements/RoomTopic";
import {avatarUrlForRoom} from "../../../Avatar";
import ToggleSwitch from "../elements/ToggleSwitch";
import AccessibleButton from "../elements/AccessibleButton";
import FormButton from "../elements/FormButton";
import Modal from "../../../Modal";
import defaultDispatcher from "../../../dispatcher/dispatcher";
import {allSettled} from "../../../utils/promise";
import {useDispatcher} from "../../../hooks/useDispatcher";
interface IProps extends IDialogProps {
matrixClient: MatrixClient;
space: Room;
}
const SpaceSettingsDialog: React.FC<IProps> = ({ matrixClient: cli, space, onFinished }) => {
useDispatcher(defaultDispatcher, ({action, ...params}) => {
if (action === "after_leave_room" && params.room_id === space.roomId) {
onFinished(false);
}
});
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const userId = cli.getUserId();
const [newAvatar, setNewAvatar] = useState<File>(null); // undefined means to remove avatar
const canSetAvatar = space.currentState.maySendStateEvent(EventType.RoomAvatar, userId);
const avatarChanged = newAvatar !== null;
const [name, setName] = useState<string>(space.name);
const canSetName = space.currentState.maySendStateEvent(EventType.RoomName, userId);
const nameChanged = name !== space.name;
const currentTopic = getTopic(space);
const [topic, setTopic] = useState<string>(currentTopic);
const canSetTopic = space.currentState.maySendStateEvent(EventType.RoomTopic, userId);
const topicChanged = topic !== currentTopic;
const currentJoinRule = space.getJoinRule();
const [joinRule, setJoinRule] = useState(currentJoinRule);
const canSetJoinRule = space.currentState.maySendStateEvent(EventType.RoomJoinRules, userId);
const joinRuleChanged = joinRule !== currentJoinRule;
const onSave = async () => {
setBusy(true);
const promises = [];
if (avatarChanged) {
promises.push(cli.sendStateEvent(space.roomId, EventType.RoomAvatar, {
url: await cli.uploadContent(newAvatar),
}, ""));
}
if (nameChanged) {
promises.push(cli.setRoomName(space.roomId, name));
}
if (topicChanged) {
promises.push(cli.setRoomTopic(space.roomId, topic));
}
if (joinRuleChanged) {
promises.push(cli.sendStateEvent(space.roomId, EventType.RoomJoinRules, { join_rule: joinRule }, ""));
}
const results = await allSettled(promises);
setBusy(false);
const failures = results.filter(r => r.status === "rejected");
if (failures.length > 0) {
console.error("Failed to save space settings: ", failures);
setError(_t("Failed to save space settings."));
}
};
return <BaseDialog
title={_t("Space settings")}
className="mx_SpaceSettingsDialog"
contentId="mx_SpaceSettingsDialog"
onFinished={onFinished}
fixedWidth={false}
>
<div className="mx_SpaceSettingsDialog_content" id="mx_SpaceSettingsDialog">
<div>{ _t("Edit settings relating to your space.") }</div>
{ error && <div className="mx_SpaceRoomView_errorText">{ error }</div> }
<SpaceBasicSettings
avatarUrl={avatarUrlForRoom(space, 80, 80, "crop")}
avatarDisabled={!canSetAvatar}
setAvatar={setNewAvatar}
name={name}
nameDisabled={!canSetName}
setName={setName}
topic={topic}
topicDisabled={!canSetTopic}
setTopic={setTopic}
/>
<div>
{ _t("Make this space private") }
<ToggleSwitch
checked={joinRule === "private"}
onChange={checked => setJoinRule(checked ? "private" : "invite")}
disabled={!canSetJoinRule}
aria-label={_t("Make this space private")}
/>
</div>
<FormButton
kind="danger"
label={_t("Leave Space")}
onClick={() => {
defaultDispatcher.dispatch({
action: "leave_room",
room_id: space.roomId,
});
}}
/>
<div className="mx_SpaceSettingsDialog_buttons">
<AccessibleButton onClick={() => Modal.createDialog(DevtoolsDialog, {roomId: space.roomId})}>
{ _t("View dev tools") }
</AccessibleButton>
<AccessibleButton onClick={onFinished} disabled={busy} kind="link">
{ _t("Cancel") }
</AccessibleButton>
<FormButton onClick={onSave} disabled={busy} label={busy ? _t("Saving...") : _t("Save Changes")} />
</div>
</div>
</BaseDialog>;
};
export default SpaceSettingsDialog;

View file

@ -60,7 +60,9 @@ import QuestionDialog from "../dialogs/QuestionDialog";
import ConfirmUserActionDialog from "../dialogs/ConfirmUserActionDialog";
import InfoDialog from "../dialogs/InfoDialog";
import { EventType } from "matrix-js-sdk/src/@types/event";
import {SetRightPanelPhasePayload} from "../../../dispatcher/payloads/SetRightPanelPhasePayload";
import { SetRightPanelPhasePayload } from "../../../dispatcher/payloads/SetRightPanelPhasePayload";
import RoomAvatar from "../avatars/RoomAvatar";
import RoomName from "../elements/RoomName";
interface IDevice {
deviceId: string;
@ -302,7 +304,8 @@ const UserOptionsSection: React.FC<{
member: RoomMember;
isIgnored: boolean;
canInvite: boolean;
}> = ({member, isIgnored, canInvite}) => {
isSpace?: boolean;
}> = ({member, isIgnored, canInvite, isSpace}) => {
const cli = useContext(MatrixClientContext);
let ignoreButton = null;
@ -342,7 +345,7 @@ const UserOptionsSection: React.FC<{
</AccessibleButton>
);
if (member.roomId) {
if (member.roomId && !isSpace) {
const onReadReceiptButton = function() {
const room = cli.getRoom(member.roomId);
dis.dispatch({
@ -434,14 +437,18 @@ const UserOptionsSection: React.FC<{
);
};
const warnSelfDemote = async () => {
const warnSelfDemote = async (isSpace) => {
const {finished} = Modal.createTrackedDialog('Demoting Self', '', QuestionDialog, {
title: _t("Demote yourself?"),
description:
<div>
{ _t("You will not be able to undo this change as you are demoting yourself, " +
"if you are the last privileged user in the room it will be impossible " +
"to regain privileges.") }
{ isSpace
? _t("You will not be able to undo this change as you are demoting yourself, " +
"if you are the last privileged user in the space it will be impossible " +
"to regain privileges.")
: _t("You will not be able to undo this change as you are demoting yourself, " +
"if you are the last privileged user in the room it will be impossible " +
"to regain privileges.") }
</div>,
button: _t("Demote"),
});
@ -717,7 +724,7 @@ const MuteToggleButton: React.FC<IBaseRoomProps> = ({member, room, powerLevels,
// if muting self, warn as it may be irreversible
if (target === cli.getUserId()) {
try {
if (!(await warnSelfDemote())) return;
if (!(await warnSelfDemote(room?.isSpaceRoom()))) return;
} catch (e) {
console.error("Failed to warn about self demotion: ", e);
return;
@ -806,7 +813,7 @@ const RoomAdminToolsContainer: React.FC<IBaseRoomProps> = ({
if (canAffectUser && me.powerLevel >= kickPowerLevel) {
kickButton = <RoomKickButton member={member} startUpdating={startUpdating} stopUpdating={stopUpdating} />;
}
if (me.powerLevel >= redactPowerLevel) {
if (me.powerLevel >= redactPowerLevel && !room.isSpaceRoom()) {
redactButton = (
<RedactMessagesButton member={member} startUpdating={startUpdating} stopUpdating={stopUpdating} />
);
@ -1085,7 +1092,7 @@ const PowerLevelEditor: React.FC<{
} else if (myUserId === target) {
// If we are changing our own PL it can only ever be decreasing, which we cannot reverse.
try {
if (!(await warnSelfDemote())) return;
if (!(await warnSelfDemote(room?.isSpaceRoom()))) return;
} catch (e) {
console.error("Failed to warn about self demotion: ", e);
}
@ -1315,12 +1322,10 @@ const BasicUserInfo: React.FC<{
if (!isRoomEncrypted) {
if (!cryptoEnabled) {
text = _t("This client does not support end-to-end encryption.");
} else if (room) {
} else if (room && !room.isSpaceRoom()) {
text = _t("Messages in this room are not end-to-end encrypted.");
} else {
// TODO what to render for GroupMember
}
} else {
} else if (!room.isSpaceRoom()) {
text = _t("Messages in this room are end-to-end encrypted.");
}
@ -1381,7 +1386,9 @@ const BasicUserInfo: React.FC<{
<UserOptionsSection
canInvite={roomPermissions.canInvite}
isIgnored={isIgnored}
member={member} />
member={member}
isSpace={room?.isSpaceRoom()}
/>
{ adminToolsContainer }
@ -1498,7 +1505,7 @@ interface IProps {
user: Member;
groupId?: string;
room?: Room;
phase: RightPanelPhases.RoomMemberInfo | RightPanelPhases.GroupMemberInfo;
phase: RightPanelPhases.RoomMemberInfo | RightPanelPhases.GroupMemberInfo | RightPanelPhases.SpaceMemberInfo;
onClose(): void;
}
@ -1542,7 +1549,9 @@ const UserInfo: React.FC<Props> = ({
previousPhase = RightPanelPhases.RoomMemberInfo;
refireParams = {member: member};
} else if (room) {
previousPhase = RightPanelPhases.RoomMemberList;
previousPhase = previousPhase = room.isSpaceRoom()
? RightPanelPhases.SpaceMemberList
: RightPanelPhases.RoomMemberList;
}
const onEncryptionPanelClose = () => {
@ -1557,6 +1566,7 @@ const UserInfo: React.FC<Props> = ({
switch (phase) {
case RightPanelPhases.RoomMemberInfo:
case RightPanelPhases.GroupMemberInfo:
case RightPanelPhases.SpaceMemberInfo:
content = (
<BasicUserInfo
room={room}
@ -1587,7 +1597,18 @@ const UserInfo: React.FC<Props> = ({
}
}
const header = <UserInfoHeader member={member} e2eStatus={e2eStatus} />;
let scopeHeader;
if (room?.isSpaceRoom()) {
scopeHeader = <div className="mx_RightPanel_scopeHeader">
<RoomAvatar room={room} height={32} width={32} />
<RoomName room={room} />
</div>;
}
const header = <React.Fragment>
{ scopeHeader }
<UserInfoHeader member={member} e2eStatus={e2eStatus} />
</React.Fragment>;
return <BaseCard
className={classes.join(" ")}
header={header}

View file

@ -100,10 +100,11 @@ export default class RoomProfileSettings extends React.Component {
const newState = {};
// TODO: What do we do about errors?
const displayName = this.state.displayName.trim();
if (this.state.originalDisplayName !== this.state.displayName) {
await client.setRoomName(this.props.roomId, this.state.displayName);
newState.originalDisplayName = this.state.displayName;
await client.setRoomName(this.props.roomId, displayName);
newState.originalDisplayName = displayName;
newState.displayName = displayName;
}
if (this.state.avatarFile) {

View file

@ -17,10 +17,8 @@ limitations under the License.
import React from 'react';
import {MatrixClientPeg} from "../../../MatrixClientPeg";
import { Room } from 'matrix-js-sdk/src/models/room'
import * as sdk from '../../../index';
import dis from "../../../dispatcher/dispatcher";
import AppsDrawer from './AppsDrawer';
import { _t } from '../../../languageHandler';
import classNames from 'classnames';
import RateLimitedFunc from '../../../ratelimitedfunc';
import SettingsStore from "../../../settings/SettingsStore";
@ -36,9 +34,6 @@ interface IProps {
userId: string,
showApps: boolean, // Render apps
// set to true to show the file drop target
draggingFile: boolean,
// maxHeight attribute for the aux panel and the video
// therein
maxHeight: number,
@ -149,21 +144,6 @@ export default class AuxPanel extends React.Component<IProps, IState> {
}
render() {
const TintableSvg = sdk.getComponent("elements.TintableSvg");
let fileDropTarget = null;
if (this.props.draggingFile) {
fileDropTarget = (
<div className="mx_RoomView_fileDropTarget">
<div className="mx_RoomView_fileDropTargetLabel" title={_t("Drop File Here")}>
<TintableSvg src={require("../../../../res/img/upload-big.svg")} width="45" height="59" />
<br />
{ _t("Drop file here to upload") }
</div>
</div>
);
}
const callView = (
<CallViewForRoom
roomId={this.props.room.roomId}
@ -246,7 +226,6 @@ export default class AuxPanel extends React.Component<IProps, IState> {
<AutoHideScrollbar className={classes} style={style} >
{ stateViews }
{ appsDrawer }
{ fileDropTarget }
{ callView }
{ this.props.children }
</AutoHideScrollbar>

View file

@ -27,6 +27,8 @@ import * as sdk from "../../../index";
import {CommunityPrototypeStore} from "../../../stores/CommunityPrototypeStore";
import BaseCard from "../right_panel/BaseCard";
import {RightPanelPhases} from "../../../stores/RightPanelStorePhases";
import RoomAvatar from "../avatars/RoomAvatar";
import RoomName from "../elements/RoomName";
const INITIAL_LOAD_NUM_MEMBERS = 30;
const INITIAL_LOAD_NUM_INVITED = 5;
@ -456,6 +458,8 @@ export default class MemberList extends React.Component {
const chat = CommunityPrototypeStore.instance.getSelectedCommunityGeneralChat();
if (chat && chat.roomId === this.props.roomId) {
inviteButtonText = _t("Invite to this community");
} else if (room.isSpaceRoom()) {
inviteButtonText = _t("Invite to this space");
}
const AccessibleButton = sdk.getComponent("elements.AccessibleButton");
@ -483,12 +487,26 @@ export default class MemberList extends React.Component {
onSearch={ this.onSearchQueryChanged } />
);
let previousPhase = RightPanelPhases.RoomSummary;
// We have no previousPhase for when viewing a MemberList from a Space
let scopeHeader;
if (room?.isSpaceRoom()) {
previousPhase = undefined;
scopeHeader = <div className="mx_RightPanel_scopeHeader">
<RoomAvatar room={room} height={32} width={32} />
<RoomName room={room} />
</div>;
}
return <BaseCard
className="mx_MemberList"
header={inviteButton}
header={<React.Fragment>
{ scopeHeader }
{ inviteButton }
</React.Fragment>}
footer={footer}
onClose={this.props.onClose}
previousPhase={RightPanelPhases.RoomSummary}
previousPhase={previousPhase}
>
<div className="mx_MemberList_wrapper">
<TruncatedList className="mx_MemberList_section mx_MemberList_joined" truncateAt={this.state.truncateAtJoined}

View file

@ -47,6 +47,9 @@ import { IconizedContextMenuOption, IconizedContextMenuOptionList } from "../con
import AccessibleButton from "../elements/AccessibleButton";
import { CommunityPrototypeStore } from "../../../stores/CommunityPrototypeStore";
import CallHandler from "../../../CallHandler";
import SpaceStore from "../../../stores/SpaceStore";
import { showAddExistingRooms, showCreateNewRoom } from "../../../utils/space";
import { EventType } from "matrix-js-sdk/src/@types/event";
interface IProps {
onKeyDown: (ev: React.KeyboardEvent) => void;
@ -152,6 +155,50 @@ const TAG_AESTHETICS: ITagAestheticsMap = {
defaultHidden: false,
addRoomLabel: _td("Add room"),
addRoomContextMenu: (onFinished: () => void) => {
if (SpaceStore.instance.activeSpace) {
const canAddRooms = SpaceStore.instance.activeSpace.currentState.maySendStateEvent(EventType.SpaceChild,
MatrixClientPeg.get().getUserId());
return <IconizedContextMenuOptionList first>
<IconizedContextMenuOption
label={_t("Create new room")}
iconClassName="mx_RoomList_iconPlus"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onFinished();
showCreateNewRoom(MatrixClientPeg.get(), SpaceStore.instance.activeSpace);
}}
disabled={!canAddRooms}
tooltip={canAddRooms ? undefined
: _t("You do not have permissions to create new rooms in this space")}
/>
<IconizedContextMenuOption
label={_t("Add existing room")}
iconClassName="mx_RoomList_iconHash"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onFinished();
showAddExistingRooms(MatrixClientPeg.get(), SpaceStore.instance.activeSpace);
}}
disabled={!canAddRooms}
tooltip={canAddRooms ? undefined
: _t("You do not have permissions to add rooms to this space")}
/>
<IconizedContextMenuOption
label={_t("Explore space rooms")}
iconClassName="mx_RoomList_iconExplore"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onFinished();
defaultDispatcher.fire(Action.ViewRoomDirectory);
}}
/>
</IconizedContextMenuOptionList>;
}
return <IconizedContextMenuOptionList first>
<IconizedContextMenuOption
label={_t("Create new room")}

View file

@ -404,7 +404,9 @@ export default class SendMessageComposer extends React.Component {
this._editorRef.clearUndoHistory();
this._editorRef.focus();
this._clearStoredEditorState();
dis.dispatch({action: "scroll_to_bottom"});
if (SettingsStore.getValue("scrollToBottomOnMessageSent")) {
dis.dispatch({action: "scroll_to_bottom"});
}
}
componentWillUnmount() {

View file

@ -23,6 +23,8 @@ import dis from "../../../dispatcher/dispatcher";
import * as sdk from "../../../index";
import Modal from "../../../Modal";
import {isValid3pidInvite} from "../../../RoomInvite";
import RoomAvatar from "../avatars/RoomAvatar";
import RoomName from "../elements/RoomName";
export default class ThirdPartyMemberInfo extends React.Component {
static propTypes = {
@ -32,14 +34,14 @@ export default class ThirdPartyMemberInfo extends React.Component {
constructor(props) {
super(props);
const room = MatrixClientPeg.get().getRoom(this.props.event.getRoomId());
const me = room.getMember(MatrixClientPeg.get().getUserId());
const powerLevels = room.currentState.getStateEvents("m.room.power_levels", "");
this.room = MatrixClientPeg.get().getRoom(this.props.event.getRoomId());
const me = this.room.getMember(MatrixClientPeg.get().getUserId());
const powerLevels = this.room.currentState.getStateEvents("m.room.power_levels", "");
let kickLevel = powerLevels ? powerLevels.getContent().kick : 50;
if (typeof(kickLevel) !== 'number') kickLevel = 50;
const sender = room.getMember(this.props.event.getSender());
const sender = this.room.getMember(this.props.event.getSender());
this.state = {
stateKey: this.props.event.getStateKey(),
@ -119,9 +121,18 @@ export default class ThirdPartyMemberInfo extends React.Component {
);
}
let scopeHeader;
if (this.room.isSpaceRoom()) {
scopeHeader = <div className="mx_RightPanel_scopeHeader">
<RoomAvatar room={this.room} height={32} width={32} />
<RoomName room={this.room} />
</div>;
}
// We shamelessly rip off the MemberInfo styles here.
return (
<div className="mx_MemberInfo" role="tabpanel">
{ scopeHeader }
<div className="mx_MemberInfo_name">
<AccessibleButton className="mx_MemberInfo_cancel"
onClick={this.onCancel}

View file

@ -81,10 +81,12 @@ export default class ProfileSettings extends React.Component {
const client = MatrixClientPeg.get();
const newState = {};
const displayName = this.state.displayName.trim();
try {
if (this.state.originalDisplayName !== this.state.displayName) {
await client.setDisplayName(this.state.displayName);
newState.originalDisplayName = this.state.displayName;
await client.setDisplayName(displayName);
newState.originalDisplayName = displayName;
newState.displayName = displayName;
}
if (this.state.avatarFile) {

View file

@ -48,6 +48,7 @@ export default class PreferencesUserSettingsTab extends React.Component {
'showRedactions',
'enableSyntaxHighlightLanguageDetection',
'expandCodeByDefault',
'scrollToBottomOnMessageSent',
'showCodeLineNumbers',
'showJoinLeaves',
'showAvatarChanges',

View file

@ -22,7 +22,7 @@ import AccessibleButton from "../elements/AccessibleButton";
import {copyPlaintext} from "../../../utils/strings";
import {sleep} from "../../../utils/promise";
import {RoomPermalinkCreator} from "../../../utils/permalinks/Permalinks";
import {showSpaceInviteDialog} from "../../../RoomInvite";
import {showRoomInviteDialog} from "../../../RoomInvite";
interface IProps {
space: Room;
@ -53,7 +53,7 @@ const SpacePublicShare = ({ space, onFinished }: IProps) => {
<AccessibleButton
className="mx_SpacePublicShare_inviteButton"
onClick={() => {
showSpaceInviteDialog(space.roomId);
showRoomInviteDialog(space.roomId);
onFinished();
}}
>

View file

@ -23,7 +23,27 @@ import SpaceStore from "../../../stores/SpaceStore";
import NotificationBadge from "../rooms/NotificationBadge";
import {RovingAccessibleButton} from "../../../accessibility/roving/RovingAccessibleButton";
import {RovingAccessibleTooltipButton} from "../../../accessibility/roving/RovingAccessibleTooltipButton";
import IconizedContextMenu, {
IconizedContextMenuOption,
IconizedContextMenuOptionList,
} from "../context_menus/IconizedContextMenu";
import {_t} from "../../../languageHandler";
import {ContextMenuTooltipButton} from "../../../accessibility/context_menu/ContextMenuTooltipButton";
import {toRightOf} from "../../structures/ContextMenu";
import {shouldShowSpaceSettings, showCreateNewRoom, showSpaceSettings} from "../../../utils/space";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import {ButtonEvent} from "../elements/AccessibleButton";
import defaultDispatcher from "../../../dispatcher/dispatcher";
import Modal from "../../../Modal";
import SpacePublicShare from "./SpacePublicShare";
import {Action} from "../../../dispatcher/actions";
import RoomViewStore from "../../../stores/RoomViewStore";
import {SetRightPanelPhasePayload} from "../../../dispatcher/payloads/SetRightPanelPhasePayload";
import {RightPanelPhases} from "../../../stores/RightPanelStorePhases";
import {showRoomInviteDialog} from "../../../RoomInvite";
import InfoDialog from "../dialogs/InfoDialog";
import {EventType} from "matrix-js-sdk/src/@types/event";
import SpaceRoomDirectory from "../../structures/SpaceRoomDirectory";
interface IItemProps {
space?: Room;
@ -78,6 +98,200 @@ export class SpaceItem extends React.PureComponent<IItemProps, IItemState> {
SpaceStore.instance.setActiveSpace(this.props.space);
};
private onMenuOpenClick = (ev: React.MouseEvent) => {
ev.preventDefault();
ev.stopPropagation();
const target = ev.target as HTMLButtonElement;
this.setState({contextMenuPosition: target.getBoundingClientRect()});
};
private onMenuClose = () => {
this.setState({contextMenuPosition: null});
};
private onHomeClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
defaultDispatcher.dispatch({
action: "view_room",
room_id: this.props.space.roomId,
});
this.setState({contextMenuPosition: null}); // also close the menu
};
private onInviteClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
if (this.props.space.getJoinRule() === "public") {
const modal = Modal.createTrackedDialog("Space Invite", "User Menu", InfoDialog, {
title: _t("Invite members"),
description: <React.Fragment>
<span>{ _t("Share your public space") }</span>
<SpacePublicShare space={this.props.space} onFinished={() => modal.close()} />
</React.Fragment>,
fixedWidth: false,
button: false,
className: "mx_SpacePanel_sharePublicSpace",
hasCloseButton: true,
});
} else {
showRoomInviteDialog(this.props.space.roomId);
}
this.setState({contextMenuPosition: null}); // also close the menu
};
private onSettingsClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
showSpaceSettings(this.context, this.props.space);
this.setState({contextMenuPosition: null}); // also close the menu
};
private onLeaveClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
defaultDispatcher.dispatch({
action: "leave_room",
room_id: this.props.space.roomId,
});
this.setState({contextMenuPosition: null}); // also close the menu
};
private onNewRoomClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
showCreateNewRoom(this.context, this.props.space);
this.setState({contextMenuPosition: null}); // also close the menu
};
private onMembersClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
if (!RoomViewStore.getRoomId()) {
defaultDispatcher.dispatch({
action: "view_room",
room_id: this.props.space.roomId,
}, true);
}
defaultDispatcher.dispatch<SetRightPanelPhasePayload>({
action: Action.SetRightPanelPhase,
phase: RightPanelPhases.SpaceMemberList,
refireParams: { space: this.props.space },
});
this.setState({contextMenuPosition: null}); // also close the menu
};
private onExploreRoomsClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
Modal.createTrackedDialog("Space room directory", "Space panel", SpaceRoomDirectory, {
space: this.props.space,
}, "mx_SpaceRoomDirectory_dialogWrapper", false, true);
this.setState({contextMenuPosition: null}); // also close the menu
};
private renderContextMenu(): React.ReactElement {
let contextMenu = null;
if (this.state.contextMenuPosition) {
const userId = this.context.getUserId();
let inviteOption;
if (this.props.space.canInvite(userId)) {
inviteOption = (
<IconizedContextMenuOption
className="mx_SpacePanel_contextMenu_inviteButton"
iconClassName="mx_SpacePanel_iconInvite"
label={_t("Invite people")}
onClick={this.onInviteClick}
/>
);
}
let settingsOption;
let leaveSection;
if (shouldShowSpaceSettings(this.context, this.props.space)) {
settingsOption = (
<IconizedContextMenuOption
iconClassName="mx_SpacePanel_iconSettings"
label={_t("Settings")}
onClick={this.onSettingsClick}
/>
);
} else {
leaveSection = <IconizedContextMenuOptionList red first>
<IconizedContextMenuOption
iconClassName="mx_SpacePanel_iconLeave"
label={_t("Leave space")}
onClick={this.onLeaveClick}
/>
</IconizedContextMenuOptionList>;
}
let newRoomOption;
if (this.props.space.currentState.maySendStateEvent(EventType.SpaceChild, userId)) {
newRoomOption = (
<IconizedContextMenuOption
iconClassName="mx_SpacePanel_iconPlus"
label={_t("New room")}
onClick={this.onNewRoomClick}
/>
);
}
contextMenu = <IconizedContextMenu
{...toRightOf(this.state.contextMenuPosition, 0)}
onFinished={this.onMenuClose}
className="mx_SpacePanel_contextMenu"
compact
>
<div className="mx_SpacePanel_contextMenu_header">
{ this.props.space.name }
</div>
<IconizedContextMenuOptionList first>
{ inviteOption }
<IconizedContextMenuOption
iconClassName="mx_SpacePanel_iconHome"
label={_t("Space Home")}
onClick={this.onHomeClick}
/>
<IconizedContextMenuOption
iconClassName="mx_SpacePanel_iconMembers"
label={_t("Members")}
onClick={this.onMembersClick}
/>
{ settingsOption }
<IconizedContextMenuOption
iconClassName="mx_SpacePanel_iconExplore"
label={_t("Explore rooms")}
onClick={this.onExploreRoomsClick}
/>
{ newRoomOption }
</IconizedContextMenuOptionList>
{ leaveSection }
</IconizedContextMenu>;
}
return (
<React.Fragment>
<ContextMenuTooltipButton
className="mx_SpaceButton_menuButton"
onClick={this.onMenuOpenClick}
title={_t("Space options")}
isExpanded={!!this.state.contextMenuPosition}
/>
{ contextMenu }
</React.Fragment>
);
}
render() {
const {space, activeSpaces, isNested} = this.props;
@ -133,6 +347,7 @@ export class SpaceItem extends React.PureComponent<IItemProps, IItemState> {
<div className="mx_SpaceButton_selectionWrapper">
<RoomAvatar width={avatarSize} height={avatarSize} room={space} />
{ notifBadge }
{ this.renderContextMenu() }
</div>
</RovingAccessibleTooltipButton>
);
@ -149,6 +364,7 @@ export class SpaceItem extends React.PureComponent<IItemProps, IItemState> {
<RoomAvatar width={avatarSize} height={avatarSize} room={space} />
<span className="mx_SpaceButton_name">{ space.name }</span>
{ notifBadge }
{ this.renderContextMenu() }
</div>
</RovingAccessibleButton>
);