diff --git a/package.json b/package.json index b4ec36dad7e..8c100feba25 100644 --- a/package.json +++ b/package.json @@ -151,7 +151,7 @@ "mocha": "^5.0.5", "react-addons-test-utils": "^15.4.0", "require-json": "0.0.1", - "rimraf": "^2.4.3", + "rimraf": "^2.6.2", "sinon": "^5.0.7", "source-map-loader": "^0.2.3", "stylelint": "^9.10.1", diff --git a/res/css/views/settings/tabs/user/_VoiceUserSettingsTab.scss b/res/css/views/settings/tabs/user/_VoiceUserSettingsTab.scss index 36c8cfd896e..0eedf209856 100644 --- a/res/css/views/settings/tabs/user/_VoiceUserSettingsTab.scss +++ b/res/css/views/settings/tabs/user/_VoiceUserSettingsTab.scss @@ -18,6 +18,20 @@ limitations under the License. margin-right: 100px; // align with the rest of the fields } +.mx_Shortcut { + height: 30px; +} + +.mx_Shortcut_label { + margin-left: 30px; +} + +.mx_VoiceUserSettingsTab .mx_ShortcutSet { + float: right; + width: 48px; + margin-right: 17px; +} + .mx_VoiceUserSettingsTab_missingMediaPermissions { margin-bottom: 15px; } diff --git a/src/FromWidgetPostMessageApi.js b/src/FromWidgetPostMessageApi.js index d34e3d8ed0e..900d64ca2e1 100644 --- a/src/FromWidgetPostMessageApi.js +++ b/src/FromWidgetPostMessageApi.js @@ -24,10 +24,11 @@ import MatrixClientPeg from "./MatrixClientPeg"; import RoomViewStore from "./stores/RoomViewStore"; import { showIntegrationsManager } from './integrations/integrations'; -const WIDGET_API_VERSION = '0.0.2'; // Current API version +const WIDGET_API_VERSION = '0.0.3'; // Current API version const SUPPORTED_WIDGET_API_VERSIONS = [ '0.0.1', '0.0.2', + '0.0.3', ]; const INBOUND_API_NAME = 'fromWidget'; diff --git a/src/PushToTalk.js b/src/PushToTalk.js new file mode 100644 index 00000000000..6e2dd563607 --- /dev/null +++ b/src/PushToTalk.js @@ -0,0 +1,83 @@ +/* +Copyright 2019 New Vector Ltd + +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 PlatformPeg from './PlatformPeg'; +import ActiveWidgetStore from './stores/ActiveWidgetStore'; +import SettingsStore, { SettingLevel } from './settings/SettingsStore'; + +export default class PushToTalk { + static id = "pushToTalk"; + + static start(keybinding) { + // Call starting, start listening for keys + const currentPTTState = SettingsStore.getValue(this.id); + currentPTTState.isInCall = true; + SettingsStore.setValue(this.id, null, SettingLevel.DEVICE, currentPTTState); + this.setKeybinding(keybinding); + } + + static stop() { + // Call finished, stop listening for keys + const currentPTTState = SettingsStore.getValue(this.id); + currentPTTState.isInCall = false; + SettingsStore.setValue(this.id, null, SettingLevel.DEVICE, currentPTTState); + this.removeKeybinding(); + } + + static setKeybinding(keybinding) { + // Add keybinding listener + PlatformPeg.get().addGlobalKeybinding(this.id, keybinding, () => { + const widgetId = ActiveWidgetStore.getPersistentWidgetId(); + + // Only try to un/mute if jitsi is onscreen + if (widgetId === null || widgetId === undefined) { + return; + } + + const widgetMessaging = ActiveWidgetStore.getWidgetMessaging(widgetId); + + // Toggle mic if in toggle mode, else just activate mic + if (SettingsStore.getValue(this.id).toggle) { + widgetMessaging.toggleJitsiAudio(); + } else { + widgetMessaging.unmuteJitsiAudio(); + } + }, () => { + // No release functionality if toggle mode is enabled + if (SettingsStore.getValue(this.id).toggle === true) { + return; + } + + const widgetId = ActiveWidgetStore.getPersistentWidgetId(); + + // Only try to un/mute if jitsi is onscreen + if (widgetId === null || widgetId === undefined) { + return; + } + + const widgetMessaging = ActiveWidgetStore.getWidgetMessaging(widgetId); + widgetMessaging.muteJitsiAudio(); + }); + PlatformPeg.get().startListeningKeys(); + } + + static removeKeybinding() { + // Remove keybinding listener + const keybinding = SettingsStore.getValue(this.id).keybinding; + PlatformPeg.get().removeGlobalKeybinding(this.id, keybinding); + PlatformPeg.get().stopListeningKeys(); + } +} diff --git a/src/WidgetMessaging.js b/src/WidgetMessaging.js index 1d8e1b9cd39..cc0d7c77858 100644 --- a/src/WidgetMessaging.js +++ b/src/WidgetMessaging.js @@ -104,6 +104,45 @@ export default class WidgetMessaging { }); } + /** + * Toggle Jitsi Audio Mute + * @return {Promise} To be resolved when action completed + */ + toggleJitsiAudio() { + return this.messageToWidget({ + api: OUTBOUND_API_NAME, + action: "audioToggle", + }).then((response) => { + return response.success; + }); + } + + /** + * Jitsi Audio Mute + * @return {Promise} To be resolved when action completed + */ + muteJitsiAudio() { + return this.messageToWidget({ + api: OUTBOUND_API_NAME, + action: "audioMute", + }).then((response) => { + return response.success; + }); + } + + /** + * Jitsi Audio Unmute + * @return {Promise} To be resolved when action completed + */ + unmuteJitsiAudio() { + return this.messageToWidget({ + api: OUTBOUND_API_NAME, + action: "audioUnmute", + }).then((response) => { + return response.success; + }); + } + sendVisibility(visible) { return this.messageToWidget({ api: OUTBOUND_API_NAME, diff --git a/src/components/views/elements/PersistedElement.js b/src/components/views/elements/PersistedElement.js index d23d6488b62..8189bfdc37f 100644 --- a/src/components/views/elements/PersistedElement.js +++ b/src/components/views/elements/PersistedElement.js @@ -17,8 +17,10 @@ limitations under the License. import React from 'react'; import ReactDOM from 'react-dom'; import PropTypes from 'prop-types'; - import ResizeObserver from 'resize-observer-polyfill'; +import SettingsStore from '../../../settings/SettingsStore'; + +import PushToTalk from '../../../PushToTalk'; import dis from '../../../dispatcher'; @@ -112,6 +114,11 @@ export default class PersistedElement extends React.Component { } componentDidMount() { + // Start Push-To-Talk service when Jitsi widget is mounted + if (this.props.persistKey.includes('jitsi') && SettingsStore.getValue(PushToTalk.id).enabled) { + PushToTalk.start(SettingsStore.getValue(PushToTalk.id).keybinding); + } + this.updateChild(); } @@ -124,6 +131,11 @@ export default class PersistedElement extends React.Component { this.resizeObserver.disconnect(); window.removeEventListener('resize', this._repositionChild); dis.unregister(this._dispatcherRef); + + // Stop Push-To-Talk service when Jitsi widget is unmounted + if (this.props.persistKey.includes('jitsi') && SettingsStore.getValue(PushToTalk.id).enabled) { + PushToTalk.stop(); + } } _onAction(payload) { diff --git a/src/components/views/settings/tabs/user/VoiceUserSettingsTab.js b/src/components/views/settings/tabs/user/VoiceUserSettingsTab.js index eb85fe4e44b..3cd1517ceac 100644 --- a/src/components/views/settings/tabs/user/VoiceUserSettingsTab.js +++ b/src/components/views/settings/tabs/user/VoiceUserSettingsTab.js @@ -19,10 +19,20 @@ import {_t} from "../../../../../languageHandler"; import CallMediaHandler from "../../../../../CallMediaHandler"; import Field from "../../../elements/Field"; import AccessibleButton from "../../../elements/AccessibleButton"; -import {SettingLevel} from "../../../../../settings/SettingsStore"; +import ToggleSwitch from "../../../elements/ToggleSwitch"; +import SettingsStore, {SettingLevel} from "../../../../../settings/SettingsStore"; +import PushToTalk from "../../../../../PushToTalk"; const Modal = require("../../../../../Modal"); const sdk = require("../../../../.."); const MatrixClientPeg = require("../../../../../MatrixClientPeg"); +const PlatformPeg = require("../../../../../PlatformPeg"); + +// When removing a listener, you must specify the same function as when you added it +// Thus we save the listener functions in a global variable to access later when +// decommissioning them +let listenKeydown = null; +let listenKeyup = null; +let globalListenKeydown = null; export default class VoiceUserSettingsTab extends React.Component { constructor() { @@ -33,6 +43,10 @@ export default class VoiceUserSettingsTab extends React.Component { activeAudioOutput: null, activeAudioInput: null, activeVideoInput: null, + pushToTalkAscii: SettingsStore.getValue(PushToTalk.id).ascii, + pushToTalkKeybinding: SettingsStore.getValue(PushToTalk.id).keybinding, + pushToTalkEnabled: SettingsStore.getValue(PushToTalk.id).enabled, + pushToTalkToggleModeEnabled: SettingsStore.getValue(PushToTalk.id).toggle, }; } @@ -43,6 +57,206 @@ export default class VoiceUserSettingsTab extends React.Component { } } + componentWillUnmount(): void { + if (PlatformPeg.get().supportsAutoLaunch()) { + // Stop recording push-to-talk shortcut if Settings or tab is closed + this._stopRecordingGlobalShortcut(); + } + } + + _translateKeybinding = (key) => { + // KeyA -> A + if (key.startsWith('Key')) { + key = key.substring(3); + } + + // Custom translations to make keycodes look nicer + const translations = { + "ShiftLeft": "Shift", + "AltLeft": "Alt", + }; + if (key in translations) { + key = translations[key]; + } + + return key; + } + + _startRecordingGlobalShortcut = (asciiStateKey, codeStateKey) => { + const keyAscii = []; + const keyCodes = []; + const self = this; + + // Record keypresses using KeyboardEvent + // Used for displaying ascii-representation of current keys + // in the UI + listenKeydown = (event) => { + const key = self._translateKeybinding(event.code); + const index = keyAscii.indexOf(key); + if (index === -1) { + keyAscii.push(key); + self.setState({pushToTalkAscii: keyAscii.join(' + ')}); + } + event.preventDefault(); + }; + + listenKeyup = (event) => { + const index = keyAscii.indexOf(self._translateKeybinding(event.code)); + if (index !== -1) { + keyAscii.splice(index, 1); + } + event.preventDefault(); + }; + + window.addEventListener("keydown", listenKeydown); + window.addEventListener("keyup", listenKeyup); + + // Record keypresses using iohook + // Used for getting keycode-representation of current keys + // for later global shortcut registration + PlatformPeg.get().startListeningKeys(); + + // When a key is pressed, add all current pressed keys to the shortcut + // When a key is lifted, don't remove it from the shortcut + + // This enables a nicer shortcut-recording experience, as the user can + // press down their desired keys, release them, and then save the + // shortcut without all the keys disappearing + globalListenKeydown = (ev, event) => { + if (event.keydown) { + const index = keyCodes.indexOf(event.keycode); + if (index === -1) { + keyCodes.push(event.keycode); + // slice is needed here to save a new copy of the keycodes + // array to the state, else if we update keycodes later, it + // still updates the state since the state has a ref to this array + this.setState({pushToTalkKeybinding: keyCodes.slice()}); + } + } else { + const index = keyCodes.indexOf(event.keycode); + if (index !== -1) { + keyCodes.splice(index, 1); + } + } + }; + PlatformPeg.get().onKeypress(this, globalListenKeydown); + + // Stop recording shortcut if window loses focus + PlatformPeg.get().onWindowBlurred(() => { + if (this.state.settingKeybinding) { + // TODO: Stop this from killing PTT on blur + //this._stopRecordingGlobalShortcut(); + } + }); + } + + _stopRecordingGlobalShortcut = () => { + // Stop recording KeyboardEvent keypresses + window.removeEventListener("keydown", listenKeydown); + window.removeEventListener("keyup", listenKeyup); + + // Stop recording iohook keypresses + PlatformPeg.get().stopListeningKeys(); + PlatformPeg.get().removeOnKeypress(this, globalListenKeydown); + + this.setState({settingKeybinding: false}); + } + + _onSetPushToTalkClicked = () => { + // Either record or save a new shortcut + const currentPTTState = SettingsStore.getValue(PushToTalk.id); + + // Determine if we're reading shortcuts or setting them + if (!this.state.settingKeybinding) { + // Start listening for keypresses and show current held shortcut on + // screen + + PushToTalk.removeKeybinding(); + + this.state.pushToTalkAscii = 'Press Keys'; + this._startRecordingGlobalShortcut('pushToTalkAscii', 'pushToTalkKeybinding'); + } else { + this._stopRecordingGlobalShortcut(); + + if (currentPTTState.isInCall) { + // Switch shortcut while in a call + PushToTalk.setKeybinding(this.state.pushToTalkKeybinding); + } + + // Set the keybinding they've currently selected + currentPTTState.keybinding = this.state.pushToTalkKeybinding; + currentPTTState.ascii = this.state.pushToTalkAscii; + + // Update push to talk keybinding + SettingsStore.setValue(PushToTalk.id, null, SettingLevel.DEVICE, currentPTTState); + } + + // Toggle setting state + this.setState({settingKeybinding: !this.state.settingKeybinding}); + } + + _onTogglePushToTalkToggleModeClicked = (enabled) => { + // Turns on/off toggle mode, which toggles the mic when the shortcut is pressed + + const currentPTTState = SettingsStore.getValue(PushToTalk.id); + currentPTTState.toggle = enabled; + SettingsStore.setValue(PushToTalk.id, null, SettingLevel.DEVICE, currentPTTState); + + this.setState({pushToTalkToggleModeEnabled: enabled}); + } + + _onTogglePushToTalkClicked = (enabled) => { + // Enable or disable push to talk functionality + const currentPTTState = SettingsStore.getValue(PushToTalk.id); + + // Set push to talk state in SettingsStore + currentPTTState.enabled = enabled; + SettingsStore.setValue(PushToTalk.id, null, SettingLevel.DEVICE, currentPTTState); + + this.setState({pushToTalkEnabled: enabled}); + } + + _renderPushToTalkSettings = () => { + const buttonLabel = this.state.settingKeybinding ? _t('Stop') : _t('Set'); + + let setToggleShortcut = null; + + if (this.state.pushToTalkEnabled) { + setToggleShortcut = ( +