diff --git a/lang/main.json b/lang/main.json index a8f4d89e0d..57c0d91ff2 100644 --- a/lang/main.json +++ b/lang/main.json @@ -823,8 +823,8 @@ "hangup": "Leave the meeting", "help": "Help", "invite": "Invite people", - "joy": "Laugh", "kick": "Kick participant", + "laugh": "Laugh", "like": "Thumbs Up", "lobbyButton": "Enable/disable lobby mode", "localRecording": "Toggle local recording controls", @@ -892,7 +892,7 @@ "hangup": "Leave the meeting", "help": "Help", "invite": "Invite people", - "joy": "Laugh", + "laugh": "Laugh", "like": "Thumbs Up", "lobbyButtonDisable": "Disable lobby mode", "lobbyButtonEnable": "Enable lobby mode", @@ -922,7 +922,7 @@ "raiseYourHand": "Raise your hand", "reactionBoo": "Send boo reaction", "reactionClap": "Send clap reaction", - "reactionJoy": "Send laugh reaction", + "reactionLaugh": "Send laugh reaction", "reactionLike": "Send thumbs up reaction", "reactionParty": "Send party popper reaction", "reactionSurprised": "Send surprised reaction", diff --git a/react/features/analytics/AnalyticsEvents.js b/react/features/analytics/AnalyticsEvents.js index 7a4b1f7be8..825dd9aca8 100644 --- a/react/features/analytics/AnalyticsEvents.js +++ b/react/features/analytics/AnalyticsEvents.js @@ -795,6 +795,23 @@ export function createToolbarEvent(buttonName, attributes = {}) { }; } +/** + * Creates an event associated with a reaction button being clicked/pressed. + * + * @param {string} buttonName - The identifier of the reaction button which was + * clicked/pressed. + * @returns {Object} The event in a format suitable for sending via + * sendAnalytics. + */ +export function createReactionMenuEvent(buttonName) { + return { + action: 'clicked', + actionSubject: buttonName, + source: 'reaction.button', + type: TYPE_UI + }; +} + /** * Creates an event which indicates that a local track was muted. * diff --git a/react/features/chat/middleware.js b/react/features/chat/middleware.js index a50e36039b..3f1a3be0e9 100644 --- a/react/features/chat/middleware.js +++ b/react/features/chat/middleware.js @@ -22,11 +22,9 @@ import { import { MiddlewareRegistry, StateListenerRegistry } from '../base/redux'; import { playSound, registerSound, unregisterSound } from '../base/sounds'; import { openDisplayNamePrompt } from '../display-name'; -import { ADD_REACTIONS_MESSAGE } from '../reactions/actionTypes'; -import { - pushReaction -} from '../reactions/actions.any'; -import { REACTIONS } from '../reactions/constants'; +import { ADD_REACTION_MESSAGE } from '../reactions/actionTypes'; +import { pushReactions } from '../reactions/actions.any'; +import { getReactionMessageFromBuffer } from '../reactions/functions.any'; import { endpointMessageReceived } from '../subtitles'; import { showToolbox } from '../toolbox/actions'; import { @@ -158,7 +156,7 @@ MiddlewareRegistry.register(store => next => action => { break; } - case ADD_REACTIONS_MESSAGE: { + case ADD_REACTION_MESSAGE: { _handleReceivedMessage(store, { id: localParticipant.id, message: action.message, @@ -212,8 +210,6 @@ StateListenerRegistry.register( * @returns {void} */ function _addChatMsgListener(conference, store) { - const reactions = {}; - if (store.getState()['features/base/config'].iAmRecorder) { // We don't register anything on web if we are in iAmRecorder mode return; @@ -252,30 +248,21 @@ function _addChatMsgListener(conference, store) { const [ { _id }, eventData ] = args; if (eventData.name === ENDPOINT_REACTION_NAME) { - reactions[_id] = reactions[_id] ?? { - timeout: null, - message: '' - }; batch(() => { - store.dispatch(pushReaction(eventData.reaction)); store.dispatch(setToolboxVisible(true)); store.dispatch(setToolboxTimeout( () => store.dispatch(hideToolbox()), 5000) ); + store.dispatch(pushReactions(eventData.reactions)); }); - clearTimeout(reactions[_id].timeout); - reactions[_id].message = `${reactions[_id].message}${REACTIONS[eventData.reaction].message}`; - reactions[_id].timeout = setTimeout(() => { - _handleReceivedMessage(store, { - id: _id, - message: reactions[_id].message, - privateMessage: false, - timestamp: eventData.timestamp - }, false); - delete reactions[_id]; - }, 500); + _handleReceivedMessage(store, { + id: _id, + message: getReactionMessageFromBuffer(eventData.reactions), + privateMessage: false, + timestamp: eventData.timestamp + }, false); } } }); diff --git a/react/features/reactions/actionTypes.js b/react/features/reactions/actionTypes.js index f3bbf5a75a..fb17b232e5 100644 --- a/react/features/reactions/actionTypes.js +++ b/react/features/reactions/actionTypes.js @@ -13,21 +13,31 @@ export const TOGGLE_REACTIONS_VISIBLE = 'TOGGLE_REACTIONS_VISIBLE'; * a new timeout. * * { - * type: SET_REACTION_MESSAGE, + * type: ADD_REACTION_BUFFER, * message: string, * timeoutID: number * } */ -export const SET_REACTIONS_MESSAGE = 'SET_REACTIONS_MESSAGE'; +export const ADD_REACTION_BUFFER = 'ADD_REACTION_BUFFER'; /** - * The type of the action which resets the reactions message and timeout. + * The type of the action which sends the reaction buffer and resets it. * * { - * type: CLEAR_REACTION_MESSAGE + * type: FLUSH_REACTION_BUFFER * } */ -export const CLEAR_REACTIONS_MESSAGE = 'CLEAR_REACTIONS_MESSAGE'; +export const FLUSH_REACTION_BUFFER = 'FLUSH_REACTION_BUFFER'; + +/** + * The type of the action which adds a new reaction message to the chat. + * + * { + * type: ADD_REACTION_MESSAGE, + * message: string, + * } + */ +export const ADD_REACTION_MESSAGE = 'ADD_REACTION_MESSAGE'; /** * The type of the action which sets the reactions queue. @@ -42,14 +52,9 @@ export const SET_REACTION_QUEUE = 'SET_REACTION_QUEUE'; /** * The type of the action which signals a send reaction to everyone in the conference. */ -export const SEND_REACTION = 'SEND_REACTION'; +export const SEND_REACTIONS = 'SEND_REACTIONS'; /** - * The type of the action to add a reaction message to the chat. + * The type of action to adds reactions to the queue. */ -export const ADD_REACTIONS_MESSAGE = 'ADD_REACTIONS_MESSAGE'; - -/** - * The type of action to add a reaction to the queue. - */ -export const PUSH_REACTION = 'PUSH_REACTION'; +export const PUSH_REACTIONS = 'PUSH_REACTIONS'; diff --git a/react/features/reactions/actions.any.js b/react/features/reactions/actions.any.js index 7747525dea..14bfa4dae1 100644 --- a/react/features/reactions/actions.any.js +++ b/react/features/reactions/actions.any.js @@ -1,11 +1,11 @@ // @flow import { - ADD_REACTIONS_MESSAGE, - CLEAR_REACTIONS_MESSAGE, - PUSH_REACTION, - SEND_REACTION, - SET_REACTIONS_MESSAGE, + ADD_REACTION_BUFFER, + ADD_REACTION_MESSAGE, + FLUSH_REACTION_BUFFER, + PUSH_REACTIONS, + SEND_REACTIONS, SET_REACTION_QUEUE } from './actionTypes'; import { type ReactionEmojiProps } from './constants'; @@ -23,42 +23,6 @@ export function setReactionQueue(value: Array) { }; } -/** - * Appends the reactions message to the chat and resets the state. - * - * @returns {void} - */ -export function flushReactionsToChat() { - return { - type: CLEAR_REACTIONS_MESSAGE - }; -} - -/** - * Adds a new reaction to the reactions message. - * - * @param {boolean} value - The new reaction. - * @returns {Object} - */ -export function addReactionsMessage(value: string) { - return { - type: SET_REACTIONS_MESSAGE, - reaction: value - }; -} - -/** - * Adds a new reaction to the reactions message. - * - * @param {boolean} value - Reaction to be added to queue. - * @returns {Object} - */ -export function pushReaction(value: string) { - return { - type: PUSH_REACTION, - reaction: value - }; -} /** * Removes a reaction from the queue. @@ -76,33 +40,75 @@ export function removeReaction(uid: number) { /** - * Sends a reaction message to everyone in the conference. + * Sends the reactions buffer to everyone in the conference. * - * @param {string} reaction - The reaction to send out. * @returns {{ - * type: SEND_REACTION, + * type: SEND_REACTION + * }} + */ +export function sendReactions() { + return { + type: SEND_REACTIONS + }; +} + +/** + * Adds a reaction to the local buffer. + * + * @param {string} reaction - The reaction to be added. + * @returns {{ + * type: ADD_REACTION_BUFFER, * reaction: string * }} */ -export function sendReaction(reaction: string) { +export function addReactionToBuffer(reaction: string) { return { - type: SEND_REACTION, + type: ADD_REACTION_BUFFER, reaction }; } /** - * Adds a reactions message to the chat. + * Clears the reaction buffer. * - * @param {string} message - The reactions message to add to chat. * @returns {{ - * type: ADD_REACTIONS_MESSAGE, + * type: FLUSH_REACTION_BUFFER + * }} + */ +export function flushReactionBuffer() { + return { + type: FLUSH_REACTION_BUFFER + }; +} + +/** + * Adds a reaction message to the chat. + * + * @param {string} message - The reaction message. + * @returns {{ + * type: ADD_REACTION_MESSAGE, * message: string * }} */ -export function addReactionsMessageToChat(message: string) { +export function addReactionsToChat(message: string) { return { - type: ADD_REACTIONS_MESSAGE, + type: ADD_REACTION_MESSAGE, message }; } + +/** + * Adds reactions to the animation queue. + * + * @param {Array} reactions - The reactions to be animated. + * @returns {{ + * type: PUSH_REACTIONS, + * reactions: Array + * }} + */ +export function pushReactions(reactions: Array) { + return { + type: PUSH_REACTIONS, + reactions + }; +} diff --git a/react/features/reactions/components/native/ReactionButton.js b/react/features/reactions/components/native/ReactionButton.js index 844d1d5791..3125fe3670 100644 --- a/react/features/reactions/components/native/ReactionButton.js +++ b/react/features/reactions/components/native/ReactionButton.js @@ -4,9 +4,10 @@ import React from 'react'; import { Text, TouchableHighlight } from 'react-native'; import { useDispatch } from 'react-redux'; +import { createReactionMenuEvent, sendAnalytics } from '../../../analytics'; import { translate } from '../../../base/i18n'; import type { StyleType } from '../../../base/styles'; -import { sendReaction } from '../../actions.any'; +import { addReactionToBuffer } from '../../actions.any'; import { REACTIONS } from '../../constants'; @@ -78,7 +79,8 @@ function ReactionButton({ * @returns {void} */ function _onClick() { - dispatch(sendReaction(reaction)); + dispatch(addReactionToBuffer(reaction)); + sendAnalytics(createReactionMenuEvent(reaction)); } return ( diff --git a/react/features/reactions/components/web/ReactionsMenu.js b/react/features/reactions/components/web/ReactionsMenu.js index 113d4aab45..23f67a2b10 100644 --- a/react/features/reactions/components/web/ReactionsMenu.js +++ b/react/features/reactions/components/web/ReactionsMenu.js @@ -4,6 +4,7 @@ import React, { Component } from 'react'; import { bindActionCreators } from 'redux'; import { + createReactionMenuEvent, createToolbarEvent, sendAnalytics } from '../../../analytics'; @@ -11,7 +12,7 @@ import { translate } from '../../../base/i18n'; import { getLocalParticipant, getParticipantCount, participantUpdated } from '../../../base/participants'; import { connect } from '../../../base/redux'; import { dockToolbox } from '../../../toolbox/actions.web'; -import { sendReaction } from '../../actions.any'; +import { addReactionToBuffer } from '../../actions.any'; import { toggleReactionsMenuVisibility } from '../../actions.web'; import { REACTIONS } from '../../constants'; @@ -144,6 +145,13 @@ class ReactionsMenu extends Component { */ _getReactionButtons() { const { t, dispatch } = this.props; + let modifierKey = 'Alt'; + + if (window.navigator?.platform) { + if (window.navigator.platform.indexOf('Mac') !== -1) { + modifierKey = '⌥'; + } + } return Object.keys(REACTIONS).map(key => { /** @@ -151,17 +159,18 @@ class ReactionsMenu extends Component { * * @returns {void} */ - function sendMessage() { - dispatch(sendReaction(key)); + function doSendReaction() { + dispatch(addReactionToBuffer(key)); + sendAnalytics(createReactionMenuEvent(key)); } return (); + tooltip = { `${t(`toolbar.${key}`)} (${modifierKey} + ${REACTIONS[key].shortcutChar})` } />); }); } diff --git a/react/features/reactions/components/web/ReactionsMenuButton.js b/react/features/reactions/components/web/ReactionsMenuButton.js index 2b8c797b70..dde67b921f 100644 --- a/react/features/reactions/components/web/ReactionsMenuButton.js +++ b/react/features/reactions/components/web/ReactionsMenuButton.js @@ -1,15 +1,14 @@ // @flow -import React, { useEffect } from 'react'; +import React from 'react'; import { translate } from '../../../base/i18n'; import { IconRaisedHand } from '../../../base/icons'; import { getLocalParticipant } from '../../../base/participants'; import { connect } from '../../../base/redux'; import ToolbarButton from '../../../toolbox/components/web/ToolbarButton'; -import { sendReaction } from '../../actions.any'; import { toggleReactionsMenuVisibility } from '../../actions.web'; -import { REACTIONS, type ReactionEmojiProps } from '../../constants'; +import { type ReactionEmojiProps } from '../../constants'; import { getReactionsQueue } from '../../functions.any'; import { getReactionsMenuVisibility } from '../../functions.web'; @@ -65,32 +64,6 @@ function ReactionsMenuButton({ dispatch }: Props) { - useEffect(() => { - const KEYBOARD_SHORTCUTS = Object.keys(REACTIONS).map(key => { - return { - character: REACTIONS[key].shortcutChar, - exec: () => dispatch(sendReaction(key)), - helpDescription: t(`toolbar.reaction${key.charAt(0).toUpperCase()}${key.slice(1)}`), - altKey: true - }; - }); - - KEYBOARD_SHORTCUTS.forEach(shortcut => { - APP.keyboardshortcut.registerShortcut( - shortcut.character, - null, - shortcut.exec, - shortcut.helpDescription, - shortcut.altKey); - }); - - return () => { - Object.keys(REACTIONS).map(key => REACTIONS[key].shortcutChar) - .forEach(letter => - APP.keyboardshortcut.unregisterShortcut(letter, true)); - }; - }, []); - /** * Toggles the reactions menu visibility. * diff --git a/react/features/reactions/constants.js b/react/features/reactions/constants.js index 160f600aeb..91b3220f47 100644 --- a/react/features/reactions/constants.js +++ b/react/features/reactions/constants.js @@ -11,7 +11,7 @@ export const REACTIONS = { emoji: '👏', shortcutChar: 'C' }, - joy: { + laugh: { message: ':grinning_face:', emoji: '😀', shortcutChar: 'L' diff --git a/react/features/reactions/functions.any.js b/react/features/reactions/functions.any.js index 023aa5355c..7fb0bcb6a1 100644 --- a/react/features/reactions/functions.any.js +++ b/react/features/reactions/functions.any.js @@ -1,5 +1,7 @@ // @flow +import uuid from 'uuid'; + import { getLocalParticipant } from '../base/participants'; import { extractFqnFromPath } from '../dynamic-branding/functions'; @@ -17,28 +19,28 @@ export function getReactionsQueue(state: Object) { } /** - * Returns reaction key from the reaction message. + * Returns chat message from reactions buffer. * - * @param {string} message - The reaction message. + * @param {Array} buffer - The reactions buffer. * @returns {string} */ -export function getReactionKeyByMessage(message: string): ?string { - return Object.keys(REACTIONS).find(key => REACTIONS[key].message === `:${message}:`); +export function getReactionMessageFromBuffer(buffer: Array) { + return buffer.map(reaction => REACTIONS[reaction].message).reduce((acc, val) => `${acc}${val}`); } /** - * Gets reactions key array from concatenated message. + * Returns reactions array with uid. * - * @param {string} message - The reaction message. + * @param {Array} buffer - The reactions buffer. * @returns {Array} */ -export function messageToKeyArray(message: string) { - let formattedMessage = message.replace(/::/g, '-'); - - formattedMessage = formattedMessage.replace(/:/g, ''); - const messageArray = formattedMessage.split('-'); - - return messageArray.map(getReactionKeyByMessage); +export function getReactionsWithId(buffer: Array) { + return buffer.map(reaction => { + return { + reaction, + uid: uuid.v4() + }; + }); } /** diff --git a/react/features/reactions/middleware.js b/react/features/reactions/middleware.js index cc1cf2a5b9..ed3d94e18a 100644 --- a/react/features/reactions/middleware.js +++ b/react/features/reactions/middleware.js @@ -1,24 +1,25 @@ // @flow +import { batch } from 'react-redux'; + import { ENDPOINT_REACTION_NAME } from '../../../modules/API/constants'; import { MiddlewareRegistry } from '../base/redux'; import { isVpaasMeeting } from '../jaas/functions'; import { - SET_REACTIONS_MESSAGE, - CLEAR_REACTIONS_MESSAGE, - SEND_REACTION, - PUSH_REACTION + ADD_REACTION_BUFFER, + FLUSH_REACTION_BUFFER, + SEND_REACTIONS, + PUSH_REACTIONS } from './actionTypes'; import { - addReactionsMessage, - addReactionsMessageToChat, - flushReactionsToChat, - pushReaction, + addReactionsToChat, + flushReactionBuffer, + pushReactions, + sendReactions, setReactionQueue } from './actions.any'; -import { REACTIONS } from './constants'; -import { messageToKeyArray, sendReactionsWebhook } from './functions.any'; +import { getReactionMessageFromBuffer, getReactionsWithId, sendReactionsWebhook } from './functions.any'; declare var APP: Object; @@ -34,56 +35,57 @@ MiddlewareRegistry.register(store => next => action => { const { dispatch, getState } = store; switch (action.type) { - case SET_REACTIONS_MESSAGE: { - const { timeoutID, message } = getState()['features/reactions']; + case ADD_REACTION_BUFFER: { + const { timeoutID, buffer } = getState()['features/reactions']; const { reaction } = action; clearTimeout(timeoutID); - action.message = `${message}${reaction}`; + buffer.push(reaction); + action.buffer = buffer; action.timeoutID = setTimeout(() => { - dispatch(flushReactionsToChat()); + dispatch(flushReactionBuffer()); }, 500); break; } - case CLEAR_REACTIONS_MESSAGE: { + case FLUSH_REACTION_BUFFER: { const state = getState(); - const { message } = state['features/reactions']; + const { buffer } = state['features/reactions']; + + batch(() => { + dispatch(sendReactions()); + dispatch(addReactionsToChat(getReactionMessageFromBuffer(buffer))); + dispatch(pushReactions(buffer)); + }); if (isVpaasMeeting(state)) { - sendReactionsWebhook(state, messageToKeyArray(message)); + sendReactionsWebhook(state, buffer); } - dispatch(addReactionsMessageToChat(message)); - break; } - case SEND_REACTION: { - const state = store.getState(); + case SEND_REACTIONS: { + const state = getState(); + const { buffer } = state['features/reactions']; const { conference } = state['features/base/conference']; if (conference) { conference.sendEndpointMessage('', { name: ENDPOINT_REACTION_NAME, - reaction: action.reaction, + reactions: buffer, timestamp: Date.now() }); - dispatch(addReactionsMessage(REACTIONS[action.reaction].message)); - dispatch(pushReaction(action.reaction)); } break; } - case PUSH_REACTION: { + case PUSH_REACTIONS: { const queue = store.getState()['features/reactions'].queue; - const reaction = action.reaction; + const reactions = action.reactions; - dispatch(setReactionQueue([ ...queue, { - reaction, - uid: window.Date.now() - } ])); + dispatch(setReactionQueue([ ...queue, ...getReactionsWithId(reactions) ])); } } diff --git a/react/features/reactions/reducer.js b/react/features/reactions/reducer.js index 23a5bf5c90..dcb8bcf130 100644 --- a/react/features/reactions/reducer.js +++ b/react/features/reactions/reducer.js @@ -4,9 +4,9 @@ import { ReducerRegistry } from '../base/redux'; import { TOGGLE_REACTIONS_VISIBLE, - SET_REACTIONS_MESSAGE, - CLEAR_REACTIONS_MESSAGE, - SET_REACTION_QUEUE + SET_REACTION_QUEUE, + ADD_REACTION_BUFFER, + FLUSH_REACTION_BUFFER } from './actionTypes'; /** @@ -30,11 +30,11 @@ function _getInitialState() { visible: false, /** - * A string that contains the message to be added to the chat. + * An array that contains the reactions buffer to be sent. * - * @type {string} + * @type {Array} */ - message: '', + buffer: [], /** * A number, non-zero value which identifies the timer created by a call @@ -64,17 +64,17 @@ ReducerRegistry.register( visible: !state.visible }; - case SET_REACTIONS_MESSAGE: + case ADD_REACTION_BUFFER: return { ...state, - message: action.message, + buffer: action.buffer, timeoutID: action.timeoutID }; - case CLEAR_REACTIONS_MESSAGE: + case FLUSH_REACTION_BUFFER: return { ...state, - message: '', + buffer: [], timeoutID: null }; diff --git a/react/features/toolbox/components/web/Toolbox.js b/react/features/toolbox/components/web/Toolbox.js index 5b67754222..278f591772 100644 --- a/react/features/toolbox/components/web/Toolbox.js +++ b/react/features/toolbox/components/web/Toolbox.js @@ -36,7 +36,9 @@ import { } from '../../../participants-pane/actions'; import ParticipantsPaneButton from '../../../participants-pane/components/ParticipantsPaneButton'; import { getParticipantsPaneOpen } from '../../../participants-pane/functions'; +import { addReactionToBuffer } from '../../../reactions/actions.any'; import { ReactionsMenuButton } from '../../../reactions/components'; +import { REACTIONS } from '../../../reactions/constants'; import { LiveStreamButton, RecordButton @@ -268,7 +270,7 @@ class Toolbox extends Component { * @returns {void} */ componentDidMount() { - const { _toolbarButtons } = this.props; + const { _toolbarButtons, t, dispatch } = this.props; const KEYBOARD_SHORTCUTS = [ isToolbarButtonEnabled('videoquality', _toolbarButtons) && { character: 'A', @@ -316,6 +318,31 @@ class Toolbox extends Component { shortcut.helpDescription); } }); + + const REACTION_SHORTCUTS = Object.keys(REACTIONS).map(key => { + const onShortcutSendReaction = () => { + dispatch(addReactionToBuffer(key)); + sendAnalytics(createShortcutEvent( + `reaction.${key}` + )); + }; + + return { + character: REACTIONS[key].shortcutChar, + exec: onShortcutSendReaction, + helpDescription: t(`toolbar.reaction${key.charAt(0).toUpperCase()}${key.slice(1)}`), + altKey: true + }; + }); + + REACTION_SHORTCUTS.forEach(shortcut => { + APP.keyboardshortcut.registerShortcut( + shortcut.character, + null, + shortcut.exec, + shortcut.helpDescription, + shortcut.altKey); + }); } /** @@ -346,6 +373,10 @@ class Toolbox extends Component { componentWillUnmount() { [ 'A', 'C', 'D', 'R', 'S' ].forEach(letter => APP.keyboardshortcut.unregisterShortcut(letter)); + + Object.keys(REACTIONS).map(key => REACTIONS[key].shortcutChar) + .forEach(letter => + APP.keyboardshortcut.unregisterShortcut(letter, true)); } /**