forked from Ivasoft/mattermost-mobile
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
This commit is contained in:
committed by
GitHub
parent
bae5477b35
commit
17dbfdcb99
@@ -402,6 +402,4 @@ export async function handleEvent(serverUrl: string, msg: WebSocketMessage) {
|
||||
handleCallUserUnraiseHand(serverUrl, msg);
|
||||
break;
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<View style={styles.wrapper}>
|
||||
<FavoriteBox
|
||||
@@ -62,13 +70,15 @@ const ChannelActions = ({channelId, channelType, inModal = false, testID}: Props
|
||||
testID={`${testID}.set_header.action`}
|
||||
/>
|
||||
}
|
||||
{channelType && !DIRECT_CHANNELS.includes(channelType) &&
|
||||
{notDM &&
|
||||
<AddPeopleBox
|
||||
channelId={channelId}
|
||||
inModal={inModal}
|
||||
testID={`${testID}.add_people.action`}
|
||||
/>
|
||||
}
|
||||
{notDM && !callsEnabled &&
|
||||
<>
|
||||
<AddPeopleBox
|
||||
channelId={channelId}
|
||||
inModal={inModal}
|
||||
testID={`${testID}.add_people.action`}
|
||||
/>
|
||||
<View style={styles.separator}/>
|
||||
<CopyChannelLinkBox
|
||||
channelId={channelId}
|
||||
@@ -77,6 +87,16 @@ const ChannelActions = ({channelId, channelType, inModal = false, testID}: Props
|
||||
/>
|
||||
</>
|
||||
}
|
||||
{callsEnabled &&
|
||||
<>
|
||||
<View style={styles.separator}/>
|
||||
<ChannelInfoStartButton
|
||||
serverUrl={serverUrl}
|
||||
channelId={channelId}
|
||||
dismissChannelInfo={dismissChannelInfo}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 (
|
||||
<SlideUpPanelItem
|
||||
onPress={onCopyLink}
|
||||
text={intl.formatMessage({id: 'channel_info.copy_link', defaultMessage: 'Copy Link'})}
|
||||
icon='link-variant'
|
||||
testID={testID}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<OptionItem
|
||||
action={onCopyLink}
|
||||
label={intl.formatMessage({id: 'channel_info.copy_link', defaultMessage: 'Copy Link'})}
|
||||
icon='link-variant'
|
||||
type='default'
|
||||
testID={testID}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default CopyChannelLinkOption;
|
||||
@@ -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));
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
]
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -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 = ({
|
||||
<CompassIcon
|
||||
name='phone-in-talk'
|
||||
size={16}
|
||||
style={styles.hasCall}
|
||||
style={[...textStyles, styles.hasCall]}
|
||||
/>
|
||||
}
|
||||
</View>
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 (
|
||||
<Pressable
|
||||
onPress={handleOnPress}
|
||||
@@ -81,16 +105,16 @@ const OptionBox = ({activeIconName, activeText, containerStyle, iconName, isActi
|
||||
{({pressed}) => (
|
||||
<>
|
||||
<CompassIcon
|
||||
color={(pressed || activated) ? theme.buttonBg : changeOpacity(theme.centerChannelColor, 0.56)}
|
||||
name={activated && activeIconName ? activeIconName : iconName}
|
||||
color={destructColor || ((pressed || activated) ? theme.buttonBg : changeOpacity(theme.centerChannelColor, 0.56))}
|
||||
name={destructIconName || (activated && activeIconName ? activeIconName : iconName)}
|
||||
size={24}
|
||||
/>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={[styles.text, {color: (pressed || activated) ? theme.buttonBg : changeOpacity(theme.centerChannelColor, 0.56)}]}
|
||||
style={[styles.text, {color: destructColor || ((pressed || activated) ? theme.buttonBg : changeOpacity(theme.centerChannelColor, 0.56))}]}
|
||||
testID={`${testID}.label`}
|
||||
>
|
||||
{activated && activeText ? activeText : text}
|
||||
{destructText || (activated && activeText ? activeText : text)}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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<string>([
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<Call> = {};
|
||||
const enabledChannels: Dictionary<boolean> = {};
|
||||
|
||||
// Batch load userModels async because we'll need them later
|
||||
const ids = new Set<string>();
|
||||
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}`};
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ export {
|
||||
loadConfig,
|
||||
loadCalls,
|
||||
enableChannelCalls,
|
||||
disableChannelCalls,
|
||||
joinCall,
|
||||
leaveCall,
|
||||
muteMyself,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<Boolean>;
|
||||
getCalls: () => Promise<ServerChannelState[]>;
|
||||
getCallsConfig: () => Promise<ServerConfig>;
|
||||
enableChannelCalls: (channelId: string) => Promise<ServerChannelState>;
|
||||
disableChannelCalls: (channelId: string) => Promise<ServerChannelState>;
|
||||
getCallsConfig: () => Promise<ServerCallsConfig>;
|
||||
enableChannelCalls: (channelId: string, enable: boolean) => Promise<ServerChannelState>;
|
||||
}
|
||||
|
||||
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}},
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<OptionItem
|
||||
action={preventDoubleTap(tryOnPress)}
|
||||
label={(enabled ? disableText : enableText) + msgPostfix}
|
||||
icon='phone-outline'
|
||||
type='default'
|
||||
testID='channel_info.options.enable_disable_calls.option'
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChannelInfoEnableCalls;
|
||||
@@ -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 (
|
||||
<OptionBox
|
||||
onPress={preventDoubleTap(tryJoin)}
|
||||
text={startText + msgPostfix}
|
||||
iconName='phone-outline'
|
||||
activeText={joinText + msgPostfix}
|
||||
activeIconName='phone-in-talk'
|
||||
isActive={isACallInCurrentChannel}
|
||||
destructiveText={leaveText}
|
||||
destructiveIconName={'phone-hangup'}
|
||||
isDestructive={alreadyInCall}
|
||||
testID='channel_info.options.join_start_call.option'
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChannelInfoStartButton;
|
||||
58
app/products/calls/components/channel_info_start/index.ts
Normal file
58
app/products/calls/components/channel_info_start/index.ts
Normal file
@@ -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));
|
||||
@@ -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<string | null>(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 = ({
|
||||
<CallAvatar
|
||||
userModel={userModelsDict[speaker || '']}
|
||||
volume={speaker ? 0.5 : 0}
|
||||
serverUrl={currentCall.serverUrl}
|
||||
serverUrl={currentCall?.serverUrl || ''}
|
||||
/>
|
||||
<View style={style.userInfo}>
|
||||
<Text style={style.speakingUser}>
|
||||
|
||||
@@ -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<UserModel>)),
|
||||
),
|
||||
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<UserModel>);
|
||||
}
|
||||
|
||||
export default enhanced(CurrentCallBar);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
73
app/products/calls/hooks.ts
Normal file
73
app/products/calls/hooks.ts
Normal file
@@ -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<void>, string];
|
||||
};
|
||||
@@ -44,6 +44,7 @@ import {makeStyleSheetFromTheme} from '@utils/theme';
|
||||
import {displayUsername} from '@utils/user';
|
||||
|
||||
export type Props = {
|
||||
componentId: string;
|
||||
currentCall: CurrentCall | null;
|
||||
participantsDict: Dictionary<CallParticipant>;
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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<Call>, enabled: Dictionary<boolean>) => {
|
||||
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});
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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<ChannelsWithCalls>({});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -43,8 +43,3 @@ const useChannelsWithCalls = (serverUrl: string) => {
|
||||
|
||||
return state;
|
||||
};
|
||||
|
||||
export const exportedForInternalUse = {
|
||||
setChannelsWithCalls,
|
||||
useChannelsWithCalls,
|
||||
};
|
||||
|
||||
@@ -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<CurrentCall | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -35,8 +35,3 @@ const useCurrentCall = () => {
|
||||
|
||||
return state;
|
||||
};
|
||||
|
||||
export const exportedForInternalUse = {
|
||||
setCurrentCall,
|
||||
useCurrentCall,
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -18,7 +18,7 @@ export const DefaultCallsState = {
|
||||
} as CallsState;
|
||||
|
||||
export type Call = {
|
||||
participants: Dictionary<CallParticipant>;
|
||||
participants: Dictionary<CallParticipant>;
|
||||
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 = {
|
||||
|
||||
@@ -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<string, boolean>);
|
||||
|
||||
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}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -56,10 +56,6 @@ export const queryUsersById = (database: Database, userIds: string[]) => {
|
||||
return database.get<UserModel>(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<UserModel>(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<ChannelMembershipModel>(CHANNEL_MEMBERSHIP).query(
|
||||
Q.where('id', Q.eq(id)),
|
||||
).observe().pipe(
|
||||
|
||||
@@ -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 = (
|
||||
<FloatingCallContainer>
|
||||
{isCallInCurrentChannel &&
|
||||
{showJoinCallBanner &&
|
||||
<JoinCallBanner
|
||||
serverUrl={serverUrl}
|
||||
channelId={channelId}
|
||||
/>
|
||||
}
|
||||
{isInCall && <CurrentCallBar/>}
|
||||
{isInACall && <CurrentCallBar/>}
|
||||
</FloatingCallContainer>
|
||||
);
|
||||
}
|
||||
@@ -128,6 +140,7 @@ const Channel = ({serverUrl, channelId, componentId, isCallsPluginEnabled, isCal
|
||||
<ChannelHeader
|
||||
channelId={channelId}
|
||||
componentId={componentId}
|
||||
callsEnabled={isCallsEnabledInChannel}
|
||||
/>
|
||||
{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}
|
||||
/>
|
||||
</View>
|
||||
<PostDraft
|
||||
|
||||
@@ -9,6 +9,7 @@ import {useSafeAreaInsets} from 'react-native-safe-area-context';
|
||||
import CompassIcon from '@components/compass_icon';
|
||||
import CustomStatusEmoji from '@components/custom_status/custom_status_emoji';
|
||||
import NavigationHeader from '@components/navigation_header';
|
||||
import {ITEM_HEIGHT} from '@components/option_item';
|
||||
import RoundedHeaderContext from '@components/rounded_header_context';
|
||||
import {General, Screens} from '@constants';
|
||||
import {QUICK_OPTIONS_HEIGHT} from '@constants/view';
|
||||
@@ -36,6 +37,7 @@ type ChannelProps = {
|
||||
memberCount?: number;
|
||||
searchTerm: string;
|
||||
teamId: string;
|
||||
callsEnabled: boolean;
|
||||
};
|
||||
|
||||
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
|
||||
@@ -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 (
|
||||
<QuickActions channelId={channelId}/>
|
||||
<QuickActions
|
||||
channelId={channelId}
|
||||
callsEnabled={callsEnabled}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
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(() => ([
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
<View style={styles.wrapper}>
|
||||
<ChannelActions
|
||||
channelId={channelId}
|
||||
dismissChannelInfo={dismissBottomSheet}
|
||||
callsEnabled={callsEnabled}
|
||||
testID='channel.quick_actions'
|
||||
/>
|
||||
</View>
|
||||
@@ -49,6 +54,12 @@ const ChannelQuickAction = ({channelId}: Props) => {
|
||||
showAsLabel={true}
|
||||
testID='channel.quick_actions.channel_info.action'
|
||||
/>
|
||||
{callsEnabled &&
|
||||
<CopyChannelLinkOption
|
||||
channelId={channelId}
|
||||
showAsLabel={true}
|
||||
/>
|
||||
}
|
||||
<View style={styles.line}/>
|
||||
<LeaveChannelLabel
|
||||
channelId={channelId}
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
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 {observeCallsConfig, observeChannelsWithCalls, observeCurrentCall} from '@calls/state';
|
||||
import {observeCallsConfig, observeCallsState, observeChannelsWithCalls, observeCurrentCall} from '@calls/state';
|
||||
import {withServerUrl} from '@context/server';
|
||||
import {observeCurrentChannelId} from '@queries/servers/system';
|
||||
|
||||
@@ -22,19 +22,49 @@ const enhanced = withObservables([], ({database, serverUrl}: EnhanceProps) => {
|
||||
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,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
<ChannelActions
|
||||
channelId={channelId}
|
||||
inModal={true}
|
||||
dismissChannelInfo={onPressed}
|
||||
callsEnabled={isCallsEnabledInChannel}
|
||||
testID='channel_info.channel_actions'
|
||||
/>
|
||||
<Extra channelId={channelId}/>
|
||||
@@ -76,8 +88,18 @@ const ChannelInfo = ({channelId, closeButtonId, componentId, type}: Props) => {
|
||||
<Options
|
||||
channelId={channelId}
|
||||
type={type}
|
||||
callsEnabled={isCallsEnabledInChannel}
|
||||
/>
|
||||
<View style={styles.separator}/>
|
||||
{canEnableDisableCalls &&
|
||||
<>
|
||||
<ChannelInfoEnableCalls
|
||||
channelId={channelId}
|
||||
enabled={isCallsEnabledInChannel}
|
||||
/>
|
||||
<View style={styles.separator}/>
|
||||
</>
|
||||
}
|
||||
<DestructiveOptions
|
||||
channelId={channelId}
|
||||
componentId={componentId}
|
||||
|
||||
@@ -3,26 +3,78 @@
|
||||
|
||||
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
|
||||
import withObservables from '@nozbe/with-observables';
|
||||
import {of as of$} from 'rxjs';
|
||||
import {switchMap} from 'rxjs/operators';
|
||||
import {combineLatest, of as of$} from 'rxjs';
|
||||
import {distinctUntilChanged, switchMap} from 'rxjs/operators';
|
||||
|
||||
import {observeChannel} from '@queries/servers/channel';
|
||||
import {observeCallsConfig, observeCallsState} from '@calls/state';
|
||||
import {General} from '@constants';
|
||||
import {withServerUrl} from '@context/server';
|
||||
import {observeCurrentChannel} from '@queries/servers/channel';
|
||||
import {observeCurrentChannelId, observeCurrentUserId} from '@queries/servers/system';
|
||||
import {observeCurrentUser, observeUserIsChannelAdmin} from '@queries/servers/user';
|
||||
import {isSystemAdmin} from '@utils/user';
|
||||
|
||||
import ChannelInfo from './channel_info';
|
||||
|
||||
import type {WithDatabaseArgs} from '@typings/database/database';
|
||||
|
||||
type Props = WithDatabaseArgs & {
|
||||
channelId: string;
|
||||
serverUrl: string;
|
||||
}
|
||||
|
||||
const enhanced = withObservables(['channelId'], ({channelId, database}: Props) => {
|
||||
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)));
|
||||
|
||||
@@ -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 &&
|
||||
<IgnoreMentions channelId={channelId}/>
|
||||
<IgnoreMentions channelId={channelId}/>
|
||||
}
|
||||
<NotificationPreference channelId={channelId}/>
|
||||
<PinnedMessages channelId={channelId}/>
|
||||
{type !== General.DM_CHANNEL &&
|
||||
<Members channelId={channelId}/>
|
||||
<Members channelId={channelId}/>
|
||||
}
|
||||
{callsEnabled &&
|
||||
<CopyChannelLinkOption channelId={channelId}/>
|
||||
}
|
||||
{type !== General.DM_CHANNEL && type !== General.GM_CHANNEL &&
|
||||
<EditChannel channelId={channelId}/>
|
||||
<EditChannel channelId={channelId}/>
|
||||
}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
|
||||
30
package-lock.json
generated
30
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user