mirror of https://github.com/jitsi/jitsi-meet
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 schortcutspull/9531/head jitsi-meet_6062
parent
8db3a341b3
commit
601ee219e7
@ -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; |
@ -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'; |
@ -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 |
||||
}; |
||||
} |
@ -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 |
||||
}; |
||||
} |
@ -0,0 +1 @@ |
||||
export * from './native'; |
@ -0,0 +1 @@ |
||||
export * from './web'; |
@ -0,0 +1 @@ |
||||
export * from './_'; |
@ -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)); |
@ -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); |
@ -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; |
@ -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; |
@ -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_; |
@ -0,0 +1,3 @@ |
||||
export { default as ReactionsMenuButton } from './ReactionsMenuButton'; |
||||
export { default as ReactionEmoji } from './ReactionEmoji'; |
||||
export { default as ReactionMenu } from './ReactionMenu'; |
@ -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; |
@ -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); |
@ -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)); |
@ -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)); |
@ -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; |
@ -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'; |
@ -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 |
||||
} |
@ -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; |
||||
} |
@ -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; |
||||
} |
@ -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); |
||||
}); |
@ -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; |
||||
}); |
@ -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); |
@ -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)); |
Loading…
Reference in new issue