From 17dbfdcb99af4e180c07137370f33b8de9065877 Mon Sep 17 00:00:00 2001 From: Christopher Poile Date: Fri, 29 Jul 2022 11:10:32 -0400 Subject: [PATCH] MM-45745 - Calls channel info screen options (#6502) * implement calls_channel_info; joinCall refactoring * i18n * Start/Join call as top button; movable copy channel link button * MM-45971 - Calls v2 PR comments (#6514) * don't clobber config if api req failed * combine two loops into one * update dependencies * fetch user model on user connected * fix state exports; spacing; unneeded field in ServerConfig type * remove useless return in websocket handler * constant sorting * move microphone permission request to leave_and_join_alert * ServerConfig -> ServerCallsConfig * console.log -> logError * ternary -> Platform.select * merge conflicts * add destructive options to OptionBox; require DismissChannelInfo fn * add CopyLink option to quick actions list * showSnackBar on link copied * adjust quick_options_height * Screens.Call * fix CopyLink spacing * fix observeUsersById, observe needed columns; fix JoinCallBanner mount * optimized observables; bug fixes * remove unneeded `return null` * PR comments * readable-stream -> 3.6.0 * merge conflicts * PR comments --- app/actions/websocket/index.ts | 2 - .../channel_actions/channel_actions.tsx | 34 +++++++-- .../copy_channel_link_option.tsx | 54 ++++++++++++++ .../copy_channel_link_option/index.ts | 38 ++++++++++ .../__snapshots__/channel_item.test.tsx.snap | 30 ++++++-- app/components/channel_item/channel_item.tsx | 3 +- app/components/channel_item/index.ts | 11 ++- app/components/option_box/index.tsx | 42 ++++++++--- app/constants/screens.ts | 4 +- app/products/calls/actions/calls.test.ts | 47 ++++++------ app/products/calls/actions/calls.ts | 65 +++++------------ app/products/calls/actions/index.ts | 1 - app/products/calls/actions/permissions.ts | 5 +- app/products/calls/client/rest.ts | 20 ++--- .../calls_custom_message.tsx | 3 +- .../components/calls_custom_message/index.ts | 34 +++++---- .../channel_info_enable_calls/index.tsx | 42 +++++++++++ .../channel_info_start_button.tsx | 67 +++++++++++++++++ .../components/channel_info_start/index.ts | 58 +++++++++++++++ .../current_call_bar/current_call_bar.tsx | 16 ++-- .../components/current_call_bar/index.ts | 46 ++++++++---- .../components/join_call_banner/index.ts | 48 ++++++++---- .../join_call_banner/join_call_banner.tsx | 21 +----- .../calls/components/leave_and_join_alert.tsx | 45 ++++++++++-- app/products/calls/connection/connection.ts | 15 ++-- .../calls/connection/websocket_client.ts | 3 +- .../connection/websocket_event_handlers.ts | 4 + app/products/calls/hooks.ts | 73 +++++++++++++++++++ .../calls/screens/call_screen/call_screen.tsx | 8 +- .../calls/screens/call_screen/index.ts | 14 +++- app/products/calls/state/actions.test.ts | 22 +++--- app/products/calls/state/actions.ts | 39 +++++++--- app/products/calls/state/calls_config.ts | 9 +-- app/products/calls/state/calls_state.ts | 9 +-- .../calls/state/channels_with_calls.ts | 9 +-- app/products/calls/state/current_call.ts | 9 +-- app/products/calls/state/index.ts | 8 +- app/products/calls/types/calls.ts | 5 +- app/products/calls/utils.ts | 36 +++++++++ app/queries/servers/user.ts | 8 +- app/screens/channel/channel.tsx | 27 +++++-- app/screens/channel/header/header.tsx | 16 +++- .../channel/header/quick_actions/index.tsx | 13 +++- app/screens/channel/index.tsx | 38 +++++++++- app/screens/channel_info/channel_info.tsx | 30 +++++++- app/screens/channel_info/index.ts | 66 +++++++++++++++-- app/screens/channel_info/options/index.tsx | 13 +++- assets/base/i18n/en.json | 12 +++ package-lock.json | 30 ++++---- package.json | 2 +- 50 files changed, 937 insertions(+), 317 deletions(-) create mode 100644 app/components/channel_actions/copy_channel_link_option/copy_channel_link_option.tsx create mode 100644 app/components/channel_actions/copy_channel_link_option/index.ts create mode 100644 app/products/calls/components/channel_info_enable_calls/index.tsx create mode 100644 app/products/calls/components/channel_info_start/channel_info_start_button.tsx create mode 100644 app/products/calls/components/channel_info_start/index.ts create mode 100644 app/products/calls/hooks.ts diff --git a/app/actions/websocket/index.ts b/app/actions/websocket/index.ts index 19f1f5e86d..01bbce1da2 100644 --- a/app/actions/websocket/index.ts +++ b/app/actions/websocket/index.ts @@ -402,6 +402,4 @@ export async function handleEvent(serverUrl: string, msg: WebSocketMessage) { handleCallUserUnraiseHand(serverUrl, msg); break; } - - return {}; } diff --git a/app/components/channel_actions/channel_actions.tsx b/app/components/channel_actions/channel_actions.tsx index 87b471851d..72a3025f3e 100644 --- a/app/components/channel_actions/channel_actions.tsx +++ b/app/components/channel_actions/channel_actions.tsx @@ -4,18 +4,22 @@ import React, {useCallback} from 'react'; import {StyleSheet, View} from 'react-native'; +import ChannelInfoStartButton from '@calls/components/channel_info_start'; import AddPeopleBox from '@components/channel_actions/add_people_box'; import CopyChannelLinkBox from '@components/channel_actions/copy_channel_link_box'; import FavoriteBox from '@components/channel_actions/favorite_box'; import MutedBox from '@components/channel_actions/mute_box'; import SetHeaderBox from '@components/channel_actions/set_header_box'; import {General} from '@constants'; +import {useServerUrl} from '@context/server'; import {dismissBottomSheet} from '@screens/navigation'; type Props = { channelId: string; channelType?: string; inModal?: boolean; + dismissChannelInfo: () => void; + callsEnabled: boolean; testID?: string; } @@ -32,7 +36,9 @@ const styles = StyleSheet.create({ }, }); -const ChannelActions = ({channelId, channelType, inModal = false, testID}: Props) => { +const ChannelActions = ({channelId, channelType, inModal = false, dismissChannelInfo, callsEnabled, testID}: Props) => { + const serverUrl = useServerUrl(); + const onCopyLinkAnimationEnd = useCallback(() => { if (!inModal) { requestAnimationFrame(async () => { @@ -41,6 +47,8 @@ const ChannelActions = ({channelId, channelType, inModal = false, testID}: Props } }, [inModal]); + const notDM = Boolean(channelType && !DIRECT_CHANNELS.includes(channelType)); + return ( } - {channelType && !DIRECT_CHANNELS.includes(channelType) && + {notDM && + + } + {notDM && !callsEnabled && <> - } + {callsEnabled && + <> + + + + } ); }; diff --git a/app/components/channel_actions/copy_channel_link_option/copy_channel_link_option.tsx b/app/components/channel_actions/copy_channel_link_option/copy_channel_link_option.tsx new file mode 100644 index 0000000000..6a249e8ac6 --- /dev/null +++ b/app/components/channel_actions/copy_channel_link_option/copy_channel_link_option.tsx @@ -0,0 +1,54 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import Clipboard from '@react-native-community/clipboard'; +import React, {useCallback} from 'react'; +import {useIntl} from 'react-intl'; + +import OptionItem from '@components/option_item'; +import SlideUpPanelItem from '@components/slide_up_panel_item'; +import {SNACK_BAR_TYPE} from '@constants/snack_bar'; +import {useServerUrl} from '@context/server'; +import {dismissBottomSheet} from '@screens/navigation'; +import {showSnackBar} from '@utils/snack_bar'; + +type Props = { + channelName?: string; + teamName?: string; + showAsLabel?: boolean; + testID?: string; +} + +const CopyChannelLinkOption = ({channelName, teamName, showAsLabel, testID}: Props) => { + const intl = useIntl(); + const serverUrl = useServerUrl(); + + const onCopyLink = useCallback(async () => { + Clipboard.setString(`${serverUrl}/${teamName}/channels/${channelName}`); + await dismissBottomSheet(); + showSnackBar({barType: SNACK_BAR_TYPE.LINK_COPIED}); + }, [channelName, teamName, serverUrl]); + + if (showAsLabel) { + return ( + + ); + } + + return ( + + ); +}; + +export default CopyChannelLinkOption; diff --git a/app/components/channel_actions/copy_channel_link_option/index.ts b/app/components/channel_actions/copy_channel_link_option/index.ts new file mode 100644 index 0000000000..ee975ec53b --- /dev/null +++ b/app/components/channel_actions/copy_channel_link_option/index.ts @@ -0,0 +1,38 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; +import withObservables from '@nozbe/with-observables'; +import {of as of$} from 'rxjs'; +import {switchMap} from 'rxjs/operators'; + +import CopyChannelLinkOption from '@components/channel_actions/copy_channel_link_option/copy_channel_link_option'; +import {observeChannel} from '@queries/servers/channel'; +import {observeTeam} from '@queries/servers/team'; + +import type {WithDatabaseArgs} from '@typings/database/database'; + +type OwnProps = WithDatabaseArgs & { + channelId: string; +} + +const enhanced = withObservables(['channelId'], ({channelId, database}: OwnProps) => { + const channel = observeChannel(database, channelId); + const team = channel.pipe( + switchMap((c) => (c?.teamId ? observeTeam(database, c.teamId) : of$(undefined))), + ); + const teamName = team.pipe( + switchMap((t) => of$(t?.name)), + ); + + const channelName = channel.pipe( + switchMap((c) => of$(c?.name)), + ); + return { + channelName, + teamName, + }; +}); + +export default withDatabase(enhanced(CopyChannelLinkOption)); + diff --git a/app/components/channel_item/__snapshots__/channel_item.test.tsx.snap b/app/components/channel_item/__snapshots__/channel_item.test.tsx.snap index 81ad599183..a89d38b600 100644 --- a/app/components/channel_item/__snapshots__/channel_item.test.tsx.snap +++ b/app/components/channel_item/__snapshots__/channel_item.test.tsx.snap @@ -226,12 +226,30 @@ exports[`components/channel_list/categories/body/channel_item should match snaps name="phone-in-talk" size={16} style={ - Object { - "color": "#ffffff", - "flex": 1, - "marginRight": 20, - "textAlign": "right", - } + Array [ + Object { + "fontFamily": "OpenSans", + "fontSize": 16, + "fontWeight": "400", + "lineHeight": 24, + }, + Object { + "color": "rgba(255,255,255,0.72)", + "marginTop": -1, + "paddingLeft": 12, + "paddingRight": 20, + }, + false, + null, + null, + false, + false, + Object { + "flex": 1, + "marginRight": 20, + "textAlign": "right", + }, + ] } /> diff --git a/app/components/channel_item/channel_item.tsx b/app/components/channel_item/channel_item.tsx index 1404ad9304..c1519a98de 100644 --- a/app/components/channel_item/channel_item.tsx +++ b/app/components/channel_item/channel_item.tsx @@ -116,7 +116,6 @@ export const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ top: 5, }, hasCall: { - color: theme.sidebarText, flex: 1, textAlign: 'right', marginRight: 20, @@ -249,7 +248,7 @@ const ChannelListItem = ({ } diff --git a/app/components/channel_item/index.ts b/app/components/channel_item/index.ts index 45021803d9..318a69a98c 100644 --- a/app/components/channel_item/index.ts +++ b/app/components/channel_item/index.ts @@ -28,7 +28,12 @@ type EnhanceProps = WithDatabaseArgs & { const observeIsMutedSetting = (mc: MyChannelModel) => mc.settings.observe().pipe(switchMap((s) => of$(s?.notifyProps?.mark_unread === General.MENTION))); -const enhance = withObservables(['channel', 'showTeamName'], ({channel, database, showTeamName, serverUrl}: EnhanceProps) => { +const enhance = withObservables(['channel', 'showTeamName'], ({ + channel, + database, + showTeamName, + serverUrl, +}: EnhanceProps) => { const currentUserId = observeCurrentUserId(database); const myChannel = observeMyChannel(database, channel.id); @@ -80,7 +85,9 @@ const enhance = withObservables(['channel', 'showTeamName'], ({channel, database ); const hasCall = observeChannelsWithCalls(serverUrl || '').pipe( - switchMap((calls) => of$(Boolean(calls[channel.id])))); + switchMap((calls) => of$(Boolean(calls[channel.id]))), + distinctUntilChanged(), + ); return { channel: channel.observe(), diff --git a/app/components/option_box/index.tsx b/app/components/option_box/index.tsx index 131f3422fc..752df0decf 100644 --- a/app/components/option_box/index.tsx +++ b/app/components/option_box/index.tsx @@ -18,6 +18,9 @@ type OptionBoxProps = { onPress: () => void; testID?: string; text: string; + destructiveIconName?: string; + destructiveText?: string; + isDestructive?: boolean; } export const OPTIONS_HEIGHT = 62; @@ -32,6 +35,9 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ justifyContent: 'center', minWidth: 80, }, + destructiveContainer: { + backgroundColor: changeOpacity(theme.dndIndicator, 0.04), + }, text: { color: changeOpacity(theme.centerChannelColor, 0.56), paddingHorizontal: 5, @@ -39,27 +45,41 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ }, })); -const OptionBox = ({activeIconName, activeText, containerStyle, iconName, isActive, onPress, testID, text}: OptionBoxProps) => { +const OptionBox = ({ + activeIconName, + activeText, + containerStyle, + iconName, + isActive, + onPress, + testID, + text, + destructiveIconName, + destructiveText, + isDestructive, +}: OptionBoxProps) => { const theme = useTheme(); const [activated, setActivated] = useState(isActive); const styles = getStyleSheet(theme); + const pressedStyle = useCallback(({pressed}: PressableStateCallbackType) => { - const style = [styles.container, Boolean(containerStyle) && containerStyle]; + const style = [styles.container, containerStyle, isDestructive && styles.destructiveContainer]; + const baseBgColor = isDestructive ? theme.dndIndicator : theme.buttonBg; if (activated) { style.push({ - backgroundColor: changeOpacity(theme.buttonBg, 0.08), + backgroundColor: changeOpacity(baseBgColor, 0.08), }); } if (pressed) { style.push({ - backgroundColor: changeOpacity(theme.buttonBg, 0.16), + backgroundColor: changeOpacity(baseBgColor, 0.16), }); } return style; - }, [activated, containerStyle, theme]); + }, [activated, containerStyle, theme, isDestructive]); const handleOnPress = useCallback(() => { if (activeIconName || activeText) { @@ -72,6 +92,10 @@ const OptionBox = ({activeIconName, activeText, containerStyle, iconName, isActi setActivated(isActive); }, [isActive]); + const destructIconName = (isDestructive && destructiveIconName) ? destructiveIconName : undefined; + const destructColor = isDestructive ? theme.dndIndicator : undefined; + const destructText = (isDestructive && destructiveText) ? destructiveText : undefined; + return ( ( <> - {activated && activeText ? activeText : text} + {destructText || (activated && activeText ? activeText : text)} )} diff --git a/app/constants/screens.ts b/app/constants/screens.ts index 422dc767bd..63cf605f7a 100644 --- a/app/constants/screens.ts +++ b/app/constants/screens.ts @@ -6,6 +6,7 @@ export const ACCOUNT = 'Account'; export const APPS_FORM = 'AppForm'; export const BOTTOM_SHEET = 'BottomSheet'; export const BROWSE_CHANNELS = 'BrowseChannels'; +export const CALL = 'Call'; export const CHANNEL = 'Channel'; export const CHANNEL_ADD_PEOPLE = 'ChannelAddPeople'; export const CHANNEL_INFO = 'ChannelInfo'; @@ -59,7 +60,6 @@ export const THREAD = 'Thread'; export const THREAD_FOLLOW_BUTTON = 'ThreadFollowButton'; export const THREAD_OPTIONS = 'ThreadOptions'; export const USER_PROFILE = 'UserProfile'; -export const CALL = 'Call'; export default { ABOUT, @@ -67,6 +67,7 @@ export default { APPS_FORM, BOTTOM_SHEET, BROWSE_CHANNELS, + CALL, CHANNEL, CHANNEL_ADD_PEOPLE, CHANNEL_INFO, @@ -120,7 +121,6 @@ export default { THREAD_FOLLOW_BUTTON, THREAD_OPTIONS, USER_PROFILE, - CALL, }; export const MODAL_SCREENS_WITHOUT_BACK = new Set([ diff --git a/app/products/calls/actions/calls.test.ts b/app/products/calls/actions/calls.test.ts index 48c82dbd0c..3e4bb17c79 100644 --- a/app/products/calls/actions/calls.test.ts +++ b/app/products/calls/actions/calls.test.ts @@ -10,10 +10,16 @@ import * as CallsActions from '@calls/actions'; import {getConnectionForTesting} from '@calls/actions/calls'; import * as Permissions from '@calls/actions/permissions'; import * as State from '@calls/state'; -import {exportedForInternalUse as callsConfigTesting} from '@calls/state/calls_config'; -import {exportedForInternalUse as callsStateTesting} from '@calls/state/calls_state'; -import {exportedForInternalUse as channelsWithCallsTesting} from '@calls/state/channels_with_calls'; -import {exportedForInternalUse as currentCallTesting} from '@calls/state/current_call'; +import { + setCallsConfig, + setCallsState, + setChannelsWithCalls, + setCurrentCall, + useCallsConfig, + useCallsState, + useChannelsWithCalls, + useCurrentCall, +} from '@calls/state'; import { Call, CallsState, @@ -23,12 +29,6 @@ import { DefaultCallsState, } from '@calls/types/calls'; import NetworkManager from '@managers/network_manager'; -import {getIntlShape} from '@test/intl-test-helper'; - -const {setCallsConfig, useCallsConfig} = callsConfigTesting; -const {setCallsState, useCallsState} = callsStateTesting; -const {setChannelsWithCalls, useChannelsWithCalls} = channelsWithCallsTesting; -const {useCurrentCall, setCurrentCall} = currentCallTesting; const mockClient = { getCalls: jest.fn(() => [ @@ -60,7 +60,6 @@ const mockClient = { ] )), enableChannelCalls: jest.fn(), - disableChannelCalls: jest.fn(), }; jest.mock('@calls/connection/connection', () => ({ @@ -98,7 +97,6 @@ describe('Actions.Calls', () => { // eslint-disable-next-line // @ts-ignore NetworkManager.getClient = () => mockClient; - const intl = getIntlShape(); jest.spyOn(Permissions, 'hasMicrophonePermission').mockReturnValue(Promise.resolve(true)); beforeAll(() => { @@ -119,7 +117,6 @@ describe('Actions.Calls', () => { mockClient.getCallsConfig.mockClear(); mockClient.getPluginsManifests.mockClear(); mockClient.enableChannelCalls.mockClear(); - mockClient.disableChannelCalls.mockClear(); // reset to default state for each test act(() => { @@ -139,7 +136,7 @@ describe('Actions.Calls', () => { let response: { data?: string }; await act(async () => { - response = await CallsActions.joinCall('server1', 'channel-id', intl); + response = await CallsActions.joinCall('server1', 'channel-id'); }); assert.equal(response!.data, 'channel-id'); @@ -162,7 +159,7 @@ describe('Actions.Calls', () => { let response: { data?: string }; await act(async () => { - response = await CallsActions.joinCall('server1', 'channel-id', intl); + response = await CallsActions.joinCall('server1', 'channel-id'); }); assert.equal(response!.data, 'channel-id'); assert.equal((result.current[1] as CurrentCall | null)?.channelId, 'channel-id'); @@ -189,7 +186,7 @@ describe('Actions.Calls', () => { let response: { data?: string }; await act(async () => { - response = await CallsActions.joinCall('server1', 'channel-id', intl); + response = await CallsActions.joinCall('server1', 'channel-id'); }); assert.equal(response!.data, 'channel-id'); assert.equal((result.current[1] as CurrentCall | null)?.channelId, 'channel-id'); @@ -215,7 +212,7 @@ describe('Actions.Calls', () => { let response: { data?: string }; await act(async () => { - response = await CallsActions.joinCall('server1', 'channel-id', intl); + response = await CallsActions.joinCall('server1', 'channel-id'); }); assert.equal(response!.data, 'channel-id'); assert.equal((result.current[1] as CurrentCall | null)?.channelId, 'channel-id'); @@ -262,26 +259,28 @@ describe('Actions.Calls', () => { it('enableChannelCalls', async () => { const {result} = renderHook(() => useCallsState('server1')); assert.equal(result.current.enabled['channel-1'], undefined); + mockClient.enableChannelCalls.mockReturnValueOnce({enabled: true}); await act(async () => { - await CallsActions.enableChannelCalls('server1', 'channel-1'); + await CallsActions.enableChannelCalls('server1', 'channel-1', true); }); - expect(mockClient.enableChannelCalls).toBeCalledWith('channel-1'); + expect(mockClient.enableChannelCalls).toBeCalledWith('channel-1', true); assert.equal(result.current.enabled['channel-1'], true); }); it('disableChannelCalls', async () => { const {result} = renderHook(() => useCallsState('server1')); assert.equal(result.current.enabled['channel-1'], undefined); + mockClient.enableChannelCalls.mockReturnValueOnce({enabled: true}); await act(async () => { - await CallsActions.enableChannelCalls('server1', 'channel-1'); + await CallsActions.enableChannelCalls('server1', 'channel-1', true); }); - expect(mockClient.enableChannelCalls).toBeCalledWith('channel-1'); - expect(mockClient.disableChannelCalls).not.toBeCalledWith('channel-1'); + expect(mockClient.enableChannelCalls).toBeCalledWith('channel-1', true); assert.equal(result.current.enabled['channel-1'], true); + mockClient.enableChannelCalls.mockReturnValueOnce({enabled: false}); await act(async () => { - await CallsActions.disableChannelCalls('server1', 'channel-1'); + await CallsActions.enableChannelCalls('server1', 'channel-1', false); }); - expect(mockClient.disableChannelCalls).toBeCalledWith('channel-1'); + expect(mockClient.enableChannelCalls).toBeCalledWith('channel-1', false); assert.equal(result.current.enabled['channel-1'], false); }); }); diff --git a/app/products/calls/actions/calls.ts b/app/products/calls/actions/calls.ts index 8d73a85a58..16aff9e986 100644 --- a/app/products/calls/actions/calls.ts +++ b/app/products/calls/actions/calls.ts @@ -5,7 +5,6 @@ import InCallManager from 'react-native-incall-manager'; import {forceLogoutIfNecessary} from '@actions/remote/session'; import {fetchUsersByIds} from '@actions/remote/user'; -import {hasMicrophonePermission} from '@calls/actions/permissions'; import { getCallsConfig, myselfJoinedCall, @@ -21,7 +20,6 @@ import { Call, CallParticipant, CallsConnection, - DefaultCallsConfig, ServerChannelState, } from '@calls/types/calls'; import Calls from '@constants/calls'; @@ -31,7 +29,6 @@ import {newConnection} from '../connection/connection'; import type {Client} from '@client/rest'; import type ClientError from '@client/rest/error'; -import type {IntlShape} from 'react-intl'; let connection: CallsConnection | null = null; export const getConnectionForTesting = () => connection; @@ -59,10 +56,6 @@ export const loadConfig = async (serverUrl: string, force = false) => { data = await client.getCallsConfig(); } catch (error) { await forceLogoutIfNecessary(serverUrl, error as ClientError); - - // Reset the config to the default (off) since it looks like Calls is not enabled. - setConfig(serverUrl, {...DefaultCallsConfig, last_retrieved_at: now}); - return {error}; } @@ -85,23 +78,20 @@ export const loadCalls = async (serverUrl: string, userId: string) => { await forceLogoutIfNecessary(serverUrl, error as ClientError); return {error}; } + const callsResults: Dictionary = {}; const enabledChannels: Dictionary = {}; - - // Batch load userModels async because we'll need them later const ids = new Set(); - resp.forEach((channel) => { - channel.call?.users.forEach((id) => ids.add(id)); - }); - if (ids.size > 0) { - fetchUsersByIds(serverUrl, Array.from(ids)); - } for (const channel of resp) { if (channel.call) { const call = channel.call; callsResults[channel.channel_id] = { participants: channel.call.users.reduce((accum, cur, curIdx) => { + // Add the id to the set of UserModels we want to ensure are loaded. + ids.add(cur); + + // Create the CallParticipant const muted = call.states && call.states[curIdx] ? !call.states[curIdx].unmuted : true; const raisedHand = call.states && call.states[curIdx] ? call.states[curIdx].raised_hand : 0; accum[cur] = {id: cur, muted, raisedHand}; @@ -116,6 +106,11 @@ export const loadCalls = async (serverUrl: string, userId: string) => { enabledChannels[channel.channel_id] = channel.enabled; } + // Batch load user models async because we'll need them later + if (ids.size > 0) { + fetchUsersByIds(serverUrl, Array.from(ids)); + } + setCalls(serverUrl, userId, callsResults, enabledChannels); return {data: {calls: callsResults, enabled: enabledChannels}}; @@ -151,7 +146,7 @@ export const checkIsCallsPluginEnabled = async (serverUrl: string) => { return {data: enabled}; }; -export const enableChannelCalls = async (serverUrl: string, channelId: string) => { +export const enableChannelCalls = async (serverUrl: string, channelId: string, enable: boolean) => { let client: Client; try { client = NetworkManager.getClient(serverUrl); @@ -160,36 +155,19 @@ export const enableChannelCalls = async (serverUrl: string, channelId: string) = } try { - await client.enableChannelCalls(channelId); + const res = await client.enableChannelCalls(channelId, enable); + if (res.enabled === enable) { + setChannelEnabled(serverUrl, channelId, enable); + } } catch (error) { await forceLogoutIfNecessary(serverUrl, error as ClientError); return {error}; } - setChannelEnabled(serverUrl, channelId, true); return {}; }; -export const disableChannelCalls = async (serverUrl: string, channelId: string) => { - let client: Client; - try { - client = NetworkManager.getClient(serverUrl); - } catch (error) { - return {error}; - } - - try { - await client.disableChannelCalls(channelId); - } catch (error) { - await forceLogoutIfNecessary(serverUrl, error as ClientError); - return {error}; - } - - setChannelEnabled(serverUrl, channelId, false); - return {}; -}; - -export const joinCall = async (serverUrl: string, channelId: string, intl: IntlShape) => { +export const joinCall = async (serverUrl: string, channelId: string): 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); @@ -197,11 +175,6 @@ export const joinCall = async (serverUrl: string, channelId: string, intl: IntlS return {error: 'calls plugin not enabled'}; } - const hasPermission = await hasMicrophonePermission(intl); - if (!hasPermission) { - return {error: 'no permissions to microphone, unable to start call'}; - } - if (connection) { connection.disconnect(); connection = null; @@ -210,9 +183,9 @@ export const joinCall = async (serverUrl: string, channelId: string, intl: IntlS try { connection = await newConnection(serverUrl, channelId, () => null, setScreenShareURL); - } catch (error) { + } catch (error: unknown) { await forceLogoutIfNecessary(serverUrl, error as ClientError); - return {error}; + return {error: error as Error}; } try { @@ -222,7 +195,7 @@ export const joinCall = async (serverUrl: string, channelId: string, intl: IntlS } catch (e) { connection.disconnect(); connection = null; - return {error: 'unable to connect to the voice call'}; + return {error: `unable to connect to the voice call: ${e}`}; } }; diff --git a/app/products/calls/actions/index.ts b/app/products/calls/actions/index.ts index 773996cb6c..e8f579c9f4 100644 --- a/app/products/calls/actions/index.ts +++ b/app/products/calls/actions/index.ts @@ -5,7 +5,6 @@ export { loadConfig, loadCalls, enableChannelCalls, - disableChannelCalls, joinCall, leaveCall, muteMyself, diff --git a/app/products/calls/actions/permissions.ts b/app/products/calls/actions/permissions.ts index a64931fa0c..9789d00710 100644 --- a/app/products/calls/actions/permissions.ts +++ b/app/products/calls/actions/permissions.ts @@ -23,7 +23,10 @@ const getMicrophonePermissionDeniedMessage = (intl: IntlShape) => { }; export const hasMicrophonePermission = async (intl: IntlShape) => { - const targetSource = Platform.OS === 'ios' ? Permissions.PERMISSIONS.IOS.MICROPHONE : Permissions.PERMISSIONS.ANDROID.RECORD_AUDIO; + const targetSource = Platform.select({ + ios: Permissions.PERMISSIONS.IOS.MICROPHONE, + default: Permissions.PERMISSIONS.ANDROID.RECORD_AUDIO, + }); const hasPermission = await Permissions.check(targetSource); switch (hasPermission) { diff --git a/app/products/calls/client/rest.ts b/app/products/calls/client/rest.ts index 6d3342c3eb..70778ded32 100644 --- a/app/products/calls/client/rest.ts +++ b/app/products/calls/client/rest.ts @@ -1,14 +1,13 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {ServerChannelState, ServerConfig} from '@calls/types/calls'; +import {ServerChannelState, ServerCallsConfig} from '@calls/types/calls'; export interface ClientCallsMix { getEnabled: () => Promise; getCalls: () => Promise; - getCallsConfig: () => Promise; - enableChannelCalls: (channelId: string) => Promise; - disableChannelCalls: (channelId: string) => Promise; + getCallsConfig: () => Promise; + enableChannelCalls: (channelId: string, enable: boolean) => Promise; } const ClientCalls = (superclass: any) => class extends superclass { @@ -35,20 +34,13 @@ const ClientCalls = (superclass: any) => class extends superclass { return this.doFetch( `${this.getCallsRoute()}/config`, {method: 'get'}, - ) as ServerConfig; + ) as ServerCallsConfig; }; - enableChannelCalls = async (channelId: string) => { + enableChannelCalls = async (channelId: string, enable: boolean) => { return this.doFetch( `${this.getCallsRoute()}/${channelId}`, - {method: 'post', body: JSON.stringify({enabled: true})}, - ); - }; - - disableChannelCalls = async (channelId: string) => { - return this.doFetch( - `${this.getCallsRoute()}/${channelId}`, - {method: 'post', body: JSON.stringify({enabled: false})}, + {method: 'post', body: {enabled: enable}}, ); }; }; diff --git a/app/products/calls/components/calls_custom_message/calls_custom_message.tsx b/app/products/calls/components/calls_custom_message/calls_custom_message.tsx index 95f663c2ab..e6232fa5cc 100644 --- a/app/products/calls/components/calls_custom_message/calls_custom_message.tsx +++ b/app/products/calls/components/calls_custom_message/calls_custom_message.tsx @@ -6,7 +6,6 @@ import React from 'react'; import {useIntl} from 'react-intl'; import {Text, TouchableOpacity, View} from 'react-native'; -import {joinCall} from '@calls/actions'; import leaveAndJoinWithAlert from '@calls/components/leave_and_join_alert'; import CompassIcon from '@components/compass_icon'; import FormattedRelativeTime from '@components/formatted_relative_time'; @@ -112,7 +111,7 @@ export const CallsCustomMessage = ({ return; } - leaveAndJoinWithAlert(intl, serverUrl, post.channelId, leaveChannelName || '', joinChannelName || '', confirmToJoin, joinCall); + leaveAndJoinWithAlert(intl, serverUrl, post.channelId, leaveChannelName || '', joinChannelName || '', confirmToJoin, false); }; if (post.props.end_at) { diff --git a/app/products/calls/components/calls_custom_message/index.ts b/app/products/calls/components/calls_custom_message/index.ts index 2c3b8cb9c6..b40fcd7acd 100644 --- a/app/products/calls/components/calls_custom_message/index.ts +++ b/app/products/calls/components/calls_custom_message/index.ts @@ -4,7 +4,7 @@ import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; import withObservables from '@nozbe/with-observables'; import {combineLatest, of as of$} from 'rxjs'; -import {switchMap} from 'rxjs/operators'; +import {distinctUntilChanged, switchMap} from 'rxjs/operators'; import {CallsCustomMessage} from '@calls/components/calls_custom_message/calls_custom_message'; import {observeCurrentCall} from '@calls/state'; @@ -36,27 +36,33 @@ const enhanced = withObservables(['post'], ({post, database}: { post: PostModel }; } - const currentCall = observeCurrentCall(); - const ccDatabase = currentCall.pipe( - switchMap((call) => of$(call ? call.serverUrl : '')), + const ccDatabase = observeCurrentCall().pipe( + switchMap((call) => of$(call?.serverUrl || '')), + distinctUntilChanged(), switchMap((url) => of$(DatabaseManager.serverDatabases[url]?.database)), ); + const currentCallChannelId = observeCurrentCall().pipe( + switchMap((call) => of$(call?.channelId || '')), + distinctUntilChanged(), + ); + const leaveChannelName = combineLatest([ccDatabase, currentCallChannelId]).pipe( + switchMap(([db, id]) => (db && id ? observeChannel(db, id) : of$(undefined))), + switchMap((c) => of$(c ? c.displayName : '')), + distinctUntilChanged(), + ); + const joinChannelName = observeChannel(database, post.channelId).pipe( + switchMap((chan) => of$(chan?.displayName || '')), + distinctUntilChanged(), + ); return { currentUser, author, isMilitaryTime, teammateNameDisplay: observeTeammateNameDisplay(database), - currentCallChannelId: currentCall.pipe( - switchMap((cc) => of$(cc?.channelId || '')), - ), - leaveChannelName: combineLatest([ccDatabase, currentCall]).pipe( - switchMap(([db, call]) => (db && call ? observeChannel(db, call.channelId) : of$(undefined))), - switchMap((c) => of$(c ? c.displayName : '')), - ), - joinChannelName: observeChannel(database, post.channelId).pipe( - switchMap((chan) => of$(chan?.displayName || '')), - ), + currentCallChannelId, + leaveChannelName, + joinChannelName, }; }); diff --git a/app/products/calls/components/channel_info_enable_calls/index.tsx b/app/products/calls/components/channel_info_enable_calls/index.tsx new file mode 100644 index 0000000000..0b9063dbd9 --- /dev/null +++ b/app/products/calls/components/channel_info_enable_calls/index.tsx @@ -0,0 +1,42 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback} from 'react'; +import {useIntl} from 'react-intl'; + +import {enableChannelCalls} from '@calls/actions'; +import {useTryCallsFunction} from '@calls/hooks'; +import OptionItem from '@components/option_item'; +import {useServerUrl} from '@context/server'; +import {preventDoubleTap} from '@utils/tap'; + +interface Props { + channelId: string; + enabled: boolean; +} + +const ChannelInfoEnableCalls = ({channelId, enabled}: Props) => { + const {formatMessage} = useIntl(); + const serverUrl = useServerUrl(); + + const toggleCalls = useCallback(async () => { + enableChannelCalls(serverUrl, channelId, !enabled); + }, [serverUrl, channelId, enabled]); + + const [tryOnPress, msgPostfix] = useTryCallsFunction(toggleCalls); + + const disableText = formatMessage({id: 'mobile.calls_disable', defaultMessage: 'Disable Calls'}); + const enableText = formatMessage({id: 'mobile.calls_enable', defaultMessage: 'Enable Calls'}); + + return ( + + ); +}; + +export default ChannelInfoEnableCalls; diff --git a/app/products/calls/components/channel_info_start/channel_info_start_button.tsx b/app/products/calls/components/channel_info_start/channel_info_start_button.tsx new file mode 100644 index 0000000000..7a608dad20 --- /dev/null +++ b/app/products/calls/components/channel_info_start/channel_info_start_button.tsx @@ -0,0 +1,67 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback} from 'react'; +import {useIntl} from 'react-intl'; + +import {leaveCall} from '@calls/actions'; +import leaveAndJoinWithAlert from '@calls/components/leave_and_join_alert'; +import {useTryCallsFunction} from '@calls/hooks'; +import OptionBox from '@components/option_box'; +import {preventDoubleTap} from '@utils/tap'; + +export interface Props { + serverUrl: string; + displayName: string; + channelId: string; + isACallInCurrentChannel: boolean; + confirmToJoin: boolean; + alreadyInCall: boolean; + currentCallChannelName: string; + dismissChannelInfo: () => void; +} + +const ChannelInfoStartButton = ({ + serverUrl, + displayName, + channelId, + isACallInCurrentChannel, + confirmToJoin, + alreadyInCall, + currentCallChannelName, + dismissChannelInfo, +}: Props) => { + const intl = useIntl(); + + const toggleJoinLeave = useCallback(() => { + if (alreadyInCall) { + leaveCall(); + } else { + leaveAndJoinWithAlert(intl, serverUrl, channelId, currentCallChannelName, displayName, confirmToJoin, !isACallInCurrentChannel); + } + + dismissChannelInfo(); + }, [alreadyInCall, dismissChannelInfo, intl, serverUrl, channelId, currentCallChannelName, displayName, confirmToJoin, isACallInCurrentChannel]); + const [tryJoin, msgPostfix] = useTryCallsFunction(toggleJoinLeave); + + const joinText = intl.formatMessage({id: 'mobile.calls_join_call', defaultMessage: 'Join Call'}); + const startText = intl.formatMessage({id: 'mobile.calls_start_call', defaultMessage: 'Start Call'}); + const leaveText = intl.formatMessage({id: 'mobile.calls_leave_call', defaultMessage: 'Leave Call'}); + + return ( + + ); +}; + +export default ChannelInfoStartButton; diff --git a/app/products/calls/components/channel_info_start/index.ts b/app/products/calls/components/channel_info_start/index.ts new file mode 100644 index 0000000000..cb0c142f66 --- /dev/null +++ b/app/products/calls/components/channel_info_start/index.ts @@ -0,0 +1,58 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; +import withObservables from '@nozbe/with-observables'; +import {combineLatest, of as of$} from 'rxjs'; +import {distinctUntilChanged, switchMap} from 'rxjs/operators'; + +import ChannelInfoStartButton from '@calls/components/channel_info_start/channel_info_start_button'; +import {observeChannelsWithCalls, observeCurrentCall} from '@calls/state'; +import DatabaseManager from '@database/manager'; +import {observeChannel} from '@queries/servers/channel'; + +import type {WithDatabaseArgs} from '@typings/database/database'; + +type EnhanceProps = WithDatabaseArgs & { + serverUrl: string; + channelId: string; +} + +const enhanced = withObservables([], ({serverUrl, channelId, database}: EnhanceProps) => { + const displayName = observeChannel(database, channelId).pipe( + switchMap((channel) => of$(channel?.displayName || '')), + distinctUntilChanged(), + ); + const isACallInCurrentChannel = observeChannelsWithCalls(serverUrl).pipe( + switchMap((calls) => of$(Boolean(calls[channelId]))), + distinctUntilChanged(), + ); + const currentCall = observeCurrentCall(); + const ccDatabase = currentCall.pipe( + switchMap((call) => of$(call?.serverUrl || '')), + distinctUntilChanged(), + switchMap((url) => of$(DatabaseManager.serverDatabases[url]?.database)), + ); + const ccChannelId = currentCall.pipe( + switchMap((call) => of$(call?.channelId)), + distinctUntilChanged(), + ); + const confirmToJoin = ccChannelId.pipe(switchMap((ccId) => of$(ccId && ccId !== channelId))); + const alreadyInCall = ccChannelId.pipe(switchMap((ccId) => of$(ccId && ccId === channelId))); + const currentCallChannelName = combineLatest([ccDatabase, ccChannelId]).pipe( + switchMap(([db, id]) => (db && id ? observeChannel(db, id) : of$(undefined))), + switchMap((c) => of$(c?.displayName || '')), + distinctUntilChanged(), + ); + + return { + displayName, + isACallInCurrentChannel, + confirmToJoin, + alreadyInCall, + currentCall, + currentCallChannelName, + }; +}); + +export default withDatabase(enhanced(ChannelInfoStartButton)); 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 142ab76d55..376f0c6289 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 @@ -2,6 +2,7 @@ // See LICENSE.txt for license information. import React, {useCallback, useEffect, useState} from 'react'; +import {useIntl} from 'react-intl'; import {View, Text, TouchableOpacity, Pressable, Platform, DeviceEventEmitter} from 'react-native'; import {Options} from 'react-native-navigation'; @@ -9,7 +10,7 @@ import {muteMyself, unmuteMyself} from '@calls/actions'; import CallAvatar from '@calls/components/call_avatar'; import {CurrentCall, VoiceEventData} from '@calls/types/calls'; import CompassIcon from '@components/compass_icon'; -import {Events, WebsocketEvents} from '@constants'; +import {Events, Screens, WebsocketEvents} from '@constants'; import {CURRENT_CALL_BAR_HEIGHT} from '@constants/view'; import {useTheme} from '@context/theme'; import {goToScreen} from '@screens/navigation'; @@ -44,6 +45,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { }, userInfo: { flex: 1, + paddingLeft: 10, }, speakingUser: { color: theme.sidebarText, @@ -85,8 +87,10 @@ const CurrentCallBar = ({ teammateNameDisplay, }: Props) => { const theme = useTheme(); - const isCurrentCall = Boolean(currentCall); + const {formatMessage} = useIntl(); const [speaker, setSpeaker] = useState(null); + + const isCurrentCall = Boolean(currentCall); const handleVoiceOn = (data: VoiceEventData) => { if (data.channelId === currentCall?.channelId) { setSpeaker(data.userId); @@ -123,13 +127,11 @@ const CurrentCallBar = ({ visible: Platform.OS === 'android', }, }; - goToScreen('Call', 'Call', {}, options); + const title = formatMessage({id: 'mobile.calls_call_screen', defaultMessage: 'Call'}); + goToScreen(Screens.CALL, title, {}, options); }, []); const myParticipant = currentCall?.participants[currentCall.myUserId]; - if (!currentCall || !myParticipant) { - return null; - } const muteUnmute = () => { if (myParticipant?.muted) { @@ -146,7 +148,7 @@ const CurrentCallBar = ({ diff --git a/app/products/calls/components/current_call_bar/index.ts b/app/products/calls/components/current_call_bar/index.ts index 238f714d5b..1ddce1122a 100644 --- a/app/products/calls/components/current_call_bar/index.ts +++ b/app/products/calls/components/current_call_bar/index.ts @@ -3,12 +3,13 @@ import withObservables from '@nozbe/with-observables'; import {combineLatest, of as of$} from 'rxjs'; -import {switchMap} from 'rxjs/operators'; +import {distinctUntilChanged, switchMap} from 'rxjs/operators'; import {observeCurrentCall} from '@calls/state'; +import {idsAreEqual} from '@calls/utils'; import DatabaseManager from '@database/manager'; import {observeChannel} from '@queries/servers/channel'; -import {observeTeammateNameDisplay, observeUsersById} from '@queries/servers/user'; +import {observeTeammateNameDisplay, queryUsersById} from '@queries/servers/user'; import CurrentCallBar from './current_call_bar'; @@ -16,22 +17,30 @@ import type UserModel from '@typings/database/models/servers/user'; const enhanced = withObservables([], () => { const currentCall = observeCurrentCall(); - const database = currentCall.pipe( - switchMap((call) => of$(call ? call.serverUrl : '')), + const ccServerUrl = currentCall.pipe( + switchMap((call) => of$(call?.serverUrl || '')), + distinctUntilChanged(), + ); + const ccChannelId = currentCall.pipe( + switchMap((call) => of$(call?.channelId || '')), + distinctUntilChanged(), + ); + const database = ccServerUrl.pipe( switchMap((url) => of$(DatabaseManager.serverDatabases[url]?.database)), ); - const displayName = combineLatest([database, currentCall]).pipe( - switchMap(([db, call]) => (db && call ? observeChannel(db, call.channelId) : of$(undefined))), - switchMap((c) => of$(c ? c.displayName : '')), + const displayName = combineLatest([database, ccChannelId]).pipe( + switchMap(([db, id]) => (db && id ? observeChannel(db, id) : of$(undefined))), + switchMap((c) => of$(c?.displayName || '')), + distinctUntilChanged(), ); - const userModelsDict = combineLatest([database, currentCall]).pipe( - switchMap(([db, call]) => (db && call ? observeUsersById(db, Object.keys(call.participants)) : of$([]))), - switchMap((ps) => of$( - ps.reduce((accum, cur) => { // eslint-disable-line max-nested-callbacks - accum[cur.id] = cur; - return accum; - }, {} as Dictionary)), - ), + const participantIds = currentCall.pipe( + distinctUntilChanged((prev, curr) => prev?.participants === curr?.participants), // Did the participants object ref change? + switchMap((call) => (call ? of$(Object.keys(call.participants)) : of$([]))), + distinctUntilChanged((prev, curr) => idsAreEqual(prev, curr)), + ); + const userModelsDict = combineLatest([database, participantIds]).pipe( + switchMap(([db, ids]) => (db && ids.length > 0 ? queryUsersById(db, ids).observeWithColumns(['nickname', 'username', 'first_name', 'last_name']) : of$([]))), + switchMap((ps) => of$(arrayToDic(ps))), ); const teammateNameDisplay = database.pipe( switchMap((db) => (db ? observeTeammateNameDisplay(db) : of$(''))), @@ -45,4 +54,11 @@ const enhanced = withObservables([], () => { }; }); +function arrayToDic(participants: UserModel[]) { + return participants.reduce((accum, cur) => { + accum[cur.id] = cur; + return accum; + }, {} as Dictionary); +} + export default enhanced(CurrentCallBar); diff --git a/app/products/calls/components/join_call_banner/index.ts b/app/products/calls/components/join_call_banner/index.ts index f416229b9f..f4a731cde4 100644 --- a/app/products/calls/components/join_call_banner/index.ts +++ b/app/products/calls/components/join_call_banner/index.ts @@ -4,12 +4,13 @@ import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; import withObservables from '@nozbe/with-observables'; import {of as of$} from 'rxjs'; -import {switchMap} from 'rxjs/operators'; +import {distinctUntilChanged, switchMap} from 'rxjs/operators'; import JoinCallBanner from '@calls/components/join_call_banner/join_call_banner'; -import {observeCallsState, observeChannelsWithCalls, observeCurrentCall} from '@calls/state'; +import {observeCallsState, observeCurrentCall} from '@calls/state'; +import {idsAreEqual} from '@calls/utils'; import {observeChannel} from '@queries/servers/channel'; -import {observeUsersById} from '@queries/servers/user'; +import {queryUsersById} from '@queries/servers/user'; import {WithDatabaseArgs} from '@typings/database/database'; type OwnProps = { @@ -17,31 +18,46 @@ type OwnProps = { channelId: string; } -const enhanced = withObservables(['serverUrl', 'channelId'], ({serverUrl, channelId, database}: OwnProps & WithDatabaseArgs) => { +const enhanced = withObservables(['serverUrl', 'channelId'], ({ + serverUrl, + channelId, + database, +}: OwnProps & WithDatabaseArgs) => { const displayName = observeChannel(database, channelId).pipe( switchMap((c) => of$(c?.displayName)), + distinctUntilChanged(), ); - const currentCall = observeCurrentCall(); - const participants = currentCall.pipe( - switchMap((call) => (call ? observeUsersById(database, Object.keys(call.participants)) : of$([]))), + const callsState = observeCallsState(serverUrl); + const participants = callsState.pipe( + switchMap((state) => of$(state.calls[channelId])), + distinctUntilChanged((prev, curr) => prev.participants === curr.participants), // Did the participants object ref change? + switchMap((call) => (call ? of$(Object.keys(call.participants)) : of$([]))), + distinctUntilChanged((prev, curr) => idsAreEqual(prev, curr)), // Continue only if we have a different set of participant ids + switchMap((ids) => (ids.length > 0 ? queryUsersById(database, ids).observeWithColumns(['last_picture_update']) : of$([]))), ); - const currentCallChannelName = currentCall.pipe( - switchMap((call) => observeChannel(database, call ? call.channelId : '')), + const currentCallChannelId = observeCurrentCall().pipe( + switchMap((call) => of$(call?.channelId || undefined)), + distinctUntilChanged(), + ); + const inACall = currentCallChannelId.pipe( + switchMap((id) => of$(Boolean(id))), + distinctUntilChanged(), + ); + const currentCallChannelName = currentCallChannelId.pipe( + switchMap((id) => observeChannel(database, id || '')), switchMap((channel) => of$(channel ? channel.displayName : '')), + distinctUntilChanged(), ); - const isCallInCurrentChannel = observeChannelsWithCalls(serverUrl).pipe( - switchMap((calls) => of$(Boolean(calls[channelId]))), - ); - const channelCallStartTime = observeCallsState(serverUrl).pipe( - switchMap((callsState) => of$(callsState.calls[channelId]?.startTime || 0)), + const channelCallStartTime = callsState.pipe( + switchMap((cs) => of$(cs.calls[channelId]?.startTime || 0)), + distinctUntilChanged(), ); return { displayName, - currentCall, participants, + inACall, currentCallChannelName, - isCallInCurrentChannel, channelCallStartTime, }; }); diff --git a/app/products/calls/components/join_call_banner/join_call_banner.tsx b/app/products/calls/components/join_call_banner/join_call_banner.tsx index 4f9c4505b8..761b1a2a89 100644 --- a/app/products/calls/components/join_call_banner/join_call_banner.tsx +++ b/app/products/calls/components/join_call_banner/join_call_banner.tsx @@ -5,9 +5,7 @@ import React from 'react'; import {useIntl} from 'react-intl'; import {View, Text, Pressable} from 'react-native'; -import {joinCall} from '@calls/actions'; import leaveAndJoinWithAlert from '@calls/components/leave_and_join_alert'; -import {CurrentCall} from '@calls/types/calls'; import CompassIcon from '@components/compass_icon'; import FormattedRelativeTime from '@components/formatted_relative_time'; import UserAvatarsStack from '@components/user_avatars_stack'; @@ -22,10 +20,9 @@ type Props = { channelId: string; serverUrl: string; displayName: string; - currentCall: CurrentCall | null; + inACall: boolean; participants: UserModel[]; currentCallChannelName: string; - isCallInCurrentChannel: boolean; channelCallStartTime: number; } @@ -74,29 +71,17 @@ const JoinCallBanner = ({ channelId, serverUrl, displayName, - currentCall, participants, + inACall, currentCallChannelName, - isCallInCurrentChannel, channelCallStartTime, }: Props) => { const intl = useIntl(); const theme = useTheme(); const style = getStyleSheet(theme); - if (!isCallInCurrentChannel) { - return null; - } - - const confirmToJoin = Boolean(currentCall && currentCall.channelId !== channelId); - const alreadyInTheCall = Boolean(currentCall && currentCall.channelId === channelId); - - if (alreadyInTheCall) { - return null; - } - const joinHandler = async () => { - leaveAndJoinWithAlert(intl, serverUrl, channelId, currentCallChannelName, displayName, confirmToJoin, joinCall); + leaveAndJoinWithAlert(intl, serverUrl, channelId, currentCallChannelName, displayName, inACall, false); }; return ( diff --git a/app/products/calls/components/leave_and_join_alert.tsx b/app/products/calls/components/leave_and_join_alert.tsx index ca969b5c30..6953507115 100644 --- a/app/products/calls/components/leave_and_join_alert.tsx +++ b/app/products/calls/components/leave_and_join_alert.tsx @@ -4,6 +4,9 @@ import {IntlShape} from 'react-intl'; import {Alert} from 'react-native'; +import {hasMicrophonePermission, joinCall} from '@calls/actions'; +import {errorAlert} from '@calls/utils'; + export default function leaveAndJoinWithAlert( intl: IntlShape, serverUrl: string, @@ -11,19 +14,28 @@ export default function leaveAndJoinWithAlert( leaveChannelName: string, joinChannelName: string, confirmToJoin: boolean, - joinCall: (serverUrl: string, channelId: string, intl: IntlShape) => void, + newCall: boolean, ) { if (confirmToJoin) { const {formatMessage} = intl; + + let joinMessage = formatMessage({ + id: 'mobile.leave_and_join_message', + defaultMessage: 'You are already on a channel call in ~{leaveChannelName}. Do you want to leave your current call and join the call in ~{joinChannelName}?', + }, {leaveChannelName, joinChannelName}); + if (newCall) { + joinMessage = formatMessage({ + id: 'mobile.leave_and_join_message', + defaultMessage: 'You are already on a channel call in ~{leaveChannelName}. Do you want to leave your current call and start a new call in ~{joinChannelName}?', + }, {leaveChannelName, joinChannelName}); + } + Alert.alert( formatMessage({ id: 'mobile.leave_and_join_title', defaultMessage: 'Are you sure you want to switch to a different call?', }), - formatMessage({ - id: 'mobile.leave_and_join_message', - defaultMessage: 'You are already on a channel call in ~{leaveChannelName}. Do you want to leave your current call and join the call in ~{joinChannelName}?', - }, {leaveChannelName, joinChannelName}), + joinMessage, [ { text: formatMessage({ @@ -36,12 +48,31 @@ export default function leaveAndJoinWithAlert( id: 'mobile.leave_and_join_confirmation', defaultMessage: 'Leave & Join', }), - onPress: () => joinCall(serverUrl, channelId, intl), + onPress: () => doJoinCall(serverUrl, channelId, intl), style: 'cancel', }, ], ); } else { - joinCall(serverUrl, channelId, intl); + doJoinCall(serverUrl, channelId, intl); } } + +export const doJoinCall = async (serverUrl: string, channelId: string, 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 res = await joinCall(serverUrl, channelId); + 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/connection/connection.ts b/app/products/calls/connection/connection.ts index 4404f5cff4..f91e511dbd 100644 --- a/app/products/calls/connection/connection.ts +++ b/app/products/calls/connection/connection.ts @@ -13,6 +13,7 @@ import { import {CallsConnection} from '@calls/types/calls'; import NetworkManager from '@managers/network_manager'; +import {logError} from '@utils/log'; import Peer from './simple-peer'; import WebSocketClient from './websocket_client'; @@ -36,13 +37,15 @@ export async function newConnection(serverUrl: string, channelID: string, closeC voiceTrack.enabled = false; streams.push(stream); } catch (err) { - console.log('Unable to get media device:', err); // eslint-disable-line no-console + logError('Unable to get media device:', err); } // getClient can throw an error, which will be handled by the caller. const client = NetworkManager.getClient(serverUrl); const ws = new WebSocketClient(serverUrl, client.getWebSocketUrl()); + + // Throws an error, to be caught by caller. await ws.initialize(); const disconnect = () => { @@ -78,7 +81,7 @@ export async function newConnection(serverUrl: string, channelID: string, closeC peer.replaceTrack(voiceTrack, null, stream); } } catch (e) { - console.log('Error from simple-peer:', e); //eslint-disable-line no-console + logError('From simple-peer:', e); return; } @@ -103,7 +106,7 @@ export async function newConnection(serverUrl: string, channelID: string, closeC voiceTrackAdded = true; } } catch (e) { - console.log('Error from simple-peer:', e); //eslint-disable-line no-console + logError('From simple-peer:', e); return; } @@ -126,7 +129,7 @@ export async function newConnection(serverUrl: string, channelID: string, closeC }; ws.on('error', (err: Event) => { - console.log('WS (CALLS) ERROR', err); // eslint-disable-line no-console + logError('WS (CALLS):', err); ws.close(); }); @@ -140,7 +143,7 @@ export async function newConnection(serverUrl: string, channelID: string, closeC try { config = await client.getCallsConfig(); } catch (err) { - console.log('ERROR FETCHING CALLS CONFIG:', err); // eslint-disable-line no-console + logError('FETCHING CALLS CONFIG:', err); return; } @@ -167,7 +170,7 @@ export async function newConnection(serverUrl: string, channelID: string, closeC }); peer.on('error', (err: any) => { - console.log('PEER ERROR', err); // eslint-disable-line no-console + logError('FROM PEER:', err); }); }); diff --git a/app/products/calls/connection/websocket_client.ts b/app/products/calls/connection/websocket_client.ts index bac8f91299..ca25f7de45 100644 --- a/app/products/calls/connection/websocket_client.ts +++ b/app/products/calls/connection/websocket_client.ts @@ -8,6 +8,7 @@ import {encode} from '@msgpack/msgpack/dist'; import Calls from '@constants/calls'; import DatabaseManager from '@database/manager'; import {getCommonSystemValues} from '@queries/servers/system'; +import {logError} from '@utils/log'; export default class WebSocketClient extends EventEmitter { private readonly serverUrl: string; @@ -53,7 +54,7 @@ export default class WebSocketClient extends EventEmitter { try { msg = JSON.parse(data); } catch (err) { - console.log(err); // eslint-disable-line no-console + logError(err); } if (!msg || !msg.event || !msg.data) { diff --git a/app/products/calls/connection/websocket_event_handlers.ts b/app/products/calls/connection/websocket_event_handlers.ts index 48b3de96c2..3cd6f69483 100644 --- a/app/products/calls/connection/websocket_event_handlers.ts +++ b/app/products/calls/connection/websocket_event_handlers.ts @@ -3,6 +3,7 @@ import {DeviceEventEmitter} from 'react-native'; +import {fetchUsersByIds} from '@actions/remote/user'; import { callStarted, setCallScreenOff, setCallScreenOn, @@ -15,6 +16,9 @@ import {WebsocketEvents} from '@constants'; import DatabaseManager from '@database/manager'; export const handleCallUserConnected = (serverUrl: string, msg: WebSocketMessage) => { + // Load user model async (if needed). + fetchUsersByIds(serverUrl, [msg.data.userID]); + userJoinedCall(serverUrl, msg.broadcast.channel_id, msg.data.userID); }; diff --git a/app/products/calls/hooks.ts b/app/products/calls/hooks.ts new file mode 100644 index 0000000000..9bd18310c5 --- /dev/null +++ b/app/products/calls/hooks.ts @@ -0,0 +1,73 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +// 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 {useIntl} from 'react-intl'; +import {Alert} from 'react-native'; + +import {errorAlert} from '@calls/utils'; +import {Client} from '@client/rest'; +import ClientError from '@client/rest/error'; +import {useServerUrl} from '@context/server'; +import NetworkManager from '@managers/network_manager'; + +export const useTryCallsFunction = (fn: () => void) => { + const intl = useIntl(); + const serverUrl = useServerUrl(); + const [msgPostfix, setMsgPostfix] = useState(''); + const [clientError, setClientError] = useState(''); + + let client: Client | undefined; + if (!clientError) { + try { + client = NetworkManager.getClient(serverUrl); + } catch (error) { + setClientError((error as ClientError).message); + } + } + const tryFn = useCallback(async () => { + if (client && await client.getEnabled()) { + setMsgPostfix(''); + fn(); + return; + } + + if (clientError) { + errorAlert(clientError, intl); + return; + } + + const title = intl.formatMessage({ + id: 'mobile.calls_not_available_title', + defaultMessage: 'Calls is not enabled', + }); + const message = intl.formatMessage({ + id: 'mobile.calls_not_available_msg', + defaultMessage: 'Please contact your system administrator to enable the feature.', + }); + const ok = intl.formatMessage({ + id: 'mobile.calls_ok', + defaultMessage: 'OK', + }); + const notAvailable = intl.formatMessage({ + id: 'mobile.calls_not_available_option', + defaultMessage: '(Not Available)', + }); + + Alert.alert( + title, + message, + [ + { + text: ok, + style: 'cancel', + }, + ], + ); + setMsgPostfix(` ${notAvailable}`); + }, [client, fn, clientError, intl]); + + return [tryFn, msgPostfix] as [() => Promise, string]; +}; diff --git a/app/products/calls/screens/call_screen/call_screen.tsx b/app/products/calls/screens/call_screen/call_screen.tsx index d219736e3f..4fa93a09ef 100644 --- a/app/products/calls/screens/call_screen/call_screen.tsx +++ b/app/products/calls/screens/call_screen/call_screen.tsx @@ -44,6 +44,7 @@ import {makeStyleSheetFromTheme} from '@utils/theme'; import {displayUsername} from '@utils/user'; export type Props = { + componentId: string; currentCall: CurrentCall | null; participantsDict: Dictionary; teammateNameDisplay: string; @@ -244,7 +245,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ }, })); -const CallScreen = ({currentCall, participantsDict, teammateNameDisplay}: Props) => { +const CallScreen = ({componentId, currentCall, participantsDict, teammateNameDisplay}: Props) => { const intl = useIntl(); const theme = useTheme(); const insets = useSafeAreaInsets(); @@ -328,7 +329,7 @@ const CallScreen = ({currentCall, participantsDict, teammateNameDisplay}: Props) // TODO: this is a temporary solution until we have a proper cross-team thread view. // https://mattermost.atlassian.net/browse/MM-45752 - popTopScreen(); + popTopScreen(componentId); await DatabaseManager.setActiveServerDatabase(currentCall.serverUrl); await appEntry(currentCall.serverUrl, Date.now()); goToScreen(Screens.THREAD, '', {rootId: currentCall.threadId}); @@ -357,6 +358,9 @@ const CallScreen = ({currentCall, participantsDict, teammateNameDisplay}: Props) }, [insets, intl, theme]); if (!currentCall || !myParticipant) { + // This should not be possible, but may happen until https://github.com/mattermost/mattermost-mobile/pull/6493 is merged. + // TODO: will figure out a way to remove the need for this check: https://mattermost.atlassian.net/browse/MM-46050 + popTopScreen(componentId); return null; } diff --git a/app/products/calls/screens/call_screen/index.ts b/app/products/calls/screens/call_screen/index.ts index 22d23d5edf..efcbf5dce7 100644 --- a/app/products/calls/screens/call_screen/index.ts +++ b/app/products/calls/screens/call_screen/index.ts @@ -3,24 +3,29 @@ import withObservables from '@nozbe/with-observables'; import {combineLatest, of as of$} from 'rxjs'; -import {switchMap} from 'rxjs/operators'; +import {distinctUntilChanged, switchMap} from 'rxjs/operators'; import CallScreen from '@calls/screens/call_screen/call_screen'; import {observeCurrentCall} from '@calls/state'; import {CallParticipant} from '@calls/types/calls'; import DatabaseManager from '@database/manager'; -import {observeTeammateNameDisplay, observeUsersById} from '@queries/servers/user'; +import {observeTeammateNameDisplay, queryUsersById} from '@queries/servers/user'; + +import type UserModel from '@typings/database/models/servers/user'; const enhanced = withObservables([], () => { const currentCall = observeCurrentCall(); const database = currentCall.pipe( switchMap((call) => of$(call ? call.serverUrl : '')), + distinctUntilChanged(), switchMap((url) => of$(DatabaseManager.serverDatabases[url]?.database)), ); + + // TODO: to be optimized const participantsDict = combineLatest([database, currentCall]).pipe( - switchMap(([db, call]) => (db && call ? observeUsersById(db, Object.keys(call.participants)) : of$([])).pipe( + switchMap(([db, call]) => (db && call ? queryUsersById(db, Object.keys(call.participants)).observeWithColumns(['nickname', 'username', 'first_name', 'last_name', 'last_picture_update']) : of$([])).pipe( // eslint-disable-next-line max-nested-callbacks - switchMap((ps) => of$(ps.reduce((accum, cur) => { + switchMap((ps: UserModel[]) => of$(ps.reduce((accum, cur) => { accum[cur.id] = { ...call!.participants[cur.id], userModel: cur, @@ -31,6 +36,7 @@ const enhanced = withObservables([], () => { ); const teammateNameDisplay = database.pipe( switchMap((db) => (db ? observeTeammateNameDisplay(db) : of$(''))), + distinctUntilChanged(), ); return { diff --git a/app/products/calls/state/actions.test.ts b/app/products/calls/state/actions.test.ts index c2cf475889..e5e5b4caf0 100644 --- a/app/products/calls/state/actions.test.ts +++ b/app/products/calls/state/actions.test.ts @@ -5,6 +5,15 @@ import assert from 'assert'; import {act, renderHook} from '@testing-library/react-hooks'; +import { + setCallsState, + setChannelsWithCalls, + setCurrentCall, + useCallsConfig, + useCallsState, + useChannelsWithCalls, + useCurrentCall, +} from '@calls/state'; import { setCalls, userJoinedCall, @@ -19,20 +28,13 @@ import { myselfLeftCall, setChannelEnabled, setScreenShareURL, - setSpeakerPhone, setConfig, setPluginEnabled, + setSpeakerPhone, + setConfig, + setPluginEnabled, } from '@calls/state/actions'; -import {exportedForInternalUse as callsConfigTesting} from '@calls/state/calls_config'; -import {exportedForInternalUse as callsStateTesting} from '@calls/state/calls_state'; -import {exportedForInternalUse as channelsWithCallsTesting} from '@calls/state/channels_with_calls'; -import {exportedForInternalUse as currentCallTesting} from '@calls/state/current_call'; import {CallsState, CurrentCall, DefaultCallsConfig, DefaultCallsState} from '../types/calls'; -const {useCallsConfig} = callsConfigTesting; -const {setCallsState, useCallsState} = callsStateTesting; -const {setChannelsWithCalls, useChannelsWithCalls} = channelsWithCallsTesting; -const {setCurrentCall, useCurrentCall} = currentCallTesting; - const call1 = { participants: { 'user-1': {id: 'user-1', muted: false, raisedHand: 0}, diff --git a/app/products/calls/state/actions.ts b/app/products/calls/state/actions.ts index 22706a81c7..84c05bb371 100644 --- a/app/products/calls/state/actions.ts +++ b/app/products/calls/state/actions.ts @@ -1,16 +1,17 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {getCallsConfig, exportedForInternalUse as callsConfigInternal} from '@calls/state/calls_config'; -import {getCallsState, exportedForInternalUse as callsStateInternal} from '@calls/state/calls_state'; -import {getChannelsWithCalls, exportedForInternalUse as channelsWithCallsInternal} from '@calls/state/channels_with_calls'; -import {getCurrentCall, exportedForInternalUse as currentCallInternal} from '@calls/state/current_call'; -import {Call, ChannelsWithCalls, ServerConfig} from '@calls/types/calls'; - -const {setCallsConfig} = callsConfigInternal; -const {setCallsState} = callsStateInternal; -const {setChannelsWithCalls} = channelsWithCallsInternal; -const {setCurrentCall} = currentCallInternal; +import { + getCallsConfig, + getCallsState, + getChannelsWithCalls, + getCurrentCall, + setCallsConfig, + setCallsState, + setChannelsWithCalls, + setCurrentCall, +} from '@calls/state'; +import {Call, ChannelsWithCalls, ServerCallsConfig} from '@calls/types/calls'; export const setCalls = (serverUrl: string, myUserId: string, calls: Dictionary, enabled: Dictionary) => { const channelsWithCalls = Object.keys(calls).reduce( @@ -96,10 +97,14 @@ export const userLeftCall = (serverUrl: string, channelId: string, userId: strin export const myselfJoinedCall = (serverUrl: string, channelId: string) => { const callsState = getCallsState(serverUrl); + + const participants = callsState.calls[channelId]?.participants || {}; setCurrentCall({ ...callsState.calls[channelId], serverUrl, myUserId: callsState.myUserId, + participants, + channelId, screenShareURL: '', speakerphoneOn: false, }); @@ -117,6 +122,18 @@ export const callStarted = (serverUrl: string, call: Call) => { const nextChannelsWithCalls = {...getChannelsWithCalls(serverUrl), [call.channelId]: true}; setChannelsWithCalls(serverUrl, nextChannelsWithCalls); + + // Was it the current call? If so, we started it, and need to fill in the currentCall's details. + const currentCall = getCurrentCall(); + if (!currentCall || currentCall.channelId !== call.channelId) { + return; + } + + const nextCurrentCall = { + ...currentCall, + ...call, + }; + setCurrentCall(nextCurrentCall); }; // TODO: should be called callEnded to match the ws event. Will fix when callEnded is implemented. @@ -265,7 +282,7 @@ export const setSpeakerPhone = (speakerphoneOn: boolean) => { } }; -export const setConfig = (serverUrl: string, config: ServerConfig) => { +export const setConfig = (serverUrl: string, config: ServerCallsConfig) => { const callsConfig = getCallsConfig(serverUrl); setCallsConfig(serverUrl, {...callsConfig, ...config}); }; diff --git a/app/products/calls/state/calls_config.ts b/app/products/calls/state/calls_config.ts index baaa0d2aac..aeb9e27e9c 100644 --- a/app/products/calls/state/calls_config.ts +++ b/app/products/calls/state/calls_config.ts @@ -20,7 +20,7 @@ export const getCallsConfig = (serverUrl: string) => { return getCallsConfigSubject(serverUrl).value; }; -const setCallsConfig = (serverUrl: string, callsConfig: CallsConfig) => { +export const setCallsConfig = (serverUrl: string, callsConfig: CallsConfig) => { getCallsConfigSubject(serverUrl).next(callsConfig); }; @@ -28,7 +28,7 @@ export const observeCallsConfig = (serverUrl: string) => { return getCallsConfigSubject(serverUrl).asObservable(); }; -const useCallsConfig = (serverUrl: string) => { +export const useCallsConfig = (serverUrl: string) => { const [state, setState] = useState(DefaultCallsConfig); const callsConfigSubject = getCallsConfigSubject(serverUrl); @@ -45,8 +45,3 @@ const useCallsConfig = (serverUrl: string) => { return state; }; - -export const exportedForInternalUse = { - setCallsConfig, - useCallsConfig, -}; diff --git a/app/products/calls/state/calls_state.ts b/app/products/calls/state/calls_state.ts index ce9b832a53..d5e7e9a1ab 100644 --- a/app/products/calls/state/calls_state.ts +++ b/app/products/calls/state/calls_state.ts @@ -20,7 +20,7 @@ export const getCallsState = (serverUrl: string) => { return getCallsStateSubject(serverUrl).value; }; -const setCallsState = (serverUrl: string, state: CallsState) => { +export const setCallsState = (serverUrl: string, state: CallsState) => { getCallsStateSubject(serverUrl).next(state); }; @@ -28,7 +28,7 @@ export const observeCallsState = (serverUrl: string) => { return getCallsStateSubject(serverUrl).asObservable(); }; -const useCallsState = (serverUrl: string) => { +export const useCallsState = (serverUrl: string) => { const [state, setState] = useState(DefaultCallsState); const callsStateSubject = getCallsStateSubject(serverUrl); @@ -45,8 +45,3 @@ const useCallsState = (serverUrl: string) => { return state; }; - -export const exportedForInternalUse = { - setCallsState, - useCallsState, -}; diff --git a/app/products/calls/state/channels_with_calls.ts b/app/products/calls/state/channels_with_calls.ts index 3fe8087e3d..4980c68295 100644 --- a/app/products/calls/state/channels_with_calls.ts +++ b/app/products/calls/state/channels_with_calls.ts @@ -20,7 +20,7 @@ export const getChannelsWithCalls = (serverUrl: string) => { return getChannelsWithCallsSubject(serverUrl).value; }; -const setChannelsWithCalls = (serverUrl: string, channelsWithCalls: ChannelsWithCalls) => { +export const setChannelsWithCalls = (serverUrl: string, channelsWithCalls: ChannelsWithCalls) => { getChannelsWithCallsSubject(serverUrl).next(channelsWithCalls); }; @@ -28,7 +28,7 @@ export const observeChannelsWithCalls = (serverUrl: string) => { return getChannelsWithCallsSubject(serverUrl).asObservable(); }; -const useChannelsWithCalls = (serverUrl: string) => { +export const useChannelsWithCalls = (serverUrl: string) => { const [state, setState] = useState({}); useEffect(() => { @@ -43,8 +43,3 @@ const useChannelsWithCalls = (serverUrl: string) => { return state; }; - -export const exportedForInternalUse = { - setChannelsWithCalls, - useChannelsWithCalls, -}; diff --git a/app/products/calls/state/current_call.ts b/app/products/calls/state/current_call.ts index d2ffe8db27..b394bfa44c 100644 --- a/app/products/calls/state/current_call.ts +++ b/app/products/calls/state/current_call.ts @@ -12,7 +12,7 @@ export const getCurrentCall = () => { return currentCallSubject.value; }; -const setCurrentCall = (currentCall: CurrentCall | null) => { +export const setCurrentCall = (currentCall: CurrentCall | null) => { currentCallSubject.next(currentCall); }; @@ -20,7 +20,7 @@ export const observeCurrentCall = () => { return currentCallSubject.asObservable(); }; -const useCurrentCall = () => { +export const useCurrentCall = () => { const [state, setState] = useState(null); useEffect(() => { @@ -35,8 +35,3 @@ const useCurrentCall = () => { return state; }; - -export const exportedForInternalUse = { - setCurrentCall, - useCurrentCall, -}; diff --git a/app/products/calls/state/index.ts b/app/products/calls/state/index.ts index ceedb16824..b44d4f8eb9 100644 --- a/app/products/calls/state/index.ts +++ b/app/products/calls/state/index.ts @@ -2,7 +2,7 @@ // See LICENSE.txt for license information. export * from './actions'; -export {getCallsState, observeCallsState} from './calls_state'; -export {getCallsConfig, observeCallsConfig} from './calls_config'; -export {getCurrentCall, observeCurrentCall} from './current_call'; -export {observeChannelsWithCalls} from './channels_with_calls'; +export * from './calls_state'; +export * from './calls_config'; +export * from './current_call'; +export * from './channels_with_calls'; diff --git a/app/products/calls/types/calls.ts b/app/products/calls/types/calls.ts index a2bf116b71..958a91d9f8 100644 --- a/app/products/calls/types/calls.ts +++ b/app/products/calls/types/calls.ts @@ -18,7 +18,7 @@ export const DefaultCallsState = { } as CallsState; export type Call = { - participants: Dictionary; + participants: Dictionary; channelId: string; startTime: number; screenOn: string; @@ -88,11 +88,10 @@ export type CallsConnection = { unraiseHand: () => void; } -export type ServerConfig = { +export type ServerCallsConfig = { ICEServers: string[]; AllowEnableCalls: boolean; DefaultEnabled: boolean; - last_retrieved_at: number; } export type CallsConfig = { diff --git a/app/products/calls/utils.ts b/app/products/calls/utils.ts index 159aa0e580..bceb223372 100644 --- a/app/products/calls/utils.ts +++ b/app/products/calls/utils.ts @@ -1,6 +1,9 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import {IntlShape} from 'react-intl'; +import {Alert} from 'react-native'; + import {CallParticipant} from '@calls/types/calls'; import {Post} from '@constants'; import Calls from '@constants/calls'; @@ -68,3 +71,36 @@ export function isSupportedServerCalls(serverVersion?: string) { export function isCallsCustomMessage(post: PostModel | Post): boolean { return Boolean(post.type && post.type?.startsWith(Post.POST_TYPES.CUSTOM_CALLS)); } + +export function idsAreEqual(a: string[], b: string[]) { + if (a.length !== b.length) { + return false; + } + + // We can assume ids are unique + // Doing a quick search indicated objects are tuned better than Map or Set + const obj = a.reduce((prev, cur) => { + prev[cur] = true; + return prev; + }, {} as Record); + + for (let i = 0; i < b.length; i++) { + if (!obj.hasOwnProperty(b[i])) { + return false; + } + } + return true; +} + +export function errorAlert(error: string, intl: IntlShape) { + Alert.alert( + intl.formatMessage({ + id: 'mobile.calls_error_title', + defaultMessage: 'Error', + }), + intl.formatMessage({ + id: 'mobile.calls_error_message', + defaultMessage: 'Error: {error}', + }, {error}), + ); +} diff --git a/app/queries/servers/user.ts b/app/queries/servers/user.ts index 4ef84e5057..c478c615a0 100644 --- a/app/queries/servers/user.ts +++ b/app/queries/servers/user.ts @@ -56,10 +56,6 @@ export const queryUsersById = (database: Database, userIds: string[]) => { return database.get(USER).query(Q.where('id', Q.oneOf(userIds))); }; -export const observeUsersById = (database: Database, userIds: string[]) => { - return queryUsersById(database, userIds).observe(); -}; - export const queryUsersByUsername = (database: Database, usernames: string[]) => { return database.get(USER).query(Q.where('username', Q.oneOf(usernames))); }; @@ -112,8 +108,8 @@ export const observeUserIsTeamAdmin = (database: Database, userId: string, teamI ); }; -export const observeUserIsChannelAdmin = (database: Database, userId: string, teamId: string) => { - const id = `${teamId}-${userId}`; +export const observeUserIsChannelAdmin = (database: Database, userId: string, channelId: string) => { + const id = `${channelId}-${userId}`; return database.get(CHANNEL_MEMBERSHIP).query( Q.where('id', Q.eq(id)), ).observe().pipe( diff --git a/app/screens/channel/channel.tsx b/app/screens/channel/channel.tsx index b7a2a6b2d7..c5556bed02 100644 --- a/app/screens/channel/channel.tsx +++ b/app/screens/channel/channel.tsx @@ -30,7 +30,9 @@ type ChannelProps = { componentId?: string; isCallsPluginEnabled: boolean; isCallInCurrentChannel: boolean; - isInCall: boolean; + isInACall: boolean; + isInCurrentChannelCall: boolean; + isCallsEnabledInChannel: boolean; }; const edges: Edge[] = ['left', 'right']; @@ -41,7 +43,16 @@ const styles = StyleSheet.create({ }, }); -const Channel = ({serverUrl, channelId, componentId, isCallsPluginEnabled, isCallInCurrentChannel, isInCall}: ChannelProps) => { +const Channel = ({ + serverUrl, + channelId, + componentId, + isCallsPluginEnabled, + isCallInCurrentChannel, + isInACall, + isInCurrentChannelCall, + isCallsEnabledInChannel, +}: ChannelProps) => { const appState = useAppState(); const isTablet = useIsTablet(); const insets = useSafeAreaInsets(); @@ -103,16 +114,17 @@ const Channel = ({serverUrl, channelId, componentId, isCallsPluginEnabled, isCal }, [channelId]); let callsComponents: JSX.Element | null = null; - if (isCallsPluginEnabled && (isCallInCurrentChannel || isInCall)) { + const showJoinCallBanner = isCallInCurrentChannel && !isInCurrentChannelCall; + if (isCallsPluginEnabled && (showJoinCallBanner || isInACall)) { callsComponents = ( - {isCallInCurrentChannel && + {showJoinCallBanner && } - {isInCall && } + {isInACall && } ); } @@ -128,6 +140,7 @@ const Channel = ({serverUrl, channelId, componentId, isCallsPluginEnabled, isCal {shouldRender && <> @@ -136,8 +149,8 @@ const Channel = ({serverUrl, channelId, componentId, isCallsPluginEnabled, isCal channelId={channelId} forceQueryAfterAppState={appState} nativeID={channelId} - currentCallBarVisible={isInCall} - joinCallBannerVisible={isCallInCurrentChannel && !isInCall} + currentCallBarVisible={isInACall} + joinCallBannerVisible={showJoinCallBanner} /> ({ @@ -63,7 +65,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ const ChannelHeader = ({ channelId, channelType, componentId, customStatus, displayName, isCustomStatusExpired, isOwnDirectMessage, memberCount, - searchTerm, teamId, + searchTerm, teamId, callsEnabled, }: ChannelProps) => { const intl = useIntl(); const isTablet = useIsTablet(); @@ -124,20 +126,26 @@ const ChannelHeader = ({ return; } + // When calls is enabled, we need space to move the "Copy Link" from a button to an option + const height = QUICK_OPTIONS_HEIGHT + (callsEnabled ? ITEM_HEIGHT : 0); + const renderContent = () => { return ( - + ); }; bottomSheet({ title: '', renderContent, - snapPoints: [QUICK_OPTIONS_HEIGHT, 10], + snapPoints: [height, 10], theme, closeButtonId: 'close-channel-quick-actions', }); - }, [channelId, channelType, isTablet, onTitlePress, theme]); + }, [channelId, channelType, isTablet, onTitlePress, theme, callsEnabled]); const rightButtons: HeaderRightButton[] = useMemo(() => ([ diff --git a/app/screens/channel/header/quick_actions/index.tsx b/app/screens/channel/header/quick_actions/index.tsx index 769e2ba61c..44942a79c3 100644 --- a/app/screens/channel/header/quick_actions/index.tsx +++ b/app/screens/channel/header/quick_actions/index.tsx @@ -5,14 +5,17 @@ import React from 'react'; import {View} from 'react-native'; import ChannelActions from '@components/channel_actions'; +import CopyChannelLinkOption from '@components/channel_actions/copy_channel_link_option'; import InfoBox from '@components/channel_actions/info_box'; import LeaveChannelLabel from '@components/channel_actions/leave_channel_label'; import {QUICK_OPTIONS_HEIGHT} from '@constants/view'; import {useTheme} from '@context/theme'; +import {dismissBottomSheet} from '@screens/navigation'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; type Props = { channelId: string; + callsEnabled: boolean; } const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ @@ -32,7 +35,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ }, })); -const ChannelQuickAction = ({channelId}: Props) => { +const ChannelQuickAction = ({channelId, callsEnabled}: Props) => { const theme = useTheme(); const styles = getStyleSheet(theme); @@ -41,6 +44,8 @@ const ChannelQuickAction = ({channelId}: Props) => { @@ -49,6 +54,12 @@ const ChannelQuickAction = ({channelId}: Props) => { showAsLabel={true} testID='channel.quick_actions.channel_info.action' /> + {callsEnabled && + + } { const channelId = observeCurrentChannelId(database); const isCallsPluginEnabled = observeCallsConfig(serverUrl).pipe( switchMap((config) => of$(config.pluginEnabled)), + distinctUntilChanged(), ); const isCallInCurrentChannel = combineLatest([channelId, observeChannelsWithCalls(serverUrl)]).pipe( switchMap(([id, calls]) => of$(Boolean(calls[id]))), + distinctUntilChanged(), ); - const isInCall = observeCurrentCall().pipe( + const currentCall = observeCurrentCall(); + const ccChannelId = currentCall.pipe( + switchMap((call) => of$(call?.channelId)), + distinctUntilChanged(), + ); + const isInACall = currentCall.pipe( switchMap((call) => of$(Boolean(call))), + distinctUntilChanged(), + ); + const isInCurrentChannelCall = combineLatest([channelId, ccChannelId]).pipe( + switchMap(([id, ccId]) => of$(id === ccId)), + distinctUntilChanged(), + ); + const callsStateEnabledDict = observeCallsState(serverUrl).pipe( + switchMap((state) => of$(state.enabled)), + distinctUntilChanged(), // Did the enabled object ref change? If so, a channel's enabled state has changed. + ); + const callsDefaultEnabled = observeCallsConfig(serverUrl).pipe( + switchMap((config) => of$(config.DefaultEnabled)), + distinctUntilChanged(), + ); + const isCallsEnabledInChannel = combineLatest([channelId, callsStateEnabledDict, callsDefaultEnabled]).pipe( + switchMap(([id, enabled, defaultEnabled]) => { + const explicitlyEnabled = enabled.hasOwnProperty(id as string) && enabled[id]; + const explicitlyDisabled = enabled.hasOwnProperty(id as string) && !enabled[id]; + return of$(explicitlyEnabled || (!explicitlyDisabled && defaultEnabled)); + }), + distinctUntilChanged(), ); return { channelId, isCallsPluginEnabled, isCallInCurrentChannel, - isInCall, + isInACall, + isInCurrentChannelCall, + isCallsEnabledInChannel, }; }); diff --git a/app/screens/channel_info/channel_info.tsx b/app/screens/channel_info/channel_info.tsx index dd7af36a89..868017e6cc 100644 --- a/app/screens/channel_info/channel_info.tsx +++ b/app/screens/channel_info/channel_info.tsx @@ -1,10 +1,11 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React from 'react'; +import React, {useCallback} from 'react'; import {ScrollView, View} from 'react-native'; import {Edge, SafeAreaView} from 'react-native-safe-area-context'; +import ChannelInfoEnableCalls from '@app/products/calls/components/channel_info_enable_calls'; import ChannelActions from '@components/channel_actions'; import {useTheme} from '@context/theme'; import useNavButtonPressed from '@hooks/navigation_button_pressed'; @@ -21,6 +22,8 @@ type Props = { closeButtonId: string; componentId: string; type?: ChannelType; + canEnableDisableCalls: boolean; + isCallsEnabledInChannel: boolean; } const edges: Edge[] = ['bottom', 'left', 'right']; @@ -40,13 +43,20 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ }, })); -const ChannelInfo = ({channelId, closeButtonId, componentId, type}: Props) => { +const ChannelInfo = ({ + channelId, + closeButtonId, + componentId, + type, + canEnableDisableCalls, + isCallsEnabledInChannel, +}: Props) => { const theme = useTheme(); const styles = getStyleSheet(theme); - const onPressed = () => { + const onPressed = useCallback(() => { dismissModal({componentId}); - }; + }, [componentId]); useNavButtonPressed(closeButtonId, componentId, onPressed, []); @@ -69,6 +79,8 @@ const ChannelInfo = ({channelId, closeButtonId, componentId, type}: Props) => { @@ -76,8 +88,18 @@ const ChannelInfo = ({channelId, closeButtonId, componentId, type}: Props) => { + {canEnableDisableCalls && + <> + + + + } { - const channel = observeChannel(database, channelId); +const enhanced = withObservables([], ({serverUrl, database}: Props) => { + const channel = observeCurrentChannel(database); const type = channel.pipe(switchMap((c) => of$(c?.type))); + const channelId = channel.pipe(switchMap((c) => of$(c?.id || ''))); + + const allowEnableCalls = observeCallsConfig(serverUrl).pipe( + switchMap((config) => of$(config.AllowEnableCalls)), + distinctUntilChanged(), + ); + const systemAdmin = observeCurrentUser(database).pipe( + switchMap((u) => (u ? of$(u.roles) : of$(''))), + switchMap((roles) => of$(isSystemAdmin(roles || ''))), + distinctUntilChanged(), + ); + const channelAdmin = combineLatest([observeCurrentUserId(database), channelId]).pipe( + switchMap(([userId, chId]) => observeUserIsChannelAdmin(database, userId, chId)), + distinctUntilChanged(), + ); + const canEnableDisableCalls = combineLatest([type, allowEnableCalls, systemAdmin, channelAdmin]).pipe( + switchMap(([t, allow, sysAdmin, chAdmin]) => { + const isDirectMessage = t === General.DM_CHANNEL; + const isGroupMessage = t === General.GM_CHANNEL; + + const isAdmin = sysAdmin || chAdmin; + let temp = Boolean(sysAdmin); + if (allow) { + temp = Boolean(isDirectMessage || isGroupMessage || isAdmin); + } + return of$(temp); + }), + ); + const callsDefaultEnabled = observeCallsConfig(serverUrl).pipe( + switchMap((config) => of$(config.DefaultEnabled)), + distinctUntilChanged(), + ); + const callsStateEnabledDict = observeCallsState(serverUrl).pipe( + switchMap((state) => of$(state.enabled)), + distinctUntilChanged(), // Did the enabled object ref change? If so, a channel's enabled state has changed. + ); + const isCallsEnabledInChannel = combineLatest([observeCurrentChannelId(database), callsStateEnabledDict, callsDefaultEnabled]).pipe( + switchMap(([id, enabled, defaultEnabled]) => { + const explicitlyEnabled = enabled.hasOwnProperty(id as string) && enabled[id]; + const explicitlyDisabled = enabled.hasOwnProperty(id as string) && !enabled[id]; + return of$(explicitlyEnabled || (!explicitlyDisabled && defaultEnabled)); + }), + distinctUntilChanged(), + ); return { type, + canEnableDisableCalls, + isCallsEnabledInChannel, }; }); -export default withDatabase(enhanced(ChannelInfo)); +export default withDatabase(withServerUrl(enhanced(ChannelInfo))); diff --git a/app/screens/channel_info/options/index.tsx b/app/screens/channel_info/options/index.tsx index 482b3d87fa..856fa7e8ee 100644 --- a/app/screens/channel_info/options/index.tsx +++ b/app/screens/channel_info/options/index.tsx @@ -3,6 +3,7 @@ import React from 'react'; +import CopyChannelLinkOption from '@components/channel_actions/copy_channel_link_option'; import {General} from '@constants'; import EditChannel from './edit_channel'; @@ -14,21 +15,25 @@ import PinnedMessages from './pinned_messages'; type Props = { channelId: string; type?: ChannelType; + callsEnabled: boolean; } -const Options = ({channelId, type}: Props) => { +const Options = ({channelId, type, callsEnabled}: Props) => { return ( <> {type !== General.DM_CHANNEL && - + } {type !== General.DM_CHANNEL && - + + } + {callsEnabled && + } {type !== General.DM_CHANNEL && type !== General.GM_CHANNEL && - + } ); diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index 97e170d6ed..6a140cd87f 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -356,6 +356,18 @@ "mobile.android.back_handler_exit": "Press back again to exit", "mobile.android.photos_permission_denied_description": "Upload photos to your server or save them to your device. Open Settings to grant {applicationName} Read and Write access to your photo library.", "mobile.android.photos_permission_denied_title": "{applicationName} would like to access your photos", + "mobile.calls_disable": "Disable Calls", + "mobile.calls_enable": "Enable Calls", + "mobile.calls_error_message": "Error: {error}", + "mobile.calls_error_title": "Error", + "mobile.calls_join_call": "Join Call", + "mobile.calls_leave_call": "Leave Call", + "mobile.calls_not_available_msg": "Please contact your system administrator to enable the feature.", + "mobile.calls_not_available_option": "(Not Available)", + "mobile.calls_not_available_title": "Calls is not enabled", + "mobile.calls_ok": "OK", + "mobile.calls_see_logs": "see server logs", + "mobile.calls_start_call": "Start Call", "mobile.camera_photo_permission_denied_description": "Take photos and upload them to your server or save them to your device. Open Settings to grant {applicationName} Read and Write access to your camera.", "mobile.camera_photo_permission_denied_title": "{applicationName} would like to access your camera", "mobile.channel_info.alertNo": "No", diff --git a/package-lock.json b/package-lock.json index d36577015c..243594fbd0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -111,7 +111,7 @@ "@babel/register": "7.17.7", "@babel/runtime": "7.18.0", "@react-native-community/eslint-config": "3.0.2", - "@testing-library/react-hooks": "8.0.0", + "@testing-library/react-hooks": "8.0.1", "@testing-library/react-native": "9.1.0", "@types/base-64": "1.0.0", "@types/commonmark": "0.27.5", @@ -6433,9 +6433,9 @@ } }, "node_modules/@testing-library/react-hooks": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@testing-library/react-hooks/-/react-hooks-8.0.0.tgz", - "integrity": "sha512-uZqcgtcUUtw7Z9N32W13qQhVAD+Xki2hxbTR461MKax8T6Jr8nsUvZB+vcBTkzY2nFvsUet434CsgF0ncW2yFw==", + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@testing-library/react-hooks/-/react-hooks-8.0.1.tgz", + "integrity": "sha512-Aqhl2IVmLt8IovEVarNDFuJDVWVvhnr9/GCU6UUnrYXwgDFF9h2L2o2P9KBni1AST5sT6riAyoukFLyjQUgD/g==", "dev": true, "dependencies": { "@babel/runtime": "^7.12.5", @@ -18303,7 +18303,7 @@ "node_modules/match-stream/node_modules/readable-stream": { "version": "1.0.34", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz", - "integrity": "sha1-Elgg40vIQtLyqq+v5MKRbuMsFXw=", + "integrity": "sha512-ok1qVCJuRkNmvebYikljxJA/UEsKwLl2nI1OmaqAu4/UE+h0wKCHok4XkL/gvi39OacXvw59RJUOFUkDib2rHg==", "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.1", @@ -21252,7 +21252,7 @@ "node_modules/pullstream/node_modules/readable-stream": { "version": "1.0.34", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz", - "integrity": "sha1-Elgg40vIQtLyqq+v5MKRbuMsFXw=", + "integrity": "sha512-ok1qVCJuRkNmvebYikljxJA/UEsKwLl2nI1OmaqAu4/UE+h0wKCHok4XkL/gvi39OacXvw59RJUOFUkDib2rHg==", "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.1", @@ -23196,7 +23196,7 @@ "node_modules/slice-stream/node_modules/readable-stream": { "version": "1.0.34", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz", - "integrity": "sha1-Elgg40vIQtLyqq+v5MKRbuMsFXw=", + "integrity": "sha512-ok1qVCJuRkNmvebYikljxJA/UEsKwLl2nI1OmaqAu4/UE+h0wKCHok4XkL/gvi39OacXvw59RJUOFUkDib2rHg==", "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.1", @@ -24972,7 +24972,7 @@ "node_modules/unzip/node_modules/readable-stream": { "version": "1.0.34", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz", - "integrity": "sha1-Elgg40vIQtLyqq+v5MKRbuMsFXw=", + "integrity": "sha512-ok1qVCJuRkNmvebYikljxJA/UEsKwLl2nI1OmaqAu4/UE+h0wKCHok4XkL/gvi39OacXvw59RJUOFUkDib2rHg==", "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.1", @@ -30937,9 +30937,9 @@ } }, "@testing-library/react-hooks": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@testing-library/react-hooks/-/react-hooks-8.0.0.tgz", - "integrity": "sha512-uZqcgtcUUtw7Z9N32W13qQhVAD+Xki2hxbTR461MKax8T6Jr8nsUvZB+vcBTkzY2nFvsUet434CsgF0ncW2yFw==", + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@testing-library/react-hooks/-/react-hooks-8.0.1.tgz", + "integrity": "sha512-Aqhl2IVmLt8IovEVarNDFuJDVWVvhnr9/GCU6UUnrYXwgDFF9h2L2o2P9KBni1AST5sT6riAyoukFLyjQUgD/g==", "dev": true, "requires": { "@babel/runtime": "^7.12.5", @@ -40151,7 +40151,7 @@ "readable-stream": { "version": "1.0.34", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz", - "integrity": "sha1-Elgg40vIQtLyqq+v5MKRbuMsFXw=", + "integrity": "sha512-ok1qVCJuRkNmvebYikljxJA/UEsKwLl2nI1OmaqAu4/UE+h0wKCHok4XkL/gvi39OacXvw59RJUOFUkDib2rHg==", "requires": { "core-util-is": "~1.0.0", "inherits": "~2.0.1", @@ -42552,7 +42552,7 @@ "readable-stream": { "version": "1.0.34", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz", - "integrity": "sha1-Elgg40vIQtLyqq+v5MKRbuMsFXw=", + "integrity": "sha512-ok1qVCJuRkNmvebYikljxJA/UEsKwLl2nI1OmaqAu4/UE+h0wKCHok4XkL/gvi39OacXvw59RJUOFUkDib2rHg==", "requires": { "core-util-is": "~1.0.0", "inherits": "~2.0.1", @@ -44067,7 +44067,7 @@ "readable-stream": { "version": "1.0.34", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz", - "integrity": "sha1-Elgg40vIQtLyqq+v5MKRbuMsFXw=", + "integrity": "sha512-ok1qVCJuRkNmvebYikljxJA/UEsKwLl2nI1OmaqAu4/UE+h0wKCHok4XkL/gvi39OacXvw59RJUOFUkDib2rHg==", "requires": { "core-util-is": "~1.0.0", "inherits": "~2.0.1", @@ -45485,7 +45485,7 @@ "readable-stream": { "version": "1.0.34", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz", - "integrity": "sha1-Elgg40vIQtLyqq+v5MKRbuMsFXw=", + "integrity": "sha512-ok1qVCJuRkNmvebYikljxJA/UEsKwLl2nI1OmaqAu4/UE+h0wKCHok4XkL/gvi39OacXvw59RJUOFUkDib2rHg==", "requires": { "core-util-is": "~1.0.0", "inherits": "~2.0.1", diff --git a/package.json b/package.json index 37a262fb94..1d54a1ec23 100644 --- a/package.json +++ b/package.json @@ -108,7 +108,7 @@ "@babel/register": "7.17.7", "@babel/runtime": "7.18.0", "@react-native-community/eslint-config": "3.0.2", - "@testing-library/react-hooks": "8.0.0", + "@testing-library/react-hooks": "8.0.1", "@testing-library/react-native": "9.1.0", "@types/base-64": "1.0.0", "@types/commonmark": "0.27.5",