diff --git a/app/actions/websocket/index.ts b/app/actions/websocket/index.ts index dc1062567d..109132be49 100644 --- a/app/actions/websocket/index.ts +++ b/app/actions/websocket/index.ts @@ -18,6 +18,7 @@ import { handleCallUserDisconnected, handleCallUserMuted, handleCallUserRaiseHand, + handleCallUserReacted, handleCallUserUnmuted, handleCallUserUnraiseHand, handleCallUserVoiceOff, @@ -393,6 +394,9 @@ export async function handleEvent(serverUrl: string, msg: WebSocketMessage) { case WebsocketEvents.CALLS_CALL_END: handleCallEnded(serverUrl, msg); break; + case WebsocketEvents.CALLS_USER_REACTED: + handleCallUserReacted(serverUrl, msg); + break; case WebsocketEvents.GROUP_RECEIVED: handleGroupReceivedEvent(serverUrl, msg); diff --git a/app/constants/calls.ts b/app/constants/calls.ts index c11a7c8116..a7238bd665 100644 --- a/app/constants/calls.ts +++ b/app/constants/calls.ts @@ -14,4 +14,7 @@ const RequiredServer = { const PluginId = 'com.mattermost.calls'; -export default {RequiredServer, RefreshConfigMillis, PluginId}; +export const REACTION_TIMEOUT = 10000; +export const REACTION_LIMIT = 20; + +export default {RequiredServer, RefreshConfigMillis, PluginId, REACTION_TIMEOUT}; diff --git a/app/constants/websocket.ts b/app/constants/websocket.ts index 57dc317a42..b92e26e643 100644 --- a/app/constants/websocket.ts +++ b/app/constants/websocket.ts @@ -66,6 +66,7 @@ const WebsocketEvents = { CALLS_SCREEN_OFF: `custom_${Calls.PluginId}_user_screen_off`, CALLS_USER_RAISE_HAND: `custom_${Calls.PluginId}_user_raise_hand`, CALLS_USER_UNRAISE_HAND: `custom_${Calls.PluginId}_user_unraise_hand`, + CALLS_USER_REACTED: `custom_${Calls.PluginId}_user_reacted`, GROUP_RECEIVED: 'received_group', GROUP_MEMBER_ADD: 'group_member_add', GROUP_MEMBER_DELETE: 'group_member_delete', diff --git a/app/products/calls/components/call_avatar.tsx b/app/products/calls/components/call_avatar.tsx index d2c3992b95..ee704d4224 100644 --- a/app/products/calls/components/call_avatar.tsx +++ b/app/products/calls/components/call_avatar.tsx @@ -4,7 +4,9 @@ import React, {useMemo} from 'react'; import {View, StyleSheet, Text, Platform} from 'react-native'; +import {CallReactionEmoji} from '@calls/types/calls'; import CompassIcon from '@components/compass_icon'; +import Emoji from '@components/emoji'; import ProfilePicture from '@components/profile_picture'; import type UserModel from '@typings/database/models/servers/user'; @@ -16,6 +18,7 @@ type Props = { muted?: boolean; sharingScreen?: boolean; raisedHand?: boolean; + reaction?: CallReactionEmoji; size?: 'm' | 'l'; } @@ -77,49 +80,49 @@ const getStyleSheet = ({volume, muted, size}: { volume: number; muted?: boolean; }, ), }, - raisedHand: { + reaction: { position: 'absolute', overflow: 'hidden', top: 0, right: -5, - backgroundColor: 'black', - borderColor: 'black', - borderRadius, - padding, - borderWidth: 2, width: widthHeight, height: widthHeight, + borderRadius, + padding, + backgroundColor: 'black', + borderColor: 'black', + borderWidth: 2, fontSize: smallIcon ? 10 : 12, + }, + raisedHand: { + backgroundColor: 'white', + right: -5, ...Platform.select( { android: { - paddingLeft: 4, - paddingTop: 2, + paddingLeft: 5, + paddingTop: 3, color: 'rgb(255, 188, 66)', }, }, ), }, screenSharing: { - position: 'absolute', - top: 0, - right: -5, - width: widthHeight, - height: widthHeight, - borderRadius, padding: padding + 1, backgroundColor: '#D24B4E', - borderColor: 'black', - borderWidth: 2, color: 'white', textAlign: 'center', textAlignVertical: 'center', - overflow: 'hidden', + paddingTop: Platform.select({ios: 3}), + }, + emoji: { + paddingLeft: 1, + paddingTop: Platform.select({ios: 2, default: 1}), }, }); }; -const CallAvatar = ({userModel, volume, serverUrl, sharingScreen, size, muted, raisedHand}: Props) => { +const CallAvatar = ({userModel, volume, serverUrl, sharingScreen, size, muted, raisedHand, reaction}: Props) => { const style = useMemo(() => getStyleSheet({volume, muted, size}), [volume, muted, size]); const profileSize = size === 'm' || !size ? 40 : 72; const iconSize = size === 'm' || !size ? 12 : 16; @@ -132,17 +135,29 @@ const CallAvatar = ({userModel, volume, serverUrl, sharingScreen, size, muted, r ); } else if (raisedHand) { topRightIcon = ( - + {'✋'} ); } + // An emoji will override the top right indicator. + if (reaction) { + topRightIcon = ( + + + + ); + } + const profile = userModel ? ( { + return ( + + + {reactionStream.map((e) => ( + + ))} + + + + ); +}; + +export default EmojiList; diff --git a/app/products/calls/components/emoji_pill.tsx b/app/products/calls/components/emoji_pill.tsx new file mode 100644 index 0000000000..281bf0f2c6 --- /dev/null +++ b/app/products/calls/components/emoji_pill.tsx @@ -0,0 +1,46 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {StyleSheet, Text, View} from 'react-native'; + +import Emoji from '@components/emoji'; +import {typography} from '@utils/typography'; + +const styles = StyleSheet.create({ + pill: { + alignItems: 'center', + justifyContent: 'center', + flexDirection: 'row', + backgroundColor: 'rgba(255,255,255,0.16)', + borderRadius: 20, + height: 30, + paddingLeft: 16, + paddingRight: 16, + marginLeft: 6, + }, + count: { + ...typography('Body', 75, 'SemiBold'), + color: 'white', + marginLeft: 8, + }, +}); + +interface Props { + name: string; + count: number; +} + +const EmojiPill = ({name, count}: Props) => { + return ( + + + {count} + + ); +}; + +export default EmojiPill; diff --git a/app/products/calls/connection/websocket_event_handlers.ts b/app/products/calls/connection/websocket_event_handlers.ts index a74d57d089..f747585ea8 100644 --- a/app/products/calls/connection/websocket_event_handlers.ts +++ b/app/products/calls/connection/websocket_event_handlers.ts @@ -15,6 +15,7 @@ import { setUserVoiceOn, userJoinedCall, userLeftCall, + userReacted, } from '@calls/state'; import {WebsocketEvents} from '@constants'; import DatabaseManager from '@database/manager'; @@ -93,3 +94,7 @@ export const handleCallUserRaiseHand = (serverUrl: string, msg: WebSocketMessage export const handleCallUserUnraiseHand = (serverUrl: string, msg: WebSocketMessage) => { setRaisedHand(serverUrl, msg.broadcast.channel_id, msg.data.userID, msg.data.raised_hand); }; + +export const handleCallUserReacted = (serverUrl: string, msg: WebSocketMessage) => { + userReacted(serverUrl, msg.broadcast.channel_id, msg.data); +}; diff --git a/app/products/calls/screens/call_screen/call_screen.tsx b/app/products/calls/screens/call_screen/call_screen.tsx index b89fce9a69..c17d3984dc 100644 --- a/app/products/calls/screens/call_screen/call_screen.tsx +++ b/app/products/calls/screens/call_screen/call_screen.tsx @@ -28,6 +28,7 @@ import { } from '@calls/actions'; import CallAvatar from '@calls/components/call_avatar'; import CallDuration from '@calls/components/call_duration'; +import EmojiList from '@calls/components/emoji_list'; import PermissionErrorBar from '@calls/components/permission_error_bar'; import UnavailableIconWrapper from '@calls/components/unavailable_icon_wrapper'; import {usePermissionsChecker} from '@calls/hooks'; @@ -439,6 +440,7 @@ const CallScreen = ({ muted={user.muted} sharingScreen={user.id === currentCall.screenOn} raisedHand={Boolean(user.raisedHand)} + reaction={user.reaction?.emoji} size={currentCall.screenOn ? 'm' : 'l'} serverUrl={currentCall.serverUrl} /> @@ -504,6 +506,7 @@ const CallScreen = ({ {usersList} {screenShareView} {micPermissionsError && } + diff --git a/app/products/calls/state/actions.test.ts b/app/products/calls/state/actions.test.ts index 5de034c7e4..1ded35e415 100644 --- a/app/products/calls/state/actions.test.ts +++ b/app/products/calls/state/actions.test.ts @@ -16,7 +16,7 @@ import { useCallsState, useChannelsWithCalls, useCurrentCall, - useGlobalCallsState, + useGlobalCallsState, userReacted, } from '@calls/state'; import { setCalls, @@ -815,4 +815,77 @@ describe('useCallsState', () => { act(() => setPluginEnabled('server1', false)); assert.deepEqual(result.current, {...newConfig, pluginEnabled: false}); }); + + it('user reactions', () => { + const initialCallsState = { + ...DefaultCallsState, + serverUrl: 'server1', + myUserId: 'myUserId', + calls: {'channel-1': call1, 'channel-2': call2}, + }; + const initialCurrentCallState: CurrentCall = { + ...DefaultCurrentCall, + serverUrl: 'server1', + myUserId: 'myUserId', + ...call1, + }; + const expectedCurrentCallState: CurrentCall = { + ...initialCurrentCallState, + reactionStream: [ + {name: 'smile', latestTimestamp: 202, count: 1}, + {name: '+1', latestTimestamp: 145, count: 2}, + ], + participants: { + ...initialCurrentCallState.participants, + 'user-1': { + ...initialCurrentCallState.participants['user-1'], + reaction: { + user_id: 'user-1', + emoji: {name: 'smile', unified: 'something'}, + timestamp: 202, + }, + }, + 'user-2': { + ...initialCurrentCallState.participants['user-2'], + reaction: { + user_id: 'user-2', + emoji: {name: '+1', unified: 'something'}, + timestamp: 123, + }, + }, + }, + }; + + // setup + const {result} = renderHook(() => { + return [useCallsState('server1'), useCurrentCall()]; + }); + act(() => { + setCallsState('server1', initialCallsState); + setCurrentCall(initialCurrentCallState); + }); + assert.deepEqual(result.current[0], initialCallsState); + assert.deepEqual(result.current[1], initialCurrentCallState); + + // test + act(() => { + userReacted('server1', 'channel-1', { + user_id: 'user-2', + emoji: {name: '+1', unified: 'something'}, + timestamp: 123, + }); + userReacted('server1', 'channel-1', { + user_id: 'user-1', + emoji: {name: '+1', unified: 'something'}, + timestamp: 145, + }); + userReacted('server1', 'channel-1', { + user_id: 'user-1', + emoji: {name: 'smile', unified: 'something'}, + timestamp: 202, + }); + }); + assert.deepEqual(result.current[0], initialCallsState); + assert.deepEqual(result.current[1], expectedCurrentCallState); + }); }); diff --git a/app/products/calls/state/actions.ts b/app/products/calls/state/actions.ts index 3b58e2f4b0..ee5be353d4 100644 --- a/app/products/calls/state/actions.ts +++ b/app/products/calls/state/actions.ts @@ -13,7 +13,16 @@ import { setCurrentCall, setGlobalCallsState, } from '@calls/state'; -import {Call, CallsConfig, ChannelsWithCalls, DefaultCall, DefaultCurrentCall} from '@calls/types/calls'; +import { + Call, + CallReaction, + CallsConfig, + ChannelsWithCalls, + CurrentCall, + DefaultCall, + DefaultCurrentCall, ReactionStreamEmoji, +} from '@calls/types/calls'; +import {REACTION_LIMIT, REACTION_TIMEOUT} from '@constants/calls'; export const setCalls = (serverUrl: string, myUserId: string, calls: Dictionary, enabled: Dictionary) => { const channelsWithCalls = Object.keys(calls).reduce( @@ -409,3 +418,78 @@ export const setMicPermissionsErrorDismissed = () => { }; setCurrentCall(nextCurrentCall); }; + +export const userReacted = (serverUrl: string, channelId: string, reaction: CallReaction) => { + // Note: Simplification for performance: + // If you are not in the call with the reaction, ignore it. There could be many calls ongoing in your + // servers, do we want to be tracking reactions and setting timeouts for all those calls? No. + // The downside of this approach: when you join/rejoin a call, you will not see the current reactions. + // When you leave a call, you will lose the reactions you were tracking. + // We can revisit this if it causes UX issues. + const currentCall = getCurrentCall(); + if (currentCall?.channelId !== channelId) { + return; + } + + // Update the reaction stream. + const newReactionStream = [...currentCall.reactionStream]; + const idx = newReactionStream.findIndex((e) => e.name === reaction.emoji.name); + if (idx > -1) { + const [newReaction] = newReactionStream.splice(idx, 1); + newReaction.count += 1; + newReaction.latestTimestamp = reaction.timestamp; + newReactionStream.splice(0, 0, newReaction); + } else { + const newReaction: ReactionStreamEmoji = { + name: reaction.emoji.name, + count: 1, + latestTimestamp: reaction.timestamp, + }; + newReactionStream.splice(0, 0, newReaction); + } + if (newReactionStream.length > REACTION_LIMIT) { + newReactionStream.pop(); + } + + // Update the participant. + const nextParticipants = {...currentCall.participants}; + if (nextParticipants[reaction.user_id]) { + const nextUser = {...nextParticipants[reaction.user_id], reaction}; + nextParticipants[reaction.user_id] = nextUser; + } + + const nextCurrentCall: CurrentCall = { + ...currentCall, + reactionStream: newReactionStream, + participants: nextParticipants, + }; + setCurrentCall(nextCurrentCall); + + setTimeout(() => { + userReactionTimeout(serverUrl, channelId, reaction); + }, REACTION_TIMEOUT); +}; + +const userReactionTimeout = (serverUrl: string, channelId: string, reaction: CallReaction) => { + const currentCall = getCurrentCall(); + if (currentCall?.channelId !== channelId) { + return; + } + + // Remove the reaction only if it was the last time that emoji was used. + const newReactions = currentCall.reactionStream.filter((e) => e.latestTimestamp !== reaction.timestamp); + + const nextParticipants = {...currentCall.participants}; + if (nextParticipants[reaction.user_id] && nextParticipants[reaction.user_id].reaction?.timestamp === reaction.timestamp) { + const nextUser = {...nextParticipants[reaction.user_id]}; + delete nextUser.reaction; + nextParticipants[reaction.user_id] = nextUser; + } + + const nextCurrentCall: CurrentCall = { + ...currentCall, + reactionStream: newReactions, + participants: nextParticipants, + }; + setCurrentCall(nextCurrentCall); +}; diff --git a/app/products/calls/state/calls_config.ts b/app/products/calls/state/calls_config.ts index aeb9e27e9c..07d9b095d1 100644 --- a/app/products/calls/state/calls_config.ts +++ b/app/products/calls/state/calls_config.ts @@ -6,7 +6,7 @@ import {BehaviorSubject} from 'rxjs'; import {CallsConfig, DefaultCallsConfig} from '@calls/types/calls'; -const callsConfigSubjects = {} as Dictionary>; +const callsConfigSubjects: Dictionary> = {}; const getCallsConfigSubject = (serverUrl: string) => { if (!callsConfigSubjects[serverUrl]) { diff --git a/app/products/calls/state/calls_state.ts b/app/products/calls/state/calls_state.ts index d5e7e9a1ab..351dade12c 100644 --- a/app/products/calls/state/calls_state.ts +++ b/app/products/calls/state/calls_state.ts @@ -6,7 +6,7 @@ import {BehaviorSubject} from 'rxjs'; import {CallsState, DefaultCallsState} from '@calls/types/calls'; -const callsStateSubjects = {} as Dictionary>; +const callsStateSubjects: Dictionary> = {}; const getCallsStateSubject = (serverUrl: string) => { if (!callsStateSubjects[serverUrl]) { diff --git a/app/products/calls/state/channels_with_calls.ts b/app/products/calls/state/channels_with_calls.ts index 4980c68295..150b8a4124 100644 --- a/app/products/calls/state/channels_with_calls.ts +++ b/app/products/calls/state/channels_with_calls.ts @@ -6,7 +6,7 @@ import {BehaviorSubject} from 'rxjs'; import {ChannelsWithCalls} from '@calls/types/calls'; -const channelsWithCallsSubject = {} as Dictionary>; +const channelsWithCallsSubject: Dictionary> = {}; const getChannelsWithCallsSubject = (serverUrl: string) => { if (!channelsWithCallsSubject[serverUrl]) { diff --git a/app/products/calls/types/calls.ts b/app/products/calls/types/calls.ts index e327144902..2cc5fba088 100644 --- a/app/products/calls/types/calls.ts +++ b/app/products/calls/types/calls.ts @@ -22,8 +22,8 @@ export type CallsState = { export const DefaultCallsState: CallsState = { serverUrl: '', myUserId: '', - calls: {} as Dictionary, - enabled: {} as Dictionary, + calls: {}, + enabled: {}, }; export type Call = { @@ -36,7 +36,7 @@ export type Call = { } export const DefaultCall: Call = { - participants: {} as Dictionary, + participants: {}, channelId: '', startTime: 0, screenOn: '', @@ -52,6 +52,7 @@ export type CurrentCall = Call & { speakerphoneOn: boolean; voiceOn: Dictionary; micPermissionsErrorDismissed: boolean; + reactionStream: ReactionStreamEmoji[]; } export const DefaultCurrentCall: CurrentCall = { @@ -68,6 +69,7 @@ export const DefaultCurrentCall: CurrentCall = { speakerphoneOn: false, voiceOn: {}, micPermissionsErrorDismissed: false, + reactionStream: [], }; export type CallParticipant = { @@ -75,6 +77,7 @@ export type CallParticipant = { muted: boolean; raisedHand: number; userModel?: UserModel; + reaction?: CallReaction; } export type ChannelsWithCalls = Dictionary; @@ -100,11 +103,6 @@ export type ServerCallState = { owner_id: string; } -export type VoiceEventData = { - channelId: string; - userId: string; -} - export type CallsConnection = { disconnect: () => void; mute: () => void; @@ -149,3 +147,21 @@ export type ApiResp = { detailed_error?: string; status_code: number; } + +export type CallReactionEmoji = { + name: string; + skin?: string; + unified: string; +} + +export type CallReaction = { + user_id: string; + emoji: CallReactionEmoji; + timestamp: number; +} + +export type ReactionStreamEmoji = { + name: string; + latestTimestamp: number; + count: number; +};