From debcc99480904093098530d7602cb72fc7418f77 Mon Sep 17 00:00:00 2001 From: Jason Frerich Date: Wed, 2 Nov 2022 12:15:54 -0500 Subject: [PATCH 01/12] [Gekidou MM-48006] Show keyboard when select a modifier (#6714) --- app/components/navigation_header/index.tsx | 12 ++++--- app/components/navigation_header/search.tsx | 39 ++++++++++++--------- app/components/search/index.tsx | 3 +- app/screens/home/index.tsx | 7 ++-- app/screens/home/search/search.tsx | 11 +++++- 5 files changed, 43 insertions(+), 29 deletions(-) diff --git a/app/components/navigation_header/index.tsx b/app/components/navigation_header/index.tsx index 7b2183013..365ee8c1f 100644 --- a/app/components/navigation_header/index.tsx +++ b/app/components/navigation_header/index.tsx @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React from 'react'; +import React, {forwardRef} from 'react'; import Animated, {useAnimatedStyle, useDerivedValue} from 'react-native-reanimated'; import {SEARCH_INPUT_HEIGHT, SEARCH_INPUT_MARGIN} from '@constants/view'; @@ -14,7 +14,7 @@ import Header, {HeaderRightButton} from './header'; import NavigationHeaderLargeTitle from './large'; import NavigationSearch from './search'; -import type {SearchProps} from '@components/search'; +import type {SearchProps, SearchRef} from '@components/search'; type Props = SearchProps & { hasSearch?: boolean; @@ -41,7 +41,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ }, })); -const NavigationHeader = ({ +const NavigationHeader = forwardRef(({ hasSearch = false, isLargeTitle = false, leftComponent, @@ -56,7 +56,7 @@ const NavigationHeader = ({ title = '', hideHeader, ...searchProps -}: Props) => { +}: Props, ref) => { const theme = useTheme(); const styles = getStyleSheet(theme); @@ -125,12 +125,14 @@ const NavigationHeader = ({ hideHeader={hideHeader} theme={theme} topStyle={searchTopStyle} + ref={ref} /> } ); -}; +}); +NavigationHeader.displayName = 'NavHeader'; export default NavigationHeader; diff --git a/app/components/navigation_header/search.tsx b/app/components/navigation_header/search.tsx index 6cb20421f..ccba4a315 100644 --- a/app/components/navigation_header/search.tsx +++ b/app/components/navigation_header/search.tsx @@ -1,11 +1,11 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React, {useCallback, useEffect, useMemo} from 'react'; +import React, {forwardRef, useCallback, useEffect, useMemo} from 'react'; import {DeviceEventEmitter, Keyboard, NativeSyntheticEvent, Platform, TextInputFocusEventData, ViewStyle} from 'react-native'; import Animated, {AnimatedStyleProp} from 'react-native-reanimated'; -import Search, {SearchProps} from '@components/search'; +import Search, {SearchProps, SearchRef} from '@components/search'; import {Events} from '@constants'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; import {typography} from '@utils/typography'; @@ -31,12 +31,12 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ }, })); -const NavigationSearch = ({ +const NavigationSearch = forwardRef(({ hideHeader, theme, topStyle, ...searchProps -}: Props) => { +}: Props, ref) => { const styles = getStyleSheet(theme); const cancelButtonProps: SearchProps['cancelButtonProps'] = useMemo(() => ({ @@ -52,24 +52,27 @@ const NavigationSearch = ({ searchProps.onFocus?.(e); }, [hideHeader, searchProps.onFocus]); - useEffect(() => { - const show = Keyboard.addListener('keyboardDidShow', () => { - if (Platform.OS === 'android') { - DeviceEventEmitter.emit(Events.TAB_BAR_VISIBLE, false); - } - }); + const showEmitter = useCallback(() => { + if (Platform.OS === 'android') { + DeviceEventEmitter.emit(Events.TAB_BAR_VISIBLE, false); + } + }, []); - const hide = Keyboard.addListener('keyboardDidHide', () => { - if (Platform.OS === 'android') { - DeviceEventEmitter.emit(Events.TAB_BAR_VISIBLE, true); - } - }); + const hideEmitter = useCallback(() => { + if (Platform.OS === 'android') { + DeviceEventEmitter.emit(Events.TAB_BAR_VISIBLE, true); + } + }, []); + + useEffect(() => { + const show = Keyboard.addListener('keyboardDidShow', showEmitter); + const hide = Keyboard.addListener('keyboardDidHide', hideEmitter); return () => { hide.remove(); show.remove(); }; - }, []); + }, [hideEmitter, showEmitter]); return ( @@ -83,10 +86,12 @@ const NavigationSearch = ({ placeholderTextColor={changeOpacity(theme.sidebarText, Platform.select({android: 0.56, default: 0.72}))} searchIconColor={theme.sidebarText} selectionColor={theme.sidebarText} + ref={ref} /> ); -}; +}); +NavigationSearch.displayName = 'NavSearch'; export default NavigationSearch; diff --git a/app/components/search/index.tsx b/app/components/search/index.tsx index 24cfcab26..756ff377d 100644 --- a/app/components/search/index.tsx +++ b/app/components/search/index.tsx @@ -42,7 +42,7 @@ export type SearchProps = TextInputProps & { showLoading?: boolean; }; -type SearchRef = { +export type SearchRef = { blur: () => void; cancel: () => void; clear: () => void; @@ -151,7 +151,6 @@ const Search = forwardRef((props: SearchProps, ref) => { focus: () => { searchRef.current?.focus(); }, - }), [searchRef]); return ( diff --git a/app/screens/home/index.tsx b/app/screens/home/index.tsx index f612c643e..e19df8e3e 100644 --- a/app/screens/home/index.tsx +++ b/app/screens/home/index.tsx @@ -20,10 +20,9 @@ import Account from './account'; import ChannelList from './channel_list'; import RecentMentions from './recent_mentions'; import SavedMessages from './saved_messages'; +import Search from './search'; import TabBar from './tab_bar'; -// import Search from './search'; - import type {LaunchProps} from '@typings/launch'; if (Platform.OS === 'ios') { @@ -125,11 +124,11 @@ export default function HomeScreen(props: HomeProps) { > {() => } - {/* */} + /> { const clearRef = useRef(false); const cancelRef = useRef(false); + const searchRef = useRef(null); + const [cursorPosition, setCursorPosition] = useState(searchTerm?.length || 0); const [searchValue, setSearchValue] = useState(searchTerm || ''); const [searchTeamId, setSearchTeamId] = useState(teamId); @@ -144,6 +147,11 @@ const SearchScreen = ({teamId}: Props) => { setCursorPosition(newValue.length); }, []); + const handleModifierTextChange = useCallback((newValue: string) => { + searchRef.current?.focus?.(); + handleTextChange(newValue); + }, [handleTextChange]); + const handleLoading = useCallback((show: boolean) => { (showResults ? setResultsLoading : setLoading)(show); }, [showResults]); @@ -218,7 +226,7 @@ const SearchScreen = ({teamId}: Props) => { scrollEnabled={scrollEnabled} searchValue={searchValue} setRecentValue={handleRecentSearch} - setSearchValue={handleTextChange} + setSearchValue={handleModifierTextChange} setTeamId={setSearchTeamId} teamId={searchTeamId} /> @@ -318,6 +326,7 @@ const SearchScreen = ({teamId}: Props) => { onClear={handleClearSearch} onCancel={handleCancelSearch} defaultValue={searchValue} + ref={searchRef} /> Date: Wed, 2 Nov 2022 19:35:23 -0500 Subject: [PATCH 02/12] [Bug] Emit boolean with "of" operator (#6729) --- app/components/autocomplete/index.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/components/autocomplete/index.ts b/app/components/autocomplete/index.ts index 531a617ed..d5b2f78d8 100644 --- a/app/components/autocomplete/index.ts +++ b/app/components/autocomplete/index.ts @@ -2,6 +2,7 @@ // See LICENSE.txt for license information. import withObservables from '@nozbe/with-observables'; +import {of as of$} from 'rxjs'; import AppsManager from '@managers/apps_manager'; @@ -12,7 +13,7 @@ type OwnProps = { } const enhanced = withObservables(['serverUrl'], ({serverUrl}: OwnProps) => ({ - isAppsEnabled: serverUrl ? AppsManager.observeIsAppsEnabled(serverUrl) : false, + isAppsEnabled: serverUrl ? AppsManager.observeIsAppsEnabled(serverUrl) : of$(false), })); export default enhanced(Autocomplete); From 684bbb4aef417ea13c7a37bbb843bb4557a5aa30 Mon Sep 17 00:00:00 2001 From: Mylon Suren <23694620+mylonsuren@users.noreply.github.com> Date: Thu, 3 Nov 2022 11:37:58 -0400 Subject: [PATCH 03/12] Remove down arrow next to team name and make team name unclickable (#6715) --- .../__snapshots__/index.test.tsx.snap | 51 +++++-------------- .../header/__snapshots__/header.test.tsx.snap | 51 +++++-------------- .../categories_list/header/header.tsx | 16 ++---- 3 files changed, 27 insertions(+), 91 deletions(-) diff --git a/app/screens/home/channel_list/categories_list/__snapshots__/index.test.tsx.snap b/app/screens/home/channel_list/categories_list/__snapshots__/index.test.tsx.snap index 8722aaba0..74f6b532f 100644 --- a/app/screens/home/channel_list/categories_list/__snapshots__/index.test.tsx.snap +++ b/app/screens/home/channel_list/categories_list/__snapshots__/index.test.tsx.snap @@ -47,7 +47,6 @@ exports[`components/categories_list should render channels error 1`] = ` > - - - Test Team! - - - - - + Test Team! + - - - Test! - - - - - + Test! + - {displayName} - - - - + Date: Fri, 4 Nov 2022 18:57:12 +0200 Subject: [PATCH 04/12] Update denim theme link color (#6733) --- .../thread_overview/__snapshots__/thread_overview.test.tsx.snap | 2 +- app/constants/preferences.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/components/post_list/thread_overview/__snapshots__/thread_overview.test.tsx.snap b/app/components/post_list/thread_overview/__snapshots__/thread_overview.test.tsx.snap index d95de26d3..feef6553e 100644 --- a/app/components/post_list/thread_overview/__snapshots__/thread_overview.test.tsx.snap +++ b/app/components/post_list/thread_overview/__snapshots__/thread_overview.test.tsx.snap @@ -77,7 +77,7 @@ exports[`ThreadOverview should match snapshot when post is not saved and 0 repli } > diff --git a/app/constants/preferences.ts b/app/constants/preferences.ts index 46108a821..7da1dd3d4 100644 --- a/app/constants/preferences.ts +++ b/app/constants/preferences.ts @@ -68,7 +68,7 @@ const Preferences: Record = { centerChannelBg: '#ffffff', centerChannelColor: '#3f4350', newMessageSeparator: '#cc8f00', - linkColor: '#386fe5', + linkColor: '#1C58D9', buttonBg: '#1c58d9', buttonColor: '#ffffff', errorTextColor: '#d24b4e', From 17d525d273b662e97318968d2baceda2ec4c752a Mon Sep 17 00:00:00 2001 From: Avinash Lingaloo Date: Fri, 4 Nov 2022 21:29:52 +0400 Subject: [PATCH 05/12] Bump app build number to 432 (#6734) --- android/app/build.gradle | 2 +- ios/Mattermost.xcodeproj/project.pbxproj | 8 ++++---- ios/Mattermost/Info.plist | 2 +- ios/MattermostShare/Info.plist | 2 +- ios/NotificationService/Info.plist | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index baa0ce873..13ca33208 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -145,7 +145,7 @@ android { applicationId "com.mattermost.rnbeta" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 430 + versionCode 432 versionName "2.0.0" testBuildType System.getProperty('testBuildType', 'debug') testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner' diff --git a/ios/Mattermost.xcodeproj/project.pbxproj b/ios/Mattermost.xcodeproj/project.pbxproj index 51b8ddf1e..9740cd0ed 100644 --- a/ios/Mattermost.xcodeproj/project.pbxproj +++ b/ios/Mattermost.xcodeproj/project.pbxproj @@ -1095,7 +1095,7 @@ CODE_SIGN_ENTITLEMENTS = Mattermost/Mattermost.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - CURRENT_PROJECT_VERSION = 430; + CURRENT_PROJECT_VERSION = 432; DEVELOPMENT_TEAM = UQ8HT4Q2XM; ENABLE_BITCODE = NO; HEADER_SEARCH_PATHS = ( @@ -1139,7 +1139,7 @@ CODE_SIGN_ENTITLEMENTS = Mattermost/Mattermost.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - CURRENT_PROJECT_VERSION = 430; + CURRENT_PROJECT_VERSION = 432; DEVELOPMENT_TEAM = UQ8HT4Q2XM; ENABLE_BITCODE = NO; HEADER_SEARCH_PATHS = ( @@ -1282,7 +1282,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 430; + CURRENT_PROJECT_VERSION = 432; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = UQ8HT4Q2XM; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -1333,7 +1333,7 @@ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 430; + CURRENT_PROJECT_VERSION = 432; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = UQ8HT4Q2XM; GCC_C_LANGUAGE_STANDARD = gnu11; diff --git a/ios/Mattermost/Info.plist b/ios/Mattermost/Info.plist index 385570a92..79ced4d86 100644 --- a/ios/Mattermost/Info.plist +++ b/ios/Mattermost/Info.plist @@ -37,7 +37,7 @@ CFBundleVersion - 430 + 432 ITSAppUsesNonExemptEncryption LSRequiresIPhoneOS diff --git a/ios/MattermostShare/Info.plist b/ios/MattermostShare/Info.plist index d57d75879..2353922be 100644 --- a/ios/MattermostShare/Info.plist +++ b/ios/MattermostShare/Info.plist @@ -21,7 +21,7 @@ CFBundleShortVersionString 2.0.0 CFBundleVersion - 430 + 432 UIAppFonts OpenSans-Bold.ttf diff --git a/ios/NotificationService/Info.plist b/ios/NotificationService/Info.plist index 2762e6799..20e1c0bb2 100644 --- a/ios/NotificationService/Info.plist +++ b/ios/NotificationService/Info.plist @@ -21,7 +21,7 @@ CFBundleShortVersionString 2.0.0 CFBundleVersion - 430 + 432 NSExtension NSExtensionPointIdentifier From e90dfb4065f1d5c77d9e793eca224b1f07c4e51d Mon Sep 17 00:00:00 2001 From: Elias Nahum Date: Sat, 5 Nov 2022 10:10:02 +0200 Subject: [PATCH 06/12] apply android rn build fix --- android/build.gradle | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/android/build.gradle b/android/build.gradle index 07108cfd8..28378bf2e 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -38,6 +38,20 @@ buildscript { allprojects { repositories { + exclusiveContent { + // We get React Native's Android binaries exclusively through npm, + // from a local Maven repo inside node_modules/react-native/. + // (The use of exclusiveContent prevents looking elsewhere like Maven Central + // and potentially getting a wrong version.) + filter { + includeGroup "com.facebook.react" + } + forRepository { + maven { + url "$rootDir/../node_modules/react-native/android" + } + } + } google() mavenCentral() mavenLocal() From 8e6c452256b43c99e41d35da024760ba8f8b410e Mon Sep 17 00:00:00 2001 From: Matthew Birtch Date: Sun, 6 Nov 2022 22:09:03 -0500 Subject: [PATCH 07/12] change generic file icon to paperclip icon (#6739) --- .../post_draft/quick_actions/file_quick_action/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/components/post_draft/quick_actions/file_quick_action/index.tsx b/app/components/post_draft/quick_actions/file_quick_action/index.tsx index 1bea0b5bf..566fa9320 100644 --- a/app/components/post_draft/quick_actions/file_quick_action/index.tsx +++ b/app/components/post_draft/quick_actions/file_quick_action/index.tsx @@ -63,7 +63,7 @@ export default function FileQuickAction({ > From 1d4806d7b067872ab9173fa1056f15a31ff9cb43 Mon Sep 17 00:00:00 2001 From: Matthew Birtch Date: Sun, 6 Nov 2022 22:09:17 -0500 Subject: [PATCH 08/12] udpated theme color and position of unread dot and adjusted border radius on selected team (#6738) --- .../team_sidebar/team_list/team_item/team_item.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/components/team_sidebar/team_list/team_item/team_item.tsx b/app/components/team_sidebar/team_list/team_item/team_item.tsx index 19704a8ac..031fe5d39 100644 --- a/app/components/team_sidebar/team_list/team_item/team_item.tsx +++ b/app/components/team_sidebar/team_list/team_item/team_item.tsx @@ -35,11 +35,11 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { }, containerSelected: { borderWidth: 3, - borderRadius: 12, + borderRadius: 14, borderColor: theme.sidebarTextActiveBorder, }, unread: { - left: 40, + left: 43, top: 3, }, mentionsOneDigit: { @@ -102,7 +102,7 @@ export default function TeamItem({team, hasUnreads, mentionCount, selected}: Pro Date: Mon, 7 Nov 2022 16:29:59 -0500 Subject: [PATCH 09/12] MM-45972 - Calls: test ws messages when app is in background (#6720) * update current call on reconnect * move loadConfigAndCalls above deferredAppEntryActions --- app/actions/websocket/index.ts | 3 +- app/products/calls/state/actions.test.ts | 41 +++++++++++++++++++++--- app/products/calls/state/actions.ts | 12 +++++++ 3 files changed, 50 insertions(+), 6 deletions(-) diff --git a/app/actions/websocket/index.ts b/app/actions/websocket/index.ts index 7d526d048..385d1f9c1 100644 --- a/app/actions/websocket/index.ts +++ b/app/actions/websocket/index.ts @@ -179,12 +179,13 @@ async function doReconnect(serverUrl: string) { const {id: currentUserId, locale: currentUserLocale} = (await getCurrentUser(database))!; const {config, license} = await getCommonSystemValues(database); - await deferredAppEntryActions(serverUrl, lastDisconnectedAt, currentUserId, currentUserLocale, prefData.preferences, config, license, teamData, chData, initialTeamId, switchedToChannel ? initialChannelId : undefined); if (isSupportedServerCalls(config?.Version)) { loadConfigAndCalls(serverUrl, currentUserId); } + await deferredAppEntryActions(serverUrl, lastDisconnectedAt, currentUserId, currentUserLocale, prefData.preferences, config, license, teamData, chData, initialTeamId, switchedToChannel ? initialChannelId : undefined); + AppsManager.refreshAppBindings(serverUrl); } diff --git a/app/products/calls/state/actions.test.ts b/app/products/calls/state/actions.test.ts index 30721001b..0bb680bf4 100644 --- a/app/products/calls/state/actions.test.ts +++ b/app/products/calls/state/actions.test.ts @@ -107,39 +107,68 @@ describe('useCallsState', () => { const initialChannelsWithCallsState = { 'channel-1': true, }; + const initialCurrentCallState: CurrentCall = { + serverUrl: 'server1', + myUserId: 'myUserId', + ...call1, + screenShareURL: '', + speakerphoneOn: false, + }; + const testNewCall1 = { + ...call1, + participants: { + 'user-1': {id: 'user-1', muted: false, raisedHand: 0}, + 'user-2': {id: 'user-2', muted: true, raisedHand: 0}, + 'user-3': {id: 'user-3', muted: false, raisedHand: 123}, + }, + }; const test = { - calls: {'channel-1': call2, 'channel-2': call3}, + calls: {'channel-1': testNewCall1, 'channel-2': call2, 'channel-3': call3}, enabled: {'channel-2': true}, }; + const expectedCallsState = { ...initialCallsState, serverUrl: 'server1', myUserId: 'myId', - calls: {'channel-1': call2, 'channel-2': call3}, + calls: {'channel-1': testNewCall1, 'channel-2': call2, 'channel-3': call3}, enabled: {'channel-2': true}, }; const expectedChannelsWithCallsState = { ...initialChannelsWithCallsState, 'channel-2': true, + 'channel-3': true, + }; + const expectedCurrentCallState = { + ...initialCurrentCallState, + ...testNewCall1, }; // setup const {result} = renderHook(() => { - return [useCallsState('server1'), useCallsState('server1'), useChannelsWithCalls('server1')]; + return [ + useCallsState('server1'), + useCallsState('server1'), + useChannelsWithCalls('server1'), + useCurrentCall(), + ]; }); act(() => { setCallsState('server1', initialCallsState); setChannelsWithCalls('server1', initialChannelsWithCallsState); + setCurrentCall(initialCurrentCallState); }); assert.deepEqual(result.current[0], initialCallsState); assert.deepEqual(result.current[1], initialCallsState); assert.deepEqual(result.current[2], initialChannelsWithCallsState); + assert.deepEqual(result.current[3], initialCurrentCallState); // test act(() => 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); + assert.deepEqual(result.current[3], expectedCurrentCallState); }); it('joinedCall', () => { @@ -469,7 +498,8 @@ describe('useCallsState', () => { }; const expectedCallsState = { ...initialCallsState, - calls: {...initialCallsState.calls, + calls: { + ...initialCallsState.calls, 'channel-1': newCall1, }, }; @@ -589,7 +619,8 @@ describe('useCallsState', () => { }; const expectedCallsState = { ...initialCallsState, - calls: {...initialCallsState.calls, + calls: { + ...initialCallsState.calls, 'channel-1': newCall1, }, }; diff --git a/app/products/calls/state/actions.ts b/app/products/calls/state/actions.ts index 4bfe8976b..74cfed3fd 100644 --- a/app/products/calls/state/actions.ts +++ b/app/products/calls/state/actions.ts @@ -22,6 +22,18 @@ export const setCalls = (serverUrl: string, myUserId: string, calls: Dictionary< setChannelsWithCalls(serverUrl, channelsWithCalls); setCallsState(serverUrl, {serverUrl, myUserId, calls, enabled}); + + // Does the current call need to be updated? + const currentCall = getCurrentCall(); + if (!currentCall || !calls[currentCall.channelId]) { + return; + } + + const nextCall = { + ...currentCall, + ...calls[currentCall.channelId], + }; + setCurrentCall(nextCall); }; export const setCallForChannel = (serverUrl: string, channelId: string, enabled: boolean, call?: Call) => { From 7f5dc3c7183c6019a7710bf14b6c5f4a6abf99a3 Mon Sep 17 00:00:00 2001 From: Christopher Poile Date: Mon, 7 Nov 2022 17:11:40 -0500 Subject: [PATCH 10/12] MM-47763 - Calls: "who is speaking" state (#6721) * move voiceOn to the currentCall state * simplify * prefer no inline fns --- app/constants/events.ts | 1 - .../current_call_bar/current_call_bar.tsx | 62 +++++---------- .../connection/websocket_event_handlers.ts | 11 +-- .../calls/screens/call_screen/call_screen.tsx | 43 +++-------- app/products/calls/state/actions.test.ts | 76 ++++++++++++++++--- app/products/calls/state/actions.ts | 33 ++++++++ app/products/calls/types/calls.ts | 1 + 7 files changed, 131 insertions(+), 96 deletions(-) diff --git a/app/constants/events.ts b/app/constants/events.ts index 4a994433a..aece21528 100644 --- a/app/constants/events.ts +++ b/app/constants/events.ts @@ -31,5 +31,4 @@ export default keyMirror({ SEND_TO_POST_DRAFT: null, CRT_TOGGLED: null, JOIN_CALL_BAR_VISIBLE: null, - CURRENT_CALL_BAR_VISIBLE: null, }); 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 5c296f03f..1594a0ba5 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 @@ -1,16 +1,16 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React, {useCallback, useEffect, useState} from 'react'; +import React, {useCallback} from 'react'; import {useIntl} from 'react-intl'; -import {View, Text, TouchableOpacity, Pressable, Platform, DeviceEventEmitter} from 'react-native'; +import {View, Text, TouchableOpacity, Pressable, Platform} from 'react-native'; import {Options} from 'react-native-navigation'; import {muteMyself, unmuteMyself} from '@calls/actions'; import CallAvatar from '@calls/components/call_avatar'; -import {CurrentCall, VoiceEventData} from '@calls/types/calls'; +import {CurrentCall} from '@calls/types/calls'; import CompassIcon from '@components/compass_icon'; -import {Events, Screens, WebsocketEvents} from '@constants'; +import {Screens} from '@constants'; import {CURRENT_CALL_BAR_HEIGHT} from '@constants/view'; import {useTheme} from '@context/theme'; import {dismissAllModalsAndPopToScreen} from '@screens/navigation'; @@ -90,45 +90,6 @@ const CurrentCallBar = ({ }: Props) => { const theme = useTheme(); const {formatMessage} = useIntl(); - const [speaker, setSpeaker] = useState(null); - const [talkingMessage, setTalkingMessage] = useState(''); - - const isCurrentCall = Boolean(currentCall); - const handleVoiceOn = (data: VoiceEventData) => { - if (data.channelId === currentCall?.channelId) { - setSpeaker(data.userId); - } - }; - const handleVoiceOff = (data: VoiceEventData) => { - if (data.channelId === currentCall?.channelId && ((speaker === data.userId) || !speaker)) { - setSpeaker(null); - } - }; - - useEffect(() => { - const onVoiceOn = DeviceEventEmitter.addListener(WebsocketEvents.CALLS_USER_VOICE_ON, handleVoiceOn); - const onVoiceOff = DeviceEventEmitter.addListener(WebsocketEvents.CALLS_USER_VOICE_OFF, handleVoiceOff); - DeviceEventEmitter.emit(Events.CURRENT_CALL_BAR_VISIBLE, isCurrentCall); - return () => { - DeviceEventEmitter.emit(Events.CURRENT_CALL_BAR_VISIBLE, Boolean(false)); - onVoiceOn.remove(); - onVoiceOff.remove(); - }; - }, [isCurrentCall]); - - useEffect(() => { - if (speaker) { - setTalkingMessage(formatMessage({ - id: 'mobile.calls_name_is_talking', - defaultMessage: '{name} is talking', - }, {name: displayUsername(userModelsDict[speaker], teammateNameDisplay)})); - } else { - setTalkingMessage(formatMessage({ - id: 'mobile.calls_noone_talking', - defaultMessage: 'No one is talking', - })); - } - }, [speaker, setTalkingMessage]); const goToCallScreen = useCallback(async () => { const options: Options = { @@ -150,6 +111,21 @@ const CurrentCallBar = ({ const myParticipant = currentCall?.participants[currentCall.myUserId]; + // Since we can only see one user talking, it doesn't really matter who we show here (e.g., we can't + // tell who is speaking louder). + const talkingUsers = Object.keys(currentCall?.voiceOn || {}); + const speaker = talkingUsers.length > 0 ? talkingUsers[0] : ''; + let talkingMessage = formatMessage({ + id: 'mobile.calls_noone_talking', + defaultMessage: 'No one is talking', + }); + if (speaker) { + talkingMessage = formatMessage({ + id: 'mobile.calls_name_is_talking', + defaultMessage: '{name} is talking', + }, {name: displayUsername(userModelsDict[speaker], teammateNameDisplay)}); + } + const muteUnmute = () => { if (myParticipant?.muted) { unmuteMyself(); diff --git a/app/products/calls/connection/websocket_event_handlers.ts b/app/products/calls/connection/websocket_event_handlers.ts index eef5ff4f3..a74d57d08 100644 --- a/app/products/calls/connection/websocket_event_handlers.ts +++ b/app/products/calls/connection/websocket_event_handlers.ts @@ -12,6 +12,7 @@ import { setChannelEnabled, setRaisedHand, setUserMuted, + setUserVoiceOn, userJoinedCall, userLeftCall, } from '@calls/state'; @@ -38,17 +39,11 @@ export const handleCallUserUnmuted = (serverUrl: string, msg: WebSocketMessage) }; export const handleCallUserVoiceOn = (msg: WebSocketMessage) => { - DeviceEventEmitter.emit(WebsocketEvents.CALLS_USER_VOICE_ON, { - channelId: msg.broadcast.channel_id, - userId: msg.data.userID, - }); + setUserVoiceOn(msg.broadcast.channel_id, msg.data.userID, true); }; export const handleCallUserVoiceOff = (msg: WebSocketMessage) => { - DeviceEventEmitter.emit(WebsocketEvents.CALLS_USER_VOICE_OFF, { - channelId: msg.broadcast.channel_id, - userId: msg.data.userID, - }); + setUserVoiceOn(msg.broadcast.channel_id, msg.data.userID, false); }; export const handleCallStarted = (serverUrl: string, msg: WebSocketMessage) => { diff --git a/app/products/calls/screens/call_screen/call_screen.tsx b/app/products/calls/screens/call_screen/call_screen.tsx index 3f983b189..ec0a1421b 100644 --- a/app/products/calls/screens/call_screen/call_screen.tsx +++ b/app/products/calls/screens/call_screen/call_screen.tsx @@ -30,7 +30,7 @@ import CallAvatar from '@calls/components/call_avatar'; import CallDuration from '@calls/components/call_duration'; import RaisedHandIcon from '@calls/icons/raised_hand_icon'; import UnraisedHandIcon from '@calls/icons/unraised_hand_icon'; -import {CallParticipant, CurrentCall, VoiceEventData} from '@calls/types/calls'; +import {CallParticipant, CurrentCall} from '@calls/types/calls'; import {sortParticipants} from '@calls/utils'; import CompassIcon from '@components/compass_icon'; import FormattedText from '@components/formatted_text'; @@ -260,7 +260,6 @@ const CallScreen = ({componentId, currentCall, participantsDict, teammateNameDis const insets = useSafeAreaInsets(); const {width, height} = useWindowDimensions(); const [showControlsInLandscape, setShowControlsInLandscape] = useState(false); - const [speakers, setSpeakers] = useState>({}); const style = getStyleSheet(theme); const isLandscape = width > height; @@ -279,30 +278,6 @@ const CallScreen = ({componentId, currentCall, participantsDict, teammateNameDis }); }, []); - useEffect(() => { - const handleVoiceOn = (data: VoiceEventData) => { - if (data.channelId === currentCall?.channelId) { - setSpeakers((prev) => ({...prev, [data.userId]: true})); - } - }; - const handleVoiceOff = (data: VoiceEventData) => { - if (data.channelId === currentCall?.channelId && speakers.hasOwnProperty(data.userId)) { - setSpeakers((prev) => { - const next = {...prev}; - delete next[data.userId]; - return next; - }); - } - }; - - const onVoiceOn = DeviceEventEmitter.addListener(WebsocketEvents.CALLS_USER_VOICE_ON, handleVoiceOn); - const onVoiceOff = DeviceEventEmitter.addListener(WebsocketEvents.CALLS_USER_VOICE_OFF, handleVoiceOff); - return () => { - onVoiceOn.remove(); - onVoiceOff.remove(); - }; - }, [speakers, currentCall?.channelId]); - const leaveCallHandler = useCallback(() => { popTopScreen(); leaveCall(); @@ -325,6 +300,10 @@ const CallScreen = ({componentId, currentCall, participantsDict, teammateNameDis } }, [myParticipant?.raisedHand]); + const toggleSpeakerPhone = useCallback(() => { + setSpeakerphoneOn(!currentCall?.speakerphoneOn); + }, [currentCall?.speakerphoneOn]); + const toggleControlsInLandscape = useCallback(() => { setShowControlsInLandscape(!showControlsInLandscape); }, [showControlsInLandscape]); @@ -424,8 +403,8 @@ const CallScreen = ({componentId, currentCall, participantsDict, teammateNameDis usersList = ( { return ( setSpeakerphoneOn(!currentCall?.speakerphoneOn)} + onPress={toggleSpeakerPhone} > { ...call1, screenShareURL: '', speakerphoneOn: false, + voiceOn: {}, }; const testNewCall1 = { ...call1, @@ -179,13 +181,14 @@ describe('useCallsState', () => { const initialChannelsWithCallsState = { 'channel-1': true, }; - const initialCurrentCallState = { + const initialCurrentCallState: CurrentCall = { serverUrl: 'server1', myUserId: 'myUserId', ...call1, screenShareURL: '', speakerphoneOn: false, - } as CurrentCall; + voiceOn: {}, + }; const expectedCallsState = { 'channel-1': { participants: { @@ -238,13 +241,14 @@ describe('useCallsState', () => { const initialChannelsWithCallsState = { 'channel-1': true, }; - const initialCurrentCallState = { + const initialCurrentCallState: CurrentCall = { serverUrl: 'server1', myUserId: 'myUserId', ...call1, screenShareURL: '', speakerphoneOn: false, - } as CurrentCall; + voiceOn: {}, + }; const expectedCallsState = { 'channel-1': { participants: { @@ -340,13 +344,14 @@ describe('useCallsState', () => { calls: {'channel-1': call1, 'channel-2': call2}, }; const initialChannelsWithCallsState = {'channel-1': true, 'channel-2': true}; - const initialCurrentCallState = { + const initialCurrentCallState: CurrentCall = { serverUrl: 'server1', myUserId: 'myUserId', ...call1, screenShareURL: '', speakerphoneOn: false, - } as CurrentCall; + voiceOn: {}, + }; // setup const {result} = renderHook(() => { @@ -387,13 +392,14 @@ describe('useCallsState', () => { calls: {'channel-1': call1, 'channel-2': call2}, }; const initialChannelsWithCallsState = {'channel-1': true, 'channel-2': true}; - const initialCurrentCallState = { + const initialCurrentCallState: CurrentCall = { serverUrl: 'server1', myUserId: 'myUserId', ...call1, screenShareURL: '', speakerphoneOn: false, - } as CurrentCall; + voiceOn: {}, + }; // setup const {result} = renderHook(() => { @@ -445,13 +451,14 @@ describe('useCallsState', () => { ownerId: 'user-1', }, }; - const initialCurrentCallState = { + const initialCurrentCallState: CurrentCall = { serverUrl: 'server1', myUserId: 'myUserId', ...call1, screenShareURL: '', speakerphoneOn: false, - } as CurrentCall; + voiceOn: {}, + }; const expectedCurrentCallState = { ...initialCurrentCallState, ...expectedCalls['channel-1'], @@ -503,13 +510,14 @@ describe('useCallsState', () => { 'channel-1': newCall1, }, }; - const expectedCurrentCallState = { + const expectedCurrentCallState: CurrentCall = { serverUrl: 'server1', myUserId: 'myUserId', screenShareURL: '', speakerphoneOn: false, ...newCall1, - } as CurrentCall; + voiceOn: {}, + }; // setup const {result} = renderHook(() => { @@ -649,6 +657,50 @@ describe('useCallsState', () => { assert.deepEqual(result.current[1], null); }); + it('voiceOn and Off', () => { + const initialCallsState = { + ...DefaultCallsState, + serverUrl: 'server1', + myUserId: 'myUserId', + calls: {'channel-1': call1, 'channel-2': call2}, + }; + const initialCurrentCallState: CurrentCall = { + serverUrl: 'server1', + myUserId: 'myUserId', + ...call1, + screenShareURL: '', + speakerphoneOn: false, + voiceOn: {}, + }; + + // setup + const {result} = renderHook(() => { + return [useCallsState('server1'), useCurrentCall()]; + }); + act(() => { + setCallsState('server1', initialCallsState); + setCurrentCall(initialCurrentCallState); + }); + assert.deepEqual(result.current[0], initialCallsState); + assert.deepEqual(result.current[1], initialCurrentCallState); + + // test + act(() => setUserVoiceOn('channel-1', 'user-1', true)); + assert.deepEqual(result.current[1], {...initialCurrentCallState, voiceOn: {'user-1': true}}); + assert.deepEqual(result.current[0], initialCallsState); + act(() => setUserVoiceOn('channel-1', 'user-2', true)); + assert.deepEqual(result.current[1], {...initialCurrentCallState, voiceOn: {'user-1': true, 'user-2': true}}); + assert.deepEqual(result.current[0], initialCallsState); + act(() => setUserVoiceOn('channel-1', 'user-1', false)); + assert.deepEqual(result.current[1], {...initialCurrentCallState, voiceOn: {'user-2': true}}); + assert.deepEqual(result.current[0], initialCallsState); + + // test that voice state is cleared on reconnect + act(() => setCalls('server1', 'myUserId', initialCallsState.calls, {})); + assert.deepEqual(result.current[1], initialCurrentCallState); + assert.deepEqual(result.current[0], initialCallsState); + }); + it('config', () => { const newConfig = { ICEServers: [], diff --git a/app/products/calls/state/actions.ts b/app/products/calls/state/actions.ts index 74cfed3fd..32c8b8261 100644 --- a/app/products/calls/state/actions.ts +++ b/app/products/calls/state/actions.ts @@ -29,9 +29,12 @@ export const setCalls = (serverUrl: string, myUserId: string, calls: Dictionary< return; } + // Edge case: if the app went into the background and lost the main ws connection, we don't know who is currently + // talking. Instead of guessing, erase voiceOn state (same state as when joining an ongoing call). const nextCall = { ...currentCall, ...calls[currentCall.channelId], + voiceOn: {}, }; setCurrentCall(nextCall); }; @@ -92,9 +95,13 @@ export const userJoinedCall = (serverUrl: string, channelId: string, userId: str // Did the user join the current call? If so, update that too. const currentCall = getCurrentCall(); if (currentCall && currentCall.channelId === channelId) { + const voiceOn = {...currentCall.voiceOn}; + delete voiceOn[userId]; + const nextCurrentCall = { ...currentCall, participants: {...currentCall.participants, [userId]: nextCall.participants[userId]}, + voiceOn, }; setCurrentCall(nextCurrentCall); } @@ -108,6 +115,7 @@ export const userJoinedCall = (serverUrl: string, channelId: string, userId: str myUserId: userId, screenShareURL: '', speakerphoneOn: false, + voiceOn: {}, }); } }; @@ -149,9 +157,14 @@ export const userLeftCall = (serverUrl: string, channelId: string, userId: strin return; } + // Clear them from the voice list + const voiceOn = {...currentCall.voiceOn}; + delete voiceOn[userId]; + const nextCurrentCall = { ...currentCall, participants: {...currentCall.participants}, + voiceOn, }; delete nextCurrentCall.participants[userId]; setCurrentCall(nextCurrentCall); @@ -220,6 +233,26 @@ export const setUserMuted = (serverUrl: string, channelId: string, userId: strin setCurrentCall(nextCurrentCall); }; +export const setUserVoiceOn = (channelId: string, userId: string, voiceOn: boolean) => { + const currentCall = getCurrentCall(); + if (!currentCall || currentCall.channelId !== channelId) { + return; + } + + const nextVoiceOn = {...currentCall.voiceOn}; + if (voiceOn) { + nextVoiceOn[userId] = true; + } else { + delete nextVoiceOn[userId]; + } + + const nextCurrentCall = { + ...currentCall, + voiceOn: nextVoiceOn, + }; + setCurrentCall(nextCurrentCall); +}; + export const setRaisedHand = (serverUrl: string, channelId: string, userId: string, timestamp: number) => { const callsState = getCallsState(serverUrl); if (!callsState.calls[channelId] || !callsState.calls[channelId].participants[userId]) { diff --git a/app/products/calls/types/calls.ts b/app/products/calls/types/calls.ts index 760683a52..7a7a6dce7 100644 --- a/app/products/calls/types/calls.ts +++ b/app/products/calls/types/calls.ts @@ -45,6 +45,7 @@ export type CurrentCall = { threadId: string; screenShareURL: string; speakerphoneOn: boolean; + voiceOn: Dictionary; } export type CallParticipant = { From cc5331f2ba90f566ef250056e4f7347427262348 Mon Sep 17 00:00:00 2001 From: Javier Aguirre Date: Tue, 8 Nov 2022 09:27:52 +0100 Subject: [PATCH 11/12] The value wasn't unique (#6731) --- app/components/settings/radio_setting/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/components/settings/radio_setting/index.tsx b/app/components/settings/radio_setting/index.tsx index afacc404b..94ec377fe 100644 --- a/app/components/settings/radio_setting/index.tsx +++ b/app/components/settings/radio_setting/index.tsx @@ -58,7 +58,7 @@ function RadioSetting({ isSelected={value === entryValue} text={text} value={entryValue} - key={value} + key={entryValue} />, ); } From 8374d7e87f7a591487d6ceb48f182071fb089c0f Mon Sep 17 00:00:00 2001 From: Christopher Poile Date: Tue, 8 Nov 2022 10:52:52 -0500 Subject: [PATCH 12/12] MM-47004 - Calls: Client-side errors for microphone permissions (#6669) * call error bar for microphone permissions; global permissions state * i18n * refactor permissionErrorBar component, PR comments * add module dependency's mocks for tests * fix error bar height * change permissions error text * working on 46999 redo audio handling -- will revert * Revert "working on 46999 redo audio handling -- will revert" This reverts commit 87bafc452c6ad6e1d7ae79ce78a0f2b461c2f150. * only get voice track when we have mic permissions * Android: enable mic when permissions are granted --- app/constants/view.ts | 1 + app/products/calls/actions/calls.test.ts | 8 +- app/products/calls/actions/calls.ts | 10 +- app/products/calls/actions/permissions.ts | 48 +------ app/products/calls/alerts.ts | 13 +- .../current_call_bar/current_call_bar.tsx | 89 ++++++------ .../components/current_call_bar/index.ts | 7 +- .../calls/components/permission_error_bar.tsx | 104 ++++++++++++++ .../components/unavailable_icon_wrapper.tsx | 68 ++++++++++ app/products/calls/connection/connection.ts | 41 ++++-- app/products/calls/hooks.ts | 32 ++++- .../calls/screens/call_screen/call_screen.tsx | 27 +++- .../calls/screens/call_screen/index.ts | 7 +- app/products/calls/state/actions.test.ts | 127 ++++++++++++++---- app/products/calls/state/actions.ts | 26 ++++ .../calls/state/global_calls_state.ts | 38 ++++++ app/products/calls/state/index.ts | 1 + app/products/calls/types/calls.ts | 32 ++++- assets/base/i18n/en.json | 7 +- test/setup.ts | 4 + 20 files changed, 533 insertions(+), 157 deletions(-) create mode 100644 app/products/calls/components/permission_error_bar.tsx create mode 100644 app/products/calls/components/unavailable_icon_wrapper.tsx create mode 100644 app/products/calls/state/global_calls_state.ts diff --git a/app/constants/view.ts b/app/constants/view.ts index e4a3a9f98..c1ef40d8a 100644 --- a/app/constants/view.ts +++ b/app/constants/view.ts @@ -23,6 +23,7 @@ export const SEARCH_INPUT_MARGIN = 5; export const JOIN_CALL_BAR_HEIGHT = 38; export const CURRENT_CALL_BAR_HEIGHT = 74; +export const CALL_ERROR_BAR_HEIGHT = 62; export const QUICK_OPTIONS_HEIGHT = 270; diff --git a/app/products/calls/actions/calls.test.ts b/app/products/calls/actions/calls.test.ts index 0222f9b1c..9352ccb75 100644 --- a/app/products/calls/actions/calls.test.ts +++ b/app/products/calls/actions/calls.test.ts @@ -139,7 +139,7 @@ describe('Actions.Calls', () => { let response: { data?: string }; await act(async () => { - response = await CallsActions.joinCall('server1', 'channel-id'); + response = await CallsActions.joinCall('server1', 'channel-id', true); userJoinedCall('server1', 'channel-id', 'myUserId'); }); @@ -163,7 +163,7 @@ describe('Actions.Calls', () => { let response: { data?: string }; await act(async () => { - response = await CallsActions.joinCall('server1', 'channel-id'); + response = await CallsActions.joinCall('server1', 'channel-id', true); userJoinedCall('server1', 'channel-id', 'myUserId'); }); assert.equal(response!.data, 'channel-id'); @@ -191,7 +191,7 @@ describe('Actions.Calls', () => { let response: { data?: string }; await act(async () => { - response = await CallsActions.joinCall('server1', 'channel-id'); + response = await CallsActions.joinCall('server1', 'channel-id', true); userJoinedCall('server1', 'channel-id', 'myUserId'); }); assert.equal(response!.data, 'channel-id'); @@ -218,7 +218,7 @@ describe('Actions.Calls', () => { let response: { data?: string }; await act(async () => { - response = await CallsActions.joinCall('server1', 'channel-id'); + response = await CallsActions.joinCall('server1', 'channel-id', true); userJoinedCall('server1', 'channel-id', 'myUserId'); }); assert.equal(response!.data, 'channel-id'); diff --git a/app/products/calls/actions/calls.ts b/app/products/calls/actions/calls.ts index 29080d781..8f3972cb6 100644 --- a/app/products/calls/actions/calls.ts +++ b/app/products/calls/actions/calls.ts @@ -218,7 +218,7 @@ export const enableChannelCalls = async (serverUrl: string, channelId: string, e return {}; }; -export const joinCall = async (serverUrl: string, channelId: string): Promise<{ error?: string | Error; data?: string }> => { +export const joinCall = async (serverUrl: string, channelId: string, hasMicPermission: boolean): Promise<{ error?: string | Error; data?: string }> => { // Edge case: calls was disabled when app loaded, and then enabled, but app hasn't // reconnected its websocket since then (i.e., hasn't called batchLoadCalls yet) const {data: enabled} = await checkIsCallsPluginEnabled(serverUrl); @@ -233,7 +233,7 @@ export const joinCall = async (serverUrl: string, channelId: string): Promise<{ setSpeakerphoneOn(false); try { - connection = await newConnection(serverUrl, channelId, () => null, setScreenShareURL); + connection = await newConnection(serverUrl, channelId, () => null, setScreenShareURL, hasMicPermission); } catch (error: unknown) { await forceLogoutIfNecessary(serverUrl, error as ClientError); return {error: error as Error}; @@ -270,6 +270,12 @@ export const unmuteMyself = () => { } }; +export const initializeVoiceTrack = () => { + if (connection) { + connection.initializeVoiceTrack(); + } +}; + export const raiseHand = () => { if (connection) { connection.raiseHand(); diff --git a/app/products/calls/actions/permissions.ts b/app/products/calls/actions/permissions.ts index 9789d0071..a4ac3709d 100644 --- a/app/products/calls/actions/permissions.ts +++ b/app/products/calls/actions/permissions.ts @@ -1,28 +1,10 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {Alert, Platform} from 'react-native'; -import DeviceInfo from 'react-native-device-info'; +import {Platform} from 'react-native'; import Permissions from 'react-native-permissions'; -import type {IntlShape} from 'react-intl'; - -const getMicrophonePermissionDeniedMessage = (intl: IntlShape) => { - const {formatMessage} = intl; - const applicationName = DeviceInfo.getApplicationName(); - return { - title: formatMessage({ - id: 'mobile.microphone_permission_denied_title', - defaultMessage: '{applicationName} would like to access your microphone', - }, {applicationName}), - text: formatMessage({ - id: 'mobile.microphone_permission_denied_description', - defaultMessage: 'To participate in this call, open Settings to grant Mattermost access to your microphone.', - }), - }; -}; - -export const hasMicrophonePermission = async (intl: IntlShape) => { +export const hasMicrophonePermission = async () => { const targetSource = Platform.select({ ios: Permissions.PERMISSIONS.IOS.MICROPHONE, default: Permissions.PERMISSIONS.ANDROID.RECORD_AUDIO, @@ -36,32 +18,8 @@ export const hasMicrophonePermission = async (intl: IntlShape) => { return permissionRequest === Permissions.RESULTS.GRANTED; } - case Permissions.RESULTS.BLOCKED: { - const grantOption = { - text: intl.formatMessage({ - id: 'mobile.permission_denied_retry', - defaultMessage: 'Settings', - }), - onPress: () => Permissions.openSettings(), - }; - - const {title, text} = getMicrophonePermissionDeniedMessage(intl); - - Alert.alert( - title, - text, - [ - grantOption, - { - text: intl.formatMessage({ - id: 'mobile.permission_denied_dismiss', - defaultMessage: 'Don\'t Allow', - }), - }, - ], - ); + case Permissions.RESULTS.BLOCKED: return false; - } } return true; diff --git a/app/products/calls/alerts.ts b/app/products/calls/alerts.ts index 2d1ebd23c..37b4bb10f 100644 --- a/app/products/calls/alerts.ts +++ b/app/products/calls/alerts.ts @@ -4,6 +4,7 @@ import {Alert} from 'react-native'; import {hasMicrophonePermission, joinCall, unmuteMyself} from '@calls/actions'; +import {setMicPermissionsGranted} from '@calls/state'; import {errorAlert} from '@calls/utils'; import type {IntlShape} from 'react-intl'; @@ -89,16 +90,10 @@ export const leaveAndJoinWithAlert = ( const doJoinCall = async (serverUrl: string, channelId: string, isDMorGM: boolean, intl: IntlShape) => { const {formatMessage} = intl; - const hasPermission = await hasMicrophonePermission(intl); - if (!hasPermission) { - errorAlert(formatMessage({ - id: 'mobile.calls_error_permissions', - defaultMessage: 'No permissions to microphone, unable to start call', - }), intl); - return; - } + const hasPermission = await hasMicrophonePermission(); + setMicPermissionsGranted(hasPermission); - const res = await joinCall(serverUrl, channelId); + const res = await joinCall(serverUrl, channelId, hasPermission); if (res.error) { const seeLogs = formatMessage({id: 'mobile.calls_see_logs', defaultMessage: 'See server logs'}); errorAlert(res.error?.toString() || seeLogs, intl); 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 1594a0ba5..3a6bb1f78 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 @@ -8,6 +8,9 @@ import {Options} from 'react-native-navigation'; import {muteMyself, unmuteMyself} from '@calls/actions'; import CallAvatar from '@calls/components/call_avatar'; +import PermissionErrorBar from '@calls/components/permission_error_bar'; +import UnavailableIconWrapper from '@calls/components/unavailable_icon_wrapper'; +import {usePermissionsChecker} from '@calls/hooks'; import {CurrentCall} from '@calls/types/calls'; import CompassIcon from '@components/compass_icon'; import {Screens} from '@constants'; @@ -24,6 +27,7 @@ type Props = { currentCall: CurrentCall | null; userModelsDict: Dictionary; teammateNameDisplay: string; + micPermissionsGranted: boolean; threadScreen?: boolean; } @@ -57,18 +61,18 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { color: theme.sidebarText, opacity: 0.64, }, - micIcon: { - color: theme.sidebarText, + micIconContainer: { width: 42, height: 42, - textAlign: 'center', - textAlignVertical: 'center', justifyContent: 'center', - backgroundColor: '#3DB887', + alignItems: 'center', + backgroundColor: theme.onlineIndicator, borderRadius: 4, margin: 4, padding: 9, - overflow: 'hidden', + }, + micIcon: { + color: theme.sidebarText, }, muted: { backgroundColor: 'transparent', @@ -86,10 +90,13 @@ const CurrentCallBar = ({ currentCall, userModelsDict, teammateNameDisplay, + micPermissionsGranted, threadScreen, }: Props) => { const theme = useTheme(); + const style = getStyleSheet(theme); const {formatMessage} = useIntl(); + usePermissionsChecker(micPermissionsGranted); const goToCallScreen = useCallback(async () => { const options: Options = { @@ -134,42 +141,48 @@ const CurrentCallBar = ({ } }; - const style = getStyleSheet(theme); + const micPermissionsError = !micPermissionsGranted && !currentCall?.micPermissionsErrorDismissed; return ( - - - - - {talkingMessage} - {`~${displayName}`} + <> + + + + + {talkingMessage} + {`~${displayName}`} + + + + + + + - - - - - - - + {micPermissionsError && } + ); }; + export default CurrentCallBar; diff --git a/app/products/calls/components/current_call_bar/index.ts b/app/products/calls/components/current_call_bar/index.ts index 1ddce1122..925110f5a 100644 --- a/app/products/calls/components/current_call_bar/index.ts +++ b/app/products/calls/components/current_call_bar/index.ts @@ -5,7 +5,7 @@ import withObservables from '@nozbe/with-observables'; import {combineLatest, of as of$} from 'rxjs'; import {distinctUntilChanged, switchMap} from 'rxjs/operators'; -import {observeCurrentCall} from '@calls/state'; +import {observeCurrentCall, observeGlobalCallsState} from '@calls/state'; import {idsAreEqual} from '@calls/utils'; import DatabaseManager from '@database/manager'; import {observeChannel} from '@queries/servers/channel'; @@ -45,12 +45,17 @@ const enhanced = withObservables([], () => { const teammateNameDisplay = database.pipe( switchMap((db) => (db ? observeTeammateNameDisplay(db) : of$(''))), ); + const micPermissionsGranted = observeGlobalCallsState().pipe( + switchMap((gs) => of$(gs.micPermissionsGranted)), + distinctUntilChanged(), + ); return { displayName, currentCall, userModelsDict, teammateNameDisplay, + micPermissionsGranted, }; }); diff --git a/app/products/calls/components/permission_error_bar.tsx b/app/products/calls/components/permission_error_bar.tsx new file mode 100644 index 000000000..66a0ba567 --- /dev/null +++ b/app/products/calls/components/permission_error_bar.tsx @@ -0,0 +1,104 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {Pressable, View} from 'react-native'; +import Permissions from 'react-native-permissions'; + +import {setMicPermissionsErrorDismissed} from '@calls/state'; +import CompassIcon from '@components/compass_icon'; +import FormattedText from '@components/formatted_text'; +import {CALL_ERROR_BAR_HEIGHT} from '@constants/view'; +import {useTheme} from '@context/theme'; +import {makeStyleSheetFromTheme} from '@utils/theme'; +import {typography} from '@utils/typography'; + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ( + { + pressable: { + zIndex: 10, + }, + errorWrapper: { + padding: 10, + paddingTop: 0, + }, + errorBar: { + flexDirection: 'row', + backgroundColor: theme.dndIndicator, + minHeight: CALL_ERROR_BAR_HEIGHT, + width: '100%', + borderRadius: 5, + padding: 10, + alignItems: 'center', + }, + errorText: { + flex: 1, + ...typography('Body', 100, 'SemiBold'), + color: theme.buttonColor, + }, + errorIconContainer: { + width: 42, + height: 42, + justifyContent: 'center', + alignItems: 'center', + borderRadius: 4, + margin: 0, + padding: 9, + }, + pressedErrorIconContainer: { + backgroundColor: theme.buttonColor, + }, + errorIcon: { + color: theme.buttonColor, + fontSize: 18, + }, + pressedErrorIcon: { + color: theme.dndIndicator, + }, + paddingRight: { + paddingRight: 9, + }, + } +)); + +const PermissionErrorBar = () => { + const theme = useTheme(); + const style = getStyleSheet(theme); + + return ( + + + + + [ + style.pressable, + style.errorIconContainer, + pressed && style.pressedErrorIconContainer, + ]} + > + {({pressed}) => ( + + )} + + + + ); +}; + +export default PermissionErrorBar; diff --git a/app/products/calls/components/unavailable_icon_wrapper.tsx b/app/products/calls/components/unavailable_icon_wrapper.tsx new file mode 100644 index 000000000..077dcf03f --- /dev/null +++ b/app/products/calls/components/unavailable_icon_wrapper.tsx @@ -0,0 +1,68 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {StyleProp, TextStyle, View} from 'react-native'; + +import CompassIcon from '@components/compass_icon'; +import {useTheme} from '@context/theme'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; + +type Props = { + name: string; + size: number; + style: StyleProp; + unavailable: boolean; +} + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { + return { + container: { + position: 'relative', + }, + unavailable: { + color: changeOpacity(theme.sidebarText, 0.32), + }, + errorContainer: { + position: 'absolute', + right: 0, + backgroundColor: '#3F4350', + justifyContent: 'center', + alignItems: 'center', + borderWidth: 0.5, + borderColor: '#3F4350', + }, + errorIcon: { + color: theme.dndIndicator, + }, + }; +}); + +const UnavailableIconWrapper = ({name, size, style: providedStyle, unavailable}: Props) => { + const theme = useTheme(); + const style = getStyleSheet(theme); + const errorIconSize = size / 2; + + return ( + + + {unavailable && + + + + } + + ); +}; + +export default UnavailableIconWrapper; diff --git a/app/products/calls/connection/connection.ts b/app/products/calls/connection/connection.ts index 692a1809a..fec4a6dbc 100644 --- a/app/products/calls/connection/connection.ts +++ b/app/products/calls/connection/connection.ts @@ -25,7 +25,13 @@ import type {CallsConnection} from '@calls/types/calls'; const peerConnectTimeout = 5000; -export async function newConnection(serverUrl: string, channelID: string, closeCb: () => void, setScreenShareURL: (url: string) => void) { +export async function newConnection( + serverUrl: string, + channelID: string, + closeCb: () => void, + setScreenShareURL: (url: string) => void, + hasMicPermission: boolean, +) { let peer: Peer | null = null; let stream: MediaStream; let voiceTrackAdded = false; @@ -34,17 +40,23 @@ export async function newConnection(serverUrl: string, channelID: string, closeC let onCallEnd: EmitterSubscription | null = null; const streams: MediaStream[] = []; - try { - stream = await mediaDevices.getUserMedia({ - video: false, - audio: true, - }) as MediaStream; - voiceTrack = stream.getAudioTracks()[0]; - voiceTrack.enabled = false; - streams.push(stream); - } catch (err) { - logError('Unable to get media device:', err); - } + const initializeVoiceTrack = async () => { + if (voiceTrack) { + return; + } + + try { + stream = await mediaDevices.getUserMedia({ + video: false, + audio: true, + }) as MediaStream; + voiceTrack = stream.getAudioTracks()[0]; + voiceTrack.enabled = false; + streams.push(stream); + } catch (err) { + logError('Unable to get media device:', err); + } + }; // getClient can throw an error, which will be handled by the caller. const client = NetworkManager.getClient(serverUrl); @@ -56,6 +68,10 @@ export async function newConnection(serverUrl: string, channelID: string, closeC // Throws an error, to be caught by caller. await ws.initialize(); + if (hasMicPermission) { + initializeVoiceTrack(); + } + const disconnect = () => { if (isClosed) { return; @@ -265,6 +281,7 @@ export async function newConnection(serverUrl: string, channelID: string, closeC waitForPeerConnection, raiseHand, unraiseHand, + initializeVoiceTrack, }; return connection; diff --git a/app/products/calls/hooks.ts b/app/products/calls/hooks.ts index 99f6e9749..cc2b88f7f 100644 --- a/app/products/calls/hooks.ts +++ b/app/products/calls/hooks.ts @@ -3,14 +3,18 @@ // Check if calls is enabled. If it is, then run fn; if it isn't, show an alert and set // msgPostfix to ' (Not Available)'. -import {useCallback, useState} from 'react'; +import {useCallback, useEffect, useState} from 'react'; import {useIntl} from 'react-intl'; -import {Alert} from 'react-native'; +import {Alert, Platform} from 'react-native'; +import Permissions from 'react-native-permissions'; +import {initializeVoiceTrack} from '@calls/actions/calls'; +import {setMicPermissionsGranted} from '@calls/state'; import {errorAlert} from '@calls/utils'; import {Client} from '@client/rest'; import ClientError from '@client/rest/error'; import {useServerUrl} from '@context/server'; +import {useAppState} from '@hooks/device'; import NetworkManager from '@managers/network_manager'; export const useTryCallsFunction = (fn: () => void) => { @@ -71,3 +75,27 @@ export const useTryCallsFunction = (fn: () => void) => { return [tryFn, msgPostfix] as [() => Promise, string]; }; + +const micPermission = Platform.select({ + ios: Permissions.PERMISSIONS.IOS.MICROPHONE, + default: Permissions.PERMISSIONS.ANDROID.RECORD_AUDIO, +}); + +export const usePermissionsChecker = (micPermissionsGranted: boolean) => { + const appState = useAppState(); + + useEffect(() => { + const asyncFn = async () => { + if (appState === 'active') { + const hasPermission = (await Permissions.check(micPermission)) === Permissions.RESULTS.GRANTED; + if (hasPermission) { + initializeVoiceTrack(); + setMicPermissionsGranted(hasPermission); + } + } + }; + if (!micPermissionsGranted) { + asyncFn(); + } + }, [appState]); +}; diff --git a/app/products/calls/screens/call_screen/call_screen.tsx b/app/products/calls/screens/call_screen/call_screen.tsx index ec0a1421b..b89fce9a6 100644 --- a/app/products/calls/screens/call_screen/call_screen.tsx +++ b/app/products/calls/screens/call_screen/call_screen.tsx @@ -28,6 +28,9 @@ import { } from '@calls/actions'; import CallAvatar from '@calls/components/call_avatar'; import CallDuration from '@calls/components/call_duration'; +import PermissionErrorBar from '@calls/components/permission_error_bar'; +import UnavailableIconWrapper from '@calls/components/unavailable_icon_wrapper'; +import {usePermissionsChecker} from '@calls/hooks'; import RaisedHandIcon from '@calls/icons/raised_hand_icon'; import UnraisedHandIcon from '@calls/icons/unraised_hand_icon'; import {CallParticipant, CurrentCall} from '@calls/types/calls'; @@ -48,13 +51,14 @@ import { import NavigationStore from '@store/navigation_store'; import {bottomSheetSnapPoint} from '@utils/helpers'; import {mergeNavigationOptions} from '@utils/navigation'; -import {makeStyleSheetFromTheme} from '@utils/theme'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; import {displayUsername} from '@utils/user'; export type Props = { componentId: string; currentCall: CurrentCall | null; participantsDict: Dictionary; + micPermissionsGranted: boolean; teammateNameDisplay: string; fromThreadScreen?: boolean; } @@ -252,19 +256,31 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ color: 'white', margin: 3, }, + unavailableText: { + color: changeOpacity(theme.sidebarText, 0.32), + }, })); -const CallScreen = ({componentId, currentCall, participantsDict, teammateNameDisplay, fromThreadScreen}: Props) => { +const CallScreen = ({ + componentId, + currentCall, + participantsDict, + micPermissionsGranted, + teammateNameDisplay, + fromThreadScreen, +}: Props) => { const intl = useIntl(); const theme = useTheme(); const insets = useSafeAreaInsets(); const {width, height} = useWindowDimensions(); + usePermissionsChecker(micPermissionsGranted); const [showControlsInLandscape, setShowControlsInLandscape] = useState(false); const style = getStyleSheet(theme); const isLandscape = width > height; const showControls = !isLandscape || showControlsInLandscape; const myParticipant = currentCall?.participants[currentCall.myUserId]; + const micPermissionsError = !micPermissionsGranted && !currentCall?.micPermissionsErrorDismissed; const chatThreadTitle = intl.formatMessage({id: 'mobile.calls_chat_thread', defaultMessage: 'Chat thread'}); useEffect(() => { @@ -463,7 +479,7 @@ const CallScreen = ({componentId, currentCall, participantsDict, teammateNameDis ); return ( @@ -487,6 +503,7 @@ const CallScreen = ({componentId, currentCall, participantsDict, teammateNameDis {usersList} {screenShareView} + {micPermissionsError && } @@ -495,10 +512,12 @@ const CallScreen = ({componentId, currentCall, participantsDict, teammateNameDis testID='mute-unmute' style={[style.mute, myParticipant.muted && style.muteMuted]} onPress={muteUnmuteHandler} + disabled={!micPermissionsGranted} > - {myParticipant.muted ? UnmuteText : MuteText} diff --git a/app/products/calls/screens/call_screen/index.ts b/app/products/calls/screens/call_screen/index.ts index efcbf5dce..ed6845fd2 100644 --- a/app/products/calls/screens/call_screen/index.ts +++ b/app/products/calls/screens/call_screen/index.ts @@ -6,7 +6,7 @@ import {combineLatest, of as of$} from 'rxjs'; import {distinctUntilChanged, switchMap} from 'rxjs/operators'; import CallScreen from '@calls/screens/call_screen/call_screen'; -import {observeCurrentCall} from '@calls/state'; +import {observeCurrentCall, observeGlobalCallsState} from '@calls/state'; import {CallParticipant} from '@calls/types/calls'; import DatabaseManager from '@database/manager'; import {observeTeammateNameDisplay, queryUsersById} from '@queries/servers/user'; @@ -34,6 +34,10 @@ const enhanced = withObservables([], () => { }, {} as Dictionary))), )), ); + const micPermissionsGranted = observeGlobalCallsState().pipe( + switchMap((gs) => of$(gs.micPermissionsGranted)), + distinctUntilChanged(), + ); const teammateNameDisplay = database.pipe( switchMap((db) => (db ? observeTeammateNameDisplay(db) : of$(''))), distinctUntilChanged(), @@ -42,6 +46,7 @@ const enhanced = withObservables([], () => { return { currentCall, participantsDict, + micPermissionsGranted, teammateNameDisplay, }; }); diff --git a/app/products/calls/state/actions.test.ts b/app/products/calls/state/actions.test.ts index cef8fdc12..6bb7755cc 100644 --- a/app/products/calls/state/actions.test.ts +++ b/app/products/calls/state/actions.test.ts @@ -9,10 +9,12 @@ import { setCallsState, setChannelsWithCalls, setCurrentCall, + setMicPermissionsErrorDismissed, + setMicPermissionsGranted, useCallsConfig, useCallsState, useChannelsWithCalls, - useCurrentCall, + useCurrentCall, useGlobalCallsState, } from '@calls/state'; import { setCalls, @@ -34,9 +36,18 @@ import { } from '@calls/state/actions'; import {License} from '@constants'; -import {CallsState, CurrentCall, DefaultCallsConfig, DefaultCallsState} from '../types/calls'; +import { + Call, + CallsState, + CurrentCall, + DefaultCallsConfig, + DefaultCallsState, + DefaultCurrentCall, + DefaultGlobalCallsState, + GlobalCallsState, +} from '../types/calls'; -const call1 = { +const call1: Call = { participants: { 'user-1': {id: 'user-1', muted: false, raisedHand: 0}, 'user-2': {id: 'user-2', muted: true, raisedHand: 0}, @@ -47,7 +58,7 @@ const call1 = { threadId: 'thread-1', ownerId: 'user-1', }; -const call2 = { +const call2: Call = { participants: { 'user-3': {id: 'user-3', muted: false, raisedHand: 0}, 'user-4': {id: 'user-4', muted: true, raisedHand: 0}, @@ -58,7 +69,7 @@ const call2 = { threadId: 'thread-2', ownerId: 'user-3', }; -const call3 = { +const call3: Call = { participants: { 'user-5': {id: 'user-5', muted: false, raisedHand: 0}, 'user-6': {id: 'user-6', muted: true, raisedHand: 0}, @@ -109,12 +120,10 @@ describe('useCallsState', () => { 'channel-1': true, }; const initialCurrentCallState: CurrentCall = { + ...DefaultCurrentCall, serverUrl: 'server1', myUserId: 'myUserId', ...call1, - screenShareURL: '', - speakerphoneOn: false, - voiceOn: {}, }; const testNewCall1 = { ...call1, @@ -181,13 +190,12 @@ describe('useCallsState', () => { const initialChannelsWithCallsState = { 'channel-1': true, }; + const initialCurrentCallState: CurrentCall = { + ...DefaultCurrentCall, serverUrl: 'server1', myUserId: 'myUserId', ...call1, - screenShareURL: '', - speakerphoneOn: false, - voiceOn: {}, }; const expectedCallsState = { 'channel-1': { @@ -242,12 +250,10 @@ describe('useCallsState', () => { 'channel-1': true, }; const initialCurrentCallState: CurrentCall = { + ...DefaultCurrentCall, serverUrl: 'server1', myUserId: 'myUserId', ...call1, - screenShareURL: '', - speakerphoneOn: false, - voiceOn: {}, }; const expectedCallsState = { 'channel-1': { @@ -345,12 +351,10 @@ describe('useCallsState', () => { }; const initialChannelsWithCallsState = {'channel-1': true, 'channel-2': true}; const initialCurrentCallState: CurrentCall = { + ...DefaultCurrentCall, serverUrl: 'server1', myUserId: 'myUserId', ...call1, - screenShareURL: '', - speakerphoneOn: false, - voiceOn: {}, }; // setup @@ -393,12 +397,10 @@ describe('useCallsState', () => { }; const initialChannelsWithCallsState = {'channel-1': true, 'channel-2': true}; const initialCurrentCallState: CurrentCall = { + ...DefaultCurrentCall, serverUrl: 'server1', myUserId: 'myUserId', ...call1, - screenShareURL: '', - speakerphoneOn: false, - voiceOn: {}, }; // setup @@ -452,12 +454,10 @@ describe('useCallsState', () => { }, }; const initialCurrentCallState: CurrentCall = { + ...DefaultCurrentCall, serverUrl: 'server1', myUserId: 'myUserId', ...call1, - screenShareURL: '', - speakerphoneOn: false, - voiceOn: {}, }; const expectedCurrentCallState = { ...initialCurrentCallState, @@ -511,12 +511,10 @@ describe('useCallsState', () => { }, }; const expectedCurrentCallState: CurrentCall = { + ...DefaultCurrentCall, serverUrl: 'server1', myUserId: 'myUserId', - screenShareURL: '', - speakerphoneOn: false, ...newCall1, - voiceOn: {}, }; // setup @@ -657,6 +655,79 @@ describe('useCallsState', () => { assert.deepEqual(result.current[1], null); }); + it('MicPermissions', () => { + const initialGlobalState = DefaultGlobalCallsState; + const initialCallsState: CallsState = { + ...DefaultCallsState, + myUserId: 'myUserId', + calls: {'channel-1': call1, 'channel-2': call2}, + }; + const newCall1: Call = { + ...call1, + participants: { + ...call1.participants, + myUserId: {id: 'myUserId', muted: true, raisedHand: 0}, + }, + }; + const expectedCallsState: CallsState = { + ...initialCallsState, + calls: { + ...initialCallsState.calls, + 'channel-1': newCall1, + }, + }; + const expectedCurrentCallState: CurrentCall = { + ...DefaultCurrentCall, + serverUrl: 'server1', + myUserId: 'myUserId', + ...newCall1, + }; + const secondExpectedCurrentCallState: CurrentCall = { + ...expectedCurrentCallState, + micPermissionsErrorDismissed: true, + }; + const expectedGlobalState: GlobalCallsState = { + micPermissionsGranted: true, + }; + + // setup + const {result} = renderHook(() => { + return [useCallsState('server1'), useCurrentCall(), useGlobalCallsState()]; + }); + act(() => setCallsState('server1', initialCallsState)); + assert.deepEqual(result.current[0], initialCallsState); + assert.deepEqual(result.current[1], null); + assert.deepEqual(result.current[2], initialGlobalState); + + // join call + act(() => { + setMicPermissionsGranted(false); + userJoinedCall('server1', 'channel-1', 'myUserId'); + }); + assert.deepEqual(result.current[0], expectedCallsState); + assert.deepEqual(result.current[1], expectedCurrentCallState); + assert.deepEqual(result.current[2], initialGlobalState); + + // dismiss mic error + act(() => setMicPermissionsErrorDismissed()); + assert.deepEqual(result.current[0], expectedCallsState); + assert.deepEqual(result.current[1], secondExpectedCurrentCallState); + assert.deepEqual(result.current[2], initialGlobalState); + + // grant permissions + act(() => setMicPermissionsGranted(true)); + assert.deepEqual(result.current[0], expectedCallsState); + assert.deepEqual(result.current[1], secondExpectedCurrentCallState); + assert.deepEqual(result.current[2], expectedGlobalState); + + act(() => { + myselfLeftCall(); + userLeftCall('server1', 'channel-1', 'myUserId'); + }); + assert.deepEqual(result.current[0], initialCallsState); + assert.deepEqual(result.current[1], null); + }); + it('voiceOn and Off', () => { const initialCallsState = { ...DefaultCallsState, @@ -665,12 +736,10 @@ describe('useCallsState', () => { calls: {'channel-1': call1, 'channel-2': call2}, }; const initialCurrentCallState: CurrentCall = { + ...DefaultCurrentCall, serverUrl: 'server1', myUserId: 'myUserId', ...call1, - screenShareURL: '', - speakerphoneOn: false, - voiceOn: {}, }; // setup diff --git a/app/products/calls/state/actions.ts b/app/products/calls/state/actions.ts index 32c8b8261..d0b2462b3 100644 --- a/app/products/calls/state/actions.ts +++ b/app/products/calls/state/actions.ts @@ -6,10 +6,12 @@ import { getCallsState, getChannelsWithCalls, getCurrentCall, + getGlobalCallsState, setCallsConfig, setCallsState, setChannelsWithCalls, setCurrentCall, + setGlobalCallsState, } from '@calls/state'; import {Call, CallsConfig, ChannelsWithCalls} from '@calls/types/calls'; @@ -116,6 +118,7 @@ export const userJoinedCall = (serverUrl: string, channelId: string, userId: str screenShareURL: '', speakerphoneOn: false, voiceOn: {}, + micPermissionsErrorDismissed: false, }); } }; @@ -363,3 +366,26 @@ export const setPluginEnabled = (serverUrl: string, pluginEnabled: boolean) => { const callsConfig = getCallsConfig(serverUrl); setCallsConfig(serverUrl, {...callsConfig, pluginEnabled}); }; + +export const setMicPermissionsGranted = (granted: boolean) => { + const globalState = getGlobalCallsState(); + + const nextGlobalState = { + ...globalState, + micPermissionsGranted: granted, + }; + setGlobalCallsState(nextGlobalState); +}; + +export const setMicPermissionsErrorDismissed = () => { + const currentCall = getCurrentCall(); + if (!currentCall) { + return; + } + + const nextCurrentCall = { + ...currentCall, + micPermissionsErrorDismissed: true, + }; + setCurrentCall(nextCurrentCall); +}; diff --git a/app/products/calls/state/global_calls_state.ts b/app/products/calls/state/global_calls_state.ts new file mode 100644 index 000000000..94ae637d5 --- /dev/null +++ b/app/products/calls/state/global_calls_state.ts @@ -0,0 +1,38 @@ +// 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 {DefaultGlobalCallsState, GlobalCallsState} from '@calls/types/calls'; + +const globalStateSubject = new BehaviorSubject(DefaultGlobalCallsState); + +export const getGlobalCallsState = () => { + return globalStateSubject.value; +}; + +export const setGlobalCallsState = (globalState: GlobalCallsState) => { + globalStateSubject.next(globalState); +}; + +export const observeGlobalCallsState = () => { + return globalStateSubject.asObservable(); +}; + +export const useGlobalCallsState = () => { + const [state, setState] = useState(DefaultGlobalCallsState); + + useEffect(() => { + const subscription = globalStateSubject.subscribe((globalState) => { + setState(globalState); + }); + + return () => { + subscription?.unsubscribe(); + }; + }, []); + + return state; +}; + diff --git a/app/products/calls/state/index.ts b/app/products/calls/state/index.ts index b44d4f8eb..ae83ecc61 100644 --- a/app/products/calls/state/index.ts +++ b/app/products/calls/state/index.ts @@ -6,3 +6,4 @@ export * from './calls_state'; export * from './calls_config'; export * from './current_call'; export * from './channels_with_calls'; +export * from './global_calls_state'; diff --git a/app/products/calls/types/calls.ts b/app/products/calls/types/calls.ts index 7a7a6dce7..8bd41ac5e 100644 --- a/app/products/calls/types/calls.ts +++ b/app/products/calls/types/calls.ts @@ -4,6 +4,14 @@ import type UserModel from '@typings/database/models/servers/user'; import type {ConfigurationParamWithUrls, ConfigurationParamWithUrl} from 'react-native-webrtc'; +export type GlobalCallsState = { + micPermissionsGranted: boolean; +} + +export const DefaultGlobalCallsState: GlobalCallsState = { + micPermissionsGranted: false, +}; + export type CallsState = { serverUrl: string; myUserId: string; @@ -11,12 +19,12 @@ export type CallsState = { enabled: Dictionary; } -export const DefaultCallsState = { +export const DefaultCallsState: CallsState = { serverUrl: '', myUserId: '', calls: {} as Dictionary, enabled: {} as Dictionary, -} as CallsState; +}; export type Call = { participants: Dictionary; @@ -46,8 +54,23 @@ export type CurrentCall = { screenShareURL: string; speakerphoneOn: boolean; voiceOn: Dictionary; + micPermissionsErrorDismissed: boolean; } +export const DefaultCurrentCall: CurrentCall = { + serverUrl: '', + myUserId: '', + participants: {}, + channelId: '', + startTime: 0, + screenOn: '', + threadId: '', + screenShareURL: '', + speakerphoneOn: false, + voiceOn: {}, + micPermissionsErrorDismissed: false, +}; + export type CallParticipant = { id: string; muted: boolean; @@ -90,6 +113,7 @@ export type CallsConnection = { waitForPeerConnection: () => Promise; raiseHand: () => void; unraiseHand: () => void; + initializeVoiceTrack: () => void; } export type ServerCallsConfig = { @@ -107,7 +131,7 @@ export type CallsConfig = ServerCallsConfig & { last_retrieved_at: number; } -export const DefaultCallsConfig = { +export const DefaultCallsConfig: CallsConfig = { pluginEnabled: false, ICEServers: [], // deprecated ICEServersConfigs: [], @@ -117,7 +141,7 @@ export const DefaultCallsConfig = { last_retrieved_at: 0, sku_short_name: '', MaxCallParticipants: 0, -} as CallsConfig; +}; export type ICEServersConfigs = Array; diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index de610b01a..a9499ae31 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -368,7 +368,6 @@ "mobile.calls_end_permission_title": "Error", "mobile.calls_ended_at": "Ended at", "mobile.calls_error_message": "Error: {error}", - "mobile.calls_error_permissions": "No permissions to microphone, unable to start call", "mobile.calls_error_title": "Error", "mobile.calls_join_call": "Join call", "mobile.calls_lasted": "Lasted {duration}", @@ -377,6 +376,7 @@ "mobile.calls_limit_msg": "The maximum number of participants per call is {maxParticipants}. Contact your System Admin to increase the limit.", "mobile.calls_limit_reached": "Participant limit reached", "mobile.calls_lower_hand": "Lower hand", + "mobile.calls_mic_error": "To participate, open Settings to grant Mattermost access to your microphone.", "mobile.calls_more": "More", "mobile.calls_mute": "Mute", "mobile.calls_name_is_talking": "{name} is talking", @@ -482,8 +482,6 @@ "mobile.message_length.message": "Your current message is too long. Current character count: {count}/{max}", "mobile.message_length.message_split_left": "Message exceeds the character limit", "mobile.message_length.title": "Message Length", - "mobile.microphone_permission_denied_description": "To participate in this call, open Settings to grant Mattermost access to your microphone.", - "mobile.microphone_permission_denied_title": "{applicationName} would like to access your microphone", "mobile.no_results_with_term": "No results for “{term}”", "mobile.no_results_with_term.files": "No files matching “{term}”", "mobile.no_results_with_term.messages": "No matches found for “{term}”", @@ -548,12 +546,9 @@ "mobile.screen.settings": "Settings", "mobile.screen.your_profile": "Your Profile", "mobile.search.jump": "Jump to recent messages", - "mobile.search.modifier.after": "after a date", - "mobile.search.modifier.before": "before a date", "mobile.search.modifier.exclude": "exclude search terms", "mobile.search.modifier.from": "a specific user", "mobile.search.modifier.in": "a specific channel", - "mobile.search.modifier.on": "a specific date", "mobile.search.modifier.phrases": "messages with phrases", "mobile.search.show_less": "Show less", "mobile.search.show_more": "Show more", diff --git a/test/setup.ts b/test/setup.ts index f19657af8..0e3e55bdc 100644 --- a/test/setup.ts +++ b/test/setup.ts @@ -128,6 +128,10 @@ jest.doMock('react-native', () => { }, }), }, + WebRTCModule: { + senderGetCapabilities: jest.fn().mockReturnValue(null), + receiverGetCapabilities: jest.fn().mockReturnValue(null), + }, }; const Linking = {