{ _t(
"Anyone will be able to find and join this room, not just members of .", {}, {
@@ -260,6 +260,12 @@ export default class CreateRoomDialog extends React.Component {
{ _t("You can change this at any time from room settings.") }
+
+
);
}
diff --git a/src/components/views/elements/JoinRuleDropdown.tsx b/src/components/views/elements/JoinRuleDropdown.tsx
new file mode 100644
index 0000000000..e2d9b6d872
--- /dev/null
+++ b/src/components/views/elements/JoinRuleDropdown.tsx
@@ -0,0 +1,68 @@
+/*
+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 from 'react';
+import { JoinRule } from 'matrix-js-sdk/src/@types/partials';
+
+import Dropdown from "./Dropdown";
+
+interface IProps {
+ value: JoinRule;
+ label: string;
+ width?: number;
+ labelInvite: string;
+ labelPublic: string;
+ labelRestricted?: string; // if omitted then this option will be hidden, e.g if unsupported
+ onChange(value: JoinRule): void;
+}
+
+const JoinRuleDropdown = ({
+ label,
+ labelInvite,
+ labelPublic,
+ labelRestricted,
+ value,
+ width = 448,
+ onChange,
+}: IProps) => {
+ const options = [
+
+ { labelInvite }
+
,
+
+ { labelPublic }
+
,
+ ];
+
+ if (labelRestricted) {
+ options.unshift(
+ { labelRestricted }
+
);
+ }
+
+ return
+ { options }
+ ;
+};
+
+export default JoinRuleDropdown;
diff --git a/src/components/views/messages/ViewSourceEvent.js b/src/components/views/messages/ViewSourceEvent.tsx
similarity index 87%
rename from src/components/views/messages/ViewSourceEvent.js
rename to src/components/views/messages/ViewSourceEvent.tsx
index 5a5c015dcf..488f8de5df 100644
--- a/src/components/views/messages/ViewSourceEvent.js
+++ b/src/components/views/messages/ViewSourceEvent.tsx
@@ -15,18 +15,21 @@ limitations under the License.
*/
import React from 'react';
-import PropTypes from 'prop-types';
+import { MatrixEvent } from 'matrix-js-sdk/src';
import classNames from 'classnames';
import { replaceableComponent } from "../../../utils/replaceableComponent";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
-@replaceableComponent("views.messages.ViewSourceEvent")
-export default class ViewSourceEvent extends React.PureComponent {
- static propTypes = {
- /* the MatrixEvent to show */
- mxEvent: PropTypes.object.isRequired,
- };
+interface IProps {
+ mxEvent: MatrixEvent;
+}
+interface IState {
+ expanded: boolean;
+}
+
+@replaceableComponent("views.messages.ViewSourceEvent")
+export default class ViewSourceEvent extends React.PureComponent {
constructor(props) {
super(props);
@@ -35,7 +38,7 @@ export default class ViewSourceEvent extends React.PureComponent {
};
}
- componentDidMount() {
+ public componentDidMount(): void {
const { mxEvent } = this.props;
const client = MatrixClientPeg.get();
@@ -46,15 +49,15 @@ export default class ViewSourceEvent extends React.PureComponent {
}
}
- onToggle = (ev) => {
+ private onToggle = (ev: React.MouseEvent) => {
ev.preventDefault();
const { expanded } = this.state;
this.setState({
expanded: !expanded,
});
- }
+ };
- render() {
+ public render(): React.ReactNode {
const { mxEvent } = this.props;
const { expanded } = this.state;
diff --git a/src/components/views/rooms/MessageComposer.tsx b/src/components/views/rooms/MessageComposer.tsx
index 327626908d..add35f38f4 100644
--- a/src/components/views/rooms/MessageComposer.tsx
+++ b/src/components/views/rooms/MessageComposer.tsx
@@ -342,8 +342,11 @@ export default class MessageComposer extends React.Component {
private onVoiceStoreUpdate = () => {
const recording = VoiceRecordingStore.instance.activeRecording;
- this.setState({ haveRecording: !!recording });
if (recording) {
+ // Delay saying we have a recording until it is started, as we might not yet have A/V permissions
+ recording.on(RecordingState.Started, () => {
+ this.setState({ haveRecording: !!VoiceRecordingStore.instance.activeRecording });
+ });
// We show a little heads up that the recording is about to automatically end soon. The 3s
// display time is completely arbitrary. Note that we don't need to deregister the listener
// because the recording instance will clean that up for us.
@@ -351,6 +354,8 @@ export default class MessageComposer extends React.Component {
this.setState({ recordingTimeLeftSeconds: secondsLeft });
setTimeout(() => this.setState({ recordingTimeLeftSeconds: null }), 3000);
});
+ } else {
+ this.setState({ haveRecording: false });
}
};
diff --git a/src/components/views/rooms/ReplyTile.tsx b/src/components/views/rooms/ReplyTile.tsx
index 18b30d33d5..16a7141bd7 100644
--- a/src/components/views/rooms/ReplyTile.tsx
+++ b/src/components/views/rooms/ReplyTile.tsx
@@ -67,15 +67,21 @@ export default class ReplyTile extends React.PureComponent {
};
private onClick = (e: React.MouseEvent): void => {
- // This allows the permalink to be opened in a new tab/window or copied as
- // matrix.to, but also for it to enable routing within Riot when clicked.
- e.preventDefault();
- dis.dispatch({
- action: 'view_room',
- event_id: this.props.mxEvent.getId(),
- highlighted: true,
- room_id: this.props.mxEvent.getRoomId(),
- });
+ const clickTarget = e.target as HTMLElement;
+ // Following a link within a reply should not dispatch the `view_room` action
+ // so that the browser can direct the user to the correct location
+ // The exception being the link wrapping the reply
+ if (clickTarget.tagName.toLowerCase() !== "a" || clickTarget.closest("a") === null) {
+ // This allows the permalink to be opened in a new tab/window or copied as
+ // matrix.to, but also for it to enable routing within Riot when clicked.
+ e.preventDefault();
+ dis.dispatch({
+ action: 'view_room',
+ event_id: this.props.mxEvent.getId(),
+ highlighted: true,
+ room_id: this.props.mxEvent.getRoomId(),
+ });
+ }
};
render() {
diff --git a/src/components/views/rooms/RoomHeader.tsx b/src/components/views/rooms/RoomHeader.tsx
index c79b5bddd5..15b25ed64b 100644
--- a/src/components/views/rooms/RoomHeader.tsx
+++ b/src/components/views/rooms/RoomHeader.tsx
@@ -29,6 +29,8 @@ import RoomTopic from "../elements/RoomTopic";
import RoomName from "../elements/RoomName";
import { PlaceCallType } from "../../../CallHandler";
import { replaceableComponent } from "../../../utils/replaceableComponent";
+import Modal from '../../../Modal';
+import InfoDialog from "../dialogs/InfoDialog";
import { throttle } from 'lodash';
import { MatrixEvent, Room, RoomState } from 'matrix-js-sdk/src';
import { E2EStatus } from '../../../utils/ShieldUtils';
@@ -87,6 +89,14 @@ export default class RoomHeader extends React.Component {
this.forceUpdate();
}, 500, { leading: true, trailing: true });
+ private displayInfoDialogAboutScreensharing() {
+ Modal.createDialog(InfoDialog, {
+ title: _t("Screen sharing is here!"),
+ description: _t("You can now share your screen by pressing the \"screen share\" " +
+ "button during a call. You can even do this in audio calls if both sides support it!"),
+ });
+ }
+
public render() {
let searchStatus = null;
@@ -185,8 +195,8 @@ export default class RoomHeader extends React.Component {
videoCallButton =
this.props.onCallPlaced(
- ev.shiftKey ? PlaceCallType.ScreenSharing : PlaceCallType.Video)}
+ onClick={(ev) => ev.shiftKey ?
+ this.displayInfoDialogAboutScreensharing() : this.props.onCallPlaced(PlaceCallType.Video)}
title={_t("Video call")} />;
}
diff --git a/src/components/views/spaces/SpaceCreateMenu.tsx b/src/components/views/spaces/SpaceCreateMenu.tsx
index 5f16684fb8..16c8ec6fb5 100644
--- a/src/components/views/spaces/SpaceCreateMenu.tsx
+++ b/src/components/views/spaces/SpaceCreateMenu.tsx
@@ -14,9 +14,9 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-import React, { useContext, useRef, useState } from "react";
+import React, { ComponentProps, RefObject, SyntheticEvent, useContext, useRef, useState } from "react";
import classNames from "classnames";
-import { EventType, RoomType, RoomCreateTypeField } from "matrix-js-sdk/src/@types/event";
+import { RoomType } from "matrix-js-sdk/src/@types/event";
import FocusLock from "react-focus-lock";
import { _t } from "../../../languageHandler";
@@ -24,7 +24,7 @@ import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
import { ChevronFace, ContextMenu } from "../../structures/ContextMenu";
import createRoom from "../../../createRoom";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
-import { SpaceAvatar } from "./SpaceBasicSettings";
+import SpaceBasicSettings, { SpaceAvatar } from "./SpaceBasicSettings";
import AccessibleButton from "../elements/AccessibleButton";
import { BetaPill } from "../beta/BetaCard";
import defaultDispatcher from "../../../dispatcher/dispatcher";
@@ -33,8 +33,7 @@ import { UserTab } from "../dialogs/UserSettingsDialog";
import Field from "../elements/Field";
import withValidation from "../elements/Validation";
import { SpaceFeedbackPrompt } from "../../structures/SpaceRoomView";
-import { Preset } from "matrix-js-sdk/src/@types/partials";
-import { ICreateRoomStateEvent } from "matrix-js-sdk/src/@types/requests";
+import { HistoryVisibility, Preset } from "matrix-js-sdk/src/@types/partials";
import RoomAliasField from "../elements/RoomAliasField";
const SpaceCreateMenuType = ({ title, description, className, onClick }) => {
@@ -66,8 +65,83 @@ const nameToAlias = (name: string, domain: string): string => {
return `#${localpart}:${domain}`;
};
-const SpaceCreateMenu = ({ onFinished }) => {
+type BProps = Pick, "setAvatar" | "name" | "setName" | "topic" | "setTopic">;
+interface ISpaceCreateFormProps extends BProps {
+ busy: boolean;
+ alias: string;
+ nameFieldRef: RefObject;
+ aliasFieldRef: RefObject;
+ showAliasField?: boolean;
+ onSubmit(e: SyntheticEvent): void;
+ setAlias(alias: string): void;
+}
+
+export const SpaceCreateForm: React.FC = ({
+ busy,
+ onSubmit,
+ setAvatar,
+ name,
+ setName,
+ nameFieldRef,
+ alias,
+ aliasFieldRef,
+ setAlias,
+ showAliasField,
+ topic,
+ setTopic,
+ children,
+}) => {
const cli = useContext(MatrixClientContext);
+ const domain = cli.getDomain();
+
+ return ;
+};
+
+const SpaceCreateMenu = ({ onFinished }) => {
const [visibility, setVisibility] = useState(null);
const [busy, setBusy] = useState(false);
@@ -98,42 +172,26 @@ const SpaceCreateMenu = ({ onFinished }) => {
return;
}
- const initialState: ICreateRoomStateEvent[] = [
- {
- type: EventType.RoomHistoryVisibility,
- content: {
- "history_visibility": visibility === Visibility.Public ? "world_readable" : "invited",
- },
- },
- ];
- if (avatar) {
- const url = await cli.uploadContent(avatar);
-
- initialState.push({
- type: EventType.RoomAvatar,
- content: { url },
- });
- }
-
try {
await createRoom({
createOpts: {
preset: visibility === Visibility.Public ? Preset.PublicChat : Preset.PrivateChat,
name,
- creation_content: {
- [RoomCreateTypeField]: RoomType.Space,
- },
- initial_state: initialState,
power_level_content_override: {
// Only allow Admins to write to the timeline to prevent hidden sync spam
events_default: 100,
- ...Visibility.Public ? { invite: 0 } : {},
+ ...visibility === Visibility.Public ? { invite: 0 } : {},
},
room_alias_name: visibility === Visibility.Public && alias
? alias.substr(1, alias.indexOf(":") - 1)
: undefined,
topic,
},
+ avatar,
+ roomType: RoomType.Space,
+ historyVisibility: visibility === Visibility.Public
+ ? HistoryVisibility.WorldReadable
+ : HistoryVisibility.Invited,
spinner: false,
encryption: false,
andView: true,
@@ -171,7 +229,6 @@ const SpaceCreateMenu = ({ onFinished }) => {
;
} else {
- const domain = cli.getDomain();
body = {
}
-
+
{ busy ? _t("Creating...") : _t("Create") }
diff --git a/src/components/views/spaces/SpaceTreeLevel.tsx b/src/components/views/spaces/SpaceTreeLevel.tsx
index f33f3a3987..827fc6bde1 100644
--- a/src/components/views/spaces/SpaceTreeLevel.tsx
+++ b/src/components/views/spaces/SpaceTreeLevel.tsx
@@ -35,6 +35,7 @@ import {
shouldShowSpaceSettings,
showAddExistingRooms,
showCreateNewRoom,
+ showCreateNewSubspace,
showSpaceInvite,
showSpaceSettings,
} from "../../../utils/space";
@@ -49,6 +50,7 @@ import { EventType } from "matrix-js-sdk/src/@types/event";
import { StaticNotificationState } from "../../../stores/notifications/StaticNotificationState";
import { NotificationColor } from "../../../stores/notifications/NotificationColor";
import { getKeyBindingsManager, RoomListAction } from "../../../KeyBindingsManager";
+import { BetaPill } from "../beta/BetaCard";
interface IItemProps extends InputHTMLAttributes {
space?: Room;
@@ -232,6 +234,14 @@ export class SpaceItem extends React.PureComponent {
this.setState({ contextMenuPosition: null }); // also close the menu
};
+ private onNewSubspaceClick = (ev: ButtonEvent) => {
+ ev.preventDefault();
+ ev.stopPropagation();
+
+ showCreateNewSubspace(this.props.space);
+ this.setState({ contextMenuPosition: null }); // also close the menu
+ };
+
private onMembersClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
@@ -316,6 +326,13 @@ export class SpaceItem extends React.PureComponent {
label={_t("Add existing room")}
onClick={this.onAddExistingRoomClick}
/>
+
+
+
;
}
diff --git a/src/components/views/voip/AudioFeed.tsx b/src/components/views/voip/AudioFeed.tsx
index a2ab760c86..3049d80c72 100644
--- a/src/components/views/voip/AudioFeed.tsx
+++ b/src/components/views/voip/AudioFeed.tsx
@@ -23,9 +23,21 @@ interface IProps {
feed: CallFeed;
}
-export default class AudioFeed extends React.Component {
+interface IState {
+ audioMuted: boolean;
+}
+
+export default class AudioFeed extends React.Component {
private element = createRef();
+ constructor(props: IProps) {
+ super(props);
+
+ this.state = {
+ audioMuted: this.props.feed.isAudioMuted(),
+ };
+ }
+
componentDidMount() {
MediaDeviceHandler.instance.addListener(
MediaDeviceHandlerEvent.AudioOutputChanged,
@@ -62,6 +74,7 @@ export default class AudioFeed extends React.Component {
private playMedia() {
const element = this.element.current;
+ if (!element) return;
this.onAudioOutputChanged(MediaDeviceHandler.getAudioOutput());
element.muted = false;
element.srcObject = this.props.feed.stream;
@@ -85,6 +98,7 @@ export default class AudioFeed extends React.Component {
private stopMedia() {
const element = this.element.current;
+ if (!element) return;
element.pause();
element.src = null;
@@ -96,10 +110,16 @@ export default class AudioFeed extends React.Component {
}
private onNewStream = () => {
+ this.setState({
+ audioMuted: this.props.feed.isAudioMuted(),
+ });
this.playMedia();
};
render() {
+ // Do not render the audio element if there is no audio track
+ if (this.state.audioMuted) return null;
+
return (
);
diff --git a/src/components/views/voip/CallView.tsx b/src/components/views/voip/CallView.tsx
index e53c2f4823..481d7b303c 100644
--- a/src/components/views/voip/CallView.tsx
+++ b/src/components/views/voip/CallView.tsx
@@ -1,6 +1,7 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
+Copyright 2021 Šimon Brandner
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -32,6 +33,10 @@ import { avatarUrlForMember } from '../../../Avatar';
import DialpadContextMenu from '../context_menus/DialpadContextMenu';
import { CallFeed } from 'matrix-js-sdk/src/webrtc/callFeed';
import { replaceableComponent } from "../../../utils/replaceableComponent";
+import DesktopCapturerSourcePicker from "../elements/DesktopCapturerSourcePicker";
+import Modal from '../../../Modal';
+import { SDPStreamMetadataPurpose } from 'matrix-js-sdk/src/webrtc/callEventTypes';
+import CallViewSidebar from './CallViewSidebar';
interface IProps {
// The call for us to display
@@ -59,11 +64,15 @@ interface IState {
isRemoteOnHold: boolean;
micMuted: boolean;
vidMuted: boolean;
+ screensharing: boolean;
callState: CallState;
controlsVisible: boolean;
+ hoveringControls: boolean;
showMoreMenu: boolean;
showDialpad: boolean;
- feeds: CallFeed[];
+ primaryFeed: CallFeed;
+ secondaryFeeds: Array;
+ sidebarShown: boolean;
}
function getFullScreenElement() {
@@ -94,7 +103,7 @@ function exitFullscreen() {
if (exitMethod) exitMethod.call(document);
}
-const CONTROLS_HIDE_DELAY = 1000;
+const CONTROLS_HIDE_DELAY = 2000;
// Height of the header duplicated from CSS because we need to subtract it from our max
// height to get the max height of the video
const CONTEXT_MENU_VPADDING = 8; // How far the context menu sits above the button (px)
@@ -110,16 +119,22 @@ export default class CallView extends React.Component {
constructor(props: IProps) {
super(props);
+ const { primary, secondary } = this.getOrderedFeeds(this.props.call.getFeeds());
+
this.state = {
isLocalOnHold: this.props.call.isLocalOnHold(),
isRemoteOnHold: this.props.call.isRemoteOnHold(),
micMuted: this.props.call.isMicrophoneMuted(),
vidMuted: this.props.call.isLocalVideoMuted(),
+ screensharing: this.props.call.isScreensharing(),
callState: this.props.call.state,
controlsVisible: true,
+ hoveringControls: false,
showMoreMenu: false,
showDialpad: false,
- feeds: this.props.call.getFeeds(),
+ primaryFeed: primary,
+ secondaryFeeds: secondary,
+ sidebarShown: true,
};
this.updateCallListeners(null, this.props.call);
@@ -194,7 +209,11 @@ export default class CallView extends React.Component {
};
private onFeedsChanged = (newFeeds: Array) => {
- this.setState({ feeds: newFeeds });
+ const { primary, secondary } = this.getOrderedFeeds(newFeeds);
+ this.setState({
+ primaryFeed: primary,
+ secondaryFeeds: secondary,
+ });
};
private onCallLocalHoldUnhold = () => {
@@ -227,6 +246,7 @@ export default class CallView extends React.Component {
};
private onControlsHideTimer = () => {
+ if (this.state.hoveringControls || this.state.showDialpad || this.state.showMoreMenu) return;
this.controlsHideTimer = null;
this.setState({
controlsVisible: false,
@@ -237,7 +257,30 @@ export default class CallView extends React.Component {
this.showControls();
};
- private showControls() {
+ private getOrderedFeeds(feeds: Array): { primary: CallFeed, secondary: Array } {
+ let primary;
+
+ // Try to use a screensharing as primary, a remote one if possible
+ const screensharingFeeds = feeds.filter((feed) => feed.purpose === SDPStreamMetadataPurpose.Screenshare);
+ primary = screensharingFeeds.find((feed) => !feed.isLocal()) || screensharingFeeds[0];
+ // If we didn't find remote screen-sharing stream, try to find any remote stream
+ if (!primary) {
+ primary = feeds.find((feed) => !feed.isLocal());
+ }
+
+ const secondary = [...feeds];
+ // Remove the primary feed from the array
+ if (primary) secondary.splice(secondary.indexOf(primary), 1);
+ secondary.sort((a, b) => {
+ if (a.isLocal() && !b.isLocal()) return -1;
+ if (!a.isLocal() && b.isLocal()) return 1;
+ return 0;
+ });
+
+ return { primary, secondary };
+ }
+
+ private showControls(): void {
if (this.state.showMoreMenu || this.state.showDialpad) return;
if (!this.state.controlsVisible) {
@@ -251,73 +294,62 @@ export default class CallView extends React.Component {
this.controlsHideTimer = window.setTimeout(this.onControlsHideTimer, CONTROLS_HIDE_DELAY);
}
- private onDialpadClick = () => {
+ private onDialpadClick = (): void => {
if (!this.state.showDialpad) {
- if (this.controlsHideTimer) {
- clearTimeout(this.controlsHideTimer);
- this.controlsHideTimer = null;
- }
-
- this.setState({
- showDialpad: true,
- controlsVisible: true,
- });
+ this.setState({ showDialpad: true });
+ this.showControls();
} else {
- if (this.controlsHideTimer !== null) {
- clearTimeout(this.controlsHideTimer);
- }
- this.controlsHideTimer = window.setTimeout(this.onControlsHideTimer, CONTROLS_HIDE_DELAY);
-
- this.setState({
- showDialpad: false,
- });
+ this.setState({ showDialpad: false });
}
};
- private onMicMuteClick = () => {
+ private onMicMuteClick = (): void => {
const newVal = !this.state.micMuted;
this.props.call.setMicrophoneMuted(newVal);
this.setState({ micMuted: newVal });
};
- private onVidMuteClick = () => {
+ private onVidMuteClick = (): void => {
const newVal = !this.state.vidMuted;
this.props.call.setLocalVideoMuted(newVal);
this.setState({ vidMuted: newVal });
};
- private onMoreClick = () => {
- if (this.controlsHideTimer) {
- clearTimeout(this.controlsHideTimer);
- this.controlsHideTimer = null;
- }
+ private onScreenshareClick = async (): Promise => {
+ const isScreensharing = await this.props.call.setScreensharingEnabled(
+ !this.state.screensharing,
+ async (): Promise => {
+ const { finished } = Modal.createDialog(DesktopCapturerSourcePicker);
+ const [source] = await finished;
+ return source;
+ },
+ );
this.setState({
- showMoreMenu: true,
- controlsVisible: true,
+ sidebarShown: true,
+ screensharing: isScreensharing,
});
};
- private closeDialpad = () => {
- this.setState({
- showDialpad: false,
- });
- this.controlsHideTimer = window.setTimeout(this.onControlsHideTimer, CONTROLS_HIDE_DELAY);
+ private onMoreClick = (): void => {
+ this.setState({ showMoreMenu: true });
+ this.showControls();
};
- private closeContextMenu = () => {
- this.setState({
- showMoreMenu: false,
- });
- this.controlsHideTimer = window.setTimeout(this.onControlsHideTimer, CONTROLS_HIDE_DELAY);
+ private closeDialpad = (): void => {
+ this.setState({ showDialpad: false });
+ };
+
+ private closeContextMenu = (): void => {
+ this.setState({ showMoreMenu: false });
};
// we register global shortcuts here, they *must not conflict* with local shortcuts elsewhere or both will fire
// Note that this assumes we always have a CallView on screen at any given time
// CallHandler would probably be a better place for this
- private onNativeKeyDown = ev => {
+ private onNativeKeyDown = (ev): void => {
let handled = false;
const ctrlCmdOnly = isOnlyCtrlOrCmdKeyEvent(ev);
@@ -347,7 +379,16 @@ export default class CallView extends React.Component {
}
};
- private onRoomAvatarClick = () => {
+ private onCallControlsMouseEnter = (): void => {
+ this.setState({ hoveringControls: true });
+ this.showControls();
+ };
+
+ private onCallControlsMouseLeave = (): void => {
+ this.setState({ hoveringControls: false });
+ };
+
+ private onRoomAvatarClick = (): void => {
const userFacingRoomId = CallHandler.sharedInstance().roomIdForCall(this.props.call);
dis.dispatch({
action: 'view_room',
@@ -355,7 +396,7 @@ export default class CallView extends React.Component {
});
};
- private onSecondaryRoomAvatarClick = () => {
+ private onSecondaryRoomAvatarClick = (): void => {
const userFacingRoomId = CallHandler.sharedInstance().roomIdForCall(this.props.secondaryCall);
dis.dispatch({
@@ -364,50 +405,30 @@ export default class CallView extends React.Component {
});
};
- private onCallResumeClick = () => {
+ private onCallResumeClick = (): void => {
const userFacingRoomId = CallHandler.sharedInstance().roomIdForCall(this.props.call);
CallHandler.sharedInstance().setActiveCallRoomId(userFacingRoomId);
};
- private onTransferClick = () => {
+ private onTransferClick = (): void => {
const transfereeCall = CallHandler.sharedInstance().getTransfereeForCallId(this.props.call.callId);
this.props.call.transferToCall(transfereeCall);
};
- public render() {
- const client = MatrixClientPeg.get();
- const callRoomId = CallHandler.sharedInstance().roomIdForCall(this.props.call);
- const secondaryCallRoomId = CallHandler.sharedInstance().roomIdForCall(this.props.secondaryCall);
- const callRoom = client.getRoom(callRoomId);
- const secCallRoom = this.props.secondaryCall ? client.getRoom(secondaryCallRoomId) : null;
+ private onHangupClick = (): void => {
+ dis.dispatch({
+ action: 'hangup',
+ room_id: CallHandler.sharedInstance().roomIdForCall(this.props.call),
+ });
+ };
- let dialPad;
- let contextMenu;
-
- if (this.state.showDialpad) {
- dialPad = ;
- }
-
- if (this.state.showMoreMenu) {
- contextMenu = ;
- }
+ private onToggleSidebar = (): void => {
+ this.setState({
+ sidebarShown: !this.state.sidebarShown,
+ });
+ };
+ private renderCallControls(): JSX.Element {
const micClasses = classNames({
mx_CallView_callControls_button: true,
mx_CallView_callControls_button_micOn: !this.state.micMuted,
@@ -420,6 +441,18 @@ export default class CallView extends React.Component {
mx_CallView_callControls_button_vidOff: this.state.vidMuted,
});
+ const screensharingClasses = classNames({
+ mx_CallView_callControls_button: true,
+ mx_CallView_callControls_button_screensharingOn: this.state.screensharing,
+ mx_CallView_callControls_button_screensharingOff: !this.state.screensharing,
+ });
+
+ const sidebarButtonClasses = classNames({
+ mx_CallView_callControls_button: true,
+ mx_CallView_callControls_button_sidebarOn: this.state.sidebarShown,
+ mx_CallView_callControls_button_sidebarOff: !this.state.sidebarShown,
+ });
+
// Put the other states of the mic/video icons in the document to make sure they're cached
// (otherwise the icon disappears briefly when toggled)
const micCacheClasses = classNames({
@@ -441,59 +474,121 @@ export default class CallView extends React.Component {
mx_CallView_callControls_hidden: !this.state.controlsVisible,
});
- const vidMuteButton = this.props.call.type === CallType.Video ? : null;
+ // We don't support call upgrades (yet) so hide the video mute button in voice calls
+ let vidMuteButton;
+ if (this.props.call.type === CallType.Video) {
+ vidMuteButton = (
+
+ );
+ }
+
+ // Screensharing is possible, if we can send a second stream and
+ // identify it using SDPStreamMetadata or if we can replace the already
+ // existing usermedia track by a screensharing track. We also need to be
+ // connected to know the state of the other side
+ let screensharingButton;
+ if (
+ (this.props.call.opponentSupportsSDPStreamMetadata() || this.props.call.type === CallType.Video) &&
+ this.props.call.state === CallState.Connected
+ ) {
+ screensharingButton = (
+
+ );
+ }
+
+ // To show the sidebar we need secondary feeds, if we don't have them,
+ // we can hide this button. If we are in PiP, sidebar is also hidden, so
+ // we can hide the button too
+ let sidebarButton;
+ if (
+ !this.props.pipMode &&
+ (
+ this.state.primaryFeed?.purpose === SDPStreamMetadataPurpose.Screenshare ||
+ this.props.call.isScreensharing()
+ )
+ ) {
+ sidebarButton = (
+
+ );
+ }
// The dial pad & 'more' button actions are only relevant in a connected call
- // When not connected, we have to put something there to make the flexbox alignment correct
- const dialpadButton = this.state.callState === CallState.Connected ? : ;
+ let contextMenuButton;
+ if (this.state.callState === CallState.Connected) {
+ contextMenuButton = (
+
+ );
+ }
+ let dialpadButton;
+ if (this.state.callState === CallState.Connected && this.props.call.opponentSupportsDTMF()) {
+ dialpadButton = (
+
+ );
+ }
- const contextMenuButton = this.state.callState === CallState.Connected ? : ;
-
- // in the near future, the dial pad button will go on the left. For now, it's the nothing button
- // because something needs to have margin-right: auto to make the alignment correct.
- const callControls =
);
} else {
@@ -585,7 +696,7 @@ export default class CallView extends React.Component {
{ holdTransferContent }
- { callControls }
+ { this.renderCallControls() }
);
}
@@ -599,77 +710,91 @@ export default class CallView extends React.Component {
mx_CallView_voice: true,
});
- const feeds = this.props.call.getLocalFeeds().map((feed, i) => {
- // Here we check to hide local audio feeds to achieve the same UI/UX
- // as before. But once again this might be subject to change
- if (feed.isVideoMuted()) return;
- return (
-
- );
- });
-
// Saying "Connecting" here isn't really true, but the best thing
// I can come up with, but this might be subject to change as well
- contentView =
- { feeds }
-
-
-
+ contentView = (
+
+ { sidebar }
+
+
+
+
+
{ _t("Connecting") }
+ { this.renderCallControls() }
-
{ _t("Connecting") }
- { callControls }
-
;
+ );
} else {
const containerClasses = classNames({
mx_CallView_content: true,
mx_CallView_video: true,
});
- // TODO: Later the CallView should probably be reworked to support
- // any number of feeds but now we can always expect there to be two
- // feeds. This is because the js-sdk ignores any new incoming streams
- const feeds = this.state.feeds.map((feed, i) => {
- // Here we check to hide local audio feeds to achieve the same UI/UX
- // as before. But once again this might be subject to change
- if (feed.isVideoMuted() && feed.isLocal()) return;
- return (
+ let toast;
+ if (someoneIsScreensharing) {
+ const presentingClasses = classNames({
+ mx_CallView_presenting: true,
+ mx_CallView_presenting_hidden: !this.state.controlsVisible,
+ });
+ const sharerName = this.state.primaryFeed.getMember().name;
+ let text = isScreensharing
+ ? _t("You are presenting")
+ : _t('%(sharerName)s is presenting', { sharerName });
+ if (!this.state.sidebarShown && isVideoCall) {
+ text += " • " + (this.props.call.isLocalVideoMuted()
+ ? _t("Your camera is turned off")
+ : _t("Your camera is still enabled"));
+ }
+
+ toast = (
+
;
@@ -728,6 +858,32 @@ export default class CallView extends React.Component {
myClassName = 'mx_CallView_pip';
}
+ let dialPad;
+ if (this.state.showDialpad) {
+ dialPad = ;
+ }
+
+ let contextMenu;
+ if (this.state.showMoreMenu) {
+ contextMenu = ;
+ }
+
return
{ header }
{ contentView }
diff --git a/src/components/views/voip/CallViewSidebar.tsx b/src/components/views/voip/CallViewSidebar.tsx
new file mode 100644
index 0000000000..a0cb25b3df
--- /dev/null
+++ b/src/components/views/voip/CallViewSidebar.tsx
@@ -0,0 +1,53 @@
+/*
+Copyright 2021 Šimon Brandner
+
+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 from "react";
+import { MatrixCall } from "matrix-js-sdk/src/webrtc/call";
+import { CallFeed } from "matrix-js-sdk/src/webrtc/callFeed";
+import VideoFeed from "./VideoFeed";
+import classNames from "classnames";
+
+interface IProps {
+ feeds: Array;
+ call: MatrixCall;
+ pipMode: boolean;
+}
+
+export default class CallViewSidebar extends React.Component {
+ render() {
+ const feeds = this.props.feeds.map((feed) => {
+ return (
+
+ );
+ });
+
+ const className = classNames("mx_CallViewSidebar", {
+ mx_CallViewSidebar_pipMode: this.props.pipMode,
+ });
+
+ return (
+
+ { feeds }
+
+ );
+ }
+}
diff --git a/src/components/views/voip/VideoFeed.tsx b/src/components/views/voip/VideoFeed.tsx
index 2d98314ae5..51d2adb845 100644
--- a/src/components/views/voip/VideoFeed.tsx
+++ b/src/components/views/voip/VideoFeed.tsx
@@ -16,7 +16,7 @@ limitations under the License.
import classnames from 'classnames';
import { MatrixCall } from 'matrix-js-sdk/src/webrtc/call';
-import React, { createRef } from 'react';
+import React from 'react';
import SettingsStore from "../../../settings/SettingsStore";
import { CallFeed, CallFeedEvent } from 'matrix-js-sdk/src/webrtc/callFeed';
import { logger } from 'matrix-js-sdk/src/logger';
@@ -37,6 +37,8 @@ interface IProps {
// a callback which is called when the video element is resized
// due to a change in video metadata
onResize?: (e: Event) => void;
+
+ primary: boolean;
}
interface IState {
@@ -46,7 +48,7 @@ interface IState {
@replaceableComponent("views.voip.VideoFeed")
export default class VideoFeed extends React.Component {
- private element = createRef();
+ private element: HTMLVideoElement;
constructor(props: IProps) {
super(props);
@@ -58,19 +60,50 @@ export default class VideoFeed extends React.Component {
}
componentDidMount() {
- this.props.feed.addListener(CallFeedEvent.NewStream, this.onNewStream);
- this.element.current?.addEventListener('resize', this.onResize);
+ this.updateFeed(null, this.props.feed);
this.playMedia();
}
componentWillUnmount() {
- this.props.feed.removeListener(CallFeedEvent.NewStream, this.onNewStream);
- this.element.current?.removeEventListener('resize', this.onResize);
- this.stopMedia();
+ this.updateFeed(this.props.feed, null);
+ }
+
+ componentDidUpdate(prevProps: IProps) {
+ this.updateFeed(prevProps.feed, this.props.feed);
+ }
+
+ static getDerivedStateFromProps(props: IProps) {
+ return {
+ audioMuted: props.feed.isAudioMuted(),
+ videoMuted: props.feed.isVideoMuted(),
+ };
+ }
+
+ private setElementRef = (element: HTMLVideoElement): void => {
+ if (!element) {
+ this.element?.removeEventListener('resize', this.onResize);
+ return;
+ }
+
+ this.element = element;
+ element.addEventListener('resize', this.onResize);
+ };
+
+ private updateFeed(oldFeed: CallFeed, newFeed: CallFeed) {
+ if (oldFeed === newFeed) return;
+
+ if (oldFeed) {
+ this.props.feed.removeListener(CallFeedEvent.NewStream, this.onNewStream);
+ this.stopMedia();
+ }
+ if (newFeed) {
+ this.props.feed.addListener(CallFeedEvent.NewStream, this.onNewStream);
+ this.playMedia();
+ }
}
private playMedia() {
- const element = this.element.current;
+ const element = this.element;
if (!element) return;
// We play audio in AudioFeed, not here
element.muted = true;
@@ -93,7 +126,7 @@ export default class VideoFeed extends React.Component {
}
private stopMedia() {
- const element = this.element.current;
+ const element = this.element;
if (!element) return;
element.pause();
@@ -122,8 +155,6 @@ export default class VideoFeed extends React.Component {
render() {
const videoClasses = {
mx_VideoFeed: true,
- mx_VideoFeed_local: this.props.feed.isLocal(),
- mx_VideoFeed_remote: !this.props.feed.isLocal(),
mx_VideoFeed_voice: this.state.videoMuted,
mx_VideoFeed_video: !this.state.videoMuted,
mx_VideoFeed_mirror: (
@@ -132,9 +163,15 @@ export default class VideoFeed extends React.Component {
),
};
+ const { pipMode, primary } = this.props;
+
if (this.state.videoMuted) {
const member = this.props.feed.getMember();
- const avatarSize = this.props.pipMode ? 76 : 160;
+ let avatarSize;
+ if (pipMode && primary) avatarSize = 76;
+ else if (pipMode && !primary) avatarSize = 16;
+ else if (!pipMode && primary) avatarSize = 160;
+ else; // TBD
return (
@@ -147,7 +184,7 @@ export default class VideoFeed extends React.Component {
);
} else {
return (
-
+
);
}
}
diff --git a/src/createRoom.ts b/src/createRoom.ts
index 613fe26c9e..a3b06fa8ba 100644
--- a/src/createRoom.ts
+++ b/src/createRoom.ts
@@ -18,9 +18,15 @@ limitations under the License.
import { MatrixClient } from "matrix-js-sdk/src/client";
import { Room } from "matrix-js-sdk/src/models/room";
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
-import { EventType } from "matrix-js-sdk/src/@types/event";
+import { EventType, RoomCreateTypeField, RoomType } from "matrix-js-sdk/src/@types/event";
import { ICreateRoomOpts } from "matrix-js-sdk/src/@types/requests";
-import { JoinRule, Preset, RestrictedAllowType, Visibility } from "matrix-js-sdk/src/@types/partials";
+import {
+ HistoryVisibility,
+ JoinRule,
+ Preset,
+ RestrictedAllowType,
+ Visibility,
+} from "matrix-js-sdk/src/@types/partials";
import { MatrixClientPeg } from './MatrixClientPeg';
import Modal from './Modal';
@@ -52,6 +58,9 @@ export interface IOpts {
inlineErrors?: boolean;
andView?: boolean;
associatedWithCommunity?: string;
+ avatar?: File | string; // will upload if given file, else mxcUrl is needed
+ roomType?: RoomType | string;
+ historyVisibility?: HistoryVisibility;
parentSpace?: Room;
joinRule?: JoinRule;
}
@@ -112,6 +121,13 @@ export default async function createRoom(opts: IOpts): Promise {
createOpts.is_direct = true;
}
+ if (opts.roomType) {
+ createOpts.creation_content = {
+ ...createOpts.creation_content,
+ [RoomCreateTypeField]: opts.roomType,
+ };
+ }
+
// By default, view the room after creating it
if (opts.andView === undefined) {
opts.andView = true;
@@ -144,12 +160,11 @@ export default async function createRoom(opts: IOpts): Promise {
if (opts.parentSpace) {
createOpts.initial_state.push(makeSpaceParentEvent(opts.parentSpace, true));
- createOpts.initial_state.push({
- type: EventType.RoomHistoryVisibility,
- content: {
- "history_visibility": createOpts.preset === Preset.PublicChat ? "world_readable" : "invited",
- },
- });
+ if (!opts.historyVisibility) {
+ opts.historyVisibility = createOpts.preset === Preset.PublicChat
+ ? HistoryVisibility.WorldReadable
+ : HistoryVisibility.Invited;
+ }
if (opts.joinRule === JoinRule.Restricted) {
if (SpaceStore.instance.restrictedJoinRuleSupport?.preferred) {
@@ -176,6 +191,27 @@ export default async function createRoom(opts: IOpts): Promise {
});
}
+ if (opts.avatar) {
+ let url = opts.avatar;
+ if (opts.avatar instanceof File) {
+ url = await client.uploadContent(opts.avatar);
+ }
+
+ createOpts.initial_state.push({
+ type: EventType.RoomAvatar,
+ content: { url },
+ });
+ }
+
+ if (opts.historyVisibility) {
+ createOpts.initial_state.push({
+ type: EventType.RoomHistoryVisibility,
+ content: {
+ "history_visibility": opts.historyVisibility,
+ },
+ });
+ }
+
let modal;
if (opts.spinner) modal = Modal.createDialog(Spinner, null, 'mx_Dialog_spinner');
diff --git a/src/customisations/WidgetVariables.ts b/src/customisations/WidgetVariables.ts
new file mode 100644
index 0000000000..db3a56436d
--- /dev/null
+++ b/src/customisations/WidgetVariables.ts
@@ -0,0 +1,53 @@
+/*
+ * 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.
+ */
+
+// Populate this class with the details of your customisations when copying it.
+import { ITemplateParams } from "matrix-widget-api";
+
+/**
+ * Provides a partial set of the variables needed to render any widget. If
+ * variables are missing or not provided then they will be filled with the
+ * application-determined defaults.
+ *
+ * This will not be called until after isReady() resolves.
+ * @returns {Partial>} The variables.
+ */
+function provideVariables(): Partial> {
+ return {};
+}
+
+/**
+ * Resolves to whether or not the customisation point is ready for variables
+ * to be provided. This will block widgets being rendered.
+ * @returns {Promise} Resolves when ready.
+ */
+async function isReady(): Promise {
+ return; // default no waiting
+}
+
+// This interface summarises all available customisation points and also marks
+// them all as optional. This allows customisers to only define and export the
+// customisations they need while still maintaining type safety.
+export interface IWidgetVariablesCustomisations {
+ provideVariables?: typeof provideVariables;
+
+ // If not provided, the app will assume that the customisation is always ready.
+ isReady?: typeof isReady;
+}
+
+// A real customisation module will define and export one or more of the
+// customisation points that make up the interface above.
+export const WidgetVariableCustomisations: IWidgetVariablesCustomisations = {};
diff --git a/src/dispatcher/actions.ts b/src/dispatcher/actions.ts
index 5732428201..06cbbba46c 100644
--- a/src/dispatcher/actions.ts
+++ b/src/dispatcher/actions.ts
@@ -193,4 +193,9 @@ export enum Action {
* Switches space. Should be used with SwitchSpacePayload.
*/
SwitchSpace = "switch_space",
+
+ /**
+ * Signals to the visible space hierarchy that a change has occurred an that it should refresh.
+ */
+ UpdateSpaceHierarchy = "update_space_hierarchy",
}
diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json
index 20316d27aa..bd8ff0589d 100644
--- a/src/i18n/strings/en_EN.json
+++ b/src/i18n/strings/en_EN.json
@@ -52,7 +52,6 @@
"A microphone and webcam are plugged in and set up correctly": "A microphone and webcam are plugged in and set up correctly",
"Permission is granted to use the webcam": "Permission is granted to use the webcam",
"No other application is using the webcam": "No other application is using the webcam",
- "Unable to capture screen": "Unable to capture screen",
"VoIP is unsupported": "VoIP is unsupported",
"You cannot place VoIP calls in this browser.": "You cannot place VoIP calls in this browser.",
"Too Many Calls": "Too Many Calls",
@@ -798,9 +797,6 @@
"Beta available for web, desktop and Android. Some features may be unavailable on your homeserver.": "Beta available for web, desktop and Android. Some features may be unavailable on your homeserver.",
"Your feedback will help make spaces better. The more detail you can go into, the better.": "Your feedback will help make spaces better. The more detail you can go into, the better.",
"Show all rooms in Home": "Show all rooms in Home",
- "Show people in spaces": "Show people in spaces",
- "If disabled, you can still add Direct Messages to Personal Spaces. If enabled, you'll automatically see everyone who is a member of the Space.": "If disabled, you can still add Direct Messages to Personal Spaces. If enabled, you'll automatically see everyone who is a member of the Space.",
- "Show notification badges for People in Spaces": "Show notification badges for People in Spaces",
"Show options to enable 'Do not disturb' mode": "Show options to enable 'Do not disturb' mode",
"Send and receive voice messages": "Send and receive voice messages",
"Render LaTeX maths in messages": "Render LaTeX maths in messages",
@@ -903,6 +899,10 @@
"You held the call Resume": "You held the call Resume",
"%(peerName)s held the call": "%(peerName)s held the call",
"Connecting": "Connecting",
+ "You are presenting": "You are presenting",
+ "%(sharerName)s is presenting": "%(sharerName)s is presenting",
+ "Your camera is turned off": "Your camera is turned off",
+ "Your camera is still enabled": "Your camera is still enabled",
"Video Call": "Video Call",
"Voice Call": "Voice Call",
"Fill Screen": "Fill Screen",
@@ -1005,6 +1005,8 @@
"Name": "Name",
"Description": "Description",
"Please enter a name for the space": "Please enter a name for the space",
+ "e.g. my-space": "e.g. my-space",
+ "Address": "Address",
"Create a space": "Create a space",
"Spaces are a new way to group rooms and people. To join an existing space you'll need an invite.": "Spaces are a new way to group rooms and people. To join an existing space you'll need an invite.",
"Public": "Public",
@@ -1017,8 +1019,6 @@
"Your private space": "Your private space",
"Add some details to help people recognise it.": "Add some details to help people recognise it.",
"You can change these anytime.": "You can change these anytime.",
- "e.g. my-space": "e.g. my-space",
- "Address": "Address",
"Creating...": "Creating...",
"Create": "Create",
"All rooms": "All rooms",
@@ -1056,6 +1056,7 @@
"Leave space": "Leave space",
"Create new room": "Create new room",
"Add existing room": "Add existing room",
+ "Add space": "Add space",
"Members": "Members",
"Manage & explore rooms": "Manage & explore rooms",
"Explore rooms": "Explore rooms",
@@ -1570,6 +1571,8 @@
"Unnamed room": "Unnamed room",
"World readable": "World readable",
"Guests can join": "Guests can join",
+ "Screen sharing is here!": "Screen sharing is here!",
+ "You can now share your screen by pressing the \"screen share\" button during a call. You can even do this in audio calls if both sides support it!": "You can now share your screen by pressing the \"screen share\" button during a call. You can even do this in audio calls if both sides support it!",
"(~%(count)s results)|other": "(~%(count)s results)",
"(~%(count)s results)|one": "(~%(count)s result)",
"Join Room": "Join Room",
@@ -1998,9 +2001,9 @@
"Use the Desktop app to search encrypted messages": "Use the Desktop app to search encrypted messages",
"This version of %(brand)s does not support viewing some encrypted files": "This version of %(brand)s does not support viewing some encrypted files",
"This version of %(brand)s does not support searching encrypted messages": "This version of %(brand)s does not support searching encrypted messages",
- "Share your screen": "Share your screen",
- "Screens": "Screens",
- "Windows": "Windows",
+ "Share entire screen": "Share entire screen",
+ "Application window": "Application window",
+ "Share content": "Share content",
"Join": "Join",
"No results": "No results",
"Please create a new issue on GitHub so that we can investigate this bug.": "Please create a new issue on GitHub so that we can investigate this bug.",
@@ -2108,17 +2111,20 @@
"Add a new server...": "Add a new server...",
"%(networkName)s rooms": "%(networkName)s rooms",
"Matrix rooms": "Matrix rooms",
+ "Add existing space": "Add existing space",
+ "Want to add a new space instead?": "Want to add a new space instead?",
+ "Create a new space": "Create a new space",
+ "Search for spaces": "Search for spaces",
"Not all selected were added": "Not all selected were added",
"Adding rooms... (%(progress)s out of %(count)s)|other": "Adding rooms... (%(progress)s out of %(count)s)",
"Adding rooms... (%(progress)s out of %(count)s)|one": "Adding room...",
- "Filter your rooms and spaces": "Filter your rooms and spaces",
- "Feeling experimental?": "Feeling experimental?",
- "You can add existing spaces to a space.": "You can add existing spaces to a space.",
"Direct Messages": "Direct Messages",
"Space selection": "Space selection",
"Add existing rooms": "Add existing rooms",
"Want to add a new room instead?": "Want to add a new room instead?",
"Create a new room": "Create a new room",
+ "Search for rooms": "Search for rooms",
+ "Adding spaces has moved.": "Adding spaces has moved.",
"Matrix ID": "Matrix ID",
"Matrix Room ID": "Matrix Room ID",
"email address": "email address",
@@ -2195,6 +2201,7 @@
"Everyone in will be able to find and join this room.": "Everyone in will be able to find and join this room.",
"You can change this at any time from room settings.": "You can change this at any time from room settings.",
"Anyone will be able to find and join this room, not just members of .": "Anyone will be able to find and join this room, not just members of .",
+ "Anyone will be able to find and join this room.": "Anyone will be able to find and join this room.",
"Only people invited will be able to find and join this room.": "Only people invited will be able to find and join this room.",
"You can’t disable this later. Bridges & most bots won’t work yet.": "You can’t disable this later. Bridges & most bots won’t work yet.",
"Your server requires encryption to be enabled in private rooms.": "Your server requires encryption to be enabled in private rooms.",
@@ -2205,13 +2212,22 @@
"Create a room in %(communityName)s": "Create a room in %(communityName)s",
"Create a public room": "Create a public room",
"Create a private room": "Create a private room",
+ "Topic (optional)": "Topic (optional)",
+ "Room visibility": "Room visibility",
"Private room (invite only)": "Private room (invite only)",
"Public room": "Public room",
"Visible to space members": "Visible to space members",
- "Topic (optional)": "Topic (optional)",
- "Room visibility": "Room visibility",
"Block anyone not part of %(serverName)s from ever joining this room.": "Block anyone not part of %(serverName)s from ever joining this room.",
"Create Room": "Create Room",
+ "Anyone in will be able to find and join.": "Anyone in will be able to find and join.",
+ "Anyone will be able to find and join this space, not just members of .": "Anyone will be able to find and join this space, not just members of .",
+ "Only people invited will be able to find and join this space.": "Only people invited will be able to find and join this space.",
+ "Add a space to a space you manage.": "Add a space to a space you manage.",
+ "Space visibility": "Space visibility",
+ "Private space (invite only)": "Private space (invite only)",
+ "Public space": "Public space",
+ "Want to add an existing space instead?": "Want to add an existing space instead?",
+ "Adding...": "Adding...",
"Sign out": "Sign out",
"To avoid losing your chat history, you must export your room keys before logging out. You will need to go back to the newer version of %(brand)s to do this": "To avoid losing your chat history, you must export your room keys before logging out. You will need to go back to the newer version of %(brand)s to do this",
"You've previously used a newer version of %(brand)s with this session. To use this version again with end to end encryption, you will need to sign out and back in again.": "You've previously used a newer version of %(brand)s with this session. To use this version again with end to end encryption, you will need to sign out and back in again.",
@@ -2811,7 +2827,6 @@
"If you can't find the room you're looking for, ask for an invite or create a new room.": "If you can't find the room you're looking for, ask for an invite or create a new room.",
"Create room": "Create room",
"Spaces are a beta feature.": "Spaces are a beta feature.",
- "Public space": "Public space",
"Private space": "Private space",
" invites you": " invites you",
"To view %(spaceName)s, turn on the Spaces beta": "To view %(spaceName)s, turn on the Spaces beta",
@@ -2826,6 +2841,7 @@
"Creating rooms...": "Creating rooms...",
"What do you want to organise?": "What do you want to organise?",
"Pick rooms or conversations to add. This is just a space for you, no one will be informed. You can add more later.": "Pick rooms or conversations to add. This is just a space for you, no one will be informed. You can add more later.",
+ "Search for rooms or spaces": "Search for rooms or spaces",
"Share %(name)s": "Share %(name)s",
"It's just you at the moment, it will be even better with others.": "It's just you at the moment, it will be even better with others.",
"Go to my first room": "Go to my first room",
diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx
index f0bdb2e0e5..5aa49df8a1 100644
--- a/src/settings/Settings.tsx
+++ b/src/settings/Settings.tsx
@@ -181,8 +181,6 @@ export const SETTINGS: {[setting: string]: ISetting} = {
feedbackLabel: "spaces-feedback",
extraSettings: [
"feature_spaces.all_rooms",
- "feature_spaces.space_member_dms",
- "feature_spaces.space_dm_badges",
],
},
},
@@ -192,20 +190,6 @@ export const SETTINGS: {[setting: string]: ISetting} = {
default: true,
controller: new ReloadOnChangeController(),
},
- "feature_spaces.space_member_dms": {
- displayName: _td("Show people in spaces"),
- description: _td("If disabled, you can still add Direct Messages to Personal Spaces. " +
- "If enabled, you'll automatically see everyone who is a member of the Space."),
- supportedLevels: LEVELS_FEATURE,
- default: true,
- controller: new ReloadOnChangeController(),
- },
- "feature_spaces.space_dm_badges": {
- displayName: _td("Show notification badges for People in Spaces"),
- supportedLevels: LEVELS_FEATURE,
- default: false,
- controller: new ReloadOnChangeController(),
- },
"feature_dnd": {
isFeature: true,
displayName: _td("Show options to enable 'Do not disturb' mode"),
diff --git a/src/stores/SpaceStore.tsx b/src/stores/SpaceStore.tsx
index 579c2ca662..dfa8bef8be 100644
--- a/src/stores/SpaceStore.tsx
+++ b/src/stores/SpaceStore.tsx
@@ -72,8 +72,6 @@ const MAX_SUGGESTED_ROOMS = 20;
// All of these settings cause the page to reload and can be costly if read frequently, so read them here only
const spacesEnabled = SettingsStore.getValue("feature_spaces");
const spacesTweakAllRoomsEnabled = SettingsStore.getValue("feature_spaces.all_rooms");
-const spacesTweakSpaceMemberDMsEnabled = SettingsStore.getValue("feature_spaces.space_member_dms");
-const spacesTweakSpaceDMBadgesEnabled = SettingsStore.getValue("feature_spaces.space_dm_badges");
const homeSpaceKey = spacesTweakAllRoomsEnabled ? "ALL_ROOMS" : "HOME_SPACE";
const getSpaceContextKey = (space?: Room) => `mx_space_context_${space?.roomId || homeSpaceKey}`;
@@ -535,15 +533,13 @@ export class SpaceStoreClass extends AsyncStoreWithClient {
const roomIds = new Set(childRooms.map(r => r.roomId));
const space = this.matrixClient?.getRoom(spaceId);
- if (spacesTweakSpaceMemberDMsEnabled) {
- // Add relevant DMs
- space?.getMembers().forEach(member => {
- if (member.membership !== "join" && member.membership !== "invite") return;
- DMRoomMap.shared().getDMRoomsForUserId(member.userId).forEach(roomId => {
- roomIds.add(roomId);
- });
+ // Add relevant DMs
+ space?.getMembers().forEach(member => {
+ if (member.membership !== "join" && member.membership !== "invite") return;
+ DMRoomMap.shared().getDMRoomsForUserId(member.userId).forEach(roomId => {
+ roomIds.add(roomId);
});
- }
+ });
const newPath = new Set(parentPath).add(spaceId);
childSpaces.forEach(childSpace => {
@@ -568,14 +564,13 @@ export class SpaceStoreClass extends AsyncStoreWithClient {
this.spaceFilteredRooms.forEach((roomIds, s) => {
// Update NotificationStates
this.getNotificationState(s)?.setRooms(visibleRooms.filter(room => {
- if (roomIds.has(room.roomId)) {
- if (s !== HOME_SPACE && spacesTweakSpaceDMBadgesEnabled) return true;
+ if (!roomIds.has(room.roomId)) return false;
- return !DMRoomMap.shared().getUserIdForRoomId(room.roomId)
- || RoomListStore.instance.getTagsForRoom(room).includes(DefaultTagID.Favourite);
+ if (DMRoomMap.shared().getUserIdForRoomId(room.roomId)) {
+ return s === HOME_SPACE;
}
- return false;
+ return true;
}));
});
}, 100, { trailing: true, leading: true });
@@ -878,8 +873,6 @@ export class SpaceStoreClass extends AsyncStoreWithClient {
export default class SpaceStore {
public static spacesEnabled = spacesEnabled;
public static spacesTweakAllRoomsEnabled = spacesTweakAllRoomsEnabled;
- public static spacesTweakSpaceMemberDMsEnabled = spacesTweakSpaceMemberDMsEnabled;
- public static spacesTweakSpaceDMBadgesEnabled = spacesTweakSpaceDMBadgesEnabled;
private static internalInstance = new SpaceStoreClass();
diff --git a/src/stores/notifications/SpaceNotificationState.ts b/src/stores/notifications/SpaceNotificationState.ts
index 4c0a582f3f..f8eb07251b 100644
--- a/src/stores/notifications/SpaceNotificationState.ts
+++ b/src/stores/notifications/SpaceNotificationState.ts
@@ -23,7 +23,7 @@ import { NOTIFICATION_STATE_UPDATE, NotificationState } from "./NotificationStat
import { FetchRoomFn } from "./ListNotificationState";
export class SpaceNotificationState extends NotificationState {
- private rooms: Room[] = [];
+ public rooms: Room[] = []; // exposed only for tests
private states: { [spaceId: string]: RoomNotificationState } = {};
constructor(private spaceId: string | symbol, private getRoomFn: FetchRoomFn) {
diff --git a/src/stores/widgets/StopGapWidget.ts b/src/stores/widgets/StopGapWidget.ts
index 24869b5edc..daa1e0e787 100644
--- a/src/stores/widgets/StopGapWidget.ts
+++ b/src/stores/widgets/StopGapWidget.ts
@@ -54,6 +54,7 @@ import { ElementWidgetCapabilities } from "./ElementWidgetCapabilities";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { ELEMENT_CLIENT_ID } from "../../identifiers";
import { getUserLanguage } from "../../languageHandler";
+import { WidgetVariableCustomisations } from "../../customisations/WidgetVariables";
// TODO: Destroy all of this code
@@ -191,7 +192,8 @@ export class StopGapWidget extends EventEmitter {
}
private runUrlTemplate(opts = { asPopout: false }): string {
- const templated = this.mockWidget.getCompleteUrl({
+ const fromCustomisation = WidgetVariableCustomisations?.provideVariables?.() ?? {};
+ const defaults: ITemplateParams = {
widgetRoomId: this.roomId,
currentUserId: MatrixClientPeg.get().getUserId(),
userDisplayName: OwnProfileStore.instance.displayName,
@@ -199,7 +201,8 @@ export class StopGapWidget extends EventEmitter {
clientId: ELEMENT_CLIENT_ID,
clientTheme: SettingsStore.getValue("theme"),
clientLanguage: getUserLanguage(),
- }, opts?.asPopout);
+ };
+ const templated = this.mockWidget.getCompleteUrl(Object.assign(defaults, fromCustomisation), opts?.asPopout);
const parsed = new URL(templated);
@@ -363,6 +366,9 @@ export class StopGapWidget extends EventEmitter {
}
public async prepare(): Promise {
+ // Ensure the variables are ready for us to be rendered before continuing
+ await (WidgetVariableCustomisations?.isReady?.() ?? Promise.resolve());
+
if (this.scalarToken) return;
const existingMessaging = WidgetMessagingStore.instance.getMessaging(this.mockWidget);
if (existingMessaging) this.messaging = existingMessaging;
diff --git a/src/utils/space.tsx b/src/utils/space.tsx
index 6dd7d62f72..fecb581e65 100644
--- a/src/utils/space.tsx
+++ b/src/utils/space.tsx
@@ -28,6 +28,11 @@ import { _t } from "../languageHandler";
import SpacePublicShare from "../components/views/spaces/SpacePublicShare";
import InfoDialog from "../components/views/dialogs/InfoDialog";
import { showRoomInviteDialog } from "../RoomInvite";
+import CreateSubspaceDialog from "../components/views/dialogs/CreateSubspaceDialog";
+import AddExistingSubspaceDialog from "../components/views/dialogs/AddExistingSubspaceDialog";
+import defaultDispatcher from "../dispatcher/dispatcher";
+import RoomViewStore from "../stores/RoomViewStore";
+import { Action } from "../dispatcher/actions";
import { leaveRoomBehaviour } from "./membership";
import Spinner from "../components/views/elements/Spinner";
import dis from "../dispatcher/dispatcher";
@@ -58,21 +63,26 @@ export const showSpaceSettings = (space: Room) => {
}, /*className=*/null, /*isPriority=*/false, /*isStatic=*/true);
};
-export const showAddExistingRooms = async (space: Room) => {
- return Modal.createTrackedDialog(
+export const showAddExistingRooms = (space: Room): void => {
+ Modal.createTrackedDialog(
"Space Landing",
"Add Existing",
AddExistingToSpaceDialog,
{
- matrixClient: space.client,
- onCreateRoomClick: showCreateNewRoom,
+ onCreateRoomClick: () => showCreateNewRoom(space),
+ onAddSubspaceClick: () => showAddExistingSubspace(space),
space,
+ onFinished: (added: boolean) => {
+ if (added && RoomViewStore.getRoomId() === space.roomId) {
+ defaultDispatcher.fire(Action.UpdateSpaceHierarchy);
+ }
+ },
},
"mx_AddExistingToSpaceDialog_wrapper",
- ).finished;
+ );
};
-export const showCreateNewRoom = async (space: Room) => {
+export const showCreateNewRoom = async (space: Room): Promise => {
const modal = Modal.createTrackedDialog<[boolean, IOpts]>(
"Space Landing",
"Create Room",
@@ -89,7 +99,7 @@ export const showCreateNewRoom = async (space: Room) => {
return shouldCreate;
};
-export const showSpaceInvite = (space: Room, initialText = "") => {
+export const showSpaceInvite = (space: Room, initialText = ""): void => {
if (space.getJoinRule() === "public") {
const modal = Modal.createTrackedDialog("Space Invite", "User Menu", InfoDialog, {
title: _t("Invite to %(spaceName)s", { spaceName: space.name }),
@@ -107,6 +117,42 @@ export const showSpaceInvite = (space: Room, initialText = "") => {
}
};
+export const showAddExistingSubspace = (space: Room): void => {
+ Modal.createTrackedDialog(
+ "Space Landing",
+ "Create Subspace",
+ AddExistingSubspaceDialog,
+ {
+ space,
+ onCreateSubspaceClick: () => showCreateNewSubspace(space),
+ onFinished: (added: boolean) => {
+ if (added && RoomViewStore.getRoomId() === space.roomId) {
+ defaultDispatcher.fire(Action.UpdateSpaceHierarchy);
+ }
+ },
+ },
+ "mx_AddExistingToSpaceDialog_wrapper",
+ );
+};
+
+export const showCreateNewSubspace = (space: Room): void => {
+ Modal.createTrackedDialog(
+ "Space Landing",
+ "Create Subspace",
+ CreateSubspaceDialog,
+ {
+ space,
+ onAddExistingSpaceClick: () => showAddExistingSubspace(space),
+ onFinished: (added: boolean) => {
+ if (added && RoomViewStore.getRoomId() === space.roomId) {
+ defaultDispatcher.fire(Action.UpdateSpaceHierarchy);
+ }
+ },
+ },
+ "mx_CreateSubspaceDialog_wrapper",
+ );
+};
+
export const leaveSpace = (space: Room) => {
Modal.createTrackedDialog("Leave Space", "", LeaveSpaceDialog, {
space,
diff --git a/test/stores/SpaceStore-setup.ts b/test/stores/SpaceStore-setup.ts
index 67d492255f..b9b865e89a 100644
--- a/test/stores/SpaceStore-setup.ts
+++ b/test/stores/SpaceStore-setup.ts
@@ -19,5 +19,3 @@ limitations under the License.
localStorage.setItem("mx_labs_feature_feature_spaces", "true");
localStorage.setItem("mx_labs_feature_feature_spaces.all_rooms", "true");
-localStorage.setItem("mx_labs_feature_feature_spaces.space_member_dms", "true");
-localStorage.setItem("mx_labs_feature_feature_spaces.space_dm_badges", "false");
diff --git a/test/stores/SpaceStore-test.ts b/test/stores/SpaceStore-test.ts
index 8855f4e470..d772a7a658 100644
--- a/test/stores/SpaceStore-test.ts
+++ b/test/stores/SpaceStore-test.ts
@@ -17,6 +17,7 @@ limitations under the License.
import { EventEmitter } from "events";
import { EventType } from "matrix-js-sdk/src/@types/event";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
+import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import "./SpaceStore-setup"; // enable space lab
import "../skinned-sdk"; // Must be first for skinning to work
@@ -53,18 +54,22 @@ const emitPromise = (e: EventEmitter, k: string | symbol) => new Promise(r => e.
const testUserId = "@test:user";
const getUserIdForRoomId = jest.fn();
+const getDMRoomsForUserId = jest.fn();
// @ts-ignore
-DMRoomMap.sharedInstance = { getUserIdForRoomId };
+DMRoomMap.sharedInstance = { getUserIdForRoomId, getDMRoomsForUserId };
const fav1 = "!fav1:server";
const fav2 = "!fav2:server";
const fav3 = "!fav3:server";
const dm1 = "!dm1:server";
-const dm1Partner = "@dm1Partner:server";
+const dm1Partner = new RoomMember(dm1, "@dm1Partner:server");
+dm1Partner.membership = "join";
const dm2 = "!dm2:server";
-const dm2Partner = "@dm2Partner:server";
+const dm2Partner = new RoomMember(dm2, "@dm2Partner:server");
+dm2Partner.membership = "join";
const dm3 = "!dm3:server";
-const dm3Partner = "@dm3Partner:server";
+const dm3Partner = new RoomMember(dm3, "@dm3Partner:server");
+dm3Partner.membership = "join";
const orphan1 = "!orphan1:server";
const orphan2 = "!orphan2:server";
const invite1 = "!invite1:server";
@@ -320,11 +325,40 @@ describe("SpaceStore", () => {
getUserIdForRoomId.mockImplementation(roomId => {
return {
- [dm1]: dm1Partner,
- [dm2]: dm2Partner,
- [dm3]: dm3Partner,
+ [dm1]: dm1Partner.userId,
+ [dm2]: dm2Partner.userId,
+ [dm3]: dm3Partner.userId,
}[roomId];
});
+ getDMRoomsForUserId.mockImplementation(userId => {
+ switch (userId) {
+ case dm1Partner.userId:
+ return [dm1];
+ case dm2Partner.userId:
+ return [dm2];
+ case dm3Partner.userId:
+ return [dm3];
+ default:
+ return [];
+ }
+ });
+
+ // have dmPartner1 be in space1 with you
+ const mySpace1Member = new RoomMember(space1, testUserId);
+ mySpace1Member.membership = "join";
+ (rooms.find(r => r.roomId === space1).getMembers as jest.Mock).mockReturnValue([
+ mySpace1Member,
+ dm1Partner,
+ ]);
+ // have dmPartner2 be in space2 with you
+ const mySpace2Member = new RoomMember(space2, testUserId);
+ mySpace2Member.membership = "join";
+ (rooms.find(r => r.roomId === space2).getMembers as jest.Mock).mockReturnValue([
+ mySpace2Member,
+ dm2Partner,
+ ]);
+ // dmPartner3 is not in any common spaces with you
+
await run();
});
@@ -375,6 +409,66 @@ describe("SpaceStore", () => {
const space = client.getRoom(space3);
expect(store.getSpaceFilteredRoomIds(space).has(invite2)).toBeTruthy();
});
+
+ it("spaces contain dms which you have with members of that space", () => {
+ expect(store.getSpaceFilteredRoomIds(client.getRoom(space1)).has(dm1)).toBeTruthy();
+ expect(store.getSpaceFilteredRoomIds(client.getRoom(space2)).has(dm1)).toBeFalsy();
+ expect(store.getSpaceFilteredRoomIds(client.getRoom(space3)).has(dm1)).toBeFalsy();
+ expect(store.getSpaceFilteredRoomIds(client.getRoom(space1)).has(dm2)).toBeFalsy();
+ expect(store.getSpaceFilteredRoomIds(client.getRoom(space2)).has(dm2)).toBeTruthy();
+ expect(store.getSpaceFilteredRoomIds(client.getRoom(space3)).has(dm2)).toBeFalsy();
+ expect(store.getSpaceFilteredRoomIds(client.getRoom(space1)).has(dm3)).toBeFalsy();
+ expect(store.getSpaceFilteredRoomIds(client.getRoom(space2)).has(dm3)).toBeFalsy();
+ expect(store.getSpaceFilteredRoomIds(client.getRoom(space3)).has(dm3)).toBeFalsy();
+ });
+
+ it("dms are only added to Notification States for only the Home Space", () => {
+ // XXX: All rooms space is forcibly enabled, as part of a future PR test Home space better
+ // [dm1, dm2, dm3].forEach(d => {
+ // expect(store.getNotificationState(HOME_SPACE).rooms.map(r => r.roomId).includes(d)).toBeTruthy();
+ // });
+ [space1, space2, space3].forEach(s => {
+ [dm1, dm2, dm3].forEach(d => {
+ expect(store.getNotificationState(s).rooms.map(r => r.roomId).includes(d)).toBeFalsy();
+ });
+ });
+ });
+
+ it("orphan rooms are added to Notification States for only the Home Space", () => {
+ // XXX: All rooms space is forcibly enabled, as part of a future PR test Home space better
+ // [orphan1, orphan2].forEach(d => {
+ // expect(store.getNotificationState(HOME_SPACE).rooms.map(r => r.roomId).includes(d)).toBeTruthy();
+ // });
+ [space1, space2, space3].forEach(s => {
+ [orphan1, orphan2].forEach(d => {
+ expect(store.getNotificationState(s).rooms.map(r => r.roomId).includes(d)).toBeFalsy();
+ });
+ });
+ });
+
+ it("favourites are added to Notification States for all spaces containing the room inc Home", () => {
+ // XXX: All rooms space is forcibly enabled, as part of a future PR test Home space better
+ // [fav1, fav2, fav3].forEach(d => {
+ // expect(store.getNotificationState(HOME_SPACE).rooms.map(r => r.roomId).includes(d)).toBeTruthy();
+ // });
+ expect(store.getNotificationState(space1).rooms.map(r => r.roomId).includes(fav1)).toBeTruthy();
+ expect(store.getNotificationState(space1).rooms.map(r => r.roomId).includes(fav2)).toBeFalsy();
+ expect(store.getNotificationState(space1).rooms.map(r => r.roomId).includes(fav3)).toBeFalsy();
+ expect(store.getNotificationState(space2).rooms.map(r => r.roomId).includes(fav1)).toBeTruthy();
+ expect(store.getNotificationState(space2).rooms.map(r => r.roomId).includes(fav2)).toBeTruthy();
+ expect(store.getNotificationState(space2).rooms.map(r => r.roomId).includes(fav3)).toBeTruthy();
+ expect(store.getNotificationState(space3).rooms.map(r => r.roomId).includes(fav1)).toBeFalsy();
+ expect(store.getNotificationState(space3).rooms.map(r => r.roomId).includes(fav2)).toBeFalsy();
+ expect(store.getNotificationState(space3).rooms.map(r => r.roomId).includes(fav3)).toBeFalsy();
+ });
+
+ it("other rooms are added to Notification States for all spaces containing the room exc Home", () => {
+ // XXX: All rooms space is forcibly enabled, as part of a future PR test Home space better
+ // expect(store.getNotificationState(HOME_SPACE).rooms.map(r => r.roomId).includes(room1)).toBeFalsy();
+ expect(store.getNotificationState(space1).rooms.map(r => r.roomId).includes(room1)).toBeTruthy();
+ expect(store.getNotificationState(space2).rooms.map(r => r.roomId).includes(room1)).toBeTruthy();
+ expect(store.getNotificationState(space3).rooms.map(r => r.roomId).includes(room1)).toBeFalsy();
+ });
});
});