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",