feat(reactions) Added Reactions (#9465)

* Created desktop reactions menu

Moved raise hand functionality to reactions menu

* Added reactions to chat

* Added animations

* Added reactions to the web mobile version

Redesigned the overflow menu. Added the reactions menu and reactions animations

* Make toolbar visible on animation start

* Bug fix

* Cleanup

* Fixed overflow menu desktop

* Revert mobile menu changes

* Removed unused CSS

* Fixed iOS safari issue

* Fixed overflow issue on mobile

* Added keyboard shortcuts for reactions

* Disabled double tap zoom on reaction buttons

* Refactored actions

* Updated option symbol for keyboard shortcuts

* Actions refactor

* Refactor

* Fixed linting errors

* Updated BottomSheet

* Added reactions on native

* Code cleanup

* Code review refactor

* Color fix

* Hide reactions on one participant

* Removed console log

* Lang fix

* Update schortcuts
This commit is contained in:
robertpin 2021-07-13 09:50:08 +03:00 committed by GitHub
parent 8db3a341b3
commit 601ee219e7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
50 changed files with 2233 additions and 363 deletions

View File

@ -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.

View File

@ -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;

189
css/_reactions-menu.scss Normal file
View File

@ -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;

View File

@ -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;

View File

@ -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

View File

@ -106,6 +106,7 @@ $flagsImagePath: "../images/";
@import 'connection-status';
@import 'drawer';
@import 'participants-pane';
@import 'reactions-menu';
@import 'plan-limit';
/* Modules END */

View File

@ -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",

View File

@ -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';

View File

@ -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

View File

@ -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';

View File

@ -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';

View File

@ -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>
};
/**

View File

@ -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
};
}

View File

@ -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);

View File

@ -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
}
});

View File

@ -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);

View File

@ -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>

View File

@ -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';

View File

@ -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
};
}

View File

@ -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
};
}

View File

@ -0,0 +1 @@
export * from './native';

View File

@ -0,0 +1 @@
export * from './web';

View File

@ -0,0 +1 @@
export * from './_';

View File

@ -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));

View File

@ -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);

View File

@ -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;

View File

@ -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;

View File

@ -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_;

View File

@ -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));

View File

@ -0,0 +1,3 @@
export { default as ReactionsMenuButton } from './ReactionsMenuButton';
export { default as ReactionEmoji } from './ReactionEmoji';
export { default as ReactionMenu } from './ReactionMenu';

View File

@ -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;

View File

@ -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);

View File

@ -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));

View File

@ -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));

View File

@ -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;

View File

@ -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';

View File

@ -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
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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);
});

View File

@ -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;
});

View File

@ -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);

View File

@ -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 } />);
}
}

View File

@ -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')

View File

@ -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.
*/

View File

@ -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;

View File

@ -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)
};
}

View File

@ -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));

View File

@ -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>

View File

@ -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'];