From c93e16218a946ebe68078656311aa33e02546c16 Mon Sep 17 00:00:00 2001 From: Christopher Poile Date: Thu, 6 Jun 2024 15:39:15 -0400 Subject: [PATCH] [MM-57250] Calls: Provide feedback after user clicks Start call (#7967) * make joinCall async; adding loading spinner to join/start button * always close channelInfo * i18n * change how we compute active text * use isPreferred for leave & join call button * add joiningChannelId to calls state to track when joining a call --- app/products/calls/alerts.ts | 69 ++++++++++-------- .../calls_custom_message.tsx | 32 +++++++-- .../components/calls_custom_message/index.ts | 7 +- .../channel_info_start_button.tsx | 71 ++++++++++++++++--- .../join_call_banner/join_call_banner.tsx | 7 +- app/products/calls/state/actions.test.ts | 26 +++++++ app/products/calls/state/actions.ts | 8 +++ app/products/calls/types/calls.ts | 2 + assets/base/i18n/en.json | 2 + 9 files changed, 176 insertions(+), 48 deletions(-) diff --git a/app/products/calls/alerts.ts b/app/products/calls/alerts.ts index 58b0a0938..308118a29 100644 --- a/app/products/calls/alerts.ts +++ b/app/products/calls/alerts.ts @@ -102,7 +102,7 @@ export const leaveAndJoinWithAlert = async ( } } catch (error) { logError('failed to getServerDatabase in leaveAndJoinWithAlert', error); - return; + return false; } if (leaveServerUrl && leaveChannelId) { @@ -119,33 +119,38 @@ export const leaveAndJoinWithAlert = async ( }, {leaveChannelName, joinChannelName}); } - Alert.alert( - formatMessage({ - id: 'mobile.leave_and_join_title', - defaultMessage: 'Are you sure you want to switch to a different call?', - }), - joinMessage, - [ - { - text: formatMessage({ - id: 'mobile.post.cancel', - defaultMessage: 'Cancel', - }), - style: 'destructive', - }, - { - text: formatMessage({ - id: 'mobile.leave_and_join_confirmation', - defaultMessage: 'Leave & Join', - }), - onPress: () => doJoinCall(joinServerUrl, joinChannelId, joinChannelIsDMorGM, newCall, intl, title, rootId), - style: 'cancel', - }, - ], - ); - } else { - doJoinCall(joinServerUrl, joinChannelId, joinChannelIsDMorGM, newCall, intl, title, rootId); + const asyncAlert = async () => new Promise((resolve) => { + Alert.alert( + formatMessage({ + id: 'mobile.leave_and_join_title', + defaultMessage: 'Are you sure you want to switch to a different call?', + }), + joinMessage, + [ + { + text: formatMessage({ + id: 'mobile.post.cancel', + defaultMessage: 'Cancel', + }), + onPress: async () => resolve(false), + style: 'destructive', + }, + { + text: formatMessage({ + id: 'mobile.leave_and_join_confirmation', + defaultMessage: 'Leave & Join', + }), + onPress: async () => resolve(await doJoinCall(joinServerUrl, joinChannelId, joinChannelIsDMorGM, newCall, intl, title, rootId)), + isPreferred: true, + }, + ], + ); + }); + + return asyncAlert(); } + + return doJoinCall(joinServerUrl, joinChannelId, joinChannelIsDMorGM, newCall, intl, title, rootId); }; const doJoinCall = async ( @@ -166,7 +171,7 @@ const doJoinCall = async ( user = await getCurrentUser(database); if (!user) { // This shouldn't happen, so don't bother localizing and displaying an alert. - return; + return false; } if (newCall) { @@ -189,12 +194,12 @@ const doJoinCall = async ( // continue through and start the call } else { contactAdminAlert(intl); - return; + return false; } } } catch (error) { logError('failed to getServerDatabaseAndOperator in doJoinCall', error); - return; + return false; } recordingAlertLock = false; @@ -215,12 +220,14 @@ const doJoinCall = async ( if (res.error) { const seeLogs = formatMessage({id: 'mobile.calls_see_logs', defaultMessage: 'See server logs'}); errorAlert(res.error?.toString() || seeLogs, intl); - return; + return false; } if (joinChannelIsDMorGM) { unmuteMyself(); } + + return true; }; const contactAdminAlert = ({formatMessage}: IntlShape) => { diff --git a/app/products/calls/components/calls_custom_message/calls_custom_message.tsx b/app/products/calls/components/calls_custom_message/calls_custom_message.tsx index 63df36327..e233a18cd 100644 --- a/app/products/calls/components/calls_custom_message/calls_custom_message.tsx +++ b/app/products/calls/components/calls_custom_message/calls_custom_message.tsx @@ -8,10 +8,12 @@ import {Text, TouchableOpacity, View} from 'react-native'; import {leaveCall} from '@calls/actions'; import {leaveAndJoinWithAlert, showLimitRestrictedAlert} from '@calls/alerts'; +import {setJoiningChannelId} from '@calls/state'; import CompassIcon from '@components/compass_icon'; import FormattedRelativeTime from '@components/formatted_relative_time'; import FormattedText from '@components/formatted_text'; import FormattedTime from '@components/formatted_time'; +import Loading from '@components/loading'; import {useServerUrl} from '@context/server'; import {useTheme} from '@context/theme'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; @@ -28,6 +30,7 @@ type Props = { isMilitaryTime: boolean; limitRestrictedInfo?: LimitRestrictedInfo; ccChannelId?: string; + joiningChannelId: string | null; } const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { @@ -121,23 +124,34 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { }; }); -export const CallsCustomMessage = ({post, currentUser, isMilitaryTime, ccChannelId, limitRestrictedInfo}: Props) => { +export const CallsCustomMessage = ({ + post, + currentUser, + isMilitaryTime, + ccChannelId, + limitRestrictedInfo, + joiningChannelId, +}: Props) => { const intl = useIntl(); const theme = useTheme(); const style = getStyleSheet(theme); const serverUrl = useServerUrl(); const timezone = getUserTimezone(currentUser); + const joiningThisCall = Boolean(joiningChannelId === post.channelId); const alreadyInTheCall = Boolean(ccChannelId && ccChannelId === post.channelId); const isLimitRestricted = Boolean(limitRestrictedInfo?.limitRestricted); + const joiningMsg = intl.formatMessage({id: 'mobile.calls_joining', defaultMessage: 'Joining...'}); - const joinHandler = useCallback(() => { + const joinHandler = useCallback(async () => { if (isLimitRestricted) { showLimitRestrictedAlert(limitRestrictedInfo!, intl); return; } - leaveAndJoinWithAlert(intl, serverUrl, post.channelId); + setJoiningChannelId(post.channelId); + await leaveAndJoinWithAlert(intl, serverUrl, post.channelId); + setJoiningChannelId(null); }, [limitRestrictedInfo, intl, serverUrl, post.channelId]); const leaveHandler = useCallback(() => { @@ -227,6 +241,16 @@ export const CallsCustomMessage = ({post, currentUser, isMilitaryTime, ccChannel ); + const joiningButton = ( + + ); + return ( <> {title} @@ -248,7 +272,7 @@ export const CallsCustomMessage = ({post, currentUser, isMilitaryTime, ccChannel style={style.timeText} /> - {button} + {joiningThisCall ? joiningButton : button} ); diff --git a/app/products/calls/components/calls_custom_message/index.ts b/app/products/calls/components/calls_custom_message/index.ts index 6368f17b6..c50763bf7 100644 --- a/app/products/calls/components/calls_custom_message/index.ts +++ b/app/products/calls/components/calls_custom_message/index.ts @@ -7,7 +7,7 @@ import {distinctUntilChanged, switchMap} from 'rxjs/operators'; import {CallsCustomMessage} from '@calls/components/calls_custom_message/calls_custom_message'; import {observeIsCallLimitRestricted} from '@calls/observers'; -import {observeCurrentCall} from '@calls/state'; +import {observeCurrentCall, observeGlobalCallsState} from '@calls/state'; import {Preferences} from '@constants'; import {getDisplayNamePreferenceAsBool} from '@helpers/api/preference'; import {queryDisplayNamePreferences} from '@queries/servers/preference'; @@ -41,12 +41,17 @@ const enhanced = withObservables(['post'], ({serverUrl, post, database}: OwnProp switchMap((call) => of$(call?.channelId)), distinctUntilChanged(), ); + const joiningChannelId = observeGlobalCallsState().pipe( + switchMap((state) => of$(state?.joiningChannelId)), + distinctUntilChanged(), + ); return { currentUser, isMilitaryTime, limitRestrictedInfo: observeIsCallLimitRestricted(database, serverUrl, post.channelId), ccChannelId, + joiningChannelId, }; }); diff --git a/app/products/calls/components/channel_info_start/channel_info_start_button.tsx b/app/products/calls/components/channel_info_start/channel_info_start_button.tsx index 26cd872c9..a79d4146a 100644 --- a/app/products/calls/components/channel_info_start/channel_info_start_button.tsx +++ b/app/products/calls/components/channel_info_start/channel_info_start_button.tsx @@ -1,14 +1,18 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React, {useCallback} from 'react'; +import React, {useCallback, useState} from 'react'; import {useIntl} from 'react-intl'; import {leaveCall} from '@calls/actions'; import {leaveAndJoinWithAlert, showLimitRestrictedAlert} from '@calls/alerts'; import {useTryCallsFunction} from '@calls/hooks'; -import OptionBox from '@components/option_box'; +import Loading from '@components/loading'; +import OptionBox, {OPTIONS_HEIGHT} from '@components/option_box'; +import {useTheme} from '@context/theme'; import {preventDoubleTap} from '@utils/tap'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; +import {typography} from '@utils/typography'; import type {LimitRestrictedInfo} from '@calls/observers'; @@ -21,6 +25,27 @@ export interface Props { limitRestrictedInfo: LimitRestrictedInfo; } +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ + container: { + alignItems: 'center', + backgroundColor: changeOpacity(theme.buttonBg, 0.08), + borderRadius: 4, + flex: 1, + maxHeight: OPTIONS_HEIGHT, + justifyContent: 'center', + minWidth: 60, + paddingTop: 12, + paddingBottom: 10, + }, + text: { + color: theme.buttonBg, + paddingTop: 3, + width: '100%', + textAlign: 'center', + ...typography('Body', 50, 'SemiBold'), + }, +})); + const ChannelInfoStartButton = ({ serverUrl, channelId, @@ -30,18 +55,30 @@ const ChannelInfoStartButton = ({ limitRestrictedInfo, }: Props) => { const intl = useIntl(); + const theme = useTheme(); + const styles = getStyleSheet(theme); const isLimitRestricted = limitRestrictedInfo.limitRestricted; + const [connecting, setConnecting] = useState(false); + const [joiningMsg, setJoiningMsg] = useState(''); - const toggleJoinLeave = useCallback(() => { + const starting = intl.formatMessage({id: 'mobile.calls_starting', defaultMessage: 'Starting...'}); + const joining = intl.formatMessage({id: 'mobile.calls_joining', defaultMessage: 'Joining...'}); + + const toggleJoinLeave = useCallback(async () => { if (alreadyInCall) { leaveCall(); + dismissChannelInfo(); } else if (isLimitRestricted) { showLimitRestrictedAlert(limitRestrictedInfo, intl); + dismissChannelInfo(); } else { - leaveAndJoinWithAlert(intl, serverUrl, channelId); - } + setJoiningMsg(isACallInCurrentChannel ? joining : starting); + setConnecting(true); - dismissChannelInfo(); + await leaveAndJoinWithAlert(intl, serverUrl, channelId); + setConnecting(false); + dismissChannelInfo(); + } }, [isLimitRestricted, alreadyInCall, dismissChannelInfo, intl, serverUrl, channelId, isACallInCurrentChannel]); const [tryJoin, msgPostfix] = useTryCallsFunction(toggleJoinLeave); @@ -49,14 +86,28 @@ const ChannelInfoStartButton = ({ const joinText = intl.formatMessage({id: 'mobile.calls_join_call', defaultMessage: 'Join call'}); const startText = intl.formatMessage({id: 'mobile.calls_start_call', defaultMessage: 'Start call'}); const leaveText = intl.formatMessage({id: 'mobile.calls_leave_call', defaultMessage: 'Leave call'}); + const text = isACallInCurrentChannel ? joinText + msgPostfix : startText + msgPostfix; + const icon = isACallInCurrentChannel ? 'phone-in-talk' : 'phone'; + + if (connecting) { + return ( + + ); + } return ( { diff --git a/app/products/calls/state/actions.test.ts b/app/products/calls/state/actions.test.ts index e0c96822a..bd89327c3 100644 --- a/app/products/calls/state/actions.test.ts +++ b/app/products/calls/state/actions.test.ts @@ -19,6 +19,7 @@ import { setChannelsWithCalls, setCurrentCall, setHost, + setJoiningChannelId, setMicPermissionsErrorDismissed, setMicPermissionsGranted, setRecordingState, @@ -919,6 +920,7 @@ describe('useCallsState', () => { }; const expectedGlobalState: GlobalCallsState = { micPermissionsGranted: true, + joiningChannelId: null, }; // setup @@ -955,11 +957,35 @@ describe('useCallsState', () => { act(() => { myselfLeftCall(); userLeftCall('server1', 'channel-1', 'mySessionId'); + + // reset state to default + setMicPermissionsGranted(false); }); assert.deepEqual(result.current[0], initialCallsState); assert.deepEqual(result.current[1], null); }); + it('joining call', () => { + const initialGlobalState = DefaultGlobalCallsState; + const expectedGlobalState: GlobalCallsState = { + ...DefaultGlobalCallsState, + joiningChannelId: 'channel-1', + }; + + // setup + const {result} = renderHook(() => { + return [useGlobalCallsState()]; + }); + + // start joining call + act(() => setJoiningChannelId('channel-1')); + assert.deepEqual(result.current[0], expectedGlobalState); + + // end joining call + act(() => setJoiningChannelId(null)); + assert.deepEqual(result.current[0], initialGlobalState); + }); + it('CallQuality', async () => { const initialCallsState: CallsState = { ...DefaultCallsState, diff --git a/app/products/calls/state/actions.ts b/app/products/calls/state/actions.ts index 5ce391ec4..43064de98 100644 --- a/app/products/calls/state/actions.ts +++ b/app/products/calls/state/actions.ts @@ -564,6 +564,14 @@ export const setSpeakerPhone = (speakerphoneOn: boolean) => { } }; +export const setJoiningChannelId = (joiningChannelId: string | null) => { + const globalCallsState = getGlobalCallsState(); + setGlobalCallsState({ + ...globalCallsState, + joiningChannelId, + }); +}; + export const setAudioDeviceInfo = (info: AudioDeviceInfo) => { const call = getCurrentCall(); if (call) { diff --git a/app/products/calls/types/calls.ts b/app/products/calls/types/calls.ts index 2846ae625..5f455b9d4 100644 --- a/app/products/calls/types/calls.ts +++ b/app/products/calls/types/calls.ts @@ -11,10 +11,12 @@ import type UserModel from '@typings/database/models/servers/user'; export type GlobalCallsState = { micPermissionsGranted: boolean; + joiningChannelId: string | null; } export const DefaultGlobalCallsState: GlobalCallsState = { micPermissionsGranted: false, + joiningChannelId: null, }; export type CallsState = { diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index b9a38edad..34a4b89b8 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -475,6 +475,7 @@ "mobile.calls_incoming_gm": "{name} is inviting you to a call with {num, plural, one {# other} other {# others}}", "mobile.calls_join": "Join", "mobile.calls_join_call": "Join call", + "mobile.calls_joining": "Joining...", "mobile.calls_lasted": "Lasted {duration}", "mobile.calls_leave": "Leave", "mobile.calls_leave_call": "Leave call", @@ -529,6 +530,7 @@ "mobile.calls_start_call": "Start Call", "mobile.calls_start_call_exists": "A call is already ongoing in the channel.", "mobile.calls_started_call": "Call started", + "mobile.calls_starting": "Starting...", "mobile.calls_stop_recording": "Stop Recording", "mobile.calls_stop_screenshare": "Stop screen share", "mobile.calls_tablet": "Tablet",