From 83f81985cd94ef1b23ef5b0a25813a0305ff9efe Mon Sep 17 00:00:00 2001 From: Christopher Poile Date: Wed, 30 Aug 2023 13:58:54 -0400 Subject: [PATCH] MM-53486 - Incoming calls notification banner (#7477) * upgrade calls-common * incoming call notifications * tests * i18n * PR comments * PR comments * PR comment --- app/actions/websocket/index.ts | 4 + app/constants/view.ts | 1 + app/constants/websocket.ts | 1 + app/products/calls/actions/calls.test.ts | 17 +- app/products/calls/actions/calls.ts | 36 ++- app/products/calls/actions/index.ts | 1 + app/products/calls/alerts.ts | 9 + app/products/calls/client/rest.ts | 8 + app/products/calls/components/call_avatar.tsx | 4 +- .../call_notification/call_notification.tsx | 227 ++++++++++++++++++ .../components/call_notification/index.ts | 42 ++++ .../current_call_bar/current_call_bar.tsx | 5 +- .../components/incoming_calls_container.tsx | 71 ++++++ app/products/calls/components/message_bar.tsx | 2 +- .../connection/websocket_event_handlers.ts | 36 ++- .../calls/screens/call_screen/call_screen.tsx | 27 ++- app/products/calls/state/actions.test.ts | 159 +++++++++++- app/products/calls/state/actions.ts | 137 ++++++++++- app/products/calls/state/incoming_calls.ts | 37 +++ app/products/calls/state/index.ts | 1 + app/products/calls/types/calls.ts | 29 +++ app/products/calls/utils.ts | 2 +- app/screens/channel/channel.tsx | 21 +- app/screens/channel/index.tsx | 23 +- app/screens/thread/index.tsx | 18 +- app/screens/thread/thread.tsx | 22 +- assets/base/i18n/en.json | 3 + package-lock.json | 8 +- package.json | 2 +- 29 files changed, 890 insertions(+), 63 deletions(-) create mode 100644 app/products/calls/components/call_notification/call_notification.tsx create mode 100644 app/products/calls/components/call_notification/index.ts create mode 100644 app/products/calls/components/incoming_calls_container.tsx create mode 100644 app/products/calls/state/incoming_calls.ts diff --git a/app/actions/websocket/index.ts b/app/actions/websocket/index.ts index a2862aa3a..275faafc3 100644 --- a/app/actions/websocket/index.ts +++ b/app/actions/websocket/index.ts @@ -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); diff --git a/app/constants/view.ts b/app/constants/view.ts index 057fc53a7..29cbfb71c 100644 --- a/app/constants/view.ts +++ b/app/constants/view.ts @@ -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; diff --git a/app/constants/websocket.ts b/app/constants/websocket.ts index 39ba559e4..454551067 100644 --- a/app/constants/websocket.ts +++ b/app/constants/websocket.ts @@ -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', diff --git a/app/products/calls/actions/calls.test.ts b/app/products/calls/actions/calls.test.ts index 9b3b018a3..15625e64a 100644 --- a/app/products/calls/actions/calls.test.ts +++ b/app/products/calls/actions/calls.test.ts @@ -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'); + }); }); diff --git a/app/products/calls/actions/calls.ts b/app/products/calls/actions/calls.ts index 4f560a143..5d80e32f5 100644 --- a/app/products/calls/actions/calls.ts +++ b/app/products/calls/actions/calls.ts @@ -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), 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 }> => { diff --git a/app/products/calls/actions/index.ts b/app/products/calls/actions/index.ts index fec8ba771..d443dd440 100644 --- a/app/products/calls/actions/index.ts +++ b/app/products/calls/actions/index.ts @@ -15,6 +15,7 @@ export { handleCallsSlashCommand, startCallRecording, stopCallRecording, + dismissIncomingCall, } from './calls'; export {hasMicrophonePermission} from './permissions'; diff --git a/app/products/calls/alerts.ts b/app/products/calls/alerts.ts index 22bf8b78c..598fc18d5 100644 --- a/app/products/calls/alerts.ts +++ b/app/products/calls/alerts.ts @@ -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'}); diff --git a/app/products/calls/client/rest.ts b/app/products/calls/client/rest.ts index 7c2f47ed9..8e88e4e0b 100644 --- a/app/products/calls/client/rest.ts +++ b/app/products/calls/client/rest.ts @@ -15,6 +15,7 @@ export interface ClientCallsMix { genTURNCredentials: () => Promise; startCallRecording: (callId: string) => Promise; stopCallRecording: (callId: string) => Promise; + dismissCall: (channelId: string) => Promise; } 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; diff --git a/app/products/calls/components/call_avatar.tsx b/app/products/calls/components/call_avatar.tsx index e29c09534..f0ad9f90f 100644 --- a/app/products/calls/components/call_avatar.tsx +++ b/app/products/calls/components/call_avatar.tsx @@ -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]); diff --git a/app/products/calls/components/call_notification/call_notification.tsx b/app/products/calls/components/call_notification/call_notification.tsx new file mode 100644 index 000000000..20d3c2249 --- /dev/null +++ b/app/products/calls/components/call_notification/call_notification.tsx @@ -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 = ( + {name} is inviting you to a call'} + values={{ + b: (text: string) => {text}, + name: displayUsername(incomingCall.callerModel, intl.locale, teammateNameDisplay), + }} + style={style.text} + numberOfLines={2} + ellipsizeMode={'tail'} + /> + ); + } else { + message = ( + {name} is inviting you to a call with {num, plural, one {# other} other {# others}}'} + values={{ + b: (text: string) => {text}, + name: displayUsername(incomingCall.callerModel, intl.locale, teammateNameDisplay), + num: (members?.length || 2) - 1, + }} + style={style.text} + numberOfLines={2} + ellipsizeMode={'tail'} + /> + ); + } + const joinLabel = ( + + ); + + return ( + + + + {message} + + + {joinLabel} + + + + + + + ); +}; diff --git a/app/products/calls/components/call_notification/index.ts b/app/products/calls/components/call_notification/index.ts new file mode 100644 index 000000000..7e7c807f8 --- /dev/null +++ b/app/products/calls/components/call_notification/index.ts @@ -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); diff --git a/app/products/calls/components/current_call_bar/current_call_bar.tsx b/app/products/calls/components/current_call_bar/current_call_bar.tsx index 4b063838d..d5698799f 100644 --- a/app/products/calls/components/current_call_bar/current_call_bar.tsx +++ b/app/products/calls/components/current_call_bar/current_call_bar.tsx @@ -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, }, diff --git a/app/products/calls/components/incoming_calls_container.tsx b/app/products/calls/components/incoming_calls_container.tsx new file mode 100644 index 000000000..b4301201b --- /dev/null +++ b/app/products/calls/components/incoming_calls_container.tsx @@ -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 ( + + {calls.map((ic) => ( + + ))} + + ); +}; diff --git a/app/products/calls/components/message_bar.tsx b/app/products/calls/components/message_bar.tsx index 3a5ed10d9..9fa6ec2f8 100644 --- a/app/products/calls/components/message_bar.tsx +++ b/app/products/calls/components/message_bar.tsx @@ -28,7 +28,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: CallsTheme) => ( zIndex: 10, }, errorWrapper: { - padding: 10, + padding: 8, paddingTop: 0, }, errorBar: { diff --git a/app/products/calls/connection/websocket_event_handlers.ts b/app/products/calls/connection/websocket_event_handlers.ts index 69585cf61..5168e4272 100644 --- a/app/products/calls/connection/websocket_event_handlers.ts +++ b/app/products/calls/connection/websocket_event_handlers.ts @@ -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) => { @@ -59,12 +67,8 @@ export const handleCallUserVoiceOff = (msg: WebSocketMessage }; export const handleCallStarted = (serverUrl: string, msg: WebSocketMessage) => { - 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) => { setHost(serverUrl, msg.broadcast.channel_id, msg.data.hostID); }; + +export const handleUserDismissedNotification = async (serverUrl: string, msg: WebSocketMessage) => { + 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); +}; diff --git a/app/products/calls/screens/call_screen/call_screen.tsx b/app/products/calls/screens/call_screen/call_screen.tsx index 9161aab3e..7f353888c 100644 --- a/app/products/calls/screens/call_screen/call_screen.tsx +++ b/app/products/calls/screens/call_screen/call_screen.tsx @@ -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 && } + {showIncomingCalls && + + {incomingCalls.incomingCalls.map((ic) => ( + + ))} + + } {micPermissionsError && ({ })), })); +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); + }); }); diff --git a/app/products/calls/state/actions.ts b/app/products/calls/state/actions.ts index 2872f32e7..f63e9fd6e 100644 --- a/app/products/calls/state/actions.ts +++ b/app/products/calls/state/actions.ts @@ -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, enabled: Dictionary) => { +export const setCalls = async (serverUrl: string, myUserId: string, calls: Dictionary, enabled: Dictionary) => { 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. }; diff --git a/app/products/calls/state/incoming_calls.ts b/app/products/calls/state/incoming_calls.ts new file mode 100644 index 000000000..9a079cde2 --- /dev/null +++ b/app/products/calls/state/incoming_calls.ts @@ -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; +}; diff --git a/app/products/calls/state/index.ts b/app/products/calls/state/index.ts index ae83ecc61..fbe0e6bca 100644 --- a/app/products/calls/state/index.ts +++ b/app/products/calls/state/index.ts @@ -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'; diff --git a/app/products/calls/types/calls.ts b/app/products/calls/types/calls.ts index 70d35f1ec..963945fdd 100644 --- a/app/products/calls/types/calls.ts +++ b/app/products/calls/types/calls.ts @@ -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; channelId: string; startTime: number; @@ -33,9 +58,11 @@ export type Call = { ownerId: string; recState?: CallRecordingState; hostId: string; + dismissed: Dictionary; } 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 = { diff --git a/app/products/calls/utils.ts b/app/products/calls/utils.ts index f6733ee00..63ed92580 100644 --- a/app/products/calls/utils.ts +++ b/app/products/calls/utils.ts @@ -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'; diff --git a/app/screens/channel/channel.tsx b/app/screens/channel/channel.tsx index d65f31a55..4781e6ff7 100644 --- a/app/screens/channel/channel.tsx +++ b/app/screens/channel/channel.tsx @@ -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 ( @@ -139,13 +139,20 @@ const Channel = ({ /> } - {renderCallsComponents && + {showFloatingCallContainer && } + {showIncomingCalls && + + } ); diff --git a/app/screens/channel/index.tsx b/app/screens/channel/index.tsx index 932149181..116c1d73b 100644 --- a/app/screens/channel/index.tsx +++ b/app/screens/channel/index.tsx @@ -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), }; }); diff --git a/app/screens/thread/index.tsx b/app/screens/thread/index.tsx index 17a588ce0..f2c6a4231 100644 --- a/app/screens/thread/index.tsx +++ b/app/screens/thread/index.tsx @@ -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, }; diff --git a/app/screens/thread/thread.tsx b/app/screens/thread/thread.tsx index 542be3612..f653b2801 100644 --- a/app/screens/thread/thread.tsx +++ b/app/screens/thread/thread.tsx @@ -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(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 ( @@ -144,7 +144,7 @@ const Thread = ({ /> } - {renderCallsComponents && + {showFloatingCallContainer && } + {showIncomingCalls && + + } ); diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index d4e0c3ea1..1858946bd 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -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": "{name} is inviting you to a call", + "mobile.calls_incoming_gm": "{name} is inviting you to a call with {num, plural, one {# other} other {# others}}", + "mobile.calls_join_button": "Join", "mobile.calls_join_call": "Join call", "mobile.calls_lasted": "Lasted {duration}", "mobile.calls_leave": "Leave", diff --git a/package-lock.json b/package-lock.json index 2c813978c..aa1932e65 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 35c04a548..d77a7d89c 100644 --- a/package.json +++ b/package.json @@ -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",