init
Some checks failed
Close stale issues and PRs / stale (push) Has been cancelled

This commit is contained in:
2025-09-02 14:49:16 +08:00
commit 38ba663466
2885 changed files with 391107 additions and 0 deletions

View File

@@ -0,0 +1,65 @@
/**
* 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: ADD_REACTION_BUFFER,
* message: string,
* timeoutID: number
* }
*/
export const ADD_REACTION_BUFFER = 'ADD_REACTION_BUFFER';
/**
* The type of the action which sends the reaction buffer and resets it.
*
* {
* type: FLUSH_REACTION_BUFFER
* }
*/
export const FLUSH_REACTION_BUFFER = 'FLUSH_REACTION_BUFFER';
/**
* The type of the action which adds a new reaction message to the chat.
*
* {
* type: ADD_REACTION_MESSAGE,
* message: string,
* }
*/
export const ADD_REACTION_MESSAGE = 'ADD_REACTION_MESSAGE';
/**
* The type of the action which sets the reactions queue.
*
* {
* 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_REACTIONS = 'SEND_REACTIONS';
/**
* The type of action to adds reactions to the queue.
*/
export const PUSH_REACTIONS = 'PUSH_REACTIONS';
/**
* The type of action to display disable notification sounds.
*/
export const SHOW_SOUNDS_NOTIFICATION = 'SHOW_SOUNDS_NOTIFICATION';

View File

@@ -0,0 +1,114 @@
import { IStore } from '../app/types';
import {
ADD_REACTION_BUFFER,
ADD_REACTION_MESSAGE,
FLUSH_REACTION_BUFFER,
PUSH_REACTIONS,
SEND_REACTIONS,
SET_REACTION_QUEUE,
SHOW_SOUNDS_NOTIFICATION
} from './actionTypes';
import { IReactionEmojiProps } from './constants';
import { IReactionsAction } from './reducer';
/**
* Sets the reaction queue.
*
* @param {Array} queue - The new queue.
* @returns {IReactionsAction}
*/
export function setReactionQueue(queue: Array<IReactionEmojiProps>): IReactionsAction {
return {
type: SET_REACTION_QUEUE,
queue
};
}
/**
* Removes a reaction from the queue.
*
* @param {string} uid - Id of the reaction to be removed.
* @returns {Function}
*/
export function removeReaction(uid: string): any {
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
const queue = getState()['features/reactions'].queue;
dispatch(setReactionQueue(queue.filter((reaction: IReactionEmojiProps) => reaction.uid !== uid)));
};
}
/**
* Sends the reactions buffer to everyone in the conference.
*
* @returns {IReactionsAction}
*/
export function sendReactions(): IReactionsAction {
return {
type: SEND_REACTIONS
};
}
/**
* Adds a reaction to the local buffer.
*
* @param {string} reaction - The reaction to be added.
* @returns {IReactionsAction}
*/
export function addReactionToBuffer(reaction: string): IReactionsAction {
return {
type: ADD_REACTION_BUFFER,
reaction
};
}
/**
* Clears the reaction buffer.
*
* @returns {IReactionsAction}
*/
export function flushReactionBuffer(): IReactionsAction {
return {
type: FLUSH_REACTION_BUFFER
};
}
/**
* Adds a reaction message to the chat.
*
* @param {string} message - The reaction message.
* @returns {IReactionsAction}
*/
export function addReactionsToChat(message: string): IReactionsAction {
return {
type: ADD_REACTION_MESSAGE,
message
};
}
/**
* Adds reactions to the animation queue.
*
* @param {Array} reactions - The reactions to be animated.
* @returns {IReactionsAction}
*/
export function pushReactions(reactions: Array<string>): IReactionsAction {
return {
type: PUSH_REACTIONS,
reactions
};
}
/**
* Displays the disable sounds notification.
*
* @returns {void}
*/
export function displayReactionSoundsNotification(): IReactionsAction {
return {
type: SHOW_SOUNDS_NOTIFICATION
};
}

View File

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

View File

@@ -0,0 +1,17 @@
import {
TOGGLE_REACTIONS_VISIBLE
} from './actionTypes';
import { IReactionsAction } from './reducer';
export * from './actions.any';
/**
* Toggles the visibility of the reactions menu.
*
* @returns {void}
*/
export function toggleReactionsMenuVisibility(): IReactionsAction {
return {
type: TOGGLE_REACTIONS_VISIBLE
};
}

View File

@@ -0,0 +1,192 @@
import React, { Component } from 'react';
import { Text } from 'react-native-paper';
import { connect } from 'react-redux';
import { createToolbarEvent } from '../../../analytics/AnalyticsEvents';
import { sendAnalytics } from '../../../analytics/functions';
import { IReduxState } from '../../../app/types';
import { RAISE_HAND_ENABLED } from '../../../base/flags/constants';
import { getFeatureFlag } from '../../../base/flags/functions';
import { translate } from '../../../base/i18n/functions';
import { raiseHand } from '../../../base/participants/actions';
import {
getLocalParticipant,
hasRaisedHand
} from '../../../base/participants/functions';
import { ILocalParticipant } from '../../../base/participants/types';
import { IProps as AbstractButtonProps } from '../../../base/toolbox/components/AbstractButton';
import Button from '../../../base/ui/components/native/Button';
import { BUTTON_TYPES } from '../../../base/ui/constants.native';
import styles from './styles';
/**
* The type of the React {@code Component} props of {@link RaiseHandButton}.
*/
interface IProps extends AbstractButtonProps {
/**
* Whether this button is enabled or not.
*/
_enabled: boolean;
/**
* The local participant.
*/
_localParticipant?: ILocalParticipant;
/**
* Whether the participant raused their hand or not.
*/
_raisedHand: boolean;
/**
* Whether or not the click is disabled.
*/
disableClick?: boolean;
/**
* Used to close the overflow menu after raise hand is clicked.
*/
onCancel: Function;
}
/**
* An implementation of a button to raise or lower hand.
*/
class RaiseHandButton extends Component<IProps> {
accessibilityLabel = 'toolbar.accessibilityLabel.raiseHand';
label = 'toolbar.raiseYourHand';
toggledLabel = 'toolbar.lowerYourHand';
/**
* Initializes a new {@code RaiseHandButton} instance.
*
* @param {IProps} props - The React {@code Component} props to initialize
* the new {@code RaiseHandButton} instance with.
*/
constructor(props: IProps) {
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);
}
/**
* Handles clicking / pressing the button.
*
* @returns {void}
*/
_onClick() {
const { disableClick, onCancel } = this.props;
if (disableClick) {
return;
}
this._toggleRaisedHand();
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);
}
/**
* Renders the "raise hand" emoji.
*
* @returns {ReactElement}
*/
_renderRaiseHandEmoji() {
return (
<Text></Text>
);
}
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
* @returns {ReactElement}
*/
override render() {
const { _enabled } = this.props;
if (!_enabled) {
return null;
}
return (
<Button
accessibilityLabel = { this.accessibilityLabel }
icon = { this._renderRaiseHandEmoji }
labelKey = { this._getLabel() }
onClick = { this._onClick }
style = { styles.raiseHandButton }
type = { BUTTON_TYPES.SECONDARY } />
);
}
}
/**
* Maps part of the Redux state to the props of this component.
*
* @param {Object} state - The Redux state.
* @private
* @returns {IProps}
*/
function _mapStateToProps(state: IReduxState) {
const _localParticipant = getLocalParticipant(state);
const enabled = getFeatureFlag(state, RAISE_HAND_ENABLED, true);
return {
_enabled: enabled,
_localParticipant,
_raisedHand: hasRaisedHand(_localParticipant)
};
}
/**
* Maps part of the Redux state to the props of this component.
*
* @param {Object} state - The Redux state.
* @private
* @returns {IProps}
*/
function _standaloneMapStateToProps(state: IReduxState) {
const _enabled = getFeatureFlag(state, RAISE_HAND_ENABLED, true);
return {
_enabled
};
}
const StandaloneRaiseHandButton = translate(connect(_standaloneMapStateToProps)(RaiseHandButton));
export { StandaloneRaiseHandButton };
export default translate(connect(_mapStateToProps)(RaiseHandButton));

View File

@@ -0,0 +1,20 @@
import React from 'react';
import { useSelector } from 'react-redux';
import { IProps as AbstractButtonProps } from '../../../base/toolbox/components/AbstractButton';
import RaiseHandButton from '../../../toolbox/components/native/RaiseHandButton';
import { shouldDisplayReactionsButtons } from '../../functions.native';
import ReactionsMenuButton from './ReactionsMenuButton';
const RaiseHandContainerButtons = (props: AbstractButtonProps) => {
const _shouldDisplayReactionsButtons = useSelector(shouldDisplayReactionsButtons);
return _shouldDisplayReactionsButtons
? <ReactionsMenuButton
{ ...props }
showRaiseHand = { true } />
: <RaiseHandButton { ...props } />;
};
export default RaiseHandContainerButtons;

View File

@@ -0,0 +1,105 @@
import React, { useCallback } from 'react';
import { WithTranslation } from 'react-i18next';
import { ColorValue, GestureResponderEvent, Text, TouchableHighlight, ViewStyle } from 'react-native';
import { useDispatch } from 'react-redux';
import { createReactionMenuEvent } from '../../../analytics/AnalyticsEvents';
import { sendAnalytics } from '../../../analytics/functions';
import { translate } from '../../../base/i18n/functions';
import { StyleType } from '../../../base/styles/functions.native';
import { addReactionToBuffer } from '../../actions.any';
import { REACTIONS } from '../../constants';
interface IReactionStyles {
/**
* Style for text container. Used on raise hand button.
*/
container?: StyleType;
/**
* Style for the emoji text on the button.
*/
emoji: StyleType;
/**
* Style for the gif button.
*/
gifButton: StyleType;
/**
* Style for the button.
*/
style: StyleType;
/**
* Style for the label text on the button.
*/
text?: StyleType;
/**
* Underlay color for the button.
*/
underlayColor: ColorValue;
}
/**
* The type of the React {@code Component} props of {@link ReactionButton}.
*/
interface IProps extends WithTranslation {
/**
* Component children.
*/
children?: React.ReactNode;
/**
* External click handler.
*/
onClick?: (e?: GestureResponderEvent) => void;
/**
* The reaction to be sent.
*/
reaction?: string;
/**
* Collection of styles for the button.
*/
styles: IReactionStyles;
}
/**
* An implementation of a button to send a reaction.
*
* @returns {ReactElement}
*/
function ReactionButton({
children,
onClick,
styles,
reaction,
t
}: IProps) {
const dispatch = useDispatch();
const _onClick = useCallback(() => {
if (reaction) {
dispatch(addReactionToBuffer(reaction));
sendAnalytics(createReactionMenuEvent(reaction));
}
}, [ reaction ]);
return (
<TouchableHighlight
accessibilityLabel = { t(`toolbar.accessibilityLabel.${reaction}`) }
accessibilityRole = 'button'
onPress = { onClick || _onClick }
style = { [ styles.style, children && styles?.gifButton ] as ViewStyle[] }
underlayColor = { styles.underlayColor }>
{children ?? <Text style = { styles.emoji }>{REACTIONS[reaction ?? ''].emoji}</Text>}
</TouchableHighlight>
);
}
export default translate(ReactionButton);

View File

@@ -0,0 +1,94 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { Animated } from 'react-native';
import { useDispatch, useSelector } from 'react-redux';
import { IReduxState } from '../../../app/types';
import ColorSchemeRegistry from '../../../base/color-scheme/ColorSchemeRegistry';
import { removeReaction } from '../../actions.any';
import { IReactionEmojiProps, REACTIONS } from '../../constants';
interface IProps extends IReactionEmojiProps {
/**
* 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 }: IProps) {
const _styles: any = useSelector((state: IReduxState) => ColorSchemeRegistry.get(state, 'Toolbox'));
const _height = useSelector((state: IReduxState) => 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: number, max: number) => 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,76 @@
import React, { useCallback } from 'react';
import { Image, View } from 'react-native';
import { useSelector } from 'react-redux';
import { IReduxState } from '../../../app/types';
import ColorSchemeRegistry from '../../../base/color-scheme/ColorSchemeRegistry';
import { isGifEnabled } from '../../../gifs/functions.native';
import { navigate } from '../../../mobile/navigation/components/conference/ConferenceNavigationContainerRef';
import { screen } from '../../../mobile/navigation/routes';
import { REACTIONS } from '../../constants';
import RaiseHandButton from './RaiseHandButton';
import ReactionButton from './ReactionButton';
/**
* The type of the React {@code Component} props of {@link ReactionMenu}.
*/
interface IProps {
/**
* 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
}: IProps) {
const _styles: any = useSelector((state: IReduxState) => ColorSchemeRegistry.get(state, 'Toolbox'));
const gifEnabled = useSelector(isGifEnabled);
const openGifMenu = useCallback(() => {
navigate(screen.conference.gifsMenu);
onCancel();
}, []);
return (
<View style = { overflowMenu ? _styles.overflowReactionMenu : _styles.reactionMenu }>
<View style = { _styles.reactionRow }>
{
Object.keys(REACTIONS).map(key => (
<ReactionButton
key = { key }
reaction = { key }
styles = { _styles.reactionButton } />
))
}
{
gifEnabled && (
<ReactionButton
onClick = { openGifMenu }
styles = { _styles.reactionButton }>
<Image // @ts-ignore
height = { 22 }
source = { require('../../../../../images/GIPHY_icon.png') } />
</ReactionButton>
)
}
</View>
<RaiseHandButton onCancel = { onCancel } />
</View>
);
}
export default ReactionMenu;

View File

@@ -0,0 +1,141 @@
import React, { ComponentType, PureComponent } from 'react';
import { SafeAreaView, TouchableWithoutFeedback, View } from 'react-native';
import { connect } from 'react-redux';
import { IReduxState, IStore } from '../../../app/types';
import ColorSchemeRegistry from '../../../base/color-scheme/ColorSchemeRegistry';
import { hideDialog } from '../../../base/dialog/actions';
import { isDialogOpen } from '../../../base/dialog/functions';
import { getParticipantCount } from '../../../base/participants/functions';
import { StyleType } from '../../../base/styles/functions.native';
import ReactionMenu from './ReactionMenu';
/**
* The type of the React {@code Component} props of {@link ReactionMenuDialog}.
*/
interface IProps {
/**
* The height of the screen.
*/
_height: number;
/**
* True if the dialog is currently visible, false otherwise.
*/
_isOpen: boolean;
/**
* Number of conference participants.
*/
_participantCount: number;
/**
* The color-schemed stylesheet of the feature.
*/
_styles: StyleType;
/**
* The width of the screen.
*/
_width: number;
/**
* Used for hiding the dialog when the selection was completed.
*/
dispatch: IStore['dispatch'];
}
/**
* 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_: ComponentType<any>; // 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<IProps> {
/**
* Initializes a new {@code ReactionMenuDialog} instance.
*
* @inheritdoc
*/
constructor(props: IProps) {
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}
*/
override 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>
);
}
/**
* 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 {IProps}
*/
function _mapStateToProps(state: IReduxState) {
return {
_isOpen: isDialogOpen(state, ReactionMenu_), // @ts-ignore
_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

@@ -0,0 +1,85 @@
import { connect } from 'react-redux';
import { IReduxState } from '../../../app/types';
import { openDialog } from '../../../base/dialog/actions';
import { isDialogOpen } from '../../../base/dialog/functions';
import { RAISE_HAND_ENABLED } from '../../../base/flags/constants';
import { getFeatureFlag } from '../../../base/flags/functions';
import { translate } from '../../../base/i18n/functions';
import { IconRaiseHand } from '../../../base/icons/svg';
import {
getLocalParticipant, hasRaisedHand
} from '../../../base/participants/functions';
import AbstractButton, { IProps as AbstractButtonProps } from '../../../base/toolbox/components/AbstractButton';
import ReactionMenuDialog from './ReactionMenuDialog';
/**
* The type of the React {@code Component} props of {@link ReactionsMenuButton}.
*/
interface IProps extends AbstractButtonProps {
/**
* Whether the participant raised their hand or not.
*/
_raisedHand: boolean;
/**
* Whether or not the reactions menu is open.
*/
_reactionsOpen: boolean;
}
/**
* An implementation of a button to raise or lower hand.
*/
class ReactionsMenuButton extends AbstractButton<IProps> {
override accessibilityLabel = 'toolbar.accessibilityLabel.reactionsMenu';
override icon = IconRaiseHand;
override label = 'toolbar.openReactionsMenu';
override toggledLabel = 'toolbar.closeReactionsMenu';
/**
* Handles clicking / pressing the button.
*
* @override
* @protected
* @returns {void}
*/
override _handleClick() {
this.props.dispatch(openDialog(ReactionMenuDialog));
}
/**
* Indicates whether this button is in toggled state or not.
*
* @override
* @protected
* @returns {boolean}
*/
override _isToggled() {
return this.props._raisedHand || this.props._reactionsOpen;
}
}
/**
* Maps part of the Redux state to the props of this component.
*
* @param {Object} state - The Redux state.
* @param {Object} ownProps - The properties explicitly passed to the component instance.
* @private
* @returns {IProps}
*/
function _mapStateToProps(state: IReduxState, ownProps: any) {
const _localParticipant = getLocalParticipant(state);
const enabled = getFeatureFlag(state, RAISE_HAND_ENABLED, true);
const { visible = enabled } = ownProps;
return {
_raisedHand: hasRaisedHand(_localParticipant),
_reactionsOpen: isDialogOpen(state, ReactionMenuDialog),
visible
};
}
export default translate(connect(_mapStateToProps)(ReactionsMenuButton));

View File

@@ -0,0 +1,11 @@
import BaseTheme from '../../../base/ui/components/BaseTheme.native';
/**
* The styles of the native components of the feature {@code reactions}.
*/
export default {
raiseHandButton: {
marginVertical: BaseTheme.spacing[3],
width: 240
}
};

View File

@@ -0,0 +1,95 @@
import { connect } from 'react-redux';
import { createToolbarEvent } from '../../../analytics/AnalyticsEvents';
import { sendAnalytics } from '../../../analytics/functions';
import { IReduxState, IStore } from '../../../app/types';
import { translate } from '../../../base/i18n/functions';
import { IconRaiseHand } from '../../../base/icons/svg';
import { raiseHand } from '../../../base/participants/actions';
import { getLocalParticipant, hasRaisedHand } from '../../../base/participants/functions';
import AbstractButton, { IProps as AbstractButtonProps } from '../../../base/toolbox/components/AbstractButton';
/**
* The type of the React {@code Component} props of {@link RaiseHandButton}.
*/
interface IProps extends AbstractButtonProps {
/**
* Whether or not the click is disabled.
*/
disableClick?: boolean;
/**
* Redux dispatch function.
*/
dispatch: IStore['dispatch'];
/**
* Whether or not the hand is raised.
*/
raisedHand: boolean;
}
/**
* Implementation of a button for raising hand.
*/
class RaiseHandButton extends AbstractButton<IProps> {
override accessibilityLabel = 'toolbar.accessibilityLabel.raiseHand';
override toggledAccessibilityLabel = 'toolbar.accessibilityLabel.lowerHand';
override icon = IconRaiseHand;
override label = 'toolbar.raiseHand';
override toggledLabel = 'toolbar.lowerYourHand';
override tooltip = 'toolbar.raiseHand';
override toggledTooltip = 'toolbar.lowerYourHand';
/**
* Indicates whether this button is in toggled state or not.
*
* @override
* @protected
* @returns {boolean}
*/
override _isToggled() {
return this.props.raisedHand;
}
/**
* Handles clicking the button, and toggles the raise hand.
*
* @private
* @returns {void}
*/
override _handleClick() {
const { disableClick, dispatch, raisedHand } = this.props;
if (disableClick) {
return;
}
sendAnalytics(createToolbarEvent(
'raise.hand',
{ enable: !raisedHand }));
dispatch(raiseHand(!raisedHand));
}
}
/**
* Function that maps parts of Redux state tree into component props.
*
* @param {Object} state - Redux state.
* @returns {Object}
*/
const mapStateToProps = (state: IReduxState) => {
const localParticipant = getLocalParticipant(state);
return {
raisedHand: hasRaisedHand(localParticipant)
};
};
export { RaiseHandButton };
export default translate(connect(mapStateToProps)(RaiseHandButton));

View File

@@ -0,0 +1,26 @@
import React from 'react';
import { useSelector } from 'react-redux';
import { IReduxState } from '../../../app/types';
import { isMobileBrowser } from '../../../base/environment/utils';
import { IProps as AbstractButtonProps } from '../../../base/toolbox/components/AbstractButton';
import { isReactionsButtonEnabled, shouldDisplayReactionsButtons } from '../../functions.web';
import RaiseHandButton from './RaiseHandButton';
import ReactionsMenuButton from './ReactionsMenuButton';
const RaiseHandContainerButton = (props: AbstractButtonProps) => {
const reactionsButtonEnabled = useSelector(isReactionsButtonEnabled);
const _shouldDisplayReactionsButtons = useSelector(shouldDisplayReactionsButtons);
const isNarrowLayout = useSelector((state: IReduxState) => state['features/base/responsive-ui'].isNarrowLayout);
const showReactionsAsPartOfRaiseHand
= _shouldDisplayReactionsButtons && !reactionsButtonEnabled && !isNarrowLayout && !isMobileBrowser();
return showReactionsAsPartOfRaiseHand
? <ReactionsMenuButton
{ ...props }
showRaiseHand = { true } />
: <RaiseHandButton { ...props } />;
};
export default RaiseHandContainerButton;

View File

@@ -0,0 +1,231 @@
import React, { Component } from 'react';
import Tooltip from '../../../base/tooltip/components/Tooltip';
import { TOOLTIP_POSITION } from '../../../base/ui/constants.any';
/**
* The type of the React {@code Component} props of {@link ReactionButton}.
*/
interface IProps {
/**
* A succinct description of what the button does. Used by accessibility
* tools and torture tests.
*/
accessibilityLabel: string;
/**
* The Icon of this {@code AbstractToolbarButton}.
*/
icon: Object;
/**
* The style of the Icon of this {@code AbstractToolbarButton}.
*/
iconStyle?: Object;
/**
* Optional label for the button.
*/
label?: string;
/**
* On click handler.
*/
onClick: Function;
/**
* {@code AbstractToolbarButton} Styles.
*/
style?: Array<string> | Object;
/**
* An optional modifier to render the button toggled.
*/
toggled?: boolean;
/**
* Optional text to display in the tooltip.
*/
tooltip?: string;
/**
* From which direction the tooltip should appear, relative to the
* button.
*/
tooltipPosition: TOOLTIP_POSITION;
/**
* The color underlying the button.
*/
underlayColor?: any;
}
/**
* The type of the React {@code Component} state of {@link ReactionButton}.
*/
interface IState {
/**
* Used to determine zoom level on reaction burst.
*/
increaseLevel: number;
/**
* Timeout ID to reset reaction burst.
*/
increaseTimeout: number | null;
}
/**
* Represents a button in the reactions menu.
*
* @augments AbstractToolbarButton
*/
class ReactionButton extends Component<IProps, IState> {
/**
* Default values for {@code ReactionButton} component's properties.
*
* @static
*/
static defaultProps = {
tooltipPosition: 'top'
};
/**
* Initializes a new {@code ReactionButton} instance.
*
* @inheritdoc
*/
constructor(props: IProps) {
super(props);
this._onKeyDown = this._onKeyDown.bind(this);
this._onClickHandler = this._onClickHandler.bind(this);
this._onClick = this._onClick.bind(this);
this.state = {
increaseLevel: 0,
increaseTimeout: null
};
}
/**
* Handles clicking/pressing this {@code AbstractToolbarButton} by
* forwarding the event to the {@code onClick} prop of this instance if any.
*
* @protected
* @returns {*} The result returned by the invocation of the {@code onClick}
* prop of this instance if any.
*/
_onClick(...args: any) {
const { onClick } = this.props;
return onClick?.(...args);
}
/**
* 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: React.KeyboardEvent) {
// 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();
}
}
/**
* Handles reaction button click.
*
* @param {Event} event - The click event.
* @returns {void}
*/
_onClickHandler(event: any) {
event.preventDefault();
event.stopPropagation();
this.props.onClick();
clearTimeout(this.state.increaseTimeout ?? 0);
const timeout = window.setTimeout(() => {
this.setState({
increaseLevel: 0
});
}, 500);
this.setState(state => {
return {
increaseLevel: state.increaseLevel + 1,
increaseTimeout: timeout
};
});
}
/**
* 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: React.ReactElement) {
return (
<div
aria-label = { this.props.accessibilityLabel }
aria-pressed = { this.props.toggled }
className = 'toolbox-button'
onClick = { this._onClickHandler }
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() {
const { toggled, icon, label } = this.props;
const { increaseLevel } = this.state;
return (
<div className = { `toolbox-icon ${toggled ? 'toggled' : ''}` }>
<span className = { `emoji increase-${increaseLevel > 12 ? 12 : increaseLevel}` }>{icon}</span>
{label && <span className = 'text'>{label}</span>}
</div>
);
}
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
* @returns {ReactElement}
*/
override render() {
return this._renderButton(this._renderIcon());
}
}
export default ReactionButton;

View File

@@ -0,0 +1,94 @@
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { IStore } from '../../../app/types';
import { removeReaction } from '../../actions.any';
import { REACTIONS } from '../../constants';
interface IProps {
/**
* Index of the reaction in the queue.
*/
index: number;
/**
* Reaction to be displayed.
*/
reaction: string;
/**
* Removes reaction from redux state.
*/
reactionRemove: Function;
/**
* Id of the reaction.
*/
uid: string;
}
interface IState {
/**
* Index of CSS animation. Number between 0-20.
*/
index: number;
}
/**
* Used to display animated reactions.
*
* @returns {ReactElement}
*/
class ReactionEmoji extends Component<IProps, IState> {
/**
* Initializes a new {@code ReactionEmoji} instance.
*
* @param {IProps} props - The read-only React {@code Component} props with
* which the new instance is to be initialized.
*/
constructor(props: IProps) {
super(props);
this.state = {
index: props.index % 21
};
}
/**
* Implements React Component's componentDidMount.
*
* @inheritdoc
*/
override componentDidMount() {
setTimeout(() => this.props.reactionRemove(this.props.uid), 5000);
}
/**
* Implements React's {@link Component#render}.
*
* @inheritdoc
*/
override 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 = (dispatch: IStore['dispatch']) => {
return {
reactionRemove: (uid: string) => dispatch(removeReaction(uid))
};
};
export default connect(undefined, mapDispatchToProps)(ReactionEmoji);

View File

@@ -0,0 +1,29 @@
import React from 'react';
import { useSelector } from 'react-redux';
import { getReactionsQueue, isReactionsEnabled, shouldDisplayReactionsButtons } from '../../functions.any';
import ReactionEmoji from './ReactionEmoji';
/**
* Renders the reactions animations in the case when there is no buttons displayed.
*
* @returns {ReactNode}
*/
export default function ReactionAnimations() {
const reactionsQueue = useSelector(getReactionsQueue);
const _shouldDisplayReactionsButtons = useSelector(shouldDisplayReactionsButtons);
const reactionsEnabled = useSelector(isReactionsEnabled);
if (reactionsEnabled && !_shouldDisplayReactionsButtons) {
return (<div className = 'reactions-animations-container'>
{reactionsQueue.map(({ reaction, uid }, index) => (<ReactionEmoji
index = { index }
key = { uid }
reaction = { reaction }
uid = { uid } />))}
</div>);
}
return null;
}

View File

@@ -0,0 +1,259 @@
import React, { useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { connect } from 'react-redux';
import { makeStyles } from 'tss-react/mui';
import { createReactionMenuEvent, createToolbarEvent } from '../../../analytics/AnalyticsEvents';
import { sendAnalytics } from '../../../analytics/functions';
import { IReduxState, IStore } from '../../../app/types';
import { raiseHand } from '../../../base/participants/actions';
import { getLocalParticipant, hasRaisedHand } from '../../../base/participants/functions';
import GifsMenu from '../../../gifs/components/web/GifsMenu';
import GifsMenuButton from '../../../gifs/components/web/GifsMenuButton';
import { isGifEnabled, isGifsMenuOpen } from '../../../gifs/functions';
import { dockToolbox } from '../../../toolbox/actions.web';
import { addReactionToBuffer } from '../../actions.any';
import { toggleReactionsMenuVisibility } from '../../actions.web';
import {
GIFS_MENU_HEIGHT_IN_OVERFLOW_MENU,
RAISE_HAND_ROW_HEIGHT, REACTIONS,
REACTIONS_MENU_HEIGHT_DRAWER,
REACTIONS_MENU_HEIGHT_IN_OVERFLOW_MENU
} from '../../constants';
import { IReactionsMenuParent } from '../../types';
import ReactionButton from './ReactionButton';
interface IProps {
/**
* Docks the toolbox.
*/
_dockToolbox: Function;
/**
* Whether or not the GIF feature is enabled.
*/
_isGifEnabled: boolean;
/**
* Whether or not the GIF menu is visible.
*/
_isGifMenuVisible: boolean;
/**
* The ID of the local participant.
*/
_localParticipantID?: string;
/**
* Whether or not the local participant's hand is raised.
*/
_raisedHand: boolean;
/**
* The Redux Dispatch function.
*/
dispatch: IStore['dispatch'];
/**
* Indicates the parent of the reactions menu.
*/
parent: IReactionsMenuParent;
/**
* Whether to show the raised hand button.
*/
showRaisedHand?: boolean;
}
const useStyles = makeStyles<IProps>()((theme, props: IProps) => {
const { parent, showRaisedHand, _isGifMenuVisible } = props;
let reactionsMenuHeight = REACTIONS_MENU_HEIGHT_DRAWER;
if (parent === IReactionsMenuParent.OverflowDrawer || parent === IReactionsMenuParent.OverflowMenu) {
if (parent === IReactionsMenuParent.OverflowMenu) {
reactionsMenuHeight = REACTIONS_MENU_HEIGHT_IN_OVERFLOW_MENU;
if (_isGifMenuVisible) {
reactionsMenuHeight += GIFS_MENU_HEIGHT_IN_OVERFLOW_MENU;
}
}
if (!showRaisedHand) {
reactionsMenuHeight -= RAISE_HAND_ROW_HEIGHT;
}
}
return {
reactionsMenuInOverflowMenu: {
'&.reactions-menu': {
'&.with-gif': {
width: 'inherit'
},
'.reactions-row': {
'.toolbox-icon': {
width: '24px',
height: '24px',
'span.emoji': {
width: '24px',
height: '24px',
lineHeight: '1.5rem',
fontSize: '1rem'
}
}
},
'.raise-hand-row': {
'.toolbox-icon': {
height: '32px'
}
}
}
},
overflow: {
width: 'auto',
paddingBottom: 'max(env(safe-area-inset-bottom, 0), 16px)',
backgroundColor: theme.palette.ui01,
boxShadow: 'none',
borderRadius: 0,
position: 'relative',
boxSizing: 'border-box',
height: `${reactionsMenuHeight}px`
}
};
});
const _getReactionButtons = (dispatch: IStore['dispatch'], t: Function) => {
let modifierKey = 'Alt';
if (window.navigator?.platform) {
if (window.navigator.platform.indexOf('Mac') !== -1) {
modifierKey = '⌥';
}
}
return Object.keys(REACTIONS).map(key => {
/**
* Sends reaction message.
*
* @returns {void}
*/
function doSendReaction() {
dispatch(addReactionToBuffer(key));
sendAnalytics(createReactionMenuEvent(key));
}
return (<ReactionButton
accessibilityLabel = { t(`toolbar.accessibilityLabel.${key}`) }
icon = { REACTIONS[key].emoji }
key = { key }
// eslint-disable-next-line react/jsx-no-bind
onClick = { doSendReaction }
toggled = { false }
tooltip = { `${t(`toolbar.${key}`)} (${modifierKey} + ${REACTIONS[key].shortcutChar})` } />);
});
};
const ReactionsMenu = (props: IProps) => {
const {
_dockToolbox,
_isGifEnabled,
_isGifMenuVisible,
_raisedHand,
dispatch,
parent,
showRaisedHand = false
} = props;
const isInOverflowMenu
= parent === IReactionsMenuParent.OverflowDrawer || parent === IReactionsMenuParent.OverflowMenu;
const { classes, cx } = useStyles(props);
const { t } = useTranslation();
useEffect(() => {
_dockToolbox(true);
return () => {
_dockToolbox(false);
};
}, []);
const _doToggleRaiseHand = useCallback(() => {
dispatch(raiseHand(!_raisedHand));
}, [ _raisedHand ]);
const _onToolbarToggleRaiseHand = useCallback(() => {
sendAnalytics(createToolbarEvent(
'raise.hand',
{ enable: !_raisedHand }));
_doToggleRaiseHand();
dispatch(toggleReactionsMenuVisibility());
}, [ _raisedHand ]);
const buttons = _getReactionButtons(dispatch, t);
if (_isGifEnabled) {
buttons.push(<GifsMenuButton parent = { parent } />);
}
return (
<div
className = { cx('reactions-menu',
parent === IReactionsMenuParent.OverflowMenu && classes.reactionsMenuInOverflowMenu,
_isGifEnabled && 'with-gif',
isInOverflowMenu && `overflow ${classes.overflow}`) }>
{_isGifEnabled && _isGifMenuVisible
&& <GifsMenu
columns = { parent === IReactionsMenuParent.OverflowMenu ? 1 : undefined }
parent = { parent } />}
<div className = 'reactions-row'>
{ buttons }
</div>
{showRaisedHand && (
<div className = 'raise-hand-row'>
<ReactionButton
accessibilityLabel = { t('toolbar.accessibilityLabel.raiseHand') }
icon = '✋'
key = 'raisehand'
label = {
`${t(`toolbar.${_raisedHand ? 'lowerYourHand' : 'raiseYourHand'}`)}
${isInOverflowMenu ? '' : ' (R)'}`
}
onClick = { _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: IReduxState) {
const localParticipant = getLocalParticipant(state);
return {
_localParticipantID: localParticipant?.id,
_isGifEnabled: isGifEnabled(state),
_isGifMenuVisible: isGifsMenuOpen(state),
_raisedHand: hasRaisedHand(localParticipant)
};
}
/**
* Function that maps parts of Redux actions into component props.
*
* @param {Object} dispatch - Redux dispatch.
* @returns {Object}
*/
function mapDispatchToProps(dispatch: IStore['dispatch']) {
return {
dispatch,
_dockToolbox: (dock: boolean) => dispatch(dockToolbox(dock))
};
}
export default connect(mapStateToProps, mapDispatchToProps)(ReactionsMenu);

View File

@@ -0,0 +1,188 @@
import React, { ReactElement, useCallback } from 'react';
import { WithTranslation } from 'react-i18next';
import { connect } from 'react-redux';
import { IReduxState, IStore } from '../../../app/types';
import { isMobileBrowser } from '../../../base/environment/utils';
import { translate } from '../../../base/i18n/functions';
import { IconArrowUp, IconFaceSmile } from '../../../base/icons/svg';
import AbstractButton, { type IProps as AbstractButtonProps } from '../../../base/toolbox/components/AbstractButton';
import ToolboxButtonWithPopup from '../../../base/toolbox/components/web/ToolboxButtonWithPopup';
import { toggleReactionsMenuVisibility } from '../../actions.web';
import { IReactionEmojiProps } from '../../constants';
import { getReactionsQueue } from '../../functions.any';
import { getReactionsMenuVisibility, isReactionsButtonEnabled } from '../../functions.web';
import { IReactionsMenuParent } from '../../types';
import RaiseHandButton from './RaiseHandButton';
import ReactionEmoji from './ReactionEmoji';
import ReactionsMenu from './ReactionsMenu';
interface IProps extends WithTranslation {
/**
* Whether a mobile browser is used or not.
*/
_isMobile: boolean;
/**
* Whether the reactions should be displayed on separate button or not.
*/
_reactionsButtonEnabled: boolean;
/**
* The button's key.
*/
buttonKey?: string;
/**
* Redux dispatch function.
*/
dispatch: IStore['dispatch'];
/**
* Whether or not it's narrow mode or mobile browser.
*/
isNarrow: boolean;
/**
* Whether or not the reactions menu is open.
*/
isOpen: boolean;
/**
* Notify mode for `toolbarButtonClicked` event -
* whether to only notify or to also prevent button click routine.
*/
notifyMode?: string;
/**
* The array of reactions to be displayed.
*/
reactionsQueue: Array<IReactionEmojiProps>;
/**
* Whether or not to show the raise hand button.
*/
showRaiseHand?: boolean;
}
/**
* Implementation of a button for reactions.
*/
class ReactionsButtonImpl extends AbstractButton<AbstractButtonProps> {
override accessibilityLabel = 'toolbar.accessibilityLabel.reactions';
override icon = IconFaceSmile;
override label = 'toolbar.reactions';
override toggledLabel = 'toolbar.reactions';
override tooltip = 'toolbar.reactions';
}
const ReactionsButton = translate(connect()(ReactionsButtonImpl));
/**
* Button used for the reactions menu.
*
* @returns {ReactElement}
*/
function ReactionsMenuButton({
_reactionsButtonEnabled,
_isMobile,
buttonKey,
dispatch,
isOpen,
isNarrow,
notifyMode,
reactionsQueue,
showRaiseHand,
t
}: IProps) {
const toggleReactionsMenu = useCallback(() => {
dispatch(toggleReactionsMenuVisibility());
}, [ dispatch ]);
const openReactionsMenu = useCallback(() => {
!isOpen && toggleReactionsMenu();
}, [ isOpen, toggleReactionsMenu ]);
const closeReactionsMenu = useCallback(() => {
isOpen && toggleReactionsMenu();
}, [ isOpen, toggleReactionsMenu ]);
if (!showRaiseHand && !_reactionsButtonEnabled) {
return null;
}
const reactionsMenu = (<div className = 'reactions-menu-container'>
<ReactionsMenu parent = { IReactionsMenuParent.Button } />
</div>);
let content: ReactElement | null = null;
if (showRaiseHand) {
content = isNarrow
? (
<RaiseHandButton
buttonKey = { buttonKey }
notifyMode = { notifyMode } />)
: (
<ToolboxButtonWithPopup
ariaLabel = { t('toolbar.accessibilityLabel.reactionsMenu') }
icon = { IconArrowUp }
iconDisabled = { false }
onPopoverClose = { toggleReactionsMenu }
onPopoverOpen = { openReactionsMenu }
popoverContent = { reactionsMenu }
visible = { isOpen }>
<RaiseHandButton
buttonKey = { buttonKey }
notifyMode = { notifyMode } />
</ToolboxButtonWithPopup>);
} else {
content = (
<ToolboxButtonWithPopup
ariaLabel = { t('toolbar.accessibilityLabel.reactionsMenu') }
onPopoverClose = { closeReactionsMenu }
onPopoverOpen = { openReactionsMenu }
popoverContent = { reactionsMenu }
trigger = { _isMobile ? 'click' : undefined }
visible = { isOpen }>
<ReactionsButton
buttonKey = { buttonKey }
notifyMode = { notifyMode } />
</ToolboxButtonWithPopup>);
}
return (
<div className = 'reactions-menu-popup-container'>
{ content }
{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: IReduxState) {
const { isNarrowLayout } = state['features/base/responsive-ui'];
return {
_reactionsButtonEnabled: isReactionsButtonEnabled(state),
_isMobile: isMobileBrowser(),
isOpen: getReactionsMenuVisibility(state),
isNarrow: isNarrowLayout,
reactionsQueue: getReactionsQueue(state)
};
}
export default translate(connect(mapStateToProps)(ReactionsMenuButton));

View File

@@ -0,0 +1,196 @@
import {
BOO_SOUND_FILES,
CLAP_SOUND_FILES,
HEART_SOUND_FILES,
LAUGH_SOUND_FILES,
LIKE_SOUND_FILES,
SILENCE_SOUND_FILES,
SURPRISE_SOUND_FILES
} from './sounds';
/**
* The height of the raise hand row in the reactions menu.
*/
export const RAISE_HAND_ROW_HEIGHT = 54;
/**
* The height of the gifs menu when displayed as part of the overflow menu.
*/
export const GIFS_MENU_HEIGHT_IN_OVERFLOW_MENU = 200;
/**
* Reactions menu height when displayed as part of drawer.
*/
export const REACTIONS_MENU_HEIGHT_DRAWER = 144;
/**
* Reactions menu height when displayed as part of overflow menu.
*/
export const REACTIONS_MENU_HEIGHT_IN_OVERFLOW_MENU = 106;
/**
* The payload name for the datachannel/endpoint reaction event.
*/
export const ENDPOINT_REACTION_NAME = 'endpoint-reaction';
/**
* The (name of the) command which transports the state (represented by
* {State} for the local state at the time of this writing) of a {MuteReactions}
* (instance) between moderator and participants.
*/
export const MUTE_REACTIONS_COMMAND = 'mute-reactions';
/**
* The prefix for all reaction sound IDs. Also the ID used in config to disable reaction sounds.
*/
export const REACTION_SOUND = 'REACTION_SOUND';
/**
* The audio ID prefix of the audio element for which the {@link playAudio} action is
* triggered when a new laugh reaction is received.
*
* @type { string }
*/
export const LAUGH_SOUND_ID = `${REACTION_SOUND}_LAUGH_`;
/**
* The audio ID prefix of the audio element for which the {@link playAudio} action is
* triggered when a new clap reaction is received.
*
* @type {string}
*/
export const CLAP_SOUND_ID = `${REACTION_SOUND}_CLAP_`;
/**
* The audio ID prefix of the audio element for which the {@link playAudio} action is
* triggered when a new like reaction is received.
*
* @type {string}
*/
export const LIKE_SOUND_ID = `${REACTION_SOUND}_LIKE_`;
/**
* The audio ID prefix of the audio element for which the {@link playAudio} action is
* triggered when a new boo reaction is received.
*
* @type {string}
*/
export const BOO_SOUND_ID = `${REACTION_SOUND}_BOO_`;
/**
* The audio ID prefix of the audio element for which the {@link playAudio} action is
* triggered when a new surprised reaction is received.
*
* @type {string}
*/
export const SURPRISE_SOUND_ID = `${REACTION_SOUND}_SURPRISE_`;
/**
* The audio ID prefix of the audio element for which the {@link playAudio} action is
* triggered when a new silence reaction is received.
*
* @type {string}
*/
export const SILENCE_SOUND_ID = `${REACTION_SOUND}_SILENCE_`;
/**
* The audio ID of the audio element for which the {@link playAudio} action is
* triggered when a new raise hand event is received.
*
* @type {string}
*/
export const HEART_SOUND_ID = `${REACTION_SOUND}_HEART_`;
/**
* The audio ID of the audio element for which the {@link playAudio} action is
* triggered when a new raise hand event is received.
*
* @type {string}
*/
export const RAISE_HAND_SOUND_ID = 'RAISE_HAND_SOUND';
export interface IReactionEmojiProps {
/**
* Reaction to be displayed.
*/
reaction: string;
/**
* Id of the reaction.
*/
uid: string;
}
export const SOUNDS_THRESHOLDS = [ 1, 4, 10 ];
interface IReactions {
[key: string]: {
emoji: string;
message: string;
shortcutChar: string;
soundFiles: string[];
soundId: string;
};
}
export const REACTIONS: IReactions = {
like: {
message: ':thumbs_up:',
emoji: '👍',
shortcutChar: 'T',
soundId: LIKE_SOUND_ID,
soundFiles: LIKE_SOUND_FILES
},
clap: {
message: ':clap:',
emoji: '👏',
shortcutChar: 'C',
soundId: CLAP_SOUND_ID,
soundFiles: CLAP_SOUND_FILES
},
laugh: {
message: ':grinning_face:',
emoji: '😀',
shortcutChar: 'L',
soundId: LAUGH_SOUND_ID,
soundFiles: LAUGH_SOUND_FILES
},
surprised: {
message: ':face_with_open_mouth:',
emoji: '😮',
shortcutChar: 'O',
soundId: SURPRISE_SOUND_ID,
soundFiles: SURPRISE_SOUND_FILES
},
boo: {
message: ':slightly_frowning_face:',
emoji: '🙁',
shortcutChar: 'B',
soundId: BOO_SOUND_ID,
soundFiles: BOO_SOUND_FILES
},
silence: {
message: ':face_without_mouth:',
emoji: '😶',
shortcutChar: 'S',
soundId: SILENCE_SOUND_ID,
soundFiles: SILENCE_SOUND_FILES
},
love: {
message: ':heart:',
emoji: '💖',
shortcutChar: 'H',
soundId: HEART_SOUND_ID,
soundFiles: HEART_SOUND_FILES
}
};
export type ReactionThreshold = {
reaction: string;
threshold: number;
};
export interface IMuteCommandAttributes {
startReactionsMuted?: string;
}

View File

@@ -0,0 +1,174 @@
import { v4 as uuidv4 } from 'uuid';
import { IReduxState } from '../app/types';
import { REACTIONS_ENABLED } from '../base/flags/constants';
import { getFeatureFlag } from '../base/flags/functions';
import { getLocalParticipant } from '../base/participants/functions';
import { extractFqnFromPath } from '../dynamic-branding/functions.any';
import { iAmVisitor } from '../visitors/functions';
import { IReactionEmojiProps, REACTIONS, ReactionThreshold, SOUNDS_THRESHOLDS } from './constants';
import logger from './logger';
/**
* Returns the queue of reactions.
*
* @param {Object} state - The state of the application.
* @returns {Array}
*/
export function getReactionsQueue(state: IReduxState): Array<IReactionEmojiProps> {
return state['features/reactions'].queue;
}
/**
* Returns chat message from reactions buffer.
*
* @param {Array} buffer - The reactions buffer.
* @returns {string}
*/
export function getReactionMessageFromBuffer(buffer: Array<string>): string {
return buffer.map<string>(reaction => REACTIONS[reaction].message).reduce((acc, val) => `${acc}${val}`);
}
/**
* Returns reactions array with uid.
*
* @param {Array} buffer - The reactions buffer.
* @returns {Array}
*/
export function getReactionsWithId(buffer: Array<string>): Array<IReactionEmojiProps> {
return buffer.map<IReactionEmojiProps>(reaction => {
return {
reaction,
uid: uuidv4()
};
});
}
/**
* Sends reactions to the backend.
*
* @param {Object} state - The redux state object.
* @param {Array} reactions - Reactions array to be sent.
* @returns {void}
*/
export async function sendReactionsWebhook(state: IReduxState, reactions: Array<string>) {
const { webhookProxyUrl: url } = state['features/base/config'];
const { conference } = state['features/base/conference'];
const { jwt } = state['features/base/jwt'];
const { connection } = state['features/base/connection'];
const jid = connection?.getJid();
const localParticipant = getLocalParticipant(state);
const headers = {
...jwt ? { 'Authorization': `Bearer ${jwt}` } : {},
'Content-Type': 'application/json'
};
const reqBody = {
meetingFqn: extractFqnFromPath(),
sessionId: conference?.sessionId,
submitted: Date.now(),
reactions,
participantId: localParticipant?.jwtId,
participantName: localParticipant?.name,
participantJid: jid
};
if (url) {
try {
const res = await fetch(`${url}/reactions`, {
method: 'POST',
headers,
body: JSON.stringify(reqBody)
});
if (!res.ok) {
logger.error('Status error:', res.status);
}
} catch (err) {
logger.error('Could not send request', err);
}
}
}
/**
* Returns unique reactions from the reactions buffer.
*
* @param {Array} reactions - The reactions buffer.
* @returns {Array}
*/
function getUniqueReactions(reactions: Array<string>): Array<string> {
return [ ...new Set(reactions) ];
}
/**
* Returns frequency of given reaction in array.
*
* @param {Array} reactions - Array of reactions.
* @param {string} reaction - Reaction to get frequency for.
* @returns {number}
*/
function getReactionFrequency(reactions: Array<string>, reaction: string): number {
return reactions.filter(r => r === reaction).length;
}
/**
* Returns the threshold number for a given frequency.
*
* @param {number} frequency - Frequency of reaction.
* @returns {number}
*/
function getSoundThresholdByFrequency(frequency: number): number {
for (const i of SOUNDS_THRESHOLDS) {
if (frequency <= i) {
return i;
}
}
return SOUNDS_THRESHOLDS[SOUNDS_THRESHOLDS.length - 1];
}
/**
* Returns unique reactions with threshold.
*
* @param {Array} reactions - The reactions buffer.
* @returns {Array}
*/
export function getReactionsSoundsThresholds(reactions: Array<string>): Array<ReactionThreshold> {
const unique = getUniqueReactions(reactions);
return unique.map<ReactionThreshold>(reaction => {
return {
reaction,
threshold: getSoundThresholdByFrequency(getReactionFrequency(reactions, reaction))
};
});
}
/**
* Whether or not the reactions are enabled.
*
* @param {Object} state - The Redux state object.
* @returns {boolean}
*/
export function isReactionsEnabled(state: IReduxState): boolean {
const { disableReactions } = state['features/base/config'];
if (navigator.product === 'ReactNative') {
return !disableReactions && getFeatureFlag(state, REACTIONS_ENABLED, true);
}
return !disableReactions;
}
/**
* Returns true if the reactions buttons should be displayed anywhere on the page and false otherwise.
*
* @param {IReduxState} state - The redux state.
* @returns {boolean}
*/
export function shouldDisplayReactionsButtons(state: IReduxState): boolean {
return isReactionsEnabled(state) && !iAmVisitor(state);
}

View File

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

View File

@@ -0,0 +1,27 @@
import { IReduxState } from '../app/types';
import { isReactionsEnabled } from './functions.any';
export * from './functions.any';
/**
* Returns the visibility state of the reactions menu.
*
* @param {Object} state - The state of the application.
* @returns {boolean}
*/
export function getReactionsMenuVisibility(state: IReduxState): boolean {
return state['features/reactions'].visible;
}
/**
* Whether or not the reactions button is enabled.
*
* @param {Object} state - The Redux state object.
* @returns {boolean}
*/
export function isReactionsButtonEnabled(state: IReduxState) {
const { toolbarButtons } = state['features/toolbox'];
return Boolean(toolbarButtons?.includes('reactions')) && isReactionsEnabled(state);
}

View File

@@ -0,0 +1,23 @@
import { useSelector } from 'react-redux';
import ReactionsMenuButton from './components/web/ReactionsMenuButton';
import { isReactionsButtonEnabled } from './functions';
const reactions = {
key: 'reactions',
Content: ReactionsMenuButton,
group: 2
};
/**
* A hook that returns the reactions button if it is enabled and undefined otherwise.
*
* @returns {Object | undefined}
*/
export function useReactionsButton() {
const reactionsButtonEnabled = useSelector(isReactionsButtonEnabled);
if (reactionsButtonEnabled) {
return reactions;
}
}

View File

@@ -0,0 +1,3 @@
import { getLogger } from '../base/logging/functions';
export default getLogger('features/base/reactions');

View File

@@ -0,0 +1,271 @@
import { batch } from 'react-redux';
import { AnyAction } from 'redux';
import { createReactionSoundsDisabledEvent } from '../analytics/AnalyticsEvents';
import { sendAnalytics } from '../analytics/functions';
import { IStore } from '../app/types';
import { APP_WILL_MOUNT, APP_WILL_UNMOUNT } from '../base/app/actionTypes';
import { CONFERENCE_JOIN_IN_PROGRESS, SET_START_REACTIONS_MUTED } from '../base/conference/actionTypes';
import { setStartReactionsMuted } from '../base/conference/actions';
import {
getParticipantById,
getParticipantCount,
isLocalParticipantModerator
} from '../base/participants/functions';
import MiddlewareRegistry from '../base/redux/MiddlewareRegistry';
import { SETTINGS_UPDATED } from '../base/settings/actionTypes';
import { updateSettings } from '../base/settings/actions';
import { playSound, registerSound, unregisterSound } from '../base/sounds/actions';
import { getDisabledSounds } from '../base/sounds/functions.any';
import { showNotification } from '../notifications/actions';
import { NOTIFICATION_TIMEOUT_TYPE } from '../notifications/constants';
import {
ADD_REACTION_BUFFER,
FLUSH_REACTION_BUFFER,
PUSH_REACTIONS,
SEND_REACTIONS,
SHOW_SOUNDS_NOTIFICATION
} from './actionTypes';
import {
addReactionsToChat,
displayReactionSoundsNotification,
flushReactionBuffer,
pushReactions,
sendReactions,
setReactionQueue
} from './actions';
import {
ENDPOINT_REACTION_NAME,
IMuteCommandAttributes,
MUTE_REACTIONS_COMMAND,
RAISE_HAND_SOUND_ID,
REACTIONS,
REACTION_SOUND,
SOUNDS_THRESHOLDS
} from './constants';
import {
getReactionMessageFromBuffer,
getReactionsSoundsThresholds,
getReactionsWithId,
sendReactionsWebhook
} from './functions.any';
import logger from './logger';
import { RAISE_HAND_SOUND_FILE } from './sounds';
/**
* Middleware which intercepts Reactions actions to handle changes to the
* visibility timeout of the Reactions.
*
* @param {IStore} store - The redux store.
* @returns {Function}
*/
MiddlewareRegistry.register((store: IStore) => (next: Function) => (action: AnyAction) => {
const { dispatch, getState } = store;
switch (action.type) {
case APP_WILL_MOUNT:
batch(() => {
Object.keys(REACTIONS).forEach(key => {
for (let i = 0; i < SOUNDS_THRESHOLDS.length; i++) {
dispatch(registerSound(
`${REACTIONS[key].soundId}${SOUNDS_THRESHOLDS[i]}`,
REACTIONS[key].soundFiles[i]
)
);
}
}
);
dispatch(registerSound(RAISE_HAND_SOUND_ID, RAISE_HAND_SOUND_FILE));
});
break;
case APP_WILL_UNMOUNT:
batch(() => {
Object.keys(REACTIONS).forEach(key => {
for (let i = 0; i < SOUNDS_THRESHOLDS.length; i++) {
dispatch(unregisterSound(`${REACTIONS[key].soundId}${SOUNDS_THRESHOLDS[i]}`));
}
});
dispatch(unregisterSound(RAISE_HAND_SOUND_ID));
});
break;
case ADD_REACTION_BUFFER: {
const { timeoutID, buffer } = getState()['features/reactions'];
const { reaction } = action;
clearTimeout(timeoutID ?? 0);
buffer.push(reaction);
action.buffer = buffer;
action.timeoutID = setTimeout(() => {
dispatch(flushReactionBuffer());
}, 500);
break;
}
case CONFERENCE_JOIN_IN_PROGRESS: {
const { conference } = action;
conference.addCommandListener(
MUTE_REACTIONS_COMMAND, ({ attributes }: { attributes: IMuteCommandAttributes; }, id: any) => {
_onMuteReactionsCommand(attributes, id, store);
});
break;
}
case FLUSH_REACTION_BUFFER: {
const state = getState();
const { buffer } = state['features/reactions'];
const participantCount = getParticipantCount(state);
batch(() => {
if (participantCount > 1) {
dispatch(sendReactions());
}
dispatch(addReactionsToChat(getReactionMessageFromBuffer(buffer)));
dispatch(pushReactions(buffer));
});
sendReactionsWebhook(state, buffer);
break;
}
case PUSH_REACTIONS: {
const state = getState();
const { queue, notificationDisplayed } = state['features/reactions'];
const { soundsReactions } = state['features/base/settings'];
const disabledSounds = getDisabledSounds(state);
const reactions = action.reactions;
batch(() => {
if (!notificationDisplayed && soundsReactions && !disabledSounds.includes(REACTION_SOUND)
&& displayReactionSoundsNotification) {
dispatch(displayReactionSoundsNotification());
}
if (soundsReactions) {
const reactionSoundsThresholds = getReactionsSoundsThresholds(reactions);
reactionSoundsThresholds.forEach(reaction =>
dispatch(playSound(`${REACTIONS[reaction.reaction].soundId}${reaction.threshold}`))
);
}
dispatch(setReactionQueue([ ...queue, ...getReactionsWithId(reactions) ]));
});
break;
}
case SEND_REACTIONS: {
const state = getState();
const { buffer } = state['features/reactions'];
const { conference } = state['features/base/conference'];
if (conference) {
conference.sendEndpointMessage('', {
name: ENDPOINT_REACTION_NAME,
reactions: buffer,
timestamp: Date.now()
});
}
break;
}
// Settings changed for mute reactions in the meeting
case SET_START_REACTIONS_MUTED: {
const state = getState();
const { conference } = state['features/base/conference'];
const { muted, updateBackend } = action;
if (conference && isLocalParticipantModerator(state) && updateBackend) {
conference.sendCommand(MUTE_REACTIONS_COMMAND, { attributes: { startReactionsMuted: Boolean(muted) } });
}
break;
}
case SETTINGS_UPDATED: {
const { soundsReactions } = getState()['features/base/settings'];
if (action.settings.soundsReactions === false && soundsReactions === true) {
sendAnalytics(createReactionSoundsDisabledEvent());
}
break;
}
case SHOW_SOUNDS_NOTIFICATION: {
const state = getState();
const isModerator = isLocalParticipantModerator(state);
const { disableReactionsModeration } = state['features/base/config'];
const customActions = [ 'notify.reactionSounds' ];
const customFunctions: Function[] = [ () => dispatch(updateSettings({
soundsReactions: false
})) ];
if (isModerator && !disableReactionsModeration) {
customActions.push('notify.reactionSoundsForAll');
customFunctions.push(() => batch(() => {
dispatch(setStartReactionsMuted(true));
dispatch(updateSettings({ soundsReactions: false }));
}));
}
dispatch(showNotification({
titleKey: 'toolbar.disableReactionSounds',
customActionNameKey: customActions,
customActionHandler: customFunctions
}, NOTIFICATION_TIMEOUT_TYPE.MEDIUM));
break;
}
}
return next(action);
});
/**
* Notifies this instance about a "Mute Reaction Sounds" command received by the Jitsi
* conference.
*
* @param {Object} attributes - The attributes carried by the command.
* @param {string} id - The identifier of the participant who issuing the
* command. A notable idiosyncrasy to be mindful of here is that the command
* may be issued by the local participant.
* @param {Object} store - The redux store. Used to calculate and dispatch
* updates.
* @private
* @returns {void}
*/
function _onMuteReactionsCommand(attributes: IMuteCommandAttributes = {}, id: string, store: IStore) {
const state = store.getState();
// We require to know who issued the command because (1) only a
// moderator is allowed to send commands and (2) a command MUST be
// issued by a defined commander.
if (typeof id === 'undefined') {
return;
}
const participantSendingCommand = getParticipantById(state, id);
// The Command(s) API will send us our own commands and we don't want
// to act upon them.
if (participantSendingCommand?.local) {
return;
}
if (participantSendingCommand?.role !== 'moderator') {
logger.warn('Received mute-reactions command not from moderator');
return;
}
const oldState = Boolean(state['features/base/conference'].startReactionsMuted);
const newState = attributes.startReactionsMuted === 'true';
if (oldState !== newState) {
batch(() => {
store.dispatch(setStartReactionsMuted(newState));
store.dispatch(updateSettings({ soundsReactions: !newState }));
});
}
}

View File

@@ -0,0 +1,121 @@
import ReducerRegistry from '../base/redux/ReducerRegistry';
import {
ADD_REACTION_BUFFER,
FLUSH_REACTION_BUFFER,
SET_REACTION_QUEUE,
SHOW_SOUNDS_NOTIFICATION,
TOGGLE_REACTIONS_VISIBLE
} from './actionTypes';
import { IReactionEmojiProps } from './constants';
export interface IReactionsState {
/**
* An array that contains the reactions buffer to be sent.
*/
buffer: Array<string>;
/**
* Whether or not the disable reaction sounds notification was shown.
*/
notificationDisplayed: boolean;
/**
* The array of reactions to animate.
*/
queue: Array<IReactionEmojiProps>;
/**
* A number, non-zero value which identifies the timer created by a call
* to setTimeout().
*/
timeoutID: number | null;
/**
* The indicator that determines whether the reactions menu is visible.
*/
visible: boolean;
}
export interface IReactionsAction extends Partial<IReactionsState> {
/**
* The message to be added to the chat.
*/
message?: string;
/**
* The reaction to be added to buffer.
*/
reaction?: string;
/**
* The reactions to be added to the animation queue.
*/
reactions?: Array<string>;
/**
* The action type.
*/
type: string;
}
/**
* Returns initial state for reactions' part of Redux store.
*
* @private
* @returns {IReactionsState}
*/
function _getInitialState(): IReactionsState {
return {
visible: false,
buffer: [],
timeoutID: null,
queue: [],
notificationDisplayed: false
};
}
ReducerRegistry.register<IReactionsState>(
'features/reactions',
(state = _getInitialState(), action: IReactionsAction): IReactionsState => {
switch (action.type) {
case TOGGLE_REACTIONS_VISIBLE:
return {
...state,
visible: !state.visible
};
case ADD_REACTION_BUFFER:
return {
...state,
buffer: action.buffer ?? [],
timeoutID: action.timeoutID ?? null
};
case FLUSH_REACTION_BUFFER:
return {
...state,
buffer: [],
timeoutID: null
};
case SET_REACTION_QUEUE: {
return {
...state,
queue: action.queue ?? []
};
}
case SHOW_SOUNDS_NOTIFICATION: {
return {
...state,
notificationDisplayed: true
};
}
}
return state;
});

View File

@@ -0,0 +1,55 @@
/**
* The name of the bundled audio files which will be played for the laugh reaction sound.
*
* @type {Array<string>}
*/
export const LAUGH_SOUND_FILES = new Array(3).fill('reactions-laughter.mp3');
/**
* The name of the bundled audio file which will be played for the clap reaction sound.
*
* @type {Array<string>}
*/
export const CLAP_SOUND_FILES = new Array(3).fill('reactions-applause.mp3');
/**
* The name of the bundled audio file which will be played for the like reaction sound.
*
* @type {Array<string>}
*/
export const LIKE_SOUND_FILES = new Array(3).fill('reactions-thumbs-up.mp3');
/**
* The name of the bundled audio file which will be played for the boo reaction sound.
*
* @type {Array<string>}
*/
export const BOO_SOUND_FILES = new Array(3).fill('reactions-boo.mp3');
/**
* The name of the bundled audio file which will be played for the surprised reaction sound.
*
* @type {Array<string>}
*/
export const SURPRISE_SOUND_FILES = new Array(3).fill('reactions-surprise.mp3');
/**
* The name of the bundled audio file which will be played for the silence reaction sound.
*
* @type {Array<string>}
*/
export const SILENCE_SOUND_FILES = new Array(3).fill('reactions-crickets.mp3');
/**
* The name of the bundled audio file which will be played for the heart reaction sound.
*
* @type {Array<string>}
*/
export const HEART_SOUND_FILES = new Array(3).fill('reactions-love.mp3');
/**
* The name of the bundled audio file which will be played for the raise hand sound.
*
* @type {string}
*/
export const RAISE_HAND_SOUND_FILE = 'reactions-raised-hand.mp3';

View File

@@ -0,0 +1,5 @@
export enum IReactionsMenuParent {
Button = 1,
OverflowMenu = 2,
OverflowDrawer = 3
}