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:
Christopher Poile
2022-07-29 11:10:32 -04:00
committed by GitHub
parent bae5477b35
commit 17dbfdcb99
50 changed files with 937 additions and 317 deletions

View File

@@ -402,6 +402,4 @@ export async function handleEvent(serverUrl: string, msg: WebSocketMessage) {
handleCallUserUnraiseHand(serverUrl, msg);
break;
}
return {};
}

View File

@@ -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>
);
};

View File

@@ -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;

View File

@@ -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));

View File

@@ -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>

View File

@@ -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>

View File

@@ -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(),

View File

@@ -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>
</>
)}

View File

@@ -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>([

View File

@@ -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);
});
});

View File

@@ -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}`};
}
};

View File

@@ -5,7 +5,6 @@ export {
loadConfig,
loadCalls,
enableChannelCalls,
disableChannelCalls,
joinCall,
leaveCall,
muteMyself,

View File

@@ -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) {

View File

@@ -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}},
);
};
};

View File

@@ -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) {

View File

@@ -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,
};
});

View File

@@ -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;

View File

@@ -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;

View 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));

View File

@@ -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}>

View File

@@ -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);

View File

@@ -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,
};
});

View File

@@ -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 (

View File

@@ -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);
}
};

View File

@@ -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);
});
});

View File

@@ -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) {

View File

@@ -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);
};

View 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];
};

View File

@@ -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;
}

View File

@@ -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 {

View File

@@ -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},

View File

@@ -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});
};

View File

@@ -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,
};

View File

@@ -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,
};

View File

@@ -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,
};

View File

@@ -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,
};

View File

@@ -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';

View File

@@ -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 = {

View File

@@ -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}),
);
}

View File

@@ -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(

View File

@@ -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

View File

@@ -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(() => ([

View File

@@ -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}

View File

@@ -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,
};
});

View File

@@ -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}

View File

@@ -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)));

View File

@@ -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}/>
}
</>
);

View File

@@ -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
View File

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

View File

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