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 = ( +
+
+ {_t("Push-to-talk shortcut toggles microphone")} + +
+
+
+ + {_t("Shortcut") + ": " + this.state.pushToTalkAscii + " "} + + + + +
+
+
+ ); + } + + return ( +
+
+ {SettingsStore.getDisplayName(PushToTalk.id)} + +
+ {setToggleShortcut} +
+ ); + } + _refreshMediaDevices = async (stream) => { this.setState({ mediaDevices: await CallMediaHandler.getDevices(), @@ -128,6 +342,7 @@ export default class VoiceUserSettingsTab extends React.Component { let speakerDropdown = null; let microphoneDropdown = null; let webcamDropdown = null; + let pushToTalk = null; if (this.state.mediaDevices === false) { requestButton = (
@@ -192,6 +407,10 @@ export default class VoiceUserSettingsTab extends React.Component { } } + if (PlatformPeg.get().supportsAutoLaunch()) { // If electron + pushToTalk = this._renderPushToTalkSettings(); + } + return (
{_t("Voice & Video")}
@@ -200,6 +419,7 @@ export default class VoiceUserSettingsTab extends React.Component { {speakerDropdown} {microphoneDropdown} {webcamDropdown} + {pushToTalk}
diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index c5ef2fd9894..fc98908728a 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -313,6 +313,8 @@ "Failed to join room": "Failed to join room", "Message Pinning": "Message Pinning", "Custom user status messages": "Custom user status messages", + "Push-to-Talk": "Push-to-Talk", + "Not set": "Not set", "Group & filter rooms by custom tags (refresh to apply changes)": "Group & filter rooms by custom tags (refresh to apply changes)", "Render simple counters in room header": "Render simple counters in room header", "Enable Emoji suggestions while typing": "Enable Emoji suggestions while typing", @@ -613,6 +615,10 @@ "Riot collects anonymous analytics to allow us to improve the application.": "Riot collects anonymous analytics to allow us to improve the application.", "Privacy is important to us, so we don't collect any personal or identifiable data for our analytics.": "Privacy is important to us, so we don't collect any personal or identifiable data for our analytics.", "Learn more about how we use analytics.": "Learn more about how we use analytics.", + "Stop": "Stop", + "Set": "Set", + "Push-to-talk shortcut toggles microphone": "Push-to-talk shortcut toggles microphone", + "Shortcut": "Shortcut", "No media permissions": "No media permissions", "You may need to manually permit Riot to access your microphone/webcam": "You may need to manually permit Riot to access your microphone/webcam", "Missing media permissions, click the button below to request.": "Missing media permissions, click the button below to request.", diff --git a/src/settings/Settings.js b/src/settings/Settings.js index 55085963d1a..7ff7396e289 100644 --- a/src/settings/Settings.js +++ b/src/settings/Settings.js @@ -102,6 +102,16 @@ export const SETTINGS = { default: false, controller: new CustomStatusController(), }, + "pushToTalk": { + displayName: _td('Push-to-Talk'), + supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS_WITH_CONFIG, + default: { + enabled: false, + toggle: false, + keybinding: [], + ascii: _td('Not set'), + }, + }, "feature_custom_tags": { isFeature: true, displayName: _td("Group & filter rooms by custom tags (refresh to apply changes)"), diff --git a/yarn.lock b/yarn.lock index b910bfca39c..dc399a8a488 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6807,7 +6807,7 @@ rfdc@^1.1.4: resolved "https://registry.yarnpkg.com/rfdc/-/rfdc-1.1.4.tgz#ba72cc1367a0ccd9cf81a870b3b58bd3ad07f8c2" integrity sha512-5C9HXdzK8EAqN7JDif30jqsBzavB7wLpaubisuQIGHWf2gUXSpzy6ArX/+Da8RjFpagWsCn+pIgxTMAmKw9Zug== -rimraf@2.6.3, rimraf@^2.4.3, rimraf@^2.5.4, rimraf@^2.6.0, rimraf@^2.6.1, rimraf@^2.6.3: +rimraf@2.6.3, rimraf@^2.5.4, rimraf@^2.6.0, rimraf@^2.6.1, rimraf@^2.6.2, rimraf@^2.6.3: version "2.6.3" resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.6.3.tgz#b2d104fe0d8fb27cf9e0a1cda8262dd3833c6cab" integrity sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==