From e086b19d1ed4faacf87cf5edeb181d8c4cfc235b Mon Sep 17 00:00:00 2001 From: Christopher Poile Date: Fri, 25 Mar 2022 15:08:28 -0400 Subject: [PATCH] MM-42360 - Calls Mobile: Implement raise hand (#6069) * implement raise hand * added tests; updated tests * spread syntax for combined styles -> array syntax * add useCallbacks, update test snapshots * fix dep array * fix dep array * sort participants based on muted and raisedHand * sort participants by name, then by state * fix screen sharing message * fix test snapshots * add monitor indicator; tweak ui * linting --- app/actions/websocket/index.ts | 6 +- app/constants/websocket.ts | 2 + .../__snapshots__/call_avatar.test.js.snap | 20 +- app/products/calls/components/call_avatar.tsx | 105 +++- .../calls/components/raised_hand_icon.tsx | 35 ++ .../calls/components/unraised_hand_icon.tsx | 35 ++ app/products/calls/connection.ts | 14 + .../__snapshots__/call_screen.test.js.snap | 480 ++++++++++++------ .../calls/screens/call/call_screen.test.js | 20 + .../calls/screens/call/call_screen.tsx | 92 ++-- app/products/calls/screens/call/index.ts | 17 +- .../call_other_actions.test.js.snap | 74 +-- .../call_other_actions/call_other_actions.tsx | 41 +- .../calls/store/action_types/calls.ts | 4 +- app/products/calls/store/actions/calls.ts | 19 +- .../calls/store/actions/websockets.ts | 14 + .../calls/store/reducers/calls.test.js | 88 +++- app/products/calls/store/reducers/calls.ts | 31 ++ app/products/calls/store/types/calls.ts | 1 + app/products/calls/utils.ts | 50 ++ 20 files changed, 818 insertions(+), 330 deletions(-) create mode 100644 app/products/calls/components/raised_hand_icon.tsx create mode 100644 app/products/calls/components/unraised_hand_icon.tsx create mode 100644 app/products/calls/utils.ts 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; + }; +};