From db61d343f5f9f2dc8552b97d9243c0ebfe8baa94 Mon Sep 17 00:00:00 2001 From: Clemens Zeidler Date: Sun, 30 Aug 2020 20:17:08 +1200 Subject: [PATCH 01/45] Add option to send/edit a message with Ctrl + Enter / Command + Enter When editing multi-line text this option helps to prevent accidentally sending a message too early. With this option, Enter just inserts a new line. For example, composing programming code in a dev chat becomes much easier when Enter just inserts a new line instead of sending the message. Signed-off-by: Clemens Zeidler --- src/components/views/rooms/EditMessageComposer.js | 8 ++++++-- src/components/views/rooms/SendMessageComposer.js | 8 ++++++-- .../settings/tabs/user/PreferencesUserSettingsTab.js | 1 + src/i18n/strings/en_EN.json | 1 + src/settings/Settings.ts | 5 +++++ 5 files changed, 19 insertions(+), 4 deletions(-) diff --git a/src/components/views/rooms/EditMessageComposer.js b/src/components/views/rooms/EditMessageComposer.js index 78c7de887d..636c5b27ff 100644 --- a/src/components/views/rooms/EditMessageComposer.js +++ b/src/components/views/rooms/EditMessageComposer.js @@ -29,9 +29,10 @@ import EditorStateTransfer from '../../../utils/EditorStateTransfer'; import classNames from 'classnames'; import {EventStatus} from 'matrix-js-sdk'; import BasicMessageComposer from "./BasicMessageComposer"; -import {Key} from "../../../Keyboard"; +import {Key, isOnlyCtrlOrCmdKeyEvent} from "../../../Keyboard"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; import {Action} from "../../../dispatcher/actions"; +import SettingsStore from "../../../settings/SettingsStore"; function _isReply(mxEvent) { const relatesTo = mxEvent.getContent()["m.relates_to"]; @@ -135,7 +136,10 @@ export default class EditMessageComposer extends React.Component { if (event.metaKey || event.altKey || event.shiftKey) { return; } - if (event.key === Key.ENTER) { + const ctrlEnterToSend = !!SettingsStore.getValue('MessageComposerInput.ctrlEnterToSend'); + const send = ctrlEnterToSend ? event.key === Key.ENTER && isOnlyCtrlOrCmdKeyEvent(event) + : event.key === Key.ENTER; + if (send) { this._sendEdit(); event.preventDefault(); } else if (event.key === Key.ESCAPE) { diff --git a/src/components/views/rooms/SendMessageComposer.js b/src/components/views/rooms/SendMessageComposer.js index 25dcf8ccd5..dd1b67c989 100644 --- a/src/components/views/rooms/SendMessageComposer.js +++ b/src/components/views/rooms/SendMessageComposer.js @@ -39,11 +39,12 @@ import * as sdk from '../../../index'; import Modal from '../../../Modal'; import {_t, _td} from '../../../languageHandler'; import ContentMessages from '../../../ContentMessages'; -import {Key} from "../../../Keyboard"; +import {Key, isOnlyCtrlOrCmdKeyEvent} from "../../../Keyboard"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; import {MatrixClientPeg} from "../../../MatrixClientPeg"; import RateLimitedFunc from '../../../ratelimitedfunc'; import {Action} from "../../../dispatcher/actions"; +import SettingsStore from "../../../settings/SettingsStore"; function addReplyToMessageContent(content, repliedToEvent, permalinkCreator) { const replyContent = ReplyThread.makeReplyMixIn(repliedToEvent); @@ -122,7 +123,10 @@ export default class SendMessageComposer extends React.Component { return; } const hasModifier = event.altKey || event.ctrlKey || event.metaKey || event.shiftKey; - if (event.key === Key.ENTER && !hasModifier) { + const ctrlEnterToSend = !!SettingsStore.getValue('MessageComposerInput.ctrlEnterToSend'); + const send = ctrlEnterToSend ? event.key === Key.ENTER && isOnlyCtrlOrCmdKeyEvent(event) + : event.key === Key.ENTER && !hasModifier; + if (send) { this._sendMessage(); event.preventDefault(); } else if (event.key === Key.ARROW_UP) { diff --git a/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.js b/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.js index a77815a68c..64208cb8cd 100644 --- a/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.js +++ b/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.js @@ -33,6 +33,7 @@ export default class PreferencesUserSettingsTab extends React.Component { 'MessageComposerInput.autoReplaceEmoji', 'MessageComposerInput.suggestEmoji', 'sendTypingNotifications', + 'MessageComposerInput.ctrlEnterToSend', ]; static TIMELINE_SETTINGS = [ diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 47063bdae4..277d9c5952 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -477,6 +477,7 @@ "Enable big emoji in chat": "Enable big emoji in chat", "Send typing notifications": "Send typing notifications", "Show typing notifications": "Show typing notifications", + "Use Ctrl + Enter to send a message (Mac: Command + Enter)": "Use Ctrl + Enter to send a message (Mac: Command + Enter)", "Automatically replace plain text Emoji": "Automatically replace plain text Emoji", "Mirror local video feed": "Mirror local video feed", "Enable Community Filter Panel": "Enable Community Filter Panel", diff --git a/src/settings/Settings.ts b/src/settings/Settings.ts index 95861e11df..d2d268b2bb 100644 --- a/src/settings/Settings.ts +++ b/src/settings/Settings.ts @@ -321,6 +321,11 @@ export const SETTINGS: {[setting: string]: ISetting} = { displayName: _td("Show typing notifications"), default: true, }, + "MessageComposerInput.ctrlEnterToSend": { + supportedLevels: LEVELS_ACCOUNT_SETTINGS, + displayName: _td("Use Ctrl + Enter to send a message (Mac: Command + Enter)"), + default: false, + }, "MessageComposerInput.autoReplaceEmoji": { supportedLevels: LEVELS_ACCOUNT_SETTINGS, displayName: _td('Automatically replace plain text Emoji'), From 9031c58aebd08b7c6ab07e173f9895006491af5c Mon Sep 17 00:00:00 2001 From: Clemens Zeidler Date: Tue, 8 Sep 2020 21:46:09 +1200 Subject: [PATCH 02/45] Make settings label platform specific --- src/i18n/strings/en_EN.json | 3 ++- src/settings/Settings.ts | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 277d9c5952..a66478ddc9 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -477,7 +477,8 @@ "Enable big emoji in chat": "Enable big emoji in chat", "Send typing notifications": "Send typing notifications", "Show typing notifications": "Show typing notifications", - "Use Ctrl + Enter to send a message (Mac: Command + Enter)": "Use Ctrl + Enter to send a message (Mac: Command + Enter)", + "Use Command + Enter to send a message": "Use Command + Enter to send a message", + "Use Ctrl + Enter to send a message": "Use Ctrl + Enter to send a message", "Automatically replace plain text Emoji": "Automatically replace plain text Emoji", "Mirror local video feed": "Mirror local video feed", "Enable Community Filter Panel": "Enable Community Filter Panel", diff --git a/src/settings/Settings.ts b/src/settings/Settings.ts index d2d268b2bb..afe9a50c1e 100644 --- a/src/settings/Settings.ts +++ b/src/settings/Settings.ts @@ -32,6 +32,7 @@ import UseSystemFontController from './controllers/UseSystemFontController'; import { SettingLevel } from "./SettingLevel"; import SettingController from "./controllers/SettingController"; import { RightPanelPhases } from "../stores/RightPanelStorePhases"; +import { isMac } from '../Keyboard'; // These are just a bunch of helper arrays to avoid copy/pasting a bunch of times const LEVELS_ROOM_SETTINGS = [ @@ -323,7 +324,7 @@ export const SETTINGS: {[setting: string]: ISetting} = { }, "MessageComposerInput.ctrlEnterToSend": { supportedLevels: LEVELS_ACCOUNT_SETTINGS, - displayName: _td("Use Ctrl + Enter to send a message (Mac: Command + Enter)"), + displayName: isMac ? _td("Use Command + Enter to send a message") : _td("Use Ctrl + Enter to send a message"), default: false, }, "MessageComposerInput.autoReplaceEmoji": { From e15041bd53538a325b4d18a21c0e98b24f77938b Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Mon, 2 Nov 2020 15:17:05 -0700 Subject: [PATCH 03/45] Add a custom widget API action for viewing a different room --- .../views/dialogs/ModalWidgetDialog.tsx | 2 +- src/stores/widgets/ElementWidgetActions.ts | 9 +++++ .../widgets/ElementWidgetCapabilities.ts | 19 ++++++++++ src/stores/widgets/StopGapWidget.ts | 37 +++++++++++++++++-- src/stores/widgets/StopGapWidgetDriver.ts | 29 +++++++++++++-- 5 files changed, 89 insertions(+), 7 deletions(-) create mode 100644 src/stores/widgets/ElementWidgetCapabilities.ts diff --git a/src/components/views/dialogs/ModalWidgetDialog.tsx b/src/components/views/dialogs/ModalWidgetDialog.tsx index 6ce3230a7a..16cf89c340 100644 --- a/src/components/views/dialogs/ModalWidgetDialog.tsx +++ b/src/components/views/dialogs/ModalWidgetDialog.tsx @@ -61,7 +61,7 @@ export default class ModalWidgetDialog extends React.PureComponent this.emit("preparing")); this.messaging.on("ready", () => this.emit("ready")); @@ -298,6 +300,35 @@ export class StopGapWidget extends EventEmitter { ActiveWidgetStore.setRoomId(this.mockWidget.id, this.appTileProps.room.roomId); } + // Always attach a handler for ViewRoom, but permission check it internally + this.messaging.on(`action:${ElementWidgetActions.ViewRoom}`, (ev: CustomEvent) => { + ev.preventDefault(); // stop the widget API from auto-rejecting this + + // Check up front if this is even a valid request + const targetRoomId = (ev.detail.data || {}).room_id; + if (!targetRoomId) { + return this.messaging.transport.reply(ev.detail, { + error: {message: "Invalid room ID."}, + }); + } + + // Check the widget's permission + if (!this.messaging.hasCapability(ElementWidgetCapabilities.CanChangeViewedRoom)) { + return this.messaging.transport.reply(ev.detail, { + error: {message: "This widget does not have permission for this action (denied)."}, + }); + } + + // at this point we can change rooms, so do that + defaultDispatcher.dispatch({ + action: 'view_room', + room_id: targetRoomId, + }); + + // acknowledge so the widget doesn't freak out + this.messaging.transport.reply(ev.detail, {}); + }); + if (WidgetType.JITSI.matches(this.mockWidget.type)) { this.messaging.on("action:set_always_on_screen", (ev: CustomEvent) => { diff --git a/src/stores/widgets/StopGapWidgetDriver.ts b/src/stores/widgets/StopGapWidgetDriver.ts index b54e4a5f7d..9b455ac481 100644 --- a/src/stores/widgets/StopGapWidgetDriver.ts +++ b/src/stores/widgets/StopGapWidgetDriver.ts @@ -14,17 +14,40 @@ * limitations under the License. */ -import { Capability, WidgetDriver } from "matrix-widget-api"; +import { Capability, WidgetDriver, WidgetType } from "matrix-widget-api"; import { iterableUnion } from "../../utils/iterables"; +import { MatrixClientPeg } from "../../MatrixClientPeg"; +import { arrayFastClone } from "../../utils/arrays"; +import { ElementWidgetCapabilities } from "./ElementWidgetCapabilities"; // TODO: Purge this from the universe export class StopGapWidgetDriver extends WidgetDriver { - constructor(private allowedCapabilities: Capability[]) { + constructor(private allowedCapabilities: Capability[], private forType: WidgetType) { super(); } public async validateCapabilities(requested: Set): Promise> { - return new Set(iterableUnion(requested, this.allowedCapabilities)); + // TODO: All of this should be a capabilities prompt. + // See https://github.com/vector-im/element-web/issues/13111 + + // Note: None of this well-known widget permissions stuff is documented intentionally. We + // do not want to encourage people relying on this, but need to be able to support it at + // the moment. + // + // If you're a widget developer and seeing this message, please ask the Element team if + // it is safe for you to use this permissions system before trying to use it - it might + // not be here in the future. + + const wkPerms = (MatrixClientPeg.get().getClientWellKnown() || {})['io.element.widget_permissions']; + const allowedCaps = arrayFastClone(this.allowedCapabilities); + if (wkPerms) { + if (Array.isArray(wkPerms["view_room_action"])) { + if (wkPerms["view_room_action"].includes(this.forType)) { + allowedCaps.push(ElementWidgetCapabilities.CanChangeViewedRoom); + } + } + } + return new Set(iterableUnion(requested, allowedCaps)); } } From f5cd079a16abf3099de37c0521bffd655c567617 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Mon, 2 Nov 2020 21:32:49 -0700 Subject: [PATCH 04/45] Add support for sending/receiving events from widgets Part of MSC2762: https://github.com/matrix-org/matrix-doc/pull/2762 Requires: https://github.com/matrix-org/matrix-widget-api/pull/9 This is the bare minimum required to send an event to a widget and receive events from widgets. Like the view_room action, this is controlled by a well-known permission key. **Danger**: This allows widgets to potentially modify room state. Use the permissions with care. --- src/stores/widgets/StopGapWidget.ts | 32 +++++++++++++++++++++++ src/stores/widgets/StopGapWidgetDriver.ts | 26 +++++++++++++++++- 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/src/stores/widgets/StopGapWidget.ts b/src/stores/widgets/StopGapWidget.ts index 441e037ddf..1c26b67faf 100644 --- a/src/stores/widgets/StopGapWidget.ts +++ b/src/stores/widgets/StopGapWidget.ts @@ -55,6 +55,8 @@ import ThemeWatcher from "../../settings/watchers/ThemeWatcher"; import {getCustomTheme} from "../../theme"; import CountlyAnalytics from "../../CountlyAnalytics"; import { ElementWidgetCapabilities } from "./ElementWidgetCapabilities"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import ActiveRoomObserver from "../../ActiveRoomObserver"; // TODO: Destroy all of this code @@ -329,6 +331,10 @@ export class StopGapWidget extends EventEmitter { this.messaging.transport.reply(ev.detail, {}); }); + // Attach listeners for feeding events - the underlying widget classes handle permissions for us + MatrixClientPeg.get().on('event', this.onEvent); + MatrixClientPeg.get().on('Event.decrypted', this.onEventDecrypted); + if (WidgetType.JITSI.matches(this.mockWidget.type)) { this.messaging.on("action:set_always_on_screen", (ev: CustomEvent) => { @@ -422,5 +428,31 @@ export class StopGapWidget extends EventEmitter { if (!this.started) return; WidgetMessagingStore.instance.stopMessaging(this.mockWidget); ActiveWidgetStore.delRoomId(this.mockWidget.id); + + if (MatrixClientPeg.get()) { + MatrixClientPeg.get().off('event', this.onEvent); + MatrixClientPeg.get().off('Event.decrypted', this.onEventDecrypted); + } + } + + private onEvent = (ev: MatrixEvent) => { + if (ev.isBeingDecrypted() || ev.isDecryptionFailure()) return; + if (ev.getRoomId() !== ActiveRoomObserver.activeRoomId) return; + this.feedEvent(ev); + }; + + private onEventDecrypted = (ev: MatrixEvent) => { + if (ev.isDecryptionFailure()) return; + if (ev.getRoomId() !== ActiveRoomObserver.activeRoomId) return; + this.feedEvent(ev); + }; + + private feedEvent(ev: MatrixEvent) { + if (!this.messaging) return; + + const raw = ev.event; + this.messaging.feedEvent(raw).catch(e => { + console.error("Error sending event to widget: ", e); + }); } } diff --git a/src/stores/widgets/StopGapWidgetDriver.ts b/src/stores/widgets/StopGapWidgetDriver.ts index 9b455ac481..5c2d1868aa 100644 --- a/src/stores/widgets/StopGapWidgetDriver.ts +++ b/src/stores/widgets/StopGapWidgetDriver.ts @@ -14,11 +14,12 @@ * limitations under the License. */ -import { Capability, WidgetDriver, WidgetType } from "matrix-widget-api"; +import { Capability, ISendEventDetails, WidgetDriver, WidgetEventCapability, WidgetType } from "matrix-widget-api"; import { iterableUnion } from "../../utils/iterables"; import { MatrixClientPeg } from "../../MatrixClientPeg"; import { arrayFastClone } from "../../utils/arrays"; import { ElementWidgetCapabilities } from "./ElementWidgetCapabilities"; +import ActiveRoomObserver from "../../ActiveRoomObserver"; // TODO: Purge this from the universe @@ -47,7 +48,30 @@ export class StopGapWidgetDriver extends WidgetDriver { allowedCaps.push(ElementWidgetCapabilities.CanChangeViewedRoom); } } + if (Array.isArray(wkPerms["event_actions"])) { + if (wkPerms["event_actions"].includes(this.forType)) { + allowedCaps.push(...WidgetEventCapability.findEventCapabilities(requested).map(c => c.raw)); + } + } } return new Set(iterableUnion(requested, allowedCaps)); } + + public async sendEvent(eventType: string, content: any, stateKey: string = null): Promise { + const client = MatrixClientPeg.get(); + const roomId = ActiveRoomObserver.activeRoomId; + + if (!client || !roomId) throw new Error("Not in a room or not attached to a client"); + + let r: {event_id: string} = null; + if (stateKey !== null) { + // state event + r = await client.sendStateEvent(roomId, eventType, content, stateKey); + } else { + // message event + r = await client.sendEvent(roomId, eventType, content); + } + + return {roomId, eventId: r.event_id}; + } } From fc90531c9f82df544a1b2229ccead326ec80bf8c Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Mon, 2 Nov 2020 21:38:59 -0700 Subject: [PATCH 05/45] Appease the linter --- src/stores/widgets/ElementWidgetActions.ts | 2 +- src/stores/widgets/StopGapWidgetDriver.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/stores/widgets/ElementWidgetActions.ts b/src/stores/widgets/ElementWidgetActions.ts index b908806069..76390086ab 100644 --- a/src/stores/widgets/ElementWidgetActions.ts +++ b/src/stores/widgets/ElementWidgetActions.ts @@ -25,6 +25,6 @@ export enum ElementWidgetActions { export interface IViewRoomApiRequest extends IWidgetApiRequest { data: { - room_id: string; + room_id: string; // eslint-disable-line camelcase }; } diff --git a/src/stores/widgets/StopGapWidgetDriver.ts b/src/stores/widgets/StopGapWidgetDriver.ts index 5c2d1868aa..e2dbf3568e 100644 --- a/src/stores/widgets/StopGapWidgetDriver.ts +++ b/src/stores/widgets/StopGapWidgetDriver.ts @@ -63,7 +63,7 @@ export class StopGapWidgetDriver extends WidgetDriver { if (!client || !roomId) throw new Error("Not in a room or not attached to a client"); - let r: {event_id: string} = null; + let r: {event_id: string} = null; // eslint-disable-line camelcase if (stateKey !== null) { // state event r = await client.sendStateEvent(roomId, eventType, content, stateKey); From 453c956d063b8d73c0f7b1d8d8235c0da79bef4e Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Mon, 9 Nov 2020 14:44:53 +0000 Subject: [PATCH 06/45] Add Analytics instrumentation to the Homepage --- src/components/structures/HomePage.tsx | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/src/components/structures/HomePage.tsx b/src/components/structures/HomePage.tsx index 8058ddad93..32ae40db58 100644 --- a/src/components/structures/HomePage.tsx +++ b/src/components/structures/HomePage.tsx @@ -34,10 +34,26 @@ import {useEventEmitter} from "../../hooks/useEventEmitter"; import MatrixClientContext from "../../contexts/MatrixClientContext"; import classNames from "classnames"; import {ENTERING} from "react-transition-group/Transition"; +import Analytics from "../../Analytics"; +import CountlyAnalytics from "../../CountlyAnalytics"; -const onClickSendDm = () => dis.dispatch({action: 'view_create_chat'}); -const onClickExplore = () => dis.fire(Action.ViewRoomDirectory); -const onClickNewRoom = () => dis.dispatch({action: 'view_create_room'}); +const onClickSendDm = () => { + Analytics.trackEvent('home_page', 'button', 'dm'); + CountlyAnalytics.instance.track("home_page_button", { button: "dm" }); + dis.dispatch({action: 'view_create_chat'}); +}; + +const onClickExplore = () => { + Analytics.trackEvent('home_page', 'button', 'room_directory'); + CountlyAnalytics.instance.track("home_page_button", { button: "room_directory" }); + dis.fire(Action.ViewRoomDirectory); +}; + +const onClickNewRoom = () => { + Analytics.trackEvent('home_page', 'button', 'create_room'); + CountlyAnalytics.instance.track("home_page_button", { button: "create_room" }); + dis.dispatch({action: 'view_create_room'}); +}; interface IProps { justRegistered?: boolean; @@ -69,6 +85,8 @@ const UserWelcomeTop = () => { onChange={async (ev) => { if (!ev.target.files?.length) return; setBusy(true); + Analytics.trackEvent("home_page", "upload_avatar"); + CountlyAnalytics.instance.track("home_page_upload_avatar"); const file = ev.target.files[0]; const uri = await cli.uploadContent(file); await cli.setAvatarUrl(uri); From 33b7367d820f537e7ee5e32e52720b8cbac8a381 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Thu, 12 Nov 2020 10:36:30 -0700 Subject: [PATCH 07/45] Fix room ID handling --- src/stores/widgets/StopGapWidget.ts | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/src/stores/widgets/StopGapWidget.ts b/src/stores/widgets/StopGapWidget.ts index 1c26b67faf..73399a5086 100644 --- a/src/stores/widgets/StopGapWidget.ts +++ b/src/stores/widgets/StopGapWidget.ts @@ -56,7 +56,6 @@ import {getCustomTheme} from "../../theme"; import CountlyAnalytics from "../../CountlyAnalytics"; import { ElementWidgetCapabilities } from "./ElementWidgetCapabilities"; import { MatrixEvent } from "matrix-js-sdk/src/models/event"; -import ActiveRoomObserver from "../../ActiveRoomObserver"; // TODO: Destroy all of this code @@ -151,6 +150,7 @@ export class StopGapWidget extends EventEmitter { private messaging: ClientWidgetApi; private mockWidget: ElementWidget; private scalarToken: string; + private roomId?: string; constructor(private appTileProps: IAppTileProps) { super(); @@ -163,6 +163,18 @@ export class StopGapWidget extends EventEmitter { } this.mockWidget = new ElementWidget(app); + this.roomId = appTileProps.room?.roomId; + } + + private get eventListenerRoomId(): string { + // When widgets are listening to events, we need to make sure they're only + // receiving events for the right room. In particular, room widgets get locked + // to the room they were added in while account widgets listen to the currently + // active room. + + if (this.roomId) return this.roomId; + + return RoomViewStore.getRoomId(); } public get widgetApi(): ClientWidgetApi { @@ -310,7 +322,7 @@ export class StopGapWidget extends EventEmitter { const targetRoomId = (ev.detail.data || {}).room_id; if (!targetRoomId) { return this.messaging.transport.reply(ev.detail, { - error: {message: "Invalid room ID."}, + error: {message: "Room ID not supplied."}, }); } @@ -437,13 +449,13 @@ export class StopGapWidget extends EventEmitter { private onEvent = (ev: MatrixEvent) => { if (ev.isBeingDecrypted() || ev.isDecryptionFailure()) return; - if (ev.getRoomId() !== ActiveRoomObserver.activeRoomId) return; + if (ev.getRoomId() !== this.eventListenerRoomId) return; this.feedEvent(ev); }; private onEventDecrypted = (ev: MatrixEvent) => { if (ev.isDecryptionFailure()) return; - if (ev.getRoomId() !== ActiveRoomObserver.activeRoomId) return; + if (ev.getRoomId() !== this.eventListenerRoomId) return; this.feedEvent(ev); }; From c9215678314f5b34e0272de7f84cb882988e8c76 Mon Sep 17 00:00:00 2001 From: David Baker Date: Thu, 12 Nov 2020 18:09:56 +0000 Subject: [PATCH 08/45] WIP: the new call views work now just need to add the buttons and then get rid of the status bar --- res/css/views/avatars/_BaseAvatar.scss | 2 +- res/css/views/voip/_CallView.scss | 145 +++++++++++---- res/css/views/voip/_VideoFeed.scss | 15 +- src/components/views/avatars/PulsedAvatar.tsx | 28 --- src/components/views/voip/CallPreview.tsx | 12 +- src/components/views/voip/CallView.tsx | 175 ++++++++++++------ src/components/views/voip/IncomingCallBox.tsx | 13 +- src/components/views/voip/VideoFeed.tsx | 4 +- src/i18n/strings/en_EN.json | 5 +- 9 files changed, 257 insertions(+), 142 deletions(-) delete mode 100644 src/components/views/avatars/PulsedAvatar.tsx diff --git a/res/css/views/avatars/_BaseAvatar.scss b/res/css/views/avatars/_BaseAvatar.scss index 1a1e14e7ac..cbddd97e18 100644 --- a/res/css/views/avatars/_BaseAvatar.scss +++ b/res/css/views/avatars/_BaseAvatar.scss @@ -41,7 +41,7 @@ limitations under the License. .mx_BaseAvatar_image { object-fit: cover; - border-radius: 40px; + border-radius: 125px; vertical-align: top; background-color: $avatar-bg-color; } diff --git a/res/css/views/voip/_CallView.scss b/res/css/views/voip/_CallView.scss index 2aeaaa87dc..eadad831ab 100644 --- a/res/css/views/voip/_CallView.scss +++ b/res/css/views/voip/_CallView.scss @@ -15,47 +15,46 @@ See the License for the specific language governing permissions and limitations under the License. */ +.mx_CallView { + border-radius: 10px; + background-color: $input-lighter-bg-color; + padding-left: 8px; + padding-right: 8px; + // XXX: CallContainer sets pointer-events: none - should probably be set back in a better place + pointer-events: initial; +} + +.mx_CallView_large { + padding-bottom: 10px; + + .mx_CallView_voice { + height: 360px; + } +} + +.mx_CallView_pip { + width: 320px; + + .mx_CallView_voice { + height: 180px + } +} + +.mx_CallView_voice { + display: flex; + align-items: center; + justify-content: center; + background-color: $inverted-bg-color; +} + +/* .mx_CallView_voice { - background-color: $accent-color; - color: $accent-fg-color; - cursor: pointer; padding: 6px; font-weight: bold; - border-radius: 8px; min-width: 200px; - - display: flex; - align-items: center; - - img { - margin: 4px; - margin-right: 10px; - } - - > div { - display: flex; - flex-direction: column; - // Hacky vertical align - padding-top: 3px; - } - - > div > p, - > div > h1 { - padding: 0; - margin: 0; - font-size: $font-13px; - line-height: $font-15px; - } - - > div > p { - font-weight: bold; - } - - > * { - flex-grow: 0; - flex-shrink: 0; - } + text-align: center; + vertical-align: middle; } .mx_CallView_hangup { @@ -92,6 +91,7 @@ limitations under the License. background-color: $primary-fg-color; } } +*/ .mx_CallView_video { width: 100%; @@ -99,3 +99,76 @@ limitations under the License. z-index: 30; } +.mx_CallView_header { + height: 44px; + display: flex; + flex-direction: row; + align-items: center; + justify-content: left; + + .mx_BaseAvatar { + margin-right: 12px; + } +} + +.mx_CallView_header_callType { + font-weight: bold; + vertical-align: middle; +} + +.mx_CallView_header_controls { + margin-left: auto; +} + +.mx_CallView_header_control_fullscreen { + display: inline-block; + vertical-align: middle; + cursor: pointer; + + &::before { + content: ''; + display: inline-block; + height: 20px; + width: 20px; + vertical-align: middle; + background-color: $secondary-fg-color; + mask-repeat: no-repeat; + mask-size: contain; + mask-position: center; + mask-image: url('$(res)/img/element-icons/call/fullscreen.svg'); + } +} + +.mx_CallView_header_roomName { + font-weight: bold; + font-size: 12px; + line-height: initial; +} + +.mx_CallView_header_callTypeSmall { + font-size: 12px; + color: $secondary-fg-color; + line-height: initial; +} + +.mx_CallView_header_phoneIcon { + display: inline-block; + margin-right: 6px; + height: 16px; + width: 16px; + vertical-align: middle; + + &::before { + content: ''; + display: inline-block; + vertical-align: top; + + height: 16px; + width: 16px; + background-color: $warning-color; + mask-repeat: no-repeat; + mask-size: contain; + mask-position: center; + mask-image: url('$(res)/img/element-icons/call/voice-call.svg'); + } +} diff --git a/res/css/views/voip/_VideoFeed.scss b/res/css/views/voip/_VideoFeed.scss index e5e3587dac..1368ead02f 100644 --- a/res/css/views/voip/_VideoFeed.scss +++ b/res/css/views/voip/_VideoFeed.scss @@ -14,9 +14,9 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_VideoFeed video { +/*.mx_VideoFeed video { width: 100%; -} +}*/ .mx_VideoFeed_remote { width: 100%; @@ -28,16 +28,17 @@ limitations under the License. width: 25%; height: 25%; position: absolute; - left: 10px; - bottom: 10px; + right: 10px; + top: 10px; z-index: 100; + border-radius: 4px; } -.mx_VideoFeed_local video { +/*.mx_VideoFeed_local video { width: auto; height: 100%; -} +}*/ -.mx_VideoFeed_mirror video { +.mx_VideoFeed_mirror { transform: scale(-1, 1); } diff --git a/src/components/views/avatars/PulsedAvatar.tsx b/src/components/views/avatars/PulsedAvatar.tsx deleted file mode 100644 index b4e876b9f6..0000000000 --- a/src/components/views/avatars/PulsedAvatar.tsx +++ /dev/null @@ -1,28 +0,0 @@ -/* -Copyright 2020 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'; - -interface IProps { -} - -const PulsedAvatar: React.FC = (props) => { - return
- {props.children} -
; -}; - -export default PulsedAvatar; diff --git a/src/components/views/voip/CallPreview.tsx b/src/components/views/voip/CallPreview.tsx index 3d9235792b..8e1b0dd963 100644 --- a/src/components/views/voip/CallPreview.tsx +++ b/src/components/views/voip/CallPreview.tsx @@ -26,6 +26,15 @@ import PersistentApp from "../elements/PersistentApp"; import SettingsStore from "../../../settings/SettingsStore"; import { CallState, MatrixCall } from 'matrix-js-sdk/src/webrtc/call'; +const SHOW_CALL_IN_STATES = [ + CallState.Connected, + CallState.InviteSent, + CallState.Connecting, + CallState.CreateAnswer, + CallState.CreateOffer, + CallState.WaitLocalMedia, +]; + interface IProps { } @@ -94,14 +103,13 @@ export default class CallPreview extends React.Component { const callForRoom = CallHandler.sharedInstance().getCallForRoom(this.state.roomId); const showCall = ( this.state.activeCall && - this.state.activeCall.state === CallState.Connected && + SHOW_CALL_IN_STATES.includes(this.state.activeCall.state) && !callForRoom ); if (showCall) { return ( diff --git a/src/components/views/voip/CallView.tsx b/src/components/views/voip/CallView.tsx index 653a72cca0..7288cd1d5b 100644 --- a/src/components/views/voip/CallView.tsx +++ b/src/components/views/voip/CallView.tsx @@ -21,10 +21,8 @@ import dis from '../../../dispatcher/dispatcher'; import CallHandler from '../../../CallHandler'; import {MatrixClientPeg} from '../../../MatrixClientPeg'; import { _t } from '../../../languageHandler'; -import AccessibleButton from '../elements/AccessibleButton'; import VideoFeed, { VideoFeedType } from "./VideoFeed"; import RoomAvatar from "../avatars/RoomAvatar"; -import PulsedAvatar from '../avatars/PulsedAvatar'; import { CallState, CallType, MatrixCall } from 'matrix-js-sdk/src/webrtc/call'; import { CallEvent } from 'matrix-js-sdk/src/webrtc/call'; @@ -43,9 +41,6 @@ interface IProps { // in a way that is likely to cause a resize. onResize?: any; - // classname applied to view, - className?: string; - // Whether to show the hang up icon:W showHangup?: boolean; } @@ -85,7 +80,7 @@ function exitFullscreen() { export default class CallView extends React.Component { private dispatcherRef: string; - private container = createRef(); + private contentRef = createRef(); constructor(props: IProps) { super(props); @@ -111,11 +106,11 @@ export default class CallView extends React.Component { private onAction = (payload) => { switch (payload.action) { case 'video_fullscreen': { - if (!this.container.current) { + if (!this.contentRef.current) { return; } if (payload.fullscreen) { - requestFullscreen(this.container.current); + requestFullscreen(this.contentRef.current); } else if (getFullScreenElement()) { exitFullscreen(); } @@ -144,11 +139,6 @@ export default class CallView extends React.Component { if (this.props.room) { const roomId = this.props.room.roomId; call = CallHandler.sharedInstance().getCallForRoom(roomId); - - // We don't currently show voice calls in this view when in the room: - // they're represented in the room status bar at the bottom instead - // (but this will all change with the new designs) - if (call && call.type == CallType.Voice) call = null; } else { call = CallHandler.sharedInstance().getAnyActiveCall(); // Ignore calls if we can't get the room associated with them. @@ -160,7 +150,7 @@ export default class CallView extends React.Component { } } - if (call && call.state == CallState.Ended) return null; + if (call && [CallState.Ended, CallState.Ringing].includes(call.state)) return null; return call; } @@ -177,51 +167,91 @@ export default class CallView extends React.Component { }); }; + private onFullscreenClick = () => { + dis.dispatch({ + action: 'video_fullscreen', + fullscreen: true, + }); + }; + public render() { - let view: React.ReactNode; + if (!this.state.call) return null; - if (this.state.call) { - if (this.state.call.type === "voice") { - const client = MatrixClientPeg.get(); - const callRoom = client.getRoom(this.state.call.roomId); + const client = MatrixClientPeg.get(); + const callRoom = client.getRoom(this.state.call.roomId); - let caption = _t("Active call"); - if (this.state.isLocalOnHold) { - // we currently have no UI for holding / unholding a call (apart from slash - // commands) so we don't disintguish between when we've put the call on hold - // (ie. we'd show an unhold button) and when the other side has put us on hold - // (where obviously we would not show such a button). - caption = _t("Call Paused"); - } + //const callControls =
- view = - - - -
-

{callRoom.name}

-

{ caption }

-
-
; - } else { - // For video calls, we currently ignore the call hold state altogether - // (the video will just go black) + //
; - // if we're fullscreen, we don't want to set a maxHeight on the video element. - const maxVideoHeight = getFullScreenElement() ? null : this.props.maxVideoHeight; - view =
- - -
; - } + // The 'content' for the call, ie. the videos for a video call and profile picture + // for voice calls (fills the bg) + let contentView: React.ReactNode; + + if (this.state.call.type === CallType.Video) { + // if we're fullscreen, we don't want to set a maxHeight on the video element. + const maxVideoHeight = getFullScreenElement() ? null : this.props.maxVideoHeight; + contentView =
+ + +
; + } else { + const avatarSize = this.props.room ? 200 : 75; + contentView =
+ +
; } + /* + if (!this.props.room) { + const client = MatrixClientPeg.get(); + const callRoom = client.getRoom(this.state.call.roomId); + + let caption = _t("Active call"); + if (this.state.isLocalOnHold) { + // we currently have no UI for holding / unholding a call (apart from slash + // commands) so we don't disintguish between when we've put the call on hold + // (ie. we'd show an unhold button) and when the other side has put us on hold + // (where obviously we would not show such a button). + caption = _t("Call Paused"); + } + + view = + + + +
+

{callRoom.name}

+

{ caption }

+
+
; + } else { + // For video calls, we currently ignore the call hold state altogether + // (the video will just go black) + + // if we're fullscreen, we don't want to set a maxHeight on the video element. + const maxVideoHeight = getFullScreenElement() ? null : this.props.maxVideoHeight; + view =
+ + +
; + } + */ + + + /* let hangup: React.ReactNode; if (this.props.showHangup) { hangup =
{ }} />; } + */ - return
- {view} - {hangup} + const callTypeText = this.state.call.type === CallType.Video ? _t("Video Call") : _t("Voice Call"); + let myClassName; + + let fullScreenButton; + if (this.state.call.type === CallType.Video) { + fullScreenButton =
; + } + + const headerControls =
+ {fullScreenButton} +
; + + let header: React.ReactNode; + if (this.props.room) { + header =
+
+ {callTypeText} + {headerControls} +
; + myClassName = 'mx_CallView_large'; + } else { + header =
+ +
+
{callRoom.name}
+
{callTypeText}
+
+ {headerControls} +
; + myClassName = 'mx_CallView_pip'; + } + + return
+ {header} + {contentView}
; } } diff --git a/src/components/views/voip/IncomingCallBox.tsx b/src/components/views/voip/IncomingCallBox.tsx index 355dff9ff6..0403a9eb75 100644 --- a/src/components/views/voip/IncomingCallBox.tsx +++ b/src/components/views/voip/IncomingCallBox.tsx @@ -22,7 +22,6 @@ import dis from '../../../dispatcher/dispatcher'; import { _t } from '../../../languageHandler'; import { ActionPayload } from '../../../dispatcher/payloads'; import CallHandler from '../../../CallHandler'; -import PulsedAvatar from '../avatars/PulsedAvatar'; import RoomAvatar from '../avatars/RoomAvatar'; import FormButton from '../elements/FormButton'; import { CallState } from 'matrix-js-sdk/lib/webrtc/call'; @@ -108,13 +107,11 @@ export default class IncomingCallBox extends React.Component { return
- - - +

{caller}

{incomingCallText}

diff --git a/src/components/views/voip/VideoFeed.tsx b/src/components/views/voip/VideoFeed.tsx index 9dba9fa9c8..5fb71a6d69 100644 --- a/src/components/views/voip/VideoFeed.tsx +++ b/src/components/views/voip/VideoFeed.tsx @@ -73,8 +73,6 @@ export default class VideoFeed extends React.Component { let videoStyle = {}; if (this.props.maxHeight) videoStyle = { maxHeight: this.props.maxHeight }; - return
- -
; + return