diff --git a/css/_atlaskit_overrides.scss b/css/_atlaskit_overrides.scss index 519ffaa69..1261dc772 100644 --- a/css/_atlaskit_overrides.scss +++ b/css/_atlaskit_overrides.scss @@ -100,12 +100,18 @@ } .audio-preview > div:nth-child(2), -.video-preview > div:nth-child(2) { +.video-preview > div:nth-child(2), +.reactions-menu-popup > div:nth-child(2) { margin-bottom: 4px; outline: none; padding: 0; } +.reactions-menu-popup > div:nth-child(2) { + margin-bottom: 6px; + box-shadow: none; +} + /** * The following selectors keep the chat modal full-size anywhere between 100px * and 580px for desktop or 680px for mobile. diff --git a/css/_drawer.scss b/css/_drawer.scss index 943855fc9..9e3cd319d 100644 --- a/css/_drawer.scss +++ b/css/_drawer.scss @@ -4,17 +4,16 @@ right: 0; bottom: 0; z-index: $drawerZ; + background-color: #141414; + border-radius: 16px 16px 0 0; } .drawer-menu { - max-height: 50vh; + max-height: calc(80vh - 64px); background: #242528; border-radius: 16px 16px 0 0; - overflow-y: auto; - - &.expanded { - max-height: 80vh; - } + overflow-y: hidden; + margin-bottom: env(safe-area-inset-bottom, 0); .drawer-toggle { display: flex; @@ -42,6 +41,8 @@ font-size: 1.2em; list-style-type: none; padding: 0; + height: calc(80vh - 144px - 64px); + overflow-y: auto; .overflow-menu-item { box-sizing: border-box; diff --git a/css/_reactions-menu.scss b/css/_reactions-menu.scss new file mode 100644 index 000000000..2301db282 --- /dev/null +++ b/css/_reactions-menu.scss @@ -0,0 +1,189 @@ +@use 'sass:math'; + +.reactions-menu { + width: 280px; + background: #292929; + box-shadow: 0px 3px 16px rgba(0, 0, 0, 0.6), 0px 0px 4px 1px rgba(0, 0, 0, 0.25); + border-radius: 3px; + padding: 16px; + + &.overflow { + width: auto; + padding-bottom: max(env(safe-area-inset-bottom, 0), 16px); + background-color: #141414; + box-shadow: none; + border-radius: 0; + position: relative; + + .toolbox-icon { + width: 48px; + height: 48px; + + span.emoji { + width: 48px; + height: 48px; + } + } + + .reactions-row { + display: flex; + flex-direction: row; + justify-content: space-around; + + .toolbox-button { + margin-right: 0; + } + } + } + + .toolbox-icon { + width: 40px; + height: 40px; + border-radius: 6px; + + span.emoji { + width: 40px; + height: 40px; + font-size: 22px; + display: flex; + align-items: center; + justify-content: center; + } + } + + .reactions-row { + .toolbox-button { + margin-right: 8px; + touch-action: manipulation; + } + + .toolbox-button:last-of-type { + margin-right: 0; + } + } + + .raise-hand-row { + margin-top: 16px; + + .toolbox-button { + width: 100%; + } + + .toolbox-icon { + width: 100%; + flex-direction: row; + align-items: center; + + span.text { + font-style: normal; + font-weight: 600; + font-size: 14px; + line-height: 24px; + margin-left: 8px; + } + } + } +} + +.reactions-animations-container { + position: absolute; + width: 20%; + bottom: 0; + left: 40%; + height: 48px; +} + +.reactions-menu-popup-container, +.reactions-menu-popup { + display: inline-block; + position: relative; +} + +$reactionCount: 20; + +@function random($min, $max) { + @return math.random() * ($max - $min) + $min; +} + +.reaction-emoji { + position: absolute; + font-size: 24px; + line-height: 32px; + width: 32px; + height: 32px; + top: 32px; + left: 10px; + opacity: 0; + z-index: 1; + + &.reaction-0 { + animation: flowToRight 5s forwards ease-in-out; + } + + @for $i from 1 through $reactionCount { + &.reaction-#{$i} { + animation: animation-#{$i} 5s forwards ease-in-out; + top: #{random(50, 0)}px; + left: #{random(-10, 10)}px; + } +} +} + +@keyframes flowToRight { + 0% { + transform: translate(0px, 0px) scale(0.6); + opacity: 1; + } + + 70% { + transform: translate(40px, -70vh) scale(1.5); + opacity: 1; + } + + 75% { + transform: translate(40px, -70vh) scale(1.5); + opacity: 1; + } + + 100% { + transform: translate(140px, -50vh) scale(1); + opacity: 0; + } +} + +@mixin animation-list { + @for $i from 1 through $reactionCount { + $topX: random(-100, 100); + $topY: random(65, 75); + $bottomX: random(150, 200); + $bottomY: random(40, 50); + + @if $topX < 0 { + $bottomX: -$bottomX; + } + + @keyframes animation-#{$i} { + 0% { + transform: translate(0, 0) scale(0.6); + opacity: 1; + } + + 70% { + transform: translate(#{$topX}px, -#{$topY}vh) scale(1.5); + opacity: 1; + } + + 75% { + transform: translate(#{$topX}px, -#{$topY}vh) scale(1.5); + opacity: 1; + } + + 100% { + transform: translate(#{$bottomX}px, -#{$bottomY}vh) scale(1); + opacity: 0; + } + } + } +} + +@include animation-list; diff --git a/css/_toolbars.scss b/css/_toolbars.scss index 1d93b7267..491ccd48a 100644 --- a/css/_toolbars.scss +++ b/css/_toolbars.scss @@ -105,11 +105,14 @@ margin: 0 auto; max-width: 100%; pointer-events: all; + background-color: #131519; + padding-bottom: env(safe-area-inset-bottom, 0); + box-shadow: 0px 2px 8px 4px rgba(0, 0, 0, 0.25), 0px 0px 0px 1px rgba(0, 0, 0, 0.15); + border-radius: 6px; } .toolbox-content-items { background: $newToolbarBackgroundColor; - box-shadow: 0px 2px 8px 4px rgba(0, 0, 0, 0.25), 0px 0px 0px 1px rgba(0, 0, 0, 0.15); border-radius: 6px; margin: 0 auto; padding: 6px; diff --git a/css/filmstrip/_vertical_filmstrip.scss b/css/filmstrip/_vertical_filmstrip.scss index ec816f066..a9fe54286 100644 --- a/css/filmstrip/_vertical_filmstrip.scss +++ b/css/filmstrip/_vertical_filmstrip.scss @@ -28,7 +28,7 @@ flex-direction: column-reverse; height: 100%; width: 100%; - padding: ($desktopAppDragBarHeight - 5px) 5px 10px; + padding: ($desktopAppDragBarHeight - 5px) 5px calc(env(safe-area-inset-bottom, 0) + 10px); /** * fixed positioning is necessary for remote menus and tooltips to pop * out of the scrolling filmstrip. AtlasKit dialogs and tooltips use diff --git a/css/main.scss b/css/main.scss index dac44f585..1711755ce 100644 --- a/css/main.scss +++ b/css/main.scss @@ -106,6 +106,7 @@ $flagsImagePath: "../images/"; @import 'connection-status'; @import 'drawer'; @import 'participants-pane'; +@import 'reactions-menu'; @import 'plan-limit'; /* Modules END */ diff --git a/lang/main.json b/lang/main.json index b583a45dd..9ed0574ec 100644 --- a/lang/main.json +++ b/lang/main.json @@ -808,6 +808,7 @@ "callQuality": "Manage video quality", "cc": "Toggle subtitles", "chat": "Open / Close chat", + "clap": "Clap", "document": "Toggle shared document", "download": "Download our apps", "embedMeeting": "Embed meeting", @@ -817,7 +818,9 @@ "hangup": "Leave the meeting", "help": "Help", "invite": "Invite people", + "joy": "Laughing Crying", "kick": "Kick participant", + "like": "Thumbs Up", "lobbyButton": "Enable/disable lobby mode", "localRecording": "Toggle local recording controls", "lockRoom": "Toggle meeting password", @@ -830,10 +833,12 @@ "muteEveryonesVideo": "Disable everyone's camera", "muteEveryoneElsesVideo": "Disable everyone else's camera", "participants": "Participants", + "party": "Party Popper", "pip": "Toggle Picture-in-Picture mode", "privateMessage": "Send private message", "profile": "Edit your profile", "raiseHand": "Raise / Lower your hand", + "reactionsMenu": "Open / Close reactions menu", "recording": "Toggle recording", "remoteMute": "Mute participant", "remoteVideoMute": "Disable camera of participant", @@ -845,7 +850,9 @@ "shareYourScreen": "Start / Stop sharing your screen", "shortcuts": "Toggle shortcuts", "show": "Show on stage", + "smile": "Smile", "speakerStats": "Toggle speaker statistics", + "surprised": "Surprised", "tileView": "Toggle tile view", "toggleCamera": "Toggle camera", "toggleFilmstrip": "Toggle filmstrip", @@ -864,7 +871,9 @@ "authenticate": "Authenticate", "callQuality": "Manage video quality", "chat": "Open / Close chat", + "clap": "Clap", "closeChat": "Close chat", + "closeReactionsMenu": "Close reactions menu", "documentClose": "Close shared document", "documentOpen": "Open shared document", "download": "Download our apps", @@ -878,6 +887,8 @@ "hangup": "Leave the meeting", "help": "Help", "invite": "Invite people", + "joy": "Joy", + "like": "Thumbs Up", "lobbyButtonDisable": "Disable lobby mode", "lobbyButtonEnable": "Enable lobby mode", "login": "Login", @@ -896,18 +907,27 @@ "noisyAudioInputTitle": "Your microphone appears to be noisy!", "noisyAudioInputDesc": "It sounds like your microphone is making noise, please consider muting or changing the device.", "openChat": "Open chat", + "openReactionsMenu": "Open reactions menu", "participants": "Participants", + "party": "Celebration", "pip": "Enter Picture-in-Picture mode", "privateMessage": "Send private message", "profile": "Edit your profile", "raiseHand": "Raise / Lower your hand", "raiseYourHand": "Raise your hand", + "reactionClap": "Send clap reaction", + "reactionJoy": "Send joy reaction", + "reactionLike": "Send thumbs up reaction", + "reactionParty": "Send party popper reaction", + "reactionSmile": "Send smile reaction", + "reactionSurprised": "Send surprised reaction", "security": "Security options", "Settings": "Settings", "shareaudio": "Share audio", "sharedvideo": "Share video", "shareRoom": "Invite someone", "shortcuts": "View shortcuts", + "smile": "Smile", "speakerStats": "Speaker stats", "startScreenSharing": "Start screen sharing", "startSubtitles": "Start subtitles", @@ -915,6 +935,7 @@ "stopScreenSharing": "Stop screen sharing", "stopSubtitles": "Stop subtitles", "stopSharedVideo": "Stop video", + "surprised": "Surprised", "talkWhileMutedPopup": "Trying to speak? You are muted.", "tileViewToggle": "Toggle tile view", "toggleCamera": "Toggle camera", diff --git a/modules/API/constants.js b/modules/API/constants.js index 4d5213719..5079dd190 100644 --- a/modules/API/constants.js +++ b/modules/API/constants.js @@ -15,3 +15,8 @@ export const API_ID = parseURLParams(window.location).jitsi_meet_external_api_id * The payload name for the datachannel/endpoint text message event */ export const ENDPOINT_TEXT_MESSAGE_NAME = 'endpoint-text-message'; + +/** + * The payload name for the datachannel/endpoint reaction event + */ +export const ENDPOINT_REACTION_NAME = 'endpoint-reaction'; diff --git a/modules/keyboardshortcut/keyboardshortcut.js b/modules/keyboardshortcut/keyboardshortcut.js index e626cfdef..b6cc06276 100644 --- a/modules/keyboardshortcut/keyboardshortcut.js +++ b/modules/keyboardshortcut/keyboardshortcut.js @@ -142,20 +142,23 @@ const KeyboardShortcut = { * @param exec the function to be executed when the shortcut is pressed * @param helpDescription the description of the shortcut that would appear * in the help menu + * @param altKey whether or not the alt key must be pressed. */ registerShortcut(// eslint-disable-line max-params shortcutChar, shortcutAttr, exec, - helpDescription) { - _shortcuts.set(shortcutChar, { + helpDescription, + altKey = false) { + _shortcuts.set(altKey ? `:${shortcutChar}` : shortcutChar, { character: shortcutChar, function: exec, - shortcutAttr + shortcutAttr, + altKey }); if (helpDescription) { - this._addShortcutToHelp(shortcutChar, helpDescription); + this._addShortcutToHelp(altKey ? `:${shortcutChar}` : shortcutChar, helpDescription); } }, @@ -164,9 +167,10 @@ const KeyboardShortcut = { * * @param shortcutChar unregisters the given shortcut, which means it will * no longer be usable + * @param altKey whether or not shortcut is combo with alt key */ - unregisterShortcut(shortcutChar) { - _shortcuts.delete(shortcutChar); + unregisterShortcut(shortcutChar, altKey = false) { + _shortcuts.delete(altKey ? `:${shortcutChar}` : shortcutChar); _shortcutsHelp.delete(shortcutChar); }, @@ -175,6 +179,15 @@ const KeyboardShortcut = { * @returns {string} e.key or something close if not supported */ _getKeyboardKey(e) { + // If alt is pressed a different char can be returned so this takes + // the char from the code. It also prefixes with a colon to differentiate + // alt combo from simple keypress. + if (e.altKey) { + const key = e.code.replace('Key', ''); + + return `:${key}`; + } + // If e.key is a string, then it is assumed it already plainly states // the key pressed. This may not be true in all cases, such as with Edge // and "?", when the browser cannot properly map a key press event to a diff --git a/react/features/app/middlewares.any.js b/react/features/app/middlewares.any.js index e9e941001..1b1f6dc7a 100644 --- a/react/features/app/middlewares.any.js +++ b/react/features/app/middlewares.any.js @@ -35,6 +35,7 @@ import '../large-video/middleware'; import '../lobby/middleware'; import '../notifications/middleware'; import '../overlay/middleware'; +import '../reactions/middleware'; import '../recent-list/middleware'; import '../recording/middleware'; import '../rejoin/middleware'; diff --git a/react/features/app/reducers.any.js b/react/features/app/reducers.any.js index 3e06590db..7a6741ff0 100644 --- a/react/features/app/reducers.any.js +++ b/react/features/app/reducers.any.js @@ -41,6 +41,7 @@ import '../large-video/reducer'; import '../lobby/reducer'; import '../notifications/reducer'; import '../overlay/reducer'; +import '../reactions/reducer'; import '../recent-list/reducer'; import '../recording/reducer'; import '../settings/reducer'; diff --git a/react/features/base/dialog/components/AbstractDialogContainer.js b/react/features/base/dialog/components/AbstractDialogContainer.js index edbf102cf..90ca12b99 100644 --- a/react/features/base/dialog/components/AbstractDialogContainer.js +++ b/react/features/base/dialog/components/AbstractDialogContainer.js @@ -2,6 +2,8 @@ import React, { Component } from 'react'; +import { type ReactionEmojiProps } from '../../../reactions/constants'; + /** * The type of the React {@code Component} props of {@link DialogContainer}. */ @@ -25,7 +27,12 @@ type Props = { /** * True if the UI is in a compact state where we don't show dialogs. */ - _reducedUI: boolean + _reducedUI: boolean, + + /** + * Array of reactions to be displayed. + */ + _reactionsQueue: Array<ReactionEmojiProps> }; /** diff --git a/react/features/base/dialog/components/native/BottomSheet.js b/react/features/base/dialog/components/native/BottomSheet.js index 0f747d1fb..546558ff0 100644 --- a/react/features/base/dialog/components/native/BottomSheet.js +++ b/react/features/base/dialog/components/native/BottomSheet.js @@ -49,7 +49,17 @@ type Props = { /** * Function to render a bottom sheet header element, if necessary. */ - renderHeader: ?Function + renderHeader: ?Function, + + /** + * Function to render a bottom sheet footer element, if necessary. + */ + renderFooter: ?Function, + + /** + * The height of the screen. + */ + _height: number }; /** @@ -80,7 +90,7 @@ class BottomSheet extends PureComponent<Props> { * @returns {ReactElement} */ render() { - const { _styles, renderHeader } = this.props; + const { _styles, renderHeader, renderFooter, _height } = this.props; return ( <SlidingView @@ -99,7 +109,10 @@ class BottomSheet extends PureComponent<Props> { <SafeAreaView style = { [ styles.sheetItemContainer, - _styles.sheet + _styles.sheet, + { + maxHeight: _height - 100 + } ] } { ...this.panResponder.panHandlers }> <ScrollView @@ -108,6 +121,7 @@ class BottomSheet extends PureComponent<Props> { style = { styles.scrollView } > { this.props.children } </ScrollView> + { renderFooter && renderFooter() } </SafeAreaView> </View> </SlidingView> @@ -167,7 +181,8 @@ class BottomSheet extends PureComponent<Props> { */ function _mapStateToProps(state) { return { - _styles: ColorSchemeRegistry.get(state, 'BottomSheet') + _styles: ColorSchemeRegistry.get(state, 'BottomSheet'), + _height: state['features/base/responsive-ui'].clientHeight }; } diff --git a/react/features/base/dialog/components/native/DialogContainer.js b/react/features/base/dialog/components/native/DialogContainer.js index d82fce7ee..224504385 100644 --- a/react/features/base/dialog/components/native/DialogContainer.js +++ b/react/features/base/dialog/components/native/DialogContainer.js @@ -1,3 +1,7 @@ +import React from 'react'; + +import { ReactionEmoji } from '../../../../reactions/components'; +import { getReactionsQueue } from '../../../../reactions/functions.any'; import { connect } from '../../../redux'; import AbstractDialogContainer, { abstractMapStateToProps @@ -11,6 +15,22 @@ import AbstractDialogContainer, { * @extends AbstractDialogContainer */ class DialogContainer extends AbstractDialogContainer { + + /** + * Returns the reactions to be displayed. + * + * @returns {Array<React$Element>} + */ + _renderReactions() { + const { _reactionsQueue } = this.props; + + return _reactionsQueue.map(({ reaction, uid }, index) => (<ReactionEmoji + index = { index } + key = { uid } + reaction = { reaction } + uid = { uid } />)); + } + /** * Implements React's {@link Component#render()}. * @@ -18,8 +38,18 @@ class DialogContainer extends AbstractDialogContainer { * @returns {ReactElement} */ render() { - return this._renderDialogContent(); + return (<React.Fragment> + {this._renderReactions()} + {this._renderDialogContent()} + </React.Fragment>); } } -export default connect(abstractMapStateToProps)(DialogContainer); +const mapStateToProps = state => { + return { + ...abstractMapStateToProps(state), + _reactionsQueue: getReactionsQueue(state) + }; +}; + +export default connect(mapStateToProps)(DialogContainer); diff --git a/react/features/base/dialog/components/native/styles.js b/react/features/base/dialog/components/native/styles.js index fd119d640..769891f15 100644 --- a/react/features/base/dialog/components/native/styles.js +++ b/react/features/base/dialog/components/native/styles.js @@ -33,7 +33,7 @@ export const bottomSheetStyles = { }, scrollView: { - paddingHorizontal: MD_ITEM_MARGIN_PADDING + paddingHorizontal: 0 }, /** @@ -117,7 +117,7 @@ const brandedDialogText = { }; const brandedDialogLabelStyle = { - color: schemeColor('text'), + color: ColorPalette.white, flexShrink: 1, fontSize: MD_FONT_SIZE, opacity: 0.90 @@ -130,7 +130,7 @@ const brandedDialogItemContainerStyle = { }; const brandedDialogIconStyle = { - color: schemeColor('icon'), + color: ColorPalette.white, fontSize: 24 }; @@ -178,20 +178,24 @@ ColorSchemeRegistry.register('BottomSheet', { * Container style for a generic item rendered in the menu. */ style: { - ...brandedDialogItemContainerStyle + ...brandedDialogItemContainerStyle, + backgroundColor: ColorPalette.darkBackground, + paddingHorizontal: MD_ITEM_MARGIN_PADDING }, /** * Additional style that is not directly used as a style object. */ - underlayColor: ColorPalette.overflowMenuItemUnderlay + underlayColor: ColorPalette.toggled }, /** * Bottom sheet's base style. */ sheet: { - backgroundColor: schemeColor('background') + backgroundColor: ColorPalette.black, + borderTopLeftRadius: 16, + borderTopRightRadius: 16 } }); diff --git a/react/features/chat/middleware.js b/react/features/chat/middleware.js index 8dc0295af..1eff622d0 100644 --- a/react/features/chat/middleware.js +++ b/react/features/chat/middleware.js @@ -1,5 +1,8 @@ // @flow +import { batch } from 'react-redux'; + +import { ENDPOINT_REACTION_NAME } from '../../../modules/API/constants'; import { APP_WILL_MOUNT, APP_WILL_UNMOUNT } from '../base/app'; import { CONFERENCE_JOINED, @@ -19,7 +22,18 @@ 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 { endpointMessageReceived } from '../subtitles'; import { showToolbox } from '../toolbox/actions'; +import { + hideToolbox, + setToolboxTimeout, + setToolboxVisible +} from '../toolbox/actions.web'; import { ADD_MESSAGE, SEND_MESSAGE, OPEN_CHAT, CLOSE_CHAT } from './actionTypes'; import { addMessage, clearMessages } from './actions'; @@ -143,6 +157,15 @@ MiddlewareRegistry.register(store => next => action => { } break; } + + case ADD_REACTIONS_MESSAGE: { + _handleReceivedMessage(store, { + id: localParticipant.id, + message: action.message, + privateMessage: false, + timestamp: Date.now() + }); + } } return next(action); @@ -189,6 +212,7 @@ 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 @@ -219,6 +243,43 @@ function _addChatMsgListener(conference, store) { } ); + conference.on( + JitsiConferenceEvents.ENDPOINT_MESSAGE_RECEIVED, + (...args) => { + store.dispatch(endpointMessageReceived(...args)); + + if (args && args.length >= 2) { + 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) + ); + }); + + 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 + }); + delete reactions[_id]; + }, 500); + } + } + }); + conference.on( JitsiConferenceEvents.CONFERENCE_ERROR, (errorType, error) => { errorType === JitsiConferenceErrors.CHAT_ERROR && _handleChatError(store, error); diff --git a/react/features/keyboard-shortcuts/components/KeyboardShortcutsDialog.web.js b/react/features/keyboard-shortcuts/components/KeyboardShortcutsDialog.web.js index d54bfc971..670f6947d 100644 --- a/react/features/keyboard-shortcuts/components/KeyboardShortcutsDialog.web.js +++ b/react/features/keyboard-shortcuts/components/KeyboardShortcutsDialog.web.js @@ -67,6 +67,14 @@ class KeyboardShortcutsDialog extends Component<Props> { * @returns {ReactElement} */ _renderShortcutsListItem(keyboardKey, translationKey) { + let modifierKey = 'Alt'; + + if (window.navigator?.platform) { + if (window.navigator.platform.indexOf('Mac') !== -1) { + modifierKey = '⌥'; + } + } + return ( <li className = 'shortcuts-list__item' @@ -78,7 +86,9 @@ class KeyboardShortcutsDialog extends Component<Props> { </span> <span className = 'item-action'> <Lozenge isBold = { true }> - { keyboardKey } + { keyboardKey.startsWith(':') + ? `${modifierKey} + ${keyboardKey.slice(1)}` + : keyboardKey } </Lozenge> </span> </li> diff --git a/react/features/reactions/actionTypes.js b/react/features/reactions/actionTypes.js new file mode 100644 index 000000000..f3bbf5a75 --- /dev/null +++ b/react/features/reactions/actionTypes.js @@ -0,0 +1,55 @@ +/** + * The type of the (redux) action which shows/hides the reactions menu. + * + * { + * type: TOGGLE_REACTIONS_VISIBLE, + * visible: boolean + * } + */ +export const TOGGLE_REACTIONS_VISIBLE = 'TOGGLE_REACTIONS_VISIBLE'; + +/** + * The type of the action which adds a new reaction to the reactions message and sets + * a new timeout. + * + * { + * type: SET_REACTION_MESSAGE, + * message: string, + * timeoutID: number + * } + */ +export const SET_REACTIONS_MESSAGE = 'SET_REACTIONS_MESSAGE'; + +/** + * The type of the action which resets the reactions message and timeout. + * + * { + * type: CLEAR_REACTION_MESSAGE + * } + */ +export const CLEAR_REACTIONS_MESSAGE = 'CLEAR_REACTIONS_MESSAGE'; + +/** + * The type of the action which sets the reactions queue. + * + * { + * type: SET_REACTION_QUEUE, + * value: Array + * } + */ +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'; + +/** + * The type of the action to add a reaction message to the chat. + */ +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'; diff --git a/react/features/reactions/actions.any.js b/react/features/reactions/actions.any.js new file mode 100644 index 000000000..7747525de --- /dev/null +++ b/react/features/reactions/actions.any.js @@ -0,0 +1,108 @@ +// @flow + +import { + ADD_REACTIONS_MESSAGE, + CLEAR_REACTIONS_MESSAGE, + PUSH_REACTION, + SEND_REACTION, + SET_REACTIONS_MESSAGE, + SET_REACTION_QUEUE +} from './actionTypes'; +import { type ReactionEmojiProps } from './constants'; + +/** + * Sets the reaction queue. + * + * @param {Array} value - The new queue. + * @returns {Function} + */ +export function setReactionQueue(value: Array<ReactionEmojiProps>) { + return { + type: SET_REACTION_QUEUE, + value + }; +} + +/** + * 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. + * + * @param {number} uid - Id of the reaction to be removed. + * @returns {void} + */ +export function removeReaction(uid: number) { + return (dispatch: Function, getState: Function) => { + const queue = getState()['features/reactions'].queue; + + dispatch(setReactionQueue(queue.filter(reaction => reaction.uid !== uid))); + }; +} + + +/** + * Sends a reaction message to everyone in the conference. + * + * @param {string} reaction - The reaction to send out. + * @returns {{ + * type: SEND_REACTION, + * reaction: string + * }} + */ +export function sendReaction(reaction: string) { + return { + type: SEND_REACTION, + reaction + }; +} + +/** + * Adds a reactions message to the chat. + * + * @param {string} message - The reactions message to add to chat. + * @returns {{ + * type: ADD_REACTIONS_MESSAGE, + * message: string + * }} + */ +export function addReactionsMessageToChat(message: string) { + return { + type: ADD_REACTIONS_MESSAGE, + message + }; +} diff --git a/react/features/reactions/actions.web.js b/react/features/reactions/actions.web.js new file mode 100644 index 000000000..ffbf5178f --- /dev/null +++ b/react/features/reactions/actions.web.js @@ -0,0 +1,16 @@ +// @flow + +import { + TOGGLE_REACTIONS_VISIBLE +} from './actionTypes'; + +/** + * Toggles the visibility of the reactions menu. + * + * @returns {Function} + */ +export function toggleReactionsMenuVisibility() { + return { + type: TOGGLE_REACTIONS_VISIBLE + }; +} diff --git a/react/features/reactions/components/_.native.js b/react/features/reactions/components/_.native.js new file mode 100644 index 000000000..738c4d2b8 --- /dev/null +++ b/react/features/reactions/components/_.native.js @@ -0,0 +1 @@ +export * from './native'; diff --git a/react/features/reactions/components/_.web.js b/react/features/reactions/components/_.web.js new file mode 100644 index 000000000..b80c83af3 --- /dev/null +++ b/react/features/reactions/components/_.web.js @@ -0,0 +1 @@ +export * from './web'; diff --git a/react/features/reactions/components/index.js b/react/features/reactions/components/index.js new file mode 100644 index 000000000..cda61441e --- /dev/null +++ b/react/features/reactions/components/index.js @@ -0,0 +1 @@ +export * from './_'; diff --git a/react/features/reactions/components/native/RaiseHandButton.js b/react/features/reactions/components/native/RaiseHandButton.js new file mode 100644 index 000000000..248d3bfc5 --- /dev/null +++ b/react/features/reactions/components/native/RaiseHandButton.js @@ -0,0 +1,165 @@ +// @flow + +import React, { Component } from 'react'; +import { Text, TouchableHighlight, View } from 'react-native'; +import { type Dispatch } from 'redux'; + +import { + createToolbarEvent, + sendAnalytics +} from '../../../analytics'; +import { ColorSchemeRegistry } from '../../../base/color-scheme'; +import { translate } from '../../../base/i18n'; +import { + getLocalParticipant, + raiseHand +} from '../../../base/participants'; +import { connect } from '../../../base/redux'; +import { type AbstractButtonProps } from '../../../base/toolbox/components'; + +import { type ReactionStyles } from './ReactionButton'; + +/** + * The type of the React {@code Component} props of {@link RaiseHandButton}. + */ +type Props = AbstractButtonProps & { + + /** + * The local participant. + */ + _localParticipant: Object, + + /** + * Whether the participant raused their hand or not. + */ + _raisedHand: boolean, + + /** + * The redux {@code dispatch} function. + */ + dispatch: Dispatch<any>, + + /** + * Used for translation + */ + t: Function, + + /** + * Used to close the overflow menu after raise hand is clicked. + */ + onCancel: Function, + + /** + * Styles for the button. + */ + _styles: ReactionStyles +}; + +/** + * An implementation of a button to raise or lower hand. + */ +class RaiseHandButton extends Component<Props, *> { + accessibilityLabel = 'toolbar.accessibilityLabel.raiseHand'; + label = 'toolbar.raiseYourHand'; + toggledLabel = 'toolbar.lowerYourHand'; + + /** + * Initializes a new {@code RaiseHandButton} instance. + * + * @param {Props} props - The React {@code Component} props to initialize + * the new {@code RaiseHandButton} instance with. + */ + constructor(props: Props) { + super(props); + + // Bind event handlers so they are only bound once per instance. + this._onClick = this._onClick.bind(this); + this._toggleRaisedHand = this._toggleRaisedHand.bind(this); + this._getLabel = this._getLabel.bind(this); + } + + _onClick: () => void; + + _toggleRaisedHand: () => void; + + _getLabel: () => string; + + /** + * Handles clicking / pressing the button. + * + * @returns {void} + */ + _onClick() { + this._toggleRaisedHand(); + this.props.onCancel(); + } + + /** + * Toggles the rased hand status of the local participant. + * + * @returns {void} + */ + _toggleRaisedHand() { + const enable = !this.props._raisedHand; + + sendAnalytics(createToolbarEvent('raise.hand', { enable })); + + this.props.dispatch(raiseHand(enable)); + } + + /** + * Gets the current label, taking the toggled state into account. If no + * toggled label is provided, the regular label will also be used in the + * toggled state. + * + * @returns {string} + */ + _getLabel() { + const { _raisedHand, t } = this.props; + + return t(_raisedHand ? this.toggledLabel : this.label); + } + + /** + * Implements React's {@link Component#render()}. + * + * @inheritdoc + * @returns {ReactElement} + */ + render() { + const { _styles, t } = this.props; + + return ( + <TouchableHighlight + accessibilityLabel = { t(this.accessibilityLabel) } + accessibilityRole = 'button' + onPress = { this._onClick } + style = { _styles.style } + underlayColor = { _styles.underlayColor }> + <View style = { _styles.container }> + <Text style = { _styles.emoji }>✋</Text> + <Text style = { _styles.text }>{this._getLabel()}</Text> + </View> + </TouchableHighlight> + ); + } +} + +/** + * Maps part of the Redux state to the props of this component. + * + * @param {Object} state - The Redux state. + * @private + * @returns {Props} + */ +function _mapStateToProps(state): Object { + const _localParticipant = getLocalParticipant(state); + + return { + _localParticipant, + _raisedHand: _localParticipant.raisedHand, + _styles: ColorSchemeRegistry.get(state, 'Toolbox').raiseHandButton + }; +} + +export default translate(connect(_mapStateToProps)(RaiseHandButton)); diff --git a/react/features/reactions/components/native/ReactionButton.js b/react/features/reactions/components/native/ReactionButton.js new file mode 100644 index 000000000..844d1d579 --- /dev/null +++ b/react/features/reactions/components/native/ReactionButton.js @@ -0,0 +1,96 @@ +// @flow + +import React from 'react'; +import { Text, TouchableHighlight } from 'react-native'; +import { useDispatch } from 'react-redux'; + +import { translate } from '../../../base/i18n'; +import type { StyleType } from '../../../base/styles'; +import { sendReaction } from '../../actions.any'; +import { REACTIONS } from '../../constants'; + + +export type ReactionStyles = { + + /** + * Style for the button. + */ + style: StyleType, + + /** + * Underlay color for the button. + */ + underlayColor: StyleType, + + /** + * Style for the emoji text on the button. + */ + emoji: StyleType, + + /** + * Style for the label text on the button. + */ + text?: StyleType, + + /** + * Style for text container. Used on raise hand button. + */ + container?: StyleType + +} + +/** + * The type of the React {@code Component} props of {@link ReactionButton}. + */ +type Props = { + + /** + * Collection of styles for the button. + */ + styles: ReactionStyles, + + /** + * The reaction to be sent + */ + reaction: string, + + /** + * Invoked to obtain translated strings. + */ + t: Function +}; + +/** + * An implementation of a button to send a reaction. + * + * @returns {ReactElement} + */ +function ReactionButton({ + styles, + reaction, + t +}: Props) { + const dispatch = useDispatch(); + + /** + * Handles clicking / pressing the button. + * + * @returns {void} + */ + function _onClick() { + dispatch(sendReaction(reaction)); + } + + return ( + <TouchableHighlight + accessibilityLabel = { t(`toolbar.accessibilityLabel.${reaction}`) } + accessibilityRole = 'button' + onPress = { _onClick } + style = { styles.style } + underlayColor = { styles.underlayColor }> + <Text style = { styles.emoji }>{REACTIONS[reaction].emoji}</Text> + </TouchableHighlight> + ); +} + +export default translate(ReactionButton); diff --git a/react/features/reactions/components/native/ReactionEmoji.js b/react/features/reactions/components/native/ReactionEmoji.js new file mode 100644 index 000000000..85d3c464f --- /dev/null +++ b/react/features/reactions/components/native/ReactionEmoji.js @@ -0,0 +1,96 @@ +// @flow + +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { Animated } from 'react-native'; +import { useDispatch, useSelector } from 'react-redux'; + +import { ColorSchemeRegistry } from '../../../base/color-scheme'; +import { removeReaction } from '../../actions.any'; +import { REACTIONS, type ReactionEmojiProps } from '../../constants'; + + +type Props = ReactionEmojiProps & { + + /** + * Index of reaction on the queue. + * Used to differentiate between first and other animations. + */ + index: number +}; + + +/** + * Animated reaction emoji. + * + * @returns {ReactElement} + */ +function ReactionEmoji({ reaction, uid, index }: Props) { + const _styles = useSelector(state => ColorSchemeRegistry.get(state, 'Toolbox')); + const _height = useSelector(state => state['features/base/responsive-ui'].clientHeight); + const dispatch = useDispatch(); + + const animationVal = useRef(new Animated.Value(0)).current; + + const vh = useState(_height / 100)[0]; + + const randomInt = (min, max) => Math.floor((Math.random() * (max - min + 1)) + min); + + const animationIndex = useMemo(() => index % 21, [ index ]); + + const coordinates = useState({ + topX: animationIndex === 0 ? 40 : randomInt(-100, 100), + topY: animationIndex === 0 ? -70 : randomInt(-65, -75), + bottomX: animationIndex === 0 ? 140 : randomInt(150, 200), + bottomY: animationIndex === 0 ? -50 : randomInt(-40, -50) + })[0]; + + + useEffect(() => { + setTimeout(() => dispatch(removeReaction(uid)), 5000); + }, []); + + useEffect(() => { + Animated.timing( + animationVal, + { + toValue: 1, + duration: 5000, + useNativeDriver: true + } + ).start(); + }, [ animationVal ]); + + + return ( + <Animated.Text + style = {{ + ..._styles.emojiAnimation, + transform: [ + { translateY: animationVal.interpolate({ + inputRange: [ 0, 0.70, 0.75, 1 ], + outputRange: [ 0, coordinates.topY * vh, coordinates.topY * vh, coordinates.bottomY * vh ] + }) + }, { + translateX: animationVal.interpolate({ + inputRange: [ 0, 0.70, 0.75, 1 ], + outputRange: [ 0, coordinates.topX, coordinates.topX, + coordinates.topX < 0 ? -coordinates.bottomX : coordinates.bottomX ] + }) + }, { + scale: animationVal.interpolate({ + inputRange: [ 0, 0.70, 0.75, 1 ], + outputRange: [ 0.6, 1.5, 1.5, 1 ] + }) + } + ], + opacity: animationVal.interpolate({ + inputRange: [ 0, 0.7, 0.75, 1 ], + outputRange: [ 1, 1, 1, 0 ] + }) + }}> + {REACTIONS[reaction].emoji} + </Animated.Text> + ); +} + +export default ReactionEmoji; diff --git a/react/features/reactions/components/native/ReactionMenu.js b/react/features/reactions/components/native/ReactionMenu.js new file mode 100644 index 000000000..91dc6bf61 --- /dev/null +++ b/react/features/reactions/components/native/ReactionMenu.js @@ -0,0 +1,59 @@ +// @flow + +import React from 'react'; +import { View } from 'react-native'; +import { useSelector } from 'react-redux'; + +import { ColorSchemeRegistry } from '../../../base/color-scheme'; +import { getParticipantCount } from '../../../base/participants'; +import { REACTIONS } from '../../constants'; + +import RaiseHandButton from './RaiseHandButton'; +import ReactionButton from './ReactionButton'; + +/** + * The type of the React {@code Component} props of {@link ReactionMenu}. + */ +type Props = { + + /** + * Used to close the overflow menu after raise hand is clicked. + */ + onCancel: Function, + + /** + * Whether or not it's displayed in the overflow menu. + */ + overflowMenu: boolean +}; + +/** + * Animated reaction emoji. + * + * @returns {ReactElement} + */ +function ReactionMenu({ + onCancel, + overflowMenu +}: Props) { + const _styles = useSelector(state => ColorSchemeRegistry.get(state, 'Toolbox')); + const _participantCount = useSelector(state => getParticipantCount(state)); + + return ( + <View style = { overflowMenu ? _styles.overflowReactionMenu : _styles.reactionMenu }> + {_participantCount > 1 + && <View style = { _styles.reactionRow }> + {Object.keys(REACTIONS).map(key => ( + <ReactionButton + key = { key } + reaction = { key } + styles = { _styles.reactionButton } /> + ))} + </View> + } + <RaiseHandButton onCancel = { onCancel } /> + </View> + ); +} + +export default ReactionMenu; diff --git a/react/features/reactions/components/native/ReactionMenuDialog.js b/react/features/reactions/components/native/ReactionMenuDialog.js new file mode 100644 index 000000000..c1faf4936 --- /dev/null +++ b/react/features/reactions/components/native/ReactionMenuDialog.js @@ -0,0 +1,143 @@ +// @flow + +import React, { PureComponent } from 'react'; +import { SafeAreaView, TouchableWithoutFeedback, View } from 'react-native'; + +import { ColorSchemeRegistry } from '../../../base/color-scheme'; +import { hideDialog, isDialogOpen } from '../../../base/dialog'; +import { getParticipantCount } from '../../../base/participants'; +import { connect } from '../../../base/redux'; +import type { StyleType } from '../../../base/styles'; + +import ReactionMenu from './ReactionMenu'; + +/** + * The type of the React {@code Component} props of {@link ReactionMenuDialog}. + */ +type Props = { + + /** + * The color-schemed stylesheet of the feature. + */ + _styles: StyleType, + + /** + * True if the dialog is currently visible, false otherwise. + */ + _isOpen: boolean, + + /** + * The width of the screen. + */ + _width: number, + + /** + * The height of the screen. + */ + _height: number, + + /** + * Number of conference participants. + */ + _participantCount: number, + + /** + * Used for hiding the dialog when the selection was completed. + */ + dispatch: Function +}; + +/** + * The exported React {@code Component}. We need it to execute + * {@link hideDialog}. + * + * XXX It does not break our coding style rule to not utilize globals for state, + * because it is merely another name for {@code export}'s {@code default}. + */ +let ReactionMenu_; // eslint-disable-line prefer-const + +/** + * Implements a React {@code Component} with some extra actions in addition to + * those in the toolbar. + */ +class ReactionMenuDialog extends PureComponent<Props> { + /** + * Initializes a new {@code ReactionMenuDialog} instance. + * + * @inheritdoc + */ + constructor(props: Props) { + super(props); + + // Bind event handlers so they are only bound once per instance. + this._onCancel = this._onCancel.bind(this); + } + + /** + * Implements React's {@link Component#render()}. + * + * @inheritdoc + * @returns {ReactElement} + */ + render() { + const { _styles, _width, _height, _participantCount } = this.props; + + return ( + <SafeAreaView style = { _styles }> + <TouchableWithoutFeedback + onPress = { this._onCancel }> + <View style = { _styles }> + <View + style = {{ + left: (_width - 360) / 2, + top: _height - (_participantCount > 1 ? 144 : 80) - 80 + }}> + <ReactionMenu + onCancel = { this._onCancel } + overflowMenu = { false } /> + </View> + </View> + </TouchableWithoutFeedback> + </SafeAreaView> + ); + } + + _onCancel: () => boolean; + + /** + * Hides this {@code ReactionMenuDialog}. + * + * @private + * @returns {boolean} + */ + _onCancel() { + if (this.props._isOpen) { + this.props.dispatch(hideDialog(ReactionMenu_)); + + return true; + } + + return false; + } +} + +/** + * Function that maps parts of Redux state tree into component props. + * + * @param {Object} state - Redux state. + * @private + * @returns {Props} + */ +function _mapStateToProps(state) { + return { + _isOpen: isDialogOpen(state, ReactionMenu_), + _styles: ColorSchemeRegistry.get(state, 'Toolbox').reactionDialog, + _width: state['features/base/responsive-ui'].clientWidth, + _height: state['features/base/responsive-ui'].clientHeight, + _participantCount: getParticipantCount(state) + }; +} + +ReactionMenu_ = connect(_mapStateToProps)(ReactionMenuDialog); + +export default ReactionMenu_; diff --git a/react/features/toolbox/components/native/RaiseHandButton.js b/react/features/reactions/components/native/ReactionsMenuButton.js similarity index 61% rename from react/features/toolbox/components/native/RaiseHandButton.js rename to react/features/reactions/components/native/ReactionsMenuButton.js index cdbf4fd43..546a77159 100644 --- a/react/features/toolbox/components/native/RaiseHandButton.js +++ b/react/features/reactions/components/native/ReactionsMenuButton.js @@ -2,35 +2,33 @@ import { type Dispatch } from 'redux'; -import { - createToolbarEvent, - sendAnalytics -} from '../../../analytics'; +import { isDialogOpen, openDialog } from '../../../base/dialog'; import { RAISE_HAND_ENABLED, getFeatureFlag } from '../../../base/flags'; import { translate } from '../../../base/i18n'; import { IconRaisedHand } from '../../../base/icons'; import { - getLocalParticipant, - raiseHand + getLocalParticipant } from '../../../base/participants'; import { connect } from '../../../base/redux'; import { AbstractButton, type AbstractButtonProps } from '../../../base/toolbox/components'; +import ReactionMenuDialog from './ReactionMenuDialog'; + /** - * The type of the React {@code Component} props of {@link RaiseHandButton}. + * The type of the React {@code Component} props of {@link ReactionsMenuButton}. */ type Props = AbstractButtonProps & { /** - * The local participant. - */ - _localParticipant: Object, - - /** - * Whether the participant raused their hand or not. + * Whether the participant raised their hand or not. */ _raisedHand: boolean, + /** + * Whether or not the reactions menu is open. + */ + _reactionsOpen: boolean, + /** * The redux {@code dispatch} function. */ @@ -40,11 +38,11 @@ type Props = AbstractButtonProps & { /** * An implementation of a button to raise or lower hand. */ -class RaiseHandButton extends AbstractButton<Props, *> { - accessibilityLabel = 'toolbar.accessibilityLabel.raiseHand'; +class ReactionsMenuButton extends AbstractButton<Props, *> { + accessibilityLabel = 'toolbar.accessibilityLabel.reactionsMenu'; icon = IconRaisedHand; - label = 'toolbar.raiseYourHand'; - toggledLabel = 'toolbar.lowerYourHand'; + label = 'toolbar.openReactionsMenu'; + toggledLabel = 'toolbar.closeReactionsMenu'; /** * Handles clicking / pressing the button. @@ -54,7 +52,7 @@ class RaiseHandButton extends AbstractButton<Props, *> { * @returns {void} */ _handleClick() { - this._toggleRaisedHand(); + this.props.dispatch(openDialog(ReactionMenuDialog)); } /** @@ -65,20 +63,7 @@ class RaiseHandButton extends AbstractButton<Props, *> { * @returns {boolean} */ _isToggled() { - return this.props._raisedHand; - } - - /** - * Toggles the rased hand status of the local participant. - * - * @returns {void} - */ - _toggleRaisedHand() { - const enable = !this.props._raisedHand; - - sendAnalytics(createToolbarEvent('raise.hand', { enable })); - - this.props.dispatch(raiseHand(enable)); + return this.props._raisedHand || this.props._reactionsOpen; } } @@ -96,10 +81,10 @@ function _mapStateToProps(state, ownProps): Object { const { visible = enabled } = ownProps; return { - _localParticipant, _raisedHand: _localParticipant.raisedHand, + _reactionsOpen: isDialogOpen(state, ReactionMenuDialog), visible }; } -export default translate(connect(_mapStateToProps)(RaiseHandButton)); +export default translate(connect(_mapStateToProps)(ReactionsMenuButton)); diff --git a/react/features/reactions/components/native/index.js b/react/features/reactions/components/native/index.js new file mode 100644 index 000000000..4a2bef910 --- /dev/null +++ b/react/features/reactions/components/native/index.js @@ -0,0 +1,3 @@ +export { default as ReactionsMenuButton } from './ReactionsMenuButton'; +export { default as ReactionEmoji } from './ReactionEmoji'; +export { default as ReactionMenu } from './ReactionMenu'; diff --git a/react/features/reactions/components/web/ReactionButton.js b/react/features/reactions/components/web/ReactionButton.js new file mode 100644 index 000000000..3a7ed0529 --- /dev/null +++ b/react/features/reactions/components/web/ReactionButton.js @@ -0,0 +1,125 @@ +/* @flow */ + +import React from 'react'; + +import { Tooltip } from '../../../base/tooltip'; +import AbstractToolbarButton from '../../../toolbox/components/AbstractToolbarButton'; +import type { Props as AbstractToolbarButtonProps } from '../../../toolbox/components/AbstractToolbarButton'; + +/** + * The type of the React {@code Component} props of {@link ReactionButton}. + */ +type Props = AbstractToolbarButtonProps & { + + /** + * Optional text to display in the tooltip. + */ + tooltip?: string, + + /** + * From which direction the tooltip should appear, relative to the + * button. + */ + tooltipPosition: string, + + /** + * Optional label for the button + */ + label?: string +}; + +/** + * Represents a button in the reactions menu. + * + * @extends AbstractToolbarButton + */ +class ReactionButton extends AbstractToolbarButton<Props> { + /** + * Default values for {@code ReactionButton} component's properties. + * + * @static + */ + static defaultProps = { + tooltipPosition: 'top' + }; + + /** + * Initializes a new {@code ReactionButton} instance. + * + * @inheritdoc + */ + constructor(props: Props) { + super(props); + + this._onKeyDown = this._onKeyDown.bind(this); + } + + _onKeyDown: (Object) => void; + + /** + * Handles 'Enter' key on the button to trigger onClick for accessibility. + * We should be handling Space onKeyUp but it conflicts with PTT. + * + * @param {Object} event - The key event. + * @private + * @returns {void} + */ + _onKeyDown(event) { + // If the event coming to the dialog has been subject to preventDefault + // we don't handle it here. + if (event.defaultPrevented) { + return; + } + + if (event.key === 'Enter') { + event.preventDefault(); + event.stopPropagation(); + this.props.onClick(); + } + } + + /** + * Renders the button of this {@code ReactionButton}. + * + * @param {Object} children - The children, if any, to be rendered inside + * the button. Presumably, contains the emoji of this {@code ReactionButton}. + * @protected + * @returns {ReactElement} The button of this {@code ReactionButton}. + */ + _renderButton(children) { + return ( + <div + aria-label = { this.props.accessibilityLabel } + aria-pressed = { this.props.toggled } + className = 'toolbox-button' + onClick = { this.props.onClick } + onKeyDown = { this._onKeyDown } + role = 'button' + tabIndex = { 0 }> + { this.props.tooltip + ? <Tooltip + content = { this.props.tooltip } + position = { this.props.tooltipPosition }> + { children } + </Tooltip> + : children } + </div> + ); + } + + /** + * Renders the icon (emoji) of this {@code reactionButton}. + * + * @inheritdoc + */ + _renderIcon() { + return ( + <div className = { `toolbox-icon ${this.props.toggled ? 'toggled' : ''}` }> + <span className = 'emoji'>{this.props.icon}</span> + {this.props.label && <span className = 'text'>{this.props.label}</span>} + </div> + ); + } +} + +export default ReactionButton; diff --git a/react/features/reactions/components/web/ReactionEmoji.js b/react/features/reactions/components/web/ReactionEmoji.js new file mode 100644 index 000000000..deba0907d --- /dev/null +++ b/react/features/reactions/components/web/ReactionEmoji.js @@ -0,0 +1,96 @@ +// @flow + +import React, { Component } from 'react'; + +import { connect } from '../../../base/redux'; +import { removeReaction } from '../../actions.any'; +import { REACTIONS } from '../../constants'; + +type Props = { + + /** + * Reaction to be displayed. + */ + reaction: string, + + /** + * Id of the reaction. + */ + uid: Number, + + /** + * Removes reaction from redux state. + */ + removeReaction: Function, + + /** + * Index of the reaction in the queue. + */ + index: number +}; + +type State = { + + /** + * Index of CSS animation. Number between 0-20. + */ + index: number +} + + +/** + * Used to display animated reactions. + * + * @returns {ReactElement} + */ +class ReactionEmoji extends Component<Props, State> { + /** + * Initializes a new {@code ReactionEmoji} instance. + * + * @param {Props} props - The read-only React {@code Component} props with + * which the new instance is to be initialized. + */ + constructor(props: Props) { + super(props); + + this.state = { + index: props.index % 21 + }; + } + + /** + * Implements React Component's componentDidMount. + * + * @inheritdoc + */ + componentDidMount() { + setTimeout(() => this.props.removeReaction(this.props.uid), 5000); + } + + /** + * Implements React's {@link Component#render}. + * + * @inheritdoc + */ + render() { + const { reaction, uid } = this.props; + const { index } = this.state; + + return ( + <div + className = { `reaction-emoji reaction-${index}` } + id = { uid }> + { REACTIONS[reaction].emoji } + </div> + ); + } +} + +const mapDispatchToProps = { + removeReaction +}; + +export default connect( + null, + mapDispatchToProps, +)(ReactionEmoji); diff --git a/react/features/reactions/components/web/ReactionsMenu.js b/react/features/reactions/components/web/ReactionsMenu.js new file mode 100644 index 000000000..113d4aab4 --- /dev/null +++ b/react/features/reactions/components/web/ReactionsMenu.js @@ -0,0 +1,233 @@ +// @flow + +import React, { Component } from 'react'; +import { bindActionCreators } from 'redux'; + +import { + createToolbarEvent, + sendAnalytics +} from '../../../analytics'; +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 { toggleReactionsMenuVisibility } from '../../actions.web'; +import { REACTIONS } from '../../constants'; + +import ReactionButton from './ReactionButton'; + +type Props = { + + /** + * The number of conference participants. + */ + _participantCount: number, + + /** + * Used for translation. + */ + t: Function, + + /** + * Whether or not the local participant's hand is raised. + */ + _raisedHand: boolean, + + /** + * The ID of the local participant. + */ + _localParticipantID: String, + + /** + * The Redux Dispatch function. + */ + dispatch: Function, + + /** + * Docks the toolbox + */ + _dockToolbox: Function, + + /** + * Whether or not it's displayed in the overflow menu. + */ + overflowMenu: boolean +}; + +declare var APP: Object; + +/** + * Implements the reactions menu. + * + * @returns {ReactElement} + */ +class ReactionsMenu extends Component<Props> { + /** + * Initializes a new {@code ReactionsMenu} instance. + * + * @param {Props} props - The read-only React {@code Component} props with + * which the new instance is to be initialized. + */ + constructor(props: Props) { + super(props); + + this._onToolbarToggleRaiseHand = this._onToolbarToggleRaiseHand.bind(this); + this._getReactionButtons = this._getReactionButtons.bind(this); + } + + _onToolbarToggleRaiseHand: () => void; + + _getReactionButtons: () => Array<React$Element<*>>; + + /** + * Implements React Component's componentDidMount. + * + * @inheritdoc + */ + componentDidMount() { + this.props._dockToolbox(true); + } + + /** + * Implements React Component's componentWillUnmount. + * + * @inheritdoc + */ + componentWillUnmount() { + this.props._dockToolbox(false); + } + + /** + * Creates an analytics toolbar event and dispatches an action for toggling + * raise hand. + * + * @returns {void} + */ + _onToolbarToggleRaiseHand() { + sendAnalytics(createToolbarEvent( + 'raise.hand', + { enable: !this.props._raisedHand })); + this._doToggleRaiseHand(); + this.props.dispatch(toggleReactionsMenuVisibility()); + } + + /** + * Dispatches an action to toggle the local participant's raised hand state. + * + * @private + * @returns {void} + */ + _doToggleRaiseHand() { + const { _localParticipantID, _raisedHand } = this.props; + const newRaisedStatus = !_raisedHand; + + this.props.dispatch(participantUpdated({ + // XXX Only the local participant is allowed to update without + // stating the JitsiConference instance (i.e. participant property + // `conference` for a remote participant) because the local + // participant is uniquely identified by the very fact that there is + // only one local participant. + + id: _localParticipantID, + local: true, + raisedHand: newRaisedStatus + })); + + APP.API.notifyRaiseHandUpdated(_localParticipantID, newRaisedStatus); + } + + /** + * Returns the emoji reaction buttons. + * + * @returns {Array} + */ + _getReactionButtons() { + const { t, dispatch } = this.props; + + return Object.keys(REACTIONS).map(key => { + /** + * Sends reaction message. + * + * @returns {void} + */ + function sendMessage() { + dispatch(sendReaction(key)); + } + + return (<ReactionButton + accessibilityLabel = { t(`toolbar.accessibilityLabel.${key}`) } + icon = { REACTIONS[key].emoji } + key = { key } + onClick = { sendMessage } + toggled = { false } + tooltip = { t(`toolbar.${key}`) } />); + }); + } + + /** + * Implements React's {@link Component#render}. + * + * @inheritdoc + */ + render() { + const { _participantCount, _raisedHand, t, overflowMenu } = this.props; + + return ( + <div className = { `reactions-menu ${overflowMenu ? 'overflow' : ''}` }> + { _participantCount > 1 && <div className = 'reactions-row'> + { this._getReactionButtons() } + </div> } + <div className = 'raise-hand-row'> + <ReactionButton + accessibilityLabel = { t('toolbar.accessibilityLabel.raiseHand') } + icon = '✋' + key = 'raisehand' + label = { + `${t(`toolbar.${_raisedHand ? 'lowerYourHand' : 'raiseYourHand'}`)} + ${overflowMenu ? '' : ' (R)'}` + } + onClick = { this._onToolbarToggleRaiseHand } + toggled = { true } /> + </div> + </div> + ); + } +} + +/** + * Function that maps parts of Redux state tree into component props. + * + * @param {Object} state - Redux state. + * @returns {Object} + */ +function mapStateToProps(state) { + const localParticipant = getLocalParticipant(state); + + return { + _localParticipantID: localParticipant.id, + _raisedHand: localParticipant.raisedHand, + _participantCount: getParticipantCount(state) + }; +} + +/** + * Function that maps parts of Redux actions into component props. + * + * @param {Object} dispatch - Redux dispatch. + * @returns {Object} + */ +function mapDispatchToProps(dispatch) { + return { + dispatch, + ...bindActionCreators( + { + _dockToolbox: dockToolbox + }, dispatch) + }; +} + +export default translate(connect( + mapStateToProps, + mapDispatchToProps, +)(ReactionsMenu)); diff --git a/react/features/reactions/components/web/ReactionsMenuButton.js b/react/features/reactions/components/web/ReactionsMenuButton.js new file mode 100644 index 000000000..2b8c797b7 --- /dev/null +++ b/react/features/reactions/components/web/ReactionsMenuButton.js @@ -0,0 +1,139 @@ +// @flow + +import React, { useEffect } 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 { getReactionsQueue } from '../../functions.any'; +import { getReactionsMenuVisibility } from '../../functions.web'; + +import ReactionEmoji from './ReactionEmoji'; +import ReactionsMenuPopup from './ReactionsMenuPopup'; + +type Props = { + + /** + * Used for translation. + */ + t: Function, + + /** + * Whether or not the local participant's hand is raised. + */ + raisedHand: boolean, + + /** + * Click handler for the reaction button. Toggles the reactions menu. + */ + onReactionsClick: Function, + + /** + * Whether or not the reactions menu is open. + */ + isOpen: boolean, + + /** + * The array of reactions to be displayed. + */ + reactionsQueue: Array<ReactionEmojiProps>, + + /** + * Redux dispatch function. + */ + dispatch: Function +}; + + +declare var APP: Object; + +/** + * Button used for the reactions menu. + * + * @returns {ReactElement} + */ +function ReactionsMenuButton({ + t, + raisedHand, + isOpen, + reactionsQueue, + 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. + * + * @returns {void} + */ + function toggleReactionsMenu() { + dispatch(toggleReactionsMenuVisibility()); + } + + return ( + <div className = 'reactions-menu-popup-container'> + <ReactionsMenuPopup> + <ToolbarButton + accessibilityLabel = { t('toolbar.accessibilityLabel.reactionsMenu') } + icon = { IconRaisedHand } + key = 'reactions' + onClick = { toggleReactionsMenu } + toggled = { raisedHand } + tooltip = { t(`toolbar.${isOpen ? 'closeReactionsMenu' : 'openReactionsMenu'}`) } /> + </ReactionsMenuPopup> + {reactionsQueue.map(({ reaction, uid }, index) => (<ReactionEmoji + index = { index } + key = { uid } + reaction = { reaction } + uid = { uid } />))} + </div> + ); +} + +/** + * Function that maps parts of Redux state tree into component props. + * + * @param {Object} state - Redux state. + * @returns {Object} + */ +function mapStateToProps(state) { + const localParticipant = getLocalParticipant(state); + + return { + isOpen: getReactionsMenuVisibility(state), + reactionsQueue: getReactionsQueue(state), + raisedHand: localParticipant?.raisedHand + }; +} + +export default translate(connect(mapStateToProps)(ReactionsMenuButton)); diff --git a/react/features/reactions/components/web/ReactionsMenuPopup.js b/react/features/reactions/components/web/ReactionsMenuPopup.js new file mode 100644 index 000000000..b328a71fd --- /dev/null +++ b/react/features/reactions/components/web/ReactionsMenuPopup.js @@ -0,0 +1,58 @@ +// @flow + +import InlineDialog from '@atlaskit/inline-dialog'; +import React from 'react'; +import { useDispatch, useSelector } from 'react-redux'; + +import { toggleReactionsMenuVisibility } from '../../actions.web'; +import { getReactionsMenuVisibility } from '../../functions.web'; + +import ReactionsMenu from './ReactionsMenu'; + + +type Props = { + + /** + * Component's children (the reactions menu button). + */ + children: React$Node +} + +/** + * Popup with reactions menu. + * + * @returns {ReactElement} + */ +function ReactionsMenuPopup({ + children +}: Props) { + /** + * Flag controlling the visibility of the popup. + */ + const isOpen = useSelector(state => getReactionsMenuVisibility(state)); + + const dispatch = useDispatch(); + + /** + * Toggles reactions menu visibility. + * + * @returns {void} + */ + function onClose() { + dispatch(toggleReactionsMenuVisibility()); + } + + return ( + <div className = 'reactions-menu-popup'> + <InlineDialog + content = { <ReactionsMenu /> } + isOpen = { isOpen } + onClose = { onClose } + placement = 'top'> + {children} + </InlineDialog> + </div> + ); +} + +export default ReactionsMenuPopup; diff --git a/react/features/reactions/components/web/index.js b/react/features/reactions/components/web/index.js new file mode 100644 index 000000000..c63636541 --- /dev/null +++ b/react/features/reactions/components/web/index.js @@ -0,0 +1,7 @@ +// @flow + +export { default as ReactionButton } from './ReactionButton'; +export { default as ReactionEmoji } from './ReactionEmoji'; +export { default as ReactionsMenu } from './ReactionsMenu'; +export { default as ReactionsMenuButton } from './ReactionsMenuButton'; +export { default as ReactionsMenuPopup } from './ReactionsMenuPopup'; diff --git a/react/features/reactions/constants.js b/react/features/reactions/constants.js new file mode 100644 index 000000000..8708b2b8a --- /dev/null +++ b/react/features/reactions/constants.js @@ -0,0 +1,47 @@ +// @flow + +export const REACTIONS = { + clap: { + message: ':clap:', + emoji: '👏', + shortcutChar: 'C' + }, + like: { + message: ':thumbs_up:', + emoji: '👍', + shortcutChar: 'T' + }, + smile: { + message: ':smile:', + emoji: '😀', + shortcutChar: 'S' + }, + joy: { + message: ':joy:', + emoji: '😂', + shortcutChar: 'L' + }, + surprised: { + message: ':face_with_open_mouth:', + emoji: '😮', + shortcutChar: 'O' + }, + party: { + message: ':party_popper:', + emoji: '🎉', + shortcutChar: 'P' + } +}; + +export type ReactionEmojiProps = { + + /** + * Reaction to be displayed. + */ + reaction: string, + + /** + * Id of the reaction. + */ + uid: number +} diff --git a/react/features/reactions/functions.any.js b/react/features/reactions/functions.any.js new file mode 100644 index 000000000..36ea78600 --- /dev/null +++ b/react/features/reactions/functions.any.js @@ -0,0 +1,11 @@ +// @flow + +/** + * Returns the queue of reactions. + * + * @param {Object} state - The state of the application. + * @returns {boolean} + */ +export function getReactionsQueue(state: Object) { + return state['features/reactions'].queue; +} diff --git a/react/features/reactions/functions.web.js b/react/features/reactions/functions.web.js new file mode 100644 index 000000000..60c89adc0 --- /dev/null +++ b/react/features/reactions/functions.web.js @@ -0,0 +1,11 @@ +// @flow + +/** + * Returns the visibility state of the reactions menu. + * + * @param {Object} state - The state of the application. + * @returns {boolean} + */ +export function getReactionsMenuVisibility(state: Object) { + return state['features/reactions'].visible; +} diff --git a/react/features/reactions/middleware.js b/react/features/reactions/middleware.js new file mode 100644 index 000000000..0c928e025 --- /dev/null +++ b/react/features/reactions/middleware.js @@ -0,0 +1,84 @@ +// @flow + +import { ENDPOINT_REACTION_NAME } from '../../../modules/API/constants'; +import { MiddlewareRegistry } from '../base/redux'; + +import { + SET_REACTIONS_MESSAGE, + CLEAR_REACTIONS_MESSAGE, + SEND_REACTION, + PUSH_REACTION +} from './actionTypes'; +import { + addReactionsMessage, + addReactionsMessageToChat, + flushReactionsToChat, + pushReaction, + setReactionQueue +} from './actions.any'; +import { REACTIONS } from './constants'; + + +declare var APP: Object; + +/** + * Middleware which intercepts Reactions actions to handle changes to the + * visibility timeout of the Reactions. + * + * @param {Store} store - The redux store. + * @returns {Function} + */ +MiddlewareRegistry.register(store => next => action => { + const { dispatch, getState } = store; + + switch (action.type) { + case SET_REACTIONS_MESSAGE: { + const { timeoutID, message } = getState()['features/reactions']; + const { reaction } = action; + + clearTimeout(timeoutID); + action.message = `${message}${reaction}`; + action.timeoutID = setTimeout(() => { + dispatch(flushReactionsToChat()); + }, 500); + + break; + } + + case CLEAR_REACTIONS_MESSAGE: { + const { message } = getState()['features/reactions']; + + dispatch(addReactionsMessageToChat(message)); + + break; + } + + case SEND_REACTION: { + const state = store.getState(); + const { conference } = state['features/base/conference']; + + if (conference) { + conference.sendEndpointMessage('', { + name: ENDPOINT_REACTION_NAME, + reaction: action.reaction, + timestamp: Date.now() + }); + dispatch(addReactionsMessage(REACTIONS[action.reaction].message)); + dispatch(pushReaction(action.reaction)); + } + break; + } + + case PUSH_REACTION: { + const queue = store.getState()['features/reactions'].queue; + const reaction = action.reaction; + + dispatch(setReactionQueue([ ...queue, { + reaction, + uid: window.Date.now() + } ])); + } + } + + return next(action); +}); diff --git a/react/features/reactions/reducer.js b/react/features/reactions/reducer.js new file mode 100644 index 000000000..23a5bf5c9 --- /dev/null +++ b/react/features/reactions/reducer.js @@ -0,0 +1,90 @@ +// @flow + +import { ReducerRegistry } from '../base/redux'; + +import { + TOGGLE_REACTIONS_VISIBLE, + SET_REACTIONS_MESSAGE, + CLEAR_REACTIONS_MESSAGE, + SET_REACTION_QUEUE +} from './actionTypes'; + +/** + * Returns initial state for reactions' part of Redux store. + * + * @private + * @returns {{ + * visible: boolean, + * message: string, + * timeoutID: number, + * queue: Array + * }} + */ +function _getInitialState() { + return { + /** + * The indicator that determines whether the reactions menu is visible. + * + * @type {boolean} + */ + visible: false, + + /** + * A string that contains the message to be added to the chat. + * + * @type {string} + */ + message: '', + + /** + * A number, non-zero value which identifies the timer created by a call + * to setTimeout(). + * + * @type {number|null} + */ + timeoutID: null, + + /** + * The array of reactions to animate + * + * @type {Array} + */ + queue: [] + }; +} + +ReducerRegistry.register( + 'features/reactions', + (state: Object = _getInitialState(), action: Object) => { + switch (action.type) { + + case TOGGLE_REACTIONS_VISIBLE: + return { + ...state, + visible: !state.visible + }; + + case SET_REACTIONS_MESSAGE: + return { + ...state, + message: action.message, + timeoutID: action.timeoutID + }; + + case CLEAR_REACTIONS_MESSAGE: + return { + ...state, + message: '', + timeoutID: null + }; + + case SET_REACTION_QUEUE: { + return { + ...state, + queue: action.value + }; + } + } + + return state; + }); diff --git a/react/features/toolbox/components/native/MoreOptionsButton.js b/react/features/toolbox/components/native/MoreOptionsButton.js deleted file mode 100644 index 735740266..000000000 --- a/react/features/toolbox/components/native/MoreOptionsButton.js +++ /dev/null @@ -1,20 +0,0 @@ -// @flow - -import { translate } from '../../../base/i18n'; -import { IconMenu } from '../../../base/icons'; -import { AbstractButton, type AbstractButtonProps } from '../../../base/toolbox/components'; - - -type Props = AbstractButtonProps; - -/** - * An implementation of a button to show more menu options. - */ -class MoreOptionsButton extends AbstractButton<Props, any> { - accessibilityLabel = 'toolbar.accessibilityLabel.moreOptions'; - icon = IconMenu; - label = 'toolbar.moreOptions'; -} - - -export default translate(MoreOptionsButton); diff --git a/react/features/toolbox/components/native/OverflowMenu.js b/react/features/toolbox/components/native/OverflowMenu.js index 018cd81bf..a2ec33467 100644 --- a/react/features/toolbox/components/native/OverflowMenu.js +++ b/react/features/toolbox/components/native/OverflowMenu.js @@ -1,17 +1,15 @@ // @flow import React, { PureComponent } from 'react'; -import { TouchableOpacity, View } from 'react-native'; -import Collapsible from 'react-native-collapsible'; import { ColorSchemeRegistry } from '../../../base/color-scheme'; import { BottomSheet, hideDialog, isDialogOpen } from '../../../base/dialog'; -import { IconDragHandle } from '../../../base/icons'; import { connect } from '../../../base/redux'; import { StyleType } from '../../../base/styles'; import { SharedDocumentButton } from '../../../etherpad'; import { InviteButton } from '../../../invite'; import { AudioRouteButton } from '../../../mobile/audio-mode'; +import { ReactionMenu } from '../../../reactions/components'; import { LiveStreamButton, RecordButton } from '../../../recording'; import SecurityDialogButton from '../../../security/components/security-dialog/SecurityDialogButton'; import { SharedVideoButton } from '../../../shared-video/components'; @@ -23,11 +21,8 @@ import MuteEveryoneButton from '../MuteEveryoneButton'; import MuteEveryonesVideoButton from '../MuteEveryonesVideoButton'; import AudioOnlyButton from './AudioOnlyButton'; -import MoreOptionsButton from './MoreOptionsButton'; -import RaiseHandButton from './RaiseHandButton'; import ScreenSharingButton from './ScreenSharingButton.js'; import ToggleCameraButton from './ToggleCameraButton'; -import styles from './styles'; /** * The type of the React {@code Component} props of {@link OverflowMenu}. @@ -65,12 +60,7 @@ type State = { /** * True if the bottom scheet is scrolled to the top. */ - scrolledToTop: boolean, - - /** - * True if the 'more' button set needas to be rendered. - */ - showMore: boolean + scrolledToTop: boolean } /** @@ -96,15 +86,12 @@ class OverflowMenu extends PureComponent<Props, State> { super(props); this.state = { - scrolledToTop: true, - showMore: false + scrolledToTop: true }; // Bind event handlers so they are only bound once per instance. this._onCancel = this._onCancel.bind(this); - this._onSwipe = this._onSwipe.bind(this); - this._onToggleMenu = this._onToggleMenu.bind(this); - this._renderMenuExpandToggle = this._renderMenuExpandToggle.bind(this); + this._renderReactionMenu = this._renderReactionMenu.bind(this); } /** @@ -115,7 +102,6 @@ class OverflowMenu extends PureComponent<Props, State> { */ render() { const { _bottomSheetStyles, _width } = this.props; - const { showMore } = this.state; const toolbarButtons = getMovableButtons(_width); const buttonProps = { @@ -124,63 +110,45 @@ class OverflowMenu extends PureComponent<Props, State> { styles: _bottomSheetStyles.buttons }; - const moreOptionsButtonProps = { - ...buttonProps, - afterClick: this._onToggleMenu, - visible: !showMore + const topButtonProps = { + afterClick: this._onCancel, + showLabel: true, + styles: { + ..._bottomSheetStyles.buttons, + style: { + ..._bottomSheetStyles.buttons.style, + borderTopLeftRadius: 16, + borderTopRightRadius: 16, + paddingTop: 16 + } + } }; return ( <BottomSheet onCancel = { this._onCancel } - onSwipe = { this._onSwipe } - renderHeader = { this._renderMenuExpandToggle }> - <AudioRouteButton { ...buttonProps } /> + renderFooter = { toolbarButtons.has('raisehand') + ? null + : this._renderReactionMenu }> + <AudioRouteButton { ...topButtonProps } /> {!toolbarButtons.has('invite') && <InviteButton { ...buttonProps } />} <AudioOnlyButton { ...buttonProps } /> - {!toolbarButtons.has('raisehand') && <RaiseHandButton { ...buttonProps } />} <SecurityDialogButton { ...buttonProps } /> <ScreenSharingButton { ...buttonProps } /> - <MoreOptionsButton { ...moreOptionsButtonProps } /> - <Collapsible collapsed = { !showMore }> - {!toolbarButtons.has('togglecamera') && <ToggleCameraButton { ...buttonProps } />} - {!toolbarButtons.has('tileview') && <TileViewButton { ...buttonProps } />} - <RecordButton { ...buttonProps } /> - <LiveStreamButton { ...buttonProps } /> - <SharedVideoButton { ...buttonProps } /> - <ClosedCaptionButton { ...buttonProps } /> - <SharedDocumentButton { ...buttonProps } /> - <MuteEveryoneButton { ...buttonProps } /> - <MuteEveryonesVideoButton { ...buttonProps } /> - <HelpButton { ...buttonProps } /> - </Collapsible> + {!toolbarButtons.has('togglecamera') && <ToggleCameraButton { ...buttonProps } />} + {!toolbarButtons.has('tileview') && <TileViewButton { ...buttonProps } />} + <RecordButton { ...buttonProps } /> + <LiveStreamButton { ...buttonProps } /> + <SharedVideoButton { ...buttonProps } /> + <ClosedCaptionButton { ...buttonProps } /> + <SharedDocumentButton { ...buttonProps } /> + <MuteEveryoneButton { ...buttonProps } /> + <MuteEveryonesVideoButton { ...buttonProps } /> + <HelpButton { ...buttonProps } /> </BottomSheet> ); } - _renderMenuExpandToggle: () => React$Element<any>; - - /** - * Function to render the menu toggle in the bottom sheet header area. - * - * @returns {React$Element} - */ - _renderMenuExpandToggle() { - return ( - <View - style = { [ - this.props._bottomSheetStyles.sheet, - styles.expandMenuContainer - ] }> - <TouchableOpacity onPress = { this._onToggleMenu }> - { /* $FlowFixMe */ } - <IconDragHandle - fill = { this.props._bottomSheetStyles.buttons.iconStyle.color } /> - </TouchableOpacity> - </View> - ); - } - _onCancel: () => boolean; /** @@ -199,45 +167,17 @@ class OverflowMenu extends PureComponent<Props, State> { return false; } - _onSwipe: string => void; + _renderReactionMenu: () => React$Element<any>; /** - * Callback to be invoked when swipe gesture is detected on the menu. Returns true - * if the swipe gesture is handled by the menu, false otherwise. + * Functoin to render the reaction menu as the footer of the bottom sheet. * - * @param {string} direction - Direction of 'up' or 'down'. - * @returns {boolean} + * @returns {React$Element} */ - _onSwipe(direction) { - const { showMore } = this.state; - - switch (direction) { - case 'up': - !showMore && this.setState({ - showMore: true - }); - - return !showMore; - case 'down': - showMore && this.setState({ - showMore: false - }); - - return showMore; - } - } - - _onToggleMenu: () => void; - - /** - * Callback to be invoked when the expand menu button is pressed. - * - * @returns {void} - */ - _onToggleMenu() { - this.setState({ - showMore: !this.state.showMore - }); + _renderReactionMenu() { + return (<ReactionMenu + onCancel = { this._onCancel } + overflowMenu = { true } />); } } diff --git a/react/features/toolbox/components/native/Toolbox.js b/react/features/toolbox/components/native/Toolbox.js index 01fa8fe54..86d3479c8 100644 --- a/react/features/toolbox/components/native/Toolbox.js +++ b/react/features/toolbox/components/native/Toolbox.js @@ -8,6 +8,7 @@ import { connect } from '../../../base/redux'; import { StyleType } from '../../../base/styles'; import { ChatButton } from '../../../chat'; import { InviteButton } from '../../../invite'; +import { ReactionsMenuButton } from '../../../reactions/components'; import { TileViewButton } from '../../../video-layout'; import { isToolboxVisible, getMovableButtons } from '../../functions.native'; import AudioMuteButton from '../AudioMuteButton'; @@ -15,7 +16,6 @@ import HangupButton from '../HangupButton'; import VideoMuteButton from '../VideoMuteButton'; import OverflowMenuButton from './OverflowMenuButton'; -import RaiseHandButton from './RaiseHandButton'; import ToggleCameraButton from './ToggleCameraButton'; import styles from './styles'; @@ -87,9 +87,9 @@ function Toolbox(props: Props) { toggledStyles = { backgroundToggledStyle } />} { additionalButtons.has('raisehand') - && <RaiseHandButton - styles = { buttonStylesBorderless } - toggledStyles = { backgroundToggledStyle } />} + && <ReactionsMenuButton + styles = { buttonStylesBorderless } + toggledStyles = { backgroundToggledStyle } />} {additionalButtons.has('tileview') && <TileViewButton styles = { buttonStylesBorderless } />} {additionalButtons.has('invite') && <InviteButton styles = { buttonStylesBorderless } />} {additionalButtons.has('togglecamera') diff --git a/react/features/toolbox/components/native/styles.js b/react/features/toolbox/components/native/styles.js index de94407af..101e67b58 100644 --- a/react/features/toolbox/components/native/styles.js +++ b/react/features/toolbox/components/native/styles.js @@ -40,18 +40,38 @@ const whiteToolbarButtonIcon = { color: ColorPalette.white }; +/** + * The style of reaction buttons. + */ +const reactionButton = { + ...toolbarButton, + backgroundColor: 'transparent', + alignItems: 'center', + marginTop: 0, + marginHorizontal: 0 +}; + +/** + * The style of the emoji on the reaction buttons. + */ +const reactionEmoji = { + fontSize: 20, + color: ColorPalette.white +}; + +const reactionMenu = { + flexDirection: 'column', + justifyContent: 'center', + alignItems: 'center', + backgroundColor: ColorPalette.black, + padding: 16 +}; + /** * The Toolbox and toolbar related styles. */ const styles = { - expandMenuContainer: { - alignItems: 'center', - borderTopLeftRadius: 16, - borderTopRightRadius: 16, - flexDirection: 'column' - }, - sheetGestureRecognizer: { alignItems: 'stretch', flexDirection: 'column' @@ -120,6 +140,67 @@ ColorSchemeRegistry.register('Toolbox', { underlayColor: ColorPalette.buttonUnderlay }, + reactionDialog: { + position: 'absolute', + width: '100%', + height: '100%', + backgroundColor: 'transparent' + }, + + overflowReactionMenu: reactionMenu, + + reactionMenu: { + ...reactionMenu, + borderRadius: 3, + width: 360 + }, + + reactionRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + width: '100%', + marginBottom: 16 + }, + + reactionButton: { + style: reactionButton, + underlayColor: ColorPalette.toggled, + emoji: reactionEmoji + }, + + raiseHandButton: { + style: { + ...reactionButton, + backgroundColor: ColorPalette.toggled, + width: '100%', + borderRadius: 6 + }, + underlayColor: ColorPalette.toggled, + emoji: reactionEmoji, + text: { + color: ColorPalette.white, + fontWeight: '600', + marginLeft: 8, + lineHeight: 24 + }, + container: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center' + } + }, + + emojiAnimation: { + color: ColorPalette.white, + position: 'absolute', + zIndex: 1001, + elevation: 2, + fontSize: 20, + left: '50%', + top: '100%' + }, + /** * Styles for toggled buttons in the toolbar. */ diff --git a/react/features/toolbox/components/web/Drawer.js b/react/features/toolbox/components/web/Drawer.js index 2b9f12dc3..cc3838137 100644 --- a/react/features/toolbox/components/web/Drawer.js +++ b/react/features/toolbox/components/web/Drawer.js @@ -1,36 +1,24 @@ // @flow -import React, { useEffect, useRef, useState } from 'react'; +import React, { useEffect, useRef } from 'react'; -import { translate } from '../../../base/i18n'; -import { Icon, IconArrowUpWide, IconArrowDownWide } from '../../../base/icons'; type Props = { - /** - * Whether the drawer should have a button that expands its size or not. - */ - canExpand: ?boolean, - /** * The component(s) to be displayed within the drawer menu. */ children: React$Node, /** - Whether the drawer should be shown or not. + * Whether the drawer should be shown or not. */ isOpen: boolean, /** - Function that hides the drawer. + * Function that hides the drawer. */ - onClose: Function, - - /** - * Invoked to obtain translated strings. - */ - t: Function + onClose: Function }; /** @@ -39,12 +27,9 @@ type Props = { * @returns {ReactElement} */ function Drawer({ - canExpand, children, isOpen, - onClose, - t }: Props) { - const [ expanded, setExpanded ] = useState(false); + onClose }: Props) { const drawerRef: Object = useRef(null); /** @@ -69,53 +54,15 @@ function Drawer({ }; }, [ drawerRef ]); - /** - * Toggles the menu state between expanded/collapsed. - * - * @returns {void} - */ - function toggleExpanded() { - setExpanded(!expanded); - } - - /** - * KeyPress handler for accessibility. - * - * @param {React.KeyboardEventHandler<HTMLDivElement>} e - The key event to handle. - * - * @returns {void} - */ - function onKeyPress(e) { - if (e.key === ' ' || e.key === 'Enter') { - e.preventDefault(); - toggleExpanded(); - } - } - return ( isOpen ? ( <div - className = { `drawer-menu${expanded ? ' expanded' : ''}` } + className = 'drawer-menu' ref = { drawerRef }> - {canExpand && ( - <div - aria-expanded = { expanded } - aria-label = { expanded ? t('toolbar.accessibilityLabel.collapse') - : t('toolbar.accessibilityLabel.expand') } - className = 'drawer-toggle' - onClick = { toggleExpanded } - onKeyPress = { onKeyPress } - role = 'button' - tabIndex = { 0 }> - <Icon - size = { 24 } - src = { expanded ? IconArrowDownWide : IconArrowUpWide } /> - </div> - )} {children} </div> ) : null ); } -export default translate(Drawer); +export default Drawer; diff --git a/react/features/toolbox/components/web/OverflowMenuButton.js b/react/features/toolbox/components/web/OverflowMenuButton.js index f032bee1a..71a78440a 100644 --- a/react/features/toolbox/components/web/OverflowMenuButton.js +++ b/react/features/toolbox/components/web/OverflowMenuButton.js @@ -7,6 +7,9 @@ import { createToolbarEvent, sendAnalytics } from '../../../analytics'; import { translate } from '../../../base/i18n'; import { IconHorizontalPoints } from '../../../base/icons'; import { connect } from '../../../base/redux'; +import { ReactionEmoji, ReactionsMenu } from '../../../reactions/components'; +import { type ReactionEmojiProps } from '../../../reactions/constants'; +import { getReactionsQueue } from '../../../reactions/functions.any'; import Drawer from './Drawer'; import DrawerPortal from './DrawerPortal'; @@ -45,7 +48,17 @@ type Props = { /** * Invoked to obtain translated strings. */ - t: Function + t: Function, + + /** + * The array of reactions to be displayed. + */ + reactionsQueue: Array<ReactionEmojiProps>, + + /** + * Whether or not to display the reactions in the mobile menu. + */ + showMobileReactions: boolean }; /** @@ -93,7 +106,7 @@ class OverflowMenuButton extends Component<Props> { * @returns {ReactElement} */ render() { - const { children, isOpen, overflowDrawer } = this.props; + const { children, isOpen, overflowDrawer, reactionsQueue, showMobileReactions } = this.props; return ( <div className = 'toolbox-button-wth-dialog'> @@ -103,11 +116,18 @@ class OverflowMenuButton extends Component<Props> { {this._renderToolbarButton()} <DrawerPortal> <Drawer - canExpand = { true } isOpen = { isOpen } onClose = { this._onCloseDialog }> {children} + {showMobileReactions && <ReactionsMenu overflowMenu = { true } />} </Drawer> + {showMobileReactions && <div className = 'reactions-animations-container'> + {reactionsQueue.map(({ reaction, uid }, index) => (<ReactionEmoji + index = { index } + key = { uid } + reaction = { reaction } + uid = { uid } />))} + </div>} </DrawerPortal> </> ) : ( @@ -188,7 +208,8 @@ function mapStateToProps(state) { const { overflowDrawer } = state['features/toolbox']; return { - overflowDrawer + overflowDrawer, + reactionsQueue: getReactionsQueue(state) }; } diff --git a/react/features/toolbox/components/web/RaiseHandButton.js b/react/features/toolbox/components/web/RaiseHandButton.js deleted file mode 100644 index 4ebdb3613..000000000 --- a/react/features/toolbox/components/web/RaiseHandButton.js +++ /dev/null @@ -1,83 +0,0 @@ -// @flow - -import { translate } from '../../../base/i18n'; -import { IconRaisedHand } from '../../../base/icons'; -import { getLocalParticipant } from '../../../base/participants'; -import { connect } from '../../../base/redux'; -import { AbstractButton, type AbstractButtonProps } from '../../../base/toolbox/components'; - -type Props = AbstractButtonProps & { - - /** - * Whether or not the local participant's hand is raised. - */ - _raisedHand: boolean, - - /** - * External handler for click action. - */ - handleClick: Function -}; - -/** - * Implementation of a button for toggling raise hand functionality. - */ -class RaiseHandButton extends AbstractButton<Props, *> { - accessibilityLabel = 'toolbar.accessibilityLabel.raiseHand'; - icon = IconRaisedHand - label = 'toolbar.raiseYourHand'; - toggledLabel = 'toolbar.lowerYourHand' - - /** - * Retrieves tooltip dynamically. - */ - get tooltip() { - return this.props._raisedHand ? 'toolbar.lowerYourHand' : 'toolbar.raiseYourHand'; - } - - /** - * Required by linter due to AbstractButton overwritten prop being writable. - * - * @param {string} value - The value. - */ - set tooltip(value) { - return value; - } - - /** - * Handles clicking / pressing the button, and opens the appropriate dialog. - * - * @protected - * @returns {void} - */ - _handleClick() { - this.props.handleClick(); - } - - /** - * Indicates whether this button is in toggled state or not. - * - * @override - * @protected - * @returns {boolean} - */ - _isToggled() { - return this.props._raisedHand; - } -} - -/** - * Function that maps parts of Redux state tree into component props. - * - * @param {Object} state - Redux state. - * @returns {Object} - */ -const mapStateToProps = state => { - const localParticipant = getLocalParticipant(state); - - return { - _raisedHand: localParticipant.raisedHand - }; -}; - -export default translate(connect(mapStateToProps)(RaiseHandButton)); diff --git a/react/features/toolbox/components/web/Toolbox.js b/react/features/toolbox/components/web/Toolbox.js index f6d9de5e5..b53ed6a46 100644 --- a/react/features/toolbox/components/web/Toolbox.js +++ b/react/features/toolbox/components/web/Toolbox.js @@ -36,6 +36,7 @@ import { } from '../../../participants-pane/actions'; import ParticipantsPaneButton from '../../../participants-pane/components/ParticipantsPaneButton'; import { getParticipantsPaneOpen } from '../../../participants-pane/functions'; +import { ReactionsMenuButton } from '../../../reactions/components'; import { LiveStreamButton, RecordButton @@ -81,7 +82,6 @@ import AudioSettingsButton from './AudioSettingsButton'; import FullscreenButton from './FullscreenButton'; import OverflowMenuButton from './OverflowMenuButton'; import ProfileButton from './ProfileButton'; -import RaiseHandButton from './RaiseHandButton'; import Separator from './Separator'; import ShareDesktopButton from './ShareDesktopButton'; import VideoSettingsButton from './VideoSettingsButton'; @@ -256,7 +256,6 @@ class Toolbox extends Component<Props> { this._onToolbarOpenVideoQuality = this._onToolbarOpenVideoQuality.bind(this); this._onToolbarToggleChat = this._onToolbarToggleChat.bind(this); this._onToolbarToggleFullScreen = this._onToolbarToggleFullScreen.bind(this); - this._onToolbarToggleRaiseHand = this._onToolbarToggleRaiseHand.bind(this); this._onToolbarToggleScreenshare = this._onToolbarToggleScreenshare.bind(this); this._onShortcutToggleTileView = this._onShortcutToggleTileView.bind(this); this._onEscKey = this._onEscKey.bind(this); @@ -547,8 +546,7 @@ class Toolbox extends Component<Props> { const raisehand = { key: 'raisehand', - Content: RaiseHandButton, - handleClick: this._onToolbarToggleRaiseHand, + Content: ReactionsMenuButton, group: 2 }; @@ -1024,23 +1022,6 @@ class Toolbox extends Component<Props> { this._doToggleFullScreen(); } - _onToolbarToggleRaiseHand: () => void; - - /** - * Creates an analytics toolbar event and dispatches an action for toggling - * raise hand. - * - * @private - * @returns {void} - */ - _onToolbarToggleRaiseHand() { - sendAnalytics(createToolbarEvent( - 'raise.hand', - { enable: !this.props._raisedHand })); - - this._doToggleRaiseHand(); - } - _onToolbarToggleScreenshare: () => void; /** @@ -1144,7 +1125,10 @@ class Toolbox extends Component<Props> { ariaControls = 'overflow-menu' isOpen = { _overflowMenuVisible } key = 'overflow-menu' - onVisibilityChange = { this._onSetOverflowVisible }> + onVisibilityChange = { this._onSetOverflowVisible } + showMobileReactions = { + overflowMenuButtons.find(({ key }) => key === 'raisehand') + }> <ul aria-label = { t(toolbarAccLabel) } className = 'overflow-menu' @@ -1154,15 +1138,15 @@ class Toolbox extends Component<Props> { {overflowMenuButtons.map(({ group, key, Content, ...rest }, index, arr) => { const showSeparator = index > 0 && arr[index - 1].group !== group; - return ( - <> + return key !== 'raisehand' + && <> {showSeparator && <Separator key = { `hr${group}` } />} <Content { ...rest } key = { key } showLabel = { true } /> </> - ); + ; })} </ul> </OverflowMenuButton> diff --git a/react/features/toolbox/middleware.js b/react/features/toolbox/middleware.js index d0c92c0bb..e43cb7b97 100644 --- a/react/features/toolbox/middleware.js +++ b/react/features/toolbox/middleware.js @@ -8,6 +8,7 @@ import { SET_FULL_SCREEN } from './actionTypes'; + declare var APP: Object; /** @@ -18,6 +19,7 @@ declare var APP: Object; * @returns {Function} */ MiddlewareRegistry.register(store => next => action => { + switch (action.type) { case CLEAR_TOOLBOX_TIMEOUT: { const { timeoutID } = store.getState()['features/toolbox'];