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;
+};