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,46 @@
/**
* 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 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,74 @@
import {
ADD_GIF_FOR_PARTICIPANT,
HIDE_GIF_FOR_PARTICIPANT,
REMOVE_GIF_FOR_PARTICIPANT,
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: string, gifUrl: string) {
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: string) {
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: string) {
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: string) {
return {
type: HIDE_GIF_FOR_PARTICIPANT,
participantId
};
}
/**
* Set visibility of the GIF menu.
*
* @param {boolean} visible - Whether or not it should be visible.
* @returns {Object}
*/
export function setGifMenuVisibility(visible: boolean) {
return {
type: SET_GIF_MENU_VISIBILITY,
visible
};
}

View File

@@ -0,0 +1,63 @@
import { GiphyContent, GiphyGridView, GiphyMediaType, GiphyRating } from '@giphy/react-native-sdk';
import React, { useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import { createGifSentEvent } from '../../../analytics/AnalyticsEvents';
import { sendAnalytics } from '../../../analytics/functions';
import JitsiScreen from '../../../base/modal/components/JitsiScreen';
import Input from '../../../base/ui/components/native/Input';
import { sendMessage } from '../../../chat/actions.any';
import { goBack } from '../../../mobile/navigation/components/conference/ConferenceNavigationContainerRef';
import { formatGifUrlMessage, getGifRating, getGifUrl } from '../../functions.native';
import GifsMenuFooter from './GifsMenuFooter';
import styles from './styles';
const GifsMenu = () => {
const [ searchQuery, setSearchQuery ] = useState('');
const dispatch = useDispatch();
const { t } = useTranslation();
const rating = useSelector(getGifRating) as GiphyRating;
const options = {
mediaType: GiphyMediaType.Gif,
limit: 20,
rating
};
const content = searchQuery === ''
? GiphyContent.trending(options)
: GiphyContent.search({
...options,
searchQuery
});
const sendGif = useCallback(e => {
const url = getGifUrl(e.nativeEvent.media);
sendAnalytics(createGifSentEvent());
dispatch(sendMessage(formatGifUrlMessage(url), true));
goBack();
}, []);
return (
<JitsiScreen
footerComponent = { GifsMenuFooter }
style = { styles.container }>
<Input
clearable = { true }
customStyles = {{ container: styles.customContainer }}
onChange = { setSearchQuery }
placeholder = { t('giphy.search') }
value = { searchQuery } />
<GiphyGridView
cellPadding = { 5 }
content = { content }
onMediaSelect = { sendGif }
style = { styles.grid } />
</JitsiScreen>
);
};
export default GifsMenu;

View File

@@ -0,0 +1,27 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Image, Text, TextStyle, View, ViewStyle } from 'react-native';
import styles from './styles';
/**
* Implements the gifs menu footer component.
*
* @returns { JSX.Element} - The gifs menu footer component.
*/
const GifsMenuFooter = (): JSX.Element => {
const { t } = useTranslation();
return (
<View style = { styles.credit as ViewStyle }>
<Text style = { styles.creditText as TextStyle }>
{ t('poweredby') }
</Text>
<Image
source = { require('../../../../../images/GIPHY_logo.png') } />
</View>
);
};
export default GifsMenuFooter;

View File

@@ -0,0 +1,36 @@
import BaseTheme from '../../../base/ui/components/BaseTheme.native';
export default {
container: {
backgroundColor: BaseTheme.palette.ui01,
flex: 1
},
customContainer: {
marginHorizontal: BaseTheme.spacing[3],
marginVertical: BaseTheme.spacing[2]
},
grid: {
flex: 1,
marginLeft: BaseTheme.spacing[3],
marginRight: BaseTheme.spacing[3]
},
credit: {
alignItems: 'center',
backgroundColor: BaseTheme.palette.ui01,
display: 'flex',
flexDirection: 'row',
height: 56,
justifyContent: 'center',
marginBottom: BaseTheme.spacing[0],
paddingBottom: BaseTheme.spacing[4],
width: '100%'
},
creditText: {
color: BaseTheme.palette.text01,
fontWeight: 'bold'
}
};

View File

@@ -0,0 +1,253 @@
import { GiphyFetch, TrendingOptions } from '@giphy/js-fetch-api';
import { Grid } from '@giphy/react-components';
import React, { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { batch, useDispatch, useSelector } from 'react-redux';
import { makeStyles } from 'tss-react/mui';
import { createGifSentEvent } from '../../../analytics/AnalyticsEvents';
import { sendAnalytics } from '../../../analytics/functions';
import { IReduxState } from '../../../app/types';
import Input from '../../../base/ui/components/web/Input';
import { sendMessage } from '../../../chat/actions.any';
import { SCROLL_SIZE } from '../../../filmstrip/constants';
import { toggleReactionsMenuVisibility } from '../../../reactions/actions.web';
import { IReactionsMenuParent } from '../../../reactions/types';
import Drawer from '../../../toolbox/components/web/Drawer';
import JitsiPortal from '../../../toolbox/components/web/JitsiPortal';
import { setGifMenuVisibility } from '../../actions';
import {
formatGifUrlMessage,
getGifAPIKey,
getGifRating,
getGifUrl
} from '../../function.any';
const OVERFLOW_DRAWER_PADDING = 16;
const useStyles = makeStyles()(theme => {
return {
gifsMenu: {
width: '100%',
marginBottom: theme.spacing(2),
display: 'flex',
flexDirection: 'column',
'& div:focus': {
border: '1px solid red !important',
boxSizing: 'border-box'
}
},
searchField: {
marginBottom: theme.spacing(3)
},
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)
},
overflowDrawerMenu: {
padding: theme.spacing(3),
width: '100%',
boxSizing: 'border-box',
height: '100%'
},
overflowMenu: {
height: '200px',
width: '201px',
marginBottom: '0px'
},
gifContainerOverflow: {
flexGrow: 1
},
drawer: {
display: 'flex',
height: '100%'
}
};
});
interface IProps {
columns?: number;
parent: IReactionsMenuParent;
}
/**
* Gifs menu.
*
* @returns {ReactElement}
*/
function GifsMenu({ columns = 2, parent }: IProps) {
const API_KEY = useSelector(getGifAPIKey);
const giphyFetch = new GiphyFetch(API_KEY);
const [ searchKey, setSearchKey ] = useState<string>();
const { classes: styles, cx } = useStyles();
const dispatch = useDispatch();
const { t } = useTranslation();
const isInOverflowMenu
= parent === IReactionsMenuParent.OverflowDrawer || parent === IReactionsMenuParent.OverflowMenu;
const { videoSpaceWidth } = useSelector((state: IReduxState) => state['features/base/responsive-ui']);
const rating = useSelector(getGifRating);
const fetchGifs = useCallback(async (offset = 0) => {
const options: TrendingOptions = {
limit: 20,
offset,
rating
};
if (!searchKey) {
return await giphyFetch.trending(options);
}
return await giphyFetch.search(searchKey, options);
}, [ searchKey ]);
const onDrawerClose = useCallback(() => {
dispatch(setGifMenuVisibility(false));
}, []);
const handleGifClick = useCallback((gif, e) => {
e?.stopPropagation();
const url = getGifUrl(gif);
sendAnalytics(createGifSentEvent());
batch(() => {
dispatch(sendMessage(formatGifUrlMessage(url), true));
dispatch(toggleReactionsMenuVisibility());
isInOverflowMenu && onDrawerClose();
});
}, [ dispatch, isInOverflowMenu ]);
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 (!document.activeElement) {
return;
}
if (e.keyCode === 38) { // up arrow
e.preventDefault();
// if the first gif is focused move focus to the input
if (document.activeElement.previousElementSibling === null) {
const element = document.querySelector('.gif-input') as HTMLElement;
element?.focus();
} else {
const element = document.activeElement.previousElementSibling as HTMLElement;
element?.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')) {
const element = document.querySelector('.giphy-gif') as HTMLElement;
element?.focus();
} else {
const element = document.activeElement.nextElementSibling as HTMLElement;
element?.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 onInputKeyPress = useCallback((e: React.KeyboardEvent) => {
e.stopPropagation();
}, []);
const gifMenu = (
<div
className = { cx(styles.gifsMenu,
parent === IReactionsMenuParent.OverflowDrawer && styles.overflowDrawerMenu,
parent === IReactionsMenuParent.OverflowMenu && styles.overflowMenu
) }>
<Input
autoFocus = { true }
className = { cx(styles.searchField, 'gif-input') }
id = 'gif-search-input'
onChange = { handleSearchKeyChange }
onKeyPress = { onInputKeyPress }
placeholder = { t('giphy.search') }
// eslint-disable-next-line react/jsx-no-bind
ref = { inputElement => {
inputElement?.focus();
setTimeout(() => inputElement?.focus(), 200);
} }
type = 'text'
value = { searchKey ?? '' } />
<div
className = { cx(styles.gifContainer,
parent === IReactionsMenuParent.OverflowDrawer && styles.gifContainerOverflow) }>
<Grid
columns = { columns }
fetchGifs = { fetchGifs }
gutter = { 6 }
hideAttribution = { true }
key = { searchKey }
noLink = { true }
noResultsMessage = { t('giphy.noResults') }
onGifClick = { handleGifClick }
onGifKeyPress = { handleGifKeyPress }
width = { parent === IReactionsMenuParent.OverflowDrawer
? videoSpaceWidth - (2 * OVERFLOW_DRAWER_PADDING) - SCROLL_SIZE
: parent === IReactionsMenuParent.OverflowMenu ? 201 : 320
} />
</div>
<div className = { styles.logoContainer }>
<span>Powered by</span>
<img
alt = 'GIPHY Logo'
src = 'images/GIPHY_logo.png' />
</div>
</div>
);
return parent === IReactionsMenuParent.OverflowDrawer ? (
<JitsiPortal>
<Drawer
className = { styles.drawer }
isOpen = { true }
onClose = { onDrawerClose }>
{gifMenu}
</Drawer>
</JitsiPortal>
) : gifMenu;
}
export default GifsMenu;

View File

@@ -0,0 +1,41 @@
import React, { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import ReactionButton from '../../../reactions/components/web/ReactionButton';
import { IReactionsMenuParent } from '../../../reactions/types';
import { setGifMenuVisibility } from '../../actions';
import { isGifsMenuOpen } from '../../functions.web';
interface IProps {
parent: IReactionsMenuParent;
}
const GifsMenuButton = ({ parent }: IProps) => {
const menuOpen = useSelector(isGifsMenuOpen);
const { t } = useTranslation();
const dispatch = useDispatch();
const icon = (
<img
alt = 'GIPHY Logo'
height = { parent === IReactionsMenuParent.OverflowMenu ? 16 : 24 }
src = 'images/GIPHY_icon.png' />
);
const handleClick = useCallback(() => {
dispatch(setGifMenuVisibility(!menuOpen));
}, [ menuOpen, parent ]);
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,14 @@
/**
* 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[';
/**
* The Giphy default option for audience rating.
*/
export const GIF_DEFAULT_RATING = 'g';

View File

@@ -0,0 +1,150 @@
import { IReduxState } from '../app/types';
import { GIF_DEFAULT_RATING, GIF_PREFIX } from './constants';
import { IGif } from './reducer';
/**
* Returns the gif config.
*
* @param {IReduxState} state - Redux state.
* @returns {Object}
*/
export function getGifConfig(state: IReduxState) {
return state['features/base/config'].giphy || {};
}
/**
* Get the GIF display mode.
*
* @param {IReduxState} state - Redux state.
* @returns {string}
*/
export function getGifDisplayMode(state: IReduxState) {
return getGifConfig(state).displayMode || 'all';
}
/**
* Get the GIF audience rating.
*
* @param {IReduxState} state - Redux state.
* @returns {string}
*/
export function getGifRating(state: IReduxState) {
return getGifConfig(state).rating || GIF_DEFAULT_RATING;
}
/**
* Gets the URL of the GIF for the given participant or null if there's none.
*
* @param {IReduxState} state - Redux state.
* @param {string} participantId - Id of the participant for which to remove the GIF.
* @returns {Object}
*/
export function getGifForParticipant(state: IReduxState, participantId: string): IGif {
return isGifEnabled(state) ? state['features/gifs'].gifList.get(participantId) || {} : {};
}
/**
* Returns true if a given URL is allowed to be rendered as gif and false otherwise.
*
* @param {string} url - The URL to be validated.
* @returns {boolean} - True if a given URL is allowed to be rendered as gif and false otherwise.
*/
export function isGifUrlAllowed(url: string) {
let hostname: string | undefined;
try {
const urlObject = new URL(url);
hostname = urlObject?.hostname;
} catch (_error) {
return false;
}
return hostname === 'i.giphy.com';
}
/**
* Whether or not the message is a GIF message.
*
* @param {string} message - Message to check.
* @returns {boolean}
*/
export function isGifMessage(message = '') {
const trimmedMessage = message.trim();
if (!trimmedMessage.toLowerCase().startsWith(GIF_PREFIX)) {
return false;
}
const url = extractGifURL(trimmedMessage);
return isGifUrlAllowed(url);
}
/**
* Extracts the URL from a gif message.
*
* @param {string} message - The message.
* @returns {string} - The URL.
*/
export function extractGifURL(message = '') {
const trimmedMessage = message.trim();
return trimmedMessage.substring(GIF_PREFIX.length, trimmedMessage.length - 1);
}
/**
* Returns the url of the gif selected in the gifs menu.
*
* @param {Object} gif - The gif data.
* @returns {boolean}
*/
export function getGifUrl(gif?: { data?: { embed_url: string; }; embed_url?: string; }) {
const embedUrl = gif?.embed_url || gif?.data?.embed_url || '';
const idx = embedUrl.lastIndexOf('/');
const id = embedUrl.substr(idx + 1);
return `https://i.giphy.com/media/${id}/giphy.gif`;
}
/**
* Formats the gif message.
*
* @param {string} url - GIF url.
* @returns {string}
*/
export function formatGifUrlMessage(url: string) {
return `${GIF_PREFIX}${url}]`;
}
/**
* Get the Giphy API Key from config.
*
* @param {IReduxState} state - Redux state.
* @returns {string}
*/
export function getGifAPIKey(state: IReduxState) {
return getGifConfig(state).sdkKey ?? '';
}
/**
* Returns whether or not the feature is enabled.
*
* @param {IReduxState} state - Redux state.
* @returns {boolean}
*/
export function isGifEnabled(state: IReduxState) {
const { disableThirdPartyRequests } = state['features/base/config'];
const { giphy } = state['features/base/config'];
const showGiphyIntegration = state['features/dynamic-branding']?.showGiphyIntegration !== false;
if (navigator.product === 'ReactNative' && window.JITSI_MEET_LITE_SDK) {
return false;
}
return showGiphyIntegration && Boolean(!disableThirdPartyRequests && giphy?.enabled && Boolean(giphy?.sdkKey));
}

View File

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

View File

@@ -0,0 +1,13 @@
import { IReduxState } from '../app/types';
export * from './function.any';
/**
* Returns the visibility state of the gifs menu.
*
* @param {IReduxState} state - The state of the application.
* @returns {boolean}
*/
export function isGifsMenuOpen(state: IReduxState) {
return state['features/gifs'].menuOpen;
}

View File

@@ -0,0 +1,61 @@
import { IReduxState } from '../app/types';
import MiddlewareRegistry from '../base/redux/MiddlewareRegistry';
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 './function.any';
/**
* 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 {IReduxState} state - Redux state.
* @param {string} id - Id of the participant for whom to clear the timeout.
* @returns {void}
*/
function _clearGifTimeout(state: IReduxState, id: string) {
const gif = getGifForParticipant(state, id);
clearTimeout(gif?.timeoutID ?? -1);
}

View File

@@ -0,0 +1,3 @@
import './middleware.any';
import './subscriber.native';

View File

@@ -0,0 +1,2 @@
import './middleware.any';

View File

@@ -0,0 +1,75 @@
import ReducerRegistry from '../base/redux/ReducerRegistry';
import {
ADD_GIF_FOR_PARTICIPANT,
HIDE_GIF_FOR_PARTICIPANT,
REMOVE_GIF_FOR_PARTICIPANT,
SET_GIF_MENU_VISIBILITY
} from './actionTypes';
const initialState = {
gifList: new Map(),
menuOpen: false
};
export interface IGif {
gifUrl?: string;
timeoutID?: number;
}
export interface IGifsState {
gifList: Map<string, IGif>;
menuOpen: boolean;
}
ReducerRegistry.register<IGifsState>(
'features/gifs',
(state = initialState, action): IGifsState => {
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_MENU_VISIBILITY:
return {
...state,
menuOpen: action.visible
};
}
return state;
});

View File

@@ -0,0 +1,20 @@
import { GiphySDK } from '@giphy/react-native-sdk';
import StateListenerRegistry from '../base/redux/StateListenerRegistry';
import { getGifConfig, isGifEnabled } from './function.any';
/**
* Listens for changes in the number of participants to calculate the dimensions of the tile view grid and the tiles.
*/
StateListenerRegistry.register(
/* selector */ state => getGifConfig(state),
/* listener */ (_, store) => {
const state = store.getState();
if (isGifEnabled(state)) {
GiphySDK.configure({ apiKey: state['features/base/config'].giphy?.sdkKey ?? '' });
}
}, {
deepEquals: true
});