forked from Ivasoft/mattermost-mobile
Compare commits
19 Commits
release-1.
...
v1.48.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
63ce6c7afb | ||
|
|
7bea097c90 | ||
|
|
0fe72d1c10 | ||
|
|
d3211b66a3 | ||
|
|
39bdb69fdd | ||
|
|
8222390c98 | ||
|
|
456208d223 | ||
|
|
2e71fcc226 | ||
|
|
04d7834024 | ||
|
|
7888b971e1 | ||
|
|
49462bd4a0 | ||
|
|
eb8579e84c | ||
|
|
41caf0d865 | ||
|
|
6a91b6544d | ||
|
|
75628cdf2e | ||
|
|
c8ee3bc722 | ||
|
|
3011992995 | ||
|
|
e23960f27d | ||
|
|
84a292003e |
35
NOTICE.txt
35
NOTICE.txt
@@ -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.
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
|
||||
Binary file not shown.
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`FormattedRelativeTime should match snapshot 1`] = `
|
||||
<Text>
|
||||
a few seconds ago
|
||||
</Text>
|
||||
`;
|
||||
@@ -66,7 +66,7 @@ const GroupMentionItem = (props) => {
|
||||
>
|
||||
<View style={style.rowPicture}>
|
||||
<CompassIcon
|
||||
name='account-group-outline'
|
||||
name='account-multiple-outline'
|
||||
style={style.rowIcon}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
62
app/components/formatted_relative_time.test.js
Normal file
62
app/components/formatted_relative_time.test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
44
app/components/formatted_relative_time.tsx
Normal file
44
app/components/formatted_relative_time.tsx
Normal 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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -18,7 +18,7 @@ exports[`MoreMessagesButton should match snapshot 1`] = `
|
||||
Object {
|
||||
"transform": Array [
|
||||
Object {
|
||||
"translateY": -438,
|
||||
"translateY": -550,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -200,4 +200,6 @@ const DocumentFile = forwardRef<DocumentFileRef, DocumentFileProps>(({background
|
||||
);
|
||||
});
|
||||
|
||||
DocumentFile.displayName = 'DocumentFile';
|
||||
|
||||
export default injectIntl(DocumentFile, {withRef: true});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
35
app/products/calls/client/rest.ts
Normal file
35
app/products/calls/client/rest.ts
Normal 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;
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -0,0 +1,9 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`CallDuration should match snapshot 1`] = `
|
||||
<Text
|
||||
style={Object {}}
|
||||
>
|
||||
00:15
|
||||
</Text>
|
||||
`;
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -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>
|
||||
`;
|
||||
36
app/products/calls/components/call_avatar.test.js
Normal file
36
app/products/calls/components/call_avatar.test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
82
app/products/calls/components/call_avatar.tsx
Normal file
82
app/products/calls/components/call_avatar.tsx
Normal 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;
|
||||
63
app/products/calls/components/call_duration.test.js
Normal file
63
app/products/calls/components/call_duration.test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
52
app/products/calls/components/call_duration.tsx
Normal file
52
app/products/calls/components/call_duration.tsx
Normal 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;
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
204
app/products/calls/components/call_message/call_message.tsx
Normal file
204
app/products/calls/components/call_message/call_message.tsx
Normal 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);
|
||||
55
app/products/calls/components/call_message/index.ts
Normal file
55
app/products/calls/components/call_message/index.ts
Normal 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);
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
175
app/products/calls/components/current_call/current_call.tsx
Normal file
175
app/products/calls/components/current_call/current_call.tsx
Normal 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;
|
||||
42
app/products/calls/components/current_call/index.ts
Normal file
42
app/products/calls/components/current_call/index.ts
Normal 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);
|
||||
39
app/products/calls/components/enable_disable_calls.test.js
Normal file
39
app/products/calls/components/enable_disable_calls.test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
45
app/products/calls/components/enable_disable_calls.tsx
Normal file
45
app/products/calls/components/enable_disable_calls.tsx
Normal 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;
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
57
app/products/calls/components/floating_call_container.tsx
Normal file
57
app/products/calls/components/floating_call_container.tsx
Normal 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;
|
||||
@@ -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>
|
||||
`;
|
||||
37
app/products/calls/components/join_call/index.ts
Normal file
37
app/products/calls/components/join_call/index.ts
Normal 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);
|
||||
75
app/products/calls/components/join_call/join_call.test.js
Normal file
75
app/products/calls/components/join_call/join_call.test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
132
app/products/calls/components/join_call/join_call.tsx
Normal file
132
app/products/calls/components/join_call/join_call.tsx
Normal 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);
|
||||
29
app/products/calls/components/leave_and_join_alert.tsx
Normal file
29
app/products/calls/components/leave_and_join_alert.tsx
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
`;
|
||||
34
app/products/calls/components/start_call/index.ts
Normal file
34
app/products/calls/components/start_call/index.ts
Normal 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);
|
||||
75
app/products/calls/components/start_call/start_call.test.js
Normal file
75
app/products/calls/components/start_call/start_call.test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
61
app/products/calls/components/start_call/start_call.tsx
Normal file
61
app/products/calls/components/start_call/start_call.tsx
Normal 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);
|
||||
153
app/products/calls/connection.ts
Normal file
153
app/products/calls/connection.ts
Normal 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
168
app/products/calls/screens/call/call_screen.test.js
Normal file
168
app/products/calls/screens/call/call_screen.test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
461
app/products/calls/screens/call/call_screen.tsx
Normal file
461
app/products/calls/screens/call/call_screen.tsx
Normal 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;
|
||||
38
app/products/calls/screens/call/index.ts
Normal file
38
app/products/calls/screens/call/index.ts
Normal 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);
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -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>
|
||||
`;
|
||||
42
app/products/calls/screens/call_other_actions/action.test.js
Normal file
42
app/products/calls/screens/call_other_actions/action.test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
129
app/products/calls/screens/call_other_actions/action.tsx
Normal file
129
app/products/calls/screens/call_other_actions/action.tsx
Normal 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;
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
18
app/products/calls/screens/call_other_actions/index.ts
Normal file
18
app/products/calls/screens/call_other_actions/index.ts
Normal 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);
|
||||
1001
app/products/calls/simple-peer.ts
Normal file
1001
app/products/calls/simple-peer.ts
Normal file
File diff suppressed because it is too large
Load Diff
25
app/products/calls/store/action_types/calls.ts
Normal file
25
app/products/calls/store/action_types/calls.ts
Normal 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,
|
||||
});
|
||||
144
app/products/calls/store/actions/calls.test.js
Normal file
144
app/products/calls/store/actions/calls.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
150
app/products/calls/store/actions/calls.ts
Normal file
150
app/products/calls/store/actions/calls.ts
Normal 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'};
|
||||
}
|
||||
83
app/products/calls/store/actions/websockets.ts
Normal file
83
app/products/calls/store/actions/websockets.ts
Normal 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},
|
||||
};
|
||||
}
|
||||
362
app/products/calls/store/reducers/calls.test.js
Normal file
362
app/products/calls/store/reducers/calls.test.js
Normal 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');
|
||||
});
|
||||
});
|
||||
210
app/products/calls/store/reducers/calls.ts
Normal file
210
app/products/calls/store/reducers/calls.ts
Normal 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,
|
||||
});
|
||||
80
app/products/calls/store/selectors/calls.test.js
Normal file
80
app/products/calls/store/selectors/calls.test.js
Normal 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');
|
||||
});
|
||||
});
|
||||
25
app/products/calls/store/selectors/calls.ts
Normal file
25
app/products/calls/store/selectors/calls.ts
Normal 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;
|
||||
}
|
||||
46
app/products/calls/store/types/calls.ts
Normal file
46
app/products/calls/store/types/calls.ts
Normal 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
Reference in New Issue
Block a user