Voicechannels (#5753)

* Some extra work on voice channels interface

* Fixing some TODOs

* Improving styling of call in channel

* Improve calls monitoring

* Replacing some of the fontawesome icons with the compass ones

* Improving the layout

* Migrating to webrtc2 for unified plan

* Add screen on and off behavior

* Adding incall manager plugin

* Moving everything into the products/calls folder

* Make products modules routes relatives

* Make products modules routes @mmproducts

* Removing initiator parameter

* Removing trickle parameter

* Simplifying code

* Removing underscore from private variables

* Removing underscore from private things

* More simplifications

* More simplifications

* More simplifications

* Changing sha sum for mmjstool

* Fixing typo

* Migrating simple-peer to typescript

* Migrating simple-peer to typescript

* Improving the size of the screen share

* Adding feature flag to disable the calls feature in mobile

* Fixing some tests

* Removing obsolte tests

* Added call ended support for the post messages

* Fixing some warnings in the tests

* Adding JoinCall tests

* Adding CallMessage tests

* Adding CurrentCall unit tests

* Adding CallAvatar unit tests

* Adding FloatingCallContainer unit tests

* Adding StartCall unit tests

* Adding EnableDisableCalls unit tests

* Adding CallDuration tests

* Improving CallDuration tests

* Adding CallScreen unit tests

* Adding CallOtherActions screen tests

* Fixing some dark theme styles

* Fixing tests

* More robustness around connecting/disconnecting

* Adding FormattedRelativeTime tests

* Adding tests for ChannelItem

* Adding tests for ChannelInfo

* Adding selectors tests

* Adding reducers unit tests

* Adding actions tests

* Removing most of the TODOs

* Removing another TODO

* Updating tests snapshots

* Removing the last TODO

* Fixed a small problem on pressing while a call is ongoing

* Remove all the inlined functions

* Replacing usage of isLandscape selector with useWindowDimensions

* Removed unnecesary makeStyleSheetFromTheme

* Removing unneded  properties from call_duration

* Fixing possible null channels return from getChannel selector

* Moving other inlined functions to its own constant

* Simplifiying enable/disable calls component

* Improving the behavior when you are in the call of the current channel

* Adding missing translation strings

* Simplified a bit the EnableDisableCalls component

* Moving other inlined functions to its own constant

* Updating snapshots

* Improving usage of makeStyleSheetFromTheme

* Moving data reformating from the rest client to the redux action

* Adding calls to the blocklist to the redux-persist

* Fixing tests

* Updating snapshots

* Update file icon name to the last compass icons version

* Fix loading state

* Only show the call connected if the websocket gets connected

* Taking into consideration the indicator bar to position the calls new bars

* Making the MoreMessagesButton component aware of calls components

* Updating snapshots

* Fixing tests

* Updating snapshot

* Fixing different use cases for start call channel menu

* Fixing tests

* Ask for confirmation to start a call when you are already in another call

* Update app/products/calls/components/floating_call_container.tsx

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>

* Memoizing userIds in join call

* Applying suggestion around combine the blocklist for calls with the one for typing

* Adding explicit types to the rest client

* Removing unneeded permission

* Making updateIntervalInSeconds prop optional in FormattedRelativeTime

* Making updateIntervalInSeconds prop optional in CallDuration

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
This commit is contained in:
Jesús Espino 2021-11-11 11:32:39 +01:00
parent eb8579e84c
commit 49462bd4a0
103 changed files with 17461 additions and 3082 deletions

View file

@ -9,6 +9,10 @@
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<application
android:name=".MainApplication"

View file

@ -11,7 +11,7 @@ import {getThreads} from '@mm-redux/actions/threads';
import {getProfilesByIds, getStatusesByIds} from '@mm-redux/actions/users';
import {General} from '@mm-redux/constants';
import {getCurrentChannelId, getCurrentChannelStats} from '@mm-redux/selectors/entities/channels';
import {getConfig} from '@mm-redux/selectors/entities/general';
import {getConfig, getFeatureFlagValue} from '@mm-redux/selectors/entities/general';
import {getPostIdsInChannel} from '@mm-redux/selectors/entities/posts';
import {isCollapsedThreadsEnabled} from '@mm-redux/selectors/entities/preferences';
import {getCurrentTeamId} from '@mm-redux/selectors/entities/teams';
@ -23,6 +23,20 @@ import {TeamMembership} from '@mm-redux/types/teams';
import {WebSocketMessage} from '@mm-redux/types/websocket';
import EventEmitter from '@mm-redux/utils/event_emitter';
import {removeUserFromList} from '@mm-redux/utils/user_utils';
import {loadCalls} from '@mmproducts/calls/store/actions/calls';
import {
handleCallStarted,
handleCallUserConnected,
handleCallUserDisconnected,
handleCallUserMuted,
handleCallUserUnmuted,
handleCallUserVoiceOn,
handleCallUserVoiceOff,
handleCallChannelEnabled,
handleCallChannelDisabled,
handleCallScreenOn,
handleCallScreenOff,
} from '@mmproducts/calls/store/actions/websockets';
import {getChannelSinceValue} from '@utils/channels';
import websocketClient from '@websocket';
@ -147,6 +161,10 @@ export function doReconnect(now: number) {
const {data: me}: any = await dispatch(loadMe(null, null, true));
if (!me.error) {
if (getFeatureFlagValue(getState(), 'CallsMobile') === 'true') {
dispatch(loadCalls());
}
const roles = [];
if (me.roles?.length) {
@ -325,7 +343,7 @@ function handleClose(connectFailCount: number) {
}
function handleEvent(msg: WebSocketMessage) {
return (dispatch: DispatchFunc) => {
return (dispatch: DispatchFunc, getState: GetStateFunc) => {
switch (msg.event) {
case WebsocketEvents.POSTED:
case WebsocketEvents.EPHEMERAL_MESSAGE:
@ -421,6 +439,33 @@ function handleEvent(msg: WebSocketMessage) {
return dispatch(handleSidebarCategoryOrderUpdated(msg));
}
if (getFeatureFlagValue(getState(), 'CallsMobile') === 'true') {
switch (msg.event) {
case WebsocketEvents.CALLS_CHANNEL_ENABLED:
return dispatch(handleCallChannelEnabled(msg));
case WebsocketEvents.CALLS_CHANNEL_DISABLED:
return dispatch(handleCallChannelDisabled(msg));
case WebsocketEvents.CALLS_USER_CONNECTED:
return dispatch(handleCallUserConnected(msg));
case WebsocketEvents.CALLS_USER_DISCONNECTED:
return dispatch(handleCallUserDisconnected(msg));
case WebsocketEvents.CALLS_USER_MUTED:
return dispatch(handleCallUserMuted(msg));
case WebsocketEvents.CALLS_USER_UNMUTED:
return dispatch(handleCallUserUnmuted(msg));
case WebsocketEvents.CALLS_USER_VOICE_ON:
return dispatch(handleCallUserVoiceOn(msg));
case WebsocketEvents.CALLS_USER_VOICE_OFF:
return dispatch(handleCallUserVoiceOff(msg));
case WebsocketEvents.CALLS_CALL_START:
return dispatch(handleCallStarted(msg));
case WebsocketEvents.CALLS_SCREEN_ON:
return dispatch(handleCallScreenOn(msg));
case WebsocketEvents.CALLS_SCREEN_OFF:
return dispatch(handleCallScreenOff(msg));
}
}
return {data: true};
};
}

View file

@ -290,6 +290,10 @@ export default class ClientBase {
return `${this.url}/plugins/com.mattermost.apps`;
}
getCallsRoute() {
return `${this.url}/plugins/com.mattermost.calls`;
}
// Client Helpers
handleRedirectProtocol = (url: string, response: RNFetchBlobFetchRepsonse) => {
const serverUrl = this.getUrl();

View file

@ -1,6 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import ClientCalls, {ClientCallsMix} from '@mmproducts/calls/client/rest';
import mix from '@utils/mix';
import ClientApps, {ClientAppsMix} from './apps';
@ -34,7 +35,8 @@ interface Client extends ClientBase,
ClientSharedChannelsMix,
ClientTeamsMix,
ClientTosMix,
ClientUsersMix
ClientUsersMix,
ClientCallsMix
{}
class Client extends mix(ClientBase).with(
@ -52,6 +54,7 @@ class Client extends mix(ClientBase).with(
ClientTeams,
ClientTos,
ClientUsers,
ClientCalls,
) {}
const Client4 = new Client();

View file

@ -0,0 +1,7 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`FormattedRelativeTime should match snapshot 1`] = `
<Text>
a few seconds ago
</Text>
`;

View file

@ -86,6 +86,7 @@ export interface AvatarsProps {
breakAt?: number;
style?: StyleProp<ViewStyle>;
theme: Theme;
listTitle?: JSX.Element;
}
export default class Avatars extends PureComponent<AvatarsProps> {
@ -94,11 +95,12 @@ export default class Avatars extends PureComponent<AvatarsProps> {
};
showParticipantsList = () => {
const {userIds} = this.props;
const {userIds, listTitle} = this.props;
const screen = 'ParticipantsList';
const passProps = {
userIds,
listTitle,
};
showModalOverCurrentContext(screen, passProps);

View file

@ -0,0 +1,62 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {shallow} from 'enzyme';
import moment from 'moment';
import React from 'react';
import FormattedRelativeTime from './formatted_relative_time';
jest.mock('react', () => ({
...jest.requireActual('react'),
useEffect: (f) => f(),
}));
describe('FormattedRelativeTime', () => {
const baseProps = {
value: moment.now() - 15000,
updateIntervalInSeconds: 10000,
};
test('should match snapshot', () => {
const wrapper = shallow(<FormattedRelativeTime {...baseProps}/>);
expect(wrapper.getElement()).toMatchSnapshot();
});
test('should match string in the past', () => {
const props = {...baseProps, value: moment.now() - ((10 * 60 * 60 * 1000) + (30 * 60 * 1000) + (25 * 1000) + 500)};
const wrapper = shallow(<FormattedRelativeTime {...props}/>);
expect(wrapper.getElement().props.children).toBe('11 hours ago');
});
test('should match string in the future', () => {
const props = {...baseProps, value: moment.now() + 15500};
const wrapper = shallow(<FormattedRelativeTime {...props}/>);
expect(wrapper.getElement().props.children).toBe('in a few seconds');
});
test('should re-render after updateIntervalInSeconds', () => {
jest.useFakeTimers();
const props = {...baseProps, value: moment.now(), updateIntervalInSeconds: 120};
const wrapper = shallow(<FormattedRelativeTime {...props}/>);
expect(wrapper.getElement().props.children).toBe('a few seconds ago');
jest.advanceTimersByTime(60000);
expect(wrapper.getElement().props.children).toBe('a few seconds ago');
jest.advanceTimersByTime(120000);
expect(wrapper.getElement().props.children).toBe('2 minutes ago');
jest.useRealTimers();
});
test('should not re-render if updateIntervalInSeconds is not passed', () => {
jest.useFakeTimers();
const props = {value: baseProps.value};
const wrapper = shallow(<FormattedRelativeTime {...props}/>);
expect(wrapper.getElement().props.children).toBe('a few seconds ago');
jest.advanceTimersByTime(120000000000);
expect(wrapper.getElement().props.children).toBe('a few seconds ago');
jest.useRealTimers();
});
});

View file

@ -0,0 +1,44 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import moment from 'moment-timezone';
import React, {useEffect, useState} from 'react';
import {Text, TextProps} from 'react-native';
import type {UserTimezone} from '@mm-redux/types/users';
type FormattedRelativeTimeProps = TextProps & {
timezone?: UserTimezone | string;
value: number | string | Date;
updateIntervalInSeconds?: number;
}
const FormattedRelativeTime = ({timezone, value, updateIntervalInSeconds, ...props}: FormattedRelativeTimeProps) => {
const getFormattedRelativeTime = () => {
let zone = timezone;
if (typeof timezone === 'object') {
zone = timezone.useAutomaticTimezone ? timezone.automaticTimezone : timezone.manualTimezone;
}
return timezone ? moment.tz(value, zone as string).fromNow() : moment(value).fromNow();
};
const [formattedTime, setFormattedTime] = useState(getFormattedRelativeTime());
useEffect(() => {
if (updateIntervalInSeconds) {
const interval = setInterval(() => setFormattedTime(getFormattedRelativeTime()), updateIntervalInSeconds * 1000);
return () => {
clearInterval(interval);
};
}
return () => null;
}, [updateIntervalInSeconds]);
return (
<Text {...props}>
{formattedTime}
</Text>
);
};
export default FormattedRelativeTime;

View file

@ -513,7 +513,7 @@ exports[`PostDraft Should render the DraftInput 1`] = `
>
<Icon
color="rgba(63,67,80,0.64)"
name="file-document-outline"
name="file-text-outline"
size={24}
/>
</View>

View file

@ -30,7 +30,7 @@ exports[`FileQuickAction should match snapshot 1`] = `
>
<Icon
color="rgba(63,67,80,0.64)"
name="file-document-outline"
name="file-text-outline"
size={24}
/>
</View>

View file

@ -83,7 +83,7 @@ const FileQuickAction = ({disabled, fileCount = 0, intl, maxFileCount, onUploadF
>
<CompassIcon
color={color}
name='file-document-outline'
name='file-text-outline'
size={ICON_SIZE}
/>
</TouchableWithFeedback>

View file

@ -18,7 +18,7 @@ exports[`MoreMessagesButton should match snapshot 1`] = `
Object {
"transform": Array [
Object {
"translateY": -438,
"translateY": -550,
},
],
},

View file

@ -13,7 +13,7 @@ import {shallowWithIntl} from '@test/intl-test-helper';
import MoreMessagesButton, {
MIN_INPUT,
MAX_INPUT,
INDICATOR_BAR_FACTOR,
BARS_FACTOR,
CANCEL_TIMER_DELAY,
} from './more_messages_button';
@ -296,17 +296,19 @@ describe('MoreMessagesButton', () => {
expect(Animated.spring).not.toHaveBeenCalledTimes(1);
});
it('should animate to MAX_INPUT - INDICATOR_BAR_FACTOR if visible and indicator bar hides', () => {
it('should animate to MAX_INPUT - BARS_FACTOR if visible and indicator bar hides', () => {
instance.buttonVisible = true;
instance.onIndicatorBarVisible(false);
expect(Animated.spring).toHaveBeenCalledWith(instance.top, {
toValue: MAX_INPUT - INDICATOR_BAR_FACTOR,
toValue: MAX_INPUT - BARS_FACTOR,
useNativeDriver: true,
});
});
it('should animate to MAX_INPUT if visible and indicator becomes visible', () => {
instance.buttonVisible = true;
instance.joinCallBarVisible = true;
instance.currentCallBarVisible = true;
instance.onIndicatorBarVisible(true);
expect(Animated.spring).toHaveBeenCalledWith(instance.top, {
toValue: MAX_INPUT,
@ -414,13 +416,15 @@ describe('MoreMessagesButton', () => {
instance.show();
expect(instance.buttonVisible).toBe(true);
expect(Animated.spring).toHaveBeenCalledWith(instance.top, {
toValue: MAX_INPUT - INDICATOR_BAR_FACTOR,
toValue: MAX_INPUT - BARS_FACTOR,
useNativeDriver: true,
});
});
it('should account for the indicator bar height when the indicator is visible', () => {
it('should account for the indicator bar heights when the indicator is visible', () => {
instance.indicatorBarVisible = true;
instance.joinCallBarVisible = true;
instance.currentCallBarVisible = true;
instance.buttonVisible = false;
wrapper.setState({moreText: '1 new message'});
wrapper.setProps({deepLinkURL: null, unreadCount: 1});
@ -457,13 +461,15 @@ describe('MoreMessagesButton', () => {
instance.hide();
expect(instance.buttonVisible).toBe(false);
expect(Animated.spring).toHaveBeenCalledWith(instance.top, {
toValue: MIN_INPUT + INDICATOR_BAR_FACTOR,
toValue: MIN_INPUT + BARS_FACTOR,
useNativeDriver: true,
});
});
it('should account for the indicator bar height when the indicator is visible', () => {
it('should account for the indicator bars heights when the indicator is visible', () => {
instance.indicatorBarVisible = true;
instance.joinCallBarVisible = true;
instance.currentCallBarVisible = true;
instance.buttonVisible = true;
instance.hide();

View file

@ -7,7 +7,7 @@ import {ActivityIndicator, Animated, AppState, AppStateStatus, NativeEventSubscr
import CompassIcon from '@components/compass_icon';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import ViewTypes, {INDICATOR_BAR_HEIGHT} from '@constants/view';
import ViewTypes, {INDICATOR_BAR_HEIGHT, JOIN_CALL_BAR_HEIGHT, CURRENT_CALL_BAR_HEIGHT} from '@constants/view';
import EventEmitter from '@mm-redux/utils/event_emitter';
import {messageCount} from '@mm-redux/utils/post_list';
import {t} from '@utils/i18n';
@ -17,22 +17,22 @@ import type {Theme} from '@mm-redux/types/theme';
const HIDDEN_TOP = -400;
const SHOWN_TOP = 0;
export const INDICATOR_BAR_FACTOR = Math.abs(INDICATOR_BAR_HEIGHT / (HIDDEN_TOP - SHOWN_TOP));
export const BARS_FACTOR = Math.abs((INDICATOR_BAR_HEIGHT + JOIN_CALL_BAR_HEIGHT + CURRENT_CALL_BAR_HEIGHT) / (HIDDEN_TOP - SHOWN_TOP));
export const MIN_INPUT = 0;
export const MAX_INPUT = 1;
const TOP_INTERPOL_CONFIG: Animated.InterpolationConfigType = {
inputRange: [
MIN_INPUT,
MIN_INPUT + INDICATOR_BAR_FACTOR,
MAX_INPUT - INDICATOR_BAR_FACTOR,
MIN_INPUT + BARS_FACTOR,
MAX_INPUT - BARS_FACTOR,
MAX_INPUT,
],
outputRange: [
HIDDEN_TOP - INDICATOR_BAR_HEIGHT,
HIDDEN_TOP - (INDICATOR_BAR_HEIGHT + JOIN_CALL_BAR_HEIGHT + CURRENT_CALL_BAR_HEIGHT),
HIDDEN_TOP,
SHOWN_TOP,
SHOWN_TOP + INDICATOR_BAR_HEIGHT,
SHOWN_TOP + INDICATOR_BAR_HEIGHT + JOIN_CALL_BAR_HEIGHT + CURRENT_CALL_BAR_HEIGHT,
],
extrapolate: 'clamp',
};
@ -71,6 +71,8 @@ export default class MoreMessageButton extends React.PureComponent<MoreMessagesB
disableViewableItems = false;
endIndex: number | null = null;
indicatorBarVisible = false;
joinCallBarVisible = false;
currentCallBarVisible = false;
pressed = false;
removeViewableItemsListener: undefined | (() => void) = undefined;
removeScrollEndIndexListener: undefined | (() => void) = undefined;
@ -81,6 +83,8 @@ export default class MoreMessageButton extends React.PureComponent<MoreMessagesB
componentDidMount() {
this.appStateListener = AppState.addEventListener('change', this.onAppStateChange);
EventEmitter.on(ViewTypes.INDICATOR_BAR_VISIBLE, this.onIndicatorBarVisible);
EventEmitter.on(ViewTypes.JOIN_CALL_BAR_VISIBLE, this.onJoinCallBarVisible);
EventEmitter.on(ViewTypes.CURRENT_CALL_BAR_VISIBLE, this.onCurrentCallBarVisible);
this.removeViewableItemsListener = this.props.registerViewableItemsListener(this.onViewableItemsChanged);
this.removeScrollEndIndexListener = this.props.registerScrollEndIndexListener(this.onScrollEndIndex);
}
@ -88,6 +92,8 @@ export default class MoreMessageButton extends React.PureComponent<MoreMessagesB
componentWillUnmount() {
this.appStateListener?.remove();
EventEmitter.off(ViewTypes.INDICATOR_BAR_VISIBLE, this.onIndicatorBarVisible);
EventEmitter.off(ViewTypes.JOIN_CALL_BAR_VISIBLE, this.onJoinCallBarVisible);
EventEmitter.off(ViewTypes.CURRENT_CALL_BAR_VISIBLE, this.onCurrentCallBarVisible);
if (this.removeViewableItemsListener) {
this.removeViewableItemsListener();
}
@ -144,8 +150,22 @@ export default class MoreMessageButton extends React.PureComponent<MoreMessagesB
onIndicatorBarVisible = (indicatorVisible: boolean) => {
this.indicatorBarVisible = indicatorVisible;
this.animateButton();
}
onCurrentCallBarVisible = (currentCallVisible: boolean) => {
this.currentCallBarVisible = currentCallVisible;
this.animateButton();
}
onJoinCallBarVisible = (joinCallVisible: boolean) => {
this.joinCallBarVisible = joinCallVisible;
this.animateButton();
}
animateButton = () => {
if (this.buttonVisible) {
const toValue = this.indicatorBarVisible ? MAX_INPUT : MAX_INPUT - INDICATOR_BAR_FACTOR;
const toValue = MAX_INPUT - this.getBarsFactor();
Animated.spring(this.top, {
toValue,
useNativeDriver: true,
@ -169,18 +189,22 @@ export default class MoreMessageButton extends React.PureComponent<MoreMessagesB
show = () => {
if (!this.buttonVisible && this.state.moreText && !this.props.deepLinkURL && !this.canceled && this.props.unreadCount > 0) {
this.buttonVisible = true;
const toValue = this.indicatorBarVisible ? MAX_INPUT : MAX_INPUT - INDICATOR_BAR_FACTOR;
Animated.spring(this.top, {
toValue,
useNativeDriver: true,
}).start();
this.animateButton();
}
}
getBarsFactor = () => {
return Math.abs((
(this.indicatorBarVisible ? 0 : INDICATOR_BAR_HEIGHT) +
(this.joinCallBarVisible ? 0 : JOIN_CALL_BAR_HEIGHT) +
(this.currentCallBarVisible ? 0 : CURRENT_CALL_BAR_HEIGHT)
) / (HIDDEN_TOP - SHOWN_TOP));
}
hide = () => {
if (this.buttonVisible) {
this.buttonVisible = false;
const toValue = this.indicatorBarVisible ? MIN_INPUT : MIN_INPUT + INDICATOR_BAR_FACTOR;
const toValue = MIN_INPUT + this.getBarsFactor();
Animated.spring(this.top, {
toValue,
useNativeDriver: true,

View file

@ -7,7 +7,7 @@ import {showPermalink} from '@actions/views/permalink';
import {THREAD} from '@constants/screen';
import {removePost} from '@mm-redux/actions/posts';
import {getChannel} from '@mm-redux/selectors/entities/channels';
import {getConfig} from '@mm-redux/selectors/entities/general';
import {getConfig, getFeatureFlagValue} from '@mm-redux/selectors/entities/general';
import {getPost, isRootPost} from '@mm-redux/selectors/entities/posts';
import {getMyPreferences, getTeammateNameDisplaySetting, isCollapsedThreadsEnabled} from '@mm-redux/selectors/entities/preferences';
import {getCurrentTeamId} from '@mm-redux/selectors/entities/teams';
@ -49,6 +49,7 @@ function mapSateToProps(state: GlobalState, ownProps: OwnProps) {
const teammateNameDisplay = getTeammateNameDisplaySetting(state);
const enablePostUsernameOverride = config.EnablePostUsernameOverride === 'true';
const isConsecutivePost = post && previousPost && !author?.is_bot && !isRootPost(state, post.id) && areConsecutivePosts(post, previousPost);
const callsFeatureEnabled = getFeatureFlagValue(state, 'CallsMobile') === 'true';
let isFirstReply = true;
let isLastReply = true;
let canDelete = false;
@ -97,6 +98,7 @@ function mapSateToProps(state: GlobalState, ownProps: OwnProps) {
teammateNameDisplay,
thread,
threadStarter: getUser(state, post.user_id),
callsFeatureEnabled,
};
}

View file

@ -16,6 +16,7 @@ import {UserThread} from '@mm-redux/types/threads';
import {UserProfile} from '@mm-redux/types/users';
import EventEmitter from '@mm-redux/utils/event_emitter';
import {fromAutoResponder, isPostEphemeral, isPostPendingOrFailed, isSystemMessage} from '@mm-redux/utils/post_utils';
import CallMessage from '@mmproducts/calls/components/call_message';
import {preventDoubleTap} from '@utils/tap';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
@ -54,6 +55,7 @@ type PostProps = {
theme: Theme;
thread: UserThread;
threadStarter: UserProfile;
callsFeatureEnabled: boolean;
};
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
@ -113,7 +115,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
const Post = ({
canDelete, collapsedThreadsEnabled, enablePostUsernameOverride, highlight, highlightPinnedOrFlagged = true, intl, isConsecutivePost, isFirstReply, isFlagged, isLastReply,
location, post, removePost, rootPostAuthor, shouldRenderReplyButton, skipFlaggedHeader, skipPinnedHeader, showAddReaction = true, showPermalink, style,
teammateNameDisplay, testID, theme, thread, threadStarter,
teammateNameDisplay, testID, theme, thread, threadStarter, callsFeatureEnabled,
}: PostProps) => {
const pressDetected = useRef(false);
const styles = getStyleSheet(theme);
@ -239,6 +241,13 @@ const Post = ({
theme={theme}
/>
);
} else if (post.type === 'custom_calls' && callsFeatureEnabled) {
body = (
<CallMessage
post={post}
theme={theme}
/>
);
} else {
body = (
<Body

View file

@ -808,6 +808,240 @@ exports[`ChannelItem should match snapshot for no displayName 1`] = `null`;
exports[`ChannelItem should match snapshot for showUnreadForMsgs 1`] = `null`;
exports[`ChannelItem should match snapshot when there is a call and but calls are disabled 1`] = `
<TouchableHighlight
onPress={[Function]}
underlayColor="rgba(40,66,123,0.5)"
>
<View
style={
Array [
Object {
"flex": 1,
"flexDirection": "row",
"height": 44,
},
undefined,
]
}
testID="main.sidebar.channels_list.list.channel_item"
>
<View
style={
Array [
Object {
"alignItems": "center",
"flex": 1,
"flexDirection": "row",
"paddingLeft": 16,
},
undefined,
]
}
testID="main.sidebar.channels_list.list.channel_item.channel_id"
>
<ChannelIcon
channelId="channel_id"
hasDraft={false}
isActive={false}
isArchived={false}
isInfo={false}
isUnread={true}
membersCount={1}
size={24}
statusStyle={
Object {
"backgroundColor": "#1e325c",
"borderColor": "transparent",
}
}
testID="main.sidebar.channels_list.list.channel_item.channel_icon"
theme={
Object {
"awayIndicator": "#ffbc1f",
"buttonBg": "#1c58d9",
"buttonColor": "#ffffff",
"centerChannelBg": "#ffffff",
"centerChannelColor": "#3f4350",
"codeTheme": "github",
"dndIndicator": "#d24b4e",
"errorTextColor": "#d24b4e",
"linkColor": "#386fe5",
"mentionBg": "#ffffff",
"mentionColor": "#1e325c",
"mentionHighlightBg": "#ffd470",
"mentionHighlightLink": "#1b1d22",
"newMessageSeparator": "#cc8f00",
"onlineIndicator": "#3db887",
"sidebarBg": "#1e325c",
"sidebarHeaderBg": "#192a4d",
"sidebarHeaderTextColor": "#ffffff",
"sidebarTeamBarBg": "#14213e",
"sidebarText": "#ffffff",
"sidebarTextActiveBorder": "#5d89ea",
"sidebarTextActiveColor": "#ffffff",
"sidebarTextHoverBg": "#28427b",
"sidebarUnreadText": "#ffffff",
"type": "Denim",
}
}
type="O"
/>
<Text
ellipsizeMode="tail"
numberOfLines={1}
style={
Array [
Object {
"alignSelf": "center",
"color": "rgba(255,255,255,0.6)",
"fontFamily": "Open Sans",
"fontSize": 16,
"lineHeight": 24,
"marginLeft": 13,
"maxWidth": "80%",
"paddingRight": 10,
},
Object {
"color": "#ffffff",
"fontWeight": "500",
"maxWidth": "70%",
"opacity": 1,
},
]
}
testID="main.sidebar.channels_list.list.channel_item.display_name"
>
display_name
</Text>
</View>
</View>
</TouchableHighlight>
`;
exports[`ChannelItem should match snapshot when there is a call and calls are enabled 1`] = `
<TouchableHighlight
onPress={[Function]}
underlayColor="rgba(40,66,123,0.5)"
>
<View
style={
Array [
Object {
"flex": 1,
"flexDirection": "row",
"height": 44,
},
undefined,
]
}
testID="main.sidebar.channels_list.list.channel_item"
>
<View
style={
Array [
Object {
"alignItems": "center",
"flex": 1,
"flexDirection": "row",
"paddingLeft": 16,
},
undefined,
]
}
testID="main.sidebar.channels_list.list.channel_item.channel_id"
>
<ChannelIcon
channelId="channel_id"
hasDraft={false}
isActive={false}
isArchived={false}
isInfo={false}
isUnread={true}
membersCount={1}
size={24}
statusStyle={
Object {
"backgroundColor": "#1e325c",
"borderColor": "transparent",
}
}
testID="main.sidebar.channels_list.list.channel_item.channel_icon"
theme={
Object {
"awayIndicator": "#ffbc1f",
"buttonBg": "#1c58d9",
"buttonColor": "#ffffff",
"centerChannelBg": "#ffffff",
"centerChannelColor": "#3f4350",
"codeTheme": "github",
"dndIndicator": "#d24b4e",
"errorTextColor": "#d24b4e",
"linkColor": "#386fe5",
"mentionBg": "#ffffff",
"mentionColor": "#1e325c",
"mentionHighlightBg": "#ffd470",
"mentionHighlightLink": "#1b1d22",
"newMessageSeparator": "#cc8f00",
"onlineIndicator": "#3db887",
"sidebarBg": "#1e325c",
"sidebarHeaderBg": "#192a4d",
"sidebarHeaderTextColor": "#ffffff",
"sidebarTeamBarBg": "#14213e",
"sidebarText": "#ffffff",
"sidebarTextActiveBorder": "#5d89ea",
"sidebarTextActiveColor": "#ffffff",
"sidebarTextHoverBg": "#28427b",
"sidebarUnreadText": "#ffffff",
"type": "Denim",
}
}
type="O"
/>
<Text
ellipsizeMode="tail"
numberOfLines={1}
style={
Array [
Object {
"alignSelf": "center",
"color": "rgba(255,255,255,0.6)",
"fontFamily": "Open Sans",
"fontSize": 16,
"lineHeight": 24,
"marginLeft": 13,
"maxWidth": "80%",
"paddingRight": 10,
},
Object {
"color": "#ffffff",
"fontWeight": "500",
"maxWidth": "70%",
"opacity": 1,
},
]
}
testID="main.sidebar.channels_list.list.channel_item.display_name"
>
display_name
</Text>
<CompassIcon
name="phone-in-talk"
size={16}
style={
Object {
"color": "#ffffff",
"flex": 1,
"marginRight": 20,
"textAlign": "right",
}
}
/>
</View>
</View>
</TouchableHighlight>
`;
exports[`ChannelItem should match snapshot with custom status emoji 1`] = `
<TouchableHighlight
onPress={[Function]}

View file

@ -13,6 +13,7 @@ import {
import Badge from '@components/badge';
import ChannelIcon from '@components/channel_icon';
import CompassIcon from '@components/compass_icon';
import CustomStatusEmoji from '@components/custom_status/custom_status_emoji';
import {General} from '@mm-redux/constants';
import {preventDoubleTap} from '@utils/tap';
@ -41,6 +42,8 @@ export default class ChannelItem extends PureComponent {
isSearchResult: PropTypes.bool,
viewingGlobalThreads: PropTypes.bool,
customStatusEnabled: PropTypes.bool.isRequired,
channelHasCall: PropTypes.bool.isRequired,
callsFeatureEnabled: PropTypes.bool.isRequired,
};
static defaultProps = {
@ -214,6 +217,13 @@ export default class ChannelItem extends PureComponent {
</Text>
{customStatus}
{badge}
{this.props.callsFeatureEnabled && this.props.channelHasCall &&
<CompassIcon
name='phone-in-talk'
size={16}
style={style.hasCall}
/>
}
</View>
</View>
</TouchableHighlight>
@ -288,5 +298,11 @@ export const getStyleSheet = makeStyleSheetFromTheme((theme) => {
muted: {
opacity: 0.5,
},
hasCall: {
color: theme.sidebarText,
flex: 1,
textAlign: 'right',
marginRight: 20,
},
};
});

View file

@ -40,6 +40,8 @@ describe('ChannelItem', () => {
isSearchResult: false,
isBot: false,
customStatusEnabled: true,
channelHasCall: false,
callsFeatureEnabled: false,
};
test('should match snapshot', () => {
@ -50,6 +52,34 @@ describe('ChannelItem', () => {
expect(wrapper.getElement()).toMatchSnapshot();
});
test('should match snapshot when there is a call and calls are enabled', () => {
const newProps = {
...baseProps,
callsFeatureEnabled: true,
channelHasCall: true,
};
const wrapper = shallowWithIntl(
<ChannelItem {...newProps}/>,
);
expect(wrapper.getElement()).toMatchSnapshot();
});
test('should match snapshot when there is a call and but calls are disabled', () => {
const newProps = {
...baseProps,
callsFeatureEnabled: false,
channelHasCall: true,
};
const wrapper = shallowWithIntl(
<ChannelItem {...newProps}/>,
);
expect(wrapper.getElement()).toMatchSnapshot();
});
test('should match snapshot with mentions and muted', () => {
const newProps = {
...baseProps,

View file

@ -11,10 +11,12 @@ import {
makeGetChannel,
shouldHideDefaultChannel,
} from '@mm-redux/selectors/entities/channels';
import {getFeatureFlagValue} from '@mm-redux/selectors/entities/general';
import {getTheme, getTeammateNameDisplaySetting, isCollapsedThreadsEnabled} from '@mm-redux/selectors/entities/preferences';
import {getCurrentUserId, getUser} from '@mm-redux/selectors/entities/users';
import {getMsgCountInChannel, getUserIdFromChannelName, isChannelMuted} from '@mm-redux/utils/channel_utils';
import {displayUsername} from '@mm-redux/utils/user_utils';
import {getCalls} from '@mmproducts/calls/store/selectors/calls';
import {isCustomStatusEnabled} from '@selectors/custom_status';
import {getViewingGlobalThreads} from '@selectors/threads';
import {getDraftForChannel} from '@selectors/views';
@ -31,6 +33,7 @@ function makeMapStateToProps() {
const currentUserId = getCurrentUserId(state);
const channelDraft = getDraftForChannel(state, channel.id);
const collapsedThreadsEnabled = isCollapsedThreadsEnabled(state);
const channelHasCall = Boolean(getCalls(state)[ownProps.channelId]);
let displayName = channel.display_name;
let isGuest = false;
@ -72,6 +75,7 @@ function makeMapStateToProps() {
if (member && member.notify_props) {
showUnreadForMsgs = member.notify_props.mark_unread !== General.MENTION;
}
const callsFeatureEnabled = getFeatureFlagValue(state, 'CallsMobile') === 'true';
const viewingGlobalThreads = getViewingGlobalThreads(state);
return {
@ -92,6 +96,8 @@ function makeMapStateToProps() {
unreadMsgs,
viewingGlobalThreads,
customStatusEnabled: isCustomStatusEnabled(state),
channelHasCall,
callsFeatureEnabled,
};
};
}

View file

@ -27,6 +27,8 @@ export const NotificationLevels = {
export const NOTIFY_ALL_MEMBERS = 5;
export const INDICATOR_BAR_HEIGHT = 38;
export const JOIN_CALL_BAR_HEIGHT = 38;
export const CURRENT_CALL_BAR_HEIGHT = 74;
export const CHANNEL_ITEM_LARGE_BADGE_MAX_WIDTH = 38;
export const CHANNEL_ITEM_SMALL_BADGE_MAX_WIDTH = 32;
@ -107,6 +109,9 @@ const ViewTypes = keyMirror({
VIEWING_GLOBAL_THREADS_ALL: null,
THREAD_LAST_VIEWED_AT: null,
JOIN_CALL_BAR_VISIBLE: null,
CURRENT_CALL_BAR_VISIBLE: null,
});
const RequiredServer = {

View file

@ -51,5 +51,16 @@ const WebsocketEvents = {
SIDEBAR_CATEGORY_UPDATED: 'sidebar_category_updated',
SIDEBAR_CATEGORY_DELETED: 'sidebar_category_deleted',
SIDEBAR_CATEGORY_ORDER_UPDATED: 'sidebar_category_order_updated',
CALLS_CHANNEL_ENABLED: 'custom_com.mattermost.calls_channel_enable_voice',
CALLS_CHANNEL_DISABLED: 'custom_com.mattermost.calls_channel_disable_voice',
CALLS_USER_CONNECTED: 'custom_com.mattermost.calls_user_connected',
CALLS_USER_DISCONNECTED: 'custom_com.mattermost.calls_user_disconnected',
CALLS_USER_MUTED: 'custom_com.mattermost.calls_user_muted',
CALLS_USER_UNMUTED: 'custom_com.mattermost.calls_user_unmuted',
CALLS_USER_VOICE_ON: 'custom_com.mattermost.calls_user_voice_on',
CALLS_USER_VOICE_OFF: 'custom_com.mattermost.calls_user_voice_off',
CALLS_CALL_START: 'custom_com.mattermost.calls_call_start',
CALLS_SCREEN_ON: 'custom_com.mattermost.calls_user_screen_on',
CALLS_SCREEN_OFF: 'custom_com.mattermost.calls_user_screen_off',
};
export default WebsocketEvents;

View file

@ -1,6 +1,8 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import CallsTypes from '@mmproducts/calls/store/action_types/calls';
import AppsTypes from './apps';
import BotTypes from './bots';
import ChannelCategoryTypes from './channel_categories';
@ -43,4 +45,5 @@ export {
ThreadTypes,
RemoteClusterTypes,
AppsTypes,
CallsTypes,
};

View file

@ -1,6 +1,8 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import * as calls from '@mmproducts/calls/store/actions/calls';
import * as bots from './bots';
import * as channels from './channels';
import * as emojis from './emojis';
@ -37,5 +39,6 @@ export {
timezone,
users,
remoteCluster,
calls,
};

View file

@ -3,6 +3,8 @@
import {combineReducers} from 'redux';
import calls from '@mmproducts/calls/store/reducers/calls';
import apps from './apps';
import bots from './bots';
import channelCategories from './channel_categories';
@ -43,4 +45,5 @@ export default combineReducers({
threads,
remoteCluster,
apps,
calls,
});

View file

@ -1,6 +1,8 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import * as calls from '@mmproducts/calls/store/types/calls';
import * as actions from './actions';
import * as bots from './bots';
import * as channels from './channels';
@ -32,6 +34,7 @@ export {
bots,
plugins,
store,
calls,
channels,
errors,
emojis,

View file

@ -26,7 +26,8 @@ export type PostType = 'system_add_remove' |
'system_join_leave' |
'system_leave_channel' |
'system_purpose_change' |
'system_remove_from_channel';
'system_remove_from_channel' |
'custom_calls';
export type PostEmbedType = 'image' | 'message_attachment' | 'opengraph';

View file

@ -1,6 +1,8 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {CallsState} from '@mmproducts/calls/store/types/calls';
import {AppsState} from './apps';
import {Bot} from './bots';
import {ChannelCategoriesState} from './channel_categories';
@ -58,6 +60,7 @@ export type GlobalState = {
};
};
apps: AppsState;
calls: CallsState;
};
errors: Array<any>;
requests: {

View file

@ -0,0 +1,35 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {ServerChannelState} from '@mmproducts/calls/store/types/calls';
export interface ClientCallsMix {
getCalls: () => Promise<ServerChannelState[]>;
enableChannelCalls: (channelId: string) => Promise<ServerChannelState>;
disableChannelCalls: (channelId: string) => Promise<ServerChannelState>;
}
const ClientCalls = (superclass: any) => class extends superclass {
getCalls = async () => {
return this.doFetch(
`${this.getCallsRoute()}/channels`,
{method: 'get'},
);
};
enableChannelCalls = async (channelId: string) => {
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})},
);
};
};
export default ClientCalls;

View file

@ -0,0 +1,196 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`CallAvatar should match snapshot muted 1`] = `
<View
style={
Object {
"backgroundColor": "rgba(255,255,255,0.16)",
"borderRadius": 28,
"height": 56,
"marginRight": 4,
"padding": 4,
"width": 56,
}
}
>
<View
style={
Object {
"backgroundColor": "rgba(255,255,255,0.24)",
"borderRadius": 24,
"height": 48,
"padding": 4,
"width": 48,
}
}
>
<View
style={
Object {
"borderRadius": 20,
"height": 40,
"width": 40,
}
}
>
<Connect(ProfilePicture)
showStatus={false}
size={40}
userId="user-id"
/>
<CompassIcon
name="microphone-off"
size={16}
style={
Object {
"backgroundColor": "black",
"borderColor": "black",
"borderRadius": 12,
"borderWidth": 2,
"bottom": -5,
"color": "white",
"height": 24,
"overflow": "hidden",
"padding": 2,
"position": "absolute",
"right": -5,
"textAlign": "center",
"textAlignVertical": "center",
"width": 24,
}
}
/>
</View>
</View>
</View>
`;
exports[`CallAvatar should match snapshot size large 1`] = `
<View
style={
Object {
"backgroundColor": "rgba(255,255,255,0.16)",
"borderRadius": 44,
"height": 88,
"marginRight": 4,
"padding": 4,
"width": 88,
}
}
>
<View
style={
Object {
"backgroundColor": "rgba(255,255,255,0.24)",
"borderRadius": 40,
"height": 80,
"padding": 4,
"width": 80,
}
}
>
<View
style={
Object {
"borderRadius": 36,
"height": 72,
"width": 72,
}
}
>
<Connect(ProfilePicture)
showStatus={false}
size={72}
userId="user-id"
/>
<CompassIcon
name="microphone"
size={16}
style={
Object {
"backgroundColor": "#3DB887",
"borderColor": "black",
"borderRadius": 12,
"borderWidth": 2,
"bottom": -5,
"color": "white",
"height": 24,
"overflow": "hidden",
"padding": 2,
"position": "absolute",
"right": -5,
"textAlign": "center",
"textAlignVertical": "center",
"width": 24,
}
}
/>
</View>
</View>
</View>
`;
exports[`CallAvatar should match snapshot unmuted 1`] = `
<View
style={
Object {
"backgroundColor": "rgba(255,255,255,0.16)",
"borderRadius": 28,
"height": 56,
"marginRight": 4,
"padding": 4,
"width": 56,
}
}
>
<View
style={
Object {
"backgroundColor": "rgba(255,255,255,0.24)",
"borderRadius": 24,
"height": 48,
"padding": 4,
"width": 48,
}
}
>
<View
style={
Object {
"borderRadius": 20,
"height": 40,
"width": 40,
}
}
>
<Connect(ProfilePicture)
showStatus={false}
size={40}
userId="user-id"
/>
<CompassIcon
name="microphone"
size={16}
style={
Object {
"backgroundColor": "#3DB887",
"borderColor": "black",
"borderRadius": 12,
"borderWidth": 2,
"bottom": -5,
"color": "white",
"height": 24,
"overflow": "hidden",
"padding": 2,
"position": "absolute",
"right": -5,
"textAlign": "center",
"textAlignVertical": "center",
"width": 24,
}
}
/>
</View>
</View>
</View>
`;

View file

@ -0,0 +1,9 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`CallDuration should match snapshot 1`] = `
<Text
style={Object {}}
>
00:15
</Text>
`;

View file

@ -0,0 +1,151 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`EnableDisableCalls should match snapshot if calls are disabled 1`] = `
<React.Fragment>
<Separator
theme={
Object {
"awayIndicator": "#ffbc1f",
"buttonBg": "#1c58d9",
"buttonColor": "#ffffff",
"centerChannelBg": "#ffffff",
"centerChannelColor": "#3f4350",
"codeTheme": "github",
"dndIndicator": "#d24b4e",
"errorTextColor": "#d24b4e",
"linkColor": "#386fe5",
"mentionBg": "#ffffff",
"mentionColor": "#1e325c",
"mentionHighlightBg": "#ffd470",
"mentionHighlightLink": "#1b1d22",
"newMessageSeparator": "#cc8f00",
"onlineIndicator": "#3db887",
"sidebarBg": "#1e325c",
"sidebarHeaderBg": "#192a4d",
"sidebarHeaderTextColor": "#ffffff",
"sidebarTeamBarBg": "#14213e",
"sidebarText": "#ffffff",
"sidebarTextActiveBorder": "#5d89ea",
"sidebarTextActiveColor": "#ffffff",
"sidebarTextHoverBg": "#28427b",
"sidebarUnreadText": "#ffffff",
"type": "Denim",
}
}
/>
<channelInfoRow
action={[Function]}
defaultMessage="Enable Calls"
icon="phone-outline"
rightArrow={false}
shouldRender={true}
testID="test-id"
textId="mobile.channel_info.enable_calls"
theme={
Object {
"awayIndicator": "#ffbc1f",
"buttonBg": "#1c58d9",
"buttonColor": "#ffffff",
"centerChannelBg": "#ffffff",
"centerChannelColor": "#3f4350",
"codeTheme": "github",
"dndIndicator": "#d24b4e",
"errorTextColor": "#d24b4e",
"linkColor": "#386fe5",
"mentionBg": "#ffffff",
"mentionColor": "#1e325c",
"mentionHighlightBg": "#ffd470",
"mentionHighlightLink": "#1b1d22",
"newMessageSeparator": "#cc8f00",
"onlineIndicator": "#3db887",
"sidebarBg": "#1e325c",
"sidebarHeaderBg": "#192a4d",
"sidebarHeaderTextColor": "#ffffff",
"sidebarTeamBarBg": "#14213e",
"sidebarText": "#ffffff",
"sidebarTextActiveBorder": "#5d89ea",
"sidebarTextActiveColor": "#ffffff",
"sidebarTextHoverBg": "#28427b",
"sidebarUnreadText": "#ffffff",
"type": "Denim",
}
}
togglable={false}
/>
</React.Fragment>
`;
exports[`EnableDisableCalls should match snapshot if calls are enabled 1`] = `
<React.Fragment>
<Separator
theme={
Object {
"awayIndicator": "#ffbc1f",
"buttonBg": "#1c58d9",
"buttonColor": "#ffffff",
"centerChannelBg": "#ffffff",
"centerChannelColor": "#3f4350",
"codeTheme": "github",
"dndIndicator": "#d24b4e",
"errorTextColor": "#d24b4e",
"linkColor": "#386fe5",
"mentionBg": "#ffffff",
"mentionColor": "#1e325c",
"mentionHighlightBg": "#ffd470",
"mentionHighlightLink": "#1b1d22",
"newMessageSeparator": "#cc8f00",
"onlineIndicator": "#3db887",
"sidebarBg": "#1e325c",
"sidebarHeaderBg": "#192a4d",
"sidebarHeaderTextColor": "#ffffff",
"sidebarTeamBarBg": "#14213e",
"sidebarText": "#ffffff",
"sidebarTextActiveBorder": "#5d89ea",
"sidebarTextActiveColor": "#ffffff",
"sidebarTextHoverBg": "#28427b",
"sidebarUnreadText": "#ffffff",
"type": "Denim",
}
}
/>
<channelInfoRow
action={[Function]}
defaultMessage="Disable Calls"
icon="phone-outline"
rightArrow={false}
shouldRender={true}
testID="test-id"
textId="mobile.channel_info.disable_calls"
theme={
Object {
"awayIndicator": "#ffbc1f",
"buttonBg": "#1c58d9",
"buttonColor": "#ffffff",
"centerChannelBg": "#ffffff",
"centerChannelColor": "#3f4350",
"codeTheme": "github",
"dndIndicator": "#d24b4e",
"errorTextColor": "#d24b4e",
"linkColor": "#386fe5",
"mentionBg": "#ffffff",
"mentionColor": "#1e325c",
"mentionHighlightBg": "#ffd470",
"mentionHighlightLink": "#1b1d22",
"newMessageSeparator": "#cc8f00",
"onlineIndicator": "#3db887",
"sidebarBg": "#1e325c",
"sidebarHeaderBg": "#192a4d",
"sidebarHeaderTextColor": "#ffffff",
"sidebarTeamBarBg": "#14213e",
"sidebarText": "#ffffff",
"sidebarTextActiveBorder": "#5d89ea",
"sidebarTextActiveColor": "#ffffff",
"sidebarTextHoverBg": "#28427b",
"sidebarUnreadText": "#ffffff",
"type": "Denim",
}
}
togglable={false}
/>
</React.Fragment>
`;

View file

@ -0,0 +1,21 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`FloatingCallContainer should match snapshot 1`] = `
<View
style={
Array [
Object {
"position": "absolute",
"top": 91,
"width": "100%",
"zIndex": 9,
},
undefined,
]
}
>
<Text>
test
</Text>
</View>
`;

View file

@ -0,0 +1,36 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {shallow} from 'enzyme';
import React from 'react';
import CallAvatar from './call_avatar';
describe('CallAvatar', () => {
const baseProps = {
userId: 'user-id',
volume: 1,
muted: false,
size: 'm',
};
test('should match snapshot unmuted', () => {
const wrapper = shallow(<CallAvatar {...baseProps}/>);
expect(wrapper.getElement()).toMatchSnapshot();
});
test('should match snapshot muted', () => {
const props = {...baseProps, muted: true};
const wrapper = shallow(<CallAvatar {...props}/>);
expect(wrapper.getElement()).toMatchSnapshot();
});
test('should match snapshot size large', () => {
const props = {...baseProps, size: 'l'};
const wrapper = shallow(<CallAvatar {...props}/>);
expect(wrapper.getElement()).toMatchSnapshot();
});
});

View file

@ -0,0 +1,82 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {View, StyleSheet} from 'react-native';
import CompassIcon from '@components/compass_icon';
import ProfilePicture from '@components/profile_picture';
type Props = {
userId: string;
volume: number;
muted?: boolean;
size?: 'm' | 'l';
}
const getStyleSheet = (props: Props) => {
const baseSize = props.size === 'm' || !props.size ? 40 : 72;
return StyleSheet.create({
pictureHalo: {
backgroundColor: 'rgba(255,255,255,' + (0.16 * props.volume) + ')',
height: baseSize + 16,
width: baseSize + 16,
padding: 4,
marginRight: 4,
borderRadius: (baseSize + 16) / 2,
},
pictureHalo2: {
backgroundColor: 'rgba(255,255,255,' + (0.24 * props.volume) + ')',
height: baseSize + 8,
width: baseSize + 8,
padding: 4,
borderRadius: (baseSize + 8) / 2,
},
picture: {
borderRadius: baseSize / 2,
height: baseSize,
width: baseSize,
},
mute: {
position: 'absolute',
bottom: -5,
right: -5,
width: 24,
height: 24,
borderRadius: 12,
padding: 2,
backgroundColor: props.muted ? 'black' : '#3DB887',
borderColor: 'black',
borderWidth: 2,
color: 'white',
textAlign: 'center',
textAlignVertical: 'center',
overflow: 'hidden',
},
});
};
const CallAvatar = (props: Props) => {
const style = getStyleSheet(props);
return (
<View style={style.pictureHalo}>
<View style={style.pictureHalo2}>
<View style={style.picture}>
<ProfilePicture
userId={props.userId}
size={props.size === 'm' || !props.size ? 40 : 72}
showStatus={false}
/>
{props.muted !== undefined &&
<CompassIcon
name={props.muted ? 'microphone-off' : 'microphone'}
size={16}
style={style.mute}
/>}
</View>
</View>
</View>
);
};
export default CallAvatar;

View file

@ -0,0 +1,63 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {shallow} from 'enzyme';
import moment from 'moment';
import React from 'react';
import CallDuration from './call_duration';
jest.mock('react', () => ({
...jest.requireActual('react'),
useEffect: (f) => f(),
}));
describe('CallDuration', () => {
const baseProps = {
style: {},
value: moment.now() - 15000,
updateIntervalInSeconds: 10000,
};
test('should match snapshot', () => {
const wrapper = shallow(<CallDuration {...baseProps}/>);
expect(wrapper.getElement()).toMatchSnapshot();
});
test('should match snapshot more in the past', () => {
const props = {...baseProps, value: moment.now() - ((10 * 60 * 60 * 1000) + (30 * 60 * 1000) + (25 * 1000) + 500)};
const wrapper = shallow(<CallDuration {...props}/>);
expect(wrapper.getElement().props.children).toBe('10:30:25');
});
test('should match snapshot more in the future', () => {
const props = {...baseProps, value: moment.now() + 15500};
const wrapper = shallow(<CallDuration {...props}/>);
expect(wrapper.getElement().props.children).toBe('00:00');
});
test('should re-render after updateIntervalInSeconds', () => {
jest.useFakeTimers();
const props = {...baseProps, value: moment.now(), updateIntervalInSeconds: 10};
const wrapper = shallow(<CallDuration {...props}/>);
expect(wrapper.getElement().props.children).toBe('00:00');
jest.advanceTimersByTime(5000);
expect(wrapper.getElement().props.children).toBe('00:00');
jest.advanceTimersByTime(5000);
expect(wrapper.getElement().props.children).toBe('00:10');
jest.useRealTimers();
});
test('should not re-render if updateIntervalInSeconds is not passed', () => {
jest.useFakeTimers();
const props = {value: moment.now()};
const wrapper = shallow(<CallDuration {...props}/>);
expect(wrapper.getElement().props.children).toBe('00:00');
jest.advanceTimersByTime(500000000);
expect(wrapper.getElement().props.children).toBe('00:00');
jest.useRealTimers();
});
});

View file

@ -0,0 +1,52 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import moment from 'moment-timezone';
import React, {useEffect, useState} from 'react';
import {Text, StyleProp, TextStyle} from 'react-native';
type CallDurationProps = {
style: StyleProp<TextStyle>;
value: number;
updateIntervalInSeconds?: number;
}
const CallDuration = ({value, style, updateIntervalInSeconds}: CallDurationProps) => {
const getCallDuration = () => {
const now = moment();
const startTime = moment(value);
if (now < startTime) {
return '00:00';
}
const totalSeconds = now.diff(startTime, 'seconds');
const seconds = totalSeconds % 60;
const totalMinutes = Math.floor(totalSeconds / 60);
const minutes = totalMinutes % 60;
const hours = Math.floor(totalMinutes / 60);
if (hours > 0) {
return `${hours}:${minutes < 10 ? '0' + minutes : minutes}:${seconds < 10 ? '0' + seconds : seconds}`;
}
return `${minutes < 10 ? '0' + minutes : minutes}:${seconds < 10 ? '0' + seconds : seconds}`;
};
const [formattedTime, setFormattedTime] = useState(getCallDuration());
useEffect(() => {
if (updateIntervalInSeconds) {
const interval = setInterval(() => setFormattedTime(getCallDuration()), updateIntervalInSeconds * 1000);
return () => {
clearInterval(interval);
};
}
return () => null;
}, [updateIntervalInSeconds]);
return (
<Text style={style}>
{formattedTime}
</Text>
);
};
export default CallDuration;

View file

@ -0,0 +1,292 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`CallMessage should match snapshot 1`] = `
<View
style={
Object {
"color": "rgba(63,67,80,0.6)",
"flexDirection": "row",
"fontSize": 15,
"lineHeight": 20,
"paddingBottom": 5,
"paddingTop": 5,
}
}
>
<CompassIcon
name="phone-in-talk"
size={16}
style={
Object {
"backgroundColor": "#339970",
"borderRadius": 8,
"color": "white",
"marginRight": 5,
"overflow": "hidden",
"padding": 12,
}
}
/>
<View
style={
Object {
"flex": 1,
}
}
>
<InjectIntl(FormattedText)
defaultMessage="{user} started a call"
id="call_message.started_a_call"
style={
Object {
"color": "#3f4350",
"fontWeight": "bold",
}
}
values={
Object {
"user": "User 1",
}
}
/>
<FormattedRelativeTime
style={
Object {
"color": "#3f4350",
}
}
updateIntervalInSeconds={1}
value={100}
/>
</View>
<TouchableOpacity
onPress={[Function]}
style={
Object {
"alignContent": "center",
"alignItems": "center",
"backgroundColor": "#339970",
"borderRadius": 8,
"flexDirection": "row",
"padding": 12,
}
}
>
<CompassIcon
name="phone-outline"
size={16}
style={
Object {
"color": "white",
"marginRight": 5,
}
}
/>
<InjectIntl(FormattedText)
defaultMessage="Join Call"
id="call_message.join_call"
style={
Object {
"color": "white",
}
}
/>
</TouchableOpacity>
</View>
`;
exports[`CallMessage should match snapshot for ended call 1`] = `
<View
style={
Object {
"color": "rgba(63,67,80,0.6)",
"flexDirection": "row",
"fontSize": 15,
"lineHeight": 20,
"paddingBottom": 5,
"paddingTop": 5,
}
}
>
<CompassIcon
name="phone-hangup"
size={16}
style={
Object {
"backgroundColor": "rgba(63,67,80,0.6)",
"borderRadius": 8,
"color": "white",
"marginRight": 5,
"overflow": "hidden",
"padding": 12,
}
}
/>
<View
style={
Object {
"flex": 1,
}
}
>
<InjectIntl(FormattedText)
defaultMessage="Call ended"
id="call_message.call_ended"
style={
Object {
"color": "#3f4350",
"fontWeight": "bold",
}
}
/>
<View
style={
Object {
"alignContent": "center",
"alignItems": "center",
"flexDirection": "row",
}
}
>
<InjectIntl(FormattedText)
defaultMessage="Ended at {time}"
id="call_message.call_ended_at"
style={
Object {
"color": "#3f4350",
}
}
values={
Object {
"time": <FormattedTime
isMilitaryTime={false}
timezone="utc"
value={200}
/>,
}
}
/>
<Text
style={
Object {
"color": "#3f4350",
"marginLeft": 5,
"marginRight": 5,
}
}
>
</Text>
<InjectIntl(FormattedText)
defaultMessage="Lasted {duration}"
id="call_message.call_lasted"
style={
Object {
"color": "#3f4350",
}
}
values={
Object {
"duration": "a few seconds",
}
}
/>
</View>
</View>
</View>
`;
exports[`CallMessage should match snapshot for the call already in the current channel 1`] = `
<View
style={
Object {
"color": "rgba(63,67,80,0.6)",
"flexDirection": "row",
"fontSize": 15,
"lineHeight": 20,
"paddingBottom": 5,
"paddingTop": 5,
}
}
>
<CompassIcon
name="phone-in-talk"
size={16}
style={
Object {
"backgroundColor": "#339970",
"borderRadius": 8,
"color": "white",
"marginRight": 5,
"overflow": "hidden",
"padding": 12,
}
}
/>
<View
style={
Object {
"flex": 1,
}
}
>
<InjectIntl(FormattedText)
defaultMessage="{user} started a call"
id="call_message.started_a_call"
style={
Object {
"color": "#3f4350",
"fontWeight": "bold",
}
}
values={
Object {
"user": "User 1",
}
}
/>
<FormattedRelativeTime
style={
Object {
"color": "#3f4350",
}
}
updateIntervalInSeconds={1}
value={100}
/>
</View>
<TouchableOpacity
onPress={[Function]}
style={
Object {
"alignContent": "center",
"alignItems": "center",
"backgroundColor": "#339970",
"borderRadius": 8,
"flexDirection": "row",
"padding": 12,
}
}
>
<CompassIcon
name="phone-outline"
size={16}
style={
Object {
"color": "white",
"marginRight": 5,
}
}
/>
<InjectIntl(FormattedText)
defaultMessage="Current Call"
id="call_message.current_call"
style={
Object {
"color": "white",
}
}
/>
</TouchableOpacity>
</View>
`;

View file

@ -0,0 +1,86 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {Alert, TouchableOpacity} from 'react-native';
import Preferences from '@mm-redux/constants/preferences';
import {shallowWithIntl} from '@test/intl-test-helper';
import CallMessage from './call_message';
describe('CallMessage', () => {
const baseProps = {
actions: {
joinCall: jest.fn(),
},
theme: Preferences.THEMES.denim,
post: {
props: {
start_at: 100,
},
type: 'custom_calls',
},
user: {
id: 'user-1-id',
username: 'user-1-username',
nickname: 'User 1',
},
teammateNameDisplay: Preferences.DISPLAY_PREFER_NICKNAME,
confirmToJoin: false,
isMilitaryTime: false,
userTimezone: 'utc',
currentChannelName: 'Current Channel',
callChannelName: 'Call Channel',
};
test('should match snapshot', () => {
const wrapper = shallowWithIntl(<CallMessage {...baseProps}/>).dive();
expect(wrapper.getElement()).toMatchSnapshot();
});
test('should match snapshot for the call already in the current channel', () => {
const props = {...baseProps, alreadyInTheCall: true};
const wrapper = shallowWithIntl(<CallMessage {...props}/>).dive();
expect(wrapper.getElement()).toMatchSnapshot();
});
test('should match snapshot for ended call', () => {
const props = {...baseProps, post: {...baseProps.post, props: {start_at: 100, end_at: 200}}};
const wrapper = shallowWithIntl(<CallMessage {...props}/>).dive();
expect(wrapper.getElement()).toMatchSnapshot();
});
test('should join on click join button', () => {
const joinCall = jest.fn();
const props = {...baseProps, actions: {joinCall}};
const wrapper = shallowWithIntl(<CallMessage {...props}/>).dive();
wrapper.find(TouchableOpacity).simulate('press');
expect(Alert.alert).not.toHaveBeenCalled();
expect(props.actions.joinCall).toHaveBeenCalled();
});
test('should ask for confirmation on click join button', () => {
const joinCall = jest.fn();
const props = {...baseProps, confirmToJoin: true, actions: {joinCall}};
const wrapper = shallowWithIntl(<CallMessage {...props}/>).dive();
wrapper.find(TouchableOpacity).simulate('press');
expect(Alert.alert).toHaveBeenCalled();
expect(props.actions.joinCall).not.toHaveBeenCalled();
});
test('should not ask or join on click current call button if I am in the current call', () => {
const joinCall = jest.fn();
const props = {...baseProps, actions: {joinCall}, alreadyInTheCall: true};
const wrapper = shallowWithIntl(<CallMessage {...props}/>).dive();
wrapper.find(TouchableOpacity).simulate('press');
expect(Alert.alert).not.toHaveBeenCalled();
expect(props.actions.joinCall).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,204 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import moment from 'moment-timezone';
import React, {useCallback} from 'react';
import {injectIntl, IntlShape} from 'react-intl';
import {View, TouchableOpacity, Text} from 'react-native';
import CompassIcon from '@components/compass_icon';
import FormattedRelativeTime from '@components/formatted_relative_time';
import FormattedText from '@components/formatted_text';
import FormattedTime from '@components/formatted_time';
import {displayUsername} from '@mm-redux/utils/user_utils';
import leaveAndJoinWithAlert from '@mmproducts/calls/components/leave_and_join_alert';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import type {Post} from '@mm-redux/types/posts';
import type {Theme} from '@mm-redux/types/theme';
import type {UserProfile} from '@mm-redux/types/users';
type CallMessageProps = {
actions: {
joinCall: (channelId: string) => void;
};
post: Post;
user: UserProfile;
theme: Theme;
teammateNameDisplay: string;
confirmToJoin: boolean;
alreadyInTheCall: boolean;
isMilitaryTime: boolean;
userTimezone: string;
currentChannelName: string;
callChannelName: string;
intl: typeof IntlShape;
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
messageStyle: {
flexDirection: 'row',
color: changeOpacity(theme.centerChannelColor, 0.6),
fontSize: 15,
lineHeight: 20,
paddingTop: 5,
paddingBottom: 5,
},
messageText: {
flex: 1,
},
joinCallIcon: {
padding: 12,
backgroundColor: '#339970',
borderRadius: 8,
marginRight: 5,
color: 'white',
overflow: 'hidden',
},
phoneHangupIcon: {
padding: 12,
backgroundColor: changeOpacity(theme.centerChannelColor, 0.6),
borderRadius: 8,
marginRight: 5,
color: 'white',
overflow: 'hidden',
},
joinCallButtonText: {
color: 'white',
},
joinCallButtonIcon: {
color: 'white',
marginRight: 5,
},
startedText: {
color: theme.centerChannelColor,
fontWeight: 'bold',
},
joinCallButton: {
flexDirection: 'row',
padding: 12,
backgroundColor: '#339970',
borderRadius: 8,
alignItems: 'center',
alignContent: 'center',
},
timeText: {
color: theme.centerChannelColor,
},
endCallInfo: {
flexDirection: 'row',
alignItems: 'center',
alignContent: 'center',
},
separator: {
color: theme.centerChannelColor,
marginLeft: 5,
marginRight: 5,
},
};
});
const CallMessage = ({post, user, teammateNameDisplay, confirmToJoin, alreadyInTheCall, theme, actions, userTimezone, isMilitaryTime, currentChannelName, callChannelName, intl}: CallMessageProps) => {
const style = getStyleSheet(theme);
const joinHandler = useCallback(() => {
if (alreadyInTheCall) {
return;
}
leaveAndJoinWithAlert(intl, post.channel_id, callChannelName, currentChannelName, confirmToJoin, actions.joinCall);
}, [post.channel_id, callChannelName, currentChannelName, confirmToJoin, actions.joinCall]);
if (post.props.end_at) {
return (
<View style={style.messageStyle}>
<CompassIcon
name='phone-hangup'
size={16}
style={style.phoneHangupIcon}
/>
<View style={style.messageText}>
<FormattedText
id='call_message.call_ended'
defaultMessage='Call ended'
style={style.startedText}
/>
<View
style={style.endCallInfo}
>
<FormattedText
id='call_message.call_ended_at'
defaultMessage='Ended at {time}'
style={style.timeText}
values={{
time: (
<FormattedTime
value={post.props.end_at}
isMilitaryTime={isMilitaryTime}
timezone={userTimezone}
/>
),
}}
/>
<Text style={style.separator}>{'•'}</Text>
<FormattedText
style={style.timeText}
id='call_message.call_lasted'
defaultMessage='Lasted {duration}'
values={{
duration: moment.duration(post.props.end_at - post.props.start_at).humanize(false),
}}
/>
</View>
</View>
</View>
);
}
return (
<View style={style.messageStyle}>
<CompassIcon
name='phone-in-talk'
size={16}
style={style.joinCallIcon}
/>
<View style={style.messageText}>
<FormattedText
id='call_message.started_a_call'
defaultMessage='{user} started a call'
values={{user: displayUsername(user, teammateNameDisplay)}}
style={style.startedText}
/>
<FormattedRelativeTime
value={post.props.start_at}
updateIntervalInSeconds={1}
style={style.timeText}
/>
</View>
<TouchableOpacity
style={style.joinCallButton}
onPress={joinHandler}
>
<CompassIcon
name='phone-outline'
size={16}
style={style.joinCallButtonIcon}
/>
{alreadyInTheCall &&
<FormattedText
id='call_message.current_call'
defaultMessage='Current Call'
style={style.joinCallButtonText}
/>}
{!alreadyInTheCall &&
<FormattedText
id='call_message.join_call'
defaultMessage='Join Call'
style={style.joinCallButtonText}
/>}
</TouchableOpacity>
</View>
);
};
export default injectIntl(CallMessage);

View file

@ -0,0 +1,55 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {connect} from 'react-redux';
import {bindActionCreators, Dispatch} from 'redux';
import {Preferences} from '@mm-redux/constants';
import {getChannel} from '@mm-redux/selectors/entities/channels';
import {getBool, getTeammateNameDisplaySetting} from '@mm-redux/selectors/entities/preferences';
import {isTimezoneEnabled} from '@mm-redux/selectors/entities/timezone';
import {getUser, getCurrentUser} from '@mm-redux/selectors/entities/users';
import {getUserCurrentTimezone} from '@mm-redux/utils/timezone_utils';
import {joinCall} from '@mmproducts/calls/store/actions/calls';
import {getCalls, getCurrentCall} from '@mmproducts/calls/store/selectors/calls';
import CallMessage from './call_message';
import type {Post} from '@mm-redux/types/posts';
import type {GlobalState} from '@mm-redux/types/store';
import type {Theme} from '@mm-redux/types/theme';
type OwnProps = {
post: Post;
theme: Theme;
}
function mapStateToProps(state: GlobalState, ownProps: OwnProps) {
const {post} = ownProps;
const user = getUser(state, post.user_id);
const currentUser = getCurrentUser(state);
const currentCall = getCurrentCall(state);
const call = getCalls(state)[post.channel_id];
const enableTimezone = isTimezoneEnabled(state);
return {
user,
teammateNameDisplay: getTeammateNameDisplaySetting(state),
confirmToJoin: Boolean(currentCall && call && currentCall.channelId !== call.channelId),
alreadyInTheCall: Boolean(currentCall && call && currentCall.channelId === call.channelId),
isMilitaryTime: getBool(state, Preferences.CATEGORY_DISPLAY_SETTINGS, 'use_military_time'),
userTimezone: enableTimezone ? getUserCurrentTimezone(currentUser.timezone) : undefined,
currentChannelName: getChannel(state, post.channel_id)?.display_name,
callChannelName: currentCall ? getChannel(state, currentCall.channelId)?.display_name : '',
};
}
function mapDispatchToProps(dispatch: Dispatch) {
return {
actions: bindActionCreators({
joinCall,
}, dispatch),
};
}
export default connect(mapStateToProps, mapDispatchToProps)(CallMessage);

View file

@ -0,0 +1,253 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`CurrentCall should match snapshot muted 1`] = `
<View
style={
Object {
"padding": 10,
}
}
>
<View
style={
Object {
"alignItems": "center",
"backgroundColor": "#3F4350",
"borderRadius": 5,
"flexDirection": "row",
"height": 64,
"padding": 4,
"width": "100%",
}
}
>
<CallAvatar
userId="user-1-id"
volume={0.5}
/>
<View
style={
Object {
"flex": 1,
}
}
>
<Text
style={
Object {
"color": "#ffffff",
"fontSize": 16,
"fontWeight": "600",
}
}
>
<InjectIntl(FormattedText)
defaultMessage="{username} is speaking"
id="current_call.user-is-speaking"
values={
Object {
"username": "User 1",
}
}
/>
</Text>
<Text
style={
Object {
"color": "#ffffff",
"opacity": 0.64,
}
}
>
<InjectIntl(FormattedText)
defaultMessage="~{channelName}"
id="current_call.channel-name"
values={
Object {
"channelName": "Channel Name",
}
}
/>
</Text>
</View>
<Pressable
onPressIn={[Function]}
style={
Object {
"zIndex": 10,
}
}
>
<CompassIcon
name="arrow-expand"
size={24}
style={
Object {
"color": "#ffffff",
"marginRight": 8,
"padding": 8,
}
}
/>
</Pressable>
<TouchableOpacity
onPress={[Function]}
style={
Object {
"zIndex": 10,
}
}
>
<CompassIcon
name="microphone-off"
size={24}
style={
Array [
Object {
"backgroundColor": "#3DB887",
"borderRadius": 4,
"color": "#ffffff",
"height": 42,
"justifyContent": "center",
"margin": 4,
"overflow": "hidden",
"padding": 9,
"textAlign": "center",
"textAlignVertical": "center",
"width": 42,
},
Object {
"backgroundColor": "transparent",
},
]
}
/>
</TouchableOpacity>
</View>
</View>
`;
exports[`CurrentCall should match snapshot unmuted 1`] = `
<View
style={
Object {
"padding": 10,
}
}
>
<View
style={
Object {
"alignItems": "center",
"backgroundColor": "#3F4350",
"borderRadius": 5,
"flexDirection": "row",
"height": 64,
"padding": 4,
"width": "100%",
}
}
>
<CallAvatar
userId="user-1-id"
volume={0.5}
/>
<View
style={
Object {
"flex": 1,
}
}
>
<Text
style={
Object {
"color": "#ffffff",
"fontSize": 16,
"fontWeight": "600",
}
}
>
<InjectIntl(FormattedText)
defaultMessage="{username} is speaking"
id="current_call.user-is-speaking"
values={
Object {
"username": "User 1",
}
}
/>
</Text>
<Text
style={
Object {
"color": "#ffffff",
"opacity": 0.64,
}
}
>
<InjectIntl(FormattedText)
defaultMessage="~{channelName}"
id="current_call.channel-name"
values={
Object {
"channelName": "Channel Name",
}
}
/>
</Text>
</View>
<Pressable
onPressIn={[Function]}
style={
Object {
"zIndex": 10,
}
}
>
<CompassIcon
name="arrow-expand"
size={24}
style={
Object {
"color": "#ffffff",
"marginRight": 8,
"padding": 8,
}
}
/>
</Pressable>
<TouchableOpacity
onPress={[Function]}
style={
Object {
"zIndex": 10,
}
}
>
<CompassIcon
name="microphone"
size={24}
style={
Array [
Object {
"backgroundColor": "#3DB887",
"borderRadius": 4,
"color": "#ffffff",
"height": 42,
"justifyContent": "center",
"margin": 4,
"overflow": "hidden",
"padding": 9,
"textAlign": "center",
"textAlignVertical": "center",
"width": 42,
},
undefined,
]
}
/>
</TouchableOpacity>
</View>
</View>
`;

View file

@ -0,0 +1,94 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {shallow} from 'enzyme';
import React from 'react';
import {TouchableOpacity} from 'react-native';
import Preferences from '@mm-redux/constants/preferences';
import CurrentCall from './current_call';
describe('CurrentCall', () => {
const baseProps = {
actions: {
muteMyself: jest.fn(),
unmuteMyself: jest.fn(),
},
theme: Preferences.THEMES.denim,
channel: {
display_name: 'Channel Name',
},
speaker: {
id: 'user-1-id',
muted: false,
isTalking: true,
},
speakerUser: {
id: 'user-1-id',
username: 'user-1-username',
nickname: 'User 1',
},
call: {
participants: {
'user-1-id': {
id: 'user-1-id',
muted: false,
isTalking: false,
},
'user-2-id': {
id: 'user-2-id',
muted: true,
isTalking: true,
},
},
channelId: 'channel-id',
startTime: 100,
speakers: 'user-2-id',
screenOn: false,
threadId: false,
},
currentParticipant: {
id: 'user-2-id',
muted: true,
isTalking: true,
},
teammateNameDisplay: Preferences.DISPLAY_PREFER_NICKNAME,
};
test('should match snapshot muted', () => {
const props = {...baseProps, currentParticipant: {...baseProps.currentParticipant, muted: true}};
const wrapper = shallow(<CurrentCall {...props}/>);
expect(wrapper.getElement()).toMatchSnapshot();
});
test('should match snapshot unmuted', () => {
const props = {...baseProps, currentParticipant: {...baseProps.currentParticipant, muted: false}};
const wrapper = shallow(<CurrentCall {...props}/>);
expect(wrapper.getElement()).toMatchSnapshot();
});
test('should mute on click mute button', () => {
const muteMyself = jest.fn();
const unmuteMyself = jest.fn();
const props = {...baseProps, actions: {muteMyself, unmuteMyself}, currentParticipant: {...baseProps.currentParticipant, muted: false}};
const wrapper = shallow(<CurrentCall {...props}/>);
wrapper.find(TouchableOpacity).simulate('press');
expect(props.actions.muteMyself).toHaveBeenCalled();
expect(props.actions.unmuteMyself).not.toHaveBeenCalled();
});
test('should ask for confirmation on click', () => {
const muteMyself = jest.fn();
const unmuteMyself = jest.fn();
const props = {...baseProps, actions: {unmuteMyself, muteMyself}, currentParticipant: {...baseProps.currentParticipant, muted: true}};
const wrapper = shallow(<CurrentCall {...props}/>);
wrapper.find(TouchableOpacity).simulate('press');
expect(props.actions.muteMyself).not.toHaveBeenCalled();
expect(props.actions.unmuteMyself).toHaveBeenCalled();
});
});

View file

@ -0,0 +1,175 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useEffect} from 'react';
import {View, Text, TouchableOpacity, Pressable, Platform} from 'react-native';
import {Options} from 'react-native-navigation';
import {goToScreen} from '@actions/navigation';
import CompassIcon from '@components/compass_icon';
import FormattedText from '@components/formatted_text';
import ViewTypes, {CURRENT_CALL_BAR_HEIGHT} from '@constants/view';
import {GenericAction} from '@mm-redux/types/actions';
import EventEmitter from '@mm-redux/utils/event_emitter';
import {displayUsername} from '@mm-redux/utils/user_utils';
import CallAvatar from '@mmproducts/calls/components/call_avatar';
import {makeStyleSheetFromTheme} from '@utils/theme';
import type {Channel} from '@mm-redux/types/channels';
import type {Theme} from '@mm-redux/types/theme';
import type {UserProfile} from '@mm-redux/types/users';
import type {Call, CallParticipant} from '@mmproducts/calls/store/types/calls';
type Props = {
actions: {
muteMyself: (channelId: string) => GenericAction;
unmuteMyself: (channelId: string) => GenericAction;
};
theme: Theme;
channel: Channel;
speaker: CallParticipant;
speakerUser: UserProfile;
call: Call;
currentParticipant: CallParticipant;
teammateNameDisplay: string;
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
wrapper: {
padding: 10,
},
container: {
flexDirection: 'row',
backgroundColor: '#3F4350',
width: '100%',
borderRadius: 5,
padding: 4,
height: CURRENT_CALL_BAR_HEIGHT - 10,
alignItems: 'center',
},
pressable: {
zIndex: 10,
},
userInfo: {
flex: 1,
},
speakingUser: {
color: theme.sidebarText,
fontWeight: '600',
fontSize: 16,
},
currentChannel: {
color: theme.sidebarText,
opacity: 0.64,
},
micIcon: {
color: theme.sidebarText,
width: 42,
height: 42,
textAlign: 'center',
textAlignVertical: 'center',
justifyContent: 'center',
backgroundColor: '#3DB887',
borderRadius: 4,
margin: 4,
padding: 9,
overflow: 'hidden',
},
muted: {
backgroundColor: 'transparent',
},
expandIcon: {
color: theme.sidebarText,
padding: 8,
marginRight: 8,
},
};
});
const CurrentCall = (props: Props) => {
useEffect(() => {
EventEmitter.emit(ViewTypes.CURRENT_CALL_BAR_VISIBLE, Boolean(props.call));
return () => {
EventEmitter.emit(ViewTypes.CURRENT_CALL_BAR_VISIBLE, Boolean(false));
};
}, [props.call]);
const goToCallScreen = useCallback(() => {
const options: Options = {
layout: {
backgroundColor: '#000',
componentBackgroundColor: '#000',
orientation: ['portrait', 'landscape'],
},
topBar: {
background: {
color: '#000',
},
visible: Platform.OS === 'android',
},
};
goToScreen('Call', 'Call', {}, options);
}, []);
const muteUnmute = useCallback(() => {
if (props.currentParticipant?.muted) {
props.actions.unmuteMyself(props.call.channelId);
} else {
props.actions.muteMyself(props.call.channelId);
}
}, [props.currentParticipant?.muted]);
if (!props.call) {
return null;
}
const style = getStyleSheet(props.theme);
return (
<View style={style.wrapper}>
<View style={style.container}>
<CallAvatar
userId={props.speaker?.id}
volume={props.speaker?.isTalking ? 0.5 : 0}
/>
<View style={style.userInfo}>
<Text style={style.speakingUser}>
<FormattedText
id='current_call.user-is-speaking'
defaultMessage='{username} is speaking'
values={{username: displayUsername(props.speakerUser, props.teammateNameDisplay)}}
/>
</Text>
<Text style={style.currentChannel}>
<FormattedText
id='current_call.channel-name'
defaultMessage='~{channelName}'
values={{channelName: props.channel.display_name}}
/>
</Text>
</View>
<Pressable
onPressIn={goToCallScreen}
style={style.pressable}
>
<CompassIcon
name='arrow-expand'
size={24}
style={style.expandIcon}
/>
</Pressable>
<TouchableOpacity
onPress={muteUnmute}
style={style.pressable}
>
<CompassIcon
name={props.currentParticipant?.muted ? 'microphone-off' : 'microphone'}
size={24}
style={[style.micIcon, props.currentParticipant?.muted ? style.muted : undefined]}
/>
</TouchableOpacity>
</View>
</View>
);
};
export default CurrentCall;

View file

@ -0,0 +1,42 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {connect} from 'react-redux';
import {bindActionCreators, Dispatch} from 'redux';
import {getChannel} from '@mm-redux/selectors/entities/channels';
import {getTheme, getTeammateNameDisplaySetting} from '@mm-redux/selectors/entities/preferences';
import {getCurrentUserId} from '@mm-redux/selectors/entities/users';
import {muteMyself, unmuteMyself} from '@mmproducts/calls/store/actions/calls';
import {getCurrentCall} from '@mmproducts/calls/store/selectors/calls';
import CurrentCall from './current_call';
import type {GlobalState} from '@mm-redux/types/store';
function mapStateToProps(state: GlobalState) {
const currentCall = getCurrentCall(state);
const currentUserId = getCurrentUserId(state);
const speakerId = currentCall && currentCall.speakers && currentCall.speakers[0];
const speaker = currentCall && ((speakerId && currentCall.participants[speakerId]) || Object.values(currentCall.participants)[0]);
const currentParticipant = currentCall?.participants[currentUserId];
return {
theme: getTheme(state),
call: currentCall,
speaker,
speakerUser: speaker ? state.entities.users.profiles[speaker.id] : null,
channel: getChannel(state, currentCall?.channelId || ''),
currentParticipant,
teammateNameDisplay: getTeammateNameDisplaySetting(state),
};
}
function mapDispatchToProps(dispatch: Dispatch) {
return {
actions: bindActionCreators({
muteMyself,
unmuteMyself,
}, dispatch),
};
}
export default connect(mapStateToProps, mapDispatchToProps)(CurrentCall);

View file

@ -0,0 +1,39 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {shallow} from 'enzyme';
import React from 'react';
import Preferences from '@mm-redux/constants/preferences';
import EnableDisableCalls from './enable_disable_calls';
describe('EnableDisableCalls', () => {
const baseProps = {
testID: 'test-id',
theme: Preferences.THEMES.denim,
onPress: jest.fn(),
canEnableDisableCalls: true,
enabled: false,
};
test('should match snapshot if calls are disabled', () => {
const wrapper = shallow(<EnableDisableCalls {...baseProps}/>);
expect(wrapper.getElement()).toMatchSnapshot();
});
test('should match snapshot if calls are enabled', () => {
const props = {...baseProps, enabled: true};
const wrapper = shallow(<EnableDisableCalls {...props}/>);
expect(wrapper.getElement()).toMatchSnapshot();
});
test('should be null if you can not enable/disable calls', () => {
const props = {...baseProps, canEnableDisableCalls: false};
const wrapper = shallow(<EnableDisableCalls {...props}/>);
expect(wrapper.getElement()).toBeNull();
});
});

View file

@ -0,0 +1,45 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import {Theme} from '@mm-redux/types/theme';
import ChannelInfoRow from '@screens/channel_info/channel_info_row';
import Separator from '@screens/channel_info/separator';
import {t} from '@utils/i18n';
import {preventDoubleTap} from '@utils/tap';
type Props = {
testID?: string;
theme: Theme;
onPress: (channelId: string) => void;
canEnableDisableCalls: boolean;
enabled: boolean;
}
const EnableDisableCalls = (props: Props) => {
const {testID, canEnableDisableCalls, theme, onPress, enabled} = props;
const handleEnableDisableCalls = useCallback(preventDoubleTap(onPress), [onPress]);
if (!canEnableDisableCalls) {
return null;
}
return (
<>
<Separator theme={theme}/>
<ChannelInfoRow
testID={testID}
action={handleEnableDisableCalls}
defaultMessage={enabled ? 'Disable Calls' : 'Enable Calls'}
icon='phone-outline'
textId={enabled ? t('mobile.channel_info.disable_calls') : t('mobile.channel_info.enable_calls')}
theme={theme}
rightArrow={false}
/>
</>
);
};
export default EnableDisableCalls;

View file

@ -0,0 +1,16 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {shallow} from 'enzyme';
import React from 'react';
import {Text} from 'react-native';
import FloatingCallContainer from './floating_call_container';
describe('FloatingCallContainer', () => {
test('should match snapshot', () => {
const wrapper = shallow(<FloatingCallContainer><Text>{'test'}</Text></FloatingCallContainer>);
expect(wrapper.getElement()).toMatchSnapshot();
});
});

View file

@ -0,0 +1,57 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useState, useEffect} from 'react';
import {View, Platform} from 'react-native';
import {ViewTypes} from '@constants';
import EventEmitter from '@mm-redux/utils/event_emitter';
import {makeStyleSheetFromTheme} from '@utils/theme';
const {
IOS_TOP_PORTRAIT,
STATUS_BAR_HEIGHT,
} = ViewTypes;
const getStyleSheet = makeStyleSheetFromTheme(() => {
const topBarHeight = Platform.select({android: 9, ios: IOS_TOP_PORTRAIT - STATUS_BAR_HEIGHT}) || 0;
return {
wrapper: {
position: 'absolute',
top: topBarHeight + ViewTypes.STATUS_BAR_HEIGHT + 27,
width: '100%',
...Platform.select({
android: {
elevation: 9,
},
ios: {
zIndex: 9,
},
}),
},
withIndicatorBar: {
top: topBarHeight + ViewTypes.STATUS_BAR_HEIGHT + 27 + ViewTypes.INDICATOR_BAR_HEIGHT,
},
};
});
type Props = {
children: React.ReactNodeArray;
}
const FloatingCallContainer = (props: Props) => {
const style = getStyleSheet(props);
const [indicatorBarVisible, setIndicatorBarVisible] = useState(false);
useEffect(() => {
EventEmitter.on(ViewTypes.INDICATOR_BAR_VISIBLE, setIndicatorBarVisible);
return () => EventEmitter.off(ViewTypes.INDICATOR_BAR_VISIBLE, setIndicatorBarVisible);
}, []);
return (
<View style={[style.wrapper, indicatorBarVisible ? style.withIndicatorBar : undefined]}>
{props.children}
</View>
);
};
export default FloatingCallContainer;

View file

@ -0,0 +1,89 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`JoinCall should match snapshot 1`] = `
<Pressable
onPress={[Function]}
style={
Object {
"alignItems": "center",
"backgroundColor": "#3DB887",
"flexDirection": "row",
"height": 38,
"justifyContent": "center",
"padding": 5,
"width": "100%",
}
}
>
<CompassIcon
name="phone-in-talk"
size={16}
style={
Object {
"color": "#ffffff",
"marginLeft": 10,
"marginRight": 5,
}
}
/>
<Text
style={
Object {
"color": "#ffffff",
"fontSize": 16,
"fontWeight": "bold",
}
}
>
Join Call
</Text>
<Text
style={
Object {
"color": "#ffffff",
"flex": 1,
"fontWeight": "400",
"marginLeft": 10,
}
}
>
<FormattedRelativeTime
updateIntervalInSeconds={1}
value={100}
/>
</Text>
<View
style={
Object {
"marginRight": 5,
}
}
>
<Connect(Avatars)
breakAt={1}
listTitle={
<InjectIntl(FormattedText)
defaultMessage="CALL PARTICIPANTS"
id="calls.join_call.participants_list_header"
style={
Object {
"color": "rgba(63,67,80,0.56)",
"fontSize": 12,
"fontWeight": "600",
"paddingHorizontal": 16,
"paddingVertical": 0,
"top": 16,
}
}
/>
}
userIds={
Array [
"user-1-id",
"user-2-id",
]
}
/>
</View>
</Pressable>
`;

View file

@ -0,0 +1,37 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {connect} from 'react-redux';
import {bindActionCreators, Dispatch} from 'redux';
import {getChannel, getCurrentChannelId} from '@mm-redux/selectors/entities/channels';
import {getTheme} from '@mm-redux/selectors/entities/preferences';
import {joinCall} from '@mmproducts/calls/store/actions/calls';
import {getCalls, getCurrentCall} from '@mmproducts/calls/store/selectors/calls';
import JoinCall from './join_call';
import type {GlobalState} from '@mm-redux/types/store';
function mapStateToProps(state: GlobalState) {
const currentChannelId = getCurrentChannelId(state);
const call = getCalls(state)[currentChannelId];
const currentCall = getCurrentCall(state);
return {
theme: getTheme(state),
call: call === currentCall ? null : call,
confirmToJoin: Boolean(currentCall && call && currentCall.channelId !== call.channelId),
alreadyInTheCall: Boolean(currentCall && call && currentCall.channelId === call.channelId),
currentChannelName: getChannel(state, currentChannelId)?.display_name,
callChannelName: currentCall ? getChannel(state, currentCall.channelId)?.display_name : '',
};
}
function mapDispatchToProps(dispatch: Dispatch) {
return {
actions: bindActionCreators({
joinCall,
}, dispatch),
};
}
export default connect(mapStateToProps, mapDispatchToProps)(JoinCall);

View file

@ -0,0 +1,75 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {Alert} from 'react-native';
import Preferences from '@mm-redux/constants/preferences';
import {shallowWithIntl} from '@test/intl-test-helper';
import JoinCall from './join_call';
describe('JoinCall', () => {
const baseProps = {
actions: {
joinCall: jest.fn(),
},
theme: Preferences.THEMES.denim,
call: {
participants: {
'user-1-id': {
id: 'user-1-id',
muted: false,
isTalking: false,
},
'user-2-id': {
id: 'user-2-id',
muted: true,
isTalking: true,
},
},
channelId: 'channel-id',
startTime: 100,
speakers: 'user-2-id',
screenOn: false,
threadId: false,
},
confirmToJoin: false,
alreadyInTheCall: false,
currentChannelName: 'Current Channel',
callChannelName: 'Call Channel',
};
test('should match snapshot', () => {
const wrapper = shallowWithIntl(<JoinCall {...baseProps}/>).dive();
expect(wrapper.getElement()).toMatchSnapshot();
});
test('should not show it when I am already in the call', () => {
const props = {...baseProps, alreadyInTheCall: true};
const wrapper = shallowWithIntl(<JoinCall {...props}/>).dive();
expect(wrapper.getElement()).toBeNull();
});
test('should join on click', () => {
const joinCall = jest.fn();
const props = {...baseProps, actions: {joinCall}};
const wrapper = shallowWithIntl(<JoinCall {...props}/>).dive();
wrapper.simulate('press');
expect(Alert.alert).not.toHaveBeenCalled();
expect(props.actions.joinCall).toHaveBeenCalled();
});
test('should ask for confirmation on click', () => {
const joinCall = jest.fn();
const props = {...baseProps, confirmToJoin: true, actions: {joinCall}};
const wrapper = shallowWithIntl(<JoinCall {...props}/>).dive();
wrapper.simulate('press');
expect(Alert.alert).toHaveBeenCalled();
expect(props.actions.joinCall).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,132 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useEffect, useMemo} from 'react';
import {injectIntl, IntlShape} from 'react-intl';
import {View, Text, Pressable} from 'react-native';
import Avatars from '@components/avatars';
import CompassIcon from '@components/compass_icon';
import FormattedRelativeTime from '@components/formatted_relative_time';
import FormattedText from '@components/formatted_text';
import ViewTypes, {JOIN_CALL_BAR_HEIGHT} from '@constants/view';
import EventEmitter from '@mm-redux/utils/event_emitter';
import leaveAndJoinWithAlert from '@mmproducts/calls/components/leave_and_join_alert';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import type {Theme} from '@mm-redux/types/theme';
import type {Call} from '@mmproducts/calls/store/types/calls';
type Props = {
actions: {
joinCall: (channelId: string) => any;
};
theme: Theme;
call: Call;
confirmToJoin: boolean;
alreadyInTheCall: boolean;
currentChannelName: string;
callChannelName: string;
intl: typeof IntlShape;
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
container: {
flexDirection: 'row',
backgroundColor: '#3DB887',
width: '100%',
padding: 5,
justifyContent: 'center',
alignItems: 'center',
height: JOIN_CALL_BAR_HEIGHT,
},
joinCallIcon: {
color: theme.sidebarText,
marginLeft: 10,
marginRight: 5,
},
joinCall: {
color: theme.sidebarText,
fontWeight: 'bold',
fontSize: 16,
},
started: {
flex: 1,
color: theme.sidebarText,
fontWeight: '400',
marginLeft: 10,
},
avatars: {
marginRight: 5,
},
headerText: {
color: changeOpacity(theme.centerChannelColor, 0.56),
fontSize: 12,
fontWeight: '600',
paddingHorizontal: 16,
paddingVertical: 0,
top: 16,
},
};
});
const JoinCall = (props: Props) => {
if (!props.call) {
return null;
}
useEffect(() => {
EventEmitter.emit(ViewTypes.JOIN_CALL_BAR_VISIBLE, Boolean(props.call && !props.alreadyInTheCall));
return () => {
EventEmitter.emit(ViewTypes.JOIN_CALL_BAR_VISIBLE, Boolean(false));
};
}, [props.call, props.alreadyInTheCall]);
const joinHandler = useCallback(() => {
leaveAndJoinWithAlert(props.intl, props.call.channelId, props.callChannelName, props.currentChannelName, props.confirmToJoin, props.actions.joinCall);
}, [props.call.channelId, props.callChannelName, props.currentChannelName, props.confirmToJoin, props.actions.joinCall]);
if (props.alreadyInTheCall) {
return null;
}
const style = getStyleSheet(props.theme);
const userIds = useMemo(() => {
return Object.values(props.call.participants || {}).map((x) => x.id);
}, [props.call.participants]);
return (
<Pressable
style={style.container}
onPress={joinHandler}
>
<CompassIcon
name='phone-in-talk'
size={16}
style={style.joinCallIcon}
/>
<Text style={style.joinCall}>{'Join Call'}</Text>
<Text style={style.started}>
<FormattedRelativeTime
value={props.call.startTime}
updateIntervalInSeconds={1}
/>
</Text>
<View style={style.avatars}>
<Avatars
userIds={userIds}
breakAt={1}
listTitle={
<FormattedText
id='calls.join_call.participants_list_header'
defaultMessage={'CALL PARTICIPANTS'}
style={style.headerText}
/>
}
/>
</View>
</Pressable>
);
};
export default injectIntl(JoinCall);

View file

@ -0,0 +1,29 @@
// 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';
export default function leaveAndJoinWithAlert(intl: typeof IntlShape, channelId: string, callChannelName: string, currentChannelName: string, confirmToJoin: boolean, joinCall: (channelId: string) => void) {
if (confirmToJoin) {
Alert.alert(
intl.formatMessage({id: 'calls.confirm-to-join-title', defaultMessage: 'Are you sure you want to switch to a different call?'}),
intl.formatMessage({
id: 'calls.confirm-to-join-description',
defaultMessage: 'You are already on a channel call in ~{callChannelName}. Do you want to leave your current call and join the call in ~{currentChannelName}?',
}, {callChannelName, currentChannelName}),
[
{
text: intl.formatMessage({id: 'calls.confirm-to-join-cancel', defaultMessage: 'Cancel'}),
},
{
text: intl.formatMessage({id: 'calls.confirm-to-join-leave-and-join', defaultMessage: 'Leave & Join'}),
onPress: () => joinCall(channelId),
style: 'cancel',
},
],
);
} else {
joinCall(channelId);
}
}

View file

@ -0,0 +1,151 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`StartCall should match snapshot 1`] = `
<React.Fragment>
<Separator
theme={
Object {
"awayIndicator": "#ffbc1f",
"buttonBg": "#1c58d9",
"buttonColor": "#ffffff",
"centerChannelBg": "#ffffff",
"centerChannelColor": "#3f4350",
"codeTheme": "github",
"dndIndicator": "#d24b4e",
"errorTextColor": "#d24b4e",
"linkColor": "#386fe5",
"mentionBg": "#ffffff",
"mentionColor": "#1e325c",
"mentionHighlightBg": "#ffd470",
"mentionHighlightLink": "#1b1d22",
"newMessageSeparator": "#cc8f00",
"onlineIndicator": "#3db887",
"sidebarBg": "#1e325c",
"sidebarHeaderBg": "#192a4d",
"sidebarHeaderTextColor": "#ffffff",
"sidebarTeamBarBg": "#14213e",
"sidebarText": "#ffffff",
"sidebarTextActiveBorder": "#5d89ea",
"sidebarTextActiveColor": "#ffffff",
"sidebarTextHoverBg": "#28427b",
"sidebarUnreadText": "#ffffff",
"type": "Denim",
}
}
/>
<channelInfoRow
action={[Function]}
defaultMessage="Start Call"
icon="phone-in-talk"
rightArrow={false}
shouldRender={true}
testID="test-id"
textId="mobile.channel_info.start_call"
theme={
Object {
"awayIndicator": "#ffbc1f",
"buttonBg": "#1c58d9",
"buttonColor": "#ffffff",
"centerChannelBg": "#ffffff",
"centerChannelColor": "#3f4350",
"codeTheme": "github",
"dndIndicator": "#d24b4e",
"errorTextColor": "#d24b4e",
"linkColor": "#386fe5",
"mentionBg": "#ffffff",
"mentionColor": "#1e325c",
"mentionHighlightBg": "#ffd470",
"mentionHighlightLink": "#1b1d22",
"newMessageSeparator": "#cc8f00",
"onlineIndicator": "#3db887",
"sidebarBg": "#1e325c",
"sidebarHeaderBg": "#192a4d",
"sidebarHeaderTextColor": "#ffffff",
"sidebarTeamBarBg": "#14213e",
"sidebarText": "#ffffff",
"sidebarTextActiveBorder": "#5d89ea",
"sidebarTextActiveColor": "#ffffff",
"sidebarTextHoverBg": "#28427b",
"sidebarUnreadText": "#ffffff",
"type": "Denim",
}
}
togglable={false}
/>
</React.Fragment>
`;
exports[`StartCall should match snapshot when there is already an ongoing call in the channel 1`] = `
<React.Fragment>
<Separator
theme={
Object {
"awayIndicator": "#ffbc1f",
"buttonBg": "#1c58d9",
"buttonColor": "#ffffff",
"centerChannelBg": "#ffffff",
"centerChannelColor": "#3f4350",
"codeTheme": "github",
"dndIndicator": "#d24b4e",
"errorTextColor": "#d24b4e",
"linkColor": "#386fe5",
"mentionBg": "#ffffff",
"mentionColor": "#1e325c",
"mentionHighlightBg": "#ffd470",
"mentionHighlightLink": "#1b1d22",
"newMessageSeparator": "#cc8f00",
"onlineIndicator": "#3db887",
"sidebarBg": "#1e325c",
"sidebarHeaderBg": "#192a4d",
"sidebarHeaderTextColor": "#ffffff",
"sidebarTeamBarBg": "#14213e",
"sidebarText": "#ffffff",
"sidebarTextActiveBorder": "#5d89ea",
"sidebarTextActiveColor": "#ffffff",
"sidebarTextHoverBg": "#28427b",
"sidebarUnreadText": "#ffffff",
"type": "Denim",
}
}
/>
<channelInfoRow
action={[Function]}
defaultMessage="Join Ongoing Call"
icon="phone-in-talk"
rightArrow={false}
shouldRender={true}
testID="test-id"
textId="mobile.channel_info.join_ongoing_call"
theme={
Object {
"awayIndicator": "#ffbc1f",
"buttonBg": "#1c58d9",
"buttonColor": "#ffffff",
"centerChannelBg": "#ffffff",
"centerChannelColor": "#3f4350",
"codeTheme": "github",
"dndIndicator": "#d24b4e",
"errorTextColor": "#d24b4e",
"linkColor": "#386fe5",
"mentionBg": "#ffffff",
"mentionColor": "#1e325c",
"mentionHighlightBg": "#ffd470",
"mentionHighlightLink": "#1b1d22",
"newMessageSeparator": "#cc8f00",
"onlineIndicator": "#3db887",
"sidebarBg": "#1e325c",
"sidebarHeaderBg": "#192a4d",
"sidebarHeaderTextColor": "#ffffff",
"sidebarTeamBarBg": "#14213e",
"sidebarText": "#ffffff",
"sidebarTextActiveBorder": "#5d89ea",
"sidebarTextActiveColor": "#ffffff",
"sidebarTextHoverBg": "#28427b",
"sidebarUnreadText": "#ffffff",
"type": "Denim",
}
}
togglable={false}
/>
</React.Fragment>
`;

View file

@ -0,0 +1,34 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {connect} from 'react-redux';
import {bindActionCreators, Dispatch} from 'redux';
import {getChannel, getCurrentChannelId} from '@mm-redux/selectors/entities/channels';
import {joinCall} from '@mmproducts/calls/store/actions/calls';
import {getCalls, getCurrentCall} from '@mmproducts/calls/store/selectors/calls';
import StartCall from './start_call';
import type {GlobalState} from '@mm-redux/types/store';
function mapStateToProps(state: GlobalState) {
const currentChannelId = getCurrentChannelId(state);
const call = getCalls(state)[currentChannelId];
const currentCall = getCurrentCall(state);
return {
confirmToJoin: Boolean(currentCall && currentCall.channelId !== currentChannelId),
alreadyInTheCall: Boolean(currentCall && call && currentCall.channelId === call.channelId),
callChannelName: currentCall ? getChannel(state, currentCall.channelId)?.display_name : '',
ongoingCall: Boolean(call),
};
}
function mapDispatchToProps(dispatch: Dispatch) {
return {
actions: bindActionCreators({
joinCall,
}, dispatch),
};
}
export default connect(mapStateToProps, mapDispatchToProps)(StartCall);

View file

@ -0,0 +1,75 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {Alert, TouchableHighlight} from 'react-native';
import Preferences from '@mm-redux/constants/preferences';
import ChannelInfoRow from '@screens/channel_info/channel_info_row';
import {shallowWithIntl} from '@test/intl-test-helper';
import StartCall from './start_call';
describe('StartCall', () => {
const baseProps = {
actions: {
joinCall: jest.fn(),
},
testID: 'test-id',
theme: Preferences.THEMES.denim,
currentChannelId: 'channel-id',
currentChannelName: 'Channel Name',
canStartCall: true,
callChannelName: 'Call channel name',
confirmToJoin: false,
alreadyInTheCall: false,
ongoingCall: false,
};
test('should match snapshot', () => {
const wrapper = shallowWithIntl(<StartCall {...baseProps}/>).dive();
expect(wrapper.getElement()).toMatchSnapshot();
});
test('should match snapshot when there is already an ongoing call in the channel', () => {
const props = {...baseProps, ongoingCall: true};
const wrapper = shallowWithIntl(<StartCall {...props}/>).dive();
expect(wrapper.getElement()).toMatchSnapshot();
});
test('should be null when you are already in the channel call', () => {
const props = {...baseProps, alreadyInTheCall: true};
const wrapper = shallowWithIntl(<StartCall {...props}/>).dive();
expect(wrapper.getElement()).toBeNull();
});
test('should be null if you can not start a call', () => {
const props = {...baseProps, canStartCall: false};
const wrapper = shallowWithIntl(<StartCall {...props}/>).dive();
expect(wrapper.getElement()).toBeNull();
});
test('should join on click', () => {
const joinCall = jest.fn();
const props = {...baseProps, actions: {joinCall}};
const wrapper = shallowWithIntl(<StartCall {...props}/>).dive();
wrapper.find(ChannelInfoRow).dive().find(TouchableHighlight).simulate('press');
expect(Alert.alert).not.toHaveBeenCalled();
expect(props.actions.joinCall).toHaveBeenCalled();
});
test('should ask for confirmation on click', () => {
const joinCall = jest.fn();
const props = {...baseProps, confirmToJoin: true, actions: {joinCall}};
const wrapper = shallowWithIntl(<StartCall {...props}/>).dive();
wrapper.find(ChannelInfoRow).dive().find(TouchableHighlight).simulate('press');
expect(Alert.alert).toHaveBeenCalled();
expect(props.actions.joinCall).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,61 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import {injectIntl, IntlShape} from 'react-intl';
import {Theme} from '@mm-redux/types/theme';
import leaveAndJoinWithAlert from '@mmproducts/calls/components/leave_and_join_alert';
import ChannelInfoRow from '@screens/channel_info/channel_info_row';
import Separator from '@screens/channel_info/separator';
import {t} from '@utils/i18n';
import {preventDoubleTap} from '@utils/tap';
type Props = {
actions: {
joinCall: (channelId: string) => any;
};
testID?: string;
theme: Theme;
currentChannelId: string;
currentChannelName: string;
callChannelName: string;
confirmToJoin: boolean;
alreadyInTheCall: boolean;
canStartCall: boolean;
ongoingCall: boolean;
intl: typeof IntlShape;
}
const StartCall = (props: Props) => {
const {testID, canStartCall, theme, actions, currentChannelId, callChannelName, currentChannelName, confirmToJoin, alreadyInTheCall, ongoingCall, intl} = props;
const handleStartCall = useCallback(preventDoubleTap(() => {
leaveAndJoinWithAlert(intl, currentChannelId, callChannelName, currentChannelName, confirmToJoin, actions.joinCall);
}), [actions.joinCall, currentChannelId]);
if (!canStartCall) {
return null;
}
if (alreadyInTheCall) {
return null;
}
return (
<>
<Separator theme={theme}/>
<ChannelInfoRow
testID={testID}
action={handleStartCall}
defaultMessage={ongoingCall ? 'Join Ongoing Call' : 'Start Call'}
icon='phone-in-talk'
textId={ongoingCall ? t('mobile.channel_info.join_ongoing_call') : t('mobile.channel_info.start_call')}
theme={theme}
rightArrow={false}
/>
</>
);
};
export default injectIntl(StartCall);

View file

@ -0,0 +1,153 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import InCallManager from 'react-native-incall-manager';
import {
MediaStream,
MediaStreamTrack,
mediaDevices,
} from 'react-native-webrtc2';
import {Client4} from '@client/rest';
import Peer from './simple-peer';
export let client: any = null;
const websocketConnectTimeout = 3000;
function getWSConnectionURL(channelID: string): string {
let url = Client4.getAbsoluteUrl(`/plugins/com.mattermost.calls/${channelID}/ws`);
url = url.replace(/^https:/, 'wss:');
url = url.replace(/^http:/, 'ws:');
return url;
}
export async function newClient(channelID: string, closeCb: () => void, setScreenShareURL: (url: string) => void) {
let peer: any = null;
const streams: MediaStream[] = [];
let stream: MediaStream;
let audioTrack: any;
try {
stream = await mediaDevices.getUserMedia({
video: false,
audio: true,
}) as MediaStream;
audioTrack = stream.getAudioTracks()[0];
audioTrack.enabled = false;
streams.push(stream);
} catch (err) {
console.log('Unable to get media device:', err); // eslint-disable-line no-console
}
const ws = new WebSocket(getWSConnectionURL(channelID));
const disconnect = () => {
ws.close();
streams.forEach((s) => {
s.getTracks().forEach((track: MediaStreamTrack) => {
track.stop();
});
});
if (peer) {
peer.destroy();
}
InCallManager.stop();
if (closeCb) {
closeCb();
}
};
const mute = () => {
if (audioTrack) {
audioTrack.enabled = false;
}
if (ws) {
ws.send(JSON.stringify({
type: 'mute',
}));
}
};
const unmute = () => {
if (audioTrack) {
audioTrack.enabled = true;
}
if (ws) {
ws.send(JSON.stringify({
type: 'unmute',
}));
}
};
ws.onerror = (err) => console.log('WS ERROR', err); // eslint-disable-line no-console
ws.onopen = async () => {
InCallManager.start({media: 'audio'});
peer = new Peer(stream);
peer.on('signal', (data: any) => {
if (data.type === 'offer' || data.type === 'answer') {
ws.send(JSON.stringify({
type: 'signal',
data,
}));
} else if (data.type === 'candidate') {
ws.send(JSON.stringify({
type: 'ice',
data,
}));
}
});
peer.on('stream', (remoteStream: MediaStream) => {
streams.push(remoteStream);
if (remoteStream.getVideoTracks().length > 0) {
setScreenShareURL(remoteStream.toURL());
}
});
peer.on('error', (err: any) => console.log('PEER ERROR', err)); // eslint-disable-line no-console
ws.onmessage = ({data}) => {
const msg = JSON.parse(data);
if (msg.type === 'answer' || msg.type === 'offer') {
peer.signal(data);
}
};
};
const waitForReady = () => {
const waitForReadyImpl = (callback: () => void, fail: () => void, timeout: number) => {
if (timeout <= 0) {
fail();
return;
}
setTimeout(() => {
if (ws.readyState === WebSocket.OPEN) {
callback();
} else {
waitForReadyImpl(callback, fail, timeout - 10);
}
}, 10);
};
const promise = new Promise<void>((resolve, reject) => {
waitForReadyImpl(resolve, reject, websocketConnectTimeout);
});
return promise;
};
client = {
disconnect,
mute,
unmute,
waitForReady,
};
return client;
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,168 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {shallow} from 'enzyme';
import React from 'react';
import Preferences from '@mm-redux/constants/preferences';
import CallScreen from './call_screen';
describe('CallScreen', () => {
const baseProps = {
actions: {
muteMyself: jest.fn(),
unmuteMyself: jest.fn(),
leaveCall: jest.fn(),
},
theme: Preferences.THEMES.denim,
call: {
participants: {
'user-1-id': {
id: 'user-1-id',
muted: false,
isTalking: false,
},
'user-2-id': {
id: 'user-2-id',
muted: true,
isTalking: true,
},
},
channelId: 'channel-id',
startTime: 100,
speakers: 'user-2-id',
screenOn: false,
threadId: false,
},
users: {
'user-1-id': {
id: 'user-1-id',
username: 'user-1-username',
nickname: 'User 1',
},
'user-2-id': {
id: 'user-2-id',
username: 'user-2-username',
nickname: 'User 2',
},
},
currentParticipant: {
id: 'user-2-id',
muted: true,
isTalking: true,
},
teammateNameDisplay: Preferences.DISPLAY_PREFER_NICKNAME,
screenShareURL: '',
};
beforeEach(() => {
jest.doMock('react-native/Libraries/Utilities/useWindowDimensions', () => ({
default: jest.fn().mockReturnValue({width: 800, height: 400}),
}));
});
afterEach(() => {
jest.resetModules();
});
test('should show controls in landscape view on click the users list', () => {
const props = {...baseProps, call: {...baseProps.call, screenOn: false}};
const wrapper = shallow(<CallScreen {...props}/>);
wrapper.find({testID: 'users-list'}).simulate('press');
expect(wrapper.getElement()).toMatchSnapshot();
});
test('should show controls in landscape view on click the screen share', () => {
const props = {...baseProps, call: {...baseProps.call, screenOn: true}, screenShareURL: 'screen-share-url'};
const wrapper = shallow(<CallScreen {...props}/>);
wrapper.find({testID: 'screen-share-container'}).simulate('press');
expect(wrapper.getElement()).toMatchSnapshot();
});
['Portrait', 'Landscape'].forEach((orientation) => {
describe(orientation, () => {
beforeEach(() => {
if (orientation === 'Landscape') {
jest.doMock('react-native/Libraries/Utilities/useWindowDimensions', () => ({
default: jest.fn().mockReturnValue({width: 800, height: 400}),
}));
} else {
jest.doMock('react-native/Libraries/Utilities/useWindowDimensions', () => ({
default: jest.fn().mockReturnValue({width: 400, height: 800}),
}));
}
});
afterEach(() => {
jest.resetModules();
});
test('should match snapshot', () => {
const wrapper = shallow(<CallScreen {...baseProps}/>);
expect(wrapper.getElement()).toMatchSnapshot();
});
test('should match snapshot with screenshare', () => {
const props = {...baseProps, call: {...baseProps.call, screenOn: true}, screenShareURL: 'screen-share-url'};
const wrapper = shallow(<CallScreen {...props}/>);
expect(wrapper.getElement()).toMatchSnapshot();
});
test('should leave on click leave button', () => {
const leaveCall = jest.fn();
const props = {...baseProps, actions: {...baseProps.actions, leaveCall}};
const wrapper = shallow(<CallScreen {...props}/>);
wrapper.find({testID: 'leave'}).simulate('press');
expect(props.actions.leaveCall).toHaveBeenCalled();
});
test('should mute myself on click mute/unmute button if i am not muted', () => {
const muteMyself = jest.fn();
const unmuteMyself = jest.fn();
const props = {
...baseProps,
actions: {
...baseProps.actions,
muteMyself,
unmuteMyself,
},
currentParticipant: {
...baseProps.currentParticipant,
muted: false,
},
};
const wrapper = shallow(<CallScreen {...props}/>);
wrapper.find({testID: 'mute-unmute'}).simulate('press');
expect(props.actions.muteMyself).toHaveBeenCalled();
expect(props.actions.unmuteMyself).not.toHaveBeenCalled();
});
test('should mute myself on click mute/unmute button if i am muted', () => {
const muteMyself = jest.fn();
const unmuteMyself = jest.fn();
const props = {
...baseProps,
actions: {
...baseProps.actions,
muteMyself,
unmuteMyself,
},
currentParticipant: {
...baseProps.currentParticipant,
muted: true,
},
};
const wrapper = shallow(<CallScreen {...props}/>);
wrapper.find({testID: 'mute-unmute'}).simulate('press');
expect(props.actions.muteMyself).not.toHaveBeenCalled();
expect(props.actions.unmuteMyself).toHaveBeenCalled();
});
});
});
});

View file

@ -0,0 +1,461 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useEffect, useCallback, useState} from 'react';
import {Keyboard, View, Text, Platform, Pressable, SafeAreaView, ScrollView, useWindowDimensions} from 'react-native';
import {RTCView} from 'react-native-webrtc2';
import {showModalOverCurrentContext, mergeNavigationOptions, popTopScreen, goToScreen} from '@actions/navigation';
import CompassIcon from '@components/compass_icon';
import FormattedText from '@components/formatted_text';
import {THREAD} from '@constants/screen';
import {GenericAction} from '@mm-redux/types/actions';
import {displayUsername} from '@mm-redux/utils/user_utils';
import CallAvatar from '@mmproducts/calls/components/call_avatar';
import CallDuration from '@mmproducts/calls/components/call_duration';
import {makeStyleSheetFromTheme} from '@utils/theme';
import type {Theme} from '@mm-redux/types/theme';
import type {UserProfile} from '@mm-redux/types/users';
import type {IDMappedObjects} from '@mm-redux/types/utilities';
import type {Call, CallParticipant} from '@mmproducts/calls/store/types/calls';
type Props = {
actions: {
muteMyself: (channelId: string) => GenericAction;
unmuteMyself: (channelId: string) => GenericAction;
leaveCall: () => GenericAction;
};
theme: Theme;
call: Call|null;
users: IDMappedObjects<UserProfile>;
currentParticipant: CallParticipant;
teammateNameDisplay: string;
screenShareURL: string;
}
const getStyleSheet = makeStyleSheetFromTheme((props: any) => {
const showControls = !props.isLandscape || props.showControlsInLandscape;
const buttons: any = {
flexDirection: 'column',
backgroundColor: 'rgba(255,255,255,0.16)',
width: '100%',
paddingBottom: 10,
...Platform.select({
android: {
elevation: 4,
},
ios: {
zIndex: 4,
},
}),
};
if (props.isLandscape) {
buttons.height = 128;
buttons.position = 'absolute';
buttons.backgroundColor = 'rgba(0,0,0,0.64)';
buttons.bottom = 0;
if (!showControls) {
buttons.bottom = 1000;
}
}
const header: any = {
flexDirection: 'row',
width: '100%',
padding: 14,
...Platform.select({
android: {
elevation: 4,
},
ios: {
zIndex: 4,
},
}),
};
if (props.isLandscape) {
header.position = 'absolute';
header.top = 0;
header.backgroundColor = 'rgba(0,0,0,0.64)';
header.height = 64;
header.padding = 0;
if (!showControls) {
header.top = -1000;
}
}
const usersScroll: any = {};
const users: any = {
flex: 1,
flexDirection: 'row',
flexWrap: 'wrap',
width: '100%',
height: '100%',
alignContent: 'center',
alignItems: 'center',
};
if (props.isLandscape && props.call?.screenOn) {
usersScroll.position = 'absolute';
usersScroll.height = 0;
}
return {
wrapper: {
flex: 1,
},
container: {
...Platform.select({
android: {
elevation: 3,
},
ios: {
zIndex: 3,
},
}),
flexDirection: 'column',
backgroundColor: 'black',
width: '100%',
height: '100%',
borderRadius: 5,
alignItems: 'center',
},
header,
time: {
flex: 1,
color: props.theme.sidebarText,
margin: 10,
padding: 10,
},
users,
usersScroll,
user: {
flexGrow: 1,
flexDirection: 'column',
alignItems: 'center',
marginTop: props.call?.screenOn ? 0 : 10,
marginBottom: props.call?.screenOn ? 0 : 10,
marginLeft: 10,
marginRight: 10,
},
username: {
color: props.theme.sidebarText,
},
buttons,
button: {
flexDirection: 'column',
alignItems: 'center',
flex: 1,
},
mute: {
flexDirection: 'column',
alignItems: 'center',
padding: 30,
backgroundColor: props.currentParticipant?.muted ? 'rgba(255,255,255,0.16)' : '#3DB887',
borderRadius: 20,
marginBottom: 10,
marginTop: 20,
marginLeft: 10,
marginRight: 10,
},
otherButtons: {
flexDirection: 'row',
alignItems: 'center',
alignContent: 'space-between',
},
collapseIcon: {
color: props.theme.sidebarText,
margin: 10,
padding: 10,
backgroundColor: 'rgba(255,255,255,0.12)',
borderRadius: 4,
overflow: 'hidden',
},
muteIcon: {
color: props.theme.sidebarText,
},
buttonText: {
color: props.theme.sidebarText,
},
buttonIcon: {
color: props.theme.sidebarText,
backgroundColor: 'rgba(255,255,255,0.12)',
borderRadius: 34,
padding: 22,
width: 68,
height: 68,
margin: 10,
overflow: 'hidden',
},
muteIconLandscape: {
backgroundColor: props.currentParticipant?.muted ? 'rgba(255,255,255,0.16)' : '#3DB887',
},
hangUpIcon: {
backgroundColor: '#D24B4E',
},
screenShareImage: {
flex: 7,
width: '100%',
height: '100%',
alignItems: 'center',
},
screenShareText: {
color: 'white',
margin: 3,
},
};
});
const CallScreen = (props: Props) => {
if (!props.call) {
return null;
}
const {width, height} = useWindowDimensions();
const isLandscape = width > height;
const [showControlsInLandscape, setShowControlsInLandscape] = useState(false);
const style = getStyleSheet({...props, showControlsInLandscape, isLandscape});
useEffect(() => {
mergeNavigationOptions('Call', {
layout: {
componentBackgroundColor: 'black',
},
topBar: {
visible: false,
},
});
}, []);
const showOtherActions = () => {
const screen = 'CallOtherActions';
const passProps = {
};
Keyboard.dismiss();
const otherActionsRequest = requestAnimationFrame(() => {
showModalOverCurrentContext(screen, passProps);
cancelAnimationFrame(otherActionsRequest);
});
};
const minimizeCallHandler = useCallback(() => popTopScreen(), []);
const leaveCallHandler = useCallback(() => {
popTopScreen();
props.actions.leaveCall();
}, [props.actions.leaveCall]);
const openThreadHandler = useCallback(() => {
const passProps = {
channelId: props.call?.channelId,
rootId: props.call?.threadId,
};
goToScreen(THREAD, '', passProps);
}, [props.call]);
const muteUnmuteHandler = useCallback(() => {
if (props.call) {
if (props.currentParticipant?.muted) {
props.actions.unmuteMyself(props.call.channelId);
} else {
props.actions.muteMyself(props.call.channelId);
}
}
}, [props.call.channelId, props.currentParticipant]);
const toggleControlsInLandscape = useCallback(() => {
setShowControlsInLandscape(!showControlsInLandscape);
}, [showControlsInLandscape]);
let screenShareView = null;
if (props.screenShareURL && props.call.screenOn) {
screenShareView = (
<Pressable
testID='screen-share-container'
style={style.screenShareImage}
onPress={toggleControlsInLandscape}
>
<RTCView
streamURL={props.screenShareURL}
style={style.screenShareImage}
/>
<FormattedText
id='call.screen_share_user'
defaultMessage='You are seing {userDisplayName} screen'
values={{userDisplayName: displayUsername(props.users[props.call.screenOn], props.teammateNameDisplay)}}
style={style.screenShareText}
/>
</Pressable>
);
}
let usersList = null;
if (!props.call.screenOn || !isLandscape) {
usersList = (
<ScrollView
alwaysBounceVertical={false}
horizontal={props.call?.screenOn !== ''}
contentContainerStyle={style.usersScroll}
>
<Pressable
testID='users-list'
onPress={toggleControlsInLandscape}
style={style.users}
>
{Object.values(props.call.participants).map((user) => {
return (
<View
style={style.user}
key={user.id}
>
<CallAvatar
userId={user.id}
volume={user.isTalking ? 1 : 0}
muted={user.muted}
size={props.call?.screenOn ? 'm' : 'l'}
/>
<Text style={style.username}>{displayUsername(props.users[user.id], props.teammateNameDisplay)}</Text>
</View>
);
})}
</Pressable>
</ScrollView>
);
}
return (
<SafeAreaView style={style.wrapper}>
<View style={style.container}>
<View style={style.header}>
<CallDuration
style={style.time}
value={props.call.startTime}
updateIntervalInSeconds={1}
/>
<Pressable
onPress={minimizeCallHandler}
>
<CompassIcon
name='arrow-collapse'
size={24}
style={style.collapseIcon}
/>
</Pressable>
</View>
{usersList}
{screenShareView}
<View style={style.buttons}>
{!isLandscape &&
<Pressable
testID='mute-unmute'
style={style.mute}
onPress={muteUnmuteHandler}
>
<CompassIcon
name={props.currentParticipant?.muted ? 'microphone-off' : 'microphone'}
size={24}
style={style.muteIcon}
/>
{props.currentParticipant?.muted &&
<FormattedText
style={style.buttonText}
id='call.unmute'
defaultMessage='Unmute'
/>}
{!props.currentParticipant?.muted &&
<FormattedText
style={style.buttonText}
id='call.mute'
defaultMessage='Mute'
/>}
</Pressable>}
<View style={style.otherButtons}>
<Pressable
testID='leave'
style={style.button}
onPress={leaveCallHandler}
>
<CompassIcon
name='phone-hangup'
size={24}
style={{...style.buttonIcon, ...style.hangUpIcon}}
/>
<FormattedText
style={style.buttonText}
id='call.leave'
defaultMessage='Leave'
/>
</Pressable>
<Pressable
style={style.button}
onPress={openThreadHandler}
>
<CompassIcon
name='message-text-outline'
size={24}
style={style.buttonIcon}
/>
<FormattedText
style={style.buttonText}
id='call.chat_thread'
defaultMessage='Chat thread'
/>
</Pressable>
<Pressable
style={style.button}
>
<CompassIcon
name='settings-outline'
size={24}
style={style.buttonIcon}
/>
<FormattedText
style={style.buttonText}
id='call.settings'
defaultMessage='Settings'
/>
</Pressable>
<Pressable
style={style.button}
onPress={showOtherActions}
>
<CompassIcon
name='dots-horizontal'
size={24}
style={style.buttonIcon}
/>
<FormattedText
style={style.buttonText}
id='call.more'
defaultMessage='More'
/>
</Pressable>
{isLandscape &&
<Pressable
testID='mute-unmute'
style={style.button}
onPress={muteUnmuteHandler}
>
<CompassIcon
name={props.currentParticipant?.muted ? 'microphone-off' : 'microphone'}
size={24}
style={{...style.buttonIcon, ...style.muteIconLandscape}}
/>
{props.currentParticipant?.muted &&
<FormattedText
style={style.buttonText}
id='call.unmute'
defaultMessage='Unmute'
/>}
{!props.currentParticipant?.muted &&
<FormattedText
style={style.buttonText}
id='call.mute'
defaultMessage='Mute'
/>}
</Pressable>}
</View>
</View>
</View>
</SafeAreaView>
);
};
export default CallScreen;

View file

@ -0,0 +1,38 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {connect} from 'react-redux';
import {bindActionCreators, Dispatch} from 'redux';
import {getTheme, getTeammateNameDisplaySetting} from '@mm-redux/selectors/entities/preferences';
import {getCurrentUserId} from '@mm-redux/selectors/entities/users';
import {muteMyself, unmuteMyself, leaveCall} from '@mmproducts/calls//store/actions/calls';
import {getCurrentCall, getScreenShareURL} from '@mmproducts/calls/store/selectors/calls';
import CallScreen from './call_screen';
import type {GlobalState} from '@mm-redux/types/store';
function mapStateToProps(state: GlobalState) {
const currentCall = getCurrentCall(state);
const currentUserId = getCurrentUserId(state);
return {
theme: getTheme(state),
call: currentCall,
teammateNameDisplay: getTeammateNameDisplaySetting(state),
users: state.entities.users.profiles,
currentParticipant: currentCall && currentCall.participants[currentUserId],
screenShareURL: getScreenShareURL(state),
};
}
function mapDispatchToProps(dispatch: Dispatch) {
return {
actions: bindActionCreators({
muteMyself,
unmuteMyself,
leaveCall,
}, dispatch),
};
}
export default connect(mapStateToProps, mapDispatchToProps)(CallScreen);

View file

@ -0,0 +1,189 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Action should match snapshot 1`] = `
<View
style={
Object {
"height": 51,
"width": "100%",
}
}
>
<TouchableHighlight
onPress={[Function]}
style={
Object {
"flex": 1,
"flexDirection": "row",
}
}
testID="action"
underlayColor="rgba(0, 0, 0, 0.1)"
>
<View
style={
Object {
"flex": 1,
"flexDirection": "row",
}
}
>
<View
style={
Object {
"alignItems": "center",
"height": 50,
"justifyContent": "center",
"width": 60,
}
}
>
<CompassIcon
name="test-icon"
size={24}
style={
Array [
Object {
"color": "rgba(63,67,80,0.64)",
},
null,
]
}
/>
</View>
<View
style={
Object {
"flex": 1,
"height": 50,
"justifyContent": "center",
"marginRight": 5,
}
}
>
<Text
style={
Array [
Object {
"color": "#3f4350",
"fontSize": 16,
"letterSpacing": -0.45,
"lineHeight": 19,
"opacity": 0.9,
},
null,
]
}
>
test-text
</Text>
</View>
</View>
</TouchableHighlight>
<View
style={
Object {
"borderBottomColor": "rgba(63,67,80,0.2)",
"borderBottomWidth": 0.5,
"marginHorizontal": 17,
}
}
/>
</View>
`;
exports[`Action should match snapshot when is destructive 1`] = `
<View
style={
Object {
"height": 51,
"width": "100%",
}
}
>
<TouchableHighlight
onPress={[Function]}
style={
Object {
"flex": 1,
"flexDirection": "row",
}
}
testID="action"
underlayColor="rgba(0, 0, 0, 0.1)"
>
<View
style={
Object {
"flex": 1,
"flexDirection": "row",
}
}
>
<View
style={
Object {
"alignItems": "center",
"height": 50,
"justifyContent": "center",
"width": 60,
}
}
>
<CompassIcon
name="test-icon"
size={24}
style={
Array [
Object {
"color": "rgba(63,67,80,0.64)",
},
Object {
"color": "#D0021B",
},
]
}
/>
</View>
<View
style={
Object {
"flex": 1,
"height": 50,
"justifyContent": "center",
"marginRight": 5,
}
}
>
<Text
style={
Array [
Object {
"color": "#3f4350",
"fontSize": 16,
"letterSpacing": -0.45,
"lineHeight": 19,
"opacity": 0.9,
},
Object {
"color": "#D0021B",
},
]
}
>
test-text
</Text>
</View>
</View>
</TouchableHighlight>
<View
style={
Object {
"borderBottomColor": "rgba(63,67,80,0.2)",
"borderBottomWidth": 0.5,
"marginHorizontal": 17,
}
}
/>
</View>
`;

View file

@ -0,0 +1,151 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`CallOtherActions should match snapshot 1`] = `
<View
style={
Object {
"flex": 1,
}
}
>
<Connect(SlideUpPanel)
initialPosition={0.24}
onRequestClose={[Function]}
theme={
Object {
"awayIndicator": "#ffbc1f",
"buttonBg": "#1c58d9",
"buttonColor": "#ffffff",
"centerChannelBg": "#ffffff",
"centerChannelColor": "#3f4350",
"codeTheme": "github",
"dndIndicator": "#d24b4e",
"errorTextColor": "#d24b4e",
"linkColor": "#386fe5",
"mentionBg": "#ffffff",
"mentionColor": "#1e325c",
"mentionHighlightBg": "#ffd470",
"mentionHighlightLink": "#1b1d22",
"newMessageSeparator": "#cc8f00",
"onlineIndicator": "#3db887",
"sidebarBg": "#1e325c",
"sidebarHeaderBg": "#192a4d",
"sidebarHeaderTextColor": "#ffffff",
"sidebarTeamBarBg": "#14213e",
"sidebarText": "#ffffff",
"sidebarTextActiveBorder": "#5d89ea",
"sidebarTextActiveColor": "#ffffff",
"sidebarTextHoverBg": "#28427b",
"sidebarUnreadText": "#ffffff",
"type": "Denim",
}
}
>
<Action
destructive={false}
icon="account-plus-outline"
onPress={[Function]}
text="Add participants"
theme={
Object {
"awayIndicator": "#ffbc1f",
"buttonBg": "#1c58d9",
"buttonColor": "#ffffff",
"centerChannelBg": "#ffffff",
"centerChannelColor": "#3f4350",
"codeTheme": "github",
"dndIndicator": "#d24b4e",
"errorTextColor": "#d24b4e",
"linkColor": "#386fe5",
"mentionBg": "#ffffff",
"mentionColor": "#1e325c",
"mentionHighlightBg": "#ffd470",
"mentionHighlightLink": "#1b1d22",
"newMessageSeparator": "#cc8f00",
"onlineIndicator": "#3db887",
"sidebarBg": "#1e325c",
"sidebarHeaderBg": "#192a4d",
"sidebarHeaderTextColor": "#ffffff",
"sidebarTeamBarBg": "#14213e",
"sidebarText": "#ffffff",
"sidebarTextActiveBorder": "#5d89ea",
"sidebarTextActiveColor": "#ffffff",
"sidebarTextHoverBg": "#28427b",
"sidebarUnreadText": "#ffffff",
"type": "Denim",
}
}
/>
<Action
destructive={false}
icon="link-variant"
onPress={[Function]}
text="Copy call link"
theme={
Object {
"awayIndicator": "#ffbc1f",
"buttonBg": "#1c58d9",
"buttonColor": "#ffffff",
"centerChannelBg": "#ffffff",
"centerChannelColor": "#3f4350",
"codeTheme": "github",
"dndIndicator": "#d24b4e",
"errorTextColor": "#d24b4e",
"linkColor": "#386fe5",
"mentionBg": "#ffffff",
"mentionColor": "#1e325c",
"mentionHighlightBg": "#ffd470",
"mentionHighlightLink": "#1b1d22",
"newMessageSeparator": "#cc8f00",
"onlineIndicator": "#3db887",
"sidebarBg": "#1e325c",
"sidebarHeaderBg": "#192a4d",
"sidebarHeaderTextColor": "#ffffff",
"sidebarTeamBarBg": "#14213e",
"sidebarText": "#ffffff",
"sidebarTextActiveBorder": "#5d89ea",
"sidebarTextActiveColor": "#ffffff",
"sidebarTextHoverBg": "#28427b",
"sidebarUnreadText": "#ffffff",
"type": "Denim",
}
}
/>
<Action
destructive={false}
icon="send-outline"
onPress={[Function]}
text="Give Feedback"
theme={
Object {
"awayIndicator": "#ffbc1f",
"buttonBg": "#1c58d9",
"buttonColor": "#ffffff",
"centerChannelBg": "#ffffff",
"centerChannelColor": "#3f4350",
"codeTheme": "github",
"dndIndicator": "#d24b4e",
"errorTextColor": "#d24b4e",
"linkColor": "#386fe5",
"mentionBg": "#ffffff",
"mentionColor": "#1e325c",
"mentionHighlightBg": "#ffd470",
"mentionHighlightLink": "#1b1d22",
"newMessageSeparator": "#cc8f00",
"onlineIndicator": "#3db887",
"sidebarBg": "#1e325c",
"sidebarHeaderBg": "#192a4d",
"sidebarHeaderTextColor": "#ffffff",
"sidebarTeamBarBg": "#14213e",
"sidebarText": "#ffffff",
"sidebarTextActiveBorder": "#5d89ea",
"sidebarTextActiveColor": "#ffffff",
"sidebarTextHoverBg": "#28427b",
"sidebarUnreadText": "#ffffff",
"type": "Denim",
}
}
/>
</Connect(SlideUpPanel)>
</View>
`;

View file

@ -0,0 +1,42 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {shallow} from 'enzyme';
import React from 'react';
import Preferences from '@mm-redux/constants/preferences';
import Action from './action';
describe('Action', () => {
const baseProps = {
theme: Preferences.THEMES.denim,
destructive: false,
icon: 'test-icon',
onPress: jest.fn(),
text: 'test-text',
};
test('should match snapshot', () => {
const wrapper = shallow(<Action {...baseProps}/>);
expect(wrapper.getElement()).toMatchSnapshot();
});
test('should match snapshot when is destructive', () => {
const props = {...baseProps, destructive: true};
const wrapper = shallow(<Action {...props}/>);
expect(wrapper.getElement()).toMatchSnapshot();
});
test('should call on callback on press', () => {
const onPress = jest.fn();
const props = {...baseProps, onPress};
const wrapper = shallow(<Action {...props}/>);
wrapper.find({testID: 'action'}).simulate('press');
expect(onPress).toBeCalled();
});
});

View file

@ -0,0 +1,129 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {
Text,
Platform,
TouchableHighlight,
TouchableNativeFeedback,
View,
} from 'react-native';
import CompassIcon from '@components/compass_icon';
import {preventDoubleTap} from '@utils/tap';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import type {Theme} from '@mm-redux/types/theme';
type Props = {
destructive: boolean;
icon: string;
onPress: () => null;
text: string;
theme: Theme;
};
const Action = (props: Props) => {
const handleOnPress = preventDoubleTap(() => {
props.onPress();
}, 500);
const {destructive, icon, text, theme} = props;
const style = getStyleSheet(theme);
const Touchable = Platform.select({
ios: TouchableHighlight as any,
android: TouchableNativeFeedback as any,
});
const touchableProps = Platform.select({
ios: {
underlayColor: 'rgba(0, 0, 0, 0.1)',
},
android: {
background: TouchableNativeFeedback.Ripple( //eslint-disable-line new-cap
'rgba(0, 0, 0, 0.1)',
false,
),
},
});
return (
<View
style={style.container}
>
<Touchable
testID='action'
onPress={handleOnPress}
{...touchableProps}
style={style.row}
>
<View style={style.row}>
<View style={style.iconContainer}>
<CompassIcon
name={icon}
size={24}
style={[style.icon, destructive ? style.destructive : null]}
/>
</View>
<View style={style.textContainer}>
<Text style={[style.text, destructive ? style.destructive : null]}>
{text}
</Text>
</View>
</View>
</Touchable>
<View style={style.footer}/>
</View>
);
};
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
container: {
height: 51,
width: '100%',
},
destructive: {
color: '#D0021B',
},
row: {
flex: 1,
flexDirection: 'row',
},
iconContainer: {
alignItems: 'center',
height: 50,
justifyContent: 'center',
width: 60,
},
noIconContainer: {
height: 50,
width: 18,
},
icon: {
color: changeOpacity(theme.centerChannelColor, 0.64),
},
textContainer: {
justifyContent: 'center',
flex: 1,
height: 50,
marginRight: 5,
},
text: {
color: theme.centerChannelColor,
fontSize: 16,
lineHeight: 19,
opacity: 0.9,
letterSpacing: -0.45,
},
footer: {
marginHorizontal: 17,
borderBottomWidth: 0.5,
borderBottomColor: changeOpacity(theme.centerChannelColor, 0.2),
},
};
});
export default Action;

View file

@ -0,0 +1,21 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import Preferences from '@mm-redux/constants/preferences';
import {shallowWithIntl} from '@test/intl-test-helper';
import CallOtherActions from './call_other_actions';
describe('CallOtherActions', () => {
const baseProps = {
theme: Preferences.THEMES.denim,
};
test('should match snapshot', () => {
const wrapper = shallowWithIntl(<CallOtherActions {...baseProps}/>).dive();
expect(wrapper.getElement()).toMatchSnapshot();
});
});

View file

@ -0,0 +1,66 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import {injectIntl, intlShape} from 'react-intl';
import {View} from 'react-native';
import {dismissModal} from '@actions/navigation';
import SlideUpPanel from '@components/slide_up_panel';
import Action from './action';
import type {Theme} from '@mm-redux/types/theme';
type Props = {
theme: Theme;
intl: typeof intlShape;
}
const CallOtherActions = ({theme, intl}: Props) => {
const close = () => {
dismissModal();
};
// TODO: Implement this whenever we support participants invitation to calls
const addParticipants = useCallback(() => null, []);
// TODO: Implement this whenever we support calls links
const copyCallLink = useCallback(() => null, []);
// TODO: Implement this whenever we support give feedback
const giveFeedback = useCallback(() => null, []);
return (
<View style={{flex: 1}}>
<SlideUpPanel
onRequestClose={close}
initialPosition={0.24}
theme={theme}
>
<Action
destructive={false}
icon='account-plus-outline'
onPress={addParticipants}
text={intl.formatMessage({id: 'call.add_participants', defaultMessage: 'Add participants'})}
theme={theme}
/>
<Action
destructive={false}
icon='link-variant'
onPress={copyCallLink}
text={intl.formatMessage({id: 'call.copy_call_link', defaultMessage: 'Copy call link'})}
theme={theme}
/>
<Action
destructive={false}
icon='send-outline'
onPress={giveFeedback}
text={intl.formatMessage({id: 'call.give_feedback', defaultMessage: 'Give Feedback'})}
theme={theme}
/>
</SlideUpPanel>
</View>
);
};
export default injectIntl(CallOtherActions);

View file

@ -0,0 +1,18 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {connect} from 'react-redux';
import {getTheme} from '@mm-redux/selectors/entities/preferences';
import CallOtherActions from './call_other_actions';
import type {GlobalState} from '@mm-redux/types/store';
function mapStateToProps(state: GlobalState) {
return {
theme: getTheme(state),
};
}
export default connect(mapStateToProps)(CallOtherActions);

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,25 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import keyMirror from '@mm-redux/utils/key_mirror';
export default keyMirror({
RECEIVED_CALLS: null,
RECEIVED_CALL_STARTED: null,
RECEIVED_CALL_FINISHED: null,
RECEIVED_CHANNEL_CALL_ENABLED: null,
RECEIVED_CHANNEL_CALL_DISABLED: null,
RECEIVED_CHANNEL_CALL_SCREEN_ON: null,
RECEIVED_CHANNEL_CALL_SCREEN_OFF: null,
RECEIVED_JOINED_CALL: null,
RECEIVED_LEFT_CALL: null,
RECEIVED_MYSELF_JOINED_CALL: null,
RECEIVED_MYSELF_LEFT_CALL: null,
RECEIVED_MUTE_USER_CALL: null,
RECEIVED_UNMUTE_USER_CALL: null,
RECEIVED_VOICE_ON_USER_CALL: null,
RECEIVED_VOICE_OFF_USER_CALL: null,
RECEIVED_RAISE_HAND_CALL: null,
RECEIVED_UNRAISE_HAND_CALL: null,
SET_SCREENSHARE_URL: null,
});

View file

@ -0,0 +1,144 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import assert from 'assert';
import {Client4} from '@client/rest';
import configureStore from '@test/test_store';
import CallsTypes from '../action_types/calls';
import * as CallsActions from './calls';
jest.mock('@client/rest', () => ({
Client4: {
setUrl: jest.fn(),
getCalls: jest.fn(() => [
{
call: {
users: ['user-1', 'user-2'],
states: {
'user-1': {unmuted: true},
'user-2': {unmuted: false},
},
start_at: 123,
screen_sharing_id: '',
thread_id: 'thread-1',
},
channel_id: 'channel-1',
enabled: true,
},
]),
enableChannelCalls: jest.fn(() => null),
disableChannelCalls: jest.fn(() => null),
},
}));
jest.mock('@mmproducts/calls/connection', () => ({
newClient: jest.fn(() => Promise.resolve({
disconnect: jest.fn(),
mute: jest.fn(),
unmute: jest.fn(),
waitForReady: jest.fn(() => Promise.resolve()),
})),
}));
export function addFakeCall(channelId) {
return {
type: CallsTypes.RECEIVED_CALL_STARTED,
data: {
participants: {
xohi8cki9787fgiryne716u84o: {id: 'xohi8cki9787fgiryne716u84o', isTalking: true, muted: false},
xohi8cki9787fgiryne716u841: {id: 'xohi8cki9787fgiryne716u84o', isTalking: true, muted: true},
xohi8cki9787fgiryne716u842: {id: 'xohi8cki9787fgiryne716u84o', isTalking: false, uted: false},
xohi8cki9787fgiryne716u843: {id: 'xohi8cki9787fgiryne716u84o', isTalking: false, muted: true},
xohi8cki9787fgiryne716u844: {id: 'xohi8cki9787fgiryne716u84o', isTalking: true, muted: false},
xohi8cki9787fgiryne716u845: {id: 'xohi8cki9787fgiryne716u84o', isTalking: true, muted: true},
},
channelId,
startTime: (new Date()).getTime(),
},
};
}
describe('Actions.Calls', () => {
let store;
const {newClient} = require('@mmproducts/calls/connection');
beforeEach(async () => {
newClient.mockClear();
Client4.setUrl.mockClear();
Client4.getCalls.mockClear();
Client4.enableChannelCalls.mockClear();
Client4.disableChannelCalls.mockClear();
store = await configureStore();
});
it('joinCall', async () => {
await store.dispatch(addFakeCall('channel-id'));
const response = await store.dispatch(CallsActions.joinCall('channel-id'));
const result = store.getState().entities.calls.joined;
assert.equal('channel-id', result);
assert.equal(response.data, 'channel-id');
expect(newClient).toBeCalled();
expect(newClient.mock.calls[0][0]).toBe('channel-id');
await store.dispatch(CallsActions.leaveCall());
});
it('leaveCall', async () => {
await store.dispatch(addFakeCall('channel-id'));
expect(CallsActions.ws).toBe(null);
await store.dispatch(CallsActions.joinCall('channel-id'));
let result = store.getState().entities.calls.joined;
assert.equal('channel-id', result);
expect(CallsActions.ws.disconnect).not.toBeCalled();
const disconnectMock = CallsActions.ws.disconnect;
await store.dispatch(CallsActions.leaveCall());
expect(disconnectMock).toBeCalled();
expect(CallsActions.ws).toBe(null);
result = store.getState().entities.calls.joined;
assert.equal('', result);
});
it('muteMyself', async () => {
await store.dispatch(addFakeCall('channel-id'));
await store.dispatch(CallsActions.joinCall('channel-id'));
await store.dispatch(CallsActions.muteMyself());
expect(CallsActions.ws.mute).toBeCalled();
await store.dispatch(CallsActions.leaveCall());
});
it('unmuteMyself', async () => {
await store.dispatch(addFakeCall('channel-id'));
await store.dispatch(CallsActions.joinCall('channel-id'));
await store.dispatch(CallsActions.unmuteMyself());
expect(CallsActions.ws.unmute).toBeCalled();
await store.dispatch(CallsActions.leaveCall());
});
it('loadCalls', async () => {
await store.dispatch(CallsActions.loadCalls());
expect(Client4.getCalls).toBeCalledWith();
assert.equal(store.getState().entities.calls.calls['channel-1'].channelId, 'channel-1');
assert.equal(store.getState().entities.calls.enabled['channel-1'], true);
});
it('enableChannelCalls', async () => {
assert.equal(store.getState().entities.calls.enabled['channel-1'], undefined);
await store.dispatch(CallsActions.enableChannelCalls('channel-1'));
expect(Client4.enableChannelCalls).toBeCalledWith('channel-1');
});
it('disableChannelCalls', async () => {
assert.equal(store.getState().entities.calls.enabled['channel-1'], undefined);
await store.dispatch(CallsActions.enableChannelCalls('channel-1'));
assert.equal(store.getState().entities.calls.enabled['channel-1'], true);
expect(Client4.disableChannelCalls).not.toBeCalledWith('channel-1');
await store.dispatch(CallsActions.disableChannelCalls('channel-1'));
expect(Client4.disableChannelCalls).toBeCalledWith('channel-1');
assert.equal(store.getState().entities.calls.enabled['channel-1'], false);
});
});

View file

@ -0,0 +1,150 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Client4} from '@client/rest';
import {logError} from '@mm-redux/actions/errors';
import {forceLogoutIfNecessary} from '@mm-redux/actions/helpers';
import {GenericAction, ActionFunc, DispatchFunc, GetStateFunc} from '@mm-redux/types/actions';
import {Dictionary} from '@mm-redux/types/utilities';
import {newClient} from '@mmproducts/calls/connection';
import CallsTypes from '@mmproducts/calls/store/action_types/calls';
import type {Call, CallParticipant} from '@mmproducts/calls/store/types/calls';
export let ws: any = null;
export function loadCalls(): ActionFunc {
return async (dispatch: DispatchFunc, getState: GetStateFunc) => {
let resp = [];
try {
resp = await Client4.getCalls();
} catch (error) {
forceLogoutIfNecessary(error, dispatch, getState);
dispatch(logError(error));
return {error};
}
const callsResults: Dictionary<Call> = {};
const enabledChannels: Dictionary<boolean> = {};
for (let i = 0; i < resp.length; i++) {
const channel = resp[i];
if (channel.call) {
callsResults[channel.channel_id] = {
participants: channel.call.users.reduce((prev: Dictionary<CallParticipant>, cur: string, curIdx: number) => {
const muted = channel.call.states && channel.call.states[curIdx] ? !channel.call.states[curIdx].unmuted : true;
prev[cur] = {id: cur, muted, isTalking: false};
return prev;
}, {}),
channelId: channel.channel_id,
startTime: channel.call.start_at,
speakers: [],
screenOn: channel.call.screen_sharing_id,
threadId: channel.call.thread_id,
};
}
enabledChannels[channel.channel_id] = channel.enabled;
}
const data = {
calls: callsResults,
enabled: enabledChannels,
};
dispatch({type: CallsTypes.RECEIVED_CALLS, data});
return {data};
};
}
export function enableChannelCalls(channelId: string): ActionFunc {
return async (dispatch: DispatchFunc, getState: GetStateFunc) => {
try {
await Client4.enableChannelCalls(channelId);
} catch (error) {
forceLogoutIfNecessary(error, dispatch, getState);
dispatch(logError(error));
return {error};
}
dispatch({type: CallsTypes.RECEIVED_CHANNEL_CALL_ENABLED, data: channelId});
return {data: channelId};
};
}
export function disableChannelCalls(channelId: string): ActionFunc {
return async (dispatch: DispatchFunc, getState: GetStateFunc) => {
try {
await Client4.disableChannelCalls(channelId);
} catch (error) {
forceLogoutIfNecessary(error, dispatch, getState);
dispatch(logError(error));
return {error};
}
dispatch({type: CallsTypes.RECEIVED_CHANNEL_CALL_DISABLED, data: channelId});
return {data: channelId};
};
}
export function joinCall(channelId: string): ActionFunc {
return async (dispatch: DispatchFunc, getState: GetStateFunc) => {
const setScreenShareURL = (url: string) => {
dispatch({
type: CallsTypes.SET_SCREENSHARE_URL,
data: url,
});
};
if (ws) {
ws.disconnect();
ws = null;
}
try {
ws = await newClient(channelId, () => null, setScreenShareURL);
} catch (error) {
forceLogoutIfNecessary(error, dispatch, getState);
dispatch(logError(error));
return {error};
}
try {
await ws.waitForReady();
dispatch({
type: CallsTypes.RECEIVED_MYSELF_JOINED_CALL,
data: channelId,
});
return {data: channelId};
} catch (e) {
ws.disconnect();
ws = null;
return {error: 'unable to connect to the voice call'};
}
};
}
export function leaveCall(): GenericAction {
if (ws) {
ws.disconnect();
ws = null;
}
return {
type: CallsTypes.RECEIVED_MYSELF_LEFT_CALL,
};
}
export function muteMyself(): GenericAction {
if (ws) {
ws.mute();
}
return {type: 'empty'};
}
export function unmuteMyself(): GenericAction {
if (ws) {
ws.unmute();
}
return {type: 'empty'};
}

View file

@ -0,0 +1,83 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {GenericAction} from '@mm-redux/types/actions';
import {WebSocketMessage} from '@mm-redux/types/websocket';
import CallsTypes from '@mmproducts/calls/store/action_types/calls';
export function handleCallUserDisconnected(msg: WebSocketMessage): GenericAction {
return {
type: CallsTypes.RECEIVED_LEFT_CALL,
data: {channelId: msg.broadcast.channel_id, userId: msg.data.userID},
};
}
export function handleCallUserConnected(msg: WebSocketMessage): GenericAction {
return {
type: CallsTypes.RECEIVED_JOINED_CALL,
data: {channelId: msg.broadcast.channel_id, userId: msg.data.userID},
};
}
export function handleCallUserMuted(msg: WebSocketMessage): GenericAction {
return {
type: CallsTypes.RECEIVED_MUTE_USER_CALL,
data: {channelId: msg.broadcast.channel_id, userId: msg.data.userID},
};
}
export function handleCallUserUnmuted(msg: WebSocketMessage): GenericAction {
return {
type: CallsTypes.RECEIVED_UNMUTE_USER_CALL,
data: {channelId: msg.broadcast.channel_id, userId: msg.data.userID},
};
}
export function handleCallUserVoiceOn(msg: WebSocketMessage): GenericAction {
return {
type: CallsTypes.RECEIVED_VOICE_ON_USER_CALL,
data: {channelId: msg.broadcast.channel_id, userId: msg.data.userID},
};
}
export function handleCallUserVoiceOff(msg: WebSocketMessage): GenericAction {
return {
type: CallsTypes.RECEIVED_VOICE_OFF_USER_CALL,
data: {channelId: msg.broadcast.channel_id, userId: msg.data.userID},
};
}
export function handleCallStarted(msg: WebSocketMessage): GenericAction {
return {
type: CallsTypes.RECEIVED_CALL_STARTED,
data: {channelId: msg.data.channelID, startTime: msg.data.start_at, threadId: msg.data.thread_id, participants: {}},
};
}
export function handleCallChannelEnabled(msg: WebSocketMessage): GenericAction {
return {
type: CallsTypes.RECEIVED_CHANNEL_CALL_ENABLED,
data: msg.broadcast.channel_id,
};
}
export function handleCallChannelDisabled(msg: WebSocketMessage): GenericAction {
return {
type: CallsTypes.RECEIVED_CHANNEL_CALL_DISABLED,
data: msg.broadcast.channel_id,
};
}
export function handleCallScreenOn(msg: WebSocketMessage): GenericAction {
return {
type: CallsTypes.RECEIVED_CHANNEL_CALL_SCREEN_ON,
data: {channelId: msg.broadcast.channel_id, userId: msg.data.userID},
};
}
export function handleCallScreenOff(msg: WebSocketMessage): GenericAction {
return {
type: CallsTypes.RECEIVED_CHANNEL_CALL_SCREEN_OFF,
data: {channelId: msg.broadcast.channel_id, userId: msg.data.userID},
};
}

View file

@ -0,0 +1,362 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import assert from 'assert';
import CallsTypes from '@mmproducts/calls/store/action_types/calls';
import callsReducer from './calls';
describe('Reducers.calls.calls', () => {
const call1 = {
participants: {
'user-1': {id: 'user-1', muted: false, isTalking: false},
'user-2': {id: 'user-2', muted: true, isTalking: true},
},
channelId: 'channel-1',
startTime: 123,
speakers: ['user-2'],
screenOn: '',
threadId: 'thread-1',
};
const call2 = {
participants: {
'user-3': {id: 'user-3', muted: false, isTalking: false},
'user-4': {id: 'user-4', muted: true, isTalking: true},
},
channelId: 'channel-2',
startTime: 123,
speakers: ['user-4'],
screenOn: '',
threadId: 'thread-2',
};
const call3 = {
participants: {
'user-5': {id: 'user-5', muted: false, isTalking: false},
'user-6': {id: 'user-6', muted: true, isTalking: true},
},
channelId: 'channel-3',
startTime: 123,
speakers: ['user-6'],
screenOn: '',
threadId: 'thread-3',
};
it('initial state', async () => {
let state = {};
state = callsReducer(state, {});
assert.deepEqual(state.calls, {}, 'initial state');
});
it('RECEIVED_CALLS', async () => {
let state = {calls: {calls: {'channel-1': call1}}, enabled: {'channel-1': true}, joined: 'channel-1'};
const testAction = {
type: CallsTypes.RECEIVED_CALLS,
data: {calls: {'channel-1': call2, 'channel-2': call3}, enabled: {'channel-1': true}},
};
state = callsReducer(state, testAction);
assert.deepEqual(state.calls, {'channel-1': call2, 'channel-2': call3});
});
it('RECEIVED_LEFT_CALL', async () => {
const initialState = {calls: {'channel-1': call1}};
const testAction = {
type: CallsTypes.RECEIVED_LEFT_CALL,
data: {channelId: 'channel-1', userId: 'user-1'},
};
let state = callsReducer(initialState, testAction);
assert.deepEqual(
state.calls,
{
'channel-1': {
participants: {
'user-2': {id: 'user-2', muted: true, isTalking: true},
},
channelId: 'channel-1',
startTime: 123,
speakers: ['user-2'],
screenOn: false,
threadId: 'thread-1',
},
},
);
testAction.data = {channelId: 'channel-1', userId: 'not-valid-user'};
state = callsReducer(initialState, testAction);
assert.deepEqual(state.calls, {'channel-1': call1});
testAction.data = {channelId: 'invalid-channel', userId: 'user-1'};
state = callsReducer(initialState, testAction);
assert.deepEqual(state.calls, {'channel-1': call1});
});
it('RECEIVED_JOINED_CALL', async () => {
const initialState = {calls: {'channel-1': call1}};
const testAction = {
type: CallsTypes.RECEIVED_JOINED_CALL,
data: {channelId: 'channel-1', userId: 'user-3'},
};
let state = callsReducer(initialState, testAction);
assert.deepEqual(
state.calls,
{
'channel-1': {
participants: {
'user-1': {id: 'user-1', muted: false, isTalking: false},
'user-2': {id: 'user-2', muted: true, isTalking: true},
'user-3': {id: 'user-3', muted: true, isTalking: false},
},
channelId: 'channel-1',
startTime: 123,
speakers: ['user-2'],
screenOn: false,
threadId: 'thread-1',
},
},
);
testAction.data = {channelId: 'invalid-channel', userId: 'user-1'};
state = callsReducer(initialState, testAction);
assert.deepEqual(state.calls, {'channel-1': call1});
});
it('RECEIVED_CALL_STARTED', async () => {
const initialState = {calls: {}};
const testAction = {
type: CallsTypes.RECEIVED_CALL_STARTED,
data: call1,
};
const state = callsReducer(initialState, testAction);
assert.deepEqual(state.calls, {'channel-1': call1});
});
it('RECEIVED_CALL_FINISHED', async () => {
const initialState = {calls: {'channel-1': call1, 'channel-2': call2}};
const testAction = {
type: CallsTypes.RECEIVED_CALL_FINISHED,
data: call1,
};
const state = callsReducer(initialState, testAction);
assert.deepEqual(state.calls, {'channel-2': call2});
});
it('RECEIVED_MUTE_USER_CALL', async () => {
const initialState = {calls: {'channel-1': call1, 'channel-2': call2}};
const testAction = {
type: CallsTypes.RECEIVED_MUTE_USER_CALL,
data: {channelId: 'channel-1', userId: 'user-1'},
};
let state = callsReducer(initialState, testAction);
assert.equal(state.calls['channel-1'].participants['user-1'].muted, true);
testAction.data = {channelId: 'channel-1', userId: 'invalidUser'};
state = callsReducer(initialState, testAction);
assert.deepEqual(state.calls, initialState.calls);
testAction.data = {channelId: 'invalid-channel', userId: 'user-1'};
state = callsReducer(initialState, testAction);
assert.deepEqual(state.calls, initialState.calls);
});
it('RECEIVED_UNMUTE_USER_CALL', async () => {
const initialState = {calls: {'channel-1': call1, 'channel-2': call2}};
const testAction = {
type: CallsTypes.RECEIVED_UNMUTE_USER_CALL,
data: {channelId: 'channel-1', userId: 'user-2'},
};
let state = callsReducer(initialState, testAction);
assert.equal(state.calls['channel-1'].participants['user-2'].muted, false);
testAction.data = {channelId: 'channel-1', userId: 'invalidUser'};
state = callsReducer(initialState, testAction);
assert.deepEqual(state.calls, initialState.calls);
testAction.data = {channelId: 'invalid-channel', userId: 'user-2'};
state = callsReducer(initialState, testAction);
assert.deepEqual(state.calls, initialState.calls);
});
it('RECEIVED_VOICE_ON_USER_CALL', async () => {
const initialState = {calls: {'channel-1': call1, 'channel-2': call2}};
const testAction = {
type: CallsTypes.RECEIVED_VOICE_ON_USER_CALL,
data: {channelId: 'channel-1', userId: 'user-1'},
};
let state = callsReducer(initialState, testAction);
assert.equal(state.calls['channel-1'].participants['user-1'].isTalking, true);
assert.deepEqual(state.calls['channel-1'].speakers, ['user-1', 'user-2']);
testAction.data = {channelId: 'channel-1', userId: 'invalidUser'};
state = callsReducer(initialState, testAction);
assert.deepEqual(state.calls, initialState.calls);
testAction.data = {channelId: 'invalid-channel', userId: 'user-2'};
state = callsReducer(initialState, testAction);
assert.deepEqual(state.calls, initialState.calls);
});
it('RECEIVED_VOICE_OFF_USER_CALL', async () => {
const initialState = {calls: {'channel-1': call1, 'channel-2': call2}};
const testAction = {
type: CallsTypes.RECEIVED_VOICE_OFF_USER_CALL,
data: {channelId: 'channel-1', userId: 'user-2'},
};
let state = callsReducer(initialState, testAction);
assert.equal(state.calls['channel-1'].participants['user-2'].isTalking, false);
assert.deepEqual(state.calls['channel-1'].speakers, []);
testAction.data = {channelId: 'channel-1', userId: 'invalidUser'};
state = callsReducer(initialState, testAction);
assert.deepEqual(state.calls, initialState.calls);
testAction.data = {channelId: 'invalid-channel', userId: 'user-2'};
state = callsReducer(initialState, testAction);
assert.deepEqual(state.calls, initialState.calls);
});
it('RECEIVED_CHANNEL_CALL_SCREEN_ON', async () => {
const initialState = {calls: {'channel-1': call1, 'channel-2': call2}};
const testAction = {
type: CallsTypes.RECEIVED_CHANNEL_CALL_SCREEN_ON,
data: {channelId: 'channel-1', userId: 'user-1'},
};
let state = callsReducer(initialState, testAction);
assert.equal(state.calls['channel-1'].screenOn, 'user-1');
testAction.data = {channelId: 'channel-1', userId: 'invalidUser'};
state = callsReducer(initialState, testAction);
assert.deepEqual(state.calls, initialState.calls);
testAction.data = {channelId: 'invalid-channel', userId: 'user-1'};
state = callsReducer(initialState, testAction);
assert.deepEqual(state.calls, initialState.calls);
});
it('RECEIVED_CHANNEL_CALL_SCREEN_OFF', async () => {
const initialState = {calls: {'channel-1': call1, 'channel-2': call2}};
const testAction = {
type: CallsTypes.RECEIVED_CHANNEL_CALL_SCREEN_OFF,
data: {channelId: 'channel-1'},
};
let state = callsReducer(initialState, testAction);
assert.equal(state.calls['channel-1'].screenOn, '');
testAction.data = {channelId: 'invalid-channel'};
state = callsReducer(initialState, testAction);
assert.deepEqual(state.calls, initialState.calls);
});
});
describe('Reducers.calls.joined', () => {
it('RECEIVED_CALLS', async () => {
const initialState = {joined: 'test'};
const testAction = {
type: CallsTypes.RECEIVED_CALLS,
data: {calls: {'channel-1': {}, 'channel-2': {}}, enabled: {'channel-1': true}},
};
const state = callsReducer(initialState, testAction);
assert.equal(state.joined, '');
});
it('RECEIVED_MYSELF_JOINED_CALL', async () => {
const initialState = {joined: ''};
const testAction = {
type: CallsTypes.RECEIVED_MYSELF_JOINED_CALL,
data: 'channel-id',
};
const state = callsReducer(initialState, testAction);
assert.equal(state.joined, 'channel-id');
});
it('RECEIVED_MYSELF_LEFT_CALL', async () => {
const initialState = {joined: 'test'};
const testAction = {
type: CallsTypes.RECEIVED_MYSELF_LEFT_CALL,
data: null,
};
const state = callsReducer(initialState, testAction);
assert.equal(state.joined, '');
});
});
describe('Reducers.calls.enabled', () => {
it('RECEIVED_CALLS', async () => {
const initialState = {enabled: {}};
const testAction = {
type: CallsTypes.RECEIVED_CALLS,
data: {calls: {'channel-1': {}, 'channel-2': {}}, enabled: {'channel-1': true}},
};
const state = callsReducer(initialState, testAction);
assert.deepEqual(state.enabled, {'channel-1': true});
});
it('RECEIVED_CHANNEL_CALL_ENABLED', async () => {
const initialState = {enabled: {'channel-1': true, 'channel-2': false}};
const testAction = {
type: CallsTypes.RECEIVED_CHANNEL_CALL_ENABLED,
data: 'channel-3',
};
let state = callsReducer(initialState, testAction);
assert.deepEqual(state.enabled, {'channel-1': true, 'channel-2': false, 'channel-3': true});
testAction.data = 'channel-2';
state = callsReducer(initialState, testAction);
assert.deepEqual(state.enabled, {'channel-1': true, 'channel-2': true});
testAction.data = 'channel-1';
state = callsReducer(initialState, testAction);
assert.deepEqual(state.enabled, {'channel-1': true, 'channel-2': false});
});
it('RECEIVED_CHANNEL_CALL_DISABLED', async () => {
const initialState = {enabled: {'channel-1': true, 'channel-2': false}};
const testAction = {
type: CallsTypes.RECEIVED_CHANNEL_CALL_DISABLED,
data: 'channel-3',
};
let state = callsReducer(initialState, testAction);
assert.deepEqual(state.enabled, {'channel-1': true, 'channel-2': false, 'channel-3': false});
testAction.data = 'channel-2';
state = callsReducer(initialState, testAction);
assert.deepEqual(state.enabled, {'channel-1': true, 'channel-2': false});
testAction.data = 'channel-1';
state = callsReducer(initialState, testAction);
assert.deepEqual(state.enabled, {'channel-1': false, 'channel-2': false});
});
});
describe('Reducers.calls.screenShareURL', () => {
it('RECEIVED_MYSELF_JOINED_CALL', async () => {
const initialState = {screenShareURL: 'test'};
const testAction = {
type: CallsTypes.RECEIVED_MYSELF_JOINED_CALL,
data: 'channel-id',
};
const state = callsReducer(initialState, testAction);
assert.deepEqual(state.screenShareURL, '');
});
it('RECEIVED_MYSELF_LEFT_CALL', async () => {
const initialState = {screenShareURL: 'test'};
const testAction = {
type: CallsTypes.RECEIVED_MYSELF_LEFT_CALL,
data: 'channel-id',
};
const state = callsReducer(initialState, testAction);
assert.deepEqual(state.screenShareURL, '');
});
it('SET_SCREENSHARE_URL', async () => {
const initialState = {screenShareURL: 'test'};
const testAction = {
type: CallsTypes.SET_SCREENSHARE_URL,
data: 'new-url',
};
const state = callsReducer(initialState, testAction);
assert.deepEqual(state.screenShareURL, 'new-url');
});
});

View file

@ -0,0 +1,210 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {combineReducers} from 'redux';
import {GenericAction} from '@mm-redux/types/actions';
import {Dictionary} from '@mm-redux/types/utilities';
import CallsTypes from '@mmproducts/calls/store/action_types/calls';
import {Call} from '@mmproducts/calls/store/types/calls';
function calls(state: Dictionary<Call> = {}, action: GenericAction) {
switch (action.type) {
case CallsTypes.RECEIVED_CALLS: {
return action.data.calls;
}
case CallsTypes.RECEIVED_LEFT_CALL: {
const {channelId, userId} = action.data;
if (!state[channelId]) {
return state;
}
if (!state[channelId].participants[userId]) {
return state;
}
const channelUpdate = {...state[channelId], participants: {...state[channelId].participants}};
delete channelUpdate.participants[userId];
const nextState = {...state};
if (Object.keys(channelUpdate.participants).length === 0) {
delete nextState[channelId];
} else {
nextState[channelId] = channelUpdate;
}
return nextState;
}
case CallsTypes.RECEIVED_JOINED_CALL: {
const {channelId, userId} = action.data;
if (!state[channelId]) {
return state;
}
const channelUpdate = {...state[channelId], participants: {...state[channelId].participants}};
channelUpdate.participants[userId] = {
id: userId,
muted: true,
isTalking: false,
};
const nextState = {...state};
nextState[channelId] = channelUpdate;
return nextState;
}
case CallsTypes.RECEIVED_CALL_STARTED: {
const newCall = action.data;
const nextState = {...state};
nextState[newCall.channelId] = newCall;
return nextState;
}
case CallsTypes.RECEIVED_CALL_FINISHED: {
const newCall = action.data;
const nextState = {...state};
delete nextState[newCall.channelId];
return nextState;
}
case CallsTypes.RECEIVED_MUTE_USER_CALL: {
const {channelId, userId} = action.data;
if (!state[channelId]) {
return state;
}
if (!state[channelId].participants[userId]) {
return state;
}
const userUpdate = {...state[channelId].participants[userId], muted: true};
const channelUpdate = {...state[channelId], participants: {...state[channelId].participants}};
channelUpdate.participants[userId] = userUpdate;
const nextState = {...state};
nextState[channelId] = channelUpdate;
return nextState;
}
case CallsTypes.RECEIVED_UNMUTE_USER_CALL: {
const {channelId, userId} = action.data;
if (!state[channelId]) {
return state;
}
if (!state[channelId].participants[userId]) {
return state;
}
const userUpdate = {...state[channelId].participants[userId], muted: false};
const channelUpdate = {...state[channelId], participants: {...state[channelId].participants}};
channelUpdate.participants[userId] = userUpdate;
const nextState = {...state};
nextState[channelId] = channelUpdate;
return nextState;
}
case CallsTypes.RECEIVED_VOICE_ON_USER_CALL: {
const {channelId, userId} = action.data;
if (!state[channelId]) {
return state;
}
if (!state[channelId].participants[userId]) {
return state;
}
const userUpdate = {...state[channelId].participants[userId], isTalking: true};
const channelUpdate = {...state[channelId], participants: {...state[channelId].participants}};
channelUpdate.participants[userId] = userUpdate;
channelUpdate.speakers = [userId, ...(channelUpdate.speakers || [])];
const nextState = {...state};
nextState[channelId] = channelUpdate;
return nextState;
}
case CallsTypes.RECEIVED_VOICE_OFF_USER_CALL: {
const {channelId, userId} = action.data;
if (!state[channelId]) {
return state;
}
if (!state[channelId].participants[userId]) {
return state;
}
const userUpdate = {...state[channelId].participants[userId], isTalking: false};
const channelUpdate = {...state[channelId], participants: {...state[channelId].participants}};
channelUpdate.participants[userId] = userUpdate;
channelUpdate.speakers = channelUpdate.speakers?.filter((id) => id !== userId);
if (!channelUpdate.speakers) {
channelUpdate.speakers = [];
}
const nextState = {...state};
nextState[channelId] = channelUpdate;
return nextState;
}
case CallsTypes.RECEIVED_CHANNEL_CALL_SCREEN_ON: {
const {channelId, userId} = action.data;
if (!state[channelId]) {
return state;
}
if (!state[channelId].participants[userId]) {
return state;
}
const channelUpdate = {...state[channelId], screenOn: userId};
const nextState = {...state};
nextState[channelId] = channelUpdate;
return nextState;
}
case CallsTypes.RECEIVED_CHANNEL_CALL_SCREEN_OFF: {
const {channelId} = action.data;
if (!state[channelId]) {
return state;
}
const channelUpdate = {...state[channelId], screenOn: ''};
const nextState = {...state};
nextState[channelId] = channelUpdate;
return nextState;
}
default:
return state;
}
}
function joined(state = '', action: GenericAction) {
switch (action.type) {
case CallsTypes.RECEIVED_MYSELF_JOINED_CALL: {
return action.data;
}
case CallsTypes.RECEIVED_CALLS: {
return '';
}
case CallsTypes.RECEIVED_MYSELF_LEFT_CALL: {
return '';
}
default:
return state;
}
}
function enabled(state: Dictionary<boolean> = {}, action: GenericAction) {
switch (action.type) {
case CallsTypes.RECEIVED_CALLS: {
return action.data.enabled;
}
case CallsTypes.RECEIVED_CHANNEL_CALL_ENABLED: {
const nextState = {...state};
nextState[action.data] = true;
return nextState;
}
case CallsTypes.RECEIVED_CHANNEL_CALL_DISABLED: {
const nextState = {...state};
nextState[action.data] = false;
return nextState;
}
default:
return state;
}
}
function screenShareURL(state = '', action: GenericAction) {
switch (action.type) {
case CallsTypes.RECEIVED_MYSELF_JOINED_CALL: {
return '';
}
case CallsTypes.RECEIVED_MYSELF_LEFT_CALL: {
return '';
}
case CallsTypes.SET_SCREENSHARE_URL: {
return action.data;
}
default:
return state;
}
}
export default combineReducers({
calls,
enabled,
joined,
screenShareURL,
});

View file

@ -0,0 +1,80 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import assert from 'assert';
import deepFreezeAndThrowOnMutation from '@mm-redux/utils/deep_freeze';
import * as Selectors from './calls';
describe('Selectors.Calls', () => {
const call1 = {id: 'call1'};
const call2 = {id: 'call2'};
const testState = deepFreezeAndThrowOnMutation({
entities: {
channels: {
currentChannelId: 'channel-1',
},
calls: {
calls: {call1, call2},
joined: 'call1',
enabled: {'channel-1': true, 'channel-2': false},
screenShareURL: 'screenshare-url',
},
},
});
it('getCalls', () => {
assert.deepEqual(Selectors.getCalls(testState), {call1, call2});
});
it('getCurrentCall', () => {
assert.deepEqual(Selectors.getCurrentCall(testState), call1);
let newState = {
...testState,
entities: {
...testState.entities,
calls: {
...testState.entities.calls,
joined: null,
},
},
};
assert.equal(Selectors.getCurrentCall(newState), null);
newState = {
...testState,
entities: {
...testState.entities,
calls: {
...testState.entities.calls,
joined: 'invalid-id',
},
},
};
assert.equal(Selectors.getCurrentCall(newState), null);
});
it('isCallsEnabled', () => {
assert.equal(Selectors.isCallsEnabled(testState), true);
let newState = {
...testState,
entities: {
...testState.entities,
channels: {currentChannelId: 'channel-2'},
},
};
assert.equal(Selectors.isCallsEnabled(newState), false);
newState = {
...testState,
entities: {
...testState.entities,
channels: {currentChannelId: 'not-valid-channel'},
},
};
assert.equal(Selectors.isCallsEnabled(newState), false);
});
it('getScreenShareURL', () => {
assert.equal(Selectors.getScreenShareURL(testState), 'screenshare-url');
});
});

View file

@ -0,0 +1,25 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {getCurrentChannelId} from '@mm-redux/selectors/entities/common';
import {GlobalState} from '@mm-redux/types/store';
export function getCalls(state: GlobalState) {
return state.entities.calls.calls;
}
export function getCurrentCall(state: GlobalState) {
const currentCall = state.entities.calls.joined;
if (!currentCall) {
return null;
}
return state.entities.calls.calls[currentCall];
}
export function isCallsEnabled(state: GlobalState) {
return Boolean(state.entities.calls.enabled[getCurrentChannelId(state)]);
}
export function getScreenShareURL(state: GlobalState) {
return state.entities.calls.screenShareURL;
}

View file

@ -0,0 +1,46 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Dictionary} from '@mm-redux/types/utilities';
export type CallsState = {
calls: Dictionary<Call>;
enabled: Dictionary<boolean>;
joined: string;
screenShareURL: string;
}
export type Call = {
participants: Dictionary<CallParticipant>;
channelId: string;
startTime: number;
speakers: string[];
screenOn: string;
threadId: string;
}
export type CallParticipant = {
id: string;
muted: boolean;
isTalking: boolean;
}
export type ServerChannelState = {
channel_id: string;
enabled: boolean;
call: ServerCallState;
}
export type ServerUserState = {
unmuted: boolean;
raised_hand: number;
}
export type ServerCallState = {
id: string;
start_at: number;
users: string[];
states: ServerUserState[];
thread_id: string;
screen_sharing_id: string;
}

View file

@ -14,6 +14,9 @@ import PostDraft from '@components/post_draft';
import {NavigationTypes} from '@constants';
import {CHANNEL_POST_TEXTBOX_CURSOR_CHANGE, CHANNEL_POST_TEXTBOX_VALUE_CHANGE} from '@constants/post_draft';
import EventEmitter from '@mm-redux/utils/event_emitter';
import CurrentCall from '@mmproducts/calls/components/current_call';
import FloatingCallContainer from '@mmproducts/calls/components/floating_call_container';
import JoinCall from '@mmproducts/calls/components/join_call';
import EphemeralStore from '@store/ephemeral_store';
import ChannelBase from './channel_base';
@ -61,7 +64,7 @@ export default class ChannelAndroid extends ChannelBase {
}
render() {
const {theme, viewingGlobalThreads} = this.props;
const {theme, viewingGlobalThreads, callsFeatureEnabled} = this.props;
let component;
if (viewingGlobalThreads) {
@ -102,6 +105,11 @@ export default class ChannelAndroid extends ChannelBase {
{component}
<NetworkIndicator/>
<AnnouncementBanner/>
{callsFeatureEnabled &&
<FloatingCallContainer>
<JoinCall/>
<CurrentCall/>
</FloatingCallContainer>}
</>
);

View file

@ -16,6 +16,9 @@ import SettingsSidebar from '@components/sidebars/settings';
import StatusBar from '@components/status_bar';
import DEVICE from '@constants/device';
import {ACCESSORIES_CONTAINER_NATIVE_ID, CHANNEL_POST_TEXTBOX_CURSOR_CHANGE, CHANNEL_POST_TEXTBOX_VALUE_CHANGE} from '@constants/post_draft';
import CurrentCall from '@mmproducts/calls/components/current_call';
import FloatingCallContainer from '@mmproducts/calls/components/floating_call_container';
import JoinCall from '@mmproducts/calls/components/join_call';
import {makeStyleSheetFromTheme} from '@utils/theme';
import ChannelBase from './channel_base';
@ -54,7 +57,7 @@ export default class ChannelIOS extends ChannelBase {
};
render() {
const {currentChannelId, theme, viewingGlobalThreads} = this.props;
const {currentChannelId, theme, viewingGlobalThreads, callsFeatureEnabled} = this.props;
let component;
let renderDraftArea = false;
@ -82,6 +85,11 @@ export default class ChannelIOS extends ChannelBase {
<>
<AnnouncementBanner/>
<NetworkIndicator/>
{callsFeatureEnabled &&
<FloatingCallContainer>
<JoinCall/>
<CurrentCall/>
</FloatingCallContainer>}
</>
);
const header = (

View file

@ -24,6 +24,7 @@ export default class ChannelBase extends PureComponent {
loadChannelsForTeam: PropTypes.func.isRequired,
selectDefaultTeam: PropTypes.func.isRequired,
selectInitialChannel: PropTypes.func.isRequired,
loadCalls: PropTypes.func.isRequired,
}).isRequired,
componentId: PropTypes.string.isRequired,
currentChannelId: PropTypes.string,
@ -38,6 +39,7 @@ export default class ChannelBase extends PureComponent {
skipMetrics: PropTypes.bool,
viewingGlobalThreads: PropTypes.bool,
collapsedThreadsEnabled: PropTypes.bool.isRequired,
callsFeatureEnabled: PropTypes.bool.isRequired,
selectedPost: PropTypes.shape({
id: PropTypes.string.isRequired,
channel_id: PropTypes.string.isRequired,
@ -102,6 +104,10 @@ export default class ChannelBase extends PureComponent {
// Only display the Alert if the TOS does not need to show first
unsupportedServer(isSystemAdmin, this.context.intl.formatMessage);
}
if (this.props.callsFeatureEnabled) {
this.props.actions.loadCalls();
}
}
componentDidUpdate(prevProps) {

View file

@ -23,10 +23,12 @@ describe('ChannelBase', () => {
markChannelViewedAndRead: jest.fn(),
selectDefaultTeam: jest.fn(),
selectInitialChannel: jest.fn(),
loadCalls: jest.fn(),
},
componentId: channelBaseComponentId,
theme: Preferences.THEMES.denim,
collapsedThreadsEnabled: false,
callsFeatureEnabled: false,
};
const optionsForTheme = (theme) => {
return {

View file

@ -10,13 +10,14 @@ import {Client4} from '@client/rest';
import {ViewTypes} from '@constants';
import {getChannelStats} from '@mm-redux/actions/channels';
import {getCurrentChannelId} from '@mm-redux/selectors/entities/channels';
import {getServerVersion} from '@mm-redux/selectors/entities/general';
import {getServerVersion, getFeatureFlagValue} from '@mm-redux/selectors/entities/general';
import {getSelectedPost} from '@mm-redux/selectors/entities/posts';
import {getTheme, isCollapsedThreadsEnabled} from '@mm-redux/selectors/entities/preferences';
import {getCurrentTeam} from '@mm-redux/selectors/entities/teams';
import {getCurrentUserId, getCurrentUserRoles, shouldShowTermsOfService} from '@mm-redux/selectors/entities/users';
import {isMinimumServerVersion} from '@mm-redux/utils/helpers';
import {isSystemAdmin as checkIsSystemAdmin} from '@mm-redux/utils/user_utils';
import {loadCalls} from '@mmproducts/calls/store/actions/calls';
import {getViewingGlobalThreads} from '@selectors/threads';
import Channel from './channel';
@ -41,6 +42,7 @@ function mapStateToProps(state) {
const currentTeamId = currentTeam?.delete_at === 0 ? currentTeam?.id : '';
const currentChannelId = currentTeam?.delete_at === 0 ? getCurrentChannelId(state) : '';
const collapsedThreadsEnabled = isCollapsedThreadsEnabled(state);
const callsFeatureEnabled = getFeatureFlagValue(state, 'CallsMobile') === 'true';
return {
currentChannelId,
@ -54,6 +56,7 @@ function mapStateToProps(state) {
teamName: currentTeam?.display_name,
theme: getTheme(state),
viewingGlobalThreads: collapsedThreadsEnabled && getViewingGlobalThreads(state),
callsFeatureEnabled,
};
}
@ -64,6 +67,7 @@ function mapDispatchToProps(dispatch) {
loadChannelsForTeam,
selectDefaultTeam,
selectInitialChannel,
loadCalls,
}, dispatch),
};
}

View file

@ -13,6 +13,8 @@ import {SafeAreaView} from 'react-native-safe-area-context';
import {dismissModal} from '@actions/navigation';
import StatusBar from '@components/status_bar';
import EnableDisableCalls from '@mmproducts/calls/components/enable_disable_calls';
import StartCall from '@mmproducts/calls/components/start_call';
import {alertErrorWithFallback} from '@utils/general';
import {t} from '@utils/i18n';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
@ -38,6 +40,9 @@ export default class ChannelInfo extends PureComponent {
getChannelStats: PropTypes.func.isRequired,
getCustomEmojisInText: PropTypes.func.isRequired,
setChannelDisplayName: PropTypes.func.isRequired,
joinCall: PropTypes.func.isRequired,
enableChannelCalls: PropTypes.func.isRequired,
disableChannelCalls: PropTypes.func.isRequired,
}),
currentChannel: PropTypes.object.isRequired,
currentChannelCreatorName: PropTypes.string,
@ -52,6 +57,9 @@ export default class ChannelInfo extends PureComponent {
isCustomStatusEnabled: PropTypes.bool.isRequired,
isCustomStatusExpired: PropTypes.bool.isRequired,
isCustomStatusExpirySupported: PropTypes.bool.isRequired,
isCallsEnabled: PropTypes.bool.isRequired,
isChannelAdmin: PropTypes.bool.isRequired,
callsFeatureEnabled: PropTypes.bool.isRequired,
};
static defaultProps = {
@ -84,6 +92,19 @@ export default class ChannelInfo extends PureComponent {
dismissModal();
};
startCallHandler = (channelId) => {
this.props.actions.joinCall(channelId);
this.close();
}
toggleCalls = () => {
if (this.props.isCallsEnabled) {
this.props.actions.disableChannelCalls(this.props.currentChannel.id);
} else {
this.props.actions.enableChannelCalls(this.props.currentChannel.id);
}
}
permalinkBadTeam = () => {
const {intl} = this.context;
const message = {
@ -95,7 +116,7 @@ export default class ChannelInfo extends PureComponent {
};
actionsRows = (channelIsArchived) => {
const {currentChannel, currentUserId, isDirectMessage, theme} = this.props;
const {currentChannel, currentUserId, isDirectMessage, theme, isCallsEnabled, callsFeatureEnabled, isChannelAdmin} = this.props;
if (channelIsArchived) {
return (
@ -156,6 +177,24 @@ export default class ChannelInfo extends PureComponent {
testID='channel_info.edit_channel.action'
theme={theme}
/>
{callsFeatureEnabled &&
<>
<StartCall
testID='channel_info.start_call.action'
theme={theme}
currentChannelId={currentChannel.id}
currentChannelName={currentChannel.display_name}
joinCall={this.startCallHandler}
canStartCall={isCallsEnabled}
/>
<EnableDisableCalls
testID='channel_info.start_call.action'
theme={theme}
onPress={this.toggleCalls}
canEnableDisableCalls={isChannelAdmin}
enabled={isCallsEnabled}
/>
</>}
<Bindings
theme={theme}
/>

View file

@ -57,7 +57,13 @@ describe('channelInfo', () => {
getCustomEmojisInText: jest.fn(),
showPermalink: jest.fn(),
setChannelDisplayName: jest.fn(),
joinCall: jest.fn(),
enableChannelCalls: jest.fn(),
disableChannelCalls: jest.fn(),
},
isCallsEnabled: false,
callsFeatureEnabled: false,
isChannelAdmin: false,
};
test('should match snapshot', async () => {
@ -70,6 +76,39 @@ describe('channelInfo', () => {
expect(wrapper.getElement()).toMatchSnapshot();
});
test('should match snapshot on calls features enabled, user is not admin and calls disabled for channel', async () => {
const props = {...baseProps, isCallsEnabled: false, callsFeatureEnabled: true, isChannelAdmin: false};
const wrapper = shallow(
<ChannelInfo
{...props}
/>,
{context: {intl: intlMock}},
);
expect(wrapper.getElement()).toMatchSnapshot();
});
test('should match snapshot on calls features enabled and calls disabled for channel', async () => {
const props = {...baseProps, isCallsEnabled: false, callsFeatureEnabled: true, isChannelAdmin: true};
const wrapper = shallow(
<ChannelInfo
{...props}
/>,
{context: {intl: intlMock}},
);
expect(wrapper.getElement()).toMatchSnapshot();
});
test('should match snapshot on calls features enabled and calls enabled for channel', async () => {
const props = {...baseProps, isCallsEnabled: true, callsFeatureEnabled: true, isChannelAdmin: true};
const wrapper = shallow(
<ChannelInfo
{...props}
/>,
{context: {intl: intlMock}},
);
expect(wrapper.getElement()).toMatchSnapshot();
});
test('should dismiss modal on close', () => {
const dismissModal = jest.spyOn(NavigationActions, 'dismissModal');
const wrapper = shallow(

View file

@ -9,10 +9,13 @@ import {getChannelStats} from '@mm-redux/actions/channels';
import {getCustomEmojisInText} from '@mm-redux/actions/emojis';
import {General} from '@mm-redux/constants';
import {getCurrentChannel, getCurrentChannelStats} from '@mm-redux/selectors/entities/channels';
import {getFeatureFlagValue} from '@mm-redux/selectors/entities/general';
import {getTeammateNameDisplaySetting, getTheme} from '@mm-redux/selectors/entities/preferences';
import {getCurrentUserId, getUser} from '@mm-redux/selectors/entities/users';
import {getCurrentUserRoles, getCurrentUserId, getUser} from '@mm-redux/selectors/entities/users';
import {getUserIdFromChannelName} from '@mm-redux/utils/channel_utils';
import {displayUsername} from '@mm-redux/utils/user_utils';
import {isAdmin as checkIsAdmin, isChannelAdmin as checkIsChannelAdmin, displayUsername} from '@mm-redux/utils/user_utils';
import {joinCall, enableChannelCalls, disableChannelCalls} from '@mmproducts/calls/store/actions/calls';
import {isCallsEnabled} from '@mmproducts/calls/store/selectors/calls';
import {makeGetCustomStatus, isCustomStatusEnabled, isCustomStatusExpired, isCustomStatusExpirySupported} from '@selectors/custom_status';
import {isGuest} from '@utils/users';
@ -36,6 +39,8 @@ function makeMapStateToProps() {
let customStatus;
let customStatusExpired = true;
let customStatusExpirySupported = false;
const roles = getCurrentUserRoles(state) || '';
const isChannelAdmin = checkIsAdmin(roles) || checkIsChannelAdmin(roles);
const isDirectMessage = currentChannel.type === General.DM_CHANNEL;
if (isDirectMessage) {
@ -55,6 +60,8 @@ function makeMapStateToProps() {
currentChannelMemberCount = currentChannel.display_name.split(',').length;
}
const callsFeatureEnabled = getFeatureFlagValue(state, 'CallsMobile') === 'true';
return {
currentChannel,
currentChannelCreatorName,
@ -69,6 +76,9 @@ function makeMapStateToProps() {
isCustomStatusEnabled: customStatusEnabled,
isCustomStatusExpired: customStatusExpired,
isCustomStatusExpirySupported: customStatusExpirySupported,
isCallsEnabled: isCallsEnabled(state),
callsFeatureEnabled,
isChannelAdmin,
};
};
}
@ -79,6 +89,9 @@ function mapDispatchToProps(dispatch) {
getChannelStats,
getCustomEmojisInText,
setChannelDisplayName,
joinCall,
enableChannelCalls,
disableChannelCalls,
}, dispatch),
};
}

View file

@ -120,6 +120,9 @@ Navigation.setLazyComponentRegistrator((screenName) => {
case 'MainSidebar':
screen = require('app/components/sidebars/main').default;
break;
case 'Call':
screen = require('@mmproducts/calls/screens/call').default;
break;
case 'MFA':
screen = require('@screens/mfa').default;
break;
@ -169,6 +172,9 @@ Navigation.setLazyComponentRegistrator((screenName) => {
case 'PostOptions':
screen = require('@screens/post_options').default;
break;
case 'CallOtherActions':
screen = require('@mmproducts/calls/screens/call_other_actions').default;
break;
case 'ReactionList':
screen = require('@screens/reaction_list').default;
break;

View file

@ -18,16 +18,24 @@ type Props = {
currentUserId: $ID<UserProfile>;
userProfiles: UserProfile[];
teammateNameDisplay: string;
listTitle?: JSX.Element;
theme: Theme;
}
const ParticipantsList = ({currentUserId, teammateNameDisplay, theme, userProfiles}: Props) => {
const ParticipantsList = ({currentUserId, teammateNameDisplay, theme, userProfiles, listTitle}: Props) => {
const close = () => {
dismissModal();
};
const renderHeader = () => {
const style = getStyleSheet(theme);
if (listTitle) {
return (
<View style={style.header}>
{listTitle}
</View>
);
}
return (
<View style={style.header}>
<FormattedText

View file

@ -74,9 +74,9 @@ const viewsBlackListFilter = createBlacklistFilter(
['extension', 'root'],
);
const typingBlackListFilter = createBlacklistFilter(
const entitiesBlackListFilter = createBlacklistFilter(
'entities',
['typing'],
['typing', 'calls'],
);
const channelViewBlackList: any = {loading: true, refreshing: true, loadingPosts: true, retryFailed: true, loadMorePostsVisible: true};
@ -174,7 +174,7 @@ export default function configureStore(storage: any, preloadedState: any = {}, o
transforms: [
setTransformer,
viewsBlackListFilter,
typingBlackListFilter,
entitiesBlackListFilter,
channelViewBlackListFilter,
emojiBlackListFilter,
],

File diff suppressed because it is too large Load diff

View file

@ -56,6 +56,27 @@
"apps.suggestion.no_static": "No matching options.",
"apps.suggestion.no_suggestion": "No matching suggestions.",
"archivedChannelMessage": "You are viewing an **archived channel**. New messages cannot be posted.",
"call_message.call_ended": "Call ended",
"call_message.call_ended_at": "Ended at {time}",
"call_message.call_lasted": "Lasted {duration}",
"call_message.current_call": "Current Call",
"call_message.join_call": "Join Call",
"call_message.started_a_call": "{user} started a call",
"call.add_participants": "Add participants",
"call.chat_thread": "Chat thread",
"call.copy_call_link": "Copy call link",
"call.give_feedback": "Give Feedback",
"call.leave": "Leave",
"call.more": "More",
"call.mute": "Mute",
"call.screen_share_user": "You are seing {userDisplayName} screen",
"call.settings": "Settings",
"call.unmute": "Unmute",
"calls.confirm-to-join-cancel": "Cancel",
"calls.confirm-to-join-description": "You are already on a channel call in ~{callChannelName}. Do you want to leave your current call and join the call in ~{currentChannelName}?",
"calls.confirm-to-join-leave-and-join": "Leave & Join",
"calls.confirm-to-join-title": "Are you sure you want to switch to a different call?",
"calls.join_call.participants_list_header": "CALL PARTICIPANTS",
"camera_type.photo.option": "Capture Photo",
"camera_type.title": "Choose an action",
"camera_type.video.option": "Record Video",
@ -134,6 +155,8 @@
"create_comment.addComment": "Add a comment...",
"create_post.deactivated": "You are viewing an archived channel with a deactivated user.",
"create_post.write": "Write to {channelDisplayName}",
"current_call.channel-name": "~{channelName}",
"current_call.user-is-speaking": "{username} is speaking",
"custom_status.expiry_dropdown.custom": "Custom",
"custom_status.expiry_dropdown.date_and_time": "Date and Time",
"custom_status.expiry_dropdown.dont_clear": "Don't clear",
@ -294,9 +317,13 @@
"mobile.channel_info.copy_header": "Copy Header",
"mobile.channel_info.copy_purpose": "Copy Purpose",
"mobile.channel_info.delete_failed": "We couldn't archive the channel {displayName}. Please check your connection and try again.",
"mobile.channel_info.disable_calls": "Disable Calls",
"mobile.channel_info.edit": "Edit Channel",
"mobile.channel_info.enable_calls": "Enable Calls",
"mobile.channel_info.join_ongoing_call": "Join Ongoing Call",
"mobile.channel_info.privateChannel": "Private Channel",
"mobile.channel_info.publicChannel": "Public Channel",
"mobile.channel_info.start_call": "Start Call",
"mobile.channel_info.unarchive_failed": "We couldn't unarchive the channel {displayName}. Please check your connection and try again.",
"mobile.channel_list.alertNo": "No",
"mobile.channel_list.alertYes": "Yes",

Binary file not shown.

View file

@ -22,6 +22,7 @@ module.exports = {
'@init': './app/init',
'@mattermost-managed': './app/mattermost_managed/index',
'@mm-redux': './app/mm-redux',
'@mmproducts': './app/products',
'@share': './share_extension',
'@screens': './app/screens',
'@selectors': './app/selectors',

View file

@ -496,6 +496,7 @@
AE4769B235D14E6C9C64EA78 /* Upload Debug Symbols to Sentry */,
7FFE32A91FD9CB650038C7A0 /* Embed App Extensions */,
ED4C644925C525E30315E09E /* [CP] Copy Pods Resources */,
E23E84F30F683B193780BB30 /* [CP] Embed Pods Frameworks */,
);
buildRules = (
);
@ -743,6 +744,24 @@
shellPath = /bin/sh;
shellScript = "./uploadDebugSymbols.sh\n";
};
E23E84F30F683B193780BB30 /* [CP] Embed Pods Frameworks */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Mattermost/Pods-Mattermost-frameworks.sh",
"${PODS_ROOT}/../../node_modules/react-native-webrtc2/ios/WebRTC.framework",
);
name = "[CP] Embed Pods Frameworks";
outputPaths = (
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/WebRTC.framework",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Mattermost/Pods-Mattermost-frameworks.sh\"\n";
showEnvVarsInLog = 0;
};
ED4C644925C525E30315E09E /* [CP] Copy Pods Resources */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
@ -1170,7 +1189,7 @@
COPY_PHASE_STRIP = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = "arm64 ";
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = "";
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
@ -1226,7 +1245,7 @@
COPY_PHASE_STRIP = YES;
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = "arm64 ";
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = "";
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;

View file

@ -276,6 +276,8 @@ PODS:
- react-native-video/Video (= 5.2.0)
- react-native-video/Video (5.2.0):
- React-Core
- react-native-webrtc2 (1.84.5):
- React
- react-native-webview (11.14.2):
- React-Core
- React-perflogger (0.66.1)
@ -345,6 +347,8 @@ PODS:
- React-perflogger (= 0.66.1)
- ReactNativeExceptionHandler (2.10.10):
- React-Core
- ReactNativeIncallManager (3.3.0):
- React
- ReactNativeKeyboardTrackingView (5.7.0):
- React
- ReactNativeNavigation (7.23.1):
@ -493,6 +497,7 @@ DEPENDENCIES:
- react-native-safe-area-context (from `../node_modules/react-native-safe-area-context`)
- react-native-startup-time (from `../node_modules/react-native-startup-time`)
- react-native-video (from `../node_modules/react-native-video`)
- react-native-webrtc2 (from `../node_modules/react-native-webrtc2`)
- react-native-webview (from `../node_modules/react-native-webview`)
- React-perflogger (from `../node_modules/react-native/ReactCommon/reactperflogger`)
- React-RCTActionSheet (from `../node_modules/react-native/Libraries/ActionSheetIOS`)
@ -507,6 +512,7 @@ DEPENDENCIES:
- React-runtimeexecutor (from `../node_modules/react-native/ReactCommon/runtimeexecutor`)
- ReactCommon/turbomodule/core (from `../node_modules/react-native/ReactCommon`)
- ReactNativeExceptionHandler (from `../node_modules/react-native-exception-handler`)
- ReactNativeIncallManager (from `../node_modules/react-native-incall-manager`)
- ReactNativeKeyboardTrackingView (from `../node_modules/react-native-keyboard-tracking-view`)
- ReactNativeNavigation (from `../node_modules/react-native-navigation`)
- rn-fetch-blob (from `../node_modules/rn-fetch-blob`)
@ -631,6 +637,8 @@ EXTERNAL SOURCES:
:path: "../node_modules/react-native-startup-time"
react-native-video:
:path: "../node_modules/react-native-video"
react-native-webrtc2:
:path: "../node_modules/react-native-webrtc2"
react-native-webview:
:path: "../node_modules/react-native-webview"
React-perflogger:
@ -659,6 +667,8 @@ EXTERNAL SOURCES:
:path: "../node_modules/react-native/ReactCommon"
ReactNativeExceptionHandler:
:path: "../node_modules/react-native-exception-handler"
ReactNativeIncallManager:
:path: "../node_modules/react-native-incall-manager"
ReactNativeKeyboardTrackingView:
:path: "../node_modules/react-native-keyboard-tracking-view"
ReactNativeNavigation:
@ -764,6 +774,7 @@ SPEC CHECKSUMS:
react-native-safe-area-context: 584dc04881deb49474363f3be89e4ca0e854c057
react-native-startup-time: 1a068b744ce5097a85ebe0fbff691b05961c324d
react-native-video: a4c2635d0802f983594b7057e1bce8f442f0ad28
react-native-webrtc2: b3f1d5504c8bbd3aa4ce9b6c3ce0fbc47c7e146a
react-native-webview: cc4ac8f021a832dce2979e1f48c5595341dd2fb5
React-perflogger: fcac6090a80e3d967791b4c7f1b1a017f9d4a398
React-RCTActionSheet: caf5913d9f9e605f5467206cf9d1caa6d47d7ad6
@ -778,6 +789,7 @@ SPEC CHECKSUMS:
React-runtimeexecutor: 33a949a51bec5f8a3c9e8d8092deb259600d761e
ReactCommon: 620442811dc6f707b4bf5e3b27d4f19c12d5a821
ReactNativeExceptionHandler: b11ff67c78802b2f62eed0e10e75cb1ef7947c60
ReactNativeIncallManager: a840ef75640518065742a893e6c6ff98ee820039
ReactNativeKeyboardTrackingView: 02137fac3b2ebd330d74fa54ead48b14750a2306
ReactNativeNavigation: 3ab7ff832654964985de47c9ee67999e34cd63d8
rn-fetch-blob: 17961aec08caae68bb8fc0e5b40f93b3acfa6932

892
package-lock.json generated

File diff suppressed because it is too large Load diff

Some files were not shown because too many files have changed in this diff Show more