feat(gif) Added GIF support (GIPHY integration) (#11021)

Show GIF menu in reactions menu
Search GIFs using the GIPHY API
Show GIFs as images in chat
Show GIFs on the thumbnail of the participant that sent it
Move GIF focus using up/ down arrows and send with Enter
Added analytics
This commit is contained in:
Robert Pintilii 2022-03-11 15:00:49 +02:00 committed by GitHub
parent b6d55571ba
commit 190041fc5a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
30 changed files with 1648 additions and 38 deletions

View File

@ -1300,6 +1300,21 @@ var config = {
// Specifies whether the chat emoticons are disabled or not // Specifies whether the chat emoticons are disabled or not
// disableChatSmileys: false, // disableChatSmileys: false,
// Settings for the GIPHY integration.
// giphy: {
// // Whether the feature is enabled or not.
// enabled: false,
// // SDK API Key from Giphy.
// sdkKey: '',
// // Display mode can be one of:
// // - tile: show the GIF on the tile of the participant that sent it.
// // - chat: show the GIF as a message in chat
// // - all: all of the above. This is the default option
// displayMode: 'all',
// // How long the GIF should be displayed on the tile (in miliseconds).
// tileTime: 5000
// },
// Allow all above example options to include a trailing comma and // Allow all above example options to include a trailing comma and
// prevent fear when commenting out the last value. // prevent fear when commenting out the last value.
makeJsonParserHappy: 'even if last key had a trailing comma' makeJsonParserHappy: 'even if last key had a trailing comma'

View File

@ -7,7 +7,20 @@
border-radius: 3px; border-radius: 3px;
padding: 16px; padding: 16px;
&.with-gif {
width: 328px;
.reactions-row .toolbox-button:last-of-type {
top: 3px;
& .toolbox-icon.toggled {
background-color: #000000;
}
}
}
&.overflow { &.overflow {
width: 100%;
.toolbox-icon { .toolbox-icon {
width: 48px; width: 48px;
@ -27,6 +40,10 @@
.toolbox-button { .toolbox-button {
margin-right: 0; margin-right: 0;
} }
.toolbox-button:last-of-type {
top: 0;
}
} }
} }
@ -56,6 +73,7 @@
.toolbox-button { .toolbox-button {
margin-right: 8px; margin-right: 8px;
touch-action: manipulation; touch-action: manipulation;
position: relative;
} }
.toolbox-button:last-of-type { .toolbox-button:last-of-type {

BIN
images/GIPHY_icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 284 B

BIN
images/GIPHY_logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@ -421,6 +421,10 @@
"veryBad": "Very Bad", "veryBad": "Very Bad",
"veryGood": "Very Good" "veryGood": "Very Good"
}, },
"giphy": {
"noResults": "No results found :(",
"search": "Search GIPHY"
},
"helpView": { "helpView": {
"header": "Help center" "header": "Help center"
}, },
@ -487,6 +491,7 @@
"focusLocal": "Focus on your video", "focusLocal": "Focus on your video",
"focusRemote": "Focus on another person's video", "focusRemote": "Focus on another person's video",
"fullScreen": "View or exit full screen", "fullScreen": "View or exit full screen",
"giphyMenu": "Toggle GIPHY menu",
"keyboardShortcuts": "Keyboard shortcuts", "keyboardShortcuts": "Keyboard shortcuts",
"localRecording": "Show or hide local recording controls", "localRecording": "Show or hide local recording controls",
"mute": "Mute or unmute your microphone", "mute": "Mute or unmute your microphone",
@ -1007,6 +1012,7 @@
"expand": "Expand", "expand": "Expand",
"feedback": "Leave feedback", "feedback": "Leave feedback",
"fullScreen": "Toggle full screen", "fullScreen": "Toggle full screen",
"giphy": "Toggle GIPHY menu",
"grantModerator": "Grant Moderator Rights", "grantModerator": "Grant Moderator Rights",
"hangup": "Leave the meeting", "hangup": "Leave the meeting",
"help": "Help", "help": "Help",
@ -1076,6 +1082,7 @@
"exitFullScreen": "Exit full screen", "exitFullScreen": "Exit full screen",
"exitTileView": "Exit tile view", "exitTileView": "Exit tile view",
"feedback": "Leave feedback", "feedback": "Leave feedback",
"giphy": "Toggle GIPHY menu",
"hangup": "Leave the meeting", "hangup": "Leave the meeting",
"help": "Help", "help": "Help",
"invite": "Invite people", "invite": "Invite people",

669
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -33,6 +33,8 @@
"@atlaskit/theme": "11.0.2", "@atlaskit/theme": "11.0.2",
"@atlaskit/toggle": "12.0.3", "@atlaskit/toggle": "12.0.3",
"@atlaskit/tooltip": "17.1.2", "@atlaskit/tooltip": "17.1.2",
"@giphy/js-fetch-api": "4.1.2",
"@giphy/react-components": "5.6.0",
"@hapi/bourne": "2.0.0", "@hapi/bourne": "2.0.0",
"@jitsi/js-utils": "2.0.0", "@jitsi/js-utils": "2.0.0",
"@jitsi/logger": "2.0.0", "@jitsi/logger": "2.0.0",

View File

@ -899,3 +899,15 @@ export function createBreakoutRoomsEvent(actionSubject) {
source: 'breakout.rooms' source: 'breakout.rooms'
}; };
} }
/**
* Creates and event which indicates a GIF was sent.
*
* @returns {Object} The event in a format suitable for sending via
* sendAnalytics.
*/
export function createGifSentEvent() {
return {
action: 'gif.sent'
};
}

View File

@ -30,6 +30,7 @@ import '../display-name/middleware';
import '../etherpad/middleware'; import '../etherpad/middleware';
import '../filmstrip/middleware'; import '../filmstrip/middleware';
import '../follow-me/middleware'; import '../follow-me/middleware';
import '../gifs/middleware';
import '../invite/middleware'; import '../invite/middleware';
import '../jaas/middleware'; import '../jaas/middleware';
import '../large-video/middleware'; import '../large-video/middleware';

View File

@ -33,6 +33,7 @@ import '../dynamic-branding/reducer';
import '../etherpad/reducer'; import '../etherpad/reducer';
import '../filmstrip/reducer'; import '../filmstrip/reducer';
import '../follow-me/reducer'; import '../follow-me/reducer';
import '../gifs/reducer';
import '../google-api/reducer'; import '../google-api/reducer';
import '../invite/reducer'; import '../invite/reducer';
import '../jaas/reducer'; import '../jaas/reducer';

View File

@ -161,6 +161,7 @@ export default [
'forceJVB121Ratio', 'forceJVB121Ratio',
'forceTurnRelay', 'forceTurnRelay',
'gatherStats', 'gatherStats',
'giphy',
'googleApiApplicationClientID', 'googleApiApplicationClientID',
'hiddenPremeetingButtons', 'hiddenPremeetingButtons',
'hideConferenceSubject', 'hideConferenceSubject',

View File

@ -2,6 +2,7 @@
import React, { PureComponent } from 'react'; import React, { PureComponent } from 'react';
import { isMobileBrowser } from '../../../environment/utils';
import { getFieldValue } from '../../../react'; import { getFieldValue } from '../../../react';
type Props = { type Props = {
@ -132,6 +133,9 @@ export default class InputField extends PureComponent<Props, State> {
onKeyDown = { this._onKeyDown } onKeyDown = { this._onKeyDown }
placeholder = { this.props.placeHolder } placeholder = { this.props.placeHolder }
readOnly = { this.props.readOnly } readOnly = { this.props.readOnly }
// eslint-disable-next-line react/jsx-no-bind
ref = { inputElement => this.props.autoFocus && isMobileBrowser()
&& inputElement && inputElement.focus() }
type = { this.props.type } type = { this.props.type }
value = { this.state.value } /> value = { this.state.value } />
); );

View File

@ -3,6 +3,9 @@
import React, { Component } from 'react'; import React, { Component } from 'react';
import { toArray } from 'react-emoji-render'; import { toArray } from 'react-emoji-render';
import GifMessage from '../../../../chat/components/web/GifMessage';
import { isGifMessage } from '../../../../gifs/functions';
import Linkify from './Linkify'; import Linkify from './Linkify';
type Props = { type Props = {
@ -44,7 +47,16 @@ class Message extends Component<Props> {
const content = []; const content = [];
// check if the message is a GIF
if (isGifMessage(text)) {
const url = text.substring(4, text.length - 1);
content.push(<GifMessage
key = { url }
url = { url } />);
} else {
for (const token of tokens) { for (const token of tokens) {
if (token.includes('://')) { if (token.includes('://')) {
// Bypass the emojification when urls are involved // Bypass the emojification when urls are involved
@ -55,6 +67,7 @@ class Message extends Component<Props> {
content.push(' '); content.push(' ');
} }
}
content.forEach(token => { content.forEach(token => {
if (typeof token === 'string' && token !== ' ') { if (typeof token === 'string' && token !== ' ') {

View File

@ -0,0 +1,42 @@
// @flow
import { makeStyles } from '@material-ui/styles';
import React from 'react';
type Props = {
/**
* URL of the GIF.
*/
url: string
}
const useStyles = makeStyles(() => {
return {
container: {
display: 'flex',
justifyContent: 'center',
overflow: 'hidden',
maxHeight: '150px',
'& img': {
maxWidth: '100%',
maxHeight: '100%',
objectFit: 'contain',
flexGrow: '1'
}
}
};
});
const GifMessage = ({ url }: Props) => {
const styles = useStyles();
return (<div className = { styles.container }>
<img
alt = { url }
src = { url } />
</div>);
};
export default GifMessage;

View File

@ -18,6 +18,9 @@ import {
} from '../base/participants'; } from '../base/participants';
import { MiddlewareRegistry, StateListenerRegistry } from '../base/redux'; import { MiddlewareRegistry, StateListenerRegistry } from '../base/redux';
import { playSound, registerSound, unregisterSound } from '../base/sounds'; import { playSound, registerSound, unregisterSound } from '../base/sounds';
import { addGif } from '../gifs/actions';
import { GIF_PREFIX } from '../gifs/constants';
import { getGifDisplayMode, isGifMessage } from '../gifs/functions';
import { NOTIFICATION_TIMEOUT_TYPE, showMessageNotification } from '../notifications'; import { NOTIFICATION_TIMEOUT_TYPE, showMessageNotification } from '../notifications';
import { resetNbUnreadPollsMessages } from '../polls/actions'; import { resetNbUnreadPollsMessages } from '../polls/actions';
import { ADD_REACTION_MESSAGE } from '../reactions/actionTypes'; import { ADD_REACTION_MESSAGE } from '../reactions/actionTypes';
@ -226,25 +229,21 @@ function _addChatMsgListener(conference, store) {
conference.on( conference.on(
JitsiConferenceEvents.MESSAGE_RECEIVED, JitsiConferenceEvents.MESSAGE_RECEIVED,
(id, message, timestamp) => { (id, message, timestamp) => {
_handleReceivedMessage(store, { _onConferenceMessageReceived(store, { id,
id,
message, message,
privateMessage: false, timestamp,
lobbyChat: false, privateMessage: false });
timestamp
});
} }
); );
conference.on( conference.on(
JitsiConferenceEvents.PRIVATE_MESSAGE_RECEIVED, JitsiConferenceEvents.PRIVATE_MESSAGE_RECEIVED,
(id, message, timestamp) => { (id, message, timestamp) => {
_handleReceivedMessage(store, { _onConferenceMessageReceived(store, {
id, id,
message, message,
privateMessage: true, timestamp,
lobbyChat: false, privateMessage: true
timestamp
}); });
} }
); );
@ -283,6 +282,45 @@ function _addChatMsgListener(conference, store) {
}); });
} }
/**
* Handles a received message.
*
* @param {Object} store - Redux store.
* @param {Object} message - The message object.
* @returns {void}
*/
function _onConferenceMessageReceived(store, { id, message, timestamp, privateMessage }) {
const isGif = isGifMessage(message);
if (isGif) {
_handleGifMessageReceived(store, id, message);
if (getGifDisplayMode(store.getState()) === 'tile') {
return;
}
}
_handleReceivedMessage(store, {
id,
message,
privateMessage,
lobbyChat: false,
timestamp
}, true, isGif);
}
/**
* Handles a received gif message.
*
* @param {Object} store - Redux store.
* @param {string} id - Id of the participant that sent the message.
* @param {string} message - The message sent.
* @returns {void}
*/
function _handleGifMessageReceived(store, id, message) {
const url = message.substring(GIF_PREFIX.length, message.length - 1);
store.dispatch(addGif(id, url));
}
/** /**
* Handles a chat error received from the xmpp server. * Handles a chat error received from the xmpp server.
* *

View File

@ -24,6 +24,8 @@ import {
updateLastTrackVideoMediaEvent updateLastTrackVideoMediaEvent
} from '../../../base/tracks'; } from '../../../base/tracks';
import { getVideoObjectPosition } from '../../../face-centering/functions'; import { getVideoObjectPosition } from '../../../face-centering/functions';
import { hideGif, showGif } from '../../../gifs/actions';
import { getGifDisplayMode, getGifForParticipant } from '../../../gifs/functions';
import { PresenceLabel } from '../../../presence-status'; import { PresenceLabel } from '../../../presence-status';
import { getCurrentLayout, LAYOUTS } from '../../../video-layout'; import { getCurrentLayout, LAYOUTS } from '../../../video-layout';
import { import {
@ -96,6 +98,11 @@ export type Props = {|
*/ */
_disableTileEnlargement: boolean, _disableTileEnlargement: boolean,
/**
* URL of GIF sent by this participant, null if there's none.
*/
_gifSrc ?: string,
/** /**
* The height of the Thumbnail. * The height of the Thumbnail.
*/ */
@ -181,16 +188,16 @@ export type Props = {|
*/ */
_width: number, _width: number,
/**
* An object containing CSS classes.
*/
classes: Object,
/** /**
* The redux dispatch function. * The redux dispatch function.
*/ */
dispatch: Function, dispatch: Function,
/**
* An object containing the CSS classes.
*/
classes: Object,
/** /**
* The horizontal offset in px for the thumbnail. Used to center the thumbnails from the last row in tile view. * The horizontal offset in px for the thumbnail. Used to center the thumbnails from the last row in tile view.
*/ */
@ -267,10 +274,14 @@ const defaultStyles = theme => {
position: 'absolute', position: 'absolute',
width: '100%', width: '100%',
height: '100%', height: '100%',
zIndex: '9', zIndex: 9,
borderRadius: '4px' borderRadius: '4px'
}, },
borderIndicatorOnTop: {
zIndex: 11
},
activeSpeaker: { activeSpeaker: {
'& .active-speaker-indicator': { '& .active-speaker-indicator': {
boxShadow: `inset 0px 0px 0px 4px ${theme.palette.link01Active} !important` boxShadow: `inset 0px 0px 0px 4px ${theme.palette.link01Active} !important`
@ -281,6 +292,25 @@ const defaultStyles = theme => {
'& .raised-hand-border': { '& .raised-hand-border': {
boxShadow: `inset 0px 0px 0px 2px ${theme.palette.warning02} !important` boxShadow: `inset 0px 0px 0px 2px ${theme.palette.warning02} !important`
} }
},
gif: {
position: 'absolute',
width: '100%',
height: '100%',
zIndex: 11,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
overflow: 'hidden',
backgroundColor: theme.palette.ui02,
'& img': {
maxWidth: '100%',
maxHeight: '100%',
objectFit: 'contain',
flexGrow: '1'
}
} }
}; };
}; };
@ -339,6 +369,8 @@ class Thumbnail extends Component<Props, State> {
this._onTouchMove = this._onTouchMove.bind(this); this._onTouchMove = this._onTouchMove.bind(this);
this._showPopover = this._showPopover.bind(this); this._showPopover = this._showPopover.bind(this);
this._hidePopover = this._hidePopover.bind(this); this._hidePopover = this._hidePopover.bind(this);
this._onGifMouseEnter = this._onGifMouseEnter.bind(this);
this._onGifMouseLeave = this._onGifMouseLeave.bind(this);
} }
/** /**
@ -741,6 +773,52 @@ class Thumbnail extends Component<Props, State> {
return className; return className;
} }
_onGifMouseEnter: () => void;
/**
* Keep showing the GIF for the current participant.
*
* @returns {void}
*/
_onGifMouseEnter() {
const { dispatch, _participant: { id } } = this.props;
dispatch(showGif(id));
}
_onGifMouseLeave: () => void;
/**
* Keep showing the GIF for the current participant.
*
* @returns {void}
*/
_onGifMouseLeave() {
const { dispatch, _participant: { id } } = this.props;
dispatch(hideGif(id));
}
/**
* Renders GIF.
*
* @returns {Component}
*/
_renderGif() {
const { _gifSrc, classes } = this.props;
return _gifSrc && (
<div
className = { classes.gif }
onMouseEnter = { this._onGifMouseEnter }
onMouseLeave = { this._onGifMouseLeave }>
<img
alt = 'GIF'
src = { _gifSrc } />
</div>
);
}
_onCanPlay: Object => void; _onCanPlay: Object => void;
/** /**
@ -798,7 +876,8 @@ class Thumbnail extends Component<Props, State> {
_localFlipX, _localFlipX,
_participant, _participant,
_videoTrack, _videoTrack,
classes classes,
_gifSrc
} = this.props; } = this.props;
const { id } = _participant || {}; const { id } = _participant || {};
const { isHovered, popoverVisible } = this.state; const { isHovered, popoverVisible } = this.state;
@ -850,9 +929,9 @@ class Thumbnail extends Component<Props, State> {
} }
) } ) }
style = { styles.thumbnail }> style = { styles.thumbnail }>
{local {!_gifSrc && (local
? <span id = 'localVideoWrapper'>{video}</span> ? <span id = 'localVideoWrapper'>{video}</span>
: video} : video)}
<div className = { classes.containerBackground } /> <div className = { classes.containerBackground } />
<div <div
className = { clsx(classes.indicatorsContainer, className = { clsx(classes.indicatorsContainer,
@ -880,7 +959,7 @@ class Thumbnail extends Component<Props, State> {
local = { local } local = { local }
participantId = { id } /> participantId = { id } />
</div> </div>
{ this._renderAvatar(styles.avatar) } {!_gifSrc && this._renderAvatar(styles.avatar) }
{ !local && ( { !local && (
<div className = 'presence-label-container'> <div className = 'presence-label-container'>
<PresenceLabel <PresenceLabel
@ -889,8 +968,15 @@ class Thumbnail extends Component<Props, State> {
</div> </div>
)} )}
<ThumbnailAudioIndicator _audioTrack = { _audioTrack } /> <ThumbnailAudioIndicator _audioTrack = { _audioTrack } />
<div className = { clsx(classes.borderIndicator, 'raised-hand-border') } /> {this._renderGif()}
<div className = { clsx(classes.borderIndicator, 'active-speaker-indicator') } /> <div
className = { clsx(classes.borderIndicator,
_gifSrc && classes.borderIndicatorOnTop,
'raised-hand-border') } />
<div
className = { clsx(classes.borderIndicator,
_gifSrc && classes.borderIndicatorOnTop,
'active-speaker-indicator') } />
</span> </span>
); );
} }
@ -1003,6 +1089,9 @@ function _mapStateToProps(state, ownProps): Object {
} }
} }
const { gifUrl: gifSrc } = getGifForParticipant(state, id);
const mode = getGifDisplayMode(state);
return { return {
_audioTrack, _audioTrack,
_currentLayout, _currentLayout,
@ -1023,7 +1112,8 @@ function _mapStateToProps(state, ownProps): Object {
_raisedHand: hasRaisedHand(participant), _raisedHand: hasRaisedHand(participant),
_videoObjectPosition: getVideoObjectPosition(state, participant?.id), _videoObjectPosition: getVideoObjectPosition(state, participant?.id),
_videoTrack, _videoTrack,
...size ...size,
_gifSrc: mode === 'chat' ? null : gifSrc
}; };
} }

View File

@ -0,0 +1,55 @@
/**
* Adds a gif for a given participant.
* {{
* type: ADD_GIF_FOR_PARTICIPANT,
* participantId: string,
* gifUrl: string,
* timeoutID: number
* }}
*/
export const ADD_GIF_FOR_PARTICIPANT = 'ADD_GIF_FOR_PARTICIPANT';
/**
* Set timeout to hide a gif for a given participant.
* {{
* type: HIDE_GIF_FOR_PARTICIPANT,
* participantId: string
* }}
*/
export const HIDE_GIF_FOR_PARTICIPANT = 'HIDE_GIF_FOR_PARTICIPANT';
/**
* Removes a gif for a given participant.
* {{
* type: REMOVE_GIF_FOR_PARTICIPANT,
* participantId: string
* }}
*/
export const REMOVE_GIF_FOR_PARTICIPANT = 'REMOVE_GIF_FOR_PARTICIPANT';
/**
* Set gif menu drawer visibility.
* {{
* type: SET_GIF_DRAWER_VISIBILITY,
* visible: boolean
* }}
*/
export const SET_GIF_DRAWER_VISIBILITY = 'SET_GIF_DRAWER_VISIBILITY';
/**
* Set gif menu visibility.
* {{
* type: SET_GIF_MENU_VISIBILITY,
* visible: boolean
* }}
*/
export const SET_GIF_MENU_VISIBILITY = 'SET_GIF_MENU_VISIBILITY';
/**
* Keep showing a gif for a given participant.
* {{
* type: SHOW_GIF_FOR_PARTICIPANT,
* participantId: string
* }}
*/
export const SHOW_GIF_FOR_PARTICIPANT = 'SHOW_GIF_FOR_PARTICIPANT';

View File

@ -0,0 +1,88 @@
import {
ADD_GIF_FOR_PARTICIPANT,
HIDE_GIF_FOR_PARTICIPANT,
REMOVE_GIF_FOR_PARTICIPANT,
SET_GIF_DRAWER_VISIBILITY,
SET_GIF_MENU_VISIBILITY,
SHOW_GIF_FOR_PARTICIPANT
} from './actionTypes';
/**
* Adds a GIF for a given participant.
*
* @param {string} participantId - The id of the participant that sent the GIF.
* @param {string} gifUrl - The URL of the GIF.
* @returns {Object}
*/
export function addGif(participantId, gifUrl) {
return {
type: ADD_GIF_FOR_PARTICIPANT,
participantId,
gifUrl
};
}
/**
* Removes the GIF of the given participant.
*
* @param {string} participantId - The Id of the participant for whom to remove the GIF.
* @returns {Object}
*/
export function removeGif(participantId) {
return {
type: REMOVE_GIF_FOR_PARTICIPANT,
participantId
};
}
/**
* Keep showing the GIF of the given participant.
*
* @param {string} participantId - The Id of the participant for whom to show the GIF.
* @returns {Object}
*/
export function showGif(participantId) {
return {
type: SHOW_GIF_FOR_PARTICIPANT,
participantId
};
}
/**
* Set timeout to hide the GIF of the given participant.
*
* @param {string} participantId - The Id of the participant for whom to show the GIF.
* @returns {Object}
*/
export function hideGif(participantId) {
return {
type: HIDE_GIF_FOR_PARTICIPANT,
participantId
};
}
/**
* Set visibility of the GIF drawer.
*
* @param {boolean} visible - Whether or not it should be visible.
* @returns {Object}
*/
export function setGifDrawerVisibility(visible) {
return {
type: SET_GIF_DRAWER_VISIBILITY,
visible
};
}
/**
* Set visibility of the GIF menu.
*
* @param {boolean} visible - Whether or not it should be visible.
* @returns {Object}
*/
export function setGifMenuVisibility(visible) {
return {
type: SET_GIF_MENU_VISIBILITY,
visible
};
}

View File

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

View File

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

View File

@ -0,0 +1,223 @@
// @flow
import { GiphyFetch } from '@giphy/js-fetch-api';
import { Grid } from '@giphy/react-components';
import { makeStyles } from '@material-ui/core';
import clsx from 'clsx';
import React, { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { batch, useDispatch, useSelector } from 'react-redux';
import { createGifSentEvent, sendAnalytics } from '../../../analytics';
import InputField from '../../../base/premeeting/components/web/InputField';
import BaseTheme from '../../../base/ui/components/BaseTheme';
import { sendMessage } from '../../../chat/actions.any';
import { SCROLL_SIZE } from '../../../filmstrip';
import { toggleReactionsMenuVisibility } from '../../../reactions/actions.web';
import { setOverflowMenuVisible } from '../../../toolbox/actions.web';
import { Drawer, JitsiPortal } from '../../../toolbox/components/web';
import { showOverflowDrawer } from '../../../toolbox/functions.web';
import { setGifDrawerVisibility } from '../../actions';
import { formatGifUrlMessage, getGifAPIKey, getGifUrl } from '../../functions';
const OVERFLOW_DRAWER_PADDING = BaseTheme.spacing(3);
const useStyles = makeStyles(theme => {
return {
gifsMenu: {
width: '100%',
marginBottom: `${theme.spacing(2)}px`,
display: 'flex',
flexDirection: 'column',
'& div:focus': {
border: '1px solid red !important',
boxSizing: 'border-box'
}
},
searchField: {
backgroundColor: theme.palette.field01,
borderRadius: `${theme.shape.borderRadius}px`,
border: 'none',
outline: 0,
...theme.typography.bodyShortRegular,
lineHeight: `${theme.typography.bodyShortRegular.lineHeight}px`,
color: theme.palette.text01,
padding: `${theme.spacing(2)}px ${theme.spacing(3)}px`,
width: '100%',
marginBottom: `${theme.spacing(3)}px`
},
gifContainer: {
height: '245px',
overflowY: 'auto'
},
logoContainer: {
width: `calc(100% - ${SCROLL_SIZE}px)`,
backgroundColor: '#121119',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#fff',
marginTop: `${theme.spacing(1)}px`
},
overflowMenu: {
padding: `${theme.spacing(3)}px`,
width: '100%',
boxSizing: 'border-box'
},
gifContainerOverflow: {
flexGrow: 1
},
drawer: {
display: 'flex',
height: '100%'
}
};
});
/**
* Gifs menu.
*
* @returns {ReactElement}
*/
function GifsMenu() {
const API_KEY = useSelector(getGifAPIKey);
const giphyFetch = new GiphyFetch(API_KEY);
const [ searchKey, setSearchKey ] = useState();
const styles = useStyles();
const dispatch = useDispatch();
const { t } = useTranslation();
const overflowDrawer = useSelector(showOverflowDrawer);
const { clientWidth } = useSelector(state => state['features/base/responsive-ui']);
const fetchGifs = useCallback(async (offset = 0) => {
const options = {
rating: 'pg-13',
limit: 20,
offset
};
if (!searchKey) {
return await giphyFetch.trending(options);
}
return await giphyFetch.search(searchKey, options);
}, [ searchKey ]);
const onDrawerClose = useCallback(() => {
dispatch(setGifDrawerVisibility(false));
dispatch(setOverflowMenuVisible(false));
});
const handleGifClick = useCallback((gif, e) => {
e?.stopPropagation();
const url = getGifUrl(gif);
sendAnalytics(createGifSentEvent());
batch(() => {
dispatch(sendMessage(formatGifUrlMessage(url), true));
dispatch(toggleReactionsMenuVisibility());
overflowDrawer && onDrawerClose();
});
}, [ dispatch, overflowDrawer ]);
const handleGifKeyPress = useCallback((gif, e) => {
if (e.nativeEvent.keyCode === 13) {
handleGifClick(gif, null);
}
}, [ handleGifClick ]);
const handleSearchKeyChange = useCallback(value => {
setSearchKey(value);
});
const handleKeyDown = useCallback(e => {
if (e.keyCode === 38) { // up arrow
e.preventDefault();
// if the first gif is focused move focus to the input
if (document.activeElement.previousElementSibling === null) {
document.querySelector('.gif-input').focus();
} else {
document.activeElement.previousElementSibling.focus();
}
} else if (e.keyCode === 40) { // down arrow
e.preventDefault();
// if the input is focused move focus to the first gif
if (document.activeElement.classList.contains('gif-input')) {
document.querySelector('.giphy-gif').focus();
} else {
document.activeElement.nextElementSibling.focus();
}
}
}, []);
useEffect(() => {
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, []);
// For some reason, the Grid component does not do an initial call on mobile.
// This fixes that.
useEffect(() => setSearchKey(''), []);
const gifMenu = (
<div
className = { clsx(styles.gifsMenu,
overflowDrawer && styles.overflowMenu
) }>
<InputField
autoFocus = { true }
className = { clsx(styles.searchField, 'gif-input') }
onChange = { handleSearchKeyChange }
placeHolder = { t('giphy.search') }
testId = 'gifSearch.key'
type = 'text' />
<div
className = { clsx(styles.gifContainer,
overflowDrawer && styles.gifContainerOverflow) }>
<Grid
columns = { 2 }
fetchGifs = { fetchGifs }
gutter = { 6 }
hideAttribution = { true }
key = { searchKey }
noLink = { true }
noResultsMessage = { t('giphy.noResults') }
onGifClick = { handleGifClick }
onGifKeyPress = { handleGifKeyPress }
width = { overflowDrawer
? clientWidth - (2 * OVERFLOW_DRAWER_PADDING) - SCROLL_SIZE
: 320
} />
</div>
<div className = { styles.logoContainer }>
<span>Powered by</span>
<img
alt = 'GIPHY Logo'
src = 'images/GIPHY_logo.png' />
</div>
</div>
);
return overflowDrawer ? (
<JitsiPortal>
<Drawer
className = { styles.drawer }
isOpen = { true }
onClose = { onDrawerClose }>
{gifMenu}
</Drawer>
</JitsiPortal>
) : gifMenu;
}
export default GifsMenu;

View File

@ -0,0 +1,42 @@
import React, { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import ReactionButton from '../../../reactions/components/web/ReactionButton';
import { showOverflowDrawer } from '../../../toolbox/functions.web';
import { setGifDrawerVisibility, setGifMenuVisibility } from '../../actions';
import { isGifsMenuOpen } from '../../functions';
const GifsMenuButton = () => {
const menuOpen = useSelector(isGifsMenuOpen);
const overflowDrawer = useSelector(showOverflowDrawer);
const { t } = useTranslation();
const dispatch = useDispatch();
const icon = (
<img
alt = 'GIPHY Logo'
height = { 24 }
src = 'images/GIPHY_icon.png' />
);
const handleClick = useCallback(() =>
dispatch(
overflowDrawer
? setGifDrawerVisibility(!menuOpen)
: setGifMenuVisibility(!menuOpen)
)
, [ menuOpen, overflowDrawer ]);
return (
<ReactionButton
accessibilityLabel = { t('toolbar.accessibilityLabel.giphy') }
icon = { icon }
key = 'gif'
onClick = { handleClick }
toggled = { true }
tooltip = { t('toolbar.accessibilityLabel.giphy') } />
);
};
export default GifsMenuButton;

View File

@ -0,0 +1,4 @@
// @flow
export { default as GifsMenuButton } from './GifsMenuButton';
export { default as GifsMenu } from './GifsMenu';

View File

@ -0,0 +1,9 @@
/**
* The default time that GIFs will be displayed on the tile.
*/
export const GIF_DEFAULT_TIMEOUT = 5000;
/**
* The prefix for formatted GIF messages.
*/
export const GIF_PREFIX = 'gif[';

View File

@ -0,0 +1,96 @@
import { showOverflowDrawer } from '../toolbox/functions.web';
import { GIF_PREFIX } from './constants';
/**
* Gets the URL of the GIF for the given participant or null if there's none.
*
* @param {Object} state - Redux state.
* @param {string} participantId - Id of the participant for which to remove the GIF.
* @returns {Object}
*/
export function getGifForParticipant(state, participantId) {
return state['features/gifs'].gifList.get(participantId) || {};
}
/**
* Whether or not the message is a GIF message.
*
* @param {string} message - Message to check.
* @returns {boolean}
*/
export function isGifMessage(message) {
return message.trim().startsWith(GIF_PREFIX);
}
/**
* Returns the visibility state of the gifs menu.
*
* @param {Object} state - The state of the application.
* @returns {boolean}
*/
export function isGifsMenuOpen(state) {
const overflowDrawer = showOverflowDrawer(state);
const { drawerVisible, menuOpen } = state['features/gifs'];
return overflowDrawer ? drawerVisible : menuOpen;
}
/**
* Returns the url of the gif selected in the gifs menu.
*
* @param {Object} gif - The gif data.
* @returns {boolean}
*/
export function getGifUrl(gif) {
const embedUrl = gif?.embed_url || '';
const idx = embedUrl.lastIndexOf('/');
const id = embedUrl.substr(idx + 1);
return `https://i.giphy.com/media/${id}/giphy.webp`;
}
/**
* Formats the gif message.
*
* @param {string} url - GIF url.
* @returns {string}
*/
export function formatGifUrlMessage(url) {
return `${GIF_PREFIX}${url}]`;
}
/**
* Get the Giphy API Key from config.
*
* @param {Object} state - Redux state.
* @returns {string}
*/
export function getGifAPIKey(state) {
return state['features/base/config']?.giphy?.sdkKey;
}
/**
* Returns whether or not the feature is enabled.
*
* @param {Object} state - Redux state.
* @returns {boolean}
*/
export function isGifEnabled(state) {
const { disableThirdPartyRequests } = state['features/base/config'];
const { giphy } = state['features/base/config'];
return !disableThirdPartyRequests && giphy?.enabled && Boolean(giphy?.sdkKey);
}
/**
* Get the GIF display mode.
*
* @param {Object} state - Redux state.
* @returns {string}
*/
export function getGifDisplayMode(state) {
const { giphy } = state['features/base/config'];
return giphy?.displayMode || 'all';
}

View File

@ -0,0 +1,60 @@
import { MiddlewareRegistry } from '../base/redux';
import { ADD_GIF_FOR_PARTICIPANT, HIDE_GIF_FOR_PARTICIPANT, SHOW_GIF_FOR_PARTICIPANT } from './actionTypes';
import { removeGif } from './actions';
import { GIF_DEFAULT_TIMEOUT } from './constants';
import { getGifForParticipant } from './functions';
/**
* Middleware which intercepts Gifs actions to handle changes to the
* visibility timeout of the Gifs.
*
* @param {Store} store - The redux store.
* @returns {Function}
*/
MiddlewareRegistry.register(store => next => action => {
const { dispatch, getState } = store;
const state = getState();
switch (action.type) {
case ADD_GIF_FOR_PARTICIPANT: {
const id = action.participantId;
const { giphy } = state['features/base/config'];
_clearGifTimeout(state, id);
const timeoutID = setTimeout(() => dispatch(removeGif(id)), giphy?.tileTime || GIF_DEFAULT_TIMEOUT);
action.timeoutID = timeoutID;
break;
}
case SHOW_GIF_FOR_PARTICIPANT: {
const id = action.participantId;
_clearGifTimeout(state, id);
break;
}
case HIDE_GIF_FOR_PARTICIPANT: {
const { giphy } = state['features/base/config'];
const id = action.participantId;
const timeoutID = setTimeout(() => dispatch(removeGif(id)), giphy?.tileTime || GIF_DEFAULT_TIMEOUT);
action.timeoutID = timeoutID;
break;
}
}
return next(action);
});
/**
* Clears GIF timeout.
*
* @param {Object} state - Redux state.
* @param {string} id - Id of the participant for whom to clear the timeout.
* @returns {void}
*/
function _clearGifTimeout(state, id) {
const gif = getGifForParticipant(state, id);
clearTimeout(gif?.timeoutID);
}

View File

@ -0,0 +1,73 @@
import { ReducerRegistry } from '../base/redux';
import {
ADD_GIF_FOR_PARTICIPANT,
HIDE_GIF_FOR_PARTICIPANT,
REMOVE_GIF_FOR_PARTICIPANT,
SET_GIF_DRAWER_VISIBILITY,
SET_GIF_MENU_VISIBILITY
} from './actionTypes';
const initialState = {
drawerVisible: false,
gifList: new Map(),
menuOpen: false
};
ReducerRegistry.register(
'features/gifs',
(state = initialState, action) => {
switch (action.type) {
case ADD_GIF_FOR_PARTICIPANT: {
const newList = state.gifList;
newList.set(action.participantId, {
gifUrl: action.gifUrl,
timeoutID: action.timeoutID
});
return {
...state,
gifList: newList
};
}
case REMOVE_GIF_FOR_PARTICIPANT: {
const newList = state.gifList;
newList.delete(action.participantId);
return {
...state,
gifList: newList
};
}
case HIDE_GIF_FOR_PARTICIPANT: {
const newList = state.gifList;
const gif = state.gifList.get(action.participantId);
newList.set(action.participantId, {
gifUrl: gif.gifUrl,
timeoutID: action.timeoutID
});
return {
...state,
gifList: newList
};
}
case SET_GIF_DRAWER_VISIBILITY:
return {
...state,
drawerVisible: action.visible
};
case SET_GIF_MENU_VISIBILITY:
return {
...state,
menuOpen: action.visible
};
}
return state;
});

View File

@ -3,6 +3,7 @@
/* eslint-disable react/jsx-no-bind */ /* eslint-disable react/jsx-no-bind */
import { withStyles } from '@material-ui/styles'; import { withStyles } from '@material-ui/styles';
import clsx from 'clsx';
import React, { Component } from 'react'; import React, { Component } from 'react';
import { bindActionCreators } from 'redux'; import { bindActionCreators } from 'redux';
@ -15,6 +16,8 @@ import { isMobileBrowser } from '../../../base/environment/utils';
import { translate } from '../../../base/i18n'; import { translate } from '../../../base/i18n';
import { getLocalParticipant, hasRaisedHand, raiseHand } from '../../../base/participants'; import { getLocalParticipant, hasRaisedHand, raiseHand } from '../../../base/participants';
import { connect } from '../../../base/redux'; import { connect } from '../../../base/redux';
import { GifsMenu, GifsMenuButton } from '../../../gifs/components';
import { isGifEnabled, isGifsMenuOpen } from '../../../gifs/functions';
import { dockToolbox } from '../../../toolbox/actions.web'; import { dockToolbox } from '../../../toolbox/actions.web';
import { addReactionToBuffer } from '../../actions.any'; import { addReactionToBuffer } from '../../actions.any';
import { toggleReactionsMenuVisibility } from '../../actions.web'; import { toggleReactionsMenuVisibility } from '../../actions.web';
@ -29,6 +32,16 @@ type Props = {
*/ */
_dockToolbox: Function, _dockToolbox: Function,
/**
* Whether or not the GIF feature is enabled.
*/
_isGifEnabled: boolean,
/**
* Whether or not the GIF menu is visible.
*/
_isGifMenuVisible: boolean,
/** /**
* Whether or not it's a mobile browser. * Whether or not it's a mobile browser.
*/ */
@ -193,12 +206,16 @@ class ReactionsMenu extends Component<Props> {
* @inheritdoc * @inheritdoc
*/ */
render() { render() {
const { _raisedHand, t, overflowMenu, _isMobile, classes } = this.props; const { _raisedHand, t, overflowMenu, _isMobile, classes, _isGifMenuVisible, _isGifEnabled } = this.props;
return ( return (
<div className = { `reactions-menu ${overflowMenu ? `overflow ${classes.overflow}` : ''}` }> <div
className = { clsx('reactions-menu', _isGifEnabled && 'with-gif',
overflowMenu && `overflow ${classes.overflow}`) }>
{_isGifEnabled && _isGifMenuVisible && <GifsMenu />}
<div className = 'reactions-row'> <div className = 'reactions-row'>
{ this._getReactionButtons() } { this._getReactionButtons() }
{_isGifEnabled && <GifsMenuButton />}
</div> </div>
{_isMobile && ( {_isMobile && (
<div className = 'raise-hand-row'> <div className = 'raise-hand-row'>
@ -231,6 +248,8 @@ function mapStateToProps(state) {
return { return {
_localParticipantID: localParticipant.id, _localParticipantID: localParticipant.id,
_isMobile: isMobileBrowser(), _isMobile: isMobileBrowser(),
_isGifEnabled: isGifEnabled(state),
_isGifMenuVisible: isGifsMenuOpen(state),
_raisedHand: hasRaisedHand(localParticipant) _raisedHand: hasRaisedHand(localParticipant)
}; };
} }

View File

@ -8,6 +8,11 @@ import { DRAWER_MAX_HEIGHT } from '../../constants';
type Props = { type Props = {
/**
* Class name for custom styles.
*/
className: string,
/** /**
* The component(s) to be displayed within the drawer menu. * The component(s) to be displayed within the drawer menu.
*/ */
@ -40,6 +45,7 @@ const useStyles = makeStyles(theme => {
*/ */
function Drawer({ function Drawer({
children, children,
className = '',
isOpen, isOpen,
onClose onClose
}: Props) { }: Props) {
@ -72,7 +78,7 @@ function Drawer({
className = 'drawer-menu-container' className = 'drawer-menu-container'
onClick = { handleOutsideClick }> onClick = { handleOutsideClick }>
<div <div
className = { `drawer-menu ${styles.drawer}` } className = { `drawer-menu ${styles.drawer} ${className}` }
onClick = { handleInsideClick }> onClick = { handleInsideClick }>
{children} {children}
</div> </div>

View File

@ -3,6 +3,7 @@
import { withStyles } from '@material-ui/core/styles'; import { withStyles } from '@material-ui/core/styles';
import clsx from 'clsx'; import clsx from 'clsx';
import React, { Component, Fragment } from 'react'; import React, { Component, Fragment } from 'react';
import { batch } from 'react-redux';
import keyboardShortcut from '../../../../../modules/keyboardshortcut/keyboardshortcut'; import keyboardShortcut from '../../../../../modules/keyboardshortcut/keyboardshortcut';
import { import {
@ -30,6 +31,8 @@ import { ChatButton } from '../../../chat/components';
import { EmbedMeetingButton } from '../../../embed-meeting'; import { EmbedMeetingButton } from '../../../embed-meeting';
import { SharedDocumentButton } from '../../../etherpad'; import { SharedDocumentButton } from '../../../etherpad';
import { FeedbackButton } from '../../../feedback'; import { FeedbackButton } from '../../../feedback';
import { setGifMenuVisibility } from '../../../gifs/actions';
import { isGifEnabled } from '../../../gifs/functions';
import { InviteButton } from '../../../invite/components/add-people-dialog'; import { InviteButton } from '../../../invite/components/add-people-dialog';
import { isVpaasMeeting } from '../../../jaas/functions'; import { isVpaasMeeting } from '../../../jaas/functions';
import { KeyboardShortcutsButton } from '../../../keyboard-shortcuts'; import { KeyboardShortcutsButton } from '../../../keyboard-shortcuts';
@ -41,6 +44,7 @@ import {
import { ParticipantsPaneButton } from '../../../participants-pane/components/web'; import { ParticipantsPaneButton } from '../../../participants-pane/components/web';
import { getParticipantsPaneOpen } from '../../../participants-pane/functions'; import { getParticipantsPaneOpen } from '../../../participants-pane/functions';
import { addReactionToBuffer } from '../../../reactions/actions.any'; import { addReactionToBuffer } from '../../../reactions/actions.any';
import { toggleReactionsMenuVisibility } from '../../../reactions/actions.web';
import { ReactionsMenuButton } from '../../../reactions/components'; import { ReactionsMenuButton } from '../../../reactions/components';
import { REACTIONS, REACTIONS_MENU_HEIGHT } from '../../../reactions/constants'; import { REACTIONS, REACTIONS_MENU_HEIGHT } from '../../../reactions/constants';
import { isReactionsEnabled } from '../../../reactions/functions.any'; import { isReactionsEnabled } from '../../../reactions/functions.any';
@ -159,6 +163,11 @@ type Props = {
*/ */
_fullScreen: boolean, _fullScreen: boolean,
/**
* Whether or not the GIFs feature is enabled.
*/
_gifsEnabled: boolean,
/** /**
* Whether the app has Salesforce integration. * Whether the app has Salesforce integration.
*/ */
@ -334,7 +343,7 @@ class Toolbox extends Component<Props> {
* @returns {void} * @returns {void}
*/ */
componentDidMount() { componentDidMount() {
const { _toolbarButtons, t, dispatch, _reactionsEnabled } = this.props; const { _toolbarButtons, t, dispatch, _reactionsEnabled, _gifsEnabled } = this.props;
const KEYBOARD_SHORTCUTS = [ const KEYBOARD_SHORTCUTS = [
isToolbarButtonEnabled('videoquality', _toolbarButtons) && { isToolbarButtonEnabled('videoquality', _toolbarButtons) && {
character: 'A', character: 'A',
@ -408,6 +417,22 @@ class Toolbox extends Component<Props> {
shortcut.helpDescription, shortcut.helpDescription,
shortcut.altKey); shortcut.altKey);
}); });
if (_gifsEnabled) {
const onGifShortcut = () => {
batch(() => {
dispatch(toggleReactionsMenuVisibility());
dispatch(setGifMenuVisibility(true));
});
};
APP.keyboardshortcut.registerShortcut(
'G',
null,
onGifShortcut,
t('keyboardShortcuts.giphyMenu')
);
}
} }
} }
@ -1410,6 +1435,7 @@ function _mapStateToProps(state, ownProps) {
_disabled: Boolean(iAmRecorder || iAmSipGateway), _disabled: Boolean(iAmRecorder || iAmSipGateway),
_feedbackConfigured: Boolean(callStatsID), _feedbackConfigured: Boolean(callStatsID),
_fullScreen: fullScreen, _fullScreen: fullScreen,
_gifsEnabled: isGifEnabled(state),
_isProfileDisabled: Boolean(disableProfile), _isProfileDisabled: Boolean(disableProfile),
_isIosMobile: isIosMobileBrowser(), _isIosMobile: isIosMobileBrowser(),
_isMobile: isMobileBrowser(), _isMobile: isMobileBrowser(),