[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
This commit is contained in:
Christopher Poile 2024-06-06 15:39:15 -04:00 committed by GitHub
parent f46ddb49d0
commit c93e16218a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 176 additions and 48 deletions

View file

@ -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) => {

View file

@ -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
</TouchableOpacity>
);
const joiningButton = (
<Loading
color={theme.buttonColor}
size={'small'}
footerText={joiningMsg}
containerStyle={[style.callButton, style.joinCallButton]}
footerTextStyles={style.buttonText}
/>
);
return (
<>
{title}
@ -248,7 +272,7 @@ export const CallsCustomMessage = ({post, currentUser, isMilitaryTime, ccChannel
style={style.timeText}
/>
</View>
{button}
{joiningThisCall ? joiningButton : button}
</View>
</>
);

View file

@ -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,
};
});

View file

@ -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 (
<Loading
color={theme.buttonBg}
size={'small'}
footerText={joiningMsg}
containerStyle={styles.container}
footerTextStyles={styles.text}
/>
);
}
return (
<OptionBox
onPress={preventDoubleTap(tryJoin)}
text={startText + msgPostfix}
iconName='phone'
activeText={joinText + msgPostfix}
activeIconName='phone-in-talk'
text={text}
iconName={icon}
activeText={text}
activeIconName={icon}
isActive={isACallInCurrentChannel}
destructiveText={leaveText}
destructiveIconName={'phone-hangup'}

View file

@ -7,7 +7,7 @@ import {View, Pressable} from 'react-native';
import {dismissIncomingCall} from '@calls/actions';
import {leaveAndJoinWithAlert, showLimitRestrictedAlert} from '@calls/alerts';
import {removeIncomingCall} from '@calls/state';
import {removeIncomingCall, setJoiningChannelId} from '@calls/state';
import CompassIcon from '@components/compass_icon';
import FormattedRelativeTime from '@components/formatted_relative_time';
import FormattedText from '@components/formatted_text';
@ -148,7 +148,10 @@ const JoinCallBanner = ({
showLimitRestrictedAlert(limitRestrictedInfo, intl);
return;
}
leaveAndJoinWithAlert(intl, serverUrl, channelId);
setJoiningChannelId(channelId);
await leaveAndJoinWithAlert(intl, serverUrl, channelId);
setJoiningChannelId(null);
};
const onDismissPress = () => {

View file

@ -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,

View file

@ -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) {

View file

@ -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 = {

View file

@ -475,6 +475,7 @@
"mobile.calls_incoming_gm": "<b>{name}</b> is inviting you to a call with <b>{num, plural, one {# other} other {# others}}</b>",
"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",