MM-53486 - Incoming calls notification banner (#7477)

* upgrade calls-common

* incoming call notifications

* tests

* i18n

* PR comments

* PR comments

* PR comment
This commit is contained in:
Christopher Poile 2023-08-30 13:58:54 -04:00 committed by GitHub
parent 469ca75d49
commit 83f81985cd
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
29 changed files with 890 additions and 63 deletions

View file

@ -28,6 +28,7 @@ import {
handleCallUserUnraiseHand,
handleCallUserVoiceOff,
handleCallUserVoiceOn,
handleUserDismissedNotification,
} from '@calls/connection/websocket_event_handlers';
import {isSupportedServerCalls} from '@calls/utils';
import {Screens, WebsocketEvents} from '@constants';
@ -392,6 +393,9 @@ export async function handleEvent(serverUrl: string, msg: WebSocketMessage) {
case WebsocketEvents.CALLS_HOST_CHANGED:
handleCallHostChanged(serverUrl, msg);
break;
case WebsocketEvents.CALLS_USER_DISMISSED_NOTIFICATION:
handleUserDismissedNotification(serverUrl, msg);
break;
case WebsocketEvents.GROUP_RECEIVED:
handleGroupReceivedEvent(serverUrl, msg);

View file

@ -24,6 +24,7 @@ export const SEARCH_INPUT_MARGIN = 5;
export const JOIN_CALL_BAR_HEIGHT = 38;
export const CURRENT_CALL_BAR_HEIGHT = 68;
export const CALL_ERROR_BAR_HEIGHT = 62;
export const CALL_NOTIFICATION_BAR_HEIGHT = 60;
export const ANNOUNCEMENT_BAR_HEIGHT = 40;

View file

@ -76,6 +76,7 @@ const WebsocketEvents = {
CALLS_USER_REACTED: `custom_${Calls.PluginId}_user_reacted`,
CALLS_RECORDING_STATE: `custom_${Calls.PluginId}_call_recording_state`,
CALLS_HOST_CHANGED: `custom_${Calls.PluginId}_call_host_changed`,
CALLS_USER_DISMISSED_NOTIFICATION: `custom_${Calls.PluginId}_user_dismissed_notification`,
GROUP_RECEIVED: 'received_group',
GROUP_MEMBER_ADD: 'group_member_add',
GROUP_MEMBER_DELETE: 'group_member_delete',

View file

@ -67,6 +67,7 @@ const mockClient = {
enableChannelCalls: jest.fn(),
startCallRecording: jest.fn(),
stopCallRecording: jest.fn(),
dismissCall: jest.fn(),
};
jest.mock('@calls/connection/connection', () => ({
@ -98,7 +99,8 @@ jest.mock('react-native-navigation', () => ({
}));
const addFakeCall = (serverUrl: string, channelId: string) => {
const call = {
const call: Call = {
id: 'call',
participants: {
xohi8cki9787fgiryne716u84o: {id: 'xohi8cki9787fgiryne716u84o', muted: false, raisedHand: 0},
xohi8cki9787fgiryne716u841: {id: 'xohi8cki9787fgiryne716u84o', muted: true, raisedHand: 0},
@ -113,7 +115,8 @@ const addFakeCall = (serverUrl: string, channelId: string) => {
threadId: 'abcd1234567',
ownerId: 'xohi8cki9787fgiryne716u84o',
hostId: 'xohi8cki9787fgiryne716u84o',
} as Call;
dismissed: {},
};
act(() => {
State.setCallsState(serverUrl, {myUserId: 'myUserId', calls: {}, enabled: {}});
State.callStarted(serverUrl, call);
@ -158,7 +161,7 @@ describe('Actions.Calls', () => {
setCallsState('server1', DefaultCallsState);
setChannelsWithCalls('server1', {});
setCurrentCall(null);
setCallsConfig('server1', DefaultCallsConfig);
setCallsConfig('server1', {...DefaultCallsConfig, EnableRinging: true});
});
});
@ -385,4 +388,12 @@ describe('Actions.Calls', () => {
expect(needsRecordingErrorAlert).toBeCalled();
expect(needsRecordingWillBePostedAlert).toBeCalled();
});
it('dismissIncomingCall', async () => {
await act(async () => {
await CallsActions.dismissIncomingCall('server1', 'channel-id');
});
expect(mockClient.dismissCall).toBeCalledWith('channel-id');
});
});

View file

@ -7,21 +7,21 @@ import InCallManager from 'react-native-incall-manager';
import {forceLogoutIfNecessary} from '@actions/remote/session';
import {updateThreadFollowing} from '@actions/remote/thread';
import {fetchUsersByIds} from '@actions/remote/user';
import {leaveAndJoinWithAlert, needsRecordingWillBePostedAlert, needsRecordingErrorAlert} from '@calls/alerts';
import {leaveAndJoinWithAlert, needsRecordingErrorAlert, needsRecordingWillBePostedAlert} from '@calls/alerts';
import {
getCallsConfig,
getCallsState,
getChannelsWithCalls,
getCurrentCall,
myselfLeftCall,
newCurrentCall,
setCallForChannel,
setCalls,
setChannelEnabled,
setConfig,
setPluginEnabled,
setScreenShareURL,
setSpeakerPhone,
setCallForChannel,
newCurrentCall,
myselfLeftCall,
getCurrentCall,
getChannelsWithCalls,
} from '@calls/state';
import {General, Preferences} from '@constants';
import Calls from '@constants/calls';
@ -39,12 +39,7 @@ import {displayUsername, getUserIdFromChannelName, isSystemAdmin} from '@utils/u
import {newConnection} from '../connection/connection';
import type {
AudioDevice,
Call,
CallParticipant,
CallsConnection,
} from '@calls/types/calls';
import type {AudioDevice, Call, CallParticipant, CallsConnection} from '@calls/types/calls';
import type {CallChannelState, CallState, EmojiData} from '@mattermost/calls/lib/types';
import type {IntlShape} from 'react-intl';
@ -150,12 +145,14 @@ const createCallAndAddToIds = (channelId: string, call: CallState, ids: Set<stri
return accum;
}, {} as Dictionary<CallParticipant>),
channelId,
id: call.id,
startTime: call.start_at,
screenOn: call.screen_sharing_id,
threadId: call.thread_id,
ownerId: call.owner_id,
hostId: call.host_id,
recState: call.recording,
dismissed: call.dismissed_notification || {},
} as Call;
};
@ -417,6 +414,21 @@ export const stopCallRecording = async (serverUrl: string, callId: string) => {
}
};
export const dismissIncomingCall = async (serverUrl: string, channelId: string) => {
if (!getCallsConfig(serverUrl).EnableRinging) {
return {};
}
try {
const client = NetworkManager.getClient(serverUrl);
return await client.dismissCall(channelId);
} catch (error) {
logDebug('error on dismissIncomingCall', getFullErrorMessage(error));
await forceLogoutIfNecessary(serverUrl, error);
return error;
}
};
// handleCallsSlashCommand will return true if the slash command was handled
export const handleCallsSlashCommand = async (value: string, serverUrl: string, channelId: string, rootId: string, currentUserId: string, intl: IntlShape):
Promise<{ handled?: boolean; error?: string }> => {

View file

@ -15,6 +15,7 @@ export {
handleCallsSlashCommand,
startCallRecording,
stopCallRecording,
dismissIncomingCall,
} from './calls';
export {hasMicrophonePermission} from './permissions';

View file

@ -4,12 +4,14 @@
import {Alert} from 'react-native';
import {hasMicrophonePermission, joinCall, leaveCall, unmuteMyself} from '@calls/actions';
import {dismissIncomingCall} from '@calls/actions/calls';
import {hasBluetoothPermission} from '@calls/actions/permissions';
import {
getCallsConfig,
getCallsState,
getChannelsWithCalls,
getCurrentCall,
removeIncomingCall,
setMicPermissionsGranted,
} from '@calls/state';
import {errorAlert} from '@calls/utils';
@ -199,6 +201,13 @@ const doJoinCall = async (
const hasPermission = await hasMicrophonePermission();
setMicPermissionsGranted(hasPermission);
if (!newCall && joinChannelIsDMorGM) {
// we're joining an existing call, so dismiss any notifications (for all clients, too)
const callId = getCallsState(serverUrl).calls[channelId].id;
dismissIncomingCall(serverUrl, channelId);
removeIncomingCall(serverUrl, callId, channelId);
}
const res = await joinCall(serverUrl, channelId, user.id, hasPermission, title, rootId);
if (res.error) {
const seeLogs = formatMessage({id: 'mobile.calls_see_logs', defaultMessage: 'See server logs'});

View file

@ -15,6 +15,7 @@ export interface ClientCallsMix {
genTURNCredentials: () => Promise<RTCIceServer[]>;
startCallRecording: (callId: string) => Promise<ApiResp | CallRecordingState>;
stopCallRecording: (callId: string) => Promise<ApiResp | CallRecordingState>;
dismissCall: (channelId: string) => Promise<ApiResp>;
}
const ClientCalls = (superclass: any) => class extends superclass {
@ -85,6 +86,13 @@ const ClientCalls = (superclass: any) => class extends superclass {
{method: 'post'},
);
};
dismissCall = async (channelID: string) => {
return this.doFetch(
`${this.getCallsRoute()}/calls/${channelID}/dismiss-notification`,
{method: 'post'},
);
};
};
export default ClientCalls;

View file

@ -17,7 +17,7 @@ import type UserModel from '@typings/database/models/servers/user';
type Props = {
userModel?: UserModel;
volume: number;
volume?: number;
serverUrl: string;
size: number;
muted?: boolean;
@ -149,7 +149,7 @@ const getStyleSheet = ({
});
};
const CallAvatar = ({userModel, volume, serverUrl, sharingScreen, size, muted, raisedHand, reaction}: Props) => {
const CallAvatar = ({userModel, volume = 0, serverUrl, sharingScreen, size, muted, raisedHand, reaction}: Props) => {
const theme = useTheme();
const callsTheme = useMemo(() => makeCallsTheme(theme), [theme]);
const style = useMemo(() => getStyleSheet({theme: callsTheme, volume, size}), [callsTheme, volume, size]);

View file

@ -0,0 +1,227 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useEffect} from 'react';
import {useIntl} from 'react-intl';
import {Pressable, StyleSheet, Text, View} from 'react-native';
import {switchToChannelById} from '@actions/remote/channel';
import {fetchProfilesInChannel} from '@actions/remote/user';
import {dismissIncomingCall} from '@calls/actions/calls';
import {leaveAndJoinWithAlert} from '@calls/alerts';
import {removeIncomingCall} from '@calls/state';
import {ChannelType, type IncomingCallNotification} from '@calls/types/calls';
import CompassIcon from '@components/compass_icon';
import FormattedText from '@components/formatted_text';
import ProfilePicture from '@components/profile_picture';
import {Preferences} from '@constants';
import {CALL_NOTIFICATION_BAR_HEIGHT} from '@constants/view';
import {useServerUrl} from '@context/server';
import DatabaseManager from '@database/manager';
import WebsocketManager from '@managers/websocket_manager';
import ChannelMembershipModel from '@typings/database/models/servers/channel_membership';
import {changeOpacity} from '@utils/theme';
import {typography} from '@utils/typography';
import {displayUsername} from '@utils/user';
const style = StyleSheet.create({
outerContainer: {
backgroundColor: Preferences.THEMES.denim.onlineIndicator,
borderRadius: 8,
height: CALL_NOTIFICATION_BAR_HEIGHT,
marginLeft: 8,
marginRight: 8,
},
outerOnCallsScreen: {
backgroundColor: changeOpacity(Preferences.THEMES.denim.onlineIndicator, 0.40),
},
innerContainer: {
flexDirection: 'row',
width: '100%',
height: '100%',
paddingTop: 8,
paddingBottom: 8,
paddingLeft: 12,
paddingRight: 12,
borderRadius: 8,
borderWidth: 2,
borderStyle: 'solid',
borderColor: changeOpacity(Preferences.THEMES.denim.buttonColor, 0.16),
gap: 8,
alignItems: 'center',
backgroundColor: changeOpacity('#000', 0.16),
},
innerOnCallsScreen: {
borderColor: changeOpacity(Preferences.THEMES.denim.buttonColor, 0.16),
backgroundColor: changeOpacity('#000', 0.12),
},
text: {
flex: 1,
...typography('Body', 200),
lineHeight: 20,
color: Preferences.THEMES.denim.buttonColor,
},
boldText: {
...typography('Body', 200, 'SemiBold'),
lineHeight: 20,
},
join: {
flexDirection: 'row',
alignItems: 'flex-end',
height: 40,
gap: 7,
backgroundColor: Preferences.THEMES.denim.buttonColor,
paddingTop: 10,
paddingRight: 20,
paddingBottom: 10,
paddingLeft: 20,
borderRadius: 30,
},
joinOnCallsScreen: {
backgroundColor: changeOpacity(Preferences.THEMES.denim.buttonColor, 0.12),
},
joinLabel: {
...typography('Body', 100, 'SemiBold'),
},
joinIconLabel: {
color: Preferences.THEMES.denim.onlineIndicator,
},
joinIconLabelOnCallsScreen: {
color: Preferences.THEMES.denim.buttonColor,
},
dismiss: {
height: 40,
width: 40,
borderRadius: 20,
padding: 0,
backgroundColor: changeOpacity(Preferences.THEMES.denim.buttonColor, 0.08),
alignItems: 'center',
justifyContent: 'center',
},
dismissOnCallsScreen: {
backgroundColor: 'transparent',
},
dismissIcon: {
color: Preferences.THEMES.denim.buttonColor,
},
});
type Props = {
incomingCall: IncomingCallNotification;
currentUserId: string;
teammateNameDisplay: string;
members?: ChannelMembershipModel[];
onCallsScreen?: boolean;
}
export const CallNotification = ({
incomingCall,
currentUserId,
teammateNameDisplay,
members,
onCallsScreen,
}: Props) => {
const intl = useIntl();
const serverUrl = useServerUrl();
useEffect(() => {
const channelMembers = members?.filter((m) => m.userId !== currentUserId);
if (!channelMembers?.length) {
fetchProfilesInChannel(serverUrl, incomingCall.channelID, currentUserId, undefined, false);
}
}, []);
const onJoinPress = useCallback(() => {
leaveAndJoinWithAlert(intl, incomingCall.serverUrl, incomingCall.channelID);
}, [intl, incomingCall]);
const onContainerPress = useCallback(async () => {
if (serverUrl !== incomingCall.serverUrl) {
await DatabaseManager.setActiveServerDatabase(incomingCall.serverUrl);
await WebsocketManager.initializeClient(incomingCall.serverUrl);
}
switchToChannelById(incomingCall.serverUrl, incomingCall.channelID);
}, [serverUrl, incomingCall]);
const onDismissPress = useCallback(() => {
removeIncomingCall(serverUrl, incomingCall.callID, incomingCall.channelID);
dismissIncomingCall(incomingCall.serverUrl, incomingCall.channelID);
}, [incomingCall]);
let message: React.ReactElement;
if (incomingCall.type === ChannelType.DM) {
message = (
<FormattedText
id={'mobile.calls_incoming_dm'}
defaultMessage={'<b>{name}</b> is inviting you to a call'}
values={{
b: (text: string) => <Text style={style.boldText}>{text}</Text>,
name: displayUsername(incomingCall.callerModel, intl.locale, teammateNameDisplay),
}}
style={style.text}
numberOfLines={2}
ellipsizeMode={'tail'}
/>
);
} else {
message = (
<FormattedText
id={'mobile.calls_incoming_gm'}
defaultMessage={'<b>{name}</b> is inviting you to a call with <b>{num, plural, one {# other} other {# others}}</b>'}
values={{
b: (text: string) => <Text style={style.boldText}>{text}</Text>,
name: displayUsername(incomingCall.callerModel, intl.locale, teammateNameDisplay),
num: (members?.length || 2) - 1,
}}
style={style.text}
numberOfLines={2}
ellipsizeMode={'tail'}
/>
);
}
const joinLabel = (
<FormattedText
id={'mobile.calls_join_button'}
defaultMessage={'Join'}
style={[style.joinIconLabel, style.joinLabel, onCallsScreen && style.joinIconLabelOnCallsScreen]}
/>
);
return (
<View style={[style.outerContainer, onCallsScreen && style.outerOnCallsScreen]}>
<Pressable
style={[style.innerContainer, onCallsScreen && style.innerOnCallsScreen]}
onPress={onContainerPress}
>
<ProfilePicture
author={incomingCall.callerModel}
url={incomingCall.serverUrl}
size={32}
showStatus={false}
/>
{message}
<Pressable
style={[style.join, onCallsScreen && style.joinOnCallsScreen]}
onPress={onJoinPress}
>
<CompassIcon
name='phone-in-talk'
size={18}
style={[style.joinIconLabel, onCallsScreen && style.joinIconLabelOnCallsScreen]}
/>
{joinLabel}
</Pressable>
<Pressable
style={[style.dismiss, onCallsScreen && style.dismissOnCallsScreen]}
onPress={onDismissPress}
>
<CompassIcon
name={'close'}
size={24}
style={style.dismissIcon}
/>
</Pressable>
</Pressable>
</View>
);
};

View file

@ -0,0 +1,42 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import withObservables from '@nozbe/with-observables';
import {of as of$} from 'rxjs';
import {distinctUntilChanged, switchMap} from 'rxjs/operators';
import {CallNotification} from '@calls/components/call_notification/call_notification';
import DatabaseManager from '@database/manager';
import {observeChannelMembers} from '@queries/servers/channel';
import {observeCurrentUserId} from '@queries/servers/system';
import {observeTeammateNameDisplay} from '@queries/servers/user';
import type {IncomingCallNotification} from '@calls/types/calls';
type OwnProps = {
incomingCall: IncomingCallNotification;
}
const enhanced = withObservables(['incomingCall'], ({incomingCall}: OwnProps) => {
const database = of$(DatabaseManager.serverDatabases[incomingCall.serverUrl]?.database);
const currentUserId = database.pipe(
switchMap((db) => (db ? observeCurrentUserId(db) : of$(''))),
distinctUntilChanged(),
);
const teammateNameDisplay = database.pipe(
switchMap((db) => (db ? observeTeammateNameDisplay(db) : of$(''))),
distinctUntilChanged(),
);
const members = database.pipe(
switchMap((db) => (db ? observeChannelMembers(db, incomingCall.channelID) : of$([]))),
distinctUntilChanged(),
);
return {
currentUserId,
teammateNameDisplay,
members,
};
});
export default enhanced(CallNotification);

View file

@ -39,10 +39,7 @@ type Props = {
const getStyleSheet = makeStyleSheetFromTheme((theme: CallsTheme) => {
return {
wrapper: {
marginTop: 8,
marginRight: 6,
marginBottom: 8,
marginLeft: 6,
margin: 8,
backgroundColor: theme.callsBg,
borderRadius: 8,
},

View file

@ -0,0 +1,71 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {StyleSheet, View} from 'react-native';
import {useSafeAreaInsets} from 'react-native-safe-area-context';
import CallNotification from '@calls/components/call_notification';
import {useCurrentCall, useGlobalCallsState, useIncomingCalls} from '@calls/state';
import {
CALL_ERROR_BAR_HEIGHT,
CURRENT_CALL_BAR_HEIGHT,
DEFAULT_HEADER_HEIGHT,
JOIN_CALL_BAR_HEIGHT,
} from '@constants/view';
const topBarHeight = DEFAULT_HEADER_HEIGHT;
const style = StyleSheet.create({
wrapper: {
position: 'absolute',
width: '100%',
marginTop: 8,
gap: 8,
},
});
type Props = {
channelId: string;
showingJoinCallBanner: boolean;
showingCurrentCallBanner: boolean;
threadScreen?: boolean;
}
export const IncomingCallsContainer = ({
channelId,
showingJoinCallBanner,
showingCurrentCallBanner,
threadScreen,
}: Props) => {
const incomingCalls = useIncomingCalls().incomingCalls;
const insets = useSafeAreaInsets();
const micPermissionsGranted = useGlobalCallsState().micPermissionsGranted;
const currentCall = useCurrentCall();
// If we're in the channel for the incoming call, don't show the incoming call banner.
const calls = incomingCalls.filter((ic) => ic.channelID !== channelId);
if (calls.length === 0) {
return null;
}
const micPermissionsError = !micPermissionsGranted && (currentCall ? !currentCall.micPermissionsErrorDismissed : false);
const qualityAlert = showingCurrentCallBanner && (currentCall ? currentCall.callQualityAlert && currentCall.callQualityAlertDismissed === 0 : false);
const top = insets.top + (threadScreen ? 0 : topBarHeight) +
(showingJoinCallBanner ? JOIN_CALL_BAR_HEIGHT : 0) +
(showingCurrentCallBanner ? CURRENT_CALL_BAR_HEIGHT - 2 : 0) +
(micPermissionsError ? CALL_ERROR_BAR_HEIGHT + 8 : 0) +
(qualityAlert ? CALL_ERROR_BAR_HEIGHT + 8 : 0);
const wrapperTop = {top};
return (
<View style={[style.wrapper, wrapperTop]}>
{calls.map((ic) => (
<CallNotification
key={ic.callID}
incomingCall={ic}
/>
))}
</View>
);
};

View file

@ -28,7 +28,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: CallsTheme) => (
zIndex: 10,
},
errorWrapper: {
padding: 10,
padding: 8,
paddingTop: 0,
},
errorBar: {

View file

@ -7,6 +7,7 @@ import {fetchUsersByIds} from '@actions/remote/user';
import {
callEnded,
callStarted,
removeIncomingCall,
setCallScreenOff,
setCallScreenOn,
setChannelEnabled,
@ -21,14 +22,21 @@ import {
} from '@calls/state';
import {WebsocketEvents} from '@constants';
import DatabaseManager from '@database/manager';
import {getCurrentUserId} from '@queries/servers/system';
import type {
CallHostChangedData, CallRecordingStateData,
CallHostChangedData,
CallRecordingStateData,
CallStartData,
EmptyData,
UserConnectedData,
UserDisconnectedData, UserMutedUnmutedData, UserRaiseUnraiseHandData,
UserReactionData, UserScreenOnOffData, UserVoiceOnOffData,
UserDisconnectedData,
UserDismissedNotification,
UserMutedUnmutedData,
UserRaiseUnraiseHandData,
UserReactionData,
UserScreenOnOffData,
UserVoiceOnOffData,
} from '@mattermost/calls/lib/types';
export const handleCallUserConnected = (serverUrl: string, msg: WebSocketMessage<UserConnectedData>) => {
@ -59,12 +67,8 @@ export const handleCallUserVoiceOff = (msg: WebSocketMessage<UserVoiceOnOffData>
};
export const handleCallStarted = (serverUrl: string, msg: WebSocketMessage<CallStartData>) => {
const database = DatabaseManager.serverDatabases[serverUrl]?.database;
if (!database) {
return;
}
callStarted(serverUrl, {
id: msg.data.id,
channelId: msg.data.channelID,
startTime: msg.data.start_at,
threadId: msg.data.thread_id,
@ -72,6 +76,7 @@ export const handleCallStarted = (serverUrl: string, msg: WebSocketMessage<CallS
participants: {},
ownerId: msg.data.owner_id,
hostId: msg.data.host_id,
dismissed: {},
});
};
@ -118,3 +123,18 @@ export const handleCallRecordingState = (serverUrl: string, msg: WebSocketMessag
export const handleCallHostChanged = (serverUrl: string, msg: WebSocketMessage<CallHostChangedData>) => {
setHost(serverUrl, msg.broadcast.channel_id, msg.data.hostID);
};
export const handleUserDismissedNotification = async (serverUrl: string, msg: WebSocketMessage<UserDismissedNotification>) => {
const database = DatabaseManager.serverDatabases[serverUrl]?.database;
if (!database) {
return;
}
// For now we are only handling our own dismissed.
const myUserId = await getCurrentUserId(database);
if (myUserId !== msg.data.userID) {
return;
}
removeIncomingCall(serverUrl, msg.data.callID);
};

View file

@ -27,6 +27,7 @@ import {recordingAlert, recordingWillBePostedAlert, recordingErrorAlert} from '@
import {AudioDeviceButton} from '@calls/components/audio_device_button';
import CallAvatar from '@calls/components/call_avatar';
import CallDuration from '@calls/components/call_duration';
import CallNotification from '@calls/components/call_notification';
import CallsBadge, {CallsBadgeType} from '@calls/components/calls_badge';
import EmojiList from '@calls/components/emoji_list';
import MessageBar from '@calls/components/message_bar';
@ -34,7 +35,12 @@ import ReactionBar from '@calls/components/reaction_bar';
import UnavailableIconWrapper from '@calls/components/unavailable_icon_wrapper';
import {usePermissionsChecker} from '@calls/hooks';
import {RaisedHandBanner} from '@calls/screens/call_screen/raised_hand_banner';
import {setCallQualityAlertDismissed, setMicPermissionsErrorDismissed, useCallsConfig} from '@calls/state';
import {
setCallQualityAlertDismissed,
setMicPermissionsErrorDismissed,
useCallsConfig,
useIncomingCalls,
} from '@calls/state';
import {getHandsRaised, makeCallsTheme, sortParticipants} from '@calls/utils';
import CompassIcon from '@components/compass_icon';
import FormattedText from '@components/formatted_text';
@ -177,6 +183,11 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: CallsTheme) => ({
marginTop: 0,
width: usernameM,
},
incomingCallContainer: {
width: '100%',
marginBottom: 8,
gap: 8,
},
buttonsContainer: {
alignItems: 'center',
},
@ -316,6 +327,8 @@ const CallScreen = ({
const serverUrl = useServerUrl();
const {EnableRecordings} = useCallsConfig(serverUrl);
usePermissionsChecker(micPermissionsGranted);
const incomingCalls = useIncomingCalls();
const [showControlsInLandscape, setShowControlsInLandscape] = useState(false);
const [showReactions, setShowReactions] = useState(false);
const callsTheme = useMemo(() => makeCallsTheme(theme), [theme]);
@ -330,6 +343,7 @@ const CallScreen = ({
const smallerAvatar = isLandscape || screenShareOn;
const avatarSize = smallerAvatar ? avatarM : avatarL;
const numParticipants = Object.keys(participantsDict).length;
const showIncomingCalls = incomingCalls.incomingCalls.length > 0;
const callThreadOptionTitle = intl.formatMessage({id: 'mobile.calls_call_thread', defaultMessage: 'Call Thread'});
const recordOptionTitle = intl.formatMessage({id: 'mobile.calls_record', defaultMessage: 'Record'});
@ -675,6 +689,17 @@ const CallScreen = ({
{!isLandscape && currentCall.reactionStream.length > 0 &&
<EmojiList reactionStream={currentCall.reactionStream}/>
}
{showIncomingCalls &&
<View style={style.incomingCallContainer}>
{incomingCalls.incomingCalls.map((ic) => (
<CallNotification
key={ic.callID}
incomingCall={ic}
onCallsScreen={true}
/>
))}
</View>
}
{micPermissionsError &&
<MessageBar
type={Calls.MessageBarType.Microphone}

View file

@ -8,9 +8,12 @@ import {act, renderHook} from '@testing-library/react-hooks';
import {needsRecordingAlert} from '@calls/alerts';
import {
newCurrentCall,
processIncomingCalls,
processMeanOpinionScore,
removeIncomingCall,
setAudioDeviceInfo,
setCallQualityAlertDismissed,
setCallsConfig,
setCallsState,
setChannelsWithCalls,
setCurrentCall,
@ -23,6 +26,7 @@ import {
useChannelsWithCalls,
useCurrentCall,
useGlobalCallsState,
useIncomingCalls,
userReacted,
} from '@calls/state';
import {
@ -52,6 +56,7 @@ import {
DefaultCallsState,
DefaultCurrentCall,
DefaultGlobalCallsState,
DefaultIncomingCalls,
type GlobalCallsState,
} from '@calls/types/calls';
import {License} from '@constants';
@ -75,6 +80,18 @@ jest.mock('@queries/servers/thread', () => ({
})),
}));
jest.mock('@queries/servers/channel', () => ({
getChannelById: jest.fn(() => Promise.resolve({
type: 'D',
})),
}));
jest.mock('@queries/servers/user', () => ({
getUserById: jest.fn(() => Promise.resolve({
username: 'user-5',
})),
}));
jest.mock('react-native-navigation', () => ({
Navigation: {
pop: jest.fn(() => Promise.resolve({
@ -84,6 +101,7 @@ jest.mock('react-native-navigation', () => ({
}));
const call1: Call = {
id: 'call1',
participants: {
'user-1': {id: 'user-1', muted: false, raisedHand: 0},
'user-2': {id: 'user-2', muted: true, raisedHand: 0},
@ -94,8 +112,10 @@ const call1: Call = {
threadId: 'thread-1',
ownerId: 'user-1',
hostId: 'user-1',
dismissed: {},
};
const call2: Call = {
id: 'call2',
participants: {
'user-3': {id: 'user-3', muted: false, raisedHand: 0},
'user-4': {id: 'user-4', muted: true, raisedHand: 0},
@ -106,8 +126,10 @@ const call2: Call = {
threadId: 'thread-2',
ownerId: 'user-3',
hostId: 'user-3',
dismissed: {},
};
const call3: Call = {
id: 'call3',
participants: {
'user-5': {id: 'user-5', muted: false, raisedHand: 0},
'user-6': {id: 'user-6', muted: true, raisedHand: 0},
@ -118,6 +140,20 @@ const call3: Call = {
threadId: 'thread-3',
ownerId: 'user-5',
hostId: 'user-5',
dismissed: {},
};
const callDM: Call = {
id: 'callDM',
participants: {
'user-5': {id: 'user-5', muted: false, raisedHand: 0},
},
channelId: 'channel-private',
startTime: 123,
screenOn: '',
threadId: 'thread-4',
ownerId: 'user-5',
hostId: 'user-5',
dismissed: {},
};
describe('useCallsState', () => {
@ -153,7 +189,7 @@ describe('useCallsState', () => {
assert.deepEqual(result.current[1], {});
});
it('setCalls, two callsState hooks, channelsWithCalls hook, ', () => {
it('setCalls, two callsState hooks, channelsWithCalls hook, ', async () => {
const initialCallsState = {
...DefaultCallsState,
calls: {'channel-1': call1},
@ -217,7 +253,7 @@ describe('useCallsState', () => {
assert.deepEqual(result.current[3], initialCurrentCallState);
// test
act(() => setCalls('server1', 'myId', test.calls, test.enabled));
await act(async () => setCalls('server1', 'myId', test.calls, test.enabled));
assert.deepEqual(result.current[0], expectedCallsState);
assert.deepEqual(result.current[1], expectedCallsState);
assert.deepEqual(result.current[2], expectedChannelsWithCallsState);
@ -242,6 +278,7 @@ describe('useCallsState', () => {
};
const expectedCallsState = {
'channel-1': {
id: 'call1',
participants: {
'user-1': {id: 'user-1', muted: false, raisedHand: 0},
'user-2': {id: 'user-2', muted: true, raisedHand: 0},
@ -253,6 +290,7 @@ describe('useCallsState', () => {
threadId: 'thread-1',
ownerId: 'user-1',
hostId: 'user-1',
dismissed: {},
},
};
const expectedChannelsWithCallsState = initialChannelsWithCallsState;
@ -302,6 +340,7 @@ describe('useCallsState', () => {
};
const expectedCallsState = {
'channel-1': {
id: 'call1',
participants: {
'user-2': {id: 'user-2', muted: true, raisedHand: 0},
},
@ -311,6 +350,7 @@ describe('useCallsState', () => {
threadId: 'thread-1',
ownerId: 'user-1',
hostId: 'user-1',
dismissed: {},
},
};
const expectedChannelsWithCallsState = initialChannelsWithCallsState;
@ -366,6 +406,7 @@ describe('useCallsState', () => {
};
const expectedCallsState = {
'channel-1': {
id: 'call1',
participants: {
'user-2': {id: 'user-2', muted: true, raisedHand: 0},
},
@ -375,6 +416,7 @@ describe('useCallsState', () => {
ownerId: 'user-1',
hostId: 'user-1',
screenOn: '',
dismissed: {},
},
};
const expectedChannelsWithCallsState = initialChannelsWithCallsState;
@ -563,6 +605,7 @@ describe('useCallsState', () => {
};
const expectedCalls = {
'channel-1': {
id: 'call1',
participants: {
'user-1': {id: 'user-1', muted: false, raisedHand: 0},
'user-2': {id: 'user-2', muted: true, raisedHand: 345},
@ -573,6 +616,7 @@ describe('useCallsState', () => {
threadId: 'thread-1',
ownerId: 'user-1',
hostId: 'user-1',
dismissed: {},
},
};
const initialCurrentCallState: CurrentCall = {
@ -989,7 +1033,7 @@ describe('useCallsState', () => {
assert.deepEqual((result.current[1] as CurrentCall).callQualityAlert, true);
});
it('voiceOn and Off', () => {
it('voiceOn and Off', async () => {
const initialCallsState = {
...DefaultCallsState,
myUserId: 'myUserId',
@ -1025,7 +1069,7 @@ describe('useCallsState', () => {
assert.deepEqual(result.current[0], initialCallsState);
// test that voice state is cleared on reconnect
act(() => setCalls('server1', 'myUserId', initialCallsState.calls, {}));
await act(() => setCalls('server1', 'myUserId', initialCallsState.calls, {}));
assert.deepEqual(result.current[1], initialCurrentCallState);
assert.deepEqual(result.current[0], initialCallsState);
});
@ -1248,4 +1292,111 @@ describe('useCallsState', () => {
act(() => setHost('server1', 'channel-1', 'myUserId'));
expect(needsRecordingAlert).toBeCalled();
});
it('incoming calls', async () => {
const calls = {'channel-dm': callDM};
const afterLoadCallsState: CallsState = {
myUserId: 'myId',
calls,
enabled: {},
};
const initialCurrentCallState: CurrentCall | null = null;
const initialIncomingCalls = DefaultIncomingCalls;
const expectedIncomingCalls = {
incomingCalls: [{
callID: 'callDM',
callerID: 'user-5',
callerModel: {username: 'user-5'},
channelID: 'channel-private',
myUserId: 'myId',
serverUrl: 'server1',
startAt: 123,
type: 0,
}],
};
const dismissedCalls = {
'channel-dm': {...callDM, dismissed: {myId: true}},
};
const callIStarted: Call = {
id: 'callIStartedid',
participants: {
'user-5': {id: 'user-5', muted: false, raisedHand: 0},
},
channelId: 'channel-private2',
startTime: 123,
screenOn: '',
threadId: 'thread-4',
ownerId: 'myId',
hostId: 'user-5',
dismissed: {},
};
const callImIn: Call = {
id: 'callImInId',
participants: {},
channelId: 'channel-private2',
startTime: 123,
screenOn: '',
threadId: 'thread-4',
ownerId: 'user-5',
hostId: 'user-5',
dismissed: {},
};
const currentCallStateImIn: CurrentCall = {
...DefaultCurrentCall,
serverUrl: 'server1',
myUserId: 'myId',
...callImIn,
};
// setup
await DatabaseManager.init(['server1']);
const {result} = renderHook(() => {
return [
useCallsState('server1'),
useCurrentCall(),
useIncomingCalls(),
];
});
act(() => {
setCallsConfig('server1', {...DefaultCallsConfig, EnableRinging: true});
});
assert.deepEqual(result.current[0], DefaultCallsState);
assert.deepEqual(result.current[1], initialCurrentCallState);
assert.deepEqual(result.current[2], initialIncomingCalls);
// test incoming call on load
await act(async () => setCalls('server1', 'myId', afterLoadCallsState.calls, {}));
assert.deepEqual(result.current[0], afterLoadCallsState);
assert.deepEqual(result.current[1], initialCurrentCallState);
assert.deepEqual(result.current[2], expectedIncomingCalls);
// test dismissing (same path for manually dismissing, joining that call,
// or receiving ws event from dismissing/joining from another client)
act(() => removeIncomingCall('server1', 'callDM'));
assert.deepEqual(result.current[0], afterLoadCallsState);
assert.deepEqual(result.current[1], initialCurrentCallState);
assert.deepEqual(result.current[2], DefaultIncomingCalls);
// test load call, but call has been dismissed
await act(async () => setCalls('server1', 'myId', dismissedCalls, {}));
assert.deepEqual(result.current[2], DefaultIncomingCalls);
// test load call, then load same call again (eg from ws event): should only have one notification
await act(async () => setCalls('server1', 'myId', afterLoadCallsState.calls, {}));
assert.deepEqual(result.current[2], expectedIncomingCalls);
await act(async () => processIncomingCalls('server1', [calls['channel-dm']]));
assert.deepEqual(result.current[2], expectedIncomingCalls);
// test received ws event for a call I started
await act(async () => processIncomingCalls('server1', [callIStarted]));
assert.deepEqual(result.current[2], expectedIncomingCalls);
// test received ws event for a call I am in
await act(async () => {
setCurrentCall(currentCallStateImIn);
await processIncomingCalls('server1', [callImIn]);
});
assert.deepEqual(result.current[1], currentCallStateImIn);
assert.deepEqual(result.current[2], expectedIncomingCalls);
});
});

View file

@ -12,30 +12,37 @@ import {
getChannelsWithCalls,
getCurrentCall,
getGlobalCallsState,
getIncomingCalls,
setCallsConfig,
setCallsState,
setChannelsWithCalls,
setCurrentCall,
setGlobalCallsState,
setIncomingCalls,
} from '@calls/state';
import {
type AudioDeviceInfo,
type Call,
type CallsConfigState,
type ChannelsWithCalls,
ChannelType,
type CurrentCall,
DefaultCall,
DefaultCurrentCall,
type IncomingCallNotification,
type ReactionStreamEmoji,
} from '@calls/types/calls';
import {Calls, Screens} from '@constants';
import {Calls, General, Screens} from '@constants';
import DatabaseManager from '@database/manager';
import {getChannelById} from '@queries/servers/channel';
import {getThreadById} from '@queries/servers/thread';
import {getUserById} from '@queries/servers/user';
import {isDMorGM} from '@utils/channel';
import {logDebug} from '@utils/log';
import type {CallRecordingState, UserReactionData} from '@mattermost/calls/lib/types';
export const setCalls = (serverUrl: string, myUserId: string, calls: Dictionary<Call>, enabled: Dictionary<boolean>) => {
export const setCalls = async (serverUrl: string, myUserId: string, calls: Dictionary<Call>, enabled: Dictionary<boolean>) => {
const channelsWithCalls = Object.keys(calls).reduce(
(accum, next) => {
accum[next] = true;
@ -45,6 +52,8 @@ export const setCalls = (serverUrl: string, myUserId: string, calls: Dictionary<
setCallsState(serverUrl, {myUserId, calls, enabled});
await processIncomingCalls(serverUrl, Object.values(calls), false);
// Does the current call need to be updated?
const currentCall = getCurrentCall();
if (!currentCall || !calls[currentCall.channelId]) {
@ -61,6 +70,118 @@ export const setCalls = (serverUrl: string, myUserId: string, calls: Dictionary<
setCurrentCall(nextCall);
};
export const processIncomingCalls = async (serverUrl: string, calls: Call[], keepExisting = true) => {
if (!getCallsConfig(serverUrl).EnableRinging) {
return;
}
const database = DatabaseManager.serverDatabases[serverUrl]?.database;
if (!database) {
return;
}
// Do we have incoming calls we should notify about?
const incomingCalls = getIncomingCalls().incomingCalls;
const existingCalls = getCallsState(serverUrl).calls;
const myUserId = getCallsState(serverUrl).myUserId;
const newIncoming: IncomingCallNotification[] = [];
for await (const call of calls) {
// dismissed already?
if (call.dismissed[myUserId] || existingCalls[call.channelId]?.dismissed[myUserId]) {
continue;
}
// already in our incomingCalls notifications?
if (incomingCalls.findIndex((c) => c.callID === call.id) >= 0) {
continue;
}
// Never send a notification for a call you started, or a call you are currently in.
if (myUserId === call.ownerId || getCurrentCall()?.id === call.id) {
continue;
}
const channel = await getChannelById(database, call.channelId);
if (!channel) {
logDebug('calls: processIncomingCalls could not find channel by id', call.channelId, 'for serverUrl', serverUrl);
continue;
}
if (!isDMorGM(channel)) {
continue;
}
const callerModel = await getUserById(database, call.ownerId);
newIncoming.push({
serverUrl,
myUserId,
callID: call.id,
callerID: call.ownerId,
callerModel,
channelID: call.channelId,
startAt: call.startTime,
type: channel.type === General.DM_CHANNEL ? ChannelType.DM : ChannelType.GM,
});
}
if (newIncoming.length === 0 && keepExisting) {
return;
}
if (keepExisting) {
newIncoming.push(...incomingCalls);
} else {
const removedThisServer = incomingCalls.filter((ic) => ic.serverUrl !== serverUrl);
newIncoming.push(...removedThisServer);
}
if (newIncoming.length === 0 && incomingCalls.length === 0) {
return;
}
newIncoming.sort((a, b) => a.startAt - b.startAt);
setIncomingCalls({incomingCalls: newIncoming});
};
const getChannelIdFromCallId = (serverUrl: string, callId: string) => {
const callsState = getCallsState(serverUrl);
for (const call of Object.values(callsState.calls)) {
if (call.id === callId) {
return call.channelId;
}
}
return undefined;
};
export const removeIncomingCall = (serverUrl: string, callId: string, channelId?: string) => {
if (!getCallsConfig(serverUrl).EnableRinging) {
return;
}
const incomingCalls = getIncomingCalls();
const newIncomingCalls = incomingCalls.incomingCalls.filter((ic) => ic.callID !== callId);
if (incomingCalls.incomingCalls.length !== newIncomingCalls.length) {
setIncomingCalls({incomingCalls: newIncomingCalls});
}
let chId = channelId;
if (!chId) {
chId = getChannelIdFromCallId(serverUrl, callId);
if (!chId) {
return;
}
}
const callsState = getCallsState(serverUrl);
const nextCalls = {...callsState.calls};
if (nextCalls[chId]) {
nextCalls[chId].dismissed[callsState.myUserId] = true;
}
setCallsState(serverUrl, {...callsState, calls: nextCalls});
};
export const setCallForChannel = (serverUrl: string, channelId: string, enabled?: boolean, call?: Call) => {
const callsState = getCallsState(serverUrl);
let nextEnabled = callsState.enabled;
@ -136,6 +257,10 @@ export const userJoinedCall = (serverUrl: string, channelId: string, userId: str
setCurrentCall(nextCurrentCall);
}
if (userId === callsState.myUserId) {
removeIncomingCall(serverUrl, callsState.calls[channelId].id, channelId);
}
};
export const userLeftCall = (serverUrl: string, channelId: string, userId: string) => {
@ -159,6 +284,9 @@ export const userLeftCall = (serverUrl: string, channelId: string, userId: strin
if (Object.keys(nextCall.participants).length === 0) {
delete nextCalls[channelId];
const callId = callsState.calls[channelId].id;
removeIncomingCall(serverUrl, callId, channelId);
const channelsWithCalls = getChannelsWithCalls(serverUrl);
const nextChannelsWithCalls = {...channelsWithCalls};
delete nextChannelsWithCalls[channelId];
@ -230,6 +358,8 @@ export const callStarted = async (serverUrl: string, call: Call) => {
nextCalls[call.channelId] = call;
setCallsState(serverUrl, {...callsState, calls: nextCalls});
await processIncomingCalls(serverUrl, [call]);
const nextChannelsWithCalls = {...getChannelsWithCalls(serverUrl), [call.channelId]: true};
setChannelsWithCalls(serverUrl, nextChannelsWithCalls);
@ -263,6 +393,7 @@ export const callStarted = async (serverUrl: string, call: Call) => {
export const callEnded = (serverUrl: string, channelId: string) => {
const callsState = getCallsState(serverUrl);
const nextCalls = {...callsState.calls};
const callId = nextCalls[channelId]?.id || '';
delete nextCalls[channelId];
setCallsState(serverUrl, {...callsState, calls: nextCalls});
@ -271,6 +402,8 @@ export const callEnded = (serverUrl: string, channelId: string) => {
delete nextChannelsWithCalls[channelId];
setChannelsWithCalls(serverUrl, nextChannelsWithCalls);
removeIncomingCall(serverUrl, callId, channelId);
// currentCall is set to null by the disconnect.
};

View file

@ -0,0 +1,37 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {useEffect, useState} from 'react';
import {BehaviorSubject} from 'rxjs';
import {DefaultIncomingCalls, type IncomingCalls} from '@calls/types/calls';
const incomingCallsSubject = new BehaviorSubject(DefaultIncomingCalls);
export const getIncomingCalls = () => {
return incomingCallsSubject.value;
};
export const setIncomingCalls = (state: IncomingCalls) => {
incomingCallsSubject.next(state);
};
export const observeIncomingCalls = () => {
return incomingCallsSubject.asObservable();
};
export const useIncomingCalls = () => {
const [state, setState] = useState(DefaultIncomingCalls);
useEffect(() => {
const subscription = incomingCallsSubject.subscribe((incomingCalls) => {
setState(incomingCalls);
});
return () => {
subscription?.unsubscribe();
};
}, []);
return state;
};

View file

@ -7,3 +7,4 @@ export * from './calls_config';
export * from './current_call';
export * from './channels_with_calls';
export * from './global_calls_state';
export * from './incoming_calls';

View file

@ -24,7 +24,32 @@ export const DefaultCallsState: CallsState = {
enabled: {},
};
export enum ChannelType {
DM,
GM
}
export type IncomingCallNotification = {
serverUrl: string;
myUserId: string;
callID: string;
channelID: string;
callerID: string;
callerModel?: UserModel;
startAt: number;
type: ChannelType;
}
export type IncomingCalls = {
incomingCalls: IncomingCallNotification[];
}
export const DefaultIncomingCalls: IncomingCalls = {
incomingCalls: [],
};
export type Call = {
id: string;
participants: Dictionary<CallParticipant>;
channelId: string;
startTime: number;
@ -33,9 +58,11 @@ export type Call = {
ownerId: string;
recState?: CallRecordingState;
hostId: string;
dismissed: Dictionary<boolean>;
}
export const DefaultCall: Call = {
id: '',
participants: {},
channelId: '',
startTime: 0,
@ -43,6 +70,7 @@ export const DefaultCall: Call = {
threadId: '',
ownerId: '',
hostId: '',
dismissed: {},
};
export enum AudioDevice {
@ -123,6 +151,7 @@ export const DefaultCallsConfig: CallsConfigState = {
MaxRecordingDuration: 60,
AllowScreenSharing: true,
EnableSimulcast: false,
EnableRinging: false,
};
export type ApiResp = {

View file

@ -1,7 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {makeCallsBaseAndBadgeRGB, rgbToCSS} from '@mattermost/calls/lib/utils';
import {makeCallsBaseAndBadgeRGB, rgbToCSS} from '@mattermost/calls';
import {Alert} from 'react-native';
import {Calls, Post} from '@constants';

View file

@ -7,6 +7,7 @@ import {type Edge, SafeAreaView, useSafeAreaInsets} from 'react-native-safe-area
import {storeLastViewedChannelIdAndServer, removeLastViewedChannelIdAndServer} from '@actions/app/global';
import FloatingCallContainer from '@calls/components/floating_call_container';
import {IncomingCallsContainer} from '@calls/components/incoming_calls_container';
import {RoundedHeaderCalls} from '@calls/components/join_call_banner/rounded_header_calls';
import FreezeScreen from '@components/freeze_screen';
import PostDraft from '@components/post_draft';
@ -30,10 +31,10 @@ import type {KeyboardTrackingViewRef} from 'react-native-keyboard-tracking-view'
type ChannelProps = {
channelId: string;
componentId?: AvailableScreens;
isCallInCurrentChannel: boolean;
showJoinCallBanner: boolean;
isInACall: boolean;
isInCurrentChannelCall: boolean;
isCallsEnabledInChannel: boolean;
showIncomingCalls: boolean;
isTabletView?: boolean;
};
@ -49,10 +50,10 @@ const styles = StyleSheet.create({
const Channel = ({
channelId,
componentId,
isCallInCurrentChannel,
showJoinCallBanner,
isInACall,
isInCurrentChannelCall,
isCallsEnabledInChannel,
showIncomingCalls,
isTabletView,
}: ChannelProps) => {
const isTablet = useIsTablet();
@ -98,8 +99,7 @@ const Channel = ({
setContainerHeight(e.nativeEvent.layout.height);
}, []);
const showJoinCallBanner = isCallInCurrentChannel && !isInCurrentChannelCall;
const renderCallsComponents = showJoinCallBanner || isInACall;
const showFloatingCallContainer = showJoinCallBanner || isInACall;
return (
<FreezeScreen>
@ -139,13 +139,20 @@ const Channel = ({
/>
</>
}
{renderCallsComponents &&
{showFloatingCallContainer &&
<FloatingCallContainer
channelId={channelId}
showJoinCallBanner={showJoinCallBanner}
isInACall={isInACall}
/>
}
{showIncomingCalls &&
<IncomingCallsContainer
channelId={channelId}
showingJoinCallBanner={showJoinCallBanner}
showingCurrentCallBanner={isInACall}
/>
}
</SafeAreaView>
</FreezeScreen>
);

View file

@ -6,7 +6,12 @@ import withObservables from '@nozbe/with-observables';
import {combineLatest, distinctUntilChanged, of as of$, switchMap} from 'rxjs';
import {observeIsCallsEnabledInChannel} from '@calls/observers';
import {observeChannelsWithCalls, observeCurrentCall} from '@calls/state';
import {
observeCallsState,
observeChannelsWithCalls,
observeCurrentCall,
observeIncomingCalls,
} from '@calls/state';
import {withServerUrl} from '@context/server';
import {observeCurrentChannelId} from '@queries/servers/system';
@ -34,16 +39,28 @@ const enhanced = withObservables([], ({database, serverUrl}: EnhanceProps) => {
switchMap((call) => of$(Boolean(call?.connected))),
distinctUntilChanged(),
);
const dismissed = combineLatest([channelId, observeCallsState(serverUrl)]).pipe(
switchMap(([id, state]) => of$(Boolean(state.calls[id]?.dismissed[state.myUserId]))),
distinctUntilChanged(),
);
const isInCurrentChannelCall = combineLatest([channelId, ccChannelId]).pipe(
switchMap(([id, ccId]) => of$(id === ccId)),
distinctUntilChanged(),
);
const showJoinCallBanner = combineLatest([isCallInCurrentChannel, dismissed, isInCurrentChannelCall]).pipe(
switchMap(([isCall, dism, inCurrCall]) => of$(Boolean(isCall && !dism && !inCurrCall))),
distinctUntilChanged(),
);
const showIncomingCalls = observeIncomingCalls().pipe(
switchMap((ics) => of$(ics.incomingCalls.length > 0)),
distinctUntilChanged(),
);
return {
channelId,
isCallInCurrentChannel,
showJoinCallBanner,
isInACall,
isInCurrentChannelCall,
showIncomingCalls,
isCallsEnabledInChannel: observeIsCallsEnabledInChannel(database, serverUrl, channelId),
};
});

View file

@ -5,7 +5,7 @@ import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
import withObservables from '@nozbe/with-observables';
import {distinctUntilChanged, switchMap, combineLatest, of as of$} from 'rxjs';
import {observeChannelsWithCalls, observeCurrentCall} from '@calls/state';
import {observeCallsState, observeChannelsWithCalls, observeCurrentCall, observeIncomingCalls} from '@calls/state';
import {withServerUrl} from '@context/server';
import {observePost} from '@queries/servers/post';
import {observeIsCRTEnabled} from '@queries/servers/thread';
@ -41,16 +41,28 @@ const enhanced = withObservables(['rootId'], ({database, serverUrl, rootId}: Enh
switchMap((call) => of$(Boolean(call?.connected))),
distinctUntilChanged(),
);
const dismissed = combineLatest([channelId, observeCallsState(serverUrl)]).pipe(
switchMap(([id, state]) => of$(Boolean(state.calls[id]?.dismissed[state.myUserId]))),
distinctUntilChanged(),
);
const isInCurrentChannelCall = combineLatest([channelId, ccChannelId]).pipe(
switchMap(([id, ccId]) => of$(id === ccId)),
distinctUntilChanged(),
);
const showJoinCallBanner = combineLatest([isCallInCurrentChannel, dismissed, isInCurrentChannelCall]).pipe(
switchMap(([isCall, dism, inCurrCall]) => of$(Boolean(isCall && !dism && !inCurrCall))),
distinctUntilChanged(),
);
const showIncomingCalls = observeIncomingCalls().pipe(
switchMap((ics) => of$(ics.incomingCalls.length > 0)),
distinctUntilChanged(),
);
return {
isCRTEnabled: observeIsCRTEnabled(database),
isCallInCurrentChannel,
showJoinCallBanner,
isInACall,
isInCurrentChannelCall,
showIncomingCalls,
rootId: of$(rId),
rootPost,
};

View file

@ -8,6 +8,7 @@ import {type Edge, SafeAreaView} from 'react-native-safe-area-context';
import {storeLastViewedThreadIdAndServer, removeLastViewedThreadIdAndServer} from '@actions/app/global';
import FloatingCallContainer from '@calls/components/floating_call_container';
import {IncomingCallsContainer} from '@calls/components/incoming_calls_container';
import {RoundedHeaderCalls} from '@calls/components/join_call_banner/rounded_header_calls';
import FreezeScreen from '@components/freeze_screen';
import PostDraft from '@components/post_draft';
@ -30,9 +31,9 @@ import type {KeyboardTrackingViewRef} from 'react-native-keyboard-tracking-view'
type ThreadProps = {
componentId: AvailableScreens;
isCRTEnabled: boolean;
isCallInCurrentChannel: boolean;
showJoinCallBanner: boolean;
isInACall: boolean;
isInCurrentChannelCall: boolean;
showIncomingCalls: boolean;
rootId: string;
rootPost?: PostModel;
};
@ -49,9 +50,9 @@ const Thread = ({
isCRTEnabled,
rootId,
rootPost,
isCallInCurrentChannel,
showJoinCallBanner,
isInACall,
isInCurrentChannelCall,
showIncomingCalls,
}: ThreadProps) => {
const postDraftRef = useRef<KeyboardTrackingViewRef>(null);
const [containerHeight, setContainerHeight] = useState(0);
@ -110,8 +111,7 @@ const Thread = ({
setContainerHeight(e.nativeEvent.layout.height);
}, []);
const showJoinCallBanner = isCallInCurrentChannel && !isInCurrentChannelCall;
const renderCallsComponents = showJoinCallBanner || isInACall;
const showFloatingCallContainer = showJoinCallBanner || isInACall;
return (
<FreezeScreen>
@ -144,7 +144,7 @@ const Thread = ({
/>
</>
}
{renderCallsComponents &&
{showFloatingCallContainer &&
<FloatingCallContainer
channelId={rootPost!.channelId}
showJoinCallBanner={showJoinCallBanner}
@ -152,6 +152,14 @@ const Thread = ({
threadScreen={true}
/>
}
{showIncomingCalls &&
<IncomingCallsContainer
channelId={rootPost!.channelId}
showingJoinCallBanner={showJoinCallBanner}
showingCurrentCallBanner={isInACall}
threadScreen={true}
/>
}
</SafeAreaView>
</FreezeScreen>
);

View file

@ -441,6 +441,9 @@
"mobile.calls_host_rec_stopped": "You can find the recording in this call's chat thread once it's finished processing.",
"mobile.calls_host_rec_stopped_title": "Recording has stopped. Processing...",
"mobile.calls_host_rec_title": "You are recording",
"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_button": "Join",
"mobile.calls_join_call": "Join call",
"mobile.calls_lasted": "Lasted {duration}",
"mobile.calls_leave": "Leave",

8
package-lock.json generated
View file

@ -17,7 +17,7 @@
"@formatjs/intl-pluralrules": "5.2.4",
"@formatjs/intl-relativetimeformat": "11.2.4",
"@gorhom/bottom-sheet": "4.4.7",
"@mattermost/calls": "github:mattermost/calls-common#v0.14.0",
"@mattermost/calls": "github:mattermost/calls-common#v0.17.0",
"@mattermost/compass-icons": "0.1.37",
"@mattermost/react-native-emm": "1.3.5",
"@mattermost/react-native-network-client": "1.3.8",
@ -3402,7 +3402,7 @@
"node_modules/@mattermost/calls": {
"name": "@calls/common",
"version": "0.14.0",
"resolved": "git+ssh://git@github.com/mattermost/calls-common.git#d539eb36f9549075b0a0a6578174db31297c45ae"
"resolved": "git+ssh://git@github.com/mattermost/calls-common.git#43011d8e9c7985b0542628329be098715a43dc03"
},
"node_modules/@mattermost/commonmark": {
"version": "0.30.1-0",
@ -25390,8 +25390,8 @@
}
},
"@mattermost/calls": {
"version": "git+ssh://git@github.com/mattermost/calls-common.git#d539eb36f9549075b0a0a6578174db31297c45ae",
"from": "@mattermost/calls@github:mattermost/calls-common#v0.14.0"
"version": "git+ssh://git@github.com/mattermost/calls-common.git#43011d8e9c7985b0542628329be098715a43dc03",
"from": "@mattermost/calls@github:mattermost/calls-common#v0.17.0"
},
"@mattermost/commonmark": {
"version": "0.30.1-0",

View file

@ -14,7 +14,7 @@
"@formatjs/intl-pluralrules": "5.2.4",
"@formatjs/intl-relativetimeformat": "11.2.4",
"@gorhom/bottom-sheet": "4.4.7",
"@mattermost/calls": "github:mattermost/calls-common#v0.14.0",
"@mattermost/calls": "github:mattermost/calls-common#v0.17.0",
"@mattermost/compass-icons": "0.1.37",
"@mattermost/react-native-emm": "1.3.5",
"@mattermost/react-native-network-client": "1.3.8",