[MM-57267] Calls: Update post UI (#7965)

* MM-58259 - allow current host to run /end call; fix deprecated test fns

* update call card UI

* update call ended icon

* make joinCall async; adding loading spinner to join/start button

* Revert "make joinCall async; adding loading spinner to join/start button"

This reverts commit bbb136690702c46ce2d4430a6b49aa96f143fb5e.

* shadowRadius 3 -> 1
This commit is contained in:
Christopher Poile 2024-05-24 14:16:28 -04:00 committed by GitHub
parent 5e1c75fd6d
commit 3f7fa8b1a1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 178 additions and 137 deletions

View file

@ -115,12 +115,42 @@ const addFakeCall = (serverUrl: string, channelId: string) => {
const call: Call = {
id: 'call',
sessions: {
a23456abcdefghijklmnopqrs: {sessionId: 'a23456abcdefghijklmnopqrs', userId: 'xohi8cki9787fgiryne716u84o', muted: false, raisedHand: 0},
a12345667890bcdefghijklmn1: {sessionId: 'a12345667890bcdefghijklmn1', userId: 'xohi8cki9787fgiryne716u84o', muted: true, raisedHand: 0},
a12345667890bcdefghijklmn2: {sessionId: 'a12345667890bcdefghijklmn2', userId: 'xohi8cki9787fgiryne716u84o', muted: false, raisedHand: 0},
a12345667890bcdefghijklmn3: {sessionId: 'a12345667890bcdefghijklmn3', userId: 'xohi8cki9787fgiryne716u84o', muted: true, raisedHand: 0},
a12345667890bcdefghijklmn4: {sessionId: 'a12345667890bcdefghijklmn4', userId: 'xohi8cki9787fgiryne716u84o', muted: false, raisedHand: 0},
a12345667890bcdefghijklmn5: {sessionId: 'a12345667890bcdefghijklmn5', userId: 'xohi8cki9787fgiryne716u84o', muted: true, raisedHand: 0},
a23456abcdefghijklmnopqrs: {
sessionId: 'a23456abcdefghijklmnopqrs',
userId: 'xohi8cki9787fgiryne716u84o',
muted: false,
raisedHand: 0,
},
a12345667890bcdefghijklmn1: {
sessionId: 'a12345667890bcdefghijklmn1',
userId: 'xohi8cki9787fgiryne716u84o',
muted: true,
raisedHand: 0,
},
a12345667890bcdefghijklmn2: {
sessionId: 'a12345667890bcdefghijklmn2',
userId: 'xohi8cki9787fgiryne716u84o',
muted: false,
raisedHand: 0,
},
a12345667890bcdefghijklmn3: {
sessionId: 'a12345667890bcdefghijklmn3',
userId: 'xohi8cki9787fgiryne716u84o',
muted: true,
raisedHand: 0,
},
a12345667890bcdefghijklmn4: {
sessionId: 'a12345667890bcdefghijklmn4',
userId: 'xohi8cki9787fgiryne716u84o',
muted: false,
raisedHand: 0,
},
a12345667890bcdefghijklmn5: {
sessionId: 'a12345667890bcdefghijklmn5',
userId: 'xohi8cki9787fgiryne716u84o',
muted: true,
raisedHand: 0,
},
},
channelId,
startTime: (new Date()).getTime(),
@ -198,9 +228,9 @@ describe('Actions.Calls', () => {
assert.equal(response!.data, 'channel-id');
assert.equal((result.current[1] as CurrentCall).channelId, 'channel-id');
expect(newConnection).toBeCalled();
expect(newConnection).toHaveBeenCalled();
expect(newConnection.mock.calls[0][1]).toBe('channel-id');
expect(updateThreadFollowing).toBeCalled();
expect(updateThreadFollowing).toHaveBeenCalled();
await act(async () => {
CallsActions.leaveCall();
@ -229,7 +259,7 @@ describe('Actions.Calls', () => {
assert.equal(response!.data, 'channel-id');
assert.equal((result.current[1] as CurrentCall | null)?.channelId, 'channel-id');
expect(getConnectionForTesting()!.disconnect).not.toBeCalled();
expect(getConnectionForTesting()!.disconnect).not.toHaveBeenCalled();
const disconnectMock = getConnectionForTesting()!.disconnect;
await act(async () => {
@ -239,7 +269,7 @@ describe('Actions.Calls', () => {
myselfLeftCall();
});
expect(disconnectMock).toBeCalled();
expect(disconnectMock).toHaveBeenCalled();
expect(getConnectionForTesting()).toBe(null);
assert.equal((result.current[1] as CurrentCall | null), null);
});
@ -270,7 +300,7 @@ describe('Actions.Calls', () => {
CallsActions.muteMyself();
});
expect(getConnectionForTesting()!.mute).toBeCalled();
expect(getConnectionForTesting()!.mute).toHaveBeenCalled();
await act(async () => {
CallsActions.leaveCall();
@ -303,7 +333,7 @@ describe('Actions.Calls', () => {
CallsActions.unmuteMyself();
});
expect(getConnectionForTesting()!.unmute).toBeCalled();
expect(getConnectionForTesting()!.unmute).toHaveBeenCalled();
await act(async () => {
CallsActions.leaveCall();
@ -319,7 +349,7 @@ describe('Actions.Calls', () => {
await act(async () => {
await CallsActions.loadCalls('server1', 'userId1');
});
expect(mockClient.getCalls).toBeCalled();
expect(mockClient.getCalls).toHaveBeenCalled();
assert.equal((result.current[0] as CallsState).calls['channel-1'].channelId, 'channel-1');
assert.equal((result.current[0] as CallsState).enabled['channel-1'], true);
assert.equal((result.current[1] as ChannelsWithCalls)['channel-1'], true);
@ -347,7 +377,7 @@ describe('Actions.Calls', () => {
await act(async () => {
await CallsActions.loadCalls('server1', 'userId1');
});
expect(mockClient.getCalls).toBeCalled();
expect(mockClient.getCalls).toHaveBeenCalled();
assert.deepEqual((result.current[0] as CallsState), expectedCallsState);
assert.deepEqual((result.current[1] as ChannelsWithCalls), {});
assert.equal((result.current[2] as CurrentCall | null), null);
@ -362,7 +392,7 @@ describe('Actions.Calls', () => {
await act(async () => {
await CallsActions.loadConfig('server1');
});
expect(mockClient.getCallsConfig).toBeCalledWith();
expect(mockClient.getCallsConfig).toHaveBeenCalledWith();
assert.equal(result.current.DefaultEnabled, true);
assert.equal(result.current.AllowEnableCalls, true);
});
@ -374,7 +404,7 @@ describe('Actions.Calls', () => {
await act(async () => {
await CallsActions.enableChannelCalls('server1', 'channel-1', true);
});
expect(mockClient.enableChannelCalls).toBeCalledWith('channel-1', true);
expect(mockClient.enableChannelCalls).toHaveBeenCalledWith('channel-1', true);
assert.equal(result.current.enabled['channel-1'], true);
});
@ -385,13 +415,13 @@ describe('Actions.Calls', () => {
await act(async () => {
await CallsActions.enableChannelCalls('server1', 'channel-1', true);
});
expect(mockClient.enableChannelCalls).toBeCalledWith('channel-1', true);
expect(mockClient.enableChannelCalls).toHaveBeenCalledWith('channel-1', true);
assert.equal(result.current.enabled['channel-1'], true);
mockClient.enableChannelCalls.mockReturnValueOnce({enabled: false});
await act(async () => {
await CallsActions.enableChannelCalls('server1', 'channel-1', false);
});
expect(mockClient.enableChannelCalls).toBeCalledWith('channel-1', false);
expect(mockClient.enableChannelCalls).toHaveBeenCalledWith('channel-1', false);
assert.equal(result.current.enabled['channel-1'], false);
});
@ -400,8 +430,8 @@ describe('Actions.Calls', () => {
await CallsActions.startCallRecording('server1', 'channel-id');
});
expect(mockClient.startCallRecording).toBeCalledWith('channel-id');
expect(needsRecordingErrorAlert).toBeCalled();
expect(mockClient.startCallRecording).toHaveBeenCalledWith('channel-id');
expect(needsRecordingErrorAlert).toHaveBeenCalled();
});
it('stopCallRecording', async () => {
@ -409,9 +439,9 @@ describe('Actions.Calls', () => {
await CallsActions.stopCallRecording('server1', 'channel-id');
});
expect(mockClient.stopCallRecording).toBeCalledWith('channel-id');
expect(needsRecordingErrorAlert).toBeCalled();
expect(needsRecordingWillBePostedAlert).toBeCalled();
expect(mockClient.stopCallRecording).toHaveBeenCalledWith('channel-id');
expect(needsRecordingErrorAlert).toHaveBeenCalled();
expect(needsRecordingWillBePostedAlert).toHaveBeenCalled();
});
it('dismissIncomingCall', async () => {
@ -419,7 +449,7 @@ describe('Actions.Calls', () => {
await CallsActions.dismissIncomingCall('server1', 'channel-id');
});
expect(mockClient.dismissCall).toBeCalledWith('channel-id');
expect(mockClient.dismissCall).toHaveBeenCalledWith('channel-id');
});
it('userLeftChannelErr', async () => {
@ -446,20 +476,20 @@ describe('Actions.Calls', () => {
assert.equal(response!.data, 'channel-id');
assert.equal((result.current[1] as CurrentCall).channelId, 'channel-id');
expect(newConnection).toBeCalled();
expect(newConnection).toHaveBeenCalled();
expect(newConnection.mock.calls[0][1]).toBe('channel-id');
expect(updateThreadFollowing).toBeCalled();
expect(updateThreadFollowing).toHaveBeenCalled();
await act(async () => {
CallsActions.leaveCall(userLeftChannelErr);
});
expect(intl.formatMessage).toBeCalledWith({
expect(intl.formatMessage).toHaveBeenCalledWith({
id: 'mobile.calls_user_left_channel_error_title',
defaultMessage: 'You left the channel',
});
expect(intl.formatMessage).toBeCalledWith({
expect(intl.formatMessage).toHaveBeenCalledWith({
id: 'mobile.calls_user_left_channel_error_message',
defaultMessage: 'You have left the channel, and have been disconnected from the call.',
});
@ -489,20 +519,20 @@ describe('Actions.Calls', () => {
assert.equal(response!.data, 'channel-id');
assert.equal((result.current[1] as CurrentCall).channelId, 'channel-id');
expect(newConnection).toBeCalled();
expect(newConnection).toHaveBeenCalled();
expect(newConnection.mock.calls[0][1]).toBe('channel-id');
expect(updateThreadFollowing).toBeCalled();
expect(updateThreadFollowing).toHaveBeenCalled();
await act(async () => {
CallsActions.leaveCall(userRemovedFromChannelErr);
});
expect(intl.formatMessage).toBeCalledWith({
expect(intl.formatMessage).toHaveBeenCalledWith({
id: 'mobile.calls_user_removed_from_channel_error_title',
defaultMessage: 'You were removed from channel',
});
expect(intl.formatMessage).toBeCalledWith({
expect(intl.formatMessage).toHaveBeenCalledWith({
id: 'mobile.calls_user_removed_from_channel_error_message',
defaultMessage: 'You have been removed from the channel, and have been disconnected from the call.',
});
@ -532,14 +562,14 @@ describe('Actions.Calls', () => {
assert.equal(response!.data, 'channel-id');
assert.equal((result.current[1] as CurrentCall).channelId, 'channel-id');
expect(newConnection).toBeCalled();
expect(newConnection).toHaveBeenCalled();
expect(newConnection.mock.calls[0][1]).toBe('channel-id');
expect(updateThreadFollowing).toBeCalled();
expect(updateThreadFollowing).toHaveBeenCalled();
await act(async () => {
CallsActions.leaveCall(new Error('generic error'));
});
expect(errorAlert).toBeCalled();
expect(errorAlert).toHaveBeenCalled();
});
});

View file

@ -363,7 +363,7 @@ export const canEndCall = async (serverUrl: string, channelId: string) => {
return false;
}
return isSystemAdmin(currentUser.roles) || currentUser.id === call.ownerId;
return isSystemAdmin(currentUser.roles) || currentUser.id === call.hostId;
};
export const getEndCallMessage = async (serverUrl: string, channelId: string, currentUserId: string, intl: IntlShape) => {

View file

@ -2,10 +2,11 @@
// See LICENSE.txt for license information.
import moment from 'moment-timezone';
import React from 'react';
import React, {useCallback} from 'react';
import {useIntl} from 'react-intl';
import {Text, TouchableOpacity, View} from 'react-native';
import {leaveCall} from '@calls/actions';
import {leaveAndJoinWithAlert, showLimitRestrictedAlert} from '@calls/alerts';
import CompassIcon from '@components/compass_icon';
import FormattedRelativeTime from '@components/formatted_relative_time';
@ -15,7 +16,7 @@ import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
import {displayUsername, getUserTimezone} from '@utils/user';
import {getUserTimezone} from '@utils/user';
import type {LimitRestrictedInfo} from '@calls/observers';
import type PostModel from '@typings/database/models/servers/post';
@ -24,9 +25,7 @@ import type UserModel from '@typings/database/models/servers/user';
type Props = {
post: PostModel;
currentUser?: UserModel;
author?: UserModel;
isMilitaryTime: boolean;
teammateNameDisplay?: string;
limitRestrictedInfo?: LimitRestrictedInfo;
ccChannelId?: string;
}
@ -41,61 +40,74 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
flexDirection: 'row',
alignItems: 'center',
color: changeOpacity(theme.centerChannelColor, 0.6),
paddingTop: 5,
paddingBottom: 5,
padding: 12,
marginBottom: 2,
gap: 8,
backgroundColor: theme.centerChannelBg,
borderWidth: 1,
borderColor: changeOpacity(theme.centerChannelColor, 0.12),
borderRadius: 4,
shadowColor: theme.centerChannelColor,
shadowOffset: {
width: 0,
height: 2,
},
shadowRadius: 1,
shadowOpacity: 0.08,
elevation: 1,
},
messageText: {
message: {
flex: 1,
paddingLeft: 12,
paddingRight: 4,
},
text: {
color: theme.centerChannelColor,
...typography('Heading', 200),
},
timeText: {
color: changeOpacity(theme.centerChannelColor, 0.72),
...typography('Body', 75),
},
callIcon: {
padding: 10,
borderRadius: 20,
color: theme.buttonColor,
overflow: 'hidden',
},
joinCallIcon: {
padding: 8,
backgroundColor: theme.onlineIndicator,
borderRadius: 4,
color: theme.buttonColor,
overflow: 'hidden',
},
phoneHangupIcon: {
padding: 8,
backgroundColor: changeOpacity(theme.centerChannelColor, 0.6),
borderRadius: 4,
color: theme.buttonColor,
overflow: 'hidden',
color: changeOpacity(theme.centerChannelColor, 0.72),
backgroundColor: changeOpacity(theme.centerChannelColor, 0.08),
},
joinCallButtonText: {
color: theme.buttonColor,
...typography('Body', 75, 'SemiBold'),
},
joinCallButtonTextRestricted: {
color: changeOpacity(theme.centerChannelColor, 0.32),
},
joinCallButtonIcon: {
color: theme.buttonColor,
marginRight: 5,
},
joinCallButtonIconRestricted: {
color: changeOpacity(theme.centerChannelColor, 0.32),
},
startedText: {
color: theme.centerChannelColor,
...typography('Body', 100, 'SemiBold'),
},
joinCallButton: {
callButton: {
flexDirection: 'row',
padding: 12,
backgroundColor: theme.onlineIndicator,
paddingHorizontal: 16,
paddingVertical: 10,
gap: 7,
borderRadius: 4,
alignItems: 'center',
alignContent: 'center',
},
joinCallButton: {
backgroundColor: theme.onlineIndicator,
},
leaveCallButton: {
backgroundColor: theme.dndIndicator,
},
buttonText: {
color: theme.buttonColor,
...typography('Body', 100, 'SemiBold'),
},
buttonRestricted: {
color: changeOpacity(theme.centerChannelColor, 0.32),
},
buttonIcon: {
color: theme.buttonColor,
},
joinCallButtonRestricted: {
backgroundColor: changeOpacity(theme.centerChannelColor, 0.08),
},
timeText: {
...typography('Body', 75),
color: changeOpacity(theme.centerChannelColor, 0.64),
},
endCallInfo: {
flexDirection: 'row',
alignItems: 'center',
@ -109,10 +121,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
};
});
export const CallsCustomMessage = ({
post, currentUser, author, isMilitaryTime, teammateNameDisplay,
ccChannelId, limitRestrictedInfo,
}: Props) => {
export const CallsCustomMessage = ({post, currentUser, isMilitaryTime, ccChannelId, limitRestrictedInfo}: Props) => {
const intl = useIntl();
const theme = useTheme();
const style = getStyleSheet(theme);
@ -122,18 +131,18 @@ export const CallsCustomMessage = ({
const alreadyInTheCall = Boolean(ccChannelId && ccChannelId === post.channelId);
const isLimitRestricted = Boolean(limitRestrictedInfo?.limitRestricted);
const joinHandler = () => {
if (alreadyInTheCall) {
return;
}
const joinHandler = useCallback(() => {
if (isLimitRestricted) {
showLimitRestrictedAlert(limitRestrictedInfo!, intl);
return;
}
leaveAndJoinWithAlert(intl, serverUrl, post.channelId);
};
}, [limitRestrictedInfo, intl, serverUrl, post.channelId]);
const leaveHandler = useCallback(() => {
leaveCall();
}, []);
const title = post.props.title ? (
<Text style={style.title}>
@ -148,20 +157,20 @@ export const CallsCustomMessage = ({
<View style={style.messageStyle}>
<CompassIcon
name='phone-hangup'
size={24}
style={style.phoneHangupIcon}
size={20}
style={[style.callIcon, style.phoneHangupIcon]}
/>
<View style={style.messageText}>
<View style={style.message}>
<FormattedText
id={'mobile.calls_call_ended'}
defaultMessage={'Call ended'}
style={style.startedText}
style={style.text}
/>
<View style={style.endCallInfo}>
<FormattedText
style={style.timeText}
id={'mobile.calls_ended_at'}
defaultMessage={'Ended at'}
style={style.timeText}
/>
<Text>{' '}</Text>
<FormattedTime
@ -173,9 +182,9 @@ export const CallsCustomMessage = ({
<Text style={style.separator}>{'•'}</Text>
<FormattedText
id={'mobile.calls_lasted'}
style={style.timeText}
defaultMessage={'Lasted {duration}'}
values={{duration: moment.duration(post.props.end_at - post.props.start_at).humanize(false)}}
style={style.timeText}
/>
</View>
</View>
@ -184,22 +193,54 @@ export const CallsCustomMessage = ({
);
}
const joinTextStyle = [style.joinCallButtonText, isLimitRestricted && style.joinCallButtonTextRestricted];
const button = alreadyInTheCall ? (
<TouchableOpacity
style={[style.callButton, style.leaveCallButton]}
onPress={leaveHandler}
>
<CompassIcon
name='phone-hangup'
size={18}
style={[style.buttonIcon]}
/>
<FormattedText
id={'mobile.calls_leave'}
defaultMessage={'Leave'}
style={style.buttonText}
/>
</TouchableOpacity>
) : (
<TouchableOpacity
style={[style.callButton, style.joinCallButton, isLimitRestricted && style.joinCallButtonRestricted]}
onPress={joinHandler}
>
<CompassIcon
name='phone-in-talk'
size={18}
style={[style.buttonIcon, isLimitRestricted && style.buttonRestricted]}
/>
<FormattedText
id={'mobile.calls_join'}
defaultMessage={'Join'}
style={[style.buttonText, isLimitRestricted && style.buttonRestricted]}
/>
</TouchableOpacity>
);
return (
<>
{title}
<View style={style.messageStyle}>
<CompassIcon
name='phone-in-talk'
size={24}
style={style.joinCallIcon}
size={20}
style={[style.callIcon, style.joinCallIcon]}
/>
<View style={style.messageText}>
<View style={style.message}>
<FormattedText
id={'mobile.calls_name_started_call'}
defaultMessage={'{name} started a call'}
values={{name: displayUsername(author, intl.locale, teammateNameDisplay)}}
style={style.startedText}
id={'mobile.calls_started_call'}
defaultMessage={'Call started'}
style={style.text}
/>
<FormattedRelativeTime
value={post.props.start_at}
@ -207,33 +248,7 @@ export const CallsCustomMessage = ({
style={style.timeText}
/>
</View>
<TouchableOpacity
style={[style.joinCallButton, isLimitRestricted && style.joinCallButtonRestricted]}
onPress={joinHandler}
>
<CompassIcon
name='phone'
size={14}
style={[style.joinCallButtonIcon, isLimitRestricted && style.joinCallButtonIconRestricted]}
/>
{
alreadyInTheCall &&
<FormattedText
id={'mobile.calls_current_call'}
defaultMessage={'Current call'}
style={joinTextStyle}
/>
}
{
!alreadyInTheCall &&
<FormattedText
id={'mobile.calls_join_call'}
defaultMessage={'Join call'}
style={joinTextStyle}
/>
}
</TouchableOpacity>
{button}
</View>
</>
);

View file

@ -11,7 +11,7 @@ import {observeCurrentCall} from '@calls/state';
import {Preferences} from '@constants';
import {getDisplayNamePreferenceAsBool} from '@helpers/api/preference';
import {queryDisplayNamePreferences} from '@queries/servers/preference';
import {observeCurrentUser, observeTeammateNameDisplay, observeUser} from '@queries/servers/user';
import {observeCurrentUser} from '@queries/servers/user';
import type {WithDatabaseArgs} from '@typings/database/database';
import type PostModel from '@typings/database/models/servers/post';
@ -23,7 +23,6 @@ type OwnProps = {
const enhanced = withObservables(['post'], ({serverUrl, post, database}: OwnProps & WithDatabaseArgs) => {
const currentUser = observeCurrentUser(database);
const author = observeUser(database, post.userId);
const isMilitaryTime = queryDisplayNamePreferences(database).observeWithColumns(['value']).pipe(
switchMap(
(preferences) => of$(getDisplayNamePreferenceAsBool(preferences, Preferences.USE_MILITARY_TIME)),
@ -34,7 +33,6 @@ const enhanced = withObservables(['post'], ({serverUrl, post, database}: OwnProp
if (post.props.end_at) {
return {
currentUser,
author,
isMilitaryTime,
};
}
@ -46,9 +44,7 @@ const enhanced = withObservables(['post'], ({serverUrl, post, database}: OwnProp
return {
currentUser,
author,
isMilitaryTime,
teammateNameDisplay: observeTeammateNameDisplay(database),
limitRestrictedInfo: observeIsCallLimitRestricted(database, serverUrl, post.channelId),
ccChannelId,
};

View file

@ -447,7 +447,6 @@
"mobile.calls_call_thread": "Call Thread",
"mobile.calls_captions": "Captions",
"mobile.calls_captions_turned_on": "Live captions turned on",
"mobile.calls_current_call": "Current call",
"mobile.calls_disable": "Disable calls",
"mobile.calls_dismiss": "Dismiss",
"mobile.calls_enable": "Enable calls",
@ -474,6 +473,7 @@
"mobile.calls_host_transcription_title": "Recording and transcription has started",
"mobile.calls_incoming_dm": "<b>{name}</b> is inviting you to a call",
"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_lasted": "Lasted {duration}",
"mobile.calls_leave": "Leave",
@ -489,7 +489,6 @@
"mobile.calls_mute_others": "Mute others",
"mobile.calls_mute_participant": "Mute participant",
"mobile.calls_name_is_talking_postfix": "is talking...",
"mobile.calls_name_started_call": "{name} started a call",
"mobile.calls_noone_talking": "No one is talking",
"mobile.calls_not_available_msg": "Please contact your System Admin to enable the feature.",
"mobile.calls_not_available_option": "(Not available)",
@ -529,6 +528,7 @@
"mobile.calls_speaker": "Speaker",
"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_stop_recording": "Stop Recording",
"mobile.calls_stop_screenshare": "Stop screen share",
"mobile.calls_tablet": "Tablet",