diff --git a/app/actions/websocket/index.ts b/app/actions/websocket/index.ts
index 3d31f288c..de39eee4c 100644
--- a/app/actions/websocket/index.ts
+++ b/app/actions/websocket/index.ts
@@ -35,7 +35,7 @@ import {
handleCallChannelEnabled,
handleCallChannelDisabled,
handleCallScreenOn,
- handleCallScreenOff,
+ handleCallScreenOff, handleCallUserRaiseHand, handleCallUserUnraiseHand,
} from '@mmproducts/calls/store/actions/websockets';
import {getChannelSinceValue} from '@utils/channels';
import {semverFromServerVersion} from '@utils/general';
@@ -467,6 +467,10 @@ function handleEvent(msg: WebSocketMessage) {
return dispatch(handleCallScreenOn(msg));
case WebsocketEvents.CALLS_SCREEN_OFF:
return dispatch(handleCallScreenOff(msg));
+ case WebsocketEvents.CALLS_USER_RAISE_HAND:
+ return dispatch(handleCallUserRaiseHand(msg));
+ case WebsocketEvents.CALLS_USER_UNRAISE_HAND:
+ return dispatch(handleCallUserUnraiseHand(msg));
}
}
diff --git a/app/constants/websocket.ts b/app/constants/websocket.ts
index 1632a9f15..9b3ae6217 100644
--- a/app/constants/websocket.ts
+++ b/app/constants/websocket.ts
@@ -62,5 +62,7 @@ const WebsocketEvents = {
CALLS_CALL_START: 'custom_com.mattermost.calls_call_start',
CALLS_SCREEN_ON: 'custom_com.mattermost.calls_user_screen_on',
CALLS_SCREEN_OFF: 'custom_com.mattermost.calls_user_screen_off',
+ CALLS_USER_RAISE_HAND: 'custom_com.mattermost.calls_user_raise_hand',
+ CALLS_USER_UNRAISE_HAND: 'custom_com.mattermost.calls_user_unraise_hand',
};
export default WebsocketEvents;
diff --git a/app/products/calls/components/__snapshots__/call_avatar.test.js.snap b/app/products/calls/components/__snapshots__/call_avatar.test.js.snap
index ac488add8..d2e727d64 100644
--- a/app/products/calls/components/__snapshots__/call_avatar.test.js.snap
+++ b/app/products/calls/components/__snapshots__/call_avatar.test.js.snap
@@ -40,23 +40,23 @@ exports[`CallAvatar should match snapshot muted 1`] = `
/>
@@ -170,23 +170,23 @@ exports[`CallAvatar should match snapshot unmuted 1`] = `
/>
diff --git a/app/products/calls/components/call_avatar.tsx b/app/products/calls/components/call_avatar.tsx
index d87b99a3f..0f575ab7d 100644
--- a/app/products/calls/components/call_avatar.tsx
+++ b/app/products/calls/components/call_avatar.tsx
@@ -2,7 +2,7 @@
// See LICENSE.txt for license information.
import React from 'react';
-import {View, StyleSheet} from 'react-native';
+import {View, StyleSheet, Text, Platform} from 'react-native';
import CompassIcon from '@components/compass_icon';
import ProfilePicture from '@components/profile_picture';
@@ -11,11 +11,17 @@ type Props = {
userId?: string;
volume: number;
muted?: boolean;
+ sharingScreen?: boolean;
+ raisedHand?: boolean;
size?: 'm' | 'l';
}
const getStyleSheet = (props: Props) => {
const baseSize = props.size === 'm' || !props.size ? 40 : 72;
+ const smallIcon = props.size === 'm' || !props.size;
+ const widthHeight = smallIcon ? 20 : 24;
+ const borderRadius = smallIcon ? 10 : 12;
+ const padding = smallIcon ? 1 : 2;
return StyleSheet.create({
pictureHalo: {
@@ -42,10 +48,10 @@ const getStyleSheet = (props: Props) => {
position: 'absolute',
bottom: -5,
right: -5,
- width: 24,
- height: 24,
- borderRadius: 12,
- padding: 2,
+ width: widthHeight,
+ height: widthHeight,
+ borderRadius,
+ padding,
backgroundColor: props.muted ? 'black' : '#3DB887',
borderColor: 'black',
borderWidth: 2,
@@ -54,32 +60,95 @@ const getStyleSheet = (props: Props) => {
textAlignVertical: 'center',
overflow: 'hidden',
},
+ raisedHand: {
+ position: 'absolute',
+ overflow: 'hidden',
+ top: 0,
+ right: -5,
+ backgroundColor: 'black',
+ borderColor: 'black',
+ borderRadius,
+ padding,
+ borderWidth: 2,
+ width: widthHeight,
+ height: widthHeight,
+ fontSize: smallIcon ? 10 : 12,
+ ...Platform.select(
+ {
+ android: {
+ padding: 4,
+ 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',
+ },
});
};
const CallAvatar = (props: Props) => {
const style = getStyleSheet(props);
+ const profileSize = props.size === 'm' || !props.size ? 40 : 72;
+ const iconSize = props.size === 'm' || !props.size ? 12 : 16;
+
+ // Only show one or the other.
+ let topRightIcon: JSX.Element | null = null;
+ if (props.sharingScreen) {
+ topRightIcon = (
+
+ );
+ } else if (props.raisedHand) {
+ topRightIcon = (
+
+ {'✋'}
+
+ );
+ }
+
return (
- {props.userId ?
- :
-
+ {
+ props.userId ?
+ :
+
}
- {props.muted !== undefined &&
+ {
+ props.muted !== undefined &&
}
+ />
+ }
+ {topRightIcon}
diff --git a/app/products/calls/components/raised_hand_icon.tsx b/app/products/calls/components/raised_hand_icon.tsx
new file mode 100644
index 000000000..fcd5d6fda
--- /dev/null
+++ b/app/products/calls/components/raised_hand_icon.tsx
@@ -0,0 +1,35 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import React from 'react';
+import {StyleProp, View, ViewStyle} from 'react-native';
+import Svg, {Path} from 'react-native-svg';
+
+type Props = {
+ className?: string;
+ width?: number;
+ height?: number;
+ fill?: string;
+ style?: StyleProp;
+ svgStyle?: StyleProp;
+}
+
+export default function RaisedHandIcon({width = 25, height = 27, style, svgStyle, ...props}: Props) {
+ return (
+
+
+
+ );
+}
+
diff --git a/app/products/calls/components/unraised_hand_icon.tsx b/app/products/calls/components/unraised_hand_icon.tsx
new file mode 100644
index 000000000..4544bcb68
--- /dev/null
+++ b/app/products/calls/components/unraised_hand_icon.tsx
@@ -0,0 +1,35 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import React from 'react';
+import {StyleProp, View, ViewStyle} from 'react-native';
+import Svg, {Path} from 'react-native-svg';
+
+type Props = {
+ className?: string;
+ width?: number;
+ height?: number;
+ fill?: string;
+ style?: StyleProp;
+ svgStyle?: StyleProp;
+}
+
+export default function UnraisedHandIcon({width = 24, height = 24, style, svgStyle, ...props}: Props) {
+ return (
+
+
+
+ );
+}
+
diff --git a/app/products/calls/connection.ts b/app/products/calls/connection.ts
index aa88b68e1..94d3dfcf1 100644
--- a/app/products/calls/connection.ts
+++ b/app/products/calls/connection.ts
@@ -94,6 +94,18 @@ export async function newClient(channelID: string, closeCb: () => void, setScree
}
};
+ const raiseHand = () => {
+ if (ws) {
+ ws.send('raise_hand');
+ }
+ };
+
+ const unraiseHand = () => {
+ if (ws) {
+ ws.send('unraise_hand');
+ }
+ };
+
ws.on('error', (err) => {
console.log('WS (CALLS) ERROR', err); // eslint-disable-line no-console
ws.close();
@@ -179,6 +191,8 @@ export async function newClient(channelID: string, closeCb: () => void, setScree
mute,
unmute,
waitForReady,
+ raiseHand,
+ unraiseHand,
};
return client;
diff --git a/app/products/calls/screens/call/__snapshots__/call_screen.test.js.snap b/app/products/calls/screens/call/__snapshots__/call_screen.test.js.snap
index 00bd86bba..7c4b51a35 100644
--- a/app/products/calls/screens/call/__snapshots__/call_screen.test.js.snap
+++ b/app/products/calls/screens/call/__snapshots__/call_screen.test.js.snap
@@ -28,6 +28,9 @@ exports[`CallScreen Landscape should match snapshot 1`] = `
"flexDirection": "row",
"height": 64,
"padding": 0,
+ "paddingLeft": 14,
+ "paddingRight": 14,
+ "paddingTop": 10,
"position": "absolute",
"top": -1000,
"width": "100%",
@@ -101,6 +104,8 @@ exports[`CallScreen Landscape should match snapshot 1`] = `
>
- Chat thread
+ Speaker
-
- Speaker
+ Raise hand
- You are seeing User 2 screen
+ You are viewing User 2's screen
- Chat thread
+ Speaker
-
- Speaker
+ Raise hand
- Chat thread
+ Speaker
-
- Speaker
+ Raise hand
- You are seeing User 2 screen
+ You are viewing User 2's screen
- Chat thread
+ Speaker
-
- Speaker
+ Raise hand
- You are seeing User 2 screen
+ You are viewing User 2's screen
- Chat thread
+ Speaker
-
- Speaker
+ Raise hand
- Chat thread
+ Speaker
-
- Speaker
+ Raise hand
{
screenOn: '',
threadId: false,
},
+ participants: [{
+ id: 'user-1-id',
+ muted: false,
+ isTalking: false,
+ profile: {
+ id: 'user-1-id',
+ username: 'user-1-username',
+ nickname: 'User 1',
+ },
+ },
+ {
+ id: 'user-2-id',
+ muted: true,
+ isTalking: true,
+ profile: {
+ id: 'user-2-id',
+ username: 'user-2-username',
+ nickname: 'User 2',
+ },
+ }],
currentParticipant: {
id: 'user-2-id',
muted: true,
diff --git a/app/products/calls/screens/call/call_screen.tsx b/app/products/calls/screens/call/call_screen.tsx
index 157016039..d33d00508 100644
--- a/app/products/calls/screens/call/call_screen.tsx
+++ b/app/products/calls/screens/call/call_screen.tsx
@@ -15,14 +15,15 @@ import {
} from 'react-native';
import {RTCView} from 'react-native-webrtc';
-import {showModalOverCurrentContext, mergeNavigationOptions, popTopScreen, goToScreen} from '@actions/navigation';
+import {showModalOverCurrentContext, mergeNavigationOptions, popTopScreen} from '@actions/navigation';
import CompassIcon from '@components/compass_icon';
import {WebsocketEvents} from '@constants';
-import {THREAD} from '@constants/screen';
import {ActionFunc, GenericAction} from '@mm-redux/types/actions';
import {displayUsername} from '@mm-redux/utils/user_utils';
import CallAvatar from '@mmproducts/calls/components/call_avatar';
import CallDuration from '@mmproducts/calls/components/call_duration';
+import RaisedHandIcon from '@mmproducts/calls/components/raised_hand_icon';
+import UnraisedHandIcon from '@mmproducts/calls/components/unraised_hand_icon';
import {makeStyleSheetFromTheme} from '@utils/theme';
import type {Theme} from '@mm-redux/types/theme';
@@ -35,9 +36,12 @@ type Props = {
unmuteMyself: (channelId: string) => GenericAction;
setSpeakerphoneOn: (newState: boolean) => GenericAction;
leaveCall: () => ActionFunc;
+ raiseHand: () => GenericAction;
+ unraiseHand: () => GenericAction;
};
theme: Theme;
call: Call | null;
+ participants: CallParticipant[];
currentParticipant: CallParticipant;
teammateNameDisplay: string;
screenShareURL: string;
@@ -72,7 +76,9 @@ const getStyleSheet = makeStyleSheetFromTheme((props: any) => {
const header: any = {
flexDirection: 'row',
width: '100%',
- padding: 14,
+ paddingTop: 10,
+ paddingLeft: 14,
+ paddingRight: 14,
...Platform.select({
android: {
elevation: 4,
@@ -165,6 +171,18 @@ const getStyleSheet = makeStyleSheetFromTheme((props: any) => {
marginLeft: 10,
marginRight: 10,
},
+ handIcon: {
+ borderRadius: 34,
+ padding: 34,
+ margin: 10,
+ overflow: 'hidden',
+ backgroundColor: props.currentParticipant?.raisedHand ? 'rgba(255, 188, 66, 0.16)' : 'rgba(255,255,255,0.12)',
+ },
+ handIconSvgStyle: {
+ position: 'relative',
+ top: -12,
+ right: 13,
+ },
speakerphoneIcon: {
color: props.speakerphoneOn ? 'black' : props.theme.sidebarText,
backgroundColor: props.speakerphoneOn ? 'white' : 'rgba(255,255,255,0.12)',
@@ -259,7 +277,11 @@ const CallScreen = (props: Props) => {
const showOtherActions = () => {
const screen = 'CallOtherActions';
- const passProps = {};
+ const passProps = {
+ theme: props.theme,
+ channelId: props.call?.channelId,
+ rootId: props.call?.threadId,
+ };
Keyboard.dismiss();
const otherActionsRequest = requestAnimationFrame(() => {
@@ -275,14 +297,6 @@ const CallScreen = (props: Props) => {
props.actions.leaveCall();
}, [props.actions.leaveCall]);
- const openThreadHandler = useCallback(() => {
- const passProps = {
- channelId: props.call?.channelId,
- rootId: props.call?.threadId,
- };
- goToScreen(THREAD, '', passProps);
- }, [props.call]);
-
const muteUnmuteHandler = useCallback(() => {
if (props.call) {
if (props.currentParticipant?.muted) {
@@ -293,9 +307,17 @@ const CallScreen = (props: Props) => {
}
}, [props.call?.channelId, props.currentParticipant]);
- const toggleSpeakerphoneHandler = () => {
+ const toggleSpeakerphoneHandler = useCallback(() => {
props.actions.setSpeakerphoneOn(!props.speakerphoneOn);
- };
+ }, [props.speakerphoneOn]);
+
+ const toggleRaiseHand = useCallback(() => {
+ if (props.currentParticipant?.raisedHand > 0) {
+ props.actions.unraiseHand();
+ } else {
+ props.actions.raiseHand();
+ }
+ }, [props.currentParticipant?.raisedHand]);
const toggleControlsInLandscape = useCallback(() => {
setShowControlsInLandscape(!showControlsInLandscape);
@@ -317,9 +339,9 @@ const CallScreen = (props: Props) => {
streamURL={props.screenShareURL}
style={style.screenShareImage}
/>
- {`You are seeing ${displayUsername(props.call.participants[props.call.screenOn].profile, props.teammateNameDisplay)} screen`}
+
+ {`You are viewing ${displayUsername(props.call.participants[props.call.screenOn].profile, props.teammateNameDisplay)}'s screen`}
+
);
}
@@ -336,7 +358,7 @@ const CallScreen = (props: Props) => {
onPress={toggleControlsInLandscape}
style={style.users}
>
- {Object.values(props.call.participants).map((user) => {
+ {props.participants.map((user) => {
return (
{
userId={user.id}
volume={speaker && speaker.id === user.id ? 1 : 0}
muted={user.muted}
+ sharingScreen={user.id === props.call?.screenOn}
+ raisedHand={Boolean(user.raisedHand)}
size={props.call?.screenOn ? 'm' : 'l'}
/>
- {displayUsername(props.call?.participants[user.id].profile, props.teammateNameDisplay)}
+ {displayUsername(user.profile, props.teammateNameDisplay)}
);
@@ -359,6 +383,8 @@ const CallScreen = (props: Props) => {
);
}
+ const HandIcon = props.currentParticipant?.raisedHand ? UnraisedHandIcon : RaisedHandIcon;
+
return (
@@ -410,17 +436,6 @@ const CallScreen = (props: Props) => {
/>
{'Leave'}
-
-
- {'Chat thread'}
-
{
{'Speaker'}
+
+
+
+ {props.currentParticipant?.raisedHand ? 'Lower hand' : 'Raise hand'}
+
+
-
- {
+const CallOtherActions = ({theme, channelId, rootId}: Props) => {
const close = () => {
dismissModal();
};
- // TODO: Implement this whenever we support participants invitation to calls
- const addParticipants = useCallback(() => null, []);
-
- // TODO: Implement this whenever we support calls links
- const copyCallLink = useCallback(() => null, []);
-
- // TODO: Implement this whenever we support give feedback
- const giveFeedback = useCallback(() => null, []);
+ const chatThread = () => {
+ goToScreen(THREAD, '', {channelId, rootId});
+ return null;
+ };
return (
@@ -38,23 +37,9 @@ const CallOtherActions = ({theme}: Props) => {
>
-
-
diff --git a/app/products/calls/store/action_types/calls.ts b/app/products/calls/store/action_types/calls.ts
index 5b5f14c7c..e9f5ce0db 100644
--- a/app/products/calls/store/action_types/calls.ts
+++ b/app/products/calls/store/action_types/calls.ts
@@ -19,8 +19,8 @@ export default keyMirror({
RECEIVED_UNMUTE_USER_CALL: null,
RECEIVED_VOICE_ON_USER_CALL: null,
RECEIVED_VOICE_OFF_USER_CALL: null,
- RECEIVED_RAISE_HAND_CALL: null,
- RECEIVED_UNRAISE_HAND_CALL: null,
+ RECEIVED_RAISE_HAND: null,
+ RECEIVED_UNRAISE_HAND: null,
SET_SCREENSHARE_URL: null,
SET_SPEAKERPHONE: null,
});
diff --git a/app/products/calls/store/actions/calls.ts b/app/products/calls/store/actions/calls.ts
index e608b43ff..098b5cb44 100644
--- a/app/products/calls/store/actions/calls.ts
+++ b/app/products/calls/store/actions/calls.ts
@@ -35,7 +35,8 @@ export function loadCalls(): ActionFunc {
participants: channel.call.users.reduce((prev: Dictionary, cur: string, curIdx: number) => {
const profile = getState().entities.users.profiles[cur];
const muted = channel.call.states && channel.call.states[curIdx] ? !channel.call.states[curIdx].unmuted : true;
- prev[cur] = {id: cur, muted, isTalking: false, profile};
+ const raised_hand = channel.call.states && channel.call.states[curIdx] ? channel.call.states[curIdx].raised_hand : 0;
+ prev[cur] = {id: cur, muted, raisedHand: raised_hand, isTalking: false, profile};
return prev;
}, {}),
channelId: channel.channel_id,
@@ -155,6 +156,22 @@ export function unmuteMyself(): GenericAction {
return {type: 'empty'};
}
+export function raiseHand(): GenericAction {
+ if (ws) {
+ ws.raiseHand();
+ }
+
+ return {type: 'empty'};
+}
+
+export function unraiseHand(): GenericAction {
+ if (ws) {
+ ws.unraiseHand();
+ }
+
+ return {type: 'empty'};
+}
+
export function setSpeakerphoneOn(newState: boolean): GenericAction {
InCallManager.setSpeakerphoneOn(newState);
return {
diff --git a/app/products/calls/store/actions/websockets.ts b/app/products/calls/store/actions/websockets.ts
index 972bc7846..1739696dc 100644
--- a/app/products/calls/store/actions/websockets.ts
+++ b/app/products/calls/store/actions/websockets.ts
@@ -81,3 +81,17 @@ export function handleCallScreenOff(msg: WebSocketMessage): GenericAction {
data: {channelId: msg.broadcast.channel_id, userId: msg.data.userID},
};
}
+
+export function handleCallUserRaiseHand(msg: WebSocketMessage): GenericAction {
+ return {
+ type: CallsTypes.RECEIVED_RAISE_HAND,
+ data: {channelId: msg.broadcast.channel_id, userId: msg.data.userID, ts: msg.data.raised_hand},
+ };
+}
+
+export function handleCallUserUnraiseHand(msg: WebSocketMessage): GenericAction {
+ return {
+ type: CallsTypes.RECEIVED_UNRAISE_HAND,
+ data: {channelId: msg.broadcast.channel_id, userId: msg.data.userID, ts: msg.data.raised_hand},
+ };
+}
diff --git a/app/products/calls/store/reducers/calls.test.js b/app/products/calls/store/reducers/calls.test.js
index 64f91d47e..2ae9e02d2 100644
--- a/app/products/calls/store/reducers/calls.test.js
+++ b/app/products/calls/store/reducers/calls.test.js
@@ -10,8 +10,8 @@ import callsReducer from './calls';
describe('Reducers.calls.calls', () => {
const call1 = {
participants: {
- 'user-1': {id: 'user-1', muted: false, isTalking: false, profile: {id: 'user-1'}},
- 'user-2': {id: 'user-2', muted: true, isTalking: true, profile: {id: 'user-2'}},
+ 'user-1': {id: 'user-1', muted: false, raisedHand: 0, isTalking: false, profile: {id: 'user-1'}},
+ 'user-2': {id: 'user-2', muted: true, raisedHand: 0, isTalking: true, profile: {id: 'user-2'}},
},
channelId: 'channel-1',
startTime: 123,
@@ -21,8 +21,8 @@ describe('Reducers.calls.calls', () => {
};
const call2 = {
participants: {
- 'user-3': {id: 'user-3', muted: false, isTalking: false, profile: {id: 'user-3'}},
- 'user-4': {id: 'user-4', muted: true, isTalking: true, profile: {id: 'user-4'}},
+ 'user-3': {id: 'user-3', muted: false, raisedHand: 0, isTalking: false, profile: {id: 'user-3'}},
+ 'user-4': {id: 'user-4', muted: true, raisedHand: 0, isTalking: true, profile: {id: 'user-4'}},
},
channelId: 'channel-2',
startTime: 123,
@@ -32,8 +32,8 @@ describe('Reducers.calls.calls', () => {
};
const call3 = {
participants: {
- 'user-5': {id: 'user-5', muted: false, isTalking: false, profile: {id: 'user-5'}},
- 'user-6': {id: 'user-6', muted: true, isTalking: true, profile: {id: 'user-6'}},
+ 'user-5': {id: 'user-5', muted: false, raisedHand: 0, isTalking: false, profile: {id: 'user-5'}},
+ 'user-6': {id: 'user-6', muted: true, raisedHand: 0, isTalking: true, profile: {id: 'user-6'}},
},
channelId: 'channel-3',
startTime: 123,
@@ -71,7 +71,7 @@ describe('Reducers.calls.calls', () => {
{
'channel-1': {
participants: {
- 'user-2': {id: 'user-2', muted: true, isTalking: true, profile: {id: 'user-2'}},
+ 'user-2': {id: 'user-2', muted: true, raisedHand: 0, isTalking: true, profile: {id: 'user-2'}},
},
channelId: 'channel-1',
startTime: 123,
@@ -105,9 +105,9 @@ describe('Reducers.calls.calls', () => {
{
'channel-1': {
participants: {
- 'user-1': {id: 'user-1', muted: false, isTalking: false, profile: {id: 'user-1'}},
- 'user-2': {id: 'user-2', muted: true, isTalking: true, profile: {id: 'user-2'}},
- 'user-3': {id: 'user-3', muted: true, isTalking: false, profile: {id: 'user-3'}},
+ 'user-1': {id: 'user-1', muted: false, raisedHand: 0, isTalking: false, profile: {id: 'user-1'}},
+ 'user-2': {id: 'user-2', muted: true, raisedHand: 0, isTalking: true, profile: {id: 'user-2'}},
+ 'user-3': {id: 'user-3', muted: true, raisedHand: 0, isTalking: false, profile: {id: 'user-3'}},
},
channelId: 'channel-1',
startTime: 123,
@@ -209,6 +209,74 @@ describe('Reducers.calls.calls', () => {
state = callsReducer(initialState, testAction);
assert.deepEqual(state.calls, initialState.calls);
});
+ it('RECEIVED_RAISE_HAND', async () => {
+ const initialState = {calls: {'channel-1': call1}};
+ const testAction = {
+ type: CallsTypes.RECEIVED_RAISE_HAND,
+ data: {channelId: 'channel-1', userId: 'user-2', ts: 345},
+ };
+ let state = callsReducer(initialState, testAction);
+ assert.deepEqual(
+ state.calls,
+ {
+ 'channel-1': {
+ participants: {
+ 'user-1': {id: 'user-1', muted: false, raisedHand: 0, isTalking: false, profile: {id: 'user-1'}},
+ 'user-2': {id: 'user-2', muted: true, raisedHand: 345, isTalking: true, profile: {id: 'user-2'}},
+ },
+ channelId: 'channel-1',
+ startTime: 123,
+ speakers: ['user-2'],
+ screenOn: false,
+ threadId: 'thread-1',
+ },
+ },
+ );
+
+ testAction.data = {channelId: 'invalid-channel', userId: 'user-1', ts: 345};
+
+ state = callsReducer(initialState, testAction);
+ assert.deepEqual(state.calls, {'channel-1': call1});
+ });
+ it('RECEIVED_UNRAISE_HAND', async () => {
+ const initialState = {
+ calls: {
+ 'channel-1': {
+ ...call1,
+ participants: {
+ ...call1.participants,
+ 'user-1': {...call1.participants['user-1'], raisedHand: 345},
+ },
+ },
+ },
+ };
+ const testAction = {
+ type: CallsTypes.RECEIVED_UNRAISE_HAND,
+ data: {channelId: 'channel-1', userId: 'user-1', ts: 0},
+ };
+ let state = callsReducer(initialState, testAction);
+ assert.deepEqual(
+ state.calls,
+ {
+ 'channel-1': {
+ participants: {
+ 'user-1': {id: 'user-1', muted: false, raisedHand: 0, isTalking: false, profile: {id: 'user-1'}},
+ 'user-2': {id: 'user-2', muted: true, raisedHand: 0, isTalking: true, profile: {id: 'user-2'}},
+ },
+ channelId: 'channel-1',
+ startTime: 123,
+ speakers: ['user-2'],
+ screenOn: false,
+ threadId: 'thread-1',
+ },
+ },
+ );
+
+ testAction.data = {channelId: 'invalid-channel', userId: 'user-1', ts: 0};
+
+ state = callsReducer(initialState, testAction);
+ assert.deepEqual(state.calls, initialState.calls);
+ });
});
describe('Reducers.calls.joined', () => {
diff --git a/app/products/calls/store/reducers/calls.ts b/app/products/calls/store/reducers/calls.ts
index 0c1f401ef..762ac6390 100644
--- a/app/products/calls/store/reducers/calls.ts
+++ b/app/products/calls/store/reducers/calls.ts
@@ -40,6 +40,7 @@ function calls(state: Dictionary = {}, action: GenericAction) {
id: userId,
muted: true,
isTalking: false,
+ raisedHand: 0,
profile,
};
const nextState = {...state};
@@ -88,6 +89,36 @@ function calls(state: Dictionary = {}, action: GenericAction) {
nextState[channelId] = channelUpdate;
return nextState;
}
+ case CallsTypes.RECEIVED_RAISE_HAND: {
+ const {channelId, userId, ts} = action.data;
+ if (!state[channelId]) {
+ return state;
+ }
+ if (!state[channelId].participants[userId]) {
+ return state;
+ }
+ const userUpdate = {...state[channelId].participants[userId], raisedHand: ts};
+ const channelUpdate = {...state[channelId], participants: {...state[channelId].participants}};
+ channelUpdate.participants[userId] = userUpdate;
+ const nextState = {...state};
+ nextState[channelId] = channelUpdate;
+ return nextState;
+ }
+ case CallsTypes.RECEIVED_UNRAISE_HAND: {
+ const {channelId, userId, ts} = action.data;
+ if (!state[channelId]) {
+ return state;
+ }
+ if (!state[channelId].participants[userId]) {
+ return state;
+ }
+ const userUpdate = {...state[channelId].participants[userId], raisedHand: ts};
+ const channelUpdate = {...state[channelId], participants: {...state[channelId].participants}};
+ channelUpdate.participants[userId] = userUpdate;
+ const nextState = {...state};
+ nextState[channelId] = channelUpdate;
+ return nextState;
+ }
case CallsTypes.RECEIVED_CHANNEL_CALL_SCREEN_ON: {
const {channelId, userId} = action.data;
if (!state[channelId]) {
diff --git a/app/products/calls/store/types/calls.ts b/app/products/calls/store/types/calls.ts
index 540d42392..7c21509d8 100644
--- a/app/products/calls/store/types/calls.ts
+++ b/app/products/calls/store/types/calls.ts
@@ -25,6 +25,7 @@ export type CallParticipant = {
id: string;
muted: boolean;
isTalking: boolean;
+ raisedHand: number;
profile: UserProfile;
}
diff --git a/app/products/calls/utils.ts b/app/products/calls/utils.ts
new file mode 100644
index 000000000..3c9a0d261
--- /dev/null
+++ b/app/products/calls/utils.ts
@@ -0,0 +1,50 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import {Dictionary} from '@mm-redux/types/utilities';
+import {displayUsername} from '@mm-redux/utils/user_utils';
+import {CallParticipant} from '@mmproducts/calls/store/types/calls';
+
+export function sortParticipants(teammateNameDisplay: string, participants?: Dictionary, presenterID?: string): CallParticipant[] {
+ if (!participants) {
+ return [];
+ }
+
+ const users = Object.values(participants);
+
+ return users.sort(sortByName(teammateNameDisplay)).sort(sortByState(presenterID));
+}
+
+const sortByName = (teammateNameDisplay: string) => {
+ return (a: CallParticipant, b: CallParticipant) => {
+ const nameA = displayUsername(a.profile, teammateNameDisplay);
+ const nameB = displayUsername(b.profile, teammateNameDisplay);
+ return nameA.localeCompare(nameB);
+ };
+};
+
+const sortByState = (presenterID?: string) => {
+ return (a: CallParticipant, b: CallParticipant) => {
+ if (a.id === presenterID) {
+ return -1;
+ } else if (b.id === presenterID) {
+ return 1;
+ }
+
+ if (!a.muted && b.muted) {
+ return -1;
+ } else if (!b.muted && a.muted) {
+ return 1;
+ }
+
+ if (a.raisedHand && !b.raisedHand) {
+ return -1;
+ } else if (b.raisedHand && !a.raisedHand) {
+ return 1;
+ } else if (a.raisedHand && b.raisedHand) {
+ return a.raisedHand - b.raisedHand;
+ }
+
+ return 0;
+ };
+};