Compare commits

...

19 Commits

Author SHA1 Message Date
Elias Nahum
63ce6c7afb Version 1.48.2 build 382 (#5896)
* Bump app version number to  1.48.2

* Bump app build number to  382
2022-01-06 16:08:14 +02:00
Mattermost Build
7bea097c90 Fixes custom status update (#5881) (#5895)
(cherry picked from commit 16d4231ccd)

Co-authored-by: Shaz Amjad <shaz.amjad@mattermost.com>
2022-01-06 15:53:12 +02:00
Mattermost Build
0fe72d1c10 Bump Version 1.48.1 build 381 (#5860) (#5861)
* Bump app version number to  1.48.1

* Bump app build number to  381

(cherry picked from commit f453c77ac6)

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2021-12-03 11:46:26 +02:00
Elias Nahum
d3211b66a3 Fix android broken build cause by rudderstack (#5856)
* Fix android broken build cause by rudderstack

* update dependencies
2021-11-30 11:51:09 +02:00
Mattermost Build
39bdb69fdd Added YouTube query to Android Manifest (#5841) (#5855)
(cherry picked from commit 1c0d0bb1a4)

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2021-11-30 11:32:42 +02:00
Mattermost Build
8222390c98 MM-36687, MM-38302, MM-37598 Fix push notifications with CRT (#5669) (#5853)
* initalised

* Removed unused packages

* Android: Added groupId for supporting both threadId & channelId

* Fixed ios condition check

* Removed commented code

* Removed unwanted condition

* Removed unused variable

* CRT reduced chunk size to 30, Android global threads showing GlobalThreads & iOS is_crt_enabled field is expected to be a boolean

* Update android/app/src/main/java/com/mattermost/rnbeta/CustomPushNotification.java

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

* Misc fixes

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
Co-authored-by: Mattermod <mattermod@users.noreply.github.com>
(cherry picked from commit 805b90205a)

Co-authored-by: Anurag Shivarathri <anurag6713@gmail.com>
2021-11-30 11:32:28 +02:00
Mattermost Build
456208d223 [MM-40160] Crt gallery fix (#5833) (#5852)
* Fix Gallery crash

* fix file type icons

* memoize gallery files

(cherry picked from commit 1b285dac49)

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2021-11-30 11:32:17 +02:00
Mattermost Build
2e71fcc226 Bump app build number to 380 (#5828) (#5829)
(cherry picked from commit e4dafb4d3b)

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2021-11-11 16:53:15 -03:00
Mattermost Build
04d7834024 replace account-group-outline with account-multiple-outline compass icon (#5826) (#5827)
(cherry picked from commit a570fdea24)

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2021-11-11 16:25:49 -03:00
Mattermost Build
7888b971e1 Bump app build number to 379 (#5824) (#5825)
(cherry picked from commit 94f683d743)

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2021-11-11 08:36:49 -03:00
Jesús Espino
49462bd4a0 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>
2021-11-11 11:33:30 +01:00
Mattermost Build
eb8579e84c Fix options modal dismiss for OptionsModal (#5819) (#5822) 2021-11-10 09:27:57 -03:00
Puerco
41caf0d865 Automated cherry pick of #5816 on release-1.48 (#5817) 2021-11-08 19:54:47 +01:00
Mattermost Build
6a91b6544d Version 1.48.0 build 378 (#5811) (#5814)
* Bump app version number to  1.48.0

* Bump app build number to  378

(cherry picked from commit cbc4d5add2)

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2021-11-04 09:24:44 -03:00
Mattermost Build
75628cdf2e Disable reachability test (#5812) (#5813)
(cherry picked from commit ec3b44498a)

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2021-11-04 09:24:29 -03:00
Puerco
c8ee3bc722 Automated cherry pick of #5803 on release-1.48 (#5810)
* Translated using Weblate (Turkish)

Currently translated at 100.0% (778 of 778 strings)

Translation: mattermost-languages-shipped/mattermost-mobile
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_master/tr/

* Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (778 of 778 strings)

Translation: mattermost-languages-shipped/mattermost-mobile
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_master/zh_Hans/

* Translated using Weblate (Swedish)

Currently translated at 100.0% (778 of 778 strings)

Translation: mattermost-languages-shipped/mattermost-mobile
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_master/sv/

* Translated using Weblate (Polish)

Currently translated at 100.0% (778 of 778 strings)

Translation: mattermost-languages-shipped/mattermost-mobile
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_master/pl/

* Translated using Weblate (Hungarian)

Currently translated at 100.0% (778 of 778 strings)

Translation: mattermost-languages-shipped/mattermost-mobile
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_master/hu/

* Translated using Weblate (Spanish)

Currently translated at 100.0% (778 of 778 strings)

Translation: mattermost-languages-shipped/mattermost-mobile
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_master/es/

* Translated using Weblate (English (Australia))

Currently translated at 100.0% (778 of 778 strings)

Translation: mattermost-languages-shipped/mattermost-mobile
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_master/en_AU/

* Translated using Weblate (Dutch)

Currently translated at 100.0% (778 of 778 strings)

Translation: mattermost-languages-shipped/mattermost-mobile
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_master/nl/

Translated using Weblate (Dutch)

Currently translated at 100.0% (778 of 778 strings)

Translation: mattermost-languages-shipped/mattermost-mobile
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_master/nl/

* Translated using Weblate (German)

Currently translated at 100.0% (778 of 778 strings)

Translation: mattermost-languages-shipped/mattermost-mobile
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_master/de/

* Translated using Weblate (Korean)

Currently translated at 93.9% (730 of 777 strings)

Translation: mattermost-languages-shipped/mattermost-mobile
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_master/ko/

* Translated using Weblate (Japanese)

Currently translated at 100.0% (778 of 778 strings)

Translation: mattermost-languages-shipped/mattermost-mobile
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_master/ja/

Translated using Weblate (Japanese)

Currently translated at 100.0% (777 of 777 strings)

Translation: mattermost-languages-shipped/mattermost-mobile
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_master/ja/

Co-authored-by: Kaya Zeren <kayazeren@gmail.com>
Co-authored-by: aeomin <lin@aeomin.net>
Co-authored-by: MArtin Johnson <martinjohnson@bahnhof.se>
Co-authored-by: master7 <marcin.karkosz@rajska.info>
Co-authored-by: Tóth Csaba // Online ERP Hungary Kft <csaba.toth@online-erp.hu>
Co-authored-by: Elias  Nahum <elias@mattermost.com>
Co-authored-by: Matthew Williams <Matthew.Williams@outlook.com.au>
Co-authored-by: Tom De Moor <tom@controlaltdieliet.be>
Co-authored-by: jprusch <rs@schaeferbarthold.de>
Co-authored-by: teamzamong <heekang@korea.ac.kr>
Co-authored-by: kaakaa <stooner.hoe@gmail.com>
2021-11-04 09:07:55 +01:00
Mattermost Build
3011992995 Update NOTICE.txt (#5805) (#5809)
(cherry picked from commit 800f3b5648)

Co-authored-by: Amy Blais <29708087+amyblais@users.noreply.github.com>
2021-11-03 11:34:33 -03:00
Mattermost Build
e23960f27d Updated Jump to to match webapp (#5797) (#5808)
Co-authored-by: Mattermod <mattermod@users.noreply.github.com>
(cherry picked from commit fae944c208)

Co-authored-by: Carrie Warner (Mattermost) <74422101+cwarnermm@users.noreply.github.com>
2021-11-03 11:34:19 -03:00
Mattermost Build
84a292003e fix replaceAll with replace as is not available on some JSC (#5801) (#5806) 2021-11-02 04:14:15 -03:00
155 changed files with 20279 additions and 5027 deletions

View File

@@ -43,6 +43,41 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
---
## @mattermost/react-native-paste-input
This product contains '@mattermost/react-native-paste-input' by Mattermost.
React Native TextInput component have functionality to capture text input from a user by using the soft and hardware keyboards but lacks the ability to restrict copy & paste options as well as allwing pasting different files formats copied from other apps, like images & videos from the Photos gallery app.
* HOMEPAGE:
* https://github.com/mattermost/react-native-paste-input
* LICENSE: MIT
MIT License
Copyright (c) 2020 Elias Nahum
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
---
## @react-native-community/async-storage
This product contains 'async-storage' by Krzysztof Borowy.

View File

@@ -132,8 +132,8 @@ android {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
missingDimensionStrategy "RNNotifications.reactNativeVersion", "reactNative60"
versionCode 377
versionName "1.47.2"
versionCode 382
versionName "1.48.2"
multiDexEnabled = true
testBuildType System.getProperty('testBuildType', 'debug')
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'

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"
@@ -78,5 +82,9 @@
</intent-filter>
</activity>
</application>
<queries>
<intent>
<action android:name="com.google.android.youtube.api.service.START" />
</intent>
</queries>
</manifest>

View File

@@ -15,7 +15,6 @@ import androidx.annotation.Nullable;
import androidx.core.app.NotificationCompat;
import androidx.core.app.NotificationManagerCompat;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.Iterator;
@@ -30,7 +29,6 @@ import static com.wix.reactnativenotifications.Defs.NOTIFICATION_RECEIVED_EVENT_
import com.mattermost.react_native_interface.ResolvePromise;
import org.json.JSONArray;
import org.json.JSONObject;
public class CustomPushNotification extends PushNotification {
@@ -61,7 +59,7 @@ public class CustomPushNotification extends PushNotification {
editor.apply();
}
Map<String, List<Integer>> inputMap = new HashMap<>();
Map<String, Map<String, JSONObject>> inputMap = new HashMap<>();
saveNotificationsMap(context, inputMap);
}
} catch (PackageManager.NameNotFoundException e) {
@@ -69,55 +67,70 @@ public class CustomPushNotification extends PushNotification {
}
}
public static void cancelNotification(Context context, String channelId, Integer notificationId) {
public static void cancelNotification(Context context, String channelId, String rootId, Integer notificationId, Boolean isCRTEnabled) {
if (!android.text.TextUtils.isEmpty(channelId)) {
Map<String, List<Integer>> notificationsInChannel = loadNotificationsMap(context);
List<Integer> notifications = notificationsInChannel.get(channelId);
final String notificationIdStr = notificationId.toString();
final Boolean isThreadNotification = isCRTEnabled && !android.text.TextUtils.isEmpty(rootId);
final String groupId = isThreadNotification ? rootId : channelId;
Map<String, Map<String, JSONObject>> notificationsInChannel = loadNotificationsMap(context);
Map<String, JSONObject> notifications = notificationsInChannel.get(groupId);
if (notifications == null) {
return;
}
final NotificationManager notificationManager = context.getSystemService(NotificationManager.class);
notificationManager.cancel(notificationId);
notifications.remove(notificationId);
notifications.remove(notificationIdStr);
final StatusBarNotification[] statusNotifications = notificationManager.getActiveNotifications();
boolean hasMore = false;
for (final StatusBarNotification status : statusNotifications) {
if (status.getNotification().extras.getString("channel_id").equals(channelId)) {
hasMore = true;
Bundle bundle = status.getNotification().extras;
if (isThreadNotification) {
hasMore = bundle.getString("root_id").equals(rootId);
} else if (isCRTEnabled) {
hasMore = !bundle.getString("root_id").equals(rootId);
} else {
hasMore = bundle.getString("channel_id").equals(channelId);
}
if (hasMore) {
break;
}
}
if (!hasMore) {
notificationsInChannel.remove(channelId);
notificationsInChannel.remove(groupId);
} else {
notificationsInChannel.put(groupId, notifications);
}
saveNotificationsMap(context, notificationsInChannel);
}
}
public static void clearChannelNotifications(Context context, String channelId) {
public static void clearChannelNotifications(Context context, String channelId, String rootId, Boolean isCRTEnabled) {
if (!android.text.TextUtils.isEmpty(channelId)) {
Map<String, List<Integer>> notificationsInChannel = loadNotificationsMap(context);
List<Integer> notifications = notificationsInChannel.get(channelId);
final NotificationManagerCompat notificationManager = NotificationManagerCompat.from(context);
// rootId is available only when CRT is enabled & clearing the thread
final boolean isClearThread = isCRTEnabled && !android.text.TextUtils.isEmpty(rootId);
Map<String, Map<String, JSONObject>> notificationsInChannel = loadNotificationsMap(context);
String groupId = isClearThread ? rootId : channelId;
Map<String, JSONObject> notifications = notificationsInChannel.get(groupId);
if (notifications == null) {
return;
}
notificationsInChannel.remove(channelId);
notificationsInChannel.remove(groupId);
saveNotificationsMap(context, notificationsInChannel);
for (final Integer notificationId : notifications) {
final NotificationManagerCompat notificationManager = NotificationManagerCompat.from(context);
notificationManager.cancel(notificationId);
}
notifications.forEach(
(notificationIdStr, post) -> notificationManager.cancel(Integer.valueOf(notificationIdStr))
);
}
}
public static void clearAllNotifications(Context context) {
if (context != null) {
Map<String, List<Integer>> notificationsInChannel = loadNotificationsMap(context);
Map<String, Map<String, JSONObject>> notificationsInChannel = loadNotificationsMap(context);
notificationsInChannel.clear();
saveNotificationsMap(context, notificationsInChannel);
final NotificationManagerCompat notificationManager = NotificationManagerCompat.from(context);
@@ -132,6 +145,8 @@ public class CustomPushNotification extends PushNotification {
final String ackId = initialData.getString("ack_id");
final String postId = initialData.getString("post_id");
final String channelId = initialData.getString("channel_id");
final String rootId = initialData.getString("root_id");
final boolean isCRTEnabled = initialData.getString("is_crt_enabled") != null && initialData.getString("is_crt_enabled").equals("true");
final boolean isIdLoaded = initialData.getString("id_loaded") != null && initialData.getString("id_loaded").equals("true");
int notificationId = CustomPushNotificationHelper.MESSAGE_NOTIFICATION_ID;
if (postId != null) {
@@ -165,24 +180,41 @@ public class CustomPushNotification extends PushNotification {
if (type.equals(PUSH_TYPE_MESSAGE)) {
if (channelId != null) {
Map<String, List<Integer>> notificationsInChannel = loadNotificationsMap(mContext);
List<Integer> list = notificationsInChannel.get(channelId);
if (list == null) {
list = Collections.synchronizedList(new ArrayList(0));
}
try {
list.add(0, notificationId);
if (list.size() > 1) {
createSummary = false;
}
JSONObject post = new JSONObject();
if (!android.text.TextUtils.isEmpty(rootId)) {
post.put("root_id", rootId);
}
if (!android.text.TextUtils.isEmpty(postId)) {
post.put("post_id", postId);
}
if (createSummary) {
// Add the summary notification id as well
list.add(0, notificationId + 1);
}
final Boolean isThreadNotification = isCRTEnabled && post.has("root_id");
final String groupId = isThreadNotification ? rootId : channelId;
Map<String, Map<String, JSONObject>> notificationsInChannel = loadNotificationsMap(mContext);
Map<String, JSONObject> notifications = notificationsInChannel.get(groupId);
if (notifications == null) {
notifications = Collections.synchronizedMap(new HashMap<String, JSONObject>());
}
notificationsInChannel.put(channelId, list);
saveNotificationsMap(mContext, notificationsInChannel);
if (notifications.size() > 0) {
createSummary = false;
}
notifications.put(String.valueOf(notificationId), post);
if (createSummary) {
// Add the summary notification id as well
notifications.put(String.valueOf(notificationId + 1), new JSONObject());
}
notificationsInChannel.put(groupId, notifications);
saveNotificationsMap(mContext, notificationsInChannel);
} catch(Exception e) {
e.printStackTrace();
}
}
}
@@ -190,7 +222,7 @@ public class CustomPushNotification extends PushNotification {
}
break;
case PUSH_TYPE_CLEAR:
clearChannelNotifications(mContext, channelId);
clearChannelNotifications(mContext, channelId, rootId, isCRTEnabled);
break;
}
@@ -205,22 +237,11 @@ public class CustomPushNotification extends PushNotification {
Bundle data = mNotificationProps.asBundle();
final String channelId = data.getString("channel_id");
final String postId = data.getString("post_id");
Integer notificationId = CustomPushNotificationHelper.MESSAGE_NOTIFICATION_ID;
if (postId != null) {
notificationId = postId.hashCode();
}
final String rootId = data.getString("root_id");
final Boolean isCRTEnabled = data.getBoolean("is_crt_enabled");
if (channelId != null) {
Map<String, List<Integer>> notificationsInChannel = loadNotificationsMap(mContext);
List<Integer> notifications = notificationsInChannel.get(channelId);
if (notifications == null) {
return;
}
notifications.remove(notificationId);
saveNotificationsMap(mContext, notificationsInChannel);
clearChannelNotifications(mContext, channelId);
clearChannelNotifications(mContext, channelId, rootId, isCRTEnabled);
}
}
@@ -253,7 +274,7 @@ public class CustomPushNotification extends PushNotification {
mJsIOHelper.sendEventToJS(NOTIFICATION_RECEIVED_EVENT_NAME, mNotificationProps.asBundle(), mAppLifecycleFacade.getRunningReactContext());
}
private static void saveNotificationsMap(Context context, Map<String, List<Integer>> inputMap) {
private static void saveNotificationsMap(Context context, Map<String, Map<String, JSONObject>> inputMap) {
SharedPreferences pSharedPref = context.getSharedPreferences(PUSH_NOTIFICATIONS, Context.MODE_PRIVATE);
if (pSharedPref != null && context != null) {
JSONObject json = new JSONObject(inputMap);
@@ -265,23 +286,41 @@ public class CustomPushNotification extends PushNotification {
}
}
private static Map<String, List<Integer>> loadNotificationsMap(Context context) {
Map<String, List<Integer>> outputMap = new HashMap<>();
/**
* Map Structure
*
* {
* channel_id1 | thread_id1: {
* notification_id1: {
* post_id: 'p1',
* root_id: 'r1',
* }
* }
* }
*
*/
private static Map<String, Map<String, JSONObject>> loadNotificationsMap(Context context) {
Map<String, Map<String, JSONObject>> outputMap = new HashMap<>();
if (context != null) {
SharedPreferences pSharedPref = context.getSharedPreferences(PUSH_NOTIFICATIONS, Context.MODE_PRIVATE);
try {
if (pSharedPref != null) {
String jsonString = pSharedPref.getString(NOTIFICATIONS_IN_CHANNEL, (new JSONObject()).toString());
JSONObject json = new JSONObject(jsonString);
Iterator<String> keysItr = json.keys();
while (keysItr.hasNext()) {
String key = keysItr.next();
JSONArray array = json.getJSONArray(key);
List<Integer> values = new ArrayList<>();
for (int i = 0; i < array.length(); ++i) {
values.add(array.getInt(i));
// Can be a channel_id or thread_id
Iterator<String> groupIdsItr = json.keys();
while (groupIdsItr.hasNext()) {
String groupId = groupIdsItr.next();
JSONObject notificationsJSONObj = json.getJSONObject(groupId);
Map<String, JSONObject> notifications = new HashMap<>();
Iterator<String> notificationIdKeys = notificationsJSONObj.keys();
while(notificationIdKeys.hasNext()) {
String notificationId = notificationIdKeys.next();
JSONObject post = notificationsJSONObj.getJSONObject(notificationId);
notifications.put(notificationId, post);
}
outputMap.put(key, values);
outputMap.put(groupId, notifications);
}
}
} catch (Exception e) {

View File

@@ -98,6 +98,16 @@ public class CustomPushNotificationHelper {
userInfoBundle = new Bundle();
}
String postId = bundle.getString("post_id");
if (postId != null) {
userInfoBundle.putString("post_id", postId);
}
String rootId = bundle.getString("root_id");
if (rootId != null) {
userInfoBundle.putString("root_id", rootId);
}
String channelId = bundle.getString("channel_id");
if (channelId != null) {
userInfoBundle.putString("channel_id", channelId);
@@ -145,13 +155,17 @@ public class CustomPushNotificationHelper {
String channelId = bundle.getString("channel_id");
String postId = bundle.getString("post_id");
String rootId = bundle.getString("root_id");
int notificationId = postId != null ? postId.hashCode() : MESSAGE_NOTIFICATION_ID;
NotificationPreferences notificationPreferences = NotificationPreferences.getInstance(context);
Boolean is_crt_enabled = bundle.getString("is_crt_enabled") != null && bundle.getString("is_crt_enabled").equals("true");
String groupId = is_crt_enabled && !android.text.TextUtils.isEmpty(rootId) ? rootId : channelId;
addNotificationExtras(notification, bundle);
setNotificationIcons(context, notification, bundle);
setNotificationMessagingStyle(context, notification, bundle);
setNotificationGroup(notification, channelId, createSummary);
setNotificationGroup(notification, groupId, createSummary);
setNotificationBadgeType(notification);
setNotificationSound(notification, notificationPreferences);
setNotificationVibrate(notification, notificationPreferences);

View File

@@ -19,6 +19,9 @@ public class NotificationDismissService extends IntentService {
final Bundle bundle = NotificationIntentAdapter.extractPendingNotificationDataFromIntent(intent);
final String channelId = bundle.getString("channel_id");
final String postId = bundle.getString("post_id");
final String rootId = bundle.getString("root_id");
final Boolean isCRTEnabled = bundle.getString("is_crt_enabled") != null && bundle.getString("is_crt_enabled").equals("true");
int notificationId = CustomPushNotificationHelper.MESSAGE_NOTIFICATION_ID;
if (postId != null) {
notificationId = postId.hashCode();
@@ -26,7 +29,7 @@ public class NotificationDismissService extends IntentService {
notificationId = channelId.hashCode();
}
CustomPushNotification.cancelNotification(context, channelId, notificationId);
CustomPushNotification.cancelNotification(context, channelId, rootId, notificationId, isCRTEnabled);
Log.i("ReactNative", "Dismiss notification");
}
}

View File

@@ -118,6 +118,10 @@ public class NotificationPreferencesModule extends ReactContextBaseJavaModule {
WritableMap map = Arguments.createMap();
Notification n = sbn.getNotification();
Bundle bundle = n.extras;
String postId = bundle.getString("post_id");
map.putString("post_id", postId);
String rootId = bundle.getString("root_id");
map.putString("root_id", rootId);
String channelId = bundle.getString("channel_id");
map.putString("channel_id", channelId);
result.pushMap(map);
@@ -126,8 +130,9 @@ public class NotificationPreferencesModule extends ReactContextBaseJavaModule {
}
@ReactMethod
public void removeDeliveredNotifications(String channelId) {
public void removeDeliveredNotifications(String channelId, String rootId, Boolean isCRTEnabled) {
final Context context = mApplication.getApplicationContext();
CustomPushNotification.clearChannelNotifications(context, channelId);
CustomPushNotification.clearChannelNotifications(context, channelId, rootId, isCRTEnabled);
}
}

View File

@@ -52,9 +52,6 @@ allprojects {
maven {
url "https://www.jitpack.io"
}
maven {
url ("https://dl.bintray.com/rudderstack/rudderstack")
}
maven {
url "$rootDir/../node_modules/detox/Detox-android"
}

View File

@@ -390,7 +390,7 @@ export function markAsViewedAndReadBatch(state, channelId, prevChannelId = '', m
type: ChannelTypes.SET_UNREAD_MSG_COUNT,
data: {
channelId,
count: unreadMessageCount,
count: isCollapsedThreadsEnabled(state) ? unreadMessageCountRoot : unreadMessageCount,
},
}, {
type: ChannelTypes.DECREMENT_UNREAD_MSG_COUNT,

View File

@@ -19,6 +19,11 @@ export function setCustomStatus(customStatus: UserCustomStatus): ActionFunc {
user.props.customStatus = JSON.stringify(customStatus);
dispatch({type: UserTypes.RECEIVED_ME, data: user});
// Server does not like empty 'expires_at' string.
if (!customStatus.expires_at) {
delete customStatus.expires_at;
}
try {
await Client4.updateCustomStatus(customStatus);
} catch (error) {

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

@@ -66,7 +66,7 @@ const GroupMentionItem = (props) => {
>
<View style={style.rowPicture}>
<CompassIcon
name='account-group-outline'
name='account-multiple-outline'
style={style.rowIcon}
/>
</View>

View File

@@ -48,7 +48,7 @@ export default class SpecialMentionItem extends PureComponent {
<View style={style.row}>
<View style={style.rowPicture}>
<CompassIcon
name='account-group-outline'
name='account-multiple-outline'
style={style.rowIcon}
/>
</View>

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

@@ -200,4 +200,6 @@ const DocumentFile = forwardRef<DocumentFileRef, DocumentFileProps>(({background
);
});
DocumentFile.displayName = 'DocumentFile';
export default injectIntl(DocumentFile, {withRef: true});

View File

@@ -25,21 +25,21 @@ const BLUE_ICON = '#338AFF';
const RED_ICON = '#ED522A';
const GREEN_ICON = '#1CA660';
const GRAY_ICON = '#999999';
const FAILED_ICON_NAME_AND_COLOR = ['jumbo-attachment-image-broken', GRAY_ICON];
const FAILED_ICON_NAME_AND_COLOR = ['file-image-broken-outline-large', GRAY_ICON];
const ICON_NAME_AND_COLOR_FROM_FILE_TYPE: Record<string, string[]> = {
audio: ['jumbo-attachment-audio', BLUE_ICON],
code: ['jumbo-attachment-code', BLUE_ICON],
image: ['jumbo-attachment-image', BLUE_ICON],
audio: ['file-audio-outline-large', BLUE_ICON],
code: ['file-code-outline-large', BLUE_ICON],
image: ['file-image-outline-large', BLUE_ICON],
smallImage: ['image-outline', BLUE_ICON],
other: ['jumbo-attachment-generic', BLUE_ICON],
patch: ['jumbo-attachment-patch', BLUE_ICON],
pdf: ['jumbo-attachment-pdf', RED_ICON],
presentation: ['jumbo-attachment-powerpoint', RED_ICON],
spreadsheet: ['jumbo-attachment-excel', GREEN_ICON],
text: ['jumbo-attachment-text', GRAY_ICON],
video: ['jumbo-attachment-video', BLUE_ICON],
word: ['jumbo-attachment-word', BLUE_ICON],
zip: ['jumbo-attachment-zip', BLUE_ICON],
other: ['file-generic-outline-large', BLUE_ICON],
patch: ['file-patch-outline-large', BLUE_ICON],
pdf: ['file-pdf-outline-large', RED_ICON],
presentation: ['file-powerpoint-outline-large', RED_ICON],
spreadsheet: ['file-excel-outline-large', GREEN_ICON],
text: ['file-text-outline-large', GRAY_ICON],
video: ['file-video-outline-large', BLUE_ICON],
word: ['file-word-outline-large', BLUE_ICON],
zip: ['file-zip-outline-large', BLUE_ICON],
};
const styles = StyleSheet.create({

View File

@@ -1,7 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useEffect, useRef, useState} from 'react';
import React, {useEffect, useMemo, useState} from 'react';
import {DeviceEventEmitter, StyleProp, StyleSheet, View, ViewStyle} from 'react-native';
import {Client4} from '@client/rest';
@@ -46,10 +46,10 @@ const Files = ({canDownloadFiles, failed, files, isReplyPost, postId, theme}: Fi
const [inViewPort, setInViewPort] = useState(false);
const permanentSidebar = usePermanentSidebar();
const isSplitView = useSplitView();
const imageAttachments = useRef<FileInfo[]>([]).current;
const nonImageAttachments = useRef<FileInfo[]>([]).current;
const {imageAttachments, nonImageAttachments} = useMemo(() => {
const images: FileInfo[] = [];
const nonImages: FileInfo[] = [];
if (!imageAttachments.length && !nonImageAttachments.length) {
files.reduce((info, file) => {
if (isImage(file)) {
let uri;
@@ -58,15 +58,17 @@ const Files = ({canDownloadFiles, failed, files, isReplyPost, postId, theme}: Fi
} else {
uri = isGif(file) ? Client4.getFileUrl(file.id, 0) : Client4.getFilePreviewUrl(file.id, 0);
}
info.imageAttachments.push({...file, uri});
info.images.push({...file, uri});
} else {
info.nonImageAttachments.push(file);
info.nonImages.push(file);
}
return info;
}, {imageAttachments, nonImageAttachments});
}
}, {images, nonImages});
const filesForGallery = useRef<FileInfo[]>(imageAttachments.concat(nonImageAttachments)).current;
return {imageAttachments: images, nonImageAttachments: nonImages};
}, [files]);
const filesForGallery = imageAttachments.concat(nonImageAttachments);
const attachmentIndex = (fileId: string) => {
return filesForGallery.findIndex((file) => file.id === fileId) || 0;
};

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

@@ -146,7 +146,7 @@ export default class ChannelsList extends PureComponent {
<SearchBar
testID={searchBarTestID}
ref={this.setSearchBarRef}
placeholder={intl.formatMessage({id: 'mobile.channel_drawer.search', defaultMessage: 'Jump to...'})}
placeholder={intl.formatMessage({id: 'mobile.channel_drawer.search', defaultMessage: 'Find channel'})}
cancelTitle={intl.formatMessage({id: 'mobile.post.cancel', defaultMessage: 'Cancel'})}
backgroundColor='transparent'
inputHeight={36}

View File

@@ -431,7 +431,7 @@ export default class List extends PureComponent {
case CategoryTypes.DIRECT_MESSAGES:
return CategoryTypes.DIRECT_MESSAGES.toLowerCase();
default:
return name.replaceAll(' ', '_').toLowerCase();
return name.replace(/ /g, '_').toLowerCase();
}
};

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 = {
@@ -120,7 +125,7 @@ export default {
...ViewTypes,
RequiredServer,
POST_VISIBILITY_CHUNK_SIZE: 60,
CRT_CHUNK_SIZE: 60,
CRT_CHUNK_SIZE: 30,
FEATURE_TOGGLE_PREFIX: 'feature_enabled_',
EMBED_PREVIEW: 'embed_preview',
LINK_PREVIEW_DISPLAY: 'link_previews',

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

@@ -5,6 +5,7 @@
import {Notifications} from 'react-native-notifications';
import * as Preferences from '@mm-redux/selectors/entities/preferences';
import * as ViewSelectors from '@selectors/views';
import Store from '@store/store';
@@ -49,13 +50,118 @@ describe('PushNotification', () => {
// Clear channel1 notifications
await PushNotification.clearChannelNotifications(channel1ID);
await Notifications.ios.getDeliveredNotifications(async (deliveredNotifs) => {
expect(deliveredNotifs.length).toBe(2);
const channel1DeliveredNotifications = deliveredNotifs.filter((n) => n.channel_id === channel1ID);
const channel2DeliveredNotifications = deliveredNotifs.filter((n) => n.channel_id === channel2ID);
expect(channel1DeliveredNotifications.length).toBe(0);
expect(channel2DeliveredNotifications.length).toBe(2);
});
const deliveredNotifs = await Notifications.ios.getDeliveredNotifications();
expect(deliveredNotifs.length).toBe(2);
const channel1DeliveredNotifications = deliveredNotifs.filter((n) => n.channel_id === channel1ID);
const channel2DeliveredNotifications = deliveredNotifs.filter((n) => n.channel_id === channel2ID);
expect(channel1DeliveredNotifications.length).toBe(0);
expect(channel2DeliveredNotifications.length).toBe(2);
});
it('should clear root posts only from the channel notifications when CRT is enabled', async () => {
Store.redux = {
getState: jest.fn(),
};
Preferences.isCollapsedThreadsEnabled = jest.fn().mockImplementation(() => true);
ViewSelectors.getBadgeCount = jest.fn().mockReturnValue(5);
const deliveredNotifications = [
// Three channel1 delivered notifications
{
identifier: 'channel1-1',
channel_id: channel1ID,
root_id: 'root-id-1',
},
{
identifier: 'channel1-2',
channel_id: channel1ID,
},
{
identifier: 'channel1-3',
channel_id: channel1ID,
},
// Two channel2 delivered notifications
{
identifier: 'channel2-1',
channel_id: channel2ID,
root_id: 'root-id-2',
},
{
identifier: 'channel2-2',
channel_id: channel2ID,
},
];
Notifications.setDeliveredNotifications(deliveredNotifications);
const notificationCount = deliveredNotifications.length;
expect(notificationCount).toBe(5);
// Clear channel1 notifications
await PushNotification.clearChannelNotifications(channel1ID);
const deliveredNotifs = await Notifications.ios.getDeliveredNotifications();
expect(deliveredNotifs.length).toBe(3);
const channel1DeliveredNotifications = deliveredNotifs.filter((n) => n.channel_id === channel1ID);
const channel2DeliveredNotifications = deliveredNotifs.filter((n) => n.channel_id === channel2ID);
expect(channel1DeliveredNotifications.length).toBe(1);
expect(channel2DeliveredNotifications.length).toBe(2);
});
it('should clear all thread notifications', async () => {
Store.redux = null;
ViewSelectors.getBadgeCount = jest.fn().mockReturnValue(5);
const root1ID = 'root-1-id';
const root2ID = 'root-2-id';
const root3ID = 'root-3-id';
const deliveredNotifications = [
// Three channel1 delivered notifications
{
identifier: 'channel1-1',
channel_id: channel1ID,
root_id: root1ID,
},
{
identifier: 'channel1-2',
channel_id: channel1ID,
root_id: root1ID,
},
{
identifier: 'channel1-3',
channel_id: channel1ID,
root_id: root2ID,
},
// Two channel2 delivered notifications
{
identifier: 'channel2-2',
channel_id: channel2ID,
},
{
identifier: 'channel2-2',
channel_id: channel2ID,
root_id: root3ID,
},
];
Notifications.setDeliveredNotifications(deliveredNotifications);
const notificationCount = deliveredNotifications.length;
expect(notificationCount).toBe(5);
// Clear channel1 notifications
await PushNotification.clearChannelNotifications(channel1ID, root1ID);
const deliveredNotifs = await Notifications.ios.getDeliveredNotifications();
expect(deliveredNotifs.length).toBe(3);
const channel1DeliveredNotifications = deliveredNotifs.filter((n) => n.channel_id === channel1ID);
const channel2DeliveredNotifications = deliveredNotifs.filter((n) => n.channel_id === channel2ID);
expect(channel1DeliveredNotifications.length).toBe(1);
expect(channel2DeliveredNotifications.length).toBe(2);
});
it('should clear all notifications', async () => {
@@ -63,7 +169,7 @@ describe('PushNotification', () => {
const cancelAllLocalNotifications = jest.spyOn(PushNotification, 'cancelAllLocalNotifications');
PushNotification.clearNotifications();
await expect(setApplicationIconBadgeNumber).toHaveBeenCalledWith(0);
expect(setApplicationIconBadgeNumber).toHaveBeenCalledWith(0);
expect(Notifications.ios.setBadgeCount).toHaveBeenCalledWith(0);
expect(cancelAllLocalNotifications).toHaveBeenCalled();
expect(Notifications.cancelAllLocalNotifications).toHaveBeenCalled();

View File

@@ -46,6 +46,8 @@ const NOTIFICATION_TYPE = {
interface NotificationWithChannel extends Notification {
identifier: string;
channel_id: string;
post_id: string;
root_id: string;
}
class PushNotifications {
@@ -61,6 +63,13 @@ class PushNotifications {
this.getInitialNotification();
}
getNotifications = async (): Promise<NotificationWithChannel[]> => {
if (Platform.OS === 'android') {
return AndroidNotificationPreferences.getDeliveredNotifications();
}
return Notifications.ios.getDeliveredNotifications() as Promise<NotificationWithChannel[]>;
}
cancelAllLocalNotifications() {
Notifications.cancelAllLocalNotifications();
}
@@ -75,34 +84,54 @@ class PushNotifications {
}
};
clearChannelNotifications = async (channelId: string) => {
if (Platform.OS === 'android') {
const notifications = await AndroidNotificationPreferences.getDeliveredNotifications();
const notificationForChannel = notifications.find((n: NotificationWithChannel) => n.channel_id === channelId);
if (notificationForChannel) {
AndroidNotificationPreferences.removeDeliveredNotifications(channelId);
}
} else {
const ids: string[] = [];
const notifications = await Notifications.ios.getDeliveredNotifications();
clearChannelNotifications = async (channelId: string, rootId?: string) => {
const notifications = await this.getNotifications();
//set the badge count to the total amount of notifications present in the not-center
let badgeCount = notifications.length;
let collapsedThreadsEnabled = false;
if (Store.redux) {
collapsedThreadsEnabled = isCollapsedThreadsEnabled(Store.redux.getState());
}
for (let i = 0; i < notifications.length; i++) {
const notification = notifications[i] as NotificationWithChannel;
if (notification.channel_id === channelId) {
ids.push(notification.identifier);
badgeCount--;
const clearThreads = Boolean(rootId);
const notificationIds: string[] = [];
for (let i = 0; i < notifications.length; i++) {
const notification = notifications[i];
if (notification.channel_id === channelId) {
let doesNotificationMatch = true;
if (clearThreads) {
doesNotificationMatch = notification.root_id === rootId;
} else if (collapsedThreadsEnabled) {
// Do not match when CRT is enabled BUT post is not a root post
doesNotificationMatch = !notification.root_id;
}
if (doesNotificationMatch) {
notificationIds.push(notification.identifier || notification.post_id);
// For Android, We just need one matching notification to clear the notifications
if (Platform.OS === 'android') {
break;
}
}
}
}
if (ids.length) {
Notifications.ios.removeDeliveredNotifications(ids);
}
if (Platform.OS === 'ios') {
//set the badge count to the total amount of notifications present in the not-center
const badgeCount = notifications.length - notificationIds.length;
this.setBadgeCountByMentions(badgeCount);
}
if (!notificationIds.length) {
return;
}
if (Platform.OS === 'android') {
AndroidNotificationPreferences.removeDeliveredNotifications(channelId, rootId, collapsedThreadsEnabled);
} else {
Notifications.ios.removeDeliveredNotifications(notificationIds);
}
}
setBadgeCountByMentions = (initialBadge = 0) => {

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

@@ -15,14 +15,14 @@ export function getConfig(state: GlobalState): Partial<Config> {
* Safely get value of a specific or known FeatureFlag
*/
export function getFeatureFlagValue(state: GlobalState, key: keyof FeatureFlags): string | undefined {
return getConfig(state)?.[`FeatureFlag${key}` as keyof Partial<Config>];
return getConfig(state)?.[`FeatureFlag${key}` as unknown as keyof Partial<Config>];
}
export function getLicense(state: GlobalState): any {
return state.entities.general.license;
}
export function getSupportedTimezones(state: GlobalState): Array<string> {
export function getSupportedTimezones(state: GlobalState): string[] {
return state.entities.general.timezones;
}

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

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