Compare commits

..

22 Commits

Author SHA1 Message Date
Mattermost Build
4b42271960 Bump version to 1.53.0 Build 404 (#6345) (#6347)
* Bump app version number to  1.53.0

* Bump app build number to  404

(cherry picked from commit cb1773bda5)

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2022-06-03 11:33:45 -04:00
Mattermost Build
1e4e59b537 [MM-44651] Implement MaxCallParticipants config setting (#6334) (#6339)
* Implement MaxCallParticipants config setting

* Add test

(cherry picked from commit bb655c8c60)

Co-authored-by: Claudio Costa <cstcld91@gmail.com>
2022-06-03 09:05:57 +02:00
Mattermost Build
fe29459906 [MM-44155] Handle call_end event (#6316) (#6332)
* Handle call_end event

* exit call screen on call end; /call end for mobile

* handle permissions before sending cmd to server; handle error

Co-authored-by: Christopher Poile <cpoile@gmail.com>
(cherry picked from commit 23509cbb83)

Co-authored-by: Claudio Costa <cstcld91@gmail.com>
2022-06-02 13:50:07 +02:00
Mattermost Build
8985791f40 MM-44546 -- Calls: Cloud freemium limits (#6318) (#6331)
* remove API call to config/pass iceServers; leave call on ws error

* cloud limits

* fix makeStyleSheetFromTheme

* revert podfile & package-lock diffs

* update snapshots

* edge case of cloud server on calls 0.5.3

(cherry picked from commit c74cd14713)

Co-authored-by: Christopher Poile <cpoile@gmail.com>
2022-06-01 19:32:16 -04:00
Elias Nahum
7d9d7d45ac update fastlane 2022-05-24 08:39:38 -04:00
Guillermo Vayá
738d25ec3b Merge pull request #6282 from weblate/weblate-mattermost-mattermost-mobile_master
Translations update from Mattermost Weblate
2022-05-17 14:25:17 +02:00
aeomin
ffe1ef2494 Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (791 of 791 strings)

Translation: mattermost-languages-shipped/mattermost-mobile
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_master/zh_Hans/
2022-05-17 10:12:29 +02:00
MArtin Johnson
79e9f043f3 Translated using Weblate (Swedish)
Currently translated at 100.0% (791 of 791 strings)

Translation: mattermost-languages-shipped/mattermost-mobile
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_master/sv/
2022-05-17 10:12:29 +02:00
Tom De Moor
9aef24070d Translated using Weblate (Dutch)
Currently translated at 100.0% (791 of 791 strings)

Translation: mattermost-languages-shipped/mattermost-mobile
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_master/nl/
2022-05-17 10:12:29 +02:00
Guillermo Vayá
fef4cfa93d Merge pull request #6256 from weblate/weblate-mattermost-mattermost-mobile_master
Translations update from Mattermost Weblate
2022-05-10 16:18:40 +02:00
gavin-luo
54a243f578 Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (791 of 791 strings)

Translation: mattermost-languages-shipped/mattermost-mobile
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_master/zh_Hans/
2022-05-05 20:25:22 +02:00
MArtin Johnson
24607f3d42 Translated using Weblate (Swedish)
Currently translated at 100.0% (791 of 791 strings)

Translation: mattermost-languages-shipped/mattermost-mobile
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_master/sv/
2022-05-05 20:25:22 +02:00
yeongeun.seo
61370801ab Translated using Weblate (Korean)
Currently translated at 100.0% (791 of 791 strings)

Translation: mattermost-languages-shipped/mattermost-mobile
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_master/ko/
2022-05-05 20:25:22 +02:00
Tóth Csaba // Online ERP Hungary Kft
edc682b02a Translated using Weblate (Hungarian)
Currently translated at 100.0% (791 of 791 strings)

Translation: mattermost-languages-shipped/mattermost-mobile
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_master/hu/
2022-05-05 20:25:22 +02:00
SiderealArt
9b4bc2d6a2 Translated using Weblate (Chinese (Traditional))
Currently translated at 74.7% (591 of 791 strings)

Translation: mattermost-languages-shipped/mattermost-mobile
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_master/zh_Hant/
2022-05-05 20:25:22 +02:00
Maxime tremblay Godindustry@gmail.com
c020222c1f Translated using Weblate (Bulgarian)
Currently translated at 94.9% (751 of 791 strings)

Translation: mattermost-languages-shipped/mattermost-mobile
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_master/bg/
2022-05-05 20:25:22 +02:00
Elias Nahum
d5856f7b06 Bump app build number to 398 (#6236) 2022-05-05 14:25:17 -04:00
Christopher Poile
67c65156a7 MM-43904 - Fix: Calls batch actions (#6229)
* fix batch actions

* tests
2022-05-05 12:33:23 -04:00
Elias Nahum
cd34873c23 Bump app build number to 396 (#6218) 2022-05-03 18:11:52 -04:00
Christopher Poile
912287fbe0 MM-43904 - Fix: Calls: "Access to route for non-existent plugin" error log (#6210)
* add isCallsPluginEnabled; refactor Calls.PluginId

* revert Podfile.lock changes
2022-05-03 15:02:58 -04:00
Elias Nahum
c1e3c878e3 Bump version 1.52.0 build 394 (#6201)
* Bump app version number to  1.52.0

* Bump app build number to  394
2022-04-29 18:14:53 -04:00
Amy Blais
fe5bd60cec Update NOTICE.txt (#6198) 2022-04-29 09:57:28 -04:00
42 changed files with 669 additions and 247 deletions

View File

@@ -131,8 +131,8 @@ android {
applicationId "com.mattermost.rnbeta"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 398
versionName "1.52.0"
versionCode 404
versionName "1.53.0"
multiDexEnabled = true
testBuildType System.getProperty('testBuildType', 'debug')
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'

View File

@@ -27,6 +27,7 @@ import {removeUserFromList} from '@mm-redux/utils/user_utils';
import {batchLoadCalls} from '@mmproducts/calls/store/actions/calls';
import {
handleCallStarted,
handleCallEnded,
handleCallUserConnected,
handleCallUserDisconnected,
handleCallUserMuted,
@@ -473,6 +474,8 @@ function handleEvent(msg: WebSocketMessage) {
break;
case WebsocketEvents.CALLS_CALL_START:
return dispatch(handleCallStarted(msg));
case WebsocketEvents.CALLS_CALL_END:
return dispatch(handleCallEnded(msg));
case WebsocketEvents.CALLS_SCREEN_ON:
return dispatch(handleCallScreenOn(msg));
case WebsocketEvents.CALLS_SCREEN_OFF:

View File

@@ -63,6 +63,7 @@ export default class DraftInput extends PureComponent {
channelMemberCountsByGroup: PropTypes.object,
groupsWithAllowReference: PropTypes.object,
addRecentUsedEmojisInMessage: PropTypes.func.isRequired,
endCallAlert: PropTypes.func.isRequired,
};
static defaultProps = {
@@ -296,6 +297,12 @@ export default class DraftInput extends PureComponent {
const {intl} = this.context;
const {channelId, executeCommand, rootId, userIsOutOfOffice, theme} = this.props;
if (msg.trim() === '/call end') {
this.props.endCallAlert(channelId);
// NOTE: fallthrough because the server may want to handle the command as well
}
const status = DraftUtils.getStatusFromSlashCommand(msg);
if (userIsOutOfOffice && DraftUtils.isStatusSlashCommand(status)) {
confirmOutOfOfficeDisabled(intl, status, this.updateStatus);

View File

@@ -18,6 +18,7 @@ import {getAssociatedGroupsForReferenceMap} from '@mm-redux/selectors/entities/g
import {getTheme} from '@mm-redux/selectors/entities/preferences';
import {haveIChannelPermission} from '@mm-redux/selectors/entities/roles';
import {getCurrentUserId, getStatusForUserId} from '@mm-redux/selectors/entities/users';
import {endCallAlert} from '@mmproducts/calls/store/actions/calls';
import {isLandscape} from '@selectors/device';
import {getCurrentChannelDraft, getThreadDraft} from '@selectors/views';
@@ -103,6 +104,7 @@ const mapDispatchToProps = {
setStatus,
getChannelMemberCountsByGroup,
addRecentUsedEmojisInMessage,
endCallAlert,
};
export default connect(mapStateToProps, mapDispatchToProps, null, {forwardRef: true})(PostDraft);

View File

@@ -12,4 +12,8 @@ const RequiredServer = {
const PluginId = 'com.mattermost.calls';
export default {RequiredServer, RefreshConfigMillis, PluginId};
// Used for case when cloud server is using Calls v0.5.3.
// This can be removed when v0.5.4 is prepackaged in cloud.
const DefaultCloudMaxParticipants = 8;
export default {RequiredServer, RefreshConfigMillis, PluginId, DefaultCloudMaxParticipants};

View File

@@ -65,6 +65,7 @@ const WebsocketEvents = {
CALLS_USER_VOICE_ON: `custom_${Calls.PluginId}_user_voice_on`,
CALLS_USER_VOICE_OFF: `custom_${Calls.PluginId}_user_voice_off`,
CALLS_CALL_START: `custom_${Calls.PluginId}_call_start`,
CALLS_CALL_END: `custom_${Calls.PluginId}_call_end`,
CALLS_SCREEN_ON: `custom_${Calls.PluginId}_user_screen_on`,
CALLS_SCREEN_OFF: `custom_${Calls.PluginId}_user_screen_off`,
CALLS_USER_RAISE_HAND: `custom_${Calls.PluginId}_user_raise_hand`,

View File

@@ -9,6 +9,7 @@ export interface ClientCallsMix {
getCallsConfig: () => Promise<ServerConfig>;
enableChannelCalls: (channelId: string) => Promise<ServerChannelState>;
disableChannelCalls: (channelId: string) => Promise<ServerChannelState>;
endCall: (channelId: string) => Promise<any>;
}
const ClientCalls = (superclass: any) => class extends superclass {
@@ -51,6 +52,13 @@ const ClientCalls = (superclass: any) => class extends superclass {
{method: 'post', body: JSON.stringify({enabled: false})},
);
};
endCall = async (channelId: string) => {
return this.doFetch(
`${this.getCallsRoute()}/calls/${channelId}/end`,
{method: 'post'},
);
};
};
export default ClientCalls;

View File

@@ -57,31 +57,40 @@ exports[`CallMessage should match snapshot 1`] = `
<TouchableOpacity
onPress={[Function]}
style={
Object {
"alignContent": "center",
"alignItems": "center",
"backgroundColor": "#339970",
"borderRadius": 8,
"flexDirection": "row",
"padding": 12,
}
Array [
Object {
"alignContent": "center",
"alignItems": "center",
"backgroundColor": "#339970",
"borderRadius": 8,
"flexDirection": "row",
"padding": 12,
},
undefined,
]
}
>
<CompassIcon
name="phone-outline"
size={16}
style={
Object {
"color": "white",
"marginRight": 5,
}
Array [
Object {
"color": "white",
"marginRight": 5,
},
undefined,
]
}
/>
<Text
style={
Object {
"color": "white",
}
Array [
Object {
"color": "white",
},
undefined,
]
}
>
Join call
@@ -239,31 +248,40 @@ exports[`CallMessage should match snapshot for the call already in the current c
<TouchableOpacity
onPress={[Function]}
style={
Object {
"alignContent": "center",
"alignItems": "center",
"backgroundColor": "#339970",
"borderRadius": 8,
"flexDirection": "row",
"padding": 12,
}
Array [
Object {
"alignContent": "center",
"alignItems": "center",
"backgroundColor": "#339970",
"borderRadius": 8,
"flexDirection": "row",
"padding": 12,
},
undefined,
]
}
>
<CompassIcon
name="phone-outline"
size={16}
style={
Object {
"color": "white",
"marginRight": 5,
}
Array [
Object {
"color": "white",
"marginRight": 5,
},
undefined,
]
}
/>
<Text
style={
Object {
"color": "white",
}
Array [
Object {
"color": "white",
},
undefined,
]
}
>
Current call

View File

@@ -2,7 +2,7 @@
// See LICENSE.txt for license information.
import moment from 'moment-timezone';
import React, {useCallback} from 'react';
import React from 'react';
import {injectIntl, intlShape, IntlShape} from 'react-intl';
import {View, TouchableOpacity, Text} from 'react-native';
@@ -32,6 +32,7 @@ type CallMessageProps = {
currentChannelName: string;
callChannelName: string;
intl: typeof IntlShape;
isLimitRestricted: boolean;
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
@@ -66,10 +67,16 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
joinCallButtonText: {
color: 'white',
},
joinCallButtonTextRestricted: {
color: changeOpacity(theme.centerChannelColor, 0.32),
},
joinCallButtonIcon: {
color: 'white',
marginRight: 5,
},
joinCallButtonIconRestricted: {
color: changeOpacity(theme.centerChannelColor, 0.32),
},
startedText: {
color: theme.centerChannelColor,
fontWeight: 'bold',
@@ -82,6 +89,9 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
alignItems: 'center',
alignContent: 'center',
},
joinCallButtonRestricted: {
backgroundColor: changeOpacity(theme.centerChannelColor, 0.08),
},
timeText: {
color: theme.centerChannelColor,
},
@@ -98,14 +108,28 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
};
});
const CallMessage = ({post, user, teammateNameDisplay, confirmToJoin, alreadyInTheCall, theme, actions, userTimezone, isMilitaryTime, currentChannelName, callChannelName, intl}: CallMessageProps) => {
const CallMessage = ({
post,
user,
teammateNameDisplay,
confirmToJoin,
alreadyInTheCall,
theme,
actions,
userTimezone,
isMilitaryTime,
currentChannelName,
callChannelName,
intl,
isLimitRestricted,
}: CallMessageProps) => {
const style = getStyleSheet(theme);
const joinHandler = useCallback(() => {
if (alreadyInTheCall) {
const joinHandler = () => {
if (alreadyInTheCall || isLimitRestricted) {
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 (
@@ -142,6 +166,8 @@ const CallMessage = ({post, user, teammateNameDisplay, confirmToJoin, alreadyInT
);
}
const joinCallButtonText = alreadyInTheCall ? 'Current call' : 'Join call';
return (
<View style={style.messageStyle}>
<CompassIcon
@@ -161,22 +187,17 @@ const CallMessage = ({post, user, teammateNameDisplay, confirmToJoin, alreadyInT
</View>
<TouchableOpacity
style={style.joinCallButton}
style={[style.joinCallButton, isLimitRestricted && style.joinCallButtonRestricted]}
onPress={joinHandler}
>
<CompassIcon
name='phone-outline'
size={16}
style={style.joinCallButtonIcon}
style={[style.joinCallButtonIcon, isLimitRestricted && style.joinCallButtonIconRestricted]}
/>
{alreadyInTheCall &&
<Text
style={style.joinCallButtonText}
>{'Current call'}</Text>}
{!alreadyInTheCall &&
<Text
style={style.joinCallButtonText}
>{'Join call'}</Text>}
<Text style={[style.joinCallButtonText, isLimitRestricted && style.joinCallButtonTextRestricted]}>
{joinCallButtonText}
</Text>
</TouchableOpacity>
</View>
);

View File

@@ -11,7 +11,7 @@ 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 {getCalls, getCurrentCall, isLimitRestricted} from '@mmproducts/calls/store/selectors/calls';
import CallMessage from './call_message';
@@ -41,6 +41,7 @@ function mapStateToProps(state: GlobalState, ownProps: OwnProps) {
userTimezone: enableTimezone ? getUserCurrentTimezone(currentUser.timezone) : undefined,
currentChannelName: getChannel(state, post.channel_id)?.display_name,
callChannelName: currentCall ? getChannel(state, currentCall.channelId)?.display_name : '',
isLimitRestricted: isLimitRestricted(state, post.channel_id),
};
}

View File

@@ -10,7 +10,7 @@ import {GlobalState} from '@mm-redux/types/store';
import {Theme} from '@mm-redux/types/theme';
import leaveAndJoinWithAlert from '@mmproducts/calls/components/leave_and_join_alert';
import {useTryCallsFunction} from '@mmproducts/calls/hooks';
import {getCalls, getCurrentCall} from '@mmproducts/calls/store/selectors/calls';
import {getCalls, getCurrentCall, isLimitRestricted} from '@mmproducts/calls/store/selectors/calls';
import ChannelInfoRow from '@screens/channel_info/channel_info_row';
import Separator from '@screens/channel_info/separator';
import {preventDoubleTap} from '@utils/tap';
@@ -28,6 +28,7 @@ const StartCall = ({testID, theme, intl, joinCall}: Props) => {
const currentCall = useSelector(getCurrentCall);
const currentCallChannelId = currentCall?.channelId || '';
const callChannelName = useSelector((state: GlobalState) => getChannel(state, currentCallChannelId)?.display_name) || '';
const limitRestricted = useSelector(isLimitRestricted);
const confirmToJoin = Boolean(currentCall && currentCall.channelId !== currentChannel.id);
const alreadyInTheCall = Boolean(currentCall && call && currentCall.channelId === call.channelId);
@@ -39,7 +40,7 @@ const StartCall = ({testID, theme, intl, joinCall}: Props) => {
const [tryLeaveAndJoin, msgPostfix] = useTryCallsFunction(leaveAndJoin);
const handleStartCall = useCallback(preventDoubleTap(tryLeaveAndJoin), [tryLeaveAndJoin]);
if (alreadyInTheCall) {
if (alreadyInTheCall || limitRestricted) {
return null;
}

View File

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

View File

@@ -6,7 +6,7 @@ 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 {getCalls, getCurrentCall, isLimitRestricted} from '@mmproducts/calls/store/selectors/calls';
import JoinCall from './join_call';
@@ -23,6 +23,7 @@ function mapStateToProps(state: GlobalState) {
alreadyInTheCall: Boolean(currentCall && call && currentCall.channelId === call.channelId),
currentChannelName: getChannel(state, currentChannelId)?.display_name,
callChannelName: currentCall ? getChannel(state, currentCall.channelId)?.display_name : '',
isLimitRestricted: isLimitRestricted(state),
};
}

View File

@@ -38,6 +38,7 @@ describe('JoinCall', () => {
alreadyInTheCall: false,
currentChannelName: 'Current Channel',
callChannelName: 'Call Channel',
isLimitRestricted: false,
};
test('should match snapshot', () => {
@@ -56,7 +57,7 @@ describe('JoinCall', () => {
test('should join on click', () => {
const joinCall = jest.fn();
const props = {...baseProps, actions: {joinCall}};
const wrapper = shallowWithIntl(<JoinCall {...props}/>).dive();
const wrapper = shallowWithIntl(<JoinCall {...props}/>).dive().childAt(0);
wrapper.simulate('press');
expect(Alert.alert).not.toHaveBeenCalled();
@@ -66,7 +67,7 @@ describe('JoinCall', () => {
test('should ask for confirmation on click', () => {
const joinCall = jest.fn();
const props = {...baseProps, confirmToJoin: true, actions: {joinCall}};
const wrapper = shallowWithIntl(<JoinCall {...props}/>).dive();
const wrapper = shallowWithIntl(<JoinCall {...props}/>).dive().childAt(0);
wrapper.simulate('press');
expect(Alert.alert).toHaveBeenCalled();

View File

@@ -1,7 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useEffect, useMemo} from 'react';
import React, {useEffect, useMemo} from 'react';
import {injectIntl, IntlShape} from 'react-intl';
import {View, Text, Pressable} from 'react-native';
@@ -26,49 +26,62 @@ type Props = {
alreadyInTheCall: boolean;
currentChannelName: string;
callChannelName: string;
isLimitRestricted: boolean;
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 getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
outerContainer: {
backgroundColor: theme.centerChannelBg,
},
innerContainer: {
flexDirection: 'row',
backgroundColor: '#3DB887',
width: '100%',
padding: 5,
justifyContent: 'center',
alignItems: 'center',
height: JOIN_CALL_BAR_HEIGHT,
},
innerContainerRestricted: {
backgroundColor: changeOpacity(theme.centerChannelColor, 0.48),
},
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,
},
limitReached: {
flex: 1,
display: 'flex',
textAlign: 'right',
marginRight: 10,
color: '#FFFFFFD6',
fontWeight: '400',
},
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) {
@@ -82,9 +95,12 @@ const JoinCall = (props: Props) => {
};
}, [props.call, props.alreadyInTheCall]);
const joinHandler = useCallback(() => {
const joinHandler = () => {
if (props.isLimitRestricted) {
return;
}
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;
@@ -96,32 +112,39 @@ const JoinCall = (props: Props) => {
}, [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}
<View style={style.outerContainer}>
<Pressable
style={[style.innerContainer, props.isLimitRestricted && style.innerContainerRestricted]}
onPress={joinHandler}
>
<CompassIcon
name='phone-in-talk'
size={16}
style={style.joinCallIcon}
/>
</Text>
<View style={style.avatars}>
<Avatars
userIds={userIds}
breakAt={1}
listTitle={
<Text style={style.headerText}>{'Call participants'}</Text>
}
/>
</View>
</Pressable>
<Text style={style.joinCall}>{'Join Call'}</Text>
{props.isLimitRestricted ?
<Text style={style.limitReached}>
{'Participant limit reached'}
</Text> :
<Text style={style.started}>
<FormattedRelativeTime
value={props.call.startTime}
updateIntervalInSeconds={1}
/>
</Text>
}
<View style={style.avatars}>
<Avatars
userIds={userIds}
breakAt={1}
listTitle={
<Text style={style.headerText}>{'Call participants'}</Text>
}
/>
</View>
</Pressable>
</View>
);
};
export default injectIntl(JoinCall);

View File

@@ -4,6 +4,7 @@
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
import {deflate} from 'pako/lib/deflate.js';
import {DeviceEventEmitter, EmitterSubscription} from 'react-native';
import InCallManager from 'react-native-incall-manager';
import {
MediaStream,
@@ -12,6 +13,7 @@ import {
} from 'react-native-webrtc';
import {Client4} from '@client/rest';
import {WebsocketEvents} from '@constants';
import Peer from './simple-peer';
import WebSocketClient from './websocket';
@@ -20,12 +22,13 @@ export let client: any = null;
const websocketConnectTimeout = 3000;
export async function newClient(channelID: string, closeCb: () => void, setScreenShareURL: (url: string) => void) {
export async function newClient(channelID: string, iceServers: string[], closeCb: () => void, setScreenShareURL: (url: string) => void) {
let peer: Peer | null = null;
let stream: MediaStream;
let voiceTrackAdded = false;
let voiceTrack: MediaStreamTrack | null = null;
let isClosed = false;
let onCallEnd: EmitterSubscription | null = null;
const streams: MediaStream[] = [];
try {
@@ -47,6 +50,11 @@ export async function newClient(channelID: string, closeCb: () => void, setScree
ws.close();
}
if (onCallEnd) {
onCallEnd.remove();
onCallEnd = null;
}
streams.forEach((s) => {
s.getTracks().forEach((track: MediaStreamTrack) => {
track.stop();
@@ -65,6 +73,12 @@ export async function newClient(channelID: string, closeCb: () => void, setScree
}
};
onCallEnd = DeviceEventEmitter.addListener(WebsocketEvents.CALLS_CALL_END, ({channelId}) => {
if (channelId === channelID) {
disconnect();
}
});
const mute = () => {
if (!peer) {
return;
@@ -119,16 +133,8 @@ export async function newClient(channelID: string, closeCb: () => void, setScree
});
ws.on('join', async () => {
let config;
try {
config = await Client4.getCallsConfig();
} catch (err) {
console.log('ERROR FETCHING CALLS CONFIG:', err); // eslint-disable-line no-console
return;
}
InCallManager.start({media: 'audio'});
peer = new Peer(null, config.ICEServers);
peer = new Peer(null, iceServers);
peer.on('signal', (data: any) => {
if (data.type === 'offer' || data.type === 'answer') {
ws.send('sdp', {

View File

@@ -323,6 +323,16 @@ const CallScreen = (props: Props) => {
setShowControlsInLandscape(!showControlsInLandscape);
}, [showControlsInLandscape]);
useEffect(() => {
const listener = DeviceEventEmitter.addListener(WebsocketEvents.CALLS_CALL_END, ({channelId}) => {
if (channelId === props.call?.channelId) {
popTopScreen();
}
});
return () => listener.remove();
}, []);
if (!props.call) {
return null;
}

View File

@@ -6,6 +6,7 @@ import keyMirror from '@mm-redux/utils/key_mirror';
export default keyMirror({
RECEIVED_CALLS: null,
RECEIVED_CALL_STARTED: null,
RECEIVED_CALL_ENDED: null,
RECEIVED_CALL_FINISHED: null,
RECEIVED_CHANNEL_CALL_ENABLED: null,
RECEIVED_CHANNEL_CALL_DISABLED: null,

View File

@@ -117,11 +117,14 @@ describe('Actions.Calls', () => {
expect(CallsActions.ws.disconnect).not.toBeCalled();
const disconnectMock = CallsActions.ws.disconnect;
await store.dispatch(CallsActions.leaveCall());
// ws.disconnect calls the callback, which is what sends the CallsTypes.RECEIVED_MYSELF_LEFT_CALL action.
expect(disconnectMock).toBeCalled();
await store.dispatch({type: CallsTypes.RECEIVED_MYSELF_LEFT_CALL});
expect(CallsActions.ws).toBe(null);
result = store.getState().entities.calls.joined;
assert.equal('', result);
assert.equal(result, '');
});
it('muteMyself', async () => {

View File

@@ -2,6 +2,7 @@
// See LICENSE.txt for license information.
import {intlShape} from 'react-intl';
import {Alert} from 'react-native';
import InCallManager from 'react-native-incall-manager';
import {batch} from 'react-redux';
@@ -9,6 +10,10 @@ import {Client4} from '@client/rest';
import Calls from '@constants/calls';
import {logError} from '@mm-redux/actions/errors';
import {forceLogoutIfNecessary} from '@mm-redux/actions/helpers';
import {General} from '@mm-redux/constants';
import {getCurrentChannel} from '@mm-redux/selectors/entities/channels';
import {getTeammateNameDisplaySetting} from '@mm-redux/selectors/entities/preferences';
import {getCurrentUserId, getCurrentUserRoles, getUser} from '@mm-redux/selectors/entities/users';
import {
GenericAction,
ActionFunc,
@@ -17,10 +22,16 @@ import {
ActionResult,
} from '@mm-redux/types/actions';
import {Dictionary} from '@mm-redux/types/utilities';
import {displayUsername, isAdmin as checkIsAdmin} from '@mm-redux/utils/user_utils';
import {newClient} from '@mmproducts/calls/connection';
import CallsTypes from '@mmproducts/calls/store/action_types/calls';
import {getConfig} from '@mmproducts/calls/store/selectors/calls';
import {
getCallInCurrentChannel,
getConfig,
getNumCurrentConnectedParticipants,
} from '@mmproducts/calls/store/selectors/calls';
import {Call, CallParticipant, DefaultServerConfig} from '@mmproducts/calls/store/types/calls';
import {getUserIdFromDM} from '@mmproducts/calls/utils';
import {hasMicrophonePermission} from '@utils/permission';
export let ws: any = null;
@@ -82,6 +93,7 @@ export function loadCalls(): ActionFunc {
speakers: [],
screenOn: channel.call.screen_sharing_id,
threadId: channel.call.thread_id,
creatorId: channel.call.creator_id,
};
}
enabledChannels[channel.channel_id] = channel.enabled;
@@ -189,7 +201,10 @@ export function joinCall(channelId: string, intl: typeof intlShape): ActionFunc
dispatch(setSpeakerphoneOn(false));
try {
ws = await newClient(channelId, () => null, setScreenShareURL);
ws = await newClient(channelId, getConfig(getState()).ICEServers, () => {
dispatch(setSpeakerphoneOn(false));
dispatch({type: CallsTypes.RECEIVED_MYSELF_LEFT_CALL});
}, setScreenShareURL);
} catch (error) {
forceLogoutIfNecessary(error, dispatch, getState);
dispatch(logError(error));
@@ -212,13 +227,11 @@ export function joinCall(channelId: string, intl: typeof intlShape): ActionFunc
}
export function leaveCall(): ActionFunc {
return async (dispatch: DispatchFunc) => {
return async () => {
if (ws) {
ws.disconnect();
ws = null;
}
dispatch(setSpeakerphoneOn(false));
dispatch({type: CallsTypes.RECEIVED_MYSELF_LEFT_CALL});
return {};
};
}
@@ -260,3 +273,51 @@ export function setSpeakerphoneOn(newState: boolean): GenericAction {
data: newState,
};
}
export function endCallAlert(channelId: string): ActionFunc {
return async (dispatch: DispatchFunc, getState: GetStateFunc) => {
const userId = getCurrentUserId(getState());
const numParticipants = getNumCurrentConnectedParticipants(getState());
const channel = getCurrentChannel(getState());
const currentCall = getCallInCurrentChannel(getState());
const roles = getCurrentUserRoles(getState());
const isAdmin = checkIsAdmin(roles);
if (!isAdmin && userId !== currentCall?.creatorId) {
Alert.alert('Error', 'You do not have permission to end the call. Please ask the call creator to end call.');
return {};
}
let msg = `Are you sure you want to end a call with ${numParticipants} participants in ${channel.display_name}?`;
if (channel.type === General.DM_CHANNEL) {
const otherID = getUserIdFromDM(channel.name, getCurrentUserId(getState()));
const otherUser = getUser(getState(), otherID);
const nameDisplay = getTeammateNameDisplaySetting(getState());
msg = `Are you sure you want to end a call with ${displayUsername(otherUser, nameDisplay)}?`;
}
Alert.alert(
'End call',
msg,
[
{
text: 'Cancel',
},
{
text: 'End call',
onPress: async () => {
try {
await Client4.endCall(channelId);
} catch (e) {
const err = e.message || 'unable to complete command, see server logs';
Alert.alert('Error', `Error: ${err}`);
}
},
style: 'cancel',
},
],
);
return {};
};
}

View File

@@ -50,7 +50,15 @@ export function handleCallUserVoiceOff(msg: WebSocketMessage) {
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: {}},
data: {channelId: msg.data.channelID, startTime: msg.data.start_at, threadId: msg.data.thread_id, participants: {}, creatorId: msg.data.creator_id},
};
}
export function handleCallEnded(msg: WebSocketMessage): GenericAction {
DeviceEventEmitter.emit(WebsocketEvents.CALLS_CALL_END, {channelId: msg.broadcast.channel_id});
return {
type: CallsTypes.RECEIVED_CALL_ENDED,
data: {channelId: msg.broadcast.channel_id},
};
}

View File

@@ -145,6 +145,16 @@ describe('Reducers.calls.calls', () => {
assert.deepEqual(state.calls, {'channel-2': call2});
});
it('RECEIVED_CALL_ENDED', async () => {
const initialState = {calls: {'channel-1': call1, 'channel-2': call2}};
const testAction = {
type: CallsTypes.RECEIVED_CALL_FINISHED,
data: {channelId: 'channel-1'},
};
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 = {

View File

@@ -53,6 +53,11 @@ function calls(state: Dictionary<Call> = {}, action: GenericAction) {
nextState[newCall.channelId] = newCall;
return nextState;
}
case CallsTypes.RECEIVED_CALL_ENDED: {
const nextState = {...state};
delete nextState[action.data.channelId];
return nextState;
}
case CallsTypes.RECEIVED_CALL_FINISHED: {
const newCall = action.data;
const nextState = {...state};
@@ -162,6 +167,12 @@ function joined(state = '', action: GenericAction) {
case CallsTypes.RECEIVED_MYSELF_JOINED_CALL: {
return action.data;
}
case CallsTypes.RECEIVED_CALL_ENDED: {
if (action.data.channelId === state) {
return '';
}
return state;
}
case CallsTypes.RECEIVED_MYSELF_LEFT_CALL: {
return '';
}

View File

@@ -4,11 +4,12 @@
import assert from 'assert';
import deepFreezeAndThrowOnMutation from '@mm-redux/utils/deep_freeze';
import {DefaultServerConfig} from '@mmproducts/calls/store/types/calls';
import * as Selectors from './calls';
describe('Selectors.Calls', () => {
const call1 = {id: 'call1'};
const call1 = {id: 'call1', participants: [{id: 'me'}]};
const call2 = {id: 'call2'};
const testState = deepFreezeAndThrowOnMutation({
entities: {
@@ -20,6 +21,10 @@ describe('Selectors.Calls', () => {
joined: 'call1',
enabled: {'channel-1': true, 'channel-2': false},
screenShareURL: 'screenshare-url',
config: DefaultServerConfig,
},
general: {
license: {},
},
},
});
@@ -77,4 +82,119 @@ describe('Selectors.Calls', () => {
it('getScreenShareURL', () => {
assert.equal(Selectors.getScreenShareURL(testState), 'screenshare-url');
});
it('isLimitRestricted', () => {
// Default, no limit
assert.equal(Selectors.isLimitRestricted(testState, 'call1'), false);
let newState = {
...testState,
entities: {
...testState.entities,
calls: {
...testState.entities.calls,
config: {
...testState.entities.calls.config,
MaxCallParticipants: 1,
},
},
},
};
// Limit to 1 and one participant already in call.
assert.equal(Selectors.isLimitRestricted(newState, 'call1'), true);
// Limit to 1 but no call ongoing.
assert.equal(Selectors.isLimitRestricted(newState), false);
newState = {
...testState,
entities: {
...testState.entities,
general: {
license: {Cloud: 'true'},
},
},
};
// On cloud, no limit.
assert.equal(Selectors.isLimitRestricted(newState, 'call1'), false);
newState = {
...testState,
entities: {
...testState.entities,
calls: {
...testState.entities.calls,
config: {
...testState.entities.calls.config,
MaxCallParticipants: 1,
},
},
general: {
license: {Cloud: 'true'},
},
},
};
// On cloud, with limit.
assert.equal(Selectors.isLimitRestricted(newState, 'call1'), true);
const call = {id: 'call1',
participants: [
{},
{},
{},
{},
{},
{},
{},
]};
newState = {
...testState,
entities: {
...testState.entities,
calls: {
...testState.entities.calls,
calls: {call1: call},
},
general: {
license: {Cloud: 'true'},
},
},
};
delete newState.entities.calls.config.MaxCallParticipants;
// On cloud, MaxCallParticipants missing, default should be used.
assert.equal(Selectors.isLimitRestricted(newState, 'call1'), false);
const newCall = {id: 'call1',
participants: [
{},
{},
{},
{},
{},
{},
{},
{},
]};
newState = {
...testState,
entities: {
...testState.entities,
calls: {
...testState.entities.calls,
calls: {call1: newCall},
},
general: {
license: {Cloud: 'true'},
},
},
};
delete newState.entities.calls.config.MaxCallParticipants;
// On cloud, MaxCallParticipants missing, default should be used.
assert.equal(Selectors.isLimitRestricted(newState, 'call1'), true);
});
});

View File

@@ -1,12 +1,15 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {createSelector} from 'reselect';
import {Client4} from '@client/rest';
import Calls from '@constants/calls';
import {getCurrentChannelId} from '@mm-redux/selectors/entities/common';
import {getServerVersion} from '@mm-redux/selectors/entities/general';
import {getLicense, getServerVersion} from '@mm-redux/selectors/entities/general';
import {GlobalState} from '@mm-redux/types/store';
import {isMinimumServerVersion} from '@mm-redux/utils/helpers';
import {Call} from '@mmproducts/calls/store/types/calls';
export function getConfig(state: GlobalState) {
return state.entities.calls.config;
@@ -65,3 +68,49 @@ export function isSupportedServer(state: GlobalState) {
export function isCallsPluginEnabled(state: GlobalState) {
return state.entities.calls.pluginEnabled;
}
export const getCallInCurrentChannel: (state: GlobalState) => Call | undefined = createSelector(
getCurrentChannelId,
getCalls,
(currentChannelId, calls) => calls[currentChannelId],
);
export const getNumCurrentConnectedParticipants: (state: GlobalState) => number = createSelector(
getCurrentChannelId,
getCalls,
(currentChannelId, calls) => {
const participants = calls[currentChannelId]?.participants;
if (!participants) {
return 0;
}
return Object.keys(participants).length || 0;
},
);
const isCloud: (state: GlobalState) => boolean = createSelector(
getLicense,
(license) => license?.Cloud === 'true',
);
export const isLimitRestricted: (state: GlobalState, channelId?: string) => boolean = createSelector(
isCloud,
(state: GlobalState, channelId: string) => (channelId ? getCalls(state)[channelId] : getCallInCurrentChannel(state)),
getConfig,
(cloud, call, config) => {
if (!call) {
return false;
}
const numParticipants = Object.keys(call.participants || {}).length;
// TODO: The next block is used for case when cloud server is using Calls v0.5.3. This can be removed
// when v0.5.4 is prepackaged in cloud. Then replace the max in the return statement with
// config.MaxCallParticipants.
let max = config.MaxCallParticipants;
if (cloud && !max) {
max = Calls.DefaultCloudMaxParticipants;
}
return max !== 0 && numParticipants >= max;
},
);

View File

@@ -15,12 +15,13 @@ export type CallsState = {
}
export type Call = {
participants: Dictionary<CallParticipant>;
participants: Dictionary<CallParticipant>;
channelId: string;
startTime: number;
speakers: string[];
screenOn: string;
threadId: string;
creatorId: string;
}
export type CallParticipant = {
@@ -49,6 +50,7 @@ export type ServerCallState = {
states: ServerUserState[];
thread_id: string;
screen_sharing_id: string;
creator_id: string;
}
export type VoiceEventData = {
@@ -60,6 +62,8 @@ export type ServerConfig = {
ICEServers: string[];
AllowEnableCalls: boolean;
DefaultEnabled: boolean;
MaxCallParticipants: number;
sku_short_name: string;
last_retrieved_at: number;
}
@@ -67,5 +71,7 @@ export const DefaultServerConfig = {
ICEServers: [],
AllowEnableCalls: false,
DefaultEnabled: false,
MaxCallParticipants: 0,
sku_short_name: '',
last_retrieved_at: 0,
} as ServerConfig;

View File

@@ -48,3 +48,14 @@ const sortByState = (presenterID?: string) => {
return 0;
};
};
export function getUserIdFromDM(dmName: string, currentUserId: string) {
const ids = dmName.split('__');
let otherUserId = '';
if (ids[0] === currentUserId) {
otherUserId = ids[1];
} else {
otherUserId = ids[0];
}
return otherUserId;
}

View File

@@ -11,9 +11,12 @@
"about.teamEditiont1": "Корпоративна версия",
"about.title": "Относно {appTitle}",
"announcment_banner.dont_show_again": "Не показвай отново",
"api.channel.add_guest.added": "{addedUsername} е добавен към канала като гост от {username}.",
"api.channel.add_member.added": "{addedUsername} е добавен в канала от {username}.",
"api.channel.guest_join_channel.post_and_forget": "{username} се присъедини към канала като гост.",
"apps.error": "Грешка: {error}",
"apps.error.command.field_missing": "Липсват задължителни полета: `{fieldName}`.",
"apps.error.command.same_channel": "Повтаря се каналът с поле `{fieldName}`: `{option}`.",
"apps.error.command.unknown_channel": "Неизвестен канал за полето `{fieldName}`: `{option}`.",
"apps.error.command.unknown_option": "Неизвестна опция за полето `{fieldName}`: `{option}`.",
"apps.error.command.unknown_user": "Неизвестен потребител за поле `{fieldName}`: `{option}`.",

View File

@@ -30,7 +30,7 @@
"apps.error.form.refresh_no_refresh": "Frissítést hívott egy frissítés nélküli mezőn.",
"apps.error.form.submit.pretext": "Hiba történt az adatok elküldése során. Lépjen kapcsolatba az alkalmazás fejlesztőjével. Részletek: {details}",
"apps.error.lookup.error_preparing_request": "Hiba a keresési kérelem előkészítése során: {errorMessage}",
"apps.error.malformed_binding": "Ez az összekapcsolás nincs jól formázva. Lépjen kapcsolatba az App fejlesztőjével.",
"apps.error.malformed_binding": "Ez a kapcsolódás nincs jól formázva. Lépjen kapcsolatba az App fejlesztőjével.",
"apps.error.parser": "Feldolgozási hiba: {error}",
"apps.error.parser.empty_value": "üres értékek nem megengedettek",
"apps.error.parser.execute_non_leaf": "Ki kell választania egy alparancsot.",
@@ -39,7 +39,7 @@
"apps.error.parser.missing_list_end": "Az elvárt a lista záró token.",
"apps.error.parser.missing_quote": "Egyező idézőjel elvárt a bemenet vége előtt.",
"apps.error.parser.missing_source": "Az űrlapnak nincs sem submit sem source mezője.",
"apps.error.parser.missing_submit": "Nincs submit meghívás az összekapcsolásban vagy a formon.",
"apps.error.parser.missing_submit": "Nincs submit meghívás az összekötőben vagy az űrlapban.",
"apps.error.parser.missing_tick": "Azonos idézőjelel kell használni a bemenet vége előtt.",
"apps.error.parser.multiple_equal": "Többszörös `=` jel nem engedélyezett.",
"apps.error.parser.no_argument_pos_x": "Nem sikerült azonosítani az argumentumot.",

View File

@@ -16,35 +16,49 @@
"api.channel.guest_join_channel.post_and_forget": "{username}이(가) 게스트로 채널에 참여했습니다.",
"apps.error": "오류: {error}",
"apps.error.command.field_missing": "필수 필드 누락: `{fieldName}`.",
"apps.error.command.same_channel": "필드 `{fieldName}`에서 채널이 반복 지정되었습니다: `{option}`.",
"apps.error.command.same_option": "필드 `{fieldName}` 에서 옵션이 반복 지정되었습니다: `{option}`.",
"apps.error.command.same_user": "필드 `{fieldName}` 에서 사용자가 반복 지정되었습니다: `{option}`.",
"apps.error.command.unknown_channel": "`{fieldName}` 필드에 대한 알 수 없는 채널: `{option}`.",
"apps.error.command.unknown_option": "`{fieldName}` 필드에 대한 알 수 없는 옵션: `{option}`.",
"apps.error.command.unknown_user": "`{fieldName}` 필드의 알 수 없는 사용자: `{option}`.",
"apps.error.form.no_form": "`form`이 정의되어 있지 않습니다.",
"apps.error.form.no_lookup": "`lookup` 이 정의되어 있지 않습니다.",
"apps.error.form.no_source": "`source` 가 정의되어 있지 않습니다.",
"apps.error.form.no_submit": "`submit` 가 정의되어 있지 않습니다",
"apps.error.form.refresh": "선택한 필드를 가져오는 동안 오류가 발생했습니다. 앱 개발자에게 문의하세요. 세부정보: {details}",
"apps.error.form.refresh_no_refresh": "새로고침이 불가능한 필드에 대해 새로고침을 실행합니다.",
"apps.error.form.submit.pretext": "모달을 제출하는 중에 오류가 발생했습니다. 앱 개발자에게 문의하세요. 세부정보: {details}",
"apps.error.lookup.error_preparing_request": "조회 요청을 준비하는 중 오류 발생: {errorMessage}",
"apps.error.malformed_binding": "바인딩이 제대로 형성되지 않았습니다. 앱 개발자에게 문의하십시오.",
"apps.error.parser": "파싱 오류: {error}",
"apps.error.parser.empty_value": "빈 값은 허용되지 않습니다",
"apps.error.parser.execute_non_leaf": "하위 명령을 선택해야 합니다.",
"apps.error.parser.missing_binding": "커맨드 연결을 찾을 수 없습니다.",
"apps.error.parser.missing_field_value": "필드 값이 누락되었습니다.",
"apps.error.parser.missing_list_end": "목록을 닫는 토큰이 필요합니다.",
"apps.error.parser.missing_quote": "입력 종료 전에 \"가 필요합니다.",
"apps.error.parser.missing_source": "양식이 제출되지 않았거나 원본이 없습니다.",
"apps.error.parser.missing_submit": "바인딩 또는 양식에 제출 호출이 없습니다.",
"apps.error.parser.missing_tick": "입력 종료 전에 '가 필요합니다.",
"apps.error.parser.multiple_equal": "'=' 기호는 여러 개 사용할 수 없습니다.",
"apps.error.parser.no_argument_pos_x": "인수를 식별할 수 없습니다.",
"apps.error.parser.no_bindings": "바인딩 된 명령어가 없습니다.",
"apps.error.parser.no_form": "양식을 찾을 수 없습니다.",
"apps.error.parser.no_match": "`{command}`: 이 작업 공간에서 일치하는 명령어를 찾을 수 없습니다.",
"apps.error.parser.no_slash_start": "명령어는 '/'로 시작해야 합니다.",
"apps.error.parser.unexpected_character": "예기치 않은 문자.",
"apps.error.parser.unexpected_comma": "예기치 않은 콤마.",
"apps.error.parser.unexpected_error": "예기치 않은 오류입니다.",
"apps.error.parser.unexpected_flag": "명령어에서 '{flagName}' 플래그를 허용하지 않습니다.",
"apps.error.parser.unexpected_state": "연결할 수 없음: matchBinding에서 예기치 않은 상태: '{state}'.",
"apps.error.parser.unexpected_flag": "명령어에서`{flagName}`플래그를 허용하지 않습니다.",
"apps.error.parser.unexpected_squared_bracket": "예기치 않은 목록이 열립니다.",
"apps.error.parser.unexpected_state": "연결할 수 없음: matchBinding에서 예기치 않은 상태: `{state}`.",
"apps.error.parser.unexpected_whitespace": "연결할 수 없음: 예기치 않은 공백입니다.",
"apps.error.responses.form.no_form": "응답 유형은 `form`이지만 응답에 `form`이 포함되지 않았습니다.",
"apps.error.responses.navigate.no_url": "응답 유형은 `navigate`이지만, URL이 포함되지 않았습니다.",
"apps.error.responses.unexpected_error": "예기치 않은 오류를 수신했습니다.",
"apps.error.responses.unexpected_type": "예상하지 못한 앱 응답 유형입니다. 응답 유형: {type}.",
"apps.error.responses.unknown_field_error": "알 수 없는 필드에 대한 오류를 수신했습니다. 필드 이름: '{field}'. 오류: '{error}'.",
"apps.error.responses.unknown_field_error": "알 수 없는 필드에 대한 오류를 수신했습니다. 필드 이름: `{field}`. 오류: `{error}`.",
"apps.error.responses.unknown_type": "지원되지 않는 앱 응답 유형입니다. 응답 유형: {type}.",
"apps.error.unknown": "알 수 없는 오류가 발생했습니다.",
"apps.suggestion.dynamic.error": "동적 선택 오류",
@@ -401,6 +415,8 @@
"mobile.message_length.message": "Your current message is too long. Current character count: {max}/{count}",
"mobile.message_length.message_split_left": "메시지가 문자 제한을 초과합니다",
"mobile.message_length.title": "Message Length",
"mobile.microphone_permission_denied_description": "이 호출에 참여하려면 마이크로폰에 대한 가장 중요한 액세스 권한을 부여하기 위한 설정을 여십시오.",
"mobile.microphone_permission_denied_title": "{applicationName}이(가) 마이크에 액세스하려고 합니다",
"mobile.more_dms.add_more": "You can add {remaining, number} more users",
"mobile.more_dms.cannot_add_more": "You cannot add more users",
"mobile.more_dms.one_more": "You can add 1 more user",

View File

@@ -686,7 +686,7 @@
"post_body.check_for_out_of_channel_groups_mentions.message": "werd niet verwittigd door deze vermelding omdat deze niet in het kanaal zijn. Zij kunnen niet aan het kanaal toegevoegd worden omdat ze geen lid zijn van de gekoppelde groepen. Om hem aan het kanaal toe te voegen, moeten ze toegevoegd worden aan de gelinkte groepen.",
"post_body.check_for_out_of_channel_mentions.link.and": " en ",
"post_body.check_for_out_of_channel_mentions.link.private": "voeg ze toe aan dit privé-kanaal",
"post_body.check_for_out_of_channel_mentions.link.public": "voeg ze toe aan het kanaal",
"post_body.check_for_out_of_channel_mentions.link.public": "ze toevoegen aan het kanaal",
"post_body.check_for_out_of_channel_mentions.message.multiple": "niet door deze vermelding op de hoogte zijn gebracht omdat ze niet in het kanaal aanwezig zijn. Wil je ",
"post_body.check_for_out_of_channel_mentions.message.one": "niet door deze vermelding op de hoogte zijn gebracht omdat ze niet in het kanaal aanwezig zijn. Wil je ",
"post_body.check_for_out_of_channel_mentions.message_last": "? Ze zullen toegang hebben tot de volledige berichtgeschiedenis.",

View File

@@ -415,6 +415,8 @@
"mobile.message_length.message": "Ditt meddelande är för långt. Nuvarande antal tecken: {max}/{count}",
"mobile.message_length.message_split_left": "Meddelandet överskrider teckenbegränsningen",
"mobile.message_length.title": "Meddelandelängd",
"mobile.microphone_permission_denied_description": "För att delta i samtalet, öppna Inställningar och ge Mattermost tillåtelse att använda mikrofonen.",
"mobile.microphone_permission_denied_title": "{applicationName} behöver åtkomst till din mikrofon",
"mobile.more_dms.add_more": "Du kan lägga till {remaining, number} användare",
"mobile.more_dms.cannot_add_more": "Du kan lägga till 1 till användare",
"mobile.more_dms.one_more": "Du kan lägga till 1 till användare",
@@ -701,7 +703,7 @@
"rhs_thread.rootPostDeletedMessage.body": "Delar av denna tråd har raderats utifrån gallringsregler. Det går inte att svara i denna tråd.",
"search_bar.search": "Sök",
"search_header.results": "Sökresultat",
"search_header.title2": "Nyliga omnämnanden",
"search_header.title2": "Senaste omnämnanden",
"search_header.title3": "Sparade meddelanden",
"search_item.channelArchived": "Arkiverad",
"sidebar.channels": "PUBLIKA KANALER",

View File

@@ -415,6 +415,8 @@
"mobile.message_length.message": "您的消息过长。目前字数:{max}/{count}",
"mobile.message_length.message_split_left": "消息超过字数限制",
"mobile.message_length.title": "消息长度",
"mobile.microphone_permission_denied_description": "若要参与此呼叫,请打开“设置”以授予对您的麦克风“授权使用”的权限。",
"mobile.microphone_permission_denied_title": "{applicationName} 想使用您的麦克风",
"mobile.more_dms.add_more": "您还可以添加 {remaining, number} 位用户",
"mobile.more_dms.cannot_add_more": "您不能再添加更多用户",
"mobile.more_dms.one_more": "您还能添加 1 个用户",

View File

@@ -12,6 +12,7 @@
"about.title": "關於 {appTitle}",
"announcment_banner.dont_show_again": "不要再顯示",
"api.channel.add_member.added": "{username} 已將 {addedUsername} 加入頻道",
"apps.error": "錯誤:{error}",
"archivedChannelMessage": "正在觀看**被封存的頻道**。不能發布新訊息。",
"center_panel.archived.closeChannel": "關閉頻道",
"channel.channelHasGuests": "此頻道有訪客",

View File

@@ -8,16 +8,16 @@ GEM
artifactory (3.0.15)
atomos (0.1.3)
aws-eventstream (1.2.0)
aws-partitions (1.581.0)
aws-sdk-core (3.130.2)
aws-partitions (1.590.0)
aws-sdk-core (3.131.1)
aws-eventstream (~> 1, >= 1.0.2)
aws-partitions (~> 1, >= 1.525.0)
aws-sigv4 (~> 1.1)
jmespath (~> 1.0)
aws-sdk-kms (1.56.0)
jmespath (~> 1, >= 1.6.1)
aws-sdk-kms (1.57.0)
aws-sdk-core (~> 3, >= 3.127.0)
aws-sigv4 (~> 1.1)
aws-sdk-s3 (1.113.2)
aws-sdk-s3 (1.114.0)
aws-sdk-core (~> 3, >= 3.127.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.4)
@@ -66,7 +66,7 @@ GEM
faraday_middleware (1.2.0)
faraday (~> 1.0)
fastimage (2.2.6)
fastlane (2.205.2)
fastlane (2.206.1)
CFPropertyList (>= 2.3, < 4.0.0)
addressable (>= 2.8, < 3.0.0)
artifactory (~> 3.0)
@@ -111,9 +111,9 @@ GEM
fastlane-plugin-find_replace_string (0.1.0)
fastlane-plugin-versioning_android (0.1.0)
gh_inspector (1.1.3)
google-apis-androidpublisher_v3 (0.19.0)
google-apis-androidpublisher_v3 (0.21.0)
google-apis-core (>= 0.4, < 2.a)
google-apis-core (0.4.2)
google-apis-core (0.5.0)
addressable (~> 2.5, >= 2.5.1)
googleauth (>= 0.16.2, < 2.a)
httpclient (>= 2.8.1, < 3.a)
@@ -126,7 +126,7 @@ GEM
google-apis-core (>= 0.4, < 2.a)
google-apis-playcustomapp_v1 (0.7.0)
google-apis-core (>= 0.4, < 2.a)
google-apis-storage_v1 (0.13.0)
google-apis-storage_v1 (0.14.0)
google-apis-core (>= 0.4, < 2.a)
google-cloud-core (1.6.0)
google-cloud-env (~> 1.0)
@@ -154,7 +154,7 @@ GEM
domain_name (~> 0.5)
httpclient (2.8.3)
jmespath (1.6.1)
json (2.6.1)
json (2.6.2)
jwt (2.3.0)
memoist (0.16.2)
mini_magick (4.11.0)
@@ -164,7 +164,7 @@ GEM
multipart-post (2.0.0)
nanaimo (0.3.0)
naturally (2.2.1)
nokogiri (1.13.4)
nokogiri (1.13.6)
mini_portile2 (~> 2.8.0)
racc (~> 1.4)
optparse (0.1.1)
@@ -173,7 +173,7 @@ GEM
public_suffix (4.0.7)
racc (1.6.0)
rake (13.0.6)
representable (3.1.1)
representable (3.2.0)
declarative (< 0.1.0)
trailblazer-option (>= 0.1.1, < 0.2.0)
uber (< 0.2.0)

View File

@@ -911,7 +911,7 @@
CODE_SIGN_ENTITLEMENTS = Mattermost/Mattermost.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CURRENT_PROJECT_VERSION = 398;
CURRENT_PROJECT_VERSION = 404;
DEAD_CODE_STRIPPING = NO;
DEVELOPMENT_TEAM = UQ8HT4Q2XM;
ENABLE_BITCODE = NO;
@@ -953,7 +953,7 @@
CODE_SIGN_ENTITLEMENTS = Mattermost/Mattermost.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CURRENT_PROJECT_VERSION = 398;
CURRENT_PROJECT_VERSION = 404;
DEAD_CODE_STRIPPING = NO;
DEVELOPMENT_TEAM = UQ8HT4Q2XM;
ENABLE_BITCODE = NO;

View File

@@ -21,7 +21,7 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.52.0</string>
<string>1.53.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>
@@ -37,7 +37,7 @@
</dict>
</array>
<key>CFBundleVersion</key>
<string>398</string>
<string>404</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>LSRequiresIPhoneOS</key>

View File

@@ -19,9 +19,9 @@
<key>CFBundlePackageType</key>
<string>XPC!</string>
<key>CFBundleShortVersionString</key>
<string>1.52.0</string>
<string>1.53.0</string>
<key>CFBundleVersion</key>
<string>398</string>
<string>404</string>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>

View File

@@ -19,9 +19,9 @@
<key>CFBundlePackageType</key>
<string>XPC!</string>
<key>CFBundleShortVersionString</key>
<string>1.52.0</string>
<string>1.53.0</string>
<key>CFBundleVersion</key>
<string>398</string>
<string>404</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>

View File

@@ -733,7 +733,7 @@ SPEC CHECKSUMS:
FBLazyVector: 244195e30d63d7f564c55da4410b9a24e8fbceaa
FBReactNativeSpec: c94002c1d93da3658f4d5119c6994d19961e3d52
fmt: ff9d55029c625d3757ed641535fd4a75fedc7ce9
glog: 5337263514dd6f09803962437687240c5dc39aa4
glog: 85ecdd10ee8d8ec362ef519a6a45ff9aa27b2e85
HMSegmentedControl: 34c1f54d822d8308e7b24f5d901ec674dfa31352
iosMath: f7a6cbadf9d836d2149c2a84c435b1effc244cba
jail-monkey: 07b83767601a373db876e939b8dbf3f5eb15f073
@@ -746,7 +746,7 @@ SPEC CHECKSUMS:
Permission-Notifications: bb420c3d28328df24de1b476b41ed8249ccf2537
Permission-PhotoLibrary: 7bec836dcdd04a0bfb200c314f1aae06d4476357
Permission-PhotoLibraryAddOnly: 06fb0cdb1d35683b235ad8c464ef0ecc88859ea3
RCT-Folly: a21c126816d8025b547704b777a2ba552f3d9fa9
RCT-Folly: 803a9cfd78114b2ec0f140cfa6fa2a6bafb2d685
RCTRequired: cd47794163052d2b8318c891a7a14fcfaccc75ab
RCTTypeSafety: 393bb40b3e357b224cde53d3fec26813c52428b1
RCTYouTube: a8bb45705622a6fc9decf64be04128d3658ed411
@@ -824,4 +824,4 @@ SPEC CHECKSUMS:
PODFILE CHECKSUM: 8214414d5676358401d8ad51dff19e7fd8c71b5c
COCOAPODS: 1.11.2
COCOAPODS: 1.11.3

2
package-lock.json generated
View File

@@ -1,6 +1,6 @@
{
"name": "mattermost-mobile",
"version": "1.52.0",
"version": "1.53.0",
"lockfileVersion": 2,
"requires": true,
"packages": {

View File

@@ -1,6 +1,6 @@
{
"name": "mattermost-mobile",
"version": "1.52.0",
"version": "1.53.0",
"description": "Mattermost Mobile with React Native",
"repository": "git@github.com:mattermost/mattermost-mobile.git",
"author": "Mattermost, Inc.",