[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:
parent
f46ddb49d0
commit
c93e16218a
9 changed files with 176 additions and 48 deletions
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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'}
|
||||
|
|
|
|||
|
|
@ -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 = () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue